Additional Appearance Calls Triggered When Removing TabView or NavigationView/Stack From View Tree

Appearance Issues

I've tried looking through the developer forums and could not find a post that exactly described my situation. I've found many posts that described unexpected onAppear or onDisappear calls, but none that touched on TabView or NavigationView/NavigationStack container views being removed from the view tree.

Versions tested against with the issue:

  • iOS 14.0 - iOS 16.1
  • Xcode 13.4.1 - Xcode 14.1

I just filed a feedback (FB11721387), but wanted to post here in case someone has a good workaround or solution.

Summary

When a TabView or NavigationView/NavigationStack is removed from the view tree, there can be unexpected additional calls to appearance modifiers like onAppear, task, and onDisappear for some of the views.

Example Series of Events:

// ... Assume Already Logged in to Tabbed App
// 1. Tab 2 - OnAppear
// 2. Tab 2 - Task
// 3. Tab 1 - OnDisappear // <- Tab we just left
// 4. Log Out Button Tapped
// 5. Login View - OnAppear
// 6. Login View - Task
// 7. Tab 1 - Task // <- Events 7-9 not expected
// 8. Tab 1 - OnAppear
// 9. Tab 1 - OnDisappear
// 10. Tab 2 - OnDisappear // <- This was expected

TabView Appearance Issues

For TabViews, you must select at least one other tab before changing state to another case that doesn't include the TabView. When removed from the view tree, all already-viewed, non-currently-viewed tab views will run through all appearance calls again, even when the state binding that controls the selected tab does not change.

Reproducible Steps

  1. Have an application where the root of the app has some conditional for logged-in and logged-out states, and the logged-in view has a TabView as the root with at least two tabs
  2. Run the app and "Log In"
  3. Select another non-root tab view
  4. Log out (bringing you to the Login View)
  5. Observe the already-viewed, non-currently-selected tab views run through their appearance calls again

NavigationView/NavigationStack Appearance Issues

For NavigationView and NavigationStack (potentially NavigationSplitView as well, but I didn't test this), you must push at least one other view onto the stack before changing state to another case that doesn't include the Navigation type. When removed from the view tree, the root view will run through all appearance calls again.

Reproducible Steps

  1. Have an application where the root of the app has some conditional for logged-in and logged-out states, and the logged-in view has a NavigationView/NavigationStack as the root and can push a view onto the stack to log out
  2. Run the app and "Log In"
  3. Push a view onto the stack
  4. Log out (bringing you to the Login View)
  5. Observe the Navigation Root view (Not the Navigation View itself) runs through the appearance calls again

Typical Example

Assume you have an app that has a Login view, and once logged in, you have a tabbed or some other typical navigational structure as the root of your authenticated app flow. Following any of the above situations will result in additional appearance callbacks for the affected views when logging out of the application.

Sample Project Pseudocode

struct ContentView: View {
	@StateObject
	private var viewModel = ViewModel()
	
	var body: some View {
		switch viewModel.state {
			case .loading:
				SplashView()
			case .loggedOut:
				LoginView()
			case .loggedIn:
				Authenticated() // Some TabView or other NavigationView/NavigationStack Root
		}
	}
}

Current Workaround

While not a great workaround that fills me with confidence, using OSSignposts to measure how quickly a view goes from onAppear to onDisappear has shown in my testing that when the bug occurs, it completes the lifecycle within ~15 ms with many completing as few as ~6 ms.

Using this and timing the quickest human interaction for switching tabs to be about 75ms in length, we've decided to implement a debounce plus filter behavior for an onAppear overload that will only perform the provided action if onDisappear isn't called before 30 ms has expired.

I understand this workaround is fragile, considering different devices and resource environments (performance throttling, low battery, etc) can play a factor.

Hoping for a more official answer or better ideas for workarounds. Thanks for any help that can be provided!

Ideally, TabView would initialize itself with the tab item value that it's bound to, but I don't think there's any such guarantee at the level of an API contract. That means that the trip to tab 2 via tab 1 may simply be a scenario that you have to deal with — and in that case, "debouncing" your associated behavior might be a reasonable choice. You'd probably also want to make the actions in .onAppear and .task be quickly cancelable. You could then cancel the .onAppear action in .onDisappear. (.task actions are automatically canceled when the view leaves the view hierarchy.)

However, I think there's a better solution, which is to always display the tabbed view when your app starts, regardless of the login status. In general, tabbed views are expected to be the permanent "root" of an app. See here:

https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars

for more detailed guidance.

However, I think there's a better solution, which is to always display the tabbed view when your app starts, regardless of the login status. In general, tabbed views are expected to be the permanent "root" of an app. 

I may need to experiment with this specifically for the Login View and the rest of the app.

Regarding the NavigationView/Stack having a similar issue, though, we have a flow that may be a bit wonky if we tried to treat it similarly. We have a modifier view responsible for gating functionality behind a condition.

To avoid the issue documented above for this, I see two approaches:

  1. Present a modal over whatever you want to be locked with an opportunity for the user to see the sensitive content.
  2. Require handling this by the referring View and ViewModel and only present the intended destination once the condition is met. (less easy to share handling in various parts of the application)

From a code structure, shared handling, and modeling perspective, it seems much easier to have a ViewModifier conditionally present the Navigation flow (which uses a Nav View) when needed and show the intended destination when authenticated, regardless of presentation context.

For this case, I don't see much of a workaround to guarantee unnecessary work isn't performed without more work on the referring views, potentially showing sensitive content before gating with a shared modifier, or using the debounce + cancellable behavior described above to work within the constraints of this bug.

I faced the same problem recently and my workaround is to create a view modifier for authenticated views and use it only for such screens/views:

import SwiftUI

struct GuardedOnAppearViewModifier: ViewModifier {
  let perform: () -> Void
  @EnvironmentObject private var accountState: AccountState
  
  func body(content: Content) -> some View {
    content
      .onAppear {
        guard accountState.isAuthorized else { return }
        perform()
      }
  }
}

extension View {
  func onGuardedAppear(perform: @escaping () -> Void) -> some View {
    modifier(GuardedOnAppearViewModifier(perform: perform))
  }
}

So when I'm logging out none of my onAppear method of authorized views will be called during the removal of NavigationView.

Facing same issue, when resetting the stack of NavigationStack, onAppear of the screen before root is called. I reset the stack during log out, so calling this screen during this is causing crashes.

Stack before reset

Root -> Home -> PIN -> Setting

Now reset stack from Settings screen

Expected,

NavigationStack just resets, onAppear of previous screens are not called.

What happens,

Home screen's onAppear is called during stack reset

Request apple team to provide me with some work around.

Thanks.

Additional Appearance Calls Triggered When Removing TabView or NavigationView/Stack From View Tree
 
 
Q