MotionLayout 学习笔记 - 入门

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:constraintSetStartmotion: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,也就是根据动画的完成度选择一个最近的场景。

相关推荐
Winston Wood2 小时前
Android Parcelable和Serializable的区别与联系
android·序列化
清风徐来辽2 小时前
Android 项目模型配置管理
android
帅得不敢出门2 小时前
Gradle命令编译Android Studio工程项目并签名
android·ide·android studio·gradlew
problc3 小时前
Flutter中文字体设置指南:打造个性化的应用体验
android·javascript·flutter
帅得不敢出门13 小时前
安卓设备adb执行AT指令控制电话卡
android·adb·sim卡·at指令·电话卡
我又来搬代码了15 小时前
【Android】使用productFlavors构建多个变体
android
德育处主任17 小时前
Mac和安卓手机互传文件(ADB)
android·macos
芦半山17 小时前
Android“引用们”的底层原理
android·java
迃-幵17 小时前
力扣:225 用队列实现栈
android·javascript·leetcode
大风起兮云飞扬丶18 小时前
Android——从相机/相册获取图片
android