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 });
});
const renderedOverflowTrigger = useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved);
useTopOverscroll(containerRef, handleShowStoryRibbon, handleHideStoryRibbon, isSaved);
function renderChats() {
const viewportOffset = orderedIds!.indexOf(viewportIds![0]);
@ -254,7 +254,6 @@ const ChatList: FC<OwnProps> = ({
itemSelector=".ListItem:not(.chat-item-archive)"
preloadBackwards={CHAT_LIST_SLICE}
withAbsolutePositioning
beforeChildren={renderedOverflowTrigger}
maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight}
onLoadMore={getMore}
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';
const CONTENT_PANEL_SHOW_DELAY = 300;
import { forceMutation } from '../../lib/fasterdom/fasterdom.ts';
import {
getHasAdminRight,
getIsDownloading,
@ -71,7 +70,6 @@ import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import { captureEvents, SwipeDirection } from '../../util/captureEvents';
import { isUserId } from '../../util/entities/ids';
import { stopScrollInertia } from '../../util/resetScroll.ts';
import { resolveTransitionName } from '../../util/resolveTransitionName.ts';
import { LOCAL_TGS_URLS } from '../common/helpers/animatedAssets';
import renderText from '../common/helpers/renderText';
@ -531,12 +529,7 @@ const Profile: FC<OwnProps & StateProps> = ({
const handleCollapseProfile = useLastCallback(() => {
if (!isProfileExpanded) return;
const scrollContainer = containerRef.current;
startViewTransition(VTT_RIGHT_PROFILE_COLLAPSE, () => {
if (!scrollContainer) return;
forceMutation(() => {
stopScrollInertia(scrollContainer);
}, scrollContainer);
collapseProfile();
});
});
@ -622,7 +615,7 @@ const Profile: FC<OwnProps & StateProps> = ({
resetGiftProfileFilter({ peerId: chatId });
});
const renderedOverflowTrigger = useTopOverscroll(
useTopOverscroll(
containerRef, handleExpandProfile, handleCollapseProfile, !hasAvatar,
);
@ -1114,7 +1107,6 @@ const Profile: FC<OwnProps & StateProps> = ({
itemSelector={itemSelector}
items={canRenderContent ? viewportIds : undefined}
cacheBuster={cacheBuster}
beforeChildren={renderedOverflowTrigger}
sensitiveArea={PROFILE_SENSITIVE_AREA}
preloadBackwards={canRenderContent ? (resultType === 'members' ? MEMBERS_SLICE : SHARED_MEDIA_SLICE) : 0}
// To prevent scroll jumps caused by reordering member list

View File

@ -1,16 +1,34 @@
import type { ElementRef } from '../../lib/teact/teact';
import { useEffect, useRef } from '../../lib/teact/teact';
import { type ElementRef, useEffect, useRef, useSignal } from '@teact';
import { forceMutation, requestMutation } from '../../lib/fasterdom/fasterdom';
import { IS_TAURI } from '../../util/browser/globalEnvironment';
import { IS_IOS, IS_SAFARI } from '../../util/browser/windowEnvironment';
import { stopScrollInertia } from '../../util/resetScroll';
import useDebouncedCallback from '../useDebouncedCallback';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import stopEvent from '../../util/stopEvent';
import useLastCallback from '../useLastCallback';
const MOUSE_WHEEL_DEBOUNCE = 250;
const TRIGGER_HEIGHT = 1;
const INERTIA_THRESHOLD = 100;
type State = 'overscroll' | 'animating' | 'normal';
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(
containerRef: ElementRef<HTMLDivElement>,
@ -18,121 +36,188 @@ export default function useTopOverscroll(
onReset?: AnyToVoidFunction,
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 lastScrollTopRef = useRef(0);
const isTriggerEnabledRef = useRef(false);
const lastIsOnTopRef = useRef(true);
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 triggerOverscroll = useLastCallback(() => {
clearTimeout(transitionTimeoutRef.current);
setState('overscroll');
onOverscroll?.();
});
const disableOverscrollTrigger = useLastCallback(() => {
if (!isTriggerEnabledRef.current) return;
if (!overscrollTriggerRef.current) return;
overscrollTriggerRef.current.style.display = 'none';
isTriggerEnabledRef.current = false;
const triggerReset = useLastCallback(() => {
setState('animating');
transitionTimeoutRef.current = window.setTimeout(() => {
setState('normal');
}, TRANSITION_DURATION);
onReset?.();
});
const handleScroll = useLastCallback(() => {
if (!containerRef.current) return;
if (isTriggerJustEnabled.current) {
isTriggerJustEnabled.current = false;
const scheduleResetActiveScroll = useLastCallback((timeout: number) => {
clearTimeout(activeScrollRef.current.timeout);
activeScrollRef.current.timeout = window.setTimeout(() => {
activeScrollRef.current = { ...initialActiveScrollContext };
}, timeout);
});
const handleWheel = useLastCallback((e: WheelEvent) => {
const container = containerRef.current;
if (!container || e.defaultPrevented) {
return;
}
const newScrollTop = containerRef.current.scrollTop;
const isMovingDown = newScrollTop > lastScrollTopRef.current;
const isMovingUp = newScrollTop < lastScrollTopRef.current;
const isOnTop = newScrollTop === 0;
const lastEventDelay = Date.now() - lastScrollAtRef.current;
const { deltaY } = e;
const { scrollTop } = container;
const state = getState();
if (overscrollTriggerRef.current) {
if (isOnTop && !isTriggerEnabledRef.current) {
forceMutation(enableOverscrollTrigger, [containerRef.current, overscrollTriggerRef.current]);
return;
const activeScroll = activeScrollRef.current;
const lastAverageDelta = activeScroll.lastAverageDelta;
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
&& (
(lastIsOnTopRef.current && lastEventDelay > INERTIA_THRESHOLD)
|| (newScrollTop < 0 && isReturningOverscrollRef.current) // Overscroll repeated by the user
)) {
onOverscroll?.();
lastCalledStateRef.current = 'overscroll';
} else if (lastCalledStateRef.current !== 'reset' && isMovingDown && newScrollTop > 0) {
onReset?.();
lastCalledStateRef.current = 'reset';
// Ignore scroll events during collapse animation
if (state === 'animating' && deltaY > 0) {
stopEvent(e);
return;
}
lastScrollTopRef.current = newScrollTop;
lastIsOnTopRef.current = isOnTop;
lastScrollAtRef.current = Date.now();
isReturningOverscrollRef.current = isMovingDown && newScrollTop < 0;
// If we're overscrolled, any down wheel event should reset
if (state === 'overscroll' && deltaY > 0) {
triggerReset();
stopEvent(e);
return;
}
});
// Handle non-scrollable container
const handleWheel = useDebouncedCallback((event: WheelEvent) => {
if (!containerRef.current) return;
const handleTouchStart = useLastCallback((e: TouchEvent) => {
const container = containerRef.current;
if (!container || e.touches.length !== 1) return;
const isScrollable = container.scrollHeight > container.offsetHeight;
if (isScrollable || event.deltaY === 0) return;
const { scrollTop } = container;
const state = getState();
if (lastCalledStateRef.current !== 'overscroll' && event.deltaY < 0) {
onOverscroll?.();
lastCalledStateRef.current = 'overscroll';
} else if (lastCalledStateRef.current !== 'reset') {
onReset?.();
lastCalledStateRef.current = 'reset';
// Register touch start position when at top or in overscroll state
if (scrollTop === 0 || state === 'overscroll') {
touchStartYRef.current = e.touches[0].clientY;
}
}, [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(() => {
const container = containerRef.current;
if (!container) return undefined;
if (container.scrollTop === 0) {
requestMutation(() => {
enableOverscrollTrigger(true);
});
}
container.addEventListener('scroll', handleScroll, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
if (isDisabled || !container) return;
requestMutation(() => {
container.classList.add(OVERSCROLL_CONTAINER_CLASS);
});
return () => {
container.removeEventListener('scroll', handleScroll);
container.removeEventListener('wheel', handleWheel);
requestMutation(() => {
container.classList.remove(OVERSCROLL_CONTAINER_CLASS);
});
};
}, [containerRef, handleWheel]);
}, [containerRef, isDisabled]);
return !IS_IOS && !isDisabled ? (
<div ref={overscrollTriggerRef} className="overscroll-trigger" key="overscroll-trigger" />
) : undefined;
useEffect(() => {
const container = containerRef.current;
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 {
overflow: hidden;
display: inline-block;