diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 6645d25b5d..fdd688cf9d 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -23,7 +23,7 @@ namespace Avalonia private EventHandler _propertyChanged; private List _inheritanceChildren; private ValueStore _values; - private ValueStore Values => _values ?? (_values = new ValueStore(this)); + private bool _batchUpdate; /// /// Initializes a new instance of the class. @@ -117,6 +117,22 @@ namespace Avalonia set { this.Bind(binding.Property, value); } } + private ValueStore Values + { + get + { + if (_values is null) + { + _values = new ValueStore(this); + + if (_batchUpdate) + _values.BeginBatchUpdate(); + } + + return _values; + } + } + public bool CheckAccess() => Dispatcher.UIThread.CheckAccess(); public void VerifyAccess() => Dispatcher.UIThread.VerifyAccess(); @@ -434,6 +450,28 @@ namespace Avalonia _values?.CoerceValue(property); } + public void BeginBatchUpdate() + { + if (_batchUpdate) + { + throw new InvalidOperationException("Batch update already in progress."); + } + + _batchUpdate = true; + _values?.BeginBatchUpdate(); + } + + public void EndBatchUpdate() + { + if (!_batchUpdate) + { + throw new InvalidOperationException("No batch update in progress."); + } + + _batchUpdate = false; + _values?.EndBatchUpdate(); + } + /// void IAvaloniaObject.AddInheritanceChild(IAvaloniaObject child) { diff --git a/src/Avalonia.Base/PropertyStore/BindingEntry.cs b/src/Avalonia.Base/PropertyStore/BindingEntry.cs index 0d563947e7..38c1728cd9 100644 --- a/src/Avalonia.Base/PropertyStore/BindingEntry.cs +++ b/src/Avalonia.Base/PropertyStore/BindingEntry.cs @@ -9,8 +9,9 @@ namespace Avalonia.PropertyStore /// /// Represents an untyped interface to . /// - internal interface IBindingEntry : IPriorityValueEntry, IDisposable + internal interface IBindingEntry : IBatchUpdate, IPriorityValueEntry, IDisposable { + void Start(bool ignoreBatchUpdate); } /// @@ -22,6 +23,8 @@ namespace Avalonia.PropertyStore private readonly IAvaloniaObject _owner; private IValueSink _sink; private IDisposable? _subscription; + private bool _isSubscribed; + private bool _batchUpdate; private Optional _value; public BindingEntry( @@ -39,10 +42,20 @@ namespace Avalonia.PropertyStore } public StyledPropertyBase Property { get; } - public BindingPriority Priority { get; } + public BindingPriority Priority { get; private set; } public IObservable> Source { get; } Optional IValue.GetValue() => _value.ToObject(); + public void BeginBatchUpdate() => _batchUpdate = true; + + public void EndBatchUpdate() + { + _batchUpdate = false; + + if (_sink is ValueStore) + Start(); + } + public Optional GetValue(BindingPriority maxPriority) { return Priority >= maxPriority ? _value : Optional.Empty; @@ -52,10 +65,17 @@ namespace Avalonia.PropertyStore { _subscription?.Dispose(); _subscription = null; - _sink.Completed(Property, this, _value); + _isSubscribed = false; + OnCompleted(); } - public void OnCompleted() => _sink.Completed(Property, this, _value); + public void OnCompleted() + { + var oldValue = _value; + _value = default; + Priority = BindingPriority.Unset; + _sink.Completed(Property, this, oldValue); + } public void OnError(Exception error) { @@ -79,13 +99,36 @@ namespace Avalonia.PropertyStore } } - public void Start() + public void Start() => Start(false); + + public void Start(bool ignoreBatchUpdate) { - _subscription = Source.Subscribe(this); + // We can't use _subscription to check whether we're subscribed because it won't be set + // until Subscribe has finished, which will be too late to prevent reentrancy. + if (!_isSubscribed && (!_batchUpdate || ignoreBatchUpdate)) + { + _isSubscribed = true; + _subscription = Source.Subscribe(this); + } } public void Reparent(IValueSink sink) => _sink = sink; - + + public void RaiseValueChanged( + IValueSink sink, + IAvaloniaObject owner, + AvaloniaProperty property, + Optional oldValue, + Optional newValue) + { + sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( + owner, + (AvaloniaProperty)property, + oldValue.GetValueOrDefault(), + newValue.GetValueOrDefault(), + Priority)); + } + private void UpdateValue(BindingValue value) { if (value.HasValue && Property.ValidateValue?.Invoke(value.Value) == false) diff --git a/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs b/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs index 46f6f9a137..600d725187 100644 --- a/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Avalonia.Data; #nullable enable @@ -17,7 +18,7 @@ namespace Avalonia.PropertyStore public ConstantValueEntry( StyledPropertyBase property, - T value, + [AllowNull] T value, BindingPriority priority, IValueSink sink) { @@ -28,7 +29,7 @@ namespace Avalonia.PropertyStore } public StyledPropertyBase Property { get; } - public BindingPriority Priority { get; } + public BindingPriority Priority { get; private set; } Optional IValue.GetValue() => _value.ToObject(); public Optional GetValue(BindingPriority maxPriority = BindingPriority.Animation) @@ -36,7 +37,30 @@ namespace Avalonia.PropertyStore return Priority >= maxPriority ? _value : Optional.Empty; } - public void Dispose() => _sink.Completed(Property, this, _value); + public void Dispose() + { + var oldValue = _value; + _value = default; + Priority = BindingPriority.Unset; + _sink.Completed(Property, this, oldValue); + } + public void Reparent(IValueSink sink) => _sink = sink; + public void Start() { } + + public void RaiseValueChanged( + IValueSink sink, + IAvaloniaObject owner, + AvaloniaProperty property, + Optional oldValue, + Optional newValue) + { + sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( + owner, + (AvaloniaProperty)property, + oldValue.GetValueOrDefault(), + newValue.GetValueOrDefault(), + Priority)); + } } } diff --git a/src/Avalonia.Base/PropertyStore/IBatchUpdate.cs b/src/Avalonia.Base/PropertyStore/IBatchUpdate.cs new file mode 100644 index 0000000000..af4faf989c --- /dev/null +++ b/src/Avalonia.Base/PropertyStore/IBatchUpdate.cs @@ -0,0 +1,8 @@ +namespace Avalonia.PropertyStore +{ + internal interface IBatchUpdate + { + void BeginBatchUpdate(); + void EndBatchUpdate(); + } +} diff --git a/src/Avalonia.Base/PropertyStore/IValue.cs b/src/Avalonia.Base/PropertyStore/IValue.cs index 249cfc360c..7f5245bb45 100644 --- a/src/Avalonia.Base/PropertyStore/IValue.cs +++ b/src/Avalonia.Base/PropertyStore/IValue.cs @@ -9,8 +9,15 @@ namespace Avalonia.PropertyStore /// internal interface IValue { - Optional GetValue(); BindingPriority Priority { get; } + Optional GetValue(); + void Start(); + void RaiseValueChanged( + IValueSink sink, + IAvaloniaObject owner, + AvaloniaProperty property, + Optional oldValue, + Optional newValue); } /// diff --git a/src/Avalonia.Base/PropertyStore/LocalValueEntry.cs b/src/Avalonia.Base/PropertyStore/LocalValueEntry.cs index 859e9ba81c..f49b74f4a8 100644 --- a/src/Avalonia.Base/PropertyStore/LocalValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/LocalValueEntry.cs @@ -24,5 +24,21 @@ namespace Avalonia.PropertyStore } public void SetValue(T value) => _value = value; + public void Start() { } + + public void RaiseValueChanged( + IValueSink sink, + IAvaloniaObject owner, + AvaloniaProperty property, + Optional oldValue, + Optional newValue) + { + sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( + owner, + (AvaloniaProperty)property, + oldValue.GetValueOrDefault(), + newValue.GetValueOrDefault(), + BindingPriority.LocalValue)); + } } } diff --git a/src/Avalonia.Base/PropertyStore/PriorityValue.cs b/src/Avalonia.Base/PropertyStore/PriorityValue.cs index 5e223cad60..80496fc045 100644 --- a/src/Avalonia.Base/PropertyStore/PriorityValue.cs +++ b/src/Avalonia.Base/PropertyStore/PriorityValue.cs @@ -18,7 +18,7 @@ namespace Avalonia.PropertyStore /// entries (sorted first by priority and then in the order /// they were added) plus a local value. /// - internal class PriorityValue : IValue, IValueSink + internal class PriorityValue : IValue, IValueSink, IBatchUpdate { private readonly IAvaloniaObject _owner; private readonly IValueSink _sink; @@ -26,6 +26,8 @@ namespace Avalonia.PropertyStore private readonly Func? _coerceValue; private Optional _localValue; private Optional _value; + private bool _isCalculatingValue; + private bool _batchUpdate; public PriorityValue( IAvaloniaObject owner, @@ -53,6 +55,18 @@ namespace Avalonia.PropertyStore existing.Reparent(this); _entries.Add(existing); + if (existing is IBindingEntry binding && + existing.Priority == BindingPriority.LocalValue) + { + // Bit of a special case here: if we have a local value binding that is being + // promoted to a priority value we need to make sure the binding is subscribed + // even if we've got a batch operation in progress because otherwise we don't know + // whether the binding or a subsequent SetValue with local priority will win. A + // notification won't be sent during batch update anyway because it will be + // caught and stored for later by the ValueStore. + binding.Start(ignoreBatchUpdate: true); + } + var v = existing.GetValue(); if (v.HasValue) @@ -78,6 +92,28 @@ namespace Avalonia.PropertyStore public IReadOnlyList> Entries => _entries; Optional IValue.GetValue() => _value.ToObject(); + public void BeginBatchUpdate() + { + _batchUpdate = true; + + foreach (var entry in _entries) + { + (entry as IBatchUpdate)?.BeginBatchUpdate(); + } + } + + public void EndBatchUpdate() + { + _batchUpdate = false; + + foreach (var entry in _entries) + { + (entry as IBatchUpdate)?.EndBatchUpdate(); + } + + UpdateEffectiveValue(null); + } + public void ClearLocalValue() { UpdateEffectiveValue(new AvaloniaPropertyChangedEventArgs( @@ -134,10 +170,37 @@ namespace Avalonia.PropertyStore var binding = new BindingEntry(_owner, Property, source, priority, this); var insert = FindInsertPoint(binding.Priority); _entries.Insert(insert, binding); + + if (_batchUpdate) + { + binding.BeginBatchUpdate(); + + if (priority == BindingPriority.LocalValue) + { + binding.Start(ignoreBatchUpdate: true); + } + } + return binding; } - public void CoerceValue() => UpdateEffectiveValue(null); + public void UpdateEffectiveValue() => UpdateEffectiveValue(null); + public void Start() => UpdateEffectiveValue(null); + + public void RaiseValueChanged( + IValueSink sink, + IAvaloniaObject owner, + AvaloniaProperty property, + Optional oldValue, + Optional newValue) + { + sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( + owner, + (AvaloniaProperty)property, + oldValue.GetValueOrDefault(), + newValue.GetValueOrDefault(), + Priority)); + } void IValueSink.ValueChanged(AvaloniaPropertyChangedEventArgs change) { @@ -146,7 +209,7 @@ namespace Avalonia.PropertyStore _localValue = default; } - if (change is AvaloniaPropertyChangedEventArgs c) + if (!_isCalculatingValue && change is AvaloniaPropertyChangedEventArgs c) { UpdateEffectiveValue(c); } @@ -188,41 +251,47 @@ namespace Avalonia.PropertyStore public (Optional, BindingPriority) CalculateValue(BindingPriority maxPriority) { - var reachedLocalValues = false; + _isCalculatingValue = true; - for (var i = _entries.Count - 1; i >= 0; --i) + try { - var entry = _entries[i]; - - if (entry.Priority < maxPriority) + for (var i = _entries.Count - 1; i >= 0; --i) { - continue; + var entry = _entries[i]; + + if (entry.Priority < maxPriority) + { + continue; + } + + entry.Start(); + + if (entry.Priority >= BindingPriority.LocalValue && + maxPriority <= BindingPriority.LocalValue && + _localValue.HasValue) + { + return (_localValue, BindingPriority.LocalValue); + } + + var entryValue = entry.GetValue(); + + if (entryValue.HasValue) + { + return (entryValue, entry.Priority); + } } - if (!reachedLocalValues && - entry.Priority >= BindingPriority.LocalValue && - maxPriority <= BindingPriority.LocalValue && - _localValue.HasValue) + if (maxPriority <= BindingPriority.LocalValue && _localValue.HasValue) { return (_localValue, BindingPriority.LocalValue); } - var entryValue = entry.GetValue(); - - if (entryValue.HasValue) - { - return (entryValue, entry.Priority); - } + return (default, BindingPriority.Unset); } - - if (!reachedLocalValues && - maxPriority <= BindingPriority.LocalValue && - _localValue.HasValue) + finally { - return (_localValue, BindingPriority.LocalValue); + _isCalculatingValue = false; } - - return (default, BindingPriority.Unset); } private void UpdateEffectiveValue(AvaloniaPropertyChangedEventArgs? change) diff --git a/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs b/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs index 6e52b6770a..1af6f21156 100644 --- a/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs +++ b/src/Avalonia.Base/Utilities/AvaloniaPropertyValueStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -22,6 +22,9 @@ namespace Avalonia.Utilities _entries = s_emptyEntries; } + public int Count => _entries.Length - 1; + public TValue this[int index] => _entries[index].Value; + private (int, bool) TryFindEntry(int propertyId) { if (_entries.Length <= 12) @@ -163,18 +166,6 @@ namespace Avalonia.Utilities } } - public Dictionary ToDictionary() - { - var dict = new Dictionary(_entries.Length - 1); - - for (int i = 0; i < _entries.Length - 1; ++i) - { - dict.Add(AvaloniaPropertyRegistry.Instance.FindRegistered(_entries[i].PropertyId), _entries[i].Value); - } - - return dict; - } - private struct Entry { internal int PropertyId; diff --git a/src/Avalonia.Base/ValueStore.cs b/src/Avalonia.Base/ValueStore.cs index 6b89fcbdb9..e32b20cc96 100644 --- a/src/Avalonia.Base/ValueStore.cs +++ b/src/Avalonia.Base/ValueStore.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Avalonia.Data; using Avalonia.PropertyStore; using Avalonia.Utilities; @@ -26,6 +27,7 @@ namespace Avalonia private readonly AvaloniaObject _owner; private readonly IValueSink _sink; private readonly AvaloniaPropertyValueStore _values; + private BatchUpdate? _batchUpdate; public ValueStore(AvaloniaObject owner) { @@ -33,6 +35,25 @@ namespace Avalonia _values = new AvaloniaPropertyValueStore(); } + public void BeginBatchUpdate() + { + _batchUpdate ??= new BatchUpdate(this); + _batchUpdate.Begin(); + } + + public void EndBatchUpdate() + { + if (_batchUpdate is null) + { + throw new InvalidOperationException("No batch update in progress."); + } + + if (_batchUpdate.End()) + { + _batchUpdate = null; + } + } + public bool IsAnimating(AvaloniaProperty property) { if (_values.TryGetValue(property, out var slot)) @@ -90,23 +111,21 @@ namespace Avalonia { // If the property has any coercion callbacks then always create a PriorityValue. var entry = new PriorityValue(_owner, property, this); - _values.AddValue(property, entry); + AddValue(property, entry); result = entry.SetValue(value, priority); } else { - var change = new AvaloniaPropertyChangedEventArgs(_owner, property, default, value, priority); - if (priority == BindingPriority.LocalValue) { - _values.AddValue(property, new LocalValueEntry(value)); - _sink.ValueChanged(change); + AddValue(property, new LocalValueEntry(value)); + NotifyValueChanged(property, default, value, priority); } else { var entry = new ConstantValueEntry(property, value, priority, this); - _values.AddValue(property, entry); - _sink.ValueChanged(change); + AddValue(property, entry); + NotifyValueChanged(property, default, value, priority); result = entry; } } @@ -128,15 +147,13 @@ namespace Avalonia // If the property has any coercion callbacks then always create a PriorityValue. var entry = new PriorityValue(_owner, property, this); var binding = entry.AddBinding(source, priority); - _values.AddValue(property, entry); - binding.Start(); + AddValue(property, entry); return binding; } else { var entry = new BindingEntry(_owner, property, source, priority, this); - _values.AddValue(property, entry); - entry.Start(); + AddValue(property, entry); return entry; } } @@ -149,23 +166,32 @@ namespace Avalonia { p.ClearLocalValue(); } - else + else if (slot.Priority == BindingPriority.LocalValue) { - var remove = slot is ConstantValueEntry c ? - c.Priority == BindingPriority.LocalValue : - !(slot is IPriorityValueEntry); + var old = TryGetValue(property, BindingPriority.LocalValue, out var value) ? value : default; - if (remove) + // During batch update values can't be removed immediately because they're needed to raise + // a correctly-typed _sink.ValueChanged notification. They instead mark themselves for removal + // by setting their priority to Unset. + if (_batchUpdate is null) { - var old = TryGetValue(property, BindingPriority.LocalValue, out var value) ? value : default; _values.Remove(property); - _sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( - _owner, - property, - new Optional(old), - default, - BindingPriority.Unset)); } + else if (slot is IDisposable d) + { + d.Dispose(); + } + else + { + // Local value entries are optimized and contain only a single value field to save space, + // so there's no way to mark them for removal at the end of a batch update. Instead convert + // them to a constant value entry with Unset priority in the event of a local value being + // cleared during a batch update. + var sentinel = new ConstantValueEntry(property, default, BindingPriority.Unset, _sink); + _values.SetValue(property, sentinel); + } + + NotifyValueChanged(property, old, default, BindingPriority.Unset); } } } @@ -176,7 +202,7 @@ namespace Avalonia { if (slot is PriorityValue p) { - p.CoerceValue(); + p.UpdateEffectiveValue(); } } } @@ -198,7 +224,17 @@ namespace Avalonia void IValueSink.ValueChanged(AvaloniaPropertyChangedEventArgs change) { - _sink.ValueChanged(change); + if (_batchUpdate is object) + { + if (change.IsEffectiveValueChange) + { + NotifyValueChanged(change.Property, change.OldValue, change.NewValue, change.Priority); + } + } + else + { + _sink.ValueChanged(change); + } } void IValueSink.Completed( @@ -206,13 +242,17 @@ namespace Avalonia IPriorityValueEntry entry, Optional oldValue) { - if (_values.TryGetValue(property, out var slot)) + if (_values.TryGetValue(property, out var slot) && slot == entry) { - if (slot == entry) + if (_batchUpdate is null) { _values.Remove(property); _sink.Completed(property, entry, oldValue); } + else + { + _batchUpdate.ValueChanged(property, oldValue.ToObject()); + } } } @@ -240,16 +280,13 @@ namespace Avalonia { var old = l.GetValue(BindingPriority.LocalValue); l.SetValue(value); - _sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( - _owner, - property, - old, - value, - priority)); + NotifyValueChanged(property, old, value, priority); } else { var priorityValue = new PriorityValue(_owner, property, this, l); + if (_batchUpdate is object) + priorityValue.BeginBatchUpdate(); result = priorityValue.SetValue(value, priority); _values.SetValue(property, priorityValue); } @@ -273,6 +310,11 @@ namespace Avalonia if (slot is IPriorityValueEntry e) { priorityValue = new PriorityValue(_owner, property, this, e); + + if (_batchUpdate is object) + { + priorityValue.BeginBatchUpdate(); + } } else if (slot is PriorityValue p) { @@ -289,8 +331,162 @@ namespace Avalonia var binding = priorityValue.AddBinding(source, priority); _values.SetValue(property, priorityValue); - binding.Start(); + priorityValue.UpdateEffectiveValue(); return binding; } + + private void AddValue(AvaloniaProperty property, IValue value) + { + _values.AddValue(property, value); + if (_batchUpdate is object && value is IBatchUpdate batch) + batch.BeginBatchUpdate(); + value.Start(); + } + + private void NotifyValueChanged( + AvaloniaProperty property, + Optional oldValue, + BindingValue newValue, + BindingPriority priority) + { + if (_batchUpdate is null) + { + _sink.ValueChanged(new AvaloniaPropertyChangedEventArgs( + _owner, + property, + oldValue, + newValue, + priority)); + } + else + { + _batchUpdate.ValueChanged(property, oldValue.ToObject()); + } + } + + private class BatchUpdate + { + private ValueStore _owner; + private List? _notifications; + private int _batchUpdateCount; + private int _iterator = -1; + + public BatchUpdate(ValueStore owner) => _owner = owner; + + public void Begin() + { + if (_batchUpdateCount++ == 0) + { + var values = _owner._values; + + for (var i = 0; i < values.Count; ++i) + { + (values[i] as IBatchUpdate)?.BeginBatchUpdate(); + } + } + } + + public bool End() + { + if (--_batchUpdateCount > 0) + return false; + + var values = _owner._values; + + // First call EndBatchUpdate on all bindings. This should cause the active binding to be subscribed + // but notifications will still not be raised because the owner ValueStore will still have a reference + // to this batch update object. + for (var i = 0; i < values.Count; ++i) + { + (values[i] as IBatchUpdate)?.EndBatchUpdate(); + + // Somehow subscribing to a binding caused a new batch update. This shouldn't happen but in case it + // does, abort and continue batch updating. + if (_batchUpdateCount > 0) + return false; + } + + if (_notifications is object) + { + // Raise all batched notifications. Doing this can cause other notifications to be added and even + // cause a new batch update to start, so we need to handle _notifications being modified by storing + // the index in field. + _iterator = 0; + + for (; _iterator < _notifications.Count; ++_iterator) + { + var entry = _notifications[_iterator]; + + if (values.TryGetValue(entry.property, out var slot)) + { + var oldValue = entry.oldValue; + var newValue = slot.GetValue(); + + // Raising this notification can cause a new batch update to be started, which in turn + // results in another change to the property. In this case we need to update the old value + // so that the *next* notification has an oldValue which follows on from the newValue + // raised here. + _notifications[_iterator] = new Notification + { + property = entry.property, + oldValue = newValue, + }; + + // Call _sink.ValueChanged with an appropriately typed AvaloniaPropertyChangedEventArgs. + slot.RaiseValueChanged(_owner._sink, _owner._owner, entry.property, oldValue, newValue); + + // During batch update values can't be removed immediately because they're needed to raise + // the _sink.ValueChanged notification. They instead mark themselves for removal by setting + // their priority to Unset. + if (slot.Priority == BindingPriority.Unset) + { + values.Remove(entry.property); + } + } + else + { + throw new AvaloniaInternalException("Value could not be found at the end of batch update."); + } + + // If a new batch update was started while ending this one, abort. + if (_batchUpdateCount > 0) + return false; + } + } + + _iterator = int.MaxValue - 1; + return true; + } + + public void ValueChanged(AvaloniaProperty property, Optional oldValue) + { + _notifications ??= new List(); + + for (var i = 0; i < _notifications.Count; ++i) + { + if (_notifications[i].property == property) + { + oldValue = _notifications[i].oldValue; + _notifications.RemoveAt(i); + + if (i <= _iterator) + --_iterator; + break; + } + } + + _notifications.Add(new Notification + { + property = property, + oldValue = oldValue, + }); + } + + private struct Notification + { + public AvaloniaProperty property; + public Optional oldValue; + } + } } } diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index 620f5afa81..4aab92c428 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -20,7 +20,7 @@ namespace Avalonia.Controls /// /// - A property to allow user-defined data to be attached to the control. /// - public class Control : InputElement, IControl, INamed, ISupportInitialize, IVisualBrushInitialize, ISetterValue + public class Control : InputElement, IControl, INamed, IVisualBrushInitialize, ISetterValue { /// /// Defines the property. diff --git a/src/Avalonia.Styling/IStyledElement.cs b/src/Avalonia.Styling/IStyledElement.cs index 37e6ed6fbb..046a6f9872 100644 --- a/src/Avalonia.Styling/IStyledElement.cs +++ b/src/Avalonia.Styling/IStyledElement.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using Avalonia.Controls; using Avalonia.LogicalTree; using Avalonia.Styling; @@ -10,7 +11,8 @@ namespace Avalonia IStyleHost, ILogical, IResourceHost, - IDataContextProvider + IDataContextProvider, + ISupportInitialize { /// /// Occurs when the control has finished initialization. diff --git a/src/Avalonia.Styling/StyledElement.cs b/src/Avalonia.Styling/StyledElement.cs index fad281244f..5f26f65512 100644 --- a/src/Avalonia.Styling/StyledElement.cs +++ b/src/Avalonia.Styling/StyledElement.cs @@ -334,7 +334,16 @@ namespace Avalonia { if (_initCount == 0 && !_styled) { - AvaloniaLocator.Current.GetService()?.ApplyStyles(this); + try + { + BeginBatchUpdate(); + AvaloniaLocator.Current.GetService()?.ApplyStyles(this); + } + finally + { + EndBatchUpdate(); + } + _styled = true; } @@ -748,12 +757,21 @@ namespace Avalonia { if (_appliedStyles is object) { - foreach (var i in _appliedStyles) + BeginBatchUpdate(); + + try { - i.Dispose(); - } + foreach (var i in _appliedStyles) + { + i.Dispose(); + } - _appliedStyles.Clear(); + _appliedStyles.Clear(); + } + finally + { + EndBatchUpdate(); + } } _styled = false; diff --git a/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs b/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs index 929f7142bb..f187199c00 100644 --- a/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs +++ b/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs @@ -92,6 +92,7 @@ namespace Avalonia.Styling { if (!_isActive) { + _innerSubscription ??= _binding.Observable.Subscribe(_inner); _isActive = true; PublishNext(); } @@ -102,6 +103,8 @@ namespace Avalonia.Styling if (_isActive) { _isActive = false; + _innerSubscription?.Dispose(); + _innerSubscription = null; PublishNext(); } } @@ -122,9 +125,6 @@ namespace Avalonia.Styling sub.Dispose(); } - _innerSubscription?.Dispose(); - _innerSubscription = null; - base.Dispose(); } @@ -148,7 +148,17 @@ namespace Avalonia.Styling protected override void Subscribed() { - _innerSubscription = _binding.Observable.Subscribe(_inner); + if (_isActive) + { + if (_innerSubscription is null) + { + _innerSubscription ??= _binding.Observable.Subscribe(_inner); + } + else + { + PublishNext(); + } + } } protected override void Unsubscribed() diff --git a/src/Avalonia.Styling/Styling/PropertySetterInstance.cs b/src/Avalonia.Styling/Styling/PropertySetterInstance.cs index 689438dfd7..b52ae6b146 100644 --- a/src/Avalonia.Styling/Styling/PropertySetterInstance.cs +++ b/src/Avalonia.Styling/Styling/PropertySetterInstance.cs @@ -7,7 +7,7 @@ using Avalonia.Reactive; namespace Avalonia.Styling { /// - /// A which has been instance on a control. + /// A which has been instanced on a control. /// /// The target property type. internal class PropertySetterInstance : SingleSubscriberObservableBase>, diff --git a/src/Avalonia.Styling/Styling/PropertySetterLazyInstance.cs b/src/Avalonia.Styling/Styling/PropertySetterLazyInstance.cs new file mode 100644 index 0000000000..03d33faff9 --- /dev/null +++ b/src/Avalonia.Styling/Styling/PropertySetterLazyInstance.cs @@ -0,0 +1,128 @@ +using System; +using Avalonia.Data; +using Avalonia.Reactive; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// A which has been instanced on a control and whose value is lazily + /// evaluated. + /// + /// The target property type. + internal class PropertySetterLazyInstance : SingleSubscriberObservableBase>, + ISetterInstance + { + private readonly IStyleable _target; + private readonly StyledPropertyBase? _styledProperty; + private readonly DirectPropertyBase? _directProperty; + private readonly Func _valueFactory; + private BindingValue _value; + private IDisposable? _subscription; + private bool _isActive; + + public PropertySetterLazyInstance( + IStyleable target, + StyledPropertyBase property, + Func valueFactory) + { + _target = target; + _styledProperty = property; + _valueFactory = valueFactory; + } + + public PropertySetterLazyInstance( + IStyleable target, + DirectPropertyBase property, + Func valueFactory) + { + _target = target; + _directProperty = property; + _valueFactory = valueFactory; + } + + public void Start(bool hasActivator) + { + _isActive = !hasActivator; + + if (_styledProperty is object) + { + var priority = hasActivator ? BindingPriority.StyleTrigger : BindingPriority.Style; + _subscription = _target.Bind(_styledProperty, this, priority); + } + else + { + _subscription = _target.Bind(_directProperty, this); + } + } + + public void Activate() + { + if (!_isActive) + { + _isActive = true; + PublishNext(); + } + } + + public void Deactivate() + { + if (_isActive) + { + _isActive = false; + PublishNext(); + } + } + + public override void Dispose() + { + if (_subscription is object) + { + var sub = _subscription; + _subscription = null; + sub.Dispose(); + } + else if (_isActive) + { + if (_styledProperty is object) + { + _target.ClearValue(_styledProperty); + } + else + { + _target.ClearValue(_directProperty); + } + } + + base.Dispose(); + } + + protected override void Subscribed() => PublishNext(); + protected override void Unsubscribed() { } + + private T GetValue() + { + if (_value.HasValue) + { + return _value.Value; + } + + _value = _valueFactory(); + return _value.Value; + } + + private void PublishNext() + { + if (_isActive) + { + GetValue(); + PublishNext(_value); + } + else + { + PublishNext(default); + } + } + } +} diff --git a/src/Avalonia.Styling/Styling/Setter.cs b/src/Avalonia.Styling/Styling/Setter.cs index c6bdeef317..1f3d6335a9 100644 --- a/src/Avalonia.Styling/Styling/Setter.cs +++ b/src/Avalonia.Styling/Styling/Setter.cs @@ -68,18 +68,10 @@ namespace Avalonia.Styling throw new InvalidOperationException("Setter.Property must be set."); } - var value = Value; - - if (value is ITemplate template && - !typeof(ITemplate).IsAssignableFrom(Property.PropertyType)) - { - value = template.Build(); - } - var data = new SetterVisitorData { target = target, - value = value, + value = Value, }; Property.Accept(this, ref data); @@ -97,6 +89,13 @@ namespace Avalonia.Styling property, binding); } + else if (data.value is ITemplate template && !typeof(ITemplate).IsAssignableFrom(property.PropertyType)) + { + data.result = new PropertySetterLazyInstance( + data.target, + property, + () => (T)template.Build()); + } else { data.result = new PropertySetterInstance( @@ -117,6 +116,13 @@ namespace Avalonia.Styling property, binding); } + else if (data.value is ITemplate template && !typeof(ITemplate).IsAssignableFrom(property.PropertyType)) + { + data.result = new PropertySetterLazyInstance( + data.target, + property, + () => (T)template.Build()); + } else { data.result = new PropertySetterInstance( diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj index badfd09430..32f90d5cbf 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj @@ -3,6 +3,7 @@ netcoreapp3.1;net47 Library true + latest diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_BatchUpdate.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_BatchUpdate.cs new file mode 100644 index 0000000000..9f0e52c8d9 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_BatchUpdate.cs @@ -0,0 +1,494 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Text; +using Avalonia.Data; +using Xunit; + +namespace Avalonia.Base.UnitTests +{ + public class AvaloniaObjectTests_BatchUpdate + { + [Fact] + public void SetValue_Should_Not_Raise_Property_Changes_During_Batch_Update() + { + var target = new TestClass(); + var raised = new List(); + + target.GetObservable(TestClass.FooProperty).Skip(1).Subscribe(x => raised.Add(x)); + target.BeginBatchUpdate(); + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + + Assert.Empty(raised); + } + + [Fact] + public void Binding_Should_Not_Raise_Property_Changes_During_Batch_Update() + { + var target = new TestClass(); + var observable = new TestObservable("foo"); + var raised = new List(); + + target.GetObservable(TestClass.FooProperty).Skip(1).Subscribe(x => raised.Add(x)); + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + + Assert.Empty(raised); + } + + [Fact] + public void Binding_Completion_Should_Not_Raise_Property_Changes_During_Batch_Update() + { + var target = new TestClass(); + var observable = new TestObservable("foo"); + var raised = new List(); + + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + target.GetObservable(TestClass.FooProperty).Skip(1).Subscribe(x => raised.Add(x)); + target.BeginBatchUpdate(); + observable.OnCompleted(); + + Assert.Empty(raised); + } + + [Fact] + public void SetValue_Change_Should_Be_Raised_After_Batch_Update_1() + { + var target = new TestClass(); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Equal("foo", target.Foo); + Assert.Null(raised[0].OldValue); + Assert.Equal("foo", raised[0].NewValue); + } + + [Fact] + public void SetValue_Change_Should_Be_Raised_After_Batch_Update_2() + { + var target = new TestClass(); + var raised = new List(); + + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.SetValue(TestClass.FooProperty, "bar", BindingPriority.LocalValue); + target.SetValue(TestClass.FooProperty, "baz", BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Equal("baz", target.Foo); + } + + [Fact] + public void SetValue_Changes_Should_Be_Raised_In_Correct_Order_After_Batch_Update() + { + var target = new TestClass(); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + target.SetValue(TestClass.BarProperty, "bar", BindingPriority.LocalValue); + target.SetValue(TestClass.FooProperty, "baz", BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.BarProperty, raised[0].Property); + Assert.Equal(TestClass.FooProperty, raised[1].Property); + Assert.Equal("baz", target.Foo); + Assert.Equal("bar", target.Bar); + } + + [Fact] + public void SetValue_And_Binding_Changes_Should_Be_Raised_In_Correct_Order_After_Batch_Update_1() + { + var target = new TestClass(); + var observable = new TestObservable("baz"); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + target.SetValue(TestClass.BarProperty, "bar", BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.BarProperty, raised[0].Property); + Assert.Equal(TestClass.FooProperty, raised[1].Property); + Assert.Equal("baz", target.Foo); + Assert.Equal("bar", target.Bar); + } + + [Fact] + public void SetValue_And_Binding_Changes_Should_Be_Raised_In_Correct_Order_After_Batch_Update_2() + { + var target = new TestClass(); + var observable = new TestObservable("foo"); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + target.SetValue(TestClass.BarProperty, "bar", BindingPriority.LocalValue); + target.SetValue(TestClass.FooProperty, "baz", BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.BarProperty, raised[0].Property); + Assert.Equal(TestClass.FooProperty, raised[1].Property); + Assert.Equal("baz", target.Foo); + Assert.Equal("bar", target.Bar); + } + + [Fact] + public void SetValue_And_Binding_Changes_Should_Be_Raised_In_Correct_Order_After_Batch_Update_3() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("qux"); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.LocalValue); + target.SetValue(TestClass.BarProperty, "bar", BindingPriority.LocalValue); + target.SetValue(TestClass.FooProperty, "baz", BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.BarProperty, raised[0].Property); + Assert.Equal(TestClass.FooProperty, raised[1].Property); + Assert.Equal("baz", target.Foo); + Assert.Equal("bar", target.Bar); + } + + [Fact] + public void Binding_Change_Should_Be_Raised_After_Batch_Update_1() + { + var target = new TestClass(); + var observable = new TestObservable("foo"); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Equal("foo", target.Foo); + Assert.Null(raised[0].OldValue); + Assert.Equal("foo", raised[0].NewValue); + } + + [Fact] + public void Binding_Change_Should_Be_Raised_After_Batch_Update_2() + { + var target = new TestClass(); + var observable1 = new TestObservable("bar"); + var observable2 = new TestObservable("baz"); + var raised = new List(); + + target.SetValue(TestClass.FooProperty, "foo", BindingPriority.LocalValue); + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.LocalValue); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Equal("baz", target.Foo); + Assert.Equal("foo", raised[0].OldValue); + Assert.Equal("baz", raised[0].NewValue); + } + + [Fact] + public void Binding_Completion_Should_Be_Raised_After_Batch_Update() + { + var target = new TestClass(); + var observable = new TestObservable("foo"); + var raised = new List(); + + target.Bind(TestClass.FooProperty, observable, BindingPriority.LocalValue); + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + observable.OnCompleted(); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Null(target.Foo); + Assert.Equal("foo", raised[0].OldValue); + Assert.Null(raised[0].NewValue); + Assert.Equal(BindingPriority.Unset, raised[0].Priority); + } + + [Fact] + public void ClearValue_Change_Should_Be_Raised_After_Batch_Update_1() + { + var target = new TestClass(); + var raised = new List(); + + target.Foo = "foo"; + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.ClearValue(TestClass.FooProperty); + target.EndBatchUpdate(); + + Assert.Equal(1, raised.Count); + Assert.Null(target.Foo); + Assert.Equal("foo", raised[0].OldValue); + Assert.Null(raised[0].NewValue); + Assert.Equal(BindingPriority.Unset, raised[0].Priority); + } + + [Fact] + public void Bindings_Should_Be_Subscribed_Before_Batch_Update() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + + target.Bind(TestClass.FooProperty, observable1, BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.LocalValue); + + Assert.Equal(1, observable1.SubscribeCount); + Assert.Equal(1, observable2.SubscribeCount); + } + + [Fact] + public void Non_Active_Binding_Should_Not_Be_Subscribed_Before_Batch_Update() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + + target.Bind(TestClass.FooProperty, observable1, BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.Style); + + Assert.Equal(1, observable1.SubscribeCount); + Assert.Equal(0, observable2.SubscribeCount); + } + + [Fact] + public void LocalValue_Bindings_Should_Be_Subscribed_During_Batch_Update() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + // We need to subscribe to LocalValue bindings even if we've got a batch operation + // in progress because otherwise we don't know whether the binding or a subsequent + // SetValue with local priority will win. Notifications however shouldn't be sent. + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.LocalValue); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.LocalValue); + + Assert.Equal(1, observable1.SubscribeCount); + Assert.Equal(1, observable2.SubscribeCount); + Assert.Empty(raised); + } + + [Fact] + public void Style_Bindings_Should_Not_Be_Subscribed_During_Batch_Update() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.Style); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.StyleTrigger); + + Assert.Equal(0, observable1.SubscribeCount); + Assert.Equal(0, observable2.SubscribeCount); + } + + [Fact] + public void Active_Style_Binding_Should_Be_Subscribed_After_Batch_Uppdate_1() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.Style); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.Style); + target.EndBatchUpdate(); + + Assert.Equal(0, observable1.SubscribeCount); + Assert.Equal(1, observable2.SubscribeCount); + } + + [Fact] + public void Active_Style_Binding_Should_Be_Subscribed_After_Batch_Uppdate_2() + { + var target = new TestClass(); + var observable1 = new TestObservable("foo"); + var observable2 = new TestObservable("bar"); + + target.BeginBatchUpdate(); + target.Bind(TestClass.FooProperty, observable1, BindingPriority.StyleTrigger); + target.Bind(TestClass.FooProperty, observable2, BindingPriority.Style); + target.EndBatchUpdate(); + + Assert.Equal(1, observable1.SubscribeCount); + Assert.Equal(0, observable2.SubscribeCount); + } + + [Fact] + public void Change_Can_Be_Triggered_By_Ending_Batch_Update_1() + { + var target = new TestClass(); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Foo = "foo"; + + target.PropertyChanged += (s, e) => + { + if (e.Property == TestClass.FooProperty && (string)e.NewValue == "foo") + target.Bar = "bar"; + }; + + target.EndBatchUpdate(); + + Assert.Equal("foo", target.Foo); + Assert.Equal("bar", target.Bar); + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.FooProperty, raised[0].Property); + Assert.Equal(TestClass.BarProperty, raised[1].Property); + } + + [Fact] + public void Change_Can_Be_Triggered_By_Ending_Batch_Update_2() + { + var target = new TestClass(); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Foo = "foo"; + target.Bar = "baz"; + + target.PropertyChanged += (s, e) => + { + if (e.Property == TestClass.FooProperty && (string)e.NewValue == "foo") + target.Bar = "bar"; + }; + + target.EndBatchUpdate(); + + Assert.Equal("foo", target.Foo); + Assert.Equal("bar", target.Bar); + Assert.Equal(2, raised.Count); + } + + [Fact] + public void Batch_Update_Can_Be_Triggered_By_Ending_Batch_Update() + { + var target = new TestClass(); + var raised = new List(); + + target.PropertyChanged += (s, e) => raised.Add(e); + + target.BeginBatchUpdate(); + target.Foo = "foo"; + target.Bar = "baz"; + + // Simulates the following scenario: + // - A control is added to the logical tree + // - A batch update is started to apply styles + // - Ending the batch update triggers something which removes the control from the logical tree + // - A new batch update is started to detach styles + target.PropertyChanged += (s, e) => + { + if (e.Property == TestClass.FooProperty && (string)e.NewValue == "foo") + { + target.BeginBatchUpdate(); + target.ClearValue(TestClass.FooProperty); + target.ClearValue(TestClass.BarProperty); + target.EndBatchUpdate(); + } + }; + + target.EndBatchUpdate(); + + Assert.Null(target.Foo); + Assert.Null(target.Bar); + Assert.Equal(2, raised.Count); + Assert.Equal(TestClass.FooProperty, raised[0].Property); + Assert.Null(raised[0].OldValue); + Assert.Equal("foo", raised[0].NewValue); + Assert.Equal(TestClass.FooProperty, raised[1].Property); + Assert.Equal("foo", raised[1].OldValue); + Assert.Null(raised[1].NewValue); + } + + public class TestClass : AvaloniaObject + { + public static readonly StyledProperty FooProperty = + AvaloniaProperty.Register(nameof(Foo)); + + public static readonly StyledProperty BarProperty = + AvaloniaProperty.Register(nameof(Bar)); + + public string Foo + { + get => GetValue(FooProperty); + set => SetValue(FooProperty, value); + } + + public string Bar + { + get => GetValue(BarProperty); + set => SetValue(BarProperty, value); + } + } + + public class TestObservable : ObservableBase> + { + private readonly T _value; + private IObserver> _observer; + + public TestObservable(T value) => _value = value; + + public int SubscribeCount { get; private set; } + + public void OnCompleted() => _observer.OnCompleted(); + public void OnError(Exception e) => _observer.OnError(e); + + protected override IDisposable SubscribeCore(IObserver> observer) + { + ++SubscribeCount; + _observer = observer; + observer.OnNext(_value); + return Disposable.Empty; + } + } + } +} diff --git a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj index 251894e324..e8e69efdbc 100644 --- a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj +++ b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs new file mode 100644 index 0000000000..d41665c78c --- /dev/null +++ b/tests/Avalonia.Benchmarks/Themes/FluentBenchmark.cs @@ -0,0 +1,74 @@ +using System; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.Styling; +using Avalonia.Platform; +using Avalonia.Shared.PlatformSupport; +using Avalonia.Styling; +using Avalonia.UnitTests; +using BenchmarkDotNet.Attributes; +using Moq; + +namespace Avalonia.Benchmarks.Themes +{ + [MemoryDiagnoser] + public class FluentBenchmark + { + private readonly IDisposable _app; + private readonly TestRoot _root; + + public FluentBenchmark() + { + _app = CreateApp(); + _root = new TestRoot(true, null) + { + Renderer = new NullRenderer() + }; + + _root.LayoutManager.ExecuteInitialLayoutPass(); + } + + public void Dispose() + { + _app.Dispose(); + } + + [Benchmark] + public void RepeatButton() + { + var button = new RepeatButton(); + _root.Child = button; + _root.LayoutManager.ExecuteLayoutPass(); + } + + private static IDisposable CreateApp() + { + var services = new TestServices( + assetLoader: new AssetLoader(), + globalClock: new MockGlobalClock(), + platform: new AppBuilder().RuntimePlatform, + renderInterface: new MockPlatformRenderInterface(), + standardCursorFactory: Mock.Of(), + styler: new Styler(), + theme: () => LoadFluentTheme(), + threadingInterface: new NullThreadingPlatform(), + fontManagerImpl: new MockFontManagerImpl(), + textShaperImpl: new MockTextShaperImpl(), + windowingPlatform: new MockWindowingPlatform()); + + return UnitTestApplication.Start(services); + } + + private static Styles LoadFluentTheme() + { + AssetLoader.RegisterResUriParsers(); + return new Styles + { + new StyleInclude(new Uri("avares://Avalonia.Benchmarks")) + { + Source = new Uri("avares://Avalonia.Themes.Fluent/Accents/FluentDark.xaml") + } + }; + } + } +} diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj index 44b604197d..7748115137 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -3,6 +3,7 @@ netcoreapp3.1;net47 Library true + latest diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs index 9152131ab3..a97cb69c86 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Presenters; @@ -809,6 +810,82 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions Assert.Equal(0xff506070, brush.Color.ToUint32()); } + [Fact] + public void Resource_In_Non_Matching_Style_Is_Not_Resolved() + { + using var app = StyledWindow(); + + var xaml = @" + + + + + + + + + + + + + + + +"; + + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var border = window.FindControl("border"); + + Assert.Equal("bar", border.Tag); + + var resourceProvider = (TrackingResourceProvider)window.Resources.MergedDictionaries[0]; + Assert.Equal(new[] { "bar" }, resourceProvider.RequestedResources); + } + + [Fact] + public void Resource_In_Non_Active_Style_Is_Not_Resolved() + { + using var app = StyledWindow(); + + var xaml = @" + + + + + + + + + + + + + + + +"; + + var window = (Window)AvaloniaRuntimeXamlLoader.Load(xaml); + var border = window.FindControl("border"); + + Assert.Equal("bar", border.Tag); + + var resourceProvider = (TrackingResourceProvider)window.Resources.MergedDictionaries[0]; + Assert.Equal(new[] { "bar" }, resourceProvider.RequestedResources); + } + private IDisposable StyledWindow(params (string, string)[] assets) { var services = TestServices.StyledWindow.With( @@ -839,4 +916,23 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions }; } } + + public class TrackingResourceProvider : IResourceProvider + { + public IResourceHost Owner { get; private set; } + public bool HasResources => true; + public List RequestedResources { get; } = new List(); + + public event EventHandler OwnerChanged; + + public void AddOwner(IResourceHost owner) => Owner = owner; + public void RemoveOwner(IResourceHost owner) => Owner = null; + + public bool TryGetResource(object key, out object value) + { + RequestedResources.Add(key); + value = key; + return true; + } + } } diff --git a/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj b/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj index a07ec050a6..7864687800 100644 --- a/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj +++ b/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj @@ -4,6 +4,7 @@ Library CS0067 true + latest diff --git a/tests/Avalonia.Styling.UnitTests/StyleTests.cs b/tests/Avalonia.Styling.UnitTests/StyleTests.cs index df94887340..14710f44ba 100644 --- a/tests/Avalonia.Styling.UnitTests/StyleTests.cs +++ b/tests/Avalonia.Styling.UnitTests/StyleTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Avalonia.Controls; +using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.UnitTests; using Moq; @@ -217,6 +218,278 @@ namespace Avalonia.Styling.UnitTests Assert.Equal(new[] { "foodefault", "Bar" }, values); } + [Fact] + public void Inactive_Values_Should_Not_Be_Made_Active_During_Style_Attach() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, "Foo"), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, "Bar"), + }, + } + } + }; + + var values = new List(); + var target = new Class1(); + + target.GetObservable(Class1.FooProperty).Subscribe(x => values.Add(x)); + root.Child = target; + + Assert.Equal(new[] { "foodefault", "Bar" }, values); + } + + [Fact] + public void Inactive_Bindings_Should_Not_Be_Made_Active_During_Style_Attach() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, new Binding("Foo")), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, new Binding("Bar")), + }, + } + } + }; + + var values = new List(); + var target = new Class1 + { + DataContext = new + { + Foo = "Foo", + Bar = "Bar", + } + }; + + target.GetObservable(Class1.FooProperty).Subscribe(x => values.Add(x)); + root.Child = target; + + Assert.Equal(new[] { "foodefault", "Bar" }, values); + } + + [Fact] + public void Inactive_Values_Should_Not_Be_Made_Active_During_Style_Detach() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, "Foo"), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, "Bar"), + }, + } + } + }; + + var target = new Class1(); + root.Child = target; + + var values = new List(); + target.GetObservable(Class1.FooProperty).Subscribe(x => values.Add(x)); + root.Child = null; + + Assert.Equal(new[] { "Bar", "foodefault" }, values); + } + + [Fact] + public void Inactive_Values_Should_Not_Be_Made_Active_During_Style_Detach_2() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType().Class("foo")) + { + Setters = + { + new Setter(Class1.FooProperty, "Foo"), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, "Bar"), + }, + } + } + }; + + var target = new Class1 { Classes = { "foo" } }; + root.Child = target; + + var values = new List(); + target.GetObservable(Class1.FooProperty).Subscribe(x => values.Add(x)); + root.Child = null; + + Assert.Equal(new[] { "Foo", "foodefault" }, values); + } + + [Fact] + public void Inactive_Bindings_Should_Not_Be_Made_Active_During_Style_Detach() + { + using var app = UnitTestApplication.Start(TestServices.RealStyler); + + var root = new TestRoot + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, new Binding("Foo")), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.FooProperty, new Binding("Bar")), + }, + } + } + }; + + var target = new Class1 + { + DataContext = new + { + Foo = "Foo", + Bar = "Bar", + } + }; + + root.Child = target; + + var values = new List(); + target.GetObservable(Class1.FooProperty).Subscribe(x => values.Add(x)); + root.Child = null; + + Assert.Equal(new[] { "Bar", "foodefault" }, values); + } + + [Fact] + public void Template_In_Non_Matching_Style_Is_Not_Built() + { + var instantiationCount = 0; + var template = new FuncTemplate(() => + { + ++instantiationCount; + return new Class1(); + }); + + Styles styles = new Styles + { + new Style(x => x.OfType().Class("foo")) + { + Setters = + { + new Setter(Class1.ChildProperty, template), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.ChildProperty, template), + }, + } + }; + + var target = new Class1(); + styles.TryAttach(target, null); + + Assert.NotNull(target.Child); + Assert.Equal(1, instantiationCount); + } + + [Fact] + public void Template_In_Inactive_Style_Is_Not_Built() + { + var instantiationCount = 0; + var template = new FuncTemplate(() => + { + ++instantiationCount; + return new Class1(); + }); + + Styles styles = new Styles + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.ChildProperty, template), + }, + }, + + new Style(x => x.OfType()) + { + Setters = + { + new Setter(Class1.ChildProperty, template), + }, + } + }; + + var target = new Class1(); + target.BeginBatchUpdate(); + styles.TryAttach(target, null); + target.EndBatchUpdate(); + + Assert.NotNull(target.Child); + Assert.Equal(1, instantiationCount); + } + [Fact] public void Style_Should_Detach_When_Control_Removed_From_Logical_Tree() { @@ -453,12 +726,21 @@ namespace Avalonia.Styling.UnitTests public static readonly StyledProperty FooProperty = AvaloniaProperty.Register(nameof(Foo), "foodefault"); + public static readonly StyledProperty ChildProperty = + AvaloniaProperty.Register(nameof(Child)); + public string Foo { get { return GetValue(FooProperty); } set { SetValue(FooProperty, value); } } + public Class1 Child + { + get => GetValue(ChildProperty); + set => SetValue(ChildProperty, value); + } + protected override Size MeasureOverride(Size availableSize) { throw new NotImplementedException(); diff --git a/tests/Avalonia.UnitTests/UnitTestApplication.cs b/tests/Avalonia.UnitTests/UnitTestApplication.cs index 6fc82088e9..2d37057779 100644 --- a/tests/Avalonia.UnitTests/UnitTestApplication.cs +++ b/tests/Avalonia.UnitTests/UnitTestApplication.cs @@ -25,6 +25,7 @@ namespace Avalonia.UnitTests public UnitTestApplication(TestServices services) { _services = services ?? new TestServices(); + AvaloniaLocator.CurrentMutable.BindToSelf(this); RegisterServices(); } @@ -36,7 +37,6 @@ namespace Avalonia.UnitTests { var scope = AvaloniaLocator.EnterScope(); var app = new UnitTestApplication(services); - AvaloniaLocator.CurrentMutable.BindToSelf(app); Dispatcher.UIThread.UpdateServices(); return Disposable.Create(() => {