2

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)
    }
}
gds
  • 578
  • 1
  • 4
  • 7
  • I don't know why, but to "fix" your problem add `.navigationViewStyle(.stack)` to the `NavigationView` in `ContentView`. – workingdog support Ukraine Dec 12 '21 at 08:23
  • A `Timer` publisher without being ***connect***ed makes no sense. – vadian Dec 12 '21 at 08:24
  • @workingdog - awesome, yea that works. Would really like to understand why it doesn't work without this, though. But thanks -- definitely unblocks! @vadian - this snippet was a minimal version to demonstrate the problem. The longer version I minimized it from does connect the `TimerPublisher`. – gds Dec 12 '21 at 09:37
  • Your timer is not in the view body, as stated in the title of this question, but in the view builder. That's a very important difference. I have made a more detailed answer. – Moose Dec 12 '21 at 10:58

2 Answers2

0

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.

Moose
  • 2,607
  • 24
  • 23
0

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
        }
    }
}