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

相关推荐
ggs_and_ddu2 小时前
Android--java实现手机亮度控制
android·java·智能手机
zhangphil8 小时前
Android绘图Path基于LinearGradient线性动画渐变,Kotlin(2)
android·kotlin
watl08 小时前
【Android】unzip aar删除冲突classes再zip
android·linux·运维
键盘上的蚂蚁-8 小时前
PHP爬虫类的并发与多线程处理技巧
android
喜欢猪猪9 小时前
Java技术专家视角解读:SQL优化与批处理在大数据处理中的应用及原理
android·python·adb
JasonYin~11 小时前
HarmonyOS NEXT 实战之元服务:静态案例效果---手机查看电量
android·华为·harmonyos
zhangphil11 小时前
Android adb查看某个进程的总线程数
android·adb
抛空11 小时前
Android14 - SystemServer进程的启动与工作流程分析
android
Gerry_Liang13 小时前
记一次 Android 高内存排查
android·性能优化·内存泄露·mat
天天打码14 小时前
ThinkPHP项目如何关闭runtime下Log日志文件记录
android·java·javascript