diff --git a/README.md b/README.md index 008cb29..2620f8a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,35 @@ # Cycle React Native Driver ## Experimental +This fork of [cyclejs/cycle-react-native](https://github.com/cyclejs/cycle-react-native) was created at the CycleConf 2016 hackathon by @ohanhi, @justinwoo, @chadrien, @sectore, @ozzee and @jevakallio. It contains a collection of ideas and hacks to test feasibility of the CycleJS architecture on React Native. + +## Features + + * Custom `Touchable*` components for event delegation to support lazily created elements (e.g. navigation scenes, ListView rows) + * Custom `Animated` component to run animations declaratively + * Custom `ListView` component to manage "infinite scrolling" with `ListView.DataSource` + * Navigation support with `NavigationExperimental` + +See [jevakallio/cycle-react-native-example](https://github.com/jevakallio/cycle-react-native-example) for example of use. + +## Running on iOS + +Start by installing React Native [prerequisites](https://facebook.github.io/react-native/docs/getting-started.html) (XCode, react-native-cli, watchman). + +Then: ``` -npm install @cycle/react-native@1.0.0-experimental.12 +git clone git@github.com:jevakallio/cycle-react-native.git && cd cycle-react-native +npm install +react-native run-ios ``` +## Running on Android + +Good luck! + ## Usage + ```js let {Rx, run} = require('@cycle/core'); let React = require('react-native'); @@ -32,3 +55,4 @@ run(main, { RN: makeReactNativeDriver('MyMobileApp'), }); ``` + diff --git a/package.json b/package.json index 975a8fe..f6987dc 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,11 @@ }, "peerDependencies": { "rx": ">=3", - "react-native": "0.18.0" + "react-native": "~0.24.0" }, "devDependencies": { "rx": ">=3", - "react-native": "0.18.0", + "react-native": "~0.24.0", "babel": "5.8.x", "eslint": "1.0.x", "eslint-config-cycle": "3.0.x", diff --git a/src/Animated.js b/src/Animated.js new file mode 100644 index 0000000..871f335 --- /dev/null +++ b/src/Animated.js @@ -0,0 +1,78 @@ +import React from 'react-native'; +const { + Animated, + PropTypes +} = React; + +function createAnimationDongle(className) { + return React.createClass({ + displayName: 'Cycle' + className, + + propTypes: { + }, + + getInitialState() { + const currentValue = new Animated.Value( + this.props.initialValue || 0 + ); + + return { + currentValue + } + }, + + componentWillReceiveProps(nextProps) { + if (nextProps.value !== this.state.currentValue._value) { + this.runAnimation(nextProps); + } + }, + + runAnimation({animation, options = {}, value}) { + animation(this.state.currentValue, {...options, + toValue: value + }).start(); + }, + + render() { + const AnimatedClass = Animated[className]; + const { animate } = this.props; + + const animatedStyle = Object.keys(animate) + .filter(key => key !== 'transform') + .reduce((acc, key) => { + return {...acc, + [key]: this.state.currentValue.interpolate(animate[key]) + } + }, {}); + + if (animate.transform) { + const transforms = animate.transform.map(obj => { + const key = Object.keys(obj)[0]; + return { + [key]: this.state.currentValue.interpolate(obj[key]) + }; + }); + + animatedStyle.transform = transforms; + } + + const style = {...(this.props.style || {}), ...animatedStyle} + + const extraProps = { + source: this.props.source + }; + + return ( + + {this.props.children} + + ); + } + }); +}; + +export default { + View: createAnimationDongle('View'), + Text: createAnimationDongle('Text'), + Image: createAnimationDongle('Image') +}; diff --git a/src/ListView.js b/src/ListView.js new file mode 100644 index 0000000..f4e564c --- /dev/null +++ b/src/ListView.js @@ -0,0 +1,41 @@ +'use strict'; + +import React from 'react-native'; + +const { + PropTypes, + ListView +} = React; + +export default React.createClass({ + displayName: 'CycleListView', + propTypes: { + items: PropTypes.array.isRequired + }, + + getInitialState() { + const dataSource = new ListView.DataSource({rowHasChanged: (r1, r2) => r1 !== r2}); + return {dataSource: dataSource.cloneWithRows(this.props.items)}; + }, + + componentWillReceiveProps({items}) { + if (items !== this.props.items) { + this.setState({dataSource: this.state.dataSource.cloneWithRows(items)}); + } + }, + + getScrollResponder() { + return this._listView.getScrollResponder(); + }, + + render() { + const {items, ...listViewProps} = this.props; + return ( + this._listView = listView} + dataSource={this.state.dataSource} + {...listViewProps} + /> + ); + } +}); diff --git a/src/Touchable.js b/src/Touchable.js new file mode 100644 index 0000000..164b1ff --- /dev/null +++ b/src/Touchable.js @@ -0,0 +1,61 @@ +'use strict'; + +import React from 'react-native'; +import {findHandler} from './driver'; +const { + View, + PropTypes +} = React; + +const ACTION_TYPES = { + onPress: 'press', + onPressIn: 'pressIn', + onPressOut: 'pressOut', + onLongPress: 'longPress' +}; + +function createTouchableClass(className) { + return React.createClass({ + displayName: 'Cycle' + className, + propTypes: { + selector: PropTypes.string.isRequired, + payload: PropTypes.any + }, + setNativeProps(props) { + this._touchable.setNativeProps(props); + }, + render() { + const TouchableClass = React[className]; + const {selector, ...props} = this.props; + + // find all defined touch handlers + const handlers = Object.keys(ACTION_TYPES) + .map(name => [name, findHandler(ACTION_TYPES[name], selector)]) + .filter(([_, handler]) => !!handler) + .reduce((memo, [name, handler]) => { + // pass payload to event handler if defined + memo[name] = () => handler(this.props.payload || null); + return memo; + }, {}); + + + return ( + this._touchable = view} + {...handlers} + > + + {this.props.children} + + + ); + } + }); +} + +export default { + TouchableOpacity: createTouchableClass('TouchableOpacity'), + TouchableWithoutFeedback: createTouchableClass('TouchableWithoutFeedback'), + TouchableHighlight: createTouchableClass('TouchableHighlight'), + TouchableNativeFeedback: createTouchableClass('TouchableNativeFeedback') +}; diff --git a/src/driver.js b/src/driver.js index b40a183..f37fb21 100644 --- a/src/driver.js +++ b/src/driver.js @@ -2,51 +2,52 @@ import React from 'react-native' import Rx from 'rx' const {AppRegistry, View} = React +const BACK_ACTION = '@@back'; +const backHandler = new Rx.Subject + +let handlers = { + [BACK_ACTION]: createHandler() +}; + +function createHandler() { + const handler = new Rx.Subject(); + handler.send = function sendIntoSubject(...args) { + handler.onNext(...args) + } + return handler; +} + +export function getBackHandler() { + return handlers[BACK_ACTION]; +} + +export function registerHandler(selector, evType) { + handlers[selector] = handlers[selector] || {}; + handlers[selector][evType] = handlers[selector][evType] || createHandler(); + return handlers[selector][evType]; +}; + +export function findHandler(evType, selector) { + if (evType === BACK_ACTION && !selector) { + return handlers[BACK_ACTION]; + } + + if (handlers[selector].hasOwnProperty(evType)) { + return handlers[selector][evType].send + } +} + function isChildReactElement(child) { return !!child && typeof child === `object` && child._isReactElement } function makeReactNativeDriver(appKey) { return function reactNativeDriver(vtree$) { - let handlers = {} - - function augmentVTreeWithHandlers(vtree, index = null) { - if (typeof vtree === `string` || typeof vtree === `number`) { - return vtree - } - let newProps = {} - if (!vtree.props.selector && !!index) { - newProps.selector = index - } - let wasTouched = false - if (handlers[vtree.props.selector]) { - for (let evType in handlers[vtree.props.selector]) { - if (handlers[vtree.props.selector].hasOwnProperty(evType)) { - let handlerFnName = `on${evType.charAt(0).toUpperCase()}${evType.slice(1)}` - newProps[handlerFnName] = handlers[vtree.props.selector][evType].send - wasTouched = true - } - } - } - let newChildren = vtree.props.children - if (Array.isArray(vtree.props.children)) { - newChildren = vtree.props.children.map(augmentVTreeWithHandlers) - wasTouched = true - } else if (isChildReactElement(vtree.props.children)) { - newChildren = augmentVTreeWithHandlers(vtree.props.children) - wasTouched = true - } - return wasTouched ? - React.cloneElement(vtree, newProps, newChildren) : - vtree - } - function componentFactory() { return React.createClass({ componentWillMount() { - vtree$.subscribe(rawVTree => { - let replacedVTree = augmentVTreeWithHandlers(rawVTree) - this.setState({vtree: replacedVTree}) + vtree$.subscribe(newVTree => { + this.setState({vtree: newVTree}) }) }, getInitialState() { @@ -59,21 +60,18 @@ function makeReactNativeDriver(appKey) { } let response = { - select: function select(selector) { + select(selector) { return { observable: Rx.Observable.empty(), events: function events(evType) { - handlers[selector] = handlers[selector] || {} - handlers[selector][evType] = handlers[selector][evType] || new Rx.Subject() - handlers[selector][evType].send = function sendIntoSubject(...args) { - const props = this - const event = {currentTarget: {props}, args} - handlers[selector][evType].onNext(event) - } - return handlers[selector][evType] + return registerHandler(selector, evType); }, } }, + + navigateBack() { + return findHandler(BACK_ACTION); + } } AppRegistry.registerComponent(appKey, componentFactory)