Ir al contenido
wannacode

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 clase MainWindow, partida en dos con partial.

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
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 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:

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>
  • Grid divide el espacio en filas y columnas. Aquí hay tres filas: Auto es lo que necesite el contenido, y * es todo lo que queda. Un control dice en qué fila está con Grid.Row="1".
  • StackPanel coloca los controles uno tras otro, en vertical, o en horizontal con Orientation="Horizontal".
  • TextBlock es texto para leer, TextBox es un campo para escribir, Button es un botón y ListBox es 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
  • Width y Height está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 Grid y StackPanel existen DockPanel (los controles se «acoplan» arriba, abajo o a los lados, y el último ocupa el centro) y WrapPanel (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 y PriceBox es null.
  • Un manejador siempre tiene dos parámetros. sender es el control que lanzó el evento (aquí, el botón), y e trae los detalles del evento.
  • PriceBox.Text es el texto del campo. decimal.TryParse lo 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, y TotalText.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: TextChanged en un TextBox con cada letra, SelectionChanged en un ListBox al elegir una fila, Loaded en la ventana cuando aparece y Closing antes 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 void y usa await: la ventana sigue respondiendo mientras espera. async void solo 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" />

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. Cuando Total recibe un valor nuevo, OnPropertyChanged("Total") lanza el evento y WPF vuelve a leer la propiedad. ?.Invoke significa «llámalo si alguien escucha»: sin nadie que escuche, el evento es null.
  • ObservableCollection<T>: una lista que avisa sola cuando un elemento entra o sale. Un ListBox con ItemsSource="{Binding Items}" la vigila, y cada fila muestra el ToString() 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
  1. DataContext = cart: el ListBox lee Items y el TextBlock lee Total. Las flechas son los enlaces.
  2. Add(Espresso en grano): Items.Add. La ObservableCollection avisa de la nueva fila y el ListBox la muestra.
  3. Total pasa a 24.90. El setter guarda el valor y llama a OnPropertyChanged("Total").
  4. El evento llega al TextBlock: WPF vuelve a leer Total y el texto pasa a ser «Total: 24.90 €».
  5. Add(Menta): Items.Add. La ObservableCollection avisa de la nueva fila y el ListBox la muestra.
  6. Total pasa a 29.10. El setter guarda el valor y llama a OnPropertyChanged("Total").
  7. El evento llega al TextBlock: WPF vuelve a leer Total y el texto pasa a ser «Total: 29.10 €».
  8. 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 de cart.
  • ItemsList.SelectedItem is CartItem item comprueba que hay una fila elegida y te la da como CartItem.
  • Items.Count en el XAML también está enlazado: la ObservableCollection avisa 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). Cart es un ViewModel: el modelo, preparado para la pantalla. Los botones no se enlazan con Click, sino con comandos: Command="{Binding AddCommand}", donde AddCommand es una propiedad de tipo ICommand. 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 escribir OnPropertyChanged(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.

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

1. ¿Dónde se describe cómo se ve una ventana de WPF?
2. ¿Qué conecta Click="AddButton_Click" en XAML?
3. La propiedad Total cambia, pero un TextBlock con {Binding Total} muestra el valor viejo. ¿Qué falta?
4. ¿Por qué para la lista de un ListBox se usa ObservableCollection y no List?
5. ¿En qué sistemas funciona un programa WPF?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·