iPhone X 环绕刘海滚动列表

iPhone X 面市后,其异形屏给交互设计提供了更多想象的空间。在 Twitter 上,这位推友就针对刘海设计了列表环绕刘海滚动的效果。

最近在 codepen 上看到已经有人实现了这个 demo:https://codepen.io/davvidbaker/pen/KXgPyG,本文将图文结合分析一下实现这个效果的逻辑。

DOM 结构


1
2
3
4
5
6
7
8
<div class="outer">
<div class="inner">
<ul>
<li>北京</li>
</ul>
</div>
<div class="notch"></div>
</div>

DOM 结构比较简单,主要包括列表 ul,其滚动容器为 class="inner" 的 DIV;刘海是 class="notch" 的 DIV。

滚动逻辑

为了实现列表环绕刘海滚动,需要在滚动事件中,计算处理每一行列表 X 轴方向的位置移动。

如上图列表向下滚动,上方的列表需要向右位移 notch 宽度;对应的,下方的列表需要向左位移 notch 宽度。我们需要对移动进行相应的计算处理,使位移线性变化,从而在滚动时,环绕效果自然。

distFromTopdistFromBottom

列表在上下滚动过程中分为两部分:

  • 向下滚动:上方列表向右移动进入刘海区域,下方列表向左移动离开刘海区域
  • 向上滚动:上方列表向左移动离开刘海区域,下方列表向右移动进入刘海区域

这两部分刚好是相对应的,此处我们只分析上方列表移动进入/离开刘海区域,即可以了解滚动过程中的逻辑处理。首先介绍两个变量:distFromTopdistFromBottom

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置,返回值是一个 DOMRect 对象。DOMRect 对象包含了一组用于描述边框的只读属性 —— left、top、right 和 bottom,单位为像素。除了 width 和 height 外的属性都是相对于视口的左上角位置而言的,如下图:

1
2
3
4
let notchRect = notch.getBoundingClientRect()
let itemRect = item.getBoundingClientRect()
let distFromTop = itemRect.bottom - notchRect.top
let distFromBottom = itemRect.top - notchRect.bottom

这两个变量表征当前这一行列表距离上下刘海边界的位置,在滚动过程中根据 distFromTopdistFromBottom 变量触发不同的位移逻辑。

位移逻辑

分析上方列表移动进入/离开刘海区域时,列表位移的逻辑。

1️⃣ 阈值:Threshold
在刘海边界上下 Threshold 区域定义为位移区域,列表进入这一区域即需要进行计算位移量,检测是否进入这一区域的逻辑判断为:

1
Math.abs(distFromTop) <= Threshold

2️⃣ 计算位移量
考虑两个边界情况:

  • distFromTop 等于 - Threshold,即列表开始进入刘海区域
    此时位移量为 0
  • distFromTop 等于 + Threshold,即列表完全进入刘海区域
    此时位移量为刘海宽度 NotchWidth

这个变换过程可以通过线性插值来完成,根据 distFromTop 变量计算在 Y 轴方向的比例关系,从而得到 X 轴方向的位移量。

关于线性插值参考:维基百科:线性插值

在此处,我们可以简化理解,如下图

计算公式如下:

1
let lerp = (v0, v1, t) => v0 + (v1 - v0) * t

在我们分析的这个情形:上方列表向右移动进入刘海区域,对应的调用为:

1
x = lerp(0, NotchWidth, (distFromTop + Threshold) / (Threshold * 2))

其他情况,位移量计算分别为:

  • 在刘海区域内滚动
    判断条件:distFromTop > 0 && distFromBottom < - Threshold
    位移量始终为:刘海宽度 NotchWidth
  • 下方列表移动进入/离开刘海区域
    判断条件:Math.abs(distFromBottom) <= Threshold
    位移量始终为:x = lerp(NotchWidth, 0, (distFromBottom + Threshold) / (Threshold * 2))
  • 其他
    位移量始终为:0

完整代码

最终实现效果:https://codepen.io/yingshandeng/pen/JrJRBR?editors=1010

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
<style>
html,
body {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
background: #E3F3FD;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.outer {
position: relative;
width: 640px;
height: 310px;
min-width: 640px;
min-height: 310px;
background-color: white;
border-radius: 45px;
-webkit-box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.10),
inset 0 -5px 20px 0 rgba(0, 0, 0, 0.08);
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.10),
inset 0 -5px 20px 0 rgba(0, 0, 0, 0.08);
}
.inner {
position: absolute;
width: 610px;
height: 280px;
margin: 15px;
border-radius: 30px;
background-color: #EBEBEB;
overflow-x: hidden;
overflow-y: scroll;
}
.notch {
position: absolute;
left: 15px;
top: 79px;
width: 22px;
height: 152px;
border-top-right-radius: 17px;
border-bottom-right-radius: 17px;
background-color: #fff;
}
ul {
list-style: none;
margin: 0 auto;
padding-left: 10px;
}
li {
padding: 6px 5px;
border-bottom: 1px solid #dadada;
transform-origin: center left;
}
*::-webkit-scrollbar {
visibility: hidden;
}
</style>
</head>
<body>
<div class="outer">
<div class="inner">
<ul>
<li>北京</li>
<li>天津</li>
<li>河北</li>
<li>山西</li>
<li>内蒙古</li>
<li>辽宁</li>
<li>吉林</li>
<li>黑龙江</li>
<li>上海</li>
<li>江苏</li>
<li>浙江省</li>
<li>安徽</li>
<li>福建</li>
<li>江西</li>
<li>山东</li>
<li>河南</li>
<li>湖北</li>
<li>湖南</li>
<li>广东</li>
<li>广西</li>
<li>海南</li>
<li>重庆</li>
<li>四川</li>
<li>贵州</li>
<li>云南</li>
<li>西藏</li>
<li>陕西</li>
<li>甘肃省</li>
<li>青海</li>
<li>宁夏</li>
<li>新疆</li>
<li>台湾</li>
<li>香港特别行政区</li>
<li>澳门</li>
</ul>
</div>
<div class="notch"></div>
</div>
<script>
let inner = document.querySelector('.inner')
let items = document.querySelectorAll('li')
let notch = document.querySelector('.notch')
let notchRect = notch.getBoundingClientRect()
inner.addEventListener('scroll', () => {
window.requestAnimationFrame(scrollDection)
})
window.addEventListener('resize', () => {
notchRect = notch.getBoundingClientRect()
})
const Threshold = 10
const NotchWidth = 30
let scrollDection = () => {
let item
for (item of items) {
let itemRect = item.getBoundingClientRect()
let distFromTop = itemRect.bottom - notchRect.top
let distFromBottom = itemRect.top - notchRect.bottom
let x
if (Math.abs(distFromTop) <= Threshold) {
x = lerp(0, NotchWidth, (distFromTop + Threshold) / (Threshold * 2))
} else if (distFromTop > 0 && distFromBottom < - Threshold) {
x = NotchWidth
} else if (Math.abs(distFromBottom) <= Threshold) {
x = lerp(NotchWidth, 0, (distFromBottom + Threshold) / (Threshold * 2))
} else {
x = 0
}
item.style.transform = `translateX(${x}px)`
}
}
let lerp = (v0, v1, t) => v0 + (v1 - v0) * t
scrollDection()
</script>
</body>
</html>