auto-animate으로 항목을 들이고 내고 제자리로
@formkit/auto-animate은 단일 부모 ref로 둘 다 고칩니다 — 그리고 ScrollMenu의 containerRef 프로퍼티가 그것이 필요한 바로 그 요소를 건넵니다.추가, 제거, 셔플 — 입장, 퇴장, 재정렬 모두 애니메이션됩니다. 메뉴 자체에는 애니메이션 코드가 없습니다.
동작 방식
useAutoAnimate()는 애니메이션할 요소의 직접 부모에 놓여야 하는 ref를 반환합니다. ScrollMenu 안에서 그 부모는 스크롤 컨테이너입니다. 전달한 각 자식은 item div로 감싸이고, 그 item div들이 컨테이너의 직접 자식입니다. 스토리는 ref를 그대로 통과시킵니다 — <ScrollMenu containerRef={parent}> — auto-animate이 거기서 이어받습니다. 추가된 항목은 이즈인하고, 제거된 항목은 애니메이션으로 빠져나가고, 재정렬된 항목은 새 자리로 미끄러집니다. 메뉴 자신은 애니메이션되고 있음을 결코 알지 못합니다.
추가, 제거, 셔플
세 제어 모두 items 배열에 대한 평범한 setState 호출입니다 — addItems는 하나를 추가하고, removeItems는 마지막을 떨어뜨리고, shuffle은 복사본에 대한 Fisher–Yates 패스입니다. 애니메이션은 전적으로 그 갱신이 일으키는 DOM 변이에서 옵니다. 지킬 가치가 있는 한 규칙: itemId는 React key와 메뉴의 추적 맵 안의 항목 핸들을 겸하므로, id는 유일을 유지해야 합니다 — 스토리는 중복을 만들 위험을 지느니, 제거가 남긴 번호 빈틈을 되메우기까지 합니다.
스크롤과 추적은 계속 동작
메뉴는 자식이 변할 때마다 재관찰하므로, 새로 추가된 항목의 useIsVisible은 즉시 올바르게 보고하고 화살표는 페이지 넘김을 계속합니다. 다만 새 항목은 대개 화면 밖에 착지합니다 — 입장을 실제로 보여주려면, add-item-and-scroll-to-it 예제처럼 scrollToItem과 짝지으세요.
참고
containerRef는 ref 객체나 콜백 ref를 받습니다 —useAutoAnimate의 콜백이 곧바로 끼워집니다.- auto-animate은 무설정이고 프레임워크 독립적입니다. React 바인딩은
useAutoAnimate훅 하나입니다. - 위의 데모는 id 관리를 단조 카운터로 단순화합니다. 코드 패널은 스토리의 빈틈 메우기 버전을 보여줍니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.
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();
}
}