Skip to content

Commit ca15b69

Browse files
authored
Add missing Layout API implementation (#472)
1 parent a1cad7d commit ca15b69

File tree

3 files changed

+333
-26
lines changed

3 files changed

+333
-26
lines changed

Example/HostingExample/ViewController.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ class ViewController: NSViewController {
6666

6767
struct ContentView: View {
6868
var body: some View {
69-
TransactionExample()
69+
FlowLayoutDemo()
70+
.frame(width: 500)
71+
.padding()
7072
}
7173
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)