做小程序开发的同学应该都见过类似抖音、小红书那种底部导航——中间一个凸起的圆形按钮,点击还能弹出一圈功能入口,切换tab时图标会弹跳一下。这种效果用微信小程序原生的tabBar配置文件是写不出来的,因为app.json里只能设置文字、图标和颜色,样式完全由官方渲染。好在小程序官方预留了custom-tab-bar组件机制,允许开发者彻底接管底部导航栏。本文就从零开始,带你实现一个带中间凸起按钮和切换动画的自定义tabBar。

一、custom-tab-bar的基础配置与目录结构
首先要在app.json的tabBar节点里开启自定义开关。注意一点:即便全部使用自定义渲染,list数组里的页面配置也不能删掉,因为小程序依然依赖这个list来管理页面栈和路由跳转。
{
"tabBar": {
"custom": true,
"color": "#999999",
"selectedColor": "#1AAD19",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/publish/publish",
"text": "发布"
},
{
"pagePath": "pages/mine/mine",
"text": "我的"
}
]
}
}</code>接下来要在项目根目录(与pages同级)创建一个固定名称为custom-tab-bar的文件夹,里面放index.js、index.json、index.wxml、index.wxss四个文件。这个文件夹名和文件名都是约定好的,写错一个字母小程序都识别不到。index.json里要把component字段声明为true,表示这是一个自定义组件:
{
"component": true
}js部分通常会在data里定义一个选中索引和tab列表,并提供一个跳转方法。这里有个关键点:每个tab页面都拥有自己独立的custom-tab-bar实例,切换页面后组件会重新初始化,所以每个页面的onShow里都要通过getTabBar()重新设置选中状态:
// custom-tab-bar/index.js
Component({
data: {
selected: 0,
list: [
{ pagePath: '/pages/index/index', text: '首页', icon: 'home' },
{ pagePath: '/pages/publish/publish', text: '发布', icon: 'add' },
{ pagePath: '/pages/mine/mine', text: '我的', icon: 'user' }
]
},
methods: {
switchTab(e) {
const index = e.currentTarget.dataset.index;
const path = '/' + this.data.list[index].pagePath;
wx.switchTab({ url: path });
this.setData({ selected: index });
}
}
});
// 各个tab页面的onShow中同步选中态
Page({
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0 }); // 每个页面写自己的索引
}
}
});二、中间凸起按钮的布局实现
凸起按钮的核心思路是:让中间那个tab项脱离正常的排列流,用绝对定位把它往上顶出容器。tabBar容器保持固定高度(比如100rpx),凸起按钮设置成圆形,直径比容器高度大,再用position: absolute配合负的bottom值让它一半悬浮在tabBar之外。
这里有个非常容易被忽略的坑:tabBar容器默认会被小程序限制在页面底部,超出容器的部分会被裁剪掉,导致凸起的半圆显示不出来。解决办法是给容器加overflow: visible,同时背景不要直接铺在容器上,而是铺在内层。另外凸起按钮的定位基准点要用bottom而不是top,这样在全面屏手机上跟随安全区域变化时不会错位。
<view class="tab-bar">
<view
wx:for="{{list}}"
wx:key="index"
class="tab-item {{selected === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="switchTab"
>
<!-- 中间项渲染成凸起按钮 -->
<block wx:if="{{index === 1}}">
<view class="raise-btn {{animClass}}">
<image src="/images/add.png" class="raise-icon"/>
</view>
</block>
<block wx:else>
<image src="/images/{{item.icon}}.png" class="tab-icon"/>
<text class="tab-text">{{item.text}}</text>
</block>
</view>
</view>
.tab-bar {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100rpx;
padding-bottom: env(safe-area-inset-bottom);
background: #ffffff;
overflow: visible;
}
.raise-btn {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ff7e5f, #feb47b);
margin-top: -60rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(255, 126, 95, 0.4);
}
.raise-icon {
width: 56rpx;
height: 56rpx;
}关于安全区域适配,苹果X之后的机型底部有一条约34px的home指示条,直接用padding-bottom: env(safe-area-inset-bottom)处理最稳妥。如果要在js里拿到具体数值做更精细的布局,也可以通过wx.getSystemInfoSync()的safeArea字段计算,不过纯CSS方案已经能覆盖绝大多数场景。
三、点击切换动画的实现方案
动画部分分两块:一是普通tab项被选中时图标的弹跳效果,二是凸起按钮被按下时的缩放反馈。小程序里实现动画最轻量的方式是CSS过渡,配合切换class名触发。
选中弹跳可以用@keyframes定义一个先放大再回弹的动画,只对active状态的图标生效。凸起按钮的按压效果则要区分hover-class和点击态,hover-class按住即生效松手即消失,适合做按压反馈;而点击后的放大动画要在js里切换一个class,动画结束后再移除,避免下次点击不触发。这里要注意动画重复触发的问题:同一个class如果上次动画还没结束又点击一次,动画不会重新播放,所以要在bindanimationend回调里把这个class置空。
@keyframes tabBounce {
0% { transform: scale(1); }
40% { transform: scale(1.35) translateY(-8rpx); }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.tab-icon.active {
animation: tabBounce 0.4s ease;
}
@keyframes pressScale {
0% { transform: scale(1); }
50% { transform: scale(0.85); }
100% { transform: scale(1); }
}
.raise-btn.pressed {
animation: pressScale 0.25s ease;
}
switchTab(e) {
const index = e.currentTarget.dataset.index;
if (index === 1) {
// 凸起按钮:先播动画再跳转,也可以改成弹出半屏菜单
this.setData({ pressed: true });
setTimeout(() => {
wx.switchTab({ url: '/' + this.data.list[1].pagePath });
this.setData({ pressed: false });
}, 200);
return;
}
wx.switchTab({ url: '/' + this.data.list[index].pagePath });
}如果产品要求更炫的动画,比如凸起按钮点击后旋转扩散出一圈菜单项,CSS动画就会显得吃力,这时可以上wx.createAnimation或者直接用小程序的animate接口。不过从性能角度讲,能用CSS transform和opacity实现的动画尽量用CSS,它们走的是合成层渲染,不会引起重排,低端机上也能保持流畅。
四、常见问题与避坑总结
第一个高频问题是切换tab后选中态错乱。原因前面提过,每个tab页对应独立的custom-tab-bar实例,只靠组件内部记录selected是不够的。规范做法是封装一个公共函数,在每个tab页面的onShow里调用this.getTabBar().init(),把选中态注入进去,这样无论从哪个入口进入页面都能保证导航高亮正确。
第二个问题是凸起按钮点击无效或者点击区域被遮挡。因为凸起部分超出了tabBar容器,如果上方页面内容有元素叠在上面,需要检查页面的z-index层级。自定义tabBar的层级在小程序里并不是最高的,遇到遮挡时可以在组件根节点显式设置一个较大的z-index。另外记得给凸起按钮本身绑定tap事件而不是依赖外层的tab-item,避免点击到半圆之外的空白区域误触发。
第三个问题是一些项目里tabBar会闪烁一下。这是因为自定义组件也要走下载渲染流程,首次进入时可能先显示空白的原生占位。可以在custom-tab-bar的wxss里给容器一个和页面背景接近的底色,减少视觉跳变。同时在app.json的tabBar里保留list配置的意义也在这里,框架需要它来预知有哪些tab页面。
最后补充一点:如果中间的凸起按钮实际并不是一个真正的tab页面,而只是触发一个动作(比如打开发布弹窗),可以在switchTab方法里直接return掉,改为调用this.triggerEvent通知页面层处理,或者直接在组件里用wx.navigateTo打开普通页面。这种情况下app.json的list里依然要保留一个占位的tab页面,只是这个按钮永远不执行switchTab而已。掌握这些细节后,一个交互流畅、视觉突出的自定义底部导航就完整落地了。
微信小程序custom-tab-bar自定义tabBar修改时间:2026-09-14 10:50:55