Skip to content

Commit

Permalink
feat: Add swipe gestures (#200)
Browse files Browse the repository at this point in the history
  • Loading branch information
ccallendar authored Jul 29, 2024
1 parent 6f4845d commit a79a5f9
Show file tree
Hide file tree
Showing 9 changed files with 276 additions and 26 deletions.
26 changes: 20 additions & 6 deletions frontend/src/components/HorizontalScroller.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'

import { render } from '@/test-utils'
import { HorizontalScroller } from './HorizontalScroller'
Expand Down Expand Up @@ -40,25 +40,39 @@ describe('Test suite for HorizontalScroller', () => {
.mockImplementation(() => 300)

const { user } = render(
<div style={{ width: '100px', height: '20px' }} title="scroller">
<HorizontalScroller>
<div style={{ width: '100px', height: '20px' }}>
<HorizontalScroller title="scroller">
<div style={{ width: '300px', height: '20px' }}>Content</div>
</HorizontalScroller>
</div>,
)

screen.getByTitle('scroller')
const scroller = screen.getByTitle('scroller')
screen.getByText('Content')

const scrollRight = await screen.findByTitle('Scroll right')
expect(screen.queryByTitle('Scroll left')).not.toBeInTheDocument()

await user.click(scrollRight)

const scrollLeft = await screen.findByTitle('Scroll left')

await user.click(scrollLeft)

// Swipe left - scroll right
fireEvent.touchStart(scroller, {
changedTouches: [{ clientX: 50, clientY: 0 }],
})
fireEvent.touchEnd(scroller, {
changedTouches: [{ clientX: 10, clientY: 0 }],
})

// Swipe right - scroll left
fireEvent.touchStart(scroller, {
changedTouches: [{ clientX: 10, clientY: 0 }],
})
fireEvent.touchEnd(scroller, {
changedTouches: [{ clientX: 50, clientY: 0 }],
})

clientWidthMock.mockRestore()
scrollWidthMock.mockRestore()
})
Expand Down
51 changes: 37 additions & 14 deletions frontend/src/components/HorizontalScroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Button } from '@mui/material'
import { ChevronLeft, ChevronRight } from '@mui/icons-material'
import clsx from 'clsx'

import { SwipeDirection, useSwipe } from '@/hooks/useSwipe'

import './HorizontalScroller.css'

const DEFAULT_SCROLL_OFFSET = 100
Expand All @@ -18,30 +20,32 @@ interface Props {
/**
* When enabled, this component will horizontally scroll its children,
* showing a left and right scroll button when appropriate.
* It also supports swipe left/right gestures.
*/
export function HorizontalScroller({
children,
isEnabled = true,
...rest
}: Readonly<Props>) {
return isEnabled ? (
<HorizontalScrollerComponent {...rest}>
{children}
</HorizontalScrollerComponent>
) : (
children
)
}

function HorizontalScrollerComponent({
children,
className,
scrollOffset = DEFAULT_SCROLL_OFFSET,
...rest
}: Readonly<Props>) {
}: Readonly<Omit<Props, 'isEnabled'>>) {
const ref = useRef<HTMLDivElement | null>(null)
const [scrollLeftVisible, setScrollLeftVisible] = useState<boolean>(false)
const [scrollRightVisible, setScrollRightVisible] = useState<boolean>(false)

useEffect(() => {
if (isEnabled && ref.current) {
const { scrollLeft, scrollWidth, clientWidth } = ref.current
setScrollLeftVisible(scrollLeft > 0)
setScrollRightVisible(scrollWidth > clientWidth)
}
}, [isEnabled])

if (!isEnabled) {
return children
}

const doScroll = (offset: number) => {
if (ref.current) {
const { scrollLeft, scrollWidth, clientWidth } = ref.current
Expand All @@ -61,18 +65,37 @@ export function HorizontalScroller({
doScroll(-scrollOffset)
}
}

const onScrollRight = () => {
if (ref.current) {
doScroll(scrollOffset)
}
}

const swipeCallback = (direction: SwipeDirection) => {
if (direction === 'left') {
onScrollRight()
} else if (direction === 'right') {
onScrollLeft()
}
}

useSwipe(ref, swipeCallback)

useEffect(() => {
if (ref.current) {
const { scrollLeft, scrollWidth, clientWidth } = ref.current
setScrollLeftVisible(scrollLeft > 0)
setScrollRightVisible(scrollWidth > clientWidth)
}
}, [])

return (
<div className="horizontal-scroller-container">
<div
{...rest}
className={clsx('horizontal-scroller', className)}
ref={ref}
className={clsx('horizontal-scroller', className)}
>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { getMyLocation } from '@/utils/utils'

/**
* Uses the navigator geolocation to find the GPS location of the user.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition
* @see https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/permissions
* @returns position LatLngLiteral object
* @returns { position, accuracy } object
*/
export function useMyLocation(): MyLocationData {
const [data, setData] = useState<MyLocationData>({})
Expand Down
110 changes: 110 additions & 0 deletions frontend/src/hooks/useSwipe.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { useRef } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'

import { SwipeCallback, useSwipe } from './useSwipe'

describe('Test suite for useSwipe', () => {
function renderComponent(callback: SwipeCallback) {
const TestComponent = () => {
const ref = useRef<HTMLDivElement>(null)
useSwipe(ref, callback)
return (
<div ref={ref} style={{ height: '100%' }}>
Swipe
</div>
)
}

return render(<TestComponent />)
}

it('should test useSwipe with touch events', () => {
const callback = vi.fn()
renderComponent(callback)

const div = screen.getByText('Swipe')
expect(callback).toHaveBeenCalledTimes(0)

// Swipe left
fireEvent.touchStart(div, {
changedTouches: [{ clientX: 100, clientY: 0 }],
})
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 10, clientY: 0 }],
})
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('left')

// Swipe right
fireEvent.touchStart(div, {
changedTouches: [{ clientX: 10, clientY: 0 }],
})
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 100, clientY: 0 }],
})
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenCalledWith('right')

// Swipe down
fireEvent.touchStart(div, {
changedTouches: [{ clientX: 0, clientY: 10 }],
})
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 0, clientY: 100 }],
})
expect(callback).toHaveBeenCalledTimes(3)
expect(callback).toHaveBeenCalledWith('down')

