Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement native Animated value listeners on Android #8844

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions Examples/UIExplorer/js/NativeAnimationsExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,15 @@
*/
'use strict';

var React = require('react');
var ReactNative = require('react-native');
var {
const React = require('react');
const ReactNative = require('react-native');
const {
View,
Text,
Animated,
StyleSheet,
TouchableWithoutFeedback,
} = ReactNative;
var UIExplorerButton = require('./UIExplorerButton');

class Tester extends React.Component {
state = {
Expand All @@ -47,12 +46,8 @@ class Tester extends React.Component {
...this.props.config,
toValue: this.current,
};
try {
Animated[this.props.type](this.state.native, { ...config, useNativeDriver: true }).start();
} catch (e) {
// uncomment this if you want to get the redbox errors!
throw e;
}

Animated[this.props.type](this.state.native, { ...config, useNativeDriver: true }).start();
Animated[this.props.type](this.state.js, { ...config, useNativeDriver: false }).start();
};

Expand All @@ -78,6 +73,52 @@ class Tester extends React.Component {
}
}

class ValueListenerExample extends React.Component {
state = {
anim: new Animated.Value(0),
progress: 0,
};
_current = 0;

componentDidMount() {
this.state.anim.addListener((e) => this.setState({ progress: e.value }));
}

componentWillUnmount() {
this.state.anim.removeAllListeners();
}

_onPress = () => {
this._current = this._current ? 0 : 1;
const config = {
duration: 1000,
toValue: this._current,
};

Animated.timing(this.state.anim, { ...config, useNativeDriver: true }).start();
};

render() {
return (
<TouchableWithoutFeedback onPress={this._onPress}>
<View>
<View style={styles.row}>
<Animated.View
style={[
styles.block,
{
opacity: this.state.anim,
}
]}
/>
</View>
<Text>Value: {this.state.progress}</Text>
</View>
</TouchableWithoutFeedback>
);
}
}

const styles = StyleSheet.create({
row: {
padding: 10,
Expand Down Expand Up @@ -304,4 +345,13 @@ exports.examples = [
);
},
},
{
title: 'Animated value listener',
platform: 'android',
render: function() {
return (
<ValueListenerExample />
);
},
},
];
50 changes: 47 additions & 3 deletions Libraries/Animated/src/AnimatedImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
'use strict';

var DeviceEventEmitter = require('RCTDeviceEventEmitter');
var InteractionManager = require('InteractionManager');
var Interpolation = require('Interpolation');
var React = require('React');
Expand Down Expand Up @@ -634,6 +635,7 @@ class AnimatedValue extends AnimatedWithChildren {
_animation: ?Animation;
_tracking: ?Animated;
_listeners: {[key: string]: ValueListenerCallback};
__nativeAnimatedValueListener: ?any;

constructor(value: number) {
super();
Expand All @@ -652,6 +654,14 @@ class AnimatedValue extends AnimatedWithChildren {
return this._value + this._offset;
}

__makeNative() {
super.__makeNative();

if (Object.keys(this._listeners).length) {
this._startListeningToNativeValueUpdates();
}
}

/**
* Directly set the value. This will stop any animations running on the value
* and update all the bound properties.
Expand Down Expand Up @@ -693,15 +703,49 @@ class AnimatedValue extends AnimatedWithChildren {
addListener(callback: ValueListenerCallback): string {
var id = String(_uniqueId++);
this._listeners[id] = callback;
if (this.__isNative) {
this._startListeningToNativeValueUpdates();
}
return id;
}

removeListener(id: string): void {
delete this._listeners[id];
if (this.__isNative && Object.keys(this._listeners).length === 0) {
this._stopListeningForNativeValueUpdates();
}
}

removeAllListeners(): void {
this._listeners = {};
if (this.__isNative) {
this._stopListeningForNativeValueUpdates();
}
}

_startListeningToNativeValueUpdates() {
if (this.__nativeAnimatedValueListener ||
!NativeAnimatedHelper.supportsNativeListener()) {
return;
}

NativeAnimatedAPI.startListeningToAnimatedNodeValue(this.__getNativeTag());
this.__nativeAnimatedValueListener = DeviceEventEmitter.addListener('onAnimatedValueUpdate', (data) => {
if (data.tag !== this.__getNativeTag()) {
return;
}
this._updateValue(data.value, false /* flush */);
});
}

_stopListeningForNativeValueUpdates() {
if (!this.__nativeAnimatedValueListener ||
!NativeAnimatedHelper.supportsNativeListener()) {
return;
}

this.__nativeAnimatedValueListener.remove();
NativeAnimatedAPI.stopListeningToAnimatedNodeValue(this.__getNativeTag());
}

