From 6c49292e1b0144f4b06695da85b2f6abfcae535b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 1/9] Render ProgressView as a native progress bar Determinate progress uses the horizontal track and indeterminate progress the platform spinner; the two are separate widgets because the track is only selectable through a style attribute at construction. --- .../AndroidSwiftUI/AndroidProgressView.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Sources/AndroidSwiftUI/AndroidProgressView.swift diff --git a/Sources/AndroidSwiftUI/AndroidProgressView.swift b/Sources/AndroidSwiftUI/AndroidProgressView.swift new file mode 100644 index 0000000..10f52b6 --- /dev/null +++ b/Sources/AndroidSwiftUI/AndroidProgressView.swift @@ -0,0 +1,68 @@ +// +// AndroidProgressView.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit + +/// The scale determinate progress is reported on, since `ProgressBar` works in whole steps +/// while `ProgressView` reports a fraction between zero and one. +private let progressScale: Int32 = 10_000 + +extension _FractionalProgressView: AndroidPrimitive { + + var renderedBody: AnyView { + AnyView(AndroidProgressBar(fractionCompleted: fractionCompleted)) + } +} + +extension _IndeterminateProgressView: AndroidPrimitive { + + var renderedBody: AnyView { + AnyView(AndroidProgressBar(fractionCompleted: nil)) + } +} + +/// Native progress bar, shown as a horizontal track when the progress is known and as the +/// platform's indeterminate spinner otherwise. +struct AndroidProgressBar { + + let fractionCompleted: Double? +} + +extension AndroidProgressBar: AndroidViewRepresentable { + + typealias Coordinator = Void + + func makeAndroidView(context: Self.Context) -> AndroidWidget.ProgressBar { + // the horizontal track is only selectable through a style attribute, so the + // determinate and indeterminate bars are distinct widgets rather than one widget + // toggling `setIndeterminate` + let style = fractionCompleted == nil + ? try! JavaClass().progressBarStyle + : try! JavaClass().progressBarStyleHorizontal + let view = AndroidWidget.ProgressBar(context.androidContext, nil, style) + view.setMax(progressScale) + updateView(view) + return view + } + + func updateAndroidView(_ view: AndroidWidget.ProgressBar, context: Self.Context) { + updateView(view) + } +} + +private extension AndroidProgressBar { + + func updateView(_ view: AndroidWidget.ProgressBar) { + guard let fractionCompleted else { + view.setIndeterminate(true) + return + } + view.setIndeterminate(false) + let clamped = min(max(fractionCompleted, 0), 1) + view.setProgress(Int32((clamped * Double(progressScale)).rounded())) + } +} From 8e481ff12a5e35def28fcd57aaa3dda73cfef098 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 2/9] Add a checked change listener bridge for compound buttons --- .../swiftandroid/CompoundButtonOnCheckedChangeListener.kt | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/CompoundButtonOnCheckedChangeListener.kt diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/CompoundButtonOnCheckedChangeListener.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/CompoundButtonOnCheckedChangeListener.kt new file mode 100644 index 0000000..7cce5ee --- /dev/null +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/CompoundButtonOnCheckedChangeListener.kt @@ -0,0 +1,8 @@ +package com.pureswift.swiftandroid + +import android.widget.CompoundButton + +class CompoundButtonOnCheckedChangeListener(val action: SwiftObject): CompoundButton.OnCheckedChangeListener { + + external override fun onCheckedChanged(button: CompoundButton, isChecked: Boolean) +} From be6e9a92e042f04031b7804e2705c4c567038dc0 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 3/9] Bridge compound button checked changes to Swift --- .../OnCheckedChangeListener.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Sources/AndroidSwiftUI/OnCheckedChangeListener.swift diff --git a/Sources/AndroidSwiftUI/OnCheckedChangeListener.swift b/Sources/AndroidSwiftUI/OnCheckedChangeListener.swift new file mode 100644 index 0000000..38ea6f3 --- /dev/null +++ b/Sources/AndroidSwiftUI/OnCheckedChangeListener.swift @@ -0,0 +1,45 @@ +// +// OnCheckedChangeListener.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import Foundation +import AndroidKit + +@JavaClass("com.pureswift.swiftandroid.CompoundButtonOnCheckedChangeListener", extends: AndroidWidget.CompoundButton.OnCheckedChangeListener.self) +open class CompoundButtonOnCheckedChangeListener: JavaObject { + + public typealias Action = (Bool) -> () + + @JavaMethod + @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) + + @JavaMethod + public func getAction() -> SwiftObject? +} + +@JavaImplementation("com.pureswift.swiftandroid.CompoundButtonOnCheckedChangeListener") +extension CompoundButtonOnCheckedChangeListener { + + @JavaMethod + func onCheckedChanged(_ isChecked: Bool) { + // drain queue, matching `ViewOnClickListener` + RunLoop.main.run(until: Date() + 0.01) + action(isChecked) + RunLoop.main.run(until: Date() + 0.01) + } +} + +public extension CompoundButtonOnCheckedChangeListener { + + convenience init(action: @escaping (Bool) -> (), environment: JNIEnvironment? = nil) { + let object = SwiftObject(action, environment: environment) + self.init(action: object, environment: environment) + } + + var action: ((Bool) -> ()) { + getAction()!.valueObject().value as! Action + } +} From 83e0bebf6b0551eed8dd3e44338ecd2fc378e25f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 4/9] Render Toggle as a Material switch Supplied as the default toggle style rather than a primitive conformance, so the binding round-trips and .toggleStyle(_:) keeps working. --- Sources/AndroidSwiftUI/AndroidToggle.swift | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 Sources/AndroidSwiftUI/AndroidToggle.swift diff --git a/Sources/AndroidSwiftUI/AndroidToggle.swift b/Sources/AndroidSwiftUI/AndroidToggle.swift new file mode 100644 index 0000000..6f7ce0b --- /dev/null +++ b/Sources/AndroidSwiftUI/AndroidToggle.swift @@ -0,0 +1,66 @@ +// +// AndroidToggle.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit + +/// Renders a toggle as the platform's Material switch. +/// +/// `Toggle` reads `EnvironmentValues.toggleStyle` whether or not its body is used, and the +/// key traps unless a renderer supplies a default, so this is registered in +/// `EnvironmentValues.defaultEnvironment`. +struct DefaultToggleStyle: ToggleStyle { + + typealias Body = AndroidSwitch + + func makeBody(configuration: ToggleStyleConfiguration) -> AndroidSwitch { + AndroidSwitch(isOn: configuration.$isOn, label: configuration.label) + } +} + +/// Material switch bound to a `Bool`, labelled by the toggle's own label. +struct AndroidSwitch { + + @Binding + var isOn: Bool + + let label: AnyView +} + +extension AndroidSwitch: AndroidViewRepresentable { + + typealias Coordinator = Void + + func makeAndroidView(context: Self.Context) -> AndroidMaterial.MaterialSwitch { + let view = MaterialSwitch(context.androidContext) + let listener = CompoundButtonOnCheckedChangeListener { isChecked in + // only write back on a real change, so the binding isn't touched when the + // reconciler sets the checked state during an update + guard isChecked != self.isOn else { return } + self.isOn = isChecked + } + view.setOnCheckedChangeListener(listener.as(AndroidWidget.CompoundButton.OnCheckedChangeListener.self)) + updateView(view) + return view + } + + func updateAndroidView(_ view: AndroidMaterial.MaterialSwitch, context: Self.Context) { + updateView(view) + } +} + +private extension AndroidSwitch { + + func updateView(_ view: AndroidMaterial.MaterialSwitch) { + if view.isChecked() != isOn { + view.setChecked(isOn) + } + if let text = mapAnyView(label, transform: { (text: Text) in text }), + let textView = view.as(AndroidWidget.TextView.self) { + text.updateTextView(textView) + } + } +} From 8d7a367bbafe18d113f26c5e6ec66943ca5b9536 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 5/9] Provide a renderer default for the toggle style The key traps without one, and is read whenever a toggle is in the view tree rather than only when its body is evaluated. --- .../TokamakCore/Environment/EnvironmentValues.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AndroidSwiftUI/TokamakCore/Environment/EnvironmentValues.swift b/Sources/AndroidSwiftUI/TokamakCore/Environment/EnvironmentValues.swift index 54d2b18..81423b0 100644 --- a/Sources/AndroidSwiftUI/TokamakCore/Environment/EnvironmentValues.swift +++ b/Sources/AndroidSwiftUI/TokamakCore/Environment/EnvironmentValues.swift @@ -117,6 +117,9 @@ internal extension EnvironmentValues { static var defaultEnvironment: Self { var environment = EnvironmentValues() environment[_ColorSchemeKey.self] = .light + // control style keys trap unless the renderer supplies a default, and are read + // whenever a control is in the view tree — not only when its body is evaluated + environment[_ToggleStyleKey.self] = _AnyToggleStyle(DefaultToggleStyle()) return environment } } From 9673465292b9792e6e73812a148f041787fea95d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 6/9] Add a gallery screen exercising form controls --- Demo/App.swiftpm/Sources/ControlsScreen.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/ControlsScreen.swift diff --git a/Demo/App.swiftpm/Sources/ControlsScreen.swift b/Demo/App.swiftpm/Sources/ControlsScreen.swift new file mode 100644 index 0000000..5622c6f --- /dev/null +++ b/Demo/App.swiftpm/Sources/ControlsScreen.swift @@ -0,0 +1,53 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Form controls and their state bindings. +struct ControlsScreen: View { + + var body: some View { + ScrollView { + VStack(spacing: 24) { + ToggleSection() + Divider() + ProgressSection() + } + } + } +} + +struct ToggleSection: View { + + @State + private var isOn = false + + var body: some View { + VStack(spacing: 8) { + Text("Toggle") + Toggle("Enabled", isOn: $isOn) + Text(isOn ? "Toggle is on" : "Toggle is off") + } + } +} + +struct ProgressSection: View { + + @State + private var progress = 0.25 + + var body: some View { + VStack(spacing: 8) { + Text("Indeterminate progress") + ProgressView() + Divider() + Text("Determinate progress") + ProgressView(value: progress) + Text("\(Int(progress * 100))%") + Button("Advance") { + progress = progress >= 1 ? 0 : progress + 0.25 + } + } + } +} From 6c2b97b056a92bd6e119e4c4edbd2a77fa3a2fdf Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 09:45:08 -0400 Subject: [PATCH 7/9] Add Controls screen to the gallery --- Demo/App.swiftpm/Sources/ContentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Demo/App.swiftpm/Sources/ContentView.swift b/Demo/App.swiftpm/Sources/ContentView.swift index 58325f2..191cf1c 100644 --- a/Demo/App.swiftpm/Sources/ContentView.swift +++ b/Demo/App.swiftpm/Sources/ContentView.swift @@ -34,6 +34,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable { case observation case state case modifiers + case controls var id: String { rawValue } @@ -49,6 +50,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable { case .observation: return "Observation" case .state: return "State" case .modifiers: return "Modifiers" + case .controls: return "Controls" } } @@ -64,6 +66,7 @@ enum GalleryScreen: String, CaseIterable, Identifiable { case .observation: return AnyView(ObservationScreen()) case .state: return AnyView(StateScreen()) case .modifiers: return AnyView(ModifierScreen()) + case .controls: return AnyView(ControlsScreen()) } } } From e926d3326221d16fc199ae8e49e7f3f998d8e67b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 10:30:29 -0400 Subject: [PATCH 8/9] Flatten the controls gallery screen The view builder no longer caps a block at ten children, so the sections no longer need to be split into separate views. --- Demo/App.swiftpm/Sources/ControlsScreen.swift | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/Demo/App.swiftpm/Sources/ControlsScreen.swift b/Demo/App.swiftpm/Sources/ControlsScreen.swift index 5622c6f..9bb054c 100644 --- a/Demo/App.swiftpm/Sources/ControlsScreen.swift +++ b/Demo/App.swiftpm/Sources/ControlsScreen.swift @@ -7,46 +7,28 @@ import SwiftUI /// Form controls and their state bindings. struct ControlsScreen: View { - var body: some View { - ScrollView { - VStack(spacing: 24) { - ToggleSection() - Divider() - ProgressSection() - } - } - } -} - -struct ToggleSection: View { - @State private var isOn = false - var body: some View { - VStack(spacing: 8) { - Text("Toggle") - Toggle("Enabled", isOn: $isOn) - Text(isOn ? "Toggle is on" : "Toggle is off") - } - } -} - -struct ProgressSection: View { - @State private var progress = 0.25 var body: some View { - VStack(spacing: 8) { - Text("Indeterminate progress") - ProgressView() - Divider() - Text("Determinate progress") - ProgressView(value: progress) - Text("\(Int(progress * 100))%") - Button("Advance") { - progress = progress >= 1 ? 0 : progress + 0.25 + ScrollView { + VStack(spacing: 24) { + Text("Toggle") + Toggle("Enabled", isOn: $isOn) + Text(isOn ? "Toggle is on" : "Toggle is off") + Divider() + Text("Indeterminate progress") + ProgressView() + Divider() + Text("Determinate progress") + ProgressView(value: progress) + Text("\(Int(progress * 100))%") + Button("Advance") { + progress = progress >= 1 ? 0 : progress + 0.25 + } } } } From 4cc82297c7360bf263c354397ec7889a6c0dd05d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 10:30:29 -0400 Subject: [PATCH 9/9] Copy every built shared library into jniLibs The app links against `libSwiftJava.so` at runtime and fails to start when only `libSwiftAndroidApp.so` is copied. --- Demo/build-swift.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Demo/build-swift.sh b/Demo/build-swift.sh index e0e1741..25aeb4f 100755 --- a/Demo/build-swift.sh +++ b/Demo/build-swift.sh @@ -8,9 +8,10 @@ xcrun --toolchain swift swift build -c $SWIFT_COMPILATION_MODE \ --toolchain $XCTOOLCHAIN \ --package-path $SWIFT_PACKAGE_SRC -# Copy compiled Swift package +# Copy compiled Swift package, along with the shared libraries it links against +# (`libSwiftJava.so` is loaded at runtime and the app fails to start without it) mkdir -p $SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH/ -cp -rf $SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftAndroidApp.so \ +cp -rf $SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/*.so \ $SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH/ # Build locally for preview