LazyFish:简单的UIViewDSL轻量框架介绍

LazyFish:简单的UIViewDSL轻量框架介绍

self.view.arrangeViews {
    UILabel()
        .text("Hello World")
        .alignment(.center)
}

起点:

SwiftUI的简洁的表达方式:

SwiftUI使用简洁的表达方式描绘UI布局,是未来的趋势。

虽说iOS13就可以用,但SwiftUI的也在新版本迭代中对iOS13的功能兼容性就不太好,猜测实际可用的版本应该会在iOS14、15以上。

ResultBuilder提供灵活的组件序列生成

Swift5.4提供了自行实现resultBuilder的可能性(在更早版本5.1是命名为functionBuilder的隐藏功能)
Swift官方的ResultBuilder介绍

想到SwiftUI中的View.body就说ResultBuilder的一种实现,那么能否把UIView和ResultBuilder结合,实现像SwiftUI那样的简洁表达
(暂时忽略Combine框架里@State@Binding等相关的高级功能,涉及到View的刷新)

人家SwiftUI这样写:

VStack {
    Text("Hello") //.font(...)
    Text("World")
}

那么我用UIView写出类似的表达(假设):

UIStactView(.vertical) {
    UILabel("Hello") //.font(...)
    UILabel("World")
}

目标

  • 支持UIView的声明式布局
  • 支持低版本系统例如iOS9
  • 默认排版行为不一定与SwiftUI一致
  • 能投入使用

ResultBuilder使用

假设我有一个方法,给array添加元素:

mutating func appendContents(other: [Element]) {
    self.append(contentsOf: other) // 这里是原生的添加元素方法
}
// 传入一个数组
array.appendContents([a, b, c, d])

要使用这个方法需要传入一个数组,感觉不太灵活

将other参数改为() -> [Element]类型,并使用ResultBuilder修饰:

mutating func appendContents(@ResultBuilder<Element> other: () -> [Element]) {
    let otherArr = other()
    self.append(contentsOf: otherArr)
}
// 调用会变成这样!
array.appendContents {
    a
    b
    c
    d
}

甚至加上if..else..for..in..

array.appendContents {
    if somevalue == 1 {
        a
    } else {
        b
    }
    for i in 0..<100 {
        c
    }
    d
}

具体的ResultBuilder能兼容什么写法,取决于实现了哪些BuildBlock函数。

最基本的ResultBuilder需要实现一个buildBlock(...)方法,以下的实现将所有元素组合成一维数组返回,例如返回[UIView][String]等。为了方便,将ResultBuilder写成泛型ResultBuilder<T>,不局限于UIView

@resultBuilder public struct ResultBuilder<MyReturnType> {
    public static func buildBlock(_ components: [MyReturnType]...) -> [MyReturnType] {
        let res = components.flatMap { r in
            return r
        }
        return res
    }
}

其他复杂功能可酌情添加:

extension ResultBuilder {
    // MARK: 处理空白block
    static func buildOptional<T>(_ component: [T]?) -> [MyReturnType]
    
    // MARK: 处理不包含else的if语句
    static func buildOptional(_ component: [MyReturnType]?) -> [MyReturnType]
    
    // MARK: 处理每一行表达式的返回值
    static func buildExpression(_ expression: MyReturnType) -> [MyReturnType]
    static func buildExpression(_ expression: MyReturnType?) -> [MyReturnType]
    static func buildExpression(_ expression: Void) -> [MyReturnType]
    static func buildExpression(_ expression: Void?) -> [MyReturnType]
    static func buildExpression(_ expression: [MyReturnType]) -> [MyReturnType]
    
    // MARK: Available API
    static func buildLimitedAvailability(_ component: [MyReturnType]) -> [MyReturnType]
    
    // MARK: 处理for循环
    static func buildArray(_ components: [[MyReturnType]]) -> [MyReturnType]
    
    // MARK: 处理if...else...(必须包含else)
    static func buildEither(first component: [MyReturnType]) -> [MyReturnType]
    static func buildEither(second component: [MyReturnType]) -> [MyReturnType]
}

假设要实现以下例子,表达一个视图层级:

parent1 {
    child1
    child2
    child3 {
        grandchild1
        grandchild2
    }
}

child1、child2、child3这3个元素将被组合为[child1, child2, child3], 最终作为parent1subviews加入;grandchild1、2同理

给UIView拓展几个方法,可以直接加入[UIView]作为subview,或者init时就加入[UIView]

extension UIView {
    @discardableResult func arrangeViews(@ResultBuilder<UIView> content: () -> [UIView]) -> Self {
        let views = content()
        for i in views {
            // 如果是self是stackview使用addArrangedSubview
            self.addSubview(i)
        }
        /// 其他细节
        return self
    }

    convenience init(@ResultBuilder<UIView> content: () -> [UIView]) {
        self.init()
        self.arrangeViews(content)
    }
}

// 调用
view.arrangedSubviews {
    child1
    child2
    child3 {
        grandchild1
        grandchild2
    }
    ...
}

