【UI库】刷新组件

开发过程中经常会用到类似vant或者element等UI库,但对这些的实现原理并不深刻,前端不能一直只会用别人封装好的东西,所以我准备模仿造一个UI库的轮子,最终目的是研究别人的源码学习一些思想,至于能不能真的用上就另说了。

在开发UI库之前,选择了vantelement源码进行研究,关于按需引入和全局引入如何实现的原理,后期再写文章补充。由于是要造一个移动端的UI库,所以主要以vant源码进行模仿。这段时间研究了刷新组件,今天开发完毕,在此记录一下思路。

需求

下拉的时候,先下拉即可刷新,超过一定高度之后显示 释放即可刷新,释放时候回弹,显示加载中,刷新完毕之后回到顶部。
效果图

开发思路

在这里插入图片描述

需要有一个主容器(黑色框pull-refresh)包裹,内部的子容器(深蓝色pull-refresh_track)每次根据滑动更改transhlateY,头部(蓝色pull-refresh__head)的内容是绝对定位,并且translateY(-100%),由于主容器overflow:hidden,所以就看不到头部了,插槽显示的是列表内容。

布局

<template>
  <div class="pull-refresh">
    <div ref="track" class="pull-refresh__track" :style="trackStyle">
      <div class="pull-refresh__head">{
   
   {statusText}}</div>
      <slot></slot>
    </div>
  </div>
</template>
pull-refresh{
    
    
  overflow: hidden;
  user-select: none;
  &__track{
    
    
    position: relative;
    height: 100%;
    transition-property: transform;
  }
  &__head{
    
    
    position: absolute;
    left: 0;
    width: 100%;
    height: @pull-refresh-head-height;
    overflow: hidden;
    color: @pull-refresh-head-text-color;
    font-size: @pull-refresh-head-font-size;
    line-height: @pull-refresh-head-height;
    text-align: center;
    transform: translateY(-100%);
    color: #333;
  }
}

绑定跟touch相关事件

mounted生命周期中使用addEventListenertouchstarttouchmovetouchend绑定想要的事件handleTouchStarthandleTouchMovehandleTouchEnd

实现上下拉动的功能

我们先来实现第一个简单的功能,当你拉动屏幕的时候,蓝色的框,即子容器的translateY发生改变,当你向下拉动的时候,头部会出现在视野(黑色的框框)中。

在开始触摸handleTouchStart时记录当前的开始位置:

handleTouchStart(event: TouchEvent){
    
    
    const touches = event.touches[0];
    this.startX = touches.clientX;
    this.startY = touches.clientY;
}

在触摸过程中不断更新distance

handleTouchMove(event: TouchEvent){
    
    
    const touches = event.touches[0];
    this.deltaX = touches.clientX - this.startX;
    this.deltaY = touches.clientY - this.startY;
    this.distance = this.deltaY;
}

最后更改一下pull-refresh__tracktranslateY:

get trackStyle(){
    
    
    return  {
    
    
      transform:  this.distance ? `translate3d(0,${
      
      this.distance}px, 0)` : '',
    }
  }

trackStyle设置为pull-refresh__trackstyle。这样向下拖动的时候就可以看到pull-refresh__head了。
在这里插入图片描述

当为垂直方向时才更新distance

现在横向拉动的时候也会带动屏幕拖动,不是我们想要的,我们必须实现:当拖动的方向为垂直时才更改distace

getDirection(x: number, y: number){
    
    
    if(x > y && x > MIN_DISTANCE) return 'horizontal';
    if(y > x && y > MIN_DISTANCE) return 'vertical';
    return '';
}

handleTouchMove(event: TouchEvent){
    
    
    const touches = event.touches[0];
    this.deltaX = touches.clientX - this.startX;
    this.deltaY = touches.clientY - this.startY;
    this.offsetX = Math.abs(this.deltaX);
    this.offsetY = Math.abs(this.deltaY);
    this.direction = this.getDirection(this.offsetX, this.offsetY);
    
    if(this.direction === 'vertical'){
    
    
       this.distance = this.deltaY; 
    }
}

优化下拉的感觉

UI库的下拉都会有一种你越拉越难往下拉动的感觉:

// 优化下拉距离
_optimizeDistance(distance: number){
    
    
    // 你设置的pull-refresh__head的高度
    const headHeight = +this.headHeight;

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

    return Math.round(distance);
}

handleTouchMove(event: TouchEvent){
    
    
   // ...
   if(this.direction === 'vertical'){
    
    
      this.distance = this._optimizeDistance(this.deltaY); 
   }
}

更改下拉时候的文字

当向下拉动的距离小于pull-refresh_head的高度时,显示下拉即可刷新, 超过时显示释放即可刷新。这两句话不是固定的可以由用户自定义。

@Prop({
    
    default: '下拉即可刷新...'}) private pullingText!: string;
@Prop({
    
    default: '释放即可刷新...'}) private loosingText!: string;
handleTouchMove(event: TouchEvent){
    
    
   // ...
   if(this.direction === 'vertical'){
    
    
      this.distance = this._optimizeDistance(this.deltaY); 
       if(this.distance < this.headHeight){
    
    
          this.statusText = this.pullingText;
       }else {
    
    
          this.statusText = this.loosingText;
       }
   }
}

touchEnd事件实现

接下来我们释放的时候,就会触发touchEnd,如果下拉即可刷新即直接回到顶部,如果是释放即可刷新,则将pull-refresh__tracktranslateY更改为headHeight并显示加载中并触发使用了这个刷新组件的父组件的刷新事件。

原先我们是直接更改statusText,我们将它更改为computed属性,它的返回值根据status更改。

handleTouchMove(event: TouchEvent){
    
    
   // ...
   if(this.direction === 'vertical'){
    
    
      this.distance = this._optimizeDistance(this.deltaY); 
       if(this.distance < this.headHeight){
    
    
          this.status = 'pulling';
       }else {
    
    
          this.statusText = 'loosing';
       }
   }
}

get statusText(){
    
    
    switch(this.status){
    
    
      case 'pulling': return this.pullingText;
      case 'loosing': return this.loosingText;
      case 'loading': return this.loadingText;
      default: return this.pullingText;
    }
  }
public handleTouchEnd(){
    
    
    if(this.status === 'loosing'){
    
    
       // 释放即可刷新,则先回到`headHeight`高度,然后触发父组件的刷新事件
       this.$emit('input', true);
       this.$nextTick(() => {
    
    
         this.$emit('refresh');
       })
        this.distance = +this.headHeight;
    }else{
    
    
      // 下拉即可刷新时,则直接返回顶部
      this.status = 'pulling';
      this.distance = 0;
    }
}

我们可以在调用这个父组件里这么写:

<template>
  <div class="wrapper">
    <pull-refresh @refresh="handleRefresh" v-model="isLoading">
      <div class="pull-content">这是下拉刷新容器包裹的内容</div>
    </pull-refresh>
  </div>
</template>

<script lang='ts'>
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class  extends Vue {
  private isLoading: boolean = false;

  private handleRefresh(){
    setTimeout(() => {
      this.isLoading = false;
    }, 1000)
  }
}
</script>

根据value更新status

@Watch('value', {
    
     immediate: true })
  changeIsLoading(value: boolean){
    
    
    // 说明正在刷新
    if(value){
    
    
      this.status = 'loading';
      this.distance = +this.headHeight;
    }else{
    
    
      // 没有刷新
      this._updateStatus(0);
    }
  }

我们可以封装一个函数_updateStatus,在这个函数里,我们根据距离更新status,然后更新distace:

_updateStatus(distance: number, isLoading?:boolean){
    
    
    if(isLoading) this.status = 'loading';
    else if(distance < this.headHeight){
    
    
      this.status = 'pulling';
    }else {
    
    
      this.status = 'loosing';
    }
    this.distance = distance;
  }

试着把之前的代码重构一下吧~~

刷新过程中不可以再次刷新

现在已经大致完成了下拉刷新的功能,接下来进行优化,我们现在在刷新过程中又可以触发刷新过程,并不科学。

get enableRefresh(){
    
    
	return !(this.status === 'loading' || this.disabled);
}

public handleTouchStart(event: TouchEvent){
    
    
    if(!this.enableRefresh) return;
    // ...
}

public handleTouchMove(event: TouchEvent){
    
    
    if(!this.enableRefresh) return;
    // ...
}

public handleTouchEnd(event: TouchEvent){
    
    
    if(!this.enableRefresh) return;
    // ...
}

只有在顶部的时候才可以下拉刷新

接下来我们要考虑一种很常见的情况,在常见的页面中,上面是banner图,下面是列表,列表可以下拉刷新,我们应该等到页面在顶部的时候刷新,而不是随时随地都可以刷新。

mounted(){
    
    
    // getScroller函数获得当前可以滚动的元素,如果没有则获得最近的可滚动父元素 
    this.scrollEl = getScroller(this.$el as HTMLElement);
}

判断是否在顶部this.isCeiling = getScrollTop(this.scrollEl) === 0;

touchemove时要阻止默认行为

如果直接使用event.preventDefault会包下面的错误

[Intervention] Ignored attempt to cancel a touchmove event with cancelable=false, for example because scrolling is in progress and cannot be interrupted.

应该修改为:

if(typeof event.cancelBubble !== 'boolean' || event.cancelable) {
    
    
    event.preventDefault();
}

源码

封装了eventevent.ts:

// event.ts

// addEventListener绑定
export const on = (target: EventTarget, event: string, handler: EventHandlerNonNull, passive = false) => {
    
    
  target.addEventListener(event, handler, {
    
     capture: false, passive});
}

