Browse Source

Initial implementation of control themes.

Steven Kirk 3 years ago
parent
commit
088d8cfc5c

+ 27 - 0
src/Avalonia.Base/Styling/ControlTheme.cs

@@ -0,0 +1,27 @@
+using System;
+
+namespace Avalonia.Styling
+{
+    /// <summary>
+    /// Defines a switchable theme for a control.
+    /// </summary>
+    public class ControlTheme : StyleBase
+    {
+        /// <summary>
+        /// Gets or sets the type for which this control theme is intended.
+        /// </summary>
+        public Type? TargetType { get; set; }
+
+        internal override bool HasSelector => TargetType is not null;
+
+        internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe)
+        {
+            if (TargetType is null)
+                throw new InvalidOperationException("ControlTheme has no TargetType.");
+
+            return control.StyleKey == TargetType ?
+                SelectorMatch.AlwaysThisType :
+                SelectorMatch.NeverThisType;
+        }
+    }
+}

+ 1 - 1
src/Avalonia.Base/Styling/IStyle.cs

@@ -23,6 +23,6 @@ namespace Avalonia.Styling
         /// <returns>
         /// A <see cref="SelectorMatchResult"/> describing how the style matches the control.
         /// </returns>
-        SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host);
+        SelectorMatchResult TryAttach(IStyleable target, object? host);
     }
 }

+ 13 - 0
src/Avalonia.Base/Styling/IThemed.cs

@@ -0,0 +1,13 @@
+namespace Avalonia.Styling
+{
+    /// <summary>
+    /// Represents a themed element.
+    /// </summary>
+    public interface IThemed
+    {
+        /// <summary>
+        /// Gets the theme style for the element.
+        /// </summary>
+        public ControlTheme? Theme { get; }
+    }
+}

+ 2 - 2
src/Avalonia.Base/Styling/NestingSelector.cs

@@ -15,9 +15,9 @@ namespace Avalonia.Styling
 
         protected override SelectorMatch Evaluate(IStyleable control, IStyle? parent, bool subscribe)
         {
-            if (parent is Style s && s.Selector is Selector selector)
+            if (parent is StyleBase s && s.HasSelector)
             {
-                return selector.Match(control, (parent as Style)?.Parent, subscribe);
+                return s.Match(control, null, subscribe);
             }
 
             throw new InvalidOperationException(

+ 5 - 3
src/Avalonia.Base/Styling/Style.cs

@@ -28,6 +28,8 @@ namespace Avalonia.Styling
         /// </summary>
         public Selector? Selector { get; set; }
 
+        internal override bool HasSelector => Selector is not null;
+
         /// <summary>
         /// Returns a string representation of the style.
         /// </summary>
@@ -44,10 +46,10 @@ namespace Avalonia.Styling
             }
         }
 
