ZLForm 是一个基于 UITableView 的声明式表单库,使用 DifferenceKit 做差量刷新,支持 Swift 与 Objective-C 混合使用。它将表单建模为 ZLFormDescriptor → [ZLFormSectionDescriptor] → [ZLFormRowDescriptor] 的三层数据结构,开发者只需要维护"数据模型",UI 的刷新(插入、删除、移动、reload)都由库内部计算 diff 并播放动画。
- ✅ 声明式 API,数据驱动 UI
- ✅ 基于 DifferenceKit 的差量刷新,只刷新变化的 row/section,自带动画
- ✅ 支持 Section 和 Row 的 隐藏 / 显示(section 优先级高于 row)
- ✅ 支持按 tag 自动排序(同时更新底层数组顺序,diff 稳定)
- ✅ 支持 AutoLayout 自适应高度,外部设置的固定高度优先
- ✅ 支持 Section 背景 View,自动跟随 cell 增删动态调整 frame
- ✅ Swift / Objective-C 混合调用,Cell 可以用 OC 写,外部可以用 Swift 配置
- ✅ Cell 创建支持
cellClass与cellProviderblock 两种方式,block 优先 - ✅ 防止误用:
init已被禁用,只能通过工厂方法创建
- iOS 11.0+
- Xcode 12+
- Swift 5.0+
- 依赖:
DifferenceKit、SnapKit、Then
在 Podfile 中添加:
use_frameworks!
target 'YourApp' do
pod 'ZLForm'
end然后执行:
pod install必须使用
use_frameworks!,因为 ZLForm 是 Swift 编写并依赖 SnapKit/DifferenceKit。
如果你的主工程是 Objective-C,需要在 Swift 文件中使用自定义 OC Cell,请创建桥接头:
- 新建
YourApp-Bridging-Header.h - 在 Build Settings 中设置
SWIFT_OBJC_BRIDGING_HEADER指向该文件 - 在桥接头中
#import "YourCustomCell.h"
详见 Objective-C 集成 章节。
import ZLForm
class MyFormVC: UIViewController {
let tableView = UITableView(frame: .zero, style: .grouped)
let formDescriptor = ZLFormDescriptor()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.frame = view.bounds
// 1. 把 tableView 交给 formDescriptor 管理(自动设 dataSource/delegate)
formDescriptor.tableView = tableView
// 2. 创建 section
let section = ZLFormSectionDescriptor.formSection(title: "个人信息")
// 3. 创建 row(必须用工厂方法,init 已禁用)
let nameRow = ZLFormRowDescriptor.formRowDescriptor(tag: "name")
nameRow.title = "姓名"
nameRow.value = "张三" as NSObject
let ageRow = ZLFormRowDescriptor.formRowDescriptor(tag: "age")
ageRow.title = "年龄"
ageRow.value = NSNumber(value: 18)
// 4. 组装
section.addFormRow(nameRow)
section.addFormRow(ageRow)
formDescriptor.addFormSection(section)
}
}#import <ZLForm/ZLForm-Swift.h>
@interface MyFormVC ()
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) ZLFormDescriptor *formDescriptor;
@end
@implementation MyFormVC
- (void)viewDidLoad {
[super viewDidLoad];
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleGrouped];
[self.view addSubview:self.tableView];
self.formDescriptor = [[ZLFormDescriptor alloc] init];
self.formDescriptor.tableView = self.tableView;
ZLFormSectionDescriptor *section = [ZLFormSectionDescriptor formSectionWithTitle:@"个人信息"];
ZLFormRowDescriptor *nameRow = [ZLFormRowDescriptor formRowDescriptorWithTag:@"name"];
nameRow.title = @"姓名";
nameRow.value = @"张三";
[section addFormRow:nameRow];
[self.formDescriptor addFormSection:section];
}
@endZLFormDescriptor ← 表单(对应 1 个 UITableView)
├─ ZLFormSectionDescriptor ← 分组(对应 1 个 section)
│ ├─ ZLFormRowDescriptor ← 行(对应 1 个 cell)
│ └─ ...
└─ ...
ZLForm 内部用 ArraySection<ZLFormSectionDescriptor, ZLFormRowDescriptor> 维护"可见"数据:
public private(set) var formSections: [ArraySection<ZLFormSectionDescriptor, ZLFormRowDescriptor>] = []所有增删改操作都会:
- 构造一个
target数组(期望的最终状态) - 通过
StagedChangeset(source: formSections, target: target)计算分阶段差异 - 调用
tableView.reload(using: changeset, with: .automatic) { setData }让 DifferenceKit 自动播动画并在setData闭包中更新数据源
这意味着:你只需要修改数据,UI 会自动以动画方式更新,不会调用 reloadData() 整表刷新。
| 属性 | 含义 |
|---|---|
allSections |
真实数据源(包含隐藏的 section / row) |
formSections |
可见数据源(DifferenceKit 驱动,过滤掉 hidden) |
buildVisibleTarget() 负责把 allSections 过滤、排序后转成 formSections。
表单顶层容器,每个表单对应 1 个 UITableView。
| 属性 | 类型 | 说明 |
|---|---|---|
tableView |
UITableView? |
关联的 tableView,设置后自动接管 dataSource/delegate |
delegate |
ZLFormDescriptorDelegate? |
表单事件回调 |
allSections |
[ZLFormSectionDescriptor] |
所有 section(只读,含隐藏) |
formSections |
[ArraySection<...>] |
可见 section(只读,Swift only) |
sectionDescriptors |
[ZLFormSectionDescriptor] |
OC 可访问的所有 section |
sortByTag |
Bool |
是否按 tag 自动排序所有 section 和 row,默认 false |
// 添加 section
func addFormSection(_ formSection: ZLFormSectionDescriptor)
func addFormSection(_ formSection: ZLFormSectionDescriptor, at index: Int)
// 删除 section
func removeFormSection(_ formSection: ZLFormSectionDescriptor)
func removeFormSection(at index: Int)
// 根据 tag 查找
func formSection(withTag tag: String) -> ZLFormSectionDescriptor?
func formRow(withTag tag: String) -> ZLFormRowDescriptor?
// 隐藏 / 显示变化后调用,触发差量刷新
func reloadVisibility()
// 同步某个 section 的 rows 后刷新(内部使用,外部一般不需要)
func syncAndReloadSection(_ formSection: ZLFormSectionDescriptor)
// 获取表单所有值(key = row.tag)
func formValues() -> [String: Any]
⚠️ 不要直接修改formSections。所有修改都应通过addFormSection/removeFormSection/reloadVisibility等方法,否则 DifferenceKit 的 source/target 会不一致,导致回退到reloadData()。
ZLFormSectionDescriptor.formSection()
ZLFormSectionDescriptor.formSection(title: "标题")| 属性 | 类型 | 说明 |
|---|---|---|
tag |
String |
唯一标识,diff 用 |
title |
String? |
section header 文字 |
footerTitle |
String? |
section footer 文字 |
headerHeight |
CGFloat |
header 高度,0 表示自动 |
footerHeight |
CGFloat |
footer 高度,0 表示自动 |
headerView |
UIView? |
自定义 header view |
footerView |
UIView? |
自定义 footer view |
isHiddenSection |
Bool |
是否隐藏整组(优先级高于 row 的 hidden) |
sectionBackgroundView |
UIView? |
整组背景 view |
sectionBackgroundInsets |
UIEdgeInsets |
背景 view 距离首行/末行的偏移 |
formRows |
[ZLFormRowDescriptor] |
所有 row(含隐藏) |
formDescriptor |
ZLFormDescriptor? |
反向引用 |
func addFormRow(_ formRow: ZLFormRowDescriptor)
func addFormRow(_ formRow: ZLFormRowDescriptor, at index: Int)
func removeFormRow(_ formRow: ZLFormRowDescriptor)
func removeFormRow(at index: Int)
func formRow(withTag tag: String) -> ZLFormRowDescriptor?// 必须使用工厂方法(init 已被禁用)
let row = ZLFormRowDescriptor.formRowDescriptor(tag: "name")// OC 必须使用类方法(alloc/new/init 已 NS_UNAVAILABLE)
ZLFormRowDescriptor *row = [ZLFormRowDescriptor formRowDescriptorWithTag:@"name"];| 属性 | 类型 | 说明 |
|---|---|---|
tag |
String |
唯一标识 |
title |
String? |
标题文本 |
value |
NSObject? |
数据值 |
placeholder |
String? |
占位提示 |
required |
Bool |
是否必填 |
disabled |
Bool |
是否禁用 |
height |
CGFloat |
固定高度,0 表示自动 |
isHiddenRow |
Bool |
是否隐藏该行 |
cellClass |
AnyClass? |
Cell 类(必须实现 ZLFormDescriptorCell 协议) |
cellProvider |
ZLCellProviderBlock? |
Cell 创建闭包(优先级高于 cellClass) |
valueMapperToDisplay |
((ZLFormRowDescriptor, Any?) -> Any?)? |
value 转显示文本的转换器 |
sectionDescriptor |
ZLFormSectionDescriptor? |
反向引用 |
forceUpdateFlag |
Int |
修改它会让 isContentEqual 返回 false,触发 cell 重新刷新 |
// 获取最终展示高度(外部 height > cell.cellHeight > automaticDimension)
func effectiveHeight() -> CGFloat
// 拿到关联的 cell(懒加载创建)
func cell() -> UITableViewCell自定义 cell 必须实现此协议(除 rowDescriptor 外其余都是 optional):
@objc public protocol ZLFormDescriptorCell {
var rowDescriptor: ZLFormRowDescriptor? { get set } // 必须
@objc optional func configure() // 初始化时调用一次
@objc optional func update() // 每次 cellForRow 调用
@objc optional func cellHeight(for rowDescriptor: ZLFormRowDescriptor) -> CGFloat
@objc optional func formDescriptorCellDidSelected(with tableView: UITableView)
}库内置基类,默认提供 titleLabel + detailLabel 的简单展示。自定义 cell 可继承它,也可以直接实现协议。
open class ZLFormBaseCell: UITableViewCell, ZLFormDescriptorCell {
public let titleLabel = UILabel()
public let detailLabel = UILabel()
open var rowDescriptor: ZLFormRowDescriptor?
open func configure() { /* 子类重写 */ }
open func update() { /* 子类重写 */ }
}// 添加 section(带动画)
let newSection = ZLFormSectionDescriptor.formSection(title: "新分组")
formDescriptor.addFormSection(newSection)
// 添加 row 到指定 section
let newRow = ZLFormRowDescriptor.formRowDescriptor(tag: "newRow")
newRow.title = "新行"
newSection.addFormRow(newRow)
// 删除
formDescriptor.removeFormSection(newSection)
newSection.removeFormRow(newRow)⚙️ 内部全部走 DifferenceKit diff,UI 自动带
.fade动画。
// 隐藏单行
nameRow.isHiddenRow = true
formDescriptor.reloadVisibility() // 触发 diff 刷新
// 隐藏整组(优先级高于 row)
infoSection.isHiddenSection = true
formDescriptor.reloadVisibility()🔑 优先级:
section.isHiddenSection = true时,该 section 下所有 row(无论是否isHiddenRow)都不展示。
formDescriptor.sortByTag = true
// 之后任何 add/remove 都会自动按 tag 排序
let row = ZLFormRowDescriptor.formRowDescriptor(tag: "a_first")
section.addFormRow(row)🔑 开启
sortByTag后,allSections和section.formRows数组本身的顺序会被更新(不只是展示时排序),diff 计算稳定,不会出现"加在末尾但显示在中间"的诡异闪动。
class MyCustomCell: ZLFormBaseCell {
override func configure() {
// 添加子视图、约束
}
override func update() {
titleLabel.text = rowDescriptor?.title
}
}
row.cellClass = MyCustomCell.selfrow.cellProvider = { rowDesc in
let cell = MyCustomCell(style: .default, reuseIdentifier: rowDesc.tag)
return cell
}✅ 当两者都设置时,
cellProvider优先,cellClass被忽略。两者都未设置时使用ZLFormBaseCell兜底。
// 固定高度
row1.height = 80
// 自动高度(不设 height,由 AutoLayout 撑开)
row2.height = 0 // 或不设📐 优先级:
row.height > cell.cellHeight(for:) > UITableView.automaticDimension。Header / Footer 同理:
section.headerHeight > 0时用固定值,否则automaticDimension。
let bgView = UIView()
bgView.backgroundColor = .white
bgView.layer.cornerRadius = 12
bgView.layer.shadowOpacity = 0.1
bgView.layer.shadowRadius = 4
bgView.layer.shadowOffset = CGSize(width: 0, height: 2)
section.sectionBackgroundView = bgView
section.sectionBackgroundInsets = UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)🎨 实现原理:
- 在
willDisplay cell/header/footer时计算该 section 所有可见 row 的 union rect- 更新背景 view frame,并对所有背景 view 调用
sendSubviewToBack,保证 cell 在背景之上- 删除 row/section 后内部调用
layoutAllSectionBackgroundViews(),背景 frame 自动收缩
⚠️ 不使用layer.zPosition,因为 tableView 在重布局时会重排子视图层级,必须每次显示时主动sendSubviewToBack。
如果某行的 value 改变了,但你希望 UI 重新走一次 cellForRowAt:
row.value = "新值" as NSObject
row.forceUpdateFlag += 1 // 触发 isContentEqual 返回 false
section.formDescriptor?.syncAndReloadSection(section)
⚠️ 不要直接让isContentEqual永远返回false,否则 DifferenceKit 会对所有 row 执行reloadRows(at:with:.fade)。由于本库每行持有同一个 cell 对象(不走 dequeue),fade 动画会导致 cellalpha异常,看起来像消失了。 内部已做处理:内容变化的 row 用.none动画手动 reload,insert/delete 仍带.fade。
如果在 Swift 文件中使用 OC 写的自定义 Cell:
YourApp-Bridging-Header.h:
#import "MyCustomCell.h"Build Settings → Objective-C Bridging Header 设置路径:
$(SRCROOT)/YourApp/YourApp-Bridging-Header.h
#import <ZLForm/ZLForm-Swift.h>
ZLFormDescriptor *desc = [[ZLFormDescriptor alloc] init];
ZLFormSectionDescriptor *sec = [ZLFormSectionDescriptor formSectionWithTitle:@"标题"];
ZLFormRowDescriptor *row = [ZLFormRowDescriptor formRowDescriptorWithTag:@"name"];为了防止 OC 直接 [[ZLFormRowDescriptor alloc] init],库提供了一个分类头:
#import "ZLFormRowDescriptor+Unavailable.h"
// 之后 alloc/new/init 在编译期就会报错只能用:
[ZLFormRowDescriptor formRowDescriptorWithTag:@"xxx"];Example/ 目录下包含多个示例控制器:
| 控制器 | 演示内容 |
|---|---|
ZLViewController |
主入口,基础表单 + 动态隐藏 section/row |
ZLFormSubmitViewController |
表单提交 + 取值 |
ZLFormSectionBackgroundViewController |
section 背景 view 动态适应 |
ZLFormAutoHeightViewController |
自动高度 + 固定高度混合 |
ZLFormTagSortDemoViewController |
tag 自动排序 + 动态插入/删除 |
运行方式:
cd Example
pod install
open ZLForm.xcworkspaceDifferenceKit 的 reload(using:with:setData:) 会分多个阶段执行 diff,每个阶段都需要数据源处于正确的中间状态。如果你提前把 formSections 改成最终状态,DifferenceKit 检测到不一致会回退到 reloadData() 整表刷新,所有动画失效。
// ❌ 错误
formSections = newData
tableView.reload(using: changeset, with: .fade) { _ in }
// ✅ 正确(库内部已这样实现)
let target = buildVisibleTarget()
tableView.reload(using: StagedChangeset(source: formSections, target: target), with: .fade) { [weak self] data in
self?.formSections = data
}formDescriptor.tableView = tableView 会接管这两个协议。如果你自己再实现并覆盖,会导致 numberOfRows 等返回错误数据,引发 EXC_BAD_ACCESS(访问无效内存)。
如果需要监听 tableView 事件,请通过 formDescriptor.delegate 协议回调,而不是直接占用 dataSource/delegate。
库内使用 Swift 4+ 的 observe(\.keyPath) 方式,deinit 时会自动移除观察,不需要在 dealloc 调 removeObserver。
OC 文件中无法使用 SnapKit。如果你写 OC Cell,请用 NSLayoutConstraint 或 Masonry。
会导致 DifferenceKit 对每个 row 执行 reload 动画,由于本库不走 dequeue(同一 cell 对象),fade 动画会让 cell alpha = 0 看起来消失。需要强制刷新某行时,请用 forceUpdateFlag += 1。
rowDescriptor 已经强引用 _cell,cell 内 rowDescriptor 属性是 weak 或受 row 生命周期约束的 strong(库内已处理)。自定义 cell 不要再额外 strong 引用 row 之外的 ZLForm 对象。
使用 UITableView.automaticDimension 时,cell 的 contentView 内所有子视图必须有完整的 top→bottom 约束链,否则会出现高度为 0 或异常。
// ✅ 正确
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().offset(-10) // 关键!
}tag 是 DifferenceKit 的 differenceIdentifier,同一表单内 row/section 的 tag 必须唯一,否则 diff 计算异常,可能导致动画错乱或崩溃。
新建的 section 没加进 descriptor 之前调用 addFormRow 不会触发 UI 更新(因为没关联 tableView)。先建好父子关系再操作 row,或者在加完后再调用 reloadVisibility()。
// ✅ 推荐
let section = ZLFormSectionDescriptor.formSection()
section.addFormRow(row1)
section.addFormRow(row2)
formDescriptor.addFormSection(section) // 最后一次性加入
// ⚠️ 也可以但会触发多次 diff
formDescriptor.addFormSection(section)
section.addFormRow(row1) // 触发 diff
section.addFormRow(row2) // 又触发 diffvalue 改变只是数据层变化。如需 UI 同步,要么 cell 内监听 row 属性(KVO),要么手动 forceUpdateFlag += 1 后调 syncAndReloadSection。
调用后 formSections 立即更新,tableView 的 section/row 数也同步变化。动画只是视觉过渡。如需在动画结束后做事,使用 CATransaction.setCompletionBlock。
section.sectionBackgroundView = bgView 后,库会自动管理它的添加/移除/布局。不要自己再 addSubview,否则会出现重复或位置错乱。
formSections 的类型是 Swift 泛型 [ArraySection<...>],OC 无法访问。需要遍历 section 请用 sectionDescriptors。
// ❌ 报错
self.formDescriptor.formSections;
// ✅ 正确
NSArray<ZLFormSectionDescriptor *> *sections = self.formDescriptor.sectionDescriptors;Q1:添加 row 时整张表都刷新了?
A:检查是否在 setData 之外提前修改了 formSections,或者你重写的 isContentEqual 总是返回 false。详见注意事项 1 / 5。
Q2:选中行时 EXC_BAD_ACCESS?
A:通常是 dataSource 被双重设置或 formRows 与 formSections.elements 行数不一致。移除你自己实现的 UITableViewDataSource,让 formDescriptor 接管。
Q3:隐藏 row 后背景 view 没缩小?
A:库内 performDiffUpdate 和 syncAndReloadSection 完成后会调用 layoutAllSectionBackgroundViews()。如果你自定义了刷新逻辑,记得也要调一下。
Q4:OC 中 [ZLFormRowDescriptor alloc] 不报错?
A:导入 ZLFormRowDescriptor+Unavailable.h 即可在编译期阻止。
Q5:自动高度 cell 显示高度为 0?
A:检查 cell 内约束是否完整。UITableView.automaticDimension 需要从 contentView.top 到 contentView.bottom 有完整约束链。
Q6:自定义 cell 没生效,显示成了默认样式?
A:检查 cellClass 是否设置正确,或者 cellProvider 是否返回了非 nil 的 cell。cellProvider 优先级高于 cellClass。
Q7:tag 排序后顺序不稳定?
A:确保所有 row 的 tag 唯一且字符串可比较。库内用 String < 排序。
Q8:编译报 unknown type name 'nonnull'?
A:这是 Swift 生成 -Swift.h 时的格式问题。把 #import <ZLForm/ZLForm-Swift.h> 从 .h 移到 .m 文件即可。或用条件编译:
#if __has_include(<ZLForm/ZLForm-Swift.h>)
#import <ZLForm/ZLForm-Swift.h>
#endifQ9:isHiddenRow 设置了但 UI 没变化?
A:必须显式调用 formDescriptor.reloadVisibility(),库不会自动 KVO 监听该属性。
Q10:能否在 cell 内部直接修改 rowDescriptor.value?
A:可以。但如果需要表单其他 row 联动(比如某行改变后另一行隐藏),需要在 cell 的 value 变化回调里调用 formDescriptor.reloadVisibility()。
Q11:删除当前正在编辑的 row 会崩溃吗?
A:库内部用 DifferenceKit 安全分阶段更新,不会崩。但如果 cell 内部还在用 rowDescriptor,请加 guard let row = rowDescriptor else { return }。
Q12:可以一个 tableView 关联多个 formDescriptor 吗? A:不可以。一个 tableView 只能由一个 formDescriptor 接管,否则 dataSource/delegate 互相覆盖。