diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake index 084a098460c9..0a647032ea06 100644 --- a/eng/cake/dotnet.cake +++ b/eng/cake/dotnet.cake @@ -224,6 +224,13 @@ Task("uitests-apphost") Information("Building with Material3 enabled"); properties.Add("UseMaterial3", "true"); } + + var useWindowsCV1 = Argument("usewindowscv1", false); + if (useWindowsCV1) + { + Information("Building with UseWindowsCollectionView2Handler=false (CV1)"); + properties.Add("UseWindowsCollectionView2Handler", "false"); + } if (useNuget) { diff --git a/eng/devices/windows.cake b/eng/devices/windows.cake index 20c66c4e434f..2f5665bd1cf3 100644 --- a/eng/devices/windows.cake +++ b/eng/devices/windows.cake @@ -678,6 +678,14 @@ Task("uitest") SetEnvironmentVariable("WINDOWS_APP_PATH", TEST_APP); SetEnvironmentVariable("APPIUM_LOG_FILE", $"{BINLOG_DIR}/appium_windows_{name}.log"); + // Set TEST_CONFIGURATION_ARGS for handler selection and snapshot folder determination + var TEST_CONFIGURATION_ARGS = Argument("TEST_CONFIGURATION_ARGS", EnvironmentVariable("TEST_CONFIGURATION_ARGS") ?? ""); + if (!string.IsNullOrEmpty(TEST_CONFIGURATION_ARGS)) + { + Information("TEST_CONFIGURATION_ARGS: {0}", TEST_CONFIGURATION_ARGS); + SetEnvironmentVariable("TEST_CONFIGURATION_ARGS", TEST_CONFIGURATION_ARGS); + } + Information("Run UITests project {0}",PROJECT.FullPath); RunTestWithLocalDotNet(PROJECT.FullPath, CONFIGURATION, localToolPath, noBuild: true, resultsFileNameWithoutExtension: $"{name}-{CONFIGURATION}-windows"); }); diff --git a/eng/pipelines/common/ui-tests-build-sample.yml b/eng/pipelines/common/ui-tests-build-sample.yml index 06e46751cf36..7784da6a4a9c 100644 --- a/eng/pipelines/common/ui-tests-build-sample.yml +++ b/eng/pipelines/common/ui-tests-build-sample.yml @@ -12,6 +12,7 @@ parameters: headless: true runtimeVariant: 'CoreCLR' #Mono, CoreCLR, NativeAOT useMaterial3: false # NEW PARAMETER - Enable Material3 build + useWindowsCV1: false # NEW PARAMETER - Enable WindowsCollectionView1Handler testing (default false) steps: - ${{ if eq(parameters.platform, 'ios')}}: @@ -75,6 +76,12 @@ steps: Write-Host "Material3 is only supported for Android platform, ignoring useMaterial3 parameter" } + # Add UseWindowsCV1 build argument if enabled + if (("${{ parameters.useWindowsCV1 }}" -eq "true") -and ("${{ parameters.platform }}" -eq "windows")) { + $buildCommand += " --usewindowscv1=true" + Write-Host "Building with UseWindowsCollectionView2Handler=false (CV1)" + } + Invoke-Expression $buildCommand displayName: 'Build the samples' @@ -107,9 +114,13 @@ steps: artifact: ui-tests-samples-material3-coreclr - publish: $(System.DefaultWorkingDirectory)/artifacts/bin - condition: and(eq('${{ parameters.platform }}' , 'windows'), succeeded()) + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'false'), succeeded()) artifact: ui-tests-samples-windows +- publish: $(System.DefaultWorkingDirectory)/artifacts/bin + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'true'), succeeded()) + artifact: ui-tests-samples-windows-cv1 + - publish: $(System.DefaultWorkingDirectory)/artifacts/bin condition: and(ne('${{ parameters.platform }}' , 'windows'), ne('${{ parameters.runtimeVariant }}' , 'NativeAOT'), ne('${{ parameters.runtimeVariant }}' , 'CoreCLR'), eq('${{ parameters.useMaterial3 }}', 'false'), failed()) artifact: ui-tests-samples_failed_$(System.JobAttempt) @@ -128,8 +139,12 @@ steps: artifact: ui-tests-samples-material3_failed_$(System.JobAttempt) - publish: $(System.DefaultWorkingDirectory)/artifacts/bin - condition: and(ne('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.runtimeVariant }}' , 'CoreCLR'), eq('${{ parameters.useMaterial3 }}', 'true'), failed()) - artifact: ui-tests-samples-material3-coreclr_failed_$(System.JobAttempt) + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'false'), failed()) + artifact: ui-tests-samples-windows_failed_$(System.JobAttempt) + +- publish: $(System.DefaultWorkingDirectory)/artifacts/bin + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'true'), failed()) + artifact: ui-tests-samples-windows-cv1_failed_$(System.JobAttempt) - publish: $(System.DefaultWorkingDirectory)/artifacts/bin condition: and(eq('${{ parameters.platform }}' , 'windows'), failed()) diff --git a/eng/pipelines/common/ui-tests-steps.yml b/eng/pipelines/common/ui-tests-steps.yml index 9176b6eb3f07..88728446cf55 100644 --- a/eng/pipelines/common/ui-tests-steps.yml +++ b/eng/pipelines/common/ui-tests-steps.yml @@ -10,6 +10,7 @@ parameters: configuration: "Release" runtimeVariant: "CoreCLR" useMaterial3: false # NEW PARAMETER - Enable Material3 build + useWindowsCV1: false # NEW PARAMETER - Enable WindowsCollectionView1Handler test configuration testFilter: '' headless: true testConfigurationArgs: '' @@ -43,10 +44,15 @@ steps: artifact: ui-tests-samples-material3-coreclr - task: DownloadPipelineArtifact@2 - condition: eq('${{ parameters.platform }}' , 'windows') + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'false')) inputs: artifact: ui-tests-samples-windows +- task: DownloadPipelineArtifact@2 + condition: and(eq('${{ parameters.platform }}' , 'windows'), eq('${{ parameters.useWindowsCV1 }}', 'true')) + inputs: + artifact: ui-tests-samples-windows-cv1 + - ${{ if eq(parameters.platform, 'ios')}}: - bash: | chmod +x $(System.DefaultWorkingDirectory)/eng/scripts/clean-bot.sh diff --git a/eng/pipelines/common/ui-tests.yml b/eng/pipelines/common/ui-tests.yml index 1f786114fe14..fae087e15305 100644 --- a/eng/pipelines/common/ui-tests.yml +++ b/eng/pipelines/common/ui-tests.yml @@ -121,6 +121,25 @@ stages: runtimeVariant: "CoreCLR" skipProvisioning: ${{ parameters.skipProvisioning }} + # Windows CV1 Build Stage - Separate build with UseWindowsCollectionView2Handler=false + - stage: build_ui_tests_windows_cv1 + displayName: Build UITests Windows CV1 Sample App + dependsOn: [] + jobs: + - job: build_ui_tests_windows_cv1 + displayName: Build Sample App (Windows CV1) + workspace: + clean: all + pool: ${{ parameters.windowsBuildPool }} + variables: + APPIUM_HOME: $(System.DefaultWorkingDirectory)/.appium/ + steps: + - template: ui-tests-build-sample.yml + parameters: + platform: windows + runtimeVariant: "CoreCLR" + useWindowsCV1: true + skipProvisioning: ${{ parameters.skipProvisioning }} - stage: android_ui_tests_coreclr displayName: Android UITests CoreClr dependsOn: build_ui_tests_coreclr @@ -425,6 +444,41 @@ stages: platform: 'Windows' artifactName: 'uitest-snapshot-results-windows-$(System.StageName)-$(System.JobName)-$(System.JobAttempt)' + # Windows CV1 UI Tests - Testing CollectionView with UseWindowsCollectionView2Handler=false + - stage: winui_ui_tests_cv1 + displayName: WinUI UITests CV1 + dependsOn: build_ui_tests_windows_cv1 + jobs: + - ${{ each project in parameters.projects }}: + - ${{ if ne(project.winui, '') }}: + - job: CV1_winui_ui_tests_${{ project.name }} + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + workspace: + clean: all + displayName: ${{ coalesce(project.desc, project.name) }} (CV1) + pool: ${{ parameters.windowsPool }} + variables: + APPIUM_HOME: $(System.DefaultWorkingDirectory)\.appium\ + steps: + - template: ui-tests-steps.yml + parameters: + platform: windows + version: "10.0.19041.0" + device: windows10 + path: ${{ project.winui }} + app: ${{ project.app }} + provisionatorChannel: ${{ parameters.provisionatorChannel }} + runtimeVariant: "CoreCLR" + useWindowsCV1: true + testFilter: "CollectionView" + testConfigurationArgs: "UseWindowsCV1:true" + skipProvisioning: ${{ parameters.skipProvisioning }} + + # Collect and publish Windows CV1 snapshot diffs + - template: ui-tests-collect-snapshot-diffs.yml + parameters: + platform: 'Windows CV1' + artifactName: 'uitest-snapshot-results-windows-cv1-$(System.StageName)-$(System.JobName)-$(System.JobAttempt)' - stage: maccatalyst_ui_tests_coreclr displayName: MacCatalyst UITests CoreCLR dependsOn: build_ui_tests_coreclr diff --git a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets index 1bae631fbeb1..3165d6cd1a8b 100644 --- a/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets +++ b/src/Controls/src/Build.Tasks/nuget/buildTransitive/netstandard2.0/Microsoft.Maui.Controls.targets @@ -380,6 +380,10 @@ Condition="'$(UseMaterial3)' != ''" Value="$(UseMaterial3)" Trim="true" /> + diff --git a/src/Controls/src/Core/Handlers/Items/ItemsViewExtensions.Windows.cs b/src/Controls/src/Core/Handlers/Items/ItemsViewExtensions.Windows.cs new file mode 100644 index 000000000000..8d7d07fe90a5 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items/ItemsViewExtensions.Windows.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Controls.Platform; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using WASDKScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility; +using WASDKScrollingScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollingScrollBarVisibility; + +namespace Microsoft.Maui.Controls.Handlers.Items +{ + internal static class ItemsViewExtensions + { + internal static FrameworkElement RealizeEmptyViewTemplate(object? bindingContext, DataTemplate? emptyViewTemplate, IMauiContext mauiContext, ref View? mauiEmptyView, BindableObject? container = null) + { + if (emptyViewTemplate is null) + { + return CreateDefaultEmptyViewTextBlock(bindingContext); + } + + // Pass the owning ItemsView as the container so DataTemplateSelector + // implementations can inspect it (parity with iOS/Android/Tizen). + var template = emptyViewTemplate.SelectDataTemplate(bindingContext, container); + + var view = template.CreateContent() as View; + if (view is not null) + { + view.BindingContext = bindingContext; + } + + return RealizeEmptyView(view, mauiContext, ref mauiEmptyView); + } + + static TextBlock CreateDefaultEmptyViewTextBlock(object? bindingContext) + { + return new TextBlock + { + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Center, + Text = bindingContext?.ToString() ?? string.Empty + }; + } + internal static FrameworkElement RealizeEmptyView(View? view, IMauiContext mauiContext, ref View? mauiEmptyView) + { + mauiEmptyView = view ?? throw new ArgumentNullException(nameof(view)); + + var handler = view.ToHandler(mauiContext); + var platformView = handler.ContainerView ?? handler.PlatformView; + + return platformView as FrameworkElement ?? throw new InvalidOperationException("Unable to convert view to FrameworkElement"); + } + + internal static FrameworkElement RealizeHeaderFooterTemplate(object? bindingContext, DataTemplate? template, IMauiContext mauiContext, ref View? mauiView, BindableObject? container = null) + { + if (template is null) + { + return new TextBlock + { + Text = bindingContext?.ToString() ?? string.Empty, + Margin = new Microsoft.UI.Xaml.Thickness(0, 5, 0, 5) + }; + } + + // Pass the owning ItemsView as the container so DataTemplateSelector + // implementations can inspect it (parity with iOS/Android/Tizen). + var dataTemplate = template.SelectDataTemplate(bindingContext, container); + var view = dataTemplate.CreateContent() as View; + if (view is not null) + { + view.BindingContext = bindingContext; + } + + return RealizeHeaderFooterView(view, mauiContext, ref mauiView); + } + + internal static FrameworkElement RealizeHeaderFooterView(View? view, IMauiContext mauiContext, ref View? mauiView) + { + mauiView = view ?? throw new ArgumentNullException(nameof(view)); + + var handler = view.ToHandler(mauiContext); + var platformView = handler.ContainerView ?? handler.PlatformView; + + return platformView as FrameworkElement ?? throw new InvalidOperationException("Unable to convert view to FrameworkElement"); + } + + /// + /// Applies MAUI margin and layout options to a WinUI element that hosts a MAUI View + /// (e.g., CollectionView header/footer placed in a WinUI ContentControl). + /// The cross-platform layout system never applies these because the element + /// is not inside a MAUI layout. + /// + internal static void ApplyMauiLayoutProperties(View? mauiView, FrameworkElement? platformElement) + { + if (mauiView is null || platformElement is null) + { + return; + } + + // Apply MAUI margin as WinUI margin + var margin = mauiView.Margin; + platformElement.Margin = new Microsoft.UI.Xaml.Thickness(margin.Left, margin.Top, margin.Right, margin.Bottom); + + // Map HorizontalOptions/VerticalOptions to WinUI alignment + platformElement.UpdateHorizontalOptions(mauiView); + platformElement.UpdateVerticalOptions(mauiView); + } + } +} diff --git a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs index e8fc4ae79906..281436097c50 100644 --- a/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items/ItemsViewHandler.Windows.cs @@ -382,11 +382,11 @@ protected virtual void UpdateEmptyView() if (emptyViewTemplate is DataTemplate template) { - _emptyView = RealizeEmptyViewTemplate(emptyView, template); + _emptyView = ItemsViewExtensions.RealizeEmptyViewTemplate(emptyView, template, MauiContext, ref _formsEmptyView, Element); } else if (emptyView is View view) { - _emptyView = RealizeEmptyView(view); + _emptyView = ItemsViewExtensions.RealizeEmptyView(view, MauiContext, ref _formsEmptyView); } else { @@ -535,24 +535,6 @@ void ScrollViewChanged(object sender, ScrollViewerViewChangedEventArgs e) HandleScroll(_scrollViewer); } - FrameworkElement RealizeEmptyViewTemplate(object bindingContext, DataTemplate emptyViewTemplate) - { - var template = emptyViewTemplate.SelectDataTemplate(bindingContext, ItemsView); - var templatedElement = template.CreateContent() as View; - templatedElement.BindingContext = bindingContext; - return RealizeEmptyView(templatedElement); - } - - FrameworkElement RealizeEmptyView(View view) - { - _formsEmptyView = view ?? throw new ArgumentNullException(nameof(view)); - - var handler = view.ToHandler(MauiContext); - var platformView = handler.ContainerView ?? handler.PlatformView; - - return platformView as FrameworkElement; - } - internal void HandleScroll(ScrollViewer scrollViewer) { var itemsViewScrolledEventArgs = new ItemsViewScrolledEventArgs diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs new file mode 100644 index 000000000000..8e4b49e2dfdd --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using Microsoft.Maui.Controls.Platform; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using WItemsView = Microsoft.UI.Xaml.Controls.ItemsView; +using WSolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; +using WThickness = Microsoft.UI.Xaml.Thickness; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +public partial class CollectionViewHandler2 +{ + public CollectionViewHandler2() : base(Mapper) + { + } + + + public CollectionViewHandler2(PropertyMapper? mapper = null) : base(mapper ?? Mapper) + { + } + + public static PropertyMapper Mapper = new(ReorderableItemsViewMapper) + { + [GroupableItemsView.IsGroupedProperty.PropertyName] = MapIsGrouped, + [GroupableItemsView.GroupHeaderTemplateProperty.PropertyName] = MapGroupHeaderTemplate, + [GroupableItemsView.GroupFooterTemplateProperty.PropertyName] = MapGroupFooterTemplate, + [SelectableItemsView.SelectedItemProperty.PropertyName] = MapSelectedItem, + [SelectableItemsView.SelectedItemsProperty.PropertyName] = MapSelectedItems, + [SelectableItemsView.SelectionModeProperty.PropertyName] = MapSelectionMode, + [StructuredItemsView.ItemSizingStrategyProperty.PropertyName] = MapItemSizingStrategy, + + }; +} +public partial class CollectionViewHandler2 : ReorderableItemsViewHandler2 +{ + bool _ignorePlatformSelectionChange; + bool _selectionDirty; + + // Cache for MeasureFirstItem optimization + global::Windows.Foundation.Size _firstItemMeasuredSize = global::Windows.Foundation.Size.Empty; + + public static void MapIsGrouped(CollectionViewHandler2 handler, GroupableItemsView itemsView) + { + // When IsGrouped changes with GridItemsLayout, we need to recreate the layout + // because grouped grids use GroupableUniformGridLayout while ungrouped use UniformGridLayout + if (handler.Layout is GridItemsLayout) + { + handler.UpdateItemsLayout(); + } + + handler.UpdateItemsSource(); + } + + public static void MapGroupHeaderTemplate(CollectionViewHandler2 handler, GroupableItemsView itemsView) + { + handler.UpdateItemsSource(); + } + + public static void MapGroupFooterTemplate(CollectionViewHandler2 handler, GroupableItemsView itemsView) + { + handler.UpdateItemsSource(); + } + + public static void MapItemSizingStrategy(CollectionViewHandler2 handler, ItemsView itemsView) + { + handler.InvalidateFirstItemSize(); + handler.UpdateItemsSource(); + } + + public static void MapItemsSource(CollectionViewHandler2 handler, SelectableItemsView itemsView) + { + ItemsViewHandler2.MapItemsSource(handler, itemsView); + } + + public static void MapSelectedItem(CollectionViewHandler2 handler, SelectableItemsView itemsView) + { + handler.UpdatePlatformSelection(); + } + + public static void MapSelectedItems(CollectionViewHandler2 handler, SelectableItemsView itemsView) + { + handler.UpdatePlatformSelection(); + } + + /// + /// Left margin applied to item content when in multi-select mode to avoid overlapping + /// the WinUI ItemContainer checkbox. Matches WinUI's ListViewItemMultiselectCheckBoxMargin. + /// + internal static readonly WThickness MultiSelectContentMargin = new WThickness(32, 0, 0, 0); + static readonly WThickness ZeroMargin = new WThickness(0); + + public static void MapSelectionMode(CollectionViewHandler2 handler, SelectableItemsView itemsView) + { + handler.UpdatePlatformSelection(); + handler.UpdateMultiSelectContentMargin(); + // The margin change affects item sizes, so invalidate the MeasureFirstItem cache + // to avoid stale sizes being applied to items realized after the mode switch. + handler.InvalidateFirstItemSize(); + } + + /// + /// When in multi-select mode the WinUI ItemContainer renders a checkbox overlay + /// on the left. Add left margin to the content (ElementWrapper) so that user + /// content is not hidden behind the checkbox. + /// + void UpdateMultiSelectContentMargin() + { + if (PlatformView is null || ItemsView is null) + return; + + var margin = ItemsView.SelectionMode == SelectionMode.Multiple + ? MultiSelectContentMargin + : ZeroMargin; + + foreach (var container in PlatformView.GetChildren()) + { + if (container?.Child is ElementWrapper wrapper && !wrapper.IsHeaderOrFooter) + { + wrapper.Margin = margin; + } + } + } + + /// + /// Gets the cached first item measured size for MeasureFirstItem optimization. + /// Returns Size.Empty if not cached or not using MeasureFirstItem strategy. + /// + internal global::Windows.Foundation.Size GetCachedFirstItemSize() + { + if (VirtualView is CollectionView cv && cv.ItemSizingStrategy == ItemSizingStrategy.MeasureFirstItem) + { + return _firstItemMeasuredSize; + } + return global::Windows.Foundation.Size.Empty; + } + + /// + /// Sets the cached first item measured size for MeasureFirstItem optimization. + /// + internal void SetCachedFirstItemSize(global::Windows.Foundation.Size size) + { + if (VirtualView is CollectionView cv && cv.ItemSizingStrategy == ItemSizingStrategy.MeasureFirstItem) + { + _firstItemMeasuredSize = size; + } + } + + /// + /// Invalidates the cached first item size. + /// + internal void InvalidateFirstItemSize() + { + _firstItemMeasuredSize = global::Windows.Foundation.Size.Empty; + } + + protected override void ConnectHandler(WItemsView platformView) + { + base.ConnectHandler(platformView); + + ItemsView.SelectionChanged += VirtualSelectionChanged; + + if (PlatformView is not null) + { + PlatformView.SetBinding(WItemsView.SelectionModeProperty, + new UI.Xaml.Data.Binding + { + Source = ItemsView, + Path = new UI.Xaml.PropertyPath("SelectionMode"), + Converter = new SelectionModeConverter(), + Mode = UI.Xaml.Data.BindingMode.TwoWay + }); + + PlatformView.SelectionChanged += PlatformSelectionChanged; + PlatformView.Loaded += OnPlatformViewLoaded; + } + } + + void OnPlatformViewLoaded(object? sender, UI.Xaml.RoutedEventArgs e) + { + // Re-sync visual states to MAUI selection whenever the view re-enters the visual tree. + // This is the fix for the stale selection highlight on navigation back + if (_selectionDirty) + { + _selectionDirty = false; + UpdatePlatformSelection(); + } + } + + protected override void DisconnectHandler(WItemsView platformView) + { + if (platformView is null) + { + return; + } + + platformView.SelectionChanged -= PlatformSelectionChanged; + platformView.Loaded -= OnPlatformViewLoaded; + platformView.ClearValue(WItemsView.SelectionModeProperty); + + if (ItemsView is not null) + { + ItemsView.SelectionChanged -= VirtualSelectionChanged; + } + + base.DisconnectHandler(platformView); + } + + protected override void UpdateItemsSource() + { + _ignorePlatformSelectionChange = true; + try + { + base.UpdateItemsSource(); + UpdatePlatformSelection(); + } + finally + { + _ignorePlatformSelectionChange = false; + } + } + + /// + /// Handles changes to the virtual (MAUI) selection and synchronizes them to the platform. + /// + void VirtualSelectionChanged(object? sender, SelectionChangedEventArgs? e) + { + UpdatePlatformSelection(); + } + + /// + /// Handles changes to the platform (WinUI) selection and synchronizes them to the virtual view. + /// + void PlatformSelectionChanged(WItemsView sender, ItemsViewSelectionChangedEventArgs args) + { + if (PlatformView is null) + return; + + UpdateVirtualSelection(); + } + + /// + /// Reads the current platform selection state and updates the virtual (MAUI) selection accordingly. + /// + void UpdateVirtualSelection() + { + if (_ignorePlatformSelectionChange || ItemsView is null || PlatformView is null) + { + return; + } + + switch (PlatformView.SelectionMode) + { + case ItemsViewSelectionMode.Single: + UpdateVirtualSingleSelection(); + break; + case ItemsViewSelectionMode.Multiple: + UpdateVirtualMultipleSelection(); + break; + case ItemsViewSelectionMode.None: + default: + break; + } + + UpdateVisualStates(); + } + + void UpdateVisualStates() + { + if (PlatformView is null || ItemsView is null) + return; + + var transparent = new WSolidColorBrush(Microsoft.UI.Colors.Transparent); + + foreach (var itemContainer in PlatformView.GetChildren()) + { + if (itemContainer?.Child is ElementWrapper wrapper && wrapper.VirtualView is VisualElement visualElement) + { + var actualItem = visualElement.BindingContext; + bool isSelected = object.Equals(ItemsView.SelectedItem, actualItem) || ItemsView.SelectedItems.Contains(actualItem); + VisualStateManager.GoToState(visualElement, isSelected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); + + // When the item template defines a "Selected" visual state, MAUI + // handles the selection appearance. Suppress the native WinUI + // selection border (PART_SelectionVisual) and selected background + // fill on that container so the two don't overlap. Items without + // a Selected visual state keep the default WinUI selection visuals. + if (visualElement.HasVisualState(VisualStateManager.CommonStates.Selected)) + { + itemContainer.Resources["ItemContainerSelectedBorderBrush"] = transparent; + itemContainer.Resources["ItemContainerSelectedPointerOverBorderBrush"] = transparent; + itemContainer.Resources["ItemContainerSelectedPressedBorderBrush"] = transparent; + itemContainer.Resources["ItemContainerSelectionVisualBackground"] = transparent; + itemContainer.Resources["ItemContainerSelectionVisualPointerOverBackground"] = transparent; + itemContainer.Resources["ItemContainerSelectionVisualPressedBackground"] = transparent; + itemContainer.Resources["ItemContainerSelectedInnerBorderBrush"] = transparent; + // Also suppress the selected background fill so MAUI's VSM + // is the only thing painting the selection appearance. + itemContainer.Resources["ItemContainerSelectedBackground"] = transparent; + itemContainer.Resources["ItemContainerSelectedPointerOverBackground"] = transparent; + itemContainer.Resources["ItemContainerSelectedPressedBackground"] = transparent; + } + } + } + } + + void UpdateVirtualSingleSelection() + { + if (PlatformView is null || ItemsView is null) + return; + + var selectedItem = PlatformView.SelectedItem is ItemTemplateContext2 itemPair + ? itemPair.Item + : PlatformView.SelectedItem; + + ItemsView.SelectionChanged -= VirtualSelectionChanged; + ItemsView.SelectedItem = selectedItem; + + ItemsView.SelectionChanged += VirtualSelectionChanged; + } + + void UpdateVirtualMultipleSelection() + { + if (PlatformView is null || ItemsView is null) + return; + + ItemsView.SelectionChanged -= VirtualSelectionChanged; + + var newSelection = ComputeNewMultipleSelection(); + if (!SelectionListsAreEqual(newSelection, ItemsView.SelectedItems)) + { + ItemsView.UpdateSelectedItems(newSelection); + } + + ItemsView.SelectionChanged += VirtualSelectionChanged; + } + + static bool SelectionListsAreEqual(IList list1, IList list2) + { + if (list1.Count != list2.Count) + return false; + + var set2 = new HashSet(list2); + for (int i = 0; i < list1.Count; i++) + { + if (!set2.Contains(list1[i])) + return false; + } + return true; + } + + /// + /// Computes the new multiple selection list by merging the current platform selection + /// with the existing virtual selection, preserving the order of previously selected items + /// and appending newly selected items at the end. + /// + List ComputeNewMultipleSelection() + { + // Extract actual items from platform selection (unwrapping ItemTemplateContext2) + var currentPlatformSelection = ExtractPlatformSelectedItems(); + var previousSelection = new HashSet(ItemsView.SelectedItems); + var newSelection = new List(); + var addedToSelection = new HashSet(); + + // Keep existing items that are still selected (maintains their order) + foreach (var existingItem in ItemsView.SelectedItems) + { + if (currentPlatformSelection.Contains(existingItem)) + { + newSelection.Add(existingItem); + addedToSelection.Add(existingItem); + } + } + + // Append newly selected items (in platform but not in previous virtual selection) + foreach (var item in currentPlatformSelection) + { + if (!previousSelection.Contains(item) && !addedToSelection.Contains(item)) + { + newSelection.Add(item); + } + } + + return newSelection; + } + + /// + /// Extracts the actual data items from the platform's selected items, + /// unwrapping wrappers when present. + /// + HashSet ExtractPlatformSelectedItems() + { + var result = new HashSet(); + foreach (var item in PlatformView.SelectedItems) + { + var selectedItem = item is ItemTemplateContext2 itc ? itc.Item : item; + if (selectedItem is not null) + result.Add(selectedItem); + } + return result; + } + + /// + /// Reads the current virtual (MAUI) selection state and updates the platform (WinUI) selection accordingly. + /// + void UpdatePlatformSelection() + { + if (PlatformView is null || ItemsView is null) + { + return; + } + + // When the WinUI ItemsView is not loaded (e.g. the page is hidden during navigation), + // Select(index) calls silently fail because item containers are not realized. + // Defer the selection update until the view is loaded again. + if (!PlatformView.IsLoaded) + { + _selectionDirty = true; + return; + } + + var itemList = PlatformView.ItemsSource as ICollectionView; + + if (itemList is null) + { + return; + } + + _ignorePlatformSelectionChange = true; + + switch (PlatformView.SelectionMode) + { + case ItemsViewSelectionMode.Single: + if (ItemsView.SelectedItem is null) + { + PlatformView.DeselectAll(); + } + else + { + var selectedIndex = FindItemIndexInSource(itemList, ItemsView.SelectedItem); + if (selectedIndex >= 0) + { + PlatformView.Select(selectedIndex); + } + } + + break; + case ItemsViewSelectionMode.Multiple: + PlatformView.DeselectAll(); + + // Use safe enumeration to avoid ArgumentOutOfRangeException during collection updates + int index = 0; + foreach (var nativeItem in itemList) + { + if (nativeItem is ItemTemplateContext2 itemPair && ItemsView.SelectedItems.Contains(itemPair.Item)) + { + PlatformView.Select(index); + } + else if (ItemsView.SelectedItems.Contains(nativeItem)) + { + PlatformView.Select(index); + } + index++; + } + break; + case ItemsViewSelectionMode.None: + case ItemsViewSelectionMode.Extended: + default: + break; + } + + _ignorePlatformSelectionChange = false; + UpdateVisualStates(); + } + + /// + /// Finds the index of the specified item in the collection view source, using a single-pass + /// iteration. Returns -1 if the item is not found. + /// + static int FindItemIndexInSource(ICollectionView itemList, object targetItem) + { + int index = 0; + foreach (var nativeItem in itemList) + { + var actualItem = nativeItem is ItemTemplateContext2 itc ? itc.Item : nativeItem; + if (object.Equals(actualItem, targetItem)) + { + return index; + } + index++; + } + return -1; + } +} + +/// +/// Converts between MAUI and WinUI values. +/// +partial class SelectionModeConverter : UI.Xaml.Data.IValueConverter +{ + /// + public object Convert(object value, Type targetType, object parameter, string language) + { + var selectionMode = (SelectionMode)value; + return selectionMode switch + { + SelectionMode.Single => ItemsViewSelectionMode.Single, + SelectionMode.Multiple => ItemsViewSelectionMode.Multiple, + _ => ItemsViewSelectionMode.None, + }; + } + + /// + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + var winUISelectionMode = (ItemsViewSelectionMode)value; + return winUISelectionMode switch + { + ItemsViewSelectionMode.None => SelectionMode.None, + ItemsViewSelectionMode.Single => SelectionMode.Single, + ItemsViewSelectionMode.Multiple => SelectionMode.Multiple, + _ => SelectionMode.None, + }; + } +} \ No newline at end of file diff --git a/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.Windows.cs new file mode 100644 index 000000000000..8f0640c7235e --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.Windows.cs @@ -0,0 +1,1964 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using Microsoft.Maui.Controls.Handlers.Items; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Controls.Platform; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using WASDKScrollBarVisibility = Microsoft.UI.Xaml.Controls.ScrollBarVisibility; +using WItemsView = Microsoft.UI.Xaml.Controls.ItemsView; +using WScrollPresenter = Microsoft.UI.Xaml.Controls.Primitives.ScrollPresenter; +using WRect = Windows.Foundation.Rect; +using WVisibility = Microsoft.UI.Xaml.Visibility; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// Base handler for ItemsView controls on Windows, providing item source management, +/// layout, scrolling, snap points, empty views, headers, and footers. +/// +public abstract class ItemsViewHandler2 : ViewHandler where TItemsView : ItemsView +{ + /// + /// Alignment ratio that positions the item at the start (top/left) of the viewport. + /// + const double AlignToStart = 0.0; + + /// + /// Alignment ratio that positions the item at the center of the viewport. + /// + const double AlignToCenter = 0.5; + + /// + /// Alignment ratio that positions the item at the end (bottom/right) of the viewport. + /// + const double AlignToEnd = 1.0; + + CollectionViewSource? _collectionViewSource; + IList? _itemsSource; + ItemFactory? _itemFactory; + + FrameworkElement? _emptyView; + View? _mauiEmptyView; + bool _emptyViewDisplayed; + double _previousHorizontalOffset; + double _previousVerticalOffset; + + FrameworkElement? _footer; + View? _mauiFooter; + bool _footerDisplayed; + + FrameworkElement? _header; + View? _mauiHeader; + bool _headerDisplayed; + + ScrollViewer? _scrollViewer; + + int _lastRemainingItemsThresholdIndex = -1; + + WeakNotifyPropertyChangedProxy? _layoutPropertyChangedProxy; + PropertyChangedEventHandler? _layoutPropertyChanged; + bool _isScrollingForItemsUpdate; + SnapPointsType _snapPointsType; + SnapPointsAlignment _snapPointsAlignment; + bool _isSnapping; // guard against re-entrant snap from our own ChangeView + int _pendingScrollToIndex = -1; + RoutedEventHandler? _pendingLoadedHandler; + protected TItemsView ItemsView => VirtualView; + protected TItemsView Element => VirtualView; + + protected abstract IItemsLayout Layout { get; } + + bool IsLayoutHorizontal => Layout switch + { + LinearItemsLayout linearLayout => linearLayout.Orientation == ItemsLayoutOrientation.Horizontal, + GridItemsLayout gridLayout => gridLayout.Orientation == ItemsLayoutOrientation.Horizontal, + _ => false + }; + + public ItemsViewHandler2() : base(ItemsViewMapper) + { + + } + + public ItemsViewHandler2(PropertyMapper? mapper = null) : base(mapper ?? ItemsViewMapper) + { + + } + + public static PropertyMapper> ItemsViewMapper = new(ViewMapper) + { + [Controls.ItemsView.ItemsSourceProperty.PropertyName] = MapItemsSource, + [Controls.ItemsView.HorizontalScrollBarVisibilityProperty.PropertyName] = MapHorizontalScrollBarVisibility, + [Controls.ItemsView.VerticalScrollBarVisibilityProperty.PropertyName] = MapVerticalScrollBarVisibility, + [Controls.ItemsView.ItemTemplateProperty.PropertyName] = MapItemTemplate, + [Controls.ItemsView.EmptyViewProperty.PropertyName] = MapEmptyView, + [Controls.ItemsView.EmptyViewTemplateProperty.PropertyName] = MapEmptyViewTemplate, + [Controls.ItemsView.FlowDirectionProperty.PropertyName] = MapFlowDirection, + [Controls.ItemsView.IsVisibleProperty.PropertyName] = MapIsVisible, + [Controls.ItemsView.ItemsUpdatingScrollModeProperty.PropertyName] = MapItemsUpdatingScrollMode, + [Controls.StructuredItemsView.ItemsLayoutProperty.PropertyName] = MapItemsLayout, + [Controls.StructuredItemsView.HeaderProperty.PropertyName] = MapHeader, + [Controls.StructuredItemsView.HeaderTemplateProperty.PropertyName] = MapHeaderTemplate, + [Controls.StructuredItemsView.FooterProperty.PropertyName] = MapFooter, + [Controls.StructuredItemsView.FooterTemplateProperty.PropertyName] = MapFooterTemplate, + [nameof(IView.IsEnabled)] = MapIsEnabled, + }; + + bool _scrollUpdatePending; + + public static void MapItemsSource(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateItemsSource(); + } + + // Whole-list fade for the directly-disabled case (CollectionView.IsEnabled = false), + // matching CV1's behavior on Windows. The per-ItemContainer Disabled VisualState fade + // is suppressed app-wide via the ItemContainerDisabledOpacity = 1.0 override in + // ItemsViewStyles.xaml so a parent-cascade disable (e.g. RefreshView.IsEnabled = false) + // does NOT fade items — that scenario doesn't invoke this mapper because MAUI's + // VirtualView.IsEnabled remains true. Here we only fade when the user explicitly + // disables the CollectionView itself, by applying the fade to MauiItemsView as a whole. + // The default ViewHandler.MapIsEnabled is still invoked first to preserve WinUI's + // IsEnabled = false semantics (input blocking, focus, automation). + // Declared as 'static new' to suppress CS0108 (this hides the inherited public + // ViewHandler.MapIsEnabled); kept private so it does not appear in the public API + // surface (no PublicAPI.Unshipped.txt entry needed). + // Fixes: https://github.com/dotnet/maui/issues/28343 + static new void MapIsEnabled(IViewHandler handler, IView view) + { + ViewHandler.MapIsEnabled(handler, view); + + if (handler.PlatformView is MauiItemsView miv) + miv.Opacity = view.IsEnabled ? 1.0 : 0.3; + } + + // Intentionally empty: ItemsUpdatingScrollMode is handled during scroll events + // via ApplyItemsUpdatingScrollMode, not as a direct property map. + public static void MapItemsUpdatingScrollMode(ItemsViewHandler2 handler, ItemsView itemsView) + { + } + + public static void MapHorizontalScrollBarVisibility(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateHorizontalScrollBarVisibility(); + } + + public static void MapVerticalScrollBarVisibility(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateVerticalScrollBarVisibility(); + } + + public static void MapItemTemplate(ItemsViewHandler2 handler, ItemsView itemsView) + { + // Only invalidate cache if: + // 1. ItemSizingStrategy is MeasureFirstItem + // 2. Control is already loaded (runtime template change, not initial load) + if (handler is CollectionViewHandler2 cvHandler && itemsView is CollectionView cv && + cv.ItemSizingStrategy == ItemSizingStrategy.MeasureFirstItem && itemsView.IsLoadedOnPlatform()) + { + cvHandler.InvalidateFirstItemSize(); + } + + handler.UpdateItemsSource(); + } + + public static void MapEmptyView(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateEmptyView(); + } + + public static void MapEmptyViewTemplate(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateEmptyView(); + } + + public static void MapFlowDirection(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.PlatformView.UpdateFlowDirection(itemsView); + } + + public static void MapIsVisible(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.PlatformView.UpdateVisibility(itemsView); + handler.UpdateEmptyViewVisibility(); + } + + public static void MapItemsLayout(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateItemsLayout(); + } + + public static void MapHeader(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateHeader(); + } + + public static void MapHeaderTemplate(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateHeader(); + } + + public static void MapFooter(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateFooter(); + } + + public static void MapFooterTemplate(ItemsViewHandler2 handler, ItemsView itemsView) + { + handler.UpdateFooter(); + } + + protected override WItemsView CreatePlatformView() + { + var itemsView = SelectListViewBase(); + + return itemsView; + } + + protected override void ConnectHandler(WItemsView platformView) + { + base.ConnectHandler(platformView); + + if (Layout is not null) + { + _layoutPropertyChanged ??= LayoutPropertyChanged; + _layoutPropertyChangedProxy = new WeakNotifyPropertyChangedProxy(Layout, _layoutPropertyChanged); + } + else + { + _layoutPropertyChangedProxy?.Unsubscribe(); + _layoutPropertyChangedProxy = null; + } + + VirtualView.ScrollToRequested += ScrollToRequested; + FindScrollViewer(); + } + + + protected override void DisconnectHandler(WItemsView platformView) + { + // Phase 1: Unsubscribe ALL events first to prevent callbacks during cleanup + if (_collectionViewSource?.Source is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= ItemsChanged; + } + + if (_scrollViewer is not null) + { + _scrollViewer.ViewChanged -= ScrollViewChanged; + _scrollViewer.ViewChanging -= OnScrollViewerViewChanging; + _scrollViewer = null; + } + + // Unsubscribe pending Loaded handler if ScrollView wasn't found yet + if (_pendingLoadedHandler is not null) + { + platformView.Loaded -= _pendingLoadedHandler; + _pendingLoadedHandler = null; + } + + if (_scrollUpdatePending) + { + platformView.LayoutUpdated -= OnLayoutUpdated; + _scrollUpdatePending = false; + } + + _layoutPropertyChangedProxy?.Unsubscribe(); + _layoutPropertyChangedProxy = null; + + if (VirtualView is not null) + { + VirtualView.ScrollToRequested -= ScrollToRequested; + } + + // Safe subscription cleanup only — do NOT call CleanUpCollectionViewSource() here. + // + // CleanUpCollectionViewSource() performs two operations that trigger WinUI side effects + // during teardown: + // 1. Sets _collectionViewSource.Source = null → causes WinUI to recycle/release elements + // 2. Calls PlatformView.GetChildren() → triggers element enumeration + // while the ItemsRepeater is tearing down, leading to collection change notifications + // and potential InvalidOperationException. + // + // The CleanUp() methods below are safe because they ONLY unsubscribe event handlers + // (CollectionChanged, group change notifications) without touching WinUI state. + if (_collectionViewSource?.Source is ObservableItemTemplateCollection2 observableCollection) + { + observableCollection.CleanUp(); + } + else if (_collectionViewSource?.Source is GroupedItemTemplateCollection2 groupedCollection) + { + groupedCollection.CleanUp(); + + } + + _itemFactory?.CleanUp(); + _itemFactory = null; + + // Clean up logical children for empty view, header, and footer to prevent memory leaks + if (_mauiEmptyView is not null && _emptyViewDisplayed) + { + ItemsView?.RemoveLogicalChild(_mauiEmptyView); + } + _mauiEmptyView?.Handler?.DisconnectHandler(); + _mauiEmptyView = null; + _emptyView = null; + _emptyViewDisplayed = false; + + if (_mauiHeader is not null && _headerDisplayed) + { + ItemsView?.RemoveLogicalChild(_mauiHeader); + } + _mauiHeader = null; + _header = null; + _headerDisplayed = false; + + if (_mauiFooter is not null && _footerDisplayed) + { + ItemsView?.RemoveLogicalChild(_mauiFooter); + } + _mauiFooter = null; + _footer = null; + _footerDisplayed = false; + + base.DisconnectHandler(platformView); + } + + CollectionViewSource CreateCollectionViewSource() + { + var itemsSource = Element.ItemsSource; + var itemTemplate = Element.ItemTemplate; + + if (itemTemplate is not null && itemsSource is not null) + { + if (ItemsView is GroupableItemsView groupableItemsView && groupableItemsView.IsGrouped + && IsItemsSourceGrouped(itemsSource)) + { + return new CollectionViewSource + { + Source = TemplatedItemSourceFactory2.CreateGrouped(itemsSource, itemTemplate, + groupableItemsView.GroupHeaderTemplate, groupableItemsView.GroupFooterTemplate, + Element, mauiContext: MauiContext) + , + IsSourceGrouped = false + }; + } + + else + { + var flattenedSource = itemsSource; + if (itemsSource is not null && IsItemsSourceGrouped(itemsSource)) + { + flattenedSource = FlattenGroupedItemsSource(itemsSource); + } + return new CollectionViewSource + { + Source = TemplatedItemSourceFactory2.Create(flattenedSource, itemTemplate, Element, mauiContext: MauiContext), + IsSourceGrouped = false + }; + } + } + + // When IsGrouped=true but no ItemTemplate, flatten the grouped source so that + // the WinUI ItemsView (which doesn't support native grouping) can display the + // individual items. Without this, the raw grouped collections are set as the source + // but ItemsView cannot render them, resulting in an empty list. + if (itemsSource is not null && + ItemsView is GroupableItemsView groupable && groupable.IsGrouped && + IsItemsSourceGrouped(itemsSource)) + { + return new CollectionViewSource + { + Source = FlattenGroupedItemsSource(itemsSource), + IsSourceGrouped = false + }; + } + + return new CollectionViewSource + { + Source = itemsSource, + IsSourceGrouped = false + }; + } + + bool IsItemsSourceGrouped(object itemsSource) + { + if (itemsSource is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item is IEnumerable && item is not string) + { + return true; + } + break; + } + } + return false; + } + + IEnumerable FlattenGroupedItemsSource(IEnumerable groupedSource) + { + return groupedSource.Cast(). + Where(group => group is IEnumerable && group is not string). + SelectMany(group => ((IEnumerable)group).Cast()). + ToList(); + } + + + protected virtual void UpdateItemsSource() + { + if (PlatformView is null) + { + return; + } + + // Save reference before cleanup nulls the field + var oldCollectionViewSource = _collectionViewSource; + + // Safe cleanup: unsubscribe events, clean up collections and factory. + CleanUpCollectionViewSource(); + + // Create the new CollectionViewSource + _collectionViewSource = CreateCollectionViewSource(); + _itemsSource = _collectionViewSource?.Source as IList; + + if (_collectionViewSource?.Source is INotifyCollectionChanged incc) + { + incc.CollectionChanged += ItemsChanged; + } + + // Set up the new ItemTemplate + if (VirtualView.ItemTemplate is not null) + { + _itemFactory = new ItemFactory(Element); + PlatformView.ItemTemplate = _itemFactory; + } + else if (PlatformView.ItemTemplate is not null) + { + PlatformView.ItemTemplate = null; + } + + PlatformView.ItemsSource = _collectionViewSource?.View; + + if (PlatformView is MauiItemsView mauiItemsViewFlat) + { + // The 'as ObservableItemTemplateCollection2' cast intentionally returns null for + // grouped sources (which use GroupedItemTemplateCollection2, not + // ObservableItemTemplateCollection2). Null is the correct sentinel for + // PerformReorder to skip the MoveItemAndSyncSource fast path for grouped lists. + mauiItemsViewFlat.FlatTemplateCollection = + _collectionViewSource?.Source as ObservableItemTemplateCollection2; + } + + // Now safely null the old Source — PlatformView no longer references it, + // so the CollectionChanged(Reset) it fires won't cause WinUI side effects. + if (oldCollectionViewSource is not null) + { + oldCollectionViewSource.Source = null; + } + + _scrollViewer?.ChangeView(0, 0, null, disableAnimation: true); + _previousHorizontalOffset = 0; + _previousVerticalOffset = 0; + + UpdateEmptyViewVisibility(); + } + + void CleanUpCollectionViewSource() + { + if (PlatformView is MauiItemsView mauiItemsViewClean) + { + mauiItemsViewClean.FlatTemplateCollection = null; + } + + // Clean up the recycle pool in the old ItemFactory to release pooled elements + _itemFactory?.CleanUp(); + _itemFactory = null; + + if (_collectionViewSource is not null) + { + if (_collectionViewSource.Source is ObservableItemTemplateCollection2 observableItemTemplateCollection) + { + observableItemTemplateCollection.CleanUp(); + } + else if (_collectionViewSource.Source is GroupedItemTemplateCollection2 groupedItemTemplateCollection) + { + groupedItemTemplateCollection.CleanUp(); + } + + if (_collectionViewSource.Source is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= ItemsChanged; + } + + _collectionViewSource.Source = null; + _collectionViewSource = null; + } + + // Remove all children inside the ItemsSource + if (VirtualView is not null && PlatformView is not null) + { + // CV2 uses ItemContainer + ElementWrapper, not ItemContentControl. + // Check both types to handle CV1 and CV2 handlers correctly. + foreach (var itemContainer in PlatformView.GetChildren()) + { + if (itemContainer?.Child is ElementWrapper wrapper && wrapper.VirtualView is View mauiView) + { + mauiView.DisconnectHandlers(); + VirtualView.RemoveLogicalChild(mauiView); + } + } + + foreach (var item in PlatformView.GetChildren()) + { + if (item is not null) + { + var element = item.GetVisualElement(); + VirtualView.RemoveLogicalChild(element); + } + } + } + + if (VirtualView?.ItemsSource is null && PlatformView is not null) + { + PlatformView.ItemsSource = null; + } + } + + void ItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // Skip if handler is disconnected + if (PlatformView is null || VirtualView is null) + { + return; + } + + // During drag-drop reorder, MoveItemAndSyncSource fires CollectionChanged(Remove) then + // CollectionChanged(Add) on the template collection. Without this guard those events would + // trigger ApplyItemsUpdatingScrollMode → StartBringItemIntoView(0), scrolling the list + // to the top (KeepItemsInView is the default mode = 0). IsReordering is set synchronously + // before any collection mutation, so this check reliably catches those spurious events. + if (PlatformView is MauiItemsView { IsReordering: true }) + { + return; + } + + UpdateEmptyViewVisibility(); + + if (e.Action == NotifyCollectionChangedAction.Add || e.Action == NotifyCollectionChangedAction.Reset) + { + _lastRemainingItemsThresholdIndex = -1; + } + + if (!_scrollUpdatePending && PlatformView is not null) + { + _scrollUpdatePending = true; + + PlatformView.LayoutUpdated += OnLayoutUpdated; + } + } + void OnLayoutUpdated(object? s, object args) + { + if (PlatformView is null) + { + _scrollUpdatePending = false; + return; + } + + PlatformView.LayoutUpdated -= OnLayoutUpdated; + _scrollUpdatePending = false; + ApplyItemsUpdatingScrollMode(); + } + + void ApplyItemsUpdatingScrollMode() + { + if (PlatformView is null || VirtualView is null) + { + return; + } + + if (VirtualView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepScrollOffset) + { + return; + } + + if (!PlatformView.IsLoaded) + { + return; + } + + if (_itemsSource is null || _itemsSource.Count == 0) + { + return; + } + + // Get the actual count from the CollectionView's view + var viewCount = _collectionViewSource?.View?.Count ?? 0; + if (viewCount == 0) + { + return; + } + + // Force layout update to ensure items are properly positioned + PlatformView.UpdateLayout(); + + if (VirtualView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepItemsInView) + { + _isScrollingForItemsUpdate = true; + + // Use dispatcher to ensure the scroll happens after layout is fully complete + VirtualView.Dispatcher.Dispatch(() => + { + if (PlatformView is null || !PlatformView.IsLoaded) + { + return; + } + + // Keeps the first item in the list displayed when new items are added. + PlatformView.StartBringItemIntoView(0, new BringIntoViewOptions() + { + AnimationDesired = false, + VerticalAlignmentRatio = AlignToStart, + HorizontalAlignmentRatio = AlignToStart + }); + }); + } + else if (VirtualView.ItemsUpdatingScrollMode == ItemsUpdatingScrollMode.KeepLastItemInView) + { + _isScrollingForItemsUpdate = true; + + // Use the view count to ensure we're scrolling to the correct last item + var lastIndex = viewCount - 1; + if (lastIndex >= 0) + { + _pendingScrollToIndex = lastIndex; + + // Use dispatcher to ensure the scroll happens after layout is fully complete + VirtualView.Dispatcher.Dispatch(() => + { + if (PlatformView is null || !PlatformView.IsLoaded || _pendingScrollToIndex < 0) + { + _pendingScrollToIndex = -1; + return; + } + + var currentViewCount = _collectionViewSource?.View?.Count ?? 0; + var scrollIndex = Math.Min(_pendingScrollToIndex, currentViewCount - 1); + + if (scrollIndex >= 0) + { + PlatformView.StartBringItemIntoView(scrollIndex, new BringIntoViewOptions() + { + AnimationDesired = false, + VerticalAlignmentRatio = AlignToEnd, + HorizontalAlignmentRatio = AlignToEnd + }); + } + + _pendingScrollToIndex = -1; + }); + } + } + } + + MauiItemsView SelectListViewBase() + { + var itemsView = new MauiItemsView() + { + Layout = CreateItemsLayout() + }; + + if (IsLayoutHorizontal) + { + ScrollViewer.SetHorizontalScrollMode(itemsView, UI.Xaml.Controls.ScrollMode.Enabled); + ScrollViewer.SetHorizontalScrollBarVisibility(itemsView, WASDKScrollBarVisibility.Visible); + + ScrollViewer.SetVerticalScrollMode(itemsView, UI.Xaml.Controls.ScrollMode.Disabled); + ScrollViewer.SetVerticalScrollBarVisibility(itemsView, WASDKScrollBarVisibility.Disabled); + + } + else + { + ScrollViewer.SetHorizontalScrollMode(itemsView, UI.Xaml.Controls.ScrollMode.Disabled); + ScrollViewer.SetHorizontalScrollBarVisibility(itemsView, WASDKScrollBarVisibility.Disabled); + + ScrollViewer.SetVerticalScrollMode(itemsView, UI.Xaml.Controls.ScrollMode.Enabled); + ScrollViewer.SetVerticalScrollBarVisibility(itemsView, WASDKScrollBarVisibility.Visible); + } + + itemsView.SetLayoutOrientation(IsLayoutHorizontal); + return itemsView; + } + + protected void UpdateItemsLayout() + { + FindScrollViewer(); + + // Unsubscribe from the old layout's property changes and subscribe to the new layout + _layoutPropertyChangedProxy?.Unsubscribe(); + _layoutPropertyChangedProxy = null; + + if (Layout is not null) + { + _layoutPropertyChanged ??= LayoutPropertyChanged; + _layoutPropertyChangedProxy = new WeakNotifyPropertyChangedProxy(Layout, _layoutPropertyChanged); + } + + PlatformView.Layout = CreateItemsLayout(); + + // Update header/footer orientation + if (PlatformView is MauiItemsView mauiItemsView) + { + if (IsLayoutHorizontal) + { + ScrollViewer.SetHorizontalScrollMode(mauiItemsView, UI.Xaml.Controls.ScrollMode.Enabled); + ScrollViewer.SetVerticalScrollMode(mauiItemsView, UI.Xaml.Controls.ScrollMode.Disabled); + } + else + { + ScrollViewer.SetHorizontalScrollMode(mauiItemsView, UI.Xaml.Controls.ScrollMode.Disabled); + ScrollViewer.SetVerticalScrollMode(mauiItemsView, UI.Xaml.Controls.ScrollMode.Enabled); + } + + mauiItemsView.SetLayoutOrientation(IsLayoutHorizontal); + } + + UpdateVerticalScrollBarVisibility(); + UpdateHorizontalScrollBarVisibility(); + UpdateEmptyView(); + UpdateSnapPoints(); + } + + UI.Xaml.Controls.Layout CreateItemsLayout() + { + switch (Layout) + { + case GridItemsLayout gridItemsLayout: + return CreateGridView(gridItemsLayout); + case LinearItemsLayout listItemsLayout: + return CreateStackLayout(listItemsLayout); + default: + break; + } + + throw new NotImplementedException("The layout is not implemented"); + } + + static UI.Xaml.Controls.StackLayout CreateStackLayout(LinearItemsLayout listItemsLayout) + { + return new UI.Xaml.Controls.StackLayout() + { + Orientation = listItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal + ? Orientation.Horizontal : Orientation.Vertical, + Spacing = listItemsLayout.ItemSpacing + }; + } + + UniformGridLayout CreateGridView(GridItemsLayout gridItemsLayout) + { + // Use custom layout for grouped items to handle headers/footers spanning full width + bool isGrouped = ItemsView is GroupableItemsView groupableItemsView && groupableItemsView.IsGrouped; + + UniformGridLayout layout = isGrouped + ? new GroupableUniformGridLayout() + : new UniformGridLayout(); + + layout.Orientation = gridItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal + ? Orientation.Vertical : Orientation.Horizontal; + layout.MaximumRowsOrColumns = gridItemsLayout.Span; + layout.MinColumnSpacing = gridItemsLayout.HorizontalItemSpacing; + layout.MinRowSpacing = gridItemsLayout.VerticalItemSpacing; + bool noTemplate = ItemsView.ItemTemplate is null; + bool isMeasureAllItems = ItemsView is CollectionView cv && cv.ItemSizingStrategy == ItemSizingStrategy.MeasureAllItems; + + layout.ItemsStretch = (noTemplate && isMeasureAllItems) + ? UniformGridLayoutItemsStretch.Uniform + : UniformGridLayoutItemsStretch.Fill; + layout.ItemsJustification = UniformGridLayoutItemsJustification.Start; + + + ApplyMinItemSizeForSpan(layout, gridItemsLayout); + return layout; + } + + /// + /// Computes and applies MinItemWidth/MinItemHeight to work around a WinUI UniformGridLayout bug + /// where two internal formulas disagree on items-per-line when ItemsStretch=Fill: + /// - GetItemsPerLine uses: floor((available + spacing) / (minWidth + spacing)) + /// - CalculateExtraPixelsInLine uses: floor(available / (minWidth + spacing)) + /// The more restrictive formula sees fewer items and inflates the effective width, causing + /// fewer columns than requested. By using floor(crossAxisSize / span) - spacing as MinItemWidth, + /// both formulas agree on the correct number of columns. + /// See: https://github.com/microsoft/microsoft-ui-xaml/blob/main/src/controls/dev/Repeater/UniformGridLayout.cpp + /// + void ApplyMinItemSizeForSpan(UniformGridLayout layout, GridItemsLayout gridItemsLayout) + { + // Only apply when an explicit cross-axis size is set on the CollectionView + bool isHorizontal = gridItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal; + double crossAxisSize = isHorizontal + ? VirtualView.HeightRequest + : VirtualView.WidthRequest; + + if (crossAxisSize <= 0 || double.IsNaN(crossAxisSize)) + return; + + int span = gridItemsLayout.Span; + double spacing = isHorizontal + ? gridItemsLayout.VerticalItemSpacing + : gridItemsLayout.HorizontalItemSpacing; + + // Formula: floor(crossAxisSize / span) - spacing + // This ensures both WinUI internal formulas agree on the correct number of columns. + double minItemSize = Math.Floor(crossAxisSize / span) - spacing; + if (minItemSize <= 0) + return; + + if (isHorizontal) + layout.MinItemHeight = minItemSize; + else + layout.MinItemWidth = minItemSize; + } + + void FindScrollViewer() + { + if (PlatformView is null) + { + return; + } + + var scrollViewer = PlatformView.GetFirstDescendant(); + if (scrollViewer is not null) + { + OnScrollViewerFound(scrollViewer); + return; + } + + // Unsubscribe any previous pending Loaded handler + if (_pendingLoadedHandler is not null) + { + PlatformView.Loaded -= _pendingLoadedHandler; + } + + _pendingLoadedHandler = (sender, e) => + { + var lv = (WItemsView)sender; + lv.Loaded -= _pendingLoadedHandler; + _pendingLoadedHandler = null; + FindScrollViewer(); + }; + + PlatformView.Loaded += _pendingLoadedHandler; + } + + void OnScrollViewerFound(ScrollViewer scrollViewer) + { + if (_scrollViewer == scrollViewer) + { + return; + } + + if (_scrollViewer is not null) + { + _scrollViewer.ViewChanged -= ScrollViewChanged; + _scrollViewer.ViewChanging -= OnScrollViewerViewChanging; + } + + _scrollViewer = scrollViewer; + _scrollViewer.ViewChanged += ScrollViewChanged; + _scrollViewer.ViewChanging += OnScrollViewerViewChanging; + + UpdateVerticalScrollBarVisibility(); + UpdateHorizontalScrollBarVisibility(); + UpdateSnapPoints(); + } + + void LayoutPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == GridItemsLayout.SpanProperty.PropertyName) + { + UpdateItemsLayoutSpan(); + } + else if (e.PropertyName == GridItemsLayout.HorizontalItemSpacingProperty.PropertyName || e.PropertyName == GridItemsLayout.VerticalItemSpacingProperty.PropertyName) + { + UpdateItemsLayoutItemSpacing(); + } + else if (e.PropertyName == LinearItemsLayout.ItemSpacingProperty.PropertyName) + { + UpdateItemsLayoutItemSpacing(); + } + else if (e.PropertyName == nameof(ItemsLayout.SnapPointsType) || + e.PropertyName == nameof(ItemsLayout.SnapPointsAlignment)) + { + UpdateSnapPoints(); + } + } + + void UpdateItemsLayoutSpan() + { + if (PlatformView.Layout is UniformGridLayout listViewLayout && + Layout is GridItemsLayout gridItemsLayout) + { + listViewLayout.MaximumRowsOrColumns = gridItemsLayout.Span; + ApplyMinItemSizeForSpan(listViewLayout, gridItemsLayout); + } + } + + void UpdateItemsLayoutItemSpacing() + { + if (PlatformView.Layout is UniformGridLayout listViewLayout && + Layout is GridItemsLayout gridItemsLayout) + { + listViewLayout.MinColumnSpacing = gridItemsLayout.HorizontalItemSpacing; + listViewLayout.MinRowSpacing = gridItemsLayout.VerticalItemSpacing; + ApplyMinItemSizeForSpan(listViewLayout, gridItemsLayout); + } + else if (PlatformView.Layout is UI.Xaml.Controls.StackLayout stackLayout && + Layout is LinearItemsLayout linearItemsLayout) + { + stackLayout.Spacing = linearItemsLayout.ItemSpacing; + } + } + + void UpdateSnapPoints() + { + if (_scrollViewer is null || Layout is not ItemsLayout itemsLayout) + { + return; + } + + _snapPointsType = itemsLayout.SnapPointsType; + _snapPointsAlignment = itemsLayout.SnapPointsAlignment; + + // Do NOT set native SnapPointsType on the ScrollViewer. + // The StackPanel's built-in IScrollSnapPointsInfo provides snap points + // at its direct children (Header, ItemsRepeater, EmptyView, Footer), + // not at individual items. Instead, we handle snapping manually in + // OnScrollViewerViewChanging, similar to how iOS/Mac uses TargetContentOffset. + _scrollViewer.HorizontalSnapPointsType = Microsoft.UI.Xaml.Controls.SnapPointsType.None; + _scrollViewer.VerticalSnapPointsType = Microsoft.UI.Xaml.Controls.SnapPointsType.None; + } + + /// + /// Handles the ScrollViewer's ViewChanging event, which fires DURING deceleration + /// before the scroll settles. This is the Windows equivalent of iOS's + /// TargetContentOffset(proposedContentOffset, scrollingVelocity). + /// + /// We compute the nearest item to the proposed scroll destination and redirect + /// the scroll to snap to that item's boundary using ChangeView. + /// + void OnScrollViewerViewChanging(object? sender, ScrollViewerViewChangingEventArgs e) + { + if (_scrollViewer is null || PlatformView is null || + _snapPointsType == SnapPointsType.None || _isSnapping) + { + return; + } + + // e.NextView is where the ScrollViewer is headed. + // e.FinalView is where it will end up if we don't intervene. + // We use FinalView as the "proposed content offset" (like Mac's proposedContentOffset). + var finalView = e.FinalView; + bool isHorizontal = IsLayoutHorizontal; + double proposedOffset = isHorizontal ? finalView.HorizontalOffset : finalView.VerticalOffset; + double currentOffset = isHorizontal ? _scrollViewer.HorizontalOffset : _scrollViewer.VerticalOffset; + double viewportSize = isHorizontal ? _scrollViewer.ViewportWidth : _scrollViewer.ViewportHeight; + double maxOffset = isHorizontal ? _scrollViewer.ScrollableWidth : _scrollViewer.ScrollableHeight; + + // Determine scroll direction (like Mac's scrollingVelocity) + double scrollDelta = proposedOffset - currentOffset; + + // Find the best snap target among visible item containers + // This mirrors Mac's SnapHelpers.FindBestSnapCandidate + AdjustContentOffset + double? snapTarget = null; + + if (_snapPointsType == SnapPointsType.MandatorySingle) + { + // MandatorySingle: like Mac's ScrollSingle — advance exactly one item + // from the CURRENT position, based on scroll direction + snapTarget = FindSingleSnapTarget(isHorizontal, currentOffset, viewportSize, maxOffset, scrollDelta); + } + else + { + // Mandatory: like Mac's standard TargetContentOffset — + // find the nearest item to the PROPOSED destination + snapTarget = FindNearestSnapTarget(isHorizontal, proposedOffset, viewportSize, maxOffset); + } + + if (snapTarget.HasValue) + { + double distance = Math.Abs(snapTarget.Value - proposedOffset); + if (distance > 1.0) // Only intervene if snap target differs by more than 1px + { + _isSnapping = true; + if (isHorizontal) + { + _scrollViewer.ChangeView(snapTarget.Value, null, null, disableAnimation: false); + } + else + { + _scrollViewer.ChangeView(null, snapTarget.Value, null, disableAnimation: false); + } + } + } + } + + /// + /// Finds the item nearest to the proposed offset and returns the scroll offset + /// that would align it per the current SnapPointsAlignment. + /// Equivalent to Mac's SnapHelpers.FindBestSnapCandidate + AdjustContentOffset. + /// + double? FindNearestSnapTarget(bool isHorizontal, double proposedOffset, double viewportSize, double maxOffset) + { + double? bestTarget = null; + double bestDistance = double.MaxValue; + + foreach (var container in PlatformView.GetChildren()) + { + if (container is null || container.Visibility != WVisibility.Visible) + continue; + + var target = ComputeSnapTarget(container, isHorizontal, proposedOffset, viewportSize, maxOffset); + if (target is null) + continue; + + double distance = Math.Abs(target.Value - proposedOffset); + if (distance < bestDistance) + { + bestDistance = distance; + bestTarget = target; + } + } + + return bestTarget; + } + + /// + /// Finds the next item in the scroll direction from the current position. + /// Equivalent to Mac's ScrollSingle — only advances one item per gesture. + /// + double? FindSingleSnapTarget(bool isHorizontal, double currentOffset, double viewportSize, double maxOffset, double scrollDelta) + { + // First, find the item currently aligned to the snap position + double? currentAligned = FindNearestSnapTarget(isHorizontal, currentOffset, viewportSize, maxOffset); + + if (currentAligned is null) + return null; + + if (Math.Abs(scrollDelta) < 1.0) + return currentAligned; // No meaningful scroll, stay put + + // Collect all snap targets sorted by offset + var allTargets = new List(); + foreach (var container in PlatformView.GetChildren()) + { + if (container is null || container.Visibility != WVisibility.Visible) + continue; + + var target = ComputeSnapTarget(container, isHorizontal, currentOffset, viewportSize, maxOffset); + if (target.HasValue) + allTargets.Add(target.Value); + } + + allTargets.Sort(); + + if (allTargets.Count == 0) + return null; + + // Find current index in sorted targets + int currentIndex = 0; + double minDist = double.MaxValue; + for (int i = 0; i < allTargets.Count; i++) + { + double d = Math.Abs(allTargets[i] - currentAligned.Value); + if (d < minDist) + { + minDist = d; + currentIndex = i; + } + } + + // Advance exactly one step based on scroll direction (like Mac's FindNextItem) + int span = 1; + if (Layout is GridItemsLayout gridLayout) + span = gridLayout.Span; + + int nextIndex; + if (scrollDelta > 0) + nextIndex = Math.Min(currentIndex + span, allTargets.Count - 1); + else + nextIndex = Math.Max(currentIndex - span, 0); + + return allTargets[nextIndex]; + } + + /// + /// Computes the scroll offset that would align the given container + /// per the current SnapPointsAlignment. Returns null if the container + /// position cannot be determined. + /// + /// This mirrors Mac's SnapHelpers.GetViewportOffset logic: + /// - Start: item's leading edge aligns with viewport's leading edge + /// - Center: item's center aligns with viewport's center + /// - End: item's trailing edge aligns with viewport's trailing edge + /// + double? ComputeSnapTarget(ItemContainer container, bool isHorizontal, double referenceOffset, double viewportSize, double maxOffset) + { + // TransformToVisual requires the element to be in the visual tree and have a valid layout. + // Without these checks, TransformToVisual throws — guard instead of catching. + if (!container.IsLoaded || _scrollViewer is null) + return null; + + double itemSize = isHorizontal ? container.ActualWidth : container.ActualHeight; + if (itemSize <= 0) + return null; + + var transform = container.TransformToVisual(_scrollViewer); + var point = transform.TransformPoint(new global::Windows.Foundation.Point(0, 0)); + + // Container position relative to viewport + double itemStart = isHorizontal ? point.X : point.Y; + + // Convert viewport-relative position to absolute content offset, + // then compute the target scroll offset per alignment. + // currentScrollOffset + itemStart = absolute position of item's leading edge. + double currentScrollOffset = isHorizontal + ? _scrollViewer.HorizontalOffset + : _scrollViewer.VerticalOffset; + + double absoluteItemStart = currentScrollOffset + itemStart; + + double snapTarget = _snapPointsAlignment switch + { + // Mac: viewport.Left - itemFrame.Left (delta), applied to proposed offset + SnapPointsAlignment.Start => absoluteItemStart, + // Mac: center(viewport) - center(itemFrame) + SnapPointsAlignment.Center => absoluteItemStart + itemSize / 2 - viewportSize / 2, + // Mac: viewport.Right - itemFrame.Right + SnapPointsAlignment.End => absoluteItemStart + itemSize - viewportSize, + _ => absoluteItemStart, + }; + + // Clamp to valid scroll range + return Math.Max(0, Math.Min(snapTarget, maxOffset)); + } + + void UpdateEmptyView() + { + if (Element is null || PlatformView is null) + { + return; + } + + var emptyView = Element.EmptyView; + var emptyViewTemplate = Element.EmptyViewTemplate; + + // Clear empty view + if (emptyView is null && emptyViewTemplate is null) + { + if (_emptyViewDisplayed) + { + if (_emptyView != null && PlatformView is IEmptyView ev) + ev.EmptyViewVisibility = WVisibility.Collapsed; + + if (_mauiEmptyView != null) + ItemsView.RemoveLogicalChild(_mauiEmptyView); + + _emptyViewDisplayed = false; + } + + _emptyView = null; + _mauiEmptyView = null; + (PlatformView as IEmptyView)?.SetEmptyView(null, null); + return; + } + + if (emptyViewTemplate is DataTemplateSelector && emptyView is null) + { + _emptyView = null; + _mauiEmptyView = null; + (PlatformView as IEmptyView)?.SetEmptyView(null, null); + return; + } + + // Resolve empty view + var oldMauiEmptyView = _mauiEmptyView; + _emptyView = emptyViewTemplate != null + ? ItemsViewExtensions.RealizeEmptyViewTemplate(emptyView, emptyViewTemplate, MauiContext!, ref _mauiEmptyView, ItemsView) + : emptyView switch + { + string text => new TextBlock + { + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Center, + Text = text + }, + View view => ItemsViewExtensions.RealizeEmptyView(view, MauiContext!, ref _mauiEmptyView), + _ => ItemsViewExtensions.RealizeEmptyViewTemplate(emptyView, null, MauiContext!, ref _mauiEmptyView, ItemsView) + }; + + // Remove old logical child before adding the new one to prevent leak + if (oldMauiEmptyView is not null && _emptyViewDisplayed) + { + ItemsView.RemoveLogicalChild(oldMauiEmptyView); + _emptyViewDisplayed = false; + } + + // Apply MAUI margin/layout options to the platform EmptyView element. + // The MAUI layout system does not handle these when the view is hosted + // outside a MAUI layout (i.e. inside a WinUI ContentControl), so we + // must transfer them manually — same as we do for Header and Footer. + ItemsViewExtensions.ApplyMauiLayoutProperties(_mauiEmptyView, _emptyView as Microsoft.UI.Xaml.FrameworkElement); + + (PlatformView as IEmptyView)?.SetEmptyView(_emptyView, _mauiEmptyView); + UpdateEmptyViewVisibility(); + } + + void UpdateEmptyViewVisibility() + { + if (PlatformView is null || VirtualView is null) + { + return; + } + + // Check both CollectionViewSource.View and the underlying _itemsSource + // After a Reset action, CollectionViewSource.View.Count might not be immediately updated + bool isEmpty = (_collectionViewSource?.View?.Count ?? 0) == 0 && (_itemsSource?.Count ?? 0) == 0; + + if (isEmpty) + { + if (_mauiEmptyView is not null && !_emptyViewDisplayed) + { + if (ItemsView.EmptyViewTemplate is null) + { + ItemsView.AddLogicalChild(_mauiEmptyView); + } + } + + if (_emptyView is not null && PlatformView is IEmptyView emptyView) + { + emptyView.EmptyViewVisibility = WVisibility.Visible; + } + + _emptyViewDisplayed = true; + } + else + { + // Always set EmptyViewVisibility to Collapsed when items exist, + // regardless of _emptyViewDisplayed state. This handles the case + // where the empty view was made visible on the platform but + // _emptyViewDisplayed got out of sync (e.g., during Collapsed→Visible + // transitions where the template wasn't yet applied when the flag was set). + if (_emptyView is not null && PlatformView is IEmptyView emptyView) + { + emptyView.EmptyViewVisibility = WVisibility.Collapsed; + } + + if (_emptyViewDisplayed) + { + ItemsView.RemoveLogicalChild(_mauiEmptyView); + } + + _emptyViewDisplayed = false; + } + } + + void UpdateHeader() + { + if (Element is not StructuredItemsView structuredItemsView || PlatformView is null) + { + return; + } + + var header = structuredItemsView.Header; + var headerTemplate = structuredItemsView.HeaderTemplate; + + // Hide header only if both Header and HeaderTemplate are null + if (header is null && headerTemplate is null) + { + if (_headerDisplayed) + { + if (PlatformView is MauiItemsView mauiItemsView) + { + mauiItemsView.HeaderVisibility = WVisibility.Collapsed; + } + + if (_mauiHeader is not null) + { + ItemsView.RemoveLogicalChild(_mauiHeader); + } + _headerDisplayed = false; + } + return; + } + + // Save old logical child reference before realization overwrites _mauiHeader via ref + var oldMauiHeader = _mauiHeader; + + // If HeaderTemplate is set, use it regardless of header value + if (headerTemplate is not null) + { + var bindingContext = header ?? (object)string.Empty; + _header = ItemsViewExtensions.RealizeHeaderFooterTemplate(bindingContext, headerTemplate, MauiContext!, ref _mauiHeader, ItemsView); + } + else if (header is not null) + { + _header = header switch + { + string text => new TextBlock + { + Text = text, + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Start, + }, + View view => ItemsViewExtensions.RealizeHeaderFooterView(view, MauiContext!, ref _mauiHeader), + _ => new TextBlock + { + Text = header.ToString(), + Margin = new Microsoft.UI.Xaml.Thickness(0, 0, 0, 10), + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Start, + } + }; + } + else + { + // This shouldn't happen due to null check above, but handle it safely + return; + } + + ItemsViewExtensions.ApplyMauiLayoutProperties(_mauiHeader, _header); + + if (PlatformView is MauiItemsView platformItemsView && _header is not null) + { + platformItemsView.SetHeader(_header); + platformItemsView.HeaderVisibility = WVisibility.Visible; + } + + // Remove old logical child before adding the new one to prevent leak + if (oldMauiHeader is not null && _headerDisplayed) + { + ItemsView.RemoveLogicalChild(oldMauiHeader); + } + + if (_mauiHeader is not null) + { + ItemsView.AddLogicalChild(_mauiHeader); + } + + _headerDisplayed = true; + } + + void UpdateFooter() + { + if (Element is not StructuredItemsView structuredItemsView || PlatformView is null) + { + return; + } + + var footer = structuredItemsView.Footer; + var footerTemplate = structuredItemsView.FooterTemplate; + + // Hide footer only if both Footer and FooterTemplate are null + if (footer is null && footerTemplate is null) + { + if (_footerDisplayed) + { + if (PlatformView is MauiItemsView mauiItemsView) + { + mauiItemsView.FooterVisibility = WVisibility.Collapsed; + } + + if (_mauiFooter is not null) + { + ItemsView.RemoveLogicalChild(_mauiFooter); + } + _footerDisplayed = false; + } + return; + } + + // Save old logical child reference before realization overwrites _mauiFooter via ref + var oldMauiFooter = _mauiFooter; + + // If FooterTemplate is set, use it regardless of footer value + if (footerTemplate is not null) + { + var bindingContext = footer ?? (object)string.Empty; + _footer = ItemsViewExtensions.RealizeHeaderFooterTemplate(bindingContext, footerTemplate, MauiContext!, ref _mauiFooter, ItemsView); + } + else if (footer is not null) + { + _footer = footer switch + { + string text => new TextBlock + { + Text = text, + Margin = new Microsoft.UI.Xaml.Thickness(0, 10, 0, 0), + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Start, + }, + View view => ItemsViewExtensions.RealizeHeaderFooterView(view, MauiContext!, ref _mauiFooter), + _ => new TextBlock + { + Text = footer.ToString(), + Margin = new Microsoft.UI.Xaml.Thickness(0, 10, 0, 0), + TextAlignment = Microsoft.UI.Xaml.TextAlignment.Start, + } + }; + } + else + { + // This shouldn't happen due to null check above, but handle it safely + return; + } + + ItemsViewExtensions.ApplyMauiLayoutProperties(_mauiFooter, _footer); + + if (PlatformView is MauiItemsView platformItemsView && _footer is not null) + { + platformItemsView.SetFooter(_footer); + platformItemsView.FooterVisibility = WVisibility.Visible; + } + + // Remove old logical child before adding the new one to prevent leak + if (oldMauiFooter is not null && _footerDisplayed) + { + ItemsView.RemoveLogicalChild(oldMauiFooter); + } + + if (_mauiFooter is not null) + { + ItemsView.AddLogicalChild(_mauiFooter); + } + + _footerDisplayed = true; + } + + void UpdateVerticalScrollBarVisibility() + { + if (_scrollViewer is null) + return; + + // For horizontal layout, vertical is the cross-axis — must stay Disabled + // so ScrollContentPresenter constrains content height to viewport + if (IsLayoutHorizontal) + { + _scrollViewer.VerticalScrollBarVisibility = WASDKScrollBarVisibility.Disabled; + return; + } + + // Vertical is the scroll axis — respect the user's MAUI setting + _scrollViewer.VerticalScrollBarVisibility = Element.VerticalScrollBarVisibility switch + { + ScrollBarVisibility.Always => WASDKScrollBarVisibility.Visible, + ScrollBarVisibility.Never => WASDKScrollBarVisibility.Hidden, + _ => WASDKScrollBarVisibility.Auto, + }; + } + + void UpdateHorizontalScrollBarVisibility() + { + if (_scrollViewer is null) + return; + + // For vertical layout, horizontal is the cross-axis — must stay Disabled + // so ScrollContentPresenter constrains content width to viewport + if (!IsLayoutHorizontal) + { + _scrollViewer.HorizontalScrollBarVisibility = WASDKScrollBarVisibility.Disabled; + return; + } + + // Horizontal is the scroll axis — respect the user's MAUI setting + _scrollViewer.HorizontalScrollBarVisibility = Element.HorizontalScrollBarVisibility switch + { + ScrollBarVisibility.Always => WASDKScrollBarVisibility.Visible, + ScrollBarVisibility.Never => WASDKScrollBarVisibility.Hidden, + _ => WASDKScrollBarVisibility.Auto, + }; + } + + void ScrollViewChanged(object? sender, ScrollViewerViewChangedEventArgs e) + { + if (_scrollViewer is null) + return; + + // Reset the snapping guard when the scroll animation (including our ChangeView) + // fully settles. This prevents re-entrant snap during our own snap animation. + if (!e.IsIntermediate) + _isSnapping = false; + + HandleScroll(_scrollViewer); + } + + void HandleScroll(ScrollViewer scrollViewer) + { + if (_isScrollingForItemsUpdate) + { + _isScrollingForItemsUpdate = false; + return; + } + + var itemsViewScrolledEventArgs = new ItemsViewScrolledEventArgs + { + HorizontalOffset = scrollViewer.HorizontalOffset, + HorizontalDelta = scrollViewer.HorizontalOffset - _previousHorizontalOffset, + VerticalOffset = scrollViewer.VerticalOffset, + VerticalDelta = scrollViewer.VerticalOffset - _previousVerticalOffset, + }; + + _previousHorizontalOffset = scrollViewer.HorizontalOffset; + _previousVerticalOffset = scrollViewer.VerticalOffset; + + bool advancing = true; + switch (Layout) + { + case LinearItemsLayout linearItemsLayout: + advancing = linearItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal + ? itemsViewScrolledEventArgs.HorizontalDelta > 0 + : itemsViewScrolledEventArgs.VerticalDelta > 0; + break; + case GridItemsLayout gridItemsLayout: + advancing = gridItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal + ? itemsViewScrolledEventArgs.HorizontalDelta > 0 + : itemsViewScrolledEventArgs.VerticalDelta > 0; + break; + default: + break; + } + + itemsViewScrolledEventArgs = ComputeVisibleIndexes(itemsViewScrolledEventArgs, advancing); + + Element.SendScrolled(itemsViewScrolledEventArgs); + + var remainingItemsThreshold = Element.RemainingItemsThreshold; + if (_collectionViewSource != null && remainingItemsThreshold > -1) + { + var itemsRemaining = _collectionViewSource.View.Count - 1 - itemsViewScrolledEventArgs.LastVisibleItemIndex; + + if (itemsRemaining <= remainingItemsThreshold) + { + if (itemsViewScrolledEventArgs.LastVisibleItemIndex > _lastRemainingItemsThresholdIndex) + { + _lastRemainingItemsThresholdIndex = itemsViewScrolledEventArgs.LastVisibleItemIndex; + Element.SendRemainingItemsThresholdReached(); + } + // When scrolling backward within the threshold zone, keep the + // high-water mark — don't reset, to avoid duplicate event fires. + // The reset happens in the outer else (when leaving the zone entirely) + // or in ItemsChanged (when new items are added). + } + else + { + // Reset when scrolling away from the threshold zone so the + // event can re-fire when the user scrolls back. + _lastRemainingItemsThresholdIndex = -1; + } + } + } + + ItemsViewScrolledEventArgs ComputeVisibleIndexes(ItemsViewScrolledEventArgs args, bool advancing) + { + var (firstVisibleItemIndex, lastVisibleItemIndex, centerItemIndex) = GetVisibleIndexes(advancing); + + args.FirstVisibleItemIndex = firstVisibleItemIndex; + args.CenterItemIndex = centerItemIndex; + args.LastVisibleItemIndex = lastVisibleItemIndex; + + return args; + } + + (int firstVisibleItemIndex, int lastVisibleItemIndex, int centerItemIndex) GetVisibleIndexes(bool advancing) + { + int firstVisibleItemIndex = -1; + int lastVisibleItemIndex = -1; + + if (PlatformView is null || _scrollViewer is null) + { + return (firstVisibleItemIndex, lastVisibleItemIndex, -1); + } + + // MauiItemsView uses a custom ControlTemplate with a ScrollViewer wrapping + // a StackPanel > ItemsRepeater (not the default ItemsView template with ScrollView). + // Snap points are handled manually in OnScrollViewerViewChanging (like iOS TargetContentOffset). + // WinUI's TryGetItemIndex relies on its internal ScrollView part (PART_ScrollView), + // which doesn't exist in our template — so it always returns -1. + // Instead, walk the realized ItemContainer children and check which ones + // are visible within the ScrollViewer viewport, similar to CV1's fallback approach. + bool isHorizontal = IsLayoutHorizontal; + + // Use ItemsRepeater.GetElementIndex to map each realized container back to its + // source-collection index. A naive per-container counter starting at 0 reports + // the position within the realized window (e.g., 0..20) instead of the actual + // source index (e.g., 4500..4520) once virtualization scrolls deep into a large + // list, which breaks Scrolled args and RemainingItemsThreshold math. + // GetElementIndex returns -1 for unrealized elements; those are skipped. + var repeater = (PlatformView as MauiItemsView)?.ItemsRepeaterControl; + + foreach (var container in PlatformView.GetChildren()) + { + if (container is null) + { + continue; + } + + int index = repeater?.GetElementIndex(container) ?? -1; + if (index < 0) + { + continue; + } + + if (IsElementVisibleInScrollViewer(container, _scrollViewer, isHorizontal)) + { + if (firstVisibleItemIndex == -1 || index < firstVisibleItemIndex) + { + firstVisibleItemIndex = index; + } + + if (index > lastVisibleItemIndex) + { + lastVisibleItemIndex = index; + } + } + } + + double center = (lastVisibleItemIndex + firstVisibleItemIndex) / 2.0; + int centerItemIndex = advancing ? (int)Math.Ceiling(center) : (int)Math.Floor(center); + + return (firstVisibleItemIndex, lastVisibleItemIndex, centerItemIndex); + } + + /// + /// Checks whether a UI element is visible within the scroll viewer's viewport. + /// Uses coordinate transformation to compare element bounds against the viewport. + /// + static bool IsElementVisibleInScrollViewer(FrameworkElement element, ScrollViewer scrollViewer, bool isHorizontal) + { + if (element.Visibility != WVisibility.Visible) + { + return false; + } + + try + { + var transform = element.TransformToVisual(scrollViewer); + var elementBounds = transform.TransformBounds( + new WRect(0, 0, element.ActualWidth, element.ActualHeight)); + var viewportBounds = new WRect( + 0, 0, scrollViewer.ActualWidth, scrollViewer.ActualHeight); + + if (isHorizontal) + { + return elementBounds.Left < viewportBounds.Right && elementBounds.Right > viewportBounds.Left; + } + else + { + return elementBounds.Top < viewportBounds.Bottom && elementBounds.Bottom > viewportBounds.Top; + } + } + catch + { + // TransformToVisual can throw if the element is not in the visual tree + return false; + } + } + + void ScrollToRequested(object? sender, ScrollToRequestEventArgs args) + { + // Use base.PlatformView to avoid InvalidOperationException + if (base.PlatformView is not WItemsView platformView) + { + return; + } + + int index; + + // Handle grouped scrolling by position + if (args.Mode == ScrollToMode.Position && + ItemsView is GroupableItemsView groupableItemsView && + groupableItemsView.IsGrouped && + args.GroupIndex >= 0) + { + index = FindGroupedItemIndex(args.GroupIndex, args.Index); + } + else if (args.Mode == ScrollToMode.Element && args.Group is not null) + { + index = FindGroupedItemByElement(args.Item, args.Group); + } + else if (args.Mode == ScrollToMode.Element) + { + index = FindItemIndex(args.Item); + } + else + { + // Non-grouped position-based scroll + index = args.Index; + } + + // Validate index is within bounds + var itemCount = _collectionViewSource?.View?.Count ?? 0; + if (index < 0 || index >= itemCount) + { + return; + } + + double offset = AlignToStart; + switch (args.ScrollToPosition) + { + case ScrollToPosition.Start: + offset = AlignToStart; + break; + case ScrollToPosition.Center: + offset = AlignToCenter; + break; + case ScrollToPosition.End: + offset = AlignToEnd; + break; + } + + if (platformView.IsLoaded) + { + PerformScrollTo(platformView, index, offset, args.IsAnimated); + } + else + { + platformView.Loaded -= OnPlatformViewLoaded; + + void OnPlatformViewLoaded(object sender, RoutedEventArgs e) + { + platformView.Loaded -= OnPlatformViewLoaded; + PerformScrollTo(platformView, index, offset, args.IsAnimated); + } + platformView.Loaded += OnPlatformViewLoaded; + } + + } + + void PerformScrollTo(WItemsView platformView, int index, double offset, bool animated) + { + platformView.StartBringItemIntoView(index, new BringIntoViewOptions() + { + AnimationDesired = animated, + VerticalAlignmentRatio = offset, + HorizontalAlignmentRatio = offset + }); + } + + /// + /// Finds the flat index in the flattened grouped collection for the specified group and item index. + /// + int FindGroupedItemIndex(int groupIndex, int itemIndex) + { + if (_collectionViewSource is null) + { + return -1; + } + + if (ItemsView is not GroupableItemsView groupableItemsView) + { + return -1; + } + + var itemsSource = groupableItemsView.ItemsSource; + if (itemsSource is null) + { + return -1; + } + + var hasGroupHeader = groupableItemsView.GroupHeaderTemplate is not null; + var hasGroupFooter = groupableItemsView.GroupFooterTemplate is not null; + + int flatIndex = 0; + int currentGroupIndex = 0; + + foreach (var group in itemsSource) + { + if (group is IList groupList) + { + if (currentGroupIndex == groupIndex) + { + // Found the target group + if (hasGroupHeader) + { + flatIndex++; // Skip group header + } + + // Check if itemIndex is within bounds of this group + if (itemIndex < 0 || itemIndex >= groupList.Count) + { + return -1; + } + + // Return the calculated flat index + return flatIndex + itemIndex; + } + + // Count items in this group to move to next group + if (hasGroupHeader) + { + flatIndex++; + } + flatIndex += groupList.Count; + if (hasGroupFooter) + { + flatIndex++; + } + } + + currentGroupIndex++; + } + + // Group index not found + return -1; + } + + /// + /// Finds the index of an item in the collection view by searching through the flattened list. + /// Used for non-grouped ScrollToMode.Element requests. + /// + int FindItemIndex(object item) + { + if (_collectionViewSource is null) + { + return -1; + } + + for (int index = 0; index < _collectionViewSource.View.Count; index++) + { + var viewItem = _collectionViewSource.View[index]; + + // Check for ItemTemplateContext (non-grouped templated items) + if (viewItem is ItemTemplateContext pair) + { + if (Equals(pair.Item, item)) + { + return index; + } + } + // Check for ItemTemplateContext2 (grouped templated items) + else if (viewItem is ItemTemplateContext2 pair2) + { + // Skip headers and footers, only match actual items + if (!pair2.IsHeader && !pair2.IsFooter && Equals(pair2.Item, item)) + { + return index; + } + } + // Check for non-templated items (direct equality) + else if (Equals(viewItem, item)) + { + return index; + } + } + + return -1; + } + + /// + /// Finds the flat index in the flattened grouped collection for a ScrollToMode.Element request + /// where a group is specified. If item is null, returns the index of the group header. + /// If item is non-null, returns the index of that item within the specified group. + /// + int FindGroupedItemByElement(object? item, object group) + { + if (_collectionViewSource is null) + { + return -1; + } + + if (ItemsView is not GroupableItemsView groupableItemsView) + { + return -1; + } + + var itemsSource = groupableItemsView.ItemsSource; + if (itemsSource is null) + { + return -1; + } + + var hasGroupHeader = groupableItemsView.GroupHeaderTemplate is not null; + var hasGroupFooter = groupableItemsView.GroupFooterTemplate is not null; + + // Find the target group and its items by matching the group object + IList? targetGroupItems = null; + int flatIndexOfGroup = 0; + int currentFlatIndex = 0; + + foreach (var g in itemsSource) + { + if (g is not IList groupList) + { + continue; + } + + if (Equals(g, group)) + { + targetGroupItems = groupList; + flatIndexOfGroup = currentFlatIndex; + break; + } + + // Advance past this group's entries in the flat list + if (hasGroupHeader) + { + currentFlatIndex++; + } + + currentFlatIndex += groupList.Count; + + if (hasGroupFooter) + { + currentFlatIndex++; + } + } + + if (targetGroupItems is null) + { + return -1; + } + + // If item is null, scroll to the group header (if it exists) + if (item is null) + { + if (hasGroupHeader) + { + return flatIndexOfGroup; + } + + // No header template — scroll to the first item in the group instead + if (targetGroupItems.Count > 0) + { + return flatIndexOfGroup; + } + + return -1; + } + + // Find the item within the target group + int itemStartIndex = flatIndexOfGroup; + if (hasGroupHeader) + { + itemStartIndex++; + } + + for (int i = 0; i < targetGroupItems.Count; i++) + { + if (Equals(targetGroupItems[i], item)) + { + return itemStartIndex + i; + } + } + + return -1; + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/ReorderableItemsViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/ReorderableItemsViewHandler2.Windows.cs new file mode 100644 index 000000000000..cd65cd7855ae --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/ReorderableItemsViewHandler2.Windows.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Controls.Platform; +using Microsoft.UI.Xaml; +using WItemsView = Microsoft.UI.Xaml.Controls.ItemsView; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// Windows-specific handler for in Items2 (CollectionView2). +/// Owns drag-and-drop wiring on top of so that any +/// CV2-derived handler (e.g. ) inherits reorder support. +/// +public partial class ReorderableItemsViewHandler2 : ItemsViewHandler2 + where TItemsView : ReorderableItemsView +{ + public ReorderableItemsViewHandler2() : base(ReorderableItemsViewMapper) + { + } + + public ReorderableItemsViewHandler2(PropertyMapper? mapper = null) : base(mapper ?? ReorderableItemsViewMapper) + { + } + + public static PropertyMapper> ReorderableItemsViewMapper = new(ItemsViewMapper) + { + [ReorderableItemsView.CanReorderItemsProperty.PropertyName] = MapCanReorderItems, + }; + + protected override IItemsLayout Layout { get => ItemsView.ItemsLayout; } + + /// + /// Maps to the platform view, enabling or + /// disabling drag/drop reordering on the underlying . + /// + public static void MapCanReorderItems(ReorderableItemsViewHandler2 handler, ReorderableItemsView itemsView) + { + if (handler.PlatformView is MauiItemsView mauiItemsView) + { + mauiItemsView.SetMauiVirtualView(itemsView); + mauiItemsView.UpdateCanReorderItems(itemsView.CanReorderItems); + } + } + + protected override void ConnectHandler(WItemsView platformView) + { + base.ConnectHandler(platformView); + + if (platformView is MauiItemsView mauiItemsView) + { + mauiItemsView.SetMauiVirtualView(VirtualView); + mauiItemsView.ReorderCompleted -= OnReorderCompleted; + mauiItemsView.ReorderCompleted += OnReorderCompleted; + mauiItemsView.UpdateCanReorderItems(VirtualView.CanReorderItems); + } + } + + protected override void DisconnectHandler(WItemsView platformView) + { + if (platformView is MauiItemsView mauiItemsView) + { + mauiItemsView.ReorderCompleted -= OnReorderCompleted; + mauiItemsView.DisconnectDragDrop(); + } + + base.DisconnectHandler(platformView); + } + + void OnReorderCompleted(object? sender, EventArgs e) + { + VirtualView?.SendReorderCompleted(); + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/GroupableUniformGridLayout.cs b/src/Controls/src/Core/Handlers/Items2/Windows/GroupableUniformGridLayout.cs new file mode 100644 index 000000000000..9feb76041236 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/GroupableUniformGridLayout.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.Maui.Controls.Handlers.Items2 +{ + /// + /// Custom UniformGridLayout that handles group headers and footers spanning full width/height. + /// This mimics the behavior of CV1's GridView with GroupStyle support. + /// + internal partial class GroupableUniformGridLayout : UniformGridLayout + { + const double DefaultEstimatedRegularExtent = 48; + const double DefaultEstimatedHeaderExtent = 36; + const int RealizationBufferRowsOrColumns = 2; + + double _estimatedVerticalRegularExtent = DefaultEstimatedRegularExtent; + double _estimatedHorizontalRegularExtent = DefaultEstimatedRegularExtent; + double _estimatedHeaderVerticalExtent = DefaultEstimatedHeaderExtent; + double _estimatedHeaderHorizontalExtent = DefaultEstimatedHeaderExtent; + + readonly List _bandStartOffsets = new(); + readonly List _bandEndOffsets = new(); + readonly List _bandExtents = new(); + readonly List _bandStartIndices = new(); + readonly List _bandEndIndices = new(); + + int[] _itemToBand = Array.Empty(); + bool _bandCacheValid; + int _bandCacheItemCount = -1; + int _bandCacheSpan; + bool _bandCacheIsVertical; + double _bandCacheRegularExtent; + double _bandCacheHeaderExtent; + double _bandCacheSpacing; + double _cachedTotalPrimaryExtent; + + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + if (context.ItemCount == 0) + { + InvalidateBandCache(); + return new Size(0, 0); + } + + var span = Math.Max(1, MaximumRowsOrColumns); + var isVerticalOrientation = Orientation == Orientation.Horizontal; + + // Compute the per-cell cross-axis size so items are measured at their actual + // arranged width (not the full viewport width), giving correct wrapping heights. + Size regularMeasureSize; + if (isVerticalOrientation) + { + double totalSpacing = MinColumnSpacing * (span - 1); + double cellWidth = Math.Max(0, (availableSize.Width - totalSpacing) / span); + regularMeasureSize = new Size(cellWidth, availableSize.Height); + } + else + { + double totalSpacing = MinRowSpacing * (span - 1); + double cellHeight = Math.Max(0, (availableSize.Height - totalSpacing) / span); + regularMeasureSize = new Size(availableSize.Width, cellHeight); + } + + EnsureBandCache(context, span, isVerticalOrientation); + var realizationRange = GetRealizationIndexRange(context, span, isVerticalOrientation); + + double maxMeasuredRegular = 0; + double maxMeasuredHeader = 0; + + for (int i = realizationRange.start; i <= realizationRange.end; i++) + { + var element = context.GetOrCreateElementAt(i); + bool isHeaderOrFooter = IsHeaderOrFooter(element); + + // Headers span full cross-axis; regular items use their cell size. + element.Measure(isHeaderOrFooter ? availableSize : regularMeasureSize); + double primaryExtent = isVerticalOrientation ? element.DesiredSize.Height : element.DesiredSize.Width; + + if (isHeaderOrFooter) + { + maxMeasuredHeader = Math.Max(maxMeasuredHeader, primaryExtent); + } + else + { + maxMeasuredRegular = Math.Max(maxMeasuredRegular, primaryExtent); + } + } + + // Invalidate the band cache without resetting estimates, so the grown values + // are used in the rebuild rather than the default 48/36 px constants. + bool changed = UpdateEstimates(isVerticalOrientation, maxMeasuredRegular, maxMeasuredHeader); + if (changed) + { + _bandCacheValid = false; + EnsureBandCache(context, span, isVerticalOrientation); + } + + if (isVerticalOrientation) + { + return new Size(availableSize.Width, _cachedTotalPrimaryExtent); + } + + return new Size(_cachedTotalPrimaryExtent, availableSize.Height); + } + + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + if (context.ItemCount == 0) + { + return finalSize; + } + + var span = Math.Max(1, MaximumRowsOrColumns); + var minColumnSpacing = MinColumnSpacing; + var minRowSpacing = MinRowSpacing; + var isVerticalOrientation = Orientation == Orientation.Horizontal; + + EnsureBandCache(context, span, isVerticalOrientation); + var realizationRange = GetRealizationIndexRange(context, span, isVerticalOrientation); + + double cellWidth; + double cellHeight; + + if (isVerticalOrientation) + { + double totalSpacing = minColumnSpacing * (span - 1); + cellWidth = Math.Max(0, (finalSize.Width - totalSpacing) / span); + cellHeight = _estimatedVerticalRegularExtent; + } + else + { + double totalSpacing = minRowSpacing * (span - 1); + cellHeight = Math.Max(0, (finalSize.Height - totalSpacing) / span); + cellWidth = _estimatedHorizontalRegularExtent; + } + + double maxMeasuredRegular = 0; + double maxMeasuredHeader = 0; + + for (int i = realizationRange.start; i <= realizationRange.end; i++) + { + var child = context.GetOrCreateElementAt(i); + bool isHeaderOrFooter = IsHeaderOrFooter(child); + int bandIndex = GetBandIndex(i); + + if (bandIndex < 0) + { + continue; + } + + double bandStart = _bandStartOffsets[bandIndex]; + double bandExtent = _bandExtents[bandIndex]; + + if (isHeaderOrFooter) + { + if (isVerticalOrientation) + { + child.Arrange(new Rect(0, bandStart, finalSize.Width, bandExtent)); + maxMeasuredHeader = Math.Max(maxMeasuredHeader, child.DesiredSize.Height); + } + else + { + child.Arrange(new Rect(bandStart, 0, bandExtent, finalSize.Height)); + maxMeasuredHeader = Math.Max(maxMeasuredHeader, child.DesiredSize.Width); + } + } + else + { + int slot = i - _bandStartIndices[bandIndex]; + if (isVerticalOrientation) + { + double x = slot * (cellWidth + minColumnSpacing); + child.Arrange(new Rect(x, bandStart, cellWidth, bandExtent)); + maxMeasuredRegular = Math.Max(maxMeasuredRegular, child.DesiredSize.Height); + } + else + { + double y = slot * (cellHeight + minRowSpacing); + child.Arrange(new Rect(bandStart, y, bandExtent, cellHeight)); + maxMeasuredRegular = Math.Max(maxMeasuredRegular, child.DesiredSize.Width); + } + } + } + + if (UpdateEstimates(isVerticalOrientation, maxMeasuredRegular, maxMeasuredHeader)) + { + // Invalidate without resetting estimates so the grown values survive into + // the next rebuild rather than reverting to the 48/36 px defaults. + _bandCacheValid = false; + } + + if (isVerticalOrientation) + { + return new Size(finalSize.Width, _cachedTotalPrimaryExtent); + } + + return new Size(_cachedTotalPrimaryExtent, finalSize.Height); + } + + (double start, double end) GetRealizationPrimaryRange(VirtualizingLayoutContext context, bool isVerticalOrientation) + { + if (isVerticalOrientation) + { + return (context.RealizationRect.Top, context.RealizationRect.Bottom); + } + + return (context.RealizationRect.Left, context.RealizationRect.Right); + } + + (int start, int end) GetRealizationIndexRange(VirtualizingLayoutContext context, int span, bool isVerticalOrientation) + { + if (context.ItemCount <= 0) + { + return (0, -1); + } + + EnsureBandCache(context, span, isVerticalOrientation); + if (_bandStartIndices.Count == 0) + { + return (0, context.ItemCount - 1); + } + + var (realizationStart, realizationEnd) = GetRealizationPrimaryRange(context, isVerticalOrientation); + + int startBand = FindBandByOffset(realizationStart) - RealizationBufferRowsOrColumns; + int endBand = FindBandByOffset(realizationEnd) + RealizationBufferRowsOrColumns; + + startBand = Math.Max(0, startBand); + endBand = Math.Min(_bandStartIndices.Count - 1, endBand); + if (endBand < startBand) + { + endBand = startBand; + } + + return (_bandStartIndices[startBand], _bandEndIndices[endBand]); + } + + double GetEstimatedPrimarySize(bool isVerticalOrientation, bool isHeaderOrFooter) + { + if (isHeaderOrFooter) + { + return isVerticalOrientation ? _estimatedHeaderVerticalExtent : _estimatedHeaderHorizontalExtent; + } + + return isVerticalOrientation ? _estimatedVerticalRegularExtent : _estimatedHorizontalRegularExtent; + } + + bool IsHeaderOrFooterContext(VirtualizingLayoutContext context, int index) + { + var item = context.GetItemAt(index); + if (item is ItemTemplateContext2 itemTemplateContext) + { + return itemTemplateContext.IsHeader || itemTemplateContext.IsFooter; + } + + return false; + } + + /// + /// Determines if a child element is a header or footer via ElementWrapper metadata. + /// + bool IsHeaderOrFooter(UIElement child) + { + return child is ItemContainer itemContainer + && itemContainer.Child is ElementWrapper wrapper + && wrapper.IsHeaderOrFooter; + } + + void EnsureBandCache(VirtualizingLayoutContext context, int span, bool isVerticalOrientation) + { + double regularExtent = GetEstimatedPrimarySize(isVerticalOrientation, isHeaderOrFooter: false); + double headerExtent = GetEstimatedPrimarySize(isVerticalOrientation, isHeaderOrFooter: true); + double spacing = isVerticalOrientation ? MinRowSpacing : MinColumnSpacing; + + if (_bandCacheValid + && _bandCacheItemCount == context.ItemCount + && _bandCacheSpan == span + && _bandCacheIsVertical == isVerticalOrientation + && Math.Abs(_bandCacheRegularExtent - regularExtent) < 0.1 + && Math.Abs(_bandCacheHeaderExtent - headerExtent) < 0.1 + && Math.Abs(_bandCacheSpacing - spacing) < 0.1) + { + return; + } + + RebuildBandCache(context, span, isVerticalOrientation, regularExtent, headerExtent, spacing); + } + + void RebuildBandCache(VirtualizingLayoutContext context, int span, bool isVerticalOrientation, double regularExtent, double headerExtent, double spacing) + { + _bandStartOffsets.Clear(); + _bandEndOffsets.Clear(); + _bandExtents.Clear(); + _bandStartIndices.Clear(); + _bandEndIndices.Clear(); + _cachedTotalPrimaryExtent = 0; + + if (_itemToBand.Length != context.ItemCount) + { + _itemToBand = new int[context.ItemCount]; + } + + Array.Fill(_itemToBand, -1); + + double currentPrimary = 0; + int runStart = -1; + int runCount = 0; + + for (int i = 0; i < context.ItemCount; i++) + { + if (IsHeaderOrFooterContext(context, i)) + { + if (runCount > 0) + { + AddRegularBands(runStart, runCount, span, regularExtent, spacing, ref currentPrimary); + runStart = -1; + runCount = 0; + } + + AddBand(i, i, isHeader: true, headerExtent, spacing, ref currentPrimary); + } + else + { + if (runStart < 0) + { + runStart = i; + } + + runCount++; + } + } + + if (runCount > 0) + { + AddRegularBands(runStart, runCount, span, regularExtent, spacing, ref currentPrimary); + } + + _cachedTotalPrimaryExtent = currentPrimary; + _bandCacheValid = true; + _bandCacheItemCount = context.ItemCount; + _bandCacheSpan = span; + _bandCacheIsVertical = isVerticalOrientation; + _bandCacheRegularExtent = regularExtent; + _bandCacheHeaderExtent = headerExtent; + _bandCacheSpacing = spacing; + } + + void AddRegularBands(int runStart, int runCount, int span, double regularExtent, double spacing, ref double currentPrimary) + { + int regularRunEnd = runStart + runCount - 1; + int bandCount = (runCount + span - 1) / span; + + for (int band = 0; band < bandCount; band++) + { + int bandStart = runStart + (band * span); + int bandEnd = Math.Min(regularRunEnd, bandStart + span - 1); + AddBand(bandStart, bandEnd, isHeader: false, regularExtent, spacing, ref currentPrimary); + } + } + + void AddBand(int startIndex, int endIndex, bool isHeader, double extent, double spacing, ref double currentPrimary) + { + if (_bandStartOffsets.Count > 0) + { + currentPrimary += spacing; + } + + double startOffset = currentPrimary; + currentPrimary += extent; + + int bandIndex = _bandStartOffsets.Count; + _bandStartOffsets.Add(startOffset); + _bandEndOffsets.Add(currentPrimary); + _bandExtents.Add(extent); + _bandStartIndices.Add(startIndex); + _bandEndIndices.Add(endIndex); + + for (int i = startIndex; i <= endIndex; i++) + { + _itemToBand[i] = bandIndex; + } + } + + int FindBandByOffset(double offset) + { + int lo = 0; + int hi = _bandEndOffsets.Count - 1; + if (hi < 0) + { + return 0; + } + + while (lo <= hi) + { + int mid = lo + ((hi - lo) / 2); + if (_bandEndOffsets[mid] < offset) + { + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return Math.Min(_bandEndOffsets.Count - 1, Math.Max(0, lo)); + } + + int GetBandIndex(int itemIndex) + { + if ((uint)itemIndex >= (uint)_itemToBand.Length) + { + return -1; + } + + return _itemToBand[itemIndex]; + } + + bool UpdateEstimates(bool isVerticalOrientation, double measuredRegular, double measuredHeader) + { + bool changed = false; + + if (isVerticalOrientation) + { + if (measuredRegular > _estimatedVerticalRegularExtent + 0.1) + { + _estimatedVerticalRegularExtent = measuredRegular; + changed = true; + } + + if (measuredHeader > _estimatedHeaderVerticalExtent + 0.1) + { + _estimatedHeaderVerticalExtent = measuredHeader; + changed = true; + } + } + else + { + if (measuredRegular > _estimatedHorizontalRegularExtent + 0.1) + { + _estimatedHorizontalRegularExtent = measuredRegular; + changed = true; + } + + if (measuredHeader > _estimatedHeaderHorizontalExtent + 0.1) + { + _estimatedHeaderHorizontalExtent = measuredHeader; + changed = true; + } + } + + return changed; + } + + void InvalidateBandCache() + { + _bandCacheValid = false; + _bandCacheItemCount = -1; + _cachedTotalPrimaryExtent = 0; + // Reset estimates on a genuine structural change (items added/removed/cleared). + // Do NOT call this from measure/arrange grow-paths — use _bandCacheValid = false + // directly there so grown estimates survive into the next rebuild. + _estimatedVerticalRegularExtent = DefaultEstimatedRegularExtent; + _estimatedHorizontalRegularExtent = DefaultEstimatedRegularExtent; + _estimatedHeaderVerticalExtent = DefaultEstimatedHeaderExtent; + _estimatedHeaderHorizontalExtent = DefaultEstimatedHeaderExtent; + } + + protected override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + InvalidateBandCache(); + base.OnItemsChangedCore(context, source, args); + } + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs new file mode 100644 index 000000000000..1a2843059294 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs @@ -0,0 +1,412 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// An observable collection that flattens grouped items into a single list with +/// header/footer contexts per group, keeping in sync with the source via INCC. +/// +internal class GroupedItemTemplateCollection2 : ObservableCollection +{ + readonly IEnumerable _itemsSource; + readonly DataTemplate _itemTemplate; + readonly DataTemplate? _groupHeaderTemplate; + readonly DataTemplate? _groupFooterTemplate; + readonly BindableObject _container; + readonly IMauiContext? _mauiContext; + readonly Dictionary _groupSubscriptions = new(); + bool _suppressNotifications; + + public GroupedItemTemplateCollection2(IEnumerable itemsSource, + DataTemplate itemTemplate, DataTemplate groupHeaderTemplate, DataTemplate groupFooterTemplate, + BindableObject container, IMauiContext? mauiContext = null) + { + _itemsSource = itemsSource; + _itemTemplate = itemTemplate; + _groupHeaderTemplate = groupHeaderTemplate; + _groupFooterTemplate = groupFooterTemplate; + _container = container; + _mauiContext = mauiContext; + + RebuildFlatList(); + SubscribeToGroups(_itemsSource); + + if (_itemsSource is INotifyCollectionChanged incc) + { + incc.CollectionChanged += OnGroupsChanged; + } + } + + protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (!_suppressNotifications) + { + base.OnCollectionChanged(e); + } + } + + ItemTemplateContext2 CreateItemContext(object item) => + new(_itemTemplate, item, _container, mauiContext: _mauiContext); + + ItemTemplateContext2? CreateHeaderContext(object group) => + _groupHeaderTemplate is not null + ? new(_groupHeaderTemplate, group, _container, null, null, null, isHeader: true, isFooter: false, mauiContext: _mauiContext) + : null; + + ItemTemplateContext2? CreateFooterContext(object group) => + _groupFooterTemplate is not null + ? new(_groupFooterTemplate, group, _container, null, null, null, isHeader: false, isFooter: true, mauiContext: _mauiContext) + : null; + + void SubscribeToGroups(IEnumerable? groups) + { + if (groups is null) + return; + + foreach (var group in groups) + { + SubscribeToGroup(group); + } + } + + void SubscribeToGroup(object group) + { + if (group is INotifyCollectionChanged incc && !_groupSubscriptions.ContainsKey(group)) + { + var handler = CreateGroupItemsChangedHandler(group); + incc.CollectionChanged += handler; + _groupSubscriptions[group] = handler; + } + } + + NotifyCollectionChangedEventHandler CreateGroupItemsChangedHandler(object group) => + (s, e) => _container.Dispatcher.DispatchIfRequired(() => OnGroupItemsChanged(group, e)); + + void UnsubscribeFromGroups(IEnumerable? groups) + { + if (groups is null) + return; + + foreach (var group in groups) + { + UnsubscribeFromGroup(group); + } + } + + void UnsubscribeFromGroup(object group) + { + if (_groupSubscriptions.TryGetValue(group, out var handler)) + { + if (group is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= handler; + } + _groupSubscriptions.Remove(group); + } + } + + void UnsubscribeFromAllGroups() + { + foreach (var kvp in _groupSubscriptions) + { + if (kvp.Key is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= kvp.Value; + } + } + _groupSubscriptions.Clear(); + } + + void RebuildFlatList() + { + Items.Clear(); + + foreach (var group in _itemsSource) + { + if (group is string || group is not IEnumerable itemsList) + continue; + + var header = CreateHeaderContext(group); + if (header is not null) + { + Items.Add(header); + } + + foreach (var item in itemsList) + { + Items.Add(CreateItemContext(item)); + } + + var footer = CreateFooterContext(group); + if (footer is not null) + { + Items.Add(footer); + } + } + } + + void OnGroupsChanged(object? sender, NotifyCollectionChangedEventArgs args) => + _container.Dispatcher.DispatchIfRequired(() => OnGroupsCollectionChanged(args)); + + void OnGroupsCollectionChanged(NotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + SubscribeToGroups(args.NewItems); + ResetWithoutResubscribe(); + break; + case NotifyCollectionChangedAction.Move: + ResetWithoutResubscribe(); + break; + case NotifyCollectionChangedAction.Remove: + if (args.OldItems is not null) + { + UnsubscribeFromGroups(args.OldItems); + ResetWithoutResubscribe(); + } + break; + + case NotifyCollectionChangedAction.Replace: + case NotifyCollectionChangedAction.Reset: + Reset(); + break; + } + } + + /// + /// Gets the flat index for the first item in a group by iterating through the source collection. + /// Returns the index after the group header (if present), pointing to where group items start. + /// + int GetFlatIndexForGroupItems(object targetGroup) + { + int flatIndex = 0; + + foreach (var group in _itemsSource) + { + if (ReferenceEquals(group, targetGroup)) + { + // Found the group - return index after header (if present) + return _groupHeaderTemplate is not null ? flatIndex + 1 : flatIndex; + } + + if (group is IEnumerable items && group is not string) + { + // Count header + items + footer for this group + if (_groupHeaderTemplate is not null) + flatIndex++; + + if (group is ICollection collection) + flatIndex += collection.Count; + else + flatIndex += items.Cast().Count(); + + if (_groupFooterTemplate is not null) + flatIndex++; + } + } + + return -1; + } + + void OnGroupItemsChanged(object group, NotifyCollectionChangedEventArgs e) + { + int flatIndex = GetFlatIndexForGroupItems(group); + if (flatIndex == -1) + return; + + // For incremental updates (Add/Remove/Replace/Move), we need IList for indexed access. + // For IEnumerable-only groups, fall back to a full Reset. + if (group is not IList groupList) + { + ResetWithoutResubscribe(); + return; + } + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + HandleGroupItemsAdd(e, flatIndex, groupList); + break; + + case NotifyCollectionChangedAction.Remove: + HandleGroupItemsRemove(e, flatIndex); + break; + + case NotifyCollectionChangedAction.Replace: + HandleGroupItemsReplace(e, flatIndex); + break; + + case NotifyCollectionChangedAction.Move: + HandleGroupItemsMove(e, flatIndex); + break; + + case NotifyCollectionChangedAction.Reset: + ResetWithoutResubscribe(); + break; + } + } + + void HandleGroupItemsAdd(NotifyCollectionChangedEventArgs e, int flatIndex, IList groupList) + { + if (e.NewItems is null) + return; + + int insertIndex = flatIndex + (e.NewStartingIndex >= 0 ? e.NewStartingIndex : groupList.Count - e.NewItems.Count); + + if (insertIndex < 0 || insertIndex > Items.Count) + { + ResetWithoutResubscribe(); + return; + } + var newItems = new List(e.NewItems.Count); + + _suppressNotifications = true; + foreach (var item in e.NewItems) + { + var newItem = CreateItemContext(item); + newItems.Add(newItem); + Items.Insert(insertIndex++, newItem); + } + _suppressNotifications = false; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, newItems, insertIndex - newItems.Count)); + } + + void HandleGroupItemsRemove(NotifyCollectionChangedEventArgs e, int flatIndex) + { + if (e.OldItems is null) + return; + + int removeIndex = flatIndex + (e.OldStartingIndex >= 0 ? e.OldStartingIndex : 0); + if (removeIndex < 0 || removeIndex + e.OldItems.Count > Items.Count) + { + ResetWithoutResubscribe(); + return; + } + var removedItems = new List(e.OldItems.Count); + + _suppressNotifications = true; + for (int i = 0; i < e.OldItems.Count; i++) + { + removedItems.Add(Items[removeIndex]); + Items.RemoveAt(removeIndex); + } + _suppressNotifications = false; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, removedItems, removeIndex)); + } + + void HandleGroupItemsReplace(NotifyCollectionChangedEventArgs e, int flatIndex) + { + if (e.NewItems is null || e.OldItems is null) + return; + + int replaceIndex = flatIndex + (e.NewStartingIndex >= 0 ? e.NewStartingIndex : 0); + if (replaceIndex < 0 || replaceIndex + e.NewItems.Count - 1 >= Items.Count) + { + ResetWithoutResubscribe(); + return; + } + var oldItems = new List(e.NewItems.Count); + var newItems = new List(e.NewItems.Count); + + _suppressNotifications = true; + for (int i = 0; i < e.NewItems.Count; i++) + { + oldItems.Add(Items[replaceIndex + i]); + var item = e.NewItems[i]; + if (item is null) + continue; + + var newItem = CreateItemContext(e.NewItems[i]!); + newItems.Add(newItem); + Items[replaceIndex + i] = newItem; + } + _suppressNotifications = false; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Replace, newItems, oldItems, replaceIndex)); + } + + void HandleGroupItemsMove(NotifyCollectionChangedEventArgs e, int flatIndex) + { + if (e.OldItems is null) + return; + + int oldIndex = flatIndex + (e.OldStartingIndex >= 0 ? e.OldStartingIndex : 0); + int newIndex = flatIndex + (e.NewStartingIndex >= 0 ? e.NewStartingIndex : 0); + if (oldIndex < 0 || oldIndex >= Items.Count || newIndex < 0 || newIndex > Items.Count) + { + ResetWithoutResubscribe(); + return; + } + var movedItems = new List(e.OldItems.Count); + + _suppressNotifications = true; + for (int i = 0; i < e.OldItems.Count; i++) + { + var item = Items[oldIndex]; + Items.RemoveAt(oldIndex); + Items.Insert(newIndex + i, item); + movedItems.Add(item); + } + _suppressNotifications = false; + + // Fire Remove + Add instead of Move. + // CsWinRT translates CollectionChanged(Move) -> VectorChanged(Reset), which causes + // ItemsRepeater to discard all realized containers and reset the scroll position. + // Remove + Add translates to VectorChanged(ItemRemoved) + VectorChanged(ItemInserted), + // which ItemsRepeater handles by repositioning only the affected containers while + // preserving the ScrollViewer offset. This matches the flat-list fix in + // ObservableItemTemplateCollection2.MoveItem. + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, movedItems, oldIndex)); + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, movedItems, newIndex)); + } + + void ResetWithoutResubscribe() + { + _suppressNotifications = true; + RebuildFlatList(); + _suppressNotifications = false; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + /// + /// Full reset that also resubscribes to all groups. + /// Used for Replace and Reset actions where group references may have changed. + /// + void Reset() + { + UnsubscribeFromAllGroups(); + + _suppressNotifications = true; + RebuildFlatList(); + _suppressNotifications = false; + + SubscribeToGroups(_itemsSource); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + /// + /// Unsubscribes from all group and top-level collection changed events. + /// Must be called when the collection is being replaced or the handler disconnects. + /// + internal void CleanUp() + { + UnsubscribeFromAllGroups(); + + if (_itemsSource is INotifyCollectionChanged incc) + { + incc.CollectionChanged -= OnGroupsChanged; + } + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs new file mode 100644 index 000000000000..97408f237451 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Generic; +using Microsoft.Maui.Controls.Platform; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// Element factory that creates, recycles, and manages elements +/// for the WinUI ItemsView/ItemsRepeater, using a template-keyed recycle pool. +/// +internal partial class ItemFactory(ItemsView view) : IElementFactory +{ + readonly ItemsView _view = view; + Dictionary> _recyclePool = new(); + + /// + /// A minimal ControlTemplate for ItemContainer that contains no selection visuals + /// (no PART_SelectionCheckbox, no PART_SelectionVisual, no PART_CommonVisual). + /// Defined in ItemsViewStyles.xaml and applied to group header/footer containers + /// so they can never show a checkbox or selection highlight. + /// + static Microsoft.UI.Xaml.Controls.ControlTemplate NonSelectableItemContainerTemplate => + (Microsoft.UI.Xaml.Controls.ControlTemplate)Microsoft.UI.Xaml.Application.Current.Resources["NonSelectableItemContainerTemplate"]; + + /// + /// Caches the default ItemContainer template so it can be restored + /// when a header/footer container is recycled for a regular item. + /// + Microsoft.UI.Xaml.Controls.ControlTemplate? _defaultItemContainerTemplate; + internal static readonly BindableProperty OriginTemplateProperty = + BindableProperty.CreateAttached( + "OriginTemplate", typeof(DataTemplate), typeof(ItemFactory), null); + + /// + /// Creates or retrieves a recycled for the given data context. + /// + public UIElement? GetElement(ElementFactoryGetArgs args) + { + // NOTE: 1.6: replace w/ RecyclePool + if (args.Data is ItemTemplateContext2 templateContext) + { + DataTemplate? template = templateContext.MauiDataTemplate; + if (template is DataTemplateSelector selector) + { + template = selector.SelectTemplate(templateContext.Item, _view); + } + + if (template is null) + { + template = _view.EmptyViewTemplate; + } + + ItemContainer? container = null; + ElementWrapper? wrapper = null; + + if (_recyclePool.TryGetValue(template, out var itemContainers)) + { + if (itemContainers.Count > 0) + { + container = itemContainers[0]; + if (container is not null) + { + wrapper = container.Child as ElementWrapper; + } + + itemContainers.RemoveAt(0); + } + } + + if (wrapper is null) + { + var viewContent = template.CreateContent() as View; + if (_view.Handler?.MauiContext is not null && viewContent is not null) + { + wrapper = new ElementWrapper(_view.Handler.MauiContext); + wrapper.HorizontalAlignment = viewContent.HorizontalOptions.Alignment switch + { + LayoutAlignment.Start => HorizontalAlignment.Left, + LayoutAlignment.Center => HorizontalAlignment.Center, + LayoutAlignment.End => HorizontalAlignment.Right, + _ => HorizontalAlignment.Stretch + }; + wrapper.VerticalAlignment = viewContent.VerticalOptions.Alignment switch + { + LayoutAlignment.Start => VerticalAlignment.Top, + LayoutAlignment.Center => VerticalAlignment.Center, + LayoutAlignment.End => VerticalAlignment.Bottom, + _ => VerticalAlignment.Stretch + }; + wrapper.HorizontalContentAlignment = HorizontalAlignment.Stretch; + wrapper.VerticalContentAlignment = VerticalAlignment.Stretch; + wrapper.SetContent(viewContent); + + if (wrapper.VirtualView is View virtualView) + { + virtualView.SetValue(OriginTemplateProperty, template); + } + } + } + + if (wrapper?.VirtualView is View view) + { + // For header/footer contexts the item can legitimately be null (standalone + // header with no group), so fall back to the parent ItemsView.BindingContext. + // For regular data items a null entry must stay null so the cell renders + // blank — matching CV1 behaviour and avoiding the parent viewmodel leaking + // into the cell when the source contains null elements. + view.BindingContext = (templateContext.IsHeader || templateContext.IsFooter) + ? (templateContext.Item ?? _view.BindingContext) + : templateContext.Item; + _view.AddLogicalChild(view); + + // Sync the CommonStates VSM group to match actual selection state on every + // rebind (new or recycled container). GoToState(Normal) only transitions + // within the CommonStates VisualStateGroup and cannot affect states in other + // groups, so it will not clobber app-defined custom states. This matches the + // Selected : Normal contract that Android, iOS, and Tizen all honour. + if (view is VisualElement visualElement && + _view is SelectableItemsView selectableItemsView) + { + bool isSelected = selectableItemsView.SelectionMode != SelectionMode.None && + (selectableItemsView.SelectionMode == SelectionMode.Single + ? object.Equals(selectableItemsView.SelectedItem, templateContext.Item) + : selectableItemsView.SelectedItems.Contains(templateContext.Item)); + + VisualStateManager.GoToState(visualElement, + isSelected + ? VisualStateManager.CommonStates.Selected + : VisualStateManager.CommonStates.Normal); + } + + } + + container ??= new ItemContainer() + { + Child = wrapper, + VerticalAlignment = VerticalAlignment.Stretch, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + // Prevent group headers/footers from being selectable by swapping + // the ItemContainer's ControlTemplate to one that has no checkbox + // or selection visuals. This is stable across all selection modes + // and visual state transitions. + // Must be set every time to handle recycled containers correctly. + if (wrapper is not null) + { + // Refresh the flag from the current context for both new and recycled wrappers. + // The pool is keyed by DataTemplate, so a recycled container can be reused for + // different purposes (header vs. item) if they share the same template. Without + // refreshing, the wrapper keeps its original role, causing incorrect template or + // selectability behavior. + wrapper.IsHeaderOrFooter = templateContext.IsHeader || templateContext.IsFooter; + bool isHeaderOrFooter = wrapper.IsHeaderOrFooter; + if (isHeaderOrFooter) + { + // Cache the default template once for later restoration + _defaultItemContainerTemplate ??= container.Template; + container.Template = NonSelectableItemContainerTemplate; + } + else + { + // Restore the default template for regular items (recycled from header/footer) + if (_defaultItemContainerTemplate is not null && container.Template == NonSelectableItemContainerTemplate) + { + container.Template = _defaultItemContainerTemplate; + } + + // When in multi-select mode, push content right so it doesn't + // overlap the WinUI ItemContainer checkbox. + if (_view is SelectableItemsView selectable && selectable.SelectionMode == SelectionMode.Multiple) + { + wrapper.Margin = CollectionViewHandler2.MultiSelectContentMargin; + } + else + { + wrapper.Margin = new Microsoft.UI.Xaml.Thickness(0); + } + } + } + return container; + } + + return null; + } + + /// + /// Returns an element to the recycle pool, keyed by its original . + /// + public void RecycleElement(ElementFactoryRecycleArgs args) + { + var item = args.Element as ItemContainer; + var wrapper = item?.Child as ElementWrapper; + var wrapperView = wrapper?.VirtualView as View; + + // Do NOT set wrapperView.BindingContext = null here. + // + // WinUI's ItemsRepeater can call RecycleElement immediately followed by GetElement + // for the SAME item during its layout/measurement pass. Nulling BindingContext here + // causes three BindingContextChanged events instead of one (null→item, item→null, null→item), + // which breaks Issue #16787 (BindingContextChanged fires too many times). + // + // Memory note: pooled containers hold a reference to the last BindingContext while + // sitting in the pool. This is bounded by the number of visible (realized) items — + // WinUI's ItemsRepeater only creates as many containers as fit on screen — so the + // pool can never grow to O(total-list-size). References are released as soon as: + // (a) GetElement reuses the container and overwrites BindingContext, or + // (b) CleanUp() runs when ItemsSource changes (which nulls all pooled BCs). + // CleanUp() is the correct place for full teardown; RecycleElement is not. + + if (wrapper is not null) + { + wrapper.DataContext = null; + // Unsubscribe the first-item SizeChanged observer so a recycled wrapper + // cannot update the global size cache when rebound to a different item. + wrapper.UnwireContentSizeObserver(); + } + + DataTemplate? template = wrapperView?.GetValue(OriginTemplateProperty) as DataTemplate; + if (template != null && item is not null) + { + if (_recyclePool.TryGetValue(template, out var itemContainers)) + { + itemContainers.Add(item); + } + else + { + _recyclePool[template] = new List { item }; + } + } + + _view.RemoveLogicalChild(wrapperView); + } + + /// + /// Clears the recycle pool and removes logical children held by pooled elements. + /// Must be called when the items source changes or when the handler disconnects + /// to prevent memory leaks from pooled ItemContainers holding strong references. + /// + internal void CleanUp() + { + foreach (var kvp in _recyclePool) + { + foreach (var container in kvp.Value) + { + var wrapper = container?.Child as ElementWrapper; + var wrapperView = wrapper?.VirtualView as View; + if (wrapperView is not null) + { + wrapperView.BindingContext = null; + _view.RemoveLogicalChild(wrapperView); + } + + if (wrapper is not null) + { + wrapper.DataContext = null; + } + } + } + + _recyclePool.Clear(); + } +} + +/// +/// A wrapper that hosts a MAUI inside a WinUI element tree. +/// Handles MeasureFirstItem optimization by caching the first measured size. +/// +internal partial class ElementWrapper : ContentControl +{ + /// The MAUI virtual view hosted by this wrapper. + public IView? VirtualView { get; private set; } + IMauiContext _context; + + /// Whether this wrapper hosts a group header or footer (excluded from size caching). + public bool IsHeaderOrFooter { get; set; } + + // Stored so RecycleElement can unsubscribe without a flag — mirrors iOS prepareForReuse. + SizeChangedEventHandler? _contentSizeChangedHandler; + FrameworkElement? _observedContent; + + /// + /// Unsubscribes the first-item SizeChanged observer wired during MeasureOverride. + /// Must be called from RecycleElement so a recycled wrapper cannot poison the global size cache. + /// + internal void UnwireContentSizeObserver() + { + if (_observedContent != null && _contentSizeChangedHandler != null) + { + _observedContent.SizeChanged -= _contentSizeChangedHandler; + _contentSizeChangedHandler = null; + _observedContent = null; + } + } + + public ElementWrapper(IMauiContext context) + { + _context = context; + } + + /// + /// Sets the MAUI view content, converting it to a platform element. + /// Only sets content if not already initialized. + /// + public void SetContent(IView view) + { + if (VirtualView is null || VirtualView.Handler is null) + { + // Store the virtual view but defer ToPlatform() until MeasureOverride. + // At this point the ElementWrapper is not yet in the WinUI visual tree + // (no XamlRoot). Calling ToPlatform() here would create a handler and + // GesturePlatformManager, which tries to subscribe pointer events on + // a disconnected element — causing a COM exception when the view has + // a PointerOver visual state. By deferring to MeasureOverride, the + // element is already in the visual tree with a valid XamlRoot. + VirtualView = view; + } + } + + void EnsurePlatformViewCreated() + { + if (VirtualView is not null && Content is null) + { + if (XamlRoot is null) + { + // Element is not yet in the visual tree. Defer ToPlatform() + // to avoid a COM exception when GesturePlatformManager tries + // to subscribe pointer events on a disconnected element. + // Re-trigger layout once the element is loaded. + Loaded += OnLoadedCreatePlatformView; + return; + } + + var platformView = VirtualView.ToPlatform(_context); + Content = platformView; + } + } + + void OnLoadedCreatePlatformView(object sender, RoutedEventArgs e) + { + Loaded -= OnLoadedCreatePlatformView; + EnsurePlatformViewCreated(); + } + + CollectionViewHandler2? GetCollectionViewHandler() + { + if (VirtualView is View view && + view.Parent is ItemsView itemsView && + itemsView.Handler is CollectionViewHandler2 cvHandler) + { + return cvHandler; + } + return null; + } + protected override global::Windows.Foundation.Size MeasureOverride(global::Windows.Foundation.Size availableSize) + { + EnsurePlatformViewCreated(); + var handler = GetCollectionViewHandler(); + // Check if we should use cached first item size. + // Headers and footers always measure at their natural size — never pin them to the + // cached regular-item extent (e.g. a 100px FlexLayout would bloat every header). + var cachedSize = handler?.GetCachedFirstItemSize() ?? global::Windows.Foundation.Size.Empty; + if (!cachedSize.IsEmpty && !IsHeaderOrFooter) + { + // For MeasureFirstItem: pin ONLY the along-axis (scroll direction) to the + // cached first-item size so every item is uniform in that direction. Measure + // the child with the current cross-axis availableSize so its internal layout + // (e.g. MAUI Grid column widths) is computed against the real arrange width — + // without this, right-aligned content in the template would position against + // the cached narrow width and get truncated on later items. See #25191. + bool isHorizontal = handler?.PlatformView is MauiItemsView miv && miv.IsHorizontalLayout; + double measureWidth = isHorizontal + ? cachedSize.Width + : (double.IsInfinity(availableSize.Width) ? cachedSize.Width : availableSize.Width); + double measureHeight = isHorizontal + ? (double.IsInfinity(availableSize.Height) ? cachedSize.Height : availableSize.Height) + : cachedSize.Height; + var constrainedSize = new global::Windows.Foundation.Size(measureWidth, measureHeight); + base.MeasureOverride(constrainedSize); + // Return the constrained size (cross-axis = current viewport, along-axis = cached) + // so DesiredSize tracks viewport changes and the list re-flows when the viewport + // shrinks (e.g. window minimize). Along-axis uniformity is preserved via the + // cached value. + return constrainedSize; + } + // Measure normally with the original available size + var measuredSize = base.MeasureOverride(availableSize); + // Cache the size if this is the first item being measured and the size is valid + if (handler != null && !IsHeaderOrFooter && measuredSize.Width > 0 && measuredSize.Height > 0) + { + var currentCached = handler.GetCachedFirstItemSize(); + if (currentCached.IsEmpty) + { + // For first item with images: Hook into content's SizeChanged to update cache when images load + if (VirtualView is View firstView && Content is FrameworkElement content) + { + void OnContentSizeChanged(object? sender, Microsoft.UI.Xaml.SizeChangedEventArgs e) + { + var currentCache = handler.GetCachedFirstItemSize(); + if (!currentCache.IsEmpty && (e.NewSize.Width > currentCache.Width || e.NewSize.Height > currentCache.Height)) + { + handler.SetCachedFirstItemSize(e.NewSize); + InvalidateMeasure(); + } + } + void OnContentUnloaded(object? sender, RoutedEventArgs e) + { + if (sender is FrameworkElement element) + { + element.SizeChanged -= OnContentSizeChanged; + element.Unloaded -= OnContentUnloaded; + } + _contentSizeChangedHandler = null; + _observedContent = null; + } + // Store delegate and content so RecycleElement can unsubscribe directly. + _contentSizeChangedHandler = OnContentSizeChanged; + _observedContent = content; + content.SizeChanged += OnContentSizeChanged; + content.Unloaded += OnContentUnloaded; + } + handler.SetCachedFirstItemSize(measuredSize); + } + } + return measuredSize; + } + + protected override global::Windows.Foundation.Size ArrangeOverride(global::Windows.Foundation.Size finalSize) + { + var handler = GetCollectionViewHandler(); + var cachedSize = handler?.GetCachedFirstItemSize() ?? global::Windows.Foundation.Size.Empty; + if (!cachedSize.IsEmpty && !IsHeaderOrFooter) + { + // For MeasureFirstItem: Enforce uniformity on the along-axis (scroll direction) + // only. The cross-axis uses finalSize so items continue to stretch across the + // viewport (matching non-MeasureFirstItem behavior and Android/iOS parity). + // Without this, items whose template has no explicit cross-axis size (e.g. a + // Border with only HeightRequest) would be clipped to their natural content + // width, producing a narrow column instead of full-width rows. + // See: https://github.com/dotnet/maui/issues/25191 + bool isHorizontal = handler?.PlatformView is MauiItemsView miv && miv.IsHorizontalLayout; + var arrangeSize = isHorizontal + ? new global::Windows.Foundation.Size(cachedSize.Width, finalSize.Height) + : new global::Windows.Foundation.Size(finalSize.Width, cachedSize.Height); + base.ArrangeOverride(arrangeSize); + Clip = new Microsoft.UI.Xaml.Media.RectangleGeometry + { + Rect = new global::Windows.Foundation.Rect(0, 0, arrangeSize.Width, arrangeSize.Height) + }; + return arrangeSize; + } + // Clear any clip from a previously cached state (recycled container scenario) + if (Clip is not null) + { + Clip = null; + } + return base.ArrangeOverride(finalSize); + } +} \ No newline at end of file diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContext2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContext2.cs new file mode 100644 index 000000000000..cd2c2837cada --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContext2.cs @@ -0,0 +1,55 @@ +using System; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// Pairs a data item with its and layout metadata. +/// Used by the WinUI ItemsView/ItemsRepeater as the data context for each realized element. +/// +internal class ItemTemplateContext2 +{ + readonly WeakReference _container; + + /// The data template used to create the visual element for this item. + public DataTemplate? MauiDataTemplate { get; } + + /// The MAUI context used for platform view creation. + public IMauiContext? MauiContext { get; } + + /// The actual data item from the items source. + public object? Item { get; } + + /// The parent container (e.g., the ItemsView). Held via a weak reference to avoid leaks. + public BindableObject? Container => _container.TryGetTarget(out var c) ? c : null; + + /// The desired item height for uniform sizing strategies. + public double ItemHeight { get; } + + /// The desired item width for uniform sizing strategies. + public double ItemWidth { get; } + + /// The spacing between items. + public Thickness ItemSpacing { get; } + + /// Whether this context represents a group header. + public bool IsHeader { get; } + + /// Whether this context represents a group footer. + public bool IsFooter { get; } + + public ItemTemplateContext2(DataTemplate mauiDataTemplate, object? item, BindableObject container, + double? height = null, double? width = null, Thickness? itemSpacing = null, + bool isHeader = false, bool isFooter = false, IMauiContext? mauiContext = null) + { + MauiDataTemplate = mauiDataTemplate; + Item = item; + _container = new(container); + MauiContext = mauiContext; + ItemHeight = height ?? 0; + ItemWidth = width ?? 0; + ItemSpacing = itemSpacing ?? default; + + IsHeader = isHeader; + IsFooter = isFooter; + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextEnumerable2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextEnumerable2.cs new file mode 100644 index 000000000000..4ea9e0bb7037 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextEnumerable2.cs @@ -0,0 +1,39 @@ +using System.Collections; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// Wraps a non-list items source, yielding an +/// for each item on enumeration. +/// Used when the source does not implement . +/// +internal class ItemTemplateContextEnumerable2 : IEnumerable +{ + readonly IEnumerable _itemsSource; + readonly DataTemplate _itemTemplate; + readonly BindableObject _container; + readonly IMauiContext? _mauiContext; + readonly double _itemHeight; + readonly double _itemWidth; + readonly Thickness _itemSpacing; + + public ItemTemplateContextEnumerable2(IEnumerable itemsSource, DataTemplate itemTemplate, BindableObject container, + double? itemHeight = null, double? itemWidth = null, Thickness? itemSpacing = null, IMauiContext? mauiContext = null) + { + _itemsSource = itemsSource; + _itemTemplate = itemTemplate; + _container = container; + _mauiContext = mauiContext; + _itemHeight = itemHeight ?? 0; + _itemWidth = itemWidth ?? 0; + _itemSpacing = itemSpacing ?? default; + } + + public IEnumerator GetEnumerator() + { + foreach (var item in _itemsSource) + { + yield return new ItemTemplateContext2(_itemTemplate, item, _container, _itemHeight, _itemWidth, _itemSpacing, + false, false, _mauiContext); + } + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextList2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextList2.cs new file mode 100644 index 000000000000..e50a1f6fb9fe --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemTemplateContextList2.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// A lazily-populated read-only list that wraps an items source, +/// creating instances on demand and caching them by index. +/// Used for non-observable list sources. +/// +internal class ItemTemplateContextList2 : IReadOnlyList +{ + readonly IList _itemsSource; + readonly DataTemplate _itemTemplate; + readonly BindableObject _container; + readonly IMauiContext? _mauiContext; + readonly double _itemHeight; + readonly double _itemWidth; + readonly Thickness _itemSpacing; + + readonly Dictionary _itemTemplateContexts; + + public int Count => _itemsSource.Count; + + public ItemTemplateContext2 this[int index] + { + get + { + if (!_itemTemplateContexts.TryGetValue(index, out var context)) + { + var item = _itemsSource[index]; + _itemTemplateContexts[index] = context = new ItemTemplateContext2(_itemTemplate, item!, + _container, _itemHeight, _itemWidth, _itemSpacing, false, false, _mauiContext); + } + + return context; + } + } + + public ItemTemplateContextList2(IList itemsSource, DataTemplate itemTemplate, BindableObject container, + double? itemHeight = null, double? itemWidth = null, Thickness? itemSpacing = null, IMauiContext? mauiContext = null) + { + _itemsSource = itemsSource; + _itemTemplate = itemTemplate; + _container = container; + _mauiContext = mauiContext; + _itemHeight = itemHeight ?? 0; + _itemWidth = itemWidth ?? 0; + _itemSpacing = itemSpacing ?? default; + + // Cap initial dictionary capacity at 64 to avoid over-allocation for small collections + _itemTemplateContexts = new(capacity: Math.Min(64, _itemsSource.Count)); + } + + public IEnumerator GetEnumerator() + { + return new ItemTemplateContextListEnumerator2(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + +#nullable disable + internal class ItemTemplateContextListEnumerator2 : IEnumerator + { + public ItemTemplateContext2 Current { get; private set; } + object IEnumerator.Current => Current; + int _currentIndex = -1; + ItemTemplateContextList2 _itemTemplateContextList; + + public ItemTemplateContextListEnumerator2(ItemTemplateContextList2 itemTemplateContextList) + { + _itemTemplateContextList = itemTemplateContextList; + } + + public void Dispose() + { + } + + public bool MoveNext() + { + if (_currentIndex >= _itemTemplateContextList.Count - 1) + { + return false; + } + + _currentIndex += 1; + Current = _itemTemplateContextList[_currentIndex]; + + return true; + } + + public void Reset() + { + Current = null; + _currentIndex = -1; + } + } +#nullable enable +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs new file mode 100644 index 000000000000..469ec2e17506 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -0,0 +1,1698 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Shapes; +using WApp = Microsoft.UI.Xaml.Application; +using WBorder = Microsoft.UI.Xaml.Controls.Border; +using WDataTransfer = Windows.ApplicationModel.DataTransfer; +using WVisibility = Microsoft.UI.Xaml.Visibility; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// Drag-and-drop / reordering implementation for . +/// Supports and +/// for both flat and grouped sources. +/// +internal partial class MauiItemsView +{ + // Drag and drop fields + object? _draggedItem; + // Flat source index of the dragged container, captured at DragStarting. + // Used by PerformReorder to disambiguate value-equal duplicates (record structs, + // boxed primitives, repeated entries) where IndexOfItem would return the first match. + int _draggedSourceIndex = -1; + ItemContainer? _sourceContainer; + int _insertionIndex = -1; + bool _insertAfter; + bool _canReorderItems; + bool _dragDropWired; + ItemsView? _mauiVirtualView; + + /// + /// Set by when a flat (non-grouped) + /// is active. Allows + /// to call — which atomically + /// moves the item in both the source and the template collection without reflection — instead of + /// falling back to plain RemoveAt + Insert on the raw source. + /// + internal ObservableItemTemplateCollection2? FlatTemplateCollection { get; set; } + + /// + /// True while a drag/drop reorder mutation is in progress. Used by + /// to skip ApplyItemsUpdatingScrollMode + /// during the collection change that results from the reorder, so the scroll position + /// is not reset to the first or last item by the items-updating scroll mode logic. + /// + internal bool IsReordering { get; private set; } + + // Between-items drop indicator — circle head with "+" and a colored line on _dropIndicatorCanvas. + WBorder? _dropIndicatorHead; // hollow circle at the leading edge + Rectangle? _dropIndicatorLine; // accent-colored line extending from the circle + + // Dim overlay opacity applied to non-source containers during a drag so the + // list visually enters "reorder mode" (same pattern as iOS drag-reorder). + const double DragDimOpacity = 0.4; + + // Auto-scroll fields + Microsoft.UI.Dispatching.DispatcherQueueTimer? _autoScrollTimer; + double _targetScrollVelocity; + double _currentScrollVelocity; + const double AutoScrollThreshold = 60.0; + const double AutoScrollMaxSpeed = 25.0; + const double AutoScrollMinSpeed = 3.0; + const double ScrollAcceleration = 0.3; + + RoutedEventHandler? _deferredWireHandler; + + // Cached insertion-indicator fade-in animation (created once in OnApplyTemplate). + Microsoft.UI.Xaml.Media.Animation.Storyboard? _insertionFadeStoryboard; + Microsoft.UI.Xaml.Media.Animation.DoubleAnimation? _insertionHeadFadeIn; + Microsoft.UI.Xaml.Media.Animation.DoubleAnimation? _insertionLineFadeIn; + + // Per-type cache for ObservableCollection.Move(int,int) MethodInfo lookups. + // Keyed by concrete collection type so each closed generic variant is cached once. + static readonly System.Collections.Concurrent.ConcurrentDictionary + s_moveMethodCache = new(); + + /// + /// Event fired when a reorder operation completes successfully. + /// + public event EventHandler? ReorderCompleted; + + /// + /// Convenience accessor that exposes the templated ItemsRepeater. + /// + internal ItemsRepeater? ItemsRepeaterControl => _itemsRepeater as ItemsRepeater; + + /// + /// Sets a reference to the MAUI ItemsView for accessing the original ItemsSource + /// during drag/drop reorder operations. The MAUI source is mutated directly so + /// reorder events propagate back through the normal data binding pipeline. + /// + public void SetMauiVirtualView(ItemsView? itemsView) + { + _mauiVirtualView = itemsView; + } + + /// + /// Updates drag and drop capabilities for reordering items. + /// + public void UpdateCanReorderItems(bool canReorderItems) + { + _canReorderItems = canReorderItems; + + if (_scrollViewer is null) + { + // Template hasn't applied yet — defer wiring until Loaded. + if (_deferredWireHandler is null) + { + _deferredWireHandler = OnLoadedForDragDrop; + Loaded += _deferredWireHandler; + } + return; + } + + ApplyDragDropState(); + } + + void OnLoadedForDragDrop(object sender, RoutedEventArgs e) + { + if (_deferredWireHandler is not null) + { + Loaded -= _deferredWireHandler; + _deferredWireHandler = null; + } + + ApplyDragDropState(); + } + + void ApplyDragDropState() + { + if (_canReorderItems) + { + WireUpDragDropEvents(); + } + else + { + UnwireDragDropEvents(); + } + } + + #region Drag and Drop Event Wiring + + void WireUpDragDropEvents() + { + if (_dragDropWired || _scrollViewer is null) + { + return; + } + + _scrollViewer.AllowDrop = true; + _scrollViewer.DragEnter -= ScrollViewer_DragEnter; + _scrollViewer.DragOver -= ScrollViewer_DragOver; + _scrollViewer.DragLeave -= ScrollViewer_DragLeave; + _scrollViewer.Drop -= ScrollViewer_Drop; + + _scrollViewer.DragEnter += ScrollViewer_DragEnter; + _scrollViewer.DragOver += ScrollViewer_DragOver; + _scrollViewer.DragLeave += ScrollViewer_DragLeave; + _scrollViewer.Drop += ScrollViewer_Drop; + + var repeater = ItemsRepeaterControl; + if (repeater is not null) + { + repeater.ElementPrepared -= ItemsRepeater_ElementPrepared; + repeater.ElementClearing -= ItemsRepeater_ElementClearing; + repeater.ElementPrepared += ItemsRepeater_ElementPrepared; + repeater.ElementClearing += ItemsRepeater_ElementClearing; + + // Apply drag affordance to already-realized containers. Walk the + // ItemsRepeater's visual children directly — only realized containers + // exist in the visual tree, so this is O(realized) rather than + // O(total items). GetElementIndex returns the authoritative flat index + // for each realized element (consistent with how FindAllContainers works). + int childCount = VisualTreeHelper.GetChildrenCount(repeater); + for (int i = 0; i < childCount; i++) + { + if (VisualTreeHelper.GetChild(repeater, i) is ItemContainer ic) + { + int index = repeater.GetElementIndex(ic); + if (index >= 0) + ApplyDragAffordance(ic, index); + } + } + + // When the page is off-screen (e.g. an Options page is on top via + // PushAsync), the repeater has no realized children because MAUI's + // StackNavigationManager clears the ContentPresenter on navigation. + // Subscribe a persistent Loaded handler so affordance is re-applied + // every time the page re-enters the visual tree. This must be + // unconditional (not gated on childCount == 0) because Phase 3's + // deferred SetContent means containers may exist as lightweight + // shells at wiring time but only become fully realized after the + // next MeasureOverride — which only runs after Loaded. + Loaded -= OnLoadedForAffordanceReapply; + Loaded += OnLoadedForAffordanceReapply; + } + + _dragDropWired = true; + } + + void OnLoadedForAffordanceReapply(object sender, RoutedEventArgs e) + { + if (!_canReorderItems || !_dragDropWired) + return; + + var repeater = ItemsRepeaterControl; + if (repeater is null) + return; + + int childCount = VisualTreeHelper.GetChildrenCount(repeater); + for (int i = 0; i < childCount; i++) + { + if (VisualTreeHelper.GetChild(repeater, i) is ItemContainer ic) + { + int index = repeater.GetElementIndex(ic); + if (index >= 0) + ApplyDragAffordance(ic, index); + } + } + } + + void UnwireDragDropEvents() + { + if (!_dragDropWired) + { + return; + } + + if (_scrollViewer is not null) + { + _scrollViewer.AllowDrop = false; + _scrollViewer.DragEnter -= ScrollViewer_DragEnter; + _scrollViewer.DragOver -= ScrollViewer_DragOver; + _scrollViewer.DragLeave -= ScrollViewer_DragLeave; + _scrollViewer.Drop -= ScrollViewer_Drop; + } + + var repeater = ItemsRepeaterControl; + if (repeater is not null) + { + repeater.ElementPrepared -= ItemsRepeater_ElementPrepared; + repeater.ElementClearing -= ItemsRepeater_ElementClearing; + + // Walk the ItemsRepeater's visual children directly (O(realized)) + // to clear drag affordance from every realized container. Only + // realized containers exist in the visual tree, so no index loop needed. + int childCount = VisualTreeHelper.GetChildrenCount(repeater); + for (int i = 0; i < childCount; i++) + { + if (VisualTreeHelper.GetChild(repeater, i) is ItemContainer ic) + { + ic.CanDrag = false; + ic.DragStarting -= ItemContainer_DragStarting; + ic.DropCompleted -= ItemContainer_DropCompleted; + // Clear the card Background set in ApplyDragAffordance so the + // container falls back to the transparent ThemeResource (#13197). + RemoveDragGhostAppearance(ic); + } + } + } + + StopAutoScroll(); + _dragDropWired = false; + Loaded -= OnLoadedForAffordanceReapply; + } + + internal void DisconnectDragDrop() + { + Loaded -= OnLoadedForAffordanceReapply; + UnwireDragDropEvents(); + if (_deferredWireHandler is not null) + { + Loaded -= _deferredWireHandler; + _deferredWireHandler = null; + } + + // Fully tear down the auto-scroll timer. StopAutoScroll only calls Stop(), + // which leaves the Tick delegate (and therefore this instance) rooted by + // the dispatcher queue. + if (_autoScrollTimer is not null) + { + _autoScrollTimer.Stop(); + _autoScrollTimer.Tick -= AutoScrollTimer_Tick; + _autoScrollTimer = null; + } + + // Clear any remaining ReorderCompleted subscribers so a stray subscriber + // can't keep this instance alive past disconnect. + ReorderCompleted = null; + + // Hide the indicator visuals. They are template parts owned by the control + // template (declared in XAML), so we only need to collapse them — not remove. + HideInsertionIndicator(); + + _mauiVirtualView = null; + FlatTemplateCollection = null; + CleanupDragState(); + } + + #endregion + + #region ItemsRepeater Element Management + + void ItemsRepeater_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) + { + if (!_canReorderItems) + { + return; + } + + if (args.Element is ItemContainer itemContainer) + { + ApplyDragAffordance(itemContainer, args.Index); + + // If a live-reorder happens to recycle a container onto the dragged + // item's new position, hide it immediately. This is what makes the + // "empty source slot" follow the dragged item without us having to + // chase a moving _sourceContainer reference through dispatcher races. + if (_draggedItem is not null && IsContainerBoundToDraggedItem(itemContainer)) + { + itemContainer.Opacity = 0; + itemContainer.IsHitTestVisible = false; + _sourceContainer = itemContainer; + } + else + { + // Apply dim if a drag is in progress and this is not the source. + itemContainer.Opacity = _draggedItem is not null ? DragDimOpacity : 1; + itemContainer.IsHitTestVisible = true; + } + } + } + + void ApplyDragAffordance(ItemContainer itemContainer, int index) + { + itemContainer.Tag = index; + + // Don't allow dragging headers, footers, or group headers/footers. + bool isHeaderOrFooter = itemContainer.Child is ElementWrapper wrapper && + wrapper.IsHeaderOrFooter; + + itemContainer.CanDrag = !isHeaderOrFooter; + itemContainer.DragStarting -= ItemContainer_DragStarting; + if (!isHeaderOrFooter) + { + itemContainer.DragStarting += ItemContainer_DragStarting; + } + + // Set the Fluent card background as a LOCAL dependency-property value so that: + // 1. The drag ghost (captured by the compositor BEFORE DragStarting fires) + // always carries a visible card background regardless of DataTemplate content. + // 2. The local value takes precedence over the transparent ThemeResource override + // set in the constructor (fix #13197) without changing that global default. + // RemoveDragGhostAppearance calls ClearValue(BackgroundProperty) to undo this, + // letting the transparent ThemeResource resume when drag-reorder is disabled. + if (!isHeaderOrFooter + && WApp.Current?.Resources?.TryGetValue("CardBackgroundFillColorDefaultBrush", out var cardBg) == true + && cardBg is Microsoft.UI.Xaml.Media.Brush cardBrush) + { + itemContainer.Background = cardBrush; + } + } + + void ItemsRepeater_ElementClearing(ItemsRepeater sender, ItemsRepeaterElementClearingEventArgs args) + { + if (!_canReorderItems) + { + return; + } + + if (args.Element is ItemContainer itemContainer) + { + itemContainer.CanDrag = false; + itemContainer.DragStarting -= ItemContainer_DragStarting; + // Unsubscribe DropCompleted — it is a one-shot handler wired during + // DragStarting and must be removed here so that a recycled container + // doesn't carry a stale subscription into its next use. + itemContainer.DropCompleted -= ItemContainer_DropCompleted; + itemContainer.Tag = null; + + // If the container being cleared is the one currently hidden as the drag + // source (e.g. recycled mid-drag), restore its opacity and hit-testability + // so it doesn't get reused while invisible. + itemContainer.Opacity = 1; + itemContainer.IsHitTestVisible = true; + RemoveDragGhostAppearance(itemContainer); + if (ReferenceEquals(_sourceContainer, itemContainer)) + { + _sourceContainer = null; + } + + // Reset any stale Translation so the recycled container starts clean. + itemContainer.Translation = System.Numerics.Vector3.Zero; + } + } + + #endregion + + #region Drag Event Handlers + + void ItemContainer_DragStarting(UIElement sender, UI.Xaml.DragStartingEventArgs args) + { + var itemContainer = (ItemContainer)sender; + + // Use the container's currently bound item first. The Tag/index can become + // stale after a reorder because the element is reused without being recreated. + object? item = GetContainerItem(itemContainer); + + // Fallback: look up by index from the source (works for IList and IEnumerable). + if (item is null && itemContainer.Tag is int index && index >= 0) + { + var sourceList = GetSourceList(); + if (sourceList is not null && index < sourceList.Count) + { + item = GetItemAtIndex(index, sourceList); + } + } + + if (item is null) + { + args.Cancel = true; + return; + } + + _draggedItem = item; + _draggedSourceIndex = GetContainerIndex(itemContainer); + _sourceContainer = itemContainer; + + args.Data.Properties.Add("DragSource", "MauiItemsView"); + args.Data.RequestedOperation = WDataTransfer.DataPackageOperation.Move; + + // Make sure the drop-completed handler is wired exactly once so the source + // container's opacity is restored on success, cancel, or escape. + itemContainer.DropCompleted -= ItemContainer_DropCompleted; + itemContainer.DropCompleted += ItemContainer_DropCompleted; + + // WinUI captures the drag ghost from the compositor tree BEFORE DragStarting + // fires — no DragStarting-based approach (sync, deferral, RenderTargetBitmap) + // can modify that snapshot. The default ghost shows the item content on a + // transparent background, which is the correct CV2 behaviour. + // Hide the source slot and dim others on the next dispatcher frame so the + // compositor snapshot has been committed before we change visual state. + DispatcherQueue.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, + () => + { + if (_sourceContainer is not null) + { + _sourceContainer.Opacity = 0; + _sourceContainer.IsHitTestVisible = false; + } + + DimNonSourceContainers(); + }); + } + + void ItemContainer_DropCompleted(UIElement sender, UI.Xaml.DropCompletedEventArgs args) + { + if (sender is ItemContainer itemContainer) + { + itemContainer.DropCompleted -= ItemContainer_DropCompleted; + } + + // DropCompleted fires on ALL drag-end paths: success (drop inside list), + // cancel/ESC, and drop outside the window. On the success path, + // ScrollViewer_Drop already called CleanupDragState() and unsubscribed this + // handler — so DropCompleted typically only fires here for cancel/ESC/outside. + // CleanupDragState() is idempotent, so calling it again on the success path + // is harmless. This guarantees the auto-scroll timer is always stopped. + CleanupDragState(); + } + + void ScrollViewer_DragEnter(object sender, UI.Xaml.DragEventArgs e) + { + if (!_canReorderItems) + { + e.AcceptedOperation = WDataTransfer.DataPackageOperation.None; + return; + } + + e.AcceptedOperation = WDataTransfer.DataPackageOperation.Move; + e.DragUIOverride.IsGlyphVisible = false; + e.DragUIOverride.IsCaptionVisible = false; + } + + void ScrollViewer_DragOver(object sender, UI.Xaml.DragEventArgs e) + { + if (!_canReorderItems) + { + e.AcceptedOperation = WDataTransfer.DataPackageOperation.None; + return; + } + + e.DragUIOverride.IsGlyphVisible = false; + e.DragUIOverride.IsCaptionVisible = false; + + HandleAutoScroll(e); + + var targetContainer = FindContainerUnderPointer(e); + if (targetContainer is null) + { + return; + } + + var pt = e.GetPosition(targetContainer); + + int targetIndex = GetContainerIndex(targetContainer); + if (targetIndex < 0) + { + return; + } + + if (_isHorizontalLayout) + { + _insertAfter = pt.X >= targetContainer.ActualWidth / 2; + } + else + { + _insertAfter = pt.Y >= targetContainer.ActualHeight / 2; + } + + _insertionIndex = _insertAfter ? targetIndex + 1 : targetIndex; + + if (targetContainer is ItemContainer ic) + { + UpdateInsertionIndicator(ic, _insertAfter); + } + + e.AcceptedOperation = WDataTransfer.DataPackageOperation.Move; + e.Handled = true; + } + + bool IsContainerBoundToDraggedItem(ItemContainer container) + { + return _draggedItem is not null && IsContainerBoundToItem(container, _draggedItem); + } + + static bool IsContainerBoundToItem(ItemContainer container, object item) + { + if (container.Child is not ElementWrapper wrapper || wrapper.VirtualView is not View view) + { + return false; + } + + var bound = view.BindingContext; + if (bound is null) + { + return false; + } + + return ReferenceEquals(bound, item) || Equals(bound, item); + } + + /// + /// Attempts to call + /// on a grouped source collection. Used only for same-group drag-drop reorder in grouped lists. + /// + /// Not used for flat lists. For flat lists, uses + /// (when a template + /// collection is active) or plain RemoveAt + Insert (no-template case). + /// Calling Move on a source that is directly bound to + /// (no wrapping) fires + /// CollectionChanged(Move) which CsWinRT maps to VectorChanged(Reset), + /// causing ItemsRepeater to clear all containers and scroll to the top. + /// + /// Only ObservableCollection<object> is handled directly; all other types + /// return false and the caller falls back to RemoveAt + Insert. + /// + static bool TryMoveObservableCollection(IList list, int oldIndex, int newIndex) + { + // Fast path: no reflection needed for the common MAUI binding source type. + if (list is System.Collections.ObjectModel.ObservableCollection oc) + { + oc.Move(oldIndex, newIndex); + return true; + } + + // General path: ObservableCollection for any T. + // Reflection is used intentionally here — ObservableCollection.Move is a + // public, stable API and this code runs only during interactive drag/drop on Windows. +#pragma warning disable IL2070 // 't' parameter doesn't need trimmer annotation — Move is always preserved on ObservableCollection + var moveMethod = s_moveMethodCache.GetOrAdd(list.GetType(), t => t.GetMethod( + "Move", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance, + null, + new[] { typeof(int), typeof(int) }, + null)); +#pragma warning restore IL2070 + + if (moveMethod is not null) + { + moveMethod.Invoke(list, new object[] { oldIndex, newIndex }); + return true; + } + + return false; + } + + void ScrollViewer_DragLeave(object sender, UI.Xaml.DragEventArgs e) + { + // Do NOT stop auto-scroll here. Two cases where this fires during a valid drag: + // 1. Horizontal right edge — pointer exits the ScrollViewer bounds; we want + // scrolling to continue until the drag ends or velocity decays. + // 2. Vertical scroll — ChangeView() triggers a brief DragLeave/DragEnter + // cycle as WinUI re-evaluates hit-targets after content shifts; stopping + // here would cause stuttering (scroll→stop→scroll→stop). + // The timer decelerates naturally when _targetScrollVelocity is reset to 0 in + // HandleAutoScroll (pointer back in neutral zone) or when the scroll boundary + // is reached. Full cleanup happens in CleanupDragState when the drag ends. + HideInsertionIndicator(); + } + + void ScrollViewer_Drop(object sender, UI.Xaml.DragEventArgs e) + { + if (!_canReorderItems || _draggedItem is null || _insertionIndex < 0 || _mauiVirtualView is null) + { + CleanupDragState(); + return; + } + + bool isGrouped = _mauiVirtualView is GroupableItemsView giv && giv.IsGrouped; + + if (isGrouped) + { + if (_mauiVirtualView.ItemsSource is not IList groupsList) + { + CleanupDragState(); + return; + } + + try + { + bool reordered = PerformGroupedReorder(groupsList); + if (reordered) + { + ReorderCompleted?.Invoke(this, EventArgs.Empty); + } + } + finally + { + CleanupDragState(); + } + } + else + { + if (_mauiVirtualView.ItemsSource is IList itemsList) + { + try + { + // Visual-only shuffle never mutates the collection during drag. + // Always call PerformReorder here to commit the actual move. + bool reordered = PerformReorder(itemsList); + + if (reordered) + { + ReorderCompleted?.Invoke(this, EventArgs.Empty); + } + } + finally + { + CleanupDragState(); + } + } + else if (_mauiVirtualView.ItemsSource is IEnumerable itemsEnumerable) + { + // For plain IEnumerable sources (non-IList), materialize into a new + // list, reorder it, then reassign ItemsSource so the change propagates + // back through the normal data-binding pipeline. + try + { + var materializedList = new System.Collections.Generic.List(itemsEnumerable.Cast()); + bool reordered = PerformReorder(materializedList); + if (reordered) + { + _mauiVirtualView.ItemsSource = materializedList; + ReorderCompleted?.Invoke(this, EventArgs.Empty); + } + } + finally + { + CleanupDragState(); + } + } + else + { + CleanupDragState(); + } + } + } + + #endregion + + #region Reordering Logic + + bool PerformReorder(IList itemsList) + { + if (_draggedItem is null) + { + return false; + } + + // Prefer the source index captured at DragStarting so value-equal duplicates + // (record structs, boxed primitives, two equal entries) resolve to the row the + // user actually dragged. Validate it still points at an equal item — if a + // concurrent insert/remove shifted the row, fall back to a linear search. + int oldIndex = -1; + if (_draggedSourceIndex >= 0 && _draggedSourceIndex < itemsList.Count) + { + var candidate = GetItemAtIndex(_draggedSourceIndex, itemsList); + if (ReferenceEquals(candidate, _draggedItem) || Equals(candidate, _draggedItem)) + { + oldIndex = _draggedSourceIndex; + } + } + + if (oldIndex < 0) + { + oldIndex = IndexOfItem(_draggedItem, itemsList); + } + + if (oldIndex < 0) + { + return false; + } + + int adjustedInsertionIndex = _insertionIndex; + if (oldIndex < adjustedInsertionIndex) + { + adjustedInsertionIndex--; + } + + if (oldIndex == adjustedInsertionIndex) + { + return false; + } + + // Prefer Move over RemoveAt + Insert. + // + // When FlatTemplateCollection is set (ItemTemplate is active), MoveItemAndSyncSource: + // 1. Mutates the source silently (_observeChanges=false suppresses InnerCollectionChanged). + // 2. Calls Move on the template collection; MoveItem fires Remove+Add (not Move) so + // CsWinRT emits VectorChanged(ItemRemoved + ItemInserted) — not VectorChanged(Reset). + // ItemsRepeater repositions the existing container without recycling it. + // + // When there is no ItemTemplate the raw source is the ItemsRepeater's data directly. + // In that case we must use RemoveAt+Insert, NOT ObservableCollection.Move: + // Move → CollectionChanged(Move) → CsWinRT → VectorChanged(Reset) + // → ItemsRepeater clears all realized containers → scroll to top. + // RemoveAt+Insert → CollectionChanged(Remove+Add) → VectorChanged(ItemRemoved+ItemInserted) + // → ItemsRepeater repositions without resetting the scroll position. + // + // IsReordering guards ItemsChanged in ItemsViewHandler2 so ApplyItemsUpdatingScrollMode + // does not call StartBringItemIntoView(0) (KeepItemsInView default) during the mutation. + IsReordering = true; + try + { + if (FlatTemplateCollection is not null) + { + // Template-collection path: atomically moves source item and repositions + // the existing ItemTemplateContext2 wrapper — no new wrapper, no BringIntoView. + FlatTemplateCollection.MoveItemAndSyncSource(oldIndex, adjustedInsertionIndex); + } + else + { + // No-template path: source IS the ItemsRepeater's data. Must NOT use Move + // (would cause VectorChanged(Reset) via CsWinRT). RemoveAt+Insert is safe. + var itemToMove = itemsList[oldIndex]; + itemsList.RemoveAt(oldIndex); + adjustedInsertionIndex = Math.Clamp(adjustedInsertionIndex, 0, itemsList.Count); + itemsList.Insert(adjustedInsertionIndex, itemToMove); + } + } + finally + { + IsReordering = false; + } + + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + { + ItemsRepeaterControl?.UpdateLayout(); + UpdateAllContainerIndices(); + }); + + return true; + } + + /// + /// Performs a reorder operation on grouped data, respecting CanMixGroups. + /// Maps the flat insertion index to the correct group and position within the group. + /// Groups that implement only IEnumerable (not IList) are traversed for item lookup + /// but cannot be mutated — a reorder into or out of such a group returns false. + /// + bool PerformGroupedReorder(IList groupsList) + { + if (_draggedItem is null || _mauiVirtualView is not GroupableItemsView groupableView) + { + return false; + } + + bool hasHeaders = groupableView.GroupHeaderTemplate is not null; + bool hasFooters = groupableView.GroupFooterTemplate is not null; + + // Find which group the dragged item belongs to. + // Groups may be IEnumerable-only (e.g., IGrouping), so enumerate rather + // than requiring IList for the search. IList is still required for mutation. + int sourceGroupIndex = -1; + int sourceItemIndex = -1; + IList? sourceGroup = null; + + for (int g = 0; g < groupsList.Count; g++) + { + if (groupsList[g] is not IEnumerable groupItems) + { + continue; + } + + int i = 0; + foreach (var groupItem in groupItems) + { + if (ReferenceEquals(groupItem, _draggedItem) || Equals(groupItem, _draggedItem)) + { + sourceGroupIndex = g; + sourceItemIndex = i; + sourceGroup = groupsList[g] as IList; + break; + } + + i++; + } + + if (sourceGroupIndex >= 0) + { + break; + } + } + + // sourceGroup being null means the group is not mutable — reorder not possible. + if (sourceGroupIndex < 0 || sourceItemIndex < 0 || sourceGroup is null) + { + return false; + } + + // Map the flat _insertionIndex to a target group and position within that group. + int targetGroupIndex = -1; + int targetItemIndex = -1; + IList? targetGroup = null; + int flatPos = 0; + + for (int g = 0; g < groupsList.Count; g++) + { + if (groupsList[g] is not IEnumerable groupItems) + { + continue; + } + + // Use ICollection.Count when available (O(1)); otherwise enumerate (O(n)). + int groupItemCount = groupsList[g] is ICollection coll + ? coll.Count + : groupItems.Cast().Count(); + + int groupStart = flatPos; + + if (hasHeaders) + { + flatPos++; // skip header + } + + int itemsStart = flatPos; + flatPos += groupItemCount; + + if (hasFooters) + { + flatPos++; // skip footer + } + + if (_insertionIndex >= itemsStart && _insertionIndex <= itemsStart + groupItemCount) + { + targetGroupIndex = g; + targetItemIndex = _insertionIndex - itemsStart; + targetGroup = groupsList[g] as IList; + break; + } + + if (hasHeaders && _insertionIndex == groupStart) + { + targetGroupIndex = g; + targetItemIndex = 0; + targetGroup = groupsList[g] as IList; + break; + } + } + + // If we didn't find a target (e.g., dragged past the end), use the last group. + if (targetGroup is null && groupsList.Count > 0) + { + for (int g = groupsList.Count - 1; g >= 0; g--) + { + if (groupsList[g] is IEnumerable groupItems) + { + int groupItemCount = groupsList[g] is ICollection coll + ? coll.Count + : groupItems.Cast().Count(); + + targetGroupIndex = g; + targetItemIndex = groupItemCount; + targetGroup = groupsList[g] as IList; + break; + } + } + } + + if (targetGroup is null || targetGroupIndex < 0) + { + return false; + } + + // Honor CanMixGroups: reject cross-group moves when disabled. + if (sourceGroupIndex != targetGroupIndex) + { + if (_mauiVirtualView is ReorderableItemsView riv && !riv.CanMixGroups) + { + return false; + } + } + + if (sourceGroupIndex == targetGroupIndex) + { + int adjustedTargetIndex = targetItemIndex; + if (sourceItemIndex < adjustedTargetIndex) + { + adjustedTargetIndex--; + } + + if (sourceItemIndex == adjustedTargetIndex) + { + return false; + } + + IsReordering = true; + try + { + // Use Move when possible to fire a single CollectionChanged(Move) event, + // preventing ItemsRepeater from recycling containers and resetting scroll. + if (!TryMoveObservableCollection(sourceGroup, sourceItemIndex, adjustedTargetIndex)) + { + var item = sourceGroup[sourceItemIndex]; + sourceGroup.RemoveAt(sourceItemIndex); + adjustedTargetIndex = Math.Clamp(adjustedTargetIndex, 0, sourceGroup.Count); + sourceGroup.Insert(adjustedTargetIndex, item); + } + } + finally + { + IsReordering = false; + } + } + else + { + IsReordering = true; + try + { + var item = sourceGroup[sourceItemIndex]; + sourceGroup.RemoveAt(sourceItemIndex); + targetItemIndex = Math.Clamp(targetItemIndex, 0, targetGroup.Count); + targetGroup.Insert(targetItemIndex, item); + } + finally + { + IsReordering = false; + } + } + + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + { + ItemsRepeaterControl?.UpdateLayout(); + UpdateAllContainerIndices(); + }); + + return true; + } + + #endregion + + #region Container and Item Management + + IList? GetSourceList() + { + // Prefer the WinUI-side bound source so indices map to realized containers. + if (ItemsSource is IList list) + { + return list; + } + + var mauiSource = _mauiVirtualView?.ItemsSource; + if (mauiSource is IList mauiList) + { + return mauiList; + } + + // For plain IEnumerable sources (non-IList), materialize a snapshot so that + // all index/count operations (wiring affordances, finding containers, etc.) + // work correctly. This snapshot is read-only — mutation reassigns ItemsSource + // directly in ScrollViewer_Drop for IEnumerable sources. + if (mauiSource is IEnumerable enumerable) + { + return enumerable.Cast().ToList(); + } + + return null; + } + + FrameworkElement? FindContainerUnderPointer(UI.Xaml.DragEventArgs e) + { + var repeater = ItemsRepeaterControl; + if (repeater is null) + { + return null; + } + + var position = e.GetPosition(repeater); + + var elements = VisualTreeHelper.FindElementsInHostCoordinates( + repeater.TransformToVisual(null).TransformPoint(position), + repeater, + false); + + foreach (var element in elements) + { + if (element is ItemContainer itemContainer && + itemContainer.Tag is int && + !ReferenceEquals(itemContainer, _sourceContainer)) + { + return itemContainer; + } + } + + // Fallback: find by axis-aligned position. + var sourceList = GetSourceList(); + if (sourceList is not null && sourceList.Count > 0) + { + var allContainers = FindAllContainers().ToList(); + + foreach (var container in allContainers) + { + // Skip the invisible source container — its layout slot is occupied + // but should not be a valid drop target while the drag is active. + if (ReferenceEquals(container, _sourceContainer)) + { + continue; + } + + var containerPosition = container.TransformToVisual(repeater).TransformPoint(new global::Windows.Foundation.Point(0, 0)); + + bool isInBounds; + if (_isHorizontalLayout) + { + isInBounds = position.X >= containerPosition.X && + position.X <= containerPosition.X + container.ActualWidth; + } + else + { + isInBounds = position.Y >= containerPosition.Y && + position.Y <= containerPosition.Y + container.ActualHeight; + } + + if (isInBounds) + { + return container; + } + } + + if (allContainers.Count > 0) + { + var lastContainer = allContainers[allContainers.Count - 1]; + var lastPos = lastContainer.TransformToVisual(repeater).TransformPoint(new global::Windows.Foundation.Point(0, 0)); + + bool isBeyondLast = _isHorizontalLayout + ? position.X > lastPos.X + lastContainer.ActualWidth + : position.Y > lastPos.Y + lastContainer.ActualHeight; + + if (isBeyondLast) + { + return lastContainer; + } + } + } + + return null; + } + + FrameworkElement? FindContainerByIndex(int index) + { + var repeater = ItemsRepeaterControl; + int flatCount = repeater?.ItemsSourceView?.Count ?? 0; + if (index < 0 || index >= flatCount) + { + return null; + } + + return FindAllContainers().FirstOrDefault(c => GetContainerIndex(c) == index); + } + + /// + /// Finds the realized container whose binding context equals + /// by identity. Unlike , this is safe to call from + /// an async callback because it does not rely on a captured index that may have been + /// invalidated by a subsequent collection change. + /// + FrameworkElement? FindContainerByItem(object? targetItem) + { + if (targetItem is null) + { + return null; + } + + // Prefer reference equality so duplicate value-equal items resolve to the + // correct container. Fall back to value equality for value types. + FrameworkElement? valueEqualFallback = null; + foreach (var container in FindAllContainers()) + { + var item = GetContainerItem(container); + if (ReferenceEquals(item, targetItem)) + { + return container; + } + + if (valueEqualFallback is null && Equals(item, targetItem)) + { + valueEqualFallback = container; + } + } + + return valueEqualFallback; + } + + IEnumerable FindAllContainers() + { + var repeater = ItemsRepeaterControl; + if (repeater is null) + yield break; + + // Walk the ItemsRepeater's visual children directly: only realized containers + // exist in the visual tree, so this is O(realized) rather than O(total items). + // The previous approach (TryGetElement(i) for i in 0..ItemsSourceView.Count) + // was O(N) over ALL items even though only ~20 are realized at any time. + int childCount = VisualTreeHelper.GetChildrenCount(repeater); + for (int i = 0; i < childCount; i++) + { + if (VisualTreeHelper.GetChild(repeater, i) is FrameworkElement fe) + yield return fe; + } + } + + object? GetContainerItem(FrameworkElement container) + { + if (container is ItemContainer itemContainer && + itemContainer.Child is ElementWrapper wrapper && + wrapper.VirtualView is View view) + { + return view.BindingContext; + } + + return null; + } + + int GetContainerIndex(FrameworkElement container) + { + var sourceList = GetSourceList(); + var containerItem = GetContainerItem(container); + + // Prefer the Tag set during ElementPrepared — it is the authoritative flat + // index and avoids the ambiguity where group headers and footers share the + // same underlying Item (the group object). Validate the tag by checking that + // the item at that index still matches the container's current item. + if (container.Tag is int tagIndex && sourceList is not null && + tagIndex >= 0 && tagIndex < sourceList.Count) + { + var tagItem = GetItemAtIndex(tagIndex, sourceList); + if (containerItem is not null && Equals(tagItem, containerItem)) + { + return tagIndex; + } + } + + // Tag is stale — fall back to a linear search. + if (sourceList is not null && containerItem is not null) + { + var liveIndex = IndexOfItem(containerItem, sourceList); + if (liveIndex >= 0) + { + return liveIndex; + } + } + + // Last resort: use the raw tag even if unvalidated. + if (container.Tag is int index) + { + return index; + } + + var allContainers = FindAllContainers().ToList(); + return allContainers.IndexOf(container); + } + + int IndexOfItem(object item, IList itemsList) + { + // First pass: reference equality — correctly distinguishes two items that are + // value-equal but distinct objects (e.g., duplicate records in the list). + for (int i = 0; i < itemsList.Count; i++) + { + var currentItem = GetItemAtIndex(i, itemsList); + if (ReferenceEquals(currentItem, item)) + { + return i; + } + } + + // Second pass: value equality fallback for value types (structs, primitives) + // where ReferenceEquals is always false. + for (int i = 0; i < itemsList.Count; i++) + { + var currentItem = GetItemAtIndex(i, itemsList); + if (Equals(currentItem, item)) + { + return i; + } + } + + return -1; + } + + object? GetItemAtIndex(int index, IList itemsList) + { + var item = itemsList[index]; + + if (item is ItemTemplateContext2 itc) + { + return itc.Item; + } + + return item; + } + + void UpdateAllContainerIndices() + { + var sourceList = GetSourceList(); + if (sourceList is null) + { + return; + } + + // Derive each container's Tag from its item's actual position in the source. + // A positional loop (containers[i].Tag = i) is wrong when ItemsRepeater + // virtualizes: FindAllContainers skips unrealized slots, so containers[i] + // does not necessarily correspond to sourceList[i]. + foreach (var container in FindAllContainers()) + { + var item = GetContainerItem(container); + if (item is not null) + { + int actualIndex = IndexOfItem(item, sourceList); + if (actualIndex >= 0) + { + container.Tag = actualIndex; + } + } + } + } + + #endregion + + #region Cleanup + + void CleanupDragState() + { + // Hide insertion indicator before restoring containers. + HideInsertionIndicator(); + + // Restore the source container synchronously in case DropCompleted does not + // fire (e.g. drop handled outside the source element, or disconnect). + if (_sourceContainer is not null) + { + // Restore only the drag-source-specific overrides (opacity + hit-testing). + // Do NOT call RemoveDragGhostAppearance here — that would clear the card + // Background set by ApplyDragAffordance. A same-location drop leaves the + // container in place (not recycled), so ApplyDragAffordance won't run again; + // clearing the Background here would expose the transparent ThemeResource + // and leave the item visually broken for subsequent drags. + // Background is cleared in ElementClearing (recycle) and UnwireDragDropEvents + // (drag-reorder disabled) — the two paths that actually require the cleanup. + _sourceContainer.Opacity = 1; + _sourceContainer.IsHitTestVisible = true; + _sourceContainer.DropCompleted -= ItemContainer_DropCompleted; + _sourceContainer = null; + } + + // Restore all dimmed containers. + RestoreAllContainerOpacity(); + + _draggedItem = null; + _draggedSourceIndex = -1; + _insertionIndex = -1; + _insertAfter = false; + StopAutoScroll(); + } + + #endregion + + #region Auto-Scroll During Drag + + void HandleAutoScroll(UI.Xaml.DragEventArgs e) + { + if (_scrollViewer is null) + { + return; + } + + var position = e.GetPosition(_scrollViewer); + + if (_isHorizontalLayout) + { + HandleHorizontalAutoScroll(position); + } + else + { + HandleVerticalAutoScroll(position); + } + } + + void HandleVerticalAutoScroll(global::Windows.Foundation.Point position) + { + if (_scrollViewer is null) + { + return; + } + + var height = _scrollViewer.ActualHeight; + var distanceFromTop = position.Y; + var distanceFromBottom = height - position.Y; + + if (distanceFromTop < AutoScrollThreshold && _scrollViewer.VerticalOffset > 0) + { + var normalizedDistance = 1.0 - (distanceFromTop / AutoScrollThreshold); + _targetScrollVelocity = -(AutoScrollMinSpeed + (normalizedDistance * (AutoScrollMaxSpeed - AutoScrollMinSpeed))); + StartAutoScroll(); + } + else if (distanceFromBottom < AutoScrollThreshold && + _scrollViewer.VerticalOffset < _scrollViewer.ScrollableHeight) + { + var normalizedDistance = 1.0 - (distanceFromBottom / AutoScrollThreshold); + _targetScrollVelocity = AutoScrollMinSpeed + (normalizedDistance * (AutoScrollMaxSpeed - AutoScrollMinSpeed)); + StartAutoScroll(); + } + else + { + _targetScrollVelocity = 0; + if (Math.Abs(_currentScrollVelocity) < 0.1) + { + StopAutoScroll(); + } + } + } + + void HandleHorizontalAutoScroll(global::Windows.Foundation.Point position) + { + if (_scrollViewer is null) + { + return; + } + + var width = _scrollViewer.ActualWidth; + var distanceFromLeft = position.X; + var distanceFromRight = width - position.X; + + if (distanceFromLeft < AutoScrollThreshold && _scrollViewer.HorizontalOffset > 0) + { + var normalizedDistance = 1.0 - (distanceFromLeft / AutoScrollThreshold); + _targetScrollVelocity = -(AutoScrollMinSpeed + (normalizedDistance * (AutoScrollMaxSpeed - AutoScrollMinSpeed))); + StartAutoScroll(); + } + else if (distanceFromRight < AutoScrollThreshold && + _scrollViewer.HorizontalOffset < _scrollViewer.ScrollableWidth) + { + var normalizedDistance = 1.0 - (distanceFromRight / AutoScrollThreshold); + _targetScrollVelocity = AutoScrollMinSpeed + (normalizedDistance * (AutoScrollMaxSpeed - AutoScrollMinSpeed)); + StartAutoScroll(); + } + else + { + _targetScrollVelocity = 0; + if (Math.Abs(_currentScrollVelocity) < 0.1) + { + StopAutoScroll(); + } + } + } + + void StartAutoScroll() + { + if (_autoScrollTimer is null) + { + _autoScrollTimer = DispatcherQueue.CreateTimer(); + _autoScrollTimer.Interval = TimeSpan.FromMilliseconds(16); // ~60fps + _autoScrollTimer.Tick += AutoScrollTimer_Tick; + _autoScrollTimer.Start(); + } + else if (!_autoScrollTimer.IsRunning) + { + _autoScrollTimer.Start(); + } + } + + void StopAutoScroll() + { + _autoScrollTimer?.Stop(); + _targetScrollVelocity = 0; + _currentScrollVelocity = 0; + } + + void AutoScrollTimer_Tick(object? sender, object e) + { + // Cache to a local so that a concurrent DisconnectHandler nulling _scrollViewer + // cannot produce a NullReferenceException between the null-check and ChangeView. + var scrollViewer = _scrollViewer; + if (scrollViewer is null) + { + StopAutoScroll(); + return; + } + + _currentScrollVelocity = Lerp(_currentScrollVelocity, _targetScrollVelocity, ScrollAcceleration); + + if (Math.Abs(_currentScrollVelocity) < 0.01) + { + if (_targetScrollVelocity == 0) + { + StopAutoScroll(); + } + return; + } + + double newOffset; + if (_isHorizontalLayout) + { + newOffset = scrollViewer.HorizontalOffset + _currentScrollVelocity; + // Stop accelerating when the boundary is reached so the timer + // decelerates and stops instead of spinning at max offset. + if (newOffset <= 0 || newOffset >= scrollViewer.ScrollableWidth) + _targetScrollVelocity = 0; + newOffset = Math.Clamp(newOffset, 0, scrollViewer.ScrollableWidth); + scrollViewer.ChangeView(newOffset, scrollViewer.VerticalOffset, null, disableAnimation: true); + } + else + { + newOffset = scrollViewer.VerticalOffset + _currentScrollVelocity; + // Stop accelerating when the boundary is reached. + if (newOffset <= 0 || newOffset >= scrollViewer.ScrollableHeight) + _targetScrollVelocity = 0; + newOffset = Math.Clamp(newOffset, 0, scrollViewer.ScrollableHeight); + scrollViewer.ChangeView(scrollViewer.HorizontalOffset, newOffset, null, disableAnimation: true); + } + } + + static double Lerp(double start, double end, double amount) + { + return start + (end - start) * amount; + } + + #endregion + + #region Drop Target Indicator + + /// + /// Builds and caches the Storyboard + DoubleAnimations used to fade in the + /// insertion indicator. Called once from + /// after the template parts are resolved. Caching avoids allocating new animation + /// objects on every DragOver event that transitions Collapsed → Visible. + /// + void InitInsertionFadeStoryboard() + { + if (_dropIndicatorHead is null || _dropIndicatorLine is null) + return; + + var ease = new Microsoft.UI.Xaml.Media.Animation.CubicEase + { + EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut + }; + var duration = new Duration(TimeSpan.FromMilliseconds(80)); + + _insertionHeadFadeIn = new Microsoft.UI.Xaml.Media.Animation.DoubleAnimation + { + To = 1.0, + Duration = duration, + EasingFunction = ease, + }; + _insertionLineFadeIn = new Microsoft.UI.Xaml.Media.Animation.DoubleAnimation + { + To = 1.0, + Duration = duration, + EasingFunction = ease, + }; + + _insertionFadeStoryboard = new Microsoft.UI.Xaml.Media.Animation.Storyboard(); + _insertionFadeStoryboard.Children.Add(_insertionHeadFadeIn); + _insertionFadeStoryboard.Children.Add(_insertionLineFadeIn); + + Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTarget(_insertionHeadFadeIn, _dropIndicatorHead); + Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTargetProperty(_insertionHeadFadeIn, "Opacity"); + Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTarget(_insertionLineFadeIn, _dropIndicatorLine); + Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTargetProperty(_insertionLineFadeIn, "Opacity"); + } + + /// + /// Shows a 2 px accent-coloured line on at the + /// boundary between items — "insert before " or "insert + /// after ". The line is positioned in the canvas coordinate + /// space so it is always correct regardless of scroll position. + /// + void UpdateInsertionIndicator(ItemContainer target, bool insertAfter) + { + if (_dropIndicatorHead is null || _dropIndicatorLine is null || _dropIndicatorCanvas is null) + return; + + // Skip the source slot itself. + if (ReferenceEquals(target, _sourceContainer)) + { + HideInsertionIndicator(); + return; + } + + // ── Calculate position in canvas coordinates ────────────────────────── + var origin = target.TransformToVisual(_dropIndicatorCanvas) + .TransformPoint(new global::Windows.Foundation.Point(0, 0)); + + if (_isHorizontalLayout) + { + // Vertical indicator: hollow circle at top-center, line extending downward. + double lineX = insertAfter + ? origin.X + target.ActualWidth + : origin.X; + + double lineHeight = target.ActualHeight - IndicatorHeadSize - IndicatorHeadGap; + if (lineHeight < 0) + lineHeight = 0; + + // Head at top-center of the insertion edge. + Canvas.SetLeft(_dropIndicatorHead, lineX - IndicatorHeadSize / 2); + Canvas.SetTop(_dropIndicatorHead, origin.Y); + + // Line: starts below the head, centered on the insertion edge. + _dropIndicatorLine.Width = IndicatorLineThickness; + _dropIndicatorLine.Height = lineHeight; + Canvas.SetLeft(_dropIndicatorLine, lineX - IndicatorLineThickness / 2); + Canvas.SetTop(_dropIndicatorLine, origin.Y + IndicatorHeadSize + IndicatorHeadGap); + } + else + { + // Horizontal indicator: hollow circle on left, line extending to the right edge. + double lineY = insertAfter + ? origin.Y + target.ActualHeight + : origin.Y; + + // Circle sits at the left edge of the item; line fills the remaining width. + double lineWidth = target.ActualWidth - IndicatorHeadSize - IndicatorHeadGap; + if (lineWidth < 0) + lineWidth = 0; + + // Head: vertically centered on the insertion line, pinned to item left edge. + Canvas.SetLeft(_dropIndicatorHead, origin.X); + Canvas.SetTop(_dropIndicatorHead, lineY - IndicatorHeadSize / 2); + + // Line: immediately right of circle, 2 px tall, runs to the right edge. + _dropIndicatorLine.Width = lineWidth; + _dropIndicatorLine.Height = IndicatorLineThickness; + Canvas.SetLeft(_dropIndicatorLine, origin.X + IndicatorHeadSize + IndicatorHeadGap); + Canvas.SetTop(_dropIndicatorLine, lineY - IndicatorLineThickness / 2); + } + + // ── Make visible; only animate on first appearance to avoid flicker ───── + // Starting a new Storyboard on every DragOver mouse-move (while already visible) + // causes multiple animations to compete on Opacity, producing a visible flicker. + // Only fade in when transitioning from Collapsed → Visible. + bool wasCollapsed = _dropIndicatorHead.Visibility == WVisibility.Collapsed; + + if (wasCollapsed) + { + _dropIndicatorHead.Opacity = 0; + _dropIndicatorLine.Opacity = 0; + } + + _dropIndicatorHead.Visibility = WVisibility.Visible; + _dropIndicatorLine.Visibility = WVisibility.Visible; + + if (wasCollapsed) + { + // One-shot fade-in only on first show. Use the pre-built cached storyboard + // so we don't allocate a new Storyboard + 2 DoubleAnimations on every DragOver. + if (_insertionFadeStoryboard is not null) + { + _insertionFadeStoryboard.Begin(); + } + else + { + // Fallback if the storyboard couldn't be built in OnApplyTemplate + // (e.g. template parts missing). Snap to full opacity immediately. + _dropIndicatorHead.Opacity = 1; + _dropIndicatorLine.Opacity = 1; + } + } + else + { + // Already visible — ensure full opacity without starting another animation. + _dropIndicatorHead.Opacity = 1; + _dropIndicatorLine.Opacity = 1; + } + } + + /// + /// Hides the between-items drop indicator. Safe to call when no indicator is shown. + /// + void HideInsertionIndicator() + { + // Type-pattern variables let us assign the field to a local before setting + // the property — null-conditional (?.) cannot appear on the left side of an + // assignment, so a local capture is the idiomatic null-safe setter pattern. + if (_dropIndicatorHead is WBorder head) + head.Visibility = WVisibility.Collapsed; + if (_dropIndicatorLine is Rectangle line) + line.Visibility = WVisibility.Collapsed; + } + + // Indicator geometry constants. + const double IndicatorHeadSize = 12.0; // hollow circle outer diameter in px + const double IndicatorHeadGap = 2.0; // gap between circle and line + const double IndicatorLineThickness = 2.0; + + #endregion + + #region Dim / Restore During Drag + + /// + /// Removes any locally-set Background on so the + /// DP falls back to the Style-set ThemeResource (transparent by default in + /// MauiItemsView). Called defensively in ElementClearing and CleanupDragState + /// to guard against stale local values on recycled containers. + /// + static void RemoveDragGhostAppearance(ItemContainer container) + { + container.ClearValue(Microsoft.UI.Xaml.Controls.Control.BackgroundProperty); + } + + /// + /// Dims all realized containers except the source container to the + /// level, visually signalling reorder mode. + /// Called after the source container is hidden so it doesn't accidentally + /// receive DragDimOpacity on top of Opacity=0. + /// + void DimNonSourceContainers() + { + if (_draggedItem is null) + { + return; + } + + foreach (var container in FindAllContainers()) + { + // Skip the source slot (already at Opacity=0). + if (ReferenceEquals(container, _sourceContainer)) + { + continue; + } + + container.Opacity = DragDimOpacity; + } + } + + /// + /// Restores all realized containers to full opacity. Called from + /// and . + /// + void RestoreAllContainerOpacity() + { + foreach (var container in FindAllContainers()) + { + container.Opacity = 1; + container.IsHitTestVisible = true; + } + } + + #endregion +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.cs new file mode 100644 index 000000000000..5d1920de15e2 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.cs @@ -0,0 +1,432 @@ +using Microsoft.Maui.Controls.Platform; +using Microsoft.Maui.Graphics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using WApp = Microsoft.UI.Xaml.Application; +using WBorder = Microsoft.UI.Xaml.Controls.Border; +using WControlTemplate = Microsoft.UI.Xaml.Controls.ControlTemplate; +using WRectangle = Microsoft.UI.Xaml.Shapes.Rectangle; +using WSolidColorBrush = Microsoft.UI.Xaml.Media.SolidColorBrush; +using WStackPanel = Microsoft.UI.Xaml.Controls.StackPanel; +using WVisibility = Microsoft.UI.Xaml.Visibility; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// Custom subclass that adds support for +/// empty views, headers, footers, and layout orientation for the CollectionView Handler 2. +/// +internal partial class MauiItemsView : UI.Xaml.Controls.ItemsView, IEmptyView +{ + ContentControl? _emptyViewContentControl; + FrameworkElement? _emptyView; + View? _mauiEmptyView; + WVisibility _emptyViewVisibility = WVisibility.Collapsed; + + ContentControl? _headerContentControl; + FrameworkElement? _header; + + ContentControl? _footerContentControl; + FrameworkElement? _footer; + + WStackPanel? _containerPanel; + FrameworkElement? _itemsRepeater; + bool _isHorizontalLayout; + ScrollViewer? _scrollViewer; + Canvas? _dropIndicatorCanvas; + + public MauiItemsView() + { + Template = (WControlTemplate)WApp.Current.Resources["MauiItemsViewTemplate"]; + + // Disable WinUI's default ItemCollectionTransitionProvider which plays a + // staggered top-to-bottom cascade animation as virtualized items enter the + // viewport during scroll. This is unexpected for a data list in MAUI. + // See also OnApplyTemplate where this is re-applied after template inflation. + ItemTransitionProvider = null; + + ApplyItemContainerResourceOverrides(); + } + + /// + /// Overrides WinUI ItemContainer theme resources on this instance so that + /// hover/press/selection visuals are suppressed in favour of MAUI's own + /// VisualStateManager. Scoped to this.Resources so other + /// consumers in the app are unaffected. + /// See: https://github.com/microsoft/microsoft-ui-xaml/blob/main/src/controls/dev/ItemContainer/ItemContainer_themeresources.xaml + /// Fixes: https://github.com/dotnet/maui/issues/13197 + /// + void ApplyItemContainerResourceOverrides() + { + var transparent = new WSolidColorBrush(Microsoft.UI.Colors.Transparent); + var zeroCornerRadius = new Microsoft.UI.Xaml.CornerRadius(0); + + CornerRadius = zeroCornerRadius; + Resources["ControlCornerRadius"] = zeroCornerRadius; + + // Suppress hover/press overlay and border (PART_CommonVisual). + SetResources(transparent, + "ItemContainerBackground", + "ItemContainerPointerOverBackground", + "ItemContainerPressedBackground", + "ItemContainerBorderBrush", + "ItemContainerPointerOverBorderBrush", + "ItemContainerPressedBorderBrush"); + + // Multi-select checkbox: left-aligned, vertically centered. + Resources["ItemContainerCheckboxHorizontalAlignment"] = UI.Xaml.HorizontalAlignment.Left; + Resources["ItemContainerCheckboxVerticalAlignment"] = UI.Xaml.VerticalAlignment.Center; + + // Hide the 3px accent selection border (PART_SelectionVisual) and inner stroke. + SetResources(transparent, + "ItemContainerSelectionVisualBackground", + "ItemContainerSelectionVisualPointerOverBackground", + "ItemContainerSelectionVisualPressedBackground", + "ItemContainerSelectedInnerBorderBrush"); + + // Selected-state fill: use the system subtle fill brush (CV1 ListView parity). + if (WApp.Current.Resources.TryGetValue("SubtleFillColorSecondaryBrush", out var subtleSecondary)) + SetResources(subtleSecondary, "ItemContainerSelectedBackground", "ItemContainerSelectedPressedBackground"); + + if (WApp.Current.Resources.TryGetValue("SubtleFillColorTertiaryBrush", out var subtleTertiary)) + Resources["ItemContainerSelectedPointerOverBackground"] = subtleTertiary; + + // No inset margin when selected. + Resources["ItemContainerSelectedInnerMargin"] = new Microsoft.UI.Xaml.Thickness(0); + + // NOTE: "ItemContainerDisabledOpacity" is intentionally NOT overridden here. + // WinUI's ItemContainer template references it via {StaticResource ...} which is + // resolved once at template parse time against the application's merged dictionaries + // — not against per-instance Resources. Setting it here would be a silent no-op. + // The override (1.0 instead of the default 0.3) lives in + // src/Controls/src/Core/Platform/Windows/CollectionView/ItemsViewStyles.xaml so it + // is visible to the StaticResource lookup when ItemContainer's template is inflated. + // Fixes: https://github.com/dotnet/maui/issues/28343 + } + + void SetResources(object value, params string[] keys) + { + foreach (var key in keys) + Resources[key] = value; + } + + /// Gets or sets the visibility of the empty view overlay. + public WVisibility EmptyViewVisibility + { + get => _emptyViewVisibility; + set + { + _emptyViewVisibility = value; + if (_emptyViewContentControl is not null) + { + _emptyViewContentControl.Visibility = value; + + // When transitioning to/from visible, MinHeight/MinWidth need to be + // recomputed so the EmptyView fills (or releases) the remaining viewport. + if (value != WVisibility.Visible) + { + _emptyViewContentControl.ClearValue(MinHeightProperty); + _emptyViewContentControl.ClearValue(MinWidthProperty); + } + InvalidateMeasure(); + } + } + } + + /// Gets or sets the visibility of the header element. + internal WVisibility HeaderVisibility + { + get => _headerContentControl?.Visibility ?? WVisibility.Collapsed; + set + { + if (_headerContentControl is not null) + { + _headerContentControl.Visibility = value; + } + } + } + + /// Gets or sets the visibility of the footer element. + internal WVisibility FooterVisibility + { + get => _footerContentControl?.Visibility ?? WVisibility.Collapsed; + set + { + if (_footerContentControl is not null) + { + _footerContentControl.Visibility = value; + } + } + } + + /// Sets the empty view content and its MAUI view counterpart. + public void SetEmptyView(FrameworkElement emptyView, View mauiEmptyView) + { + _emptyView = emptyView; + _mauiEmptyView = mauiEmptyView; + + if (_emptyViewContentControl is not null) + { + _emptyViewContentControl.Content = emptyView; + } + } + + /// Sets the header content. + internal void SetHeader(FrameworkElement header) + { + _header = header; + + if (_headerContentControl is not null) + { + _headerContentControl.Content = header; + } + } + + /// Sets the footer content. + internal void SetFooter(FrameworkElement footer) + { + _footer = footer; + + if (_footerContentControl is not null) + { + _footerContentControl.Content = footer; + } + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + // WinUI's base.OnApplyTemplate() re-assigns ItemTransitionProvider to its + // default StackLayoutItemCollectionTransitionProvider (cascade animation). + // Override it again here so it stays null regardless of template application order. + ItemTransitionProvider = null; + + _emptyViewContentControl = GetTemplateChild("EmptyViewContentControl") as ContentControl; + _headerContentControl = GetTemplateChild("HeaderContentControl") as ContentControl; + _footerContentControl = GetTemplateChild("FooterContentControl") as ContentControl; + _containerPanel = GetTemplateChild("PART_ContainerStack") as WStackPanel; + _itemsRepeater = GetTemplateChild("PART_ItemsRepeater") as FrameworkElement; + _scrollViewer = GetTemplateChild("PART_ScrollViewer") as ScrollViewer; + _dropIndicatorCanvas = GetTemplateChild("PART_DropIndicatorCanvas") as Canvas; + _dropIndicatorHead = GetTemplateChild("PART_DropIndicatorHead") as WBorder; + _dropIndicatorLine = GetTemplateChild("PART_DropIndicatorLine") as WRectangle; + + // Also null out the inner ItemsRepeater's own provider directly — the + // TemplateBinding {x:Null} in XAML may be evaluated before WinUI assigns + // defaults, so a direct code assignment is the reliable approach. + if (_itemsRepeater is ItemsRepeater repeater) + repeater.ItemTransitionProvider = null; + + if (_emptyViewContentControl is not null) + { + if (_emptyView is not null) + { + _emptyViewContentControl.Content = _emptyView; + } + _emptyViewContentControl.Visibility = _emptyViewVisibility; + } + + if (_header is not null && _headerContentControl is not null) + { + _headerContentControl.Content = _header; + } + + if (_footer is not null && _footerContentControl is not null) + { + _footerContentControl.Content = _footer; + } + + // Apply orientation if it was set before template was applied + ApplyLayoutOrientation(); + + // Build the cached insertion-indicator fade-in animations now that + // the template parts (_dropIndicatorHead / _dropIndicatorLine) are resolved. + // Re-creating the Storyboard on every first-appearance (inside UpdateInsertionIndicator) + // allocates a Storyboard + 2 DoubleAnimations per drag gesture; caching one instance + // avoids the repeated allocations. + InitInsertionFadeStoryboard(); + } + + /// Gets whether the items are arranged horizontally (along-axis = width) or vertically (along-axis = height). + internal bool IsHorizontalLayout => _isHorizontalLayout; + + /// Sets the layout orientation and updates the visual tree accordingly. + internal void SetLayoutOrientation(bool isHorizontal) + { + _isHorizontalLayout = isHorizontal; + ApplyLayoutOrientation(); + } + + void ApplyLayoutOrientation() + { + if (_containerPanel is null || _headerContentControl is null || _footerContentControl is null || _itemsRepeater is null) + { + return; + } + + if (_isHorizontalLayout) + { + _containerPanel.Orientation = Orientation.Horizontal; + // For horizontal layout, the container panel should stretch vertically + _containerPanel.VerticalAlignment = UI.Xaml.VerticalAlignment.Stretch; + _containerPanel.HorizontalAlignment = UI.Xaml.HorizontalAlignment.Left; + + // Items should stretch vertically (cross-axis) + _itemsRepeater.VerticalAlignment = UI.Xaml.VerticalAlignment.Stretch; + _itemsRepeater.HorizontalAlignment = UI.Xaml.HorizontalAlignment.Left; + _headerContentControl.VerticalAlignment = UI.Xaml.VerticalAlignment.Stretch; + _headerContentControl.VerticalContentAlignment = UI.Xaml.VerticalAlignment.Stretch; + _headerContentControl.HorizontalContentAlignment = UI.Xaml.HorizontalAlignment.Left; + _footerContentControl.VerticalAlignment = UI.Xaml.VerticalAlignment.Stretch; + _footerContentControl.VerticalContentAlignment = UI.Xaml.VerticalAlignment.Stretch; + _footerContentControl.HorizontalContentAlignment = UI.Xaml.HorizontalAlignment.Left; + + if (_scrollViewer is not null) + { + _scrollViewer.HorizontalScrollMode = UI.Xaml.Controls.ScrollMode.Enabled; + _scrollViewer.VerticalScrollMode = UI.Xaml.Controls.ScrollMode.Disabled; + _scrollViewer.HorizontalScrollBarVisibility = UI.Xaml.Controls.ScrollBarVisibility.Auto; + _scrollViewer.VerticalScrollBarVisibility = UI.Xaml.Controls.ScrollBarVisibility.Disabled; + } + } + else + { + _containerPanel.Orientation = Orientation.Vertical; + // For vertical layout, the container panel should stretch horizontally + _containerPanel.VerticalAlignment = UI.Xaml.VerticalAlignment.Top; + _containerPanel.HorizontalAlignment = UI.Xaml.HorizontalAlignment.Stretch; + + // Items should stretch horizontally (cross-axis) + _itemsRepeater.VerticalAlignment = UI.Xaml.VerticalAlignment.Top; + _itemsRepeater.HorizontalAlignment = UI.Xaml.HorizontalAlignment.Stretch; + _headerContentControl.VerticalAlignment = UI.Xaml.VerticalAlignment.Top; + _headerContentControl.VerticalContentAlignment = UI.Xaml.VerticalAlignment.Top; + _headerContentControl.HorizontalContentAlignment = UI.Xaml.HorizontalAlignment.Stretch; + _footerContentControl.VerticalAlignment = UI.Xaml.VerticalAlignment.Top; + _footerContentControl.VerticalContentAlignment = UI.Xaml.VerticalAlignment.Top; + _footerContentControl.HorizontalContentAlignment = UI.Xaml.HorizontalAlignment.Stretch; + + if (_scrollViewer is not null) + { + _scrollViewer.HorizontalScrollMode = UI.Xaml.Controls.ScrollMode.Disabled; + _scrollViewer.VerticalScrollMode = UI.Xaml.Controls.ScrollMode.Enabled; + _scrollViewer.HorizontalScrollBarVisibility = UI.Xaml.Controls.ScrollBarVisibility.Disabled; + _scrollViewer.VerticalScrollBarVisibility = UI.Xaml.Controls.ScrollBarVisibility.Auto; + } + } + } + + protected override global::Windows.Foundation.Size MeasureOverride(global::Windows.Foundation.Size availableSize) + { + // Whether the EmptyView is currently driving the layout (visible AND will stretch + // to fill the remaining viewport). When true, we skip the ItemsRepeater MinWidth + // inflation below, because there are no items to stretch and inflating the + // repeater to viewport width would leave 0 width for the EmptyView to fill. + bool emptyViewWillFill = _emptyViewContentControl is not null + && _emptyViewVisibility == WVisibility.Visible; + + // For horizontal layouts, the ScrollViewer provides infinite width to its content, + // which prevents UniformGridLayout's ItemsStretch=Fill from stretching items to fill + // the viewport when there are few items. Setting MinWidth on the ItemsRepeater + // ensures items stretch to at least the viewport width. + // Using MinWidth (not Width) preserves horizontal scrolling when content exceeds + // the viewport — DesiredSize = max(contentWidth, MinWidth), so the ScrollViewer + // still sees the full content extent for many-item scenarios. + if (_isHorizontalLayout && _itemsRepeater is not null) + { + if (!emptyViewWillFill && !double.IsInfinity(availableSize.Width) && availableSize.Width > 0) + { + _itemsRepeater.MinWidth = availableSize.Width; + } + else + { + // Empty state: don't inflate the repeater — let the EmptyView take the + // remaining viewport width. + _itemsRepeater.ClearValue(MinWidthProperty); + } + } + else if (_itemsRepeater is not null) + { + // Clear MinWidth for vertical layouts - width is naturally constrained + // by the ScrollViewer when horizontal scroll is disabled. + _itemsRepeater.ClearValue(MinWidthProperty); + } + + var measured = base.MeasureOverride(availableSize); + + // When the EmptyView is visible, stretch it to fill the remaining viewport + // space (after Header/Footer/Items) so it occupies the entire available area. + // The EmptyView is laid out inside the StackPanel between the items repeater + // and the footer; setting MinHeight/MinWidth here pushes the Footer to the + // far edge of the viewport when empty. + if (SizeEmptyViewToFillViewport(availableSize)) + { + // MinHeight/MinWidth changed — re-measure so the StackPanel picks it up. + measured = base.MeasureOverride(availableSize); + } + + return measured; + } + + bool SizeEmptyViewToFillViewport(global::Windows.Foundation.Size availableSize) + { + if (_emptyViewContentControl is null || _emptyViewVisibility != WVisibility.Visible) + { + return false; + } + + if (_isHorizontalLayout) + { + _emptyViewContentControl.ClearValue(MinHeightProperty); + + if (double.IsInfinity(availableSize.Width) || availableSize.Width <= 0) + { + _emptyViewContentControl.ClearValue(MinWidthProperty); + return false; + } + + var headerWidth = _headerContentControl?.DesiredSize.Width ?? 0; + var footerWidth = _footerContentControl?.DesiredSize.Width ?? 0; + var itemsWidth = (_itemsRepeater is not null && _itemsRepeater.Visibility == WVisibility.Visible) + ? _itemsRepeater.DesiredSize.Width + : 0; + + var remaining = availableSize.Width - headerWidth - footerWidth - itemsWidth; + var newMin = remaining > 0 ? remaining : 0; + if (_emptyViewContentControl.MinWidth == newMin) + { + return false; + } + _emptyViewContentControl.MinWidth = newMin; + return true; + } + else + { + _emptyViewContentControl.ClearValue(MinWidthProperty); + + if (double.IsInfinity(availableSize.Height) || availableSize.Height <= 0) + { + _emptyViewContentControl.ClearValue(MinHeightProperty); + return false; + } + + var headerHeight = _headerContentControl?.DesiredSize.Height ?? 0; + var footerHeight = _footerContentControl?.DesiredSize.Height ?? 0; + var itemsHeight = (_itemsRepeater is not null && _itemsRepeater.Visibility == WVisibility.Visible) + ? _itemsRepeater.DesiredSize.Height + : 0; + + var remaining = availableSize.Height - headerHeight - footerHeight - itemsHeight; + var newMin = remaining > 0 ? remaining : 0; + if (_emptyViewContentControl.MinHeight == newMin) + { + + return false; + } + _emptyViewContentControl.MinHeight = newMin; + return true; + } + } + +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ObservableItemTemplateCollection2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ObservableItemTemplateCollection2.cs new file mode 100644 index 000000000000..6a545bacdb5f --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ObservableItemTemplateCollection2.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// An observable collection of that mirrors an +/// items source, keeping the template collection +/// synchronized with the underlying data and supporting drag/drop reordering. +/// +internal class ObservableItemTemplateCollection2 : ObservableCollection +{ + readonly IList _itemsSource; + readonly DataTemplate _itemTemplate; + readonly BindableObject _container; + readonly IMauiContext? _mauiContext; + readonly double _itemHeight; + readonly double _itemWidth; + readonly Thickness _itemSpacing; + readonly NotifyCollectionChangedEventHandler _collectionChanged; + readonly WeakNotifyCollectionChangedProxy _proxy = new(); + + bool _innerCollectionChange = false; + bool _observeChanges = true; + bool _isMoving = false; + + ~ObservableItemTemplateCollection2() => _proxy.Unsubscribe(); + + public ObservableItemTemplateCollection2(IList itemsSource, DataTemplate itemTemplate, BindableObject container, + double? itemHeight = null, double? itemWidth = null, Thickness? itemSpacing = null, IMauiContext? mauiContext = null) + { + _itemsSource = itemsSource; + _itemTemplate = itemTemplate; + _container = container; + _mauiContext = mauiContext; + _itemHeight = itemHeight ?? 0; + _itemWidth = itemWidth ?? 0; + _itemSpacing = itemSpacing ?? default; + _collectionChanged = InnerCollectionChanged; + + PopulateInitialItems(itemsSource, itemTemplate, container); + SubscribeToSourceChanges(itemsSource); + } + + /// + /// Subscribes to collection change events on the items source and this collection. + /// + void SubscribeToSourceChanges(IList itemsSource) + { + if (itemsSource is INotifyCollectionChanged notifyCollectionChanged) + { + _proxy.Subscribe(notifyCollectionChanged, _collectionChanged); + } + + CollectionChanged += TemplateCollectionChanged; + } + + /// + /// Unsubscribes from collection change events. Must be called when replacing + /// the items source or when the handler disconnects. + /// + internal void CleanUp() + { + CollectionChanged -= TemplateCollectionChanged; + _proxy.Unsubscribe(); + } + + /// + /// Populates the collection with initial entries + /// for each item in the source list. + /// + void PopulateInitialItems(IList itemsSource, DataTemplate itemTemplate, BindableObject container) + { + for (int index = 0; index < itemsSource.Count; index++) + { + var item = itemsSource[index]; + Add(new ItemTemplateContext2(itemTemplate, item, container, _itemHeight, _itemWidth, _itemSpacing, + false, false, _mauiContext)); + } + } + + void TemplateCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) + { + if (!_innerCollectionChange) + { + // When the template collection changes not as result of an inner collection change. + // The only time this happens is during a drag/drop item reorder (CanReorderItems). + // The ListView/GridView has notified us now we need to move those changes into the source. + // One might think it would be a "Move" event but it is actually a "Remove" followed by "Add". + _observeChanges = false; + + try + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + AddToSource(args); + break; + case NotifyCollectionChangedAction.Remove: + RemoveFromSource(args); + break; + default: + break; + } + } + finally + { + _observeChanges = true; + } + } + } + + void AddToSource(NotifyCollectionChangedEventArgs args) + { + if (args.NewItems is null) + { + return; + } + + var firstItem = args.NewItems[0] as ItemTemplateContext2; + var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : (firstItem is not null ? IndexOf(firstItem) : -1); + + var count = args.NewItems.Count; + + for (int index = 0; index < count; index++) + { + var newItem = args.NewItems[index] as ItemTemplateContext2; + if (newItem is not null) + _itemsSource.Insert(startIndex + index, newItem.Item); + } + } + + void RemoveFromSource(NotifyCollectionChangedEventArgs args) + { + if (args.OldItems is null) + { + return; + } + + var startIndex = args.OldStartingIndex; + + if (startIndex < 0) + { + // INCC implementation isn't giving us enough information to know where the removed items were in the + return; + } + + var count = args.OldItems.Count; + + for (int index = startIndex + count - 1; index >= startIndex; index--) + { + _itemsSource.RemoveAt(index); + } + } + + void InnerCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) + { + // Synchronous short-circuit: skip the dispatch entirely when observation is already + // suppressed. This is the common hot path during MoveItemAndSyncSource where the source + // fires Remove+Insert and we must not relay those back to the template collection. + // The check is kept INSIDE the lambda as well to handle the rare race where a background + // thread fires this handler while the UI thread is mid-way through clearing _observeChanges. + if (!_observeChanges) + return; + + _container.Dispatcher.DispatchIfRequired(() => + { + if (!_observeChanges) + return; + + InnerCollectionChanged(args); + }); + } + + void InnerCollectionChanged(NotifyCollectionChangedEventArgs args) + { + _innerCollectionChange = true; + try + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + Add(args); + break; + case NotifyCollectionChangedAction.Move: + Move(args); + break; + case NotifyCollectionChangedAction.Remove: + Remove(args); + break; + case NotifyCollectionChangedAction.Replace: + Replace(args); + break; + case NotifyCollectionChangedAction.Reset: + Reset(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + finally + { + _innerCollectionChange = false; + } + } + + void Add(NotifyCollectionChangedEventArgs args) + { + if (args.NewItems is null) + { + return; + } + + var startIndex = args.NewStartingIndex > -1 ? args.NewStartingIndex : _itemsSource.IndexOf(args.NewItems[0]); + + var count = args.NewItems.Count; + + for (int index = 0; index < count; index++) + { + var item = args.NewItems[index]; + Insert(startIndex + index, new ItemTemplateContext2(_itemTemplate, item, _container, _itemHeight, _itemWidth, _itemSpacing, + false, false, _mauiContext)); + } + } + + void Move(NotifyCollectionChangedEventArgs args) + { + if (args.NewItems is null) + { + return; + } + + var count = args.NewItems.Count; + if (args.OldStartingIndex > args.NewStartingIndex) + { + for (int n = 0; n < count; n++) + { + Move(args.OldStartingIndex + n, args.NewStartingIndex + n); + } + + return; + } + + for (int n = count - 1; n >= 0; n--) + { + Move(args.OldStartingIndex + n, args.NewStartingIndex + n); + } + } + + /// + /// Atomically moves an item in the underlying source and the template collection + /// without triggering feedback loops or creating new wrappers. + /// + /// Using + + /// on the source would fire two separate events. + /// relays these to the template collection + /// as Remove + Add, which creates a brand-new on Insert. + /// ItemsRepeater treats a new item as freshly added and may call + /// BringIntoView, resetting the scroll position. + /// + /// + /// This method avoids that by suppressing + /// while mutating the source (via ), then calling Move(int, int) + /// on the template collection directly. fires Remove + Add on the + /// existing wrapper — ItemsRepeater repositions the container in-place without recycling. + /// + /// + internal void MoveItemAndSyncSource(int oldIndex, int newIndex) + { + // Prevent re-entrant calls (e.g. if a CollectionChanged subscriber on the source + // somehow triggers another reorder while we are mid-mutation). + if (_isMoving) + return; + + // Guard against stale indices (e.g. external collection changes between drag-over + // and drop). Return early rather than throw so a mis-timed drop is a silent no-op. + if (oldIndex < 0 || oldIndex >= _itemsSource.Count) + return; + + _isMoving = true; + try + { + // Suppress InnerCollectionChanged so the Remove+Insert we fire on the source + // doesn't cause the template collection to apply the change a second time. + _observeChanges = false; + try + { + var sourceItem = _itemsSource[oldIndex]; + _itemsSource.RemoveAt(oldIndex); + // After RemoveAt the count is one less; clamp so Insert never throws. + newIndex = Math.Clamp(newIndex, 0, _itemsSource.Count); + _itemsSource.Insert(newIndex, sourceItem); + } + finally + { + _observeChanges = true; + } + + // Suppress TemplateCollectionChanged so the Remove+Add we fire below does not + // attempt to back-propagate changes to the source again. + _innerCollectionChange = true; + try + { + Move(oldIndex, newIndex); // → MoveItem override → fires Remove+Add (not Move) + } + finally + { + _innerCollectionChange = false; + } + } + finally + { + _isMoving = false; + } + } + + /// + /// Overrides to fire + /// followed by + /// instead of the default + /// event. + /// + /// + /// ⚠ CONTRACT VIOLATION — intentional and load-bearing. + /// + /// Normal contract: Move(old, new) raises a single + /// CollectionChanged(Move) event. This override intentionally violates that contract by + /// raising CollectionChanged(Remove) + CollectionChanged(Add) instead. + /// + /// + /// Why Remove+Add is safe: Firing Remove + Add causes CsWinRT to emit + /// VectorChanged(ItemRemoved) + VectorChanged(ItemInserted), which + /// ItemsRepeater handles by repositioning only the affected container while preserving + /// the ScrollViewer offset. + /// (set by the caller) prevents + /// from back-propagating these synthetic Remove/Add + /// events back to the source. + /// + /// + /// Callers: Only and + /// invoke this method. Both set + /// _innerCollectionChange = true before calling and restore it in a finally block. + /// External code must NOT call directly + /// on this collection — use instead. + /// + /// + protected override void MoveItem(int oldIndex, int newIndex) + { + CheckReentrancy(); + + var item = this[oldIndex]; + + // Update the underlying list directly — same as the base class does before + // firing CollectionChanged(Move). We avoid calling base.MoveItem() so we + // control which events are raised. + Items.RemoveAt(oldIndex); + Items.Insert(newIndex, item); + + // Notify indexer bindings that indexed items have changed — required by the + // ObservableCollection contract. Count is unchanged, so no Count notification. + OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Item[]")); + + // Wrap both events in BlockReentrancy so no mutation can slip between them. + // Each OnCollectionChanged call already increments the reentrancy monitor + // internally, but without an outer block a handler of Remove could trigger + // a second MoveItem before Add fires. + using (BlockReentrancy()) + { + // Fire Remove + Add in place of the default Move event. + // CollectionChanged(Remove) → CsWinRT → VectorChanged(ItemRemoved) + // CollectionChanged(Add) → CsWinRT → VectorChanged(ItemInserted) + // Neither triggers VectorChanged(Reset), so ItemsRepeater preserves scroll position. + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Remove, item, oldIndex)); + OnCollectionChanged(new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, item, newIndex)); + } + } + + void Remove(NotifyCollectionChangedEventArgs args) + { + if (args.OldItems is null) + { + return; + } + + var startIndex = args.OldStartingIndex; + + if (startIndex < 0) + { + // INCC implementation isn't giving us enough information to know where the removed items were in the + // collection. So the best we can do is a full Reset. + Reset(); + return; + } + + var count = args.OldItems.Count; + + for (int index = startIndex + count - 1; index >= startIndex; index--) + { + RemoveAt(index); + } + } + + void Replace(NotifyCollectionChangedEventArgs args) + { + if (args.OldItems == null || args.NewItems == null) + { + return; + } + + var newItemCount = args.NewItems.Count; + + if (newItemCount == args.OldItems.Count) + { + for (int index = 0; index < newItemCount; index++) + { + var item = args.NewItems[index]; + var itemIndex = args.OldStartingIndex + index; + var oldItem = this[itemIndex]; + var newItem = new ItemTemplateContext2(_itemTemplate, item, _container, _itemHeight, _itemWidth, _itemSpacing, + false, false, _mauiContext); + Items[itemIndex] = newItem; + var update = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem, itemIndex); + OnCollectionChanged(update); + } + } + else + { + // If we're replacing one set with an equal size set, we can do a soft reset; if not, we have to completely + // rebuild the collection + Reset(); + } + } + + void Reset() + { + Items.Clear(); + foreach (var item in _itemsSource) + { + Items.Add(new ItemTemplateContext2(_itemTemplate, item, _container, _itemHeight, _itemWidth, _itemSpacing, + false, false, _mauiContext)); + } + + var reset = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset); + OnCollectionChanged(reset); + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/TemplatedItemSourceFactory2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/TemplatedItemSourceFactory2.cs new file mode 100644 index 000000000000..8eed1c67f63a --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Windows/TemplatedItemSourceFactory2.cs @@ -0,0 +1,38 @@ +using System.Collections; +using System.Collections.Specialized; + +namespace Microsoft.Maui.Controls.Handlers.Items2; +/// +/// Factory that creates the appropriate templated item source collection +/// based on the capabilities of the items source (observable, list, or enumerable). +/// +internal static class TemplatedItemSourceFactory2 +{ + /// + /// Creates a templated item source for the given items source, choosing the best + /// collection type: observable (for INCC sources), indexed list, or enumerable fallback. + /// + internal static object Create(IEnumerable itemsSource, DataTemplate itemTemplate, BindableObject container, + double? itemHeight = null, double? itemWidth = null, Thickness? itemSpacing = null, IMauiContext? mauiContext = null) + { + switch (itemsSource) + { + case IList observable when itemsSource is INotifyCollectionChanged: + return new ObservableItemTemplateCollection2(observable, itemTemplate, container, itemHeight, itemWidth, itemSpacing, mauiContext); + case IList list: + return new ItemTemplateContextList2(list, itemTemplate, container, itemHeight, itemWidth, itemSpacing, mauiContext); + } + + return new ItemTemplateContextEnumerable2(itemsSource, itemTemplate, container, itemHeight, itemWidth, itemSpacing, mauiContext); + } + + /// + /// Creates a grouped templated item source that flattens grouped data into a single + /// list with header and footer contexts for each group. + /// + internal static object CreateGrouped(IEnumerable itemsSource, DataTemplate itemTemplate, + DataTemplate groupHeaderTemplate, DataTemplate groupFooterTemplate, BindableObject container, IMauiContext? mauiContext = null) + { + return new GroupedItemTemplateCollection2(itemsSource, itemTemplate, groupHeaderTemplate, groupFooterTemplate, container, mauiContext); + } +} diff --git a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs index d9377ff10a38..3697768723ee 100644 --- a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs +++ b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs @@ -17,6 +17,7 @@ #elif WINDOWS using ResourcesProvider = Microsoft.Maui.Controls.Compatibility.Platform.UWP.WindowsResourcesProvider; using Microsoft.Maui.Controls.Compatibility.Platform.UWP; +using Microsoft.Maui.Controls.Handlers.Items2; #elif IOS || MACCATALYST using Microsoft.Maui.Controls.Compatibility.Platform.iOS; using Microsoft.Maui.Controls.Handlers.Compatibility; @@ -66,6 +67,16 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo #if IOS || MACCATALYST handlersCollection.AddHandler(); handlersCollection.AddHandler(); +#elif WINDOWS + if (RuntimeFeature.IsWindowsCollectionView2HandlerEnabled) + { + handlersCollection.AddHandler(); + } + else + { + handlersCollection.AddHandler(); + } + handlersCollection.AddHandler(); #else handlersCollection.AddHandler(); handlersCollection.AddHandler(); diff --git a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemsViewStyles.xaml b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemsViewStyles.xaml index fabf34302705..8a0a5cf804f0 100644 --- a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemsViewStyles.xaml +++ b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemsViewStyles.xaml @@ -1,265 +1,447 @@ - - - - - - - - - - + 1.0 + + + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt index dd6618904c10..3d922659292e 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -90,6 +90,55 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~static Microsoft.Maui.Controls.NavigationPage.GetBackButtonAccessibilityLabel(Microsoft.Maui.Controls.BindableObject page) -> string ~static Microsoft.Maui.Controls.NavigationPage.SetBackButtonAccessibilityLabel(Microsoft.Maui.Controls.BindableObject page, string value) -> void ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +abstract Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.Layout.get -> Microsoft.Maui.Controls.IItemsLayout! +Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2 +Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.CollectionViewHandler2() -> void +Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.CollectionViewHandler2(Microsoft.Maui.PropertyMapper? mapper = null) -> void +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2 +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.Element.get -> TItemsView! +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.ItemsView.get -> TItemsView! +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.ItemsViewHandler2() -> void +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.ItemsViewHandler2(Microsoft.Maui.PropertyMapper? mapper = null) -> void +Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.UpdateItemsLayout() -> void +Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2 +Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.ReorderableItemsViewHandler2() -> void +Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.ReorderableItemsViewHandler2(Microsoft.Maui.PropertyMapper? mapper = null) -> void +override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.ConnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.DisconnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.UpdateItemsSource() -> void +override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.ConnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.CreatePlatformView() -> Microsoft.UI.Xaml.Controls.ItemsView! +override Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.DisconnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.ConnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.DisconnectHandler(Microsoft.UI.Xaml.Controls.ItemsView! platformView) -> void +override Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.Layout.get -> Microsoft.Maui.Controls.IItemsLayout! +static Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.MapCanReorderItems(Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2! handler, Microsoft.Maui.Controls.ReorderableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ReorderableItemsViewHandler2.ReorderableItemsViewMapper -> Microsoft.Maui.PropertyMapper!>! +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapGroupFooterTemplate(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.GroupableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapGroupHeaderTemplate(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.GroupableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapIsGrouped(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.GroupableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapItemSizingStrategy(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapItemsSource(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.SelectableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.Mapper -> Microsoft.Maui.PropertyMapper! +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapSelectedItem(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.SelectableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapSelectedItems(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.SelectableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2.MapSelectionMode(Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2! handler, Microsoft.Maui.Controls.SelectableItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.ItemsViewMapper -> Microsoft.Maui.PropertyMapper!>! +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapEmptyView(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapHorizontalScrollBarVisibility(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapItemsSource(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapItemsUpdatingScrollMode(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapItemTemplate(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapVerticalScrollBarVisibility(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapEmptyViewTemplate(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapFlowDirection(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapIsVisible(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapItemsLayout(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapHeader(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapHeaderTemplate(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapFooter(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +static Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.MapFooterTemplate(Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2! handler, Microsoft.Maui.Controls.ItemsView! itemsView) -> void +virtual Microsoft.Maui.Controls.Handlers.Items2.ItemsViewHandler2.UpdateItemsSource() -> void ~static Microsoft.Maui.Controls.VisualStateManager.InvalidateVisualStates(Microsoft.Maui.Controls.VisualElement visualElement) -> void ~static readonly Microsoft.Maui.Controls.BackButtonBehavior.AccessibilityLabelProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.BaseShellItem.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/tests/TestCases.Shared.Tests/UITest.cs b/src/Controls/tests/TestCases.Shared.Tests/UITest.cs index ff5ebffc306e..639ead31b0c2 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/UITest.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/UITest.cs @@ -388,6 +388,14 @@ but both can happen. case TestDevice.Windows: environmentName = "windows"; + + // Check if using CollectionView1 handler via TEST_CONFIGURATION_ARGS environment variable + // This uses a separate snapshot directory for visual regression testing + var testConfigurationArgs = Environment.GetEnvironmentVariable("TEST_CONFIGURATION_ARGS") ?? ""; + if (testConfigurationArgs.Contains("UseWindowsCV1:true", StringComparison.OrdinalIgnoreCase)) + { + environmentName = "windows-cv1"; + } break; case TestDevice.Mac: diff --git a/src/Controls/tests/TestCases.WinUI.Tests/Controls.TestCases.WinUI.Tests.csproj b/src/Controls/tests/TestCases.WinUI.Tests/Controls.TestCases.WinUI.Tests.csproj index c19d813fafa3..f91e6858ac6c 100644 --- a/src/Controls/tests/TestCases.WinUI.Tests/Controls.TestCases.WinUI.Tests.csproj +++ b/src/Controls/tests/TestCases.WinUI.Tests/Controls.TestCases.WinUI.Tests.csproj @@ -25,10 +25,7 @@ - + @@ -56,4 +53,8 @@ + + + + \ No newline at end of file diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CVHorizontalLinearItemsLayoutItemSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CVHorizontalLinearItemsLayoutItemSpacing.png new file mode 100644 index 000000000000..b992fe568823 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CVHorizontalLinearItemsLayoutItemSpacing.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CheckEmptyViewMargin.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CheckEmptyViewMargin.png new file mode 100644 index 000000000000..92d33400ec3f Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CheckEmptyViewMargin.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewEmptyViewFillsAvailableSpaceByDefault.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewEmptyViewFillsAvailableSpaceByDefault.png new file mode 100644 index 000000000000..81ab872c462d Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewEmptyViewFillsAvailableSpaceByDefault.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewGroupFooterTemplateShouldNotCrash.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewGroupFooterTemplateShouldNotCrash.png new file mode 100644 index 000000000000..0ba9f8ef83f0 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewGroupFooterTemplateShouldNotCrash.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeaderSizewithIsVisibleBinding.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeaderSizewithIsVisibleBinding.png new file mode 100644 index 000000000000..e2556ba46e49 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeaderSizewithIsVisibleBinding.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeightIsCorrectAfterDelayedLoad.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeightIsCorrectAfterDelayedLoad.png new file mode 100644 index 000000000000..82d5df5ba7f9 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewHeightIsCorrectAfterDelayedLoad.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewMeasureFirstItem.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewMeasureFirstItem.png new file mode 100644 index 000000000000..4059f21c8c23 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewMeasureFirstItem.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewPreSelectionShouldUpdate.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewPreSelectionShouldUpdate.png new file mode 100644 index 000000000000..f1c3c0e83eee Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewPreSelectionShouldUpdate.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png new file mode 100644 index 000000000000..0942f3fe8fd3 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionModeOnLightTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionModeOnLightTheme.png new file mode 100644 index 000000000000..3aca2b40a70e Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionModeOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionShouldClear.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionShouldClear.png new file mode 100644 index 000000000000..8fdead425ee4 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSelectionShouldClear.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewShouldChangeItemsLayout.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewShouldChangeItemsLayout.png new file mode 100644 index 000000000000..7f0b5bd4810e Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewShouldChangeItemsLayout.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSingleItemAlignmentWithFooter.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSingleItemAlignmentWithFooter.png new file mode 100644 index 000000000000..79f72661d3af Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/CollectionViewSingleItemAlignmentWithFooter.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png new file mode 100644 index 000000000000..3d4ef0674f57 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldRemeasureWhenParentLayoutChanges.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldRemeasureWhenParentLayoutChanges.png new file mode 100644 index 000000000000..64cbcf0d01aa Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/EmptyViewShouldRemeasureWhenParentLayoutChanges.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FlowdirectionShouldWorkForHeaderFooter.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FlowdirectionShouldWorkForHeaderFooter.png new file mode 100644 index 000000000000..01e4ae5faeb8 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FlowdirectionShouldWorkForHeaderFooter.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterShouldDisplayAtBottomOfEmptyView.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterShouldDisplayAtBottomOfEmptyView.png new file mode 100644 index 000000000000..1f72b0c3dd97 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterShouldDisplayAtBottomOfEmptyView.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterWithEmptyCVShouldHaveCorrectSize.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterWithEmptyCVShouldHaveCorrectSize.png new file mode 100644 index 000000000000..196b931050bc Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/FooterWithEmptyCVShouldHaveCorrectSize.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/GroupedCollectionViewItems.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/GroupedCollectionViewItems.png new file mode 100644 index 000000000000..69a97548b963 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/GroupedCollectionViewItems.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderAndFooterShouldBeVisible.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderAndFooterShouldBeVisible.png new file mode 100644 index 000000000000..9079c7417db0 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderAndFooterShouldBeVisible.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderShouldNotCollapseWithItems.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderShouldNotCollapseWithItems.png new file mode 100644 index 000000000000..c6926ebd05ae Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/HeaderShouldNotCollapseWithItems.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue17400Test.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue17400Test.png new file mode 100644 index 000000000000..a286afdbbf02 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue17400Test.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue18751Test.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue18751Test.png new file mode 100644 index 000000000000..d89f14afc7eb Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue18751Test.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue23377ItemSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue23377ItemSpacing.png new file mode 100644 index 000000000000..fa30ab8ecfe4 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue23377ItemSpacing.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue28343_ProgressSpinnerDisabled.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue28343_ProgressSpinnerDisabled.png new file mode 100644 index 000000000000..5866141b6101 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/Issue28343_ProgressSpinnerDisabled.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithDefaultWidth.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithDefaultWidth.png new file mode 100644 index 000000000000..c44e41d4e92a Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithDefaultWidth.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithMinimalWidth.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithMinimalWidth.png new file mode 100644 index 000000000000..f1878fd093e7 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ItemsWrapGridWithMinimalWidth.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/OnlyManuallySwipedItemShouldBeOpened.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/OnlyManuallySwipedItemShouldBeOpened.png new file mode 100644 index 000000000000..01942bb440bd Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/OnlyManuallySwipedItemShouldBeOpened.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/PointerOverWithSelectedStateShouldWork.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/PointerOverWithSelectedStateShouldWork.png new file mode 100644 index 000000000000..be6d7990e672 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/PointerOverWithSelectedStateShouldWork.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RefreshShouldNotChangeSize.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RefreshShouldNotChangeSize.png new file mode 100644 index 000000000000..2aebe71d7ad3 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RefreshShouldNotChangeSize.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveEmptyViewAtRuntime.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveEmptyViewAtRuntime.png new file mode 100644 index 000000000000..8cbed2cd9af1 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveEmptyViewAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveHeaderFooterAtRuntime.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveHeaderFooterAtRuntime.png new file mode 100644 index 000000000000..9cc5c6af8aed Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/RemoveHeaderFooterAtRuntime.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ScrollToLastItem.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ScrollToLastItem.png new file mode 100644 index 000000000000..b0868440f217 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ScrollToLastItem.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemVisualIsCleared.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemVisualIsCleared.png new file mode 100644 index 000000000000..65b21b80dcbe Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemVisualIsCleared.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_multiple.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_multiple.png new file mode 100644 index 000000000000..a8e17a15e671 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_multiple.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_none.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_none.png new file mode 100644 index 000000000000..db88e69fd43c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_none.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_single.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_single.png new file mode 100644 index 000000000000..ca7c6ad59a38 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectedItemsShowSelected_single.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectionShouldNotMovedToTopWithGroupedCollection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectionShouldNotMovedToTopWithGroupedCollection.png new file mode 100644 index 000000000000..77f713c966db Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SelectionShouldNotMovedToTopWithGroupedCollection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ShouldIgnoreBottomContentInsetForCollectionViewItems.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ShouldIgnoreBottomContentInsetForCollectionViewItems.png new file mode 100644 index 000000000000..8b7efaa52283 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/ShouldIgnoreBottomContentInsetForCollectionViewItems.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SingleItemAlignmentInCollectionViewHorizontalGridLayout.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SingleItemAlignmentInCollectionViewHorizontalGridLayout.png new file mode 100644 index 000000000000..002d74c0bf45 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/SingleItemAlignmentInCollectionViewHorizontalGridLayout.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCVBackgroundAndBackgroundColorWithVSM.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCVBackgroundAndBackgroundColorWithVSM.png new file mode 100644 index 000000000000..3e66c2390ff6 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCVBackgroundAndBackgroundColorWithVSM.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewEmptyView.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewEmptyView.png new file mode 100644 index 000000000000..3341a647ffd3 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewEmptyView.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewItemsAfterScrolling.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewItemsAfterScrolling.png new file mode 100644 index 000000000000..24f04c2358ea Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewItemsAfterScrolling.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewVisualState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewVisualState.png new file mode 100644 index 000000000000..a2c5c42e5ff2 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyCollectionViewVisualState.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDataTemplateParentIsNotNull.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDataTemplateParentIsNotNull.png new file mode 100644 index 000000000000..ded903c9b9f8 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDataTemplateParentIsNotNull.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDefaultScrollToRequested.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDefaultScrollToRequested.png new file mode 100644 index 000000000000..5d8497fa0d3a Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyDefaultScrollToRequested.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionLTRAndMeasureAllItemsWithObservableCollection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionLTRAndMeasureAllItemsWithObservableCollection.png new file mode 100644 index 000000000000..1b0a786f99f7 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionLTRAndMeasureAllItemsWithObservableCollection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionRTLAndMeasureAllItemsWithObservableCollection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionRTLAndMeasureAllItemsWithObservableCollection.png new file mode 100644 index 000000000000..dc8837503761 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyFlowDirectionRTLAndMeasureAllItemsWithObservableCollection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyMeasureAllItemsWithObservableCollection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyMeasureAllItemsWithObservableCollection.png new file mode 100644 index 000000000000..d916b6e66f86 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyMeasureAllItemsWithObservableCollection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png new file mode 100644 index 000000000000..a33cec902f0d Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenSingleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenSingleModePreSelection.png new file mode 100644 index 000000000000..40ed1798ac92 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsGroupedListWhenSingleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png new file mode 100644 index 000000000000..9258bf160813 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenSingleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenSingleModePreSelection.png new file mode 100644 index 000000000000..5d164d300702 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyModelItemsObservableCollectionWhenSingleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectedItemClearsOnNullAssignment.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectedItemClearsOnNullAssignment.png new file mode 100644 index 000000000000..f067dc95b2be Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectedItemClearsOnNullAssignment.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png new file mode 100644 index 000000000000..d7baa2994cfe Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeSingleWhenProgrammaticSelectionWorksWithVerticalList.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeSingleWhenProgrammaticSelectionWorksWithVerticalList.png new file mode 100644 index 000000000000..359ead8b32c0 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifySelectionModeSingleWhenProgrammaticSelectionWorksWithVerticalList.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png new file mode 100644 index 000000000000..2614aece8329 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png new file mode 100644 index 000000000000..6b5715b90a17 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenSingleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenSingleModePreSelection.png new file mode 100644 index 000000000000..a1314e15321f Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyStringItemsObservableCollectionWhenSingleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Disabled.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Disabled.png new file mode 100644 index 000000000000..73b3d02a27d3 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Disabled.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_InitialState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_InitialState.png new file mode 100644 index 000000000000..512d0702934c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_InitialState.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Normal.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Normal.png new file mode 100644 index 000000000000..bdc8ee719892 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Normal.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Reset.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Reset.png new file mode 100644 index 000000000000..8a277921af90 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Reset.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected.png new file mode 100644 index 000000000000..3875b2638f18 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected_Multiple.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected_Multiple.png new file mode 100644 index 000000000000..b8ac55e2102c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerifyVSM_CollectionView_Selected_Multiple.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png new file mode 100644 index 000000000000..2f015e791ffe Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewLTRToRTLToggleShouldWork.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png new file mode 100644 index 000000000000..144628882c4c Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalGridCollectionViewRTLColumnMirroringShouldWork.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png new file mode 100644 index 000000000000..38b251a5b74d Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows-cv1/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CVHorizontalLinearItemsLayoutItemSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CVHorizontalLinearItemsLayoutItemSpacing.png index b992fe568823..4a636daa7049 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CVHorizontalLinearItemsLayoutItemSpacing.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CVHorizontalLinearItemsLayoutItemSpacing.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewGroupFooterTemplateShouldNotCrash.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewGroupFooterTemplateShouldNotCrash.png index 0ba9f8ef83f0..d19df50cdc65 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewGroupFooterTemplateShouldNotCrash.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewGroupFooterTemplateShouldNotCrash.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeaderSizewithIsVisibleBinding.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeaderSizewithIsVisibleBinding.png index e2556ba46e49..ed85146db789 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeaderSizewithIsVisibleBinding.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeaderSizewithIsVisibleBinding.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeightIsCorrectAfterDelayedLoad.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeightIsCorrectAfterDelayedLoad.png index 82d5df5ba7f9..024702da23d7 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeightIsCorrectAfterDelayedLoad.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewHeightIsCorrectAfterDelayedLoad.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewPreSelectionShouldUpdate.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewPreSelectionShouldUpdate.png index f1c3c0e83eee..f1853b72dbd2 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewPreSelectionShouldUpdate.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewPreSelectionShouldUpdate.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png index 0942f3fe8fd3..69890665ac28 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectedItemBackgroundShouldPersistAfterModalNavigation.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectionModeOnLightTheme.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectionModeOnLightTheme.png index 3aca2b40a70e..af73053bfb2d 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectionModeOnLightTheme.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSelectionModeOnLightTheme.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewShouldChangeItemsLayout.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewShouldChangeItemsLayout.png index 7f0b5bd4810e..9ddf102a6168 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewShouldChangeItemsLayout.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewShouldChangeItemsLayout.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSingleItemAlignmentWithFooter.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSingleItemAlignmentWithFooter.png index 79f72661d3af..2b5f3fa5e768 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSingleItemAlignmentWithFooter.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/CollectionViewSingleItemAlignmentWithFooter.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png index 3d4ef0674f57..dadbcdbf4724 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldDisplayWhenCollectionViewIsInsideVerticalStackLayout.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldRemeasureWhenParentLayoutChanges.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldRemeasureWhenParentLayoutChanges.png index 64cbcf0d01aa..d491d0dc07bc 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldRemeasureWhenParentLayoutChanges.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/EmptyViewShouldRemeasureWhenParentLayoutChanges.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FlowdirectionShouldWorkForHeaderFooter.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FlowdirectionShouldWorkForHeaderFooter.png index 01e4ae5faeb8..7f95289de630 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FlowdirectionShouldWorkForHeaderFooter.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FlowdirectionShouldWorkForHeaderFooter.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FooterWithEmptyCVShouldHaveCorrectSize.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FooterWithEmptyCVShouldHaveCorrectSize.png index 196b931050bc..9c85ec12c53b 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FooterWithEmptyCVShouldHaveCorrectSize.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/FooterWithEmptyCVShouldHaveCorrectSize.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/GroupedCollectionViewItems.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/GroupedCollectionViewItems.png index 0b7dbd29016b..6de965b9e6f0 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/GroupedCollectionViewItems.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/GroupedCollectionViewItems.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/HeaderAndFooterShouldBeVisible.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/HeaderAndFooterShouldBeVisible.png index 9079c7417db0..99a559cbff0d 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/HeaderAndFooterShouldBeVisible.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/HeaderAndFooterShouldBeVisible.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue17400Test.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue17400Test.png index a286afdbbf02..0691d7c5514a 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue17400Test.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue17400Test.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue18751Test.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue18751Test.png index d89f14afc7eb..f81d58d0b105 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue18751Test.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue18751Test.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue23377ItemSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue23377ItemSpacing.png index fa30ab8ecfe4..1292233a0d89 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue23377ItemSpacing.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue23377ItemSpacing.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue28343_ProgressSpinnerDisabled.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue28343_ProgressSpinnerDisabled.png index 5866141b6101..3b98d627c3b5 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue28343_ProgressSpinnerDisabled.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue28343_ProgressSpinnerDisabled.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/OnlyManuallySwipedItemShouldBeOpened.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/OnlyManuallySwipedItemShouldBeOpened.png index 01942bb440bd..55c752483193 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/OnlyManuallySwipedItemShouldBeOpened.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/OnlyManuallySwipedItemShouldBeOpened.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemVisualIsCleared.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemVisualIsCleared.png index 65b21b80dcbe..792e7a9fc52d 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemVisualIsCleared.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemVisualIsCleared.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_multiple.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_multiple.png index 977942e44475..a33b3b075fb5 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_multiple.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_multiple.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_none.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_none.png index 7c51e889dcb8..a7fdd283f747 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_none.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_none.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_single.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_single.png index ca7c6ad59a38..3eebe37ffd8a 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_single.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectedItemsShowSelected_single.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectionShouldNotMovedToTopWithGroupedCollection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectionShouldNotMovedToTopWithGroupedCollection.png index 77f713c966db..bc466f44918a 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectionShouldNotMovedToTopWithGroupedCollection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/SelectionShouldNotMovedToTopWithGroupedCollection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ShouldIgnoreBottomContentInsetForCollectionViewItems.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ShouldIgnoreBottomContentInsetForCollectionViewItems.png index 8b7efaa52283..dfa158cfffd9 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ShouldIgnoreBottomContentInsetForCollectionViewItems.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/ShouldIgnoreBottomContentInsetForCollectionViewItems.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCVBackgroundAndBackgroundColorWithVSM.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCVBackgroundAndBackgroundColorWithVSM.png index 3e66c2390ff6..05c70590204b 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCVBackgroundAndBackgroundColorWithVSM.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCVBackgroundAndBackgroundColorWithVSM.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCollectionViewEmptyView.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCollectionViewEmptyView.png index 3341a647ffd3..cd9bde0d8538 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCollectionViewEmptyView.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyCollectionViewEmptyView.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDataTemplateParentIsNotNull.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDataTemplateParentIsNotNull.png index ded903c9b9f8..cb74c1eb9359 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDataTemplateParentIsNotNull.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDataTemplateParentIsNotNull.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDefaultScrollToRequested.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDefaultScrollToRequested.png index 654d87ae3a3a..3d8f72215227 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDefaultScrollToRequested.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyDefaultScrollToRequested.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png index a33cec902f0d..c383cc50a79b 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenSingleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenSingleModePreSelection.png index 40ed1798ac92..da66c587dac9 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenSingleModePreSelection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsGroupedListWhenSingleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png index 9258bf160813..9f0e554aca0c 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyModelItemsObservableCollectionWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png index d7baa2994cfe..a93600c4383d 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifySelectionModeMultipleWhenProgrammaticSelectionWorksWithVerticalList.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png index 2614aece8329..d7d77ed326b7 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsGroupedListWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png index 6b5715b90a17..49d5cff36513 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyStringItemsObservableCollectionWhenMultipleModePreSelection.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Disabled.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Disabled.png index 73b3d02a27d3..75e4d6d922d4 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Disabled.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Disabled.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_InitialState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_InitialState.png index 512d0702934c..7f5b057e7c24 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_InitialState.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_InitialState.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Normal.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Normal.png index bdc8ee719892..0f371b490cd0 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Normal.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Normal.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Reset.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Reset.png index 8a277921af90..21b1481c8ea0 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Reset.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Reset.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected.png index 3875b2638f18..80d9cf1caa2b 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected_Multiple.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected_Multiple.png index b8ac55e2102c..a9652d49f243 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected_Multiple.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerifyVSM_CollectionView_Selected_Multiple.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png index 38b251a5b74d..65ac738faa60 100644 Binary files a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/VerticalItemsRemainFullyVisibleAfterChangingSpacing.png differ diff --git a/src/Core/src/RuntimeFeature.cs b/src/Core/src/RuntimeFeature.cs index 68e67c6588e5..183f68f5b2ba 100644 --- a/src/Core/src/RuntimeFeature.cs +++ b/src/Core/src/RuntimeFeature.cs @@ -29,6 +29,7 @@ static class RuntimeFeature const bool EnableAspireByDefault = true; const bool IsMaterial3EnabledByDefault = false; const bool IsCssEnabledByDefault = true; + const bool IsWindowsCollectionView2HandlerEnabledByDefault = true; #pragma warning disable IL4000 // Return value does not match FeatureGuardAttribute 'System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute'. #if NET9_0_OR_GREATER @@ -157,6 +158,14 @@ internal set ? isEnabled : IsMaterial3EnabledByDefault; +#if NET11_0_OR_GREATER + [FeatureSwitchDefinition($"{FeatureSwitchPrefix}.{nameof(IsWindowsCollectionView2HandlerEnabled)}")] +#endif + public static bool IsWindowsCollectionView2HandlerEnabled => + AppContext.TryGetSwitch($"{FeatureSwitchPrefix}.{nameof(IsWindowsCollectionView2HandlerEnabled)}", out bool isEnabled) + ? isEnabled + : IsWindowsCollectionView2HandlerEnabledByDefault; + #pragma warning restore IL4000 #if NET9_0_OR_GREATER