安卓模仿微信选择昵称备注效果

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

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

实现一个标签容器效果

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

源代码:ChipGroup.java

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

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

kotlin 复制代码
        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方法。

kotlin 复制代码
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三点位置分别为 <math xmlns="http://www.w3.org/1998/Math/MathML"> x A x_A </math>xA、 <math xmlns="http://www.w3.org/1998/Math/MathML"> x B x_B </math>xB、 <math xmlns="http://www.w3.org/1998/Math/MathML"> x C x_C </math>xC,那么点A到线段BC的距离有
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x = { x B − x A x A < x B 0 x B < x A < x C x A − x C x A > x C 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} </math>x=⎩ ⎨ ⎧xB−xA0xA−xCxA<xBxB<xA<xCxA>xC

推广到点到矩形的距离,得到距离计算方式

kotlin 复制代码
    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

kotlin 复制代码
    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。

kotlin 复制代码
    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

相关推荐
一切皆是定数25 分钟前
Android车载——VehicleHal初始化(Android 11)
android·gitee
一切皆是定数27 分钟前
Android车载——VehicleHal运行流程(Android 11)
android
problc28 分钟前
Android 组件化利器:WMRouter 与 DRouter 的选择与实践
android·java
图王大胜1 小时前
Android SystemUI组件(11)SystemUIVisibility解读
android·framework·systemui·visibility
服装学院的IT男5 小时前
【Android 13源码分析】Activity生命周期之onCreate,onStart,onResume-2
android
Arms2065 小时前
android 全面屏最底部栏沉浸式
android
服装学院的IT男5 小时前
【Android 源码分析】Activity生命周期之onStop-1
android
ChinaDragonDreamer8 小时前
Kotlin:2.0.20 的新特性
android·开发语言·kotlin
网络研究院10 小时前
Android 安卓内存安全漏洞数量大幅下降的原因
android·安全·编程·安卓·内存·漏洞·技术
凉亭下10 小时前
android navigation 用法详细使用
android