-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Description
Describe the bug
Description
When a ListBox is initially hidden (e.g., inside a collapsed Expander) at application startup, and ListBoxItem.IsSelected is bound to a view model property, the ListBox.SelectedItem (as well as SelectedItem property) incorrectly resets to null when the control is first rendered/shown.
Key observations:
- The issue only occurs on the first time the hidden
ListBoxbecomes visible - Once shown, subsequent hide/show cycles work correctly
- The bound
IsSelectedproperty in the view model appears to remain true (noPropertyChangedevent fires whenSelectedItemresets) - Removing the
IsSelectedbinding prevents the issue - A visible
ListBoxbound to the same view model maintains selection correctly until it resets by the hiddenListBox
To Reproduce
Reproduction
XAML:
<Grid ColumnDefinitions="*,*" Background="DimGray">
<!-- Initially collapsed - problematic ListBox -->
<Expander VerticalAlignment="Top" HorizontalAlignment="Stretch">
<ListBox ItemsSource="{Binding Items}"
SelectedItem="{Binding SelectedItem}">
<ListBox.ItemContainerTheme>
<ControlTheme TargetType="ListBoxItem"
BasedOn="{StaticResource {x:Type ListBoxItem}}"
x:DataType="vm:ItemViewModel">
<Setter Property="IsSelected"
Value="{Binding IsSelected, Mode=TwoWay}" />
</ControlTheme>
</ListBox.ItemContainerTheme>
</ListBox>
</Expander>
<!-- Always visible - works correctly -->
<ListBox Grid.Column="1"
ItemsSource="{Binding Items}"
SelectedItem="{Binding SelectedItem}" />
</Grid>
ViewModel:
public partial class MainViewModel : ObservableObject
{
public ObservableCollection<ItemViewModel> Items { get; set; }
[ObservableProperty]
private ItemViewModel? selectedItem;
public MainViewModel()
{
Items = new ObservableCollection<ItemViewModel>
{
new ItemViewModel("Item 1", 0) { IsSelected = false },
new ItemViewModel("Item 2", 1) { IsSelected = true }, // Pre-selected
new ItemViewModel("Item 3", 2) { IsSelected = false }
};
SelectedItem = Items[1]; // Set initial selection
}
}
public partial class ItemViewModel : ObservableObject
{
[ObservableProperty]
private bool isSelected;
public int Index { get; set; }
public string Name { get; set; }
public ItemViewModel(string name, int index)
{
Name = name;
Index = index;
}
}
Steps to reproduce:
- Run the application with the code above
- Observe that the right ListBox shows Item 2 selected (correct)
- Expand the Expander to show the left ListBox
- Observe that the left ListBox reseting the
MainViewModel.SelectedItem - Collapse and re-expand the Expander - selection now works correctly
- Observe that the left ListBox no longer has
SelectedItembut the item containerIsSelectedremains true
Expected behavior
- Showing a
ListBoxfor the first time should preserve theSelectedItem, regardless of initial visibility state - Selection should remain synchronized between visible and initially-hidden
ListBoxinstances bound to the same view model - The
IsSelectedbinding should not interfere withSelectedItemmanagement
Avalonia version
11.3.10
OS
Windows
Additional context
Root cause investigation:
I've traced the issue to Avalonia.Controls/Primitives/SelectingItemsControl.cs in the UpdateSelection method. When the Expander expands and the ListBox is first realized/rendered, the following code path executes:
protected void UpdateSelection(
int index,
bool select = true,
bool rangeModifier = false,
bool toggleModifier = false,
bool rightButton = false,
bool fromFocus = false)
{
if (index < 0 || index >= ItemCount)
{
return;
}
// ... mode setup code ...
if (!select) // This condition is TRUE on first render
{
Selection.Deselect(index); // ← This deselects the item
}
// ... rest of method
}
Theory:
The IsSelected binding is being applied too early in the initialization cycle, before the selection state is properly established. This causes the framework to process the existing selection as a deselection command.
Timing workaround confirmation:
I was able to avoid the issue by delaying the IsSelected binding with Dispatcher.UIThread.Post:
public class OutlinerListBoxItemAdv : ListBoxItem
{
public static readonly StyledProperty<DataTemplate> ControlBoxTemplateProperty =
AvaloniaProperty.Register<OutlinerListBoxItemAdv, DataTemplate>(
nameof(ControlBoxTemplate),
null
);
public DataTemplate ControlBoxTemplate
{
get => GetValue(ControlBoxTemplateProperty);
set => SetValue(ControlBoxTemplateProperty, value);
}
private IDisposable? _isSelectedBindingSubscription;
public OutlinerListBoxItemAdv()
{
this.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
this.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
this.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
DataContextChanged += OnDataContextChanged;
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (Parent is OutlinerListBox listBox && listBox.AllowReorder)
{
var dragManager = listBox.DragDropManager;
dragManager?.OnItemPointerPressed(this, e);
}
}
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (Parent is OutlinerListBox listBox && listBox.AllowReorder)
{
var dragManager = listBox.DragDropManager;
dragManager?.OnItemPointerMoved(this, e);
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (Parent is OutlinerListBox listBox && listBox.AllowReorder)
{
var dragManager = listBox.DragDropManager;
dragManager?.OnItemPointerReleased(this, e);
}
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
ApplyIsSelectedBinding();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyIsSelectedBinding();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
// Dispose the binding subscription
_isSelectedBindingSubscription?.Dispose();
_isSelectedBindingSubscription = null;
}
private void ApplyIsSelectedBinding()
{
Dispatcher.UIThread.Post(
(
() =>
{
if (
Parent is OutlinerListBox
{
ItemTemplate: DataTemplateEx
{
IsSelectedBinding: not null
} templateEx
}
)
{
_isSelectedBindingSubscription?.Dispose();
_isSelectedBindingSubscription = this.Bind(
IsSelectedProperty,
templateEx.IsSelectedBinding
);
}
}
)
);
}
}
This workaround confirms the timing/initialization theory - by deferring the binding to the next dispatcher cycle, the selection state is properly initialized before the IsSelected binding is applied. However, this workaround only works because I have control over when the binding is applied through a custom template and list item container.
Scope: This behavior is also observed in TreeView, suggesting the issue exists in the base SelectingItemsControl class and affects all derived controls.