import { noop } from '@vkontakte/vkjs'; import { clamp } from '../../../helpers/math'; import { rubberbandIfOutOfBounds } from '../../animation'; import { getNearestOverflowAncestor, hasSelectionWithRangeType } from '../../dom'; import { UIPanGestureRecognizer } from '../../touch/UIPanGestureRecognizer'; import { BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY, DRAG_THRESHOLDS, DYNAMIC_SNAP_POINT_DATA, SNAP_POINT_DETENTS, } from '../constants'; import type { CSSTransitionController } from './CSSTransitionController'; export type SnapPointDetents = [number, number] | [number, number, number]; export type SnapPoint = 'auto' | { initial: number; detents: SnapPointDetents }; export type SnapPointChange = (snapPoint: number) => void; export type BottomSheetControllerOptions = { sheetScrollEl: HTMLElement | null; sheetTransitionController: CSSTransitionController; backdropTransitionController: CSSTransitionController | null; onSnapPointChange: SnapPointChange; onDismiss: VoidFunction; }; export class BottomSheetController { private readonly sheetEl: HTMLElement; constructor( sheetEl: HTMLElement, { sheetScrollEl, sheetTransitionController, backdropTransitionController, onSnapPointChange, onDismiss, }: BottomSheetControllerOptions, ) { this.sheetEl = sheetEl; this.onSnapPointChange = onSnapPointChange; this.onDismiss = onDismiss; this.panGestureRecognizer = new UIPanGestureRecognizer(); this.sheetScrollEl = sheetScrollEl; this.sheetTransitionController = sheetTransitionController; this.backdropTransitionController = backdropTransitionController; } init(snapPoint: SnapPoint) { this.isInitialized = true; if (snapPoint === 'auto') { this.unit = 'px'; this.currentSnapPoint = DYNAMIC_SNAP_POINT_DATA.IDLE_POINT_VALUE; this.snapPointDetents = [SNAP_POINT_DETENTS.MIN, DYNAMIC_SNAP_POINT_DATA.IDLE_POINT_VALUE]; } else { this.unit = '%'; this.currentSnapPoint = snapPoint.initial; this.snapPointDetents = snapPoint.detents; } } destroy() { this.isInitialized = false; this.pannedEl = null; this.sheetTransitionController.cleanup(); this.backdropTransitionController?.cleanup(); this.disableVerticalScrollBouncingDispose(); this.disableVerticalScrollBouncingDispose = noop; } panStart(event: UIEvent) { if ( !this.isInitialized || this.panState !== 'idle' || hasSelectionWithRangeType(event.target) ) { return; } this.panState = 'start'; this.pannedEl = event.target as HTMLElement; this.panGestureRecognizer.setStartCoords(event); } panMove(event: UIEvent) { switch (this.panState) { case 'start': this.panGestureRecognizer.setInitialTimeOnce(); this.panGestureRecognizer.setEndCoords(event); if (this.preventUntilPanGestureBecomesExpected()) { return; } if (this.preventImmediatelyIfPannedElIsNotValid()) { this.panState = 'idle'; return; } if (this.preventUntilVerticalScrollingOnSheetScrollElBecomesExpected()) { return; } if (this.preventImmediatelyIfVerticalScrollingOnPannedElIsScrolled()) { this.panState = 'idle'; return; } this.panState = 'moving'; this.panGestureRecognizer.setStartCoords(event); this.sheetHeight = this.sheetEl.offsetHeight; this.disableVerticalScrollBouncingDispose = BottomSheetController.disableVerticalScrollBouncingIfNeeded( this.sheetScrollEl, this.pannedEl, ); if (this.isDynamicSnapPoint) { this.currentSnapPoint = this.sheetHeight; this.snapPointDetents[DYNAMIC_SNAP_POINT_DATA.COMPUTED_INDEX] = this.sheetHeight; } break; case 'moving': this.panGestureRecognizer.setEndCoords(event); const { y1, y2 } = this.panGestureRecognizer; this.nextSnapPoint = rubberbandIfOutOfBounds( this.currentSnapPoint - ((y2 - y1) / this.sheetHeight) * this.currentSnapPoint, SNAP_POINT_DETENTS.MIN, this.isDynamicSnapPoint ? this.sheetHeight : SNAP_POINT_DETENTS.LARGE, ); this.calculateSnapPoint(this.nextSnapPoint, true); break; } } panEnd() { switch (this.panState) { case 'moving': const prevCurrentSnapPoint = this.currentSnapPoint; this.currentSnapPoint = this.getSnapPointTo(this.nextSnapPoint); if ( prevCurrentSnapPoint !== this.currentSnapPoint && this.currentSnapPoint > SNAP_POINT_DETENTS.MIN ) { this.onSnapPointChange(this.currentSnapPoint); } this.calculateSnapPoint(this.currentSnapPoint); break; } this.panState = 'idle'; this.panGestureRecognizer.reset(); this.disableVerticalScrollBouncingDispose(); this.disableVerticalScrollBouncingDispose = noop; } private isInitialized = false; private panState: 'idle' | 'start' | 'moving' = 'idle'; private pannedEl: HTMLElement | null = null; private sheetHeight = 0; private rafId: number | null = null; private currentSnapPoint = 0; private nextSnapPoint = 0; private snapPointDetents: SnapPointDetents = [0, 0]; private unit: 'px' | '%' = '%'; private get isDynamicSnapPoint() { return this.unit === 'px'; } private disableVerticalScrollBouncingDispose = noop; private readonly sheetScrollEl: HTMLElement | null; private readonly sheetTransitionController: CSSTransitionController; private readonly backdropTransitionController: CSSTransitionController | null; private readonly panGestureRecognizer: UIPanGestureRecognizer; private readonly onSnapPointChange: SnapPointChange; private readonly onDismiss: VoidFunction; private calculateSnapPoint(nextSnapPoint: number, immediately = false) { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); } if (nextSnapPoint <= SNAP_POINT_DETENTS.MIN) { this.sheetTransitionController.enableTransition(); this.backdropTransitionController?.enableTransition(); this.panState = 'idle'; this.onDismiss(); return; } const backdropOpacity = clamp( this.isDynamicSnapPoint ? nextSnapPoint / this.sheetHeight : (nextSnapPoint * 2) / SNAP_POINT_DETENTS.LARGE, 0, 1, ); this.rafId = requestAnimationFrame(() => { if (immediately) { this.backdropTransitionController?.disableTransition().set(backdropOpacity); this.sheetTransitionController.disableTransition().set(`${nextSnapPoint}${this.unit}`); return; } if (this.isDynamicSnapPoint) { this.sheetTransitionController.cleanupOnTransitionEnd(); } this.backdropTransitionController?.unset(); this.sheetTransitionController.enableTransition().set(`${this.currentSnapPoint}${this.unit}`); }); } private getSnapPointTo(nextSnapPoint: number) { const closestSnapPoint = BottomSheetController.getClosestSnapPoint( this.snapPointDetents, nextSnapPoint, ); if (closestSnapPoint !== this.currentSnapPoint) { return closestSnapPoint; } const panDirection = this.panGestureRecognizer.direction(); if (panDirection.axis !== 'y' || panDirection.direction === null) { return this.currentSnapPoint; } const velocity = this.panGestureRecognizer.velocity(); if (Math.abs(velocity.y) < DRAG_THRESHOLDS.VELOCITY) { return this.currentSnapPoint; } const closestSnapPointByDirection = BottomSheetController.getClosestSnapPointByDirection( this.snapPointDetents, closestSnapPoint, panDirection.direction, ); return closestSnapPointByDirection; } private preventUntilPanGestureBecomesExpected() { return ( this.panGestureRecognizer.direction().axis === 'x' || this.panGestureRecognizer.distance() < DRAG_THRESHOLDS.DISTANCE_FOR_MOVING_START ); } private preventImmediatelyIfPannedElIsNotValid() { return ( this.pannedEl === null || // Элемент со специальным атрибутом this.pannedEl.closest(`[${BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY}=true]`) !== null || // eslint-disable-line no-restricted-properties // Элемент за пределами панели. !this.sheetEl.contains(this.pannedEl) ); } private preventUntilVerticalScrollingOnSheetScrollElBecomesExpected() { if ( this.sheetScrollEl === null || !this.sheetScrollEl.contains(this.pannedEl) || this.sheetScrollEl.scrollHeight <= this.sheetScrollEl.clientHeight ) { return false; } if (this.sheetScrollEl.scrollTop === 0) { return ( this.panGestureRecognizer.direction().direction === -1 && BottomSheetController.isLastSnapPointDetents(this.snapPointDetents, this.currentSnapPoint) ); } return true; } private preventImmediatelyIfVerticalScrollingOnPannedElIsScrolled() { if ( /* istanbul ignore next: покрываем TypeScript */ this.pannedEl === null || this.pannedEl === this.sheetEl || this.pannedEl === this.sheetScrollEl ) { return false; } const overflowAncestor = getNearestOverflowAncestor(this.pannedEl, this.sheetEl); if ( overflowAncestor === null || this.sheetScrollEl === overflowAncestor || overflowAncestor.scrollHeight <= overflowAncestor.clientHeight ) { return false; } return ( overflowAncestor.scrollTop !== 0 || this.panGestureRecognizer.direction().direction === -1 ); } private static disableVerticalScrollBouncingIfNeeded( sheetScrollEl: HTMLElement | null, targetEl: HTMLElement | null, ) { if ( sheetScrollEl !== null && sheetScrollEl.scrollTop <= 0 && sheetScrollEl.contains(targetEl) && sheetScrollEl.scrollHeight > sheetScrollEl.clientHeight ) { const scrollbarWidth = sheetScrollEl.offsetWidth - sheetScrollEl.clientWidth; Object.assign(sheetScrollEl.style, { paddingInlineEnd: `${scrollbarWidth}px`, overflowY: 'hidden', boxSizing: 'border-box', }); return function dispose() { Object.assign(sheetScrollEl.style, { paddingInlineEnd: '', overflowY: '', boxSizing: '', }); }; } return noop; } private static isLastSnapPointDetents( snapPointDetents: SnapPointDetents, currentY: number, ): boolean { return currentY === snapPointDetents[snapPointDetents.length - 1]; } private static getClosestSnapPointByDirection( snapPointDetents: SnapPointDetents, currentY: number, direction: -1 | 1, ): number { const foundIndex = snapPointDetents.indexOf(currentY); switch (direction) { case -1: return snapPointDetents[foundIndex + 1] ?? snapPointDetents[snapPointDetents.length - 1]; case 1: return snapPointDetents[foundIndex - 1] ?? snapPointDetents[0]; } } private static getClosestSnapPoint(snapPointDetents: SnapPointDetents, currentY: number) { let closest = snapPointDetents[0]; let minDifference = Math.abs(snapPointDetents[0] - currentY); for (let i = 1; i < snapPointDetents.length; i += 1) { const difference = Math.abs(snapPointDetents[i] - currentY); if (difference < minDifference) { closest = snapPointDetents[i]; minDifference = difference; } } return closest; } }