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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ const [index, setIndex] = useState(1);
/>;
```

### Locking the picker while a form submits

```tsx
const [saving, setSaving] = useState(false);

<View style={{ opacity: saving ? 0.4 : 1 }}>
<DrumPicker items={items} disabled={saving} onChange={handleChange} />
</View>;
```

`disabled` blocks drags and taps, and swallows touches so nothing behind the picker reacts either. Programmatic movement is deliberately left working — `selectedIndex` and the `scrollToIndex` / `scrollToValue` ref methods still scroll the wheel, so you can reset or correct a locked form. Appearance is up to you: the prop changes behaviour only, so wrap it as above if it should also look inert.

### `onChange` and expensive side effects

![Debounce demo](./func-gif/Debounce.gif)
Expand Down Expand Up @@ -624,6 +636,7 @@ In addition to all `DrumPicker` props (on the wrapped instance):
| `selectionIndicatorColor` | `string` | `#D1D1D6` | Line color |
| `selectionIndicatorHeight` | `number` | `1` | Line thickness (dp) |
| `hapticFeedback` | `boolean` | `false` | Light haptic on snap (Android + iOS) |
| `disabled` | `boolean` | `false` | Block user drags and taps. Programmatic scrolling still works. |
| `circular` | `boolean` | `false` | Enable infinite loop scroll. Wraps last->first and first->last. |
| `enableScrollByTapOnItem` | `boolean` | `false` | Tap a visible row to scroll it to center (Android + iOS) |
| `onChange` | `function` | — | `nativeEvent: { index, value }` |
Expand Down Expand Up @@ -658,6 +671,7 @@ In addition to all `DrumPicker` props (on the wrapped instance):
| `itemBackgroundColor` | `string` | `transparent` | Passed to each column |
| `containerBackgroundColor` | `string` | `transparent` | Passed to each column |
| `hapticFeedback` | `boolean` | `false` | Passed to each column |
| `disabled` | `boolean` | `false` | Passed to each column |
| `enableScrollByTapOnItem` | `boolean` | `false` | Passed to each column |
| `style` | `ViewStyle` | — | Row container |
| `columnStyle` | `ViewStyle` | — | All columns |
Expand Down
42 changes: 42 additions & 0 deletions android/src/androidTest/java/com/drumpicker/DrumPickerTapTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,46 @@ class DrumPickerTapTest {
assertEquals(2, readSelectedIndex(scenario))
}
}

@Test
fun tapIgnoredWhileInteractionDisabled() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
ActivityScenario.launch(TestActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
// Tap-to-scroll is on: only `disabled` should be keeping the wheel still.
activity.picker.setEnableScrollByTapOnItemProp(true)
activity.picker.setDisabledProp(true)
activity.picker.setSelectedIndexProp(2)
activity.picker.requestLayout()
}
instrumentation.waitForIdleSync()
waitForSelectedIndex(scenario, 2)

scenario.onActivity { activity ->
activity.picker.testingPerformItemTap(0)
}
instrumentation.waitForIdleSync()
assertEquals(2, readSelectedIndex(scenario))
}
}

@Test
fun programmaticSelectionStillWorksWhileInteractionDisabled() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
ActivityScenario.launch(TestActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
activity.picker.setDisabledProp(true)
activity.picker.setSelectedIndexProp(1)
activity.picker.requestLayout()
}
instrumentation.waitForIdleSync()
waitForSelectedIndex(scenario, 1)

