From 9caff2df7398343d18caa8204d3ac1e859ea22ee Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Mon, 27 Apr 2026 07:32:43 -0700 Subject: [PATCH] Fix PixelCopy snapshot for partially off-screen views (#56608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ## Changelog: [Internal] [Fixed] - Fix PixelCopy snapshot for partially off-screen PixelCopy captures only the visible portion of the window surface. When a view is partially off-screen, the capture rect extends beyond the window bounds, resulting in a bitmap where only the visible region has content. This partial bitmap then gets stretched to fill the full-size pseudo-element, causing visual distortion. Fix by clamping the PixelCopy rect to the window bounds and compositing the clamped capture into a full-size bitmap at the correct offset. The off-screen portions remain transparent instead of being stretched. If the view is entirely off-screen, skip capture entirely — the pseudo-element will have no snapshot applied (didMountItems skips tags without a captured bitmap). Reviewed By: javache Differential Revision: D102360642 --- .../fabric/ViewTransitionSnapshotManager.kt | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt index efd963165358..6c21fbc4d454 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/ViewTransitionSnapshotManager.kt @@ -103,28 +103,54 @@ internal class ViewTransitionSnapshotManager( @RequiresApi(Build.VERSION_CODES.O) private fun captureHardwareBitmap(view: View, reactTag: Int, window: Window) { - val bitmap = createBitmap(view.width, view.height) val location = IntArray(2) view.getLocationInWindow(location) - val rect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height) + + // The view's rect in window coordinates. + val viewRect = + Rect(location[0], location[1], location[0] + view.width, location[1] + view.height) + + // Clamp to window bounds — PixelCopy only captures what's visible on the + // window surface. Without clamping, off-screen portions are black/empty + // and the partial result gets stretched to fill the pseudo-element. + val windowWidth = window.decorView.width + val windowHeight = window.decorView.height + val clampedRect = + Rect( + viewRect.left.coerceAtLeast(0), + viewRect.top.coerceAtLeast(0), + viewRect.right.coerceAtMost(windowWidth), + viewRect.bottom.coerceAtMost(windowHeight), + ) + + if (clampedRect.isEmpty) { + // Entirely off-screen — nothing to capture. + return + } + + val clampedBitmap = createBitmap(clampedRect.width(), clampedRect.height()) + // Offset of the clamped region within the full view. + val offsetX = clampedRect.left - viewRect.left + val offsetY = clampedRect.top - viewRect.top + // PixelCopy callback is posted to mainHandler, so onBitmapCaptured may run after // setViewSnapshot has already recorded the target tag for this source tag. try { PixelCopy.request( window, - rect, - bitmap, + clampedRect, + clampedBitmap, { copyResult -> if (copyResult == PixelCopy.SUCCESS) { - val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false) - if (hwBitmap != null) { - bitmap.recycle() - onBitmapCaptured(reactTag, hwBitmap) - } else { - onBitmapCaptured(reactTag, bitmap) - } + // Compose the clamped capture into a full-size bitmap at the + // correct offset so it aligns with the pseudo-element's bounds. + val fullBitmap = createBitmap(view.width, view.height) + Canvas(fullBitmap) + .drawBitmap(clampedBitmap, offsetX.toFloat(), offsetY.toFloat(), null) + clampedBitmap.recycle() + onBitmapCaptured(reactTag, fullBitmap) } else { - bitmap.recycle() + clampedBitmap.recycle() onBitmapCaptured(reactTag, captureSoftwareBitmap(view)) } }, @@ -133,7 +159,7 @@ internal class ViewTransitionSnapshotManager( } catch (e: IllegalArgumentException) { // Window surface may have been destroyed (e.g., device idle/sleep). // Fall back to software rendering. - bitmap.recycle() + clampedBitmap.recycle() onBitmapCaptured(reactTag, captureSoftwareBitmap(view)) } }