diff --git a/src/components/PullToRefresh/PullToRefresh.css b/src/components/PullToRefresh/PullToRefresh.css new file mode 100644 index 0000000000..51fc92a96a --- /dev/null +++ b/src/components/PullToRefresh/PullToRefresh.css @@ -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); + } + } diff --git a/src/components/PullToRefresh/PullToRefresh.js b/src/components/PullToRefresh/PullToRefresh.js new file mode 100644 index 0000000000..0c4e5b95a8 --- /dev/null +++ b/src/components/PullToRefresh/PullToRefresh.js @@ -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 ( + + + + + +
+ {children} +
+
+ ); + } +} diff --git a/src/components/PullToRefresh/PullToRefreshSpinner.js b/src/components/PullToRefresh/PullToRefreshSpinner.js new file mode 100644 index 0000000000..1c761b08ed --- /dev/null +++ b/src/components/PullToRefresh/PullToRefreshSpinner.js @@ -0,0 +1,83 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import classNames from '../../lib/classNames'; + +function calcStrokeDashOffset (value, radius) { + const progress = value / 100; + return 2 * Math.PI * radius * (1 - progress); +} + +export default class PullToRefreshSpinner extends PureComponent { + constructor (props) { + super(props); + + const { size, strokeWidth } = this.props; + + const radius = 0.5 * size - 0.5 * strokeWidth; + const dasharray = 2 * Math.PI * radius; + const circleCenter = 0.5 * size; + + this.state = { radius, dasharray, circleCenter }; + } + + static propTypes = { + size: PropTypes.number, + strokeWidth: PropTypes.number, + on: PropTypes.bool, + progress: PropTypes.number, + style: PropTypes.object + }; + + static defaultProps = { + size: 24, + strokeWidth: 2.5, + on: true, + progress: null + }; + + render () { + const { on, progress, size, strokeWidth, style } = this.props; + const { radius, dasharray, circleCenter } = this.state; + + const dashoffset = calcStrokeDashOffset(on ? 80 : progress, radius); + + return ( +
+ + + + + +
+ ); + } +} diff --git a/src/components/PullToRefresh/Readme.md b/src/components/PullToRefresh/Readme.md new file mode 100644 index 0000000000..1e6f79dd69 --- /dev/null +++ b/src/components/PullToRefresh/Readme.md @@ -0,0 +1,61 @@ +Компонент для обновления контента жестом pull-to-refresh. +Работает на тач-экранах. + +При достаточном вытягивании спиннера вызывается обязательное свойство-функция `onRefresh`. + +Необходимо при срабатывании `onRefresh` передать `isFetching={true}` компоненту, а затем после получения контента установить его как `false` для скрытия спиннера. + +``` +class Example extends React.Component { + constructor () { + let items = []; + + for (let i = 0; i < 10; i++) { + items.push(this.getNewItem()) + } + + this.state = { + items: items, + fetching: false + } + + this.onRefresh = () => { + this.setState({ fetching: true }); + + setTimeout(() => { + this.setState({ + items: [this.getNewItem(), ...this.state.items], + fetching: false + }); + }, getRandomInt(600, 2000)); + } + } + + getNewItem() { + return getRandomUser(); + } + + render () { + + return ( + + + Пользователи + + + + + {this.state.items.map(({ id, name, photo }, i) => } + >{name})} + + + + + + ) + } +} + + +``` diff --git a/src/index.js b/src/index.js index 96a5c50f07..37f8c02c01 100644 --- a/src/index.js +++ b/src/index.js @@ -52,6 +52,7 @@ export { default as Tabs } from './components/Tabs/Tabs'; export { default as TabsItem } from './components/TabsItem/TabsItem'; export { default as FixedTabs } from './components/FixedTabs/FixedTabs'; export { default as Spinner } from './components/Spinner/Spinner'; +export { default as PullToRefresh } from './components/PullToRefresh/PullToRefresh'; export { default as Link } from './components/Link/Link'; export { default as Tooltip } from './components/Tooltip/Tooltip'; diff --git a/src/styles/common.css b/src/styles/common.css index b63acd326e..45c975ec30 100644 --- a/src/styles/common.css +++ b/src/styles/common.css @@ -35,6 +35,7 @@ @import '../components/TabsItem/TabsItem.css'; @import '../components/Spinner/Spinner.css'; @import '../components/ScreenSpinner/ScreenSpinner.css'; +@import '../components/PullToRefresh/PullToRefresh.css'; @import '../components/Link/Link.css'; @import '../components/Tooltip/Tooltip.css'; @import '../components/FormLayout/FormLayout.css'; diff --git a/styleguide/config.js b/styleguide/config.js index 9d6fa0b6bf..b8d45ce1e4 100644 --- a/styleguide/config.js +++ b/styleguide/config.js @@ -72,7 +72,8 @@ module.exports = { '../src/components/Tabs/Tabs.js', '../src/components/TabsItem/TabsItem.js', '../src/components/FixedTabs/FixedTabs.js', - '../src/components/Tooltip/Tooltip.js' + '../src/components/Tooltip/Tooltip.js', + '../src/components/PullToRefresh/PullToRefresh.js' ] }, { name: 'Forms', diff --git a/styleguide/setup.js b/styleguide/setup.js index 93e157c0b8..5d7237a3e7 100644 --- a/styleguide/setup.js +++ b/styleguide/setup.js @@ -32,6 +32,7 @@ import Icon28Notifications from '@vkontakte/icons/dist/28/notifications'; import Icon28Messages from '@vkontakte/icons/dist/28/messages'; import Icon28More from '@vkontakte/icons/dist/28/more'; import pkg from '../package'; +import { getRandomInt, getRandomUser } from './utils'; window.uaList = { ios: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36', @@ -78,3 +79,6 @@ window.Icon28Search = Icon28Search; window.Icon28Notifications = Icon28Notifications; window.Icon28Messages = Icon28Messages; window.Icon28More = Icon28More; + +window.getRandomInt = getRandomInt; +window.getRandomUser = getRandomUser; \ No newline at end of file diff --git a/styleguide/utils.js b/styleguide/utils.js new file mode 100644 index 0000000000..72d54b98ef --- /dev/null +++ b/styleguide/utils.js @@ -0,0 +1,74 @@ +export function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export const testStrings = [ + 'Далеко-далеко за словесными горами в стране, гласных и согласных живут рыбные тексты.', + 'Переписали правилами по всей свой буквенных единственное жизни пустился!', + 'Которой реторический, текстами речью маленький обеспечивает пор они знаках свой текстов но!', + 'Семантика по всей ведущими несколько за, выйти эта, lorem свой рукописи на берегу свое обеспечивает, злых коварный?', + 'Путь не сих переулка сбить моей, напоивший, над продолжил необходимыми, снова журчит за грамматики злых.', + 'По всей подпоясал, безопасную рыбного деревни послушавшись свое маленький текстов.', + 'Сбить свою несколько маленький взобравшись.', + 'Подпоясал осталось своего, до это свой реторический всеми агенство.', + 'Назад океана, коварных они моей дороге но рукописи которое заманивший власти проектах текст себя текста то великий решила взобравшись большой приставка?', + 'Свой там, переписывается семь, напоивший буквенных необходимыми если первую предложения своих?', + 'Живет взобравшись лучше безорфографичный текстов пояс заманивший родного не она одна он дороге дал алфавит заголовок мир последний', + 'Напоивший продолжил сих свой буквенных власти диких составитель, правилами lorem.', + 'Что образ прямо ipsum от всех назад предупреждал наш точках.', + 'Живет всеми но текстов рекламных грамматики залетают все вершину последний. Запятой его там, великий она это?', + 'Рот языкового скатился, злых, жаренные вопрос снова он единственное журчит меня одна дорогу залетают повстречался толку страна вершину.' +]; + +export const testUsers = [ + { + 'name': 'Катя Лебедева', + 'photo': 'https://sun9-13.userapi.com/c846216/v846216067/c640b/QKvJtUOHEWk.jpg?ava=1' + }, + { + 'name': 'Антон Циварев', + 'photo': 'https://pp.userapi.com/c830708/v830708352/1c50b4/Nl8LPuMRj5k.jpg?ava=1', + }, + { + 'name': 'Настя Семенюк', + 'photo': 'https://sun9-22.userapi.com/c852132/v852132805/8c0d8/N9WQym0ZEyc.jpg?ava=1', + }, + { + 'name': 'Тимофей Чаптыков', + 'photo': 'https://pp.userapi.com/c845121/v845121950/63c01/svMMPOmI5SM.jpg?ava=1', + }, + { + 'name': 'Павел Князев', + 'photo': 'https://pp.userapi.com/c844521/v844521213/83b9f/uYAH_OJZisM.jpg?ava=1', + }, + { + 'name': 'Igor Fedorov', + 'photo': 'https://sun9-22.userapi.com/c851320/v851320261/7159d/VPN91nn0cHY.jpg?ava=1', + }, + { + 'name': 'Artur Stambultsian', + 'photo': 'https://pp.userapi.com/c845324/v845324995/12912f/FyWbKh6vuqs.jpg?ava=1', + }, + { + 'name': 'Кирилл Аверьянов', + 'photo': 'https://pp.userapi.com/c850636/v850636435/1c3d7/7IYTKs2elVM.jpg?ava=1', + }, + { + 'name': 'Коля Борисов', + 'photo': 'https://pp.userapi.com/c850128/v850128006/86340/1IV4iSrVWQY.jpg?ava=1', + }, + { + 'name': 'Михаил Лихачёв', + 'photo': 'https://pp.userapi.com/c840524/v840524444/1f9cd/Q7m20gtLBUw.jpg?ava=1', + }, +]; + +let prevIndex = 0; +export function getRandomUser() { + prevIndex++; + if (prevIndex >= testUsers.length - 1) prevIndex = 0; + + let user = testUsers[prevIndex]; + user.id = getRandomInt(1, 20e8); + return user; +}