Uni-app 사용자 정의 탭바 활성화 문제 해결 가이드

Uni-app 환경에서 사용자 정의 하단 탭바를 구현할 때, 특정 상황에서 탭바 아이템을 처음 클릭할 때는 정상적으로 활성화되지 않고 두 번째 클릭에서야 올바르게 하이라이트되는 문제가 발생할 수 있습니다. 이 문서는 해당 문제를 해결하기 위한 Pinia 기반의 상태 관리 솔루션을 제공합니다.

환경 설정: Pinia 설치

우선, Pinia와 지속성 플러그인을 설치합니다.

npm i pinia pinia-plugin-persistedstate

1. Pinia 스토어 구성

Pinia 인스턴스 초기화 (stores/index.js)

Pinia 인스턴스를 생성하고 pinia-plugin-persistedstate를 적용하여 상태를 영속화합니다.

// stores/index.js
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';

const piniaInstance = createPinia();
piniaInstance.use(piniaPluginPersistedstate);

export default piniaInstance;

탭 선택 상태 스토어 정의 (stores/tabSelection.js)

현재 활성화된 탭의 인덱스를 관리하는 Pinia 스토어를 정의합니다.

// stores/tabSelection.js
import { defineStore } from 'pinia';

export const useTabSelectionStore = defineStore('tabSelection', {
  state: () => ({
    // 현재 활성화된 탭의 인덱스를 저장합니다.
    currentTabIndex: 0
  }),
  actions: {
    // 활성화 인덱스를 업데이트합니다.
    updateActiveTabIndex(newIndex) {
      this.currentTabIndex = newIndex;
    }
  }
});

2. 전역 탭바 컴포넌트 생성 (components/AppTabBar/AppTabBar.vue)

사용자 정의 탭바를 감싸고 Pinia 스토어와 연동하는 상위 컴포넌트를 만듭니다.

<template>
  <CustomConcaveTabBar
    :tab-items="navigationItems"
    :label-text-size="30"
    label-text-color="#2d8cf0"
    :icon-image-size="50"
    :active-index="activeTabIdx"
    circular-bg-color="#fff"
    :auto-navigation="true"
    @selection-change="handleTabSelect"></CustomConcaveTabBar>
</template>

<script setup>
  import CustomConcaveTabBar from "@/components/CustomConcaveTabBar/CustomConcaveTabBar.vue";
  import { useTabSelectionStore } from '@/stores/tabSelection.js';

  const tabSelectionStore = useTabSelectionStore();

  const componentProps = defineProps({
    initialActiveIndex: {
      type: Number,
      default: 0
    }
  });

  // Pinia 스토어 또는 초기 props에서 활성화 인덱스를 가져옵니다.
  const activeTabIdx = tabSelectionStore.currentTabIndex || componentProps.initialActiveIndex;

  const navigationItems = [
    {
      label: '홈',
      pagePath: '/pages/home/home',
      iconPath: '/static/tabbar/home.png',
      selectedIconPath: '/static/tabbar/selectedhome.png'
    },
    {
      label: '에이전트',
      pagePath: '/pages/agent/agent',
      iconPath: '/static/tabbar/agent.png',
      selectedIconPath: '/static/tabbar/selectedagent.png'
    },
    {
      label: '메시지',
      pagePath: '/pages/news/news',
      iconPath: '/static/tabbar/News.png',
      selectedIconPath: '/static/tabbar/selectedNews.png'
    },
    {
      label: '내 정보',
      pagePath: '/pages/my/my',
      iconPath: '/static/tabbar/My.png',
      selectedIconPath: '/static/tabbar/selectedMy.png'
    }
  ];

  const handleTabSelect = (index) => {
    tabSelectionStore.updateActiveTabIndex(index);
  };
</script>

3. 오목형 사용자 정의 탭바 컴포넌트 구현 (components/CustomConcaveTabBar/CustomConcaveTabBar.vue)

실제 탭바의 시각적 로직과 동작을 담당하는 컴포넌트입니다. Pinia 스토어와 연동하여 활성화 상태를 관리합니다.

