Swift 仿 Flutter 風格宣告式 UI 封裝思路

darrenzheng發表於2019-08-05

前言

自從入坑了 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

直接看效果

Swift 仿 Flutter 風格宣告式 UI 封裝思路

和 Flutter 的語法對比

Swift 仿 Flutter 風格宣告式 UI 封裝思路

使用 Playground 快速開發

Swift 仿 Flutter 風格宣告式 UI 封裝思路

封裝了什麼?

  • 宣告式 UI
  • 隱藏了 UIStackView 的複雜度和術語
  • 支援 UIStackView 的靈活巢狀方式
  • 支援 Flutter 的 build() 入口 和更新方法 rebuild()
  • 支援 Row/Column, Spacer (sizedBox in Flutter)
  • 支援列表 ListView (UITableView in UIKit)
  • 支援約束 Padding Center SizedBox
  • 支援手勢 GestureDetector

最低版本: iOS 9
依賴:UIKit

建議使用 Then 來做初始化的語法糖。
這套封裝的另一個目標是減少或者消滅直接使用約束的場景

程式碼結構

Swift 仿 Flutter 風格宣告式 UI 封裝思路

安裝

繼承 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

相關文章