微信小程序默认的底部标签栏只能配置图标路径、文字和选中颜色,想要把中间按钮做成类似 Netflix 播放键那样,按下时缩小回弹、同时从圆心扩散出一圈波纹,单靠 app.json 里 tabBar 配置完全不够。要实现这套效果,需要把 tabBar 的 custom 字段打开,让底部导航变成自定义组件,这样 WXML 结构、WXSS 动画和 JS 交互都能由开发者控制。

一、从默认 tabBar 到自定义组件:先理清配置
在微信小程序里,app.json 的 tabBar.list 只能定义 pagePath、text、iconPath 和 selectedIconPath。它不支持自定义布局,也不允许在 list 里加入一个没有对应页面的凸起按钮,更不能给某个 item 单独加动画。因此实现 Netflix 风格按钮的前提是开启 custom tabBar。
配置方式并不复杂:在 app.json 的 tabBar 字段中增加 custom 字段并设为 true,同时保留 list 配置。list 中的 pagePath 仍然是可切换的标签页路径,但底部栏的渲染会交给项目根目录下的 custom-tab-bar/index 组件。这个组件由微信框架在 tab 页加载时自动挂载,开发者需要在自己的 WXML 和 WXSS 中完整绘制底栏。
{
"tabBar": {
"custom": true,
"color": "#666666",
"selectedColor": "#1976f2",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/center/center",
"text": "播放"
},
{
"pagePath": "pages/mine/mine",
"text": "我的"
}
]
}
}
这里的 center 页面如果作为中间按钮点击切换,可以放进 list 里;如果只是打开一个普通页面,则需要用 wx.navigateTo 而不是 switchTab。自定义 tabBar 组件在非 tab 页不会显示,所以中间按钮的目标页面要提前规划好,否则会出现点击后底栏消失的情况。
二、搭建中间凸起按钮的布局与基础样式
自定义 tabBar 的 WXML 需要包含左侧 tab、中间按钮和右侧 tab。为了做出凸起效果,中间按钮通常不参与两侧 item 的 flex 排列,而是让左右各占 1 份,中间按钮绝对定位在底栏上方。底栏容器需要预留出中间按钮的空间,并设置 position: relative。
<view class="tab-bar">
<view class="tab-side left">
<view class="tab-item {{selected === 0 ? 'active' : ''}}" data-index="0" data-path="/pages/index/index" bindtap="onTabTap">
<view class="tab-icon home"></view>
<text>首页</text>
</view>
<view class="tab-item {{selected === 1 ? 'active' : ''}}" data-index="1" data-path="/pages/center/center" bindtap="onTabTap">
<view class="tab-icon video"></view>
<text>播放</text>
</view>
</view>
<view class="center-button {{pressed ? 'pressed' : ''}}" bindtouchstart="onCenterTouchStart" bindtouchend="onCenterTouchEnd" bindtap="onCenterTap">
<view wx:for="{{ripples}}" wx:key="id" class="ripple" style="left: {{item.left}}px; top: {{item.top}}px;" data-id="{{item.id}}" bindanimationend="onRippleEnd"></view>
<view class="play-icon"></view>
</view>
<view class="tab-side right">
<view class="tab-item {{selected === 2 ? 'active' : ''}}" data-index="2" data-path="/pages/mine/mine" bindtap="onTabTap">
<view class="tab-icon user"></view>
<text>我的</text>
</view>
</view>
</view>
基础样式需要关注三点:底栏高度要足够容纳按钮,按钮要水平居中并向上偏移,同时小屏设备要适配安全区。按钮可以做成 56px 左右的圆形,使用渐变背景和阴影来强化层级。播放图标用 CSS 边框三角实现,不必依赖图片。
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 56px;
display: flex;
align-items: center;
background: #ffffff;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
padding-bottom: env(safe-area-inset-bottom);
z-index: 50;
}
.tab-side {
flex: 1;
display: flex;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #666;
}
.center-button {
position: absolute;
left: 50%;
top: -22px;
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: linear-gradient(135deg, #e50914, #b00710);
box-shadow: 0 6px 14px rgba(229, 9, 20, 0.35);
display: flex;
align-items: center;
justify-content: center;
transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.center-button.pressed {
transform: scale(0.82);
}
.play-icon {
width: 0;
height: 0;
margin-left: 4px;
border-left: 14px solid #fff;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
}
.ripple {
position: absolute;
left: 50%;
top: 50%;
width: 56px;
height: 56px;
margin-left: -28px;
margin-top: -28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
pointer-events: none;
animation: ripple 460ms ease-out forwards;
}
@keyframes ripple {
0% {
transform: scale(1);
opacity: 0.55;
}
100% {
transform: scale(3.4);
opacity: 0;
}
}
这里中间按钮的 z-index 虽然没写,但因为它们在 flex 容器之后用绝对定位,默认会覆盖两侧 item。两侧 item 的点击区域应避开中间按钮的圆形范围。波纹节点必须设置 pointer-events: none,否则会挡住按钮的 touch 事件,造成快速点击时偶发无响应。
三、缩放回弹动画与按压状态控制
Netflix 播放按钮的点击反馈不是简单缩小,而是在手指按下时快速缩到 0.82 左右,松开后弹回并短暂回到 1 附近。CSS transition 配合 cubic-bezier(0.34, 1.56, 0.64, 1) 可以模拟这个回弹曲线。按压状态由 JS 控制,不建议使用 hover-class,因为 hover-class 在延迟和松手后的状态恢复上不容易做精细控制。
在组件 JS 中维护 pressed 字段,onCenterTouchStart 把它设为 true,onCenterTouchEnd 和 onCenterTap 中恢复 false。连续快速点击时 touchstart 和 touchend 会成对触发,setData 更新频率不高,性能可以接受。如果担心 setData 频繁触发,也可以在 touchstart 中直接操作 class,但考虑到状态要驱动 WXML,setData 仍是清晰的方案。
onCenterTouchStart() {
if (this.data.pressed) return;
this.setData({ pressed: true });
},
onCenterTouchEnd() {
this.setData({ pressed: false });
this.startRipple();
},
onCenterTap() {
if (this.data.pressed) {
this.setData({ pressed: false });
}
const path = '/pages/center/center';
wx.switchTab({
url: path,
fail() {
wx.navigateTo({ url: path });
}
});
}
上面的 onCenterTouchEnd 同时触发波纹动画,而 onCenterTap 负责页面跳转。这样松手瞬间已有动画反馈,跳转成功或失败都不会影响按钮动画。需要注意的是,switchTab 的 url 必须是 app.json 中已配置的 tab 页路径。如果中间按钮要打开非 tab 页,可以直接使用 wx.navigateTo,但底栏会随页面跳转退出,此时应评估产品交互是否接受。
四、波纹扩散动画:动态节点池与自动清理
波纹不能只用一个节点反复播放,因为用户连续点击时,上一次动画还未结束,新的波纹需要叠加。推荐在 data 中维护 ripples 数组,每次按下时向数组追加一个波纹对象,对象只保存 id、left 和 top。WXML 用 wx:for 渲染节点,每个波纹都执行相同的 CSS keyframes 动画。动画结束后通过 animationend 事件移除对应节点。
波纹颜色和大小可以按产品视觉调整。为了让波纹从按钮中心扩散,left 和 top 固定指向按钮中心即可,不需要计算触摸点坐标。若希望波纹从手指按下的位置出现,可以在 touchstart 事件中用 e.touches[0].clientX 减去按钮的 boundingClientRect 计算偏移,但 Netflix 的播放按钮更偏向中心扩散,简单处理反而更接近原效果。
data: {
selected: 0,
pressed: false,
ripples: [],
rippleId: 0
},
startRipple() {
const id = this.data.rippleId + 1;
let ripples = this.data.ripples.slice();
if (ripples.length > 4) {
ripples = ripples.slice(-3);
}
ripples.push({
id,
left: 0,
top: 0
});
this.setData({
ripples,
rippleId: id
});
},
onRippleEnd(e) {
const id = e.currentTarget.dataset.id;
const ripples = this.data.ripples.filter(item => item.id !== id);
this.setData({ ripples });
}
上面的代码限制了波纹节点最多保留 4 个,超出时只保留最后 3 个再加新节点。这样在安卓低端机上快速点击也不会积累大量节点。移除节点时必须使用 animationend,不要使用 setTimeout 估算动画时长,否则真机上动画节奏可能偏移,导致节点提前或滞后删除。
波纹节点的样式在上一节的 CSS 中已经给出,关键点是 animation: ripple 460ms ease-out forwards。forwards 保证动画结束后节点停留在 opacity: 0,避免动画结束时闪一下。结合 pointer-events: none,未移除的透明节点也不会影响点击。
五、各页面选中态同步与中间按钮的页面策略
自定义 tabBar 组件不会自动知道当前处于哪个 tab 页,需要在每个 tab 页面的 onShow 生命周期中主动同步选中索引。在页面中通过 this.getTabBar() 获取组件实例,再调用 setData 更新 selected。这个方法只有在 custom tabBar 开启并且当前页面是 tab 页时可用。
onShow() {
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({
selected: 0,
pressed: false
});
}
}
中间按钮对应的 center 页面如果也在 tabBar.list 中,选中态可以设为 1,但视觉上中间按钮通常不显示选中文字或颜色。页面侧 tab 的选中态根据 selected 索引决定高亮。中间按钮自身可以始终高亮,因为它本身就是重点入口。
如果中间按钮只是负责跳转到发布页、扫码页或详情页,建议不要放进 tabBar.list,而使用 wx.navigateTo。这时自定义 tabBar 不会显示在目标页面,返回后底栏仍会恢复。唯一要注意的是返回动画与纹波动画可能同时出现,页面层级变化时不要阻塞主线程。
六、真机调试时容易踩到的几个点
第一个是底部安全区。iPhone 带 Home Indicator 的机型会把底栏内容顶到很靠近横条,自定义 tabBar 如果不加 padding-bottom: env(safe-area-inset-bottom),文字和按钮会被遮挡。第二个是按钮点击区域,56px 的圆形在真机上偏小,可以用一个更大的透明热区包裹中间按钮,或者在中心按钮内增加 padding 并保持视觉大小不变。
第三个是动画层级。波纹节点如果设置 z-index 过高,会盖住两侧 tab 的文字;如果过低,会被按钮背景遮住。通常把波纹放在按钮内部并设置 overflow: visible,或者让波纹 z-index 小于按钮播放图标。第四个是安卓端 setData 动画性能,连续点击时不要一次性创建大量节点,上面的数量限制能有效降低掉帧风险。最后还要注意基础库版本,自定义 tabBar 需要基础库 2.5.0 以上,建议在低版本上做降级提示或保留默认 tabBar 配置。
把这些细节处理好后,自定义 tabBar 的中间按钮就能稳定呈现 Netflix 风格的缩放回弹和波纹扩散。整套方案的核心不在于动画本身,而在于自定义 tabBar 提供了完全控制权,再通过 CSS 动画和轻量数据更新组合出接近原生应用的点击反馈。