Vant FloatingPanel 浮动面板
vant官方介绍
浮动在页面底部的面板,可以上下拖动来浏览内容,常用于提供额外的功能或信息。请升级 vant 到 >= 4.5.0 版本来使用该组件。
背景
业务开发中,学(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 平台上首次出现的。
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>
);
},
});