diff --git a/examples/carousel/app.js b/examples/carousel/app.js
new file mode 100644
index 0000000..397c336
--- /dev/null
+++ b/examples/carousel/app.js
@@ -0,0 +1,72 @@
+/** @jsx React.DOM */
+
+'use strict';
+
+var React = require('react');
+var ReactCanvas = require('react-canvas');
+var Page = require('./components/Page');
+var articles = require('../common/data');
+
+var Surface = ReactCanvas.Surface;
+var Carousel = ReactCanvas.Carousel;
+
+var App = React.createClass({
+
+ render: function () {
+ var size = this.getSize();
+ return (
+
+
+
+ );
+ },
+
+ renderPage: function (pageIndex, scrollLeft) {
+ var size = this.getSize();
+ var article = articles[pageIndex % articles.length];
+ var pageScrollLeft = pageIndex * this.getPageWidth() - scrollLeft;
+ return (
+
+ );
+ },
+
+ getSize: function () {
+ return document.getElementById('main').getBoundingClientRect();
+ },
+
+ // ListView
+ // ========
+
+ getListViewStyle: function () {
+ var size = this.getSize();
+ return {
+ top: 0,
+ left: 0,
+ width: size.width,
+ height: size.height
+ };
+ },
+
+ getNumberOfPages: function () {
+ return 1000;
+ },
+
+ getPageWidth: function () {
+ return this.getSize().width;
+ }
+
+});
+
+React.render(, document.getElementById('main'));
diff --git a/examples/carousel/components/Page.js b/examples/carousel/components/Page.js
new file mode 100644
index 0000000..a7e18f9
--- /dev/null
+++ b/examples/carousel/components/Page.js
@@ -0,0 +1,131 @@
+/** @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 FontFace = ReactCanvas.FontFace;
+var measureText = ReactCanvas.measureText;
+
+var CONTENT_INSET = 14;
+var TEXT_SCROLL_SPEED_MULTIPLIER = 0.6;
+var TEXT_ALPHA_SPEED_OUT_MULTIPLIER = 1.25;
+var TEXT_ALPHA_SPEED_IN_MULTIPLIER = 2.6;
+var IMAGE_LAYER_INDEX = 2;
+var TEXT_LAYER_INDEX = 1;
+
+var Page = React.createClass({
+
+ propTypes: {
+ width: React.PropTypes.number.isRequired,
+ height: React.PropTypes.number.isRequired,
+ article: React.PropTypes.object.isRequired,
+ scrollLeft: React.PropTypes.number.isRequired
+ },
+
+ componentWillMount: function () {
+ // Pre-compute headline/excerpt text dimensions.
+ var article = this.props.article;
+ var maxWidth = this.props.width - 2 * CONTENT_INSET;
+ var titleStyle = this.getTitleStyle();
+ var excerptStyle = this.getExcerptStyle();
+ this.titleMetrics = measureText(article.title, maxWidth, titleStyle.fontFace, titleStyle.fontSize, titleStyle.lineHeight);
+ this.excerptMetrics = measureText(article.excerpt, maxWidth, excerptStyle.fontFace, excerptStyle.fontSize, excerptStyle.lineHeight);
+ },
+
+ render: function () {
+ var groupStyle = this.getGroupStyle();
+ var imageStyle = this.getImageStyle();
+ var titleStyle = this.getTitleStyle();
+ var excerptStyle = this.getExcerptStyle();
+
+ // Layout title and excerpt below image.
+ titleStyle.height = this.titleMetrics.height;
+ excerptStyle.top = titleStyle.top + titleStyle.height + CONTENT_INSET;
+ excerptStyle.height = this.props.height - excerptStyle.top - CONTENT_INSET;
+
+ return (
+
+
+
+ {this.props.article.title}
+ {this.props.article.excerpt}
+
+
+ );
+ },
+
+ // Styles
+ // ======
+
+ getGroupStyle: function () {
+ return {
+ top: 0,
+ left: 0,
+ width: this.props.width,
+ height: this.props.height,
+ };
+ },
+
+ getImageHeight: function () {
+ return Math.round(this.props.height * 0.5);
+ },
+
+ getImageStyle: function () {
+ return {
+ top: 0,
+ left: 0,
+ width: this.props.width,
+ height: this.getImageHeight(),
+ backgroundColor: '#eee',
+ zIndex: IMAGE_LAYER_INDEX
+ };
+ },
+
+ getTitleStyle: function () {
+ return {
+ top: this.getImageHeight() + CONTENT_INSET,
+ left: CONTENT_INSET,
+ width: this.props.width - 2 * CONTENT_INSET,
+ fontSize: 20,
+ lineHeight: 30,
+ fontFace: FontFace('Avenir Next Condensed, Helvetica, sans-serif', null, {weight: 500})
+ };
+ },
+
+ getExcerptStyle: function () {
+ return {
+ left: CONTENT_INSET,
+ width: this.props.width - 2 * CONTENT_INSET,
+ fontFace: FontFace('Georgia, serif'),
+ fontSize: 12,
+ lineHeight: 23
+ };
+ },
+
+ getTextGroupStyle: function () {
+ var imageHeight = this.getImageHeight();
+ var translateX = 0;
+ var alphaMultiplier = (this.props.scrollLeft <= 0) ? -TEXT_ALPHA_SPEED_OUT_MULTIPLIER : TEXT_ALPHA_SPEED_IN_MULTIPLIER;
+ var alpha = 1 - (this.props.scrollLeft / this.props.width) * alphaMultiplier;
+ alpha = Math.min(Math.max(alpha, 0), 1);
+ translateX = -this.props.scrollLeft * TEXT_SCROLL_SPEED_MULTIPLIER;
+
+ return {
+ width: this.props.width,
+ height: this.props.height - imageHeight,
+ top: imageHeight,
+ left: 0,
+ alpha: alpha,
+ translateX: translateX,
+ zIndex: TEXT_LAYER_INDEX
+ };
+ }
+
+});
+
+module.exports = Page;
diff --git a/examples/carousel/index.html b/examples/carousel/index.html
new file mode 100644
index 0000000..ce47c44
--- /dev/null
+++ b/examples/carousel/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ ReactCanvas: Timeline
+
+
+
+
+
+
+
+
+
diff --git a/lib/Carousel.js b/lib/Carousel.js
new file mode 100644
index 0000000..daf5b1a
--- /dev/null
+++ b/lib/Carousel.js
@@ -0,0 +1,186 @@
+'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 Carousel = React.createClass({
+
+ propTypes: {
+ style: React.PropTypes.object,
+ numberOfItemsGetter: React.PropTypes.func.isRequired,
+ itemWidthGetter: React.PropTypes.func.isRequired,
+ itemGetter: 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 {
+ 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 item = this.props.itemGetter(itemIndex, this.state.scrollLeft);
+ var itemWidth = this.props.itemWidthGetter();
+ var style = {
+ top: 0,
+ left: 0,
+ width: itemWidth,
+ height: this.props.style.height,
+ translateX: (itemIndex * itemWidth) - this.state.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);
+ }
+ },
+
+ handleTouchMove: function (e) {
+ if (this.scroller) {
+ e.preventDefault();
+ this.scroller.doTouchMove(e.touches, e.timeStamp, e.scale);
+ }
+ },
+
+ handleTouchEnd: function (e) {
+ if (this.scroller) {
+ this.scroller.doTouchEnd(e.timeStamp);
+ if (this.props.snapping) {
+ this.updateScrollingDeceleration();
+ }
+ }
+ },
+
+ handleScroll: function (left, top) {
+ this.setState({ scrollLeft: left });
+ if (this.props.onScroll) {
+ this.props.onScroll(left);
+ }
+ },
+
+ // Scrolling
+ // =========
+
+ createScroller: function () {
+ var options = {
+ scrollingX: true,
+ scrollingY: false,
+ 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 scrollWidth = this.props.numberOfItemsGetter() * this.props.itemWidthGetter();
+ var scrollHeight = height;
+ this.scroller.setDimensions(width, height, scrollWidth, scrollHeight);
+ },
+
+ getVisibleItemIndexes: function () {
+ var itemIndexes = [];
+ var itemWidth = this.props.itemWidthGetter();
+ var itemCount = this.props.numberOfItemsGetter();
+ var scrollLeft = this.state.scrollLeft;
+ var itemScrollLeft = 0;
+
+ for (var index=0; index < itemCount; index++) {
+ itemScrollLeft = (index * itemWidth) - scrollLeft;
+
+ // Item is completely off-screen bottom
+ if (itemScrollLeft >= this.props.style.width) {
+ continue;
+ }
+
+ // Item is completely off-screen top
+ if (itemScrollLeft <= -this.props.style.width) {
+ continue;
+ }
+
+ // Part of item is on-screen.
+ itemIndexes.push(index);
+ }
+
+ return itemIndexes;
+ },
+
+ updateScrollingDeceleration: function () {
+ var currVelocity = this.scroller.__decelerationVelocityX;
+ var currScrollLeft = this.state.scrollLeft;
+ var targetScrollLeft = 0;
+ var estimatedEndScrollLeft = currScrollLeft;
+
+ while (Math.abs(currVelocity).toFixed(6) > 0) {
+ estimatedEndScrollLeft += currVelocity;
+ currVelocity *= this.props.scrollingDeceleration;
+ }
+
+ // Find the page whose estimated end scrollTop is closest to 0.
+ var closestZeroDelta = Infinity;
+ var pageWidth = this.props.itemWidthGetter();
+ var pageCount = this.props.numberOfItemsGetter();
+ var pageScrollLeft;
+
+ for (var pageIndex=0, len=pageCount; pageIndex < len; pageIndex++) {
+ pageScrollLeft = (pageWidth * pageIndex) - estimatedEndScrollLeft;
+ if (Math.abs(pageScrollLeft) < closestZeroDelta) {
+ closestZeroDelta = Math.abs(pageScrollLeft);
+ targetScrollLeft = pageWidth * pageIndex;
+ }
+ }
+
+ this.scroller.__minDecelerationScrollLeft = targetScrollLeft;
+ this.scroller.__maxDecelerationScrollLeft = targetScrollLeft;
+ }
+
+});
+
+module.exports = Carousel;
diff --git a/lib/ReactCanvas.js b/lib/ReactCanvas.js
index ba4dd79..7d1df40 100644
--- a/lib/ReactCanvas.js
+++ b/lib/ReactCanvas.js
@@ -8,6 +8,7 @@ var ReactCanvas = {
Image: require('./Image'),
Text: require('./Text'),
ListView: require('./ListView'),
+ Carousel: require('./Carousel'),
FontFace: require('./FontFace'),
measureText: require('./measureText')
diff --git a/webpack.config.js b/webpack.config.js
index b82ceef..5d68479 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -6,6 +6,7 @@ module.exports = {
entry: {
'listview': ['./examples/listview/app.js'],
'timeline': ['./examples/timeline/app.js'],
+ 'carousel': ['./examples/carousel/app.js'],
'css-layout': ['./examples/css-layout/app.js']
},