Layouts.swift (4034B)
1 import SwiftUI 2 3 /// Lays subviews left-to-right, wrapping onto a new line when the next 4 /// subview would overflow the proposed width. Hugs its content width (the 5 /// widest row) so a surrounding view can size to it and centre the block; 6 /// rows are aligned per `alignment` — centred by default, or leading. 7 struct FlowLayout: Layout { 8 enum Alignment { 9 case leading 10 case center 11 } 12 13 var alignment: Alignment = .center 14 var spacing: CGFloat = 18 15 var lineSpacing: CGFloat = 8 16 17 private struct Row { 18 var indices: [Int] = [] 19 var width: CGFloat = 0 20 var height: CGFloat = 0 21 } 22 23 private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] { 24 var rows: [Row] = [] 25 var current = Row() 26 for index in subviews.indices { 27 let size = subviews[index].sizeThatFits(.unspecified) 28 let needed = current.indices.isEmpty 29 ? size.width 30 : current.width + spacing + size.width 31 if !current.indices.isEmpty, needed > maxWidth { 32 rows.append(current) 33 current = Row(indices: [index], width: size.width, height: size.height) 34 } else { 35 if !current.indices.isEmpty { current.width += spacing } 36 current.indices.append(index) 37 current.width += size.width 38 current.height = max(current.height, size.height) 39 } 40 } 41 if !current.indices.isEmpty { rows.append(current) } 42 return rows 43 } 44 45 func sizeThatFits( 46 proposal: ProposedViewSize, 47 subviews: Subviews, 48 cache: inout () 49 ) -> CGSize { 50 let rows = rows(subviews, maxWidth: proposal.width ?? .infinity) 51 let height = rows.reduce(0) { $0 + $1.height } 52 + lineSpacing * CGFloat(max(0, rows.count - 1)) 53 let widest = rows.map(\.width).max() ?? 0 54 return CGSize(width: widest, height: height) 55 } 56 57 func placeSubviews( 58 in bounds: CGRect, 59 proposal: ProposedViewSize, 60 subviews: Subviews, 61 cache: inout () 62 ) { 63 let rows = rows(subviews, maxWidth: bounds.width) 64 var y = bounds.minY 65 for row in rows { 66 // Align each row within the allocated width. The block hugs its 67 // widest row, so leading rows start flush at the edge and only a 68 // wider sibling shifts a short row when centred. 69 var x = bounds.minX 70 if alignment == .center { 71 x += max(0, (bounds.width - row.width) / 2) 72 } 73 for index in row.indices { 74 let size = subviews[index].sizeThatFits(.unspecified) 75 subviews[index].place( 76 at: CGPoint(x: x, y: y), 77 anchor: .topLeading, 78 proposal: ProposedViewSize(size) 79 ) 80 x += size.width + spacing 81 } 82 y += row.height + lineSpacing 83 } 84 } 85 } 86 87 struct WeightedVStack: Layout { 88 let weights: [CGFloat] 89 90 func sizeThatFits( 91 proposal: ProposedViewSize, 92 subviews: Subviews, 93 cache: inout () 94 ) -> CGSize { 95 CGSize( 96 width: proposal.width ?? 0, 97 height: proposal.height ?? 0 98 ) 99 } 100 101 func placeSubviews( 102 in bounds: CGRect, 103 proposal: ProposedViewSize, 104 subviews: Subviews, 105 cache: inout () 106 ) { 107 let totalWeight = weights.reduce(0, +) 108 guard totalWeight > 0 else { return } 109 110 var y = bounds.minY 111 for (index, subview) in subviews.enumerated() { 112 let weight = index < weights.count ? weights[index] : 0 113 let height = bounds.height * weight / totalWeight 114 subview.place( 115 at: CGPoint(x: bounds.minX, y: y), 116 anchor: .topLeading, 117 proposal: ProposedViewSize(width: bounds.width, height: height) 118 ) 119 y += height 120 } 121 } 122 }