移动端日程表拖拽分享(参照teams、钉钉)

289 阅读3分钟

先不卖关子,上动图

1.gif

三大功能

  1. 生成时间块
  2. 对整块时间块进行平移
  3. 通过上下拉动把手增减时间

布局

布局.png

  • 整个布局分红、绿、蓝三块。
  • 红色部分并不重要,这边用position: sticky去实现,当然要注意一下浏览器兼容性。
  • 绿色部分用了flex分成左右两块,本人按照半小时为一格,将一天画成48格。然后总体内用overflow: auto去实现整体上下滚动。
  • 蓝色部分是整体的position: absolute去计算距离顶部的高度和时间块的高度,宽度固定。
  • 那么布局就完事了。

js基础

在实现代码之前,首先要了解移动端的触摸事件。

  • touchstart
  • touchmove
  • touchend

这部分需要自行恶补,可以参照鼠标额mousedown、mousemove、mouseup,但还是有区别的。

mousedown、mousemove、mouseup中默认的$event对象是鼠标当前指向的对象,而移动端的touch事件默认的$event对象永远都是touchstart指向的对象。

生成时间块

这里参照了vue-fullcalendar的操作。长按点击开始touchstart,通过触摸滑动的Y轴距离算出当前时间块的高度和距离顶部的高度。

<div v-for="item in gridArr" :key="item" :ref="(el) => setItemRef(el, item)" @touchstart="gridTouchStart(item, $event)"></div>
// 半小时一块 一天划分为48块
const config = { length: 48 };
// 生成右侧块状
const gridArr = ref<number[]>([]);
for (let i = 0; i < config.length; i++) {
  gridArr.value.push(i);
}
const timeRef = ref<{ [key: number]: any }>({});
// 为每个块状添加ref响应式
const setItemRef = (el: any, item: number): void => {
  if (el) {
    timeRef.value[item] = el;
  }
};
// generate grid
const startIdx = ref<number>(-1);
const endIdx = ref<number>(-1);
// 记录当前是否在滑动中的参数
const isMove = ref<boolean>(false);
const gridTouchStart = (item: number, e: any) => {
  // 禁止双指点击
  if (e.touches && e.touches.length > 1) {
    return;
  }
  console.log('start');
  let startMoveY: number;
  // 写了一个类防抖,只有长按才能生成时间块
  let loop: NodeJS.Timeout | undefined;
  let sliderLoop: NodeJS.Timeout | undefined;
  // 将当前触摸的块变成想要的颜色
  timeRef.value[item].style.backgroundColor = '#C7E0F4';
  // 记录当前时间块距离顶部的高度
  startMoveY = e.touches[0].clientY;
  // 长按0.2s+触摸响应时间后,将背景色清空,生成时间块(为了区别用户是滑动外部滚动还是想要生成时间块)
  loop = setTimeout(() => {
    console.log('slider start');
    isMove.value = true;
    timeRef.value[item].style.backgroundColor = 'white';
    startIdx.value = item;
    endIdx.value = item;
  }, 200);

  const gridTouchMove = (e: any) => {
    // 如果还在长按周期清楚loop
    if (!isMove.value) {
      clearTimeout(loop);
      loop = undefined;
    } else {
      // 禁止下滑时间冒泡到外部,影响到外部滚动条滚动
      e.preventDefault();
      if (sliderLoop) {
        clearInterval(sliderLoop);
        sliderLoop = undefined;
      }
      // 获取外部滚动条
      const scrollObject = document.querySelector('div[data-is-scrollable]');
      // 获取红框部分高度
      const calendarRects = document.getElementsByClassName('campscomp-header-calendar')[0].getBoundingClientRect();
      // 获取当前滚动部分的高度
      const bodyHeight = document.getElementsByClassName('campslayout-cotent-bottom')[0].getBoundingClientRect().top - calendarRects.top - calendarRects.height;
      // 接下来是判断鼠标是否在上下端,如果在上端就滚动条往上移,下端也一样。
      if (e.targetTouches[0].clientY - calendarRects.top - calendarRects.height < 44 && scrollObject && scrollObject.scrollTop > 0) {
        sliderLoop = setInterval(() => {
          scrollObject &&
            scrollObject.scrollTo({
              top: scrollObject.scrollTop - 1,
              behavior: 'smooth'
            });
          startMoveY += 1;
        }, 10);
      } else if (e.targetTouches[0].clientY > bodyHeight + calendarRects.top - 44 && scrollObject && scrollObject.scrollTop < bodyHeight) {
        sliderLoop = setInterval(() => {
          scrollObject &&
            scrollObject.scrollTo({
              top: scrollObject.scrollTop + 1,
              behavior: 'smooth'
            });
          startMoveY -= 1;
        }, 10);
      }
      // 判断当前离顶端高度,减去之前初始离顶端的高度获取到差值,除以一格的高度,得出当前移动了几格
      const newIdx = item + Math.floor((e.touches[0].clientY - startMoveY) / 22);
      // 判断向上还是向下移动
      if (Math.floor((e.touches[0].clientY - startMoveY) / 22) < 0) {
        startIdx.value = item > 0 ? newIdx : 0;
        endIdx.value = item;
      } else {
        startIdx.value = item;
        endIdx.value = item < 47 ? newIdx : 0;
      }
    }
  };

  const gridTouchEnd = () => {
    console.log('end');
    if (loop) {
      clearTimeout(loop);
      loop = undefined;
      clearInterval(sliderLoop);
      sliderLoop = undefined;
    }
    isMove.value = false;
    // 将背景颜色置白
    timeRef.value[item].style.backgroundColor = 'white';
    const result = duplicateTime();
    if (!result) {
      startIdx.value = -1;
      endIdx.value = -1;
    } else {
      // cloneSelectId.value = selectId.value
    }
    // 结束时注销事件
    document.removeEventListener('touchmove', gridTouchMove);
    document.removeEventListener('touchend', gridTouchEnd);
  };
  // 注册touchmove事件
  document.addEventListener('touchend', gridTouchEnd);
  // 禁止下滑时间冒泡到外部,影响到外部滚动条滚动
  document.addEventListener('touchmove', gridTouchMove, { passive: false });
};

