From 3aac1c5863ac25dafddc9ce20237af6b0447f24c Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 12 Jul 2024 15:17:25 -0700 Subject: [PATCH] Support loading vector drawables in ImageView (#45354) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/45354 Fresco has indicated that they have no plans to support loading vector assets and similar drawable types in Drawee-backed views ([issue](https://github.com/facebook/fresco/issues/329), [issue](https://github.com/facebook/fresco/issues/1463), [issue](https://github.com/facebook/fresco/issues/2463)). Guidance has been to instead load the vector drawable onto the backing image view ourselves. On the React Native side, having the ability to load vector drawables has been requested many times ([issue](https://github.com/facebook/react-native/issues/16651), [issue](https://github.com/facebook/react-native/issues/27502)). I went this route over using a custom Fresco decoder for XML assets because vector drawables are compiled down into binary XML and I couldn't find a trivial, performant way to parse those files in a context-aware manner. This change only accounts for vector drawables, not any of the other XML-based drawable types (layer lists, level lists, state lists, 9-patch, etc.). Support could be added easily in the future by expanding the `getDrawableIfUnsupported` function. ## Changelog [Android] [Added] - Added support for rendering XML assets provided to `Image` Reviewed By: javache Differential Revision: D59530172 --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/views/image/ReactImageView.java | 67 ++++++++++++++++++- .../imagehelper/ResourceDrawableIdHelper.kt | 35 ++++++++++ .../app/src/main/res/drawable/ic_android.xml | 10 +++ .../js/examples/Image/ImageExample.js | 26 +++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/rn-tester/android/app/src/main/res/drawable/ic_android.xml diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 875c4dc00415d4..8b3da2113f40fa 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6463,6 +6463,7 @@ public final class com/facebook/react/views/imagehelper/ResourceDrawableIdHelper public final fun getResourceDrawable (Landroid/content/Context;Ljava/lang/String;)Landroid/graphics/drawable/Drawable; public final fun getResourceDrawableId (Landroid/content/Context;Ljava/lang/String;)I public final fun getResourceDrawableUri (Landroid/content/Context;Ljava/lang/String;)Landroid/net/Uri; + public final fun isVectorDrawable (Landroid/content/Context;Ljava/lang/String;)Z } public final class com/facebook/react/views/imagehelper/ResourceDrawableIdHelper$Companion { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java index 395a90a6dc3b8f..66e668e123935a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.java @@ -496,6 +496,17 @@ public void maybeUpdateView() { ? mFadeDurationMs : mImageSource.isResource() ? 0 : REMOTE_IMAGE_FADE_DURATION_MS); + Drawable drawable = getDrawableIfUnsupported(mImageSource); + if (drawable != null) { + maybeUpdateViewFromDrawable(drawable); + } else { + maybeUpdateViewFromRequest(doResize); + } + + mIsDirty = false; + } + + private void maybeUpdateViewFromRequest(boolean doResize) { List postprocessors = new LinkedList<>(); if (mIterativeBoxBlurPostProcessor != null) { postprocessors.add(mIterativeBoxBlurPostProcessor); @@ -553,17 +564,45 @@ public void maybeUpdateView() { } if (mDownloadListener != null) { - hierarchy.setProgressBarImage(mDownloadListener); + getHierarchy().setProgressBarImage(mDownloadListener); } setController(mDraweeControllerBuilder.build()); - mIsDirty = false; // Reset again so the DraweeControllerBuilder clears all it's references. Otherwise, this causes // a memory leak. mDraweeControllerBuilder.reset(); } + private void maybeUpdateViewFromDrawable(Drawable drawable) { + final boolean shouldNotify = mDownloadListener != null; + final EventDispatcher mEventDispatcher = + shouldNotify + ? UIManagerHelper.getEventDispatcherForReactTag((ReactContext) getContext(), getId()) + : null; + + if (mEventDispatcher != null) { + mEventDispatcher.dispatchEvent( + ImageLoadEvent.createLoadStartEvent( + UIManagerHelper.getSurfaceId(ReactImageView.this), getId())); + } + + getHierarchy().setImage(drawable, 1, false); + + if (mEventDispatcher != null) { + mEventDispatcher.dispatchEvent( + ImageLoadEvent.createLoadEvent( + UIManagerHelper.getSurfaceId(ReactImageView.this), + getId(), + mImageSource.getSource(), + getWidth(), + getHeight())); + mEventDispatcher.dispatchEvent( + ImageLoadEvent.createLoadEndEvent( + UIManagerHelper.getSurfaceId(ReactImageView.this), getId())); + } + } + // VisibleForTesting public void setControllerListener(ControllerListener controllerListener) { mControllerForTesting = controllerListener; @@ -635,6 +674,30 @@ private boolean shouldResize(ImageSource imageSource) { } } + /** + * Checks if the provided ImageSource should not be requested through Fresco and instead loaded + * directly from the resources table. Fresco explicitly does not support a number of drawable + * types like VectorDrawable but they can still be mounted in the image hierarchy. + * + * @param imageSource + * @return drawable resource if Fresco cannot load the image, null otherwise + */ + private @Nullable Drawable getDrawableIfUnsupported(ImageSource imageSource) { + if (!ReactNativeFeatureFlags.loadVectorDrawablesOnImages()) { + return null; + } + String resourceName = imageSource.getSource(); + if (!imageSource.isResource() || resourceName == null) { + return null; + } + ResourceDrawableIdHelper drawableHelper = ResourceDrawableIdHelper.getInstance(); + boolean isVectorDrawable = drawableHelper.isVectorDrawable(getContext(), resourceName); + if (!isVectorDrawable) { + return null; + } + return drawableHelper.getResourceDrawable(getContext(), resourceName); + } + @Nullable private ResizeOptions getResizeOptions() { int width = Math.round((float) getWidth() * mResizeMultiplier); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ResourceDrawableIdHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ResourceDrawableIdHelper.kt index 7626b601713452..12f24b5aa89213 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ResourceDrawableIdHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ResourceDrawableIdHelper.kt @@ -8,10 +8,12 @@ package com.facebook.react.views.imagehelper import android.content.Context +import android.content.res.Resources import android.graphics.drawable.Drawable import android.net.Uri import androidx.core.content.res.ResourcesCompat import javax.annotation.concurrent.ThreadSafe +import org.xmlpull.v1.XmlPullParser /** Helper class for obtaining information about local images. */ @ThreadSafe @@ -61,6 +63,39 @@ public class ResourceDrawableIdHelper private constructor() { } } + public fun isVectorDrawable(context: Context, name: String): Boolean { + return getOpeningXmlTag(context, name) == "vector" + } + + /** + * If the provided resource name is a valid drawable resource and is an XML file, returns the root + * XML tag. Skips over the versioning/encoding header. Non-XML files and malformed XML files + * return null. + * + * For example, a vector drawable file would return "vector". + */ + private fun getOpeningXmlTag(context: Context, name: String): String? { + val resId = getResourceDrawableId(context, name).takeIf { it > 0 } ?: return null + return try { + val xmlParser = context.resources.getXml(resId) + xmlParser.use { + var parentTag: String? = null + var eventType = xmlParser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG) { + parentTag = xmlParser.name + break + } + eventType = xmlParser.next() + } + parentTag + } + } catch (e: Resources.NotFoundException) { + // Drawable image is not an XML file + null + } + } + public companion object { private const val LOCAL_RESOURCE_SCHEME = "res" private val resourceDrawableIdHelper: ResourceDrawableIdHelper = ResourceDrawableIdHelper() diff --git a/packages/rn-tester/android/app/src/main/res/drawable/ic_android.xml b/packages/rn-tester/android/app/src/main/res/drawable/ic_android.xml new file mode 100644 index 00000000000000..ad4f1531ac3113 --- /dev/null +++ b/packages/rn-tester/android/app/src/main/res/drawable/ic_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index 3a43e9a1a6483b..f73225338df8df 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -601,6 +601,23 @@ class OnPartialLoadExample extends React.Component< } } +type VectorDrawableExampleState = {||}; + +type VectorDrawableExampleProps = $ReadOnly<{||}>; + +class VectorDrawableExample extends React.Component< + VectorDrawableExampleProps, + VectorDrawableExampleState, +> { + render(): React.Node { + return ( + + + + ); + } +} + const fullImage: ImageSource = { uri: IMAGE2, }; @@ -1511,4 +1528,13 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'Vector Drawable', + description: + 'Demonstrating an example of loading a vector drawable asset by name', + render: function (): React.Node { + return ; + }, + platform: 'android', + }, ];