From dfefa0faaba01e168b6d6db8c122670097b7aa85 Mon Sep 17 00:00:00 2001 From: jochongs Date: Mon, 29 Dec 2025 22:05:25 +0900 Subject: [PATCH 1/2] feat(react): Enhance swipe back animation --- .../src/components/AppScreen.tsx | 6 +- .../src/useStyleEffectSwipeBack.ts | 249 +++++++++++++----- integrations/react/src/stable/useActions.ts | 13 +- 3 files changed, 190 insertions(+), 78 deletions(-) diff --git a/extensions/plugin-basic-ui/src/components/AppScreen.tsx b/extensions/plugin-basic-ui/src/components/AppScreen.tsx index bb6f273fd..6163bbf62 100644 --- a/extensions/plugin-basic-ui/src/components/AppScreen.tsx +++ b/extensions/plugin-basic-ui/src/components/AppScreen.tsx @@ -191,9 +191,11 @@ const AppScreen: React.FC = ({ return null; }, - onSwipeEnd({ swiped }) { + onTransitionEnd({ swiped }) { if (swiped) { - pop(); + pop({ + skipActiveState: true + }); } }, }); diff --git a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts index 9870aa1a7..0b5a579d5 100644 --- a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts +++ b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts @@ -1,9 +1,15 @@ import type { ActivityTransitionState } from "@stackflow/core"; import { useStyleEffect } from "./useStyleEffect"; -import { listenOnce, noop } from "./utils"; +import { noop } from "./utils"; export const SWIPE_BACK_RATIO_CSS_VAR_NAME = "--stackflow-swipe-back-ratio"; +const SPRING_STIFFNESS = 400; +const DAMPING_COEFFICIENT = 20; +const VELOCITY_THRESHOLD = 800; +const MASS = 1; +const DT = 1000 / 60; + export function useStyleEffectSwipeBack({ dimRef, edgeRef, @@ -53,6 +59,9 @@ export function useStyleEffectSwipeBack({ let x0: number | null = null; let t0: number | null = null; let x: number | null = null; + let lastX: number | null = null; + let lastT: number | null = null; + let velocity = 0; let cachedRefs: Array<{ style: { @@ -70,6 +79,9 @@ export function useStyleEffectSwipeBack({ x0 = null; t0 = null; x = null; + lastX = null; + lastT = null; + velocity = 0; cachedRefs = []; }; @@ -123,87 +135,166 @@ export function useStyleEffectSwipeBack({ } } - function resetActivity({ swiped }: { swiped: boolean }): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => { - $dim.style.opacity = `${swiped ? 0 : 1}`; - $dim.style.transition = transitionDuration; + function cleanStyles({ swiped }: { swiped: boolean }) { + const _cachedRefs = [...cachedRefs]; + + $dim.style.opacity = ""; + $paper.style.overflowY = ""; + $paper.style.transform = ""; + $paper.style.transition = ""; + + if (moveAppBarTogether && $appBarRef) { + $appBarRef.style.overflowY = ""; + $appBarRef.style.transform = ""; + $appBarRef.style.transition = ""; + } + + $appBarRef?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME); + + refs.forEach((ref, i) => { + if (!ref.current) return; + const _cachedRef = _cachedRefs[i]; + + if (swiped) { + ref.current.style.transition = ""; + ref.current.style.transform = ""; + if (ref.current.parentElement) { + ref.current.parentElement.style.display = ""; + } + } else if (_cachedRef) { + ref.current.style.transition = _cachedRef.style.transition; + ref.current.style.transform = _cachedRef.style.transform; + if (ref.current.parentElement && _cachedRef.parentElement) { + ref.current.parentElement.style.display = _cachedRef.parentElement.style.display; + } + } + + ref.current.parentElement?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME); + }); + } - $paper.style.overflowY = "hidden"; - $paper.style.transform = `translateX(${swiped ? "100%" : "0"})`; - $paper.style.transition = transitionDuration; + function resetActivity({ swiped, initialVelocity }: { swiped: boolean, initialVelocity: number }): Promise { + return new Promise((resolve) => { + if (!swiped) { + requestAnimationFrame(() => { + $dim.style.opacity = "0"; + $dim.style.transition = "200ms"; - if (moveAppBarTogether && $appBarRef) { - $appBarRef.style.overflowY = "hidden"; - $appBarRef.style.transform = `translateX(${swiped ? "100%" : "0"})`; - $appBarRef.style.transition = transitionDuration; - } + $paper.style.overflowY = "hidden"; + $paper.style.transform = "translate3d(0, 0, 0)"; + $paper.style.transition = "200ms"; - refs.forEach((ref) => { - if (!ref.current) { - return; + if (moveAppBarTogether && $appBarRef) { + $appBarRef.style.overflowY = "hidden"; + $appBarRef.style.transform = "translate3d(0, 0, 0)"; + $appBarRef.style.transition = "200ms"; } - ref.current.style.transition = transitionDuration; - ref.current.style.transform = `translate3d(${ - swiped ? "0" : `-${offset / 16}rem` - }, 0, 0)`; + refs.forEach((ref, i) => { + if (!ref.current) return; + if (cachedRefs) { + ref.current.style.transition = "transform 0.2s ease-out"; + ref.current.style.transform = cachedRefs[i].style.transform; + } + }); + + setTimeout(() => { + resolve(); + }, 200); }); - const _cachedRefs = [...cachedRefs]; + return; + } - resolve(); + let currX = x || 0; + const targetX = swiped ? $paper.clientWidth : 0; + let currVelocity = initialVelocity; - listenOnce($paper, ["transitionend", "transitioncancel"], () => { - const _swiped = - swiped || - getActivityTransitionState() === "exit-active" || - getActivityTransitionState() === "exit-done"; + let prevX = currX; - $dim.style.opacity = ""; - $paper.style.overflowY = ""; - $paper.style.transform = ""; + let lastTimeStamp: DOMHighResTimeStamp | null = null; + let accumulateTimeStamp = 0; - if (moveAppBarTogether && $appBarRef) { - $appBarRef.style.overflowY = ""; - $appBarRef.style.transform = ""; - $appBarRef.style.removeProperty("transition"); - } + let animationId: number | null = null; - $appBarRef?.style.removeProperty(SWIPE_BACK_RATIO_CSS_VAR_NAME); + function updatePhysicalQuantity() { + const displacement = currX - targetX; + const springForce = -SPRING_STIFFNESS * displacement; + const dampingForce = -DAMPING_COEFFICIENT * currVelocity; + const totalForce = springForce + dampingForce; + const acceleration = totalForce / MASS; - refs.forEach((ref, i) => { - if (!ref.current) { - return; - } + currVelocity += acceleration * (DT / 1000); + currX += currVelocity * (DT / 1000); + } + + function animateSwipeBackSuccess(timeStamp: DOMHighResTimeStamp) { + if (!animationId) return; - const _cachedRef = _cachedRefs[i]; + if (!lastTimeStamp) { + lastTimeStamp = timeStamp; + updatePhysicalQuantity(); + } - if (_swiped) { - ref.current.style.transition = ""; - ref.current.style.transform = ""; + const timeElapsed = timeStamp - lastTimeStamp; + lastTimeStamp = timeStamp; + accumulateTimeStamp += timeElapsed; - if (ref.current.parentElement) { - ref.current.parentElement.style.display = ""; - } - } else if (_cachedRef) { - ref.current.style.transition = _cachedRef.style.transition; - ref.current.style.transform = _cachedRef.style.transform; + while (accumulateTimeStamp >= DT) { + prevX = currX; + updatePhysicalQuantity(); + accumulateTimeStamp -= DT; + } - if (ref.current.parentElement && _cachedRef.parentElement) { - ref.current.parentElement.style.display = - _cachedRef.parentElement.style.display; - } - } + const alpha = accumulateTimeStamp / DT; + const renderX = prevX + (currX - prevX) * alpha; + const ratio = renderX / $paper.clientWidth; - ref.current.parentElement?.style.removeProperty( - SWIPE_BACK_RATIO_CSS_VAR_NAME, - ); - }); + renderComponent(renderX, ratio); - onTransitionEnd?.({ swiped }); + if (targetX - currX < 10) { + renderComponent(targetX, 100); + resolve(); + } else { + animationId = requestAnimationFrame(animateSwipeBackSuccess); + } + } + + function renderComponent(dx: number, ratio: number) { + const clampedRatio = Math.max(0, Math.min(1, ratio)); + + $dim.style.opacity = `${1 - clampedRatio}`; + $dim.style.transition = "0s"; + + $paper.style.overflowY = "hidden"; + $paper.style.transform = `translate3d(${dx}px, 0, 0)`; + $paper.style.transition = "0s"; + + if (moveAppBarTogether && $appBarRef) { + $appBarRef.style.overflowY = "hidden"; + $appBarRef.style.transform = `translate3d(${dx}px, 0, 0)`; + $appBarRef.style.transition = "0s"; + } + + refs.forEach((ref) => { + if (!ref.current) return; + + const backgroundOffset = -1 * (1 - clampedRatio) * offset; + ref.current.style.transform = `translate3d(${backgroundOffset}px, 0, 0)`; + ref.current.style.transition = "0s"; + + if (ref.current.parentElement?.style.display === "none") { + ref.current.parentElement.style.display = "block"; + } + + ref.current.parentElement?.style.setProperty( + SWIPE_BACK_RATIO_CSS_VAR_NAME, + String(clampedRatio), + ); }); - }); + } + + animationId = requestAnimationFrame(animateSwipeBackSuccess); }); } @@ -212,8 +303,9 @@ export function useStyleEffectSwipeBack({ activeElement?.blur?.(); - x0 = x = e.touches[0].clientX; - t0 = Date.now(); + x0 = x = lastX = e.touches[0].clientX; + t0 = lastT = Date.now(); + velocity = 0; cachedRefs = refs.map((ref) => { if (!ref.current) { @@ -244,18 +336,28 @@ export function useStyleEffectSwipeBack({ }; const onTouchMove = (e: TouchEvent) => { - if (!x0) { + if (!x0 || !lastX || !lastT) { resetState(); return; } + const currTime = Date.now(); x = e.touches[0].clientX; + const dt = (currTime - lastT) / 1000; + if (dt > 0) { + const instantVelocity = (x - lastX) / dt; + velocity = velocity * 0.7 + instantVelocity * 0.3; + } + const dx = x - x0; const ratio = dx / $paper.clientWidth; moveActivity({ dx, ratio }); onSwipeMove?.({ dx, ratio }); + + lastX = x; + lastT = currTime; }; const onTouchEnd = () => { @@ -264,14 +366,21 @@ export function useStyleEffectSwipeBack({ return; } - const t = Date.now(); - const v = (x - x0) / (t - t0); - const swiped = v > 1 || x / $paper.clientWidth > 0.4; + const displacement = x - x0; + const ratio = displacement / $paper.clientWidth; + + const swiped = (velocity > VELOCITY_THRESHOLD && displacement > 0) || ratio > 0.4; - onSwipeEnd?.({ swiped }); + onSwipeEnd?.({ swiped }) Promise.resolve() - .then(() => resetActivity({ swiped })) + .then(() => resetActivity({ swiped, initialVelocity: velocity })) + .then(() => onTransitionEnd?.({ swiped })) + // wait for unmount + .then(() => new Promise( + resolve => requestAnimationFrame(() => resolve(undefined)) + )) + .then(() => cleanStyles({ swiped })) .then(() => resetState()); }; diff --git a/integrations/react/src/stable/useActions.ts b/integrations/react/src/stable/useActions.ts index 5098e2509..65f42a447 100644 --- a/integrations/react/src/stable/useActions.ts +++ b/integrations/react/src/stable/useActions.ts @@ -64,8 +64,8 @@ export type UseActionsOutputType = { * Remove top activity */ pop(): void; - pop(options: { animate?: boolean }): void; - pop(count: number, options?: { animate?: boolean }): void; + pop(options: { animate?: boolean, skipActiveState?: boolean }): void; + pop(count: number, options: { animate?: boolean }): void; }; export function useActions< @@ -106,11 +106,11 @@ export function useActions< }; }, pop( - count?: number | { animate?: boolean } | undefined, + count?: number | { animate?: boolean, skipActiveState?: boolean } | undefined, options?: { animate?: boolean } | undefined, ) { let _count = 1; - let _options: { animate?: boolean } = {}; + let _options: { animate?: boolean, skipActiveState?: boolean } = {}; if (typeof count === "object") { _options = { @@ -123,13 +123,14 @@ export function useActions< if (options) { _options = { ...options, + skipActiveState: false, }; } for (let i = 0; i < _count; i += 1) { coreActions?.pop({ - skipExitActiveState: - i === 0 ? parseActionOptions(_options).skipActiveState : true, + skipExitActiveState: _options.skipActiveState || + (i === 0 ? parseActionOptions(_options).skipActiveState : true), }); } }, From c6a5ddcdae85e3e24845021d734e45d1144c33e9 Mon Sep 17 00:00:00 2001 From: jochongs Date: Tue, 30 Dec 2025 14:59:31 +0900 Subject: [PATCH 2/2] fix(react): correct swipe back completion ratio to 1 --- extensions/react-ui-core/src/useStyleEffectSwipeBack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts index 0b5a579d5..28bb397ca 100644 --- a/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts +++ b/extensions/react-ui-core/src/useStyleEffectSwipeBack.ts @@ -253,7 +253,7 @@ export function useStyleEffectSwipeBack({ renderComponent(renderX, ratio); if (targetX - currX < 10) { - renderComponent(targetX, 100); + renderComponent(targetX, 1); resolve(); } else { animationId = requestAnimationFrame(animateSwipeBackSuccess);