美文网首页Android学习记录
Jeptpack Compose 官网教程学习笔记(五)动画

Jeptpack Compose 官网教程学习笔记(五)动画

作者: oddly | 来源:发表于2022-05-18 21:44 被阅读0次

    动画

    主要学习内容

    • 如何使用几个基础动画 API
    • 何时使用哪个 API

    动画原理

    相比于 Compose 中的动画,对于 View 体系中的动画我们更了解一些,比如 View 动画体系中的ObjectAnimator,其是基于动画过程的计算出的数值调用对应属性的setter方法,在View的setter方法中会调用invalidate(true)进行重绘

    ObjectAnimator animator = ObjectAnimator.ofFloat(tv,"alpha",1,0,1);
    animator.setDuration(2000);
    animator.start();
    

    ObjectAnimator内部就会通过反射机制去寻找setAlpha方法,将动画过程中计算出的数值传递给setAlpha方法

    public class View implements Drawable.Callback, KeyEvent.Callback,
            AccessibilityEventSource {
      ...
        public void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha) {
            ensureTransformationInfo();
            if (mTransformationInfo.mAlpha != alpha) {
                setAlphaInternal(alpha);
                if (onSetAlpha((int) (alpha * 255))) {
                    mPrivateFlags |= PFLAG_ALPHA_SET;
                    invalidateParentCaches();
                    invalidate(true);
                } else {
                    mPrivateFlags &= ~PFLAG_ALPHA_SET;
                    invalidateViewProperty(true, false);
                    mRenderNode.setAlpha(getFinalAlpha());
                }
            }
        }
        ...
    }
    

    在 Compose 中动画原理也差不了多少,都是计算出动画过程中的数值,然后去修改属性值并通知系统重绘该区域

    不过 Compose 中使用重组通知系统重新绘画,而重组通常发生在可组合函数使用的状态值发生变化的时候。所以在 Compose 中将动画中以状态方式记录需要变化的值就可以做到通知系统重新绘制组件,然后在协程中计算出要变化的值并改变状态值就可以实现动画效果

    所以你可以发现Compose中动画变化的值都是State

    基于上述的原理实现的最基础的动画效果,当然 Compose 中动画实现会更复杂、安全和高效

    class TestActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                Surface {
                    TestAnimation()
                }
            }
        }
    
        @Composable
        fun TestAnimation() {
            var width by remember {
                mutableStateOf(80.dp)
            }
    
            Button(onClick = {
                lifecycleScope.launch {
                    var i = 0
                    while (i<20) {
                        delay(50)
                        width+=3.dp
                        i++
                    }
                }
            }, modifier = Modifier.width(width)) {
                Text(text = "开始动画")
            }
        }
    }
    

    准备工作

    官网示例下载

    因为之后的代码都是基于其中的项目进行的,所以还是推荐下载。同时也可以看一下Google人员对于的Compose的代码编写风格

    因为代码过多且需要修改资源文件,此处就不将代码写出来了

    因为涉及动画效果,运行效果不好进行展示。请一定要自己进行编码,在虚拟机或真机(推荐)上运行查看效果

    其实是懒的制作gif (/ω\),笔记中所有的效果图为官网教程中的效果图

    该项目包含多个模块:

    • start 是本 Codelab 的起始状态
    • finished 是完成本 Codelab 后应用的最终状态

    我们可以选择Import Project方式进行学习,也可以通过拷贝代码到自己项目中的方式

    我使用的是拷贝代码的方式,可能之后跟Import Project方式有些区别请谅解

    start项目中,在每个我们需要修改的代码段前都带有 //TODO 注释,方便我们查找修改位置和修改需求

    Android Studio中,可以通过左下角的 TODO 工具窗口,然后浏览文件中的每个 TODO注释

    TODO 工具窗口

    简单值动画

    我们先从 Compose 中最简单的动画 API 着手

    运行项目,点击顶部的“Home”和“Work”按钮,尝试切换标签页。这样操作不会真正切换标签页内容,不过可以看到,内容的背景颜色会发生变化

    而我们要实现的效果就是让背景颜色的变化呈现动画效果,即增加过渡效果

    Home标签 Work标签

    我们点击 TODO 工具窗口中的 TODO 1

    val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
    

    背景颜色可以在紫色和绿色之间切换,具体取决于backgroundColor。我们需要为这个值的变化添加动画效果

    如需为诸如此类的简单值变化添加动画效果,我们可以使用 animate*AsState API。只需使用 animate*AsState 可组合项的相应变体(在本例中为 animateColorAsState)封装更改值,即可创建动画值。返回的值是 State<T> 对象,因此我们可以使用包含 by 声明的本地委托属性,以将该值视为普通变量

    val backgroundColor by animateColorAsState(targetValue = if (tabPage == TabPage.Home) Purple100 else Green300)
    

    重新运行应用并尝试切换标签页。现在颜色变化会呈现动画效果

    animateColorAsState效果

    可见性动画

    当我们滚动应用内容,会发现悬浮操作按钮按照滚动方向而展开和缩小

    找到 TODO 2-1 可以看到其背后的机制。它位于 HomeFloatingActionButton 可组合项中。使用 if 语句显示或隐藏表示“EDIT”的文本

    if (extended) {
        Text(
            text = stringResource(R.string.edit),
            modifier = Modifier
                .padding(start = 8.dp, top = 3.dp)
        )
    }
    

    添加可见性动画非常简单,只需将 if 替换为 AnimatedVisibility 可组合项即可

    AnimatedVisibility(extended) {
        Text(
            text = stringResource(R.string.edit),
            modifier = Modifier
                .padding(start = 8.dp, top = 3.dp)
        )
    }
    
    AnimatedVisibility效果

    当指定的 Boolean 值发生变化时,AnimatedVisibility 会运行其动画。默认情况下,AnimatedVisibility 会以淡入和展开的方式显示元素,以淡出和缩小的方式隐藏元素

    @Composable
    fun RowScope.AnimatedVisibility(
        visible: Boolean,
        modifier: Modifier = Modifier,
        enter: EnterTransition = fadeIn() + expandHorizontally(),
        exit: ExitTransition = fadeOut() + shrinkHorizontally(),
        label: String = "AnimatedVisibility",
        content: @Composable() AnimatedVisibilityScope.() -> Unit
    ) { ... }
    

    当然我们也可以自定义动画方式

    点击FloatingActionButton后,我们会看到条内容为“Edit feature is not supported”的消息,即EditMessage可组合项

    EditMessage中使用AnimatedVisibility 为其出现和消失添加动画效果,我们通过自定义动画效果,让其元素出现时从顶部移出,消失时移入至顶部

    找到 TODO 2-2 并查看 EditMessage 可组合项中的代码

    AnimatedVisibility(
        visible = shown
    ) {
        Surface(
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colors.secondary,
            elevation = 4.dp
        ) {
            Text(
                text = stringResource(R.string.edit_message),
                modifier = Modifier.padding(16.dp)
            )
        }
    }
    

    想要自定义动画,就需要我们指定enterexit 参数的值

    enter 参数是 EnterTransition 的实例,要实现组件移动效果,我们可以使用 slideInVertically 函数创建 EnterTransition

    此函数可使用 initialOffsetYanimationSpec 参数进一步自定义

    • initialOffsetY 是返回动画开始元素 y坐标 位置的 lambda。lambda 会收到一个表示元素高度的参数,因此我们只需返回其负值即可。使用 slideInVertically 时,滑入后的目标偏移量始终为 0(像素)。可使用 lambda 函数将 initialOffsetY 指定为绝对值
    @Stable
    fun slideInVertically(
        animationSpec: FiniteAnimationSpec<IntOffset> =
            spring(
                stiffness = Spring.StiffnessMediumLow,
                visibilityThreshold = IntOffset.VisibilityThreshold
            ),
        initialOffsetY: (fullHeight: Int) -> Int = { -it / 2 },
    ): EnterTransition =
        slideIn(
            initialOffset = { IntOffset(x = 0, y = initialOffsetY(it.height)) },
            animationSpec = animationSpec
        )
    

    slideInVertically会将元素y方向上的偏移量值会从initialOffsetY变为0

    所以要实现整个元素从顶部移出效果,我们需要让 lambda 函数返回 -元素高度

    • animationSpec 是包括 EnterTransitionExitTransition在内的许多动画 API 的通用参数。我们可以传递各种 AnimationSpec 类型中的一种,以指定动画值应如何随时间变化

      在本示例中,我们使用基于时长的简单 AnimationSpec。它可以使用 tween 函数创建。时长为 150 毫秒,加/减速选项为 LinearOutSlowInEasing

    @Stable
    fun <T> tween(
        durationMillis: Int = DefaultDurationMillis,
        delayMillis: Int = 0,
        easing: Easing = FastOutSlowInEasing
    ): TweenSpec<T> = TweenSpec(durationMillis, delayMillis, easing)
    

    easing参数可以理解为是动画执行时的速度控制器,可以类比为View体系中的Interpolator类(插值器)

    同样,我们可以对 exit 参数使用 slideOutVertically 函数。slideOutVertically 假定初始偏移量为 0,因此只需指定 targetOffsetY。我们对 animationSpec 参数使用相同的 tween 函数,但时长为 250 毫秒,加/减速选项为 FastOutLinearInEasing

    slideOutVertically中参数为animationSpectargetOffsetY,其中targetOffsetY是返回动画结束时元素 y坐标 位置的 lambda,即元素 y坐标 会从0开始变化为targetOffsetY

    最后代码:

    @Composable
    private fun EditMessage(shown: Boolean) {
        AnimatedVisibility(
            visible = shown,
            enter = slideInVertically(
                animationSpec = tween(
                    durationMillis = 150,
                    easing = LinearOutSlowInEasing
                ),
                initialOffsetY = { fullHeight -> -fullHeight }
            ),
            exit = slideOutVertically(
                animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing),
                targetOffsetY = { fullHeight -> -fullHeight }
            )
        ) {
            Surface(
                modifier = Modifier.fillMaxWidth(),
                color = MaterialTheme.colors.secondary,
                elevation = 4.dp
            ) {
                Text(
                    text = stringResource(R.string.edit_message),
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
    
    AnimatedVisibility效果

    内容大小变化动画

    在实例应用中点击TopicRow会展开并显示该主题的正文部分。当正文显示或隐藏时,包含文本的组件会展开或缩小

    查看 TopicRow 可组合项中 TODO 3 的代码

    @Composable
    private fun TopicRow(topic: String, expanded: Boolean, onClick: () -> Unit) {
        TopicRowSpacer(visible = expanded)
        Surface(
            ...
        ) {
            // TODO 3: Animate the size change of the content.
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
                    .animateContentSize()
            ) {
                ...
                //也可以用AnimatedVisiibility替换if,实现动画效果
                if (expanded) {
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = stringResource(R.string.lorem_ipsum),
                        textAlign = TextAlign.Justify
                    )
                }
            }
        }
        TopicRowSpacer(visible = expanded)
    }
    

    注释处的Column 可组合项会在内容发生变化时更改其大小。我们可以添加 animateContentSize 修饰符,为其大小变化添加动画效果

    animateContentSize效果

    当然我们也可以用可见性动画实现,不过具体效果上有些差异

    可见性动画:文本是从底部向上展开

    内容大小变化动画:文本从顶部向下展开

    多值动画

    现在我们已经熟悉一些基本的动画 API,接下来我们来了解一下 Transition API。借助该 API,我们可以制作更复杂的动画

    在本示例中,我们将完成HomeTabBar上显示的矩形的移动动画,不同于之间的简单值动画,这次将同时变化多个值实现移动和颜色变化

    image.png

    HomeTabIndicator 可组合项中找到 TODO 4,查看标签页指示器的实现方式

    @Composable
    private fun HomeTabIndicator(
        tabPositions: List<TabPosition>,
        tabPage: TabPage
    ) {
        // TODO 4: Animate these value changes.
        val indicatorLeft = tabPositions[tabPage.ordinal].left
        val indicatorRight = tabPositions[tabPage.ordinal].right
        val color = if (tabPage == TabPage.Home) Purple700 else Green800
        Box(
            Modifier
                .fillMaxSize()
                .wrapContentSize(align = Alignment.BottomStart)
                .offset(x = indicatorLeft)
                .width(indicatorRight - indicatorLeft)
                .padding(4.dp)
                .fillMaxSize()
                .border(
                    BorderStroke(2.dp, color),
                    RoundedCornerShape(4.dp)
                )
        )
    }
    

    其中,indicatorLeft 表示标签页行中指示器左侧边缘的水平位置。indicatorRight 表示指示器右侧边缘的水平位置。颜色也在紫色和绿色之间变化

    如需同时为多个值添加动画效果,可使用 TransitionTransition 可使用 updateTransition 函数创建。将当前所选标签页的索引作为 targetState 参数传递

    @Composable
    fun <T> updateTransition(
     targetState: T,
     label: String? = null
    ): Transition<T> { ... }
    

    targetState 发生变化时,Transition 会将其所有子动画运行到为新 targetState 指定的目标值

    每个动画值都可以使用 Transitionanimate* 扩展函数进行声明。在本示例中,我们使用 animateDpanimateColor。它们会接受一个 lambda 块,我们可以为每个状态指定目标值

    val transition = updateTransition(tabPage)
    val indicatorLeft by transition.animateDp { page ->
        tabPositions[page.ordinal].left
    }
    val indicatorRight by transition.animateDp { page ->
        tabPositions[page.ordinal].right
    }
    val color by transition.animateColor { page ->
        if (page == TabPage.Home) Purple700 else Green800
    }
    

    点击标签页会更改 tabPage 状态的值,这时与 transition 关联的所有动画值会开始以动画方式切换至为目标状态指定的值

    Transition多值动画

    此外,我们可以指定 transitionSpec 参数来自定义动画行为

    @Composable
    inline fun <S> Transition<S>.animateDp(
        noinline transitionSpec: @Composable Transition.Segment<S>.() -> FiniteAnimationSpec<Dp> = {
            spring(visibilityThreshold = Dp.VisibilityThreshold)
        },
        label: String = "DpAnimation",
        targetValueByState: @Composable (state: S) -> Dp
    ): State<Dp>
    

    例如,我们可以让靠近目标页面的一边比另一边移动得更快来实现指示器的弹性效果。可以在 transitionSpec lambda 中使用 isTransitioningTo infix 函数来确定状态变化的方向

    val transition = updateTransition(
        tabPage,
        label = "Tab indicator"
    )
    val indicatorLeft by transition.animateDp(
        transitionSpec = {
            if (TabPage.Home isTransitioningTo TabPage.Work) {
                spring(stiffness = Spring.StiffnessVeryLow)
            } else {
                spring(stiffness = Spring.StiffnessMedium)
            }
        },
        label = "Indicator left"
    ) { page ->
        tabPositions[page.ordinal].left
    }
    val indicatorRight by transition.animateDp(
        transitionSpec = {
            if (TabPage.Home isTransitioningTo TabPage.Work) {
                spring(stiffness = Spring.StiffnessMedium)
            } else {
                spring(stiffness = Spring.StiffnessVeryLow)
            }
        },
        label = "Indicator right"
    ) { page ->
        tabPositions[page.ordinal].right
    }
    val color by transition.animateColor(
        label = "Border color"
    ) { page ->
        if (page == TabPage.Home) Purple700 else Green800
    }
    
    Spring弹性效果

    重复动画

    点击当前气温旁边的刷新图标按钮。应用开始加载最新天气信息(当然只是模拟)。在加载完成之前,会看到加载指示器,即一个灰色圆圈和一个条形。我们来为该指示器的 Alpha 值添加动画效果,以便更清楚地呈现该进程正在进行

    LoadingRow 可组合项中找到 TODO 5

    @Composable
    private fun LoadingRow() {
        // TODO 5: Animate this value between 0f and 1f, then back to 0f repeatedly.
        val alpha = 1f
        Row(
            modifier = Modifier
                .heightIn(min = 64.dp)
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(48.dp)
                    .clip(CircleShape)
                    .background(Color.LightGray.copy(alpha = alpha))
            )
            Spacer(modifier = Modifier.width(16.dp))
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(32.dp)
                    .background(Color.LightGray.copy(alpha = alpha))
            )
        }
    }
    

    我们希望Alpha值设为在 0f 和 1f 之间以动画效果方式重复呈现,为此,可以使用 InfiniteTransition

    此 API 与 Transition API 类似。两者都是为多个值添加动画效果,但 Transition 会根据状态变化为值添加动画效果,而 InfiniteTransition 则无限期地为值添加动画效果

    如需创建 InfiniteTransition,请使用 rememberInfiniteTransition 函数。然后,可以使用 InfiniteTransition 的一个 animate* 扩展函数声明每个动画值变化

    在本例中,我们要为 Alpha 值添加动画效果,所以使用 animatedFloatinitialValue 参数应为 0f,而 targetValue 应为 1f。我们还可以为此动画指定 AnimationSpec,但此 API 仅接受 InfiniteRepeatableSpec。我们可以使用 infiniteRepeatable 函数创建InfiniteRepeatableSpec

    InfiniteRepeatableSpec 会封装任何基于时长的 AnimationSpec,使其可重复

    val infiniteTransition = rememberInfiniteTransition()
    
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1000),
            repeatMode = RepeatMode.Reverse
        )
    )
    

    运行应用,然后尝试点击刷新按钮。现在,您可以看到加载指示器会显示动画效果

    rememberInfiniteTransition效果

    手势动画

    最后一部分中,我们将学习如何基于触控输入运行动画

    要实现的效果类似于状态栏中通知的滑动事件,会根据滑动速度决定元素回到原位或被移除

    在这种情况下,需要考虑几个独特的因素。首先,任何正在播放的动画都可能会被触摸事件拦截。其次,动画值可能不是唯一的可信来源。换句话说,我们可能需要将动画值与来自触摸事件的值同步

    Modifier.swipeToDismiss修饰符中找到 TODO 6-1

    我们通过创建一个修饰符,以使触摸时元素可滑动。当元素被快速滑动到屏幕边缘时,我们将调用 onDismissed 回调,以便移除该元素

    Animatable 是我们目前看到的最低级别的 API。它有一些对手势场景非常有用的功能,所以我们可以创建一个 Animatable 实例,并使用它表示可滑动元素的水平偏移量

    val offsetX = remember { Animatable(0f) } //新增这行代码
    pointerInput {
        // 用于计算动画的稳定位置
        val decay = splineBasedDecay<Float>(this)
        // 在协程中使用挂起函数来处理触摸事件和动画
        coroutineScope {
            while (true) {
                // ...
    

    TODO 6-2 是我们刚刚收到向下轻触事件的位置。如果动画当前正在运行,我们应将其拦截。可以通过对 Animatable 调用 stop 来实现此目的

    当然如果动画未运行,系统会忽略该函数调用

    // 等待手指按下事件
    val pointerId = awaitPointerEventScope { awaitFirstDown().id }
    offsetX.stop() // 新增这行代码
    // 准备拖动事件,并且记录下移动的速度
    val velocityTracker = VelocityTracker()
    // 等待拖动事件
    awaitPointerEventScope {
    

    TODO 6-3 位置,我们不断接收到拖动事件。必须将触摸事件的位置同步到动画值中。为此,我们可以对 Animatable 使用 snapTo

    horizontalDrag(pointerId) { change ->
        // 新增下列三行代码
        // 获取到触摸事件位置
        val horizontalDragOffset = offsetX.value + change.positionChange().x
        launch {
            offsetX.snapTo(horizontalDragOffset)
        }
        // 记录拖动的速度
        velocityTracker.addPosition(change.uptimeMillis, change.position)
        // 消费掉这个手势事件,不向下传递事件
        change.consumePositionChange()
    }
    

    TODO 6-4 是元素刚刚被松开和快速滑动的位置。我们需要计算快速滑动操作的最终位置,以便确定是要将元素滑回原始位置,还是滑开元素并调用回调

    // 拖动结束,计算拖动的速度
    val velocity = velocityTracker.calculateVelocity().x
    // 新增这行代码
    val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
    

    calculateTargetValue会根据 initialValueinitialVelocity 计算浮点衰减动画的目标值

    TODO 6-5 位置,我们将开始播放动画。但在此之前,我们需要为 Animatable 设置值的上下界限,使其在到达界限时立即停止。借助 pointerInput 修饰符,我们可以通过 size 属性访问元素的大小,因此我们可以使用它获取界限

    offsetX.updateBounds(
        lowerBound = -size.width.toFloat(),
        upperBound = size.width.toFloat()
    )
    

    最终,我们可以在 TODO 6-6 位置开始播放动画。我们首先来比较之前计算的快速滑动操作的最终位置以及元素的大小。如果最终位置低于该大小,则表示快速滑动的速度不够。可使用 animateTo 将值的动画效果设置回 0f。否则,我们可以使用 animateDecay 来开始播放快速滑动动画。当动画结束(很可能是到达我们之前设置的界限)时,我们可以调用回调

    launch {
        if (targetOffsetX.absoluteValue <= size.width) {
            // 拖动的速度不够,滑动回原位
            offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
        } else {
            // 拖动的速度足够,将元素滑出屏幕边缘
            offsetX.animateDecay(velocity, decay)
            // 执行回调
            onDismissed()
        }
    }
    

    最后,我们来到 TODO 6-7。我们已设置所有动画和手势,因此,请记得对元素应用偏移

    .offset { IntOffset(offsetX.value.roundToInt(), 0) }
    

    最终代码:

    private fun Modifier.swipeToDismiss(
        onDismissed: () -> Unit
    ): Modifier = composed {
        val offsetX = remember { Animatable(0f) }//新增这行代码
        pointerInput(Unit) {
            // 用于计算动画的稳定位置
            val decay = splineBasedDecay<Float>(this)
            // 在协程中使用挂起函数来处理触摸事件和动画
            coroutineScope {
                while (true) {
                    // 等待手指按下事件
                    val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                    offsetX.stop()//新增这行代码
                    // 准备拖动事件,并且记录下移动的速度
                    val velocityTracker = VelocityTracker()
                    // 等待拖动事件
                    awaitPointerEventScope {
                        horizontalDrag(pointerId) { change ->
                            // 新增下列三行代码
                            // 获取到触摸事件位置
                            val horizontalDragOffset = offsetX.value + change.positionChange().x
                            launch {
                                offsetX.snapTo(horizontalDragOffset)
                            }
                            // 记录拖动的速度
                            velocityTracker.addPosition(change.uptimeMillis, change.position)
                            // 消费掉这个手势事件,不向下传递事件
                            change.consumePositionChange()
                        }
                    }
                    // 拖动结束,计算拖动的速度
                    val velocity = velocityTracker.calculateVelocity().x
                    val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)//新增这行代码
                    //新增这行代码
                    offsetX.updateBounds(
                        lowerBound = -size.width.toFloat(),
                        upperBound = size.width.toFloat()
                    )
                    launch {
                        launch {
                            if (targetOffsetX.absoluteValue <= size.width) {
                                // 拖动的速度不够,滑动回原位
                                offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                            } else {
                                // 拖动的速度足够,将元素滑出屏幕边缘
                                offsetX.animateDecay(velocity, decay)
                                // 执行回调
                                onDismissed()
                            }
                        }
                    }
                }
            }
        }.offset {
            IntOffset(offsetX.value.roundToInt(), 0)
        }
    }
    

    手势动画这块,官网教程没有铺垫一点手势相关的部分就开始于动画效果配合,对于初学者不是特别友好,而且动画效果也没有介绍完全,比如这里使用的Animatable就没有介绍清晰

    相关文章

      网友评论

        本文标题:Jeptpack Compose 官网教程学习笔记(五)动画

        本文链接:https://www.haomeiwen.com/subject/ikfzurtx.html