Skip to content

Commit 509221c

Browse files
authored
Merge pull request #37 from PureSwift/feature/flexible-frame
2 parents 7eab81c + 38f0cf2 commit 509221c

8 files changed

Lines changed: 193 additions & 14 deletions

File tree

‎AndroidSwiftUICore/.gitignore‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.build/
2+
.swiftpm/
3+
*.d
4+
*.dia
5+
*.swiftdeps
6+
*.swiftmodule
7+
*.swiftsourceinfo
8+
*.abi.json

‎AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,63 @@ public extension View {
8383
// MARK: - Frame
8484

8585
public struct _FrameModifier: RenderModifier {
86-
let width: Double?
87-
let height: Double?
86+
var width: Double? = nil
87+
var height: Double? = nil
88+
var minWidth: Double? = nil
89+
var idealWidth: Double? = nil
90+
var maxWidth: Double? = nil
91+
var minHeight: Double? = nil
92+
var idealHeight: Double? = nil
93+
var maxHeight: Double? = nil
94+
var alignment: Alignment = .center
95+
8896
public var _modifierNode: ModifierNode {
8997
var args: [String: PropValue] = [:]
9098
if let width { args["width"] = .double(width) }
9199
if let height { args["height"] = .double(height) }
100+
if let minWidth { args["minWidth"] = .double(minWidth) }
101+
if let idealWidth { args["idealWidth"] = .double(idealWidth) }
102+
// .infinity can't cross as a JSON number, so a fill flag carries it.
103+
if let maxWidth {
104+
if maxWidth == .infinity { args["fillWidth"] = .bool(true) }
105+
else { args["maxWidth"] = .double(maxWidth) }
106+
}
107+
if let minHeight { args["minHeight"] = .double(minHeight) }
108+
if let idealHeight { args["idealHeight"] = .double(idealHeight) }
109+
if let maxHeight {
110+
if maxHeight == .infinity { args["fillHeight"] = .bool(true) }
111+
else { args["maxHeight"] = .double(maxHeight) }
112+
}
113+
if alignment.horizontal != .center || alignment.vertical != .center {
114+
args["horizontal"] = .string(alignment.horizontal.rawValue)
115+
args["vertical"] = .string(alignment.vertical.rawValue)
116+
}
92117
return ModifierNode(kind: "frame", args: args)
93118
}
94119
}
95120

96121
public extension View {
97-
func frame(width: Double? = nil, height: Double? = nil) -> ModifiedContent<Self, _FrameModifier> {
98-
modifier(_FrameModifier(width: width, height: height))
122+
/// A fixed frame, optionally aligning the content within it.
123+
func frame(width: Double? = nil, height: Double? = nil, alignment: Alignment = .center) -> ModifiedContent<Self, _FrameModifier> {
124+
modifier(_FrameModifier(width: width, height: height, alignment: alignment))
125+
}
126+
127+
/// A flexible frame with size bounds. `maxWidth`/`maxHeight` of `.infinity`
128+
/// expand to fill the available space.
129+
func frame(
130+
minWidth: Double? = nil,
131+
idealWidth: Double? = nil,
132+
maxWidth: Double? = nil,
133+
minHeight: Double? = nil,
134+
idealHeight: Double? = nil,
135+
maxHeight: Double? = nil,
136+
alignment: Alignment = .center
137+
) -> ModifiedContent<Self, _FrameModifier> {
138+
modifier(_FrameModifier(
139+
minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth,
140+
minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight,
141+
alignment: alignment
142+
))
99143
}
100144
}
101145

‎AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Stacks.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ public struct Alignment: Sendable {
1919
self.vertical = vertical
2020
}
2121
public static let center = Alignment(horizontal: .center, vertical: .center)
22+
public static let leading = Alignment(horizontal: .leading, vertical: .center)
23+
public static let trailing = Alignment(horizontal: .trailing, vertical: .center)
24+
public static let top = Alignment(horizontal: .center, vertical: .top)
25+
public static let bottom = Alignment(horizontal: .center, vertical: .bottom)
2226
public static let topLeading = Alignment(horizontal: .leading, vertical: .top)
27+
public static let topTrailing = Alignment(horizontal: .trailing, vertical: .top)
28+
public static let bottomLeading = Alignment(horizontal: .leading, vertical: .bottom)
2329
public static let bottomTrailing = Alignment(horizontal: .trailing, vertical: .bottom)
2430
}
2531

‎AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ struct ModifierTests {
110110
#expect(frame?.args["height"] == .double(50))
111111
}
112112

113+
@Test("A fixed frame with default center alignment emits no alignment")
114+
func frameDefaultAlignment() {
115+
let node = ViewHost(Text("x").frame(width: 100, height: 50)).evaluate()
116+
let frame = node.modifiers.first { $0.kind == "frame" }
117+
#expect(frame?.args["horizontal"] == nil)
118+
#expect(frame?.args["vertical"] == nil)
119+
}
120+
121+
@Test("maxWidth .infinity becomes a fill flag, not a number")
122+
func frameFill() {
123+
let node = ViewHost(Text("x").frame(maxWidth: .infinity, alignment: .leading)).evaluate()
124+
let frame = node.modifiers.first { $0.kind == "frame" }
125+
#expect(frame?.args["fillWidth"] == .bool(true))
126+
#expect(frame?.args["maxWidth"] == nil)
127+
#expect(frame?.args["horizontal"] == .string("leading"))
128+
}
129+
130+
@Test("Bounded frame emits its min and max")
131+
func frameBounds() {
132+
let node = ViewHost(Text("x").frame(minWidth: 40, maxWidth: 200, minHeight: 20)).evaluate()
133+
let frame = node.modifiers.first { $0.kind == "frame" }
134+
#expect(frame?.args["minWidth"] == .double(40))
135+
#expect(frame?.args["maxWidth"] == .double(200))
136+
#expect(frame?.args["minHeight"] == .double(20))
137+
}
138+
113139
@Test("Named font emits its style")
114140
func fontStyle() {
115141
let node = ViewHost(Text("x").font(.headline)).evaluate()

‎Demo/App.swiftpm/Sources/Catalog.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct CatalogEntry: Identifiable {
3434
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
3535
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
3636
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
37+
CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())),
3738
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
3839
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
3940
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct FramePlayground: View {
8+
var body: some View {
9+
ScrollView {
10+
VStack(alignment: .leading, spacing: 0) {
11+
Example("maxWidth: .infinity") {
12+
Text("Fills the width")
13+
.foregroundColor(.white)
14+
.padding()
15+
.frame(maxWidth: .infinity)
16+
.background(Color.blue)
17+
}
18+
Example("Fill, leading-aligned content") {
19+
Text("Left")
20+
.foregroundColor(.white)
21+
.frame(maxWidth: .infinity, alignment: .leading)
22+
.padding()
23+
.background(Color.green)
24+
}
25+
Example("Fill, trailing-aligned content") {
26+
Text("Right")
27+
.foregroundColor(.white)
28+
.frame(maxWidth: .infinity, alignment: .trailing)
29+
.padding()
30+
.background(Color.orange)
31+
}
32+
Example("Fixed 220x90, bottom-trailing content") {
33+
Text("corner")
34+
.foregroundColor(.white)
35+
.frame(width: 220, height: 90, alignment: .bottomTrailing)
36+
.background(Color.purple)
37+
}
38+
Example("Bounded width (min 120, max 200)") {
39+
Text("Bounded box grows to fit within limits")
40+
.padding()
41+
.frame(minWidth: 120, maxWidth: 200)
42+
.background(Color.pink)
43+
}
44+
}
45+
}
46+
}
47+
}

‎Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ import androidx.compose.foundation.layout.ColumnScope
1313
import androidx.compose.foundation.layout.Row
1414
import androidx.compose.foundation.layout.RowScope
1515
import androidx.compose.foundation.layout.Spacer
16+
import androidx.compose.foundation.layout.fillMaxHeight
1617
import androidx.compose.foundation.layout.height
18+
import androidx.compose.foundation.layout.heightIn
1719
import androidx.compose.foundation.layout.padding
1820
import androidx.compose.foundation.layout.size
1921
import androidx.compose.foundation.layout.width
22+
import androidx.compose.foundation.layout.widthIn
23+
import androidx.compose.foundation.layout.wrapContentSize
2024
import androidx.compose.animation.AnimatedContent
2125
import androidx.compose.animation.animateColorAsState
2226
import androidx.compose.animation.core.AnimationSpec
@@ -893,16 +897,7 @@ internal fun ViewNode.composeModifiers(): Modifier {
893897
}
894898
}
895899

