Skip to content

fix(android): clip TextureView/SurfaceView capture to scroll viewport#656

Open
hirvesh wants to merge 1 commit into
gre:masterfrom
hirvesh:fix/android-clip-textureview-to-scroll-viewport
Open

fix(android): clip TextureView/SurfaceView capture to scroll viewport#656
hirvesh wants to merge 1 commit into
gre:masterfrom
hirvesh:fix/android-clip-textureview-to-scroll-viewport

Conversation

@hirvesh
Copy link
Copy Markdown

@hirvesh hirvesh commented May 26, 2026

Problem

On Android, captures composite each TextureView/SurfaceView surface separately from the software view.draw() pass (those views render blank in software). That second blit applies the ancestor transform chain (applyTransformations) but no ancestor clip and no scroll offset.

So when a surface is larger than its scroll container — e.g. a wide @shopify/react-native-skia <Canvas> (or a GL view) inside a horizontal ScrollView — its full surface is drawn, bounded only by the output bitmap edge. It bleeds past the visible viewport, overrunning sibling padding / rounded corners. Scrolled content is also captured from offset 0 rather than the visible window.

iOS is unaffected — drawViewHierarchyInRect: already renders through the scroll view's clipsToBounds.

Fix

  • Clip each TextureView/SurfaceView blit to the on-screen frame of its scrolling ancestors (ScrollView / HorizontalScrollView) before drawing.
  • Subtract each parent's scrollX/scrollY in applyTransformations so the captured window matches what's visible.

Notes / limitations

  • The clip frame is computed as an axis-aligned rect from layout offsets, translations and scroll positions. Rotation/scale on ancestors above the scroll container isn't accounted for (uncommon for a capture root) — kept deliberately simple because applying a container's own transform to the clip rect breaks the common RN flipped-ScrollView trick (double scaleX:-1), where content lands back in the untransformed frame.
  • The scroll-offset change affects any capture of scrolled content: previously captured from offset 0, now captures the visible window (a behavior improvement, flagged in case anyone relied on the old behavior).
  • Verified against the overflow repro (Skia grid in a horizontal ScrollView) on a physical Pixel 9a, new architecture. Happy to add an example screen + Android reference snapshot to the Detox suite if you'd like — wanted to confirm the approach first.

On Android, captures composite each TextureView/SurfaceView surface
separately from the software view.draw() pass. That blit applied the
ancestor transform chain but no ancestor clip and no scroll offset, so a
surface larger than its scroll container (e.g. a Skia/GL canvas inside a
horizontal ScrollView) bled past the viewport to the output bitmap edge,
and scrolled content was captured from offset 0.

- Clip each TextureView/SurfaceView blit to the on-screen frame of its
  scrolling ancestors (ScrollView / HorizontalScrollView).
- Subtract each parent's scroll in applyTransformations so the captured
  window matches what is visible on screen.

iOS is unaffected: drawViewHierarchyInRect already honors the scroll
view's clip.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Android capture pipeline to better match on-screen rendering when TextureView/SurfaceView content is embedded inside scrolled containers, preventing oversized surfaces from bleeding outside the visible scroll viewport and aligning captures with the current scroll position.

Changes:

  • Clip TextureView / SurfaceView overlay blits to the viewport of scrolling ancestors (ScrollView / HorizontalScrollView).
  • Update the transformation walk to subtract each parent’s scrollX/scrollY so overlay positioning matches what’s visible on screen.
  • Add helpers to compute ancestor offsets in the capture-root coordinate space.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +901 to +912
// A child is drawn by Android at (getLeft - parent.scrollX), but this
// walk previously used getLeft only, so content inside a scrolled
// container was positioned at scroll offset 0. Track the parent of each
// view and subtract its scroll so the captured window matches what is
// actually visible on screen.
View parent = root;
for (final View v : ms) {
c.save();

// apply each view transformations, so each child will be affected by them
final float dx = v.getLeft() + ((v != child) ? v.getPaddingLeft() : 0) + v.getTranslationX();
final float dy = v.getTop() + ((v != child) ? v.getPaddingTop() : 0) + v.getTranslationY();
final float dx = v.getLeft() - parent.getScrollX() + ((v != child) ? v.getPaddingLeft() : 0) + v.getTranslationX();
final float dy = v.getTop() - parent.getScrollY() + ((v != child) ? v.getPaddingTop() : 0) + v.getTranslationY();
Comment on lines +948 to +952
if (parent == root) break;
if (parent instanceof HorizontalScrollView || parent instanceof ScrollView) {
final float[] off = offsetInRoot(root, parent);
c.clipRect(off[0], off[1], off[0] + parent.getWidth(), off[1] + parent.getHeight());
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants