Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 156 additions & 138 deletions android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.drawable.Drawable;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Point;
import android.net.Uri;
import android.os.Build;
Expand All @@ -22,6 +24,7 @@
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
import android.widget.HorizontalScrollView;
import android.widget.ScrollView;

import com.facebook.react.bridge.Promise;
Expand All @@ -30,19 +33,18 @@
import com.facebook.react.fabric.interop.UIBlockViewResolver;
import com.facebook.react.uimanager.NativeViewHierarchyManager;
import com.facebook.react.uimanager.UIBlock;
import com.facebook.react.views.view.ReactViewGroup;

import java.lang.reflect.Method;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.WeakHashMap;
Expand Down Expand Up @@ -83,6 +85,21 @@ public class ViewShot implements UIBlock, com.facebook.react.fabric.interop.UIBl
*/
private static final int SURFACE_VIEW_READ_PIXELS_TIMEOUT = 5;

/** Cached reflection handle for ReactViewGroup.dispatchOverflowDraw (z-index support). */
@Nullable
private static final Method sDispatchOverflowDraw;
static {
Method m = null;
try {
m = ReactViewGroup.class.getDeclaredMethod("dispatchOverflowDraw", Canvas.class);
m.setAccessible(true);
} catch (Exception ignored) {}
sDispatchOverflowDraw = m;
}

/** Reusable matrix to avoid allocation per view during rendering. */
private final Matrix tempMatrix = new Matrix();

@SuppressWarnings("WeakerAccess")
@IntDef({Formats.JPEG, Formats.PNG, Formats.WEBP, Formats.RAW})
public @interface Formats {
Expand Down Expand Up @@ -313,28 +330,6 @@ private void saveToBase64String(@NonNull final View view) throws IOException {
promise.resolve(data);
}

@NonNull
private List<View> getAllChildren(@NonNull final View v) {
if (!(v instanceof ViewGroup)) {
final ArrayList<View> viewArrayList = new ArrayList<>();
viewArrayList.add(v);

return viewArrayList;
}

final ArrayList<View> result = new ArrayList<>();

ViewGroup viewGroup = (ViewGroup) v;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View child = viewGroup.getChildAt(i);

//Do not add any parents, just add child elements
result.addAll(getAllChildren(child));
}

return result;
}

/**
* Wrap {@link #captureViewImpl(View, OutputStream)} call and on end close output stream.
*/
Expand Down Expand Up @@ -381,62 +376,10 @@ private Point captureViewImpl(@NonNull final View view, @NonNull final OutputStr
// Debug.waitForDebugger();

final Canvas c = new Canvas(bitmap);
view.draw(c);

//after view is drawn, go through children
final List<View> childrenList = getAllChildren(view);

for (final View child : childrenList) {
// skip any child that we don't know how to process
if (child instanceof TextureView) {
// skip all invisible to user child views
if (child.getVisibility() != VISIBLE) continue;

final TextureView tvChild = (TextureView) child;
tvChild.setOpaque(false); // <-- switch off background fill

// NOTE (olku): get re-usable bitmap. TextureView should use bitmaps with matching size,
// otherwise content of the TextureView will be scaled to provided bitmap dimensions
final Bitmap childBitmapBuffer = tvChild.getBitmap(getExactBitmapForScreenshot(child.getWidth(), child.getHeight()));

final int countCanvasSave = c.save();
applyTransformations(c, view, child);

// due to re-use of bitmaps for screenshot, we can get bitmap that is bigger in size than requested
c.drawBitmap(childBitmapBuffer, 0, 0, paint);

c.restoreToCount(countCanvasSave);
recycleBitmap(childBitmapBuffer);
} else if (child instanceof SurfaceView && handleGLSurfaceView) {
final SurfaceView svChild = (SurfaceView)child;
final CountDownLatch latch = new CountDownLatch(1);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final Bitmap childBitmapBuffer = getExactBitmapForScreenshot(child.getWidth(), child.getHeight());
try {
PixelCopy.request(svChild, childBitmapBuffer, new PixelCopy.OnPixelCopyFinishedListener() {
@Override
public void onPixelCopyFinished(int copyResult) {
final int countCanvasSave = c.save();
applyTransformations(c, view, child);
c.drawBitmap(childBitmapBuffer, 0, 0, paint);
c.restoreToCount(countCanvasSave);
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 " + svChild, e);
}
} else {
Bitmap cache = svChild.getDrawingCache();
if (cache != null) {
c.drawBitmap(svChild.getDrawingCache(), 0, 0, paint);
}
}
}
}
c.save();
c.translate(-view.getLeft(), -view.getTop());
renderViewToCanvas(c, view, paint, 1.0f);
c.restore();

if (width != null && height != null && (width != w || height != h)) {
final Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, width, height, true);
Expand All @@ -462,45 +405,142 @@ public void onPixelCopyFinished(int copyResult) {
return resolution; // return image width and height
}

/**
* Concat all the transformation matrix's from parent to child.
*/
@NonNull
@SuppressWarnings("UnusedReturnValue")
private Matrix applyTransformations(final Canvas c, @NonNull final View root, @NonNull final View child) {
final Matrix transform = new Matrix();
final LinkedList<View> ms = new LinkedList<>();

// find all parents of the child view
View iterator = child;
do {
ms.add(iterator);

iterator = (View) iterator.getParent();
} while (iterator != root);

// apply transformations from parent --> child order
Collections.reverse(ms);

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();
c.translate(dx, dy);
c.rotate(v.getRotation(), v.getPivotX(), v.getPivotY());
c.scale(v.getScaleX(), v.getScaleY());

// compute the matrix just for any future use
transform.postTranslate(dx, dy);
transform.postRotate(v.getRotation(), v.getPivotX(), v.getPivotY());
transform.postScale(v.getScaleX(), v.getScaleY());
//region Recursive rendering (adapted from React Native Skia's ViewScreenshotService)

private void renderViewToCanvas(Canvas canvas, View view, Paint paint, float parentOpacity) {
float combinedOpacity = parentOpacity * view.getAlpha();
canvas.save();
applyTransformations(canvas, view);

if (view instanceof ScrollView || view instanceof HorizontalScrollView) {
canvas.clipRect(
view.getScrollX(),
view.getScrollY(),
view.getScrollX() + view.getWidth(),
view.getScrollY() + view.getHeight());
}

return transform;
if (view instanceof ViewGroup && !isSvgView(view)) {
drawBackgroundIfPresent(canvas, view, combinedOpacity);
drawChildren(canvas, (ViewGroup) view, paint, combinedOpacity);
Comment on lines +424 to +425
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.
} else {
drawView(canvas, view, combinedOpacity);
}

canvas.restore();
}

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);
}
Comment on lines +433 to +443
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.
}
}

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);
Comment on lines +459 to +466
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 +447 to +467
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.
}
}

