react-native-drum-picker 0.1.2 → 0.1.5
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/CHANGELOG.md +34 -1
- package/README.md +122 -2
- package/android/src/main/java/com/drumpicker/DrumPickerAdapter.kt +1 -0
- package/android/src/main/java/com/drumpicker/DrumPickerDefaults.kt +1 -0
- package/android/src/main/java/com/drumpicker/DrumPickerView.kt +123 -38
- package/lib/module/DateDrumPicker.js +15 -1
- package/lib/module/DrumPicker.native.js +2 -5
- package/lib/module/drumPickerLayout.js +29 -0
- package/lib/typescript/src/drumPickerLayout.d.ts +7 -0
- package/package.json +3 -3
- package/src/DateDrumPicker.tsx +19 -1
- package/src/DrumPicker.native.tsx +6 -8
- package/src/drumPickerLayout.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
_No changes yet._
|
|
11
11
|
|
|
12
|
+
## [0.1.5] - 2026-05-22
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Center `selectedIndex` reliably using `scrollToPositionWithOffset` with a computed center offset (after items, layout, and metric changes).
|
|
17
|
+
- Cancel pending scroll work and `stopScroll()` on detach; broader lifecycle guards for navigation transitions.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- JS `DrumPicker` applies default `minWidth` / `height` (or `minHeight` with flex) so the picker is visible without manual sizing.
|
|
22
|
+
- Android native `minimumWidth` / `minimumHeight` aligned with `itemHeight * visibleItemCount`.
|
|
23
|
+
- One-time `__DEV__` warning when layout dimensions are missing.
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
- README: tested compatibility matrix, practical examples, `onChange` / debounce guidance.
|
|
28
|
+
- Example app: basic, time, height/weight, date, controlled, debounced demos.
|
|
29
|
+
- Bug report template: Expo, react-native-screens, navigation crash, empty picker.
|
|
30
|
+
|
|
31
|
+
## [0.1.4] - 2026-05-22
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Fixed a crash during screen unmount/navigation by avoiding unsafe RecyclerView cleanup in `onDetachedFromWindow` (`adapter = null`, `clearOnScrollListeners`, SnapHelper detach during react-native-screens transitions).
|
|
36
|
+
- Prevented `onChange` events from dispatching after the view is detached.
|
|
37
|
+
- Fixed Android Kotlin compilation on React Native 0.81+ by dispatching Fabric events with `UIManagerType.FABRIC`.
|
|
38
|
+
|
|
39
|
+
### Documentation
|
|
40
|
+
|
|
41
|
+
- Added compatibility and troubleshooting notes for React Native 0.81+, Expo SDK 54, Expo Go, and navigation crashes.
|
|
42
|
+
|
|
12
43
|
## [0.1.2] - 2026-05-22
|
|
13
44
|
|
|
14
45
|
### Fixed
|
|
@@ -51,7 +82,9 @@ First release on [npm](https://www.npmjs.com/package/react-native-drum-picker).
|
|
|
51
82
|
|
|
52
83
|
Initial GitHub release. See [v0.0.1](https://github.com/scrollDynasty/react-native-drum-picker/releases/tag/v0.0.1).
|
|
53
84
|
|
|
54
|
-
[Unreleased]: https://github.com/scrollDynasty/react-native-drum-picker/compare/v0.1.
|
|
85
|
+
[Unreleased]: https://github.com/scrollDynasty/react-native-drum-picker/compare/v0.1.5...HEAD
|
|
86
|
+
[0.1.5]: https://github.com/scrollDynasty/react-native-drum-picker/releases/tag/v0.1.5
|
|
87
|
+
[0.1.4]: https://github.com/scrollDynasty/react-native-drum-picker/releases/tag/v0.1.4
|
|
55
88
|
[0.1.2]: https://github.com/scrollDynasty/react-native-drum-picker/releases/tag/v0.1.2
|
|
56
89
|
[0.1.1]: https://github.com/scrollDynasty/react-native-drum-picker/releases/tag/v0.1.1
|
|
57
90
|
[0.1.0]: https://github.com/scrollDynasty/react-native-drum-picker/compare/v0.0.1...v0.1.0
|
package/README.md
CHANGED
|
@@ -60,6 +60,35 @@ npx react-native run-android
|
|
|
60
60
|
|
|
61
61
|
Requires **React Native 0.76+** with the **New Architecture** enabled.
|
|
62
62
|
|
|
63
|
+
## Compatibility
|
|
64
|
+
|
|
65
|
+
| Environment | Status |
|
|
66
|
+
|-------------|--------|
|
|
67
|
+
| React Native New Architecture | **Required** |
|
|
68
|
+
| Fabric | **Required** |
|
|
69
|
+
| Android | Supported |
|
|
70
|
+
| iOS | **Not supported yet** |
|
|
71
|
+
| Expo Go | **Not supported** (native Android library) |
|
|
72
|
+
| Expo SDK 54 + dev build / prebuild | Tested |
|
|
73
|
+
| `react-native-screens` navigation | Tested (use **0.1.4+** for detach safety) |
|
|
74
|
+
|
|
75
|
+
### Tested with
|
|
76
|
+
|
|
77
|
+
| Tool | Version |
|
|
78
|
+
|------|---------|
|
|
79
|
+
| Expo SDK | 54 |
|
|
80
|
+
| React Native | 0.81.5, 0.85.0 (example app) |
|
|
81
|
+
| New Architecture | enabled |
|
|
82
|
+
| Android | emulator / device |
|
|
83
|
+
|
|
84
|
+
**Intended range:** `react-native >= 0.76` with New Architecture. The package is **actively tested on RN 0.81.x / 0.85.x**. Older 0.76–0.80 may work but are not CI-guaranteed.
|
|
85
|
+
|
|
86
|
+
This package is an **Android Fabric View** library. Use a **development build** or `expo run:android` after `expo prebuild` — not Expo Go.
|
|
87
|
+
|
|
88
|
+
### React Native 0.81+ event dispatch
|
|
89
|
+
|
|
90
|
+
If Android Kotlin compile fails with `No value passed for parameter 'uiManagerType'`, upgrade to **0.1.3+** (Fabric `UIManagerType.FABRIC`).
|
|
91
|
+
|
|
63
92
|
## Basic usage
|
|
64
93
|
|
|
65
94
|
```tsx
|
|
@@ -81,7 +110,73 @@ export function Example() {
|
|
|
81
110
|
}
|
|
82
111
|
```
|
|
83
112
|
|
|
84
|
-
|
|
113
|
+
### Layout defaults (0.1.5+)
|
|
114
|
+
|
|
115
|
+
The JS wrapper applies **`minWidth: 64`** and, unless you use flex or pass `height` / `minHeight`, **`height: itemHeight * visibleItemCount`** (default **220**). Native Android also sets matching `minimumWidth` / `minimumHeight`.
|
|
116
|
+
|
|
117
|
+
For production layouts, still pass explicit dimensions:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
style={{ width: 120, height: itemHeight * visibleItemCount }}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
In `__DEV__`, a **one-time** warning is logged if neither height nor flex sizing is provided.
|
|
124
|
+
|
|
125
|
+
## Examples
|
|
126
|
+
|
|
127
|
+
### Time picker (hour · minute)
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
|
131
|
+
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
|
132
|
+
|
|
133
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
134
|
+
<DrumPicker items={hours} style={{ width: 72, height: 220 }} />
|
|
135
|
+
<DrumPicker items={minutes} style={{ width: 72, height: 220 }} />
|
|
136
|
+
</View>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Height / weight row (onboarding style)
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
143
|
+
<DrumPicker items={heightsCm} style={{ width: 90, height: 220 }} />
|
|
144
|
+
<DrumPicker items={weightsKg} style={{ width: 96, height: 220 }} />
|
|
145
|
+
</View>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Controlled `selectedIndex`
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
const [index, setIndex] = useState(1);
|
|
152
|
+
|
|
153
|
+
<DrumPicker
|
|
154
|
+
items={items}
|
|
155
|
+
selectedIndex={index}
|
|
156
|
+
onChange={(e) => setIndex(e.nativeEvent.index)}
|
|
157
|
+
style={{ width: 120, height: 220 }}
|
|
158
|
+
/>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### `onChange` and expensive side effects
|
|
162
|
+
|
|
163
|
+
Native emits `onChange` when the wheel **snaps to idle** and the **centered index changes** (duplicate indices are ignored). Use it for UI state. For AsyncStorage, APIs, or analytics, **debounce** in your app:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
const save = useMemo(() => {
|
|
167
|
+
let t: ReturnType<typeof setTimeout> | undefined;
|
|
168
|
+
return (value: string) => {
|
|
169
|
+
if (t) clearTimeout(t);
|
|
170
|
+
t = setTimeout(() => {
|
|
171
|
+
// persist value
|
|
172
|
+
}, 300);
|
|
173
|
+
};
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
<DrumPicker onChange={(e) => save(e.nativeEvent.value)} ... />
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
See the **example** app (`example/src/App.tsx`) for basic, time, height/weight, date, controlled, and debounced demos.
|
|
85
180
|
|
|
86
181
|
## DateDrumPicker
|
|
87
182
|
|
|
@@ -222,9 +317,34 @@ Use an **odd** `visibleItemCount` (e.g. `5`) for a symmetric wheel.
|
|
|
222
317
|
| Metro shows old code | `npx react-native start --reset-cache` |
|
|
223
318
|
| Gradle / build errors | `cd android && ./gradlew clean` (Windows: `.\gradlew clean`) |
|
|
224
319
|
| `adb` not found | Install Android SDK Platform-Tools; add to `PATH` |
|
|
225
|
-
| Empty or white picker | Enable New Architecture; set
|
|
320
|
+
| Empty or white picker | Enable New Architecture; upgrade to **0.1.5+** for layout defaults; or set `style={{ width, height: itemHeight * visibleItemCount }}` |
|
|
321
|
+
| Wrong initial row / off-center | Upgrade to **0.1.5+**; avoid `key` remount hacks unless needed for other reasons |
|
|
226
322
|
| Props not applied | Rebuild app after native changes; run `yarn build` in the library before packing |
|
|
227
323
|
| iOS | Not supported — Android only |
|
|
324
|
+
| Expo Go | Use prebuild + dev build; this library is not in Expo Go |
|
|
325
|
+
| RN 0.81 `uiManagerType` compile error | Upgrade to 0.1.3+ |
|
|
326
|
+
| Crash when leaving a screen | Upgrade to 0.1.4+ (safe `onDetachedFromWindow` with react-native-screens) |
|
|
327
|
+
| npm shows “no README” | Often a registry UI lag; run `npm view react-native-drum-picker readme` — if content appears, hard-refresh the package page |
|
|
328
|
+
|
|
329
|
+
### Android build fails in Expo / React Native 0.81+
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
cd android
|
|
333
|
+
./gradlew clean
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Windows:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
cd android
|
|
340
|
+
.\gradlew clean
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Then rebuild the native app (`npx expo run:android` or `npx react-native run-android`).
|
|
344
|
+
|
|
345
|
+
### Crash when leaving a screen
|
|
346
|
+
|
|
347
|
+
If the app crashes when navigating away from a screen with `DrumPicker` (especially with `react-native-screens` transitions), upgrade to **0.1.4+**, which avoids unsafe RecyclerView cleanup during `onDetachedFromWindow`.
|
|
228
348
|
|
|
229
349
|
**New Architecture:** This library is a Fabric view. Ensure New Architecture is enabled in your app (required for RN 0.76+).
|
|
230
350
|
|
|
@@ -76,6 +76,7 @@ internal class DrumPickerAdapter(
|
|
|
76
76
|
textView.layoutParams =
|
|
77
77
|
(textView.layoutParams as RecyclerView.LayoutParams).apply { this.height = height }
|
|
78
78
|
textView.text = items[position]
|
|
79
|
+
textView.contentDescription = items[position]
|
|
79
80
|
textView.setBackgroundColor(itemBackgroundColor)
|
|
80
81
|
holder.lastStyleBucket = Int.MIN_VALUE
|
|
81
82
|
val distance = distanceForPosition?.invoke(position) ?: 2f
|
|
@@ -8,6 +8,7 @@ import android.graphics.Color
|
|
|
8
8
|
internal object DrumPickerDefaults {
|
|
9
9
|
const val ITEM_HEIGHT_DP = 44f
|
|
10
10
|
const val VISIBLE_ITEM_COUNT = 5
|
|
11
|
+
const val MIN_WIDTH_DP = 64f
|
|
11
12
|
const val TEXT_SIZE_SP = 20f
|
|
12
13
|
const val SELECTED_TEXT_SIZE_SP = 22f
|
|
13
14
|
const val SELECTION_INDICATOR_HEIGHT_DP = 1f
|
|
@@ -13,6 +13,7 @@ import com.facebook.react.bridge.ReadableArray
|
|
|
13
13
|
import com.facebook.react.bridge.ReadableType
|
|
14
14
|
import com.facebook.react.bridge.ReactContext
|
|
15
15
|
import com.facebook.react.uimanager.UIManagerHelper
|
|
16
|
+
import com.facebook.react.uimanager.common.UIManagerType
|
|
16
17
|
import com.facebook.react.uimanager.events.EventDispatcher
|
|
17
18
|
import kotlin.math.abs
|
|
18
19
|
|
|
@@ -47,13 +48,16 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
47
48
|
private var selectionIndicatorHeightPx = dpToPx(selectionIndicatorHeightDp)
|
|
48
49
|
private var lastEmittedIndex = -1
|
|
49
50
|
private var suppressChangeEvent = false
|
|
50
|
-
private var
|
|
51
|
+
private var isAttachedToWindow = false
|
|
52
|
+
private var isDisposed = false
|
|
51
53
|
private var styleUpdatePosted = false
|
|
54
|
+
private var pendingScrollRunnable: Runnable? = null
|
|
55
|
+
private val minWidthPx = dpToPx(DrumPickerDefaults.MIN_WIDTH_DP)
|
|
52
56
|
|
|
53
57
|
private val styleUpdateRunnable =
|
|
54
58
|
Runnable {
|
|
55
59
|
styleUpdatePosted = false
|
|
56
|
-
if (
|
|
60
|
+
if (isLifecycleActive()) {
|
|
57
61
|
updateVisibleItemStyles()
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -61,14 +65,14 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
61
65
|
private val scrollListener =
|
|
62
66
|
object : RecyclerView.OnScrollListener() {
|
|
63
67
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
64
|
-
if (
|
|
68
|
+
if (!isLifecycleActive()) {
|
|
65
69
|
return
|
|
66
70
|
}
|
|
67
71
|
scheduleVisibleItemStyleUpdate()
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
71
|
-
if (
|
|
75
|
+
if (!isLifecycleActive()) {
|
|
72
76
|
return
|
|
73
77
|
}
|
|
74
78
|
when (newState) {
|
|
@@ -118,11 +122,12 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
118
122
|
applyBackgroundColors()
|
|
119
123
|
applyRecyclerPadding()
|
|
120
124
|
updateIndicatorAppearance()
|
|
125
|
+
updateMinimumDimensions()
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
fun setItemsProp(value: Any?) {
|
|
124
129
|
val newItems = parseItems(value)
|
|
125
|
-
if (newItems
|
|
130
|
+
if (itemsContentEquals(newItems, items)) {
|
|
126
131
|
return
|
|
127
132
|
}
|
|
128
133
|
|
|
@@ -137,7 +142,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
selectedIndex = selectedIndex.coerceIn(0, items.size - 1)
|
|
140
|
-
runWhenAttached {
|
|
145
|
+
runWhenAttached { scheduleScrollToSelectedIndexCentered(animated = false, emit = false) }
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
fun setSelectedIndexProp(value: Any?) {
|
|
@@ -213,13 +218,11 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
213
218
|
}
|
|
214
219
|
|
|
215
220
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
216
|
-
|
|
221
|
+
updateMinimumDimensions()
|
|
222
|
+
val width = resolveSize(minWidthPx, widthMeasureSpec)
|
|
217
223
|
val safeVisibleCount = visibleItemCount.coerceAtLeast(1)
|
|
218
|
-
val
|
|
219
|
-
|
|
220
|
-
(itemHeightPx * safeVisibleCount).coerceAtLeast(suggestedMinimumHeight),
|
|
221
|
-
heightMeasureSpec,
|
|
222
|
-
)
|
|
224
|
+
val defaultHeight = itemHeightPx * safeVisibleCount
|
|
225
|
+
val height = resolveSize(defaultHeight, heightMeasureSpec)
|
|
223
226
|
|
|
224
227
|
val childWidthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
|
|
225
228
|
val childHeightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
|
@@ -235,31 +238,37 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
235
238
|
layoutSelectionIndicators(width, height)
|
|
236
239
|
if (changed) {
|
|
237
240
|
applyRecyclerPadding()
|
|
238
|
-
runWhenAttached {
|
|
241
|
+
runWhenAttached { scheduleScrollToSelectedIndexCentered(animated = false, emit = false) }
|
|
239
242
|
}
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
override fun onAttachedToWindow() {
|
|
243
246
|
super.onAttachedToWindow()
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
snapHelper.attachToRecyclerView(recyclerView)
|
|
247
|
+
isAttachedToWindow = true
|
|
248
|
+
isDisposed = false
|
|
249
|
+
suppressChangeEvent = false
|
|
250
|
+
recyclerView.removeOnScrollListener(scrollListener)
|
|
249
251
|
recyclerView.addOnScrollListener(scrollListener)
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
override fun onDetachedFromWindow() {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
isAttachedToWindow = false
|
|
256
|
+
isDisposed = true
|
|
257
|
+
suppressChangeEvent = false
|
|
255
258
|
styleUpdatePosted = false
|
|
259
|
+
cancelPendingScroll()
|
|
260
|
+
recyclerView.stopScroll()
|
|
261
|
+
recyclerView.removeCallbacks(styleUpdateRunnable)
|
|
256
262
|
removeCallbacks(null)
|
|
257
|
-
recyclerView.
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
recyclerView.removeCallbacks(null)
|
|
264
|
+
recyclerView.removeOnScrollListener(scrollListener)
|
|
265
|
+
// Do not null adapter or detach SnapHelper here — react-native-screens may
|
|
266
|
+
// detach during transitions while RecyclerView is still laying out/scrolling.
|
|
260
267
|
super.onDetachedFromWindow()
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
private fun isLifecycleActive(): Boolean = isAttachedToWindow && !isDisposed
|
|
271
|
+
|
|
263
272
|
fun setSelectedIndex(index: Int) {
|
|
264
273
|
if (items.isEmpty()) {
|
|
265
274
|
selectedIndex = index.coerceAtLeast(0)
|
|
@@ -267,11 +276,15 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
267
276
|
}
|
|
268
277
|
val clamped = index.coerceIn(0, items.size - 1)
|
|
269
278
|
val snapIndex = findSnapCenterIndex()
|
|
270
|
-
|
|
279
|
+
val isAlreadyCentered =
|
|
280
|
+
clamped == selectedIndex &&
|
|
281
|
+
snapIndex == clamped &&
|
|
282
|
+
recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE
|
|
283
|
+
if (isAlreadyCentered) {
|
|
271
284
|
return
|
|
272
285
|
}
|
|
273
286
|
selectedIndex = clamped
|
|
274
|
-
|
|
287
|
+
scheduleScrollToSelectedIndexCentered(animated = false, emit = false)
|
|
275
288
|
}
|
|
276
289
|
|
|
277
290
|
fun setItemHeight(height: Float) {
|
|
@@ -283,6 +296,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
283
296
|
adapter.itemHeightPx = itemHeightPx
|
|
284
297
|
adapter.notifyRowMetricsChanged()
|
|
285
298
|
applyRecyclerPadding()
|
|
299
|
+
updateMinimumDimensions()
|
|
286
300
|
requestLayout()
|
|
287
301
|
}
|
|
288
302
|
|
|
@@ -292,9 +306,16 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
292
306
|
}
|
|
293
307
|
visibleItemCount = count
|
|
294
308
|
applyRecyclerPadding()
|
|
309
|
+
updateMinimumDimensions()
|
|
295
310
|
requestLayout()
|
|
296
311
|
}
|
|
297
312
|
|
|
313
|
+
private fun updateMinimumDimensions() {
|
|
314
|
+
val safeVisibleCount = visibleItemCount.coerceAtLeast(1)
|
|
315
|
+
minimumHeight = itemHeightPx * safeVisibleCount
|
|
316
|
+
minimumWidth = minWidthPx
|
|
317
|
+
}
|
|
318
|
+
|
|
298
319
|
fun setTextColor(color: Int) {
|
|
299
320
|
textColor = color
|
|
300
321
|
syncAdapterStyle()
|
|
@@ -387,8 +408,37 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
387
408
|
bottomIndicator.requestLayout()
|
|
388
409
|
}
|
|
389
410
|
|
|
390
|
-
private fun
|
|
391
|
-
if (
|
|
411
|
+
private fun scheduleScrollToSelectedIndexCentered(animated: Boolean, emit: Boolean) {
|
|
412
|
+
if (!isLifecycleActive() || items.isEmpty()) {
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
cancelPendingScroll()
|
|
416
|
+
val runnable =
|
|
417
|
+
Runnable {
|
|
418
|
+
pendingScrollRunnable = null
|
|
419
|
+
if (!isLifecycleActive()) {
|
|
420
|
+
return@Runnable
|
|
421
|
+
}
|
|
422
|
+
scrollToSelectedIndexCentered(animated, emit)
|
|
423
|
+
}
|
|
424
|
+
pendingScrollRunnable = runnable
|
|
425
|
+
if (recyclerView.height > 0) {
|
|
426
|
+
recyclerView.post(runnable)
|
|
427
|
+
} else {
|
|
428
|
+
post(runnable)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private fun cancelPendingScroll() {
|
|
433
|
+
pendingScrollRunnable?.let { pending ->
|
|
434
|
+
recyclerView.removeCallbacks(pending)
|
|
435
|
+
removeCallbacks(pending)
|
|
436
|
+
}
|
|
437
|
+
pendingScrollRunnable = null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private fun scrollToSelectedIndexCentered(animated: Boolean, emit: Boolean) {
|
|
441
|
+
if (!isLifecycleActive() || items.isEmpty()) {
|
|
392
442
|
return
|
|
393
443
|
}
|
|
394
444
|
suppressChangeEvent = !emit
|
|
@@ -398,27 +448,41 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
398
448
|
return
|
|
399
449
|
}
|
|
400
450
|
|
|
401
|
-
|
|
451
|
+
if (recyclerView.height <= 0) {
|
|
452
|
+
scheduleScrollToSelectedIndexCentered(animated = false, emit = emit)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
val centerOffset = ((recyclerView.height - itemHeightPx) / 2).coerceAtLeast(0)
|
|
457
|
+
layoutManager.scrollToPositionWithOffset(index, centerOffset)
|
|
402
458
|
recyclerView.post {
|
|
403
|
-
if (
|
|
459
|
+
if (!isLifecycleActive()) {
|
|
404
460
|
return@post
|
|
405
461
|
}
|
|
462
|
+
val snappedIndex = findSnapCenterIndex()
|
|
463
|
+
if (snappedIndex != RecyclerView.NO_POSITION && snappedIndex != index) {
|
|
464
|
+
layoutManager.scrollToPositionWithOffset(snappedIndex, centerOffset)
|
|
465
|
+
selectedIndex = snappedIndex
|
|
466
|
+
}
|
|
406
467
|
updateVisibleItemStyles()
|
|
468
|
+
val resolvedIndex =
|
|
469
|
+
if (snappedIndex != RecyclerView.NO_POSITION) snappedIndex else index
|
|
407
470
|
if (emit) {
|
|
408
|
-
maybeEmitChange(
|
|
471
|
+
maybeEmitChange(resolvedIndex)
|
|
409
472
|
} else {
|
|
410
|
-
lastEmittedIndex =
|
|
473
|
+
lastEmittedIndex = resolvedIndex
|
|
474
|
+
selectedIndex = resolvedIndex
|
|
411
475
|
}
|
|
412
476
|
suppressChangeEvent = false
|
|
413
477
|
}
|
|
414
478
|
}
|
|
415
479
|
|
|
416
480
|
private fun runWhenAttached(block: () -> Unit) {
|
|
417
|
-
if (
|
|
481
|
+
if (!isLifecycleActive()) {
|
|
418
482
|
return
|
|
419
483
|
}
|
|
420
484
|
post {
|
|
421
|
-
if (
|
|
485
|
+
if (!isLifecycleActive()) {
|
|
422
486
|
return@post
|
|
423
487
|
}
|
|
424
488
|
block()
|
|
@@ -426,7 +490,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
426
490
|
}
|
|
427
491
|
|
|
428
492
|
private fun updateCenterFromSnap() {
|
|
429
|
-
if (
|
|
493
|
+
if (!isLifecycleActive() || items.isEmpty()) {
|
|
430
494
|
return
|
|
431
495
|
}
|
|
432
496
|
val centerIndex = findSnapCenterIndex()
|
|
@@ -448,7 +512,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
448
512
|
}
|
|
449
513
|
|
|
450
514
|
private fun scheduleVisibleItemStyleUpdate() {
|
|
451
|
-
if (styleUpdatePosted ||
|
|
515
|
+
if (styleUpdatePosted || !isLifecycleActive()) {
|
|
452
516
|
return
|
|
453
517
|
}
|
|
454
518
|
styleUpdatePosted = true
|
|
@@ -466,7 +530,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
466
530
|
}
|
|
467
531
|
|
|
468
532
|
private fun updateVisibleItemStyles() {
|
|
469
|
-
if (
|
|
533
|
+
if (!isLifecycleActive() || recyclerView.height == 0 || itemHeightPx <= 0) {
|
|
470
534
|
return
|
|
471
535
|
}
|
|
472
536
|
|
|
@@ -483,7 +547,7 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
483
547
|
}
|
|
484
548
|
|
|
485
549
|
private fun maybeEmitChange(index: Int) {
|
|
486
|
-
if (
|
|
550
|
+
if (!isLifecycleActive() || suppressChangeEvent || items.isEmpty()) {
|
|
487
551
|
return
|
|
488
552
|
}
|
|
489
553
|
if (index < 0 || index >= items.size) {
|
|
@@ -499,7 +563,13 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
499
563
|
selectedIndex = clamped
|
|
500
564
|
|
|
501
565
|
val reactContext = context as? ReactContext ?: return
|
|
502
|
-
|
|
566
|
+
if (!reactContext.hasActiveReactInstance()) {
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
// Fabric-only: pass UIManagerType.FABRIC for RN 0.81–0.84; deprecated but still required there.
|
|
570
|
+
@Suppress("DEPRECATION")
|
|
571
|
+
val dispatcher: EventDispatcher? =
|
|
572
|
+
UIManagerHelper.getEventDispatcher(reactContext, UIManagerType.FABRIC)
|
|
503
573
|
dispatcher?.dispatchEvent(
|
|
504
574
|
DrumPickerChangeEvent(
|
|
505
575
|
UIManagerHelper.getSurfaceId(reactContext),
|
|
@@ -580,4 +650,19 @@ class DrumPickerView @JvmOverloads constructor(
|
|
|
580
650
|
|
|
581
651
|
private fun dpToPx(dp: Float): Int =
|
|
582
652
|
(dp * resources.displayMetrics.density + 0.5f).toInt()
|
|
653
|
+
|
|
654
|
+
private fun itemsContentEquals(a: List<String>, b: List<String>): Boolean {
|
|
655
|
+
if (a === b) {
|
|
656
|
+
return true
|
|
657
|
+
}
|
|
658
|
+
if (a.size != b.size) {
|
|
659
|
+
return false
|
|
660
|
+
}
|
|
661
|
+
for (i in a.indices) {
|
|
662
|
+
if (a[i] != b[i]) {
|
|
663
|
+
return false
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return true
|
|
667
|
+
}
|
|
583
668
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { StyleSheet, View } from 'react-native';
|
|
5
5
|
import { buildDayItems, buildMonthItems, buildYearItems, clampDateDrumPickerValue, clampDayForMonth, clampYear, normalizeYearRange, parseMonthFromLabel } from "./dateDrumPickerLogic.js";
|
|
6
6
|
import { DrumPicker } from './DrumPicker';
|
|
@@ -75,6 +75,20 @@ export function DateDrumPicker({
|
|
|
75
75
|
}
|
|
76
76
|
onChange?.(next);
|
|
77
77
|
}, [isControlled, onChange, resolvedValue, minYear, maxYear]);
|
|
78
|
+
|
|
79
|
+
// Controlled: parent may pass day 31 + April — clamp and notify once.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!isControlled || !onChange || value === undefined) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const clamped = clampDateDrumPickerValue(value, minYear, maxYear);
|
|
85
|
+
const day = value.day ?? clamped.day;
|
|
86
|
+
const month = value.month ?? clamped.month;
|
|
87
|
+
const year = value.year ?? clamped.year;
|
|
88
|
+
if (day !== clamped.day || month !== clamped.month || year !== clamped.year) {
|
|
89
|
+
onChange(clamped);
|
|
90
|
+
}
|
|
91
|
+
}, [isControlled, onChange, value, minYear, maxYear]);
|
|
78
92
|
const sharedPickerProps = {
|
|
79
93
|
itemHeight,
|
|
80
94
|
visibleItemCount,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { StyleSheet } from 'react-native';
|
|
4
3
|
import DrumPickerNative from './DrumPickerViewNativeComponent';
|
|
4
|
+
import { resolveDrumPickerStyle } from "./drumPickerLayout.js";
|
|
5
5
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
6
6
|
const DEFAULTS = {
|
|
7
7
|
selectedIndex: 0,
|
|
@@ -36,10 +36,7 @@ export function DrumPicker({
|
|
|
36
36
|
onChange,
|
|
37
37
|
style
|
|
38
38
|
}) {
|
|
39
|
-
const
|
|
40
|
-
const pickerStyle = StyleSheet.flatten([{
|
|
41
|
-
height: pickerHeight
|
|
42
|
-
}, style]);
|
|
39
|
+
const pickerStyle = resolveDrumPickerStyle(itemHeight, visibleItemCount, style);
|
|
43
40
|
return /*#__PURE__*/_jsx(DrumPickerNative, {
|
|
44
41
|
collapsable: false,
|
|
45
42
|
items: items,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { StyleSheet } from 'react-native';
|
|
4
|
+
let didWarnMissingLayout = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merges consumer style with sensible defaults so the native picker is visible
|
|
8
|
+
* without forcing dimensions when flex layout is used.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveDrumPickerStyle(itemHeight, visibleItemCount, style) {
|
|
11
|
+
const flat = StyleSheet.flatten(style) ?? {};
|
|
12
|
+
const pickerHeight = itemHeight * visibleItemCount;
|
|
13
|
+
const hasExplicitHeight = flat.height != null || flat.minHeight != null || flat.maxHeight != null;
|
|
14
|
+
const usesFlex = flat.flex != null || flat.flexGrow != null || flat.flexShrink != null || flat.alignSelf === 'stretch';
|
|
15
|
+
if (__DEV__ && !didWarnMissingLayout && !hasExplicitHeight && !usesFlex) {
|
|
16
|
+
didWarnMissingLayout = true;
|
|
17
|
+
console.warn('react-native-drum-picker: DrumPicker needs a visible height. ' + `Defaulting to itemHeight * visibleItemCount (${pickerHeight}). ` + 'Pass style={{ height: ... }} or style={{ minHeight: ... }} for precise control.');
|
|
18
|
+
}
|
|
19
|
+
const sizeDefaults = hasExplicitHeight || usesFlex ? {
|
|
20
|
+
minWidth: 64,
|
|
21
|
+
minHeight: pickerHeight
|
|
22
|
+
} : {
|
|
23
|
+
minWidth: 64,
|
|
24
|
+
minHeight: pickerHeight,
|
|
25
|
+
height: pickerHeight
|
|
26
|
+
};
|
|
27
|
+
return StyleSheet.flatten([sizeDefaults, style]);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=drumPickerLayout.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* Merges consumer style with sensible defaults so the native picker is visible
|
|
4
|
+
* without forcing dimensions when flex layout is used.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveDrumPickerStyle(itemHeight: number, visibleItemCount: number, style?: StyleProp<ViewStyle>): ViewStyle;
|
|
7
|
+
//# sourceMappingURL=drumPickerLayout.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-drum-picker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Android-native iOS-style drum/wheel picker for React Native (Fabric)",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"module": "./lib/module/index.js",
|
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
"typescript": "^6.0.2"
|
|
102
102
|
},
|
|
103
103
|
"peerDependencies": {
|
|
104
|
-
"react": "
|
|
105
|
-
"react-native": ">=0.76
|
|
104
|
+
"react": "*",
|
|
105
|
+
"react-native": ">=0.76"
|
|
106
106
|
},
|
|
107
107
|
"workspaces": [
|
|
108
108
|
"example"
|
package/src/DateDrumPicker.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
StyleSheet,
|
|
4
4
|
View,
|
|
@@ -164,6 +164,24 @@ export function DateDrumPicker({
|
|
|
164
164
|
[isControlled, onChange, resolvedValue, minYear, maxYear]
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
// Controlled: parent may pass day 31 + April — clamp and notify once.
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!isControlled || !onChange || value === undefined) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const clamped = clampDateDrumPickerValue(value, minYear, maxYear);
|
|
173
|
+
const day = value.day ?? clamped.day;
|
|
174
|
+
const month = value.month ?? clamped.month;
|
|
175
|
+
const year = value.year ?? clamped.year;
|
|
176
|
+
if (
|
|
177
|
+
day !== clamped.day ||
|
|
178
|
+
month !== clamped.month ||
|
|
179
|
+
year !== clamped.year
|
|
180
|
+
) {
|
|
181
|
+
onChange(clamped);
|
|
182
|
+
}
|
|
183
|
+
}, [isControlled, onChange, value, minYear, maxYear]);
|
|
184
|
+
|
|
167
185
|
const sharedPickerProps = {
|
|
168
186
|
itemHeight,
|
|
169
187
|
visibleItemCount,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { StyleSheet } from 'react-native';
|
|
2
1
|
import DrumPickerNative from './DrumPickerViewNativeComponent';
|
|
2
|
+
import { resolveDrumPickerStyle } from './drumPickerLayout';
|
|
3
3
|
import type { DrumPickerProps } from './types';
|
|
4
4
|
|
|
5
5
|
const DEFAULTS = {
|
|
@@ -36,13 +36,11 @@ export function DrumPicker({
|
|
|
36
36
|
onChange,
|
|
37
37
|
style,
|
|
38
38
|
}: DrumPickerProps) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
style,
|
|
45
|
-
]);
|
|
39
|
+
const pickerStyle = resolveDrumPickerStyle(
|
|
40
|
+
itemHeight,
|
|
41
|
+
visibleItemCount,
|
|
42
|
+
style
|
|
43
|
+
);
|
|
46
44
|
|
|
47
45
|
return (
|
|
48
46
|
<DrumPickerNative
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
let didWarnMissingLayout = false;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Merges consumer style with sensible defaults so the native picker is visible
|
|
7
|
+
* without forcing dimensions when flex layout is used.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveDrumPickerStyle(
|
|
10
|
+
itemHeight: number,
|
|
11
|
+
visibleItemCount: number,
|
|
12
|
+
style?: StyleProp<ViewStyle>
|
|
13
|
+
): ViewStyle {
|
|
14
|
+
const flat = StyleSheet.flatten(style) ?? {};
|
|
15
|
+
const pickerHeight = itemHeight * visibleItemCount;
|
|
16
|
+
const hasExplicitHeight =
|
|
17
|
+
flat.height != null || flat.minHeight != null || flat.maxHeight != null;
|
|
18
|
+
const usesFlex =
|
|
19
|
+
flat.flex != null ||
|
|
20
|
+
flat.flexGrow != null ||
|
|
21
|
+
flat.flexShrink != null ||
|
|
22
|
+
flat.alignSelf === 'stretch';
|
|
23
|
+
|
|
24
|
+
if (__DEV__ && !didWarnMissingLayout && !hasExplicitHeight && !usesFlex) {
|
|
25
|
+
didWarnMissingLayout = true;
|
|
26
|
+
console.warn(
|
|
27
|
+
'react-native-drum-picker: DrumPicker needs a visible height. ' +
|
|
28
|
+
`Defaulting to itemHeight * visibleItemCount (${pickerHeight}). ` +
|
|
29
|
+
'Pass style={{ height: ... }} or style={{ minHeight: ... }} for precise control.'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sizeDefaults: ViewStyle =
|
|
34
|
+
hasExplicitHeight || usesFlex
|
|
35
|
+
? { minWidth: 64, minHeight: pickerHeight }
|
|
36
|
+
: { minWidth: 64, minHeight: pickerHeight, height: pickerHeight };
|
|
37
|
+
|
|
38
|
+
return StyleSheet.flatten([sizeDefaults, style]) as ViewStyle;
|
|
39
|
+
}
|