Arquitectura Escalable para Enlace de Datos en WPF utilizando MVVM y Plantillas Dinámicas

Para optimizar el enlace de datos en WPF y garantizar una arquitectura altamente escalable, es fundamental aplicar los principios de separación de responsabilidades, pattrones de diseño flexibles y una estructura modular. La implementación de enumeradores para tipos de control, interfaces de validación y plantillas dinámicas permite agregar nuevas funcionalidades sin alterar el código existente.

Definición de Tipos de Control

En lugar de usar cadenas o valores mágicos para determinar el control de interfaz, se introduce un enumerador que facilita la extensión a nuevos componentes visuales como selectores de fecha o casillas de verificación.

public enum TipoControl
{
    CajaTexto,
    ListaDesplegable,
    CasillaVerificacion,
    SelectorFecha
}

Modelo de Datos Puro

Se aísla la estructura de datos en una clase simple, desacoplada de la lógica de presentación.

public class AjusteConfiguracion
{
    public int Identificador { get; set; }
    public string Etiqueta { get; set; }
    public string Dato { get; set; }
    public string Medida { get; set; }
    public string Detalle { get; set; }
    public string LimiteInferior { get; set; }
    public string LimiteSuperior { get; set; }
    public List<string> Alternativas { get; set; }
    public TipoControl Tipo { get; set; }
    public bool Activo { get; set; } = true;
}

Sistema de Validación Extensible

Mediante una interfaz de validación, se puede inyectar cualquier regla de negocio sin modificar el ViewModel principal.

public interface IValidadorDato
{
    bool EsValido(AjusteConfiguracion ajuste, out string mensajeError);
}

public class ValidadorRango : IValidadorDato
{
    public bool EsValido(AjusteConfiguracion ajuste, out string mensajeError)
    {
        mensajeError = string.Empty;
        if (ajuste.Tipo != TipoControl.CajaTexto) return true;

        if (string.IsNullOrWhiteSpace(ajuste.Dato) || string.IsNullOrWhiteSpace(ajuste.LimiteInferior) || string.IsNullOrWhiteSpace(ajuste.LimiteSuperior))
            return true;

        if (double.TryParse(ajuste.Dato, out double val) &&
            double.TryParse(ajuste.LimiteInferior, out double min) &&
            double.TryParse(ajuste.LimiteSuperior, out double max))
        {
            if (val < min)
            {
                mensajeError = "El valor está por debajo del mínimo permitido";
                return false;
            }
            if (val > max)
            {
                mensajeError = "El valor supera el máximo permitido";
                return false;
            }
        }
        return true;
    }
}

public class ValidadorOpciones : IValidadorDato
{
    public bool EsValido(AjusteConfiguracion ajuste, out string mensajeError)
    {
        mensajeError = string.Empty;
        if (ajuste.Tipo != TipoControl.ListaDesplegable) return true;

        if (!string.IsNullOrEmpty(ajuste.Dato) && ajuste.Alternativas != null && !ajuste.Alternativas.Contains(ajuste.Dato))
        {
            mensajeError = "El valor no se encuentra en la lista de opciones";
            return false;
        }
        return true;
    }
}

ViewModel con Notificación de Errores

El ViewModel gestiona la interacción de la interfaz y notifica los errores de validación a través de INotifyDataErrorInfo.

using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;

public class ConfiguracionViewModel : BindableBase, INotifyDataErrorInfo
{
    private readonly AjusteConfiguracion _ajuste;
    private readonly IValidadorDato _validador;
    private readonly Dictionary<string, List<string>> _errores = new();

    public ConfiguracionViewModel(AjusteConfiguracion ajuste, IValidadorDato validador)
    {
        _ajuste = ajuste;
        _validador = validador;
    }

    public int Identificador
    {
        get => _ajuste.Identificador;
        set => SetProperty(ref _ajuste.Identificador, value);
    }

    public string Etiqueta
    {
        get => _ajuste.Etiqueta;
        set => SetProperty(ref _ajuste.Etiqueta, value);
    }

    public string Dato
    {
        get => _ajuste.Dato;
        set
        {
            SetProperty(ref _ajuste.Dato, value, VerificarDato);
            RaisePropertyChanged(nameof(DatoFormateado));
        }
    }

    public string DatoFormateado => _ajuste.Tipo == TipoControl.CajaTexto && !string.IsNullOrEmpty(_ajuste.Medida)
        ? $"{_ajuste.Dato} {_ajuste.Medida}"
        : _ajuste.Dato;

