Desktop UI

Кросплатформна розробка з Avalonia

Створення додатків, що працюють на Windows, Linux, macOS, Mobile та WebAssembly: структура проєкту, platform-specific код, native API та deployment

Кросплатформна розробка з Avalonia

Одна з найбільших переваг Avalonia — це справжня кросплатформність. На відміну від WPF, який працює тільки на Windows, Avalonia дозволяє створювати додатки, які працюють на Windows, Linux, macOS, Android, iOS, та навіть у браузері через WebAssembly. При цьому ви пишете код один раз, а він працює скрізь — це не просто маркетинговий слоган, а реальність, яку ми детально розглянемо у цій статті.

Проте кросплатформність — це не тільки "написав один раз, працює скрізь". Кожна платформа має свої особливості, конвенції та очікування користувачів. Користувачі macOS очікують бачити меню у Menu Bar, користувачі Windows звикли до системного трею, а користувачі Linux цінують відкритість та налаштовуваність. Успішний кросплатформний додаток — це не просто додаток, який "працює" на всіх платформах, а додаток, який "відчувається нативним" на кожній платформі.

У цій статті ми розглянемо, як структурувати кросплатформний проєкт, як писати platform-specific код коли це необхідно, як використовувати native API кожної платформи (NativeMenu для macOS, TrayIcon для Windows/Linux, platform-specific dialogs), як працювати з файловою системою кросплатформно, як реалізувати drag-n-drop та clipboard, та як тестувати додаток на різних платформах. Ми також обговоримо архітектурні рішення, які дозволяють балансувати між "write once, run anywhere" та "native look and feel".

Словник термінів
  • Cross-platform — кросплатформність, здатність програми працювати на різних операційних системах
  • Runtime Identifier (RID) — ідентифікатор платформи для .NET (win-x64, linux-x64, osx-arm64)
  • Conditional Compilation — умовна компіляція, включення/виключення коду залежно від платформи
  • Native API — нативний API операційної системи
  • Menu Bar — системне меню у верхній частині екрану (macOS)
  • System Tray — системний трей, область для іконок фонових додатків (Windows/Linux)
  • WebAssembly (WASM) — технологія для запуску коду у браузері
  • Shared Project — спільний проєкт, код якого використовується кількома платформо-специфічними проєктами

Архітектура кросплатформного проєкту

Перш ніж почати писати код, важливо зрозуміти, як структурувати кросплатформний проєкт. Існує кілька підходів, кожен з яких має свої переваги та недоліки.

Підхід 1: Single Project (найпростіший)

Найпростіший підхід — один проєкт, який таргетує кілька платформ через TargetFrameworks:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia" Version="11.0.0" />
    <PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
    <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
  </ItemGroup>
</Project>

Цей проєкт працює на Windows, Linux та macOS без додаткових налаштувань. Avalonia автоматично визначає платформу та використовує відповідний backend:

  • Windows: Win32 API
  • Linux: X11 або Wayland
  • macOS: Cocoa (AppKit)

Переваги:

  • Простота — один проєкт, один csproj
  • Легко підтримувати
  • Підходить для більшості desktop-додатків

Недоліки:

  • Складно додати platform-specific код
  • Не підходить для Mobile (Android/iOS) та WASM

Підхід 2: Shared Project + Platform-Specific Projects

Для більш складних сценаріїв (особливо Mobile та WASM) використовується структура з shared project:

MySolution/
├── MyApp.Shared/              # Спільний код (Views, ViewModels, Services)
│   ├── Views/
│   ├── ViewModels/
│   ├── Services/
│   └── MyApp.Shared.csproj
├── MyApp.Desktop/             # Desktop (Windows, Linux, macOS)
│   ├── Program.cs
│   └── MyApp.Desktop.csproj
├── MyApp.Android/             # Android
│   ├── MainActivity.cs
│   └── MyApp.Android.csproj
├── MyApp.iOS/                 # iOS
│   ├── AppDelegate.cs
│   └── MyApp.iOS.csproj
└── MyApp.Browser/             # WebAssembly
    ├── Program.cs
    └── MyApp.Browser.csproj

MyApp.Shared.csproj (спільний код):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia" Version="11.0.0" />
    <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
  </ItemGroup>
</Project>

MyApp.Desktop.csproj (desktop platforms):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyApp.Shared\MyApp.Shared.csproj" />
  </ItemGroup>
</Project>

MyApp.Android.csproj (Android):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0-android</TargetFramework>
    <OutputType>Exe</OutputType>
    <Nullable>enable</Nullable>
    <SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia.Android" Version="11.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\MyApp.Shared\MyApp.Shared.csproj" />
  </ItemGroup>
</Project>

Переваги:

  • Чітке розділення спільного та platform-specific коду
  • Підтримка всіх платформ (Desktop, Mobile, WASM)
  • Гнучкість у налаштуванні кожної платформи

Недоліки:

  • Більш складна структура
  • Потрібно підтримувати кілька csproj файлів

Підхід 3: Multi-targeting (компроміс)

Компромісний варіант — один проєкт з multi-targeting:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFrameworks>net8.0;net8.0-android;net8.0-ios</TargetFrameworks>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Avalonia" Version="11.0.0" />
  </ItemGroup>

  <!-- Desktop-specific -->
  <ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
    <PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
  </ItemGroup>

  <!-- Android-specific -->
  <ItemGroup Condition="'$(TargetFramework)' == 'net8.0-android'">
    <PackageReference Include="Avalonia.Android" Version="11.0.0" />
  </ItemGroup>

  <!-- iOS-specific -->
  <ItemGroup Condition="'$(TargetFramework)' == 'net8.0-ios'">
    <PackageReference Include="Avalonia.iOS" Version="11.0.0" />
  </ItemGroup>
</Project>

Переваги:

  • Один проєкт для всіх платформ
  • Можливість використовувати conditional compilation

Недоліки:

  • Складний csproj файл
  • Важко налаштовувати platform-specific build options
Який підхід обрати?
  • Single Project: Якщо ви робите тільки desktop-додаток (Windows, Linux, macOS) без platform-specific коду
  • Shared Project: Якщо потрібна підтримка Mobile або WASM, або багато platform-specific коду
  • Multi-targeting: Якщо хочете один проєкт, але з підтримкою Mobile (рідко використовується)
Для більшості desktop-додатків рекомендується Single Project. Для повноцінних кросплатформних додатків з Mobile — Shared Project.

Platform Detection — визначення платформи

Коли ваш додаток працює на різних платформах, іноді потрібно визначити, на якій платформі він зараз виконується, щоб адаптувати поведінку.

RuntimeInformation — визначення ОС у runtime

.NET надає клас RuntimeInformation для визначення поточної операційної системи:

using System.Runtime.InteropServices;

public class PlatformService
{
    public static bool IsWindows => 
        RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
    
    public static bool IsLinux => 
        RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
    
    public static bool IsMacOS => 
        RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
    
    public static string GetPlatformName()
    {
        if (IsWindows) return "Windows";
        if (IsLinux) return "Linux";
        if (IsMacOS) return "macOS";
        return "Unknown";
    }
    
    public static string GetArchitecture()
    {
        return RuntimeInformation.ProcessArchitecture.ToString();
        // X64, Arm64, X86, Arm
    }
}

Використання у коді:

public class MainViewModel : ViewModelBase
{
    public string WelcomeMessage => 
        $"Welcome to MyApp on {PlatformService.GetPlatformName()}!";
    
    public void OpenSettings()
    {
        if (PlatformService.IsMacOS)
        {
            // macOS: Settings у Menu Bar
            ShowMacOSPreferences();
        }
        else
        {
            // Windows/Linux: Settings у вікні
            ShowSettingsWindow();
        }
    }
}

Conditional Compilation — умовна компіляція

Для platform-specific коду на етапі компіляції використовуйте preprocessor directives:

public class FileService
{
    public string GetDefaultDownloadsPath()
    {
#if WINDOWS
        return Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            "Downloads");
#elif LINUX
        return Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            "Downloads");
#elif OSX
        return Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            "Downloads");
#else
        return Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
#endif
    }
}

Щоб використовувати ці символи, додайте їх у csproj:

<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
  <DefineConstants>$(DefineConstants);WINDOWS</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
  <DefineConstants>$(DefineConstants);LINUX</DefineConstants>
</PropertyGroup>

<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('OSX'))">
  <DefineConstants>$(DefineConstants);OSX</DefineConstants>