-        protected override SelectorMatch Matches(IStyleable target, IStyleHost? host)
+        internal override SelectorMatch Match(IStyleable control, object? host, bool subscribe)
         {
-            return Selector?.Match(target, Parent) ??
-                (target == host ?
+            return Selector?.Match(control, Parent, subscribe) ??
+                (control == host ?
                     SelectorMatch.AlwaysThisInstance :
                     SelectorMatch.NeverThisInstance);
         }

+ 17 - 3
src/Avalonia.Base/Styling/StyleBase.cs

@@ -64,12 +64,14 @@ namespace Avalonia.Styling
         bool IResourceNode.HasResources => _resources?.Count > 0;
         IReadOnlyList<IStyle> IStyle.Children => (IReadOnlyList<IStyle>?)_children ?? Array.Empty<IStyle>();
 
+        internal abstract bool HasSelector { get; }
+
         public void Add(ISetter setter) => Setters.Add(setter);
         public void Add(IStyle style) => Children.Add(style);
 
         public event EventHandler? OwnerChanged;
 
-        public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host)
+        public SelectorMatchResult TryAttach(IStyleable target, object? host)
         {
             target = target ?? throw new ArgumentNullException(nameof(target));
 
@@ -77,7 +79,7 @@ namespace Avalonia.Styling
 
             if (_setters?.Count > 0 || _animations?.Count > 0)
             {
-                var match = Matches(target, host);
+                var match = Match(target, host, subscribe: true);
 
                 if (match.IsMatch)
                 {
@@ -106,7 +108,19 @@ namespace Avalonia.Styling
             return _resources?.TryGetResource(key, out result) ?? false;
         }
 
-        protected abstract SelectorMatch Matches(IStyleable target, IStyleHost? host);
+        /// <summary>
+        /// Evaluates the style's selector against the specified target element.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="host">The element that hosts the style.</param>
+        /// <param name="subscribe">
+        /// Whether the match should subscribe to changes in order to track the match over time,
+        /// or simply return an immediate result.
+        /// </param>
+        /// <returns>
+        /// A <see cref="SelectorMatchResult"/> describing how the style matches the control.
+        /// </returns>
+        internal abstract SelectorMatch Match(IStyleable control, object? host, bool subscribe);
 
         internal virtual void SetParent(StyleBase? parent) => Parent = parent;
 

+ 1 - 1
src/Avalonia.Base/Styling/StyleCache.cs

@@ -12,7 +12,7 @@ namespace Avalonia.Styling
     /// </remarks>
     internal class StyleCache : Dictionary<Type, List<IStyle>?>
     {
-        public SelectorMatchResult TryAttach(IList<IStyle> styles, IStyleable target, IStyleHost? host)
+        public SelectorMatchResult TryAttach(IList<IStyle> styles, IStyleable target, object? host)
         {
             if (TryGetValue(target.StyleKey, out var cached))
             {

+ 14 - 0
src/Avalonia.Base/Styling/Styler.cs

@@ -10,6 +10,20 @@ namespace Avalonia.Styling
         {
             target = target ?? throw new ArgumentNullException(nameof(target));
 
+            // If the control has a themed templated parent then first apply the styles from
+            // the templated parent theme.
+            if (target.TemplatedParent is IThemed themedTemplatedParent)
+            {
+                themedTemplatedParent.Theme?.TryAttach(target, themedTemplatedParent);
+            }
+
+            // If the control itself is themed, then next apply the control theme.
+            if (target is IThemed themed)
+            {
+                themed.Theme?.TryAttach(target, target);
+            }
+
+            // Apply styles from the rest of the tree.
             if (target is IStyleHost styleHost)
             {
                 ApplyStyles(target, styleHost);

+ 1 - 1
src/Avalonia.Base/Styling/Styles.cs

@@ -109,7 +109,7 @@ namespace Avalonia.Styling
             set => _styles[index] = value;
         }
 
-        public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host)
+        public SelectorMatchResult TryAttach(IStyleable target, object? host)
         {
             _cache ??= new StyleCache();
             return _cache.TryAttach(this, target, host);

+ 24 - 1
src/Avalonia.Controls/Primitives/TemplatedControl.cs

@@ -12,7 +12,7 @@ namespace Avalonia.Controls.Primitives
     /// <summary>
     /// A lookless control whose visual appearance is defined by its <see cref="Template"/>.
     /// </summary>
-    public class TemplatedControl : Control, ITemplatedControl
+    public class TemplatedControl : Control, IThemed, ITemplatedControl
     {
         /// <summary>
         /// Defines the <see cref="Background"/> property.
@@ -86,6 +86,12 @@ namespace Avalonia.Controls.Primitives
         public static readonly StyledProperty<IControlTemplate?> TemplateProperty =
             AvaloniaProperty.Register<TemplatedControl, IControlTemplate?>(nameof(Template));
 
+        /// <summary>
+        /// Defines the <see cref="Theme"/> property.
+        /// </summary>
+        public static readonly StyledProperty<ControlTheme?> ThemeProperty =
+            AvaloniaProperty.Register<TemplatedControl, ControlTheme?>(nameof(Theme));
+
         /// <summary>
         /// Defines the IsTemplateFocusTarget attached property.
         /// </summary>
@@ -228,6 +234,15 @@ namespace Avalonia.Controls.Primitives
             set { SetValue(TemplateProperty, value); }
         }
 
+        /// <summary>
+        /// Gets or sets the theme to be applied to the control.
+        /// </summary>
+        public ControlTheme? Theme
+        {
+            get { return GetValue(ThemeProperty); }
+            set { SetValue(ThemeProperty, value); }
+        }
+
         /// <summary>
         /// Gets the value of the IsTemplateFocusTargetProperty attached property on a control.
         /// </summary>
@@ -365,6 +380,14 @@ namespace Avalonia.Controls.Primitives
         {
         }
 
+        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+        {
+            base.OnPropertyChanged(change);
+
+            if (change.Property == ThemeProperty)
+                InvalidateStyles();
+        }
+
         /// <summary>
         /// Called when the control's template is applied.
         /// </summary>

+ 1 - 1
src/Avalonia.Themes.Default/SimpleTheme.cs

@@ -103,7 +103,7 @@ namespace Avalonia.Themes.Default
 
         void IResourceProvider.RemoveOwner(IResourceHost owner) => (Loaded as IResourceProvider)?.RemoveOwner(owner);
 
-        public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host);
+        public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host);
 
         public bool TryGetResource(object key, out object? value)
         {

+ 1 - 1
src/Avalonia.Themes.Fluent/FluentTheme.cs

@@ -164,7 +164,7 @@ namespace Avalonia.Themes.Fluent
             }
         }
 
-        public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host);
+        public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host);
 
         public bool TryGetResource(object key, out object? value)
         {

+ 1 - 1
src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs

@@ -82,7 +82,7 @@ namespace Avalonia.Markup.Xaml.Styling
             }
         }
 
