Към съдържанието
wannacode

Прозорци с WPF

Проблем

Касата в магазина

На касата седи продавач, не програмист. Конзола с „Въведи цена:“ не става: трябват полета, бутони, списък с продуктите в бележката и сума с едър шрифт. Програмата трябва да тръгва на компютъра на касата, с Windows, и да работи и без интернет.

Уеб страница би могла да го направи, но иска браузър и сървър. За програма, която живее на един компютър, има десктоп рамки. В .NET за Windows основната е WPF (Windows Presentation Foundation).

Идеята

Прозорецът е XAML, поведението е C#

WPF програмата има две страни:

  • Как изглежда е описано в XAML: XML с тагове за контролите. <Button Content="Добави" /> е бутон с надпис. Таговете са вложени: прозорецът съдържа подредба, подредбата съдържа контроли.
  • Какво прави е в C#, в кода зад прозореца (code-behind): MainWindow.xaml.cs. Двата файла са един и същ клас MainWindow, разделен на две с partial.

Нов проект с dotnet new wpf -n Shop има четири файла:

Файл Какво е
App.xaml приложението; StartupUri казва кой прозорец да се отвори
App.xaml.cs кодът на приложението (тук празен)
MainWindow.xaml прозорецът: контролите и подредбата им
MainWindow.xaml.cs кодът зад прозореца: обработчиците
App.xaml
<Application x:Class="Shop.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
</Application>

xmlns и xmlns:x в началото казват на компилатора откъде са таговете: първото е контролите на WPF, второто е служебните атрибути като x:Class и x:Name.

WPF работи само на Windows. Отваряш проекта във Visual Studio и натискаш F5 или пишеш dotnet run в папката му. Програмите от урока са проверени, като са компилирани, но не и пуснати: проверката на сайта е на Linux, където прозорец не се отваря. Компилирането хваща грешките в C# и в XAML, например обработчик, който го няма, но не и как изглежда прозорецът.

По-точно, за любопитните
  • Други рамки за десктоп в .NET: WinForms (по-старата, също само за Windows, прозорецът се рисува в дизайнер), WinUI 3 (новата за Windows) и .NET MAUI и Avalonia, които вървят на Windows, macOS и Linux. Avalonia ползва XAML почти като WPF.
  • XAML не е само за WPF: със същия език са и WinUI, MAUI и Avalonia. Затова наученото тук важи и там.

Виж го

Контролите и подредбата

Касата: горе полета за продукт и цена с бутон, в средата списъкът, долу сумата и бутон за махане. MainWindow.xaml:

MainWindow.xaml
<Window x:Class="Shop.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Каса" Width="440" Height="360">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Text="Продукт:" VerticalAlignment="Center" />
<TextBox x:Name="NameBox" Width="150" Margin="6,0" />
<TextBlock Text="Цена:" VerticalAlignment="Center" />
<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Добави" Click="AddButton_Click" />
</StackPanel>
<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" />
<StackPanel Grid.Row="2" Orientation="Horizontal">
<TextBlock x:Name="TotalText" Text="Общо: 0.00 €" FontWeight="Bold" VerticalAlignment="Center" />
<Button Content="Махни избрания" Click="RemoveButton_Click" Margin="16,0,0,0" />
</StackPanel>
</Grid>
</Window>
  • Grid дели мястото на редове и колони. Тук редовете са три: Auto е колкото трябва на съдържанието, * е всичко останало. Контролата казва на кой ред е с Grid.Row="1".
  • StackPanel нарежда контролите една след друга, вертикално или с Orientation="Horizontal" хоризонтално.
  • TextBlock е текст за четене, TextBox е поле за писане, Button е бутон, ListBox е списък, от който можеш да избереш ред.
  • Margin="6,0" е празното място около контролата: 6 отляво и отдясно, 0 горе и долу. С четири числа е ляво, горе, дясно, долу.
  • x:Name="PriceBox" дава име на контролата. В кода зад прозореца тя е поле с това име.
По-точно, за любопитните
  • Width и Height са в единици от 1/96 инч, не в пиксели. На екран с увеличение 150% прозорецът е по-голям в пиксели, но изглежда същият. Затова WPF прозорецът е остър на всеки екран.
  • Освен Grid и StackPanel има DockPanel (контролите се „закачат“ отгоре, отдолу, отстрани, а последната взима средата) и WrapPanel (като текст: щом редът свърши, продължава на следващия).
  • Visual Studio показва прозореца, докато пишеш XAML, и можеш да влачиш контроли в него. XAML-ът обаче си остава текст, който се чете и сравнява в git.

Идеята

Бутонът и обработчикът

Click="AddButton_Click" на бутона казва: „когато ме натиснат, извикай метода AddButton_Click“. Натискането е събитие (event), а методът, който го обработва, е обработчик (event handler). Кодът зад прозореца:

