Skip to content

Commit d21109d

Browse files
authored
Merge pull request #15 from PureSwift/feature/picker
Render Picker as a spinner
2 parents b29533f + fe27432 commit d21109d

4 files changed

Lines changed: 230 additions & 0 deletions

File tree

‎Demo/App.swiftpm/Sources/ControlsScreen.swift‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ struct ControlsScreen: View {
1919
@State
2020
private var name = ""
2121

22+
@State
23+
private var fruit = "Apple"
24+
2225
var body: some View {
2326
ScrollView {
2427
VStack(spacing: 24) {
@@ -43,6 +46,14 @@ struct ControlsScreen: View {
4346
Text("Text field")
4447
TextField("Name", text: $name)
4548
Text(name.isEmpty ? "Nothing typed yet" : "Hello, \(name)")
49+
Divider()
50+
Text("Picker")
51+
Picker("Fruit", selection: $fruit) {
52+
Text("Apple").tag("Apple")
53+
Text("Banana").tag("Banana")
54+
Text("Cherry").tag("Cherry")
55+
}
56+
Text("Selected: \(fruit)")
4657
}
4758
}
4859
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.pureswift.swiftandroid
2+
3+
import android.view.View
4+
import android.widget.AdapterView
5+
import android.widget.Spinner
6+
7+
class SpinnerOnItemSelectedListener(val action: SwiftObject): AdapterView.OnItemSelectedListener {
8+
9+
/// Registers itself on the spinner, so Swift does not need a binding for the listener type.
10+
fun attach(spinner: Spinner) {
11+
spinner.setOnItemSelectedListener(this)
12+
}
13+
14+
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
15+
onItemSelectedSwift(position)
16+
}
17+
18+
override fun onNothingSelected(parent: AdapterView<*>?) { }
19+
20+
external fun onItemSelectedSwift(position: Int)
21+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//
2+
// AndroidPicker.swift
3+
// AndroidSwiftUI
4+
//
5+
// Created by Alsey Coleman Miller on 7/22/26.
6+
//
7+
8+
import AndroidKit
9+
10+
extension _PickerContainer: AndroidPrimitive {
11+
12+
var renderedBody: AnyView {
13+
let options = Self.options(of: self)
14+
return AnyView(AndroidSpinner(
15+
selection: $selection,
16+
titles: options.map(\.title),
17+
values: options.map(\.value)
18+
))
19+
}
20+
}
21+
22+
private extension _PickerContainer {
23+
24+
struct Option {
25+
26+
let title: String
27+
28+
let value: SelectionValue
29+
}
30+
31+
/// The pickable options, in order.
32+
///
33+
/// `elements` covers the `ForEach`-with-data case, where the identity is the selection
34+
/// value. Otherwise the container's content — the generated `ForEach` of
35+
/// `_PickerElement`s that `Picker.body` builds, each wrapped in conditional content —
36+
/// is flattened, and each element's row is walked for the `tag(_:)` that associates it
37+
/// with its value.
38+
static func options(of container: Self) -> [Option] {
39+
if !container.elements.isEmpty {
40+
return container.elements.compactMap { element in
41+
guard let value = element.anyId.base as? SelectionValue else { return nil }
42+
return Option(title: title(of: element.anyContent), value: value)
43+
}
44+
}
45+
return pickerElements(in: AnyView(container.content)).compactMap { element in
46+
guard let value = tag(of: element.content.view) else { return nil }
47+
return Option(title: title(of: element.content), value: value)
48+
}
49+
}
50+
51+
/// Recursively flattens group views to the `_PickerElement` rows they contain.
52+
/// `ForEach` wraps each row in an identified view, which is descended through its
53+
/// content.
54+
static func pickerElements(in view: AnyView) -> [_PickerElement] {
55+
if let element = mapAnyView(view, transform: { (element: _PickerElement) in element }) {
56+
return [element]
57+
}
58+
if let identified = view.view as? _AnyIDView {
59+
return pickerElements(in: identified.anyContent)
60+
}
61+
guard let group = view.view as? GroupView else { return [] }
62+
return group.children.flatMap { pickerElements(in: $0) }
63+
}
64+
65+
/// The value written by `tag(_:)`, if any.
66+
static func tag(of view: Any) -> SelectionValue? {
67+
var view = view
68+
while let modified = view as? _AnyModifiedContent {
69+
if let modifier = modified.anyModifier as? _TraitWritingModifier<TagValueTraitKey<SelectionValue>>,
70+
case let .tagged(value) = modifier.value {
71+
return value
72+
}
73+
view = modified.anyContent
74+
}
75+
return nil
76+
}
77+
78+
/// The text a row displays, unwrapping any modifiers (such as the tag itself) around
79+
/// it. Rows that aren't text render as an empty entry, since a spinner shows plain
80+
/// strings rather than arbitrary views.
81+
static func title(of view: AnyView) -> String {
82+
var view = view.view
83+
while let modified = view as? _AnyModifiedContent {
84+
view = modified.anyContent
85+
}
86+
return (view as? Text).map { _TextProxy($0).rawText } ?? ""
87+
}
88+
}
89+
90+
/// Native spinner bound to the selected value.
91+
struct AndroidSpinner<SelectionValue: Hashable> {
92+
93+
@Binding
94+
var selection: SelectionValue
95+
96+
let titles: [String]
97+
98+
let values: [SelectionValue]
99+
}
100+
101+
extension AndroidSpinner: AndroidViewRepresentable {
102+
103+
typealias Coordinator = Void
104+
105+
func makeAndroidView(context: Self.Context) -> AndroidWidget.Spinner {
106+
let view = AndroidWidget.Spinner(context.androidContext)
107+
// a spinner that hugs its content collapses to the dropdown arrow, hiding the
108+
// selected value; span the stack like the other horizontal controls
109+
view.setLayoutParams(ViewGroup.LayoutParams(
110+
try! JavaClass<ViewGroup.LayoutParams>().MATCH_PARENT,
111+
try! JavaClass<ViewGroup.LayoutParams>().WRAP_CONTENT
112+
))
113+
applyAdapter(to: view, context: context.androidContext)
114+
// attached after the adapter so the initial selection doesn't call back
115+
let listener = SpinnerOnItemSelectedListener { position in
116+
guard let value = self.values.indices.contains(Int(position)) ? self.values[Int(position)] : nil,
117+
value != self.selection else { return }
118+
self.selection = value
119+
}
120+
listener.attach(view)
121+
return view
122+
}
123+
124+
func updateAndroidView(_ view: AndroidWidget.Spinner, context: Self.Context) {
125+
applyAdapter(to: view, context: view.getContext())
126+
}
127+
}
128+
129+
private extension AndroidSpinner {
130+
131+
func applyAdapter(to view: AndroidWidget.Spinner, context: AndroidContent.Context?) {
132+
guard let context else { return }
133+
let layout = try! JavaClass<AndroidR.R.layout>().simple_spinner_dropdown_item
134+
let adapter = AndroidWidget.ArrayAdapter<JavaObject>(
135+
context: context,
136+
resource: layout,
137+
objects: titles.map { JavaString($0).as(JavaObject.self) }
138+
)
139+
view.setAdapter(adapter.as(AndroidWidget.SpinnerAdapter.self))
140+
if let index = values.firstIndex(of: selection), view.getSelectedItemPosition() != Int32(index) {
141+
view.setSelection(Int32(index))
142+
}
143+
}
144+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// OnItemSelectedListener.swift
3+
// AndroidSwiftUI
4+
//
5+
// Created by Alsey Coleman Miller on 7/22/26.
6+
//
7+
8+
import Foundation
9+
import AndroidKit
10+
11+
/// Bridges `AdapterView.OnItemSelectedListener` to Swift.
12+
///
13+
/// As with the other listener bridges, the Kotlin side forwards only the position: the
14+
/// native symbol is derived from this declaration, so passing the adapter view and item id
15+
/// through would shift the arguments.
16+
@JavaClass("com.pureswift.swiftandroid.SpinnerOnItemSelectedListener")
17+
open class SpinnerOnItemSelectedListener: JavaObject {
18+
19+
public typealias Action = (Int32) -> ()
20+
21+
@JavaMethod
22+
@_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil)
23+
24+
@JavaMethod
25+
public func getAction() -> SwiftObject?
26+
27+
/// Registers this listener on the spinner, since the listener type itself has no binding.
28+
@JavaMethod
29+
public func attach(_ spinner: AndroidWidget.Spinner?)
30+
}
31+
32+
@JavaImplementation("com.pureswift.swiftandroid.SpinnerOnItemSelectedListener")
33+
extension SpinnerOnItemSelectedListener {
34+
35+
@JavaMethod
36+
func onItemSelectedSwift(_ position: Int32) {
37+
// drain queue, matching `ViewOnClickListener`
38+
RunLoop.main.run(until: Date() + 0.01)
39+
action(position)
40+
RunLoop.main.run(until: Date() + 0.01)
41+
}
42+
}
43+
44+
public extension SpinnerOnItemSelectedListener {
45+
46+
convenience init(action: @escaping (Int32) -> (), environment: JNIEnvironment? = nil) {
47+
let object = SwiftObject(action, environment: environment)
48+
self.init(action: object, environment: environment)
49+
}
50+
51+
var action: ((Int32) -> ()) {
52+
getAction()!.valueObject().value as! Action
53+
}
54+
}

0 commit comments

Comments
 (0)