vue 仿iphone 悬浮框效果

vue-suspension-frame 插件

image.png
image.png

代码

1, components->suspension-bar->ButtonWrapper.vue

<template>
  <div class="xuanfu" id="moveDiv" :style="position"
       @mousedown="down" @touchstart="down"
       @mousemove="move" @touchmove="move"
       @mouseup="end" @touchend="end"
  >
    <slot></slot>
  </div>
</template>

<script>
import BUS from '@/utils/bus'
export default {
  name: 'SuspensionBar',
  components: {},
  props: {
    // 通过position来设置初始定位
    position: {
      type: Object,
      default: function () {
        return {
          top: '0',
          left: '0'
        }
      }
    },
    // 通过fixed来禁用自由移动
    fixed: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      flags: false,
      positionTemp: { x: 0, y: 0 }, // 记录手指点击的位置
      nx: '',
      ny: '',
      dx: '',
      dy: '',
      xPum: '',
      yPum: '',
      moveDiv: '',
      windowWidth: '',
      windowHeight: '',
      startY: 0, // 初始位置
      lastY: 0, // 上一次位置
      lastMoveTime: 0, // 用于缓动的变量
      lastMoveStart: 0, // 用于缓动的变量
      stopInertiaMove: false // 是否停止缓动
    }
  },
  watch: {
  },
  computed: {},
  methods: {
    // 实现移动端拖拽
    down () {
      if (this.fixed) {
        return
      }

      this.flags = true
      var touch
      // 该if判断是用touch还是mouse来移动
      if (event.touches) {
        touch = event.touches[0]
      } else {
        touch = event
      }
      this.positionTemp.x = touch.clientX // 手指点击后的位置
      this.positionTemp.y = touch.clientY
      this.moveDiv = document.getElementById('moveDiv')
      this.windowWidth = document.documentElement.clientWidth
      this.windowHeight = document.documentElement.clientHeight
      this.dx = this.moveDiv.offsetLeft // 移动的div元素的位置
      this.dy = this.moveDiv.offsetTop
      /**
       * 缓动代码
       */
      this.lastY = this.startY = event.touches[0].pageY
      this.lastMoveStart = this.lastY
      this.lastMoveTime = event.timeStamp || Date.now()
      this.stopInertiaMove = true
    },
    move () {
      if (this.flags) {
        var touch
        if (event.touches) {
          touch = event.touches[0]
        } else {
          touch = event
        }
        this.nx = touch.clientX - this.positionTemp.x // 手指移动的变化量
        this.ny = touch.clientY - this.positionTemp.y

        this.xPum = this.dx + this.nx // 移动后,div元素的位置
        this.yPum = this.dy + this.ny

        if (this.xPum > 0 && (this.xPum + this.moveDiv.clientWidth < this.windowWidth)) {
          // movediv的左右边,未出界
          this.moveDiv.style.left = this.xPum + 'px'
        } else if (this.xPum <= 0) {
          // 左边出界,则左边缘贴边
          this.moveDiv.style.left = 0 + 'px'
        } else if (this.xPum + this.moveDiv.clientWidth >= this.windowWidth) {
          // 右边缘出界
          this.moveDiv.style.left = (this.windowWidth - this.moveDiv.clientWidth) + 'px'
        }
        // 上下未出界
        if (this.yPum > 0 && (this.yPum + this.moveDiv.clientHeight < this.windowHeight)) {
          this.moveDiv.style.top = this.yPum + 'px'
        } else if (this.yPum <= 0) {
          // 上边缘出界
          this.moveDiv.style.top = 20 + 'px'
        } else if (this.yPum + this.moveDiv.clientHeight >= this.windowHeight) {
          // 下边缘
          this.moveDiv.style.top = this.windowHeight - this.moveDiv.clientHeight - 20 + 'px'
        }

        /**
         * 缓动代码
         */
        let nowY = event.touches[0].pageY
        let moveY = nowY - this.lastY
        let contentTop = this.moveDiv.getBoundingClientRect().top
        // 设置top值移动content
        this.moveDiv.style.top = (parseInt(contentTop) + moveY) + 'px'
        this.lastY = nowY
        let nowTime = event.timeStamp || Date.now()
        this.stopInertiaMove = true
        if (nowTime - this.lastMoveTime > 300) {
          this.lastMoveTime = nowTime
          this.lastMoveStart = nowY
        }

        // 阻止页面的滑动默认事件,为了只让悬浮球滑动,其他部分不滑动;如果碰到滑动问题,1.2 请注意是否获取到 touchmove, 系统默认passive: true,无法使用preventDefault
        document.addEventListener('touchmove', this.preventDefault, { passive: false })
        document.addEventListener('mousemove', this.preventDefault, { passive: false })
      }
    },
    // 鼠标释放时候的函数,鼠标释放,移除之前添加的侦听事件,将passive设置为true,不然背景会滑动不了
    changeNumLeft (startN, endN, speed = 20) {
      let aniTimer = null
      clearInterval(aniTimer)
      let next = 0
      next = Math.floor(startN - speed)
      aniTimer = setInterval(() => {
        next -= speed
        this.moveDiv.style.left = next + 'px'
        if (next <= endN) {
          clearInterval(aniTimer)
          this.moveDiv.style.left = 0 + 'px'
          this.flags = false
          const suspensionBar = {
            left: this.moveDiv.getBoundingClientRect().left,
            top: this.moveDiv.getBoundingClientRect().top
          }
          localStorage.setItem('suspensionBar', JSON.stringify(suspensionBar))
          BUS.$emit('changePos', true)
          this.$emit('change')
        }
      }, 16.7)
      return next
    },
    changeNumRight (startN, endN, speed = 20) {
      let aniTimer = null
      clearInterval(aniTimer)
      let next = startN
      aniTimer = setInterval(() => {
        this.moveDiv.style.left = next + 'px'
        if (next >= endN) {
          clearInterval(aniTimer)
          this.moveDiv.style.left = this.windowWidth - this.moveDiv.clientWidth + 'px'
          this.flags = false
          const suspensionBar = {
            left: this.moveDiv.getBoundingClientRect().left <= this.windowWidth - 50 ? this.moveDiv.getBoundingClientRect().left : this.windowWidth - 50,
            top: this.moveDiv.getBoundingClientRect().top
          }
          localStorage.setItem('suspensionBar', JSON.stringify(suspensionBar))
          BUS.$emit('changePos', true)
          this.$emit('change')
          this.moveDiv.style.left = this.moveDiv.getBoundingClientRect().left <= this.windowWidth - 50 ? this.moveDiv.getBoundingClientRect().left + 'px' : this.windowWidth - 50 + 'px'
        }
        next += speed
      }, 16.7)
      return next
    },
    end () {
      if (this.moveDiv.getBoundingClientRect().left + this.moveDiv.clientWidth / 2 < this.windowWidth / 2) {
        this.changeNumLeft(this.moveDiv.getBoundingClientRect().left, 0)
      } else if (this.moveDiv.getBoundingClientRect().left + this.moveDiv.clientWidth / 2 > this.windowWidth / 2) {
        this.changeNumRight(this.moveDiv.getBoundingClientRect().left, this.windowWidth - this.moveDiv.clientWidth)
      }

      /**
       * 缓动代码
       */
      let nowY = event.changedTouches[0].pageY
      let moveY = nowY - this.lastY
      let contentTop = this.moveDiv.getBoundingClientRect().top
      let contentY = (parseInt(contentTop) + moveY)
      // 设置top值移动content
      this.moveDiv.style.top = contentY + 'px'
      this.lastY = nowY
      let nowTime = event.timeStamp || Date.now()
      let v = (nowY - this.lastMoveStart) / (nowTime - this.lastMoveTime) // 最后一段时间手指划动速度
      this.stopInertiaMove = false;

      (function (v, startTime, contentY) {
        var dir = v > 0 ? -1 : 1 // 加速度方向
        var deceleration = dir * 0.0006
        var duration = v / deceleration // 速度消减至0所需时间
        let dist = v * duration / 2 // 最终移动多少
        function inertiaMove () {
          if (this.stopInertiaMove) return
          let nowTime = event.timeStamp || Date.now()
          let t = nowTime - startTime
          let nowV = v + t * deceleration
          // 速度方向变化表示速度达到0了
          if (dir * nowV < 0) {
            return
          }
          let moveY = (v + nowV) / 2 * t
          this.moveDiv.style.top = (contentY + moveY) + 'px'
          setTimeout(inertiaMove, 10)
        }
        inertiaMove()
      })(v, nowTime, contentY)

      // 注意事项,在添加和删除监听事件时,其function必须是同名的函数,不能为匿名函数。
      document.removeEventListener('touchmove', this.preventDefault, false)
      document.removeEventListener('mousemove', this.preventDefault, false)
      // 下面两句是保证在移除监听事件后,除了悬浮球的部分还能够滑动,如果不添加,则无法滑动
      document.addEventListener('touchmove', function (e) {
        window.event.returnValue = true
      })
      document.addEventListener('mousemove', function (e) {
        window.event.returnValue = true
      })
      // BUS.$emit('down', true)
    },
    preventDefault (e) {
      e.preventDefault()
    }
  }
}
</script>

