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

相关推荐
非专业程序员Ping18 小时前
一文读懂字体文件
ios·swift·assembly·font
wahkim1 天前
移动端开发工具集锦
flutter·ios·android studio·swift
2501_916007471 天前
提升 iOS 26 系统流畅度的实战指南,多工具组合监控
android·macos·ios·小程序·uni-app·cocoa·iphone
hellojackjiang20111 天前
全面适配iOS 26液态玻璃,基于开源IM即时通讯框架MobileIMSDK:RainbowChat-iOS端v10.2发布
ios·网络编程·即时通讯·im开发·rainbowchat
非专业程序员Ping1 天前
一文读懂字符、字形、字体
ios·swift·font
2501_915921431 天前
iOS 应用代上架流程,多工具组合与使用 开心上架 跨平台自动化上传指南
android·ios·小程序·uni-app·自动化·cocoa·iphone
日日行不惧千万里1 天前
2025最新仿默往 IM 即时通讯系统源码(PC + Web + iOS + Android)完整版发布!
android·ios
歪歪1001 天前
React Native开发Android&IOS流程完整指南
android·开发语言·前端·react native·ios·前端框架
阿里超级工程师1 天前
ios云打包证书申请不需要苹果电脑也是可以的
ios·证书·云打包
2501_915918411 天前
iOS 混淆与 IPA 加固一页式行动手册(多工具组合实战 源码成品运维闭环)
android·运维·ios·小程序·uni-app·iphone·webview