美文网首页
UIStackView 的另类玩法(三)

UIStackView 的另类玩法(三)

作者: ltryee | 来源:发表于2024-01-03 13:11 被阅读0次

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

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

    要实现这个功能,我们需要继续拆解需求。首先,要实现滑动功能,需要引入 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

    相关文章

      网友评论

          本文标题:UIStackView 的另类玩法(三)

          本文链接:https://www.haomeiwen.com/subject/bnomndtx.html