diff --git a/Examples/UIExplorer/js/NativeAnimationsExample.js b/Examples/UIExplorer/js/NativeAnimationsExample.js index e87a8161fd2..ecafb4751be 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 bce17d3272a..ebb79b51b69 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 c9e61113168..ad2f0cfcc1a 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); } - attachProps(nextProps) { + _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) { 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 117ec61c890..9659d8b7bae 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 d57f9b67244..01feba35586 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 00000000000..20a6a8f8334 --- /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 45a39e34394..4ef8f3e3a65 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.module.annotations.ReactModule; 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 class NativeAnimatedModule extends ReactContextBaseJavaModule implements 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 class NativeAnimatedModule extends ReactContextBaseJavaModule implements } }); } + + @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 f312c88e612..e11db2835fb 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 @@ package com.facebook.react.animated; 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 @@ import javax.annotation.Nullable; * * 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 @@ import javax.annotation.Nullable; 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 d0c9f09e047..c96e9af5914 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 @@ package com.facebook.react.animated; 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 8bf009a9e83..ade36a58ca9 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 class EventDispatcher implements LifecycleEventListener { 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 class EventDispatcher implements LifecycleEventListener { */ 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 class EventDispatcher implements LifecycleEventListener { } } + /** + * 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 00000000000..20a9940e9a3 --- /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 b6f4ea8d022..c218e19f0bb 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 db779ea29ee..9f4db9d6fd1 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.Callback; 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 class NativeAnimatedNodeTraversalTest { }); 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 class NativeAnimatedNodeTraversalTest { 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); + } }