diff --git a/Examples/UIExplorer/ImageExample.js b/Examples/UIExplorer/ImageExample.js index 4cab0c1a1..68b16b2d1 100644 --- a/Examples/UIExplorer/ImageExample.js +++ b/Examples/UIExplorer/ImageExample.js @@ -28,6 +28,44 @@ var base64Icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAABLCAQAAACS var ImageCapInsetsExample = require('./ImageCapInsetsExample'); +var NetworkImageCallbackExample = React.createClass({ + getInitialState: function() { + return { + events: [], + }; + }, + + componentWillMount() { + this.setState({mountTime: new Date()}); + }, + + render: function() { + var { mountTime } = this.state; + + return ( + + this._loadEventFired(`✔ onLoadStart (+${new Date() - mountTime}ms)`)} + onLoad={() => this._loadEventFired(`✔ onLoad (+${new Date() - mountTime}ms)`)} + onLoadEnd={() => this._loadEventFired(`✔ onLoadEnd (+${new Date() - mountTime}ms)`)} + /> + + + {this.state.events.join('\n')} + + + ); + }, + + _loadEventFired(event) { + this.setState((state) => { + return state.events = [...state.events, event]; + }); + } +}); + var NetworkImageExample = React.createClass({ watchID: (null: ?number), @@ -92,6 +130,14 @@ exports.examples = [ ); }, }, + { + title: 'Image Loading Events', + render: function() { + return ( + + ); + }, + }, { title: 'Error Handler', render: function() { diff --git a/Libraries/Image/Image.android.js b/Libraries/Image/Image.android.js index 66308f682..9d9558c96 100644 --- a/Libraries/Image/Image.android.js +++ b/Libraries/Image/Image.android.js @@ -56,6 +56,7 @@ var ImageViewAttributes = merge(ReactNativeViewAttributes.UIView, { resizeMode: true, progressiveRenderingEnabled: true, fadeDuration: true, + shouldNotifyLoadEvents: true, }); var Image = React.createClass({ @@ -75,7 +76,18 @@ var Image = React.createClass({ ]).isRequired, progressiveRenderingEnabled: PropTypes.bool, fadeDuration: PropTypes.number, - style: StyleSheetPropType(ImageStylePropTypes), + /** + * Invoked on load start + */ + onLoadStart: PropTypes.func, + /** + * Invoked when load completes successfully + */ + onLoad: PropTypes.func, + /** + * Invoked when load either succeeds or fails + */ + onLoadEnd: PropTypes.func, /** * Used to locate this view in end-to-end tests. */ @@ -137,9 +149,11 @@ var Image = React.createClass({ if (source && source.uri) { var {width, height} = source; var style = flattenStyle([{width, height}, styles.base, this.props.style]); + var {onLoadStart, onLoad, onLoadEnd} = this.props; var nativeProps = merge(this.props, { style, + shouldNotifyLoadEvents: !!(onLoadStart || onLoad || onLoadEnd), src: source.uri, }); @@ -186,6 +200,7 @@ var cfg = { defaultImageSrc: true, imageTag: true, progressHandlerRegistered: true, + shouldNotifyLoadEvents: true, }, }; var RKImage = requireNativeComponent('RCTImageView', Image, cfg); diff --git a/Libraries/Image/Image.ios.js b/Libraries/Image/Image.ios.js index 4da2d53e5..6943bf909 100644 --- a/Libraries/Image/Image.ios.js +++ b/Libraries/Image/Image.ios.js @@ -113,7 +113,6 @@ var Image = React.createClass({ onLayout: PropTypes.func, /** * Invoked on load start - * @platform ios */ onLoadStart: PropTypes.func, /** @@ -128,12 +127,10 @@ var Image = React.createClass({ onError: PropTypes.func, /** * Invoked when load completes successfully - * @platform ios */ onLoad: PropTypes.func, /** * Invoked when load either succeeds or fails - * @platform ios */ onLoadEnd: PropTypes.func, }, diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageLoadEvent.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageLoadEvent.java new file mode 100644 index 000000000..fc183507a --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ImageLoadEvent.java @@ -0,0 +1,73 @@ +/** + * 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.views.image; + +import android.support.annotation.IntDef; + +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +public class ImageLoadEvent extends Event { + @IntDef({ON_ERROR, ON_LOAD, ON_LOAD_END, ON_LOAD_START, ON_PROGRESS}) + @Retention(RetentionPolicy.SOURCE) + @interface ImageEventType {} + + // Currently ON_ERROR and ON_PROGRESS are not implemented, these can be added + // easily once support exists in fresco. + public static final int ON_ERROR = 1; + public static final int ON_LOAD = 2; + public static final int ON_LOAD_END = 3; + public static final int ON_LOAD_START = 4; + public static final int ON_PROGRESS = 5; + + private final int mEventType; + + public ImageLoadEvent(int viewId, long timestampMs, @ImageEventType int eventType) { + super(viewId, timestampMs); + mEventType = eventType; + } + + public static String eventNameForType(@ImageEventType int eventType) { + switch(eventType) { + case ON_ERROR: + return "topError"; + case ON_LOAD: + return "topLoad"; + case ON_LOAD_END: + return "topLoadEnd"; + case ON_LOAD_START: + return "topLoadStart"; + case ON_PROGRESS: + return "topProgress"; + default: + throw new IllegalStateException("Invalid image event: " + Integer.toString(eventType)); + } + } + + @Override + public String getEventName() { + return ImageLoadEvent.eventNameForType(mEventType); + } + + @Override + public short getCoalescingKey() { + // Intentionally casting mEventType because it is guaranteed to be small + // enough to fit into short. + return (short) mEventType; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), null); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java index 0158ff072..19ff94205 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageManager.java @@ -11,10 +11,13 @@ package com.facebook.react.views.image; import javax.annotation.Nullable; +import java.util.Map; + import android.graphics.Color; import com.facebook.drawee.backends.pipeline.Fresco; import com.facebook.drawee.controller.AbstractDraweeControllerBuilder; +import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.ReactProp; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.ThemedReactContext; @@ -102,6 +105,23 @@ public class ReactImageManager extends SimpleViewManager { view.setFadeDuration(durationMs); } + @ReactProp(name = "shouldNotifyLoadEvents") + public void setLoadHandlersRegistered(ReactImageView view, boolean shouldNotifyLoadEvents) { + view.setShouldNotifyLoadEvents(shouldNotifyLoadEvents); + } + + @Override + public @Nullable Map getExportedCustomDirectEventTypeConstants() { + return MapBuilder.of( + ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD_START), + MapBuilder.of("registrationName", "onLoadStart"), + ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD), + MapBuilder.of("registrationName", "onLoad"), + ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD_END), + MapBuilder.of("registrationName", "onLoadEnd") + ); + } + @Override protected void onAfterUpdateTransaction(ReactImageView view) { super.onAfterUpdateTransaction(view); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java index d741356fe..c889c8183 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java @@ -20,11 +20,15 @@ import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Shader; +import android.graphics.drawable.Animatable; import android.net.Uri; +import android.os.SystemClock; import com.facebook.common.util.UriUtil; import com.facebook.drawee.controller.AbstractDraweeControllerBuilder; +import com.facebook.drawee.controller.BaseControllerListener; import com.facebook.drawee.controller.ControllerListener; +import com.facebook.drawee.controller.ForwardingControllerListener; import com.facebook.drawee.drawable.ScalingUtils; import com.facebook.drawee.generic.GenericDraweeHierarchy; import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; @@ -32,11 +36,15 @@ import com.facebook.drawee.generic.RoundingParams; import com.facebook.drawee.interfaces.DraweeController; import com.facebook.drawee.view.GenericDraweeView; import com.facebook.imagepipeline.common.ResizeOptions; +import com.facebook.imagepipeline.image.ImageInfo; import com.facebook.imagepipeline.request.BasePostprocessor; import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.facebook.imagepipeline.request.Postprocessor; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.uimanager.events.EventDispatcher; /** * Wrapper class around Fresco's GenericDraweeView, enabling persisting props across multiple view @@ -104,8 +112,9 @@ public class ReactImageView extends GenericDraweeView { private boolean mIsLocalImage; private final AbstractDraweeControllerBuilder mDraweeControllerBuilder; private final RoundedCornerPostprocessor mRoundedCornerPostprocessor; - private final @Nullable Object mCallerContext; private @Nullable ControllerListener mControllerListener; + private @Nullable ControllerListener mControllerForTesting; + private final @Nullable Object mCallerContext; private int mFadeDurationMs = -1; private boolean mProgressiveRenderingEnabled; @@ -127,6 +136,48 @@ public class ReactImageView extends GenericDraweeView { mCallerContext = callerContext; } + public void setShouldNotifyLoadEvents(boolean shouldNotify) { + if (!shouldNotify) { + mControllerListener = null; + } else { + final EventDispatcher mEventDispatcher = ((ReactContext) getContext()). + getNativeModule(UIManagerModule.class).getEventDispatcher(); + + mControllerListener = new BaseControllerListener() { + @Override + public void onSubmit(String id, Object callerContext) { + mEventDispatcher.dispatchEvent( + new ImageLoadEvent(getId(), SystemClock.uptimeMillis(), ImageLoadEvent.ON_LOAD_START) + ); + } + + @Override + public void onFinalImageSet( + String id, + @Nullable final ImageInfo imageInfo, + @Nullable Animatable animatable) { + if (imageInfo != null) { + mEventDispatcher.dispatchEvent( + new ImageLoadEvent(getId(), SystemClock.uptimeMillis(), ImageLoadEvent.ON_LOAD_END) + ); + mEventDispatcher.dispatchEvent( + new ImageLoadEvent(getId(), SystemClock.uptimeMillis(), ImageLoadEvent.ON_LOAD) + ); + } + } + + @Override + public void onFailure(String id, Throwable throwable) { + mEventDispatcher.dispatchEvent( + new ImageLoadEvent(getId(), SystemClock.uptimeMillis(), ImageLoadEvent.ON_LOAD_END) + ); + } + }; + } + + mIsDirty = true; + } + public void setBorderColor(int borderColor) { mBorderColor = borderColor; mIsDirty = true; @@ -217,21 +268,33 @@ public class ReactImageView extends GenericDraweeView { .setProgressiveRenderingEnabled(mProgressiveRenderingEnabled) .build(); - DraweeController draweeController = mDraweeControllerBuilder - .reset() + // This builder is reused + mDraweeControllerBuilder.reset(); + + mDraweeControllerBuilder .setAutoPlayAnimations(true) .setCallerContext(mCallerContext) .setOldController(getController()) - .setImageRequest(imageRequest) - .setControllerListener(mControllerListener) - .build(); - setController(draweeController); + .setImageRequest(imageRequest); + + if (mControllerListener != null && mControllerForTesting != null) { + ForwardingControllerListener combinedListener = new ForwardingControllerListener(); + combinedListener.addListener(mControllerListener); + combinedListener.addListener(mControllerForTesting); + mDraweeControllerBuilder.setControllerListener(combinedListener); + } else if (mControllerForTesting != null) { + mDraweeControllerBuilder.setControllerListener(mControllerForTesting); + } else if (mControllerListener != null) { + mDraweeControllerBuilder.setControllerListener(mControllerListener); + } + + setController(mDraweeControllerBuilder.build()); mIsDirty = false; } // VisibleForTesting public void setControllerListener(ControllerListener controllerListener) { - mControllerListener = controllerListener; + mControllerForTesting = controllerListener; mIsDirty = true; maybeUpdateView(); }