У попередніх статтях ми розглянули навігацію у WPF та MVVM-навігацію. WPF має добре продуману систему для роботи з вікнами, але вона тісно пов'язана з Windows API і не працює на інших платформах.
Проблеми WPF при портуванні на інші ОС:
ShowDialog() повертає bool? — незручний APIOpenFileDialog з WinForms — Windows-специфічнийFrame та Page — не існують в AvaloniaAvalonia пропонує кращі альтернативи:
Window.ShowDialog<T>() — типізований результатStorageProvider API — кросплатформні file pickersContentControl — працює ідентично WPFУ WPF метод ShowDialog() повертає bool? — nullable boolean. Це обмежує можливості передачі даних назад:
WPF:
// WPF — лише bool?
var dialog = new EditPersonDialog(person);
bool? result = dialog.ShowDialog();
if (result == true)
{
// Потрібно читати дані через властивості діалогу
var editedPerson = dialog.EditedPerson;
}
Avalonia має кращий API:
// Avalonia — типізований результат
var dialog = new EditPersonDialog(person);
Person? result = await dialog.ShowDialog<Person?>(this);
if (result != null)
{
// Результат вже є об'єктом потрібного типу
UpdatePerson(result);
}
| Аспект | WPF | Avalonia |
|---|---|---|
| Повернення | bool? | T (будь-який тип) |
| Синхронність | Синхронний | Асинхронний (async/await) |
| Типізація | Слабка | Сильна |
| Зручність | Потрібно читати властивості | Результат вже готовий |
Розберемо повний приклад діалогу редагування особи.
// Models/Person.cs
public record Person(string Name, int Age, string Email);
Використовуємо record — це immutable тип, ідеальний для передачі даних між вікнами.
EditPersonDialog.axaml:
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MyApp.Views.EditPersonDialog"
Title="Редагувати особу"
Width="400" Height="300"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="Редагування даних особи"
FontSize="18" FontWeight="Bold"/>
<TextBlock Text="Ім'я:" Margin="0,8,0,0"/>
<TextBox x:Name="NameTextBox" Watermark="Введіть ім'я"/>
<TextBlock Text="Вік:" Margin="0,8,0,0"/>
<NumericUpDown x:Name="AgeNumeric" Minimum="0" Maximum="150"/>
<TextBlock Text="Email:" Margin="0,8,0,0"/>
<TextBox x:Name="EmailTextBox" Watermark="example@email.com"/>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8" Margin="0,20,0,0">
<Button Content="Скасувати"
Click="CancelButton_Click"
Width="100"/>
<Button Content="Зберегти"
Click="SaveButton_Click"
Width="100"
Classes="accent"/>
</StackPanel>
</StackPanel>
</Window>
EditPersonDialog.axaml.cs:
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace MyApp.Views;
public partial class EditPersonDialog : Window
{
public EditPersonDialog()
{
InitializeComponent();
}
// Конструктор з початковими даними
public EditPersonDialog(Person person) : this()
{
NameTextBox.Text = person.Name;
AgeNumeric.Value = person.Age;
EmailTextBox.Text = person.Email;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
// Створюємо новий об'єкт з відредагованими даними
var result = new Person(
Name: NameTextBox.Text ?? "",
Age: (int)(AgeNumeric.Value ?? 0),
Email: EmailTextBox.Text ?? ""
);
// КЛЮЧОВИЙ МОМЕНТ: Close(result) — передаємо результат
Close(result);
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
// Close(null) — скасування
Close(null);
}
}
MainWindow.axaml.cs:
private async void EditPerson_Click(object sender, RoutedEventArgs e)
{
var selectedPerson = new Person("Іван Петренко", 30, "ivan@example.com");
var dialog = new EditPersonDialog(selectedPerson);
// ShowDialog<T>() — типізований результат
Person? result = await dialog.ShowDialog<Person?>(this);
if (result != null)
{
// Результат вже є об'єктом Person
Console.WriteLine($"Збережено: {result.Name}, {result.Age}, {result.Email}");
UpdatePersonInList(result);
}
else
{
Console.WriteLine("Редагування скасовано");
}
}
1. Асинхронність:
// await — обов'язковий
Person? result = await dialog.ShowDialog<Person?>(this);
Avalonia використовує асинхронний підхід для всіх діалогів. Це дозволяє UI залишатися responsive.
2. Типізація:
// Можна повертати будь-який тип
await dialog.ShowDialog<Person?>(this);
await dialog.ShowDialog<string>(this);
await dialog.ShowDialog<int>(this);
await dialog.ShowDialog<MyCustomResult>(this);
3. Close(result):
// У діалозі — передаємо результат
Close(result); // result: Person
Close(null); // Скасування
Метод Close(T result) закриває вікно і повертає результат у ShowDialog<T>().
У WPF для вибору файлів використовується OpenFileDialog з WinForms або Microsoft.Win32.OpenFileDialog. Обидва варіанти працюють лише на Windows.
WPF (Windows-only):
// WPF — Windows-специфічний
var dialog = new Microsoft.Win32.OpenFileDialog();
dialog.Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*";
if (dialog.ShowDialog() == true)
{
string filename = dialog.FileName;
// Обробка файлу
}
Avalonia має кросплатформний API:
// Avalonia — працює на Windows, macOS, Linux
var topLevel = TopLevel.GetTopLevel(this);
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Оберіть текстовий файл",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } },
new FilePickerFileType("All files") { Patterns = new[] { "*" } }
}
});
if (files.Count > 0)
{
var file = files[0];
string path = file.Path.LocalPath;
// Обробка файлу
}
IStorageProvider — це кросплатформний інтерфейс Avalonia для роботи з файловою системою.
Доступ до StorageProvider:
// Через TopLevel (Window, UserControl)
var topLevel = TopLevel.GetTopLevel(this);
var storageProvider = topLevel.StorageProvider;
Основні методи:
| Метод | Опис |
|---|---|
OpenFilePickerAsync() | Відкрити файл(и) |
SaveFilePickerAsync() | Зберегти файл |
OpenFolderPickerAsync() | Обрати папку |
Метод для відкриття файлів з нативним діалогом ОС.
private async void OpenFile_Click(object sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Відкрити файл",
AllowMultiple = false
});
if (files.Count > 0)
{
var file = files[0];
await ProcessFile(file);
}
}
private async Task ProcessFile(IStorageFile file)
{
// Читання файлу
await using var stream = await file.OpenReadAsync();
using var reader = new StreamReader(stream);
string content = await reader.ReadToEndAsync();
Console.WriteLine($"Файл: {file.Name}");
Console.WriteLine($"Шлях: {file.Path.LocalPath}");
Console.WriteLine($"Вміст: {content}");
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Оберіть зображення",
AllowMultiple = true,
FileTypeFilter = new[]
{
new FilePickerFileType("Зображення")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" },
MimeTypes = new[] { "image/*" }
},
new FilePickerFileType("Всі файли")
{
Patterns = new[] { "*" }
}
}
});
Властивості FilePickerFileType:
Patterns — шаблони файлів (*.txt, *.png)MimeTypes — MIME типи (image/, text/)AppleUniformTypeIdentifiers — UTI для macOS/iOSvar files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Оберіть файли",
AllowMultiple = true // Дозволити вибір кількох файлів
});
foreach (var file in files)
{
Console.WriteLine($"Обрано: {file.Name}");
}
Метод для збереження файлів з нативним діалогом ОС.
private async void SaveFile_Click(object sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Зберегти файл",
SuggestedFileName = "document.txt",
FileTypeChoices = new[]
{
new FilePickerFileType("Text files")
{
Patterns = new[] { "*.txt" }
}
}
});
if (file != null)
{
await SaveToFile(file, "Вміст файлу");
}
}
private async Task SaveToFile(IStorageFile file, string content)
{
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(content);
Console.WriteLine($"Збережено: {file.Path.LocalPath}");
}
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Зберегти звіт",
SuggestedFileName = $"report_{DateTime.Now:yyyy-MM-dd}.pdf",
DefaultExtension = "pdf",
ShowOverwritePrompt = true, // Попередження при перезаписі
FileTypeChoices = new[]
{
new FilePickerFileType("PDF документи")
{
Patterns = new[] { "*.pdf" }
}
}
});
Метод для вибору папки з нативним діалогом ОС.
private async void SelectFolder_Click(object sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Оберіть папку для експорту",
AllowMultiple = false
});
if (folders.Count > 0)
{
var folder = folders[0];
Console.WriteLine($"Обрано папку: {folder.Path.LocalPath}");
// Створення файлу у обраній папці
await CreateFileInFolder(folder);
}
}
private async Task CreateFileInFolder(IStorageFolder folder)
{
// Створення файлу у папці
var file = await folder.CreateFileAsync("output.txt");
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("Вміст файлу");
}
Розберемо детально відмінності у підходах до роботи з файлами.
OpenFileDialog:
// WPF — працює лише на Windows
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Відкрити файл",
Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
Multiselect = false
};
if (dialog.ShowDialog() == true)
{
string filename = dialog.FileName;
string content = File.ReadAllText(filename);
}
SaveFileDialog:
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Зберегти файл",
FileName = "document.txt",
Filter = "Text files (*.txt)|*.txt"
};
if (dialog.ShowDialog() == true)
{
File.WriteAllText(dialog.FileName, "Вміст");
}
Проблеми:
OpenFilePickerAsync:
// Avalonia — працює на Windows, macOS, Linux, Android, iOS
var topLevel = TopLevel.GetTopLevel(this);
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Відкрити файл",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }
}
});
if (files.Count > 0)
{
await using var stream = await files[0].OpenReadAsync();
using var reader = new StreamReader(stream);
string content = await reader.ReadToEndAsync();
}
SaveFilePickerAsync:
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Зберегти файл",
SuggestedFileName = "document.txt"
});
if (file != null)
{
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("Вміст");
}
Переваги:
| Аспект | WPF | Avalonia |
|---|---|---|
| Платформи | Лише Windows | Windows, macOS, Linux, mobile |
| API | Синхронний | Асинхронний |
| Діалоги | Windows-стиль | Нативні для кожної ОС |
| Фільтри | Рядок ".txt|.txt" | Типізовані FilePickerFileType |
| Безпека | Прямий доступ | Sandboxed (mobile) |
| Сучасність | Застарілий | Сучасний |
Як ми розглянули у статті про MVVM-навігацію, найкращий підхід до навігації — це ContentControl + DataTemplate. Цей самий підхід працює ідентично в Avalonia.
App.axaml — реєстрація DataTemplate:
<Application xmlns="https://github.com/avaloniaui"
xmlns:vm="using:MyApp.ViewModels"
xmlns:views="using:MyApp.Views">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Resources>
<!-- Implicit DataTemplates для навігації -->
<DataTemplate DataType="{x:Type vm:HomeViewModel}">
<views:HomeView/>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:SettingsViewModel}">
<views:SettingsView/>
</DataTemplate>
<DataTemplate DataType="{x:Type vm:AboutViewModel}">
<views:AboutView/>
</DataTemplate>
</Application.Resources>
</Application>
MainWindow.axaml — ContentControl для навігації:
<Window xmlns="https://github.com/avaloniaui"
x:Class="MyApp.Views.MainWindow"
Title="My App" Width="800" Height="600">
<Grid ColumnDefinitions="200,*">
<!-- Навігаційна панель -->
<Border Grid.Column="0" Background="#1e293b">
<StackPanel Margin="0,20" Spacing="4">
<Button Content="🏠 Головна"
Command="{Binding NavigateHomeCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="20,12"/>
<Button Content="⚙️ Налаштування"
Command="{Binding NavigateSettingsCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="20,12"/>
<Button Content="ℹ️ Про застосунок"
Command="{Binding NavigateAboutCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="20,12"/>
</StackPanel>
</Border>
<!-- КЛЮЧОВА ЧАСТИНА: ContentControl відображає поточну "сторінку" -->
<ContentControl Grid.Column="1"
Content="{Binding CurrentViewModel}"/>
</Grid>
</Window>
ViewModelBase.cs:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MyApp.ViewModels;
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
NavigationStore.cs:
namespace MyApp.Services;
public class NavigationStore
{
private ViewModelBase? _currentViewModel;
public ViewModelBase? CurrentViewModel
{
get => _currentViewModel;
set
{
_currentViewModel = value;
CurrentViewModelChanged?.Invoke();
}
}
public event Action? CurrentViewModelChanged;
}
MainViewModel.cs:
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
namespace MyApp.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private readonly NavigationStore _navigationStore;
public ViewModelBase? CurrentViewModel => _navigationStore.CurrentViewModel;
public MainViewModel(NavigationStore navigationStore)
{
_navigationStore = navigationStore;
_navigationStore.CurrentViewModelChanged += OnCurrentViewModelChanged;
// Початкова сторінка
_navigationStore.CurrentViewModel = new HomeViewModel();
}
private void OnCurrentViewModelChanged()
{
OnPropertyChanged(nameof(CurrentViewModel));
}
[RelayCommand]
private void NavigateHome()
{
_navigationStore.CurrentViewModel = new HomeViewModel();
}
[RelayCommand]
private void NavigateSettings()
{
_navigationStore.CurrentViewModel = new SettingsViewModel();
}
[RelayCommand]
private void NavigateAbout()
{
_navigationStore.CurrentViewModel = new AboutViewModel();
}
}
1. Ідентичний підхід до WPF:
Код навігації у Avalonia абсолютно ідентичний WPF. Жодних змін не потрібно — лише namespace'и.
2. Кросплатформність:
Цей підхід працює на всіх платформах: Windows, macOS, Linux, Android, iOS.
3. Тестованість:
ViewModel'і не залежать від UI — їх можна тестувати без запуску застосунку.
4. Dependency Injection:
NavigationStore можна зареєструвати у DI-контейнері (наприклад, Microsoft.Extensions.DependencyInjection).
Avalonia автоматично використовує нативні діалоги для кожної ОС.
Вигляд:
Особливості:
Вигляд:
Особливості:
Вигляд:
Особливості:
| Платформа | Діалог | Особливості |
|---|---|---|
| Windows | File Explorer | OneDrive, Search, Preview |
| macOS | Finder | iCloud, Spotlight, Quick Look |
| Linux | GTK/KDE | DE-specific, Bookmarks |
| Android | DocumentsUI | Storage Access Framework |
| iOS | UIDocumentPicker | iCloud, Files app |
Мета: Навчитися використовувати ShowDialog<T>() для передачі даних.
Завдання:
Створіть додаток з діалогом введення імені:
ShowDialog<User?>() для повернення результатуКритерії успіху:
User?)nullПідказка:
// Models/User.cs
public record User(string Name, int Age);
// AddUserDialog.axaml.cs
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
var user = new User(
Name: NameTextBox.Text ?? "",
Age: (int)(AgeNumeric.Value ?? 0)
);
Close(user);
}
// MainWindow.axaml.cs
private async void AddUser_Click(object sender, RoutedEventArgs e)
{
var dialog = new AddUserDialog();
User? result = await dialog.ShowDialog<User?>(this);
if (result != null)
{
Users.Add(result);
}
}
Мета: Навчитися використовувати StorageProvider API для роботи з файлами.
Завдання:
Створіть простий текстовий редактор:
OpenFilePickerAsync() → завантажити текст у TextBoxSaveFilePickerAsync() → зберегти текст з TextBoxКритерії успіху:
async/await)Підказка:
private async void OpenFile_Click(object sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Відкрити текстовий файл",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }
}
});
if (files.Count > 0)
{
var file = files[0];
await using var stream = await file.OpenReadAsync();
using var reader = new StreamReader(stream);
EditorTextBox.Text = await reader.ReadToEndAsync();
FilePathTextBlock.Text = file.Path.LocalPath;
}
}
private async void SaveFile_Click(object sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Зберегти файл",
SuggestedFileName = "document.txt",
FileTypeChoices = new[]
{
new FilePickerFileType("Text files") { Patterns = new[] { "*.txt" } }
}
});
if (file != null)
{
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(EditorTextBox.Text);
FilePathTextBlock.Text = file.Path.LocalPath;
}
}
Мета: Реалізувати повноцінну MVVM-навігацію з діалогами у Avalonia.
Завдання:
Створіть додаток з навігацією та діалогами:
Критерії успіху:
Підказка для DI:
// Program.cs або App.axaml.cs
public static IServiceProvider ConfigureServices()
{
var services = new ServiceCollection();
// Singleton для NavigationStore
services.AddSingleton<NavigationStore>();
// ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<HomeViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<UsersViewModel>();
return services.BuildServiceProvider();
}
// MainWindow.axaml.cs
public MainWindow()
{
InitializeComponent();
var serviceProvider = ((App)Application.Current!).ServiceProvider;
DataContext = serviceProvider.GetRequiredService<MainViewModel>();
}
Підказка для діалогів у ViewModel:
// UsersViewModel.cs
[RelayCommand]
private async Task AddUser()
{
// Отримати TopLevel через Interaction або Service
var dialog = new AddUserDialog();
// Потрібен доступ до Window — через Service або Interaction
// Варіант 1: Через Interaction (Avalonia.ReactiveUI)
var result = await Interactions.ShowDialog.Handle(dialog);
// Варіант 2: Через IDialogService
var result = await _dialogService.ShowDialog<User?>(dialog);
if (result != null)
{
Users.Add(result);
}
}
Avalonia пропонує кращі альтернативи WPF-специфічних API для навігації та діалогів.
Ключові висновки:
🎯 ShowDialog<T>()
📁 StorageProvider
🔄 MVVM-навігація
🌍 Нативні діалоги
⚡ Асинхронність
🧪 Тестованість
Переваги Avalonia:
Порівняння з WPF:
| Аспект | WPF | Avalonia |
|---|---|---|
| ShowDialog результат | bool? | T (будь-який тип) |
| File pickers | Windows-only | Кросплатформні |
| API | Синхронний | Асинхронний |
| Діалоги | Windows-стиль | Нативні для ОС |
| MVVM-навігація | ContentControl | ContentControl (ідентично) |
| Тестованість | Складна | Легка |
ShowDialog<T>() для типізованих результатів, StorageProvider для роботи з файлами, та ContentControl + DataTemplate для MVVM-навігації. Це забезпечить кросплатформність та тестованість.Що далі?
Ви завершили статтю про навігацію та діалоги у Avalonia! Наступні теми:
Навігація та керування вікнами. Частина 2: MVVM-навігація
Промисловий підхід до навігації у WPF: ContentControl + DataTemplate, клас NavigationStore з CurrentViewModel, NavigationService через Dependency Injection. Реалізація навігації без Frame та Page — повністю тестовано і MVVM-friendly.
Діалоги та File Pickers у WPF
Стандартні діалогові вікна WPF: MessageBox, OpenFileDialog, SaveFileDialog, FolderBrowserDialog. Custom Dialogs через Window.ShowDialog() та MVVM-friendly Dialog Service pattern.