From 65659293589848bf48ecefe1f89afb4b562c7022 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 19 Sep 2016 04:07:36 -0700 Subject: [PATCH] Add support for animated events 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 https://github.com/facebook/react-native/pull/9253 Differential Revision: D3759844 Pulled By: foghina fbshipit-source-id: 86989c705847955bd65e6cf5a7d572ec7ccd3eb4 --- .../UIExplorer/js/NativeAnimationsExample.js | 49 +++++ Libraries/Animated/src/Animated.js | 2 + .../Animated/src/AnimatedImplementation.js | 176 +++++++++++++++--- .../Animated/src/NativeAnimatedHelper.js | 12 ++ .../Animated/src/__tests__/Animated-test.js | 4 +- .../react/animated/EventAnimationDriver.java | 52 ++++++ .../react/animated/NativeAnimatedModule.java | 27 ++- .../animated/NativeAnimatedNodesManager.java | 71 ++++++- .../react/animated/PropsAnimatedNode.java | 2 - .../uimanager/events/EventDispatcher.java | 28 +++ .../events/EventDispatcherListener.java | 16 ++ .../java/com/facebook/react/animated/BUCK | 1 + .../NativeAnimatedNodeTraversalTest.java | 121 +++++++++++- 13 files changed, 527 insertions(+), 34 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/animated/EventAnimationDriver.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcherListener.java diff --git a/Examples/UIExplorer/js/NativeAnimationsExample.js b/Examples/UIExplorer/js/NativeAnimationsExample.js index e87a8161fd2ead..ecafb4751be368 100644 --- a/Examples/UIExplorer/js/NativeAnimationsExample.js +++ b/Examples/UIExplorer/js/NativeAnimationsExample.js @@ -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 ( + + + + + Scroll me! + + + + ); + } +} + const styles = StyleSheet.create({ row: { padding: 10, @@ -429,4 +469,13 @@ exports.examples = [ ); }, }, + { + title: 'Animated events', + platform: 'android', + render: function() { + return ( + + ); + }, + }, ]; diff --git a/Libraries/Animated/src/Animated.js b/Libraries/Animated/src/Animated.js index bce17d3272ae6e..ebb79b51b695b1 100644 --- a/Libraries/Animated/src/Animated.js +++ b/Libraries/Animated/src/Animated.js @@ -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), }; diff --git a/Libraries/Animated/src/AnimatedImplementation.js b/Libraries/Animated/src/AnimatedImplementation.js index c9e61113168eb5..ad2f0cfcc1a7a7 100644 --- a/Libraries/Animated/src/AnimatedImplementation.js +++ b/Libraries/Animated/src/AnimatedImplementation.js @@ -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; } @@ -1596,6 +1598,7 @@ function createAnimatedComponent(Component: any): any { componentWillUnmount() { this._propsAnimated && this._propsAnimated.__detach(); + this._detachNativeEvents(this.props); } setNativeProps(props) { @@ -1603,14 +1606,50 @@ function createAnimatedComponent(Component: any): any { } 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 @@ -1640,7 +1679,6 @@ function createAnimatedComponent(Component: any): any { callback, ); - if (this._component) { this._propsAnimated.setNativeView(this._component); } @@ -1657,7 +1695,8 @@ function createAnimatedComponent(Component: any): any { } componentWillReceiveProps(nextProps) { - this.attachProps(nextProps); + this._attachProps(nextProps); + this._attachNativeEvents(nextProps); } render() { @@ -1694,7 +1733,7 @@ function createAnimatedComponent(Component: any): any { ); } } - } + }, }; return AnimatedComponent; @@ -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, - config?: ?EventConfig, -): () => void { - return function(...args): void { - var traverse = function(recMapping, recEvt, key) { +class AnimatedEvent { + _argMapping: Array; + _listener: ?Function; + __isNative: bool; + + constructor( + argMapping: Array, + 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( @@ -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, + config?: EventConfig, +): any { + const animatedEvent = new AnimatedEvent(argMapping, config); + if (animatedEvent.__isNative) { + return animatedEvent; + } else { + return animatedEvent.__getHandler(); + } }; /** diff --git a/Libraries/Animated/src/NativeAnimatedHelper.js b/Libraries/Animated/src/NativeAnimatedHelper.js index 117ec61c890393..9659d8b7bae2ed 100644 --- a/Libraries/Animated/src/NativeAnimatedHelper.js +++ b/Libraries/Animated/src/NativeAnimatedHelper.js @@ -21,6 +21,10 @@ let __nativeAnimationIdCount = 1; /* used for started animations */ type EndResult = {finished: bool}; type EndCallback = (result: EndResult) => void; +type EventMapping = { + nativeEventPath: Array; + animatedValueTag: number; +}; let nativeEventEmitter; @@ -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); + } }; /** diff --git a/Libraries/Animated/src/__tests__/Animated-test.js b/Libraries/Animated/src/__tests__/Animated-test.js index d57f9b672448cb..01feba355865e7 100644 --- a/Libraries/Animated/src/__tests__/Animated-test.js +++ b/Libraries/Animated/src/__tests__/Animated-test.js @@ -13,6 +13,7 @@ jest .setMock('Text', {}) .setMock('View', {}) .setMock('Image', {}) + .setMock('ScrollView', {}) .setMock('React', {Component: class {}}); var Animated = require('Animated'); @@ -86,6 +87,7 @@ describe('Animated', () => { c.componentWillMount(); expect(anim.__detach).not.toBeCalled(); + c._component = {}; c.componentWillReceiveProps({ style: { opacity: anim, @@ -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}); diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/EventAnimationDriver.java b/ReactAndroid/src/main/java/com/facebook/react/animated/EventAnimationDriver.java new file mode 100644 index 00000000000000..20a6a8f83348ee --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/EventAnimationDriver.java @@ -0,0 +1,52 @@ +/** + * 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; + +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +import java.util.List; + +import javax.annotation.Nullable; + +/** + * Handles updating a {@link ValueAnimatedNode} when an event gets dispatched. + */ +/* package */ class EventAnimationDriver implements RCTEventEmitter { + private List mEventPath; + /* package */ ValueAnimatedNode mValueNode; + + public EventAnimationDriver(List eventPath, ValueAnimatedNode valueNode) { + mEventPath = eventPath; + mValueNode = valueNode; + } + + @Override + public void receiveEvent(int targetTag, String eventName, @Nullable WritableMap event) { + if (event == null) { + throw new IllegalArgumentException("Native animated events must have event data."); + } + + // Get the new value for the node by looking into the event map using the provided event path. + ReadableMap curMap = event; + for (int i = 0; i < mEventPath.size() - 1; i++) { + curMap = curMap.getMap(mEventPath.get(i)); + } + + mValueNode.mValue = curMap.getDouble(mEventPath.get(mEventPath.size() - 1)); + } + + @Override + public void receiveTouches(String eventName, WritableArray touches, WritableArray changedIndices) { + throw new RuntimeException("receiveTouches is not support by native animated events"); + } +} 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 45a39e34394b81..4ef8f3e3a6569f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java @@ -25,7 +25,6 @@ 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; import com.facebook.react.uimanager.UIManagerModule; import java.util.ArrayList; @@ -95,11 +94,9 @@ public void initialize() { mReactChoreographer = ReactChoreographer.getInstance(); ReactApplicationContext reactCtx = getReactApplicationContext(); - UIImplementation uiImplementation = - reactCtx.getNativeModule(UIManagerModule.class).getUIImplementation(); + UIManagerModule uiManager = reactCtx.getNativeModule(UIManagerModule.class); - final NativeAnimatedNodesManager nodesManager = - new NativeAnimatedNodesManager(uiImplementation); + final NativeAnimatedNodesManager nodesManager = new NativeAnimatedNodesManager(uiManager); mAnimatedFrameCallback = new GuardedChoreographerFrameCallback(reactCtx) { @Override protected void doFrameGuarded(final long frameTimeNanos) { @@ -312,4 +309,24 @@ public void execute(NativeAnimatedNodesManager animatedNodesManager) { } }); } + + @ReactMethod + public void addAnimatedEventToView(final int viewTag, final String eventName, final ReadableMap eventMapping) { + mOperations.add(new UIThreadOperation() { + @Override + public void execute(NativeAnimatedNodesManager animatedNodesManager) { + animatedNodesManager.addAnimatedEventToView(viewTag, eventName, eventMapping); + } + }); + } + + @ReactMethod + public void removeAnimatedEventFromView(final int viewTag, final String eventName) { + mOperations.add(new UIThreadOperation() { + @Override + public void execute(NativeAnimatedNodesManager animatedNodesManager) { + animatedNodesManager.removeAnimatedEventFromView(viewTag, eventName); + } + }); + } } 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 f312c88e612832..e11db2835fb6c7 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java @@ -11,16 +11,24 @@ import android.util.SparseArray; +import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.uimanager.UIImplementation; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.EventDispatcherListener; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Queue; import javax.annotation.Nullable; @@ -38,16 +46,21 @@ * * IMPORTANT: This class should be accessed only from the UI Thread */ -/*package*/ class NativeAnimatedNodesManager { +/*package*/ class NativeAnimatedNodesManager implements EventDispatcherListener { private final SparseArray mAnimatedNodes = new SparseArray<>(); private final ArrayList mActiveAnimations = new ArrayList<>(); private final ArrayList mUpdatedNodes = new ArrayList<>(); + private final Map mEventDrivers = new HashMap<>(); + private final Map> mCustomEventTypes; private final UIImplementation mUIImplementation; private int mAnimatedGraphBFSColor = 0; - public NativeAnimatedNodesManager(UIImplementation uiImplementation) { - mUIImplementation = uiImplementation; + public NativeAnimatedNodesManager(UIManagerModule uiManager) { + mUIImplementation = uiManager.getUIImplementation(); + uiManager.getEventDispatcher().addListener(this); + Object customEventTypes = Assertions.assertNotNull(uiManager.getConstants()).get("customDirectEventTypes"); + mCustomEventTypes = (Map>) customEventTypes; } /*package*/ @Nullable AnimatedNode getNodeById(int id) { @@ -238,6 +251,58 @@ public void disconnectAnimatedNodeFromView(int animatedNodeTag, int viewTag) { propsAnimatedNode.mConnectedViewTag = -1; } + public void addAnimatedEventToView(int viewTag, String eventName, ReadableMap eventMapping) { + int nodeTag = eventMapping.getInt("animatedValueTag"); + AnimatedNode node = mAnimatedNodes.get(nodeTag); + if (node == null) { + throw new JSApplicationIllegalArgumentException("Animated node with tag " + nodeTag + + " does not exists"); + } + if (!(node instanceof ValueAnimatedNode)) { + throw new JSApplicationIllegalArgumentException("Animated node connected to event should be" + + "of type " + ValueAnimatedNode.class.getName()); + } + + ReadableArray path = eventMapping.getArray("nativeEventPath"); + List pathList = new ArrayList<>(path.size()); + for (int i = 0; i < path.size(); i++) { + pathList.add(path.getString(i)); + } + + EventAnimationDriver event = new EventAnimationDriver(pathList, (ValueAnimatedNode) node); + mEventDrivers.put(viewTag + eventName, event); + } + + public void removeAnimatedEventFromView(int viewTag, String eventName) { + mEventDrivers.remove(viewTag + eventName); + } + + @Override + public boolean onEventDispatch(Event event) { + // Only support events dispatched from the UI thread. + if (!UiThreadUtil.isOnUiThread()) { + return false; + } + + if (!mEventDrivers.isEmpty()) { + // If the event has a different name in native convert it to it's JS name. + String eventName = event.getEventName(); + Map customEventType = mCustomEventTypes.get(eventName); + if (customEventType != null) { + eventName = customEventType.get("registrationName"); + } + + EventAnimationDriver eventDriver = mEventDrivers.get(event.getViewTag() + eventName); + if (eventDriver != null) { + event.dispatch(eventDriver); + mUpdatedNodes.add(eventDriver.mValueNode); + return true; + } + } + + return false; + } + /** * Animation loop performs two BFSes over the graph of animated nodes. We use incremented * {@code mAnimatedGraphBFSColor} to mark nodes as visited in each of the BFSes which saves diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java b/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java index d0c9f09e047697..c96e9af5914e4c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/PropsAnimatedNode.java @@ -12,10 +12,8 @@ import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.uimanager.NativeViewHierarchyManager; import com.facebook.react.uimanager.ReactStylesDiffMap; import com.facebook.react.uimanager.UIImplementation; -import com.facebook.react.uimanager.UIManagerModule; import java.util.HashMap; import java.util.Map; diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcher.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcher.java index 8bf009a9e830d4..ade36a58ca97c9 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcher.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcher.java @@ -92,6 +92,7 @@ public int compare(Event lhs, Event rhs) { private final Map mEventNameToEventId = MapBuilder.newHashMap(); private final DispatchEventsRunnable mDispatchEventsRunnable = new DispatchEventsRunnable(); private final ArrayList mEventStaging = new ArrayList<>(); + private final ArrayList mListeners = new ArrayList<>(); private Event[] mEventsToDispatch = new Event[16]; private int mEventsToDispatchSize = 0; @@ -112,6 +113,19 @@ public EventDispatcher(ReactApplicationContext reactContext) { */ public void dispatchEvent(Event event) { Assertions.assertCondition(event.isInitialized(), "Dispatched event hasn't been initialized"); + + boolean eventHandled = false; + for (EventDispatcherListener listener : mListeners) { + if (listener.onEventDispatch(event)) { + eventHandled = true; + } + } + + // If the event was handled by one of the event listener don't send it to JS. + if (eventHandled) { + return; + } + synchronized (mEventsStagingLock) { mEventStaging.add(event); Systrace.startAsyncFlow( @@ -131,6 +145,20 @@ public void dispatchEvent(Event event) { } } + /** + * Add a listener to this EventDispatcher. + */ + public void addListener(EventDispatcherListener listener) { + mListeners.add(listener); + } + + /** + * Remove a listener from this EventDispatcher. + */ + public void removeListener(EventDispatcherListener listener) { + mListeners.remove(listener); + } + @Override public void onHostResume() { UiThreadUtil.assertOnUiThread(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcherListener.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcherListener.java new file mode 100644 index 00000000000000..20a9940e9a3a86 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/EventDispatcherListener.java @@ -0,0 +1,16 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.events; + +/** + * Interface used to intercept events dispatched by {#link EventDispatcher} + */ +public interface EventDispatcherListener { + /** + * Called on every time an event is dispatched using {#link EventDispatcher#dispatchEvent}. Will be + * called from the same thread that the event is being dispatched from. + * @param event Event that was dispatched + * @return If the event was handled. If true the event won't be sent to JS. + */ + boolean onEventDispatch(Event event); +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/animated/BUCK b/ReactAndroid/src/test/java/com/facebook/react/animated/BUCK index b6f4ea8d0225e7..c218e19f0bb86d 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/BUCK @@ -9,6 +9,7 @@ robolectric3_test( react_native_target('java/com/facebook/react/animated:animated'), react_native_target('java/com/facebook/react/bridge:bridge'), react_native_target('java/com/facebook/react/uimanager:uimanager'), + react_native_target('java/com/facebook/react/common:common'), react_native_target('java/com/facebook/react:react'), react_native_tests_target('java/com/facebook/react/bridge:testhelpers'), 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 db779ea29ee89d..9f4db9d6fd1945 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/animated/NativeAnimatedNodeTraversalTest.java @@ -14,8 +14,13 @@ import com.facebook.react.bridge.JavaOnlyArray; import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.ReactStylesDiffMap; import com.facebook.react.uimanager.UIImplementation; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.RCTEventEmitter; import org.junit.Before; import org.junit.Rule; @@ -56,7 +61,9 @@ public class NativeAnimatedNodeTraversalTest { public PowerMockRule rule = new PowerMockRule(); private long mFrameTimeNanos; + private UIManagerModule mUIManagerMock; private UIImplementation mUIImplementationMock; + private EventDispatcher mEventDispatcherMock; private NativeAnimatedNodesManager mNativeAnimatedNodesManager; private long nextFrameTime() { @@ -80,8 +87,28 @@ public Object answer(InvocationOnMock invocation) throws Throwable { }); mFrameTimeNanos = INITIAL_FRAME_TIME_NANOS; + mUIManagerMock = mock(UIManagerModule.class); mUIImplementationMock = mock(UIImplementation.class); - mNativeAnimatedNodesManager = new NativeAnimatedNodesManager(mUIImplementationMock); + mEventDispatcherMock = mock(EventDispatcher.class); + PowerMockito.when(mUIManagerMock.getUIImplementation()).thenAnswer(new Answer() { + @Override + public UIImplementation answer(InvocationOnMock invocation) throws Throwable { + return mUIImplementationMock; + } + }); + PowerMockito.when(mUIManagerMock.getEventDispatcher()).thenAnswer(new Answer() { + @Override + public EventDispatcher answer(InvocationOnMock invocation) throws Throwable { + return mEventDispatcherMock; + } + }); + PowerMockito.when(mUIManagerMock.getConstants()).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return MapBuilder.of("customDirectEventTypes", MapBuilder.newHashMap()); + } + }); + mNativeAnimatedNodesManager = new NativeAnimatedNodesManager(mUIManagerMock); } /** @@ -698,4 +725,96 @@ public void testInterpolationNode() { mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); verifyNoMoreInteractions(mUIImplementationMock); } + + private Event createScrollEvent(final int tag, final double value) { + return new Event(tag) { + @Override + public String getEventName() { + return "topScroll"; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(tag, "topScroll", JavaOnlyMap.of( + "contentOffset", JavaOnlyMap.of("y", value))); + } + }; + } + + @Test + public void testNativeAnimatedEventDoUpdate() { + int viewTag = 1000; + + createSimpleAnimatedViewWithOpacity(viewTag, 0d); + + mNativeAnimatedNodesManager.addAnimatedEventToView(viewTag, "topScroll", JavaOnlyMap.of( + "animatedValueTag", 1, + "nativeEventPath", JavaOnlyArray.of("contentOffset", "y"))); + + mNativeAnimatedNodesManager.onEventDispatch(createScrollEvent(viewTag, 10)); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(viewTag), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(10); + } + + @Test + public void testNativeAnimatedEventDoNotUpdate() { + int viewTag = 1000; + + createSimpleAnimatedViewWithOpacity(viewTag, 0d); + + mNativeAnimatedNodesManager.addAnimatedEventToView(viewTag, "otherEvent", JavaOnlyMap.of( + "animatedValueTag", 1, + "nativeEventPath", JavaOnlyArray.of("contentOffset", "y"))); + + mNativeAnimatedNodesManager.addAnimatedEventToView(999, "topScroll", JavaOnlyMap.of( + "animatedValueTag", 1, + "nativeEventPath", JavaOnlyArray.of("contentOffset", "y"))); + + mNativeAnimatedNodesManager.onEventDispatch(createScrollEvent(viewTag, 10)); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(viewTag), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(0); + } + + @Test + public void testNativeAnimatedEventCustomMapping() { + int viewTag = 1000; + + PowerMockito.when(mUIManagerMock.getConstants()).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return MapBuilder.of("customDirectEventTypes", MapBuilder.of( + "topScroll", MapBuilder.of("registrationName", "onScroll") + )); + } + }); + mNativeAnimatedNodesManager = new NativeAnimatedNodesManager(mUIManagerMock); + + createSimpleAnimatedViewWithOpacity(viewTag, 0d); + + mNativeAnimatedNodesManager.addAnimatedEventToView(viewTag, "onScroll", JavaOnlyMap.of( + "animatedValueTag", 1, + "nativeEventPath", JavaOnlyArray.of("contentOffset", "y"))); + + mNativeAnimatedNodesManager.onEventDispatch(createScrollEvent(viewTag, 10)); + + ArgumentCaptor stylesCaptor = + ArgumentCaptor.forClass(ReactStylesDiffMap.class); + + reset(mUIImplementationMock); + mNativeAnimatedNodesManager.runUpdates(nextFrameTime()); + verify(mUIImplementationMock).synchronouslyUpdateViewOnUIThread(eq(viewTag), stylesCaptor.capture()); + assertThat(stylesCaptor.getValue().getDouble("opacity", Double.NaN)).isEqualTo(10); + } }