Saturday, December 20, 2014

Using navigation in Prism

Как я обещал в предыдущих статьях о том, что постараюсь написать статью по навигации в Prism, в котором активное участие принимает модель представления. Наконец-то мне пришла мысль со внятным и простым для понимания примером. Лучше всего знания закрепляются на практике, поэтому максимально на ней сосредоточимся. Теории будет по минимуму, все же, ее не избежать, поскольку нужно объяснить роль тех интерфейсов, которые мы будем использовать. Простая навигация, как мы уже рассматривали в предыдущей статье "View Switching Navigation in Prism 5", работает благодаря интерфейсу INavigateAsync, у которого есть только два метода RequestNavigate. Это позволит делать простую навигацию с одного представления в другое. Но как мы знаем, при разработке не все так просто. В основном, нужно, чтобы модель представления и представление принимали участие в навигации для, например, отображения каких-то стартовых данных, выполнения какой-то внутренней логики и т.д. Для таких целей в Prism есть интерфейс INavigateAware, который позволяет модели представления участвовать в процессе навигации. В этом интерфейсе определены три метода.
// Summary:
    //     Provides a way for objects involved in navigation to be notified of navigation
    //     activities.
    public interface INavigationAware
    {
        // Summary:
        //     Called to determine if this instance can handle the navigation request.
        //
        // Parameters:
        //   navigationContext:
        //     The navigation context.
        //
        // Returns:
        //     true if this instance accepts the navigation request; otherwise, false.
        bool IsNavigationTarget(NavigationContext navigationContext);
        //
        // Summary:
        //     Called when the implementer is being navigated away from.
        //
        // Parameters:
        //   navigationContext:
        //     The navigation context.
        void OnNavigatedFrom(NavigationContext navigationContext);
        //
        // Summary:
        //     Called when the implementer has been navigated to.
        //
        // Parameters:
        //   navigationContext:
        //     The navigation context.
        void OnNavigatedTo(NavigationContext navigationContext);
    }
Метод IsNavigationTarget позволяет указать представлению в регионе, сможет ли оно обработать запрос навигации. Это может быть полезным в случаях, когда вы хотите повторно использовать уже существующее представление для обработки навигации или хотите совершить навигацию к уже существующему представлению.
Методы OnNavigatedFrom и OnNavigatedTo вызываются уже непосредственно во время навигации. OnNavigatedFrom вызывается до начала навигации и позволяет предыдущему представлению сохранить свое состояние или подготовиться к удалению из пользовательского интерфейса. Метод OnNavigatedTo вызывается после совершения навигации. Параметры в навигацию передаются с помощью класса NavigationParameters.
После того как мы создаем, инициализируем и добавляем в регион представление, оно становится активным, а предыдущее деактивируется. Бывают случаи, когда представление из региона после деактивации нужно удалить. Для этого в Prism есть интерфейс IRegionMemberLifitime, который позволяет контролировать время жизни представления в регионе. У этого интерфейса есть всего лишь один метод KeepAlive. Если мы проставим это значение в false, то наше представление будет удалено с региона после его деактивации, и сборщик мусора сможет почистить все.
Вкратце мы рассмотрели то, что мы будем использовать. Дальше подробнее остановимся на тех или иных аспектах.
Для примера используем пример, аналогичный приведенному в статье "Using RegionContext in Prism 5". Наверное, потому что пример с заказами на книги у меня получился самым простым, и только в нем мне удалось компактно реализовать всю необходимую логику. Остальные примеры получались чересчур громоздкими и несли больше кода, чем смысловой нагрузки.
Для начала создадим новое WPF приложение и назовем его PrismNavigation. Не забываем указать при создании фреймворк 4.5 так как Prism 5 работает начиная с версии 4.5 и выше. Затем с помощью NuGet Package Manager сразу поставим Prism и Prism.UnityExtensions, поскольку дальше в статье всюду идет использование призма, словно он уже добавлен в ваш проект.
Затем нужно добавить в проект следующую иерархию папок:
·        Helpers – разные вспомогательные классы для проекта;
·        Models – модели с паттерна MVVM;
·        Modules – модули с Prism;
·        ViewModels – модели представления;
·        Views – представления;
·        Regions – контролы, которые используются для заполнения регионов.
Теперь приступим к реализации. Структура у нас простая. Добавим класс, в котором будем хранить информацию о книге, один класс – для информации о покупателе, один – для заказа, и вспомогательный класс – для отображения информации о заказе в нужном формате. Начнем свою реализацию с интерфейса, который описывает необходимые свойства для книги. Для этого перейдем в папку Models и добавим новый интерфейс IBook, как показано в примере ниже.
public interface IBook
{
    int Id { get; set; }
    string Author { get; set; }
    string Title { get; set; }
    DateTime Year { get; set; }
    string SN { get; set; }
    int Count { get; set; }
    double Cost { get; set; }
}
Примечание: Возможно у вас есть уже набранный пример со статьи "Using RegionContext in Prism 5", тогда ваши изменения будут минимальны, поскольку только две модели претерпели изменения. И это одна из них.
Реализация интерфейса IBook приведена в классе Book.
public class Book : BindableBase, IBook
{
    private int _id;
    public int Id
    {
        get { return _id; }
        set { SetProperty(ref _id, value); }
    }

    private string _author;
    public string Author
    {
        get { return _author; }
        set
        {
            SetProperty(ref _author, value);
        }
    }

    private string _title;
    public string Title
    {
        get { return _title; }
        set
        {
            SetProperty(ref _title, value);
        }
    }

    private DateTime _year;
    public DateTime Year
    {
        get { return _year; }
        set
        {
            SetProperty(ref _year, value);
        }
    }

    private string _sn;
    public string SN
    {
        get { return _sn; }
        set
        {
            SetProperty(ref _sn, value);
        }
    }

    private int _count;
    public int Count
    {
        get { return _count; }
        set
        {
            SetProperty(ref _count, value);
        }
    }

    private double _cost;
    public double Cost
    {
        get { return _cost; }
        set
        {
            SetProperty(ref _cost, value);
        }
    }
}
Следующим по счету у нас идет интерфейс, который будет хранить информацию о покупателе, поэтому и называется, соответственно, ICustomer. Добавлять его нужно все в ту же папку Models. Я напишу, когда мы будем добавлять информацию в другую папку.
public interface ICustomer
{
    int Id { get; set; }
    string FirstName { get; set; }
    string LastName { get; set; }
}
Имплементация данного интерфейса приведена в классе Customer.
public class Customer : BindableBase, ICustomer
{
    private int _id;
    public int Id
    {
        get { return _id; }
        set
        {
            SetProperty(ref _id, value);
        }
    }

    private string _firstName;
    public string FirstName
    {
        get
        {
            return _firstName;
        }
        set
        {
            SetProperty(ref _firstName, value);
        }
    }

    private string _lastName;
    public string LastName
    {
        get
        {
            return _lastName;
        }
        set
        {
            SetProperty(ref _lastName, value);
        }
    }
}
Теперь добавим аналогичный интерфейс для хранения информации о заказе.
public interface IOrder
{
    int Id { get; set; }
    int CustomerId { get; set; }
    double Price { get; set; }
    List<IBook> BuyList { get; set; }
    int Count { get; }
}
Реализация данного интерфейса приведена ниже в классе Order.
public class Order : BindableBase, IOrder
{
    private int _id;
    public int Id
    {
        get
        {
            return _id;
        }
        set
        {
            SetProperty(ref _id, value);
        }
    }

    private int _customerId;
    public int CustomerId
    {
        get
        {
            return _customerId;
        }
        set
        {
            SetProperty(ref _customerId, value);
        }
    }

    private double _price;
    public double Price
    {
        get
        {
            return _price;
        }
        set
        {
            SetProperty(ref _price, value);
        }
    }

    public int Count
    {
        get { return BuyList.Count; }
    }

    private List<IBook> _buyList; 
    public List<IBook> BuyList
    {
        get { return _buyList; }
        set { SetProperty(ref _buyList, value); }
    }
}
И напоследок вспомогательный класс OrderInfo, который будет отображать форматированную информацию о заказе в представлении. После этого с моделями закончим.
public class OrderInfo : BindableBase
{
    private string _firstName;
    public string FirstName
    {
        get
        {
            return _firstName;
        }
        set
        {
            SetProperty(ref _firstName, value);
        }
    }

    private string _lastName;
    public string LastName
    {
        get
        {
            return _lastName;
        }
        set
        {
            SetProperty(ref _lastName, value);
        }
    }

    private double _price;
    public double Price
    {
        get
        {
            return _price;
        }
        set
        {
            SetProperty(ref _price, value);
        }
    }

    private int _count;
    public int Count
    {
        get { return _count; }
        set { SetProperty(ref _count, value); }
    }
}
Нам нужно будет сгенерировать какие-то тестовые данные, для того чтобы проверить работоспособность этого всего. Для этого в папку Helpers я добавил статический класс HelperGenerator, задача которого – просто сгенерировать тестовые данные.
Теперь нам нужно произвести действия, которые описаны во многих статьях. Поэтому просто скопирую и выделю их как правило.
Нам нужно наше главное окно в перенести папку View и переименовать его с MainWindow в Shell,чтобы оперировать терминами Prism. Посмотрите, чтобы у вас в коде не было названий MainWindow. Не забудьте также убрать начальную загрузку главного окна с App.xaml, в котором запуск окна указывается параметром StartupUri. Удалите StartupUri со значением совсем. 
Мы же перейдем к реализации главного окна Shell.xaml.
<Window x:Class="SharingDataBetweenRegions.Shell"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:regions="http://www.codeplex.com/CompositeWPF"
        Title="MainWindow" Height="350" Width="525">
  <Window.Background>
    <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FFFFFFFF" Offset="0"/>
      <GradientStop Color="#FCFFF5" Offset="0.992"/>
      <GradientStop Color="#3E606F" Offset="0.185"/>
    </LinearGradientBrush>
  </Window.Background>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="50"/>
      <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
    <Border Margin="10,5,10,10" Grid.Row="1" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FFC8DDC5" BorderThickness="2,2,2,2">
      <Grid Width="Auto" Height="Auto" Margin="10,10,10,10">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width=".4*"/>
          <ColumnDefinition  Width=".6*"/>
        </Grid.ColumnDefinitions>
        <Border Grid.Column="0" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FCFFF5" BorderThickness="2,2,2,2" Margin="5" Padding="5">
          <StackPanel Orientation="Vertical">
            <ContentControl regions:RegionManager.RegionName="BookRegion" />
          </StackPanel>
        </Border>
        <Border Grid.Column="1" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FCFFF5" BorderThickness="2,2,2,2" Margin="5" Padding="5">
          <StackPanel Orientation="Vertical">
            <ItemsControl regions:RegionManager.RegionName="OrderRegion"  />
          </StackPanel>
        </Border>
      </Grid>
    </Border>
  </Grid>
</Window>
В главном окне мы добавили регионы BookRegion, для того чтобы загружать туда представление, которое связано с отображением информации о книгах, а в регионе OrderRegion будет загружено, соответственно, представление, связанное с отображением данных о покупках выбранной книги.
Реализация представления BookView, которое будет связано с регионом BookRegion, приведено ниже. Нам нужно добавить это представление в папку Regions. Для этого выбираем папку Regions и, нажав на правую клавишу, выбираем создание UserControl, задав для него имя BookView. Аналогично нужно сделать для следующего представления OrderView.
<UserControl x:Class="SharingDataBetweenRegions.Views.Regions.BookView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
      <ComboBox Name="CustomerCbx" Margin="5"  Width="Auto" HorizontalAlignment="Stretch"
                ItemsSource="{Binding Books}"
                SelectedItem="{Binding SelectedBook}">
        <ComboBox.ItemTemplate>
          <DataTemplate>
            <StackPanel Orientation="Horizontal">
              <TextBlock Text="{Binding Author}" />
              <TextBlock Text="{Binding Title}" Margin="5,0,0,0"/>
              <TextBlock Text="{Binding Cost}" Margin="5,0,0,0"/>
            </StackPanel>
          </DataTemplate>
        </ComboBox.ItemTemplate>
      </ComboBox>
    </Grid>
