From 7b5d4daaed9fd62d1b6a07ffb3742f56c49eda20 Mon Sep 17 00:00:00 2001 From: Jakub Grzywacz Date: Wed, 26 Jun 2024 09:25:54 +0200 Subject: [PATCH] fix: scaling when mask is set (#2299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 image image --- TestsExample/App.js | 1 + TestsExample/src/Test1451.tsx | 51 +++++++++++ .../java/com/horcrux/svg/RenderableView.java | 90 +++++++++---------- apple/RNSVGRenderable.mm | 40 +++++---- 4 files changed, 121 insertions(+), 61 deletions(-) create mode 100644 TestsExample/src/Test1451.tsx diff --git a/TestsExample/App.js b/TestsExample/App.js index 96451eb3..469952f5 100644 --- a/TestsExample/App.js +++ b/TestsExample/App.js @@ -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'; diff --git a/TestsExample/src/Test1451.tsx b/TestsExample/src/Test1451.tsx new file mode 100644 index 00000000..259276d3 --- /dev/null +++ b/TestsExample/src/Test1451.tsx @@ -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 ( + + + + + + + + + + + + + ); +}; diff --git a/android/src/main/java/com/horcrux/svg/RenderableView.java b/android/src/main/java/com/horcrux/svg/RenderableView.java index e58e8c1d..f1783221 100644 --- a/android/src/main/java/com/horcrux/svg/RenderableView.java +++ b/android/src/main/java/com/horcrux/svg/RenderableView.java @@ -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); } diff --git a/apple/RNSVGRenderable.mm b/apple/RNSVGRenderable.mm index b245afd3..636bbb1f 100644 --- a/apple/RNSVGRenderable.mm +++ b/apple/RNSVGRenderable.mm @@ -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);