用 SwiftUI 重塑布局:Grid、Layout 协议、

B站影视 电影资讯 2025-09-22 01:09 1

摘要:SwiftUI 的宣言之一是视图是状态的函数。但在“状态 → UI”之间,如何把一堆 Text/Image/控件组织成稳定、易维护、可扩展的结构,却经常决定了你项目的上限:

SwiftUI 的宣言之一是视图是状态的函数。但在“状态 → UI”之间,如何把一堆 Text/Image/控件组织成稳定、易维护、可扩展的结构,却经常决定了你项目的上限:

需求要点:

• 第一列“名称”前对齐,第三列“计数”后对齐;• 文案更长或动态字体更大时,两列的宽度根据最大单元自适应;• 进度条拿到剩余空间;• 行间有 Divider,且需要跨列。# Swiftimport SwiftUIstruct Contender: Identifiable, Equatable {let id = UUIDvar name: Stringvar count: Intvar percent: Double}struct LeaderboardView: View {@State private var data: [Contender] = [.init(name: "Cat", count: 128, percent: 0.42),.init(name: "Goldfish", count: 156, percent: 0.51),.init(name: "Dog", count: 22, percent: 0.07),]var body: some View {VStack(alignment: .leading, spacing: 12) {Text("Leaderboard").font(.title2.bold)Grid(alignment: .leading) { // 默认整表对齐:leading// 头部GridRow {Text("Name").font(.caption).foregroundStyle(.secondary)Text("Progress").font(.caption).foregroundStyle(.secondary)Text("Votes").font(.caption).foregroundStyle(.secondary).gridColumnAlignment(.trailing) // 第三列后对齐}Divider // 跨列的分割线(摆在 GridRow 之外可自动“满宽”)// 内容行ForEach(data) { c inGridRow {Text(c.name).lineLimit(1)ProgressView(value: c.percent).frame(minWidth: 80) // 给点最低空间,避免极端压缩Text("\(c.count)").monospacedDigit.gridColumnAlignment(.trailing)}Divider}}.padding(12).background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))}.padding}}• Grid 的列宽/行高该列/行中“最大尺寸”单元决定;• 用 Grid(alignment:) 设表级默认对齐,再对特定列使用 .gridColumnAlignment(...) 微调;• 想让 Divider跨列不要把它放进 GridRow,而是直接放在 Grid 的内容闭包里(或用 .gridCellColumns(n) 指定跨几列)。

经验:Grid 非 Lazy,会一次把所有子视图测量/渲染完,适合静态、有限数量。如果你发现内容数量不确定且可能很大,该换 Lazy,同时接受“另一个维度”要你显式指定策略。

“我有三个不同文案长度的按钮,要等宽,但宽度由最长文案决定,不要把整行撑满大屏。”
HStack 默认是“各显其能”,加 Spacer/frame(maxWidth: .infinity) 又会平均吃满父容器。
最佳解:写一个只干一件事的容器——EqualWidthHStack

# Swiftimport SwiftUIstruct EqualWidthHStack: Layout {// 计算每个子视图的“理想尺寸”,并取最大宽/高private func maxIdealSize(subviews: Subviews) -> CGSize {var maxW: CGFloat = 0var maxH: CGFloat = 0for s in subviews {let size = s.sizeThatFits(.unspecified) // 让子视图返回“理想尺寸”maxW = max(maxW, size.width)maxH = max(maxH, size.height)}return .init(width: maxW, height: maxH)}// 计算相邻视图的“首选间距”(系统有规则:取两者之最大值)private func spacings(subviews: Subviews) -> [CGFloat] {guard !subviews.isEmpty else { return }var result: [CGFloat] = for i in 0.. CGSize {guard !subviews.isEmpty else { return .zero }let maxSize = maxIdealSize(subviews: subviews)let spaces = spacings(subviews: subviews)let totalSpacing = spaces.reduce(0, +)let totalWidth = maxSize.width * CGFloat(subviews.count) + totalSpacinglet totalHeight = maxSize.heightreturn .init(width: totalWidth,height: totalHeight)}// 2) 放置:每个子视图以相同宽度放置到水平序列中func placeSubviews(in bounds: CGRect,proposal: ProposedViewSize,subviews: Subviews,cache: inout ) {guard !subviews.isEmpty else { return }let maxSize = maxIdealSize(subviews: subviews)let spaces = spacings(subviews: subviews)let itemProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)var x = bounds.minX + maxSize.width / 2let y = bounds.midYfor (i, s) in subviews.enumerated {s.place(at: CGPoint(x: x, y: y),anchor: .center,proposal: itemProposal)x += maxSize.width + spaces[i]}}}# Swiftstruct VoteButtons: View {@State private var selected: String?var body: some View {EqualWidthHStack {Button("Cat") { selected = "Cat" }.buttonStyle(.borderedProminent)Button("Goldfish"){ selected = "Goldfish" }.buttonStyle(.borderedProminent)Button("Dog") { selected = "Dog" }.buttonStyle(.borderedProminent)}.padding}}

