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 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.2...HEAD
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
- Set `style.height` `itemHeight * visibleItemCount` (default `44 × 5 = 220`).
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 explicit `width` / `height` in `style` |
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 isDetached = false
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 (!isDetached) {
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 (isDetached) {
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 (isDetached) {
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 == items) {
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 { scrollToSelectedIndex(animated = false, emit = false) }
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
- val width = resolveSize(suggestedMinimumWidth, widthMeasureSpec)
221
+ updateMinimumDimensions()
222
+ val width = resolveSize(minWidthPx, widthMeasureSpec)
217
223
  val safeVisibleCount = visibleItemCount.coerceAtLeast(1)
218
- val height =
219
- resolveSize(
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 { scrollToSelectedIndex(animated = false, emit = false) }
241
+ runWhenAttached { scheduleScrollToSelectedIndexCentered(animated = false, emit = false) }
239
242
  }
240
243
  }
241
244
 
242
245
  override fun onAttachedToWindow() {
243
246
  super.onAttachedToWindow()
244
- isDetached = false
245
- if (recyclerView.adapter == null) {
246
- recyclerView.adapter = adapter
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
- isDetached = true
254
- recyclerView.removeCallbacks(styleUpdateRunnable)
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.clearOnScrollListeners()
258
- snapHelper.attachToRecyclerView(null)
259
- recyclerView.adapter = null
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
- if (clamped == selectedIndex && snapIndex == clamped) {
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
- scrollToSelectedIndex(animated = false, emit = false)
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 scrollToSelectedIndex(animated: Boolean, emit: Boolean) {
391
- if (isDetached || items.isEmpty()) {
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
- layoutManager.scrollToPositionWithOffset(index, 0)
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 (isDetached) {
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(index)
471
+ maybeEmitChange(resolvedIndex)
409
472
  } else {
410
- lastEmittedIndex = index
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 (isDetached) {
481
+ if (!isLifecycleActive()) {
418
482
  return
419
483
  }
420
484
  post {
421
- if (isDetached) {
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 (isDetached || items.isEmpty()) {
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 || isDetached) {
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 (isDetached || recyclerView.height == 0 || itemHeightPx <= 0) {
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 (isDetached || suppressChangeEvent || items.isEmpty()) {
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
- val dispatcher: EventDispatcher? = UIManagerHelper.getEventDispatcher(reactContext)
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 pickerHeight = itemHeight * visibleItemCount;
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.2",
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": ">=18.0.0",
105
- "react-native": ">=0.76.0"
104
+ "react": "*",
105
+ "react-native": ">=0.76"
106
106
  },
107
107
  "workspaces": [
108
108
  "example"
@@ -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 pickerHeight = itemHeight * visibleItemCount;
40
- const pickerStyle = StyleSheet.flatten([
41
- {
42
- height: pickerHeight,
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
+ }