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
14 changes: 14 additions & 0 deletions Demo/App.swiftpm/Sources/ControlsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions Sources/AndroidSwiftUI/AndroidProgressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ extension AndroidProgressBar: AndroidViewRepresentable {
? try! JavaClass<AndroidR.R.attr>().progressBarStyle
: try! JavaClass<AndroidR.R.attr>().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<ViewGroup.LayoutParams>().MATCH_PARENT,
try! JavaClass<ViewGroup.LayoutParams>().WRAP_CONTENT
))
}
view.setMax(progressScale)
updateView(view)
return view
Expand Down
100 changes: 100 additions & 0 deletions Sources/AndroidSwiftUI/AndroidSlider.swift
Original file line number Diff line number Diff line change
@@ -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<Double>

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<ViewGroup.LayoutParams>().MATCH_PARENT,
try! JavaClass<ViewGroup.LayoutParams>().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)
}
}
}
67 changes: 67 additions & 0 deletions Sources/AndroidSwiftUI/AndroidTextField.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
57 changes: 57 additions & 0 deletions Sources/AndroidSwiftUI/OnSeekBarChangeListener.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
53 changes: 53 additions & 0 deletions Sources/AndroidSwiftUI/TextWatcher.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading