I'm building an image gallery using backbone. User can only view one image at a time, when he navigates next/previous using arrow keys he will see next image in the collection.
I have one top level backbone view called gallery view.
GalleryView = Backbone.View.extend({
initialize: function(options) {
this.collection = PhotosCollection();
}
});
Then for each image I'll create a view
PhotoView = Backbone.View.extend
I'll keep track of current model in collection that is in view, and I'll create photoView for that model.
My question is regarding pagination, or prefetching images that are not in view. I'm not sure about how galleryView can hold multiple PhotoViews and then show the one that is in focus. I'm thinking about building one long div for entire collection and then adding div's for each photo onto it in the indexed position. What would be the strategy of adding removing new images from that div.
Update
I've modified my original code. Though this is obviously untested it should give you a reasonable start. Images start pre-loading as soon as an img element has been created and the src attribute has been set (as mentioned here). They don't have to be attached to the DOM. This revamped version that I've included below will preload images before and after the current image based on the paginationPadding variable.
End Update
When working with things in Backbone it helps to keep all state related information in your your models and then have the UI respond to it. In this case, you can simply keep track of which image you want to display in a model and then in response to a change in the model tracking what photo you want to view, you can simply re-render the photo gallery to display the desired image. It's reactive.
This is a rough implementation but should hopefully give you some idea of how you might implement a gallery where you can click through the photos like this. Of course, you'd still have a bunch of CSS and such to do. It's incomplete. Let me know if you have any questions.
var StateModel = Backbone.Model.extend({
defaults: {
visibleImage: 0
},
initialize: function(attributes, options) {
this.photoCollection = options.photoCollection;
},
setIndex: function(photoIndex) {
if(!this.photoCollection.length) return;
if(photoIndex < 0) {
photoIndex = this.photoCollection.length - 1;
} else if(photoIndex >= this.photoCollection.length) {
photoIndex = 0;
}
this.set('visibleImage', photoIndex);
},
setPrev: function() {
this.setIndex(this.get('visibleImage') - 1);
},
setNext: function() {
this.setIndex(this.get('visibleImage') + 1);
}
});
var PhotoControls = Backbone.View.extend({
tagName: 'div',
initialize: function(options) {
this.stateModel = options.stateModel;
this.paginationPadding = options.paginationPadding;
},
render: function() {
var that = this;
this.$el.empty();
var ctrlStyle = {cursor: 'pointer', display: 'inline-block', padding: '5px 10px', margin: '0px 5px', border: '1px solid #000'};
this.$el.append($('<div><< Previous</div>')
.css(ctrlStyle))
.click(function() {
that.stateModel.setNext();
});
// Display numbers
var visibleImage = this.stateModel.get('visibleImage');
var pgStart = Math.max(visibleImage - this.paginationPadding, 0);
var pgEnd = Math.min(visibleImage + this.paginationPadding, this.stateModel.photoCollection.length - 1);
for(var i = pgStart; i <= pgEnd; i++) {
var $numEl = that.$el.append(
$('<div>' + (i + 1) + '</div>')
.css(ctrlStyle)
.click(function() {
that.stateModel.setIndex(i);
});
if(i == visibleImage) {
$numEl.css({fontWeight: 'bold', textDecoration: 'underline'});
}
);
}
this.$el.$('<div>Next >></div>')
.css(ctrlStyle))
.click(function() {
that.stateModel.setPrev();
});
return this;
}
});
var PhotoView = Backbone.View.extend({
render: function() {
this.$el.html('<img src="' + this.model.get('url') + '" />');
return this;
}
});
var GalleryView = Backbone.View.extend({
tagName: 'div',
initialize: function(options) {
this.paginationPadding = 2;
this.collection = PhotosCollection();
this.renderedViews = {};
this.state = new StateModel(null, {photoCollection: this.collection});
this.photoControls = new PhotoControls({
stateModel: this.state,
paginationPadding: this.paginationPadding
}).render();
},
render: function() {
if(this.photoView) {
this.photoView.remove();
}
// Pre-fetch images before and after current one based on pagination padding
var visibleImage = this.stateModel.get('visibleImage');
var pgStart = Math.max(visibleImage - this.paginationPadding, 0);
var pgEnd = Math.min(visibleImage + this.paginationPadding, this.stateModel.photoCollection.length - 1);
for(var i = pgStart; i <= pgEnd; i++) {
if(!this.renderedViews[fetchModel.cid]) {
// Images will begin fetching as soon as the 'src' attribute is set on the 'img' element rendered by the view
this.renderedViews[model.cid] = new PhotoView(this.collection.at(i));
this.renderedViews[model.cid].render();
}
this.$el.html(this.photoView.render().el);
}
// Attach the view for the current image
var renderModel = this.collection.at(this.state.get('visibleImage'));
if(renderModel && this.renderedViews[renderModel.cid]) {
this.photoView = this.renderedViews[renderModel.cid];
this.$el.html(this.photoView.el);
}
this.$el.append(this.photoControls.el);
return this;
}
});
I'm not sure about how galleryView can hold multiple PhotoViews and then show the one that is in focus.
This is the easy part. As long as the view isnt attached to the DOM it stays invisible, you can instance as many Views you want and only have one of them attached at any time.
I'm thinking about building one long div for entire collection and then adding div's for each photo onto it in the indexed position. What would be the strategy of adding removing new images from that div.
Make that an UL with an LI for each picture, anyway, if you want to pre load images, i guess the next one in line is enough, you dont really need to remove the PhotoViews as long as they arent absurd numbers of photos, just scroll them out of view or hide them, if you wish to actually delete views call remove().
Related
Closed. This question does not meet Stack Overflow guidelines. It is not currently accepting answers.
We don’t allow questions seeking recommendations for books, tools, software libraries, and more. You can edit the question so it can be answered with facts and citations.
Closed 6 years ago.
Improve this question
I have an ng-repeat which loads thousands of records with some complexity that can have an height between 100px and 1200px. Needless to say the performance gets quite a hit.
Infinite scrolling module would work just fine in most cases until you hit an edge case where you've scrolled down close to the bottom and most of the elements have been loaded into the DOM, which brings me back to square one.
Angular-vs-repeat would be perfect for my case, but I haven't figured out how to compute each following element's height, since they're not fixed.
Which takes me back to Infinite scrolling.
I assume if the top elements (above the viewport) would be replaced with an empty DIV with a computed height equal of their total height sum the performance wouldn't be a problem. While scrolling up would render them back into the dom and subtract the empty DIV's height.
Has anyone tackled this before? Any suggestions? Code snippets would be wonderful.
ng-repeat has a pretty steep performance drop off with long lists due to the overhead associated with its bindings. One performance-conscious library I'm particularly fond of is ag-grid, which conveniently has an example with variable row heights. You might see if it would work for your purposes.
If nothing out there seems to fit your needs for this, you can always roll your own directive and handle the DOM manipulation yourself like the code snippet I threw together below. It doesn't cover everything you mentioned, but it includes infinite scrolling and removes old elements, replacing their height with an empty <div>, without using ng-repeat.
angular.module('SuperList', [])
.controller('mainCtrl', ['$scope', '$compile',
function($scope, $compile) {
// Magic numbers
var itemsPerLoad = 4;
var thresholdPx = 1200;
var removeThresholdPx = 1600;
// Options to control your directive are cool
$scope.listOptions = {
items: [],
renderer: renderer,
threshold: thresholdPx,
removeThreshold: removeThresholdPx,
loadFn: loadNewItems
};
// This function creates a div for each item in our dataset whenever
// it's called by the directive
function renderer(item) {
var itemElem = angular.element('<div></div');
itemElem.css('height', item.height + 'px');
itemElem.html(item.text);
return itemElem;
// If each row needs special angular behavior, you can compile it with
// something like the following instead of returning basic html
// return $compile(itemElem)($scope);
}
// This gets called by the directive when we need to populate more items
function loadNewItems() {
// Let's do it async like we're getting something from the server
setTimeout(function() {
for (var i = 0; i < itemsPerLoad; i++) {
// Give each item random text and height
$scope.listOptions.items.push({
text: Math.random().toString(36).substr(2, Infinity),
height: Math.floor(100 + Math.random() * 1100)
});
}
// Call the refresh function to let the directive know we've loaded
// We could, of course, use $watch in the directive and just make
// sure a $digest gets called here, but doing it this way is much faster.
$scope.listOptions.api.refresh();
}, 500);
// return true to let the directive know we're waiting on data, so don't
// call this function again until that happens
return true;
}
}
])
.directive('itemList', function() {
return {
restrict: 'A',
scope: {
itemList: '='
},
link: function(scope, element, attrs) {
var el = element[0];
var emptySpace = angular.element('<div class="empty-space"></div>');
element.append(emptySpace);
// Keep a selection of previous elements so we can remove them
// if the user scrolls far enough
var prevElems = null;
var prevHeight = 0;
var nextElems = 0;
var nextHeight = 0;
// Options are defined above the directive to keep things modular
var options = scope.itemList;
// Keep track of how many rows we've rendered so we know where we left off
var renderedRows = 0;
var pendingLoad = false;
// Add some API functions to let the calling scope interact
// with the directive more effectively
options.api = {
refresh: refresh
};
element.on('scroll', checkScroll);
// Perform the initial setup
refresh();
function refresh() {
addRows();
checkScroll();
}
// Adds any rows that haven't already been rendered. Note that the
// directive does not process any removed items, so if that functionality
// is needed you'll need to make changes to this directive
function addRows() {
nextElems = [];
for (var i = renderedRows; i < options.items.length; i++) {
var e = options.renderer(options.items[i]);
nextElems.push(e[0])
element.append(e);
renderedRows++;
pendingLoad = false;
}
nextElems = angular.element(nextElems);
nextHeight = el.scrollHeight;
// Do this for the first time to initialize
if (!prevElems && nextElems.length) {
prevElems = nextElems;
prevHeight = nextHeight;
}
}
function checkScroll() {
// Only check if we need to load if there isn't already an async load pending
if (!pendingLoad) {
if ((el.scrollHeight - el.scrollTop - el.clientHeight) < options.threshold) {
console.log('Loading new items!');
pendingLoad = options.loadFn();
// If we're not waiting for an async event, render the new rows
if (!pendingLoad) {
addRows();
}
}
}
// if we're past the remove threshld, remove all previous elements and replace
// lengthen the empty space div to fill the space they occupied
if (options.removeThreshold && el.scrollTop > prevHeight + options.removeThreshold) {
console.log('Removing previous elements');
prevElems.remove();
emptySpace.css('height', prevHeight + 'px');
// Stage the next elements for removal
prevElems = nextElems;
prevHeight = nextHeight;
}
}
}
};
});
.item-list {
border: 1px solid green;
width: 600px;
height: 300px;
overflow: auto;
}
.item-list > div {
border: 1px solid blue;
}
.item-list > .empty-space {
background: #aaffaa;
}
<html>
<head>
<link rel="stylesheet" href="test.css">
</head>
<body ng-app="SuperList" ng-controller="mainCtrl">
<div class="item-list" item-list="listOptions"></div>
<script src="https://opensource.keycdn.com/angularjs/1.5.8/angular.min.js"></script>
<script src="test.js"></script>
</body>
</html>
I've written a stacking function with jQuery that takes container element .search-results, gathers all the .card elements within each .col element in the container and then restacks them across a number of columns, which are calculated based off the width of the container.
When the page is loaded, all cards are initialized in the first column and subsequent columns are hidden. The script then unhides however many columns it determines should exist and iterates through the columns and cards, using appendTo to evenly distribute the cards amongst the columns.
I've created a jsFiddle that demonstrates the process.
The script works great when it's called on page load. If I want to call the script after the page has loaded though, I encounter a serious problem. If I remove one of the cards and want to restack them so the distribution remains even, I must call the stacking function again. When I do so, it causes the embedded content (e.g. embedded Tweets) within the cards to be reloaded, which is undesirable. Note that when the page loads the Tweets are not initialized until after the stacking function has been called.
In my example I intentionally iterate over the tweet ids to reinitialize them using twttr.widgets.createTweet(), however if I omit that step, the Tweets disappear after the cards are restacked. If I inspect the cards I can see that the Tweet widget iframe is still present, but the iframe's body is empty.
Now obviously I could simply reinitialize my Tweets after I restack the cards but that would offer up a poor user experience since there is a delay when (re)initializing an embedded Tweet. I had previously posed a question related to this. I found that I can manually move cards around from the console using .clone() and appendTo() without reloading the embedded Tweets, however I've not had any luck refactoring my stacking function to take advantage of this behavior, which is why I'm asking this question.
Here is my stacking function:
function resizeColumns(layoutElem, ignoreRank, width) {
if (width === undefined) {
var colMin = 300;
} else {
var colMin = width;
}
var w = layoutElem.width();
var numCols = Math.floor(w / colMin);
if (numCols === 0) numCols = 1;
layoutElem.removeClass('cols-1 cols-2 cols-3 cols-4 cols-5 cols-6 cols-7 cols-8 cols-9 cols-10 cols-11 cols-12');
layoutElem.addClass('cols-' + numCols);
var cols = layoutElem.find('.col');
var cards = cols.find('.card');
var sortedCards = [];
cards.each(function(i) {
var rank;
if (!ignoreRank) {
rank = parseInt($(this).attr('rank'));
if (isNaN(rank)) rank = 1000000;
} else {
var o = parseInt($(this).attr('order'));
if (isNaN(o)) {
rank = i;
$(this).attr('order', i);
} else {
rank = o;
}
}
sortedCards.push({
rank: rank,
element: $(this)
});
});
sortedCards.sort(rankCompare);
var curCol = 0;
for (var i in sortedCards) {
var cardElem = sortedCards[i].element;
cardElem.appendTo($(cols[curCol]));
curCol++;
if (curCol >= numCols) curCol = 0;
}
// hide any additional columns
cols.each(function(i) {
if (i >= numCols) {
$(this).hide();
} else {
$(this).show();
}
});
function rankCompare(a, b) {
if (a.rank < b.rank)
return -1;
if (a.rank > b.rank)
return 1;
return 0;
}
}
Steps I take to stack the cards and initialize the Tweets in the jsFiddle
The first time I call the function I introduce a delay so that $scope.cards is defined. Here is the first call:
$timeout(function() {
// Only the first argument is required.
resizeColumns($('.cards'), true, 250);
}, 250)
After I call the resizeColumns function I set a delay before initializing the tweets for the first time. This is essential so that the Twitter widgets script can locate the Tweets in the DOM.
Once the Tweets are initialized, I set a delay of 4s before removing a card and restacking the cards. Finally, once the cards are restacked, I reinitialize the Tweets. This last step is what I want to avoid. I don't want to have to reinitialize the Tweets because it's a slow process. I want the Tweet to reappear instantly once the card is moved.
$timeout(function() {
// Iterate over the Tweet IDs and initialize them.
ids.forEach(function(id) {
twttr.widgets.createTweet(id, document.getElementById(id));
});
$timeout(function() {
// Wait a few seconds and then remove a card from the DOM.
$('#' + $scope.cards[1].id).detach();
// Restack cards
resizeColumns($('.cards'), true, 250);
$timeout(function() {
// Reinitialize Tweets after cards have been restacked. This is the step I want to avoid.
ids.forEach(function(id) {
twttr.widgets.createTweet(id, document.getElementById(id));
});
}, 500)
}, 4000);
}, 1000);
Based on the answer to the other question, try changing:
sortedCards.push({
rank: rank,
element: $(this)
});
to:
sortedCards.push({
rank: rank,
element: $(this).clone();
});
$(this).detach();
Or you could do the clone and detach in the appendTo loop:
for (var i in sortedCards) {
var cardElem = sortedCards[i].element.clone();
cardElem.appendTo($(cols[curCol]));
sortedCards[i].element.detach()
curCol = (curCol + 1) % numCols;
}
I'm trying to create a tabbed area within my page. The tabs navigate hidden areas with out leaving the page. I also want to be able to link to an area with in the page. It's working except when you click the menu as well as revealing the hidden area it's rewriting the URL with only the tab extension and therefor breaking the link of the URL. So someone trying to share the link would not know the format..
I'm using this code https://css-tricks.com/examples/OrganicTabsReplaceState which I see no problem with.
You can see a live demo with my issue here: http://bit.ly/1IP1ST4
Clicking the tab is removing:
/products/eurorack-modules/waveform-modifiers/reactive-shaper/
And replacing it with ?tab=mytabname
It should be simply adding it. I'm struggling to work out why..?
If you inspect the source of the first link you provided, you will see that the tabs contain links like this:
Featured
That's an in-page link. You should use #'s for in page links. The reason the whole url is being replaced is because it's interpreting the href as a new url to go to. #'s look inside the current page.
This version of organictabs.jquery.js got it working in the end seemed to be an issue with the way it treated the URL.. Maybe this will help someone else.
// IIFE
(function($) {
// Define Plugin
$.organicTabs = function(el, options) {
// JavaScript native version of this
var base = this;
// jQuery version of this
base.$el = $(el);
// Navigation for current selector passed to plugin
base.$nav = base.$el.find(".nav");
// Returns the fragment identifier of the given URL
function getFragmentIdentifier(url) {
if(url && url.match && url.match(/#(.*)/)) {
return RegExp.$1;
}
}
// Remove the query string from the url
function noQueryString(url) {
if(url && url.match && url.match(/^([^\?]*)\??/)) {
return RegExp.$1;
}
}
// Runs once when plugin called
base.init = function() {
// Pull in arguments
base.options = $.extend({},$.organicTabs.defaultOptions, options);
// Accessible hiding fix (hmmm, re-look at this, screen readers still run JS)
$(".hide").css({
"position": "relative",
"top": 0,
"left": 0,
"display": "none"
});
// When navigation tab is clicked...
base.$nav.delegate("a", "click", function(e) {
// no hash links
e.preventDefault();
// Figure out current list via CSS class
var curList = getFragmentIdentifier(base.$el.find("a.current").attr("href")),
// List moving to
$newList = $(this),
// Figure out ID of new list
listID = getFragmentIdentifier($newList.attr("href")),
// Set outer wrapper height to (static) height of current inner list
$allListWrap = base.$el.find(".list-wrap"),
curListHeight = $allListWrap.height();
$allListWrap.height(curListHeight);
if ((listID != curList) && ( base.$el.find(":animated").length == 0)) {
// Fade out current list
base.$el.find("#"+curList).fadeOut(base.options.speed, function() {
// Fade in new list on callback
base.$el.find("#"+listID).fadeIn(base.options.speed);
// Adjust outer wrapper to fit new list snuggly
var newHeight = base.$el.find("#"+listID).height();
$allListWrap.animate({
height: newHeight
}, base.options.speed);
// Remove highlighting - Add to just-clicked tab
base.$el.find(".nav li a").removeClass("current");
$newList.addClass("current");
// Change window location to add URL params
if (window.history && history.pushState) {
// NOTE: doesn't take into account existing params
history.replaceState("", "", noQueryString(window.location.href) + "?" + base.options.param + "=" + listID);
}
});
}
});
var queryString = {};
window.location.href.replace(
new RegExp("([^?=&]+)(=([^&]*))?", "g"),
function($0, $1, $2, $3) { queryString[$1] = $3; }
);
if (queryString[base.options.param]) {
var tab = $("a[href='#" + queryString[base.options.param] + "']");
tab
.closest(".nav")
.find("a")
.removeClass("current")
.end()
.next(".list-wrap")
.find("ul")
.hide();
tab.addClass("current");
$("#" + queryString[base.options.param]).show();
};
};
base.init();
};
$.organicTabs.defaultOptions = {
"speed": 300,
"param": "tab"
};
$.fn.organicTabs = function(options) {
return this.each(function() {
(new $.organicTabs(this, options));
});
};
})(jQuery);
So I'm trying to implement stellar.js but it must be initialized after an each loop is finished. The loop must add data attributes to the images that are going to be made parallax by the plugin.
The images are in a list:
<ul>
<li class="item">
<div class="item-image" data-stellar-background-ratio="0.7" data-image="http://picjumbo.picjumbocom.netdna-cdn.com/wp-content/uploads/IMG_7706-1300x866.jpg"></div>
</li>
...
</ul>
I must add data-stellar-vertical-offset attribute to each of them that will offset the image by half of its height, so it can be vertically centered initially.
Here is the JS:
/* Inserting the background image */
$('.item-image').each(function () {
var $this = $(this);
$this.css('background-image', 'url(' + $this.data('image') + ')');
})
/* Creating loop that will run as many times as items are in there */
var items = $('.item-image').length;
var currentItem = 0;
$('.item-image').each(function () {
var $this = $(this);
/* Taking the origin height, halving it and putting it as offset so the image can be vertically aligned */
var img = new Image();
img.src = $(this).data('image');
img.onload = function () {
var H2 = this.height;
$this.attr('data-stellar-vertical-offset', -(H2 / 2));
}
currentItem++;
/* Initializing the plugin after every item is looped */
if (currentItem >= items) {
$.stellar();
}
})
However when the plugin is initialized it isn't using the data attribute. If it's put in a timeout like this:
if (currentItem >= items) {
setTimeout(function () {
$.stellar();
}, 10)
}
.. it works but it seems to me like an ugly hack. Is there a better way for this to be done?
Here is a jsfiddle: http://jsfiddle.net/9f2tc/1/
I believe what you want is to initialize stellar once after all the images have been downloaded. The simplest approach is to check each time in the onload handler:
img.onload = function () {
var H2 = this.height;
$this.attr('data-stellar-vertical-offset', -(H2 / 2))
if (++currentItem === items) {
$.stellar();
}
}
jsfiddle: http://jsfiddle.net/X6e9n/2/
However, there are issues with the onload event not firing for images in certain cases. See the caveats section on the jQuery page: http://api.jquery.com/load-event/ The problems listed apply to the load event itself not just jQuery's .load() See Javascript callback for knowing when an image is loaded for solutions. The first answer notes the handler should be attached before the src attribute is set, which you don't do here, but it doesn't seem to be a problem for me in this case.
Our application uses infinite scrolling to navigate large lists of heterogenous items. There are a few wrinkles:
It's common for our users to have a list of 10,000 items and need to scroll through 3k+.
These are rich items, so we can only have a few hundred in the DOM before browser performance becomes unacceptable.
The items are of varying heights.
The items may contain images and we allow the user to jump to a specific date. This is tricky because the user can jump to a point in the list where we need to load images above the viewport, which would push the content down when they load. Failing to handle that means that the user may jump to a date, but then be shifted to an earlier date.
Known, incomplete solutions:
(react-infinite-scroll) - This is just a simple "load more when we hit the bottom" component. It does not cull any of the DOM, so it will die on thousands of items.
(Scroll Position with React) - Shows how to store and restore the scroll position when inserting at the top or inserting at the bottom, but not both together.
I'm not looking for the code for a complete solution (although that would be great.) Instead, I'm looking for the "React way" to model this situation. Is scroll position state or not? What state should I be tracking to retain my position in the list? What state do I need to keep so that I trigger a new render when I scroll near the bottom or top of what is rendered?
This is a mix of an infinite table and an infinite scroll scenario. The best abstraction I found for this is the following:
Overview
Make a <List> component that takes an array of all children. Since we do not render them, it's really cheap to just allocate them and discard them. If 10k allocations is too big, you can instead pass a function that takes a range and return the elements.
<List>
{thousandelements.map(function() { return <Element /> })}
</List>
Your List component is keeping track of what the scroll position is and only renders the children that are in view. It adds a large empty div at the beginning to fake the previous items that are not rendered.
Now, the interesting part is that once an Element component is rendered, you measure its height and store it in your List. This lets you compute the height of the spacer and know how many elements should be displayed in view.
Image
You are saying that when the image are loading they make everything "jump" down. The solution for this is to set the image dimensions in your img tag: <img src="..." width="100" height="58" />. This way the browser doesn't have to wait to download it before knowing what size it is going to be displayed. This requires some infrastructure but it's really worth it.
If you can't know the size in advance, then add onload listeners to your image and when it is loaded then measure its displayed dimension and update the stored row height and compensate the scroll position.
Jumping at a random element
If you need to jump at a random element in the list that's going to require some trickery with scroll position because you don't know the size of the elements in between. What I suggest you to do is to average the element heights you already have computed and jump to the scroll position of last known height + (number of elements * average).
Since this is not exact it's going to cause issues when you reach back to the last known good position. When a conflict happens, simply change the scroll position to fix it. This is going to move the scroll bar a bit but shouldn't affect him/her too much.
React Specifics
You want to provide a key to all the rendered elements so that they are maintained across renders. There are two strategies: (1) have only n keys (0, 1, 2, ... n) where n is the maximum number of elements you can display and use their position modulo n. (2) have a different key per element. If all the elements share a similar structure it's good to use (1) to reuse their DOM nodes. If they don't then use (2).
I would only have two pieces of React state: the index of the first element and the number of elements being displayed. The current scroll position and the height of all the elements would be directly attached to this. When using setState you are actually doing a rerender which should only happen when the range changes.
Here is an example of infinite list using some of the techniques I describe in this answer. It's going to be some work but React is definitively a good way to implement an infinite list :)
have a look at http://adazzle.github.io/react-data-grid/index.html#
This looks like a powerful and performant datagrid with Excel-like features and lazy loading/optimized rendering (for millions of rows) with rich editing features (MIT licenced).
Not yet tried in our project but will do so pretty soon.
A great resource to search for things like these is also http://react.rocks/
In this case, a tag search is helpful:
http://react.rocks/tag/InfiniteScroll
I was facing a similar challenge for modeling single-direction infinite scrolling with heterogeneous item heights and so made an npm package out of my solution:
https://www.npmjs.com/package/react-variable-height-infinite-scroller
and a demo: http://tnrich.github.io/react-variable-height-infinite-scroller/
You can check out the source code for the logic, but I basically followed the recipe #Vjeux outlined in the above answer. I haven't yet tackled jumping to a particular item, but I'm hoping to implement that soon.
Here's the nitty-gritty of what the code currently looks like:
var React = require('react');
var areNonNegativeIntegers = require('validate.io-nonnegative-integer-array');
var InfiniteScoller = React.createClass({
propTypes: {
averageElementHeight: React.PropTypes.number.isRequired,
containerHeight: React.PropTypes.number.isRequired,
preloadRowStart: React.PropTypes.number.isRequired,
renderRow: React.PropTypes.func.isRequired,
rowData: React.PropTypes.array.isRequired,
},
onEditorScroll: function(event) {
var infiniteContainer = event.currentTarget;
var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
var currentAverageElementHeight = (visibleRowsContainer.getBoundingClientRect().height / this.state.visibleRows.length);
this.oldRowStart = this.rowStart;
var newRowStart;
var distanceFromTopOfVisibleRows = infiniteContainer.getBoundingClientRect().top - visibleRowsContainer.getBoundingClientRect().top;
var distanceFromBottomOfVisibleRows = visibleRowsContainer.getBoundingClientRect().bottom - infiniteContainer.getBoundingClientRect().bottom;
var rowsToAdd;
if (distanceFromTopOfVisibleRows < 0) {
if (this.rowStart > 0) {
rowsToAdd = Math.ceil(-1 * distanceFromTopOfVisibleRows / currentAverageElementHeight);
newRowStart = this.rowStart - rowsToAdd;
if (newRowStart < 0) {
newRowStart = 0;
}
this.prepareVisibleRows(newRowStart, this.state.visibleRows.length);
}
} else if (distanceFromBottomOfVisibleRows < 0) {
//scrolling down, so add a row below
var rowsToGiveOnBottom = this.props.rowData.length - 1 - this.rowEnd;
if (rowsToGiveOnBottom > 0) {
rowsToAdd = Math.ceil(-1 * distanceFromBottomOfVisibleRows / currentAverageElementHeight);
newRowStart = this.rowStart + rowsToAdd;
if (newRowStart + this.state.visibleRows.length >= this.props.rowData.length) {
//the new row start is too high, so we instead just append the max rowsToGiveOnBottom to our current preloadRowStart
newRowStart = this.rowStart + rowsToGiveOnBottom;
}
this.prepareVisibleRows(newRowStart, this.state.visibleRows.length);
}
} else {
//we haven't scrolled enough, so do nothing
}
this.updateTriggeredByScroll = true;
//set the averageElementHeight to the currentAverageElementHeight
// setAverageRowHeight(currentAverageElementHeight);
},
componentWillReceiveProps: function(nextProps) {
var rowStart = this.rowStart;
var newNumberOfRowsToDisplay = this.state.visibleRows.length;
this.props.rowData = nextProps.rowData;
this.prepareVisibleRows(rowStart, newNumberOfRowsToDisplay);
},
componentWillUpdate: function() {
var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
this.soonToBeRemovedRowElementHeights = 0;
this.numberOfRowsAddedToTop = 0;
if (this.updateTriggeredByScroll === true) {
this.updateTriggeredByScroll = false;
var rowStartDifference = this.oldRowStart - this.rowStart;
if (rowStartDifference < 0) {
// scrolling down
for (var i = 0; i < -rowStartDifference; i++) {
var soonToBeRemovedRowElement = visibleRowsContainer.children[i];
if (soonToBeRemovedRowElement) {
var height = soonToBeRemovedRowElement.getBoundingClientRect().height;
this.soonToBeRemovedRowElementHeights += this.props.averageElementHeight - height;
// this.soonToBeRemovedRowElementHeights.push(soonToBeRemovedRowElement.getBoundingClientRect().height);
}
}
} else if (rowStartDifference > 0) {
this.numberOfRowsAddedToTop = rowStartDifference;
}
}
},
componentDidUpdate: function() {
//strategy: as we scroll, we're losing or gaining rows from the top and replacing them with rows of the "averageRowHeight"
//thus we need to adjust the scrollTop positioning of the infinite container so that the UI doesn't jump as we
//make the replacements
var infiniteContainer = React.findDOMNode(this.refs.infiniteContainer);
var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
var self = this;
if (this.soonToBeRemovedRowElementHeights) {
infiniteContainer.scrollTop = infiniteContainer.scrollTop + this.soonToBeRemovedRowElementHeights;
}
if (this.numberOfRowsAddedToTop) {
//we're adding rows to the top, so we're going from 100's to random heights, so we'll calculate the differenece
//and adjust the infiniteContainer.scrollTop by it
var adjustmentScroll = 0;
for (var i = 0; i < this.numberOfRowsAddedToTop; i++) {
var justAddedElement = visibleRowsContainer.children[i];
if (justAddedElement) {
adjustmentScroll += this.props.averageElementHeight - justAddedElement.getBoundingClientRect().height;
var height = justAddedElement.getBoundingClientRect().height;
}
}
infiniteContainer.scrollTop = infiniteContainer.scrollTop - adjustmentScroll;
}
var visibleRowsContainer = React.findDOMNode(this.refs.visibleRowsContainer);
if (!visibleRowsContainer.childNodes[0]) {
if (this.props.rowData.length) {
//we've probably made it here because a bunch of rows have been removed all at once
//and the visible rows isn't mapping to the row data, so we need to shift the visible rows
var numberOfRowsToDisplay = this.numberOfRowsToDisplay || 4;
var newRowStart = this.props.rowData.length - numberOfRowsToDisplay;
if (!areNonNegativeIntegers([newRowStart])) {
newRowStart = 0;
}
this.prepareVisibleRows(newRowStart , numberOfRowsToDisplay);
return; //return early because we need to recompute the visible rows
} else {
throw new Error('no visible rows!!');
}
}
var adjustInfiniteContainerByThisAmount;
//check if the visible rows fill up the viewport
//tnrtodo: maybe put logic in here to reshrink the number of rows to display... maybe...
if (visibleRowsContainer.getBoundingClientRect().height / 2 <= this.props.containerHeight) {
//visible rows don't yet fill up the viewport, so we need to add rows
if (this.rowStart + this.state.visibleRows.length < this.props.rowData.length) {
//load another row to the bottom
this.prepareVisibleRows(this.rowStart, this.state.visibleRows.length + 1);
} else {
//there aren't more rows that we can load at the bottom so we load more at the top
if (this.rowStart - 1 > 0) {
this.prepareVisibleRows(this.rowStart - 1, this.state.visibleRows.length + 1); //don't want to just shift view
} else if (this.state.visibleRows.length < this.props.rowData.length) {
this.prepareVisibleRows(0, this.state.visibleRows.length + 1);
}
}
} else if (visibleRowsContainer.getBoundingClientRect().top > infiniteContainer.getBoundingClientRect().top) {
//scroll to align the tops of the boxes
adjustInfiniteContainerByThisAmount = visibleRowsContainer.getBoundingClientRect().top - infiniteContainer.getBoundingClientRect().top;
// this.adjustmentScroll = true;
infiniteContainer.scrollTop = infiniteContainer.scrollTop + adjustInfiniteContainerByThisAmount;
} else if (visibleRowsContainer.getBoundingClientRect().bottom < infiniteContainer.getBoundingClientRect().bottom) {
//scroll to align the bottoms of the boxes
adjustInfiniteContainerByThisAmount = visibleRowsContainer.getBoundingClientRect().bottom - infiniteContainer.getBoundingClientRect().bottom;
// this.adjustmentScroll = true;
infiniteContainer.scrollTop = infiniteContainer.scrollTop + adjustInfiniteContainerByThisAmount;
}
},
componentWillMount: function(argument) {
//this is the only place where we use preloadRowStart
var newRowStart = 0;
if (this.props.preloadRowStart < this.props.rowData.length) {
newRowStart = this.props.preloadRowStart;
}
this.prepareVisibleRows(newRowStart, 4);
},
componentDidMount: function(argument) {
//call componentDidUpdate so that the scroll position will be adjusted properly
//(we may load a random row in the middle of the sequence and not have the infinte container scrolled properly initially, so we scroll to the show the rowContainer)
this.componentDidUpdate();
},
prepareVisibleRows: function(rowStart, newNumberOfRowsToDisplay) { //note, rowEnd is optional
//setting this property here, but we should try not to use it if possible, it is better to use
//this.state.visibleRowData.length
this.numberOfRowsToDisplay = newNumberOfRowsToDisplay;
var rowData = this.props.rowData;
if (rowStart + newNumberOfRowsToDisplay > this.props.rowData.length) {
this.rowEnd = rowData.length - 1;
} else {
this.rowEnd = rowStart + newNumberOfRowsToDisplay - 1;
}
// var visibleRows = this.state.visibleRowsDataData.slice(rowStart, this.rowEnd + 1);
// rowData.slice(rowStart, this.rowEnd + 1);
// setPreloadRowStart(rowStart);
this.rowStart = rowStart;
if (!areNonNegativeIntegers([this.rowStart, this.rowEnd])) {
var e = new Error('Error: row start or end invalid!');
console.warn('e.trace', e.trace);
throw e;
}
var newVisibleRows = rowData.slice(this.rowStart, this.rowEnd + 1);
this.setState({
visibleRows: newVisibleRows
});
},
getVisibleRowsContainerDomNode: function() {
return this.refs.visibleRowsContainer.getDOMNode();
},
render: function() {
var self = this;
var rowItems = this.state.visibleRows.map(function(row) {
return self.props.renderRow(row);
});
var rowHeight = this.currentAverageElementHeight ? this.currentAverageElementHeight : this.props.averageElementHeight;
this.topSpacerHeight = this.rowStart * rowHeight;
this.bottomSpacerHeight = (this.props.rowData.length - 1 - this.rowEnd) * rowHeight;
var infiniteContainerStyle = {
height: this.props.containerHeight,
overflowY: "scroll",
};
return (
<div
ref="infiniteContainer"
className="infiniteContainer"
style={infiniteContainerStyle}
onScroll={this.onEditorScroll}
>
<div ref="topSpacer" className="topSpacer" style={{height: this.topSpacerHeight}}/>
<div ref="visibleRowsContainer" className="visibleRowsContainer">
{rowItems}
</div>
<div ref="bottomSpacer" className="bottomSpacer" style={{height: this.bottomSpacerHeight}}/>
</div>
);
}
});
module.exports = InfiniteScoller;