diff --git a/README.md b/README.md index f7cc9c1..91c4664 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ React Canvas provides a set of standard React components that abstract the under **Image** is exactly what you think it is. However, it adds the ability to hide an image until it is fully loaded and optionally fade it in on load. +### <Carousel> + +**Carousel** is a touch scrolling container that renders a set of elements horizontally. + ### <ListView> **ListView** is a touch scrolling container that renders a list of elements in a column. Think of it like UITableView for the web. It leverages many of the same optimizations that make table views on iOS and list views on Android fast. diff --git a/examples/carousel/app.js b/examples/carousel/app.js new file mode 100644 index 0000000..ad85266 --- /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(); + }, + + // Carousel + // ======== + + getCarouselStyle: 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..d1515c0 --- /dev/null +++ b/examples/carousel/components/Page.js @@ -0,0 +1,66 @@ +/** @jsx React.DOM */ + +'use strict'; + +var React = require('react'); +var ReactCanvas = require('react-canvas'); + +var Group = ReactCanvas.Group; +var Image = ReactCanvas.Image; + +var CONTENT_INSET = 14; +var IMAGE_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; + }, + + render: function () { + var groupStyle = this.getGroupStyle(); + var imageStyle = this.getImageStyle(); + + return ( + + + + ); + }, + + getGroupStyle: function () { + return { + top: 0, + left: 0, + width: this.props.width, + height: this.props.height, + }; + }, + + getImageWidth: function () { + return Math.round(this.props.width); + }, + + getImageStyle: function () { + return { + top: 0, + left: 0, + width: this.getImageWidth(), + height: this.props.height, + backgroundColor: '#eee', + zIndex: IMAGE_LAYER_INDEX + }; + }, + +}); + +module.exports = Page; diff --git a/examples/carousel/index.html b/examples/carousel/index.html new file mode 100644 index 0000000..2e1610d --- /dev/null +++ b/examples/carousel/index.html @@ -0,0 +1,17 @@ + + + + + + ReactCanvas: Carousel + + + + + +
+ + + diff --git a/lib/Carousel.js b/lib/Carousel.js new file mode 100644 index 0000000..efc16dc --- /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, + onSlide: 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.onSlide) { + this.props.onSlide(left); + } + }, + + // Swiping + // ========= + + 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 itemsScrollLeft = 0; + + for (var index=0; index < itemCount; index++) { + itemsScrollLeft = (index * itemWidth) - scrollLeft; + + // Item is completely off-screen right + if (itemsScrollLeft >= this.props.style.width) { + continue; + } + + // Item is completely off-screen left + if (itemsScrollLeft <= -this.props.style.width) { + continue; + } + + // Part of item is on-screen. + itemIndexes.push(index); + } + + return itemIndexes; + }, + + updateScrollingDeceleration: function () { + var currVelocity = this.scroller.__decelerationVelocityY; + 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 scrollLeft 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 00231d6..d438b07 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ module.exports = { watch: true, entry: { + 'carousel': ['./examples/carousel/app.js'], 'listview': ['./examples/listview/app.js'], 'timeline': ['./examples/timeline/app.js'], 'css-layout': ['./examples/css-layout/app.js']