diff --git a/Example/Shared/Render/RendererEffect/BlendModeExample.swift b/Example/Shared/Render/RendererEffect/BlendModeExample.swift new file mode 100644 index 000000000..b56340cae --- /dev/null +++ b/Example/Shared/Render/RendererEffect/BlendModeExample.swift @@ -0,0 +1,33 @@ +// +// BlendModeExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct BlendModeExample: View { + var body: some View { + BlendModeRectanglesExample(blendMode: .colorBurn) + } +} + +struct BlendModeRectanglesExample: View { + var blendMode: BlendMode + + var body: some View { + HStack { + Color.yellow.frame(width: 50, height: 50, alignment: .center) + Color.red.frame(width: 50, height: 50, alignment: .center) + .rotationEffect(.degrees(45)) + .padding(-20) + .blendMode(blendMode) + } + } +} + +#Preview { + BlendModeExample() +} diff --git a/Example/Tests/OpenSwiftUIUITests/Render/RendererEffect/BlendModeEffectUITests.swift b/Example/Tests/OpenSwiftUIUITests/Render/RendererEffect/BlendModeEffectUITests.swift new file mode 100644 index 000000000..a8cced685 --- /dev/null +++ b/Example/Tests/OpenSwiftUIUITests/Render/RendererEffect/BlendModeEffectUITests.swift @@ -0,0 +1,44 @@ +// +// BlendModeEffectUITests.swift +// OpenSwiftUIUITests + +import SnapshotTesting +import Testing +@testable import TestingHost + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct BlendModeEffectUITests { + @Test( + arguments: [ + (.normal, "normal"), + (.multiply, "multiply"), + (.screen, "screen"), + (.overlay, "overlay"), + (.darken, "darken"), + (.lighten, "lighten"), + (.colorDodge, "colorDodge"), + (.colorBurn, "colorBurn"), + (.softLight, "softLight"), + (.hardLight, "hardLight"), + (.difference, "difference"), + (.exclusion, "exclusion"), + (.hue, "hue"), + (.saturation, "saturation"), + (.color, "color"), + (.luminosity, "luminosity"), + (.sourceAtop, "sourceAtop"), + (.destinationOver, "destinationOver"), + (.destinationOut, "destinationOut"), + (.plusDarker, "plusDarker"), + (.plusLighter, "plusLighter"), + ] as [(BlendMode, String)] + ) + func blendMode(_ blendMode: BlendMode, name: String) { + openSwiftUIAssertSnapshot( + of: BlendModeRectanglesExample(blendMode: blendMode), + drawHierarchyInKeyWindow: true, + testName: "blend_mode_\(name)" + ) + } +} diff --git a/Package.resolved b/Package.resolved index 47cabb83b..1c83aba0b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "f917dcc75c52e045a6af30336b2ae63320a9a650" + "revision" : "b00ecca4c7e77ab24da8c25d81f3d413db6fdcb5" } }, { @@ -43,7 +43,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenRenderBox", "state" : { "branch" : "main", - "revision" : "7a85270fd79295c86aaf8cd578168895befd07da" + "revision" : "4b0e86bcf926426fbbf2186d4dde17280a40cafb" } }, { diff --git a/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift b/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift index 90b350cd9..5e35b556a 100644 --- a/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift +++ b/Sources/OpenSwiftUICore/Graphic/GraphicsFilter.swift @@ -7,6 +7,9 @@ package import OpenCoreGraphicsShims import OpenQuartzCoreShims +#if canImport(QuartzCore) +import QuartzCore_Private +#endif package enum GraphicsFilter { case blur(BlurStyle) @@ -141,14 +144,19 @@ package enum GraphicsBlendMode: Equatable { } @inline(__always) - var filter: Any? { + func filter(compositingGroup: Bool) -> Any? { switch self { case let .caFilter(filter): return filter case let .blendMode(mode): - // TODO: _ORBBlendModeGetCompositingFilter & ORBBlendMode - _openSwiftUIUnimplementedWarning() + #if canImport(QuartzCore) + return ORBBlendModeGetCompositingFilter( + .init(rawValue: mode.rawValue), + compositingGroup: compositingGroup + ) + #else return nil + #endif } } } diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewPlatform.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewPlatform.swift index 78e09d9c3..52d828f17 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewPlatform.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayListViewPlatform.swift @@ -1036,7 +1036,7 @@ extension DisplayList.ViewUpdater.Platform { CoreViewSetCompositingFilter( system: viewSystem, view: viewInfo.view, - filter: state.pointee.blend.filter + filter: state.pointee.blend.filter(compositingGroup: state.pointee.compositingGroup) ) viewInfo.seeds.blend = state.pointee.versions.blend.seed } diff --git a/Sources/OpenSwiftUICore/Render/RendererEffect/BlendModeEffect.swift b/Sources/OpenSwiftUICore/Render/RendererEffect/BlendModeEffect.swift new file mode 100644 index 000000000..afa59bc6a --- /dev/null +++ b/Sources/OpenSwiftUICore/Render/RendererEffect/BlendModeEffect.swift @@ -0,0 +1,118 @@ +// +// BlendModeEffect.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete + +package import Foundation + +// MARK: - _BlendModeEffect + +@available(OpenSwiftUI_v1_0, *) +@frozen +@MainActor +@preconcurrency +public struct _BlendModeEffect: RendererEffect, Equatable { + public var blendMode: BlendMode + + @inlinable + nonisolated public init(blendMode: BlendMode) { + self.blendMode = blendMode + } + + package func effectValue(size: CGSize) -> DisplayList.Effect { + .blendMode(GraphicsBlendMode(blendMode)) + } +} + +@available(*, unavailable) +extension _BlendModeEffect: Sendable {} + +// MARK: - _ExtendedBlendModeEffect + +@_spi(Private) +@available(OpenSwiftUI_v3_0, *) +@frozen +@MainActor +@preconcurrency +public struct _ExtendedBlendModeEffect: RendererEffect, Equatable { + public var blendMode: GraphicsContext.BlendMode + + @inlinable + public init(blendMode: GraphicsContext.BlendMode) { + self.blendMode = blendMode + } + + package func effectValue(size: CGSize) -> DisplayList.Effect { + .blendMode(.blendMode(blendMode)) + } +} + +// MARK: - View + blendMode + +@available(OpenSwiftUI_v1_0, *) +extension View { + /// Sets the blend mode for compositing this view with overlapping views. + /// + /// Use `blendMode(_:)` to combine overlapping views and use a different + /// visual effect to produce the result. The ``BlendMode`` enumeration + /// defines many possible effects. + /// + /// In the example below, the two overlapping rectangles have a + /// ``BlendMode/colorBurn`` effect applied, which effectively removes the + /// non-overlapping portion of the second image: + /// + /// HStack { + /// Color.yellow.frame(width: 50, height: 50, alignment: .center) + /// + /// Color.red.frame(width: 50, height: 50, alignment: .center) + /// .rotationEffect(.degrees(45)) + /// .padding(-20) + /// .blendMode(.colorBurn) + /// } + /// + /// ![Two overlapping rectangles showing the effect of the blend mode view + /// modifier applying the colorBurn effect.](OpenSwiftUI-blendMode) + /// + /// - Parameter blendMode: The ``BlendMode`` for compositing this view. + /// + /// - Returns: A view that applies `blendMode` to this view. + @inlinable + nonisolated public func blendMode(_ blendMode: BlendMode) -> some View { + modifier(_BlendModeEffect(blendMode: blendMode)) + } +} + +// MARK: - View + extendedBlendMode + +@_spi(Private) +@available(OpenSwiftUI_v3_0, *) +extension View { + @inlinable + nonisolated public func extendedBlendMode(_ blendMode: GraphicsContext.BlendMode) -> some View { + modifier(_ExtendedBlendModeEffect(blendMode: blendMode)) + } +} + +// MARK: - View + graphicsBlendMode + +extension View { + @MainActor + @preconcurrency + package func graphicsBlendMode(_ blendMode: GraphicsBlendMode) -> some View { + modifier(GraphicsBlendModeEffect(blendMode: blendMode)) + } +} + +// MARK: - GraphicsBlendModeEffect + +@MainActor +@preconcurrency +struct GraphicsBlendModeEffect: RendererEffect { + var blendMode: GraphicsBlendMode + + func effectValue(size: CGSize) -> DisplayList.Effect { + .blendMode(blendMode) + } +} diff --git a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.h b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.h index 4abec4f55..c580a64d8 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.h +++ b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.h @@ -9,6 +9,7 @@ #if __has_include() +#include #import OPENSWIFTUI_ASSUME_NONNULL_BEGIN @@ -66,6 +67,9 @@ OPENSWIFTUI_EXPORT id _Nullable _CAFilterGetInput(CAFilter *filter, CAFilterInpu OPENSWIFTUI_EXPORT void _CAFilterSetInput(CAFilter * _Nullable filter, id _Nullable value, CAFilterInputKey key) OPENSWIFTUI_SWIFT_NAME(CAFilter.setInput(self:value:key:)); OPENSWIFTUI_EXPORT NSMutableArray *_CAFilterArrayCreate(void) OPENSWIFTUI_SWIFT_NAME(CAFilterArrayCreate()); OPENSWIFTUI_EXPORT void _CAFilterArrayAppend(NSMutableArray *array, CAFilter *filter) OPENSWIFTUI_SWIFT_NAME(CAFilterArrayAppend(_:_:)); +OPENSWIFTUI_EXPORT id _Nullable _ORBBlendModeGetCompositingFilter(ORBBlendMode blendMode, bool compositingGroup) OPENSWIFTUI_SWIFT_NAME(ORBBlendModeGetCompositingFilter(_:compositingGroup:)); +// Returns normal for nil and -1 for an unrecognized filter name. +OPENSWIFTUI_EXPORT ORBBlendMode _CACompositingFilterGetORBBlendMode(id _Nullable filter) OPENSWIFTUI_SWIFT_NAME(CACompositingFilterGetORBBlendMode(_:)); OPENSWIFTUI_ASSUME_NONNULL_END diff --git a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.m b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.m index b0e85dffe..fb675a358 100644 --- a/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.m +++ b/Sources/OpenSwiftUI_SPI/Shims/QuartzCore/CAFilterPrivate.m @@ -23,6 +23,43 @@ extern NSString * const kCAFilterVariableBlur; extern NSString * const kCAFilterVibrantColorMatrix; +extern NSString * const kCAFilterClear; +extern NSString * const kCAFilterColorBlendMode; +extern NSString * const kCAFilterColorBurnBlendMode; +extern NSString * const kCAFilterColorDodgeBlendMode; +extern NSString * const kCAFilterCopy; +extern NSString * const kCAFilterDarkenBlendMode; +extern NSString * const kCAFilterDarkenSourceOver; +extern NSString * const kCAFilterDestAtop; +extern NSString * const kCAFilterDestIn; +extern NSString * const kCAFilterDestOut; +extern NSString * const kCAFilterDestOver; +extern NSString * const kCAFilterDifferenceBlendMode; +extern NSString * const kCAFilterDivideBlendMode; +extern NSString * const kCAFilterExclusionBlendMode; +extern NSString * const kCAFilterHardLightBlendMode; +extern NSString * const kCAFilterHueBlendMode; +extern NSString * const kCAFilterLightenBlendMode; +extern NSString * const kCAFilterLightenSourceOver; +extern NSString * const kCAFilterLinearBurnBlendMode; +extern NSString * const kCAFilterLinearDodgeBlendMode; +extern NSString * const kCAFilterLinearLightBlendMode; +extern NSString * const kCAFilterLuminosityBlendMode; +extern NSString * const kCAFilterMaximum; +extern NSString * const kCAFilterMultiplyBlendMode; +extern NSString * const kCAFilterOverlayBlendMode; +extern NSString * const kCAFilterPinLightBlendMode; +extern NSString * const kCAFilterPlusD; +extern NSString * const kCAFilterPlusL; +extern NSString * const kCAFilterSaturationBlendMode; +extern NSString * const kCAFilterScreenBlendMode; +extern NSString * const kCAFilterSoftLightBlendMode; +extern NSString * const kCAFilterSourceAtop; +extern NSString * const kCAFilterSourceIn; +extern NSString * const kCAFilterSourceOut; +extern NSString * const kCAFilterSubtractBlendMode; +extern NSString * const kCAFilterXor; + extern NSString * const kCAFilterInputAlphaValues; extern NSString * const kCAFilterInputAmount; extern NSString * const kCAFilterInputAngle; @@ -111,4 +148,161 @@ void _CAFilterArrayAppend(NSMutableArray *array, CAFilter *filter) { CFArrayAppendValue((__bridge CFMutableArrayRef)array, (__bridge const void *)filter); } +id _ORBBlendModeGetCompositingFilter(ORBBlendMode blendMode, bool compositingGroup) { + switch (blendMode) { + case ORBBlendModeMultiply: return kCAFilterMultiplyBlendMode; + case ORBBlendModeScreen: return kCAFilterScreenBlendMode; + case ORBBlendModeOverlay: return kCAFilterOverlayBlendMode; + case ORBBlendModeDarken: return kCAFilterDarkenBlendMode; + case ORBBlendModeLighten: return kCAFilterLightenBlendMode; + case ORBBlendModeColorDodge: return kCAFilterColorDodgeBlendMode; + case ORBBlendModeColorBurn: return kCAFilterColorBurnBlendMode; + case ORBBlendModeSoftLight: return kCAFilterSoftLightBlendMode; + case ORBBlendModeHardLight: return kCAFilterHardLightBlendMode; + case ORBBlendModeDifference: return kCAFilterDifferenceBlendMode; + case ORBBlendModeExclusion: return kCAFilterExclusionBlendMode; + case ORBBlendModeHue: return kCAFilterHueBlendMode; + case ORBBlendModeSaturation: return kCAFilterSaturationBlendMode; + case ORBBlendModeColor: return kCAFilterColorBlendMode; + case ORBBlendModeLuminosity: return kCAFilterLuminosityBlendMode; + case ORBBlendModeClear: return kCAFilterClear; + case ORBBlendModeCopy: return kCAFilterCopy; + case ORBBlendModeSourceIn: return kCAFilterSourceIn; + case ORBBlendModeSourceOut: return kCAFilterSourceOut; + case ORBBlendModeSourceAtop: return kCAFilterSourceAtop; + case ORBBlendModeDestinationOver: return kCAFilterDestOver; + case ORBBlendModeDestinationIn: return kCAFilterDestIn; + case ORBBlendModeDestinationOut: return kCAFilterDestOut; + case ORBBlendModeDestinationAtop: return kCAFilterDestAtop; + case ORBBlendModeXOR: return kCAFilterXor; + case ORBBlendModePlusDarker: return kCAFilterPlusD; + case ORBBlendModePlusLighter: return kCAFilterPlusL; + case ORBBlendModeLinearDodge: return kCAFilterLinearDodgeBlendMode; + case ORBBlendModeLinearBurn: return kCAFilterLinearBurnBlendMode; + case ORBBlendModeLinearLight: return kCAFilterLinearLightBlendMode; + case ORBBlendModePinLight: return kCAFilterPinLightBlendMode; + case ORBBlendModeSubtract: return kCAFilterSubtractBlendMode; + case ORBBlendModeDivide: return kCAFilterDivideBlendMode; + case ORBBlendModeMaximum: return kCAFilterMaximum; + case ORBBlendModeDarkenSourceOver: return kCAFilterDarkenSourceOver; + case ORBBlendModeLightenSourceOver: return kCAFilterLightenSourceOver; + default: return nil; + } +} + +ORBBlendMode _CACompositingFilterGetORBBlendMode(id filter) { + if (!filter) { + return ORBBlendModeNormal; + } + if ([filter isEqualToString:kCAFilterMultiplyBlendMode]) { + return ORBBlendModeMultiply; + } + if ([filter isEqualToString:kCAFilterScreenBlendMode]) { + return ORBBlendModeScreen; + } + if ([filter isEqualToString:kCAFilterOverlayBlendMode]) { + return ORBBlendModeOverlay; + } + if ([filter isEqualToString:kCAFilterDarkenBlendMode]) { + return ORBBlendModeDarken; + } + if ([filter isEqualToString:kCAFilterLightenBlendMode]) { + return ORBBlendModeLighten; + } + if ([filter isEqualToString:kCAFilterColorDodgeBlendMode]) { + return ORBBlendModeColorDodge; + } + if ([filter isEqualToString:kCAFilterColorBurnBlendMode]) { + return ORBBlendModeColorBurn; + } + if ([filter isEqualToString:kCAFilterSoftLightBlendMode]) { + return ORBBlendModeSoftLight; + } + if ([filter isEqualToString:kCAFilterHardLightBlendMode]) { + return ORBBlendModeHardLight; + } + if ([filter isEqualToString:kCAFilterDifferenceBlendMode]) { + return ORBBlendModeDifference; + } + if ([filter isEqualToString:kCAFilterExclusionBlendMode]) { + return ORBBlendModeExclusion; + } + if ([filter isEqualToString:kCAFilterHueBlendMode]) { + return ORBBlendModeHue; + } + if ([filter isEqualToString:kCAFilterSaturationBlendMode]) { + return ORBBlendModeSaturation; + } + if ([filter isEqualToString:kCAFilterColorBlendMode]) { + return ORBBlendModeColor; + } + if ([filter isEqualToString:kCAFilterLuminosityBlendMode]) { + return ORBBlendModeLuminosity; + } + if ([filter isEqualToString:kCAFilterClear]) { + return ORBBlendModeClear; + } + if ([filter isEqualToString:kCAFilterCopy]) { + return ORBBlendModeCopy; + } + if ([filter isEqualToString:kCAFilterSourceIn]) { + return ORBBlendModeSourceIn; + } + if ([filter isEqualToString:kCAFilterSourceOut]) { + return ORBBlendModeSourceOut; + } + if ([filter isEqualToString:kCAFilterSourceAtop]) { + return ORBBlendModeSourceAtop; + } + if ([filter isEqualToString:kCAFilterDestOver]) { + return ORBBlendModeDestinationOver; + } + if ([filter isEqualToString:kCAFilterDestIn]) { + return ORBBlendModeDestinationIn; + } + if ([filter isEqualToString:kCAFilterDestOut]) { + return ORBBlendModeDestinationOut; + } + if ([filter isEqualToString:kCAFilterDestAtop]) { + return ORBBlendModeDestinationAtop; + } + if ([filter isEqualToString:kCAFilterXor]) { + return ORBBlendModeXOR; + } + if ([filter isEqualToString:kCAFilterPlusD]) { + return ORBBlendModePlusDarker; + } + if ([filter isEqualToString:kCAFilterPlusL]) { + return ORBBlendModePlusLighter; + } + if ([filter isEqualToString:kCAFilterLinearDodgeBlendMode]) { + return ORBBlendModeLinearDodge; + } + if ([filter isEqualToString:kCAFilterLinearBurnBlendMode]) { + return ORBBlendModeLinearBurn; + } + if ([filter isEqualToString:kCAFilterLinearLightBlendMode]) { + return ORBBlendModeLinearLight; + } + if ([filter isEqualToString:kCAFilterPinLightBlendMode]) { + return ORBBlendModePinLight; + } + if ([filter isEqualToString:kCAFilterSubtractBlendMode]) { + return ORBBlendModeSubtract; + } + if ([filter isEqualToString:kCAFilterDivideBlendMode]) { + return ORBBlendModeDivide; + } + if ([filter isEqualToString:kCAFilterMaximum]) { + return ORBBlendModeMaximum; + } + if ([filter isEqualToString:kCAFilterDarkenSourceOver]) { + return ORBBlendModeDarkenSourceOver; + } + if ([filter isEqualToString:kCAFilterLightenSourceOver]) { + return ORBBlendModeLightenSourceOver; + } + return (ORBBlendMode)-1; +} + #endif /* CoreAnimation.h */ diff --git a/Tests/OpenSwiftUI_SPITests/Shims/CACompositingFilterTests.swift b/Tests/OpenSwiftUI_SPITests/Shims/CACompositingFilterTests.swift new file mode 100644 index 000000000..16830f2f6 --- /dev/null +++ b/Tests/OpenSwiftUI_SPITests/Shims/CACompositingFilterTests.swift @@ -0,0 +1,79 @@ +// +// CACompositingFilterTests.swift +// OpenSwiftUI_SPITests + +import Foundation +#if canImport(QuartzCore) +import QuartzCore_Private +#endif +import Testing + +#if canImport(QuartzCore) +struct CACompositingFilterTests { + @Test(arguments: [ + (1, "multiplyBlendMode"), + (2, "screenBlendMode"), + (3, "overlayBlendMode"), + (4, "darkenBlendMode"), + (5, "lightenBlendMode"), + (6, "colorDodgeBlendMode"), + (7, "colorBurnBlendMode"), + (8, "softLightBlendMode"), + (9, "hardLightBlendMode"), + (10, "differenceBlendMode"), + (11, "exclusionBlendMode"), + (12, "hueBlendMode"), + (13, "saturationBlendMode"), + (14, "colorBlendMode"), + (15, "luminosityBlendMode"), + (16, "clear"), + (17, "copy"), + (18, "sourceIn"), + (19, "sourceOut"), + (20, "sourceAtop"), + (21, "destOver"), + (22, "destIn"), + (23, "destOut"), + (24, "destAtop"), + (25, "xor"), + (26, "plusD"), + (27, "plusL"), + (1000, "linearDodgeBlendMode"), + (1001, "linearBurnBlendMode"), + (1002, "linearLightBlendMode"), + (1003, "pinLightBlendMode"), + (1004, "subtractBlendMode"), + (1005, "divideBlendMode"), + (1006, "maximum"), + (1010, "darkenSourceOver"), + (1011, "lightenSourceOver"), + ] as [(Int32, String)], [false, true]) + func knownModes(_ value: (Int32, String), compositingGroup: Bool) { + let filter = ORBBlendModeGetCompositingFilter(.init(rawValue: value.0), compositingGroup: compositingGroup) + #expect(filter as? String == value.1) + #expect(CACompositingFilterGetORBBlendMode(value.1).rawValue == value.0) + #expect(CACompositingFilterGetORBBlendMode(NSMutableString(string: value.1)).rawValue == value.0) + } + + @Test(arguments: [ + Int32.min, -1, 0, 28, 999, 1007, 1008, 1009, 1012, 1013, 2000, Int32.max, + ], [false, true]) + func unmappedModes(_ mode: Int32, compositingGroup: Bool) { + #expect(ORBBlendModeGetCompositingFilter(.init(rawValue: mode), compositingGroup: compositingGroup) == nil) + } + + @Test + func absentFilter() { + #expect(CACompositingFilterGetORBBlendMode(nil).rawValue == 0) + } + + @Test(arguments: [ + "", "normal", "sourceOver", "colorBurn", "ColorBurnBlendMode", + "colorBurnBlendMode ", "colorBurnBlendMode\0", "gaussianBlur", + "minimum", "passThrough", "unknown", + ]) + func unrecognizedFilters(_ name: String) { + #expect(CACompositingFilterGetORBBlendMode(name).rawValue == -1) + } +} +#endif