微信小程序Taro框架自定义导航栏

2,307 阅读3分钟

设计思路

微信小程序自带导航栏,导航栏有胶囊,且胶囊位置和大小不可改变。

  1. 自定义导航栏的时候,取消默认导航栏。
  2. 用一个容器替代取消的默认导航栏。
  3. 通过小程序胶囊(...和退出)的位置和大小计算出自定义导航栏的宽度高度和位置。
  4. 抽取出自定义导航栏初始化和关键信息的hook
  5. 自定义容器预留出胶囊大小,根据胶囊大小和位置信息,写出自定义容器的CSS,设计自定义导航栏API。

1. 取消默认导航栏

每个页面都会在index.config.ts中配置页面信息,如果需要使用自定义导航栏,则配置导航栏为custom

export default definePageConfig({
  navigationStyle: 'custom',
})

2. 用容器替代取消的默认导航栏

容器指的是整个自定义导航栏,在这里我们把胶囊也算在自定义导航栏里面,因此容器的宽度是视口宽度。

设计上我们就像modal组件一样,分为containerpaper,以及胶囊,称为menuButton。 大致如下:

image.png

3. 计算导航栏位置

通过API获取到系统信息和胶囊的位置大小:

// 获取系统信息
const systemInfo = getSystemInfoSync()
// 获取胶囊信息
const menuButtonInfo = getMenuButtonBoundingClientRect()

而手机还有状态栏,在自定义导航栏的情况下,小程序会顶到手机最上面。因此整个自定义导航栏的高度是需要加上状态栏高度的:

// 状态栏高度 获取不到的情况给通用的44  图中的1
const statusBarHeight = systemInfo.statusBarHeight ?? 44

// 状态栏到胶囊的间距 图中的2
const menuButtonStatusBarGap = menuButtonInfo.top - statusBarHeight
    
// 导航栏高度 = 状态栏到胶囊的间距(胶囊距上距离-状态栏高度) * 2 + 胶囊高度 + 状态栏高度   1+ 2 + 2 + 3
const navBarHeight = menuButtonStatusBarGap * 2 + menuButtonInfo.height + statusBarHeight
// 左右两个的padding
const paddingX = systemInfo.screenWidth - menuButtonInfo.right

image.png

4. 抽离位置信息的公共逻辑

import { getSystemInfoSync, getMenuButtonBoundingClientRect } from '@tarojs/taro'

interface NavigateBarInfoProps {
  navBar: {
    height: number
    top: number
    py: number
    px: number
  }
  menuInfo: ReturnType<typeof getMenuButtonBoundingClientRect> | null
}

let navigateBarInfo: NavigateBarInfoProps = {
  navBar: { top: 0, height: 0, py: 0, px: 0 },
  menuInfo: null
}

export const useNavigationBar = () => {
  const initNavigationBar = () => {
    const systemInfo = getSystemInfoSync()
    const menuButtonInfo = getMenuButtonBoundingClientRect()
    // 导航栏高度 = 状态栏到胶囊的间距(胶囊距上距离-状态栏高度) * 2 + 胶囊高度 + 状态栏高度
    const statusBarHeight = systemInfo.statusBarHeight ?? 44
    // 状态栏到胶囊的间距
    const menuButtonStatusBarGap = menuButtonInfo.top - statusBarHeight
    const navBarHeight = menuButtonStatusBarGap * 2 + menuButtonInfo.height + statusBarHeight
    const navBarTop = statusBarHeight
    const paddingX = systemInfo.screenWidth - menuButtonInfo.right
    navigateBarInfo = {
      navBar: {
        height: navBarHeight,
        top: navBarTop,
        py: menuButtonStatusBarGap,
        px: paddingX
      },
      menuInfo: menuButtonInfo
    }
  }

  return {
    navigateBarInfo,
    initNavigationBar
  }
}

在小程序启动的时候,去初始化:

useLaunch(async options => {
  initNavigationBar()
})

5. 导航栏组件实现

import { View, Text, Button } from '@tarojs/components'
import { CSSProperties, FC, PropsWithChildren, useMemo, useState } from 'react'
import C from 'classnames'
import {
  usePageScroll,
  PageScrollObject,
  navigateBack,
  switchTab,
  getCurrentPages
} from '@tarojs/taro'
import { getNavigatePath } from '@/utils/route'
import { throttle } from 'lodash'
import { useNavigationBar } from '@/hooks/useNavigationBar'

interface NavigationBarProps {
  containerClassName?: string // 导航栏样式
  containerStyle?: CSSProperties // 导航栏样式
  className?: string // 导航栏menu内容样式
  style?: CSSProperties // 导航栏menu内容样式
  gradient?: boolean // 是否开启渐变 默认true
  back?: boolean // 是否有返回按钮 默认true
  beforeBack?: () => boolean | void // 返回前的钩子 返回false则阻断 返回true或者不返回值则继续
  title?: string
}

const NavigationBar: FC<PropsWithChildren<NavigationBarProps>> = props => {
  const {
    children,
    containerClassName,
    containerStyle,
    className,
    style,
    gradient = true,
    back = true,
    beforeBack,
    title = ''
  } = props
  const { navigateBarInfo } = useNavigationBar()
  const { navBar, menuInfo } = navigateBarInfo
  const [opacity, setOpacity] = useState(0)

  // 滑动的时候触发计算渐变度
  const scrollSetOpacity = useMemo(
    () =>
      throttle((e: PageScrollObject) => {
        let opacity = Math.min(1, Math.max(0, (e.scrollTop * 2) / navBar.height))
        setOpacity(opacity)
      }, 30),
    [navBar.height]
  )

  usePageScroll(scrollSetOpacity)

  const navigationBarContainerStyle = useMemo(() => {
    return Object.assign(
      {
        height: `${navBar.height}px`
      },
      gradient ? { backgroundColor: `rgba(255,255,255,${opacity})` } : { backgroundColor: '#fff' },
      containerStyle
    )
  }, [opacity, navBar.height, gradient, containerStyle])

  const onBack = () => {
    if (beforeBack) {
      const result = beforeBack()
      if (result === false) return
    }
    const pages = getCurrentPages()
    if (pages.length >= 2) {
      navigateBack()
    } else {
      // TODO 跳转到主页
    }
  }
  return (
    <View
      className={C('fixed w-screen top-0 left-0 z-[2000]', containerClassName)}
      style={navigationBarContainerStyle}
    >
      <View
        className={C('absolute left-0 bottom-0 w-full box-border flex items-center', className)}
        style={{
          top: `${navBar.top}px`,
          padding: `${navBar.py}px ${navBar.px}px`,
          ...style
        }}
      >
        <View
          onClick={onBack}
          style={{
            flexBasis: `${menuInfo?.width}px`
          }}
        >
          {back && <IconFont name="Back" size={20} />}
        </View>
        <View className="flex-1">
          {!children && title && (
            <View className="text-center">
              <Text className="text-primary text-base font-medium">{title}</Text>
            </View>
          )}
          {children}
        </View>
        <View
          style={{
            flexBasis: `${menuInfo?.width}px`
          }}
        ></View>
      </View>
    </View>
  )
}

export default NavigationBar