2

My WPF MVVM VB.NET app loads a list of songs into a ListBox at start. The list contents populate in a BackgroundWorker that is kicked off in the Constructor of the ViewModel. Once this is done, I want to set focus to the first song in the list.

As setting this focus is purely a View operation, I want it in the code-behind of the XAML. It's no business of the ViewModel where focus goes.

I tried doing this on various Window and ListBox events, but they either don't fire, or fire too early. So I'm think what I need is a Boolean Property that the ViewModel sets when it's done loading the songs into the list. That's when I need the View to catch that Property Change, and call the code-behind function that has the logic to maniuplate the View, in the is case, setting focus on the first song in the list.

But this is where my knowledge of WPF is short. I searched and it sounds like DataTrigger could do the trick. But where to put it, and what's the right syntax, and how to have it call my code-behind function?

Or is there an even simpler way that I'm overlooking. This seems like a basic functionality - to trigger some code-behind action in the View when a Property changes a certain way in the ViewModel.

Here's the code-behind function. I can elaborate it once it's successfully getting called at the intended time:

Private Sub FocusSongsList()
    ' set focus back to the Songs list, selected item (couldn't just set focus to the list, it ran forever and looks like it set focus to every item in turn before releasing the UI)
    Dim listBoxItem = CType(LstSongs.ItemContainerGenerator.ContainerFromItem(LstSongs.SelectedItem), ListBoxItem)
    If Not listBoxItem Is Nothing Then
        listBoxItem.Focus()
    End If
End Sub

Here's my ListBox:

<ListBox x:Name="LstSongs" ItemsSource="{Binding FilteredSongs}" DisplayMemberPath="Path" 
            HorizontalAlignment="Stretch"
            SelectionMode="Extended" SelectionChanged="LstSongs_SelectionChanged" Loaded="FocusSongsList"/>

And I would define a new property that can be set from the RunWorkerCompleted part of the BackgroundWorker.

Private _InitialSongLoadCompleted As Boolean
Public Property InitialSongLoadCompleted() As Boolean
    Get
        Return _InitialSongLoadCompleted
    End Get
    Set(ByVal value As Boolean)
        _InitialSongLoadCompleted = value
        RaisePropertyChanged("InitialSongLoadCompleted")
    End Set
End Property
Sandra
  • 608
  • 2
  • 11
  • 23
  • Is this a one off thing or do you ever replace your list after you showed it the first time? – Andy Jan 27 '23 at 16:02
  • 2
    Have you considered putting a custom event on your viewmodel and subscribing to it in your view? I generally think of datatriggers as being purely XAML-land things. – Craig Jan 27 '23 at 16:16
  • DataTrigger can't execute methods. It can only set properties. Focus can be activated by setting a property, therefore a DataTrigger can't solve your problem. From your explanation it sounds like you only want to focus after initialization? You generally shouldn't start threads from a constructor. If you have longrunning initialization routines you should move them to an init routine (which could be async) or use Lazy. You instantiate your view model class and call Initialize() afterwards. When the method returns you can continue to initialize the ListBox. – BionicCode Jan 27 '23 at 20:00
  • @BionicCode I think you mean focus CAN'T be activated by setting a property. :) – Emperor Eto Jan 27 '23 at 20:07
  • @craig that idea idea is really good, I think the best (certainly most straightforward) of anything proposed so far. I'd flesh that out a tiny bit more and make it an answer if I were you. – Emperor Eto Jan 27 '23 at 20:11
  • 2
    @PeterMoore Yes, thank you. I mistyped that part. Of course it is *"Focus __can't__ be activated by setting a property, therefore a DataTrigger can't solve your problem."* – BionicCode Jan 27 '23 at 20:17
  • Cheers. Like commas, 't's save lives. :D Also if one really wanted to use a DataTrigger, you COULD make a custom attached property to take care of the Focus issue. Personally I endorse that approach generally because the frustration of not being able to set focus through a property comes up soooo often. Good example of how to do it if the OP or anyone is interested: https://stackoverflow.com/questions/1356045/set-focus-on-textbox-in-wpf-from-view-model – Emperor Eto Jan 27 '23 at 20:50
  • @PeterMoore I guess it's a design choice. Moving focus is not trivial. It definitely is more than just setting a property of a type. It's a full-fledged natural operation. It's best practice to not use properties to execute operations. Properties have a different purpose. I would say it's a good example of the Microsoft language guidelines. – BionicCode Jan 27 '23 at 21:19
  • 1
    @PeterMoore Normally there is no need to set focus from the UI (XAML). Focus is usually set by the user via mouse clicks or keyboard/touch navigation. I don't know the UI of the OP, but it's very very likely that he shouldn't set the focus in the first place, for the sake of a good UX. Setting the SelectedItem is sufficient. – BionicCode Jan 27 '23 at 21:19
  • 1
    @PeterMoore *"'t's save lives"* - Trust me, you can cross the street right now. – BionicCode Jan 27 '23 at 21:24

3 Answers3

1

It has been a long long time since I wrote much VB, so I'm afraid this is c# code.

You can handle targetupdated on a binding.

This fires when data transfers from the source ( viewmodel property ) to the target (the ui property and here itemssource)

    <ListBox 
        x:Name="LstSongs"
        ItemsSource="{Binding Songs, NotifyOnTargetUpdated=True}"
        TargetUpdated="ListBox_TargetUpdated"/>

When you replace your list, that targetupdated will fire. If you raise property changed then the data will transfer ( obviously ).

    private async void ListBox_TargetUpdated(object sender, DataTransferEventArgs e)
    {
        await Task.Delay(200);
        var firstItem = (ListBoxItem)LstSongs.ItemContainerGenerator
                          .ContainerFromItem(LstSongs.Items[0]);
        firstItem.Focus();
        Keyboard.Focus(firstItem);
    }

As that data transfers, there will initially be no items at all of course so we need a bit of a delay. Hence that Task.Delay which will wait 200ms and should let the UI render. You could make that a bit longer or dispatcher.invokeasync.

It finds the first container and sets focus plus keyboard focus. It might not be at all obvious that item has focus.

A more elegant approach using dispatcher will effectively schedule this focussing until after the ui has rendered. It might, however, look rather tricky to someone unfamiliar with c#

    private void ListBox_TargetUpdated(object sender, DataTransferEventArgs e)
    {
        Dispatcher.CurrentDispatcher.InvokeAsync(new Action(() =>
        {
            var firstItem = (ListBoxItem)LstSongs.ItemContainerGenerator
                              .ContainerFromItem(LstSongs.Items[0]);
            firstItem.Focus();
            Keyboard.Focus(firstItem);
        }), DispatcherPriority.ContextIdle);
    }

If you want a blue background then you could select the item.

  firstItem.IsSelected = true;

Or you could use some datatrigger and styling working with IsFocused.

https://learn.microsoft.com/en-us/dotnet/api/system.windows.uielement.isfocused?view=windowsdesktop-7.0

( Always distracts me that, one s rather than two and IsFocussed. That'll be US english I gues )

Here's my mainwindowviewmodel

public partial class MainWindowViewModel : ObservableObject
{
    [ObservableProperty]
    private List<string> songs = new List<string>();

    MainWindowViewModel()
    {
        Task.Run(() => { SetupSongs(); });
    }

    private async Task SetupSongs()
    {
        await Task.Delay(1000);
        Songs = new List<string> { "AAA", "BBB", "CCC" };
    }
}

I'm using the comunity toolkit mvvm for code generation. Maybe it does vb as well as c#.

Andy
  • 11,864
  • 2
  • 17
  • 20
  • I think I tried TargetUpdated, but it didn't trigger, so I abandonned it. I may try again with all the new information here! Don't worry, at this point I can convert most C# quite well. – Sandra Jan 28 '23 at 00:47
1

A DataTrigger can't execute methods. It can only set properties.
Focus can't be activated by setting a property, therefore a DataTrigger can't solve your problem.

Generally, if you have longrunning initialization routines you should move them to an init routine (which could be async) or use Lazy<T>. For example, you instantiate your view model class and call Initialize() afterwards. After the method has returned you can continue to initialize the ListBox:

MainWindow.xaml.cs

Partial Class MainWindow
    Inherits Window

    Private ReadOnly Property MainViewModel As MainViewModel

    Public Sub New(ByVal dataContext As TestViewModel, ByVal navigator As INavigator)
        InitializeComponent()
        Me.MinViewModel = New MainViewMdel()
        Me.DataContext = Me.MainViewModel
        AddHandler Me.Loaded, AddressOf OnLoaded
    End Sub

    ' Use the Loaded event to call async methods outside the constructor
    Private Async Sub OnLoaded(ByVal sender As Object, ByVal e As EventArgs)
        Await mainViewModel.InitializeAsync()

        ' For example handle initial focus
        InitializeListBox()
    End Sub
End Class

MainViewModel.cs

Class MainViewModel
    Inherits INotifyPropertyChanged

    Public Async Function InitializeAsync() As Task
        Await Task.Run(AddressOf InitializeSongList)
    End Function

    Private Sub InitializeSongList()
        ' TODO::Initialize song list
    End Sub
End Class

BionicCode
  • 1
  • 4
  • 28
  • 44
  • Interesting! I guess I was making my constructor quick by throwing the intensive part into a backgroundWorker. This is another way, plus it allows the view to initiate the initialization. – Sandra Jan 28 '23 at 00:44
1

You might accomplish your goal by defining a custom event in the viewmodel which is raised when the list processing is complete. The view can subscribe to it and act accordingly.

It would look something like this:

Class MyViewModel
    'Custom eventargs shown for completeness, you can use EventHandler if you
    'don't need any custom eventargs.
    Public Event ListCompleted As EventHandler(Of ListCompletedEventArgs)

    '...

    Public Sub ProcessSongList()
        'Note that if this runs on a background thread, you may need to
        'get back on the UI thread to raise an event for the view to handle

        RaiseEvent ListCompleted(Me, New ListCompletedEventArgs())
    End Sub
End Class

Class MyView
    Public Sub New(ByVal vm as MyViewModel)
        Me.DataContext = vm
        AddHandler vm.ListCompleted, AddressOf OnListCompleted
    End Sub

    Private Sub OnListCompleted(ByVal sender As Object, ByVal args As ListCompletedEventArgs)
        '...
    End Sub

    '...
End Class

You mentioned doing processing on a background thread. I'm not completely sure which thread the completion event would issue into, but beware that UI stuff can only happen on the UI thread so you might need to use a Dispatcher.Invoke to make sure your code runs on the right thread. I'd do it to run the RaiseEvent so the view doesn't need to know anything about it.

Craig
  • 2,248
  • 1
  • 19
  • 23
  • I like this one - it's really simple to implement, and makes sense too. After all, the list completing its loading is an 'event' that happens. – Sandra Jan 28 '23 at 00:45
  • I wish I could have accepted this answer too. I'm sure I will use it at some point. Thanks! – Sandra Feb 01 '23 at 00:18