<style scoped>
  .xuanfu {
    /* 如果碰到滑动问题,1.3 请检查 z-index。z-index需比web大一级*/
    z-index: 999;
    position: fixed;
  }
</style>

2, components->suspension-frame->buttonContent.vue

  <div ref="buttonContentRef" :class="['button-content-wrapper', {'active': buttonActive}]">
    <div :class="['round-wrapper', {'round-wrapper-active': buttonActive}]" @click="handleClick">
      <span class="round-1"></span>
      <span class="round-2"></span>
      <span class="round-3"></span>
    </div>
  </div>
</template>

<script>
import BUS from '../../utils/bus'
export default {
  name: 'ButtonContent',
  props: {
  },
  data () {
    return {
      buttonActive: false
    }
  },
  mounted () {
    if (this.$refs.buttonContentRef) {
      // this.$refs.buttonContentRef.style.left = JSON.parse(localStorage.getItem('suspensionBar')).left + 'px'
      // this.$refs.buttonContentRef.style.top = JSON.parse(localStorage.getItem('suspensionBar')).top + 'px'
    }
    BUS.$on('buttonActive', (v) => {
      this.buttonActive = v
      this.$refs.buttonContentRef.style.opacity = '1'
    })
    BUS.$on('documentActive', (v) => {
      if (v) {
        this.$refs.buttonContentRef.style.opacity = '1'
      } else {
        setTimeout(() => {
          this.$refs.buttonContentRef.style.opacity = '0'
        }, 300)
        this.buttonActive = v
      }
    })
    BUS.$on('changePos', (v) => {
      if (this.$refs.buttonContentRef) {
        this.$refs.buttonContentRef.style.left = JSON.parse(localStorage.getItem('suspensionBar')).left + 'px'
        this.$refs.buttonContentRef.style.top = JSON.parse(localStorage.getItem('suspensionBar')).top + 'px'
      }
    })
  },
  methods: {
    handleClick () {
      alert(1)
    }
  }
}
</script>

