Skip to content

Commit

Permalink
Add support for animated events
Browse files Browse the repository at this point in the history
Summary:
This adds support for `Animated.event` driven natively. This is WIP and would like feedback on how this is implemented.

At the moment, it works by providing a mapping between a view tag, an event name, an event path and an animated value when a view has a prop with a `AnimatedEvent` object. Then we can hook into `EventDispatcher`, check for events that target our view + event name and update the animated value using the event path.

For now it works with the onScroll event but it should be generic enough to work with anything.
Closes #9253

Differential Revision: D3759844

Pulled By: foghina

fbshipit-source-id: 86989c705847955bd65e6cf5a7d572ec7ccd3eb4
  • Loading branch information
janicduplessis authored and Facebook Github Bot 9 committed Sep 19, 2016
1 parent 19d0429 commit 6565929
Show file tree
Hide file tree
Showing 13 changed files with 527 additions and 34 deletions.
49 changes: 49 additions & 0 deletions Examples/UIExplorer/js/NativeAnimationsExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,46 @@ class InternalSettings extends React.Component {
}
}

class EventExample extends React.Component {
state = {
scrollX: new Animated.Value(0),
};

render() {
const opacity = this.state.scrollX.interpolate({
inputRange: [0, 200],
outputRange: [1, 0],
});
return (
<View>
<Animated.View
style={[
styles.block,
{
opacity,
}
]}
/>
<Animated.ScrollView
horizontal
style={{ height: 100, marginTop: 16 }}
onScroll={
Animated.event([{
nativeEvent: { contentOffset: { x: this.state.scrollX } }
}], {
useNativeDriver: true,
})
}
>
<View style={{ width: 600, backgroundColor: '#eee', justifyContent: 'center' }}>
<Text>Scroll me!</Text>
</View>
</Animated.ScrollView>
</View>
);
}
}

const styles = StyleSheet.create({
row: {
padding: 10,
Expand Down Expand Up @@ -429,4 +469,13 @@ exports.examples = [
);
},
},
{
title: 'Animated events',
platform: 'android',
render: function() {
return (
<EventExample />
);
},
},
];
2 changes: 2 additions & 0 deletions Libraries/Animated/src/Animated.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ var AnimatedImplementation = require('AnimatedImplementation');
var Image = require('Image');
var Text = require('Text');
var View = require('View');
var ScrollView = require('ScrollView');

module.exports = {
...AnimatedImplementation,
View: AnimatedImplementation.createAnimatedComponent(View),
Text: AnimatedImplementation.createAnimatedComponent(Text),
Image: AnimatedImplementation.createAnimatedComponent(Image),
ScrollView: AnimatedImplementation.createAnimatedComponent(ScrollView),
};
176 changes: 154 additions & 22 deletions Libraries/Animated/src/AnimatedImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,8 @@ class AnimatedProps extends Animated {
// JS may not be up to date.
props[key] = value.__getValue();
}
} else if (value instanceof AnimatedEvent) {
props[key] = value.__getHandler();
} else {
props[key] = value;
}
Expand Down Expand Up @@ -1596,21 +1598,58 @@ function createAnimatedComponent(Component: any): any {

componentWillUnmount() {
this._propsAnimated && this._propsAnimated.__detach();
this._detachNativeEvents(this.props);
}

setNativeProps(props) {
this._component.setNativeProps(props);
}

componentWillMount() {
this.attachProps(this.props);
this._attachProps(this.props);
}

componentDidMount() {
this._propsAnimated.setNativeView(this._component);

this._attachNativeEvents(this.props);
}

_attachNativeEvents(newProps) {
if (newProps !== this.props) {
this._detachNativeEvents(this.props);
}

// Make sure to get the scrollable node for components that implement
// `ScrollResponder.Mixin`.
const ref = this._component.getScrollableNode ?
this._component.getScrollableNode() :
this._component;

for (const key in newProps) {
const prop = newProps[key];
if (prop instanceof AnimatedEvent && prop.__isNative) {
prop.__attach(ref, key);
}
}
}

_detachNativeEvents(props) {
// Make sure to get the scrollable node for components that implement
// `ScrollResponder.Mixin`.
const ref = this._component.getScrollableNode ?
this._component.getScrollableNode() :
this._component;

for (const key in props) {
const prop = props[key];
if (prop instanceof AnimatedEvent && prop.__isNative) {
prop.__detach(ref, key);
}
}
}

attachProps(nextProps) {
_attachProps(nextProps) {
var oldPropsAnimated = this._propsAnimated;

// The system is best designed when setNativeProps is implemented. It is
Expand Down Expand Up @@ -1640,7 +1679,6 @@ function createAnimatedComponent(Component: any): any {
callback,
);


if (this._component) {
this._propsAnimated.setNativeView(this._component);
}
Expand All @@ -1657,7 +1695,8 @@ function createAnimatedComponent(Component: any): any {
}

componentWillReceiveProps(nextProps) {
this.attachProps(nextProps);
this._attachProps(nextProps);
this._attachNativeEvents(nextProps);
}

render() {
Expand Down Expand Up @@ -1694,7 +1733,7 @@ function createAnimatedComponent(Component: any): any {
);
}
}
}
},
};

return AnimatedComponent;
Expand Down Expand Up @@ -1998,21 +2037,108 @@ var stagger = function(
};

type Mapping = {[key: string]: Mapping} | AnimatedValue;
type EventConfig = {
listener?: ?Function;
useNativeDriver?: bool;
};

type EventConfig = {listener?: ?Function};
var event = function(
argMapping: Array<?Mapping>,
config?: ?EventConfig,
): () => void {
return function(...args): void {
var traverse = function(recMapping, recEvt, key) {
class AnimatedEvent {
_argMapping: Array<?Mapping>;
_listener: ?Function;
__isNative: bool;

constructor(
argMapping: Array<?Mapping>,
config?: EventConfig = {}
) {
this._argMapping = argMapping;
this._listener = config.listener;
this.__isNative = config.useNativeDriver || false;

if (this.__isNative) {
invariant(!this._listener, 'Listener is not supported for native driven events.');
}

if (__DEV__) {
this._validateMapping();
}
}

__attach(viewRef, eventName) {
invariant(this.__isNative, 'Only native driven events need to be attached.');

// Find animated values in `argMapping` and create an array representing their
// key path inside the `nativeEvent` object. Ex.: ['contentOffset', 'x'].
const eventMappings = [];

const traverse = (value, path) => {
if (value instanceof AnimatedValue) {
value.__makeNative();

eventMappings.push({
nativeEventPath: path,
animatedValueTag: value.__getNativeTag(),
});
} else if (typeof value === 'object') {
for (const key in value) {
traverse(value[key], path.concat(key));
}
}
};

invariant(
this._argMapping[0] && this._argMapping[0].nativeEvent,
'Native driven events only support animated values contained inside `nativeEvent`.'
);

// Assume that the event containing `nativeEvent` is always the first argument.
traverse(this._argMapping[0].nativeEvent, []);

const viewTag = findNodeHandle(viewRef);

eventMappings.forEach((mapping) => {
NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping);
});
}

__detach(viewTag, eventName) {
invariant(this.__isNative, 'Only native driven events need to be detached.');

NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName);
}

__getHandler() {
return (...args) => {
const traverse = (recMapping, recEvt, key) => {
if (typeof recEvt === 'number' && recMapping instanceof AnimatedValue) {
recMapping.setValue(recEvt);
} else if (typeof recMapping === 'object') {
for (const mappingKey in recMapping) {
traverse(recMapping[mappingKey], recEvt[mappingKey], mappingKey);
}
}
};

if (!this.__isNative) {
this._argMapping.forEach((mapping, idx) => {
traverse(mapping, args[idx], 'arg' + idx);
});
}

if (this._listener) {
this._listener.apply(null, args);
}
};
}

_validateMapping() {
const traverse = (recMapping, recEvt, key) => {
if (typeof recEvt === 'number') {
invariant(
recMapping instanceof AnimatedValue,
'Bad mapping of type ' + typeof recMapping + ' for key ' + key +
', event value must map to AnimatedValue'
);
recMapping.setValue(recEvt);
return;
}
invariant(
Expand All @@ -2023,17 +2149,23 @@ var event = function(
typeof recEvt === 'object',
'Bad event of type ' + typeof recEvt + ' for key ' + key
);
for (var key in recMapping) {
traverse(recMapping[key], recEvt[key], key);
for (const mappingKey in recMapping) {
traverse(recMapping[mappingKey], recEvt[mappingKey], mappingKey);
}
};
argMapping.forEach((mapping, idx) => {
traverse(mapping, args[idx], 'arg' + idx);
});
if (config && config.listener) {
config.listener.apply(null, args);
}
};
}
}

var event = function(
argMapping: Array<?Mapping>,
config?: EventConfig,
): any {
const animatedEvent = new AnimatedEvent(argMapping, config);
if (animatedEvent.__isNative) {
return animatedEvent;
} else {
return animatedEvent.__getHandler();
}
};

/**
Expand Down
12 changes: 12 additions & 0 deletions Libraries/Animated/src/NativeAnimatedHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ let __nativeAnimationIdCount = 1; /* used for started animations */

type EndResult = {finished: bool};
type EndCallback = (result: EndResult) => void;
type EventMapping = {
nativeEventPath: Array<string>;
animatedValueTag: number;
};

let nativeEventEmitter;

Expand Down Expand Up @@ -73,6 +77,14 @@ const API = {
assertNativeAnimatedModule();
NativeAnimatedModule.dropAnimatedNode(tag);
},
addAnimatedEventToView: function(viewTag: number, eventName: string, eventMapping: EventMapping) {
assertNativeAnimatedModule();
NativeAnimatedModule.addAnimatedEventToView(viewTag, eventName, eventMapping);
},
removeAnimatedEventFromView(viewTag: number, eventName: string) {
assertNativeAnimatedModule();
NativeAnimatedModule.removeAnimatedEventFromView(viewTag, eventName);
}
};

/**
Expand Down
4 changes: 3 additions & 1 deletion Libraries/Animated/src/__tests__/Animated-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jest
.setMock('Text', {})
.setMock('View', {})
.setMock('Image', {})
.setMock('ScrollView', {})
.setMock('React', {Component: class {}});

var Animated = require('Animated');
Expand Down Expand Up @@ -86,6 +87,7 @@ describe('Animated', () => {
c.componentWillMount();

expect(anim.__detach).not.toBeCalled();
c._component = {};
c.componentWillReceiveProps({
style: {
opacity: anim,
Expand Down Expand Up @@ -116,7 +118,7 @@ describe('Animated', () => {
c.componentWillMount();

Animated.timing(anim, {toValue: 10, duration: 1000}).start(callback);

c._component = {};
c.componentWillUnmount();

expect(callback).toBeCalledWith({finished: false});
Expand Down
Loading

2 comments on commit 6565929

@nikitaMe1nikov
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is necessary to implement this for Gesture Responder System?

@janicduplessis
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite a lot, the event bubbling login and responder system is all written in JS so it can't be offloaded to native without rewriting all of it. @kmagiera is working on an alternative gesture system that will be able to work with native animations https://github.com/kmagiera/react-native-gesture-handler

Please sign in to comment.