安卓模仿微信选择昵称备注效果
最近对一个交互效果感兴趣,就是有人加你微信时输入了备注信息,你可以直接在备注信息中选择词语成为这个人的昵称备注。虽然微信给用户喂屎,但这个交互效果是值得肯定的。
首先,这是我们要实现的预期效果:
实现一个标签容器效果
我们把每个字视作一个标签,那么微信这个效果可以看作是多个标签逐行摆放,其中部分被选中。幸运的是MD中有这样的效果ChipGroup,这样我们可以节省不少工作。
源代码:ChipGroup.java
然后我们用代码添加一些标签,并在点击时进行反选:
binding.contentMain.chipGroup.apply {
isSelectionRequired
isSingleSelection = false
words.forEach { word ->
addView(Chip(context).also { chip ->
chip.text = word.trimIndent()
chip.setOnClickListener {
it.isSelected = !it.isSelected
}
})
}
}
涂抹选择文本
显然要实现涂抹选中,我们得自定义View,拦截触摸事件定制逻辑。既然是“涂抹”,那么要拦截的就是 ev?.action == MotionEvent.ACTION_MOVE
。
回顾Android触摸事件传递机制,复写onInterceptTouchEvent
方法。
class SwipeSelectLayout @JvmOverloads constructor(context: Context, defStyleAttr: AttributeSet? = null, defStyleRes: Int = 0) : ChipGroup(context, defStyleAttr, defStyleRes) {
private val childrenSelectionOnMoveStart = mutableMapOf<View, Boolean>()
private var closestViewIndex = -1
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
handleMotionEventDown(ev)
}
if (ev.action == MotionEvent.ACTION_MOVE) {
return true
}
return super.onInterceptTouchEvent(ev)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!children.any()) {
return super.onTouchEvent(event)
}
when(event.action) {
MotionEvent.ACTION_DOWN -> {
// 拖动起始点不在一个child上,也选择一个最近的View
// handleMotionEventDown(event)
// return true
}
MotionEvent.ACTION_MOVE -> {
handleMotionEventMove(event)
return true
}
MotionEvent.ACTION_UP -> {
childrenSelectionOnMoveStart.clear()
return true
}
MotionEvent.ACTION_CANCEL -> {
childrenSelectionOnMoveStart.clear()
return true
}
}
return super.onTouchEvent(event)
}
...
}
而“涂抹”的过程实际上是反选了起始点和终止点之间的所有View。因此我们需要
- 手指按下时记录所有View的选择状态,以及距离按下坐标最近的View
- 手指移动时找到当前点View,修改所有View的选中状态
- 不在选中范围的View,重置为初始选中状态
- 在选中范围的View,反选
- 手指抬起时清除记录
计算点到矩形的距离
首先思考点到线段的距离,
设A、B、C三点位置分别为$x_A$、$x_B$、$x_C$,那么点A到线段BC的距离有
$$ x=\begin{cases} x_B - x_A & x_A < x_B \\ 0 & xB < x_A < x_C \\ x_A - x_C & x_A > x_C \end{cases} $$
推广到点到矩形的距离,得到距离计算方式
private fun getDistanceBetweenRectAndPoint(rect: Rect, x: Int, y: Int): Int {
val xDistance = if (x in rect.left..rect.right) {
0
} else (
min(abs(x - rect.left), abs(x - rect.right))
)
val yDistance = if (y in rect.top..rect.bottom) {
0
} else {
min(abs(y - rect.top), abs(y - rect.bottom))
}
return sqrt((xDistance * xDistance + yDistance * yDistance).toFloat()).roundToInt()
}
这样,在手指按下时记录选中状态和最近的View
private fun handleMotionEventDown(event: MotionEvent) {
children.forEachIndexed { index, view ->
// 记录所有Chip的选中状态
childrenSelectionOnMoveStart[view] = view.isSelected
// 查找距离起始点最近的View
closestViewIndex = findClosestViewIndex(event.x.roundToInt(), event.y.roundToInt())
}
}
手指移动过程中反选最近View与起始点之间的View。
private fun handleMotionEventMove(event: MotionEvent) {
val startViewIndex = closestViewIndex
val endViewIndex = findClosestViewIndex(event.x.roundToInt(), event.y.roundToInt())
val range = min(startViewIndex, endViewIndex)..max(startViewIndex, endViewIndex)
for (i in 0 until childCount) {
val view = getChildAt(i)
val originSelection = childrenSelectionOnMoveStart[view] == true
view.isSelected = if (i in range) {
// 反选拖动区域所有View的选中状态
!originSelection
} else {
// 不在拖动区域,还原到初始选择状态
originSelection
}
}
}
大功告成。我们最终实现的效果:点击查看
代码:Gist
最后修改于 2024-03-17