1. 原理
  2. Dom结构
  3. Js代码
  4. 浏览器兼容
  5. 完整代码
  6. 为什么不用background-image和position实现
  7. 参考

(动画效果)跟随下拉放大图像

a.gif

原理

监控用户touch事件, 根据移动距离修改图像的高度及缩放尺寸.
图像的放大要配合修改height和scale来进行,不能单独使用scale.因为缩放并不会占用文本流位置,这会导致图像覆盖下面的内容.

alice.gif

Dom结构

用.img-container元素包裹图像,搭配height和overflow来控制图片的显示高度.
然后用position将图像定位到.img-container元素的中心位置.
用户向下拖动页面时,根据移动距离分别修改.img-container的height和.img的scale.

html代码:

1
2
3
4
5
<body>
<div class="img-container">
<img class="img" src="./2.png">
</div>
</body>

css代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.img-container {
position: relative;
height: 250px;
overflow: hidden;
background-color: cornflowerblue;
}

img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}

Js代码

用户拖动移动端页面时,会分别触发touchstart touchmove touchend事件,这三个事件分别对应手指按下 拖动 抬起.
通过访问event.targetTouches可获取Touch对象.这是一个只读的列表,包含当前所有手指触摸点对应的Touch对象.

在touchstart事件中(手指点击时), 保存手指按下时的初始位置.

1
2
3
4
5
6
let startY
window.addEventListener('touchstart', (e) => {
// 获取触摸点相对于页面可视区域的高度
const y = e.targetTouches[0].clientY
startY = y
})

在touchmove事件中(手指滑动时), 根据startY和y(当前触摸点位置)判断用户滑动方向.
如果已经到顶了还向下滑动, 则缩放图像.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
window.addEventListener('touchmove', (e) => {
const y = e.targetTouches[0].clientY
// 获取页面的滚动距离
const scrollTop = imgContEl.scrollTop
// 判断手指移动方向, 向下移动时小于0,反之大于零
const moveDis = y - startY
// 滚动到顶时继续下拉,则放大图像
if (window.scrollY <= 0 && moveDis > 0) {
// 增加.img-container元素的高度
const height = easeOutCubic(moveDis, imgHeight, 130, 300)
imgContEl.style.height = height + 'px'
// 增加.img元素的scale值来放大图片
const scale = easeOutCubic(moveDis, 1, 0.2, 300)
imgEl.style.transform = `translate(-50%, -50%) scale(${scale})`
}
})

easeOutCubic是一个三次方ease-out缓动函数,可以制造类似橡皮筋的效果.
代码如下:

1
2
3
4
5
const easeOutCubic = (t, b, c, d) => {
t /= d;
t--;
return c * (t * t * t + 1) + b;
};

在touchend事件中(用户松手时), 还原图像大小.
因为不需要根据手指的移动来计算,所以直接用css来过渡缩小时的动画效果.
js代码:

1
2
3
4
5
6
7
8
window.addEventListener('touchend', (e) => {
// 还原样式
imgContEl.style.height = imgHeight + 'px'
imgEl.style.transform = 'translate(-50%, -50%)'
// 添加class: rebound触发回弹效果
imgContEl.classList.add('rebound')
imgEl.classList.add('rebound')
})

css代码:

1
2
3
.rebound {
transition: 0.2s ease-out;
}

除了ease-out, 还可以用cubic-bezier制造回弹效果.

1
2
3
.rebound {
transition: 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}

浏览器兼容

移动端浏览器和桌面端不同,有自己的下拉效果.
例如下拉刷新, 或是ios平台浏览器拉到头时的弹动效果.
这些都和图像放大有冲突, 因此要阻止触发这些事件.

在一般元素绑定的事件内, 可以用event.preventDefault()阻止触发浏览器自带的事件. 但是body不行,如果在body或window绑定的事件中调用preventDefault, 多半会报错:

Unable to preventDefault inside passive event listener due to target being treated as passive.

这和addEventListener的第三个参数有关, 这个参数除了传入Boolean之外,还可以传入对象,例如:

1
2
3
4
document.body.addEventListener('touchmove',calback, {
capture: false,
passive: false
})

关键点就在于第三个参数中的passive属性.

当passive为true时, 浏览器会忽略calback中所有的preventDefault调用.
这是一个优化.在这之前,浏览器需要在等待touchmove事件中的回调函数执行完成, 才能滚动页面.
添加passive: true后, 浏览器可以立刻触发滚动,无需等待回调函数.

passive的默认值是false,但有三个例外: Window document document.body.这三个元素的passive值默认为true.

