十月份参加极光黑客马拉松一天时间写了个简单的 火车票 OCR 应用“票夹”,当时由于时间和熟练程度原因,并没有试下今年 WWDC 刚推出的 SwiftUI 框架。最近抽空用了 SwiftUI + Combine 进行重写,顺便感受了一下这两个新框架的魅力。先说个人感受,SwiftUI 看起来挺美好的,但是目前有 Bug 和完善度不高,比较适合用在不关心设计的 Demo 或者个人功能性项目上。Combine 完成度尚可,但 Xcode 对复杂闭包的自动推断经常失效,比较影响编码体验。
SwiftUI 总体使用起来和 React 框架很像,都有对应的概念,一般就是 HStack
和 VStack
当作视图层级使用,Spacer
用于自动填充剩余部分,比如在一个水平 HStack
中,A-Spacer-B,那么 A 靠最左,B 靠最右。
在使用 Swift UI 的过程中,碰到了一些问题,分享一下。
视图的默认行为
常用的
padding
是有默认值,且不为 0。-
List
默认是有分隔线,目前好像没法做到单独去掉,只能用下面的代码进行全局去除,并且是一种非官方做法,毕竟List
的实现后续可能不一定是UITableView
。List([]) { //… } .onAppear { UITableView.appearance().separatorColor = .clear }
-
视图的属性顺序会影响表现,比如下面两段代码
// 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()
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 提供了 UIViewRepresentable
和 UIViewControllerRepresentable
协议进行适配。这两个协议的要求几乎一致,只需要在某个类型里遵循协议,在要求的方法里处理需要适配的 UIView
或 UIViewController
,这个类型就能用于 SwiftUI 的视图中。
class BView: UIView {
}
struct AView {
}
extension AView: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext) -> BView {
// 初始化 UIView
BView()
}
func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) {
}
}
但是很多时候,UIKit 的视图里面不仅仅 UI 展示,更耦合了数据的变化,这里有两方面的数据流:SwiftUI 数据往 UIView
,UIView
数据往 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) -> BView {
BView()
}
func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) {
// 更新 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) -> BView {
let view = BView()
view.didChangeDark = {
// 将改变传递到 context 里面的 coordinator 中
context.coordinator.parent.isDark = $0
}
return view
}
func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) {
}
}
接入 Combine
Combine 和 SwiftUI 直接结合还是有点别扭,特别是对于常见的网络请求,建议通过 @ObservedObject
和 ObservableObject
进行中转一下。下面给出了 Combine 和 SwiftUI 直接结合的例子,SwiftUI 只提供了 onReceive
方法进行接收。
struct ContentView: View {
// 请求参数
@State var name = ""
// 返回结果
@State var resultCode = 0
// 请求操作,如网络请求
func fetch(_ name:String) -> AnyPublisher {
Just(name.isEmpty ? 0 : 1)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// 将请求转化为错误 Never 的,处理兜底
var nameRequest: AnyPublisher {
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 数据流