Browse Source

Initial implementation of control themes.

feature/2769-control-themes
Steven Kirk 6 years ago
parent
commit
56e2c37377
  1. 2
      src/Avalonia.Controls/Application.cs
  2. 55
      src/Avalonia.Controls/ControlTheme.cs
  3. 59
      src/Avalonia.Controls/Primitives/TemplatedControl.cs
  4. 17
      src/Avalonia.Styling/Controls/IThemed.cs
  5. 2
      src/Avalonia.Styling/StyledElement.cs
  6. 4
      src/Avalonia.Styling/Styling/IStyle.cs
  7. 2
      src/Avalonia.Styling/Styling/IStyleHost.cs
  8. 116
      src/Avalonia.Styling/Styling/Style.cs
  9. 158
      src/Avalonia.Styling/Styling/StyleBase.cs
  10. 30
      src/Avalonia.Styling/Styling/Styler.cs
  11. 4
      src/Avalonia.Styling/Styling/Styles.cs
  12. 2
      src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs
  13. 83
      tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests_Theming.cs

2
src/Avalonia.Controls/Application.cs

@ -163,7 +163,7 @@ namespace Avalonia
IStyleHost IStyleHost.StylingParent => null;
/// <inheritdoc/>
bool IStyleHost.IsStylesInitialized => _styles != null;
bool IStyleHost.HasStyles => _styles?.Count > 0;
/// <summary>
/// Application lifetime, use it for things like setting the main window and exiting the app from code

55
src/Avalonia.Controls/ControlTheme.cs

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using Avalonia.Styling;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
/// Defines a switchable theme for a control.
/// </summary>
public class ControlTheme : StyleBase
{
private Styles? _styles;
/// <summary>
/// Gets the child styles of the control theme.
/// </summary>
public Styles Styles => _styles ??= new Styles(Owner);
protected override IReadOnlyList<IStyle> GetChildrenCore()
{
return (IReadOnlyList<IStyle>?)_styles ?? Array.Empty<IStyle>();
}
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;
}
}
}
}

59
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
{
/// <summary>
/// A lookless control whose visual appearance is defined by its <see cref="Template"/>.
/// </summary>
public class TemplatedControl : Control, ITemplatedControl
public class TemplatedControl : Control, ITemplatedControl, IThemed
{
/// <summary>
/// Defines the <see cref="Background"/> property.
@ -74,6 +73,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<IStyle> ThemeProperty =
AvaloniaProperty.Register<TemplatedControl, IStyle>(nameof(Theme));
/// <summary>
/// Defines the IsTemplateFocusTarget attached property.
/// </summary>
@ -88,6 +93,8 @@ namespace Avalonia.Controls.Primitives
"TemplateApplied",
RoutingStrategies.Direct);
private static ControlTheme s_unthemed = CreateUnthemed();
private IControlTemplate _appliedTemplate;
/// <summary>
@ -97,6 +104,7 @@ namespace Avalonia.Controls.Primitives
{
ClipToBoundsProperty.OverrideDefaultValue<TemplatedControl>(true);
TemplateProperty.Changed.AddClassHandler<TemplatedControl>((x, e) => x.OnTemplateChanged(e));
ThemeProperty.Changed.AddClassHandler<TemplatedControl>((x, e) => x.OnThemeChanged(e));
}
/// <summary>
@ -198,6 +206,17 @@ namespace Avalonia.Controls.Primitives
set { SetValue(TemplateProperty, value); }
}
/// <summary>
/// Gets or sets the theme to be applied to the control.
/// </summary>
public IStyle Theme
{
get { return GetValue(ThemeProperty); }
set { SetValue(ThemeProperty, value); }
}
IStyle IThemed.Theme => Theme ?? GetDefaultControlTheme();
/// <summary>
/// Gets the value of the IsTemplateFocusTargetProperty attached property on a control.
/// </summary>
@ -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;
/// <inheritdoc/>
protected override IControl GetTemplateFocusTarget()
{
@ -362,5 +385,37 @@ namespace Avalonia.Controls.Primitives
}
}
}
/// <summary>
/// Called when the <see cref="Theme"/> property changes.
/// </summary>
/// <param name="e">The event args.</param>
private void OnThemeChanged(AvaloniaPropertyChangedEventArgs e)
{
InvalidateStyles();
}
private static ControlTheme CreateUnthemed()
{
var template = new FuncControlTemplate<TemplatedControl>((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),
}
};
}
}
}

17
src/Avalonia.Styling/Controls/IThemed.cs

@ -0,0 +1,17 @@
using Avalonia.Styling;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
/// Represents a themed element.
/// </summary>
public interface IThemed
{
/// <summary>
/// Gets the theme style for the element.
/// </summary>
public IStyle? Theme { get; }
}
}

2
src/Avalonia.Styling/StyledElement.cs

@ -298,7 +298,7 @@ namespace Avalonia
Type IStyleable.StyleKey => GetType();
/// <inheritdoc/>
bool IStyleHost.IsStylesInitialized => _styles != null;
bool IStyleHost.HasStyles => _styles?.Count > 0;
/// <inheritdoc/>
IStyleHost? IStyleHost.StylingParent => (IStyleHost)InheritanceParent;

4
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.
/// </summary>
/// <param name="target">The control to attach to.</param>
/// <param name="host">The element that hosts the style.</param>
/// <param name="host">The element that is hosting this style.</param>
/// <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);
}
}

2
src/Avalonia.Styling/Styling/IStyleHost.cs

@ -17,7 +17,7 @@ namespace Avalonia.Styling
/// The <see cref="Styles"/> property may be lazily initialized, if so this property
/// indicates whether it has been initialized.
/// </remarks>
bool IsStylesInitialized { get; }
bool HasStyles { get; }
/// <summary>
/// Gets the styles for the element.

116
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
/// <summary>
/// Defines a style.
/// </summary>
public class Style : AvaloniaObject, IStyle, IResourceProvider
public class Style : StyleBase
{
private IResourceHost? _owner;
private IResourceDictionary? _resources;
private List<ISetter>? _setters;
private List<IAnimation>? _animations;
/// <summary>
/// Initializes a new instance of the <see cref="Style"/> class.
/// </summary>
@ -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);
}
}
}
/// <summary>
/// Gets or sets a dictionary of style resources.
/// </summary>
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);
}
}
}
}
/// <summary>
/// Gets or sets the style's selector.
/// </summary>
public Selector? Selector { get; set; }
/// <summary>
/// Gets the style's setters.
/// </summary>
[Content]
public IList<ISetter> Setters => _setters ??= new List<ISetter>();
/// <summary>
/// Gets the style's animations.
/// </summary>
public IList<IAnimation> Animations => _animations ??= new List<IAnimation>();
bool IResourceNode.HasResources => _resources?.Count > 0;
IReadOnlyList<IStyle> IStyle.Children => Array.Empty<IStyle>();
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;
}
/// <summary>
/// Returns a string representation of the style.
/// </summary>
@ -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;
}
}
}

158
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
{
/// <summary>
/// Base class for <see cref="Style"/> and ControlTheme.
/// </summary>
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);
}
}
}
/// <summary>
/// Gets or sets a dictionary of style resources.
/// </summary>
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);
}
}
}
}
/// <summary>
/// Gets the style's setters.
/// </summary>
[Content]
public IList<ISetter> Setters => SettersCore ??= new List<ISetter>();
/// <summary>
/// Gets the style's animations.
/// </summary>
public IList<IAnimation> Animations => AnimationsCore ??= new List<IAnimation>();
/// <summary>
/// Gets the style's child styles.
/// </summary>
/// <remarks>
/// The default implementation is to return an empty list. This can be overridden in a
/// derived class by overriding <see cref="GetChildrenCore"/>.
/// </remarks>
IReadOnlyList<IStyle> IStyle.Children => GetChildrenCore();
/// <summary>
/// Gets a value indicating whether the style has resources.
/// </summary>
/// <remarks>
/// The implementation of this property can be overridden in a derived class by overriding
/// <see cref="GetHasResourcesCore"/>.
/// </remarks>
bool IResourceNode.HasResources => GetHasResourcesCore();
/// <summary>
/// Gets the <see cref="Animations"/> for the control, without creating a collection
/// if one does not already exist.
/// </summary>
protected List<IAnimation>? AnimationsCore { get; private set; }
/// <summary>
/// Gets the <see cref="Resources"/> for the control, without creating a resource
/// dictionary if one does not already exist.
/// </summary>
protected IResourceDictionary? ResourcesCore { get; private set; }
/// <summary>
/// Gets the <see cref="Setters"/> for the control, without creating a collection
/// if one does not already exist.
/// </summary>
protected List<ISetter>? 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();
}
}
/// <summary>
/// When overridden in a derived class, gets the value for <see cref="IStyle.Children"/>.
/// </summary>
protected virtual IReadOnlyList<IStyle> GetChildrenCore() => Array.Empty<IStyle>();
/// <summary>
/// When overridden in a derived class, gets the value for <see cref="IResourceNode.HasResources"/>.
/// </summary>
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);
}
}
}
}

30
src/Avalonia.Styling/Styling/Styler.cs

@ -1,18 +1,40 @@
using System;
using Avalonia.Controls;
#nullable enable
namespace Avalonia.Styling
{
/// <summary>
/// Applies styles to controls based on styles found in themes and styles in the logical tree.
/// </summary>
public class Styler : IStyler
{
/// <summary>
/// Applies all relevant styles to a control.
/// </summary>
/// <param name="target">The control to be styled.</param>
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);
}

4
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<Type, List<IStyle>?>();

2
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)
{

83
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<ThemedControl>((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;
}
}
}
Loading…
Cancel
Save