Merge pull request #78 from jfurrow/performance/improve-torrent-list-rendering

Improve scrolling performance in torrent list
This commit is contained in:
John Furrow
2016-07-13 22:16:24 -07:00
committed by GitHub
6 changed files with 205 additions and 90 deletions
+6 -7
View File
@@ -22,6 +22,7 @@ $progress-bar--fill--selected: #fff;
$progress-bar--fill--stopped: #e3e5e5;
.progress-bar {
align-items: center;
display: flex;
transition: opacity 0.25s;
width: 100%;
@@ -66,15 +67,13 @@ $progress-bar--fill--stopped: #e3e5e5;
}
&__fill {
align-items: center;
background: $progress-bar--fill;
bottom: 0;
display: block;
height: $progress-bar--height;
left: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
transform-origin: 0 50%;
transition: background 0.25s, width 0.25s;
width: 100%;
z-index: 1;
.is-seeding & {
@@ -101,6 +100,7 @@ $progress-bar--fill--stopped: #e3e5e5;
&__wrapper {
flex: 1 1 auto;
position: relative;
height: 3px;
&:after {
background: $progress-bar--fill;
@@ -110,8 +110,7 @@ $progress-bar--fill--stopped: #e3e5e5;
opacity: 0.5;
position: absolute;
z-index: 0;
top: 50%;
transform: translateY(-50%);
top: 1px;
transition: background 0.25s, opacity 0.25s;
width: 100%;
+2 -2
View File
@@ -197,8 +197,8 @@ $more-info--border: $textbox-repeater--button--border;
&__details {
align-items: center;
display: flex;
flex: 1;
flex-flow: row wrap;
flex: 1 1 auto;
flex-flow: row;
list-style: none;
&--primary,
@@ -2,18 +2,30 @@ import classnames from 'classnames';
import React from 'react';
import {Scrollbars} from 'react-custom-scrollbars';
const METHODS_TO_BIND = ['getHorizontalThumb', 'getVerticalThumb'];
export default class CustomScrollbar extends React.Component {
constructor() {
super();
METHODS_TO_BIND.forEach((method) => {
this[method] = this[method].bind(this);
});
}
getHorizontalThumb(props) {
return (
<div {...props}
className="scrollbars__thumb scrollbars__thumb--horizontal"/>
className="scrollbars__thumb scrollbars__thumb--horizontal"
onMouseUp={this.props.onThumbMouseUp} />
);
}
getVerticalThumb(props) {
return (
<div {...props}
className="scrollbars__thumb scrollbars__thumb--vertical"/>
className="scrollbars__thumb scrollbars__thumb--vertical"
onMouseUp={this.props.onThumbMouseUp} />
);
}
@@ -39,6 +51,8 @@ export default class CustomScrollbar extends React.Component {
renderThumbHorizontal={this.getHorizontalThumb}
renderThumbVertical={this.getVerticalThumb}
onScroll={this.props.nativeScrollHandler}
onScrollStart={this.props.onScrollStart}
onScrollStop={this.props.onScrollStop}
onScrollFrame={this.props.scrollHandler}>
{this.props.children}
</Scrollbars>
@@ -1,16 +1,34 @@
import React from 'react';
let cachedProgressBars = {};
export default class ProgressBar extends React.Component {
render() {
let {percent} = this.props;
let progressBar;
if (cachedProgressBars[percent] != null) {
progressBar = cachedProgressBars[percent];
} else {
let style = {};
if (percent !== 100) {
style = {transform: `scaleX(${percent / 100})`};
}
progressBar = (
<div className="progress-bar__fill__wrapper">
<div className="progress-bar__fill" style={style} />
</div>
);
}
return (
<div className="progress-bar">
<div className="progress-bar__icon">
{this.props.icon}
</div>
<div className="progress-bar__fill__wrapper">
<div className="progress-bar__fill"
style={{width: `${this.props.percent}%`}} />
</div>
{progressBar}
</div>
);
}
@@ -16,11 +16,37 @@ import {torrentStatusIcons} from '../../util/torrentStatusIcons';
import {torrentStatusClasses} from '../../util/torrentStatusClasses';
import UploadThickIcon from '../Icons/UploadThickIcon';
const ICONS = {
clock: <ClockIcon />,
disk: <DiskIcon />,
downloadThick: <DownloadThickIcon />,
information: <InformationIcon />,
peers: <PeersIcon />,
ratio: <RatioIcon />,
seeds: <SeedsIcon />,
uploadThick: <UploadThickIcon />
};
const METHODS_TO_BIND = [
'handleClick',
'handleRightClick'
];
const TORRENT_PRIMITIVES_TO_OBSERVE = [
'bytesDone',
'downloadRate',
'status',
'tags',
'totalPeers',
'totalSeeds',
'uploadRate'
];
const TORRENT_ARRAYS_TO_OBSERVE = [
'status',
'tags'
];
export default class Torrent extends React.Component {
constructor() {
super();
@@ -30,6 +56,33 @@ export default class Torrent extends React.Component {
});
}
shouldComponentUpdate(nextProps) {
if (nextProps.selected !== this.props.selected) {
return true;
}
let nextTorrent = nextProps.torrent;
let {torrent} = this.props;
let shouldUpdate = TORRENT_ARRAYS_TO_OBSERVE.some((key) => {
let nextArr = nextTorrent[key];
let currentArr = this.props.torrent[key];
return nextArr.length !== currentArr.length ||
nextArr.some((nextValue, index) => {
return nextValue !== currentArr[index];
});
});
if (!shouldUpdate) {
return TORRENT_PRIMITIVES_TO_OBSERVE.some((key) => {
return nextTorrent[key] !== torrent[key];
});
}
return shouldUpdate;
}
getTags(tags) {
return tags.map((tag, index) => {
return (
@@ -65,7 +118,6 @@ export default class Torrent extends React.Component {
let uploadTotal = format.data(torrent.uploadTotal);
let torrentClasses = torrentStatusClasses(torrent, this.props.selected ? 'is-selected' : null, 'torrent');
let torrentStatusIcon = torrentStatusIcons(torrent.status);
let isActive = downloadRate.value > 0 || uploadRate.value > 0;
let isDownloading = downloadRate.value > 0;
@@ -73,13 +125,13 @@ export default class Torrent extends React.Component {
let secondaryDetails = [
<li className="torrent__details--secondary torrent__details--speed
torrent__details--speed--download" key="download-rate">
<span className="torrent__details__icon"><DownloadThickIcon /></span>
<span className="torrent__details__icon">{ICONS.downloadThick}</span>
{downloadRate.value}
<em className="unit">{downloadRate.unit}</em>
</li>,
<li className="torrent__details--secondary torrent__details--speed
torrent__details--speed--upload" key="upload-rate">
<span className="torrent__details__icon"><UploadThickIcon /></span>
<span className="torrent__details__icon">{ICONS.uploadThick}</span>
{uploadRate.value}
<em className="unit">{uploadRate.unit}</em>
</li>
@@ -89,7 +141,7 @@ export default class Torrent extends React.Component {
secondaryDetails.unshift(
<li className="torrent__details--secondary torrent__details--eta"
key="eta">
<span className="torrent__details__icon"><ClockIcon /></span>
<span className="torrent__details__icon">{ICONS.clock}</span>
{eta}
</li>
);
@@ -107,7 +159,7 @@ export default class Torrent extends React.Component {
<div className="torrent__details torrent__details--tertiary">
<ul className="torrent__details torrent__details--tertiary--stats">
<li className="torrent__details--completed">
<span className="torrent__details__icon"><DownloadThickIcon /></span>
<span className="torrent__details__icon">{ICONS.downloadThick}</span>
{torrent.percentComplete}
<em className="unit">%</em>
&nbsp;&mdash;&nbsp;
@@ -115,29 +167,29 @@ export default class Torrent extends React.Component {
<em className="unit">{completed.unit}</em>
</li>
<li className="torrent__details--uploaded">
<span className="torrent__details__icon"><UploadThickIcon /></span>
<span className="torrent__details__icon">{ICONS.uploadThick}</span>
{uploadTotal.value}
<em className="unit">{uploadTotal.unit}</em>
</li>
<li className="torrent__details--ratio">
<span className="torrent__details__icon"><RatioIcon /></span>
<span className="torrent__details__icon">{ICONS.ratio}</span>
{ratio}
</li>
<li className="torrent__details--size">
<span className="torrent__details__icon"><DiskIcon /></span>
<span className="torrent__details__icon">{ICONS.disk}</span>
{totalSize.value}
<em className="unit">{totalSize.unit}</em>
</li>
<li className="torrent__details--added">
<span className="torrent__details__icon"><CalendarIcon /></span>
<span className="torrent__details__icon">{ICONS.calendar}</span>
{addedString}
</li>
<li className="torrent__details--peers">
<span className="torrent__details__icon"><PeersIcon /></span>
<span className="torrent__details__icon">{ICONS.peers}</span>
{torrent.connectedPeers} <em className="unit">of</em> {torrent.totalPeers}
</li>
<li className="torrent__details--seeds">
<span className="torrent__details__icon"><SeedsIcon /></span>
<span className="torrent__details__icon">{ICONS.seeds}</span>
{torrent.connectedSeeds} <em className="unit">of</em> {torrent.totalSeeds}
</li>
</ul>
@@ -145,11 +197,11 @@ export default class Torrent extends React.Component {
{this.getTags(torrent.tags)}
</ul>
</div>
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcon} />
<ProgressBar percent={torrent.percentComplete} icon={torrentStatusIcons(torrent.status)} />
<button className="torrent__more-info floating-action__button"
onClick={this.props.handleDetailsClick.bind(this, torrent)}
tabIndex="-1">
<InformationIcon />
{ICONS.information}
</button>
</li>
);
+94 -62
View File
@@ -25,20 +25,26 @@ const METHODS_TO_BIND = [
'handleContextMenuItemClick',
'handleDetailsClick',
'handleRightClick',
'handleScrollStop',
'handleTorrentClick',
'onContextMenuChange',
'onReceiveTorrentsError',
'onReceiveTorrentsSuccess',
'onTorrentFilterChange',
'onTorrentSelectionChange',
'postponeRerender',
'setScrollPosition',
'setViewportHeight'
];
let cachedTorrentList = null;
class TorrentListContainer extends React.Component {
constructor() {
super();
this.lastScrollPosition = 0;
this.postponedRerender = false;
this.state = {
emptyTorrentList: false,
handleTorrentPriorityChange: null,
@@ -47,7 +53,7 @@ class TorrentListContainer extends React.Component {
minTorrentIndex: 0,
scrollPosition: 0,
torrentCount: 0,
torrentHeight: 72,
torrentHeight: 71,
torrents: [],
torrentRequestError: false,
torrentRequestSuccess: false,
@@ -58,13 +64,9 @@ class TorrentListContainer extends React.Component {
this[method] = this[method].bind(this);
});
this.setScrollPosition = _.throttle(this.setScrollPosition, 100, {
leading: true,
trailing: true
});
this.handleWindowResize = _.throttle(this.setViewportHeight, 350, {
leading: true,
this.handleWindowResize = _.debounce(this.setViewportHeight, 250);
this.postponeRerender = _.debounce(this.postponeRerender, 500);
this.setScrollPosition = _.throttle(this.setScrollPosition, 250, {
trailing: true
});
}
@@ -316,7 +318,12 @@ class TorrentListContainer extends React.Component {
// Calculate the number of items that should be rendered based on the height
// of the viewport. We offset this to render a few more outide of the
// container's dimensions, which looks nicer when the user scrolls.
let offset = 10;
let offset = 0;
if (this.postponedRerender === false
&& !this.refs.torrentList.refs.scrollbar.dragging) {
offset = 20;
}
// The number of elements in view is the height of the viewport divided
// by the height of the elements.
@@ -339,8 +346,25 @@ class TorrentListContainer extends React.Component {
TorrentFilterStore.clearAllFilters();
}
handleScrollStop() {
// Force update as soon as scrolling stops.
this.postponedRerender = false;
this.forceUpdate();
}
postponeRerender() {
global.requestAnimationFrame(() => {
this.postponedRerender = false;
this.forceUpdate();
});
}
setScrollPosition(scrollValues) {
this.setState({scrollPosition: scrollValues.scrollTop});
global.requestAnimationFrame(() => {
let {scrollTop} = scrollValues;
this.setState({scrollPosition: scrollTop});
this.lastScrollPosition = scrollTop;
});
}
setViewportHeight() {
@@ -357,64 +381,71 @@ class TorrentListContainer extends React.Component {
if (this.state.emptyTorrentList || this.state.torrents.length === 0) {
content = this.getEmptyTorrentListNotification();
} else if (this.state.torrentRequestSuccess) {
let contextMenu = null;
let selectedTorrents = TorrentStore.getSelectedTorrents();
let torrents = this.state.torrents;
let viewportLimits = this.getViewportLimits();
let scrollDelta = Math.abs(this.state.scrollPosition -
this.lastScrollPosition);
let listPadding = this.getListPadding(
viewportLimits.minTorrentIndex,
viewportLimits.maxTorrentIndex,
torrents.length
);
// If the torrent list is cached and the user is scrolling a large amount,
// or the user is dragging the scroll handle, then we postpone the list's
// rerender for better FPS.
if ((cachedTorrentList != null && scrollDelta > 1000)
|| this.refs.torrentList.refs.scrollbar.dragging === true) {
this.postponedRerender = true;
content = cachedTorrentList;
let maxTorrentIndex = viewportLimits.maxTorrentIndex;
let minTorrentIndex = viewportLimits.minTorrentIndex;
global.requestAnimationFrame(() => {
this.postponeRerender();
});
} else {
let contextMenu = null;
let selectedTorrents = TorrentStore.getSelectedTorrents();
let {minTorrentIndex, maxTorrentIndex} = this.getViewportLimits();
let {torrents} = this.state;
let listPadding = this.getListPadding(minTorrentIndex, maxTorrentIndex,
torrents.length);
if (minTorrentIndex < 0) {
minTorrentIndex = 0;
}
if (this.state.contextMenu != null) {
contextMenu = (
<ContextMenu clickPosition={this.state.contextMenu.clickPosition}
items={this.state.contextMenu.items} />
);
}
let visibleTorrents = torrents.slice(minTorrentIndex, maxTorrentIndex);
let torrentList = visibleTorrents.map((torrent, index) => {
let isSelected = false;
let hash = torrent.hash;
if (selectedTorrents.indexOf(hash) > -1) {
isSelected = true;
if (minTorrentIndex < 0) {
minTorrentIndex = 0;
}
return (
<Torrent key={hash} torrent={torrent} selected={isSelected}
handleClick={this.handleTorrentClick}
handleRightClick={this.handleRightClick}
handleDetailsClick={this.handleDetailsClick} />
);
});
if (this.state.contextMenu != null) {
contextMenu = (
<ContextMenu clickPosition={this.state.contextMenu.clickPosition}
items={this.state.contextMenu.items} />
);
}
content = (
<ul className="torrent__list" key="torrent__list">
<CSSTransitionGroup
transitionName="menu"
transitionEnterTimeout={250}
transitionLeaveTimeout={250}>
{contextMenu}
</CSSTransitionGroup>
<li className="torrent__spacer torrent__spacer--top"
style={{height: `${listPadding.top}px`}}></li>
{torrentList}
<li className="torrent__spacer torrent__spacer--bottom"
style={{height: `${listPadding.bottom}px`}}></li>
</ul>
);
let torrentList = torrents.slice(minTorrentIndex, maxTorrentIndex).map(
(torrent, index) => {
let {hash} = torrent;
return (
<Torrent key={hash} torrent={torrent}
selected={selectedTorrents.includes(hash)}
handleClick={this.handleTorrentClick}
handleRightClick={this.handleRightClick}
handleDetailsClick={this.handleDetailsClick} />
);
}
);
content = (
<ul className="torrent__list" key="torrent__list">
<CSSTransitionGroup
transitionName="menu"
transitionEnterTimeout={250}
transitionLeaveTimeout={250}>
{contextMenu}
</CSSTransitionGroup>
<li className="torrent__spacer torrent__spacer--top"
style={{height: `${listPadding.top}px`}}></li>
{torrentList}
<li className="torrent__spacer torrent__spacer--bottom"
style={{height: `${listPadding.bottom}px`}}></li>
</ul>
);
cachedTorrentList = content;
}
} else {
content = this.getLoadingIndicator();
}
@@ -422,6 +453,7 @@ class TorrentListContainer extends React.Component {
return (
<div className="torrent__list__wrapper">
<CustomScrollbars className="torrent__list__wrapper--custom-scroll"
onScrollStop={this.handleScrollStop}
ref="torrentList" scrollHandler={this.setScrollPosition}>
{content}
</CustomScrollbars>