private static void drawView(Canvas canvas, View view, float opacity) {
int alpha = Math.round(opacity * 255);
if (alpha < 255) {
canvas.saveLayerAlpha(new RectF(0, 0, view.getWidth(), view.getHeight()), alpha);
view.draw(canvas);
canvas.restore();
} else {
view.draw(canvas);
}
}

private void drawBitmapWithTransform(Canvas canvas, View view, Bitmap bmp, Paint paint, float opacity) {
canvas.save();
applyTransformations(canvas, view);
paint.setAlpha(Math.round(opacity * 255));
canvas.drawBitmap(bmp, 0, 0, paint);
paint.setAlpha(255);
canvas.restore();
}

private void drawTextureView(Canvas canvas, TextureView tv, Paint paint, float opacity) {
tv.setOpaque(false);
final Bitmap childBitmapBuffer = tv.getBitmap(getBitmapForScreenshot(tv.getWidth(), tv.getHeight()));
drawBitmapWithTransform(canvas, tv, childBitmapBuffer, paint, opacity);
recycleBitmap(childBitmapBuffer);
}

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);
}
Comment on lines +498 to +516
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.
} else {
drawSurfaceViewFromCache(canvas, sv, paint, opacity);
}
}

private void drawSurfaceViewFromCache(Canvas canvas, SurfaceView sv, Paint paint, float opacity) {
Bitmap cache = sv.getDrawingCache();
if (cache != null) {
drawBitmapWithTransform(canvas, sv, cache, paint, opacity);
}
}

// Detect react-native-svg views to render as leaf nodes (avoids compile-time dependency)
private static boolean isSvgView(View view) {
return view.getClass().getName().startsWith("com.horcrux.svg");
}

private void applyTransformations(final Canvas c, @NonNull final View view) {
c.translate(
view.getLeft() + view.getPaddingLeft() - view.getScrollX(),
view.getTop() + view.getPaddingTop() - view.getScrollY());
Comment on lines +535 to +537
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.
tempMatrix.set(view.getMatrix());
c.concat(tempMatrix);
}

//endregion

@SuppressWarnings("unchecked")
private static <T extends A, A> T cast(final A instance) {
return (T) instance;
Expand Down Expand Up @@ -536,9 +576,6 @@ private static void recycleBitmap(@NonNull final Bitmap bitmap) {
}
}

/**
* Try to find a bitmap for screenshot in reusable set and if not found create a new one.
*/
@NonNull
private static Bitmap getBitmapForScreenshot(final int width, final int height) {
synchronized (guardBitmaps) {
Expand All @@ -553,25 +590,6 @@ private static Bitmap getBitmapForScreenshot(final int width, final int height)

return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
}

/**
* Try to find a bitmap with exact width and height for screenshot in reusable set and if
* not found create a new one.
*/
@NonNull
private static Bitmap getExactBitmapForScreenshot(final int width, final int height) {
synchronized (guardBitmaps) {
for (final Bitmap bmp : weakBitmaps) {
if (bmp.getWidth() == width && bmp.getHeight() == height) {
weakBitmaps.remove(bmp);
bmp.eraseColor(Color.TRANSPARENT);
return bmp;
}
}
}

return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
}
//endregion

//region Nested declarations
Expand Down
Loading