Skip to content

Commit

Permalink
feat: add pan gesture utils
Browse files Browse the repository at this point in the history
  • Loading branch information
segunadebayo committed Apr 16, 2021
1 parent 33cd7e4 commit 59ea894
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 13 deletions.
11 changes: 11 additions & 0 deletions .changeset/hip-guests-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@chakra-ui/utils": minor
---

- Add pan session class to handle pan gestures. This is used in the slider logic
and sharable with vue library.

- Perf: Throttle pan move events to once per frame which improves the slider's
`onChange` call performance.

- Update types for internal pointer event
1 change: 1 addition & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@types/lodash.mergewith": "4.6.6",
"css-box-model": "1.2.1",
"framesync": "5.3.0",
"lodash.mergewith": "4.6.2"
}
}
24 changes: 23 additions & 1 deletion packages/utils/src/function.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable no-nested-ternary */
import { isFunction, __DEV__, __TEST__ } from "./assertion"
import { isFunction, isNumber, __DEV__, __TEST__ } from "./assertion"
import { AnyFunction, FunctionArguments } from "./types"

export function runIfFn<T, U>(
Expand Down Expand Up @@ -76,3 +76,25 @@ export const scheduleMicrotask = __TEST__
: typeof queueMicrotask === "function"
? queueMicrotask
: promiseMicrotask

const combineFunctions = (a: Function, b: Function) => (v: any) => b(a(v))
export const pipe = (...transformers: Function[]) =>
transformers.reduce(combineFunctions)

const distance1D = (a: number, b: number) => Math.abs(a - b)
type Point = { x: number; y: number }

const isPoint = (point: any): point is { x: number; y: number } =>
"x" in point && "y" in point

export function distance<P extends Point | number>(a: P, b: P) {
if (isNumber(a) && isNumber(b)) {
return distance1D(a, b)
}
if (isPoint(a) && isPoint(b)) {
const xDelta = distance1D(a.x, b.x)
const yDelta = distance1D(a.y, b.y)
return Math.sqrt(xDelta ** 2 + yDelta ** 2)
}
return 0
}
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from "./focus"
export * from "./pointer-event"
export * from "./user-agent"
export * from "./breakpoint"
export * from "./pan-event"
212 changes: 212 additions & 0 deletions packages/utils/src/pan-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* This is a modified version of `PanSession` from `framer-motion`.
*
* Credit goes to `framer-motion` of this useful utilities.
* License can be found here: https://github.com/framer/motion
*/

import sync, { cancelSync } from "framesync"
import {
isMouseEvent,
extractEventInfo,
addPointerEvent,
AnyPointerEvent,
Point,
PointerEventInfo,
isMultiTouchEvent,
} from "./pointer-event"
import { pipe, distance, noop } from "./function"

/**
* The event information passed to pan event handlers like `onPan`, `onPanStart`.
*
* It contains information about the current state of the tap gesture such as its
* `point`, `delta`, and `offset`
*/
export interface PanEventInfo {
/**
* Contains `x` and `y` values for the current pan position relative
* to the device or page.
*/
point: Point
/**
* Contains `x` and `y` values for the distance moved since
* the last pan event.
*/
delta: Point
/**
* Contains `x` and `y` values for the distance moved from
* the first pan event.
*/
offset: Point
}

export type PanHandler = (event: AnyPointerEvent, info: PanEventInfo) => void

export interface PanSessionHandlers {
/**
* Callback fired when the pan session is created.
* This is typically called once `pointerdown` event is fired.
*/
onSessionStart: PanHandler
/**
* Callback fired when the pan session has started.
* The pan session when the pan offset is greater than
* the threshold (allowable move distance to detect pan)
*/
onStart: PanHandler
/**
* Callback fired while panning
*/
onMove: PanHandler
/**
* Callback fired when the current pan session has end.
* This is typically called once `pointerup` event is fired.
*/
onEnd: PanHandler
}

/**
* @internal
*
* A Pan Session is recognized when the pointer is down
* and moved in the allowed direction.
*/
export class PanSession {
/**
* We use this to keep track of the `x` and `y` pan session history
* as the pan event happens. It helps to calculate the `offset` and `delta`
*/
private history: Point[] = []

// The pointer event that started the pan session
private startEvent: AnyPointerEvent | null = null

// The current pointer event for the pan session
private lastEvent: AnyPointerEvent | null = null

// The current pointer event info for the pan session
private lastEventInfo: PointerEventInfo | null = null

private handlers: Partial<PanSessionHandlers> = {}

private removeListeners: Function = noop

/**
* Minimal pan distance required before recognizing the pan.
* @default "3px"
*/
private threshold = 3

constructor(
event: AnyPointerEvent,
handlers: Partial<PanSessionHandlers>,
threshold?: number,
) {
// If we have more than one touch, don't start detecting this gesture
if (isMultiTouchEvent(event)) return

this.handlers = handlers

if (threshold) {
this.threshold = threshold
}

// stop default browser behavior
event.stopPropagation()
event.preventDefault()

// get and save the `pointerdown` event info in history
// we'll use it to compute the `offset`
const info = extractEventInfo(event)
this.history = [info.point]

// notify pan session start
const { onSessionStart } = handlers
onSessionStart?.(event, getPanInfo(info, this.history))

// attach event listeners and return a single function to remove them all
this.removeListeners = pipe(
addPointerEvent(window, "pointermove", this.onPointerMove),
addPointerEvent(window, "pointerup", this.onPointerUp),
addPointerEvent(window, "pointercancel", this.onPointerUp),
)
}

private updatePoint = () => {
if (!(this.lastEvent && this.lastEventInfo)) return

const info = getPanInfo(this.lastEventInfo, this.history)

const isPanStarted = this.startEvent !== null

const isDistancePastThreshold =
distance(info.offset, { x: 0, y: 0 }) >= this.threshold

if (!isPanStarted && !isDistancePastThreshold) return

this.history.push(info.point)

const { onStart, onMove } = this.handlers

if (!isPanStarted) {
onStart?.(this.lastEvent, info)
this.startEvent = this.lastEvent
}

onMove?.(this.lastEvent, info)
}

private onPointerMove = (event: AnyPointerEvent, info: PointerEventInfo) => {
this.lastEvent = event
this.lastEventInfo = info

// Because Safari doesn't trigger mouseup events when it's above a `<select>`
if (isMouseEvent(event) && event.buttons === 0) {
this.onPointerUp(event, info)
return
}

// Throttle mouse move event to once per frame
sync.update(this.updatePoint, true)
}

private onPointerUp = (event: AnyPointerEvent, info: PointerEventInfo) => {
this.end()

const { onEnd } = this.handlers
if (!onEnd || !this.startEvent) return

const panInfo = getPanInfo(info, this.history)
onEnd?.(event, panInfo)
}

updateHandlers(handlers: Partial<PanSessionHandlers>) {
this.handlers = handlers
}

end() {
this.removeListeners?.()
cancelSync.update(this.updatePoint)
}
}

function subtractPoint(a: Point, b: Point) {
return { x: a.x - b.x, y: a.y - b.y }
}

function startPanPoint(history: Point[]) {
return history[0]
}

function lastPanPoint(history: Point[]) {
return history[history.length - 1]
}

function getPanInfo(info: PointerEventInfo, history: Point[]) {
return {
point: info.point,
delta: subtractPoint(info.point, lastPanPoint(history)),
offset: subtractPoint(info.point, startPanPoint(history)),
}
}
31 changes: 19 additions & 12 deletions packages/utils/src/pointer-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

import { addDomEvent, isBrowser } from "./dom"

type EventType = MouseEvent | TouchEvent | PointerEvent
export type AnyPointerEvent = MouseEvent | TouchEvent | PointerEvent

type PointType = "page" | "client"

export function isMouseEvent(event: EventType): event is MouseEvent {
export function isMouseEvent(event: AnyPointerEvent): event is MouseEvent {
// PointerEvent inherits from MouseEvent so we can't use a straight instanceof check.
if (typeof PointerEvent !== "undefined" && event instanceof PointerEvent) {
return !!(event.pointerType === "mouse")
Expand All @@ -17,21 +18,24 @@ export function isMouseEvent(event: EventType): event is MouseEvent {
return event instanceof MouseEvent
}

export function isTouchEvent(event: EventType): event is TouchEvent {
export function isTouchEvent(event: AnyPointerEvent): event is TouchEvent {
const hasTouches = !!(event as TouchEvent).touches
return hasTouches
}

export interface Point2D {
export interface Point {
x: number
y: number
}

export interface EventInfo {
point: Point2D
export interface PointerEventInfo {
point: Point
}

export type EventHandler = (event: EventType, info: EventInfo) => void
export type EventHandler = (
event: AnyPointerEvent,
info: PointerEventInfo,
) => void

/**
* Filters out events not attached to the primary pointer (currently left mouse button)
Expand All @@ -48,7 +52,10 @@ function filterPrimaryPointer(eventHandler: EventListener): EventListener {
}
}

export type EventListenerWithPointInfo = (e: EventType, info: EventInfo) => void
export type EventListenerWithPointInfo = (
e: AnyPointerEvent,
info: PointerEventInfo,
) => void

const defaultPagePoint = { pageX: 0, pageY: 0 }

Expand All @@ -73,17 +80,17 @@ function pointFromMouse(
}

export function extractEventInfo(
event: EventType,
event: AnyPointerEvent,
pointType: PointType = "page",
): EventInfo {
): PointerEventInfo {
return {
point: isTouchEvent(event)
? pointFromTouch(event, pointType)
: pointFromMouse(event, pointType),
}
}

export function getViewportPointFromEvent(event: EventType) {
export function getViewportPointFromEvent(event: AnyPointerEvent) {
return extractEventInfo(event, "client")
}

Expand Down Expand Up @@ -159,6 +166,6 @@ export function addPointerEvent(
)
}

export function isMultiTouchEvent(event: EventType) {
export function isMultiTouchEvent(event: AnyPointerEvent) {
return isTouchEvent(event) && event.touches.length > 1
}

0 comments on commit 59ea894

Please sign in to comment.