scrollItem.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import React from 'react';
  2. import BaseComponent from '../_base/baseComponent';
  3. import PropTypes from 'prop-types';
  4. import classnames from 'classnames';
  5. import { noop, debounce, throttle, find, map, findIndex, times } from 'lodash-es';
  6. import { cssClasses, numbers } from '@douyinfe/semi-foundation/scrollList/constants';
  7. import ItemFoundation, { Item, ScrollItemAdapter } from '@douyinfe/semi-foundation/scrollList/itemFoundation';
  8. import animatedScrollTo from '@douyinfe/semi-foundation/scrollList/scrollTo';
  9. import isElement from '@douyinfe/semi-foundation/utils/isElement';
  10. import { Motion } from '../_base/base';
  11. const msPerFrame = 1000 / 60;
  12. const blankReg = /^\s*$/;
  13. const wheelMode = 'wheel';
  14. export interface ScrollItemProps {
  15. mode?: string;
  16. cycled?: boolean;
  17. list?: Item[];
  18. selectedIndex?: number;
  19. onSelect?: (data: Item) => void;
  20. transform?: (value: any, text: string) => string;
  21. className?: string;
  22. motion?: Motion;
  23. style?: React.CSSProperties;
  24. type?: string | number; // used to identify the scrollItem, used internally by the semi component, and does not need to be exposed to the user
  25. }
  26. export interface ScrollItemState {
  27. prependCount: number;
  28. appendCount: number;
  29. }
  30. export default class ScrollItem extends BaseComponent<ScrollItemProps, ScrollItemState> {
  31. static propTypes = {
  32. mode: PropTypes.string,
  33. cycled: PropTypes.bool,
  34. list: PropTypes.array,
  35. selectedIndex: PropTypes.number,
  36. onSelect: PropTypes.func,
  37. transform: PropTypes.func,
  38. className: PropTypes.string,
  39. style: PropTypes.object,
  40. motion: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
  41. type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  42. };
  43. static defaultProps = {
  44. selectedIndex: 0,
  45. motion: true,
  46. // transform: identity,
  47. list: [] as Item[],
  48. onSelect: noop,
  49. cycled: false,
  50. mode: wheelMode,
  51. };
  52. selectedNode: HTMLElement;
  53. willSelectNode: HTMLElement;
  54. list: HTMLElement;
  55. wrapper: HTMLElement;
  56. selector: unknown;
  57. scrollAnimation: any;
  58. scrolling: boolean;
  59. throttledAdjustList = throttle((e, nearestNode) => {
  60. this.foundation.adjustInfiniteList(this.list, this.wrapper, nearestNode);
  61. }, msPerFrame);
  62. debouncedSelect = debounce((e, nearestNode) => {
  63. this._cacheSelectedNode(nearestNode);
  64. this.foundation.selectNode(nearestNode, this.list);
  65. }, msPerFrame * 5);
  66. constructor(props = {}) {
  67. super(props);
  68. this.state = {
  69. prependCount: 0,
  70. appendCount: 0,
  71. // selectedIndex: props.selectedIndex,
  72. // fakeSelectedIndex: props.selectedIndex,
  73. };
  74. this.selectedNode = null;
  75. this.willSelectNode = null;
  76. this.list = null;
  77. this.wrapper = null;
  78. this.selector = null;
  79. this.scrollAnimation = null;
  80. // cache if select action comes from outside
  81. this.foundation = new ItemFoundation(this.adapter);
  82. }
  83. get adapter(): ScrollItemAdapter<ScrollItemProps, ScrollItemState> {
  84. return {
  85. ...super.adapter,
  86. setState: (states, callback) => this.setState({ ...states }, callback),
  87. setPrependCount: prependCount => this.setState({ prependCount }),
  88. setAppendCount: appendCount => this.setState({ appendCount }),
  89. isDisabledIndex: this.isDisabledIndex,
  90. setSelectedNode: selectedNode => this._cacheWillSelectNode(selectedNode),
  91. notifySelectItem: (...args) => this.props.onSelect(...args),
  92. scrollToCenter: this.scrollToCenter,
  93. };
  94. }
  95. componentDidMount() {
  96. this.foundation.init();
  97. const { mode, cycled, selectedIndex, list } = this.props;
  98. const selectedNode = this.getNodeByIndex(
  99. typeof selectedIndex === 'number' && selectedIndex > -1 ? selectedIndex : 0
  100. ) as HTMLElement;
  101. this._cacheSelectedNode(selectedNode);
  102. this._cacheWillSelectNode(selectedNode);
  103. if (mode === wheelMode && cycled) {
  104. this.foundation.initWheelList(this.list, this.wrapper, () => {
  105. // we have to scroll in next tick
  106. // setTimeout(() => {
  107. this.scrollToNode(selectedNode, 0);
  108. // });
  109. });
  110. } else {
  111. this.scrollToNode(selectedNode, 0);
  112. }
  113. }
  114. componentDidUpdate(prevProps: ScrollItemProps) {
  115. const { selectedIndex } = this.props;
  116. // smooth scroll to selected option
  117. if (prevProps.selectedIndex !== selectedIndex) {
  118. const willSelectIndex = this.getIndexByNode(this.willSelectNode);
  119. if (!this.indexIsSame(willSelectIndex, selectedIndex)) {
  120. const newSelectedNode = this.getNodeByOffset(
  121. this.selectedNode,
  122. selectedIndex - prevProps.selectedIndex,
  123. this.list
  124. );
  125. this._cacheWillSelectNode(newSelectedNode);
  126. }
  127. this._cacheSelectedNode(this.willSelectNode);
  128. this.scrollToIndex(selectedIndex);
  129. }
  130. }
  131. _cacheNode = (name: string, node: Element) =>
  132. name && node && Object.prototype.hasOwnProperty.call(this, name) && (this[name] = node);
  133. _cacheSelectedNode = (selectedNode: Element) => this._cacheNode('selectedNode', selectedNode);
  134. _cacheWillSelectNode = (node: Element) => this._cacheNode('willSelectNode', node);
  135. _cacheListNode = (list: Element) => this._cacheNode('list', list);
  136. _cacheSelectorNode = (selector: Element) => this._cacheNode('selector', selector);
  137. _cacheWrapperNode = (wrapper: Element) => this._cacheNode('wrapper', wrapper);
  138. _isFirst = (node: Element) => {
  139. const { list } = this;
  140. if (isElement(node) && isElement(list)) {
  141. const chilren = list.children;
  142. const index = findIndex(chilren, node);
  143. return index === 0;
  144. }
  145. return false;
  146. };
  147. _isLast = (node: Element) => {
  148. const { list } = this;
  149. if (isElement(node) && isElement(list)) {
  150. const { children } = list;
  151. const index = findIndex(children, node);
  152. return index === children.length - 1;
  153. }
  154. return false;
  155. };
  156. /**
  157. *
  158. * @param {HTMLElement} refNode
  159. * @param {number} offset
  160. * @param {HTMLElement} listWrapper
  161. *
  162. * @returns {HTMLElement}
  163. */
  164. getNodeByOffset(refNode: Element, offset: number, listWrapper: Element) {
  165. const { list } = this.props;
  166. if (
  167. isElement(refNode) &&
  168. isElement(listWrapper) &&
  169. typeof offset === 'number' &&
  170. Array.isArray(list) &&
  171. list.length
  172. ) {
  173. offset = offset % list.length;
  174. const refIndex = this.getIndexByNode(refNode);
  175. let targetIndex = refIndex + offset;
  176. while (targetIndex < 0) {
  177. targetIndex += list.length;
  178. }
  179. if (offset) {
  180. return this.getNodeByIndex(targetIndex);
  181. }
  182. }
  183. return refNode;
  184. }
  185. indexIsSame = (index1: number, index2: number) => {
  186. const { list } = this.props;
  187. if (list.length) {
  188. return index1 % list.length === index2 % list.length;
  189. }
  190. return undefined;
  191. };
  192. isDisabledIndex = (index: number) => {
  193. const { list } = this.props;
  194. if (Array.isArray(list) && list.length && index > -1) {
  195. const size = list.length;
  196. const indexInData = index % size;
  197. return this.isDisabledData(list[indexInData]);
  198. }
  199. return false;
  200. };
  201. isDisabledNode = (node: Element) => {
  202. const listWrapper = this.list;
  203. if (isElement(node) && isElement(listWrapper)) {
  204. const index = findIndex(listWrapper.children, child => child === node);
  205. return this.isDisabledIndex(index);
  206. }
  207. return false;
  208. };
  209. isDisabledData = (data: Item) => data && typeof data === 'object' && data.disabled;
  210. isWheelMode = () => this.props.mode === wheelMode;
  211. addClassToNode = (selectedNode: Element, selectedCls = cssClasses.SELECTED) => {
  212. const { list } = this;
  213. selectedNode = selectedNode || this.selectedNode;
  214. if (isElement(selectedNode) && isElement(list)) {
  215. const { children } = list;
  216. const reg = new RegExp(`\\s*${selectedCls}\\s*`, 'g');
  217. map(children, node => {
  218. node.className = node.className && node.className.replace(reg, ' ');
  219. if (blankReg.test(node.className)) {
  220. node.className = '';
  221. }
  222. });
  223. if (selectedNode.className && !blankReg.test(selectedNode.className)) {
  224. selectedNode.className += ` ${selectedCls}`;
  225. } else {
  226. selectedNode.className = selectedCls;
  227. }
  228. }
  229. };
  230. getIndexByNode = (node: Element) => findIndex(this.list.children, node);
  231. getNodeByIndex = (index: number) => {
  232. if (index > -1) {
  233. return find(this.list.children, (node, idx) => idx === index);
  234. }
  235. const defaultSelectedNode = find(this.list.children, child => !this.isDisabledNode(child));
  236. return defaultSelectedNode;
  237. };
  238. scrollToIndex = (selectedIndex: number, duration?: number) => {
  239. // move to selected item
  240. duration = typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION;
  241. // eslint-disable-next-line
  242. selectedIndex = selectedIndex == null ? this.props.selectedIndex : selectedIndex;
  243. // this.isWheelMode() && this.addClassToNode();
  244. this.scrollToNode(this.selectedNode, duration);
  245. };
  246. scrollToNode = (node: HTMLElement, duration: number) => {
  247. const { wrapper } = this;
  248. const wrapperHeight = wrapper.offsetHeight;
  249. const itemHeight = this.getItmHeight(node);
  250. const targetTop = node.offsetTop - (wrapperHeight - itemHeight) / 2;
  251. this.scrollToPos(targetTop, duration);
  252. };
  253. scrollToPos = (targetTop: number, duration = numbers.DEFAULT_SCROLL_DURATION) => {
  254. const { wrapper } = this;
  255. // this.isWheelMode() && this.addClassToNode();
  256. if (duration && this.props.motion) {
  257. if (this.scrollAnimation) {
  258. this.scrollAnimation.destroy();
  259. this.scrolling = false;
  260. }
  261. if (wrapper.scrollTop === targetTop) {
  262. if (this.isWheelMode()) {
  263. const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
  264. this.addClassToNode(nodeInfo.nearestNode);
  265. }
  266. } else {
  267. this.scrollAnimation = animatedScrollTo(wrapper, targetTop, duration);
  268. this.scrollAnimation.on('rest', () => {
  269. if (this.isWheelMode()) {
  270. const nodeInfo = this.foundation.getNearestNodeInfo(this.list, this.selector);
  271. this.addClassToNode(nodeInfo.nearestNode);
  272. }
  273. });
  274. this.scrollAnimation.start();
  275. }
  276. } else {
  277. wrapper.scrollTop = targetTop;
  278. }
  279. };
  280. scrollToSelectItem: React.UIEventHandler = e => {
  281. const { nearestNode } = this.foundation.getNearestNodeInfo(this.list, this.selector);
  282. if (this.props.cycled) {
  283. this.throttledAdjustList(e, nearestNode);
  284. }
  285. this.debouncedSelect(e, nearestNode);
  286. };
  287. /**
  288. *
  289. * reset position to center of the scrollWrapper
  290. *
  291. * @param {HTMLElement} selectedNode
  292. * @param {HTMLElement} scrollWnumber
  293. * @param {number} duration
  294. */
  295. scrollToCenter: ScrollItemAdapter['scrollToCenter'] = (selectedNode, scrollWrapper, duration) => {
  296. selectedNode = selectedNode || this.selectedNode;
  297. scrollWrapper = scrollWrapper || this.wrapper;
  298. if (isElement(selectedNode) && isElement(scrollWrapper)) {
  299. const scrollRect = scrollWrapper.getBoundingClientRect();
  300. const selectedRect = selectedNode.getBoundingClientRect();
  301. const targetTop =
  302. scrollWrapper.scrollTop +
  303. (selectedRect.top - (scrollRect.top + scrollRect.height / 2 - selectedRect.height / 2));
  304. this.scrollToPos(targetTop, typeof duration === 'number' ? duration : numbers.DEFAULT_SCROLL_DURATION);
  305. }
  306. };
  307. clickToSelectItem: React.MouseEventHandler = e => {
  308. // const index = this.foundation.selectNearestIndex(e.nativeEvent, this.list);
  309. e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation();
  310. const { targetNode: node, infoInList } = this.foundation.getTargetNode(e, this.list);
  311. if (node && infoInList && !infoInList.disabled) {
  312. this.debouncedSelect(null, node);
  313. }
  314. };
  315. getItmHeight = (itm: HTMLElement) => (itm && itm.offsetHeight) || numbers.DEFAULT_ITEM_HEIGHT;
  316. renderItemList = (prefixKey = '') => {
  317. const { selectedIndex, mode, transform: commonTrans, list } = this.props;
  318. return list.map((item, index) => {
  319. const { transform: itemTrans } = item;
  320. const transform = typeof itemTrans === 'function' ? itemTrans : commonTrans;
  321. const cls = classnames({
  322. [`${cssClasses.PREFIX}-item-sel`]: selectedIndex === index && mode !== wheelMode,
  323. [`${cssClasses.PREFIX}-item-disabled`]: Boolean(item.disabled),
  324. });
  325. let text = '';
  326. if (selectedIndex === index) {
  327. if (typeof transform === 'function') {
  328. text = transform(item.value, item.text);
  329. } else {
  330. // eslint-disable-next-line
  331. text = item.text == null ? item.value : item.text;
  332. }
  333. } else {
  334. // eslint-disable-next-line
  335. text = item.text == null ? item.value : item.text;
  336. }
  337. const events: { onClick?: () => void } = {};
  338. if (!this.isWheelMode() && !item.disabled) {
  339. events.onClick = () => this.foundation.selectIndex(index, this.list);
  340. }
  341. return (
  342. // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
  343. <li key={prefixKey + index} {...events} className={cls}>
  344. {text}
  345. </li>
  346. );
  347. });
  348. };
  349. renderNormalList = () => {
  350. const { list, className, style } = this.props;
  351. const inner = this.renderItemList();
  352. const wrapperCls = classnames(`${cssClasses.PREFIX}-item`, className);
  353. return (
  354. <div style={style} className={wrapperCls} ref={this._cacheWrapperNode}>
  355. <ul ref={this._cacheListNode}>{inner}</ul>
  356. </div>
  357. );
  358. };
  359. /**
  360. * List of Rendering Unlimited Modes
  361. */
  362. renderInfiniteList = () => {
  363. const { list, cycled, className, style } = this.props;
  364. const { prependCount, appendCount } = this.state;
  365. const prependList = times(prependCount).reduce((arr, num) => {
  366. const items = this.renderItemList(`pre_${ num }_`);
  367. arr.unshift(...items);
  368. return arr;
  369. }, []);
  370. const appendList = times(appendCount).reduce((arr, num) => {
  371. const items = this.renderItemList(`app_${ num }_`);
  372. arr.push(...items);
  373. return arr;
  374. }, []);
  375. const inner = this.renderItemList();
  376. const listWrapperCls = classnames(`${cssClasses.PREFIX}-list-outer`, {
  377. [`${cssClasses.PREFIX}-list-outer-nocycle`]: !cycled,
  378. });
  379. const wrapperCls = classnames(`${cssClasses.PREFIX}-item-wheel`, className);
  380. const selectorCls = classnames(`${cssClasses.PREFIX}-selector`);
  381. const preShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-pre`);
  382. const postShadeCls = classnames(`${cssClasses.PREFIX}-shade`, `${cssClasses.PREFIX}-shade-post`);
  383. return (
  384. <div className={wrapperCls} style={style}>
  385. <div className={preShadeCls} />
  386. <div className={selectorCls} ref={this._cacheSelectorNode} />
  387. <div className={postShadeCls} />
  388. <div className={listWrapperCls} ref={this._cacheWrapperNode} onScroll={this.scrollToSelectItem}>
  389. <ul ref={this._cacheListNode} onClick={this.clickToSelectItem}>
  390. {prependList}
  391. {inner}
  392. {appendList}
  393. </ul>
  394. </div>
  395. </div>
  396. );
  397. };
  398. render() {
  399. return this.isWheelMode() ? this.renderInfiniteList() : this.renderNormalList();
  400. }
  401. }