【小鹅事务所】Android动效设计从入门到实战

前言

最近小鹅事务所新增了一些动画,包括转场动画、布局动画和交互动效。本文通过多个动效实战来介绍其中的动效设计思想和 动画实战。

我已经想写动效开发这个题材很久了,但一直未能动笔。这主要是因为动效开发不仅仅是编写代码就能完成的,它与其背后的APP设计密不可分,因此需要做到知其然知其所以然。否则,制作出来的动效可能无法提升 APP 使用体验,甚至可能会遭到用户反感。

本文从简单到复杂,介绍一下以 Compose 为编码实现(View 同理),以 After Effects、Figma、Lottie为动画设计的动效开发系统,并尽量减少陷入源码陷阱。这篇文章虽然基于Android,但是也适合设计、产品观看,我们开始吧!

属性动画

关于调整视图的透明度、尺寸、比例、位置等等属性来做动画我都统称为属性动画。

单独修改某项属性而实现的动画如上图所示,它的实现原理是在每一帧调整某些属性而实现的动画。

例如,在View系统中,可以使用ValueAnimator等动画辅助类来实现在每一帧对 View 进行属性设置变更,以达到每一帧展示不同的属性的效果。

kotlin 复制代码
AndroidView(
    factory = { context ->
        // 新建一个展示图片的 ImageView
        ImageView(context).apply {
            setImageResource(R.drawable.ic_little_goose)
        }
    }
) { imageView ->
    // 新建一个动画辅助类,设置在两秒内将动画数值从 1 慢慢变成 0 再变成1
    val animator = ValueAnimator.ofFloat(1F, 0F, 1F).apply {
        duration = 2000L
    }.also { it.start() }
    // 监听数值变化,在每一次变化都调整 ImageView 的透明度
    animator.addUpdateListener { a ->
        imageView.alpha = a.animatedValue as Float
    }
}

以上代码实现的功能是:在2秒钟内的每一帧将一张图片的透明度从1不断变成0再变成1。如下所示:

在介绍 Compose 属性动画系统时需要解释一个概念,声明式函数。它和 View 的 UI 系统在开发上有本质的区别,它由数据驱动 UI 变化。和上面同样的例子,它放在Compose的写法就是这样:

kotlin 复制代码
val alpha = remember { Animatable(1F) }
Image(
    painter = painterResource(id = R.drawable.ic_little_goose),
    contentDescription = "little goose",
    modifier = Modifier.alpha(alpha.value)
)
LaunchedEffect(Unit) {
    // 将 alpha 以动画的形式从1F变成0F再变成1F
    alpha.animateTo(0F, tween(2000, easing = LinearEasing))
    alpha.animateTo(1F, tween(2000, easing = LinearEasing))
}

代码中通过名为alpha的这个Animatable内部value变更来驱动Compose函数的重组,进而导致透明度渐隐动画,如下所示:

绘图动画

绘图动画是通过对图形的绘制过程进行动画处理,来展现出视觉上的变化。通常使用Canvas在每一帧绘制不同的图形以达到动画效果。

在 View 系统中需要频繁调用invalidate(),并在onDraw中绘制不同的图形,在Compose中需要频繁改变绘制参数,内部会自动重组。

举一个绘制圆形的例子:

kotlin 复制代码
@Composable
fun AnimatedCircle(modifier: Modifier = Modifier) {
    val animatedSweepAngle = remember { Animatable(0f) }
    LaunchedEffect(Unit) {
        animatedSweepAngle.animateTo(
            targetValue = 360f,
            animationSpec = tween(durationMillis = 2000)
        )
    }
    Canvas(modifier) {
        drawArc(
            color = Color.Blue,
            startAngle = 0f,
            sweepAngle = animatedSweepAngle.value,
            useCenter = false,
            topLeft = Offset.Zero,
            size = this.size
        )
    }
}

在这个例子中,我们使用 Animatable 创建了一个可以进行动画处理的变量 animatedSweepAngle。然后在 LaunchedEffect 中使用 animateTo 方法来控制这个变量的变化过程。最后在 Canvas 中使用 drawArc 方法来绘制圆形。由于 animatedSweepAngle 变量的值在每一帧都会发生变化,圆形会在2秒内逐渐绘制完成,形成了绘图动画的效果。

除了绘制圆形,Canvas 还可以绘制其他的图形,比如矩形、直线、路径等等。只需要使用相应的绘制函数即可。

例如可以绘制以下动画,用作小鹅事务所的搜索Icon动画:

结合下拉手势可以做出这样的下拉搜索界面,图标的位置、透明度、大小、动画进度跟随着滑动手势来走,而每一个动画的关键帧的时间、位置都并不是从始至终的,它跟手的同时也有些趣味。

它的代码有一百多行,我就不全放出来了,对代码感兴趣的可以拉小鹅事务所仓库查看,只放一小部分:

kotlin 复制代码
@Composable
fun PullToSearchIcon(
    modifier: Modifier = Modifier,
    progress: Float,
    color: Color = MaterialTheme.colorScheme.onSurface,
    contentDescription: String?
) {
    ...
    val drawCache = remember {
        PullToSearchIconDrawCache(
            cachePath = Path(),
            cachePathMeasure = PathMeasure(),
            cachePathToDraw = Path()
        )
    }
    Canvas {
        // 根据关键帧计算直线进度
        val lineProgress = if (cacheCurrentState.state) {
            if (progress < 0.16F) 0F else ((progress - 0.16F) / 0.72F).coerceAtMost(1F)
        } else {
            if (progress < 0.12F) 0F else ((progress - 0.12F) / 0.72F).coerceAtMost(1F)
        }
        // 根据关键帧计算箭头进度
        val arrowProgress = 1F - if (cacheCurrentState.state) {
            (progress / 0.36F).coerceAtMost(1F)
        } else {
            if (progress < 0.12F) 0F else {
                ((progress - 0.12F) / 0.36F).coerceAtMost(1F)
            }
        }
        // 计算绘制的坐标
        val lineStartOffset = Offset(
            width / 2 + (width * 0.375F - radius - radius * cos45) * lineProgress,
            (height * 0.1675F - (0.045F * height * lineProgress)) +
                    (radius + radius * cos45) * lineProgress
        )
        val lineEndOffset = Offset(
            width / 2 - lineProgress * width * 0.375F,
            height * 0.875F
        )
        // 绘制
        drawLine(
            color = color,
            start = lineStartOffset,
            end = lineEndOffset,
            strokeWidth = strokeWidth,
            cap = StrokeCap.Round
        )
        ...
    }
}

经过吃力地计算和不断地调试,我们可以做出一个看起来还不错的"箭头 → 搜索"动画,如果掌握了绘图动画,大家可能会想:

我是不是可以对线动效设计师了?

我只想说:别急。上图只是一个非常简单的动画,就需要写一百多行代码、花上几个小时调试才能做出来。甚至还有一个小问题:arrowProgresslineProgress的动画曲线是根据progress计算出来的,也就是说没有自己的动画规格曲线,动画规格曲线可以参考我之前的文章。在子progress计算的时候,沿用了父progress的曲线。如果需要每个progress都有自己的动画曲线,则需要进一步曲线映射。

要是你和设计师对线之后,设计师做出了比较复杂的动画:

这个时候就几乎没有办法通过手写代码的方式来实现了,虽然理论上也可以。

  1. 找到组件(每个绘制内容)关键帧的时间,并记录下来。
  2. 在对应关键帧时间点调整组件的位置,绘制相关组件。
  3. 动画规格曲线计算。
  4. 对每一个组件重复上面的步骤。

这种工作量就不是以小时为单位了,是以天为单位了!所以这种情况下只能对设计师投降认输。那么有没有办法可以减少这种工作量呢?是有的,Airbnb的Lottie、腾讯的PAG都方便了设计、开发对接。

Lottie

Lottie是由Airbnb创建的库,它允许设计师在Adobe After Effects(AE)中创建动画并将其导出为JSON文件。然后可以使用Lottie库在Android、iOS和Web应用程序中使用这些JSON文件。这使得设计师可以创建复杂的动画,并且开发人员可以无需额外的编码就能将其无缝地集成到应用程序中。

关于AE怎么使用Lottie导出动效文件可以查看我之前的文章,里面介绍了怎么导出AVD文件,而需要导出Json文件只需要勾选Standard即可:

使用AE制作动画并使用Lottie插件导出需要注意一个事情,也是非常重要的事情:

  • 尽量使用矢量图形制作动画

尽量减少AE的一些特效插件使用,像是粒子、FX之类的,在能使用矢量图形的地方就不要使用其他东西。否则导出的动效文件可能会包含一个文件夹,里面包含了很多帧的位图,众所周知,这东西很占用内存和增大包体积。

刚刚的动画导出的Json文件如图所示,一个18kb的文件,我们将它放在raw资源文件夹:

那么该如何使用呢?以下以Compose代码为例,只需要仅仅几行代码即可实现。

当设计师迫不得已使用了一些特效制作动图,例如发光等等,这个时候的文件比较大。我们可以通过服务器下发到手机,再展示动画也可以,这样也可以减少包体积,但是内存占用是不可避免了。

优化内存和包体积并不仅仅是开发人员的责任,设计师也可以参与其中。在我们的项目中,drawable 文件夹中可能包含一些 png 格式的图片资源。如果这些资源没有使用特效,而只是纯矢量图标,那么可以让设计师将其替换为 SVG 矢量图。

并且在Figma中也可以使用LottieFiles制作动画并直接生成Lottie动画文件,可以生成Json格式和文件大小更小的dotLottie格式,详情可以见我之前编写的Lottie生成动效

动画设计思想:符合直觉

由于本人并不是专业动画设计师,对于设计思想仅仅也是个人见解,仅供参考。

一个动效是否优秀的评判标准有很多,我觉得非常重要的是:这个动效需要符合直觉。我举一个小例子:

在如上动效中,我觉得稀土掘金是不太符合直觉的,头像从下方进入AppTopBar里面了,但是它却从上往下出来了,稍微给人一种违和感。但是它又是移动了一大段距离才降下来,减少了违和感,设计师应该也花了心思在里面。小红薯则是正经地从下往上出现,符合直觉。而这类型的动效我反而比较喜欢QQ音乐的,它的AppTopBar渐现的同时标题内容渐隐,在滑动转场的过程中给人一种顺滑的感觉。

当然,动效设计并没有高下之分。每位设计师和开发人员都希望寻求最优解,但受制于代码解耦、实现难度、无障碍等多种因素,也就是说动效开发这项工作是需要取舍的。

以下举几个小鹅事务所的例子来说明什么是符合直觉:

我在记账页面中给当前Icon做了一个切换动画,使用的是AnimatedContent,它的效果如下所示:

在点击上方新的Icon的时候需要切换当前Icon,由于动画需要符合直觉,切换动画应该从手指的方向弹到目标位置,即从上往下,为了更舒服的过渡,我加上了渐隐和渐现。代码如下所示:

kotlin 复制代码
AnimatedContent(
    targetState = iconAndContent,
    transitionSpec = {
        val inDurationMillis = 180
        val outDurationMillis = 160
        fadeIn(
            animationSpec = tween(
                durationMillis = inDurationMillis,
                delayMillis = 36,
                easing = LinearOutSlowInEasing
            )
        ) + slideIntoContainer(
            towards = AnimatedContentScope.SlideDirection.Down,
            animationSpec = tween(
                durationMillis = inDurationMillis,
                delayMillis = 36,
                easing = LinearOutSlowInEasing
            ),
            initialOffset = { it / 2 }
        ) with fadeOut(
            animationSpec = tween(outDurationMillis, easing = LinearOutSlowInEasing)
        ) + slideOutOfContainer(
            towards = AnimatedContentScope.SlideDirection.Down,
            animationSpec = tween(outDurationMillis, easing = LinearOutSlowInEasing),
            targetOffset = { it / 2 }
        )
    },
    label = "transaction content item"
) { iac ->
    // Icon and Text
    ...
}

在实现这个动画的时候,有两个细节需要注意,第一个是缓动曲线 ,第二个是delayMillis

动画规格缓动曲线

缓动曲线我选择了一个带有初始速度的LinearOutSlowInEasing曲线,它是Compose库预制的几个曲线之一。

kotlin 复制代码
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)

这里需要介绍一下CubicBezierEasing,这是一个以两个端点(0,0)(1, 1)组成的贝塞尔曲线。

横轴为动画时间、竖轴为动画进度,我们可以控制的是它的两个把手来制作一个贝塞尔曲线作为动画曲线,四个参数分别为第一个把手的xy轴,第二个把手的xy轴。上图的曲线可以使用CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)来实现,这个也是官方预制的的FastOutLinearInEasing

官方预制的常用的曲线如下所示,我们可以很容易得知这条曲线的切线为动画的速度:

其中中间的曲线为我们目前使用的曲线LinearOutSlowInEasing,它是一条带有初始速度并缓慢减速到终点的曲线。我们的目标是制作一个从视线外 到视线内的动画,是一个非常迅速的动画,给它一个初始速度是符合直觉 的,因此LinearOutSlowInEasing完美符合了我的需求。

动画规格选型

这里需要插入一个动画规格的选型参考 ,动画的其中一个作用是吸引用户注意,而一条舒服的缓动曲线还有一个非常重要的作用,方便用户视觉跟踪,使用这个曲线的图标带有速度进来,吸引到了用户注意,并且有一个减速的流程,因此用户在看到这个动画的时候能够快速追踪到这个图标停下来。

用户潜意识会觉得是自己的动态视力优秀暗暗自喜。

而一个已经在视图中的内容需要动起来,最好给它一个缓动的曲线,由静止慢慢加速,在快要结束的时候减速停下来,例如上方的第一个曲线,方便用户跟踪的同时也符合直觉。因为自然界的东西在移动的时候都是从静止加速再减速到静止。

如何设计一个舒服的动画规格曲线其实是一件很重要的事情。在Compose中实现一个好用的动画规格曲线是一件轻而易举的事情,如果项目允许使用Compose的话,优化动效体验应该提上日程了。

delayMillis

即进入动画规格中有一个 delayMillis,我将其设置为 36ms。为什么要延迟呢?在切换动画时,你需要等旧的走出去,新的才能进来,这是一个符合直觉的规范,需要让后进来的图标延迟一会。

如果进入和退出两个转场动画同时进行,会有一种非常"抢"的感觉,就好像进来的图标插队想要挤出旧的一样。

在官方默认的动画规格中也配有90ms的延迟,这一个理念是得到官方认可的。

关键帧

上方设置了delay其实也是关键帧的一种实现,一个完整的动画可能由非常多的子动画组成,未必所有子动画都从头到尾执行,例如上方的下拉搜索Icon动画就包含了关键帧计算,在不同的时间做不同的事情。在箭头变成搜索的时候,箭头先收回,棍子再移动,然后再绘制圆圈,这三个动画交替并行。

下面介绍一下小鹅事务所中的纪念日模块中的动画,它如下所示:

它的动画整体由两步组成:

  1. 纪念日内容往右移动将日期隐藏掉
  2. 日期再从纪念日展开出来

可以看到第二步会比第一步慢半拍,为什么要这么做?我打算给人一种日期从右边藏起来再从下边跑出来的有趣感觉。一旦两步同时执行的话会给人一种违和感:我可以在某个时间点看到两个日期,动画也失去了之前有趣的感觉。

在Compose中对于这种动画的实现是非常简单的事情,我们可以用到updateTransition这个API。

kotlin 复制代码
val transition = updateTransition(
    targetState = isExpended, label = "memorial expend animation"
)

transition身上可以延伸出大量的子动画参数,transition作为触发器可以触发子动画的执行。

kotlin 复制代码
val contentHeight by transition.animateDp(
    transitionSpec = {
        tween(
            durationMillis = TOTAL_ANIM_MILLIS,
            delayMillis = if (targetState) DELAY_ANIM_MILLIS else 0,
            easing = FastOutSlowInEasing
        )
    },
    label = "content height"
) {
    if (it) 180.dp else 52.dp
}

val titleBackgroundColor by transition.animateColor(
    transitionSpec = {
        tween(
            durationMillis = TOTAL_ANIM_MILLIS,
            delayMillis = if (targetState) 0 else DELAY_ANIM_MILLIS,
            easing = FastOutSlowInEasing
        )
    },
    label = "background color"
) {
    if (it) MaterialTheme.colorScheme.primary
    else MaterialTheme.colorScheme.surfaceVariant
}

val boxWidth by transition.animateDp(
    transitionSpec = {
        tween(durationMillis = TOTAL_ANIM_MILLIS, easing = FastOutSlowInEasing)
    },
    label = "box width"
) {
    if (it) 0.dp else 64.dp
}

并在对应的内容应用这些参数即可,例如:

kotlin 复制代码
val contentHeight by transition.animateDp(
    transitionSpec = {
        tween(
            durationMillis = TOTAL_ANIM_MILLIS,
            delayMillis = if (targetState) DELAY_ANIM_MILLIS else 0,
            easing = FastOutSlowInEasing
        )
    },
    label = "content height"
) {
    if (it) 180.dp else 52.dp
}

Column(
    modifier = Modifier.height(contentHeight)
) {
    // ...
}

大家可能会注意到我一直刻意地传入label参数,其实它方便在Android Studio中用作动画调试,在我们对Compose带有动画的函数进行Preview之后,我们可以在Preview的视图中看到动画调试器:

它是一个非常实用的功能,点击该按钮可以出现一个动画调试器,它可以显示我们设置的label值,方便我们在编写动画的时候进行调试,Color有四行的原因是其实内部会单独对RGBA四个值进行变换。

这个动画比之前的动画要稍长一些,因为这个涉及到了比较大的内容。而一般内容越大,幅度越大的动画时长就越长,但是也不能过长。动画的时长一般在100 - 300ms左右。

最好不要小于100,否则该动画让人应接不暇。普通的动画建议不要超过300ms,例如上方动画的时长为266ms,全屏的界面过渡效果则可以稍微超过300ms,但是不要超过500ms,否则用户等待时间过长,影响到用户体验了。

"约定俗称"

在动画设计上如果没有头绪也可以考虑遵循一下"约定俗成"原则,例如带有横杆的地方可以拖动就可以称之为设计的约定俗成:

不过有很多约定俗成其实让人摸不着头脑,由于用户已经习惯了,如果不按照约定来反而可能会让人觉得奇怪,例如Activity的跳转,以下APP在一个页面出现了三种跳转动画。

大家有没有想过为什么默认的页面跳转是从右往左进来的,底部的页面也要跟着从右往左移动一定的距离?其实我没太搞明白,但是觉得按照默认的来实现才舒服,让用户感觉到熟悉才可以减少用户的心理负担。

在Compose的Navigation框架中支持给页面跳转设置过渡动画,但是没有办法使底部页面变黑,于是我思来想去了很久,决定仿照Activity跳转的效果,加上一些新的想法来实现小鹅事务所的页面跳转动画。

既然页面的数据结构是栈,新的页面从右往左移进来。那么旧的页面可不可以缩小假装压下去让个位置给新的页面呢?于是我写下了如下代码:

kotlin 复制代码
private fun AnimatedContentScope<NavBackStackEntry>.activityEnterTransition(): EnterTransition {
    return slideIntoContainer(
        towards = AnimatedContentScope.SlideDirection.Start,
        animationSpec = tween(defaultDurationMillis),
        initialOffset = { it }
    )
}

private fun AnimatedContentScope<NavBackStackEntry>.activityExitTransition(): ExitTransition {
    return scaleOut(animationSpec = tween(defaultDurationMillis), targetScale = 0.96F)
}

private fun AnimatedContentScope<NavBackStackEntry>.activityPopEnterTransition(): EnterTransition {
    return scaleIn(animationSpec = tween(defaultDurationMillis), initialScale = 0.96F)
}

private fun AnimatedContentScope<NavBackStackEntry>.activityPopExitTransition(): ExitTransition {
    return slideOutOfContainer(
        towards = AnimatedContentScope.SlideDirection.End,
        animationSpec = tween(defaultDurationMillis),
        targetOffset = { it }
    )
}

它的效果是这样的,感觉也挺舒服的:

对于不同交互方式的跳转可以采用不同的进入方式,例如项目中做了一个下拉搜索的功能,在下拉进入第二个页面的时候,页面从上往下是最符合直觉的,所以我在跳转到搜索页面中使用了渐现+缓动的动画。

kotlin 复制代码
composable(
    route = ...
    enterTransition = {
        fadeIn(
            animationSpec = tween(200, easing = LinearOutSlowInEasing)
        ) + slideIntoContainer(
            towards = AnimatedContentScope.SlideDirection.Down,
            animationSpec = tween(200, easing = LinearOutSlowInEasing),
            initialOffset = { it / 6 }
        )
    },
    exitTransition = null,
    popExitTransition = {
        fadeOut(
            animationSpec = tween(200, easing = FastOutLinearInEasing)
        ) + slideOutOfContainer(
            towards = AnimatedContentScope.SlideDirection.Up,
            animationSpec = tween(200, easing = FastOutLinearInEasing),
            targetOffset = { it / 6 }
        )
    },
    popEnterTransition = null
) { }

在动画曲线上我选择了在进来的时候带有初始速度并缓缓停下的曲线,即刚刚介绍的LinearOutSlowInEasing,具体原因刚刚也解释过了。在离开的时候我选择了到终点也不会减速的曲线,给人一种页面离开不拖泥带水的感觉。

至于起始和目标距离我都对它除以6,上下滑动进入界面的过渡尽量避免完整地从屏幕边缘进来的,如果持续时间少了,会给人一种很突然的感觉。如果持续的时间长了,则给人一种很拖沓的感觉,影响用户体验。有一种情况例外:新页面和旧页面在视觉上有交互。举个例子,在音乐软件中通常会在下边放一个音乐播放bar,点击跳转到完整的播放页面,这种情况下的新页面就可以完整地从底部出来,并且不会有违和感。

spring动效规格

虽然官方大部分动画API默认的动效规格是模拟真实的运动,使用了spring,默认带有阻力且无弹性。

kotlin 复制代码
package androidx.compose.animation.core

private val defaultAnimation = spring<Float>()

但是这个API最好慎用,它模拟了弹簧真实的运动和阻力,也就是说它存在惯性和弹性,一旦随意修改参数把控不住,做出来的动画弹来弹去非常影响视觉体验,我们的APP通常是扁平的,如果出现弹跳的动画是一件跳脱的事情,我们的动画最好做到吸引注意而不抢眼。

但是,这并不是一件绝对的事情,在一些需要强提醒或者需要给用户更强的反馈的场景,就可以使用弹跳动效规格来增加"惊喜感"。

小鹅事务所在搜索模块中使用了弹跳动画规格,搜索本就是一件用户输入并寻求反馈的过程。

在这个场景使用弹跳并不会觉得跳脱,动画本身也是为业务服务的工具,动效设计应该根据不同场景使用不同的动画规格来进一步加强视觉效果和提高反馈效果。它的代码如下,使用了低弹性和中低的阻力,供大家参考。

kotlin 复制代码
spring(
    dampingRatio = Spring.DampingRatioLowBouncy,
    stiffness = Spring.StiffnessMediumLow
)

交互反馈

在滑动、点击等等交互的时候应该要给予用户反馈,一般采用动画的方式来反馈,我发现很多APP并没有做点击反馈,这点对于用户的体验不太好,经常让用户觉得是不是自己手机卡了没点上。

Material Design的按钮点击默认采用水波纹的形式来进行反馈。而实际项目中我们可以使用很多种反馈方式:

  • 圆角半径
  • 颜色
  • 透明度
  • 比例
  • 阴影
  • ...

方便的动效API

自动自适应布局大小

它在Compose的API是Modifier::animateContentSize,它的作用是:父布局根据测量的大小使用动画自动调整自身的大小和位置。它的实际效果是这样的:

它的好处是:简单,只需要通过一行代码即可实现大小调整动画。它可以传入两个参数:其中一个是动画规格,另外一个是动画完成监听器。

kotlin 复制代码
fun Modifier.animateContentSize(
    animationSpec: FiniteAnimationSpec<IntSize> = spring(),
    finishedListener: ((initialValue: IntSize, targetValue: IntSize) -> Unit)? = null
): Modifier

由于其API非常简单,没有太大自定义空间,因此只能用于比较简单的场景。它有以下问题

  • 内容消失突然
  • 水波纹无法填满布局

简单的设计也造成了这个问题没有很好的办法去做调整。

自动适应软键盘高度

在Android API 30以上可以在软键盘出来或隐藏的时候监听软键盘高度,我曾经使用这个特性实现了一个比较优雅的输入DialogFragment,TODO放链接,并且适配了不同的Android API,而在Compose中只需要传入一个Modifier即可实现该功能,Modifier.windowInsetsPadding(WindowInsets.ime)

也可以配合BottomAppBar使用实现如下效果:

kotlin 复制代码
BottomAppBar(
    modifier = modifier.fillMaxWidth(),
    windowInsets = if (WindowInsets.isImeVisible) {
        WindowInsets.ime.union(BottomAppBarDefaults.windowInsets)
    } else {
        BottomAppBarDefaults.windowInsets
    },
    ...
)

是否使用动态监听软键盘高度的API其实在视觉体验上差距不大,该功能适合追求更加完美的开发(和不在意性能损耗的应用)。

打破规则

在上面我介绍了很多动画规则,它不应成为限制你思维的枷锁,发挥你的想象力,打破以上规则,你可以制作更优秀的动效,更舒适的交互!以下放一些我很喜欢的动效案例。

结尾

恭喜大家入门Android动效设计,动效设计是一门很大的学问,大家有兴趣的话可以多探索!欢迎分享给其他开发、设计小伙伴一起观看。

相关项目

小鹅事务所:github.com/MReP1/Littl...

动效Demo:github.com/MReP1/Anima...

参考

相关推荐
Chrison_mu2 小时前
Android开发|关于Okhttp发送网络请求
android·网络·okhttp
rising_chain4 小时前
Uniapp 引入 Android aar 包 和 Android 离线打包
android·uni-app·uniapp 离线打包
.生产的驴7 小时前
Docker 部署Nacos 单机部署 MYSQL数据持久化
android·运维·spring boot·sql·mysql·docker·容器
找藉口是失败者的习惯9 小时前
Android adb 指令大全
android·adb
初学者-Study9 小时前
Android Osmdroid + 天地图 (二)
android·osmdroid地图点击·定位监听·marker配置
喜欢踢足球的老罗9 小时前
RN开发搬砖经验之—React Native(RN)应用转原生化-Android 平台
android·react native·react.js
红米饭配南瓜汤9 小时前
Android Binder通信02 - 驱动分析 - 架构介绍
android·架构·binder
️ 邪神9 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】启动页
android·flutter·ios·鸿蒙·reactnative
zhangphil10 小时前
Android从Drawable资源Id直接生成Bitmap,Kotlin
android·kotlin
HenCoder10 小时前
【泛型 Plus】Kotlin 的加强版类型推断:@BuilderInference
android·java·开发语言·kotlin