Codelab:构建故事组件

此 Codelab 会教您如何打造类似 Instagram 快拍的体验 。我们将继续构建组件,先从 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"> 容器,我们需要一个水平滚动容器。 我们可以通过以下方式实现这一点:

  • 将容器设置为 Grid
  • 设置每个子项以填充行轨道
  • 使每个子元素的宽度与移动设备视口的宽度相同

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

<ph type="x-smartling-placeholder">
</ph> 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 和滚动行为 部分内容。

父级容器和子级都需同意滚动贴靠,因此 我们现在来处理。将以下代码添加到 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;
}

现在,我们没有绝对定位、浮点数或其他 一个元素离开了流,我们仍然在流中。而且,它几乎几乎不需要任何代码, 看这个!这在视频和博文中会详细介绍。

.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 网页技巧,称为“加载 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 类将添加到需要退出的故事中。 我有自定义加/减速函数 (cubic-bezier(0.4, 0.0, 1,1)) (来自 Material Design 的 Easing) 指南(滚动到加速加/减速选项部分)。

如果您敏锐的目光,或许已经注意到 pointer-events: none 而且你却不知该如何着手。我想说这是 该解决方案的缺点。我们需要它,因为 .seen.story 元素 将位于顶部并接收点按,即使不可见。通过将 pointer-eventsnone,我们将玻璃故事变成窗户, 更多用户互动权衡起来还算不错,管理起来也没那么难 到这里就结束了我们现在没有在忙于处理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
    }
  }
}

试试看

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

总结

以上就是对我对该组件的需求的总结。随意构建 它可以通过数据驱动它,并且总体而言,它应该属于你自己!