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
11 changes: 11 additions & 0 deletions Demo/App.swiftpm/Sources/ControlsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ struct ControlsScreen: View {
@State
private var name = ""

@State
private var fruit = "Apple"

var body: some View {
ScrollView {
VStack(spacing: 24) {
Expand All @@ -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)")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
144 changes: 144 additions & 0 deletions Sources/AndroidSwiftUI/AndroidPicker.swift
Original file line number Diff line number Diff line change
@@ -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<TagValueTraitKey<SelectionValue>>,
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<SelectionValue: Hashable> {

@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<ViewGroup.LayoutParams>().MATCH_PARENT,
try! JavaClass<ViewGroup.LayoutParams>().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<AndroidR.R.layout>().simple_spinner_dropdown_item
let adapter = AndroidWidget.ArrayAdapter<JavaObject>(
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))
}
}
}
54 changes: 54 additions & 0 deletions Sources/AndroidSwiftUI/OnItemSelectedListener.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading