UIStackView 的另类玩法(三)

前文中,我们进行了一些重构,引入了面向协议编程范式和依赖注入设计模式,使代码变得更加解耦和易用。在本文中,我们将继续添加子控件类型,引入一种滑动子控件,并且在其内部实现递归展示子控件的能力。

在日常需求开发中,我们经常需要对界面中一些元素的高度做出限制。对于一组控件,我们会设置一个最大显示高度。当所展示的内容未超过限制高度时,根据内容的实际显示高度进行布局;当内容超过限制高度时,通过滑动来显示内容。

要实现这个功能,我们需要继续拆解需求。首先,要实现滑动功能,需要引入 UIScrollView,并向其添加子控件。其次,需要对 UIScrollView 的约束进行精确设置,使其在未超过限制高度时无法滑动。

将所有子控件放进 UIScrollView

我们可以将 ElementStackView 视为一个容器,然后在容器外部添加一个 UIScrollView。接着,设置 ElementStackView 的约束,使其内容的高度撑起 UIScrollView。同时,为 UIScrollView 设置最大高度。这样,当所展示的内容未超过限制高度时,根据内容的实际显示高度进行布局;当内容超过限制高度时,通过滑动来显示内容。

// FILE: StackViewController.swift

class StackViewController: UIViewController {
    // ....

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        // 1??
        let scrollView = UIScrollView()
        view.addSubview(scrollView)
        scrollView.snp.makeConstraints { make in
            
            // 2??
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(20).priority(.low)
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-20).priority(.low)
            make.left.equalTo(view.safeAreaLayoutGuide.snp.left).offset(20)
            make.right.equalTo(view.safeAreaLayoutGuide.snp.right).offset(-20)
            
            // 3??
            make.centerY.equalTo(view.safeAreaLayoutGuide.snp.centerY)
            make.height.lessThanOrEqualTo(150)
        }
        
        scrollView.addSubview(stackView)
        stackView.snp.makeConstraints { make in
            // 4??
            make.edges.equalTo(scrollView.contentLayoutGuide.snp.edges)
            make.width.equalToSuperview()
        }
        
        stackView.addArrangedElements(loginElementList())
    }
    
    // ....
}

为了实现此功能,我们对视图的约束做如下修改[1]

  1. 增加一个 UIScrollView,并将 ElementStackView 添加为其 subview。
  2. 设置 UIScrollView 的约束,使其四周距离 Safe Area 边缘 20px。通过这种方式,我们确保了 UIScrollView 不会被设备的刘海、圆角、状态栏或底部指示器等界面元素遮挡,同时还能适应不同设备的安全区域变化。
  3. 为 UIScrollView 设置一个最大高度且垂直居中的约束。当内容超过这个最大高度(150px)时,UIScrollView 将允许滚动。
  4. 设置 ElementStackView 的约束,使其边缘等于 UIScrollView 内容的边缘,并且宽度等于 UIScrollView 的宽度。这样设置可以确保 ElementStackView 的内容能够撑起 UIScrollView,并且 ElementStackView 的宽度与 UIScrollView 相同。

当我们设置最大高度不超过 150px 时,UIScrollView 可以滚动,实际效果如下:


横屏可滑动的效果

竖屏可滑动的效果

设置最大高度大于实际内容高度,UIScrollView 不可滚动:


不可滑动的效果

以上实现方法具备了基本的高度控制能力,但只适用于整体的 ElementStackView。当我们需要精确控制部分子控件的高度时,这种方法就不再适用。

新增一种使用 UIScrollView 的子控件类型

如需更精确地限制每个子控件的高度,我们需要考虑引入新的子控件类型。通过向界面添加 UIScrollView 子控件,并将需要高度控制的子控件添加到 UIScrollView 内部。
于是我们接下来要解决的问题就变成了:如何添加一个可滑动的子控件,并递归地向其内部添加其他子控件。

// FILE: StackViewExtention.swift

enum ElementType {
    // ....
    
    // 1??
    /// 可滚动容器
    /// - Parameters:
    ///   - height: 容器高度,传入大于 0 的值表示显式设置控件的高度为 `height`;传入 0 表示不显式指定控件高度,由 `elements` 实际高度撑起此控件。
    ///   - elements: 容器中的元素列表
    case scrollableContainer(height: CGFloat, elements: [ElementType])
}
// FILE: StackViewController.swift

class StackViewController: UIViewController {

    // ....

    func loginElementList() -> [EType] {
        return [
            .segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
            .spacer(height: 15),
            
            // 2??
            .scrollableContainer(height: 150, elements: [
                .commonInput(label: "User Name: ", placeHolder: "Email/Phone/ID", onTextChanged: { text in
                    print("User Name: \(String(describing: text))")
                }),
                .spacer(height: 100),
                .commonInput(label: "Password: ", placeHolder: "Password", onTextChanged: { text in
                    print("Password: \(String(describing: text))")
                }),
                .spacer(height: 100),
                .checker(title: "记住用户名", checked: false, onTapped: { checked in
                    print("checked: \(checked)")
                }),
                .spacer(height: 100),
            ]),
            .spacer(height: 15),
            .button(title: "登录", onTapped: nil)
        ]
    }
    
    // ....
}
// FILE: ConcreteElementGenerator.swift

struct ConcreteElementGenerator: ElementGenerator {

    // ....
    
    func elementView(from element: EType) -> UIView {
        switch element {
        // ....
        case let .scrollableContainer(height: height, elements: elements):
            return createScrollable(height: height, elements: elements)
        }
    }
    
    // ....
    
    func configureView(_ view: UIView, for element: EType) {
        switch element {
        
        // ....
        
        case .scrollableContainer(height: let height, elements: _):
            // 3??
            if height > 0 {
                view.snp.updateConstraints { make in
                    make.height.equalTo(height)
                }
            }
        default: break
        }
    }
}

private extension ConcreteElementGenerator {
    // ....
    
    // 4??
    func createScrollable(height: CGFloat, elements: [ElementType]) -> UIScrollView {
        let scrollView = UIScrollView()
        
        // ....
        
        scrollView.addSubview(stackView)
        stackView.snp.makeConstraints { make in
            // 5??
            make.edges.equalTo(scrollView.contentLayoutGuide.snp.edges)
            make.width.equalToSuperview()
            make.height.equalToSuperview().priority(.low)
        }
        stackView.addArrangedElements(elements)
        
        return scrollView
    }
    
    // ....
}

为了递归地添加子控件,我们在代码中做了如下修改[2]

  1. ElementType 新增一种子类型,表示可滚动容器。
  2. 在 View Controller 中,我们使用新添加的 scrollableContainer 子控件。第一个参数 height 定义了控件的高度,如果设为 0,则不明确指定高度,而由第二个参数 elements 的实际内容决定;如果设为大于 0 的值,则明确指定了控件的高度。具体的实现逻辑见3??。
  3. 根据2??中传入的参数,设置控件高度。
  4. scrollableContainer 子控件的创建过程包括:首先构建一个 UIScrollView,接着添加一个 ElementStackView,最后将 elements 添加到 ElementStackView 中,从而实现递归添加子控件的功能。
  5. 设置 ElementStackView 的边缘等于 UIScrollView 的内容。

以下是多种场景下的实际效果:

调用代码 场景 效果
1?? 不指定 scrollableContainer 高度
内部控件高度不超过屏幕
2?? 不指定 scrollableContainer 高度
内部控件高度超过屏幕
3?? 显式指定 scrollableContainer 高度
小于内部控件高度
4?? 显式指定 scrollableContainer 高度
大于内部控件高度
// 1?? 不指定 scrollableContainer 高度,内部控件高度不超过屏幕
func loginElementList() -> [EType] {
    return [
        .segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
        .spacer(height: 15),
        .scrollableContainer(height: 0,  // 不指定 scrollableContainer 高度
                             elements: [ // 内部控件高度不超过屏幕
                                .commonInput(label: "User Name: ", placeHolder: "Email/Phone/ID", onTextChanged: { text in
                                    print("User Name: \(String(describing: text))")
                                }),
                                .spacer(height: 10),
                                .commonInput(label: "Password: ", placeHolder: "Password", onTextChanged: { text in
                                    print("Password: \(String(describing: text))")
                                }),
                                .spacer(height: 10),
                                .checker(title: "记住用户名", checked: false, onTapped: { checked in
                                    print("checked: \(checked)")
                                }),
                                .spacer(height: 10),
                             ]),
        .spacer(height: 15),
        .button(title: "登录", onTapped: nil)
    ]
}

// 2?? 不指定 scrollableContainer 高度,内部控件高度超过屏幕
func loginElementList() -> [EType] {
    return [
        .segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
        .spacer(height: 15),
        .scrollableContainer(height: 0,  // 不指定 scrollableContainer 高度
                             elements: [ // 内部控件高度超过屏幕
                                .commonInput(label: "User Name: ", placeHolder: "Email/Phone/ID", onTextChanged: { text in
                                    print("User Name: \(String(describing: text))")
                                }),
                                .spacer(height: 300),
                                .commonInput(label: "Password: ", placeHolder: "Password", onTextChanged: { text in
                                    print("Password: \(String(describing: text))")
                                }),
                                .spacer(height: 300),
                                .checker(title: "记住用户名", checked: false, onTapped: { checked in
                                    print("checked: \(checked)")
                                }),
                                .spacer(height: 300),
                             ]),
        .spacer(height: 15),
        .button(title: "登录", onTapped: nil)
    ]
}

