diff --git a/src/Avalonia.Base/ClassBindingManager.cs b/src/Avalonia.Base/ClassBindingManager.cs index 51dac66bb0..06421b7161 100644 --- a/src/Avalonia.Base/ClassBindingManager.cs +++ b/src/Avalonia.Base/ClassBindingManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Avalonia.Data; using Avalonia.Reactive; @@ -12,10 +13,87 @@ namespace Avalonia private static readonly Dictionary s_RegisteredProperties = new Dictionary(); - public static IDisposable Bind(StyledElement target, string className, BindingBase source, object anchor) + public static readonly AttachedProperty ClassesProperty = + AvaloniaProperty.RegisterAttached( + "Classes", typeof(ClassBindingManager), ""); + + private static readonly AttachedProperty?> BoundClassesProperty = + AvaloniaProperty.RegisterAttached?>( + "BoundClasses", typeof(ClassBindingManager)); + + public static void SetClasses(StyledElement element, string value) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + element.SetValue(ClassesProperty, value); + } + + public static string GetClasses(StyledElement element) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + return element.GetValue(ClassesProperty); + } + + private static void SetBoundClasses(StyledElement element, HashSet? value) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + element.SetValue(BoundClassesProperty, value); + } + + private static HashSet? GetBoundClasses(StyledElement element) + { + _ = element ?? throw new ArgumentNullException(nameof(element)); + return element.GetValue(BoundClassesProperty); + } + + static ClassBindingManager() + { + ClassesProperty.Changed.AddClassHandler(ClassesPropertyChanged); + } + + private static void ClassesPropertyChanged(StyledElement sender, AvaloniaPropertyChangedEventArgs e) + { + var boundClasses = GetBoundClasses(sender); + + var newValue = e.GetNewValue() ?? ""; + var newValues = newValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(c => boundClasses?.Contains(c) != true) + .ToList(); + var currentValues = sender.Classes + .Where(c => !c.StartsWith(":", StringComparison.Ordinal) && boundClasses?.Contains(c) != true) + .ToList(); + if (currentValues.SequenceEqual(newValues)) + return; + + sender.Classes.Replace(currentValues, newValues); + } + + private static void AddBoundClass(StyledElement target, string className) + { + var boundClasses = GetBoundClasses(target); + if (boundClasses == null) + { + boundClasses = []; + SetBoundClasses(target, boundClasses); + } + boundClasses.Add(className); + } + + public static IDisposable BindClasses(StyledElement target, BindingBase source, object? anchor) + { + return target.Bind(ClassesProperty, source, anchor); + } + + public static void SetClass(StyledElement target, string className, bool value) + { + AddBoundClass(target, className); + target.Classes.Set(className, value); + } + + public static IDisposable BindClass(StyledElement target, string className, BindingBase source, object? anchor) { + AddBoundClass(target, className); var prop = GetClassProperty(className); - return target.Bind(prop, source); + return target.Bind(prop, source, anchor); } [System.Diagnostics.CodeAnalysis.SuppressMessage("AvaloniaProperty", "AVP1001:The same AvaloniaProperty should not be registered twice", @@ -25,8 +103,8 @@ namespace Avalonia var prop = AvaloniaProperty.Register(ClassPropertyPrefix + className); prop.Changed.Subscribe(args => { - var classes = ((StyledElement)args.Sender).Classes; - classes.Set(className, args.NewValue.GetValueOrDefault()); + var sender = (StyledElement)args.Sender; + SetClass(sender, className, args.NewValue.GetValueOrDefault()); }); return prop; @@ -44,11 +122,10 @@ namespace Avalonia [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] public static bool IsClassesBindingProperty(AvaloniaProperty property, [NotNullWhen(true)] out string? classPropertyName) { - - classPropertyName = default; - if(property.Name?.StartsWith(ClassPropertyPrefix, StringComparison.OrdinalIgnoreCase) == true) + classPropertyName = null; + if (property.Name?.StartsWith(ClassPropertyPrefix, StringComparison.OrdinalIgnoreCase) == true) { - classPropertyName = property.Name.Substring(ClassPropertyPrefix.Length + 1); + classPropertyName = property.Name.Substring(ClassPropertyPrefix.Length); return true; } return false; diff --git a/src/Avalonia.Base/Controls/Classes.cs b/src/Avalonia.Base/Controls/Classes.cs index 611cc23992..51497d51d0 100644 --- a/src/Avalonia.Base/Controls/Classes.cs +++ b/src/Avalonia.Base/Controls/Classes.cs @@ -280,6 +280,26 @@ namespace Avalonia.Controls NotifyChanged(); } + internal void Replace(IList? toRemove, IList toAdd) + { + foreach (var name in toAdd) + { + ThrowIfPseudoclass(name, "added"); + } + + if (toRemove != null) + { + foreach (var name in toRemove) + { + ThrowIfPseudoclass(name, "removed"); + } + base.RemoveAll(toRemove); + } + + base.AddRange(toAdd); + NotifyChanged(); + } + /// void IPseudoClasses.Add(string name) { diff --git a/src/Avalonia.Base/StyledElementExtensions.cs b/src/Avalonia.Base/StyledElementExtensions.cs index beea29d7f7..246788de48 100644 --- a/src/Avalonia.Base/StyledElementExtensions.cs +++ b/src/Avalonia.Base/StyledElementExtensions.cs @@ -6,8 +6,17 @@ namespace Avalonia { public static class StyledElementExtensions { - public static IDisposable BindClass(this StyledElement target, string className, BindingBase source, object anchor) => - ClassBindingManager.Bind(target, className, source, anchor); + public static IDisposable BindClasses(this StyledElement target, BindingBase source, object? anchor = null) => + ClassBindingManager.BindClasses(target, source, anchor); + + public static void SetClasses(this StyledElement target, string classNames) => + ClassBindingManager.SetClasses(target, classNames); + + public static IDisposable BindClass(this StyledElement target, string className, BindingBase source, object? anchor = null) => + ClassBindingManager.BindClass(target, className, source, anchor); + + public static void SetClass(this StyledElement target, string className, bool value) => + ClassBindingManager.SetClass(target, className, value); public static AvaloniaProperty GetClassProperty(string className) => ClassBindingManager.GetClassProperty(className); diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs index 5b00ea0bea..58d0e19210 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/AvaloniaXamlIlCompiler.cs @@ -48,6 +48,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions // Targeted InsertBefore( new AvaloniaXamlIlResolveClassesPropertiesTransformer(), + new AvaloniaXamlIlResolveClassesPropertyTransformer(), new AvaloniaXamlIlTransformInstanceAttachedProperties(), new AvaloniaXamlIlTransformSyntheticCompiledBindingMembers()); diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlClassesPropertyResolver.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlClassesPropertyResolver.cs index 48118df40c..547e3b32d0 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlClassesPropertyResolver.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlClassesPropertyResolver.cs @@ -46,15 +46,11 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers public void Emit(IXamlILEmitter emitter) { using (var value = emitter.LocalsPool.GetLocal(_types.XamlIlTypes.Boolean)) - { emitter .Stloc(value.Local) - .EmitCall(_types.StyledElementClassesProperty.Getter!) .Ldstr(_className) - .Ldloc(value.Local) - .EmitCall(_types.Classes.GetMethod(new FindMethodMethodSignature("Set", - _types.XamlIlTypes.Void, _types.XamlIlTypes.String, _types.XamlIlTypes.Boolean))); - } + .Ldloc(value.Local); + emitter.EmitCall(_types.SetClassMethod); } public IXamlType TargetType => _types.StyledElement; @@ -86,7 +82,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers .Ldloc(bloc.Local) // TODO: provide anchor? .Ldnull(); - emitter.EmitCall(_types.ClassesBindMethod, true); + emitter.EmitCall(_types.BindClassMethod, true); } public IXamlType TargetType => _types.StyledElement; diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlResolveClassesPropertyTransformer.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlResolveClassesPropertyTransformer.cs new file mode 100644 index 0000000000..48fb4efce3 --- /dev/null +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlResolveClassesPropertyTransformer.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using XamlX.Ast; +using XamlX.Emit; +using XamlX.IL; +using XamlX.Transform; +using XamlX.TypeSystem; + +namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers +{ + class AvaloniaXamlIlResolveClassesPropertyTransformer : IXamlAstTransformer + { + public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) + { + var types = context.GetAvaloniaTypes(); + if (node is XamlAstNamePropertyReference prop && + prop.Name == "Classes" && + prop.TargetType is XamlAstClrTypeReference targetRef && + prop.DeclaringType is XamlAstClrTypeReference declaringRef && + types.StyledElement.IsAssignableFrom(targetRef.Type) && + types.StyledElement.IsAssignableFrom(declaringRef.Type) + ) + { + return new XamlAstClrProperty(node, prop.Name, types.StyledElement, types.StyledElementClassesProperty.Getter) + { + Setters = { new ClassesStringSetter(types), new ClassesBindingSetter(types) } + }; + } + return node; + } + + abstract class ClassesSetter(AvaloniaXamlIlWellKnownTypes types, IXamlType parameter) + : IXamlEmitablePropertySetter + { + public abstract void Emit(IXamlILEmitter emitter); + + protected AvaloniaXamlIlWellKnownTypes Types { get; } = types; + public IXamlType TargetType => Types.StyledElement; + public PropertySetterBinderParameters BinderParameters { get; } = new() { AllowXNull = false }; + public IReadOnlyList Parameters { get; } = [parameter]; + public IReadOnlyList CustomAttributes { get; } = []; + } + + class ClassesStringSetter(AvaloniaXamlIlWellKnownTypes types) + : ClassesSetter(types, types.XamlIlTypes.String) + { + public override void Emit(IXamlILEmitter emitter) + { + using (var value = emitter.LocalsPool.GetLocal(Parameters[0])) + emitter + .Stloc(value.Local) + .Ldloc(value.Local); + emitter.EmitCall(Types.SetClassesMethod); + } + } + + class ClassesBindingSetter(AvaloniaXamlIlWellKnownTypes types) + : ClassesSetter(types, types.BindingBase) + { + public override void Emit(IXamlILEmitter emitter) + { + using (var value = emitter.LocalsPool.GetLocal(Parameters[0])) + emitter + .Stloc(value.Local) + .Ldloc(value.Local) + // TODO: provide anchor? + .Ldnull(); + emitter.EmitCall(Types.BindClassesMethod, true); + } + } + } +} diff --git a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs index 28ea193adf..f9909e2ba2 100644 --- a/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs +++ b/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs @@ -102,7 +102,10 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers public IXamlType ColumnDefinition { get; } public IXamlType ColumnDefinitions { get; } public IXamlType Classes { get; } - public IXamlMethod ClassesBindMethod { get; } + public IXamlMethod BindClassMethod { get; } + public IXamlMethod SetClassMethod { get; } + public IXamlMethod BindClassesMethod { get; } + public IXamlMethod SetClassesMethod { get; } public IXamlProperty StyledElementClassesProperty { get; } public IXamlType IBrush { get; } public IXamlType ImmutableSolidColorBrush { get; } @@ -301,10 +304,18 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers Classes = cfg.TypeSystem.GetType("Avalonia.Controls.Classes"); StyledElementClassesProperty = StyledElement.Properties.First(x => x.Name == "Classes" && x.PropertyType.Equals(Classes)); - ClassesBindMethod = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions") + BindClassMethod = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions") .GetMethod("BindClass", IDisposable, false, StyledElement, - cfg.WellKnownTypes.String, + cfg.WellKnownTypes.String, BindingBase, cfg.WellKnownTypes.Object); + SetClassMethod = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions") + .GetMethod("SetClass", cfg.WellKnownTypes.Void, false, StyledElement, + cfg.WellKnownTypes.String, cfg.WellKnownTypes.Boolean); + BindClassesMethod = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions") + .GetMethod("BindClasses", IDisposable, false, StyledElement, BindingBase, cfg.WellKnownTypes.Object); + SetClassesMethod = cfg.TypeSystem.GetType("Avalonia.StyledElementExtensions") + .GetMethod("SetClasses", cfg.WellKnownTypes.Void, false, StyledElement, + cfg.WellKnownTypes.String); IBrush = cfg.TypeSystem.GetType("Avalonia.Media.IBrush"); ImmutableSolidColorBrush = cfg.TypeSystem.GetType("Avalonia.Media.Immutable.ImmutableSolidColorBrush"); diff --git a/tests/Avalonia.Base.UnitTests/ClassBindingManagerTests.cs b/tests/Avalonia.Base.UnitTests/ClassBindingManagerTests.cs index 23f9882ba9..87f86b27f4 100644 --- a/tests/Avalonia.Base.UnitTests/ClassBindingManagerTests.cs +++ b/tests/Avalonia.Base.UnitTests/ClassBindingManagerTests.cs @@ -1,15 +1,17 @@ -using Xunit; +using Avalonia.Data; +using Avalonia.Controls; +using Xunit; namespace Avalonia.Base.UnitTests { public class ClassBindingManagerTests { - [Fact] public void GetClassProperty_Should_Return_Same_Instance_For_Same_Class() { var property1 = ClassBindingManager.GetClassProperty("Foo"); var property2 = ClassBindingManager.GetClassProperty("Foo"); + Assert.Same(property1, property2); } @@ -18,7 +20,143 @@ namespace Avalonia.Base.UnitTests { var property1 = ClassBindingManager.GetClassProperty("Foo"); var property2 = ClassBindingManager.GetClassProperty("Bar"); + Assert.NotSame(property1, property2); } + + [Fact] + public void SetClass_Should_Add_Class() + { + var target = new StyledElement(); + + ClassBindingManager.SetClass(target, "Foo", true); + + Assert.Contains("Foo", target.Classes); + } + + [Fact] + public void SetClass_Should_Remove_Added_Class() + { + var target = new StyledElement(); + + ClassBindingManager.SetClass(target, "Foo", true); + ClassBindingManager.SetClass(target, "Foo", false); + + Assert.DoesNotContain("Foo", target.Classes); + } + + [Fact] + public void SetClasses_Should_Add_Classes() + { + var target = new StyledElement(); + + ClassBindingManager.SetClasses(target, "Foo Bar"); + + Assert.Contains("Foo", target.Classes); + Assert.Contains("Bar", target.Classes); + } + + [Fact] + public void SetClasses_Should_Remove_Added_Classes() + { + var target = new StyledElement(); + + ClassBindingManager.SetClasses(target, "Foo Bar"); + ClassBindingManager.SetClasses(target, ""); + + Assert.Empty(target.Classes); + } + + [Fact] + public void SetClasses_Should_Keep_PseudoClasses() + { + var target = new StyledElement(); + ((IPseudoClasses)target.Classes).Add(":Baz"); + + ClassBindingManager.SetClasses(target, "Foo"); + + Assert.Equal(new[] { ":Baz", "Foo" }, target.Classes); + } + + [Fact] + public void SetClass_Should_Override_SetClasses_Adding() + { + var target = new StyledElement(); + + ClassBindingManager.SetClasses(target, "Foo Bar"); + ClassBindingManager.SetClass(target, "Foo", false); + + Assert.Contains("Bar", target.Classes); + Assert.DoesNotContain("Foo", target.Classes); + } + + [Fact] + public void SetClass_Should_Override_SetClasses_Adding_When_Set_Before() + { + var target = new StyledElement(); + + ClassBindingManager.SetClass(target, "Foo", false); + ClassBindingManager.SetClasses(target, "Foo Bar"); + + Assert.Contains("Bar", target.Classes); + Assert.DoesNotContain("Foo", target.Classes); + } + + [Fact] + public void SetClass_Should_Override_SetClasses_Removing() + { + var target = new StyledElement(); + + ClassBindingManager.SetClasses(target, "Bar"); + ClassBindingManager.SetClass(target, "Foo", true); + + Assert.Contains("Foo", target.Classes); + Assert.Contains("Bar", target.Classes); + } + + [Fact] + public void SetClass_Should_Override_SetClasses_Removing_When_Set_Before() + { + var target = new StyledElement(); + + ClassBindingManager.SetClass(target, "Foo", true); + ClassBindingManager.SetClasses(target, "Bar"); + + Assert.Contains("Foo", target.Classes); + Assert.Contains("Bar", target.Classes); + } + + [Fact] + public void BindClass_Should_Update_Classes() + { + var target = new StyledElement(); + + using var d = ClassBindingManager.BindClass(target, "Bar", new Binding { Source = true }, null); + + Assert.Contains("Bar", target.Classes); + } + + [Fact] + public void BindClasses_Should_Update_Classes() + { + var target = new StyledElement(); + + using var d = ClassBindingManager.BindClasses(target, new Binding { Source = "Foo Bar" }, null); + + Assert.Equal("Foo Bar", ClassBindingManager.GetClasses(target)); + Assert.Contains("Foo", target.Classes); + Assert.Contains("Bar", target.Classes); + } + + [Fact] + public void IsClassesBindingProperty_Should_Detect_Classes_Properties() + { + var prop = ClassBindingManager.GetClassProperty("Foo"); + + var result = ClassBindingManager.IsClassesBindingProperty(prop, out var name); + + Assert.True(result); + Assert.Equal("Foo", name); + } } }