Skip to content

Align Android rendering with React Native Skia's ViewScreenshotService#616

Draft
gre wants to merge 1 commit intomasterfrom
fix/android-skia-rendering
Draft

Align Android rendering with React Native Skia's ViewScreenshotService#616
gre wants to merge 1 commit intomasterfrom
fix/android-skia-rendering

Conversation

@gre
Copy link
Copy Markdown
Owner

@gre gre commented Mar 29, 2026

Summary

Closes #494 — Aligns Android view capture with React Native Skia's ViewScreenshotService as proposed by @wcandillon.

Replaces the flat view.draw() + getAllChildren() traversal with recursive per-view rendering that correctly handles:

  • CSS transforms — uses view.getMatrix() to capture rotation, scale, skew, perspective (old code only handled rotation + scale individually)
  • Opacity — tracks combined opacity through the view hierarchy via saveLayerAlpha
  • z-index — calls ReactViewGroup.dispatchOverflowDraw() for correct draw ordering
  • ScrollView clipping — clips both ScrollView and HorizontalScrollView
  • SVG views — renders react-native-svg views as opaque leaf nodes
  • TextureView / SurfaceView — preserved with proper transform + opacity support

Performance optimizations (beyond Skia reference)

  • Cache dispatchOverflowDraw reflection lookup in a static field (was per-call)
  • Skip saveLayerAlpha when opacity is 1.0 (common case)
  • Use bounded RectF instead of null for layer rects
  • Reuse Matrix object instead of allocating per view
  • Consolidate duplicate getBitmapForScreenshot / getExactBitmapForScreenshot into one method
  • Extract drawBitmapWithTransform helper (was copy-pasted 4×)
  • Reset Paint.alpha after bitmap draws to prevent state leaking

Test plan

  • Build Android example app (npm run android from example/)
  • Verify basic view capture works
  • Test with rotated/scaled views (CSS transforms)
  • Test with ScrollView content
  • Test with opacity prop on nested views
  • Run iOS E2E tests (no iOS changes, regression check)

🤖 Generated with Claude Code

#494)

Replace the flat view.draw() + getAllChildren() traversal with a recursive
per-view rendering approach adapted from Skia's ViewScreenshotService.

This fixes:
- CSS transforms (rotation, scale, skew, perspective) via view.getMatrix()
- Opacity tracking through the view hierarchy
- z-index ordering via ReactViewGroup.dispatchOverflowDraw()
- ScrollView and HorizontalScrollView clipping
- SVG views rendered as opaque leaf nodes

Performance: cache reflection lookup, skip saveLayerAlpha at full opacity,
reuse Matrix object, use bounded layer rects, consolidate bitmap pool methods.

Closes #494

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 29, 2026 12:03
@wcandillon
Copy link
Copy Markdown

hey @gre 👋

I'm not super happy with our implementation is it possible this is way better now? Let me know if we need to collaborate on this. I haven't looked at this issue in a while but would love to have a look.

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

Aligns Android view capture rendering with React Native Skia’s ViewScreenshotService approach to better match on-screen output for transforms/opacity/clipping, while keeping TextureView/SurfaceView capture support.

Changes:

  • Replaces flat view.draw() + child traversal with recursive per-view rendering using view.getMatrix().
  • Adds overflow-related handling via ReactViewGroup.dispatchOverflowDraw() and ScrollView clipping.
  • Refactors TextureView/SurfaceView bitmap drawing into helpers and simplifies bitmap reuse logic.

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

Comment on lines +535 to +537
c.translate(
view.getLeft() + view.getPaddingLeft() - view.getScrollX(),
view.getTop() + view.getPaddingTop() - view.getScrollY());
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

applyTransformations() is translating by view padding and subtracting view scroll for every view before drawing. That shifts the view’s own rendering (e.g., backgrounds/text) by its padding and can double-apply scroll (especially for ScrollView where you also clip using scrollX/Y). Consider translating by layout position only (left/top) + concat(view.getMatrix()), and apply padding/scroll only when descending into a ViewGroup’s children (similar to ViewGroup.dispatchDraw).

Suggested change
c.translate(
view.getLeft() + view.getPaddingLeft() - view.getScrollX(),
view.getTop() + view.getPaddingTop() - view.getScrollY());
// Translate canvas into the view's layout position; padding and scroll should be
// handled when drawing child views, not when drawing the view itself.
c.translate(view.getLeft(), view.getTop());

Copilot uses AI. Check for mistakes.
Comment on lines +424 to +425
drawBackgroundIfPresent(canvas, view, combinedOpacity);
drawChildren(canvas, (ViewGroup) view, paint, combinedOpacity);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

Opacity is currently propagated by multiplying into combinedOpacity and passing it down to descendants, but ViewGroup rendering is not wrapped in a layer. This changes blending for overlapping children under a partially transparent parent (parent alpha gets applied per-child instead of once to the composited subtree). To match Android rendering, when a view’s combined alpha < 1 you likely need to saveLayerAlpha for the whole view (background + children) and then render descendants without additionally multiplying by the parent’s alpha.

Suggested change
drawBackgroundIfPresent(canvas, view, combinedOpacity);
drawChildren(canvas, (ViewGroup) view, paint, combinedOpacity);
if (combinedOpacity < 1.0f) {
int alpha = Math.round(combinedOpacity * 255);
RectF bounds = new RectF(0, 0, view.getWidth(), view.getHeight());
canvas.saveLayerAlpha(bounds, alpha);
// Within this layer, descendants should render with full parent opacity.
drawBackgroundIfPresent(canvas, view, 1.0f);
drawChildren(canvas, (ViewGroup) view, paint, 1.0f);
canvas.restore();
} else {
// Fully opaque parent: no need for a separate layer, but children
// should still not have additional parent opacity multiplied in.
drawBackgroundIfPresent(canvas, view, 1.0f);
drawChildren(canvas, (ViewGroup) view, paint, 1.0f);
}

