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>