目录
上一篇博客 《组件化实战1:组件知识和基础轮播组件》介绍的轮播组件虽然有两个功能,但无法集成到一起,也存在一些小问题的,需要加入动画和手势,动画和手势是开发组件所需要的底层能力
1 动画
1.1 动画分类和实现方式
1.1.1 动画分类
动画分为属性动画(属性动态变化)和帧动画(图片动态变化),浏览器中展现的多是属性动画
1.1.2 动画的实现方式
- setInterval:存在时间积压的可能,不可控,不推荐
setInterval(()=>{}, 16) // 16ms为浏览器的一帧时间
- setTimeout
const tick = ()=>{
// 处理逻辑...
setTimeout(()=>{}, 16)
}
- requestAnimationFrame:请求在浏览器下一帧执行回调函数
const tick = ()=>{
// 处理逻辑...
requestAnimationFrame(tick)
}
1.2 时间线
可以理解成管理动画的一个工具,可以实现动画的添加、删除、开始、暂停、重播等
1.2 设计时间线的更新
目的是可以动态地给时间线添加动画:
- 当动画的开始时间早于时间线的开始时间时,TICK中流逝的时间是相对于时间线开始时间;
- 当动画的开始时间晚于时间线的开始时间时,TICK中流逝的时间是相对于动画开始时间
1.3 给时间线添加暂停和启动功能
- 暂停功能需要使用cancelAnimationFrame取消掉前面requestAnimationFrame返回的handler
- resume功能需要记录暂停后到resume的累计暂停时间并在resume时减去该时间
1.4 完善其它功能
- 增加delay功能
- 补全reset功能(将Timeline的所有属性重置)
1.5 时间线部分代码
const TICK = Symbol("tick")
const TICK_HANDLER = Symbol("tick-handler")
const ANIMATIONS = Symbol("animations")
const START_TIME = Symbol("start-time")
const PAUSE_START = Symbol("pause-start")
const PAUSE_TIME = Symbol("pause-time")
export class Timeline {
constructor() {
this[ANIMATIONS] = new Set() //使用Symbol作为属性可以防止外部私自访问
this[START_TIME] = new Map()
this.state = "inited"
this.animeRunTime = 0 // 时间线中动画的有效运行时间
}
add(animation) {
this[ANIMATIONS].add(animation)
this[START_TIME].set(animation, Date.now()+animation.delay)
}
start() {
if(this.state !== "inited")
return // start前必须为intied状态,否则不处理
this.state = "started"
let startTime = Date.now()
this[PAUSE_TIME] = 0
this[TICK] = () => {
// console.log("tick")
let now = Date.now()
// let t = Date.now() - startTime
for(let animation of this[ANIMATIONS]) {
let t
// 确定时间流逝的参照系
if(this[START_TIME].get(animation) < startTime)
t = now - startTime - this[PAUSE_TIME] - animation.delay
else
t = now - this[START_TIME].get(animation) - this[PAUSE_TIME] - animation.delay
if(animation.duration < t) {
this[ANIMATIONS].delete(animation)
t = animation.duration
}
this.animeRunTime = t;
if(t > 0)
animation.receive(t)
}
this[TICK_HANDLER] = requestAnimationFrame(this[TICK]) // 通过递归调用的方式让时间线不断向前运行
}
this[TICK]()
}
getAnimeRunTime(){
return this.animeRunTime
}
pause() {
if(this.state !== "started")
return
this.state = "paused"
this[PAUSE_START] = Date.now()
if(this[TICK_HANDLER])
cancelAnimationFrame(this[TICK_HANDLER])
}
resume() {
if(this.state !== "paused")
return
this.state = "started"
this[PAUSE_TIME] += Date.now() - this[PAUSE_START] // 注意是累加,否则第二次以后会有跳变现象
this[TICK]()
}
reset() {
this.pause()
this.state = "inited"
this.animeRunTime = 0
this[PAUSE_TIME] = 0
this[PAUSE_START] = 0
this[ANIMATIONS] = new Set() //使用Symbol作为属性可以防止外部私自访问
this[START_TIME] = new Map()
this[TICK_HANDLER] = null
}
}
1.6 动画部分代码
export class Animation{
constructor(object, property, startVal, endVal, duration, delay, timingFunction, template) {
this.timingFunction = timingFunction || (v=>v)
this.template = template || (v=>v)
this.object = object
this.property = property
this.startVal = startVal
this.endVal = endVal
this.duration = duration
this.delay = delay
}
// 执行属性根据时间变化
receive(time) {
let progress = this.timingFunction(time/ this.duration)
let range = this.endVal - this.startVal
// console.log("obj:"+this.object.backgroundImage,"startVal:"+this.startVal, "endVal:"+this.endVal)
this.object[this.property] = this.template(this.startVal + range*progress)
}
}
2 手势
2.1 手势的基本知识
2.2 实现鼠标和触摸操作
- 鼠标的标准处理
在mousedown中监听document的mousemove和mouseup事件,在mouseup中移除document的mousemove和mouseup的监听
// 鼠标操作
element.addEventListener("mousedown", event=>{
let mousemove = event => {
console.log(event.clientX, event.clientY)
}
let mouseup = event => {
document.removeEventListener("mousemove", mousemove)
document.removeEventListener("mouseup", mouseup)
}
document.addEventListener("mousemove", mousemove)
document.addEventListener("mouseup", mouseup)
})
- 触摸的处理
分别对touchstart, touchmove, touchend和touchcancel(触摸被其它事件异常中断)进行监听(touchstart触发时会同时触发touchmove)
// 触摸操作
element.addEventListener("touchstart", event=>{
for(let touch of event.changedTouches) { // changedTouches表示触发的多个触点
console.log("touchstart")
}
})
element.addEventListener("touchmove", event=>{
for(let touch of event.changedTouches) {
console.log(touch.clientX, touch.clientY)
}
})
element.addEventListener("touchend", event=>{
for(let touch of event.changedTouches) {
console.log("touchend")
}
})
element.addEventListener("touchcancel", event=>{
for(let touch of event.changedTouches) {
console.log("touchcancel")
}
})
2.3 实现手势的逻辑
主要是实现识别2.1中tap,pan,press几种手势的功能
start(point, context) {
context.startX = point.clientX
context.startY = point.clientY
context.startTime = Date.now()
context.isTap = true
context.isPress = false
context.isPan = false
context.isFlick = false
context.handler = setTimeout(()=>{
context.isPress = true
context.isTap = false
context.isPan = false
console.log("press")
}, 500)
context.points= [{
"time": Date.now(),
"pointX": point.clientX,
"pointY": point.clientY
}]
console.log("start", point.clientX, point.clientY)
}
move(point, context) {
let dx = point.clientX - context.startX, dy = point.clientY - context.startY
if(!context.isPan && dx**2 + dy**2 > 100) {
clearTimeout(context.handler)
context.isPan = true
context.isTap = false
context.isPress = false
context.handler = null
context.isVertical = Math.abs(dy) > Math.abs(dx)
console.log("panstart")
}
if(context.isPan) {
console.log("pan")
}
context.points = context.points.filter(point => Date.now()-point.time<500) // 仅保留最近半秒内的点记录
context.points.push({
"time": Date.now(),
"pointX": point.clientX,
"pointY": point.clientY
})
console.log("move", point.clientX, point.clientY)
}
end(point, context) {
clearTimeout(context.handler)
if(context.isTap)
console.log("tap")
if(context.isPress)
console.log("pressend")
let d = Math.sqrt((point.clientX - context.points[0].pointX)**2 + (point.clientY - context.points[0].pointY)**2)
let v = d / (Date.now() - context.points[0].time) // px/ms
if(v > 1.5) {
console.log("flick")
context.isFlick = true
}else{
context.isFlick = false
}
if(context.isPan) {
console.log("panend")
}
context.isTap = false
context.isPan = false
context.isPress = false
console.log("end", point.clientX, point.clientY)
}
cancel(point, context) {
clearTimeout(context.handler)
context.isTap = false
context.isPan = false
context.isPress = false
console.log("cancel")
}
2.4 处理鼠标和触摸事件
将手势识别相关的变量封装到context中,以支持多点触摸和鼠标多键点击
let contexts = new Map()
let isListeningMouse = false
// 鼠标操作
element.addEventListener("mousedown", event=>{
let context = Object.create(null) //{startX: null, startY: null, handler: null, isTap: false, isPan: false, isPress: false, isFlick: false, points: null}
contexts.set("mouse" + (1<<event.button), context) // button->2^button
recognizer.start(event, context)
let mousemove = event => {
let button = 1
while(button <= event.buttons) {// event.buttons保存了鼠标移动时所有按下的键,是二进制掩码0bxxxxx的形式
// event.buttons 0b00010 represents event.button 4 while event.buttons 0b00100 represents event.button 2
let key
if(button === 2) {
key = 4
}else if(button === 4) {
key = 2
}else{
key = button
}
if(button & event.buttons) { // 判断button是否包含于当前鼠标按键(可能有多个)中
recognizer.move(event, contexts.get("mouse"+ key))
}
button = button<<1
}
// console.log(event.clientX, event.clientY)
}
let mouseup = event => {
recognizer.end(event, contexts.get("mouse"+ (1<<event.button)))
contexts.delete(contexts.get("mouse"+ (1<<event.button)))
if(event.buttons === 0) { // 当没有键按下时才取消监听
document.removeEventListener("mousemove", mousemove)
document.removeEventListener("mouseup", mouseup)
isListeningMouse = false
}
}
if(!isListeningMouse) { // 避免多次绑定mousemove和mouseup监听
document.addEventListener("mousemove", mousemove)
document.addEventListener("mouseup", mouseup)
isListeningMouse = true
}
})
// 触摸操作
element.addEventListener("touchstart", event=>{
for(let touch of event.changedTouches) { // 触摸点可能有多个,通过identifier标识
// console.log("touchstart")
let context = Object.create(null) //{startX: null, startY: null, handler: null, isTap: false, isPan: false, isPress: false, isFlick: false, points: null}
contexts.set(touch.identifier, context)
recognizer.start(touch, context)
}
})
element.addEventListener("touchmove", event=>{
for(let touch of event.changedTouches) {
// console.log(touch.clientX, touch.clientY)
recognizer.move(touch, contexts.get(touch.identifier))
}
})
element.addEventListener("touchend", event=>{
for(let touch of event.changedTouches) {
// console.log("touchend")
recognizer.end(touch, contexts.get(touch.identifier))
contexts.delete(touch.identifier)
}
})
element.addEventListener("touchcancel", event=>{
for(let touch of event.changedTouches) {
// console.log("touchcancel")
recognizer.cancel(touch, contexts.get(touch.identifier))
contexts.delete(touch.identifier)
}
})
几点注意:
- 鼠标中键和右键的button属性值顺序和移动时的buttons顺序是相反的
- 当鼠标多个键同时按下,需要避免多次界面绑定mousemove和mouseup监听,当所有按下的鼠标键都松手时再移除mousemove和mouseup监听
2.5 派发事件
目的是将识别手势事件的能力封装成API通过addEventListener派发给元素,派发事件可以使用new Event()
2.6 实现一个Flick事件
思路:需要计算触点的移动速度,当大于某一阈值时认为是flick事件
2.7 手势库的封装
处理流程:listener=>recongnizer=>dispatch
将各环节封装成单独的API导出,同时提供一个组合API将各环节串起来方便使用
调用方式:new Listener(element, new Recognizer(new Dispatcher(element)))
完整的代码请参见github
3 小结
- 介绍了如何利用时间线来管理属性动画的添加、暂停、恢复、重播等功能
- 介绍了网页中tap(轻触)、pan(拖)、press(长按)、flick(扫)等手势识别的一般思路,实现了一个PC端和移动端通用的基础手势库
ps:如果觉得此文对你有帮助或启发,请不要吝惜你的点赞和分享,如有疑问,请留言或私信交流