@@ -9,94 +9,167 @@ import InternalContext from './InternalContext';
99import SpineTo from '../SpineTo' ;
1010import StateContext from './StateContext' ;
1111
12+ const MIN_CHECK_INTERVAL = 17 ;
13+
14+ function setImmediateInterval ( fn , ms ) {
15+ fn ( ) ;
16+
17+ return setInterval ( fn , ms ) ;
18+ }
19+
20+ function computeViewState ( { stateContext : { mode } , target : { offsetHeight, scrollHeight, scrollTop } } ) {
21+ const atBottom = scrollHeight - scrollTop - offsetHeight <= 0 ;
22+ const atTop = scrollTop <= 0 ;
23+ const atEnd = mode === 'top' ? atTop : atBottom ;
24+
25+ return {
26+ atBottom,
27+ atEnd,
28+ atStart : ! atEnd ,
29+ atTop
30+ } ;
31+ }
32+
1233export default class Composer extends React . Component {
1334 constructor ( props ) {
1435 super ( props ) ;
1536
16- this . createStateContext = memoize ( ( stateContext , scrollTop ) => ( {
17- ...stateContext ,
18- animating : scrollTop || scrollTop === 0
19- } ) ) ;
20-
2137 this . handleScroll = this . handleScroll . bind ( this ) ;
2238 this . handleScrollEnd = this . handleScrollEnd . bind ( this ) ;
2339
40+ this . _ignoreScrollEventBefore = 0 ;
41+
2442 this . state = {
2543 functionContext : {
26- scrollTo : scrollTop => this . setState ( ( ) => ( { scrollTop } ) ) ,
27- scrollToBottom : ( ) => this . state . functionContext . scrollTo ( 'bottom' ) ,
44+ scrollTo : scrollTop => this . setState ( ( { stateContext } ) => ( {
45+ scrollTop,
46+ stateContext : updateIn ( stateContext , [ 'animating' ] , ( ) => true )
47+ } ) ) ,
48+ scrollToBottom : ( ) => this . state . functionContext . scrollTo ( '100%' ) ,
2849 scrollToEnd : ( ) => {
29- const { state } = this ;
50+ const { state : { functionContext , stateContext } } = this ;
3051
31- state . stateContext . mode === 'top' ? state . functionContext . scrollToTop ( ) : state . functionContext . scrollToBottom ( ) ;
52+ stateContext . mode === 'top' ? functionContext . scrollToTop ( ) : functionContext . scrollToBottom ( ) ;
3253 } ,
3354 scrollToStart : ( ) => {
34- const { state } = this ;
55+ const { state : { functionContext , stateContext } } = this ;
3556
36- state . stateContext . mode === 'top' ? state . functionContext . scrollToBottom ( ) : state . functionContext . scrollToTop ( ) ;
57+ stateContext . mode === 'top' ? functionContext . scrollToBottom ( ) : functionContext . scrollToTop ( ) ;
3758 } ,
3859 scrollToTop : ( ) => this . state . functionContext . scrollTo ( 0 )
3960 } ,
4061 internalContext : {
41- _handleUpdate : ( ) => {
42- const { state } = this ;
43-
44- state . stateContext . atEnd && state . functionContext . scrollToEnd ( ) ;
45- } ,
46- _setTarget : target => this . setState ( ( ) => ( { target } ) )
62+ setTarget : target => this . setState ( ( ) => ( { target } ) )
4763 } ,
48- scrollTop : null ,
64+ scrollTop : props . mode === 'top' ? 0 : '100%' ,
4965 stateContext : {
5066 animating : false ,
5167 atBottom : true ,
5268 atEnd : true ,
5369 atTop : true ,
5470 mode : props . mode ,
55- threshold : 10
71+ sticky : true
5672 } ,
5773 target : null
5874 } ;
5975 }
6076
77+ componentDidMount ( ) {
78+ this . enableWorker ( ) ;
79+ }
80+
81+ disableWorker ( ) {
82+ clearInterval ( this . _stickyCheckTimeout ) ;
83+ }
84+
85+ enableWorker ( ) {
86+ clearInterval ( this . _stickyCheckTimeout ) ;
87+
88+ this . _stickyCheckTimeout = setImmediateInterval (
89+ ( ) => {
90+ const { state } = this ;
91+ const { stateContext : { sticky } , target } = state ;
92+
93+ if ( sticky && target ) {
94+ const { atEnd } = computeViewState ( state ) ;
95+
96+ ! atEnd && state . functionContext . scrollToEnd ( ) ;
97+ }
98+ } ,
99+ Math . max ( MIN_CHECK_INTERVAL , this . props . checkInterval ) || MIN_CHECK_INTERVAL
100+ ) ;
101+ }
102+
103+ componentWillUnmount ( ) {
104+ this . disableWorker ( ) ;
105+ }
106+
61107 componentWillReceiveProps ( nextProps ) {
62108 this . setState ( ( { stateContext } ) => ( {
63109 stateContext : {
64110 ...stateContext ,
65- mode : nextProps . mode === 'top' ? 'top' : 'bottom' ,
66- threshold : nextProps . threshold
111+ mode : nextProps . mode === 'top' ? 'top' : 'bottom'
67112 }
68113 } ) ) ;
69114 }
70115
71- handleScroll ( ) {
72- this . setState ( ( { stateContext, target } ) => {
73- if ( target ) {
74- const { mode, threshold } = stateContext ;
75- const { offsetHeight, scrollHeight, scrollTop } = target ;
76- const atBottom = scrollHeight - scrollTop - offsetHeight <= threshold ;
77- const atTop = scrollTop <= threshold ;
116+ handleScroll ( { timeStampLow } ) {
117+ // Currently, there are no reliable way to check if the "scroll" event is trigger due to
118+ // user gesture, programmatic scrolling, or Chrome-synthesized "scroll" event to compensate size change.
119+ // Thus, we use our best-effort to guess if it is triggered by user gesture, and disable sticky if it is heading towards the start direction.
120+
121+ if ( timeStampLow <= this . _ignoreScrollEventBefore ) {
122+ // Since we debounce "scroll" event, this handler might be called after spineTo.onEnd (a.k.a. artificial scrolling).
123+ // We should ignore debounced event fired after scrollEnd, because without skipping them, the userInitiatedScroll calculated below will not be accurate.
124+ // Thus, on a fast machine, adding elements super fast will lose the "stickiness".
78125
79- let nextStateContext ;
126+ return ;
127+ }
80128
81- nextStateContext = updateIn ( stateContext , [ 'atBottom' ] , ( ) => atBottom ) ;
82- nextStateContext = updateIn ( nextStateContext , [ 'atEnd' ] , ( ) => mode === 'top' ? atTop : atBottom ) ;
83- nextStateContext = updateIn ( nextStateContext , [ 'atStart' ] , ( ) => mode === 'top' ? atBottom : atTop ) ;
129+ this . disableWorker ( ) ;
130+
131+ this . setState ( state => {
132+ const { target } = state ;
133+
134+ if ( target ) {
135+ const { scrollTop, stateContext } = state ;
136+ const { atBottom, atEnd, atStart, atTop } = computeViewState ( state ) ;
137+ let nextStateContext = stateContext ;
138+
139+ nextStateContext = updateIn ( nextStateContext , [ 'atBottom' ] , ( ) => atBottom ) ;
140+ nextStateContext = updateIn ( nextStateContext , [ 'atEnd' ] , ( ) => atEnd ) ;
141+ nextStateContext = updateIn ( nextStateContext , [ 'atStart' ] , ( ) => atStart ) ;
84142 nextStateContext = updateIn ( nextStateContext , [ 'atTop' ] , ( ) => atTop ) ;
85143
144+ // Sticky means:
145+ // - If it is scrolled programatically, we are still in sticky mode
146+ // - If it is scrolled by the user, then sticky means if we are at the end
147+ nextStateContext = updateIn ( nextStateContext , [ 'sticky' ] , ( ) => stateContext . animating ? true : atEnd ) ;
148+
149+ // If no scrollTop is set (not in programmatic scrolling mode), we should set "animating" to false
150+ // "animating" is used to calculate the "sticky" property
151+ if ( scrollTop === null ) {
152+ nextStateContext = updateIn ( nextStateContext , [ 'animating' ] , ( ) => false ) ;
153+ }
154+
86155 if ( stateContext !== nextStateContext ) {
87156 return { stateContext : nextStateContext } ;
88157 }
89158 }
159+ } , ( ) => {
160+ this . state . stateContext . sticky && this . enableWorker ( ) ;
90161 } ) ;
91162 }
92163
93164 handleScrollEnd ( ) {
165+ // We should ignore debouncing handleScroll that emit before this time
166+ this . _ignoreScrollEventBefore = Date . now ( ) ;
167+
94168 this . setState ( ( ) => ( { scrollTop : null } ) ) ;
95169 }
96170
97171 render ( ) {
98172 const {
99- createStateContext,
100173 handleScroll,
101174 handleScrollEnd,
102175 props : { children, debounce } ,
@@ -106,7 +179,7 @@ export default class Composer extends React.Component {
106179 return (
107180 < InternalContext . Provider value = { internalContext } >
108181 < FunctionContext . Provider value = { functionContext } >
109- < StateContext . Provider value = { createStateContext ( stateContext , scrollTop ) } >
182+ < StateContext . Provider value = { stateContext } >
110183 { children }
111184 {
112185 target &&
@@ -118,7 +191,7 @@ export default class Composer extends React.Component {
118191 />
119192 }
120193 {
121- target && ( scrollTop || scrollTop === 0 ) &&
194+ target && scrollTop !== null &&
122195 < SpineTo
123196 name = "scrollTop"
124197 onEnd = { handleScrollEnd }
@@ -134,11 +207,11 @@ export default class Composer extends React.Component {
134207}
135208
136209Composer . defaultProps = {
137- debounce : 17 ,
138- threshold : 10
210+ checkInterval : 150 ,
211+ debounce : 17
139212} ;
140213
141214Composer . propTypes = {
142- debounce : PropTypes . number ,
143- threshold : PropTypes . number
215+ checkInterval : PropTypes . number ,
216+ debounce : PropTypes . number
144217} ;
0 commit comments