SwiftUI 组件创建工具集,提供可配置的视图元素、组件模型及设置界面,帮助快速构建 iOS 应用界面和 Widget 小组件。
本项目开发过程中,凡是会影响后续实现判断的内容,都需要实时补充到根目录 README.md,避免后续重新开发时重复踩坑。
需要记录的内容包括:
- 功能要求:例如某个控件应该支持哪些设置项、某类文本是否应该当作纪念日处理。
- 设计思路:例如工具区与预览区的刷新方向、元素设置 Section 的职责边界。
- 功能要点:例如
layout和addGR的 modifier 顺序、预览拖动结束后不能重建整个工具区。 - 关键坑点:例如 class 内部属性变化不会自动触发 SwiftUI 刷新、恢复 original 的捕获时机。
记录原则是:只要一个结论会影响未来如何写代码、如何拆类、如何处理状态同步,就应该写进 README;普通临时代码细节可以只写在代码注释里。
- 组件配置模型:支持文本、边框、背景、字体、颜色等多种组件类型的配置
- SwiftUI 视图组件:提供
CJSquareView、CJToolView等可复用视图 - 设置行组件:内置字体、颜色、背景、边框等常用设置界面
- Widget 支持:完整的小组件配置和渲染能力
- 扩展工具:颜色、字符串、图片等常用扩展
CJViewCreatorSDK/
├── CJViewElement-Swift/ # 核心库
│ ├── Extension/ # Swift 扩展(Color、String、Image)
│ ├── ElementModel/ # 基础模型(布局、背景、边框等)
│ ├── ComponentConfigModel/ # 组件配置模型
│ ├── CommonSettingRow/ # 通用设置行组件
│ └── SquareResultView/ # 方形结果视图
├── CJViewCreatorDemo/ # 演示项目
│ └── ViewCreatorPage/ # 视图创建页面示例
└── LICENSE # MIT 许可证
在 Podfile 中添加:
pod 'CJViewElement-Swift', :path => 'path/to/CJViewElement-Swift'或远程依赖:
pod 'CJViewElement-Swift'然后执行:
pod installimport SwiftUI
import CJViewElement_Swift
struct ContentView: View {
@State var model: CQWidgetModel = CQWidgetModel("your_template_id")
@State var backgroundModel: CJBoxDecorationModel = CJBoxDecorationModel()
@State var anyComponentModel: CJAllComponentConfigModel = CJAllComponentConfigModel()
@State var borderModel: CJBorderDataModel = CJBorderDataModel()
@State var dealUpdateUI: Int = 0
@State var toolUpdateUI: Int = 0
var body: some View {
CJSquareView(
backgroundModel: $backgroundModel,
anyComponentModel: $anyComponentModel,
borderModel: $borderModel,
widgetFamily: .systemMedium,
dealUpdateUI: $dealUpdateUI,
toolUpdateUI: $toolUpdateUI
)
}
}CJToolView(model: $model, toolUpdateUI: $toolUpdateUI, onChangeOfElementModel: { newModel in
// 处理组件模型变更
backgroundModel = newModel.backgroundModel
anyComponentModel = newModel.anyComponentModel
borderModel = newModel.borderModel
dealUpdateUI += 1
})// 文本组件
let textComponent = CJTextComponentConfigModel()
textComponent.data.text = "Hello World"
textComponent.layout.font = CJFontDataModel(id: "1", name: "System", egImage: nil)
// 背景组件
let backgroundModel = CJBoxDecorationModel(
colorModel: CJTextColorDataModel(solidColorString: "#FF5500")
)
// 边框组件
let borderModel = CJBorderDataModel(id: "border_1", imageName: "border_image")| 组件 | 说明 |
|---|---|
CJSquareView |
方形视图容器,支持背景、组件、边框配置 |
CJToolView |
工具设置视图,包含字体、颜色、背景等设置项 |
CJFontSettingRow |
字体设置行 |
CJTextColorSettingRow |
文本颜色设置行 |
CJBackgroundSettingRow |
背景设置行 |
CJBorderSettingRow |
边框设置行 |
CJDatesSettingView |
日期设置视图 |
CJTextsSettingView |
文本设置视图 |
文本预览里选中元素后,需要给真实文本边界加上编辑框和角按钮。这里有一个容易踩坑的点:SwiftUI 的 modifier 顺序会影响 overlay、gesture 和 offset 拿到的布局区域。
文本布局的正确顺序应该是:
Text
-> frame(width/height) + 字体/颜色/背景/渐变
-> 编辑态修饰,如 addGR
-> offset(left/top)如果把 .addGR(...) 写在 .layout(...) 前面:
Text(text)
.property(layout)
.addGR(...)
.layout(layout).addGR(...) 拿到的是原始 Text 的 intrinsic size,而不是 layout.width/layout.height 后的内容盒子,边框会包在文字本身上,不会贴合组件边界。
如果把 .addGR(...) 写在 .layout(...) 后面:
Text(text)
.property(layout)
.layout(layout)
.addGR(...)此时 .layout(...) 已经执行了 offset(left/top)。外层再加 overlay/gesture 时,拿到的是 offset 参与后的复合布局区域,边框位置容易和实际视图边界不一致。
因此 layout 提供了一个装饰闭包,用来把额外修饰插入到“内容盒子和持久几何变换之后、位置偏移之前”:
Text(text)
.property(layout)
.layout(layout) { content in
content.addGR(...)
}普通展示仍然直接使用原来的写法:
Text(text)
.property(layout)
.layout(layout)更推荐在业务视图里使用 CJTextView,让它统一处理 property、普通颜色/渐变分支和 layout:
// 普通展示
CJTextView(text: $text, layoutModel: $layout)
// 编辑态展示
CJTextView(text: $text, layoutModel: $layout) { content in
content.addGR(...)
}CJTextView 的装饰闭包只是把编辑能力插入到 layout 的正确位置;不需要编辑能力时,它仍然走普通 layout 流程。
持久旋转在普通态和编辑态的处理不同:
- 普通态不传
decorateContent,由layout自己应用rotationDegrees后再执行left/top偏移。 - 编辑态传
decorateContent,由addGR(baseRotation:)接管rotationDegrees,让内容和编辑边框一起旋转;layout只负责最后的left/top偏移。
预览区不要在 CJTextsView 这类列表容器里直接手写这两个分支,而是封装成预览专用元素视图,例如 CJPreviewTextElementView:
CJTextsView只负责遍历文本模型、传入isEditing和手势结束回调。CJPreviewTextElementView内部负责判断普通态/编辑态。- 普通态使用不带
decorateContent的CJTextView初始化,让layout自己应用持久旋转。 - 编辑态使用带
decorateContent的CJTextView初始化,并在闭包里插入addGR(baseRotation:)。
这样做的重点不是减少代码,而是把容易误用的 CJTextView { content in ... } 入口收在预览元素内部。以后如果图片、贴纸、日期子文本也需要编辑框和手势,也应该各自有对应的 CJPreview...ElementView 或统一的预览元素包装层,而不是让列表容器直接拼装底层展示视图和编辑手势。
不要为了减少这点重复,在普通态的 decorateContent 里手动写 content.rotationEffect(.degrees(layout.rotationDegrees))。那会把 layout 内部“谁负责持久旋转”的细节泄漏到业务视图里,后面维护时很容易误改。
不要把持久 rotationDegrees 放在 addGR 外层再执行。否则大角度旋转后,addGR 拖动时的临时 offset 会被外层旋转影响;放手写回屏幕坐标 translation 时,边框和内容的坐标系不一致,就会出现位置跳动或边框不跟着旋转的问题。
另外,addGR 内部不能通过给 content 加 padding 来扩大角按钮命中区域。padding 会参与布局,导致选中文本时位置发生变化。角按钮的外扩命中区域应该放在 overlay 层处理,边框则应贴着原始内容盒子绘制。
CJBaseLayoutModel 承载所有可视元素共有的几何布局属性。当前包含 left/top/width/height/scale/rotationDegrees,缩放和旋转都放在 CJBaseLayoutModel,而不是只放在 CJTextLayoutModel。
几何变换字段:
scale: CGFloat = 1rotationDegrees: CGFloat = 0
原因:
- 缩放和旋转不是文本专属能力,未来图片、贴纸、日期子文本等元素也可能需要。
addGR的拖动、缩放、旋转属于同一类几何变换,持久化位置尺寸时也应该能持久化缩放和旋转。- 放在 base layout 后,
CJElementLayoutStyleSettingSection这类通用元素设置区以后可以统一扩展“缩放/旋转”设置,而不用为每种元素重复加字段。 - 对文本来说,手势缩放不只是改变
width/height,还应该让文字视觉大小按比例变化。因此文本渲染时使用fontSize * scale,保留fontSize作为原始字体大小。
但不要把所有可能的属性都塞进 CJBaseLayoutModel。Base layout 只放所有元素都可能共享的几何/容器属性,例如位置、尺寸、缩放、旋转、圆角、背景。文本内容、字体、字体颜色、行数等仍然属于 CJTextLayoutModel 或文本样式设置;图片资源、裁剪方式等未来应该属于图片自己的 layout/data。
缩放/旋转持久化必须同步更新这些地方:copy、Equatable、Codable、layout 修饰符、CJGRTransformResult 写回逻辑,以及位置尺寸设置区是否展示缩放/旋转输入。
CJPositionSizeSettingRow 虽然保留了旧名字,但职责已经是“几何设置”:除了 left/top/width/height,还必须展示和设置 scale/rotationDegrees。位置、尺寸、旋转按整数展示;缩放倍数允许保留小数。
因为这个 Row 已经是几何设置,所有接收 onChangeOfPositionSize 的地方都不能只回写 left/top/width/height。恢复、手动输入、预览手势同步时,都必须一起回写 scale/rotationDegrees,否则会出现重置后位置尺寸恢复了,但字体大小或旋转仍然保留旧状态的问题。
CJLayoutInputView 负责编辑一组 layout 几何属性,内部的 CJLayoutPropertyInputView 负责编辑单个属性。加减按钮和文本输入框不能只依赖 .onChange(of:) 观察绑定值。当前 layout 是 class,CJPositionSizeSettingRow 里的 @State currentLayout 持有的是对象引用;修改 currentLayout.left 这类内部属性时,引用本身没有变化,SwiftUI 可能不会触发 .onChange。因此按钮点击和输入框 setter 必须主动调用提交方法,并在里面执行外层 onChange,确保工具区操作能立刻通知预览区刷新。
当 addGR 提供 onTransformEnded 回调时,手势内部的拖动、缩放、旋转只是临时预览状态,结束后要写回 layout,并清掉内部临时状态。如果外部已经把 deltaScale 写入 layout.scale、把 deltaRotation 写入 layout.rotationDegrees,CJGRViewModifier 内部就不能再累计自己的 scale/rotation,否则会出现双重缩放或双重旋转。
ToolView 和预览区都可能修改同一份组件模型,但它们的刷新方向不能混用同一个信号:
- 工具区修改模型时,只通知预览区刷新。
- 预览区手势修改模型时,只通知工具区刷新。
目前页面里用两个刷新触发值表达这个方向:
dealUpdateUI:工具区修改模型后递增,预览区监听它并重新读取模型。toolUpdateUI:预览区拖动、缩放等手势修改模型后递增,工具区的布局设置区域监听它并重新读取 layout。
这样做的原因是:CJAllComponentConfigModel 和 layout model 都是 class,很多修改是直接改内部属性。SwiftUI 不会因为 class 内部属性变化自动刷新依赖视图,所以需要额外的刷新触发值。但这个触发值必须区分来源,否则会形成“预览区更新位置 -> 工具区数值更新 -> 工具区又触发位置更新 -> 预览区再刷新”的回声循环。
预览区手势结束后不要重建整个 ToolView 或整个 CJTextsSettingView。拖动只是在修改当前元素的位置尺寸,不代表用户切换了编辑对象。正确做法是只让 CJElementLayoutStyleSettingSection 这类布局设置区域重新初始化,使位置数值读取最新 layout,同时保留列表 currentIndex、分段选中项等编辑态。
预览区自身也需要一个局部刷新触发值。拖动结束时,addGR 的临时位移会清零,同时新的 left/top 已经写入 layout;但 layout 是 class 内部属性,SwiftUI 不一定会立刻重新执行当前 CJTextView 的布局。如果不刷新当前文本子树,就会出现 ToolView 数值已经变化,而预览图里的文本又回到拖动前位置的问题。这个刷新只用于预览内部重新按新 layout 布局,不要复用 dealUpdateUI 做全量刷新。
同理,工具区修改位置、尺寸、字体、颜色等 layout/data 后,dealUpdateUI 到达预览区时,预览区除了重新读取模型,也要递增自己的局部刷新触发值,让当前文本子树立刻按新的 class 内部属性重建。这个动作只能发生在预览内部,不能顺手递增 toolUpdateUI,否则会形成工具区 -> 预览区 -> 工具区的反向刷新。
addGR 内部同时组合了拖动、缩放、旋转手势。手势结束时可能会产生没有实际变化的空变换,例如 translation = .zero、scale = 1、rotation = 0。这类空变换不能继续触发 layout 写回、预览局部刷新或工具区刷新,否则放手瞬间会因为多次无意义重建产生抖动。
后续新增类似能力时也按这个原则处理:谁发起修改,就只通知对侧刷新;不要让接收方再用同一个刷新信号反向触发发起方。
【Feature】位置与尺寸的“恢复”动作仍需继续梳理。注意:恢复逻辑里的 original 值应该在进入编辑会话时捕获,不能在每次刷新工具区时被当前值覆盖。否则 CJPositionSizeSettingRow 重新 init 后,original 会变成已经修改过的值。
由于 CJAllComponentConfigModel 是 class(引用类型),当 CJToolView 修改 model.anyComponentModel 内部属性时,实际上修改的是同一个对象实例。SwiftUI 的 @Binding 对于 class 类型的内部属性变化不会自动触发视图刷新,因为 Binding 只检测到引用地址没变。
详情请看: SwiftUIRefreshSolutions.md
子控件 SettingRow 的设计原则见 Setting Row State Design.md
- iOS 17.0+
- Swift 5.0+
- Xcode 15.0+
MIT License,详见 LICENSE 文件。
dvlproad