安卓模仿微信选择昵称备注效果
最近对一个交互效果感兴趣,就是有人加你微信时输入了备注信息,你可以直接在备注信息中选择词语成为这个人的昵称备注。虽然微信给用户喂屎,但这个交互效果是值得肯定的。

首先,这是我们要实现的预期效果:

微信昵称选择效果

实现一个标签容器效果

我们把每个字视作一个标签,那么微信这个效果可以看作是多个标签逐行摆放,其中部分被选中。幸运的是MD中有这样的效果ChipGroup,这样我们可以节省不少工作。

源代码:ChipGroup.java

文章介绍:Android修行手册 - ChipGroup

ChipGroup

然后我们用代码添加一些标签,并在点击时进行反选:

        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。因此我们需要

  1. 手指按下时记录所有View的选择状态,以及距离按下坐标最近的View
  2. 手指移动时找到当前点View,修改所有View的选中状态
    • 不在选中范围的View,重置为初始选中状态
    • 在选中范围的View,反选
  3. 手指抬起时清除记录

计算点到矩形的距离

首先思考点到线段的距离,

点到线段的距离

设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