896-
"frame" -> {
897-
val width = entry.args.double("width")
898-
val height = entry.args.double("height")
899-
when {
900-
width != null && height != null -> modifier.size(animatedDp(width.dp, spec), animatedDp(height.dp, spec))
901-
width != null -> modifier.width(animatedDp(width.dp, spec))
902-
height != null -> modifier.height(animatedDp(height.dp, spec))
903-
else -> modifier
904-
}
905-
}
900+
"frame" -> foldFrame(modifier, entry, spec)
906901

907902
"background" -> {
908903
val argb = entry.args.long("color") ?: 0
@@ -961,6 +956,56 @@ internal fun ViewNode.composeModifiers(): Modifier {
961956
return modifier
962957
}
963958

959+
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded
960+
// (widthIn/heightIn), and content alignment within the resulting box.
961+
@Composable
962+
private fun foldFrame(base: Modifier, entry: ModifierNode, spec: AnimSpec?): Modifier {
963+
var m = base
964+
val fixedW = entry.args.double("width")
965+
val fixedH = entry.args.double("height")
966+
val minW = entry.args.double("minWidth")
967+
val maxW = entry.args.double("maxWidth")
968+
val minH = entry.args.double("minHeight")
969+
val maxH = entry.args.double("maxHeight")
970+
val fillW = entry.args.bool("fillWidth") == true
971+
val fillH = entry.args.bool("fillHeight") == true
972+
973+
when {
974+
fixedW != null -> m = m.width(animatedDp(fixedW.dp, spec))
975+
fillW -> m = m.fillMaxWidth()
976+
minW != null || maxW != null -> m = m.widthIn(
977+
min = minW?.dp ?: Dp.Unspecified,
978+
max = maxW?.dp ?: Dp.Unspecified,
979+
)
980+
}
981+
when {
982+
fixedH != null -> m = m.height(animatedDp(fixedH.dp, spec))
983+
fillH -> m = m.fillMaxHeight()
984+
minH != null || maxH != null -> m = m.heightIn(
985+
min = minH?.dp ?: Dp.Unspecified,
986+
max = maxH?.dp ?: Dp.Unspecified,
987+
)
988+
}
989+
val h = entry.args.string("horizontal")
990+
val v = entry.args.string("vertical")
991+
if (h != null || v != null) {
992+
m = m.wrapContentSize(frameAlignment(h ?: "center", v ?: "center"))
993+
}
994+
return m
995+
}
996+
997+
private fun frameAlignment(horizontal: String, vertical: String): Alignment = when (vertical to horizontal) {
998+
"top" to "leading" -> Alignment.TopStart
999+
"top" to "center" -> Alignment.TopCenter
1000+
"top" to "trailing" -> Alignment.TopEnd
1001+
"center" to "leading" -> Alignment.CenterStart
1002+
"center" to "trailing" -> Alignment.CenterEnd
1003+
"bottom" to "leading" -> Alignment.BottomStart
1004+
"bottom" to "center" -> Alignment.BottomCenter
1005+
"bottom" to "trailing" -> Alignment.BottomEnd
1006+
else -> Alignment.Center
1007+
}
1008+
9641009
// Always-called animated wrappers: the underlying Animatable persists across
9651010
// recompositions, so a later tween eases from wherever the value currently is.
9661011

‎Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,5 @@ internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimit
8484
internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
8585

8686
internal fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.content
87+
88+
internal fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull

0 commit comments

Comments
 (0)