MotionLayout:动态工具栏

2,528 阅读7分钟

Styling Android的常规读者可能已经猜到我喜欢动画的东西。 MotionLayout为动画提供了惊人的范围,并且可以使用它创建一些非常有趣的动画。我们之前在Styling Android上看过how to implement a Collapsing Toolbar,但我们不仅限于模仿可以使用其他Android API实现的现有行为,MotionLayout给了我们一些真正的范围来获得时髦。在本文中,我们将略微突破界限并探索一些我们可以与MotionLayout一起使用的有趣技术。原文

在我们开始之前,值得指出的是,在动画方面很容易超越顶部。虽然很容易在动画中添加越来越多的复杂组件,但有时知道何时停止添加其他组件,甚至删除那些不适合整体流程的组件,是实现感觉自然的动画的关键。此外,最有效的动画通常是那些结合了非常简单的组件动画的动画,这些动画相互补充,并创造出比实际更难实现的动画。

让我们首先看看我们将要实现的整体效果。这是一个工具栏,可能会出现在一个体育应用程序中,该应用程序显示正在进行的足球比赛(美国足球比赛)的信息:

(我应该指出,我对2019年F.A.杯决赛的结果并不感到痛苦。没有丝毫。)

虽然动画中有很多东西在进行,但各种动作都在一起,因此整体效果感觉非常流畅。实际的绿色矩形块Toolbar实际上并没有改变大小,但是当前的匹配时间文本(89.59)移动到Toolbar的边界之外,并且有一种从Toolbar底部扩展的气泡形状以包含它。这是动画中可能最有趣的部分,因此本文的大部分内容都将关注这一点。

我不打算完整描述MotionLayout的机制,因为previous article已经涵盖了。关键是我们有效地定义了两个静态,每个静态表示为ConstraintSet。展开状态如下所示:

崩溃的州看起来像这样:

MotionLayout本身声明如下:

RES/布局/activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/collapsing_toolbar"
    tools:context=".MainActivity">

    <View
        android:id="@+id/toolbar"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/colorPrimary" />

    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/event_name"
        android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Subtitle.Inverse" />

    <TextView
        android:id="@+id/score"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/score"
        android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title.Inverse" />

    <ImageView
        android:id="@+id/man_city_logo"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:adjustViewBounds="true"
        android:contentDescription="@null"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:src="@drawable/man_city" />

    <TextView
        android:id="@+id/man_city"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/man_city"
        android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title.Inverse"
        app:layout_constraintBaseline_toBaselineOf="@id/score"
        app:layout_constraintEnd_toStartOf="@id/man_city_logo"/>

    <ImageView
        android:id="@+id/watford_logo"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:adjustViewBounds="true"
        android:contentDescription="@null"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:src="@drawable/watford" />

    <TextView
        android:id="@+id/watford"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/watford"
        android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Title.Inverse"
        app:layout_constraintBaseline_toBaselineOf="@id/score"
        app:layout_constraintStart_toEndOf="@id/watford_logo"/>

    <View
        android:id="@+id/toolbar_extension"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@drawable/bubble"
        android:backgroundTint="@color/colorPrimary"
        app:layout_constraintEnd_toEndOf="@id/time"
        app:layout_constraintStart_toStartOf="@id/time" />

    <TextView
        android:id="@+id/time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:paddingStart="24dp"
        android:paddingEnd="24dp"
        android:text="@string/time"
        android:textAppearance="@style/TextAppearance.AppCompat.Widget.ActionBar.Subtitle.Inverse" />

    <FrameLayout
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/toolbar_extension" />
</androidx.constraintlayout.motion.widget.MotionLayout>

虽然MotionLayoutConstraintLayout的子类,但是在此布局文件中没有声明约束 - 它们都在名为@xml/collapsing_toolbarlayoutDescriptor文件中声明。

这个文件包含我们MotionLayoutMotionScene

RES/XML/collapsing_toolbar.xml


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Transition
        app:constraintSetEnd="@id/collapsed"
        app:constraintSetStart="@id/expanded">

        <OnSwipe
            app:dragDirection="dragUp"
            app:touchAnchorId="@id/recyclerview"
            app:touchAnchorSide="top" />

    </Transition>

    <ConstraintSet android:id="@+id/collapsed">
        ...
    </ConstraintSet>

    <ConstraintSet android:id="@+id/expanded">
        ...
    </ConstraintSet>

</MotionScene>

这将在其自己的ConstraintSet中声明扩展和折叠的约束,并将其关联为拖动手势,以便拖动两个状态之间的过渡。 对于不熟悉这一点的人:earlierMotionLayoutarticle更详细地介绍了这一点。

我们应用于各个视图的大多数动画都在缩放它们。 如果你看看早期的动画GIF显示动画的外观,并专注于顶部的文本F.A. Cup Final 2019,它只是变得越来越小,ConstraintSets中的约束是:

