SwiftUI NavigationLink loads destination view immediately, without clicking
Asked Answered
G

8

100

With following code:

struct HomeView: View {
    var body: some View {
        NavigationView {
            List(dataTypes) { dataType in
                NavigationLink(destination: AnotherView()) {
                    HomeViewRow(dataType: dataType)
                }
            }
        }
    }
}

What's weird, when HomeView appears, NavigationLink immediately loads the AnotherView. As a result, all AnotherView dependencies are loaded as well, even though it's not visible on the screen yet. The user has to click on the row to make it appear. My AnotherView contains a DataSource, where various things happen. The issue is that whole DataSource is loaded at this point, including some timers etc.

Am I doing something wrong..? How to handle it in such way, that AnotherView gets loaded once the user presses on that HomeViewRow?

Gautama answered 21/8, 2019 at 14:44 Comment(5)
objc.io/blog/2019/07/02/lazy-loadingHandedness
There is nothing wrong. Yes the View is init but its body func is not called and no state or state objects are init because that is all done just before body would be called.Notogaea
Like @Notogaea mentioned variables with State and StateObject wrappers will not be initialised. ObservedObject will be initialised though. Be sure to use the correct wrapper.Robles
@Handedness that LazyView is a bad idea, it needs to call build() in init rensbr.eu/blog/swiftui-escaping-closuresNotogaea
@Robles thats not true about State wrapper. I tested that Its initialized in Views initSarena
R
189

The best way I have found to combat this issue is by using a Lazy View.

struct NavigationLazyView<Content: View>: View {
    let build: () -> Content
    init(_ build: @autoclosure @escaping () -> Content) {
        self.build = build
    }
    var body: Content {
        build()
    }
}

Then the NavigationLink would look like this. You would place the View you want to be displayed inside ()

NavigationLink(destination: NavigationLazyView(DetailView(data: DataModel))) { Text("Item") }
Rabah answered 15/4, 2020 at 16:41 Comment(14)
This solved my issue of all nested view-models (and their repository's api calls) from getting initialized too early.Blacken
Thanks! I find it really weird this is not the default behavior of NavigationLink. It caused me so many problems!Androgyne
@Rabah not many times I want to kiss the person that answered this. Thanks! I don't quite understand why this is not the default behaviour however, and wondered if there will be a solution coming that will mean this unwrapping won't be necessary one day.Agar
Works very well! But on the Console I'm getting this error message: SwiftUI encountered an issue when pushing aNavigationLink. Please file a bug. Anyone else?Chops
@Chops I have not seen this messages. What version of Xcode are you using and can you provide more details?Rabah
Great solution! How would I wrap this as a LazyNavigationLink for convenience? E.g. just calling LazyNavigationLink(DetailView(data: ...Coiffure
This NON lazy loading of detail views just seems crazy. Very counter-intuitive.Profundity
I opened a radar for it. FB9448722Afterglow
I think the thing people don't realise is although the View is init, the body func isn't called and since any state or state objects are only created before body is called, nothing actually happens and there is no issue.Notogaea
NEVER EVER do this. This can break identity and make view updates very inefficient. Consult the documentation !Cockatoo
I used this solution and it worked like a charm, amazing!! Can anybody point me in some direction so I can understand what's going on here? Also, why is Ron so against it? It worked great for me, and solved a bunch of issuesHapten
@Ron: "Consult the documentation" is very vague and doesn't really help anyone who doesn't already know what to look for exactly.Berti
@Berti I agree I have been trying to locate the documentation Ron is referring to as I would love update my answer to reflect.Rabah
@Rabah here is an article on it rensbr.eu/blog/swiftui-escaping-closuresNotogaea
V
10

EDIT: See @MwcsMac's answer for a cleaner solution which wraps View creation inside a closure and only initializes it once the view is rendered.

It takes a custom ForEach to do what you are asking for since the function builder does have to evaluate the expression

NavigationLink(destination: AnotherView()) {
    HomeViewRow(dataType: dataType)
}

for each visible row to be able to show HomeViewRow(dataType:), in which case AnotherView() must be initialized too.

So to avoid this a custom ForEach is necessary.

import SwiftUI

struct LoadLaterView: View {
    var body: some View {
        HomeView()
    }
}

struct DataType: Identifiable {
    let id = UUID()
    var i: Int
}

struct ForEachLazyNavigationLink<Data: RandomAccessCollection, Content: View, Destination: View>: View where Data.Element: Identifiable {
    var data: Data
    var destination: (Data.Element) -> (Destination)
    var content: (Data.Element) -> (Content)
    
    @State var selected: Data.Element? = nil
    @State var active: Bool = false
    
    var body: some View {
        VStack{
            NavigationLink(destination: {
                VStack{
                    if self.selected != nil {
                        self.destination(self.selected!)
                    } else {
                        EmptyView()
                    }
                }
            }(), isActive: $active){
                Text("Hidden navigation link")
                    .background(Color.orange)
                    .hidden()
            }
            List{
                ForEach(data) { (element: Data.Element) in
                    Button(action: {
                        self.selected = element
                        self.active = true
                    }) { self.content(element) }
                }
            }
        }
    }
}

struct HomeView: View {
    @State var dataTypes: [DataType] = {
        return (0...99).map{
            return DataType(i: $0)
        }
    }()
    
    var body: some View {
        NavigationView{
            ForEachLazyNavigationLink(data: dataTypes, destination: {
                return AnotherView(i: $0.i)
            }, content: {
                return HomeViewRow(dataType: $0)
            })
        }
    }
}

struct HomeViewRow: View {
    var dataType: DataType
    
    var body: some View {
        Text("Home View \(dataType.i)")
    }
}

struct AnotherView: View {
    init(i: Int) {
        print("Init AnotherView \(i.description)")
        self.i = i
    }
    
    var i: Int
    var body: some View {
        print("Loading AnotherView \(i.description)")
        return Text("hello \(i.description)").onAppear {
            print("onAppear AnotherView \(self.i.description)")
        }
    }
}
Valvular answered 21/8, 2019 at 15:25 Comment(6)
You're testing when view loads, not when it inits. Please paste init(i: Int) { self.i = i print("test") } into your AnotherView, and you'll see it being called on HomeView list cell load.Gautama
Ah I see, this is usual done with .onAppear. I posted an example of how to initialize AnotherView lazily.Valvular
I'd say initializing it should happen once it is in fact opened. Look from the logic point of view: You have a table of 1000 places. On NavigationLink you link a view with map and video player. 18 items are immediately inited. Now it's pleasant to use nonoptional lets, so you do it. So you load all of the items there. Coordinator pattern also will work weird. It should work equally to UITableViewCell - it initializes the cell when it displays it, and does the action only once it's being pressed. Optimization.Gautama
I see what you mean. Note however, that this solution does neither reuse old views (to my knowledge) nor cares about creating a view multiple times, leading to constructing the view again on each switching to it.Valvular
you mean UITableViewCell? It is dequeueing them, so 100% reuse of the old cells.Gautama
I mean the custom solution here. I didn't realize you were answering until I deleted and rewrote the comment :-) No idea whether reconstruction costs matter, they are structs after all so not so much.Valvular
M
4

I had the same issue where I might have had a list of 50 items, that then loaded 50 views for the detail view that called an API (which resulted in 50 additional images being downloaded).

The answer for me was to use .onAppear to trigger all logic that needs to be executed when the view appears on screen (like setting off your timers).

struct AnotherView: View {
    var body: some View {
        VStack{
            Text("Hello World!")
        }.onAppear {
            print("I only printed when the view appeared")
            // trigger whatever you need to here instead of on init
        }
    }
}
Magistrate answered 21/8, 2019 at 23:59 Comment(2)
Ow that's horrible, that will make an impact on architecture. Hope they'll fix it before releasing.Gautama
Yeah, it's not nice from an optimisation point of view, but practically it works ok. I have my asynchronous load of data from an API endpoint happen onAppear which is how I would have done it if I tapped on a cell and loaded another view controller. I would load up the view then go load up all the relevant remote content. The only difference at this point is that it loads the scaffolding before a user has even tapped the cell. Wonder if this is an optimisation or unintended consequence they have yet to work through the final solution for.Magistrate
O
3

I was recently struggling with this issue (for a navigation row component for forms), and this did the trick for me:

@State private var shouldShowDestination = false

NavigationLink(destination: DestinationView(), isActive: $shouldShowDestination) {
    Button("More info") {
        self.shouldShowDestination = true
    }
}

Simply wrap a Button with the NavigationLink, which activation is to be controlled with the button.

Now, if you're to have multiple button+links within the same view, and not an activation State property for each, you should rely on this initializer

    /// Creates an instance that presents `destination` when `selection` is set
    /// to `tag`.
    public init<V>(destination: Destination, tag: V, selection: Binding<V?>, @ViewBuilder label: () -> Label) where V : Hashable

https://developer.apple.com/documentation/swiftui/navigationlink/3364637-init

Along the lines of this example:

struct ContentView: View {
    @State private var selection: String? = nil

    var body: some View {
        NavigationView {
            VStack {
                NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) {
                    Button("Tap to show second") {
                        self.selection = "Second"
                    }
                }
                NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) {
                    Button("Tap to show third") {
                        self.selection = "Third"
                    }
                }
            }
            .navigationBarTitle("Navigation")
        }
    }
}

More info (and the slightly modified example above) taken from https://www.hackingwithswift.com/articles/216/complete-guide-to-navigationview-in-swiftui (under "Programmatic navigation").

Alternatively, create a custom view component (with embedded NavigationLink), such as this one

struct FormNavigationRow<Destination: View>: View {

    let title: String
    let destination: Destination

    var body: some View {
        NavigationLink(destination: destination, isActive: $shouldShowDestination) {
            Button(title) {
                self.shouldShowDestination = true
            }
        }
    }

    // MARK: Private

    @State private var shouldShowDestination = false
}

and use it repeatedly as part of a Form (or List):

Form {
    FormNavigationRow(title: "One", destination: Text("1"))
    FormNavigationRow(title: "Two", destination: Text("2"))
    FormNavigationRow(title: "Three", destination: Text("3"))
}
Osi answered 13/6, 2020 at 16:8 Comment(0)
Z
3

For iOS 14 SwiftUI.

Non-elegant solution for lazy navigation destination loading, using view modifier, based on this post.

extension View {
    func navigate<Value, Destination: View>(
        item: Binding<Value?>,
        @ViewBuilder content: @escaping (Value) -> Destination
    ) -> some View {
        return self.modifier(Navigator(item: item, content: content))
    }
}

private struct Navigator<Value, Destination: View>: ViewModifier {
    let item: Binding<Value?>
    let content: (Value) -> Destination
    
    public func body(content: Content) -> some View {
        content
            .background(
                NavigationLink(
                    destination: { () -> AnyView in
                        if let value = self.item.wrappedValue {
                            return AnyView(self.content(value))
                        } else {
                            return AnyView(EmptyView())
                        }
                    }(),
                    isActive: Binding<Bool>(
                        get: { self.item.wrappedValue != nil },
                        set: { newValue in
                            if newValue == false {
                                self.item.wrappedValue = nil
                            }
                        }
                    ),
                    label: EmptyView.init
                )
            )
    }
}

Call it like this:

struct ExampleView: View {
    @State
    private var date: Date? = nil
    
    var body: some View {
        VStack {
            Text("Source view")
            Button("Send", action: {
                self.date = Date()
            })
        }
        .navigate(
            item: self.$date,
            content: {
                VStack {
                    Text("Destination view")
                    Text($0.debugDescription)
                }
            }
        )
    }
}
Zoarah answered 11/8, 2021 at 15:51 Comment(1)
thanks, the original post solution caused some weird issues with the navigation link transition, but your solution works perfectly.Coltish
R
1

In the destination view you should listen to the event onAppear and put there all code that needs to be executed only when the new screen appears. Like this:

struct DestinationView: View {
    var body: some View {
        Text("Hello world!")
        .onAppear {
            // Do something important here, like fetching data from REST API
            // This code will only be executed when the view appears
        }
    }
}
Reviere answered 13/12, 2020 at 18:52 Comment(1)
ViewDidLoad is called once, after the view is loaded. However viewDidAppear (similarly .onAppear in SwiftUI) is being called each time the view is presented. So in case of downloading, you often want to do it on viewDidLoad and not in onAppear. The later one can happen too often.Gautama
A
0

I created my own reusable LazyNavigationLink. In code simply replace NavigationLink by MyLazyNavigationLink

public struct MyLazyNavigationLink<Label: View, Destination: View>: View {
    var destination: () -> Destination
    var label: () -> Label

    public init(@ViewBuilder destination: @escaping () -> Destination,
                @ViewBuilder label: @escaping () -> Label) {
        self.destination = destination
        self.label = label
    }

    public var body: some View {
        NavigationLink {
            LazyView {
                destination()
            }
        } label: {
            label()
        }
    }

    private struct LazyView<Content: View>: View {
        var content: () -> Content
     
        var body: some View {
            content()
        }
    }
}
Abiding answered 28/4, 2023 at 7:17 Comment(0)
B
0

That is actually the expected behaviour. You should use your service decorated with a @StateObject inside AnotherView. The @StateObject assures you that the initialiser of your service will only be called when the service marked with @StateObject is attached at the runtime to the actual view that is displayed on the screen (not the view "description" which AnotherView struct actually is, and which can be recreated multiple times throughout the lifetime of the view on the screen).

Betweentimes answered 5/5, 2023 at 12:7 Comment(2)
So you're saying that this behaviour was expected in 2019 (so when iOS 13 was the newest), thanks to @StateObject, which was introduced in iOS 14, so a year later than I posted the question?Gautama
Well, someone reading this answer now can have a sense of the solution since using a lazy loading view(which is the accepted answer) is an incorect solution.Betweentimes

© 2022 - 2024 — McMap. All rights reserved.