[Refactoring] Sticker View: Use Intersection Observer to detect shared canvas visibility

This commit is contained in:
Alexander Zinchuk 2023-04-23 18:33:07 +04:00
parent dba6963c34
commit 0ab0c13d87
6 changed files with 123 additions and 71 deletions

View File

@ -15,6 +15,8 @@ import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../hooks/
import useBackgroundMode, { isBackgroundModeActive } from '../../hooks/useBackgroundMode';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import { useStateRef } from '../../hooks/useStateRef';
import useSharedIntersectionObserver from '../../hooks/useSharedIntersectionObserver';
import useThrottledCallback from '../../hooks/useThrottledCallback';
export type OwnProps = {
ref?: RefObject<HTMLDivElement>;
@ -46,8 +48,8 @@ let RLottie: RLottieClass;
// Time for the main interface to completely load
const LOTTIE_LOAD_DELAY = 3000;
const THROTTLE_MS = 150;
const ID_STORE = {};
const ANIMATION_END_TIMEOUT = 500;
async function ensureLottie() {
if (!lottiePromise) {
@ -98,76 +100,73 @@ const AnimatedSticker: FC<OwnProps> = ({
const playRef = useStateRef(play);
const playSegmentRef = useStateRef(playSegment);
const isUnmountedRef = useRef();
const isUnmountedRef = useRef(false);
useEffect(() => {
return () => {
isUnmountedRef.current = true;
};
}, []);
const init = useCallback(() => {
if (
animationRef.current
|| isUnmountedRef.current
|| !tgsUrl
|| (sharedCanvas && (!sharedCanvasCoords || !sharedCanvas.offsetWidth || !sharedCanvas.offsetHeight))
) {
return;
}
const container = containerRef.current || sharedCanvas;
if (!container) {
return;
}
const newAnimation = RLottie.init(
tgsUrl,
container,
renderId || generateIdFor(ID_STORE, true),
viewId,
{
noLoop,
size,
quality,
isLowPriority,
coords: sharedCanvasCoords,
},
color,
onLoad,
onEnded,
onLoop,
);
if (speed) {
newAnimation.setSpeed(speed);
}
setAnimation(newAnimation);
animationRef.current = newAnimation;
}, [
color, isLowPriority, noLoop, onEnded, onLoad, onLoop, quality,
renderId, sharedCanvas, sharedCanvasCoords, size, speed, tgsUrl, viewId,
]);
useEffect(() => {
if (animation || !tgsUrl || (sharedCanvas && !sharedCanvasCoords)) {
return;
}
const exec = () => {
if (isUnmountedRef.current) {
return;
}
const container = containerRef.current || sharedCanvas;
if (!container) {
return;
}
// Wait until element is properly mounted
if (sharedCanvas && !sharedCanvas.offsetParent) {
// `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow
setTimeout(() => requestMeasure(exec), ANIMATION_END_TIMEOUT);
return;
}
const newAnimation = RLottie.init(
tgsUrl,
container,
renderId || generateIdFor(ID_STORE, true),
viewId,
{
noLoop,
size,
quality,
isLowPriority,
coords: sharedCanvasCoords,
},
color,
onLoad,
onEnded,
onLoop,
);
if (speed) {
newAnimation.setSpeed(speed);
}
setAnimation(newAnimation);
animationRef.current = newAnimation;
};
if (RLottie) {
exec();
init();
} else {
ensureLottie().then(() => {
requestMeasure(() => {
if (containerRef.current) {
exec();
}
});
requestMeasure(init);
});
}
}, [
animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, viewId,
sharedCanvas, sharedCanvasCoords,
]);
}, [animation, init, sharedCanvas, sharedCanvasCoords, tgsUrl]);
const throttledInit = useThrottledCallback(init, [init], THROTTLE_MS);
useSharedIntersectionObserver(sharedCanvas, throttledInit);
useEffect(() => {
if (!animation) return;

View File

@ -691,7 +691,7 @@ const Message: FC<OwnProps & StateProps> = ({
const throttledResize = useThrottledCallback(handleResize, [handleResize], THROTTLE_MS, false);
useResizeObserver(shouldFocusOnResize ? ref : undefined, throttledResize);
useResizeObserver(ref, throttledResize, !shouldFocusOnResize);
useEffect(() => {
const bottomMarker = bottomMarkerRef.current;

View File

@ -1,15 +1,14 @@
import {
useCallback, useEffect, useMemo, useState,
} from '../lib/teact/teact';
import { requestMeasure } from '../lib/fasterdom/fasterdom';
import { round } from '../util/math';
import useResizeObserver from './useResizeObserver';
import useThrottledCallback from './useThrottledCallback';
import useSharedIntersectionObserver from './useSharedIntersectionObserver';
const ANIMATION_END_TIMEOUT = 500;
const THROTTLE_MS = 300;
const THROTTLE_MS = 150;
export default function useBoundsInSharedCanvas(
containerRef: React.RefObject<HTMLDivElement>,
@ -27,9 +26,7 @@ export default function useBoundsInSharedCanvas(
}
// Wait until elements are properly mounted
if (!container.offsetParent || !canvas.offsetParent) {
// `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow
setTimeout(() => requestMeasure(recalculate), ANIMATION_END_TIMEOUT);
if (!canvas.offsetWidth || !canvas.offsetHeight) {
return;
}
@ -49,6 +46,7 @@ export default function useBoundsInSharedCanvas(
const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS);
useResizeObserver(sharedCanvasRef, throttledRecalculate);
useSharedIntersectionObserver(sharedCanvasRef, throttledRecalculate);
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);

View File

@ -12,12 +12,12 @@ import useSyncEffect from './useSyncEffect';
const TRANSITION_PROPERTY = 'color';
const TRANSITION_STYLE = `60ms ${TRANSITION_PROPERTY} linear`;
export default function useDynamicColorListener(ref?: React.RefObject<HTMLElement>, isDisabled?: boolean) {
export default function useDynamicColorListener(ref: React.RefObject<HTMLElement>, isDisabled?: boolean) {
const [hexColor, setHexColor] = useState<string | undefined>();
const rgbColorRef = useRef<[number, number, number] | undefined>();
const updateColor = useCallback(() => {
if (!ref?.current || isDisabled) {
if (!ref.current || isDisabled) {
setHexColor(undefined);
return;
}
@ -28,7 +28,7 @@ export default function useDynamicColorListener(ref?: React.RefObject<HTMLElemen
// Element does not receive `transitionend` event if parent has `display: none`.
// We will receive `resize` event when parent is shown again.
useResizeObserver(!isDisabled ? ref : undefined, updateColor);
useResizeObserver(ref, updateColor, isDisabled);
// Update RGB color only when hex color changes
useSyncEffect(() => {
@ -42,7 +42,7 @@ export default function useDynamicColorListener(ref?: React.RefObject<HTMLElemen
}, [hexColor]);
useLayoutEffect(() => {
const el = ref?.current;
const el = ref.current;
if (!el || isDisabled) {
return undefined;
}
@ -55,7 +55,7 @@ export default function useDynamicColorListener(ref?: React.RefObject<HTMLElemen
}, [isDisabled, ref]);
useEffect(() => {
const el = ref?.current;
const el = ref.current;
if (!el) {
return undefined;
}

View File

@ -3,25 +3,30 @@ import { useEffect } from '../lib/teact/teact';
import type { CallbackManager } from '../util/callbacks';
import { createCallbackManager } from '../util/callbacks';
import { useStateRef } from './useStateRef';
const elementObserverMap = new Map<HTMLElement, [ResizeObserver, CallbackManager]>();
export default function useResizeObserver(
ref: React.RefObject<HTMLElement> | undefined,
onResize: (entry: ResizeObserverEntry) => void,
isDisabled = false,
) {
const onResizeRef = useStateRef(onResize);
useEffect(() => {
if (!('ResizeObserver' in window) || !ref?.current) {
const el = ref?.current;
if (!el || isDisabled) {
return undefined;
}
const el = ref.current;
const callback: ResizeObserverCallback = ([entry]) => {
// Ignore updates when element is not properly mounted (`display: none`)
if (entry.contentRect.width === 0 && entry.contentRect.height === 0) {
return;
}
onResize(entry);
onResizeRef.current(entry);
};
let [observer, callbackManager] = elementObserverMap.get(el) || [undefined, undefined];
@ -41,5 +46,5 @@ export default function useResizeObserver(
elementObserverMap.delete(el);
}
};
}, [onResize, ref]);
}, [isDisabled, onResizeRef, ref]);
}

View File

@ -0,0 +1,50 @@
import { useEffect } from '../lib/teact/teact';
import type { CallbackManager } from '../util/callbacks';
import { createCallbackManager } from '../util/callbacks';
import { useStateRef } from './useStateRef';
const elementObserverMap = new Map<HTMLElement, [IntersectionObserver, CallbackManager]>();
export default function useSharedIntersectionObserver(
refOrElement: React.RefObject<HTMLElement> | HTMLElement | undefined,
onIntersectionChange: (entry: IntersectionObserverEntry) => void,
isDisabled = false,
) {
const onIntersectionChangeRef = useStateRef(onIntersectionChange);
useEffect(() => {
const el = refOrElement && 'current' in refOrElement ? refOrElement.current : refOrElement;
if (!el || isDisabled) {
return undefined;
}
const callback: IntersectionObserverCallback = ([entry]) => {
// Ignore updates when element is not properly mounted (`display: none`)
if (!(entry.target as HTMLElement).offsetWidth || !(entry.target as HTMLElement).offsetHeight) {
return;
}
onIntersectionChangeRef.current(entry);
};
let [observer, callbackManager] = elementObserverMap.get(el) || [undefined, undefined];
if (!observer) {
callbackManager = createCallbackManager();
observer = new IntersectionObserver(callbackManager.runCallbacks);
elementObserverMap.set(el, [observer, callbackManager]);
observer.observe(el);
}
callbackManager!.addCallback(callback);
return () => {
callbackManager!.removeCallback(callback);
if (!callbackManager!.hasCallbacks()) {
observer!.unobserve(el);
observer!.disconnect();
elementObserverMap.delete(el);
}
};
}, [isDisabled, onIntersectionChangeRef, refOrElement]);
}