Skip to content

ListBox.SelectedItem resets to null when initially hidden control is first shown with IsSelected binding #20375

@dabblob

Description

@dabblob

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:

  1. The issue only occurs on the first time the hidden ListBox becomes visible
  2. Once shown, subsequent hide/show cycles work correctly
  3. The bound IsSelected property in the view model appears to remain true (no PropertyChanged event fires when SelectedItem resets)
  4. Removing the IsSelected binding prevents the issue
  5. A visible ListBox bound to the same view model maintains selection correctly until it resets by the hidden ListBox

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:

  1. Run the application with the code above
  2. Observe that the right ListBox shows Item 2 selected (correct)
  3. Expand the Expander to show the left ListBox
  4. Observe that the left ListBox reseting the MainViewModel.SelectedItem
  5. Collapse and re-expand the Expander - selection now works correctly
  6. Observe that the left ListBox no longer has SelectedItem but the item container IsSelected remains true

Expected behavior

  1. Showing a ListBox for the first time should preserve the SelectedItem, regardless of initial visibility state
  2. Selection should remain synchronized between visible and initially-hidden ListBox instances bound to the same view model
  3. The IsSelected binding should not interfere with SelectedItem management

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions