H5/小程序通用新手引导组件实现指南

背景

在做微信小程序 (同时跨端输出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负责绘制操作区域,也就是描述文案和按钮等

逻辑层设计

观察效果图,得出新手引导运行流程:

  1. 初始化展示第一步
  2. 点击按钮,进入下一步的展示,同时页面向上滚动到合适位置
  3. 重复第二步

这个过程很类似咱们的迭代器(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实现)到合适位置,这样就能够展示任意位置的内容。

核心逻辑很简单:

  1. 监听用户点击事件
  2. 执行事件回调
  3. 计算当前步骤的样式
  4. 判断是否完成,如果完成则将屏幕滚动到开始位置
  5. 销毁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>
相关推荐
nppe62 分钟前
sequlize操作mysql小记
前端·后端
Moment11 分钟前
面试官:一个接口使用postman这些测试很快,但是页面加载很慢怎么回事 😤😤😤
前端·后端·面试
诗书画唱15 分钟前
【前端面试题】JavaScript 核心知识点解析(第二十二题到第六十一题)
开发语言·前端·javascript
excel21 分钟前
前端必备:从能力检测到 UA-CH,浏览器客户端检测的完整指南
前端
前端小巷子28 分钟前
Vue 3全面提速剖析
前端·vue.js·面试
悟空聊架构35 分钟前
我的网站被攻击了,被干掉了 120G 流量,还在持续攻击中...
java·前端·架构
CodeSheep36 分钟前
国内 IT 公司时薪排行榜。
前端·后端·程序员
尖椒土豆sss40 分钟前
踩坑vue项目中使用 iframe 嵌套子系统无法登录,不报错问题!
前端·vue.js
遗悲风41 分钟前
html二次作业
前端·html
江城开朗的豌豆44 分钟前
React输入框优化:如何精准获取用户输入完成后的最终值?
前端·javascript·全栈