This repository has been archived on 2022-08-19. You can view files and clone it, but cannot push or open issues or pull requests.
hydrogen-web/src/platform/web/ui/general/LazyListView.js

281 lines
9.8 KiB
JavaScript

/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {el} from "./html";
import {mountView} from "./utils";
import {ListView} from "./ListView";
import {insertAt} from "./utils";
class ItemRange {
constructor(topCount, renderCount, bottomCount) {
this.topCount = topCount;
this.renderCount = renderCount;
this.bottomCount = bottomCount;
}
contains(range) {
// don't contain empty ranges
// as it will prevent clearing the list
// once it is scrolled far enough out of view
if (!range.renderCount && this.renderCount) {
return false;
}
return range.topCount >= this.topCount &&
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
}
containsIndex(idx) {
return idx >= this.topCount && idx <= (this.topCount + this.renderCount);
}
expand(amount) {
// don't expand ranges that won't render anything
if (this.renderCount === 0) {
return this;
}
const topGrow = Math.min(amount, this.topCount);
const bottomGrow = Math.min(amount, this.bottomCount);
return new ItemRange(
this.topCount - topGrow,
this.renderCount + topGrow + bottomGrow,
this.bottomCount - bottomGrow,
);
}
totalSize() {
return this.topCount + this.renderCount + this.bottomCount;
}
normalize(idx) {
/*
map index from list to index in rendered range
eg: if the index range of this._list is [0, 200] and we have rendered
elements in range [50, 60] then index 50 in list must map to index 0
in DOM tree/childInstance array.
*/
return idx - this.topCount;
}
}
export class LazyListView extends ListView {
constructor({itemHeight, overflowMargin = 5, overflowItems = 20,...options}, childCreator) {
super(options, childCreator);
this._itemHeight = itemHeight;
this._overflowMargin = overflowMargin;
this._overflowItems = overflowItems;
}
_getVisibleRange() {
const length = this._list ? this._list.length : 0;
const scrollTop = this._parent.scrollTop;
const topCount = Math.min(Math.max(0, Math.floor(scrollTop / this._itemHeight)), length);
const itemsAfterTop = length - topCount;
const visibleItems = this._height !== 0 ? Math.ceil(this._height / this._itemHeight) : 0;
const renderCount = Math.min(visibleItems, itemsAfterTop);
const bottomCount = itemsAfterTop - renderCount;
return new ItemRange(topCount, renderCount, bottomCount);
}
_renderIfNeeded(forceRender = false) {
/*
forceRender only because we don't optimize onAdd/onRemove yet.
Ideally, onAdd/onRemove should only render whatever has changed + update padding + update renderRange
*/
const range = this._getVisibleRange();
const intersectRange = range.expand(this._overflowMargin);
const renderRange = range.expand(this._overflowItems);
// only update render Range if the new range + overflowMargin isn't contained by the old anymore
// or if we are force rendering
if (forceRender || !this._renderRange.contains(intersectRange)) {
this._renderRange = renderRange;
this._renderElementsInRange();
}
}
async _initialRender() {
/*
Wait two frames for the return from mount() to be inserted into DOM.
This should be enough, but if this gives us trouble we can always use
MutationObserver.
*/
await new Promise(r => requestAnimationFrame(r));
await new Promise(r => requestAnimationFrame(r));
this._height = this._parent.clientHeight;
if (this._height === 0) { console.error("LazyListView could not calculate parent height."); }
const range = this._getVisibleRange();
const renderRange = range.expand(this._overflowItems);
this._renderRange = renderRange;
this._renderElementsInRange();
}
_itemsFromList(start, end) {
const array = [];
let i = 0;
for (const item of this._list) {
if (i >= start && i < end) {
array.push(item);
}
i = i + 1;
}
return array;
}
_itemAtIndex(idx) {
let i = 0;
for (const item of this._list) {
if (i === idx) {
return item;
}
i = i + 1;
}
return null;
}
_renderElementsInRange() {
const { topCount, renderCount, bottomCount } = this._renderRange;
const paddingTop = topCount * this._itemHeight;
const paddingBottom = bottomCount * this._itemHeight;
const renderedItems = this._itemsFromList(topCount, topCount + renderCount);
this._root.style.paddingTop = `${paddingTop}px`;
this._root.style.paddingBottom = `${paddingBottom}px`;
for (const child of this._childInstances) {
this._removeChild(child);
}
this._childInstances = [];
const fragment = document.createDocumentFragment();
for (const item of renderedItems) {
const view = this._childCreator(item);
this._childInstances.push(view);
fragment.appendChild(mountView(view, this._mountArgs));
}
this._root.appendChild(fragment);
}
mount() {
const root = super.mount();
this._parent = el("div", {className: "LazyListParent"}, root);
/*
Hooking to scroll events can be expensive.
Do we need to do more (like event throttling)?
*/
this._parent.addEventListener("scroll", () => this._renderIfNeeded());
this._initialRender();
return this._parent;
}
update(attributes) {
this._renderRange = null;
super.update(attributes);
this._initialRender();
}
loadList() {
if (!this._list) { return; }
this._subscription = this._list.subscribe(this);
this._childInstances = [];
/*
super.loadList() would render the entire list at this point.
We instead lazy render a part of the list in _renderIfNeeded
*/
}
_removeChild(child) {
child.root().remove();
child.unmount();
}
// If size of the list changes, re-render
onAdd() {
this._renderIfNeeded(true);
}
onRemove() {
this._renderIfNeeded(true);
}
onUpdate(idx, value, params) {
if (this._renderRange.containsIndex(idx)) {
const normalizedIdx = this._renderRange.normalize(idx);
super.onUpdate(normalizedIdx, value, params);
}
}
recreateItem(idx, value) {
if (this._renderRange.containsIndex(idx)) {
const normalizedIdx = this._renderRange.normalize(idx);
super.recreateItem(normalizedIdx, value)
}
}
/**
* Render additional element from top or bottom to offset the outgoing element
*/
_renderExtraOnMove(fromIdx, toIdx) {
const {topCount, renderCount} = this._renderRange;
if (toIdx < fromIdx) {
// Element is moved up the list, so render element from top boundary
const index = topCount;
const child = this._childCreator(this._itemAtIndex(index));
this._childInstances.unshift(child);
this._root.insertBefore(mountView(child, this._mountArgs), this._root.firstChild);
}
else {
// Element is moved down the list, so render element from bottom boundary
const index = topCount + renderCount - 1;
const child = this._childCreator(this._itemAtIndex(index));
this._childInstances.push(child);
this._root.appendChild(mountView(child, this._mountArgs));
}
}
/**
* Remove an element from top or bottom to make space for the incoming element
*/
_removeElementOnMove(fromIdx, toIdx) {
// If element comes from the bottom, remove element at bottom and vice versa
const child = toIdx < fromIdx ? this._childInstances.pop() : this._childInstances.shift();
this._removeChild(child);
}
onMove(fromIdx, toIdx, value) {
const fromInRange = this._renderRange.containsIndex(fromIdx);
const toInRange = this._renderRange.containsIndex(toIdx);
const normalizedFromIdx = this._renderRange.normalize(fromIdx);
const normalizedToIdx = this._renderRange.normalize(toIdx);
if (fromInRange && toInRange) {
super.onMove(normalizedFromIdx, normalizedToIdx, value);
}
else if (fromInRange && !toInRange) {
this.onBeforeListChanged();
const [child] = this._childInstances.splice(normalizedFromIdx, 1);
this._removeChild(child);
this._renderExtraOnMove(fromIdx, toIdx);
this.onListChanged();
}
else if (!fromInRange && toInRange) {
this.onBeforeListChanged();
const child = this._childCreator(value);
this._removeElementOnMove(fromIdx, toIdx);
this._childInstances.splice(normalizedToIdx, 0, child);
insertAt(this._root, normalizedToIdx, mountView(child, this._mountArgs));
this.onListChanged();
}
}
}