Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AndroidSwiftUICore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.build/
.swiftpm/
*.d
*.dia
*.swiftdeps
*.swiftmodule
*.swiftsourceinfo
*.abi.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,63 @@ public extension View {
// MARK: - Frame

public struct _FrameModifier: RenderModifier {
let width: Double?
let height: Double?
var width: Double? = nil
var height: Double? = nil
var minWidth: Double? = nil
var idealWidth: Double? = nil
var maxWidth: Double? = nil
var minHeight: Double? = nil
var idealHeight: Double? = nil
var maxHeight: Double? = nil
var alignment: Alignment = .center

public var _modifierNode: ModifierNode {
var args: [String: PropValue] = [:]
if let width { args["width"] = .double(width) }
if let height { args["height"] = .double(height) }
if let minWidth { args["minWidth"] = .double(minWidth) }
if let idealWidth { args["idealWidth"] = .double(idealWidth) }
// .infinity can't cross as a JSON number, so a fill flag carries it.
if let maxWidth {
if maxWidth == .infinity { args["fillWidth"] = .bool(true) }
else { args["maxWidth"] = .double(maxWidth) }
}
if let minHeight { args["minHeight"] = .double(minHeight) }
if let idealHeight { args["idealHeight"] = .double(idealHeight) }
if let maxHeight {
if maxHeight == .infinity { args["fillHeight"] = .bool(true) }
else { args["maxHeight"] = .double(maxHeight) }
}
if alignment.horizontal != .center || alignment.vertical != .center {
args["horizontal"] = .string(alignment.horizontal.rawValue)
args["vertical"] = .string(alignment.vertical.rawValue)
}
return ModifierNode(kind: "frame", args: args)
}
}

public extension View {
func frame(width: Double? = nil, height: Double? = nil) -> ModifiedContent<Self, _FrameModifier> {
modifier(_FrameModifier(width: width, height: height))
/// A fixed frame, optionally aligning the content within it.
func frame(width: Double? = nil, height: Double? = nil, alignment: Alignment = .center) -> ModifiedContent<Self, _FrameModifier> {
modifier(_FrameModifier(width: width, height: height, alignment: alignment))
}

/// A flexible frame with size bounds. `maxWidth`/`maxHeight` of `.infinity`
/// expand to fill the available space.
func frame(
minWidth: Double? = nil,
idealWidth: Double? = nil,
maxWidth: Double? = nil,
minHeight: Double? = nil,
idealHeight: Double? = nil,
maxHeight: Double? = nil,
alignment: Alignment = .center
) -> ModifiedContent<Self, _FrameModifier> {
modifier(_FrameModifier(
minWidth: minWidth, idealWidth: idealWidth, maxWidth: maxWidth,
minHeight: minHeight, idealHeight: idealHeight, maxHeight: maxHeight,
alignment: alignment
))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ public struct Alignment: Sendable {
self.vertical = vertical
}
public static let center = Alignment(horizontal: .center, vertical: .center)
public static let leading = Alignment(horizontal: .leading, vertical: .center)
public static let trailing = Alignment(horizontal: .trailing, vertical: .center)
public static let top = Alignment(horizontal: .center, vertical: .top)
public static let bottom = Alignment(horizontal: .center, vertical: .bottom)
public static let topLeading = Alignment(horizontal: .leading, vertical: .top)
public static let topTrailing = Alignment(horizontal: .trailing, vertical: .top)
public static let bottomLeading = Alignment(horizontal: .leading, vertical: .bottom)
public static let bottomTrailing = Alignment(horizontal: .trailing, vertical: .bottom)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ struct ModifierTests {
#expect(frame?.args["height"] == .double(50))
}

@Test("A fixed frame with default center alignment emits no alignment")
func frameDefaultAlignment() {
let node = ViewHost(Text("x").frame(width: 100, height: 50)).evaluate()
let frame = node.modifiers.first { $0.kind == "frame" }
#expect(frame?.args["horizontal"] == nil)
#expect(frame?.args["vertical"] == nil)
}

@Test("maxWidth .infinity becomes a fill flag, not a number")
func frameFill() {
let node = ViewHost(Text("x").frame(maxWidth: .infinity, alignment: .leading)).evaluate()
let frame = node.modifiers.first { $0.kind == "frame" }
#expect(frame?.args["fillWidth"] == .bool(true))
#expect(frame?.args["maxWidth"] == nil)
#expect(frame?.args["horizontal"] == .string("leading"))
}

@Test("Bounded frame emits its min and max")
func frameBounds() {
let node = ViewHost(Text("x").frame(minWidth: 40, maxWidth: 200, minHeight: 20)).evaluate()
let frame = node.modifiers.first { $0.kind == "frame" }
#expect(frame?.args["minWidth"] == .double(40))
#expect(frame?.args["maxWidth"] == .double(200))
#expect(frame?.args["minHeight"] == .double(20))
}

@Test("Named font emits its style")
func fontStyle() {
let node = ViewHost(Text("x").font(.headline)).evaluate()
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())),
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())),
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
Expand Down
47 changes: 47 additions & 0 deletions Demo/App.swiftpm/Sources/FramePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

struct FramePlayground: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("maxWidth: .infinity") {
Text("Fills the width")
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
}
Example("Fill, leading-aligned content") {
Text("Left")
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.green)
}
Example("Fill, trailing-aligned content") {
Text("Right")
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding()
.background(Color.orange)
}
Example("Fixed 220x90, bottom-trailing content") {
Text("corner")
.foregroundColor(.white)
.frame(width: 220, height: 90, alignment: .bottomTrailing)
.background(Color.purple)
}
Example("Bounded width (min 120, max 200)") {
Text("Bounded box grows to fit within limits")
.padding()
.frame(minWidth: 120, maxWidth: 200)
.background(Color.pink)
}
}
}
}
}
65 changes: 55 additions & 10 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
Expand Down Expand Up @@ -845,16 +849,7 @@ internal fun ViewNode.composeModifiers(): Modifier {
}
}

"frame" -> {
val width = entry.args.double("width")
val height = entry.args.double("height")
when {
width != null && height != null -> modifier.size(animatedDp(width.dp, spec), animatedDp(height.dp, spec))
width != null -> modifier.width(animatedDp(width.dp, spec))
height != null -> modifier.height(animatedDp(height.dp, spec))
else -> modifier
}
}
"frame" -> foldFrame(modifier, entry, spec)

"background" -> {
val argb = entry.args.long("color") ?: 0
Expand Down Expand Up @@ -913,6 +908,56 @@ internal fun ViewNode.composeModifiers(): Modifier {
return modifier
}

// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded
// (widthIn/heightIn), and content alignment within the resulting box.
@Composable
private fun foldFrame(base: Modifier, entry: ModifierNode, spec: AnimSpec?): Modifier {
var m = base
val fixedW = entry.args.double("width")
val fixedH = entry.args.double("height")
val minW = entry.args.double("minWidth")
val maxW = entry.args.double("maxWidth")
val minH = entry.args.double("minHeight")
val maxH = entry.args.double("maxHeight")
val fillW = entry.args.bool("fillWidth") == true
val fillH = entry.args.bool("fillHeight") == true

when {
fixedW != null -> m = m.width(animatedDp(fixedW.dp, spec))
fillW -> m = m.fillMaxWidth()
minW != null || maxW != null -> m = m.widthIn(
min = minW?.dp ?: Dp.Unspecified,
max = maxW?.dp ?: Dp.Unspecified,
)
}
when {
fixedH != null -> m = m.height(animatedDp(fixedH.dp, spec))
fillH -> m = m.fillMaxHeight()
minH != null || maxH != null -> m = m.heightIn(
min = minH?.dp ?: Dp.Unspecified,
max = maxH?.dp ?: Dp.Unspecified,
)
}
val h = entry.args.string("horizontal")
val v = entry.args.string("vertical")
if (h != null || v != null) {
m = m.wrapContentSize(frameAlignment(h ?: "center", v ?: "center"))
}
return m
}

private fun frameAlignment(horizontal: String, vertical: String): Alignment = when (vertical to horizontal) {
"top" to "leading" -> Alignment.TopStart
"top" to "center" -> Alignment.TopCenter
"top" to "trailing" -> Alignment.TopEnd
"center" to "leading" -> Alignment.CenterStart
"center" to "trailing" -> Alignment.CenterEnd
"bottom" to "leading" -> Alignment.BottomStart
"bottom" to "center" -> Alignment.BottomCenter
"bottom" to "trailing" -> Alignment.BottomEnd
else -> Alignment.Center
}

// Always-called animated wrappers: the underlying Animatable persists across
// recompositions, so a later tween eases from wherever the value currently is.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ internal fun JsonObject.double(key: String): Double? = (this[key] as? JsonPrimit
internal fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull

internal fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.content

internal fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
Loading