前端训练营学习笔记——组件化实战2:设计动画和手势库


上一篇博客 《组件化实战1:组件知识和基础轮播组件》介绍的轮播组件虽然有两个功能,但无法集成到一起,也存在一些小问题的,需要加入动画和手势,动画和手势是开发组件所需要的底层能力

1 动画

1.1 动画分类和实现方式

1.1.1 动画分类

动画分为属性动画(属性动态变化)和帧动画(图片动态变化),浏览器中展现的多是属性动画

1.1.2 动画的实现方式

  1. setInterval:存在时间积压的可能,不可控,不推荐
setInterval(()=>{}, 16) // 16ms为浏览器的一帧时间
  1. setTimeout
const tick = ()=>{
  // 处理逻辑...
  setTimeout(()=>{}, 16)
}
  1. requestAnimationFrame:请求在浏览器下一帧执行回调函数
const tick = ()=>{
  // 处理逻辑...
  requestAnimationFrame(tick)
}

1.2 时间线

可以理解成管理动画的一个工具,可以实现动画的添加、删除、开始、暂停、重播等

1.2 设计时间线的更新

目的是可以动态地给时间线添加动画:

  • 当动画的开始时间早于时间线的开始时间时,TICK中流逝的时间是相对于时间线开始时间;
  • 当动画的开始时间晚于时间线的开始时间时,TICK中流逝的时间是相对于动画开始时间

1.3 给时间线添加暂停和启动功能

  • 暂停功能需要使用cancelAnimationFrame取消掉前面requestAnimationFrame返回的handler
  • resume功能需要记录暂停后到resume的累计暂停时间并在resume时减去该时间

1.4 完善其它功能

  1. 增加delay功能
  2. 补全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 手势的基本知识

end
移动10px
move
end
end且速度>?
0.5s
end
移动10px
start
tap轻触
pan start
pan拖动
pan end
flick扫
press start
press end

2.2 实现鼠标和触摸操作

  1. 鼠标的标准处理

在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)
})
  1. 触摸的处理

分别对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)
      }
    })

几点注意:

  1. 鼠标中键和右键的button属性值顺序和移动时的buttons顺序是相反的
  2. 当鼠标多个键同时按下,需要避免多次界面绑定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 小结

  1. 介绍了如何利用时间线来管理属性动画的添加、暂停、恢复、重播等功能
  2. 介绍了网页中tap(轻触)、pan(拖)、press(长按)、flick(扫)等手势识别的一般思路,实现了一个PC端和移动端通用的基础手势库

ps:如果觉得此文对你有帮助或启发,请不要吝惜你的点赞和分享,如有疑问,请留言或私信交流

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值