src/components/DataBoundScrollView.js
/**
This Source Code is licensed under the MIT license. If a copy of the
MIT-license was not distributed with this file, You can obtain one at:
http://opensource.org/licenses/mit-license.html.
@author: Hans van den Akker (mysim1)
@license MIT
@copyright Bizboard, 2015
*/
import sortBy from 'lodash/sortBy.js';
import findIndex from 'lodash/findIndex.js';
import {Throttler} from '../utils/Throttler.js';
import {combineOptions} from '../utils/CombineOptions.js';
import {ReflowingScrollView} from './ReflowingScrollView.js';
import Timer from 'famous/utilities/Timer.js';
/**
* A FlexScrollView with enhanced functionality for maintaining a two-way connection with a PrioritisedArray.
*/
export class DataBoundScrollView extends ReflowingScrollView {
/**
* Be sure to specifiy either a getSize function in the class of the itemTemplate, or to specify the size in the
* layoutOptions.
*
* @param {Object} options The options passed inherit from previous classes. Avoid using the dataSource option since
* the DataBoundScrollView creates its own dataSource from options.dataStore.
* @param {PrioriisedArray} [options.dataStore] The data that should be read to create entries.
* @param {Function} [options.itemTemplate] A function that returns a renderable representing each data item.
* @param {Function} [options.placeholderTemplate] A function that returns a renderable to display when there are
* no items present.
* @param {Function} [options.headerTemplate] A function that returns a renderable to display as a header.
* @param {Function} [options.orderBy] An ordering function that takes two data models (model1, model2).
* If it returns true, then model1 should go before model2.
* @param {Function} [options.groupBy] A function that takes a model and returns a value to group by. If set, then
* the groupTemplate option also needs to be set.
* @param {Function} [options.groupTemplate] A function that takes as a single argument the groupBy value and returns
* a renderable to insert before a group belonging to that value.
* @param {Boolean} [options.stickHeaders] If set to true, then the group headers will stick to the top when scrolling.
* Beware that this is slightly buggy as of now and might require some fine tuning to provide a better UX.
* @param {Function} [options.customInsertSpec] A function that takes as a single argument a model and returns a spec
* that is used when inserting a new item.
* @param {Boolean} [options.chatScrolling] If set to true, the scroll will remain at the bottom if at bottom already
* when new messages are added.
*
* If this function returns true, then model1 will be placed before model2.
*
*/
constructor(options = {}) {
super(combineOptions({
scrollFriction: {
strength: 0.0015
},
autoPipeEvents: true,
throttleDelay: 0, /* If set to 0, no delay is added in between adding items to the DataBoundScrollView. */
dataSource: [],
sortingDirection: 'ascending',
flow: true,
flowOptions: {
spring: { // spring-options used when transitioning between states
dampingRatio: 0.8, // spring damping ratio
period: 1000 // duration of the animation
},
insertSpec: { // render-spec used when inserting renderables
opacity: 0 // start opacity is 0, causing a fade-in effect,
}
},
dataFilter: ()=> true,
ensureVisible: null,
layoutOptions: {
isSectionCallback: options.stickyHeaders ? function (renderNode) {
return renderNode.groupId !== undefined;
} : undefined
},
chatScrolling: false
}, options));
this._internalDataSource = {};
this._internalGroups = {};
this._isGrouped = this.options.groupBy != null;
this._isDescending = this.options.sortingDirection === 'descending';
this._throttler = new Throttler(this.options.throttleDelay, true, this);
this._useCustomOrdering = !!this.options.orderBy;
/* If no orderBy method is set, or it is a string field name, we set our own ordering method. */
if (!this.options.orderBy || typeof this.options.orderBy === 'string') {
let fieldName = this.options.orderBy || 'id';
this.options.orderBy = function (currentChild, {model}) {
if (this._isDescending) {
return currentChild[fieldName] > model[fieldName];
} else {
return currentChild[fieldName] < model[fieldName];
}
}.bind(this);
}
/* If present in options.headerTemplate or options.placeholderTemplate, we build the header and placeholder elements. */
this._addHeader();
this._addPlaceholder();
if (this.options.dataStore) {
this._bindDataSource(this.options.dataStore);
}
}
/**
* Set a template function, optionally re-renders all the dataSource' renderables
* @param templateFunction
*/
setItemTemplate(templateFunction = {}, reRender = false) {
this.options.itemTemplate = templateFunction;
if (reRender) {
this.clearDataSource();
this.reloadFilter(this.options.dataFilter);
}
}
/**
* Sets a group template function, optionally re-renders all the dataSource' renderables.
* @param templateFunction
* @param reRender
*/
setGroupTemplate(templateFunction = {}, reRender = false) {
this.options.groupTemplate = templateFunction;
if (reRender) {
this.clearDataSource();
this.reloadFilter(this.options.dataFilter);
}
}
/**
* Sets the datastore to use. This will repopulate the view and remove any (if present) old items.
* @param dataStore
*/
setDataStore(dataStore) {
if (this.options.dataStore) {
this.clearDataSource();
}
this.options.dataStore = dataStore;
this._bindDataSource(this.options.dataStore);
}
/**
* Gets the currently set dataStore.
* @returns {*}
*/
getDataStore() {
return this.options.dataStore;
}
/**
* Reloads the dataFilter option of the DataBoundScrollView, and verifies whether the items in the dataStore are allowed by the new filter.
* It removes any currently visible items that aren't allowed anymore, and adds any non-visible ones that are allowed now.
* @param {Function} newFilter New filter function to verify item visibility with.
* @param {Boolean} reRender Boolean to rerender all childs that pass the filter function. Usefull when setting a new itemTemplate alongside reloading the filter
* @returns {Promise} Resolves when filter has been applied
*/
reloadFilter(newFilter) {
this.options.dataFilter = newFilter;
let filterPromises = [];
for (let entry of this.options.dataStore || []) {
let alreadyExists = this._internalDataSource[entry.id] !== undefined;
let result = newFilter(entry);
if (result instanceof Promise) {
filterPromises.push(result);
result.then(function (shouldShow) {
this._handleNewFilterResult(shouldShow, alreadyExists, entry);
}.bind(this))
} else {
this._handleNewFilterResult(result, alreadyExists, entry);
}
}
return Promise.all(filterPromises);
}
/**
* Clears the dataSource by removing all entries
*/
clearDataSource() {
for (let entry of this.options.dataStore || []) {
this._removeItem(entry);
}
}
/**
* Determines whether the last element showing is the actual last element
* @returns {boolean} True if the last element showing is the actual last element
*/
isAtBottom() {
let lastVisibleItem = this.getLastVisibleItem();
return (lastVisibleItem && lastVisibleItem.renderNode === this._dataSource._.tail._value);
}
/**
* Returns the currently active group elements, or an empty object of none are present.
* @returns {Object}
*/
getGroups() {
return this._internalGroups || {};
}
_addHeader() {
if (this.options.headerTemplate) {
this._header = this.options.headerTemplate();
this._header.isHeader = true;
this._insertId(0, 0, this._header, null, {isHeader: true});
this.insert(0, this._header);
}
}
/**
* @private
* Patch because Hein forgot to auto pipe events when replacing
* @param indexOrId
* @param renderable
* @param noAnimation
*/
_replace(indexOrId, renderable, noAnimation) {
super.replace(indexOrId, renderable, noAnimation);
// Auto pipe events
if (this.options.autoPipeEvents && renderable && renderable.pipe) {
renderable.pipe(this);
renderable.pipe(this._eventOutput);
}
}
_handleNewFilterResult(shouldShow, alreadyExists, entry) {
if (shouldShow) {
/* This entry should be in the view, add it if it doesn't exist yet. */
if (!alreadyExists) {
this._addItem(entry);
}
} else {
/* This entry should not be in the view, remove if present. */
if (alreadyExists) {
this._removeItem(entry);
}
}
}
_findGroup(groupId) {
return this._internalGroups[groupId] || -1;
}
_getGroupByValue(child) {
let groupByValue = '';
if (typeof this.options.groupBy === 'function') {
groupByValue = this.options.groupBy(child);
} else if (typeof this.options.groupBy === 'string') {
groupByValue = this.options.groupBy;
}
return groupByValue;
}
_addGroupItem(groupByValue, insertIndex) {
let newSurface = this.options.groupTemplate(groupByValue);
newSurface.groupId = groupByValue;
this._internalGroups[groupByValue] = {position: insertIndex, itemsCount: 0};
this.insert(insertIndex, newSurface);
return newSurface;
}
_getInsertIndex(child, previousSiblingID = undefined) {
/* By default, add item at the end if the orderBy function does not specify otherwise. */
let firstIndex = this._getZeroIndex();
let insertIndex = this._dataSource.getLength();
let placedWithinGroup = false;
if (this._isGrouped) {
let groupIndex;
let groupId = this._getGroupByValue(child);
let groupData = this._findGroup(groupId);
if (groupData) groupIndex = groupData.position;
if (groupIndex != undefined && groupIndex !== -1) {
for (insertIndex = groupIndex + 1; insertIndex <= (groupIndex + groupData.itemsCount); insertIndex++) {
if (this.options.orderBy) {
let dataId = this._viewSequence.findByIndex(insertIndex)._value.dataId;
if (dataId && this.options.orderBy(child, this._internalDataSource[dataId])) {
break;
}
} else {
insertIndex += this._internalGroups[groupId].itemsCount;
break;
}
}
placedWithinGroup = true;
}
}
if (!placedWithinGroup) {
/* If we have an orderBy function, find the index we should be inserting at. */
if ((this._useCustomOrdering && this.options.orderBy && typeof this.options.orderBy === 'function') || this._isGrouped) {
let foundOrderedIndex = -1;
if (this._isGrouped) {
for (let group of sortBy(this._internalGroups, 'position')) {
/* Check the first and last item of every group (they're sorted) */
for (let position of group.itemsCount > 1 ? [group.position + 1, group.position + group.itemsCount - 1] : [group.position + 1]) {
let {dataId} = this._viewSequence.findByIndex(position)._value;
if (this.options.orderBy(child, this._internalDataSource[dataId])) {
foundOrderedIndex = group.position;
break;
}
}
if (foundOrderedIndex > -1) {
break;
}
}
} else {
foundOrderedIndex = this._orderBy(child, this.options.orderBy);
}
if (foundOrderedIndex !== -1) {
insertIndex = foundOrderedIndex;
}
/*
There is no guarantee of order when grouping objects unless orderBy is explicitly defined
*/
} else if (previousSiblingID !== undefined && previousSiblingID != null) {
/* We don't have an orderBy method, but do have a previousSiblingID we can use to find the correct insertion index. */
let siblingIndex = this._findData(previousSiblingID).position;
if (siblingIndex !== -1) {
insertIndex = siblingIndex + 1;
}
}
}
return insertIndex;
}
_insertGroup(insertIndex, groupByValue) {
let groupIndex = this._findGroup(groupByValue);
if (groupByValue) {
let groupExists = groupIndex !== -1;
if (!groupExists) {
/* No group of this value exists yet, so we'll need to create one. */
this._updatePosition(insertIndex, 1);
let newSurface = this._addGroupItem(groupByValue, insertIndex);
this._insertId(`group_${groupByValue}`, insertIndex, newSurface, null, {groupId: groupByValue});
/*insertIndex++;*/
}
return !groupExists;
}
return null;
}
async _addItem(child, previousSiblingID = undefined) {
if (this._findData(child.id)) {
console.log('Child already exists ', child.id);
return;
}
this._removePlaceholder();
let insertIndex = this._getInsertIndex(child, previousSiblingID);
/* If we're using groups, check if we need to insert a group item before this child. */
if (this._isGrouped) {
let groupByValue = this._getGroupByValue(child);
if (this._insertGroup(insertIndex, groupByValue)) {
/* If a new group is inserted, then increase the insert index */
insertIndex++;
}
/* Increase the count of the number of items in the group */
this._internalGroups[groupByValue].itemsCount++;
}
let newSurface = this.options.itemTemplate(child);
if(newSurface instanceof Promise) {
newSurface = await newSurface;
}
newSurface.dataId = child.id;
this._subscribeToClicks(newSurface, child);
/* If we're scrolling as with a chat window, then scroll to last child if we're at the bottom */
if (this.options.chatScrolling && insertIndex === this._dataSource.getLength()) {
if (this.isAtBottom() || !this._allChildrenAdded) {
this._lastChild = child;
}
}
let insertSpec;
if(this.options.customInsertSpec){
insertSpec = this.options.customInsertSpec(child);
}
this.insert(insertIndex, newSurface, insertSpec);
this._updatePosition(insertIndex);
this._insertId(child.id, insertIndex, newSurface, child);
if (this.options.ensureVisible != null || this.options.chatScrolling) {
let shouldEnsureVisibleUndefined = this.options.ensureVisible == null;
let shouldEnsureVisible = !shouldEnsureVisibleUndefined ? this.options.ensureVisible(child, newSurface, insertIndex) : false;
if (this.options.chatScrolling) {
if (child === this._lastChild && (shouldEnsureVisible || shouldEnsureVisibleUndefined)) {
this.ensureVisible(newSurface)
}
} else if (shouldEnsureVisible) {
this.ensureVisible(newSurface);
}
}
super._addItem(child, previousSiblingID);
}
_replaceItem(child) {
let index = this._findData(child.id).position;
let newSurface = this.options.itemTemplate(child);
newSurface.dataId = child.id;
this._subscribeToClicks(newSurface, child);
this._insertId(child.id, index, newSurface, child);
this._replace(index, newSurface, true);
}
_removeGroupIfNecessary(groupByValue) {
/* Check if the group corresponding to the child is now empty */
let group = this._internalGroups[groupByValue];
if (group && group.itemsCount === 0) {
/* TODO: Maybe remove internalgroups[groupByValue]? (Or not?) */
let {position} = group;
this._updatePosition(position, -1);
this.remove(position);
delete this._internalGroups[groupByValue];
delete this._internalDataSource[groupByValue];
}
}
_removeItem(child) {
let internalChild = this._internalDataSource[child.id] || {};
let index = internalChild.position;
if (index > -1) {
this._updatePosition(index, -1);
this.remove(index);
delete this._internalDataSource[child.id];
}
/* If we're using groups, check if we need to remove the group that this child belonged to. */
if (this._isGrouped) {
let groupByValue = this._getGroupByValue(child);
let group = this._internalGroups[groupByValue];
if(group){ group.itemsCount--; }
this._removeGroupIfNecessary(groupByValue);
}
/* The amount of items in the dataSource is subtracted with a header if present, to get the total amount of actual items in the scrollView. */
let itemCount = this._dataSource.getLength() - (this._getZeroIndex());
if (itemCount === 0) {
this._addPlaceholder();
}
}
_moveItem(oldId, prevChildId = null) {
let oldData = this._findData(oldId);
let oldIndex = oldData.position;
let previousSiblingIndex = this._getNextVisibleIndex(prevChildId);
if (oldIndex !== previousSiblingIndex) {
this.move(oldIndex, previousSiblingIndex);
this._internalDataSource[previousSiblingIndex] = oldData;
this._internalDataSource[previousSiblingIndex].position = oldIndex;
}
}
_removeHeader() {
if (this._header) {
this.remove(0);
delete this._internalDataSource[0];
this._header = null;
}
}
_addPlaceholder() {
if (this.options.placeholderTemplate && !this._placeholder) {
let insertIndex = this._getZeroIndex();
this._placeholder = this.options.placeholderTemplate();
this._placeholder.isPlaceholder = true;
this.insert(insertIndex, this._placeholder);
}
}
_getZeroIndex() {
return this._header ? 1 : 0;
}
_removePlaceholder() {
if (this._placeholder) {
if (this._placeholder)
this.remove(this._getZeroIndex());
this._placeholder = null;
}
}
_bindDataSource() {
if (!this.options.dataStore || !this.options.itemTemplate) {
console.log('Datasource and template should both be set.');
return;
}
if (!this.options.template instanceof Function) {
console.log('Template needs to be a function.');
return;
}
if (this.options.chatScrolling) {
this.options.dataStore.on('ready', () => this._allChildrenAdded = true);
}
this.options.dataStore.on('child_added', this._onChildAdded.bind(this));
this.options.dataStore.on('child_changed', this._onChildChanged.bind(this));
this.options.dataStore.on('child_moved', this._onChildMoved.bind(this));
this.options.dataStore.on('child_removed', this._onChildRemoved.bind(this));
}
_onChildAdded(child, previousSiblingID) {
if (this.options.dataFilter &&
(typeof this.options.dataFilter === 'function')) {
let result = this.options.dataFilter(child);
if (result instanceof Promise) {
/* If the result is a Promise, show the item when that promise resolves. */
result.then((show) => {
if (show) {
this._throttler.add(() => {
this._addItem(child, previousSiblingID)
});
}
});
} else if (result) {
/* The result is an item, so we can add it directly. */
this._throttler.add(() => {
this._addItem(child, previousSiblingID);
});
}
} else {
/* There is no dataFilter method, so we can add this child. */
this._throttler.add(() => {
this._addItem(child, previousSiblingID);
});
}
}
_onChildChanged(child, previousSiblingID) {
let changedItemIndex = this._getDataSourceIndex(child.id);
if (this._dataSource && changedItemIndex < this._dataSource.getLength()) {
let result = this.options.dataFilter ? this.options.dataFilter(child) : true;
if (result instanceof Promise) {
result.then(function (show) {
if (show) {
this._throttler.add(() => {
this._replaceItem(child);
});
} else {
this._removeItem(child);
}
}.bind(this));
}
else if (this.options.dataFilter &&
typeof this.options.dataFilter === 'function' && !result) {
this._removeItem(child);
} else {
if (changedItemIndex === -1) {
this._throttler.add(() => {
this._addItem(child, previousSiblingID);
});
} else {
this._throttler.add(() => {
this._replaceItem(child);
if (previousSiblingID && !this._isGrouped && !this._useCustomOrdering) {
this._moveItem(child.id, previousSiblingID);
}
});
}
}
}
}
_onChildMoved(child, previousSiblingID) {
let current = this._getDataSourceIndex(child.id);
this._throttler.add(() => {
this._moveItem(current, previousSiblingID);
});
}
_onChildRemoved(child) {
this._throttler.add(() => {
this._removeItem(child);
});
};
_getDataSourceIndex(id) {
let data = this._findData(id);
return data ? data.position : -1;
}
_getNextVisibleIndex(id) {
let viewIndex = -1;
let viewData = this._findData(id);
if (viewData) {
viewIndex = viewData.position
}
if (viewIndex === -1) {
let modelIndex = findIndex(this.options.dataStore, function (model) {
return model.id === id;
});
if (modelIndex === 0 || modelIndex === -1) {
return this._isDescending ? this._dataSource ? this._dataSource.getLength() - 1 : 0 : 0;
} else {
let nextModel = this.options.dataStore[this._isDescending ? modelIndex + 1 : modelIndex - 1];
let nextIndex = this._findData(nextModel.id).position;
if (nextIndex > -1) {
return this._isDescending ? nextIndex === 0 ? 0 : nextIndex - 1 :
this._dataSource.getLength() === nextIndex + 1 ? nextIndex : nextIndex + 1;
} else {
return this._getNextVisibleIndex(nextModel.id);
}
}
} else {
return this._isDescending ? viewIndex === 0 ? 0 : viewIndex - 1 :
this._dataSource.getLength() === viewIndex + 1 ? viewIndex : viewIndex + 1;
}
}
_orderBy(child, orderByFunction) {
let item = this._dataSource._.head;
let index = 0;
while (item) {
if (item._value.dataId && this._internalDataSource[item._value.dataId] && orderByFunction(child, this._internalDataSource[item._value.dataId])) {
return index;
}
index++;
item = item._next;
}
return -1;
}
_updatePosition(position, change = 1) {
if (position === undefined || position === this._dataSource.getLength() - 1) return;
for (let element of Object.keys(this._internalDataSource)) {
let dataObject = this._internalDataSource[element];
if (dataObject.position >= position) {
dataObject.position += change
}
}
if (this._isGrouped) {
this._updateGroupPosition(position, change);
}
}
_updateGroupPosition(position, change = 1) {
for (let element of Object.keys(this._internalGroups)) {
if (this._internalGroups[element].position >= position) {
/* Update the position of groups coming after */
this._internalGroups[element].position += change;
}
}
}
_findData(id) {
let data = this._internalDataSource[id] || undefined;
return data;
}
_insertId(id = null, position, renderable = {}, model = {}, options = {}) {
if (id === undefined || id === null) return;
this._internalDataSource[id] = {position: position, renderable: renderable, model: model};
for (let element of Object.keys(options)) {
this._internalDataSource[id][element] = options[element];
}
}
_subscribeToClicks(surface, model) {
surface.on('click', function () {
this._eventOutput.emit('child_click', {renderNode: surface, dataObject: model});
}.bind(this));
}
}