Settings: Introduce Controlled Swipe
This commit is contained in:
parent
e815c677f3
commit
cd809f03ca
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
15
src/hooks/usePrevious2.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
183
src/util/swipeController.ts
Normal 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];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user