|
| 1 | +// |
| 2 | +// FlowLayout.swift |
| 3 | +// SharedExample |
| 4 | + |
| 5 | +#if OPENSWIFTUI |
| 6 | +import OpenSwiftUI |
| 7 | +#else |
| 8 | +import SwiftUI |
| 9 | +#endif |
| 10 | + |
| 11 | +struct FlowLayout: Layout { |
| 12 | + enum HorizontalAlignment { |
| 13 | + case leading, center, trailing |
| 14 | + } |
| 15 | + |
| 16 | + struct Cache { |
| 17 | + var frames: [CGRect] = [] |
| 18 | + var containerSize: CGSize = .zero |
| 19 | + var proposalWidth: CGFloat? = nil |
| 20 | + } |
| 21 | + |
| 22 | + private let spacing: CGFloat |
| 23 | + private let rowSpacing: CGFloat |
| 24 | + private let alignment: HorizontalAlignment |
| 25 | + private let maxRowWidth: CGFloat? |
| 26 | + |
| 27 | + init(spacing: CGFloat = 8, |
| 28 | + rowSpacing: CGFloat = 8, |
| 29 | + alignment: HorizontalAlignment = .leading, |
| 30 | + maxRowWidth: CGFloat? = nil) { |
| 31 | + self.spacing = spacing |
| 32 | + self.rowSpacing = rowSpacing |
| 33 | + self.alignment = alignment |
| 34 | + self.maxRowWidth = maxRowWidth |
| 35 | + } |
| 36 | + |
| 37 | + func makeCache(subviews: Subviews) -> Cache { Cache() } |
| 38 | + |
| 39 | + func updateCache(_ cache: inout Cache, subviews: Subviews) { |
| 40 | + cache.frames.removeAll(keepingCapacity: true) |
| 41 | + } |
| 42 | + |
| 43 | + func sizeThatFits(proposal: ProposedViewSize, |
| 44 | + subviews: Subviews, |
| 45 | + cache: inout Cache) -> CGSize { |
| 46 | + let proposedWidth = maxRowWidth ?? proposal.width ?? .greatestFiniteMagnitude |
| 47 | + |
| 48 | + let result = layoutFrames(for: subviews, inWidth: proposedWidth, proposal: proposal) |
| 49 | + cache.frames = result.frames |
| 50 | + cache.containerSize = result.size |
| 51 | + cache.proposalWidth = proposedWidth |
| 52 | + return result.size |
| 53 | + } |
| 54 | + |
| 55 | + func placeSubviews(in bounds: CGRect, |
| 56 | + proposal: ProposedViewSize, |
| 57 | + subviews: Subviews, |
| 58 | + cache: inout Cache) { |
| 59 | + let actualWidth = maxRowWidth ?? bounds.width |
| 60 | + if cache.frames.isEmpty || cache.proposalWidth != actualWidth { |
| 61 | + let result = layoutFrames(for: subviews, inWidth: actualWidth, proposal: proposal) |
| 62 | + cache.frames = result.frames |
| 63 | + cache.containerSize = result.size |
| 64 | + cache.proposalWidth = actualWidth |
| 65 | + } |
| 66 | + |
| 67 | + var lineStartIndex = 0 |
| 68 | + while lineStartIndex < cache.frames.count { |
| 69 | + let y = cache.frames[lineStartIndex].origin.y |
| 70 | + var lineEndIndex = lineStartIndex |
| 71 | + while lineEndIndex + 1 < cache.frames.count && |
| 72 | + abs(cache.frames[lineEndIndex + 1].origin.y - y) < 0.5 { |
| 73 | + lineEndIndex += 1 |
| 74 | + } |
| 75 | + |
| 76 | + let lineFrames = cache.frames[lineStartIndex...lineEndIndex] |
| 77 | + let lineWidth = lineFrames.last!.maxX - lineFrames.first!.minX |
| 78 | + let free = actualWidth - lineWidth |
| 79 | + let xOffset: CGFloat |
| 80 | + switch alignment { |
| 81 | + case .leading: xOffset = 0 |
| 82 | + case .center: xOffset = max(0, free / 2) |
| 83 | + case .trailing: xOffset = max(0, free) |
| 84 | + } |
| 85 | + |
| 86 | + for (idx, frame) in zip(lineStartIndex...lineEndIndex, lineFrames) { |
| 87 | + let adjusted = frame.offsetBy(dx: xOffset, dy: 0) |
| 88 | + subviews[idx].place(at: CGPoint(x: bounds.minX + adjusted.minX, |
| 89 | + y: bounds.minY + adjusted.minY), |
| 90 | + proposal: .unspecified) |
| 91 | + } |
| 92 | + |
| 93 | + lineStartIndex = lineEndIndex + 1 |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + private func layoutFrames(for subviews: Subviews, |
| 98 | + inWidth containerWidth: CGFloat, |
| 99 | + proposal: ProposedViewSize) -> (frames: [CGRect], size: CGSize) { |
| 100 | + var frames: [CGRect] = [] |
| 101 | + var cursorX: CGFloat = 0 |
| 102 | + var cursorY: CGFloat = 0 |
| 103 | + var lineHeight: CGFloat = 0 |
| 104 | + |
| 105 | + func newLine() { |
| 106 | + cursorX = 0 |
| 107 | + cursorY += lineHeight + rowSpacing |
| 108 | + lineHeight = 0 |
| 109 | + } |
| 110 | + |
| 111 | + for subview in subviews { |
| 112 | + let size = subview.sizeThatFits(.unspecified) |
| 113 | + let viewSize = CGSize(width: min(size.width, containerWidth), height: size.height) |
| 114 | + |
| 115 | + if cursorX > 0, cursorX + viewSize.width > containerWidth { |
| 116 | + newLine() |
| 117 | + } |
| 118 | + |
| 119 | + let frame = CGRect(x: cursorX, y: cursorY, width: viewSize.width, height: viewSize.height) |
| 120 | + frames.append(frame) |
| 121 | + |
| 122 | + cursorX += viewSize.width |
| 123 | + if subview != subviews.last { |
| 124 | + cursorX += spacing |
| 125 | + } |
| 126 | + lineHeight = max(lineHeight, viewSize.height) |
| 127 | + } |
| 128 | + |
| 129 | + let totalHeight = cursorY + lineHeight |
| 130 | + let totalWidth = containerWidth.isFinite ? containerWidth : (frames.last?.maxX ?? 0) |
| 131 | + return (frames, CGSize(width: totalWidth, height: totalHeight)) |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +struct FlowLayoutDemo: View { |
| 136 | + @State private var alignIndex: Int = 0 |
| 137 | + private let alignments: [FlowLayout.HorizontalAlignment] = [.leading, .center, .trailing] |
| 138 | + |
| 139 | + private let tags: [String] = [ |
| 140 | + "SwiftUI", "AttributeGraph", "DisplayList", "Transactions", |
| 141 | + "Layout Protocol", "FlowLayout", "ZStack", "HStack", "VStack", |
| 142 | + "Observation", "Preview", "GeometryReader", "AnyLayout", "ViewThatFits" |
| 143 | + ] |
| 144 | + |
| 145 | + var body: some View { |
| 146 | + VStack(spacing: 16) { |
| 147 | +// header |
| 148 | + |
| 149 | + FlowLayout(spacing: 8, |
| 150 | + rowSpacing: 10, |
| 151 | + alignment: alignments[alignIndex], |
| 152 | + maxRowWidth: nil) { |
| 153 | +// ForEach(tags, id: \.self) { tag in |
| 154 | +// TagChip(text: tag) |
| 155 | +// } |
| 156 | + TagChip(text: tags[0]) |
| 157 | + TagChip(text: tags[1]) |
| 158 | + } |
| 159 | + .padding() |
| 160 | +// .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| 161 | + |
| 162 | +// GroupBox("Fixed width container (320)") { |
| 163 | +// FlowLayout(spacing: 8, rowSpacing: 8, alignment: .center, maxRowWidth: 320) { |
| 164 | +// ForEach(tags, id: \.self) { TagChip(text: $0) } |
| 165 | +// } |
| 166 | +// .padding(.vertical, 8) |
| 167 | +// } |
| 168 | + } |
| 169 | + .padding() |
| 170 | + .animation(.snappy, value: alignIndex) |
| 171 | + } |
| 172 | + |
| 173 | +// private var header: some View { |
| 174 | +// HStack { |
| 175 | +// Text("FlowLayout Demo") |
| 176 | +// .font(.title2).bold() |
| 177 | +// |
| 178 | +// Spacer() |
| 179 | +// |
| 180 | +// Picker("Alignment", selection: $alignIndex) { |
| 181 | +// Text("Leading").tag(0) |
| 182 | +// Text("Center").tag(1) |
| 183 | +// Text("Trailing").tag(2) |
| 184 | +// } |
| 185 | +// .pickerStyle(.segmented) |
| 186 | +// .frame(maxWidth: 280) |
| 187 | +// } |
| 188 | +// } |
| 189 | +} |
| 190 | + |
| 191 | +private struct TagChip: View { |
| 192 | + let text: String |
| 193 | + var body: some View { |
| 194 | +// Text(text) |
| 195 | +// .font(.callout) |
| 196 | +// .padding(.horizontal, 12) |
| 197 | +// .padding(.vertical, 6) |
| 198 | +// .background(Color.accentColor.opacity(0.12), in: Capsule()) |
| 199 | +// .overlay { |
| 200 | +// Capsule().strokeBorder(Color.accentColor.opacity(0.35)) |
| 201 | +// } |
| 202 | + Color.red |
| 203 | + } |
| 204 | +} |
0 commit comments