From ebdf54148bb7e7b3c578c3bf215eec077057e932 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 1/8] Add a change listener bridge for seek bars --- .../SeekBarOnSeekBarChangeListener.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/SeekBarOnSeekBarChangeListener.kt diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/SeekBarOnSeekBarChangeListener.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/SeekBarOnSeekBarChangeListener.kt new file mode 100644 index 0000000..3815161 --- /dev/null +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/SeekBarOnSeekBarChangeListener.kt @@ -0,0 +1,21 @@ +package com.pureswift.swiftandroid + +import android.widget.SeekBar + +class SeekBarOnSeekBarChangeListener(val action: SwiftObject): SeekBar.OnSeekBarChangeListener { + + /// Registers itself on the bar, so Swift does not need a binding for the listener type. + fun attach(seekBar: SeekBar) { + seekBar.setOnSeekBarChangeListener(this) + } + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + onProgressChangedSwift(progress, fromUser) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { } + + override fun onStopTrackingTouch(seekBar: SeekBar) { } + + external fun onProgressChangedSwift(progress: Int, fromUser: Boolean) +} From 2e995bdbe1d1e8eda5fd2af42759426296294253 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 2/8] Bridge seek bar progress changes to Swift --- .../OnSeekBarChangeListener.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Sources/AndroidSwiftUI/OnSeekBarChangeListener.swift diff --git a/Sources/AndroidSwiftUI/OnSeekBarChangeListener.swift b/Sources/AndroidSwiftUI/OnSeekBarChangeListener.swift new file mode 100644 index 0000000..94da9de --- /dev/null +++ b/Sources/AndroidSwiftUI/OnSeekBarChangeListener.swift @@ -0,0 +1,57 @@ +// +// OnSeekBarChangeListener.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import Foundation +import AndroidKit + +/// Bridges `SeekBar.OnSeekBarChangeListener` to Swift. +/// +/// The Kotlin side collapses the three callbacks into one and drops the `SeekBar` argument, +/// so the native signature matches this declaration exactly — passing the bar through would +/// shift the remaining arguments and yield garbage values. +@JavaClass("com.pureswift.swiftandroid.SeekBarOnSeekBarChangeListener") +open class SeekBarOnSeekBarChangeListener: JavaObject { + + /// Reports the progress and whether the change came from the user. + public typealias Action = (Int32) -> () + + @JavaMethod + @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) + + @JavaMethod + public func getAction() -> SwiftObject? + + /// Registers this listener on the bar, since the listener type itself has no binding. + @JavaMethod + public func attach(_ seekBar: AndroidWidget.SeekBar?) +} + +@JavaImplementation("com.pureswift.swiftandroid.SeekBarOnSeekBarChangeListener") +extension SeekBarOnSeekBarChangeListener { + + @JavaMethod + func onProgressChangedSwift(_ progress: Int32, _ fromUser: Bool) { + // ignore programmatic changes, which the renderer makes while updating the view + guard fromUser else { return } + // drain queue, matching `ViewOnClickListener` + RunLoop.main.run(until: Date() + 0.01) + action(progress) + RunLoop.main.run(until: Date() + 0.01) + } +} + +public extension SeekBarOnSeekBarChangeListener { + + convenience init(action: @escaping (Int32) -> (), environment: JNIEnvironment? = nil) { + let object = SwiftObject(action, environment: environment) + self.init(action: object, environment: environment) + } + + var action: ((Int32) -> ()) { + getAction()!.valueObject().value as! Action + } +} From ad2107e81acd214f14eba4cd09bdca997c613cfa Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 3/8] Render Slider as a seek bar --- Sources/AndroidSwiftUI/AndroidSlider.swift | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Sources/AndroidSwiftUI/AndroidSlider.swift diff --git a/Sources/AndroidSwiftUI/AndroidSlider.swift b/Sources/AndroidSwiftUI/AndroidSlider.swift new file mode 100644 index 0000000..40c7b27 --- /dev/null +++ b/Sources/AndroidSwiftUI/AndroidSlider.swift @@ -0,0 +1,100 @@ +// +// AndroidSlider.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit + +/// The number of steps a continuous slider is divided into, since `SeekBar` reports whole +/// numbers while `Slider` works in a floating point range. +private let continuousSteps: Int32 = 10_000 + +extension Slider: AndroidPrimitive { + + var renderedBody: AnyView { + AnyView(AndroidSeekBar( + value: valueBinding, + bounds: bounds, + step: step, + onEditingChanged: onEditingChanged + )) + } +} + +/// Native seek bar bound to a `Double` within a range. +struct AndroidSeekBar { + + @Binding + var value: Double + + let bounds: ClosedRange + + let step: _SliderStep + + let onEditingChanged: (Bool) -> () +} + +extension AndroidSeekBar: AndroidViewRepresentable { + + typealias Coordinator = Void + + func makeAndroidView(context: Self.Context) -> AndroidWidget.SeekBar { + let view = AndroidWidget.SeekBar(context.androidContext) + // a seek bar that hugs its content collapses to the thumb and can't be dragged, so + // it spans the stack instead; the renderer preserves parameters set here + view.setLayoutParams(ViewGroup.LayoutParams( + try! JavaClass().MATCH_PARENT, + try! JavaClass().WRAP_CONTENT + )) + view.setMax(stepCount) + let listener = SeekBarOnSeekBarChangeListener { progress in + let newValue = self.value(forProgress: progress) + guard newValue != self.value else { return } + self.value = newValue + self.onEditingChanged(true) + } + listener.attach(view) + updateView(view) + return view + } + + func updateAndroidView(_ view: AndroidWidget.SeekBar, context: Self.Context) { + updateView(view) + } +} + +private extension AndroidSeekBar { + + /// The number of discrete positions the bar offers, matching the slider's step when it + /// has one so the thumb lands exactly on each value. + var stepCount: Int32 { + switch step { + case .any: + return continuousSteps + case let .discrete(stride): + guard stride > 0 else { return continuousSteps } + return Int32(((bounds.upperBound - bounds.lowerBound) / stride).rounded()) + } + } + + func value(forProgress progress: Int32) -> Double { + let fraction = Double(progress) / Double(stepCount) + return bounds.lowerBound + fraction * (bounds.upperBound - bounds.lowerBound) + } + + func progress(forValue value: Double) -> Int32 { + let span = bounds.upperBound - bounds.lowerBound + guard span > 0 else { return 0 } + let fraction = (min(max(value, bounds.lowerBound), bounds.upperBound) - bounds.lowerBound) / span + return Int32((fraction * Double(stepCount)).rounded()) + } + + func updateView(_ view: AndroidWidget.SeekBar) { + let progress = progress(forValue: value) + if view.getProgress() != progress { + view.setProgress(progress) + } + } +} From b0355849dff659adae7551c91cb4fcb04d117056 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 4/8] Add a text watcher bridge for editable text --- .../swiftandroid/EditTextTextWatcher.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/EditTextTextWatcher.kt diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/EditTextTextWatcher.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/EditTextTextWatcher.kt new file mode 100644 index 0000000..92599cd --- /dev/null +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/EditTextTextWatcher.kt @@ -0,0 +1,24 @@ +package com.pureswift.swiftandroid + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +class EditTextTextWatcher(val action: SwiftObject): TextWatcher { + + /// Registers itself on the field. `TextWatcher` has no Swift binding, so the + /// registration happens here rather than from Swift. + fun attach(editText: EditText) { + editText.addTextChangedListener(this) + } + + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) { } + + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) { + onTextChangedSwift(text?.toString() ?: "") + } + + override fun afterTextChanged(editable: Editable?) { } + + external fun onTextChangedSwift(text: String) +} From b673be500243ba05aa84fadd2102dad2ba780c28 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 5/8] Bridge text changes to Swift --- Sources/AndroidSwiftUI/TextWatcher.swift | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Sources/AndroidSwiftUI/TextWatcher.swift diff --git a/Sources/AndroidSwiftUI/TextWatcher.swift b/Sources/AndroidSwiftUI/TextWatcher.swift new file mode 100644 index 0000000..83ceb3e --- /dev/null +++ b/Sources/AndroidSwiftUI/TextWatcher.swift @@ -0,0 +1,53 @@ +// +// TextWatcher.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import Foundation +import AndroidKit + +/// Bridges `TextWatcher` to Swift. +/// +/// Only the changed text is reported: the Kotlin side collapses the three callbacks into a +/// single one, since the edit offsets aren't needed to drive a `String` binding. +@JavaClass("com.pureswift.swiftandroid.EditTextTextWatcher") +open class EditTextTextWatcher: JavaObject { + + public typealias Action = (String) -> () + + @JavaMethod + @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) + + @JavaMethod + public func getAction() -> SwiftObject? + + /// Registers this watcher on the field, since `TextWatcher` itself has no binding. + @JavaMethod + public func attach(_ editText: AndroidWidget.EditText?) +} + +@JavaImplementation("com.pureswift.swiftandroid.EditTextTextWatcher") +extension EditTextTextWatcher { + + @JavaMethod + func onTextChangedSwift(_ text: String) { + // drain queue, matching `ViewOnClickListener` + RunLoop.main.run(until: Date() + 0.01) + action(text) + RunLoop.main.run(until: Date() + 0.01) + } +} + +public extension EditTextTextWatcher { + + convenience init(action: @escaping (String) -> (), environment: JNIEnvironment? = nil) { + let object = SwiftObject(action, environment: environment) + self.init(action: object, environment: environment) + } + + var action: ((String) -> ()) { + getAction()!.valueObject().value as! Action + } +} From 1ba66548058bd1b55d0e3aadf86e19b54a660416 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 6/8] Render TextField as editable text --- Sources/AndroidSwiftUI/AndroidTextField.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Sources/AndroidSwiftUI/AndroidTextField.swift diff --git a/Sources/AndroidSwiftUI/AndroidTextField.swift b/Sources/AndroidSwiftUI/AndroidTextField.swift new file mode 100644 index 0000000..4d6f89a --- /dev/null +++ b/Sources/AndroidSwiftUI/AndroidTextField.swift @@ -0,0 +1,67 @@ +// +// AndroidTextField.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit + +extension TextField: AndroidPrimitive { + + var renderedBody: AnyView { + AnyView(AndroidEditText( + text: textBinding, + placeholder: mapAnyView(AnyView(label), transform: { (text: Text) in text }), + onEditingChanged: onEditingChanged, + onCommit: onCommit + )) + } +} + +/// Native editable text bound to a `String`. +struct AndroidEditText { + + @Binding + var text: String + + let placeholder: Text? + + let onEditingChanged: (Bool) -> () + + let onCommit: () -> () +} + +extension AndroidEditText: AndroidViewRepresentable { + + typealias Coordinator = Void + + func makeAndroidView(context: Self.Context) -> AndroidWidget.EditText { + let view = AndroidWidget.EditText(context.androidContext) + let watcher = EditTextTextWatcher { newText in + guard newText != self.text else { return } + self.text = newText + } + watcher.attach(view) + updateView(view) + return view + } + + func updateAndroidView(_ view: AndroidWidget.EditText, context: Self.Context) { + updateView(view) + } +} + +private extension AndroidEditText { + + func updateView(_ view: AndroidWidget.EditText) { + // writing the text back unconditionally would reset the cursor on every keystroke, + // since the watcher has already applied the user's edit + if view.text != text { + view.text = text + } + if let placeholder { + view.setHint(JavaString(_TextProxy(placeholder).rawText).as(CharSequence.self)) + } + } +} From ca6509b52fc22759787f25fb2dd5600758cc7a1f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 7/8] Span the stack with the determinate progress track --- Sources/AndroidSwiftUI/AndroidProgressView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/AndroidSwiftUI/AndroidProgressView.swift b/Sources/AndroidSwiftUI/AndroidProgressView.swift index 10f52b6..4a9ca9b 100644 --- a/Sources/AndroidSwiftUI/AndroidProgressView.swift +++ b/Sources/AndroidSwiftUI/AndroidProgressView.swift @@ -44,6 +44,14 @@ extension AndroidProgressBar: AndroidViewRepresentable { ? try! JavaClass().progressBarStyle : try! JavaClass().progressBarStyleHorizontal let view = AndroidWidget.ProgressBar(context.androidContext, nil, style) + if fractionCompleted != nil { + // the horizontal track is meaningless at its intrinsic width, so it spans the + // stack; the spinner keeps its natural size + view.setLayoutParams(ViewGroup.LayoutParams( + try! JavaClass().MATCH_PARENT, + try! JavaClass().WRAP_CONTENT + )) + } view.setMax(progressScale) updateView(view) return view From 5fca3a26004eb00a48936e6f4e5f7ab99aac5b5d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 11:46:59 -0400 Subject: [PATCH 8/8] Demonstrate Slider and TextField in the controls screen --- Demo/App.swiftpm/Sources/ControlsScreen.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Demo/App.swiftpm/Sources/ControlsScreen.swift b/Demo/App.swiftpm/Sources/ControlsScreen.swift index 9bb054c..d00b0e0 100644 --- a/Demo/App.swiftpm/Sources/ControlsScreen.swift +++ b/Demo/App.swiftpm/Sources/ControlsScreen.swift @@ -13,6 +13,12 @@ struct ControlsScreen: View { @State private var progress = 0.25 + @State + private var sliderValue = 0.5 + + @State + private var name = "" + var body: some View { ScrollView { VStack(spacing: 24) { @@ -29,6 +35,14 @@ struct ControlsScreen: View { Button("Advance") { progress = progress >= 1 ? 0 : progress + 0.25 } + Divider() + Text("Slider") + Slider(value: $sliderValue, in: 0...1) + Text("Value: \(Int(sliderValue * 100))%") + Divider() + Text("Text field") + TextField("Name", text: $name) + Text(name.isEmpty ? "Nothing typed yet" : "Hello, \(name)") } } }