scenario.onActivity { activity ->
activity.picker.setSelectedIndexProp(3)
}
instrumentation.waitForIdleSync()
waitForSelectedIndex(scenario, 3)
}
}
}
34 changes: 33 additions & 1 deletion android/src/main/java/com/drumpicker/DrumPickerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ class DrumPickerView @JvmOverloads constructor(
private var nativeGestureNotified = false
private var lastDownEvent: MotionEvent? = null

/**
* Blocks touch-driven scrolling. Programmatic centering stays available, so a controlled
* parent can still move the wheel while the user cannot.
*/
private var isInteractionDisabled = false

private val styleUpdateRunnable =
Runnable {
styleUpdatePosted = false
Expand Down Expand Up @@ -285,6 +291,19 @@ class DrumPickerView @JvmOverloads constructor(
hapticFeedback = value as? Boolean ?: false
}

fun setDisabledProp(value: Any?) {
val disabled = toBoolean(value, false)
if (isInteractionDisabled == disabled) {
return
}
isInteractionDisabled = disabled
if (disabled) {
// A drag may be in flight when the prop flips; drop it rather than letting it settle
// somewhere the user can no longer correct.
recyclerView.stopScroll()
}
}

fun setEnableScrollByTapOnItemProp(value: Any?) {
enableScrollByTapOnItem = toBoolean(value, false)
adapter.enableScrollByTapOnItem = enableScrollByTapOnItem
Expand All @@ -304,7 +323,12 @@ class DrumPickerView @JvmOverloads constructor(
}

private fun scrollToPositionFromTap(position: Int) {
if (!enableScrollByTapOnItem || !isLifecycleActive() || items.isEmpty()) {
if (
isInteractionDisabled ||
!enableScrollByTapOnItem ||
!isLifecycleActive() ||
items.isEmpty()
) {
return
}
val clamped = position.coerceIn(0, items.size - 1)
Expand Down Expand Up @@ -414,6 +438,11 @@ class DrumPickerView @JvmOverloads constructor(
}

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
// Intercept before the RecyclerView sees anything, so a disabled wheel neither scrolls nor
// leaks the gesture to whatever sits behind it.
if (isInteractionDisabled) {
return true
}
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
nativeGestureNotified = false
lastDownEvent?.recycle()
Expand All @@ -422,6 +451,9 @@ class DrumPickerView @JvmOverloads constructor(
return super.onInterceptTouchEvent(ev)
}

override fun onTouchEvent(ev: MotionEvent): Boolean =
if (isInteractionDisabled) true else super.onTouchEvent(ev)

/**
* Tells React Native that a native scroll has taken over the touch sequence.
*
Expand Down
5 changes: 5 additions & 0 deletions android/src/main/java/com/drumpicker/DrumPickerViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class DrumPickerViewManager :
"containerBackgroundColor" -> view.setContainerBackgroundColorProp(value)
"itemBackgroundColor" -> view.setItemBackgroundColorProp(value)
"hapticFeedback" -> view.setHapticFeedbackProp(value)
"disabled" -> view.setDisabledProp(value)
"circular" -> view.setCircularProp(value)
"enableScrollByTapOnItem" -> view.setEnableScrollByTapOnItemProp(value)
"onValueChangingEnabled" -> view.setOnValueChangingEnabledProp(value)
Expand Down Expand Up @@ -132,6 +133,10 @@ class DrumPickerViewManager :
view?.setHapticFeedbackProp(value)
}

override fun setDisabled(view: DrumPickerView?, value: Boolean) {
view?.setDisabledProp(value)
}

override fun setCircular(view: DrumPickerView?, value: Boolean) {
view?.setCircularProp(value)
}
Expand Down
16 changes: 16 additions & 0 deletions ios/DrumPickerTests/DrumPickerWheelViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ final class DrumPickerWheelViewTests: XCTestCase {

XCTAssertEqual(view.selectedIndexForTesting(), 2)
}

func testSetDisabledTogglesUserInteraction() {
view.setDisabled(true)
XCTAssertFalse(picker.isUserInteractionEnabled)

view.setDisabled(false)
XCTAssertTrue(picker.isUserInteractionEnabled)
}

func testProgrammaticSelectionStillWorksWhileDisabled() {
view.setDisabled(true)
view.setSelectedIndex(3, animated: false)

XCTAssertEqual(view.selectedIndexForTesting(), 3)
XCTAssertEqual(picker.selectedRow(inComponent: 0), 3)
}
}

private final class MockWheelDelegate: NSObject, DrumPickerWheelViewDelegate {
Expand Down
4 changes: 4 additions & 0 deletions ios/DrumPickerView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
[_wheelView setEnableScrollByTapOnItem:newViewProps.enableScrollByTapOnItem];
}

if (oldViewProps.disabled != newViewProps.disabled) {
[_wheelView setDisabled:newViewProps.disabled];
}

if (oldViewProps.circular != newViewProps.circular) {
_wheelView.isCircular = newViewProps.circular;
}
Expand Down
6 changes: 6 additions & 0 deletions ios/DrumPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@ public final class DrumPickerWheelView: UIView, UIPickerViewDataSource, UIPicker
enableScrollByTapOnItem = value
}

/// Blocks touch-driven scrolling. `setSelectedIndex(_:animated:)` keeps working, so a
/// controlled parent can still move the wheel while the user cannot.
@objc public func setDisabled(_ value: Bool) {
picker.isUserInteractionEnabled = !value
}

@objc public func setOnValueChangingEnabled(_ value: Bool) {
onValueChangingEnabled = value
if value {
Expand Down
4 changes: 4 additions & 0 deletions src/DateDrumPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export type DateDrumPickerProps = {
itemBackgroundColor?: string;
containerBackgroundColor?: string;
hapticFeedback?: boolean;
/** Block user interaction on every column. Programmatic scrolling still works. */
disabled?: boolean;
enableScrollByTapOnItem?: boolean;
style?: StyleProp<ViewStyle>;
columnStyle?: StyleProp<ViewStyle>;
Expand Down Expand Up @@ -160,6 +162,7 @@ export const DateDrumPicker = forwardRef<
itemBackgroundColor = 'transparent',
containerBackgroundColor = 'transparent',
hapticFeedback = false,
disabled = false,
enableScrollByTapOnItem = false,
style,
columnStyle,
Expand Down Expand Up @@ -429,6 +432,7 @@ export const DateDrumPicker = forwardRef<
itemBackgroundColor,
containerBackgroundColor,
hapticFeedback,
disabled,
enableScrollByTapOnItem,
};

Expand Down
6 changes: 6 additions & 0 deletions src/DrumPicker.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const DEFAULTS = {
itemBackgroundColor: 'transparent',
containerBackgroundColor: 'transparent',
hapticFeedback: false,
disabled: false,
enableScrollByTapOnItem: false,
} as const;

Expand Down Expand Up @@ -73,6 +74,7 @@ export const DrumPickerNativeBase = forwardRef<
itemBackgroundColor = DEFAULTS.itemBackgroundColor,
containerBackgroundColor = DEFAULTS.containerBackgroundColor,
hapticFeedback = DEFAULTS.hapticFeedback,
disabled = DEFAULTS.disabled,
enableScrollByTapOnItem = DEFAULTS.enableScrollByTapOnItem,
onValueChanging,
onChange,
Expand Down Expand Up @@ -375,6 +377,7 @@ export const DrumPickerNativeBase = forwardRef<
itemBackgroundColor={itemBackgroundColor}
containerBackgroundColor={containerBackgroundColor}
hapticFeedback={hapticFeedback}
disabled={disabled}
enableScrollByTapOnItem={enableScrollByTapOnItem}
onValueChangingEnabled={shouldEmitValueChanging}
onValueChanging={
Expand Down Expand Up @@ -408,6 +411,7 @@ const DrumPickerImpl = forwardRef<DrumPickerRef, DrumPickerProps<any>>(
itemBackgroundColor = DEFAULTS.itemBackgroundColor,
containerBackgroundColor = DEFAULTS.containerBackgroundColor,
hapticFeedback = DEFAULTS.hapticFeedback,
disabled = DEFAULTS.disabled,
enableScrollByTapOnItem = DEFAULTS.enableScrollByTapOnItem,
onValueChanging,
onChange,
Expand Down Expand Up @@ -439,6 +443,7 @@ const DrumPickerImpl = forwardRef<DrumPickerRef, DrumPickerProps<any>>(
itemBackgroundColor={itemBackgroundColor}
containerBackgroundColor={containerBackgroundColor}
hapticFeedback={hapticFeedback}
disabled={disabled}
enableScrollByTapOnItem={enableScrollByTapOnItem}
onValueChanging={onValueChanging}
onChange={onChange}
Expand Down Expand Up @@ -470,6 +475,7 @@ const DrumPickerImpl = forwardRef<DrumPickerRef, DrumPickerProps<any>>(
itemBackgroundColor={itemBackgroundColor}
containerBackgroundColor={containerBackgroundColor}
hapticFeedback={hapticFeedback}
disabled={disabled}
enableScrollByTapOnItem={enableScrollByTapOnItem}
onValueChanging={onValueChanging}
onChange={onChange}
Expand Down
1 change: 1 addition & 0 deletions src/DrumPickerViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface NativeProps extends ViewProps {
containerBackgroundColor?: ColorValue;
itemBackgroundColor?: ColorValue;
hapticFeedback?: CodegenTypes.WithDefault<boolean, false>;
disabled?: CodegenTypes.WithDefault<boolean, false>;
enableScrollByTapOnItem?: CodegenTypes.WithDefault<boolean, false>;
onValueChangingEnabled?: CodegenTypes.WithDefault<boolean, false>;
onValueChange?: CodegenTypes.DirectEventHandler<DrumPickerChangeEventPayload>;
Expand Down
1 change: 1 addition & 0 deletions src/__mocks__/DrumPickerViewNativeComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type MockProps = ViewProps & {
circular?: boolean;
scrollAnimated?: boolean;
hapticFeedback?: boolean;
disabled?: boolean;
enableScrollByTapOnItem?: boolean;
onValueChangingEnabled?: boolean;
onValueChange?: (event: {
Expand Down
37 changes: 37 additions & 0 deletions src/__tests__/DrumPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render } from '@testing-library/react-native';
import React from 'react';
import { Text } from 'react-native';
import {
fireNativeDrumPickerChange,
getLatestNativeDrumPickerProps,
Expand Down Expand Up @@ -100,3 +101,39 @@ describe('enableScrollByTapOnItem', () => {
);
});
});

describe('disabled', () => {
beforeEach(() => {
jest.clearAllMocks();
resetNativeDrumPickerMocks();
});

it('passes disabled=false by default', () => {
render(<DrumPicker items={['A', 'B', 'C']} />);
expect(getLatestNativeDrumPickerProps()?.disabled).toBe(false);
});

it('passes disabled=true when set', () => {
render(<DrumPicker items={['A', 'B', 'C']} disabled />);
expect(getLatestNativeDrumPickerProps()?.disabled).toBe(true);
});

it('forwards disabled through the renderItem path', () => {
render(
<DrumPicker
items={['A', 'B', 'C']}
disabled
renderItem={({ label }) => <Text>{label}</Text>}
/>
);
expect(getLatestNativeDrumPickerProps()?.disabled).toBe(true);
});

it('still follows a controlled selectedIndex while disabled', () => {
const { rerender } = render(
<DrumPicker items={['A', 'B', 'C']} selectedIndex={0} disabled />
);
rerender(<DrumPicker items={['A', 'B', 'C']} selectedIndex={2} disabled />);
expect(getLatestNativeDrumPickerProps()?.selectedIndex).toBe(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ exports[`DateDrumPicker snapshots matches day-month-year mode 1`] = `
circular={false}
collapsable={false}
containerBackgroundColor="transparent"
disabled={false}
enableScrollByTapOnItem={false}
hapticFeedback={false}
itemBackgroundColor="transparent"
Expand Down Expand Up @@ -86,6 +87,7 @@ exports[`DateDrumPicker snapshots matches day-month-year mode 1`] = `
circular={false}
collapsable={false}
containerBackgroundColor="transparent"
disabled={false}
enableScrollByTapOnItem={false}
hapticFeedback={false}
itemBackgroundColor="transparent"
Expand Down Expand Up @@ -135,6 +137,7 @@ exports[`DateDrumPicker snapshots matches day-month-year mode 1`] = `
circular={false}
collapsable={false}
containerBackgroundColor="transparent"
disabled={false}
enableScrollByTapOnItem={false}
hapticFeedback={false}
itemBackgroundColor="transparent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ exports[`DrumPicker snapshots matches default render 1`] = `
circular={false}
collapsable={false}
containerBackgroundColor="transparent"
disabled={false}
enableScrollByTapOnItem={false}
hapticFeedback={false}
itemBackgroundColor="transparent"
Expand Down
Loading
Loading