仿手机底部导航栏制作
起初动手写这个仿手机底部导航栏,是因为某天刷手机 App 时,注意到很多应用虽然风格各异,但底部 Tab 栏却有着惊人的一致性:图标尺寸统一、动效自然、响应迅速,而且 UI 极为精致。那一瞬间我意识到,这小小一栏,其实是一个极具锻炼价值的练习场。不仅能挑战前端基础功(比如 Flex 布局和 SVG 运用),还涵盖了 CSS 动效、DOM 操作、甚至一些轻交互逻辑,比如徽章通知、拖拽排序和长按触发菜单等。如果能完整地模拟一套这样的交互体系,简直堪比做一个小型组件库。
所以,我开始了这个“仿手机底部导航栏”的项目。目标清晰:四五个图标组成的底部导航,点击切换内容,高亮状态跟随,添加简单的点击音效、流畅的缩放/平移动画;进阶一点,还要支持拖拽重新排序,长按弹出菜单,以及带有小红点或数字徽章的 Tab。
在正式敲下第一行代码前,我先做了一张功能流转图,理一理这个项目的骨架。整个流程图如下所示:
这个流程图定下之后,开发起来就心中有谱了。我决定采用原生 HTML/CSS/JS 实现一版,最大限度地控制细节,也更适合讲解。
接下来,进入设计阶段。
我从 UI 开始,设定了整体样式风格:扁平化、微圆角、低饱和色系、浅阴影,参考 iOS 和 Material Design 的部分规范。为了保证响应式和灵活布局,我全程采用 Flexbox 来处理导航栏的分布逻辑。底部导航栏需要固定在底部、等宽分布、图标垂直排列(图标上,文字下)。这是基本布局。
我在 HTML 中这样定义了结构:
<div class="app"><div class="content" id="content"></div><div class="tabbar"><div class="tab-item active" data-tab="home"><span class="icon icon-home"></span><span class="label">首页</span></div><div class="tab-item" data-tab="search"><span class="icon icon-search"></span><span class="label">发现</span></div><div class="tab-item" data-tab="add"><span class="icon icon-add"></span><span class="label">添加</span></div><div class="tab-item" data-tab="message"><span class="icon icon-message"><span class="badge">3</span></span><span class="label">消息</span></div><div class="tab-item" data-tab="profile"><span class="icon icon-user"></span><span class="label">我的</span></div></div>
</div>
可以看到,每个 tab-item 都包含图标和文本,并且某些 tab 上有 badge 提醒(比如消息)。这部分我用的是 Iconfont 图标,也可以替换成 SVG Sprite,更精致一些。CSS 中我为 .tabbar 设置了底部固定,并通过 display: flex
和 flex: 1
让各项平均分布:
.tabbar {position: fixed;bottom: 0;width: 100%;height: 60px;display: flex;background: #fff;border-top: 1px solid #eee;z-index: 10;
}.tab-item {flex: 1;text-align: center;padding: 8px 0;font-size: 12px;color: #888;position: relative;transition: all 0.2s ease;
}.tab-item .icon {font-size: 22px;display: block;transition: transform 0.2s ease;
}.tab-item.active {color: #007aff;
}.tab-item.active .icon {transform: scale(1.2);
}
最初,我在点击 tab 时,只是加个 active 类,然后用 JS 控制 content 区域切换。但这个切换显得太生硬,我想要加入更细腻的体验:点击时图标轻微放大,文字颜色渐变,甚至有“滴”一声的反馈音。于是我在 JS 中加入了这些小交互:
const tabs = document.querySelectorAll('.tab-item');
const content = document.getElementById('content');
const sound = new Audio('./tap.mp3'); // 一段点击音效tabs.forEach(tab => {tab.addEventListener('click', () => {document.querySelector('.tab-item.active').classList.remove('active');tab.classList.add('active');// 播放音效sound.currentTime = 0;sound.play();// 内容切换const tabName = tab.getAttribute('data-tab');content.innerHTML = `<div class="page">${tabName} 页面内容</div>`;});
});
每次点击 tab,不但切换了内容,也播放了点击音,同时通过 CSS 动效让图标轻微缩放。这个细节,虽然微小,却大大提升了整体体验的“真实感”。
我还尝试加入一些更复杂的交互:比如拖拽排序。这个功能听起来简单,实际实现中挑战挺大。如何精准判断拖拽目标?如何在拖拽过程中实时计算位置变化?怎样实现动画化的过渡效果?我决定用原生拖拽 API(dragstart
, dragover
, drop
),但很快发现这个 API 和移动端不太兼容,尤其在手机浏览器中效果很差,最后还是切回了手势模拟。
为了模拟拖拽,我在 touchstart 时记录初始位置,在 touchmove 时改变 translate 值,并在 touchend 判断位置变化、重新排列 DOM。中间遇到不少坑,尤其是事件冒泡和 transition 的兼容问题,我一度打算放弃,后来决定引入一个轻量级库 Sortable.js 来处理这个逻辑。
引入后,拖拽交互变得丝滑自然。配置代码如下:
new Sortable(document.querySelector('.tabbar'), {animation: 150,onEnd: function (evt) {console.log('Tab moved:', evt.oldIndex, '->', evt.newIndex);}
});
这段配置可以让整个 tabbar 支持拖拽重排序,同时自动带有过渡动画。而我只需在 onEnd 中更新本地存储或状态就行。
徽章功能我设计得也很精巧。消息图标上的数字徽章,使用 CSS 的绝对定位实现:
.badge {position: absolute;top: 2px;right: 16px;background: red;color: white;font-size: 10px;padding: 2px 5px;border-radius: 10px;
}
如果消息数量为 0,则隐藏 badge。这个状态可以通过定时 AJAX 轮询或 WebSocket 实时更新,甚至是伪造数据进行演示。为了演示效果,我加入一个简单的定时器模拟新消息到来:
setInterval(() => {const badge = document.querySelector('.icon-message .badge');const newCount = Math.floor(Math.random() * 10);badge.textContent = newCount;badge.style.display = newCount > 0 ? 'block' : 'none';
}, 5000);
接下来是“长按弹出二级菜单”。说实话,长按这个交互一开始我并没有打算加入,因为它不像点击那样天然地由浏览器事件支持。在 PC 上长按是没有语义意义的,只有在触屏设备上,才存在“长按 ≠ 点击”的语义分离。而为了模拟移动端的行为,我不得不实现一个“长按事件模拟器”。
它的基本原理其实很简单:当用户触发 touchstart 或 mousedown 时,记录下开始时间,并启动一个定时器,比如 500ms。如果在这个时间内用户抬起手指(触发 touchend 或 mouseup),说明只是一次普通点击,那就取消长按行为。如果 500ms 过去了,手指还在原地不动,那我们就可以认为用户是“长按了”。
于是我封装了这样一个函数:
function attachLongPress(targetElement, callback, duration = 500) {let timer = null;targetElement.addEventListener('touchstart', start, { passive: true });targetElement.addEventListener('touchend', cancel);targetElement.addEventListener('touchmove', cancel);targetElement.addEventListener('mousedown', start);targetElement.addEventListener('mouseup', cancel);targetElement.addEventListener('mouseleave', cancel);function start(e) {timer = setTimeout(() => {callback(e);}, duration);}function cancel() {if (timer) clearTimeout(timer);timer = null;}
}
用法也很简单:
const addTab = document.querySelector('[data-tab="add"]');
attachLongPress(addTab, () => {showSecondaryMenu();
});
当用户长按“添加”这个按钮时,会弹出一个二级菜单。这个二级菜单我是使用绝对定位和遮罩实现的,效果参考抖音、微信等 App 的交互风格。菜单从底部缓慢浮现,背景加一个半透明黑色遮罩,可以点击取消。
HTML 结构如下:
<div class="mask" id="mask"></div>
<div class="popup-menu" id="popupMenu"><div class="menu-item">拍摄</div><div class="menu-item">从相册选择</div><div class="menu-item cancel">取消</div>
</div>
CSS 动画我用了简单的 transform: translateY(...)
和 opacity
混合动画。整体效果很顺滑:
.mask {position: fixed;top: 0; left: 0;right: 0; bottom: 0;background: rgba(0,0,0,0.4);display: none;z-index: 99;
}.popup-menu {position: fixed;bottom: -200px;left: 0;right: 0;background: #fff;border-top-left-radius: 12px;border-top-right-radius: 12px;padding: 10px 0;text-align: center;z-index: 100;transition: all 0.3s ease;
}.popup-menu.active {bottom: 0;
}
JavaScript 逻辑如下:
function showSecondaryMenu() {const mask = document.getElementById('mask');const menu = document.getElementById('popupMenu');mask.style.display = 'block';setTimeout(() => {menu.classList.add('active');}, 20);mask.addEventListener('click', closeMenu);document.querySelector('.popup-menu .cancel').addEventListener('click', closeMenu);function closeMenu() {menu.classList.remove('active');setTimeout(() => {mask.style.display = 'none';}, 300);}
}
这个小小的长按交互看似简单,但实际上前后花了我一整天时间去调试。尤其是触发条件的精度控制,比如用户手指略微移动该不该取消长按?遮罩点击是否需要防止事件穿透?这些都必须考虑清楚,否则体验会打折。
经过这些努力,这个项目的功能已经趋于完整。但我并不满足,我还想在视觉上再精致一点。
我重新回头打磨了一下 SVG 图标。之前用的是 Iconfont,它虽然方便,但在精度和动画支持上不如原生 SVG。我把每个图标都换成了 inline SVG,并定义了专门的 viewBox、path 动画。例如选中时图标颜色渐变、路径平滑过渡。
示意 SVG 结构如下:
<svg class="icon" viewBox="0 0 24 24"><path d="M..." fill="currentColor"></path>
</svg>
通过控制 .tab-item.active .icon path 的颜色和 transform,我们可以实现更流畅的动画效果。
我还为每个 tab 添加了 hover 态、选中高亮的渐变背景,并对响应式进行了适配处理,比如在横屏模式下底栏缩小字体、缩短间距,在 iOS 安全区内添加 padding-bottom 等。
我甚至添加了暗色模式支持。只需用 media query:
@media (prefers-color-scheme: dark) {.tabbar {background: #1e1e1e;border-top: 1px solid #444;}.tab-item {color: #aaa;}.tab-item.active {color: #2da1ff;}
}
这一点点细节,让整个项目的质感提升了一个维度。
到了这个阶段,我突然意识到:这个小项目已经远远超出了“仿一个 UI”的范畴。它其实成了一个完整的交互系统练习。不仅考察布局功底、动画技巧、状态管理,还涉及可访问性、用户行为模式、设备兼容性等等。它逼迫我重新思考很多“理所当然”的前端习惯。
我决定将这个项目组件化,以便今后可以快速在其它项目中复用。我把 tabbar 写成了一个独立类,用纯 JavaScript 模拟组件的挂载和更新逻辑:
class TabBar {constructor(rootElement, options) {this.root = rootElement;this.tabs = options.tabs;this.onChange = options.onChange;this.render();this.bindEvents();}render() {this.root.innerHTML = this.tabs.map(tab => `<div class="tab-item" data-tab="${tab.key}"><span class="icon">${tab.icon}</span><span class="label">${tab.label}</span></div>`).join('');}bindEvents() {this.root.addEventListener('click', e => {const item = e.target.closest('.tab-item');if (item) {this.selectTab(item.dataset.tab);if (this.onChange) this.onChange(item.dataset.tab);}});}selectTab(key) {this.root.querySelectorAll('.tab-item').forEach(tab => {tab.classList.toggle('active', tab.dataset.tab === key);});}
}
使用方式也非常清晰:
new TabBar(document.querySelector('.tabbar'), {tabs: [{ key: 'home', label: '首页', icon: '🏠' },{ key: 'search', label: '发现', icon: '🔍' },{ key: 'add', label: '添加', icon: '➕' },{ key: 'message', label: '消息', icon: '💬' },{ key: 'profile', label: '我的', icon: '👤' },],onChange: tab => {console.log('切换到:', tab);}
});
当然,这还只是第一版组件化。后续我还会把它封装成 Web Component 或 Vue/React 组件,以适应不同项目架构。