Swift 仿 Flutter 风格声明式 UI 封装思路

4,475 阅读2分钟

前言

自从入坑了 Flutter,了解了现代 web 框架,回头来看 iOS 原生的命令式 UI 产能实在太低了,就好像骑自行车和汽车赛跑一样

问题出在哪?

  1. 没有响应式,没有 setState(),这一点可以通过 RxSwift 的绑定来将就。
  2. 没有声明式,传统的命令式 UI 的代码和效果不匹配。
  3. 没有 JIT,编译耗费大量时间。

命令式的问题在陶文的 面向对象不是银弹,DDD 也不是,TypeScript 才是 中有更深入的讨论:

Many states:数量上多
Concurrent / Parallel:并发是逻辑上的,并行是物理上的。无论是哪种,都比 sequential 更复杂。
Long range causality:长距离的因果关系
Entangled:剪不断理还乱

SwiftUI 呢?

虽然 SwiftUI 很美,甚至支持了 Hot reload,但是远水解不了近渴,iOS 13+ 的最低门槛把国内大多 App 挡在门外,如同以前的 UIStackView 一样几年内遥不可及。

UIStackView 呢?

因为去年 App 终于升级了最低支持 iOS 9,所以 安利了一波 UIStackView ,它确实是实现了不少 FlexBox 的功能,但是 StackView 真的是声明式吗?

headerStackView.axis = .horizontal
headerStackView.addArrangedSubviews([headerLeftLine,
                                    headerLabel,
                                    headerRightLine])
headerStackView.alignment = .center
headerStackView.snp.makeConstraints {
    $0.centerX.equalToSuperview()
}

只能勉强说有一点声明式的意思吧。

决定自己封装

UIStackView 其实足够强大,问题就出在调用层的不够友好,如果让它长着 Flutter/Dart 一样的脸,也许还能一战。

介绍一下 DeclarativeSugar

直接看效果

和 Flutter 的语法对比

使用 Playground 快速开发

封装了什么?

  • 声明式 UI
  • 隐藏了 UIStackView 的复杂度和术语
  • 支持 UIStackView 的灵活嵌套方式
  • 支持 Flutter 的 build() 入口 和更新方法 rebuild()
  • 支持 Row/Column, Spacer (sizedBox in Flutter)
  • 支持列表 ListView (UITableView in UIKit)
  • 支持约束 Padding Center SizedBox
  • 支持手势 GestureDetector

最低版本: iOS 9
依赖:UIKit

建议使用 Then 来做初始化的语法糖。
这套封装的另一个目标是减少或者消灭直接使用约束的场景

代码结构

安装

继承 DeclarativeViewController 或者 DeclarativeView

class ViewController: DeclarativeViewController {
    ...
}

重写 build() 函数,返回你的 UI,和 Flutter 类似。
这个 View 会被加到 ViewController 的 view 上,并且全屏化。

override func build() -> DZWidget {
    return ...
}

功能

1. Row

横向布局 同 Flutter 的 Row

DZRow(
    mainAxisAlignment: ... // UIStackView.Distribution
    crossAxisAlignment: ... // UIStackView.Alignment
    children: [
       ...
    ])

2. Column

纵向布局 同 Flutter 的 Column

DZColumn(
    mainAxisAlignment: ... // UIStackView.Distribution
    crossAxisAlignment: ... // UIStackView.Alignment
    children: [
       ...
    ])

3. Padding

内填充 同 Flutter 的 Padding

3.1 only

 DZPadding(
    edgeInsets: DZEdgeInsets.only(left: 10, top: 8, right: 10, bottom: 8),
    child: UILabel().then { $0.text = "hello world" }
 ),

3.2 symmetric

 DZPadding(
    edgeInsets: DZEdgeInsets.symmetric(vertical: 10, horizontal: 20),
    child: UILabel().then { $0.text = "hello world" }
 ),

3.3 all

 DZPadding(
    edgeInsets: DZEdgeInsets.all(16),
    child: UILabel().then { $0.text = "hello world" }
 ),

4. Center

autolayout 的 centerX 和 centerY

DZCenter(
    child: UILabel().then { $0.text = "hello world" }
)

5. SizedBox

宽高约束

DZSizedBox(
    width: 50, 
    height: 50, 
    child: UIImageView(image: UIImage(named: "icon"))
)

6. Spacer

占位空间

对于 Row: 同 Flutter 的 SizedBox 设置 width.

DZRow(
    children: [
        ...
        DZSpacer(20), 
        ...
    ]
)

对于 Column: 同 Flutter 的 SizedBox 设置 height.

DZColumn(
    children: [
        ...
        DZSpacer(20), 
        ...
    ]
)

7. ListView

列表

隐藏了 delegate/datasourceUITableViewCell 的概念

静态表格

 DZListView(
    tableView: UITableView().then { $0.separatorStyle = .singleLine },
    sections: [
        DZSection(
            cells: [
                DZCell(
                    widget: ...,
                DZCell(
                    widget: ...,
            ]),
        DZSection(
            cells: [
                DZCell(widget: ...)
            ])
    ])

动态表格

return DZListView(
    tableView: UITableView(),
    cells: ["a", "b", "c", "d", "e"].map { model in 
        DZCell(widget: UILabel().then { $0.text = model })
    }
)

8. Stack

是 Flutter stack, 不是 UIStackView,用来处理两个页面的叠加

DZStack(
    edgeInsets: DZEdgeInsets.only(bottom: 40), 
    direction: .horizontal, // center direction
    base: YourViewBelow,
    target: YourViewAbove
)

9. Gesture

支持点击事件(child 是 UIView 调用 TapGesture, UIButton 调用 touchUpInside)
支持递归查找,也就是说传入的 child 可以是嵌套很多层的 DZWidget

DZGestureDetector(
    onTap: { print("label tapped") },
    child: UILabel().then { $0.text = "Darren"}
)

DZGestureDetector(
    onTap: { print("button tapped") },
    child: UIButton().then {
        $0.setTitle("button", for: UIControl.State.normal)
        $0.setTitleColor(UIColor.red, for: UIControl.State.normal)
}),

10. AppBar

支持设置导航栏,这个控件只是一个配置类

DZAppBar(
    title: "App Bar Title",
    child: ... 
)

刷新

重刷

self.rebuild {
    self.hide = !self.hide
}

增量刷新

UIView.animate(withDuration: 0.5) {
    // incremental reload
    self.hide = !self.hide
    self.context.setSpacing(self.hide ? 50 : 10, for: self.spacer) // 支持改变区间距离
    self.context.setHidden(self.hide, for: self.label) // 支持隐藏
}

总结

这套轻量封装已经减轻了不少我日常写 UI 的认知负担,提高不少的产能。(程序员为了犯懒什么苦都能吃)

虽然做不到 Flutter 那种 Widget Tree 随便换,Element Tree 狂优化来兜底,但是对于相对静态的页面,布局变化不大的话,这层封装还是胜任的。(就是写法 Fancy 一点的 UITableView/UIStackView 而已)

如果你也觉得有用,欢迎一起来完善。

GitHub 地址: DeclarativeSugar