mirror of
https://github.com/facebook/react.git
synced 2026-02-27 03:07:57 +00:00
* Facebook -> Meta in copyright rg --files | xargs sed -i 's#Copyright (c) Facebook, Inc. and its affiliates.#Copyright (c) Meta Platforms, Inc. and affiliates.#g' * Manual tweaks
298 lines
8.3 KiB
JavaScript
298 lines
8.3 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|