Android自动高亮引导,支持异形区高亮

引言

本文将详细介绍如何一步步在 Android 应用中实现支持多 View 组合异形区域高亮的自动高亮引导。

传统的引导方式通常只高亮简单的矩形或圆形区域,但在实际业务中,我们可能会遇到复杂的界面设计,比如多个元素组合的功能区域。这时就需要一种多 View 组合异形区域高亮的引导。

一、多元素组合异形区高亮的引导

在许多具有复杂界面设计的应用中,传统的矩形高亮引导往往无法满足需求。

某些功能区域可能由多个 View 组合而成,单独高亮其中一个 View 会让用户感到困惑。例如下图:


多 View 组合**异形区域高亮**,可以更精准地覆盖复杂的功能区域,为用户提供更直观的操作指引。

二、最终实现效果

可以看到,最后只需要传上下文和需要高亮显示的view即可,不需要手动指定各点的坐标以及坐标顺序

三、核心原理

3.1 高亮区域绘制

常规的绘制思路可能是直接去绘制非高亮区域,但通常情况下,非高亮区域的形状相较于高亮区域更为复杂,要精确地勾勒出复杂形状的边界,实现较困难。

幸运的是,Android 提供了 PorterDuff.Mode.CLEAR 这种绘制混合模式。PorterDuff.Mode.CLEAR 模式的作用是清除指定区域的绘制内容。

💡 实现思路

  • 绘制基础蒙层:首先,我们使用 canvas.drawColor(backColor) 方法绘制整个蒙层的背景颜色。这个背景颜色通常是半透明的黑色,用于覆盖在应用的其他界面之上。

  • 设置绘制混合模式为 CLEAR:为了实现清除指定区域内容的效果,通过 paint.xfermode = PorterDuff.Mode.CLEAR,将我们需要设置画笔的混合模式为 PorterDuff.Mode.CLEAR。

  • 清除高亮区域:接下来,直接使用canvas.drawPath(path, paint) 在 canvas 绘制高亮的形状就可以清除这些区域的绘制内容。

3.2 高亮区域路径获取

常规的做法是获取各个矩形的顶点坐标,然后手动在代码中指定各个顶点的顺序,底层绘图按照这个指定的点顺序来绘制线条。

然而,这种方式存每次进行开发时,都需要手动输入点的顺序,圆角部分需要使用贝塞尔曲线,繁琐容易出错。一旦矩形的数量、位置或者布局发生变化,就需要重新调整顶点顺序。属于面向过程的做法,将重点放在了具体的绘制步骤上。

换个思路来想,从设计同学的角度出发,我们期望达到的效果是,如果两个矩形相邻距离过近,就将它们合并成一个区域进行展示,这样能够使界面更加整洁。基于这个思路,我们可以将距离比较近的矩形吸附到一起,然后绘制它们合并后的图形。这种方式更符合面向对象的设计理念,将关注点从具体的顶点顺序转移到了矩形之间的关系和整体的区域合并上。

Android 为我们提供了 getBoundaryPath 方法,能够帮助我们轻松获取叠加在一起图形的外边路径。我们只需要将相近的矩形叠加在一起即可。下面我们详细描述矩形合并方法和路径生成方法的具体过程。

💡 矩形合并方法

我们的目标是判断哪些矩形距离足够近,然后将它们重叠。实现思路如下

  • 为了实现这个目标,我们会对所有矩形进行两两比较。使用两层嵌套的循环遍历矩形列表,依次比较每一对矩形。
  • 首先,我们会计算两个矩形在上下左右四个方向上的距离差。例如,计算一个矩形的顶部与另一个矩形的底部之间的垂直距离。
  • 同时,我们还会检查它们在水平和垂直方向上是否存在包含关系,即一个矩形的左右边界是否在另一个矩形的左右边界之内,或者一个矩形的上下边界是否在另一个矩形的上下边界之内。
  • 当满定条件时,既两个矩形距离在吸附距离阈值之内,并且吸附方向上完全被另一个矩形包含。我们就认为这两个矩形是相邻且可以合并的。那么我们就将这个矩形的边界延伸到另一个矩形边界,从而实现重叠。

具体实现如下:

kotlin 复制代码
    private fun unionRect(absRectList: List<Rect>) {
        for (i in absRectList.indices) {
            for (j in absRectList.indices) {
                val rectI = absRectList[i]
                val rectJ = absRectList[j]
                val topDiff = rectI.top - rectJ.bottom
                val leftDiff = rectI.left - rectJ.right
                val bottomDiff = rectJ.top - rectI.bottom
                val rightDiff = rectJ.left - rectI.right

                when {
                    topDiff in 1..CLOSE_DISTANCE && rectI.left >= rectJ.left && rectI.right <= rectJ.right -> {
                        // 上边靠近且包含
                        rectI.top = rectJ.top
                    }

                    leftDiff in 1..CLOSE_DISTANCE && rectI.top >= rectJ.top && rectI.bottom <= rectJ.bottom -> {
                        // 左边靠近且包含
                        rectI.left = rectJ.left
                    }

                    bottomDiff in 1..CLOSE_DISTANCE && rectI.left >= rectJ.left && rectI.right <= rectJ.right -> {
                        // 下边靠近且包含
                        rectI.bottom = rectJ.bottom
                    }

                    rightDiff in 1..CLOSE_DISTANCE && rectI.top >= rectJ.top && rectI.bottom <= rectJ.bottom -> {
                        // 右边靠近且包含
                        rectI.right = rectJ.right
                    }

                    else -> continue
                }
            }
        }
    }