</PropertyGroup>
Обережно з conditional compilationConditional compilation корисна, але може ускладнити код та тестування. Використовуйте її тільки коли:
  • Код принципово різний для різних платформ
  • Потрібно виключити залежності для певних платформ
  • Потрібна максимальна продуктивність (без runtime checks)
Для більшості випадків краще використовувати RuntimeInformation та dependency injection з platform-specific implementations.

Platform-specific DI registrations

Елегантний спосіб обробки platform-specific коду — через Dependency Injection:

// Інтерфейс
public interface IPlatformService
{
    string GetPlatformName();
    void ShowNotification(string title, string message);
    void OpenUrl(string url);
}

// Windows implementation
public class WindowsPlatformService : IPlatformService
{
    public string GetPlatformName() => "Windows";
    
    public void ShowNotification(string title, string message)
    {
        // Windows Toast Notification
        var toastXml = ToastNotificationManager.GetTemplateContent(
            ToastTemplateType.ToastText02);
        // ...
    }
    
    public void OpenUrl(string url)
    {
        Process.Start(new ProcessStartInfo
        {
            FileName = url,
            UseShellExecute = true
        });
    }
}

// macOS implementation
public class MacOSPlatformService : IPlatformService
{
    public string GetPlatformName() => "macOS";
    
    public void ShowNotification(string title, string message)
    {
        // macOS Notification Center
        // Використання NSUserNotification через P/Invoke або Xamarin.Mac
    }
    
    public void OpenUrl(string url)
    {
        Process.Start("open", url);
    }
}

// Linux implementation
public class LinuxPlatformService : IPlatformService
{
    public string GetPlatformName() => "Linux";
    
    public void ShowNotification(string title, string message)
    {
        // libnotify через notify-send
        Process.Start("notify-send", $"\"{title}\" \"{message}\"");
    }
    
    public void OpenUrl(string url)
    {
        Process.Start("xdg-open", url);
    }
}

// Реєстрація у DI
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddPlatformServices(
        this IServiceCollection services)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            services.AddSingleton<IPlatformService, WindowsPlatformService>();
        }
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            services.AddSingleton<IPlatformService, MacOSPlatformService>();
        }
        else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            services.AddSingleton<IPlatformService, LinuxPlatformService>();
        }
        
        return services;
    }
}

// Використання у ViewModel
public class MainViewModel : ViewModelBase
{
    private readonly IPlatformService _platformService;
    
    public MainViewModel(IPlatformService platformService)
    {
        _platformService = platformService;
    }
    
    public void ShowWelcome()
    {
        _platformService.ShowNotification(
            "Welcome",
            $"Running on {_platformService.GetPlatformName()}");
    }
}

Цей підхід має кілька переваг:

  • Чистий код без #if директив
  • Легко тестувати (можна замокати IPlatformService)
  • Легко додати нову платформу (просто нова implementation)
  • Дотримання SOLID принципів

NativeMenu — системне меню macOS

Одна з найбільших відмінностей між macOS та іншими платформами — це Menu Bar. На macOS меню додатку розташоване у верхній частині екрану (системний Menu Bar), а не у вікні додатку. Avalonia надає NativeMenu для роботи з цією особливістю.

Різниця між Menu та NativeMenu

Menu (звичайне меню):

  • Розташоване у вікні додатку
  • Працює на всіх платформах однаково
  • Виглядає як частина вікна

NativeMenu (системне меню):

  • Розташоване у системному Menu Bar (macOS)
  • На Windows/Linux ігнорується або відображається як звичайне меню
  • Виглядає як нативне меню ОС

Створення NativeMenu

using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;

public class App : Application
{
    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            desktop.MainWindow = new MainWindow();
            
            // Створюємо NativeMenu тільки для macOS
            if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            {
                SetupMacOSMenu(desktop.MainWindow);
            }
        }
        
        base.OnFrameworkInitializationCompleted();
    }
    
    private void SetupMacOSMenu(Window mainWindow)
    {
        var menu = new NativeMenu();
        
        // Application Menu (перше меню, з назвою додатку)
        var appMenu = new NativeMenuItem("MyApp");
        var appSubMenu = new NativeMenu();
        
        appSubMenu.Add(new NativeMenuItem
        {
            Header = "About MyApp",
            Command = ReactiveCommand.Create(() => ShowAboutDialog())
        });
        
        appSubMenu.Add(new NativeMenuItemSeparator());
        
        appSubMenu.Add(new NativeMenuItem
        {
            Header = "Preferences...",
            Gesture = KeyGesture.Parse("Cmd+,"),
            Command = ReactiveCommand.Create(() => ShowPreferences())
        });
        
        appSubMenu.Add(new NativeMenuItemSeparator());
        
        appSubMenu.Add(new NativeMenuItem
        {
            Header = "Quit MyApp",
            Gesture = KeyGesture.Parse("Cmd+Q"),
            Command = ReactiveCommand.Create(() => 
                ((IClassicDesktopStyleApplicationLifetime)Application.Current!
                    .ApplicationLifetime!).Shutdown())
        });
        
        appMenu.Menu = appSubMenu;
        menu.Add(appMenu);
        
        // File Menu
        var fileMenu = new NativeMenuItem("File");
        var fileSubMenu = new NativeMenu();
        
        fileSubMenu.Add(new NativeMenuItem
        {
            Header = "New",
            Gesture = KeyGesture.Parse("Cmd+N"),
            Command = ReactiveCommand.Create(() => CreateNewDocument())
        });
        
        fileSubMenu.Add(new NativeMenuItem
        {
            Header = "Open...",
            Gesture = KeyGesture.Parse("Cmd+O"),
            Command = ReactiveCommand.Create(() => OpenDocument())
        });
        
        fileSubMenu.Add(new NativeMenuItemSeparator());
        
        fileSubMenu.Add(new NativeMenuItem
        {
            Header = "Save",
            Gesture = KeyGesture.Parse("Cmd+S"),
            Command = ReactiveCommand.Create(() => SaveDocument())
        });
        
        fileMenu.Menu = fileSubMenu;
        menu.Add(fileMenu);
        
        // Edit Menu
        var editMenu = new NativeMenuItem("Edit");
        var editSubMenu = new NativeMenu();
        
        editSubMenu.Add(new NativeMenuItem
        {
            Header = "Cut",
            Gesture = KeyGesture.Parse("Cmd+X")
        });
        
        editSubMenu.Add(new NativeMenuItem
        {
            Header = "Copy",
            Gesture = KeyGesture.Parse("Cmd+C")
        });
        
        editSubMenu.Add(new NativeMenuItem
        {
            Header = "Paste",
            Gesture = KeyGesture.Parse("Cmd+V")
        });
        
        editMenu.Menu = editSubMenu;
        menu.Add(editMenu);
        
        // Встановлюємо меню для вікна
        NativeMenu.SetMenu(mainWindow, menu);
    }
}

Гібридний підхід: Menu + NativeMenu

Для кросплатформних додатків рекомендується гібридний підхід:

<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="MyApp.Views.MainWindow">
    
    <!-- Звичайне меню для Windows/Linux -->
    <Window.Styles>
        <Style Selector="Window">
            <Setter Property="IsVisible" Value="True" />
        </Style>
        <!-- Приховуємо звичайне меню на macOS -->
        <Style Selector="Window[IsOSX=True] > DockPanel > Menu">
            <Setter Property="IsVisible" Value="False" />
        </Style>
    </Window.Styles>
    
    <DockPanel>
        <!-- Звичайне меню (Windows/Linux) -->
        <Menu DockPanel.Dock="Top">
            <MenuItem Header="_File">
                <MenuItem Header="_New" InputGesture="Ctrl+N" />
                <MenuItem Header="_Open" InputGesture="Ctrl+O" />
                <Separator />
                <MenuItem Header="_Save" InputGesture="Ctrl+S" />
                <MenuItem Header="_Exit" />
            </MenuItem>
            <MenuItem Header="_Edit">
                <MenuItem Header="_Cut" InputGesture="Ctrl+X" />
                <MenuItem Header="_Copy" InputGesture="Ctrl+C" />
                <MenuItem Header="_Paste" InputGesture="Ctrl+V" />
            </MenuItem>
        </Menu>
        
        <!-- Основний контент -->
        <ContentControl Content="{Binding CurrentView}" />
    </DockPanel>
