Compose 动画 : 高可定制性的动画 Animatable

Posted 氦客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Compose 动画 : 高可定制性的动画 Animatable相关的知识,希望对你有一定的参考价值。

1. Animatable和animateDpAsState的区别是什么

Animatableandroid Compose动画的底层API,如果我们查看源码,可以发现animateDpAsState内部是调用的animateValueAsState,而animateValueAsState内部调用的是Animatable

animateDpAsStateAnimatable更便捷易用,但屏蔽了部分功能,animateDpAsState抛弃了设置初始值的功能。
animateDpAsState是对于动画具体场景化的一种实现,它针对状态切换这一种动画的场景,设置了专门的扩展,而状态切换是不需要初始值的。

如果想要更高的可定制性(比如设置初始值),那么就使用Animatable

2. animateDpAsState实现动画

首先,我们使用animateDpAsState来实现一个简单的动画

var big by remember 
    mutableStateOf(false)

val anim = animateDpAsState(if (big) 100.dp else 50.dp)
Box(modifier = Modifier
    .size(anim.value)
    .background(Color.Blue)
    .clickable  big = !big )

3. 使用Animatable实现上述动画

var big by remember 
    mutableStateOf(false)

val size = remember(big) 
    if (big) 100.dp else 50.dp

val anim1 = remember  Animatable(size, Dp.VectorConverter) 
LaunchedEffect(key1 = big, block = 
    anim1.animateTo(size)
)
Box(modifier = Modifier
    .size(anim1.value)
    .background(Color.Blue)
    .clickable  big = !big )

可以发现效果是一样的

这里需要注意的点有

  • Dp.VectorConverter : 是TwoWayConverter<T, V> 类型,用来进行数值转换
  • LaunchedEffect : 是Compose中的协程,animateTo是一个挂起函数,需要在协程中使用

接下来我们来逐个讲解

3.1 TwoWayConverter

Animatable中默认的TwoWayConverterFloat.VectorConverter

fun Animatable(
    initialValue: Float,
    visibilityThreshold: Float = Spring.DefaultDisplacementThreshold
) = Animatable(
    initialValue,
    Float.VectorConverter,
    visibilityThreshold
)

可以看到Float.VectorConverterTwoWayConverter<Float, AnimationVector1D>类型,说明是直接转换成Float

val Float.Companion.VectorConverter: TwoWayConverter<Float, AnimationVector1D>
    get() = FloatToVector

private val FloatToVector: TwoWayConverter<Float, AnimationVector1D> =
    TwoWayConverter( AnimationVector1D(it) ,  it.value )

3.2 Animatable 支持 多种转换

Animatable不仅支持Float,还支持多种转换

//Float (默认不传就是Float类型)
val animatableFloat1 = remember  Animatable(100F) 
val animatableFloat2 = remember  Animatable(100F,Float.VectorConverter) 
//DP
val animatableDp = remember Animatable(100.dp,Dp.VectorConverter) 
//Int
val animatableInt = remember  Animatable(100, Int.VectorConverter) 
//Rect
val animatableRect =
    remember  Animatable(Rect(100F, 100F, 100F, 100F), Rect.VectorConverter) 
//Offset
val animatableOffset = remember 
    Animatable(Offset(100F, 100F), Offset.VectorConverter)

//IntOffset
val animatableIntOffset = remember 
    Animatable(IntOffset(100, 100), IntOffset.VectorConverter)

//Size
val animatableSize = remember 
    Animatable(Size(100F, 100F), Size.VectorConverter)

//IntSize
val animatableIntSize = remember 
    Animatable(IntSize(100, 100), IntSize.VectorConverter)


val blackColor = Color(0xFF000000)
val converter = remember(blackColor.colorSpace) 
    (Color.VectorConverter)(blackColor.colorSpace)

//Color
val animatableColor = remember 
    Animatable(blackColor, converter)

3.3 1D、2D、3D和4D的区别

val animatableDp = remember Animatable(100.dp,Dp.VectorConverter) 

我们以Dp.VectorConverter为例,可以发现AnimationVector1D后缀带有1D字样,这是啥意思呢?

val Dp.Companion.VectorConverter: TwoWayConverter<Dp, AnimationVector1D>
    get() = DpToVector

D代表的是维度,即dimension的意思。1D、2D、3D、4D分别代表着一维、二维、三维、四维

val animatableOffset = remember 
    Animatable(Offset(100F, 100F), Offset.VectorConverter)

比如这个Offset就是二维的

