构建一个Toast组件
今年早些时候,我为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);}