I’m writing a workout app for Apple Watch but am running into issues with navigation. I have a version which includes the behaviour I’m looking for but am trying to simplify the navigation through the app which is leading to issues.
The “working” version of the app (code below) has the user select a workout from a launch page. When the workout is started, a tabbed view is displayed which contains controls on one page and metrics on another, cut down here for the purpose of demonstration. When End on the controls page is pressed, a boolean is set to true forcing a summary view to be displayed. When Done is pressed here, the view is dismissed returning the user to the launch page. A new workout can then be initiated.
As mentioned above, I’m trying to simplify the app, doing away with the workout selection and immediately leading to a start page. From there the functionality is basically the same. However, when I return to the start page, I am unable to initiate another workout without re-launching the app.
I’m just starting out on my SwiftUI journey so can only presume I’m misusing the NavigationStack or misunderstanding the concepts behind it but have not been able to see the issue.
Any assistance would be greatly appreciated.
Thanks much.
struct ContentView: View {
#StateObject private var workoutManager = WorkoutManager()
var body: some View {
NavigationStack {
List(Workout.workouts) { workout in
NavigationLink(value: workout) {
Text(workout.shortName)
}
}
.navigationDestination(for: Workout.self) { workout in
WorkoutSetupView()
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
.environmentObject(workoutManager)
}
}
struct Workout: Identifiable, Hashable {
var id: String
var shortName: String
static var workouts: [Workout] {
[
Workout(id: "WORKOUT1", shortName: "Workout 1"),
Workout(id: "WORKOUT2", shortName: "Workout 2")
]
}
}
class WorkoutManager: NSObject, ObservableObject {
#Published var showingSummaryView: Bool = false
func endWorkout() {
showingSummaryView = true
}
}
struct SummaryView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack{
Text("Results: 2")
Button("Done") {
dismiss()
}
}
}
.navigationTitle("Summary")
.navigationBarBackButtonHidden(true)
}
}
struct WorkoutSetupView: View {
var body: some View {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
}
}
struct SessionPagingView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#State private var selection: Tab = .session
enum Tab {
case controls, session
}
var body: some View {
TabView(selection: $selection) {
Button {
workoutManager.endWorkout()
} label: {
Text("End")
}.tag(Tab.controls)
Text("0:10").tag(Tab.session)
}
.navigationBarBackButtonHidden(true)
}
}
If I replace the NavigationStack block in ContentView with the code below, the app works in a simplified way as expected but a new workout cannot be initiated without relaunching the app.
NavigationStack {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
I've tried resetting showingSummaryView to false on pressing Done in SummaryView and also tried using a NavigationPath but neither had any noticeable effect.
Thanks
Related
I have an app which presents a sheet. It works for the first time but when I click on it again it does not work. I am making isPresented false when you dismiss a sheet but when I tap on the Filter button again, it does not show the sheet.
ContentView
struct ContentView: View {
#State private var isPresented: Bool = false
var body: some View {
NavigationView {
List(1...20, id: \.self) { index in
Text("\(index)")
}.listStyle(.plain)
.navigationTitle("Hotels")
.toolbar {
Button("Filters") {
isPresented = true
}
}
.sheet(isPresented: $isPresented) {
isPresented = false
} content: {
FilterView()
}
}
}
}
FilterView:
import SwiftUI
struct FilterView: View {
#Environment(\.presentationMode) private var presentationMode
var body: some View {
ZStack {
Text("FilterView")
Button {
// action
presentationMode.wrappedValue.dismiss()
} label: {
Text("Dismiss")
}
}
}
}
struct FilterView_Previews: PreviewProvider {
static var previews: some View {
FilterView()
}
}
A couple of things to note from my experience.
Firstly, when using the isPresented binding to show a sheet, you don't need to reset the bound value in a custom onDismiss handler to reset it to false - that's handled for you internally by SwiftUI as part of the dismiss action.
So your modifier can be simplified a little:
.sheet(isPresented: $isPresented) {
FilterView()
}
Secondly, when running an app in the Simulator I've noticed that when you come back to the main view after dismissing a sheet you have to interact with the app somehow before clicking on the toolbar button, or the action won't trigger.
In cases like this, just scrolling the list up or down a little would be enough, and then the toolbar button works as you'd expect.
I've not encountered the same thing while running apps on a physical device – whether that's because the bug isn't present, or just that it's a lot easier to interact with the app in some microscopic form of gesture, I couldn't say.
I'm having trouble with what I think may be a bug, but most likely me doing something wrong.
I have a slightly complex navigation state variable in my model that I'm using for tracking/setting state between tab and sidebar presentations when multitasking on iPad. That all works fine except in tab mode, once I use a navigation link once I can't seem to use one again, whether the binding is on my tab view or navigation links in a list.
Would really appreciate any thoughts on this,
Cheers!
Example
NavigationItem.swift
enum SubNavigationItem: Hashable {
case overview, user, hobby
}
enum NavigationItem: Hashable {
case home(SubNavigationItem)
case settings
}
Model.swift
final class Model: ObservableObject {
#Published var selectedTab: NavigationItem = .home(.overview)
}
SwiftUIApp.swift
#main
struct SwiftUIApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
ContentView.swift
struct ContentView: View {
var body: some View {
AppTabNavigation()
}
}
AppTabNavigation.swift
struct AppTabNavigation: View {
#EnvironmentObject private var model: Model
var body: some View {
TabView(selection: $model.selectedTab) {
NavigationView {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(NavigationItem.home(.overview))
NavigationView {
Text("Settings View")
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(NavigationItem.settings)
}
}
}
HomeView.swift
I created a binding here because selection required an optional <NavigationItem?> not
struct HomeView: View {
#EnvironmentObject private var model: Model
var body: some View {
let binding = Binding<NavigationItem?>(
get: {
model.selectedTab
},
set: {
guard let item = $0 else { return }
model.selectedTab = item
}
)
List {
NavigationLink(
destination: Text("Users"),
tag: .home(.user),
selection: binding
) {
Text("Users")
}
NavigationLink(
destination: Text("Hobbies"),
tag: .home(.hobby),
selection: binding
) {
Text("Hobbies")
}
}
.navigationTitle("Home")
}
}
Second Attempt
I tried making the selectedTab property optional as #Lorem Ipsum suggested. Which means I can remove the binding there. But then the TabView doesn't work with the property. So I create a binding for that and have the same issue but with the tab bar!
Make the selected tab optional
#Published var selectedTab: NavigationItem? = .home(.overview)
And get rid of that makeshift binding variable. Just use the variable
$model.selectedTab
If the variable can never be nil then something is always selected IAW with that makeshift variable it will just keep the last value.
I get the following error: Unable to present view. Please file a bug whenever I make an asynchronous call on a view and leave the view (e.g. navigate to another view in the navigation stack) before it can make changes to the ui. Consequently, the next view in the navigation stack is unable to update its view. How can I fix this problem?
An example of the problem occurring is when I switch from view1 to view2 before my GetIoTThingIndex() call finishes and makes an update to the ui.
GetIoTThingIndex.query(device) { error in
DispatchQueue.main.async { [self] in
...
}
}
EDIT:
After doing more investigating, I found that this problem is due to the fact that I am implementing my logic in an MVVM pattern. When I moved my logic directly into the the view and called the functions and state variables inside the view, everything worked fine. It's interesting because when I started building my app with just a few pages with minimal logic and dependencies, this MVVM pattern worked fine without any bugs. However, when my project grew to 20+ pages with more logic and dependencies, the MVVM pattern causes this bug. Is this just a problem I see or has anyone seen anything like this before and have any recommendations for fixing it?
This is the way I had things with MVVM.
View
struct DeviceView: View {
#ObservedObject var viewModel = DeviceViewModel()
var body: some View {
Text(viewModel.name)
...
}
}
View Model
class DeviceViewModel: ObservableObject {
#Published var name = ""
public func updateUI() {
...
}
...
}
This is the way I have things now (which works without this bug).
View
struct DeviceView: View {
var body: some View {
Text(name)
...
}
#State var name = ""
public func updateUI() {
...
}
...
}
Are you sure this is what is happening?
I've tested the idea of navigating to another view
before the parent can make a change to its view. And all works well.
This is the code I used for the test, click on the button first, then within 3 sec click on the NavigationLink.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var thingToUpdate = ""
var body: some View {
NavigationView {
VStack (spacing: 40) {
Text("text \(thingToUpdate)")
Button("click me first") {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
thingToUpdate = " is updated now"
}
}
NavigationLink(destination: Text("the detail view")) {
Text("then to DetailView")
}
}
}
}
}
Edit update using ObservableObject that works for me:
class DeviceViewModel: ObservableObject {
#Published var name = "no name"
public func updateUI() {
// simulated delay on the main thread
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.name = "success"
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = DeviceViewModel()
var body: some View {
NavigationView {
VStack (spacing: 40) {
Text("viewModel name is \(viewModel.name)")
Button("click me first") {
viewModel.updateUI()
}
NavigationLink(destination: Text("DetailView")) {
Text("then to DetailView")
}
}
}
}
}
My app has 4 views (let's call them View_A[root] -> View_B -> View_C -> View_D). The navigation between them was made using NavigationView/NavigationLink.
When I call self.presentationMode.wrappedValue.dismiss() from the last view(View_D) I expect it to dismiss the current view (D) only, but for some reason it dismissed ALL the views and stops at view A (root view).
That's weird.
I spent a couple of hours trying to figure out what's going on there and I found that
- if I remove "ForEach" from "View_A" it works correctly and only the last view is dismissed. Even though ForEach gets just 1 static object in this example.
The second weird thing is that
- if I don't change "self.thisSession.stats" to false it also works correctly dismissing only the last view.
This is super weird as View_A (as far as I understand) is not dependent on thisSession environment variable.
Any ideas on how to prevent View_C and View_B from being dismissed in this case? I wanna end up at View_C after clicking the link, not at View_A.
Any help is appreciated, it took me a while to find out where it comes from but I'm not smart enough to proceed any further ;)
import SwiftUI
struct A_View: View {
#EnvironmentObject var thisSession: CurrentSession
var body: some View {
NavigationView {
VStack {
Text("View A")
ForEach([TestObject()], id: \.id) { _ in
NavigationLink(destination: View_B() ) {
Text("Move to View B")
}
}
}
}
}
}
struct View_B: View {
var body: some View {
NavigationView {
NavigationLink(destination: View_C()
) {
Text("GO TO VIEW C")
}
}
}
}
struct View_C: View {
var body: some View {
ZStack {
NavigationView {
NavigationLink(destination: View_D()) {
Text("GO TO VIEW D")
}
}
}
}
}
struct View_D: View {
#EnvironmentObject var thisSession: CurrentSession
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
ZStack {
VStack {
Button(action: {
self.thisSession.stats = false
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Return!")
}
}
}
}
}
class CurrentSession: ObservableObject {
#Published var stats: Bool = false
#Published var user: String = "user"
}
struct TestObject: Identifiable, Codable {
let id = UUID()
}
Your issue is with:
NavigationView
There is only supposed to be one NavigationView in an entire view stack. Try removing the NavigationView from views B and C
I've presented a modal view but I would like the user to go through some steps before it can be dismissed.
Currently the view can be dragged to dismiss.
Is there a way to stop this from being possible?
I've watched the WWDC Session videos and they mention it but I can't seem to put my finger on the exact code I'd need.
struct OnboardingView2 : View {
#Binding
var dismissFlag: Bool
var body: some View {
VStack {
Text("Onboarding here! 🙌🏼")
Button(action: {
self.dismissFlag.toggle()
}) {
Text("Dismiss")
}
}
}
}
I currently have some text and a button I'm going to use at a later date to dismiss the view.
iOS 15+
Starting from iOS 15 we can use interactiveDismissDisabled:
func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View
We just need to attach it to the sheet. Here is an example from the documentation:
struct PresentingView: View {
#Binding var showTerms: Bool
var body: some View {
AppContents()
.sheet(isPresented: $showTerms) {
Sheet()
}
}
}
struct Sheet: View {
#State private var acceptedTerms = false
var body: some View {
Form {
Button("Accept Terms") {
acceptedTerms = true
}
}
.interactiveDismissDisabled(!acceptedTerms)
}
}
It is easy if you use the 3rd party lib Introspect, which is very useful as it access the corresponding UIKit component easily. In this case, the property in UIViewController:
VStack { ... }
.introspectViewController {
$0.isModalInPresentation = true
}
Not sure this helps or even the method to show the modal you are using but when you present a SwiftUI view from a UIViewController using UIHostingController
let vc = UIHostingController(rootView: <#your swiftUI view#>(<#your parameters #>))
you can set a modalPresentationStyle. You may have to decide which of the styles suits your needs but .currentContext prevents the dragging to dismiss.
Side note:I don't know how to dismiss a view presented from a UIHostingController though which is why I've asked a Q myself on here to find out 😂
I had a similar question here
struct Start : View {
let destinationView = SetUp()
.navigationBarItem(title: Text("Set Up View"), titleDisplayMode: .automatic, hidesBackButton: true)
var body: some View {
NavigationView {
NavigationButton(destination: destinationView) {
Text("Set Up")
}
}
}
}
The main thing here is that it is hiding the back button. This turns off the back button and makes it so the user can't swipe back ether.
For the setup portion of your app you could create a new SwiftUI file and add a similar thing to get home, while also incorporating your own setup code.
struct SetUp : View {
let destinationView = Text("Your App Here")
.navigationBarItem(title: Text("Your all set up!"), titleDisplayMode: .automatic, hidesBackButton: true)
var body: some View {
NavigationView {
NavigationButton(destination: destinationView) {
Text("Done")
}
}
}
}
There is an extension to make controlling the modal dismission effortless, at https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0
A temporary solution before the official solution released by Apple.
/// Example:
struct ContentView: View {
#State private var presenting = false
var body: some View {
VStack {
Button {
presenting = true
} label: {
Text("Present")
}
}
.sheet(isPresented: $presenting) {
ModalContent()
.allowAutoDismiss { false }
// or
// .allowAutoDismiss(false)
}
}
}