前回の記事から色々機能を増やしたところ、
怪しい挙動が発生していたので、
全面的にやり直してみました。
ついでに、サムネイル生成部分をParallel.ForEachAsyncで並列処理にしてみました。
ソースコード
プロジェクト
ファイル名:WpfFileManager2.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
Helpers
ファイル名:Helpers\Dialog.cs
using Microsoft.Win32;
namespace Maywork.WPF.Helpers;
static class Dialogs
{
public static string? OpenDir(string dir)
{
var dialog = new OpenFolderDialog
{
Title = "フォルダを選択してください",
InitialDirectory = dir,
Multiselect = false
};
return dialog.ShowDialog() == true ? dialog.FolderName : null;
}
}
ファイル名:Helpers\IconHelper.cs
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace Maywork.WPF.Helpers;
public static class IconHelper
{
// ------------------------------------------------------------
// Win32 API
// ------------------------------------------------------------
[DllImport("Shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHGetFileInfo(
string pszPath,
uint dwFileAttributes,
out SHFILEINFO psfi,
uint cbFileInfo,
uint uFlags);
[DllImport("Shell32.dll", EntryPoint = "#727")]
private static extern int SHGetImageList(
int iImageList,
ref Guid riid,
out IImageList ppv);
[DllImport("User32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
// ------------------------------------------------------------
// Structs & Constants
// ------------------------------------------------------------
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
public string szTypeName;
}
private const uint SHGFI_SYSICONINDEX = 0x00004000;
private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
private const int SHIL_SMALL = 0; // 16x16
private const int SHIL_LARGE = 1; // 32x32
private const int SHIL_EXTRALARGE = 2; // 48x48
private const int SHIL_JUMBO = 4; // 256x256
// ------------------------------------------------------------
// COM Interface (Order is critical!)
// ------------------------------------------------------------
[ComImport]
[Guid("46EB5926-582E-4017-9FDF-E8998DAA0950")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IImageList
{
int Add(IntPtr hbmImage, IntPtr hbmMask, ref int pi);
int ReplaceIcon(int i, IntPtr hicon, ref int pi);
int SetOverlayImage(int iImage, int iOverlay);
int Replace(int i, IntPtr hbmImage, IntPtr hbmMask);
int AddMasked(IntPtr hbmImage, uint crMask, ref int pi);
int Draw(IntPtr pimldp); // 引数は簡易化
int Remove(int i);
int GetIcon(int i, int flags, out IntPtr picon); // 8番目のメソッド
// 以降のメソッドは今回使用しないため省略可能
}
// ------------------------------------------------------------
// Public API
// ------------------------------------------------------------
public static ImageSource GetIconImageSource(string path, int size = 32)
{
var info = new SHFILEINFO();
bool isDirectory = System.IO.Directory.Exists(path);
uint attr = isDirectory ? (uint)0x10 : (uint)0x80; // 0x10: Directory, 0x80: Normal File
uint flags = SHGFI_SYSICONINDEX | SHGFI_USEFILEATTRIBUTES;
// 第一引数に path を渡しても、USEFILEATTRIBUTES があれば属性優先になります。
SHGetFileInfo(path, attr, out info, (uint)Marshal.SizeOf(info), flags);
// 2. サイズに応じたイメージリストの種類を選択
int listType = size switch
{
<= 16 => SHIL_SMALL,
<= 32 => SHIL_LARGE,
<= 48 => SHIL_EXTRALARGE,
_ => SHIL_JUMBO
};
// 3. IImageList 取得
Guid guid = typeof(IImageList).GUID;
int hr = SHGetImageList(listType, ref guid, out var imageList);
if (hr == 0 && imageList != null)
{
hr = imageList.GetIcon(info.iIcon, 0x00000001, out IntPtr hIcon);
if (hr == 0 && hIcon != IntPtr.Zero)
{
try
{
// BitmapSizeOptions を指定せず、元のサイズを維持して作成
var bmp = Imaging.CreateBitmapSourceFromHIcon(
hIcon,
Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
bmp.Freeze();
return bmp;
}
finally
{
DestroyIcon(hIcon); // ハンドル漏れ防止
}
}
}
// 何も取れなければ透明1x1
var wb = new WriteableBitmap(1, 1, 96, 96, PixelFormats.Bgra32, null);
wb.Freeze();
return wb;
}
}
/*
public static class IconHelper
{
// SHGetFileInfoでHICONを取得してWPFのImageSourceへ変換
[DllImport("Shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes,
out SHFILEINFO psfi, uint cbFileInfo, uint uFlags);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
public string szTypeName;
}
private const uint SHGFI_ICON = 0x000000100;
private const uint SHGFI_LARGEICON = 0x000000000; // 32x32
private const uint SHGFI_SHELLICONSIZE = 0x000000004;
[DllImport("User32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
public static ImageSource GetIconImageSource(string path)
{
var info = new SHFILEINFO();
IntPtr result = SHGetFileInfo(path, 0, out info, (uint)Marshal.SizeOf(info),
SHGFI_ICON | SHGFI_LARGEICON | SHGFI_SHELLICONSIZE);
if (result != IntPtr.Zero && info.hIcon != IntPtr.Zero)
{
var img = Imaging.CreateBitmapSourceFromHIcon(
info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
DestroyIcon(info.hIcon);
img.Freeze();
return img;
}
// 何も取れなければ透明1x1
var wb = new WriteableBitmap(1, 1, 96, 96, PixelFormats.Bgra32, null);
wb.Freeze();
return wb;
}
}
*/
ファイル名:Helpers\ThumbnailHelper.cs
using System.Windows.Media.Imaging;
namespace Maywork.WPF.Helpers;
public static class ThumbnailHelper
{
// サムネイル用画像の読み込み
public static BitmapSource LoadImageThumb(
string file,
int thumbSize = 256)
{
var bmp = new BitmapImage();
bmp.BeginInit();
try
{
// ファイルロック回避
bmp.CacheOption = BitmapCacheOption.OnLoad;
// デコード段階で縮小(超重要)
bmp.DecodePixelWidth = thumbSize;
// または Height でもOK(片方だけ指定)
bmp.UriSource = new Uri(file, UriKind.Absolute);
bmp.EndInit();
}
catch
{
// 何もしない。
}
finally
{
bmp.Freeze(); // 非UIスレッドOK
}
return bmp;
}
}
ファイル名:MainWindow.xaml.cs
using System.Windows;
using System.Windows.Controls;
namespace WpfFileManager2;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// タブのアイテムを動的に追加するループ
TabItem? firstTab = null;
foreach (var factory in TabViewRegistry.Tabs)
{
var tabview = factory();
var tab = new TabItem
{
Header = tabview.Title,
Content = tabview // ← UserControl
};
if (firstTab is null)
{
firstTab = tab;
}
TabHost.Items.Add(tab);
}
TabHost.SelectedItem = firstTab;
}
}
ファイル名:TabViewRegistry.cs
using WpfFileManager2.Views;
namespace WpfFileManager2;
public static class TabViewRegistry
{
public static IReadOnlyList<Func<ITabView>> Tabs { get; } =
[
() => new FileManagerView(),
];
}
Utilities
ファイル名:Utilities\SubProcUtil.cs
using System.Diagnostics;
namespace Maywork.Utilities;
public static class SubProcUtil
{
public static bool Launch(string path)
{
bool result = true;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch
{
result = false;
}
return result;
}
}
ファイル名:Views\FileManagerFileItem.cs
using System.Collections.Concurrent;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.IO;
using Maywork.WPF.Helpers;
using System.Security.Cryptography;
using System.Text;
public class FileManagerFileItem
{
public string Path { get; }
public string DisplayName { get; }
public ImageSource Icon { get; }
public BitmapSource? Thumb {get; set;}
public BitmapSource? TempThumb {get; set;}
// コンストラクタ
public FileManagerFileItem(string path, string displayName, ImageSource icon, BitmapSource? thumb)
{
Path = path;
DisplayName = displayName;
Icon = icon;
Thumb = thumb;
}
// ファクトリメソッド
public static FileManagerFileItem FromPath(string path)
{
var name = System.IO.Path.GetFileName(path);
var icon = GetIconCached(path);
var thumb = GetJumboIconCached(path) as BitmapSource;
var item = new FileManagerFileItem(path, name, icon, thumb);
return item;
}
// ------------------------------
// icon cache
// ------------------------------
private static readonly ConcurrentDictionary<string, ImageSource> _iconCache
= new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, ImageSource> _jumboIconCache
= new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, BitmapSource> _thumbCache
= new(StringComparer.OrdinalIgnoreCase);
// ------------------------------
// icon logic
// ------------------------------
private static ImageSource GetIconCached(string path)
{
// フォルダ
bool isDirectory = string.IsNullOrEmpty(System.IO.Path.GetExtension(path));
if (isDirectory)
{
return _iconCache.GetOrAdd(
"<DIR>",
_ => IconHelper.GetIconImageSource(path, 16));
}
var ext = System.IO.Path.GetExtension(path);
// 拡張子なし
if (string.IsNullOrEmpty(ext))
ext = "<NOEXT>";
// exe は個別アイコン
if (ext.Equals(".exe", StringComparison.OrdinalIgnoreCase))
{
return IconHelper.GetIconImageSource(path, 16);
}
// 拡張子単位でキャッシュ
return _iconCache.GetOrAdd(ext, _ =>
{
return IconHelper.GetIconImageSource(path, 16);
});
}
private static ImageSource GetJumboIconCached(string path)
{
// フォルダ
bool isDirectory = string.IsNullOrEmpty(System.IO.Path.GetExtension(path));
if (isDirectory)
{
return _jumboIconCache.GetOrAdd(
"<DIR>",
_ => IconHelper.GetIconImageSource(path, 256));
}
var ext = System.IO.Path.GetExtension(path);
// 拡張子なし
if (string.IsNullOrEmpty(ext))
ext = "<NOEXT>";
// exe は個別アイコン
if (ext.Equals(".exe", StringComparison.OrdinalIgnoreCase))
{
return IconHelper.GetIconImageSource(path, 256);
}
// 拡張子単位でキャッシュ
return _jumboIconCache.GetOrAdd(ext, _ =>
{
return IconHelper.GetIconImageSource(path, 256);
});
}
// 文字列からMD5ハッシュ文字列へ変換
public static string ToMd5(string text)
{
using var md5 = MD5.Create();
byte[] bytes = Encoding.UTF8.GetBytes(text);
byte[] hash = md5.ComputeHash(bytes);
// 32文字の16進文字列へ
return Convert.ToHexString(hash).ToLowerInvariant();
}
// キャッシュのKeyを生成
public static string CreateCacheKey(string path)
{
var info = new FileInfo(path);
string modified =
info.LastWriteTime.ToString("yyyyMMddHHmmss");
string size = info.Length.ToString();
return ToMd5($"{path}-{modified}-{size}");
}
// サムネ画像のロード
public static BitmapSource LoadImageThumb(string path)
{
var bmp = ThumbnailHelper.LoadImageThumb(path);
var key = CreateCacheKey(path);
// MD5でキャッシュ
return _thumbCache.GetOrAdd(key, _ =>
{
return ThumbnailHelper.LoadImageThumb(path);
});
}
}
ファイル名:Views\FileManagerView.xaml.cs
using System.Diagnostics;
using System.IO;
using System.Windows.Controls;
using System.Windows;
using System.Windows.Media;
using System.Windows.Input;
using Maywork.WPF.Helpers;
using Maywork.Utilities;
namespace WpfFileManager2.Views;
public partial class FileManagerView : UserControl, ITabView
{
public string Title => "ファイルマネージャ";
// ドラッグ開始を判定するための開始位置
private Point _startPoint;
private string _currentDir = "";
public FileManagerView()
{
InitializeComponent();
List.MouseDoubleClick += async (_, __) => await ListItemDoubleClickAsync();
Thumb.MouseDoubleClick += async (_, __) => await ListItemDoubleClickAsync();
UpButton.Click += async (_, __) => await GoParentDirAsync();
UpdateButton.Click += async(_, __) => await UpdateDirAsync();
MenuOpen.Click += async (_, __) => await DirSelectDialogAsync();
MenuListMode.Click += (_, __) => ChangeListMode();
MenuThumbMode.Click += (_, __) => ChangeThumMode();
MenuThumbUpdate.Click += async (_, __) => await UpdateThumbAsync();
List.PreviewMouseLeftButtonDown += ListView_PreviewMouseLeftButtonDown;
List.PreviewMouseMove += ListView_PreviewMouseMove;
Thumb.PreviewMouseLeftButtonDown += ListView_PreviewMouseLeftButtonDown;
Thumb.PreviewMouseMove += ListView_PreviewMouseMove;
Loaded += async (_, __) =>
{
await ChangeCurrentDir(@"C:\Users\karet\Pictures");
};
}
// カレントディレクトリの変更処理
async Task ChangeCurrentDir(string path)
{
if (_currentDir == path) return;
_currentDir = path;
CurrentDirTextBox.Text = _currentDir;
var sw = Stopwatch.StartNew();
var entries = Directory.EnumerateFileSystemEntries(path)
.Where(file =>
{
try
{
var attr = File.GetAttributes(file);
return (attr & (FileAttributes.Hidden | FileAttributes.System)) == 0;
}
catch
{
return false;
}
})
.Select(file => FileManagerFileItem.FromPath(file))
.ToList();
List.ItemsSource = entries;
if (Thumb.Visibility == Visibility.Visible)
{
Thumb.ItemsSource = entries;
await UpdateThumbAsync();
}
sw.Stop();
Debug.WriteLine($"ChangeCurrentDir: {sw.ElapsedMilliseconds} ms {path}");
}
// ダブルクリック処理
async Task ListItemDoubleClickAsync()
{
var item = List.SelectedItem as FileManagerFileItem;
if (item is null)
{
item = Thumb.SelectedItem as FileManagerFileItem;
}
if (item is null) return;
bool isDirectory = string.IsNullOrEmpty(Path.GetExtension(item.Path));
if (isDirectory)
{
await ChangeCurrentDir(item.Path);
}
else
{
await Task.Run(()=>SubProcUtil.Launch(item.Path));
}
}
// 親ディレクトリへ
async Task GoParentDirAsync()
{
var parent = Path.GetDirectoryName(_currentDir);
if (parent is null || parent == "") return;
await ChangeCurrentDir(parent);
}
// カレントディレクトリをアドレスバーの入力で更新
async Task UpdateDirAsync()
{
string path = CurrentDirTextBox.Text;
if (!Directory.Exists(path)) return;
await ChangeCurrentDir(path);
}
// ディレクトリ選択ダイアログ
async Task DirSelectDialogAsync()
{
string? newPath = Dialogs.OpenDir(_currentDir);
if (newPath is null) return;
await ChangeCurrentDir(newPath);
}
// サムネモードへ変更
void ChangeThumMode()
{
if (Thumb.Visibility == Visibility.Visible) return;
Thumb.Visibility = Visibility.Visible;
List.Visibility = Visibility.Hidden;
}
// 一覧モードへ変更
void ChangeListMode()
{
if (List.Visibility == Visibility.Visible) return;
Thumb.Visibility = Visibility.Hidden;
List.Visibility = Visibility.Visible;
}
// サムネイル更新
async Task UpdateThumbAsync()
{
string path = _currentDir;
var sw = Stopwatch.StartNew();
// ① UIスレッドでコピー
var items = List.ItemsSource
.Cast<FileManagerFileItem>()
.ToList();
await Parallel.ForEachAsync(items, async (item, ct) =>
{
await Task.Run(() =>
{
string file = item.Path;
string ext = Path.GetExtension(file).ToLower();
if (ext is ".jpeg" or ".jpg" or ".png" or ".webp")
{
item.TempThumb = FileManagerFileItem.LoadImageThumb(file);
}
}, ct);
});
// ③ UIスレッドで反映
foreach (var item in items)
{
if (item.TempThumb != null)
{
item.Thumb = item.TempThumb;
item.TempThumb = null;
}
}
Thumb.ItemsSource = items;
sw.Stop();
Debug.WriteLine($"UpdateThumb: {sw.ElapsedMilliseconds} ms {path}");
}
private void ListView_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// マウスを押した位置を記録
_startPoint = e.GetPosition(null);
}
private void ListView_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed)
return;
Point mousePos = e.GetPosition(null);
Vector diff = _startPoint - mousePos;
if (Math.Abs(diff.X) < SystemParameters.MinimumHorizontalDragDistance &&
Math.Abs(diff.Y) < SystemParameters.MinimumVerticalDragDistance)
return;
var listView = sender as ListView;
if (listView == null)
return;
// ★ マウス直下の要素を取得
DependencyObject? obj =
listView.InputHitTest(e.GetPosition(listView)) as DependencyObject;
// ★ ListViewItem を親方向に探索
while (obj != null && obj is not ListViewItem)
{
obj = VisualTreeHelper.GetParent(obj);
}
// ScrollBar 等はここで弾かれる
if (obj is not ListViewItem item)
return;
// 選択アイテム取得
if (item.DataContext is not FileManagerFileItem file)
return;
if (string.IsNullOrEmpty(file.Path))
return;
// ドラッグ開始
string[] paths = { file.Path };
DataObject data = new(DataFormats.FileDrop, paths);
DragDrop.DoDragDrop(listView, data, DragDropEffects.Copy);
}
}
ファイル名:Views\ITabView.cs
namespace WpfFileManager2.Views;
public interface ITabView
{
string Title { get; }
}
ファイル名:MainWindow.xaml
<Window x:Class="WpfFileManager2.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfFileManager2"
mc:Ignorable="d"
Title="FileManager" Height="800" Width="400">
<TabControl
x:Name="TabHost"
TabStripPlacement="Bottom"/>
</Window>
ファイル名:Views\FileManagerView.xaml
<UserControl x:Class="WpfFileManager2.Views.FileManagerView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Grid.RowDefinitions>
<!-- メニューバー -->
<RowDefinition Height="Auto"/>
<!-- アドレスバー -->
<RowDefinition Height="Auto"/>
<!-- メイン: -->
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- メニューバー -->
<Menu
Grid.Row="0">
<Menu.FontSize>16</Menu.FontSize>
<MenuItem Header="ファイル(_F)">
<MenuItem Header="開く(_O)" x:Name="MenuOpen"/>
</MenuItem>
<MenuItem Header="表示(_V)">
<MenuItem Header="一覧(_L)" x:Name="MenuListMode" />
<MenuItem Header="サムネ(_T)" x:Name="MenuThumbMode" />
<Separator />
<MenuItem Header="サムネ更新(_U)" x:Name="MenuThumbUpdate" />
</MenuItem>
</Menu>
<!-- アドレスバー -->
<Border Grid.Row="1" BorderBrush="#DDD" BorderThickness="0,1,0,1">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <!-- 上へ -->
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/> <!-- カレントディレクトリ -->
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/> <!-- 更新 -->
</Grid.ColumnDefinitions>
<Button
x:Name="UpButton"
Grid.Column="0"
Padding="4,4"
Content="↑"
Background="Transparent"
FontSize="16"/>
<!-- カレントディレクトリ -->
<DockPanel Grid.Column="2" LastChildFill="True">
<TextBlock Text="アドレス:" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox
x:Name="CurrentDirTextBox"
VerticalContentAlignment="Center"
Padding="6,3"/>
</DockPanel>
<Button
x:Name="UpdateButton"
Grid.Column="4"
Padding="10,4"
Content="↵"
Background="Transparent"
FontSize="16"/>
</Grid>
</Border>
<!-- 一覧 -->
<ListView Grid.Row="2"
x:Name="List"
Visibility="Collapsed"
Margin="8"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter
Property="FontSize"
Value="14"/>
<Setter
Property="Padding"
Value="4"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.View>
<GridView AllowsColumnReorder="False">
<GridViewColumn
Header="アイコン"
Width="48">
<GridViewColumn.CellTemplate>
<DataTemplate>
<Image
Source="{Binding Icon}"
Width="24"
Height="24"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn
Header="名前"
Width="300"
DisplayMemberBinding="{Binding DisplayName}"/>
</GridView>
</ListView.View>
</ListView>
<!-- サムネイル -->
<ListView Grid.Row="2"
x:Name="Thumb"
Visibility="Visible"
Margin="8"
SelectionMode="Single"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter
Property="FontSize"
Value="14"/>
<Setter
Property="Padding"
Value="4"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemTemplate>
<DataTemplate>
<Border
BorderThickness="4"
CornerRadius="6"
Background="Transparent"
Margin="5">
<StackPanel Margin="5" Width="256">
<Image
Source="{Binding Thumb}"
Width="256" Height="256"
Stretch="UniForm" />
<TextBlock
Text = "{Binding DisplayName}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</UserControl>
実行例
サムネイル作成に時間がかかるので、表示が遅れます。


コメント