val Offset.Companion.VectorConverter: TwoWayConverter<Offset, AnimationVector2D>
    get() = OffsetToVector

4. Compose中的协程

Animatable中的animateTo是一个挂起函数,需要在协程中使用。

4.1 之前协程的写法

lifecycleScope.launch 
    //TODO

4.2 LaunchedEffect

但是在Compose里面,不能这么写,因为这种用法,没有针对Compose做优化,在Compose重组过程中,会重复进行调用。

所以Compose提供了一个专门在Compose中启动协程的API : LaunchedEffect

LaunchedEffect(key1 = 123, block = 
	//代码块
    anim1.animateTo(100.dp)
)

key不变的话可以这么写 : LaunchedEffect(Unit, block = )

这里,只要key没有发生变化,那么block代码块就不会再次执行。这里也可以有多个key

LaunchedEffect(key1 = 123,key2=456,key3=789, block = )

如果有让LaunchedEffect多次执行的场景,那么key1参数就需要可以变化
到这里,我们也就能看懂 3. 使用Animatable实现上述动画 中的代码了

var big by remember 
    mutableStateOf(false)

val size = remember(big) 
    if (big) 100.dp else 50.dp

val anim1 = remember  Animatable(size, Dp.VectorConverter) 
LaunchedEffect(key1 = big, block = 
    anim1.animateTo(size)
)
Box(modifier = Modifier
    .size(anim1.value)
    .background(Color.Blue)
    .clickable  big = !big )

5. 使用Animatable进行流程定制

Animatable对于动画是高度可定制化的,我们可以通过Animatable来进行动画流程的定制

var big by remember 
        mutableStateOf(false)
    
    val size = remember(big) 
        if (big) 100.dp else 50.dp
    
    val anim1 = remember  Animatable(size, Dp.VectorConverter) 
    LaunchedEffect(key1 = big, block = 
        //流程定制
        anim1.animateTo(10.dp)
        anim1.animateTo(200.dp)
        anim1.animateTo(size)
    )

    Box(modifier = Modifier
        .size(anim1.value)
        .background(Color.Blue)
        .clickable  big = !big )

比如这里会先动画执行到10.dp,然后再执行到200.dp,最后再执行目标的size
效果如下所示

6. snapTo 瞬间完成动画

snapTo会瞬间完成动画,就和没有动画的效果进行变化是一样的。
那这个方法有啥用呢 ?
可以在开始动画前先瞬间设置好初始动画状态,从而达到设置动画初始值的效果。

var big by remember 
    mutableStateOf(false)

val size = remember(big) 
    if (big) 100.dp else 50.dp

val anim1 = remember  Animatable(size, Dp.VectorConverter) 
LaunchedEffect(key1 = big, block = 
    //代码块
    anim1.snapTo(size) //瞬间完成 ---> 设置初始值
)

Box(modifier = Modifier
    .size(anim1.value)
    .background(Color.Blue)
    .clickable  big = !big )

效果如下所示

7. animateDecay 惯性衰减动画

animateDecay是惯性衰减动画,比如惯性滑动操作,可以实现和Android自带的惯性滑动一致的效果。
那和animateTo有啥区别呢 ?
最大的区别在于 animateTo 是需要设置目标值的,也就是动画结束的那一刻 某个view属性的值 必须明确指定,
而惯性衰减动画 animateDecay 则不需要指定。

  • animateDecay: 从初始速度慢慢停下来,例如松手之后的惯性滑动
  • animateTo: 指定结束的属性值
val anim = remember 
    Animatable(0.dp, Dp.VectorConverter)

val decay = remember 
    exponentialDecay<Dp>()

LaunchedEffect(key1 = Unit, block = 
    delay(1000L)
    anim.animateDecay(2000.dp, decay) 
        //动画监听,可以获取动画当前的值 this.value
    
)
Box(
    modifier = Modifier
        .padding(0.dp, anim.value, 0.dp, 0.dp)
        .size(50.dp)
        .background(Color.Blue)
)

效果如下所示

7.1 两种decay的区别

exponentialDecayrememberSplineBasedDecay都可以实现惯性衰减动画,但它们两个有什么区别呢 ?

  • splineBasedDecay : 一般情况下只有在使用像素的情况下,会使用这个,它不会做针对像素做修正的,多个设备滑动效果会不一致
  • exponentialDecay :其他情况下一般都是用这个,不会根据像素密度变化而变化,比如DP,颜色,角度之类的

7.2 为什么rememberSplineBasedDecay自带remember

