import { useResizeObserver, useWindowSize } from '@vueuse/core' import type { FullGestureState } from '@vueuse/gesture' import { rubberbandIfOutOfBounds } from '@vueuse/gesture' import type { ShallowRef } from 'vue' import { computed, ref, watch } from 'vue' type SnapPoint = number | `${number}%` type ElementRef = Readonly> interface Options { canSwipeClose: boolean expandOnContentDrag: boolean halfAndExpand: boolean preventClose: boolean snapPoints: SnapPoint[] swipeCloseThreshold: string } interface Context { contentRef: ElementRef footerRef: ElementRef headerRef: ElementRef onDismiss: () => void scrollRef: ElementRef } const RUBBERBAND_TENSION = 0.25 const FLICK_CLOSE_TOLERANCE = 10 export const useBottomSheetDrag = (options: Options, context: Context) => { const { contentRef, footerRef, headerRef, onDismiss, scrollRef } = context const { height: windowHeight } = useWindowSize() const height = ref(null) const translateY = ref(0) const isDragging = ref(false) const naturalHeight = ref(0) const currentSnapPointIndex = ref(0) let dragStartHeight = 0 let contentDragOffset = 0 let isContentDragActive = false const measureNaturalHeight = () => { const parts = [headerRef, contentRef, footerRef] const total = parts.reduce((sum, part) => sum + (part.value?.getBoundingClientRect().height ?? 0), 0) naturalHeight.value = Math.ceil(total) } const resolvedSnapPoints = computed(() => { if (options.snapPoints.length) return options.snapPoints if (options.halfAndExpand) return ['50%', '100%'] return [naturalHeight.value] }) const snapPointHeights = computed(() => resolvedSnapPoints.value.map((snapPoint) => typeof snapPoint === 'number' ? Math.min(snapPoint, windowHeight.value) : (windowHeight.value * Number.parseFloat(snapPoint)) / 100, ), ) const minSnapPointHeight = computed(() => Math.min(...snapPointHeights.value)) const maxSnapPointHeight = computed(() => Math.max(...snapPointHeights.value)) const closestSnapPointIndex = computed(() => { const distances = snapPointHeights.value.map((snapPointHeight) => Math.abs(snapPointHeight - (height.value ?? 0))) return distances.indexOf(Math.min(...distances)) }) const swipeCloseDistance = computed(() => { const sheetHeight = height.value ?? minSnapPointHeight.value const threshold = Number.parseFloat(options.swipeCloseThreshold) if (Number.isNaN(threshold)) return sheetHeight / 2 if (options.swipeCloseThreshold.includes('%')) return (sheetHeight * threshold) / 100 return threshold }) const snapTo = (snapPointIndex: number) => { const snapPointHeight = snapPointHeights.value[snapPointIndex] if (snapPointHeight === undefined) return currentSnapPointIndex.value = snapPointIndex height.value = snapPointHeight translateY.value = 0 } const snapToSmallest = () => snapTo(snapPointHeights.value.indexOf(minSnapPointHeight.value)) const reset = () => { height.value = null translateY.value = 0 isDragging.value = false } const startDrag = () => { isDragging.value = true dragStartHeight = height.value ?? minSnapPointHeight.value } const applyDragOffset = (offsetY: number) => { const targetHeight = dragStartHeight - offsetY const minHeight = minSnapPointHeight.value if (targetHeight >= minHeight) { height.value = rubberbandIfOutOfBounds(targetHeight, 0, maxSnapPointHeight.value, RUBBERBAND_TENSION) translateY.value = 0 return } const overshoot = minHeight - targetHeight height.value = minHeight translateY.value = options.canSwipeClose ? overshoot : rubberbandIfOutOfBounds(overshoot, -minHeight, 0, RUBBERBAND_TENSION) } const shouldCloseAfterDrag = (swipeY: number) => { if (!options.canSwipeClose || options.preventClose) return false const isFlickedDown = swipeY > 0 && (height.value ?? 0) <= minSnapPointHeight.value + FLICK_CLOSE_TOLERANCE return isFlickedDown || translateY.value > swipeCloseDistance.value } const snapPointIndexInSwipeDirection = (swipeY: number) => { const currentHeight = height.value ?? 0 const isSwipingDown = swipeY > 0 const reachable = snapPointHeights.value.filter((snapPointHeight) => isSwipingDown ? snapPointHeight < currentHeight - 1 : snapPointHeight > currentHeight + 1, ) if (!reachable.length) return closestSnapPointIndex.value return snapPointHeights.value.indexOf(isSwipingDown ? Math.max(...reachable) : Math.min(...reachable)) } const endDrag = (swipeY: number) => { isDragging.value = false if (shouldCloseAfterDrag(swipeY)) { onDismiss() return } const isSwipe = swipeY !== 0 && snapPointHeights.value.length > 1 snapTo(isSwipe ? snapPointIndexInSwipeDirection(swipeY) : closestSnapPointIndex.value) } const onHandleDrag = ({ first, last, movement, swipe }: FullGestureState<'drag'>) => { if (first) startDrag() else if (last) endDrag(swipe[1]) else applyDragOffset(movement[1]) } const canStartContentDrag = (movementY: number) => { if (!options.expandOnContentDrag) return false if ((scrollRef.value?.scrollTop ?? 0) > 0) return false if (movementY > 0) return true return snapPointHeights.value.length > 1 && (height.value ?? 0) < maxSnapPointHeight.value } const onContentDrag = ({ first, last, movement, swipe }: FullGestureState<'drag'>) => { if (first) { isContentDragActive = false return } if (last) { if (isContentDragActive) endDrag(swipe[1]) isContentDragActive = false return } if (!isContentDragActive) { if (!canStartContentDrag(movement[1])) return isContentDragActive = true contentDragOffset = movement[1] startDrag() } applyDragOffset(movement[1] - contentDragOffset) } const onScrollTouchMove = (event: TouchEvent) => { if (!isContentDragActive || !event.cancelable) return event.preventDefault() } useResizeObserver([headerRef, contentRef, footerRef], measureNaturalHeight) watch(snapPointHeights, () => { if (height.value === null || isDragging.value) return snapTo(currentSnapPointIndex.value) }) return { height, isDragging, measureNaturalHeight, onContentDrag, onHandleDrag, onScrollTouchMove, reset, snapToSmallest, translateY, windowHeight, } }