scrollItem.tsx 17 KB

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