From a07caadb765a3baf426067837d8438e55cd50e1d Mon Sep 17 00:00:00 2001 From: Yair <39923744+yair100@users.noreply.github.com> Date: Mon, 11 May 2026 22:56:17 -0400 Subject: [PATCH 1/2] Init tree view --- .../Sidebar/FlatSidebarItem.cs | 46 +++ .../Sidebar/ISidebarItemModel.cs | 13 +- .../Sidebar/SidebarItem.Properties.cs | 60 ++- src/Files.App.Controls/Sidebar/SidebarItem.cs | 258 +++++++----- .../Sidebar/SidebarStyles.xaml | 245 ++++-------- .../Sidebar/SidebarView.Properties.cs | 20 +- .../Sidebar/SidebarView.xaml | 6 +- .../Sidebar/SidebarView.xaml.cs | 13 +- .../Actions/Show/ToggleSidebarAction.cs | 27 +- .../Data/Contracts/IGeneralSettingsService.cs | 35 -- src/Files.App/Data/Items/DriveItem.cs | 13 +- .../Data/Items/ExpandableSidebarItemBase.cs | 292 ++++++++++++++ src/Files.App/Data/Items/FileTagItem.cs | 2 - .../Data/Items/IExpandableSidebarFolder.cs | 14 + src/Files.App/Data/Items/LocationItem.cs | 177 ++++++++- src/Files.App/Data/Items/WslDistroItem.cs | 2 - .../Settings/GeneralSettingsService.cs | 42 -- .../Utils/Storage/Helpers/FolderHelpers.cs | 79 ++++ .../Settings/SettingsPageViewModel.cs | 7 +- .../UserControls/SidebarViewModel.FlatTree.cs | 376 ++++++++++++++++++ .../SidebarViewModel.TabExpansion.cs | 268 +++++++++++++ .../UserControls/SidebarViewModel.cs | 179 ++++++--- src/Files.App/Views/MainPage.xaml | 6 +- src/Files.App/Views/MainPage.xaml.cs | 27 ++ .../Views/Settings/SettingsPage.xaml | 2 +- 25 files changed, 1744 insertions(+), 465 deletions(-) create mode 100644 src/Files.App.Controls/Sidebar/FlatSidebarItem.cs create mode 100644 src/Files.App/Data/Items/ExpandableSidebarItemBase.cs create mode 100644 src/Files.App/Data/Items/IExpandableSidebarFolder.cs create mode 100644 src/Files.App/ViewModels/UserControls/SidebarViewModel.FlatTree.cs create mode 100644 src/Files.App/ViewModels/UserControls/SidebarViewModel.TabExpansion.cs diff --git a/src/Files.App.Controls/Sidebar/FlatSidebarItem.cs b/src/Files.App.Controls/Sidebar/FlatSidebarItem.cs new file mode 100644 index 000000000000..3183ce30a205 --- /dev/null +++ b/src/Files.App.Controls/Sidebar/FlatSidebarItem.cs @@ -0,0 +1,46 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +namespace Files.App.Controls +{ + /// + /// Per-row wrapper used by the sidebar's virtualized ItemsRepeater. Carries the row's tree depth and section-gap flags so the underlying data items don't have to. + /// + public sealed class FlatSidebarItem : INotifyPropertyChanged + { + public ISidebarItemModel Item { get; } + + public int Depth { get; } + + // Caller-supplied at construction; hidden filesystem items are dimmed to match the file list's dimming convention. + public double RowOpacity { get; } + + private static readonly PropertyChangedEventArgs SectionGapMarginChangedArgs = new(nameof(SectionGapMargin)); + + private bool _hasExpandedPredecessor; + public bool HasExpandedPredecessor + { + get => _hasExpandedPredecessor; + set + { + if (_hasExpandedPredecessor == value) + return; + _hasExpandedPredecessor = value; + PropertyChanged?.Invoke(this, SectionGapMarginChangedArgs); + } + } + + public Thickness SectionGapMargin => _hasExpandedPredecessor + ? new Thickness(0, 12, 0, 0) + : new Thickness(0); + + public event PropertyChangedEventHandler? PropertyChanged; + + public FlatSidebarItem(ISidebarItemModel item, int depth, double rowOpacity = 1.0) + { + Item = item; + Depth = depth; + RowOpacity = rowOpacity; + } + } +} diff --git a/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs b/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs index 9e6791014aa7..eedd91e8f4d1 100644 --- a/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs +++ b/src/Files.App.Controls/Sidebar/ISidebarItemModel.cs @@ -17,13 +17,18 @@ public interface ISidebarItemModel : INotifyPropertyChanged bool IsExpanded { get; set; } /// - /// Indicates whether the children should have an indentation or not. + /// Optional path associated with this sidebar item for drag/drop scenarios. /// - bool PaddedItem { get; } + string? Path { get; } /// - /// Optional path associated with this sidebar item for drag/drop scenarios. + /// Renders as expandable even when Children is empty (children load lazily on first expansion). /// - string? Path { get; } + bool HasUnrealizedChildren => false; + + /// + /// Expansion participant that keeps the regular row appearance (icon + normal text) instead of the section-header style. + /// + bool IsLeafWithChildren => false; } } diff --git a/src/Files.App.Controls/Sidebar/SidebarItem.Properties.cs b/src/Files.App.Controls/Sidebar/SidebarItem.Properties.cs index 6b502408dac0..6d6565235a6a 100644 --- a/src/Files.App.Controls/Sidebar/SidebarItem.Properties.cs +++ b/src/Files.App.Controls/Sidebar/SidebarItem.Properties.cs @@ -13,7 +13,14 @@ public SidebarView? Owner set { SetValue(OwnerProperty, value); } } public static readonly DependencyProperty OwnerProperty = - DependencyProperty.Register(nameof(Owner), typeof(SidebarView), typeof(SidebarItem), new PropertyMetadata(null)); + DependencyProperty.Register(nameof(Owner), typeof(SidebarView), typeof(SidebarItem), new PropertyMetadata(null, OnOwnerChanged)); + + // Owner is assigned by the hosting ItemsRepeater's ElementPrepared (top-level rows) or by the parent row (flyout children) — recycled containers can carry a stale Owner across realizations, so the chevron-column visual state must re-apply whenever Owner flips. + private static void OnOwnerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is SidebarItem item && item.Owner is { } owner) + VisualStateManager.GoToState(item, owner.SupportsExpansion ? "OwnerSupportsExpansion" : "OwnerDoesNotSupportExpansion", false); + } public bool IsSelected { @@ -31,6 +38,37 @@ public bool IsExpanded public static readonly DependencyProperty IsExpandedProperty = DependencyProperty.Register(nameof(IsExpanded), typeof(bool), typeof(SidebarItem), new PropertyMetadata(true, OnPropertyChanged)); + public int NestingLevel + { + get { return (int)GetValue(NestingLevelProperty); } + set { SetValue(NestingLevelProperty, value); } + } + public static readonly DependencyProperty NestingLevelProperty = + DependencyProperty.Register(nameof(NestingLevel), typeof(int), typeof(SidebarItem), new PropertyMetadata(0, OnNestingLevelChanged)); + + public double IndentWidth + { + get { return (double)GetValue(IndentWidthProperty); } + set { SetValue(IndentWidthProperty, value); } + } + public static readonly DependencyProperty IndentWidthProperty = + DependencyProperty.Register(nameof(IndentWidth), typeof(double), typeof(SidebarItem), new PropertyMetadata(0d)); + + private static void OnNestingLevelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is SidebarItem item && e.NewValue is int level) + item.IndentWidth = level * 16d; + } + + // Dims icon + text + chevron + decorator only; the selection indicator and pointer-over fill stay at full opacity so a selected hidden row still reads as selected. + public double ContentOpacity + { + get { return (double)GetValue(ContentOpacityProperty); } + set { SetValue(ContentOpacityProperty, value); } + } + public static readonly DependencyProperty ContentOpacityProperty = + DependencyProperty.Register(nameof(ContentOpacity), typeof(double), typeof(SidebarItem), new PropertyMetadata(1.0)); + public bool IsInFlyout { get { return (bool)GetValue(IsInFlyoutProperty); } @@ -39,15 +77,6 @@ public bool IsInFlyout public static readonly DependencyProperty IsInFlyoutProperty = DependencyProperty.Register(nameof(IsInFlyout), typeof(bool), typeof(SidebarItem), new PropertyMetadata(false)); - public double ChildrenPresenterHeight - { - get { return (double)GetValue(ChildrenPresenterHeightProperty); } - set { SetValue(ChildrenPresenterHeightProperty, value); } - } - // Using 30 as a default in case something goes wrong - public static readonly DependencyProperty ChildrenPresenterHeightProperty = - DependencyProperty.Register(nameof(ChildrenPresenterHeight), typeof(double), typeof(SidebarItem), new PropertyMetadata(30d)); - public ISidebarItemModel? Item { get { return (ISidebarItemModel)GetValue(ItemProperty); } @@ -94,17 +123,6 @@ public SidebarDisplayMode DisplayMode [GeneratedDependencyProperty] public partial object? ToolTip { get; set; } - public static void SetTemplateRoot(DependencyObject target, FrameworkElement value) - { - target.SetValue(TemplateRootProperty, value); - } - public static FrameworkElement GetTemplateRoot(DependencyObject target) - { - return (FrameworkElement)target.GetValue(TemplateRootProperty); - } - public static readonly DependencyProperty TemplateRootProperty = - DependencyProperty.Register("TemplateRoot", typeof(FrameworkElement), typeof(SidebarItem), new PropertyMetadata(null)); - public static void OnPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) { if (sender is not SidebarItem item) return; diff --git a/src/Files.App.Controls/Sidebar/SidebarItem.cs b/src/Files.App.Controls/Sidebar/SidebarItem.cs index 9ac319ab1aff..4edd80550dc5 100644 --- a/src/Files.App.Controls/Sidebar/SidebarItem.cs +++ b/src/Files.App.Controls/Sidebar/SidebarItem.cs @@ -16,7 +16,7 @@ public sealed partial class SidebarItem : Control { private const double DROP_REPOSITION_THRESHOLD = 0.2; // Percentage of top/bottom at which we consider a drop to be a reposition/insertion - public bool HasChildren => Item?.Children is IList enumerable && enumerable.Count > 0; + public bool HasChildren => (Item?.Children is IList enumerable && enumerable.Count > 0) || (Item?.HasUnrealizedChildren ?? false); public bool IsGroupHeader => Item?.Children is not null; public bool CollapseEnabled => DisplayMode != SidebarDisplayMode.Compact; @@ -24,8 +24,10 @@ public sealed partial class SidebarItem : Control private bool isPointerOver = false; private bool isClicking = false; private object? selectedChildItem = null; - private ItemsRepeater? childrenRepeater; private ISidebarItemModel? lastSubscriber; + // Owner DisplayMode callback runs once per container, gated by isWiredUp. Template-child handlers (ElementBorder pointer events etc.) run once per template application, gated by isTemplateWired — they can't share the gate because Loaded can fire on a Visibility=Collapsed container before OnApplyTemplate has supplied any template children to hook up. + private bool isWiredUp; + private bool isTemplateWired; public SidebarItem() { @@ -34,10 +36,20 @@ public SidebarItem() PointerReleased += Item_PointerReleased; KeyDown += (sender, args) => { - if (args.Key == Windows.System.VirtualKey.Enter) + switch (args.Key) { - Clicked(PointerUpdateKind.Other); - args.Handled = true; + case Windows.System.VirtualKey.Enter: + Clicked(PointerUpdateKind.Other); + args.Handled = true; + break; + case Windows.System.VirtualKey.Right when HasChildren && CollapseEnabled && !IsExpanded: + IsExpanded = true; + args.Handled = true; + break; + case Windows.System.VirtualKey.Left when HasChildren && CollapseEnabled && IsExpanded: + IsExpanded = false; + args.Handled = true; + break; } }; DragStarting += SidebarItem_DragStarting; @@ -50,6 +62,43 @@ protected override AutomationPeer OnCreateAutomationPeer() return new SidebarItemAutomationPeer(this); } + // Template-tied work needs to run *here* (not in Loaded) because Loaded can fire while the control is still not measured; template parts may not exist yet. Sub-rows realized later would otherwise keep isWiredUp=true with no handlers attached. + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + if (!isTemplateWired) + { + isTemplateWired = true; + if (GetTemplateChild("ElementBorder") is Border border) + { + border.PointerEntered += ItemBorder_PointerEntered; + border.PointerExited += ItemBorder_PointerExited; + border.PointerCanceled += ItemBorder_PointerCanceled; + border.PointerPressed += ItemBorder_PointerPressed; + border.ContextRequested += ItemBorder_ContextRequested; + border.DoubleTapped += ItemBorder_DoubleTapped; + border.DragLeave += ItemBorder_DragLeave; + border.DragOver += ItemBorder_DragOver; + border.Drop += ItemBorder_Drop; + border.AllowDrop = true; + border.IsTabStop = false; + } + if (GetTemplateChild("ChevronContainer") is Border chevronContainer) + chevronContainer.PointerPressed += ChevronContainer_PointerPressed; + if (GetTemplateChild("FlyoutChildrenPresenter") is ItemsRepeater flyoutRepeater) + flyoutRepeater.ElementPrepared += FlyoutChildrenPresenter_ElementPrepared; + } + + if (Owner is null) + return; + VisualStateManager.GoToState(this, Owner.SupportsExpansion ? "OwnerSupportsExpansion" : "OwnerDoesNotSupportExpansion", false); + // Flyout items inherit DisplayMode=Compact from the parent SidebarView but render full-size inside the overlay; they must NOT enter the Compact visual state or their text gets hidden. This matches the !IsInFlyout guard in SidebarDisplayModeChanged. + if (!IsInFlyout) + VisualStateManager.GoToState(this, DisplayMode == SidebarDisplayMode.Compact ? "Compact" : "NonCompact", false); + UpdateExpansionState(); + } + internal void Select() { if (Owner is not null) @@ -58,32 +107,14 @@ internal void Select() private void SidebarItem_Loaded(object sender, RoutedEventArgs e) { - HookupOwners(); - - if (GetTemplateChild("ElementBorder") is Border border) + // Loaded fires every time ItemsRepeater recycles the container; only the per-row HandleItemChange runs each time. + if (!isWiredUp) { - border.PointerEntered += ItemBorder_PointerEntered; - border.PointerExited += ItemBorder_PointerExited; - border.PointerCanceled += ItemBorder_PointerCanceled; - border.PointerPressed += ItemBorder_PointerPressed; - border.ContextRequested += ItemBorder_ContextRequested; - border.DragLeave += ItemBorder_DragLeave; - border.DragOver += ItemBorder_DragOver; - border.Drop += ItemBorder_Drop; - border.AllowDrop = true; - border.IsTabStop = false; + HookupOwners(); + // HookupOwners can leave Owner null for static SidebarItems whose FindAscendant walk fires before they're parented into a SidebarView (rare). Leave isWiredUp=false so the next Loaded retries. + if (Owner is not null) + isWiredUp = true; } - - if (GetTemplateChild("ChildrenPresenter") is ItemsRepeater repeater) - { - childrenRepeater = repeater; - repeater.ElementPrepared += ChildrenPresenter_ElementPrepared; - } - if (GetTemplateChild("FlyoutChildrenPresenter") is ItemsRepeater flyoutRepeater) - { - flyoutRepeater.ElementPrepared += ChildrenPresenter_ElementPrepared; - } - HandleItemChange(); } @@ -97,24 +128,26 @@ public void HandleItemChange() private void HookupOwners() { - FrameworkElement resolvingTarget = this; - if (GetTemplateRoot(Parent) is FrameworkElement element) - { - resolvingTarget = element; - } - Owner = resolvingTarget.FindAscendant()!; + // Owner is pushed in by the hosting SidebarView's MenuItemsHost_ElementPrepared (top-level rows) or the parent SidebarItem's FlyoutChildrenPresenter_ElementPrepared (flyout children) before Loaded fires. Static SidebarItems declared directly in XAML (MainPage's SettingsButton in SidebarView.Footer) aren't realized through either path, so resolve Owner via a visual-tree walk for them. OwnerExpansionSupport state is applied by OnOwnerChanged. + if (Owner is null) + Owner = this.FindAscendant(); + if (Owner is null) + return; Owner.RegisterPropertyChangedCallback(SidebarView.DisplayModeProperty, (sender, args) => { DisplayMode = Owner.DisplayMode; }); DisplayMode = Owner.DisplayMode; + // Setting the DP above only fires SidebarDisplayModeChanged (which calls GoToState) when the value actually changes from the default — sub-rows realized after Compact→Expanded never trigger it because both default and new value are Expanded. Force the state transition. Flyout items are skipped (same as in SidebarDisplayModeChanged) so they don't enter Compact and hide their text. + if (!IsInFlyout) + VisualStateManager.GoToState(this, DisplayMode == SidebarDisplayMode.Compact ? "Compact" : "NonCompact", false); + // Static SidebarItems (MainPage's SettingsButton inside SidebarView.Footer) sit outside MenuItemsHost, so SidebarView.OnSelectedItemChanged's broadcast can't reach them. The per-row callback fills that gap. Owner.RegisterPropertyChangedCallback(SidebarView.SelectedItemProperty, (sender, args) => { ReevaluateSelection(); }); - ReevaluateSelection(); } private void HookupItemChangeListener(ISidebarItemModel? oldItem, ISidebarItemModel? newItem) @@ -123,18 +156,34 @@ private void HookupItemChangeListener(ISidebarItemModel? oldItem, ISidebarItemMo { if (lastSubscriber.Children is INotifyCollectionChanged observableCollection) observableCollection.CollectionChanged -= ChildItems_CollectionChanged; + lastSubscriber.PropertyChanged -= Item_PropertyChanged; } if (oldItem != null) { if (oldItem.Children is INotifyCollectionChanged observableCollection) observableCollection.CollectionChanged -= ChildItems_CollectionChanged; + oldItem.PropertyChanged -= Item_PropertyChanged; } if (newItem != null) { lastSubscriber = newItem; if (newItem.Children is INotifyCollectionChanged observableCollection) observableCollection.CollectionChanged += ChildItems_CollectionChanged; + newItem.PropertyChanged += Item_PropertyChanged; + } + } + + private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(ISidebarItemModel.HasUnrealizedChildren): + case nameof(ISidebarItemModel.IsLeafWithChildren): + case nameof(ISidebarItemModel.Children): + UpdateExpansionState(); + ReevaluateSelection(); + break; } } @@ -171,14 +220,19 @@ private void SetFlyoutOpen(bool isOpen = true) if (Item?.Children is null) return; var flyoutOwner = (GetTemplateChild("ElementGrid") as FrameworkElement)!; - if (isOpen) - { - FlyoutBase.ShowAttachedFlyout(flyoutOwner); - } - else + try { - FlyoutBase.GetAttachedFlyout(flyoutOwner).Hide(); + if (isOpen) + { + FlyoutBase.ShowAttachedFlyout(flyoutOwner); + } + else + { + FlyoutBase.GetAttachedFlyout(flyoutOwner).Hide(); + } } + // ArgumentException when GetAttachedFlyout/ShowAttachedFlyout runs before the template is applied (e.g. DisplayMode toggled via ToggleSidebarAction at startup, before all containers have realized). + catch (ArgumentException) { } } private void ChildItems_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) @@ -191,54 +245,57 @@ private void ChildItems_CollectionChanged(object? sender, System.Collections.Spe } } + // Entry point for SidebarView's SelectedItem PropertyChangedCallback to broadcast selection changes to every realized row, bypassing the per-row RegisterPropertyChangedCallback (which only attaches after Loaded). + internal void ReevaluateSelectionFromOwner() => ReevaluateSelection(); + private void ReevaluateSelection() { - if (!IsGroupHeader) + // Leaves-with-children (tree-view folder rows) can be selected themselves as well as host a selected descendant. + var isLeafWithChildren = Item?.IsLeafWithChildren == true; + var selected = Owner?.SelectedItem; + if (!IsGroupHeader || isLeafWithChildren) { - IsSelected = Item == Owner?.SelectedItem; + // Item-null guard avoids the null==null match that paints cleared/recycled containers as selected when SelectedItem is also null (e.g. after collapsing the section that held the active path). + IsSelected = Item is not null && Item == selected; if (IsSelected) { Owner?.UpdateSelectedItemContainer(this); } } - else if (Item?.Children is IList list) + else { - if (list.Contains(Owner?.SelectedItem)) - { - selectedChildItem = Owner?.SelectedItem; - SetFlyoutOpen(false); - } - else - { - selectedChildItem = null; - } - UpdateSelectionState(); + // Recycled container previously bound to a selected leaf carries IsSelected=true into its new section-header binding; left unset, the header paints selected alongside the actual selected row after Compact↔overlay flips rebuild the flat list. + IsSelected = false; } + if (IsGroupHeader && Item?.Children is IList list && selected is not null && list.Contains(selected)) + { + selectedChildItem = selected; + SetFlyoutOpen(false); + } + else + { + selectedChildItem = null; + } + UpdateSelectionState(); } - private void ChildrenPresenter_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) + // Flyout items live outside the flat list and need their selection state mirrored here so the realized row matches what the inline row would render. + private void FlyoutChildrenPresenter_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) { - if (args.Element is SidebarItem item) + if (args.Element is SidebarItem item && Item?.Children is IList enumerable) { - if (Item?.Children is IList enumerable) - { - var newElement = enumerable[args.Index]; - if (newElement == selectedChildItem) - { - (args.Element as SidebarItem)!.IsSelected = true; - } - else - { - (args.Element as SidebarItem)!.IsSelected = false; - } - item.HandleItemChange(); - } + // Inherit the owning SidebarView so the flyout row's click routes to the correct view's RaiseItemInvoked instead of falling through to a FindAscendant walk that — inside a popup-hosted ItemsRepeater — can resolve to the wrong SidebarView entirely. + item.Owner = Owner; + var newElement = enumerable[args.Index]; + item.IsSelected = newElement == selectedChildItem; + item.HandleItemChange(); } } internal void Clicked(PointerUpdateKind pointerUpdateKind) { - if (IsGroupHeader) + // Section headers (Pinned, Drives, ...) toggle expansion on row click since they have no navigation target. Tree-view folder rows (leaves-with-children) only navigate — their expansion is reserved for the chevron click target. + if (IsGroupHeader && Item?.IsLeafWithChildren != true) { if (CollapseEnabled) { @@ -252,6 +309,21 @@ internal void Clicked(PointerUpdateKind pointerUpdateKind) RaiseItemInvoked(pointerUpdateKind); } + // Chevron press: suppress the bubbling press; otherwise ElementBorder treats the chevron click as a row click and raises ItemInvoked. + private void ChevronContainer_PointerPressed(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + => e.Handled = TryToggleExpansion(); + + private void ItemBorder_DoubleTapped(object sender, Microsoft.UI.Xaml.Input.DoubleTappedRoutedEventArgs e) + => e.Handled = TryToggleExpansion(); + + private bool TryToggleExpansion() + { + if (!HasChildren || !CollapseEnabled) + return false; + IsExpanded = !IsExpanded; + return true; + } + internal void RaiseItemInvoked(PointerUpdateKind pointerUpdateKind) { Owner?.RaiseItemInvoked(this, pointerUpdateKind); @@ -259,32 +331,32 @@ internal void RaiseItemInvoked(PointerUpdateKind pointerUpdateKind) private void SidebarDisplayModeChanged(SidebarDisplayMode oldValue) { - var useAnimations = oldValue != SidebarDisplayMode.Minimal; switch (DisplayMode) { case SidebarDisplayMode.Expanded: - UpdateExpansionState(useAnimations); + UpdateExpansionState(); UpdateSelectionState(); SetFlyoutOpen(false); break; case SidebarDisplayMode.Minimal: - UpdateExpansionState(useAnimations); + UpdateExpansionState(); SetFlyoutOpen(false); break; case SidebarDisplayMode.Compact: - UpdateExpansionState(useAnimations); + UpdateExpansionState(); UpdateSelectionState(); break; } if (!IsInFlyout) { - VisualStateManager.GoToState(this, DisplayMode == SidebarDisplayMode.Compact ? "Compact" : "NonCompact", true); + VisualStateManager.GoToState(this, DisplayMode == SidebarDisplayMode.Compact ? "Compact" : "NonCompact", false); } } private void UpdateSelectionState() { - VisualStateManager.GoToState(this, ShouldShowSelectionIndicator() ? "Selected" : "Unselected", true); + // Containers re-bind constantly during fast scroll; play state changes without transitions so no implicit animations fire on each ItemsRepeater realization. + VisualStateManager.GoToState(this, ShouldShowSelectionIndicator() ? "Selected" : "Unselected", false); UpdatePointerState(); } @@ -305,40 +377,40 @@ private void UpdatePointerState(bool isPointerDown = false) var useSelectedState = ShouldShowSelectionIndicator(); if (isPointerDown) { - VisualStateManager.GoToState(this, useSelectedState ? "PressedSelected" : "Pressed", true); + VisualStateManager.GoToState(this, useSelectedState ? "PressedSelected" : "Pressed", false); } else if (isPointerOver) { - VisualStateManager.GoToState(this, useSelectedState ? "PointerOverSelected" : "PointerOver", true); + VisualStateManager.GoToState(this, useSelectedState ? "PointerOverSelected" : "PointerOver", false); } else { - VisualStateManager.GoToState(this, useSelectedState ? "NormalSelected" : "Normal", true); + VisualStateManager.GoToState(this, useSelectedState ? "NormalSelected" : "Normal", false); } } - private void UpdateExpansionState(bool useAnimations = true) + private void UpdateExpansionState() { + if (Owner?.SupportsExpansion == false) + { + VisualStateManager.GoToState(this, "NoExpansion", false); + UpdateSelectionState(); + return; + } + if (Item?.Children is null || !CollapseEnabled) { - VisualStateManager.GoToState(this, Item?.PaddedItem == true ? "NoExpansionWithPadding" : "NoExpansion", useAnimations); + VisualStateManager.GoToState(this, "NoExpansion", false); } else if (!HasChildren) { - VisualStateManager.GoToState(this, "NoChildren", useAnimations); + // Empty folder leaves render like normal leaves; empty group headers keep the section-heading style. + VisualStateManager.GoToState(this, Item?.IsLeafWithChildren == true ? "NoExpansion" : "NoChildren", false); } else { - if (Item?.Children is IList enumerable && enumerable.Count > 0 && childrenRepeater is not null) - { - var firstChild = childrenRepeater.GetOrCreateElement(0); - - // Collapsed elements might have a desired size of 0 so we need to have a sensible fallback - var childHeight = firstChild.DesiredSize.Height > 0 ? firstChild.DesiredSize.Height : 32; - ChildrenPresenterHeight = enumerable.Count * childHeight; - } - VisualStateManager.GoToState(this, IsExpanded ? "Expanded" : "Collapsed", useAnimations); - VisualStateManager.GoToState(this, IsExpanded ? "ExpandedIconNormal" : "CollapsedIconNormal", useAnimations); + VisualStateManager.GoToState(this, Item?.IsLeafWithChildren == true ? "LeafWithChildren" : (IsExpanded ? "Expanded" : "Collapsed"), false); + VisualStateManager.GoToState(this, IsExpanded ? "ExpandedIconNormal" : "CollapsedIconNormal", false); } UpdateSelectionState(); } diff --git a/src/Files.App.Controls/Sidebar/SidebarStyles.xaml b/src/Files.App.Controls/Sidebar/SidebarStyles.xaml index a51d65ef1ac2..7f5dd3738777 100644 --- a/src/Files.App.Controls/Sidebar/SidebarStyles.xaml +++ b/src/Files.App.Controls/Sidebar/SidebarStyles.xaml @@ -38,13 +38,16 @@ + AutomationProperties.AutomationId="{Binding Item.Text}" + ContentOpacity="{Binding RowOpacity}" + Decorator="{Binding Item.ItemDecorator}" + Icon="{Binding Item.IconElement}" + IsExpanded="{Binding Item.IsExpanded, Mode=TwoWay}" + Item="{Binding Item}" + Margin="{Binding SectionGapMargin}" + NestingLevel="{Binding Depth}" + Text="{Binding Item.Text}" + ToolTip="{Binding Item.ToolTip}" />