// 3?? 显式指定 scrollableContainer 高度,小于内部控件高度
func loginElementList() -> [EType] {
    return [
        .segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
        .spacer(height: 15),
        .scrollableContainer(height: 200, // 显式指定 scrollableContainer 高度
                             elements: [  // 小于内部控件高度
                                .commonInput(label: "User Name: ", placeHolder: "Email/Phone/ID", onTextChanged: { text in
                                    print("User Name: \(String(describing: text))")
                                }),
                                .spacer(height: 100),
                                .commonInput(label: "Password: ", placeHolder: "Password", onTextChanged: { text in
                                    print("Password: \(String(describing: text))")
                                }),
                                .spacer(height: 100),
                                .checker(title: "记住用户名", checked: false, onTapped: { checked in
                                    print("checked: \(checked)")
                                }),
                                .spacer(height: 100),
                             ]),
        .spacer(height: 15),
        .button(title: "登录", onTapped: nil)
    ]
}

// 4?? 显式指定 scrollableContainer 高度,大于内部控件高度
func loginElementList() -> [EType] {
    return [
        .segment(items: ["登录", "注册"], defaultIndex: 0, onTapped: nil),
        .spacer(height: 15),
        .scrollableContainer(height: 200, // 显式指定 scrollableContainer 高度
                             elements: [  // 大于内部控件高度
                                .commonInput(label: "User Name: ", placeHolder: "Email/Phone/ID", onTextChanged: { text in
                                    print("User Name: \(String(describing: text))")
                                }),
                                .spacer(height: 10),
                                .commonInput(label: "Password: ", placeHolder: "Password", onTextChanged: { text in
                                    print("Password: \(String(describing: text))")
                                }),
                                .spacer(height: 10),
                                .checker(title: "记住用户名", checked: false, onTapped: { checked in
                                    print("checked: \(checked)")
                                }),
                                .spacer(height: 10),
                             ]),
        .spacer(height: 15),
        .button(title: "登录", onTapped: nil)
    ]
}

通过上述修改,调用者只需简单调整 loginElementList 函数中的代码,即可轻松改变界面风格。这样的修改不仅提高了代码的可维护性,也使得界面风格的变化变得更加灵活和便捷。调用者只需根据具体需求,调整loginElementList函数中的代码,比如修改间距、更换按钮样式等,就能够实现不同的界面风格。这种灵活性和可定制性使得该代码更适合应对不同的用户需求和界面设计要求:

  • 代码复用和??榛和ü?UIScrollView 作为子控件的一种,调用方可以在不同的界面中重用相同的滚动视图子控件。这种??榛姆椒蚧舜氲奈ず透?,因为共同的样式和行为被封装在可复用的组件中。
  • 简化的界面更新:当需要更改界面样式时,调用方只需对 ElementStackView 或其子控件的样式进行修改。由于 ElementStackView 的灵活性,这些更改可以快速反映在整个界面上,而无需对每个子控件单独进行更新。
  • 样式和逻辑分离:通过将样式代码(如颜色、字体、间距等)与业务逻辑代码分开,调用方可以更容易地调整界面的内容和外观。这种分离也使得设计师和开发者能够更加协作地工作。

通过使用 ElementStackView 和精确的子控件布局管理,调用方可以实现高度可定制的界面,同时保持代码的简洁性和易于维护性。这种方式允许快速且轻松地适应不同的设计需求和用户体验改进。

总结

使用 Safe Area 设置视图的约束

我们使用 Safe Area 设置视图的约束,使其适应不同尺寸的屏幕。在 iOS 开发中,考虑到不同尺寸和型号的设备,使用 Safe Area 来设置视图的约束是非常重要的。

  • 避免界面元素被遮挡:使用Safe Area可以确保UI元素不会被设备的状态栏、导航栏、标签栏、工具栏或者其他系统级视图覆盖。对于有刘?;蛟步堑纳璞?,Safe Area同样可以防止内容被这些特殊设计遮挡。
  • 提升应用的兼容性:Safe Area 的使用允许应用界面能够适配多种不同尺寸和形状的屏幕,从而提高了应用的兼容性??⒄呶扌栉恐稚璞傅ザ赖髡季郑档土丝⒑臀さ哪讯?。
  • 适应屏幕旋转:当用户旋转设备时,Safe Area会自动调整,保证界面元素始终在可视区域内。这意味着无论用户如何持握设备,应用界面都会正确显示。
  • 简化开发流程:使用 Safe Area 可以简化界面设计和布局的过程??⒄呖梢愿幼ㄗ⒂谀谌荼旧?,而不是如何适配不同的屏幕尺寸和形状。

通过使用Safe Area来设置约束,开发者可以更轻松地设计出既美观又实用的应用界面,同时确保应用在不同设备上都能提供良好的用户体验。

使用 lessThanOrEqualgreaterThanOrEqual 设置约束

使用 lessThanOrEqual (小于等于)和 greaterThanOrEqual (大于等于)约束可以构建一个灵活的界面布局,它可以适应不同的屏幕尺寸和内容大小。这种方式允许某些视图元素在不超过或不低于特定值的情况下,根据需要动态调整大小。
这两种约束类型非常适合用于确保界面元素(如按钮、文本框等)不会因为内容变化而变得太小而难以操作,或者太大而破坏布局的美观。例如,可以设置一个按钮的宽度 greaterThanOrEqual 到一个最小值,确保按钮总是足够大,用户可以轻松点击。
在某些情况下,内容可能会因为过多而不能完全显示在屏幕上。使用 lessThanOrEqual 约束可以限制内容的最大尺寸,防止它溢出屏幕或覆盖其他重要的界面元素。
通过结合使用 lessThanOrEqualgreaterThanOrEqual,可以创建出既不会过小也不会过大的界面元素,这样的布局能够更好地适应各种设备和用户需求。这在响应式设计中尤为重要,因为它需要在不同的设备和分辨率下都能提供良好的用户体验。
在需要同时满足多个布局条件的情况下,lessThanOrEqualgreaterThanOrEqual 约束可以同时使用,以形成一个复杂的布局逻辑。例如,一个视图的宽度可以设置为小于等于父视图的宽度同时大于等于其内容的宽度,这样就可以保证内容不会被截断,同时又不会超出父视图的范围。
合理使用 lessThanOrEqualgreaterThanOrEqual 约束可以优化布局的性能。在布局过程中,系统只需要计算满足这些约束条件的布局方案,而不是考虑所有可能的布局方案,这可以减少计算量并提高效率。
总之,lessThanOrEqualgreaterThanOrEqual 是强大的工具,能够帮助开发者创建出既灵活又稳定的界面布局。正确使用这些约束,可以大大提升应用程序的质量和用户体验。

设置约束的优先级

在布局系统中,约束优先级允许开发者指定哪些约束是必须满足的,哪些可以在必要时被忽略。优先级范围通常是从最低的 1 到最高的 1000,其中 1000 表示约束是必须满足的(也称为 required)。
当布局中存在冲突的约束时,系统将根据约束的优先级来解决冲突。具有较高优先级的约束将被满足,而较低优先级的约束可能会被暂时忽略,以确保布局不会因为无法同时满足所有约束而崩溃。
通过为不同的约束设置不同的优先级,可以创建更加灵活和响应式的布局。例如,可以为某个视图的最小宽度约束设置高优先级,而为其最大宽度约束设置较低的优先级,这样可以保证视图至少具有足够的宽度,同时在空间允许的情况下能够扩展。
除了系统的默认优先级(如 UILayoutPriorityRequiredUILayoutPriorityDefaultHigh),开发者还可以自定义优先级值,以满足特定的布局需求。自定义优先级提供了更细致的控制,有助于实现复杂的布局逻辑。
虽然使用优先级可以解决布局冲突,但滥用或不当使用优先级可能会影响布局的性能。创建太多具有不同优先级的约束可能会增加布局计算的复杂性,因此应当谨慎使用,并尽量保持布局的简洁。
在布局调试过程中,了解约束优先级是很重要的。当出现布局问题时,检查约束的优先级可以帮助快速定位问题所在。同时,在维护阶段,合理组织和注释约束的优先级设置,可以使其他开发者更容易理解和修改布局。

总的来说,合理地设置约束优先级是高级布局设计的关键部分,它可以帮助开发者创建出更加精确和适应性强的界面布局。然而,开发者应当注意优先级设置的复杂性和性能影响,确保布局的优化和高效。


  1. https://gist.github.com/ltryee/669dc811972019a6bf6c6cb86750e5a1 ?

  2. https://gist.github.com/ltryee/41af72562177f29011d5223540fee6a3 ?

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

推荐阅读更多精彩内容