SwiftUI组件封装:仿 Flutter 原生组件 Wrap实现

一、需求来源

在开发Flutter项目时有一个高频使用的组件 Wrap,使用场景特别多。在学习SwiftUI 时发现缺少这个组件,就特别别扭,花点时间实现了一个,遵循极简代码原则。

二、使用示例

swift 复制代码
//
//  WrapDemo.swift
//  SwiftUITemplet
//
//  Created by Bin Shang on 2025/2/27.
//

import SwiftUI

struct WrapDemo: View {

    @StateObject private var router = Router.shared

    var body: some View {
        NavigationStack(path: $router.path) {
            ScrollView(.vertical, showsIndicators: true) {
                VStack(alignment: .leading, spacing: 10, content: {
                    buildWrap(alignment: .leading, itemBgColor: Color.blue);
                    buildWrap(alignment: .center, itemBgColor: Color.green);
                    buildWrap(alignment: .trailing, itemBgColor: Color.red);
                })
            }

            .navigationBar(title: "\(clsName)")//自定义导航封装
  //            .navigationDestination(for: AppPage<AnyView>.self) { page in
  //                page.makeView()
  //            }
        }
    }

    private func buildWrap(
        alignment: HorizontalAlignment = .leading,
        itemBgColor: Color = .blue
    ) -> some View {
        var enumString = ""
        switch alignment {
        case HorizontalAlignment.center:
            enumString = "HorizontalAlignment.center"
        case HorizontalAlignment.trailing:
            enumString = "HorizontalAlignment.trailing"
        default:
            enumString = "HorizontalAlignment.leading"
        }

        return VStack(alignment: alignment, spacing: 10, content: {
            Text("\(enumString)").font(.subheadline)
            Wrap(
                spacing: 10,
                runSpacing: 10,
                alignment: alignment
            ) {
                ForEach(0..<10) { i in
                    Text("选项_\(i)\("z" * i)")
                        .padding(EdgeInsets.symmetric(
                            horizontal: 8,
                            vertical: 4))
                        .background(itemBgColor)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                        .onTapGesture { p in
                            DDLog("onTapGesture: \(i)")
                        }
                }

           }.padding(10)
            Divider()
        })
    }
}

#Preview {
    WrapDemo()
}

三、源码

1、Wrap 源码
swift 复制代码
//
//  Wrap.swift
//  SwiftUITemplet
//
//  Created by Bin Shang on 2025/2/27.
//

import SwiftUI

//Wrap(spacing: 10,
//      runSpacing: 10,
//      alignment: .leading) {
//    ForEach(0..<10) { i in
//        Text("选项_\(i)\("z" * i)")
//            .padding(EdgeInsets.symmetric(
//                horizontal: 8,
//                vertical: 4))
//            .background(Color.blue)
//            .foregroundColor(.white)
//            .cornerRadius(8)
//            .onTapGesture { p in
//                DDLog("onTapGesture: \(i)")
//            }
//    }
//}
//.padding(10)


/// 折行布局
struct Wrap: Layout {

    /// 水平间距
    var spacing: CGFloat

    /// 竖直间距
    var runSpacing: CGFloat

    /// 对齐方式
    var alignment: HorizontalAlignment

    init(spacing: CGFloat = 10, runSpacing: CGFloat = 10, alignment: HorizontalAlignment = .leading) {
        self.spacing = spacing
        self.runSpacing = runSpacing
        self.alignment = alignment
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

        // 计算布局所需的总大小
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let maxWidth = proposal.width ?? .infinity
        var totalHeight: CGFloat = 0
        var currentLineWidth: CGFloat = 0
        var currentLineHeight: CGFloat = 0

        for (index, size) in sizes.enumerated() {
            if currentLineWidth + size.width > maxWidth {
                // 换行
                totalHeight += currentLineHeight + runSpacing
                currentLineWidth = size.width
                currentLineHeight = size.height
            } else {
                // 继续当前行
                if index > 0 {
                    currentLineWidth += spacing
                }
                currentLineWidth += size.width
                currentLineHeight = max(currentLineHeight, size.height)
            }
        }

        // 添加最后一行的高度
        totalHeight += currentLineHeight
        return CGSize(width: maxWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

        let maxWidth = bounds.width

        var y = bounds.minY

        var currentLineHeight: CGFloat = 0

        var currentLineSubviews: [LayoutSubviews.Element] = []

        var currentLineWidth: CGFloat = 0
        
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if currentLineWidth + size.width > maxWidth {
                // 换行,放置当前行的子视图
                placeLineSubviews(
                    in: bounds,
                    lineSubviews: currentLineSubviews,
                    lineWidth: currentLineWidth,
                    lineHeight: currentLineHeight,
                    y: y
                )
                y += currentLineHeight + runSpacing
                currentLineSubviews = []
                currentLineWidth = 0
                currentLineHeight = 0
            }

            // 添加子视图到当前行
            currentLineSubviews.append(subview)
            if currentLineSubviews.count > 1 {
                currentLineWidth += spacing
            }
            currentLineWidth += size.width
            currentLineHeight = max(currentLineHeight, size.height)

        }

        // 放置最后一行
        if !currentLineSubviews.isEmpty {
            placeLineSubviews(
                in: bounds,
                lineSubviews: currentLineSubviews,
                lineWidth: currentLineWidth,
                lineHeight: currentLineHeight,
                y: y
            )
        }
    }
    
    private func placeLineSubviews(
        in bounds: CGRect,
        lineSubviews: [LayoutSubviews.Element],
        lineWidth: CGFloat,
        lineHeight: CGFloat,
        y: CGFloat
    ) {
        let maxWidth = bounds.width
        var x = bounds.minX

        // 根据对齐方式调整起始位置
        switch alignment {
        case .leading:
            x = bounds.minX
        case .center:
            x = bounds.minX + (maxWidth - lineWidth) / 2
        case .trailing:
            x = bounds.minX + (maxWidth - lineWidth)
        default:
            x = bounds.minX
        }

        // 放置当前行的子视图
        for subview in lineSubviews {
            let size = subview.sizeThatFits(.unspecified)
            subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: .unspecified)
            x += size.width + spacing
        }
    }
}

Layout 协议的核心概念:

Layout 主要涉及 3 个核心方法

  1. sizeThatFits(proposal:subviews:cache:)

    • 计算布局的尺寸 ,返回整个布局的 CGSize
  2. placeSubviews(in:proposal:subviews:cache:)

    • 对子视图进行布局,控制子视图的位置。
  3. makeCache(subviews:)(可选)

    • 缓存计算结果,提高性能。
  4. func updateCache(_ cache:, subviews: )(可选)

    • 更新缓存计算结果,提高性能。

最后、总结

SwiftUI 的 Layout 协议(iOS 16+)提供了一种 结构化、自定义的方式 来管理视图排列。它比 GeometryReader 更清晰、更高效,适用于 复杂布局需求Layout高级用法 ,包括 自适应布局、缓存优化、动画支持、多行流式布局 等。

Layout VS GeometryReader

对比项 Layout 协议 GeometryReader
主要用途 适用于自定义布局 适用于响应父视图尺寸
API 复杂度 结构化,API 规范 容易造成嵌套过深
性能 缓存优化,效率更高 影响父视图布局,可能降低性能
子视图信息 直接获取 Subviews 需要 GeometryProxy 获取
适用场景 复杂布局(如流式布局) 适用于 相对布局

结论

  • Layout 更适合复杂布局 (如 瀑布流环形排列)。
  • GeometryReader 更适合响应父视图尺寸 (如自适应布局)。

github

相关推荐
汉秋1 小时前
SwiftUI 中的 compositingGroup():真正含义与渲染原理
swiftui·swift
柯南二号2 小时前
【大前端】【iOS】iOS 真实项目可落地目录结构方案
前端·ios
2501_916007472 小时前
iOS与Android符号还原服务统一重构实践总结
android·ios·小程序·重构·uni-app·iphone·webview
汉秋2 小时前
SwiftUI 中的 @ViewBuilder 全面解析
swiftui·swift
二流小码农3 小时前
鸿蒙开发:自定义一个圆形动画菜单
android·ios·harmonyos
00后程序员张4 小时前
fastlane 结合 appuploader 命令行实现跨平台上传发布 iOS App
android·ios·小程序·https·uni-app·iphone·webview
2501_915106324 小时前
iOS 性能优化这件事,结合多工具分析运行期性能问题
android·ios·性能优化·小程序·uni-app·cocoa·iphone
游戏开发爱好者84 小时前
App Store 上架流程,结合多工具协作
android·ios·小程序·https·uni-app·iphone·webview
2501_915921436 小时前
uni-app 的 iOS 打包与上架流程,多工具协作
android·ios·小程序·uni-app·cocoa·iphone·webview
胖虎117 小时前
SwiftUI 页面作为一级页面数据被重置问题分析
ios·swiftui·swift·state·observedobject·stateobject·swiftui页面生命周期