|
1 | 1 | import SwiftUI |
2 | 2 |
|
3 | | -/// Single image display mode |
4 | | -enum SingleMode { |
5 | | - /// Respect the image's own aspect ratio (recommend using .resizable().scaledToFit() inside `content`) |
6 | | - case preserveImageAspect |
7 | | - /// Force 16:9 (the container applies .aspectRatio(16/9, .fit)) |
8 | | - case force16x9 |
9 | | -} |
| 3 | +struct AdaptiveGrid: Layout { |
| 4 | + |
| 5 | + public var singleFollowsImageAspect: Bool |
| 6 | + public var spacing: CGFloat |
| 7 | + public var maxColumns: Int |
10 | 8 |
|
11 | | -/// A generic mosaic grid (WeChat/Instagram-style) |
12 | | -/// - Rules: |
13 | | -/// 1) items.count == 1 -> decided by `singleMode` |
14 | | -/// 2) 2...4: |
15 | | -/// - odd (=3): left large image occupies the left half; right side has two images stacked vertically; overall 16:9 |
16 | | -/// - even (2, 4): grid fill; overall 16:9 |
17 | | -/// 3) >4: 3-column grid; all full rows are 1:1 squares; the last incomplete row uses weight=1 to fill the width |
18 | | -struct AdaptiveMosaic<Item, Content: View>: View { |
19 | | - let items: [Item] |
20 | | - let spacing: CGFloat |
21 | | - let singleMode: SingleMode |
22 | | - let content: (Item) -> Content |
23 | | - |
24 | | - init( |
25 | | - _ items: [Item], |
| 9 | + public init( |
| 10 | + singleFollowsImageAspect: Bool = true, |
26 | 11 | spacing: CGFloat = 4, |
27 | | - singleMode: SingleMode = .preserveImageAspect, |
28 | | - @ViewBuilder content: @escaping (Item) -> Content |
| 12 | + maxColumns: Int = 3 |
29 | 13 | ) { |
30 | | - self.items = items |
| 14 | + self.singleFollowsImageAspect = singleFollowsImageAspect |
31 | 15 | self.spacing = spacing |
32 | | - self.singleMode = singleMode |
33 | | - self.content = content |
| 16 | + self.maxColumns = max(1, maxColumns) |
34 | 17 | } |
35 | 18 |
|
36 | | - var body: some View { |
37 | | - switch items.count { |
38 | | - case 0: |
39 | | - EmptyView() |
| 19 | + public struct Cache {} |
| 20 | + public func makeCache(subviews: Subviews) -> Cache { Cache() } |
| 21 | + public func updateCache(_ cache: inout Cache, subviews: Subviews) {} |
| 22 | + |
| 23 | + public func sizeThatFits( |
| 24 | + proposal: ProposedViewSize, |
| 25 | + subviews: Subviews, |
| 26 | + cache: inout Cache |
| 27 | + ) -> CGSize { |
| 28 | + let count = subviews.count |
| 29 | + guard count > 0 else { return .zero } |
| 30 | + |
| 31 | + let defaultWidth: CGFloat = 320 |
| 32 | + var width = proposal.width ?? defaultWidth |
| 33 | + |
| 34 | + switch count { |
40 | 35 | case 1: |
41 | | - singleView(items[0]) |
42 | | - case 2...4: |
43 | | - twoToFour(items) |
44 | | - default: |
45 | | - many(items) |
46 | | - } |
47 | | - } |
48 | | -} |
| 36 | + let ratio = aspectForSingle(subviews: subviews) |
| 37 | + if let height = proposal.height, proposal.width == nil { width = height * ratio } |
| 38 | + return CGSize(width: width, height: width / ratio) |
49 | 39 |
|
50 | | -// MARK: - Single image |
51 | | -private extension AdaptiveMosaic { |
52 | | - func singleView(_ item: Item) -> some View { |
53 | | - Group { |
54 | | - switch singleMode { |
55 | | - case .preserveImageAspect: |
56 | | - // Let inner content keep its own aspect ratio (e.g., .scaledToFit()) |
57 | | - content(item) |
58 | | - .frame(maxWidth: .infinity) |
59 | | - |
60 | | - case .force16x9: |
61 | | - GeometryReader { geo in |
62 | | - let width = geo.size.width |
63 | | - let height = width * 9.0 / 16.0 |
64 | | - contentBox(item, size: CGSize(width: width, height: height)) |
65 | | - .frame(width: width, height: height) |
66 | | - } |
67 | | - .aspectRatio(16.0/9.0, contentMode: .fit) |
68 | | -// content(item) |
69 | | -// .clipped() |
70 | | -// .aspectRatio(contentMode: .fill) |
71 | | -// .frame( |
72 | | -// minWidth: 0, |
73 | | -// maxWidth: .infinity, |
74 | | -// minHeight: 0, |
75 | | -// maxHeight: .infinity |
76 | | -// ) |
77 | | -// .aspectRatio(16 / 9, contentMode: .fit) |
| 40 | + case 2, 3, 4: |
| 41 | + let ratio: CGFloat = 16.0 / 9.0 |
| 42 | + if let height = proposal.height, proposal.width == nil { width = height * ratio } |
| 43 | + return CGSize(width: width, height: width / ratio) |
| 44 | + |
| 45 | + default: |
| 46 | + let cols = min(maxColumns, 3) |
| 47 | + let rowsTotal = Int(ceil(Double(count) / Double(cols))) |
| 48 | + if let height = proposal.height, proposal.width == nil { |
| 49 | + let a = CGFloat(rowsTotal) / CGFloat(cols) |
| 50 | + let b = spacing * (CGFloat(rowsTotal) / CGFloat(cols) - 1) |
| 51 | + width = a > 0 ? max(1, (height - b) / a) : defaultWidth |
78 | 52 | } |
| 53 | + let height = heightForGridFillLastRow(width: width, count: count, cols: cols, spacing: spacing) |
| 54 | + return CGSize(width: width, height: height) |
79 | 55 | } |
80 | 56 | } |
81 | | -} |
82 | 57 |
|
83 | | -// MARK: - 2 ~ 4 items (overall 16:9) |
84 | | -private extension AdaptiveMosaic { |
85 | | - @ViewBuilder |
86 | | - func twoToFour(_ arr: [Item]) -> some View { |
87 | | - GeometryReader { geo in |
88 | | - let wdith = geo.size.width |
89 | | - let height = wdith * 9.0 / 16.0 // Fix container height to 16:9 of its width |
90 | | - let halfW = (wdith - spacing) / 2 |
91 | | - let halfH = (height - spacing) / 2 |
92 | | - |
93 | | - ZStack { |
94 | | - switch arr.count { |
95 | | - case 2: |
96 | | - // 1 row × 2 columns |
97 | | - HStack(spacing: spacing) { |
98 | | - contentBox(arr[0], size: CGSize(width: halfW, height: height)) |
99 | | - contentBox(arr[1], size: CGSize(width: halfW, height: height)) |
100 | | - } |
101 | | - |
102 | | - case 3: |
103 | | - // Left: one large image (left half); Right: two stacked images |
104 | | - HStack(spacing: spacing) { |
105 | | - contentBox(arr[0], size: CGSize(width: halfW, height: height)) |
106 | | - |
107 | | - VStack(spacing: spacing) { |
108 | | - contentBox(arr[1], size: CGSize(width: halfW, height: halfH)) |
109 | | - contentBox(arr[2], size: CGSize(width: halfW, height: halfH)) |
110 | | - } |
111 | | - } |
112 | | - |
113 | | - case 4: |
114 | | - // 2 × 2 grid |
115 | | - VStack(spacing: spacing) { |
116 | | - HStack(spacing: spacing) { |
117 | | - contentBox(arr[0], size: CGSize(width: halfW, height: halfH)) |
118 | | - contentBox(arr[1], size: CGSize(width: halfW, height: halfH)) |
119 | | - } |
120 | | - HStack(spacing: spacing) { |
121 | | - contentBox(arr[2], size: CGSize(width: halfW, height: halfH)) |
122 | | - contentBox(arr[3], size: CGSize(width: halfW, height: halfH)) |
123 | | - } |
124 | | - } |
125 | | - |
126 | | - default: |
127 | | - EmptyView() |
128 | | - } |
129 | | - } |
130 | | - .frame(width: wdith, height: height) |
| 58 | + public func placeSubviews( |
| 59 | + in bounds: CGRect, |
| 60 | + proposal: ProposedViewSize, |
| 61 | + subviews: Subviews, |
| 62 | + cache: inout Cache |
| 63 | + ) { |
| 64 | + let count = subviews.count |
| 65 | + guard count > 0 else { return } |
| 66 | + |
| 67 | + let size = sizeThatFits( |
| 68 | + proposal: ProposedViewSize(width: bounds.width, height: bounds.height), |
| 69 | + subviews: subviews, |
| 70 | + cache: &cache |
| 71 | + ) |
| 72 | + let width = size.width |
| 73 | + let height = size.height |
| 74 | + let spacing = spacing |
| 75 | + let origin = CGPoint(x: bounds.minX, y: bounds.minY) |
| 76 | + |
| 77 | + func place(_ i: Int, x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) { |
| 78 | + guard subviews.indices.contains(i) else { return } |
| 79 | + subviews[i].place( |
| 80 | + at: CGPoint(x: origin.x + x, y: origin.y + y), |
| 81 | + anchor: .topLeading, |
| 82 | + proposal: ProposedViewSize(width: width, height: height) |
| 83 | + ) |
131 | 84 | } |
132 | | - .aspectRatio(16.0/9.0, contentMode: .fit) // Guard at the outer level as well |
133 | | - } |
134 | 85 |
|
135 | | - /// Unified cell wrapper: clip to a given fixed `size` |
136 | | - @ViewBuilder |
137 | | - func contentBox(_ item: Item, size: CGSize) -> some View { |
138 | | - content(item) |
139 | | - .frame(width: size.width, height: size.height) |
140 | | - .clipped() |
141 | | - .contentShape(Rectangle()) |
142 | | - } |
143 | | -} |
| 86 | + switch count { |
| 87 | + case 1: |
| 88 | + let ratio = aspectForSingle(subviews: subviews) |
| 89 | + place(0, x: 0, y: 0, width: width, height: width / ratio) |
| 90 | + |
| 91 | + case 2: |
| 92 | + let cellW = (width - spacing) / 2 |
| 93 | + place(0, x: 0, y: 0, width: cellW, height: height) |
| 94 | + place(1, x: cellW + spacing, y: 0, width: cellW, height: height) |
| 95 | + |
| 96 | + case 3: |
| 97 | + let halfW = (width - spacing) / 2 |
| 98 | + let rightH = (height - spacing) / 2 |
| 99 | + place(0, x: 0, y: 0, width: halfW, height: height) |
| 100 | + place(1, x: halfW + spacing, y: 0, width: halfW, height: rightH) |
| 101 | + place(2, x: halfW + spacing, y: rightH + spacing, width: halfW, height: rightH) |
| 102 | + |
| 103 | + case 4: |
| 104 | + let cellW = (width - spacing) / 2 |
| 105 | + let cellH = (height - spacing) / 2 |
| 106 | + place(0, x: 0, y: 0, width: cellW, height: cellH) |
| 107 | + place(1, x: cellW + spacing, y: 0, width: cellW, height: cellH) |
| 108 | + place(2, x: 0, y: cellH + spacing, width: cellW, height: cellH) |
| 109 | + place(3, x: cellW + spacing, y: cellH + spacing, width: cellW, height: cellH) |
144 | 110 |
|
145 | | -// MARK: - > 4 items (max 3 columns; full rows 1:1; last row uses weights to fill) |
146 | | -private extension AdaptiveMosaic { |
147 | | - @ViewBuilder |
148 | | - func many(_ arr: [Item]) -> some View { |
149 | | - GeometryReader { geo in |
150 | | - let width = geo.size.width |
151 | | - let columns = 3 |
152 | | - let fullRowCount = arr.count / columns |
153 | | - let remainder = arr.count % columns |
154 | | - |
155 | | - // Side length of normal 1:1 squares based on a 3-column layout |
156 | | - let side = (width - CGFloat(columns - 1) * spacing) / CGFloat(columns) |
157 | | - |
158 | | - VStack(spacing: spacing) { |
159 | | - // Render full rows first (each cell is 1:1) |
160 | | - ForEach(0..<fullRowCount, id: \.self) { row in |
161 | | - HStack(spacing: spacing) { |
162 | | - ForEach(0..<columns, id: \.self) { col in |
163 | | - let idx = row * columns + col |
164 | | - squareBox(arr[idx], side: side) |
165 | | - } |
166 | | - } |
| 111 | + default: |
| 112 | + let cols = min(maxColumns, 3) |
| 113 | + let fullRows = count / cols |
| 114 | + let rem = count % cols |
| 115 | + |
| 116 | + let columnWidth = (width - CGFloat(cols - 1) * spacing) / CGFloat(cols) // 行高 |
| 117 | + var idx = 0 |
| 118 | + var y: CGFloat = 0 |
| 119 | + |
| 120 | + for r in 0..<fullRows { |
| 121 | + for c in 0..<cols { |
| 122 | + let x = CGFloat(c) * (columnWidth + spacing) |
| 123 | + place(idx, x: x, y: y, width: columnWidth, height: columnWidth) |
| 124 | + idx += 1 |
167 | 125 | } |
| 126 | + y += columnWidth |
| 127 | + if r < fullRows - 1 || rem > 0 { y += spacing } |
| 128 | + } |
168 | 129 |
|
169 | | - // Last row: if fewer than 3 items, fill the row using weight=1 (height keeps the same as above) |
170 | | - if remainder > 0 { |
171 | | - HStack(spacing: spacing) { |
172 | | - ForEach(0..<remainder, id: \.self) { index in |
173 | | - let idx = fullRowCount * columns + index |
174 | | - content(arr[idx]) |
175 | | - .frame(height: side) // Keep height consistent with previous rows |
176 | | - .frame(maxWidth: .infinity) |
177 | | - .clipped() |
178 | | - } |
179 | | - } |
| 130 | + if rem > 0 { |
| 131 | + let tailW = (width - CGFloat(rem - 1) * spacing) / CGFloat(rem) |
| 132 | + for c in 0..<rem { |
| 133 | + let x = CGFloat(c) * (tailW + spacing) |
| 134 | + place(idx, x: x, y: y, width: tailW, height: columnWidth) |
| 135 | + idx += 1 |
180 | 136 | } |
181 | 137 | } |
182 | 138 | } |
183 | | - .frame(maxWidth: .infinity) |
184 | 139 | } |
185 | 140 |
|
186 | | - @ViewBuilder |
187 | | - func squareBox(_ item: Item, side: CGFloat) -> some View { |
188 | | - content(item) |
189 | | - .frame(width: side, height: side) |
190 | | - .clipped() |
191 | | - .contentShape(Rectangle()) |
| 141 | + private func aspectForSingle(subviews: Subviews) -> CGFloat { |
| 142 | + if singleFollowsImageAspect { |
| 143 | + let ideal = subviews[0].sizeThatFits(.unspecified) |
| 144 | + if ideal.width > 0, ideal.height > 0 { return max(0.01, ideal.width / ideal.height) } |
| 145 | + return 1 |
| 146 | + } else { |
| 147 | + return 16.0 / 9.0 |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + private func heightForGridFillLastRow(width width: CGFloat, count n: Int, cols: Int, spacing s: CGFloat) -> CGFloat { |
| 152 | + let rowsTotal = Int(ceil(Double(n) / Double(cols))) |
| 153 | + let columnWidth = (width - CGFloat(cols - 1) * s) / CGFloat(cols) |
| 154 | + return CGFloat(rowsTotal) * columnWidth + CGFloat(max(0, rowsTotal - 1)) * s |
192 | 155 | } |
193 | 156 | } |
0 commit comments