Settings: Introduce Controlled Swipe

This commit is contained in:
Alexander Zinchuk 2023-10-27 12:50:18 +02:00
parent e815c677f3
commit cd809f03ca
12 changed files with 446 additions and 75 deletions

View File

@ -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}
</Transition>

View File

@ -149,6 +149,7 @@ const Settings: FC<OwnProps> = ({
shouldSkipTransition,
}) => {
const { closeShareChatFolderModal } = getActions();
const [twoFaState, twoFaDispatch] = useTwoFaReducer();
const [privacyPasscode, setPrivacyPasscode] = useState<string>('');
@ -476,6 +477,7 @@ const Settings: FC<OwnProps> = ({
activeKey={currentScreen}
renderCount={TRANSITION_RENDER_COUNT}
shouldWrap
withSwipeControl
>
{renderCurrentSection}
</Transition>

View File

@ -422,6 +422,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
doubleTapZoom: DOUBLE_TAP_ZOOM,
withWheelDrag: true,
onCapture: (e) => {
if (checkIfControlTarget(e)) return;
const { x, y, scale } = transformRef.current;

View File

@ -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);
}

View File

@ -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<HTMLDivElement>;
@ -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<AnimationName>([
'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<Record<number, React.ReactNode | ChildrenFn>>({});
const prevActiveKey = usePrevious<any>(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(() => {

View File

@ -1,5 +1,6 @@
import { useRef } from '../lib/teact/teact';
// Deprecated. Use `usePrevious2` instead
function usePrevious<T extends any>(next: T): T | undefined;
function usePrevious<T extends any>(next: T, shouldSkipUndefined: true): Exclude<T, undefined> | undefined;
function usePrevious<T extends any>(next: T, shouldSkipUndefined?: boolean): Exclude<T, undefined> | undefined;

15
src/hooks/usePrevious2.ts Normal file
View File

@ -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<T extends any>(current: T) {
const prevRef = useRef<T>();
const lastRef = useRef<T>();
if (lastRef.current !== current) {
prevRef.current = lastRef.current;
}
lastRef.current = current;
return prevRef.current;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

183
src/util/swipeController.ts Normal file
View File

@ -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];
}