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

相关推荐
懋学的前端攻城狮15 小时前
iOS 列表性能优化实战:从 45fps 到 60fps 的蜕变
ios·性能优化·ui kit
斯班奇的好朋友阿法法16 小时前
鸿蒙 vs iOS vs 微信小程序:开发平台全面对比
ios·微信小程序·harmonyos
@大迁世界1 天前
14个你现在必须关闭的 iOS 26 设置,不然手机很快被它榨干
macos·ios·智能手机·objective-c·cocoa
YJlio2 天前
10.2.8 以其他账户运行服务(Running services in alternate accounts):为什么“把服务切到某个用户账号下运行”,本质上是在改变服务的整个安全上下文?
python·安全·ios·机器人·django·iphone·7-zip
pop_xiaoli2 天前
【iOS】KVC与KVO
笔记·macos·ios·objective-c·cocoa
90后的晨仔2 天前
《swiftUI进阶 第10章:现代状态管理(iOS 17+)》
ios
sakiko_2 天前
UIKit学习笔记4-使用UITableView制作滚动视图
笔记·学习·ios·swift·uikit
小锋学长生活大爆炸2 天前
【开源软件】这次iPhone也是用上Claw了 | PhoneClaw
ios·开源软件·iphone·claw
SameX2 天前
独立开发一个把走过的路变成 km² 的 App,聊聊 25m 网格和后台 GPS 的坑
ios
XD7429716363 天前
科技早报晚报|2026年4月30日:Agent 安全壳、浏览器 iOS 测试台与可穿戴数据 API,今天更值得看的 3 个技术机会
科技·ios·开源项目·科技新闻·开发者工具