From 700abba2731e084674fc59ba436e0b5cbc135708 Mon Sep 17 00:00:00 2001
From: NaBian <836904362@qq.com>
Date: Sat, 13 Jun 2026 18:12:59 +0800
Subject: [PATCH] feat: Add responsive grid Row/Col controls and demo.
---
.../UserControl/Controls/GridDemo.axaml | 156 +++++++++++++++++
.../UserControl/Controls/GridDemo.axaml.cs | 9 +
.../Controls/Panel/Col.cs | 89 ++++++++++
.../Controls/Panel/Row.cs | 118 +++++++++++++
.../Data/Enum/ColLayoutStatus.cs | 12 ++
.../Tools/Converter/ColLayoutConverter.cs | 46 +++++
.../Tools/Extension/ColLayout.cs | 159 ++++++++++++++++++
7 files changed, 589 insertions(+)
create mode 100644 src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml
create mode 100644 src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Controls/Panel/Col.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Controls/Panel/Row.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Data/Enum/ColLayoutStatus.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Tools/Converter/ColLayoutConverter.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Tools/Extension/ColLayout.cs
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml
new file mode 100644
index 00000000..4906040c
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml.cs
new file mode 100644
index 00000000..73c212b0
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/GridDemo.axaml.cs
@@ -0,0 +1,9 @@
+namespace HandyControlDemo.UserControl;
+
+public partial class GridDemo : Avalonia.Controls.UserControl, IFull
+{
+ public GridDemo()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Col.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Col.cs
new file mode 100644
index 00000000..455ed93a
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Col.cs
@@ -0,0 +1,89 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+using HandyControl.Data;
+using HandyControl.Tools.Extension;
+
+namespace HandyControl.Controls;
+
+public class Col : ContentControl
+{
+ public static readonly StyledProperty LayoutProperty =
+ AvaloniaProperty.Register(nameof(Layout));
+
+ public ColLayout? Layout
+ {
+ get => GetValue(LayoutProperty);
+ set => SetValue(LayoutProperty, value);
+ }
+
+ public static readonly StyledProperty OffsetProperty =
+ AvaloniaProperty.Register(nameof(Offset));
+
+ public int Offset
+ {
+ get => GetValue(OffsetProperty);
+ set => SetValue(OffsetProperty, value);
+ }
+
+ public static readonly StyledProperty SpanProperty =
+ AvaloniaProperty.Register(nameof(Span), defaultValue: ColLayout.ColMaxCellCount,
+ validate: v => v is >= 1 and <= ColLayout.ColMaxCellCount);
+
+ public int Span
+ {
+ get => GetValue(SpanProperty);
+ set => SetValue(SpanProperty, value);
+ }
+
+ public static readonly StyledProperty IsFixedProperty =
+ AvaloniaProperty.Register(nameof(IsFixed));
+
+ public bool IsFixed
+ {
+ get => GetValue(IsFixedProperty);
+ set => SetValue(IsFixedProperty, value);
+ }
+
+ static Col()
+ {
+ LayoutProperty.Changed.AddClassHandler(InvalidateParentMeasure);
+ OffsetProperty.Changed.AddClassHandler(InvalidateParentMeasure);
+ SpanProperty.Changed.AddClassHandler(InvalidateParentMeasure);
+ IsFixedProperty.Changed.AddClassHandler(InvalidateParentMeasure);
+ }
+
+ private static void InvalidateParentMeasure(Col col, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (col.GetVisualParent() is Control parent)
+ {
+ parent.InvalidateMeasure();
+ }
+ }
+
+ internal int GetLayoutCellCount(ColLayoutStatus status)
+ {
+ if (Layout is not null)
+ {
+ return status switch
+ {
+ ColLayoutStatus.Xs => Layout.Xs,
+ ColLayoutStatus.Sm => Layout.Sm,
+ ColLayoutStatus.Md => Layout.Md,
+ ColLayoutStatus.Lg => Layout.Lg,
+ ColLayoutStatus.Xl => Layout.Xl,
+ ColLayoutStatus.Xxl => Layout.Xxl,
+ ColLayoutStatus.Auto => 0,
+ _ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
+ };
+ }
+
+ if (IsFixed)
+ {
+ return 0;
+ }
+
+ return Span;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Row.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Row.cs
new file mode 100644
index 00000000..74de35ef
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Panel/Row.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using HandyControl.Data;
+using HandyControl.Tools.Extension;
+
+namespace HandyControl.Controls;
+
+public class Row : Panel
+{
+ private ColLayoutStatus _layoutStatus;
+
+ private double _maxChildDesiredHeight;
+
+ private double _fixedWidth;
+
+ public static readonly StyledProperty GutterProperty =
+ AvaloniaProperty.Register(nameof(Gutter), coerce: CoerceGutter);
+
+ private static double CoerceGutter(AvaloniaObject d, double value)
+ => double.IsNaN(value) || value < 0 ? 0 : value;
+
+ public double Gutter
+ {
+ get => GetValue(GutterProperty);
+ set => SetValue(GutterProperty, value);
+ }
+
+ static Row()
+ {
+ AffectsMeasure(GutterProperty);
+ }
+
+ protected override Size MeasureOverride(Size constraint)
+ {
+ var gutter = Gutter;
+ var totalCellCount = 0;
+ var totalRowCount = 1;
+ _fixedWidth = 0;
+ _maxChildDesiredHeight = 0;
+ var cols = Children.OfType().ToList();
+
+ foreach (var child in cols)
+ {
+ var cellCount = child.GetLayoutCellCount(_layoutStatus);
+ if (cellCount == 0 || child.IsFixed)
+ {
+ child.Measure(constraint);
+ _maxChildDesiredHeight = Math.Max(_maxChildDesiredHeight, child.DesiredSize.Height);
+ _fixedWidth += child.DesiredSize.Width + gutter;
+ }
+ }
+
+ var itemWidth = (constraint.Width - _fixedWidth + gutter) / ColLayout.ColMaxCellCount;
+ itemWidth = Math.Max(0, itemWidth);
+
+ foreach (var child in cols)
+ {
+ var cellCount = child.GetLayoutCellCount(_layoutStatus);
+ if (cellCount > 0 && !child.IsFixed)
+ {
+ totalCellCount += cellCount;
+ var availableWidth = Math.Max(0, cellCount * itemWidth - gutter);
+
+ child.Measure(new Size(availableWidth, constraint.Height));
+ _maxChildDesiredHeight = Math.Max(_maxChildDesiredHeight, child.DesiredSize.Height);
+
+ if (totalCellCount > ColLayout.ColMaxCellCount)
+ {
+ totalCellCount = cellCount;
+ totalRowCount++;
+ }
+ }
+ }
+
+ return new Size(0, _maxChildDesiredHeight * totalRowCount);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var gutter = Gutter;
+ var totalCellCount = 0;
+ var cols = Children.OfType().ToList();
+ var itemWidth = (finalSize.Width - _fixedWidth + gutter) / ColLayout.ColMaxCellCount;
+ itemWidth = Math.Max(0, itemWidth);
+
+ var childBounds = new Rect(0, 0, 0, _maxChildDesiredHeight);
+ _layoutStatus = ColLayout.GetLayoutStatus(finalSize.Width);
+
+ foreach (var child in cols)
+ {
+ if (!child.IsVisible)
+ {
+ continue;
+ }
+
+ var cellCount = child.GetLayoutCellCount(_layoutStatus);
+ totalCellCount += cellCount;
+
+ var childWidth = (cellCount > 0 && !child.IsFixed) ? Math.Max(0, cellCount * itemWidth - gutter) : child.DesiredSize.Width;
+ childBounds = childBounds.WithWidth(childWidth);
+ childBounds = childBounds.WithX(childBounds.X + child.Offset * itemWidth);
+
+ if (totalCellCount > ColLayout.ColMaxCellCount)
+ {
+ childBounds = childBounds.WithX(0);
+ childBounds = childBounds.WithY(childBounds.Y + _maxChildDesiredHeight);
+ totalCellCount = cellCount;
+ }
+
+ child.Arrange(childBounds);
+ childBounds = childBounds.WithX(childBounds.X + childWidth + gutter);
+ }
+
+ return finalSize;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Data/Enum/ColLayoutStatus.cs b/src/Avalonia/HandyControl_Avalonia/Data/Enum/ColLayoutStatus.cs
new file mode 100644
index 00000000..baaa7e32
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Data/Enum/ColLayoutStatus.cs
@@ -0,0 +1,12 @@
+namespace HandyControl.Data;
+
+public enum ColLayoutStatus
+{
+ Xs,
+ Sm,
+ Md,
+ Lg,
+ Xl,
+ Xxl,
+ Auto
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Tools/Converter/ColLayoutConverter.cs b/src/Avalonia/HandyControl_Avalonia/Tools/Converter/ColLayoutConverter.cs
new file mode 100644
index 00000000..65518355
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Tools/Converter/ColLayoutConverter.cs
@@ -0,0 +1,46 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using HandyControl.Tools.Extension;
+
+namespace HandyControl.Tools.Converter;
+
+public class ColLayoutConverter : TypeConverter
+{
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ {
+ return Type.GetTypeCode(sourceType) switch
+ {
+ TypeCode.Int16 or TypeCode.UInt16
+ or TypeCode.Int32 or TypeCode.UInt32
+ or TypeCode.Int64 or TypeCode.UInt64
+ or TypeCode.Single or TypeCode.Double or TypeCode.Decimal
+ or TypeCode.String => true,
+ _ => false
+ };
+ }
+
+ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
+ => destinationType == typeof(string);
+
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ if (value == null) throw GetConvertFromException(null!);
+
+ return value switch
+ {
+ string s => ColLayout.Parse(s),
+ double d => new ColLayout((int) d),
+ _ => new ColLayout(System.Convert.ToInt32(value, culture))
+ };
+ }
+
+ public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
+ {
+ if (value is ColLayout layout && destinationType == typeof(string))
+ {
+ return layout.ToString();
+ }
+ throw new ArgumentException("CannotConvertType");
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Tools/Extension/ColLayout.cs b/src/Avalonia/HandyControl_Avalonia/Tools/Extension/ColLayout.cs
new file mode 100644
index 00000000..728da6af
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Tools/Extension/ColLayout.cs
@@ -0,0 +1,159 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using System.Text;
+using HandyControl.Data;
+using HandyControl.Tools.Converter;
+
+namespace HandyControl.Tools.Extension;
+
+[TypeConverter(typeof(ColLayoutConverter))]
+public class ColLayout
+{
+ public const int ColMaxCellCount = 24;
+
+ public const int HalfColMaxCellCount = 12;
+
+ public const int XsMaxWidth = 768;
+
+ public const int SmMaxWidth = 992;
+
+ public const int MdMaxWidth = 1200;
+
+ public const int LgMaxWidth = 1920;
+
+ public const int XlMaxWidth = 2560;
+
+ public int Xs { get; set; } = 24;
+
+ public int Sm { get; set; } = 12;
+
+ public int Md { get; set; } = 8;
+
+ public int Lg { get; set; } = 6;
+
+ public int Xl { get; set; } = 4;
+
+ public int Xxl { get; set; } = 2;
+
+ public ColLayout()
+ {
+
+ }
+
+ public ColLayout(int uniformWidth)
+ {
+ Xs = uniformWidth;
+ Sm = uniformWidth;
+ Md = uniformWidth;
+ Lg = uniformWidth;
+ Xl = uniformWidth;
+ Xxl = uniformWidth;
+ }
+
+ public ColLayout(int xs, int sm, int md, int lg, int xl, int xxl)
+ {
+ Xs = xs;
+ Sm = sm;
+ Md = md;
+ Lg = lg;
+ Xl = xl;
+ Xxl = xxl;
+ }
+
+ public object ProvideValue(IServiceProvider serviceProvider)
+ {
+ return new ColLayout
+ {
+ Xs = Xs,
+ Sm = Sm,
+ Md = Md,
+ Lg = Lg,
+ Xl = Xl,
+ Xxl = Xxl
+ };
+ }
+
+ public static ColLayoutStatus GetLayoutStatus(double width)
+ {
+ if (width < MdMaxWidth)
+ {
+ if (width < SmMaxWidth)
+ {
+ if (width < XsMaxWidth)
+ {
+ return ColLayoutStatus.Xs;
+ }
+ return ColLayoutStatus.Sm;
+ }
+ return ColLayoutStatus.Md;
+ }
+ if (width < XlMaxWidth)
+ {
+ if (width < LgMaxWidth)
+ {
+ return ColLayoutStatus.Lg;
+ }
+ return ColLayoutStatus.Xl;
+ }
+ return ColLayoutStatus.Xxl;
+ }
+
+ public static ColLayout Parse(string s)
+ {
+ if (string.IsNullOrWhiteSpace(s)) throw new FormatException("InvalidStringColLayout");
+
+ var separators = new[] { ',', ' ' };
+ var tokens = s.Split(separators, StringSplitOptions.RemoveEmptyEntries);
+ var lengths = new int[6];
+ var i = 0;
+ foreach (var token in tokens)
+ {
+ if (i >= 6) throw new FormatException("InvalidStringColLayout");
+ lengths[i++] = int.Parse(token, CultureInfo.InvariantCulture);
+ }
+
+ return i switch
+ {
+ 1 => new ColLayout(lengths[0]),
+ 2 => new ColLayout { Xs = lengths[0], Sm = lengths[1] },
+ 3 => new ColLayout { Xs = lengths[0], Sm = lengths[1], Md = lengths[2] },
+ 4 => new ColLayout { Xs = lengths[0], Sm = lengths[1], Md = lengths[2], Lg = lengths[3] },
+ 5 => new ColLayout
+ {
+ Xs = lengths[0],
+ Sm = lengths[1],
+ Md = lengths[2],
+ Lg = lengths[3],
+ Xl = lengths[4]
+ },
+ 6 => new ColLayout
+ {
+ Xs = lengths[0],
+ Sm = lengths[1],
+ Md = lengths[2],
+ Lg = lengths[3],
+ Xl = lengths[4],
+ Xxl = lengths[5]
+ },
+ _ => throw new FormatException("InvalidStringColLayout")
+ };
+ }
+
+ public override string ToString()
+ {
+ var sb = new StringBuilder(128);
+ sb.Append(Xs.ToString(CultureInfo.CurrentCulture));
+ sb.Append(',');
+ sb.Append(Sm.ToString(CultureInfo.CurrentCulture));
+ sb.Append(',');
+ sb.Append(Md.ToString(CultureInfo.CurrentCulture));
+ sb.Append(',');
+ sb.Append(Lg.ToString(CultureInfo.CurrentCulture));
+ sb.Append(',');
+ sb.Append(Xl.ToString(CultureInfo.CurrentCulture));
+ sb.Append(',');
+ sb.Append(Xxl.ToString(CultureInfo.CurrentCulture));
+ return sb.ToString();
+ }
+}