diff --git a/Avalonia.sln b/Avalonia.sln index 3c2e8fc83d..7a9c1f04f5 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -157,6 +157,7 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.DesignerSupport.TestApp", "tests\Avalonia.DesignerSupport.TestApp\Avalonia.DesignerSupport.TestApp.csproj", "{F1381F98-4D24-409A-A6C5-1C5B1E08BB08}" EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Avalonia.RenderTests", "tests\Avalonia.RenderTests\Avalonia.RenderTests.shproj", "{48840EDD-24BF-495D-911E-2EB12AE75D3B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualizationTest", "samples\VirtualizationTest\VirtualizationTest.csproj", "{FBCAF3D0-2808-4934-8E96-3F607594517B}" EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution @@ -1909,6 +1910,38 @@ Global {F1381F98-4D24-409A-A6C5-1C5B1E08BB08}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {F1381F98-4D24-409A-A6C5-1C5B1E08BB08}.Release|x86.ActiveCfg = Release|Any CPU {F1381F98-4D24-409A-A6C5-1C5B1E08BB08}.Release|x86.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|Any CPU.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|Any CPU.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|iPhone.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|iPhone.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|iPhoneSimulator.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|x86.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Ad-Hoc|x86.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|Any CPU.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|Any CPU.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|iPhone.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|iPhone.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|iPhoneSimulator.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|iPhoneSimulator.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|x86.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.AppStore|x86.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|iPhone.Build.0 = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|x86.ActiveCfg = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Debug|x86.Build.0 = Debug|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|Any CPU.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|iPhone.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|iPhone.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|x86.ActiveCfg = Release|Any CPU + {FBCAF3D0-2808-4934-8E96-3F607594517B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1960,5 +1993,6 @@ Global {52F55355-D120-42AC-8116-8410A7D602FA} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {F1381F98-4D24-409A-A6C5-1C5B1E08BB08} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {48840EDD-24BF-495D-911E-2EB12AE75D3B} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} + {FBCAF3D0-2808-4934-8E96-3F607594517B} = {9B9E3891-2366-4253-A952-D08BCEB71098} EndGlobalSection EndGlobal diff --git a/samples/ControlCatalog/Pages/MenuPage.xaml b/samples/ControlCatalog/Pages/MenuPage.xaml index ca28fadea0..98171f29d6 100644 --- a/samples/ControlCatalog/Pages/MenuPage.xaml +++ b/samples/ControlCatalog/Pages/MenuPage.xaml @@ -10,6 +10,7 @@ + diff --git a/samples/VirtualizationTest/App.config b/samples/VirtualizationTest/App.config new file mode 100644 index 0000000000..88fa4027bd --- /dev/null +++ b/samples/VirtualizationTest/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/VirtualizationTest/App.xaml b/samples/VirtualizationTest/App.xaml new file mode 100644 index 0000000000..d9630eef58 --- /dev/null +++ b/samples/VirtualizationTest/App.xaml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/VirtualizationTest/App.xaml.cs b/samples/VirtualizationTest/App.xaml.cs new file mode 100644 index 0000000000..14ab5b3f84 --- /dev/null +++ b/samples/VirtualizationTest/App.xaml.cs @@ -0,0 +1,16 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia; +using Avalonia.Markup.Xaml; + +namespace VirtualizationTest +{ + public class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/VirtualizationTest/MainWindow.xaml b/samples/VirtualizationTest/MainWindow.xaml new file mode 100644 index 0000000000..55bd729fec --- /dev/null +++ b/samples/VirtualizationTest/MainWindow.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/VirtualizationTest/MainWindow.xaml.cs b/samples/VirtualizationTest/MainWindow.xaml.cs new file mode 100644 index 0000000000..952383dffb --- /dev/null +++ b/samples/VirtualizationTest/MainWindow.xaml.cs @@ -0,0 +1,25 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using VirtualizationTest.ViewModels; + +namespace VirtualizationTest +{ + public class MainWindow : Window + { + public MainWindow() + { + this.InitializeComponent(); + this.AttachDevTools(); + DataContext = new MainWindowViewModel(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/VirtualizationTest/Program.cs b/samples/VirtualizationTest/Program.cs new file mode 100644 index 0000000000..8958605a3b --- /dev/null +++ b/samples/VirtualizationTest/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Logging.Serilog; +using Serilog; + +namespace VirtualizationTest +{ + class Program + { + static void Main(string[] args) + { + InitializeLogging(); + + AppBuilder.Configure() + .UseWin32() + .UseDirect2D1() + .Start(); + } + + private static void InitializeLogging() + { +#if DEBUG + SerilogLogger.Initialize(new LoggerConfiguration() + .MinimumLevel.Warning() + .WriteTo.Trace(outputTemplate: "{Area}: {Message}") + .CreateLogger()); +#endif + } + } +} diff --git a/samples/VirtualizationTest/Properties/AssemblyInfo.cs b/samples/VirtualizationTest/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..e6af64555b --- /dev/null +++ b/samples/VirtualizationTest/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("VirtualizationTest")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("VirtualizationTest")] +[assembly: AssemblyCopyright("Copyright © 2016")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("fbcaf3d0-2808-4934-8e96-3f607594517b")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/samples/VirtualizationTest/ViewModels/ItemViewModel.cs b/samples/VirtualizationTest/ViewModels/ItemViewModel.cs new file mode 100644 index 0000000000..75777012c1 --- /dev/null +++ b/samples/VirtualizationTest/ViewModels/ItemViewModel.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using ReactiveUI; + +namespace VirtualizationTest.ViewModels +{ + internal class ItemViewModel : ReactiveObject + { + private string _prefix; + private int _index; + + public ItemViewModel(int index, string prefix = "Item") + { + _prefix = prefix; + _index = index; + } + + public string Header => $"{_prefix} {_index}"; + } +} diff --git a/samples/VirtualizationTest/ViewModels/MainWindowViewModel.cs b/samples/VirtualizationTest/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..86869eb46a --- /dev/null +++ b/samples/VirtualizationTest/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,141 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Controls; +using ReactiveUI; + +namespace VirtualizationTest.ViewModels +{ + internal class MainWindowViewModel : ReactiveObject + { + private int _itemCount = 200; + private string _newItemString = "New Item"; + private int _newItemIndex; + private IReactiveList _items; + private string _prefix = "Item"; + private Orientation _orientation; + private ItemVirtualizationMode _virtualizationMode = ItemVirtualizationMode.Simple; + + public MainWindowViewModel() + { + this.WhenAnyValue(x => x.ItemCount).Subscribe(ResizeItems); + RecreateCommand = ReactiveCommand.Create(); + RecreateCommand.Subscribe(_ => Recreate()); + + AddItemCommand = ReactiveCommand.Create(); + AddItemCommand.Subscribe(_ => AddItem()); + + RemoveItemCommand = ReactiveCommand.Create(); + RemoveItemCommand.Subscribe(_ => Remove()); + + SelectFirstCommand = ReactiveCommand.Create(); + SelectFirstCommand.Subscribe(_ => SelectItem(0)); + + SelectLastCommand = ReactiveCommand.Create(); + SelectLastCommand.Subscribe(_ => SelectItem(Items.Count - 1)); + } + + public string NewItemString + { + get { return _newItemString; } + set { this.RaiseAndSetIfChanged(ref _newItemString, value); } + } + + public int ItemCount + { + get { return _itemCount; } + set { this.RaiseAndSetIfChanged(ref _itemCount, value); } + } + + public AvaloniaList SelectedItems { get; } + = new AvaloniaList(); + + public IReactiveList Items + { + get { return _items; } + private set { this.RaiseAndSetIfChanged(ref _items, value); } + } + + public Orientation Orientation + { + get { return _orientation; } + set { this.RaiseAndSetIfChanged(ref _orientation, value); } + } + + public IEnumerable Orientations => + Enum.GetValues(typeof(Orientation)).Cast(); + + public ItemVirtualizationMode VirtualizationMode + { + get { return _virtualizationMode; } + set { this.RaiseAndSetIfChanged(ref _virtualizationMode, value); } + } + + public IEnumerable VirtualizationModes => + Enum.GetValues(typeof(ItemVirtualizationMode)).Cast(); + + public ReactiveCommand AddItemCommand { get; private set; } + public ReactiveCommand RecreateCommand { get; private set; } + public ReactiveCommand RemoveItemCommand { get; private set; } + public ReactiveCommand SelectFirstCommand { get; private set; } + public ReactiveCommand SelectLastCommand { get; private set; } + + private void ResizeItems(int count) + { + if (Items == null) + { + var items = Enumerable.Range(0, count) + .Select(x => new ItemViewModel(x)); + Items = new ReactiveList(items); + } + else if (count > Items.Count) + { + var items = Enumerable.Range(Items.Count, count - Items.Count) + .Select(x => new ItemViewModel(x)); + Items.AddRange(items); + } + else if (count < Items.Count) + { + Items.RemoveRange(count, Items.Count - count); + } + } + + private void AddItem() + { + var index = Items.Count; + + if (SelectedItems.Count > 0) + { + index = Items.IndexOf(SelectedItems[0]); + } + + Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString)); + } + + private void Remove() + { + if (SelectedItems.Count > 0) + { + Items.RemoveAll(SelectedItems); + } + } + + private void Recreate() + { + _prefix = _prefix == "Item" ? "Recreated" : "Item"; + var items = Enumerable.Range(0, _itemCount) + .Select(x => new ItemViewModel(x, _prefix)); + Items = new ReactiveList(items); + } + + private void SelectItem(int index) + { + SelectedItems.Clear(); + SelectedItems.Add(Items[index]); + } + } +} diff --git a/samples/VirtualizationTest/VirtualizationTest.csproj b/samples/VirtualizationTest/VirtualizationTest.csproj new file mode 100644 index 0000000000..ed7a340234 --- /dev/null +++ b/samples/VirtualizationTest/VirtualizationTest.csproj @@ -0,0 +1,180 @@ + + + + + Debug + AnyCPU + {FBCAF3D0-2808-4934-8E96-3F607594517B} + WinExe + Properties + VirtualizationTest + VirtualizationTest + v4.5.2 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + ..\..\packages\Serilog.1.5.14\lib\net45\Serilog.dll + True + + + ..\..\packages\Serilog.1.5.14\lib\net45\Serilog.FullNetFx.dll + True + + + ..\..\packages\Splat.1.6.2\lib\Net45\Splat.dll + True + + + + + ..\..\packages\Rx-Core.2.2.5\lib\net45\System.Reactive.Core.dll + True + + + ..\..\packages\Rx-Interfaces.2.2.5\lib\net45\System.Reactive.Interfaces.dll + True + + + ..\..\packages\Rx-Linq.2.2.5\lib\net45\System.Reactive.Linq.dll + True + + + ..\..\packages\Rx-PlatformServices.2.2.5\lib\net45\System.Reactive.PlatformServices.dll + True + + + + + + + + + + + App.xaml + + + MainWindow.xaml + + + + + + + + + + + + + {d211e587-d8bc-45b9-95a4-f297c8fa5200} + Avalonia.Animation + + + {b09b78d8-9b26-48b0-9149-d64a2f120f3f} + Avalonia.Base + + + {d2221c82-4a25-4583-9b43-d791e3f6820c} + Avalonia.Controls + + + {799a7bb5-3c2c-48b6-85a7-406a12c420da} + Avalonia.DesignerSupport + + + {7062ae20-5dcc-4442-9645-8195bdece63e} + Avalonia.Diagnostics + + + {62024b2d-53eb-4638-b26b-85eeaa54866e} + Avalonia.Input + + + {6b0ed19d-a08b-461c-a9d9-a9ee40b0c06b} + Avalonia.Interactivity + + + {42472427-4774-4c81-8aff-9f27b8e31721} + Avalonia.Layout + + + {B61B66A3-B82D-4875-8001-89D3394FE0C9} + Avalonia.Logging.Serilog + + + {6417b24e-49c2-4985-8db2-3ab9d898ec91} + Avalonia.ReactiveUI + + + {eb582467-6abb-43a1-b052-e981ba910e3a} + Avalonia.SceneGraph + + + {f1baa01a-f176-4c6a-b39d-5b40bb1b148f} + Avalonia.Styling + + + {3e10a5fa-e8da-48b1-ad44-6a5b6cb7750f} + Avalonia.Themes.Default + + + {3e53a01a-b331-47f3-b828-4a5717e77a24} + Avalonia.Markup.Xaml + + + {6417e941-21bc-467b-a771-0de389353ce6} + Avalonia.Markup + + + {3e908f67-5543-4879-a1dc-08eace79b3cd} + Avalonia.Direct2D1 + + + {811a76cf-1cf6-440f-963b-bbe31bd72a82} + Avalonia.Win32 + + + + + Designer + + + + + Designer + + + + + \ No newline at end of file diff --git a/samples/VirtualizationTest/VirtualizationTest.v2.ncrunchproject b/samples/VirtualizationTest/VirtualizationTest.v2.ncrunchproject new file mode 100644 index 0000000000..f744eecae0 --- /dev/null +++ b/samples/VirtualizationTest/VirtualizationTest.v2.ncrunchproject @@ -0,0 +1,26 @@ + + true + 1000 + false + false + false + true + false + false + true + false + false + true + true + false + true + true + true + 60000 + + + + AutoDetect + STA + x86 + \ No newline at end of file diff --git a/samples/VirtualizationTest/packages.config b/samples/VirtualizationTest/packages.config new file mode 100644 index 0000000000..782e7fbf2c --- /dev/null +++ b/samples/VirtualizationTest/packages.config @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/samples/XamlTestApplicationPcl/TestScrollable.cs b/samples/XamlTestApplicationPcl/TestScrollable.cs index 9ec70a4eb1..9d4c1d9b29 100644 --- a/samples/XamlTestApplicationPcl/TestScrollable.cs +++ b/samples/XamlTestApplicationPcl/TestScrollable.cs @@ -2,11 +2,13 @@ using System; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Input; using Avalonia.Media; +using Avalonia.VisualTree; namespace XamlTestApplication { - public class TestScrollable : Control, IScrollable + public class TestScrollable : Control, ILogicalScrollable { private int itemCount = 100; private Size _extent; @@ -14,6 +16,7 @@ namespace XamlTestApplication private Size _viewport; private Size _lineSize; + public bool IsLogicalScrollEnabled => true; public Action InvalidateScroll { get; set; } Size IScrollable.Extent @@ -53,6 +56,36 @@ namespace XamlTestApplication } } + public override void Render(DrawingContext context) + { + var y = 0.0; + + for (var i = (int)_offset.Y; i < itemCount; ++i) + { + using (var line = new FormattedText( + "Item " + (i + 1), + TextBlock.GetFontFamily(this), + TextBlock.GetFontSize(this), + TextBlock.GetFontStyle(this), + TextAlignment.Left, + TextBlock.GetFontWeight(this))) + { + context.DrawText(Brushes.Black, new Point(-_offset.X, y), line); + y += _lineSize.Height; + } + } + } + + public bool BringIntoView(IControl target, Rect targetRect) + { + throw new NotImplementedException(); + } + + public IControl GetControlInDirection(NavigationDirection direction, IControl from) + { + throw new NotImplementedException(); + } + protected override Size MeasureOverride(Size availableSize) { using (var line = new FormattedText( @@ -76,25 +109,5 @@ namespace XamlTestApplication InvalidateScroll?.Invoke(); return finalSize; } - - public override void Render(DrawingContext context) - { - var y = 0.0; - - for (var i = (int)_offset.Y; i < itemCount; ++i) - { - using (var line = new FormattedText( - "Item " + (i + 1), - TextBlock.GetFontFamily(this), - TextBlock.GetFontSize(this), - TextBlock.GetFontStyle(this), - TextAlignment.Left, - TextBlock.GetFontWeight(this))) - { - context.DrawText(Brushes.Black, new Point(-_offset.X, y), line); - y += _lineSize.Height; - } - } - } } } \ No newline at end of file diff --git a/samples/XamlTestApplicationPcl/Views/MainWindow.cs b/samples/XamlTestApplicationPcl/Views/MainWindow.cs index 2c0012d65f..ed5cb8f73a 100644 --- a/samples/XamlTestApplicationPcl/Views/MainWindow.cs +++ b/samples/XamlTestApplicationPcl/Views/MainWindow.cs @@ -1,6 +1,8 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System.Collections.Generic; +using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Diagnostics; @@ -25,6 +27,9 @@ namespace XamlTestApplication.Views AvaloniaXamlLoader.Load(this); _exitMenu = this.FindControl("exitMenu"); _exitMenu.Click += (s, e) => Application.Current.Exit(); + + var virtualList = this.FindControl("virtualList"); + virtualList.Items = Enumerable.Range(0, 200).Select(x => $"Item {x}").ToList(); } } } \ No newline at end of file diff --git a/samples/XamlTestApplicationPcl/Views/MainWindow.xaml b/samples/XamlTestApplicationPcl/Views/MainWindow.xaml index b93072d2d5..75cdfd00a4 100644 --- a/samples/XamlTestApplicationPcl/Views/MainWindow.xaml +++ b/samples/XamlTestApplicationPcl/Views/MainWindow.xaml @@ -24,6 +24,16 @@ + + + + + + + + + + diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index dd047db279..b0db89f7ea 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -26,7 +26,7 @@ namespace Avalonia /// /// The parent object that inherited values are inherited from. /// - private AvaloniaObject _inheritanceParent; + private IAvaloniaObject _inheritanceParent; /// /// The set values/bindings on this object. @@ -120,7 +120,7 @@ namespace Avalonia /// /// The inheritance parent. /// - protected AvaloniaObject InheritanceParent + protected IAvaloniaObject InheritanceParent { get { diff --git a/src/Avalonia.Base/Collections/AvaloniaList.cs b/src/Avalonia.Base/Collections/AvaloniaList.cs index e893fcb75b..65b559482d 100644 --- a/src/Avalonia.Base/Collections/AvaloniaList.cs +++ b/src/Avalonia.Base/Collections/AvaloniaList.cs @@ -319,6 +319,57 @@ namespace Avalonia.Collections } } + /// + /// Moves an item to a new index. + /// + /// The index of the item to move. + /// The index to move the item to. + public void Move(int oldIndex, int newIndex) + { + var item = this[oldIndex]; + _inner.RemoveAt(oldIndex); + _inner.Insert(newIndex, item); + + if (_collectionChanged != null) + { + var e = new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + item, + newIndex, + oldIndex); + _collectionChanged(this, e); + } + } + + /// + /// Moves multiple items to a new index. + /// + /// The first index of the items to move. + /// The number of items to move. + /// The index to move the items to. + public void MoveRange(int oldIndex, int count, int newIndex) + { + var items = _inner.GetRange(oldIndex, count); + _inner.RemoveRange(oldIndex, count); + + if (newIndex > oldIndex) + { + newIndex -= count; + } + + _inner.InsertRange(newIndex, items); + + if (_collectionChanged != null) + { + var e = new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Move, + items, + newIndex, + oldIndex); + _collectionChanged(this, e); + } + } + /// /// Removes an item from the collection. /// diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 6358a8c9d9..c181aab1e2 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -50,13 +50,19 @@ - + + + + + + + @@ -68,8 +74,11 @@ + + + - + @@ -170,6 +179,7 @@ + diff --git a/src/Avalonia.Controls/Canvas.cs b/src/Avalonia.Controls/Canvas.cs index ebccb7435e..74eebea4bc 100644 --- a/src/Avalonia.Controls/Canvas.cs +++ b/src/Avalonia.Controls/Canvas.cs @@ -136,7 +136,7 @@ namespace Avalonia.Controls /// The movement direction. /// The control from which movement begins. /// The control. - IInputElement INavigableContainer.GetControl(FocusNavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) { // TODO: Implement this return null; diff --git a/src/Avalonia.Controls/Control.cs b/src/Avalonia.Controls/Control.cs index e246635906..944f6b82ac 100644 --- a/src/Avalonia.Controls/Control.cs +++ b/src/Avalonia.Controls/Control.cs @@ -33,7 +33,7 @@ namespace Avalonia.Controls /// - Implements to allow styling to work on the control. /// - Implements to form part of a logical tree. /// - public class Control : InputElement, IControl, INamed, ISetLogicalParent, ISupportInitialize + public class Control : InputElement, IControl, INamed, ISetInheritanceParent, ISetLogicalParent, ISupportInitialize { /// /// Defines the property. @@ -435,7 +435,11 @@ namespace Avalonia.Controls OnDetachedFromLogicalTreeCore(e); } - InheritanceParent = parent as AvaloniaObject; + if (InheritanceParent == null || parent == null) + { + InheritanceParent = parent as AvaloniaObject; + } + _parent = (IControl)parent; if (_parent is IStyleRoot || _parent?.IsAttachedToLogicalTree == true) @@ -455,6 +459,15 @@ namespace Avalonia.Controls } } + /// + /// Sets the control's inheritance parent. + /// + /// The parent. + void ISetInheritanceParent.SetParent(IAvaloniaObject parent) + { + InheritanceParent = parent; + } + /// /// Adds a pseudo-class to be set when a property is true. /// diff --git a/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs index 8199333f47..ba584e33b9 100644 --- a/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/IItemContainerGenerator.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections; using System.Collections.Generic; using Avalonia.Controls.Templates; @@ -16,7 +15,7 @@ namespace Avalonia.Controls.Generators /// /// Gets the currently realized containers. /// - IEnumerable Containers { get; } + IEnumerable Containers { get; } /// /// Gets or sets the data template used to display the items in the control. @@ -34,28 +33,33 @@ namespace Avalonia.Controls.Generators event EventHandler Dematerialized; /// - /// Creates container controls for a collection of items. + /// Event raised whenever containers are recycled. /// - /// - /// The index of the first item of the data in the containing collection. + event EventHandler Recycled; + + /// + /// Creates a container control for an item. + /// + /// + /// The index of the item of data in the control's items. /// - /// The items. + /// The item. /// An optional member selector. /// The created controls. - IEnumerable Materialize( - int startingIndex, - IEnumerable items, + ItemContainerInfo Materialize( + int index, + object item, IMemberSelector selector); /// /// Removes a set of created containers. /// /// - /// The index of the first item of the data in the containing collection. + /// The index of the first item in the control's items. /// /// The the number of items to remove. /// The removed containers. - IEnumerable Dematerialize(int startingIndex, int count); + IEnumerable Dematerialize(int startingIndex, int count); /// /// Inserts space for newly inserted containers in the index. @@ -69,17 +73,23 @@ namespace Avalonia.Controls.Generators /// the gap. /// /// - /// The index of the first item of the data in the containing collection. + /// The index of the first item in the control's items. /// /// The the number of items to remove. /// The removed containers. - IEnumerable RemoveRange(int startingIndex, int count); + IEnumerable RemoveRange(int startingIndex, int count); + + bool TryRecycle( + int oldIndex, + int newIndex, + object item, + IMemberSelector selector); /// /// Clears all created containers and returns the removed controls. /// /// The removed controls. - IEnumerable Clear(); + IEnumerable Clear(); /// /// Gets the container control representing the item with the specified index. diff --git a/src/Avalonia.Controls/Generators/ItemContainerEventArgs.cs b/src/Avalonia.Controls/Generators/ItemContainerEventArgs.cs index 304e0a99fa..6821a842e3 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerEventArgs.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerEventArgs.cs @@ -15,13 +15,10 @@ namespace Avalonia.Controls.Generators /// /// Initializes a new instance of the class. /// - /// The index of the first container in the source items. /// The container. - public ItemContainerEventArgs( - int startingIndex, - ItemContainer container) + public ItemContainerEventArgs(ItemContainerInfo container) { - StartingIndex = startingIndex; + StartingIndex = container.Index; Containers = new[] { container }; } @@ -32,7 +29,7 @@ namespace Avalonia.Controls.Generators /// The containers. public ItemContainerEventArgs( int startingIndex, - IList containers) + IList containers) { StartingIndex = startingIndex; Containers = containers; @@ -41,7 +38,7 @@ namespace Avalonia.Controls.Generators /// /// Gets the containers. /// - public IList Containers { get; } + public IList Containers { get; } /// /// Gets the index of the first container in the source items. diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index 114d31c5a6..45f7dff49c 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -2,10 +2,9 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reactive.Subjects; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; namespace Avalonia.Controls.Generators @@ -15,7 +14,7 @@ namespace Avalonia.Controls.Generators /// public class ItemContainerGenerator : IItemContainerGenerator { - private List _containers = new List(); + private Dictionary _containers = new Dictionary(); /// /// Initializes a new instance of the class. @@ -29,7 +28,7 @@ namespace Avalonia.Controls.Generators } /// - public IEnumerable Containers => _containers.Where(x => x != null); + public IEnumerable Containers => _containers.Values; /// public event EventHandler Materialized; @@ -37,6 +36,9 @@ namespace Avalonia.Controls.Generators /// public event EventHandler Dematerialized; + /// + public event EventHandler Recycled; + /// /// Gets or sets the data template used to display the items in the control. /// @@ -48,41 +50,29 @@ namespace Avalonia.Controls.Generators public IControl Owner { get; } /// - public IEnumerable Materialize( - int startingIndex, - IEnumerable items, + public ItemContainerInfo Materialize( + int index, + object item, IMemberSelector selector) { - Contract.Requires(items != null); + var i = selector != null ? selector.Select(item) : item; + var container = new ItemContainerInfo(CreateContainer(i), item, index); - int index = startingIndex; - var result = new List(); + _containers.Add(container.Index, container); + Materialized?.Invoke(this, new ItemContainerEventArgs(container)); - foreach (var item in items) - { - var i = selector != null ? selector.Select(item) : item; - var container = new ItemContainer(CreateContainer(i), item, index++); - result.Add(container); - } - - AddContainers(result); - Materialized?.Invoke(this, new ItemContainerEventArgs(startingIndex, result)); - - return result.Where(x => x != null).ToList(); + return container; } /// - public virtual IEnumerable Dematerialize(int startingIndex, int count) + public virtual IEnumerable Dematerialize(int startingIndex, int count) { - var result = new List(); + var result = new List(); for (int i = startingIndex; i < startingIndex + count; ++i) { - if (i < _containers.Count) - { - result.Add(_containers[i]); - _containers[i] = null; - } + result.Add(_containers[i]); + _containers.Remove(i); } Dematerialized?.Invoke(this, new ItemContainerEventArgs(startingIndex, result)); @@ -93,18 +83,47 @@ namespace Avalonia.Controls.Generators /// public virtual void InsertSpace(int index, int count) { - _containers.InsertRange(index, Enumerable.Repeat(null, count)); + if (count > 0) + { + var toMove = _containers.Where(x => x.Key >= index).ToList(); + + foreach (var i in toMove) + { + _containers.Remove(i.Key); + i.Value.Index += count; + _containers[i.Value.Index] = i.Value; + } + } } /// - public virtual IEnumerable RemoveRange(int startingIndex, int count) + public virtual IEnumerable RemoveRange(int startingIndex, int count) { - List result = new List(); + var result = new List(); - if (startingIndex < _containers.Count) + if (count > 0) { - result.AddRange(_containers.GetRange(startingIndex, count)); - _containers.RemoveRange(startingIndex, count); + for (var i = startingIndex; i < startingIndex + count; ++i) + { + ItemContainerInfo found; + + if (_containers.TryGetValue(i, out found)) + { + result.Add(found); + } + + _containers.Remove(i); + } + + var toMove = _containers.Where(x => x.Key >= startingIndex).ToList(); + + foreach (var i in toMove) + { + _containers.Remove(i.Key); + i.Value.Index -= count; + _containers.Add(i.Value.Index, i.Value); + } + Dematerialized?.Invoke(this, new ItemContainerEventArgs(startingIndex, result)); } @@ -112,10 +131,20 @@ namespace Avalonia.Controls.Generators } /// - public virtual IEnumerable Clear() + public virtual bool TryRecycle( + int oldIndex, + int newIndex, + object item, + IMemberSelector selector) + { + return false; + } + + /// + public virtual IEnumerable Clear() { - var result = _containers.Where(x => x != null).ToList(); - _containers = new List(); + var result = Containers.ToList(); + _containers.Clear(); if (result.Count > 0) { @@ -128,27 +157,20 @@ namespace Avalonia.Controls.Generators /// public IControl ContainerFromIndex(int index) { - if (index < _containers.Count) - { - return _containers[index]?.ContainerControl; - } - - return null; + ItemContainerInfo result; + _containers.TryGetValue(index, out result); + return result?.ContainerControl; } /// public int IndexFromContainer(IControl container) { - var index = 0; - foreach (var i in _containers) { - if (i?.ContainerControl == container) + if (i.Value.ContainerControl == container) { - return index; + return i.Key; } - - ++index; } return -1; @@ -161,44 +183,35 @@ namespace Avalonia.Controls.Generators /// The created container control. protected virtual IControl CreateContainer(object item) { - var result = Owner.MaterializeDataTemplate(item, ItemTemplate); + var result = item as IControl; - if (result != null && !(item is IControl)) + if (result == null) { - result.DataContext = item; + result = new ContentPresenter + { + ContentTemplate = ItemTemplate, + Content = item, + }; } return result; } /// - /// Adds a collection of containers to the index. + /// Moves a container. /// - /// The containers. - protected void AddContainers(IList containers) + /// The old index. + /// The new index. + /// The new item. + /// The container info. + protected ItemContainerInfo MoveContainer(int oldIndex, int newIndex, object item) { - Contract.Requires(containers != null); - - foreach (var c in containers) - { - while (_containers.Count < c.Index) - { - _containers.Add(null); - } - - if (_containers.Count == c.Index) - { - _containers.Add(c); - } - else if (_containers[c.Index] == null) - { - _containers[c.Index] = c; - } - else - { - throw new InvalidOperationException("Container already created."); - } - } + var container = _containers[oldIndex]; + container.Index = newIndex; + container.Item = item; + _containers.Remove(oldIndex); + _containers.Add(newIndex, container); + return container; } /// @@ -207,9 +220,18 @@ namespace Avalonia.Controls.Generators /// The first index. /// The number of elements in the range. /// The containers. - protected IEnumerable GetContainerRange(int index, int count) + protected IEnumerable GetContainerRange(int index, int count) + { + return _containers.Where(x => x.Key >= index && x.Key <= index + count).Select(x => x.Value); + } + + /// + /// Raises the event. + /// + /// The event args. + protected void RaiseRecycled(ItemContainerEventArgs e) { - return _containers.GetRange(index, count); + Recycled?.Invoke(this, e); } } } \ No newline at end of file diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs index c4bc730e15..35107a84fc 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator`1.cs @@ -75,5 +75,34 @@ namespace Avalonia.Controls.Generators return result; } } + + /// + public override bool TryRecycle( + int oldIndex, + int newIndex, + object item, + IMemberSelector selector) + { + var container = ContainerFromIndex(oldIndex); + + if (container == null) + { + throw new IndexOutOfRangeException("Could not recycle container: not materialized."); + } + + var i = selector != null ? selector.Select(item) : item; + + container.SetValue(ContentProperty, i); + + if (!(item is IControl)) + { + container.DataContext = i; + } + + var info = MoveContainer(oldIndex, newIndex, i); + RaiseRecycled(new ItemContainerEventArgs(info)); + + return true; + } } } diff --git a/src/Avalonia.Controls/Generators/ItemContainer.cs b/src/Avalonia.Controls/Generators/ItemContainerInfo.cs similarity index 85% rename from src/Avalonia.Controls/Generators/ItemContainer.cs rename to src/Avalonia.Controls/Generators/ItemContainerInfo.cs index ed2e433a28..b9387f1022 100644 --- a/src/Avalonia.Controls/Generators/ItemContainer.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerInfo.cs @@ -7,17 +7,17 @@ namespace Avalonia.Controls.Generators /// Holds information about an item container generated by an /// . /// - public class ItemContainer + public class ItemContainerInfo { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The container control. /// The item that the container represents. /// /// The index of the item in the collection. /// - public ItemContainer(IControl container, object item, int index) + public ItemContainerInfo(IControl container, object item, int index) { ContainerControl = container; Item = item; @@ -35,11 +35,11 @@ namespace Avalonia.Controls.Generators /// /// Gets the item that the container represents. /// - public object Item { get; } + public object Item { get; internal set; } /// /// Gets the index of the item in the collection. /// - public int Index { get; } + public int Index { get; internal set; } } } \ No newline at end of file diff --git a/src/Avalonia.Controls/Generators/MenuItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/MenuItemContainerGenerator.cs new file mode 100644 index 0000000000..c9b3a55aaa --- /dev/null +++ b/src/Avalonia.Controls/Generators/MenuItemContainerGenerator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Avalonia.Controls.Generators +{ + public class MenuItemContainerGenerator : ItemContainerGenerator + { + /// + /// Initializes a new instance of the class. + /// + /// The owner control. + public MenuItemContainerGenerator(IControl owner) + : base(owner, MenuItem.HeaderProperty, null) + { + } + + /// + protected override IControl CreateContainer(object item) + { + var separator = item as Separator; + return separator != null ? separator : base.CreateContainer(item); + } + } +} diff --git a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs index b7a9bb2dc1..e0c52beb11 100644 --- a/src/Avalonia.Controls/Generators/TreeContainerIndex.cs +++ b/src/Avalonia.Controls/Generators/TreeContainerIndex.cs @@ -47,7 +47,7 @@ namespace Avalonia.Controls.Generators Materialized?.Invoke( this, - new ItemContainerEventArgs(0, new ItemContainer(container, item, 0))); + new ItemContainerEventArgs(new ItemContainerInfo(container, item, 0))); } /// @@ -62,14 +62,14 @@ namespace Avalonia.Controls.Generators Dematerialized?.Invoke( this, - new ItemContainerEventArgs(0, new ItemContainer(container, item, 0))); + new ItemContainerEventArgs(new ItemContainerInfo(container, item, 0))); } /// /// Removes a set of containers from the index. /// /// The item containers. - public void Remove(IEnumerable containers) + public void Remove(IEnumerable containers) { foreach (var container in containers) { diff --git a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs index 681bea865a..e26a4fb0d6 100644 --- a/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/TreeItemContainerGenerator.cs @@ -99,25 +99,30 @@ namespace Avalonia.Controls.Generators } } - public override IEnumerable Clear() + public override IEnumerable Clear() { var items = base.Clear(); Index.Remove(items); return items; } - public override IEnumerable Dematerialize(int startingIndex, int count) + public override IEnumerable Dematerialize(int startingIndex, int count) { Index.Remove(GetContainerRange(startingIndex, count)); return base.Dematerialize(startingIndex, count); } - public override IEnumerable RemoveRange(int startingIndex, int count) + public override IEnumerable RemoveRange(int startingIndex, int count) { Index.Remove(GetContainerRange(startingIndex, count)); return base.RemoveRange(startingIndex, count); } + public override bool TryRecycle(int oldIndex, int newIndex, object item, IMemberSelector selector) + { + return false; + } + /// /// Gets the data template for the specified item. /// diff --git a/src/Avalonia.Controls/IScrollable.cs b/src/Avalonia.Controls/IScrollable.cs new file mode 100644 index 0000000000..9bbd0d8518 --- /dev/null +++ b/src/Avalonia.Controls/IScrollable.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Interface implemented by scrollable controls. + /// + public interface IScrollable + { + /// + /// Gets the extent of the scrollable content, in logical units + /// + Size Extent { get; } + + /// + /// Gets or sets the current scroll offset, in logical units. + /// + Vector Offset { get; set; } + + /// + /// Gets the size of the viewport, in logical units. + /// + Size Viewport { get; } + } +} diff --git a/src/Avalonia.Controls/ISetInheritanceParent.cs b/src/Avalonia.Controls/ISetInheritanceParent.cs new file mode 100644 index 0000000000..788ab77246 --- /dev/null +++ b/src/Avalonia.Controls/ISetInheritanceParent.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Controls +{ + /// + /// Defines an interface through which a 's inheritance parent can be set. + /// + /// + /// You should not usually need to use this interface - it is for advanced scenarios only. + /// Additionally, also sets the inheritance parent; this + /// interface is only needed where the logical and inheritance parents differ. + /// + public interface ISetInheritanceParent + { + /// + /// Sets the control's inheritance parent. + /// + /// The parent. + void SetParent(IAvaloniaObject parent); + } +} \ No newline at end of file diff --git a/src/Avalonia.Controls/IVirtualizingController.cs b/src/Avalonia.Controls/IVirtualizingController.cs new file mode 100644 index 0000000000..0b997f4948 --- /dev/null +++ b/src/Avalonia.Controls/IVirtualizingController.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Controls +{ + /// + /// Interface implemented by controls that act as controllers for an + /// . + /// + public interface IVirtualizingController + { + /// + /// Called when the 's controls should be updated. + /// + /// + /// The controller should respond to this method being called by either adding + /// children up until becomes true or + /// removing controls. + /// + void UpdateControls(); + } +} diff --git a/src/Avalonia.Controls/IVirtualizingPanel.cs b/src/Avalonia.Controls/IVirtualizingPanel.cs new file mode 100644 index 0000000000..5d35fa1ec8 --- /dev/null +++ b/src/Avalonia.Controls/IVirtualizingPanel.cs @@ -0,0 +1,67 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Controls +{ + /// + /// A panel that can be used to virtualize items. + /// + public interface IVirtualizingPanel : IPanel + { + /// + /// Gets or sets the controller for the virtualizing panel. + /// + /// + /// A virtualizing controller is responsible for maintaing the controls in the virtualizing + /// panel. This property will be set by the controller when virtualization is initialized. + /// Note that this property may remain null if the panel is added to a control that does + /// not act as a virtualizing controller. + /// + IVirtualizingController Controller { get; set; } + + /// + /// Gets a value indicating whether the panel is full. + /// + /// + /// This property should return false until enough children are added to fill the space + /// passed into the last measure in the direction of scroll. It should be updated + /// immediately after a child is added or removed. + /// + bool IsFull { get; } + + /// + /// Gets the number of items that can be removed while keeping the panel full. + /// + /// + /// This property should return the number of children that are completely out of the + /// panel's current bounds in the direction of scroll. It should be updated after an + /// arrange. + /// + int OverflowCount { get; } + + /// + /// Gets the direction of scroll. + /// + Orientation ScrollDirection { get; } + + /// + /// Gets the average size of the materialized items in the direction of scroll. + /// + double AverageItemSize { get; } + + /// + /// Gets or sets a size in pixels by which the content is overflowing the panel, in the + /// direction of scroll. + /// + /// + /// This may be non-zero even when is zero if the last item + /// overflows the panel bounds. + /// + double PixelOverflow { get; } + + /// + /// Gets or sets the current pixel offset of the items in the direction of scroll. + /// + double PixelOffset { get; set; } + } +} diff --git a/src/Avalonia.Controls/ItemVirtualizationMode.cs b/src/Avalonia.Controls/ItemVirtualizationMode.cs new file mode 100644 index 0000000000..f17e8c07ad --- /dev/null +++ b/src/Avalonia.Controls/ItemVirtualizationMode.cs @@ -0,0 +1,21 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Controls +{ + /// + /// Describes the item virtualization method to use for a list. + /// + public enum ItemVirtualizationMode + { + /// + /// Do not virtualize items. + /// + None, + + /// + /// Virtualize items without smooth scrolling. + /// + Simple, + } +} diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 3a77f61f1b..8e8603ca24 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -25,7 +25,6 @@ namespace Avalonia.Controls /// /// The default value for the property. /// - [SuppressMessage("Microsoft.StyleCop.CSharp.NamingRules", "SA1202:ElementsMustBeOrderedByAccess", Justification = "Needs to be before or a NullReferenceException is thrown.")] private static readonly FuncTemplate DefaultPanel = new FuncTemplate(() => new StackPanel()); @@ -90,6 +89,7 @@ namespace Avalonia.Controls _itemContainerGenerator.ItemTemplate = ItemTemplate; _itemContainerGenerator.Materialized += (_, e) => OnContainersMaterialized(e); _itemContainerGenerator.Dematerialized += (_, e) => OnContainersDematerialized(e); + _itemContainerGenerator.Recycled += (_, e) => OnContainersRecycled(e); } } @@ -265,6 +265,28 @@ namespace Avalonia.Controls LogicalChildren.RemoveAll(toRemove); } + /// + /// Called when containers are recycled for the by its + /// . + /// + /// The details of the containers. + protected virtual void OnContainersRecycled(ItemContainerEventArgs e) + { + var toRemove = new List(); + + foreach (var container in e.Containers) + { + // If the item is its own container, then it will be removed from the logical tree + // when it is removed from the Items collection. + if (container?.ContainerControl != container?.Item) + { + toRemove.Add(container.ContainerControl); + } + } + + LogicalChildren.RemoveAll(toRemove); + } + /// protected override void OnTemplateChanged(AvaloniaPropertyChangedEventArgs e) { diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 0f32bc91da..84e4751d37 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -2,12 +2,11 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System.Collections; -using System.Collections.Generic; -using Avalonia.Collections; using Avalonia.Controls.Generators; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Interactivity; namespace Avalonia.Controls { @@ -16,6 +15,18 @@ namespace Avalonia.Controls /// public class ListBox : SelectingItemsControl { + /// + /// The default value for the property. + /// + private static readonly FuncTemplate DefaultPanel = + new FuncTemplate(() => new VirtualizingStackPanel()); + + /// + /// Defines the property. + /// + public static readonly DirectProperty ScrollProperty = + AvaloniaProperty.RegisterDirect(nameof(Scroll), o => o.Scroll); + /// /// Defines the property. /// @@ -28,6 +39,31 @@ namespace Avalonia.Controls public static readonly new AvaloniaProperty SelectionModeProperty = SelectingItemsControl.SelectionModeProperty; + /// + /// Defines the property. + /// + public static readonly AvaloniaProperty VirtualizationModeProperty = + ItemsPresenter.VirtualizationModeProperty.AddOwner(); + + private IScrollable _scroll; + + /// + /// Initializes static members of the class. + /// + static ListBox() + { + ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); + } + + /// + /// Gets the scroll information for the . + /// + public IScrollable Scroll + { + get { return _scroll; } + private set { SetAndRaise(ScrollProperty, ref _scroll, value); } + } + /// public new IList SelectedItems => base.SelectedItems; @@ -38,6 +74,15 @@ namespace Avalonia.Controls set { base.SelectionMode = value; } } + /// + /// Gets or sets the virtualization mode for the items. + /// + public ItemVirtualizationMode VirtualizationMode + { + get { return GetValue(VirtualizationModeProperty); } + set { SetValue(VirtualizationModeProperty, value); } + } + /// protected override IItemContainerGenerator CreateItemContainerGenerator() { @@ -75,5 +120,11 @@ namespace Avalonia.Controls (e.InputModifiers & InputModifiers.Control) != 0); } } + + protected override void OnTemplateApplied(TemplateAppliedEventArgs e) + { + base.OnTemplateApplied(e); + Scroll = e.NameScope.Find("PART_ScrollViewer"); + } } } diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index d5d70ab1ea..2185fd982b 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Reactive.Disposables; +using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; @@ -131,6 +132,12 @@ namespace Avalonia.Controls _subscription.Dispose(); } + /// + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return new ItemContainerGenerator(this, MenuItem.HeaderProperty, null); + } + /// /// Called when a key is pressed within the menu. /// diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index e4bb44049d..3d15ed99e7 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Windows.Input; +using Avalonia.Controls.Generators; using Avalonia.Controls.Mixins; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; @@ -204,6 +205,12 @@ namespace Avalonia.Controls IsSelected = true; } + /// + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return new MenuItemContainerGenerator(this); + } + /// /// Called when a key is pressed in the . /// diff --git a/src/Avalonia.Controls/Mixins/SelectableMixin.cs b/src/Avalonia.Controls/Mixins/SelectableMixin.cs index 0472b604db..2369e9f530 100644 --- a/src/Avalonia.Controls/Mixins/SelectableMixin.cs +++ b/src/Avalonia.Controls/Mixins/SelectableMixin.cs @@ -51,22 +51,7 @@ namespace Avalonia.Controls.Mixins if (sender != null) { - var itemsControl = sender.Parent as SelectingItemsControl; - - if ((bool)x.NewValue) - { - ((IPseudoClasses)sender.Classes).Add(":selected"); - - if (((IVisual)sender).IsAttachedToVisualTree && - itemsControl?.AutoScrollToSelectedItem == true) - { - sender.BringIntoView(); - } - } - else - { - ((IPseudoClasses)sender.Classes).Remove(":selected"); - } + ((IPseudoClasses)sender.Classes).Set(":selected", (bool)x.NewValue); sender.RaiseEvent(new RoutedEventArgs { diff --git a/src/Avalonia.Controls/Panel.cs b/src/Avalonia.Controls/Panel.cs index 55bdbc9dd4..793399841c 100644 --- a/src/Avalonia.Controls/Panel.cs +++ b/src/Avalonia.Controls/Panel.cs @@ -79,12 +79,28 @@ namespace Avalonia.Controls set { SetValue(BackgroundProperty, value); } } + /// + /// Renders the visual to a . + /// + /// The drawing context. + public override void Render(DrawingContext context) + { + var background = Background; + if (background != null) + { + var renderSize = Bounds.Size; + context.FillRectangle(background, new Rect(renderSize)); + } + + base.Render(context); + } + /// /// Called when the collection changes. /// /// The event sender. /// The event args. - private void ChildrenChanged(object sender, NotifyCollectionChangedEventArgs e) + protected virtual void ChildrenChanged(object sender, NotifyCollectionChangedEventArgs e) { List controls; @@ -122,21 +138,5 @@ namespace Avalonia.Controls InvalidateMeasure(); } - - /// - /// Renders the visual to a . - /// - /// The drawing context. - public override void Render(DrawingContext context) - { - var background = Background; - if (background != null) - { - var renderSize = Bounds.Size; - context.FillRectangle(background, new Rect(renderSize)); - } - - base.Render(context); - } } } diff --git a/src/Avalonia.Controls/Presenters/CarouselPresenter.cs b/src/Avalonia.Controls/Presenters/CarouselPresenter.cs index a4f9c0cb43..8b408002a1 100644 --- a/src/Avalonia.Controls/Presenters/CarouselPresenter.cs +++ b/src/Avalonia.Controls/Presenters/CarouselPresenter.cs @@ -95,9 +95,8 @@ namespace Avalonia.Controls.Presenters } /// - protected override void CreatePanel() + protected override void PanelCreated(IPanel panel) { - base.CreatePanel(); var task = MoveToPage(-1, SelectedIndex); } @@ -175,12 +174,9 @@ namespace Avalonia.Controls.Presenters if (container == null) { var item = Items.Cast().ElementAt(index); - var materialized = ItemContainerGenerator.Materialize( - index, - new[] { item }, - MemberSelector); - container = materialized.First().ContainerControl; - Panel.Children.Add(container); + var materialized = ItemContainerGenerator.Materialize(index, item, MemberSelector); + Panel.Children.Add(materialized.ContainerControl); + container = materialized.ContainerControl; } return container; diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index 3470a43c1c..54d7e76d73 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -80,6 +80,7 @@ namespace Avalonia.Controls.Presenters private IControl _child; private bool _createdChild; + private IDataTemplate _dataTemplate; /// /// Initializes static members of the class. @@ -95,10 +96,6 @@ namespace Avalonia.Controls.Presenters /// public ContentPresenter() { - var dataContext = this.GetObservable(ContentProperty) - .Select(x => x is IControl ? AvaloniaProperty.UnsetValue : x); - - Bind(Control.DataContextProperty, dataContext); } /// @@ -200,6 +197,13 @@ namespace Avalonia.Controls.Presenters } } + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _dataTemplate = null; + } + /// /// Updates the control based on the control's . /// @@ -213,35 +217,73 @@ namespace Avalonia.Controls.Presenters /// public void UpdateChild() { - var old = Child; var content = Content; - var result = this.MaterializeDataTemplate(content, ContentTemplate); - - if (old != null) - { - VisualChildren.Remove(old); - } + var oldChild = Child; + var newChild = content as IControl; - if (result != null) + if (content != null && newChild == null) { - if (!(content is IControl)) + // We have content and it isn't a control, so first try to recycle the existing + // child control to display the new data by querying if the template that created + // the child can recycle items and that it also matches the new data. + if (oldChild != null && + _dataTemplate != null && + _dataTemplate.SupportsRecycling && + _dataTemplate.Match(content)) { - result.DataContext = content; + newChild = oldChild; } - - Child = result; - - if (result.Parent == null) + else { - ((ISetLogicalParent)result).SetParent((ILogical)this.TemplatedParent ?? this); + // We couldn't recycle an existing control so find a data template for the data + // and use it to create a control. + _dataTemplate = this.FindDataTemplate(content, ContentTemplate) ?? FuncDataTemplate.Default; + newChild = _dataTemplate.Build(content); + + // Try to give the new control its own name scope. + var controlResult = newChild as Control; + + if (controlResult != null) + { + NameScope.SetNameScope(controlResult, new NameScope()); + } } - - VisualChildren.Add(result); } else + { + _dataTemplate = null; + } + + // Remove the old child if we're not recycling it. + if (oldChild != null && newChild != oldChild) + { + VisualChildren.Remove(oldChild); + } + + // Set the DataContext if the data isn't a control. + if (!(content is IControl)) + { + DataContext = content; + } + + // Update the Child. + if (newChild == null) { Child = null; } + else if (newChild != oldChild) + { + ((ISetInheritanceParent)newChild).SetParent(this); + + Child = newChild; + + if (newChild.Parent == null) + { + ((ISetLogicalParent)newChild).SetParent((ILogical)this.TemplatedParent ?? this); + } + + VisualChildren.Add(newChild); + } _createdChild = true; } diff --git a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs index 95df903ed3..42311dc781 100644 --- a/src/Avalonia.Controls/Presenters/IItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/IItemsPresenter.cs @@ -6,5 +6,7 @@ namespace Avalonia.Controls.Presenters public interface IItemsPresenter : IPresenter { IPanel Panel { get; } + + void ScrollIntoView(object item); } } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs new file mode 100644 index 0000000000..964ce82849 --- /dev/null +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs @@ -0,0 +1,198 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Specialized; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Utils; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Presenters +{ + /// + /// Base class for classes which handle virtualization for an . + /// + internal abstract class ItemVirtualizer : IVirtualizingController, IDisposable + { + private bool disposedValue; + + /// + /// Initializes a new instance of the class. + /// + /// + public ItemVirtualizer(ItemsPresenter owner) + { + Owner = owner; + Items = owner.Items; + ItemCount = owner.Items.Count(); + } + + /// + /// Gets the which owns the virtualizer. + /// + public ItemsPresenter Owner { get; } + + /// + /// Gets the which will host the items. + /// + public IVirtualizingPanel VirtualizingPanel => Owner.Panel as IVirtualizingPanel; + + /// + /// Gets the items to display. + /// + public IEnumerable Items { get; private set; } + + /// + /// Gets the number of items in . + /// + public int ItemCount { get; private set; } + + /// + /// Gets or sets the index of the first item displayed in the panel. + /// + public int FirstIndex { get; protected set; } + + /// + /// Gets or sets the index of the first item beyond those displayed in the panel. + /// + public int NextIndex { get; protected set; } + + /// + /// Gets a value indicating whether the items should be scroll horizontally or vertically. + /// + public bool Vertical => VirtualizingPanel.ScrollDirection == Orientation.Vertical; + + /// + /// Gets a value indicating whether logical scrolling is enabled. + /// + public abstract bool IsLogicalScrollEnabled { get; } + + /// + /// Gets the value of the scroll extent. + /// + public abstract double ExtentValue { get; } + + /// + /// Gets or sets the value of the current scroll offset. + /// + public abstract double OffsetValue { get; set; } + + /// + /// Gets the value of the scrollable viewport. + /// + public abstract double ViewportValue { get; } + + /// + /// Gets the as a . + /// + public Size Extent => Vertical ? new Size(0, ExtentValue) : new Size(ExtentValue, 0); + + /// + /// Gets the as a . + /// + public Size Viewport => Vertical ? new Size(0, ViewportValue) : new Size(ViewportValue, 0); + + /// + /// Gets or sets the as a . + /// + public Vector Offset + { + get + { + return Vertical ? new Vector(0, OffsetValue) : new Vector(OffsetValue, 0); + } + + set + { + OffsetValue = Vertical ? value.Y : value.X; + } + } + + /// + /// Creates an based on an item presenter's + /// . + /// + /// The items presenter. + /// An . + public static ItemVirtualizer Create(ItemsPresenter owner) + { + var virtualizingPanel = owner.Panel as IVirtualizingPanel; + var scrollable = (ILogicalScrollable)owner; + ItemVirtualizer result = null; + + if (virtualizingPanel != null && scrollable.InvalidateScroll != null) + { + switch (owner.VirtualizationMode) + { + case ItemVirtualizationMode.Simple: + result = new ItemVirtualizerSimple(owner); + break; + } + } + + if (result == null) + { + result = new ItemVirtualizerNone(owner); + } + + if (virtualizingPanel != null) + { + virtualizingPanel.Controller = result; + } + + return result; + } + + /// + public virtual void UpdateControls() + { + } + + /// + /// Gets the next control in the specified direction. + /// + /// The movement direction. + /// The control from which movement begins. + /// The control. + public virtual IControl GetControlInDirection(NavigationDirection direction, IControl from) + { + return null; + } + + /// + /// Called when the items for the presenter change, either because + /// has been set, the items collection has been + /// modified, or the panel has been created. + /// + /// The items. + /// A description of the change. + public virtual void ItemsChanged(IEnumerable items, NotifyCollectionChangedEventArgs e) + { + Items = items; + ItemCount = items.Count(); + } + + /// + /// Scrolls the specified item into view. + /// + /// The item. + public virtual void ScrollIntoView(object item) + { + } + + /// + public virtual void Dispose() + { + VirtualizingPanel.Controller = null; + VirtualizingPanel.Children.Clear(); + Owner.ItemContainerGenerator.Clear(); + } + + /// + /// Invalidates the current scroll. + /// + protected void InvalidateScroll() => ((ILogicalScrollable)Owner).InvalidateScroll(); + } +} diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs new file mode 100644 index 0000000000..56bb9299ae --- /dev/null +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs @@ -0,0 +1,170 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Controls.Generators; +using Avalonia.Controls.Utils; + +namespace Avalonia.Controls.Presenters +{ + /// + /// Represents an item virtualizer for an that doesn't actually + /// virtualize items - it just creates a container for every item. + /// + internal class ItemVirtualizerNone : ItemVirtualizer + { + public ItemVirtualizerNone(ItemsPresenter owner) + : base(owner) + { + if (Items != null && owner.Panel != null) + { + AddContainers(0, Items); + } + } + + /// + public override bool IsLogicalScrollEnabled => false; + + /// + /// This property should never be accessed because is + /// false. + /// + public override double ExtentValue + { + get { throw new NotSupportedException(); } + } + + /// + /// This property should never be accessed because is + /// false. + /// + public override double OffsetValue + { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + + /// + /// This property should never be accessed because is + /// false. + /// + public override double ViewportValue + { + get { throw new NotSupportedException(); } + } + + /// + public override void ItemsChanged(IEnumerable items, NotifyCollectionChangedEventArgs e) + { + base.ItemsChanged(items, e); + + var generator = Owner.ItemContainerGenerator; + var panel = Owner.Panel; + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + if (e.NewStartingIndex + e.NewItems.Count < Items.Count()) + { + generator.InsertSpace(e.NewStartingIndex, e.NewItems.Count); + } + + AddContainers(e.NewStartingIndex, e.NewItems); + break; + + case NotifyCollectionChangedAction.Remove: + RemoveContainers(generator.RemoveRange(e.OldStartingIndex, e.OldItems.Count)); + break; + + case NotifyCollectionChangedAction.Replace: + RemoveContainers(generator.Dematerialize(e.OldStartingIndex, e.OldItems.Count)); + var containers = AddContainers(e.NewStartingIndex, e.NewItems); + + var i = e.NewStartingIndex; + + foreach (var container in containers) + { + panel.Children[i++] = container.ContainerControl; + } + + break; + + case NotifyCollectionChangedAction.Move: + // TODO: Handle move in a more efficient manner. At the moment we just + // drop through to Reset to recreate all the containers. + + case NotifyCollectionChangedAction.Reset: + RemoveContainers(generator.Clear()); + + if (Items != null) + { + AddContainers(0, Items); + } + + break; + } + + Owner.InvalidateMeasure(); + } + + /// + /// Scrolls the specified item into view. + /// + /// The item. + public override void ScrollIntoView(object item) + { + var index = Items.IndexOf(item); + + if (index != -1) + { + var container = Owner.ItemContainerGenerator.ContainerFromIndex(index); + container.BringIntoView(); + } + } + + private IList AddContainers(int index, IEnumerable items) + { + var generator = Owner.ItemContainerGenerator; + var result = new List(); + var panel = Owner.Panel; + + foreach (var item in items) + { + var i = generator.Materialize(index++, item, Owner.MemberSelector); + + if (i.ContainerControl != null) + { + if (i.Index < panel.Children.Count) + { + // TODO: This will insert at the wrong place when there are null items. + panel.Children.Insert(i.Index, i.ContainerControl); + } + else + { + panel.Children.Add(i.ContainerControl); + } + } + + result.Add(i); + } + + return result; + } + + private void RemoveContainers(IEnumerable items) + { + var panel = Owner.Panel; + + foreach (var i in items) + { + if (i.ContainerControl != null) + { + panel.Children.Remove(i.ContainerControl); + } + } + } + } +} diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs new file mode 100644 index 0000000000..1dca52f885 --- /dev/null +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -0,0 +1,491 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Specialized; +using System.Linq; +using Avalonia.Controls.Utils; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Presenters +{ + /// + /// Handles virtualization in an for + /// . + /// + internal class ItemVirtualizerSimple : ItemVirtualizer + { + /// + /// Initializes a new instance of the class. + /// + /// + public ItemVirtualizerSimple(ItemsPresenter owner) + : base(owner) + { + // Don't need to add children here as UpdateControls should be called by the panel + // measure/arrange. + } + + /// + public override bool IsLogicalScrollEnabled => true; + + /// + public override double ExtentValue => ItemCount; + + /// + public override double OffsetValue + { + get + { + var offset = VirtualizingPanel.PixelOffset > 0 ? 1 : 0; + return FirstIndex + offset; + } + + set + { + var panel = VirtualizingPanel; + var offset = VirtualizingPanel.PixelOffset > 0 ? 1 : 0; + var delta = (int)(value - (FirstIndex + offset)); + + if (delta != 0) + { + var newLastIndex = (NextIndex - 1) + delta; + + if (newLastIndex < ItemCount) + { + if (panel.PixelOffset > 0) + { + panel.PixelOffset = 0; + delta += 1; + } + + if (delta != 0) + { + RecycleContainersForMove(delta); + } + } + else + { + // We're moving to a partially obscured item at the end of the list so + // offset the panel by the height of the first item. + var firstIndex = ItemCount - panel.Children.Count; + RecycleContainersForMove(firstIndex - FirstIndex); + + panel.PixelOffset = VirtualizingPanel.ScrollDirection == Orientation.Vertical ? + panel.Children[0].Bounds.Height : + panel.Children[0].Bounds.Width; + } + } + } + } + + /// + public override double ViewportValue + { + get + { + // If we can't fit the last item in the panel fully, subtract 1 from the viewport. + var overflow = VirtualizingPanel.PixelOverflow > 0 ? 1 : 0; + return VirtualizingPanel.Children.Count - overflow; + } + } + + /// + public override void UpdateControls() + { + CreateAndRemoveContainers(); + InvalidateScroll(); + } + + /// + public override void ItemsChanged(IEnumerable items, NotifyCollectionChangedEventArgs e) + { + base.ItemsChanged(items, e); + + if (items != null) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + if (e.NewStartingIndex >= FirstIndex && + e.NewStartingIndex + e.NewItems.Count <= NextIndex) + { + CreateAndRemoveContainers(); + RecycleContainers(); + } + + break; + + case NotifyCollectionChangedAction.Remove: + if (e.OldStartingIndex >= FirstIndex && + e.OldStartingIndex + e.OldItems.Count <= NextIndex) + { + RecycleContainersOnRemove(); + } + + break; + + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Replace: + RecycleContainers(); + break; + + case NotifyCollectionChangedAction.Reset: + RecycleContainersOnRemove(); + break; + } + } + else + { + Owner.ItemContainerGenerator.Clear(); + VirtualizingPanel.Children.Clear(); + } + + InvalidateScroll(); + } + + public override IControl GetControlInDirection(NavigationDirection direction, IControl from) + { + var generator = Owner.ItemContainerGenerator; + var panel = VirtualizingPanel; + var itemIndex = generator.IndexFromContainer(from); + var vertical = VirtualizingPanel.ScrollDirection == Orientation.Vertical; + + if (itemIndex == -1) + { + return null; + } + + var newItemIndex = -1; + + switch (direction) + { + case NavigationDirection.First: + newItemIndex = 0; + break; + + case NavigationDirection.Last: + newItemIndex = ItemCount - 1; + break; + + case NavigationDirection.Up: + if (vertical) + { + newItemIndex = itemIndex - 1; + } + + break; + case NavigationDirection.Down: + if (vertical) + { + newItemIndex = itemIndex + 1; + } + + break; + + case NavigationDirection.Left: + if (!vertical) + { + newItemIndex = itemIndex - 1; + } + break; + + case NavigationDirection.Right: + if (!vertical) + { + newItemIndex = itemIndex + 1; + } + break; + + case NavigationDirection.PageUp: + newItemIndex = Math.Max(0, itemIndex - (int)ViewportValue); + break; + + case NavigationDirection.PageDown: + newItemIndex = Math.Min(ItemCount - 1, itemIndex + (int)ViewportValue); + break; + } + + return ScrollIntoView(newItemIndex); + } + + /// + public override void ScrollIntoView(object item) + { + var index = Items.IndexOf(item); + + if (index != -1) + { + ScrollIntoView(index); + } + } + + /// + /// Creates and removes containers such that we have at most enough containers to fill + /// the panel. + /// + private void CreateAndRemoveContainers() + { + var generator = Owner.ItemContainerGenerator; + var panel = VirtualizingPanel; + + if (!panel.IsFull && Items != null) + { + var memberSelector = Owner.MemberSelector; + var index = NextIndex; + var step = 1; + + while (!panel.IsFull) + { + if (index >= ItemCount) + { + // We can fit more containers in the panel, but we're at the end of the + // items. If we're scrolled to the top (FirstIndex == 0), then there are + // no more items to create. Otherwise, go backwards adding containers to + // the beginning of the panel. + if (FirstIndex == 0) + { + break; + } + else + { + index = FirstIndex - 1; + step = -1; + } + } + + var materialized = generator.Materialize(index, Items.ElementAt(index), memberSelector); + + if (step == 1) + { + panel.Children.Add(materialized.ContainerControl); + } + else + { + panel.Children.Insert(0, materialized.ContainerControl); + } + + index += step; + } + + if (step == 1) + { + NextIndex = index; + } + else + { + NextIndex = ItemCount; + FirstIndex = index + 1; + } + } + + if (panel.OverflowCount > 0) + { + RemoveContainers(panel.OverflowCount); + } + } + + /// + /// Updates the containers in the panel to make sure they are displaying the correct item + /// based on . + /// + /// + /// This method requires that + the number of + /// materialized containers is not more than . + /// + private void RecycleContainers() + { + var panel = VirtualizingPanel; + var generator = Owner.ItemContainerGenerator; + var selector = Owner.MemberSelector; + var containers = generator.Containers.ToList(); + var itemIndex = FirstIndex; + + foreach (var container in containers) + { + var item = Items.ElementAt(itemIndex); + + if (!object.Equals(container.Item, item)) + { + if (!generator.TryRecycle(itemIndex, itemIndex, item, selector)) + { + throw new NotImplementedException(); + } + } + + ++itemIndex; + } + } + + /// + /// Recycles containers when a move occurs. + /// + /// The delta of the move. + /// + /// If the move is less than a page, then this method moves the containers for the items + /// that are still visible to the correct place, and recyles and moves the others. For + /// example: if there are 20 items and 10 containers visible and the user scrolls 5 + /// items down, then the bottom 5 containers will be moved to the top and the top 5 will + /// be moved to the bottom and recycled to display the newly visible item. Updates + /// and + /// with their new values. + /// + private void RecycleContainersForMove(int delta) + { + var panel = VirtualizingPanel; + var generator = Owner.ItemContainerGenerator; + var selector = Owner.MemberSelector; + var sign = delta < 0 ? -1 : 1; + var count = Math.Min(Math.Abs(delta), panel.Children.Count); + var move = count < panel.Children.Count; + var first = delta < 0 && move ? panel.Children.Count + delta : 0; + var containers = panel.Children.GetRange(first, count).ToList(); + + for (var i = 0; i < count; ++i) + { + var oldItemIndex = FirstIndex + first + i; + var newItemIndex = oldItemIndex + delta + ((panel.Children.Count - count) * sign); + + var item = Items.ElementAt(newItemIndex); + + if (!generator.TryRecycle(oldItemIndex, newItemIndex, item, selector)) + { + throw new NotImplementedException(); + } + } + + if (move) + { + if (delta > 0) + { + panel.Children.MoveRange(first, count, panel.Children.Count); + } + else + { + panel.Children.MoveRange(first, count, 0); + } + } + + FirstIndex += delta; + NextIndex += delta; + } + + /// + /// Recycles containers due to items being removed. + /// + private void RecycleContainersOnRemove() + { + var panel = VirtualizingPanel; + + if (NextIndex <= ItemCount) + { + // Items have been removed but FirstIndex..NextIndex is still a valid range in the + // items, so just recycle the containers to adapt to the new state. + RecycleContainers(); + } + else + { + // Items have been removed and now the range FirstIndex..NextIndex goes out of + // the item bounds. Remove any excess containers, try to scroll up and then recycle + // the containers to make sure they point to the correct item. + var newFirstIndex = Math.Max(0, FirstIndex - (NextIndex - ItemCount)); + var delta = newFirstIndex - FirstIndex; + var newNextIndex = NextIndex + delta; + + if (newNextIndex > ItemCount) + { + RemoveContainers(newNextIndex - ItemCount); + } + + if (delta != 0) + { + RecycleContainersForMove(delta); + } + + RecycleContainers(); + } + } + + /// + /// Removes the specified number of containers from the end of the panel and updates + /// . + /// + /// The number of containers to remove. + private void RemoveContainers(int count) + { + var index = VirtualizingPanel.Children.Count - count; + + VirtualizingPanel.Children.RemoveRange(index, count); + Owner.ItemContainerGenerator.Dematerialize(FirstIndex + index, count); + NextIndex -= count; + } + + /// + /// Scrolls the item with the specified index into view. + /// + /// The item index. + /// The container that was brought into view. + private IControl ScrollIntoView(int index) + { + var panel = VirtualizingPanel; + var generator = Owner.ItemContainerGenerator; + var newOffset = -1.0; + + if (index >= 0 && index < ItemCount) + { + if (index < FirstIndex) + { + newOffset = index; + } + else if (index >= NextIndex) + { + newOffset = index - Math.Ceiling(ViewportValue - 1); + } + else if (OffsetValue + ViewportValue >= ItemCount) + { + newOffset = OffsetValue - 1; + } + + if (newOffset != -1) + { + OffsetValue = newOffset; + } + + var container = generator.ContainerFromIndex(index); + var layoutManager = LayoutManager.Instance; + + // We need to do a layout here because it's possible that the container we moved to + // is only partially visible due to differing item sizes. If the container is only + // partially visible, scroll again. Don't do this if there's no layout manager: + // it means we're running a unit test. + if (layoutManager != null) + { + layoutManager.ExecuteLayoutPass(); + + if (!new Rect(panel.Bounds.Size).Contains(container.Bounds)) + { + OffsetValue += 1; + } + } + + return container; + } + + return null; + } + + /// + /// Ensures an offset value is within the value range. + /// + /// The value. + /// The coerced value. + private double CoerceOffset(double value) + { + var max = Math.Max(ExtentValue - ViewportValue, 0); + return MathUtilities.Clamp(value, 0, max); + } + } +} diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 8c8ab5c0f8..4547c8fd45 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -1,19 +1,29 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. -using System.Collections.Generic; +using System; using System.Collections.Specialized; -using Avalonia.Controls.Generators; -using Avalonia.Controls.Utils; +using Avalonia.Controls.Primitives; using Avalonia.Input; +using static Avalonia.Utilities.MathUtilities; namespace Avalonia.Controls.Presenters { /// /// Displays items inside an . /// - public class ItemsPresenter : ItemsPresenterBase + public class ItemsPresenter : ItemsPresenterBase, ILogicalScrollable { + /// + /// Defines the property. + /// + public static readonly StyledProperty VirtualizationModeProperty = + AvaloniaProperty.Register( + nameof(VirtualizationMode), + defaultValue: ItemVirtualizationMode.Simple); + + private ItemVirtualizer _virtualizer; + /// /// Initializes static members of the class. /// @@ -22,127 +32,101 @@ namespace Avalonia.Controls.Presenters KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue( typeof(ItemsPresenter), KeyboardNavigationMode.Once); + + VirtualizationModeProperty.Changed + .AddClassHandler(x => x.VirtualizationModeChanged); } - /// - protected override void CreatePanel() + /// + /// Gets or sets the virtualization mode for the items. + /// + public ItemVirtualizationMode VirtualizationMode { - base.CreatePanel(); - - if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty)) - { - KeyboardNavigation.SetDirectionalNavigation( - (InputElement)Panel, - KeyboardNavigationMode.Contained); - } - - KeyboardNavigation.SetTabNavigation( - (InputElement)Panel, - KeyboardNavigation.GetTabNavigation(this)); + get { return GetValue(VirtualizationModeProperty); } + set { SetValue(VirtualizationModeProperty, value); } } /// - protected override void ItemsChanged(NotifyCollectionChangedEventArgs e) + bool ILogicalScrollable.IsLogicalScrollEnabled { - var generator = ItemContainerGenerator; + get { return _virtualizer?.IsLogicalScrollEnabled ?? false; } + } - // TODO: Handle Move and Replace etc. - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (e.NewStartingIndex + e.NewItems.Count < Items.Count()) - { - generator.InsertSpace(e.NewStartingIndex, e.NewItems.Count); - } + /// + Size IScrollable.Extent => _virtualizer.Extent; - AddContainers(generator.Materialize(e.NewStartingIndex, e.NewItems, MemberSelector)); - break; + /// + Vector IScrollable.Offset + { + get { return _virtualizer.Offset; } + set { _virtualizer.Offset = CoerceOffset(value); } + } + + /// + Size IScrollable.Viewport => _virtualizer.Viewport; - case NotifyCollectionChangedAction.Remove: - RemoveContainers(generator.RemoveRange(e.OldStartingIndex, e.OldItems.Count)); - break; + /// + Action ILogicalScrollable.InvalidateScroll { get; set; } - case NotifyCollectionChangedAction.Replace: - RemoveContainers(generator.Dematerialize(e.OldStartingIndex, e.OldItems.Count)); - var containers = generator.Materialize(e.NewStartingIndex, e.NewItems, MemberSelector); - AddContainers(containers); + /// + Size ILogicalScrollable.ScrollSize => new Size(0, 1); - var i = e.NewStartingIndex; + /// + Size ILogicalScrollable.PageScrollSize => new Size(0, 1); - foreach (var container in containers) - { - Panel.Children[i++] = container.ContainerControl; - } + /// + bool ILogicalScrollable.BringIntoView(IControl target, Rect targetRect) + { + return false; + } - break; + /// + IControl ILogicalScrollable.GetControlInDirection(NavigationDirection direction, IControl from) + { + return _virtualizer?.GetControlInDirection(direction, from); + } - case NotifyCollectionChangedAction.Move: - // TODO: Implement Move in a more efficient manner. - case NotifyCollectionChangedAction.Reset: - RemoveContainers(generator.Clear()); + public override void ScrollIntoView(object item) + { + _virtualizer?.ScrollIntoView(item); + } - if (Items != null) - { - AddContainers(generator.Materialize(0, Items, MemberSelector)); - } + /// + protected override void PanelCreated(IPanel panel) + { + _virtualizer = ItemVirtualizer.Create(this); + ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); - break; + if (!Panel.IsSet(KeyboardNavigation.DirectionalNavigationProperty)) + { + KeyboardNavigation.SetDirectionalNavigation( + (InputElement)Panel, + KeyboardNavigationMode.Contained); } - InvalidateMeasure(); + KeyboardNavigation.SetTabNavigation( + (InputElement)Panel, + KeyboardNavigation.GetTabNavigation(this)); } - private void AddContainersToPanel(IEnumerable items) + protected override void ItemsChanged(NotifyCollectionChangedEventArgs e) { - foreach (var i in items) - { - if (i.ContainerControl != null) - { - if (i.Index < this.Panel.Children.Count) - { - // HACK: This will insert at the wrong place when there are null items, - // but all of this will need to be rewritten when we implement - // virtualization so hope no-one notices until then :) - this.Panel.Children.Insert(i.Index, i.ContainerControl); - } - else - { - this.Panel.Children.Add(i.ContainerControl); - } - } - } + _virtualizer?.ItemsChanged(Items, e); } - private void AddContainers(IEnumerable items) + private Vector CoerceOffset(Vector value) { - foreach (var i in items) - { - if (i.ContainerControl != null) - { - if (i.Index < this.Panel.Children.Count) - { - // HACK: This will insert at the wrong place when there are null items, - // but all of this will need to be rewritten when we implement - // virtualization so hope no-one notices until then :) - this.Panel.Children.Insert(i.Index, i.ContainerControl); - } - else - { - this.Panel.Children.Add(i.ContainerControl); - } - } - } + var scrollable = (ILogicalScrollable)this; + var maxX = Math.Max(scrollable.Extent.Width - scrollable.Viewport.Width, 0); + var maxY = Math.Max(scrollable.Extent.Height - scrollable.Viewport.Height, 0); + return new Vector(Clamp(value.X, 0, maxX), Clamp(value.Y, 0, maxY)); } - private void RemoveContainers(IEnumerable items) + private void VirtualizationModeChanged(AvaloniaPropertyChangedEventArgs e) { - foreach (var i in items) - { - if (i.ContainerControl != null) - { - this.Panel.Children.Remove(i.ContainerControl); - } - } + _virtualizer?.Dispose(); + _virtualizer = ItemVirtualizer.Create(this); + ((ILogicalScrollable)this).InvalidateScroll?.Invoke(); } } } \ No newline at end of file diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs index 91ffec9c4c..5a56e52029 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs @@ -101,8 +101,7 @@ namespace Avalonia.Controls.Presenters { if (_generator == null) { - var i = TemplatedParent as ItemsControl; - _generator = (i?.ItemContainerGenerator) ?? new ItemContainerGenerator(this); + _generator = CreateItemContainerGenerator(); } return _generator; @@ -164,6 +163,31 @@ namespace Avalonia.Controls.Presenters } } + /// + public virtual void ScrollIntoView(object item) + { + } + + /// + /// Creates the for the control. + /// + /// + /// An or null. + /// + protected virtual IItemContainerGenerator CreateItemContainerGenerator() + { + var i = TemplatedParent as ItemsControl; + var result = i?.ItemContainerGenerator; + + if (result == null) + { + result = new ItemContainerGenerator(this); + result.ItemTemplate = ItemTemplate; + } + + return result; + } + /// protected override Size MeasureOverride(Size availableSize) { @@ -178,11 +202,26 @@ namespace Avalonia.Controls.Presenters return finalSize; } + /// + /// Called when the is created. + /// + /// The panel. + protected virtual void PanelCreated(IPanel panel) + { + } + + /// + /// Called when the items for the presenter change, either because + /// has been set, the items collection has been modified, or the panel has been created. + /// + /// A description of the change. + protected abstract void ItemsChanged(NotifyCollectionChangedEventArgs e); + /// /// Creates the when is called for the first /// time. /// - protected virtual void CreatePanel() + private void CreatePanel() { Panel = ItemsPanel.Build(); Panel.SetValue(TemplatedParentProperty, TemplatedParent); @@ -201,16 +240,11 @@ namespace Avalonia.Controls.Presenters incc.CollectionChanged += ItemsCollectionChanged; } + PanelCreated(Panel); + ItemsChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } - /// - /// Called when the items for the presenter change, either because - /// has been set, or the items collection has been modified. - /// - /// A description of the change. - protected abstract void ItemsChanged(NotifyCollectionChangedEventArgs e); - /// /// Called when the collection changes. /// diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 0077497b10..e0061ed386 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -15,7 +15,7 @@ namespace Avalonia.Controls.Presenters /// /// Presents a scrolling view of content inside a . /// - public class ScrollContentPresenter : ContentPresenter, IPresenter + public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable { /// /// Defines the property. @@ -50,7 +50,7 @@ namespace Avalonia.Controls.Presenters private Size _extent; private Size _measuredExtent; private Vector _offset; - private IDisposable _scrollableSubscription; + private IDisposable _logicalScrollSubscription; private Size _viewport; /// @@ -59,6 +59,7 @@ namespace Avalonia.Controls.Presenters static ScrollContentPresenter() { ClipToBoundsProperty.OverrideDefaultValue(typeof(ScrollContentPresenter), true); + ChildProperty.Changed.AddClassHandler(x => x.ChildChanged); AffectsArrange(OffsetProperty); } @@ -69,7 +70,7 @@ namespace Avalonia.Controls.Presenters { AddHandler(RequestBringIntoViewEvent, BringIntoViewRequested); - this.GetObservable(ChildProperty).Subscribe(ChildChanged); + this.GetObservable(ChildProperty).Subscribe(UpdateScrollableSubscription); } /// @@ -117,6 +118,14 @@ namespace Avalonia.Controls.Presenters return false; } + var scrollable = Child as ILogicalScrollable; + var control = target as IControl; + + if (scrollable?.IsLogicalScrollEnabled == true && control != null) + { + return scrollable.BringIntoView(control, targetRect); + } + var transform = target.TransformToVisual(Child); if (transform == null) @@ -169,7 +178,7 @@ namespace Avalonia.Controls.Presenters { var measureSize = availableSize; - if (_scrollableSubscription == null) + if (_logicalScrollSubscription == null) { measureSize = new Size(double.PositiveInfinity, double.PositiveInfinity); @@ -194,21 +203,25 @@ namespace Avalonia.Controls.Presenters protected override Size ArrangeOverride(Size finalSize) { var child = this.GetVisualChildren().SingleOrDefault() as ILayoutable; - var offset = default(Vector); + var logicalScroll = _logicalScrollSubscription != null; - if (_scrollableSubscription == null) + if (!logicalScroll) { Viewport = finalSize; Extent = _measuredExtent; - offset = Offset; - } - if (child != null) - { - var size = new Size( + if (child != null) + { + var size = new Size( Math.Max(finalSize.Width, child.DesiredSize.Width), Math.Max(finalSize.Height, child.DesiredSize.Height)); - child.Arrange(new Rect((Point)(-offset), size)); + child.Arrange(new Rect((Point)(-Offset), size)); + return finalSize; + } + } + else if (child != null) + { + child.Arrange(new Rect(finalSize)); return finalSize; } @@ -220,9 +233,9 @@ namespace Avalonia.Controls.Presenters { if (Extent.Height > Viewport.Height) { - var scrollable = Child as IScrollable; + var scrollable = Child as ILogicalScrollable; - if (scrollable != null) + if (scrollable?.IsLogicalScrollEnabled == true) { var y = Offset.Y + (-e.Delta.Y * scrollable.ScrollSize.Height); y = Math.Max(y, 0); @@ -246,28 +259,53 @@ namespace Avalonia.Controls.Presenters e.Handled = BringDescendentIntoView(e.TargetObject, e.TargetRect); } - private void ChildChanged(IControl child) + private void ChildChanged(AvaloniaPropertyChangedEventArgs e) { - var scrollable = child as IScrollable; + UpdateScrollableSubscription((IControl)e.NewValue); - _scrollableSubscription?.Dispose(); - _scrollableSubscription = null; + if (e.OldValue != null) + { + Offset = default(Vector); + } + } + + private void UpdateScrollableSubscription(IControl child) + { + var scrollable = child as ILogicalScrollable; + + _logicalScrollSubscription?.Dispose(); + _logicalScrollSubscription = null; if (scrollable != null) { scrollable.InvalidateScroll = () => UpdateFromScrollable(scrollable); - _scrollableSubscription = new CompositeDisposable( - this.GetObservable(OffsetProperty).Skip(1).Subscribe(x => scrollable.Offset = x), - Disposable.Create(() => scrollable.InvalidateScroll = null)); - UpdateFromScrollable(scrollable); + + if (scrollable.IsLogicalScrollEnabled == true) + { + _logicalScrollSubscription = new CompositeDisposable( + this.GetObservable(OffsetProperty).Skip(1).Subscribe(x => scrollable.Offset = x), + Disposable.Create(() => scrollable.InvalidateScroll = null)); + UpdateFromScrollable(scrollable); + } } } - private void UpdateFromScrollable(IScrollable scrollable) + private void UpdateFromScrollable(ILogicalScrollable scrollable) { - Viewport = scrollable.Viewport; - Extent = scrollable.Extent; - Offset = scrollable.Offset; + var logicalScroll = _logicalScrollSubscription != null; + + if (logicalScroll != scrollable.IsLogicalScrollEnabled) + { + UpdateScrollableSubscription(Child); + Offset = default(Vector); + InvalidateMeasure(); + } + else if (scrollable.IsLogicalScrollEnabled) + { + Viewport = scrollable.Viewport; + Extent = scrollable.Extent; + Offset = scrollable.Offset; + } } } } diff --git a/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs b/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs new file mode 100644 index 0000000000..6c8f463a96 --- /dev/null +++ b/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs @@ -0,0 +1,69 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Avalonia.Input; + +namespace Avalonia.Controls.Primitives +{ + /// + /// Interface implemented by controls that handle their own scrolling when placed inside a + /// . + /// + /// + /// Controls that implement this interface, when placed inside a + /// can override the physical scrolling behavior of the scroll viewer with logical scrolling. + /// Physical scrolling means that the scroll viewer is a simple viewport onto a larger canvas + /// whereas logical scrolling means that the scrolling is handled by the child control itself + /// and it can choose to do handle the scroll information as it sees fit. + /// + public interface ILogicalScrollable : IScrollable + { + /// + /// Gets a value indicating whether logical scrolling is enabled on the control. + /// + bool IsLogicalScrollEnabled { get; } + + /// + /// Gets or sets the scroll invalidation method. + /// + /// + /// + /// This method notifies the attached of a change in + /// the , or + /// properties. + /// + /// + /// This property is set by the parent when the + /// is placed inside it. + /// + /// + Action InvalidateScroll { get; set; } + + /// + /// Gets the size to scroll by, in logical units. + /// + Size ScrollSize { get; } + + /// + /// Gets the size to page by, in logical units. + /// + Size PageScrollSize { get; } + + /// + /// Attempts to bring a portion of the target visual into view by scrolling the content. + /// + /// The target visual. + /// The portion of the target visual to bring into view. + /// True if the scroll offset was changed; otherwise false. + bool BringIntoView(IControl target, Rect targetRect); + + /// + /// Gets the next control in the specified direction. + /// + /// The movement direction. + /// The control from which movement begins. + /// The control. + IControl GetControlInDirection(NavigationDirection direction, IControl from); + } +} diff --git a/src/Avalonia.Controls/Primitives/IScrollable.cs b/src/Avalonia.Controls/Primitives/IScrollable.cs deleted file mode 100644 index a5165fcfb4..0000000000 --- a/src/Avalonia.Controls/Primitives/IScrollable.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; - -namespace Avalonia.Controls.Primitives -{ - /// - /// Interface implemented by controls that handle their own scrolling when placed inside a - /// . - /// - public interface IScrollable - { - /// - /// Gets or sets the scroll invalidation method. - /// - /// - /// - /// This method notifies the attached of a change in - /// the , or properties. - /// - /// - /// This property is set by the parent when the - /// is placed inside it. - /// - /// - Action InvalidateScroll { get; set; } - - /// - /// Gets the extent of the scrollable content, in logical units - /// - Size Extent { get; } - - /// - /// Gets or sets the current scroll offset, in logical units. - /// - Vector Offset { get; set; } - - /// - /// Gets the size of the viewport, in logical units. - /// - Size Viewport { get; } - - /// - /// Gets the size to scroll by, in logical units. - /// - Size ScrollSize { get; } - - /// - /// Gets the size to page by, in logical units. - /// - Size PageScrollSize { get; } - } -} diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 018507bbc8..27f3407fd9 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -280,6 +280,12 @@ namespace Avalonia.Controls.Primitives } } + /// + /// Scrolls the specified item into view. + /// + /// The item. + public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(item); + /// /// Tries to get the container that was the source of an event. /// @@ -394,6 +400,19 @@ namespace Avalonia.Controls.Primitives } } + protected override void OnContainersRecycled(ItemContainerEventArgs e) + { + foreach (var i in e.Containers) + { + if (i.ContainerControl != null && i.Item != null) + { + MarkContainerSelected( + i.ContainerControl, + SelectedItems.Contains(i.Item)); + } + } + } + /// protected override void OnDataContextChanging() { @@ -710,6 +729,12 @@ namespace Avalonia.Controls.Primitives { case NotifyCollectionChangedAction.Add: SelectedItemsAdded(e.NewItems.Cast().ToList()); + + if (AutoScrollToSelectedItem) + { + ScrollIntoView(e.NewItems[0]); + } + added = e.NewItems; break; diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 4f5cf304b2..e9d1e56b3a 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -12,7 +12,7 @@ namespace Avalonia.Controls /// /// A control scrolls its content if the content is bigger than the space available. /// - public class ScrollViewer : ContentControl + public class ScrollViewer : ContentControl, IScrollable { /// /// Defines the property. diff --git a/src/Avalonia.Controls/StackPanel.cs b/src/Avalonia.Controls/StackPanel.cs index 1a032d4def..26a755e5f1 100644 --- a/src/Avalonia.Controls/StackPanel.cs +++ b/src/Avalonia.Controls/StackPanel.cs @@ -72,37 +72,52 @@ namespace Avalonia.Controls /// The movement direction. /// The control from which movement begins. /// The control. - IInputElement INavigableContainer.GetControl(FocusNavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) + { + var fromControl = from as IControl; + return (fromControl != null) ? GetControlInDirection(direction, fromControl) : null; + } + + /// + /// Gets the next control in the specified direction. + /// + /// The movement direction. + /// The control from which movement begins. + /// The control. + protected virtual IInputElement GetControlInDirection(NavigationDirection direction, IControl from) { var horiz = Orientation == Orientation.Horizontal; int index = Children.IndexOf((IControl)from); switch (direction) { - case FocusNavigationDirection.First: + case NavigationDirection.First: index = 0; break; - case FocusNavigationDirection.Last: + case NavigationDirection.Last: index = Children.Count - 1; break; - case FocusNavigationDirection.Next: + case NavigationDirection.Next: ++index; break; - case FocusNavigationDirection.Previous: + case NavigationDirection.Previous: --index; break; - case FocusNavigationDirection.Left: + case NavigationDirection.Left: index = horiz ? index - 1 : -1; break; - case FocusNavigationDirection.Right: + case NavigationDirection.Right: index = horiz ? index + 1 : -1; break; - case FocusNavigationDirection.Up: + case NavigationDirection.Up: index = horiz ? -1 : index - 1; break; - case FocusNavigationDirection.Down: + case NavigationDirection.Down: index = horiz ? -1 : index + 1; break; + default: + index = -1; + break; } if (index >= 0 && index < Children.Count) @@ -181,6 +196,7 @@ namespace Avalonia.Controls /// The space taken. protected override Size ArrangeOverride(Size finalSize) { + var orientation = Orientation; double arrangedWidth = finalSize.Width; double arrangedHeight = finalSize.Height; double gap = Gap; @@ -199,11 +215,11 @@ namespace Avalonia.Controls double childWidth = child.DesiredSize.Width; double childHeight = child.DesiredSize.Height; - if (Orientation == Orientation.Vertical) + if (orientation == Orientation.Vertical) { double width = Math.Max(childWidth, arrangedWidth); Rect childFinal = new Rect(0, arrangedHeight, width, childHeight); - child.Arrange(childFinal); + ArrangeChild(child, childFinal, finalSize, orientation); arrangedWidth = Math.Max(arrangedWidth, childWidth); arrangedHeight += childHeight + gap; } @@ -211,13 +227,13 @@ namespace Avalonia.Controls { double height = Math.Max(childHeight, arrangedHeight); Rect childFinal = new Rect(arrangedWidth, 0, childWidth, height); - child.Arrange(childFinal); + ArrangeChild(child, childFinal, finalSize, orientation); arrangedWidth += childWidth + gap; arrangedHeight = Math.Max(arrangedHeight, childHeight); } } - if (Orientation == Orientation.Vertical) + if (orientation == Orientation.Vertical) { arrangedHeight = Math.Max(arrangedHeight - gap, finalSize.Height); } @@ -228,5 +244,14 @@ namespace Avalonia.Controls return new Size(arrangedWidth, arrangedHeight); } + + internal virtual void ArrangeChild( + IControl child, + Rect rect, + Size panelSize, + Orientation orientation) + { + child.Arrange(rect); + } } } \ No newline at end of file diff --git a/src/Avalonia.Controls/Templates/DataTemplateExtensions.cs b/src/Avalonia.Controls/Templates/DataTemplateExtensions.cs index 9eff4243b1..df25733524 100644 --- a/src/Avalonia.Controls/Templates/DataTemplateExtensions.cs +++ b/src/Avalonia.Controls/Templates/DataTemplateExtensions.cs @@ -11,54 +11,6 @@ namespace Avalonia.Controls.Templates /// public static class DataTemplateExtensions { - /// - /// Materializes a piece of data based on a data template. - /// - /// The control materializing the data template. - /// The data. - /// - /// An optional primary template that can will be tried before the - /// in the tree are searched. - /// - /// The data materialized as a control. - public static IControl MaterializeDataTemplate( - this IControl control, - object data, - IDataTemplate primary = null) - { - if (data == null) - { - return null; - } - else - { - var asControl = data as IControl; - - if (asControl != null) - { - return asControl; - } - else - { - IDataTemplate template = control.FindDataTemplate(data, primary); - IControl result; - - if (template != null) - { - result = template.Build(data); - } - else - { - result = FuncDataTemplate.Default.Build(data); - } - - NameScope.SetNameScope((Control)result, new NameScope()); - - return result; - } - } - } - /// /// Find a data template that matches a piece of data. /// diff --git a/src/Avalonia.Controls/Templates/FuncDataTemplate.cs b/src/Avalonia.Controls/Templates/FuncDataTemplate.cs index 8ce3f69f36..1d90fcd01e 100644 --- a/src/Avalonia.Controls/Templates/FuncDataTemplate.cs +++ b/src/Avalonia.Controls/Templates/FuncDataTemplate.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.Reactive.Linq; using System.Reflection; namespace Avalonia.Controls.Templates @@ -12,10 +13,26 @@ namespace Avalonia.Controls.Templates public class FuncDataTemplate : FuncTemplate, IDataTemplate { /// - /// The default data template used in the case where not matching data template is found. + /// The default data template used in the case where no matching data template is found. /// public static readonly FuncDataTemplate Default = - new FuncDataTemplate(typeof(object), o => (o != null) ? new TextBlock { Text = o.ToString() } : null); + new FuncDataTemplate( + data => + { + if (data != null) + { + var result = new TextBlock(); + result.Bind( + TextBlock.TextProperty, + result.GetObservable(Control.DataContextProperty).Select(x => x?.ToString())); + return result; + } + else + { + return null; + } + }, + true); /// /// The implementation of the method. @@ -29,8 +46,12 @@ namespace Avalonia.Controls.Templates /// /// A function which when passed an object of returns a control. /// - public FuncDataTemplate(Type type, Func build) - : this(o => IsInstance(o, type), build) + /// Whether the control can be recycled. + public FuncDataTemplate( + Type type, + Func build, + bool supportsRecycling = false) + : this(o => IsInstance(o, type), build, supportsRecycling) { } @@ -43,14 +64,22 @@ namespace Avalonia.Controls.Templates /// /// A function which returns a control for matching data. /// - public FuncDataTemplate(Func match, Func build) + /// Whether the control can be recycled. + public FuncDataTemplate( + Func match, + Func build, + bool supportsRecycling = false) : base(build) { Contract.Requires(match != null); _match = match; + SupportsRecycling = supportsRecycling; } + /// + public bool SupportsRecycling { get; } + /// /// Checks to see if this data template matches the specified data. /// diff --git a/src/Avalonia.Controls/Templates/FuncDataTemplate`1.cs b/src/Avalonia.Controls/Templates/FuncDataTemplate`1.cs index cb2d064605..7154c0e558 100644 --- a/src/Avalonia.Controls/Templates/FuncDataTemplate`1.cs +++ b/src/Avalonia.Controls/Templates/FuncDataTemplate`1.cs @@ -17,8 +17,9 @@ namespace Avalonia.Controls.Templates /// /// A function which when passed an object of returns a control. /// - public FuncDataTemplate(Func build) - : base(typeof(T), CastBuild(build)) + /// Whether the control can be recycled. + public FuncDataTemplate(Func build, bool supportsRecycling = false) + : base(typeof(T), CastBuild(build), supportsRecycling) { } @@ -31,8 +32,12 @@ namespace Avalonia.Controls.Templates /// /// A function which when passed an object of returns a control. /// - public FuncDataTemplate(Func match, Func build) - : base(CastMatch(match), CastBuild(build)) + /// Whether the control can be recycled. + public FuncDataTemplate( + Func match, + Func build, + bool supportsRecycling = false) + : base(CastMatch(match), CastBuild(build), supportsRecycling) { } diff --git a/src/Avalonia.Controls/Templates/IDataTemplate.cs b/src/Avalonia.Controls/Templates/IDataTemplate.cs index ee490da4de..f9c97e55f3 100644 --- a/src/Avalonia.Controls/Templates/IDataTemplate.cs +++ b/src/Avalonia.Controls/Templates/IDataTemplate.cs @@ -8,6 +8,12 @@ namespace Avalonia.Controls.Templates /// public interface IDataTemplate : ITemplate { + /// + /// Gets a value indicating whether the data template supports recycling of the generated + /// control. + /// + bool SupportsRecycling { get; } + /// /// Checks to see if this data template matches the specified data. /// diff --git a/src/Avalonia.Controls/Utils/IEnumerableUtils.cs b/src/Avalonia.Controls/Utils/IEnumerableUtils.cs index 1e05c23e5a..ad95b0269a 100644 --- a/src/Avalonia.Controls/Utils/IEnumerableUtils.cs +++ b/src/Avalonia.Controls/Utils/IEnumerableUtils.cs @@ -17,17 +17,22 @@ namespace Avalonia.Controls.Utils public static int Count(this IEnumerable items) { - Contract.Requires(items != null); - - var collection = items as ICollection; - - if (collection != null) + if (items != null) { - return collection.Count; + var collection = items as ICollection; + + if (collection != null) + { + return collection.Count; + } + else + { + return Enumerable.Count(items.Cast()); + } } else { - return Enumerable.Count(items.Cast()); + return 0; } } diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs new file mode 100644 index 0000000000..46a17d601f --- /dev/null +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -0,0 +1,229 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Specialized; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; + +namespace Avalonia.Controls +{ + public class VirtualizingStackPanel : StackPanel, IVirtualizingPanel + { + private Size _availableSpace; + private double _takenSpace; + private int _canBeRemoved; + private double _averageItemSize; + private int _averageCount; + private double _pixelOffset; + + bool IVirtualizingPanel.IsFull + { + get + { + return Orientation == Orientation.Horizontal ? + _takenSpace >= _availableSpace.Width : + _takenSpace >= _availableSpace.Height; + } + } + + IVirtualizingController IVirtualizingPanel.Controller { get; set; } + int IVirtualizingPanel.OverflowCount => _canBeRemoved; + Orientation IVirtualizingPanel.ScrollDirection => Orientation; + double IVirtualizingPanel.AverageItemSize => _averageItemSize; + + double IVirtualizingPanel.PixelOverflow + { + get + { + var bounds = Orientation == Orientation.Horizontal ? + _availableSpace.Width : _availableSpace.Height; + return Math.Max(0, _takenSpace - bounds); + } + } + + double IVirtualizingPanel.PixelOffset + { + get { return _pixelOffset; } + + set + { + if (_pixelOffset != value) + { + _pixelOffset = value; + InvalidateArrange(); + } + } + } + + private IVirtualizingController Controller => ((IVirtualizingPanel)this).Controller; + + protected override Size MeasureOverride(Size availableSize) + { + if (availableSize != ((ILayoutable)this).PreviousMeasure) + { + // TODO: We need to put a reasonable limit on this, probably based on the max + // window size. + _availableSpace = availableSize; + Controller?.UpdateControls(); + } + + return base.MeasureOverride(availableSize); + } + + protected override Size ArrangeOverride(Size finalSize) + { + _availableSpace = finalSize; + _canBeRemoved = 0; + _takenSpace = 0; + _averageItemSize = 0; + _averageCount = 0; + var result = base.ArrangeOverride(finalSize); + _takenSpace += _pixelOffset; + Controller?.UpdateControls(); + return result; + } + + protected override void ChildrenChanged(object sender, NotifyCollectionChangedEventArgs e) + { + base.ChildrenChanged(sender, e); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (IControl control in e.NewItems) + { + UpdateAdd(control); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (IControl control in e.OldItems) + { + UpdateRemove(control); + } + + break; + } + } + + protected override IInputElement GetControlInDirection(NavigationDirection direction, IControl from) + { + var logicalScrollable = Parent as ILogicalScrollable; + var fromControl = from as IControl; + + if (logicalScrollable?.IsLogicalScrollEnabled == true && fromControl != null) + { + return logicalScrollable.GetControlInDirection(direction, fromControl); + } + else + { + return base.GetControlInDirection(direction, from); + } + } + + internal override void ArrangeChild( + IControl child, + Rect rect, + Size panelSize, + Orientation orientation) + { + if (orientation == Orientation.Vertical) + { + rect = new Rect(rect.X, rect.Y - _pixelOffset, rect.Width, rect.Height); + child.Arrange(rect); + + if (rect.Y >= _availableSpace.Height) + { + ++_canBeRemoved; + } + + if (rect.Bottom >= _takenSpace) + { + _takenSpace = rect.Bottom; + } + + AddToAverageItemSize(rect.Height); + } + else + { + rect = new Rect(rect.X - _pixelOffset, rect.Y, rect.Width, rect.Height); + child.Arrange(rect); + + if (rect.X >= _availableSpace.Width) + { + ++_canBeRemoved; + } + + if (rect.Right >= _takenSpace) + { + _takenSpace = rect.Right; + } + + AddToAverageItemSize(rect.Width); + } + } + + private void UpdateAdd(IControl child) + { + var bounds = Bounds; + var gap = Gap; + + child.Measure(_availableSpace); + ++_averageCount; + + if (Orientation == Orientation.Vertical) + { + var height = child.DesiredSize.Height; + _takenSpace += height + gap; + AddToAverageItemSize(height); + } + else + { + var width = child.DesiredSize.Width; + _takenSpace += width + gap; + AddToAverageItemSize(width); + } + } + + private void UpdateRemove(IControl child) + { + var bounds = Bounds; + var gap = Gap; + + if (Orientation == Orientation.Vertical) + { + var height = child.DesiredSize.Height; + _takenSpace -= height + gap; + RemoveFromAverageItemSize(height); + } + else + { + var width = child.DesiredSize.Width; + _takenSpace -= width + gap; + RemoveFromAverageItemSize(width); + } + + if (_canBeRemoved > 0) + { + --_canBeRemoved; + } + } + + private void AddToAverageItemSize(double value) + { + ++_averageCount; + _averageItemSize += (value - _averageItemSize) / _averageCount; + } + + private void RemoveFromAverageItemSize(double value) + { + _averageItemSize = ((_averageItemSize * _averageCount) - value) / (_averageCount - 1); + --_averageCount; + } + } +} diff --git a/src/Avalonia.Controls/WrapPanel.cs b/src/Avalonia.Controls/WrapPanel.cs index d0f1e93c56..9d3a5b6536 100644 --- a/src/Avalonia.Controls/WrapPanel.cs +++ b/src/Avalonia.Controls/WrapPanel.cs @@ -48,35 +48,35 @@ namespace Avalonia.Controls /// The movement direction. /// The control from which movement begins. /// The control. - IInputElement INavigableContainer.GetControl(FocusNavigationDirection direction, IInputElement from) + IInputElement INavigableContainer.GetControl(NavigationDirection direction, IInputElement from) { var horiz = Orientation == Orientation.Horizontal; int index = Children.IndexOf((IControl)from); switch (direction) { - case FocusNavigationDirection.First: + case NavigationDirection.First: index = 0; break; - case FocusNavigationDirection.Last: + case NavigationDirection.Last: index = Children.Count - 1; break; - case FocusNavigationDirection.Next: + case NavigationDirection.Next: ++index; break; - case FocusNavigationDirection.Previous: + case NavigationDirection.Previous: --index; break; - case FocusNavigationDirection.Left: + case NavigationDirection.Left: index = horiz ? index - 1 : -1; break; - case FocusNavigationDirection.Right: + case NavigationDirection.Right: index = horiz ? index + 1 : -1; break; - case FocusNavigationDirection.Up: + case NavigationDirection.Up: index = horiz ? -1 : index - 1; break; - case FocusNavigationDirection.Down: + case NavigationDirection.Down: index = horiz ? -1 : index + 1; break; } diff --git a/src/Avalonia.Diagnostics/ViewLocator.cs b/src/Avalonia.Diagnostics/ViewLocator.cs index ddaa11b092..b107338aec 100644 --- a/src/Avalonia.Diagnostics/ViewLocator.cs +++ b/src/Avalonia.Diagnostics/ViewLocator.cs @@ -9,6 +9,8 @@ namespace Avalonia.Diagnostics { public class ViewLocator : IDataTemplate { + public bool SupportsRecycling => false; + public IControl Build(object data) { var name = data.GetType().FullName.Replace("ViewModel", "View"); diff --git a/src/Avalonia.Input/Avalonia.Input.csproj b/src/Avalonia.Input/Avalonia.Input.csproj index 569e1bb2cc..3722c80def 100644 --- a/src/Avalonia.Input/Avalonia.Input.csproj +++ b/src/Avalonia.Input/Avalonia.Input.csproj @@ -1,4 +1,4 @@ - + @@ -79,7 +79,7 @@ - + diff --git a/src/Avalonia.Input/IKeyboardNavigationHandler.cs b/src/Avalonia.Input/IKeyboardNavigationHandler.cs index 14e2144da9..db3a3cf114 100644 --- a/src/Avalonia.Input/IKeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/IKeyboardNavigationHandler.cs @@ -25,7 +25,7 @@ namespace Avalonia.Input /// Any input modifiers active at the time of focus. void Move( IInputElement element, - FocusNavigationDirection direction, + NavigationDirection direction, InputModifiers modifiers = InputModifiers.None); } } \ No newline at end of file diff --git a/src/Avalonia.Input/INavigableContainer.cs b/src/Avalonia.Input/INavigableContainer.cs index 48f85819c0..13d734bd0b 100644 --- a/src/Avalonia.Input/INavigableContainer.cs +++ b/src/Avalonia.Input/INavigableContainer.cs @@ -14,6 +14,6 @@ namespace Avalonia.Input /// The movement direction. /// The control from which movement begins. /// The control. - IInputElement GetControl(FocusNavigationDirection direction, IInputElement from); + IInputElement GetControl(NavigationDirection direction, IInputElement from); } } diff --git a/src/Avalonia.Input/KeyboardNavigationHandler.cs b/src/Avalonia.Input/KeyboardNavigationHandler.cs index 9991f7ec10..57da49fa03 100644 --- a/src/Avalonia.Input/KeyboardNavigationHandler.cs +++ b/src/Avalonia.Input/KeyboardNavigationHandler.cs @@ -48,11 +48,11 @@ namespace Avalonia.Input /// public static IInputElement GetNext( IInputElement element, - FocusNavigationDirection direction) + NavigationDirection direction) { Contract.Requires(element != null); - if (direction == FocusNavigationDirection.Next || direction == FocusNavigationDirection.Previous) + if (direction == NavigationDirection.Next || direction == NavigationDirection.Previous) { return TabNavigation.GetNextInTabOrder(element, direction); } @@ -70,7 +70,7 @@ namespace Avalonia.Input /// Any input modifiers active at the time of focus. public void Move( IInputElement element, - FocusNavigationDirection direction, + NavigationDirection direction, InputModifiers modifiers = InputModifiers.None) { Contract.Requires(element != null); @@ -79,8 +79,8 @@ namespace Avalonia.Input if (next != null) { - var method = direction == FocusNavigationDirection.Next || - direction == FocusNavigationDirection.Previous ? + var method = direction == NavigationDirection.Next || + direction == NavigationDirection.Previous ? NavigationMethod.Tab : NavigationMethod.Directional; FocusManager.Instance.Focus(next, method, modifiers); } @@ -97,25 +97,37 @@ namespace Avalonia.Input if (current != null) { - FocusNavigationDirection? direction = null; + NavigationDirection? direction = null; switch (e.Key) { case Key.Tab: direction = (e.Modifiers & InputModifiers.Shift) == 0 ? - FocusNavigationDirection.Next : FocusNavigationDirection.Previous; + NavigationDirection.Next : NavigationDirection.Previous; break; case Key.Up: - direction = FocusNavigationDirection.Up; + direction = NavigationDirection.Up; break; case Key.Down: - direction = FocusNavigationDirection.Down; + direction = NavigationDirection.Down; break; case Key.Left: - direction = FocusNavigationDirection.Left; + direction = NavigationDirection.Left; break; case Key.Right: - direction = FocusNavigationDirection.Right; + direction = NavigationDirection.Right; + break; + case Key.PageUp: + direction = NavigationDirection.PageUp; + break; + case Key.PageDown: + direction = NavigationDirection.PageDown; + break; + case Key.Home: + direction = NavigationDirection.First; + break; + case Key.End: + direction = NavigationDirection.Last; break; } diff --git a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs index 4d6e706516..6d4da5d976 100644 --- a/src/Avalonia.Input/Navigation/DirectionalNavigation.cs +++ b/src/Avalonia.Input/Navigation/DirectionalNavigation.cs @@ -24,18 +24,17 @@ namespace Avalonia.Input.Navigation /// public static IInputElement GetNext( IInputElement element, - FocusNavigationDirection direction) + NavigationDirection direction) { Contract.Requires(element != null); Contract.Requires( - direction != FocusNavigationDirection.Next && - direction != FocusNavigationDirection.Previous); + direction != NavigationDirection.Next && + direction != NavigationDirection.Previous); var container = element.GetVisualParent(); if (container != null) { - var isForward = IsForward(direction); var mode = KeyboardNavigation.GetDirectionalNavigation((InputElement)container); switch (mode) @@ -63,12 +62,12 @@ namespace Avalonia.Input.Navigation /// /// The direction. /// True if the direction is forward. - private static bool IsForward(FocusNavigationDirection direction) + private static bool IsForward(NavigationDirection direction) { - return direction == FocusNavigationDirection.Next || - direction == FocusNavigationDirection.Last || - direction == FocusNavigationDirection.Right || - direction == FocusNavigationDirection.Down; + return direction == NavigationDirection.Next || + direction == NavigationDirection.Last || + direction == NavigationDirection.Right || + direction == NavigationDirection.Down; } /// @@ -77,7 +76,7 @@ namespace Avalonia.Input.Navigation /// The element. /// The direction to search. /// The element or null if not found.## - private static IInputElement GetFocusableDescendent(IInputElement container, FocusNavigationDirection direction) + private static IInputElement GetFocusableDescendent(IInputElement container, NavigationDirection direction) { return IsForward(direction) ? GetFocusableDescendents(container).FirstOrDefault() : @@ -121,9 +120,9 @@ namespace Avalonia.Input.Navigation private static IInputElement GetNextInContainer( IInputElement element, IInputElement container, - FocusNavigationDirection direction) + NavigationDirection direction) { - if (direction == FocusNavigationDirection.Down) + if (direction == NavigationDirection.Down) { var descendent = GetFocusableDescendents(element).FirstOrDefault(); @@ -156,7 +155,7 @@ namespace Avalonia.Input.Navigation element = null; } - if (element != null && direction == FocusNavigationDirection.Up) + if (element != null && direction == NavigationDirection.Up) { var descendent = GetFocusableDescendents(element).LastOrDefault(); @@ -180,7 +179,7 @@ namespace Avalonia.Input.Navigation /// The first element, or null if there are no more elements. private static IInputElement GetFirstInNextContainer( IInputElement container, - FocusNavigationDirection direction) + NavigationDirection direction) { var parent = container.GetVisualParent(); var isForward = IsForward(direction); diff --git a/src/Avalonia.Input/Navigation/TabNavigation.cs b/src/Avalonia.Input/Navigation/TabNavigation.cs index 90502cdbf9..bc3826d90e 100644 --- a/src/Avalonia.Input/Navigation/TabNavigation.cs +++ b/src/Avalonia.Input/Navigation/TabNavigation.cs @@ -24,12 +24,12 @@ namespace Avalonia.Input.Navigation /// public static IInputElement GetNextInTabOrder( IInputElement element, - FocusNavigationDirection direction) + NavigationDirection direction) { Contract.Requires(element != null); Contract.Requires( - direction == FocusNavigationDirection.Next || - direction == FocusNavigationDirection.Previous); + direction == NavigationDirection.Next || + direction == NavigationDirection.Previous); var container = element.GetVisualParent(); @@ -63,9 +63,9 @@ namespace Avalonia.Input.Navigation /// The element. /// The direction to search. /// The element or null if not found.## - private static IInputElement GetFocusableDescendent(IInputElement container, FocusNavigationDirection direction) + private static IInputElement GetFocusableDescendent(IInputElement container, NavigationDirection direction) { - return direction == FocusNavigationDirection.Next ? + return direction == NavigationDirection.Next ? GetFocusableDescendents(container).FirstOrDefault() : GetFocusableDescendents(container).LastOrDefault(); } @@ -128,9 +128,9 @@ namespace Avalonia.Input.Navigation private static IInputElement GetNextInContainer( IInputElement element, IInputElement container, - FocusNavigationDirection direction) + NavigationDirection direction) { - if (direction == FocusNavigationDirection.Next) + if (direction == NavigationDirection.Next) { var descendent = GetFocusableDescendents(element).FirstOrDefault(); @@ -165,7 +165,7 @@ namespace Avalonia.Input.Navigation element = null; } - if (element != null && direction == FocusNavigationDirection.Previous) + if (element != null && direction == NavigationDirection.Previous) { var descendent = GetFocusableDescendents(element).LastOrDefault(); @@ -189,14 +189,14 @@ namespace Avalonia.Input.Navigation /// The first element, or null if there are no more elements. private static IInputElement GetFirstInNextContainer( IInputElement container, - FocusNavigationDirection direction) + NavigationDirection direction) { var parent = container.GetVisualParent(); IInputElement next = null; if (parent != null) { - if (direction == FocusNavigationDirection.Previous && parent.CanFocus()) + if (direction == NavigationDirection.Previous && parent.CanFocus()) { return parent; } @@ -204,7 +204,7 @@ namespace Avalonia.Input.Navigation var siblings = parent.GetVisualChildren() .OfType() .Where(FocusExtensions.CanFocusDescendents); - var sibling = direction == FocusNavigationDirection.Next ? + var sibling = direction == NavigationDirection.Next ? siblings.SkipWhile(x => x != container).Skip(1).FirstOrDefault() : siblings.TakeWhile(x => x != container).LastOrDefault(); @@ -216,7 +216,7 @@ namespace Avalonia.Input.Navigation } else { - next = direction == FocusNavigationDirection.Next ? + next = direction == NavigationDirection.Next ? GetFocusableDescendents(sibling).FirstOrDefault() : GetFocusableDescendents(sibling).LastOrDefault(); } @@ -229,7 +229,7 @@ namespace Avalonia.Input.Navigation } else { - next = direction == FocusNavigationDirection.Next ? + next = direction == NavigationDirection.Next ? GetFocusableDescendents(container).FirstOrDefault() : GetFocusableDescendents(container).LastOrDefault(); } diff --git a/src/Avalonia.Input/FocusNavigationDirection.cs b/src/Avalonia.Input/NavigationDirection.cs similarity index 78% rename from src/Avalonia.Input/FocusNavigationDirection.cs rename to src/Avalonia.Input/NavigationDirection.cs index c8f670a13c..fbaa7e74c7 100644 --- a/src/Avalonia.Input/FocusNavigationDirection.cs +++ b/src/Avalonia.Input/NavigationDirection.cs @@ -4,9 +4,9 @@ namespace Avalonia.Input { /// - /// Describes how focus should be moved. + /// Describes how focus should be moved by directional or tab keys. /// - public enum FocusNavigationDirection + public enum NavigationDirection { /// /// Move the focus to the next control in the tab order. @@ -47,5 +47,15 @@ namespace Avalonia.Input /// Move the focus down. /// Down, + + /// + /// Move the focus up a page. + /// + PageUp, + + /// + /// Move the focus down a page. + /// + PageDown, } } diff --git a/src/Avalonia.Layout/LayoutManager.cs b/src/Avalonia.Layout/LayoutManager.cs index 42ed45b0bb..85daff28b9 100644 --- a/src/Avalonia.Layout/LayoutManager.cs +++ b/src/Avalonia.Layout/LayoutManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Avalonia.Logging; using Avalonia.Threading; @@ -13,8 +14,8 @@ namespace Avalonia.Layout /// public class LayoutManager : ILayoutManager { - private readonly Queue _toMeasure = new Queue(); - private readonly Queue _toArrange = new Queue(); + private readonly HashSet _toMeasure = new HashSet(); + private readonly HashSet _toArrange = new HashSet(); private bool _queued; private bool _running; @@ -29,8 +30,8 @@ namespace Avalonia.Layout Contract.Requires(control != null); Dispatcher.UIThread.VerifyAccess(); - _toMeasure.Enqueue(control); - _toArrange.Enqueue(control); + _toMeasure.Add(control); + _toArrange.Add(control); QueueLayoutPass(); } @@ -40,7 +41,7 @@ namespace Avalonia.Layout Contract.Requires(control != null); Dispatcher.UIThread.VerifyAccess(); - _toArrange.Enqueue(control); + _toArrange.Add(control); QueueLayoutPass(); } @@ -107,7 +108,7 @@ namespace Avalonia.Layout { while (_toMeasure.Count > 0) { - var next = _toMeasure.Dequeue(); + var next = _toMeasure.First(); Measure(next); } } @@ -116,7 +117,7 @@ namespace Avalonia.Layout { while (_toArrange.Count > 0 && _toMeasure.Count == 0) { - var next = _toArrange.Dequeue(); + var next = _toArrange.First(); Arrange(next); } } @@ -124,29 +125,45 @@ namespace Avalonia.Layout private void Measure(ILayoutable control) { var root = control as ILayoutRoot; + var parent = control.VisualParent as ILayoutable; if (root != null) { root.Measure(root.MaxClientSize); } - else if (control.PreviousMeasure.HasValue) + else if (parent != null) + { + Measure(parent); + } + + if (!control.IsMeasureValid) { control.Measure(control.PreviousMeasure.Value); } + + _toMeasure.Remove(control); } private void Arrange(ILayoutable control) { var root = control as ILayoutRoot; + var parent = control.VisualParent as ILayoutable; if (root != null) { root.Arrange(new Rect(root.DesiredSize)); } - else if (control.PreviousArrange.HasValue) + else if (parent != null) + { + Arrange(parent); + } + + if (control.PreviousArrange.HasValue) { control.Arrange(control.PreviousArrange.Value); } + + _toArrange.Remove(control); } private void QueueLayoutPass() diff --git a/src/Avalonia.SceneGraph/Rect.cs b/src/Avalonia.SceneGraph/Rect.cs index 806ad39b53..10253ec829 100644 --- a/src/Avalonia.SceneGraph/Rect.cs +++ b/src/Avalonia.SceneGraph/Rect.cs @@ -224,7 +224,7 @@ namespace Avalonia } /// - /// Determines whether a points in in the bounds of the rectangle. + /// Determines whether a point in in the bounds of the rectangle. /// /// The point. /// true if the point is in the bounds of the rectangle; otherwise false. @@ -234,6 +234,16 @@ namespace Avalonia p.Y >= _y && p.Y <= _y + _height; } + /// + /// Determines whether the rectangle fully contains another rectangle. + /// + /// The rectangle. + /// true if the rectangle is fully contained; otherwise false. + public bool Contains(Rect r) + { + return Contains(r.TopLeft) && Contains(r.BottomRight); + } + /// /// Centers another rectangle in this rectangle. /// diff --git a/src/Avalonia.Themes.Default/ListBox.xaml b/src/Avalonia.Themes.Default/ListBox.xaml index 782cc36107..eecca02384 100644 --- a/src/Avalonia.Themes.Default/ListBox.xaml +++ b/src/Avalonia.Themes.Default/ListBox.xaml @@ -7,13 +7,14 @@ - + + MemberSelector="{TemplateBinding MemberSelector}" + VirtualizationMode="{TemplateBinding VirtualizationMode}"/> diff --git a/src/Markup/Avalonia.Markup.Xaml/Context/PropertyAccessor.cs b/src/Markup/Avalonia.Markup.Xaml/Context/PropertyAccessor.cs index 20d9b07daf..e295292ba0 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Context/PropertyAccessor.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Context/PropertyAccessor.cs @@ -136,7 +136,14 @@ namespace Avalonia.Markup.Xaml.Context if (control != null) { - DelayedBinding.Add(control, property, binding); + if (property != Control.DataContextProperty) + { + DelayedBinding.Add(control, property, binding); + } + else + { + control.Bind(property, binding); + } } else { diff --git a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs index 4b812e12f0..ec60695374 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Data/Binding.cs @@ -20,6 +20,7 @@ namespace Avalonia.Markup.Xaml.Data /// public Binding() { + FallbackValue = AvaloniaProperty.UnsetValue; } /// @@ -27,6 +28,7 @@ namespace Avalonia.Markup.Xaml.Data /// /// The binding path. public Binding(string path) + : this() { Path = path; } @@ -122,12 +124,23 @@ namespace Avalonia.Markup.Xaml.Data throw new NotSupportedException(); } + var fallback = FallbackValue; + + // If we're binding to DataContext and our fallback is UnsetValue then override + // the fallback value to null, as broken bindings to DataContext must reset the + // DataContext in order to not propagate incorrect DataContexts to child controls. + // See Avalonia.Markup.Xaml.UnitTests.Data.DataContext_Binding_Should_Produce_Correct_Results. + if (targetProperty == Control.DataContextProperty && fallback == AvaloniaProperty.UnsetValue) + { + fallback = null; + } + var subject = new ExpressionSubject( observer, targetProperty?.PropertyType ?? typeof(object), + fallback, Converter ?? DefaultValueConverter.Instance, ConverterParameter, - FallbackValue, Priority); return new InstancedBinding(subject, Mode, Priority); diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs index a58a8614ee..70d3f7d161 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/BindingExtension.cs @@ -36,7 +36,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions public IValueConverter Converter { get; set; } public object ConverterParameter { get; set; } public string ElementName { get; set; } - public object FallbackValue { get; set; } + public object FallbackValue { get; set; } = AvaloniaProperty.UnsetValue; public BindingMode Mode { get; set; } public string Path { get; set; } public BindingPriority Priority { get; set; } = BindingPriority.LocalValue; diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs index 2886b63c55..1feaed1b27 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Templates/DataTemplate.cs @@ -16,6 +16,8 @@ namespace Avalonia.Markup.Xaml.Templates [Content] public TemplateContent Content { get; set; } + public bool SupportsRecycling => true; + public bool Match(object data) { if (DataType == null) @@ -28,11 +30,6 @@ namespace Avalonia.Markup.Xaml.Templates } } - public IControl Build(object data) - { - var visualTreeForItem = Content.Load(); - visualTreeForItem.DataContext = data; - return visualTreeForItem; - } + public IControl Build(object data) => Content.Load(); } } \ No newline at end of file diff --git a/src/Markup/Avalonia.Markup.Xaml/Templates/TreeDataTemplate.cs b/src/Markup/Avalonia.Markup.Xaml/Templates/TreeDataTemplate.cs index 3c4d3c5baa..a640f00c65 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Templates/TreeDataTemplate.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Templates/TreeDataTemplate.cs @@ -24,6 +24,8 @@ namespace Avalonia.Markup.Xaml.Templates [AssignBinding] public Binding ItemsSource { get; set; } + public bool SupportsRecycling => true; + public bool Match(object data) { if (DataType == null) diff --git a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs index 05bf818aad..0a3be26c18 100644 --- a/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs +++ b/src/Markup/Avalonia.Markup/Data/ExpressionSubject.cs @@ -41,16 +41,36 @@ namespace Avalonia.Markup.Data /// /// A parameter to pass to . /// + /// The binding priority. + public ExpressionSubject( + ExpressionObserver inner, + Type targetType, + IValueConverter converter, + object converterParameter = null, + BindingPriority priority = BindingPriority.LocalValue) + : this(inner, targetType, AvaloniaProperty.UnsetValue, converter, converterParameter, priority) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The type to convert the value to. /// /// The value to use when the binding is unable to produce a value. /// + /// The value converter to use. + /// + /// A parameter to pass to . + /// /// The binding priority. public ExpressionSubject( ExpressionObserver inner, - Type targetType, + Type targetType, + object fallbackValue, IValueConverter converter, object converterParameter = null, - object fallbackValue = null, BindingPriority priority = BindingPriority.LocalValue) { Contract.Requires(inner != null); @@ -117,7 +137,7 @@ namespace Avalonia.Markup.Data _inner.Expression, error.Exception.Message); - if (_fallbackValue != null) + if (_fallbackValue != AvaloniaProperty.UnsetValue) { if (TypeUtilities.TryConvert( type, @@ -162,7 +182,7 @@ namespace Avalonia.Markup.Data ConverterParameter, CultureInfo.CurrentUICulture); - if (_fallbackValue != null && + if (_fallbackValue != AvaloniaProperty.UnsetValue && (converted == AvaloniaProperty.UnsetValue || converted is BindingError)) { diff --git a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs index 2cd92c219b..587816b07b 100644 --- a/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs +++ b/tests/Avalonia.Base.UnitTests/Collections/AvaloniaListTests.cs @@ -53,6 +53,36 @@ namespace Avalonia.Base.UnitTests.Collections Assert.Throws(() => target.InsertRange(1, new List() { 1 })); } + [Fact] + public void Move_Should_Update_Collection() + { + var target = new AvaloniaList(new[] { 1, 2, 3 }); + + target.Move(2, 0); + + Assert.Equal(new[] { 3, 1, 2 }, target); + } + + [Fact] + public void MoveRange_Should_Update_Collection() + { + var target = new AvaloniaList(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + + target.MoveRange(4, 3, 0); + + Assert.Equal(new[] { 5, 6, 7, 1, 2, 3, 4, 8, 9, 10 }, target); + } + + [Fact] + public void MoveRange_Can_Move_To_End() + { + var target = new AvaloniaList(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }); + + target.MoveRange(0, 5, 10); + + Assert.Equal(new[] { 6, 7, 8, 9, 10, 1, 2, 3, 4, 5 }, target); + } + [Fact] public void Adding_Item_Should_Raise_CollectionChanged() { @@ -95,6 +125,29 @@ namespace Avalonia.Base.UnitTests.Collections Assert.True(raised); } + [Fact] + public void Replacing_Item_Should_Raise_CollectionChanged() + { + var target = new AvaloniaList(new[] { 1, 2 }); + var raised = false; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(target, s); + Assert.Equal(NotifyCollectionChangedAction.Replace, e.Action); + Assert.Equal(new[] { 2 }, e.OldItems.Cast()); + Assert.Equal(new[] { 3 }, e.NewItems.Cast()); + Assert.Equal(1, e.OldStartingIndex); + Assert.Equal(1, e.NewStartingIndex); + + raised = true; + }; + + target[1] = 3; + + Assert.True(raised); + } + [Fact] public void Inserting_Item_Should_Raise_CollectionChanged() { @@ -158,6 +211,52 @@ namespace Avalonia.Base.UnitTests.Collections Assert.True(raised); } + [Fact] + public void Moving_Item_Should_Raise_CollectionChanged() + { + var target = new AvaloniaList(new[] { 1, 2, 3 }); + var raised = false; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(target, s); + Assert.Equal(NotifyCollectionChangedAction.Move, e.Action); + Assert.Equal(new[] { 3 }, e.OldItems.Cast()); + Assert.Equal(new[] { 3 }, e.NewItems.Cast()); + Assert.Equal(2, e.OldStartingIndex); + Assert.Equal(0, e.NewStartingIndex); + + raised = true; + }; + + target.Move(2, 0); + + Assert.True(raised); + } + + [Fact] + public void Moving_Items_Should_Raise_CollectionChanged() + { + var target = new AvaloniaList(new[] { 1, 2, 3 }); + var raised = false; + + target.CollectionChanged += (s, e) => + { + Assert.Equal(target, s); + Assert.Equal(NotifyCollectionChangedAction.Move, e.Action); + Assert.Equal(new[] { 2, 3 }, e.OldItems.Cast()); + Assert.Equal(new[] { 2, 3 }, e.NewItems.Cast()); + Assert.Equal(1, e.OldStartingIndex); + Assert.Equal(0, e.NewStartingIndex); + + raised = true; + }; + + target.MoveRange(1, 2, 0); + + Assert.True(raised); + } + [Fact] public void Clearing_Items_Should_Raise_CollectionChanged_Reset() { diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index 173163c849..3732f13769 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -91,6 +91,8 @@ + + @@ -100,7 +102,6 @@ - @@ -108,7 +109,7 @@ - + @@ -142,6 +143,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.v2.ncrunchproject b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.v2.ncrunchproject index 30815b1937..b5cd70b13f 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.v2.ncrunchproject +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.v2.ncrunchproject @@ -17,10 +17,11 @@ true true 60000 - - - + + + AutoDetect STA x86 + LongTestTimesWithoutParallelExecution \ No newline at end of file diff --git a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs index 554aab68c3..0e3c6f5953 100644 --- a/tests/Avalonia.Controls.UnitTests/CarouselTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CarouselTests.cs @@ -50,8 +50,8 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(1, target.GetLogicalChildren().Count()); var child = target.GetLogicalChildren().Single(); - Assert.IsType(child); - Assert.Equal("Foo", ((TextBlock)child).Text); + Assert.IsType(child); + Assert.Equal("Foo", ((ContentPresenter)child).Content); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/ControlTests.cs b/tests/Avalonia.Controls.UnitTests/ControlTests.cs index 4871f84e82..605406518d 100644 --- a/tests/Avalonia.Controls.UnitTests/ControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ControlTests.cs @@ -22,22 +22,51 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void LogicalParent_Should_Be_Set_To_Parent() + public void Setting_Parent_Should_Also_Set_InheritanceParent() { var parent = new Decorator(); var target = new TestControl(); parent.Child = target; + Assert.Equal(parent, target.Parent); Assert.Equal(parent, target.InheritanceParent); } [Fact] - public void LogicalParent_Should_Be_Cleared_When_Removed_From_Parent() + public void Setting_Parent_Should_Not_Set_InheritanceParent_If_Already_Set() { var parent = new Decorator(); + var inheritanceParent = new Decorator(); var target = new TestControl(); + ((ISetInheritanceParent)target).SetParent(inheritanceParent); + parent.Child = target; + + Assert.Equal(parent, target.Parent); + Assert.Equal(inheritanceParent, target.InheritanceParent); + } + + [Fact] + public void InheritanceParent_Should_Be_Cleared_When_Removed_From_Parent() + { + var parent = new Decorator(); + var target = new TestControl(); + + parent.Child = target; + parent.Child = null; + + Assert.Null(target.InheritanceParent); + } + + [Fact] + public void InheritanceParent_Should_Be_Cleared_When_Removed_From_Parent_When_Has_Different_InheritanceParent() + { + var parent = new Decorator(); + var inheritanceParent = new Decorator(); + var target = new TestControl(); + + ((ISetInheritanceParent)target).SetParent(inheritanceParent); parent.Child = target; parent.Child = null; @@ -262,7 +291,7 @@ namespace Avalonia.Controls.UnitTests private class TestControl : Control { - public new AvaloniaObject InheritanceParent => base.InheritanceParent; + public new IAvaloniaObject InheritanceParent => base.InheritanceParent; } } } diff --git a/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTests.cs b/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTests.cs index cdff65b35a..5d2269e73c 100644 --- a/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTests.cs @@ -1,8 +1,10 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Generators; +using Avalonia.Controls.Presenters; using Xunit; namespace Avalonia.Controls.UnitTests.Generators @@ -15,11 +17,11 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null); + var containers = Materialize(target, 0, items); var result = containers .Select(x => x.ContainerControl) - .OfType() - .Select(x => x.Text) + .OfType() + .Select(x => x.Content) .ToList(); Assert.Equal(items, result); @@ -31,7 +33,7 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null).ToList(); + var containers = Materialize(target, 0, items); Assert.Equal(containers[0].ContainerControl, target.ContainerFromIndex(0)); Assert.Equal(containers[1].ContainerControl, target.ContainerFromIndex(1)); @@ -44,7 +46,7 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null).ToList(); + var containers = Materialize(target, 0, items); Assert.Equal(0, target.IndexFromContainer(containers[0].ContainerControl)); Assert.Equal(1, target.IndexFromContainer(containers[1].ContainerControl)); @@ -57,7 +59,7 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null).ToList(); + var containers = Materialize(target, 0, items); target.Dematerialize(1, 1); @@ -72,26 +74,57 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null); + var containers = Materialize(target, 0, items); var expected = target.Containers.Take(2).ToList(); var result = target.Dematerialize(0, 2); Assert.Equal(expected, result); } + [Fact] + public void InsertSpace_Should_Alter_Successive_Container_Indexes() + { + var items = new[] { "foo", "bar", "baz" }; + var owner = new Decorator(); + var target = new ItemContainerGenerator(owner); + var containers = Materialize(target, 0, items); + + target.InsertSpace(1, 3); + + Assert.Equal(3, target.Containers.Count()); + Assert.Equal(new[] { 0, 4, 5 }, target.Containers.Select(x => x.Index)); + } + [Fact] public void RemoveRange_Should_Alter_Successive_Container_Indexes() { var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner); - var containers = target.Materialize(0, items, null).ToList(); + var containers = Materialize(target, 0, items); var removed = target.RemoveRange(1, 1).Single(); Assert.Equal(containers[0].ContainerControl, target.ContainerFromIndex(0)); Assert.Equal(containers[2].ContainerControl, target.ContainerFromIndex(1)); Assert.Equal(containers[1], removed); + Assert.Equal(new[] { 0, 1 }, target.Containers.Select(x => x.Index)); + } + + private IList Materialize( + IItemContainerGenerator generator, + int index, + string[] items) + { + var result = new List(); + + foreach (var item in items) + { + var container = generator.Materialize(index++, item, null); + result.Add(container); + } + + return result; } } } diff --git a/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTypedTests.cs b/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTypedTests.cs index 2522f1ef03..f63c0efbf9 100644 --- a/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTypedTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Generators/ItemContainerGeneratorTypedTests.cs @@ -1,6 +1,7 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Generators; using Xunit; @@ -15,7 +16,7 @@ namespace Avalonia.Controls.UnitTests.Generators var items = new[] { "foo", "bar", "baz" }; var owner = new Decorator(); var target = new ItemContainerGenerator(owner, ListBoxItem.ContentProperty, null); - var containers = target.Materialize(0, items, null); + var containers = Materialize(target, 0, items); var result = containers .Select(x => x.ContainerControl) .OfType() @@ -24,5 +25,21 @@ namespace Avalonia.Controls.UnitTests.Generators Assert.Equal(items, result); } + + private IList Materialize( + IItemContainerGenerator generator, + int index, + string[] items) + { + var result = new List(); + + foreach (var item in items) + { + var container = generator.Materialize(index++, item, null); + result.Add(container); + } + + return result; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 92c89039f5..dcbc71b9a1 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -27,7 +27,10 @@ namespace Avalonia.Controls.UnitTests target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - Assert.IsType(target.Presenter.Panel.Children[0]); + var container = (ContentPresenter)target.Presenter.Panel.Children[0]; + container.UpdateChild(); + + Assert.IsType(container.Child); } [Fact] @@ -44,7 +47,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Item_Should_Have_TemplatedParent_Set_To_Null() + public void Container_Should_Have_TemplatedParent_Set_To_Null() { var target = new ItemsControl(); @@ -53,9 +56,9 @@ namespace Avalonia.Controls.UnitTests target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - var item = (TextBlock)target.Presenter.Panel.GetVisualChildren().First(); + var container = (ContentPresenter)target.Presenter.Panel.Children[0]; - Assert.Null(item.TemplatedParent); + Assert.Null(container.TemplatedParent); } [Fact] @@ -135,7 +138,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Adding_String_Item_Should_Make_TextBlock_Appear_In_LogicalChildren() + public void Adding_String_Item_Should_Make_ContentPresenter_Appear_In_LogicalChildren() { var target = new ItemsControl(); var child = new Control(); @@ -147,7 +150,7 @@ namespace Avalonia.Controls.UnitTests var logical = (ILogical)target; Assert.Equal(1, logical.LogicalChildren.Count); - Assert.IsType(logical.LogicalChildren[0]); + Assert.IsType(logical.LogicalChildren[0]); } [Fact] @@ -367,6 +370,7 @@ namespace Avalonia.Controls.UnitTests target.Presenter.ApplyTemplate(); var dataContexts = target.Presenter.Panel.Children + .Do(x => (x as ContentPresenter)?.UpdateChild()) .Cast() .Select(x => x.DataContext) .ToList(); @@ -390,8 +394,8 @@ namespace Avalonia.Controls.UnitTests target.Presenter.ApplyTemplate(); var text = target.Presenter.Panel.Children - .Cast() - .Select(x => x.Text) + .Cast() + .Select(x => x.Content) .ToList(); Assert.Equal(new[] { "Foo", "Bar" }, text); @@ -419,7 +423,7 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void DataTemplate_Created_Item_Should_Be_NameScope() + public void DataTemplate_Created_Content_Should_Be_NameScope() { var items = new object[] { @@ -435,8 +439,10 @@ namespace Avalonia.Controls.UnitTests target.ApplyTemplate(); target.Presenter.ApplyTemplate(); - var item = target.Presenter.Panel.LogicalChildren[0]; - Assert.NotNull(NameScope.GetNameScope((TextBlock)item)); + var container = (ContentPresenter)target.Presenter.Panel.LogicalChildren[0]; + container.UpdateChild(); + + Assert.NotNull(NameScope.GetNameScope((TextBlock)container.Child)); } private class Item diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs index d5fd59d1fc..db2bb0f886 100644 --- a/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.LogicalTree; using Avalonia.Styling; +using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; @@ -19,19 +20,14 @@ namespace Avalonia.Controls.UnitTests { var target = new ListBox { - Template = new FuncControlTemplate(CreateListBoxTemplate), + Template = ListBoxTemplate(), + Items = new[] { "Foo" }, ItemTemplate = new FuncDataTemplate(_ => new Canvas()), }; - target.Items = new[] { "Foo" }; - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); + Prepare(target); var container = (ListBoxItem)target.Presenter.Panel.Children[0]; - container.Template = ListBoxItemTemplate(); - container.ApplyTemplate(); - ((ContentPresenter)container.Presenter).UpdateChild(); - Assert.IsType(container.Presenter.Child); } @@ -40,10 +36,10 @@ namespace Avalonia.Controls.UnitTests { var target = new ListBox { - Template = new FuncControlTemplate(CreateListBoxTemplate), + Template = ListBoxTemplate(), }; - ApplyTemplate(target); + Prepare(target); Assert.IsType(target.Presenter); } @@ -51,113 +47,152 @@ namespace Avalonia.Controls.UnitTests [Fact] public void ListBoxItem_Containers_Should_Be_Generated() { - var items = new[] { "Foo", "Bar", "Baz " }; - var target = new ListBox + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - Template = new FuncControlTemplate(CreateListBoxTemplate), - Items = items, - }; + var items = new[] { "Foo", "Bar", "Baz " }; + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = items, + }; - ApplyTemplate(target); + Prepare(target); - var text = target.Presenter.Panel.Children - .OfType() - .Do(x => x.Template = ListBoxItemTemplate()) - .Do(x => { x.ApplyTemplate(); ((ContentPresenter)x.Presenter).UpdateChild(); }) - .Select(x => x.Presenter.Child) - .OfType() - .Select(x => x.Text) - .ToList(); + var text = target.Presenter.Panel.Children + .OfType() + .Select(x => x.Presenter.Child) + .OfType() + .Select(x => x.Text) + .ToList(); - Assert.Equal(items, text); + Assert.Equal(items, text); + } } [Fact] public void LogicalChildren_Should_Be_Set_For_DataTemplate_Generated_Items() { - var target = new ListBox + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - Template = new FuncControlTemplate(CreateListBoxTemplate), - Items = new[] { "Foo", "Bar", "Baz " }, - }; + var target = new ListBox + { + Template = ListBoxTemplate(), + Items = new[] { "Foo", "Bar", "Baz " }, + }; - ApplyTemplate(target); + Prepare(target); - Assert.Equal(3, target.GetLogicalChildren().Count()); + Assert.Equal(3, target.GetLogicalChildren().Count()); - foreach (var child in target.GetLogicalChildren()) - { - Assert.IsType(child); + foreach (var child in target.GetLogicalChildren()) + { + Assert.IsType(child); + } } } [Fact] public void DataContexts_Should_Be_Correctly_Set() { - var items = new object[] + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - "Foo", - new Item("Bar"), - new TextBlock { Text = "Baz" }, - new ListBoxItem { Content = "Qux" }, - }; + var items = new object[] + { + "Foo", + new Item("Bar"), + new TextBlock { Text = "Baz" }, + new ListBoxItem { Content = "Qux" }, + }; - var target = new ListBox - { - Template = new FuncControlTemplate(CreateListBoxTemplate), - DataContext = "Base", - DataTemplates = new DataTemplates + var target = new ListBox + { + Template = ListBoxTemplate(), + DataContext = "Base", + DataTemplates = new DataTemplates { new FuncDataTemplate(x => new Button { Content = x }) }, - Items = items, - }; + Items = items, + }; - ApplyTemplate(target); + Prepare(target); - var dataContexts = target.Presenter.Panel.Children - .Cast() - .Select(x => x.DataContext) - .ToList(); + var dataContexts = target.Presenter.Panel.Children + .Cast() + .Select(x => x.DataContext) + .ToList(); - Assert.Equal( - new object[] { items[0], items[1], "Base", "Base" }, - dataContexts); + Assert.Equal( + new object[] { items[0], items[1], "Base", "Base" }, + dataContexts); + } } - private Control CreateListBoxTemplate(ITemplatedControl parent) + [Fact] + public void Selection_Should_Be_Cleared_On_Recycled_Items() { - return new ScrollViewer + var target = new ListBox { - Template = new FuncControlTemplate(CreateScrollViewerTemplate), - Content = new ItemsPresenter - { - Name = "PART_ItemsPresenter", - [~ItemsPresenter.ItemsProperty] = parent.GetObservable(ItemsControl.ItemsProperty), - } + Template = ListBoxTemplate(), + Items = Enumerable.Range(0, 20).Select(x => $"Item {x}").ToList(), + ItemTemplate = new FuncDataTemplate(x => new TextBlock { Height = 10 }), + SelectedIndex = 0, }; + + Prepare(target); + + // Make sure we're virtualized and first item is selected. + Assert.Equal(10, target.Presenter.Panel.Children.Count); + Assert.True(((ListBoxItem)target.Presenter.Panel.Children[0]).IsSelected); + + // Scroll down a page. + target.Scroll.Offset = new Vector(0, 10); + + // Make sure recycled item isn't now selected. + Assert.False(((ListBoxItem)target.Presenter.Panel.Children[0]).IsSelected); + } + + private FuncControlTemplate ListBoxTemplate() + { + return new FuncControlTemplate(parent => + new ScrollViewer + { + Name = "PART_ScrollViewer", + Template = ScrollViewerTemplate(), + Content = new ItemsPresenter + { + Name = "PART_ItemsPresenter", + [~ItemsPresenter.ItemsProperty] = parent.GetObservable(ItemsControl.ItemsProperty), + [~ItemsPresenter.ItemsPanelProperty] = parent.GetObservable(ItemsControl.ItemsPanelProperty), + } + }); } private FuncControlTemplate ListBoxItemTemplate() { - return new FuncControlTemplate(parent => new ContentPresenter - { - Name = "PART_ContentPresenter", - [!ContentPresenter.ContentProperty] = parent[!ListBoxItem.ContentProperty], - [!ContentPresenter.ContentTemplateProperty] = parent[!ListBoxItem.ContentTemplateProperty], - }); + return new FuncControlTemplate(parent => + new ContentPresenter + { + Name = "PART_ContentPresenter", + [!ContentPresenter.ContentProperty] = parent[!ListBoxItem.ContentProperty], + [!ContentPresenter.ContentTemplateProperty] = parent[!ListBoxItem.ContentTemplateProperty], + }); } - private Control CreateScrollViewerTemplate(ITemplatedControl parent) + private FuncControlTemplate ScrollViewerTemplate() { - return new ScrollContentPresenter - { - Name = "PART_ContentPresenter", - [~ContentPresenter.ContentProperty] = parent.GetObservable(ContentControl.ContentProperty), - }; + return new FuncControlTemplate(parent => + new ScrollContentPresenter + { + Name = "PART_ContentPresenter", + [~ScrollContentPresenter.ContentProperty] = parent.GetObservable(ScrollViewer.ContentProperty), + [~~ScrollContentPresenter.ExtentProperty] = parent[~~ScrollViewer.ExtentProperty], + [~~ScrollContentPresenter.OffsetProperty] = parent[~~ScrollViewer.OffsetProperty], + [~~ScrollContentPresenter.ViewportProperty] = parent[~~ScrollViewer.ViewportProperty], + }); } - private void ApplyTemplate(ListBox target) + private void Prepare(ListBox target) { // Apply the template to the ListBox itself. target.ApplyTemplate(); @@ -171,6 +206,28 @@ namespace Avalonia.Controls.UnitTests // Now the ItemsPresenter should be reigstered, so apply its template. target.Presenter.ApplyTemplate(); + + // Because ListBox items are virtualized we need to do a layout to make them appear. + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + // Now set and apply the item templates. + foreach (ListBoxItem item in target.Presenter.Panel.Children) + { + item.Template = ListBoxItemTemplate(); + item.ApplyTemplate(); + item.Presenter.ApplyTemplate(); + ((ContentPresenter)item.Presenter).UpdateChild(); + } + + // The items were created before the template was applied, so now we need to go back + // and re-arrange everything. + foreach (IControl i in target.GetSelfAndVisualDescendents()) + { + i.InvalidateMeasure(); + } + + target.Arrange(new Rect(0, 0, 100, 100)); } private class Item diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/CarouselPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/CarouselPresenterTests.cs index 33bbf83140..260c070406 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/CarouselPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/CarouselPresenterTests.cs @@ -60,8 +60,8 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); - Assert.IsType(target.Panel.Children[0]); - Assert.Equal("foo", ((TextBlock)target.Panel.Children[0]).Text); + Assert.IsType(target.Panel.Children[0]); + Assert.Equal("foo", ((ContentPresenter)target.Panel.Children[0]).Content); } [Fact] @@ -76,8 +76,8 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); target.SelectedIndex = 1; - Assert.IsType(target.Panel.Children[0]); - Assert.Equal("bar", ((TextBlock)target.Panel.Children[0]).Text); + Assert.IsType(target.Panel.Children[0]); + Assert.Equal("bar", ((ContentPresenter)target.Panel.Children[0]).Content); } [Fact] @@ -155,7 +155,7 @@ namespace Avalonia.Controls.UnitTests.Presenters } [Fact] - public void Should_have_correct_index_itemscontainer() + public void Should_Have_Correct_ItemsContainer_Index() { ObservableCollection items = new ObservableCollection(); @@ -186,7 +186,7 @@ namespace Avalonia.Controls.UnitTests.Presenters items.Remove(items[0]); Assert.Equal(1, target.ItemContainerGenerator.Containers.Count()); Assert.Equal(1, target.Panel.Children.Count); - Assert.Equal(1, target.ItemContainerGenerator.Containers.First().Index); + Assert.Equal(0, target.ItemContainerGenerator.Containers.First().Index); items.Remove(items[0]); Assert.Equal(0, target.ItemContainerGenerator.Containers.Count()); diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests.cs index 2639220023..5c26ba5a5e 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ContentPresenterTests.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Templates; using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; +using System; namespace Avalonia.Controls.UnitTests.Presenters { @@ -142,17 +143,42 @@ namespace Avalonia.Controls.UnitTests.Presenters } [Fact] - public void Assigning_NonControl_To_Content_Should_Set_DataContext() + public void Assigning_NonControl_To_Content_Should_Set_DataContext_On_UpdateChild() { var target = new ContentPresenter { Content = "foo", }; + target.UpdateChild(); + Assert.Equal("foo", target.DataContext); } - private class TestContentControl : TemplatedControl + [Fact] + public void Tries_To_Recycle_DataTemplate() + { + var target = new ContentPresenter + { + DataTemplates = new DataTemplates + { + new FuncDataTemplate(_ => new Border(), true), + }, + Content = "foo", + }; + + target.UpdateChild(); + var control = target.Child; + + Assert.IsType(control); + + target.Content = "bar"; + target.UpdateChild(); + + Assert.Same(control, target.Child); + } + + private class TestContentControl : ContentControl { public IControl Child { get; set; } } diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs index 519b9b3d1d..da1558439f 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests.cs @@ -38,10 +38,10 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); Assert.Equal(2, target.Panel.Children.Count); - Assert.IsType(target.Panel.Children[0]); - Assert.IsType(target.Panel.Children[1]); - Assert.Equal("foo", ((TextBlock)target.Panel.Children[0]).Text); - Assert.Equal("bar", ((TextBlock)target.Panel.Children[1]).Text); + Assert.IsType(target.Panel.Children[0]); + Assert.IsType(target.Panel.Children[1]); + Assert.Equal("foo", ((ContentPresenter)target.Panel.Children[0]).Content); + Assert.Equal("bar", ((ContentPresenter)target.Panel.Children[1]).Content); } [Fact] @@ -88,8 +88,8 @@ namespace Avalonia.Controls.UnitTests.Presenters items.RemoveAt(0); Assert.Equal(1, target.Panel.Children.Count); - Assert.Equal("bar", ((TextBlock)target.Panel.Children[0]).Text); - Assert.Equal("bar", ((TextBlock)target.ItemContainerGenerator.ContainerFromIndex(0)).Text); + Assert.Equal("bar", ((ContentPresenter)target.Panel.Children[0]).Content); + Assert.Equal("bar", ((ContentPresenter)target.ItemContainerGenerator.ContainerFromIndex(0)).Content); } [Fact] @@ -121,8 +121,8 @@ namespace Avalonia.Controls.UnitTests.Presenters items[1] = "baz"; var text = target.Panel.Children - .OfType() - .Select(x => x.Text) + .OfType() + .Select(x => x.Content) .ToList(); Assert.Equal(new[] { "foo", "baz", "baz" }, text); @@ -141,8 +141,8 @@ namespace Avalonia.Controls.UnitTests.Presenters items.Move(2, 1); var text = target.Panel.Children - .OfType() - .Select(x => x.Text) + .OfType() + .Select(x => x.Content) .ToList(); Assert.Equal(new[] { "foo", "baz", "bar" }, text); @@ -161,8 +161,8 @@ namespace Avalonia.Controls.UnitTests.Presenters items.Insert(2, "insert"); var text = target.Panel.Children - .OfType() - .Select(x => x.Text) + .OfType() + .Select(x => x.Content) .ToList(); Assert.Equal(new[] { "foo", "bar", "insert", "baz" }, text); @@ -195,16 +195,16 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); - var text = target.Panel.Children.Cast().Select(x => x.Text).ToList(); + var text = target.Panel.Children.Cast().Select(x => x.Content).ToList(); - Assert.Equal(new[] { "foo", "bar" }, text); + Assert.Equal(new[] { "foo", null, "bar" }, text); Assert.NotNull(target.ItemContainerGenerator.ContainerFromIndex(0)); - Assert.Null(target.ItemContainerGenerator.ContainerFromIndex(1)); + Assert.NotNull(target.ItemContainerGenerator.ContainerFromIndex(1)); Assert.NotNull(target.ItemContainerGenerator.ContainerFromIndex(2)); items.RemoveAt(1); - text = target.Panel.Children.Cast().Select(x => x.Text).ToList(); + text = target.Panel.Children.Cast().Select(x => x.Content).ToList(); Assert.Equal(new[] { "foo", "bar" }, text); Assert.NotNull(target.ItemContainerGenerator.ContainerFromIndex(0)); @@ -224,8 +224,11 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); items.RemoveAt(2); - var text = target.Panel.Children.OfType().Select(x => x.Text); - Assert.Equal(new[] { "1", "2" }, text); + var numbers = target.Panel.Children + .OfType() + .Select(x => x.Content) + .Cast(); + Assert.Equal(new[] { 1, 2 }, numbers); } [Fact] @@ -288,8 +291,8 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); var text = target.Panel.Children - .Cast() - .Select(x => x.Text) + .Cast() + .Select(x => x.Content) .ToList(); Assert.Equal(new[] { "Foo", "Bar" }, text); @@ -308,7 +311,8 @@ namespace Avalonia.Controls.UnitTests.Presenters target.ApplyTemplate(); var dataContexts = target.Panel.Children - .Cast() + .Cast() + .Do(x => x.UpdateChild()) .Select(x => x.DataContext) .ToList(); diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization.cs new file mode 100644 index 0000000000..26657a0fbb --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization.cs @@ -0,0 +1,302 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls.Generators; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Layout; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Presenters +{ + public class ItemsPresenterTests_Virtualization + { + [Fact] + public void Should_Return_IsLogicalScrollEnabled_False_When_Has_No_Virtualizing_Panel() + { + var target = CreateTarget(); + target.ClearValue(ItemsPresenter.ItemsPanelProperty); + + target.ApplyTemplate(); + + Assert.False(((ILogicalScrollable)target).IsLogicalScrollEnabled); + } + + [Fact] + public void Should_Return_IsLogicalScrollEnabled_False_When_VirtualizationMode_None() + { + var target = CreateTarget(ItemVirtualizationMode.None); + + target.ApplyTemplate(); + + Assert.False(((ILogicalScrollable)target).IsLogicalScrollEnabled); + } + + [Fact] + public void Should_Return_IsLogicalScrollEnabled_False_When_Doesnt_Have_ScrollPresenter_Parent() + { + var target = new ItemsPresenter + { + ItemsPanel = VirtualizingPanelTemplate(), + ItemTemplate = ItemTemplate(), + VirtualizationMode = ItemVirtualizationMode.Simple, + }; + + target.ApplyTemplate(); + + Assert.False(((ILogicalScrollable)target).IsLogicalScrollEnabled); + } + + [Fact] + public void Should_Return_IsLogicalScrollEnabled_True() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + + Assert.True(((ILogicalScrollable)target).IsLogicalScrollEnabled); + } + + [Fact] + public void Parent_ScrollContentPresenter_Properties_Should_Be_Set() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var scroll = (ScrollContentPresenter)target.Parent; + Assert.Equal(new Size(0, 20), scroll.Extent); + Assert.Equal(new Size(0, 10), scroll.Viewport); + } + + [Fact] + public void Should_Fill_Panel_With_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + + target.Measure(new Size(100, 100)); + Assert.Equal(10, target.Panel.Children.Count); + + target.Arrange(new Rect(0, 0, 100, 100)); + Assert.Equal(10, target.Panel.Children.Count); + } + + [Fact] + public void Should_Only_Create_Enough_Containers_To_Display_All_Items() + { + var target = CreateTarget(itemCount: 2); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(2, target.Panel.Children.Count); + } + + [Fact] + public void Should_Expand_To_Fit_Containers_When_Flexible_Size() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(Size.Infinity); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Size(10, 200), target.DesiredSize); + Assert.Equal(new Size(10, 200), target.Bounds.Size); + Assert.Equal(20, target.Panel.Children.Count); + } + + [Fact] + public void Initial_Item_DataContexts_Should_Be_Correct() + { + var target = CreateTarget(); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i], target.Panel.Children[i].DataContext); + } + } + + [Fact] + public void Should_Add_New_Items_When_Control_Is_Enlarged() + { + var target = CreateTarget(); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(10, target.Panel.Children.Count); + + target.Measure(new Size(120, 120)); + target.Arrange(new Rect(0, 0, 100, 120)); + + Assert.Equal(12, target.Panel.Children.Count); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i], target.Panel.Children[i].DataContext); + } + } + + [Fact] + public void Changing_VirtualizationMode_None_To_Simple_Should_Update_Control() + { + var target = CreateTarget(mode: ItemVirtualizationMode.None); + var scroll = (ScrollContentPresenter)target.Parent; + + scroll.Measure(new Size(100, 100)); + scroll.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(20, target.Panel.Children.Count); + Assert.Equal(new Size(10, 200), scroll.Extent); + Assert.Equal(new Size(100, 100), scroll.Viewport); + + target.VirtualizationMode = ItemVirtualizationMode.Simple; + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(10, target.Panel.Children.Count); + Assert.Equal(new Size(0, 20), scroll.Extent); + Assert.Equal(new Size(0, 10), scroll.Viewport); + } + + [Fact] + public void Changing_VirtualizationMode_None_To_Simple_Should_Add_Correct_Number_Of_Controls() + { + using (UnitTestApplication.Start(TestServices.RealLayoutManager)) + { + var target = CreateTarget(mode: ItemVirtualizationMode.None); + var scroll = (ScrollContentPresenter)target.Parent; + + scroll.Measure(new Size(100, 100)); + scroll.Arrange(new Rect(0, 0, 100, 100)); + + // Ensure than an intermediate measure pass doesn't add more controls than it + // should. This can happen if target gets measured with Size.Infinity which + // is what the available size should be when VirtualizationMode == None but not + // what it should after VirtualizationMode is changed to Simple. + target.Panel.Children.CollectionChanged += (s, e) => + { + Assert.InRange(target.Panel.Children.Count, 0, 10); + }; + + target.VirtualizationMode = ItemVirtualizationMode.Simple; + LayoutManager.Instance.ExecuteLayoutPass(); + + Assert.Equal(10, target.Panel.Children.Count); + } + } + + [Fact] + public void Changing_VirtualizationMode_Simple_To_None_Should_Update_Control() + { + var target = CreateTarget(); + var scroll = (ScrollContentPresenter)target.Parent; + + scroll.Measure(new Size(100, 100)); + scroll.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(10, target.Panel.Children.Count); + Assert.Equal(new Size(0, 20), scroll.Extent); + Assert.Equal(new Size(0, 10), scroll.Viewport); + + target.VirtualizationMode = ItemVirtualizationMode.None; + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + // Here - unlike changing the other way - we need to do a layout pass on the scroll + // content presenter as non-logical scroll values are only updated on arrange. + scroll.Measure(new Size(100, 100)); + scroll.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(20, target.Panel.Children.Count); + Assert.Equal(new Size(10, 200), scroll.Extent); + Assert.Equal(new Size(100, 100), scroll.Viewport); + } + + private static ItemsPresenter CreateTarget( + ItemVirtualizationMode mode = ItemVirtualizationMode.Simple, + Orientation orientation = Orientation.Vertical, + bool useContainers = true, + int itemCount = 20) + { + ItemsPresenter result; + var items = Enumerable.Range(0, itemCount).Select(x => $"Item {x}").ToList(); + + var scroller = new ScrollContentPresenter + { + Content = result = new TestItemsPresenter(useContainers) + { + Items = items, + ItemsPanel = VirtualizingPanelTemplate(orientation), + ItemTemplate = ItemTemplate(), + VirtualizationMode = mode, + } + }; + + scroller.UpdateChild(); + + return result; + } + + private static IDataTemplate ItemTemplate() + { + return new FuncDataTemplate(x => new Canvas + { + Width = 10, + Height = 10, + }); + } + + private static ITemplate VirtualizingPanelTemplate( + Orientation orientation = Orientation.Vertical) + { + return new FuncTemplate(() => new VirtualizingStackPanel + { + Orientation = orientation, + }); + } + + private class TestItemsPresenter : ItemsPresenter + { + private bool _useContainers; + + public TestItemsPresenter(bool useContainers) + { + _useContainers = useContainers; + } + + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return _useContainers ? + new ItemContainerGenerator(this, TestContainer.ContentProperty, null) : + new ItemContainerGenerator(this); + } + } + + private class TestContainer : ContentControl + { + public TestContainer() + { + Width = 10; + Height = 10; + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs new file mode 100644 index 0000000000..354f93097b --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs @@ -0,0 +1,683 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Collections; +using Avalonia.Controls.Generators; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Presenters +{ + public class ItemsPresenterTests_Virtualization_Simple + { + [Fact] + public void Should_Return_Items_Count_For_Extent_Vertical() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + + Assert.Equal(new Size(0, 20), ((ILogicalScrollable)target).Extent); + } + + [Fact] + public void Should_Return_Items_Count_For_Extent_Horizontal() + { + var target = CreateTarget(orientation: Orientation.Horizontal); + + target.ApplyTemplate(); + + Assert.Equal(new Size(20, 0), ((ILogicalScrollable)target).Extent); + } + + [Fact] + public void Should_Have_Number_Of_Visible_Items_As_Viewport_Vertical() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Size(0, 10), ((ILogicalScrollable)target).Viewport); + } + + [Fact] + public void Should_Have_Number_Of_Visible_Items_As_Viewport_Horizontal() + { + var target = CreateTarget(orientation: Orientation.Horizontal); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Size(10, 0), ((ILogicalScrollable)target).Viewport); + } + + [Fact] + public void Should_Remove_Items_When_Control_Is_Shrank() + { + var target = CreateTarget(); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(10, target.Panel.Children.Count); + + target.Measure(new Size(100, 80)); + target.Arrange(new Rect(0, 0, 100, 80)); + + Assert.Equal(8, target.Panel.Children.Count); + } + + [Fact] + public void Should_Add_New_Containers_At_Top_When_Control_Is_Scrolled_To_Bottom_And_Enlarged() + { + var target = CreateTarget(); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(10, target.Panel.Children.Count); + + ((IScrollable)target).Offset = new Vector(0, 10); + target.Measure(new Size(120, 120)); + target.Arrange(new Rect(0, 0, 100, 120)); + + Assert.Equal(12, target.Panel.Children.Count); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i + 8], target.Panel.Children[i].DataContext); + } + } + + [Fact] + public void Should_Update_Containers_When_Items_Changes() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + target.Items = new[] { "foo", "bar", "baz" }; + + Assert.Equal(3, target.Panel.Children.Count); + } + + [Fact] + public void Should_Decrease_The_Viewport_Size_By_One_If_There_Is_A_Partial_Item() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 95)); + target.Arrange(new Rect(0, 0, 100, 95)); + + Assert.Equal(new Size(0, 9), ((ILogicalScrollable)target).Viewport); + } + + [Fact] + public void Moving_To_And_From_The_End_With_Partial_Item_Should_Set_Panel_PixelOffset() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 95)); + target.Arrange(new Rect(0, 0, 100, 95)); + + ((ILogicalScrollable)target).Offset = new Vector(0, 11); + + var minIndex = target.ItemContainerGenerator.Containers.Min(x => x.Index); + Assert.Equal(new Vector(0, 11), ((ILogicalScrollable)target).Offset); + Assert.Equal(10, minIndex); + Assert.Equal(10, ((IVirtualizingPanel)target.Panel).PixelOffset); + + ((ILogicalScrollable)target).Offset = new Vector(0, 10); + + minIndex = target.ItemContainerGenerator.Containers.Min(x => x.Index); + Assert.Equal(new Vector(0, 10), ((ILogicalScrollable)target).Offset); + Assert.Equal(10, minIndex); + Assert.Equal(0, ((IVirtualizingPanel)target.Panel).PixelOffset); + + ((ILogicalScrollable)target).Offset = new Vector(0, 11); + + minIndex = target.ItemContainerGenerator.Containers.Min(x => x.Index); + Assert.Equal(new Vector(0, 11), ((ILogicalScrollable)target).Offset); + Assert.Equal(10, minIndex); + Assert.Equal(10, ((IVirtualizingPanel)target.Panel).PixelOffset); + } + + [Fact] + public void Inserting_Items_Should_Update_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + ((ILogicalScrollable)target).Offset = new Vector(0, 5); + + var expected = Enumerable.Range(5, 10).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.Insert(6, "Inserted"); + expected.Insert(1, "Inserted"); + expected.RemoveAt(expected.Count - 1); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Removing_First_Materialized_Item_Should_Update_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var expected = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.RemoveAt(0); + expected = Enumerable.Range(1, 10).Select(x => $"Item {x}").ToList(); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Removing_Items_From_Middle_Should_Update_Containers_When_All_Items_Visible() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 200)); + target.Arrange(new Rect(0, 0, 100, 200)); + + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(items, actual); + + items.RemoveAt(2); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(items, actual); + + items.RemoveAt(items.Count - 2); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(items, actual); + } + + [Fact] + public void Removing_Last_Item_Should_Update_Containers_When_All_Items_Visible() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 200)); + target.Arrange(new Rect(0, 0, 100, 200)); + + ((ILogicalScrollable)target).Offset = new Vector(0, 5); + + var expected = Enumerable.Range(0, 20).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.Remove(items.Last()); + expected.Remove(expected.Last()); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Removing_Items_When_Scrolled_To_End_Should_Recyle_Containers_At_Top() + { + var target = CreateTarget(useAvaloniaList: true); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + ((ILogicalScrollable)target).Offset = new Vector(0, 10); + + var expected = Enumerable.Range(10, 10).Select(x => $"Item {x}").ToList(); + var items = (AvaloniaList)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.RemoveRange(18, 2); + expected = Enumerable.Range(8, 10).Select(x => $"Item {x}").ToList(); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Removing_Items_When_Scrolled_To_Near_End_Should_Recycle_Containers_At_Bottom_And_Top() + { + var target = CreateTarget(useAvaloniaList: true); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + ((ILogicalScrollable)target).Offset = new Vector(0, 9); + + var expected = Enumerable.Range(9, 10).Select(x => $"Item {x}").ToList(); + var items = (AvaloniaList)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.RemoveRange(15, 3); + expected = Enumerable.Range(7, 8).Select(x => $"Item {x}") + .Concat(Enumerable.Range(18, 2).Select(x => $"Item {x}")) + .ToList(); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Replacing_Items_Should_Update_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var expected = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items[4] = expected[4] = "Replaced"; + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Moving_Items_Should_Update_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var expected = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + items.Move(4, 8); + var i = expected[4]; + expected.RemoveAt(4); + expected.Insert(8, i); + + actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + Assert.Equal(expected, actual); + } + + [Fact] + public void Setting_Items_To_Null_Should_Remove_Containers() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var expected = Enumerable.Range(0, 10).Select(x => $"Item {x}").ToList(); + var items = (ObservableCollection)target.Items; + var actual = target.Panel.Children.Select(x => x.DataContext).ToList(); + + Assert.Equal(expected, actual); + + target.Items = null; + + Assert.Empty(target.Panel.Children); + } + + public class Vertical + { + [Fact] + public void GetControlInDirection_Down_Should_Return_Existing_Container_If_Materialized() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var from = target.Panel.Children[5]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Down, + from); + + Assert.Same(target.Panel.Children[6], result); + } + + [Fact] + public void GetControlInDirection_Down_Should_Scroll_If_Necessary() + { + var target = CreateTarget(); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var from = target.Panel.Children[9]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Down, + from); + + Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[9], result); + } + + [Fact] + public void GetControlInDirection_Down_Should_Scroll_If_Partially_Visible() + { + using (UnitTestApplication.Start(TestServices.RealLayoutManager)) + { + var target = CreateTarget(); + var scroller = (ScrollContentPresenter)target.Parent; + + scroller.Measure(new Size(100, 95)); + scroller.Arrange(new Rect(0, 0, 100, 95)); + + var from = target.Panel.Children[8]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Down, + from); + + Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[8], result); + } + } + + [Fact] + public void GetControlInDirection_Up_Should_Scroll_If_Partially_Visible_Item_Is_Currently_Shown() + { + using (UnitTestApplication.Start(TestServices.RealLayoutManager)) + { + var target = CreateTarget(); + var scroller = (ScrollContentPresenter)target.Parent; + + scroller.Measure(new Size(100, 95)); + scroller.Arrange(new Rect(0, 0, 100, 95)); + ((ILogicalScrollable)target).Offset = new Vector(0, 11); + + var from = target.Panel.Children[1]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Up, + from); + + Assert.Equal(new Vector(0, 10), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[0], result); + } + } + } + + public class Horizontal + { + [Fact] + public void GetControlInDirection_Right_Should_Return_Existing_Container_If_Materialized() + { + var target = CreateTarget(orientation: Orientation.Horizontal); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var from = target.Panel.Children[5]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Right, + from); + + Assert.Same(target.Panel.Children[6], result); + } + + [Fact] + public void GetControlInDirection_Right_Should_Scroll_If_Necessary() + { + var target = CreateTarget(orientation: Orientation.Horizontal); + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var from = target.Panel.Children[9]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Right, + from); + + Assert.Equal(new Vector(1, 0), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[9], result); + } + + [Fact] + public void GetControlInDirection_Right_Should_Scroll_If_Partially_Visible() + { + using (UnitTestApplication.Start(TestServices.RealLayoutManager)) + { + var target = CreateTarget(orientation: Orientation.Horizontal); + var scroller = (ScrollContentPresenter)target.Parent; + + scroller.Measure(new Size(95, 100)); + scroller.Arrange(new Rect(0, 0, 95, 100)); + + var from = target.Panel.Children[8]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Right, + from); + + Assert.Equal(new Vector(1, 0), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[8], result); + } + } + + [Fact] + public void GetControlInDirection_Left_Should_Scroll_If_Partially_Visible_Item_Is_Currently_Shown() + { + var target = CreateTarget(orientation: Orientation.Horizontal); + + target.ApplyTemplate(); + target.Measure(new Size(95, 100)); + target.Arrange(new Rect(0, 0, 95, 100)); + ((ILogicalScrollable)target).Offset = new Vector(11, 0); + + var from = target.Panel.Children[1]; + var result = ((ILogicalScrollable)target).GetControlInDirection( + NavigationDirection.Left, + from); + + Assert.Equal(new Vector(10, 0), ((ILogicalScrollable)target).Offset); + Assert.Same(target.Panel.Children[0], result); + } + } + + public class WithContainers + { + [Fact] + public void Scrolling_Less_Than_A_Page_Should_Move_Recycled_Items() + { + var target = CreateTarget(); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var containers = target.Panel.Children.ToList(); + var scroller = (ScrollContentPresenter)target.Parent; + + scroller.Offset = new Vector(0, 5); + + var scrolledContainers = containers + .Skip(5) + .Take(5) + .Concat(containers.Take(5)).ToList(); + + Assert.Equal(new Vector(0, 5), ((ILogicalScrollable)target).Offset); + Assert.Equal(scrolledContainers, target.Panel.Children); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i + 5], target.Panel.Children[i].DataContext); + } + + scroller.Offset = new Vector(0, 0); + Assert.Equal(new Vector(0, 0), ((ILogicalScrollable)target).Offset); + Assert.Equal(containers, target.Panel.Children); + + var dcs = target.Panel.Children.Select(x => x.DataContext).ToList(); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i], target.Panel.Children[i].DataContext); + } + } + + [Fact] + public void Scrolling_More_Than_A_Page_Should_Recycle_Items() + { + var target = CreateTarget(itemCount: 50); + var items = (IList)target.Items; + + target.ApplyTemplate(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + var containers = target.Panel.Children.ToList(); + var scroller = (ScrollContentPresenter)target.Parent; + + scroller.Offset = new Vector(0, 20); + + Assert.Equal(new Vector(0, 20), ((ILogicalScrollable)target).Offset); + Assert.Equal(containers, target.Panel.Children); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i + 20], target.Panel.Children[i].DataContext); + } + + scroller.Offset = new Vector(0, 0); + + Assert.Equal(new Vector(0, 0), ((ILogicalScrollable)target).Offset); + Assert.Equal(containers, target.Panel.Children); + + for (var i = 0; i < target.Panel.Children.Count; ++i) + { + Assert.Equal(items[i], target.Panel.Children[i].DataContext); + } + } + } + + private static ItemsPresenter CreateTarget( + Orientation orientation = Orientation.Vertical, + bool useContainers = true, + int itemCount = 20, + bool useAvaloniaList = false) + { + ItemsPresenter result; + var itemsSource = Enumerable.Range(0, itemCount).Select(x => $"Item {x}"); + var items = useAvaloniaList ? + (IEnumerable)new AvaloniaList(itemsSource) : + (IEnumerable)new ObservableCollection(itemsSource); + + var scroller = new ScrollContentPresenter + { + Content = result = new TestItemsPresenter(useContainers) + { + Items = items, + ItemsPanel = VirtualizingPanelTemplate(orientation), + ItemTemplate = ItemTemplate(), + VirtualizationMode = ItemVirtualizationMode.Simple, + } + }; + + scroller.UpdateChild(); + + return result; + } + + private static IDataTemplate ItemTemplate() + { + return new FuncDataTemplate(x => new Canvas + { + Width = 10, + Height = 10, + }); + } + + private static ITemplate VirtualizingPanelTemplate( + Orientation orientation = Orientation.Vertical) + { + return new FuncTemplate(() => new VirtualizingStackPanel + { + Orientation = orientation, + }); + } + + private class TestItemsPresenter : ItemsPresenter + { + private bool _useContainers; + + public TestItemsPresenter(bool useContainers) + { + _useContainers = useContainers; + } + + protected override IItemContainerGenerator CreateItemContainerGenerator() + { + return _useContainers ? + new ItemContainerGenerator(this, TestContainer.ContentProperty, null) : + new ItemContainerGenerator(this); + } + } + + private class TestContainer : ContentControl + { + public TestContainer() + { + Width = 10; + Height = 10; + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_IScrollable.cs b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_ILogicalScrollable.cs similarity index 57% rename from tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_IScrollable.cs rename to tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_ILogicalScrollable.cs index db3b2a4a19..1258a26d6a 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_IScrollable.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_ILogicalScrollable.cs @@ -5,11 +5,12 @@ using System; using System.Reactive.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Input; using Xunit; namespace Avalonia.Controls.UnitTests { - public class ScrollContentPresenterTests_IScrollable + public class ScrollContentPresenterTests_ILogicalScrollable { [Fact] public void Measure_Should_Pass_Unchanged_Bounds_To_IScrollable() @@ -48,6 +49,27 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Rect(0, 0, 100, 100), scrollable.Bounds); } + [Fact] + public void Arrange_Should_Offset_IScrollable_Bounds_When_Logical_Scroll_Disabled() + { + var scrollable = new TestScrollable + { + IsLogicalScrollEnabled = false, + }; + + var target = new ScrollContentPresenter + { + Content = scrollable, + Offset = new Vector(25, 25), + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Rect(-25, -25, 150, 150), scrollable.Bounds); + } + [Fact] public void Arrange_Should_Not_Set_Viewport_And_Extent_With_IScrollable() { @@ -169,12 +191,108 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new Vector(50, 50), scrollable.Offset); } - private class TestScrollable : Control, IScrollable + [Fact] + public void Toggling_IsLogicalScrollEnabled_Should_Update_State() + { + var scrollable = new TestScrollable + { + Extent = new Size(100, 100), + Offset = new Vector(50, 50), + Viewport = new Size(25, 25), + }; + + var target = new ScrollContentPresenter + { + Content = scrollable, + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(scrollable.Extent, target.Extent); + Assert.Equal(scrollable.Offset, target.Offset); + Assert.Equal(scrollable.Viewport, target.Viewport); + Assert.Equal(new Rect(0, 0, 100, 100), scrollable.Bounds); + + scrollable.IsLogicalScrollEnabled = false; + scrollable.InvalidateScroll(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Size(150, 150), target.Extent); + Assert.Equal(new Vector(0, 0), target.Offset); + Assert.Equal(new Size(100, 100), target.Viewport); + Assert.Equal(new Rect(0, 0, 150, 150), scrollable.Bounds); + + scrollable.IsLogicalScrollEnabled = true; + scrollable.InvalidateScroll(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(scrollable.Extent, target.Extent); + Assert.Equal(scrollable.Offset, target.Offset); + Assert.Equal(scrollable.Viewport, target.Viewport); + Assert.Equal(new Rect(0, 0, 100, 100), scrollable.Bounds); + } + + [Fact] + public void Changing_Content_Should_Update_State() + { + var logicalScrollable = new TestScrollable + { + Extent = new Size(100, 100), + Offset = new Vector(50, 50), + Viewport = new Size(25, 25), + }; + + var nonLogicalScrollable = new TestScrollable + { + IsLogicalScrollEnabled = false, + }; + + var target = new ScrollContentPresenter + { + Content = logicalScrollable, + }; + + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(logicalScrollable.Extent, target.Extent); + Assert.Equal(logicalScrollable.Offset, target.Offset); + Assert.Equal(logicalScrollable.Viewport, target.Viewport); + Assert.Equal(new Rect(0, 0, 100, 100), logicalScrollable.Bounds); + + target.Content = nonLogicalScrollable; + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(new Size(150, 150), target.Extent); + Assert.Equal(new Vector(0, 0), target.Offset); + Assert.Equal(new Size(100, 100), target.Viewport); + Assert.Equal(new Rect(0, 0, 150, 150), nonLogicalScrollable.Bounds); + + target.Content = logicalScrollable; + target.UpdateChild(); + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 100, 100)); + + Assert.Equal(logicalScrollable.Extent, target.Extent); + Assert.Equal(logicalScrollable.Offset, target.Offset); + Assert.Equal(logicalScrollable.Viewport, target.Viewport); + Assert.Equal(new Rect(0, 0, 100, 100), logicalScrollable.Bounds); + } + + private class TestScrollable : Control, ILogicalScrollable { private Size _extent; private Vector _offset; private Size _viewport; + public bool IsLogicalScrollEnabled { get; set; } = true; public Size AvailableSize { get; private set; } public Action InvalidateScroll { get; set; } @@ -224,11 +342,21 @@ namespace Avalonia.Controls.UnitTests } } + public bool BringIntoView(IControl target, Rect targetRect) + { + throw new NotImplementedException(); + } + protected override Size MeasureOverride(Size availableSize) { AvailableSize = availableSize; return new Size(150, 150); } + + public IControl GetControlInDirection(NavigationDirection direction, IControl from) + { + throw new NotImplementedException(); + } } } } \ No newline at end of file diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 7840b55aae..2bbd08bf42 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -184,23 +184,29 @@ namespace Avalonia.Controls.UnitTests ApplyTemplate(target); var carousel = (Carousel)target.Pages; - var dataContext = ((TextBlock)carousel.Presenter.Panel.GetLogicalChildren().Single()).DataContext; + var container = (ContentPresenter)carousel.Presenter.Panel.Children.Single(); + container.UpdateChild(); + var dataContext = ((TextBlock)container.Child).DataContext; Assert.Equal(items[0], dataContext); target.SelectedIndex = 1; - dataContext = ((Button)carousel.Presenter.Panel.GetLogicalChildren().Single()).DataContext; + container = (ContentPresenter)carousel.Presenter.Panel.Children.Single(); + container.UpdateChild(); + dataContext = ((Button)container.Child).DataContext; Assert.Equal(items[1], dataContext); target.SelectedIndex = 2; - dataContext = ((TextBlock)carousel.Presenter.Panel.GetLogicalChildren().Single()).DataContext; + dataContext = ((TextBlock)carousel.Presenter.Panel.Children.Single()).DataContext; Assert.Equal("Base", dataContext); target.SelectedIndex = 3; - dataContext = ((TextBlock)carousel.Presenter.Panel.GetLogicalChildren().Single()).DataContext; + container = (ContentPresenter)carousel.Presenter.Panel.Children[0]; + container.UpdateChild(); + dataContext = ((TextBlock)container.Child).DataContext; Assert.Equal("Qux", dataContext); target.SelectedIndex = 4; - dataContext = ((TextBlock)carousel.Presenter.Panel.GetLogicalChildren().Single()).DataContext; + dataContext = ((TextBlock)carousel.Presenter.Panel.Children.Single()).DataContext; Assert.Equal("Base", dataContext); } diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index 7b19a6a4e2..ef8a9ca871 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -462,6 +462,8 @@ namespace Avalonia.Controls.UnitTests return new TextBlock { Text = node.Value }; } + public bool SupportsRecycling => false; + public InstancedBinding ItemsSelector(object item) { var obs = new ExpressionObserver(item, nameof(Node.Children)); diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs new file mode 100644 index 0000000000..b8cd868252 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Moq; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class VirtualizingStackPanelTests + { + public class Vertical + { + [Fact] + public void Measure_Invokes_Controller_UpdateControls() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + var controller = new Mock(); + + target.Controller = controller.Object; + target.Measure(new Size(100, 100)); + + controller.Verify(x => x.UpdateControls()); + } + + [Fact] + public void Arrange_Invokes_Controller_UpdateControls() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + var controller = new Mock(); + + target.Controller = controller.Object; + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(0, 0, 110, 110)); + + controller.Verify(x => x.UpdateControls()); + } + + [Fact] + public void Reports_IsFull_False_Until_Measure_Height_Is_Reached() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + + target.Measure(new Size(100, 100)); + + Assert.Equal(new Size(0, 0), target.DesiredSize); + Assert.Equal(new Size(0, 0), target.Bounds.Size); + + Assert.False(target.IsFull); + Assert.Equal(0, target.OverflowCount); + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + Assert.False(target.IsFull); + Assert.Equal(0, target.OverflowCount); + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + Assert.True(target.IsFull); + Assert.Equal(0, target.OverflowCount); + } + + [Fact] + public void Reports_Overflow_After_Arrange() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(new Size(0, 0), target.Bounds.Size); + + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + Assert.Equal(0, target.OverflowCount); + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(2, target.OverflowCount); + } + + [Fact] + public void Reports_Correct_Overflow_During_Arrange() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + var controller = new Mock(); + var called = false; + + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 52 }); + target.Measure(new Size(100, 100)); + + controller.Setup(x => x.UpdateControls()).Callback(() => + { + Assert.Equal(2, target.PixelOverflow); + Assert.Equal(0, target.OverflowCount); + called = true; + }); + + target.Controller = controller.Object; + target.Arrange(new Rect(target.DesiredSize)); + + Assert.True(called); + } + + [Fact] + public void Reports_PixelOverflow_After_Arrange() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 52 }); + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(2, target.PixelOverflow); + } + + [Fact] + public void Reports_PixelOverflow_With_PixelOffset() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 52 }); + target.PixelOffset = 2; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(2, target.PixelOverflow); + } + + [Fact] + public void PixelOffset_Can_Be_More_Than_Child_Without_Affecting_IsFull() + { + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + + target.Children.Add(new Canvas { Width = 50, Height = 50 }); + target.Children.Add(new Canvas { Width = 50, Height = 52 }); + target.PixelOffset = 55; + + target.Measure(new Size(100, 100)); + target.Arrange(new Rect(target.DesiredSize)); + + Assert.Equal(55, target.PixelOffset); + Assert.Equal(2, target.PixelOverflow); + Assert.True(target.IsFull); + } + + [Fact] + public void Passes_Navigation_Request_To_ILogicalScrollable_Parent() + { + var presenter = new Mock().As(); + var scrollable = presenter.As(); + var target = (IVirtualizingPanel)new VirtualizingStackPanel(); + var from = new Canvas(); + + scrollable.Setup(x => x.IsLogicalScrollEnabled).Returns(true); + + ((ISetLogicalParent)target).SetParent(presenter.Object); + ((INavigableContainer)target).GetControl(NavigationDirection.Next, from); + + scrollable.Verify(x => x.GetControlInDirection(NavigationDirection.Next, from)); + } + } + } +} \ No newline at end of file diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs index c41162ffed..dddb120f26 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Arrows.cs @@ -44,7 +44,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -83,7 +83,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -113,7 +113,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -158,7 +158,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -177,7 +177,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(top, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Down); Assert.Equal(next, result); } @@ -224,7 +224,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -262,7 +262,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -300,7 +300,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -339,7 +339,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Equal(next, result); } @@ -378,7 +378,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Null(result); } @@ -416,7 +416,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Down); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Down); Assert.Null(result); } @@ -455,7 +455,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -494,7 +494,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -525,7 +525,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -570,7 +570,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -615,7 +615,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -635,7 +635,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(top, result); } @@ -674,7 +674,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -713,7 +713,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -752,7 +752,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Equal(next, result); } @@ -790,7 +790,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Null(result); } @@ -813,7 +813,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Up); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Up); Assert.Null(result); } diff --git a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs index cc77dba1fc..efc9abedbb 100644 --- a/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs +++ b/tests/Avalonia.Input.UnitTests/KeyboardNavigationTests_Tab.cs @@ -42,7 +42,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -79,7 +79,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -123,7 +123,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -152,7 +152,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -195,7 +195,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -213,7 +213,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(top, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(top, NavigationDirection.Next); Assert.Equal(next, result); } @@ -256,7 +256,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -294,7 +294,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -332,7 +332,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -370,7 +370,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -408,7 +408,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Null(result); } @@ -446,7 +446,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -486,7 +486,7 @@ namespace Avalonia.Input.UnitTests KeyboardNavigation.SetTabOnceActiveElement(container, next); - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -524,7 +524,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -564,7 +564,7 @@ namespace Avalonia.Input.UnitTests KeyboardNavigation.SetTabOnceActiveElement(container, next); - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Next); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Next); Assert.Equal(next, result); } @@ -601,7 +601,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -638,7 +638,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -667,7 +667,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -710,7 +710,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -753,7 +753,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -772,7 +772,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(top, result); } @@ -810,7 +810,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -848,7 +848,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -886,7 +886,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -924,7 +924,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Null(result); } @@ -962,7 +962,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -1002,7 +1002,7 @@ namespace Avalonia.Input.UnitTests KeyboardNavigation.SetTabOnceActiveElement(container, next); - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -1040,7 +1040,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Equal(next, result); } @@ -1063,7 +1063,7 @@ namespace Avalonia.Input.UnitTests } }; - var result = KeyboardNavigationHandler.GetNext(current, FocusNavigationDirection.Previous); + var result = KeyboardNavigationHandler.GetNext(current, NavigationDirection.Previous); Assert.Null(result); } diff --git a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs index 9ee32149e0..8b763e7fb9 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/ExpressionSubjectTests.cs @@ -123,8 +123,8 @@ namespace Avalonia.Markup.UnitTests.Data var target = new ExpressionSubject( new ExpressionObserver(data, "DoubleValue"), typeof(string), - DefaultValueConverter.Instance, - fallbackValue: "9.8"); + "9.8", + DefaultValueConverter.Instance); target.OnNext("foo"); @@ -162,7 +162,7 @@ namespace Avalonia.Markup.UnitTests.Data new ExpressionObserver(data, "DoubleValue"), typeof(string), converter.Object, - "foo"); + converterParameter: "foo"); target.Subscribe(_ => { }); @@ -178,7 +178,7 @@ namespace Avalonia.Markup.UnitTests.Data new ExpressionObserver(data, "DoubleValue"), typeof(string), converter.Object, - "foo"); + converterParameter: "foo"); target.OnNext("bar"); diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs index ca65b763f5..1174b4ceb5 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs @@ -1,11 +1,13 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. -using Moq; +using System; +using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Markup.Data; using Avalonia.Markup.Xaml.Data; +using Moq; using ReactiveUI; using Xunit; @@ -159,6 +161,26 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data Assert.Equal("foo", child.DataContext); } + [Fact] + public void DataContext_Binding_Should_Produce_Correct_Results() + { + var root = new Decorator + { + DataContext = new { Foo = "bar" }, + }; + + var child = new Control(); + var dataContextBinding = new Binding("Foo"); + var values = new List(); + + child.GetObservable(Border.DataContextProperty).Subscribe(x => values.Add(x)); + child.Bind(ContentControl.DataContextProperty, dataContextBinding); + + root.Child = child; + + Assert.Equal(new[] { null, "bar" }, values); + } + [Fact] public void Should_Use_DefaultValueConverter_When_No_Converter_Specified() { @@ -337,5 +359,17 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data Bind(BarProperty, this.GetObservable(FooProperty)); } } + + private class InheritanceTest : Decorator + { + public static readonly StyledProperty BazProperty = + AvaloniaProperty.Register("Baz", defaultValue: 6, inherits: true); + + public int Baz + { + get { return GetValue(BazProperty); } + set { SetValue(BazProperty, value); } + } + } } } diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/DataTemplateTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/DataTemplateTests.cs index 7bfd3c0b59..f79d12cfde 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/DataTemplateTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Xaml/DataTemplateTests.cs @@ -77,6 +77,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml var canvas = (Canvas)target.Presenter.Child; Assert.Same(viewModel, target.DataContext); + Assert.Same(viewModel.Child, target.Presenter.DataContext); Assert.Same(viewModel.Child.Child, canvas.DataContext); } } diff --git a/tests/Avalonia.UnitTests/TestServices.cs b/tests/Avalonia.UnitTests/TestServices.cs index db0f600a0a..c4a0c98908 100644 --- a/tests/Avalonia.UnitTests/TestServices.cs +++ b/tests/Avalonia.UnitTests/TestServices.cs @@ -27,6 +27,9 @@ namespace Avalonia.UnitTests threadingInterface: Mock.Of(x => x.CurrentThreadIsLoopThread == true), windowingPlatform: new MockWindowingPlatform()); + public static readonly TestServices MockPlatformRenderInterface = new TestServices( + renderInterface: CreateRenderInterfaceMock()); + public static readonly TestServices MockPlatformWrapper = new TestServices( platformWrapper: Mock.Of()); @@ -41,6 +44,9 @@ namespace Avalonia.UnitTests keyboardDevice: () => new KeyboardDevice(), inputManager: new InputManager()); + public static readonly TestServices RealLayoutManager = new TestServices( + layoutManager: new LayoutManager()); + public static readonly TestServices RealStyler = new TestServices( styler: new Styler());