iOS 14 SwiftUI List Lazyload
在WWDC2020中,Apple为SwiftUI添加了很多新特性,其中LazyVStack与StateObject是实现列表懒加载的最重要的两个特性,通常我们开发列表懒加载的实现思路是:列表底部放一个占位,当占位出现在屏幕中时请求新数据,并将结果插入到现有列表中,等下次占位出现在屏幕中时,重复这个过程。
列表懒加载
按照这个思路,我们可以使用ScrollView做列表滚动容器,使用一个高度为0的Rectangle做占位,当它的onAppear方法被调用后,继续请求新的数据。但在SwiftUI1.0中,ScrollView会将它其中的元素一次性全部渲染出来,这就导致列表刚一渲染,Rectangle的onAppear方法就会被调用,而不是预期的出现在屏幕内才会被调用,这显然是不符合预期的。
新的LazyVStack解决了这个问题,它会在只有LazyVStack中的元素开始出现在屏幕时才会渲染它,屏幕范围之外的元素将不会被渲染,这样配合Rectangle的onAppear方法,我们就能知道什么时候该去取新数据。
一个典型的结构是这样的,data是我们的列表数据来源,通过ForEach来遍历数据生成一个个Text。注意LazyVStack中还包含一个VStack,这是因为如果LazyVStack内直属的子元素是根据数据动态生成的,通过append方法更新数据后会导致app crash,比如这样:
在请求一次数据后,我们调用data.append(...)
向data内插入新请求回来的数据,由于LazyVStack下直接通过ForEach遍历data生成Text,这会在append调用后,SwiftUI开始刷新时导致app crash,在Xcode 12.0 beta 4 + iOS 14 Beta 4中会出现这个问题,解决办法就是在LazyVStack中套一个VStack。
数据管理
为了提升代码整洁度,我将SwiftUI与请求数据管理分成了两个文件,避免数据请求管理逻辑与UI逻辑混在一个struct中,简要的代码是这样:
1class SearchManager: ObservableObject {
2 // 对外暴露的搜索结果数据,SwiftUI会根据这个数据来渲染列表
3 @Published var data: [SearchResultItem]?
4
5 // 根据关键字搜索数据
6 func search(keyword: String) {
7 // ...
8 }
9
10 // 对当前关键字请求翻页数据
11 func next() {
12 // ...
13 }
14}
1struct Search: View {
2 @ObservedObject private var searchManager = SearchManager()
3
4 var body: some View {
5 ScrollView {
6 LazyVStack {
7 VStack {
8 ForEach(searchManager.data) { item in
9 SearchItemCard(item)
10 }
11 Rectangle()
12 .frame(height: 0)
13 .onAppear {
14 searchManager.next()
15 }
16 }
17 }
18 }
19 }
20}
基本的逻辑是我实现了一个ObservableObject的class: SearchManager,它用来搜索与存储搜索数据,search或者next调用后会更新data属性,同时data使用@Published标记,表示这是一个可被观察的数据来源,当data变动后,SwiftUI会使用新数据渲染列表。
到目前为止,如果是一个不会被重新创建的View,这么做没有问题,每次滚到底部后都能正常刷新数据。但如果界面中有TextField作为搜索框,点击TextField会调起键盘,当输入完成后,键盘并不会自动收起,一般我们会通过这样的方法来隐藏键盘:
1func endEditing() {
2 sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
3}
当endEditing调用后,会导致View重新生成,因为@ObservedObject只是在View和Model添加了订阅关系,所以这并不会影响存储,当View被重新生成时,View内的searchManager也会重新初始化,这样searchManager.data就会变成nil,导致数据丢失。表现就是一旦出现滚动(将endEditing放在DragGesture中来隐藏键盘),列表数据就会被清空。
在iOS14中Apple带来的新的@StateObject可以解决这个问题,它确保对象只会被创建一次,不受View重新创建的影响。使用方法也很简单,直接将@ObservedObject替换为@StateObject即可。
1@StateObject private var searchManager = SearchManager()
基本上所有在View内创建的ObservableObject对象,都可以使用@StateObject。这样就能确保隐藏键盘导致的View刷新并不会影响数据,懒加载就能正常工作了。