From b140818ad66240e30272a3c5bb368716947ee8c2 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 11 Apr 2016 14:46:06 -0400 Subject: [PATCH] Implement native Animated value listeners on Android --- .../UIExplorer/js/NativeAnimationsExample.js | 70 ++++++++++++++++--- .../Animated/src/AnimatedImplementation.js | 50 ++++++++++++- .../Animated/src/NativeAnimatedHelper.js | 14 ++++ .../animated/AnimatedNodeValueListener.java | 17 +++++ .../java/com/facebook/react/animated/BUCK | 2 +- .../react/animated/NativeAnimatedModule.java | 33 +++++++++ .../animated/NativeAnimatedNodesManager.java | 22 ++++++ .../react/animated/ValueAnimatedNode.java | 15 +++- .../NativeAnimatedNodeTraversalTest.java | 58 +++++++++++++++ 9 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.java diff --git a/Examples/UIExplorer/js/NativeAnimationsExample.js b/Examples/UIExplorer/js/NativeAnimationsExample.js index ef92dae605f421..bd7add201d258d 100644 --- a/Examples/UIExplorer/js/NativeAnimationsExample.js +++ b/Examples/UIExplorer/js/NativeAnimationsExample.js @@ -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 = { @@ -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(); }; @@ -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 ( + + + + + + Value: {this.state.progress} + + + ); + } +} + const styles = StyleSheet.create({ row: { padding: 10, @@ -304,4 +345,13 @@ exports.examples = [ ); }, }, + { + title: 'Animated value listener', + platform: 'android', + render: function() { + return ( + + ); + }, + }, ]; diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index 7f21c4ffb5b41f..3d78bfed4484c2 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -11,6 +11,7 @@ */ 'use strict'; +var DeviceEventEmitter = require('RCTDeviceEventEmitter'); var InteractionManager = require('InteractionManager'); var Interpolation = require('Interpolation'); var React = require('React'); @@ -634,6 +635,7 @@ class AnimatedValue extends AnimatedWithChildren { _animation: ?Animation; _tracking: ?Animated; _listeners: {[key: string]: ValueListenerCallback}; + __nativeAnimatedValueListener: ?any; constructor(value: number) { super(); @@ -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. @@ -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()); } /** @@ -1204,7 +1248,7 @@ class AnimatedStyle extends AnimatedWithChildren { if (value instanceof Animated) { 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 { @@ -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 { diff --git a/Libraries/Animated/src/NativeAnimatedHelper.js b/Libraries/Animated/src/NativeAnimatedHelper.js index 3380df240976a6..9138a4308e351b 100644 --- a/Libraries/Animated/src/NativeAnimatedHelper.js +++ b/Libraries/Animated/src/NativeAnimatedHelper.js @@ -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); @@ -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, @@ -153,4 +166,5 @@ module.exports = { generateNewNodeTag, generateNewAnimationId, assertNativeAnimatedModule, + supportsNativeListener, }; diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.java b/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.java new file mode 100644 index 00000000000000..fa0248cb3e1947 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.java @@ -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); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/BUCK b/ReactAndroid/src/main/java/com/facebook/react/animated/BUCK index 73444282c089f7..d6c34221f18ed1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/BUCK @@ -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'), diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java index ab4e19fc534565..303fb83253bc5f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java @@ -12,6 +12,7 @@ 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; @@ -19,6 +20,8 @@ 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; @@ -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() { diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java index 1aa5e8631274fa..8f5ad6ec81de93 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java @@ -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)) { @@ -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); diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java index 7f573e3063c284..4d8a42d5fcb7b6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/ValueAnimatedNode.java @@ -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 @@ -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; + } } diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java index 5f1a63f2730046..e321a0b6e8073a 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -140,6 +140,64 @@ public void testFramesAnimation() { verifyNoMoreInteractions(mUIImplementationMock); } + @Test + public void testNodeValueListenerIfNotListening() { + int nodeId = 1; + + createSimpleAnimatedViewWithOpacity(1000, 0d); + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.2d, 0.4d, 0.6d, 0.8d, 1d); + + Callback animationCallback = mock(Callback.class); + AnimatedNodeValueListener valueListener = mock(AnimatedNodeValueListener.class); + + mNativeAnimatedNodesManager.startListeningToAnimatedNodeValue(nodeId, valueListener); + mNativeAnimatedNodesManager.startAnimatingNode( + 1, + nodeId, + JavaOnlyMap.of("type", "frames", "frames", frames, "toValue", 1d), + animationCallback); + + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(valueListener).onValueUpdate(eq(0d)); + + mNativeAnimatedNodesManager.stopListeningToAnimatedNodeValue(nodeId); + + reset(valueListener); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(valueListener); + } + + @Test + public void testNodeValueListenerIfListening() { + int nodeId = 1; + + createSimpleAnimatedViewWithOpacity(1000, 0d); + JavaOnlyArray frames = JavaOnlyArray.of(0d, 0.2d, 0.4d, 0.6d, 0.8d, 1d); + + Callback animationCallback = mock(Callback.class); + AnimatedNodeValueListener valueListener = mock(AnimatedNodeValueListener.class); + + mNativeAnimatedNodesManager.startListeningToAnimatedNodeValue(nodeId, valueListener); + mNativeAnimatedNodesManager.startAnimatingNode( + 1, + nodeId, + JavaOnlyMap.of("type", "frames", "frames", frames, "toValue", 1d), + animationCallback); + + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(valueListener).onValueUpdate(eq(0d)); + + for (int i = 0; i < frames.size(); i++) { + reset(valueListener); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(valueListener).onValueUpdate(eq(frames.getDouble(i))); + } + + reset(valueListener); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verifyNoMoreInteractions(valueListener); + } + @Test public void testAnimationCallbackFinish() { createSimpleAnimatedViewWithOpacity(1000, 0d);