// removeEventListener移除
export const off = (target: EventTarget, event: string, handler: EventHandlerNonNull) => {
    
    
  target.removeEventListener(event, handler);
}

// 阻止冒泡
export const stopPropagation = (event: Event) => {
    
    
  event.stopPropagation();
}

// 阻止默认行为
export const preventDefault = (event: Event,  isStopPropagation?: boolean) => {
    
    
  if(typeof event.cancelBubble !== 'boolean' || event.cancelable) {
    
    
    event.preventDefault();
  }

  if(isStopPropagation){
    
    
    stopPropagation(event);
  }
}

封装了有关滚动的scroll.ts:

// scroll.ts
type ScrollElement = HTMLElement | Window;

// 是否是window
function isWindow(val: unknown): val is Window {
    
    
  return val === window;
}

// 获得滚动容器
const overflowScrollReg = /scroll|auto/i;
export const getScroller = (el: HTMLElement, root: ScrollElement = window) => {
    
    
  let node = el;

  // nodeType === 1代表是元素
  while(
    node &&
    node.tagName !== 'HTML' &&
    node.nodeType === 1 &&
    node !== root
  ){
    
    
    // 获得当前的overflow-y的值
    const {
    
     overflowY } = window.getComputedStyle(node);

    // 判断overflow-y是否等于scroll或者auto
    if(overflowScrollReg.test(overflowY)){
    
    
      if(node.tagName !== 'BODY') return node;

      // body元素可滚动的情况下,判断html元素是否可以滚动,可以则返回body
      // 不这么写的话,默认返回的是window, 原因如下:
      // 当只设置 html { overflow-y: scroll } 或 只设置 body { overflow-y: scroll } 时,滚动条及 scroll 事件都是直接作用到 window 上的。
      // 只有同时设置 html, body { overflow-y: scroll } 时,body 才会拥有滚动条和事件
      const {
    
     overflowY: htmlOverflowY } = window.getComputedStyle(
        node.parentElement as Element
      );

      if(overflowScrollReg.test(htmlOverflowY)) return node;
    }

    node = node.parentElement as HTMLElement;
  }

  return root;
}

// 获得滚动条距离顶部的距离
export const getScrollTop = (el: ScrollElement): number => {
    
    
  const top = 'scrollTop' in el ? el.scrollTop : el.pageYOffset;

  // ios由于有回弹的效果,所以到顶部时实际上的top可能会小于0
  return Math.max(top, 0);
}

·由于以后用到touch的地方很多,封装可以用mixinx的形式:

// touch.ts
import {
    
     Vue } from 'vue-property-decorator';
import {
    
     on, off } from '../utils/dom/event';
const MIN_DISTANCE = 10;

export default class TouchMixins extends Vue {
    
    
  public startX: number = 0;
  public startY: number = 0;
  public deltaX: number = 0;
  public deltaY: number = 0;
  public offsetX: number = 0;
  public offsetY: number = 0;
  public direction: string = '';

  resetTouchStatus(){
    
    
    this.direction = '';
    this.deltaX = 0;
    this.deltaY = 0;
    this.offsetX = 0;
    this.offsetY = 0;
  }

  getDirection(x: number, y: number){
    
    
    if(x > y && x > MIN_DISTANCE) return 'horizontal';
    if(y > x && y > MIN_DISTANCE) return 'vertical';
    return '';
  }

  touchStart(event: TouchEvent){
    
    
    this.resetTouchStatus();
    const touches = event.touches[0];
    this.startX = touches.clientX;
    this.startY = touches.clientY;
  }

  toucheMove(event: TouchEvent) {
    
    
    const touches = event.touches[0];
    this.deltaX = touches.clientX - this.startX;
    this.deltaY = touches.clientY - this.startY;
    this.offsetX = Math.abs(this.deltaX);
    this.offsetY = Math.abs(this.deltaY);
    this.direction = this.getDirection(this.offsetX, this.offsetY);
  }

  bindTouchEvent(el: EventTarget){
    
    
    // @ts-ignore
    const {
    
     handleTouchStart, handleTouchMove, handleTouchEnd }  = this;
    
    on(el, 'touchstart', handleTouchStart);
    on(el, 'touchmove', handleTouchMove);

    if(handleTouchEnd){
    
    
      on(el, 'touchend', handleTouchEnd);
      on(el, 'touchcancel', handleTouchEnd);
    }

    this.$once('hook:beforeDestory', () => {
    
    
      off(el, 'touchstart', handleTouchStart);
      off(el, 'touchmove', handleTouchMove);
      if(handleTouchEnd){
    
    
        off(el, 'touchend', handleTouchEnd);
        off(el, 'touchcancel', handleTouchEnd);
      }
    })
  }
}

刷新组件的源码:

<template>
  <div class="ar-pull-refresh">
    <div ref="track" class="ar-pull-refresh__track" :style="trackStyle">
      <div class="ar-pull-refresh__head" :style="headStyle">{
    
    {
    
    statusText}}</div>
      <slot></slot>
    </div>
  </div>
</template>

<script lang='ts'>
import {
    
     Component, Prop, Ref, Watch } from 'vue-property-decorator';
import {
    
     getScroller, getScrollTop } from '../../../src/utils/dom/scroll';
import {
    
     preventDefault } from '../../../src/utils/dom/event';
import ToucheMixins from '../../../src/mixins/touch';
const DEFAULT_HEAD_HEIGHT = 50;
const TEXT_STATUS = ['pulling', 'loosing', 'success'];
type ScrollElement = HTMLElement | Window;

@Component({
    
    
  name: 'ArPullRefresh'
})
export default class ArPullRefresh extends ToucheMixins {
    
    
  @Ref() readonly track: any;
  @Prop({
    
    default: false}) private value!: boolean;
  @Prop({
    
    default: '下拉即可刷新...'}) private pullingText!: string;
  @Prop({
    
    default: '释放即可刷新...'}) private loosingText!: string;
  @Prop({
    
    default: '加载中...'}) private loadingText!: string;
  @Prop({
    
    default: '刷新成功'}) private successText!: string;
  @Prop({
    
    default: 300}) private animationDuration!: string | number;
  @Prop({
    
    default: DEFAULT_HEAD_HEIGHT}) private headHeight!: string | number;
  @Prop({
    
    default: false}) private disabled!: boolean;

  private status: string = 'normal';
  private scrollEl: any;
  private distance: number = 0;
  private duration: number = 0;
  private isCeiling: boolean = true;

  // 优化下拉距离
  _optimizeDistance(distance: number){
    
    
    const headHeight = +this.headHeight;

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

    return Math.round(distance);
  }

  // 设置status
  _updateStatus(distance: number, isLoading?:boolean){
    
    
    if(isLoading) this.status = 'loading';
    else if(distance < this.headHeight){
    
    
      this.status = 'pulling';
    }else {
    
    
      this.status = 'loosing';
    }
    this.distance = distance;
  }

  public checkPullStart(event: TouchEvent){
    
    
    this.isCeiling = getScrollTop(this.scrollEl) === 0;
    if(this.isCeiling){
    
    
      this.duration = 0;
      this.touchStart(event);
    }
  }

  public handleTouchStart(event: TouchEvent){
    
    
    if(this.enableRefresh) {
    
    
      this.checkPullStart(event);
    };
  }

  public handleTouchMove(event: TouchEvent){
    
    
    if(!this.enableRefresh) return;

    if(!this.isCeiling){
    
    
      this.checkPullStart(event);
    }
    
    this.toucheMove(event);

    if(this.isCeiling && this.deltaY > 0 && this.direction === 'vertical'){
    
    
      preventDefault(event);
      let distance = this._optimizeDistance(this.deltaY);
      this._updateStatus(distance);
    }
  }

  public handleTouchEnd(){
    
    
    if(this.enableRefresh && this.isCeiling && this.deltaY){
    
    
      this.duration = +this.animationDuration;

      if(this.status === 'loosing'){
    
    
        this.$emit('input', true);
        this.$nextTick(() => {
    
    
          this.$emit('refresh');
        })
      }else{
    
    
        this._updateStatus(0);
      }
    }
  }

  get headStyle(){
    
    
    return {
    
    
      height: this.headHeight ? `${
      
      this.headHeight}px` : `${
      
      DEFAULT_HEAD_HEIGHT}px`
    }
  }

  get trackStyle(){
    
    
    return  {
    
    
      transitionDuration: `${
      
      this.duration}ms`,
      transform:  this.distance ? `translate3d(0,${
      
      this.distance}px, 0)` : '',
    }
  }

  get statusText(){
    
    
    switch(this.status){
    
    
      case 'pulling': return this.pullingText;
      case 'loosing': return this.loosingText;
      case 'loading': return this.loadingText;
      default: return this.pullingText;
    }
  }

  get enableRefresh(){
    
    
    return !(this.status === 'loading' || this.disabled);
  }

  @Watch('value', {
    
     immediate: true })
  changeIsLoading(value: boolean){
    
    
    this.duration = +this.animationDuration;

    if(value){
    
    
      this._updateStatus(+this.headHeight, true);
    }else{
    
    
      this._updateStatus(0);
    }
  }

  mounted(){
    
    
    this.bindTouchEvent(this.track);
    this.scrollEl = getScroller(this.$el as HTMLElement);
  }
}
</script>

写在最后

感谢阅读~

猜你喜欢

转载自blog.csdn.net/qq_34086980/article/details/108609295