Hoạt ảnh mục vào, ra và vào đúng chỗ với auto-animate
@formkit/auto-animate sửa cả hai bằng một ref cha duy nhất — và prop containerRef của ScrollMenu trao đúng phần tử nó cần.Thêm, xóa và trộn — mỗi lần vào, ra và sắp xếp lại đều được hoạt ảnh. Bản thân menu không có code hoạt ảnh.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
Cách hoạt động
useAutoAnimate() trả một ref phải đặt lên cha trực tiếp của các phần tử cần hoạt ảnh. Trong ScrollMenu cha đó là container cuộn: mỗi con bạn truyền được bọc trong một div mục, và các div mục đó là con trực tiếp của container. Story truyền ref xuyên suốt — <ScrollMenu containerRef={parent}> — và auto-animate tiếp quản từ đó: mục thêm vào ease in, mục xóa hoạt ảnh ra, và mục sắp xếp lại trượt về slot mới. Bản thân menu không bao giờ biết nó đang bị hoạt ảnh.
Thêm, xóa, trộn
Ba điều khiển đều là các lời gọi setState thường trên mảng items — addItems thêm một, removeItems bỏ cái cuối, shuffle là một lượt Fisher–Yates trên bản sao. Các hoạt ảnh hoàn toàn đến từ các đột biến DOM mà những cập nhật đó gây ra. Một quy tắc đáng giữ: itemId kiêm vai key React và vai chốt của mục trong bản đồ theo dõi của menu, nên id phải giữ duy nhất — story thậm chí lấp lại khoảng trống đánh số do xóa để lại thay vì liều đúc ra một bản trùng.
Cuộn và theo dõi tiếp tục hoạt động
Menu quan sát lại các con mỗi khi chúng đổi, nên useIsVisible của một mục mới thêm báo đúng ngay và các mũi tên tiếp tục phân trang. Tuy nhiên, mục mới thường hạ cánh ngoài màn hình — nếu màn vào phải thực sự được thấy, ghép cái này với scrollToItem như ví dụ add-item-and-scroll-to-it làm.
Ghi chú
containerRefnhận một đối tượng ref hoặc một ref callback — callback củauseAutoAnimatecắm thẳng vào.- auto-animate không cấu hình và độc lập framework; liên kết React là một hook
useAutoAnimateduy nhất. - Demo phía trên đơn giản hóa quản lý id thành bộ đếm đơn điệu; panel code cho thấy phiên bản lấp khoảng trống của story.
Nguồn đầy đủ
Đầy đủ và sẵn sàng sao chép-dán — đây là file chính xác phía sau phiên bản Storybook có thể chỉnh sửa trực tiếp.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
export function ItemsAnimation() {
const [parent] = useAutoAnimate();
const [items, setItems] = React.useState(() => getItems(10));
const [selected, setSelected] = React.useState<string[]>([]);
const isItemSelected = (id: string): boolean =>
!!selected.find((el) => el === id);
const handleItemClick = (itemId: string) => {
const itemSelected = isItemSelected(itemId);
setSelected((currentSelected: string[]) =>
itemSelected
? currentSelected.filter((el) => el !== itemId)
: currentSelected.concat(itemId),
);
};
const addItems = React.useCallback(() => {
setItems((curr) => {
const currentItemsNumbers = curr
.map((el) => +el.id.replace(/\D/g, ''))
.sort();
const lastSeqItem = currentItemsNumbers.find(
(el, ind, arr) => arr[ind + 1] - el > 1,
);
const haveGaps = typeof lastSeqItem === 'number';
return [
...curr,
...getItems(1, haveGaps ? lastSeqItem + 1 : curr.length),
];
});
}, []);
const removeItems = React.useCallback(() => {
setItems((curr) => [...curr.sort().slice(0, curr.length - 1)]);
}, []);
const shuffle = React.useCallback(() => {
setItems((curr) => {
const array = [...curr];
let currentIndex = array.length,
randomIndex;
while (currentIndex > 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex],
];
}
return array;
});
}, []);
return (
<NoScrollbar>
<ScrollMenu
containerRef={parent}
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
noPolyfill={false}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
<div style={{ display: 'flex', gap: '8px', margin: '8px' }}>
<button onClick={addItems}>Add item</button>
<button onClick={removeItems}>Remove item</button>
<button onClick={shuffle}>Shuffle items</button>
</div>
</NoScrollbar>
);
}
export default ItemsAnimation;
const NoScrollbar = styled('div')({
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
},
});
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useLeftArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollPrev()}
testId="left-arrow"
>
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const disabled = visibility.useRightArrowVisible();
return (
<Arrow
disabled={disabled}
onClick={() => visibility.scrollNext()}
testId="right-arrow"
>
Right
</Arrow>
);
}
function Arrow({
children,
disabled,
onClick,
className,
testId,
}: {
children: React.ReactNode;
disabled: boolean;
onClick: VoidFunction;
className?: string;
testId: string;
}) {
return (
<ArrowButton
disabled={disabled}
onClick={onClick}
className={'arrow' + `-${className}`}
data-testid={testId}
>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')((props) => ({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
opacity: props.disabled ? '0' : '1',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
}));
function Card({
onClick,
selected,
title,
itemId,
}: {
onClick: (context: publicApiType) => void;
selected: boolean;
title: string;
itemId: string;
}) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId, true);
return (
<CardBody
data-cy={itemId}
onClick={() => onClick(visibility)}
onKeyDown={(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && onClick(visibility);
}}
data-testid="card"
role="button"
tabIndex={0}
className="card"
visible={isVisible}
selected={selected}
>
<div className="header">
<div>{title}</div>
<div className="visible">visible: {JSON.stringify(isVisible)}</div>
<div className="selected">selected: {JSON.stringify(!!selected)}</div>
</div>
<div className="background" />
</CardBody>
);
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
(props) => ({
border: '1px solid',
display: 'inline-block',
margin: '0 10px',
width: '160px',
userSelect: 'none',
borderRadius: '8px',
overflow: 'hidden',
'& .header': {
backgroundColor: 'white',
},
'& .visible': {
backgroundColor: props.visible ? 'transparent' : 'gray',
},
'& .background': {
backgroundColor: props.selected ? 'green' : 'bisque',
height: '200px',
},
}),
);
const getId = (index: number) => `${'test'}${index}`;
const getItems = (count: number, start: number = 0) =>
Array(count)
.fill(0)
.map((_, ind) => ({ id: getId(start + ind) }));
function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
// NOTE: no good standart way to distinguish touchpad scrolling gestures
// but can assume that gesture will affect X axis, mouse scroll only Y axis
// of if deltaY too small probably is it touchpad
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;
if (isThouchpad) {
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) {
apiObj.scrollNext();
} else {
apiObj.scrollPrev();
}
}