Create WinUI3/MVVM Most Recently Used (MRU) List in Menu Bar

614 views Asked by At

I would like to create a classic "Recent Files" list in my Windows app menu bar (similar to Visual Studio's menu bar -> File -> Recent Files -> see recent files list)

The MRU list (List < string > myMRUList...) is known and is not in focus of this question. The problem is how to display and bind/interact with the list according to the MVVM rules.

Microsoft.Toolkit.Uwp.UI.Controls's Menu class will be removed in a future release and they recommend to use MenuBar control from the WinUI. I haven't found any examples, that use WinUI's MenuBar to create a "Recent Files" list.

I'm using Template Studio to create a WinUI 3 app. In the ShellPage.xaml I added

<MenuFlyoutSubItem x:Name="mruFlyout" Text="Recent Files"></MenuFlyoutSubItem> 

and in ShellPage.xaml.c

private void Button_Click(object sender, RoutedEventArgs e)
{
   mruFlyout.Items.Insert(mruFlyout.Items.Count, new MenuFlyoutItem(){ Text = "C:\\Test1_" + DateTime.Now.ToString("MMMM dd") } );
   mruFlyout.Items.Insert(mruFlyout.Items.Count, new MenuFlyoutItem(){ Text = "C:\\Test2_" + DateTime.Now.ToString("MMMM dd") } );
   mruFlyout.Items.Insert(mruFlyout.Items.Count, new MenuFlyoutItem(){ Text = "C:\\Test3_" + DateTime.Now.ToString("MMMM dd") } );
} 

knowing this is not MVVM, but even this approach does not work properly, because the dynamically generated MenuFlyoutItem can be updated only once by Button_Click() event.

Could anybody give me an example, how to create the "Recent Files" functionality, but any help would be great! Thanks

2

There are 2 answers

4
marcelwgn On BEST ANSWER

Unfortunately, it seems that there is no better solution than handling this in code behind since the Items collection is readonly and also doesn't response to changes in the UI Layout.

In addition to that, note that because of https://github.com/microsoft/microsoft-ui-xaml/issues/7797, updating the Items collection does not get reflected until the Flyout has been closed and reopened.

So assuming your ViewModel has an ObservableCollection, I would probably do this:

// 1. Register collection changed
MyViewModel.RecentFiles.CollectionChanged += RecentFilesChanged;

// 2. Handle collection change
private void RecentFilesChanged(object sender, NotifyCollectionChangedEventArgs args)
{
    // 3. Create new UI collection
    var flyoutItems = list.Select(entry =>
        new MenuFlyoutItem()
        {
            Text = entry.Name
        }
    );

    // 4. Updating your MenuFlyoutItem
    mruFlyout.Items.Clear();
    flyoutItems.ForEach(entry => mruFlyout.Items.Add(entry));
}

0
Zulu On

Based on chingucoding's answer I got to the "recent files list" binding working.

For completeness I post the detailed code snippets here (keep in mind, that I'm not an expert):

Again using Template Studio to create a WinUI 3 app.

ShellViewModel.cs

// constructor
public ShellViewModel(INavigationService navigationService, ILocalSettingsService localSettingsService)
{
    ...
    MRUUpdateItems();
}

ShellViewModel_RecentFiles.cs ( <-- partial class )

using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Windows.Storage;
using Windows.Storage.AccessCache;
using Windows.Storage.Pickers;

namespace App_MostRecentUsedTest.ViewModels;

public partial class ShellViewModel : ObservableRecipient
{
    public ObservableCollection<MRUItem> MRUItems{ get; set;} = new();
    
    // update ObservableCollection<MRUItem>MRUItems from MostRecentlyUsedList
    public void MRUUpdateItems()
    {
        var mruTokenList = StorageApplicationPermissions.MostRecentlyUsedList.Entries.Select(entry => entry.Token).ToList();
        var mruMetadataList = StorageApplicationPermissions.MostRecentlyUsedList.Entries.Select(entry => entry.Metadata).ToList(); // contains path as string
    
        MRUItems.Clear(); var i = 0;
        foreach (var path in mruMetadataList)
        {
            MRUItems.Add(new MRUItem() { Path = path, Token = mruTokenList[i++] });
        }
    }
    
    
    // called if user selects a recent used file from menu bar list
    [RelayCommand]
    protected async Task MRULoadFileClicked(int? fileId)
    {
        if (fileId is not null)
        {
            var mruItem = MRUItems[(int)fileId];
    
            FileInfo fInfo = new FileInfo(mruItem.Path ?? "");
            if (fInfo.Exists)
            {
                StorageFile? file = await Windows.Storage.AccessCache.StorageApplicationPermissions.MostRecentlyUsedList.GetFileAsync(mruItem.Token);
                if (file is not null) 
                { 
                    Windows.Storage.AccessCache.StorageApplicationPermissions.MostRecentlyUsedList.Add(file, file.Path); // store file.Path into Metadata
                    MRUUpdateItems();
                    // LOAD_FILE(file);
                }
            }
            else
            {
            }
        }
        await Task.CompletedTask;
    }
    
    
    [RelayCommand]
    protected async Task MenuLoadFileClicked()
    {
        StorageFile? file = await GetFilePathAsync();
        if (file is not null)
        {
            Windows.Storage.AccessCache.StorageApplicationPermissions.MostRecentlyUsedList.Add(file, file.Path); // store file.Path into Metadata
            MRUUpdateItems();
            // LOAD_FILE(file);
        }
        await Task.CompletedTask;
    }
    
    // get file path with filePicker
    private async Task<StorageFile?> GetFilePathAsync()
    {
        FileOpenPicker filePicker = new();
        filePicker.FileTypeFilter.Add(".txt");
        IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow); 
        WinRT.Interop.InitializeWithWindow.Initialize(filePicker, hwnd); 
        return await filePicker.PickSingleFileAsync();
    }
   
   
    public class MRUItem : INotifyPropertyChanged
    {
        private string? path;
        private string? token;

        public string? Path
        {
            get => path;
            set
            {
                path = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(path));
            }
        }
    
        public string? Token
        {
            get => token;
            set => token = value;
        }
    
        public event PropertyChangedEventHandler? PropertyChanged;
    }
}

ShellPage.xaml

<MenuBar>
    <MenuBarItem x:Name="ShellMenuBarItem_File">
        <MenuFlyoutItem x:Uid="ShellMenuItem_File_Load" Command="{x:Bind ViewModel.MenuLoadFileClickedCommand}" />
        <MenuFlyoutSubItem x:Name="MRUFlyout" Text="Recent Files..." />
    </MenuBarItem>
</MenuBar>

ShellPage.xaml.cs

// constructor
public ShellPage(ShellViewModel viewModel)
{ 
    ...
    
    // MRU initialziation
    // assign RecentFilesChanged() to CollectionChanged-event
    ViewModel.MRUItems.CollectionChanged += RecentFilesChanged;
    // Add (and RemoveAt) trigger RecentFilesChanged-event to update MenuFlyoutItems 
    ViewModel.MRUItems.Add(new MRUItem() { Path = "", Token = ""});
    ViewModel.MRUItems.RemoveAt(ViewModel.MRUItems.Count - 1);
}



// MRU Handle collection change
private void RecentFilesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
    // project each MRUItems list element into a new UI MenuFlyoutItem flyoutItems list
    var i = 0;
    var flyoutItems = ViewModel.MRUItems.Select(entry =>
        new MenuFlyoutItem()
        {
            Text = "  " + i.ToString() + " " + FilenameHelper.EllipsisString(entry.Path, 65),
            Command = ViewModel.MRULoadFileClickedCommand,
            CommandParameter = i++
        }
    ); 

    //// If you want to update the list while it is shown, 
    //// you will need to create a new FlyoutItem because of 
    //// https://github.com/microsoft/microsoft-ui-xaml/issues/7797

    // Create a new flyout and populate it
    var newFlyout = new MenuFlyoutSubItem();
    newFlyout.Text = MRUFlyout.Text;  // Text="Recent Files...";

    // Updating your MenuFlyoutItem
    flyoutItems.ToList().ForEach(item => newFlyout.Items.Add(item));

    // Get index of old sub item and remove it
    var oldIndex = ShellMenuBarItem_File.Items.IndexOf(MRUFlyout);
    ShellMenuBarItem_File.Items.Remove(MRUFlyout);

    // Insert the new flyout at the correct position
    ShellMenuBarItem_File.Items.Insert(oldIndex, newFlyout);

    // Assign newFlyout to "old"-MRUFlyout
    MRUFlyout = newFlyout;
}