Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active April 19, 2024 07:12
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save nandorojo/92e7301a49a8b9575bb24b3b1ddc19bf to your computer and use it in GitHub Desktop.
Save nandorojo/92e7301a49a8b9575bb24b3b1ddc19bf to your computer and use it in GitHub Desktop.
Make a horizontal `ScrollView` draggable with a mouse (`react-native-web`)

ScrollViews with react-native-web let mobile devices drag to scroll, and let you use your mac trackpad on desktop.

For horizontal scrollable content, such as carousels, I often find myself wanting to drag with my mouse.

This gist provides a simple hook that makes your ScrollView draggable with a mouse.

It hasn't been tested with pagingEnabled on FlatLists, but it should work for normal a FlatList on web.

Here's an example video.

Warning

This won't work with react@17 because it uses findNodeHandle. Maybe try it without that and see if it still works? I haven't tried yet.

import React, { ComponentProps } from 'react'
import { ScrollView } from 'react-native'
import { useDraggableScroll } from './use-draggable-scroll'
export const DraggableScrollView = React.forwardRef<
ScrollView,
ComponentProps<typeof ScrollView>
>(function DraggableScrollView(props, ref) {
const { refs } = useDraggableScroll<ScrollView>({
outerRef: ref,
cursor: 'grab', // optional, default
})
return <ScrollView ref={refs} horizontal {...props} />
})
import { RefObject, useEffect, useRef, useMemo } from 'react'
import { Platform, findNodeHandle } from 'react-native'
import type { ScrollView } from 'react-native'
import mergeRefs from 'react-merge-refs'
type Props<Scrollable extends ScrollView = ScrollView> = {
cursor?: string
outerRef?: RefObject<Scrollable>
}
export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
outerRef,
cursor = 'grab',
}: Props<Scrollable> = {}) {
const ref = useRef<Scrollable>(null)
useEffect(
function listeners() {
if (Platform.OS !== 'web' || !ref.current) {
return
}
const slider = (findNodeHandle(ref.current) as unknown) as HTMLDivElement
if (!slider) {
return
}
let isDragging = false
let startX = 0
let scrollLeft = 0
const mouseDown = (e: MouseEvent) => {
isDragging = true
startX = e.pageX - slider.offsetLeft
scrollLeft = slider.scrollLeft
slider.style.cursor = cursor
}
const mouseLeave = () => {
isDragging = false
}
const mouseUp = () => {
isDragging = false
slider.style.cursor = 'default'
}
const mouseMove = (e: MouseEvent) => {
if (!isDragging) return
e.preventDefault()
const x = e.pageX - slider.offsetLeft
const walk = x - startX
slider.scrollLeft = scrollLeft - walk
}
slider.addEventListener('mousedown', mouseDown)
slider.addEventListener('mouseleave', mouseLeave)
slider.addEventListener('mouseup', mouseUp)
slider.addEventListener('mousemove', mouseMove)
return () => {
slider.removeEventListener('mousedown', mouseDown)
slider.removeEventListener('mouseleave', mouseLeave)
slider.removeEventListener('mouseup', mouseUp)
slider.removeEventListener('mousemove', mouseMove)
}
},
[cursor]
)
const refs = useMemo(() => mergeRefs(outerRef ? [ref, outerRef] : [ref]), [
ref,
outerRef,
])
return {
refs,
}
}
@kopax-polyconseil
Copy link

kopax-polyconseil commented Sep 5, 2021

Hi and thanks for sharing, this produce a typing issue on outerRef in DraggableScrollView:

TS2322: Type '((instance: ScrollView | null) => void) | MutableRefObject<ScrollView | null> | null' is not assignable to type 'RefObject<ScrollView> | undefined'.   Type 'null' is not assignable to type 'RefObject<ScrollView> | undefined'.

I also tried to @ts-ignore, but then the whole typing for DraggableScrollView is wrong:

TS2322: Type '{ children: (Element | Element[])[]; horizontal: true; testID: string; showsHorizontalScrollIndicator: false; scrollEventThrottle: number; onScroll: ({ nativeEvent }: NativeSyntheticEvent<NativeScrollEvent>) => void; }' is not assignable to type 'IntrinsicAttributes & ScrollViewProps & RefAttributes<ScrollView>'.   Property 'children' does not exist on type 'IntrinsicAttributes & ScrollViewProps & RefAttributes<ScrollView>'.

Any clue why?

@mcrombie
Copy link

I am seeing the same typing issue as well as this error:
TS2559: Type '{ children: (false | Element)[]; }' has no properties in common with type 'IntrinsicAttributes & ScrollViewProps & RefAttributes '.

Can you post an example of how you applied this?

@testermumatstudio
Copy link

hi, thanks for sharing, how can i implement this in a flatlist

@testermumatstudio
Copy link

why everything I put inside DraggableScrollView modifies styles and cancels animations

@SkySails
Copy link

Thanks a lot for this! I tried using it but had a few issues that I believe to have solved:

  • The use of RefObject was causing type issues because of the use of React.forwardRef, replacing it with React.ForwardedRef got rid of them.
  • Scrolling would stop immediately once the cursor left the scrollable container, which is unlike the native behavior.
  • Having clickable elements inside the scrollable container was tricky, since click events would be fired directly after dragging (mousedown -> mouseup on the same element). This was unexpected/unwanted behavior in my case at least.

Here are the steps I took to solve the above (TL;DR):

Replacing RefObject with ForwardedRef

diff --git a/before.ts b/after.ts
index fc98ebf0..3ae0b28c 100644
--- a/before.ts
+++ b/after.ts
@@ -1,11 +1,11 @@
-import { RefObject, useEffect, useRef, useMemo } from 'react'
+import { ForwardedRef, useEffect, useRef, useMemo } from 'react'
 import { Platform, findNodeHandle } from 'react-native'
 import type { ScrollView } from 'react-native'
 import mergeRefs from 'react-merge-refs'
 
 type Props<Scrollable extends ScrollView = ScrollView> = {
   cursor?: string
-  outerRef?: RefObject<Scrollable>
+  outerRef?: ForwardedRef<Scrollable>
 }
 
 export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({

Allow cursor to move outside container once dragging has started

diff --git a/before.ts b/after.ts
index fc98ebf0..c00c6426 100644
--- a/before.ts
+++ b/after.ts
@@ -34,9 +34,6 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
 
         slider.style.cursor = cursor
       }
-      const mouseLeave = () => {
-        isDragging = false
-      }
 
       const mouseUp = () => {
         isDragging = false
@@ -52,15 +49,13 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
       }
 
       slider.addEventListener('mousedown', mouseDown)
-      slider.addEventListener('mouseleave', mouseLeave)
-      slider.addEventListener('mouseup', mouseUp)
-      slider.addEventListener('mousemove', mouseMove)
+      window.addEventListener('mouseup', mouseUp)
+      window.addEventListener('mousemove', mouseMove)
 
       return () => {
         slider.removeEventListener('mousedown', mouseDown)
-        slider.removeEventListener('mouseleave', mouseLeave)
-        slider.removeEventListener('mouseup', mouseUp)
-        slider.removeEventListener('mousemove', mouseMove)
+        window.removeEventListener('mouseup', mouseUp)
+        window.removeEventListener('mousemove', mouseMove)
       }
     },
     [cursor]

Prevent click events from firing after drag

Here, the goal was to somehow prevent the click event in the case where dragging was going on. This below was the most convenient solution I could come up with. With this change, we only transition into the isDragging = true state when the dragging movement starts, not when the mouse button is held down. This allows us to correctly dismiss the upcoming click event only when dragging has actually occurred, in this case for more than 3 pixels.

diff --git a/before.ts b/after.ts
index fc98ebf0..91041f2d 100644
--- a/before.ts
+++ b/after.ts
@@ -24,11 +24,12 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
         return
       }
       let isDragging = false
+      let isMouseDown = false
       let startX = 0
       let scrollLeft = 0
 
       const mouseDown = (e: MouseEvent) => {
-        isDragging = true
+        isMouseDown = true
         startX = e.pageX - slider.offsetLeft
         scrollLeft = slider.scrollLeft
 
@@ -39,14 +40,22 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
       }
 
       const mouseUp = () => {
+        if (isDragging) slider.addEventListener("click", (e) => e.stopPropagation(), { once: true })
+
+        isMouseDown = false
         isDragging = false
         slider.style.cursor = 'default'
       }
 
       const mouseMove = (e: MouseEvent) => {
-        if (!isDragging) return
-        e.preventDefault()
+        if (!isMouseDown) return
+
+        // Require n pixels momement before start of drag (3 in this case )
         const x = e.pageX - slider.offsetLeft
+        if (Math.abs(x - startX) < 3) return
+  
+        isDragging = true
+        e.preventDefault()
         const walk = x - startX
         slider.scrollLeft = scrollLeft - walk
       }

End result

Combined, the above diffs look like this:

import { useEffect, useRef, useMemo, ForwardedRef } from 'react'
import { Platform, findNodeHandle } from 'react-native'
import type { ScrollView } from 'react-native'
import mergeRefs from 'react-merge-refs'

type Props<Scrollable extends ScrollView = ScrollView> = {
  cursor?: string
  outerRef?: ForwardedRef<Scrollable>
}

export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
  outerRef,
  cursor = 'grab',
}: Props<Scrollable> = {}) {
  const ref = useRef<Scrollable>(null)

  useEffect(() => {
      if (Platform.OS !== 'web' || !ref.current) {
        return
      }
      const slider = (findNodeHandle(ref.current) as unknown) as HTMLDivElement
      if (!slider) {
        return
      }
      let isDragging = false
      let isMouseDown = false
      let startX = 0
      let scrollLeft = 0

      const mouseDown = (e: MouseEvent) => {
        isMouseDown = true
        startX = e.pageX - slider.offsetLeft
        scrollLeft = slider.scrollLeft

        slider.style.cursor = cursor
      }

      const mouseUp = () => {
        if (isDragging) slider.addEventListener("click", (e) => e.stopPropagation(), { once: true })
 
        isMouseDown = false
        isDragging = false
        slider.style.cursor = 'default'
      }

      const mouseMove = (e: MouseEvent) => {
        if (!isMouseDown) return
        
        // Require n pixels momement before start of drag (3 in this case )
        const x = e.pageX - slider.offsetLeft
        if (Math.abs(x - startX) < 3) return
        
        isDragging = true
        e.preventDefault()
        const walk = x - startX
        slider.scrollLeft = scrollLeft - walk
      }

      slider.addEventListener('mousedown', mouseDown)
      window.addEventListener('mouseup', mouseUp)
      window.addEventListener('mousemove', mouseMove)

      return () => {
        slider.removeEventListener('mousedown', mouseDown)
        window.removeEventListener('mouseup', mouseUp)
        window.removeEventListener('mousemove', mouseMove)
      }
    },
    [cursor]
  )

  const refs = useMemo(() => mergeRefs(outerRef ? [ref, outerRef] : [ref]), [
    ref,
    outerRef,
  ])

  return {
    refs,
  }
}

@SaxenaShiv
Copy link

SaxenaShiv commented Jun 18, 2023

Is the end result is in .ts file or I can also add this code in .js file?

@SkySails
Copy link

It is Typescript syntax and won't work in JS. But if you remove the types, all should be good!

@itsramiel
Copy link

Sweet 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment