diff --git a/src/Avalonia.Base/Styling/ControlTheme.cs b/src/Avalonia.Base/Styling/ControlTheme.cs
new file mode 100644
index 0000000000..54fc972c31
--- /dev/null
+++ b/src/Avalonia.Base/Styling/ControlTheme.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Avalonia.Styling
+{
+ ///
+ /// Defines a switchable theme for a control.
+ ///
+ public class ControlTheme : StyleBase
+ {
+ ///
+ /// Gets or sets the type for which this control theme is intended.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Styling/IStyle.cs b/src/Avalonia.Base/Styling/IStyle.cs
index e9faf82c07..417739fb28 100644
--- a/src/Avalonia.Base/Styling/IStyle.cs
+++ b/src/Avalonia.Base/Styling/IStyle.cs
@@ -23,6 +23,6 @@ namespace Avalonia.Styling
///
/// 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.Base/Styling/IThemed.cs b/src/Avalonia.Base/Styling/IThemed.cs
new file mode 100644
index 0000000000..32ae515bcb
--- /dev/null
+++ b/src/Avalonia.Base/Styling/IThemed.cs
@@ -0,0 +1,13 @@
+namespace Avalonia.Styling
+{
+ ///
+ /// Represents a themed element.
+ ///
+ public interface IThemed
+ {
+ ///
+ /// Gets the theme style for the element.
+ ///
+ public ControlTheme? Theme { get; }
+ }
+}
diff --git a/src/Avalonia.Base/Styling/NestingSelector.cs b/src/Avalonia.Base/Styling/NestingSelector.cs
index 481a937867..6d31f7cb18 100644
--- a/src/Avalonia.Base/Styling/NestingSelector.cs
+++ b/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(
diff --git a/src/Avalonia.Base/Styling/Style.cs b/src/Avalonia.Base/Styling/Style.cs
index c85c85fe21..ca20ff2b4b 100644
--- a/src/Avalonia.Base/Styling/Style.cs
+++ b/src/Avalonia.Base/Styling/Style.cs
@@ -28,6 +28,8 @@ namespace Avalonia.Styling
///
public Selector? Selector { get; set; }
+ internal override bool HasSelector => Selector is not null;
+
///
/// Returns a string representation of the style.
///
@@ -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);
}
diff --git a/src/Avalonia.Base/Styling/StyleBase.cs b/src/Avalonia.Base/Styling/StyleBase.cs
index 0fc57da728..b6bfec62bd 100644
--- a/src/Avalonia.Base/Styling/StyleBase.cs
+++ b/src/Avalonia.Base/Styling/StyleBase.cs
@@ -64,12 +64,14 @@ namespace Avalonia.Styling
bool IResourceNode.HasResources => _resources?.Count > 0;
IReadOnlyList IStyle.Children => (IReadOnlyList?)_children ?? Array.Empty();
+ 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);
+ ///
+ /// Evaluates the style's selector against the specified target element.
+ ///
+ /// The control.
+ /// The element that hosts the style.
+ ///
+ /// Whether the match should subscribe to changes in order to track the match over time,
+ /// or simply return an immediate result.
+ ///
+ ///
+ /// A describing how the style matches the control.
+ ///
+ internal abstract SelectorMatch Match(IStyleable control, object? host, bool subscribe);
internal virtual void SetParent(StyleBase? parent) => Parent = parent;
diff --git a/src/Avalonia.Base/Styling/StyleCache.cs b/src/Avalonia.Base/Styling/StyleCache.cs
index 3285476880..81196f6a27 100644
--- a/src/Avalonia.Base/Styling/StyleCache.cs
+++ b/src/Avalonia.Base/Styling/StyleCache.cs
@@ -12,7 +12,7 @@ namespace Avalonia.Styling
///
internal class StyleCache : Dictionary?>
{
- public SelectorMatchResult TryAttach(IList styles, IStyleable target, IStyleHost? host)
+ public SelectorMatchResult TryAttach(IList styles, IStyleable target, object? host)
{
if (TryGetValue(target.StyleKey, out var cached))
{
diff --git a/src/Avalonia.Base/Styling/Styler.cs b/src/Avalonia.Base/Styling/Styler.cs
index 74cf77ea40..b9359b3329 100644
--- a/src/Avalonia.Base/Styling/Styler.cs
+++ b/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);
diff --git a/src/Avalonia.Base/Styling/Styles.cs b/src/Avalonia.Base/Styling/Styles.cs
index 7c0bc4ad7f..4c011f1b0d 100644
--- a/src/Avalonia.Base/Styling/Styles.cs
+++ b/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);
diff --git a/src/Avalonia.Controls/Primitives/TemplatedControl.cs b/src/Avalonia.Controls/Primitives/TemplatedControl.cs
index db029d38c0..e1f42b6eb0 100644
--- a/src/Avalonia.Controls/Primitives/TemplatedControl.cs
+++ b/src/Avalonia.Controls/Primitives/TemplatedControl.cs
@@ -12,7 +12,7 @@ namespace Avalonia.Controls.Primitives
///
/// A lookless control whose visual appearance is defined by its .
///
- public class TemplatedControl : Control, ITemplatedControl
+ public class TemplatedControl : Control, IThemed, ITemplatedControl
{
///
/// Defines the property.
@@ -86,6 +86,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.
///
@@ -228,6 +234,15 @@ namespace Avalonia.Controls.Primitives
set { SetValue(TemplateProperty, value); }
}
+ ///
+ /// Gets or sets the theme to be applied to the control.
+ ///
+ public ControlTheme? Theme
+ {
+ get { return GetValue(ThemeProperty); }
+ set { SetValue(ThemeProperty, value); }
+ }
+
///
/// Gets the value of the IsTemplateFocusTargetProperty attached property on a control.
///
@@ -365,6 +380,14 @@ namespace Avalonia.Controls.Primitives
{
}
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == ThemeProperty)
+ InvalidateStyles();
+ }
+
///
/// Called when the control's template is applied.
///
diff --git a/src/Avalonia.Themes.Default/SimpleTheme.cs b/src/Avalonia.Themes.Default/SimpleTheme.cs
index 6929660757..d7939a68c1 100644
--- a/src/Avalonia.Themes.Default/SimpleTheme.cs
+++ b/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)
{
diff --git a/src/Avalonia.Themes.Fluent/FluentTheme.cs b/src/Avalonia.Themes.Fluent/FluentTheme.cs
index f6b47a5466..befe669029 100644
--- a/src/Avalonia.Themes.Fluent/FluentTheme.cs
+++ b/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)
{
diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs
index fa4a27fc50..109e85f1a4 100644
--- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs
+++ b/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)
{
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..b24adfe7ab
--- /dev/null
+++ b/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(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(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((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())
+ {
+ Setters =
+ {
+ new Setter(Border.BackgroundProperty, Brushes.Red),
+ }
+ },
+ new Style(x => x.Nesting().Class("foo").Template().OfType())
+ {
+ 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();
+ }
+ }
+}