fix: scaling when mask is set (#2299)

# Summary

This PR resolves an issue raised in #1451. 
Currently, when a mask is used, we render the element as a bitmap (or
platform equivalent), but the bitmap's size does not update accordingly
with transformations. With these changes, the problem is addressed as
follows:
* **Android**: We utilize the original canvas layers to render the mask
and element with the appropriate blending mode.
* **iOS**: We create an offscreen context with the size multiplied by
the screen scale and apply the original UIGraphics CTM (current
transformation matrix) to the offscreen context. This ensures that the
same transformations are applied as on the original context.

Additionally, there is a significant performance improvement on Android
as we are not creating three new Bitmaps and three new Canvases.

## Test Plan

There are many ways for testing these changes, but the required ones
are:
* `TestsExample` app -> `Test1451.tsx`
* `Example` app -> Mask section
* `FabricExample` app -> Mask section

## Compatibility

| OS      | Implemented |
| ------- | :---------: |
| Android |         |
| iOS     |         |

## Preview

<img width="337" alt="image"
src="https://github.com/software-mansion/react-native-svg/assets/39670088/93dbae85-edbd-452a-84b0-9a50107b1361">
<img width="337" alt="image"
src="https://github.com/software-mansion/react-native-svg/assets/39670088/07838dff-cb2d-4072-a2fc-5c16a76f6c33">
This commit is contained in:
Jakub Grzywacz
2024-06-26 09:25:54 +02:00
committed by GitHub
parent a36a676d43
commit 7b5d4daaed
4 changed files with 121 additions and 61 deletions

View File

@@ -4,6 +4,7 @@ import React from 'react';
import ColorTest from './src/ColorTest';
import PointerEventsBoxNone from './src/PointerEventsBoxNone';
import Test1374 from './src/Test1374';
import Test1451 from './src/Test1451';
import Test1718 from './src/Test1718';
import Test1813 from './src/Test1813';
import Test1845 from './src/Test1845';

View File

@@ -0,0 +1,51 @@
import React, {useEffect, useRef} from 'react';
import {Animated, View} from 'react-native';
import {Circle, Mask, Path, Rect, Svg} from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle as any);
export default () => {
const zoom = useRef(new Animated.Value(1)).current;
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(zoom, {
toValue: 4,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(zoom, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
]),
).start();
});
return (
<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<View style={{borderWidth: 3}}>
<Svg width="400" height="400" viewBox="0 0 400 400">
<Mask id="myMask">
<Rect x="0" y="0" width="100" height="100" fill="white" />
<Path
d="M10,35 A20,20,0,0,1,50,35 A20,20,0,0,1,90,35 Q90,65,50,95 Q10,65,10,35 Z"
fill="black"
/>
</Mask>
<Rect x="0" y="0" width="150" height="150" fill="pink" />
<AnimatedCircle
cx="50"
cy="50"
r="50"
fill="purple"
mask="url(#myMask)"
matrix={[zoom, 0, 0, zoom, 0, 0]}
/>
</Svg>
</View>
</View>
);
};

View File