    public string Medida
    {
        get => _ajuste.Medida;
        set => SetProperty(ref _ajuste.Medida, value);
    }

    public string Detalle
    {
        get => _ajuste.Detalle;
        set => SetProperty(ref _ajuste.Detalle, value);
    }

    public string LimiteInferior
    {
        get => _ajuste.LimiteInferior;
        set => SetProperty(ref _ajuste.LimiteInferior, value, VerificarDato);
    }

    public string LimiteSuperior
    {
        get => _ajuste.LimiteSuperior;
        set => SetProperty(ref _ajuste.LimiteSuperior, value, VerificarDato);
    }

    public ObservableCollection<string> Alternativas
    {
        get => new ObservableCollection<string>(_ajuste.Alternativas ?? new List<string>());
        set => SetProperty(ref _ajuste.Alternativas, value.ToList());
    }

    public TipoControl Tipo
    {
        get => _ajuste.Tipo;
        set => SetProperty(ref _ajuste.Tipo, value);
    }

    public bool Activo
    {
        get => _ajuste.Activo;
        set => SetProperty(ref _ajuste.Activo, value);
    }

    private void VerificarDato()
    {
        _errores.Remove(nameof(Dato));
        if (!_validador.EsValido(_ajuste, out string mensajeError))
        {
            _errores[nameof(Dato)] = new List<string> { mensajeError };
        }
        ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Dato)));
    }

    public bool HasErrors => _errores.Any();
    public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
    public IEnumerable GetErrors(string nombrePropiedad) => _errores.TryGetValue(nombrePropiedad, out var errs) ? errs : null;
}

Gestor de Colecciones

El contenedor principal administra el listado de elementos y expone comandos para manipular la colección dinámicamente.

using Prism.Commands;
using Prism.Mvvm;
using System.Collections.ObjectModel;

public class ProyectoViewModel : BindableBase
{
    private ObservableCollection<ConfiguracionViewModel> _listaAjustes;
    public ObservableCollection<ConfiguracionViewModel> ListaAjustes
    {
        get => _listaAjustes;
        set => SetProperty(ref _listaAjustes, value);
    }

    public DelegateCommand AgregarAjusteCommand { get; }

    public ProyectoViewModel()
    {
        ListaAjustes = new ObservableCollection<ConfiguracionViewModel>
        {
            new ConfiguracionViewModel(new AjusteConfiguracion
            {
                Identificador = 1, Etiqueta = "Tolerancia", Dato = "0.1", LimiteInferior = "0.1", LimiteSuperior = "1", Medida = "°C",
                Detalle = "Margen de estabilidad térmica", Tipo = TipoControl.CajaTexto
            }, new ValidadorRango()),
            new ConfiguracionViewModel(new AjusteConfiguracion
            {
                Identificador = 2, Etiqueta = "ModoConmutacion", Dato = "Arriba", Detalle = "Dirección de conmutación",
                Alternativas = new List<string> { "Arriba", "Abajo", "Arriba-Abajo", "Abajo-Arriba" }, Tipo = TipoControl.ListaDesplegable
            }, new ValidadorOpciones())
        };
        AgregarAjusteCommand = new DelegateCommand(AgregarAjuste);
    }

    private void AgregarAjuste()
    {
        ListaAjustes.Add(new ConfiguracionViewModel(new AjusteConfiguracion
        {
            Identificador = ListaAjustes.Count + 1,
            Etiqueta = "Nuevo Parámetro",
            Tipo = TipoControl.CajaTexto
        }, new ValidadorRango()));
    }
}

Convertidores de Interfaz

Se implementan convertidores genéricos para controlar la visibilidad según el tipo de control y aplicar estilos visuales basados en el estado de validación.

using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;

public class ConvertidorVisibilidadControl : IValueConverter
{
    public object Convert(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        if (valor is TipoControl tipo && parametro is string tipoEsperado &&
            Enum.TryParse(tipoEsperado, out TipoControl objetivo))
        {
            return tipo == objetivo ? Visibility.Visible : Visibility.Collapsed;
        }
        return Visibility.Collapsed;
    }

    public object ConvertBack(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        throw new NotImplementedException();
    }
}

public class ConvertidorColorValidacion : IValueConverter
{
    public object Convert(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        if (valor is IEnumerable<string> errores && errores.Any())
            return System.Windows.Media.Brushes.Red;
        return System.Windows.Media.Brushes.Black;
    }

    public object ConvertBack(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        throw new NotImplementedException();
    }
}

public class ConvertidorBooleano : IValueConverter
{
    public object Convert(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        return valor?.ToString().ToLower() == "true";
    }