-        public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) => Loaded.TryAttach(target, host);
+        public SelectorMatchResult TryAttach(IStyleable target, object? host) => Loaded.TryAttach(target, host);
 
         public bool TryGetResource(object key, out object? value)
         {

+ 119 - 0
tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs

@@ -0,0 +1,119 @@
+using System.Linq;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Xunit;
+
+#nullable enable
+
+namespace Avalonia.Controls.UnitTests.Primitives
+{
+    public class TemplatedControlTests_Theming
+    {
+        [Fact]
+        public void Theme_Is_Applied_When_Attached_To_Logical_Tree()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealStyler);
+            var target = CreateTarget();
+
+            Assert.Null(target.Template);
+
+            var root = CreateRoot(target);
+
+            Assert.NotNull(target.Template);
+            var border = Assert.IsType<Border>(target.VisualChild);
+            
+            Assert.Equal(border.Background, Brushes.Red);
+
+            target.Classes.Add("foo");
+            Assert.Equal(border.Background, Brushes.Green);
+        }
+
+        [Fact]
+        public void Theme_Is_Detached_When_Theme_Property_Cleared()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealStyler);
+            var target = CreateTarget();
+            var root = CreateRoot(target);
+
+            Assert.NotNull(target.Template);
+
+            target.Theme = null;
+            Assert.Null(target.Template);
+        }
+
+        [Fact]
+        public void Theme_Is_Applied_On_Layout_After_Theme_Property_Changes()
+        {
+            using var app = UnitTestApplication.Start(TestServices.RealStyler);
+            var target = new ThemedControl();
+            var root = CreateRoot(target);
+
+            Assert.Null(target.Template);
+
+            target.Theme = CreateTheme();
+            Assert.Null(target.Template);
+
+            root.LayoutManager.ExecuteLayoutPass();
+
+            var border = Assert.IsType<Border>(target.VisualChild);
+            Assert.NotNull(target.Template);
+            Assert.Equal(border.Background, Brushes.Red);
+        }
+
+        private static ThemedControl CreateTarget()
+        {
+            return new ThemedControl
+            {
+                Theme = CreateTheme(),
+            };
+        }
+
+        private static ControlTheme CreateTheme()
+        {
+            var template = new FuncControlTemplate<ThemedControl>((o, n) =>
+                new Border { Name = "PART_Border" });
+
+            return new ControlTheme
+            {
+                TargetType = typeof(ThemedControl),
+                Setters =
+                {
+                    new Setter(ThemedControl.TemplateProperty, template),
+                },
+                Children =
+                {
+                    new Style(x => x.Nesting().Template().OfType<Border>())
+                    {
+                        Setters =
+                        {
+                            new Setter(Border.BackgroundProperty, Brushes.Red),
+                        }
+                    },
+                    new Style(x => x.Nesting().Class("foo").Template().OfType<Border>())
+                    {
+                        Setters =
+                        {
+                            new Setter(Border.BackgroundProperty, Brushes.Green),
+                        }
+                    },
+                }
+            };
+        }
+
+        private static TestRoot CreateRoot(IControl child)
+        {
+            var result = new TestRoot(child);
+            result.LayoutManager.ExecuteInitialLayoutPass();
+            return result;
+        }
+
+        private class ThemedControl : TemplatedControl
+        {
+            public IVisual? VisualChild => VisualChildren?.SingleOrDefault();
+        }
+    }
+}