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
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0](https://github.com/scrollDynasty/react-native-drum-picker/compare/v0.2.4...v0.3.0) (2026-07-27)

Fixes the integration defects that forced consumers to work around the picker with delayed
mounting, two-phase range changes and `onChange` suppression flags.

### ⚠ BREAKING CHANGES

* **types:** `DateDrumPickerValue` now requires `day`, `month` and `year`. A partial value passed to
`DateDrumPicker.value` silently fell back to today's date for the missing fields. Use the new
`DateDrumPickerPartialValue` where an incomplete date is intentional — `scrollToDate` and
`clampDateDrumPickerValue` both accept it. Migration: `useState<DateDrumPickerValue>({})` becomes
an explicit `{ day, month, year }`.
* **android:** changing `items` now restores the selected **value** instead of the scroll position,
and no longer emits `onChange`. Code that relied on the old drift (or on the spurious event) will
see different behaviour.

### Bug Fixes

* **android:** put the selected row under the selection indicator instead of
`(visibleItemCount - 1) / 2` rows below it. `scrollToPositionWithOffset` measures its offset from
the layout manager's start *after padding*, but the offset was computed from the viewport centre
as if padding did not exist — and the picker reserves exactly that many rows of top padding. The
wheel therefore showed a row two positions early at the default `visibleItemCount = 5`. It went
unnoticed because the old code then forced `selectedIndex` to the requested value regardless of
where the wheel had actually landed, which is why the reported symptom was "JS state is correct,
only the rendering disagrees".
* **android:** center on `selectedIndex` when the picker mounts without a size. Centering was
scheduled through nested `post` calls and computed its offset from a height that was still `0`,
so a picker mounted in a collapsed container stayed on index 0. The request is now parked and
flushed from `onLayout`, where the real height is known.
* **android:** draw the rows around the selection after a programmatic scroll.
`scrollToPositionWithOffset` only parks an anchor and calls `requestLayout()`; React Native's
root view does not lay out native children, so the anchor went unused and the wheel was left
with just the centre row until the user dragged it.
* **android:** keep the selected value when `items` or the date range changes.
`selectedIndex` was clamped against the list it was replacing — Fabric applies props in
arbitrary order — which permanently moved the selection. The raw requested index is now kept and
re-resolved against the new list, and `DrumPickerViewManager` applies size, list and position
props in a fixed order.
* **android:** never emit `onChange` while props are being applied, so a controlled parent no
longer needs a ref flag to mute events during reconfiguration.
* **android:** hand the touch sequence to React Native via `NativeGestureUtil` when the wheel
starts scrolling and back when it settles, so the JS responder system stops competing for it.
This is what makes the wheel spin inside a `Modal`, where
`DialogRootViewGroup.requestDisallowInterceptTouchEvent` is an empty method and the usual signal
is discarded.
* **android:** re-centre when `itemHeight` or `visibleItemCount` changes. Both feed the centering
offset while often leaving the picker's own bounds untouched, so `onLayout` reported no change
and nothing re-centred.
* **ios:** re-resolve the selection from the requested index on `setItems`, and reassert it in
`layoutSubviews`, matching the Android behaviour for zero-size mounts and list swaps.
* **android:** stop swallowing the user's `onChange` when they grab the wheel mid-animation. An
animated programmatic scroll armed change suppression until the wheel settled; a drag that
interrupted it settled under that same flag, so the row the user chose was applied silently.
Suppression is now released as soon as a drag starts, and is never armed when the target row is
already centred (no scroll would follow, so nothing would release it).
* **DateDrumPicker:** clamp the year column's `selectedIndex` like the day and month columns.

### Features

* **dev:** warn in `__DEV__` about empty `items`, an out-of-range `selectedIndex` that outlives a
render, and a picker still measuring zero after ~1.5s.
* **docs:** new "Common problems" section covering hidden containers, `Modal`, dependent columns
and controlled usage, with an "open on the current date" example.

### Verification

Fixes were derived from React Native 0.86's own layout and touch plumbing —
`ReactViewGroup.requestLayout()` and `ReactViewGroup.onLayout()` are both empty, and
`DialogRootViewGroup.requestDisallowInterceptTouchEvent` likewise — which is why the picker now
drives the RecyclerView's measure/layout itself and hands gestures over explicitly.

The JS suite and a standalone compile against RN 0.86 both pass. `DrumPickerRegressionTest` ran on
an emulator in CI and caught the centering-offset bug above, which every pre-existing test had
missed because they only asserted the *reported* index and never the row actually sitting under the
indicator.

Still unverified: the iOS `DrumPickerWheelViewTests` additions, and the reporter's `Modal` scenario
on a device.

## [0.2.4](https://github.com/scrollDynasty/react-native-drum-picker/compare/v0.2.3...v0.2.4) (2026-05-28)


Expand Down
112 changes: 111 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,113 @@ Backgrounds are **transparent by default**. Only text and optional indicator lin

Use an **odd** `visibleItemCount` (e.g. `5`) for a symmetric wheel.

## Common problems

### Opening the picker on a given date

The most frequent case: a section that renders the picker only while it is open, and wants it to
open already showing a date. A controlled `value` is all you need — no ref calls, no delayed
mounting, no `key` remount.

```tsx
const [open, setOpen] = useState(false);
const [date, setDate] = useState({ day: 26, month: 7, year: 2026 });

return (
<>
<Button title="Pick a date" onPress={() => setOpen(true)} />
{open ? (
<DateDrumPicker
mode="day-month-year"
locale="ru"
value={date}
onChange={setDate}
minDate={{ day: 1, month: 1, year: 1966 }}
maxDate={{ day: 31, month: 12, year: 2031 }}
itemHeight={44}
visibleItemCount={5}
/>
) : null}
</>
);
```

On open the wheel sits on `26 июль 2026` with its neighbours (24, 25, **26**, 27, 28) already
drawn, and `onChange` stays silent until the user actually spins something.

### Mounting inside a hidden or zero-height container

Accordions, bottom sheets and `{isOpen ? <Picker/> : null}` all mount the picker before it has a
size. Since **0.3.0** the picker parks its centering request and applies it the moment it is first
laid out, so this works out of the box. Two things still matter:

- the container must eventually reach a non-zero height — an ancestor stuck at `height: 0`, or
`display: 'none'`, means the picker is never measured and never becomes visible. In `__DEV__` the
picker warns about this after ~1.5s;
- keep `value` / `selectedIndex` controlled across the open/close cycle; do not reset it to `0`
while the section is collapsed.

### Inside a React Native `<Modal>`

Up to 0.2.4 the picker was effectively unusable inside `Modal`: rows clipped, offset from the
selection indicator, and swiping did nothing. Both halves of that are addressed in **0.3.0**, for
reasons worth spelling out because they explain the rest of this section too.

**Layout.** `Modal` hosts its children in a separate Android window under `DialogRootViewGroup`,
whose size only reaches the shadow tree asynchronously (`onSizeChanged` → state update → new mount
transaction). So the picker is mounted, laid out at height 0, and only gets real bounds several
frames later — the same situation as a collapsed accordion, just slower. The 0.3.0 centering fix is
layout-driven and applies whenever the real height arrives, however late that is.

**Gestures.** `DialogRootViewGroup.requestDisallowInterceptTouchEvent` is an empty method, so the
usual "this gesture is mine" signal a scrolling view sends to its ancestors is discarded there. The
picker now also calls `NativeGestureUtil.notifyNativeGestureStarted`, which routes through
`onChildStartedNativeGesture` — the one channel that still reaches the touch dispatcher inside a
dialog root.

> **Caveat:** this was derived from React Native 0.86's own layout and touch plumbing, not from a
> reproduction on a device — the reporter's original scenario was never re-run inside a `Modal`.
> The underlying centering fix is covered by instrumented tests, the gesture handoff is not. If
> `Modal` still misbehaves for you, please open an issue with your RN version; the alternatives
> below are known-good either way.

If you would rather not depend on it, any of these avoids the extra window entirely:

- an absolutely positioned overlay `View` inside your normal screen tree;
- a bottom-sheet library that renders into the same root (`@gorhom/bottom-sheet`,
`react-native-modalize`);
- a dedicated screen / route.

```tsx
// instead of <Modal> …
{open ? (
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
<Pressable style={StyleSheet.absoluteFill} onPress={() => setOpen(false)} />
<View style={styles.sheet}>
<DateDrumPicker value={date} onChange={setDate} />
</View>
</View>
) : null}
```

### Dependent columns (days in month, ranges that change)

Changing `items` — a shorter month, a widened year range, a new `minDate`/`maxDate` — keeps the
**selected value**, not the scroll position, and does not emit `onChange`. You do not need a ref
flag to suppress events while reconfiguring props, and you do not need to widen the range in two
phases.

If the selected value falls outside the new range it is clamped: the year to
`minDate.year…maxDate.year`, then the month to that year's allowed months, then the day to that
month's length. A clamp caused by *your* value being out of range does emit `onChange`, once, so
controlled parents can store the corrected value.

### Controlled usage

`value` is authoritative on every render. `onChange` fires only for user input, an explicit
`scrollToDate` / `scrollToIndex` call, or the clamp described above — never as a side effect of
changing other props.

## Troubleshooting

| Issue | What to try |
Expand All @@ -704,7 +811,10 @@ Use an **odd** `visibleItemCount` (e.g. `5`) for a symmetric wheel.
| Gradle / build errors | `cd android && ./gradlew clean` (Windows: `.\gradlew clean`) |
| `adb` not found | Install Android SDK Platform-Tools; add to `PATH` |
| Empty or white picker | Enable New Architecture; upgrade to **0.1.5+** for layout defaults; or set `style={{ width, height: itemHeight * visibleItemCount }}` |
| Wrong initial row / off-center | Upgrade to **0.1.5+**; avoid `key` remount hacks unless needed for other reasons |
| Wrong initial row / off-center | Upgrade to **0.3.0+**; see [Mounting inside a hidden or zero-height container](#mounting-inside-a-hidden-or-zero-height-container) |
| Only the centre row is drawn | Upgrade to **0.3.0+** |
| Value jumps when the range changes | Upgrade to **0.3.0+**; see [Dependent columns](#dependent-columns-days-in-month-ranges-that-change) |
| Clipped / unscrollable in `<Modal>` | Upgrade to **0.3.0+**; see [Inside a React Native `<Modal>`](#inside-a-react-native-modal) |
| Props not applied | Rebuild app after native changes; run `yarn build` in the library before packing |
| iOS build issues | Run `pod install` in `example/ios`; use New Architecture; see [CONTRIBUTING.md](./CONTRIBUTING.md) |
| Expo Go | Use prebuild + dev build; this library is not in Expo Go |
Expand Down
4 changes: 4 additions & 0 deletions android/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
android:name="com.drumpicker.TestActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<activity
android:name="com.drumpicker.CollapsedTestActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.drumpicker

import android.os.Bundle
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity

/**
* Hosts the picker inside a container that starts with zero height, mirroring the
* `{isOpen ? <DrumPicker/> : null}` / collapsed-accordion mounting pattern.
*/
class CollapsedTestActivity : AppCompatActivity() {
lateinit var picker: DrumPickerView
private lateinit var container: FrameLayout

companion object {
const val ITEM_HEIGHT_DP = 44f
const val VISIBLE_ITEM_COUNT = 5

val ITEMS: List<String> =
listOf(
"Alpha",
"Beta",
"Gamma",
"Delta",
"Echo",
"Foxtrot",
"Golf",
"Hotel",
"India",
"Juliett",
)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
picker = DrumPickerView(this)
container = FrameLayout(this)
container.addView(
picker,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
setContentView(
container,
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0),
)
}

/** Grows the container to the picker's natural height, as an accordion would. */
fun expand() {
val density = resources.displayMetrics.density
val height = (ITEM_HEIGHT_DP * density + 0.5f).toInt() * VISIBLE_ITEM_COUNT
container.layoutParams = container.layoutParams.apply { this.height = height }
container.requestLayout()
}
}
Loading
Loading