I'm trying to migrate a coordinator pattern using UINavigationController into the new NavigationStack.
The navigation flow is quite complex, but I've made a simple project to simplify it to this:
NavigationStack
-> MainScreen
-> TabView
-> FirstTab
-> NavigationStack
-> FirstTabFirst
-> FirstTabSecond
-> SecondTab
-> SecondTabScreen
-> Second Top Screen
Althoug the first Screens in each NavigatorStack are actually the root view of the navigator, it shouldn't be a problem as I can't even get it to navigate to something.
All the navigators have their state defined by a #StateObject to allow for both programatic imperative navigation and NavigationLink, similar to what a Coordinator pattern provides.
Upon launching the app, it immediatelly throws in the #main line, with no further information about the call stack:
Thread 1: Fatal error: 'try!' expression unexpectedly raised an error: SwiftUI.AnyNavigationPath.Error.comparisonTypeMismatch
This is the code for the whole app:
import SwiftUI
protocol NavigationRoute: Hashable {
associatedtype V: View
#ViewBuilder
func view() -> V
}
enum Top: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: TopFirst()
case .second: TopSecond()
}
}
}
enum Tab: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: TabFirst()
case .second: TabSecond()
}
}
var label: String {
switch self {
case .first: return "First"
case .second: return "Second"
}
}
var systemImage: String {
switch self {
case .first: return "house"
case .second: return "person.fill"
}
}
#ViewBuilder
func tab() -> some View {
view()
.tabItem { Label(label, systemImage: systemImage) }
.tag(self)
}
}
enum FirstTab: NavigationRoute {
case first, second
#ViewBuilder
func view() -> some View {
switch self {
case .first: FirstTabFirst()
case .second: FirstTabSecond()
}
}
}
class StackNavigator<Route: NavigationRoute>: ObservableObject {
#Published var routes: [Route] = []
}
class TabNavigator<Route: NavigationRoute>: ObservableObject {
#Published public var tab: Route
public init(initial: Route) {
tab = initial
}
public func navigate(_ route: Route) {
tab = route
}
}
#main
struct NavigationStackTestApp: App {
#StateObject var navigator = StackNavigator<Top>()
var body: some Scene {
WindowGroup {
NavigationStack(path: $navigator.routes) {
TopFirst().navigationDestination(for: Top.self) {
$0.view()
}
}
}
}
}
struct TopFirst: View {
#StateObject var navigator = TabNavigator<Tab>(initial: .first)
var body: some View {
TabView(selection: $navigator.tab) {
Tab.first.tab()
Tab.second.tab()
}
}
}
struct TopSecond: View {
var body: some View {
Text("Top Second")
}
}
struct TabFirst: View {
#StateObject var navigator = StackNavigator<FirstTab>()
var body: some View {
NavigationStack(path: $navigator.routes) {
FirstTabFirst().navigationDestination(for: FirstTab.self) {
$0.view()
}
}
}
}
struct TabSecond: View {
var body: some View {
Text("Tab Second")
}
}
struct FirstTabFirst: View {
var body: some View {
Text("First Tab First")
}
}
struct FirstTabSecond: View {
var body: some View {
Text("First Tab Second")
}
}
To avoid it from crashing I have to replace the NavigationStack of the first tab with an EmptyView(), that is changing:
case .first: TabFirst()
to
case .first: EmptyView()
But of course after doing that the first tab is missing and the app doesn't do what it's supposed to.
Has anyone encountered something like this or has an idea of how to get this working? SwiftUI is closed source and it's documentation is very limited so it's really hard to know what's really going on under the hood.
EDIT:
Using NavigationPath as state for StackNavigator doesn't crash the app, but any navigation in the first tab's NavigationStack pushes a new view in the top navigator and hides the bottom tab bar.
I'm assuming you just can't have two navigation stacks in the same hierarchy and expect it to work as you would assume.
Using two NavigationStacks in the same hierarchy doesn't work very well as their internal states collide, I would not recommend it and it seems to cause the error. The error is thrown when the lower navigator is declared because it's declared type is also handled by the top navigator, which doesn't have a handler declared for the lower navigator's tap in its navigationDestination.
It is better to use conditional rendering and manual animation/transitions in the upper level and then from there render only one NavigationStack per hierarchy. There is no issue using NavigationStack inside TabView as long as that TabView is not itself inside a NavigationStack.
NavigationPath can be used to support multiple unrelated types in the state stack.
Related
This question already has answers here:
What is the difference between #StateObject and #ObservedObject in child views in swiftUI
(3 answers)
Closed 3 months ago.
Here's a hypothetical master/detail pair of SwiftUI views that presents a button which uses NavigationLink:value:label: to navigate to a child view. The child view uses MVVM and has a .navigationTitle modifier that displays a placeholder until the real value is set (by a network operation that is omitted for the sake of brevity).
Upon first launch, tapping the button does navigate to the child view, but the "Loading child..." navigationTitle placeholder never changes to the actual value of "Alice" despite being set in the viewmodel's loadChild() method. If you navigate back and tap the button again, all subsequent navigations do set the navigationTitle correctly.
However, the child view has an if condition. If that if condition is replaced with Text("whatever") and the app is re-built and re-launched, the navigationTitle gets set properly every time. Why does the presence of an if condition inside the view affect the setting of the view's navigationTitle, and only on the first use of navigation?
import SwiftUI
// MARK: Data Structures
struct AppDestinationChild: Identifiable, Hashable {
var id: Int
}
struct Child: Identifiable, Hashable {
var id: Int
var name: String
}
// MARK: -
struct ChildView: View {
#ObservedObject var vm: ChildViewModel
init(id: Int) {
vm = ChildViewModel(id: id)
}
var body: some View {
VStack(alignment: .center) {
// Replacing this `if` condition with just some Text()
// view makes the navigationTitle *always* set properly,
// including during first use.
if vm.pets.count <= 0 {
Text("No pets")
} else {
Text("List of pets would go here")
}
}
.navigationTitle(vm.child?.name ?? "Loading child...")
.task {
vm.loadChild()
}
}
}
// MARK: -
extension ChildView {
#MainActor class ChildViewModel: ObservableObject {
#Published var id: Int
#Published var child: Child?
#Published var pets = [String]()
init(id: Int) {
self.id = id
}
func loadChild() {
// Some network operation would happen here to fetch child details by id
self.child = Child(id: id, name: "Alice")
}
}
}
// MARK: -
struct ContentView: View {
var body: some View {
NavigationStack {
NavigationLink(value: AppDestinationChild(id: 42), label: {
Text("Go to child view")
})
.navigationDestination(for: AppDestinationChild.self) { destination in
ChildView(id: destination.id)
}
}
}
}
The point of .task is to get rid of the need for a reference type for async code, I recommend you replace your state object with state, e.g.
#State var child: Child?
.task {
child = await Child.load()
}
You could also catch an exception and have another state for an error message.
Abstract
I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.
Code structure
Please note that this is a minimal reproducible example.
Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:
specifying object's name
summary screen
To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.
Assume that AddContentPresenterView requires the view model. No workarounds with #State will do - I desire to learn how to handle ObservableObject in this case.
Code
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
VStack {
NavigationStack(path: $model.path) {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
AddContentPresenterView(page: page)
}
}
Button {
model.navigateToNextPartOfContentCreation()
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
ContentDetailView (irrelevant)
struct ContentDetailView: View {
let content: Content
var body: some View {
Text(content.name)
}
}
AddContentPresenterView
As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.
extension AddContentPresenterView {
var contentName: some View {
TextField("Name your content", text: $addContentViewModel.contentName)
.onSubmit {
model.navigateToNextPartOfContentCreation()
}
}
var contentSummary: some View {
VStack {
Text(addContentViewModel.contentName)
Button {
model.addContent(addContentViewModel.createContent())
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
ContentViewViewModel
Controls the navigation and adding content.
class ContentViewViewModel: ObservableObject {
#Published var path = NavigationPath()
#Published var content: [Content] = []
func navigateToNextPartOfContentCreation() {
switch path.count {
case 0:
path.append(Page.contentName)
case 1:
path.append(Page.contentSummary)
default:
fatalError("Navigation error.")
}
}
func navigateToRoot() {
path.removeLast(path.count)
}
func addContent(_ content: Content) {
self.content.append(content)
}
}
AddContentViewModel
Manages content creation.
class AddContentViewModel: ObservableObject {
#Published var contentName = ""
func createContent() -> Content {
return Content(name: contentName)
}
}
Page
Enum containing creation screen pages.
enum Page: Hashable {
case contentName, contentSummary
}
What is wrong
Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.
I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.
What I've tried
The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.
Wraping up
How to keep SwiftUI from creating additional StateObjects in this custom page view?
Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?
Or maybe there is a better way of achieving what I've summarized in abstract?
If you declare #StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.
First, update your contentSummary of the Page enum with an associated value like this.
enum Page {
case contentName, contentSummary(String)
}
Now update your navigate to the next page method of your ContentViewModel like below.
func navigateToNextPage(_ page: Page) {
path.append(page)
}
Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
NavigationStack(path: $model.path) {
VStack {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
switch page {
case .contentName: AddContentView()
case .contentSummary(let name): ContentSummaryView(contentName: name)
}
}
Button {
model.navigateToNextPage(.contentName)
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
}
So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.
AddContentView
struct AddContentView: View {
#EnvironmentObject var model: ContentViewViewModel
#State private var contentName = ""
var body: some View {
TextField("Name your content", text: $contentName)
.onSubmit {
model.navigateToNextPage(.contentSummary(contentName))
}
}
}
ContentSummaryView
struct ContentSummaryView: View {
#EnvironmentObject var model: ContentViewViewModel
let contentName: String
var body: some View {
VStack {
Text(contentName)
Button {
model.addContent(Content(name: contentName))
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
So as you can see I have used #State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.
I'm using Parchment to add menu items at the top. The hierarchy of the main view is the following:
NavigationView
-> TabView
--> Parchment PagingView
---> NavigationLink(ChildView)
All works well going to the child view and then back again repeatedly. The issue happens when I go to ChildView, then go to the background/Home Screen then re-open. If I click back and then go to the child again the back button and the whole navigation bar disappears.
Here's code to replicate:
import SwiftUI
import Parchment
#main
struct ParchmentBugApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
TabView {
PagingView(items: [
PagingIndexItem(index: 0, title: "View 0"),
]) { item in
VStack {
NavigationLink(destination: ChildView()) {
Text("Go to child view")
}
}
.navigationBarHidden(true)
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Child View")
}
.navigationBarHidden(false)
.navigationBarTitle("Child View")
}
}
To replicate:
Launch and go to the child view
Click the home button to send the app to the background
Open the app again
Click on back
Navigate to the child view. The nav bar/back button are not there anymore.
What I noticed:
Removing the TabView makes the problem go away.
Removing PagingView also makes the problem go.
I tried to use a custom PagingController and played with various settings without success. Here's the custom PagingView if someone would like to tinker with the settings as well:
struct CustomPagingView<Item: PagingItem, Page: View>: View {
private let items: [Item]
private let options: PagingOptions
private let content: (Item) -> Page
/// Initialize a new `PageView`.
///
/// - Parameters:
/// - options: The configuration parameters we want to customize.
/// - items: The array of `PagingItem`s to display in the menu.
/// - content: A callback that returns the `View` for each item.
public init(options: PagingOptions = PagingOptions(),
items: [Item],
content: #escaping (Item) -> Page) {
self.options = options
self.items = items
self.content = content
}
public var body: some View {
PagingController(items: items, options: options,
content: content)
}
struct PagingController: UIViewControllerRepresentable {
let items: [Item]
let options: PagingOptions
let content: (Item) -> Page
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PagingController>) -> PagingViewController {
let pagingViewController = PagingViewController(options: options)
return pagingViewController
}
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
} else {
pagingViewController.reloadData()
}
}
}
class Coordinator: PagingViewControllerDataSource {
var parent: PagingController
init(_ pagingController: PagingController) {
self.parent = pagingController
}
func numberOfViewControllers(in pagingViewController: PagingViewController) -> Int {
return parent.items.count
}
func pagingViewController(_: PagingViewController, viewControllerAt index: Int) -> UIViewController {
let view = parent.content(parent.items[index])
return UIHostingController(rootView: view)
}
func pagingViewController(_: PagingViewController, pagingItemAt index: Int) -> PagingItem {
return parent.items[index]
}
}
}
Tested on iOS Simulator 14.4 & 14.5, and device 14.5 beta 2.
Any tips or ideas are very much appreciated.
Okay I found the issue while debugging something else that was related to Parchment as well.
The issue is updateUIViewController() gets called each time the encompassing SwiftUI state changes (and when coming back to the foreground), and the PageController wrapper provided by the library will call reloadData() since the data source data has already been set. So to resolve this just remove/comment out the reloadData() call since the PageController will be re-built if the relevant state changes. The same issue was the cause for the bug I was debugging.
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
}
//else {
// pagingViewController.reloadData()
//}
}
I am presenting a custom view when my viewModel changes to an error state. I want to animate this presentation. I represent the viewModel state using an enum
enum ViewModelState<T> {
case idle
case error(Error)
case loading
case data(T)
var isError: Bool {
switch self {
case .error:
return true
default:
return false
}
}
}
In my viewModel I have this property...
#Published var state: ViewModelState = .idle
When it changes to an error state I want to animate in my error view. When I do the following with an if statement in my view it animates in...
private var content: some View {
return ZStack {
mainView // VStack
if case let .error(error) = viewModel.state {
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}
}.animation(.default)
}
var body: some View {
content
.navigationBarTitle(viewModel.title)
.navigationBarBackButtonHidden(true)
}
However I want to switch on the error and do this
private var content: some View {
switch viewModel.state {
case .idle:
return mainView.eraseToAnyView()
case let .error(error):
return ZStack {
mainView
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}.animation(.default).eraseToAnyView()
default:
return EmptyView().eraseToAnyView()
}
}
I don't understand why this approach doesn't animate - is there a way to animate views without resorting to multiple properties in my view model to represent state/computed properties off my state enum or is this the best approach?
AnyView hides all implementation details from the compiler. That means that SiwftUI can't animate, because it tries to infer all animations during compilation.
That's why you should try to avoid AnyView whenever possible.
If you want to seperate some logic of your body into a computed property (or function), use the #ViewBuilder annotation.
#ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle:
mainView
case let .error(error):
ZStack {
mainView
ErrorView(
errorDescription: error.localizedDescription,
state: $viewModel.state
)
}.animation(.default)
default:
EmptyView()
}
}
Notice that I removed return in the property. The #ViewBuilder behaves much like the default body property by wrapping everything in a Group.
The second problem are the states between you animate.
By using the switch you are creating an SwiftUI._ConditionalContent<MainView,SwiftUI.ZStack<SwiftUI.TupleView<(MainView, ErrorView)>>
That is a little hard to parse, but you creating a ConditionalContent that switches between just the MainView and an ZStack consisting of your MainView and the ErrorView, which is not what we want.
We should try to create a ZStack with out MainView and an optional ErrorView.
What I like to to is to extend by view models with a computed error: Error? property.
extension ViewModel {
var error: Error? {
guard case let .error(error) = state else {
return nil
}
return error
}
}
Now, you can create an optional view, by using the map function of the Optional type
ZStack {
mainView
viewModel.error.map { unwrapped in
ErrorView(
errorDescription: unwrapped.localizedDescription,
state: $viewModel.state
)
}
}
That should work as expected.
I want to programmatically be able to navigate to a link within a List of NavigationLinks when the view appears (building deep linking from push notification). I have a string -> Bool dictionary which is bound to a custom Binding<Bool> inside my view. When the view appears, I set the bool property, navigation happens, however, it immediately pops back. I followed the answer in SwiftUI NavigationLink immediately navigates back and made sure that each item in the List has a unique identifier, but the issue still persists.
Two questions:
Is my binding logic here correct?
How come the view pops back immediately?
import SwiftUI
class ContentViewModel: ObservableObject {
#Published var isLinkActive:[String: Bool] = [:]
}
struct ContentViewTwo: View {
#ObservedObject var contentViewModel = ContentViewModel()
#State var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
.onAppear() {
print("Page 3 Appeared!")
}
}
func binding(chatId: String) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.contentViewModel.isLinkActive[chatId, default: false]
}) { (value) in
self.contentViewModel.isLinkActive[chatId] = value
}
}
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, isActive: self.binding(chatId: data)) {
Text("Page 3 Link with Data: \(data)")
}.onAppear() {
print("link appeared")
}
}.onAppear() {
print ("ContentViewTwo Appeared")
if (self.shouldPushPage3) {
self.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Page 2 Link")
}
}
}
}
}
The error is due to the lifecycle of the ViewModel, and is a limitation with SwiftUI NavigationLink itself at the moment, will have to wait to see if Apple updates the pending issues in the next release.
Update for SwiftUI 2.0:
Change:
#ObservedObject var contentViewModel = ContentViewModel()
to:
#StateObject var contentViewModel = ContentViewModel()
#StateObject means that changes in the state of the view model do not trigger a redraw of the whole body.
You also need to store the shouldPushPage3 variable outside the View as the view will get recreated every time you pop back to the root View.
enum DeepLinking {
static var shouldPushPage3 = true
}
And reference it as follows:
if (DeepLinking.shouldPushPage3) {
DeepLinking.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
The bug got fixed with the latest SwiftUI release. But to use this code at the moment, you will need to use the beta version of Xcode and iOS 14 - it will be live in a month or so with the next GM Xcode release.
I was coming up against this problem, with a standard (not using 'isActive') NavigationLink - for me the problem turned out to be the use of the view modifiers: .onAppear{code} and .onDisappear{code} in the destination view. I think it was causing a re-draw loop or something which caused the view to pop back to my list view (after approx 1 second).
I solved it by moving the modifiers onto a part of the destination view that's not affected by the code in those modifiers.