SwiftUI开发总结(二) 大概是项目中最熟悉的面孔List

4,200 阅读6分钟

List是TableView的封装么

因为我查了两天并没有找到swiftUI的开源代码,所以其内部实现我并不能断言,但是在使用过程中追寻一些蛛丝马迹,List的确与UIKit中的UITableVIew有点关系。尽管如此,正如我上一篇博客中说的那样,对于项目作为极简设计的app,swiftUI是一个非常不错的选择。 它的极简不仅仅体现在设计上,在代码编写上,swiftUI也尽可能降低开发者对于UI本身的开发复杂度。因此,List减少了许多我们在UITableView不愿意写,又不得不写的冗余代码,以尽可能简单的方法,完成流式布局。如下例子:

List {
    ForEach(viewModel.dataSource) { model in
        Text("(model.title)")
    }
}

这是List的一种写法,还有另外一种写法:

List(viewModel.dataSource) { model in
    Text("(model.title)")
}

具体这两种写法有什么区别,我引用一篇博客中的结论是,for-each的写法会在加载的时候将数据源数据个数的视图一次性全部加载出来,一旦数据源数据量很大,则容易影响到性能。 目前我并没有对此进行论证,所以这里并不给予评价。但如果各位在使用List的时候,遇到了卡顿问题,可以注意一下这两种写法。下面我们看一下效果图:

截屏2023-02-09 22.29.34.png

如果觉得简单,请把简单打在公屏上。还有比这更简单的布局么?如果有,那一定是安卓的ListView

事实上,我们很少会把一个系统控件直接扔给List,因此我们需要自定义一个视图,我们可以起个名字,叫item或者沿用之前UIKit的传统给它起名cell,只要便于理解怎么起名都可以。如下代码:

struct MMListItem: View {
    var title: String
    var body: some View {
        return HStack {
            Text(title)
                .font(.title)
        }
        .background(Color.white)
    }
}
​
struct MMContentView: View {
    let supportedDevices: [String] =
    ["iPhone5s-iPhone5s",
     "iPadAir-iPadAir", 
     "iPadAirCellular-iPadAirCellular", 
     "iPadMiniRetina-iPadMiniRetina",
     "iPadMiniRetinaCellular-iPadMiniRetinaCellular",
     "iPhone6-iPhone6", 
     "iPhone6Plus-iPhone6Plus", 
     "iPadAir2-iPadAir2",
     "iPadAir2Cellular-iPadAir2Cellular",
     "iPadMini3-iPadMini3", 
     "iPadMini3Cellular-iPadMini3Cellular", 
     "iPodTouchSixthGen-iPodTouchSixthGen", 
     "iPhone6s-iPhone6s", 
     "iPhone6sPlus-iPhone6sPlus", 
     "iPadMini4-iPadMini4", 
     "iPadMini4Cellular-iPadMini4Cellular", 
     "iPadPro-iPadPro", 
     "iPadProCellular-iPadProCellular", 
     "iPadPro97-iPadPro97", 
     "iPadPro97Cellular-iPadPro97Cellular", 
     "iPhoneSE-iPhoneSE", 
     "iPhone7-iPhone7", 
     "iPhone7Plus-iPhone7Plus"]
  
    var body: some View {
        NavigationView {
            List {
                ForEach(0..<supportedDevices.count) {
                    MMListItem(title: supportedDevices[$0])
                        .listRowBackground(Color.gray)
​
                }
            }
            .listStyle(.plain)
            .background(Color.gray)
        }
    }
}
​
struct MMContentView_Previews: PreviewProvider {
    static var previews: some View {
        MMContentView()
    }
}
​
​

刚刚接触SwiftUI的人可能会一脸懵x,我们一点一点分析,这里我没有像别人一样从基础控件开始讲,讲一大堆的理论逻辑,还是遵循之前的方式,整篇文档以实际操作为主,秉承着command + c and command +v即可使用的原则,尽快的将大家带入开发节奏中。

说说什么是容器

先说容器stack,作为基础容器它的作用与安卓中的layout类极为相似,都是本身并不负责什么渲染工作,或者说最好不要把它用成一个渲染组件,只是负责内部视图的布局任务。

HStack为横向布局,VStack为水平布局,还有一个ZStack容器是以Z轴为布局方向布局,这里抛出来一个问题,为什么苹果要如此设计,而不是用一个Stack+interface来控制布局方式?对于HStackVStack,同学们应该很容易具象出它们作用出来的样子,所以这里并不多做介绍,而是引用苹果官方事例简单展示一下ZStack来理解容器概念:

let colors: [Color] =
    [.red, .orange, .yellow, .green, .blue, .purple]var body: some View {
    ZStack {
        ForEach(0..<colors.count) {
            Rectangle()
                .fill(colors[$0])
                .frame(width: 100, height: 100)
                .offset(x: CGFloat($0) * 10.0,
                        y: CGFloat($0) * 10.0)
        }
    }
}

SwiftUI-ZStack-offset-rectangles_dark@2x.png

如果之前有同学使用过zIndex,那么对这个概念一定不会陌生,当然有图就更容易理解了:

20698c680e770fd9ceb84e5f8b0a05f3.png

理解一下坐标系,然后我们接着聊容器布局。以HStack为例子,它的初始化方法可以很直观地看出它的布局方式:

    @inlinable public init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
​

VStack的初始化方法跟HStack的一样,ZStack的初始化方法中缺少了space入参。alignment是对其方式,

与安卓不同的是,Stack没有直接提供跟父视图保持一致或是根据内容填充这样的参数方法,当然这种功能并非不可能实现,可以通过GeometryReader的方法与父视图建立联系,获取父视图尺寸信息,从而根据父视图布局,本章先不做介绍。

Stack的布局方式是通过内容填充将容器撑开,如果视图深度复杂度不够(说人话的话,就是没有Stack嵌套,只是扁平层布局)不会出现什么问题,但是试想一下我们刚刚自定义itemList底色不同就会有问题。如图所示:

截屏2023-02-10 21.16.24.png

这样显然是不符合设计要求的,由于容器大小是其内部视图填充起来的,因此,在加载到List时,会出现长度或者高度都可能不一致的情况,此时就需要另一个控件的使用——space

放在箱子里的泡沫——Spacer

假设一个箱子是根据里面的货物大小而自动伸缩的,但是由于每个货物大小不一,所以导致箱子的大小不一致,那么,如果我们希望箱子大小是一致的,该如何解决这个问题呢?最简单的办法就是往箱子里注入相同体积的泡沫,这样箱子就可以做到同样大了,那么这里的泡沫就是Spacer

如果我们希望每个item看起来一样长,那么就需要为其添加Spacer,扩充其内容,如下:

struct MMListItem: View {
    var title: String
    var body: some View {
        return HStack {
            Text(title)
                .font(.title)
            Spacer()
        }
        .background(Color.white)
    }
}

这样一切看起来就正常许多了。

截屏2023-03-20 16.51.37.png

上拉下拉

接下来我们介绍日常使用Tableview都绕不开的一个功能,上拉下拉。

SwiftUI中,系统已经帮助实现了下拉方法,使用起来也十分方便,一行代码就可以解决问题:

.refreshable {
    // 模拟数据加载时间
    try? await Task.sleep(nanoseconds: 3_500_000_000)
}

效果如下:

截屏2023-03-20 16.58.14.png

还是那句话,如果不苛求app的UI设计一定是可以拿国际设计金奖的话,swiftUI是一个不错的选择,因为它真的很容易。

说完下拉,再来说一下上拉加载更多。这里说一个比较取巧的办法,就是利用视图的onAppear方法,来触发加载逻辑。

实现逻辑是将自定义视图放在List队尾,一旦自定义视图被展示出来,则触发加载动画,进行加载,这里我将我自定义的footView贴在下面,有需要的可以自取,然后删删改改:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
final class XXRefreshHandler {
    var moreAction: (() async -> Void)?
}
@available(iOS 13.0, macOS 10.15, *)
struct XXActivityIndicator: UIViewRepresentable {
​
    @Binding var isAnimating: Bool
    let style: UIActivityIndicatorView.Style
​
    func makeUIView(context: UIViewRepresentableContext<XXActivityIndicator>) -> UIActivityIndicatorView {
        return UIActivityIndicatorView(style: style)
    }
​
    func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<XXActivityIndicator>) {
        isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
    }
}
​
struct XXRefreshFooterView: View {
    @Binding var noMoreData: Bool
    var footHandler = XXRefreshHandler()
    
    public func getMoreAction(action: @escaping () async -> Void) -> XXRefreshFooterView {
        self.footHandler.moreAction = action
        return self
    }
    
    var body: some View {
        return HStack(alignment: .center) {
            Spacer()
            if noMoreData {
                Text("No More Data")
            } else {
                XXActivityIndicator(isAnimating: .constant(true), style: .medium)
                Text("Loading..")
            }
            Spacer()
        }
        .background(Color.clear)
        .onAppear {
            Task {
                // 模拟网络请求
                try? await Task.sleep(nanoseconds: 3_500_000_000)
                await footHandler.moreAction?()
            }
        }
        .listRowSeparator(.hidden)
​
    }
}
​

如果对于协程还不清楚的同学,可以先忽略Task中的代码,以及删除asyncawait等关键字。

XXActivityIndicator: 俗称‘菊花轮’,是常用的等待指示器,上面我将其封装好,如果需要自定义样式,可以自行修改。

XXRefreshHandler: 需要注意一下这个类,之所以采用这样的一种写法,出于两方面原因:

  1. XXRefreshFooterView作为结构体,其成员是在初始化之后不能被直接修改的。
  1. swiftUI 的body内部不允许有数据的操作(可以通过闭包的方式封装数据处理逻辑,但结果必须返回view实例)。

因此这里使用class将属性进行了包装,这里还可以采用以下方式修改:

    public func getMoreAction(action: @escaping () async -> Void) -> XXRefreshFooterView {
    		var result = self
        result.moreAction = action
        return result
    }

结语

这篇跟大家大概了聊了一下如何使用swiftUIlist进行布局,以及一些常用方法的总结,希望可以帮助同学们快速上手swiftUI

以我个人的开发经验而言,万事开头难,但只有上手之后,才能发现问题,解决问题,这些问题跟空想或者单纯学习理论知识是不一样的。真正投入开发中会发现,很多问题是细枝末节,想要处理好也需要很大功夫。因此,还是希望大家动手先试一下,有什么问题交流讨论,请在下面留言。