MotionLayout 入门
前言
属性动画
kotlin
view.animate()
.translationX(500f)
.start()
对于属性动画,我们的关注点在于动画的过程,产品不会和你说:"我想让这个按钮点击的时候向右移动 xx 个像素",产品只会和你描述她想要的"结果",也就是"我想让这个按钮点击的时候变到右边去",我们理解了产品的要求后,脑子自然会根据这个"结果"去推算出"过程",噢,把东西从最左边移动到最右边,懂了
kotlin
val didtance = (view.parent as FrameLayout).width - view.width
view.animate()
.translationX(x.toFloat())
.start()
哎,搞定
现在需求比较简单,只是从左到右,代码量很少,看起来没啥问题,如果需求很复杂,属性动画就得写一大坨,既不美观,也难以维护,第二天再打开电脑就看不懂了。
过渡动画
与属性动画关注动画的过程不同,过渡动画更关注于元素状态之间的转换,不知道在座的有没有使用过 PPT 里面的平滑过渡效果:
同样的圆形,在第二张 ppt 里面只是改变的位置和填充颜色,然后切换 PPT 时使用平滑过渡效果,就能实现连贯自然的动画效果。两张 PPT 只是描述了初始状态和结束状态,至于切换过程中怎么去实现动画我不关心。
Android 中也有过渡动画(Transition Animation),刚才的例子如果用过渡动画实现,代码应该怎么写呢?
kotlin
val layoutParams = view.layoutParams as FrameLayout.LayoutParams
layoutParams.gravity = Gravity.END
TransitionManager.beginDelayedTransition(view.parent as FrameLayout)
// 使用 requestLayout() 方法来请求重新布局
view.requestLayout()
这时候有人可能会说了,就这...?我属性动画用起来不比这舒服简单吗?那么这么写到底有什么好处呢?
来看这么一种情况,View2 在 View1 的下方且二者左对齐
xml
<RelativeLayout 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">
<View
android:id="@+id/view1"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#8C9EFF" />
<View
android:id="@+id/view2"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_below="@id/view1"
android:layout_alignLeft="@id/view1"
android:background="#FF8A80" />
</RelativeLayout>
现在我用属性动画把蓝色 View 向右移
kotlin
val distance = (view1.parent as RelativeLayout).width - view1.width
view1.animate()
.translationX(distance.toFloat())
.start()
你会发现使用属性动画,View 的布局参数是不会被改变的。View2 在 xml 里声明了定位在 View1 的下方且左对齐,如果 View1 的布局参数被属性动画改变,那么 View2 理应一并跟随移动。
如果用过渡动画来实现这个过程
kotlin
val layoutParams = view1.layoutParams as RelativeLayout.LayoutParams
TransitionManager.beginDelayedTransition(rootLayout)
// 靠右
layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
view1.requestLayout()
通过过渡动画,我们改变了 View1 的布局参数(位置),相应的,View2 的位置自然也跟着改变了。
使用过渡动画,我们需要手动获取修改控件的布局参数,开启过渡动画,再触发重新绘制,其实吧,这么写比起属性动画并不明显,可读性并不高,有没有更好的实现方式呢?如果能够把状态描述写在 xml 里就好了,能够预览那就好了。
其实也是行的
activity_go.xml
:
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/go_root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#82B1FF"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
activity_go_start.xml
:
xml
<?xml version="1.0" encoding="utf-8"?>
<merge 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">
<View
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:background="#82B1FF" />
</merge>
activity_go_end.xml
:
xml
<?xml version="1.0" encoding="utf-8"?>
<merge 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">
<View
android:id="@+id/view"
android:layout_width="200dp"
android:layout_height="200dp"
android:background="#FF8A80"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</merge>
GoActivity.kt
kotlin
class GoActivity : AppCompatActivity() {
private var toggle = false
private lateinit var startScene: Scene
private lateinit var endScene: Scene
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_go)
val root = findViewById<ViewGroup>(R.id.go_root_layout)
// 创建 start 和 end 两个场景
startScene = Scene.getSceneForLayout(root, R.layout.activity_go_start, this)
endScene = Scene.getSceneForLayout(root, R.layout.activity_go_end, this)
initEvent()
}
private fun initEvent() {
findViewById<View>(R.id.view).setOnClickListener { view ->
// 点击的时候根据不同的状态去切换场景
val targetScene = if (toggle) startScene else endScene
TransitionManager.go(targetScene)
// ???
initEvent()
toggle = !toggle
}
}
}
为什么在切换场景后,要重新设置点击监听呢?这是因为使用 TransitionManager.go()
会先把布局中的所有 view 都清空,再把结束状态里的 view 全部添加进来,添加的过程中,动画也是根据当前状态和结束状态去计算的,所以动画前后的 view 已经不是同一个实例了,自然就要重新设置监听以及数据。
重新设置数据?!!再见
使用过渡动画,我要是用 beginDelayedTransition
就得在代码里面写一大坨代码去改变控件属性,可读性差。要是把状态写在 xml 里面,是解决了可读性的问题,但切换要重新设置数据。能不能取二者的长处,把要切换状态写在 xml 文件里,然后用 beginDelayedTransition
来切换呢?
过渡动画(beginDelayedTransition) + ConstraintLayout
activity_begin_delayed_transition_with_constraint_layout_start
:
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<View
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#82B1FF"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
activity_begin_delayed_transition_with_constraint_layout_end
:
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<View
android:id="@+id/view"
android:layout_width="200dp"
android:layout_height="200dp"
android:background="#FF8A80"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
BeginDelayedTransitionWithConstraintLayoutActivity
kotlin
class BeginDelayedTransitionWithConstraintLayoutActivity : AppCompatActivity() {
private val binding: ActivityBeginDelayedTransitionWithConstraintLayoutStartBinding by lazy {
ActivityBeginDelayedTransitionWithConstraintLayoutStartBinding.inflate(layoutInflater)
}
private var toggle = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.view.setOnClickListener { view ->
val constraintSet = ConstraintSet()
if (toggle) constraintSet.clone(
this,
R.layout.activity_begin_delayed_transition_with_constraint_layout_start
)
else constraintSet.clone(
this,
R.layout.activity_begin_delayed_transition_with_constraint_layout_end
)
TransitionManager.beginDelayedTransition(binding.root)
constraintSet.applyTo(binding.root)
toggle = !toggle
}
}
}
借助 ConstraintLayout,我们可以用 ConstraintSet.apply()
来改变/应用布局属性(状态)了,这个状态就是写在 xml 里的,而且我们使用 TransitionManager.beginDelayedTransition
,view 并没有被清空重新添加,所以也不需要重新设置数据。
嗯,前面提到的问题好像都解决了。
but,enough?
这么做的话,xml 文件里会有非常多的重复代码,如果我的布局里有比较多的控件,我只是要改变其中某个控件的位置,但是布局中其他控件的代码我也必须全部原封不动 copy 过来。而且这样做动画,没办法做到和触摸联动,什么叫和触摸联动?比如说,一个 view 可以在屏幕的左右两端滑动,滑到一半我想松手了,我想让这个 view 停在松手的位置,使用属性动画和过渡动画肯定是做不到的,动画开始了就会一直执行直到结束。
更好的解决方案是什么呢,主角登场啦,有请:MotionLayout!
MotionLayout
基础
要使用 MotionLayout 很简单,在 ConstraintLayout 的布局上右键选择 Convert to MotionLayout 即可。
activity_simple_motion_layout.xml
:
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"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/layout_motion_scene">
<View
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#82B1FF"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.motion.widget.MotionLayout>
转换完成后发现多了这么一行:app:layoutDescription="@xml/layout_motion_scene"
,点进 layout_motion_scene.xml
看一下:
xml
<?xml version="1.0" encoding="utf-8"?>
<MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">
<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="1000">
<KeyFrameSet>
</KeyFrameSet>
</Transition>
<ConstraintSet android:id="@+id/start">
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
</ConstraintSet>
</MotionScene>
<MotionScene>
里的 <Transition>
标签用于指定需要执行的运动序列。 motion:constraintSetStart
和 motion:constraintSetEnd
指定的是动画的开始和结束的场景,id 指向了下面的 <ConstraintSet>
标签,<ConstraintSet>
标签默认拥有与这个 MotionSecen 关联的 MotionLayout 里面的各种属性。同样的需求,实现 view 从屏幕左侧到屏幕的右侧。
打开 MotionLayout 编辑面板,点击我们的 end 约束集,
选中要创建约束的 view
创建约束
约束已经创建
再看看 MotionScene 文件,对应我们的操作,文件多了这几行
xml
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
motion:layout_constraintLeft_toLeftOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
其实就是把原来布局中的要改变布局参数的 view 复制过来,标签改为 Constraint
,另外 ConstrainLayout 的参数命名改为 motion:xxx
,
把 motion:layout_constraintLeft_toLeftOf="parent"
改为 motion:layout_constraintRinght_toRightOf="parent"
那怎么触发动画呢?
我们可以在 <Transition>
添加 <OnClick>
标签,并用 motion:targetId
来指定目标 view 的 id,如果不指定,点击整个布局都会触发动画。现在整个 MotionScene 文件如下:
xml
<?xml version="1.0" encoding="utf-8"?>
<MotionScene
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">
<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="1000">
<KeyFrameSet>
</KeyFrameSet>
<OnClick motion:targetId="@+id/view" />
</Transition>
<ConstraintSet android:id="@+id/start">
</ConstraintSet>
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/view"
android:layout_width="100dp"
android:layout_height="100dp"
motion:layout_constraintRight_toRightOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</ConstraintSet>
</MotionScene>
运行
我们可以在 <Click>
标签里面使用 motion:clickAction
配置触发动画后的效果,toggle
就是来回切换,jumpXXX
就是不带动画跳转到开始、结束场景,transitionXXX
就不用多说了。
xml
<OnClick
motion:targetId="@+id/view"
motion:clickAction="transitionToEnd|jumpToStart" />
前面我们说属性动画和过渡动画存在一些不足:无法和触摸联动,那 MotionLayout 就能做到啦?
我们来试试 onSwipe
标签
xml
<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@id/start"
motion:duration="1000">
<KeyFrameSet>
</KeyFrameSet>
<!--
<OnClick
motion:targetId="@+id/view"
motion:clickAction="transitionToEnd|jumpToStart" />
-->
<OnSwipe
motion:touchRegionId="@id/view"
motion:dragDirection="dragRight"
motion:onTouchUp="stop" />
</Transition>
touchRegionId
指定在哪个区域滑动才会触发,我要滑动 view,那么区域肯定就是 view 了。 dragDirection
指定滑动触发的方向,那么这里肯定就是往右了。onTouchUp
指定的是如果中途我们提手,MotionLayout 的处理方式,如果不指定,默认是 autoComplete
,也就是根据动画的完成度选择一个最近的场景。