From 0ecc175b02484a05bbb3765ce190c0ac9a35301d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 21:08:44 -0400 Subject: [PATCH 1/5] Add control style modifiers --- .../Modifiers/ControlStyles.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/ControlStyles.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/ControlStyles.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/ControlStyles.swift new file mode 100644 index 0000000..f028ede --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/ControlStyles.swift @@ -0,0 +1,67 @@ +// +// ControlStyles.swift +// AndroidSwiftUICore +// +// Control styles. Unlike a per-node modifier, a style applies to every matching +// control in the subtree — `VStack { … }.buttonStyle(.bordered)` styles all the +// buttons inside it — so the interpreter carries these down as environment +// values rather than reading them off the styled node. +// +// These are the built-in style spellings only; the `ButtonStyle` / +// `ToggleStyle` / … protocols for writing custom styles aren't modeled. +// + +/// A style value shared by every control-style modifier: an opaque kind string +/// the interpreter maps to a platform presentation. +public struct _ControlStyle: Sendable, Equatable { + internal let kind: String + internal init(_ kind: String) { self.kind = kind } +} + +public extension _ControlStyle { + // button + static let automatic = _ControlStyle("automatic") + static let bordered = _ControlStyle("bordered") + static let borderedProminent = _ControlStyle("borderedProminent") + static let borderless = _ControlStyle("borderless") + static let plain = _ControlStyle("plain") + // picker + static let segmented = _ControlStyle("segmented") + static let menu = _ControlStyle("menu") + static let inline = _ControlStyle("inline") + // toggle + static let `switch` = _ControlStyle("switch") + static let checkbox = _ControlStyle("checkbox") + static let button = _ControlStyle("button") + // text field + static let roundedBorder = _ControlStyle("roundedBorder") +} + +/// Emits one style kind; the `kind` names which control it governs, so styles +/// for different controls compose instead of overwriting each other. +public struct _ControlStyleModifier: RenderModifier { + let control: String + let style: _ControlStyle + public var _modifierNode: ModifierNode { + ModifierNode(kind: control, args: ["style": .string(style.kind)]) + } +} + +public extension View { + + func buttonStyle(_ style: _ControlStyle) -> ModifiedContent { + modifier(_ControlStyleModifier(control: "buttonStyle", style: style)) + } + + func pickerStyle(_ style: _ControlStyle) -> ModifiedContent { + modifier(_ControlStyleModifier(control: "pickerStyle", style: style)) + } + + func toggleStyle(_ style: _ControlStyle) -> ModifiedContent { + modifier(_ControlStyleModifier(control: "toggleStyle", style: style)) + } + + func textFieldStyle(_ style: _ControlStyle) -> ModifiedContent { + modifier(_ControlStyleModifier(control: "textFieldStyle", style: style)) + } +} From c64e4a3fd75d3b0a303a1136596b2ca2ad0fab1f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 21:08:44 -0400 Subject: [PATCH 2/5] Style controls from the inherited environment --- .../kotlin/com/pureswift/swiftui/Render.kt | 235 ++++++++++++++---- 1 file changed, 187 insertions(+), 48 deletions(-) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 2ac0918..b26e428 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -59,6 +59,16 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -179,6 +189,13 @@ internal val LocalInheritedColor = compositionLocalOf { Color.Unspecified } internal val LocalInheritedDisabled = compositionLocalOf { false } internal val LocalTint = compositionLocalOf { null } +// Control styles inherit like the text attributes above: a style set on a +// container applies to every matching control in its subtree. +internal val LocalButtonStyle = compositionLocalOf { "automatic" } +internal val LocalPickerStyle = compositionLocalOf { "automatic" } +internal val LocalToggleStyle = compositionLocalOf { "automatic" } +internal val LocalTextFieldStyle = compositionLocalOf { "automatic" } + /// Interprets a Swift-evaluated node tree into Material 3 composables. /// /// One `when` per node type; unknown types render a diagnostic so schema @@ -209,6 +226,10 @@ internal fun RenderChild(node: ViewNode) { var color = LocalInheritedColor.current var disabled = LocalInheritedDisabled.current var tint = LocalTint.current + var buttonStyle = LocalButtonStyle.current + var pickerStyle = LocalPickerStyle.current + var toggleStyle = LocalToggleStyle.current + var textFieldStyle = LocalTextFieldStyle.current for (m in node.modifiers) { when (m.kind) { "font" -> { @@ -223,6 +244,10 @@ internal fun RenderChild(node: ViewNode) { "foregroundColor" -> m.args.long("color")?.let { color = Color(it.toInt()) } "disabled" -> if ((m.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true") disabled = true "tint" -> m.args.long("color")?.let { tint = Color(it.toInt()) } + "buttonStyle" -> m.args.string("style")?.let { buttonStyle = it } + "pickerStyle" -> m.args.string("style")?.let { pickerStyle = it } + "toggleStyle" -> m.args.string("style")?.let { toggleStyle = it } + "textFieldStyle" -> m.args.string("style")?.let { textFieldStyle = it } } } @@ -233,6 +258,10 @@ internal fun RenderChild(node: ViewNode) { LocalInheritedColor provides color, LocalInheritedDisabled provides disabled, LocalTint provides tint, + LocalButtonStyle provides buttonStyle, + LocalPickerStyle provides pickerStyle, + LocalToggleStyle provides toggleStyle, + LocalTextFieldStyle provides textFieldStyle, ) { RenderResolved(node) } } @@ -243,32 +272,9 @@ private fun RenderResolved(node: ViewNode) { when (node.type) { "Text" -> RenderText(node) - "Button" -> { - val onTap = node.long("onTap") - val tint = LocalTint.current - Button( - onClick = { onTap?.let { SwiftBridge.sink.invokeVoid(it) } }, - enabled = node.isEnabled(), - colors = if (tint != null) ButtonDefaults.buttonColors(containerColor = tint) else ButtonDefaults.buttonColors(), - modifier = node.composeModifiers(), - ) { - RenderChildren(node) - } - } + "Button" -> RenderButton(node) - "Toggle" -> { - val onChange = node.long("onChange") - val tint = LocalTint.current - Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers()) { - RenderChildren(node) - Switch( - checked = node.bool("isOn") ?: false, - onCheckedChange = { onChange?.let { id -> SwiftBridge.sink.invokeBool(id, it) } }, - enabled = node.isEnabled(), - colors = if (tint != null) SwitchDefaults.colors(checkedTrackColor = tint) else SwitchDefaults.colors(), - ) - } - } + "Toggle" -> RenderToggle(node) "VStack" -> Column( verticalArrangement = node.double("spacing") @@ -464,6 +470,82 @@ private fun RenderProgressView(node: ViewNode) { } } +// The inherited style picks the button's presentation; `plain`/`borderless` +// drop the container so only the label shows, as they do on iOS. +@Composable +private fun RenderButton(node: ViewNode) { + val onTap = node.long("onTap") + val tint = LocalTint.current + val click = { onTap?.let { SwiftBridge.sink.invokeVoid(it) }; Unit } + val enabled = node.isEnabled() + val modifier = node.composeModifiers() + val content: @Composable RowScope.() -> Unit = { RenderChildren(node) } + + when (LocalButtonStyle.current) { + "plain", "borderless" -> TextButton( + onClick = click, + enabled = enabled, + colors = if (tint != null) ButtonDefaults.textButtonColors(contentColor = tint) else ButtonDefaults.textButtonColors(), + modifier = modifier, + content = content, + ) + "bordered" -> OutlinedButton( + onClick = click, + enabled = enabled, + colors = if (tint != null) ButtonDefaults.outlinedButtonColors(contentColor = tint) else ButtonDefaults.outlinedButtonColors(), + modifier = modifier, + content = content, + ) + // automatic and borderedProminent are both the filled button + else -> Button( + onClick = click, + enabled = enabled, + colors = if (tint != null) ButtonDefaults.buttonColors(containerColor = tint) else ButtonDefaults.buttonColors(), + modifier = modifier, + content = content, + ) + } +} + +// switch (default), checkbox, or a toggling button. +@Composable +private fun RenderToggle(node: ViewNode) { + val onChange = node.long("onChange") + val tint = LocalTint.current + val isOn = node.bool("isOn") ?: false + val enabled = node.isEnabled() + val set = { value: Boolean -> onChange?.let { SwiftBridge.sink.invokeBool(it, value) }; Unit } + + if (LocalToggleStyle.current == "button") { + // a button whose selected state is the toggle's value + FilterChip( + selected = isOn, + onClick = { set(!isOn) }, + enabled = enabled, + label = { RenderChildren(node) }, + modifier = node.composeModifiers(), + ) + return + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = node.composeModifiers()) { + RenderChildren(node) + when (LocalToggleStyle.current) { + "checkbox" -> Checkbox( + checked = isOn, + onCheckedChange = { set(it) }, + enabled = enabled, + colors = if (tint != null) CheckboxDefaults.colors(checkedColor = tint) else CheckboxDefaults.colors(), + ) + else -> Switch( + checked = isOn, + onCheckedChange = { set(it) }, + enabled = enabled, + colors = if (tint != null) SwitchDefaults.colors(checkedTrackColor = tint) else SwitchDefaults.colors(), + ) + } + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem" @@ -510,20 +592,42 @@ private fun RenderTextField(node: ViewNode) { } } - OutlinedTextField( - value = local, - onValueChange = { v -> - local = v - if (v.text != lastSent) { - lastSent = v.text - onChange?.let { SwiftBridge.sink.invokeString(it, v.text) } - } - }, - label = { Text(node.string("placeholder") ?: "") }, - enabled = node.isEnabled(), - visualTransformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None, - modifier = fieldModifier, - ) + val change: (TextFieldValue) -> Unit = { v -> + local = v + if (v.text != lastSent) { + lastSent = v.text + onChange?.let { SwiftBridge.sink.invokeString(it, v.text) } + } + } + val label: @Composable () -> Unit = { Text(node.string("placeholder") ?: "") } + val transformation = if (node.bool("secure") == true) PasswordVisualTransformation() else VisualTransformation.None + + // plain drops the box outline; roundedBorder and automatic keep it + if (LocalTextFieldStyle.current == "plain") { + TextField( + value = local, + onValueChange = change, + label = label, + enabled = node.isEnabled(), + visualTransformation = transformation, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ), + modifier = fieldModifier, + ) + } else { + OutlinedTextField( + value = local, + onValueChange = change, + label = label, + enabled = node.isEnabled(), + visualTransformation = transformation, + modifier = fieldModifier, + ) + } if (focus != null) { LaunchedEffect(shouldFocus) { @@ -667,16 +771,50 @@ private fun RenderPicker(node: ViewNode) { if (tag != null) tag to text else null } val currentLabel = options.firstOrNull { it.first == selection }?.second ?: (selection ?: "") - Row(modifier = node.composeModifiers().fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(node.string("title") ?: "") - Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = { expanded = true }, enabled = node.isEnabled()) { Text(currentLabel) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + val enabled = node.isEnabled() + val select = { value: String -> onChange?.let { SwiftBridge.sink.invokeString(it, value) }; Unit } + val title = node.string("title") ?: "" + + when (LocalPickerStyle.current) { + // a segmented control: every option visible at once + "segmented" -> Column(modifier = node.composeModifiers().fillMaxWidth()) { + if (title.isNotEmpty()) Text(title) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, (value, label) -> + SegmentedButton( + selected = value == selection, + onClick = { select(value) }, + enabled = enabled, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { Text(label) } + } + } + } + // inline: the options laid out in place as a selectable list + "inline" -> Column(modifier = node.composeModifiers().fillMaxWidth()) { + if (title.isNotEmpty()) Text(title) for ((value, label) in options) { - DropdownMenuItem(text = { Text(label) }, onClick = { - expanded = false - onChange?.let { SwiftBridge.sink.invokeString(it, value) } - }) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { select(value) }, + ) { + RadioButton(selected = value == selection, onClick = { select(value) }, enabled = enabled) + Text(label) + } + } + } + // automatic and menu: the label opens a dropdown + else -> Row(modifier = node.composeModifiers().fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title) + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { expanded = true }, enabled = enabled) { Text(currentLabel) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for ((value, label) in options) { + DropdownMenuItem(text = { Text(label) }, onClick = { + expanded = false + select(value) + }) + } } } } @@ -1315,6 +1453,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment", "tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem", "transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle", + "buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded From c10078db90700b4aa23fd7d011f7bdcb5eacf914 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 21:08:44 -0400 Subject: [PATCH 3/5] Test control style emission and inheritance --- .../ControlTests.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index 4e99923..b4abbfb 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -148,6 +148,53 @@ struct ControlTests { #expect(ViewHost(ProgressView(value: 0.5)).evaluate().children.isEmpty) } + @Test("A control style is emitted on the view it is applied to") + func controlStyleEmission() { + let node = ViewHost(Button("Go") {}.buttonStyle(.bordered)).evaluate() + #expect(node.modifiers.first { $0.kind == "buttonStyle" }?.args["style"] == .string("bordered")) + + // each control's style is a distinct kind, so they compose rather than + // overwrite one another + let both = ViewHost( + VStack { Text("x") } + .buttonStyle(.plain) + .pickerStyle(.segmented) + ).evaluate() + #expect(both.modifiers.first { $0.kind == "buttonStyle" }?.args["style"] == .string("plain")) + #expect(both.modifiers.first { $0.kind == "pickerStyle" }?.args["style"] == .string("segmented")) + #expect(both.modifiers.first { $0.kind == "toggleStyle" } == nil) + } + + @Test("A style set on a container is inherited by the controls inside it") + func controlStyleInheritance() { + // The style rides on the container; the interpreter carries it down as + // an environment value, so the buttons themselves carry no style of + // their own — that inheritance is what makes this different from a + // per-node modifier. + let node = ViewHost( + VStack { + Button("One") {} + Button("Two") {} + } + .buttonStyle(.borderedProminent) + ).evaluate() + #expect(node.type == "VStack") + #expect(node.modifiers.first { $0.kind == "buttonStyle" }?.args["style"] == .string("borderedProminent")) + #expect(node.children.count == 2) + for child in node.children { + #expect(child.type == "Button") + #expect(child.modifiers.first { $0.kind == "buttonStyle" } == nil) + } + } + + @Test("Every control style spelling reaches its own modifier kind") + func controlStyleKinds() { + let toggle = ViewHost(Toggle("t", isOn: .constant(true)).toggleStyle(.checkbox)).evaluate() + #expect(toggle.modifiers.first { $0.kind == "toggleStyle" }?.args["style"] == .string("checkbox")) + let field = ViewHost(TextField("n", text: .constant("")).textFieldStyle(.plain)).evaluate() + #expect(field.modifiers.first { $0.kind == "textFieldStyle" }?.args["style"] == .string("plain")) + } + @Test("Picker emits tagged children and maps the selection string back") func picker() { struct Screen: View { From 492c21058fb2b7a7c530958894487aadb7cefb90 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 21:08:44 -0400 Subject: [PATCH 4/5] Add a control style playground --- .../Sources/ControlStylePlaygrounds.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/ControlStylePlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/ControlStylePlaygrounds.swift b/Demo/App.swiftpm/Sources/ControlStylePlaygrounds.swift new file mode 100644 index 0000000..0d53dcd --- /dev/null +++ b/Demo/App.swiftpm/Sources/ControlStylePlaygrounds.swift @@ -0,0 +1,67 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct ControlStylePlayground: View { + + @State private var flavor = "Vanilla" + @State private var notify = true + @State private var name = "" + + private let flavors = ["Vanilla", "Cocoa", "Mint"] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("buttonStyle") { + VStack(alignment: .leading, spacing: 10) { + Button("automatic") {} + Button("bordered") {}.buttonStyle(.bordered) + Button("borderedProminent") {}.buttonStyle(.borderedProminent) + Button("plain") {}.buttonStyle(.plain) + } + } + Example("Inherited by a container") { + // one style, applied once, styles both buttons + VStack(alignment: .leading, spacing: 10) { + Button("Inherited one") {} + Button("Inherited two") {} + } + .buttonStyle(.bordered) + } + Example("pickerStyle") { + VStack(alignment: .leading, spacing: 14) { + Picker("Menu", selection: $flavor) { + ForEach(flavors, id: \.self) { Text($0).tag($0) } + } + Picker("Segmented", selection: $flavor) { + ForEach(flavors, id: \.self) { Text($0).tag($0) } + } + .pickerStyle(.segmented) + Picker("Inline", selection: $flavor) { + ForEach(flavors, id: \.self) { Text($0).tag($0) } + } + .pickerStyle(.inline) + Text("Chosen: \(flavor)") + } + } + Example("toggleStyle") { + VStack(alignment: .leading, spacing: 10) { + Toggle("switch", isOn: $notify) + Toggle("checkbox", isOn: $notify).toggleStyle(.checkbox) + Toggle("button", isOn: $notify).toggleStyle(.button) + } + } + Example("textFieldStyle") { + VStack(alignment: .leading, spacing: 10) { + TextField("rounded border", text: $name) + TextField("plain", text: $name).textFieldStyle(.plain) + } + } + } + } + .navigationTitle("Control Styles") + } +} From e1234cc33ccc6c808758498fa841f8627cee1c7c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 21:08:44 -0400 Subject: [PATCH 5/5] List the control style playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index e3120f1..5411360 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -34,6 +34,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "picker", title: "Picker", screen: AnyCatalogScreen(PickerPlayground())), CatalogEntry(id: "progress", title: "ProgressView", screen: AnyCatalogScreen(ProgressViewPlayground())), CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())), + CatalogEntry(id: "controlstyle", title: "Control Styles", screen: AnyCatalogScreen(ControlStylePlayground())), 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())),