我们可以发现,rememberSplineBasedDecay是自带remember的,但是exponentialDecay却没有自带remember,为什么会这么设计呢 ? 我们来分别看一下rememberSplineBasedDecay的源码

rememberSplineBasedDecay

@Composable
actual fun <T> rememberSplineBasedDecay(): DecayAnimationSpec<T> 
    val density = LocalDensity.current
    return remember(density.density) 
        SplineBasedFloatDecayAnimationSpec(density).generateDecayAnimationSpec()
    

可以看到remember中传入了density这个参数,说明rememberSplineBasedDecay会随着density像素密度的改变而改变。
exponentialDecay 则因为不会响应系统的变化,所以不需要在remember中传入density,就由使用者自己来包装remember就好了。

7.3 animateDecay中的block监听

suspend fun animateDecay(
        initialVelocity: T,
        animationSpec: DecayAnimationSpec<T>,
        block: (Animatable<T, V>.() -> Unit)? = null
    ): AnimationResult<T, V>

animateDecay中有个block的监听,动画发生变化的时候,会回调这个监听。
通过this.value能够取到当前的动画值。
我们可以通过这个block回调,来让其他view 响应这个动画的变化。

LaunchedEffect(key1 = Unit, block = 
    anim.animateDecay(2000.dp, decay) 
        //动画监听,可以获取动画当前的值 this.value
    
)

8. Compose 动画系列

Compose 动画系列,后续持续更新
Compose 动画 (一) : animateXxxAsState 实现放大/缩小/渐变等效果
Compose 动画 (二) : 为什么animateDpAsState要用val ? MutableState和State有什么区别 ?
Compose 动画 (三) : AnimatedVisibility 从入门到深入
Compose 动画 (四) : AnimatedVisibility 各种入场和出场动画效果
Compose 动画 (五) : animateContentSize / animateEnterExit / Crossfade / AnimatedContent
Compose 动画 (六) : 使用Transition管理多个动画,实现动画预览

动画实现更简单,Navigation Compose 帮您忙

Jetpack Compose 将动画实现的门槛降低了——从 “如果有时间再慢慢打磨” 到 “动画实现很简单,没有理由不试试看了”。这里有个很大的课题是页面级的过渡动画,这也是 Navigation Compose 一直致力解决的问题,具体是满足下面三种场景:

  • 仅使用 Compose 1.0.0 中稳定的动画 API
  • 开始对 Compose 1.0.0 中存在的实验性动画 API 提供支持
  • 构建在 Compose 1.1.0 及更高版本中面向未来的动画 API (共享元素过渡)

每一种情况的实现方法都稍有不同,我们将在本文中介绍。

Compose 💚 动画

从首次发布 Jetpack Compose 0.1.0-dev01 到最新的 Compose 1.0.1,经历了漫长的过程。相对于 View 系统而言,它巨大的改进之一便是动画和过渡。在追求完美的动画 API 的过程中,对 Compose 进行了大量的修改才一步步迭代到 版本 1.0.0

虽然许多底层的动画 API,比如非常强大的 animateTo()animate*AsState() 到目前为止是 Compose 稳定的基础构成部分,但仍有许多基于这些代码构建的 API 被标记为@ExperimentalAnimationApi

实验性 API 和语义化版本控制

实验性 API (任何在 Kotlin 领域使用 @RequiresOptIn 注解的 API) 可能随时会被更改。这意味着这些 API 可能在未来任一版本 (可能是 Compose 1.1.0-alpha04 或者 1.2.0-alpha08) 中被更改、优化或替换。因此,如果您使用了任何一个基于这些实验性 API 构建的库,当您更新了您使用的 Compose 版本但没有同时更新这些库的版本时,这些库可能会直接崩溃并构建失败。(如果您使用了早期发布的 Compose 版本,您就会知道这种痛苦。)

所有 AndroidX 库 (包括 Navigation 和 Compose),都遵循 严格的语义化版本控制,如 AndroidX 版本页面 所述。这意味着一旦某个库迭代至候选版本 (Release Candidate,即 RC),任何非实验性 API 将不会再被更改。对这些稳定的 API 进行破坏性变更需要增加主版本号 (如,‘2.0’)。

这对向前和向后兼容很友好。例如,您可以升级 Fragment 版本以尝试新的 alpha 内容,同时将其他依赖项保持在其稳定版本上,一切工作如常。

