Enlace de datos y MVVM
El patrón Model-View-ViewModel (MVVM) exige una separación entre tres capas de software: la interfaz de usuario XAML, denominada vista, los datos subyacentes, denominados modelo, y un intermediario entre la vista y el modelo, denominado modelo de vista. La vista y el modelo de vista a menudo se conectan a través de enlaces de datos definidos en XAML. El BindingContext
para la vista suele ser una instancia del modelo de vista.
Importante
.NET Multi-platform App UI (.NET MAUI) serializa las actualizaciones de enlace al hilo de interfaz de usuario. Al usar MVVM, esto te permite actualizar las propiedades del modelo de vista enlazados a datos desde cualquier hilo, con el motor de enlace de .NET MAUI que lleva las actualizaciones al subproceso de la interfaz de usuario.
Hay varios enfoques para implementar el patrón MVVM y este artículo se centra en un enfoque sencillo. Usa vistas y modelos de vista, pero no modelos, para centrarse en el enlace de datos entre las dos capas. Para obtener una explicación detallada del uso del patrón MVVM en .NET MAUI, consulta Model-View-ViewModel (MVVM) en Patrones de aplicación empresarial mediante .NET MAUI. Para obtener un tutorial que te ayude a implementar el patrón MVVM, consulta Actualización de tu aplicación con conceptos de MVVM.
Simple MVVM
En las extensiones de marcado XAML has visto cómo definir una nueva declaración de espacio de nombres XML para permitir que un archivo XAML haga referencia a clases en otros conjuntos. En el siguiente ejemplo se usa la extensión de marcado x:Static
para obtener la fecha y hora actuales de la propiedad estática DateTime.Now
en el espacio de nombres System
:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:sys="clr-namespace:System;assembly=netstandard"
x:Class="XamlSamples.OneShotDateTimePage"
Title="One-Shot DateTime Page">
<VerticalStackLayout BindingContext="{x:Static sys:DateTime.Now}"
Spacing="25" Padding="30,0"
VerticalOptions="Center" HorizontalOptions="Center">
<Label Text="{Binding Year, StringFormat='The year is {0}'}" />
<Label Text="{Binding StringFormat='The month is {0:MMMM}'}" />
<Label Text="{Binding Day, StringFormat='The day is {0}'}" />
<Label Text="{Binding StringFormat='The time is {0:T}'}" />
</VerticalStackLayout>
</ContentPage>
En este ejemplo, el valor recuperado DateTime
se establece como en BindingContext
en un StackLayout. Cuando se establece en un elemento BindingContext
, este se hereda en todos los elementos secundarios de ese elemento. Esto significa que todos los elementos secundarios de StackLayout tienen el mismo BindingContext
y pueden contener enlaces a propiedades de ese objeto:
Sin embargo, el problema es que la fecha y hora se establecen una vez cuando la página se construye e inicializa, y nunca cambia.
Advertencia
En una clase que deriva de BindableObject, solo se pueden enlazar las propiedades de tipo BindableProperty. Por ejemplo, VisualElement.IsLoaded y Element.Parent no se pueden enlazar.
Una página XAML puede mostrar un reloj que siempre muestra la hora actual, pero requiere código adicional. El patrón MVVM es una opción natural para las aplicaciones MAUI de .NET cuando los datos se enlazan desde propiedades entre objetos visuales y los datos subyacentes. Al pensar en términos de MVVM, el modelo y el modelo de vista son clases escritas completamente en código. La vista suele ser un archivo XAML que hace referencia a las propiedades definidas en el modelo de vista a través de enlaces de datos. En MVVM, un modelo es independiente de un modelo de vista y modelo de vista es independiente de vista. Sin embargo, a menudo se adaptan los tipos expuestos por el modelo de vista a los tipos asociados a la interfaz de usuario.
Nota:
En ejemplos sencillos de MVVM, como los que se muestran aquí, a menudo no hay ningún modelo en absoluto, y el patrón implica solo una vista y un modelo de vista vinculados con enlaces de datos.
En el siguiente ejemplo se muestra un modelo de vista para un reloj, con una sola propiedad denominada DateTime
que se actualiza cada segundo:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace XamlSamples;
class ClockViewModel: INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private DateTime _dateTime;
private Timer _timer;
public DateTime DateTime
{
get => _dateTime;
set
{
if (_dateTime != value)
{
_dateTime = value;
OnPropertyChanged(); // reports this property
}
}
}
public ClockViewModel()
{
this.DateTime = DateTime.Now;
// Update the DateTime property every second.
_timer = new Timer(new TimerCallback((s) => this.DateTime = DateTime.Now),
null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
~ClockViewModel() =>
_timer.Dispose();
public void OnPropertyChanged([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Los modelos de vista suelen implementar la interfaz INotifyPropertyChanged
, lo que proporciona la capacidad de que una clase genere el evento PropertyChanged
cada vez que cambie una de sus propiedades. El mecanismo de enlace de datos de .NET MAUI asocia un controlador a este evento PropertyChanged
para que se pueda notificar cuando una propiedad cambia y mantener el destino actualizado con el nuevo valor. En el ejemplo de código anterior, el método OnPropertyChanged
controla la generación del evento al determinar automáticamente el nombre de origen de la propiedad: DateTime
.
En el siguiente ejemplo se muestra XAML que contiene ClockViewModel
:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:XamlSamples"
x:Class="XamlSamples.ClockPage"
Title="Clock Page">
<ContentPage.BindingContext>
<local:ClockViewModel />
</ContentPage.BindingContext>
<Label Text="{Binding DateTime, StringFormat='{0:T}'}"
FontSize="18"
HorizontalOptions="Center"
VerticalOptions="Center" />
</ContentPage>
En este ejemplo, ClockViewModel
se establece al BindingContext
de la ContentPage que usa las etiquetas del elemento de la propiedad. Como alternativa, el archivo de código subyacente podría crear instancias del modelo de vista.
La extensión de marcado Binding
en la propiedad Text
de los formatos Label a la propiedad de DateTime
. En la siguiente captura de pantalla se muestra el resultado:
Además, es posible acceder a las propiedades individuales de la propiedad DateTime
del modelo de vista separando las propiedades con puntos:
<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >
MVVM interactivo
MVVM se suele usar con enlaces de datos bidireccionales para una vista interactiva basada en un modelo de datos subyacente.
En el siguiente ejemplo se muestra que HslViewModel
convierte un valor Coloren valores Hue
, Saturation
y Luminosity
, y de nuevo:
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace XamlSamples;
class HslViewModel: INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private float _hue, _saturation, _luminosity;
private Color _color;
public float Hue
{
get => _hue;
set
{
if (_hue != value)
Color = Color.FromHsla(value, _saturation, _luminosity);
}
}
public float Saturation
{
get => _saturation;
set
{
if (_saturation != value)
Color = Color.FromHsla(_hue, value, _luminosity);
}
}
public float Luminosity
{
get => _luminosity;
set
{
if (_luminosity != value)
Color = Color.FromHsla(_hue, _saturation, value);
}
}
public Color Color
{
get => _color;
set
{
if (_color != value)
{
_color = value;
_hue = _color.GetHue();
_saturation = _color.GetSaturation();
_luminosity = _color.GetLuminosity();
OnPropertyChanged("Hue");
OnPropertyChanged("Saturation");
OnPropertyChanged("Luminosity");
OnPropertyChanged(); // reports this property
}
}
}
public void OnPropertyChanged([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
En este ejemplo, los cambios realizados en las propiedades Hue
, Saturation
y Luminosity
hacen que la propiedad Color
cambie, y los cambios en la propiedad Color
hacen que las otras tres propiedades cambien. Esto puede parecer un bucle infinito, salvo que el modelo de vista no invoca el evento PropertyChanged
a menos que la propiedad haya cambiado.
El siguiente ejemplo XAML contiene un BoxView cuya propiedad Color
está enlazada a la propiedad Color
del modelo de vista, y tres Slider y tres Label vistas enlazadas a las propiedades Hue
, Saturation
y Luminosity
:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:XamlSamples"
x:Class="XamlSamples.HslColorScrollPage"
Title="HSL Color Scroll Page">
<ContentPage.BindingContext>
<local:HslViewModel Color="Aqua" />
</ContentPage.BindingContext>
<VerticalStackLayout Padding="10, 0, 10, 30">
<BoxView Color="{Binding Color}"
HeightRequest="100"
WidthRequest="100"
HorizontalOptions="Center" />
<Label Text="{Binding Hue, StringFormat='Hue = {0:F2}'}"
HorizontalOptions="Center" />
<Slider Value="{Binding Hue}"
Margin="20,0,20,0" />
<Label Text="{Binding Saturation, StringFormat='Saturation = {0:F2}'}"
HorizontalOptions="Center" />
<Slider Value="{Binding Saturation}"
Margin="20,0,20,0" />
<Label Text="{Binding Luminosity, StringFormat='Luminosity = {0:F2}'}"
HorizontalOptions="Center" />
<Slider Value="{Binding Luminosity}"
Margin="20,0,20,0" />
</VerticalStackLayout>
</ContentPage>
El enlace en cada Label es el valor predeterminado OneWay
. Solo necesita mostrar el valor. Sin embargo, el enlace predeterminado en cada Slider es TwoWay
. Esto permite que el Slider se inicie a partir del modelo de vista. Cuando se crea una instancia del modelo de vista, la propiedad Color
se establece en Aqua
. Un cambio en un Slider establece un nuevo valor de la propiedad en el modelo de vista, que luego calcula un nuevo color:
Comandos
A veces, una aplicación tiene necesidades que van más allá de estos enlaces de propiedad y solicita al usuario que inicie comandos que influyen en el modelo de vista. Por lo general, estos comandos se señalizan mediante clics de botón o pulsaciones con el dedo, y tradicionalmente se procesan en el archivo de código subyacente en un controlador para el evento Clicked
del elemento Button o el evento Tapped
de un elemento TapGestureRecognizer.
La interfaz de comandos proporciona un enfoque alternativo para implementar comandos que se adapta mucho mejor a la arquitectura MVVM. El modelo de vista puede contener comandos, que son métodos que se ejecutan en respuesta a una actividad específica en la vista, como un clic Button. Los enlaces de datos se definen entre estos comandos y el objeto Button.
Para permitir un enlace de datos entre Button y un modelo de vista, Button define dos propiedades:
Command
de tipoSystem.Windows.Input.ICommand
CommandParameter
de tipoObject
Nota:
Muchos otros controles también definen las propiedades Command
y CommandParameter
.
La interfaz ICommand se define en el espacio de nombreSystem.Windows.Input y consta de dos métodos y un evento:
void Execute(object arg)
bool CanExecute(object arg)
event EventHandler CanExecuteChanged
El modelo de vista puede definir propiedades de tipo ICommand. Después puedes enlazar estas propiedades a la propiedad Command
de cada Button u otro elemento, o quizás una vista personalizada que implemente esta interfaz. Opcionalmente, puedes establecer la propiedad CommandParameter
para identificar objetos individuales Button (u otros elementos) enlazados a esta propiedad del modelo de vista. Internamente, Button llama al método Execute
cada vez que el usuario pulsa en Button, pasando al método Execute
su CommandParameter
.
El método CanExecute
y el evento CanExecuteChanged
se usan para los casos en los que una pulsación Button podría ser actualmente no válida, en cuyo caso Button debe deshabilitarse. Button llama a CanExecute
cuando la propiedad Command
se establece por primera vez y cada vez que se genera el evento CanExecuteChanged
. Si CanExecute
devuelve false
, Button se deshabilita y no genera llamadas Execute
.
Puedes usar la clase Command
o Command<T>
incluidas en .NET MAUI para implementar la interfaz ICommand. Estas dos clases definen varios constructores además de un método ChangeCanExecute
al que el modelo de vista puede llamar para forzar al objeto Command
a generar el evento CanExecuteChanged
.
En el ejemplo siguiente se muestra un modelo de vista para un teclado simple destinado a escribir números de teléfono:
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
namespace XamlSamples;
class KeypadViewModel: INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private string _inputString = "";
private string _displayText = "";
private char[] _specialChars = { '*', '#' };
public ICommand AddCharCommand { get; private set; }
public ICommand DeleteCharCommand { get; private set; }
public string InputString
{
get => _inputString;
private set
{
if (_inputString != value)
{
_inputString = value;
OnPropertyChanged();
DisplayText = FormatText(_inputString);
// Perhaps the delete button must be enabled/disabled.
((Command)DeleteCharCommand).ChangeCanExecute();
}
}
}
public string DisplayText
{
get => _displayText;
private set
{
if (_displayText != value)
{
_displayText = value;
OnPropertyChanged();
}
}
}
public KeypadViewModel()
{
// Command to add the key to the input string
AddCharCommand = new Command<string>((key) => InputString += key);
// Command to delete a character from the input string when allowed
DeleteCharCommand =
new Command(
// Command will strip a character from the input string
() => InputString = InputString.Substring(0, InputString.Length - 1),
// CanExecute is processed here to return true when there's something to delete
() => InputString.Length > 0
);
}
string FormatText(string str)
{
bool hasNonNumbers = str.IndexOfAny(_specialChars) != -1;
string formatted = str;
// Format the string based on the type of data and the length
if (hasNonNumbers || str.Length < 4 || str.Length > 10)
{
// Special characters exist, or the string is too small or large for special formatting
// Do nothing
}
else if (str.Length < 8)
formatted = string.Format("{0}-{1}", str.Substring(0, 3), str.Substring(3));
else
formatted = string.Format("({0}) {1}-{2}", str.Substring(0, 3), str.Substring(3, 3), str.Substring(6));
return formatted;
}
public void OnPropertyChanged([CallerMemberName] string name = "") =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
En este ejemplo, los métodos Execute
y CanExecute
para los comandos se definen como funciones lambda en el constructor. El modelo de vista supone que la propiedad AddCharCommand
está enlazada a la propiedad Command
de varios botones (o cualquier otro control que tenga una interfaz de comando), cada uno de los cuales se identifica mediante CommandParameter
. Estos botones agregan caracteres a una propiedad InputString
, a la que a luego se da formato como un número de teléfono para la propiedad DisplayText
. También hay una segunda propiedad de tipo ICommand denominada DeleteCharCommand
. Esto está enlazado a un botón de retroceso, pero el botón debe deshabilitarse si no hay caracteres que eliminar.
En el siguiente ejemplo se muestra XAML que consume el KeypadViewModel
:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:XamlSamples"
x:Class="XamlSamples.KeypadPage"
Title="Keypad Page">
<ContentPage.BindingContext>
<local:KeypadViewModel />
</ContentPage.BindingContext>
<Grid HorizontalOptions="Center" VerticalOptions="Center">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<Label Text="{Binding DisplayText}"
Margin="0,0,10,0" FontSize="20" LineBreakMode="HeadTruncation"
VerticalTextAlignment="Center" HorizontalTextAlignment="End"
Grid.ColumnSpan="2" />
<Button Text="⇦" Command="{Binding DeleteCharCommand}" Grid.Column="2"/>
<Button Text="1" Command="{Binding AddCharCommand}" CommandParameter="1" Grid.Row="1" />
<Button Text="2" Command="{Binding AddCharCommand}" CommandParameter="2" Grid.Row="1" Grid.Column="1" />
<Button Text="3" Command="{Binding AddCharCommand}" CommandParameter="3" Grid.Row="1" Grid.Column="2" />
<Button Text="4" Command="{Binding AddCharCommand}" CommandParameter="4" Grid.Row="2" />
<Button Text="5" Command="{Binding AddCharCommand}" CommandParameter="5" Grid.Row="2" Grid.Column="1" />
<Button Text="6" Command="{Binding AddCharCommand}" CommandParameter="6" Grid.Row="2" Grid.Column="2" />
<Button Text="7" Command="{Binding AddCharCommand}" CommandParameter="7" Grid.Row="3" />
<Button Text="8" Command="{Binding AddCharCommand}" CommandParameter="8" Grid.Row="3" Grid.Column="1" />
<Button Text="9" Command="{Binding AddCharCommand}" CommandParameter="9" Grid.Row="3" Grid.Column="2" />
<Button Text="*" Command="{Binding AddCharCommand}" CommandParameter="*" Grid.Row="4" />
<Button Text="0" Command="{Binding AddCharCommand}" CommandParameter="0" Grid.Row="4" Grid.Column="1" />
<Button Text="#" Command="{Binding AddCharCommand}" CommandParameter="#" Grid.Row="4" Grid.Column="2" />
</Grid>
</ContentPage>
En este ejemplo, la propiedad Command
de la primera Button que está enlazada a DeleteCharCommand
. Los demás botones están enlazados a AddCharCommand
con un CommandParameter
que es el mismo que el carácter que aparece en Button: