Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
Binary file added apps/docs/static/img/group/bloom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/docs/static/img/group/bloom2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/skia/cpp/api/recorder/JsiRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ class JsiRecorder : public JsiSkWrappingSharedPtrHostObject<Recorder> {
}

JSI_HOST_FUNCTION(saveLayer) {
getObject()->saveLayer();
getObject()->saveLayer(runtime, arguments[0].asObject(runtime));
return jsi::Value::undefined();
}

Expand Down
49 changes: 43 additions & 6 deletions packages/skia/cpp/api/recorder/Paint.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#include "Convertor.h"
#include "DrawingCtx.h"

#include "include/core/SkCanvas.h"

namespace RNSkia {

struct TransformProps {
Expand Down Expand Up @@ -38,7 +40,39 @@ SkMatrix processTransform(std::optional<SkMatrix> &matrix,
return m3;
}

struct CTMCmdProps : TransformProps {
struct SaveLayerProps {
std::optional<SkCanvas::SaveLayerFlags> saveLayerFlags;
std::optional<sk_sp<SkImageFilter>> backdropFilter;
}

class SaveLayerCmd : public Command {
private:
SaveLayerProps props;

public:
SaveLayerCmd(jsi::Runtime &runtime, const jsi::Object &object,
Variables &variables)
: Command(CommandType::SaveLayer) {
convertProperty(runtime, object, "saveLayerFlags", props.saveLayerFlags,
variables);
convertProperty(runtime, object, "backdropFilter", props.backdropFilter,
variables);
}

void saveLayer(DrawingCtx *ctx) {
ctx->materializePaint();
auto paint = ctx->paintDeclarations.back();
ctx->paintDeclarations.pop_back();

SkCanvas::SaveLayerRec layerRec(nullptr, &paint,
props.backdropFilter.value_or(nullptr),
props.saveLayerFlags.value_or(0));
ctx->canvas->saveLayer(layerRec);
}
}

struct CTMCmdProps : TransformProps,
SaveLayerProps {
std::optional<ClipDef> clip;
std::optional<bool> invertClip;
std::optional<Layer> layer;
Expand All @@ -58,12 +92,15 @@ class SaveCTMCmd : public Command {
convertProperty(runtime, object, "clip", props.clip, variables);
convertProperty(runtime, object, "invertClip", props.invertClip, variables);
convertProperty(runtime, object, "layer", props.layer, variables);
convertProperty(runtime, object, "saveLayerFlags", props.saveLayerFlags,
variables);
}

void saveCTM(DrawingCtx *ctx) {
auto clip = props.clip;
auto invertClip = props.invertClip;
auto layer = props.layer;
auto saveLayerFlags = props.saveLayerFlags;
auto hasTransform = props.matrix.has_value() || props.transform.has_value();
auto hasClip = clip.has_value();
auto op = invertClip.has_value() && invertClip.value()
Expand All @@ -73,12 +110,12 @@ class SaveCTMCmd : public Command {
SkMatrix m3 = processTransform(props.matrix, props.transform, props.origin);
if (shouldSave) {
if (layer.has_value()) {
if (std::holds_alternative<bool>(layer.value())) {
ctx->canvas->saveLayer(nullptr, nullptr);
} else {
auto paint = std::get<SkPaint>(layer.value());
ctx->canvas->saveLayer(nullptr, &paint);
SkCanvas::SaveLayerRec layerRec;
layerRec.fPaint = std::get_if<SkPaint>(layer.value());
if (saveLayerFlags.has_value()) {
layerRec.fSaveLayerFlags = saveLayerFlags.value();
}
ctx->canvas->saveLayer(layerRec);
} else {
ctx->canvas->save();
}
Expand Down
10 changes: 4 additions & 6 deletions packages/skia/cpp/api/recorder/RNRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,8 @@ class Recorder {
std::make_unique<Command>(CommandType::RestorePaintDeclaration));
}

void saveLayer() {
pushCommand(std::make_unique<Command>(CommandType::SaveLayer));
void saveLayer(jsi::Runtime &runtime, const jsi::Object &props) {
pushCommand(std::make_unique<SaveLayerCmd>(runtime, props, variables));
}

void saveBackdropFilter() {
Expand Down Expand Up @@ -418,10 +418,8 @@ inline void Recorder::playCommand(DrawingCtx *ctx, Command *cmd) {
break;
}
case CommandType::SaveLayer: {
ctx->materializePaint();
auto paint = ctx->paintDeclarations.back();
ctx->paintDeclarations.pop_back();
ctx->canvas->saveLayer(SkCanvas::SaveLayerRec(nullptr, &paint, nullptr, 0));
auto *saveCTMCmd = static_cast<SaveCTMCmd *>(cmd);
saveCTMCmd->saveCTM(ctx);
break;
}
case CommandType::MaterializePaint: {
Expand Down
9 changes: 8 additions & 1 deletion packages/skia/src/dom/types/Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
InputMatrix,
InputRRect,
PaintStyle,
SaveLayerFlag,
SkImageFilter,
SkPaint,
SkPath,
SkRect,
Expand Down Expand Up @@ -70,7 +72,12 @@ export interface TransformProps {
matrix?: InputMatrix;
}

export interface CTMProps extends TransformProps {
export interface SaveLayerProps {
backdropFilter?: SkImageFilter;
saveLayerFlags?: SaveLayerFlag;
Copy link
Author

@macksal macksal Dec 16, 2025

Choose a reason for hiding this comment

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

SaveLayerFlag being a union is a little awkward since this can actually be a bitset OR of any of the flags. But this problem seems to exist elsewhere in the library. Another option is one boolean property per flag, but then we just have to unpack that everywhere, so it may not be worthwhile.

}

export interface CTMProps extends TransformProps, SaveLayerProps {
clip?: ClipDef;
invertClip?: boolean;
layer?: SkPaint | boolean;
Expand Down
171 changes: 170 additions & 1 deletion packages/skia/src/renderer/__tests__/e2e/Group.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import React from "react";

import { checkImage, docPath } from "../../../__tests__/setup";
import { Image, Group, Fill, FitBox, Path } from "../../components";
import {
Image,
Group,
Fill,
FitBox,
Path,
Rect,
Circle,
Paint,
Blur,
} from "../../components";
import { images, importSkia, PIXEL_RATIO, surface } from "../setup";

describe("Group", () => {
Expand Down Expand Up @@ -125,4 +135,163 @@ describe("Group", () => {
);
checkImage(img, docPath("group/scale-path.png"));
});
it("Copies a layer and adds new content to it", async () => {
const { width, height } = surface;
const centreY = height / 2;
const { BlendMode, TileMode, SaveLayerFlag, ClipOp, rect } = importSkia();
const img = await surface.drawOffscreen(
(Skia, canvas, _ctx) => {
canvas.clear(Skia.Color("black"));

const bluePaint = Skia.Paint();
bluePaint.setColor(Skia.Color([0, 0, 0.6, 1]));
canvas.drawRect(
{ x: 0, y: centreY - 0.1 * height, width, height: 0.2 * height },
bluePaint
);

const redPaint = Skia.Paint();
redPaint.setColor(Skia.Color([0.8, 0, 0, 1]));
canvas.drawCircle(0.25 * width, centreY, 0.2 * width, redPaint);

const layerRestorePaint = Skia.Paint();
layerRestorePaint.setBlendMode(BlendMode.Screen);
layerRestorePaint.setImageFilter(
Skia.ImageFilter.MakeBlur(width * 0.05, width * 0.05, TileMode.Decal)
);
// show the difference between top and bottom halves
canvas.clipRect(rect(0, 0, width, centreY), ClipOp.Intersect, false);
canvas.saveLayer(
layerRestorePaint,
null,
null,
SaveLayerFlag.SaveLayerInitWithPrevious
);
canvas.drawCircle(0.75 * width, centreY, 0.2 * width, redPaint);
canvas.restore();
},
{ width: surface.width }
);
checkImage(img, docPath("group/bloom.png"));
});
it("Copies a layer and adds new content to it (decl)", async () => {
const { width, height } = surface;
const centreY = height / 2;
const { SaveLayerFlag, rect } = importSkia();
const img = await surface.draw(
<>
<Fill color="black" />
<Rect
x={0}
y={centreY - 0.1 * height}
width={width}
height={0.2 * height}
color={[0, 0, 0.6, 1]}
/>
<Circle
cx={0.25 * width}
cy={centreY}
r={0.2 * width}
color={[0.8, 0, 0, 1]}
/>
<Group clip={rect(0, 0, width, centreY)}>
<Group
saveLayerFlags={SaveLayerFlag.SaveLayerInitWithPrevious}
layer={
<Paint blendMode={"screen"}>
<Blur blur={width * 0.05} mode={"decal"} />
</Paint>
}
>
<Circle
cx={0.75 * width}
cy={centreY}
r={0.2 * width}
color={[0.8, 0, 0, 1]}
/>
</Group>
</Group>
</>
);

checkImage(img, docPath("group/bloom.png"));
});
it("Copies a layer through a backdrop filter and adds new content to it", async () => {
const { width, height } = surface;
const centreY = height / 2;
const { BlendMode, TileMode, ClipOp, rect } = importSkia();
const img = await surface.drawOffscreen(
(Skia, canvas, _ctx) => {
canvas.clear(Skia.Color("black"));

const bluePaint = Skia.Paint();
bluePaint.setColor(Skia.Color([0, 0, 0.6, 1]));
canvas.drawRect(
{ x: 0, y: centreY - 0.1 * height, width, height: 0.2 * height },
bluePaint
);

const redPaint = Skia.Paint();
redPaint.setColor(Skia.Color([0.8, 0, 0, 1]));
canvas.drawCircle(0.25 * width, centreY, 0.2 * width, redPaint);

const layerRestorePaint = Skia.Paint();
layerRestorePaint.setBlendMode(BlendMode.Screen);

// show the difference between top and bottom halves
canvas.clipRect(rect(0, 0, width, centreY), ClipOp.Intersect, false);
canvas.saveLayer(
layerRestorePaint,
null,
Skia.ImageFilter.MakeBlur(width * 0.05, width * 0.05, TileMode.Decal)
);
canvas.drawCircle(0.75 * width, centreY, 0.2 * width, redPaint);
canvas.restore();
},
{ width: surface.width }
);
checkImage(img, docPath("group/bloom2.png"));
});
it("Copies a layer through a backdrop filter and adds new content to it (decl)", async () => {
const { width, height } = surface;
const centreY = height / 2;
const { TileMode, rect, Skia } = importSkia();
const img = await surface.draw(
<>
<Fill color="black" />
<Rect
x={0}
y={centreY - 0.1 * height}
width={width}
height={0.2 * height}
color={[0, 0, 0.6, 1]}
/>
<Circle
cx={0.25 * width}
cy={centreY}
r={0.2 * width}
color={[0.8, 0, 0, 1]}
/>
<Group clip={rect(0, 0, width, centreY)}>
<Group
layer={<Paint blendMode={"screen"} />}
backdropFilter={Skia.ImageFilter.MakeBlur(
width * 0.05,
width * 0.05,
TileMode.Decal
)}
>
<Circle
cx={0.75 * width}
cy={centreY}
r={0.2 * width}
color={[0.8, 0, 0, 1]}
/>
</Group>
</Group>
</>
);

checkImage(img, docPath("group/bloom2.png"));
});
});
19 changes: 16 additions & 3 deletions packages/skia/src/renderer/components/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,27 @@ export interface PublicGroupProps extends Omit<GroupProps, "layer"> {
layer?: GroupProps["layer"] | ChildrenProps["children"];
}

export const Group = ({ layer, ...props }: SkiaProps<PublicGroupProps>) => {
export const Group = ({
layer,
backdropFilter,
saveLayerFlags,
...props
}: SkiaProps<PublicGroupProps>) => {
Comment on lines +11 to +16
Copy link
Author

@macksal macksal Dec 23, 2025

Choose a reason for hiding this comment

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

it would be really nice if we could pass a child node to the backdropFilter prop. (Similar to the layer prop - that accepts an SkPaint object or a <Paint> node).

I couldn't quite work out how to do this without a big change to the node visitor. I think this dual support for the layer prop is handled by passing the paint node or related nodes as children. They are then implicitly grouped by type in sortNodeChildren.

Because the nodes that would be supported in backdropFilter prop are just image filter nodes, we'd need a way to differentiate between the paint (children) props and this new backdrop prop.

The features still works by passing an SkImageFilter only, we could revisit this later.

if (isValidElement(layer) && typeof layer === "object") {
return (
<skLayer>
// keep the saveLayerFlags on whichever node triggers saveLayer
Copy link
Author

@macksal macksal Dec 16, 2025

Choose a reason for hiding this comment

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

this is a little awkward, but I tracked everywhere in the graph traversal that ends up calling saveLayer. The Paint props on group must also be provided to saveLayer, so I decided it is safe if I always store the flag prop alongside the layer or other paint props.

BTW I think I found that this <skLayer><skGroup> structure will end up calling saveLayer (for the layer node) and then later calling save via saveCTM (for the group node). Small possibility for optimisation if they can be combined. From what I see the if here is only needed so the paint can be provided as JSX, but I think if the paint element was given as a child to skGroup it might just work out of the box anyway?

Edit: this split creates a surprising gap in test coverage, the execution path when passing an SkPaint or a <Paint/> is quite different. My declarative tests miss one side

<skLayer saveLayerFlags={saveLayerFlags} backdropFilter={backdropFilter}>
{layer}
<skGroup {...props} />
</skLayer>
);
}
return <skGroup layer={layer as GroupProps["layer"]} {...props} />;
return (
<skGroup
layer={layer as GroupProps["layer"]}
backdropFilter={backdropFilter}
saveLayerFlags={saveLayerFlags}
{...props}
/>
);
};
3 changes: 2 additions & 1 deletion packages/skia/src/skia/types/Recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
VerticesProps,
SkottieProps,
DrawingNodeProps,
SaveLayerProps,
} from "../../dom/types";
import type { AnimatedProps } from "../../renderer/processors/Animations/Animations";

Expand Down Expand Up @@ -61,7 +62,7 @@ export interface BaseRecorder {
saveCTM(props: AnimatedProps<CTMProps>): void;
restoreCTM(): void;
drawPaint(): void;
saveLayer(): void;
saveLayer(props: AnimatedProps<SaveLayerProps>): void;
saveBackdropFilter(): void;
drawBox(
boxProps: AnimatedProps<BoxProps>,
Expand Down
3 changes: 2 additions & 1 deletion packages/skia/src/sksg/Elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,15 @@ import type {
BlendProps,
SkottieProps,
ImageFilterProps,
SaveLayerProps,
} from "../dom/types";
import type { SkiaProps } from "../renderer";

declare module "react" {
namespace JSX {
interface IntrinsicElements {
skGroup: SkiaProps<GroupProps>;
skLayer: SkiaProps<ChildrenProps>;
skLayer: SkiaProps<ChildrenProps & SaveLayerProps>;
skPaint: SkiaProps<PaintProps>;

// Drawings
Expand Down
Loading