Copilot uses AI. Check for mistakes.
Comment on lines +433 to +443
private static void drawBackgroundIfPresent(Canvas canvas, View view, float opacity) {
Drawable bg = view.getBackground();
if (bg != null) {
int alpha = Math.round(opacity * 255);
if (alpha < 255) {
canvas.saveLayerAlpha(new RectF(0, 0, view.getWidth(), view.getHeight()), alpha);
bg.draw(canvas);
canvas.restore();
} else {
bg.draw(canvas);
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

drawBackgroundIfPresent() calls bg.draw(canvas) without ensuring the drawable bounds match the view size. View.draw() normally sets background bounds every draw; without doing it here backgrounds can render with stale/empty bounds. Set the background bounds (e.g., 0..width/height) before drawing.

Copilot uses AI. Check for mistakes.
Comment on lines +447 to +467
private void drawChildren(Canvas canvas, ViewGroup group, Paint paint, float parentOpacity) {
if (sDispatchOverflowDraw != null && group instanceof ReactViewGroup) {
try {
sDispatchOverflowDraw.invoke(group, canvas);
} catch (Exception e) {
Log.e(TAG, "couldn't invoke dispatchOverflowDraw() on ReactViewGroup", e);
}
}
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child.getVisibility() != VISIBLE) continue;

if (child instanceof TextureView) {
drawTextureView(canvas, (TextureView) child, paint, parentOpacity);
} else if (child instanceof SurfaceView) {
if (handleGLSurfaceView) {
drawSurfaceView(canvas, (SurfaceView) child, paint, parentOpacity);
}
} else {
renderViewToCanvas(canvas, child, paint, parentOpacity);
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

Child rendering order here always uses getChildAt(i). For React Native z-index, ViewGroups typically rely on custom drawing order (childrenDrawingOrderEnabled + getChildDrawingOrder). dispatchOverflowDraw() is for overflow clipping (not z-index), so this loop still won’t respect z-index ordering. Consider iterating using getChildDrawingOrder() when enabled, or otherwise mirroring ViewGroup’s draw order logic.

Copilot uses AI. Check for mistakes.
Comment on lines +459 to +466
if (child instanceof TextureView) {
drawTextureView(canvas, (TextureView) child, paint, parentOpacity);
} else if (child instanceof SurfaceView) {
if (handleGLSurfaceView) {
drawSurfaceView(canvas, (SurfaceView) child, paint, parentOpacity);
}
} else {
renderViewToCanvas(canvas, child, paint, parentOpacity);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

TextureView/SurfaceView opacity currently ignores the child view’s own alpha: drawTextureView/drawSurfaceView receive the parentOpacity, but don’t multiply by tv.getAlpha()/sv.getAlpha(). This makes semi-transparent TextureView/SurfaceView render as fully opaque relative to siblings. Pass parentOpacity * child.getAlpha() (or incorporate view.getAlpha() inside drawBitmapWithTransform).

Suggested change
if (child instanceof TextureView) {
drawTextureView(canvas, (TextureView) child, paint, parentOpacity);
} else if (child instanceof SurfaceView) {
if (handleGLSurfaceView) {
drawSurfaceView(canvas, (SurfaceView) child, paint, parentOpacity);
}
} else {
renderViewToCanvas(canvas, child, paint, parentOpacity);
float childOpacity = parentOpacity * child.getAlpha();
if (child instanceof TextureView) {
drawTextureView(canvas, (TextureView) child, paint, childOpacity);
} else if (child instanceof SurfaceView) {
if (handleGLSurfaceView) {
drawSurfaceView(canvas, (SurfaceView) child, paint, childOpacity);
}
} else {
renderViewToCanvas(canvas, child, paint, childOpacity);

Copilot uses AI. Check for mistakes.
Comment on lines +498 to +516
private void drawSurfaceView(Canvas canvas, SurfaceView sv, Paint paint, float opacity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final Bitmap childBitmapBuffer = getBitmapForScreenshot(sv.getWidth(), sv.getHeight());
final CountDownLatch latch = new CountDownLatch(1);
try {
PixelCopy.request(sv, childBitmapBuffer, new PixelCopy.OnPixelCopyFinishedListener() {
@Override
public void onPixelCopyFinished(int copyResult) {
drawBitmapWithTransform(canvas, sv, childBitmapBuffer, paint, opacity);
recycleBitmap(childBitmapBuffer);
latch.countDown();
}
}, new Handler(Looper.getMainLooper()));
latch.await(SURFACE_VIEW_READ_PIXELS_TIMEOUT, TimeUnit.SECONDS);
} catch (Exception e) {
Log.e(TAG, "Cannot PixelCopy for " + sv, e);
recycleBitmap(childBitmapBuffer);
drawSurfaceViewFromCache(canvas, sv, paint, opacity);
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

drawSurfaceView() waits with a timeout but doesn’t handle the timeout result. If await() returns false (or PixelCopy finishes after the timeout), the bitmap buffer may never be recycled, and the callback could still draw onto the canvas after capture has moved on. Handle the await result (fallback + recycle) and guard the callback to avoid drawing after timeout/finalization.

Copilot uses AI. Check for mistakes.
@gre
Copy link
Copy Markdown
Owner Author

gre commented Mar 29, 2026

@wcandillon to be honest, i have not tested it yet, this PR is currently vibe coded 😆 i asked Claude to do a proposal and here is this PR.
This serves as a possible exploration & also will be interesting to see if Copilot see ways to improve it.
but indeed I think we'll need to dig more. What I need to build first is some specific examples to be added so i can have much more tests to back test the library against.
I also still need to close some build issue topics, it seems the library isn't still well enabled in a standalone project (e.g. people experiencing some No view found with reactTag: 772 but I couldn't reproduce yet #564 )

@gre gre marked this pull request as draft March 29, 2026 15:54
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.

Align with React Native Skia?

3 participants