diff --git a/Material.Styles/Assists/ShadowAssist.cs b/Material.Styles/Assists/ShadowAssist.cs index cd1040e1..fd47e6c3 100644 --- a/Material.Styles/Assists/ShadowAssist.cs +++ b/Material.Styles/Assists/ShadowAssist.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using Material.Styles.Internal; namespace Material.Styles.Assists { public static class ShadowProvider { diff --git a/Material.Styles/Assists/TransitionAssist.cs b/Material.Styles/Assists/TransitionAssist.cs index ddc4fdfb..fe57e1df 100644 --- a/Material.Styles/Assists/TransitionAssist.cs +++ b/Material.Styles/Assists/TransitionAssist.cs @@ -1,11 +1,9 @@ -using System; -using Avalonia; +using Avalonia; using Avalonia.Data; +using Material.Styles.Internal; -namespace Material.Styles.Assists -{ - public static class TransitionAssist - { +namespace Material.Styles.Assists { + public static class TransitionAssist { /// /// Allows transitions to be disabled where supported. Note this is an inheritable property. /// @@ -13,10 +11,8 @@ public static class TransitionAssist AvaloniaProperty.RegisterAttached( "DisableTransitions", typeof(TransitionAssist), false, true, BindingMode.TwoWay); - static TransitionAssist() - { - DisableTransitionsProperty.Changed.Subscribe(args => - { + static TransitionAssist() { + DisableTransitionsProperty.Changed.Subscribe(args => { if (args.Sender is not StyledElement styledElement) return; styledElement.Classes.Set("no-transitions", args.NewValue.Value); diff --git a/Material.Styles/Controls/CircleClockPicker.axaml.cs b/Material.Styles/Controls/CircleClockPicker.axaml.cs index 25b65818..bf89089c 100644 --- a/Material.Styles/Controls/CircleClockPicker.axaml.cs +++ b/Material.Styles/Controls/CircleClockPicker.axaml.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Reactive.Disposables; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -32,6 +31,16 @@ public class CircleClockPicker : TemplatedControl { public static readonly StyledProperty CellShiftNumberProperty = AvaloniaProperty.Register(nameof(CellShiftNumber)); + private readonly Dictionary _cachedAccessors = new(); + private Panel? _cellPanel; + + private bool _isDragging; + private Control? _pointer; + private Control? _pointerPin; + + private int? _value; + + static CircleClockPicker() { } public int? Value { get => _value; @@ -73,14 +82,9 @@ public int CellShiftNumber { public event EventHandler? AfterDrag; - static CircleClockPicker() { } - protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - _subscription?.Dispose(); - _subscription = null; - var pointer = e.NameScope.Find("PART_Pointer"); var canvas = e.NameScope.Find("PART_CellPanel"); var pointerPin = e.NameScope.Find("PART_PointerPin"); @@ -89,21 +93,27 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _pointerPin = pointerPin; _cellPanel = canvas; - _subscription = new CompositeDisposable { - MinimumProperty.Changed.Subscribe(OnNext), - MaximumProperty.Changed.Subscribe(OnNext), - StepFrequencyProperty.Changed.Subscribe(OnNext), - FirstLabelOverrideProperty.Changed.Subscribe(OnNext), - RadiusMultiplierProperty.Changed.Subscribe(OnNext), - BoundsProperty.Changed.Subscribe(OnCanvasResize) - }; - UpdateCellPanel(); AdjustPointer(); UpdateVisual(_value); } - private void OnCanvasResize(AvaloniaPropertyChangedEventArgs obj) { + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + if (change.Property == MinimumProperty || + change.Property == MaximumProperty || + change.Property == StepFrequencyProperty || + change.Property == FirstLabelOverrideProperty || + change.Property == RadiusMultiplierProperty) { + OnNext(change); + return; + } + + if (change.Property == BoundsProperty) OnCanvasResize(change); + } + + private void OnCanvasResize(AvaloniaPropertyChangedEventArgs obj) { if (!ReferenceEquals(obj.Sender, _cellPanel)) return; @@ -140,15 +150,6 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) { AfterDrag?.Invoke(this, EventArgs.Empty); } - private bool _isDragging; - private Control? _pointer; - private Control? _pointerPin; - private Panel? _cellPanel; - private readonly Dictionary _cachedAccessors = new(); - private IDisposable? _subscription; - - private int? _value; - private void ProcessPointerEvent(Point point) { var halfSize = (float)(Bounds.Width / 2); var rad = (float)Math.Atan2(point.Y - halfSize, point.X - halfSize); @@ -271,4 +272,4 @@ private void AdjustPointer() { var radius = _cellPanel.Bounds.Width / 2; _pointerPin.Height = radius * RadiusMultiplier; } -} +} \ No newline at end of file diff --git a/Material.Styles/Controls/MaterialInternalIcon.axaml.cs b/Material.Styles/Controls/MaterialInternalIcon.axaml.cs index c6b6a094..6656884e 100644 --- a/Material.Styles/Controls/MaterialInternalIcon.axaml.cs +++ b/Material.Styles/Controls/MaterialInternalIcon.axaml.cs @@ -17,10 +17,6 @@ public class MaterialInternalIcon : TemplatedControl { private Geometry? _data; - static MaterialInternalIcon() { - KindProperty.Changed.Subscribe(args => (args.Sender as MaterialInternalIcon)?.UpdateData()); - } - /// /// Gets or sets the icon to display. /// @@ -48,6 +44,13 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { UpdateData(); } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + + if (change.Property == KindProperty) UpdateData(); + } + private void UpdateData() { if (Kind is null) return; @@ -59,4 +62,4 @@ private void UpdateData() { Data = null; } } -} +} \ No newline at end of file diff --git a/Material.Styles/Internal/Disposable.cs b/Material.Styles/Internal/Disposable.cs new file mode 100644 index 00000000..6fe21ff4 --- /dev/null +++ b/Material.Styles/Internal/Disposable.cs @@ -0,0 +1,26 @@ +using System; + +namespace Material.Styles.Internal; + +/// +/// Provides a set of static methods for creating objects. +/// +internal static class Disposable { + /// + /// Gets the disposable that does nothing when disposed. + /// + public static IDisposable Empty => EmptyDisposable.Instance; + + /// + /// Represents a disposable that does nothing on disposal. + /// + private sealed class EmptyDisposable : IDisposable { + public static readonly EmptyDisposable Instance = new(); + + private EmptyDisposable() { } + + public void Dispose() { + // no op + } + } +} \ No newline at end of file diff --git a/Material.Styles/Internal/LightweightObservableBase.cs b/Material.Styles/Internal/LightweightObservableBase.cs new file mode 100644 index 00000000..ba5ddab0 --- /dev/null +++ b/Material.Styles/Internal/LightweightObservableBase.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Material.Styles.Internal; + +/// +/// Lightweight base class for observable implementations. +/// +/// The observable type. +/// +/// ObservableBase{T} is rather heavyweight in terms of allocations and memory +/// usage. This class provides a more lightweight base for some internal observable types +/// in the Avalonia framework. +/// +internal abstract class LightweightObservableBase : IObservable { + private Exception? _error; + private List>? _observers = new(); + + public bool HasObservers => _observers?.Count > 0; + + public IDisposable Subscribe(IObserver observer) { + _ = observer ?? throw new ArgumentNullException(nameof(observer)); + + //Dispatcher.UIThread.VerifyAccess(); + + var first = false; + + for (;;) { + if (Volatile.Read(ref _observers) == null) { + if (_error != null) + observer.OnError(_error); + else + observer.OnCompleted(); + + return Disposable.Empty; + } + + lock (this) { + if (_observers == null) continue; + + first = _observers.Count == 0; + _observers.Add(observer); + break; + } + } + + if (first) Initialize(); + + Subscribed(observer, first); + + return new RemoveObserver(this, observer); + } + + private void Remove(IObserver observer) { + if (Volatile.Read(ref _observers) != null) { + lock (this) { + var observers = _observers; + + if (observers != null) { + observers.Remove(observer); + + if (observers.Count == 0) { + observers.TrimExcess(); + Deinitialize(); + } + } + } + } + } + + protected abstract void Initialize(); + protected abstract void Deinitialize(); + + protected void PublishNext(T value) { + if (Volatile.Read(ref _observers) != null) { + IObserver[]? observers = null; + IObserver? singleObserver = null; + lock (this) { + if (_observers == null) return; + if (_observers.Count == 1) + singleObserver = _observers[0]; + else + observers = _observers.ToArray(); + } + if (singleObserver != null) + singleObserver.OnNext(value); + else { + foreach (var observer in observers!) observer.OnNext(value); + } + } + } + + protected void PublishCompleted() { + if (Volatile.Read(ref _observers) != null) { + IObserver[] observers; + + lock (this) { + if (_observers == null) return; + observers = _observers.ToArray(); + Volatile.Write(ref _observers, null); + } + + foreach (var observer in observers) observer.OnCompleted(); + + Deinitialize(); + } + } + + protected void PublishError(Exception error) { + if (Volatile.Read(ref _observers) != null) { + + IObserver[] observers; + + lock (this) { + if (_observers == null) return; + + _error = error; + observers = _observers.ToArray(); + Volatile.Write(ref _observers, null); + } + + foreach (var observer in observers) observer.OnError(error); + + Deinitialize(); + } + } + + protected virtual void Subscribed(IObserver observer, bool first) { } + + private sealed class RemoveObserver : IDisposable { + private IObserver? _observer; + private LightweightObservableBase? _parent; + + public RemoveObserver(LightweightObservableBase parent, IObserver observer) { + _parent = parent; + Volatile.Write(ref _observer, observer); + } + + public void Dispose() { + var observer = _observer; + Interlocked.Exchange(ref _parent, null)?.Remove(observer!); + _observer = null; + } + } +} \ No newline at end of file diff --git a/Material.Styles/Internal/LightweightSubject.cs b/Material.Styles/Internal/LightweightSubject.cs new file mode 100644 index 00000000..0ca76543 --- /dev/null +++ b/Material.Styles/Internal/LightweightSubject.cs @@ -0,0 +1,21 @@ +using System; + +namespace Material.Styles.Internal; + +internal class LightweightSubject : LightweightObservableBase { + public void OnCompleted() { + PublishCompleted(); + } + + public void OnError(Exception error) { + PublishError(error); + } + + public void OnNext(T value) { + PublishNext(value); + } + + protected override void Initialize() { } + + protected override void Deinitialize() { } +} \ No newline at end of file diff --git a/Material.Styles/Internal/Observable.cs b/Material.Styles/Internal/Observable.cs new file mode 100644 index 00000000..59f6c96a --- /dev/null +++ b/Material.Styles/Internal/Observable.cs @@ -0,0 +1,10 @@ +using System; +using Avalonia.Reactive; + +namespace Material.Styles.Internal; + +internal static class Observable { + public static IDisposable Subscribe(this IObservable source, Action action) { + return source.Subscribe(new AnonymousObserver(action)); + } +} \ No newline at end of file diff --git a/Material.Styles/Material.Styles.csproj b/Material.Styles/Material.Styles.csproj index d7961011..41b6e21c 100644 --- a/Material.Styles/Material.Styles.csproj +++ b/Material.Styles/Material.Styles.csproj @@ -19,6 +19,5 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Material.Styles/Themes/MaterialTheme.cs b/Material.Styles/Themes/MaterialTheme.cs index a81eee45..9c4fa7dc 100644 --- a/Material.Styles/Themes/MaterialTheme.cs +++ b/Material.Styles/Themes/MaterialTheme.cs @@ -1,8 +1,5 @@ using System; -using System.Reactive; -using System.Reactive.Linq; using Avalonia; -using Avalonia.Controls; using Avalonia.Styling; using Avalonia.Threading; using Material.Colors; @@ -15,7 +12,7 @@ namespace Material.Styles.Themes { /// /// You need to setup all these properties: , , /// - public class MaterialTheme : MaterialThemeBase, IDisposable, IResourceNode { + public class MaterialTheme : MaterialThemeBase, IDisposable { public static readonly StyledProperty BaseThemeProperty = AvaloniaProperty.Register(nameof(BaseTheme)); @@ -29,36 +26,18 @@ public class MaterialTheme : MaterialThemeBase, IDisposable, IResourceNode { AvaloniaProperty.RegisterDirect( nameof(ActualBaseTheme), o => o.ActualBaseTheme); - private readonly IDisposable _themeUpdaterDisposable; + private readonly ITheme _theme = new Theme(); private BaseThemeMode _actualBaseTheme; - private IDisposable? _baseThemeChangeObservable; private bool _isLoaded; - private ITheme _theme = new Theme(); + private IThemeVariantHost? _lastThemeVariantHost; + private IDisposable? _themeUpdateDisposable; /// /// Initializes a new instance of the class. /// /// The XAML service provider. public MaterialTheme(IServiceProvider serviceProvider) : base(serviceProvider) { - var baseThemeObservable = this.GetObservable(ActualBaseThemeProperty) - .Do(mode => _theme = _theme.SetBaseTheme(mode.GetBaseTheme())) - .Select(_ => Unit.Default); - var primaryColorObservable = this.GetObservable(PrimaryColorProperty) - .Do(color => _theme = _theme.SetPrimaryColor(SwatchHelper.Lookup[(MaterialColor)color])) - .Select(_ => Unit.Default); - var secondaryColorObservable = this.GetObservable(SecondaryColorProperty) - .Do(color => _theme = _theme.SetSecondaryColor(SwatchHelper.Lookup[(MaterialColor)color])) - .Select(_ => Unit.Default); - - _themeUpdaterDisposable = baseThemeObservable - .Merge(primaryColorObservable) - .Merge(secondaryColorObservable) - .Where(_ => _isLoaded) - .Throttle(TimeSpan.FromMilliseconds(100)) - .ObserveOn(new AvaloniaSynchronizationContext()) - .Subscribe(_ => CurrentTheme = _theme); - OwnerChanged += OnOwnerChanged; } @@ -83,7 +62,7 @@ public SecondaryColor SecondaryColor { } public void Dispose() { - _themeUpdaterDisposable.Dispose(); + _themeUpdateDisposable?.Dispose(); } private void OnOwnerChanged(object sender, EventArgs e) { @@ -95,35 +74,65 @@ protected override bool TryGetResource(object key, ThemeVariant? theme, out obje || base.TryGetResource(key, ActualBaseTheme.GetVariantFromMaterialBaseThemeMode(), out value); } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + base.OnPropertyChanged(change); + + if (change.Property == BaseThemeProperty) { + SetupActualTheme(); + return; + } + + if (change.Property == ActualBaseThemeProperty) { + var baseTheme = change.GetNewValue().GetBaseTheme(); + _theme.SetBaseTheme(baseTheme); + EnqueueThemeUpdate(); + return; + } + + if (change.Property == PrimaryColorProperty) { + var color = change.GetNewValue(); + _theme.SetPrimaryColor(SwatchHelper.Lookup[(MaterialColor)color]); + EnqueueThemeUpdate(); + return; + } + + if (change.Property == SecondaryColorProperty) { + var color = change.GetNewValue(); + _theme.SetSecondaryColor(SwatchHelper.Lookup[(MaterialColor)color]); + EnqueueThemeUpdate(); + return; + } + } + + private void EnqueueThemeUpdate() { + if (!_isLoaded) return; + + _themeUpdateDisposable?.Dispose(); + _themeUpdateDisposable = DispatcherTimer.RunOnce(() => CurrentTheme = _theme, TimeSpan.FromMilliseconds(100)); + } + private void RegisterActualThemeObservable() { - _baseThemeChangeObservable?.Dispose(); - - var themeVariantHost = Owner as IThemeVariantHost; - var themeVariantObservable = themeVariantHost != null - ? Observable.FromEvent(action => (_, _) => { action(Unit.Default); }, - handler => themeVariantHost.ActualThemeVariantChanged += handler, - handler => themeVariantHost.ActualThemeVariantChanged -= handler) - .Select(_ => Unit.Default) - : Observable.Empty(); - - var targetBaseObservable = this.GetObservable(BaseThemeProperty) - .Select(_ => Unit.Default); - - _baseThemeChangeObservable = Observable.Return(Unit.Default) - .Merge(themeVariantObservable) - .Merge(targetBaseObservable) - .Subscribe(_ => { - ActualBaseTheme = GetActualBaseTheme(BaseTheme, themeVariantHost?.ActualThemeVariant); - }); + if (_lastThemeVariantHost is not null) _lastThemeVariantHost.ActualThemeVariantChanged -= HostOnActualThemeVariantChanged; + + _lastThemeVariantHost = Owner as IThemeVariantHost; + if (_lastThemeVariantHost is not null) _lastThemeVariantHost.ActualThemeVariantChanged += HostOnActualThemeVariantChanged; + SetupActualTheme(); } - private BaseThemeMode GetActualBaseTheme(BaseThemeMode mode, ThemeVariant? variant) { - return mode switch { - BaseThemeMode.Inherit => variant.GetMaterialBaseThemeModeFromVariant() ?? BaseThemeMode.Light, + private void HostOnActualThemeVariantChanged(object sender, EventArgs e) { + SetupActualTheme(); + } + + private void SetupActualTheme() { + var materialBaseThemeModeFromVariant = BaseTheme switch { + BaseThemeMode.Inherit => (_lastThemeVariantHost?.ActualThemeVariant).GetMaterialBaseThemeModeFromVariant() ?? BaseThemeMode.Light, BaseThemeMode.Light => BaseThemeMode.Light, BaseThemeMode.Dark => BaseThemeMode.Dark, - _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + _ => throw new ArgumentOutOfRangeException(nameof(BaseTheme), BaseTheme, null) }; + + ActualBaseTheme = materialBaseThemeModeFromVariant; } protected override ITheme ProvideInitialTheme() { diff --git a/Material.Styles/Themes/MaterialThemeBase.cs b/Material.Styles/Themes/MaterialThemeBase.cs index eeb778d1..9060c7c9 100644 --- a/Material.Styles/Themes/MaterialThemeBase.cs +++ b/Material.Styles/Themes/MaterialThemeBase.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia; @@ -12,6 +11,7 @@ using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; +using Material.Styles.Internal; namespace Material.Styles.Themes; @@ -22,20 +22,23 @@ public class MaterialThemeBase : Avalonia.Styling.Styles, IResourceNode { o => o.CurrentTheme, (o, v) => o.CurrentTheme = v); - private CancellationTokenSource? _currentCancellationTokenSource; - + private readonly IServiceProvider? _serviceProvider; + private readonly LightweightSubject _themeChangedEndSubject = new(); private IReadOnlyTheme _currentTheme = new ReadOnlyTheme(); private Task? _currentThemeUpdateTask; private IResourceDictionary? _internalResources; private bool _isResourcedAccessed; + + private CancellationTokenSource? _themeUpdateCancellationTokenSource; + /// /// Initializes a new instance of the class. /// /// The parent's service provider. public MaterialThemeBase(IServiceProvider? serviceProvider) { - AvaloniaXamlLoader.Load(serviceProvider, this); + _serviceProvider = serviceProvider; } /// @@ -78,15 +81,7 @@ internal IResourceDictionary InternalResources { public IObservable CurrentThemeChanged => this.GetObservable(CurrentThemeProperty); public IObservable ThemeChangedEndObservable => - Observable.FromEvent( - conversion => delegate(object sender, EventArgs _) { - if (sender is not MaterialThemeBase theme) - return; - - conversion(theme); - }, - h => ThemeChangedEnd += h, - h => ThemeChangedEnd -= h); + _themeChangedEndSubject; private static IReadOnlyDictionary> UpdatableColors => new Dictionary> { @@ -170,6 +165,7 @@ bool IResourceNode.TryGetResource(object key, ThemeVariant? theme, out object? v private void OnResourcedAccessed() { var initialTheme = ProvideInitialTheme(); + AvaloniaXamlLoader.Load(_serviceProvider, this); if (initialTheme != null) { var newTheme = new ReadOnlyTheme(initialTheme); var defaultThemeDictionary = (ResourceDictionary)InternalResources.ThemeDictionaries[ThemeVariant.Default]; @@ -182,11 +178,11 @@ private void OnResourcedAccessed() { private void StartUpdatingTheme(IReadOnlyTheme oldTheme, IReadOnlyTheme newTheme) { Task.Run(async () => { - _currentCancellationTokenSource?.Cancel(); - _currentCancellationTokenSource?.Dispose(); + _themeUpdateCancellationTokenSource?.Cancel(); + _themeUpdateCancellationTokenSource?.Dispose(); var currentToken = new CancellationTokenSource(); - _currentCancellationTokenSource = currentToken; + _themeUpdateCancellationTokenSource = currentToken; if (_currentThemeUpdateTask != null) await _currentThemeUpdateTask; if (!currentToken.IsCancellationRequested) { @@ -208,6 +204,7 @@ private void StartUpdatingTheme(IReadOnlyTheme oldTheme, IReadOnlyTheme newTheme await task.ContinueWith(delegate { ThemeChangedEnd?.Invoke(this, EventArgs.Empty); + _themeChangedEndSubject.OnNext(this); }, CancellationToken.None); } });