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'] },