💡 路径生成方法

在完成矩形重叠之后,我们需要将这些合并后的矩形转换为一个连续的路径,以便后续进行绘制。这里就用到了 Android 的 Region类和 getBoundaryPath方法。

Region类提供了op方法,该方法可以按照一定的规则处理叠加的矩形,其支持的几种规则如下:

这里我们使用Op.UNION 获取叠加图形的并集,然后获合并图形的外描边路径,实现思路如下:

  • 首先,我们创建一个 Region对象,它就像是一个容器,可以用来存储和操作多个矩形区域。然后遍历合并后的矩形列表,将每个矩形依次添加到 Region中,使用 region.union(rect) 方法实现矩形的合并操作。Region 会自动处理矩形之间的重叠和合并,最终得到一个包含所有合并后矩形区域的整体区域。

  • 接下来,我们创建一个 Path对象,它表示一个由一系列线段和曲线组成的路径。然后调用 region.getBoundaryPath(path) 方法,将 Region的边界信息提取出来并存储到 Path 中。这个 Path 就代表了所有合并后矩形区域的外边路径,我们可以使用这个路径来绘制高亮区域或者描边。

  • 为了实现圆角,我们可以使用PaintsetPathEffect(PathEffect effect) 方法,将pathEffect设置成CornerPathEffectPaint会自动把拐角处绘制成圆角。无需手动绘制贝塞尔曲线。

    ini 复制代码
      val pathEffect = CornerPathEffect(CORNER_SIZE.toFloat())
      paint.pathEffect = pathEffect

通过以上方式,让矩形合并和路径生成的过程变得更加简单和高效,避免了手动指定顶点顺序和绘制贝塞尔曲线的繁琐工作。

具体实现如下:

kotlin 复制代码
    private fun getUnionPath(absRectList: List<Rect>): Path {
        if (absRectList.isEmpty()) return Path()

        val region = Region()
        for (rect in absRectList) {
            region.union(rect)
        }

        val path = Path()
        region.getBoundaryPath(path)
        return path
    }

四、代码设计

4.1 接口定义

分析需求几个关键的功能点,可以提炼出了三个重要角色。

  • 第一个角色是蒙版:它可以看作是一个 "容器",负责承载引导内容以及添加内容。比如蒙版上包含的高亮、提示文字、箭头等引导元素,这些都需要蒙版来承载。
  • 第二个角色是画笔,它的职责非常明确,专门负责不同引导需求的绘制。由于不同的引导场景可能需要不同的绘制效果,比如绘制不同形状的高亮区域、不同颜色的线条等,所以将绘制功能单独抽象出来,能够更加专注于绘制逻辑的实现,提高可维护性。
  • 第三个角色蒙版的控制器:负责协调多个蒙版。在实际应用中,可能存在多个不同的蒙层,需要按照一定的顺序或者逻辑进行展示和切换,这就需要蒙版控制器来统一管理,确保各个蒙层的显示、切换等操作有序进行。

为了将这些角色的功能在代码层面实现,我们定义了三个接口:ILayerIPainterILayerController。这些接口把蒙层的能力、绘制和控制功能进行了模块化抽象。

这种抽象降低了模块间的耦合度。如果我们之后想要修改绘制高亮区域的逻辑,只需要在 IPainter接口的实现类中进行修改,而不会影响到 ILayerILayerController相关的代码。增强了代码的可维护性和扩展性,方便我们后续快速地对代码进行修改和升级 。

ILayer 接口: 定义了与蒙层相关的基本操作。它提供了添加高亮区域、添加附加视图的方法。

kotlin 复制代码
/**
 * Desc: 蒙层接口
 *
 * Date: 2025/1/23 11:48
 */
interface ILayer {
    fun addHighlight(view: View): BaseLayer
    fun withView(
        view: View,
        verticalOffset: Int = 0,
        horizontalOffset: Int = 0,
        vararg locations: Location
    ): BaseLayer

    fun withImage(imgSrc: Int): BaseLayer
}

IPainter接口: 专注于高亮区域的绘制工作,将绘制逻辑独立出来,便于维护和扩展。

kotlin 复制代码
/**
 * Desc: 高亮区域绘制接口
 *
 * Date: 2025/1/22 10:50
 */
interface IPainter {
    @ColorRes
    fun getBackgroundColor(): Int
    fun onDraw(context: Context, absRectList: List<Rect>, canvas: Canvas, paint: Paint)
}

ILayerController接口: 定义了蒙层的控制逻辑,提供了完整的蒙层显示和关闭操作。

kotlin 复制代码
/**
 * Desc: 蒙层控制器接口
 *
 * User: wangshaowei
 *
 * Date: 2025/1/21 15:02
 */
