diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml index e600e644af..08bb1584a9 100644 --- a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml @@ -12,6 +12,8 @@ Stack - Horizontal UniformGrid - Vertical UniformGrid - Horizontal + WrapLayout - Horizontal + WrapLayout - Veritcal @@ -24,12 +26,16 @@ HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + + + - + + + diff --git a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs index cce80a2d3c..a4003cadc9 100644 --- a/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ItemsRepeaterPage.xaml.cs @@ -79,6 +79,26 @@ namespace ControlCatalog.Pages MinItemHeight = 200, }; break; + case 4: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled; + _repeater.Layout = new WrapLayout + { + Orientation = Orientation.Vertical, + HorizontalSpacing = 20, + VerticalSpacing = 20 + }; + break; + case 5: + _scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + _scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + _repeater.Layout = new WrapLayout + { + Orientation = Orientation.Horizontal, + HorizontalSpacing = 20, + VerticalSpacing = 20 + }; + break; } } diff --git a/src/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Avalonia.Headless/HeadlessWindowImpl.cs index 133d9d4baa..5bd46b6714 100644 --- a/src/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Avalonia.Headless/HeadlessWindowImpl.cs @@ -244,6 +244,8 @@ namespace Avalonia.Headless public Action LostFocus { get; set; } + public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new AcrylicPlatformCompensationLevels(1, 1, 1); + void IHeadlessWindow.KeyPress(Key key, RawInputModifiers modifiers) { Input?.Invoke(new RawKeyEventArgs(_keyboard, Timestamp, InputRoot, RawKeyEventType.KeyDown, key, modifiers)); diff --git a/src/Avalonia.Layout/WrapLayout/UvBounds.cs b/src/Avalonia.Layout/WrapLayout/UvBounds.cs new file mode 100644 index 0000000000..1c69788732 --- /dev/null +++ b/src/Avalonia.Layout/WrapLayout/UvBounds.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using Avalonia; +using Avalonia.Layout; + +namespace Avalonia.Layout +{ + internal struct UvBounds + { + public UvBounds(Orientation orientation, Rect rect) + { + if (orientation == Orientation.Horizontal) + { + UMin = rect.Left; + UMax = rect.Right; + VMin = rect.Top; + VMax = rect.Bottom; + } + else + { + UMin = rect.Top; + UMax = rect.Bottom; + VMin = rect.Left; + VMax = rect.Right; + } + } + + public double UMin { get; } + + public double UMax { get; } + + public double VMin { get; } + + public double VMax { get; } + } +} diff --git a/src/Avalonia.Layout/WrapLayout/UvMeasure.cs b/src/Avalonia.Layout/WrapLayout/UvMeasure.cs new file mode 100644 index 0000000000..e10e6bd0f8 --- /dev/null +++ b/src/Avalonia.Layout/WrapLayout/UvMeasure.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using Avalonia.Layout; + +namespace Avalonia.Layout +{ + internal struct UvMeasure + { + internal static readonly UvMeasure Zero = default(UvMeasure); + + internal double U { get; set; } + + internal double V { get; set; } + + public UvMeasure(Orientation orientation, double width, double height) + { + if (orientation == Orientation.Horizontal) + { + U = width; + V = height; + } + else + { + U = height; + V = width; + } + } + + public override bool Equals(object obj) + { + if (obj is UvMeasure measure) + { + return (measure.U == U) && (measure.V == V); + } + + return false; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + } +} diff --git a/src/Avalonia.Layout/WrapLayout/WrapItem.cs b/src/Avalonia.Layout/WrapLayout/WrapItem.cs new file mode 100644 index 0000000000..2f0b39a2d1 --- /dev/null +++ b/src/Avalonia.Layout/WrapLayout/WrapItem.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Avalonia.Controls; +using Avalonia.Layout; + +namespace Avalonia.Layout +{ + internal class WrapItem + { + public WrapItem(int index) + { + this.Index = index; + } + + public int Index { get; } + + public UvMeasure? Measure { get; internal set; } + + public UvMeasure? Position { get; internal set; } + + public ILayoutable Element { get; internal set; } + } +} diff --git a/src/Avalonia.Layout/WrapLayout/WrapLayout.cs b/src/Avalonia.Layout/WrapLayout/WrapLayout.cs new file mode 100644 index 0000000000..dccbb2205e --- /dev/null +++ b/src/Avalonia.Layout/WrapLayout/WrapLayout.cs @@ -0,0 +1,327 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; +using System; +using System.Collections.Specialized; + +namespace Avalonia.Layout +{ + /// + /// Arranges elements by wrapping them to fit the available space. + /// When is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row. + /// When is set to Orientation.Vertical, element are arranged in columns until the available height is reached. + /// + public class WrapLayout : VirtualizingLayout + { + /// + /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal, + /// or between columns of items when is set to Vertical. + /// + public double HorizontalSpacing + { + get { return (double)GetValue(HorizontalSpacingProperty); } + set { SetValue(HorizontalSpacingProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly StyledProperty HorizontalSpacingProperty = + AvaloniaProperty.Register(nameof(HorizontalSpacing), 0); + + /// + /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical, + /// or between rows of items when is set to Horizontal. + /// + public double VerticalSpacing + { + get { return (double)GetValue(VerticalSpacingProperty); } + set { SetValue(VerticalSpacingProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly StyledProperty VerticalSpacingProperty = + AvaloniaProperty.Register( + nameof(VerticalSpacing), 0d); + + /// + /// Gets or sets the orientation of the WrapLayout. + /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. + /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. + /// + public Orientation Orientation + { + get { return (Orientation)GetValue(OrientationProperty); } + set { SetValue(OrientationProperty, value); } + } + + /// + /// Identifies the dependency property. + /// + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register( + nameof(Orientation), + Orientation.Horizontal); + + /// + protected internal override void InitializeForContextCore(VirtualizingLayoutContext context) + { + var state = new WrapLayoutState(context); + context.LayoutState = state; + base.InitializeForContextCore(context); + } + + /// + protected internal override void UninitializeForContextCore(VirtualizingLayoutContext context) + { + context.LayoutState = null; + base.UninitializeForContextCore(context); + } + + /// + protected internal override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + var state = (WrapLayoutState)context.LayoutState; + + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + state.RemoveFromIndex(args.NewStartingIndex); + break; + case NotifyCollectionChangedAction.Move: + int minIndex = Math.Min(args.NewStartingIndex, args.OldStartingIndex); + state.RemoveFromIndex(minIndex); + + state.RecycleElementAt(args.OldStartingIndex); + state.RecycleElementAt(args.NewStartingIndex); + break; + case NotifyCollectionChangedAction.Remove: + state.RemoveFromIndex(args.OldStartingIndex); + break; + case NotifyCollectionChangedAction.Replace: + state.RemoveFromIndex(args.NewStartingIndex); + state.RecycleElementAt(args.NewStartingIndex); + break; + case NotifyCollectionChangedAction.Reset: + state.Clear(); + break; + } + + base.OnItemsChangedCore(context, source, args); + } + + /// + protected internal override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + var totalMeasure = UvMeasure.Zero; + var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); + var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); + var realizationBounds = new UvBounds(Orientation, context.RealizationRect); + var position = UvMeasure.Zero; + + var state = (WrapLayoutState)context.LayoutState; + if (state.Orientation != Orientation) + { + state.SetOrientation(Orientation); + } + + if (spacingMeasure.Equals(state.Spacing) == false) + { + state.ClearPositions(); + state.Spacing = spacingMeasure; + } + + if (state.AvailableU != parentMeasure.U) + { + state.ClearPositions(); + state.AvailableU = parentMeasure.U; + } + + double currentV = 0; + for (int i = 0; i < context.ItemCount; i++) + { + bool measured = false; + WrapItem item = state.GetItemAt(i); + if (item.Measure == null) + { + item.Element = context.GetOrCreateElementAt(i); + item.Element.Measure(availableSize); + item.Measure = new UvMeasure(Orientation, item.Element.DesiredSize.Width, item.Element.DesiredSize.Height); + measured = true; + } + + UvMeasure currentMeasure = item.Measure.Value; + if (currentMeasure.U == 0) + { + continue; // ignore collapsed items + } + + if (item.Position == null) + { + if (parentMeasure.U < position.U + currentMeasure.U) + { + // New Row + position.U = 0; + position.V += currentV + spacingMeasure.V; + currentV = 0; + } + + item.Position = position; + } + + position = item.Position.Value; + + double vEnd = position.V + currentMeasure.V; + if (vEnd < realizationBounds.VMin) + { + // Item is "above" the bounds + if (item.Element != null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + } + else if (position.V > realizationBounds.VMax) + { + // Item is "below" the bounds. + if (item.Element != null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + + // We don't need to measure anything below the bounds + break; + } + else if (measured == false) + { + // Always measure elements that are within the bounds + item.Element = context.GetOrCreateElementAt(i); + item.Element.Measure(availableSize); + + currentMeasure = new UvMeasure(Orientation, item.Element.DesiredSize.Width, item.Element.DesiredSize.Height); + if (currentMeasure.Equals(item.Measure) == false) + { + // this item changed size; we need to recalculate layout for everything after this + state.RemoveFromIndex(i + 1); + item.Measure = currentMeasure; + + // did the change make it go into the new row? + if (parentMeasure.U < position.U + currentMeasure.U) + { + // New Row + position.U = 0; + position.V += currentV + spacingMeasure.V; + currentV = 0; + } + + item.Position = position; + } + } + + position.U += currentMeasure.U + spacingMeasure.U; + currentV = Math.Max(currentMeasure.V, currentV); + } + + // update value with the last line + // if the the last loop is(parentMeasure.U > currentMeasure.U + lineMeasure.U) the total isn't calculated then calculate it + // if the last loop is (parentMeasure.U > currentMeasure.U) the currentMeasure isn't added to the total so add it here + // for the last condition it is zeros so adding it will make no difference + // this way is faster than an if condition in every loop for checking the last item + totalMeasure.U = parentMeasure.U; + totalMeasure.V = state.GetHeight(); + + totalMeasure.U = Math.Ceiling(totalMeasure.U); + + return Orientation == Orientation.Horizontal ? new Size(totalMeasure.U, totalMeasure.V) : new Size(totalMeasure.V, totalMeasure.U); + } + + /// + protected internal override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + if (context.ItemCount > 0) + { + var parentMeasure = new UvMeasure(Orientation, finalSize.Width, finalSize.Height); + var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); + var realizationBounds = new UvBounds(Orientation, context.RealizationRect); + + var state = (WrapLayoutState)context.LayoutState; + bool Arrange(WrapItem item, bool isLast = false) + { + if (item.Measure.HasValue == false) + { + return false; + } + + if (item.Position == null) + { + return false; + } + + var desiredMeasure = item.Measure.Value; + if (desiredMeasure.U == 0) + { + return true; // if an item is collapsed, avoid adding the spacing + } + + UvMeasure position = item.Position.Value; + + // Stretch the last item to fill the available space + if (isLast) + { + desiredMeasure.U = parentMeasure.U - position.U; + } + + if (((position.V + desiredMeasure.V) >= realizationBounds.VMin) && (position.V <= realizationBounds.VMax)) + { + // place the item + var child = context.GetOrCreateElementAt(item.Index); + if (Orientation == Orientation.Horizontal) + { + child.Arrange(new Rect(position.U, position.V, desiredMeasure.U, desiredMeasure.V)); + } + else + { + child.Arrange(new Rect(position.V, position.U, desiredMeasure.V, desiredMeasure.U)); + } + } + else if (position.V > realizationBounds.VMax) + { + return false; + } + + return true; + } + + for (var i = 0; i < context.ItemCount; i++) + { + bool continueArranging = Arrange(state.GetItemAt(i)); + if (continueArranging == false) + { + break; + } + } + } + + return finalSize; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == OrientationProperty || change.Property == HorizontalSpacingProperty || change.Property == VerticalSpacingProperty) + { + InvalidateMeasure(); + InvalidateArrange(); + } + } + } +} diff --git a/src/Avalonia.Layout/WrapLayout/WrapLayoutState.cs b/src/Avalonia.Layout/WrapLayout/WrapLayoutState.cs new file mode 100644 index 0000000000..7955ea983c --- /dev/null +++ b/src/Avalonia.Layout/WrapLayout/WrapLayoutState.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Avalonia.Layout +{ + internal class WrapLayoutState + { + private List _items = new List(); + private VirtualizingLayoutContext _context; + + public WrapLayoutState(VirtualizingLayoutContext context) + { + this._context = context; + } + + public Orientation Orientation { get; private set; } + + public UvMeasure Spacing { get; internal set; } + + public double AvailableU { get; internal set; } + + internal WrapItem GetItemAt(int index) + { + if (index < 0) + { + throw new IndexOutOfRangeException(); + } + + if (index <= (_items.Count - 1)) + { + return _items[index]; + } + else + { + WrapItem item = new WrapItem(index); + _items.Add(item); + return item; + } + } + + internal void Clear() + { + _items.Clear(); + } + + internal void RemoveFromIndex(int index) + { + if (index >= _items.Count) + { + // Item was added/removed but we haven't realized that far yet + return; + } + + int numToRemove = _items.Count - index; + _items.RemoveRange(index, numToRemove); + } + + internal void SetOrientation(Orientation orientation) + { + foreach (var item in _items.Where(i => i.Measure.HasValue)) + { + UvMeasure measure = item.Measure.Value; + double v = measure.V; + measure.V = measure.U; + measure.U = v; + item.Measure = measure; + item.Position = null; + } + + Orientation = orientation; + AvailableU = 0; + } + + internal void ClearPositions() + { + foreach (var item in _items) + { + item.Position = null; + } + } + + internal double GetHeight() + { + if (_items.Count == 0) + { + return 0; + } + + bool calculateAverage = true; + if ((_items.Count == _context.ItemCount) && _items[_items.Count - 1].Position.HasValue) + { + calculateAverage = false; + } + + UvMeasure? lastPosition = null; + double maxV = 0; + + int itemCount = _items.Count; + for (int i = _items.Count - 1; i >= 0; i--) + { + var item = _items[i]; + if (item.Position == null) + { + itemCount--; + continue; + } + + if (lastPosition != null) + { + if (lastPosition.Value.V > item.Position.Value.V) + { + // This is a row above the last item. Exit and calculate the average + break; + } + } + + lastPosition = item.Position; + maxV = Math.Max(maxV, item.Measure.Value.V); + } + + double totalHeight = lastPosition.Value.V + maxV; + if (calculateAverage) + { + return (totalHeight / itemCount) * _context.ItemCount; + } + else + { + return totalHeight; + } + } + + internal void RecycleElementAt(int index) + { + var element = _context.GetOrCreateElementAt(index); + _context.RecycleElement(element); + } + } +}