From cd809f03ca2724514e4ca953f619e52946f313bc Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 27 Oct 2023 12:50:18 +0200 Subject: [PATCH] Settings: Introduce Controlled Swipe --- src/components/left/LeftColumn.tsx | 31 ++- src/components/left/settings/Settings.tsx | 2 + .../mediaViewer/MediaViewerSlides.tsx | 1 + src/components/ui/Transition.scss | 121 +++++++++++- src/components/ui/Transition.tsx | 39 +++- src/hooks/usePrevious.ts | 1 + src/hooks/usePrevious2.ts | 15 ++ src/hooks/useTimeout.ts | 16 +- src/styles/_variables.scss | 9 +- src/util/captureEvents.ts | 89 +++++---- src/util/cssAnimationEndListeners.ts | 14 +- src/util/swipeController.ts | 183 ++++++++++++++++++ 12 files changed, 446 insertions(+), 75 deletions(-) create mode 100644 src/hooks/usePrevious2.ts create mode 100644 src/util/swipeController.ts diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 7da5e2bd5..f5f9ea1b4 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -1,7 +1,5 @@ import type { RefObject } from 'react'; -import React, { - memo, useEffect, useState, -} from '../../lib/teact/teact'; +import React, { memo, useEffect, useState } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { GlobalState } from '../../global/types'; @@ -11,11 +9,16 @@ import { LeftColumnContent, SettingsScreens } from '../../types'; import { selectCurrentChat, selectIsForumPanelOpen, selectTabState } from '../../global/selectors'; import captureEscKeyListener from '../../util/captureEscKeyListener'; -import { IS_APP, IS_MAC_OS, LAYERS_ANIMATION_NAME } from '../../util/windowEnvironment'; +import { captureControlledSwipe } from '../../util/swipeController'; +import { + IS_APP, IS_MAC_OS, IS_TOUCH_ENV, LAYERS_ANIMATION_NAME, +} from '../../util/windowEnvironment'; import useFoldersReducer from '../../hooks/reducers/useFoldersReducer'; import { useHotkeys } from '../../hooks/useHotkeys'; import useLastCallback from '../../hooks/useLastCallback'; +import usePrevious2 from '../../hooks/usePrevious2'; +import { useStateRef } from '../../hooks/useStateRef'; import useSyncEffect from '../../hooks/useSyncEffect'; import Transition from '../ui/Transition'; @@ -436,6 +439,23 @@ function LeftColumn({ setSettingsScreen(screen); }); + const prevSettingsScreenRef = useStateRef(usePrevious2(contentType === ContentType.Settings ? settingsScreen : -1)); + + useEffect(() => { + if (!IS_TOUCH_ENV) { + return undefined; + } + + return captureControlledSwipe(ref.current!, { + excludedClosestSelector: '.ProfileInfo, .color-picker, .hue-picker', + onSwipeRightStart: handleReset, + onCancel: () => { + setContent(LeftColumnContent.Settings); + handleSettingsScreenSelect(prevSettingsScreenRef.current!); + }, + }); + }, [prevSettingsScreenRef, ref]); + function renderContent(isActive: boolean) { switch (contentType) { case ContentType.Archived: @@ -459,9 +479,9 @@ function LeftColumn({ currentScreen={settingsScreen} foldersState={foldersState} foldersDispatch={foldersDispatch} + shouldSkipTransition={shouldSkipHistoryAnimations} onScreenSelect={handleSettingsScreenSelect} onReset={handleReset} - shouldSkipTransition={shouldSkipHistoryAnimations} /> ); case ContentType.NewChannel: @@ -519,6 +539,7 @@ function LeftColumn({ shouldWrap wrapExceptionKey={ContentType.Main} id="LeftColumn" + withSwipeControl > {renderContent} diff --git a/src/components/left/settings/Settings.tsx b/src/components/left/settings/Settings.tsx index ba38ee981..2492542eb 100644 --- a/src/components/left/settings/Settings.tsx +++ b/src/components/left/settings/Settings.tsx @@ -149,6 +149,7 @@ const Settings: FC = ({ shouldSkipTransition, }) => { const { closeShareChatFolderModal } = getActions(); + const [twoFaState, twoFaDispatch] = useTwoFaReducer(); const [privacyPasscode, setPrivacyPasscode] = useState(''); @@ -476,6 +477,7 @@ const Settings: FC = ({ activeKey={currentScreen} renderCount={TRANSITION_RENDER_COUNT} shouldWrap + withSwipeControl > {renderCurrentSection} diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index ddf1741d2..e64c1c235 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -422,6 +422,7 @@ const MediaViewerSlides: FC = ({ minZoom: MIN_ZOOM, maxZoom: MAX_ZOOM, doubleTapZoom: DOUBLE_TAP_ZOOM, + withWheelDrag: true, onCapture: (e) => { if (checkIfControlTarget(e)) return; const { x, y, scale } = transformRef.current; diff --git a/src/components/ui/Transition.scss b/src/components/ui/Transition.scss index 3e1827024..04bcdfbd2 100644 --- a/src/components/ui/Transition.scss +++ b/src/components/ui/Transition.scss @@ -1,5 +1,6 @@ .Transition { position: relative; + width: 100%; height: 100%; @@ -16,6 +17,10 @@ left: 0; } + &-from { + pointer-events: none; + } + &-inactive { display: none !important; // Best performance when animating container //transform: scale(0); // Shortest initial delay @@ -46,6 +51,7 @@ &-slide { > .Transition_slide-to { transform: translateX(100%); + animation: slide-in var(--slide-transition); } @@ -57,6 +63,7 @@ &-slideBackwards { > .Transition_slide-to { transform: translateX(-100%); + animation: slide-out-backwards var(--slide-transition); } @@ -68,6 +75,7 @@ &-slideRtl { > .Transition_slide-to { transform: translateX(-100%); + animation: slide-in var(--slide-transition); } @@ -80,6 +88,7 @@ &-slideRtlBackwards { > .Transition_slide-to { transform: translateX(100%); + animation: slide-out-backwards var(--slide-transition); } @@ -91,6 +100,7 @@ &-slideVertical { > .Transition_slide-to { transform: translateY(100%); + animation: slide-vertical-in var(--slide-transition); } @@ -102,6 +112,7 @@ &-slideVerticalBackwards { > .Transition_slide-to { transform: translateY(-100%); + animation: slide-vertical-out-backwards var(--slide-transition); } @@ -113,6 +124,7 @@ &-slideVerticalFade { > .Transition_slide-to { transform: translateY(100%); + animation: slide-vertical-fade-in var(--slide-transition); } @@ -124,6 +136,7 @@ &-slideVerticalFadeBackwards { > .Transition_slide-to { transform: translateY(-100%); + animation: slide-vertical-fade-out-backwards var(--slide-transition); } @@ -136,14 +149,18 @@ > .Transition_slide-from { transform: translateX(0); transform-origin: left; + opacity: 1; + animation: fade-out-opacity var(--slide-transition), slide-fade-out-move var(--slide-transition); } > .Transition_slide-to { transform: translateX(1.5rem); transform-origin: left; + opacity: 0; + animation: fade-in-opacity var(--slide-transition), slide-fade-in-move var(--slide-transition); } } @@ -151,46 +168,93 @@ &-slideFadeBackwards { > .Transition_slide-from { transform: translateX(0); + opacity: 1; + animation: fade-in-backwards-opacity var(--slide-transition), slide-fade-in-backwards-move var(--slide-transition); } > .Transition_slide-to { transform: translateX(-1.5rem); + opacity: 0; + animation: fade-out-backwards-opacity var(--slide-transition), slide-fade-out-backwards-move var(--slide-transition); } } + &-slideFadeAndroid { + --background-color: var(--color-background); + + > .Transition_slide { + z-index: 0; + + background: var(--background-color); + } + + > .Transition_slide-to { + transform: translateX(1.5rem); + transform-origin: left; + + opacity: 0; + + animation: fade-in-opacity var(--slide-transition), slide-fade-in-move-android var(--slide-transition); + } + } + + &-slideFadeAndroidBackwards { + --background-color: var(--color-background); + + > .Transition_slide { + z-index: 0; + + background: var(--background-color); + } + + > .Transition_slide-from { + transform: translateX(0); + + opacity: 1; + + animation: fade-in-backwards-opacity var(--slide-transition), + slide-fade-in-backwards-move-android var(--slide-transition); + } + } + &-zoomFade { > .Transition_slide-from { transform: scale(1); transform-origin: center; + opacity: 1; + animation: fade-out-opacity 0.15s ease; } > .Transition_slide-to { transform-origin: center; + opacity: 0; // We can omit `transform: scale(1.1);` here because `opacity` is 0. // We need to for proper position calculation in `InfiniteScroll`. - animation: fade-in-opacity 0.15s ease, zoomFade-in-move 0.15s ease; + animation: fade-in-opacity 0.15s ease, zoom-fade-in-move 0.15s ease; } } &-zoomFadeBackwards { > .Transition_slide-from { transform: scale(1); - animation: fade-in-backwards-opacity 0.1s ease, zoomFade-in-backwards-move 0.15s ease; + + animation: fade-in-backwards-opacity 0.1s ease, zoom-fade-in-backwards-move 0.15s ease; } > .Transition_slide-to { transform: scale(0.95); - animation: fade-out-backwards-opacity 0.15s ease, zoomFade-out-backwards-move 0.15s ease; + + animation: fade-out-backwards-opacity 0.15s ease, zoom-fade-out-backwards-move 0.15s ease; } } @@ -198,11 +262,13 @@ &-fadeBackwards { > .Transition_slide-from { opacity: 1; + animation: fade-out-opacity 0.15s ease; } > .Transition_slide-to { opacity: 0; + animation: fade-in-opacity 0.15s ease; } } @@ -218,6 +284,7 @@ > .Transition_slide-to { opacity: 0; + animation: fade-in-opacity 250ms ease; } } @@ -225,11 +292,13 @@ &-semiFadeBackwards { > .Transition_slide-from { opacity: 1; + animation: fade-in-backwards-opacity 250ms ease; } > .Transition_slide-to { opacity: 1; + animation: none !important; } } @@ -245,11 +314,12 @@ > .Transition_slide-to { transform: translateX(100%); + animation: slide-in var(--layer-transition); } > .Transition_slide-from { - animation: slide-layers-out var(--layer-transition); + animation: slide-layers-out var(--layer-transition-behind); } } @@ -258,10 +328,17 @@ background: black !important; + > .Transition_slide { + background: var(--background-color); + } + > .Transition_slide-to { transform: translateX(-20%); - opacity: 0.75; - animation: slide-layers-out-backwards var(--layer-transition); + + opacity: calc(1 - var(--layer-blackout-opacity)); + + animation: slide-layers-out-backwards var(--layer-transition-behind); + animation-duration: 450ms; } > .Transition_slide-from { @@ -277,7 +354,9 @@ > .Transition_slide-from { transform: scale(1); transform-origin: center; + opacity: 1; + animation: push-out 0.25s ease-in-out; .custom-scroll { @@ -291,6 +370,7 @@ > .Transition_slide-to { transform: translateX(100%); + animation: slide-in-200 0.25s ease-in-out; } } @@ -302,7 +382,9 @@ > .Transition_slide-to { transform: scale(0.7); + opacity: 0; + animation: push-out-backwards 0.25s ease-in-out; } @@ -314,6 +396,7 @@ &-reveal { > .Transition_slide-to { clip-path: inset(0 100% 0 0); + animation: reveal-in 350ms ease-in; } } @@ -321,11 +404,13 @@ &-revealBackwards { > .Transition_slide-from { clip-path: inset(0 0 0 0); + animation: reveal-in-backwards 350ms ease-out; } > .Transition_slide-to { clip-path: none; + animation: none; } } @@ -527,7 +612,25 @@ } } -@keyframes zoomFade-in-move { +@keyframes slide-fade-in-move-android { + 0% { + transform: translateX(20%); + } + 100% { + transform: translateX(0); + } +} + +@keyframes slide-fade-in-backwards-move-android { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(15%); + } +} + +@keyframes zoom-fade-in-move { 0% { transform: scale(1.1); } @@ -536,7 +639,7 @@ } } -@keyframes zoomFade-in-backwards-move { +@keyframes zoom-fade-in-backwards-move { 0% { transform: scale(1); } @@ -545,7 +648,7 @@ } } -@keyframes zoomFade-out-backwards-move { +@keyframes zoom-fade-out-backwards-move { 0% { transform: scale(0.95); } diff --git a/src/components/ui/Transition.tsx b/src/components/ui/Transition.tsx index 58941e002..b37a30c1a 100644 --- a/src/components/ui/Transition.tsx +++ b/src/components/ui/Transition.tsx @@ -10,6 +10,7 @@ import { selectCanAnimateInterface } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import { waitForAnimationEnd, waitForTransitionEnd } from '../../util/cssAnimationEndListeners'; import forceReflow from '../../util/forceReflow'; +import { allowSwipeControlForTransition } from '../../util/swipeController'; import useForceUpdate from '../../hooks/useForceUpdate'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; @@ -20,8 +21,8 @@ import './Transition.scss'; type AnimationName = ( 'none' | 'slide' | 'slideRtl' | 'slideFade' | 'zoomFade' | 'slideLayers' | 'fade' | 'pushSlide' | 'reveal' | 'slideOptimized' | 'slideOptimizedRtl' | 'semiFade' - | 'slideVertical' | 'slideVerticalFade' -); + | 'slideVertical' | 'slideVerticalFade' | 'slideFadeAndroid' + ); export type ChildrenFn = (isActive: boolean, isFrom: boolean, currentKey: number) => React.ReactNode; export type TransitionProps = { ref?: RefObject; @@ -39,6 +40,7 @@ export type TransitionProps = { id?: string; className?: string; slideClassName?: string; + withSwipeControl?: boolean; onStart?: NoneToVoidFunction; onStop?: NoneToVoidFunction; children: React.ReactNode | ChildrenFn; @@ -52,6 +54,10 @@ const CLASSES = { to: 'Transition_slide-to', inactive: 'Transition_slide-inactive', }; + +export const ACTIVE_SLIDE_CLASS_NAME = CLASSES.active; +export const TO_SLIDE_CLASS_NAME = CLASSES.to; + const DISABLEABLE_ANIMATIONS = new Set([ 'slide', 'slideRtl', 'slideFade', 'zoomFade', 'slideLayers', 'pushSlide', 'reveal', 'slideOptimized', 'slideOptimizedRtl', 'slideVertical', 'slideVerticalFade', @@ -72,6 +78,7 @@ function Transition({ id, className, slideClassName, + withSwipeControl, onStart, onStop, children, @@ -90,6 +97,7 @@ function Transition({ const rendersRef = useRef>({}); const prevActiveKey = usePrevious(activeKey); const forceUpdate = useForceUpdate(); + const isSwipeJustCancelledRef = useRef(false); const activeKeyChanged = prevActiveKey !== undefined && activeKey !== prevActiveKey; @@ -169,6 +177,7 @@ function Transition({ if (!childNodes[activeIndex]) { return; } + performSlideOptimized( shouldDisableAnimation, name, @@ -187,7 +196,11 @@ function Transition({ return; } - if (name === 'none' || shouldDisableAnimation) { + if (name === 'none' || shouldDisableAnimation || isSwipeJustCancelledRef.current) { + if (isSwipeJustCancelledRef.current) { + isSwipeJustCancelledRef.current = false; + } + childNodes.forEach((node, i) => { if (node instanceof HTMLElement) { removeExtraClass(node, CLASSES.from); @@ -252,12 +265,27 @@ function Transition({ }); } - const watchedNode = name === 'reveal' && isBackwards + const watchedNode = (name === 'reveal' || name === 'slideFadeAndroid') && isBackwards ? childNodes[prevActiveIndex] : childNodes[activeIndex]; if (watchedNode) { - waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END); + if (withSwipeControl && childNodes[prevActiveIndex]) { + const giveUpAnimationEnd = waitForAnimationEnd(watchedNode, onAnimationEnd); + + allowSwipeControlForTransition( + childNodes[prevActiveIndex] as HTMLElement, + childNodes[activeIndex] as HTMLElement, + () => { + giveUpAnimationEnd(); + isSwipeJustCancelledRef.current = true; + onStop?.(); + dispatchHeavyAnimationStop(); + }, + ); + } else { + waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END); + } } else { onAnimationEnd(); } @@ -277,6 +305,7 @@ function Transition({ cleanupExceptionKey, shouldDisableAnimation, forceUpdate, + withSwipeControl, ]); useEffect(() => { diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts index 9e7c2139a..6da28f0bd 100644 --- a/src/hooks/usePrevious.ts +++ b/src/hooks/usePrevious.ts @@ -1,5 +1,6 @@ import { useRef } from '../lib/teact/teact'; +// Deprecated. Use `usePrevious2` instead function usePrevious(next: T): T | undefined; function usePrevious(next: T, shouldSkipUndefined: true): Exclude | undefined; function usePrevious(next: T, shouldSkipUndefined?: boolean): Exclude | undefined; diff --git a/src/hooks/usePrevious2.ts b/src/hooks/usePrevious2.ts new file mode 100644 index 000000000..996e25edc --- /dev/null +++ b/src/hooks/usePrevious2.ts @@ -0,0 +1,15 @@ +import { useRef } from '../lib/teact/teact'; + +// This is not render-dependent and will never allow previous to match current +export default function usePrevious2(current: T) { + const prevRef = useRef(); + const lastRef = useRef(); + + if (lastRef.current !== current) { + prevRef.current = lastRef.current; + } + + lastRef.current = current; + + return prevRef.current; +} diff --git a/src/hooks/useTimeout.ts b/src/hooks/useTimeout.ts index 5cc3b18bd..4053bf226 100644 --- a/src/hooks/useTimeout.ts +++ b/src/hooks/useTimeout.ts @@ -1,19 +1,19 @@ -import { useEffect, useLayoutEffect, useRef } from '../lib/teact/teact'; +import { useEffect } from '../lib/teact/teact'; -function useTimeout(callback: () => void, delay?: number) { - const savedCallback = useRef(callback); +import useLastCallback from './useLastCallback'; - useLayoutEffect(() => { - savedCallback.current = callback; - }, [callback]); +function useTimeout(callback: () => void, delay?: number, dependencies: readonly any[] = []) { + const savedCallback = useLastCallback(callback); useEffect(() => { if (typeof delay !== 'number') { return undefined; } - const id = setTimeout(() => savedCallback.current(), delay); + + const id = setTimeout(() => savedCallback(), delay); return () => clearTimeout(id); - }, [delay]); + // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps + }, [delay, savedCallback, ...dependencies]); } export default useTimeout; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 9286bd5b2..7c27f882a 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -291,19 +291,20 @@ $color-message-story-mention-to: #74bcff; --premium-gradient: linear-gradient(84.4deg, #6C93FF -4.85%, #976FFF 51.72%, #DF69D1 110.7%); - --layer-blackout-opacity: 0.3; + --layer-blackout-opacity: 0.1; --layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1); + --layer-transition-behind: 300ms cubic-bezier(0.33, 1, 0.68, 1); --slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1); --select-transition: 200ms ease-out; body.is-ios { - --layer-transition: 450ms cubic-bezier(0.33, 1, 0.68, 1); + --layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1); + --layer-transition-behind: 650ms cubic-bezier(0.33, 1, 0.68, 1); --slide-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1); } body.is-android { - --layer-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1); - --slide-transition: 400ms cubic-bezier(0.25, 1, 0.5, 1); + --slide-transition: 350ms cubic-bezier(0.16, 1, 0.3, 1); } } diff --git a/src/util/captureEvents.ts b/src/util/captureEvents.ts index e3d434499..b3c594e26 100644 --- a/src/util/captureEvents.ts +++ b/src/util/captureEvents.ts @@ -11,19 +11,21 @@ export enum SwipeDirection { Right, } +export interface MoveOffsets { + dragOffsetX: number; + dragOffsetY: number; +} + interface CaptureOptions { onCapture?: (e: MouseEvent | TouchEvent | WheelEvent) => void; onRelease?: (e: MouseEvent | TouchEvent | WheelEvent) => void; onDrag?: ( e: MouseEvent | TouchEvent | WheelEvent, captureEvent: MouseEvent | TouchEvent | WheelEvent, - params: { - dragOffsetX: number; - dragOffsetY: number; - }, + offsets: MoveOffsets, cancelDrag?: (x: boolean, y: boolean) => void, ) => void; - onSwipe?: (e: Event, direction: SwipeDirection) => boolean; + onSwipe?: (e: Event, direction: SwipeDirection, offsets: MoveOffsets) => boolean; onZoom?: (e: TouchEvent | WheelEvent, params: { // Absolute zoom level zoom?: number; @@ -53,6 +55,8 @@ interface CaptureOptions { initialZoom?: number; isNotPassive?: boolean; withCursor?: boolean; + swipeThreshold?: number; + withWheelDrag?: boolean; } // https://stackoverflow.com/questions/11287877/how-can-i-get-e-offsetx-on-mobile-ipad @@ -70,9 +74,8 @@ type TSwipeAxis = export const IOS_SCREEN_EDGE_THRESHOLD = 20; export const SWIPE_DIRECTION_THRESHOLD = 10; export const SWIPE_DIRECTION_TOLERANCE = 1.5; - -const MOVED_THRESHOLD = 15; -const SWIPE_THRESHOLD = 50; +const MOVE_THRESHOLD = 15; +const SWIPE_THRESHOLD_DEFAULT = 20; const RELEASE_WHEEL_ZOOM_DELAY = 150; const RELEASE_WHEEL_DRAG_DELAY = 150; @@ -121,17 +124,28 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { const maxZoom = options.maxZoom ?? 4; function onCapture(e: MouseEvent | RealTouchEvent) { - if (options.excludedClosestSelector && ( - (e.target as HTMLElement).matches(options.excludedClosestSelector) - || (e.target as HTMLElement).closest(options.excludedClosestSelector) - )) { + const target = e.target as HTMLElement; + const { + excludedClosestSelector, + withNativeDrag, + withCursor, + onDrag, + } = options; + + if (element !== target && !element.contains(target)) { + return; + } + + if ( + excludedClosestSelector && (target.matches(excludedClosestSelector) || target.closest(excludedClosestSelector)) + ) { return; } captureEvent = e; if (e.type === 'mousedown') { - if (!options.withNativeDrag && options.onDrag) { + if (!withNativeDrag && onDrag) { e.preventDefault(); } @@ -140,10 +154,9 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { } else if (e.type === 'touchstart') { // We need to always listen on `touchstart` target: // https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed - const target = e.target as HTMLElement; target.addEventListener('touchmove', onMove, { passive: true }); - target.addEventListener('touchend', onRelease); - target.addEventListener('touchcancel', onRelease); + target.addEventListener('touchend', onRelease, { passive: true }); + target.addEventListener('touchcancel', onRelease, { passive: true }); if ('touches' in e) { if (e.pageX === undefined) { @@ -161,13 +174,11 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { } } - if (options.withCursor) { + if (withCursor) { document.body.classList.add('cursor-grabbing'); } - if (options.onCapture) { - options.onCapture(e); - } + options.onCapture?.(e); } function onRelease(e?: MouseEvent | TouchEvent) { @@ -263,7 +274,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { const dragOffsetX = e.pageX! - captureEvent.pageX!; const dragOffsetY = e.pageY! - captureEvent.pageY!; - if (Math.abs(dragOffsetX) >= MOVED_THRESHOLD || Math.abs(dragOffsetY) >= MOVED_THRESHOLD) { + if (Math.abs(dragOffsetX) >= MOVE_THRESHOLD || Math.abs(dragOffsetY) >= MOVE_THRESHOLD) { hasMoved = true; } @@ -302,19 +313,12 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { const xAbs = Math.abs(dragOffsetX); const yAbs = Math.abs(dragOffsetY); - - if (dragOffsetX && dragOffsetY) { - const ratio = Math.max(xAbs, yAbs) / Math.min(xAbs, yAbs); - // Diagonal swipe - if (ratio < 2) { - return false; - } - } + const threshold = options.swipeThreshold ?? SWIPE_THRESHOLD_DEFAULT; let axis: TSwipeAxis | undefined; - if (xAbs >= SWIPE_THRESHOLD) { + if (xAbs > yAbs && xAbs >= threshold) { axis = 'x'; - } else if (yAbs >= SWIPE_THRESHOLD) { + } else if (yAbs > xAbs && yAbs >= threshold) { axis = 'y'; } @@ -414,15 +418,18 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { } } - element.addEventListener('wheel', onWheel); + if (options.withWheelDrag) { + element.addEventListener('wheel', onWheel); + } + element.addEventListener('mousedown', onCapture); - element.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive }); + document.body.addEventListener('touchstart', onCapture, { passive: !options.isNotPassive }); return () => { onRelease(); - element.removeEventListener('wheel', onWheel); - element.removeEventListener('touchstart', onCapture); + document.body.removeEventListener('touchstart', onCapture); element.removeEventListener('mousedown', onCapture); + element.removeEventListener('wheel', onWheel); }; } @@ -431,19 +438,21 @@ function processSwipe( currentSwipeAxis: TSwipeAxis, dragOffsetX: number, dragOffsetY: number, - onSwipe: (e: Event, direction: SwipeDirection) => boolean, + onSwipe: (e: Event, direction: SwipeDirection, offsets: MoveOffsets) => boolean, ) { + const offsets = { dragOffsetX, dragOffsetY }; + if (currentSwipeAxis === 'x') { if (dragOffsetX < 0) { - return onSwipe(e, SwipeDirection.Left); + return onSwipe(e, SwipeDirection.Left, offsets); } else { - return onSwipe(e, SwipeDirection.Right); + return onSwipe(e, SwipeDirection.Right, offsets); } } else if (currentSwipeAxis === 'y') { if (dragOffsetY < 0) { - return onSwipe(e, SwipeDirection.Up); + return onSwipe(e, SwipeDirection.Up, offsets); } else { - return onSwipe(e, SwipeDirection.Down); + return onSwipe(e, SwipeDirection.Down, offsets); } } diff --git a/src/util/cssAnimationEndListeners.ts b/src/util/cssAnimationEndListeners.ts index bd89eb15b..2731339dc 100644 --- a/src/util/cssAnimationEndListeners.ts +++ b/src/util/cssAnimationEndListeners.ts @@ -4,13 +4,13 @@ const ANIMATION_END_DELAY = 50; export function waitForTransitionEnd( node: Node, handler: NoneToVoidFunction, propertyName?: string, fallbackMs?: number, ) { - waitForEndEvent('transitionend', node, handler, propertyName, fallbackMs); + return waitForEndEvent('transitionend', node, handler, propertyName, fallbackMs); } export function waitForAnimationEnd( node: Node, handler: NoneToVoidFunction, animationName?: string, fallbackMs?: number, ) { - waitForEndEvent('animationend', node, handler, animationName, fallbackMs); + return waitForEndEvent('animationend', node, handler, animationName, fallbackMs); } function waitForEndEvent( @@ -22,6 +22,10 @@ function waitForEndEvent( ) { let isHandled = false; + function cleanup() { + node.removeEventListener(eventType, handleAnimationEnd); + } + function handleAnimationEnd(e: TransitionEvent | AnimationEvent | Event) { if (isHandled || e.target !== e.currentTarget) { return; @@ -36,7 +40,7 @@ function waitForEndEvent( isHandled = true; - node.removeEventListener(eventType, handleAnimationEnd); + cleanup(); setTimeout(() => { handler(); @@ -49,9 +53,11 @@ function waitForEndEvent( setTimeout(() => { if (isHandled) return; - node.removeEventListener(eventType, handleAnimationEnd); + cleanup(); handler(); }, fallbackMs); } + + return cleanup; } diff --git a/src/util/swipeController.ts b/src/util/swipeController.ts new file mode 100644 index 000000000..cbeb2a648 --- /dev/null +++ b/src/util/swipeController.ts @@ -0,0 +1,183 @@ +import type { MoveOffsets } from './captureEvents'; + +import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom'; +import { animateNumber, timingFunctions } from './animation'; +import { captureEvents, SwipeDirection } from './captureEvents'; +import { waitForAnimationEnd } from './cssAnimationEndListeners'; +import { clamp } from './math'; +import { IS_IOS } from './windowEnvironment'; + +const INERTIA_DURATION = 300; +const INERTIA_EASING = timingFunctions.easeOutCubic; + +let isSwipeActive = false; +let swipeOffsets: MoveOffsets | undefined; +let onDrag: ((offsets: MoveOffsets) => void) | undefined; +let onRelease: ((onCancel: NoneToVoidFunction) => void) | undefined; + +export function captureControlledSwipe( + element: HTMLElement, options: { + excludedClosestSelector?: string; + onSwipeLeftStart?: NoneToVoidFunction; + onSwipeRightStart?: NoneToVoidFunction; + onCancel: NoneToVoidFunction; + }, +) { + return captureEvents(element, { + excludedClosestSelector: options.excludedClosestSelector, + swipeThreshold: 10, + + onSwipe(e, direction, offsets) { + if (direction === SwipeDirection.Left) { + options.onSwipeLeftStart?.(); + } else if (direction === SwipeDirection.Right) { + options.onSwipeRightStart?.(); + } else { + return false; + } + + if (IS_IOS) { + isSwipeActive = true; + swipeOffsets = offsets; + } + + return true; + }, + + onDrag(e, captureEvent, offsets) { + if (!isSwipeActive) return; + + onDrag?.(offsets); + }, + + onRelease() { + if (!isSwipeActive) return; + + isSwipeActive = false; + + onRelease?.(options.onCancel); + + onDrag = undefined; + onRelease = undefined; + }, + }); +} + +export function allowSwipeControlForTransition( + currentSlide: HTMLElement, + nextSlide: HTMLElement, + onCancelForTransition: NoneToVoidFunction, +) { + if (!isSwipeActive) return; + + const targetPosition = extractAnimationEndPosition(currentSlide); + if (!targetPosition) return; + + currentSlide.getAnimations().forEach((a) => a.pause()); + nextSlide.getAnimations().forEach((a) => a.pause()); + + currentSlide.style.animationTimingFunction = 'linear'; + nextSlide.style.animationTimingFunction = 'linear'; + + let currentDirection: 1 | -1 | undefined; + + requestMeasure(() => { + const computedStyle = getComputedStyle(currentSlide); + const initialPositionPx = extractPositionFromMatrix(computedStyle.transform, targetPosition.axis); + const targetPositionPx = targetPosition.units === 'px' + ? targetPosition.value + : ((targetPosition.value / 100) * ( + targetPosition.axis === 'X' ? currentSlide.offsetWidth : currentSlide.offsetHeight + )); + const distance = targetPositionPx - initialPositionPx; + + let progress = 0; + + onDrag = ({ dragOffsetX, dragOffsetY }) => { + const dragOffset = targetPosition.axis === 'X' + ? dragOffsetX - swipeOffsets!.dragOffsetX + : dragOffsetY - swipeOffsets!.dragOffsetY; + + const newProgress = clamp(dragOffset / distance, 0, 1); + currentDirection = newProgress > progress ? 1 : -1; + progress = newProgress; + + updateAnimationProgress([currentSlide, nextSlide], progress); + }; + + onRelease = (onCancelForClient: NoneToVoidFunction) => { + const isCanceled = currentDirection === -1; + + function cleanup() { + currentSlide.getAnimations().forEach((a) => a.cancel()); + nextSlide.getAnimations().forEach((a) => a.cancel()); + + requestMutation(() => { + currentSlide.style.animationTimingFunction = ''; + nextSlide.style.animationTimingFunction = ''; + }); + } + + if (!isCanceled) { + // For some reason animations are not cleared when CSS class is removed + waitForAnimationEnd(currentSlide, cleanup); + } + + animateNumber({ + from: progress, + to: isCanceled ? 0 : 1, + duration: INERTIA_DURATION, + timing: INERTIA_EASING, + onUpdate(releaseProgress) { + updateAnimationProgress([currentSlide, nextSlide], releaseProgress); + }, + onEnd() { + if (isCanceled) { + cleanup(); + onCancelForTransition(); + onCancelForClient(); + } + }, + }); + }; + }); +} + +function updateAnimationProgress(elements: HTMLElement[], progress: number) { + elements.map((e) => e.getAnimations()).flat().forEach((animation) => { + animation.currentTime = (animation.effect!.getTiming().duration as number) * progress; + }); +} + +function extractAnimationEndPosition(element: HTMLElement) { + for (const animation of element.getAnimations()) { + if (!(animation.effect instanceof KeyframeEffect)) continue; + + for (const keyframe of animation.effect.getKeyframes()) { + if (keyframe.offset !== 1 || !keyframe.transform) continue; + + const position = extractPositionFromTransform(keyframe.transform as string); + if (position) { + return position; + } + } + } + + return undefined; +} + +function extractPositionFromTransform(transformRule: string) { + const match = transformRule.match(/([XY])\((-?\d+)(%|px)\)/); + if (!match) return undefined; + + return { + axis: match[1] as 'X' | 'Y', + value: Number(match[2]), + units: match[3], + }; +} + +function extractPositionFromMatrix(transform: string, axis: 'X' | 'Y') { + const matrix = transform.slice(7, -1).split(',').map(Number); + return matrix[axis === 'X' ? 4 : 5]; +}