import cx from 'classnames' ; import _ from 'lodash-es' ; import React from 'react' ; import { Subscription } from 'rxjs' ; import * as op from 'rxjs/operators' ; import styled from 'styled-components' ; import { mergeRefs } from '../../utils' ; import { domUtils } from './dom-utils' ; import { getFullRenderRange , makeRowHeightManager , OVERSCAN_SIZE , sum } from './makeRowHeightManager' ; const ROW_INDEX = 'data-rex-virtual-list-row-index' ; const VirtualListDiv = styled . div `
  position: relative;
` ; export interface VirtualListProps < Row > { rows : Row [ ] ; renderRow ( row , rowIndex , listRowDataset ) : React . ReactElement ; estimatedRowHeight : number ; virtualThreshold ? : number ; style ? : React . CSSProperties ; className ? : string ; containerRef ? : React . ForwardedRef < HTMLDivElement > ; } interface VirtualListState { offset : number ; maxRenderHeight : number ; } export enum VirtualListAlign { start = 'start' , center = 'center' , end = 'end' } /**
 * 虚拟滚动列表组件
 */ export class VirtualList < Row > extends React . Component < VirtualListProps < Row > , VirtualListState > { static defaultProps = { virtualThreshold : 200 , } ; readonly manager = makeRowHeightManager ( this . props . rows . length , this . props . estimatedRowHeight ) ; readonly containerRef = React . createRef < HTMLDivElement > ( ) ; readonly composedContainerRef = mergeRefs ( this . props . containerRef , this . containerRef ) ; get container ( ) { return this . containerRef . current ; } public scrollToRow ( rowIndex , align = VirtualListAlign . center ) { rowIndex = _ . clamp ( rowIndex , 0 , this . manager . cache . length - 1 ) ; const rowHeight = this . manager . cache [ rowIndex ] ; const scrollParent = domUtils . getScrollParent ( this . container ) ; const commonOffsetAncestor = domUtils . findCommonOffsetAncestor ( this . container , scrollParent ) ; const targetRect = domUtils . getRelativeLayoutRect ( commonOffsetAncestor , this . container ) ; const scrollParentRect = domUtils . getRelativeLayoutRect ( commonOffsetAncestor , scrollParent ) ; const currentOffset = scrollParentRect . top - targetRect . top ; const desiredOffset = sum ( this . manager . cache . slice ( 0 , rowIndex ) ) ; const clipTop = Math . max ( targetRect . top , scrollParentRect . top ) ; const clipBottom = Math . min ( targetRect . bottom , scrollParentRect . bottom ) ; const maxRenderHeight = clipBottom - clipTop ; let delta = desiredOffset - currentOffset ; if ( align === VirtualListAlign . center ) { delta -= ( maxRenderHeight - rowHeight ) / 2 ; } else if ( align === VirtualListAlign . end ) { delta -= maxRenderHeight - rowHeight ; } domUtils . applyScrollDelta ( scrollParent , delta ) ; } private subscription : Subscription ; state = { offset : 0 , maxRenderHeight : 400 , } ; private trySubscribeRichVisibleRects ( ) { if ( this . subscription != null ) { return ; } const { rows , virtualThreshold } = this . props ; const rowCount = rows . length ; if ( rowCount < virtualThreshold ) { return ; } const richVisibleRects$ = domUtils . getRichVisibleRectsStream ( this . container ) ; this . subscription = richVisibleRects$ . pipe ( op . map ( ( { clipRect , offsetY : offset } ) => ( { maxRenderHeight : clipRect . bottom - clipRect . top , offset , } ) ) , op . distinctUntilChanged ( ( prev , next ) => { return ( Math . abs ( prev . maxRenderHeight - next . maxRenderHeight ) < OVERSCAN_SIZE / 2 && Math . abs ( prev . offset - next . offset ) < OVERSCAN_SIZE / 2 ) ; } ) ) . subscribe ( ( nextPartialState ) => { this . setState ( nextPartialState ) ; } ) ; } componentDidMount ( ) { this . trySubscribeRichVisibleRects ( ) ; } componentDidUpdate ( ) { this . trySubscribeRichVisibleRects ( ) ; if ( ! this . subscription ) { return ; } const container = this . containerRef . current ; const manager = this . manager ; let zeroHeightRowCount = 0 ; let maxRowIndex = - 1 ; let maxRowBottom = - 1 ; const rows = container . querySelectorAll < HTMLDivElement > ( `:scope > *[${ ROW_INDEX }]` ) ; for ( const row of rows ) { const rowIndex = Number ( row . dataset . rexVirtualListRowIndex ) ; if ( isNaN ( rowIndex ) ) { continue ; } maxRowIndex = Math . max ( maxRowIndex , rowIndex ) ; const offset = row . offsetTop ; const size = row . offsetHeight ; if ( size === 0 ) { zeroHeightRowCount += 1 ; } maxRowBottom = Math . max ( maxRowBottom , offset + size ) ; manager . updateRow ( rowIndex , offset , size ) ; } if ( maxRowIndex !== - 1 && zeroHeightRowCount === 0 ) { const { offset , maxRenderHeight } = this . state ; if ( maxRowBottom < offset + maxRenderHeight ) { const nextVerticalRenderRange = this . manager . getRenderRange ( offset , maxRenderHeight , this . props . rows . length ) ; if ( nextVerticalRenderRange . bottomIndex - 1 > maxRowIndex ) { this . forceUpdate ( ) ; } } } } componentWillUnmount ( ) { this . subscription ?. unsubscribe ( ) ; } render ( ) { const { rows , renderRow , style , className , virtualThreshold } = this . props ; const { offset , maxRenderHeight } = this . state ; const rowCount = rows . length ; const range = rowCount >= virtualThreshold ? this . manager . getRenderRange ( offset , maxRenderHeight , rowCount ) : getFullRenderRange ( rowCount ) ; const visibleRows = rows . slice ( range . topIndex , range . bottomIndex ) ; if ( process . env . NODE_ENV !== 'production' ) { if ( style ?. height != null || style ?. maxHeight != null || style ?. overflow != null ) { console . log ( '[rexd] 不要为 <VirtualList /> 设置 style.height/style.maxHeight/style.overflow，请在上层元素中设置这些样式' ) ; } } return ( < VirtualListDiv ref = { this . composedContainerRef } style = { style } className = { cx ( 'rex-virtual-list' , className ) } > { range . topBlank > 0 && < div style = { { height : range . topBlank } } data - rex - virtual - top - blank = "true" / > } { visibleRows . map ( ( row , visibleRowIndex ) => { const rowIndex = visibleRowIndex + range . topIndex ; const listRowDataset = { [ ROW_INDEX ] : String ( rowIndex ) } ; return renderRow ( row , rowIndex , listRowDataset ) ; } ) } { range . bottomBlank > 0 && < div style = { { height : range . bottomBlank } } data - rex - virtual - bottom - blank = "true" / > } < /VirtualListDiv>); } }