如果想在这三个元素中调用preventDefault,则需要手动设置passive属性为false.

1
window.addEventListener('touchmove', (e) => {}, {passive: false})

完整代码

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>drop down</title>
<style>
body {
margin: 0;
line-height: 1.5;
}

.img-container {
position: relative;
height: 250px;
overflow: hidden;
background-color: cornflowerblue;
}

img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
}

.rebound {
transition: 0.2s ease-out;
}

.article {
margin: 8px;
height: 100vh;
}
</style>
</head>

<body>
<div class="img-container">
<img class="img" src="./2.png">
</div>
<div class="article">
Alice was beginning to get very tired of sitting by her sister on the bank, and of having
nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or
conversations in it, `and what is the use of a book,' thought Alice `without pictures or conversation?'
</div>

<script>
const easeOutCubic = (t, b, c, d) => {
t /= d;
t--;
return c * (t * t * t + 1) + b;
};
const imgContEl = document.querySelector('.img-container')
const imgEl = document.querySelector('.img')
const imgHeight = imgContEl.clientHeight
let startY
window.addEventListener('touchstart', (e) => {
const y = e.targetTouches[0].clientY
if (y < imgHeight) {
return
}
startY = y
imgContEl.classList.remove('rebound')
imgEl.classList.remove('rebound')
})
window.addEventListener('touchmove', (e) => {
if (!startY) {
return
}
e.preventDefault()
const y = e.targetTouches[0].clientY
const scrollTop = imgContEl.scrollTop
const moveDis = y - startY
if (window.scrollY <= 0 && moveDis > 0) {
const height = easeOutCubic(moveDis, imgHeight, 130, 300)
imgContEl.style.height = height + 'px'
const scale = easeOutCubic(moveDis, 1, 0.2, 300)
imgEl.style.transform = `translate(-50%, -50%) scale(${scale})`
}
}, {
passive: false
})
window.addEventListener('touchend', (e) => {
startY = null
imgContEl.style.height = imgHeight + 'px'
imgEl.style.transform = 'translate(-50%, -50%)'
imgContEl.classList.add('rebound')
imgEl.classList.add('rebound')
})
</script>
</body>

</html>

为什么不用background-image和position实现

还有一种实现结构是利用背景配合background-position和background-size来定位和居中图片.
这样就不需要套一个元素, 操作更方便.
但这种方法有个缺点, 下拉时背景图像可能会抖动.
原因可能是元素尺寸变化不大时,有些浏览器不会实时计算背景图像的位移.

拉到头时背景有轻微的抖动:
ezgif.com-optimize (1).gif
利用背景实现的下拉放大:

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>drop down</title>
<style>
body {
margin: 0;
}

.img-container {
height: 250px;
background-image: url('./2.png');
background-size: 100% auto;
background-position: center;
background-repeat: no-repeat;
background-color: cornflowerblue;
}

.rebound {
transition: 0.2s ease-out;
}

.article {
height: 100vh;
}
</style>
</head>

<body>
<div class="img-container">
</div>
<div class="article"></div>
<script>
const easeOutCubic = (t, b, c, d) => {
t /= d;
t--;
return c * (t * t * t + 1) + b;
};
const imgContEl = document.querySelector('.img-container')
const imgHeight = imgContEl.clientHeight
let startX, startY
window.addEventListener('touchstart', (e) => {
const y = e.targetTouches[0].clientY
if (y < imgHeight) {
return
}
startY = y
imgContEl.classList.remove('rebound')
})
window.addEventListener('touchmove', (e) => {
if (!startY) {
return
}
e.preventDefault()
const y = e.targetTouches[0].clientY
const scrollTop = imgContEl.scrollTop
const moveDis = y - startY
if (window.scrollY <= 0 && moveDis > 0) {
const height = easeOutCubic(moveDis, imgHeight, 130, 300)
imgContEl.style.height = height + 'px'
const scale = easeOutCubic(moveDis, 100, 20, 300)
imgContEl.style.backgroundSize = `${scale}% auto`
}
}, {
passive: false
})
window.addEventListener('touchend', (e) => {
startY = null
imgContEl.style.height = imgHeight + 'px'
imgContEl.style.backgroundSize = '100% auto'
imgContEl.classList.add('rebound')
})
</script>
</body>

</html>

参考

关于passive event listener的一次踩坑 - 掘金
EventTarget.addEventListener() - Web API 接口参考 | MDN
TouchEvent.targetTouches - Web APIs | MDN
Touch.clientX - Web API 接口参考 | MDN