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

相关推荐
Lei活在当下14 分钟前
Windows 下 Codex 高效工作流最佳实践
android·openai·ai编程
fatiaozhang952714 分钟前
基于slimBOXtv 9.19.0 v4(通刷晶晨S905L3A/L3AB芯片)ATV-安卓9-完美版线刷固件包
android·电视盒子·刷机固件·机顶盒刷机·晶晨s905l3ab·晶晨s905l3a
私房菜1 小时前
Selinux 及在Android 的使用详解
android·selinux·sepolicy
一只特立独行的Yang2 小时前
Android中的系统级共享库
android
两个人的幸福online2 小时前
php开发者 需要 协程吗
android·开发语言·php
修炼者4 小时前
WindowManager(WMS)构建全局悬浮窗
android
xiaoshiquan12064 小时前
Android Studio里,SDK Manager显示不全问题
android·ide·android studio
Lstone73645 小时前
Bitmap深入分析(一)
android
一起搞IT吧6 小时前
Android功耗系列专题理论之十四:Sensor功耗问题分析方法
android·c++·智能手机·性能优化