using System.Windows;
namespace Shop;
public partial class MainWindow : Window
{
private readonly List<decimal> prices = new List<decimal>();
public MainWindow()
{
InitializeComponent();
}
// обработчиците са по-долу
}
private void AddButton_Click(object sender, RoutedEventArgs e)
{
if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0)
{
MessageBox.Show("Въведи цена, например 4.50.");
return;
}
ItemsList.Items.Add(NameBox.Text + " – " + price.ToString("0.00") + " €");
prices.Add(price);
ShowTotal();
NameBox.Clear();
PriceBox.Clear();
}
  • InitializeComponent() създава контролите от XAML. Без него прозорецът е празен, а PriceBox е null.
  • Обработчикът винаги има два параметъра. sender е контролата, която е вдигнала събитието (тук бутонът), а e носи подробностите за него.
  • PriceBox.Text е текстът в полето. decimal.TryParse го превръща в число и казва дали е успяло, без изключение. Продавачът може да напише „абв“, а програмата не трябва да пада.
  • ItemsList.Items.Add(...) добавя ред в списъка, а TotalText.Text = ... сменя сумата. Прозорецът се прерисува сам.

TryParse чете числото според регионалните настройки на Windows. На компютър с български настройки продавачът пише 4,50 със запетая.

По-точно, за любопитните
  • Освен Click контролите имат десетки събития: TextChanged на TextBox при всяка буква, SelectionChanged на ListBox при избор на ред, Loaded на прозореца, когато се покаже, Closing, преди да се затвори (там питаш „Да запазя ли?“).
  • Обработчикът върви в нишката на прозореца. Докато работи, прозорецът не отговаря. Ако трябва да вземеш данни от сървър, обработчикът става async void и ползва await: прозорецът остава жив, докато чака. async void е допустимо само за обработчици на събития.
  • В C# сам можеш да закачиш обработчик и от кода: AddButton.Click += AddButton_Click;. Атрибутът в XAML прави същото.

Пробвай

Свържи бутона

Кой атрибут свързва натискането на бутона с метода?

<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Добави" ="AddButton_Click" />

Идеята

Свързване: данните сами стигат до екрана

В касата всеки обработчик помни да обнови списъка, после сумата, после може би брояча. Забравиш ли едно, екранът лъже. При пет такива места грешката е въпрос на време.

По-добрият начин: данните са в отделен клас, а XAML казва „тук покажи Total“. Когато Total се промени, екранът се обновява сам. Това се казва свързване на данни (data binding):

<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" ItemsSource="{Binding Items}" />
<TextBlock Text="{Binding Total, StringFormat='Общо: {0:0.00} €'}" FontWeight="Bold" VerticalAlignment="Center" />
<TextBlock Text="{Binding Items.Count, StringFormat=' ({0} бр.)'}" VerticalAlignment="Center" />

{Binding Total} чете свойството Total на обекта, който е DataContext на прозореца. StringFormat го показва с два знака. Класът с данните:

public class CartItem
{
public string Name { get; set; }
public decimal Price { get; set; }
public override string ToString() => Name + " – " + Price.ToString("0.00") + " €";
}
public class Cart : INotifyPropertyChanged
{
private decimal total;
public ObservableCollection<CartItem> Items { get; } = new ObservableCollection<CartItem>();
public decimal Total
{
get { return total; }
private set
{
total = value;
OnPropertyChanged("Total");
}
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string name)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
public void Add(CartItem item)
{
Items.Add(item);
Total = Items.Sum(i => i.Price);
}
public void Remove(CartItem item)
{
Items.Remove(item);
Total = Items.Sum(i => i.Price);
}
}

Две неща правят свързването живо:

  • INotifyPropertyChanged: интерфейс с едно събитие, PropertyChanged. Когато Total получи нова стойност, OnPropertyChanged("Total") вдига събитието и WPF пречита свойството. ?.Invoke значи „извикай, ако някой слуша“: без слушател събитието е null.
  • ObservableCollection<T>: списък, който сам казва, когато елемент влезе или излезе. ListBox с ItemsSource="{Binding Items}" го следи, а всеки ред показва ToString() на елемента.

{ get; } = new ... е свойство само за четене със стойност от самото начало.

Как промяната стига до екрана, с по-кратък запис на същото свойство Total:

