Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Making xaml Collection views recursive in Maui.net

I am building a custom file explorer for my mobile app in Maui on .NET 9. So far, folder/file creation and loading are recursive (code allows for infinite nesting). However, the issue I'm encountering now is making the XAML itself recursive without just nesting more and more collection views.

XAML - just have the collection view nested a few times already:

<CollectionView x:Name="FileCollectionView" 
            ItemsSource="{Binding FileItems}" 
            SelectionMode="None"
            SelectedItem="{Binding SelectedItem, Mode=TwoWay}">
<CollectionView.ItemTemplate>
    <DataTemplate>
        <StackLayout Padding="10"  BackgroundColor="{Binding IsSelected, Converter={StaticResource BooleanToColorConverter}}">
            <StackLayout.GestureRecognizers>
                <TapGestureRecognizer Command="{Binding TapCommand}" />
            </StackLayout.GestureRecognizers>
            <!-- Folder/File Name and Buttons -->
            <Grid ColumnDefinitions="*,Auto,Auto" Padding="0">
                <!-- Folder/File Name -->
                <Label Text="{Binding DisplayName}" FontSize="Medium" LineBreakMode="TailTruncation" VerticalOptions="Center" Grid.Column="0" />
            
            </Grid>

            <!-- Nested CollectionView for files inside folders -->
            <CollectionView
                            ItemsSource="{Binding Items}" 
                            SelectionMode="None">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <StackLayout Padding="10"
                                     BackgroundColor="{Binding IsSelected, Converter={StaticResource BooleanToColorConverter}}">

                            <StackLayout.GestureRecognizers>
                                <TapGestureRecognizer Command="{Binding TapCommand}" />
                            </StackLayout.GestureRecognizers>

                            <!-- File Name -->
                            <Label Text="{Binding DisplayName}" MaxLines="1" LineBreakMode="TailTruncation"  VerticalOptions="Center" />

                            <!-- Nested CollectionView for files inside subfolders  -->
                            <CollectionView 
                                            ItemsSource="{Binding Items}" 
                                            SelectionMode="None">
                                <CollectionView.ItemTemplate>
                                    <DataTemplate>
                                        <StackLayout Padding="10"
                                                     BackgroundColor="{Binding IsSelected, Converter={StaticResource BooleanToColorConverter}}">

                                            <StackLayout.GestureRecognizers>
                                                <TapGestureRecognizer Command="{Binding TapCommand}" />
                                            </StackLayout.GestureRecognizers>

                                            <!-- File Name -->
                                            <Label Text="{Binding DisplayName}" LineBreakMode="TailTruncation" MaxLines="1"  VerticalOptions="Center" />

                                            <CollectionView 
                                                ItemsSource="{Binding Items}" 
                                                SelectionMode="None">
                                                <CollectionView.ItemTemplate>
                                                    <DataTemplate>
                                                        <StackLayout Padding="10"
                                                                    BackgroundColor="{Binding IsSelected, Converter={StaticResource BooleanToColorConverter}}">

                                                            <StackLayout.GestureRecognizers>
                                                                <TapGestureRecognizer Command="{Binding TapCommand}" />
                                                            </StackLayout.GestureRecognizers>

                                                            <!-- File Name -->
                                                            <Label Text="{Binding DisplayName}" LineBreakMode="TailTruncation" MaxLines="1"  VerticalOptions="Center" />
                                                        </StackLayout>
                                                    </DataTemplate>
                                                </CollectionView.ItemTemplate>
                                            </CollectionView>
                                        </StackLayout>
                                    </DataTemplate>
                                </CollectionView.ItemTemplate>
                            </CollectionView>
                        </StackLayout>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
        </StackLayout>
    </DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

And this is the code behind that loads the files (seems to work perfectly fine when I inspect the directory on my phone):

private void LoadFilesAndFolders()
{
    if (Directory.Exists(_myReSEEptImagesFolder))
    {
        var items = Directory.GetFileSystemEntries(_myReSEEptImagesFolder);

        foreach (var item in items)
        {
            var fileItem = new FileItem
            {
                Name = Path.GetFileName(item),
                Parent = null,
                Path = item,
                IsFolder = Directory.Exists(item),
                Items = new ObservableCollection<FileItem>(),
                OnTappedCallback = OpenPath
            };

            if (fileItem.IsFolder)
            {
                LoadSubfolderItems(fileItem);
            }

            FileItems.Add(fileItem);
        }
    }
}

// Recursive function to load subfolders
private void LoadSubfolderItems(FileItem parentFolder)
{
    var subItems = Directory.GetFileSystemEntries(parentFolder.Path);

    foreach (var subItem in subItems)
    {
        var subFileItem = new FileItem
        {
            Name = Path.GetFileName(subItem),
            Parent = parentFolder,
            Path = subItem,
            IsFolder = Directory.Exists(subItem),
            Items = new ObservableCollection<FileItem>(),
            OnTappedCallback = OpenPath
        };

        // If it's a folder, recursively load its children
        if (subFileItem.IsFolder)
        {
            LoadSubfolderItems(subFileItem);
        }

        parentFolder.Items.Add(subFileItem);
    }
}

Here is the FileItem model class:

public class FileItem : INotifyPropertyChanged
{
    public FileItem? Parent;
    private bool _isSelected;
    private string _name;
    private bool _isFolder;
    private ObservableCollection<FileItem> _items;
    private bool _isExpanded;
    private string _path;

    public event PropertyChangedEventHandler PropertyChanged;

    public string DisplayName => IsFolder
        ? (Parent != null ? $"└─ 📁 {Name}" : $"📁 {Name}")
        : $"└─ 📜 {Name}";

    public bool IsSelected
    {
        get => _isSelected;
        set
        {
            if (_isSelected != value)
            {
                _isSelected = value;
                OnPropertyChanged(nameof(IsSelected));
            }
        }
    }

    public string Name
    {
        get => _name;
        set
        {
            if (_name != value)
            {
                _name = value;
                OnPropertyChanged(nameof(Name));
                OnPropertyChanged(nameof(DisplayName));
            }
        }
    }

    public string Path
    {
        get => _path;
        set => SetProperty(ref _path, value);
    }

    public bool IsFolder
    {
        get => _isFolder;
        set
        {
            if (SetProperty(ref _isFolder, value) && _isFolder && _items == null)
            {
                Items = new ObservableCollection<FileItem>();
            }
        }
    }

    public bool IsExpanded
    {
        get => _isExpanded;
        set
        {
            _isExpanded = value;
            OnPropertyChanged(nameof(IsExpanded));
        }
    }

    public ObservableCollection<FileItem> Items
    {
        get => _items;
        set => SetProperty(ref _items, value);
    }

    public ICommand TapCommand { get; }

    // Callbacks for external handling
    public Action<FileItem> OnTappedCallback { get; set; }

    public FileItem()
    {
        TapCommand = new Command(ExecuteTapCommand);
    }

    private void ExecuteTapCommand()
    {
        OnTappedCallback?.Invoke(this);
    }

    protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if (Equals(storage, value)) return false;
        storage = value;

        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        return true;
    }

    protected virtual void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}
like image 727
voods Avatar asked Oct 16 '25 13:10

voods


1 Answers

The ObservableCollection isn't really necessary. But anyways, consider a more simpler model, e.g.

// ItemInfo.cs
namespace MauiItemTest;
public class ItemInfo
{
    public bool IsFile { get; set; } = true;
    public string Name { get; set; } = string.Empty;
    public List<ItemInfo>? Children { get; set; } = null;
}

We can populate it with the above in our view model (I just put it on my MainPage), e.g.

<!-- MainPage.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="MauiItemTest.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiItemTest"
    x:Name="mainPage"
    x:DataType="local:MainPage"
    BindingContext="{Reference mainPage}">
    <ScrollView>
        <VerticalStackLayout Padding="30,0" Spacing="25">
            <local:ItemView Item="{Binding Files}" />
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>
// MainPage.xaml.cs
namespace MauiItemTest;
public partial class MainPage : ContentPage
{
    public ItemInfo Files { get; } = new ItemInfo()
    {
        IsFile = false,
        Name = "Windows",
        Children = new List<ItemInfo>()
        {
            new ItemInfo() { IsFile = true, Name = "win.ini" },
            new ItemInfo()
            {
                IsFile = false,
                Name = "System32",
                Children = new List<ItemInfo>()
                {
                    new ItemInfo() { IsFile = true, Name = "calc.exe" },
                    new ItemInfo() { IsFile = true, Name = "notepad.exe" },
                }
            }
        }
    };
    public MainPage()
    {
        InitializeComponent();
    }
}

Then, we can build a ContentView that has a potential recursion as follows:

<!-- ItemView.xaml -->
<?xml version="1.0" encoding="utf-8" ?>
<ContentView
    x:Class="MauiItemTest.ItemView"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:MauiItemTest"
    x:Name="itemView"
    ControlTemplate="{StaticResource fileTemplate}">
    <ContentView.Resources>
        <ResourceDictionary>
            <ControlTemplate x:Key="fileTemplate">
                <Label Text="{TemplateBinding Item.Name, StringFormat='File: {0}'}" />
            </ControlTemplate>
            <ControlTemplate x:Key="folderTemplate">
                <VerticalStackLayout>
                    <Label Text="{TemplateBinding Item.Name, StringFormat='Folder: {0}'}" />
                    <CollectionView ItemsSource="{TemplateBinding Item.Children}" TranslationX="10">
                        <CollectionView.ItemTemplate>
                            <DataTemplate x:DataType="local:ItemInfo">
                                <local:ItemView Item="{Binding .}" />
                            </DataTemplate>
                        </CollectionView.ItemTemplate>
                    </CollectionView>
                </VerticalStackLayout>
            </ControlTemplate>
        </ResourceDictionary>
    </ContentView.Resources>
    <ContentView.Triggers>
        <DataTrigger
            Binding="{Binding Item.IsFile, x:DataType=local:ItemView, Source={Reference itemView}}"
            TargetType="ContentView"
            Value="False">
            <Setter Property="ControlTemplate" Value="{StaticResource folderTemplate}" />
        </DataTrigger>
    </ContentView.Triggers>
</ContentView>
// ItemView.xaml.cs
namespace MauiItemTest;
public partial class ItemView : ContentView
{
    public static readonly BindableProperty ItemProperty = BindableProperty.Create(nameof(Item), typeof(ItemInfo), typeof(ItemView), null);
    public ItemInfo Item
    {
        get => (ItemInfo)GetValue(ItemProperty);
        set => SetValue(ItemProperty, value);
    }
    public ItemView()
    {
        InitializeComponent();
    }
}

Here's a screenshot representing the above:

ItemView.gif

like image 143
Stephen Quan Avatar answered Oct 19 '25 05:10

Stephen Quan



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!