react-native-native-select 1.2.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ There is no JS rendering logic in this package. When your user opens the list, t
30
30
  | :--- | :--- | :--- | :--- |
31
31
  | iOS | `dropdown` | A real `UIMenu` built from `UIAction`s, attached to a `UIButton` with `showsMenuAsPrimaryAction = YES` (iOS 14+). The selected row carries `UIMenuElementStateOn`, so the system draws its own checkmark. | `ios/RTNSelect.mm` |
32
32
  | iOS | `dialog` (default) | `UIPickerView`, the classic wheel, with this view as its own dataSource and delegate. | `ios/RTNSelect.mm` |
33
- | Android | any | `AppCompatSpinner` with an `ArrayAdapter` over the platform `simple_spinner_item` and `simple_spinner_dropdown_item` layouts. | `android/src/main/java/com/rtnselect/SelectView.java` |
33
+ | Android | any | `AppCompatSpinner`, pinned to `MODE_DROPDOWN` so the host app's theme cannot switch it to a dialog, with an `ArrayAdapter` over the platform `simple_spinner_item` and `simple_spinner_dropdown_item` layouts. | `android/src/main/java/com/rtnselect/SelectView.java` |
34
34
 
35
35
  On iOS a single `RCTViewComponentView` holds both the picker and the button, and `mode` decides which one is hidden. Nothing is custom-drawn on either platform: no `Modal`, no `FlatList`, no wheel re-implemented in JS.
36
36
 
@@ -59,7 +59,7 @@ If you need any of the features in the right-hand columns, use one of those libr
59
59
 
60
60
  ## Requirements
61
61
 
62
- * **React Native:** `>= 0.71.0` (declared as a peer dependency, along with `react`).
62
+ * **React Native:** `>= 0.80.0` (declared as a peer dependency, along with `react`). The codegen spec imports `codegenNativeComponent` and the `CodegenTypes` namespace from the `react-native` root, which only re-exports them from 0.80 onward. On 0.71-0.79, use `1.2.1`.
63
63
  * **Architecture:** the New Architecture, enabled. `RCT_NEW_ARCH_ENABLED=1` must have been set when you ran `pod install`.
64
64
  * **iOS:** 14.0+ for `dropdown` mode, since `UIMenu` and `showsMenuAsPrimaryAction` are gated behind an availability check. The podspec deployment target is 11.0, and `dialog` mode works all the way down.
65
65
  * **Android:** `compileSdkVersion` and `targetSdkVersion` default to 31 and `minSdkVersion` to 21, each read through `safeExtGet` so your root `ext` values win.
@@ -238,8 +238,8 @@ Three details make the difference between this working and nearly working:
238
238
  | Prop | Type | Required | Description |
239
239
  | :--- | :--- | :---: | :--- |
240
240
  | **`options`** | `ReadonlyArray<string>` | **Yes** | The rows to display. Strings only. |
241
- | **`selectedIndex`** | `number` | No | Index of the selected row. The native views start at `0`. Behaves as a controlled value on iOS; see the Android caveat below. |
242
- | **`mode`** | `'dialog' \| 'dropdown'` | No | **iOS only.** `dialog` (the codegen default) uses `UIPickerView`, the classic wheel. `dropdown` uses the iOS 14+ `UIMenu` pull-down. Ignored on Android, which always draws its spinner. |
241
+ | **`selectedIndex`** | `number` | No | Index of the selected row. The native views start at `0`. A negative index means "nothing selected yet" and is ignored rather than applied. |
242
+ | **`mode`** | `'dialog' \| 'dropdown'` | No | **iOS only.** `dialog` (the codegen default) uses `UIPickerView`, the classic wheel. `dropdown` uses the iOS 14+ `UIMenu` pull-down. Ignored on Android, which always draws its spinner dropdown. |
243
243
  | **`textColor`** | `ColorValue` | No | **iOS only.** Overrides the text color of both the wheel and the button title. Defaults to `UIColor.labelColor`, which already follows light and dark mode, so set this only when you need to override the system. |
244
244
  | **`onValueChange`** | `(event) => void` | No | Fired on selection. Read `event.nativeEvent.value` (`string`) and `event.nativeEvent.index` (`number`). |
245
245
  | **`style`** | `ViewStyle` | No | Standard style prop. Give it a `width` and a `height`, or Flexbox constraints that produce them. |
@@ -255,9 +255,8 @@ onValueChange={(e) => {
255
255
 
256
256
  Nothing here is a bug to be fixed later, it is what wrapping two different OS widgets behind one component costs. Better said out loud.
257
257
 
258
- * **`mode` and `textColor` do nothing on Android.** In `SelectViewManager`, `setMode` has an empty body and `setTextColor` carries a comment saying so. They exist only because the codegen-generated `RTNSelectManagerInterface` requires them, and a missing method there is a build failure (that is exactly what v1.1.1 fixed). Style Android text through the overlay pattern instead.
259
- * **Android does not reliably reflect a controlled `selectedIndex`.** `AppCompatSpinner` owns its own selection state and the manager pushes the prop through `setSelection`, which posts the update to the next frame. Treat the Android widget as uncontrolled and render the current value yourself, which is the overlay pattern again.
260
- * **Writing `selectedIndex` on Android emits a change event.** `setSelection` reports back through `topValueChange` as well as the adapter's own `onItemSelected`, so a state update driven from JS can echo. If your handler does more than `setState`, guard it against the value it already holds.
258
+ * **`mode` and `textColor` do nothing on Android.** In `SelectViewManager` both setters carry a comment saying so. They exist only because the codegen-generated `RTNSelectManagerInterface` requires them, and a missing method there is a build failure (that is exactly what v1.1.1 fixed). Style Android text through the overlay pattern instead.
259
+ * **Android pins its spinner to `MODE_DROPDOWN`.** The mode an `AppCompatSpinner` takes otherwise comes from the host app theme's `spinnerStyle`. AppCompat's own sets `dropdown`, but a vendor theme can hand back `dialog`, where the popup is an `AlertDialog` built on the React context. Since 2.0.2 the view constructs itself in dropdown mode so the widget behaves the same on every device.
261
260
  * **Below iOS 14, `dropdown` degrades to an inert button.** Both `showsMenuAsPrimaryAction` and the menu construction sit behind `@available(iOS 14.0, *)`, so on iOS 11 to 13 the button renders with no menu attached. Use `dialog` if you still support those versions.
262
261
  * **iOS applies props on the main queue.** `updateProps` diffs `options`, `selectedIndex`, `mode` and `textColor`, then dispatches the UIKit work asynchronously. A `selectedIndex` change animates the wheel; expect the visual update one turn later, not synchronously with your `setState`.
263
262
 
@@ -368,7 +367,7 @@ Its `opacity` is `0` (or below `0.02`). See the overlay pattern section.
368
367
  Expected, they are iOS only. See Platform differences.
369
368
 
370
369
  **Android fires `onValueChange` when I set `selectedIndex` from JS.**
371
- Expected too, and documented in Platform differences. Guard your handler against a value that has not changed.
370
+ Fixed in 2.0.2. A selection this component makes on your behalf no longer travels back as a change, and neither does the row-0 selection the adapter takes when it attaches.
372
371
 
373
372
  ## Contributing
374
373
 
@@ -1,128 +1,162 @@
1
1
  package com.rtnselect;
2
2
 
3
3
  import android.content.Context;
4
- import android.util.AttributeSet;
5
- import android.util.Log;
6
4
  import android.view.View;
7
5
  import android.widget.AdapterView;
8
6
  import android.widget.ArrayAdapter;
9
- import androidx.annotation.Nullable;
7
+ import android.widget.Spinner;
8
+
10
9
  import androidx.appcompat.widget.AppCompatSpinner;
11
10
 
12
- import com.facebook.react.bridge.Arguments;
13
11
  import com.facebook.react.bridge.ReactContext;
14
- import com.facebook.react.bridge.WritableMap;
15
- import com.facebook.react.uimanager.events.RCTEventEmitter;
12
+ import com.facebook.react.uimanager.UIManagerHelper;
13
+ import com.facebook.react.uimanager.events.EventDispatcher;
16
14
 
15
+ import java.util.ArrayList;
17
16
  import java.util.List;
18
17
 
19
18
  public class SelectView extends AppCompatSpinner implements AdapterView.OnItemSelectedListener {
20
19
 
21
- private final String TAG = "RTNSelect";
20
+ /** No index yet. Distinct from 0, which is a legitimate selection. */
21
+ private static final int NO_SELECTION = -1;
22
+
23
+ private final List<String> options = new ArrayList<>();
24
+
25
+ /**
26
+ * The index JS asked for. Fabric writes props in whatever order RawProps yields, so
27
+ * `selectedIndex` regularly lands before `options` and there is no adapter to apply it to
28
+ * yet. Holding it here and replaying it once the adapter exists makes prop order irrelevant.
29
+ */
30
+ private int requestedIndex = NO_SELECTION;
31
+
32
+ /** The last index announced to JS, so an unchanged selection stays quiet. */
33
+ private int announcedIndex = NO_SELECTION;
34
+
35
+ /**
36
+ * Set when the user opens the list. It is what tells a person's pick apart from a prop write,
37
+ * and the two are otherwise indistinguishable: the spinner's popup reports the row the user
38
+ * chose by calling this view's own `setSelection`, the very method the `selectedIndex` prop
39
+ * arrives through. Emitting from `setSelection` unconditionally is why writing the prop used
40
+ * to echo an onValueChange straight back to JS.
41
+ */
42
+ private boolean openedByUser = false;
43
+
44
+ /**
45
+ * React Native drives layout from the shadow tree and swallows a requestLayout() raised by a
46
+ * child, so AbsSpinner.setAdapter's own request never produces a measure pass and the spinner
47
+ * keeps drawing its previous row. Re-running measure and layout on our own frame is the
48
+ * standard workaround on this platform; ReactPicker and ReactSwitch both carry it.
49
+ */
50
+ private final Runnable measureAndLayout = new Runnable() {
51
+ @Override
52
+ public void run() {
53
+ measure(
54
+ MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
55
+ MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY)
56
+ );
57
+ layout(getLeft(), getTop(), getRight(), getBottom());
58
+ }
59
+ };
22
60
 
23
61
  public SelectView(Context context) {
24
- super(context);
25
- init();
26
- }
27
-
28
- public SelectView(Context context, @Nullable AttributeSet attrs) {
29
- super(context, attrs);
30
- init();
31
- }
32
-
33
- public SelectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
34
- super(context, attrs, defStyleAttr);
35
- init();
36
- }
37
-
38
- private void init() {
39
- this.setOnItemSelectedListener(this);
40
- this.setBackground(null);
62
+ // MODE_DROPDOWN is pinned rather than inherited. An AppCompatSpinner fixes its mode at
63
+ // construction, and the mode it would otherwise take comes from the host app theme's
64
+ // spinnerStyle: AppCompat's own sets `dropdown`, but a vendor theme can hand back
65
+ // `dialog`, where the popup is an AlertDialog built on the React context. Pinning it makes
66
+ // the widget behave the same whatever theme it is dropped into.
67
+ super(context, null, androidx.appcompat.R.attr.spinnerStyle, Spinner.MODE_DROPDOWN);
68
+ setOnItemSelectedListener(this);
69
+ // The overlay pattern this component is built for puts an invisible Select on top of
70
+ // app-drawn chrome, so the platform spinner background and its arrow must not show through.
71
+ setBackground(null);
41
72
  }
42
73
 
43
74
  @Override
44
- public boolean performClick() {
45
- boolean result = super.performClick();
46
- return result;
75
+ public void requestLayout() {
76
+ super.requestLayout();
77
+ post(measureAndLayout);
47
78
  }
48
79
 
49
- @Override
50
- protected void onAttachedToWindow() {
51
- super.onAttachedToWindow();
52
- }
53
-
54
- @Override
55
- protected void onDetachedFromWindow() {
56
- super.onDetachedFromWindow();
57
- }
58
-
59
- public void setOptions(List<String> options) {
80
+ public void setOptions(List<String> next) {
81
+ // Fabric re-applies every prop whenever any one of them changes, and callers routinely
82
+ // build this array inline, so an identical list arrives on most renders. Rebuilding the
83
+ // adapter for it would reset the spinner's own selection for nothing.
84
+ if (options.equals(next)) {
85
+ return;
86
+ }
87
+ options.clear();
88
+ options.addAll(next);
60
89
 
61
90
  ArrayAdapter<String> adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_item, options);
62
91
  adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
92
+ setAdapter(adapter);
63
93
 
64
- this.setAdapter(adapter);
94
+ applyRequestedSelection();
65
95
  }
66
96
 
67
97
  @Override
68
98
  public void setSelection(int position) {
99
+ boolean userPick = openedByUser;
100
+ openedByUser = false;
69
101
 
70
- this.post(new Runnable() {
71
- @Override
72
- public void run() {
73
- SelectView.super.setSelection(position);
74
- }
75
- });
76
-
77
- ReactContext reactContext = (ReactContext) getContext();
78
- if (reactContext != null) {
79
- String val = "unknown";
80
- if (getAdapter() != null && getAdapter().getCount() > position) {
81
- Object item = getAdapter().getItem(position);
82
- if (item != null) val = item.toString();
83
- }
84
-
85
- WritableMap event = Arguments.createMap();
86
- event.putString("value", val);
87
- event.putInt("index", position);
88
-
89
- try {
90
- reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
91
- getId(),
92
- "topValueChange",
93
- event
94
- );
95
- } catch (Exception e) {
96
- }
102
+ if (!userPick && requestedIndex == position) {
103
+ return;
104
+ }
105
+ requestedIndex = position;
106
+ applyRequestedSelection();
107
+
108
+ if (position < 0 || position == announcedIndex) {
109
+ return;
110
+ }
111
+ announcedIndex = position;
112
+ if (userPick) {
113
+ emitValueChange(position);
97
114
  }
98
115
  }
99
116
 
100
117
  @Override
101
- public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
102
- String val = "null";
103
- if (parent != null && parent.getItemAtPosition(position) != null) {
104
- val = parent.getItemAtPosition(position).toString();
105
- }
118
+ public boolean performClick() {
119
+ openedByUser = true;
120
+ return super.performClick();
121
+ }
106
122
 
107
- ReactContext reactContext = (ReactContext) getContext();
108
- if (reactContext != null) {
109
- WritableMap event = Arguments.createMap();
110
- event.putString("value", val);
111
- event.putInt("index", position);
112
-
113
- try {
114
- reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
115
- getId(),
116
- "topValueChange",
117
- event
118
- );
119
- } catch (Exception e) {
120
- }
121
- } else {
123
+ private void applyRequestedSelection() {
124
+ // A -1 is how callers spell "nothing selected yet" after an indexOf, and a spinner has no
125
+ // empty state. Forwarding it used to reach ArrayAdapter.getItem and take the host app down
126
+ // with IndexOutOfBoundsException.
127
+ if (requestedIndex < 0 || getAdapter() == null || requestedIndex >= getAdapter().getCount()) {
128
+ return;
122
129
  }
130
+ if (getSelectedItemPosition() != requestedIndex) {
131
+ super.setSelection(requestedIndex);
132
+ }
133
+ }
134
+
135
+ @Override
136
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
137
+ // Deliberately silent, and it leaves announcedIndex alone. Every selection that matters to
138
+ // JS already came through setSelection above, while this callback also fires for the row 0
139
+ // an attaching adapter picks on its own, which iOS never reports.
123
140
  }
124
141
 
125
142
  @Override
126
143
  public void onNothingSelected(AdapterView<?> parent) {
127
144
  }
145
+
146
+ private void emitValueChange(int index) {
147
+ if (index < 0 || index >= options.size()) {
148
+ return;
149
+ }
150
+ Context context = getContext();
151
+ if (!(context instanceof ReactContext)) {
152
+ return;
153
+ }
154
+ EventDispatcher dispatcher = UIManagerHelper.getEventDispatcher((ReactContext) context);
155
+ if (dispatcher == null) {
156
+ return;
157
+ }
158
+ dispatcher.dispatchEvent(
159
+ new ValueChangeEvent(UIManagerHelper.getSurfaceId(this), getId(), options.get(index), index)
160
+ );
161
+ }
128
162
  }
@@ -77,7 +77,11 @@ public class SelectViewManager extends SimpleViewManager<SelectView>
77
77
 
78
78
  @Override
79
79
  @ReactProp(name = "mode")
80
- public void setMode(SelectView view, @Nullable String value) {}
80
+ public void setMode(SelectView view, @Nullable String value) {
81
+ // iOS-only feature: Android has one widget for both modes, and the view pins it to
82
+ // MODE_DROPDOWN so the host app's theme cannot change it. The method must exist to
83
+ // satisfy the interface generated by TypeScript.
84
+ }
81
85
 
82
86
  @Override
83
87
  @ReactProp(name = "textColor", customType = "Color")
@@ -0,0 +1,41 @@
1
+ package com.rtnselect;
2
+
3
+ import androidx.annotation.Nullable;
4
+
5
+ import com.facebook.react.bridge.Arguments;
6
+ import com.facebook.react.bridge.WritableMap;
7
+ import com.facebook.react.uimanager.events.Event;
8
+
9
+ /**
10
+ * The `onValueChange` payload, dispatched through the EventDispatcher so it reaches both
11
+ * architectures. The legacy `RCTEventEmitter` path this replaces only still worked under
12
+ * bridgeless through the `useFabricInterop` shim, which logs a soft exception per event and
13
+ * dispatches with a surface id of -1.
14
+ */
15
+ public class ValueChangeEvent extends Event<ValueChangeEvent> {
16
+
17
+ public static final String EVENT_NAME = "topValueChange";
18
+
19
+ private final String value;
20
+ private final int index;
21
+
22
+ public ValueChangeEvent(int surfaceId, int viewId, String value, int index) {
23
+ super(surfaceId, viewId);
24
+ this.value = value;
25
+ this.index = index;
26
+ }
27
+
28
+ @Override
29
+ public String getEventName() {
30
+ return EVENT_NAME;
31
+ }
32
+
33
+ @Nullable
34
+ @Override
35
+ protected WritableMap getEventData() {
36
+ WritableMap event = Arguments.createMap();
37
+ event.putString("value", value);
38
+ event.putInt("index", index);
39
+ return event;
40
+ }
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-native-select",
3
- "version": "1.2.1",
3
+ "version": "2.0.2",
4
4
  "description": "react-native-native-select is a strictly native, performant Select component for React Native built exclusively for the New Architecture.",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -12,6 +12,7 @@
12
12
  "ios",
13
13
  "react-native-native-select.podspec",
14
14
  "!android/build",
15
+ "!android/.gradle",
15
16
  "!ios/build",
16
17
  "!tests",
17
18
  "!**/__tests__",
@@ -46,7 +47,7 @@
46
47
  "homepage": "https://github.com/wneel/react-native-native-select#readme",
47
48
  "peerDependencies": {
48
49
  "react": "*",
49
- "react-native": ">=0.71.0"
50
+ "react-native": ">=0.80.0"
50
51
  },
51
52
  "codegenConfig": {
52
53
  "name": "RTNSelectSpec",
@@ -1,20 +1,18 @@
1
- import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
1
+ import { codegenNativeComponent } from 'react-native';
2
2
 
3
- import type { ColorValue, HostComponent } from 'react-native';
4
- import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes';
5
- import type { Int32, DirectEventHandler, WithDefault } from 'react-native/Libraries/Types/CodegenTypes';
3
+ import type { CodegenTypes, ColorValue, HostComponent, ViewProps } from 'react-native';
6
4
 
7
5
  type OnChangeEvent = Readonly<{
8
6
  value: string;
9
- index: Int32;
7
+ index: CodegenTypes.Int32;
10
8
  }>;
11
9
 
12
10
  export interface NativeProps extends ViewProps {
13
11
  options: ReadonlyArray<string>;
14
- selectedIndex?: Int32;
15
- mode?: WithDefault<'dialog' | 'dropdown', 'dialog'>; // iOS only
12
+ selectedIndex?: CodegenTypes.Int32;
13
+ mode?: CodegenTypes.WithDefault<'dialog' | 'dropdown', 'dialog'>; // iOS only
16
14
  textColor?: ColorValue; // iOS only
17
- onValueChange?: DirectEventHandler<OnChangeEvent>;
15
+ onValueChange?: CodegenTypes.DirectEventHandler<OnChangeEvent>;
18
16
  }
19
17
 
20
18
  export default codegenNativeComponent<NativeProps>(
File without changes
@@ -1,2 +0,0 @@
1
- #Thu Aug 27 12:56:24 CEST 2026
2
- gradle.version=9.2.0
File without changes