Android 自定义View — 可展开的流式布局

最近公司App的搜索页进行了调整,搜索历史由原来的列表改为了可展开的流式布局,如下图:

之前找过一个开源的流式布局库,但是不支持展开功能,于是决定自己实现一个。本文简单介绍一下如何使用自定义View实现可展开的流式布局。

整理需求

在开始实现功能之前,先整理一下需求。

  1. 纵向流式布局,所有元素等高,每行可以放置多少个元素根据元素的宽度决定。
  2. 默认显示两行数据,当所有元素所需行数超过默认行数时,显示展开按钮,点击按钮后展开。
  3. 展开后显示所有元素,在末尾显示展开按钮,此时点击展开按钮,收缩为仅显示默认行数。

与普通的流式布局相比,可展开的流式布局需要针对默认行数、展开按钮进行额外的处理。当布局内含元素所需的行数大于默认行数时就可以显示展开按钮。

实现可展开的流式布局。

选择使用继承ViewGroup来实现可展开的流式布局,在onMeasure中计算元素所需行数以及布局总的高度,在onLayout中调整元素的位置,对外提供setData方法用于设置数据、elementClickCallback用于回调元素的点击事件,具体代码如下:

kotlin 复制代码
class ExpandableFlowLayout : ViewGroup {  
  
    private val defaultVerticalSpace = paddingTop + paddingBottom  
    private val defaultHorizontalSpace = paddingStart + paddingEnd  
  
    private var defaultShowRow = 2  
  
    private var measureNeedExpandView = false  
    var expand = false  
  
    private var expandView: View  
  
    private var elementDividerVertical: Int = DensityUtil.dp2Px(8)  
    private var elementDividerHorizontal: Int = DensityUtil.dp2Px(8)  
  
    var elementClickCallback: ((content: String) -> Unit)? = null  
  
    constructor(context: Context) : this(context, null)  
  
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)  
  
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {  
        context.obtainStyledAttributes(attrs, R.styleable.ExpandableFlowLayout).run {  
            defaultShowRow = getInt(R.styleable.ExpandableFlowLayout_default_show_row, 2)  
            expand = getBoolean(R.styleable.ExpandableFlowLayout_default_expand_status, false)  
  
            elementDividerVertical = getDimensionPixelSize(R.styleable.ExpandableFlowLayout_element_divider_vertical, DensityUtil.dp2Px(8))  
            elementDividerHorizontal = getDimensionPixelSize(R.styleable.ExpandableFlowLayout_element_divider_horizontal, DensityUtil.dp2Px(8))  
  
            recycle()  
        }  
        expandView = AppCompatImageView(context).apply {  
            layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, DensityUtil.dp2Px(30))  
            setImageResource(R.mipmap.icon_triangular_arrow_down)  
            rotation = if (!expand) 0f else 180f  
            setOnClickListener {  
                expand = !expand  
                rotation = if (!expand) 0f else 180f  
                requestLayout()  
            }  
        }  
    }  
  
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {  
        val rootWidth = MeasureSpec.getSize(widthMeasureSpec)  
        var usedWidth = defaultHorizontalSpace  
        var usedHeight = defaultVerticalSpace  
  
        measureChild(expandView, widthMeasureSpec, heightMeasureSpec)  
  
        var rowCount = 1  
        for (index in 0 until childCount - 1) {  
            val childView = getChildAt(index)  
            if (childView != null) {  
                // 测量当前子控件的宽高。  
                measureChild(childView, widthMeasureSpec, heightMeasureSpec)  
                val realChildViewUsedWidth = childView.measuredWidth + elementDividerHorizontal  
                val realChildViewUsedHeight = childView.measuredHeight + elementDividerVertical  
  
                if (usedHeight == defaultVerticalSpace) {  
                    usedHeight += realChildViewUsedHeight  
                }  
  
                // 当前子控件宽度加上之前已用宽度大于根布局宽度,需要换行。  
                if (usedWidth + realChildViewUsedWidth > rootWidth) {  
                    // 换行  
                    rowCount++  
  
                    // 当前为未展开状态,并且此时行数已经超过了默认显示行数,跳过后续的测量。  
                    if (!expand && rowCount > defaultShowRow) {  
                        break  
                    }  
  
                    // 重置已用宽度  
                    usedWidth = defaultHorizontalSpace  
                    // 增加已用高度  
                    usedHeight += realChildViewUsedHeight  
                }  
  
                usedWidth += realChildViewUsedWidth  
  
                if (index == childCount - 2 && expand && rowCount > defaultShowRow) {  
                    // 展开状态下的最后一个元素,  
                    // 此时判断能否再放下展开控件,不能则需要增加一行用于显示展开控件。  
                    if (usedWidth + expandView.measuredWidth > rootWidth) {  
                        usedHeight += expandView.measuredHeight + elementDividerVertical  
                    }  
                }  
            }  
        }  
        measureNeedExpandView = rowCount > defaultShowRow  
        setMeasuredDimension(rootWidth, usedHeight)  
    }  
  
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {  
        val availableWidth = right - left  
        var usedWidth = defaultHorizontalSpace  
  
        var positionX = paddingStart  
        var positionY = paddingTop  
  
        var rowCount = 1  
        for (index in 0 until childCount - 1) {  
            val childView = getChildAt(index)  
            if (childView != null) {  
                val realChildViewUsedWidth = childView.measuredWidth + elementDividerHorizontal  
                val realChildViewUsedHeight = childView.measuredHeight + elementDividerVertical  
  
                val changeRowCondition = if ((!expand && rowCount == defaultShowRow)) {  
                    // 未展开状态,并且当前行已经是默认显示行,已用空间需要加上展开控件的空间  
                    usedWidth + realChildViewUsedWidth + (if (measureNeedExpandView) expandView.measuredWidth else 0) > availableWidth  
                } else {  
                    usedWidth + realChildViewUsedWidth > availableWidth  
                }  
                if (changeRowCondition) {  
                    // 换行  
                    rowCount++  
  
                    // 当前为未展开状态,并且此时行数已经超过了默认显示行数,跳过后续处理  
                    if (!expand && rowCount > defaultShowRow) {  
                        childView.layout(0, 0, 0, 0)  
                        break  
                    }  
  
                    // 重置已用宽度  
                    usedWidth = defaultHorizontalSpace  
                    // 新行开始的x轴坐标重置  
                    positionX = paddingStart  
                    // 新行开始的y轴坐标增加  
                    positionY += realChildViewUsedHeight  
                }  
  
                childView.layout(positionX, positionY, positionX + childView.measuredWidth,                     positionY + childView.measuredHeight)  
                positionX += realChildViewUsedWidth  
                usedWidth += realChildViewUsedWidth  
  
                if (index == childCount - 2 && expand && rowCount > defaultShowRow) {  
                    // 展开状态下的最后一个元素,  
                    // 此时判断能否再放下展开控件,不能则需要增加一行用于显示展开控件。  
                    if (usedWidth + expandView.measuredWidth > availableWidth) {  
                        positionX = paddingStart  
                        // 新行开始的y轴坐标增加  
                        positionY += realChildViewUsedHeight  
                    }  
                }  
            }  
        }  
        if (measureNeedExpandView) {  
            expandView.layout(positionX, positionY, positionX + expandView.measuredWidth, positionY + expandView.measuredHeight)  
        } else {  
            expandView.layout(0, 0, 0, 0)  
        }  
    }  
  
    @SuppressLint("InflateParams")  
    fun setData(data: List<String>) {  
        removeAllViews()  
        for (content in data) {  
            LayoutInflater.from(context).inflate(R.layout.layout_example_flow_item, null, false).apply {  
                layoutParams = MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT, DensityUtil.dp2Px(30))  
                findViewById<AppCompatTextView>(R.id.tv_example_flow_item_content).run {  
                    text = content  
                    gravity = Gravity.CENTER_VERTICAL  
                    setOnClickListener {  
                        elementClickCallback?.invoke(content)  
                    }  
                }  
                addView(this)  
            }  
        }  
        addView(expandView)  
    }  
}

效果如图:

示例

演示代码已在示例Demo中添加。

ExampleDemo github

ExampleDemo gitee

相关推荐
服装学院的IT男1 小时前
【Android 13源码分析】Activity生命周期之onCreate,onStart,onResume-2
android
Arms2061 小时前
android 全面屏最底部栏沉浸式
android
服装学院的IT男1 小时前
【Android 源码分析】Activity生命周期之onStop-1
android
ChinaDragonDreamer4 小时前
Kotlin:2.0.20 的新特性
android·开发语言·kotlin
网络研究院6 小时前
Android 安卓内存安全漏洞数量大幅下降的原因
android·安全·编程·安卓·内存·漏洞·技术
凉亭下6 小时前
android navigation 用法详细使用
android
小比卡丘9 小时前
C语言进阶版第17课—自定义类型:联合和枚举
android·java·c语言
前行的小黑炭10 小时前
一篇搞定Android 实现扫码支付:如何对接海外的第三方支付;项目中的真实经验分享;如何高效对接,高效开发
android
落落落sss11 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
代码敲上天.12 小时前
数据库语句优化
android·数据库·adb