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));
}
}
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:
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With