diff --git a/README.md b/README.md index 9faffb5..0941ba3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,18 @@ const [index, setIndex] = useState(1); />; ``` +### Locking the picker while a form submits + +```tsx +const [saving, setSaving] = useState(false); + + + +; +``` + +`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) @@ -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 }` | @@ -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 | diff --git a/android/src/androidTest/java/com/drumpicker/DrumPickerTapTest.kt b/android/src/androidTest/java/com/drumpicker/DrumPickerTapTest.kt index b199b60..72a9e95 100644 --- a/android/src/androidTest/java/com/drumpicker/DrumPickerTapTest.kt +++ b/android/src/androidTest/java/com/drumpicker/DrumPickerTapTest.kt @@ -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) + } + } } diff --git a/android/src/main/java/com/drumpicker/DrumPickerView.kt b/android/src/main/java/com/drumpicker/DrumPickerView.kt index 09d7e60..4050d81 100644 --- a/android/src/main/java/com/drumpicker/DrumPickerView.kt +++ b/android/src/main/java/com/drumpicker/DrumPickerView.kt @@ -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 @@ -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 @@ -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) @@ -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() @@ -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. * diff --git a/android/src/main/java/com/drumpicker/DrumPickerViewManager.kt b/android/src/main/java/com/drumpicker/DrumPickerViewManager.kt index 19b0dd9..1e6433e 100644 --- a/android/src/main/java/com/drumpicker/DrumPickerViewManager.kt +++ b/android/src/main/java/com/drumpicker/DrumPickerViewManager.kt @@ -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) @@ -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) } diff --git a/ios/DrumPickerTests/DrumPickerWheelViewTests.swift b/ios/DrumPickerTests/DrumPickerWheelViewTests.swift index 0dc6469..ed4246b 100644 --- a/ios/DrumPickerTests/DrumPickerWheelViewTests.swift +++ b/ios/DrumPickerTests/DrumPickerWheelViewTests.swift @@ -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 { diff --git a/ios/DrumPickerView.mm b/ios/DrumPickerView.mm index 2e64258..4764723 100644 --- a/ios/DrumPickerView.mm +++ b/ios/DrumPickerView.mm @@ -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; } diff --git a/ios/DrumPickerView.swift b/ios/DrumPickerView.swift index 50c14ee..c412c16 100644 --- a/ios/DrumPickerView.swift +++ b/ios/DrumPickerView.swift @@ -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 { diff --git a/src/DateDrumPicker.tsx b/src/DateDrumPicker.tsx index 4b60b7d..9d42a29 100644 --- a/src/DateDrumPicker.tsx +++ b/src/DateDrumPicker.tsx @@ -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; columnStyle?: StyleProp; @@ -160,6 +162,7 @@ export const DateDrumPicker = forwardRef< itemBackgroundColor = 'transparent', containerBackgroundColor = 'transparent', hapticFeedback = false, + disabled = false, enableScrollByTapOnItem = false, style, columnStyle, @@ -429,6 +432,7 @@ export const DateDrumPicker = forwardRef< itemBackgroundColor, containerBackgroundColor, hapticFeedback, + disabled, enableScrollByTapOnItem, }; diff --git a/src/DrumPicker.native.tsx b/src/DrumPicker.native.tsx index 4706e5c..86cf909 100644 --- a/src/DrumPicker.native.tsx +++ b/src/DrumPicker.native.tsx @@ -33,6 +33,7 @@ const DEFAULTS = { itemBackgroundColor: 'transparent', containerBackgroundColor: 'transparent', hapticFeedback: false, + disabled: false, enableScrollByTapOnItem: false, } as const; @@ -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, @@ -375,6 +377,7 @@ export const DrumPickerNativeBase = forwardRef< itemBackgroundColor={itemBackgroundColor} containerBackgroundColor={containerBackgroundColor} hapticFeedback={hapticFeedback} + disabled={disabled} enableScrollByTapOnItem={enableScrollByTapOnItem} onValueChangingEnabled={shouldEmitValueChanging} onValueChanging={ @@ -408,6 +411,7 @@ const DrumPickerImpl = forwardRef>( itemBackgroundColor = DEFAULTS.itemBackgroundColor, containerBackgroundColor = DEFAULTS.containerBackgroundColor, hapticFeedback = DEFAULTS.hapticFeedback, + disabled = DEFAULTS.disabled, enableScrollByTapOnItem = DEFAULTS.enableScrollByTapOnItem, onValueChanging, onChange, @@ -439,6 +443,7 @@ const DrumPickerImpl = forwardRef>( itemBackgroundColor={itemBackgroundColor} containerBackgroundColor={containerBackgroundColor} hapticFeedback={hapticFeedback} + disabled={disabled} enableScrollByTapOnItem={enableScrollByTapOnItem} onValueChanging={onValueChanging} onChange={onChange} @@ -470,6 +475,7 @@ const DrumPickerImpl = forwardRef>( itemBackgroundColor={itemBackgroundColor} containerBackgroundColor={containerBackgroundColor} hapticFeedback={hapticFeedback} + disabled={disabled} enableScrollByTapOnItem={enableScrollByTapOnItem} onValueChanging={onValueChanging} onChange={onChange} diff --git a/src/DrumPickerViewNativeComponent.ts b/src/DrumPickerViewNativeComponent.ts index bd14c20..5ad1e63 100644 --- a/src/DrumPickerViewNativeComponent.ts +++ b/src/DrumPickerViewNativeComponent.ts @@ -28,6 +28,7 @@ interface NativeProps extends ViewProps { containerBackgroundColor?: ColorValue; itemBackgroundColor?: ColorValue; hapticFeedback?: CodegenTypes.WithDefault; + disabled?: CodegenTypes.WithDefault; enableScrollByTapOnItem?: CodegenTypes.WithDefault; onValueChangingEnabled?: CodegenTypes.WithDefault; onValueChange?: CodegenTypes.DirectEventHandler; diff --git a/src/__mocks__/DrumPickerViewNativeComponent.tsx b/src/__mocks__/DrumPickerViewNativeComponent.tsx index d92fec1..9997e28 100644 --- a/src/__mocks__/DrumPickerViewNativeComponent.tsx +++ b/src/__mocks__/DrumPickerViewNativeComponent.tsx @@ -13,6 +13,7 @@ type MockProps = ViewProps & { circular?: boolean; scrollAnimated?: boolean; hapticFeedback?: boolean; + disabled?: boolean; enableScrollByTapOnItem?: boolean; onValueChangingEnabled?: boolean; onValueChange?: (event: { diff --git a/src/__tests__/DrumPicker.test.tsx b/src/__tests__/DrumPicker.test.tsx index 5fed97d..564ff3f 100644 --- a/src/__tests__/DrumPicker.test.tsx +++ b/src/__tests__/DrumPicker.test.tsx @@ -1,5 +1,6 @@ import { render } from '@testing-library/react-native'; import React from 'react'; +import { Text } from 'react-native'; import { fireNativeDrumPickerChange, getLatestNativeDrumPickerProps, @@ -100,3 +101,39 @@ describe('enableScrollByTapOnItem', () => { ); }); }); + +describe('disabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetNativeDrumPickerMocks(); + }); + + it('passes disabled=false by default', () => { + render(); + expect(getLatestNativeDrumPickerProps()?.disabled).toBe(false); + }); + + it('passes disabled=true when set', () => { + render(); + expect(getLatestNativeDrumPickerProps()?.disabled).toBe(true); + }); + + it('forwards disabled through the renderItem path', () => { + render( + {label}} + /> + ); + expect(getLatestNativeDrumPickerProps()?.disabled).toBe(true); + }); + + it('still follows a controlled selectedIndex while disabled', () => { + const { rerender } = render( + + ); + rerender(); + expect(getLatestNativeDrumPickerProps()?.selectedIndex).toBe(2); + }); +}); diff --git a/src/__tests__/snapshots/__snapshots__/DateDrumPicker.snapshot.test.tsx.snap b/src/__tests__/snapshots/__snapshots__/DateDrumPicker.snapshot.test.tsx.snap index 6580469..fb75787 100644 --- a/src/__tests__/snapshots/__snapshots__/DateDrumPicker.snapshot.test.tsx.snap +++ b/src/__tests__/snapshots/__snapshots__/DateDrumPicker.snapshot.test.tsx.snap @@ -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" @@ -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" @@ -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" diff --git a/src/__tests__/snapshots/__snapshots__/DrumPicker.snapshot.test.tsx.snap b/src/__tests__/snapshots/__snapshots__/DrumPicker.snapshot.test.tsx.snap index ead6454..2c3fc03 100644 --- a/src/__tests__/snapshots/__snapshots__/DrumPicker.snapshot.test.tsx.snap +++ b/src/__tests__/snapshots/__snapshots__/DrumPicker.snapshot.test.tsx.snap @@ -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" diff --git a/src/types.ts b/src/types.ts index d979812..45f8674 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,6 +142,20 @@ export type DrumPickerProps = { itemBackgroundColor?: string; containerBackgroundColor?: string; hapticFeedback?: boolean; + /** + * Block user interaction. The wheel stops responding to drags and taps, + * and swallows touches instead of passing them to views underneath. + * + * Scrolling driven from your own code is unaffected: `selectedIndex` + * updates and the `scrollToIndex` / `scrollToValue` ref methods still work, + * which is what you usually want while a form is submitting. + * + * Appearance is left to you — combine with `textColor` or a wrapping + * `` if the picker should also look inert. + * + * @default false + */ + disabled?: boolean; /** * When true, tapping an item above or below the center * scrolls the picker to that item.