对整块时间块进行平移

其中sliderStyle为计算属性,通过上面定义的startIdxendIdx的值来计算当前时间块出现的位置。 其中的平移也是相同的计算方法,保存初始值,在move的时候计算距离来进行滑动。

<!-- template -->
    <div v-if="isSliderShow" :style="sliderStyle" @touchstart="rectTouchStart($event)">
      <!-- 自定义内容 -->
    </div>
// 设置一个参数 判断是否正在滑动
const isSlider = ref<boolean>(false);
const sliderTopBtn = ref();
const sliderBottomBtn = ref();
const rectTouchStart = (e: any) => {
  // 判断当前点击事件是否包括把手(注意新版本Chrome已经不兼容path了,可以用composedPath代替)
  if (e?.path.indexOf(sliderTopBtn.value) > -1 || e?.path.indexOf(sliderBottomBtn.value) > -1) {
    return;
  }
  // 禁止双指点击
  if (e.touches && e.touches.length > 1) {
    return;
  }
  // 写了一个类防抖,只有长按才能生成时间块
  let loop: NodeJS.Timeout | undefined;
  let startPageY = 0;
  loop = setTimeout(() => {
    loop = undefined;
    isSlider.value = true;
  }, 200);
  // 获取当前点击下的pageY,也就是离页面顶部的距离
  startPageY = e.touches[0].pageY;
  const moveStartIdx = startIdx.value;
  const moveEndIdx = endIdx.value;
  let sliderLoop: NodeJS.Timeout | undefined;

  const rectTouchMove = (e: any) => {
    if (!isSlider.value) {
      clearTimeout(loop);
      loop = undefined;
    } else {
    // 禁止下滑时间冒泡到外部,影响到外部滚动条滚动
      e.preventDefault();
      if (sliderLoop) {
        clearInterval(sliderLoop);
        sliderLoop = undefined;
      }
      // 获取外部滚动条 同上
      const scrollObject = document.querySelector('div[data-is-scrollable]');
      const calendarRects = document.getElementsByClassName('campscomp-header-calendar')[0].getBoundingClientRect();
      const bodyHeight = document.getElementsByClassName('campslayout-cotent-bottom')[0].getBoundingClientRect().top - calendarRects.top - calendarRects.height;
      if (e.targetTouches[0].clientY - calendarRects.top - calendarRects.height < 44) {
        sliderLoop = setInterval(() => {
          scrollObject &&
            scrollObject.scrollTo({
              top: scrollObject.scrollTop - 1,
              behavior: 'smooth'
            });
          startPageY += 1;
        }, 10);
      } else if (e.targetTouches[0].clientY > bodyHeight + calendarRects.top - 44) {
        sliderLoop = setInterval(() => {
          scrollObject &&
            scrollObject.scrollTo({
              top: scrollObject.scrollTop + 1,
              behavior: 'smooth'
            });
          startPageY -= 1;
        }, 10);
      }
      const endPageY = e.targetTouches[0].pageY;  
      const moveIndex = Math.round((endPageY - startPageY) / 22);
      如果移动超过第一格或者大于最后一格就返回
      if (moveStartIdx + moveIndex < 0 || moveEndIdx + moveIndex > 47) {
        return;
      }
      startIdx.value = moveStartIdx + moveIndex;
      endIdx.value = moveEndIdx + moveIndex;
    }
  };

  const rectTouchEnd = () => {
    isSlider.value = false;
    clearTimeout(loop);
    clearInterval(sliderLoop);
    loop = undefined;
    sliderLoop = undefined;
    document.removeEventListener('touchmove', rectTouchMove);
    document.removeEventListener('touchend', rectTouchEnd);
  };
  // 禁止下滑时间冒泡到外部,影响到外部滚动条滚动
  document.addEventListener('touchmove', rectTouchMove, { passive: false });
  document.addEventListener('touchend', rectTouchEnd);
};
  const sliderStyle = computed(() => {
  const res: StyleValue = {
    top: '0',
    height: '0'
    // opacity: (isMove.value || isSlider.value || isMoveSlider.value) ? 0.88 : 1
  };
  const startRef = timeRef.value[startIdx.value];
  const endRef = timeRef.value[endIdx.value];
  res.top = startRef.offsetTop + 'px';
  res.height = Math.abs(startRef.offsetTop - endRef.offsetTop) + startRef.clientHeight + 'px';
  return res;
});

