Browse Source

Fix some issues on SelectingItemsControls when items or control changes visibility (#20798)

* Add failing test for #14718

* Fix for AutoScrollToSelectedItemIfNecessary

* fix failing CI build and move test to the right location

* add failing test for TabItem selection of invisble tab

* introduce a helper method to figure out which item to select

when nothing was selected beforehand and AlwaysSelected is true

* ensure selection works for invisible tabcontrol

* simplify conditions

* propose: Remove redundant logic from ColorView

The TabItem now handles the correct selection of only visible items

* fix test: Need to set SelectedIndex after adding items

* re-add unused method and make it obsolete

Otherwise API-diff will fail.

* Address review

* Update tests/Avalonia.Controls.UnitTests/TabControlTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Avalonia.Controls.UnitTests/TabControlTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Adress copilot review

* fix duplicate braces

* address review

* fix for failing tests on CarouselPage and TabbedPage

* address feedback

- adding more tests to avoid regressions

* Fix failing test: add UnitTestApplication.Start() to dedicated thread test

Agent-Logs-Url: https://github.com/timunie/Avalonia/sessions/fe2f1190-6d20-4982-8a03-1ae9b52ee701

Co-authored-by: timunie <47110241+timunie@users.noreply.github.com>

* Refactor SelectingItemsControl auto-scroll duplicate logic

Agent-Logs-Url: https://github.com/timunie/Avalonia/sessions/8895cb60-ef41-473c-972b-bad2483a5a77

Co-authored-by: timunie <47110241+timunie@users.noreply.github.com>

* Add new failing tests for AlwaysSelected mode

* Fix AlwaysSelected scenarios

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
pull/21876/head
Tim 2 months ago
committed by GitHub
parent
commit
71ded23911
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 128
      src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs
  2. 9
      src/Avalonia.Controls/Page/CarouselPage.cs
  3. 9
      src/Avalonia.Controls/Page/TabbedPage.cs
  4. 213
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  5. 1
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  6. 311
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  7. 37
      tests/Avalonia.Controls.UnitTests/TabControlTests.cs

128
src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs

@ -1,10 +1,9 @@
using System;
using System;
using System.Collections.Generic;
using Avalonia.Controls.Converters;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Media;
using Avalonia.Threading;
namespace Avalonia.Controls
{
@ -12,7 +11,6 @@ namespace Avalonia.Controls
/// Presents a color for user editing using a spectrum, palette and component sliders.
/// </summary>
[TemplatePart("PART_HexTextBox", typeof(TextBox))]
[TemplatePart("PART_TabControl", typeof(TabControl))]
public partial class ColorView : TemplatedControl
{
/// <summary>
@ -22,7 +20,6 @@ namespace Avalonia.Controls
// XAML template parts
private TextBox? _hexTextBox;
private TabControl? _tabControl;
protected bool _ignorePropertyChanged = false;
@ -70,106 +67,16 @@ namespace Avalonia.Controls
}
/// <summary>
/// Validates the tab/panel/page selection taking into account the visibility of each item
/// as well as the current selection.
/// <b>Obsolete. No-op.</b> This method is no longer used and will be removed in a future release.
/// </summary>
/// <remarks>
/// Derived controls may re-implement this based on their default style / control template
/// and any specialized selection needs.
/// This method does nothing and should not be overridden or relied upon. Validation is now handled by TabControl.
/// </remarks>
// TODO-13: Remove this unused method
[Obsolete("The necessary validation is now handled by the TabControl. This method will be removed in the next major release.")]
protected virtual void ValidateSelection()
{
if (_tabControl != null &&
_tabControl.Items != null)
{
// Determine the number of visible tab items
int numVisibleItems = 0;
foreach (var item in _tabControl.Items)
{
if (item is Control control &&
control.IsVisible)
{
numVisibleItems++;
}
}
// Verify the selection
if (numVisibleItems > 0)
{
object? selectedItem = null;
if (_tabControl.SelectedItem == null &&
_tabControl.ItemCount > 0)
{
// As a failsafe, forcefully select the first item
foreach (var item in _tabControl.Items)
{
selectedItem = item;
break;
}
}
else
{
selectedItem = _tabControl.SelectedItem;
}
if (selectedItem is Control selectedControl &&
selectedControl.IsVisible == false)
{
// Select the first visible item instead
foreach (var item in _tabControl.Items)
{
if (item is Control control &&
control.IsVisible)
{
selectedItem = item;
break;
}
}
}
_tabControl.SelectedItem = selectedItem;
_tabControl.IsVisible = true;
}
else
{
// Special case when all items are hidden
// If TabControl ever properly supports no selected item /
// all items hidden this can be removed
_tabControl.SelectedItem = null;
_tabControl.IsVisible = false;
}
// Hide the "tab strip" if there is only one tab
// This allows, for example, to view only the palette
/*
var itemsPresenter = _tabControl.FindDescendantOfType<ItemsPresenter>();
if (itemsPresenter != null)
{
if (numVisibleItems == 1)
{
itemsPresenter.IsVisible = false;
}
else
{
itemsPresenter.IsVisible = true;
}
}
*/
// Note that if externally the SelectedIndex is set to 4 or something
// outside the valid range, the TabControl will ignore it and replace it
// with a valid SelectedIndex. This however is not propagated back through
// the TwoWay binding in the control template so the SelectedIndex and
// SelectedIndex become out of sync.
//
// The work-around for this is done here where SelectedIndex is forcefully
// synchronized with whatever the TabControl property value is. This is
// possible since selection validation is already done by this method.
SetCurrentValue(SelectedIndexProperty, _tabControl.SelectedIndex);
}
return;
// Obsolete: no-op. Will be removed in a future release.
}
/// <inheritdoc/>
@ -182,7 +89,6 @@ namespace Avalonia.Controls
}
_hexTextBox = e.NameScope.Find<TextBox>("PART_HexTextBox");
_tabControl = e.NameScope.Find<TabControl>("PART_TabControl");
SetColorToHexTextBox();
@ -193,7 +99,6 @@ namespace Avalonia.Controls
}
base.OnApplyTemplate(e);
ValidateSelection();
}
/// <inheritdoc/>
@ -260,27 +165,6 @@ namespace Avalonia.Controls
// (Color will be coerced automatically if HsvColor changes)
SetCurrentValue(HsvColorProperty, OnCoerceHsvColor(HsvColor));
}
else if (change.Property == IsColorComponentsVisibleProperty ||
change.Property == IsColorPaletteVisibleProperty ||
change.Property == IsColorSpectrumVisibleProperty)
{
// When the property changed notification is received here the visibility
// of individual tab items has not yet been updated through the bindings.
// Therefore, the validation is delayed until after bindings update.
Dispatcher.UIThread.Post(() =>
{
ValidateSelection();
}, DispatcherPriority.Background);
}
else if (change.Property == SelectedIndexProperty)
{
// Again, it is necessary to wait for the SelectedIndex value to
// be applied to the TabControl through binding before validation occurs.
Dispatcher.UIThread.Post(() =>
{
ValidateSelection();
}, DispatcherPriority.Background);
}
base.OnPropertyChanged(change);
}

9
src/Avalonia.Controls/Page/CarouselPage.cs

@ -129,6 +129,8 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
var requestedIndex = SelectedIndex;
if (_carousel != null)
{
_carousel.SelectionChanged -= OnCarouselSelectionChanged;
@ -139,7 +141,6 @@ namespace Avalonia.Controls
if (_carousel != null)
{
_carousel.SelectionChanged += OnCarouselSelectionChanged;
_carousel.ContainerPrepared += OnCarouselContainerPrepared;
_carousel.PageTransition = PageTransition;
_carousel.ItemsPanel = ItemsPanel;
@ -147,11 +148,13 @@ namespace Avalonia.Controls
_carousel.IsSwipeEnabled = IsGestureEnabled;
_carousel.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
if (SelectedIndex >= 0)
if (requestedIndex >= 0)
{
_carousel.SelectedIndex = SelectedIndex;
_carousel.SelectedIndex = requestedIndex;
}
_carousel.SelectionChanged += OnCarouselSelectionChanged;
UpdateActivePage();
}
}

9
src/Avalonia.Controls/Page/TabbedPage.cs

@ -193,6 +193,8 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
var requestedIndex = SelectedIndex;
if (_tabControl != null)
{
_tabControl.SelectionChanged -= TabControl_SelectionChanged;
@ -206,13 +208,14 @@ namespace Avalonia.Controls
if (_tabControl != null)
{
_tabControl.SelectionChanged += TabControl_SelectionChanged;
_tabControl.ContainerPrepared += OnContainerPrepared;
_tabControl.ContainerClearing += OnContainerClearing;
_tabControl.ItemsSource = (IEnumerable?)ItemsSource ?? Pages;
if (SelectedIndex >= 0)
_tabControl.SelectedIndex = SelectedIndex;
if (requestedIndex >= 0)
_tabControl.SelectedIndex = requestedIndex;
_tabControl.SelectionChanged += TabControl_SelectionChanged;
if (PageTransition != null)
_tabControl.PageTransition = PageTransition;

213
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -5,7 +5,6 @@ using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Selection;
using Avalonia.Controls.Utils;
using Avalonia.Data;
@ -153,6 +152,7 @@ namespace Avalonia.Controls.Primitives
private bool _hasScrolledToSelectedItem;
private BindingEvaluator<object?>? _selectedValueBindingEvaluator;
private bool _isSelectionChangeActive;
private int _unverifiedSelectedIndex = -1;
public SelectingItemsControl()
{
@ -466,7 +466,7 @@ namespace Avalonia.Controls.Primitives
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
{
SelectedIndex = 0;
SelectedIndex = ChooseFirstVisibleAndEnabledIndex();
}
}
@ -491,16 +491,13 @@ namespace Avalonia.Controls.Primitives
{
base.OnApplyTemplate(e);
void ExecuteScrollWhenLayoutUpdated(object? sender, EventArgs e)
{
LayoutUpdated -= ExecuteScrollWhenLayoutUpdated;
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
if (AutoScrollToSelectedItem)
{
LayoutUpdated += ExecuteScrollWhenLayoutUpdated;
Dispatcher.UIThread.Post(static state =>
{
var control = (SelectingItemsControl)state!;
control.AutoScrollToSelectedItemIfNecessary(control.GetAnchorIndex());
}, this);
}
}
@ -547,6 +544,18 @@ namespace Avalonia.Controls.Primitives
if (Selection.AnchorIndex == index)
KeyboardNavigation.SetTabOnceActiveElement(this, container);
if (AlwaysSelected)
{
if (SelectedIndex == -1 && container is { IsVisible: true, IsEnabled: true })
{
SelectedIndex = index;
}
else if (index == SelectedIndex && (!container.IsVisible || !container.IsEnabled))
{
MoveSelectionToFirstVisibleAndEnabledItem();
}
}
}
/// <inheritdoc />
@ -629,6 +638,13 @@ namespace Avalonia.Controls.Primitives
{
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
else if (change.Property == IsVisibleProperty)
{
if (change.GetNewValue<bool>())
{
AutoScrollToSelectedItemIfNecessary(GetAnchorIndex());
}
}
else if (change.Property == SelectionModeProperty && _selection is object)
{
var newValue = change.GetNewValue<SelectionMode>();
@ -1036,6 +1052,8 @@ namespace Avalonia.Controls.Primitives
_selectedItemsBeforeReset = null;
RaiseSelectionChanged(e.DeselectedItems, e.SelectedItems);
VerifySelectedIndex();
}
/// <summary>
@ -1055,7 +1073,7 @@ namespace Avalonia.Controls.Primitives
if (AlwaysSelected && ItemsView.Count > 0)
{
SelectedIndex = 0;
SelectedIndex = ChooseFirstVisibleAndEnabledIndex();
}
}
@ -1183,20 +1201,53 @@ namespace Avalonia.Controls.Primitives
}
}
private int? _pendingAutoScrollAnchorIndex;
private void AutoScrollToSelectedItemIfNecessary(int anchorIndex)
{
if (AutoScrollToSelectedItem &&
!_hasScrolledToSelectedItem &&
Presenter is object &&
anchorIndex >= 0 &&
IsAttachedToVisualTree)
if (!(AutoScrollToSelectedItem && !_hasScrolledToSelectedItem && Presenter != null && anchorIndex >= 0 && IsAttachedToVisualTree))
{
Dispatcher.UIThread.Post(state =>
{
ScrollIntoView((int)state!);
_hasScrolledToSelectedItem = true;
}, anchorIndex);
ClearPendingAutoScroll();
return;
}
if (!IsEffectivelyVisible)
{
// Defer scroll until the control becomes effectively visible.
_pendingAutoScrollAnchorIndex = anchorIndex;
IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll;
IsEffectivelyVisibleChanged += OnIsEffectivelyVisibleChangedForAutoScroll;
return;
}
ClearPendingAutoScroll();
ScrollToAnchorIndex(anchorIndex);
}
private void OnIsEffectivelyVisibleChangedForAutoScroll(object? sender, EventArgs e)
{
if (!IsEffectivelyVisible || _pendingAutoScrollAnchorIndex is not { } anchorIndex)
{
return;
}
ClearPendingAutoScroll();
ScrollToAnchorIndex(anchorIndex);
}
private void ClearPendingAutoScroll()
{
_pendingAutoScrollAnchorIndex = null;
IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll;
}
private void ScrollToAnchorIndex(int anchorIndex)
{
Dispatcher.UIThread.Post(state =>
{
ScrollIntoView((int)state!);
_hasScrolledToSelectedItem = true;
}, anchorIndex);
}
/// <summary>
@ -1243,6 +1294,122 @@ namespace Avalonia.Controls.Primitives
}
}
/// <summary>
/// Finds the first visible and enabled index in the ItemsSource.
/// </summary>
/// <param name="verified">
/// On return, true if the returned index was checked against a realized container or against
/// an item which is its own container; false if no container was realized for it and the item
/// had to be accepted without knowing its visibility and enabled state.
/// </param>
/// <returns>the index of the first visible and enabled item, or -1 if none found</returns>
private int GetFirstVisibleAndEnabledIndex(out bool verified)
{
verified = true;
var count = ItemCount;
if (count == 0)
return -1;
for (var i = 0; i < count; i++)
{
var container = ContainerFromIndex(i);
if (container is not null)
{
if (container is { IsVisible: true, IsEnabled: true })
return i;
continue;
}
var item = ItemsView[i];
if (item is Visual v)
{
if (v.IsVisible && (v is not Control c || c.IsEnabled))
return i;
}
else if (item is not null)
{
// The container isn't realized so its visibility/enabled state is unknown.
verified = false;
return i;
}
}
return -1;
}
private int ChooseFirstVisibleAndEnabledIndex()
{
var index = GetFirstVisibleAndEnabledIndex(out var verified);
_unverifiedSelectedIndex = verified ? -1 : index;
return index;
}
/// <summary>
/// Re-checks a selection which <see cref="ChooseFirstVisibleAndEnabledIndex"/> had to make before the container
/// of the selected item was realized.
/// </summary>
private void VerifySelectedIndex()
{
if (_unverifiedSelectedIndex == -1)
return;
if (!AlwaysSelected)
{
_unverifiedSelectedIndex = -1;
return;
}
// The selection has moved elsewhere in the meantime.
if (_unverifiedSelectedIndex != SelectedIndex)
{
_unverifiedSelectedIndex = -1;
return;
}
// Still not realized: ContainerForItemPreparedOverride will check it.
if (ContainerFromIndex(_unverifiedSelectedIndex) is not { } container)
return;
_unverifiedSelectedIndex = -1;
if (!container.IsVisible || !container.IsEnabled)
{
MoveSelectionToFirstVisibleAndEnabledItem();
}
}
/// <summary>
/// Moves selection to the first visible and enabled item, considering only realized (prepared)
/// containers. If no such container exists, selection is cleared to -1 so that the next
/// valid container prepared in <see cref="ContainerForItemPreparedOverride"/> can pick it up.
/// </summary>
private void MoveSelectionToFirstVisibleAndEnabledItem()
{
var index = GetFirstRealizedVisibleAndEnabledIndex();
if (index != SelectedIndex)
{
SelectedIndex = index;
}
}
/// <summary>
/// Finds the first realized (prepared) container that is both visible and enabled.
/// </summary>
/// <returns>The index of the first qualifying realized container, or -1 if none found.</returns>
private int GetFirstRealizedVisibleAndEnabledIndex()
{
var count = ItemCount;
for (var i = 0; i < count; i++)
{
var container = ContainerFromIndex(i);
if (container is { IsVisible: true, IsEnabled: true })
return i;
}
return -1;
}
private void UpdateContainerSelection()
{
if (Presenter?.Panel is { } panel)
@ -1291,7 +1458,7 @@ namespace Avalonia.Controls.Primitives
if (_updateState is null && AlwaysSelected && model.Count == 0)
{
model.SelectedIndex = 0;
model.SelectedIndex = ChooseFirstVisibleAndEnabledIndex();
}
UpdateContainerSelection();
@ -1398,7 +1565,7 @@ namespace Avalonia.Controls.Primitives
if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
{
SelectedIndex = 0;
SelectedIndex = ChooseFirstVisibleAndEnabledIndex();
}
}
}

1
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@ -2035,6 +2035,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
// https://github.com/xunit/xunit/issues/2222
=> ThreadRunHelper.RunOnDedicatedThread(() =>
{
using var _ = UnitTestApplication.Start();
var target = new TestSelector
{
Template = Template(),

311
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@ -1,9 +1,12 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Threading;
using Avalonia.UnitTests;
using Xunit;
@ -118,6 +121,235 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal("bar", target.SelectedItem);
}
[Fact]
public void First_Visible_Item_Should_Be_Selected_When_First_Container_Is_Hidden()
{
// Uses own-container items (Controls) so that IsVisible can be set before preparation.
var target = new TestSelector
{
ItemsSource = new object[]
{
new ListBoxItem { Content = "hidden", IsVisible = false },
new ListBoxItem { Content = "visible" },
},
Template = Template(),
};
target.ApplyTemplate();
target.Presenter!.ApplyTemplate();
Assert.Equal(1, target.SelectedIndex);
}
[Fact]
public void First_Enabled_Item_Should_Be_Selected_When_First_Container_Is_Disabled()
{
var target = new TestSelector
{
ItemsSource = new object[]
{
new ListBoxItem { Content = "disabled", IsEnabled = false },
new ListBoxItem { Content = "enabled" },
},
Template = Template(),
};
target.ApplyTemplate();
target.Presenter!.ApplyTemplate();
Assert.Equal(1, target.SelectedIndex);
}
[Fact]
public void First_Visible_Item_Should_Be_Selected_When_Container_Becomes_Hidden_During_Preparation()
{
// Regression test for https://github.com/AvaloniaUI/Avalonia/pull/20798
// Simulates a MVVM scenario where container visibility is set by a binding applied
// during PrepareContainerForItemOverride (e.g. an ItemContainerTheme). Verifies that
// selection lands on the first truly visible container rather than an unrealized item.
var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 2)
{
ItemsSource = new[] { "item-0", "item-1", "item-2" },
Template = Template(),
};
target.ApplyTemplate();
target.Presenter!.ApplyTemplate();
Assert.Equal(2, target.SelectedIndex);
}
[Fact]
public void Selection_Should_Be_Cleared_When_All_Containers_Are_Hidden_During_Preparation()
{
// When all containers are made invisible during preparation (MVVM binding scenario),
// SelectedIndex must be -1 rather than the last container's index. Previously the
// selection would cascade to the last unrealized item and land on an invisible one.
var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 3)
{
ItemsSource = new[] { "item-0", "item-1", "item-2" },
Template = Template(),
};
target.ApplyTemplate();
target.Presenter!.ApplyTemplate();
Assert.Equal(-1, target.SelectedIndex);
}
[Fact]
public void First_Visible_Item_Should_Be_Selected_When_Items_Are_Added_To_A_Laid_Out_Control()
{
using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
var items = new AvaloniaList<string>();
var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1);
InitWithBeginEndInit(target, items);
items.Add("item-0");
items.Add("item-1");
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("item-1", target.SelectedItem);
}
[Fact]
public void Selection_Should_Be_Cleared_When_A_Hidden_Item_Is_Added_To_A_Laid_Out_Control()
{
using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
var items = new AvaloniaList<string>();
var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1);
InitWithBeginEndInit(target, items);
items.Add("item-0");
Assert.Equal(-1, target.SelectedIndex);
Assert.Null(target.SelectedItem);
}
[Fact]
public void First_Enabled_Item_Should_Be_Selected_When_Items_Are_Added_To_A_Laid_Out_Control()
{
using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
var items = new AvaloniaList<string>();
var target = new AlwaysSelectedTestSelectorDisablingFirstContainers(disabledCount: 1);
InitWithBeginEndInit(target, items);
items.Add("item-0");
items.Add("item-1");
Assert.Equal(1, target.SelectedIndex);
Assert.Equal("item-1", target.SelectedItem);
}
[Fact]
public void Selection_Should_Settle_On_The_Visible_Item_When_Items_Are_Added_To_A_Laid_Out_Control()
{
using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
var items = new AvaloniaList<string>();
var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1);
InitWithBeginEndInit(target, items);
var selectionChanges = new List<object?>();
target.SelectionChanged += (_, _) => selectionChanges.Add(target.SelectedItem);
items.Add("item-0");
items.Add("item-1");
Assert.Equal("item-1", selectionChanges.Last());
Assert.Equal("item-1", target.SelectedItem);
}
[Fact]
public void AutoScrollToSelectedItem_Should_Work_When_Becoming_Visible()
{
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
{
var items = Enumerable.Range(0, 100).Select(i => $"Item {i}").ToList();
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = items,
ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
Height = 100,
ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
AutoScrollToSelectedItem = true,
IsVisible = false
};
target.Width = target.Height = 100;
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
// Select item 50
target.SelectedIndex = 50;
// Make visible
target.IsVisible = true;
target.UpdateLayout();
// Wait for dispatcher
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
target.UpdateLayout();
var scrollViewer = (ScrollViewer)target.VisualChildren[0];
var offset = scrollViewer.Offset.Y;
// Item 50 is at 50 * 50 = 2500.
// ListBox height is 100, so it should be visible if offset is between 2400 and 2500.
Assert.InRange(offset, 2400, 2500);
}
}
[Fact]
public void AutoScrollToSelectedItem_Should_Work_When_Ancestor_Becomes_Visible()
{
using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
{
var items = Enumerable.Range(0, 100).Select(i => $"Item {i}").ToList();
var target = new ListBox
{
Template = new FuncControlTemplate(CreateListBoxTemplate),
ItemsSource = items,
ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
Height = 100,
ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
AutoScrollToSelectedItem = true,
};
target.Width = target.Height = 100;
var host = new StackPanel
{
IsVisible = false,
Children =
{
target,
},
};
var root = new TestRoot(host);
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectedIndex = 50;
Assert.False(target.IsEffectivelyVisible);
host.IsVisible = true;
root.LayoutManager.ExecuteLayoutPass();
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
root.LayoutManager.ExecuteLayoutPass();
var scrollViewer = (ScrollViewer)target.VisualChildren[0];
var offset = scrollViewer.Offset.Y;
Assert.InRange(offset, 2400, 2500);
}
}
private static FuncControlTemplate Template()
{
return new FuncControlTemplate<SelectingItemsControl>((control, scope) =>
@ -128,6 +360,21 @@ namespace Avalonia.Controls.UnitTests.Primitives
}.RegisterInNameScope(scope));
}
/// <summary>
/// Initializes and lays out <paramref name="target"/> the way it's done in XAML.
/// This is important for some tests, as the selection model isn't committed yet while doing so.
/// </summary>
private static void InitWithBeginEndInit(SelectingItemsControl target, IEnumerable items)
{
target.BeginInit();
target.Template = Template();
target.ItemsSource = items;
target.EndInit();
var root = new TestRoot(target);
root.LayoutManager.ExecuteInitialLayoutPass();
}
private class TestSelector : SelectingItemsControl
{
static TestSelector()
@ -148,5 +395,69 @@ namespace Avalonia.Controls.UnitTests.Primitives
new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
/// <summary>
/// A selector with AlwaysSelected that hides the first N containers during preparation.
/// This simulates a MVVM scenario where an ItemContainerTheme binding sets IsVisible=false on some containers.
/// </summary>
private class AlwaysSelectedTestSelectorHidingFirstContainers(int hiddenCount) : SelectingItemsControl
{
static AlwaysSelectedTestSelectorHidingFirstContainers()
{
SelectionModeProperty.OverrideDefaultValue<AlwaysSelectedTestSelectorHidingFirstContainers>(SelectionMode.AlwaysSelected);
}
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
if (index < hiddenCount)
container.IsVisible = false;
}
}
/// <summary>
/// A selector with AlwaysSelected that disables the first N containers during preparation.
/// This simulates a MVVM scenario where an ItemContainerTheme binding sets IsEnabled=false on some containers.
/// </summary>
private class AlwaysSelectedTestSelectorDisablingFirstContainers(int disabledCount) : SelectingItemsControl
{
static AlwaysSelectedTestSelectorDisablingFirstContainers()
{
SelectionModeProperty.OverrideDefaultValue<AlwaysSelectedTestSelectorDisablingFirstContainers>(SelectionMode.AlwaysSelected);
}
protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
if (index < disabledCount)
container.IsEnabled = false;
}
}
private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope)
{
return new ScrollViewer
{
Name = "PART_ScrollViewer",
Template = new FuncControlTemplate(CreateScrollViewerTemplate),
Content = new ItemsPresenter
{
Name = "PART_ItemsPresenter",
[~ItemsPresenter.ItemsPanelProperty] =
((ListBox)parent).GetObservable(ItemsControl.ItemsPanelProperty).ToBinding(),
}.RegisterInNameScope(scope)
}.RegisterInNameScope(scope);
}
private Control CreateScrollViewerTemplate(TemplatedControl parent, INameScope scope)
{
return new ScrollContentPresenter
{
Name = "PART_ContentPresenter",
[~ContentPresenter.ContentProperty] =
parent.GetObservable(ContentControl.ContentProperty).ToBinding(),
}.RegisterInNameScope(scope);
}
}
}

37
tests/Avalonia.Controls.UnitTests/TabControlTests.cs

@ -1838,5 +1838,42 @@ namespace Avalonia.Controls.UnitTests
// TabItem without a local value gets the TabControl template
Assert.Same(tabControlTemplate, tabItems[1].IndicatorTemplate);
}
[Fact]
public void Only_First_Visible_And_Enabled_Tab_Should_Be_Selected_By_Default()
{
var target = new TabControl
{
Template = TabControlTemplate(),
Items =
{
new TabItem { Header = "hidden", IsVisible = false },
new TabItem { Header = "visible" },
}
};
ApplyTemplate(target);
Assert.Equal(1, target.SelectedIndex);
}
[Fact]
public void Only_First_Enabled_Tab_Should_Be_Selected_By_Default()
{
var target = new TabControl
{
Template = TabControlTemplate(),
Items =
{
new TabItem { Header = "disabled", IsEnabled = false },
new TabItem { Header = "enabled" },
}
};
ApplyTemplate(target);
Assert.Equal(1, target.SelectedIndex);
}
}
}

Loading…
Cancel
Save