Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI creates destination views before the user navigates to them

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?

like image 589
Matteo Manferdini Avatar asked Jun 29 '19 21:06

Matteo Manferdini


People also ask

How does SwiftUI view work?

While UIKit uses an event-driven framework, SwiftUI is based on a data-driven framework. In SwiftUI, views are a function of state, not a sequence of events (WWDC 2019). A view is bound to some data (or state) as a source of truth, and automatically updates whenever the state changes.

How does navigation work in SwiftUI?

Before NavigationStack and NavigationSplitView , SwiftUI introduced the NavigationView and NavigationLink structs. These two pieces work together to allow navigation between views in your application. The NavigationView is used to wrap the content of your views, setting them up for subsequent navigation.

How do I change the position of a view in SwiftUI?

Shift the location of a view's content For example, the offset(x:y:) modifier uses the parameters of x and y to represent a relative location within the view's coordinate space. In SwiftUI, the view's coordinate space uses x to represent a horizontal direction and y to represent a vertical direction.


1 Answers

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.

like image 68
rob mayoff Avatar answered Sep 27 '22 19:09

rob mayoff