AppTabbar.vue 7.25 KB
<template>
  <view class="app-tabbar">
    <view class="app-tabbar__placeholder" />

    <view class="app-tabbar__wrap">
      <scroll-view
        class="app-tabbar__panel"
        :scrollX="true"
        :enhanced="true"
        :showScrollbar="false"
        :mpScrollLeft="scrollLeft"
        :animated="true"
        @scroll="handlePanelScroll"
      >
        <view class="app-tabbar__content" :class="{ 'is-scrollable': isScrollable }" :style="contentStyle">
          <view
            v-for="item in tabItems"
            :key="item.key"
            class="app-tabbar__item"
            :class="{ 'is-active': isActive(item.key), 'is-scrollable': isScrollable }"
            :style="itemStyle"
            @tap="handleTabClick(item)"
          >
            <view class="app-tabbar__item-inner" :style="getItemInnerStyle(item)">
              <view class="app-tabbar__icon">
                <i
                  class="app-tabbar__icon-font"
                  :class="getIconClass(item)"
                ></i>
              </view>
              <text class="app-tabbar__label">{{ item.title }}</text>
            </view>
          </view>
        </view>
      </scroll-view>

      <view v-if="showScrollHint" class="app-tabbar__fade">
        <view v-if="showScrollArrow" class="app-tabbar__hint-arrow" />
      </view>
    </view>
  </view>
</template>

<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Taro from '@tarojs/taro'
import { useTabbarStore } from '@/stores/tabbar'
import { buildWebviewPreviewUrl } from '@/utils/webview'
import { normalizeTabbarKey } from '@/utils/tabbar'

const props = defineProps({
  current: {
    type: String,
    required: true,
  },
})

const defaultIcon = 'fa-circle-o'
const scrollHintStorageKey = 'app_tabbar_scroll_hint_seen_v1'
const scrollHintOffset = 56
const scrollableItemWidth = 168
const scrollableSidePadding = 60

const tabbarStore = useTabbarStore()
const tabItems = computed(() => tabbarStore.visibleTabItems)
const activeColor = computed(() => tabbarStore.activeColor)
const currentKey = computed(() => normalizeTabbarKey(props.current))
const isScrollable = computed(() => tabItems.value.length > 4)
const showScrollHint = computed(() => isScrollable.value)
const showScrollArrow = computed(() => showScrollHint.value && !hasUserScrolled.value)
const contentStyle = computed(() => {
  if (!isScrollable.value) {
    return null
  }

  return {
    width: `${(tabItems.value.length * scrollableItemWidth) + scrollableSidePadding}rpx`,
  }
})
const itemStyle = computed(() => {
  if (!isScrollable.value) {
    return null
  }

  const width = `${scrollableItemWidth}rpx`

  return {
    width,
    minWidth: width,
  }
})
const scrollLeft = ref(0)
const hasPlayedScrollHint = ref(false)
const hasUserScrolled = ref(false)
const isPlayingAutoScrollHint = ref(false)

let scrollHintForwardTimer = null
let scrollHintResetTimer = null

const isActive = (key) => key === currentKey.value

const getItemInnerStyle = (item) => {
  if (!isActive(item?.key)) {
    return null
  }

  return {
    color: activeColor.value,
  }
}

const getIconClass = (item) => {
  const icon = String(item?.class || item?.icon || defaultIcon).trim() || defaultIcon
  const fontClass = icon.startsWith('fa-') ? 'fa' : 'iconfont'
  return [fontClass, icon]
}

const handleTabClick = (item) => {
  const targetUrl = item?.page_url
    || (item?.key !== 'home' ? buildWebviewPreviewUrl(item?.webview_url, item?.webview_title || item?.title) : '')

  if (!targetUrl || isActive(item?.key)) {
    return
  }

  if (item?.key === 'home') {
    Taro.redirectTo({ url: targetUrl })
    return
  }

  // 非首页按钮需要保留页面栈,这样 webview-preview 才能显示微信原生返回按钮。
  Taro.navigateTo({ url: targetUrl })
}

const clearScrollHintTimers = () => {
  if (scrollHintForwardTimer) {
    clearTimeout(scrollHintForwardTimer)
    scrollHintForwardTimer = null
  }

  if (scrollHintResetTimer) {
    clearTimeout(scrollHintResetTimer)
    scrollHintResetTimer = null
  }

  isPlayingAutoScrollHint.value = false
}

const handlePanelScroll = (event) => {
  if (isPlayingAutoScrollHint.value || hasUserScrolled.value) {
    return
  }

  const currentScrollLeft = Number(event?.detail?.scrollLeft || 0)
  if (currentScrollLeft > 6) {
    hasUserScrolled.value = true
  }
}

const playScrollHint = async () => {
  if (!showScrollHint.value || hasPlayedScrollHint.value) {
    return
  }

  const hasSeenHint = Taro.getStorageSync(scrollHintStorageKey)
  if (hasSeenHint) {
    hasPlayedScrollHint.value = true
    return
  }

  hasPlayedScrollHint.value = true
  await nextTick()
  isPlayingAutoScrollHint.value = true

  scrollHintForwardTimer = setTimeout(() => {
    scrollLeft.value = scrollHintOffset
  }, 240)

  scrollHintResetTimer = setTimeout(() => {
    scrollLeft.value = 0
    isPlayingAutoScrollHint.value = false
    Taro.setStorageSync(scrollHintStorageKey, 1)
  }, 1080)
}

watch(showScrollHint, (value) => {
  if (value) {
    playScrollHint()
  }
}, { immediate: true })

onMounted(async () => {
  await tabbarStore.ensureLoaded()
})

onBeforeUnmount(() => {
  clearScrollHintTimers()
})
</script>

<style lang="less">
.app-tabbar {
  .app-tabbar__placeholder {
    height: 132rpx;
    flex-shrink: 0;
  }

  .app-tabbar__wrap {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 100;
    box-sizing: border-box;
    overflow: hidden;
  }

  .app-tabbar__panel {
    height: 150rpx;
    border-top: 2rpx solid rgba(166, 121, 57, 0.12);
    background: rgba(255, 255, 255, 0.98);
    box-sizing: border-box;
    backdrop-filter: blur(12rpx);
  }

  .app-tabbar__content {
    display: flex;
    align-items: stretch;
    padding: 16rpx 28rpx 16rpx 52rpx;
    box-sizing: border-box;
  }

  .app-tabbar__content.is-scrollable {
    display: inline-block;
    white-space: nowrap;
  }

  .app-tabbar__item {
    flex: 1;
    min-width: 0;
  }

  .app-tabbar__item.is-scrollable {
    display: inline-flex;
    vertical-align: top;
    padding-right: 4rpx;
    box-sizing: border-box;
    white-space: normal;
  }

  .app-tabbar__item-inner {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 12rpx;
    min-height: 100rpx;
    border-radius: 20rpx;
    color: #8b95a7;
    transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
  }

  .app-tabbar__icon {
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 1;
    color: inherit;
    font-size: 48rpx;
  }

  .app-tabbar__icon-font {
    line-height: 1;
  }

  .app-tabbar__label {
    font-size: 28rpx;
    font-weight: 600;
    line-height: 1.25;
  }

  .app-tabbar__fade {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 84rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    background: linear-gradient(
      90deg,
      rgba(255, 255, 255, 0) 0%,
      rgba(255, 255, 255, 0.2) 26%,
      rgba(255, 255, 255, 0.74) 68%,
      rgba(255, 255, 255, 0.98) 100%
    );
  }

  .app-tabbar__hint-arrow {
    width: 12rpx;
    height: 12rpx;
    margin-right: 12rpx;
    border-top: 2rpx solid rgba(166, 121, 57, 0.32);
    border-right: 2rpx solid rgba(166, 121, 57, 0.32);
    transform: rotate(45deg);
  }
}
</style>