构建一个Toast组件

构建一个Toast组件

03-24 ·
6 分钟阅读

今年早些时候,我为React构建了一个名为Sonner的Toast库。在本文中,我将向您展示我在构建过程中学到的一些经验教训和犯过的错误。

动画

最初,我使用CSS关键帧来实现进入和退出动画,但它们是不可中断的。CSS过渡可以被中断,并在第一个过渡完成之前平滑地过渡到新的值。你可以在下面看到区别。

为了在Toast进入屏幕时进行过渡,本质上是模仿进入动画,我们使用useEffect在第一次渲染后将mounted状态设置为true。这样,Toast最初以transform: translateY(100%)渲染,然后过渡到transform: translateY(0)。样式基于数据属性。

React.useEffect(() => { setMounted(true);}, []);
//...
<li data-mounted={mounted}>

堆叠Toasts

为了创建堆叠效果,我们将Toast之间的间隙乘以Toast的索引来获得y位置。值得注意的是,每个Toast都有position: absolute,以便更容易堆叠。我们还通过0.05 * 索引缩小它们,以创造深度的错觉。这是简化的CSS:

[data-sonner-toast][data-expanded="false"][data-front="false"] {
--scale: var(--toasts-before) * 0.05 + 1;
--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale))
);
}

这在有不同高度的Toast时效果很好,它们不会均匀地突出。我们通过在堆叠模式下简单地使所有Toast的高度与前面的Toast相同来解决这个问题。以下是不同高度的Toast的显示效果:

添加Toast

滑动

可以向下滑动Toast来关闭它。这只是Toast上的一个简单事件监听器,更新负责translateY值的变量。

// 这是代码的简化版本
const onMove = (event) => {
const yPosition = event.clientY - pointerStartRef.current.y;
toastRef.current?.style.setProperty('--swipe-amount', `${yPosition}px`);
};

滑动是基于动量的,这意味着你不需要滑动到特定阈值才能移除Toast。如果滑动动作足够快,Toast仍然会被关闭,因为速度足够高。

const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;
// 如果达到阈值或速度足够高,则移除
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
deleteToast();
}

空间一致性

最初,Toast是从底部进入的,你可以向右滑动它,如这条推文所示。然而,这感觉不自然,因为Toast没有遵循对称路径。如果某物从底部进入,它也应该从同一方向退出。我从苹果的设计流畅界面演讲中学到了这个原则。这个演讲非常精彩,我强烈推荐观看。

如果你对这类内容感兴趣,那么你可能会喜欢我的动画课程,我在其中涵盖了这些内容以及更多 — animations.dev

展开Toasts

我们通过添加所有前面Toast的高度和它们之间的间隙来计算每个Toast的展开位置。当用户悬停在Toast区域时,这个值将成为新的translateY

const toastsHeightBefore = React.useMemo(() => {
return heights.reduce((prev, curr, reducerIndex) => {
// 计算直到当前Toast的偏移量
if (reducerIndex >= heightIndex) {
return prev;
}
return prev + curr.height;
}, 0);
}, [heights, heightIndex]);
// ...
const offset = React.useMemo(
() => heightIndex * GAP + toastsHeightBefore,
[heightIndex, toastsHeightBefore],
);

状态管理

为了避免使用context,我们通过观察者模式管理状态。我们在<Toaster />组件中订阅可观察对象。每当调用toast()函数时,<Toaster />组件(作为订阅者)就会收到通知并更新其状态。然后我们可以使用Array.map()渲染所有的Toast。

function Toaster() {
React.useEffect(() => {
return ToastState.subscribe((toast) => {
setToasts((toasts) => [...toasts, toast]);
});
}, []);
// ...
return (
<ol>
{toasts.map((toast, index) => (
<Toast key={toast.id} toast={toast} />
))}
</ol>
);
}

要创建新的Toast,我们只需导入toast并调用它。不需要钩子或上下文,只是一个简单的函数调用。

import { toast } from 'sonner';
// ...
toast('我的Toast');

悬停状态

悬停状态取决于我们是否悬停在其中一个Toast上。然而,Toast之间也有间隙。为了解决这个问题,我们添加了一个:after伪元素来填充这些间隙,确保一致的悬停状态。你可以在下面看到这些填充的间隙。

添加Toast

指针捕获

一旦我们开始拖动,我们设置Toast来捕获所有未来的指针事件。这确保即使鼠标或我们的拇指在拖动时移动到Toast外部,Toast仍然是指针事件的目标。因此,即使我们在Toast外部,拖动仍然是可能的,从而带来更好的用户体验。

function onPointerDown(event) {
event.target.setPointerCapture(event.pointerId);
}
编辑于 02-22