</UserControl>
Реализация представления OrderView приведена ниже.
<UserControl x:Class="SharingDataBetweenRegions.Views.Regions.OrderView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
      <StackPanel>
        <ListBox ItemsSource="{Binding OrderDetails}">
          <ListBox.ItemTemplate>
            <DataTemplate>
              <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding FirstName}" />
                <TextBlock Text="{Binding LastName}" Margin="5,0,0,0" />
                <TextBlock FontWeight="Bold" Text="Price:" Margin="5,0,0,0"/>
                <TextBlock Text="{Binding Price}" Margin="5,0,0,0"/>
                <TextBlock FontWeight="Bold" Text="Count:" Margin="5,0,0,0"/>
                <TextBlock Text="{Binding Count}" Margin="5,0,0,0"/>
              </StackPanel>
            </DataTemplate>
          </ListBox.ItemTemplate>
        </ListBox>
      </StackPanel>
    </Grid>
</UserControl>
Теперь настало самое время реализовать модель представления, которая будет связывать нашу модель по книгам, а также представление BookView. Для этого выберем папку ViewModels и добавим новый класс BookViewModel.
public class BookViewModel : BindableBase
{
    private readonly IRegionManager _regionManager;

    #region Constructor
    public BookViewModel(IRegionManager manager)
    {
        _regionManager = manager;
        Books = new ObservableCollection<IBook>(Helpers.HelperGenerator.GenerateBooks());
    }
    #endregion

    public ObservableCollection<IBook> Books { get; set; }

    private IBook _selectedBook;

    public IBook SelectedBook
    {
        get { return _selectedBook; }
        set
        {
            SetProperty(ref _selectedBook, value);
            var query = new NavigationParameters();
            query.Add("ID", _selectedBook.Id);
            _regionManager.RequestNavigate("OrderRegion",
                new Uri("OrderView" + query, UriKind.Relative));
        }
    }
}
Вначале несколько простых пояснений по коду. Наследование от класса BindableBase добавлено лишь для того, чтобы использовать функцию SetProperty, которая кроме того, что устанавливает значение для нужного филда, еще и вызывает событие OnPropertyChanged, для того чтобы уведомить представление об изменениях в модели. Но у нас нет байндинга на свойство SelectBook, кроме этих строк:
<ComboBox Name="CustomerCbx" Margin="5"  Width="Auto" HorizontalAlignment="Stretch"
                ItemsSource="{Binding Books}"
                SelectedItem="{Binding SelectedBook}">
Поэтому нам можно убрать использование SetProperty и отвязаться от класса BindableBase для данной модели представления.
Прошу обратить внимание на участок кода, в котором мы создаем объект класса NavigationParameters и добавляем в него нужные данные.
var query = new NavigationParameters();
query.Add("ID", _selectedBook.Id);
_regionManager.RequestNavigate("OrderRegion",
    new Uri("OrderView" + query, UriKind.Relative));
Мы создаем идентификатор ID, с которого в принимающей модели представления будем доставать идентификатор книги, выбранной из списка ComboBox. Метод ReguestNavigate производит саму навигацию. Первым параметром указывается регион, который будет принимать запрос, второй Uri с самим представлением. Выглядит это вот так при получении:
Так мы можем передавать в модель представления бесконечно много параметров.
var query = new NavigationParameters();
query.Add("ID", _selectedBook.Id);
query.Add("Name", "Aleksandr");
query.Add("Age", 26);
query.Add("Position", "Senior Software Developer");
_regionManager.RequestNavigate("OrderRegion",
    new Uri("OrderView" + query, UriKind.Relative));
