前言
之前项目中有个输入车牌号的功能,产品要求通过特定键盘来实现,并给看了下其他产品的效果图,如下所示:
下面就来分享下具体的实现方法
一开始想到的方案是自定义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
}
}
效果如下所示:
车牌号输入框的部分也是通过RecyclerView来实现,因为燃油车和新能源的位数不一样,所以在切换能源类型时需要借助GridLayoutManager的setSpanCount方法动态修改输入框的个数。
接下来就是键盘和输入框的交互部分,通过监听RecyclerView每个item的点击事件,更新输入框选中状态和按键的是否可点击状态即可。这里的更新逻辑有点多,就不贴代码了,感兴趣的可直接查项目完整代码
最终效果图如下: