Ventanas con WPF
En resumen: WPF es el framework de .NET para programas de escritorio en Windows: la ventana se describe en XAML, el código detrás de ella es C#, y el enlace de datos actualiza la pantalla solo.
Problema
La caja de la tienda
En la caja trabaja un vendedor, no un programador. Una consola con «Ingresa el precio:» no sirve: hacen falta campos, botones, una lista con los productos del recibo y el total en letra grande. El programa tiene que arrancar en la computadora de la caja, que tiene Windows, y funcionar también sin internet.
Una página web podría hacerlo, pero necesita un navegador y un servidor. Para un programa que vive en una sola computadora existen los frameworks de escritorio. En .NET para Windows, el principal es WPF (Windows Presentation Foundation).
La idea
La ventana es XAML, el comportamiento es C#
Un programa WPF tiene dos lados:
- Cómo se ve se describe en XAML: XML con etiquetas para los controles.
<Button Content="Añadir" />es un botón con un texto. Las etiquetas van anidadas: la ventana contiene un panel de distribución, y el panel contiene controles. - Qué hace está en C#, en el código detrás de la ventana (code-behind):
MainWindow.xaml.cs. Los dos archivos son una misma claseMainWindow, partida en dos conpartial.
Un proyecto nuevo con dotnet new wpf -n Shop tiene cuatro archivos:
| Archivo | Qué es |
|---|---|
App.xaml |
la aplicación; StartupUri dice qué ventana abrir |
App.xaml.cs |
el código de la aplicación (aquí, vacío) |
MainWindow.xaml |
la ventana: los controles y su distribución |
MainWindow.xaml.cs |
el código detrás de la ventana: los manejadores |
<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 y xmlns:x al principio le dicen al compilador de dónde vienen las etiquetas: el primero son los controles de WPF, el segundo son los atributos especiales como x:Class y x:Name.
WPF funciona solo en Windows. Abres el proyecto en Visual Studio y pulsas F5, o escribes dotnet run en su carpeta. Los programas de esta lección se revisaron compilándolos, pero no ejecutándolos: la revisión del sitio corre en Linux, donde no se puede abrir una ventana. Al compilar se detectan los errores en C# y en XAML, por ejemplo un manejador que no existe, pero no cómo se ve la ventana.
Más precisamente, para curiosos
- Otros frameworks de escritorio en .NET: WinForms (el más antiguo, también solo para Windows, donde la ventana se dibuja en un diseñador), WinUI 3 (el nuevo para Windows), y .NET MAUI y Avalonia, que funcionan en Windows, macOS y Linux. Avalonia usa XAML casi igual que WPF.
- XAML no es solo para WPF: WinUI, MAUI y Avalonia usan el mismo lenguaje. Por eso lo que aprendes aquí también sirve allí.
Míralo
Los controles y su distribución
La caja: arriba, campos para el producto y el precio con un botón; en el medio, la lista; abajo, el total y un botón para quitar un producto. 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="Caja" 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="Producto:" VerticalAlignment="Center" /> <TextBox x:Name="NameBox" Width="150" Margin="6,0" /> <TextBlock Text="Precio:" VerticalAlignment="Center" /> <TextBox x:Name="PriceBox" Width="60" Margin="6,0" /> <Button Content="Añadir" 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="Total: 0.00 €" FontWeight="Bold" VerticalAlignment="Center" /> <Button Content="Quitar seleccionado" Click="RemoveButton_Click" Margin="16,0,0,0" /> </StackPanel> </Grid></Window>Griddivide el espacio en filas y columnas. Aquí hay tres filas:Autoes lo que necesite el contenido, y*es todo lo que queda. Un control dice en qué fila está conGrid.Row="1".StackPanelcoloca los controles uno tras otro, en vertical, o en horizontal conOrientation="Horizontal".TextBlockes texto para leer,TextBoxes un campo para escribir,Buttones un botón yListBoxes una lista de la que puedes elegir una fila.Margin="6,0"es el espacio vacío alrededor del control: 6 a la izquierda y a la derecha, 0 arriba y abajo. Con cuatro números es izquierda, arriba, derecha, abajo.x:Name="PriceBox"le da un nombre al control. En el código detrás de la ventana, es un campo con ese nombre.
Más precisamente, para curiosos
WidthyHeightestán en unidades de 1/96 de pulgada, no en píxeles. En una pantalla con escala al 150%, la ventana tiene más píxeles, pero se ve igual. Por eso una ventana WPF se ve nítida en cualquier pantalla.- Además de
GridyStackPanelexistenDockPanel(los controles se «acoplan» arriba, abajo o a los lados, y el último ocupa el centro) yWrapPanel(como el texto: cuando una fila se llena, sigue en la siguiente). - Visual Studio muestra la ventana mientras escribes XAML, y puedes arrastrar controles a ella. Pero el XAML sigue siendo texto que puedes leer y comparar en git.
La idea
El botón y el manejador
Click="AddButton_Click" en el botón dice: «cuando me pulsen, llama al método AddButton_Click». La pulsación es un evento (event), y el método que lo atiende es un manejador de eventos (event handler). El código detrás de la ventana:
using System.Windows;
namespace Shop;
public partial class MainWindow : Window{ private readonly List<decimal> prices = new List<decimal>();
public MainWindow() { InitializeComponent(); }
// los manejadores de eventos están más abajo} private void AddButton_Click(object sender, RoutedEventArgs e) { if (!decimal.TryParse(PriceBox.Text, out decimal price) || price <= 0) { MessageBox.Show("Ingresa un precio, por ejemplo 4.50."); return; } ItemsList.Items.Add(NameBox.Text + " – " + price.ToString("0.00") + " €"); prices.Add(price); ShowTotal(); NameBox.Clear(); PriceBox.Clear(); }InitializeComponent()crea los controles a partir del XAML. Sin él, la ventana está vacía yPriceBoxesnull.- Un manejador siempre tiene dos parámetros.
senderes el control que lanzó el evento (aquí, el botón), yetrae los detalles del evento. PriceBox.Textes el texto del campo.decimal.TryParselo convierte en un número y te dice si lo logró, sin excepción. El vendedor podría escribir «abc», y el programa no debe caerse.ItemsList.Items.Add(...)añade una fila a la lista, yTotalText.Text = ...cambia el total. La ventana se vuelve a dibujar sola.
TryParse lee el número según la configuración regional de Windows. En una computadora con configuración española, el vendedor escribe 4,50 con coma; con configuración de Estados Unidos, 4.50 con punto.
Más precisamente, para curiosos
- Además de
Click, los controles tienen decenas de eventos:TextChangeden unTextBoxcon cada letra,SelectionChangeden unListBoxal elegir una fila,Loadeden la ventana cuando aparece yClosingantes de que se cierre (ahí es donde preguntas «¿Guardar los cambios?»). - El manejador se ejecuta en el hilo de la ventana. Mientras trabaja, la ventana no responde. Si tienes que traer datos de un servidor, el manejador pasa a ser
async voidy usaawait: la ventana sigue respondiendo mientras espera.async voidsolo es aceptable para manejadores de eventos. - En C# también puedes conectar un manejador tú mismo desde el código:
AddButton.Click += AddButton_Click;. El atributo en XAML hace lo mismo.
Pruébalo
Conecta el botón
¿Qué atributo conecta la pulsación del botón con el método?
<TextBox x:Name="PriceBox" Width="60" Margin="6,0" />
<Button Content="Añadir" ="AddButton_Click" />Click="AddButton_Click". El nombre del método tiene que coincidir exactamente, o el proyecto no compila: «'MainWindow' does not contain a definition for 'AddButton_Click'».
La idea
Enlace: los datos llegan solos a la pantalla
En la caja, cada manejador tiene que acordarse de actualizar la lista, después el total, después quizá un contador. Si olvidas uno, la pantalla miente. Con cinco lugares así, el error es solo cuestión de tiempo.
La mejor forma: los datos viven en una clase aparte, y el XAML dice «aquí muestra Total». Cuando Total cambia, la pantalla se actualiza sola. Esto se llama enlace de datos (data binding):
<ListBox x:Name="ItemsList" Grid.Row="1" Margin="0,10" ItemsSource="{Binding Items}" /><TextBlock Text="{Binding Total, StringFormat='Total: {0:0.00} €'}" FontWeight="Bold" VerticalAlignment="Center" /><TextBlock Text="{Binding Items.Count, StringFormat=' ({0} uds.)'}" VerticalAlignment="Center" />{Binding Total} lee la propiedad Total del objeto que es el DataContext de la ventana. StringFormat la muestra con dos decimales. La clase con los datos:
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); }}Dos cosas hacen que el enlace esté vivo:
INotifyPropertyChanged: una interfaz con un solo evento,PropertyChanged. CuandoTotalrecibe un valor nuevo,OnPropertyChanged("Total")lanza el evento y WPF vuelve a leer la propiedad.?.Invokesignifica «llámalo si alguien escucha»: sin nadie que escuche, el evento esnull.ObservableCollection<T>: una lista que avisa sola cuando un elemento entra o sale. UnListBoxconItemsSource="{Binding Items}"la vigila, y cada fila muestra elToString()del elemento.
{ get; } = new ... es una propiedad de solo lectura con un valor desde el principio.
Cómo llega el cambio a la pantalla, con una forma más corta de escribir la misma propiedad 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);}Paso a paso, en palabras
- DataContext = cart: el ListBox lee Items y el TextBlock lee Total. Las flechas son los enlaces.
- Add(Espresso en grano): Items.Add. La ObservableCollection avisa de la nueva fila y el ListBox la muestra.
- Total pasa a 24.90. El setter guarda el valor y llama a OnPropertyChanged("Total").
- El evento llega al TextBlock: WPF vuelve a leer Total y el texto pasa a ser «Total: 24.90 €».
- Add(Menta): Items.Add. La ObservableCollection avisa de la nueva fila y el ListBox la muestra.
- Total pasa a 29.10. El setter guarda el valor y llama a OnPropertyChanged("Total").
- El evento llega al TextBlock: WPF vuelve a leer Total y el texto pasa a ser «Total: 29.10 €».
- Add solo cambia los datos. Ninguna línea del código toca el ListBox ni el TextBlock.
Míralo
La caja con enlace
Ahora los manejadores solo llaman a Cart. No piensan en la pantalla:
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("Ingresa un precio, por ejemplo 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;les dice a todos los{Binding ...}de la ventana que lean decart.ItemsList.SelectedItem is CartItem itemcomprueba que hay una fila elegida y te la da comoCartItem.Items.Counten el XAML también está enlazado: laObservableCollectionavisa además cuando cambia la cantidad.
El enlace con un TextBox funciona en los dos sentidos: <TextBox Text="{Binding Euros, UpdateSourceTrigger=PropertyChanged}" /> escribe en la propiedad con cada letra, y un cambio de la propiedad desde el código cambia el texto del campo. Así funciona el conversor del ejercicio.
Más precisamente, para curiosos
- La organización en la que la ventana casi no tiene código, y los datos y las acciones están en una clase aparte, se llama MVVM (Model-View-ViewModel).
Cartes un ViewModel: el modelo, preparado para la pantalla. Los botones no se enlazan conClick, sino con comandos:Command="{Binding AddCommand}", dondeAddCommandes una propiedad de tipoICommand. Así el código detrás de la ventana queda vacío, y el ViewModel se puede probar sin ventana. - El nombre en
OnPropertyChanged("Total")es texto, y una errata no se detecta. Es más seguro escribirOnPropertyChanged(nameof(Total)), o poner el atributo[CallerMemberName]en el parámetro, que pasa el nombre solo. - Un error en
{Binding ...}, como{Binding Totl}, no detiene la compilación. El programa arranca, el campo queda vacío y el aviso aparece en la ventana Output de Visual Studio.
Pruébalo
La lista no crece
El total se actualiza, pero los productos nuevos no aparecen en la lista. ¿En qué línea está el error?
Elige la línea con el error.
List<CartItem> no le avisa a nadie de que ha cambiado. El ListBox leyó la lista cuando estaba vacía y no sabe que después entraron productos. Con ObservableCollection<CartItem>, cada Add y Remove lanza un evento y el ListBox se actualiza. El total funciona porque Total lanza PropertyChanged.
Ejercicio
Tres programas pequeños
Créalos con dotnet new wpf y ejecútalos en Windows. Las soluciones del sitio se revisaron compilándolas.
1. Una cuenta con propina. Campos para la cuenta, el porcentaje de propina (10 por defecto) y el número de personas, un botón «Calcular» y un resultado. Para una cuenta de 46,80 €, 10% y 3 personas, dice «Propina: 4.68 €, total: 51.48 €, por persona: 17.16 €». Con un número no válido, un porcentaje negativo o 0 personas, dice «Revisa los números.» en lugar de caerse. Hazlo con un manejador de Click.
2. Tareas del día. Un campo, un botón «Añadir», una lista y un botón «Quitar seleccionada». Una tarea vacía, o una tarea que ya está, no se añade. Las tareas están en una ObservableCollection<string> que es el DataContext de la ventana, y el ListBox tiene ItemsSource="{Binding}": sin ruta, el enlace toma el propio DataContext. Organiza la ventana con un DockPanel: el campo arriba, el botón para quitar abajo y la lista en el medio.
3. Un conversor de monedas. Un campo para euros y dos líneas que cambian mientras escribes: dólares y libras, con los tipos de cambio de ejemplo 1 € = 1,08 $ y 1 € = 0,85 £. Sin botón y sin manejador: el campo está enlazado en los dos sentidos con Euros en una clase con INotifyPropertyChanged, y Dollars y Pounds son propiedades que se calculan a partir de Euros. Cuando Euros cambia, la clase lanza PropertyChanged para las tres.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Polimorfismo · Interfaces · Comprobaciones de tipo · Listas, diccionarios y LINQ
- Ejercicios sobre el tema: Web y escritorio