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?
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.
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.
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.
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 DetailView
s 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
.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With