import React , { CSSProperties } from 'react' ; import { Node , scrollReasons , ScrollReasons , StickyVirtualizedListProps , StickyVirtualizedListState , } from './StickyVirtualizedList.interface' ; export class StickyVirtualizedList extends React . PureComponent < StickyVirtualizedListProps , StickyVirtualizedListState > { static defaultProps = { overscanRowCount : 10 , renderRoot : false , wrapAllLeafNodes : false , isModelImmutable : false , } ; nodes : Node [ ] ; getChildrenCache : { [ key ] : Node [ ] ; } ; rowRenderCache : { [ key ] : JSX . Element ; } ; rowRenderRange : { start : number ; end : number ; visibleStart : number ; visibleEnd : number ; } ; structureChanged : boolean ; elem : HTMLElement ; pendingScrollTop : number ; treeToRender : JSX . Element ; constructor ( props ) { super ( props ) ; this . onScroll = this . onScroll . bind ( this ) ; this . state = { scrollTop : 0 , currNodePos : 0 , scrollTick : false , } ; this . nodes = [ ] ; this . getChildrenCache = { } ; this . rowRenderCache = { } ; this . rowRenderRange = undefined ; } flattenTree ( node , props = this . props , nodes = [ ] , isFirstChild = false , isLastChild = false , parentIndex = undefined , context = { totalHeight : 0 } ) { const index = nodes . length ; const height = node . height !== undefined ? node . height : props . defaultRowHeight ; const parentInfo = nodes [ parentIndex ] ; const { id , isSticky = false , stickyTop = 0 , zIndex = 0 , ... rest } = node ; const nodeInfo = { id , isSticky , stickyTop , zIndex , ... rest , top : context . totalHeight , parentIndex , parentInfo , depth : parentIndex !== undefined ? parentInfo . depth + 1 : 0 , height , index , isFirstChild , isLastChild , } ; nodes . push ( nodeInfo ) ; if ( parentIndex !== undefined ) { parentInfo . children . push ( index ) ; } context . totalHeight += height ; const children = props . getChildren ( node . id , nodeInfo ) ; if ( props . isModelImmutable ) { const oldChildren = this . getChildrenCache [ node . id ] ; if ( children === undefined || oldChildren !== children ) { delete this . rowRenderCache [ node . id ] ; this . getChildrenCache [ node . id ] = children ; if ( children && oldChildren && ( children . length !== oldChildren . length || ! children . every ( ( child , i ) => child . id === oldChildren [ i ] . id ) ) ) { this . structureChanged = true ; this . getBranchChildrenIds ( children ) . forEach ( ( id ) => delete this . rowRenderCache [ id ] ) ; } } } else { this . structureChanged = true ; } if ( Array . isArray ( children ) ) { nodeInfo . children = [ ] ; for ( let i = 0 ; i < children . length ; i ++ ) { const child = children [ i ] ; this . flattenTree ( child , props , nodes , i === 0 , i === children . length - 1 , index , context ) ; } } nodeInfo . totalHeight = context . totalHeight - nodeInfo . top ; return nodes ; } getBranchChildrenIds ( children , arr = [ ] ) { if ( ! children ) { return arr ; } children . forEach ( ( child ) => { arr . push ( child . id ) ; this . getBranchChildrenIds ( this . getChildrenCache [ child . id ] , arr ) ; } ) ; return arr ; } UNSAFE_componentWillMount ( ) { this . refreshCachedMetadata ( this . props ) ; this . storeRenderTree ( this . props , this . state ) ; } treeDataUpdated ( newProps ) { return ( newProps . root !== this . props . root || newProps . getChildren !== this . props . getChildren || newProps . defaultRowHeight !== this . props . defaultRowHeight ) ; } UNSAFE_componentWillReceiveProps ( newProps ) { if ( this . treeDataUpdated ( newProps ) ) { this . refreshCachedMetadata ( newProps ) ; } if ( newProps . scrollIndex !== undefined && newProps . scrollIndex >= 0 ) { this . scrollIndexIntoView ( newProps . scrollIndex ) ; } } UNSAFE_componentWillUpdate ( newProps , newState ) { if ( newState . scrollTick === this . state . scrollTick || newState . currNodePos !== this . state . currNodePos ) { this . storeRenderTree ( newProps , newState ) ; } } param nodeId returns { number } getNodeIndex ( nodeId ) { return this . nodes . findIndex ( ( node ) => node . id === nodeId ) ; } param nodeId returns { * } getPreviousNodeId ( nodeId ) { const index = this . getNodeIndex ( nodeId ) ; if ( index !== - 1 ) { const node = this . nodes [ index - 1 ] ; if ( node ) { return node . id ; } } return undefined ; } param nodeId returns { * } getNextNodeId ( nodeId ) { const index = this . getNodeIndex ( nodeId ) ; if ( index !== - 1 ) { const node = this . nodes [ index + 1 ] ; if ( node ) { return node . id ; } } return undefined ; } param nodeId param includeObscured returns { boolean } isNodeVisible ( nodeId , includeObscured = false ) { return this . isIndexVisible ( this . getNodeIndex ( nodeId ) , includeObscured ) ; } param index param includeObscured returns { boolean } isIndexVisible ( index , includeObscured = false ) { let inView ; const node = this . nodes [ index ] ; if ( ! node ) { return false ; } if ( ( node . isSticky && index === this . state . currNodePos ) || this . getParentPath ( this . state . currNodePos ) . includes ( this . nodes [ index ] ) ) { return true ; } if ( ! includeObscured ) { inView = this . isIndexInViewport ( index ) ; } else { inView = this . elem . scrollTop <= node . top + node . height - node . stickyTop && this . elem . scrollTop + this . props . height >= node . top ; } if ( inView ) { const path = this . getParentPath ( index , false ) ; for ( let i = 0 ; i < path . length ; i ++ ) { const ancestor = path [ i ] ; if ( ancestor . isSticky ) { if ( ! includeObscured && ancestor . stickyTop + ancestor . height > node . top - this . elem . scrollTop ) { return false ; } if ( includeObscured && ancestor . stickyTop + ancestor . height > node . top + node . height - this . elem . scrollTop ) { return false ; } } } return true ; } return false ; } isNodeInViewport ( nodeId ) { return this . isIndexInViewport ( this . getNodeIndex ( nodeId ) ) ; } isIndexInViewport ( index ) { let node = this . nodes [ index ] ; if ( ! node || ! this . elem ) { return false ; } return ( this . elem . scrollTop <= node . top - node . stickyTop && this . elem . scrollTop + this . props . height >= node . top + node . height ) ; } getNodeTop ( nodeId ) { return this . getIndexTop ( this . getNodeIndex ( nodeId ) ) ; } getIndexTop ( index ) { const node = this . nodes [ index ] ; return node ? node . top : - 1 ; } getScrollTop ( ) { return this . elem ? this . elem . scrollTop : - 1 ; } setScrollTop ( scrollTop ) { if ( ! isNaN ( scrollTop ) ) { this . setScrollTopAndClosestNode ( scrollTop , this . state . currNodePos , scrollReasons . requested ) ; } } scrollNodeIntoView ( nodeId , alignToTop = true ) { this . scrollIndexIntoView ( this . getNodeIndex ( nodeId ) , alignToTop ) ; } scrollIndexIntoView ( index , alignToTop = true ) { let node = this . nodes [ index ] ; if ( node !== undefined ) { let scrollTop ; if ( alignToTop ) { if ( node . isSticky ) { scrollTop = node . top - node . stickyTop ; } else { const path = this . getParentPath ( index , false ) ; for ( let i = 0 ; i < path . length ; i ++ ) { const ancestor = path [ i ] ; if ( ancestor . isSticky ) { scrollTop = node . top - ancestor . stickyTop - ancestor . height ; break ; } } if ( scrollTop === undefined ) { scrollTop = node . top ; } } } else { scrollTop = node . top - this . props . height + node . height ; } this . setScrollTop ( scrollTop ) ; } } componentDidUpdate ( prevProps , prevState ) { if ( this . state . scrollReason === scrollReasons . requested ) { if ( this . state . scrollTop >= 0 && this . state . scrollTop !== this . elem . scrollTop ) { this . elem . scrollTop = this . state . scrollTop ; } } if ( this . props . onRowsRendered !== undefined && ( prevState . currNodePos !== this . state . currNodePos || this . treeDataUpdated ( prevProps ) ) ) { const range = this . rowRenderRange ; const visibleStartInfo = this . nodes [ range . visibleStart ] ; const visibleEndInfo = this . nodes [ range . visibleEnd ] ; this . props . onRowsRendered ( { overscanStartIndex : range . start , overscanStopIndex : range . end , startIndex : range . visibleStart , stopIndex : range . visibleEnd , startNode : visibleStartInfo && visibleStartInfo . id , endNode : visibleEndInfo && visibleEndInfo . id , nodes : this . nodes , } ) ; } } refreshCachedMetadata ( props ) { this . structureChanged = false ; this . nodes = this . flattenTree ( props . root , props ) ; if ( this . structureChanged ) { if ( this . elem ) { this . setScrollTopAndClosestNode ( this . pendingScrollTop || this . elem . scrollTop , 0 , scrollReasons . requested ) ; } } } recomputeTree ( ) { if ( this . props . root !== undefined && this . props . getChildren !== undefined ) { this . refreshCachedMetadata ( this . props ) ; this . forceUpdate ( ) ; } } storeRenderTree ( props , state ) { this . treeToRender = this . renderParentTree ( props , state ) ; } forceUpdate ( ) { this . getChildrenCache = { } ; this . rowRenderCache = { } ; this . storeRenderTree ( this . props , this . state ) ; super . forceUpdate ( ) ; } renderParentTree ( props , state ) { this . rowRenderRange = this . getRenderRowRange ( props , state ) ; const path = this . getParentPath ( this . rowRenderRange . start ) ; const indexesToRender = new Set ( ) ; for ( let i = 0 ; i < path . length ; i ++ ) { indexesToRender . add ( path [ i ] . index ) ; } for ( let i = this . rowRenderRange . start ; i <= this . rowRenderRange . end ; i ++ ) { indexesToRender . add ( this . nodes [ i ] . index ) ; } if ( this . props . renderRoot ) { return ( < div className = "rv-sticky-node-list" style = { { width : '100%' , position : 'absolute' , top : 0 } } > { this . renderChildWithChildren ( props , state , this . nodes [ 0 ] , 0 , indexesToRender ) } < /div>); } return this . renderParentContainer ( props , state , this . nodes [ 0 ] , indexesToRender ) ; } renderParentContainer ( props , state , parent , indexesToRender ) { return ( < div className = "rv-sticky-node-list" style = { { position : 'absolute' , width : '100%' , height : parent . totalHeight - parent . height , } } > { this . renderChildren ( props , state , parent , indexesToRender ) } < /div>); } getChildContainerStyle ( child , top ) { return { position : 'absolute' , top , height : child . totalHeight , width : '100%' , } as CSSProperties ; } renderChildWithChildren ( props , state , child , top , indexesToRender ) { return ( < div key = { `rv-node-${ child . id }` } className = "rv-sticky-parent-node" style = { this . getChildContainerStyle ( child , top ) } > { this . renderNode ( props , state , child , this . getClientNodeStyle ( child ) ) } { this . renderParentContainer ( props , state , child , indexesToRender ) } < /div>); } getClientNodeStyle ( node ) { const style = { height : node . height } ; if ( node . isSticky ) { style . position = 'sticky' ; style . top = node . stickyTop ; style . zIndex = node . zIndex ; } return style ; } getClientLeafNodeStyle ( node , top ) { return { position : 'absolute' , top , height : node . height , width : '100%' , } as CSSProperties ; } renderChildren ( props , state , parent , indexesToRender ) { const nodes = [ ] ; let top = 0 ; parent . children . forEach ( ( index ) => { const child = this . nodes [ index ] ; if ( indexesToRender . has ( index ) ) { if ( child . children && child . children . length > 0 ) { nodes . push ( this . renderChildWithChildren ( props , state , child , top , indexesToRender ) ) ; } else { if ( child . isSticky || props . wrapAllLeafNodes ) { nodes . push ( < div className = "rv-sticky-leaf-node" key = { `rv-node-${ child . id }` } style = { this . getChildContainerStyle ( child , top ) } > { this . renderNode ( props , state , child , this . getClientNodeStyle ( child ) ) } < /div>); } else { nodes . push ( this . renderNode ( props , state , child , this . getClientLeafNodeStyle ( child , top ) ) ) ; } } } top += child . totalHeight ; } ) ; return nodes ; } renderNode ( props , state , nodeInfo , style ? ) { if ( props . isModelImmutable && this . rowRenderCache [ nodeInfo . id ] ) { return this . rowRenderCache [ nodeInfo . id ] ; } const renderedRow = props . rowRenderer ( { id : nodeInfo . id , nodeInfo , style } ) ; if ( props . isModelImmutable ) { this . rowRenderCache [ nodeInfo . id ] = renderedRow ; } return renderedRow ; } returns { { start : number , end : number } } getRenderRowRange ( props , state ) { const overscanRowCount = props . overscanRowCount > 0 ? props . overscanRowCount : 1 ; let start = state . currNodePos - overscanRowCount ; if ( start < 0 ) { start = 0 ; } let visibleEnd = state . currNodePos + 1 ; while ( this . nodes [ visibleEnd ] && this . nodes [ visibleEnd ] . top < state . scrollTop + props . height ) { visibleEnd ++ ; } let end = visibleEnd + overscanRowCount ; if ( end > this . nodes . length - 1 ) { end = this . nodes . length - 1 ; } return { start , end , visibleStart : state . currNodePos , visibleEnd } ; } getParentPath ( nodeIndex , topDownOrder = true ) { let currNode = this . nodes [ nodeIndex ] ; const path = [ ] ; while ( currNode ) { currNode = this . nodes [ currNode . parentIndex ] ; if ( currNode ) { path . push ( currNode ) ; } } return topDownOrder ? path . reverse ( ) : path ; } forwardSearch ( scrollTop , searchPos ) { const nodes = this . nodes ; for ( let i = searchPos ; i < nodes . length ; i ++ ) { if ( nodes [ i ] . top >= scrollTop ) { return i ; } } return nodes . length - 1 ; } backwardSearch ( scrollTop , searchPos ) { const nodes = this . nodes ; for ( let i = Math . min ( searchPos , Math . max ( nodes . length - 1 , 0 ) ) ; i >= 0 ; i -- ) { if ( nodes [ i ] . top <= scrollTop ) { return i ; } } return 0 ; } setScrollTopAndClosestNode ( scrollTop , currNodePos , scrollReason ) { if ( scrollTop === this . state . scrollTop ) { return ; } if ( scrollTop >= this . elem . scrollHeight - this . elem . offsetHeight ) { scrollTop = this . elem . scrollHeight - this . elem . offsetHeight ; } let pos ; if ( scrollTop > this . state . scrollTop || currNodePos === 0 ) { pos = this . forwardSearch ( scrollTop , currNodePos ) ; } if ( scrollTop < this . state . scrollTop && pos === undefined ) { pos = this . backwardSearch ( scrollTop , currNodePos ) ; } this . pendingScrollTop = scrollTop ; this . setState ( { currNodePos : pos ? pos : 0 , scrollTop , scrollReason } , ( ) => { this . pendingScrollTop = undefined ; } ) ; } onScroll ( e ) { const { scrollTop , scrollLeft } = e . target as HTMLElement ; const scrollReason = this . state . scrollReason || scrollReasons . requested ; this . setScrollTopAndClosestNode ( scrollTop , this . state . currNodePos , scrollReason ) ; if ( this . props . onScroll !== undefined ) { this . props . onScroll ( { scrollTop , scrollLeft , scrollReason } ) ; } this . setState ( { scrollTick : ! this . state . scrollTick , scrollReason : undefined , } ) ; } render ( ) { let style = { overflow : 'auto' , position : 'relative' } as CSSProperties ; if ( this . props . width ) { style . width = this . props . width ; } if ( this . props . height ) { style . height = this . props . height ; } return ( < div ref = { ( elem ) => ( this . elem = elem ) } className = "rv-sticky-tree" style = { style } onScroll = { this . onScroll } > { this . treeToRender } < /div>); } }