/**
Expand Down Expand Up @@ -1204,7 +1248,7 @@ class AnimatedStyle extends AnimatedWithChildren {
if (value instanceof Animated) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Aren't we going to have a problem if we happen to re-render during an animation? Namely, out-of-date JS values will override up-to-date native values when the view us updated. Contrived example:

Tick 1 (first render):

  • js=0
  • native=0

Tick 2 (animation driven natively):

  • js=0
  • native=0.1

Tick 3: (still driving, listener called on the JS side)

  • js=0.1
  • native=0.2

Tick 4: (still driving, we call a re-render)

  • js=0.2
  • native=0.3

Tick 5 (re-rendered):

  • js=0.2
  • native=0.2 (but we'd expect 0.4)

It seems like the old __getValue, this updated __getValue behavior, and __getAnimatedValue all have slightly different intents:

(1) What prop values should we be setting on the native view when we render?
(2) What are the current values of animated props?
(3) What values are we driving and may need update via setNativeProps? (inverse of 1)

It seems like there is a great tension between an animate prop being treated like any other prop, and being managed independently.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would not see this as an issue. It's similar with all the events we receive from native. E.g. when you register onLayout event it doesn't necessarily mean the values you're getting are the actual dimensions of the element, those are just values that has been calculated during some layout pass in the past and might have already been changed from JS before we managed to process onLayout event

Copy link
Contributor

Choose a reason for hiding this comment

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

It's fine that the values passed to JS aren't representative of the true state of the element. What's not okay is if we take this stale data and use it to update the element, which we will do here whenever we render the element again. Concretely, you might see the animated view unexpectedly snap backward in time when you call setState, which would be jarring.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll take another look at this to make sure it doesn't cause issues when re-rendering during an animation when there is no value listener. It did work properly when using one since we update the js value of the node.

Copy link
Contributor

@ryangomba ryangomba Aug 1, 2016

Choose a reason for hiding this comment

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

Another contrived example, but imagine there is no value listener and you do the following:

  • render a view with opacity = new Animated.Value(0)
  • call anim.setValue(1)
  • drive a native animation from 1 to 0.5
  • call setState({foo: 'bar'}); the view will be re-rendered with opacity=1

The bug will pop up when both the following conditions are true:

  1. the value has changed on the JS side since the last render
  2. the value on the JS side and the native side are out of sync

if (!value.__isNative) {
// We cannot use value of natively driven nodes this way as the value we have access from JS
// may not be up to date
// may not be up to date.
style[key] = value.__getValue();
}
} else {
Expand Down Expand Up @@ -1296,9 +1340,9 @@ class AnimatedProps extends Animated {
for (var key in this._props) {
var value = this._props[key];
if (value instanceof Animated) {
if (!value.__isNative) {
if (!value.__isNative || value instanceof AnimatedStyle) {
// We cannot use value of natively driven nodes this way as the value we have access from JS
// may not be up to date
// may not be up to date.
props[key] = value.__getValue();
}
} else {
Expand Down
14 changes: 14 additions & 0 deletions Libraries/Animated/src/NativeAnimatedHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ var API = {
assertNativeAnimatedModule();
NativeAnimatedModule.createAnimatedNode(tag, config);
},
startListeningToAnimatedNodeValue: function(tag: number) {
assertNativeAnimatedModule();
NativeAnimatedModule.startListeningToAnimatedNodeValue(tag);
},
stopListeningToAnimatedNodeValue: function(tag: number) {
assertNativeAnimatedModule();
NativeAnimatedModule.stopListeningToAnimatedNodeValue(tag);
},
connectAnimatedNodes: function(parentTag: number, childTag: number): void {
assertNativeAnimatedModule();
NativeAnimatedModule.connectAnimatedNodes(parentTag, childTag);
Expand Down Expand Up @@ -144,6 +152,11 @@ function assertNativeAnimatedModule(): void {
invariant(NativeAnimatedModule, 'Native animated module is not available');
}

// TODO: remove this when iOS supports native listeners.
function supportsNativeListener(): bool {
return !!NativeAnimatedModule.startListeningToAnimatedNodeValue;
}

module.exports = {
API,
validateProps,
Expand All @@ -153,4 +166,5 @@ module.exports = {
generateNewNodeTag,
generateNewAnimationId,
assertNativeAnimatedModule,
supportsNativeListener,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/

package com.facebook.react.animated;

/**
* Interface used to listen to {@link ValueAnimatedNode} updates.
*/
public interface AnimatedNodeValueListener {
void onValueUpdate(double value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ android_library(
]),
deps = [
react_native_target('java/com/facebook/react/bridge:bridge'),
react_native_target('java/com/facebook/react/modules/core:core'),
react_native_target('java/com/facebook/react/uimanager:uimanager'),

react_native_target('java/com/facebook/react/uimanager/annotations:annotations'),
react_native_dep('third-party/java/infer-annotations:infer-annotations'),
react_native_dep('third-party/java/jsr-305:jsr-305'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
import javax.annotation.Nullable;

import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.OnBatchCompleteListener;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.uimanager.GuardedChoreographerFrameCallback;
import com.facebook.react.uimanager.ReactChoreographer;
import com.facebook.react.uimanager.UIImplementation;
Expand Down Expand Up @@ -190,6 +193,36 @@ public void execute(NativeAnimatedNodesManager animatedNodesManager) {
});
}

@ReactMethod
public void startListeningToAnimatedNodeValue(final int tag) {
final AnimatedNodeValueListener listener = new AnimatedNodeValueListener() {
public void onValueUpdate(double value) {
WritableMap onAnimatedValueData = Arguments.createMap();
onAnimatedValueData.putInt("tag", tag);
onAnimatedValueData.putDouble("value", value);
getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("onAnimatedValueUpdate", onAnimatedValueData);
}
};

mOperations.add(new UIThreadOperation() {
@Override
public void execute(NativeAnimatedNodesManager animatedNodesManager) {
animatedNodesManager.startListeningToAnimatedNodeValue(tag, listener);
}
});
}

@ReactMethod
public void stopListeningToAnimatedNodeValue(final int tag) {
mOperations.add(new UIThreadOperation() {
@Override
public void execute(NativeAnimatedNodesManager animatedNodesManager) {
animatedNodesManager.stopListeningToAnimatedNodeValue(tag);
}
});
}

@ReactMethod
public void dropAnimatedNode(final int tag) {
mOperations.add(new UIThreadOperation() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ public void dropAnimatedNode(int tag) {
mAnimatedNodes.remove(tag);
}

public void startListeningToAnimatedNodeValue(int tag, AnimatedNodeValueListener listener) {
AnimatedNode node = mAnimatedNodes.get(tag);
if (node == null || !(node instanceof ValueAnimatedNode)) {
throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag +
" does not exists or is not a 'value' node");
}
((ValueAnimatedNode) node).setValueListener(listener);
}

public void stopListeningToAnimatedNodeValue(int tag) {
AnimatedNode node = mAnimatedNodes.get(tag);
if (node == null || !(node instanceof ValueAnimatedNode)) {
throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag +
" does not exists or is not a 'value' node");
}
((ValueAnimatedNode) node).setValueListener(null);
}

public void setAnimatedNodeValue(int tag, double value) {
AnimatedNode node = mAnimatedNodes.get(tag);
if (node == null || !(node instanceof ValueAnimatedNode)) {
Expand Down Expand Up @@ -324,6 +342,10 @@ public void runUpdates(long frameTimeNanos) {
// Send property updates to native view manager
((PropsAnimatedNode) nextNode).updateView(mUIImplementation);
}
if (nextNode instanceof ValueAnimatedNode) {
// Potentially send events to JS when the node's value is updated
((ValueAnimatedNode) nextNode).onValueUpdate();
}
if (nextNode.mChildren != null) {
for (int i = 0; i < nextNode.mChildren.size(); i++) {
AnimatedNode child = nextNode.mChildren.get(i);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@

import com.facebook.react.bridge.ReadableMap;

import javax.annotation.Nullable;

/**
* Basic type of animated node that maps directly from {@code Animated.Value(x)} of Animated.js
* library.
*/
/*package*/ class ValueAnimatedNode extends AnimatedNode {

/*package*/ double mValue = Double.NaN;
private @Nullable AnimatedNodeValueListener mValueListener;

public ValueAnimatedNode() {
// empty constructor that can be used by subclasses
Expand All @@ -26,4 +28,15 @@ public ValueAnimatedNode() {
public ValueAnimatedNode(ReadableMap config) {
mValue = config.getDouble("value");
}

public void onValueUpdate() {
if (mValueListener == null) {
return;
}
mValueListener.onValueUpdate(mValue);
}

public void setValueListener(@Nullable AnimatedNodeValueListener listener) {
mValueListener = listener;
}
}
Loading