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

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

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

实现一个标签容器效果

我们把每个字视作一个标签,那么微信这个效果可以看作是多个标签逐行摆放,其中部分被选中。幸运的是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三点位置分别为 x A x_A xA、 x B x_B xB、 x C x_C xC,那么点A到线段BC的距离有
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} 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

相关推荐
aqi002 小时前
鸿蒙版本的电子书阅读APP开放源码啦
android·华为·ai编程·harmonyos·鸿蒙
墨天梦2 小时前
B19_SunnyWeather到阅读客户端
android·kotlin
美狐美颜sdk3 小时前
直播APP源码可以直接接入视频美颜sdk吗?技术方案详解
android·人工智能·音视频·美颜sdk·直播美颜sdk
用户69371750013846 小时前
2026,程序员的时代拐点到了
android·前端·后端
浪潮IT馆8 小时前
Android Studio 非最新版本下载安装教程
android·ide·android studio
xiaozongt19899 小时前
MasterGo + Claude Code 生成 Android 布局:完整实践指南
android
Anhty10 小时前
2026最新免费手机音频处理工具 !!
android·功能测试·ios·智能手机·音视频
2501_9159090612 小时前
iOS应用从开发到上架App Store的完整发布流程与步骤指南
android·ios·小程序·https·uni-app·webview
狗凯之家源码网13 小时前
AI 步数修改提交系统源码应用场景与落地指南
android·数据库·人工智能·运动步数
ZHOUPUYU13 小时前
PHP 9.0 前瞻:下一代 PHP 会带来哪些新功能?
android·开发语言·php