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);
+ }
+}