SwiftUI creates destination views before the user navigates to them
Asked Answered
J

2

8

I am having a hard time creating in SwiftUI a pretty common use case in UIKit.

Here is the scenario. Let's suppose we want to create a master/detail app in which the user can select an item from a list and navigate to a screen with more details.

To get out of the common List examples from Apple's tutorial and WWDC video, the app needs to fetch the data for each screen from a REST API.

The problem: the declarative syntax of SwiftUI leads to the creation of all the destination views as soon as the rows in the List appear.

Here is an example using the Stack Overflow API. The list in the first screen will show a list of questions. Selecting a row will lead to a second screen that shows the body of the selected question. The full Xcode project is on GitHub)

First of all, we need a structure representing a question.

struct Question: Decodable, Hashable {
    let questionId: Int
    let title: String
    let body: String?
}

struct Wrapper: Decodable {
    let items: [Question]
}

(The Wrapper structure is needed because the Stack Exchange API wraps results in a JSON object)

Then, we create a BindableObject for the first screen, which fetches the list of questions from the REST API.

class QuestionsData: BindableObject {
    let didChange = PassthroughSubject<QuestionsData, Never>()

    var questions: [Question] = [] {
        didSet { didChange.send(self) }
    }

    init() {
        let url = URL(string: "https://api.stackexchange.com/2.2/questions?site=stackoverflow")!
        let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main)
        session.dataTask(with: url) { [weak self] (data, response, error) in
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let wrapper = try! decoder.decode(Wrapper.self, from: data!)
            self?.questions = wrapper.items
        }.resume()
    }
}

Similarly, we create a second BindableObject for the detail screen, which fetches the body of the selected question (pardon the repetition of the networking code for the sake of simplicity).

class DetaildData: BindableObject {
    let didChange = PassthroughSubject<DetaildData, Never>()

    var question: Question {
        didSet { didChange.send(self) }
    }

    init(question: Question) {
        self.question = question
        let url = URL(string: "https://api.stackexchange.com/2.2/questions/\(question.questionId)?site=stackoverflow&filter=!9Z(-wwYGT")!
        let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main)
        session.dataTask(with: url) { [weak self] (data, response, error) in
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            let wrapper = try! decoder.decode(Wrapper.self, from: data!)
            self?.question = wrapper.items[0]
            }.resume()
    }
}

The two SwiftUI views are straightforward.

  • The first one contains a List inside of a NavigationView. Each row is contained in a NavigationButton that leads to the detail screen.

  • The second view simply displays the body of a question in a multiline Text view.

Each view has an @ObjectBinding to the respective object created above.

struct QuestionListView : View {
    @ObjectBinding var data: QuestionsData

    var body: some View {
        NavigationView {
            List(data.questions.identified(by: \.self)) { question in
                NavigationButton(destination: DetailView(data: DetaildData(question: question))) {
                    Text(question.title)
                }
            }
        }
    }
}

struct DetailView: View {
    @ObjectBinding var data: DetaildData

    var body: some View {
        data.question.body.map {
            Text($0).lineLimit(nil)
        }
    }
}

If you run the app, it works.

The problem though is that each NavigationButton wants a destination view. Given the declarative nature of SwiftUI, when the list is populated, a DetailView is immediately created for each row.

One might argue that SwiftUI views are lightweight structures, so this is not an issue. The problem is that each of these views needs a DetaildData instance, which immediately starts a network request upon creation, before the user taps on a row. You can put a breakpoint or a print statement in its initializer to verify this.

It is possible, of course, to delay the network request in the DetaildData class by extracting the networking code into a separate method, which we then call using onAppear(perform:) (which you can see in the final code on GitHub).

But this still leads to the creation of multiple instances of DetaildData, which are never used, and are a waste of memory. Moreover, in this simple example, these objects are lightweight, but in other scenarios they might be expensive to build.

Is this how SwiftUI is supposed to work? or am I missing some critical concept?

Jackqueline answered 29/6, 2019 at 21:50 Comment(2)
I think it is a bug twitter.com/chriseidhof/status/1144242544680849410, the tweet also mentions a workaround for time gist.github.com/chriseidhof/d2fcafb53843df343fe07f3c0dac41d5Alixaliza
It seems that SwiftUI encourages a 2-step view creation by design, with lightweight initialisation, just as you did in your example. This is similar in concept to the prefetching APIs in UITableView and UICollectionView, so it could be actually desirable.Imposing
L
6

As you have discovered, a List (or a ForEach) creates the row view for each of its rows when the List is asked for its body. Concretely, in this code:

struct QuestionListView : View {
    @ObjectBinding var data: QuestionsData

    var body: some View {
        NavigationView {
            List(data.questions.identified(by: \.self)) { question in
                NavigationButton(destination: DetailView(data: DetailData(question: question))) {
                    Text(question.title)
                }
            }
        }
    }
}

When SwiftUI asks QuestionListView for its body, the QuestionListView body accessor will immediately create one DetailView and one DetailData for every Question in data.questions.

However, SwiftUI does not ask a DetailView for its body until the DetailView is on the screen. So if your List has room for 12 rows on screen, SwiftUI will only ask the first 12 DetailViews for their body properties.

So, don't kick off the dataTask in DetailData's init. Kick it off lazily in DetailData's question accessor. That way, it won't run until SwiftUI asks the DetailView for its body.

Lamellar answered 30/6, 2019 at 1:41 Comment(2)
I guess this is not a bug, it's by design to behave like this, I believe that you have to make the View to be as lightweight as possibleOrabelle
I think it's not a bug, but I also think they might improve it later. List and ForEach both require Data to conform to RandomAccessCollection, and both take the rowContent closure as @escaping. This leaves room for them to be changed to only request row content views for visible rows on demand.Lamellar
I
0

Please see MwcsMac's accepted answer for a really clean solution to this:

SwiftUI NavigationLink loads destination view immediately, without clicking

Edit: This is very helpful if you simply have lots of destination views (like a list of options, all wrapped with NavigationLink). For those situations where actual code is being executed (like the original network fetch example, above) then refactoring to use a more correct lazy loading strategy would be far more effective.

Inesita answered 18/9, 2020 at 16:15 Comment(2)
Thanks, but I would say that is a workaround to use an old mental model similar to the one we had for UIKit. I wrote this question shortly after SwiftUI was released and that was my mental model too. After using the framework for a long time, I came to the conclusion that this behavior is as expected. What we did in initializers in UIKit view controllers should not be translated to SwiftUI views and should be moved somewhere else.Jackqueline
Yes, you're probably right. After working with (or should I say trying to work with) SwiftUI for several months now, I'm giving up and re-writing my whole app with UIKit instead. SwiftUI is just wrong, and the god-awful mashup of Combine and SwiftUI with the utterly dreadful documentation is just too much. I wish I'd never heard of it now. I need performance, and SwiftUI doesn't cut it.Inesita

© 2022 - 2024 — McMap. All rights reserved.