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>
相关推荐
程序员海军12 分钟前
2024 Nuxt3 年度生态总结
前端·nuxt.js
m0_7482567822 分钟前
SpringBoot 依赖之Spring Web
前端·spring boot·spring
web135085886351 小时前
前端node.js
前端·node.js·vim
m0_512744641 小时前
极客大挑战2024-web-wp(详细)
android·前端
若川1 小时前
Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?
前端·javascript·react.js
潜意识起点1 小时前
精通 CSS 阴影效果:从基础到高级应用
前端·css
奋斗吧程序媛1 小时前
删除VSCode上 origin/分支名,但GitLab上实际上不存在的分支
前端·vscode
IT女孩儿2 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748256564 小时前
如何解决前端发送数据到后端为空的问题
前端
请叫我飞哥@4 小时前
HTML5适配手机
前端·html·html5