|
| 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 | +} |
0 commit comments