效果如上图。
Main Ideas
- 左右两个列表
- 左列表展示人员数据,含有姓氏首字母的 header item
- 右列表是一个全由姓氏首字母组成的索引列表,点击某个item,展示一个气泡组件(它会自动延时关闭), 左列表滚动并显示与点击的索引列表item 相同的 header
- 搜索动作后,匹配人员名称中是否包含搜索字符串,或搜索字符串为单一字符时,是否能匹配到某个首字母;而且滚动后,左右列表都能滚动至对应 Header 或索引处。
Steps
S1. 汉字拼音转换
先找到了 Pinyin4J 这个库;后来发现没有对多音字姓氏 的处理;之后找到 TinyPinyin ,它可以自建字典,指明多音汉字(作为姓氏时)的指定读音。
fun initChinaNamesDictMap() {
// 增加 多音字 姓氏拼音词典
Pinyin.init(
Pinyin.newConfig()
.with(object : PinyinMapDict() {
override fun mapping(): MutableMap<String, Array<String>> {
val map = hashMapOf<String, Array<String>>()
map["解"] = arrayOf("XIE")
map["单"] = arrayOf("SHAN")
map["仇"] = arrayOf("QIU")
map["区"] = arrayOf("OU")
map["查"] = arrayOf("ZHA")
map["曾"] = arrayOf("ZENG")
map["尉"] = arrayOf("YU")
map["折"] = arrayOf("SHE")
map["盖"] = arrayOf("GE")
map["乐"] = arrayOf("YUE")
map["种"] = arrayOf("CHONG")
map["员"] = arrayOf("YUN")
map["繁"] = arrayOf("PO")
map["句"] = arrayOf("GOU")
map["牟"] = arrayOf("MU") // mù、móu、mū
map["覃"] = arrayOf("QIN")
map["翟"] = arrayOf("ZHAI")
return map
}
})
)
}
// Pinyin.toPinyin(char) 方法不使用自定义字典
而使用Pinyin.toPinyin(nameText.first().toString(), ",").first()
// 将 nameText 的首字符,转为拼音,并取拼音首字母
S2. 数据bean 和 item view
对原有数据bean 增加 属性:
data class DriverInfo(
var Name: String?, // 人名
var isHeader: Boolean, // 是否是 header item
var headerPinyinText: String? // header item view 的拼音首字母
)
左列表的 item view,当数据是 header时,仅显示 header textView (下图红色的文字),否则仅显示 item textView (下图黑色的文字):
右列表的 item view,更简单了,就只含一个 TextView 。
S3. 处理数据源
这一步要做的是:转拼音;拼音排序;设置 isHeader、headerPinyinText
属性;构建新的数据源集合 …
// 返回新的数据源
fun getPinyinHeaderList(list: List<DriverInfo>): List<DriverInfo> {
list.forEachIndexed { index, driverInfo ->
if (driverInfo.Name.isNullOrEmpty()) return@forEachIndexed
// Pinyin.toPinyin(char) 方法不使用自定义字典
val header = Pinyin.toPinyin(driverInfo.Name!!.first().toString(), ",").first()
driverInfo.headerPinyinText = header.toString()
}
// 以拼音首字母排序
(list as MutableList).sortBy { it.headerPinyinText }
val newer = mutableListOf<DriverInfo>()
list.forEachIndexed { index, driverInfo ->
val newHeader = index == 0 || driverInfo.headerPinyinText != list[index - 1].headerPinyinText
if (newHeader) {
newer.add(DriverInfo(null, true, driverInfo.headerPinyinText))
}
newer.add(driverInfo)
}
return newer
}
当左侧列表有了数据源之后,那右侧的也就可以有了:将所有 header item 的 headerPinyinText 取出,并转为 新的 集合。
val indexList = driverList?.filter { it.isHeader }?.map { it.headerPinyinText ?: ""} ?: arrayListOf()
indexAdapter.updateData(indexList)
S4. Adapter 的点击事件
这里省略设置 adapter 、LinearLayoutManager 等 样板代码 …
设定:左侧的适配器名为 adapter, 右侧字母索引名为 indexAdapter;左侧 RV 名为 recyclerView,右侧的名为了 rvIndex。
- 左侧的点击事件,不会触发右侧的联动
override fun onItemClick(position: Int, data: DriverInfo) {
if (data.isHeader) return
// 如果是点击 item, 做自己的业务
}
- 右侧点击事件,会触发左侧的滚动;还可以触发气泡 view 的显示,甚至自身的滚动
override fun onItemClick(position: Int, data: DriverInfo) {
val item = adapter.dataset.first { it.isHeader && it.headerPinyinText == data }
val index = adapter.dataset.indexOf(item)
// mBind.recyclerView.scrollToPosition(index)
(mBind.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(index, 0)
// mBind.rvIndex.scrollToPosition(position)
val rightIndex = indexAdapter.dataset.indexOf(item.headerPinyinText)
(mBind.rvIndex.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(rightIndex, 0)
showBubbleView(position, data)
}
一开始用 rv#scrollToPosition(),发现也能滚动。但是呢,指定 position 之后还有其它内容时,且该位置之前也有很多的内容;点击后,仅将 该位置 item ,显示在页面可见项的最后一个位置。 改成 LinearLayoutManager#scrollToPositionWithOffset()后,更符合预期。
S5. 气泡 view
<widget.BubbleView
android:id="@+id/bubbleView"
android:layout_width="100dp"
android:layout_height="100dp"
android:visibility="invisible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/rv_index" />
设置 文本;获取点击的 item view;根据 item view 的位置 进行显示设置;延迟1秒 隐藏气泡:
private fun showBubbleView(position: Int, data: String) {
lifecycleScope.launch {
mBind.bubbleView.setText(data)
val itemView = mBind.rvIndex.findViewHolderForAdapterPosition(position)?.itemView ?: return@launch
mBind.bubbleView.showAtLocation(itemView)
delay(1000)
mBind.bubbleView.visibility = View.GONE
}
}
自定义 气泡 view:
/**
* desc: 指定view左侧显示的气泡view
* author: stone
* email: aa86799@163.com
* time: 2024/9/27 18:22
*/
class BubbleView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = resources.getColor(R.color.syscolor)
style = Paint.Style.FILL
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 50f, resources.displayMetrics)
textAlign = Paint.Align.CENTER
}
private val path = Path()
private var text: String = ""
fun setText(text: String) {
this.text = text
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制贝塞尔曲线气泡
path.reset()
path.moveTo(width / 2f, height.toFloat())
path.quadTo(width.toFloat(), height.toFloat(), width.toFloat(), height / 2f)
path.quadTo(width.toFloat(), 0f, width / 2f, 0f)
path.quadTo(0f, 0f, 0f, height / 2f)
path.quadTo(0f, height.toFloat(), width / 2f, height.toFloat())
path.close()
canvas.drawPath(path, paint)
// 绘制文本
canvas.drawText(text, width / 2f, height / 2f + textPaint.textSize / 3, textPaint)
}
fun showAtLocation(view: View) {
view.let {
val location = IntArray(2)
it.getLocationOnScreen(location)
// 设置气泡的位置
x = location[0] - width.toFloat() - 10
y = location[1] - abs(height - it.height) / 2f - getStatusBarHeight()
visibility = View.VISIBLE
}
}
private fun getStatusBarHeight(): Int {
var result = 0
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
result = resources.getDimensionPixelSize(resourceId)
}
return result
}
}
S6. 搜索实现
- 空白输入字符时,左侧返回全数据源;右侧列表跟随左侧变化。
- 有输入时,根据全数据源,获取 匹配的子数据源;右侧列表跟随左侧变化。
fun filterTextToNewHeaderList(list: List<DriverInfo>?, text: String): List<DriverInfo>? {
// 如果item 的拼音和 查询字符 相同;或者,非 header 时,名称包含查询字符
val filterList = list?.filter { it.headerPinyinText?.equals(text, true) == true
|| !it.isHeader && it.Name?.contains(text, ignoreCase = true) == true }
if (filterList.isNullOrEmpty()) {
return null
}
val newer = mutableListOf<DriverInfo>()
filterList.forEachIndexed { index, driverInfo ->
val newHeader = (index == 0 || driverInfo.headerPinyinText != filterList[index - 1].headerPinyinText) && !driverInfo.isHeader
if (newHeader) {
newer.add(DriverInfo(null, true, driverInfo.headerPinyinText))
}
newer.add(driverInfo)
}
return newer
}
// 搜索点击
mBind.tvSearch.setOnClickListener {
val beanList: List<DriverInfo>? = adapter.filterTextToNewHeaderList(driverList, text)
adapter.updateData(beanList)
val indexList = beanList.filter { it.isHeader }.map { it.headerPinyinText ?: ""}
indexAdapter.updateData(indexList)
}
整体核心实现都贴出来了,如果有什么bug,欢迎回复