学更好的别人,

做更好的自己。

——《微卡智享》

前言

今天是折叠屏开发的第三篇,前面已经介绍了铰链的角度监听和Jetpack Window实现监听效果,今天我们就来做个折叠状态和展开状态显示的不同效果Demo,本篇的重点主要是两个,一是布局文件的设计,另一个就是MotionLayout的动画效果。

android 折叠listview android 折叠屏_viewpager

实现效果

android 折叠listview android 折叠屏_移动开发_02

android 折叠listview android 折叠屏_列表_03

竖屏折叠

android 折叠listview android 折叠屏_列表_04

竖屏展开

android 折叠listview android 折叠屏_列表_05

横屏折叠

android 折叠listview android 折叠屏_移动开发_06

横屏展开

上图中可以看到,竖屏折叠时,宫格布局和按钮都在同一界面,按钮在下方,当竖屏展开后,宫格布局移动到左边,而按钮布局移动到右边了,并且由原来的水平排列改为了垂直排列(完整的效果视频看P2)。接下来就来看看怎么实现的。

代码实现



android 折叠listview android 折叠屏_android 折叠listview_07

微卡智享

核心代码

实现分屏布局,最主要的就是靠我们自己定义的一个FrameLayout,里面内置了WindowLayoutInfo的参数,参数传入的WindowLayoutInfo来判断当前的什么状态,而应用什么样的布局(左右,上下还是合并)

首先要创建一个attr.xml

android 折叠listview android 折叠屏_android 折叠listview_08

<resources>
    <declare-styleable name="SplitLayout">
        <attr name="startViewId" format="reference" />
        <attr name="endViewId" format="reference" />
    </declare-styleable>
</resources>

SplitLayout的代码:

package pers.vaccae.mvidemo.ui.view


import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintAttribute.setAttributes
import androidx.window.layout.DisplayFeature
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowLayoutInfo
import pers.vaccae.mvidemo.R


/**
 * 作者:Vaccae
 * 邮箱:3657447@qq.com
 * 创建时间:15:07
 * 功能模块说明:
 */
class SplitLayout :FrameLayout{


    private var windowLayoutInfo: WindowLayoutInfo? = null
    private var startViewId = 0
    private var endViewId = 0


    private var lastWidthMeasureSpec: Int = 0
    private var lastHeightMeasureSpec: Int = 0


    constructor(context: Context) : super(context)


    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        setAttributes(attrs)
    }


    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    ) {
        setAttributes(attrs)
    }


    private fun setAttributes(attrs: AttributeSet?) {
        context.theme.obtainStyledAttributes(attrs, R.styleable.SplitLayout, 0, 0).apply {
            try {
                startViewId = getResourceId(R.styleable.SplitLayout_startViewId, 0)
                endViewId = getResourceId(R.styleable.SplitLayout_endViewId, 0)
            } finally {
                recycle()
            }
        }
    }




    fun updateWindowLayout(windowLayoutInfo: WindowLayoutInfo) {
        this.windowLayoutInfo = windowLayoutInfo
        requestLayout()
    }


    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        val startView = findStartView()
        val endView = findEndView()
        val splitPositions = splitViewPositions(startView, endView)


        if (startView != null && endView != null && splitPositions != null) {
            val startPosition = splitPositions[0]
            val startWidthSpec = MeasureSpec.makeMeasureSpec(startPosition.width(),
                MeasureSpec.EXACTLY
            )
            val startHeightSpec = MeasureSpec.makeMeasureSpec(startPosition.height(),
                MeasureSpec.EXACTLY
            )
            startView.measure(startWidthSpec, startHeightSpec)
            startView.layout(
                startPosition.left, startPosition.top, startPosition.right,
                startPosition.bottom
            )


            val endPosition = splitPositions[1]
            val endWidthSpec = MeasureSpec.makeMeasureSpec(endPosition.width(), MeasureSpec.EXACTLY)
            val endHeightSpec = MeasureSpec.makeMeasureSpec(endPosition.height(),
                MeasureSpec.EXACTLY
            )
            endView.measure(endWidthSpec, endHeightSpec)
            endView.layout(
                endPosition.left, endPosition.top, endPosition.right,
                endPosition.bottom
            )
        } else {
            super.onLayout(changed, left, top, right, bottom)
        }
    }


    private fun findStartView(): View? {
        var startView = findViewById<View>(startViewId)
        if (startView == null && childCount > 0) {
            startView = getChildAt(0)
        }
        return startView
    }


    private fun findEndView(): View? {
        var endView = findViewById<View>(endViewId)
        if (endView == null && childCount > 1) {
            endView = getChildAt(1)
        }
        return endView
    }


    private fun splitViewPositions(startView: View?, endView: View?): Array<Rect>? {
        if (windowLayoutInfo == null || startView == null || endView == null) {
            return null
        }


        // Calculate the area for view's content with padding
        val paddedWidth = width - paddingLeft - paddingRight
        val paddedHeight = height - paddingTop - paddingBottom


        windowLayoutInfo?.displayFeatures
            ?.firstOrNull { feature -> isValidFoldFeature(feature) }
            ?.let { feature ->
                getFeaturePositionInViewRect(feature, this)?.let {
                    if (feature.bounds.left == 0) { // Horizontal layout
                        val topRect = Rect(
                            paddingLeft, paddingTop,
                            paddingLeft + paddedWidth, it.top
                        )
                        val bottomRect = Rect(
                            paddingLeft, it.bottom,
                            paddingLeft + paddedWidth, paddingTop + paddedHeight
                        )


                        if (measureAndCheckMinSize(topRect, startView) &&
                            measureAndCheckMinSize(bottomRect, endView)
                        ) {
                            return arrayOf(topRect, bottomRect)
                        }
                    } else if (feature.bounds.top == 0) { // Vertical layout
                        val leftRect = Rect(
                            paddingLeft, paddingTop,
                            it.left, paddingTop + paddedHeight
                        )
                        val rightRect = Rect(
                            it.right, paddingTop,
                            paddingLeft + paddedWidth, paddingTop + paddedHeight
                        )


                        if (measureAndCheckMinSize(leftRect, startView) &&
                            measureAndCheckMinSize(rightRect, endView)
                        ) {
                            return arrayOf(leftRect, rightRect)
                        }
                    }
                }
            }


        // We have tried to fit the children and measured them previously. Since they didn't fit,
        // we need to measure again to update the stored values.
        measure(lastWidthMeasureSpec, lastHeightMeasureSpec)
        return null
    }


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        lastWidthMeasureSpec = widthMeasureSpec
        lastHeightMeasureSpec = heightMeasureSpec
    }


    private fun measureAndCheckMinSize(rect: Rect, childView: View): Boolean {
        val widthSpec = MeasureSpec.makeMeasureSpec(rect.width(), MeasureSpec.AT_MOST)
        val heightSpec = MeasureSpec.makeMeasureSpec(rect.height(), MeasureSpec.AT_MOST)
        childView.measure(widthSpec, heightSpec)
        return childView.measuredWidthAndState and MEASURED_STATE_TOO_SMALL == 0 &&
                childView.measuredHeightAndState and MEASURED_STATE_TOO_SMALL == 0
    }


    private fun isValidFoldFeature(displayFeature: DisplayFeature) =
        (displayFeature as? FoldingFeature)?.let { feature ->
            getFeaturePositionInViewRect(feature, this) != null
        } ?: false




    private fun getFeaturePositionInViewRect(
        displayFeature: DisplayFeature,
        view: View,
        includePadding: Boolean = true
    ): Rect? {
        // The the location of the view in window to be in the same coordinate space as the feature.
        val viewLocationInWindow = IntArray(2)
        view.getLocationInWindow(viewLocationInWindow)


        // Intersect the feature rectangle in window with view rectangle to clip the bounds.
        val viewRect = Rect(
            viewLocationInWindow[0], viewLocationInWindow[1],
            viewLocationInWindow[0] + view.width, viewLocationInWindow[1] + view.height
        )


        // Include padding if needed
        if (includePadding) {
            viewRect.left += view.paddingLeft
            viewRect.top += view.paddingTop
            viewRect.right -= view.paddingRight
            viewRect.bottom -= view.paddingBottom
        }


        val featureRectInView = Rect(displayFeature.bounds)
        val intersects = featureRectInView.intersect(viewRect)
        if ((featureRectInView.width() == 0 && featureRectInView.height() == 0) ||
            !intersects
        ) {
            return null
        }


        // Offset the feature coordinates to view coordinate space start point
        featureRectInView.offset(-viewLocationInWindow[0], -viewLocationInWindow[1])


        return featureRectInView
    }


}

01

创建分屏的布局文件xml

要实现分屏的效果显示,需要创建两个不同的布局文件,像图中的宫格列表,还有按钮的布局分别在两个不同的xml中。

android 折叠listview android 折叠屏_android 折叠listview_09

split_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:id="@+id/startLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="10dp" />


</androidx.constraintlayout.widget.ConstraintLayout>

split_layout_end.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:id="@+id/endLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="20dp"
    app:layoutDescription="@xml/split_layout_end_scene">


    <Button
        android:id="@+id/btncreate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="生成数据"
        android:layout_marginBottom="30dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/btnadd"
        app:layout_constraintStart_toStartOf="parent" />


    <Button
        android:id="@+id/btnadd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="插入数据"
        android:layout_marginBottom="30dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />


    <Button
        android:id="@+id/btndel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="删除数据"
        android:layout_marginBottom="30dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/btnadd" />


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

02

创建新的Activity

创建好了我们的SplitLayout后,我们再创建一个FoldActivity。其中布局文件就要引用我们创建的SplitLayout,里面包括了刚才创建的宫格列表和按钮布局。

activity_fold.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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ui.view.FoldActivity">


    <pers.vaccae.mvidemo.ui.view.SplitLayout
        android:id="@+id/split_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:startViewId="@id/startLayout"
        app:endViewId="@id/endLayout"
        android:padding="5dp">


        <include
            android:id="@id/startLayout"
            layout="@layout/split_layout_start" />


        <include
            android:id="@+id/endLayout"
            layout="@layout/split_layout_end" />
    </pers.vaccae.mvidemo.ui.view.SplitLayout>




</androidx.constraintlayout.widget.ConstraintLayout>

03

实现动画效果

效果图片中可以看到,我们实现位移动画的是按钮的布局,其实就是通过MotionLayout实现的。

android 折叠listview android 折叠屏_android 折叠listview_10

其中app:layoutDescription="@xml/split_layout_end_scene"是动画属性,我们当布局改为MotionLayout时,会提示要缺少layoutDescription,使用ALT+ENTER会自动创建这个xml文件,位置在res.xml下

android 折叠listview android 折叠屏_android 折叠listview_11

split_layout_end_scene.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/start">
        <Constraint android:id="@+id/btncreate" />
        <Constraint android:id="@+id/btnadd" />
        <Constraint android:id="@+id/btndel" />
    </ConstraintSet>


    <ConstraintSet android:id="@+id/end">
        <Constraint android:id="@id/btncreate"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="30dp"
            app:layout_constraintBottom_toTopOf="@+id/btnadd"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>


        <Constraint android:id="@id/btnadd"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="30dp"
            app:layout_constraintBottom_toTopOf="@+id/btndel"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@id/btncreate"/>


        <Constraint android:id="@id/btndel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="30dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@id/btnadd"/>
    </ConstraintSet>


    <Transition
        app:constraintSetEnd="@id/end"
        app:constraintSetStart="@+id/start"
        app:duration="500"/>
</MotionScene>

MotionScene的子元素属性标签

<Transition> 包含运动的基本定义。

其中里面的app:constraintSetStart 和 app:constraintSetEnd 指的是运动的端点。这些端点在 MotionScene 后面的 <ConstraintSet> 元素中定义。

app:duration 指定完成运动所需的毫秒数 。

android 折叠listview android 折叠屏_列表_12

<ConstraintSet>子元素定义一个场景约束集,并在 <ConstraintSet> 元素中使用 <Constraint> 元素定义单个 View 的属性约束。

android:id:设置当前约束集的 id。这个 id 值可被 <Transition> 元素的 app:constraintSetStart 或者 app:constraintSetEnd 引用。

android 折叠listview android 折叠屏_android_13

<Constraint> 元素用来定义单个 View 的属性约束。

它支持对 View 的所有 ConstraintLayout 属性定义约束,以及对 View 的下面这些标准属性定义约束。

android 折叠listview android 折叠屏_移动开发_14

由上面的布局文件中可以看到,在start中,我们三个按钮的布局不变,而在end中,三个按钮的布局改为垂直布局了。代码中调用方式直接就是通过motionLayout.transitionToEnd()motionLayout.transitionToStart()跳转即可

android 折叠listview android 折叠屏_列表_15

定义motionlayout

android 折叠listview android 折叠屏_android_16

判断竖屏展开时调用transitionToEnd,合上状态时调用transitionStart

FoldActivity代码:

package pers.vaccae.mvidemo.ui.view


import android.content.res.Configuration
import android.graphics.drawable.ClipDrawable.HORIZONTAL
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.*
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import androidx.window.layout.WindowInfoTrackerDecorator
import androidx.window.layout.WindowLayoutInfo


import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.launch
import pers.vaccae.mvidemo.R
import pers.vaccae.mvidemo.bean.CDrugs
import pers.vaccae.mvidemo.ui.adapter.DrugsAdapter
import pers.vaccae.mvidemo.ui.intent.ActionIntent
import pers.vaccae.mvidemo.ui.intent.ActionState
import pers.vaccae.mvidemo.ui.viewmodel.MainViewModel


class FoldActivity : AppCompatActivity() {
    private val TAG = "X Fold"


    private val recyclerView: RecyclerView by lazy { findViewById(R.id.recycler_view) }
    private val btncreate: Button by lazy { findViewById(R.id.btncreate) }
    private val btnadd: Button by lazy { findViewById(R.id.btnadd) }
    private val btndel: Button by lazy { findViewById(R.id.btndel) }


    private lateinit var mainViewModel: MainViewModel
    private lateinit var drugsAdapter: DrugsAdapter


