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 aNavigationView
. Each row is contained in aNavigationButton
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?