From 7c66e5ea0869dc490b3a26b5f312ec3287ba1757 Mon Sep 17 00:00:00 2001 From: Joe Vilches Date: Wed, 13 Mar 2024 13:14:14 -0700 Subject: [PATCH 1/2] [skip ci] Add FilterHelper to get all RenderEffects needed to apply filters (#43357) Summary: This diff adds a class to get the needed RenderEffects to support CSS filters on Android. This diff does not add any of the plumbing for it to actually work, that comes in the next diff, but I figured this was complicated and isolated enough to be on its own. Note that I did not add blur or drop shadow as those are a bit more involved and I plan on adding them later. Changelog: [Internal] Reviewed By: javache Differential Revision: D54603892 --- .../react/uimanager/FilterHelper.java | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.java diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.java new file mode 100644 index 000000000000..eeb6aba25d83 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager; + +import android.annotation.TargetApi; +import android.graphics.ColorFilter; +import android.graphics.ColorMatrix; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.RenderEffect; +import android.graphics.Shader; +import androidx.annotation.Nullable; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; + +@TargetApi(31) +class FilterHelper { + static @Nullable RenderEffect parseFilters(@Nullable ReadableArray filters) { + if (filters == null) { + return null; + } + + RenderEffect chainedEffects = null; + for (int i = 0; i < filters.size(); i++) { + ReadableMap filter = filters.getMap(i); + @Nullable String filterName = filter.getString("name"); + if (filterName == null) { + continue; + } + + switch (filterName) { + case "brightness": + float brightnessAmount = (float) filter.getDouble("amount"); + ColorFilter brightnessFilter = + new ColorMatrixColorFilter(getBrightnessColorMatrix(brightnessAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, brightnessFilter); + break; + case "contrast": + float contrastAmount = (float) filter.getDouble("amount"); + ColorFilter contrastFilter = + new ColorMatrixColorFilter(getContrastColorMatrix(contrastAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, contrastFilter); + break; + case "grayscale": + float grayscaleAmount = (float) filter.getDouble("amount"); + ColorFilter grayscaleFilter = + new ColorMatrixColorFilter(getGrayscaleColorMatrix(grayscaleAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, grayscaleFilter); + break; + case "sepia": + float sepiaAmount = (float) filter.getDouble("amount"); + ColorFilter sepiaFilter = new ColorMatrixColorFilter(getSepiaColorMatrix(sepiaAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, sepiaFilter); + break; + case "saturate": + float saturateAmount = (float) filter.getDouble("amount"); + ColorFilter saturateFilter = + new ColorMatrixColorFilter(getSaturateColorMatrix(saturateAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, saturateFilter); + break; + case "hue-rotate": + float hueRotateAmount = (float) filter.getDouble("amount"); + ColorFilter hueRotateFilter = + new ColorMatrixColorFilter(getHueRotateColorMatrix(hueRotateAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, hueRotateFilter); + break; + case "invert": + float invertAmount = (float) filter.getDouble("amount"); + ColorFilter invertColorFilter = + new ColorMatrixColorFilter(getInvertColorMatrix(invertAmount)); + chainedEffects = chainColorFilterEffect(chainedEffects, invertColorFilter); + break; + case "blur": + float blurAmount = (float) filter.getDouble("amount"); + chainedEffects = chainBlurFilterEffect(chainedEffects, blurAmount); + break; + default: + throw new IllegalArgumentException("Invalid filter name: " + filterName); + } + } + + return chainedEffects; + } + + // https://www.w3.org/TR/filter-effects-1/#blurEquivalent + private static RenderEffect chainBlurFilterEffect( + @Nullable RenderEffect chainedEffects, float std) { + // Android takes blur amount as a radius while web takes a sigma. This value + // is used under the hood to convert between them on Android. + float sigmaToRadiusRatio = 0.57735f; + float radius = (std - 0.5f) / sigmaToRadiusRatio; + float scaledRadius = PixelUtil.toPixelFromDIP(radius); + + return chainedEffects == null + ? RenderEffect.createBlurEffect(scaledRadius, scaledRadius, Shader.TileMode.DECAL) + : RenderEffect.createBlurEffect( + scaledRadius, scaledRadius, chainedEffects, Shader.TileMode.DECAL); + } + + private static RenderEffect chainColorFilterEffect( + @Nullable RenderEffect chainedEffects, ColorFilter colorFilter) { + return chainedEffects == null + ? RenderEffect.createColorFilterEffect(colorFilter) + : RenderEffect.createColorFilterEffect(colorFilter, chainedEffects); + } + + // https://www.w3.org/TR/filter-effects-1/#brightnessEquivalent + private static ColorMatrix getBrightnessColorMatrix(float amount) { + ColorMatrix matrix = new ColorMatrix(); + matrix.setScale(amount, amount, amount, 1); + + return matrix; + } + + // https://www.w3.org/TR/filter-effects-1/#contrastEquivalent + private static ColorMatrix getContrastColorMatrix(float amount) { + // Multiply by 255 as Android operates in [0, 255] while the spec operates in [0, 1]. + // This really only matters if there is an intercept that needs to be added + float intercept = 255 * (-(amount / 2.0f) + 0.5f); + + float[] colorMatrix = { + amount, 0, 0, 0, intercept, // + 0, amount, 0, 0, intercept, // + 0, 0, amount, 0, intercept, // + 0, 0, 0, 1, 0 + }; + + return new ColorMatrix(colorMatrix); + } + + // https://www.w3.org/TR/filter-effects-1/#grayscaleEquivalent + private static float[] getGrayscaleColorMatrix(float amount) { + float inverseAmount = 1 - amount; + float[] colorMatrix = { + 0.2126f + 0.7874f * inverseAmount, + 0.7152f - 0.7152f * inverseAmount, + 0.0722f - 0.0722f * inverseAmount, + 0, + 0, + 0.2126f - 0.2126f * inverseAmount, + 0.7152f + 0.2848f * inverseAmount, + 0.0722f - 0.0722f * inverseAmount, + 0, + 0, + 0.2126f - 0.2126f * inverseAmount, + 0.7152f - 0.7152f * inverseAmount, + 0.0722f + 0.9278f * inverseAmount, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + }; + + return colorMatrix; + } + + // https://www.w3.org/TR/filter-effects-1/#sepiaEquivalent + private static float[] getSepiaColorMatrix(float amount) { + float inverseAmount = 1 - amount; + float[] colorMatrix = { + 0.393f + 0.607f * inverseAmount, + 0.769f - 0.769f * inverseAmount, + 0.189f - 0.189f * inverseAmount, + 0, + 0, + 0.349f - 0.349f * inverseAmount, + 0.686f + 0.314f * inverseAmount, + 0.168f - 0.168f * inverseAmount, + 0, + 0, + 0.272f - 0.272f * inverseAmount, + 0.534f - 0.534f * inverseAmount, + 0.131f + 0.869f * inverseAmount, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + }; + + return colorMatrix; + } + + // https://www.w3.org/TR/filter-effects-1/#saturateEquivalent + private static ColorMatrix getSaturateColorMatrix(float amount) { + ColorMatrix matrix = new ColorMatrix(); + matrix.setSaturation(amount); + + return matrix; + } + + // https://www.w3.org/TR/filter-effects-1/#huerotateEquivalent + private static float[] getHueRotateColorMatrix(float amount) { + double amountRads = Math.toRadians(amount); + float cos = (float) Math.cos(amountRads); + float sin = (float) Math.sin(amountRads); + float[] matrix = { + 0.213f + 0.787f * cos - 0.213f * sin, + 0.715f - 0.715f * cos - 0.715f * sin, + 0.072f - 0.072f * cos + 0.928f * sin, + 0, + 0, + 0.213f - 0.213f * cos + 0.143f * sin, + 0.715f + 0.285f * cos + 0.140f * sin, + 0.072f - 0.072f * cos - 0.283f * sin, + 0, + 0, + 0.213f - 0.213f * cos - 0.787f * sin, + 0.715f - 0.715f * cos + 0.715f * sin, + 0.072f + 0.928f * cos + 0.072f * sin, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + }; + + return matrix; + } + + // https://www.w3.org/TR/filter-effects-1/#invertEquivalent + private static float[] getInvertColorMatrix(float amount) { + float slope = 1 - 2 * amount; + float intercept = amount * 255; + float[] matrix = { + slope, 0, 0, 0, intercept, // + 0, slope, 0, 0, intercept, // + 0, 0, slope, 0, intercept, // + 0, 0, 0, 1, 0 + }; + + return matrix; + } +} From 811e933b0c3429c3b83d0309c607aea90cd1b1b3 Mon Sep 17 00:00:00 2001 From: Joe Vilches Date: Wed, 13 Mar 2024 13:14:14 -0700 Subject: [PATCH 2/2] Fix clipping children in scroll views Summary: While working on filters I noticed that my blurred image was cropped on the scrollview. Setting `overflow: visible` for `contentContainerStyle` did not change anything. Turns out we do not call `setClipChildren(false)` like we do with [ReactViewGroup](https://www.internalfb.com/code/fbsource/[93517723586c]/xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java?lines=145). Changelog: [Internal] Differential Revision: D54640466 --- .../facebook/react/views/scroll/ReactHorizontalScrollView.java | 1 + .../java/com/facebook/react/views/scroll/ReactScrollView.java | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 7e52edb5a2c7..3ec4c5c96e4e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -139,6 +139,7 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe : ViewCompat.LAYOUT_DIRECTION_LTR); setOnHierarchyChangeListener(this); + setClipChildren(false); } public boolean getScrollEnabled() { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 50fc9b0bc43c..26950b2ea9f9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -131,6 +131,7 @@ public ReactScrollView(Context context, @Nullable FpsListener fpsListener) { mScroller = getOverScrollerFromParent(); setOnHierarchyChangeListener(this); setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY); + setClipChildren(false); ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); }