</Window>
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        
        // Встановлюємо attached property для стилів
        if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
        {
            this.SetValue(IsOSXProperty, true);
        }
    }
    
    public static readonly StyledProperty<bool> IsOSXProperty =
        AvaloniaProperty.Register<MainWindow, bool>(nameof(IsOSX));
    
    public bool IsOSX
    {
        get => GetValue(IsOSXProperty);
        set => SetValue(IsOSXProperty, value);
    }
}

Цей підхід дозволяє:

  • На macOS використовувати NativeMenu у Menu Bar
  • На Windows/Linux використовувати звичайне Menu у вікні
  • Зберігати один XAML файл для всіх платформ
macOS Human Interface GuidelinesПри створенні меню для macOS дотримуйтесь Apple Human Interface Guidelines:
  • Перше меню завжди має назву додатку (не "File")
  • "About", "Preferences" та "Quit" у Application Menu
  • Використовуйте стандартні keyboard shortcuts (Cmd+Q для Quit, Cmd+, для Preferences)
  • Додайте стандартні меню: File, Edit, View, Window, Help
  • Використовуйте ellipsis (...) для команд, які відкривають діалоги
Це зробить ваш додаток більш "нативним" для користувачів macOS.

Clipboard — кросплатформний буфер обміну

Avalonia надає кросплатформний API для роботи з буфером обміну через інтерфейс IClipboard.

Базові операції з Clipboard

using Avalonia.Input.Platform;

public class ClipboardService
{
    private readonly IClipboard? _clipboard;
    
    public ClipboardService(Window window)
    {
        // Отримуємо clipboard з TopLevel (Window)
        _clipboard = window.Clipboard;
    }
    
    // Копіювання тексту
    public async Task SetTextAsync(string text)
    {
        if (_clipboard != null)
        {
            await _clipboard.SetTextAsync(text);
        }
    }
    
    // Отримання тексту
    public async Task<string?> GetTextAsync()
    {
        if (_clipboard != null)
        {
            return await _clipboard.GetTextAsync();
        }
        return null;
    }
    
    // Очищення clipboard
    public async Task ClearAsync()
    {
        if (_clipboard != null)
        {
            await _clipboard.ClearAsync();
        }
    }
}

Використання у ViewModel

public class TextEditorViewModel : ViewModelBase
{
    private readonly IClipboard _clipboard;
    private string _selectedText = string.Empty;
    
    public TextEditorViewModel(IClipboard clipboard)
    {
        _clipboard = clipboard;
        
        CopyCommand = ReactiveCommand.CreateFromTask(CopyAsync);
        CutCommand = ReactiveCommand.CreateFromTask(CutAsync);
        PasteCommand = ReactiveCommand.CreateFromTask(PasteAsync);
    }
    
    public string SelectedText
    {
        get => _selectedText;
        set => this.RaiseAndSetIfChanged(ref _selectedText, value);
    }
    
    public ICommand CopyCommand { get; }
    public ICommand CutCommand { get; }
    public ICommand PasteCommand { get; }
    
    private async Task CopyAsync()
    {
        if (!string.IsNullOrEmpty(SelectedText))
        {
            await _clipboard.SetTextAsync(SelectedText);
        }
    }
    
    private async Task CutAsync()
    {
        if (!string.IsNullOrEmpty(SelectedText))
        {
            await _clipboard.SetTextAsync(SelectedText);
            // Видаляємо виділений текст
            SelectedText = string.Empty;
        }
    }
    
    private async Task PasteAsync()
    {
        var text = await _clipboard.GetTextAsync();
        if (!string.IsNullOrEmpty(text))
        {
            SelectedText = text;
        }
    }
}

Реєстрація Clipboard у DI

public partial class App : Application
{
    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            var services = new ServiceCollection();
            
            // Реєструємо Clipboard як singleton
            services.AddSingleton<IClipboard>(provider =>
            {
                // Clipboard доступний тільки після створення Window
                var window = desktop.MainWindow 
                    ?? throw new InvalidOperationException("MainWindow not created");
                return window.Clipboard 
                    ?? throw new InvalidOperationException("Clipboard not available");
            });
            
            services.AddTransient<TextEditorViewModel>();
            
            var serviceProvider = services.BuildServiceProvider();
            
