/*** 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;
}}