@@ -8,8 +8,9 @@
package com.horcrux.svg;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.DashPathEffect;
import android.graphics.Matrix;
import android.graphics.Paint;
@@ -325,71 +326,70 @@ public abstract class RenderableView extends VirtualView implements ReactHitSlop
invalidate();
}
private static double saturate(double v) {
return v <= 0 ? 0 : (v >= 1 ? 1 : v);
}
void render(Canvas canvas, Paint paint, float opacity) {
MaskView mask = null;
if (mMask != null) {
SvgView root = getSvgView();
mask = (MaskView) root.getDefinedMask(mMask);
}
if (mask != null) {
Rect clipBounds = canvas.getClipBounds();
int height = clipBounds.height();
int width = clipBounds.width();
// https://www.w3.org/TR/SVG11/masking.html
// Adding a mask involves several steps
// 1. applying luminanceToAlpha to the mask element
// 2. merging the alpha channel of the element with the alpha channel from the previous step
// 3. applying the result from step 2 to the target element
Bitmap maskBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Bitmap original = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
canvas.saveLayer(null, paint);
draw(canvas, paint, opacity);
Canvas originalCanvas = new Canvas(original);
Canvas maskCanvas = new Canvas(maskBitmap);
Canvas resultCanvas = new Canvas(result);
Paint dstInPaint = new Paint();
dstInPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
// Clip to mask bounds and render the mask
// prepare step 3 - combined layer
canvas.saveLayer(null, dstInPaint);
// step 1 - luminance layer
// prepare maskPaint with luminanceToAlpha
// https://www.w3.org/TR/SVG11/filters.html#InterfaceSVGFEMergeElement:~:text=not%20applicable.%20A-,luminanceToAlpha,-operation%20is%20equivalent
Paint luminancePaint = new Paint();
ColorMatrix luminanceToAlpha =
new ColorMatrix(
new float[] {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2125f, 0.7154f, 0.0721f, 0, 0
});
luminancePaint.setColorFilter(new ColorMatrixColorFilter(luminanceToAlpha));
canvas.saveLayer(null, luminancePaint);
// calculate mask bounds
float maskX = (float) relativeOnWidth(mask.mX);
float maskY = (float) relativeOnHeight(mask.mY);
float maskWidth = (float) relativeOnWidth(mask.mW);
float maskHeight = (float) relativeOnHeight(mask.mH);
maskCanvas.clipRect(maskX, maskY, maskWidth + maskX, maskHeight + maskY);
// clip to mask bounds
canvas.clipRect(maskX, maskY, maskX + maskWidth, maskY + maskHeight);
Paint maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mask.draw(maskCanvas, maskPaint, 1);
mask.draw(canvas, paint, 1f);
// Apply luminanceToAlpha filter primitive
// https://www.w3.org/TR/SVG11/filters.html#feColorMatrixElement
int nPixels = width * height;
int[] pixels = new int[nPixels];
maskBitmap.getPixels(pixels, 0, width, 0, 0, width, height);
// close luminance layer
canvas.restore();
for (int i = 0; i < nPixels; i++) {
int color = pixels[i];
// step 2 - alpha layer
canvas.saveLayer(null, dstInPaint);
// clip to mask bounds
canvas.clipRect(maskX, maskY, maskX + maskWidth, maskY + maskHeight);
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
int a = color >>> 24;
mask.draw(canvas, paint, 1f);
double luminance = saturate(((0.299 * r) + (0.587 * g) + (0.144 * b)) / 255);
int alpha = (int) (a * luminance);
int pixel = (alpha << 24);
pixels[i] = pixel;
}
// close alpha layer
canvas.restore();
maskBitmap.setPixels(pixels, 0, width, 0, 0, width, height);
// close combined layer
canvas.restore();
// Render content of current SVG Renderable to image
draw(originalCanvas, paint, opacity);
// Blend current element and mask
maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
resultCanvas.drawBitmap(original, 0, 0, null);
resultCanvas.drawBitmap(maskBitmap, 0, 0, maskPaint);
// Render composited result into current render context
canvas.drawBitmap(result, 0, 0, paint);
// close element layer
canvas.restore();
} else {
draw(canvas, paint, opacity);
}

View File

@@ -241,10 +241,8 @@ UInt32 saturate(CGFloat value)
if (self.mask) {
// https://www.w3.org/TR/SVG11/masking.html#MaskElement
RNSVGMask *_maskNode = (RNSVGMask *)[self.svgView getDefinedMask:self.mask];
CGRect bounds = CGContextGetClipBoundingBox(context);
CGSize boundsSize = bounds.size;
CGFloat height = boundsSize.height;
CGFloat width = boundsSize.width;
CGFloat height = rect.size.height;
CGFloat width = rect.size.width;
CGFloat scale = 0.0;
#if TARGET_OS_OSX
scale = [[NSScreen mainScreen] backingScaleFactor];
@@ -263,7 +261,10 @@ UInt32 saturate(CGFloat value)
NSUInteger scaledHeight = iheight * iscale;
NSUInteger scaledWidth = iwidth * iscale;
NSUInteger npixels = scaledHeight * scaledWidth;
CGRect drawBounds = CGRectMake(0, 0, width, height);
CGAffineTransform screenScaleCTM = CGAffineTransformMake(scale, 0, 0, scale, 0, 0);
CGRect scaledRect = CGRectApplyAffineTransform(rect, screenScaleCTM);
// Get current context transformations for offscreenContext
CGAffineTransform currentCTM = CGContextGetCTM(context);
// Allocate pixel buffer and bitmap context for mask
NSUInteger bytesPerPixel = 4;
@@ -279,16 +280,16 @@ UInt32 saturate(CGFloat value)
bytesPerRow,
colorSpace,
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
CGContextScaleCTM(bcontext, iscale, iscale);
CGContextConcatCTM(bcontext, currentCTM);
// Clip to mask bounds and render the mask
CGFloat x = [self relativeOn:[_maskNode x] relative:width];
CGFloat y = [self relativeOn:[_maskNode y] relative:height];
CGFloat w = [self relativeOn:[_maskNode maskwidth] relative:width];
CGFloat h = [self relativeOn:[_maskNode maskheight] relative:height];
CGRect maskBounds = CGRectMake(x, y, w, h);
CGRect maskBounds = CGRectApplyAffineTransform(CGRectMake(x, y, w, h), screenScaleCTM);
CGContextClipToRect(bcontext, maskBounds);
[_maskNode renderLayerTo:bcontext rect:rect];
[_maskNode renderLayerTo:bcontext rect:scaledRect];
// Apply luminanceToAlpha filter primitive
// https://www.w3.org/TR/SVG11/filters.html#feColorMatrixElement
@@ -313,26 +314,33 @@ UInt32 saturate(CGFloat value)
#if !TARGET_OS_OSX // [macOS]
UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat defaultFormat];
format.scale = scale;
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:boundsSize format:format];
UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:rect.size format:format];
// Get the content image
UIImage *contentImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull rendererContext) {
CGContextTranslateCTM(rendererContext.CGContext, 0.0, height);
CGContextScaleCTM(rendererContext.CGContext, 1.0, -1.0);
[self renderLayerTo:rendererContext.CGContext rect:rect];
CGContextConcatCTM(
rendererContext.CGContext, CGAffineTransformInvert(CGContextGetCTM(rendererContext.CGContext)));
CGContextConcatCTM(rendererContext.CGContext, currentCTM);
[self renderLayerTo:rendererContext.CGContext rect:scaledRect];
}];
// Blend current element and mask
UIImage *blendedImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull rendererContext) {
CGContextConcatCTM(
rendererContext.CGContext, CGAffineTransformInvert(CGContextGetCTM(rendererContext.CGContext)));
CGContextTranslateCTM(rendererContext.CGContext, 0.0, scaledHeight);
CGContextScaleCTM(rendererContext.CGContext, 1.0, -1.0);
CGContextSetBlendMode(rendererContext.CGContext, kCGBlendModeCopy);
CGContextDrawImage(rendererContext.CGContext, drawBounds, maskImage);
CGContextDrawImage(rendererContext.CGContext, scaledRect, maskImage);
CGContextSetBlendMode(rendererContext.CGContext, kCGBlendModeSourceIn);
CGContextDrawImage(rendererContext.CGContext, drawBounds, contentImage.CGImage);
CGContextDrawImage(rendererContext.CGContext, scaledRect, contentImage.CGImage);
}];
// Render blended result into current render context
[blendedImage drawInRect:drawBounds];
CGContextConcatCTM(context, CGAffineTransformInvert(currentCTM));
[blendedImage drawInRect:scaledRect];
CGContextConcatCTM(context, currentCTM);
// Render blended result into current render context
CGImageRelease(maskImage);