SwiftUI 初探

十月份参加极光黑客马拉松一天时间写了个简单的 火车票 OCR 应用“票夹”,当时由于时间和熟练程度原因,并没有试下今年 WWDC 刚推出的 SwiftUI 框架。最近抽空用了 SwiftUI + Combine 进行重写,顺便感受了一下这两个新框架的魅力。先说个人感受,SwiftUI 看起来挺美好的,但是目前有 Bug 和完善度不高,比较适合用在不关心设计的 Demo 或者个人功能性项目上。Combine 完成度尚可,但 Xcode 对复杂闭包的自动推断经常失效,比较影响编码体验。

SwiftUI 总体使用起来和 React 框架很像,都有对应的概念,一般就是 HStackVStack 当作视图层级使用,Spacer 用于自动填充剩余部分,比如在一个水平 HStack 中,A-Spacer-B,那么 A 靠最左,B 靠最右。

在使用 Swift UI 的过程中,碰到了一些问题,分享一下。

视图的默认行为

  1. 常用的 padding 是有默认值,且不为 0。

  2. List 默认是有分隔线,目前好像没法做到单独去掉,只能用下面的代码进行全局去除,并且是一种非官方做法,毕竟 List 的实现后续可能不一定是 UITableView

    List([]) {
    //…
    }
    .onAppear {
       UITableView.appearance().separatorColor = .clear
    }
    
  3. 视图的属性顺序会影响表现,比如下面两段代码

    // 1
    HStack {
       Spacer()
    }
    .frame(height: 300)
    .background(Color.blue)
    .padding(30)
    
    // 2
    HStack {
       Spacer()
    }
    .frame(height: 300)
    .padding(30)
    .background(Color.blue)
    

    要实现想要的效果,得使用第一种,第二种会是没有边距的蓝色矩形,这个我怀疑是 Bug。

数据交互

@State

这个修饰符和 React 的 State 差不多,就当 State 改变时会触发所有使用了 State 地方的 UI 刷新。比 React 好用的地方是可以用多个修饰符分别修饰多个变量,而不用放在一起,然后也不用调用 setState 进行刷新,只需要正常赋值就会触发刷新。

@Binding

这个修饰符用于解决数据是从上层传入的,上层数据改变时需要通知下层 UI 的刷新,这个时候下层的数据就应该用 @Binding 修饰,这样不像 @State 修饰的数据会在传递时遵循值语义发生复制,从而导致数据不同步的问题。

@EnvironmentObject

这个修饰符用于解决多层嵌套时,下层视图想访问上层数据的问题,除了用 @Binding 一层层传递外,通过声明这个修饰符也可以在任意嵌套层级内使用该数据。

@ObservedObject & ObservableObject

这个修饰符可以用于在多个视图里共享一份数据模型时使用,可以将已有的数据模型集合进 SwiftUI。遵循 Observable 协议,并在接收数据改变的地方用 @ObservedObject 修饰,这样该 Observable 类型里所有的 Publisher 在发生改变时都会通知 @ObservedObject。对于已经存在的属性,可以加上 @Published 修饰符或者使用自定义的 Publisher 发送通知。

// 1.
class GlobalModel: ObservableObject {
    @Published var name = "myName"
}
// 2.
class GlobalModel: ObservableObject {
    let didChange = PassthroughSubject<Void, Never>()
  
    var name: = "myName" {
        didSet {
            didChange.send()
        }
    }
}

实现模态展示视图

在 App 开发中,必不可少有需要 Modal 方式弹出 UIViewController 的情况,在 UIKit 中,只需要简单的 vc1.present(vc2, animated: true) 一行代码就能完成,但是在 SwiftUI 中,要完成这个操作却显繁琐。

struct ContentView: View {
    @State var isShowModal = false
    var body: some View {
        Button(action: {
            self.isShowModal = true
        }){
            Text("show")
        }
        .sheet(isPresented: $isShowModal) {
            ModalView(isShow: self.$isShowModal)
        }
    }
}

struct ModalView: View {
    @Binding var isShow:Bool
    
    var body: some View {
        Button(action: {
            self.isShow = false
        }){
            Text("dismiss")
        }
    }
}

可以看到,不仅需要传递一个标志位代表是否展示,还需要在需要关闭时改变该状态告诉原始视图让其消失。这样会带来不必要的状态传递和维护。笔者推荐通过定义闭包的方式来进行状态传递,并且方便两个视图之间数据传递。

struct ContentView: View {
    @State var isShowModal = false
    var body: some View {
        Button(action: {
            self.isShowModal = true
        }){
            Text("show")
        }
        .sheet(isPresented: $isShowModal) {
            ModalView { intent in
                self.isShowModal = false
                // intent 处理
            }
        }
    }
}

struct ModalView: View {
    typealias Intent = String
    let onViewResult:((Intent?) -> ())
    
    var body: some View {
        Button(action: {
            self.onViewResult(nil)
        }){
            Text("dismiss")
        }
    }
}

UIKit 的适配

在现阶段,即便是没有任何历史的新应用,全用 SwiftUI 进行构建也是不太现实的,在某些系统的视图和第三方库没有适配 SwiftUI 之前,继续和 UIKit 打交道是很正常的。

SwiftUI 分别为 UIView 和 UIViewController 提供了 UIViewRepresentableUIViewControllerRepresentable 协议进行适配。这两个协议的要求几乎一致,只需要在某个类型里遵循协议,在要求的方法里处理需要适配的 UIViewUIViewController,这个类型就能用于 SwiftUI 的视图中。

class BView: UIView {
}

struct AView {
}

extension AView: UIViewRepresentable {
    func makeUIView(context: UIViewRepresentableContext<AView>) -> BView {
        // 初始化 UIView
        BView()
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
    }
}

但是很多时候,UIKit 的视图里面不仅仅 UI 展示,更耦合了数据的变化,这里有两方面的数据流:SwiftUI 数据往 UIViewUIView 数据往 SwiftUI (UIViewController 也是类似的)。

SwiftUI -> UIView

蛮简单的,协议里提供了方法。

class BView: UIView {
    var isDark:Bool = false {
        didSet {
            backgroundColor = isDark ? .black : .white
        }
    }
}

struct AView {
    @State var isDark = false
}

extension AView: UIViewRepresentable {
    func makeUIView(context: UIViewRepresentableContext<AView>) -> BView {
        BView()
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
        // 更新 UIView
        uiView.isDark = isDark
    }
}

UIView -> SwiftUI

这种情况略微复杂,SwiftUI 里面提供了 Coodinator 来处理这种情况,简单来说,Coodinator 就是中间人,用于接收 UIView 变化的实例。

class BView: UIView {
    var isDark:Bool = false {
        didSet {
            didChangeDark?(isDark)
        }
    }
    
    // UIKit 常用的数据回调方式,闭包或者代理等
    var didChangeDark:((Bool) -> ())?
}

struct AView {
    // 需要接收变化的属性
    @Binding var isDark: Bool
    
    // 定义 Coordinator,里面持有 AView
    class Coordinator {
        let parent:AView
        
        init(_ view:AView) {
            parent = view
        }
    }
}

extension AView: UIViewRepresentable {
    // 实现方法
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: UIViewRepresentableContext<AView>) -> BView {
        let view = BView()
        view.didChangeDark = {
            // 将改变传递到 context 里面的 coordinator 中
            context.coordinator.parent.isDark = $0
        }
        return view
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
    }
}

接入 Combine

Combine 和 SwiftUI 直接结合还是有点别扭,特别是对于常见的网络请求,建议通过 @ObservedObjectObservableObject 进行中转一下。下面给出了 Combine 和 SwiftUI 直接结合的例子,SwiftUI 只提供了 onReceive 方法进行接收。

struct ContentView: View {
    // 请求参数
    @State var name = ""
    // 返回结果
    @State var resultCode = 0
    
    // 请求操作,如网络请求
    func fetch(_ name:String) -> AnyPublisher<Int, Error> {
        Just(name.isEmpty ? 0 : 1)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
    
    // 将请求转化为错误 Never 的,处理兜底
    var nameRequest: AnyPublisher<Int, Never> {
        fetch(name)
        .catch { _ in
            Just(0)
                .setFailureType(to: Error.self)
        }
        .assertNoFailure()
        .eraseToAnyPublisher()
    }
    
    var body: some View {
        VStack {
            Button(action: {
                // 触发请求
                self.name = "Request"
            }) {
                Text("send request")
            }
            Text("code is \(resultCode)")
        }
        // 监听请求,错误类型必须为 Never
        .onReceive(nameRequest) { resultCode in
            self.resultCode = resultCode
        }
    }
}

最后

“票夹” App 可以识别照片里的火车票并自动整理展示和汇总,用 SwiftUI + Combine 编写,基本不使用第三方库。可以作为 SwiftUI 实际运用的例子参考。

参考链接

Interfacing with UIKit

SwiftUI 数据流

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,976评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,249评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,449评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,433评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,460评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,132评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,721评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,641评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,180评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,267评论 3 339
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,408评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,076评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,767评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,255评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,386评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,764评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,413评论 2 358

推荐阅读更多精彩内容