如何在Angular/React中实现类似Google Photos的垂直照片时间轴滑块?
实现类似Google Photos的垂直年份-月份联动滑块方案
我完全懂这种找不到合适开源库的烦恼——Google Photos的那个垂直滑块体验确实很丝滑,而且逻辑细节挺多的。下面我就分享一套纯JavaScript/TypeScript的实现思路,不管你用Angular还是React,都能把这些核心逻辑套进去:
第一步:先理清楚核心数据结构
首先得把你的照片数据按「年份-有照片的月份」提前分组,还要给每个年份、每个月份标记好对应的照片起始/结束索引——这是后面滚动和点击联动的基础。举个TypeScript的例子:
interface MonthEntry { month: number; // 1-12 photoCount: number; startIndex: number; // 该月份第一张照片在总列表里的位置 endIndex: number; // 该月份最后一张照片的位置 } interface YearGroup { year: number; months: MonthEntry[]; startIndex: number; // 该年份第一张照片的位置 endIndex: number; // 该年份最后一张照片的位置 } // 示例数据(实际要根据你的照片列表生成) const photoGroups: YearGroup[] = [ { year: 2024, months: [ { month: 3, photoCount: 12, startIndex: 0, endIndex: 11 }, { month: 5, photoCount: 8, startIndex: 12, endIndex: 19 } ], startIndex: 0, endIndex: 19 }, { year: 2023, months: [ { month: 10, photoCount: 20, startIndex: 20, endIndex: 39 }, { month: 12, photoCount: 15, startIndex: 40, endIndex: 54 } ], startIndex: 20, endIndex: 54 } ];
第二步:渲染垂直滑块UI
滑块的结构很简单:垂直排列的年份标题,每个年份下面对应12个「月份小点」——只有有照片的月份才显示可点击的小点(或者用不同样式区分)。纯JS的渲染逻辑大概是这样:
function renderYearSlider(photoGroups) { const sliderContainer = document.getElementById('year-slider'); sliderContainer.innerHTML = ''; // 清空容器 photoGroups.forEach(group => { // 创建年份标题 const yearTitle = document.createElement('div'); yearTitle.className = 'year-title'; yearTitle.textContent = group.year; yearTitle.dataset.year = group.year; // 存年份数据用于后续交互 sliderContainer.appendChild(yearTitle); // 创建月份小点容器 const monthDotsWrapper = document.createElement('div'); monthDotsWrapper.className = 'month-dots-wrapper'; // 生成12个月份位置 for (let month = 1; month <= 12; month++) { const dot = document.createElement('span'); dot.className = 'month-dot'; // 给有照片的月份标记特殊样式和数据 const hasPhotos = group.months.some(m => m.month === month); if (hasPhotos) { dot.classList.add('has-photos'); dot.dataset.year = group.year; dot.dataset.month = month; } monthDotsWrapper.appendChild(dot); } sliderContainer.appendChild(monthDotsWrapper); }); }
第三步:实现滚动联动核心逻辑
这是最关键的部分,要做两件事:
- 照片区域滚动时,同步更新滑块的高亮位置
- 点击滑块的年份/月份时,滚动到对应的照片区域
1. 滚动到指定位置的函数
function scrollToTarget(group: YearGroup, targetMonth?: number) { const photoContainer = document.getElementById('photo-container'); let targetScrollTop = 0; const photoItemHeight = 320; // 你实际的照片项高度,或者用DOM计算 // 如果指定了月份,定位到该月份的第一张照片 if (targetMonth) { const monthEntry = group.months.find(m => m.month === targetMonth); targetScrollTop = monthEntry?.startIndex * photoItemHeight || group.startIndex * photoItemHeight; } else { // 否则定位到年份的第一张照片 targetScrollTop = group.startIndex * photoItemHeight; } // 平滑滚动(可选,根据需求调整) photoContainer.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); }
2. 监听照片区域滚动,更新滑块高亮
注意要加防抖,避免滚动时频繁计算:
let scrollTimeout: number; function setupScrollSync(photoGroups) { const photoContainer = document.getElementById('photo-container'); photoContainer.addEventListener('scroll', () => { // 防抖处理,减少计算次数 clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { const scrollTop = photoContainer.scrollTop; const photoItemHeight = 320; const currentPhotoIndex = Math.floor(scrollTop / photoItemHeight); // 找到当前照片所属的年份和月份 let activeYear: number | null = null; let activeMonth: number | null = null; for (const group of photoGroups) { if (currentPhotoIndex >= group.startIndex && currentPhotoIndex <= group.endIndex) { activeYear = group.year; // 进一步匹配月份 for (const month of group.months) { if (currentPhotoIndex >= month.startIndex && currentPhotoIndex <= month.endIndex) { activeMonth = month.month; break; } } break; } } // 更新滑块高亮状态 document.querySelectorAll('.year-title.active, .month-dot.active').forEach(el => el.classList.remove('active')); if (activeYear) { document.querySelector(`.year-title[data-year="${activeYear}"]`)?.classList.add('active'); if (activeMonth) { document.querySelector(`.month-dot[data-year="${activeYear}"][data-month="${activeMonth}"]`)?.classList.add('active'); } } }, 50); // 50ms防抖,可根据性能调整 }); }
3. 绑定滑块的点击事件
function setupSliderClickHandlers(photoGroups) { // 年份标题点击事件 document.querySelectorAll('.year-title').forEach(title => { title.addEventListener('click', () => { const year = parseInt(title.dataset.year); const targetGroup = photoGroups.find(g => g.year === year); if (targetGroup) scrollToTarget(targetGroup); }); }); // 月份小点点击事件 document.querySelectorAll('.month-dot.has-photos').forEach(dot => { dot.addEventListener('click', () => { const year = parseInt(dot.dataset.year); const month = parseInt(dot.dataset.month); const targetGroup = photoGroups.find(g => g.year === year); if (targetGroup) scrollToTarget(targetGroup, month); }); }); }
第四步:框架适配小提示
如果用Angular或React,把这些逻辑封装成组件就行:
- React:用
useState管理当前激活的年份/月份,useEffect绑定滚动和点击事件,渲染部分用map循环生成年份和月份节点。 - Angular:用
@Component创建滑块组件,通过@Input接收photoGroups数据,用*ngFor渲染节点,用HostListener监听滚动事件,点击事件用(click)绑定。
最后一些优化建议
- 动态照片高度:如果照片高度不固定,不要用固定值计算,而是提前获取每个照片项的
offsetTop存在数据里,或者实时计算。 - 懒加载兼容:如果照片是懒加载的,要在加载完成后更新对应的索引数据,避免定位错误。
- 移动端适配:确保触控滚动时联动流畅,测试
touchmove事件的兼容性(不过现代浏览器的scroll事件已经支持触控)。
内容的提问来源于stack exchange,提问作者Aakash Kumar
相关产品推荐
相关产品推荐

