I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.
Below is a simplified example that mirrors the flow in my real app:
1️⃣ The user starts on the onboarding screen.
2️⃣ They proceed to the authorization screen.
3️⃣ After successful authentication, they transition to the main app flow.
Behavior Flow:
- Initially, there are two navigation stacks, and the user sees the first one.
✅ [NavigationStack1] → [View1]
[NavigationStack2] (inactive)
- A child view is pushed onto the first navigation stack.
✅ [NavigationStack1] → [View1] → [View2]
[NavigationStack2] (inactive)
- The user switches to the second navigation stack.
[NavigationStack1] → [View1] → [View2] (inactive)
✅ [NavigationStack2] → [View3]
- Observe the appear and disappear methods, along with the initialization of state variables inside View2.
- View 1 appears and disappears a second time unexpectedly
- View 2 ViewModel is initialized twice
- View 2 disappears and is deinitialized twice
Does anyone know why this happens and how to prevent it?
struct MainView: View {
enum Destination: Hashable {
case second
}
@State var isThirdPresented: Bool = false
@State var path: NavigationPath = .init()
var body: some View {
if isThirdPresented {
NavigationStack {
ContentView(identifier: "3️⃣") { }
}
} else {
NavigationStack(path: $path) {
ContentView(identifier: "1️⃣") {
path.append(Destination.second)
}
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .second:
ContentView(identifier: "2️⃣") {
isThirdPresented.toggle()
}
}
}
}
}
}
}
struct ContentView: View {
@Observable class ViewModel {
let identifier: String
init(identifier: String) {
self.identifier = identifier
print("\(identifier) view model initialized")
}
deinit {
print("\(identifier) view model deinitialized")
}
}
@State var viewModel: ViewModel
let action: () -> Void
init(identifier: String, action: @escaping () -> Void) {
viewModel = .init(identifier: identifier)
self.action = action
}
var body: some View {
VStack {
Text("View \(viewModel.identifier)")
Button("Action", action: action)
}
.onAppear { print("\(viewModel.identifier) appeared") }
.onDisappear { print("\(viewModel.identifier) disappeared") }
}
}
I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.
Below is a simplified example that mirrors the flow in my real app:
1️⃣ The user starts on the onboarding screen.
2️⃣ They proceed to the authorization screen.
3️⃣ After successful authentication, they transition to the main app flow.
Behavior Flow:
- Initially, there are two navigation stacks, and the user sees the first one.
✅ [NavigationStack1] → [View1]
[NavigationStack2] (inactive)
- A child view is pushed onto the first navigation stack.
✅ [NavigationStack1] → [View1] → [View2]
[NavigationStack2] (inactive)
- The user switches to the second navigation stack.
[NavigationStack1] → [View1] → [View2] (inactive)
✅ [NavigationStack2] → [View3]
- Observe the appear and disappear methods, along with the initialization of state variables inside View2.
- View 1 appears and disappears a second time unexpectedly
- View 2 ViewModel is initialized twice
- View 2 disappears and is deinitialized twice
Does anyone know why this happens and how to prevent it?
struct MainView: View {
enum Destination: Hashable {
case second
}
@State var isThirdPresented: Bool = false
@State var path: NavigationPath = .init()
var body: some View {
if isThirdPresented {
NavigationStack {
ContentView(identifier: "3️⃣") { }
}
} else {
NavigationStack(path: $path) {
ContentView(identifier: "1️⃣") {
path.append(Destination.second)
}
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .second:
ContentView(identifier: "2️⃣") {
isThirdPresented.toggle()
}
}
}
}
}
}
}
struct ContentView: View {
@Observable class ViewModel {
let identifier: String
init(identifier: String) {
self.identifier = identifier
print("\(identifier) view model initialized")
}
deinit {
print("\(identifier) view model deinitialized")
}
}
@State var viewModel: ViewModel
let action: () -> Void
init(identifier: String, action: @escaping () -> Void) {
viewModel = .init(identifier: identifier)
self.action = action
}
var body: some View {
VStack {
Text("View \(viewModel.identifier)")
Button("Action", action: action)
}
.onAppear { print("\(viewModel.identifier) appeared") }
.onDisappear { print("\(viewModel.identifier) disappeared") }
}
}
Share
Improve this question
edited Mar 7 at 10:53
Mark Osypenko
asked Mar 6 at 19:01
Mark OsypenkoMark Osypenko
861 silver badge4 bronze badges
6
|
Show 1 more comment
1 Answer
Reset to default 0I had never seen an observable class inside a View
until now, which is maybe why I find this entire setup very twisted.
If the "observable" ViewModel
is inside a View
struct, who/what is going to observe it?
Then, there's the issue of all the init
and deinit
and looping around to the "same" view passing different string "identifiers". As others mentioned comments, you're never actually coming back to the same view, since you're creating a new instance of ContentView
every time.
If you have an established flow, you should have models that support it.
For example, instead of string identifiers, you could have an Auhorization
enum for the various possible auth cases, and for each case an associated route. So if the user status is expired
, the logic for where to go/navigate next is defined therein:
enum Authorization: String {
case authorized, denied, locked, expired, guest
var route: DestinationRoute {
switch self {
case .authorized: .home
case .denied: .error
case .locked: .error
case .expired: .error
case .guest: .authorization
}
}
}
The same enum can be further enhanced with various options like a color for each case;
var color: Color {
switch self {
case .authorized: .green
case .denied: .red
case .locked: .pink
case .expired: .gray
case .guest: .orange
}
}
Instead of forcing the same ContentView
to support all these cases, it would be simpler and more flexible to have dedicated views. Maybe not for all cases, but for example, auth errors could go to an ErrorView
. Otherwise, your ContentView
will get messy quickly.
For observation, you can use singleton @Observable
classes that can be accessed from any view, or go with non-singletons that would have to be passed via environment
. In this example, I used singletons for simplicity.
//Authorization observable singleton
@Observable class UserAuthorization {
var status: Authorization = .guest
var navigateOnStatusChange = false
static let service = UserAuthorization() // <- the singleton
private init() {}
func setAuthStatus(_ status: Authorization) {
self.status = status
if navigateOnStatusChange {
Navigator.nav.path.append(self.status.route)
}
}
}
Like this, you can access or modify the user status from any view using UserAuthorization.service.status
. Or, assign it to a view property:
//Observables
let userAuth = UserAuthorization.service
Same idea for a navigation observable that makes the navigation path accessible and modifiable from any view, or from any function, including the UserAuthorization
class, which has an option for triggering navigation when the user status changes.
//Navigator observable singleton
@Observable class Navigator {
var path = NavigationPath()
static let nav = Navigator()
private init() {}
}
Here's the complete code that puts all this together:
import SwiftUI
enum DestinationRoute: Hashable, View {
case authorization, home, error
var body: some View {
switch self {
case .authorization:
AuthorizationView()
case .home:
MainAppFlowView()
case .error:
ErrorView()
}
}
}
enum Authorization: String {
case authorized, denied, locked, expired, guest
var color: Color {
switch self {
case .authorized: .green
case .denied: .red
case .locked: .pink
case .expired: .gray
case .guest: .orange
}
}
var icon: String {
switch self {
case .authorized: "lock.open"
case .denied: "lock"
case .locked: "lock"
case .expired: "lock.badge.clock"
case .guest: "person.fill.questionmark"
}
}
var route: DestinationRoute {
switch self {
case .authorized: .home
case .denied: .error
case .locked: .error
case .expired: .error
case .guest: .authorization
}
}
var description: String {
switch self {
case .authorized: "All good, you may proceed."
case .denied: "Your access has been denied"
case .locked: "Your account is locked."
case .expired: "Your access has expired."
case .guest: "You are not logged in."
}
}
}
struct OnboardingView: View {
//State values
@Bindable var navigator = Navigator.nav
//Observable binding
@Bindable var userAuth = UserAuthorization.service
//Body
var body: some View {
VStack {
NavigationStack(path: $navigator.path) {
VStack {
Label("Your authorization status is: \(userAuth.status)", systemImage: userAuth.status.icon)
.fontWeight(.bold)
.foregroundStyle(userAuth.status.color)
Text("Click below to continue.")
.foregroundStyle(.secondary)
.font(.subheadline)
Button {
navigator.path.append(userAuth.status.route)
} label: {
Text("Continue")
}
.buttonStyle(.borderedProminent)
.padding()
.tint(userAuth.status.color)
}
.navigationDestination(for: DestinationRoute.self) { destination in
destination
}
.navigationTitle("Welcome")
}
//Demo controls
VStack {
Text("Simulate authorization status:")
.font(.caption)
//Buttons
HStack {
Group {
Button {
userAuth.setAuthStatus(.guest)
} label: {
Text("Guest")
}
.tint(Authorization.guest.color)
Button {
userAuth.setAuthStatus(.authorized)
} label: {
Text("Authorized")
}
.tint(Authorization.authorized.color)
Button {
userAuth.setAuthStatus(.locked)
} label: {
Text("Locked")
}
.tint(Authorization.locked.color)
Button {
userAuth.setAuthStatus(.expired)
} label: {
Text("Expired")
}
.tint(Authorization.expired.color)
}
.buttonStyle(.bordered)
}
//Toggle
Toggle("Navigate on status change", isOn: $userAuth.navigateOnStatusChange)
.foregroundStyle(.secondary)
.scaleEffect(0.8)
.fixedSize()
}
.padding(.horizontal)
}
}
}
//Navigator observable singleton
@Observable class Navigator {
var path = NavigationPath()
static let nav = Navigator()
private init() {}
}
//Authorization observable singleton
@Observable class UserAuthorization {
var status: Authorization = .guest
var navigateOnStatusChange = false
static let service = UserAuthorization()
private init() {}
func setAuthStatus(_ status: Authorization) {
self.status = status
if navigateOnStatusChange {
Navigator.nav.path.append(self.status.route)
}
}
}
struct AuthorizationView: View {
//Observables
let userAuth = UserAuthorization.service
let navigator = Navigator.nav
//Body
var body: some View {
VStack {
Text("You must log in:")
Button {
userAuth.status = .authorized
navigator.path.append(userAuth.status.route)
} label: {
Text("Proceed with authorization")
}
.tint(Authorization.authorized.color)
Button {
userAuth.status = .expired
navigator.path.append(userAuth.status.route)
} label: {
Text("Proceed without authorization")
}
.tint(Authorization.expired.color)
}
.navigationTitle("Authorization")
.buttonStyle(.borderedProminent)
}
}
struct MainAppFlowView: View {
//Body
var body: some View {
VStack(spacing: 10) {
Image(systemName: "party.popper.fill")
.font(.system(size: 60))
.foregroundStyle(.pink)
Text("Congratulations!")
.fontWeight(.bold)
.font(.title)
Text("You made it to the main screen.")
}
.navigationTitle("Main")
}
}
struct ErrorView: View {
//Observables
let userAuth = UserAuthorization.service
//Body
var body: some View {
VStack {
ContentUnavailableView {
Label("\(userAuth.status.rawValue.capitalized)", systemImage: userAuth.status.icon)
} description: {
Text(userAuth.status.description)
} actions: {
//..
}
.foregroundStyle(userAuth.status.color)
}
}
}
//Preview
#Preview("Onboarding") {
OnboardingView()
}
#Preview("Main App") {
NavigationStack {
MainAppFlowView()
}
}
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1744955352a4603159.html
ContentView
you create a newViewModel
that has no relations to any others. In other words, it is completely useless to have thisViewModel
. Note also, typically@State private var ...
should be initialised ininit()
like this:self._viewModel = State(initialValue: ViewModel(identifier: identifier))
. – workingdog support Ukraine Commented Mar 6 at 23:38State
, yourContentView
init should be of the formself._viewModel = State(initialValue: ViewModel(identifier: identifier)
, notviewModel = .init(identifier: identifier)
which only initialises aViewModel
not aState
. In any case, it does not matter since initialising an@Observable class
ininit(...)
is not recommended at all, use.onAppear{...}
instead. – workingdog support Ukraine Commented Mar 7 at 11:34