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