背景
在做微信小程序 (同时跨端输出H5 )的时候,涉及到新手引导功能的实现,即在用户第一次访问页面的时候,告诉用户页面有哪些主要的功能,用户点击屏幕可以进入下一步,同时会在蒙层中添加一些描述文案,如下图所示:
以上图片截取自 2024/03/01 滴滴司机招募,活动内容及奖励金额可能有变更,以官方为准
实现效果:
以上gif录制于 2024/03/01 滴滴司机招募,活动内容及奖励金额可能有变更,以官方为准
市面上有很多专业的新手引导第三方库,如:
这些库都能很方便的实现上述功能,遗憾的是,小程序是没有dom
的,所以以上开源方案我们都没有办法使用。
技术方案
既然没办法通过开源方案做,那我们就需要实现一套可以跨平台的移动端新手引导方案。
设计模式
跨端意味着我们的设计是抽象 的,抽象意味着我们需要制定一套接口规范(DIP ),不涉及具体平台的逻辑实现。因此我们需要抹平小程序和H5 的差异,找出二者的共同点------"基于JS实现逻辑层"。
视觉层设计
参照intro.js,采用状态机的形式完成新手引导。组件视图层结构设计如下:
- div.intro (z-index: 99999)
- div.intro-overlay (z-index: 1)
- div.intro-helperLayer (z-index: 2)
- div.intro-tooltipReferenceLayer (z-index: 3)
- div.content
其中:
intro-overlay
负责给屏幕添加一层遮罩,使屏幕下的元素不可操作intro-helperLayer
负责展示Step
设定的高亮区域intro-tooltipReferenceLayer
负责绘制操作区域,也就是描述文案和按钮等
逻辑层设计
观察效果图,得出新手引导运行流程:
- 初始化展示第一步
- 点击按钮,进入下一步的展示,同时页面向上滚动到合适位置
- 重复第二步
这个过程很类似咱们的迭代器(Iterator),定义如下:
typescript
interface Iterable {
[Symbol.iterator]() : Iterator,
}
interface Iterator {
next(value?: any) : IterationResult,
}
interface IterationResult {
value: any,
done: boolean,
}
因此,咱们的新手引导也采用同样的设计思路,具体的定义如下:
typescript
import { DefineComponent } from 'vue';
// 定义单个步骤数据结构
interface Step {
desc?: string // 步骤描述
icon?: string // 左侧图标
scrollTop: number // 屏幕需要向上折叠滚动的距离
style: { // 描述高亮区域
width: string
height: string
left: string
top: string
}
}
// 定义 props 类型
interface Props {
steps: Step[] // 一共有几步
}
// 定义 emits 类型
interface Emits {
next: (done: boolean, currentStep: Step) => void; // 每一次点击都会执行
}
// 定义 data 类型
interface Data {
done: boolean // 标志引导是否完成
currentStep: Step // 当前正在展示的步骤
currentStepIndex: number // 当前正在展示的步骤下标
style: CSSStyleDeclaration //
}
// 定义 methods 类型
interface Methods {
next: () => void
}
// 使用 DefineComponent 定义组件类型
type Intro = DefineComponent<Props, Emits, Data & Methods>
由于咱们的屏幕显示内容有限,同时可能会展示到第二屏 、第三屏 的内容,因此设计了一个scrollTop
属性,在执行到下一步的时候,滚动屏幕(H5 通过window.scrollTo
实现)到合适位置,这样就能够展示任意位置的内容。
核心逻辑很简单:
- 监听用户点击事件
- 执行事件回调
- 计算当前步骤的样式
- 判断是否完成,如果完成则将屏幕滚动到开始位置
- 销毁dom,结束新手引导
使用方法
其中getResponsiveValue
是计算需要响应式展示的尺寸,比如设计稿为750
,屏幕实际宽度375
,那么以下数据都需要除以2
并向下取整。
typescript
const done = ref(true)
const steps = [
{
desc: '试试左右滑动卡片,可以查看更多奖励',
scrollTop: 0,
icon: 'https://gift-pypu-cdn.didistatic.com/static/driver_miniprogram/do1_hANKc5dtbGzSrNVUBwaM',
style: {
width: `${getResponsiveValue(690)}px`,
height: `${getResponsiveValue(440)}px`,
left: `${getResponsiveValue(30)}px`,
top: `${getResponsiveValue(115)}px`
}
}
]
const handleNext = (params) => {
// do something...
}
具体代码
小程序
小程序部分基于MPX框架实现(同时支持跨端输出Web):
typescript
<template>
<view
wx:if="{{!done && steps.length}}"
class="cube-intro"
bindtap="next"
catchtouchmove="catchtouchmove"
>
<view class="cube-intro-overlay" />
<view class="cube-intro-helperLayer" wx:style="{{ currentStep.style }}" />
<view
class="cube-intro-tooltipReferenceLayer"
wx:style="{{ style }}"
>
<view class="desc-box" wx:class="{{ { 'has-icon': currentStep.icon } }}">
<view class="image" wx:if="{{currentStep.icon}}" wx:style="background-image: url({{currentStep.icon}})" />
<text>{{currentStep.desc}}</text>
</view>
<button class="next-btn">知道了</button>
</view>
</view>
</template>
<script lang="ts">
import mpx, { createComponent, computed, ref } from '@mpxjs/core'
interface Step {
desc: string
scrollTop: number
icon?: string // 图片链接
style: {
width: string
height: string
left: string
top: string
}
}
createComponent({
properties: {
steps: {
type: Array,
value: [] as Step[]
}
},
setup(props, { triggerEvent }) {
const done = ref(false)
const currentStepIndex = ref(0)
const currentStep = computed(() => props.steps[currentStepIndex.value] ?? {})
const next = () => {
let currentStep
if (!done.value) {
currentStepIndex.value++
}
done.value = currentStepIndex.value === props.steps.length
if (!done.value) {
currentStep = props.steps[currentStepIndex.value]
mpx.pageScrollTo({
scrollTop: parseInt(currentStep.scrollTop),
duration: 300
})
} else {
mpx.pageScrollTo({
scrollTop: 0,
duration: 300
})
if (__mpx_mode__ !== 'wx') {
document.body.style.overflow = 'unset'
}
}
triggerEvent('next', { done: done.value, currentStep })
}
const style = computed(() => {
const step = currentStep.value as Step
return { ...step.style, transform: 'translateY(' + step.style.height + ')' }
})
const catchtouchmove = () => {}
if (__mpx_mode__ !== 'wx') {
document.body.style.overflow = 'hidden'
}
return {
next,
done,
style,
currentStep,
catchtouchmove
}
}
})
</script>
<style lang="stylus">
.cube-intro
position: absolute
top: 0
left: 0
z-index: 99999
width: 100%
height: 100%
.cube-intro-overlay
position: absolute
top: 0
left: 0
z-index: 1
width: 100%
height: 100%
opacity: 0
.cube-intro-helperLayer
position: fixed
z-index: 2
border-radius: 25rpx
box-shadow: rgba(33, 33, 33, .8) 0rpx 0rpx 1rpx 1rpx, rgba(0, 0, 0, .6) 0rpx 0rpx 0rpx 5000rpx
.cube-intro-tooltipReferenceLayer
position: fixed
z-index: 3
display: flex
flex-direction: column
align-items: center
justify-content: center
height: unset !important
margin-top: 100rpx
&::before
position: absolute
top: 0
left: 50%
width: 2rpx
height: 100rpx
background: #fff
transform: translate(calc(-50% - 2rpx), -100%)
content: ''
.desc-box
position: relative
width: 456rpx
padding: 11rpx 0 11rpx 0
padding-left: 23rpx
color: #fff
font-weight: 400
font-size: 34rpx
line-height: 46rpx
border: 2rpx solid rgba(255, 255, 255, 1)
border-radius: 16rpx
box-shadow: 0rpx 0rpx 9rpx 0rpx rgba(200, 230, 255, .55)
transform: translateX(-3rpx)
text
display: inline-block
width: 100%
&.has-icon
display: flex
align-items: center
border: none
box-shadow: none
&::before
display: none
.image
flex-shrink: 0
width: 82rpx
height: 82rpx
margin-right: 16rpx
background-repeat: no-repeat
background-size: 100% 100%
&::before
position: absolute
top: -5rpx
left: 126rpx
width: 9rpx
height: 9rpx
background: #fff
border-radius: 50%
box-shadow: 0rpx 0rpx 14rpx 7rpx rgba(255, 255, 255, .5)
content: ''
.next-btn
width: auto
height: 60rpx
margin-top: 30rpx
padding: 7rpx 44rpx
color: #fff
font-weight: 600
font-size: 32rpx
line-height: 45rpx
background: rgba(255, 255, 255, 0)
border: 1rpx solid rgba(255, 255, 255, 1)
border-radius: 33rpx
</style>
<script type="application/json">
{
"component": true,
"styleIsolation": "apply-shared"
}
</script>
H5代码
H5部分基于Vue 2.7.14
实现:
typescript
<template>
<div
v-if="!done && steps.length"
class="cube-intro"
@click="next"
>
<div class="cube-intro-overlay" />
<div class="cube-intro-helperLayer" :style="currentStep.style" />
<div
class="cube-intro-tooltipReferenceLayer"
:style="style"
>
<div class="desc-box" :class="{ 'has-icon': currentStep.icon }">
<div class="image" v-if="currentStep.icon" :style="{ 'background-image': `url(${currentStep.icon})` }" />
<span>{{currentStep.desc}}</span>
</div>
<button class="next-btn">知道了</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed } from 'vue'
interface Step {
desc: string
scrollTop: number
icon?: string // 图片链接
style: {
width: string
height: string
left: string
top: string
}
}
export default defineComponent({
props: {
steps: {
type: Array as PropType<Step[]>,
default: () => ([])
}
},
setup(props, { emit }) {
const done = ref(false)
const currentStepIndex = ref(0)
const currentStep = computed(() => props.steps[currentStepIndex.value] ?? {})
const next = () => {
let currentStep
if (!done.value) {
currentStepIndex.value++
}
done.value = currentStepIndex.value === props.steps.length
if (!done.value) {
currentStep = props.steps[currentStepIndex.value]
scrollTo({
top: parseInt(currentStep.scrollTop),
behavior: 'smooth'
})
} else {
scrollTo({
top: 0,
behavior: 'smooth'
})
document.body.style.overflow = 'unset'
}
emit('next', { done: done.value, currentStep })
}
const style = computed(() => {
const step = currentStep.value as Step
return { ...step.style, transform: 'translateY(' + step.style.height + ')' }
})
document.body.style.overflow = 'hidden'
return {
next,
done,
style,
currentStep
}
}
})
</script>
<style lang="stylus">
.cube-intro
position: absolute
top: 0
left: 0
z-index: 99999
width: 100%
height: 100%
.cube-intro-overlay
position: absolute
top: 0
left: 0
z-index: 1
width: 100%
height: 100%
opacity: 0
.cube-intro-helperLayer
position: fixed
z-index: 2
border-radius: 25px
box-shadow: rgba(33, 33, 33, .8) 0px 0px 1px 1px, rgba(0, 0, 0, .8) 0px 0px 0px 5000px
.cube-intro-tooltipReferenceLayer
position: fixed
z-index: 3
display: flex
flex-direction: column
align-items: center
justify-content: center
height: unset !important
margin-top: 40px
.desc-box
position: relative
width: 456px
padding: 11px 0 11px 0
padding-left: 23px
color: #fff
font-weight: 600
font-size: 32px
line-height: 45px
white-space: pre-wrap
text-align: center
text
display: inline-block
width: 100%
&.has-icon
display: flex
align-items: center
border: none
box-shadow: none
&::before
display: none
.image
flex-shrink: 0
width: 82px
height: 82px
margin-right: 16px
background-repeat: no-repeat
background-size: 100% 100%
.next-btn
width: auto
height: auto
margin-top: 40px
padding: 16px 50px
color: #fff
font-weight: 600
font-size: 32px
background: rgba(255, 255, 255, 0)
border: 1px solid rgba(255, 255, 255, 1)
border-radius: 40px
</style>