动态贝塞尔曲线

480 阅读14分钟

简介

首先 - 呜呼!这是我在新博客上发表的第一篇文章。我超级兴奋。谢谢你来检查它!🥂

在建立这个博客时,我希望它能给人以异想天开的感觉,有大量迷人的互动和动画。我在制作React欧洲演讲《异想天开的案例》的时候制作了这个博客,所以它在我的脑海中留下了深刻的印象。

例如,你是否注意到,当你开始在这个页面上滚动时,绿色标题英雄边缘的贝塞尔曲线开始变平了?当你在文档的顶部滚动时,请留意帖子文本上方的俯冲式曲线。注意到它们在接近视口顶部的标题时是如何变平的吗?

在一个令人愉快的偶然事件中,我在建立博客时意识到,这个功能将成为一个伟大的第一篇博客文章!我开始这个博客的全部原因是,我认为它是一个很好的工具。

我建立这个博客的全部原因是,我想要一种方法来建立动态的、互动的文章,以便更有效地分享和教授概念。与Medium上的纯文本不同,这个博客是一个全功能的React应用,因此我可以创建和嵌入互动元素,帮助读者建立对所介绍主题的直观理解。这些动态的 "可平坦的 "贝塞尔曲线是这种形式的完美主题,因为它们有潜在的复杂性,仅用文字很难解释。

在这篇处女博文中,我们将了解在React.js中使用贝塞尔曲线和SVG的基本知识。我们将学习如何构建能响应用户输入的动态曲线。

这是一个交互式演示!试着拖动右边的滑块。

一个快速的SVG复习

为了实现这种效果,我们将使用SVG。我们也可以使用HTML Canvas,但我一般更喜欢使用SVG。它的API更像React,设置起来没有那么复杂,而且对a11y更友好。

虽然深入研究SVG超出了这篇文章的范围(我推荐W3Schools的教程),但我们会涵盖基础知识,并展示如何从头创建一些形状。有经验的SVG用户可以跳到下一节

最简单的SVG绘图形式使用形状元素,比如<rect><ellipse>

*注意:*这个页面中的所有代码片段都是React元素,而不是HTML。

这些形状是直接的和声明性的,但这种简单性是以灵活性为代价的;你只能创建少数几个不同的形状。

为了做整齐的曲线,我们需要使用<path> 元素。这个由SVG基元组成的 "军刀 "可以让你在一束看似难以捉摸的字母和数字中指定一个要执行的步骤序列。

jsx

上面的交互式代码片段使用了两个命令。

  • M,它指示路径移动到一个特定的坐标。

  • L,指示路径创建一条从当前位置到指定坐标的线

在命令ML 之后,我们看到一些数字。这些数字可以被认为是命令的 "参数"。在这种情况下,参数是坐标;两个命令都需要一个X/Y对。

换句话说,我们可以将上述路径解读为。"移动到{x: 100, y: 100} ,然后画一条线到{x: 200, y: 100}",以此类推。

坐标系统是相对于viewBox 中指定的值而言的。当前的视窗指定可视区域的左上角为0/0,宽度为300,高度为300。所以在path 中指定的所有坐标都在这个300x300的框内。

viewBox 是使SVG具有可伸缩性的原因;我们可以使我们的SVG具有任何我们喜欢的大小,并且所有的东西都会自然地缩放,因为我们的SVG中的元素是相对于这个300x300的盒子而言的。

path 元素具有相当多的这些命令。其中有两条与我们的目的相关。

  • Q,它指示路径创建一个二次贝塞尔曲线。

  • C,指示路径创建一个三维贝塞尔曲线。

贝塞尔曲线的介绍

贝塞尔曲线出乎意料地普遍。由于它们的多功能性,它们是大多数图形软件(如Photoshop)的主打产品,但它们也被用作计时功能:如果你曾经使用过非线性CSS过渡(如默认的 "ease"),你已经使用过贝塞尔曲线了!但它们是什么?

但它们是什么,又是如何工作的呢?

贝塞尔曲线本质上是一条从起点终点的直线,它受到一个或多个控制点的作用。一个控制点使直线向它弯曲,就像控制点把它拉向它的方向一样。

下面这条直线看起来是一条直线,但请看看当你移动这些点时发生了什么--试着上下拖动中间的控制点。

上面这条线是一条二次贝塞尔曲线;这意味着它有一个控制点。我猜它的名字来自于你可以用它来创造抛物线般的形状。

与此相反,三维贝塞尔曲线有两个控制点。这使得曲线变得更加有趣。

二次方

三次方

贝塞尔曲线在SVGpath 定义中的语法有点反直觉,但它看起来像这样。

jsx

至少对我来说,使之变得反直觉的是,startPoint 是在Q 命令中推断出来的;虽然二次贝塞尔曲线需要3个点,但只有2个点作为参数传递给Q

同样地,对于三维贝塞尔曲线,只有控制点和端点被提供给C 命令。

这种语法确实意味着曲线可以方便地串联起来,因为一条曲线的起点是上一条的终点。

jsx

好了,我想这就够了,玩玩普通的SVG。让我们看看如何利用React使这些曲线变得动态起来

React中的贝赛尔曲线

到目前为止,我们一直在研究静态的SVG。我们如何让它们随着时间的推移或基于用户的输入而变化?

那么,为了与本篇博文的 "元 "主题保持一致,为什么不研究一下本篇博文前面的可拖动带线贝塞尔曲线呢?

即使在这个略微简化的片段中,也有相当多的代码来管理这个。我给它做了大量的注释,希望能让事情更容易解析。🤞

*注意:*支持触摸事件的完整版本可以在GitHub上找到。

总结一下这是如何工作的。

  • React在组件状态中为startPoint,controlPoint, 和endPoint 保存变量。

  • 渲染方法中,我们使用这些状态变量来构建path 的指令。

  • 当用户点击或敲击其中一个点时,我们更新状态以跟踪哪个点在移动,draggingPointId

  • 当用户在SVG的表面上移动鼠标(或手指)时,我们会做一些计算来计算出当前拖动的点需要移动到哪里。这是因为SVG有自己的内部坐标系(viewBox),所以我们必须将屏幕上的像素转换到这个坐标系上,这就变得很复杂。

  • 一旦我们有了活动点的新的X/Y坐标,setState 就会让React知道这个状态的变化,组件就会重新渲染,从而导致path ,重新计算。

关于性能的说明

通过使用React的更新周期来管理点的坐标,让React在每个mousemove 上运行它的调和周期,会增加开销。这是不是太贵了?

答案是,这取决于。React的调和可能出乎意料地快,特别是在处理这样一个小树的时候(毕竟,唯一需要差异化的是SVG)。特别是在 "生产 "模式下,当React不需要做大量的开发警告检查时,这个过程可能需要几分之一秒的时间。

我写了一个替代实现,直接更新DOM。它确实运行得更快(在我的快速测试中大约快了50%),但这两种实现在现代高端硬件上的时间仍然低于1ms。在我能找到的最便宜的Chromebook上,"未优化 "的实现仍然平均为50fps左右。

曲线插值

我似乎有点跑题了!我们最初的目标是创建一条贝塞尔曲线,在滚动时使其自身变平。

鉴于我们到目前为止所做的工作,我们几乎拥有了解决这个问题所需的所有工具贝塞尔曲线的控制点直接位于起点和终点之间,实际上是一条直线。因此,我们需要将控制点从其曲线值过渡到一个平面值。

我们需要一种方法来插值。我们知道控制点在0%和100%的时候应该在哪里,但是当用户在内容中滚动了25%的时候呢?

虽然我们可以很花哨地缓和过渡,但线性转换对我们的目的来说是很好的。因此,当用户在内容中滚动到50%时,控制点将在其最初的曲线值和平线值之间的50%处。

对于这一点,一些中学的数学知识将派上用场。如果你已经掌握了内插法,你可以跳过这部分

如果你翻开你的记忆深处,你可能记得如何计算直线的斜率。斜率告诉你这条直线是如何随时间变化的。我们通过将y的变化除以x的变化来计算它。

slope = =(y2 - y1) / (x2 - x1) (Δy) / (Δx)

还有这个无赖,线性方程公式。它允许我们绘制一条直线,并计算出给定x值的y值。按照惯例,斜率被赋予变量a。

y = ax + b

这与内插法有什么关系?好吧,让我们想象一下,我们的贝塞尔曲线的控制点,当它全部弯曲时,离它的平坦位置有200个像素,所以我们给它一个初始y值为200。在这种情况下,x实际上是一个衡量进度的标准,所以我们要让它的范围从0(完全弯曲)到1(完全平坦)。如果我们把这条线画出来,我们会得到这个结果。

2001000.51.0

澄清一下,这条线代表了二次贝塞尔曲线控制点的可能Y值范围。我们的x值代表了 "扁平化 "的程度;这对我们很有用,因为我们希望能够提供一个像0.46这样的x值,并找出相应的y值(我们的x值将来自用户的输入,比如在视口中滚动的百分比)。

