diff --git a/examples/gridview/app.js b/examples/gridview/app.js
new file mode 100644
index 0000000..cf9f1d5
--- /dev/null
+++ b/examples/gridview/app.js
@@ -0,0 +1,67 @@
+'use strict';
+
+var React = require('react');
+var ReactCanvas = require('react-canvas');
+var Item = require('./components/Item');
+var articles = require('../common/data');
+
+var Surface = ReactCanvas.Surface;
+var GridView = ReactCanvas.GridView;
+
+var App = React.createClass({
+ render: function () {
+ var size = this.getSize();
+ return (
+
+
+
+ );
+ },
+
+ renderItem: function (itemIndex, scrollTop, scrollLeft) {
+ var article = articles[itemIndex % articles.length];
+ return (
+
+ );
+ },
+
+ getSize: function () {
+ return document.getElementById('main').getBoundingClientRect();
+ },
+
+
+ // GridView
+ // ========
+
+ getGridViewStyle: function () {
+ var size = this.getSize();
+ return {
+ top: 0,
+ left: 0,
+ width: size.width,
+ height: size.height,
+ backgroundColor: '#fff'
+ };
+ },
+
+ getNumberOfItems: function () {
+ return 100;
+ },
+
+ getNumberOfColumns: function () {
+ return 4;
+ }
+});
+
+React.render(, document.getElementById('main'));
diff --git a/examples/gridview/components/Item.js b/examples/gridview/components/Item.js
new file mode 100644
index 0000000..2c185c5
--- /dev/null
+++ b/examples/gridview/components/Item.js
@@ -0,0 +1,75 @@
+/** @jsx React.DOM */
+
+'use strict';
+
+var React = require('react');
+var ReactCanvas = require('react-canvas');
+
+var Group = ReactCanvas.Group;
+var Image = ReactCanvas.Image;
+var Text = ReactCanvas.Text;
+
+var Item = React.createClass({
+
+ propTypes: {
+ width: React.PropTypes.number.isRequired,
+ height: React.PropTypes.number.isRequired,
+ imageUrl: React.PropTypes.string.isRequired,
+ title: React.PropTypes.string.isRequired,
+ itemIndex: React.PropTypes.number.isRequired,
+ },
+
+ statics: {
+ getItemHeight: function () {
+ return 180;
+ },
+ getItemWidth: function () {
+ return 180;
+ },
+ },
+
+ render: function () {
+ return (
+
+
+ {this.props.title}
+
+ );
+ },
+
+ getStyle: function () {
+ return {
+ width: this.props.width,
+ height: this.props.height,
+ backgroundColor: (this.props.itemIndex % 2) ? '#eee' : '#a5d2ee',
+ borderColor: '#999',
+ borderWidth: 1
+ };
+ },
+
+ getImageStyle: function () {
+ return {
+ top: 10,
+ left: 10,
+ width: 60,
+ height: 60,
+ backgroundColor: '#ddd',
+ borderColor: '#999',
+ borderWidth: 1
+ };
+ },
+
+ getTitleStyle: function () {
+ return {
+ top: 10,
+ left: 80,
+ width: this.props.width - 90,
+ height: this.props.height,
+ fontSize: 12,
+ lineHeight: 18,
+ };
+ }
+
+});
+
+module.exports = Item;
diff --git a/examples/gridview/index.html b/examples/gridview/index.html
new file mode 100644
index 0000000..28d8687
--- /dev/null
+++ b/examples/gridview/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ ReactCanvas: GridView
+
+
+
+
+
+
+
+
+
diff --git a/lib/GridView.js b/lib/GridView.js
new file mode 100644
index 0000000..2ef7801
--- /dev/null
+++ b/lib/GridView.js
@@ -0,0 +1,217 @@
+'use strict';
+
+var React = require('react');
+var assign = require('react/lib/Object.assign');
+var Scroller = require('scroller');
+var Group = require('./Group');
+var clamp = require('./clamp');
+
+var GridView = React.createClass({
+
+ propTypes: {
+ style: React.PropTypes.object,
+ numberOfItemsGetter: React.PropTypes.func.isRequired,
+ itemHeightGetter: React.PropTypes.func.isRequired,
+ itemWidthGetter: React.PropTypes.func.isRequired,
+ itemGetter: React.PropTypes.func.isRequired,
+ numberOfColumnsGetter: React.PropTypes.func.isRequired,
+ snapping: React.PropTypes.bool,
+ scrollingDeceleration: React.PropTypes.number,
+ scrollingPenetrationAcceleration: React.PropTypes.number,
+ onScroll: React.PropTypes.func
+ },
+
+ getDefaultProps: function () {
+ return {
+ style: { left: 0, top: 0, width: 0, height: 0 },
+ snapping: false,
+ scrollingDeceleration: 0.95,
+ scrollingPenetrationAcceleration: 0.08
+ };
+ },
+
+ getInitialState: function () {
+ return {
+ scrollTop: 0,
+ scrollLeft: 0,
+ };
+ },
+
+ componentDidMount: function () {
+ this.createScroller();
+ this.updateScrollingDimensions();
+ },
+
+ render: function () {
+ var items = this.getVisibleItemIndexes().map(this.renderItem);
+ return (
+ React.createElement(Group, {
+ style: this.props.style,
+ onTouchStart: this.handleTouchStart,
+ onTouchMove: this.handleTouchMove,
+ onTouchEnd: this.handleTouchEnd,
+ onTouchCancel: this.handleTouchEnd},
+ items
+ )
+ );
+ },
+
+ renderItem: function (itemIndex) {
+ var scrollTop = this.state.scrollTop;
+ var scrollLeft = this.state.scrollLeft;
+ var item = this.props.itemGetter(itemIndex, scrollTop, scrollLeft);
+ var itemHeight = this.props.itemHeightGetter();
+ var itemWidth = this.props.itemWidthGetter();
+ var columnCount = this.props.numberOfColumnsGetter();
+ var row = Math.floor(itemIndex / columnCount);
+ var column = itemIndex % columnCount;
+ var style = {
+ top: 0,
+ left: 0,
+ width: itemWidth,
+ height: itemHeight,
+ translateY: (row * itemHeight) - scrollTop,
+ translateX: (column * itemWidth) - scrollLeft,
+ zIndex: itemIndex
+ };
+
+ return (
+ React.createElement(Group, {style: style, key: itemIndex},
+ item
+ )
+ );
+ },
+
+ // Events
+ // ======
+
+ handleTouchStart: function (e) {
+ if (this.scroller) {
+ this.scroller.doTouchStart(e.touches, e.timeStamp || Date.now());
+ }
+ },
+
+ handleTouchMove: function (e) {
+ if (this.scroller) {
+ e.preventDefault();
+ this.scroller.doTouchMove(e.touches, e.timeStamp || Date.now(), e.scale);
+ }
+ },
+
+ handleTouchEnd: function (e) {
+ if (this.scroller) {
+ this.scroller.doTouchEnd(e.timeStamp || Date.now());
+ if (this.props.snapping) {
+ this.updateScrollingDeceleration();
+ }
+ }
+ },
+
+ handleScroll: function (left, top) {
+ this.setState({ scrollTop: top, scrollLeft: left });
+ if (this.props.onScroll) {
+ this.props.onScroll(top, left);
+ }
+ },
+
+ // Scrolling
+ // =========
+
+ createScroller: function () {
+ var options = {
+ scrollingX: true,
+ scrollingY: true,
+ decelerationRate: this.props.scrollingDeceleration,
+ penetrationAcceleration: this.props.scrollingPenetrationAcceleration,
+ };
+ this.scroller = new Scroller(this.handleScroll, options);
+ },
+
+ updateScrollingDimensions: function () {
+ var width = this.props.style.width;
+ var height = this.props.style.height;
+ var numberOfItems = this.props.numberOfItemsGetter();
+ var numberOfColumns = this.props.numberOfColumnsGetter();
+ var rows = Math.floor(numberOfItems / numberOfColumns);
+ var scrollWidth = numberOfColumns * this.props.itemWidthGetter();
+ var scrollHeight = rows * this.props.itemHeightGetter();
+ this.scroller.setDimensions(width, height, scrollWidth, scrollHeight);
+ },
+
+ getVisibleItemIndexes: function () {
+ var itemIndexes = [];
+ var itemHeight = this.props.itemHeightGetter();
+ var itemWidth = this.props.itemWidthGetter();
+ var itemCount = this.props.numberOfItemsGetter();
+ var columnCount = this.props.numberOfColumnsGetter();
+ var scrollTop = this.state.scrollTop;
+ var scrollLeft = this.state.scrollLeft;
+ var itemScrollTop = 0;
+ var itemScrollLeft = 0;
+
+ for (var index=0; index < itemCount; index++) {
+ var row = Math.floor(index / columnCount);
+ var column = index % columnCount;
+
+ itemScrollTop = (row * itemHeight) - scrollTop;
+ itemScrollLeft = (column * itemWidth) - scrollLeft;
+
+ // Item is completely off-screen bottom
+ if (itemScrollTop >= this.props.style.height) {
+ continue;
+ }
+
+ // Item is completely off-screen top
+ if (itemScrollTop <= -this.props.style.height) {
+ continue;
+ }
+
+ // Item is completely off-screen right
+ if (itemScrollLeft >= this.props.style.width) {
+ continue;
+ }
+
+ // Item is completely off-screen left
+ if (itemScrollLeft <= -this.props.style.width) {
+ continue;
+ }
+
+ // Part of item is on-screen.
+ itemIndexes.push(index);
+ }
+
+ return itemIndexes;
+ },
+
+ updateScrollingDeceleration: function () {
+ var currVelocity = this.scroller.__decelerationVelocityY;
+ var currScrollTop = this.state.scrollTop;
+ var targetScrollTop = 0;
+ var estimatedEndScrollTop = currScrollTop;
+
+ while (Math.abs(currVelocity).toFixed(6) > 0) {
+ estimatedEndScrollTop += currVelocity;
+ currVelocity *= this.props.scrollingDeceleration;
+ }
+
+ // Find the page whose estimated end scrollTop is closest to 0.
+ var closestZeroDelta = Infinity;
+ var pageHeight = this.props.itemHeightGetter();
+ var pageCount = this.props.numberOfItemsGetter();
+ var pageScrollTop;
+
+ for (var pageIndex=0, len=pageCount; pageIndex < len; pageIndex++) {
+ pageScrollTop = (pageHeight * pageIndex) - estimatedEndScrollTop;
+ if (Math.abs(pageScrollTop) < closestZeroDelta) {
+ closestZeroDelta = Math.abs(pageScrollTop);
+ targetScrollTop = pageHeight * pageIndex;
+ }
+ }
+
+ this.scroller.__minDecelerationScrollTop = targetScrollTop;
+ this.scroller.__maxDecelerationScrollTop = targetScrollTop;
+ }
+
+});
+
+module.exports = GridView;
diff --git a/lib/ReactCanvas.js b/lib/ReactCanvas.js
index ba4dd79..d4c2eb0 100644
--- a/lib/ReactCanvas.js
+++ b/lib/ReactCanvas.js
@@ -8,6 +8,7 @@ var ReactCanvas = {
Image: require('./Image'),
Text: require('./Text'),
ListView: require('./ListView'),
+ GridView: require('./GridView'),
FontFace: require('./FontFace'),
measureText: require('./measureText')
diff --git a/webpack.config.js b/webpack.config.js
index b82ceef..9a75726 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -5,6 +5,7 @@ module.exports = {
entry: {
'listview': ['./examples/listview/app.js'],
+ 'gridview': ['./examples/gridview/app.js'],
'timeline': ['./examples/timeline/app.js'],
'css-layout': ['./examples/css-layout/app.js']
},