RES/XML/collapsing_toolbar.xml


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    ...
    <ConstraintSet android:id="@+id/collapsed">
        ...
        <Constraint android:id="@id/title">
            <Transform
                android:scaleX="0.5"
                android:scaleY="0.5" />
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintBottom_toTopOf="@id/score"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="@id/toolbar" />
        </Constraint>
        ...
    </ConstraintSet>

    <ConstraintSet android:id="@+id/expanded">
        ...
        <Constraint android:id="@id/title">
            <Transform
                android:scaleX="1.0"
                android:scaleY="1.0" />
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </Constraint>
        ...
    </ConstraintSet>

</MotionScene>

尽管layout_constraint*属性存在一些细微差别,但这里的关键是Transform,它将在collpased和expanded状态之间缩放文本。 MotionLayout将自动为我们进行这种缩放。 我们免费获得的是,如果我们将其他View限制在View的底部,他们将随着缩放应用于此View而移动。 所以视觉效果是它下面的View随着它的增长和收缩而移动。 在GIF中查看团队名称和分数如何随着标题文本的大小变化而移动。

我们使用相同的技术来缩放团队徽标和匹配时间文本(89:59)。 我不会单独介绍这些内容,但请查看accompanying source code以查看此内容。

但是匹配时间文本值得一看:

RES/XML/collapsing_toolbar.xml


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    ...
    <ConstraintSet android:id="@+id/collapsed">
        ...
        <Constraint android:id="@id/toolbar">
            <Layout
                android:layout_width="0dp"
                android:layout_height="?attr/actionBarSize"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </Constraint>
        ...
        <Constraint android:id="@id/time">
            <Transform
                android:scaleX="0.5"
                android:scaleY="0.5" />
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintBottom_toBottomOf="@id/toolbar"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/score" />
        </Constraint>
        ...
    </ConstraintSet>

    <ConstraintSet android:id="@+id/expanded">
        ...
        <Constraint android:id="@id/toolbar">
            <Layout
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toTopOf="@id/time"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </Constraint>
        ...
        <Constraint android:id="@id/time">
            <Transform
                android:scaleX="1.0"
                android:scaleY="1.0" />
            <Layout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/score"
                android:layout_marginTop="8dp"/>
        </Constraint>
        ...
    </ConstraintSet>

</MotionScene>

正在进行相同类型的缩放,但在折叠状态下它位于Toolbar内部,但在展开状态下它位于其下方。

就其自身而言,这对于扩展状态来说效果不佳,因为文本本身很轻,而Toolbar下面的背景也很轻。 因此,我们需要将绿色“泡沫"降至Toolbar以下,以确保文本具有对比鲜明的背景。

泡沫本身就是一个VectorDrawable

RES/抽拉/bubble.xml


<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp"
    android:height="16dp"
    android:viewportWidth="150"
    android:viewportHeight="50">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M0,0 a 25,25 0 0 1 25,25 a 25,25 0 0 0 25,25 h 50 a 25,25 0 0 0 25,-25 a 25,25 0 0 1 25,-25 Z" />
</vector>

它由一些弧线和水平线组成,实际上看起来像这样:

形状应该在我们之前看到的静态展开状态图像中可见,并且在布局中应用绿色。 它变得有趣的地方是如何在MotionScene中应用它:

RES/XML/collapsing_toolbar.xml


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    ...
    <ConstraintSet android:id="@+id/collapsed">
        ...
        <Constraint android:id="@id/toolbar_extension">
            <Layout
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toBottomOf="@id/toolbar"
                app:layout_constraintEnd_toEndOf="@id/time"
                app:layout_constraintStart_toStartOf="@id/time"
                app:layout_constraintTop_toBottomOf="@id/toolbar" />
        </Constraint>
        ...
    </ConstraintSet>

    <ConstraintSet android:id="@+id/expanded">
        ...
        <Constraint android:id="@id/toolbar_extension">
            <Layout
                android:layout_width="0dp"
                android:layout_height="0dp"
                app:layout_constraintBottom_toBottomOf="@id/time"
                app:layout_constraintEnd_toEndOf="@id/time"
                app:layout_constraintStart_toStartOf="@id/time"
                app:layout_constraintTop_toTopOf="@id/time" />
        </Constraint>
        ...
    </ConstraintSet>

</MotionScene>

在折叠状态下,此View的顶部和底部都被约束到Toolbar的底部。结果没有高度。

在展开状态下,此View的顶部和底部都被约束到匹配时间TextView的顶部和底部。它的高度与匹配时间TextView相匹配。

MotionLayout在折叠状态和展开状态之间转换时,气泡的高度将发生变化,这会产生从Toolbar底部生长的气泡的错觉。我们实际上可以得到一个微妙的不同效果,我们保持泡沫与匹配时间TextView保持一致。但我个人更喜欢这种效果,因为它会使气泡看起来增长而不是从Toolbar中滑出 - 我觉得如果感觉更有机。

我们完成了。如果我们把所有这些放在一起,我们得到以下结果:

这种特定的实现可能不适合某些情况,因为匹配时间将覆盖Toolbar下面的任何位置,但我认为形状改变工具栏扩展是一个有趣的想法,这就是我在这里分享它的原因。

here提供了本文的源代码。