UX: Rewrite overscroll detection (#6271)
This commit is contained in:
parent
0b6c61f9d0
commit
591c526be3
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user