Skip to content

Commit 2c1aa2d

Browse files
authored
Merge pull request #1275 from DimensionDev/feature/ios_media_grid
rework AdaptiveGrid for ios
2 parents 2e61b59 + 1e6a94b commit 2c1aa2d

File tree

15 files changed

+216
-258
lines changed

15 files changed

+216
-258
lines changed

compose-ui/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ kotlin {
3030
appleTarget.binaries.framework {
3131
baseName = "KotlinSharedUI"
3232
isStatic = true
33-
linkerOpts("-ld_classic")
3433
export(projects.shared)
3534
}
3635
}

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ android.lint.useK2Uast=true
88
kotlin.native.cacheKind=none
99
# https://github.com/ajalt/clikt/discussions/571
1010
kotlin.native.cacheKind.linuxX64=none
11+
kotlin.native.binary.gc=cms
1112

1213
#MPP
1314
kotlin.mpp.stability.nowarn=true

iosApp/Flare.xcodeproj/project.pbxproj

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@
8282
buildRules = (
8383
);
8484
dependencies = (
85-
06791BD62E7AA41500FF2050 /* PBXTargetDependency */,
8685
);
8786
fileSystemSynchronizedGroups = (
8887
06E434002E6A9A2600CD0826 /* Flare */,
@@ -178,13 +177,6 @@
178177
};
179178
/* End PBXSourcesBuildPhase section */
180179

181-
/* Begin PBXTargetDependency section */
182-
06791BD62E7AA41500FF2050 /* PBXTargetDependency */ = {
183-
isa = PBXTargetDependency;
184-
productRef = 06791BD52E7AA41500FF2050 /* SwiftLintBuildToolPlugin */;
185-
};
186-
/* End PBXTargetDependency section */
187-
188180
/* Begin XCBuildConfiguration section */
189181
06E434072E6A9A2700CD0826 /* Debug */ = {
190182
isa = XCBuildConfiguration;
@@ -451,11 +443,6 @@
451443
package = 0646B2602E7151A700535A3E /* XCRemoteSwiftPackageReference "Kingfisher" */;
452444
productName = Kingfisher;
453445
};
454-
06791BD52E7AA41500FF2050 /* SwiftLintBuildToolPlugin */ = {
455-
isa = XCSwiftPackageProductDependency;
456-
package = 06791BD42E7AA40000FF2050 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */;
457-
productName = "plugin:SwiftLintBuildToolPlugin";
458-
};
459446
068F7CD02E75405A00B5FB40 /* MarkdownUI */ = {
460447
isa = XCSwiftPackageProductDependency;
461448
package = 068F7CCF2E75405A00B5FB40 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;

iosApp/flare/Localizable.xcstrings

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,9 @@
939939
},
940940
"storage_title" : {
941941

942+
},
943+
"tip_alt_text_title" : {
944+
942945
},
943946
"unblock" : {
944947

Lines changed: 130 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,193 +1,156 @@
11
import SwiftUI
22

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
108

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,
2611
spacing: CGFloat = 4,
27-
singleMode: SingleMode = .preserveImageAspect,
28-
@ViewBuilder content: @escaping (Item) -> Content
12+
maxColumns: Int = 3
2913
) {
30-
self.items = items
14+
self.singleFollowsImageAspect = singleFollowsImageAspect
3115
self.spacing = spacing
32-
self.singleMode = singleMode
33-
self.content = content
16+
self.maxColumns = max(1, maxColumns)
3417
}
3518

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 {
4035
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)
4939

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
7852
}
53+
let height = heightForGridFillLastRow(width: width, count: count, cols: cols, spacing: spacing)
54+
return CGSize(width: width, height: height)
7955
}
8056
}
81-
}
8257

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+
)
13184
}
132-
.aspectRatio(16.0/9.0, contentMode: .fit) // Guard at the outer level as well
133-
}
13485

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)
144110

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
167125
}
126+
y += columnWidth
127+
if r < fullRows - 1 || rem > 0 { y += spacing }
128+
}
168129

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
180136
}
181137
}
182138
}
183-
.frame(maxWidth: .infinity)
184139
}
185140

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
192155
}
193156
}

0 commit comments

Comments
 (0)