public decimal Total
{
get => total;
private set { total = value; OnPropertyChanged("Total"); }
}
public void Add(CartItem item)
{
Items.Add(item);
Total = Items.Sum(i => i.Price);
}
Стъпка по стъпка, с думи
  1. DataContext = cart: ListBox чете Items, а TextBlock чете Total. Стрелките са свързванията.
  2. Add(Еспресо на зърна): Items.Add. ObservableCollection съобщава за новия ред и ListBox го показва.
  3. Total става 24.90. Сетерът записва стойността и вика OnPropertyChanged("Total").
  4. Събитието стига до TextBlock: WPF пречита Total и текстът става „Общо: 24.90 €“.
  5. Add(Мента): Items.Add. ObservableCollection съобщава за новия ред и ListBox го показва.
  6. Total става 29.10. Сетерът записва стойността и вика OnPropertyChanged("Total").
  7. Събитието стига до TextBlock: WPF пречита Total и текстът става „Общо: 29.10 €“.
  8. Add сменя само данните. Никой ред в кода не пипа ListBox или TextBlock.

Виж го

Касата със свързване

Обработчиците вече само викат Cart. За екрана не мислят:

public partial class MainWindow : Window
{
private readonly Cart cart = new Cart();
public MainWindow()
{
InitializeComponent();
DataContext = cart;
}
private void AddButton_Click(object sender, RoutedEventArgs e)
{
if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0)
{
MessageBox.Show("Въведи цена, например 4.50.");
return;
}
cart.Add(new CartItem { Name = NameBox.Text, Price = price });
NameBox.Clear();
PriceBox.Clear();
}
private void RemoveButton_Click(object sender, RoutedEventArgs e)
{
if (ItemsList.SelectedItem is CartItem item) cart.Remove(item);
}
}
  • DataContext = cart; казва на всички {Binding ...} в прозореца да четат от cart.
  • ItemsList.SelectedItem is CartItem item проверява, че има избран ред, и го дава като CartItem.
  • Items.Count в XAML също е свързано: ObservableCollection съобщава и за промяната на броя.

Свързването с TextBox е двупосочно: <TextBox Text="{Binding Euros, UpdateSourceTrigger=PropertyChanged}" /> пише в свойството при всяка буква, а промяна на свойството от кода сменя текста в полето. Така е направен конверторът в упражнението.

По-точно, за любопитните
  • Подредбата, при която прозорецът почти няма код, а данните и действията са в отделен клас, се казва MVVM (Model-View-ViewModel). Cart е ViewModel: моделът, приготвен за екрана. Бутоните се свързват не с Click, а с команди: Command="{Binding AddCommand}", където AddCommand е свойство от тип ICommand. Тогава кодът зад прозореца е празен, а ViewModel-ът се тества без прозорец.
  • Името в OnPropertyChanged("Total") е текст и правописна грешка не се хваща. По-сигурно е OnPropertyChanged(nameof(Total)) или атрибут [CallerMemberName] на параметъра, който подава името сам.
  • Грешка в {Binding ...}, например {Binding Totl}, не спира компилирането. Програмата тръгва, полето е празно, а предупреждението е в прозореца Output на Visual Studio.

Пробвай

Списъкът не расте

Сумата се обновява, но новите продукти не се появяват в списъка. На кой ред е грешката?

Избери реда с грешката.

Упражнение

Три малки програми

Направи ги с dotnet new wpf и ги пусни на Windows. Решенията от сайта са проверени, като са компилирани.

1. Сметка с бакшиш. Полета за сметката, процента бакшиш (10 по подразбиране) и броя хора, бутон „Сметни“ и резултат. За сметка 46,80 €, 10% и 3 души пише „Бакшиш: 4.68 €, общо: 51.48 €, на човек: 17.16 €“. При невалидно число, отрицателен процент или 0 души пише „Провери числата.“ вместо да падне. Направи го с обработчик на Click.

2. Задачи за деня. Поле, бутон „Добави“, списък и бутон „Махни избраната“. Празна задача и задача, която вече я има, не се добавят. Задачите са в ObservableCollection<string>, който е DataContext на прозореца, а ListBox е с ItemsSource="{Binding}": без път свързването взима самия DataContext. Подреди прозореца с DockPanel: полето горе, бутонът за махане долу, списъкът в средата.

3. Конвертор на валута. Едно поле за евро и два реда, които се сменят, докато пишеш: долари и паунди по примерен курс 1 € = 1,08 $ и 1 € = 0,85 £. Без бутон и без обработчик: полето е свързано двупосочно с Euros на клас с INotifyPropertyChanged, а Dollars и Pounds са свойства, които се смятат от Euros. Когато Euros се смени, класът вдига PropertyChanged и за трите.

Тест

Провери се

1. Къде се описва как изглежда WPF прозорецът?
2. Какво свързва Click="AddButton_Click" в XAML?
3. Свойството Total се променя, но TextBlock с {Binding Total} показва старата стойност. Какво липсва?
4. Защо за списъка в ListBox се ползва ObservableCollection, а не List?
5. На какви системи тръгва WPF програма?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·