通过上下拉动把手增减时间块高度

在上面的基础上先画两个把手,通过position: absolute居中吸附到顶部和尾部,然后这里写了两个div是为了增加触摸面积,可以将父级的长宽设置的多一点。原理和上一样,我就不一一介绍了。

    <div v-if="isSliderShow" :style="sliderStyle" @touchstart="rectTouchStart($event)">
      <!-- 增加滑块面积 -->
      <div ref="sliderTopBtn" @touchstart="handleSliderBtn($event, 'top')">
        <div class="campscomp-slider-btn"></div>
      </div>
      <div ref="sliderBottomBtn" @touchstart="handleSliderBtn($event, 'bottom')">
        <div class="campscomp-slider-btn"></div>
      </div>
    </div>
// Handle Slider Start
const handleSliderBtn = (e: any, direction: 'top' | 'bottom') => {
  if (e.touches && e.touches.length > 1) {
    return;
  }
  let silderStartX: number = e.touches[0].clientY;
  // deep clone 深拷贝一份数据
  const startIndex = deepClone(startIdx.value);
  const endIndex = deepClone(endIdx.value);
  let sliderLoop: NodeJS.Timeout | undefined;

  const mouseover = (el: any) => {
    el.preventDefault();

    if (sliderLoop) {
      clearInterval(sliderLoop);
      sliderLoop = undefined;
    }
    const scrollObject = document.querySelector('div[data-is-scrollable]');
    const calendarRects = document.getElementsByClassName('campscomp-header-calendar')[0].getBoundingClientRect();
    const bodyHeight = document.getElementsByClassName('campslayout-cotent-bottom')[0].getBoundingClientRect().top - calendarRects.top - calendarRects.height;
    if (el.targetTouches[0].clientY - calendarRects.top - calendarRects.height < 44 && scrollObject && scrollObject.scrollTop > 0) {
      sliderLoop = setInterval(() => {
        scrollObject &&
          scrollObject.scrollTo({
            top: scrollObject.scrollTop - 1,
            behavior: 'smooth'
          });
        silderStartX += 1;
      }, 10);
    } else if (el.targetTouches[0].clientY > bodyHeight + calendarRects.top - 44 && scrollObject && scrollObject.scrollTop < bodyHeight) {
      sliderLoop = setInterval(() => {
        scrollObject &&
          scrollObject.scrollTo({
            top: scrollObject.scrollTop + 1,
            behavior: 'smooth'
          });
        silderStartX -= 1;
      }, 10);
    }

    const clientX = el.touches[0].clientY;
    if (direction === 'top') {
      const resultIndx = startIndex + Math.round((clientX - silderStartX) / 22);
      if (resultIndx <= endIdx.value && resultIndx >= 0 && resultIndx <= config.length! - 1) {
        startIdx.value = resultIndx;
      }
    } else {
      const resultIndx = endIndex + Math.round((clientX - silderStartX) / 22);
      if (resultIndx >= startIdx.value && resultIndx >= 0 && resultIndx <= config.length! - 1) {
        endIdx.value = resultIndx;
      }
    }
  };

  const mouseup = (): void => {
    isSlider.value = false;
    clearInterval(sliderLoop);
    sliderLoop = undefined;
    duplicateTime(startIndex, endIndex);
    document.removeEventListener('touchmove', mouseover);
    document.removeEventListener('touchend', mouseup);
  };
  // Disable outer border scrolling => el.preventDefault();
  document.addEventListener('touchmove', mouseover, { passive: false });
  document.addEventListener('touchend', mouseup);
};

将公共部分抽离一下,再润色一下页面就完事了。 Pc端会更加简单一些。

最后

好久没写文章了,最近逛了沸点,发现有兄弟在问移动端该怎么实现,让我新增了积极性,也希望我写的代码能被看懂吧。毕业一年小菜鸡的代码,属实又臭又长。