Skip to content

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

Closed
gre wants to merge 3 commits intomasterfrom
fix/android-skia-rendering
Closed

Align Android rendering with React Native Skia's ViewScreenshotService#616
gre wants to merge 3 commits 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

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
@wcandillon
Copy link
Copy Markdown

You have the right intuitions. On Android it's really a hard problem to solve I think. One thing where I can help is with e2e testing of Android/iOS snapshot we have in Skia, potentially these can be contributed to this repo or you can send me a bunch of test to run against Skia. This is how it looks: https://github.com/wcandillon/react-native-skia/tree/main/apps/example/src/Tests/Screens (we have a bunch of native component we render and check the view-shot).

@gre gre force-pushed the fix/android-skia-rendering branch from 8a6d521 to 90c6c64 Compare April 29, 2026 12:29
gre pushed a commit that referenced this pull request Apr 29, 2026
Single-capture ViewShot exercising the rendering dimensions touched by
the Android renderer rewrite (PR #616) and Copilot review feedback:
transforms (rotate/scale/skew/3D/combo), nested opacity, z-index draw
order, mid-scrolled ScrollView clip, padding+bg+borderRadius+border,
overflow:hidden, and a Skia Canvas vs RN View comparator.

Adds @shopify/react-native-skia + react-native-reanimated +
react-native-worklets to the example app deps and wires the worklets
babel plugin. New screen reuses the existing useViewShotCapture /
CaptureButton / PreviewContainer shared components.

Detox case wired into viewshot-all-screens.test.js so CI captures the
card on each platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gre pushed a commit that referenced this pull request Apr 29, 2026
Apply 5 of 6 issues raised in Copilot's review of #616:

1. applyTransformations no longer translates by padding/scroll for
   the view itself — that shifted the view's own background/text by
   its padding. Padding+scroll are now applied at child descent in
   drawChildren, matching ViewGroup.dispatchDraw semantics. ScrollView
   clipRect is now in local view coords (0,0,W,H).

2. ViewGroup with combined alpha < 1 is now wrapped in saveLayerAlpha
   and descendants render at full opacity. Previously each child got
   its own alpha layer, which produced wrong blending for overlapping
   siblings under a transparent parent.

3. drawBackgroundIfPresent now calls bg.setBounds(0,0,W,H) before
   drawing — view.draw() does this implicitly but we bypass that path
   for ViewGroups so the bounds could be stale.

4. drawChildren now respects ViewGroup.getChildDrawingOrder when
   isChildrenDrawingOrderEnabled() is true (this is what RN sets when
   children have zIndex). dispatchOverflowDraw was for overflow:visible,
   not z-index — the previous code conflated the two and drew children
   in tree order regardless of zIndex.

5. TextureView/SurfaceView children now use child.getAlpha() instead
   of inheriting only the parent's opacity — semi-transparent video
   etc. used to render as fully opaque.

Skipped: SurfaceView PixelCopy timeout handling (rare edge case,
deferred). Also dropped the now-redundant parentOpacity parameter
threaded through renderViewToCanvas (always 1.0 with the new layer
wrapping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gre added a commit that referenced this pull request Apr 29, 2026
* test: add rendering correctness test card to example app

Single-capture ViewShot exercising the rendering dimensions touched by
the Android renderer rewrite (PR #616) and Copilot review feedback:
transforms (rotate/scale/skew/3D/combo), nested opacity, z-index draw
order, mid-scrolled ScrollView clip, padding+bg+borderRadius+border,
overflow:hidden, and a Skia Canvas vs RN View comparator.

Adds @shopify/react-native-skia + react-native-reanimated +
react-native-worklets to the example app deps and wires the worklets
babel plugin. New screen reuses the existing useViewShotCapture /
CaptureButton / PreviewContainer shared components.

Detox case wired into viewshot-all-screens.test.js so CI captures the
card on each platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot feedback + unbreak Android APK CI

Copilot review on #627:
- ScrolledCell ScrollView now has padding:8 so the fixture actually
  exercises the padding/scroll double-apply scenario (was claimed in
  the PR description but the ScrollView had no padding).
- Preview imageHeight is now derived from the card constants (5 rows
  × 110 + gaps + card padding ≈ 606) instead of CARD_WIDTH * 1.7
  (544), so the default cover-mode Image no longer crops the bottom
  of the captured card.

CI: the Build Android APK debug job in ci.yml broke once Skia +
reanimated + worklets entered the example deps — the separate
"prefabDebugConfigurePackage" pre-step was wiped by the subsequent
`clean assembleDebug`, and Skia 2.x relies on the prefab artefact
being available when the app's CMake step runs. Aligning with the
already-passing "Build Example APK" workflow: drop the pre-step,
drop `clean`, run a single `assembleDebug -PnewArchEnabled=true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(example): persist Detox manual screenshots

Add the Detox artifacts plugin so device.takeScreenshot() calls land
on disk under example/artifacts/. The CI workflow already attempts to
upload example/artifacts/**/*.png as detox-screenshots-ios but the
upload was warning "No files were found" because Detox's default
config never persists manual screenshot calls.

This unblocks downloading the new Rendering test card capture from CI
artifacts to commit as a reference snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: gre <greweb@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gre pushed a commit that referenced this pull request Apr 29, 2026
Generated locally by running the new Detox case in isolation:
  npx detox test --configuration ios.sim.debug ... -t "Rendering test card"

(Other tests in the suite were skipped to avoid the pre-existing
react-native-video crash that's been killing the iOS Detox run.)

Sim used: iPhone 16 Pro on iOS 26.4 (1206x2622). Existing references
were committed at 1179x2556 from an older iOS version — separate
issue, not blocking this snapshot.

The capture is a full device screenshot showing the test card live-
rendered by iOS layout, which serves as the ground-truth visual
reference for back-testing PR #616's Android renderer rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gre pushed a commit that referenced this pull request Apr 29, 2026
Generated locally by running the new Detox case in isolation:
  npx detox test --configuration ios.sim.debug ... -t "Rendering test card"

(Other tests in the suite were skipped to avoid the pre-existing
react-native-video crash that's been killing the iOS Detox run.)

Sim used: iPhone 16 Pro on iOS 26.4 (1206x2622). Existing references
were committed at 1179x2556 from an older iOS version — separate
issue, not blocking this snapshot.

The capture is a full device screenshot showing the test card live-
rendered by iOS layout, which serves as the ground-truth visual
reference for back-testing PR #616's Android renderer rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gre
Copy link
Copy Markdown
Owner Author

gre commented Apr 29, 2026

on Android,

Skia View

Screenshot_2026-04-29-21-28-30-568_com viewshotexample

Getting screenshot from main branch

Screenshot_2026-04-29-21-28-34-242_com viewshotexample

almost all is good, only issue with opacity.

Getting screenshot from this branch

Screenshot_2026-04-29-21-30-53-151_com viewshotexample

apparently a regression on overflow:hidden

gre and others added 3 commits April 29, 2026 21:36
#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>
Apply 5 of 6 issues raised in Copilot's review of #616:

1. applyTransformations no longer translates by padding/scroll for
   the view itself — that shifted the view's own background/text by
   its padding. Padding+scroll are now applied at child descent in
   drawChildren, matching ViewGroup.dispatchDraw semantics. ScrollView
   clipRect is now in local view coords (0,0,W,H).

2. ViewGroup with combined alpha < 1 is now wrapped in saveLayerAlpha
   and descendants render at full opacity. Previously each child got
   its own alpha layer, which produced wrong blending for overlapping
   siblings under a transparent parent.

3. drawBackgroundIfPresent now calls bg.setBounds(0,0,W,H) before
   drawing — view.draw() does this implicitly but we bypass that path
   for ViewGroups so the bounds could be stale.

4. drawChildren now respects ViewGroup.getChildDrawingOrder when
   isChildrenDrawingOrderEnabled() is true (this is what RN sets when
   children have zIndex). dispatchOverflowDraw was for overflow:visible,
   not z-index — the previous code conflated the two and drew children
   in tree order regardless of zIndex.

5. TextureView/SurfaceView children now use child.getAlpha() instead
   of inheriting only the parent's opacity — semi-transparent video
   etc. used to render as fully opaque.

Skipped: SurfaceView PixelCopy timeout handling (rare edge case,
deferred). Also dropped the now-redundant parentOpacity parameter
threaded through renderViewToCanvas (always 1.0 with the new layer
wrapping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ViewGroup.isChildrenDrawingOrderEnabled() is protected so calling it
directly fails the Java compile. Mirror the same reflection-cache
pattern used for getChildDrawingOrder and dispatchOverflowDraw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gre gre force-pushed the fix/android-skia-rendering branch from ce74709 to b58ad07 Compare April 29, 2026 19:37
@gre
Copy link
Copy Markdown
Owner Author

gre commented Apr 29, 2026

I think this PR is diverging too much. i'm going to close it for now, we'll need to have more example that possibly reveal more issues, but apart from the opacity issue, it seems to be fine at the moment.

@wcandillon
Copy link
Copy Markdown

@gre should we use the Skia e2e infrastructure to add a bunch of test for this feature so we can track everything better? what do you think?

@gre
Copy link
Copy Markdown
Owner Author

gre commented Apr 30, 2026

@wcandillon it could be useful. I'm currently iterating on the opacity one and will manually test it. But overall it's not easy to anticipate all scenario, i actually wonder if we can leverage generative AI to smartly "bruteforce" all possible combination of views nesting / compositions 😆 so it could automatically locate for us where there are diff 🤔

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