internal interface ILayerController {
    /**
     * 显示蒙层
     */
    fun show()

    /**
     * 显示下一个蒙层
     */
    fun showNext()

    /**
     * 关闭当前蒙层
     */
    fun dismissCurrent()
}

4.2 蒙层画布

在完成接口定义之后,我们把目光聚焦到核心部分 ------ 画布

在蒙版中我们需要一个实际的视图来进行引导渲染:LayerView继承自 RelativeLayout,为我们提供了一个灵活的布局容器。通过继承 RelativeLayout,我们可以方便地利用其布局特性来管理子视图,更好地呈现蒙层和相关引导内容。

kotlin 复制代码
class LayerView : RelativeLayout {
    //......
}

💡 关键变量

我们的目标是实现一个带有高亮区域和附加视图的蒙层,并且要处理点击事件。为了记录需要高亮显示的视图边界,我们创建一个List<Rect>Rect类可以很好地表示一个矩形区域,后续的绘制和位置计算都将基于这些矩形进行。

ini 复制代码
private val targetRectList = mutableListOf<Rect>()

在绘制过程中,利用 CLEAR 模式清除指定区域的内容,从而形成高亮效果。

在 "3.1 高亮区域绘制" 章节已经介绍过了。

ini 复制代码
private val porterDuffXMode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)

💡 附加视图处理

为了精准布局附加视图,我们要重写 onLayout方法,它主要依据用户设置的对齐方式和目标矩形位置来算出每个附加视图的布局位置,具体步骤如下:

  • 确定视图范围:获取当前视图LayerView的绝对位置信息,方便把后续子视图布局和当前视图关联起来。
  • 遍历子视图:逐个查看子视图,从其 tag 里拿到 LocBean位置信息(添加附加视图时添加到tag中),这里面有目标矩形索引、位置枚举列表以及垂直和水平偏移量。
  • 计算基础位置:依据对齐方式 Location枚举值,如TO_TOPTO_BOTTOM 这些,结合目标矩形中心和子视图大小,算出子视图的基础布局位置。
  • 调整最终位置:在基础布局位置上,加上 LocBean里的垂直和水平偏移量,保证布局更精准。
kotlin 复制代码
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    //确定视图范围
    val rectSelf = HighLightUtils.getViewAbsRect(this)
    val childCount = this.childCount
    //遍历子视图
    for (i in 0 until childCount) {
        val child = this.getChildAt(i)
        val locBean = child.tag as LocBean
        if (locBean.targetIndex !in 0 until targetRectList.size) {
            continue
        }
        val targetRect = targetRectList[locBean.targetIndex]
        val verticalAxis = (targetRect.left + targetRect.right) / 2
        val horizontalAxis = (targetRect.top + targetRect.bottom) / 2
        val width = child.measuredWidth
        val height = child.measuredHeight
        var top = 0
        var bottom = 0
        var left = 0
        var right = 0
        //计算基础位置
        locBean.locateList.forEach {
            when (it) {
                Location.TO_TOP -> {
                    left = if (left == 0) verticalAxis - width / 2 else left
                    top = targetRect.top - height
                    right = if (right == 0) verticalAxis + width / 2 else right
                    bottom = targetRect.top
                }
                // 其他 Location 枚举值的处理逻辑类似
            }
        }
        //调整最终位置
        child.layout(
            left + locBean.horizontalOffset - rectSelf.left,
            top + locBean.verticalOffset - rectSelf.top,
            right + locBean.horizontalOffset - rectSelf.left,
            bottom + locBean.verticalOffset - rectSelf.top
        )
    }
}

💡 绘制流程

dispatchDraw方法负责整个绘制流程。步骤如下:

  • 绘制蒙版的背景颜色:设置整个蒙层的底色。
  • 重置画笔并设置抗锯齿和混合模式:确保绘制的质量和实现清除指定区域的效果。
  • 坐标转换:将目标矩形的坐标从全局坐标系转换到LayerView的局部坐标系中,以便在 canvas 上正确绘制矩形。
  • 调用自定义绘制回调:通过 drawCallBack让外界可以自定义绘制逻辑,增加了代码的灵活性。
  • 调用父类的 dispatchDraw方法:完成剩余的绘制操作。
kotlin 复制代码
override fun dispatchDraw(canvas: Canvas) {
    canvas.drawColor(backColor)
    paint.reset()
    paint.isAntiAlias = true
    paint.xfermode = porterDuffXMode
    val offset = HighLightUtils.getViewAbsRect(this)
    val rectList = targetRectList.map {
        it.offset(-offset.left, -offset.top)
        it
    }
    drawCallBack?.invoke(context, rectList, canvas, paint)
    super.dispatchDraw(canvas)
}

💡 点击事件处理

为了实现蒙层的交互功能,我们需要处理点击事件。onTouchEvent 方法负责捕获用户的点击操作,并根据点击位置判断是否点击在目标矩形区域内。

步骤如下:

  • 记录按下位置:当用户按下屏幕时,记录按下的坐标。
  • 判断点击有效性:当用户抬起手指时,判断移动距离是否小于一定阈值,以确定是否为有效的点击事件。
  • 遍历目标矩形列表:如果是有效点击事件,遍历目标矩形列表,判断点击位置是否在某个目标矩形内。
  • 触发回调:如果点击在某个目标矩形内,调用targetClickListener回调,并传入目标矩形的索引;如果不在任何目标矩形内,传入 -1。
kotlin 复制代码
override fun onTouchEvent(event: MotionEvent): Boolean {
    val action = event.action
    when (action) {
        MotionEvent.ACTION_DOWN -> {
            downX = event.x
            downY = event.y
            return true
        }
        MotionEvent.ACTION_UP -> {
            performClick()
            val upX = event.x
            val upY = event.y
            if (abs(upX - downX) < 10 && abs(upY - downY) < 10) {
                targetClickListener?.let {
                    for ((index, value) in targetRectList.withIndex()) {
                        if (value.contains(upX, upY)) {
                            it.invoke(index)
                            return true
                        }
                    }
                    it.invoke(-1)
                    return true
                }
            }
        }
    }
    return super.onTouchEvent(event)
}

💡 完整代码

scss 复制代码
/**
 * Desc: 蒙版View,实际引导渲染的View
 * 具体绘制委托给外界操作,不同业务绘制实现不同
 * 处理附加视图的位置控制,以及点击事件
 *
 * Date: 2025/1/21 18:11
 */
class LayerView : RelativeLayout {
    private val targetRectList = mutableListOf<Rect>()

    var backColor = 0x60000000
    private val paint = Paint()
    private val porterDuffXMode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
    private var downX = 0f
    private var downY = 0f

    var drawCallBack:
            ((context: Context, rectList: List<Rect>, canvas: Canvas, paint: Paint) -> Unit)? = null
    var targetClickListener: ((index: Int) -> Unit)? = null

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    init {
        setLayerType(View.LAYER_TYPE_SOFTWARE, null)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val rectSelf = HighLightUtils.getViewAbsRect(this)
        val childCount = this.childCount
        for (i in 0 until childCount) {
            val child = this.getChildAt(i)
            val locBean = child.tag as LocBean
            if (locBean.targetIndex !in 0 until targetRectList.size) {
                continue
            }
            val targetRect = targetRectList[locBean.targetIndex]
            val verticalAxis = (targetRect.left + targetRect.right) / 2
            val horizontalAxis = (targetRect.top + targetRect.bottom) / 2
            val width = child.measuredWidth
            val height = child.measuredHeight
            var top = 0
            var bottom = 0
            var left = 0
            var right = 0
            locBean.locateList.forEach {
                when (it) {
                    Location.TO_TOP -> {
                        left = if (left == 0) verticalAxis - width / 2 else left
                        top = targetRect.top - height
                        right = if (right == 0) verticalAxis + width / 2 else right
                        bottom = targetRect.top
                    }

                    Location.TO_BOTTOM -> {
                        left = if (left == 0) verticalAxis - width / 2 else left
                        top = targetRect.bottom
                        right = if (right == 0) verticalAxis + width / 2 else right
                        bottom = targetRect.bottom + height
                    }

                    Location.TO_LEFT -> {
                        left = targetRect.left - width
                        top = if (top == 0) horizontalAxis - height / 2 else top
                        right = targetRect.left
                        bottom = if (bottom == 0) horizontalAxis + height / 2 else bottom
                    }

                    Location.TO_RIGHT -> {
                        left = targetRect.right
                        top = if (top == 0) horizontalAxis - height / 2 else top
                        right = targetRect.right + width
                        bottom = if (bottom == 0) horizontalAxis + height / 2 else bottom
                    }

                    Location.COVER -> {
                        left = targetRect.left
                        top = targetRect.top
                        right = targetRect.right
                        bottom = targetRect.bottom
                    }

                    Location.ALIGN_TOP -> {
                        left = if (left == 0) verticalAxis - width / 2 else left
                        top = targetRect.top
                        right = if (right == 0) verticalAxis + width / 2 else right
                        bottom = targetRect.top + height
                    }

                    Location.ALIGN_BOTTOM -> {
                        left = if (left == 0) verticalAxis - width / 2 else left
                        top = targetRect.bottom - height
                        right = if (right == 0) verticalAxis + width / 2 else right
                        bottom = targetRect.bottom
                    }

                    Location.ALIGN_LEFT -> {
                        left = targetRect.left
                        top = if (top == 0) horizontalAxis - height / 2 else top
                        right = targetRect.left + width
                        bottom = if (bottom == 0) horizontalAxis + height / 2 else bottom
                    }

                    Location.ALIGN_RIGHT -> {
                        left = targetRect.right - width
                        top = if (top == 0) horizontalAxis - height / 2 else top
                        right = targetRect.right
                        bottom = if (bottom == 0) horizontalAxis + height / 2 else bottom
                    }

                    Location.ALIGN_PARENT_RIGHT -> {
                        left = rectSelf.right - width
                        top = if (top == 0) horizontalAxis - height / 2 else top
                        right = rectSelf.right
                        bottom = if (bottom == 0) horizontalAxis + height / 2 else bottom
                    }
                }
            }

            child.layout(
                left + locBean.horizontalOffset - rectSelf.left,
                top + locBean.verticalOffset - rectSelf.top,
                right + locBean.horizontalOffset - rectSelf.left,
                bottom + locBean.verticalOffset - rectSelf.top
            )
        }
    }

    override fun dispatchDraw(canvas: Canvas) {
        canvas.drawColor(backColor)
        paint.reset()
        paint.isAntiAlias = true
        paint.xfermode = porterDuffXMode
        val offset = HighLightUtils.getViewAbsRect(this)
        //将矩形的坐标从全局坐标系转换到 layerView 的局部坐标系中,以便在canvas上绘制矩形。
        val rectList = targetRectList.map {
            it.offset(-offset.left, -offset.top)
            it
        }
        drawCallBack?.invoke(context, rectList, canvas, paint)
        super.dispatchDraw(canvas)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val action = event.action
        when (action) {
            MotionEvent.ACTION_DOWN -> {
                downX = event.x
                downY = event.y
                return true
            }

            MotionEvent.ACTION_UP -> {
                performClick()
                val upX = event.x
                val upY = event.y
                if (abs(upX - downX) < 10 && abs(upY - downY) < 10) {
                    targetClickListener?.let {
                        for ((index, value) in targetRectList.withIndex()) {
                            if (value.contains(upX, upY)) {
                                it.invoke(index)
                                return true
                            }
                        }
                        it.invoke(-1)
                        return true
                    }
                }
            }
        }
        return super.onTouchEvent(event)
    }

    fun addTargetsRect(rect: Rect) {
        targetRectList.add(rect)
    }

    fun addExtraView(
        view: View,
        targetIndex: Int,
        verticalOffset: Int,
        horizontalOffset: Int,
        locateList: List<Location>
    ) {
        view.tag = LocBean(targetIndex, locateList, verticalOffset, horizontalOffset)
        addView(view)
    }

    private fun Rect.contains(x: Float, y: Float): Boolean {
        return (left < right && top < bottom && x >= left && x < right && y >= top && y < bottom)
    }

    private data class LocBean(
        var targetIndex: Int,
        var locateList: List<Location>,
        var verticalOffset: Int,
        var horizontalOffset: Int
    )
}

4.3 基础蒙层

前文我们完成了 ILayer 接口的定义,接下来需要一个具体的类来实现此接口。

BaseLayer 作为一个抽象类,继承自 ILayer 接口。它的职责有两点:

  1. 为蒙层功能赋予基础能力,添加高亮区域、添加提示视图等操作。

  2. 协调 LayerViewIPainter,确保蒙层的渲染、绘制等流程顺畅进行。

💡 成员变量核心作用

  • controllerILayerController 类型,控制蒙层显示切换。(具体实现后续会提到)

  • layerViewLayerView 实例(前面提到的画布),负责蒙层渲染

  • painterIPainter 类型,通过懒加载获取,负责绘制。(具体实现后续会提到)

💡 初始化

在 init 块中创建 LayerView,设置其背景颜色、绘制回调和点击回调,关联 painterlayerView的绘制逻辑。

ini 复制代码
    init {
        layerView = LayerView(context).apply {
            backColor = ContextCompat.getColor(context, painter.getBackgroundColor())
            drawCallBack = painter::onDraw
            targetClickListener = ::onClick
        }
    }

💡 核心方法功能

  • getView():首次调用时请求布局,确保布局准确,避免重复操作。

  • addHighlight():用于添加高亮区域,添加矩形到 layerView。

  • withView():添加附加视图,关联到高亮目标。

  • withImage():创建 ImageView 并添加到蒙层,默认置于高亮目标底部。

  • onClick():处理点击事件,默认显示下一个蒙层,可被子类重写。

💡 完整代码

kotlin 复制代码
/**
 * Desc: 蒙层基类
 * 提供基本的蒙层能力:添加高亮、添加提示视图
 * 并协调LayerView 与 IPainter
 *
 * Date: 2025/1/21 15:08
 */
abstract class BaseLayer(val context: Context) : ILayer {
    internal lateinit var controller: ILayerController
    private var layerView: LayerView
    private val painter: IPainter by lazy { providePainter() }
    private var layerInit = false
    private var targetCounts = 0

    init {
        layerView = LayerView(context).apply {
            backColor = ContextCompat.getColor(context, painter.getBackgroundColor())
            drawCallBack = painter::onDraw
            targetClickListener = ::onClick
        }
    }

    fun getView(): View {
        if (!layerInit) {
            layerView.post {
                layerView.requestLayout()
            }
            layerInit = true
        }
        return layerView
    }

    override fun addHighlight(view: View): BaseLayer {
        targetCounts++
        view.post {
            addHighlight(HighLightUtils.getViewAbsRect(view))
        }
        return this
    }

    private fun addHighlight(rect: Rect): BaseLayer {
        targetCounts++
        layerView.addTargetsRect(rect)
        return this
    }

    override fun withView(
        view: View,
        verticalOffset: Int,
        horizontalOffset: Int,
        vararg locations: Location
    ): BaseLayer {
        layerView.addExtraView(
            view,
            targetCounts - 1,
            verticalOffset,
            horizontalOffset,
            locations.toList()
        )
        return this
    }