И все эти параметры мы получим в виде, подобному тому, что мы видим в браузерах.
Это очень удобная и мощная вещь, и в умелых руках она может превратить ваше приложение в элегантное и одновременно легко читаемое и сопровождаемое. По сути, если вы хотите что- то передать в какую-то модель, и чтобы она при этом как-то на это отреагировала, то это один из способов, как это можно сделать.
Часть, которая отправляет необходимые параметры, мы рассмотрели, теперь приступим к реализации модели представления, которая будет обрабатывать полученный идентификатор. Для этого в папке ViewModels создадим новый класс CustomerOrderViewModel.
public class CustomerOrderViewModel : INavigationAware
{
    #region Constructor
    public CustomerOrderViewModel()
    {
        Orders = new ObservableCollection<IOrder>(Helpers.HelperGenerator.GenerateOrders());
        Customes = new ObservableCollection<ICustomer>(Helpers.HelperGenerator.GenerateCustomers());
        OrderDetails = new ObservableCollection<OrderInfo>();
    }
    #endregion

    public ObservableCollection<IOrder> Orders { get; set; }
    public ObservableCollection<ICustomer> Customes { get; set; }

    public ObservableCollection<OrderInfo> OrderDetails { get; set; }

    public bool IsNavigationTarget(NavigationContext navigationContext)
    {
        return true;
    }

    public void OnNavigatedFrom(NavigationContext navigationContext)
    {

    }

    public void OnNavigatedTo(NavigationContext navigationContext)
    {
        int id = int.Parse((string)navigationContext.Parameters["ID"]);
        OrderDetails.Clear();
        foreach (var order in Orders)
        {
            var books = order.BuyList.Where(x => x.Id == id).ToList();

            var customer = Customes.First(c => c.Id == order.CustomerId);
            var orderInfo = new OrderInfo
            {
                FirstName = customer.FirstName,
                LastName = customer.LastName,
                Count = books.Count,
                Price = books.Sum(x => x.Cost)
            };
            OrderDetails.Add(orderInfo);
        }
    }
}
Начнем с реализации функции IsNavigationTarget.
public bool IsNavigationTarget(NavigationContext navigationContext)
{
    return true;
}
Этот метод может использовать NavigationContext, для того чтобы определить, может ли представление обработать навигационный запрос. В нашем же примере, в котором этот метод возвращает true, в независимости от входящих параметров, будет означать то, что представление, связанное с данной моделью представления, будет использоваться повторно. То есть, если вы поставите для данного метода результат выполнения как true, то у вас в конкретном регионе будет существовать только одно представление определенного типа.
Так как в нашем примере сохранять результат с предыдущей модели нам не нужно, мы не добавляем обработку для OnNavigatedFrom. Но добавляем обработку для метода OnNavigatedTo, поскольку в конце навигации мы хотим отобразить то, что у нас есть.
public void OnNavigatedTo(NavigationContext navigationContext)
{
    int id = int.Parse((string)navigationContext.Parameters["ID"]);
    OrderDetails.Clear();
    foreach (var order in Orders)
    {
        var books = order.BuyList.Where(x => x.Id == id).ToList();

        var customer = Customes.First(c => c.Id == order.CustomerId);
        var orderInfo = new OrderInfo
        {
            FirstName = customer.FirstName,
            LastName = customer.LastName,
            Count = books.Count,
            Price = books.Sum(x => x.Cost)
        };
        OrderDetails.Add(orderInfo);
    }
}
Вы не представляете, как удобно использовать данный метод. Например, начать асинхронную загрузку данных и показать их по завершению. Зачастую используется, если нужно достать какие-то данные с БД.
В данной реализации я не добавлял проверки на ошибки, чтобы не усложнять логику. Поэтому сразу же пытаюсь достать с идентификатора значение с параметром ID. Затем ищу в заказах все покупки книг по принятому идентификатору. Затем выбираю покупателя, для которого был совершена данная покупка. И последним этапом создаю новый объект OrderInfo и добавляю в него нужную информацию. Если вы внимательно посмотрите на пример, то увидите, что коллекцию OrderDetails я просто очищаю перед тем, как заполнить, а не пересоздаю. Дело в том, что ваше представление не будет корректно работать, поскольку вы уже сделали ему привязку на объект OrderDetails, а сам объект изменился. В таком случае вам нужно переподписывать данные или реагировать на событие DataContextChanged. Но проще оперировать тем списком, который уже у вас есть.
Теперь осталось задать начальную привязку регионов с представлениями и соответствующими моделями представлений. Для этого я пользуюсь модулями с Prism. Перейдем в папку Modules и создадим новый класс MainModule, в котором выполним всю необходимую начальную привязку.
public class MainModule : IModule
{
    #region Variables