    //adapter的位置
    private var adapterpos = -1


    private lateinit var windowInfoTracker :WindowInfoTracker
    private lateinit var windowLayoutInfoFlow : Flow<WindowLayoutInfo>


    private val splitLayout: SplitLayout by lazy { findViewById(R.id.split_layout) }
    private val motionLayout :MotionLayout by lazy { findViewById(R.id.endLayout) }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_fold)


        windowInfoTracker = WindowInfoTracker.getOrCreate(this@FoldActivity)
        windowLayoutInfoFlow = windowInfoTracker.windowLayoutInfo(this@FoldActivity)
        observeFold()


        mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)


        drugsAdapter = DrugsAdapter(R.layout.rcl_item, mainViewModel.listDrugs)
        drugsAdapter.setOnItemClickListener { baseQuickAdapter, view, i ->
            adapterpos = i
        }


        val gridLayoutManager = GridLayoutManager(this, 3)
        recyclerView.layoutManager = gridLayoutManager
        recyclerView.adapter = drugsAdapter


        //初始化ViewModel监听
        observeViewModel()


        btncreate.setOnClickListener {
            Log.i(TAG, "create")
            lifecycleScope.launch {
                mainViewModel.actionIntent.send(ActionIntent.LoadDrugs)
            }
        }


        btnadd.setOnClickListener {
            lifecycleScope.launch {
                mainViewModel.actionIntent.send(ActionIntent.InsDrugs)
            }
        }


        btndel.setOnClickListener {
            lifecycleScope.launch {
                Log.i("status", "$adapterpos")
                val item = try {
                    drugsAdapter.getItem(adapterpos)
                } catch (e: Exception) {
                    CDrugs()
                }
                mainViewModel.actionIntent.send(ActionIntent.DelDrugs(adapterpos, item))
            }
        }
    }


    private fun observeFold() {
        lifecycleScope.launch(Dispatchers.Main) {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                windowLayoutInfoFlow.collect { layoutInfo ->
                        Log.i(TAG, "size:${layoutInfo.displayFeatures.size}")
                        splitLayout.updateWindowLayout(layoutInfo)
                        // New posture information
                        val foldingFeature = layoutInfo.displayFeatures
                            .filterIsInstance<FoldingFeature>()
                            .firstOrNull()
                        foldingFeature?.let {
                            Log.i(TAG, "state:${it.state}")
                        }
                        when {
                            isTableTopPosture(foldingFeature) ->
                                Log.i(TAG, "TableTopPosture")
                            isBookPosture(foldingFeature) ->
                                Log.i(TAG, "BookPosture")
                            isSeparating(foldingFeature) ->
                                // Dual-screen device
                                foldingFeature?.let {
                                    if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) {
                                        Log.i(TAG, "Separating HORIZONTAL")
                                    } else {
                                        Log.i(TAG, "Separating VERTICAL")
                                        motionLayout.transitionToEnd()
                                    }
                                }
                            else -> {
                                Log.i(TAG, "NormalMode")
                                motionLayout.transitionToStart()
                            }
                        }
                    }
            }
        }
    }


    fun isTableTopPosture(foldFeature: FoldingFeature?): Boolean {
        return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
                foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
    }


    fun isBookPosture(foldFeature: FoldingFeature?): Boolean {
        return foldFeature?.state == FoldingFeature.State.HALF_OPENED &&
                foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
    }


    fun isSeparating(foldFeature: FoldingFeature?): Boolean {
        return foldFeature?.state == FoldingFeature.State.FLAT && foldFeature.isSeparating
    }


    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        Log.i(TAG, "configurationchanged")
    }


    private fun observeViewModel() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                mainViewModel.state.collect {
                    when (it) {
                        is ActionState.Normal -> {
                            btncreate.isEnabled = true
                            btnadd.isEnabled = true
                            btndel.isEnabled = true
                        }
                        is ActionState.Loading -> {
                            btncreate.isEnabled = false
                            btncreate.isEnabled = false
                            btncreate.isEnabled = false
                        }
                        is ActionState.Drugs -> {
                            drugsAdapter.setList(it.drugs)
//                            drugsAdapter.setNewInstance(it.drugs)
                        }
                        is ActionState.Error-> {
                            Toast.makeText(this@FoldActivity, it.msg, Toast.LENGTH_SHORT).show()
                        }
                        is ActionState.Info ->{
                            Toast.makeText(this@FoldActivity, it.msg, Toast.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
    }
}

这样折叠屏展开的Demo就完成了。


android 折叠listview android 折叠屏_android 折叠listview_17

android 折叠listview android 折叠屏_android 折叠listview_18

往期精彩回顾


android 折叠listview android 折叠屏_android 折叠listview_19







android 折叠listview android 折叠屏_android_20






android 折叠listview android 折叠屏_android 折叠listview_21