Codelab:构建故事组件

在此 Codelab 中,您将学习如何打造类似 Instagram 快拍 (Web) 的体验。我们将逐步构建组件,从 HTML 开始,然后是 CSS,最后是 JavaScript。

请查看我的博文构建短片故事组件,了解构建此组件时进行的渐进式增强功能。

初始设置

  1. 点击 Remix to Edit 使项目可修改。
  2. 打开 app/index.html

HTML

我一直致力于使用语义 HTML。由于每个好友可以有任意数量的故事,因此我认为最好为每个好友使用 <section> 元素,并为每个故事使用 <article> 元素。我们还是从头开始吧。首先,我们需要为“故事”组件创建一个容器

<body> 添加 <div> 元素:

<div class="stories">

</div>

添加一些 <section> 元素来表示好友:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

添加一些 <article> 元素来表示故事:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • 我们使用图片服务 (picsum.com) 来帮助设计故事原型。
  • 每个 <article> 上的 style 属性是占位符加载方法的一部分,我们将在下一部分中对此进行详细介绍。

CSS

我们的内容随时可彰显您的时尚品味。让我们把这些骨骼变成大家乐于与之互动的东西。今天,我们将以移动优先的方式工作。

.stories

对于 <div class="stories"> 容器,我们需要一个水平滚动容器。为此,我们采取了以下措施:

  • 将容器设为网格
  • 将每个子项设置为填充行轨道
  • 将每个子级的宽度设为移动设备视口的宽度

网格会继续将宽度为 100vw 的新列放置在前一个列的右侧,直到将所有 HTML 元素都放入标记中。

Chrome 和开发者工具已打开,带有显示全宽布局的网格可视化内容
Chrome 开发者工具显示网格列溢出,使之成为水平滚动条。

将以下 CSS 添加到 app/css/index.css 的底部:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

现在,既然内容延伸到了视口以外,是时候告知容器如何处理它了。将突出显示的代码行添加到您的 .stories 规则集中:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

我们希望实现水平滚动,因此我们将 overflow-x 设为 auto。当用户滚动时,我们希望该组件轻轻地停留在下一个故事上,因此我们将使用 scroll-snap-type: x mandatory。如需详细了解此 CSS,请参阅我的博文的 CSS 滚动贴靠点overscroll-behavior 部分。

父级容器和子级都同意滚动贴靠,因此,我们现在来处理一下。将以下代码添加到 app/css/index.css 底部:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

您的应用尚无法运行,但下面的视频展示了启用和停用 scroll-snap-type 后会发生什么情况。启用后,每个水平滚动条会贴靠到下一个故事。停用后,浏览器会使用其默认滚动行为。

这样,您就可以滚动浏览朋友们,但我们仍需解决故事方面的问题。

.user

让我们在 .user 部分创建一个布局,将这些子故事元素整理到适当位置。我们将使用一种便捷的堆叠技巧来解决这个问题。实际上,我们是在创建一个 1x1 网格,其中行和列具有相同的网格别名 [story],并且每个故事网格项都将尝试声明该空间,从而形成一个堆栈。

将突出显示的代码添加到您的 .user 规则集中:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

将以下规则集添加到 app/css/index.css 底部:

.story {
  grid-area: story;
}

现在,如果没有绝对定位、浮点数或其他会将元素移出 flow 的布局指令,我们仍然会处于流畅状态。而且,它就像任何代码一样,看看它!本文在视频和博文中对此进行了更详细的介绍。

.story

现在,我们只需为故事项本身设置样式。

我们之前提到过,每个 <article> 元素上的 style 属性都是占位符加载方法的一部分:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

我们将使用 CSS 的 background-image 属性,该属性允许指定多张背景图片。我们可以按顺序排列它们,这样用户图片就在顶部,在加载完成后会自动显示。为了实现这一点,我们将图片网址放入自定义属性 (--bg) 中,然后在 CSS 中使用该网址,与加载占位符放在一起。

首先,我们来更新 .story 规则集,以便在完成加载后将渐变替换为背景图片。将突出显示的代码添加到您的 .story 规则集中:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

background-size 设置为 cover 可确保视口中没有空白区域,因为我们的图片将被填满。通过定义 2 张背景图片,我们能够实现一种简洁的 CSS Web 技巧,称为“加载 Tombstone”

  • 背景图片 1 (var(--bg)) 是我们在 HTML 中以内嵌方式传递的网址
  • 背景图片 2(linear-gradient(to top, lch(98 0 0), lch(90 0 0)) 是网址加载时显示的渐变色)

图片下载完毕后,CSS 会自动将渐变效果替换为图片。

接下来,我们将添加一些 CSS 以移除某些行为,从而释放浏览器空间,加快移动速度。 将突出显示的代码添加到您的 .story 规则集中:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none 可防止用户意外选择文本
  • touch-action: manipulation 指示浏览器将这些互动视为触摸事件,这样可以让浏览器不必再尝试确定您是否要点击某个网址

最后,我们添加一点 CSS 以动画形式呈现故事之间的过渡效果。将突出显示的代码添加到您的 .story 规则集中:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

系统会将 .seen 类添加到需要退出的故事中。我从 Material Design 的加/减速指南中找到了自定义加/减速函数 (cubic-bezier(0.4, 0.0, 1,1))(滚动到加速加/减速部分)。

请注意,您可能会注意到 pointer-events: none 声明,并且现在开始抓起脑子。我想,这是目前为止解决方案的唯一缺点。我们需要它,因为 .seen.story 元素位于顶部并可以接收点按,即使它不可见。通过将 pointer-events 设置为 none,我们可以将玻璃故事转变为窗口,并且不会再窃取任何用户互动。权衡还不错,目前在 CSS 中管理也没那么难。我们不会在处理z-index的问题。我仍然感觉还好。

JavaScript

“短片故事”组件的交互方式对用户来说非常简单:点按右侧即可前进,点按左侧即可返回。对用户来说,简单的工作对于开发者来说往往是一项艰苦的工作。不过,我们会处理很多事情。

初始设置

首先,让我们计算和存储尽可能多的信息。将以下代码添加到 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

JavaScript 的第一行会获取并存储对主要 HTML 元素根目录的引用。下一行会计算元素中间的位置,以便我们确定点按是前进还是后退。

状态

接下来,我们创建一个小对象,其中包含一些与逻辑相关的状态。在本示例中,我们只对当前故事感兴趣。在我们的 HTML 标记中,我们只需获取第一位好友及其最近报道,便可访问该文件。将突出显示的代码添加到您的 app/js/index.js 中:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

监听器

现在,我们有了足够的逻辑,可以开始监听用户事件并引导用户事件了。

老鼠

我们首先监听故事容器上的 'click' 事件。将突出显示的代码添加到 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

如果发生了点击,但该点击不在 <article> 元素上,我们将解放,不执行任何操作。如果是文章,我们会使用 clientX 抓取鼠标或手指的水平位置。我们尚未实现 navigateStories,但其采用的参数指定了我们需要朝向的方向。如果用户位置大于中间值,我们知道需要转到 next,否则需要转到 prev(上一个)。

键盘

现在,我们来监听键盘按下操作。如果按向下箭头,则会转到 next。如果是向上箭头,我们会转到 prev

将突出显示的代码添加到 app/js/index.js

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

故事导航

是时候开始处理故事的独特业务逻辑以及这些故事闻名的用户体验了。这看起来很厚重,也很棘手,但我认为如果您逐行阅读,会发现它非常易于理解。

我们预先存储了一些选择器,可帮助我们决定是滚动到好友位置还是显示/隐藏报道。由于我们要使用 HTML,因此将查询其是否存在好友(用户)或故事(故事)。

这些变量可以帮助我们回答诸如以下问题:“给定故事 x,‘下一个’是来自同一个朋友的另一个故事,还是另一个朋友的?”我借助于我们构建的树形结构 触及了家长和他们的子女

将以下代码添加到 app/js/index.js 底部:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

以下是我们的业务逻辑目标,尽可能接近自然语言:

  • 确定如何处理点按操作
    • 如果有下一个/上一个故事:显示那个故事
    • 如果这是该好友的最后一个故事/第一个故事:显示一位新朋友
    • 如果没有任何故事可以朝这个方向推进:什么都不做
  • 将最新的最新故事保存到state

将突出显示的代码添加到您的 navigateStories 函数中:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

试试看

  • 如需预览网站,请按查看应用,然后按全屏 全屏

总结

以上就是对组件的需求。您可以在此基础上构建模型,利用数据驱动模型,并且一般来说就是实现自己的目标!