UX: Rewrite overscroll detection (#6271)

This commit is contained in:
zubiden 2025-09-30 16:52:27 +02:00 committed by Alexander Zinchuk
parent 0b6c61f9d0
commit 591c526be3
4 changed files with 193 additions and 109 deletions

View File

@ -215,7 +215,7 @@ const ChatList: FC<OwnProps> = ({
toggleStoryRibbon({ isShown: false, isArchived }); toggleStoryRibbon({ isShown: false, isArchived });
}); });
const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved); useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved);
function renderChats() { function renderChats() {
const viewportOffset = orderedIds!.indexOf(viewportIds![0]); const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
@ -254,7 +254,6 @@ const ChatList: FC<OwnProps> = ({
itemSelector=".ListItem:not(.chat-item-archive)" itemSelector=".ListItem:not(.chat-item-archive)"
preloadBackwards={CHAT_LIST_SLICE} preloadBackwards={CHAT_LIST_SLICE}
withAbsolutePositioning withAbsolutePositioning
beforeChildren={renderedOverflowTrigger}
maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight} maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight}
onLoadMore={getMore} onLoadMore={getMore}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View File

@ -25,7 +25,6 @@ import { MEMBERS_SLICE, PROFILE_SENSITIVE_AREA, SHARED_MEDIA_SLICE, SLIDE_TRANSI
import { selectActiveGiftsCollectionId } from '../../global/selectors/payments'; import { selectActiveGiftsCollectionId } from '../../global/selectors/payments';
const CONTENT_PANEL_SHOW_DELAY = 300; const CONTENT_PANEL_SHOW_DELAY = 300;
import { forceMutation } from '../../lib/fasterdom/fasterdom.ts';
import { import {
getHasAdminRight, getHasAdminRight,
getIsDownloading, getIsDownloading,
@ -71,7 +70,6 @@ import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { captureEvents, SwipeDirection } from '../../util/captureEvents'; import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import { isUserId } from '../../util/entities/ids'; import { isUserId } from '../../util/entities/ids';
import { stopScrollInertia } from '../../util/resetScroll.ts';
import { resolveTransitionName } from '../../util/resolveTransitionName.ts'; import { resolveTransitionName } from '../../util/resolveTransitionName.ts';
import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets'; import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
@ -531,12 +529,7 @@ const Profile: FC<OwnProps & StateProps> = ({
const handleCollapseProfile = useLastCallback(() => { const handleCollapseProfile = useLastCallback(() => {
if (!isProfileExpanded) return; if (!isProfileExpanded) return;
const scrollContainer = containerRef.current;
startViewTransition(VTT_RIGHT_PROFILE_COLLAPSE, () => { startViewTransition(VTT_RIGHT_PROFILE_COLLAPSE, () => {
if (!scrollContainer) return;
forceMutation(() => {
stopScrollInertia(scrollContainer);
}, scrollContainer);
collapseProfile(); collapseProfile();
}); });
}); });
@ -622,7 +615,7 @@ const Profile: FC<OwnProps & StateProps> = ({
resetGiftProfileFilter({ peerId: chatId }); resetGiftProfileFilter({ peerId: chatId });
}); });
const renderedOverflowTrigger = useTopOverscroll( useTopOverscroll(
containerRef, handleExpandProfile, handleCollapseProfile, !hasAvatar, containerRef, handleExpandProfile, handleCollapseProfile, !hasAvatar,
); );
@ -1114,7 +1107,6 @@ const Profile: FC<OwnProps & StateProps> = ({
itemSelector={itemSelector} itemSelector={itemSelector}
items={canRenderContent ? viewportIds : undefined} items={canRenderContent ? viewportIds : undefined}
cacheBuster={cacheBuster} cacheBuster={cacheBuster}
beforeChildren={renderedOverflowTrigger}
sensitiveArea={PROFILE_SENSITIVE_AREA} sensitiveArea={PROFILE_SENSITIVE_AREA}
preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0} preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0}
// To prevent scroll jumps caused by reordering member list // To prevent scroll jumps caused by reordering member list

View File

@ -1,16 +1,34 @@
import type { ElementRef } from '../../lib/teact/teact'; import { type ElementRef, useEffect, useRef, useSignal } from '@teact';
import { useEffect, useRef } from '../../lib/teact/teact';
import { forceMutation, requestMutation } from '../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { IS_TAURI } from '../../util/browser/globalEnvironment'; import stopEvent from '../../util/stopEvent';
import { IS_IOS, IS_SAFARI } from '../../util/browser/windowEnvironment';
import { stopScrollInertia } from '../../util/resetScroll';
import useDebouncedCallback from '../useDebouncedCallback';
import useLastCallback from '../useLastCallback'; import useLastCallback from '../useLastCallback';
const MOUSE_WHEEL_DEBOUNCE = 250; type State = 'overscroll' | 'animating' | 'normal';
const TRIGGER_HEIGHT = 1;
const INERTIA_THRESHOLD = 100; type ActiveScrollContext = {
lastDeltas: number[];
lastAverageDelta: number;
isStartedAtTop: boolean;
resetStartTopAt?: number;
timeout: number | undefined;
};
const LAST_DELTA_COUNT = 7;
const ACTIVE_SCROLL_RESET_TIMEOUT = 100;
const NEW_INPUT_DELTA_THRESHOLD = 7;
const OVERSCROLL_CONTAINER_CLASS = 'no-overscroll';
const NO_TOUCH_CONTAINER_CLASS = 'no-touch';
const TRANSITION_DURATION = 350;
const DRAG_TRIGGER_DISTANCE = 75;
const initialActiveScrollContext: ActiveScrollContext = {
lastDeltas: new Array(LAST_DELTA_COUNT).fill(0),
lastAverageDelta: 0,
isStartedAtTop: false,
resetStartTopAt: undefined,
timeout: undefined,
};
export default function useTopOverscroll( export default function useTopOverscroll(
containerRef: ElementRef<HTMLDivElement>, containerRef: ElementRef<HTMLDivElement>,
@ -18,121 +36,188 @@ export default function useTopOverscroll(
onReset?: AnyToVoidFunction, onReset?: AnyToVoidFunction,
isDisabled?: boolean, isDisabled?: boolean,
) { ) {
const overscrollTriggerRef = useRef<HTMLDivElement>(); const [getState, setState] = useSignal<State>('normal');
const activeScrollRef = useRef<ActiveScrollContext>({ ...initialActiveScrollContext });
const transitionTimeoutRef = useRef<number | undefined>();
const touchStartYRef = useRef<number | undefined>();
const isTriggerJustEnabled = useRef(false); const triggerOverscroll = useLastCallback(() => {
const lastScrollTopRef = useRef(0); clearTimeout(transitionTimeoutRef.current);
const isTriggerEnabledRef = useRef(false); setState('overscroll');
const lastIsOnTopRef = useRef(true); onOverscroll?.();
const lastScrollAtRef = useRef(0);
const isReturningOverscrollRef = useRef(false);
const lastCalledStateRef = useRef<'overscroll' | 'reset' | undefined>(undefined);
const enableOverscrollTrigger = useLastCallback((noScrollInertiaStop = false) => {
if (isTriggerEnabledRef.current) return;
if (!overscrollTriggerRef.current || !containerRef.current) return;
overscrollTriggerRef.current.style.display = 'block';
containerRef.current.scrollTop = TRIGGER_HEIGHT;
if (!IS_SAFARI && !noScrollInertiaStop && !IS_TAURI) {
stopScrollInertia(containerRef.current);
}
isTriggerJustEnabled.current = true;
lastScrollTopRef.current = TRIGGER_HEIGHT;
isTriggerEnabledRef.current = true;
lastIsOnTopRef.current = true;
}); });
const disableOverscrollTrigger = useLastCallback(() => { const triggerReset = useLastCallback(() => {
if (!isTriggerEnabledRef.current) return; setState('animating');
if (!overscrollTriggerRef.current) return; transitionTimeoutRef.current = window.setTimeout(() => {
setState('normal');
overscrollTriggerRef.current.style.display = 'none'; }, TRANSITION_DURATION);
onReset?.();
isTriggerEnabledRef.current = false;
}); });
const handleScroll = useLastCallback(() => { const scheduleResetActiveScroll = useLastCallback((timeout: number) => {
if (!containerRef.current) return; clearTimeout(activeScrollRef.current.timeout);
activeScrollRef.current.timeout = window.setTimeout(() => {
if (isTriggerJustEnabled.current) { activeScrollRef.current = { ...initialActiveScrollContext };
isTriggerJustEnabled.current = false; }, timeout);
});
const handleWheel = useLastCallback((e: WheelEvent) => {
const container = containerRef.current;
if (!container || e.defaultPrevented) {
return; return;
} }
const newScrollTop = containerRef.current.scrollTop; const { deltaY } = e;
const isMovingDown = newScrollTop > lastScrollTopRef.current; const { scrollTop } = container;
const isMovingUp = newScrollTop < lastScrollTopRef.current; const state = getState();
const isOnTop = newScrollTop === 0;
const lastEventDelay = Date.now() - lastScrollAtRef.current;
if (overscrollTriggerRef.current) { const activeScroll = activeScrollRef.current;
if (isOnTop && !isTriggerEnabledRef.current) { const lastAverageDelta = activeScroll.lastAverageDelta;
forceMutation(enableOverscrollTrigger, [containerRef.current, overscrollTriggerRef.current]);
return; const isStarting = activeScroll.lastDeltas.at(-1) === 0
|| (activeScroll.resetStartTopAt && Date.now() >= activeScroll.resetStartTopAt);
if (scrollTop === 0 && isStarting) {
activeScroll.isStartedAtTop = true;
activeScroll.resetStartTopAt = undefined;
}
const lastDeltas = activeScrollRef.current.lastDeltas.slice(); // Copy
lastDeltas.push(deltaY);
if (lastDeltas.length > LAST_DELTA_COUNT) {
lastDeltas.shift();
}
activeScrollRef.current.lastDeltas = lastDeltas;
const currentAverageDelta = lastDeltas.reduce((a, b) => a + b, 0) / lastDeltas.length;
activeScrollRef.current.lastAverageDelta = currentAverageDelta;
const isNewInput = Math.abs(currentAverageDelta) - Math.abs(lastAverageDelta) > NEW_INPUT_DELTA_THRESHOLD;
scheduleResetActiveScroll(ACTIVE_SCROLL_RESET_TIMEOUT);
// If we're at the top and scrolling up
if (scrollTop === 0 && deltaY < 0 && state !== 'overscroll') {
if (!activeScroll.resetStartTopAt) {
// Schedule delta reset, so we would respond to new input with `isStartedAtTop` flag set
activeScroll.resetStartTopAt = Date.now() + ACTIVE_SCROLL_RESET_TIMEOUT;
} }
forceMutation(disableOverscrollTrigger, overscrollTriggerRef.current); // Only trigger overscroll on new input, ignore momentum events
if (isNewInput && activeScroll.isStartedAtTop) {
triggerOverscroll();
}
return;
} }
if (lastCalledStateRef.current !== 'overscroll' && isMovingUp // Ignore scroll events during collapse animation
&& ( if (state === 'animating' && deltaY > 0) {
(lastIsOnTopRef.current && lastEventDelay > INERTIA_THRESHOLD) stopEvent(e);
|| (newScrollTop < 0 && isReturningOverscrollRef.current) // Overscroll repeated by the user return;
)) {
onOverscroll?.();
lastCalledStateRef.current = 'overscroll';
} else if (lastCalledStateRef.current !== 'reset' && isMovingDown && newScrollTop > 0) {
onReset?.();
lastCalledStateRef.current = 'reset';
} }
lastScrollTopRef.current = newScrollTop; // If we're overscrolled, any down wheel event should reset
lastIsOnTopRef.current = isOnTop; if (state === 'overscroll' && deltaY > 0) {
lastScrollAtRef.current = Date.now(); triggerReset();
isReturningOverscrollRef.current = isMovingDown && newScrollTop < 0; stopEvent(e);
return;
}
}); });
// Handle non-scrollable container const handleTouchStart = useLastCallback((e: TouchEvent) => {
const handleWheel = useDebouncedCallback((event: WheelEvent) => {
if (!containerRef.current) return;
const container = containerRef.current; const container = containerRef.current;
if (!container || e.touches.length !== 1) return;
const isScrollable = container.scrollHeight > container.offsetHeight; const { scrollTop } = container;
if (isScrollable || event.deltaY === 0) return; const state = getState();
if (lastCalledStateRef.current !== 'overscroll' && event.deltaY < 0) { // Register touch start position when at top or in overscroll state
onOverscroll?.(); if (scrollTop === 0 || state === 'overscroll') {
lastCalledStateRef.current = 'overscroll'; touchStartYRef.current = e.touches[0].clientY;
} else if (lastCalledStateRef.current !== 'reset') {
onReset?.();
lastCalledStateRef.current = 'reset';
} }
}, [containerRef, onOverscroll, onReset], MOUSE_WHEEL_DEBOUNCE); });
const handleTouchMove = useLastCallback((e: TouchEvent) => {
const container = containerRef.current;
const startY = touchStartYRef.current;
if (!container || startY === undefined || e.touches.length !== 1) return;
const { scrollTop } = container;
const state = getState();
const currentY = e.touches[0].clientY;
const deltaY = currentY - startY;
if (state === 'animating') {
return;
}
// If we're at the top and dragging down by more than trigger distance
if (scrollTop === 0 && deltaY > DRAG_TRIGGER_DISTANCE && state !== 'overscroll') {
triggerOverscroll();
touchStartYRef.current = undefined; // Reset to prevent multiple triggers
return;
}
// If we're overscrolled and dragging up by more than trigger distance, reset
if (state === 'overscroll' && deltaY < -DRAG_TRIGGER_DISTANCE) {
triggerReset();
touchStartYRef.current = undefined; // Reset to prevent multiple triggers
return;
}
});
const handleTouchEnd = useLastCallback(() => {
touchStartYRef.current = undefined;
});
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return undefined; if (isDisabled || !container) return;
requestMutation(() => {
if (container.scrollTop === 0) { container.classList.add(OVERSCROLL_CONTAINER_CLASS);
requestMutation(() => { });
enableOverscrollTrigger(true);
});
}
container.addEventListener('scroll', handleScroll, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
return () => { return () => {
container.removeEventListener('scroll', handleScroll); requestMutation(() => {
container.removeEventListener('wheel', handleWheel); container.classList.remove(OVERSCROLL_CONTAINER_CLASS);
});
}; };
}, [containerRef, handleWheel]); }, [containerRef, isDisabled]);
return !IS_IOS && !isDisabled ? ( useEffect(() => {
<div ref={overscrollTriggerRef} className="overscroll-trigger" key="overscroll-trigger" /> const container = containerRef.current;
) : undefined; if (isDisabled || !container) return;
requestMutation(() => {
container.classList.toggle(NO_TOUCH_CONTAINER_CLASS, getState() !== 'normal');
});
return () => {
requestMutation(() => {
container.classList.remove(NO_TOUCH_CONTAINER_CLASS);
});
};
}, [containerRef, isDisabled, getState]);
useEffect(() => {
const container = containerRef.current;
if (isDisabled || !container) {
return undefined;
}
container.addEventListener('wheel', handleWheel, { passive: getState() === 'normal' });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
return () => {
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
const activeScroll = activeScrollRef.current;
if (activeScroll?.timeout) clearTimeout(activeScroll.timeout);
};
}, [containerRef, handleWheel, handleTouchStart, handleTouchMove, handleTouchEnd, getState, isDisabled]);
} }

View File

@ -226,6 +226,14 @@ body:not(.is-ios) {
} }
} }
.no-overscroll {
overscroll-behavior: none;
}
.no-touch {
touch-action: none;
}
.emoji-small { .emoji-small {
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;