27

SwiftUI 初探

 4 years ago
source link: https://nemocdz.github.io/post/swift-ui-初探/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

十月份参加极光黑客马拉松一天时间写了个简单的 火车票 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 数据流


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK