此 Codelab 将教您如何在 Web 上构建类似 Instagram Stories 的体验。我们将逐步构建组件,从 HTML 开始,然后是 CSS,然后是 JavaScript。
请查看我的博文构建 Stories 组件,了解在构建此组件时进行的渐进增强。
设置
- 点击Remix to Edit使项目可编辑。
- 打开
app/index.html
。
HTML
我始终致力于使用语义 HTML。由于每个朋友可以拥有任意数量的故事,我认为为每个朋友使用 <section>
元素,为每个故事使用 <article>
元素是有意义的。不过,让我们从头开始。首先,我们需要一个用于 Stories 组件的容器。
将 <div>
元素添加到您的 <body>
<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
- 将每个子项设置为填充行轨道
- 使每个子项的宽度成为移动设备视口的宽度
Grid 将继续在新 100vw
宽的列中向右放置,直到它放置了标记中的所有 HTML 元素。

将以下 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 滚动捕捉点和overscroll-behavior部分中阅读有关此 CSS 的更多信息。
父容器和子容器都需要同意滚动捕捉,因此我们现在来处理它。将以下代码添加到 app/css/index.css
的底部
.user {
scroll-snap-align: start;
scroll-snap-stop: always;
}
您的应用尚无法工作,但下面的视频显示了启用和禁用 scroll-snap-type
时会发生什么。启用后,每次水平滚动都会捕捉到下一个故事。禁用后,浏览器使用其默认滚动行为。
这将使您滚动浏览您的朋友,但我们仍然有一个故事问题需要解决。
.user
让我们在 .user
部分中创建一个布局,将这些子故事元素整理到位。我们将使用一个方便的堆叠技巧来解决这个问题。我们本质上是在创建一个 1x1 网格,其中行和列具有相同的 Grid 别名 [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
属性,该属性允许我们指定多个背景图像。我们可以按顺序放置它们,以便我们的用户图片位于顶部,并在加载完成后自动显示。为了启用此功能,我们将图像 URL 放入自定义属性 (--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 技巧
- 背景图像 1 (
var(--bg)
) 是我们在 HTML 中内联传递的 URL - 背景图像 2 (
linear-gradient(to top, lch(98 0 0), lch(90 0 0))
是在 URL 加载时显示的渐变
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
指示浏览器应将这些交互视为触摸事件,这使浏览器无需尝试确定您是否在单击 URL
最后,让我们添加一些 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 的Easing 指南(滚动到加速缓动部分)中获得了自定义缓动函数 (cubic-bezier(0.4, 0.0, 1,1)
)。
如果您眼光敏锐,您可能已经注意到 pointer-events: none
声明,并且现在正在挠头。我想说这是迄今为止该解决方案的唯一缺点。我们需要这个,因为 .seen.story
元素将位于顶部并接收点击,即使它是不可见的。通过将 pointer-events
设置为 none
,我们将玻璃故事变成了一个窗口,并且不再窃取用户交互。权衡还不错,现在在我们的 CSS 中管理起来也不太难。我们没有处理 z-index
。我对现在的情况仍然感觉良好。
JavaScript
Stories 组件的交互对于用户来说非常简单:点击右侧前进,点击左侧后退。对用户来说简单的事情往往对开发者来说是艰苦的工作。不过,我们将处理很多事情。
设置
首先,让我们计算并存储尽可能多的信息。将以下代码添加到 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
}
监听器
我们现在有足够的逻辑来开始监听用户事件并指导它们。
鼠标
让我们首先监听 Stories 容器上的 '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>
元素上,我们就会退出并且不执行任何操作。如果它是一个 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')
})
故事导航
是时候解决故事的独特业务逻辑以及它们闻名于世的 UX 了。这看起来很笨重且棘手,但我认为如果您逐行阅读,您会发现它很容易理解。
首先,我们存储一些选择器,这些选择器可以帮助我们确定是滚动到朋友还是显示/隐藏故事。由于 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
}
}
}
试用一下
- 要预览站点,请按View App。然后按全屏
。
结论
这就是我需要的组件的总结。随意在其基础上构建,用数据驱动它,总的来说,让它成为您的!