<style scoped>
  .button-content-wrapper {
    position: absolute;
    width: 50px;
    height: 50px;
    background: #29292c;
    text-align: center;
    line-height: 100px;
    border-radius: 12px;
    opacity: 0;
    transition: all .3s;
    z-index: 998;
  }
  .button-content-wrapper.active {
    position: absolute;
    top: 50% !important;
    left: 50% !important;
    width: 240px;
    height: 240px;
    margin-left: -120px;
    margin-top: -120px;
    background: #29292c;
    text-align: center;
    line-height: 100px;
    border-radius: 12px;
    z-index: 2000;
    opacity: 1;
    transition: all .3s;
  }
  .button-content-wrapper .round-1 {
    display: block;
    position: absolute;
    left: 6px;
    top: 7px;
    width: 38px;
    height: 38px;
    background: #474e59;
    border-radius: 100%;
  }
  .button-content-wrapper .round-2 {
    display: block;
    position: absolute;
    left: 9px;
    top: 10px;
    width: 30px;
    height: 30px;
    background: #555e6c;
    border-radius: 100%;
    border: 1px solid #72727b;
  }
  .button-content-wrapper .round-3 {
    display: block;
    position: absolute;
    left: 13px;
    top: 14px;
    width: 24px;
    height: 24px;
    background: #cbd7e2;
    border-radius: 100%;
    box-sizing: border-box;
  }
  .button-content-wrapper .round-wrapper {
    transition: all .3s;
  }
  .button-content-wrapper .round-wrapper-active {
    transform: translate(95px, 180px);
    transition: all .3s;
  }
</style>

3, components->suspension-frame->iphoneButton.vue

<template>
  <div ref="buttonContentRef" v-if="isShowBtn" :class="['iphone-button-wrapper', {'opacity-active': !!status}]" @click.stop.prevent="handleClick">
    <div>
      <span class="round-1"></span>
      <span class="round-2"></span>
      <span class="round-3"></span>
    </div>
  </div>
</template>

<script>
import BUS from '../../utils/bus'
export default {
  name: 'iphoneButton',
  props: {
    changeOpacity: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      status: false,
      opacity: '',
      flag: false,
      isShowBtn: true,
      buttonActive: true
    }
  },
  watch: {
    changeOpacity (v) {
      this.status = v
    }
  },
  created () {
    this.status = this.changeOpacity
    BUS.$on('documentActive', (v) => {
      if (!v) {
        setTimeout(() => {
          this.isShowBtn = !v
        }, 300)
      }
    })
  },
  methods: {
    handleClick () {
      BUS.$emit('buttonActive', this.buttonActive)
      this.isShowBtn = false
    }
  }
}
</script>

