Skip to content

Commit da00208

Browse files
authored
Rework scroll-to-end algorithm (#8)
* Rework algorithm * Remove threshold and format table * Remove threshold * Add checkInterval props * Add entry * Add sticky to state context * Update checkInterval
1 parent a34589b commit da00208

File tree

12 files changed

+208
-120
lines changed

12 files changed

+208
-120
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77
## [Unreleased]
88
### Changed
99
- Playground: bumped to `[email protected]`, `[email protected]`, and `[email protected]`
10+
- Update algorithm, instead of using `componentDidUpdate`, we now use `setInterval` to check if the panel is sticky or not, this help to track content update that happen outside of React lifecycle, for example, `HTMLImageElement.onload` event
11+
- `scrollTo()` now accepts `"100%"` instead of `"bottom"`
12+
13+
### Removed
14+
- Removed `threshold` props because the algorithm is now more robust
1015

1116
## [1.2.0] - 2018-10-28
1217
### Added

README.md

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ export default props =>
3333
3434
## Props
3535

36-
| Name | Default | Description |
37-
| - | - | - |
38-
| `className` | | Set the class name for the root element |
39-
| `debounce` | `17` | Set the debounce for tracking the `onScroll` event |
40-
| `followButtonClassName` | | Set the class name for the follow button |
41-
| `mode` | `"bottom"` | Set it to `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
42-
| `scrollViewClassName` | | Set the class name for the container element that house all `props.children` |
36+
| Name | Type | Default | Description |
37+
|-------------------------|----------|------------|------------------------------------------------------------------------------|
38+
| `checkInterval` | `number` | 150 | Recurring interval of stickiness check, in milliseconds (minimum is 17 ms) |
39+
| `className` | `string` | | Set the class name for the root element |
40+
| `debounce` | `number` | `17` | Set the debounce for tracking the `onScroll` event |
41+
| `followButtonClassName` | `string` | | Set the class name for the follow button |
42+
| `mode` | `string` | `"bottom"` | Set it to `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
43+
| `scrollViewClassName` | `string` | | Set the class name for the container element that house all `props.children` |
4344

4445
## Context
4546

@@ -49,27 +50,29 @@ We use 2 different contexts with different performance characteristics to provid
4950

5051
This context contains functions used to manipulate the container. And will not update throughout the lifetime of the composer.
5152

52-
| Name | Type | Description |
53-
| - | - | - |
54-
| `scrollTo` | `(scrollTop: number | 'bottom') => void` | Scroll panel to specified position |
55-
| `scrollToBottom` | `() => void` | Scroll panel to bottom |
56-
| `scrollToEnd` | `() => void` | Scroll panel to end (depends on `mode`) |
57-
| `scrollToStart` | `() => void` | Scroll panel to start (depends on `mode`) |
58-
| `scrollToTop` | `() => void` | Scroll panel to top |
53+
| Name | Type | Description |
54+
|------------------|----------------------------------------|-------------------------------------------|
55+
| `scrollTo` | `(scrollTop: number | '100%') => void` | Scroll panel to specified position |
56+
| `scrollToBottom` | `() => void` | Scroll panel to bottom |
57+
| `scrollToEnd` | `() => void` | Scroll panel to end (depends on `mode`) |
58+
| `scrollToStart` | `() => void` | Scroll panel to start (depends on `mode`) |
59+
| `scrollToTop` | `() => void` | Scroll panel to top |
5960

6061
### State context
6162

6263
This context contains state of the container.
6364

64-
| Name | Type | Description |
65-
| - | - | - |
66-
| `animating` | `boolean` | `true` if the panel is animating scroll effect |
67-
| `atBottom` | `boolean` | `true` if the panel is currently near bottom (see `threshold`) |
68-
| `atEnd` | `boolean` | `true` if the panel is currently near the end (depends on `mode`, see `mode` and `threshold` |
69-
| `atStart` | `boolean` | `true` if the panel is currently near the start (depends on `mode`, see `threshold`) |
70-
| `atTop` | `boolean` | `true` if the panel is currently near top (see `threshold`) |
71-
| `mode` | `string` | `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
72-
| `threshold` | `number` | Threshold in pixels to consider the panel is near top/bottom, read-only and only set thru `props` |
65+
| Name | Type | Description |
66+
|-------------|-----------|---------------------------------------------------------------------|
67+
| `animating` | `boolean` | `true` if the panel is animating scroll effect |
68+
| `atBottom` | `boolean` | `true` if the panel is currently near bottom |
69+
| `atEnd` | `boolean` | `true` if the panel is currently near the end (depends on `mode`) |
70+
| `atStart` | `boolean` | `true` if the panel is currently near the start (depends on `mode`) |
71+
| `atTop` | `boolean` | `true` if the panel is currently near top |
72+
| `mode` | `string` | `"bottom"` for scroll-to-bottom, `"top"` for scroll-to-top |
73+
| `sticky` | `boolean` | `true` if the panel is sticking to the end |
74+
75+
> `atEnd` and `sticky` are slightly different. During scroll animation, the panel is not at the end yet, but it is still sticky.
7376
7477
# Road map
7578

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"scripts": {
77
"bootstrap": "lerna bootstrap",
88
"build": "lerna run build --stream",
9-
"test": "lerna run test --stream"
9+
"test": "lerna run test --stream",
10+
"watch": "lerna run watch --parallel --stream"
1011
},
1112
"devDependencies": {
1213
"lerna": "^3.4.3"

packages/component/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"scripts": {
2121
"build": "babel --out-dir lib --ignore **/*.spec.js,**/*.test.js --source-maps inline --verbose src/",
2222
"clean": "rimraf lib",
23-
"test": "echo no test specified"
23+
"test": "echo no test specified",
24+
"watch": "npm run build -- --watch"
2425
},
2526
"author": "William Wong <[email protected]> (http://compulim.info/)",
2627
"license": "MIT",

packages/component/src/BasicScrollToBottom.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,24 @@ const ROOT_CSS = css({
1010
position: 'relative'
1111
});
1212

13-
export default props =>
13+
export default ({
14+
checkInterval,
15+
children,
16+
className,
17+
debounce,
18+
followButtonClassName,
19+
mode,
20+
scrollViewClassName
21+
}) =>
1422
<Composer
15-
debounce={ props.debounce }
16-
mode={ props.mode === 'top' ? 'top' : 'bottom'}
17-
threshold={ props.threshold }
23+
checkInterval={ checkInterval }
24+
debounce={ debounce }
25+
mode={ mode === 'top' ? 'top' : 'bottom'}
1826
>
19-
<div className={ classNames(ROOT_CSS + '', (props.className || '') + '') }>
20-
<Panel className={ props.scrollViewClassName }>
21-
{ props.children }
27+
<div className={ classNames(ROOT_CSS + '', (className || '') + '') }>
28+
<Panel className={ scrollViewClassName }>
29+
{ children }
2230
</Panel>
23-
<AutoHideFollowButton className={ props.followButtonClassName } />
31+
<AutoHideFollowButton className={ followButtonClassName } />
2432
</div>
2533
</Composer>

packages/component/src/EventSpy.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export default class EventSpy extends React.Component {
5858
}
5959

6060
handleEvent(event) {
61+
event.timeStampLow = Date.now();
62+
6163
this.debouncer(event);
6264
}
6365

packages/component/src/ScrollToBottom/AutoHideFollowButton.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const ROOT_CSS = css({
2828

2929
export default ({ children, className }) =>
3030
<StateContext.Consumer>
31-
{ ({ animating, atEnd }) => !animating && !atEnd &&
31+
{ ({ sticky }) => !sticky &&
3232
<FunctionContext.Consumer>
3333
{ ({ scrollToEnd }) =>
3434
<button

packages/component/src/ScrollToBottom/Composer.js

Lines changed: 112 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,94 +9,167 @@ import InternalContext from './InternalContext';
99
import SpineTo from '../SpineTo';
1010
import 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+
1233
export 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

136209
Composer.defaultProps = {
137-
debounce: 17,
138-
threshold: 10
210+
checkInterval: 150,
211+
debounce: 17
139212
};
140213

141214
Composer.propTypes = {
142-
debounce: PropTypes.number,
143-
threshold: PropTypes.number
215+
checkInterval: PropTypes.number,
216+
debounce: PropTypes.number
144217
};

0 commit comments

Comments
 (0)