滚动到横向列表中的特定项目
深度链接到某一行:聊天打开在正在进行的会话上,画廊打开在你分享的那张照片上。滚动容器位于库内部,但你不需要指向它 DOM 的 ref——
onInit 把 api 交给你,由 scrollToItem 负责定位。onInit scrolls straight to quito
轨道不会停在 Tokyo 挂载——onInit 会直接跳到 quito。拖到别处,再重新挂载,看它再次落在那里。
工作原理
ScrollMenu 接受一个 onInit 回调,并在菜单渲染完毕、测量过项目之后调用它,传入与内部 VisibilityContext 所提供的同一个 api 对象。处理器用 getItemElementById(id) 查找元素,再交给 scrollToItem(item, ’auto’, ’start’)。因为 onInit 只在测量之后触发,对于已渲染的项目,查找不可能落空——无需 setTimeout,也无需重试循环。
行为与对齐
该 story 传入 ’auto’ 与 ’start’:’auto’ 不带动画地跳转,这正是初始位置所需要的——用户不会看到轨道停在第一个项目。’start’ 把项目的左边缘与轨道对齐。对于点击驱动的滚动,同一调用则使用 ’smooth’ 与 ’center’——就是下面的点击居中示例。
注意
- 当你只知道位置而不知道 id 时,
getItemElementByIndex是按位置使用的替代方案。 - 你传入的 id 是项目的
itemId——与菜单用于可见性追踪的同一个键。 - 演示通过用新的
key重新挂载菜单来重放行为;每次全新挂载都会再次运行onInit。
完整源码
完整且可直接复制粘贴——这正是背后这份文件的 可在 Storybook 中实时编辑的版本.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
export function ScrollToItem() {
const [items] = React.useState(() => getItems());
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),
);
};
// `onInit` fires once the menu has rendered and measured its items,
// so the api is safe to use right away — no timers needed.
const scrollToItemOnInit = (api: publicApiType) => {
const item = api.getItemElementById(getId(5));
// const item = api.getItemElementByIndex('5') // or by index
if (item) {
api.scrollToItem(item, 'auto', 'start');
}
};
return (
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onInit={scrollToItemOnInit}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
);
}
export default ScrollToItem;
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 = () =>
Array(10)
.fill(0)
.map((_, ind) => ({ id: getId(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();
}
}