做即时通讯、社区或者电商类小程序时,tabbar上的消息徽标几乎是必备元素。原生tabbar虽然能显示数字,但样式呆板,消息数量变化时没有任何反馈动画,用户很容易忽略掉。相比之下,微信自己的App在收到新消息时,底部tab的徽标会有一个明显的弹跳放大效果,视觉冲击力很强。这个效果在小程序里同样可以实现,核心思路是放弃原生tabbar,改用custom-tab-bar自定义组件,再配合CSS动画驱动徽标的放大缩小。下面把整个实现过程拆开讲清楚。

一、从原生tabbar切换到自定义组件
微信小程序提供了custom-tab-bar机制,允许开发者完全接管底部导航栏的渲染。第一步是在app.json的tabBar配置中加上"custom": true声明,同时保留list数组(list中的页面路径仍然生效,用于页面切换逻辑),只是原生样式不再渲染:
"tabBar": {
"custom": true,
"color": "#7A7E83",
"selectedColor": "#3cc51f",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/message/message",
"text": "消息"
}
]
}
接着在项目根目录(与pages同级)创建custom-tab-bar文件夹,里面放index.js、index.json、index.wxml、index.wxss四个文件。需要注意的是,这个目录名是固定的,微信会自动识别并挂载,不能随意改名。每个tab页面切换时,都会重新实例化一份tabbar组件,这一点和普通组件不同,也是后面跨页面同步状态时要重点处理的问题。
index.json中要将component字段设为true,声明这是一个组件。wxml里渲染tab项和徽标,徽标部分就是本次动画的主角。一个典型的基础结构如下:
<view class="tab-bar">
<view
wx:for="{{list}}"
wx:key="index"
class="tab-item"
data-path="{{item.pagePath}}"
bindtap="switchTab">
<view class="icon-wrap">
<image src="{{selected === index ? item.selectedIcon : item.icon}}" class="icon"/>
<view wx:if="{{item.badge > 0}}" class="badge {{bump ? 'badge-bump' : ''}}">
{{item.badge > 99 ? '99+' : item.badge}}
</view>
</view>
<view class="tab-text {{selected === index ? 'active' : ''}}">{{item.text}}</view>
</view>
</view>
徽标上加了一个bump状态类,当这个类被添加时触发动画。通过wx:if控制badge大于0才显示,超过99时显示99+,这是通用做法,避免长数字撑破圆形徽标。
二、用CSS动画实现徽标的放大缩小
动画的核心在wxss里。小程序的wxss支持标准的transform和animation属性,实现弹跳效果推荐用keyframes定义一段先放大再回弹的动画,而不是简单地transition到某个scale值。因为transition只能做一次线性插值,出来的效果比较生硬,而keyframes可以定义多个关键帧,模拟出弹性回弹的节奏感:
.badge {
position: absolute;
top: -8rpx;
right: -16rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
box-sizing: border-box;
border-radius: 16rpx;
background: #ff4d4f;
color: #fff;
font-size: 20rpx;
line-height: 30rpx;
text-align: center;
}
/* 触发动画的类 */
.badge-bump {
animation: badgeBump 0.4s ease-in-out;
}
@keyframes badgeBump {
0% { transform: scale(1); }
30% { transform: scale(1.45); }
55% { transform: scale(0.85); }
75% { transform: scale(1.15); }
100% { transform: scale(1); }
}
这套关键帧的设计思路:先快速放大到1.45倍制造冲击感,然后缩到0.85倍形成回弹压缩,再小幅放大一次,最后回到原始大小。整个过程0.4秒,节奏接近真实的物理弹跳。如果想要更Q弹的感觉,可以把中间帧拉大到1.6倍,或者增加一轮回弹;如果追求克制的风格,只保留放大和回落两帧即可,动画时长缩短到0.25秒左右。
有一点要特别注意:徽标本身使用了绝对定位,transform和position不会冲突,但如果父级元素(这里是icon-wrap)设置了overflow: hidden,放大后的徽标会被裁剪掉一部分,视觉效果大打折扣。所以图标容器不要裁剪溢出内容,必要时给图标本身加border-radius,而不是在容器上裁剪。
三、未读数变化时触发动画的逻辑
动画类已经写好,接下来要解决的是什么时候加类、什么时候移除类。这里有个容易踩的坑:CSS animation在类名已经存在的情况下不会重复触发。也就是说,如果第一次消息到来时加了badge-bump类,第二次消息到来时类名没变化,动画就不会再播放。解决办法是每次触发前先移除类,强制触发一次重绘,再重新添加。小程序里可以通过setData一个时间戳拼接的类名,或者用两次setData配合setTimeout来移除再添加:
// custom-tab-bar/index.js
Component({
data: {
selected: 0,
bump: false,
list: [
{ pagePath: '/pages/index/index', text: '首页', badge: 0 },
{ pagePath: '/pages/message/message', text: '消息', badge: 0 }
]
},
methods: {
// 外部调用:更新未读数并触发动画
updateBadge(index, count) {
const old = this.data.list[index].badge;
if (count <= old) {
// 数量减少或不变,只更新不播动画
this.setData({ [`list[${index}].badge`]: count });
return;
}
this.setData({ [`list[${index}].badge`]: count });
this.playBump();
},
playBump() {
// 先移除类,下一帧再加回,保证动画可重复触发
this.setData({ bump: false }, () => {
setTimeout(() => {
this.setData({ bump: true });
}, 30);
});
},
switchTab(e) {
const path = e.currentTarget.dataset.path;
wx.switchTab({ url: path });
}
}
});
这里的判断逻辑是只有数量增加时才播动画,减少(比如用户点进消息页清空了未读)不需要弹跳,避免干扰。setData带回调的写法保证了移除和添加类之间有时间差,30毫秒足够浏览器完成一次渲染刷新。如果项目里动画触发频繁,也可以考虑用一个自增的key值绑定到wxml的wx:key上,通过重建节点的方式触发动画,两种方案效果一致,按团队习惯选择即可。
四、跨页面同步徽标状态
前文提到每个tab页面都会实例化自己的custom-tab-bar组件,这意味着消息页上的tabbar和首页上的tabbar是两个独立实例,data互不相通。轮询接口收到新消息后更新当前页的tabbar很容易,但用户切到另一个tab时,那边的徽标数字还是旧的。解决这个问题的惯用做法是维护一个全局状态,页面onShow时让tabbar组件读取并同步:
// app.js
App({
globalData: {
unreadCount: 0
}
});
// 每个tab页面的onShow
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const app = getApp();
this.getTabBar().setData({
selected: 1, // 当前页对应的tab索引
'list[1].badge': app.globalData.unreadCount
});
}
}
WebSocket或轮询收到新消息时,先更新globalData.unreadCount,再通过this.getTabBar()拿到当前页面的tabbar实例调用updateBadge播放动画。页面切换时onShow会重新执行,新页面的tabbar实例从globalData读到最新数值,状态就同步了。这个模式在社区里被称为全局单例数据源,几乎所有自定义tabbar项目最终都会收敛到这个方案。
另外提醒一点,动画虽然好看,但transform动画运行在合成线程,性能开销极小,真正需要注意的是别在动画期间频繁setData整个list数组。上面的代码都用了list[index].badge这种路径式局部更新,只传输变化的数据,这在长列表和频繁消息推送的场景下能明显减少通信开销。把动画时长控制在0.4秒以内、回弹帧数不超过三帧,低端安卓机上也能稳定跑满帧率,最终得到一个既醒目又不拖沓的消息徽标效果。