Введение
В мире кроссплатформенной разработки под .NET долгое время доминировали Windows-ориентированные технологии WPF и WinForms, которые плохо подходили для создания приложений под Linux и macOS. С появлением Avalonia UI ситуация кардинально изменилась. Avalonia — это кроссплатформенный XAML-фреймворк для создания нативных графических интерфейсов, который работает на Windows, Linux, macOS, iOS, Android и даже в браузерах через WebAssembly. Построенный на принципах, схожих с WPF, но с современной архитектурой, Avalonia позволяет разработчикам C# создавать красивые, производительные приложения с единой кодовой базой для всех платформ, что делает его идеальным выбором для современных desktop- и mobile-приложений.
Основы Avalonia UI: архитектура и первое приложение
1.1. Установка и настройка окружения
1.1.1. Создание первого проекта
// Установка шаблонов Avalonia // dotnet new install Avalonia.Templates // Создание нового проекта // dotnet new avalonia.app -n MyAvaloniaApp -o ./MyAvaloniaApp // Program.cs - точка входа public class Program { // Инициализация Avalonia [STAThread] public static void Main(string[] args) => BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); // Конфигурация Avalonia приложения public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>() .UsePlatformDetect() // Автоматическое определение платформы .LogToTrace() // Логирование в Trace .WithInterFont(); // Использование шрифта Inter } // App.axaml.cs - основное приложение public partial class App : Application { public override void Initialize() { AvaloniaXamlLoader.Load(this); // Загрузка XAML } public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Создание главного окна для desktop desktop.MainWindow = new MainWindow(); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { // Для mobile/браузера singleView.MainView = new MainView(); } base.OnFrameworkInitializationCompleted(); } }
1.1.2. Основное окно и XAML разметка
<!-- MainWindow.axaml --> <Windowxmlns="https://github.com/avaloniaui" 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" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="MyAvaloniaApp.MainWindow" Title="My Avalonia App" Width="800" Height="450" WindowStartupLocation="CenterScreen"> <!-- Стили и ресурсы --> <Window.Styles> <StyleIncludeSource="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/> <StyleIncludeSource="avares://Avalonia.Themes.Default/Accents/BaseDark.xaml"/> </Window.Styles> <DockPanelBackground="#1E1E1E"> <!-- Верхняя панель инструментов --> <BorderDockPanel.Dock="Top" Height="40" Background="#252526" BorderBrush="#3E3E40" BorderThickness="0 0 0 1"> <StackPanelOrientation="Horizontal" HorizontalAlignment="Left" Margin="10 0"> <ButtonContent="Файл" Background="Transparent" BorderBrush="Transparent" Foreground="White" Margin="0 0 10 0"> <Button.ContextMenu> <ContextMenu> <MenuItemHeader="Новый проект"/> <MenuItemHeader="Открыть"/> <Separator/> <MenuItemHeader="Выход"/> </ContextMenu> </Button.ContextMenu> </Button> <ButtonContent="Правка" Background="Transparent" BorderBrush="Transparent" Foreground="White"/> <ButtonContent="Вид" Background="Transparent" BorderBrush="Transparent" Foreground="White"/> </StackPanel> </Border> <!-- Основной контент --> <Grid> <Grid.ColumnDefinitions> <ColumnDefinitionWidth="200"/> <ColumnDefinitionWidth="*"/> <ColumnDefinitionWidth="300"/> </Grid.ColumnDefinitions> <!-- Левая панель навигации --> <BorderGrid.Column="0" Background="#252526" BorderBrush="#3E3E40" BorderThickness="0 0 1 0"> <StackPanelMargin="10"> <TextBlockText="Навигация" Foreground="#CCCCCC" FontWeight="Bold" Margin="0 0 0 10"/> <ListBoxx:Name="NavigationList" Background="Transparent" BorderThickness="0" ItemsSource="{Binding NavigationItems}" SelectedItem="{Binding SelectedNavigationItem}"> <ListBox.ItemTemplate> <DataTemplate> <StackPanelOrientation="Horizontal" Margin="5 2"> <PathIconData="{Binding IconPath}" Width="16" Height="16" Margin="0 0 10 0" Foreground="#569CD6"/> <TextBlockText="{Binding Title}" Foreground="White" VerticalAlignment="Center"/> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </StackPanel> </Border> <!-- Центральная область --> <BorderGrid.Column="1" Background="#1E1E1E" Padding="20"> <ContentControlContent="{Binding CurrentView}" ContentTemplate="{Binding ViewTemplateSelector}"/> </Border> <!-- Правая панель свойств --> <BorderGrid.Column="2" Background="#252526" BorderBrush="#3E3E40" BorderThickness="1 0 0 0"> <ScrollViewer> <StackPanelMargin="15" Spacing="10"> <TextBlockText="Свойства" Foreground="#CCCCCC" FontWeight="Bold"/> <SeparatorBackground="#3E3E40"/> <StackPanelSpacing="5"> <TextBlockText="Имя:" Foreground="#9CDCFE"/> <TextBoxText="{Binding SelectedItem.Name}" Watermark="Введите имя" Background="#3C3C3C" Foreground="White" BorderBrush="#3E3E40"/> </StackPanel> <StackPanelSpacing="5"> <TextBlockText="Описание:" Foreground="#9CDCFE"/> <TextBoxText="{Binding SelectedItem.Description}" AcceptsReturn="True" TextWrapping="Wrap" Height="60" Background="#3C3C3C" Foreground="White" BorderBrush="#3E3E40"/> </StackPanel> <StackPanelSpacing="5"> <TextBlockText="Тип:" Foreground="#9CDCFE"/> <ComboBoxItemsSource="{Binding ItemTypes}" SelectedItem="{Binding SelectedItem.Type}" Background="#3C3C3C" Foreground="White" BorderBrush="#3E3E40"/> </StackPanel> <ButtonContent="Сохранить" HorizontalAlignment="Stretch" Background="#0E639C" Foreground="White" Margin="0 20 0 0" Command="{Binding SaveCommand}"/> </StackPanel> </ScrollViewer> </Border> </Grid> <!-- Статус бар --> <BorderDockPanel.Dock="Bottom" Height="25" Background="#007ACC" BorderBrush="#3E3E40" BorderThickness="0 1 0 0"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinitionWidth="*"/> <ColumnDefinitionWidth="Auto"/> </Grid.ColumnDefinitions> <TextBlockText="{Binding StatusMessage}" Foreground="White" VerticalAlignment="Center" Margin="10 0"/> <StackPanelGrid.Column="1" Orientation="Horizontal" Margin="0 0 10 0"> <TextBlockText="Готово" Foreground="White" VerticalAlignment="Center" Margin="0 0 10 0"/> <ProgressBarWidth="100" Height="10" Value="{Binding Progress}" Maximum="100" IsIndeterminate="{Binding IsProcessing}" VerticalAlignment="Center"/> </StackPanel> </Grid> </Border> </DockPanel> </Window>
1.2. ViewModel и привязка данных
1.2.1. Реализация ViewModel с ReactiveUI
// Установка: dotnet add package ReactiveUI // dotnet add package ReactiveUI.Avalonia using ReactiveUI; using System.Reactive.Linq; using System.Collections.ObjectModel; public class MainViewModel : ReactiveObject { private string _statusMessage = "Готово"; private double _progress; private bool _isProcessing; private NavigationItem _selectedNavigationItem; private object _currentView; private ProjectItem _selectedItem; public MainViewModel() { // Инициализация коллекций NavigationItems = new ObservableCollection<NavigationItem> { new NavigationItem { Title = "Обзор", IconPath = "M12 2L1 21h22L12 2zm0 4l7.53 13H4.47L12 6z", ViewType = typeof(DashboardView) }, new NavigationItem { Title = "Проекты", IconPath = "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", ViewType = typeof(ProjectsView) }, new NavigationItem { Title = "Настройки", IconPath = "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z", ViewType = typeof(SettingsView) } }; ItemTypes = new ObservableCollection<string> { "Проект", "Документ", "Изображение", "Видео", "Аудио" }; // Команды SaveCommand = ReactiveCommand.CreateFromTask(SaveItemAsync); LoadDataCommand = ReactiveCommand.CreateFromTask(LoadDataAsync); // Реактивные свойства this.WhenAnyValue(x => x.SelectedNavigationItem) .Where(x => x != null) .Subscribe(item => { CurrentView = Activator.CreateInstance(item.ViewType); StatusMessage = $"Выбрано: {item.Title}"; }); this.WhenAnyValue(x => x.SelectedItem) .Subscribe(item => { if (item != null) { StatusMessage = $"Выбран элемент: {item.Name}"; } }); // Загрузка данных при старте LoadDataCommand.Execute().Subscribe(); } // Свойства public ObservableCollection<NavigationItem> NavigationItems { get; } public ObservableCollection<string> ItemTypes { get; } public ObservableCollection<ProjectItem> ProjectItems { get; } = new(); public string StatusMessage { get => _statusMessage; set => this.RaiseAndSetIfChanged(ref _statusMessage, value); } public double Progress { get => _progress; set => this.RaiseAndSetIfChanged(ref _progress, value); } public bool IsProcessing { get => _isProcessing; set => this.RaiseAndSetIfChanged(ref _isProcessing, value); } public NavigationItem SelectedNavigationItem { get => _selectedNavigationItem; set => this.RaiseAndSetIfChanged(ref _selectedNavigationItem, value); } public object CurrentView { get => _currentView; set => this.RaiseAndSetIfChanged(ref _currentView, value); } public ProjectItem SelectedItem { get => _selectedItem; set => this.RaiseAndSetIfChanged(ref _selectedItem, value); } // Команды public ReactiveCommand<Unit, Unit> SaveCommand { get; } public ReactiveCommand<Unit, Unit> LoadDataCommand { get; } // Методы private async Task SaveItemAsync() { IsProcessing = true; try { await Task.Delay(1000); // Имитация сохранения Progress = 100; StatusMessage = "Элемент сохранен успешно!"; await Task.Delay(1000); Progress = 0; } finally { IsProcessing = false; } } private async Task LoadDataAsync() { IsProcessing = true; try { // Имитация загрузки данных for (int i = 0; i <= 100; i += 10) { Progress = i; await Task.Delay(100); } // Загрузка тестовых данных ProjectItems.Clear(); for (int i = 1; i <= 10; i++) { ProjectItems.Add(new ProjectItem { Id = i, Name = $"Проект {i}", Description = $"Описание проекта {i}", Type = "Проект", CreatedDate = DateTime.Now.AddDays(-i), Size = i * 1024 * 1024 }); } StatusMessage = "Данные загружены успешно"; } finally { IsProcessing = false; } } } // Модели данных public class NavigationItem { public string Title { get; set; } public string IconPath { get; set; } public Type ViewType { get; set; } } public class ProjectItem : ReactiveObject { private int _id; private string _name; private string _description; private string _type; private DateTime _createdDate; private long _size; public int Id { get => _id; set => this.RaiseAndSetIfChanged(ref _id, value); } public string Name { get => _name; set => this.RaiseAndSetIfChanged(ref _name, value); } public string Description { get => _description; set => this.RaiseAndSetIfChanged(ref _description, value); } public string Type { get => _type; set => this.RaiseAndSetIfChanged(ref _type, value); } public DateTime CreatedDate { get => _createdDate; set => this.RaiseAndSetIfChanged(ref _createdDate, value); } public long Size { get => _size; set => this.RaiseAndSetIfChanged(ref _size, value); } [IgnoreDataMember] public string FormattedSize => FormatSize(Size); [IgnoreDataMember] public string FormattedDate => CreatedDate.ToString("dd.MM.yyyy HH:mm"); private static string FormatSize(long bytes) { string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; int counter = 0; decimal number = bytes; while (Math.Round(number / 1024) >= 1) { number /= 1024; counter++; } return $"{number:n1} {suffixes[counter]}"; } }
Сложные UI компоненты и кастомные контролы
2.1. Создание кастомных контролов
2.1.1. Кастомный графический редактор
// CustomCanvas.axaml.cs public partial class CustomCanvas : UserControl { private Point? _startPoint; private List<Shape> _shapes = new(); private Shape? _currentShape; private Pen _pen; public static readonly StyledProperty<Color> StrokeColorProperty = AvaloniaProperty.Register<CustomCanvas, Color>(nameof(StrokeColor), Colors.Black); public static readonly StyledProperty<double> StrokeThicknessProperty = AvaloniaProperty.Register<CustomCanvas, double>(nameof(StrokeThickness), 2); public static readonly StyledProperty<Color> FillColorProperty = AvaloniaProperty.Register<CustomCanvas, Color>(nameof(FillColor), Colors.Transparent); public static readonly StyledProperty<DrawingMode> ModeProperty = AvaloniaProperty.Register<CustomCanvas, DrawingMode>(nameof(Mode), DrawingMode.Select); public CustomCanvas() { InitializeComponent(); _pen = new Pen(Brushes.Black, 2); PointerPressed += OnPointerPressed; PointerMoved += OnPointerMoved; PointerReleased += OnPointerReleased; } public Color StrokeColor { get => GetValue(StrokeColorProperty); set => SetValue(StrokeColorProperty, value); } public double StrokeThickness { get => GetValue(StrokeThicknessProperty); set => SetValue(StrokeThicknessProperty, value); } public Color FillColor { get => GetValue(FillColorProperty); set => SetValue(FillColorProperty, value); } public DrawingMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); } public IReadOnlyList<Shape> Shapes => _shapes.AsReadOnly(); private void OnPointerPressed(object sender, PointerPressedEventArgs e) { var point = e.GetPosition(this); switch (Mode) { case DrawingMode.Rectangle: _startPoint = point; _currentShape = new RectangleShape { Stroke = new SolidColorBrush(StrokeColor), StrokeThickness = StrokeThickness, Fill = new SolidColorBrush(FillColor), StartPoint = point, EndPoint = point }; break; case DrawingMode.Ellipse: _startPoint = point; _currentShape = new EllipseShape { Stroke = new SolidColorBrush(StrokeColor), StrokeThickness = StrokeThickness, Fill = new SolidColorBrush(FillColor), StartPoint = point, EndPoint = point }; break; case DrawingMode.Line: _startPoint = point; _currentShape = new LineShape { Stroke = new SolidColorBrush(StrokeColor), StrokeThickness = StrokeThickness, StartPoint = point, EndPoint = point }; break; case DrawingMode.Freehand: _startPoint = point; _currentShape = new PolylineShape { Stroke = new SolidColorBrush(StrokeColor), StrokeThickness = StrokeThickness, Points = new List<Point> { point } }; break; } if (_currentShape != null) { _shapes.Add(_currentShape); InvalidateVisual(); } } private void OnPointerMoved(object sender, PointerEventArgs e) { if (_startPoint == null || _currentShape == null) return; var point = e.GetPosition(this); switch (Mode) { case DrawingMode.Rectangle: case DrawingMode.Ellipse: case DrawingMode.Line: if (_currentShape is IResizableShape resizable) { resizable.EndPoint = point; } break; case DrawingMode.Freehand: if (_currentShape is PolylineShape polyline) { polyline.Points.Add(point); } break; } InvalidateVisual(); } private void OnPointerReleased(object sender, PointerReleasedEventArgs e) { _startPoint = null; _currentShape = null; } public override void Render(DrawingContext context) { base.Render(context); // Отрисовка сетки DrawGrid(context); // Отрисовка всех фигур foreach (var shape in _shapes) { shape.Draw(context); } // Отрисовка временной фигуры _currentShape?.Draw(context); } private void DrawGrid(DrawingContext context) { var gridSize = 20; var bounds = Bounds; var pen = new Pen(Brushes.Gray, 0.5); // Вертикальные линии for (double x = 0; x < bounds.Width; x += gridSize) { context.DrawLine(pen, new Point(x, 0), new Point(x, bounds.Height)); } // Горизонтальные линии for (double y = 0; y < bounds.Height; y += gridSize) { context.DrawLine(pen, new Point(0, y), new Point(bounds.Width, y)); } } public void Clear() { _shapes.Clear(); InvalidateVisual(); } public void Undo() { if (_shapes.Count > 0) { _shapes.RemoveAt(_shapes.Count - 1); InvalidateVisual(); } } public void SaveToFile(string filePath) { var target = new RenderTargetBitmap(new PixelSize((int)Bounds.Width, (int)Bounds.Height)); using (var context = target.CreateDrawingContext()) { Render(context); } target.Save(filePath); } } // Базовый класс фигуры public abstract class Shape { public IBrush? Stroke { get; set; } public double StrokeThickness { get; set; } public IBrush? Fill { get; set; } public abstract void Draw(DrawingContext context); } // Интерфейс для изменяемых фигур public interface IResizableShape { Point StartPoint { get; set; } Point EndPoint { get; set; } } // Конкретные реализации фигур public class RectangleShape : Shape, IResizableShape { public Point StartPoint { get; set; } public Point EndPoint { get; set; } public override void Draw(DrawingContext context) { var rect = new Rect(StartPoint, EndPoint); if (Fill != null) { context.FillRectangle(Fill, rect); } if (Stroke != null) { context.DrawRectangle(Stroke, new Pen(Stroke, StrokeThickness), rect); } } } public class EllipseShape : Shape, IResizableShape { public Point StartPoint { get; set; } public Point EndPoint { get; set; } public override void Draw(DrawingContext context) { var rect = new Rect(StartPoint, EndPoint); var center = rect.Center; var radiusX = rect.Width / 2; var radiusY = rect.Height / 2; var geometry = new EllipseGeometry(center, radiusX, radiusY); if (Fill != null) { context.DrawGeometry(Fill, null, geometry); } if (Stroke != null) { context.DrawGeometry(null, new Pen(Stroke, StrokeThickness), geometry); } } } public class LineShape : Shape, IResizableShape { public Point StartPoint { get; set; } public Point EndPoint { get; set; } public override void Draw(DrawingContext context) { if (Stroke != null) { context.DrawLine(new Pen(Stroke, StrokeThickness), StartPoint, EndPoint); } } } public class PolylineShape : Shape { public List<Point> Points { get; set; } = new(); public override void Draw(DrawingContext context) { if (Points.Count < 2 || Stroke == null) return; var pen = new Pen(Stroke, StrokeThickness); for (int i = 0; i < Points.Count - 1; i++) { context.DrawLine(pen, Points[i], Points[i + 1]); } } } public enum DrawingMode { Select, Rectangle, Ellipse, Line, Freehand }
2.2. Сложные DataTemplate и стили
2.2.1. DataTemplateSelector для динамического отображения
// DataTemplateSelector для разных типов данных public class ProjectItemTemplateSelector : IDataTemplate { public IDataTemplate? ProjectTemplate { get; set; } public IDataTemplate? DocumentTemplate { get; set; } public IDataTemplate? ImageTemplate { get; set; } public IDataTemplate? DefaultTemplate { get; set; } public Control Build(object? param) { if (param is ProjectItem item) { return item.Type switch { "Проект" => ProjectTemplate?.Build(param) ?? DefaultTemplate?.Build(param)!, "Документ" => DocumentTemplate?.Build(param) ?? DefaultTemplate?.Build(param)!, "Изображение" => ImageTemplate?.Build(param) ?? DefaultTemplate?.Build(param)!, _ => DefaultTemplate?.Build(param)! }; } return new TextBlock { Text = "Неизвестный тип" }; } public bool Match(object? data) { return data is ProjectItem; } } // Использование в XAML <DataTemplate x:Key="ProjectTemplate"> <Border Background="#2D2D30" BorderBrush="#3E3E40" BorderThickness="1" CornerRadius="4" Margin="0 0 0 5"> <StackPanel Orientation="Horizontal" Margin="10"> <PathIcon Data="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" Width="24" Height="24" Foreground="#569CD6" Margin="0 0 10 0"/> <StackPanel> <TextBlock Text="{Binding Name}" FontWeight="Bold" Foreground="White"/> <TextBlock Text="{Binding Description}" Foreground="#CCCCCC" TextWrapping="Wrap"/> <StackPanel Orientation="Horizontal" Spacing="10" Margin="0 5 0 0"> <TextBlock Text="{Binding FormattedDate}" Foreground="#808080" FontSize="11"/> <TextBlock Text="{Binding FormattedSize}" Foreground="#808080" FontSize="11"/> </StackPanel> </StackPanel> </StackPanel> </Border> </DataTemplate> <DataTemplate x:Key="DocumentTemplate"> <Border Background="#2D2D30" BorderBrush="#3E3E40" BorderThickness="1" CornerRadius="4" Margin="0 0 0 5"> <StackPanel Orientation="Horizontal" Margin="10"> <PathIcon Data="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" Width="24" Height="24" Foreground="#CE9178" Margin="0 0 10 0"/> <!-- Остальной контент аналогично --> </StackPanel> </Border> </DataTemplate>
Анимации и эффекты
3.1. Использование анимаций в Avalonia
3.1.1. Анимация элементов интерфейса
// Анимация через XAML <UserControl.Resources> <Style Selector="Button.animated"> <Setter Property="RenderTransformOrigin" Value="0.5,0.5"/> <Setter Property="RenderTransform"> <TransformGroup> <ScaleTransform x:Name="ScaleTransform" ScaleX="1" ScaleY="1"/> </TransformGroup> </Setter> <Style.Animations> <Animation Duration="0:0:0.2"> <KeyFrame Cue="0%"> <Setter Property="Opacity" Value="1"/> </KeyFrame> <KeyFrame Cue="100%"> <Setter Property="Opacity" Value="0.7"/> </KeyFrame> </Animation> </Style.Animations> </Style> <Style Selector="Button.animated:pointerover /template/ ContentPresenter"> <Style.Animations> <Animation Duration="0:0:0.1"> <KeyFrame Cue="0%"> <Setter Property="ScaleTransform.ScaleX" Value="1"/> <Setter Property="ScaleTransform.ScaleY" Value="1"/> </KeyFrame> <KeyFrame Cue="100%"> <Setter Property="ScaleTransform.ScaleX" Value="1.05"/> <Setter Property="ScaleTransform.ScaleY" Value="1.05"/> </KeyFrame> </Animation> </Style.Animations> </Style> <Style Selector="Border.glow"> <Style.Animations> <Animation Duration="0:0:1" IterationCount="Infinite"> <KeyFrame Cue="0%"> <Setter Property="BoxShadow" Value="0 0 5px 2px #569CD6, 0 0 10px 5px rgba(86, 156, 214, 0.5)"/> </KeyFrame> <KeyFrame Cue="50%"> <Setter Property="BoxShadow" Value="0 0 10px 5px #569CD6, 0 0 20px 10px rgba(86, 156, 214, 0.7)"/> </KeyFrame> <KeyFrame Cue="100%"> <Setter Property="BoxShadow" Value="0 0 5px 2px #569CD6, 0 0 10px 5px rgba(86, 156, 214, 0.5)"/> </KeyFrame> </Animation> </Style.Animations> </Style> </UserControl.Resources> // Анимация через код public class AnimatedPanel : Panel { private readonly Dictionary<Control, AnimationState> _animations = new(); protected override void ChildrenChanged(object sender, NotifyCollectionChangedEventArgs e) { base.ChildrenChanged(sender, e); if (e.NewItems != null) { foreach (Control child in e.NewItems) { AnimateAdd(child); } } if (e.OldItems != null) { foreach (Control child in e.OldItems) { AnimateRemove(child); } } } private async void AnimateAdd(Control control) { control.Opacity = 0; control.RenderTransform = new ScaleTransform(0.5, 0.5); control.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); var animation = new Animation { Duration = TimeSpan.FromSeconds(0.3), Easing = new CubicEaseInOut(), Children = { new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0), new Setter(ScaleTransform.ScaleXProperty, 0.5), new Setter(ScaleTransform.ScaleYProperty, 0.5) } }, new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1), new Setter(ScaleTransform.ScaleXProperty, 1), new Setter(ScaleTransform.ScaleYProperty, 1) } } } }; await animation.RunAsync(control); } private async void AnimateRemove(Control control) { var animation = new Animation { Duration = TimeSpan.FromSeconds(0.2), Easing = new CubicEaseIn(), Children = { new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 1), new Setter(ScaleTransform.ScaleXProperty, 1), new Setter(ScaleTransform.ScaleYProperty, 1) } }, new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 0), new Setter(ScaleTransform.ScaleXProperty, 0.5), new Setter(ScaleTransform.ScaleYProperty, 0.5) } } } }; await animation.RunAsync(control); Children.Remove(control); } } // Параллакс эффект для прокрутки public class ParallaxScrollViewer : ScrollViewer { private readonly List<ParallaxElement> _parallaxElements = new(); protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); var delta = e.Delta.Y * 20; // Скорость параллакса ApplyParallax(delta); } public void RegisterParallaxElement(Control element, double factor) { _parallaxElements.Add(new ParallaxElement(element, factor)); } private void ApplyParallax(double delta) { foreach (var element in _parallaxElements) { var transform = element.Control.RenderTransform as TranslateTransform; if (transform == null) { transform = new TranslateTransform(); element.Control.RenderTransform = transform; } transform.Y += delta * element.Factor; } } private class ParallaxElement { public Control Control { get; } public double Factor { get; } public ParallaxElement(Control control, double factor) { Control = control; Factor = factor; } } }
Кроссплатформенные возможности и развертывание
4.1. Работа с файловой системой на разных платформах
4.1.1. Абстракция файловой системы
public interface IFileSystemService { Task<string> PickFolderAsync(); Task<string[]> PickFilesAsync(string filter = "All files (*.*)|*.*"); Task<string> SaveFileAsync(string defaultFileName); Task<string> ReadTextAsync(string path); Task WriteTextAsync(string path, string content); Task<Stream> OpenReadAsync(string path); Task<Stream> OpenWriteAsync(string path); bool FileExists(string path); bool DirectoryExists(string path); void CreateDirectory(string path); IEnumerable<string> GetFiles(string path, string searchPattern = "*.*"); } public class FileSystemService : IFileSystemService { private readonly Window _parentWindow; public FileSystemService(Window parentWindow) { _parentWindow = parentWindow; } public async Task<string> PickFolderAsync() { var dialog = new OpenFolderDialog { Title = "Выберите папку", Directory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; var result = await dialog.ShowAsync(_parentWindow); return result; } public async Task<string[]> PickFilesAsync(string filter = "All files (*.*)|*.*") { var dialog = new OpenFileDialog { Title = "Выберите файлы", AllowMultiple = true, Filters = ParseFilter(filter), Directory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; var result = await dialog.ShowAsync(_parentWindow); return result ?? Array.Empty<string>(); } public async Task<string> SaveFileAsync(string defaultFileName) { var dialog = new SaveFileDialog { Title = "Сохранить файл", InitialFileName = defaultFileName, DefaultExtension = Path.GetExtension(defaultFileName), Directory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) }; var result = await dialog.ShowAsync(_parentWindow); return result; } private List<FileDialogFilter> ParseFilter(string filter) { var filters = new List<FileDialogFilter>(); var parts = filter.Split('|'); for (int i = 0; i < parts.Length; i += 2) { if (i + 1 < parts.Length) { filters.Add(new FileDialogFilter { Name = parts[i], Extensions = parts[i + 1] .Split(';') .Select(ext => ext.Trim('*', '.')) .ToList() }); } } return filters; } public async Task<string> ReadTextAsync(string path) { using var reader = new StreamReader(path); return await reader.ReadToEndAsync(); } public async Task WriteTextAsync(string path, string content) { using var writer = new StreamWriter(path); await writer.WriteAsync(content); } public async Task<Stream> OpenReadAsync(string path) { return File.OpenRead(path); } public async Task<Stream> OpenWriteAsync(string path) { return File.OpenWrite(path); } public bool FileExists(string path) => File.Exists(path); public bool DirectoryExists(string path) => Directory.Exists(path); public void CreateDirectory(string path) { Directory.CreateDirectory(path); } public IEnumerable<string> GetFiles(string path, string searchPattern = "*.*") { return Directory.GetFiles(path, searchPattern); } }
4.2. Публикация для разных платформ
4.2.1. Конфигурация публикации
<!-- MyAvaloniaApp.csproj --> <ProjectSdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <BuiltInComInteropSupport>true</BuiltInComInteropSupport> <ApplicationManifest>app.manifest</ApplicationManifest> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> </PropertyGroup> <ItemGroup> <AvaloniaResourceInclude="Assets\**"/> </ItemGroup> <ItemGroup> <!-- Основные пакеты Avalonia --> <PackageReferenceInclude="Avalonia" Version="11.0.6" /> <PackageReferenceInclude="Avalonia.Desktop" Version="11.0.6" /> <PackageReferenceInclude="Avalonia.Themes.Fluent" Version="11.0.6" /> <PackageReferenceInclude="Avalonia.Fonts.Inter" Version="11.0.6" /> <!-- Пакеты для конкретных платформ --> <PackageReferenceInclude="Avalonia.Native" Version="11.0.6" Condition="'$(RuntimeIdentifier)' == 'osx-x64'" /> <PackageReferenceInclude="Avalonia.LinuxFramebuffer" Version="11.0.6" Condition="'$(RuntimeIdentifier)' == 'linux-x64'" /> <!-- Дополнительные пакеты --> <PackageReferenceInclude="Avalonia.ReactiveUI" Version="11.0.6" /> <PackageReferenceInclude="CommunityToolkit.Mvvm" Version="8.2.1" /> <PackageReferenceInclude="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReferenceInclude="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReferenceInclude="Serilog.Extensions.Logging.File" Version="3.0.0" /> </ItemGroup> </Project>
4.2.2. Скрипты сборки для разных платформ
#!/bin/bash # build-all-platforms.sh # Windows dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/win-x64 # Linux dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/linux-x64 # macOS dotnet publish -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/osx-x64 # Создание установщиков # Для Windows (используя WiX или Inno Setup) # Для Linux (deb/rpm пакеты) # Для macOS (dmg пакет) echo "Build completed for all platforms!"
4.3. Нативные интеграции
4.3.1. Работа с системными уведомлениями
public interface INotificationService { void ShowNotification(string title, string message, NotificationType type = NotificationType.Info); Task<bool> ShowDialogAsync(string title, string message, DialogButtons buttons); } public class NotificationService : INotificationService { private readonly Window _mainWindow; private readonly ILogger<NotificationService> _logger; public NotificationService(Window mainWindow, ILogger<NotificationService> logger) { _mainWindow = mainWindow; _logger = logger; } public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info) { // Использование системных уведомлений if (OperatingSystem.IsWindows()) { ShowWindowsNotification(title, message, type); } else if (OperatingSystem.IsLinux()) { ShowLinuxNotification(title, message, type); } else if (OperatingSystem.IsMacOS()) { ShowMacOSNotification(title, message, type); } else { // Fallback на кастомное уведомление ShowCustomNotification(title, message, type); } } private void ShowWindowsNotification(string title, string message, NotificationType type) { try { // Использование Windows API для уведомлений var icon = type switch { NotificationType.Success => System.Drawing.SystemIcons.Information, NotificationType.Warning => System.Drawing.SystemIcons.Warning, NotificationType.Error => System.Drawing.SystemIcons.Error, _ => System.Drawing.SystemIcons.Information }; // Создание всплывающего уведомления var notification = new System.Windows.Forms.NotifyIcon { Icon = icon, Visible = true, BalloonTipTitle = title, BalloonTipText = message, BalloonTipIcon = ConvertToWinFormsIcon(type) }; notification.ShowBalloonTip(3000); // Автоматическое скрытие Task.Delay(4000).ContinueWith(_ => { notification.Dispose(); }); } catch (Exception ex) { _logger.LogError(ex, "Failed to show Windows notification"); ShowCustomNotification(title, message, type); } } private void ShowCustomNotification(string title, string message, NotificationType type) { // Кастомное уведомление на Avalonia var notificationWindow = new NotificationWindow { Title = title, Message = message, Type = type }; notificationWindow.Show(); // Автоматическое закрытие через 3 секунды Task.Delay(3000).ContinueWith(_ => { _mainWindow.Dispatcher.InvokeAsync(() => { notificationWindow.Close(); }); }); } public async Task<bool> ShowDialogAsync(string title, string message, DialogButtons buttons) { var dialog = new MessageBoxWindow { Title = title, Message = message, Buttons = buttons }; var result = await dialog.ShowDialog<bool>(_mainWindow); return result; } } // Окно для кастомных уведомлений public class NotificationWindow : Window { public static readonly StyledProperty<string> MessageProperty = AvaloniaProperty.Register<NotificationWindow, string>(nameof(Message)); public static readonly StyledProperty<NotificationType> TypeProperty = AvaloniaProperty.Register<NotificationWindow, NotificationType>(nameof(Type)); public NotificationWindow() { InitializeComponent(); // Позиционирование в правом нижнем углу PositionWindow(); // Анимация появления Opacity = 0; this.Animate(OpacityProperty, 0, 1, TimeSpan.FromMilliseconds(300)); } private void PositionWindow() { var screen = Screens.Primary; if (screen != null) { var workingArea = screen.WorkingArea; Position = new PixelPoint( (int)(workingArea.Right - Width - 20), (int)(workingArea.Bottom - Height - 20) ); } } public string Message { get => GetValue(MessageProperty); set => SetValue(MessageProperty, value); } public NotificationType Type { get => GetValue(TypeProperty); set => SetValue(TypeProperty, value); } } public enum NotificationType { Info, Success, Warning, Error } public enum DialogButtons { Ok, OkCancel, YesNo, YesNoCancel }
Производительность и оптимизация
5.1. Виртуализация списков
5.1.1. Использование ItemsRepeater для больших списков
public class VirtualizedListControl : UserControl { private readonly ItemsRepeater _itemsRepeater; private readonly ObservableCollection<DataItem> _items; private readonly Func<DataItem, Control> _itemTemplate; public VirtualizedListControl(IEnumerable<DataItem> items, Func<DataItem, Control> itemTemplate) { _items = new ObservableCollection<DataItem>(items); _itemTemplate = itemTemplate; InitializeComponent(); } private void InitializeComponent() { _itemsRepeater = new ItemsRepeater { ItemsSource = _items, ItemTemplate = new FuncTemplate<DataItem, Control>(item => { var control = _itemTemplate(item); // Ленивая загрузка изображений if (control is IAsyncImageLoader imageLoader) { imageLoader.LoadImageAsync(item.ImageUrl); } return control; }), Layout = new UniformGridLayout { Orientation = Orientation.Vertical, MinRowSpacing = 10, MinColumnSpacing = 10, ItemsStretch = UniformGridLayoutItemsStretch.Fill }, VirtualizationInfo = new ItemVirtualizationInfo { VirtualizationMode = ItemVirtualizationMode.Recycling } }; var scrollViewer = new ScrollViewer { Content = _itemsRepeater, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; Content = scrollViewer; // Оптимизация: загрузка по мере прокрутки scrollViewer.ScrollChanged += OnScrollChanged; } private async void OnScrollChanged(object sender, ScrollChangedEventArgs e) { var offset = e.Offset.Y; var extent = e.Extent.Height; var viewport = e.Viewport.Height; // Загрузка дополнительных данных при достижении конца if (offset + viewport >= extent - 100) // 100px до конца { await LoadMoreItemsAsync(); } } private async Task LoadMoreItemsAsync() { // Симуляция загрузки данных await Task.Delay(500); var newItems = await DataService.LoadMoreItemsAsync(_items.Count, 50); foreach (var item in newItems) { _items.Add(item); } } public void FilterItems(Func<DataItem, bool> predicate) { // Использование CollectionView для фильтрации var view = new DataCollectionView(_items); view.Filter = item => predicate((DataItem)item); _itemsRepeater.ItemsSource = view; } public void SortItems<TKey>(Func<DataItem, TKey> keySelector, bool descending = false) { var sorted = descending ? _items.OrderByDescending(keySelector) : _items.OrderBy(keySelector); _items.Clear(); foreach (var item in sorted) { _items.Add(item); } } } // Оптимизация отрисовки public class OptimizedPanel : Panel { private readonly HashSet<Control> _visibleChildren = new(); protected override Size MeasureOverride(Size availableSize) { var visibleRect = new Rect(0, 0, availableSize.Width, availableSize.Height); foreach (var child in Children) { var childRect = new Rect(child.Bounds.Position, child.DesiredSize); if (visibleRect.Intersects(childRect)) { child.Measure(availableSize); _visibleChildren.Add(child); } else { child.IsVisible = false; _visibleChildren.Remove(child); } } return availableSize; } protected override Size ArrangeOverride(Size finalSize) { foreach (var child in _visibleChildren) { child.Arrange(child.Bounds); child.IsVisible = true; } return finalSize; } }
Заключение
Avalonia UI представляет собой мощный, современный фреймворк для создания кроссплатформенных графических приложений на C#, который успешно сочетает в себе лучшие черты WPF и современные подходы к разработке UI.

