稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

降Compose十八掌之『震惊百里』| Animations

动画对于UI来说无疑是最重要的核心功能,它能够让UI变得生动有吸引力。适当的使用动画可以提升UI的流畅性,让UI体验更为顺滑。在Jetpack Compose中有丰富的函数可以用来实现动画,今天就从一些最为常用的学起,闲话就说这么多,赶紧开工。

所见即所得的动画函数

最为方便和快速上手的就是使用封装的最好的动画函数(Animation Composables)。

给Composable出现和隐藏加上动画

UI元素的出现和隐藏是动画最为常用的场景,让视觉体验平滑过度,不那么的突兀。使用函数AnimatedVisibility)就可以方便的给单个部件的出现和隐藏加上动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    var visible by remember {
        mutableStateOf(true)
    }

    AnimatedVisibility(visible) {
        Box(
            modifier = Modifier
                .size(200.dp)
                .clip(RoundedCornerShape(8.dp))
                .background(colorGreen)
        )
    }

    Button(modifier = Modifier.align(Alignment.BottomCenter), onClick = {
        visible = !visible
    }) {
        Text("Toggle Show/Hide")
    }

anim_visibility

注意:AnimatedVisibility做完淡出动画时,会把其子布局从渲染树中移除。

AnimatedVisibility默认会使用淡入(fadeIn)/淡出(fadeOut)+缩放(shrinking)作为内容的出现/隐藏动画,如果要指定不同的动画,可以通过参数enter和参数exit来指定。EnterTransitionExitTransition有很多预定义的动画可以使用,并且可以通过+进行组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically {
        // Slide in from 40 dp from the top.
        with(density) { -40.dp.roundToPx() }
    } + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("降Compose十八掌", Modifier.fillMaxWidth().height(200.dp))
}

仔细看AnimatedVisibility的实现,不难发现,它其实相当于是一个Column,可以当成一个Column来使用,动画是加在此布局上面的。它还支持对其子布局设置单独的动画。可以使用这个扩展函数animateEnterExit.animateEnterExit(androidx.compose.animation.EnterTransition,androidx.compose.animation.ExitTransition,kotlin.String))来对子布局的出现/隐藏加上特定的动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Composable
fun CustomForChildren(modifier: Modifier = Modifier.fillMaxSize()) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var visible by remember { mutableStateOf(true) }

        AnimatedVisibility(
            visible = visible,
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            // Fade in/out the background and the foreground.
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .background(Color.LightGray)) {
                Box(
                    Modifier
                        .align(Alignment.Center)
                        .animateEnterExit(
                            // Slide in/out the inner box.
                            enter = slideInHorizontally(),
                            exit = slideOutHorizontally()
                        )
                        .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                        .background(Color.Magenta)
                ) {
                    Text(
                        text = "你会看到不同的风景!",
                        style = MaterialTheme.typography.headlineLarge,
                        modifier = Modifier
                            .padding(16.dp)
                            .align(Alignment.Center)
                    )
                }
            }
        }

        Button(
            onClick = { visible = !visible },
            modifier = Modifier.padding(16.dp)
        ) {
            Text("点击有惊喜!!!")
        }
    }
}

children_enter_exit

两个布局之间淡入淡出

AnimatedVisibility只能用于单个部件或者单个布局的出现隐藏。但有时会涉及两个部件之间的切换,虽然也是一个出现,前一个隐藏,但它们是有联动的,这时就需要使用专门的切换动画函数Crossfade.Crossfade(androidx.compose.ui.Modifier,androidx.compose.animation.core.FiniteAnimationSpec,kotlin.Function1,kotlin.Function1)),最为典型的场景就是加载内容,先是显示加载进度,有数据可显示时就把进度隐藏,让内容显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Composable
fun CrossfadeDemo(modifier: Modifier = Modifier.fillMaxSize()) {
    var done by remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        delay(5000)
        done = true
    }

    Crossfade(
        modifier = modifier,
        targetState = !done,
        label = "crossfade"
    ) { loading ->
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = if (loading) Alignment.Center else Alignment.TopStart
        ) {
            if (loading) {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    CircularProgressIndicator(Modifier.size(66.dp))
                    Text(
                        text = "玩命加载中...",
                        modifier = Modifier.padding(16.dp),
                        style = MaterialTheme.typography.headlineMedium
                    )
                }
            } else {
                Text(
                    text =
                    """
                       “降龙十八掌可说是【武学中的巅峰绝诣】,当真是无坚不摧、无固不破。虽招数有限,但每一招均具绝大威力。
                        北宋年间,丐帮帮主萧峰以此邀斗天下英雄,极少有人能挡得他三招两式,气盖当世,群豪束手。
                        当时共有“降龙廿八掌”,后经萧峰及他义弟虚竹子删繁就简,取精用宏,改为降龙十八掌,掌力更厚。
                        这掌法传到洪七公手上,在华山绝顶与王重阳、黄药师等人论剑时施展出来,王重阳等尽皆称道。”
                    """.trimIndent(),
                    modifier = Modifier.padding(16.dp),
                    style = MaterialTheme.typography.headlineMedium
                )
            }
        }
    }
}

crossfade

通用的布局切换动画

如果变幻不止有淡入淡出,或者说布局不只有两个,这时就要用更为通用也更为强大的切换动画函数AnimatedContent),它可以用来定制多个布局之间两两切换的动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var state by remember {
    mutableStateOf(UiState.Loading)
}
AnimatedContent(
    state,
    transitionSpec = {
        fadeIn(
            animationSpec = tween(3000)
        ) togetherWith fadeOut(animationSpec = tween(3000))
    },
    modifier = Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null
    ) {
        state = when (state) {
            UiState.Loading -> UiState.Loaded
            UiState.Loaded -> UiState.Error
            UiState.Error -> UiState.Loading
        }
    },
    label = "Animated Content"
) { targetState ->
    when (targetState) {
        UiState.Loading -> {
            LoadingScreen()
        }
        UiState.Loaded -> {
            LoadedScreen()
        }
        UiState.Error -> {
            ErrorScreen()
        }
    }
}

animated_content

尺寸改变动画

UI元素的尺寸变化也是非常常用的一类动画,通常作为出场和入场比较合适。对于尺寸的改变可以通过Modifier.animateContentSize.animateContentSize(androidx.compose.animation.core.FiniteAnimationSpec,kotlin.Function2))来实现。它会自己感知尺寸的变化,然后触发动画。可以设置一个参数finishedListener以接收动画做完了的通知。需要特别注意的是,调用的顺序很重要,animateContentSize必须在任何的尺寸设置之前,还要注意的是尺寸必须根据不同的条件有所变化,要不然动画没机会展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var expanded by remember { mutableStateOf(false) }
Box(
    modifier = Modifier
        .background(colorBlue)
        .animateContentSize()
        .height(if (expanded) 400.dp else 200.dp)
        .fillMaxWidth()
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null
        ) {
            expanded = !expanded
        }

)

属性状态驱动动画

一般来说,动画的本质是让参数随时间变化,然后再让UI元素响应这些参数的变化,通过重新渲染,或者做渲染图层的变幻。在Compose中参数变化想影响部件的渲染,就必须把其封装成状态(State),这样参数变化就能被Compose感知到并做Recomposition。然后我们把状态的变化再通过属性设置给Composables,让其做渲染或者变幻,就形成了动画效果。这就是属性动画。

Compose定义了很多方法可以把参数转变成为状态,不同的参数可以通过不同的函数作用于不同的属性:

animateFloatAsState

可以把值的类型为浮点数的属性变为状态驱动动画,比如透明度(alpha),尺寸(size),间隔(padding/offset),字体大小(textSize)以及像旋转/缩放/位移等等。只要是浮点类型就可以用这个来转成状态,然后再通过相应的属性设置给Composable即可。

最为常用的就是结合graphicsLayer图层做变幻:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Composable
fun PropertyAnimation(modifier: Modifier = Modifier.fillMaxSize()) {
    var showing by remember { mutableStateOf(false) }

    val scale by animateFloatAsState(
        targetValue = if (showing) 0f else 1f,
        label = "property"
    )
    val alpha by animateFloatAsState(
        targetValue = if (showing) 0f else 1f,
        label = "property"
    )
    Column(
        modifier = Modifier.padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(
            text = "降Compose十八掌",
            modifier = Modifier
                .padding(20.dp)
                .graphicsLayer {
                    this.alpha = alpha
                    scaleX = scale
                    scaleY = scale
                },
            style = MaterialTheme.typography.headlineLarge
        )
        Spacer(Modifier.height(50.dp))
        Button(onClick = { showing = !showing}) {
            Text("再点一下试试!(试试就试试!)")
        }
    }
}

property_state

至于其他的像尺寸和间隔,虽然也可以,但因为像padding和offset会直接用dp或者Offset作为值,有更为舒适的API可以直接用(尽管浮点值也可以转换成为dp或者Offset)。

animateColorAsState

专门用于颜色值变化,指定两个值后,会在它们中间进行的插值作为动画的帧,能让颜色变化更为平滑和细腻。

animateIntOffsetAsState

用于把值Offset的变化变成动画,适合于使用Offset的地方,如Modifier.offset,Modifier.layout等。

animateDpAsState

把类型为Dp的值变为动画,适合所有能使用Dp作为参数值的地方,如padding,shadowElavation等。

小结:可以发现由属性状态驱动的动画使用起来比较麻烦,先是要把参数转化为状态,要管理好不同状态下参数的值,还要使用正确的函数把状态作用于Composable的属性。复杂的同时意味着强大,它能实现一些更为复杂的动画。推荐优先使用动画函数,如果无法满足再考虑用属性状态动画。

页面切换转场动画

页面是应用中较为完整的一屏UI,比如说新闻应用,列表页是一个页面,点开进入单篇新闻又是一个页面,用户中心是一个页面,设置又是一个页面。不同的页面之间的跳转称之为导航,用的是Jetpack中的库navigation,在Compose中通过navigation-compose做了桥接,所以在Compose中可以直接使用navigation,可以通过创建NavHost时通过参数enterTransition和exitTransition来为页面设置转场动画。可以为每个页面设置单独的转场,也可以设置一个统一的默认的转场动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
val navController = rememberNavController()
NavHost(
    navController = navController, startDestination = "landing",
    enterTransition = { EnterTransition.None },
    exitTransition = { ExitTransition.None }
) {
    composable("landing") {
        ScreenLanding(
            // ...
        )
    }
    composable(
        "detail/{photoUrl}",
        arguments = listOf(navArgument("photoUrl") { type = NavType.StringType }),
        enterTransition = {
            fadeIn(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideIntoContainer(
                animationSpec = tween(300, easing = EaseIn),
                towards = AnimatedContentTransitionScope.SlideDirection.Start
            )
        },
        exitTransition = {
            fadeOut(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideOutOfContainer(
                animationSpec = tween(300, easing = EaseOut),
                towards = AnimatedContentTransitionScope.SlideDirection.End
            )
        }
    ) { backStackEntry ->
        ScreenDetails(
            // ...
        )
    }
}

navigation_transition

组合首次运行时动画

对于像AnimatedVisibility以及使用animate&42;AsState属性状态动画来说,可以发现它们都是在组合发生之后才能生效,这是因为状态是为了重组而设置的,状态只会在组合之后部件渲染完了,响应事件由事件触发状态变化。组合首次运行的时候,状态仅是初始值,但不会变化,也就不会触发动画。

所以需要一个能在首次组合时就能运行的事件来触发动画依赖的状态,LaunchedEffect正合适。LaunchedEffect会在首次组合时运行,可以在里面执行一些「副作用」也就是Compose组合之外的行为。可以在这里触发动画的状态,就能够让动画在首次组合时生效了。这一般用作部件的出场动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun LaunchAnimation(modifier: Modifier = Modifier.fillMaxSize()) {
    val alphaAnimation = remember {
        Animatable(0f)
    }
    LaunchedEffect(Unit) {
        alphaAnimation.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = 30000)
        )
    }
    Box(
        modifier = Modifier
            .offset(16.dp, 16.dp)
            .size(200.dp)
            .graphicsLayer {
                alpha = alphaAnimation.value
            }
            .background(Color.Magenta)
    )
}

launch_anim 需要注意,LaunchedEffect的参数用作标识,参数有变化时,会再次运行,因此对于出场动画,LaunchedEffect的参数要设置为不可变的常量,如Unit。还需要注意的是,LaunchedEffect会在首次组合时运行,对于像集合性布局,会重复的使用子布局来展示元素项,所以每次元素项进入屏幕可视范围时,LaunchedEffect都会运行,动画都会触发,这并不是想要的结果,因为我们只想列表首次加载时触发动画。一个解决办法就是把状态和LaunchedEffect提高到列表的上一级Composable中。

调整动画参数进行定制

动画除了具体的形式以外,还有一些共性的参数可以设置,像时长,速度和是否重复,有过View动画经验的同学对此一定不会陌生。可以通过AnimationSpec对象来对动画参数进行定制,所有的动画API都能接受一个animationSpec参数。Compose提供了很多AnimationSpec的构造函数可以直接使用:

  • spring 刚性动画(或者叫做弹性动画)是模拟物理中刚性物体运动和碰撞的动画,与生活中的体验类似,所以这是默认的动画参数。可以通过调整硬度(stiffness)和阻尼系数(dampingRatio)来改变动画效果。
  • tween 补间动画,有一定时长,在两个值之间通过Easing函数插值形成的动画。
  • keyframes 关键帧动画,指定一定的关键节点作为动画中的帧。
  • repeatable 可重复一定次数的动画,通过RepeatMode指定重复方式(简单重复,或者反向播放)。
  • infiniteRepeatable 无限重复动画,通过RepeatMode指定重复方式。
  • snap 猛跳到目标值,无动画。

根据不同的动画参数,可以进一步的做更细致的参数调整,比如像时长和速度。

修改动画的时长和延时

动画肯定都有时长,即使无限重复的动画,其每一次也是有时长的。大部分AnimationSpec函数都能接受一个durationMillis参数来调整动画的时长。

动画被触发后,也不一定立马播放,参数本身有一个延时可以控制,大部分都能接受一个delayMilis参数来控制播放的延时。

注意,刚性动画(spring)比较特殊,它不能直接控制时长和延时,刚性动画是通过硬度和阻尼来调整,时长是根据它们计算出来的,而物理世界的刚硬碰撞哪有延时?

修改动画的播放速度

对于补间动画和关键帧动画,还可以通过Easing函数来改变动画的播放速度,比如匀速,先快后慢,先慢后快,匀速等等。Easing函数与View动画中的Interpolator是同样的东西。它是一个简单的数学函数,一个浮点数输入代表当前的时间点,一个浮点数输出代表动画应该到达的位置,取值都是0到1之间。可以理解为物理题,输入参数是时间t,输出则是位移s。

有很多定义好的Easing函数)可以使用,当然也可以自定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // ……
}

未完待续

动画是UI中比较复杂的话题,在Compose中更是如此,动画的本质是把参数转成状态随时间变化,状态再去驱动部件做渲染或者做变幻。本文总结了最常用的和封装层次较为高级的创建动画的方法,足以应付较为常见的动画需求场景。但涉及动画的内容还有很多,比如像与手势交互相关的动画,多个不同的部件联动的动画,以及像动画的性能调优等一些较复杂的话题,将在后续的文章中讲解。

References

Comments