diff --git a/README.md b/README.md index ba6c0011a..a645d0db4 100644 --- a/README.md +++ b/README.md @@ -661,12 +661,18 @@ This section provides an overview of all available classes and their purpose in *Automatically selects a ListBoxItem when the pointer moves over it.* ### Responsive -- **AdaptiveBehavior** +- **AdaptiveBehavior** *Observes bounds changes of a control (or a specified source) and conditionally adds or removes CSS-style classes based on adaptive rules.* -- **AdaptiveClassSetter** +- **AdaptiveClassSetter** *Specifies comparison conditions (min/max width/height) and the class to apply when those conditions are met.* +- **AspectRatioBehavior** + *Observes bounds changes and toggles CSS-style classes when the control's aspect ratio matches specified rules.* + +- **AspectRatioClassSetter** + *Defines aspect ratio comparison conditions and the class to apply when those conditions are met.* + ### ScrollViewer - **HorizontalScrollViewerBehavior** *Enables horizontal scrolling via the pointer wheel. Optionally requires the Shift key and supports line or page scrolling.* diff --git a/samples/BehaviorsTestApplication/Views/MainView.axaml b/samples/BehaviorsTestApplication/Views/MainView.axaml index 20311267a..396c0400c 100644 --- a/samples/BehaviorsTestApplication/Views/MainView.axaml +++ b/samples/BehaviorsTestApplication/Views/MainView.axaml @@ -64,6 +64,9 @@ + + + diff --git a/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml b/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml new file mode 100644 index 000000000..5a3d1c2b9 --- /dev/null +++ b/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml.cs b/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml.cs new file mode 100644 index 000000000..6dc73c48d --- /dev/null +++ b/samples/BehaviorsTestApplication/Views/Pages/AspectRatioBehaviorView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace BehaviorsTestApplication.Views.Pages; + +public partial class AspectRatioBehaviorView : UserControl +{ + public AspectRatioBehaviorView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioBehavior.cs b/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioBehavior.cs new file mode 100644 index 000000000..682c24865 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioBehavior.cs @@ -0,0 +1,193 @@ +using System; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Metadata; +using Avalonia.Reactive; +using Avalonia.Xaml.Interactivity; + +namespace Avalonia.Xaml.Interactions.Responsive; + +/// +/// Observes bounds changes of a control (or a specified source) and conditionally adds or removes classes +/// based on rules. +/// +public class AspectRatioBehavior : StyledElementBehavior +{ + private IDisposable? _disposable; + private AvaloniaList? _setters; + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty SourceControlProperty = + AvaloniaProperty.Register(nameof(SourceControl)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty TargetControlProperty = + AvaloniaProperty.Register(nameof(TargetControl)); + + /// + /// Identifies the avalonia property. + /// + public static readonly DirectProperty> SettersProperty = + AvaloniaProperty.RegisterDirect>(nameof(Setters), t => t.Setters); + + /// + /// Gets or sets the control whose bounds are observed. If not set, is used. This is an avalonia property. + /// + [ResolveByName] + public Control? SourceControl + { + get => GetValue(SourceControlProperty); + set => SetValue(SourceControlProperty, value); + } + + /// + /// Gets or sets the target control to add or remove classes from. If not set, the associated object or setter TargetControl is used. This is an avalonia property. + /// + [ResolveByName] + public Control? TargetControl + { + get => GetValue(TargetControlProperty); + set => SetValue(TargetControlProperty, value); + } + + /// + /// Gets aspect ratio class setters collection. This is an avalonia property. + /// + [Content] + public AvaloniaList Setters => _setters ??= []; + + /// + protected override void OnAttachedToVisualTree() + { + base.OnAttachedToVisualTree(); + StopObserving(); + StartObserving(); + } + + /// + protected override void OnDetachedFromVisualTree() + { + base.OnDetachedFromVisualTree(); + StopObserving(); + } + + private void StartObserving() + { + var sourceControl = GetValue(SourceControlProperty) is not null + ? SourceControl + : AssociatedObject; + + if (sourceControl is not null) + { + _disposable = ObserveBounds(sourceControl); + } + } + + private void StopObserving() + { + _disposable?.Dispose(); + } + + private IDisposable ObserveBounds(Control sourceControl) + { + if (sourceControl is null) + { + throw new ArgumentNullException(nameof(sourceControl)); + } + + Execute(sourceControl, Setters, sourceControl.GetValue(Visual.BoundsProperty)); + + return sourceControl.GetObservable(Visual.BoundsProperty) + .Subscribe(new AnonymousObserver(bounds => Execute(sourceControl, Setters, bounds))); + } + + private void Execute(Control? sourceControl, AvaloniaList? setters, Rect bounds) + { + if (sourceControl is null || setters is null) + { + return; + } + + var ratio = bounds.Height > 0 ? bounds.Width / bounds.Height : double.PositiveInfinity; + + foreach (var setter in setters) + { + var ratioConditionTriggered = GetResult(setter.MinRatioOperator, ratio, setter.MinRatio) && + GetResult(setter.MaxRatioOperator, ratio, setter.MaxRatio); + + var targetControl = setter.GetValue(AspectRatioClassSetter.TargetControlProperty) is not null + ? setter.TargetControl + : GetValue(TargetControlProperty) is not null + ? TargetControl + : AssociatedObject; + + if (targetControl is not null) + { + var className = setter.ClassName; + var isPseudoClass = setter.IsPseudoClass; + + if (ratioConditionTriggered) + { + Add(targetControl, className, isPseudoClass); + } + else + { + Remove(targetControl, className, isPseudoClass); + } + } + else + { + throw new ArgumentNullException(nameof(targetControl)); + } + } + } + + private static bool GetResult(ComparisonConditionType comparisonConditionType, double property, double value) => comparisonConditionType switch + { + ComparisonConditionType.Equal => property == value, + ComparisonConditionType.NotEqual => property != value, + ComparisonConditionType.LessThan => property < value, + ComparisonConditionType.LessThanOrEqual => property <= value, + ComparisonConditionType.GreaterThan => property > value, + ComparisonConditionType.GreaterThanOrEqual => property >= value, + _ => throw new ArgumentOutOfRangeException() + }; + + private static void Add(Control targetControl, string? className, bool isPseudoClass) + { + if (className is null || string.IsNullOrEmpty(className) || targetControl.Classes.Contains(className)) + { + return; + } + + if (isPseudoClass) + { + ((IPseudoClasses)targetControl.Classes).Add(className); + } + else + { + targetControl.Classes.Add(className); + } + } + + private static void Remove(Control targetControl, string? className, bool isPseudoClass) + { + if (className is null || string.IsNullOrEmpty(className) || !targetControl.Classes.Contains(className)) + { + return; + } + + if (isPseudoClass) + { + ((IPseudoClasses)targetControl.Classes).Remove(className); + } + else + { + targetControl.Classes.Remove(className); + } + } +} diff --git a/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioClassSetter.cs b/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioClassSetter.cs new file mode 100644 index 000000000..b0dbc7619 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions.Responsive/AspectRatioClassSetter.cs @@ -0,0 +1,118 @@ +using Avalonia.Controls; +using Avalonia.Metadata; +using Avalonia.Xaml.Interactivity; + +namespace Avalonia.Xaml.Interactions.Responsive; + +/// +/// Conditional class setter based on aspect ratio used in . +/// +public class AspectRatioClassSetter : AvaloniaObject +{ + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty MinRatioProperty = + AvaloniaProperty.Register(nameof(MinRatio)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty MinRatioOperatorProperty = + AvaloniaProperty.Register(nameof(MinRatioOperator), ComparisonConditionType.GreaterThanOrEqual); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty MaxRatioProperty = + AvaloniaProperty.Register(nameof(MaxRatio), double.PositiveInfinity); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty MaxRatioOperatorProperty = + AvaloniaProperty.Register(nameof(MaxRatioOperator), ComparisonConditionType.LessThan); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty ClassNameProperty = + AvaloniaProperty.Register(nameof(ClassName)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty IsPseudoClassProperty = + AvaloniaProperty.Register(nameof(IsPseudoClass)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty TargetControlProperty = + AvaloniaProperty.Register(nameof(TargetControl)); + + /// + /// Gets or sets minimum aspect ratio used for comparison. This is an avalonia property. + /// + public double MinRatio + { + get => GetValue(MinRatioProperty); + set => SetValue(MinRatioProperty, value); + } + + /// + /// Gets or sets minimum aspect ratio comparison operator. This is an avalonia property. + /// + public ComparisonConditionType MinRatioOperator + { + get => GetValue(MinRatioOperatorProperty); + set => SetValue(MinRatioOperatorProperty, value); + } + + /// + /// Gets or sets maximum aspect ratio used for comparison. This is an avalonia property. + /// + public double MaxRatio + { + get => GetValue(MaxRatioProperty); + set => SetValue(MaxRatioProperty, value); + } + + /// + /// Gets or sets maximum aspect ratio comparison operator. This is an avalonia property. + /// + public ComparisonConditionType MaxRatioOperator + { + get => GetValue(MaxRatioOperatorProperty); + set => SetValue(MaxRatioOperatorProperty, value); + } + + /// + /// Gets or sets the class name that should be added or removed. This is an avalonia property. + /// + [Content] + public string? ClassName + { + get => GetValue(ClassNameProperty); + set => SetValue(ClassNameProperty, value); + } + + /// + /// Gets or sets the flag whether is a pseudo class. This is an avalonia property. + /// + public bool IsPseudoClass + { + get => GetValue(IsPseudoClassProperty); + set => SetValue(IsPseudoClassProperty, value); + } + + /// + /// Gets or sets the target control that class name should be added or removed from when triggered. This is an avalonia property. + /// + [ResolveByName] + public Control? TargetControl + { + get => GetValue(TargetControlProperty); + set => SetValue(TargetControlProperty, value); + } +}