SwiftUI Navigation: why is Timer.publish() in View body breaking nav stack

Viewed 303

SwiftUI n00b here. I'm trying some very simple navigation using NavigationView and NavigationLink. In the sample below, I've isolated to a 3 level nav. The 1st level is just a link to the 2nd, the 2nd to the 3rd, and the 3rd level is a text input box.

In the 2nd level view builder, I have a

private let timer = Timer.publish(every: 2, on: .main, in: .common)

and when I navigate to the 3rd level, as soon as I start typing into the text box, I get navigated back to the 2nd level.

Why?

A likely clue that I don't understand. The print(Self._printChanges()) in the 2nd level shows

NavLevel2: @self changed.

immediately when I start typing into the 3rd level text box.

When I remove this timer declaration, the problem goes away. Alternatively, when I modify the @EnvironmentObject I'm using in the 3rd level to just be @State, the problem goes away.

So trying to understand what's going on here, if this is a bug, and if it's not a bug, why does it behave this way.

Here's the full ContentView building code that repos this

import SwiftUI

class AuthDataModel: ObservableObject {
    @Published var someValue: String = ""
}

struct NavLevel3: View {
    @EnvironmentObject var model: AuthDataModel

    var body: some View {
        print(Self._printChanges())
        return TextField("Level 3: Type Something", text: $model.someValue)
        
        // Replacing above with this fixes everything, even when the
        // below timer is still in place.
        // (put this decl instead of @EnvironmentObject above
        //     @State var fff: String = ""
        // )
        // return TextField("Level 3: Type Something", text: $fff)
    }
}

struct NavLevel2: View {
    
    // LOOK HERE!!!!  Removing this declaration fixes everything.
    private let timer = Timer.publish(every: 2, on: .main, in: .common)

    var body: some View {
        print(Self._printChanges())
        return NavigationLink(
                destination: NavLevel3()
            ) { Text("Level 2") }
    }
}

struct ContentView: View {
    @StateObject private var model = AuthDataModel()

    var body: some View {
        print(Self._printChanges())
        return NavigationView {
            NavigationLink(destination: NavLevel2())
            {
                Text("Level 1")
            }
        }
        .environmentObject(model)
    }
}
2 Answers

First, if you remove @StateObject from model declaration in ContentView, it will work.

You should not set the whole model as a State for the root view.

If you do, on each change of any published property, your whole hierarchy will be reconstructed. You will agree that if you type changes in the text field, you don't want the complete UI to rebuild at each letter.

Now, about the behaviour you describe, that's weird. Given what's said above, it looks like when you type, the whole view is reconstructed, as expected since your model is a @State object, but reconstruction is broken by this unmanaged timer.. I have no real clue to explain it, but I have a rule to avoid it ;)

Rule:

You should not make timers in view builders. Remember swiftUI views are builders and not 'views' as we used to represent before. The concrete view object is returned by the 'body' function.

If you put a break on timer creation, you will notice your timer is called as soon as the root view is displayed. ( from NavigationLink(destination: NavLevel2())

That's probably not what you expect.

If you move your timer creation in the body, it will work, because the timer is then created when the view is created.

    var body: some View {
        var timer = Timer.publish(every: 2, on: .main, in: .common)
        print(Self._printChanges())
        return NavigationLink(
                destination: NavLevel3()
            ) { Text("Level 2") }
    }

However, it is usually not the right way neither.

You should create the timer:

  • in the .appear handler, keep the reference, and cancel the timer in .disappear handler.

  • in a .task handler that is reserved for asynchronous tasks.

I personally only declare wrapped values ( @State, @Binding, .. ) in view builders structs, or very simple primitives variables ( Bool, Int, .. ) that I use as conditions when building the view.

I keep all functional stuffs in the body or in handlers.

To stop going back to the previous view when you type in the TextField add .navigationViewStyle(.stack) to the NavigationView in ContentView.

Here is the code I used to test my answer:

import SwiftUI

@main
struct TestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @StateObject var model = AuthDataModel()
    
    var body: some View {
        NavigationView {
            NavigationLink(destination: NavLevel2()){
                Text("Level 1")
            }
        }.navigationViewStyle(.stack)  // <--- here the important bit
        .environmentObject(model)
    }
}

class AuthDataModel: ObservableObject {
    @Published var someValue: String = ""
}

struct NavLevel3: View {
    @EnvironmentObject var model: AuthDataModel
    
    var body: some View {
        TextField("Level 3: Type Something", text: $model.someValue)
    }
}

struct NavLevel2: View {
    @EnvironmentObject var model: AuthDataModel
    @State var tickCount: Int = 0  // <-- for testing
    
    private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
    
    var body: some View {
        NavigationLink(destination: NavLevel3()) {
            Text("Level 2 tick: \(tickCount)")
        }
        .onReceive(timer) { val in  // <-- for testing
            tickCount += 1
        }
    }
}
Related