<style scoped>
  .opacity-active {
    transition: all .3s;
    opacity: 0.5;
  }
  .iphone-button-wrapper {
    transition: all .3s;
  }
  .iphone-button-wrapper .round-1 {
    display: block;
    position: absolute;
    left: 6px;
    top: 7px;
    width: 38px;
    height: 38px;
    background: #474e59;
    border-radius: 100%;
  }
  .iphone-button-wrapper .round-2 {
    display: block;
    position: absolute;
    left: 9px;
    top: 10px;
    width: 30px;
    height: 30px;
    background: #555e6c;
    border-radius: 100%;
    border: 1px solid #72727b;
  }
  .iphone-button-wrapper .round-3 {
    display: block;
    position: absolute;
    left: 13px;
    top: 14px;
    width: 24px;
    height: 24px;
    background: #cbd7e2;
    border-radius: 100%;
    box-sizing: border-box;
  }
</style>

4, components->suspension-frame->suspensionFrame.vue

<template>
  <div class="suspension-bar-box">
    <div ref="maskRef" class="suspension-mask"></div>
    <div class="suspension-bar-wrapper">
      <button-wrapper :position="position" :isClickBtn="isClickBtn" @change="change">
        <iphone-button :changeOpacity="changeOpacity"></iphone-button>
      </button-wrapper>
      <button-content></button-content>
    </div>
  </div>
</template>

<script>
import ButtonWrapper from './ButtonWrapper'
import IphoneButton from './iphoneButton'
import ButtonContent from './buttonContent'
import BUS from '../../utils/bus'
export default {
  name: 'vue-suspension-frame',
  data () {
    return {
      position: {
        top: '0px',
        left: '0px'
      },
      endTime: 1,
      timer: null,
      changeOpacity: false,
      isClickBtn: false,
      isShowContent: false
    }
  },
  components: {ButtonContent, IphoneButton, ButtonWrapper},
  destroyed () {
    document.removeEventListener('click', this.listenClick, false)
  },
  mounted () {
    if (localStorage.getItem('suspensionBar')) {
      this.position = {
        top: JSON.parse(localStorage.getItem('suspensionBar')).top + 'px',
        left: JSON.parse(localStorage.getItem('suspensionBar')).left + 'px'
      }
    }
    this.change()
    this.listenClick()
    BUS.$on('buttonActive', (v) => {
      if (v) {
        this.$refs.maskRef.style.zIndex = 1001
      }
    })
  },
  methods: {
    change () {
      this.endTime = 1
      this.changeOpacity = false
      clearTimeout(this.timer)
      this.timer = setInterval(() => {
        if (this.endTime >= 3) {
          this.changeOpacity = true
          clearTimeout(this.timer)
        }
        this.endTime++
      }, 1000)
    },
    listenClick () {
      document.addEventListener('click', (e) => {
        console.log(e.target.className, 'e.target.className')
        if (e.target.className.indexOf('mask') !== -1) {
          BUS.$emit('documentActive', false)
          this.$refs.maskRef.style.zIndex = 0
        } else if (e.target.className.indexOf('round-1') !== -1 || e.target.className.indexOf('round-2') !== -1 || e.target.className.indexOf('round-3') !== -1 || e.target.className.indexOf('iphone-button-wrapper') !== -1) {
          this.$refs.maskRef.style.zIndex = 1001
          BUS.$emit('documentActive', true)
        }
      })
    }
  }
}
</script>

<style scoped>
  .suspension-mask {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: transparent;
    z-index: 0;
  }
  .suspension-bar-wrapper {
    position: absolute;
    width: 100%;
    height: 100%;
    background-size: cover;
    overflow: hidden;
  }
  .iphone-button-wrapper {
    width: 50px;
    height: 50px;
    background: #29292c;
    text-align: center;
    line-height: 100px;
    border-radius: 12px;
    position: relative;
  }

</style>

5, components->suspension-frame->index.js

import SuspensionFrame from './SuspensionFrame'

const install = function (Vue) {
  Vue.component(SuspensionFrame.name, SuspensionFrame)
}

if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}

export default {
  install
}

main.js

import VueSuspensionFrame from './components/suspension-frame'
Vue.use(VueSuspensionFrame)

完整代码
https://github.com/websmallrabbit/vue-better-router-transition

喜欢的话,帮忙点个小赞👍

推荐阅读更多精彩内容