Solving the Clashing of Scrolls đź’Ą

In many web applications, especially those optimized for mobile, developers often build custom components that mimic native user interface elements. A common example is the date picker, which typically features scrollable columns for selecting the month, day, and year.

In this case, the implementation relies on react-mobile-picker, an open-source library designed specifically for creating mobile-friendly, scrollable pickers in React. The library provides core building blocks Picker, Picker.Column, and Picker.Item that make it straightforward to build a carousel-like interface for value selection. This approach closely mirrors the native iOS and Android picker experiences, giving users an interface that feels familiar and intuitive.

However, a challenge arises on iOS devices. When users scroll within the custom picker, the browser sometimes misinterprets the gesture and scrolls the entire page alongside the picker. This introduces a significant usability issue because it:

  • Breaks the user’s flow: The page jumps or moves unexpectedly, interrupting the user’s action.
  • Hinders precision: It makes it difficult for the user to select the desired date accurately.
  • Feels buggy: The application appears unresponsive or poorly implemented.

This conflict arises because the browser’s default scroll behavior, which is optimized for scrolling the entire page, takes precedence over the component’s internal scrolling logic. The react-mobile-picker library, while great for creating the visual component, doesn’t inherently solve this deep-seated browser behavior.


The Solution: A Multi-Layered Approach

The fixed code tackles this problem with a robust, multi-layered solution that targets both the component and the parent elements. Here’s a breakdown of the key changes:

1. DOM Element References and Event Listeners

The most critical part of the fix is the use of a ref and the useEffect hook.

const pickerRef = useRef(null)

useEffect(() => {
    const pickerElement = pickerRef.current
    if (!pickerElement) return

    // ... event listeners ...

    return () => {
        // ... cleanup listeners ...
    }
}, [])

return (
    <div ref={pickerRef}>
        <Picker>...</Picker>
    </div>
)
  • useRef: This hook creates a persistent reference to the div element that wraps the Picker component.
  • useEffect: This hook runs after the component is rendered, giving us access to the actual DOM element via the pickerRef. Inside this hook, we add and remove event listeners. This is the correct way to handle DOM side effects in React.

2. Preventing Default Scroll Behavior

Within the useEffect hook, the code adds event listeners for various scroll-related events like touchstart, touchmove, and wheel.

const preventScroll = (e) => {
    e.preventDefault()
    e.stopPropagation()
    return false
}

const events = ['touchstart', 'touchmove', 'touchend', 'wheel', 'scroll']
events.forEach(eventType => {
    pickerElement.addEventListener(eventType, preventScroll, { passive: false })
    document.addEventListener(eventType, preventScrollPropagation, { passive: false })
})
  • e.preventDefault(): This is the core of the fix. It tells the browser to not perform its default action for that event. In this case, it stops the page from scrolling.
  • e.stopPropagation(): This prevents the event from “bubbling up” to parent elements (like the document body), which might also have their own scroll behaviors.
  • { passive: false }: This is crucial for touch events. By default, many browsers use passive event listeners for performance reasons, meaning e.preventDefault() is ignored. Setting passive: false explicitly tells the browser to let us handle the event and prevent its default action. .

3. Inline Styles and CSS Properties

The fixed code also adds specific CSS properties to both the picker and its wrapper.

/* CSS for the picker and its wrapper */
.picker {
    -webkit-overflow-scrolling: touch; /* Old, but still used sometimes */
    overscroll-behavior: contain;
    touch-action: pan-y;
}
.date-picker-wrapper {
    touch-action: none;
    -webkit-overflow-scrolling: auto;
}
  • touch-action: none: This is a modern CSS property that is a powerful way to control how a browser handles touch input. Setting it to none on the wrapper tells the browser to not perform any default actions (like panning or zooming) based on touch gestures within that element.
  • overscroll-behavior: contain: This property tells the browser what to do when the user scrolls past the end of the scroll container. contain prevents the scroll from “spilling over” and affecting the parent container.
  • -webkit-overflow-scrolling: This is an older, iOS-specific property. While touch was initially intended to enable momentum scrolling, setting it to auto or using overscroll-behavior can sometimes help in preventing unexpected scroll behaviors.

4. The div Wrapper and Inline Event Handlers

The new code also introduces a wrapper div with inline event handlers.

<div 
    className="date-picker-wrapper"
    onTouchStart={(e) => e.preventDefault()}
    onTouchMove={(e) => e.preventDefault()}
    onWheel={(e) => e.stopPropagation()}
>

This provides a quick and direct way to prevent the default touch and wheel actions on the component, acting as a final line of defense against unwanted scrolling.


Why This Matters for User Experience :technologist:

By implementing these fixes, the date picker now functions as a standalone, self-contained component. When a user touches or scrolls the picker, only the picker’s columns move. The main page remains perfectly still. This creates a more intuitive and “native” feel, improving the application’s overall quality and professionalism. It shows attention to detail and a commitment to providing a smooth, bug-free experience for users on all platforms, especially iOS where this issue is most prevalent.

4 Likes