diff --git a/src/Avalonia.Controls/Application.cs b/src/Avalonia.Controls/Application.cs index 3bf72460df..95628fb087 100644 --- a/src/Avalonia.Controls/Application.cs +++ b/src/Avalonia.Controls/Application.cs @@ -163,7 +163,7 @@ namespace Avalonia IStyleHost IStyleHost.StylingParent => null; /// - bool IStyleHost.IsStylesInitialized => _styles != null; + bool IStyleHost.HasStyles => _styles?.Count > 0; /// /// Application lifetime, use it for things like setting the main window and exiting the app from code diff --git a/src/Avalonia.Controls/ControlTheme.cs b/src/Avalonia.Controls/ControlTheme.cs new file mode 100644 index 0000000000..9f003e309a --- /dev/null +++ b/src/Avalonia.Controls/ControlTheme.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Avalonia.Styling; + +#nullable enable + +namespace Avalonia.Controls +{ + /// + /// Defines a switchable theme for a control. + /// + public class ControlTheme : StyleBase + { + private Styles? _styles; + + /// + /// Gets the child styles of the control theme. + /// + public Styles Styles => _styles ??= new Styles(Owner); + + protected override IReadOnlyList GetChildrenCore() + { + return (IReadOnlyList?)_styles ?? Array.Empty(); + } + + protected override bool GetHasResourcesCore() + { + if (ResourcesCore?.Count > 0) + { + return true; + } + + return ((IResourceNode?)_styles)?.HasResources ?? false; + } + + public override SelectorMatchResult TryAttach(IStyleable target, object? host) + { + if (target == host) + { + // If target and host are the same control, then we're applying styles to the + // control that the theme is applied to. + Attach(target); + _styles?.TryAttach(target, host); + return SelectorMatchResult.AlwaysThisType; + } + else + { + // If the target is different to the host then we're applying styles to a templated + // child of the host. The setters in the control theme itself don't apply here: only + // the child styles. + return _styles?.TryAttach(target, host) ?? SelectorMatchResult.NeverThisType; + } + } + } +} diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs index 820d5777f5..69efbcc8c4 100644 --- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs +++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs @@ -5,14 +5,13 @@ using Avalonia.Logging; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.Styling; -using Avalonia.VisualTree; namespace Avalonia.Controls.Primitives { /// /// A lookless control whose visual appearance is defined by its . /// - public class TemplatedControl : Control, ITemplatedControl + public class TemplatedControl : Control, ITemplatedControl, IThemed { /// /// Defines the property. @@ -74,6 +73,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty TemplateProperty = AvaloniaProperty.Register(nameof(Template)); + /// + /// Defines the property. + /// + public static readonly StyledProperty ThemeProperty = + AvaloniaProperty.Register(nameof(Theme)); + /// /// Defines the IsTemplateFocusTarget attached property. /// @@ -88,6 +93,8 @@ namespace Avalonia.Controls.Primitives "TemplateApplied", RoutingStrategies.Direct); + private static ControlTheme s_unthemed = CreateUnthemed(); + private IControlTemplate _appliedTemplate; /// @@ -97,6 +104,7 @@ namespace Avalonia.Controls.Primitives { ClipToBoundsProperty.OverrideDefaultValue(true); TemplateProperty.Changed.AddClassHandler((x, e) => x.OnTemplateChanged(e)); + ThemeProperty.Changed.AddClassHandler((x, e) => x.OnThemeChanged(e)); } /// @@ -198,6 +206,17 @@ namespace Avalonia.Controls.Primitives set { SetValue(TemplateProperty, value); } } + /// + /// Gets or sets the theme to be applied to the control. + /// + public IStyle Theme + { + get { return GetValue(ThemeProperty); } + set { SetValue(ThemeProperty, value); } + } + + IStyle IThemed.Theme => Theme ?? GetDefaultControlTheme(); + /// /// Gets the value of the IsTemplateFocusTargetProperty attached property on a control. /// @@ -265,7 +284,9 @@ namespace Avalonia.Controls.Primitives var e = new TemplateAppliedEventArgs(nameScope); OnApplyTemplate(e); +#pragma warning disable CS0618 // Type or member is obsolete OnTemplateApplied(e); +#pragma warning restore CS0618 // Type or member is obsolete RaiseEvent(e); } @@ -273,6 +294,8 @@ namespace Avalonia.Controls.Primitives } } + protected virtual IStyle GetDefaultControlTheme() => s_unthemed; + /// protected override IControl GetTemplateFocusTarget() { @@ -362,5 +385,37 @@ namespace Avalonia.Controls.Primitives } } } + + /// + /// Called when the property changes. + /// + /// The event args. + private void OnThemeChanged(AvaloniaPropertyChangedEventArgs e) + { + InvalidateStyles(); + } + + private static ControlTheme CreateUnthemed() + { + var template = new FuncControlTemplate((o, n) => + new Border + { + Background = Brushes.Red, + Child = new TextBlock + { + Foreground = Brushes.White, + Text = "No template found.", + } + }); + ; + + return new ControlTheme + { + Setters = + { + new Setter(TemplateProperty, template), + } + }; + } } } diff --git a/src/Avalonia.Styling/Controls/IThemed.cs b/src/Avalonia.Styling/Controls/IThemed.cs new file mode 100644 index 0000000000..080a160f2e --- /dev/null +++ b/src/Avalonia.Styling/Controls/IThemed.cs @@ -0,0 +1,17 @@ +using Avalonia.Styling; + +#nullable enable + +namespace Avalonia.Controls +{ + /// + /// Represents a themed element. + /// + public interface IThemed + { + /// + /// Gets the theme style for the element. + /// + public IStyle? Theme { get; } + } +} diff --git a/src/Avalonia.Styling/StyledElement.cs b/src/Avalonia.Styling/StyledElement.cs index bdd01924f1..c46be1144e 100644 --- a/src/Avalonia.Styling/StyledElement.cs +++ b/src/Avalonia.Styling/StyledElement.cs @@ -298,7 +298,7 @@ namespace Avalonia Type IStyleable.StyleKey => GetType(); /// - bool IStyleHost.IsStylesInitialized => _styles != null; + bool IStyleHost.HasStyles => _styles?.Count > 0; /// IStyleHost? IStyleHost.StylingParent => (IStyleHost)InheritanceParent; diff --git a/src/Avalonia.Styling/Styling/IStyle.cs b/src/Avalonia.Styling/Styling/IStyle.cs index 78fbe0f2b5..002be685f7 100644 --- a/src/Avalonia.Styling/Styling/IStyle.cs +++ b/src/Avalonia.Styling/Styling/IStyle.cs @@ -19,10 +19,10 @@ namespace Avalonia.Styling /// Attaches the style and any child styles to a control if the style's selector matches. /// /// The control to attach to. - /// The element that hosts the style. + /// The element that is hosting this style. /// /// A describing how the style matches the control. /// - SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host); + SelectorMatchResult TryAttach(IStyleable target, object? host); } } diff --git a/src/Avalonia.Styling/Styling/IStyleHost.cs b/src/Avalonia.Styling/Styling/IStyleHost.cs index 360b40d9a1..05d54f9cdc 100644 --- a/src/Avalonia.Styling/Styling/IStyleHost.cs +++ b/src/Avalonia.Styling/Styling/IStyleHost.cs @@ -17,7 +17,7 @@ namespace Avalonia.Styling /// The property may be lazily initialized, if so this property /// indicates whether it has been initialized. /// - bool IsStylesInitialized { get; } + bool HasStyles { get; } /// /// Gets the styles for the element. diff --git a/src/Avalonia.Styling/Styling/Style.cs b/src/Avalonia.Styling/Styling/Style.cs index 00819ef7be..b022dcf398 100644 --- a/src/Avalonia.Styling/Styling/Style.cs +++ b/src/Avalonia.Styling/Styling/Style.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using Avalonia.Animation; -using Avalonia.Controls; -using Avalonia.Metadata; #nullable enable @@ -11,13 +7,8 @@ namespace Avalonia.Styling /// /// Defines a style. /// - public class Style : AvaloniaObject, IStyle, IResourceProvider + public class Style : StyleBase { - private IResourceHost? _owner; - private IResourceDictionary? _resources; - private List? _setters; - private List? _animations; - /// /// Initializes a new instance of the class. /// @@ -34,89 +25,11 @@ namespace Avalonia.Styling Selector = selector(null); } - public IResourceHost? Owner - { - get => _owner; - private set - { - if (_owner != value) - { - _owner = value; - OwnerChanged?.Invoke(this, EventArgs.Empty); - } - } - } - - /// - /// Gets or sets a dictionary of style resources. - /// - public IResourceDictionary Resources - { - get => _resources ?? (Resources = new ResourceDictionary()); - set - { - value = value ?? throw new ArgumentNullException(nameof(value)); - - var hadResources = _resources?.HasResources ?? false; - - _resources = value; - - if (Owner is object) - { - _resources.AddOwner(Owner); - - if (hadResources || _resources.HasResources) - { - Owner.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); - } - } - } - } - /// /// Gets or sets the style's selector. /// public Selector? Selector { get; set; } - /// - /// Gets the style's setters. - /// - [Content] - public IList Setters => _setters ??= new List(); - - /// - /// Gets the style's animations. - /// - public IList Animations => _animations ??= new List(); - - bool IResourceNode.HasResources => _resources?.Count > 0; - IReadOnlyList IStyle.Children => Array.Empty(); - - public event EventHandler? OwnerChanged; - - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) - { - target = target ?? throw new ArgumentNullException(nameof(target)); - - var match = Selector is object ? Selector.Match(target) : - target == host ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; - - if (match.IsMatch && (_setters is object || _animations is object)) - { - var instance = new StyleInstance(this, target, _setters, _animations, match.Activator); - target.StyleApplied(instance); - instance.Start(); - } - - return match.Result; - } - - public bool TryGetResource(object key, out object? result) - { - result = null; - return _resources?.TryGetResource(key, out result) ?? false; - } - /// /// Returns a string representation of the style. /// @@ -133,28 +46,21 @@ namespace Avalonia.Styling } } - void IResourceProvider.AddOwner(IResourceHost owner) + public override SelectorMatchResult TryAttach(IStyleable target, object? host) { - owner = owner ?? throw new ArgumentNullException(nameof(owner)); - - if (Owner != null) - { - throw new InvalidOperationException("The Style already has a parent."); - } - - Owner = owner; - _resources?.AddOwner(owner); - } + target = target ?? throw new ArgumentNullException(nameof(target)); - void IResourceProvider.RemoveOwner(IResourceHost owner) - { - owner = owner ?? throw new ArgumentNullException(nameof(owner)); + var match = Selector is object ? Selector.Match(target) : + target == host ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; - if (Owner == owner) + if (match.IsMatch && (SettersCore is object || AnimationsCore is object)) { - Owner = null; - _resources?.RemoveOwner(owner); + var instance = new StyleInstance(this, target, SettersCore, AnimationsCore, match.Activator); + target.StyleApplied(instance); + instance.Start(); } + + return match.Result; } } } diff --git a/src/Avalonia.Styling/Styling/StyleBase.cs b/src/Avalonia.Styling/Styling/StyleBase.cs new file mode 100644 index 0000000000..77251d0daf --- /dev/null +++ b/src/Avalonia.Styling/Styling/StyleBase.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Metadata; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// Base class for and ControlTheme. + /// + public abstract class StyleBase : AvaloniaObject, IStyle, IResourceProvider + { + private IResourceHost? _owner; + + public IResourceHost? Owner + { + get => _owner; + private set + { + if (_owner != value) + { + _owner = value; + OwnerChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + /// + /// Gets or sets a dictionary of style resources. + /// + public IResourceDictionary Resources + { + get => ResourcesCore ?? (Resources = new ResourceDictionary()); + set + { + value = value ?? throw new ArgumentNullException(nameof(value)); + + var hadResources = ResourcesCore?.HasResources ?? false; + + ResourcesCore = value; + + if (Owner is object) + { + ResourcesCore.AddOwner(Owner); + + if (hadResources || ResourcesCore.HasResources) + { + Owner.NotifyHostedResourcesChanged(ResourcesChangedEventArgs.Empty); + } + } + } + } + + /// + /// Gets the style's setters. + /// + [Content] + public IList Setters => SettersCore ??= new List(); + + /// + /// Gets the style's animations. + /// + public IList Animations => AnimationsCore ??= new List(); + + /// + /// Gets the style's child styles. + /// + /// + /// The default implementation is to return an empty list. This can be overridden in a + /// derived class by overriding . + /// + IReadOnlyList IStyle.Children => GetChildrenCore(); + + /// + /// Gets a value indicating whether the style has resources. + /// + /// + /// The implementation of this property can be overridden in a derived class by overriding + /// . + /// + bool IResourceNode.HasResources => GetHasResourcesCore(); + + /// + /// Gets the for the control, without creating a collection + /// if one does not already exist. + /// + protected List? AnimationsCore { get; private set; } + + /// + /// Gets the for the control, without creating a resource + /// dictionary if one does not already exist. + /// + protected IResourceDictionary? ResourcesCore { get; private set; } + + /// + /// Gets the for the control, without creating a collection + /// if one does not already exist. + /// + protected List? SettersCore { get; private set; } + + public event EventHandler? OwnerChanged; + + public abstract SelectorMatchResult TryAttach(IStyleable target, object? host); + + public bool TryGetResource(object key, out object? result) + { + result = null; + return ResourcesCore?.TryGetResource(key, out result) ?? false; + } + + protected void Attach(IStyleable target) + { + if (SettersCore is object || AnimationsCore is object) + { + var instance = new StyleInstance(this, target, SettersCore, AnimationsCore); + target.StyleApplied(instance); + instance.Start(); + } + } + + /// + /// When overridden in a derived class, gets the value for . + /// + protected virtual IReadOnlyList GetChildrenCore() => Array.Empty(); + + /// + /// When overridden in a derived class, gets the value for . + /// + protected virtual bool GetHasResourcesCore() => ResourcesCore?.Count > 0; + + void IResourceProvider.AddOwner(IResourceHost owner) + { + owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + if (Owner != null) + { + throw new InvalidOperationException("The Style already has a parent."); + } + + Owner = owner; + ResourcesCore?.AddOwner(owner); + } + + void IResourceProvider.RemoveOwner(IResourceHost owner) + { + owner = owner ?? throw new ArgumentNullException(nameof(owner)); + + if (Owner == owner) + { + Owner = null; + ResourcesCore?.RemoveOwner(owner); + } + } + } +} diff --git a/src/Avalonia.Styling/Styling/Styler.cs b/src/Avalonia.Styling/Styling/Styler.cs index 74cf77ea40..15da82f4b2 100644 --- a/src/Avalonia.Styling/Styling/Styler.cs +++ b/src/Avalonia.Styling/Styling/Styler.cs @@ -1,18 +1,40 @@ using System; +using Avalonia.Controls; #nullable enable namespace Avalonia.Styling { + /// + /// Applies styles to controls based on styles found in themes and styles in the logical tree. + /// public class Styler : IStyler { + /// + /// Applies all relevant styles to a control. + /// + /// The control to be styled. public void ApplyStyles(IStyleable target) { target = target ?? throw new ArgumentNullException(nameof(target)); - if (target is IStyleHost styleHost) + // If the control has a themed templated parent then first apply the styles from + // the templated parent theme. + if (target.TemplatedParent is IThemed themedTemplatedParent) { - ApplyStyles(target, styleHost); + 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 host) + { + ApplyStyles(target, host); } } @@ -20,12 +42,14 @@ namespace Avalonia.Styling { var parent = host.StylingParent; + // Later styles have precedence so styles are applied from the root of the tree up + // towards the control being styled. if (parent != null) { ApplyStyles(target, parent); } - if (host.IsStylesInitialized) + if (host.HasStyles) { host.Styles.TryAttach(target, host); } diff --git a/src/Avalonia.Styling/Styling/Styles.cs b/src/Avalonia.Styling/Styling/Styles.cs index 7c79060930..1eefc8e038 100644 --- a/src/Avalonia.Styling/Styling/Styles.cs +++ b/src/Avalonia.Styling/Styling/Styles.cs @@ -29,7 +29,7 @@ namespace Avalonia.Styling _styles.CollectionChanged += OnCollectionChanged; } - public Styles(IResourceHost owner) + public Styles(IResourceHost? owner) : this() { Owner = owner; @@ -110,7 +110,7 @@ namespace Avalonia.Styling set => _styles[index] = value; } - public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) + public SelectorMatchResult TryAttach(IStyleable target, object? host) { _cache ??= new Dictionary?>(); diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs index 2b39263ee9..39332f9d22 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs @@ -80,7 +80,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) { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs new file mode 100644 index 0000000000..87ef0e3d3c --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs @@ -0,0 +1,83 @@ +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Xunit; + +#nullable enable + +namespace Avalonia.Controls.UnitTests.Primitives +{ + public class TemplatedControlTests_Theming + { + [Fact] + public void IThemed_Theme_Returns_Default_Theme_If_Theme_Property_Unset() + { + var theme = CreateTheme(); + var target = new ThemedControl(theme); + + Assert.Same(theme, ((IThemed)target).Theme); + } + + [Fact] + public void IThemed_Theme_Returns_Theme_Property_If_Set() + { + var theme1 = CreateTheme(); + var theme2 = CreateTheme(); + var target = new ThemedControl(theme1) { Theme = theme2 }; + + Assert.Same(theme2, ((IThemed)target).Theme); + } + + [Fact] + public void Theme_Is_Applied_When_Attached_To_Logical_Tree() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new ThemedControl(); + + Assert.Null(target.Template); + + var root = new TestRoot(target); + + Assert.NotNull(target.Template); + } + + [Fact] + public void Theme_Is_Detached_When_Theme_Property_Changed() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + var target = new ThemedControl(); + var root = new TestRoot(target); + + target.Theme = CreateTheme(); + + Assert.Null(target.Template); + } + + private static ControlTheme CreateTheme() + { + var template = new FuncControlTemplate((o, n) => + new Border { Name = "PART_Border" }); + + return new ControlTheme + { + Setters = + { + new Setter(ThemedControl.TemplateProperty, template), + } + }; + } + + private class ThemedControl : TemplatedControl + { + private ControlTheme _defaultTheme; + + public ThemedControl(ControlTheme? defaultTheme = null) + { + _defaultTheme = defaultTheme ?? CreateTheme(); + } + + protected override IStyle GetDefaultControlTheme() => _defaultTheme; + } + } +}