Прозорци с 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 |
кодът зад прозореца: обработчиците |
<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:
<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" />Click="AddButton_Click". Името на метода трябва да съвпада точно, иначе проектът не се компилира: „'MainWindow' does not contain a definition for '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);}Стъпка по стъпка, с думи
- DataContext = cart: ListBox чете Items, а TextBlock чете Total. Стрелките са свързванията.
- Add(Еспресо на зърна): Items.Add. ObservableCollection съобщава за новия ред и ListBox го показва.
- Total става 24.90. Сетерът записва стойността и вика OnPropertyChanged("Total").
- Събитието стига до TextBlock: WPF пречита Total и текстът става „Общо: 24.90 €“.
- Add(Мента): Items.Add. ObservableCollection съобщава за новия ред и ListBox го показва.
- Total става 29.10. Сетерът записва стойността и вика OnPropertyChanged("Total").
- Събитието стига до TextBlock: WPF пречита Total и текстът става „Общо: 29.10 €“.
- 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.
Пробвай
Списъкът не расте
Сумата се обновява, но новите продукти не се появяват в списъка. На кой ред е грешката?
Избери реда с грешката.
List<CartItem> не казва на никого, че е променен. ListBox е прочел списъка, когато е бил празен, и не знае, че после са влезли продукти. С ObservableCollection<CartItem> всяко Add и Remove вдига събитие и ListBox се обновява. Сумата работи, защото Total вдига PropertyChanged.
Упражнение
Три малки програми
Направи ги с 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 и за трите.
Тест