Android实现蒙版引导(附带源码)

一、项目介绍

在移动应用中,为新用户提供一套引导流程可以让他们快速上手核心功能,减少“迷茫”与“流失”。其中,蒙版引导(Mask Guide) 通过对页面中重要控件进行高亮,其余区域覆盖半透明遮罩,并结合文字说明或箭头,能直观地指示用户“这里是干嘛的”、“点这里可以……”。典型场景包括:

  • 首次打开 App 时引导添加好友、发布动态等关键按钮

  • 功能更新后针对新交互做局部提示

  • 复杂流程中分步引导,如填写信息、操作流程

本项目目标是打造一个 通用、可配置、易集成 的蒙版引导库,满足以下需求:

  1. 多步骤:支持按序列展示多步高亮;

  2. 多形状:高亮区域支持圆形、矩形、圆角矩形;

  3. 提示自定义:提示文字与箭头位置可自定义;

  4. 点击继续/退出:点击遮罩任意处可进入下一步或直接退出;

  5. 只显示一次:引导完成后通过 SharedPreferences 标记,不再重复展示;

  6. 任意页面集成:可在 ActivityFragment 中随时调用;

  7. 样式可配置:遮罩颜色、透明度、文字样式、箭头图标等均可 XML/Builder 方式配置。

借助本文,您将学会:

  • 如何利用 CanvasPorterDuffXfermode 绘制遮罩与高亮;

  • 构建通用的引导管理器(Builder + Step 模式);

  • 在不同形状目标 View 周围计算高亮区域;

  • 结合布局文件和自定义 View 实现提示文字与箭头;

  • 利用 SharedPreferences 实现“只引导一次”控制;

  • 优雅地在任意页面中启动和销毁引导组件。


二、相关知识

在动手实现之前,建议先了解以下技术点,否则后续示例难以理解:

2.1 Canvas 与 PorterDuffXfermode

  • Canvas.drawRect()/drawCircle():在画布上绘制矩形/圆形区域。

  • PorterDuffXfermode:设置绘制模式,Mode.CLEAR 可将指定区域擦除为透明。

  • 图层管理:使用 saveLayer()restore() 创建临时图层,保证清除模式只作用于遮罩层。

2.2 自定义 View

  • onDraw():所有绘制逻辑放这里,避免频繁新建对象。

  • onSizeChanged():获取 View 大小及绘制区域初始化。

  • 属性解析:在 attrs.xml 中声明自定义属性,通过 context.obtainStyledAttributes() 解析。

2.3 形状计算

  • 目标 View 坐标:通过 view.getLocationOnScreen() 获取绝对位置。

  • RectF:高亮区域用 RectF 表示,可根据形状类型扩展内外边距。

  • 圆角:圆角矩形由 canvas.drawRoundRect() 实现。

2.4 布局与提示

  • FrameLayout 覆盖:将引导层放在最顶层 FrameLayout 中,拦截点击事件。

  • 提示布局:在高亮区域周围动态放置 TextViewImageView(箭头),可用 RelativeLayoutConstraintLayout 进行位置控制。

2.5 步骤管理

  • Builder 模式GuideBuilder 用链式调用配置多个步骤、样式,最后 build()GuideMaskView 并展示。

  • Step 列表:内部维护 List<GuideStep>,依次在遮罩点击回调中切换。

  • 生命周期:引导结束后移除遮罩层并可执行回调。

2.6 持久化控制

  • SharedPreferences:用键值对记录某次引导是否完成,下次启动时检查,决定是否展示。

  • 唯一标识:每组引导配置一个 guideId,支持不同页面的独立控制。


三、实现思路

  1. GuideStep:数据模型,包含目标 View、高亮形状、提示布局的资源 ID 以及相对于目标的 Offset。

  2. GuideBuilder:链式接口,用户逐步添加 addStep(target: View, shape: Shape, tipLayoutRes: Int, xOffset: Int, yOffset: Int),并可设置遮罩颜色、透明度、箭头偏移、guideId。

  3. GuideMaskView:继承 FrameLayout,在其 onDraw() 中绘制半透明遮罩,并对当前 GuideStep 的目标区域进行 clear。同时在 onLayout() 加载对应的提示布局,并根据 xOffset,yOffset 调整位置。

  4. 引导流程:用户调用 GuideBuilder.build() 得到 GuideMaskViewshow(),第一次加载第 0 步;用户点击遮罩,内部 stepIndex++,如果还有下一步,则 invalidate() 重绘并刷新提示布局,否则执行 remove() 并标记完成状态。

  5. 只显示一次:在构建时检查 SharedPreferences 中的 guideId 标记,若已完成则直接 build() 返回空实现,不显示。

  6. 集成方式:在 Activity.onCreate()Fragment.onViewCreated() 中调用一次 GuideBuilder,无需在布局中做任何改动。


四、整合代码

以下所有源文件与资源文件均整合到一个代码块中,用注释区分文件名,并附中英文双注释说明关键逻辑。

// -------------------- 文件: GuideStep.kt --------------------
package com.example.guide

import android.view.View

/**
 * GuideStep:每一步引导模型
 * @param targetView 需要高亮的目标 View
 * @param shape      高亮形状(CIRCLE, RECT, ROUND_RECT)
 * @param tipLayoutRes  提示布局资源 ID
 * @param xOffset    提示布局相对于目标区域 X 方向偏移
 * @param yOffset    提示布局相对于目标区域 Y 方向偏移
 */
data class GuideStep(
    val targetView: View,
    val shape: HighlightShape,
    val tipLayoutRes: Int,
    val xOffset: Int,
    val yOffset: Int
)

// ---------------- 文件: HighlightShape.kt ----------------
package com.example.guide

/**
 * 高亮形状枚举
 */
enum class HighlightShape {
    CIRCLE,       // 圆形
    RECT,         // 矩形
    ROUND_RECT    // 圆角矩形
}

// ---------------- 文件: GuidePreferenceUtil.kt ----------------
package com.example.guide

import android.content.Context
import android.preference.PreferenceManager

/**
 * GuidePreferenceUtil:引导完成状态持久化工具
 */
object GuidePreferenceUtil {
    private const val PREFIX = "guide_completed_"
    fun isCompleted(context: Context, guideId: String): Boolean {
        val prefs = PreferenceManager.getDefaultSharedPreferences(context)
        return prefs.getBoolean(PREFIX + guideId, false)
    }
    fun markCompleted(context: Context, guideId: String) {
        val prefs = PreferenceManager.getDefaultSharedPreferences(context)
        prefs.edit().putBoolean(PREFIX + guideId, true).apply()
    }
}

// ---------------- 文件: GuideBuilder.kt ----------------
package com.example.guide

import android.app.Activity

/**
 * GuideBuilder:链式配置引导步骤与样式,最后 build() 显示
 */
class GuideBuilder(private val activity: Activity) {
    private val steps = mutableListOf<GuideStep>()
    private var maskColor: Int = 0x88000000.toInt()  // 半透明黑
    private var guideId: String = "default_guide"

    /** 设置遮罩颜色 */
    fun setMaskColor(color: Int) = apply { maskColor = color }

    /** 设置唯一引导 ID,用于“只显示一次” */
    fun setGuideId(id: String) = apply { guideId = id }

    /** 添加一步引导 */
    fun addStep(
        target: View,
        shape: HighlightShape,
        tipLayoutRes: Int,
        xOffset: Int = 0,
        yOffset: Int = 0
    ) = apply {
        steps.add(GuideStep(target, shape, tipLayoutRes, xOffset, yOffset))
    }

    /** 构建并立即展示引导 */
    fun build() {
        // 若已完成,则不展示
        if (GuidePreferenceUtil.isCompleted(activity, guideId)) return
        val maskView = GuideMaskView(
            activity,
            steps.toList(),
            maskColor,
            onFinish = {
                GuidePreferenceUtil.markCompleted(activity, guideId)
            }
        )
        // 将遮罩层添加到 Activity 根布局
        activity.window.decorView.findViewById(android.R.id.content)
            .also { container ->
                container as android.view.ViewGroup
                container.addView(maskView,
                    android.view.ViewGroup.LayoutParams.MATCH_PARENT,
                    android.view.ViewGroup.LayoutParams.MATCH_PARENT
                )
            }
        maskView.showStep(0)
    }
}

