欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android自定义ViewGroup—ExpansionPanel

程序员文章站 2022-03-31 07:59:42
前言最近在开发中需要实现一个伸缩效果,就像Flutter中的ExpansionPanel控件一样,效果如下图,发现Android中竟然没有类似的控件,网上也有较多的实现,效果不太理想,因此决定自己实现一个。思路为了方便可伸缩内容的拓展以及达到图中的效果,我选择直接继承于CardView来实现,这样比较方便,不需要自己实现测量等方法。在CardView内部填充一个ConstraintLayout(减少布局嵌套层数)作为根布局,使其他控件在根布局的特点位置进行摆放,最后利用动画效果实现伸缩。从效果图中可...

前言

最近在开发中需要实现一个伸缩效果,就像Flutter中的ExpansionPanel控件一样,效果如下图,发现Android中竟然没有类似的控件,网上也有较多的实现,效果不太理想,因此决定自己实现一个。
Android自定义ViewGroup—ExpansionPanel

思路

为了方便可伸缩内容的拓展以及达到图中的效果,我选择直接继承于CardView来实现,这样比较方便,不需要自己实现测量等方法。在CardView内部填充一个ConstraintLayout(减少布局嵌套层数)作为根布局,使其他控件在根布局的特点位置进行摆放,最后利用动画效果实现伸缩。从效果图中可以得知,该控件分为标题(也就是处于收缩状态下显示的控件)、伸缩按钮以及展开后需要显示的控件。知道思路之后那就直接开干。

开始

  • 新建类ExpansionPanel并继承于CardView,定义根布局root
class NewExpansionPanel(
    context: Context,
    attributeSet: AttributeSet
) : CardView(context,attributeSet){

	private var root = ConstraintLayout(context)    // 根布局
    private var toggle = ImageButton(context)       // 伸缩按钮
    
}

这里直接创建ConstraintLayout是为了减少解析XML的时间,如果闲麻烦的话可以直接在构造方法中加载一个XML布局,然后得到根布局。

  • 添加ViewNewExpansionPanel
    在解析XML时,系统会自动调用ViewGroup中的addView方法将解析得到的子控件添加到其中,因此,我们想要得到XML中的子控件,我才用重写addView方法实现。
	private val views = mutableListOf<View>()       // 存放子控件
	private var initial = false		// 是否初始化
// 重写addView获取子控件
    override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
        views.add(child!!)		// 保存子控件
        child.layoutParams = params
        if (views.size > 2){
            throw IllegalArgumentException("childCount must less or equals two. Your childCount is ${views.size} !")
        }
        if (!initial){
        // 由于可能有多个子控件,保证以下代码只执行一次,减少不必要的测量
            removeAllViews()
            // 添加根布局root
            super.addView(root, index, params)
            initial = true
        }
    }

这样既得到了子控件也让根布局成功的添加到了NewExpansionPanel中。

  • 默认显示收缩状态
    定义变量isExpand作为是否伸缩的标志,定义变量expansionHeight存放整个控件的高度,重写onMeasure方法,控制控件高度。
	private var isExpand = false
	private var expansionHeight = 0
	
	override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        addAllViews()	// 将XML中的子控件添加到root
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)	// 测量各控件的高宽
        getExpansionHeight()	// 获取整个控件的高度
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),expansionHeight)		// 设置控件的高宽
    }

在第一次测量之前,把XML中的子控件添加到根布局,并为这些控件添加约束,完成后再进行测量,测量后获取各个控件的高度以及设置根布局的高度。

	
	private var isAdd = false   // 是否添加布局

	private fun addAllViews(){
        if (!isAdd){
            // 遍历子控件并添加到root
            views.forEach {
                if (it.id == View.NO_ID){
                    it.id = View.generateViewId()
                }
                root.addView(it)
            }
        }
        initConstraint()    // 为各个控件添加约束
    }

定义变量topHeightcontentHeight分别存放顶部最大高度(顶部View与伸缩按钮的高度最大值)以及底部内容的高度。

	private fun getExpansionHeight(){
	        if (!isAdd){
	        	// 得到控件高度
	            expansionHeight = if (views.size == 0){
	                toggle.measuredHeight
	            }else{
	            // 得到顶部高度
	                topHeight = if (views[0].measuredHeight > toggle.measuredHeight) views[0].measuredHeight else toggle.measuredHeight
	                topHeight
	            }
	            // 得到内容高度
	            contentHeight = if (views.size > 1) views[1].measuredHeight else 0
	            isAdd = true	// 更改标记
	        }
	    }

这样各个控件的高度就得到了,然后根据这些高度与动画结合起来就可以实现伸缩效果了。

  • 增加动画
    这里使用View的属性动画进行伸缩操作。
private fun startExpand(){
        if (isExpand){
        	// 将按钮旋转180°
            toggle.animate().rotation(180f).setUpdateListener {
            	// 动画进度值
                val v = it.animatedValue as Float
                // 更改控件的高度
                expansionHeight = topHeight + (v * contentHeight).toInt()
                // 重新测量
                requestLayout()
            }
        }else{
            toggle.animate().rotation(0f).setUpdateListener {
                val v = it.animatedValue as Float
                expansionHeight = topHeight + (( 1f - v) * contentHeight).toInt()
                requestLayout()
            }
        }
    }

最后设置伸缩按钮的点击事件,整个效果就完成了,我们来看看最终效果。

  • 最终效果
    布局文件
<?xml version="1.0" encoding="utf-8"?>
<com.example.myapplication.widget.NewExpansionPanel android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp"
    xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="NewExpansionPanel"
        android:textSize="30sp"/>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:layout_width="96dp"
            android:layout_height="94dp"
            android:src="@mipmap/ic_launcher"
            android:layout_centerInParent="true"
            android:layout_margin="16dp"/>

    </RelativeLayout>

</com.example.myapplication.widget.NewExpansionPanel>

贴上最终效果图:
Android自定义ViewGroup—ExpansionPanel
完整代码如下:

/**
 * Created by Holo.
 * On 2020/11/20.
 */
class NewExpansionPanel(
    context: Context,
    attributeSet: AttributeSet
) : CardView(context,attributeSet){

    companion object{
        private const val TAG = "NewExpansionPanel"
    }

    private val size = dp2Px(64)

    private val views = mutableListOf<View>()
    private val constraintSet = ConstraintSet()

    private var root = ConstraintLayout(context)
    private var toggle = ImageButton(context)

    private var initial = false
    private var add = false

    private var isExpand = false

    private var topHeight = 0
    private var expansionHeight = 0
    private var contentHeight = 0

    init {
        with(root){
            id = View.generateViewId()
        }
        with(toggle){
            id = View.generateViewId()
            root.addView(this)
            setImageResource(R.drawable.ic_baseline_keyboard_arrow_up_24)
            background = getSelectedItemBackground()
            setOnClickListener {
                isExpand = !isExpand
                startExpand()
            }
        }
    }

    private fun getSelectedItemBackground() : Drawable{
        val typedValue = TypedValue()
        context.theme.resolveAttribute(android.R.attr.selectableItemBackground,typedValue,false)
        val typedArray = context.theme.obtainStyledAttributes(typedValue.resourceId, IntArray(1) { android.R.attr.selectableItemBackground })
        return typedArray.getDrawable(0)!!
    }

    private fun startExpand(){
        if (isExpand){
            toggle.animate().rotation(180f).setUpdateListener {
                val v = it.animatedValue as Float
                expansionHeight = topHeight + (v * contentHeight).toInt()
                requestLayout()
            }
        }else{
            toggle.animate().rotation(0f).setUpdateListener {
                val v = it.animatedValue as Float
                expansionHeight = topHeight + (( 1f - v) * contentHeight).toInt()
                requestLayout()
            }
        }
    }

    fun expand(){
        isExpand = true
        startExpand()
    }

    fun fold(){
        isExpand = false
        startExpand()
    }

    private fun initConstraint(){
        root.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT)
        // 设置toggle的约束
        constraintSet.apply {
            clone(root)
            // toggle
            constrainHeight(toggle.id,size)
            constrainWidth(toggle.id,size)
            connect(toggle.id,ConstraintSet.END,ConstraintSet.PARENT_ID,ConstraintSet.END)
            connect(toggle.id,ConstraintSet.TOP,ConstraintSet.PARENT_ID,ConstraintSet.TOP)
            if (views.size > 0){
                // titleView
                setHorizontalWeight(views[0].id,1f)
                constrainWidth(views[0].id,0)
                connect(views[0].id,ConstraintSet.START,ConstraintSet.PARENT_ID,ConstraintSet.START)
                connect(views[0].id,ConstraintSet.END,toggle.id,ConstraintSet.START)
                connect(views[0].id,ConstraintSet.TOP,ConstraintSet.PARENT_ID,ConstraintSet.TOP)
                //connect(toggle.id,ConstraintSet.START,views[0].id,ConstraintSet.END)
            }
            if (views.size > 1){
                // 取高度最大的View作为顶部约束的对象
                val v = if (toggle.measuredHeight > views[0].measuredHeight) toggle else views[0]
                // contentView
                connect(views[1].id,ConstraintSet.TOP,v.id,ConstraintSet.BOTTOM)
                connect(views[1].id,ConstraintSet.START,ConstraintSet.PARENT_ID,ConstraintSet.START)
                connect(views[1].id,ConstraintSet.END,ConstraintSet.PARENT_ID,ConstraintSet.END)
                connect(views[1].id,ConstraintSet.BOTTOM,ConstraintSet.PARENT_ID,ConstraintSet.BOTTOM)
            }
            applyTo(root)
        }
    }

    private fun addAllViews(){
        if (!add){
            views.forEach {
                if (it.id == View.NO_ID){
                    it.id = View.generateViewId()
                }
                root.addView(it)
            }
        }
        initConstraint()
    }

    private fun getExpansionHeight(){
        if (!add){
            expansionHeight = if (views.size == 0){
                toggle.measuredHeight
            }else{
                topHeight = if (views[0].measuredHeight > toggle.measuredHeight) views[0].measuredHeight else toggle.measuredHeight
                topHeight
            }
            contentHeight = if (views.size > 1) views[1].measuredHeight else 0
            add = true
        }
    }

    // 重写addView获取子控件
    override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
        Log.d(TAG, "addView: ")
        views.add(child!!)
        child.layoutParams = params
        if (views.size > 2){
            throw IllegalArgumentException("childCount must less or equals two. Your childCount is ${views.size} !")
        }
        if (!initial){
            removeAllViews()
            super.addView(root, index, params)
            initial = true
            Log.d(TAG, "addView: success !")
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        addAllViews()
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        getExpansionHeight()
        Log.d(TAG, "onMeasure: toggle:${toggle.measuredHeight} top:${views[0].measuredHeight}")
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),expansionHeight)
    }

    private fun dp2Px(v: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,v.toFloat(),resources.displayMetrics).toInt()

}

本文地址:https://blog.csdn.net/qq_31054411/article/details/110070512