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

相关推荐
拭心2 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王4 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡4 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道4 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库5 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道6 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe6 小时前
Android Hook - 动态加载so库
android
居居飒7 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He10 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗10 小时前
Android笔试面试题AI答之Android基础(1)
android