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

相关推荐
开心猴爷35 分钟前
应对iOS审核4.3:可以使用 IpaGuard 来给属性名混淆的技术细节与平衡之道
后端·ios
iOS开发上架哦39 分钟前
iOS应用耗电测试:使用Energy Log、Keymob和Instruments进行性能监控与能源消耗分析
后端·ios
_瑞2 小时前
APM_KSCrash 原理
ios
茶底世界之下10 小时前
窗光静物一定要更亮吗?用 Awaking 把曝光收回一点
ios
ii_best11 小时前
ios手机开发安卓脚本开发软件[按键精灵教程]从「找最小值」到「Boss 最近刷新」再到「屏幕范围极值」
android·ios·智能手机·自动化·按键精灵
东坡肘子12 小时前
谁是 AI-relay App 的真正威胁 -- 肘子的 Swift 周报 #158
人工智能·swiftui·swift
茶底世界之下1 天前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源
season_zhu1 天前
Skills For Real Engineers 系列之一:先配好项目,再让 ask-matt 帮我选技能
ios·openai·ai编程
疯狂敲代码的老刘1 天前
d2r-mobile:3.2千星开源暗黑破坏神2 iOS兼容层,3天冲上GitHub日榜
ios·github
黑科技iOS上架2 天前
App Store5.6隐藏功能究竟怎么排查?
ios·审核