    public object ConvertBack(object valor, Type tipoObjetivo, object parametro, CultureInfo cultura)
    {
        return valor is bool val ? val.ToString() : "False";
    }
}

Implementación XAML Dinámica

El XAML aprovecha ContentPresenter y plantillas de datos para renderizar controles de forma dinámica según el tipo definido en el ViewModel.

<Window x:Class="TuEspacioNombres.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:TuEspacioNombres"
        Title="Ventana Principal" Height="450" Width="800">
    <Window.Resources>
        <local:ConvertidorVisibilidadControl x:Key="VisibilidadControlConv"/>
        <local:ConvertidorColorValidacion x:Key="ColorValidacionConv"/>
        <local:ConvertidorBooleano x:Key="BooleanoConv"/>

        <DataTemplate x:Key="PlantillaTexto">
            <TextBox Text="{Binding Dato, UpdateSourceTrigger=PropertyChanged, Delay=1000, ValidatesOnNotifyDataErrors=True}" 
                     Width="100" Height="30" HorizontalContentAlignment="Center" FontWeight="Bold">
                <TextBox.Foreground>
                    <Binding Path="Errors[Dato]" Converter="{StaticResource ColorValidacionConv}"/>
                </TextBox.Foreground>
            </TextBox>
        </DataTemplate>

        <DataTemplate x:Key="PlantillaCombo">
            <ComboBox ItemsSource="{Binding Alternativas}" 
                      SelectedItem="{Binding Dato, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}"
                      Width="100" Height="30" HorizontalContentAlignment="Center"/>
        </DataTemplate>

        <DataTemplate x:Key="PlantillaCheck">
            <CheckBox IsChecked="{Binding Dato, Converter={StaticResource BooleanoConv}, UpdateSourceTrigger=PropertyChanged}"
                      HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </DataTemplate>
    </Window.Resources>
    <Grid>
        <DataGrid ItemsSource="{Binding ModeloProyecto.ListaAjustes}" Margin="4" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="ID" Width="60" Binding="{Binding Identificador}" IsReadOnly="True" Foreground="Gray"/>
                <DataGridTemplateColumn Header="Ajuste" Width="200">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBlock Text="{Binding Etiqueta}" VerticalAlignment="Center" Width="180" FontWeight="Bold" Foreground="DarkBlue"/>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Valor" Width="120">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <Grid>
                                <ContentPresenter Content="{Binding}" ContentTemplate="{StaticResource PlantillaTexto}"
                                                  Visibility="{Binding Tipo, Converter={StaticResource VisibilidadControlConv}, ConverterParameter=CajaTexto}"/>
                                <ContentPresenter Content="{Binding}" ContentTemplate="{StaticResource PlantillaCombo}"
                                                  Visibility="{Binding Tipo, Converter={StaticResource VisibilidadControlConv}, ConverterParameter=ListaDesplegable}"/>
                                <ContentPresenter Content="{Binding}" ContentTemplate="{StaticResource PlantillaCheck}"
                                                  Visibility="{Binding Tipo, Converter={StaticResource VisibilidadControlConv}, ConverterParameter=CasillaVerificacion}"/>
                            </Grid>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Unidad" Width="80">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <Label Content="{Binding Medida}" VerticalAlignment="Center" Width="80" HorizontalContentAlignment="Center"/>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Mínimo" Width="110">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBox Text="{Binding LimiteInferior, UpdateSourceTrigger=PropertyChanged, Delay=500}" 
                                     Width="100" Height="30" HorizontalContentAlignment="Center"/>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Máximo" Width="110">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBox Text="{Binding LimiteSuperior, UpdateSourceTrigger=PropertyChanged, Delay=500}" 
                                     Width="100" Height="30" HorizontalContentAlignment="Center"/>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTemplateColumn Header="Notas" Width="380">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <TextBox Text="{Binding Detalle, UpdateSourceTrigger=PropertyChanged, Delay=500}" 
                                     Foreground="Gray" IsReadOnly="True" Width="340" Height="30" HorizontalContentAlignment="Left"/>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
            </DataGrid.Columns>
        </DataGrid>
        <Button Content="Añadir Ajuste" Command="{Binding ModeloProyecto.AgregarAjusteCommand}" Width="100" Height="30" Margin="10" HorizontalAlignment="Right" VerticalAlignment="Bottom"/>
    </Grid>
</Window>

Etiquetas: WPF DataBinding MVVM Prism INotifyDataErrorInfo

Publicado el 7-25 09:23