    override fun withImage(
        @DrawableRes imgSrc: Int
    ): BaseLayer {
        val imageView = ImageView(context)
        val params = RelativeLayout.LayoutParams(
            RelativeLayout.LayoutParams.WRAP_CONTENT,
            RelativeLayout.LayoutParams.WRAP_CONTENT
        )
        imageView.scaleType = ImageView.ScaleType.FIT_CENTER
        imageView.layoutParams = params
        imageView.adjustViewBounds = true
        imageView.setImageResource(imgSrc)

        withView(imageView, 0, 0, Location.TO_BOTTOM)
        return this
    }

    open fun onClick(index: Int) {
        controller.showNext()
    }

    abstract fun providePainter(): IPainter
    abstract fun onShow()
    abstract fun onDismiss()
}

4.4 通用蒙层

在基础蒙层 BaseLayer 中,我们已经实现了添加高亮区域、添加提示视图等基础能力。

但是,设计同学往往会为项目中的引导环节制定一些通用的显示规范,就如示例中的附加视图规则:

  • 采用居右布局,当屏幕宽度大于素材宽度时,附加视图固定为 368dp 的尺寸。
  • 当屏幕宽度小于等于素材宽度时,附加视图进行等比例缩小。

考虑到这些特定的业务逻辑与 BaseLayer 所承担的基础功能有所不同,将其放在 BaseLayer 里并不合适。因此,我们可以通过继承 BaseLayer 的方式,实现一套适用于整个项目的通用蒙层。这样一来,该蒙层能够在项目内进行复用,又可以将业务与基础能力解耦。

所以我们创建一个CommonLayer类,继承自 BaseLayer

💡 核心设计思路

  • 继承复用:继承 BaseLayer 类,复用其提供的基本蒙层能力,如添加高亮、添加提示视图等功能。

  • 绘制逻辑实现:重写 providePainter() 方法,返回 CommonPainter 实例,将具体的绘制逻辑委托给 CommonPainter 类,实现绘制功能的定制。CommonPainter的实现后续会提到。

  • 附加视图布局规则实现:重写 withImage() 方法,当添加图片作为附加视图时,遵循特定布局规则。

  • 生命周期方法处理:重写 onShow() onDismiss() 方法,当前业务需求无具体行为,但预留了蒙层显示和关闭时执行特定操作的接口,方便后续扩展。

💡 完整代码

kotlin 复制代码
/**
 * Desc: 通用蒙层
 * 附加视图规则
 * 居右布局, 如果屏幕宽度大于素材宽度,则固定尺寸368dp;
 * 如果屏幕宽度小于等于素材宽度,则等比例缩小;
 *
 * Date: 2025/1/21 15:30
 */
class CommonLayer(context: Context) : BaseLayer(context) {
    override fun providePainter() = CommonPainter()

    override fun withImage(imgSrc: Int): BaseLayer {
        val imageView = ImageView(context)
        val params = RelativeLayout.LayoutParams(
            UIUtils.dip2px(368),
            RelativeLayout.LayoutParams.WRAP_CONTENT
        )
        imageView.scaleType = ImageView.ScaleType.FIT_CENTER
        imageView.layoutParams = params
        imageView.adjustViewBounds = true
        imageView.setImageResource(imgSrc)

        withView(
            imageView,
            UIUtils.dip2px(10),
            0,
            Location.TO_BOTTOM,
            Location.ALIGN_PARENT_RIGHT
        )
        return this
    }

    override fun onShow() {
        //无行为
    }

    override fun onDismiss() {
        //无行为
    }
}

4.5 通用画笔

上面我们说到,基础蒙层 BaseLayer 承担着协调 LayerViewIPainter 的职责。

现在,我们聚焦于IPainter进行具体实现。 鉴于引导的绘制工作严格遵循设计同学所指定的通用规则,而这些规则属于业务层面的内容。为了在项目级别实现复用,我们直接实现了通用画笔 CommonPainter

示例中的绘制规则如下:

  • 亮区采用红色描边。
  • 相邻高亮区吸附显示。
  • 拐角处做圆角显示。

CommonPainter类实现了 IPainter接口,专门用于在蒙层上精准绘制高亮区域,以满足上述设计规则要求。

💡 方法实现

  • getBackgroundColor():返回设计同学指定的蒙层颜色的资源 ID。

  • onDraw():核心绘制方法。生成绘制路径,详细在章节 "3.1 高亮区域路径获取" 中描述

    • 合并矩形(吸附操作):调用 unionRect(absRectList) 方法,实现相邻高亮区的吸附显示。

    • 生成合并后的路径:调用 getUnionPath(absRectList) 方法,获取边界路径 Path,这个路径表示了所有高亮区域合并后的形状。

    • 绘制圆角路径:创建 CornerPathEffect对象,将其应用到 paint 上,使路径的拐角处呈现圆角效果,然后使用 canvas.drawPath(path, paint) 绘制带有圆角的路径。

    • 绘制红色描边:创建一个新的 Paint,设置其颜色为红色,样式为描边,开启抗锯齿,设置描边宽度,并应用相同的圆角效果,最后使用 canvas.drawPath(path, strokePaint) 绘制红色描边。

