Skip to content

Commit

Permalink
refactor(all): deterministic scroll handling
Browse files Browse the repository at this point in the history
  • Loading branch information
bigopon committed Apr 4, 2019
1 parent a3251a3 commit baff38a
Show file tree
Hide file tree
Showing 10 changed files with 473 additions and 50 deletions.
82 changes: 76 additions & 6 deletions src/array-virtual-repeat-strategy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ICollectionObserverSplice, mergeSplice } from 'aurelia-binding';
import { ViewSlot } from 'aurelia-templating';
import { ArrayRepeatStrategy, createFullOverrideContext } from 'aurelia-templating-resources';
import { IView, IVirtualRepeatStrategy, VirtualizationCalculation } from './interfaces';
import { IView, IVirtualRepeatStrategy, VirtualizationCalculation, IScrollerInfo } from './interfaces';
import {
Math$abs,
Math$floor,
Expand All @@ -11,10 +11,11 @@ import {
} from './utilities';
import { VirtualRepeat } from './virtual-repeat';
import { getDistanceToParent, hasOverflowScroll, calcScrollHeight, calcOuterHeight } from './utilities-dom';
import { htmlElement } from './constants';

/**
* A strategy for repeating a template over an array.
*/
* A strategy for repeating a template over an array.
*/
export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements IVirtualRepeatStrategy {

createFirstItem(repeat: VirtualRepeat): IView {
Expand Down Expand Up @@ -48,13 +49,78 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I
repeat.itemHeight = itemHeight;
const scroll_el_height = isFixedHeightContainer
? calcScrollHeight(containerEl)
: document.documentElement.clientHeight;
: innerHeight;
// console.log({ scroll_el_height })
const elementsInView = repeat.elementsInView = Math$floor(scroll_el_height / itemHeight) + 1;
const viewsCount = repeat._viewsLength = elementsInView * 2;
return VirtualizationCalculation.has_sizing | VirtualizationCalculation.observe_scroller;
}

onAttached(repeat: VirtualRepeat): void {
if (repeat.items.length < repeat.elementsInView) {
repeat._getMore2(0, /*is near top?*/true, this.isNearBottom(repeat, repeat._lastViewIndex()), /*force?*/true);
}
}

getViewRange(repeat: VirtualRepeat, scrollerInfo: IScrollerInfo): [number, number] {
const topBufferEl = repeat.topBufferEl;
const scrollerEl = repeat.scrollerEl;
const itemHeight = repeat.itemHeight;
let realScrollTop = 0;
const isFixedHeightContainer = scrollerInfo.scroller !== htmlElement;
if (isFixedHeightContainer) {
// If offset parent of top buffer is the scroll container
// its actual offsetTop is just the offset top itself
// If not, then the offset top is calculated based on the parent offsetTop as well
const topBufferDistance = getDistanceToParent(topBufferEl, scrollerEl);
const scrollerScrollTop = scrollerInfo.scrollTop;
realScrollTop = Math$max(0, scrollerScrollTop - Math$abs(topBufferDistance));
} else {
realScrollTop = pageYOffset - repeat.distanceToTop;
}

const realViewCount = repeat._viewsLength;

// Calculate the index of first view
// Using Math floor to ensure it has correct space for both small and large calculation
let firstVisibleIndex = Math$max(0, itemHeight > 0 ? Math$floor(realScrollTop / itemHeight) : 0);
const lastVisibleIndex = Math.min(
repeat.items.length - 1,
firstVisibleIndex + (realViewCount - /*number of view count includes the first view, so minus 1*/1));
firstVisibleIndex = Math.max(
0,
Math.min(
firstVisibleIndex,
lastVisibleIndex - (realViewCount - /*number of view count includes the first view, so minus 1*/1)
)
);
return [firstVisibleIndex, lastVisibleIndex];
}

updateBuffers(repeat: VirtualRepeat, firstIndex: number): void {
const itemHeight = repeat.itemHeight;
const itemCount = repeat.items.length;
repeat._topBufferHeight = firstIndex * itemHeight;
repeat._bottomBufferHeight = (itemCount - firstIndex - repeat.viewCount()) * itemHeight;
repeat._updateBufferElements(/*skip update?*/true);
}

isNearTop(repeat: VirtualRepeat, firstIndex: number): boolean {
const itemCount = repeat.items.length;
return itemCount > 0
? firstIndex <= repeat.edgeDistance
: false;
}

isNearBottom(repeat: VirtualRepeat, lastIndex: number): boolean {
const itemCount = repeat.items.length;
return lastIndex === -1
? true
: itemCount > 0
? lastIndex >= (itemCount - repeat.edgeDistance)
: false;
}

/**
* @override
* Handle the repeat's collection instance changing.
Expand Down Expand Up @@ -379,6 +445,10 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I
this._remeasure(repeat, itemHeight, newViewCount, newArraySize, firstIndexAfterMutation);
}

remeasure(repeat: VirtualRepeat): void {
this._remeasure(repeat, repeat.itemHeight, repeat.viewCount(), repeat.items.length, repeat._firstViewIndex());
}

/**
* Unlike normal repeat, virtualization repeat employs "padding" elements. Those elements
* often are just blank block with proper height/width to adjust the height/width/scroll feeling
Expand All @@ -391,7 +461,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I
*
* @internal
*/
_remeasure(repeat: VirtualRepeat, itemHeight: number, newViewCount: number, newArraySize: number, firstIndexAfterMutation: number): void {
_remeasure(repeat: VirtualRepeat, itemHeight: number, newViewCount: number, newArraySize: number, firstIndex: number): void {
const scrollerInfo = repeat.getScrollerInfo();
const topBufferDistance = getDistanceToParent(repeat.topBufferEl, scrollerInfo.scroller);
const realScrolltop = Math$max(0, scrollerInfo.scrollTop === 0
Expand All @@ -410,7 +480,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I
const bot_buffer_item_count_after_scroll_adjustment = Math$max(0, newArraySize - top_buffer_item_count_after_scroll_adjustment - newViewCount);
repeat._first
= repeat._lastRebind = first_index_after_scroll_adjustment;
repeat._previousFirst = firstIndexAfterMutation;
repeat._previousFirst = firstIndex;
repeat._isAtTop = first_index_after_scroll_adjustment === 0;
repeat._isLastIndex = bot_buffer_item_count_after_scroll_adjustment === 0;
repeat._topBufferHeight = top_buffer_item_count_after_scroll_adjustment * itemHeight;
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const doc = document;
export const htmlElement = doc.documentElement;
49 changes: 49 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ export interface IVirtualRepeatStrategy extends RepeatStrategy {
*/
initCalculation(repeat: VirtualRepeat, items: number | any[] | Map<any, any> | Set<any>): VirtualizationCalculation;

/**
* Handle special initialization if any, depends on different strategy
*/
onAttached(repeat: VirtualRepeat): void;

/**
* Calculate the start and end index of a repeat based on its container current scroll position
*/
getViewRange(repeat: VirtualRepeat, scrollerInfo: IScrollerInfo): [number, number];

/**
* Returns true if first index is approaching start of the collection
* Virtual repeat can use this to invoke infinite scroll next
*/
isNearTop(repeat: VirtualRepeat, firstIndex: number): boolean;

/**
* Returns true if last index is approaching end of the collection
* Virtual repeat can use this to invoke infinite scroll next
*/
isNearBottom(repeat: VirtualRepeat, lastIndex: number): boolean;

/**
* Update repeat buffers height based on repeat.items
*/
updateBuffers(repeat: VirtualRepeat, firstIndex: number): void;

/**
* Get the observer based on collection type of `items`
*/
Expand All @@ -79,6 +106,18 @@ export interface IVirtualRepeatStrategy extends RepeatStrategy {
* @param splices Records of array changes.
*/
instanceMutated(repeat: VirtualRepeat, array: any[], splices: ICollectionObserverSplice[]): void;

/**
* Unlike normal repeat, virtualization repeat employs "padding" elements. Those elements
* often are just blank block with proper height/width to adjust the height/width/scroll feeling
* of virtualized repeat.
*
* Because of this, either mutation or change of the collection of repeat will potentially require
* readjustment (or measurement) of those blank block, based on scroll position
*
* This is 2 phases scroll handle
*/
remeasure(repeat: VirtualRepeat): void;
}

/**
Expand Down Expand Up @@ -158,6 +197,16 @@ export const enum VirtualizationCalculation {
observe_scroller = 0b0_00100
}

export const enum VirtualiationScrollState {
none = 0,
isAtTop = 0b0_000001,
isAtBottom = 0b0_000010,
scrolling = 0b0_000100,
scrollingDown = 0b0_001000,
scrollingUp = 0b0_010000,
switchedDirection = 0b0_100000
}

/**
* List of events that can be used to notify virtual repeat that size has changed
*/
Expand Down
26 changes: 24 additions & 2 deletions src/null-virtual-repeat-strategy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import { NullRepeatStrategy, RepeatStrategy } from 'aurelia-templating-resources';
import { VirtualRepeat } from './virtual-repeat';
import { IVirtualRepeatStrategy, IView, VirtualizationCalculation } from './interfaces';
import { IVirtualRepeatStrategy, IView, VirtualizationCalculation, IScrollerInfo } from './interfaces';

export class NullVirtualRepeatStrategy extends NullRepeatStrategy implements IVirtualRepeatStrategy {

getViewRange(repeat: VirtualRepeat, scrollerInfo: IScrollerInfo): [number, number] {
throw new Error('Method not implemented.');
}

updateBuffers(repeat: VirtualRepeat, firstIndex: number): void {
throw new Error('Method not implemented.');
}

onAttached() {/*empty*/}

isNearTop(): boolean {
return false;
}

isNearBottom(): boolean {
return false;
}

initCalculation(repeat: VirtualRepeat, items: any): VirtualizationCalculation {
repeat.itemHeight
= repeat.elementsInView
Expand All @@ -19,10 +37,14 @@ export class NullVirtualRepeatStrategy extends NullRepeatStrategy implements IVi
return null;
}

instanceMutated() {/**/}
instanceMutated() {/*empty*/}

instanceChanged(repeat: VirtualRepeat): void {
repeat.removeAllViews(/**return to cache?*/true, /**skip animation?*/false);
repeat._resetCalculation();
}

remeasure(repeat: VirtualRepeat): void {
throw new Error('Method not implemented.');
}
}
3 changes: 3 additions & 0 deletions src/template-strategy-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { IView, ITemplateStrategy } from './interfaces';
import { insertBeforeNode, getScrollContainer } from './utilities-dom';
import { DOM } from 'aurelia-pal';

/**
* A template strategy for any virtual repeat usage that is not placed on tr, tbody, li, dd
*/
export class DefaultTemplateStrategy implements ITemplateStrategy {

getScrollContainer(element: Element): HTMLElement {
Expand Down
12 changes: 0 additions & 12 deletions src/template-strategy-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,6 @@ import { hasOverflowScroll } from './utilities-dom';
*/
export class ListTemplateStrategy extends DefaultTemplateStrategy implements ITemplateStrategy {

/**@override */
getScrollContainer(element: Element): HTMLElement {
let listElement = this.getList(element);
return hasOverflowScroll(listElement)
? listElement
: listElement.parentNode as HTMLElement;
}

/**@override */
createBuffers(element: Element): [HTMLElement, HTMLElement] {
const parent = element.parentNode;
Expand All @@ -49,8 +41,4 @@ export class ListTemplateStrategy extends DefaultTemplateStrategy implements ITe
parent.insertBefore(DOM.createElement('li'), element.nextSibling)
];
}

private getList(element: Element): HTMLOListElement | HTMLUListElement {
return element.parentNode as HTMLOListElement | HTMLUListElement;
}
}
3 changes: 2 additions & 1 deletion src/template-strategy-table.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { DOM } from 'aurelia-pal';
import { ITemplateStrategy } from './interfaces';
import { DefaultTemplateStrategy } from './template-strategy-default';
import { getScrollContainer } from './utilities-dom';

abstract class BaseTableTemplateStrategy extends DefaultTemplateStrategy implements ITemplateStrategy {

/**@override */
getScrollContainer(element: Element): HTMLElement {
return this.getTable(element).parentNode as HTMLElement;
return getScrollContainer(this.getTable(element));
}

createBuffers(element: Element): [HTMLElement, HTMLElement] {
Expand Down
13 changes: 4 additions & 9 deletions src/utilities-dom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Math$round, $isNaN } from './utilities';
import { IView } from './interfaces';
import { htmlElement } from './constants';

/**
* Walk up the DOM tree and determine what element will be scroller for an element
Expand All @@ -14,17 +15,16 @@ export const getScrollContainer = (element: Node): HTMLElement => {
}
current = current.parentNode as HTMLElement;
}
return document.documentElement;
return htmlElement;
};

/**
* Determine real distance of an element to top of current document
*/
export const getElementDistanceToTopOfDocument = (element: Element): number => {
let box = element.getBoundingClientRect();
let documentElement = document.documentElement;
let scrollTop = window.pageYOffset;
let clientTop = documentElement.clientTop;
let clientTop = htmlElement.clientTop;
let top = box.top + scrollTop - clientTop;
return Math$round(top);
};
Expand Down Expand Up @@ -74,12 +74,7 @@ export const insertBeforeNode = (view: IView, bottomBuffer: Element): void => {
* child.offsetTop - parent.offsetTop
* There are steps in the middle to account for offsetParent but it's basically that
*/
export const getDistanceToParent = (child: HTMLElement, parent: HTMLElement) => {
// optimizable case where child is the first child of parent
// and parent is the target parent to calculate
if (child.previousSibling === null && child.parentNode === parent) {
return 0;
}
export const getDistanceToParent = (child: HTMLElement, parent: HTMLElement): number => {
const offsetParent = child.offsetParent as HTMLElement;
const childOffsetTop = child.offsetTop;
// [el] <-- offset parent === parent
Expand Down
33 changes: 31 additions & 2 deletions src/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { updateOverrideContext } from 'aurelia-templating-resources';
import { VirtualRepeat } from './virtual-repeat';
import { IView } from './interfaces';
import { IView, VirtualiationScrollState, IScrollerInfo } from './interfaces';


/**
Expand Down Expand Up @@ -30,7 +30,7 @@ export const updateAllViews = (repeat: VirtualRepeat, startIndex: number): void

const delta = Math$floor(repeat._topBufferHeight / repeat.itemHeight);
let collectionIndex = 0;
let view;
let view: IView;

for (; viewLength > startIndex; ++startIndex) {
collectionIndex = startIndex + delta;
Expand Down Expand Up @@ -72,3 +72,32 @@ export const Math$round = Math.round;
export const Math$ceil = Math.ceil;
export const Math$floor = Math.floor;
export const $isNaN = isNaN;

/**
* On scroll event:
* - Set flags based on internal values of first view index, previous view index
* - Determines scrolling state, scroll direction, switching scroll direction
* @internal
*/
export const getScrollingState = (
previousState: VirtualiationScrollState,
currentScrollerInfo: IScrollerInfo,
prevScrollerInfo: IScrollerInfo
): VirtualiationScrollState => {
const currTop = currentScrollerInfo.scrollTop;
const prevTop = prevScrollerInfo.scrollTop;
const isScrolling = currTop !== prevTop;
let scrollState = isScrolling ? VirtualiationScrollState.scrolling : 0;
let scrollingDown = currTop > prevTop;

scrollState |= scrollingDown
? VirtualiationScrollState.scrollingDown
: VirtualiationScrollState.scrollingUp;

if ((scrollingDown && !(previousState & VirtualiationScrollState.scrollingDown))
|| (!scrollingDown && (previousState & VirtualiationScrollState.scrollingDown))
) {
scrollState |= VirtualiationScrollState.switchedDirection;
}
return scrollState;
};
Loading

0 comments on commit baff38a

Please sign in to comment.