然而,这也意味着严格禁止实验性 API (即可以从您底层移除的 API) 跨越不同的库使用。例如,升级您的 androidx.fragment 版本不应该破坏 androidx.appcompat。这一规则同样应用于 androidx.navigation 和 androidx.compose.animation。

使 Navigation 2.4 稳定

Navigation 2.4 是一个重要的版本,它既是第一个 Navigation Compose 版本,也是第一个对 Navigation Compose 和带有 Fragment 的 Navigation 支持 多返回栈 的版本。这意味着我们正在整理剩余的相关 API 需求以准备通过测试版、RC 版和稳定版。

对于 Navigation Compose 而言,这意味着我们正基于 Compose 1.0.1 进行构建,并为那些想要 (或者已经) 开始依赖 Compose 1.1.0-alpha01 或更新版本的开发者提供向前兼容。

这种向前兼容性要求意味着 Navigation Compose 2.4.0 的任何代码只能依赖于稳定的 Compose 动画 API。这也是我们在 Navigation 2.4.0-alpha05 中增加交叉淡入淡出支持的方式——在 Compose 的世界中,您应该首先消除生硬的页面跳转。

这种仅使用稳定 Compose 动画 API 的限制意味着 Navigation 2.4 不能直接使用 AnimatedContent 之类的 API,您不能将它们直接作为 Navigation 2.4 的一部分来使用以实现那种丰富的动画控制。但是,Navigation 的可扩展性意味着底层框架已经被构建好了并且是可用的。

介绍: Accompanist 导航动画!

对于目的地之间动画切换的支持是我们能发布 Accompanist Navigation Animation 的原因,它基于最近发布的 Navigation 2.4.0-alpha06。导航动画库为您一直在使用的 Navigation Compose API 提供一套带动画的版本:

  • 使用 rememberAnimatedNavController() 替换rememberNavController()

  • 使用 AnimatedNavHost 替换 NavHost

  • 使用 import com.google.accompanist.navigation.animation.navigation 替换 import androidx.navigation.compose.navigation

  • 使用 import com.google.accompanist.navigation.animation.composable 替换 import androidx.navigation.compose.composable

乍一看,您应用的外观没有发生改变——默认动画仍然是 fadeIn 和 fadeOut 类型,与 Navigation 2.4 中所提供的淡入淡出类型相同。然而,您将获得一项重要的新功能——能够配置这些动画并在页面之间替换您自己的过渡动画

每个 composable 目的地都有四个新参数可以设置:

  • enterTransition: 指定当您使用 navigate() 导航至该目的地时执行的动画。
  • exitTransition: 指定当您通过导航至另一个目的地的方式离开该目的地时执行的动画。
  • popEnterTransition: 指定当该目的地在经过调用 popBackStack() 后重新入场时执行的动画。默认为 enterTransition。
  • popExitTransition: 指定当该目的地在以弹出返回栈的方式离开屏幕时执行的动画。默认为 exitTransition

在每种情况下,这些参数都具有相同的格式:

enterTransition: (
   (
       initial: NavBackStackEntry,
       target: NavBackStackEntry
   ) -> EnterTransition?
)? = null,

每个参数都接收一个 lambda。该 lambda 有两个 NavBackStackEntry 类型的参数,分别表示您来自何处 (initial) 和您要去往何处 (target)。以 enterTransition 为例,将要进入的目的地为 target—— 也就是将要启用 enterTransition 的目的地。而 exitTransition 则相反: initial 为将要执行退出动画的目的地。

这使得您可以像这样编写目的地:

