通过RecyclerView LayoutManager自定义车牌键盘

2,732 阅读4分钟

前言

之前项目中有个输入车牌号的功能,产品要求通过特定键盘来实现,并给看了下其他产品的效果图,如下所示:

keyboard.2024-03-27 21_54_48.gif

下面就来分享下具体的实现方法

一开始想到的方案是自定义View,但是这样的话就需要加入数据的处理和触摸事件落点判断,实现起来稍显复杂。

那有没有什么简单一点的方案呢,答案就是RecyclerView。其实仔细观察键盘布局,有点类似于一个N*N的宫格列表,但是因为有部分按键的尺寸和位置有点特殊,通过系统自带的GridLayoutManager无法实现上图的效果,这时候只自定义LayoutManager就派上用场了。

自定义LayoutManager的教程有很多,感兴趣的可自行百度,此处重点说一下onLayoutChildren方法

/**
 * Lay out all relevant child views from the given adapter.(布置给定适配器中的所有相关子视图。)
 * 部分注释省略...
 */
public void onLayoutChildren(Recycler recycler, State state) {            
  Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, State state) ");    
}

通过注释可以看出,该方法可以指定RecyclerView子视图位置(相当于ViewGroup的OnLayout方法),我们可以将键盘分为多种按键类型,然后在该方法中通过判断按键类型来摆放子View的位置

定义的按键类型如下

/** 键盘上的数字 */  
const val ITEM_NUMBER = 0  
/** 键盘上的汉字或英文 */  
const val ITEM_CHAR = 1  
/** 键盘上的删除按钮 */  
const val ITEM_DELETE = 2  
/** 键盘上的完成按钮 */  
const val ITEM_COMPLETE = 3  
/** 键盘上其他类型的按钮 */  
const val ITEM_OTHER = 4

虽然类型比较多,但我们只需要从左到右依次排列即可,可用宽度不够则换行。不过由于“删除”、“完成”按键位置比较特殊,所以需要单独判断。

在排列摆放子View之前需要先获取子View的尺寸,以确定子view的坐标,测量子view尺寸的方法为RecyclerView的measureChildWithMargins方法

// RecyclerView.java
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {...}

该方法接收三个参数:待测量的子view、已用宽度、已用高度,第一个参数就不用说了,直接传要测量的View即可。那已用宽度有什么作用呢,就是用来确定测量后子view的可用宽度为多少,即可用宽度=父View宽度-已用宽度,假设子view宽度为match_parent,则子view宽度=可用宽度,已用高度同理。由于本例中键盘上的按键为9列或者10列,所以单列按键的可用宽度=(父View宽度-间距)/9,则已用宽度=父View宽度-可用宽度

测量完尺寸以后就可以通过getDecoratedMeasuredWidth、getDecoratedMeasuredHeight获得子view的宽高信息,然后调用layoutDecoratedWithMargins方法传入子view及其上下左右位置的坐标来摆放子view就可以了。每摆放一次子view就更新下已用宽度,当判断已用宽度+子view宽度大于父View宽度时则进行换行处理,换行时更新已用高度。这里需要注意一点,“删除”、“完成”的排列方式是从右到左。

关键代码如下:

override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
        recycler ?: return
        detachAndScrapAttachedViews(recycler)
        // 是否存在数字类型
        var existNumber = false
        // 数字(一行,共10位)宽度
        val numItemWidth = (width - mColumnPadding * 11) / 10
        // 省份和字母(每行9位)宽度
        val charItemWidth = ((width - mColumnPadding * 10) / 9f).roundToInt()
        // 已用宽度
        var widthUsed = mColumnPadding
        // 已用高度
        var heightUsed = mColumnPadding
        // 是否为每行的最后一个子项
        var isRowLastChild: Boolean
        for (i in 0 until itemCount) {
            val child = recycler.getViewForPosition(i)
            addView(child)
            // item视图类型
            val type = getItemViewType(child)
            val childWidth = when (type) {
                ITEM_NUMBER -> {
                    existNumber = true
                    numItemWidth
                }
                ITEM_CHAR -> charItemWidth
                ITEM_DELETE -> width - charItemWidth * 7 - mColumnPadding * 9
                ITEM_COMPLETE -> width - charItemWidth * 6 - mColumnPadding * 8
                else -> charItemWidth * 2 + mColumnPadding
            }
            isRowLastChild = when (type) {
                ITEM_NUMBER -> i > 0 && i % 9 == 0
                ITEM_CHAR -> i > 0 && (if (existNumber) i % 9 == 0 else i % 9 == 8)
                else -> false
            }
            val measureWidthUsed = if (isRowLastChild) widthUsed + mColumnPadding else width - childWidth
            measureChildWithMargins(child, measureWidthUsed, 0)
            val measuredW = getDecoratedMeasuredWidth(child)
            val measuredH = getDecoratedMeasuredHeight(child)
            val childBottom = heightUsed + measuredH + mRowPadding
            when (type) {
                ITEM_DELETE, ITEM_COMPLETE -> {
                    val right = width - mColumnPadding
                    layoutDecoratedWithMargins(child, right - measuredW, heightUsed, right, childBottom)
                    widthUsed = width
                }
                else -> {
                    layoutDecoratedWithMargins(child, widthUsed, heightUsed, widthUsed + measuredW, childBottom)
                    widthUsed += measuredW + mColumnPadding
                }
            }
            // 如果已用宽度等于父view宽度,表示当前行的子view已全部摆放完毕
            // 所以需要在新的一行摆放下个子view,已用宽度置为初始值,已用高度等于当前行子view的底部坐标 + 行间距
            if (widthUsed == width) {
                widthUsed = mColumnPadding
                heightUsed = childBottom
            }
        }

效果如下所示:

Screenshot_20240408_170313.png

车牌号输入框的部分也是通过RecyclerView来实现,因为燃油车和新能源的位数不一样,所以在切换能源类型时需要借助GridLayoutManager的setSpanCount方法动态修改输入框的个数。

接下来就是键盘和输入框的交互部分,通过监听RecyclerView每个item的点击事件,更新输入框选中状态和按键的是否可点击状态即可。这里的更新逻辑有点多,就不贴代码了,感兴趣的可直接查项目完整代码

项目地址:github.com/WangXiminDe…

最终效果图如下:

2024-04-08 23-13-33.2024-04-08 23_14_11.gif