diff --git a/samples/RenderDemo/Pages/AnimationsPage.xaml b/samples/RenderDemo/Pages/AnimationsPage.xaml
index 3f89a9d5f7..d764af8978 100644
--- a/samples/RenderDemo/Pages/AnimationsPage.xaml
+++ b/samples/RenderDemo/Pages/AnimationsPage.xaml
@@ -308,6 +308,41 @@
+
+
@@ -332,6 +367,11 @@
+
+
+ Drop
+ Shadow
+
diff --git a/src/Avalonia.Base/Media/Effects/BlurEffect.cs b/src/Avalonia.Base/Media/Effects/BlurEffect.cs
new file mode 100644
index 0000000000..47c86e4e42
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/BlurEffect.cs
@@ -0,0 +1,22 @@
+using System;
+// ReSharper disable CheckNamespace
+namespace Avalonia.Media;
+
+public class BlurEffect : Effect, IBlurEffect, IMutableEffect
+{
+ public static readonly StyledProperty RadiusProperty = AvaloniaProperty.Register(
+ nameof(Radius), 5);
+
+ public double Radius
+ {
+ get => GetValue(RadiusProperty);
+ set => SetValue(RadiusProperty, value);
+ }
+
+ static BlurEffect()
+ {
+ AffectsRender(RadiusProperty);
+ }
+
+ public IImmutableEffect ToImmutable() => new ImmutableBlurEffect(Radius);
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs
new file mode 100644
index 0000000000..67be74fe49
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/DropShadowEffect.cs
@@ -0,0 +1,104 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+// ReSharper disable CheckNamespace
+
+namespace Avalonia.Media;
+
+public abstract class DropShadowEffectBase : Effect
+{
+ public static readonly StyledProperty BlurRadiusProperty =
+ AvaloniaProperty.Register(
+ nameof(BlurRadius), 5);
+
+ public double BlurRadius
+ {
+ get => GetValue(BlurRadiusProperty);
+ set => SetValue(BlurRadiusProperty, value);
+ }
+
+ public static readonly StyledProperty ColorProperty = AvaloniaProperty.Register(
+ nameof(Color));
+
+ public Color Color
+ {
+ get => GetValue(ColorProperty);
+ set => SetValue(ColorProperty, value);
+ }
+
+ public static readonly StyledProperty OpacityProperty =
+ AvaloniaProperty.Register(
+ nameof(Opacity), 1);
+
+ public double Opacity
+ {
+ get => GetValue(OpacityProperty);
+ set => SetValue(OpacityProperty, value);
+ }
+
+ static DropShadowEffectBase()
+ {
+ AffectsRender(BlurRadiusProperty, ColorProperty, OpacityProperty);
+ }
+}
+
+public class DropShadowEffect : DropShadowEffectBase, IDropShadowEffect, IMutableEffect
+{
+ public static readonly StyledProperty OffsetXProperty = AvaloniaProperty.Register(
+ nameof(OffsetX), 3.5355);
+
+ public double OffsetX
+ {
+ get => GetValue(OffsetXProperty);
+ set => SetValue(OffsetXProperty, value);
+ }
+
+ public static readonly StyledProperty OffsetYProperty = AvaloniaProperty.Register(
+ nameof(OffsetY), 3.5355);
+
+ public double OffsetY
+ {
+ get => GetValue(OffsetYProperty);
+ set => SetValue(OffsetYProperty, value);
+ }
+
+ static DropShadowEffect()
+ {
+ AffectsRender(OffsetXProperty, OffsetYProperty);
+ }
+
+ public IImmutableEffect ToImmutable()
+ {
+ return new ImmutableDropShadowEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity);
+ }
+}
+
+///
+/// This class is compatible with WPF's DropShadowEffect and provides Direction and ShadowDepth properties instead of OffsetX/OffsetY
+///
+public class DropShadowDirectionEffect : DropShadowEffectBase, IDirectionDropShadowEffect, IMutableEffect
+{
+ public static readonly StyledProperty ShadowDepthProperty =
+ AvaloniaProperty.Register(
+ nameof(ShadowDepth), 5);
+
+ public double ShadowDepth
+ {
+ get => GetValue(ShadowDepthProperty);
+ set => SetValue(ShadowDepthProperty, value);
+ }
+
+ public static readonly StyledProperty DirectionProperty = AvaloniaProperty.Register(
+ nameof(Direction), 315);
+
+ public double Direction
+ {
+ get => GetValue(DirectionProperty);
+ set => SetValue(DirectionProperty, value);
+ }
+
+ public double OffsetX => Math.Cos(Direction) * ShadowDepth;
+ public double OffsetY => Math.Sin(Direction) * ShadowDepth;
+
+ public IImmutableEffect ToImmutable() => new ImmutableDropShadowDirectionEffect(OffsetX, OffsetY, BlurRadius, Color, Opacity);
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/Effect.cs b/src/Avalonia.Base/Media/Effects/Effect.cs
new file mode 100644
index 0000000000..182e8613f8
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/Effect.cs
@@ -0,0 +1,93 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Animation.Animators;
+using Avalonia.Reactive;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Utilities;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Media;
+
+public class Effect : Animatable, IAffectsRender
+{
+ ///
+ /// Marks a property as affecting the brush's visual representation.
+ ///
+ /// The properties.
+ ///
+ /// After a call to this method in a brush's static constructor, any change to the
+ /// property will cause the event to be raised on the brush.
+ ///
+ protected static void AffectsRender(params AvaloniaProperty[] properties)
+ where T : Effect
+ {
+ var invalidateObserver = new AnonymousObserver(
+ static e => (e.Sender as T)?.RaiseInvalidated(EventArgs.Empty));
+
+ foreach (var property in properties)
+ {
+ property.Changed.Subscribe(invalidateObserver);
+ }
+ }
+
+ ///
+ /// Raises the event.
+ ///
+ /// The event args.
+ protected void RaiseInvalidated(EventArgs e) => Invalidated?.Invoke(this, e);
+
+ ///
+ public event EventHandler? Invalidated;
+
+
+ static Exception ParseError(string s) => throw new ArgumentException("Unable to parse effect: " + s);
+ public static IEffect Parse(string s)
+ {
+ var span = s.AsSpan();
+ var r = new TokenParser(span);
+ if (r.TryConsume("blur"))
+ {
+ if (!r.TryConsume('(') || !r.TryParseDouble(out var radius) || !r.TryConsume(')') || !r.IsEofWithWhitespace())
+ throw ParseError(s);
+ return new ImmutableBlurEffect(radius);
+ }
+
+
+ if (r.TryConsume("drop-shadow"))
+ {
+ if (!r.TryConsume('(') || !r.TryParseDouble(out var offsetX)
+ || !r.TryParseDouble(out var offsetY))
+ throw ParseError(s);
+ double blurRadius = 0;
+ var color = Colors.Black;
+ if (!r.TryConsume(')'))
+ {
+ if (!r.TryParseDouble(out blurRadius) || blurRadius < 0)
+ throw ParseError(s);
+ if (!r.TryConsume(')'))
+ {
+ var endOfExpression = s.LastIndexOf(")", StringComparison.Ordinal);
+ if (endOfExpression == -1)
+ throw ParseError(s);
+
+ if (!new TokenParser(span.Slice(endOfExpression + 1)).IsEofWithWhitespace())
+ throw ParseError(s);
+
+ if (!Color.TryParse(span.Slice(r.Position, endOfExpression - r.Position).TrimEnd(), out color))
+ throw ParseError(s);
+ return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1);
+ }
+ }
+ if (!r.IsEofWithWhitespace())
+ throw ParseError(s);
+ return new ImmutableDropShadowEffect(offsetX, offsetY, blurRadius, color, 1);
+ }
+
+ throw ParseError(s);
+ }
+
+ static Effect()
+ {
+ EffectAnimator.EnsureRegistered();
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs
new file mode 100644
index 0000000000..70d359911b
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Data;
+using Avalonia.Logging;
+using Avalonia.Media;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Animation.Animators;
+
+public class EffectAnimator : Animator
+{
+ public override IDisposable? Apply(Animation animation, Animatable control, IClock? clock,
+ IObservable match, Action? onComplete)
+ {
+ if (TryCreateAnimator(out var animator)
+ || TryCreateAnimator(out animator))
+ return animator.Apply(animation, control, clock, match, onComplete);
+
+ Logger.TryGet(LogEventLevel.Error, LogArea.Animations)?.Log(
+ this,
+ "The animation's keyframe value types set is not supported.");
+
+ return base.Apply(animation, control, clock, match, onComplete);
+ }
+
+ private bool TryCreateAnimator([NotNullWhen(true)] out IAnimator? animator)
+ where TAnimator : EffectAnimatorBase, new() where TInterface : class, IEffect
+ {
+ TAnimator? createdAnimator = null;
+ foreach (var keyFrame in this)
+ {
+ if (keyFrame.Value is TInterface)
+ {
+ createdAnimator ??= new TAnimator()
+ {
+ Property = Property
+ };
+ createdAnimator.Add(new AnimatorKeyFrame(typeof(TAnimator), () => new TAnimator(), keyFrame.Cue,
+ keyFrame.KeySpline)
+ {
+ Value = keyFrame.Value
+ });
+ }
+ else
+ {
+ animator = null;
+ return false;
+ }
+ }
+
+ animator = createdAnimator;
+ return animator != null;
+ }
+
+ ///
+ /// Fallback implementation of animation.
+ ///
+ public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue) => progress >= 0.5 ? newValue : oldValue;
+
+ private static bool s_Registered;
+ public static void EnsureRegistered()
+ {
+ if(s_Registered)
+ return;
+ s_Registered = true;
+ Animation.RegisterAnimator(prop =>
+ typeof(IEffect).IsAssignableFrom(prop.PropertyType));
+ }
+}
+
+public abstract class EffectAnimatorBase : Animator where T : class, IEffect?
+{
+ public override IDisposable BindAnimation(Animatable control, IObservable instance)
+ {
+ if (Property is null)
+ {
+ throw new InvalidOperationException("Animator has no property specified.");
+ }
+
+ return control.Bind((AvaloniaProperty)Property, instance, BindingPriority.Animation);
+ }
+
+ protected abstract T Interpolate(double progress, T oldValue, T newValue);
+ public override IEffect? Interpolate(double progress, IEffect? oldValue, IEffect? newValue)
+ {
+ var old = oldValue as T;
+ var n = newValue as T;
+ if (old == null || n == null)
+ return progress >= 0.5 ? newValue : oldValue;
+ return Interpolate(progress, old, n);
+ }
+}
+
+public class BlurEffectAnimator : EffectAnimatorBase
+{
+ private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator();
+
+ protected override IBlurEffect Interpolate(double progress, IBlurEffect oldValue, IBlurEffect newValue)
+ {
+ return new ImmutableBlurEffect(
+ s_doubleAnimator.Interpolate(progress, oldValue.Radius, newValue.Radius));
+ }
+}
+
+public class DropShadowEffectAnimator : EffectAnimatorBase
+{
+ private static readonly DoubleAnimator s_doubleAnimator = new DoubleAnimator();
+
+ protected override IDropShadowEffect Interpolate(double progress, IDropShadowEffect oldValue,
+ IDropShadowEffect newValue)
+ {
+ var blur = s_doubleAnimator.Interpolate(progress, oldValue.BlurRadius, newValue.BlurRadius);
+ var color = ColorAnimator.InterpolateCore(progress, oldValue.Color, newValue.Color);
+ var opacity = s_doubleAnimator.Interpolate(progress, oldValue.Opacity, newValue.Opacity);
+
+ if (oldValue is IDirectionDropShadowEffect oldDirection && newValue is IDirectionDropShadowEffect newDirection)
+ {
+ return new ImmutableDropShadowDirectionEffect(
+ s_doubleAnimator.Interpolate(progress, oldDirection.Direction, newDirection.Direction),
+ s_doubleAnimator.Interpolate(progress, oldDirection.ShadowDepth, newDirection.ShadowDepth),
+ blur, color, opacity
+ );
+ }
+
+ return new ImmutableDropShadowEffect(
+ s_doubleAnimator.Interpolate(progress, oldValue.OffsetX, newValue.OffsetX),
+ s_doubleAnimator.Interpolate(progress, oldValue.OffsetY, newValue.OffsetY),
+ blur, color, opacity
+ );
+ }
+}
diff --git a/src/Avalonia.Base/Media/Effects/EffectConverter.cs b/src/Avalonia.Base/Media/Effects/EffectConverter.cs
new file mode 100644
index 0000000000..6ec3bace03
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/EffectConverter.cs
@@ -0,0 +1,18 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+
+namespace Avalonia.Media;
+
+public class EffectConverter : TypeConverter
+{
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ {
+ return sourceType == typeof(string);
+ }
+
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object? value)
+ {
+ return value is string s ? Effect.Parse(s) : null;
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/EffectExtesions.cs b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs
new file mode 100644
index 0000000000..adc287607b
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/EffectExtesions.cs
@@ -0,0 +1,56 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Media;
+
+public static class EffectExtensions
+{
+ static double AdjustPaddingRadius(double radius)
+ {
+ if (radius <= 0)
+ return 0;
+ return Math.Ceiling(radius) + 1;
+ }
+ internal static Thickness GetEffectOutputPadding(this IEffect? effect)
+ {
+ if (effect == null)
+ return default;
+ if (effect is IBlurEffect blur)
+ return new Thickness(AdjustPaddingRadius(blur.Radius));
+ if (effect is IDropShadowEffect dropShadowEffect)
+ {
+ var radius = AdjustPaddingRadius(dropShadowEffect.BlurRadius);
+ var rc = new Rect(-radius, -radius,
+ radius * 2, radius * 2);
+ rc = rc.Translate(new(dropShadowEffect.OffsetX, dropShadowEffect.OffsetY));
+ return new Thickness(Math.Max(0, 0 - rc.X),
+ Math.Max(0, 0 - rc.Y), Math.Max(0, rc.Right), Math.Max(0, rc.Bottom));
+ }
+
+ throw new ArgumentException("Unknown effect type: " + effect.GetType());
+ }
+
+ ///
+ /// Converts a effect to an immutable effect.
+ ///
+ /// The effect.
+ ///
+ /// The result of calling if the effect is mutable,
+ /// otherwise .
+ ///
+ public static IImmutableEffect ToImmutable(this IEffect effect)
+ {
+ _ = effect ?? throw new ArgumentNullException(nameof(effect));
+
+ return (effect as IMutableEffect)?.ToImmutable() ?? (IImmutableEffect)effect;
+ }
+
+ internal static bool EffectEquals(this IImmutableEffect? immutable, IEffect? right)
+ {
+ if (immutable == null && right == null)
+ return true;
+ if (immutable != null && right != null)
+ return immutable.Equals(right);
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/EffectTransition.cs b/src/Avalonia.Base/Media/Effects/EffectTransition.cs
new file mode 100644
index 0000000000..b2e0d07355
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/EffectTransition.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Animation.Animators;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+
+
+// ReSharper disable once CheckNamespace
+namespace Avalonia.Animation;
+
+///
+/// Transition class that handles with type.
+///
+public class EffectTransition : Transition
+{
+ private static readonly BlurEffectAnimator s_blurEffectAnimator = new();
+ private static readonly DropShadowEffectAnimator s_dropShadowEffectAnimator = new();
+ private static readonly ImmutableBlurEffect s_DefaultBlur = new ImmutableBlurEffect(0);
+ private static readonly ImmutableDropShadowDirectionEffect s_DefaultDropShadow = new(0, 0, 0, default, 0);
+
+ bool TryWithAnimator(
+ IObservable progress,
+ TAnimator animator,
+ IEffect? oldValue, IEffect? newValue, TInterface defaultValue, [MaybeNullWhen(false)] out IObservable observable)
+ where TAnimator : EffectAnimatorBase where TInterface : class, IEffect
+ {
+ observable = null;
+ TInterface? oldI = null, newI = null;
+ if (oldValue is TInterface oi)
+ {
+ oldI = oi;
+ if (newValue is TInterface ni)
+ newI = ni;
+ else if (newValue == null)
+ newI = defaultValue;
+ else
+ return false;
+ }
+ else if (newValue is TInterface nv)
+ {
+ oldI = defaultValue;
+ newI = nv;
+
+ }
+ else
+ return false;
+
+ observable = new AnimatorTransitionObservable>(animator, progress, Easing, oldI, newI);
+ return true;
+
+ }
+
+ public override IObservable DoTransition(IObservable progress, IEffect? oldValue, IEffect? newValue)
+ {
+ if ((oldValue != null || newValue != null)
+ && (
+ TryWithAnimator(progress, s_blurEffectAnimator,
+ oldValue, newValue, s_DefaultBlur, out var observable)
+ || TryWithAnimator(progress, s_dropShadowEffectAnimator,
+ oldValue, newValue, s_DefaultDropShadow, out observable)
+ ))
+ return observable;
+
+ return new IncompatibleTransitionObservable(progress, Easing, oldValue, newValue);
+ }
+
+ private sealed class IncompatibleTransitionObservable : TransitionObservableBase
+ {
+ private readonly IEffect? _from;
+ private readonly IEffect? _to;
+
+ public IncompatibleTransitionObservable(IObservable progress, Easing easing, IEffect? from, IEffect? to) : base(progress, easing)
+ {
+ _from = from;
+ _to = to;
+ }
+
+ protected override IEffect? ProduceValue(double progress)
+ {
+ return progress >= 0.5 ? _to : _from;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/IBlurEffect.cs b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs
new file mode 100644
index 0000000000..716159747c
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/IBlurEffect.cs
@@ -0,0 +1,29 @@
+// ReSharper disable once CheckNamespace
+
+using Avalonia.Animation.Animators;
+
+namespace Avalonia.Media;
+
+public interface IBlurEffect : IEffect
+{
+ double Radius { get; }
+}
+
+public class ImmutableBlurEffect : IBlurEffect, IImmutableEffect
+{
+ static ImmutableBlurEffect()
+ {
+ EffectAnimator.EnsureRegistered();
+ }
+
+ public ImmutableBlurEffect(double radius)
+ {
+ Radius = radius;
+ }
+
+ public double Radius { get; }
+
+ public bool Equals(IEffect? other) =>
+ // ReSharper disable once CompareOfFloatsByEqualityOperator
+ other is IBlurEffect blur && blur.Radius == Radius;
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs
new file mode 100644
index 0000000000..30d787198c
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/IDropShadowEffect.cs
@@ -0,0 +1,84 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+using Avalonia.Animation.Animators;
+
+namespace Avalonia.Media;
+
+public interface IDropShadowEffect : IEffect
+{
+ double OffsetX { get; }
+ double OffsetY { get; }
+ double BlurRadius { get; }
+ Color Color { get; }
+ double Opacity { get; }
+}
+
+internal interface IDirectionDropShadowEffect : IDropShadowEffect
+{
+ double Direction { get; }
+ double ShadowDepth { get; }
+}
+
+public class ImmutableDropShadowEffect : IDropShadowEffect, IImmutableEffect
+{
+ static ImmutableDropShadowEffect()
+ {
+ EffectAnimator.EnsureRegistered();
+ }
+
+ public ImmutableDropShadowEffect(double offsetX, double offsetY, double blurRadius, Color color, double opacity)
+ {
+ OffsetX = offsetX;
+ OffsetY = offsetY;
+ BlurRadius = blurRadius;
+ Color = color;
+ Opacity = opacity;
+ }
+
+ public double OffsetX { get; }
+ public double OffsetY { get; }
+ public double BlurRadius { get; }
+ public Color Color { get; }
+ public double Opacity { get; }
+ public bool Equals(IEffect? other)
+ {
+ return other is IDropShadowEffect d
+ && d.OffsetX == OffsetX && d.OffsetY == OffsetY
+ && d.BlurRadius == BlurRadius
+ && d.Color == Color && d.Opacity == Opacity;
+ }
+}
+
+
+public class ImmutableDropShadowDirectionEffect : IDirectionDropShadowEffect, IImmutableEffect
+{
+ static ImmutableDropShadowDirectionEffect()
+ {
+ EffectAnimator.EnsureRegistered();
+ }
+
+ public ImmutableDropShadowDirectionEffect(double direction, double shadowDepth, double blurRadius, Color color, double opacity)
+ {
+ Direction = direction;
+ ShadowDepth = shadowDepth;
+ BlurRadius = blurRadius;
+ Color = color;
+ Opacity = opacity;
+ }
+
+ public double OffsetX => Math.Cos(Direction) * ShadowDepth;
+ public double OffsetY => Math.Sin(Direction) * ShadowDepth;
+ public double Direction { get; }
+ public double ShadowDepth { get; }
+ public double BlurRadius { get; }
+ public Color Color { get; }
+ public double Opacity { get; }
+ public bool Equals(IEffect? other)
+ {
+ return other is IDropShadowEffect d
+ && d.OffsetX == OffsetX && d.OffsetY == OffsetY
+ && d.BlurRadius == BlurRadius
+ && d.Color == Color && d.Opacity == Opacity;
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Media/Effects/IEffect.cs b/src/Avalonia.Base/Media/Effects/IEffect.cs
new file mode 100644
index 0000000000..698dccf1dd
--- /dev/null
+++ b/src/Avalonia.Base/Media/Effects/IEffect.cs
@@ -0,0 +1,26 @@
+// ReSharper disable once CheckNamespace
+
+using System;
+using System.ComponentModel;
+
+namespace Avalonia.Media;
+
+[TypeConverter(typeof(EffectConverter))]
+public interface IEffect
+{
+
+}
+
+public interface IMutableEffect : IEffect, IAffectsRender
+{
+ ///
+ /// Creates an immutable clone of the effect.
+ ///
+ /// The immutable clone.
+ internal IImmutableEffect ToImmutable();
+}
+
+public interface IImmutableEffect : IEffect, IEquatable
+{
+
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs
index ffdfa9aac1..1359ad6603 100644
--- a/src/Avalonia.Base/Platform/IDrawingContextImpl.cs
+++ b/src/Avalonia.Base/Platform/IDrawingContextImpl.cs
@@ -180,6 +180,12 @@ namespace Avalonia.Platform
object? GetFeature(Type t);
}
+ public interface IDrawingContextImplWithEffects
+ {
+ void PushEffect(IEffect effect);
+ void PopEffect();
+ }
+
public static class DrawingContextImplExtensions
{
///
diff --git a/src/Avalonia.Base/Rect.cs b/src/Avalonia.Base/Rect.cs
index cc030eea04..fc5d0fc043 100644
--- a/src/Avalonia.Base/Rect.cs
+++ b/src/Avalonia.Base/Rect.cs
@@ -526,6 +526,15 @@ namespace Avalonia
}
}
+ internal static Rect? Union(Rect? left, Rect? right)
+ {
+ if (left == null)
+ return right;
+ if (right == null)
+ return left;
+ return left.Value.Union(right.Value);
+ }
+
///
/// Returns a new with the specified X position.
///
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
index df3a70b3e6..814ecdba29 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
@@ -252,8 +252,14 @@ public class CompositingRenderer : IRendererWithCompositor
comp.Opacity = (float)visual.Opacity;
comp.ClipToBounds = visual.ClipToBounds;
comp.Clip = visual.Clip?.PlatformImpl;
- comp.OpacityMask = visual.OpacityMask;
-
+
+
+ if (!Equals(comp.OpacityMask, visual.OpacityMask))
+ comp.OpacityMask = visual.OpacityMask?.ToImmutable();
+
+ if (!comp.Effect.EffectEquals(visual.Effect))
+ comp.Effect = visual.Effect?.ToImmutable();
+
var renderTransform = Matrix.Identity;
if (visual.HasMirrorTransform)
diff --git a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs
index bb7372c375..8ecc0028ce 100644
--- a/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Expressions/TokenParser.cs
@@ -29,6 +29,8 @@ namespace Avalonia.Rendering.Composition.Expressions
}
}
+ public bool NextIsWhitespace() => _s.Length > 0 && char.IsWhiteSpace(_s[0]);
+
static bool IsAlphaNumeric(char ch) => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z');
@@ -238,6 +240,12 @@ namespace Avalonia.Rendering.Composition.Expressions
len = c + 1;
dotCount++;
}
+ else if (ch == '-')
+ {
+ if (len != 0)
+ return false;
+ len = c + 1;
+ }
else
break;
}
@@ -254,7 +262,55 @@ namespace Avalonia.Rendering.Composition.Expressions
Advance(len);
return true;
}
+
+ public bool TryParseDouble(out double res)
+ {
+ res = 0;
+ SkipWhitespace();
+ if (_s.Length == 0)
+ return false;
+
+ var len = 0;
+ var dotCount = 0;
+ for (var c = 0; c < _s.Length; c++)
+ {
+ var ch = _s[c];
+ if (ch >= '0' && ch <= '9')
+ len = c + 1;
+ else if (ch == '.' && dotCount == 0)
+ {
+ len = c + 1;
+ dotCount++;
+ }
+ else if (ch == '-')
+ {
+ if (len != 0)
+ return false;
+ len = c + 1;
+ }
+ else
+ break;
+ }
+
+ var span = _s.Slice(0, len);
+
+#if NETSTANDARD2_0
+ if (!double.TryParse(span.ToString(), NumberStyles.Number, CultureInfo.InvariantCulture, out res))
+ return false;
+#else
+ if (!double.TryParse(span, NumberStyles.Number, CultureInfo.InvariantCulture, out res))
+ return false;
+#endif
+ Advance(len);
+ return true;
+ }
+ public bool IsEofWithWhitespace()
+ {
+ SkipWhitespace();
+ return Length == 0;
+ }
+
public override string ToString() => _s.ToString();
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs
index eaa9a70ca0..1ec1362a4c 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/DrawingContextProxy.cs
@@ -18,7 +18,8 @@ namespace Avalonia.Rendering.Composition.Server;
/// they have information about the full render transform (they are not)
/// 2) Keeps the draw list for the VisualBrush contents of the current drawing operation.
///
-internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingContextWithAcrylicLikeSupport
+internal class CompositorDrawingContextProxy : IDrawingContextImpl,
+ IDrawingContextWithAcrylicLikeSupport, IDrawingContextImplWithEffects
{
private IDrawingContextImpl _impl;
@@ -155,4 +156,16 @@ internal class CompositorDrawingContextProxy : IDrawingContextImpl, IDrawingCont
if (_impl is IDrawingContextWithAcrylicLikeSupport acrylic)
acrylic.DrawRectangle(material, rect);
}
+
+ public void PushEffect(IEffect effect)
+ {
+ if (_impl is IDrawingContextImplWithEffects effects)
+ effects.PushEffect(effect);
+ }
+
+ public void PopEffect()
+ {
+ if (_impl is IDrawingContextImplWithEffects effects)
+ effects.PopEffect();
+ }
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs
index 19349a5196..b9e6833d21 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionContainerVisual.cs
@@ -1,4 +1,6 @@
+using System;
using System.Numerics;
+using Avalonia.Media;
using Avalonia.Platform;
// Special license applies License.md
@@ -13,6 +15,8 @@ namespace Avalonia.Rendering.Composition.Server
internal partial class ServerCompositionContainerVisual : ServerCompositionVisual
{
public ServerCompositionVisualCollection Children { get; private set; } = null!;
+ private Rect? _transformedContentBounds;
+ private IImmutableEffect? _oldEffect;
protected override void RenderCore(CompositorDrawingContextProxy canvas, Rect currentTransformedClip)
{
@@ -24,18 +28,76 @@ namespace Avalonia.Rendering.Composition.Server
}
}
- public override void Update(ServerCompositionTarget root)
+ public override UpdateResult Update(ServerCompositionTarget root)
{
- base.Update(root);
+ var (combinedBounds, oldInvalidated, newInvalidated) = base.Update(root);
foreach (var child in Children)
{
if (child.AdornedVisual != null)
root.EnqueueAdornerUpdate(child);
else
- child.Update(root);
+ {
+ var res = child.Update(root);
+ oldInvalidated |= res.InvalidatedOld;
+ newInvalidated |= res.InvalidatedNew;
+ combinedBounds = Rect.Union(combinedBounds, res.Bounds);
+ }
}
+
+ // If effect is changed, we need to clean both old and new bounds
+ var effectChanged = !Effect.EffectEquals(_oldEffect);
+ if (effectChanged)
+ oldInvalidated = newInvalidated = true;
+
+ // Expand invalidated bounds to the whole content area since we don't actually know what is being sampled
+ // We also ignore clip for now since we don't have means to reset it?
+ if (_oldEffect != null && oldInvalidated && _transformedContentBounds.HasValue)
+ AddEffectPaddedDirtyRect(_oldEffect, _transformedContentBounds.Value);
+
+ if (Effect != null && newInvalidated && combinedBounds.HasValue)
+ AddEffectPaddedDirtyRect(Effect, combinedBounds.Value);
+
+ _oldEffect = Effect;
+ _transformedContentBounds = combinedBounds;
IsDirtyComposition = false;
+ return new(_transformedContentBounds, oldInvalidated, newInvalidated);
+ }
+
+ void AddEffectPaddedDirtyRect(IImmutableEffect effect, Rect transformedBounds)
+ {
+ var padding = effect.GetEffectOutputPadding();
+ if (padding == default)
+ {
+ AddDirtyRect(transformedBounds);
+ return;
+ }
+
+ // We are in a weird position here: bounds are in global coordinates while padding gets applied in local ones
+ // Since we have optimizations to AVOID recomputing transformed bounds and since visuals with effects are relatively rare
+ // we instead apply the transformation matrix to rescale the bounds
+
+
+ // If we only have translation and scale, just scale the padding
+ if (CombinedTransformMatrix is
+ {
+ M12: 0, M13: 0, M14: 0,
+ M21: 0, M23: 0, M24: 0,
+ M31: 0, M32: 0, M34: 0,
+ M43: 0, M44: 1
+ })
+ padding = new Thickness(padding.Left * CombinedTransformMatrix.M11,
+ padding.Top * CombinedTransformMatrix.M22,
+ padding.Right * CombinedTransformMatrix.M11,
+ padding.Bottom * CombinedTransformMatrix.M22);
+ else
+ {
+ // Conservatively use the transformed rect size
+ var transformedPaddingRect = new Rect().Inflate(padding).TransformToAABB(CombinedTransformMatrix);
+ padding = new(Math.Max(transformedPaddingRect.Width, transformedPaddingRect.Height));
+ }
+
+ AddDirtyRect(transformedBounds.Inflate(padding));
}
partial void Initialize()
diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs
index 6fb5ad3741..6e7ef85183 100644
--- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs
@@ -54,6 +54,9 @@ namespace Avalonia.Rendering.Composition.Server
canvas.PostTransform = MatrixUtils.ToMatrix(transform);
canvas.Transform = Matrix.Identity;
+ if (Effect != null)
+ canvas.PushEffect(Effect);
+
if (Opacity != 1)
canvas.PushOpacity(Opacity, boundsRect);
if (ClipToBounds && !HandlesClipToBounds)
@@ -79,6 +82,9 @@ namespace Avalonia.Rendering.Composition.Server
canvas.PopClip();
if (Opacity != 1)
canvas.PopOpacity();
+
+ if (Effect != null)
+ canvas.PopEffect();
}
protected virtual bool HandlesClipToBounds => false;
@@ -101,10 +107,18 @@ namespace Avalonia.Rendering.Composition.Server
public Matrix4x4 CombinedTransformMatrix { get; private set; } = Matrix4x4.Identity;
public Matrix4x4 GlobalTransformMatrix { get; private set; }
- public virtual void Update(ServerCompositionTarget root)
+ public record struct UpdateResult(Rect? Bounds, bool InvalidatedOld, bool InvalidatedNew)
+ {
+ public UpdateResult() : this(null, false, false)
+ {
+
+ }
+ }
+
+ public virtual UpdateResult Update(ServerCompositionTarget root)
{
if (Parent == null && Root == null)
- return;
+ return default;
var wasVisible = IsVisibleInFrame;
@@ -146,6 +160,11 @@ namespace Avalonia.Rendering.Composition.Server
GlobalTransformMatrix = newTransform;
var ownBounds = OwnContentBounds;
+
+ // Since padding is applied in the current visual's coordinate space we expand bounds before transforming them
+ if (Effect != null)
+ ownBounds = ownBounds.Inflate(Effect.GetEffectOutputPadding());
+
if (ownBounds != _oldOwnContentBounds || positionChanged)
{
_oldOwnContentBounds = ownBounds;
@@ -168,7 +187,7 @@ namespace Avalonia.Rendering.Composition.Server
_combinedTransformedClipBounds =
AdornedVisual?._combinedTransformedClipBounds
- ?? Parent?._combinedTransformedClipBounds
+ ?? (Parent?.Effect == null ? Parent?._combinedTransformedClipBounds : null)
?? new Rect(Root!.Size);
if (_transformedClipBounds != null)
@@ -208,9 +227,10 @@ namespace Avalonia.Rendering.Composition.Server
readback.Matrix = GlobalTransformMatrix;
readback.TargetId = Root.Id;
readback.Visible = IsHitTestVisibleInFrame;
+ return new(TransformedOwnContentBounds, invalidateNewBounds, invalidateOldBounds);
}
- void AddDirtyRect(Rect rc)
+ protected void AddDirtyRect(Rect rc)
{
if (rc == default)
return;
diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs
index 05159eb4ae..79cc760fc6 100644
--- a/src/Avalonia.Base/Visual.cs
+++ b/src/Avalonia.Base/Visual.cs
@@ -48,7 +48,7 @@ namespace Avalonia
///
public static readonly StyledProperty ClipProperty =
AvaloniaProperty.Register(nameof(Clip));
-
+
///
/// Defines the property.
///
@@ -66,6 +66,12 @@ namespace Avalonia
///
public static readonly StyledProperty OpacityMaskProperty =
AvaloniaProperty.Register(nameof(OpacityMask));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty EffectProperty =
+ AvaloniaProperty.Register(nameof(Effect));
///
/// Defines the property.
@@ -127,6 +133,8 @@ namespace Avalonia
ClipToBoundsProperty,
IsVisibleProperty,
OpacityProperty,
+ OpacityMaskProperty,
+ EffectProperty,
HasMirrorTransformProperty);
RenderTransformProperty.Changed.Subscribe(RenderTransformChanged);
ZIndexProperty.Changed.Subscribe(ZIndexChanged);
@@ -233,6 +241,16 @@ namespace Avalonia
get { return GetValue(OpacityMaskProperty); }
set { SetValue(OpacityMaskProperty, value); }
}
+
+ ///
+ /// Gets or sets the effect of the control.
+ ///
+ public IEffect? Effect
+ {
+ get => GetValue(EffectProperty);
+ set => SetValue(EffectProperty, value);
+ }
+
///
/// Gets or sets a value indicating whether to apply mirror transform on this control.
diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml
index 31722974ee..91d718dfd8 100644
--- a/src/Avalonia.Base/composition-schema.xml
+++ b/src/Avalonia.Base/composition-schema.xml
@@ -6,7 +6,8 @@
Avalonia.Rendering.Composition.Animations
-
+
+