【翻译】MotionLayout实现折叠工具栏(Part 1)

2018-08-13 by Liuqingwen | Tags: Android 翻译 | Hits

一、说明

没有严格按照中英对照进行的翻译,但是我尽量把意思翻译到位,能看原文的朋友可以直接欣赏原文啦。 blush

本文特点:没有 Kotlin/Java 代码,讲解部分全为 XML 代码,阅读时间短,获取技能: MotionLayout 的入门和使用!发布时间: 8 月 10 号 ,作者: Mark Allison ,原文链接: https://blog.stylingandroid.com/motionlayout-collapsing-toolbar-part-1/

二、正文

谷歌 IO 2018 发布了 ConstraintLayout 2.0 版本,其中最重要的部分就是 MotionLayout 了,这玩意就是一个全新的、超牛的布局动画工具! Nicolas Roard 哥们早已发布了一个关于 MotionLayout 的完美详情介绍,我强烈推荐大家去阅读一下,从中理解 MotionLayout 组件的基础架构。本系列教程中,我会讲解如何使用 MotionLayout 来创建一个我们已经非常熟悉的动画行为:一个折叠工具栏动画( a Collapsing Toolbar )。

在我们开始之前,有必要在这里澄清一下:在 CoordinatorLayout 中使用 CollapsingToolbarLayout 来实现折叠工具栏是没任何问题的。当然了,如果你已经在自己的 App 中使用了,那么你在学会了这里的知识后也没什么必要做更改。也就是说, CoordinatorLayout 这个布局已经提供了一些非常有用的行为动画,如果你尝试去修改它,或者创建一些基于它的自定义动画,那都是相当困难的。相反, MotionLayout 提供了更多的灵活性,以我个人早期的经验来看,这是一个非常简单又易学的效果神器。而且, MotionLayout 让那些 CoordinatorLayout 望而却步的动画变得简单直接。学习来吧,骚年!

MotionLayout 和安卓上许多其他的动画框架的一个主要不同点在于:视图动画和属性动画运行的时长是给定的,比如指定动画的时长,取消某个动画都是可行的,但是不能做到用户控制一个正在进行中的动画。举个例子,一个折叠工具栏应该根据用户的滚动进行展开和折叠,所以实际动画的运行应该时刻跟随用户的拖拽进行。这也是那些框架办不到的地方。

废话不多说,让我们看下我们所要尝试模拟做到的行为动作。这里的代码展示了一个折叠工具栏,应用了 Material Components Library 库里的 CollapsingToolbarLayoutCoordinatorLayout 布局。

res/layout/activity_main_traditional.xml
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
49
50
51
52
53
54
55
56
57
58
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />

<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar"
android:layout_width="match_parent"
android:layout_height="200dp"
android:fitsSystemWindows="true"
app:contentScrim="?attr/colorPrimary"
app:expandedTitleGravity="bottom"
app:expandedTitleMarginEnd="@dimen/activity_horizontal_margin"
app:expandedTitleMarginStart="@dimen/activity_horizontal_margin"
app:layout_scrollFlags="scroll|exitUntilCollapsed"
app:title="@string/app_name">

<ImageView
android:id="@+id/toolbar_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:fitsSystemWindows="true"
android:scaleType="centerCrop"
android:src="@drawable/beach_huts" />

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"
app:popupTheme="@style/ThemeOverlay.AppCompat" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

运行这段代码所得到的动画行为是这样的:

blog_traditional.gif

使用 MotionLayout 做到接近上述动画效果非常简单。首先从我们的布局文件开始:

res/layout/activity_main.xml
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
49
50
51
52
53
54
55
56
57
58
59
60
<?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"
tools:context=".MainActivity"
app:layoutDescription="@xml/collapsing_toolbar"
tools:showPaths="true">

<androidx.recyclerview.widget.RecyclerView
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_image" />

<ImageView
android:id="@+id/toolbar_image"
android:layout_width="0dp"
android:layout_height="200dp"
android:adjustViewBounds="true"
android:contentDescription="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:fitsSystemWindows="true"
android:scaleType="center"
android:src="@drawable/beach_huts"
android:background="@color/colorPrimary" />

<ImageView
android:id="@android:id/home"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:src="@drawable/abc_ic_ab_back_material"
android:tint="?android:attr/textColorPrimaryInverse"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="24dp"
android:text="@string/app_name"
android:textColor="?android:attr/textColorPrimaryInverse"
android:textSize="32sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/toolbar_image"
app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.motion.widget.MotionLayout>

这基本上是使用标准的 ConstraintLayout 创建出来的一个布局,唯一区别在于父布局实际为一个 MotionLayout 布局( MotionLayout 继承于 ConstraintLayout ,所以我们能够把它当做一个普通的 ConstraintLayout 来使用)。这个 MotionLayout 布局有一个属性名为: app:layoutDescription ,它也是奇迹所发生的地方。在这里我特意使用了最基本的 View 控件类型,用来说明视图本身并没有产生任何其他的行为动作。当然在实际 App 开发过程中我应该会使用 AppBarLayout 布局配合 Toolbar 控件吧。

如果在设计视图中查看这个布局,我们能看到布局所展示的工具栏处于展开的状态:

blog_activity_main.png

我刚刚提到奇迹发生的地方在于布局文件中的 app:layoutDescription 属性,那么现在我们来仔细瞧瞧它吧:

res/xml/collapsing_toolbar.xml
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?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/expanded">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="200dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="255" />
</Constraint>
<Constraint
android:id="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="24dp"
android:scaleX="1.0"
android:scaleY="1.0"
app:layout_constraintBottom_toBottomOf="@id/toolbar_image"
app:layout_constraintStart_toStartOf="parent">
</Constraint>
</ConstraintSet>

<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="?attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="0" />
</Constraint>
<Constraint
android:id="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginBottom="0dp"
android:scaleX="0.625"
android:scaleY="0.625"
app:layout_constraintBottom_toBottomOf="@id/toolbar_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/toolbar_image">
</Constraint>

</ConstraintSet>

</MotionScene>

这就是一个崭新的 MotionLayout ,也许看起来会有点恐惧,让我们来把它分解成一块块很好理解的小件然后再进行细细剖析。这里父布局首先是一个 MotionScene ,它持有所有我们定义的过渡动画所需要的组件。它包含两个 ConstraintSet ,每个 ConstraintSet 又定义了一套相关约束,这套约束体现为布局的一个固定的状态,这个我们会在后面深入探讨,目前我们只需要知道:有一个 ConstraintSet 表示工具栏的完全展开状态,而另一个表示工具栏处于完全闭合状态就足以。

这里的 Transition 元素定义了过渡动画的开始和结束状态,以及过渡效果如何和用户进行交互:

res/xml/collapsing_toolbar.xml
1
2
3
4
5
6
7
8
9
10
<Transition
app:constraintSetEnd="@id/collapsed"
app:constraintSetStart="@id/expanded">

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

</Transition>

两个属性: app:constraintSetStartapp:constraintSetEnd 分别指 ConstrainSet 所定义的两种状态:展开状态和折叠状态。元素 OnSwipe 把过渡动画和用户在 RecyclerView 上的拖拽操作绑定到了一起,也就是之前我们查看到的主布局中的列表。在展开和折叠状态下, RecyclerView 列表的上边缘是处于不同位置的,因为它被约束到了 ID 为 toolbar_imageImageView 图片下边缘,而这个过渡动画的实现正是由于控制着这个位置变量的值,这个值又源于用户拖拽 RecyclerView 来产生。别小看这里短短的 10 行 XML 代码,它背后可为我们做了大量的工作哦。这其中内部原理非常复杂,它由 RecyclerView 的滚动行为所驱动。

为了理解这两个 ConstrainSet 的定义,让我们先假设这里只有两件事情需要进行控制。第一件事情就是作为背景的 ImageView 图片( ID 为 toolbar_image )高度值的改变,以及图片透明度值的改变。通过改变图片的高度,这会导致 RecyclerView 的上边缘的移动,因为后者正是约束在图片的下边缘位置。第二个控件则是包含了标题( ID 为 title )的文本 TextView ,它需要移动的同时改变自身大小尺寸。

让我们首先看看这两个状态下图片 ImageView 的高度差。在展开状态下是这样的:

res/xml/collapsing_toolbar.xml
1
2
3
4
5
6
7
8
9
10
11
<ConstraintSet android:id="@+id/expanded">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="200dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="255" />
</Constraint>

对于折叠状态下则为:

res/xml/collapsing_toolbar.xml
1
2
3
4
5
6
7
8
9
10
11
<ConstraintSet android:id="@+id/collapsed">
<Constraint
android:id="@id/toolbar_image"
android:layout_height="?attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<CustomAttribute
app:attributeName="imageAlpha"
app:customIntegerValue="0" />
</Constraint>

这里只有两个小小的区别。第一个就是高度 layout_height ,第二个则为名为 imageAlphaCustomAttribute 。以 CustomAttribute 为名暗示着我们正在使用一个自定义视图 View ,但实际上并不是这样。我们使用的是一个标准的 ImageView 控件,当其位于 ConstraintSet 下的 Constraint 元素中时,其主要的属性变成可以是 ConstraintLayout.LayoutParams 中的任何一个属性,也可以是 View 中的任何一个属性,但即使像 ImageView 这类作为 View 的子类控件,我们仍然需要使用一个 CustomAttribute 符号,这里实际上和 ObjectAnimator 的原理非常类似。在这里,我们需要调整 ImageViewimageAlpha 值。当然,你也可以使用自定义视图上的自定义属性来实现,就如同 ObjectAnimator 一样。

另外 TextView 实际上也非常类似。展开状态是:

res/xml/collapsing_toolbar.xml
1
2
3
4
5
6
7
8
9
10
<Constraint
android:id="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="24dp"
android:scaleX="1.0"
android:scaleY="1.0"
app:layout_constraintBottom_toBottomOf="@id/toolbar_image"
app:layout_constraintStart_toStartOf="parent" />

还有,折叠状态则为:

res/xml/collapsing_toolbar.xml
1
2
3
4
5
6
7
8
9
10
11
<Constraint
android:id="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginBottom="0dp"
android:scaleX="0.625"
android:scaleY="0.625"
app:layout_constraintBottom_toBottomOf="@id/toolbar_image"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/toolbar_image"/>

这里,我们通过使用视图的缩放来改变 TextView 的大小。如果你对为什么这里选择缩放而非直接通过一个 CustomAttribute 改变 textSize 来实现表示怀疑的话,那么你要知道,在这里的理由就是因为相比简单直接地在文本上应用一个形变,通过改变文本大小和重新渲染会非常耗计算资源,所以我们为了在过渡动画结束时尽量减少锯齿的产生需要使用这个技巧。

我们所做的另一件事情则是改变边距大小( margins ),以及如何让 TextView 文本的位置相对于 ImageView 图片的位置而固定。在折叠状态下它会垂直居中,而在展开状态下它会对齐在底部,因此 TextView 会更多的相对于 ImageView 的大小尺寸来进行相关设定。

如果我们使用该布局来代替一开始我们就使用的 CoordinatorLayout 布局来实现,那么我们将会得到这样的行为:

blog_motion_basic.gif

这事实上效果已经非常接近,但是仔细看你会发现这里与刚开始我们使用的 CoordinatorLayout 方式有一个细微的区别:在 CoordinatorLayout 布局下图片的褪色渐变动画和 MotionLayout 版本中的行为有点不一致。这里卖个关子,在本系列文章的最后,我们将会介绍关于 MotionLayout 布局中更细粒度的一些控制。

三、总结

本篇的源代码请移步这里

© 2018 , Mark Allison 。保留所有版权。


Comments: