Vant FloatingPanel 浮动面板源码学习

2,998 阅读3分钟

Vant FloatingPanel 浮动面板

vant官方介绍

浮动在页面底部的面板,可以上下拖动来浏览内容,常用于提供额外的功能或信息。请升级 vant 到 >= 4.5.0 版本来使用该组件。

image.png

背景

业务开发中,学(chao)习(xi)到某大厂页面,需要用到该功能,有个好消息,有个坏消息。 好消息是vant中已有该功能组件 坏消息是需要用到vue3以上版本,一看项目版本停留在 vue2.6.11 vant2.12.54,升级是不可能升级的,那就参考vant FloatingPanel组件吧

  • FloatingPanel 效果有点眼熟,google搜索不到基于vue2上面的组件

FloatingPanel 起源

FloatingPanel 最早是在 iOS 平台上开发起来的。它是一个用于创建浮动面板或悬浮窗口的 UI 控件,可以在应用程序的界面上显示一个可拖动、可调整大小的面板,提供额外的功能或信息展示。

FloatingPanel 在 iOS 开发中提供了更灵活的界面布局选项,可以帮助开发者实现类似于 Apple Maps 或其他应用中常见的浮动面板效果。通过使用 FloatingPanel,开发者可以轻松地添加这种浮动面板功能,而无需手动处理复杂的手势和布局。

随着时间的推移,FloatingPanel 的概念也被其他平台和框架采纳和实现,例如 Android 平台的 Floating Action Button(悬浮操作按钮)和 Web 开发中的浮动面板组件。但最早的 FloatingPanel 实现是在 iOS 平台上首次出现的。

image.gif

FloatingPanel 源码学习

组件开发中比较精妙的地方有如下:

  • 拖拽超出锚点之后的阻尼效果
  • 拖拽过程中的css3位移以及其动画效果
  • 拖拽过程中内容滚动情况下的优化
  • 拖拽后自动滚动到最近的锚点
  • useSyncPropRef 自动收集 prop height 的变化设置新值
  • useLockScroll 锁屏 body overflow hiddend
import {
  ref,
  watch,
  computed,
  defineComponent,
  type ExtractPropTypes,
} from 'vue';

// Utils
import {
  addUnit,
  // closest,
  createNamespace,
  makeArrayProp,
  makeNumericProp,
  preventDefault,
  truthProp,
  windowHeight,
} from '../utils';

function closest(arr: number[], target: number) {
  return arr.reduce((pre, cur) =>
    Math.abs(pre - target) < Math.abs(cur - target) ? pre : cur,
  );
}


// Composables
import { useEventListener } from '@vant/use';
import { useLockScroll } from '../composables/use-lock-scroll';
import { useTouch } from '../composables/use-touch';
import { useSyncPropRef } from '../composables/use-sync-prop-ref';

export const floatingPanelProps = {
  height: makeNumericProp(0),
  anchors: makeArrayProp<number>(),
  duration: makeNumericProp(0.3),
  contentDraggable: truthProp,
  lockScroll: Boolean,
  safeAreaInsetBottom: truthProp,
};

export type FloatingPanelProps = ExtractPropTypes<typeof floatingPanelProps>;

const [name, bem] = createNamespace('floating-panel');

export default defineComponent({
  name,

  props: floatingPanelProps,

  emits: ['heightChange', 'update:height'],

  setup(props, { emit, slots }) {
    const DAMP = 0.2; // 阻尼系数
    const rootRef = ref<HTMLDivElement>(); // 最外层嵌套层
    const contentRef = ref<HTMLDivElement>(); // 内容层
    // 响应式拦截 height属性
    const height = useSyncPropRef(
      () => +props.height,
      (value) => emit('update:height', value),
    );

    // 自动计算默认锚点
    const boundary = computed(() => ({
      min: props.anchors[0] ?? 100,
      max:
        props.anchors[props.anchors.length - 1] ??
        Math.round(windowHeight.value * 0.6),
    }));

    // 防止锚点没按要求传,取默认锚点
    const anchors = computed(() =>
      props.anchors.length >= 2
        ? props.anchors
        : [boundary.value.min, boundary.value.max],
    );

    // 是否拖拽
    const dragging = ref(false);

    // 根据拖拽距离,计算最终位移高度
    const rootStyle = computed(() => ({
      height: addUnit(boundary.value.max), // 有点绕,固定高度,你设置的anchors或者默认值
      transform: `translateY(calc(100% + ${addUnit(-height.value)}))`,
      transition: !dragging.value
        ? `transform ${props.duration}s cubic-bezier(0.18, 0.89, 0.32, 1.28)`
        : 'none', // 优化拖拽效果,拖拽时不需要动画,松手后才需要动画
    }));

    // 优化拖拽效果,超出边界时,增加阻尼效果
    const ease = (moveY: number): number => {
      const absDistance = Math.abs(moveY);
      const { min, max } = boundary.value;

      if (absDistance > max) {
        return -(max + (absDistance - max) * DAMP);
      }

      if (absDistance < min) {
        return -(min - (min - absDistance) * DAMP);
      }

      return moveY;
    };

    let startY: number;
    let maxScroll: number = -1;
    const touch = useTouch();
    // 开始拖拽
    const onTouchstart = (e: TouchEvent) => {
      touch.start(e);
      dragging.value = true;
      startY = -height.value;
      maxScroll = -1;
    };
    // 拖拽中
    const onTouchmove = (e: TouchEvent) => {
      touch.move(e);

      const target = e.target as Element;
      // 优化内容层拖拽效果,当内容层滚动到顶部时,再往下拖拽时,不需要拖拽效果
      if (contentRef.value === target || contentRef.value?.contains(target)) {
        const { scrollTop } = contentRef.value;
        // If maxScroll value more than zero, indicates that panel movement is not triggered from the top
        maxScroll = Math.max(maxScroll, scrollTop);

        if (!props.contentDraggable) return;
        // console.log('preventDefault',-startY, boundary.value.max);
        if (-startY < boundary.value.max) {
          // 阻止什么默认事件 ??
          preventDefault(e, true);
        } else if (
          !(scrollTop <= 0 && touch.deltaY.value > 0) ||
          maxScroll > 0
        ) {
          return;
        }
      }

      const moveY = touch.deltaY.value + startY;
      height.value = -ease(moveY);
    };
    // 拖拽结束
    const onTouchend = () => {
      maxScroll = -1;
      dragging.value = false;
      height.value = closest(anchors.value, height.value); // 优化拖拽效果,松手后,自动滚动到最近的锚点

      if (height.value !== -startY) {
        emit('heightChange', { height: height.value });
      }
    };

    // 监听锚点变化,自动滚动到最近的锚点
    watch(
      boundary,
      () => {
        height.value = closest(anchors.value, height.value);
      },
      { immediate: true },
    );

    useLockScroll(rootRef, () => props.lockScroll || dragging.value);

    // useEventListener will set passive to `false` to eliminate the warning of Chrome
    useEventListener('touchmove', onTouchmove, { target: rootRef });

    return () => (
      <div
        class={[bem(), { 'van-safe-area-bottom': props.safeAreaInsetBottom }]}
        ref={rootRef}
        style={rootStyle.value}
        onTouchstartPassive={onTouchstart}
        onTouchend={onTouchend}
        onTouchcancel={onTouchend}
      >
        <div class={bem('header')}>
          <div class={bem('header-bar')} />
        </div>
        <div class={bem('content')} ref={contentRef}>
          {slots.default?.()}
        </div>
      </div>
    );
  },
});