💡 完整代码

kotlin 复制代码
/**
 * Desc: 通用绘制Painter
 * 高亮区红色描边
 * 相邻高亮区吸附显示
 * 拐角处圆角显示
 *
 * Date: 2025/1/22 10:55
 */
class CommonPainter : IPainter {
    companion object {
        //高亮区吸附距离阈值
        private const val CLOSE_DISTANCE = 100
        private val CORNER_SIZE = UIUtils.dip2px(12)
        private val STROKE_WIDTH = UIUtils.dip2px(2)
        private val BACKGROUND_COLOR = R.color.c51
        private val STROKE_COLOR = R.color.c12
    }

    override fun getBackgroundColor() = BACKGROUND_COLOR
    override fun onDraw(context: Context, absRectList: List<Rect>, canvas: Canvas, paint: Paint) {
        //canvas.drawRect(rect, paint)
        unionRect(absRectList)
        val path = getUnionPath(absRectList)

        val pathEffect = CornerPathEffect(CORNER_SIZE.toFloat())
        paint.pathEffect = pathEffect
        canvas.drawPath(path, paint)

        val strokePaint = Paint()
        strokePaint.color = ContextCompat.getColor(context, STROKE_COLOR)
        strokePaint.style = Paint.Style.STROKE
        strokePaint.isAntiAlias = true
        strokePaint.strokeWidth = STROKE_WIDTH.toFloat()
        strokePaint.pathEffect = pathEffect
        canvas.drawPath(path, strokePaint)
    }

    private fun unionRect(absRectList: List<Rect>) {
        for (i in absRectList.indices) {
            for (j in absRectList.indices) {
                val rectI = absRectList[i]
                val rectJ = absRectList[j]
                val topDiff = rectI.top - rectJ.bottom
                val leftDiff = rectI.left - rectJ.right
                val bottomDiff = rectJ.top - rectI.bottom
                val rightDiff = rectJ.left - rectI.right

                when {
                    topDiff in 1..CLOSE_DISTANCE && rectI.left >= rectJ.left && rectI.right <= rectJ.right -> {
                        // 上边靠近且包含
                        rectI.top = rectJ.top
                    }

                    leftDiff in 1..CLOSE_DISTANCE && rectI.top >= rectJ.top && rectI.bottom <= rectJ.bottom -> {
                        // 左边靠近且包含
                        rectI.left = rectJ.left
                    }

                    bottomDiff in 1..CLOSE_DISTANCE && rectI.left >= rectJ.left && rectI.right <= rectJ.right -> {
                        // 下边靠近且包含
                        rectI.bottom = rectJ.bottom
                    }

                    rightDiff in 1..CLOSE_DISTANCE && rectI.top >= rectJ.top && rectI.bottom <= rectJ.bottom -> {
                        // 右边靠近且包含
                        rectI.right = rectJ.right
                    }

                    else -> continue
                }
            }
        }
    }


    private fun getUnionPath(absRectList: List<Rect>): Path {
        if (absRectList.isEmpty()) return Path()

        val region = Region()
        for (rect in absRectList) {
            region.union(rect)
        }

        val path = Path()
        region.getBoundaryPath(path)
        return path
    }
}

4.6 蒙层管理器

此前,我们已经完成了蒙层与画笔的实现工作。鉴于实际需求中存在多张蒙层切换的场景,为了蒙层能按预期展示与切换,我们需要一个专门的管理器来统筹调度。

基于这一需求,我们设计了 HighlightGuideManager类,实现了 ILayerController接口。

💡 构造函数

它提供了两种构造方式。

  • 一种是接收一个 FrameLayout 类型的 parentView 作为参数,这个 parentView 将作为蒙层视图的父容器。
  • 另一种是接收一个 Activity 对象,直接获取该 Activity 的根视图activity.window.decorView 作为父容器。方便调用
kotlin 复制代码
internal class HighlightGuideManager(val parentView: FrameLayout) : ILayerController {
    //......
    constructor(activity: Activity) : this(activity.window.decorView as FrameLayout)
    //.....
}

💡 核心成员变量

  • layerQueue:这是一个 Queue<BaseLayer> 类型的队列。它用于存储待展示的蒙层对象,按照先进先出的顺序管理蒙层,确保蒙层能够依次展示。

  • currentLayer:用于记录当前正在展示的蒙层对象,在需要展示蒙层时再进行赋值。

💡 核心方法

  • dismissCurrent():该方法用于关闭当前正在展示的蒙层。从 parentView 中移除该蒙层视图,然后从 layerQueue 中移除该蒙层对象。

  • showNext():此方法用于展示下一个蒙层。它先调用 dismissCurrent() 方法关闭当前蒙层,然后调用 show() 方法展示队列中的下一个蒙层。

  • show():该方法用于展示蒙层。如果 layerQueue 不为空,则从队列中取出队首的蒙层对象赋值给 currentLayer。为了确保在视图绘制完成后进行操作,使用 parentView.post() 方法,在其中将蒙层视图添加到 parentView 中。

  • addLayer(layer: BaseLayer):该方法用于向 layerQueue 中添加蒙层对象。

💡 完整代码

kotlin 复制代码
/**
 * Desc: 高亮引导管理器
 *
 * Date: 2025/1/21 14:40
 */
class HighlightGuideManager(val parentView: FrameLayout) : ILayerController {
    constructor(activity: Activity) : this(activity.window.decorView as FrameLayout)

    private val layerQueue: Queue<BaseLayer> = LinkedList()
    private lateinit var currentLayer: BaseLayer

    override fun dismissCurrent() {
        if (::currentLayer.isInitialized) {
            currentLayer.onDismiss()
            parentView.removeView(currentLayer.getView())
            layerQueue.poll()
        }
    }

    override fun showNext() {
        dismissCurrent()
        show()
    }

    override fun show() {
        if (layerQueue.isNotEmpty()) {
            currentLayer = layerQueue.peek()!!
            parentView.post {
                parentView.addView(
                    currentLayer.getView(),
                    FrameLayout.LayoutParams(
                        FrameLayout.LayoutParams.MATCH_PARENT,
                        FrameLayout.LayoutParams.MATCH_PARENT
                    )
                )
                currentLayer.onShow()
            }
        }
    }

    fun addLayer(layer: BaseLayer): HighlightGuideManager {
        layer.controller = this
        layerQueue.add(layer)
        return this
    }
}

4.7 引导帮助类

在完成蒙层、画笔以及管理器的设计后,为了更便捷地在具体业务场景中使用这些功能,进一步减少业务层的代码,我们创建一个帮助类来封装复杂的调用逻辑。

showQuickFolderGuide 方法中我们创建了两个蒙层,每个蒙层传入了需要高亮的View,以及需要附加的图片资源,最终业务层使用一行代码即可实现多元素组合异形区高亮的引导。

less 复制代码
/**
 * Desc: 高亮引导帮助类
 * <p>
 * Date: 2025/1/23 16:52
 */
object HighLightHelper {
    fun showQuickFolderGuide(
        activity: Activity,
        view1: View,
        view2: View,
        view3: View,
        view4: View
    ) {
        HighlightGuideManager(activity)
            .addLayer(
                CommonLayer(activity)
                    .addHighlight(view1)
                    .addHighlight(view2)
                    .withImage(R.drawable.cover_guide)
            ).addLayer(
                CommonLayer(activity)
                    .addHighlight(view3)
                    .addHighlight(view4)
                    .withImage(R.drawable.cover_guide2)
            ).show()
    }
}

4.8 其他类代码

c 复制代码
/**
 * Desc: 附加视图的位置
 * TO_XXX可以和ALIGN_XXX同时使用
 *
 * Date: 2025/1/23 10:53
 */
enum class Location {
    COVER,
    TO_LEFT,
    TO_RIGHT,
    TO_TOP,
    TO_BOTTOM,
    ALIGN_TOP,
    ALIGN_BOTTOM,
    ALIGN_LEFT,
    ALIGN_RIGHT,
    ALIGN_PARENT_RIGHT;
}
kotlin 复制代码
/**
 * Desc: 高亮引导工具类
 * 
 * Date: 2025/1/23 10:51
 */
object HighLightUtils {
    fun getViewAbsRect(view: View): Rect {
        val locView = IntArray(2)
        view.getLocationOnScreen(locView)
        return Rect().apply {
            set(locView[0], locView[1], locView[0] + view.measuredWidth, locView[1] + view.measuredHeight)
        }
    }
}

总结

为实现多 View 组合异形区域高亮的引导,我们没有使用传统面向过程的手动指定坐标的方式。而是从设计角度出发,将相邻矩形合并展示,借助 Android 已有方法获取合并图形路径,简化操作。

在设计上,我们采用了模块化和抽象化理念。先抽象出蒙层、画笔、控制器三个核心角色,将各自功能独立,降低模块间的耦合度,便于后续维护与扩展。

以上就是全部内容,有问题还请多指正。

相关推荐
*星星之火*3 小时前
【GPT入门】第5课 思维链的提出与案例
android·gpt
EasyCVR4 小时前
EasyRTC嵌入式视频通话SDK的跨平台适配,构建web浏览器、Linux、ARM、安卓等终端的低延迟音视频通信
android·arm开发·网络协议·tcp/ip·音视频·webrtc
韩家老大4 小时前
RK Android14 在计算器内输入特定字符跳转到其他应用
android
张拭心6 小时前
2024 总结,我的停滞与觉醒
android·前端
夜晚中的人海6 小时前
【C语言】------ 实现扫雷游戏
android·c语言·游戏
ljx14000525508 小时前
Android AudioFlinger(一)——初识AndroidAudio Flinger
android
ljx14000525508 小时前
Android AudioFlinger(四)—— 揭开PlaybackThread面纱
android
Codingwiz_Joy8 小时前
Day04 模拟原生开发app过程 Androidstudio+逍遥模拟器
android·安全·web安全·安全性测试
叶羽西8 小时前
Android15 Camera框架中的StatusTracker
android·camera框架
梦中千秋8 小时前
安卓设备root检测与隐藏手段
android