// ---------------- 文件: GuideMaskView.kt ----------------
package com.example.guide

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.*
import android.widget.FrameLayout

/**
 * GuideMaskView:核心引导层,负责绘制蒙版、擦除高亮区域、加载提示布局和步骤切换
 */
class GuideMaskView @JvmOverloads constructor(
    context: Context,
    private val steps: List<GuideStep>,
    private val maskColor: Int,
    private val onFinish: () -> Unit,
    attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var currentStep = 0
    private val layerRect = RectF()
    private var layer: Int = 0

    init {
        setWillNotDraw(false)        // 启用 onDraw
        paint.color = maskColor
        setOnClickListener {         // 点击遮罩切换步骤
            currentStep++
            if (currentStep >= steps.size) {
                removeFromParent()
                onFinish()
            } else {
                removeAllTipViews()
                invalidate()
                showStep(currentStep)
            }
        }
    }

    /** 将自身移除根布局 */
    private fun removeFromParent() {
        (parent as? ViewGroup)?.removeView(this)
    }

    /** 展示第 stepIndex 步 */
    fun showStep(stepIndex: Int) {
        currentStep = stepIndex
        val step = steps[stepIndex]
        // 添加提示布局
        val tipView = LayoutInflater.from(context).inflate(step.tipLayoutRes, this, false)
        // 计算目标位置
        val loc = IntArray(2).also { step.targetView.getLocationOnScreen(it) }
        val targetRect = RectF(
            loc[0].toFloat(),
            loc[1].toFloat() - getStatusBarHeight(),
            loc[0] + step.targetView.width.toFloat(),
            loc[1] - getStatusBarHeight() + step.targetView.height.toFloat()
        )
        // 显示提示布局:使用 FrameLayout.LayoutParams 设置 margin
        val lp = LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
        lp.leftMargin = (targetRect.left + step.xOffset).toInt()
        lp.topMargin  = (targetRect.bottom + step.yOffset).toInt()
        addView(tipView, lp)
        // 保存高亮区域
        layerRect.set(targetRect)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 1. 保存图层
        layer = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
        // 2. 绘制全屏遮罩
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
        // 3. 擦除高亮区域
        paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
        val step = steps[currentStep]
        when (step.shape) {
            HighlightShape.CIRCLE -> {
                val cx = layerRect.centerX()
                val cy = layerRect.centerY()
                val radius = maxOf(layerRect.width(), layerRect.height()) / 2f + 8f
                canvas.drawCircle(cx, cy, radius, paint)
            }
            HighlightShape.RECT -> {
                canvas.drawRect(layerRect, paint)
            }
            HighlightShape.ROUND_RECT -> {
                canvas.drawRoundRect(layerRect, 16f, 16f, paint)
            }
        }
        paint.xfermode = null
        // 4. 恢复图层
        canvas.restoreToCount(layer)
    }

    /** 移除所有提示布局 */
    private fun removeAllTipViews() {
        removeAllViews()
    }

    /** 获取状态栏高度 */
    private fun getStatusBarHeight(): Int {
        val resId = resources.getIdentifier("status_bar_height", "dimen", "android")
        return if (resId > 0) resources.getDimensionPixelSize(resId) else 0
    }
}

// ---------------- 文件: attrs.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 在 GuideBuilder/GuideMaskView 中我们未用 attrs,此处仅示例如何定义 -->
</resources>

// ---------------- 文件: guide_tip_layout_step1.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:background="@drawable/guide_tip_bg"
    android:padding="8dp"
    android:layout_width="wrap_content" android:layout_height="wrap_content">
    <TextView
        android:id="@+id/tvTip1"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="点击这里可以发起聊天" android:textColor="#FFFFFF"/>
    <ImageView
        android:layout_width="24dp" android:layout_height="24dp"
        android:src="@drawable/ic_arrow_down" android:contentDescription=""/>
</LinearLayout>

// ---------------- 文件: guide_tip_layout_step2.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:background="@drawable/guide_tip_bg"
    android:padding="8dp"
    android:layout_width="wrap_content" android:layout_height="wrap_content">
    <TextView
        android:id="@+id/tvTip2"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="这里可以查看设置" android:textColor="#FFFFFF"/>
    <ImageView
        android:layout_width="24dp" android:layout_height="24dp"
        android:src="@drawable/ic_arrow_up" android:contentDescription=""/>
</LinearLayout>

// ---------------- 文件: guide_tip_bg.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="#CC000000"/>
    <corners android:radius="8dp"/>
</shape>

// ---------------- 文件: 在 Activity 中集成 ----------------
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // 获取需要高亮的 View
        val chatBtn = findViewById<View>(R.id.btn_chat)
        val settingIcon = findViewById<View>(R.id.icon_setting)
        // 构建并展示引导
        GuideBuilder(this)
            .setGuideId("main_page_guide")
            .setMaskColor(0xBF000000.toInt())
            .addStep(chatBtn, HighlightShape.CIRCLE, R.layout.guide_tip_layout_step1, 0, 16)
            .addStep(settingIcon, HighlightShape.ROUND_RECT, R.layout.guide_tip_layout_step2, 0, -64)
            .build()
    }
}

五、代码解读

  • GuideStep:封装单步引导所需的信息,包括目标 View、高亮形状、提示布局资源 ID,以及提示相对偏移。

  • HighlightShape:高亮区域形状枚举,支持 CIRCLERECTROUND_RECT

  • GuidePreferenceUtil:利用 SharedPreferences 存取“guideId”完成状态,保证每个引导只显示一次。

  • GuideBuilder:链式 API,用户在 ActivityFragment 中配置 guideId、遮罩色和多个步骤;build() 时检查是否已完成,若未完成则创建 GuideMaskView 并添加到根布局,展示第一步。

  • GuideMaskView:核心自定义 FrameLayout,在 onDraw()

    1. saveLayer 建立临时图层,

    2. drawRect 绘制全屏半透明遮罩,

    3. setXfermode(Mode.CLEAR) 在目标区域擦除为透明(即高亮),形状根据 GuideStep.shape 决定,

    4. restoreToCount 恢复画布。
      同时在 showStep() 中动态添加提示布局,并根据目标 View 的屏幕位置与 xOffset/yOffset 设置其 LayoutParams
      点击遮罩触发 OnClickListener,切换到下一步,或当步骤结束时移除自身并执行 onFinish() 标记完成。

  • 布局文件guide_tip_layout_step1.xmlguide_tip_layout_step2.xml 分别为两步提示布局,包含 TextView 与箭头 ImageView,背景为圆角半透明黑(guide_tip_bg.xml)。

  • Activity 集成:在 MainActivity.onCreate() 中获取目标控件引用,使用 GuideBuilder 配置并一行代码启动引导,无需修改现有布局结构。


六、项目总结

  1. 成果回顾

    • 完全自研的通用蒙版引导库,支持多步、多形状、高度可配置;

    • 使用 Canvas+PorterDuff 高效实现高亮区域擦除,无需额外 View 叠加;

    • Builder 模式简化调用流程,集成极其方便;

    • 利用 SharedPreferences 实现“只展示一次”的业务逻辑。

  2. 技术收获

    • 深入理解 PorterDuffXfermode 与 Android 图层绘制机制;

    • 学会动态获取目标 View 屏幕坐标并与自定义 View 配合布局;

    • 掌握自定义 FrameLayout 结合触摸事件管理多步引导;

    • 熟练运用 Builder 及数据模型驱动 UI 组件。

  3. 后续优化

    • 动画过渡:在切换步骤时为高亮区域及提示布局添加渐隐渐显动画;

    • 手势支持:支持手指拖动、滑动切换步骤,以及“返回上一步”手势;

    • 更多形状:支持椭圆、心形等自定义路径高亮;

    • 性能优化:对大型页面引导时优化 onDraw(),避免阻塞主线程;

    • 国际化与无障碍:提示文字支持多语言,添加 ContentDescription,兼容 TalkBack。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值