<template>
  <view class="custom-tabbar-wrapper">
    <view class="custom-tabbar">
      <view :style="{'grid-template-columns': `repeat(${tabItems.length}, 1fr)`}" class="tabbar-content-area">
        <view v-for="(item, index) in tabItems" :key="item.label" class="tab-item"
          @tap="onTabItemClick(index, item)">
          <image :key="`icon-${index}`" :class="[tabState.currentIdx === index ? 'icon-hidden':'icon-visible']"
            :src="item.iconPath" :style="[iconImageStyle]" class="item-icon"></image>
          <text :key="`label-${index}`" :class="[tabState.currentIdx === index ? 'label-active':'label-inactive']"
            :style="{fontSize: pixelUnit(labelTextSize), color: labelTextColor}" class="item-label">
            {{ item.label }}
          </text>
        </view>
      </view>
      <view :style="{width: pixelUnit(750 * 2 + 750 / props.tabItems.length), left: pixelUnit(tabState.backgroundOffset)}"
        class="tab-background">
        <view :style="{width: pixelUnit(750 / tabItems.length)}" class="background-middle-section">
          <image :src="base64ConcaveImage" class="bg-concave-shape"></image>
          <view :style="{backgroundColor: circularBgColor}" class="middle-circular-area">
            <image :class="['active-icon-show']" :src="tabItems[tabState.currentIdx].selectedIconPath"
              :style="[iconImageStyle]" class="circular-icon"></image>
          </view>
        </view>
      </view>
    </view>
    <view class="custom-tabbar-placeholder"></view>
  </view>
</template>

<script setup>
  import {
    reactive,
    computed,
    watch
  } from "vue";
  import {
    useTabSelectionStore
  } from '@/stores/tabSelection.js';

  // Pinia 스토어 가져오기
  const tabSelectionStore = useTabSelectionStore();

  const props = defineProps({
    /**
     * @description 하단 탭바 아이템 목록 (2~5개 권장)
     */
    tabItems: {
      type: Array,
      required: true,
      validator: (val) => {
        return val.length >= 2 && val.length <= 5;
      }
    },
    /**
     * @description 중앙 원형 배경색
     */
    circularBgColor: {
      type: String,
      default: '#FFA500'
    },
    /**
     * @description 텍스트 라벨 크기
     */
    labelTextSize: {
      type: Number,
      default: 30
    },
    /**
     * @description 텍스트 라벨 색상
     */
    labelTextColor: {
      type: String,
      default: '#666'
    },
    /**
     * @description 활성 텍스트 라벨 색상 (현재 사용 안 함)
     */
    activeLabelTextColor: {
      type: String,
      default: '#FFA500'
    },
    /**
     * @description 아이콘 이미지 크기
     */
    iconImageSize: {
      type: Number,
      default: 60
    },
    /**
     * @description 현재 활성화된 탭의 인덱스
     */
    activeIndex: {
      type: Number,
      default: 0
    },
    /**
     * @description 탭 클릭 시 자동으로 페이지를 이동할지 여부 (기본값: false)
     */
    autoNavigation: {
      type: Boolean,
      default: false
    },
    /**
     * @description 페이지 이동 가로채기 함수 (함수가 true를 반환하면 이동, false면 이동 안 함)
     */
    navigationInterceptor: {
      type: Function
    }
  });

  // 오목한 배경 모양을 위한 Base64 이미지
  let base64ConcaveImage =
    'data:image/png;base64,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';

  // 탭바의 현재 상태를 관리합니다. (Pinia의 인덱스를 우선 사용)
  const tabState = reactive({
    currentIdx: tabSelectionStore.currentTabIndex,
    backgroundOffset: -750 + 750 / props.tabItems.length * tabSelectionStore.currentTabIndex
  });

  // Pinia 스토어의 활성 인덱스 변경을 감지하고 UI를 업데이트합니다.
  watch(
    () => tabSelectionStore.currentTabIndex,
    (newIndex) => {
      tabState.currentIdx = newIndex;
      tabState.backgroundOffset = -750 + 750 / props.tabItems.length * newIndex;
    }, {
      immediate: true
    }
  );

  // 숫자에 'rpx' 단위를 추가하는 유틸리티 함수
  function pixelUnit(num) {
    return num + 'rpx';
  }

  // 아이콘 크기 스타일을 계산된 속성으로 정의
  const iconImageStyle = computed(() => {
    return {
      width: pixelUnit(props.iconImageSize),
      height: pixelUnit(props.iconImageSize)
    };
  });

  const emittedEvents = defineEmits(['selection-change']);

  // 탭 아이템 클릭 핸들러
  function onTabItemClick(index, item) {
    if (tabState.currentIdx === index) return; // 이미 활성화된 탭은 다시 클릭해도 무시
    if (props.navigationInterceptor) {
      if (!props.navigationInterceptor(index, item)) return; // 인터셉터가 false를 반환하면 이동 중단
    }
    if (props.autoNavigation && item.pagePath) {
      uni.switchTab({
        url: item.pagePath
      });
    }
    // Pinia 스토어의 활성 인덱스를 업데이트합니다.
    tabSelectionStore.updateActiveTabIndex(index);
    // 로컬 상태를 업데이트합니다. (watch를 통해 자동 동기화되지만, 여기서는 혹시 모를 상황에 대비)
    tabState.currentIdx = index;
    tabState.backgroundOffset = -750 + 750 / props.tabItems.length * index;
    emittedEvents('selection-change', index, item);
  }
</script>

<style lang="scss" scoped>
  .custom-tabbar {
    position: fixed;
    bottom: 0;
    margin-bottom: env(safe-area-inset-bottom);
    width: 750rpx;
    height: 100rpx;

    &::before {
      position: absolute;
      top: 99rpx;
      width: 750rpx;
      height: calc(env(safe-area-inset-bottom) + 1rpx);
      background-color: #fff;
      content: " ";
    }
  }

  .custom-tabbar-placeholder {
    padding-bottom: env(safe-area-inset-bottom);
    width: 750rpx;
    height: 100rpx;
  }

  .tabbar-content-area {
    position: absolute;
    z-index: 2;
    display: grid;
    width: 750rpx;
    height: 100%;
    background-color: rgba(0, 0, 0, 0);

    .tab-item {
      position: relative;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 100%;
      height: 100%;

      .item-icon {
        transition: all 0.3s ease;
      }

      .item-icon.icon-visible {
        transform: translateY(0rpx);
      }

      .item-icon.icon-hidden {
        opacity: 0;
        transform: translateY(-10rpx);
      }

      .item-label {
        position: absolute;
        bottom: 0;
        width: 100%;
        text-align: center;
        line-height: 45rpx;
        transition: all 0.1s ease;
      }

      .item-label.label-inactive {
        opacity: 0;
        transform: scale(0.5);
      }

      .item-label.label-active {
        transform: scale(1);
      }
    }
  }

  .tab-background {
    position: absolute;
    z-index: 1;
    display: flex;
    justify-content: center;
    height: 100%;
    background: radial-gradient(circle at 50% 0rpx, transparent 70rpx, #fff 0) top left 100% no-repeat;
    transition: left 0.3s ease;

    .background-middle-section {
      display: flex;
      justify-content: center;
      height: 100%;

      .bg-concave-shape {
        display: block;
        width: 150rpx;
        height: 75rpx;
      }

      .middle-circular-area {
        position: absolute;
        top: -45rpx;
        display: flex;
        align-items: center;
        justify-content: center;
        width: 90rpx;
        height: 90rpx;
        border-radius: 50%;
        background-color: #fff;
      }

      .circular-icon {
        position: absolute;
        transition: all 0.5s ease;
      }

      .circular-icon.active-icon-hide {
        opacity: 0;
        transition: all 0s ease;
        transform: translateY(30rpx);
      }

      .circular-icon.active-icon-show {
        opacity: 1;
      }
    }
  }
</style>

4. pages.json에 전역 컴포넌트 등록

pages.json 파일의 easycom 설정을 통해 AppTabBar를 전역 컴포넌트로 등록하여 모든 페이지에서 편리하게 사용할 수 있도록 합니다.

"easycom": {
  "custom": {
    "AppTabBar": "@/components/AppTabBar/AppTabBar.vue"
  }
}

5. 페이지에서 탭바 사용

이제 원하는 페이지에서 AppTabBar 컴포넌트를 사용하면 됩니다. initial-active-index prop을 통해 초기 활성 탭을 설정할 수 있습니다.

<template>
  <view class="main-page">
    메인 페이지 내용
    <AppTabBar :initial-active-index="0"></AppTabBar>
  </view>
</template>

<script setup>
</script>

태그: uni-app Pinia vue3 사용자 정의 탭바 상태 관리

8월 6일 09:14에 게시됨