diff --git a/Examples/UIExplorer/ToolbarAndroidExample.android.js b/Examples/UIExplorer/ToolbarAndroidExample.android.js index 359ba087e..c621296d9 100644 --- a/Examples/UIExplorer/ToolbarAndroidExample.android.js +++ b/Examples/UIExplorer/ToolbarAndroidExample.android.js @@ -93,6 +93,14 @@ var ToolbarAndroidExample = React.createClass({ Touch the icon to reset the custom colors to the default (theme-provided) ones. + + + ); }, diff --git a/Examples/UIExplorer/bunny.png b/Examples/UIExplorer/bunny.png new file mode 100644 index 000000000..0d94af660 Binary files /dev/null and b/Examples/UIExplorer/bunny.png differ diff --git a/Examples/UIExplorer/hawk.png b/Examples/UIExplorer/hawk.png new file mode 100644 index 000000000..7205b1e47 Binary files /dev/null and b/Examples/UIExplorer/hawk.png differ diff --git a/Libraries/Components/ToolbarAndroid/ToolbarAndroid.android.js b/Libraries/Components/ToolbarAndroid/ToolbarAndroid.android.js index 371f49e42..9c1a5b877 100644 --- a/Libraries/Components/ToolbarAndroid/ToolbarAndroid.android.js +++ b/Libraries/Components/ToolbarAndroid/ToolbarAndroid.android.js @@ -13,11 +13,13 @@ var Image = require('Image'); var NativeMethodsMixin = require('NativeMethodsMixin'); +var RCTUIManager = require('NativeModules').UIManager; var React = require('React'); var ReactNativeViewAttributes = require('ReactNativeViewAttributes'); var ReactPropTypes = require('ReactPropTypes'); var requireNativeComponent = require('requireNativeComponent'); +var resolveAssetSource = require('resolveAssetSource'); /** * React component that wraps the Android-only [`Toolbar` widget][0]. A Toolbar can display a logo, @@ -27,6 +29,12 @@ var requireNativeComponent = require('requireNativeComponent'); * * If the toolbar has an only child, it will be displayed between the title and actions. * + * Although the Toolbar supports remote images for the logo, navigation and action icons, this + * should only be used in DEV mode where `require('./some_icon.png')` translates into a packager + * URL. In release mode you should always use a drawable resource for these icons. Using + * `require('./some_icon.png')` will do this automatically for you, so as long as you don't + * explicitly use e.g. `{uri: 'http://...'}`, you will be good. + * * Example: * * ``` @@ -115,16 +123,10 @@ var ToolbarAndroid = React.createClass({ ...this.props, }; if (this.props.logo) { - if (!this.props.logo.isStatic) { - throw 'logo prop should be a static image (obtained via ix)'; - } - nativeProps.logo = this.props.logo.uri; + nativeProps.logo = resolveAssetSource(this.props.logo); } if (this.props.navIcon) { - if (!this.props.navIcon.isStatic) { - throw 'navIcon prop should be static image (obtained via ix)'; - } - nativeProps.navIcon = this.props.navIcon.uri; + nativeProps.navIcon = resolveAssetSource(this.props.navIcon); } if (this.props.actions) { nativeProps.actions = []; @@ -133,10 +135,10 @@ var ToolbarAndroid = React.createClass({ ...this.props.actions[i], }; if (action.icon) { - if (!action.icon.isStatic) { - throw 'action icons should be static images (obtained via ix)'; - } - action.icon = action.icon.uri; + action.icon = resolveAssetSource(action.icon); + } + if (action.show) { + action.show = RCTUIManager.ToolbarAndroid.Constants.ShowAsAction[action.show]; } nativeProps.actions.push(action); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/DrawableWithIntrinsicSize.java b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/DrawableWithIntrinsicSize.java new file mode 100644 index 000000000..849e1e44d --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/DrawableWithIntrinsicSize.java @@ -0,0 +1,42 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.views.toolbar; + +import android.graphics.drawable.Drawable; + +import com.facebook.drawee.drawable.ForwardingDrawable; +import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.react.uimanager.PixelUtil; + +/** + * Fresco currently sets drawables' intrinsic size to (-1, -1). This is to guarantee that scaling is + * performed correctly. In the case of the Toolbar, we don't have access to the widget's internal + * ImageView, which has width/height set to WRAP_CONTENT, which relies on intrinsic size. + * + * To work around this we have this class which just wraps another Drawable, but returns the correct + * dimensions in getIntrinsicWidth/Height. This makes WRAP_CONTENT work in Toolbar's internals. + * + * This drawable uses the size of a loaded image to determine the intrinsic size. It therefore can't + * be used safely until *after* an image has loaded, and must be replaced when the image is + * replaced. + */ +public class DrawableWithIntrinsicSize extends ForwardingDrawable implements Drawable.Callback { + + private final ImageInfo mImageInfo; + + public DrawableWithIntrinsicSize(Drawable drawable, ImageInfo imageInfo) { + super(drawable); + mImageInfo = imageInfo; + } + + @Override + public int getIntrinsicWidth() { + return (int) PixelUtil.toDIPFromPixel(mImageInfo.getWidth()); + } + + @Override + public int getIntrinsicHeight() { + return (int) PixelUtil.toDIPFromPixel(mImageInfo.getHeight()); + } + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbar.java b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbar.java new file mode 100644 index 000000000..91741c1c6 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbar.java @@ -0,0 +1,250 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.views.toolbar; + +import javax.annotation.Nullable; + +import android.content.Context; +import android.graphics.drawable.Animatable; +import android.net.Uri; +import android.support.v7.widget.Toolbar; +import android.view.Menu; +import android.view.MenuItem; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.controller.ControllerListener; +import com.facebook.drawee.drawable.ScalingUtils; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.drawee.view.DraweeHolder; +import com.facebook.drawee.view.MultiDraweeHolder; +import com.facebook.imagepipeline.image.ImageInfo; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; + +/** + * Custom implementation of the {@link Toolbar} widget that adds support for remote images in logo + * and navigationIcon using fresco. + */ +public class ReactToolbar extends Toolbar { + private static final String PROP_ACTION_ICON = "icon"; + private static final String PROP_ACTION_SHOW = "show"; + private static final String PROP_ACTION_SHOW_WITH_TEXT = "showWithText"; + private static final String PROP_ACTION_TITLE = "title"; + + private final DraweeHolder mLogoHolder; + private final DraweeHolder mNavIconHolder; + private final MultiDraweeHolder mActionsHolder = + new MultiDraweeHolder<>(); + + private final ControllerListener mLogoControllerListener = + new BaseControllerListener() { + @Override + public void onFinalImageSet( + String id, + @Nullable final ImageInfo imageInfo, + @Nullable Animatable animatable) { + if (imageInfo != null) { + final DrawableWithIntrinsicSize logoDrawable = + new DrawableWithIntrinsicSize(mLogoHolder.getTopLevelDrawable(), imageInfo); + setLogo(logoDrawable); + } + } + }; + + private final ControllerListener mNavIconControllerListener = + new BaseControllerListener() { + @Override + public void onFinalImageSet( + String id, + @Nullable final ImageInfo imageInfo, + @Nullable Animatable animatable) { + if (imageInfo != null) { + final DrawableWithIntrinsicSize navIconDrawable = + new DrawableWithIntrinsicSize(mNavIconHolder.getTopLevelDrawable(), imageInfo); + setNavigationIcon(navIconDrawable); + } + } + }; + + private static class ActionIconControllerListener extends BaseControllerListener { + private final MenuItem mItem; + private final DraweeHolder mHolder; + + ActionIconControllerListener(MenuItem item, DraweeHolder holder) { + mItem = item; + mHolder = holder; + } + + @Override + public void onFinalImageSet( + String id, + @Nullable ImageInfo imageInfo, + @Nullable Animatable animatable) { + if (imageInfo != null) { + mItem.setIcon(new DrawableWithIntrinsicSize(mHolder.getTopLevelDrawable(), imageInfo)); + } + } + } + + public ReactToolbar(Context context) { + super(context); + + mLogoHolder = DraweeHolder.create(createDraweeHierarchy(), context); + mNavIconHolder = DraweeHolder.create(createDraweeHierarchy(), context); + } + + private final Runnable mLayoutRunnable = new Runnable() { + @Override + public void run() { + measure( + MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY)); + layout(getLeft(), getTop(), getRight(), getBottom()); + } + }; + + @Override + public void requestLayout() { + super.requestLayout(); + + // The toolbar relies on a measure + layout pass happening after it calls requestLayout(). + // Without this, certain calls (e.g. setLogo) only take effect after a second invalidation. + post(mLayoutRunnable); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + detachDraweeHolders(); + } + + @Override + public void onStartTemporaryDetach() { + super.onStartTemporaryDetach(); + detachDraweeHolders(); + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + attachDraweeHolders(); + } + + @Override + public void onFinishTemporaryDetach() { + super.onFinishTemporaryDetach(); + mLogoHolder.onAttach(); + mNavIconHolder.onAttach(); + } + + private void detachDraweeHolders() { + mLogoHolder.onDetach(); + mNavIconHolder.onDetach(); + mActionsHolder.onDetach(); + } + + private void attachDraweeHolders() { + mLogoHolder.onAttach(); + mNavIconHolder.onAttach(); + mActionsHolder.onAttach(); + } + + /* package */ void setLogoSource(@Nullable ReadableMap source) { + String uri = source != null ? source.getString("uri") : null; + if (uri == null) { + setLogo(null); + } else if (uri.startsWith("http://") || uri.startsWith("https://")) { + DraweeController controller = Fresco.newDraweeControllerBuilder() + .setUri(Uri.parse(uri)) + .setControllerListener(mLogoControllerListener) + .setOldController(mLogoHolder.getController()) + .build(); + mLogoHolder.setController(controller); + } else { + setLogo(getDrawableResourceByName(uri)); + } + } + + /* package */ void setNavIconSource(@Nullable ReadableMap source) { + String uri = source != null ? source.getString("uri") : null; + if (uri == null) { + setNavigationIcon(null); + } else if (uri.startsWith("http://") || uri.startsWith("https://")) { + DraweeController controller = Fresco.newDraweeControllerBuilder() + .setUri(Uri.parse(uri)) + .setControllerListener(mNavIconControllerListener) + .setOldController(mNavIconHolder.getController()) + .build(); + mNavIconHolder.setController(controller); + } else { + setNavigationIcon(getDrawableResourceByName(uri)); + } + } + + /* package */ void setActions(@Nullable ReadableArray actions) { + Menu menu = getMenu(); + menu.clear(); + mActionsHolder.clear(); + if (actions != null) { + for (int i = 0; i < actions.size(); i++) { + ReadableMap action = actions.getMap(i); + MenuItem item = menu.add(Menu.NONE, Menu.NONE, i, action.getString(PROP_ACTION_TITLE)); + ReadableMap icon = action.hasKey(PROP_ACTION_ICON) ? action.getMap(PROP_ACTION_ICON) : null; + if (icon != null) { + String iconSource = icon.getString("uri"); + if (iconSource.startsWith("http://") || iconSource.startsWith("https://")) { + setMenuItemIcon(item, icon); + } else { + item.setIcon(getDrawableResourceByName(iconSource)); + } + } + int showAsAction = action.hasKey(PROP_ACTION_SHOW) + ? action.getInt(PROP_ACTION_SHOW) + : MenuItem.SHOW_AS_ACTION_NEVER; + if (action.hasKey(PROP_ACTION_SHOW_WITH_TEXT) && + action.getBoolean(PROP_ACTION_SHOW_WITH_TEXT)) { + showAsAction = showAsAction | MenuItem.SHOW_AS_ACTION_WITH_TEXT; + } + item.setShowAsAction(showAsAction); + } + } + } + + /** + * This is only used when the icon is remote (http/s). Creates & adds a new {@link DraweeHolder} + * to {@link #mActionsHolder} and attaches a {@link ActionIconControllerListener} that just sets + * the top level drawable when it's loaded. + */ + private void setMenuItemIcon(MenuItem item, ReadableMap icon) { + String iconSource = icon.getString("uri"); + + DraweeHolder holder = + DraweeHolder.create(createDraweeHierarchy(), getContext()); + DraweeController controller = Fresco.newDraweeControllerBuilder() + .setUri(Uri.parse(iconSource)) + .setControllerListener(new ActionIconControllerListener(item, holder)) + .setOldController(holder.getController()) + .build(); + holder.setController(controller); + + mActionsHolder.add(holder); + } + + private GenericDraweeHierarchy createDraweeHierarchy() { + return new GenericDraweeHierarchyBuilder(getResources()) + .setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER) + .setFadeDuration(0) + .build(); + } + + private int getDrawableResourceByName(String name) { + return getResources().getIdentifier( + name, + "drawable", + getContext().getPackageName()); + } + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbarManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbarManager.java index 6b9a988dd..93fe8a61d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbarManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/toolbar/ReactToolbarManager.java @@ -11,19 +11,20 @@ package com.facebook.react.views.toolbar; import javax.annotation.Nullable; +import java.util.Map; + import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.os.SystemClock; -import android.support.v7.widget.Toolbar; -import android.view.Menu; import android.view.MenuItem; import android.view.View; import com.facebook.react.R; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.ReactProp; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.UIManagerModule; @@ -32,52 +33,39 @@ import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.views.toolbar.events.ToolbarClickEvent; /** - * Manages instances of Toolbar. + * Manages instances of ReactToolbar. */ -public class ReactToolbarManager extends ViewGroupManager { +public class ReactToolbarManager extends ViewGroupManager { private static final String REACT_CLASS = "ToolbarAndroid"; - private static final String PROP_ACTION_ICON = "icon"; - private static final String PROP_ACTION_SHOW = "show"; - private static final String PROP_ACTION_SHOW_WITH_TEXT = "showWithText"; - private static final String PROP_ACTION_TITLE = "title"; - @Override public String getName() { return REACT_CLASS; } @Override - protected Toolbar createViewInstance(ThemedReactContext reactContext) { - return new Toolbar(reactContext); + protected ReactToolbar createViewInstance(ThemedReactContext reactContext) { + return new ReactToolbar(reactContext); } @ReactProp(name = "logo") - public void setLogo(Toolbar view, @Nullable String logo) { - if (logo != null) { - view.setLogo(getDrawableResourceByName(view.getContext(), logo)); - } else { - view.setLogo(null); - } + public void setLogo(ReactToolbar view, @Nullable ReadableMap logo) { + view.setLogoSource(logo); } @ReactProp(name = "navIcon") - public void setNavIcon(Toolbar view, @Nullable String navIcon) { - if (navIcon != null) { - view.setNavigationIcon(getDrawableResourceByName(view.getContext(), navIcon)); - } else { - view.setNavigationIcon(null); - } + public void setNavIcon(ReactToolbar view, @Nullable ReadableMap navIcon) { + view.setNavIconSource(navIcon); } @ReactProp(name = "subtitle") - public void setSubtitle(Toolbar view, @Nullable String subtitle) { + public void setSubtitle(ReactToolbar view, @Nullable String subtitle) { view.setSubtitle(subtitle); } @ReactProp(name = "subtitleColor", customType = "Color") - public void setSubtitleColor(Toolbar view, @Nullable Integer subtitleColor) { + public void setSubtitleColor(ReactToolbar view, @Nullable Integer subtitleColor) { int[] defaultColors = getDefaultColors(view.getContext()); if (subtitleColor != null) { view.setSubtitleTextColor(subtitleColor); @@ -87,12 +75,12 @@ public class ReactToolbarManager extends ViewGroupManager { } @ReactProp(name = "title") - public void setTitle(Toolbar view, @Nullable String title) { + public void setTitle(ReactToolbar view, @Nullable String title) { view.setTitle(title); } @ReactProp(name = "titleColor", customType = "Color") - public void setTitleColor(Toolbar view, @Nullable Integer titleColor) { + public void setTitleColor(ReactToolbar view, @Nullable Integer titleColor) { int[] defaultColors = getDefaultColors(view.getContext()); if (titleColor != null) { view.setTitleTextColor(titleColor); @@ -102,37 +90,12 @@ public class ReactToolbarManager extends ViewGroupManager { } @ReactProp(name = "actions") - public void setActions(Toolbar view, @Nullable ReadableArray actions) { - Menu menu = view.getMenu(); - menu.clear(); - if (actions != null) { - for (int i = 0; i < actions.size(); i++) { - ReadableMap action = actions.getMap(i); - MenuItem item = menu.add(Menu.NONE, Menu.NONE, i, action.getString(PROP_ACTION_TITLE)); - String icon = action.hasKey(PROP_ACTION_ICON) ? action.getString(PROP_ACTION_ICON) : null; - if (icon != null) { - item.setIcon(getDrawableResourceByName(view.getContext(), icon)); - } - String show = action.hasKey(PROP_ACTION_SHOW) ? action.getString(PROP_ACTION_SHOW) : null; - if (show != null) { - int showAsAction = MenuItem.SHOW_AS_ACTION_NEVER; - if ("always".equals(show)) { - showAsAction = MenuItem.SHOW_AS_ACTION_ALWAYS; - } else if ("ifRoom".equals(show)) { - showAsAction = MenuItem.SHOW_AS_ACTION_IF_ROOM; - } - if (action.hasKey(PROP_ACTION_SHOW_WITH_TEXT) && - action.getBoolean(PROP_ACTION_SHOW_WITH_TEXT)) { - showAsAction = showAsAction | MenuItem.SHOW_AS_ACTION_WITH_TEXT; - } - item.setShowAsAction(showAsAction); - } - } - } + public void setActions(ReactToolbar view, @Nullable ReadableArray actions) { + view.setActions(actions); } @Override - protected void addEventEmitters(final ThemedReactContext reactContext, final Toolbar view) { + protected void addEventEmitters(final ThemedReactContext reactContext, final ReactToolbar view) { final EventDispatcher mEventDispatcher = reactContext.getNativeModule(UIManagerModule.class) .getEventDispatcher(); view.setNavigationOnClickListener( @@ -145,7 +108,7 @@ public class ReactToolbarManager extends ViewGroupManager { }); view.setOnMenuItemClickListener( - new Toolbar.OnMenuItemClickListener() { + new ReactToolbar.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(MenuItem menuItem) { mEventDispatcher.dispatchEvent( @@ -158,6 +121,17 @@ public class ReactToolbarManager extends ViewGroupManager { }); } + @Nullable + @Override + public Map getExportedViewConstants() { + return MapBuilder.of( + "ShowAsAction", + MapBuilder.of( + "never", MenuItem.SHOW_AS_ACTION_NEVER, + "always", MenuItem.SHOW_AS_ACTION_ALWAYS, + "ifRoom", MenuItem.SHOW_AS_ACTION_IF_ROOM)); + } + @Override public boolean needsCustomLayoutForChildren() { return true; @@ -205,12 +179,4 @@ public class ReactToolbarManager extends ViewGroupManager { } } - private static int getDrawableResourceByName(Context context, String name) { - name = name.toLowerCase().replace("-", "_"); - return context.getResources().getIdentifier( - name, - "drawable", - context.getPackageName()); - } - }