// Swipe up
fireEvent.touchStart(div, {
changedTouches: [{ clientX: 0, clientY: 100 }],
})
fireEvent.touchEnd(div, {
changedTouches: [{ clientX: 0, clientY: 10 }],
})
expect(callback).toHaveBeenCalledTimes(4)
expect(callback).toHaveBeenCalledWith('up')
})

it('should test useSwipe with mouse events', () => {
const original = window.ontouchstart
delete window.ontouchstart

const callback = vi.fn()
renderComponent(callback)

const div = screen.getByText('Swipe')
expect(callback).toHaveBeenCalledTimes(0)

// Swipe left
fireEvent.mouseDown(div, { clientX: 100, clientY: 0 })
fireEvent.mouseUp(div, { clientX: 10, clientY: 0 })
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('left')

// Swipe right
fireEvent.mouseDown(div, { clientX: 10, clientY: 0 })
fireEvent.mouseUp(div, { clientX: 100, clientY: 0 })
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenCalledWith('right')

// Swipe down
fireEvent.mouseDown(div, { clientX: 0, clientY: 10 })
fireEvent.mouseUp(div, { clientX: 0, clientY: 100 })
expect(callback).toHaveBeenCalledTimes(3)
expect(callback).toHaveBeenCalledWith('down')

// Swipe up
fireEvent.mouseDown(div, { clientX: 0, clientY: 100 })
fireEvent.mouseUp(div, { clientX: 0, clientY: 10 })
expect(callback).toHaveBeenCalledTimes(4)
expect(callback).toHaveBeenCalledWith('up')

// Too small distance
fireEvent.mouseDown(div, { clientX: 0, clientY: 0 })
fireEvent.mouseUp(div, { clientX: 10, clientY: 10 })
expect(callback).toHaveBeenCalledTimes(4)

window.ontouchstart = original
})
})
78 changes: 78 additions & 0 deletions frontend/src/hooks/useSwipe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { RefObject, useEffect, useRef } from 'react'

export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
export type SwipeCallback = (swipeDirection: SwipeDirection) => void

export function useSwipe(
ref: RefObject<HTMLElement | null | undefined>,
callback: SwipeCallback,
minDistance = 30,
) {
const callbackRef = useRef<SwipeCallback>(callback)
callbackRef.current = callback
const minDistanceRef = useRef<number>(minDistance)
minDistanceRef.current = minDistance
const startRef = useRef<[number, number]>([0, 0])

function eventEnd(endX: number, endY: number) {
const [startX, startY] = startRef.current
const diffX = endX - startX
const diffY = endY - startY
if (Math.max(Math.abs(diffX), Math.abs(diffY)) >= minDistanceRef.current) {
let direction: SwipeDirection
if (Math.abs(diffX) > Math.abs(diffY)) {
direction = diffX < 0 ? 'left' : 'right'
} else {
direction = diffY < 0 ? 'up' : 'down'
}
callbackRef.current(direction)
}
}

function onTouchStart(e: TouchEvent) {
const [touchSrc] = e.changedTouches
if (touchSrc) {
startRef.current = [touchSrc.clientX, touchSrc.clientY]
}
}

function onTouchEnd(e: TouchEvent) {
const [touchSrc] = e.changedTouches
if (touchSrc) {
eventEnd(touchSrc.clientX, touchSrc.clientY)
}
}

function onMouseDown(e: MouseEvent) {
startRef.current = [e.clientX, e.clientY]
}

function onMouseUp(e: MouseEvent) {
eventEnd(e.clientX, e.clientY)
}

useEffect(() => {
const el = ref.current
const isTouch = 'ontouchstart' in window
if (el) {
if (isTouch) {
el.addEventListener('touchstart', onTouchStart)
el.addEventListener('touchend', onTouchEnd)
} else {
el.addEventListener('mousedown', onMouseDown)
el.addEventListener('mouseup', onMouseUp)
}
}
return () => {
if (el) {
if (isTouch) {
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchend', onTouchEnd)
} else {
el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('mouseup', onMouseUp)
}
}
}
}, [ref])
}
2 changes: 1 addition & 1 deletion frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ button.MuiButton-containedPrimary {
color: white;
}

button.MuiButton-containedPrimary:hover {
button.MuiButton-containedPrimary:not(.Mui-disabled):hover {
background-color: #385a8a;
color: white;
}
Loading

0 comments on commit a79a5f9

Please sign in to comment.