[Perf] Transition: Optimize slide transitions (#1562)

This commit is contained in:
Alexander Zinchuk 2021-12-10 18:32:30 +01:00
parent dee7d2163f
commit 3375954d5f
20 changed files with 229 additions and 131 deletions

View File

@ -53,11 +53,15 @@ const ProfileInfo: FC<OwnProps & StateProps & DispatchProps> = ({
loadFullUser, loadFullUser,
openMediaViewer, openMediaViewer,
}) => { }) => {
const lang = useLang();
const { id: userId } = user || {}; const { id: userId } = user || {};
const { id: chatId } = chat || {}; const { id: chatId } = chat || {};
const fullName = user ? getUserFullName(user) : (chat ? chat.title : ''); const fullName = user ? getUserFullName(user) : (chat ? chat.title : '');
const photos = user?.photos || chat?.photos || []; const photos = user?.photos || chat?.photos || [];
const slideAnimation = animationLevel >= 1 ? 'slide' : 'none'; const slideAnimation = animationLevel >= 1
? (lang.isRtl ? 'slide-optimized-rtl' : 'slide-optimized')
: 'none';
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const isFirst = isSavedMessages || photos.length <= 1 || currentPhotoIndex === 0; const isFirst = isSavedMessages || photos.length <= 1 || currentPhotoIndex === 0;
@ -70,8 +74,6 @@ const ProfileInfo: FC<OwnProps & StateProps & DispatchProps> = ({
} }
}, [currentPhotoIndex, photos.length]); }, [currentPhotoIndex, photos.length]);
const lang = useLang();
useEffect(() => { useEffect(() => {
if (connectionState === 'connectionStateReady' && userId && !forceShowSelf) { if (connectionState === 'connectionStateReady' && userId && !forceShowSelf) {
loadFullUser({ userId }); loadFullUser({ userId });

View File

@ -228,7 +228,7 @@ const ChatFolders: FC<OwnProps & StateProps & DispatchProps> = ({
) : undefined} ) : undefined}
<Transition <Transition
ref={transitionRef} ref={transitionRef}
name={shouldSkipHistoryAnimations ? 'none' : lang.isRtl ? 'slide-reversed' : 'slide'} name={shouldSkipHistoryAnimations ? 'none' : lang.isRtl ? 'slide-optimized-rtl' : 'slide-optimized'}
activeKey={activeChatFolder} activeKey={activeChatFolder}
renderCount={folderTabs ? folderTabs.length : undefined} renderCount={folderTabs ? folderTabs.length : undefined}
> >

View File

@ -87,7 +87,7 @@ const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({
<div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}> <div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}>
<TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} /> <TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} />
<Transition <Transition
name={lang.isRtl ? 'slide-reversed' : 'slide'} name={lang.isRtl ? 'slide-optimized-rtl' : 'slide-optimized'}
renderCount={TRANSITION_RENDER_COUNT} renderCount={TRANSITION_RENDER_COUNT}
activeKey={currentContent} activeKey={currentContent}
> >

View File

@ -403,7 +403,9 @@ const MediaViewer: FC<StateProps & DispatchProps> = ({
// Support for swipe gestures and closing on click // Support for swipe gestures and closing on click
useEffect(() => { useEffect(() => {
const element = document.querySelector<HTMLDivElement>('.slide-container > .active, .slide-container > .to'); const element = document.querySelector<HTMLDivElement>(
'.slide-container > .Transition__slide--active, .slide-container > .to',
);
if (!element) { if (!element) {
return undefined; return undefined;
} }

View File

@ -45,7 +45,7 @@ const SCALE_VALUES = {
const ZOOM_SAFE_AREA = 150; const ZOOM_SAFE_AREA = 150;
function calculateSafeZoneOnZoom(oldScale: number, matrixData: number[], wrapper: HTMLDivElement | null) { function calculateSafeZoneOnZoom(oldScale: number, matrixData: number[], wrapper: HTMLDivElement | null) {
const image = wrapper && wrapper.querySelector('.active img'); const image = wrapper && wrapper.querySelector('.Transition__slide--active img');
if (!wrapper || !image) { if (!wrapper || !image) {
return matrixData; return matrixData;
} }

View File

@ -96,7 +96,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
} }
const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>( const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>(
'.active .media-viewer-content img, .active .media-viewer-content video', '.Transition__slide--active .media-viewer-content img, .Transition__slide--active .media-viewer-content video',
); );
if (!fromImage || !toImage) { if (!fromImage || !toImage) {
return; return;
@ -271,7 +271,7 @@ function isElementInViewport(el: HTMLElement) {
} }
function isMessageImageFullyVisible(container: HTMLElement, imageEl: HTMLElement) { function isMessageImageFullyVisible(container: HTMLElement, imageEl: HTMLElement) {
const messageListElement = document.querySelector<HTMLDivElement>('.active > .MessageList')!; const messageListElement = document.querySelector<HTMLDivElement>('.Transition__slide--active > .MessageList')!;
let imgOffsetTop = container.offsetTop + imageEl.closest<HTMLDivElement>('.content-inner, .WebPage')!.offsetTop; let imgOffsetTop = container.offsetTop + imageEl.closest<HTMLDivElement>('.content-inner, .WebPage')!.offsetTop;
if (container.id.includes('album-media-')) { if (container.id.includes('album-media-')) {
imgOffsetTop += container.parentElement!.offsetTop + container.closest<HTMLDivElement>('.Message')!.offsetTop; imgOffsetTop += container.parentElement!.offsetTop + container.closest<HTMLDivElement>('.Message')!.offsetTop;
@ -302,7 +302,7 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
switch (origin) { switch (origin) {
case MediaViewerOrigin.Album: case MediaViewerOrigin.Album:
case MediaViewerOrigin.ScheduledAlbum: case MediaViewerOrigin.ScheduledAlbum:
containerSelector = `.active > .MessageList #album-media-${message!.id}`; containerSelector = `.Transition__slide--active > .MessageList #album-media-${message!.id}`;
mediaSelector = '.full-media'; mediaSelector = '.full-media';
break; break;
@ -322,19 +322,19 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
break; break;
case MediaViewerOrigin.SettingsAvatar: case MediaViewerOrigin.SettingsAvatar:
containerSelector = '#Settings .ProfileInfo .active .ProfilePhoto'; containerSelector = '#Settings .ProfileInfo .Transition__slide--active .ProfilePhoto';
mediaSelector = 'img.avatar-media'; mediaSelector = 'img.avatar-media';
break; break;
case MediaViewerOrigin.ProfileAvatar: case MediaViewerOrigin.ProfileAvatar:
containerSelector = '#RightColumn .ProfileInfo .active .ProfilePhoto'; containerSelector = '#RightColumn .ProfileInfo .Transition__slide--active .ProfilePhoto';
mediaSelector = 'img.avatar-media'; mediaSelector = 'img.avatar-media';
break; break;
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
default: default:
containerSelector = `.active > .MessageList #message${message!.id}`; containerSelector = `.Transition__slide--active > .MessageList #message${message!.id}`;
mediaSelector = '.message-content .full-media, .message-content .thumbnail'; mediaSelector = '.message-content .full-media, .message-content .thumbnail';
} }

View File

@ -302,7 +302,7 @@
.Avatar { .Avatar {
margin-right: .625rem; margin-right: .625rem;
// TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle // TODO For some reason webpack imports `Audio.scss` second time when loading calls bundle
width: 2.5rem !important; width: 2.5rem !important;
height: 2.5rem !important; height: 2.5rem !important;
font-size: 1.0625rem; font-size: 1.0625rem;

View File

@ -97,11 +97,11 @@ const MessageContextMenu: FC<OwnProps> = ({
const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined); const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined);
const getTriggerElement = useCallback(() => { const getTriggerElement = useCallback(() => {
return document.querySelector(`.active > .MessageList div[data-message-id="${message.id}"]`); return document.querySelector(`.Transition__slide--active > .MessageList div[data-message-id="${message.id}"]`);
}, [message.id]); }, [message.id]);
const getRootElement = useCallback( const getRootElement = useCallback(
() => document.querySelector('.active > .MessageList'), () => document.querySelector('.Transition__slide--active > .MessageList'),
[], [],
); );

View File

@ -431,7 +431,7 @@ const Profile: FC<OwnProps & StateProps & DispatchProps> = ({
> >
<Transition <Transition
ref={transitionRef} ref={transitionRef}
name={lang.isRtl ? 'slide-reversed' : 'slide'} name={lang.isRtl ? 'slide-rtl' : 'slide'}
activeKey={activeKey} activeKey={activeKey}
renderCount={tabs.length} renderCount={tabs.length}
shouldRestoreHeight shouldRestoreHeight
@ -479,7 +479,7 @@ function buildInfiniteScrollItemSelector(resultType: string) {
// Used on first render // Used on first render
`.shared-media-transition > div:only-child > .${resultType}-list > .scroll-item`, `.shared-media-transition > div:only-child > .${resultType}-list > .scroll-item`,
// Used after transition // Used after transition
`.shared-media-transition > div.active > .${resultType}-list > .scroll-item`, `.shared-media-transition > .Transition__slide--active > .${resultType}-list > .scroll-item`,
].join(', '); ].join(', ');
} }

View File

@ -20,16 +20,16 @@
outline: none; outline: none;
} }
&.active { &--active {
cursor: default; cursor: default;
color: var(--color-primary); color: var(--color-primary);
i { i {
opacity: 1; opacity: 1 !important;
} }
} }
&:not(.active):hover { &:not(&--active):hover {
background: var(--color-interactive-element-hover); background: var(--color-interactive-element-hover);
} }
@ -57,10 +57,10 @@
body.is-macos & { body.is-macos & {
line-height: 1.25rem; line-height: 1.25rem;
} }
}
&.active { &__badge--active {
background: var(--color-primary); background: var(--color-primary) !important;
}
} }
i { i {

View File

@ -1,8 +1,9 @@
import React, { import React, {
FC, useLayoutEffect, useRef, memo, FC, useRef, memo, useEffect,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import forceReflow from '../../util/forceReflow';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import './Tab.scss'; import './Tab.scss';
@ -10,7 +11,7 @@ import './Tab.scss';
type OwnProps = { type OwnProps = {
className?: string; className?: string;
title: string; title: string;
active?: boolean; isActive?: boolean;
badgeCount?: number; badgeCount?: number;
isBadgeActive?: boolean; isBadgeActive?: boolean;
previousActiveTab?: number; previousActiveTab?: number;
@ -18,10 +19,15 @@ type OwnProps = {
clickArg: number; clickArg: number;
}; };
const classNames = {
active: 'Tab--active',
badgeActive: 'Tab__badge--active',
};
const Tab: FC<OwnProps> = ({ const Tab: FC<OwnProps> = ({
className, className,
title, title,
active, isActive,
badgeCount, badgeCount,
isBadgeActive, isBadgeActive,
previousActiveTab, previousActiveTab,
@ -29,56 +35,51 @@ const Tab: FC<OwnProps> = ({
clickArg, clickArg,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const tabRef = useRef<HTMLButtonElement>(null); const tabRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useEffect(() => {
if (!active || previousActiveTab === undefined) { if (!isActive || previousActiveTab === undefined) {
return; return;
} }
const tab = tabRef.current!; const tabEl = tabRef.current!;
const indicator = tab.querySelector('i')!; const prevTabEl = tabEl.parentElement!.children[previousActiveTab];
const prevTab = tab.parentElement!.children[previousActiveTab]; if (!prevTabEl) {
if (!prevTab) {
return; return;
} }
const currentIndicator = prevTab.querySelector('i')!;
currentIndicator.classList.remove('animate'); const platformEl = tabEl.querySelector('i')!;
indicator.classList.remove('animate'); const prevPlatformEl = prevTabEl.querySelector('i')!;
// We move and resize our indicator so it repeats the position and size of the previous one. // We move and resize the platform, so it repeats the position and size of the previous one
const shiftLeft = currentIndicator.parentElement!.offsetLeft - indicator.parentElement!.offsetLeft; const shiftLeft = prevPlatformEl.parentElement!.offsetLeft - platformEl.parentElement!.offsetLeft;
const scaleFactor = currentIndicator.clientWidth / indicator.clientWidth; const scaleFactor = prevPlatformEl.clientWidth / platformEl.clientWidth;
indicator.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`;
// 3 AFs needed here to synchronize animations with Transition component prevPlatformEl.classList.remove('animate');
requestAnimationFrame(() => { platformEl.classList.remove('animate');
requestAnimationFrame(() => { platformEl.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`;
requestAnimationFrame(() => { forceReflow(platformEl);
// Now we remove the transform to let it animate to its own position and size. platformEl.classList.add('animate');
indicator.classList.add('animate'); platformEl.style.transform = 'none';
indicator.style.transform = 'none';
}); prevTabEl.classList.remove(classNames.active);
}); tabEl.classList.add(classNames.active);
}); }, [isActive, previousActiveTab]);
}, [active, previousActiveTab]);
return ( return (
<button <div
type="button" className={buildClassName('Tab', className)}
className={buildClassName('Tab', className, active && 'active')}
onClick={() => onClick(clickArg)} onClick={() => onClick(clickArg)}
ref={tabRef} ref={tabRef}
> >
<span> <span>
{renderText(title)} {renderText(title)}
{!!badgeCount && ( {!!badgeCount && (
<span className={buildClassName('badge', isBadgeActive && 'active')}>{badgeCount}</span> <span className={buildClassName('badge', isBadgeActive && classNames.badgeActive)}>{badgeCount}</span>
)} )}
<i /> <i />
</span> </span>
</button> </div>
); );
}; };

View File

@ -26,5 +26,7 @@
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
// `box-shadow` prevents repaint on macOS when hovering out of scrollable container
box-shadow: 0 0 1px rgba(255, 255, 255, .01);
} }
} }

View File

@ -2,6 +2,7 @@ import React, {
FC, memo, useRef, useEffect, FC, memo, useRef, useEffect,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { IS_ANDROID, IS_IOS } from '../../util/environment';
import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal'; import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../hooks/useHorizontalScroll';
@ -25,6 +26,8 @@ type OwnProps = {
}; };
const TAB_SCROLL_THRESHOLD_PX = 16; const TAB_SCROLL_THRESHOLD_PX = 16;
// Should match duration from `--slide-transition` CSS variable
const SCROLL_DURATION = IS_IOS || IS_ANDROID ? 450 : 300;
const TabList: FC<OwnProps> = ({ const TabList: FC<OwnProps> = ({
tabs, activeTab, big, onSwitchTab, tabs, activeTab, big, onSwitchTab,
@ -38,21 +41,24 @@ const TabList: FC<OwnProps> = ({
// Scroll container to place active tab in the center // Scroll container to place active tab in the center
useEffect(() => { useEffect(() => {
const container = containerRef.current!; const container = containerRef.current!;
if (container.scrollWidth <= container.offsetWidth) { const { scrollWidth, offsetWidth, scrollLeft } = container;
if (scrollWidth <= offsetWidth) {
return; return;
} }
const activeTabElement = container.querySelector('.Tab.active') as HTMLElement | null; const activeTabElement = container.childNodes[activeTab] as HTMLElement | null;
if (activeTabElement) { if (!activeTabElement) {
const newLeft = activeTabElement.offsetLeft - (container.offsetWidth / 2) + (activeTabElement.offsetWidth / 2); return;
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
if (Math.abs(newLeft - container.scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
fastSmoothScrollHorizontal(container, newLeft);
} }
const newLeft = activeTabElement.offsetLeft - (offsetWidth / 2) + (activeTabElement.offsetWidth / 2);
// Prevent scrolling by only a couple of pixels, which doesn't look smooth
if (Math.abs(newLeft - scrollLeft) < TAB_SCROLL_THRESHOLD_PX) {
return;
}
fastSmoothScrollHorizontal(container, newLeft, SCROLL_DURATION);
}, [activeTab]); }, [activeTab]);
const lang = useLang(); const lang = useLang();
@ -67,7 +73,7 @@ const TabList: FC<OwnProps> = ({
<Tab <Tab
key={tab.title} key={tab.title}
title={lang(tab.title)} title={lang(tab.title)}
active={i === activeTab} isActive={i === activeTab}
badgeCount={tab.badgeCount} badgeCount={tab.badgeCount}
isBadgeActive={tab.isBadgeActive} isBadgeActive={tab.isBadgeActive}
previousActiveTab={previousActiveTab} previousActiveTab={previousActiveTab}

View File

@ -13,7 +13,7 @@
left: 0; left: 0;
} }
&:not(.active):not(.from):not(.to) { &:not(.Transition__slide--active):not(.from):not(.to) {
display: none !important; // Best performance when animating container display: none !important; // Best performance when animating container
//transform: scale(0); // Shortest initial delay //transform: scale(0); // Shortest initial delay
} }
@ -26,6 +26,20 @@
/* /*
* slide * slide
*/ */
&.slide-optimized,
&.slide-optimized-rtl {
// Create a new composite layer to reduce the page repaint area when switching a tab
transform: translate3d(0, 0, 0);
#root & > div {
position: absolute;
display: block !important;
top: 0;
left: 0;
transition: transform var(--slide-transition);
transform: scale(0);
}
}
&.slide { &.slide {
> .to { > .to {
@ -60,7 +74,7 @@
} }
&.slide-reversed { &.slide-rtl {
> .to { > .to {
transform: translateX(-100%); transform: translateX(-100%);
} }

View File

@ -4,11 +4,12 @@ import React, {
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getGlobal } from '../../lib/teact/teactn'; import { getGlobal } from '../../lib/teact/teactn';
import buildClassName from '../../util/buildClassName';
import forceReflow from '../../util/forceReflow';
import { waitForAnimationEnd, waitForTransitionEnd } from '../../util/cssAnimationEndListeners';
import useForceUpdate from '../../hooks/useForceUpdate'; import useForceUpdate from '../../hooks/useForceUpdate';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import buildClassName from '../../util/buildClassName';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import { waitForAnimationEnd } from '../../util/cssAnimationEndListeners';
import './Transition.scss'; import './Transition.scss';
@ -17,8 +18,8 @@ type OwnProps = {
ref?: RefObject<HTMLDivElement>; ref?: RefObject<HTMLDivElement>;
activeKey: number; activeKey: number;
name: ( name: (
'none' | 'slide' | 'slide-reversed' | 'mv-slide' | 'slide-fade' | 'zoom-fade' | 'slide-layers' 'none' | 'slide' | 'slide-rtl' | 'mv-slide' | 'slide-fade' | 'zoom-fade' | 'slide-layers'
| 'fade' | 'push-slide' | 'reveal' | 'fade' | 'push-slide' | 'reveal' | 'slide-optimized' | 'slide-optimized-rtl'
); );
direction?: 'auto' | 'inverse' | 1 | -1; direction?: 'auto' | 'inverse' | 1 | -1;
renderCount?: number; renderCount?: number;
@ -27,13 +28,17 @@ type OwnProps = {
cleanupExceptionKey?: number; cleanupExceptionKey?: number;
id?: string; id?: string;
className?: string; className?: string;
onStart?: () => void; onStart?: NoneToVoidFunction;
onStop?: () => void; onStop?: NoneToVoidFunction;
children: ChildrenFn; children: ChildrenFn;
}; };
const CLEANED_UP = Symbol('CLEANED_UP'); const CLEANED_UP = Symbol('CLEANED_UP');
const classNames = {
active: 'Transition__slide--active',
};
const Transition: FC<OwnProps> = ({ const Transition: FC<OwnProps> = ({
ref, ref,
activeKey, activeKey,
@ -85,7 +90,11 @@ const Transition: FC<OwnProps> = ({
const childElements = container.children; const childElements = container.children;
if (childElements.length === 1 && !activeKeyChanged) { if (childElements.length === 1 && !activeKeyChanged) {
childElements[0].classList.add('active'); if (name.startsWith('slide-optimized')) {
(childElements[0] as HTMLElement).style.transform = 'translate3d(0, 0, 0)';
}
childElements[0].classList.add(classNames.active);
return; return;
} }
@ -96,24 +105,44 @@ const Transition: FC<OwnProps> = ({
return; return;
} }
currentKeyRef.current = activeKey;
const isBackwards = ( const isBackwards = (
direction === -1 direction === -1
|| (direction === 'auto' && prevActiveKey > activeKey) || (direction === 'auto' && prevActiveKey > activeKey)
|| (direction === 'inverse' && prevActiveKey < activeKey) || (direction === 'inverse' && prevActiveKey < activeKey)
); );
container.classList.remove('animating');
container.classList.toggle('backwards', isBackwards);
const keys = Object.keys(rendersRef.current).map(Number); const keys = Object.keys(rendersRef.current).map(Number);
const prevActiveIndex = renderCount ? prevActiveKey : keys.indexOf(prevActiveKey); const prevActiveIndex = renderCount ? prevActiveKey : keys.indexOf(prevActiveKey);
const activeIndex = renderCount ? activeKey : keys.indexOf(activeKey); const activeIndex = renderCount ? activeKey : keys.indexOf(activeKey);
if (name === 'slide-optimized' || name === 'slide-optimized-rtl') {
performSlideOptimized(
name,
isBackwards,
cleanup,
activeKey,
currentKeyRef,
container,
shouldRestoreHeight,
onStart,
onStop,
childNodes[activeIndex] as HTMLElement,
childNodes[prevActiveIndex] as HTMLElement,
);
return;
}
container.classList.remove('animating');
container.classList.toggle('backwards', isBackwards);
if (name === 'none' || animationLevel === 0) { if (name === 'none' || animationLevel === 0) {
childNodes.forEach((node, i) => { childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) { if (node instanceof HTMLElement) {
node.classList.remove('from', 'through', 'to'); node.classList.remove('from', 'through', 'to');
node.classList.toggle('active', i === activeIndex); node.classList.toggle(classNames.active, i === activeIndex);
} }
}); });
@ -124,7 +153,7 @@ const Transition: FC<OwnProps> = ({
childNodes.forEach((node, i) => { childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) { if (node instanceof HTMLElement) {
node.classList.remove('active'); node.classList.remove(classNames.active);
node.classList.toggle('from', i === prevActiveIndex); node.classList.toggle('from', i === prevActiveIndex);
node.classList.toggle('through', ( node.classList.toggle('through', (
(i > prevActiveIndex && i < activeIndex) || (i < prevActiveIndex && i > activeIndex) (i > prevActiveIndex && i < activeIndex) || (i < prevActiveIndex && i > activeIndex)
@ -138,9 +167,7 @@ const Transition: FC<OwnProps> = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
container.classList.add('animating'); container.classList.add('animating');
if (onStart) { onStart?.();
onStart();
}
function onAnimationEnd() { function onAnimationEnd() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -153,12 +180,12 @@ const Transition: FC<OwnProps> = ({
childNodes.forEach((node, i) => { childNodes.forEach((node, i) => {
if (node instanceof HTMLElement) { if (node instanceof HTMLElement) {
node.classList.remove('from', 'through', 'to'); node.classList.remove('from', 'through', 'to');
node.classList.toggle('active', i === activeIndex); node.classList.toggle(classNames.active, i === activeIndex);
} }
}); });
if (shouldRestoreHeight) { if (shouldRestoreHeight) {
const activeElement = container.querySelector<HTMLDivElement>('.active'); const activeElement = container.querySelector<HTMLDivElement>(`.${classNames.active}`);
if (activeElement) { if (activeElement) {
activeElement.style.height = 'auto'; activeElement.style.height = 'auto';
@ -166,13 +193,9 @@ const Transition: FC<OwnProps> = ({
} }
} }
onStop?.();
dispatchHeavyAnimationStop(); dispatchHeavyAnimationStop();
cleanup(); cleanup();
if (onStop) {
onStop();
}
}); });
} }
@ -182,8 +205,6 @@ const Transition: FC<OwnProps> = ({
? childNodes[prevActiveIndex] ? childNodes[prevActiveIndex]
: childNodes[activeIndex]; : childNodes[activeIndex];
currentKeyRef.current = activeKey;
if (watchedNode) { if (watchedNode) {
waitForAnimationEnd(watchedNode, onAnimationEnd); waitForAnimationEnd(watchedNode, onAnimationEnd);
} else { } else {
@ -209,7 +230,7 @@ const Transition: FC<OwnProps> = ({
useLayoutEffect(() => { useLayoutEffect(() => {
if (shouldRestoreHeight) { if (shouldRestoreHeight) {
const container = containerRef.current!; const container = containerRef.current!;
const activeElement = container.querySelector<HTMLDivElement>('.active') const activeElement = container.querySelector<HTMLDivElement>(`.${classNames.active}`)
|| container.querySelector<HTMLDivElement>('.from'); || container.querySelector<HTMLDivElement>('.from');
if (activeElement) { if (activeElement) {
@ -222,27 +243,85 @@ const Transition: FC<OwnProps> = ({
const renders = rendersRef.current; const renders = rendersRef.current;
const collection = Object.keys(renderCount ? new Array(renderCount).fill(undefined) : renders).map(Number); const collection = Object.keys(renderCount ? new Array(renderCount).fill(undefined) : renders).map(Number);
const contents = collection.map((key) => { const contents = collection.map((key) => {
const render = renders[key]; const render = renders[key];
return ( return (
typeof render === 'function' typeof render === 'function' ? (
? <div key={key}>{render(key === activeKey, key === prevActiveKey, activeKey)}</div> <div key={key} teactOrderKey={key}>{render(key === activeKey, key === prevActiveKey, activeKey)}</div>
: undefined ) : undefined
); );
}); });
const fullClassName = buildClassName(
'Transition',
className,
name,
);
return ( return (
<div ref={containerRef} id={id} className={fullClassName}> <div ref={containerRef} id={id} className={buildClassName('Transition', className, name)} teactFastList>
{contents} {contents}
</div> </div>
); );
}; };
export default Transition; export default Transition;
function performSlideOptimized(
name: 'slide-optimized' | 'slide-optimized-rtl',
isBackwards: boolean,
cleanup: NoneToVoidFunction,
activeKey: number,
currentKeyRef: { current: number | undefined },
container: HTMLElement,
shouldRestoreHeight?: boolean,
onStart?: NoneToVoidFunction,
onStop?: NoneToVoidFunction,
currentSlide?: HTMLElement,
prevSlide?: HTMLElement,
) {
if (!prevSlide || !currentSlide) {
return;
}
if (name === 'slide-optimized-rtl') {
isBackwards = !isBackwards;
}
const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent();
requestAnimationFrame(() => {
onStart?.();
prevSlide.style.transition = 'none';
prevSlide.style.transform = 'translate3d(0, 0, 0)';
currentSlide.style.transition = 'none';
currentSlide.style.transform = `translate3d(${isBackwards ? '-' : ''}100%, 0, 0)`;
forceReflow(currentSlide);
prevSlide.style.transition = '';
prevSlide.style.transform = `translate3d(${isBackwards ? '' : '-'}100%, 0, 0)`;
currentSlide.style.transition = '';
currentSlide.style.transform = 'translate3d(0, 0, 0)';
prevSlide.classList.remove(classNames.active);
currentSlide.classList.add(classNames.active);
waitForTransitionEnd(prevSlide, () => {
if (activeKey !== currentKeyRef.current) {
return;
}
prevSlide.style.transition = 'none';
prevSlide.style.transform = '';
if (shouldRestoreHeight) {
currentSlide.style.height = 'auto';
container.style.height = `${currentSlide.clientHeight}px`;
}
onStop?.();
dispatchHeavyAnimationStop();
cleanup();
});
});
}

View File

@ -1,6 +1,6 @@
// @optimization // @optimization
@mixin while-transition() { @mixin while-transition() {
.Transition > div:not(.active) &, body.animating-right-column & { .Transition > div:not(.Transition__slide--active) &, body.animating-right-column & {
@content; @content;
} }
} }

View File

@ -210,21 +210,15 @@ $color-user-8: #faa774;
--layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1); --layer-transition: 300ms cubic-bezier(0.33, 1, 0.68, 1);
--layer-blackout-opacity: 0.3; --layer-blackout-opacity: 0.3;
// For some reason these parameters cause worse animation in desktop Chrome
@media (max-width: 600px) {
--layer-transition: 450ms cubic-bezier(0.33, 1, 0.68, 1);
body.is-android {
--layer-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1);
}
}
--slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1); --slide-transition: 300ms cubic-bezier(0.25, 1, 0.5, 1);
--select-transition: 200ms ease-out;
body.is-ios {
--layer-transition: 450ms cubic-bezier(0.33, 1, 0.68, 1);
}
body.is-android { body.is-android {
--slide-transition: 200ms ease-in-out; --layer-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1);
--slide-transition: 450ms cubic-bezier(0.25, 1, 0.5, 1);
} }
--select-transition: 200ms ease-out;
} }

View File

@ -137,7 +137,9 @@ body.cursor-ew-resize {
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background-color: rgba(90, 90, 90, 0); background-color: rgba(90, 90, 90, 0);
border-radius: 0.35rem; border-radius: 0.375rem;
// `box-shadow` prevents repaint on macOS when hovering out of scrollable container
box-shadow: 0 0 1px rgba(255, 255, 255, .01);
} }
&:hover, &:focus, &:focus-within { &:hover, &:focus, &:focus-within {

View File

@ -1,7 +1,6 @@
import { getGlobal } from '../lib/teact/teactn'; import { getGlobal } from '../lib/teact/teactn';
import { ANIMATION_LEVEL_MIN } from '../config'; import { ANIMATION_LEVEL_MIN } from '../config';
import { IS_IOS } from './environment';
import { animate } from './animation'; import { animate } from './animation';
const DEFAULT_DURATION = 300; const DEFAULT_DURATION = 300;
@ -11,15 +10,7 @@ export default function fastSmoothScrollHorizontal(container: HTMLElement, left:
duration = 0; duration = 0;
} }
// Native way seems to be smoother in Chrome scrollWithJs(container, left, duration);
if (!IS_IOS) {
container.scrollTo({
left,
...(duration && { behavior: 'smooth' }),
});
} else {
scrollWithJs(container, left, duration);
}
} }
function scrollWithJs(container: HTMLElement, left: number, duration: number) { function scrollWithJs(container: HTMLElement, left: number, duration: number) {

5
src/util/forceReflow.ts Normal file
View File

@ -0,0 +1,5 @@
// Used before applying CSS transition
export default function forceReflow(element: HTMLElement) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
element.offsetWidth;
}