From 930944d516a282ef7793bdc6df2447861cafdfb2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 12:51:29 -0400 Subject: [PATCH 1/4] Add an item selection listener bridge for spinners --- .../SpinnerOnItemSelectedListener.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/SpinnerOnItemSelectedListener.kt diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/SpinnerOnItemSelectedListener.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/SpinnerOnItemSelectedListener.kt new file mode 100644 index 0000000..c18a695 --- /dev/null +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/SpinnerOnItemSelectedListener.kt @@ -0,0 +1,21 @@ +package com.pureswift.swiftandroid + +import android.view.View +import android.widget.AdapterView +import android.widget.Spinner + +class SpinnerOnItemSelectedListener(val action: SwiftObject): AdapterView.OnItemSelectedListener { + + /// Registers itself on the spinner, so Swift does not need a binding for the listener type. + fun attach(spinner: Spinner) { + spinner.setOnItemSelectedListener(this) + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + onItemSelectedSwift(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { } + + external fun onItemSelectedSwift(position: Int) +} From 8a6765ea6994439e728bd0779d326d584a82588b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 12:51:29 -0400 Subject: [PATCH 2/4] Bridge spinner item selection to Swift --- .../OnItemSelectedListener.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Sources/AndroidSwiftUI/OnItemSelectedListener.swift diff --git a/Sources/AndroidSwiftUI/OnItemSelectedListener.swift b/Sources/AndroidSwiftUI/OnItemSelectedListener.swift new file mode 100644 index 0000000..05015f8 --- /dev/null +++ b/Sources/AndroidSwiftUI/OnItemSelectedListener.swift @@ -0,0 +1,54 @@ +// +// OnItemSelectedListener.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import Foundation +import AndroidKit + +/// Bridges `AdapterView.OnItemSelectedListener` to Swift. +/// +/// As with the other listener bridges, the Kotlin side forwards only the position: the +/// native symbol is derived from this declaration, so passing the adapter view and item id +/// through would shift the arguments. +@JavaClass("com.pureswift.swiftandroid.SpinnerOnItemSelectedListener") +open class SpinnerOnItemSelectedListener: JavaObject { + + public typealias Action = (Int32) -> () + + @JavaMethod + @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) + + @JavaMethod + public func getAction() -> SwiftObject? + + /// Registers this listener on the spinner, since the listener type itself has no binding. + @JavaMethod + public func attach(_ spinner: AndroidWidget.Spinner?) +} + +@JavaImplementation("com.pureswift.swiftandroid.SpinnerOnItemSelectedListener") +extension SpinnerOnItemSelectedListener { + + @JavaMethod + func onItemSelectedSwift(_ position: Int32) { + // drain queue, matching `ViewOnClickListener` + RunLoop.main.run(until: Date() + 0.01) + action(position) + RunLoop.main.run(until: Date() + 0.01) + } +} + +public extension SpinnerOnItemSelectedListener { + + 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 fcca3815bb199532f5b8667e999dd5c8cece94e9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 12:51:29 -0400 Subject: [PATCH 3/4] Render Picker as a spinner --- Sources/AndroidSwiftUI/AndroidPicker.swift | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 Sources/AndroidSwiftUI/AndroidPicker.swift diff --git a/Sources/AndroidSwiftUI/AndroidPicker.swift b/Sources/AndroidSwiftUI/AndroidPicker.swift new file mode 100644 index 0000000..37c83a0 --- /dev/null +++ b/Sources/AndroidSwiftUI/AndroidPicker.swift @@ -0,0 +1,144 @@ +// +// AndroidPicker.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit + +extension _PickerContainer: AndroidPrimitive { + + var renderedBody: AnyView { + let options = Self.options(of: self) + return AnyView(AndroidSpinner( + selection: $selection, + titles: options.map(\.title), + values: options.map(\.value) + )) + } +} + +private extension _PickerContainer { + + struct Option { + + let title: String + + let value: SelectionValue + } + + /// The pickable options, in order. + /// + /// `elements` covers the `ForEach`-with-data case, where the identity is the selection + /// value. Otherwise the container's content — the generated `ForEach` of + /// `_PickerElement`s that `Picker.body` builds, each wrapped in conditional content — + /// is flattened, and each element's row is walked for the `tag(_:)` that associates it + /// with its value. + static func options(of container: Self) -> [Option] { + if !container.elements.isEmpty { + return container.elements.compactMap { element in + guard let value = element.anyId.base as? SelectionValue else { return nil } + return Option(title: title(of: element.anyContent), value: value) + } + } + return pickerElements(in: AnyView(container.content)).compactMap { element in + guard let value = tag(of: element.content.view) else { return nil } + return Option(title: title(of: element.content), value: value) + } + } + + /// Recursively flattens group views to the `_PickerElement` rows they contain. + /// `ForEach` wraps each row in an identified view, which is descended through its + /// content. + static func pickerElements(in view: AnyView) -> [_PickerElement] { + if let element = mapAnyView(view, transform: { (element: _PickerElement) in element }) { + return [element] + } + if let identified = view.view as? _AnyIDView { + return pickerElements(in: identified.anyContent) + } + guard let group = view.view as? GroupView else { return [] } + return group.children.flatMap { pickerElements(in: $0) } + } + + /// The value written by `tag(_:)`, if any. + static func tag(of view: Any) -> SelectionValue? { + var view = view + while let modified = view as? _AnyModifiedContent { + if let modifier = modified.anyModifier as? _TraitWritingModifier>, + case let .tagged(value) = modifier.value { + return value + } + view = modified.anyContent + } + return nil + } + + /// The text a row displays, unwrapping any modifiers (such as the tag itself) around + /// it. Rows that aren't text render as an empty entry, since a spinner shows plain + /// strings rather than arbitrary views. + static func title(of view: AnyView) -> String { + var view = view.view + while let modified = view as? _AnyModifiedContent { + view = modified.anyContent + } + return (view as? Text).map { _TextProxy($0).rawText } ?? "" + } +} + +/// Native spinner bound to the selected value. +struct AndroidSpinner { + + @Binding + var selection: SelectionValue + + let titles: [String] + + let values: [SelectionValue] +} + +extension AndroidSpinner: AndroidViewRepresentable { + + typealias Coordinator = Void + + func makeAndroidView(context: Self.Context) -> AndroidWidget.Spinner { + let view = AndroidWidget.Spinner(context.androidContext) + // a spinner that hugs its content collapses to the dropdown arrow, hiding the + // selected value; span the stack like the other horizontal controls + view.setLayoutParams(ViewGroup.LayoutParams( + try! JavaClass().MATCH_PARENT, + try! JavaClass().WRAP_CONTENT + )) + applyAdapter(to: view, context: context.androidContext) + // attached after the adapter so the initial selection doesn't call back + let listener = SpinnerOnItemSelectedListener { position in + guard let value = self.values.indices.contains(Int(position)) ? self.values[Int(position)] : nil, + value != self.selection else { return } + self.selection = value + } + listener.attach(view) + return view + } + + func updateAndroidView(_ view: AndroidWidget.Spinner, context: Self.Context) { + applyAdapter(to: view, context: view.getContext()) + } +} + +private extension AndroidSpinner { + + func applyAdapter(to view: AndroidWidget.Spinner, context: AndroidContent.Context?) { + guard let context else { return } + let layout = try! JavaClass().simple_spinner_dropdown_item + let adapter = AndroidWidget.ArrayAdapter( + context: context, + resource: layout, + objects: titles.map { JavaString($0).as(JavaObject.self) } + ) + view.setAdapter(adapter.as(AndroidWidget.SpinnerAdapter.self)) + if let index = values.firstIndex(of: selection), view.getSelectedItemPosition() != Int32(index) { + view.setSelection(Int32(index)) + } + } +} From fe27432f34465f03f375c32e422500e66427bb22 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 12:51:29 -0400 Subject: [PATCH 4/4] Demonstrate Picker in the controls screen --- Demo/App.swiftpm/Sources/ControlsScreen.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Demo/App.swiftpm/Sources/ControlsScreen.swift b/Demo/App.swiftpm/Sources/ControlsScreen.swift index d00b0e0..82ab822 100644 --- a/Demo/App.swiftpm/Sources/ControlsScreen.swift +++ b/Demo/App.swiftpm/Sources/ControlsScreen.swift @@ -19,6 +19,9 @@ struct ControlsScreen: View { @State private var name = "" + @State + private var fruit = "Apple" + var body: some View { ScrollView { VStack(spacing: 24) { @@ -43,6 +46,14 @@ struct ControlsScreen: View { Text("Text field") TextField("Name", text: $name) Text(name.isEmpty ? "Nothing typed yet" : "Hello, \(name)") + Divider() + Text("Picker") + Picker("Fruit", selection: $fruit) { + Text("Apple").tag("Apple") + Text("Banana").tag("Banana") + Text("Cherry").tag("Cherry") + } + Text("Selected: \(fruit)") } } }