composable(
  "profile/id",
  enterTransition =  _, _ ->
    // 让我们写一个很长的淡入
    fadeIn(animationSpec = tween(2000)
  
) 
  // 像往常一样添加内容

或者,根据您来自/去往何处来控制您的动画:

composable(
  "friendList"
  exitTransition =  _, target ->
    when (target.destination.route) 
      "profile/id" -> ExitTransition.fadeOut(
          animationSpec = tween(2000)
      ) // 慢慢地淡出
      else -> null // 使用默认值
    
  
) 
  // 像往常一样添加内容

composable(
  "profile/id",
  enterTransition =  initial, _ ->
    when (initial.destination.route) 
      "friendList" -> slideInVertically(
          initialOffsetY =  1800 
      ) // 滑入 profile 页面
      else -> null // 使用默认值
  
) 
  // 像往常一样添加内容

在这里,friendList 页控制其退出到 profile 页的过渡动画,profile 页控制其从 friendList 页进入的过渡动画,并且允许在这两个目的地之间自定义滑动动画。同时,我们可以使用 null 表示 “使用默认值”。这些默认值依次来自父导航图、父导航图的父导航图,一直向上到根 AnimatedNavHost。这意味着想要设置默认动画 (例如,交叉淡入淡出的时机),只需要在您的 AnimatedNavHost 中修改全局的 enterTransitionexitTransition

如果您只想修改某个子图的默认值 (例如,您的登录子图中的页面总是使用横向滑动动画),您也可以在嵌套图级别设置动画:

navigation(
  startDestination = "ask_username"
  route = "login"
  enterTransition =  initial, _ ->
    // 检查上一个页面是否在登录子图中
    if (initial.destination.hierarchy.any  it.route == "login" ) 
      slideInHorizontally(initialOffsetX =  1000 
     else
      null // 使用默认值
  
  exitTransition =  _, target ->
    // 检查新的页面是否在登录子图中
    if (target.destination.hierarchy.any  it.route == "login" ) 
      slideOutHorizontally(targetOffsetX =  -1000 
     else
      null // 使用默认值
  
  popEnterTransition =  initial, _ ->
    // 检查上一个页面是否在登录子图中
    if (initial.destination.hierarchy.any  it.route == "login" ) 
      // 请注意我们在 pop 操作时从相反的方向做动画
      slideInHorizontally(initialOffsetX =  -1000 
     else
      null // 使用默认值
  
  popExitTransition =  _, target ->
    // 检查新的页面是否在登录子图中
    if (target.destination.hierarchy.any  it.route == "login" ) 
      // 请注意我们在 pop 操作时从相反的方向做动画
      slideOutHorizontally(targetOffsetX =  1000 
     else
      null // 使用默认值
  
) 
  composable("ask_username") 
    // 添加内容
  
  composable("ask_password") 
    // 添加内容
  
  composable("register") 
    // 添加内容
  

请注意我们使用 hierarchy 扩展方法 来判断某个目的地是否属于登录子图的一部分——这样一来,我们进入登录子图和离开登录子图的过渡动画将使用默认值 (或者您在更高一级设置的任何过渡动画)。

每当您有一个方向性的过渡动画,比如水平滑动时,enterTransition 和 popEnterTransition 之间的区别就非常方便——您将能够避免造成一个页面向右滑动而另一个页面向左滑动的情况。

Accompanist 充当了 Jetpack 库的助推器,使得我们可以在 Compose 1.1 的开发过程中立即获得实验性功能。

添加 Accompanist 导航动画 依赖:

implementation "com.google.accompanist:accompanist-navigation-animation:0.16.0"

Navigation Compose 和动画的未来

随着基于 Compose 1.0.1 的 Navigation 2.4 和 Accompanist 导航动画库通过实验性 API 突破了 Compose 1.0 的限制,还有其他内容即将展现: Compose 1.1。通过 Compose 路线图 可以发现,有一个非常重要的、令人兴奋的功能即将推出:

支持共享元素过渡

我们对于 Navigation 2.5 的目标是将 Compose 1.1 的所有优点带到 Navigation Compose 中。这意味着当动画 API 解除实验性状态时,我们可以直接将其带到 Navigation Compose。这也意味着我们可以构建支持共享元素过渡的 API。

这还意味着 Accompanist 导航动画应该被视为一种临时措施: 一旦 Navigation Compose 自身提供了相同级别的动画 API (根据您的反馈量身定做),您将可以直接依赖于它并且可以完全移除 Accompanist 导航动画库。

继续前进

平衡稳定性以及我们作为 Jetpack 库对自己提出的向前和向后兼容性要求,并具有快速交付功能的能力,这并不像我们想象的那么简单。随着 Jetpack Compose 不断发展,对不断超前的需求而言,Accompanist 是一个巨大的福音。我要感谢 Chris Banes 和所有投入时间在 Accompanist 上的开发者、Compose 背后的整个团队,以及大家帮助塑造 Android 开发的未来。

备注 : 如果您正在寻找更多关于 Navigation+Accompanist 的资料,请查阅:

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

以上是关于Compose 动画 : 高可定制性的动画 Animatable的主要内容,如果未能解决你的问题,请参考以下文章

Compose 动画艺术探索之可见性动画

Compose 动画 : AnimatedVisibility 各种入场和出场动画效果

动画实现更简单,Navigation Compose 帮您忙

Jetpack Compose中的动画

Compose 动画边学边做 - 夏日彩虹

Compose 动画边学边做 - 夏日彩虹