            desktop.MainWindow = new MainWindow
            {
                DataContext = serviceProvider.GetRequiredService<TextEditorViewModel>()
            };
        }
        
        base.OnFrameworkInitializationCompleted();
    }
}

Складні типи даних у Clipboard

Clipboard підтримує не тільки текст, але й інші типи даних:

public class AdvancedClipboardService
{
    private readonly IClipboard _clipboard;
    
    public AdvancedClipboardService(IClipboard clipboard)
    {
        _clipboard = clipboard;
    }
    
    // Копіювання файлів
    public async Task SetFilesAsync(IEnumerable<string> filePaths)
    {
        var dataObject = new DataObject();
        dataObject.Set(DataFormats.Files, filePaths.ToArray());
        await _clipboard.SetDataObjectAsync(dataObject);
    }
    
    // Отримання файлів
    public async Task<IEnumerable<string>?> GetFilesAsync()
    {
        var formats = await _clipboard.GetFormatsAsync();
        if (formats.Contains(DataFormats.Files))
        {
            var dataObject = await _clipboard.GetDataAsync(DataFormats.Files);
            return dataObject as IEnumerable<string>;
        }
        return null;
    }
    
    // Копіювання зображення
    public async Task SetImageAsync(Bitmap bitmap)
    {
        var dataObject = new DataObject();
        dataObject.Set(DataFormats.Bitmap, bitmap);
        await _clipboard.SetDataObjectAsync(dataObject);
    }
    
    // Перевірка наявності даних певного типу
    public async Task<bool> ContainsTextAsync()
    {
        var formats = await _clipboard.GetFormatsAsync();
        return formats.Contains(DataFormats.Text);
    }
    
    public async Task<bool> ContainsFilesAsync()
    {
        var formats = await _clipboard.GetFormatsAsync();
        return formats.Contains(DataFormats.Files);
    }
}
DataFormats — стандартні формати данихAvalonia підтримує стандартні формати даних:
  • DataFormats.Text — простий текст
  • DataFormats.Files — список файлів
  • DataFormats.Bitmap — зображення
  • Custom formats — власні формати (для drag-n-drop між вікнами вашого додатку)
Різні платформи можуть підтримувати різні формати. Завжди перевіряйте GetFormatsAsync() перед отриманням даних.

Drag-and-Drop — кросплатформне перетягування

Avalonia надає кросплатформний API для drag-and-drop операцій, який працює однаково на всіх платформах.

Базовий Drag-and-Drop

<!-- Джерело drag (можна перетягувати) -->
<Border Background="LightBlue"
        Width="100" Height="100"
        DragDrop.AllowDrop="False"
        PointerPressed="OnDragStart">
    <TextBlock Text="Drag me!" 
               HorizontalAlignment="Center"
               VerticalAlignment="Center" />
</Border>

<!-- Ціль drop (можна скидати) -->
<Border Background="LightGreen"
        Width="200" Height="200"
        Margin="20"
        DragDrop.AllowDrop="True"
        DragOver="OnDragOver"
        Drop="OnDrop">
    <TextBlock x:Name="DropTargetText"
               Text="Drop here"
               HorizontalAlignment="Center"
               VerticalAlignment="Center" />
</Border>
public partial class DragDropWindow : Window
{
    public DragDropWindow()
    {
        InitializeComponent();
    }
    
    private async void OnDragStart(object? sender, PointerPressedEventArgs e)
    {
        if (sender is Border border)
        {
            var dataObject = new DataObject();
            dataObject.Set(DataFormats.Text, "Hello from drag!");
            
            var result = await DragDrop.DoDragDrop(e, dataObject, DragDropEffects.Copy);
            
            if (result == DragDropEffects.Copy)
            {
                // Drag успішний
            }
        }
    }
    
    private void OnDragOver(object? sender, DragEventArgs e)
    {
        // Перевіряємо, чи можемо прийняти дані
        if (e.Data.Contains(DataFormats.Text))
        {
            e.DragEffects = DragDropEffects.Copy;
        }
        else
        {
            e.DragEffects = DragDropEffects.None;
        }
    }
    
    private void OnDrop(object? sender, DragEventArgs e)
    {
        if (e.Data.Contains(DataFormats.Text))
        {
            var text = e.Data.Get(DataFormats.Text) as string;
            DropTargetText.Text = $"Dropped: {text}";
        }
    }
}

