diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs new file mode 100644 index 00000000..b7aa699a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs @@ -0,0 +1,24 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using System.Diagnostics; + +namespace Snap.Hutao.Control.Layout; + +[DebuggerDisplay("Count = {Count}, Height = {Height}")] +internal class UniformStaggeredColumnLayout : List +{ + public double Height { get; private set; } + + public new void Add(UniformStaggeredItem item) + { + Height = item.Top + item.Height; + base.Add(item); + } + + public new void Clear() + { + Height = 0; + base.Clear(); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs new file mode 100644 index 00000000..3ae32b3c --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs @@ -0,0 +1,29 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Snap.Hutao.Control.Layout; + +internal sealed class UniformStaggeredItem +{ + public UniformStaggeredItem(int index) + { + Index = index; + } + + public double Top { get; internal set; } + + public double Height { get; internal set; } + + public int Index { get; } + + public UIElement? Element { get; internal set; } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs new file mode 100644 index 00000000..e2ce7a17 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs @@ -0,0 +1,270 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Collections.Specialized; +using Windows.Foundation; + +namespace Snap.Hutao.Control.Layout; + +[DependencyProperty("MinItemWidth", typeof(double), 0D, nameof(OnMinItemWidthChanged))] +[DependencyProperty("MinColumnSpacing", typeof(double), 0D, nameof(OnSpacingChanged))] +[DependencyProperty("MinRowSpacing", typeof(double), 0D, nameof(OnSpacingChanged))] +internal sealed partial class UniformStaggeredLayout : VirtualizingLayout +{ + /// + protected override void InitializeForContextCore(VirtualizingLayoutContext context) + { + context.LayoutState = new UniformStaggeredLayoutState(context); + base.InitializeForContextCore(context); + } + + /// + protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + { + context.LayoutState = null; + base.UninitializeForContextCore(context); + } + + /// + protected override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + state.RemoveFromIndex(args.NewStartingIndex); + break; + case NotifyCollectionChangedAction.Replace: + state.RemoveFromIndex(args.NewStartingIndex); + + // We must recycle the element to ensure that it gets the correct context + state.RecycleElementAt(args.NewStartingIndex); + break; + case NotifyCollectionChangedAction.Move: + int minIndex = Math.Min(args.NewStartingIndex, args.OldStartingIndex); + int maxIndex = Math.Max(args.NewStartingIndex, args.OldStartingIndex); + state.RemoveRange(minIndex, maxIndex); + break; + case NotifyCollectionChangedAction.Remove: + state.RemoveFromIndex(args.OldStartingIndex); + break; + case NotifyCollectionChangedAction.Reset: + state.Clear(); + break; + } + + base.OnItemsChangedCore(context, source, args); + } + + /// + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + if (context.ItemCount == 0) + { + return new Size(availableSize.Width, 0); + } + + if ((context.RealizationRect.Width == 0) && (context.RealizationRect.Height == 0)) + { + return new Size(availableSize.Width, 0.0f); + } + + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + double availableWidth = availableSize.Width; + double availableHeight = availableSize.Height; + + (int columnCount, double columnWidth) = GetColumnInfo(availableWidth, MinItemWidth, MinColumnSpacing); + + if (columnWidth != state.ColumnWidth) + { + // The items will need to be remeasured + state.Clear(); + } + + state.ColumnWidth = columnWidth; + + // adjust for column spacing on all columns expect the first + double totalWidth = state.ColumnWidth + ((columnCount - 1) * (state.ColumnWidth + MinColumnSpacing)); + if (totalWidth > availableWidth) + { + columnCount--; + } + else if (double.IsInfinity(availableWidth)) + { + availableWidth = totalWidth; + } + + if (columnCount != state.NumberOfColumns) + { + // The items will not need to be remeasured, but they will need to go into new columns + state.ClearColumns(); + } + + if (MinRowSpacing != state.RowSpacing) + { + // If the RowSpacing changes the height of the rows will be different. + // The columns stores the height so we'll want to clear them out to + // get the proper height + state.ClearColumns(); + state.RowSpacing = MinRowSpacing; + } + + double[] columnHeights = new double[columnCount]; + int[] itemsPerColumn = new int[columnCount]; + HashSet deadColumns = new(); + + for (int i = 0; i < context.ItemCount; i++) + { + int columnIndex = GetColumnIndex(columnHeights); + + bool measured = false; + UniformStaggeredItem item = state.GetItemAt(i); + if (item.Height == 0) + { + // Item has not been measured yet. Get the element and store the values + item.Element = context.GetOrCreateElementAt(i); + item.Element.Measure(new Size((float)state.ColumnWidth, (float)availableHeight)); + item.Height = item.Element.DesiredSize.Height; + measured = true; + } + + double spacing = itemsPerColumn[columnIndex] > 0 ? MinRowSpacing : 0; + item.Top = columnHeights[columnIndex] + spacing; + double bottom = item.Top + item.Height; + columnHeights[columnIndex] = bottom; + itemsPerColumn[columnIndex]++; + state.AddItemToColumn(item, columnIndex); + + if (bottom < context.RealizationRect.Top) + { + // The bottom of the element is above the realization area + if (item.Element is not null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + } + else if (item.Top > context.RealizationRect.Bottom) + { + // The top of the element is below the realization area + if (item.Element is not null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + + deadColumns.Add(columnIndex); + } + else if (measured == false) + { + // We ALWAYS want to measure an item that will be in the bounds + item.Element = context.GetOrCreateElementAt(i); + item.Element.Measure(new Size((float)state.ColumnWidth, (float)availableHeight)); + if (item.Height != item.Element.DesiredSize.Height) + { + // this item changed size; we need to recalculate layout for everything after this + state.RemoveFromIndex(i + 1); + item.Height = item.Element.DesiredSize.Height; + columnHeights[columnIndex] = item.Top + item.Height; + } + } + + if (deadColumns.Count == columnCount) + { + break; + } + } + + double desiredHeight = state.GetHeight(); + + return new Size((float)availableWidth, (float)desiredHeight); + } + + /// + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + if ((context.RealizationRect.Width == 0) && (context.RealizationRect.Height == 0)) + { + return finalSize; + } + + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + // Cycle through each column and arrange the items that are within the realization bounds + for (int columnIndex = 0; columnIndex < state.NumberOfColumns; columnIndex++) + { + UniformStaggeredColumnLayout layout = state.GetColumnLayout(columnIndex); + for (int i = 0; i < layout.Count; i++) + { + UniformStaggeredItem item = layout[i]; + + double bottom = item.Top + item.Height; + if (bottom < context.RealizationRect.Top) + { + // element is above the realization bounds + continue; + } + + if (item.Top <= context.RealizationRect.Bottom) + { + double itemHorizontalOffset = (state.ColumnWidth * columnIndex) + (MinColumnSpacing * columnIndex); + + Rect bounds = new((float)itemHorizontalOffset, (float)item.Top, (float)state.ColumnWidth, (float)item.Height); + UIElement element = context.GetOrCreateElementAt(item.Index); + element.Arrange(bounds); + } + else + { + break; + } + } + } + + return finalSize; + } + + private static (int ColumnCount, double ColumnWidth) GetColumnInfo(double availableWidth, double minItemWidth, double minColumnSpacing) + { + // less than 2 item per row + if ((2 * minItemWidth) + minColumnSpacing > availableWidth) + { + return (1, availableWidth); + } + + int columnCount = (int)Math.Max(1, Math.Floor((availableWidth + minColumnSpacing) / (minItemWidth + minColumnSpacing))); + double columnWidthAddSpacing = (availableWidth + minColumnSpacing) / columnCount; + return (columnCount, columnWidthAddSpacing - minColumnSpacing); + } + + private static void OnMinItemWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UniformStaggeredLayout panel = (UniformStaggeredLayout)d; + panel.InvalidateMeasure(); + } + + private static void OnSpacingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UniformStaggeredLayout panel = (UniformStaggeredLayout)d; + panel.InvalidateMeasure(); + } + + private static int GetColumnIndex(double[] columnHeights) + { + int columnIndex = 0; + double height = columnHeights[0]; + for (int j = 1; j < columnHeights.Length; j++) + { + if (columnHeights[j] < height) + { + columnIndex = j; + height = columnHeights[j]; + } + } + + return columnIndex; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs new file mode 100644 index 00000000..20b3b52f --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs @@ -0,0 +1,188 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Snap.Hutao.Control.Layout; + +internal sealed class UniformStaggeredLayoutState +{ + private readonly List items = new(); + private readonly VirtualizingLayoutContext context; + private readonly Dictionary columnLayout = new(); + private double lastAverageHeight; + + public UniformStaggeredLayoutState(VirtualizingLayoutContext context) + { + this.context = context; + } + + public double ColumnWidth { get; internal set; } + + public int NumberOfColumns + { + get => columnLayout.Count; + } + + public double RowSpacing { get; internal set; } + + internal void AddItemToColumn(UniformStaggeredItem item, int columnIndex) + { + if (this.columnLayout.TryGetValue(columnIndex, out UniformStaggeredColumnLayout? columnLayout) == false) + { + columnLayout = new UniformStaggeredColumnLayout(); + this.columnLayout[columnIndex] = columnLayout; + } + + if (columnLayout.Contains(item) == false) + { + columnLayout.Add(item); + } + } + + internal UniformStaggeredItem GetItemAt(int index) + { + if (index < 0) + { + throw new IndexOutOfRangeException(); + } + + if (index <= (items.Count - 1)) + { + return items[index]; + } + else + { + UniformStaggeredItem item = new(index); + items.Add(item); + return item; + } + } + + internal UniformStaggeredColumnLayout GetColumnLayout(int columnIndex) + { + this.columnLayout.TryGetValue(columnIndex, out UniformStaggeredColumnLayout? columnLayout); + return columnLayout!; + } + + /// + /// Clear everything that has been calculated. + /// + internal void Clear() + { + columnLayout.Clear(); + items.Clear(); + } + + /// + /// Clear the layout columns so they will be recalculated. + /// + internal void ClearColumns() + { + columnLayout.Clear(); + } + + /// + /// Gets the estimated height of the layout. + /// + /// The estimated height of the layout. + /// + /// If all of the items have been calculated then the actual height will be returned. + /// If all of the items have not been calculated then an estimated height will be calculated based on the average height of the items. + /// + internal double GetHeight() + { + double desiredHeight = Enumerable.Max(columnLayout.Values, c => c.Height); + + int itemCount = Enumerable.Sum(columnLayout.Values, c => c.Count); + if (itemCount == context.ItemCount) + { + return desiredHeight; + } + + double averageHeight = 0; + foreach (KeyValuePair kvp in columnLayout) + { + averageHeight += kvp.Value.Height / kvp.Value.Count; + } + + averageHeight /= columnLayout.Count; + double estimatedHeight = (averageHeight * context.ItemCount) / columnLayout.Count; + if (estimatedHeight > desiredHeight) + { + desiredHeight = estimatedHeight; + } + + if (Math.Abs(desiredHeight - lastAverageHeight) < 5) + { + return lastAverageHeight; + } + + lastAverageHeight = desiredHeight; + return desiredHeight; + } + + internal void RecycleElementAt(int index) + { + UIElement element = context.GetOrCreateElementAt(index); + context.RecycleElement(element); + } + + 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); + + foreach (KeyValuePair kvp in columnLayout) + { + UniformStaggeredColumnLayout layout = kvp.Value; + for (int i = 0; i < layout.Count; i++) + { + if (layout[i].Index >= index) + { + numToRemove = layout.Count - i; + layout.RemoveRange(i, numToRemove); + break; + } + } + } + } + + internal void RemoveRange(int startIndex, int endIndex) + { + for (int i = startIndex; i <= endIndex; i++) + { + if (i > items.Count) + { + break; + } + + UniformStaggeredItem item = items[i]; + item.Height = 0; + item.Top = 0; + + // We must recycle all elements to ensure that it gets the correct context + RecycleElementAt(i); + } + + foreach ((int key, UniformStaggeredColumnLayout layout) in columnLayout) + { + for (int i = 0; i < layout.Count; i++) + { + if ((startIndex <= layout[i].Index) && (layout[i].Index <= endIndex)) + { + int numToRemove = layout.Count - i; + layout.RemoveRange(i, numToRemove); + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml index 33d02646..1510dc9e 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml @@ -12,6 +12,7 @@ xmlns:shc="using:Snap.Hutao.Control" xmlns:shcb="using:Snap.Hutao.Control.Behavior" xmlns:shci="using:Snap.Hutao.Control.Image" + xmlns:shcl="using:Snap.Hutao.Control.Layout" xmlns:shcm="using:Snap.Hutao.Control.Markup" xmlns:shvco="using:Snap.Hutao.View.Control" xmlns:shvcu="using:Snap.Hutao.ViewModel.Cultivation" @@ -149,10 +150,7 @@ - + - +