You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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. 照片区域滚动时,同步更新滑块的高亮位置
  2. 点击滑块的年份/月份时,滚动到对应的照片区域

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.28 23:43:12