# 一、开始

这是一篇简单的学习笔记。

# 二、结构

先看下pull-refresh组件的HTML结构:

<div ref={root} class={bem()}>
  <div
    class={bem('track')}
    style={trackStyle}
    onTouchstart={onTouchStart}
    onTouchmove={onTouchMove}
    onTouchend={onTouchEnd}
    onTouchcancel={onTouchEnd}
  >
    <div class={bem('head')} style={getHeadStyle()}>
      {renderStatus()}
    </div>
    {slots.default?.()}
  </div>
</div>

可以看到下拉刷新的本质是对touch事件的监听,Vant这里是对class为track的元素的监听。

其中 renderStatus() 是 pulling、loosing、loading、success 这些状态的体现,就是一个div文本或者Loading组件:

const renderStatus = () => {
  const { status, distance } = state;

  if (slots[status]) {
    return slots[status]!({ distance });
  }

  const nodes: JSX.Element[] = [];

  if (TEXT_STATUS.includes(status)) {
    nodes.push(<div class={bem('text')}>{getStatusText()}</div>);
  }
  if (status === 'loading') {
    nodes.push(
      <Loading
        v-slots={{ default: getStatusText }}
        class={bem('loading')}
      />
    );
  }

  return nodes;
};

# 三、下拉状态

看一下下拉状态的流转:

normal => pulling => losing => loading => success => normal...

假设触发下拉刷新的距离(pulling-distance)为M,头部占位距离(head-height)为N,动画时长(animation-duration)为D。状态流转解释如下:

  • normal,原始位置
  • pulling,下拉过程中下拉距离小于M的部分
  • losing,下拉过程中下拉距离大于M的部分,松手前
  • loading,松手后至数据加载完毕前,立即触发refresh事件,distance会被马上设置为N
  • success,数据加载完毕后,下拉元素归位前,这部分存在时长为动画时长D。
  • normal,动画运行完,归位

具体实现如下:

type PullRefreshStatus =
  | 'normal'
  | 'loading'
  | 'loosing'
  | 'pulling'
  | 'success';

const state = reactive({
  status: 'normal' as PullRefreshStatus,
  distance: 0,
  duration: 0,
});

const setStatus = (distance: number, isLoading?: boolean) => {
  const pullDistance = +(props.pullDistance || props.headHeight);
  state.distance = distance;

  if (isLoading) {
    state.status = 'loading';
  } else if (distance === 0) {
    state.status = 'normal';
  } else if (distance < pullDistance) {
    state.status = 'pulling';
  } else {
    state.status = 'loosing';
  }
};

在 onTouchMove 中会调用 setStatus 进行状态的流转:

const onTouchMove = (e: TouchEvent) => {
  if (!isTouchable()) {
    return;
  }
  const { deltaY } = touch;
  if (deltaY.value >= 0) {
    e.preventDefault();
    setStatus(ease(deltaY.value, PULL_DISTANCE));
  }
  touch.move(e);
};

在 onTouchEnd 中,会触发 refresh 事件:

const onTouchEnd = () => {
  state.duration = ANIMATION_DURATION;
  if (state.status === 'loosing') {
    emit('update:modelValue', true);
    nextTick(() => emit('refresh'));
  } else {
    setStatus(0);
  }
};

# 四、动画处理

在 onTouchStart 中,会将 state.duration 设置为0,这个数值会作为动画的持续时间。

这么做是为了在下拉的过程中不要有延迟,也就是animation-duration仅对松手释放、元素归位有效。

const onTouchStart = (e: TouchEvent) => {
  if (isTouchable()) {
    state.duration = 0;
    touch.start(e);
  }
};

下拉过程中,并不是向下拉多少,元素就向下位移多少,而是加了压缩,下拉越多,压缩比例越大。这样用户体验会好一点,而且几乎所有的APP都是这么做的。实现方式如下:

const ease = (distance: number) => {
  const pullDistance = +(props.pullDistance || props.headHeight);

  if (distance > pullDistance) {
    if (distance < pullDistance * 2) {
      distance = pullDistance + (distance - pullDistance) / 2;
    } else {
      distance = pullDistance * 1.5 + (distance - pullDistance * 2) / 4;
    }
  }

  return Math.round(distance);
};

# 五、样式

最后看下样式的实现。track 元素是position: relative,head 元素(就是“释放即可刷新...”等状态)是position:absolute; transform: translateY(-100%)

.van-pull-refresh {
  overflow: hidden;

  &__track {
    position: relative;
    height: 100%;
    transition-property: transform;
  }

  &__head {
    position: absolute;
    left: 0;
    width: 100%;
    height: var(--van-pull-refresh-head-height);
    overflow: hidden;
    color: var(--van-pull-refresh-head-text-color);
    font-size: var(--van-pull-refresh-head-font-size);
    line-height: var(--van-pull-refresh-head-height);
    text-align: center;
    transform: translateY(-100%);
  }
}

track 元素的下拉距离动画是写入行内的:

const trackStyle = {
  transitionDuration: `${state.duration}ms`,
  transform: state.distance
    ? `translate3d(0,${state.distance}px, 0)`
    : '',
};

# 六、useTouch的实现

是利用的event.touches[0].clientY/clientX,来获取的deltaY,即拖动了多少。

export function useTouch() {
  const startX = ref(0);
  const startY = ref(0);
  const deltaX = ref(0);
  const deltaY = ref(0);
  const offsetX = ref(0);
  const offsetY = ref(0);
  const direction = ref<Direction>('');

  const isVertical = () => direction.value === 'vertical';
  const isHorizontal = () => direction.value === 'horizontal';

  const reset = () => {
    deltaX.value = 0;
    deltaY.value = 0;
    offsetX.value = 0;
    offsetY.value = 0;
    direction.value = '';
  };

  const start = ((event: TouchEvent) => {
    reset();
    startX.value = event.touches[0].clientX;
    startY.value = event.touches[0].clientY;
  }) as EventListener;

  const move = ((event: TouchEvent) => {
    const touch = event.touches[0];
    // safari back will set clientX to negative number
    deltaX.value = touch.clientX < 0 ? 0 : touch.clientX - startX.value;
    deltaY.value = touch.clientY - startY.value;
    offsetX.value = Math.abs(deltaX.value);
    offsetY.value = Math.abs(deltaY.value);

    // lock direction when distance is greater than a certain value
    const LOCK_DIRECTION_DISTANCE = 10;
    if (
      !direction.value ||
      (offsetX.value < LOCK_DIRECTION_DISTANCE &&
        offsetY.value < LOCK_DIRECTION_DISTANCE)
    ) {
      direction.value = getDirection(offsetX.value, offsetY.value);
    }
  }) as EventListener;

  return {
    move,
    start,
    reset,
    startX,
    startY,
    deltaX,
    deltaY,
    offsetX,
    offsetY,
    direction,
    isVertical,
    isHorizontal,
  };
}

复习下基础知识,鼠标事件属性:

  • event.clientX、event.clientY

    • 鼠标相对于浏览器窗口可视区域的X,Y坐标(窗口坐标),可视区域不包括工具栏和滚动条。IE事件和标准事件都定义了这2个属性
  • event.pageX、event.pageY

    • 类似于event.clientX、event.clientY,但它们使用的是文档坐标而非窗口坐标。这2个属性不是标准属性,但得到了广泛支持。IE事件中没有这2个属性。

    • event.clientX /event.clientY是目标点距离浏览器可视范围的X轴/Y轴坐标

    • event.pageX /event.pageY 是目标点距离document最左上角的X轴/Y轴坐标

    • 也就是body滚动的时候event.pageY比event.clientY大

  • event.offsetX、event.offsetY

    • 鼠标相对于事件源元素(srcElement)的X,Y坐标,只有IE事件有这2个属性,标准事件没有对应的属性。
  • event.screenX、event.screenY

    • 鼠标相对于用户显示器屏幕左上角的X,Y坐标。标准事件和IE事件都定义了这2个属性

HTMLElement属性:

  • offsetLeft, offsetTop

    • offsetLeft从字面意思上理解,就是以父元素作为参照点(父元素的定位不能是static),当前元素相对于父元素左边的偏移量。
    • 那么offsetTop就是以父元素为参照物,当前元素相对于父元素上边的偏移量。
    • 如果没有父元素那么参照点就是body。
    • 这里要注意一点,如果当前定位元素本身是固定定位(position:fixed;),那么就别费心找爹了,返回的是当前元素与可视窗口的距离。
  • clientLeft、clientTop

    • 表示内容区域的左上角相对于整个元素左上角的位置(包括边框)。(取决于边框的像数值?)
  • scrollLeft、scrollTop 元素滚动的距离大小

  • offsetWidth、offsetHeight

    • 整个元素的尺寸,包括滚动条的宽度(包括元素高度、内边距和边框,不包括外边距)
  • clientWidth、clientHeight 内容区域的宽高,不包括边框宽度值。包括滚动条的宽度(包括元素高度、内边距,不包括边框和外边距)

  • scrollWidth、scrollHeight 整个内容区域的宽度(包括需拉动滚动条隐藏起来的那些部分) scrollWidth = scrollTop+clientWidth