@@ -9,94 +9,167 @@ import InternalContext from './InternalContext';
9
9
import SpineTo from '../SpineTo' ;
10
10
import StateContext from './StateContext' ;
11
11
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
+
12
33
export default class Composer extends React . Component {
13
34
constructor ( props ) {
14
35
super ( props ) ;
15
36
16
- this . createStateContext = memoize ( ( stateContext , scrollTop ) => ( {
17
- ...stateContext ,
18
- animating : scrollTop || scrollTop === 0
19
- } ) ) ;
20
-
21
37
this . handleScroll = this . handleScroll . bind ( this ) ;
22
38
this . handleScrollEnd = this . handleScrollEnd . bind ( this ) ;
23
39
40
+ this . _ignoreScrollEventBefore = 0 ;
41
+
24
42
this . state = {
25
43
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%' ) ,
28
49
scrollToEnd : ( ) => {
29
- const { state } = this ;
50
+ const { state : { functionContext , stateContext } } = this ;
30
51
31
- state . stateContext . mode === 'top' ? state . functionContext . scrollToTop ( ) : state . functionContext . scrollToBottom ( ) ;
52
+ stateContext . mode === 'top' ? functionContext . scrollToTop ( ) : functionContext . scrollToBottom ( ) ;
32
53
} ,
33
54
scrollToStart : ( ) => {
34
- const { state } = this ;
55
+ const { state : { functionContext , stateContext } } = this ;
35
56
36
- state . stateContext . mode === 'top' ? state . functionContext . scrollToBottom ( ) : state . functionContext . scrollToTop ( ) ;
57
+ stateContext . mode === 'top' ? functionContext . scrollToBottom ( ) : functionContext . scrollToTop ( ) ;
37
58
} ,
38
59
scrollToTop : ( ) => this . state . functionContext . scrollTo ( 0 )
39
60
} ,
40
61
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 } ) )
47
63
} ,
48
- scrollTop : null ,
64
+ scrollTop : props . mode === 'top' ? 0 : '100%' ,
49
65
stateContext : {
50
66
animating : false ,
51
67
atBottom : true ,
52
68
atEnd : true ,
53
69
atTop : true ,
54
70
mode : props . mode ,
55
- threshold : 10
71
+ sticky : true
56
72
} ,
57
73
target : null
58
74
} ;
59
75
}
60
76
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
+
61
107
componentWillReceiveProps ( nextProps ) {
62
108
this . setState ( ( { stateContext } ) => ( {
63
109
stateContext : {
64
110
...stateContext ,
65
- mode : nextProps . mode === 'top' ? 'top' : 'bottom' ,
66
- threshold : nextProps . threshold
111
+ mode : nextProps . mode === 'top' ? 'top' : 'bottom'
67
112
}
68
113
} ) ) ;
69
114
}
70
115
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".
78
125
79
- let nextStateContext ;
126
+ return ;
127
+ }
80
128
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 ) ;
84
142
nextStateContext = updateIn ( nextStateContext , [ 'atTop' ] , ( ) => atTop ) ;
85
143
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
+
86
155
if ( stateContext !== nextStateContext ) {
87
156
return { stateContext : nextStateContext } ;
88
157
}
89
158
}
159
+ } , ( ) => {
160
+ this . state . stateContext . sticky && this . enableWorker ( ) ;
90
161
} ) ;
91
162
}
92
163
93
164
handleScrollEnd ( ) {
165
+ // We should ignore debouncing handleScroll that emit before this time
166
+ this . _ignoreScrollEventBefore = Date . now ( ) ;
167
+
94
168
this . setState ( ( ) => ( { scrollTop : null } ) ) ;
95
169
}
96
170
97
171
render ( ) {
98
172
const {
99
- createStateContext,
100
173
handleScroll,
101
174
handleScrollEnd,
102
175
props : { children, debounce } ,
@@ -106,7 +179,7 @@ export default class Composer extends React.Component {
106
179
return (
107
180
< InternalContext . Provider value = { internalContext } >
108
181
< FunctionContext . Provider value = { functionContext } >
109
- < StateContext . Provider value = { createStateContext ( stateContext , scrollTop ) } >
182
+ < StateContext . Provider value = { stateContext } >
110
183
{ children }
111
184
{
112
185
target &&
@@ -118,7 +191,7 @@ export default class Composer extends React.Component {
118
191
/>
119
192
}
120
193
{
121
- target && ( scrollTop || scrollTop === 0 ) &&
194
+ target && scrollTop !== null &&
122
195
< SpineTo
123
196
name = "scrollTop"
124
197
onEnd = { handleScrollEnd }
@@ -134,11 +207,11 @@ export default class Composer extends React.Component {
134
207
}
135
208
136
209
Composer . defaultProps = {
137
- debounce : 17 ,
138
- threshold : 10
210
+ checkInterval : 150 ,
211
+ debounce : 17
139
212
} ;
140
213
141
214
Composer . propTypes = {
142
- debounce : PropTypes . number ,
143
- threshold : PropTypes . number
215
+ checkInterval : PropTypes . number ,
216
+ debounce : PropTypes . number
144
217
} ;
0 commit comments