From 240975d2ac537f8b8b7d4edee7713b51ed532938 Mon Sep 17 00:00:00 2001 From: zombiej Date: Wed, 21 Aug 2019 15:59:28 +0800 Subject: [PATCH 1/2] support scroll to item --- .eslintrc.js | 1 + examples/basic.tsx | 87 +++++++++++++++++++++++------- src/List.tsx | 131 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 193 insertions(+), 26 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 7d6c51d7..6173a624 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,5 +7,6 @@ module.exports = { '@typescript-eslint/no-explicit-any': 0, 'react/no-did-update-set-state': 0, 'react/no-find-dom-node': 0, + 'no-dupe-class-members': 0, }, }; diff --git a/examples/basic.tsx b/examples/basic.tsx index 1e4c5b24..9d3b651d 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ import * as React from 'react'; import List from '../src/List'; @@ -5,27 +6,27 @@ interface Item { id: number; } -const MyItem: React.FC = ({ id }, ref) => { - return ( - - {id} - - ); -}; +const MyItem: React.FC = ({ id }, ref) => ( + + {id} + +); const ForwardMyItem = React.forwardRef(MyItem); -class TestItem extends React.Component<{ id: number }> { +class TestItem extends React.Component<{ id: number }, {}> { + state = {}; + render() { return
{this.props.id}
; } @@ -45,6 +46,7 @@ const TYPES = [ const Demo = () => { const [type, setType] = React.useState('dom'); + const listRef = React.useRef(null); return ( @@ -65,6 +67,7 @@ const Demo = () => { ))} { }} > {(item, _, props) => - type === 'dom' ? ( + (type === 'dom' ? ( ) : ( - ) + )) } + + + + + ); }; export default Demo; + +/* eslint-enable */ diff --git a/src/List.tsx b/src/List.tsx index 2fe5560b..daa0cc55 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -107,7 +107,7 @@ interface ListState { * # located item * The base position item which other items position calculate base on. */ -class List extends React.Component, ListState> { +class List extends React.Component, ListState> { static defaultProps = { itemHeight: 15, data: [], @@ -404,7 +404,7 @@ class List extends React.Component, ListState> { return this.getItemKey(item, mergedProps); }; - public getItemKey = (item: T, props?: Partial>) => { + public getItemKey = (item: T, props?: Partial>): string => { const { itemKey } = props || this.props; return typeof itemKey === 'function' ? itemKey(item) : item[itemKey]; @@ -413,8 +413,8 @@ class List extends React.Component, ListState> { /** * Collect current rendered dom element item heights */ - public collectItemHeights = () => { - const { startIndex, endIndex } = this.state; + public collectItemHeights = (range?: { startIndex: number; endIndex: number }) => { + const { startIndex, endIndex } = range || this.state; const { data } = this.props; // Record here since measure item height will get warning in `render` @@ -429,8 +429,127 @@ class List extends React.Component, ListState> { } }; - public scrollTo(scrollTop: number) { - this.listRef.current.scrollTop = scrollTop; + public scrollTo(scrollTop: number): void; + + public scrollTo(config: { index: number; align?: 'top' | 'bottom' | 'auto' }): void; + + public scrollTo(arg0: any) { + // Number top + if (typeof arg0 === 'object') { + const { isVirtual } = this.state; + const { height, itemHeight, data } = this.props; + const { index, align = 'auto' } = arg0; + const itemCount = Math.ceil(height / itemHeight); + const item = data[index]; + if (item) { + const { clientHeight } = this.listRef.current; + + if (isVirtual) { + // Calculate related data + const { itemIndex, itemOffsetPtg, startIndex, endIndex } = this.state; + + const relativeLocatedItemTop = getItemRelativeTop({ + itemIndex, + itemOffsetPtg, + itemElementHeights: this.itemElementHeights, + scrollPtg: getElementScrollPercentage(this.listRef.current), + clientHeight, + getItemKey: this.getIndexKey, + }); + + // We will force render related items to collect height for re-location + this.setState( + { + startIndex: Math.max(0, index - itemCount), + endIndex: Math.min(data.length - 1, index + itemCount), + }, + () => { + this.collectItemHeights(); + + // Calculate related top + let relativeTop: number; + let mergedAlgin = align; + + if (align === 'auto') { + let shouldChange = true; + + // Check if exist in the visible range + if (Math.abs(itemIndex - index) < itemCount) { + let itemTop = relativeLocatedItemTop; + if (index < itemIndex) { + for (let i = index; i < itemIndex; i += 1) { + const eleKey = this.getIndexKey(i); + itemTop -= this.itemElementHeights[eleKey] || 0; + } + } else { + for (let i = itemIndex; i <= index; i += 1) { + const eleKey = this.getIndexKey(i); + itemTop += this.itemElementHeights[eleKey] || 0; + } + } + + shouldChange = itemTop <= 0 || itemTop >= clientHeight; + } + + if (shouldChange) { + // Out of range will fall back to position align + mergedAlgin = index < itemIndex ? 'top' : 'bottom'; + } else { + this.setState({ + startIndex, + endIndex, + }); + return; + } + } + + // Align with position should make scroll happen + if (mergedAlgin === 'top') { + relativeTop = 0; + } else if (mergedAlgin === 'bottom') { + const eleKey = this.getItemKey(item); + + relativeTop = clientHeight - this.itemElementHeights[eleKey] || 0; + } + + this.internalScrollTo({ + itemIndex: index, + relativeTop, + }); + }, + ); + } else { + // Raw list without virtual scroll set position directly + this.collectItemHeights({ startIndex: 0, endIndex: data.length - 1 }); + let mergedAlgin = align; + + // Collection index item position + const indexItemHeight = this.itemElementHeights[this.getIndexKey(index)]; + let itemTop = 0; + for (let i = 0; i < index; i += 1) { + const eleKey = this.getIndexKey(i); + itemTop += this.itemElementHeights[eleKey] || 0; + } + const itemBottom = itemTop + indexItemHeight; + + if (mergedAlgin === 'auto') { + if (itemTop < this.listRef.current.scrollTop) { + mergedAlgin = 'top'; + } else if (itemBottom > this.listRef.current.scrollTop + clientHeight) { + mergedAlgin = 'bottom'; + } + } + + if (mergedAlgin === 'top') { + this.listRef.current.scrollTop = itemTop; + } else if (mergedAlgin === 'bottom') { + this.listRef.current.scrollTop = itemTop - (clientHeight - indexItemHeight); + } + } + } + } else { + this.listRef.current.scrollTop = arg0; + } } public internalScrollTo(relativeScroll: RelativeScroll): void { From 18313a7ae874c1b363ef5c91ec0edcb9885a7eee Mon Sep 17 00:00:00 2001 From: zombiej Date: Wed, 21 Aug 2019 16:37:21 +0800 Subject: [PATCH 2/2] add test case --- tests/list.test.js | 169 ++++++++++++++++++++++++++++++++++++++++ tests/scroll.test.js | 171 ++++++++++++----------------------------- tests/util.test.js | 4 + tests/utils/domHook.js | 8 +- 4 files changed, 228 insertions(+), 124 deletions(-) create mode 100644 tests/list.test.js diff --git a/tests/list.test.js b/tests/list.test.js new file mode 100644 index 00000000..e78be6c7 --- /dev/null +++ b/tests/list.test.js @@ -0,0 +1,169 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import List from '../src'; +import Filler from '../src/Filler'; +import { spyElementPrototypes } from './utils/domHook'; + +function genData(count) { + return new Array(count).fill(null).map((_, id) => ({ id })); +} + +describe('List', () => { + function genList(props) { + let node = ( + + {({ id }) =>
  • {id}
  • } +
    + ); + + if (props.ref) { + node =
    {node}
    ; + } + + return mount(node); + } + + describe('raw', () => { + it('without height', () => { + const wrapper = genList({ data: genData(1) }); + expect(wrapper.find(Filler).props().offset).toBeFalsy(); + }); + + it('height over itemHeight', () => { + const wrapper = genList({ data: genData(1), itemHeight: 1, height: 999 }); + + expect(wrapper.find(Filler).props().offset).toBeFalsy(); + }); + }); + + describe('virtual', () => { + let scrollTop = 0; + let mockElement; + + beforeAll(() => { + mockElement = spyElementPrototypes(HTMLElement, { + offsetHeight: { + get: () => 20, + }, + scrollHeight: { + get: () => 2000, + }, + clientHeight: { + get: () => 100, + }, + scrollTop: { + get: () => scrollTop, + set(_, val) { + scrollTop = val; + }, + }, + }); + }); + + afterAll(() => { + mockElement.mockRestore(); + }); + + it('scroll', () => { + // scroll to top + scrollTop = 0; + const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); + expect(wrapper.find(Filler).props().height).toEqual(2000); + expect(wrapper.find(Filler).props().offset).toEqual(0); + + // scrollTop to end + scrollTop = 2000 - 100; + wrapper.find('ul').simulate('scroll', { + scrollTop, + }); + expect(wrapper.find(Filler).props().height).toEqual(2000); + expect(wrapper.find(Filler).props().offset + wrapper.find('li').length * 20).toEqual(2000); + }); + + it('render out of view', () => { + scrollTop = 0; + let data = genData(20); + const onSkipRender = jest.fn(); + const wrapper = genList({ itemHeight: 20, height: 100, disabled: true, data, onSkipRender }); + + data = [{ id: 'beforeAll' }, ...data]; + wrapper.setProps({ data }); + expect(onSkipRender).not.toHaveBeenCalled(); + + wrapper.setProps({ disabled: false }); + data = [...data, { id: 'afterAll' }]; + wrapper.setProps({ data, disabled: true }); + expect(onSkipRender).toHaveBeenCalled(); + }); + }); + + describe('status switch', () => { + let scrollTop = 0; + let scrollHeight = 0; + + let mockLiElement; + let mockElement; + + beforeAll(() => { + mockLiElement = spyElementPrototypes(HTMLLIElement, { + offsetHeight: { + get: () => 40, + }, + }); + + mockElement = spyElementPrototypes(HTMLElement, { + scrollHeight: { + get: () => scrollHeight, + }, + clientHeight: { + get: () => 100, + }, + scrollTop: { + get: () => scrollTop, + set(_, val) { + scrollTop = val; + }, + }, + }); + }); + + afterAll(() => { + mockElement.mockRestore(); + mockLiElement.mockRestore(); + }); + + it('raw to virtual', () => { + let data = genData(5); + const wrapper = genList({ itemHeight: 20, height: 100, data }); + + scrollHeight = 200; + scrollTop = 40; + wrapper.find('ul').simulate('scroll', { + scrollTop, + }); + + scrollHeight = 120; + data = [...data, { id: 'afterAll' }]; + wrapper.setProps({ data }); + expect(wrapper.find('ul').instance().scrollTop < 10).toBeTruthy(); + }); + + it('virtual to raw', done => { + let data = genData(6); + const wrapper = genList({ itemHeight: 20, height: 100, data }); + + scrollHeight = 120; + scrollTop = 10; + wrapper.find('ul').simulate('scroll', { + scrollTop, + }); + + scrollHeight = 200; + data = data.slice(0, -1); + wrapper.setProps({ data }); + expect(wrapper.find('ul').instance().scrollTop > 40).toBeTruthy(); + + setTimeout(done, 50); + }); + }); +}); diff --git a/tests/scroll.test.js b/tests/scroll.test.js index f8f26669..be29dd36 100644 --- a/tests/scroll.test.js +++ b/tests/scroll.test.js @@ -1,14 +1,13 @@ import React from 'react'; import { mount } from 'enzyme'; import List from '../src'; -import Filler from '../src/Filler'; import { spyElementPrototypes } from './utils/domHook'; function genData(count) { return new Array(count).fill(null).map((_, id) => ({ id })); } -describe('List', () => { +describe('List.Scroll', () => { function genList(props) { let node = ( @@ -23,81 +22,7 @@ describe('List', () => { return mount(node); } - describe('raw', () => { - it('without height', () => { - const wrapper = genList({ data: genData(1) }); - expect(wrapper.find(Filler).props().offset).toBeFalsy(); - }); - - it('height over itemHeight', () => { - const wrapper = genList({ data: genData(1), itemHeight: 1, height: 999 }); - - expect(wrapper.find(Filler).props().offset).toBeFalsy(); - }); - }); - - describe('virtual', () => { - let scrollTop = 0; - let mockElement; - - beforeAll(() => { - mockElement = spyElementPrototypes(HTMLElement, { - offsetHeight: { - get: () => 20, - }, - scrollHeight: { - get: () => 2000, - }, - clientHeight: { - get: () => 100, - }, - scrollTop: { - get: () => scrollTop, - set(_, val) { - scrollTop = val; - }, - }, - }); - }); - - afterAll(() => { - mockElement.mockRestore(); - }); - - it('scroll', () => { - // scroll to top - scrollTop = 0; - const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) }); - expect(wrapper.find(Filler).props().height).toEqual(2000); - expect(wrapper.find(Filler).props().offset).toEqual(0); - - // scrollTop to end - scrollTop = 2000 - 100; - wrapper.find('ul').simulate('scroll', { - scrollTop, - }); - expect(wrapper.find(Filler).props().height).toEqual(2000); - expect(wrapper.find(Filler).props().offset + wrapper.find('li').length * 20).toEqual(2000); - }); - - it('render out of view', () => { - scrollTop = 0; - let data = genData(20); - const onSkipRender = jest.fn(); - const wrapper = genList({ itemHeight: 20, height: 100, disabled: true, data, onSkipRender }); - - data = [{ id: 'beforeAll' }, ...data]; - wrapper.setProps({ data }); - expect(onSkipRender).not.toHaveBeenCalled(); - - wrapper.setProps({ disabled: false }); - data = [...data, { id: 'afterAll' }]; - wrapper.setProps({ data, disabled: true }); - expect(onSkipRender).toHaveBeenCalled(); - }); - }); - - describe('scrollTo', () => { + describe('scrollTo number', () => { const listRef = React.createRef(); const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef }); @@ -107,27 +32,21 @@ describe('List', () => { }); }); - describe('status switch', () => { - let scrollTop = 0; - let scrollHeight = 0; - - let mockLiElement; + describe('scrollTo item index', () => { let mockElement; + let scrollTop = 0; beforeAll(() => { - mockLiElement = spyElementPrototypes(HTMLLIElement, { - offsetHeight: { - get: () => 40, - }, - }); - mockElement = spyElementPrototypes(HTMLElement, { - scrollHeight: { - get: () => scrollHeight, + offsetHeight: { + get: () => 20, }, clientHeight: { get: () => 100, }, + scrollHeight: { + get: () => 400, + }, scrollTop: { get: () => scrollTop, set(_, val) { @@ -139,41 +58,49 @@ describe('List', () => { afterAll(() => { mockElement.mockRestore(); - mockLiElement.mockRestore(); }); - it('raw to virtual', () => { - let data = genData(5); - const wrapper = genList({ itemHeight: 20, height: 100, data }); - - scrollHeight = 200; - scrollTop = 40; - wrapper.find('ul').simulate('scroll', { - scrollTop, + function testPlots(type, props) { + describe(`${type} list`, () => { + let listRef; + + beforeEach(() => { + listRef = React.createRef(); + genList({ itemHeight: 20, height: 100, data: genData(20), ref: listRef, ...props }); + }); + + it('top', () => { + listRef.current.scrollTo({ index: 10, align: 'top' }); + expect(scrollTop).toEqual(200); + }); + it('bottom', () => { + listRef.current.scrollTo({ index: 10, align: 'bottom' }); + expect(scrollTop).toEqual(120); + }); + describe('auto', () => { + it('upper of', () => { + scrollTop = 210; + listRef.current.onScroll(); + listRef.current.scrollTo({ index: 10, align: 'auto' }); + expect(scrollTop).toEqual(200); + }); + it('lower of', () => { + scrollTop = 110; + listRef.current.onScroll(); + listRef.current.scrollTo({ index: 10, align: 'auto' }); + expect(scrollTop).toEqual(120); + }); + it('in range', () => { + scrollTop = 150; + listRef.current.onScroll(); + listRef.current.scrollTo({ index: 10, align: 'auto' }); + expect(scrollTop).toEqual(150); + }); + }); }); + } - scrollHeight = 120; - data = [...data, { id: 'afterAll' }]; - wrapper.setProps({ data }); - expect(wrapper.find('ul').instance().scrollTop < 10).toBeTruthy(); - }); - - it('virtual to raw', done => { - let data = genData(6); - const wrapper = genList({ itemHeight: 20, height: 100, data }); - - scrollHeight = 120; - scrollTop = 10; - wrapper.find('ul').simulate('scroll', { - scrollTop, - }); - - scrollHeight = 200; - data = data.slice(0, -1); - wrapper.setProps({ data }); - expect(wrapper.find('ul').instance().scrollTop > 40).toBeTruthy(); - - setTimeout(done, 50); - }); + testPlots('virtual list'); + testPlots('raw list', { itemHeight: null }); }); }); diff --git a/tests/util.test.js b/tests/util.test.js index 0e531b7b..c8f811c0 100644 --- a/tests/util.test.js +++ b/tests/util.test.js @@ -84,6 +84,10 @@ describe('Util', () => { } }); + it('both empty', () => { + expect(findListDiffIndex([], [], num => num)).toEqual(null); + }); + it('same list', () => { const list = [1, 2, 3, 4]; expect(findListDiffIndex(list, list, num => num)).toEqual(null); diff --git a/tests/utils/domHook.js b/tests/utils/domHook.js index 80701bf7..4a420088 100644 --- a/tests/utils/domHook.js +++ b/tests/utils/domHook.js @@ -1,11 +1,13 @@ /* eslint-disable no-param-reassign */ +const NO_EXIST = { __NOT_EXIST: true }; + export function spyElementPrototypes(Element, properties) { const propNames = Object.keys(properties); const originDescriptors = {}; propNames.forEach(propName => { const originDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, propName); - originDescriptors[propName] = originDescriptor; + originDescriptors[propName] = originDescriptor || NO_EXIST; const spyProp = properties[propName]; @@ -39,7 +41,9 @@ export function spyElementPrototypes(Element, properties) { mockRestore() { propNames.forEach(propName => { const originDescriptor = originDescriptors[propName]; - if (typeof originDescriptor === 'function') { + if (originDescriptor === NO_EXIST) { + delete Element.prototype[propName]; + } else if (typeof originDescriptor === 'function') { Element.prototype[propName] = originDescriptor; } else { Object.defineProperty(Element.prototype, propName, originDescriptor);