为了使我们的公式奏效,我们需要知道这条线上至少有两个点。值得庆幸的是,我们知道了!我们知道初始位置,完全弯曲,是在{ x: 0, y: 200 } ,我们知道曲线在{ x: 1, y: 0 } ,完全变平。

  • 斜率将等于(Δy) / (Δx) =(0 - 200) / (1 - 0) =-200 / 1 =-200

  • 我们的b值是y轴的截点,也就是我们的初始曲线值,200。

  • x将是滚动的比例,在0和1之间,我们将从我们的滚动处理程序得到。

把它填进去。

y = -200x + 200

如果是25%,x将是0.25,因此我们的y值将是y = (-200)(0.25) + 200 = 150,这是正确的:150是200和0之间的1/4。

这是我们执行上述计算的函数。

看起来少年时代的我错了;代数有用的,也是实用的!

在React中处理滚动

我们现在已经到了关键时刻了!是时候把所有这些想法结合起来变成可用的东西了。

让我们先建立一个组件,包含我们的滚动处理程序,从视口的底部插值到顶部,并将这些值连接到渲染函数中的贝塞尔曲线。

jsx

这个最初的方法似乎很好用但有两点我想改进。

  • 我觉得平坦化的 "时机 "不对。
    当曲线完全进入视口时,它已经开始被压平了。我们并没有看到它100%的弯曲形式。更糟的是,当它滚动到视野之外时,它还没有完成平坦化这是因为这个页面有一个占据视口顶部50px的标题,而我们并没有考虑到这一点。
    为了解决这些问题,我们需要定义一个可滚动的区域,而不是使用视口。

  • 这个组件做了非常多的事情。感觉我们可以从中提取几个组件。重构它不仅会使它更容易被理解,而且会使它更容易被重用。

让我们来解决这些问题。这里有一个重构的版本。

jsx

啊,漂亮多了!由于扁平化动画发生在一个较小的滚动窗口内,效果更令人愉快,而且代码更容易解析。作为奖励,我们的BezierCurveScrollArea 组件是通用的,所以它们可以在完全不同的背景下发挥作用。

关于性能的另一个说明

上面的两个版本是在没有考虑性能的情况下编写的。事实证明,性能并不差;在我的低端Chromebook上,它不时地有一点停顿,但大多数情况下是以60fps的速度运行。在我迟钝的iPhone 6上,它运行得足够好(在手机上最大的问题是,浏览器地址栏在滚动时发生变化。由于这个原因,在手机上完全禁用基于滚动的东西可能是明智的)。

这就是说,你的里程可能有所不同。如果你想提高性能,有几种方法可以优化。

  • ScrollArea ,对滚动处理程序进行节制,使其每20ms左右才启动一次。这是为了让某些触摸屏或触控板界面平静下来,因为它们的启动频率可能远远超过要求。

  • 这个效果的一个比较昂贵的部分是,我们正在与DOM进行交互,通过 [getBoundingClientRect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)在每个滚动事件上与DOM交互。理想情况下,我们可以缓存我们的ScrollArea ,然后根据这个值检查当前的滚动距离。
    不幸的是,这种方法会带来新的问题。它假定在文档顶部和贝塞尔曲线之间没有任何东西会改变高度,因为我们的计算假定两者之间的距离是静态的。像iOS Safari这样的移动浏览器会在你向下滚动时隐藏它们的铬,所以我们也必须把这个因素考虑进去。
    这远非不可能,但对我来说不值得这么麻烦,因为在我的目标设备上,性能是令人满意的。

  • 通过在状态中存储scrollRatio ,并在它发生变化时重新渲染,React需要一些时间来计算DOM是如何因滚动而发生变化的。
    提取几个组件的重构,虽然对DX和重用性非常好,但也意味着React有一个稍微复杂的树来进行协调。
    这一切听起来有点吓人,但正如我们之前发现的,React的调和过程在这样的小树上是非常快的。在我的chromebook上,重构的成本是可以忽略不计的。
    如果你真的需要提取每一滴性能,你可以直接处理DOM,通过使用setAttribute ,设置新的path 指令。请注意,你需要再次将所有东西存储在一个组件中。

总结

呼,你已经完成了这个贝塞尔的深度研究

这篇博文中描述的技术是基础性的,你可以在上面添加大量的装饰。

  • 这篇博客使用了3条分层的贝塞尔曲线,用不同的填充颜色来提供深度体验。

  • 你可以尝试用不同的宽松度来进行插值(毕竟贝塞尔曲线经常被用于计时功能!)。如果曲线在平滑之前变得更加戏剧化呢?

  • 你可以用弹簧物理学进行实验,以赋予过渡的惯性。

我很想看看你用这种技术做了什么!请在Twitter上告诉我。请在Twitter上告诉我。

补充阅读

通过这两个惊人的资源了解更多关于贝塞尔曲线背后的数学和力学知识。

最后更新

2018年5月23日

点击率