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 DetailData: BindableObject {
let didChange = PassthroughSubject<detaildata, 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&fil er=!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 DetailData 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 DetailData 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 DetailData, 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?