Enlace de datos y MVVM

Browse sample.Examina la muestra

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 de la vista suele ser una instancia del modelo de vista.

Importante

.NET Multi-platform App UI (.NET MAUI) serializa las actualizaciones de enlace al subproceso de interfaz de usuario. Al usar MVVM, puedes actualizar las propiedades del modelo de vista enlazado a datos desde cualquier subproceso; el motor de enlace de .NET MAUI llevará 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 la aplicación con conceptos de MVVM.

MVVM sencillo

En Extensiones de marcado XAML viste cómo definir una nueva declaración de espacio de nombres XML para permitir que un archivo XAML haga referencia a clases de otros ensamblados. 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 el BindingContext en StackLayout. Cuando estableces BindingContext en un elemento, lo heredan 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:

Screenshot of a page displaying the date and time.

Sin embargo, el problema es que la fecha y hora se establecen una vez cuando la página se construye e inicializa, y nunca cambian.

Una página XAML puede mostrar un reloj que muestre siempre la hora actual, pero requiere código adicional. El patrón MVVM es una opción natural para las aplicaciones de .NET MAUI 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 con 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 ignora el modelo de vista, y un modelo de vista ignora la vista. Sin embargo, a menudo se adaptan los tipos expuestos por el modelo de vista a los tipos asociados con 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.

El siguiente ejemplo muestra un modelo de vista para un reloj, con una única 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, que proporciona a una clase la capacidad de generar el evento PropertyChanged cada vez que cambia una de sus propiedades. El mecanismo de enlace de datos de .NET MAUI adjunta un controlador a este evento PropertyChanged para que reciba una notificación cuando cambie una propiedad y mantenga el destino actualizado con el nuevo valor. En el ejemplo de código anterior, el método OnPropertyChanged controla la generación del evento a la vez que determina automáticamente el nombre de origen de la propiedad: DateTime.

En el siguiente ejemplo se muestra XAML que consume 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 en el BindingContext de ContentPage con etiquetas de elemento de 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 Label formatea la propiedad DateTime. En la captura de pantalla siguiente se muestra el resultado:

Screenshot of a page displaying the date and time via a viewmodel.

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 ejemplo siguiente se muestra el HslViewModel que convierte un valor Color en valores Hue, Saturation y Luminosity, y viceversa:

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 en las propiedades Hue, Saturation y Luminosity hacen que cambie la propiedad Color, y los cambios en la propiedad Color hacen que cambien las otras tres propiedades. 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 ejemplo XAML siguiente contiene un BoxView cuya propiedad Color está vinculada a la propiedad Color del modelo de vista, así como tres vistas Slider y tres vistas Label vinculadas 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 OneWay predeterminado. Solo necesita mostrar el valor. Sin embargo, el enlace predeterminado en cada Slider es TwoWay. Esto permite inicializar el Slider desde el 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 para la propiedad en el modelo de vista, que después calcula un nuevo color:

MVVM using two-way data bindings.

Comandos

A veces, una aplicación tiene necesidades que van más allá de estos enlaces de propiedad y requiere que el usuario inicie comandos que afectan a algo 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, el Button define dos propiedades:

Nota:

Muchos otros controles también definen las propiedades Command y CommandParameter.

La interfaz ICommand se define en el espacio de nombres System.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 elemento 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 de modelo de vista. Internamente, el Button llama al método Execute cada vez que el usuario pulsa el Button y pasa 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 de un Button podría ser no válida en estos momentos, en cuyo caso el Button debería deshabilitarse por sí solo. Button llama a CanExecute cuando la propiedad Command se establece por primera vez y siempre que se genere el evento CanExecuteChanged. Si CanExecute devuelve false, Button se deshabilita y no genera llamadas Execute.

Puedes usar la clase Command o Command<T> incluida en .NET MAUI para implementar la interfaz ICommand. Estas dos clases definen varios constructores más 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 diseñado para 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 de 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, que se fomatea como un número de teléfono para la propiedad DisplayText. También hay una segunda propiedad de tipo ICommand denominada DeleteCharCommand. 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 el 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="&#x21E6;" 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 del primer Button que está enlazado 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 el Button:

Screenshot of a calculator using MVVM and commands.