Drag-and-Drop файлів

Особливо корисний сценарій — перетягування файлів з файлового менеджера у додаток:

<Border Background="LightGray"
        Width="400" Height="300"
        DragDrop.AllowDrop="True"
        DragOver="OnFileDragOver"
        Drop="OnFileDrop">
    <StackPanel VerticalAlignment="Center">
        <TextBlock Text="Drop files here"
                   HorizontalAlignment="Center"
                   FontSize="20" />
        <ItemsControl x:Name="FilesList" Margin="20">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding}" />
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
    </StackPanel>
</Border>
private void OnFileDragOver(object? sender, DragEventArgs e)
{
    // Перевіряємо, чи це файли
    if (e.Data.Contains(DataFormats.Files))
    {
        e.DragEffects = DragDropEffects.Copy;
    }
    else
    {
        e.DragEffects = DragDropEffects.None;
    }
}

private void OnFileDrop(object? sender, DragEventArgs e)
{
    if (e.Data.Contains(DataFormats.Files))
    {
        var files = e.Data.GetFiles();
        if (files != null)
        {
            var filePaths = files
                .Select(f => f.Path.LocalPath)
                .ToList();
            
            FilesList.ItemsSource = filePaths;
            
            // Обробка файлів
            foreach (var filePath in filePaths)
            {
                ProcessFile(filePath);
            }
        }
    }
}

private void ProcessFile(string filePath)
{
    // Ваша логіка обробки файлу
    Console.WriteLine($"Processing: {filePath}");
}

MVVM-friendly Drag-and-Drop

Для MVVM підходу можна створити Behavior:

using Avalonia.Xaml.Interactivity;

public class DropFileBehavior : Behavior<Control>
{
    public static readonly StyledProperty<ICommand?> CommandProperty =
        AvaloniaProperty.Register<DropFileBehavior, ICommand?>(nameof(Command));
    
    public ICommand? Command
    {
        get => GetValue(CommandProperty);
        set => SetValue(CommandProperty, value);
    }
    
    protected override void OnAttached()
    {
        base.OnAttached();
        
        if (AssociatedObject != null)
        {
            DragDrop.SetAllowDrop(AssociatedObject, true);
            AssociatedObject.AddHandler(DragDrop.DropEvent, OnDrop);
            AssociatedObject.AddHandler(DragDrop.DragOverEvent, OnDragOver);
        }
    }
    
    protected override void OnDetaching()
    {
        if (AssociatedObject != null)
        {
            AssociatedObject.RemoveHandler(DragDrop.DropEvent, OnDrop);
            AssociatedObject.RemoveHandler(DragDrop.DragOverEvent, OnDragOver);
        }
        
        base.OnDetaching();
    }
    
    private void OnDragOver(object? sender, DragEventArgs e)
    {
        if (e.Data.Contains(DataFormats.Files))
        {
            e.DragEffects = DragDropEffects.Copy;
        }
        else
        {
            e.DragEffects = DragDropEffects.None;
        }
    }
    
    private void OnDrop(object? sender, DragEventArgs e)
    {
        if (e.Data.Contains(DataFormats.Files) && Command?.CanExecute(null) == true)
        {
            var files = e.Data.GetFiles();
            if (files != null)
            {
                var filePaths = files.Select(f => f.Path.LocalPath).ToArray();
                Command.Execute(filePaths);
            }
        }
    }
}

Використання у XAML:

<Border Background="LightGray" Width="400" Height="300">
    <Interaction.Behaviors>
        <behaviors:DropFileBehavior Command="{Binding DropFilesCommand}" />
    </Interaction.Behaviors>
    
    <TextBlock Text="Drop files here" />
</Border>

ViewModel:

public class FileProcessorViewModel : ViewModelBase
{
    public FileProcessorViewModel()
    {
        DropFilesCommand = ReactiveCommand.Create<string[]>(OnFilesDropped);
    }
    
    public ICommand DropFilesCommand { get; }
    
    private void OnFilesDropped(string[] filePaths)
    {
        foreach (var filePath in filePaths)
        {
            // Обробка файлу
            Console.WriteLine($"Dropped: {filePath}");
        }
    }
}