feat: add hierarchical demo.

This commit is contained in:
rabbitism
2023-07-26 16:42:17 +08:00
parent 591c728d69
commit 3d174bf65f
8 changed files with 569 additions and 21 deletions

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Avalonia.Metadata;
namespace Semi.Avalonia.TreeDataGrid.Demo.Converters;
public class FileIconConverter: IMultiValueConverter
{
[Content]
public Dictionary<string, PathGeometry> Items { get; set; } = new Dictionary<string, PathGeometry>();
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values[0] is bool isDirectory && values[1] is bool isOpen)
{
if (!isDirectory)
{
return Items["file"];
}
return isOpen ? Items["folderOpen"] : Items["folderClosed"];
}
return AvaloniaProperty.UnsetValue;
}
}

View File

@@ -2,19 +2,128 @@
x:Class="Semi.Avalonia.TreeDataGrid.Demo.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:Semi.Avalonia.TreeDataGrid.Demo.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Semi.Avalonia.TreeDataGrid.Demo.ViewModels;assembly=Semi.Avalonia.TreeDataGrid.Demo"
Title="Semi.Avalonia.TreeDataGrid.Demo"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vm:SongsPageViewModel"
x:DataType="vm:MainViewModel"
mc:Ignorable="d">
<Window.Resources>
<converters:FileIconConverter x:Key="FileIconConverter">
<PathGeometry x:Key="file">M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z</PathGeometry>
<PathGeometry x:Key="folderOpen">M6.1,10L4,18V8H21A2,2 0 0,0 19,6H12L10,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H19C19.9,20 20.7,19.4 20.9,18.5L23.2,10H6.1M19,18H6L7.6,12H20.6L19,18Z</PathGeometry>
<PathGeometry x:Key="folderClosed">M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z</PathGeometry>
</converters:FileIconConverter>
</Window.Resources>
<Grid RowDefinitions="Auto, *">
<Button Click="Button_OnClick" Content="Theme" />
<Button
HorizontalAlignment="Right"
Click="Button_OnClick"
Content="Theme" />
<TabControl Grid.Row="1">
<TabItem Header="Songs">
<TreeDataGrid Source="{Binding Songs}" />
<TreeDataGrid
AutoDragDropRows="True"
DataContext="{Binding SongsContext}"
Source="{Binding Songs}">
<TreeDataGrid.Resources>
<DataTemplate x:Key="AlbumCell" DataType="vm:SongViewModel">
<TextBlock
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Background="Transparent"
Text="{Binding Album}" />
</DataTemplate>
<DataTemplate x:Key="AlbumEditCell" DataType="vm:SongViewModel">
<ComboBox
VerticalAlignment="Center"
Classes="Small"
ItemsSource="{x:Static vm:Song.Albums}"
SelectedItem="{Binding Album}" />
</DataTemplate>
<DataTemplate x:Key="CommentsCell" DataType="vm:SongViewModel">
<TextBlock VerticalAlignment="Center" Text="{Binding CountOfComment}" />
</DataTemplate>
<DataTemplate x:Key="CommentsEditCell" DataType="vm:SongViewModel">
<NumericUpDown
Width="100"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Classes="Small"
Value="{Binding CountOfComment}" />
</DataTemplate>
</TreeDataGrid.Resources>
<TreeDataGrid.Styles>
<Style Selector="TreeDataGrid TreeDataGridRow:nth-last-child(2n)">
<Setter Property="Background" Value="#20808080" />
</Style>
</TreeDataGrid.Styles>
</TreeDataGrid>
</TabItem>
<TabItem Header="Files">
<Grid DataContext="{Binding FilesContext}" RowDefinitions="Auto, *">
<DockPanel Margin="0,4" DockPanel.Dock="Top">
<ComboBox
DockPanel.Dock="Left"
ItemsSource="{Binding Drives}"
SelectedItem="{Binding SelectedDrive}" />
<TextBox
Margin="4,0,0,0"
VerticalContentAlignment="Center"
KeyDown="SelectedPath_KeyDown"
Text="{Binding SelectedPath, Mode=OneWay}" />
</DockPanel>
<TreeDataGrid
Name="fileViewer"
Grid.Row="1"
Source="{Binding Source}">
<TreeDataGrid.Resources>
<!-- Template for Name column cells -->
<DataTemplate x:Key="FileNameCell" DataType="vm:FileNodeViewModel">
<StackPanel Orientation="Horizontal">
<PathIcon
Width="16"
Height="16"
Margin="8,0"
VerticalAlignment="Center">
<PathIcon.Data>
<MultiBinding Converter="{StaticResource FileIconConverter}">
<Binding Path="IsDirectory" />
<Binding Path="IsExpanded" />
</MultiBinding>
</PathIcon.Data>
</PathIcon>
<TextBlock VerticalAlignment="Center" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
<!-- Edit template for Name column cells -->
<DataTemplate x:Key="FileNameEditCell" DataType="vm:FileNodeViewModel">
<StackPanel Orientation="Horizontal">
<Image Margin="0,0,4,0" VerticalAlignment="Center">
<Image.Source>
<MultiBinding Converter="{StaticResource FileIconConverter}">
<Binding Path="IsDirectory" />
<Binding Path="IsExpanded" />
</MultiBinding>
</Image.Source>
</Image>
<TextBox VerticalAlignment="Center" Text="{Binding Name}" />
</StackPanel>
</DataTemplate>
</TreeDataGrid.Resources>
<TreeDataGrid.Styles>
<Style Selector="TreeDataGrid TreeDataGridRow:nth-child(2n)">
<Setter Property="Background" Value="#20808080" />
</Style>
</TreeDataGrid.Styles>
</TreeDataGrid>
</Grid>
</TabItem>
</TabControl>
</Grid>

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Styling;
using Semi.Avalonia.TreeDataGrid.Demo.ViewModels;
@@ -11,7 +12,7 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
this.DataContext = new SongsPageViewModel();
this.DataContext = new MainViewModel();
}
private void Button_OnClick(object? sender, RoutedEventArgs e)
@@ -23,4 +24,13 @@ public partial class MainWindow : Window
app.RequestedThemeVariant = theme == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark;
}
}
private void SelectedPath_KeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
var vm = (MainViewModel)DataContext!;
vm.FilesContext.SelectedPath = ((TextBox)sender!).Text;
}
}
}

View File

@@ -1,7 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Controls.Models.TreeDataGrid;
using Avalonia.Controls.Selection;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Semi.Avalonia.TreeDataGrid.Demo.ViewModels;
@@ -10,6 +17,26 @@ public class FilesPageViewModel: ObservableObject
{
public IList<string> Drives { get; }
private string _selectedDrive;
private string? _selectedPath;
private FileNodeViewModel? _root;
public string SelectedDrive
{
get => _selectedDrive;
set
{
SetProperty(ref _selectedDrive, value);
_root = new FileNodeViewModel(_selectedDrive, isDirectory: true, isRoot: true);
Source.Items = new[] { _root };
}
}
public string? SelectedPath
{
get => _selectedPath;
set => SetSelectedPath(value);
}
public HierarchicalTreeDataGridSource<FileNodeViewModel> Source { get; }
public FilesPageViewModel()
{
@@ -22,5 +49,354 @@ public class FilesPageViewModel: ObservableObject
{
_selectedDrive = Drives.FirstOrDefault() ?? "/";
}
Source = new HierarchicalTreeDataGridSource<FileNodeViewModel>(Array.Empty<FileNodeViewModel>())
{
Columns =
{
new CheckBoxColumn<FileNodeViewModel>(
null,
x => x.IsChecked,
(o, v) => o.IsChecked = v,
options: new()
{
CanUserResizeColumn = false,
}),
new HierarchicalExpanderColumn<FileNodeViewModel>(
new TemplateColumn<FileNodeViewModel>(
"Name",
"FileNameCell",
"FileNameEditCell",
new GridLength(1, GridUnitType.Star),
new()
{
CompareAscending = FileNodeViewModel.SortAscending(x => x.Name),
CompareDescending = FileNodeViewModel.SortDescending(x => x.Name),
IsTextSearchEnabled = true,
TextSearchValueSelector = x => x.Name
}),
x => x.Children,
x => x.HasChildren,
x => x.IsExpanded),
new TextColumn<FileNodeViewModel, long?>(
"Size",
x => x.Size,
options: new()
{
CompareAscending = FileNodeViewModel.SortAscending(x => x.Size),
CompareDescending = FileNodeViewModel.SortDescending(x => x.Size),
}),
new TextColumn<FileNodeViewModel, DateTimeOffset?>(
"Modified",
x => x.Modified,
options: new()
{
CompareAscending = FileNodeViewModel.SortAscending(x => x.Modified),
CompareDescending = FileNodeViewModel.SortDescending(x => x.Modified),
}),
}
};
Source.RowSelection!.SingleSelect = false;
Source.RowSelection.SelectionChanged += SelectionChanged;
}
private void SelectionChanged(object? sender, TreeSelectionModelSelectionChangedEventArgs<FileNodeViewModel> e)
{
var selectedPath = Source.RowSelection?.SelectedItem?.Path;
this.SetProperty(ref _selectedPath, selectedPath, nameof(SelectedPath));
foreach (var i in e.DeselectedItems)
System.Diagnostics.Trace.WriteLine($"Deselected '{i?.Path}'");
foreach (var i in e.SelectedItems)
System.Diagnostics.Trace.WriteLine($"Selected '{i?.Path}'");
}
private void SetSelectedPath(string? value)
{
if (string.IsNullOrEmpty(value))
{
Source.RowSelection!.Clear();
return;
}
var path = value;
var components = new Stack<string>();
DirectoryInfo? d = null;
if (File.Exists(path))
{
var f = new FileInfo(path);
components.Push(f.Name);
d = f.Directory;
}
else if (Directory.Exists(path))
{
d = new DirectoryInfo(path);
}
while (d is not null)
{
components.Push(d.Name);
d = d.Parent;
}
var index = IndexPath.Unselected;
if (components.Count > 0)
{
var drive = components.Pop();
var driveIndex = Drives.FindIndex(x => string.Equals(x, drive, StringComparison.OrdinalIgnoreCase));
if (driveIndex >= 0)
SelectedDrive = Drives[driveIndex];
FileNodeViewModel? node = _root;
index = new IndexPath(0);
while (node is not null && components.Count > 0)
{
node.IsExpanded = true;
var component = components.Pop();
var i = node.Children.FindIndex(x => string.Equals(x.Name, component, StringComparison.OrdinalIgnoreCase));
node = i >= 0 ? node.Children[i] : null;
index = i >= 0 ? index.Append(i) : default;
}
}
Source.RowSelection!.SelectedIndex = index;
}
}
public class FileNodeViewModel: ObservableObject, IEditableObject
{
private string _path;
private string _name;
private string? _undoName;
private long? _size;
private DateTimeOffset? _modified;
private FileSystemWatcher? _watcher;
private ObservableCollection<FileNodeViewModel>? _children;
private bool _hasChildren = true;
private bool _isExpanded;
public FileNodeViewModel(
string path,
bool isDirectory,
bool isRoot = false)
{
_path = path;
_name = isRoot ? path : System.IO.Path.GetFileName(Path);
_isExpanded = isRoot;
IsDirectory = isDirectory;
HasChildren = isDirectory;
if (!isDirectory)
{
var info = new FileInfo(path);
Size = info.Length;
Modified = info.LastWriteTimeUtc;
}
}
public string Path
{
get => _path;
private set => SetProperty(ref _path, value);
}
public string Name
{
get => _name;
private set => SetProperty(ref _name, value);
}
public long? Size
{
get => _size;
private set => SetProperty(ref _size, value);
}
public DateTimeOffset? Modified
{
get => _modified;
private set => SetProperty(ref _modified, value);
}
public bool HasChildren
{
get => _hasChildren;
private set => SetProperty(ref _hasChildren, value);
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
public bool IsChecked { get; set; }
public bool IsDirectory { get; }
public IReadOnlyList<FileNodeViewModel> Children => _children ??= LoadChildren();
private ObservableCollection<FileNodeViewModel> LoadChildren()
{
if (!IsDirectory)
{
throw new NotSupportedException();
}
var options = new EnumerationOptions { IgnoreInaccessible = true };
var result = new ObservableCollection<FileNodeViewModel>();
foreach (var d in Directory.EnumerateDirectories(Path, "*", options))
{
result.Add(new FileNodeViewModel(d, true));
}
foreach (var f in Directory.EnumerateFiles(Path, "*", options))
{
result.Add(new FileNodeViewModel(f, false));
}
_watcher = new FileSystemWatcher
{
Path = Path,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite,
};
_watcher.Changed += OnChanged;
_watcher.Created += OnCreated;
_watcher.Deleted += OnDeleted;
_watcher.Renamed += OnRenamed;
_watcher.EnableRaisingEvents = true;
if (result.Count == 0)
HasChildren = false;
return result;
}
public static Comparison<FileNodeViewModel?> SortAscending<T>(Func<FileNodeViewModel, T> selector)
{
return (x, y) =>
{
if (x is null && y is null)
return 0;
else if (x is null)
return -1;
else if (y is null)
return 1;
if (x.IsDirectory == y.IsDirectory)
return Comparer<T>.Default.Compare(selector(x), selector(y));
else if (x.IsDirectory)
return -1;
else
return 1;
};
}
public static Comparison<FileNodeViewModel?> SortDescending<T>(Func<FileNodeViewModel, T> selector)
{
return (x, y) =>
{
if (x is null && y is null)
return 0;
else if (x is null)
return 1;
else if (y is null)
return -1;
if (x.IsDirectory == y.IsDirectory)
return Comparer<T>.Default.Compare(selector(y), selector(x));
else if (x.IsDirectory)
return -1;
else
return 1;
};
}
void IEditableObject.BeginEdit() => _undoName = _name;
void IEditableObject.CancelEdit() => _name = _undoName!;
void IEditableObject.EndEdit() => _undoName = null;
private void OnChanged(object sender, FileSystemEventArgs e)
{
if (e.ChangeType == WatcherChangeTypes.Changed && File.Exists(e.FullPath))
{
Dispatcher.UIThread.Post(() =>
{
foreach (var child in _children!)
{
if (child.Path == e.FullPath)
{
if (!child.IsDirectory)
{
var info = new FileInfo(e.FullPath);
child.Size = info.Length;
child.Modified = info.LastWriteTimeUtc;
}
break;
}
}
});
}
}
private void OnCreated(object sender, FileSystemEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
var node = new FileNodeViewModel(
e.FullPath,
File.GetAttributes(e.FullPath).HasFlag(FileAttributes.Directory));
_children!.Add(node);
});
}
private void OnDeleted(object sender, FileSystemEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
for (var i = 0; i < _children!.Count; ++i)
{
if (_children[i].Path == e.FullPath)
{
_children.RemoveAt(i);
System.Diagnostics.Debug.WriteLine($"Removed {e.FullPath}");
break;
}
}
});
}
private void OnRenamed(object sender, RenamedEventArgs e)
{
Dispatcher.UIThread.Post(() =>
{
foreach (var child in _children!)
{
if (child.Path == e.OldFullPath)
{
child.Path = e.FullPath;
child.Name = e.Name ?? string.Empty;
break;
}
}
});
}
}
internal static class ListExtensions
{
public static int FindIndex<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
int i = 0;
foreach (var item in source)
{
if (predicate(item))
return i;
i++;
}
return -1;
}
}

View File

@@ -4,5 +4,6 @@ namespace Semi.Avalonia.TreeDataGrid.Demo.ViewModels;
public class MainViewModel: ObservableObject
{
public SongsPageViewModel SongsContext { get; } = new();
public FilesPageViewModel FilesContext { get; } = new();
}

View File

@@ -21,15 +21,21 @@ public class SongsPageViewModel: ObservableObject
Title = a.Title, Artist = a.Artist, Album = a.Album, CountOfComment = a.CountOfComment,
IsSelected = false
}));
Songs = new FlatTreeDataGridSource<SongViewModel>(_songs)
{
Columns =
{
new CheckBoxColumn<SongViewModel>("IsSelected", a=>a.IsSelected, (model, b) => { model.IsSelected = b; }, new GridLength(72, GridUnitType.Pixel)),
new TextColumn<SongViewModel,string>("Title", a=>a.Title, (o, a) => o.Title = a, new GridLength(6, GridUnitType.Star)),
new TextColumn<SongViewModel,string>("Artist", a=>a.Artist, (o, a) => o.Artist = a, new GridLength(6, GridUnitType.Star)),
new TextColumn<SongViewModel,string>("Album", a=>a.Album, (o, a) => o.Album = a, new GridLength(6, GridUnitType.Star)),
new CheckBoxColumn<SongViewModel>("IsSelected", a => a.IsSelected,
(model, b) => { model.IsSelected = b; }, new GridLength(108, GridUnitType.Pixel)),
new TextColumn<SongViewModel, string>("Title", a => a.Title, (o, a) => o.Title = a,
new GridLength(6, GridUnitType.Star)),
new TextColumn<SongViewModel, string>("Artist", a => a.Artist, (o, a) => o.Artist = a,
new GridLength(6, GridUnitType.Star)),
new TemplateColumn<SongViewModel>("Album", "AlbumCell", "AlbumEditCell",
new GridLength(6, GridUnitType.Star)),
new TemplateColumn<SongViewModel>("Comments", "CommentsCell", "CommentsEditCell",
new GridLength(6, GridUnitType.Star)),
}
};
}
@@ -57,6 +63,25 @@ public class Song
}
public static List<string> Albums { get; set; } = new List<string>()
{
"A.S.I.A",
"饕餮人间",
"七步咙咚呛",
"大惊小怪",
"The ONE",
"以梦为马 (壮志骄阳版)",
"emo了",
"一眼万年",
"冲刺吧",
"爱的赏味期限",
"COSMIC ANTHEM / 手紙",
"世界晚安",
"明年也要好好长大",
"320万年前",
"W.O.R.L.D.",
};
public static List<Song> Songs { get; set; } = new List<Song>()
{
new("好肚有肚(feat.李玲玉)", "熊猫堂ProducePandas", 2, 50, "A.S.I.A", 730, 1487039339),