From 5b9d88167062d19b3482d882f80d533af1481559 Mon Sep 17 00:00:00 2001 From: Marcin Szalski Date: Fri, 16 Oct 2026 09:47:40 +0200 Subject: [PATCH] feat: add backdrop-filter android support --- .../react/uimanager/BaseViewManager.java | 9 ++ .../uimanager/BaseViewManagerDelegate.kt | 2 + .../facebook/react/uimanager/FilterHelper.kt | 13 ++- .../com/facebook/react/uimanager/ViewProps.kt | 1 + .../react/views/view/BackdropFilterHelper.kt | 110 ++++++++++++++++++ .../react/views/view/ReactViewGroup.kt | 68 +++++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../components/view/BaseViewProps.cpp | 7 ++ .../renderer/components/view/BaseViewProps.h | 3 + 9 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/BackdropFilterHelper.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 55daec06a8c6..14d21031a984 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -132,6 +132,7 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setTag(R.id.use_hardware_layer, null); view.setTag(R.id.filter, null); + view.setTag(R.id.backdrop_filter, null); view.setTag(R.id.mix_blend_mode, null); LayerEffectsHelper.apply(view, null, null); @@ -242,6 +243,14 @@ public void setFilter(@NonNull T view, @Nullable ReadableArray filter) { view.setTag(R.id.filter, filter); } + @ReactProp(name = ViewProps.BACKDROP_FILTER, customType = "Filter") + public void setBackdropFilter(@NonNull T view, @Nullable ReadableArray backdropFilter) { + view.setTag(R.id.backdrop_filter, backdropFilter); + if (view.getParent() instanceof View) { + ((View) view.getParent()).invalidate(); + } + } + @ReactProp(name = ViewProps.MIX_BLEND_MODE) public void setMixBlendMode(@NonNull T view, @Nullable String mixBlendMode) { view.setTag(R.id.mix_blend_mode, BlendModeHelper.parseMixBlendMode(mixBlendMode)); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt index c1d63abe3a7e..9d7dbcfbb694 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt @@ -86,6 +86,8 @@ public abstract class BaseViewManagerDelegate< ViewProps.FILTER -> mViewManager.setFilter(view, value as ReadableArray?) + ViewProps.BACKDROP_FILTER -> mViewManager.setBackdropFilter(view, value as ReadableArray?) + ViewProps.MIX_BLEND_MODE -> mViewManager.setMixBlendMode(view, value as String?) ViewProps.SHADOW_COLOR -> diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.kt index 7eb0f4378e6f..0c3cccf9ff22 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/FilterHelper.kt @@ -117,15 +117,18 @@ internal object FilterHelper { // https://www.w3.org/TR/filter-effects-1/#blurEquivalent fun createBlurEffect(sigma: Float, chainedEffects: RenderEffect? = null): RenderEffect? { - if (sigma <= 0.5) { + // RenderEffect.createBlurEffect takes sigma (standard deviation) directly — it passes + // through to Skia's SkImageFilters::Blur(sigmaX, sigmaY). Convert dp→px here; do NOT + // apply sigmaToRadius (that formula is for HWUI's box-filter BlurMaskFilter, not RenderEffect). + val pxSigma = PixelUtil.toPixelFromDIP(sigma) + if (pxSigma <= 0.5f) { return null } - val radius: Float = sigmaToRadius(sigma) return if (chainedEffects == null) { - RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.DECAL) + RenderEffect.createBlurEffect(pxSigma, pxSigma, Shader.TileMode.DECAL) } else { - RenderEffect.createBlurEffect(radius, radius, chainedEffects, Shader.TileMode.DECAL) + RenderEffect.createBlurEffect(pxSigma, pxSigma, chainedEffects, Shader.TileMode.DECAL) } } @@ -199,7 +202,7 @@ internal object FilterHelper { val color: Int = if (filterValues.hasKey("color")) filterValues.getInt("color") else Color.BLACK val radius: Float = if (filterValues.hasKey("standardDeviation")) - sigmaToRadius(filterValues.getDouble("standardDeviation").toFloat()) + PixelUtil.toPixelFromDIP(filterValues.getDouble("standardDeviation").toFloat()) else 0f return createDropShadowEffect(offsetX, offsetY, radius, color, chainedEffects) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index 1d0a305b2f2b..708773b090e2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -146,6 +146,7 @@ public object ViewProps { public const val BORDER_START_COLOR: String = "borderStartColor" public const val BORDER_END_COLOR: String = "borderEndColor" public const val BOX_SHADOW: String = "boxShadow" + public const val BACKDROP_FILTER: String = "backdropFilter" public const val FILTER: String = "filter" public const val MIX_BLEND_MODE: String = "mixBlendMode" public const val OUTLINE_COLOR: String = "outlineColor" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/BackdropFilterHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/BackdropFilterHelper.kt new file mode 100644 index 000000000000..8029ff9305a7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/BackdropFilterHelper.kt @@ -0,0 +1,110 @@ +/* + * 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.views.view + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RenderEffect +import android.graphics.RenderNode +import android.os.Build +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.uimanager.FilterHelper + +/** + * Helper object for applying CSS backdrop-filter effects on Android. + * + * Applies filter effects to the composited backdrop (the pixels behind a view) captured as a + * [Bitmap] snapshot. Color-matrix filters (brightness, contrast, grayscale, hue-rotate, invert, + * opacity, saturate, sepia) work on all supported API levels. Blur and drop-shadow require API 31+ + * via [RenderEffect] drawn into the hardware-accelerated canvas via [RenderNode]. + */ +@SuppressLint("UseRequiresApi") +internal object BackdropFilterHelper { + + /** + * Draws a filtered region of [source] directly onto [canvas] at the child's position. + * + * @param canvas Hardware-accelerated canvas of the parent [ReactViewGroup]. + * @param source Full-size backdrop snapshot bitmap (same dimensions as the parent view). + * @param destLeft Left coordinate of the child in parent space (canvas destination). + * @param destTop Top coordinate of the child in parent space (canvas destination). + * @param srcLeft Clamped left bound of the crop region within [source]. + * @param srcTop Clamped top bound of the crop region within [source]. + * @param srcRight Clamped right bound of the crop region within [source]. + * @param srcBottom Clamped bottom bound of the crop region within [source]. + * @param filters ReadableArray of filter descriptors as produced by processFilter.js. + */ + fun drawFilteredBackdrop( + canvas: Canvas, + source: Bitmap, + destLeft: Int, + destTop: Int, + srcLeft: Int, + srcTop: Int, + srcRight: Int, + srcBottom: Int, + filters: ReadableArray, + ) { + val cropWidth = srcRight - srcLeft + val cropHeight = srcBottom - srcTop + if (cropWidth <= 0 || cropHeight <= 0) return + + val cropped = Bitmap.createBitmap(source, srcLeft, srcTop, cropWidth, cropHeight) + try { + when { + FilterHelper.isOnlyColorMatrixFilters(filters) -> { + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.colorFilter = FilterHelper.parseColorMatrixFilters(filters) + canvas.drawBitmap(cropped, destLeft.toFloat(), destTop.toFloat(), paint) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + drawWithRenderEffect(canvas, cropped, destLeft.toFloat(), destTop.toFloat(), filters) + } + else -> { + // Pre-API 31: blur and drop-shadow are not supported; draw the backdrop unfiltered so + // at least the element's transparency is composited correctly. + canvas.drawBitmap(cropped, destLeft.toFloat(), destTop.toFloat(), null) + } + } + } finally { + cropped.recycle() + } + } + + @TargetApi(Build.VERSION_CODES.S) + private fun drawWithRenderEffect( + canvas: Canvas, + source: Bitmap, + destLeft: Float, + destTop: Float, + filters: ReadableArray, + ) { + val renderEffect: RenderEffect = + FilterHelper.parseFilters(filters) + ?: run { + canvas.drawBitmap(source, destLeft, destTop, null) + return + } + + val renderNode = RenderNode("backdropFilter") + renderNode.setPosition(0, 0, source.width, source.height) + renderNode.setRenderEffect(renderEffect) + + val recordCanvas = renderNode.beginRecording() + recordCanvas.drawBitmap(source, 0f, 0f, null) + renderNode.endRecording() + + canvas.save() + canvas.translate(destLeft, destTop) + canvas.drawRenderNode(renderNode) + canvas.restore() + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 363f1336d000..87e9578f9c1b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -13,8 +13,10 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context import android.content.res.Configuration +import android.graphics.Bitmap import android.graphics.BlendMode import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint import android.graphics.Rect import android.graphics.drawable.Drawable @@ -29,6 +31,7 @@ import com.facebook.react.R import com.facebook.react.bridge.ReactNoCrashSoftException import com.facebook.react.bridge.ReactSoftExceptionLogger import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.UiThreadUtil.assertOnUiThread import com.facebook.react.bridge.UiThreadUtil.runOnUiThread @@ -988,11 +991,45 @@ public open class ReactViewGroup public constructor(context: Context?) : } } + // Snapshot of the parent's composited content (background + previous siblings) used to apply + // backdrop-filter effects on children that request them. + private var backdropBitmap: Bitmap? = null + private var backdropCanvas: Canvas? = null + private var hasBackdropFilterChild = false + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + backdropBitmap?.recycle() + backdropBitmap = null + backdropCanvas = null + } + override fun dispatchDraw(canvas: Canvas) { if (_overflow != Overflow.VISIBLE || getTag(R.id.filter) != null) { clipToPaddingBox(this, canvas) } + + hasBackdropFilterChild = + (0 until childCount).any { getChildAt(it).getTag(R.id.backdrop_filter) != null } + + if (hasBackdropFilterChild && width > 0 && height > 0) { + val bmp = + backdropBitmap?.takeIf { it.width == width && it.height == height } + ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { + backdropBitmap?.recycle() + backdropBitmap = it + } + bmp.eraseColor(Color.WHITE) + val snapCanvas = Canvas(bmp) + backdropCanvas = snapCanvas + background?.draw(snapCanvas) + } + super.dispatchDraw(canvas) + + if (hasBackdropFilterChild) { + backdropCanvas = null + } } override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean { @@ -1002,6 +1039,31 @@ public open class ReactViewGroup public constructor(context: Context?) : enableZ(canvas, true) } + val backdropFilter: ReadableArray? = + if (hasBackdropFilterChild) child.getTag(R.id.backdrop_filter) as? ReadableArray else null + val snapBitmap = backdropBitmap + if (backdropFilter != null && snapBitmap != null) { + val childLeft = child.left + val childTop = child.top + val srcLeft = childLeft.coerceAtLeast(0).coerceAtMost(snapBitmap.width) + val srcTop = childTop.coerceAtLeast(0).coerceAtMost(snapBitmap.height) + val srcRight = child.right.coerceAtLeast(0).coerceAtMost(snapBitmap.width) + val srcBottom = child.bottom.coerceAtLeast(0).coerceAtMost(snapBitmap.height) + if (srcRight > srcLeft && srcBottom > srcTop) { + BackdropFilterHelper.drawFilteredBackdrop( + canvas, + snapBitmap, + childLeft, + childTop, + srcLeft, + srcTop, + srcRight, + srcBottom, + backdropFilter, + ) + } + } + var mixBlendMode: BlendMode? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && needsIsolatedLayer(this)) { mixBlendMode = child.getTag(R.id.mix_blend_mode) as? BlendMode @@ -1025,6 +1087,12 @@ public open class ReactViewGroup public constructor(context: Context?) : canvas.restore() } + // Update snapshot so subsequent backdrop-filter siblings see this child's pixels. + val snapCanvas = backdropCanvas + if (hasBackdropFilterChild && snapCanvas != null && backdropFilter == null) { + super.drawChild(snapCanvas, child, drawingTime) + } + if (drawWithZ) { enableZ(canvas, false) } diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index d03b719892c1..0f865c9c32ea 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -80,6 +80,9 @@ + + + diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 713ab1470fd0..0dab5a8c9e6d 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -203,6 +203,12 @@ BaseViewProps::BaseViewProps( {})), filter( convertRawProp(context, rawProps, "filter", sourceProps.filter, {})), + backdropFilter(convertRawProp( + context, + rawProps, + "backdropFilter", + sourceProps.backdropFilter, + {})), backgroundImage(convertRawProp( context, rawProps, @@ -390,6 +396,7 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(outlineStyle); RAW_SET_PROP_SWITCH_CASE_BASIC(outlineWidth); RAW_SET_PROP_SWITCH_CASE_BASIC(filter); + RAW_SET_PROP_SWITCH_CASE_BASIC(backdropFilter); RAW_SET_PROP_SWITCH_CASE_BASIC(boxShadow); RAW_SET_PROP_SWITCH_CASE_BASIC(mixBlendMode); // events field diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index b72c5f944f63..fbde3d8d2524 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -74,6 +74,9 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { // Filter std::vector filter{}; + // Backdrop Filter + std::vector backdropFilter{}; + // Background Image std::vector backgroundImage{};