Z

iOS 14 SwiftUI List Lazyload

在WWDC2020中,Apple为SwiftUI添加了很多新特性,其中LazyVStackStateObject是实现列表懒加载的最重要的两个特性,通常我们开发列表懒加载的实现思路是:列表底部放一个占位,当占位出现在屏幕中时请求新数据,并将结果插入到现有列表中,等下次占位出现在屏幕中时,重复这个过程。

列表懒加载

按照这个思路,我们可以使用ScrollView做列表滚动容器,使用一个高度为0的Rectangle做占位,当它的onAppear方法被调用后,继续请求新的数据。但在SwiftUI1.0中,ScrollView会将它其中的元素一次性全部渲染出来,这就导致列表刚一渲染,Rectangle的onAppear方法就会被调用,而不是预期的出现在屏幕内才会被调用,这显然是不符合预期的。

新的LazyVStack解决了这个问题,它会在只有LazyVStack中的元素开始出现在屏幕时才会渲染它,屏幕范围之外的元素将不会被渲染,这样配合Rectangle的onAppear方法,我们就能知道什么时候该去取新数据。

1ScrollView {
2    LazyVStack {
3        VStack {
4            ForEach(data) { item in
5                Text(item)
6            }
7        }
8    }
9}

一个典型的结构是这样的,data是我们的列表数据来源,通过ForEach来遍历数据生成一个个Text。注意LazyVStack中还包含一个VStack,这是因为如果LazyVStack内直属的子元素是根据数据动态生成的,通过append方法更新数据后会导致app crash,比如这样:

1ScrollView {
2    LazyVStack {
3        ForEach(data) { item in
4            Text(item)
5        }
6    }
7}

在请求一次数据后,我们调用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刷新并不会影响数据,懒加载就能正常工作了。