92

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?

Nat
  • 12,032
  • 9
  • 56
  • 103
  • 10
    https://www.objc.io/blog/2019/07/02/lazy-loading/ – hstdt Jan 10 '20 at 07:46
  • 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. – malhal Dec 05 '21 at 15:59

8 Answers8

177

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") }
MwcsMac
  • 6,810
  • 5
  • 32
  • 52
  • 11
    This solved my issue of all nested view-models (and their repository's api calls) from getting initialized too early. – vikzilla Jun 22 '20 at 07:49
  • 2
    Thanks! I find it really weird this is not the default behavior of NavigationLink. It caused me so many problems! – Herr der Töne Sep 27 '20 at 16:56
  • 1
    @MwcsMac 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. – The Senator Oct 11 '20 at 15:08
  • 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? – Chris Nov 06 '20 at 20:56
  • @Chris I have not seen this messages. What version of Xcode are you using and can you provide more details? – MwcsMac Nov 12 '20 at 14:43
  • 4
    Great solution! How would I wrap this as a `LazyNavigationLink` for convenience? E.g. just calling `LazyNavigationLink(DetailView(data: ...` – Martin Jan 23 '21 at 01:10
  • 1
    This NON lazy loading of detail views just seems crazy. Very counter-intuitive. – Morgz Feb 16 '21 at 17:28
  • 1
    I opened a radar for it. FB9448722 – Pascale Beaulac Aug 03 '21 at 17:06
  • 1
    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. – malhal Dec 05 '21 at 15:57
  • 1
    NEVER EVER do this. This can break identity and make view updates very inefficient. Consult the documentation ! – Ron Mar 21 '22 at 20:04
  • 2
    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 issues – MoLoW Apr 26 '22 at 07:26
  • 5
    @Ron: "Consult the documentation" is very vague and doesn't really help anyone who doesn't already know what to look for exactly. – damd Apr 28 '22 at 17:06
  • 1
    @damd I agree I have been trying to locate the documentation Ron is referring to as I would love update my answer to reflect. – MwcsMac Apr 28 '22 at 19:51
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)")
        }
    }
}
Fabian
  • 5,040
  • 2
  • 23
  • 35
  • 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. – Nat Aug 22 '19 at 07:54
  • Ah I see, this is usual done with `.onAppear`. I posted an example of how to initialize `AnotherView` lazily. – Fabian Aug 22 '19 at 08:43
  • 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. – Nat Aug 22 '19 at 08:43
  • 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. – Fabian Aug 22 '19 at 08:47
  • you mean UITableViewCell? It is dequeueing them, so 100% reuse of the old cells. – Nat Aug 22 '19 at 08:48
  • 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. – Fabian Aug 22 '19 at 08:48
3

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
        }
    }
}
  • 1
    Ow that's horrible, that will make an impact on architecture. Hope they'll fix it before releasing. – Nat Aug 22 '19 at 07:46
  • 1
    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. – Ernest Cunningham Aug 27 '19 at 01:29
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"))
}
Xtian D.
  • 434
  • 5
  • 5
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)
                }
            }
        )
    }
}
bteapot
  • 1,897
  • 16
  • 24
  • thanks, the original post solution caused some weird issues with the navigation link transition, but your solution works perfectly. – bze12 Nov 21 '21 at 04:05
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
        }
    }
}
vegidio
  • 822
  • 2
  • 11
  • 32
  • 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. – Nat Dec 15 '20 at 09:44
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()
        }
    }
}
Leontien
  • 612
  • 5
  • 22
-1

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).

Petru Lutenco
  • 217
  • 3
  • 11
  • 1
    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? – Nat May 07 '23 at 08:31
  • 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. – Petru Lutenco May 08 '23 at 07:48