UIView {
    child1
    child2
    child3 {
        grandchild1
        grandchild2
    }
    ...
}

这样我们就通过ResultBuilder实现了视图层级的创建

布局规则

以上内容已经完成了ResultBuilder的使命,UIView也确实已加入到superview中,但是还没布局,如何布局?

目前已实现的布局规则比较简单:

alignment:

  • superview对齐(top、leading、bottom、trailing、allEdges、centerX、centerY、center等)

frame:

  • 大小等于常量(width、height = constant
  • 大小等于变量(width、height = Binding<CGFloat>

padding:

  • 内边距

offset:

  • 偏移

paddingoffset,比较取巧的用了一个额外的容器去实现(使用offset时,不确保view可以被正确点击)

目前暂无subviews之间的相互约束规则与实现,仅能通过stack或其他方式进行排列

举个例子

使用举例,展示一个文本和输入框,注意.alignment()、.padding()、.frame()可以重复改写:

@State var text: String = "abc"

override func viewDidLoad() {
    super.viewDidLoad()
    self.view.arrangeViews {
        UIView() {
            UIStackView(axis: .vertical, spacing: 10) {
                UILabel().text("your input:")
                UILabel().text(binding: self.$text)
                UITextField().text(binding: self.$text).borderStyle(.roundedRect)
            }
            .padding(top: 10, leading: 10, bottom: 10, trailing: 10)
            .alignment(.allEdges)
        }
        .borderWidth(1)
        .borderColor(.black)
        .frame(width: 200)
        .alignment(.top, value: 160)
        .alignment(.centerX, value: 0)
    }
    // Do any additional setup after loading the view.
}

以上就是基本的view声明与排版

关于view、label、button的一些常用属性修改,也封装成可以链式调用的方法,例如UILabel:

UILabel()
    .text("abc")
    .textColor(.red)
    .font(.systemFont(ofSize: 14, weight: .semibold))
    .backgroundColor(.yellow)
    .border(width: 1, color: .green)

有需要可自行拓展,确保返回Self类型即可

如何刷新页面或元素

如果仅是上文提到的内容,那么所有视图都是静态的,很难有改动的可能

参考SwiftUI使用了@State@Binding修饰符,在修改他们的所修饰的属性时,页面就会自动刷新,具体到文本内容、视图是否展示、数量等

那么我也需要实现一个自己的@State@Binding,可以在属性被修改时做某些事

那么@propertyWrapper就可以很好的实现这个需求,可以参考Swift官方的propertyWrapper介绍

实现@State和Binding?

要支持泛型,且改动时要触发动作,改写didSet,加上observers的数组(个人认为用class比较靠谱)

@propertyWrapper public class State<T> {
    public var wrappedValue: T {
        didSet {
            let newValue = wrappedValue
            let oldValue = oldValue
            for obs in self.observers {
                let changed = Changed(old: oldValue, new: newValue)
                obs(changed)
            }
        }
    }
    public init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
    }

    public typealias ObserverHandler = (Changed<T>) -> Void
    private var observers = [ObserverHandler]()
    public func addObserver(observer: @escaping ObserverHandler) {
        self.observers.append(observer)
        let changed = Changed(old: wrappedValue, new: wrappedValue)
        observer(changed)
    }

    public struct Changed<T> {
        let old: T
        let new: T
    }
}

这时候我们可以使用@State修饰属性:

@State var text: String = "abc"

编译器会自动给我们生成一对属性(内部的setget仅为个人猜测):

var text: String {
    set(newValue) {
        _text.wrappedValue = newValue
    } 
    get {
        _text.wrappedValue
    }
}
var _text: State<String>

也就是说,修改text,即修改_textwrappedValue,即触发observers调用

那么给UILabel添加一个接收这个_text的方法,就直接可以实现动态修改label文本了:

extension UILabel {
    func text(state stateText: State<String>?) -> Self {
        stateText?.addObserver { [weak self] changed in
            self?.text = changed.new
        }
        return self
    }
}

// 调用
UILabel().text(state: _text)

个人认为传入_text不好看,像$text#text?之类的就看起来比较厉害

还真有办法让编译器额外生成一个$text属性,给State添加projectedValue,暂且命名为Binding类型(此Binding非彼Binding):

extension State {
    public var projectedValue: Binding<T> {
        return Binding(wrapper: self)
    }
}

public struct Binding<T> {
    var wrapper: State<T>
}
var text: String
var _text: State<String>
var $text: Binding<String>

修改UILabel:

extension UILabel {
    func text(binding bindingText: Binding<String>?) -> Self {
        bindingText?.wrapper.addObserver { [weak self] changed in
            self?.text = changed.new
        }
        return self
    }
}

// 调用
UILabel().text(binding: $text)

这里和SwiftUI不一样。SwitUI只需Text(text),完全不用考虑传StringState<String>还是Binding<String>

  • 注:源码不断在进化中,可能与文章内容有出入,具体可看源码里的Readme.md

LazyFish源码地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容