    private readonly IRegionManager _regionManager;
    private readonly IUnityContainer _container;
    #endregion

    #region Constructor
    public MainModule(IRegionManager regionManager, IUnityContainer container)
    {
        _regionManager = regionManager;
        _container = container;
    }
    #endregion

    #region Public Methods
    public void Initialize()
    {
        var bookViewModel = _container.Resolve<BookViewModel>();
        var bookView = _container.Resolve<BookView>();
        bookView.DataContext = bookViewModel;

        var customerViewModel = _container.Resolve<CustomerOrderViewModel>();
        var orderView = _container.Resolve<OrderView>();
        orderView.DataContext = customerViewModel;

        _regionManager.Regions["BookRegion"].Add(bookView);
        _regionManager.Regions["OrderRegion"].Add(orderView);
    }
    #endregion
}
И теперь остался последний нюанс. Мы же с самого начала практически удалили свойство StartupUri, так как всю функциональность по созданию окна и заданию стартовых значений для запуска мы возложим на загрузчик (Bootstrapper). Для этого в корень проекта нужно добавить класс Bootstrapper, который нужно наследоваться от UnityBoostrapper.
public class Bootstrapper : UnityBootstrapper
{
    protected override DependencyObject CreateShell()
    {
        return Container.Resolve<Shell>();
    }

    protected override void InitializeShell()
    {
        Application.Current.MainWindow = (Window) Shell;
        Application.Current.MainWindow.Show();
    }

    protected override void InitializeModules()
    {
        IModule module = Container.Resolve<MainModule>();
        module.Initialize();
    }

    protected override void ConfigureContainer()
    {
        RegisterTypeIfMissing(typeof(IBook), typeof(Book), false);
        RegisterTypeIfMissing(typeof(ICustomer), typeof(Customer), false);
        RegisterTypeIfMissing(typeof(IOrder), typeof(Order), false);

        base.ConfigureContainer();
    }
}
Для того чтобы у вас работали методы Resolve<> IoC контейнера Unity добавьте ссылку на нужный namespace.
using Microsoft.Practices.Unity;
И последний нюанс — это нужно в App.xaml.cs переопределить метод OnStartup, который служит для запуска начального окна, и создать в нем объект класса Boostrapper, а затем для созданного объекта вызвать функцию Run.
protected override void OnStartup(StartupEventArgs e)
{
    var bootstrapper = new Bootstrapper();
    bootstrapper.Run();
}
После этого наш проект должен успешно запуститься, и мы сможем посмотреть, что у нас получилось, на экране.

Так как вы уже знаете, как можно использовать навигацию, то можете этот пример дописать по своему усмотрению. Например, добавить проверку переданных данных и т.д. Надеюсь, что простые способы навигации вы теперь сможете реализовать без проблем. В следующих статьях мы попробуем рассмотреть пример, в котором для навигации необходимо подтверждение или отмена. Например, когда мы хотим перейти с контрола, в котором введены какие-то данные для редактирования. А также рассмотрим, как можно использовать журнал навигации. Вероятнее всего, это будет разбито на две отдельные статьи. Если у вас остались какие-то вопросы или что-то в статье осталось нераскрытым, буду рад ответить на это в комментариях или раскрыть этот момент в другой статье. 

No comments:

Post a Comment