Esto no es papel mojado!!!

Otro sitio más de Geeks.ms

En toda navegación hay un patrón (MVVM)

Como he visto que existen dudas sobre como implementar la navegación en el patrón MVVM (Model – View – ViewModel) separando en varios proyectos el View y el ViewModel. Para ello voy a exponer una forma de hacerlo que creo que es la que mejor se adapta a este escenario.

El problema de la navegación radica en que si separamos el View y el ViewModel en diferentes proyectos el ViewModel no tiene una referencia al View (ni la necesita, ya que sino estaría acoplada y no podríamos reutilizar este componente) y por tanto ¿cómo llamo al entorno para que abra la ventana o página?

Para ello nos definimos estos objetivos:

  1. Vamos ha implementar la navegación entre dos ventanas de forma que al pulsar sobre un botón se abra la otra.
  2. El View y el ViewModel deben estar en dlls separadas
  3. Los Code-Behind de los XAML deben estar vacíos
  4. La aplicación debe poder tener un proyecto View y múltiples ViewModels (para ti Pedro 🙂

Ejemplo

Para el ejemplo he usado WPF, pero viendo y entendiendo el patrón es lo mismo para W8, WP7, WP8, SL y demás…

Por ello planteo el siguiente modelo:

clip_image002

Navigation

Empezando por el objeto que va a realizar la navegación, este será definido como un Command. En MVVM todas las acciones y navegaciones que se definen en el modelo deben ser comandos que después serán enlazados a los botones, links, … para lanzar su ejecución.

Como este comando tiene que tener código que abre una ventana o página su implementación debe estar en la capa View. De esta forma podemos hacer lo que queramos al tener acceso a los View y ViewModels de toda la aplicación.

    public class IrAPostByBlogNavigation : DelegateCommand
    {
        public IrAPostByBlogNavigation()
            : base((parameter) =>
            {
                var view = new PostByBlogView();
                var viewModel = view.DataContext as PostByBlogViewModel;
                viewModel.BlogId = parameter as int?;

                view.Show();
            })
        {
        }
    }

Para simplificar el código, me he permitido el lujo de refactorizar parte de esta clase en una clase más genérica llamada DelegateNavigation. Esta clase la vamos a definir en el ViewModel, pero en un proyecto genérico con clases comunes y helpers. De esta forma podremos tener varios proyectos con ViewModels y un proyecto con código común y compartido por todos.

    public class DelegateCommand : ICommand
    {
        private Action<object> Action;
        private Func<object, bool> CanExecuteFunc;

        public event EventHandler CanExecuteChanged;

        public DelegateCommand(Action<object> action)
        {
            this.Action = action;
            this.CanExecuteFunc = null;
        }
        public DelegateCommand(Action<object> action, Func<object, bool> canExecuteFunc)
        {
            this.Action = action;
            this.CanExecuteFunc = canExecuteFunc;
        }
        public bool CanExecute(object parameter)
        {
            return (this.Action != null) && ((this.CanExecuteFunc == null) || this.CanExecuteFunc(parameter));
        }
        public void Execute(object parameter)
        {
            if (this.Action != null)
                this.Action(parameter);
        }
    }

Esta clase implementa los métodos de la interface haciendo que sea más fácil implementar los comandos. Para especializarla sólo se le deben pasar por constructor 2 delegados con el código a ejecutar y el código para saber si se puede ejecutar (opcional).

Aquí uno se puede preguntar ¿hay que escribir una clase por cada navegación? Bueno, sí y no. Concretamente aquí hemos definido una clase porqué la foreign key está en post y por tanto no sabemos navegar desde el blog seleccionado. Si el caso fuera al contrario y estuviésemos implementado la navegación desde post a blog, entonces podríamos implementar un Navigation de carga por Id, y por tanto sería compartido por esta navegación y cualquier otra donde se tuviese cargado el id.

Otra buena pregunta que alguien se puede plantear es: Como solo puedo pasarle un valor al Command, ¿solo puedo abrir un nuevo escenario con un valor? No tiene porqué, en el caso descrito, yo le he pasado el id del blog como parámetro del command, pero se puede pasar todo el ViewModel origen y en el Navigation inicializar lo que uno quiera del destino con toda la información disponible en el ViewModel.

ViewModel

Una vez visto que el Command concreto está en el View y puede instanciar vistas, llamar al show, … sólo queda decláralo en el ViewModel concreto de la ventana o página para que este pueda ser enlazado (Bind) desde la vista. Como hemos comentado al principio, hemos decidido poner el Navigation en el View, por lo que no es accesible desde donde queremos y por ello tendremos que declarar una variable de tipo ICommand en el ViewModel que luego en tiempo de ejecución contendrá el Navigation correcto:

    public class BlogViewModel : ViewModelBase
    {
        private ObservableCollection<Blog> _Blogs = new ObservableCollection<Blog>();
        public ObservableCollection<Blog> Blogs { get { return _Blogs; }}

        private Blog _BlogSeleccionado;
        public Blog BlogSeleccionado
        {
            get {return _BlogSeleccionado; }
            set
            {
                if (_BlogSeleccionado != value)
                {
                    _BlogSeleccionado = value;
                    OnPropertyChanged("BlogSeleccionado");
                }
            }
        }

        public ICommand IrAPost { get; set; }

        public override void Initialize()
        {
            base.Initialize();
            this.InicializarBlogs();
        }

        public void InicializarBlogs()
        {
            var id = this.BlogSeleccionado == null ? (int?)null : this.BlogSeleccionado.id;

            this.Blogs.Clear();
            using (var application = this.Container.Resolve<IBlogApplication>())
            {
                foreach (var item in application.ObtenerBlogs())
                    this.Blogs.Add(item);
            }

            if (id != null)
                this.BlogSeleccionado = (
                    from e in this.Blogs
                    where e.id == id
                    select e
                ).FirstOrDefault();
        }
    }

View

Y… ¿Cómo vamos a crear este comando de navegación? Yo he decidido hacerlo desde un Locator inyectando a pelo, pero se puede inyectar con DI como NInject, Unity, …

    public class ViewModelLocator
    {
        public Lazy<BlogViewModel> blogViewModel = new Lazy<BlogViewModel>(() => new BlogViewModel() { IrAPost = new IrAPostByBlogNavigation() });
        public BlogViewModel BlogViewModel { get { return this.blogViewModel.Value; }}

        public Lazy<PostByBlogViewModel> postViewModel = new Lazy<PostByBlogViewModel>(() => new PostByBlogViewModel());
        public PostByBlogViewModel PostViewModel { get { return this.postViewModel.Value; } }

        public ViewModelLocator()
        {
        }
    }

Y después sólo hay que hacer Bind desde el XAML como se hace siempre:

<Window
    x:Class="GUSENet.Ejemplo.View.BlogView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:view="clr-namespace:GUSENet.Ejemplo.View"
    xmlns:viewModel="clr-namespace:GUSENet.Ejemplo.ViewModel;assembly=GUSENet.Ejemplo.ViewModel"
    xmlns:interactivity="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
    DataContext="{Binding BlogViewModel, Source={StaticResource ViewModelLocator}}"
  Title="Blogs"
>
    <interactivity:Interaction.Triggers>
        <interactivity:EventTrigger EventName="Loaded">
            <interactivity:InvokeCommandAction Command="{Binding ViewLoaded}"/>
        </interactivity:EventTrigger>
    </interactivity:Interaction.Triggers>
    <StackPanel>
        <StackPanel Orientation="Horizontal">
            <Label Content="Blogs" Height="28" HorizontalAlignment="Left" VerticalAlignment="Top"/>
            <Button Content="->" Command="{Binding IrAPost}" CommandParameter="{Binding BlogSeleccionado.id}"/>
        </StackPanel>
        <ListBox ItemsSource="{Binding Blogs}" SelectedItem="{Binding BlogSeleccionado}"/>
    </StackPanel>
</Window>

Y voilà!!! F5 y a funcionar…

Volviendo un poco a la pregunta de antes de si hay que implementar un Navigation por cada navegación, se podría simplificar poniendo el código que navega en el locator y eliminando los Navigations, pero me parece más claro crear una clase de forma explícita:

        public Lazy<BlogViewModel> blogViewModel = new Lazy<BlogViewModel>(() => new BlogViewModel()
        {
            IrAPost = new DelegateCommand(
                (parameter) =>
                {
                    var view = new PostByBlogView();
                    var viewModel = view.DataContext as PostByBlogViewModel;
                    viewModel.BlogId = parameter as int?;

                    view.Show();
                })
        });

Conclusiones

Revisando uno poco los objetivos

  1. El ejemplo es sencillo y creo que queda claro cómo se pulsa el botón y se abre la otra ventana utilizando un comando para la navegación
  2. Debido a que el comando está declarado en el View, no existe ningún inconveniente en que ambos proyectos estén separados. Es más deben estarlo en aplicacione multiplataforma, ya que así conseguiremos compartir el proyecto ViewModel entre diferentes Views (por ejemplo: uno para W8, otro WP8, otro para WP7, otro para SL, otro para WPF, …)
  3. Los code-behind de los XAML solo contendrán los constructores típicos que es lo que queríamos.
  4. Si sacamos todo el código común a otro proyecto compartido, no existe ningún problema en tener 5000 (o más 😉 proyectos con ViewModels, lo único es que el View sí deberá tener una referencia a todos los ViewModels para poder instanciarlos y manejarlos en los Navigation

Espero que sea de utilidad mi primer post. Espero no tardar tanto en escribir el segundo,

Au

MEJORA

Durante una conversión con @_PedroHurtado por la página de Facebook del GUSENet me pregunta: ¿Pero tengo que implementar un Navigation por cada navegación y por cada plataforma (WPF, SL, W8, WP7, WP8)? Bueno, con la implementación que yo he hecho sí, pero… ¿qué sucede si refactorizamos del Navigation la parte que depende de la plataforma? Pues aquí teneis:

    public class Navigation<V, VM>
        where V : Window, new()
        where VM : ViewModelBase
    {
        public V View { get; set; }
        public VM ViewModel { get; set; }

        public Navigation()
        {
            this.View = new V();
            this.ViewModel = this.View.DataContext as VM;
        }
        public void Open()
        {
            this.View.Show();
        }
    }

    public class IrAPostByBlogNavigation : DelegateCommand
    {
        public IrAPostByBlogNavigation()
            : base((parameter) =>
            {
                var navigation = new Navigation<PostByBlogView, PostByBlogViewModel>();
                navigation.ViewModel.BlogId = parameter as int?;

                navigation.Open();
            })
        {
        }
    }

6 Comentarios

  1. santypr

    Mola!
    Muy bueno el artículo. Tengo que mirarlo con detenimiento porque me va a servir y mucho

  2. alexander.fernandez.sauco

    Personalmente prefiero estrategias «View-Model First».

    Las implementaciones de los View-Models deben evitar hacer referencia a objetos de tipo «View» para permitir que todo el código del View-Model pueda ser probado.

    Hecha un vistazo a http://catel.codeplex.com/

  3. xavipaper

    En el código que he puesto no existe ninguna referencia desde el VM al V. En el código que he implementado ni tan siquiera aparece una referencia en el proyecto, por lo que cumple lo que comentas…

    ¿O he entendido mal? No se a qué «referencia» te refieres

  4. alexander.fernandez.sauco

    En el post escribes algo como:

    var view = new PostByBlogView();
    var viewModel = view.DataContext as PostByBlogViewModel;
    viewModel.BlogId = parameter as int?; view.Show();

    Donde PostByBlogView es una vista, no?

    A lo que me refiero es que se puede lograr una implementación como:

    Container.Resolve().Show(new PostByBlogViewModel() { BlogId = parameter as int?});

    Claro necesitas una implementación de IUIVisualizerService que permita mostrar vista desde sus respectivos VM, pero se puede lograr con cosas como ViewModelLocator y ViewLocator.

    Es solo un punto de vista (ViewModel-First), que puede ser un poco extremista 😉

    http://likewastoldtome.blogspot.com/2012/08/when-use-view-model-first-approach_14.html

  5. xavipaper

    Contestando un poco sobre lo del VM-First. Yo en ocasiones lo he utilizado, pero tiene un problema, y eso es que te centras principalmente en la lógica de un escenario y no en el escenario en sí.
    Por ejemplo, no se puede implementar una aplicación que tenga dos escenarios (views) diferentes pero con la misma lógica (viewmodel), así como escenarios donde intervienen más de un VM para un V.
    Pongo un ejemplo concreto: Imaginemos un maestro-detalle con factura-lineas. ¿Por qué no usar el VM que visualiza una factura y el VM que visualiza las lineas de una factura determinada?
    Yo creo que el usuario lo que quiere es navegar a un escenario (view) concreto y lo que haya por detrás es la infraestructura que se monta para pintar y ejecutar lo que él quiere.
    De todas formas son dos formas con sus pros y sus contras, aunque yo veo más flexible el View first y inyectar por locator o DI el ViewModel

  6. martinromania

    Es un artículo muy interesante sobre un tema que a mi me tiene un poco confundio y bien explicado. A ver si esta semana pongo en práctica el ejemplo que propones y no tengo muchas dudas sobre como implementarlo (aunque este bien explicado yo cuando me pongo …).

    Enhorabuena por el primer post. Pero a ver que haces ahora para no bajar el nivel por que lo has puesto muy alto

    un saludo y gracias

Responder a Cancelar respuesta

Tema creado por Anders Norén