AndroidX 中的变形金刚
翻译自 [Shape Morphing in Android],原作者 Chet Haase,有删改。
⚠️ 提示:本文的主要目的不是教会你如何使用这个库的 API 来实现形状之间的变形,而更多是从库设计者的角度刨析:为什么要创建这个库?它能解决什么问题?实现过程中遇到的困难,采取的应对方案,所以阅读前请静下心来,否则你很快就会觉得枯燥,然后关掉这篇文章。朋友,"Patience is the Key in Life." 啊
如果你只想快速掌握 Shapes 库里的 API,可以直接跳到介绍 API 的部分,不过我还是建议大家从头开始开始读起。废话不多说,我们现在开始探索变形之旅吧!
在上一篇文章【AndroidX:新纪元的形状 (Shapes)】 中,我展示了如何使用新发布的 AndroidX Shapes 库来创建和绘制带可选圆角的多边形。让创建这些形状变得更简单是我们目标的其中一部分……但这不是我们的主要目标,我们真正想要做的是让这些形状之间过渡动画(又称“变形 Morph”)变得更容易。
欢迎来到本系列的第二部分,今天我要为大家展示,如何轻松创建并绘制可以变形的形状。
变形很难 😭(#不嘻嘻)
当我刚开始研究这个问题时,我发现形状之间的变形不仅是一个难题,而且实际上(据我所知)这个问题目前仍没有一种有效的解决方案。
具体来说,在任意形状之间创建优秀的过渡动画不是不可能,但形状越随机,就越容易变成一个“设计时”问题,这时候需要开发者和设计师共同参与进来,确定他们预期的动画效果。比如,在两个非常不同且非常复杂的形状之间进行动画往往可能导致一些非常奇怪的效果,为了防止动画效果看起来太奇怪,用户/设计师需要决定如何构建动画(以及可能需要调整形状的基础几何结构)。
为了帮助理解,现在假设你想从某个三角形对象动画过渡到一个圆角矩形形状,如下所示:
第一眼看上去,虽然不知道具体应该怎么做才能实现(从左边三角形动画过渡到右边圆角矩形),🧠 但直觉告诉你:这应该是可行的!
总体的实现思路是:通过一系列向量操作,使两种形状的底层几何结构相似,这样我们就能在构成这些结构的点之间进行动画处理。对于上面的情况,您可以想象:在左侧简单形状的适当位置添加占位符曲线,并将其动画扩展成实现右侧形状所需的曲线。虽然这种方案代码写起来可能会很复杂,但采用合适的编程技术和算法,这个过程可以被自动化处理,也就是说,不需要手动调整每一个细节,可以让程序来自动完成这些复杂的形状变换。
好,你已经理解了 1+1,现在上点难度,想象一下,设计团队要求你将三角形动画过渡为右侧最终形状:
从三角形动画过渡到右边这个复杂得多的形状,首先从直觉上就让人感觉无法实现......
考虑到右边对象的间断性和多个子形状的组合,再加上最终形状相对于起始形状的复杂性。两者之间的过渡动画看似不可能,又好似有无限种可能,要确定出其中某一种过渡动画,很模糊,很难。至少,在程序运行时自动计算出看起来合理的过渡动画很棘手,甚至是不可能的。退一步来看,这是一个“设计时”的问题,因为在设计或编码阶段就需要有人来确定如何从简单形状动画变形到更复杂形状:新的子形状从哪里开始动画、变换点的位置、如何动画展示出不相连的形状......而不应该将这些问题推迟到程序运行阶段,由算法来决定。
对于这样的问题,有一些工具例如 After Effects 能够创建和微调这样的动画。在 Android 世界中,Alex Lockwood 创建了一个叫 ShapeShifter 的工具,让开发者或设计人员能手动编辑这样复杂的形状变形。
但现在我们希望所有动画都能自动发生,也就是说,给定任意的起始形状和结束形状,程序自己能够计算出两者之间的变形,并实时运行,而且不会出现奇怪的效果。
那怎么办?我在上面已经解释了这不是一个容易解决的问题。一般情况下,没有好的办法处理任意对象并使所有变形看起来合理。可我们真的很希望能找到某种方法,能够实现在曲线形状之间的动画过渡(变形)。
是时候稍微约束一下问题了。
约束问题,聚焦 RoundedPolygon 🎯
上面提到的问题,是针对于一般形状而言,关键在于那个麻烦的词:"一般"。确实,如果有人给我们一个任意复杂且不连续的形状,即使作为人类,我们恐怕也很难优雅地处理它,更别说算法了。另一方面,对于我们本质的目的来说,我们也不需要考虑那么多,毕竟我们只希望能够在这个库支持的形状之间进行动画,我们又不需要解决这个世界上所有形状之间的变形问题。因此我们将问题的范围收窄,聚焦在 graphics-shapes 库所支持的形状之间的变形。
还记得我们的库支持生成的形状吗?带可选圆角的多边形(Rounded Polygon),它的特点是:
- 连续的(没有像上面地图形状那样的独立子对象);
- 非自相交的(顶点按顺序围绕轮廓排列)。
- 内部以相同的结构创建所有这些对象(从头到尾的一系列贝塞尔三次曲线),任何两个形状之间的唯一结构差异只是这些三次曲线的位置和数量。
好吧,其实“非自相交”的约束并不完全准确。
RoundedPolygon
的其中一个构造函数接受任意顶点列表,如前一篇文章中最后的三角形示例。你实际上可以使用该 API 创建一个复杂且自相交的任意形状……然后可能会在变形时遇到上面描述的一些问题。所以你需要约束自己(和你的形状),遵循与其他多边形构造函数的类似规则,以实现合理的变形效果。
通过“约束”变形问题,我们将一个可能无限复杂的通用形状变形问题,简化为两组曲线之间互相映射的问题。最后我们成功实现了自动变形,并且效果很好。
我们是怎么做到的呢?请继续往下看👇
创建变形 🏗️
变形的基本思路是将两个可能不同的形状进行某种映射,使得动画可以简单地在这些映射的点值之间进行插值。具体到我们库的可选圆角多边形 Rounded Polygon,这意味着将一个有序的三次贝塞尔曲线列表(用于起始形状)映射到另一个三次贝塞尔曲线列表(用于结束形状)。
这种映射需要考虑到接近度(确定两个形状之间哪些曲线最接近)以及数量(如果形状的曲线数量不同,则会创建新曲线,以确保映射是 1:1 的——每个形状上的每条曲线都对应另一个形状上的特定曲线)。
形状特征 🏷️
形状中的特征在我们实现变形过程中发挥了重要作用,什么是“特征”?其实很简单,“特征”只是我们对形状的直边和角的一种统称。比如下图这个形状,有 3 个角和 3 条边,它们都属于这个形状的特征。
具体来说,我们会提取每个形状中这些特征的位置信息,以及角特征的凸性(凸的程度)。在对齐结构时(等会我们会详细介绍这个步骤),会用到这些特征信息,以尽可能在动画过程中使特征变形为相似的特征。
计算变形最简单的方法是直接将每条曲线与另一形状上最接近的曲线配对(根据两条曲线的轮廓重合度),并在必要时添加新曲线。但是这种方法往往会导致动画伪影,尤其是拐角处的曲线可能会分裂成不相关的曲线和另一形状的边。我们进行了大量实验,最终采用了另一种不同的方案,我们称之为“特征映射”,在这种方法中,我们使用图形的特征信息,从而在变形过程中保留每个形状的一些基本特点,并减少一些角度伪影。最终结果在总体上比我们实验过程中得到的许多结果要好得多。
下面是一张示例应用程序的截图,此时程序正在执行从类三角形到星形的动画,动画将类三角形的四个角特征(三个凸角和底部的一个凹角)映射到星形中的类似特征。可以看到星形的另外两个凸角特征就像是从类三角形的边缘长出来一样。
注意形状之间的特征如何相互映射,这样做可以最大限度地减少由于忽略特征和基于曲线相似度进行变形而导致的伪影。
变形步骤 1️⃣2️⃣3️⃣
在我们的具体实现中,形状变形过程被划分为三个阶段:
-
测量:测量确定每个曲线在其形状整体轮廓中的位置。这会记录在一个
outlineProgress
参数中,一个FloatList
的列表结构(每个 item 取值范围为 0 到 1),用于存储每个曲线点(即锚点或控制点)在整个形状轮廓的起点(0)和终点(1)之间的位置。另外,此步骤还会提取每个形状的特征,记录哪些曲线位于直边、凸角或凹角的位置。这些信息在后续的映射阶段中非常重要,以确保动画的平滑过渡。 -
(结构) 映射:给定两个形状的特征列表,我们现在可以在这些特征之间创建映射关系,即一一对应(例如,起始形状的凸角映射到终止形状的最近的凸角)。特征映射完成后,我们可以根据曲线在已映射特征之间的相对距离,类似地映射形状之间的其余曲线。
-
匹配:一旦(针对特征和曲线的)结构映射完成,我们就可以创建变形结构了,变形结构负责匹配并映射两个形状的曲线,从而有效地创建一系列数值,这样我们就可以在形状变形动画期间轻松地进行插值。
整个过程中有一个重要的部分——裁剪。当绕着两个形状的轮廓,将一个形状上的曲线与另一个形状上的曲线匹配时,我们可能需要在两个对象上插入曲线,以便创建一个(可以轻松从一个形状转换到另一个形状)的整体结构。回看上面的例子,在将类三角形变形为星形时,三角形中的四个角特征都能轻松地映射到星形中的相似特征,但星形有两个额外的凸角特征需要插入到变形结构中,以便在类三角形变形为星形时,它们可以动画地出现。如下图所示,通过裁剪类三角形的直边曲线,创建出一条可以变形为星形凸角的曲线。
目前我们在这个库中采用一种简单且高效的算法来计算每个点在多边形轮廓上的位置,即使用每个点相对于多边形中心的角度测量。这种方法已经足够满足大多数情况的速度要求,但在某些情况下可能不太理想,例如当顶点并不都与中心等距时。一个更好、更通用的方法是使用实际曲线长度(或者至少是其近似值),未来的库更新可能会默认采用这种测量方法,或者提供为可选项。
起始和终止形状通常具有不同数量的曲线。举例来说,一个非圆角的三角形只有 3 条“曲线”来表示它的 3 条直边,但一个带平滑圆角的三角形可能有多达 12 条曲线(3 条直边曲线加上每个圆角的 3 条曲线——两条侧翼曲线 & 中心的纯圆曲线)。所以通常都是需要对形状进行裁剪调整,以使两个形状的结构具有相同数量的曲线。
上述步骤的代码实现涉及到非常多的细节,虽然我觉得这些细节非常有趣……但在这里详细介绍它们有点“过”了。如果大家感兴趣的话,可以通过传送门查看代码以了解细节实现,下面我将简单介绍如何使用库 API 来实际变形一些形状。
上面这一大堆文字,无论你读没读明白,其实都没关系,实际开发中熟练调用 API 才是王道。所以我们继续吧!
变形 API 很简单 🥳(#嘻嘻)
终于到介绍变形 API 的时候了!
我假设你已经学会了如何创建形状(RoundedPolygon
),因为这是上一篇文章的内容,我不想再重复一遍。
一旦你有了两个形状,你就可以在二者之间进行变形,只需要简单的两个步骤:
- 创建
Morph
变形对象实例; - 动画化
Morph
对象的progress
属性。
就这么简单。开发者无需关心变形的复杂实现细节,在创建 RoundedPolygon
和 Morph
对象时它们早就已经处理好了。
例如,要将一个正三角形变形为一个圆角星形,你可以这样做:
val path = Path()
val pointyTriangle = RoundedPolygon(numVertices = 3)
val roundedStar = RoundedPolygon.star(numVerticesPerRadius = 5, innerRadius = .5f, rounding = CornerRounding(.1f))
val morph = Morph(start = pointyTriangle, end = roundedStar) // 📌
ValueAnimator.ofFloat(0f, 1f).apply {
addUpdateListener {
val percentage = it as Float
morph.toPath(progress = percentage, path = path) // 📌
}
start()
}
// 最后绘制 Path
这里我们利用两个 RoundedPolygon
对象创建出一个 Morph
实例,然后用一个 ValueAnimator 来执行变形动画,将动画进度传给 Morph.toPath(...)
方法,获取某一时刻的形状路径,然后将路径绘制出来。
看看效果,多么 Perfect 💯!
和上面解释变形实现原理的一大堆文字相比,API 简单得不能再简单了,对吧?
未来展望
正如前面所说,变形的复杂部分隐藏在库的内部,外层的 API 简单得不得了(由上面讨论的那些约束简化得到的结果)。实际上 API 的使用方式也仅限于你上面看到的:创建两个形状,用它们创建一个变形,并动画化变形的进度。
为了使这个库更强大更灵活,我们还计划往里面添加更多的功能。例如,我在上面提到过,我们目前使用角度测量的方式来跟踪形状曲线(outlineProgress
),我们很快会改用曲线长度的方式来进行测量。但是算法这种东西,没有最好的算法,只有合适的算法,所以我们更青睐于设计出一个 API:允许开发者自定义测量算法。同时集成以上两种算法作为可选项。
同样地,如果能自定义匹配算法,那就更好了。我们对上面讨论的特征映射结果非常满意,但说不定你希望你的动画有稍微不同的感觉(例如:在某些情况下允许凹特征映射到凸特征),目前这个库还不支持这样做……但理论上是可行的。
我们还想添加变形任意 Path
对象的功能。随着 Android 14 平台和 AndroidX 终于引入了路径查询 API,理论上我们能够实现在两个任意 Path
之间生成过渡动画,而不仅仅局限于 RoundedPolygon。这里有一个问题,我上面提到过:在两个非常不同的路径对象之间进行变形可能会产生非常不理想的效果。所以这个 API 的使用条件可能比较苛刻,同时,自动生成令人满意的变形结果可能性也比较小,但就算有一些限制,也总比没有任何方法能做到自动变形 Path
要好。
See Also
APIs
截至译稿发出前,该库在 AndroidX 中最新版本为 beta 1.0.0-bata01
:Jetpack graphics
implementation("androidx.graphics:graphics-shapes:1.0.0-beta01")
示例代码 </>
你可以在 ShapeDemo 中找到本文开头的示例应用程序代码:
该示例具有基于 Jetpack Compose 和 View UI 的应用程序,以展示如何在这两个工具包的基础上用该库创建和变形形状。Compose 版本有一个额外的编辑器视图,能可视化各种形状参数:
还有一个调试视图,可以让你在看见构成这些形状的三次曲线:
翻译不易 🥵,若有帮助 🤝,赏个点赞 👍,手留余香🌹