/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import type {Size, IntrinsicSize, Rect} from './geometry'; import type { Interaction, MouseDownInteraction, MouseMoveInteraction, MouseUpInteraction, WheelWithShiftInteraction, } from './useCanvasInteraction'; import type {ScrollState} from './utils/scrollState'; import type {ViewRefs} from './Surface'; import type {ViewState} from '../types'; import {Surface} from './Surface'; import {View} from './View'; import {rectContainsPoint} from './geometry'; import { clampState, areScrollStatesEqual, translateState, } from './utils/scrollState'; import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants'; import {COLORS} from '../content-views/constants'; const CARET_MARGIN = 3; const CARET_WIDTH = 5; const CARET_HEIGHT = 3; type OnChangeCallback = ( scrollState: ScrollState, containerLength: number, ) => void; export class VerticalScrollView extends View { _contentView: View; _isPanning: boolean; _mutableViewStateKey: string; _onChangeCallback: OnChangeCallback | null; _scrollState: ScrollState; _viewState: ViewState; constructor( surface: Surface, frame: Rect, contentView: View, viewState: ViewState, label: string, ) { super(surface, frame); this._contentView = contentView; this._isPanning = false; this._mutableViewStateKey = label + ':VerticalScrollView'; this._onChangeCallback = null; this._scrollState = { offset: 0, length: 0, }; this._viewState = viewState; this.addSubview(contentView); this._restoreMutableViewState(); } setFrame(newFrame: Rect) { super.setFrame(newFrame); // Revalidate scrollState this._setScrollState(this._scrollState); } desiredSize(): Size | IntrinsicSize { return this._contentView.desiredSize(); } draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) { super.draw(context, viewRefs); // Show carets if there's scroll overflow above or below the viewable area. if (this.frame.size.height > CARET_HEIGHT * 2 + CARET_MARGIN * 3) { const offset = this._scrollState.offset; const desiredSize = this._contentView.desiredSize(); const above = offset; const below = this.frame.size.height - desiredSize.height - offset; if (above < 0 || below < 0) { const {visibleArea} = this; const {x, y} = visibleArea.origin; const {width, height} = visibleArea.size; const horizontalCenter = x + width / 2; const halfWidth = CARET_WIDTH; const left = horizontalCenter + halfWidth; const right = horizontalCenter - halfWidth; if (above < 0) { const topY = y + CARET_MARGIN; context.beginPath(); context.moveTo(horizontalCenter, topY); context.lineTo(left, topY + CARET_HEIGHT); context.lineTo(right, topY + CARET_HEIGHT); context.closePath(); context.fillStyle = COLORS.SCROLL_CARET; context.fill(); } if (below < 0) { const bottomY = y + height - CARET_MARGIN; context.beginPath(); context.moveTo(horizontalCenter, bottomY); context.lineTo(left, bottomY - CARET_HEIGHT); context.lineTo(right, bottomY - CARET_HEIGHT); context.closePath(); context.fillStyle = COLORS.SCROLL_CARET; context.fill(); } } } } layoutSubviews() { const {offset} = this._scrollState; const desiredSize = this._contentView.desiredSize(); const minimumHeight = this.frame.size.height; const desiredHeight = desiredSize ? desiredSize.height : 0; // Force view to take up at least all remaining vertical space. const height = Math.max(desiredHeight, minimumHeight); const proposedFrame = { origin: { x: this.frame.origin.x, y: this.frame.origin.y + offset, }, size: { width: this.frame.size.width, height, }, }; this._contentView.setFrame(proposedFrame); super.layoutSubviews(); } handleInteraction(interaction: Interaction): ?boolean { switch (interaction.type) { case 'mousedown': return this._handleMouseDown(interaction); case 'mousemove': return this._handleMouseMove(interaction); case 'mouseup': return this._handleMouseUp(interaction); case 'wheel-shift': return this._handleWheelShift(interaction); } } onChange(callback: OnChangeCallback) { this._onChangeCallback = callback; } scrollBy(deltaY: number): boolean { const newState = translateState({ state: this._scrollState, delta: -deltaY, containerLength: this.frame.size.height, }); // If the state is updated by this wheel scroll, // return true to prevent the interaction from bubbling. // For instance, this prevents the outermost container from also scrolling. return this._setScrollState(newState); } _handleMouseDown(interaction: MouseDownInteraction) { if (rectContainsPoint(interaction.payload.location, this.frame)) { const frameHeight = this.frame.size.height; const contentHeight = this._contentView.desiredSize().height; // Don't claim drag operations if the content is not tall enough to be scrollable. // This would block any outer scroll views from working. if (frameHeight < contentHeight) { this._isPanning = true; } } } _handleMouseMove(interaction: MouseMoveInteraction): void | boolean { if (!this._isPanning) { return; } // Don't prevent mouse-move events from bubbling if they are horizontal drags. const {movementX, movementY} = interaction.payload.event; if (Math.abs(movementX) > Math.abs(movementY)) { return; } const newState = translateState({ state: this._scrollState, delta: interaction.payload.event.movementY, containerLength: this.frame.size.height, }); this._setScrollState(newState); return true; } _handleMouseUp(interaction: MouseUpInteraction) { if (this._isPanning) { this._isPanning = false; } } _handleWheelShift(interaction: WheelWithShiftInteraction): boolean { const { location, delta: {deltaX, deltaY}, } = interaction.payload; if (!rectContainsPoint(location, this.frame)) { return false; // Not scrolling on view } const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); if (absDeltaX > absDeltaY) { return false; // Scrolling horizontally } if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { return false; // Movement was too small and should be ignored. } return this.scrollBy(deltaY); } _restoreMutableViewState() { if ( this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey) ) { this._scrollState = ((this._viewState.viewToMutableViewStateMap.get( this._mutableViewStateKey, ): any): ScrollState); } else { this._viewState.viewToMutableViewStateMap.set( this._mutableViewStateKey, this._scrollState, ); } this.setNeedsDisplay(); } _setScrollState(proposedState: ScrollState): boolean { const contentHeight = this._contentView.frame.size.height; const containerHeight = this.frame.size.height; const clampedState = clampState({ state: proposedState, minContentLength: contentHeight, maxContentLength: contentHeight, containerLength: containerHeight, }); if (!areScrollStatesEqual(clampedState, this._scrollState)) { this._scrollState.offset = clampedState.offset; this._scrollState.length = clampedState.length; this.setNeedsDisplay(); if (this._onChangeCallback !== null) { this._onChangeCallback(clampedState, this.frame.size.height); } return true; } // Don't allow wheel events to bubble past this view even if we've scrolled to the edge. // It just feels bad to have the scrolling jump unexpectedly from in a container to the outer page. // The only exception is when the container fits the content (no scrolling). if (contentHeight === containerHeight) { return false; } return true; } }