From 99e5625ca63810a9a81710772136244b66b9eed7 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Mon, 6 Apr 2020 17:34:56 -0400 Subject: [PATCH 1/7] Add failing test for cut not working with undo/redo --- .../TextBoxTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 6bb3d55799..557c3920cc 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -554,6 +554,28 @@ namespace Avalonia.Controls.UnitTests } } + + [Fact] + public void Cut_Allows_Undo() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "0123" + }; + target.SelectionStart = 1; + target.SelectionEnd = 3; + + RaiseKeyEvent(target, Key.X, KeyModifiers.Control); // cut + Assert.True(target.Text == "03"); + + RaiseKeyEvent(target, Key.Z, KeyModifiers.Control); // undo + Assert.True(target.Text == "0123"); + } + } + private static TestServices FocusServices => TestServices.MockThreadingInterface.With( focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice(), From d8f74b7e41737f0fe703f5f2f8e3d9d861f48d6f Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Mon, 6 Apr 2020 17:48:12 -0400 Subject: [PATCH 2/7] Cut now triggers an undo --- src/Avalonia.Controls/TextBox.cs | 2 ++ tests/Avalonia.Controls.UnitTests/TextBoxTests.cs | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 680d7e7d2b..02e34675fe 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -473,8 +473,10 @@ namespace Avalonia.Controls { if (!IsPasswordBox) { + _undoRedoHelper.Snapshot(); Copy(); DeleteSelection(); + _undoRedoHelper.Snapshot(); } handled = true; diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 557c3920cc..362ef53461 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -1,10 +1,12 @@ using System; using System.Reactive.Linq; +using System.Threading.Tasks; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Data; using Avalonia.Input; +using Avalonia.Input.Platform; using Avalonia.Media; using Avalonia.Platform; using Avalonia.UnitTests; @@ -567,6 +569,8 @@ namespace Avalonia.Controls.UnitTests }; target.SelectionStart = 1; target.SelectionEnd = 3; + AvaloniaLocator.CurrentMutable + .Bind().ToSingleton(); RaiseKeyEvent(target, Key.X, KeyModifiers.Control); // cut Assert.True(target.Text == "03"); @@ -638,5 +642,14 @@ namespace Avalonia.Controls.UnitTests set { _bar = value; RaisePropertyChanged(); } } } + + private class ClipboardStub : IClipboard // in order to get tests working that use the clipboard + { + public Task GetTextAsync() => Task.FromResult(""); + + public Task SetTextAsync(string text) => Task.CompletedTask; + + public Task ClearAsync() => Task.CompletedTask; + } } } From 3885a69ab156b42edaf409af4b1b9205cad9405d Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Mon, 6 Apr 2020 18:03:35 -0400 Subject: [PATCH 3/7] Add more failing tests for other keys --- .../TextBoxTests.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 362ef53461..d2f62cde04 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -556,25 +556,29 @@ namespace Avalonia.Controls.UnitTests } } - - [Fact] - public void Cut_Allows_Undo() + [Theory] + [InlineData(Key.X, KeyModifiers.Control)] + [InlineData(Key.Back, KeyModifiers.None)] + [InlineData(Key.Delete, KeyModifiers.None)] + [InlineData(Key.Tab, KeyModifiers.None)] + [InlineData(Key.Enter, KeyModifiers.None)] + public void Keys_Allow_Undo(Key key, KeyModifiers modifiers) { using (UnitTestApplication.Start(Services)) { var target = new TextBox { Template = CreateTemplate(), - Text = "0123" + Text = "0123", + AcceptsReturn = true, + AcceptsTab = true }; target.SelectionStart = 1; target.SelectionEnd = 3; AvaloniaLocator.CurrentMutable .Bind().ToSingleton(); - RaiseKeyEvent(target, Key.X, KeyModifiers.Control); // cut - Assert.True(target.Text == "03"); - + RaiseKeyEvent(target, key, modifiers); RaiseKeyEvent(target, Key.Z, KeyModifiers.Control); // undo Assert.True(target.Text == "0123"); } From 9463e278a545eeb9c213b086565c0da370dbbef1 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Mon, 6 Apr 2020 18:03:54 -0400 Subject: [PATCH 4/7] Fix undo not available for multiple key types --- src/Avalonia.Controls/TextBox.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 02e34675fe..06624c555f 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -602,6 +602,7 @@ namespace Avalonia.Controls break; case Key.Back: + _undoRedoHelper.Snapshot(); if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlBackspace(); @@ -625,11 +626,13 @@ namespace Avalonia.Controls CaretIndex -= removedCharacters; SelectionStart = SelectionEnd = CaretIndex; } + _undoRedoHelper.Snapshot(); handled = true; break; case Key.Delete: + _undoRedoHelper.Snapshot(); if (hasWholeWordModifiers && SelectionStart == SelectionEnd) { SetSelectionForControlDelete(); @@ -651,6 +654,7 @@ namespace Avalonia.Controls SetTextInternal(text.Substring(0, caretIndex) + text.Substring(caretIndex + removedCharacters)); } + _undoRedoHelper.Snapshot(); handled = true; break; @@ -658,7 +662,9 @@ namespace Avalonia.Controls case Key.Enter: if (AcceptsReturn) { + _undoRedoHelper.Snapshot(); HandleTextInput(NewLine); + _undoRedoHelper.Snapshot(); handled = true; } @@ -667,7 +673,9 @@ namespace Avalonia.Controls case Key.Tab: if (AcceptsTab) { + _undoRedoHelper.Snapshot(); HandleTextInput("\t"); + _undoRedoHelper.Snapshot(); handled = true; } else From 804d70e44868bc9de59cd61bf0403b482fd366ad Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 7 Apr 2020 14:59:54 +0200 Subject: [PATCH 5/7] Add failing test for #2680. --- .../Shapes/PathTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Shapes/PathTests.cs b/tests/Avalonia.Controls.UnitTests/Shapes/PathTests.cs index 05224c2495..5a9ca410e4 100644 --- a/tests/Avalonia.Controls.UnitTests/Shapes/PathTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Shapes/PathTests.cs @@ -1,4 +1,6 @@ using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Controls.UnitTests.Shapes @@ -12,5 +14,21 @@ namespace Avalonia.Controls.UnitTests.Shapes target.Measure(Size.Infinity); } + + [Fact] + public void Subscribes_To_Geometry_Changes() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var geometry = new EllipseGeometry { Rect = new Rect(0, 0, 10, 10) }; + var target = new Path { Data = geometry }; + + target.Measure(Size.Infinity); + Assert.True(target.IsMeasureValid); + + geometry.Rect = new Rect(0, 0, 20, 20); + + Assert.False(target.IsMeasureValid); + } } } From 608efb11403be129f73ca0871aa348df1ad99c30 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 7 Apr 2020 15:06:28 +0200 Subject: [PATCH 6/7] Invalidate path on geometry change. Fixes #2680. --- src/Avalonia.Controls/Shapes/Path.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/Avalonia.Controls/Shapes/Path.cs b/src/Avalonia.Controls/Shapes/Path.cs index 84c3ededa5..3fd84c0c7b 100644 --- a/src/Avalonia.Controls/Shapes/Path.cs +++ b/src/Avalonia.Controls/Shapes/Path.cs @@ -1,3 +1,5 @@ +using System; +using Avalonia.Data; using Avalonia.Media; namespace Avalonia.Controls.Shapes @@ -10,6 +12,7 @@ namespace Avalonia.Controls.Shapes static Path() { AffectsGeometry(DataProperty); + DataProperty.Changed.AddClassHandler((o, e) => o.DataChanged(e)); } public Geometry Data @@ -19,5 +22,26 @@ namespace Avalonia.Controls.Shapes } protected override Geometry CreateDefiningGeometry() => Data; + + private void DataChanged(AvaloniaPropertyChangedEventArgs e) + { + var oldGeometry = (Geometry)e.OldValue; + var newGeometry = (Geometry)e.NewValue; + + if (oldGeometry is object) + { + oldGeometry.Changed -= GeometryChanged; + } + + if (newGeometry is object) + { + newGeometry.Changed += GeometryChanged; + } + } + + private void GeometryChanged(object sender, EventArgs e) + { + InvalidateGeometry(); + } } } From faec3ae67f23e8f3d90cf1ac166c88b424b6215e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Apr 2020 09:03:32 +0200 Subject: [PATCH 7/7] Added NonVirtualizingStackLayout. Required fixing/implementing a bunch of infrastructure around non-virtualizing layouts. --- src/Avalonia.Layout/AttachedLayout.cs | 104 +++++- src/Avalonia.Layout/LayoutContextAdapter.cs | 45 +++ src/Avalonia.Layout/NonVirtualizingLayout.cs | 36 +- .../NonVirtualizingLayoutContext.cs | 17 + .../NonVirtualizingStackLayout.cs | 160 +++++++++ src/Avalonia.Layout/StackLayout.cs | 8 +- src/Avalonia.Layout/UniformGridLayout.cs | 8 +- .../VirtualLayoutContextAdapter.cs | 42 +++ src/Avalonia.Layout/VirtualizingLayout.cs | 36 +- .../VirtualizingLayoutContext.cs | 5 + .../NonVirtualizingStackLayoutTests.cs | 335 ++++++++++++++++++ 11 files changed, 728 insertions(+), 68 deletions(-) create mode 100644 src/Avalonia.Layout/LayoutContextAdapter.cs create mode 100644 src/Avalonia.Layout/NonVirtualizingStackLayout.cs create mode 100644 src/Avalonia.Layout/VirtualLayoutContextAdapter.cs create mode 100644 tests/Avalonia.Layout.UnitTests/NonVirtualizingStackLayoutTests.cs diff --git a/src/Avalonia.Layout/AttachedLayout.cs b/src/Avalonia.Layout/AttachedLayout.cs index 5622731a7c..d22566442a 100644 --- a/src/Avalonia.Layout/AttachedLayout.cs +++ b/src/Avalonia.Layout/AttachedLayout.cs @@ -46,7 +46,23 @@ namespace Avalonia.Layout /// to provide the behavior for /// this method in a derived class. /// - public abstract void InitializeForContext(LayoutContext context); + public void InitializeForContext(LayoutContext context) + { + if (this is VirtualizingLayout virtualizingLayout) + { + var virtualizingContext = GetVirtualizingLayoutContext(context); + virtualizingLayout.InitializeForContextCore(virtualizingContext); + } + else if (this is NonVirtualizingLayout nonVirtualizingLayout) + { + var nonVirtualizingContext = GetNonVirtualizingLayoutContext(context); + nonVirtualizingLayout.InitializeForContextCore(nonVirtualizingContext); + } + else + { + throw new NotSupportedException(); + } + } /// /// Removes any state the layout previously stored on the ILayoutable container. @@ -55,7 +71,23 @@ namespace Avalonia.Layout /// The context object that facilitates communication between the layout and its host /// container. /// - public abstract void UninitializeForContext(LayoutContext context); + public void UninitializeForContext(LayoutContext context) + { + if (this is VirtualizingLayout virtualizingLayout) + { + var virtualizingContext = GetVirtualizingLayoutContext(context); + virtualizingLayout.UninitializeForContextCore(virtualizingContext); + } + else if (this is NonVirtualizingLayout nonVirtualizingLayout) + { + var nonVirtualizingContext = GetNonVirtualizingLayoutContext(context); + nonVirtualizingLayout.UninitializeForContextCore(nonVirtualizingContext); + } + else + { + throw new NotSupportedException(); + } + } /// /// Suggests a DesiredSize for a container element. A container element that supports @@ -73,7 +105,23 @@ namespace Avalonia.Layout /// if scrolling or other resize behavior is possible in that particular container. /// /// - public abstract Size Measure(LayoutContext context, Size availableSize); + public Size Measure(LayoutContext context, Size availableSize) + { + if (this is VirtualizingLayout virtualizingLayout) + { + var virtualizingContext = GetVirtualizingLayoutContext(context); + return virtualizingLayout.MeasureOverride(virtualizingContext, availableSize); + } + else if (this is NonVirtualizingLayout nonVirtualizingLayout) + { + var nonVirtualizingContext = GetNonVirtualizingLayoutContext(context); + return nonVirtualizingLayout.MeasureOverride(nonVirtualizingContext, availableSize); + } + else + { + throw new NotSupportedException(); + } + } /// /// Positions child elements and determines a size for a container UIElement. Container @@ -88,7 +136,23 @@ namespace Avalonia.Layout /// The final size that the container computes for the child in layout. /// /// The actual size that is used after the element is arranged in layout. - public abstract Size Arrange(LayoutContext context, Size finalSize); + public Size Arrange(LayoutContext context, Size finalSize) + { + if (this is VirtualizingLayout virtualizingLayout) + { + var virtualizingContext = GetVirtualizingLayoutContext(context); + return virtualizingLayout.ArrangeOverride(virtualizingContext, finalSize); + } + else if (this is NonVirtualizingLayout nonVirtualizingLayout) + { + var nonVirtualizingContext = GetNonVirtualizingLayoutContext(context); + return nonVirtualizingLayout.ArrangeOverride(nonVirtualizingContext, finalSize); + } + else + { + throw new NotSupportedException(); + } + } /// /// Invalidates the measurement state (layout) for all ILayoutable containers that reference @@ -102,5 +166,37 @@ namespace Avalonia.Layout /// occurs asynchronously. /// protected void InvalidateArrange() => ArrangeInvalidated?.Invoke(this, EventArgs.Empty); + + private VirtualizingLayoutContext GetVirtualizingLayoutContext(LayoutContext context) + { + if (context is VirtualizingLayoutContext virtualizingContext) + { + return virtualizingContext; + } + else if (context is NonVirtualizingLayoutContext nonVirtualizingContext) + { + return nonVirtualizingContext.GetVirtualizingContextAdapter(); + } + else + { + throw new NotSupportedException(); + } + } + + private NonVirtualizingLayoutContext GetNonVirtualizingLayoutContext(LayoutContext context) + { + if (context is NonVirtualizingLayoutContext nonVirtualizingContext) + { + return nonVirtualizingContext; + } + else if (context is VirtualizingLayoutContext virtualizingContext) + { + return virtualizingContext.GetNonVirtualizingContextAdapter(); + } + else + { + throw new NotSupportedException(); + } + } } } diff --git a/src/Avalonia.Layout/LayoutContextAdapter.cs b/src/Avalonia.Layout/LayoutContextAdapter.cs new file mode 100644 index 0000000000..695866df94 --- /dev/null +++ b/src/Avalonia.Layout/LayoutContextAdapter.cs @@ -0,0 +1,45 @@ +// This source file is adapted from the WinUI project. +// (https://github.com/microsoft/microsoft-ui-xaml) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System; + +namespace Avalonia.Layout +{ + internal class LayoutContextAdapter : VirtualizingLayoutContext + { + private readonly NonVirtualizingLayoutContext _nonVirtualizingContext; + + public LayoutContextAdapter(NonVirtualizingLayoutContext nonVirtualizingContext) + { + _nonVirtualizingContext = nonVirtualizingContext; + } + + protected override object LayoutStateCore + { + get => _nonVirtualizingContext.LayoutState; + set => _nonVirtualizingContext.LayoutState = value; + } + + protected override Point LayoutOriginCore + { + get => default; + set + { + if (value != default) + { + throw new InvalidOperationException("LayoutOrigin must be at (0,0) when RealizationRect is infinite sized."); + } + } + } + + protected override Rect RealizationRectCore() => new Rect(Size.Infinity); + + protected override int ItemCountCore() => _nonVirtualizingContext.Children.Count; + protected override object GetItemAtCore(int index) => _nonVirtualizingContext.Children[index]; + protected override ILayoutable GetOrCreateElementAtCore(int index, ElementRealizationOptions options) => + _nonVirtualizingContext.Children[index]; + protected override void RecycleElementCore(ILayoutable element) { } + } +} diff --git a/src/Avalonia.Layout/NonVirtualizingLayout.cs b/src/Avalonia.Layout/NonVirtualizingLayout.cs index 5d27ba9199..fb6b0dd4c9 100644 --- a/src/Avalonia.Layout/NonVirtualizingLayout.cs +++ b/src/Avalonia.Layout/NonVirtualizingLayout.cs @@ -17,30 +17,6 @@ namespace Avalonia.Layout /// public abstract class NonVirtualizingLayout : AttachedLayout { - /// - public sealed override void InitializeForContext(LayoutContext context) - { - InitializeForContextCore((NonVirtualizingLayoutContext)context); - } - - /// - public sealed override void UninitializeForContext(LayoutContext context) - { - UninitializeForContextCore((NonVirtualizingLayoutContext)context); - } - - /// - public sealed override Size Measure(LayoutContext context, Size availableSize) - { - return MeasureOverride((NonVirtualizingLayoutContext)context, availableSize); - } - - /// - public sealed override Size Arrange(LayoutContext context, Size finalSize) - { - return ArrangeOverride((NonVirtualizingLayoutContext)context, finalSize); - } - /// /// When overridden in a derived class, initializes any per-container state the layout /// requires when it is attached to an ILayoutable container. @@ -49,7 +25,7 @@ namespace Avalonia.Layout /// The context object that facilitates communication between the layout and its host /// container. /// - protected virtual void InitializeForContextCore(LayoutContext context) + protected internal virtual void InitializeForContextCore(LayoutContext context) { } @@ -61,7 +37,7 @@ namespace Avalonia.Layout /// The context object that facilitates communication between the layout and its host /// container. /// - protected virtual void UninitializeForContextCore(LayoutContext context) + protected internal virtual void UninitializeForContextCore(LayoutContext context) { } @@ -83,7 +59,9 @@ namespace Avalonia.Layout /// of the allocated sizes for child objects or based on other considerations such as a /// fixed container size. /// - protected abstract Size MeasureOverride(NonVirtualizingLayoutContext context, Size availableSize); + protected internal abstract Size MeasureOverride( + NonVirtualizingLayoutContext context, + Size availableSize); /// /// When implemented in a derived class, provides the behavior for the "Arrange" pass of @@ -98,6 +76,8 @@ namespace Avalonia.Layout /// its children. /// /// The actual size that is used after the element is arranged in layout. - protected virtual Size ArrangeOverride(NonVirtualizingLayoutContext context, Size finalSize) => finalSize; + protected internal virtual Size ArrangeOverride( + NonVirtualizingLayoutContext context, + Size finalSize) => finalSize; } } diff --git a/src/Avalonia.Layout/NonVirtualizingLayoutContext.cs b/src/Avalonia.Layout/NonVirtualizingLayoutContext.cs index d3dec83e9b..cef551f32e 100644 --- a/src/Avalonia.Layout/NonVirtualizingLayoutContext.cs +++ b/src/Avalonia.Layout/NonVirtualizingLayoutContext.cs @@ -3,6 +3,8 @@ // // Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. +using System.Collections.Generic; + namespace Avalonia.Layout { /// @@ -10,5 +12,20 @@ namespace Avalonia.Layout /// public abstract class NonVirtualizingLayoutContext : LayoutContext { + private VirtualizingLayoutContext _contextAdapter; + + /// + /// Gets the collection of child controls from the container that provides the context. + /// + public IReadOnlyList Children => ChildrenCore; + + /// + /// Implements the behavior for getting the return value of in a + /// derived or custom . + /// + protected abstract IReadOnlyList ChildrenCore { get; } + + internal VirtualizingLayoutContext GetVirtualizingContextAdapter() => + _contextAdapter ?? (_contextAdapter = new LayoutContextAdapter(this)); } } diff --git a/src/Avalonia.Layout/NonVirtualizingStackLayout.cs b/src/Avalonia.Layout/NonVirtualizingStackLayout.cs new file mode 100644 index 0000000000..0b730315e1 --- /dev/null +++ b/src/Avalonia.Layout/NonVirtualizingStackLayout.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Avalonia.Data; + +namespace Avalonia.Layout +{ + public class NonVirtualizingStackLayout : NonVirtualizingLayout + { + /// + /// Defines the property. + /// + public static readonly StyledProperty OrientationProperty = + StackLayout.OrientationProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SpacingProperty = + StackLayout.SpacingProperty.AddOwner(); + + /// + /// Gets or sets the axis along which items are laid out. + /// + /// + /// One of the enumeration values that specifies the axis along which items are laid out. + /// The default is Vertical. + /// + public Orientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + /// + /// Gets or sets a uniform distance (in pixels) between stacked items. It is applied in the + /// direction of the StackLayout's Orientation. + /// + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + protected internal override Size MeasureOverride( + NonVirtualizingLayoutContext context, + Size availableSize) + { + var extentU = 0.0; + var extentV = 0.0; + var childCount = context.Children.Count; + var isVertical = Orientation == Orientation.Vertical; + var spacing = Spacing; + var constraint = isVertical ? + availableSize.WithHeight(double.PositiveInfinity) : + availableSize.WithWidth(double.PositiveInfinity); + + for (var i = 0; i < childCount; ++i) + { + var element = context.Children[i]; + + if (!element.IsVisible) + { + continue; + } + + element.Measure(constraint); + + if (isVertical) + { + extentU += element.DesiredSize.Height; + extentV = Math.Max(extentV, element.DesiredSize.Width); + } + else + { + extentU += element.DesiredSize.Width; + extentV = Math.Max(extentV, element.DesiredSize.Height); + } + + if (i < childCount - 1) + { + extentU += spacing; + } + } + + return isVertical ? new Size(extentV, extentU) : new Size(extentU, extentV); + } + + protected internal override Size ArrangeOverride( + NonVirtualizingLayoutContext context, + Size finalSize) + { + var u = 0.0; + var childCount = context.Children.Count; + var isVertical = Orientation == Orientation.Vertical; + var spacing = Spacing; + var bounds = new Rect(); + + for (var i = 0; i < childCount; ++i) + { + var element = context.Children[i]; + + if (!element.IsVisible) + { + continue; + } + + bounds = isVertical ? + LayoutVertical(element, u, finalSize) : + LayoutHorizontal(element, u, finalSize); + element.Arrange(bounds); + u = (isVertical ? bounds.Bottom : bounds.Right) + spacing; + } + + return new Size(bounds.Right, bounds.Bottom); + } + + private static Rect LayoutVertical(ILayoutable element, double y, Size constraint) + { + var x = 0.0; + var width = element.DesiredSize.Width; + + switch (element.HorizontalAlignment) + { + case HorizontalAlignment.Center: + x += (constraint.Width - element.DesiredSize.Width) / 2; + break; + case HorizontalAlignment.Right: + x += constraint.Width - element.DesiredSize.Width; + break; + case HorizontalAlignment.Stretch: + width = constraint.Width; + break; + } + + return new Rect(x, y, width, element.DesiredSize.Height); + } + + private static Rect LayoutHorizontal(ILayoutable element, double x, Size constraint) + { + var y = 0.0; + var height = element.DesiredSize.Height; + + switch (element.VerticalAlignment) + { + case VerticalAlignment.Center: + y += (constraint.Height - element.DesiredSize.Height) / 2; + break; + case VerticalAlignment.Bottom: + y += constraint.Height - element.DesiredSize.Height; + break; + case VerticalAlignment.Stretch: + height = constraint.Height; + break; + } + + return new Rect(x, y, element.DesiredSize.Width, height); + } + } +} diff --git a/src/Avalonia.Layout/StackLayout.cs b/src/Avalonia.Layout/StackLayout.cs index e8ad49e9b9..9b8eb4814e 100644 --- a/src/Avalonia.Layout/StackLayout.cs +++ b/src/Avalonia.Layout/StackLayout.cs @@ -234,7 +234,7 @@ namespace Avalonia.Layout return new FlowLayoutAnchorInfo { Index = anchorIndex, Offset = offset, }; } - protected override void InitializeForContextCore(VirtualizingLayoutContext context) + protected internal override void InitializeForContextCore(VirtualizingLayoutContext context) { var state = context.LayoutState; var stackState = state as StackLayoutState; @@ -254,13 +254,13 @@ namespace Avalonia.Layout stackState.InitializeForContext(context, this); } - protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + protected internal override void UninitializeForContextCore(VirtualizingLayoutContext context) { var stackState = (StackLayoutState)context.LayoutState; stackState.UninitializeForContext(context); } - protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + protected internal override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) { var desiredSize = GetFlowAlgorithm(context).Measure( availableSize, @@ -275,7 +275,7 @@ namespace Avalonia.Layout return new Size(desiredSize.Width, desiredSize.Height); } - protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + protected internal override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) { var value = GetFlowAlgorithm(context).Arrange( finalSize, diff --git a/src/Avalonia.Layout/UniformGridLayout.cs b/src/Avalonia.Layout/UniformGridLayout.cs index 54c3ccbb90..ee9cff4a01 100644 --- a/src/Avalonia.Layout/UniformGridLayout.cs +++ b/src/Avalonia.Layout/UniformGridLayout.cs @@ -392,7 +392,7 @@ namespace Avalonia.Layout { } - protected override void InitializeForContextCore(VirtualizingLayoutContext context) + protected internal override void InitializeForContextCore(VirtualizingLayoutContext context) { var state = context.LayoutState; var gridState = state as UniformGridLayoutState; @@ -412,13 +412,13 @@ namespace Avalonia.Layout gridState.InitializeForContext(context, this); } - protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + protected internal override void UninitializeForContextCore(VirtualizingLayoutContext context) { var gridState = (UniformGridLayoutState)context.LayoutState; gridState.UninitializeForContext(context); } - protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + protected internal override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) { // Set the width and height on the grid state. If the user already set them then use the preset. // If not, we have to measure the first element and get back a size which we're going to be using for the rest of the items. @@ -442,7 +442,7 @@ namespace Avalonia.Layout return new Size(desiredSize.Width, desiredSize.Height); } - protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + protected internal override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) { var value = GetFlowAlgorithm(context).Arrange( finalSize, diff --git a/src/Avalonia.Layout/VirtualLayoutContextAdapter.cs b/src/Avalonia.Layout/VirtualLayoutContextAdapter.cs new file mode 100644 index 0000000000..80ccee2114 --- /dev/null +++ b/src/Avalonia.Layout/VirtualLayoutContextAdapter.cs @@ -0,0 +1,42 @@ +using System.Collections; +using System.Collections.Generic; + +namespace Avalonia.Layout +{ + public class VirtualLayoutContextAdapter : NonVirtualizingLayoutContext + { + private readonly VirtualizingLayoutContext _virtualizingContext; + private ChildrenCollection _children; + + public VirtualLayoutContextAdapter(VirtualizingLayoutContext virtualizingContext) + { + _virtualizingContext = virtualizingContext; + } + + protected override object LayoutStateCore + { + get => _virtualizingContext.LayoutState; + set => _virtualizingContext.LayoutState = value; + } + + protected override IReadOnlyList ChildrenCore => + _children ?? (_children = new ChildrenCollection(_virtualizingContext)); + + private class ChildrenCollection : IReadOnlyList + { + private readonly VirtualizingLayoutContext _context; + public ChildrenCollection(VirtualizingLayoutContext context) => _context = context; + public ILayoutable this[int index] => _context.GetOrCreateElementAt(index); + public int Count => _context.ItemCount; + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < Count; ++i) + { + yield return this[i]; + } + } + } + } +} diff --git a/src/Avalonia.Layout/VirtualizingLayout.cs b/src/Avalonia.Layout/VirtualizingLayout.cs index 4c601175f3..15c7749dfe 100644 --- a/src/Avalonia.Layout/VirtualizingLayout.cs +++ b/src/Avalonia.Layout/VirtualizingLayout.cs @@ -19,30 +19,6 @@ namespace Avalonia.Layout /// public abstract class VirtualizingLayout : AttachedLayout { - /// - public sealed override void InitializeForContext(LayoutContext context) - { - InitializeForContextCore((VirtualizingLayoutContext)context); - } - - /// - public sealed override void UninitializeForContext(LayoutContext context) - { - UninitializeForContextCore((VirtualizingLayoutContext)context); - } - - /// - public sealed override Size Measure(LayoutContext context, Size availableSize) - { - return MeasureOverride((VirtualizingLayoutContext)context, availableSize); - } - - /// - public sealed override Size Arrange(LayoutContext context, Size finalSize) - { - return ArrangeOverride((VirtualizingLayoutContext)context, finalSize); - } - /// /// Notifies the layout when the data collection assigned to the container element (Items) /// has changed. @@ -70,7 +46,7 @@ namespace Avalonia.Layout /// The context object that facilitates communication between the layout and its host /// container. /// - protected virtual void InitializeForContextCore(VirtualizingLayoutContext context) + protected internal virtual void InitializeForContextCore(VirtualizingLayoutContext context) { } @@ -82,7 +58,7 @@ namespace Avalonia.Layout /// The context object that facilitates communication between the layout and its host /// container. /// - protected virtual void UninitializeForContextCore(VirtualizingLayoutContext context) + protected internal virtual void UninitializeForContextCore(VirtualizingLayoutContext context) { } @@ -104,7 +80,9 @@ namespace Avalonia.Layout /// of the allocated sizes for child objects or based on other considerations such as a /// fixed container size. /// - protected abstract Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize); + protected internal abstract Size MeasureOverride( + VirtualizingLayoutContext context, + Size availableSize); /// /// When implemented in a derived class, provides the behavior for the "Arrange" pass of @@ -119,7 +97,9 @@ namespace Avalonia.Layout /// its children. /// /// The actual size that is used after the element is arranged in layout. - protected virtual Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) => finalSize; + protected internal virtual Size ArrangeOverride( + VirtualizingLayoutContext context, + Size finalSize) => finalSize; /// /// Notifies the layout when the data collection assigned to the container element (Items) diff --git a/src/Avalonia.Layout/VirtualizingLayoutContext.cs b/src/Avalonia.Layout/VirtualizingLayoutContext.cs index 980daec2eb..079b91a90f 100644 --- a/src/Avalonia.Layout/VirtualizingLayoutContext.cs +++ b/src/Avalonia.Layout/VirtualizingLayoutContext.cs @@ -43,6 +43,8 @@ namespace Avalonia.Layout /// public abstract class VirtualizingLayoutContext : LayoutContext { + private NonVirtualizingLayoutContext _contextAdapter; + /// /// Gets the number of items in the data. /// @@ -186,5 +188,8 @@ namespace Avalonia.Layout /// /// The element to clear. protected abstract void RecycleElementCore(ILayoutable element); + + internal NonVirtualizingLayoutContext GetNonVirtualizingContextAdapter() => + _contextAdapter ?? (_contextAdapter = new VirtualLayoutContextAdapter(this)); } } diff --git a/tests/Avalonia.Layout.UnitTests/NonVirtualizingStackLayoutTests.cs b/tests/Avalonia.Layout.UnitTests/NonVirtualizingStackLayoutTests.cs new file mode 100644 index 0000000000..a7b378c322 --- /dev/null +++ b/tests/Avalonia.Layout.UnitTests/NonVirtualizingStackLayoutTests.cs @@ -0,0 +1,335 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Xunit; + +namespace Avalonia.Layout.UnitTests +{ + public class NonVirtualizingStackLayoutTests + { + [Fact] + public void Lays_Out_Children_Vertically() + { + var target = new NonVirtualizingStackLayout { Orientation = Orientation.Vertical }; + var context = CreateContext(new[] + { + new Border { Height = 20, Width = 120 }, + new Border { Height = 30 }, + new Border { Height = 50 }, + }); + + var desiredSize = target.Measure(context, Size.Infinity); + var arrangeSize = target.Arrange(context, desiredSize); + + Assert.Equal(new Size(120, 100), desiredSize); + Assert.Equal(new Size(120, 100), arrangeSize); + Assert.Equal(new Rect(0, 0, 120, 20), context.Children[0].Bounds); + Assert.Equal(new Rect(0, 20, 120, 30), context.Children[1].Bounds); + Assert.Equal(new Rect(0, 50, 120, 50), context.Children[2].Bounds); + } + + [Fact] + public void Lays_Out_Children_Horizontally() + { + var target = new NonVirtualizingStackLayout { Orientation = Orientation.Horizontal }; + var context = CreateContext(new[] + { + new Border { Width = 20, Height = 120 }, + new Border { Width = 30 }, + new Border { Width = 50 }, + }); + + var desiredSize = target.Measure(context, Size.Infinity); + var arrangeSize = target.Arrange(context, desiredSize); + + Assert.Equal(new Size(100, 120), desiredSize); + Assert.Equal(new Size(100, 120), arrangeSize); + Assert.Equal(new Rect(0, 0, 20, 120), context.Children[0].Bounds); + Assert.Equal(new Rect(20, 0, 30, 120), context.Children[1].Bounds); + Assert.Equal(new Rect(50, 0, 50, 120), context.Children[2].Bounds); + } + + [Fact] + public void Lays_Out_Children_Vertically_With_Spacing() + { + var target = new NonVirtualizingStackLayout + { + Orientation = Orientation.Vertical, + Spacing = 10, + }; + + var context = CreateContext(new[] + { + new Border { Height = 20, Width = 120 }, + new Border { Height = 30 }, + new Border { Height = 50 }, + }); + + var desiredSize = target.Measure(context, Size.Infinity); + var arrangeSize = target.Arrange(context, desiredSize); + + Assert.Equal(new Size(120, 120), desiredSize); + Assert.Equal(new Size(120, 120), arrangeSize); + Assert.Equal(new Rect(0, 0, 120, 20), context.Children[0].Bounds); + Assert.Equal(new Rect(0, 30, 120, 30), context.Children[1].Bounds); + Assert.Equal(new Rect(0, 70, 120, 50), context.Children[2].Bounds); + } + + [Fact] + public void Lays_Out_Children_Horizontally_With_Spacing() + { + var target = new NonVirtualizingStackLayout + { + Orientation = Orientation.Horizontal, + Spacing = 10, + }; + + var context = CreateContext(new[] + { + new Border { Width = 20, Height = 120 }, + new Border { Width = 30 }, + new Border { Width = 50 }, + }); + + var desiredSize = target.Measure(context, Size.Infinity); + var arrangeSize = target.Arrange(context, desiredSize); + + Assert.Equal(new Size(120, 120), desiredSize); + Assert.Equal(new Size(120, 120), arrangeSize); + Assert.Equal(new Rect(0, 0, 20, 120), context.Children[0].Bounds); + Assert.Equal(new Rect(30, 0, 30, 120), context.Children[1].Bounds); + Assert.Equal(new Rect(70, 0, 50, 120), context.Children[2].Bounds); + } + + [Fact] + public void Arranges_Vertical_Children_With_Correct_Bounds() + { + var target = new NonVirtualizingStackLayout + { + Orientation = Orientation.Vertical + }; + + var context = CreateContext(new[] + { + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Left, + MeasureSize = new Size(50, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Left, + MeasureSize = new Size(150, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Center, + MeasureSize = new Size(50, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Center, + MeasureSize = new Size(150, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Right, + MeasureSize = new Size(50, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Right, + MeasureSize = new Size(150, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Stretch, + MeasureSize = new Size(50, 10), + }, + new TestControl + { + HorizontalAlignment = HorizontalAlignment.Stretch, + MeasureSize = new Size(150, 10), + }, + }); + + var desiredSize = target.Measure(context, new Size(100, 150)); + Assert.Equal(new Size(100, 80), desiredSize); + + target.Arrange(context, desiredSize); + + var bounds = context.Children.Select(x => x.Bounds).ToArray(); + + Assert.Equal( + new[] + { + new Rect(0, 0, 50, 10), + new Rect(0, 10, 100, 10), + new Rect(25, 20, 50, 10), + new Rect(0, 30, 100, 10), + new Rect(50, 40, 50, 10), + new Rect(0, 50, 100, 10), + new Rect(0, 60, 100, 10), + new Rect(0, 70, 100, 10), + + }, bounds); + } + + [Fact] + public void Arranges_Horizontal_Children_With_Correct_Bounds() + { + var target = new NonVirtualizingStackLayout + { + Orientation = Orientation.Horizontal + }; + + var context = CreateContext(new[] + { + new TestControl + { + VerticalAlignment = VerticalAlignment.Top, + MeasureSize = new Size(10, 50), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Top, + MeasureSize = new Size(10, 150), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Center, + MeasureSize = new Size(10, 50), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Center, + MeasureSize = new Size(10, 150), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Bottom, + MeasureSize = new Size(10, 50), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Bottom, + MeasureSize = new Size(10, 150), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Stretch, + MeasureSize = new Size(10, 50), + }, + new TestControl + { + VerticalAlignment = VerticalAlignment.Stretch, + MeasureSize = new Size(10, 150), + }, + }); + + var desiredSize = target.Measure(context, new Size(150, 100)); + Assert.Equal(new Size(80, 100), desiredSize); + + target.Arrange(context, desiredSize); + + var bounds = context.Children.Select(x => x.Bounds).ToArray(); + + Assert.Equal( + new[] + { + new Rect(0, 0, 10, 50), + new Rect(10, 0, 10, 100), + new Rect(20, 25, 10, 50), + new Rect(30, 0, 10, 100), + new Rect(40, 50, 10, 50), + new Rect(50, 0, 10, 100), + new Rect(60, 0, 10, 100), + new Rect(70, 0, 10, 100), + }, bounds); + } + + [Theory] + [InlineData(Orientation.Horizontal)] + [InlineData(Orientation.Vertical)] + public void Spacing_Not_Added_For_Invisible_Children(Orientation orientation) + { + var targetThreeChildrenOneInvisble = new NonVirtualizingStackLayout + { + Orientation = orientation, + Spacing = 40, + }; + + var contextThreeChildrenOneInvisble = CreateContext(new[] + { + new StackPanel { Width = 10, Height= 10, IsVisible = false }, + new StackPanel { Width = 10, Height= 10 }, + new StackPanel { Width = 10, Height= 10 }, + }); + + var targetTwoChildrenNoneInvisible = new NonVirtualizingStackLayout + { + Spacing = 40, + Orientation = orientation, + }; + + var contextTwoChildrenNoneInvisible = CreateContext(new[] + { + new StackPanel { Width = 10, Height = 10 }, + new StackPanel { Width = 10, Height = 10 } + }); + + var desiredSize1 = targetThreeChildrenOneInvisble.Measure(contextThreeChildrenOneInvisble, Size.Infinity); + var desiredSize2 = targetTwoChildrenNoneInvisible.Measure(contextTwoChildrenNoneInvisible, Size.Infinity); + + Assert.Equal(desiredSize2, desiredSize1); + } + + [Theory] + [InlineData(Orientation.Horizontal)] + [InlineData(Orientation.Vertical)] + public void Only_Arrange_Visible_Children(Orientation orientation) + { + var hiddenPanel = new Panel { Width = 10, Height = 10, IsVisible = false }; + var panel = new Panel { Width = 10, Height = 10 }; + + var target = new NonVirtualizingStackLayout + { + Spacing = 40, + Orientation = orientation, + }; + + var context = CreateContext(new[] + { + hiddenPanel, + panel + }); + + var desiredSize = target.Measure(context, Size.Infinity); + var arrangeSize = target.Arrange(context, desiredSize); + Assert.Equal(new Size(10, 10), arrangeSize); + } + + private NonVirtualizingLayoutContext CreateContext(Control[] children) + { + return new TestLayoutContext(children); + } + + private class TestLayoutContext : NonVirtualizingLayoutContext + { + public TestLayoutContext(Control[] children) => ChildrenCore = children; + protected override IReadOnlyList ChildrenCore { get; } + } + + private class TestControl : Control + { + public Size MeasureConstraint { get; private set; } + public Size MeasureSize { get; set; } + + protected override Size MeasureOverride(Size availableSize) + { + MeasureConstraint = availableSize; + return MeasureSize; + } + } + } +}