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)") } } } 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) +} 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) +} 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 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) + } + } +} 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)) + } + } +} 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 + } +} 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 + } +}