为什么不用 GeometryReader?
GeometryReader 用于读取父容器尺寸,不是用于影响父容器布局。把“测量结果”再回写到父级 frame 容易引发布局-测量死循环。Layout 协议才是**“在布局引擎内部”**安全、正统地做这件事的方式。

当动态字重 / 多语言 / 分屏导致横向放不下时,我们希望自动退化到“竖排”按钮组,而不是溢出或缩放。

# Swiftstruct VoteButtonsAdaptive: View {var body: some View {ViewThatFits {// Plan A:横排等宽EqualWidthHStack {VoteButton("Cat")VoteButton("Goldfish")VoteButton("Dog")}// Plan B:竖排等宽(写个 EqualWidthVStack 类似实现;此处用 VStack + max width 也可)VStack(spacing: 10) {VoteButton("Cat")VoteButton("Goldfish")VoteButton("Dog")}}.padding}}struct VoteButton: View {let title: Stringinit(_ title: String) { self.title = title }var body: some View {Button(title) {}.buttonStyle(.borderedProminent).frame(maxWidth: .infinity, alignment: .center) // 在 VStack 中拉伸到同宽}}

工作原理:ViewThatFits 会依次测量子布局,选择第一个“能塞进父容器”的分支渲染。iPad/macOS 上横排成立;在极端大字重窄屏时自动改用竖排。

我们把候选头像环形排列(RadialLayout),并根据排名旋转。若出现三方平局,改用 HStack,且希望平滑过渡,而非“砰”地换布局。

4.1 自定义 RadialLayout(基于 Layout)# Swiftstruct RadialLayout: Layout {var radiusScale: CGFloat = 0.4 // 占容器的比例func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ) -> CGSize {// 占满父容器给的尺寸proposal.replacingUnspecifiedDimensions}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ) {guard !subviews.isEmpty else { return }let center = CGPoint(x: bounds.midX, y: bounds.midY)let r = min(bounds.width, bounds.height) * radiusScale// 读取“排名”布局值(下文会写 .layoutValue )let ranks: [Int] = subviews.map { $0[RankLayoutKey.self] }let sortedIdx = (0.. some View {layoutValue(key: RankLayoutKey.self, value: value)}}# Swiftstruct AvatarsView: View {struct Pet: Identifiable { let id = UUID; let name: String; let score: Int }@State private var pets = [Pet(name: "Cat", score: 50),Pet(name: "Goldfish", score: 80),Pet(name: "Dog", score: 60),]var isThreeWayTie: Bool {let s = Set(pets.map { $0.score })return s.count == 1}var layout: AnyLayout {isThreeWayTie ? AnyLayout(HStackLayout) : AnyLayout(RadialLayout)}var body: some View {layout {ForEach(pets) { p inCircle.fill(color(for: p.name)).overlay(Text(String(p.name.prefix(1))).font(.headline).foregroundStyle(.white)).frame(width: 56, height: 56).rank(rank(for: p)) // 传递排名给布局引擎.animation(.snappy, value: pets) // 排名变化 → 位置动画}}.animation(.easeInOut, value: isThreeWayTie) // 布局类型变化 → 容器动画.frame(height: 180).padding.toolbar {Button("随机变化") {withAnimation {for i in pets.indices { pets[i].score = .random(in: 0...100) }}}}}private func rank(for p: Pet) -> Int {// 分数高 → rank 小(靠前)pets.sorted { $0.score > $1.score }.firstIndex(where: { $0.id == p.id }) ?? 0}private func color(for name: String) -> Color {switch name { case "Cat": .pink; case "Dog": .orange; default: .cyan }}}• AnyLayout(某布局实例) 把不同“布局类型”统一在一个容器接口中;• 视图层级结构不变(ForEach 中的头像视图还是原来的那些),因此状态与动画连续;• 对布局类型的变化布局内部配置的变化分别添加 .animation,即可获得平滑过渡。• SwiftUI 布局是自顶向下的尺寸提案(proposed size)与自底向上的实际尺寸(fitted size)交互;• Layout 协议让你显式参与这两步:• sizeThatFits:你可以用 .unspecified / .zero / .infinity 等提议去“探”子视图的理想尺寸;• placeSubviews:拿到确定的 bounds 矩形后,按 anchor 放置。# SwiftImage(systemName: "cat.fill").accessibilityLabel("Cat")# Swift#Preview("RTL + 大字") {LeaderboardView.environment(\.layoutDirection, .rightToLeft).environment(\.dynamicTypeSize, .accessibility3)}# Swiftstruct MetricsGrid: View {let items: [(name: String, value: String, up: Bool)]var body: some View {Grid(alignment: .leading) {ForEach(Array(items.enumerated), id:\.0) { _, it inGridRow {Text(it.name)Text(it.value).bold.gridColumnAlignment(.trailing)Image(systemName: it.up ? "arrow.up" : "arrow.down").foregroundStyle(it.up ? .green : .red).gridColumnAlignment(.trailing)}Divider}}.padding}}# Swiftstruct CardsBoard: View {let cards: [String]var body: some View {Grid(horizontalSpacing: 12, verticalSpacing: 12) {ForEach(cards, id: \.self) { text inGridRow {Text(text).padding.frame(height: 80).background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)).gridCellColumns(2) // 跨两列示例}}}.padding}}# Swiftstruct ToolbarAdaptive: View {var body: some View {ViewThatFits {HStack {Tool("Cut","scissors")Tool("Copy","doc.on.doc")Tool("Paste","doc.on.clipboard")Tool("Share","square.and.arrow.up")}VStack(alignment: .leading) {Tool("Cut","scissors")Tool("Copy","doc.on.doc")Tool("Paste","doc.on.clipboard")Tool("Share","square.and.arrow.up")}}.padding}}private func Tool(_ title: String, _ icon: String) -> some View {Label(title, systemImage: icon).padding(.horizontal, 10).padding(.vertical, 6).background(.thickMaterial, in: Capsule)}# Swiftimport SwiftUI// MARK: - Modelstruct Contender: Identifiable, Equatable {let id = UUIDvar name: Stringvar count: Intvar percent: Double}// MARK: - Grid Leaderboardstruct LeaderboardView: View {@State var data: [Contender]var body: some View {Grid(alignment: .leading) {GridRow {Text("Name").font(.caption).foregroundStyle(.secondary)Text("Progress").font(.caption).foregroundStyle(.secondary)Text("Votes").font(.caption).foregroundStyle(.secondary).gridColumnAlignment(.trailing)}DividerForEach(data) { c inGridRow {Text(c.name).lineLimit(1)ProgressView(value: c.percent).frame(minWidth: 100)Text("\(c.count)").monospacedDigit.gridColumnAlignment(.trailing)}Divider}}.padding(12).background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))}}// MARK: - Equal Width HStackstruct EqualWidthHStack: Layout {private func maxIdealSize(subviews: Subviews) -> CGSize {subviews.reduce(.zero) { acc, s inlet sz = s.sizeThatFits(.unspecified)return .init(width: max(acc.width, sz.width), height: max(acc.height, sz.height))}}private func spacings(subviews: Subviews) -> [CGFloat] {guard !subviews.isEmpty else { return }return (0.. CGSize {guard !subviews.isEmpty else { return .zero }let maxSize = maxIdealSize(subviews: subviews)let totalSpacing = spacings(subviews: subviews).reduce(0, +)return .init(width: maxSize.width * CGFloat(subviews.count) + totalSpacing,height: maxSize.height)}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ) {guard !subviews.isEmpty else { return }let maxSize = maxIdealSize(subviews: subviews)let spaces = spacings(subviews: subviews)var x = bounds.minX + maxSize.width / 2let y = bounds.midYfor (i, s) in subviews.enumerated {s.place(at: .init(x: x, y: y), anchor: .center,proposal: .init(width: maxSize.width, height: maxSize.height))x += maxSize.width + spaces[i]}}}// MARK: - Rank Layout Value Key & RadialLayoutstruct RankLayoutKey: LayoutValueKey { static let defaultValue: Int = 0 }extension View { func rank(_ v: Int) -> some View { layoutValue(key: RankLayoutKey.self, value: v) } }struct RadialLayout: Layout {var radiusScale: CGFloat = 0.4func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ) -> CGSize {proposal.replacingUnspecifiedDimensions}func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ) {let center = CGPoint(x: bounds.midX, y: bounds.midY)let r = min(bounds.width, bounds.height) * radiusScaleguard !subviews.isEmpty else { return }let ranks = subviews.map { $0[RankLayoutKey.self] }let sorted = (0.. Int {pets.sorted { $0.score > $1.score }.firstIndex(where: { $0.id == p.id }) ?? 0}private func color(_ name: String) -> Color {switch name { case "Cat": .pink; case "Dog": .orange; default: .cyan }}}// MARK: - Whole Demo Screenstruct ComposeLayoutsDemo: View {@State private var board = [Contender(name: "Cat", count: 128, percent: 0.42),Contender(name: "Goldfish", count: 156, percent: 0.51),Contender(name: "Dog", count: 22, percent: 0.07),]var body: some View {ScrollView {VStack(alignment: .leading, spacing: 24) {Text("Compose Custom Layouts").font(.largeTitle.bold)AvatarsViewLeaderboardView(data: board)Text("Vote").font(.title2.bold)ViewThatFits {EqualWidthHStack {vote("Cat")vote("Goldfish")vote("Dog")}VStack(spacing: 10) {vote("Cat")vote("Goldfish")vote("Dog")}}}.padding}}private func vote(_ name: String) -> some View {Button(name) {withAnimation(.spring) {if let idx = board.firstIndex(where: { $0.name == name }) {board[idx].count += 1let total = max(1, board.map(\.count).reduce(0, +))for i in board.indices {board[i].percent = Double(board[i].count) / Double(total)}}}}.buttonStyle(.borderedProminent)}}

来源:有趣的科技君

相关推荐