学更好的别人,
做更好的自己。
——《微卡智享》
本文长度为6259字,预计阅读11分钟
前言
今天是折叠屏开发的第三篇,前面已经介绍了铰链的角度监听和Jetpack Window实现监听效果,今天我们就来做个折叠状态和展开状态显示的不同效果Demo,本篇的重点主要是两个,一是布局文件的设计,另一个就是MotionLayout的动画效果。
实现效果
竖屏折叠
竖屏展开
横屏折叠
横屏展开
上图中可以看到,竖屏折叠时,宫格布局和按钮都在同一界面,按钮在下方,当竖屏展开后,宫格布局移动到左边,而按钮布局移动到右边了,并且由原来的水平排列改为了垂直排列(完整的效果视频看P2)。接下来就来看看怎么实现的。
代码实现
微卡智享
核心代码
实现分屏布局,最主要的就是靠我们自己定义的一个FrameLayout,里面内置了WindowLayoutInfo的参数,参数传入的WindowLayoutInfo来判断当前的什么状态,而应用什么样的布局(左右,上下还是合并)
首先要创建一个attr.xml
<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
* 邮箱:[email protected]
* 创建时间: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中。
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实现的。
其中app:layoutDescription="@xml/split_layout_end_scene"是动画属性,我们当布局改为MotionLayout时,会提示要缺少layoutDescription,使用ALT+ENTER会自动创建这个xml文件,位置在res.xml下
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 指定完成运动所需的毫秒数 。
<ConstraintSet>子元素定义一个场景约束集,并在 <ConstraintSet> 元素中使用 <Constraint> 元素定义单个 View 的属性约束。
android:id:设置当前约束集的 id。这个 id 值可被 <Transition> 元素的 app:constraintSetStart 或者 app:constraintSetEnd 引用。
<Constraint> 元素用来定义单个 View 的属性约束。
它支持对 View 的所有 ConstraintLayout 属性定义约束,以及对 View 的下面这些标准属性定义约束。
由上面的布局文件中可以看到,在start中,我们三个按钮的布局不变,而在end中,三个按钮的布局改为垂直布局了。代码中调用方式直接就是通过motionLayout.transitionToEnd()或motionLayout.transitionToStart()跳转即可
定义motionlayout
判断竖屏展开时调用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就完成了。
源码地址
https://github.com/Vaccae/AndroidMVIDemo.git
点击阅读原文可以看到“码云”的地址
完
往期精彩回顾
Android折叠屏开发学习(二)---使用Jetpack WindowManager监听折叠屏开合状态
Android折叠屏开发学习(一)---通过传感器获取铰链角度