最近有个需求需要在小程序中实现一个新手引导组件,通过遮罩、高亮区域和提示框的组合,为应用提供流畅的用户引导体验。
组件功能概述
这个引导组件提供了以下核心功能:
- 分步引导:支持多步骤引导流程
- 智能定位:自动计算高亮区域位置
- 遮罩效果:突出显示目标元素
- 方向感知:根据位置调整提示框方向
- 进度控制:下一步/跳过/完成操作
- 状态保存:使用 localStorage 记录完成状态(已取消,可扩展)
核心实现代码分析
组件模板结构
<template> <view v-if="visible" class="guide-mask"> <!-- 遮罩四块 --> <view class="mask-piece top" :style="maskStyles.top"></view> <view class="mask-piece bottom" :style="maskStyles.bottom"></view> <view class="mask-piece left" :style="maskStyles.left"></view> <view class="mask-piece right" :style="maskStyles.right"></view> <!-- 高亮区域 --> <view v-if="currentStep" class="highlight" :style="highlightStyleStr" ></view> <!-- 提示框 --> <view class="tooltip" :style="tooltipStyleStr"> <text class="tip-text">{{ currentStep.tip }}</text> <view class="tip-arrow" :class="currentStep.tipPosition || 'left'"></view> <view class="btns"> <button @tap="nextStep">{{ isLast ? "完成" : "下一步" }}</button> <button class="skip" @tap="skip">跳过</button> </view> </view> <!-- 引导机器人图标:这个也可以是别的图标,这边用的是机器人图标 --> <image class="robot-img" src="更换为自己的图标" :style="tooltipStyleImg" mode="widthFix" /> </view> </template>
组件逻辑实现
export default { props: { steps: { type: Array, required: true }, // 引导步骤配置 guideKey: { type: String, default: "default_guide_key" }, // 引导标识键,用于确认是否做过引导,可以扩展 }, data() { return { stepIndex: 0, // 当前步骤索引 visible: false, // 是否显示引导 }; }, computed: { // 当前步骤配置 currentStep() { return this.steps[this.stepIndex]; }, // 是否为最后一步 isLast() { return this.stepIndex === this.steps.length - 1; }, // 高亮区域样式 highlightStyleStr() { // 计算样式逻辑... }, // 机器人图标位置 tooltipStyleImg() { // 根据提示位置计算坐标... }, // 提示框样式 tooltipStyleStr() { // 根据位置计算提示框方向... }, // 遮罩层样式计算 maskStyles() { // 计算四块遮罩的位置和尺寸... }, }, methods: { // 开始引导 start(force = false) { if (!force) return; this.stepIndex = 0; this.visible = true; }, // 下一步 nextStep() { this.isLast ? this.finish() : this.stepIndex++; }, // 跳过引导 skip() { this.finish(); }, // 完成引导 finish() { this.visible = false; this.$emit("finish"); localStorage.setItem(this.guideKey, "completed"); }, }, };
样式实现
.guide-mask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1000001; } .highlight { position: absolute; border: 2px solid #fff; border-radius: 8px; box-shadow: 0 0 10px #fff; } .tooltip { position: absolute; background: white; padding: 10px 16px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); min-width: 250rpx; } /* 箭头方向样式 */ .tip-arrow.bottom { bottom: -8px; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 8px solid white; } /* 其他方向样式... */ .robot-img { position: absolute; width: 150rpx; z-index: 10003; }
关键实现技术
1. 智能遮罩计算
组件将遮罩分为四个部分(上、下、左、右),通过计算目标元素的位置动态设置每块遮罩的尺寸:
maskStyles() { const { top, left, width, height } = this.currentStep const windowWidth = uni.getSystemInfoSync().windowWidth const windowHeight = uni.getSystemInfoSync().windowHeight return { top: `... height: ${top}px; ...`, bottom: `... top: ${top + height}px; height: ${windowHeight - (top + height)}px; ...`, left: `... top: ${top}px; width: ${left}px; height: ${height}px; ...`, right: `... left: ${left + width}px; width: ${windowWidth - (left + width)}px; ...` } }
2. 动态提示框定位
根据目标元素位置自动调整提示框方向:
tooltipStyleStr() { const top = this.currentStep.top + this.currentStep.height + 10 const left = this.currentStep.left const right = this.currentStep.right const tipPosition = this.currentStep.tipPosition || 'left' const { windowWidth } = uni.getSystemInfoSync(); return tipPosition === 'left' ? `right:${windowWidth - right}px;` : `left:${left}px;` }
3. 引导机器人位置计算
根据提示方向计算机器人图标位置:
tooltipStyleImg() { const { top, left, width, height, tipPosition = 'left' } = this.currentStep let x = 0, y = 0 switch (tipPosition) { case 'left': x = left - width y = top + height break case 'right': x = left + width / 5 * 3 y = top + height break // 其他情况... } return `top:${y}px;left:${x}px;` }
完整代码
<template> <view v-if="visible" class="guide-mask"> <!-- 遮罩四块 --> <view class="mask-piece top" :style="maskStyles.top"></view> <view class="mask-piece bottom" :style="maskStyles.bottom"></view> <view class="mask-piece left" :style="maskStyles.left"></view> <view class="mask-piece right" :style="maskStyles.right"></view> <view v-if="currentStep" class="highlight" :style="highlightStyleStr" ></view> <view class="tooltip" :style="tooltipStyleStr"> <text class="tip-text">{{ currentStep.tip }}</text> <view class="tip-arrow" :class="currentStep.tipPosition || 'left'"></view> <view class="btns"> <button @tap="nextStep">{{ isLast ? "完成" : "下一步" }}</button> <button class="skip" @tap="skip">跳过</button> </view> </view> <image class="robot-img" src="@/images/static/robot.png" :style="tooltipStyleImg" mode="widthFix" /> </view> </template> <script> export default { props: { steps: { type: Array, required: true, }, guideKey: { type: String, default: "default_guide_key", }, }, data() { return { stepIndex: 0, visible: false, }; }, computed: { currentStep() { return this.steps[this.stepIndex]; }, isLast() { return this.stepIndex === this.steps.length - 1; }, highlightStyleStr() { if (!this.currentStep) return ""; const { top, left, width, height } = this.currentStep; return `position:absolute;top:${top}px;left:${left}px;width:${width}px;height:${height}px;border:2px solid #fff;box-shadow:0 0 10px #fff;border-radius:8px;z-index:10000;`; }, tooltipStyleImg() { if (!this.currentStep) return ""; const { top, left, width, height, tipPosition = "left", } = this.currentStep; let x = 0, y = 0; switch (tipPosition) { case "left": x = left - width; y = top + height; break; case "right": x = left + (width / 5) * 3; y = top + height; break; case "top": x = left + width / 2; y = top - 100; // 高度预估 break; case "bottom": default: x = left + width / 2; y = top + height; break; } return `top:${y}px;left:${x}px;`; }, tooltipStyleStr() { if (!this.currentStep) return ""; const top = this.currentStep.top + this.currentStep.height + 10; const left = this.currentStep.left; const right = this.currentStep.right; const tipPosition = this.currentStep.tipPosition || "left"; const { windowWidth } = uni.getSystemInfoSync(); return ( `position:absolute;top:${top}px;z-index:10001;` + (tipPosition === "left" ? `right:${windowWidth - right}px;` : `left:${left}px;`) ); }, maskStyles() { if (!this.currentStep) return {}; const { top, left, width, height } = this.currentStep; const windowWidth = uni.getSystemInfoSync().windowWidth; const windowHeight = uni.getSystemInfoSync().windowHeight; return { top: `position: absolute; top: 0px; left: 0px; width: ${windowWidth}px; height: ${top}px; background: rgba(0, 0, 0, 0.6);`, bottom: `position: absolute; top: ${ top + height }px; left: 0px; width: ${windowWidth}px; height: ${ windowHeight - (top + height) }px; background: rgba(0, 0, 0, 0.6);`, left: `position: absolute; top: ${top}px; left: 0px; width: ${left}px; height: ${height}px; background: rgba(0, 0, 0, 0.6);`, right: `position: absolute; top: ${top}px; left: ${ left + width }px; width: ${ windowWidth - (left + width) }px; height: ${height}px; background: rgba(0, 0, 0, 0.6);`, }; }, }, methods: { start(force = false) { if (!force) return; this.stepIndex = 0; this.visible = true; }, nextStep() { if (this.isLast) { this.finish(); } else { this.stepIndex++; } }, skip() { this.finish(); }, finish() { this.visible = false; this.$emit("finish"); }, }, }; </script> <style scoped> .guide-mask { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1000001; } .mask-piece { position: absolute; background: rgba(0, 0, 0, 0.6); } .mask-layer { background: rgba(0, 0, 0, 0.6); width: 100%; height: 100%; position: absolute; } .highlight { position: absolute; border: 2px solid #fff; border-radius: 8px; box-shadow: 0 0 10px #fff; } .tooltip { /* box-shadow: 0 0 8px #0004; */ position: absolute; background: white; padding: 10px 16px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); font-size: 14px; color: #007aff; z-index: 10002; min-width: 250rpx; } .tip-arrow { position: absolute; width: 0; height: 0; } .tip-arrow.bottom { bottom: -8px; left: 20px; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 8px solid white; } .tip-arrow.top { top: -8px; left: 20px; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid white; } .tip-arrow.right { top: 12px; right: -8px; border-top: 8px solid transparent; border-bottom: 8px solid transparent; border-left: 8px solid white; } .tip-arrow.left { top: 12px; left: -8px; border-top: 8px solid transparent; border-bottom: 8px solid transparent; border-right: 8px solid white; } .robot-img { position: absolute; width: 150rpx; z-index: 10003; } .tip-text { font-size: 14px; color: #333; } .btns { display: flex; gap: 10px; } button { font-size: 12px; padding: 4px 8px; } .skip { color: #888; } </style>
使用示例
const guideSteps = [ { tip: "这是 AI 聊天功能,点击进行聊天", top: 100, left: 50, width: 200, height: 40, tipPosition: "bottom" }, { tip: "这是个人中心入口", top: 500, left: 300, width: 80, height: 80, tipPosition: "left" } ] // 在组件中使用 <GuideMask :steps="guideSteps" guideKey="home_guide" @finish="onGuideFinish"/>
组件不足
- tip&icon 定位:这里的组件定位主要是做了左右适配定位,如果需要兼容可以进行扩展或者优化
- 高亮区域:组件高亮区域当前只是对于定位区域宽高进行高亮,可以做往外扩展,例如椭圆形的
- 跨页面:目前只能对同个单一的页面进行引导式访问,无法做到跨页面跳转的引导式访问
- 多端适配:暂无进行多端的适配测试,目前看来应该兼容的,实用还是得做下测试进行优化
- 遮罩层:这里遮罩层做的是根据定位区域来实现覆盖的,没有进行穿透效果,兼容可以好点,但也可以进行其他方面的优化例如各种形状或者区域高亮扩展,这时候就需要更复杂的计算,扩展性维护性就差点
优化方向
不足的地方都可以进行优化,下面就只是扩展方向:
- 动画效果:为高亮区域和提示框添加过渡动画
- 自动定位:通过选择器自动获取元素位置(使用 createSelectorQuery 和 boundingClientRect)
- 主题定制:支持自定义颜色和样式
- 手势支持:添加滑动手势切换步骤
- 语音引导:结合语音 API 提供语音提示
- 引导记忆:组件有个标识专门针对已经做过引导访问的页面进行标识,如果遇到可以不再引导,也可以强制引导
总结
这个只是做了简单的示例,有需要可以进行优化改善,没有太大要求的话可以直接复制粘贴使用。效果图片想想还是贴下吧:
