Skip to content

Commit

Permalink
Merge pull request #181 from VKCOM/pulltorefresh
Browse files Browse the repository at this point in the history
PullToRefresh component
  • Loading branch information
fedorov-xyz authored Feb 1, 2019
2 parents ba5a0ce + 115cb65 commit ba78a3a
Show file tree
Hide file tree
Showing 9 changed files with 562 additions and 1 deletion.
96 changes: 96 additions & 0 deletions src/components/PullToRefresh/PullToRefresh.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
.PullToRefresh {
height: 100%;
}

.PullToRefresh--refreshing {
touch-action: none;
pointer-events: none;
}

.PullToRefresh__controls {
left: 0;
width: 100%;
pointer-events: none;
z-index: 100;
}

.PullToRefresh--ios .PullToRefresh__controls {
z-index: 0;
}

.PullToRefresh__spinner {
display: flex;
margin: auto;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
box-sizing: border-box;
transition: transform 300ms cubic-bezier(.1, 0, .25, 1), opacity 220ms ease-out;
opacity: 0;
}

.PullToRefresh--ios .PullToRefresh__spinner {
color: var(--activity_indicator_tint);
}

.PullToRefresh--android .PullToRefresh__spinner {
background: var(--background_suggestions);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
color: var(--accent);
}

.PullToRefresh--watching .PullToRefresh__spinner {
transition: opacity 220ms ease-out;
}

.PullToRefresh__spinner-self {
stroke: currentColor;
}

.PullToRefresh--refreshing .PullToRefresh__spinner-self {
animation: PullToRefreshToRefreshing 380ms ease-out;
}

.PullToRefresh__spinner-path {
transform: rotate(-90deg);
transform-origin: center center;
transition: stroke-dashoffset 167ms ease-out;
}

.PullToRefresh--watching .PullToRefresh__spinner-path,
.PullToRefresh--refreshing .PullToRefresh__spinner-path {
transition: none;
}

.PullToRefresh__spinner--on .PullToRefresh__spinner-path {
animation: rotator var(--duration) linear infinite;
}

.PullToRefresh__content {
overflow: hidden;
transition: transform 400ms var(--ios-easing);
}

.PullToRefresh--watching .PullToRefresh__content {
transition: none;
}

@keyframes PullToRefreshToRefreshing {
0% {
transform: scale(1);
}

30% {
transform: scale(.6);
}

90% {
transform: scale(1.1);
}

100% {
transform: scale(1);
}
}
240 changes: 240 additions & 0 deletions src/components/PullToRefresh/PullToRefresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import Touch from '../Touch/Touch';
import FixedLayout from '../FixedLayout/FixedLayout';
import classNames from '../../lib/classNames';
import { platform, ANDROID, IOS } from '../../lib/platform';
import getClassName from '../../helpers/getClassName';
import PullToRefreshSpinner from './PullToRefreshSpinner';

const baseClassName = getClassName('PullToRefresh');

const osname = platform();
const isAndroid = osname === ANDROID;
const isIOS = osname === IOS;

function cancelEvent (event) {
if (!event) return false;
while (event.originalEvent) event = event.originalEvent;
if (event.preventDefault) event.preventDefault();
if (event.stopPropagation) event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
event.cancelBubble = true;
event.returnValue = false;
return false;
}

export default class PullToRefresh extends PureComponent {
constructor (props) {
super(props);

this.params = {
start: isAndroid ? -40 : -10,
max: isAndroid ? 80 : 50,
maxY: isAndroid ? 80 : 400,
refreshing: isAndroid ? 50 : 36,

positionMultiplier: isAndroid ? 1 : 0.21
};

this.state = {
watching: false,
refreshing: false,
canRefresh: false,

touchDown: false,
refreshingFinished: false,

touchY: 0,
spinnerY: this.params.start,
spinnerProgress: 0,
contentShift: 0
};

this._contentElement = React.createRef();
}

static propTypes = {
children: PropTypes.element,
className: PropTypes.string,

/**
* Будет вызвана для обновления контента
*/
onRefresh: PropTypes.func.isRequired,

/**
* Определяет, выполняется ли обновление. Для скрытия спиннера после получения контента необходимо передать `false`
*/
isFetching: PropTypes.bool
};

static contextTypes = {
window: PropTypes.any,
document: PropTypes.any
};

get document () {
return this.context.document || document;
}

get window () {
return this.context.window || window;
}

componentDidMount () {
this.document.addEventListener('touchmove', this.onWindowTouchMove, { cancelable: true, passive: false });
}

componentWillUnmount () {
this.document.removeEventListener('touchmove', this.onWindowTouchMove);
}

componentWillReceiveProps (nextProps) {
if (!nextProps.isFetching && this.props.isFetching) {
this.onRefreshingFinish();
}
}

get scrollTop () {
return this.document.scrollingElement.scrollTop;
}

onTouchStart = (e) => {
if (this.state.refreshing) cancelEvent(e);
this.setState({ touchDown: true });
};

onWindowTouchMove = (e) => {
if (this.state.refreshing) cancelEvent(e);
};

onTouchMove = (e) => {
const { isY, shiftY } = e;
const { start, max } = this.params;
const pageYOffset = this.window.pageYOffset;

const { refreshing, watching, touchDown } = this.state;

if (watching && touchDown) {
cancelEvent(e);

const { positionMultiplier } = this.params;

const shift = Math.max(0, shiftY - this.state.touchY);

const currentY = Math.max(start, Math.min(this.params.maxY, start + (shift * positionMultiplier)));
const progress = currentY > -10 ? Math.abs((currentY + 10) / max) * 80 : 0;

this.setState({
spinnerY: currentY,
spinnerProgress: Math.min(80, Math.max(0, progress)),
canRefresh: progress > 80,
contentShift: (currentY + 10) * 2.3
});

if (progress > 85 && !refreshing) {
this.runRefreshing();
}
} else if (isY && pageYOffset === 0 && shiftY > 0 && !refreshing && touchDown) {
cancelEvent(e);

this.setState({
watching: true,
touchY: shiftY,
spinnerY: start,
spinnerProgress: 0
});
}
};

onTouchEnd = () => {
const { refreshing, canRefresh, refreshingFinished } = this.state;

this.setState({
watching: false,
touchDown: false
}, () => {
if (canRefresh && !refreshing) {
this.runRefreshing();
} else if (refreshing && refreshingFinished) {
this.resetRefreshingState();
} else {
this.setState({
spinnerY: refreshing ? this.params.refreshing : this.params.start,
spinnerProgress: 0,
contentShift: 0
});
}
});
};

runRefreshing () {
if (!this.state.refreshing && this.props.onRefresh) {
this.setState({
refreshing: true
});

this.props.onRefresh();
}
}

onRefreshingFinish = () => {
this.setState({
refreshingFinished: true
}, () => {
!this.state.touchDown && this.resetRefreshingState();
});
}

resetRefreshingState () {
this.setState({
watching: false,
canRefresh: false,
refreshing: false,
refreshingFinished: false,
spinnerY: this.params.start,
spinnerProgress: 0,
contentShift: 0
});
}

render () {
const { children, className, onRefresh, isFetching, ...restProps } = this.props;
const { watching, refreshing, spinnerY, spinnerProgress, canRefresh, touchDown, contentShift } = this.state;

return (
<Touch
onStart={this.onTouchStart}
onMove={this.onTouchMove}
onEnd={this.onTouchEnd}
className={classNames(baseClassName, className, {
'PullToRefresh--watching': watching,
'PullToRefresh--refreshing': refreshing
})}
{...restProps}
>
<FixedLayout className="PullToRefresh__controls">
<PullToRefreshSpinner
style={{
transform: `translate3d(0, ${spinnerY}px, 0)`,
opacity: watching || refreshing || canRefresh ? 1 : 0
}}
on={refreshing}
progress={refreshing ? null : spinnerProgress}
/>
</FixedLayout>

<div
className="PullToRefresh__content"
ref={this._contentElement}
style={{
transform: refreshing && !touchDown && isIOS ? `translate3d(0, 100px, 0)` : isIOS && contentShift ? `translate3d(0, ${contentShift}px, 0)` : ''
}}
>
{children}
</div>
</Touch>
);
}
}
Loading

0 comments on commit ba78a3a

Please sign in to comment.