Применение страничной навигации достаточно актуальная задача для настольных WPF-MVVM приложений.
Разномастных руководств по организации такой навигации в сети достаточно.
И, конечно, Хабрахабр не исключение (имеются статьи раз и два).
Взглянув на первую статью Вы узнаете про NavigationService и возможность пользоваться Hyperlink.
Если перейдете по второй ссылке, то узнаете как пользоваться NavigationService в так называемом «Code Behind».
Таким образом, решения полного в этих статьях не представлено (на мой взгляд).
Хочется заполнить пробел и представить Вашему вниманию, как мне кажется, вполне рабочее решение.
Абсолютно не претендую на законченный компонент для организации страничной навигации.
Буду благодарен за полезные комментарии, поправки и дополнения.
Рад буду, если кому-то моя реализация навигатора окажется полезной.
Введение
Все, кто уже работал с WPF, наверняка знакомы с паттерном MVVM (дал ссылки в конце статьи). Ведь концепция MVVM несложна и, как минимум, интуитивно должна быть понятной выгода от его использования. Если хотите, чтобы он проявил себя во всей красе, то как можно меньше логики помещайте в «Code Behind» пользовательских элементов управления (UserControl) и ни в коем случае не используйте прямые ссылки на UI внутри ViewModel’ей. Данный подход даст Вам большой профит в виде возможности тестирования ViewModel’ей отдельно от контролов. Еще одной хорошей практикой будет сведение к минимуму создания экземпляров ViewModel’ей напрямую в контролах. Нестрашно, если элемент управления сам для себя создает ViewModel конкретного типа – в этом случае просто труднее будет подложить контролу какую-нибудь тестовую куклу. Иначе будут обстоять дела, когда некий родительский контрол будет занят созданием ViewModel’ей для остальных экранов, ведь тогда код может превратиться в нетестируемую кучу спагетти. Если за создание ViewModel’ей будут отвечать другие ViewModel’и, то тестировать станет намного легче.
Давайте представим себе приложение с панелью навигации, несколькими экранами и диалоговыми окнами. Нечто подобное представлено ниже.
Мы можем разглядеть несколько сущностей: главное окно, панель навигации с кнопками, текущая страница и диалог над этой страницей. В нашем приложении для страничной навигации можно было бы использовать HyperLink, подложив вместо кнопок TextBlock с HyperLink в качестве контента. У HyperLink есть свойство, указывающее имя Frame, в котором выполнять переход на новую страницу. И вроде все нормально, но с использованием HyperLink представляется трудным передача странице нужной ViewModel’и.
Я видел в сети пару решений этой проблемы:
- В событии Frame.Navigated в главном окне приложения через Code Behind можно получить доступ к загруженному во фрейм содержимому и подложить туда созданную там же в Code Behind ViewModel. Таким образом, создание ViewModel’ей для всех страниц будет сконцентрировано в одном обработчике с использованием длинной портянки if…else if… либо switch. Про то, что тестирование такого «Hard Coded» процесса навигации крайне трудно автоматизировать, я молчу.
- Другим решением является создание экземпляра Page и ViewModel’и под нее, подкладывание ViewModel’и в DataContext экземпляра Page и вызов Navigate у фрейма с передачей созданного экземпляра Page. Это решение немного лучше предыдущего, но по-прежнему совсем не «MVVM-way».
- Третьим решением можно назвать использование библиотек PRISM. Она используется в секторе крупных корпоративных приложений для реализации композитного UI. Если знакомы с AngularJS, то поймете что это. Реализуется некий RegionManager, в котором регистрируются части UI. Потом через созданный менеджер вызывается инстанциирование контрола по некоемому псевдониму, также присвоение нужного контекста данных. Этот функционал похож на то, что уже реализовано в NavigationService WPF.
Первые два решения — явный костыль. PRISM же — это целый фреймворк композиции UI. Инвестировать в его изучение, конечно, стоит, но для небольших приложений (proof of concept, напр.) использование таких вещей, как IoC и PRISM, может оказаться нецелесообразным.
Какое простейшее решение могло бы более-менее гладко вписаться в контекст MVVM? У класса Page в Silverlight есть перегружаемый метод OnNavigatedTo. В этом методе было бы удобно принимать ViewModel, переданную в NavigationService.Navigate(Uri uri, object navigationContext) вторым параметром. Однако в WPF у Page такого метода нет. По крайней мере я не нашел его или чего-то эквивалентного. Нам нужен некий посредник или, если хотите, менеджер, который будет контролировать переходы по страницам и перекладывать из параметра метода в DataContext нужную ViewModel. О реализации такого менеджера навигации и пойдет речь в данной статье.
В следующем разделе я расскажу о реализации ядра решения, о менеджере навигации. Затем, будет рассказано о том, что нужно реализовать на UI и ViewModel слоях. Для экономии времени можно прочитать раздел «Менеджер навигации», а остальное додумать по ходу решения своих задач.
Кому интересно сразу взглянуть на код, может переходить в репозиторий на GitHub.
Менеджер навигации
Этот менеджер реализован в виде синглтона с двойной проверкой экземляра на null (так называемый Double-Check Locking Singleton, многопоточная версия синглтона). Использование синглтона — это мое предпочтение. Так мне проще контролировать жизненный цикл. Вам возможно хватило бы и простого статического класса.
Код реализации синглтона смотрите ниже.
#region Singleton
private static volatile Navigation instance;
private static object syncRoot = new Object();
private Navigation() { }
private static Navigation Instance
{
get
{
if (instance == null)
{
lock (syncRoot)
{
if (instance == null)
instance = new Navigation();
}
}
return instance;
}
}
#endregion
В представленном выше коде Вы можете увидеть, что свойство Instance я сделал приватным. Так сделано для простоты, чтобы наружу не выглядывало ничего лишнего. Вам же на практике может потребоваться сделать его доступным публично. Вместо приватного свойства экземпляра синглтона я создал публичное свойство сервиса навигации Service (типа NavigationService), которое транслирует вызовы через приватный экземпляр синглтона. Можно было сделать наоборот, но тогда бы все вызовы снаружи приходилось делать через экземпляр, т.е.
Navigation.Instance.Service
вместо
Navigation.Service
Выбирайте вариант, который Вам больше нравится. Мне кажется последний вариант проще, но он требует дополнительной реализации статических свойств и методов. Поэтому с реализацией нового функционала может стать выгоднее открыть свойство экземпляра (Navigation.Instance).
Свойство Service в этом синглтоне будет хранить ссылку на NavigationService экземпляра Frame, в котором требуется выполнять страничные переходы. Присваивать актуальное значение этой ссылке можно как при старте приложения (в обработчике события Loaded главного окна), так и в любой другой более поздний момент до вызова одного из методов навигации.
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
Navigation.Navigation.Service = MainFrame.NavigationService;
DataContext = new MainViewModel(new ViewModelsResolver());
}
В примере выше мы назначаем нашему навигатору NavigationService Frame главного окна. Вместо главного окна мог быть любой контрол, но забирать NavigationService нужно в событии Loaded данного контрола. До этого события можно получить null. Более детально жизненный цикл контролов и NavigationService я не изучал.
В качестве альтернативного сценария я мог бы предложить использование ChildWindow из WPF Toolkit Extended, в который встроен еще один Frame. Можно в таком случае временно подменить NavigationService в нашем навигаторе, чтобы совершить переход внутри такого диалога. Это позволит автоматизировать через биндинг подгрузку различных экранов в диалоговые окна. Но сценарий такого использования кажется весьма экзотичным, потому подробно расписывать не буду. Если такой сценарий интересен, то напишу отдельную статью.
В текущей реализации менеджер работает предельно просто. В сеттере сервиса навигации помимо присвоения нового значения приватному полю делается отписка и подписка на событие Navigated сервиса.
public static NavigationService Service
{
get { return Instance._navService; }
set
{
if (Instance._navService != null)
{
Instance._navService.Navigated -= Instance._navService_Navigated;
}
Instance._navService = value;
Instance._navService.Navigated += Instance._navService_Navigated;
}
}
По-хорошему, в сеттере (и публичных методах менеджера тоже) не хватает использования lock. Но вообще, если у Вас в приложении параллельно с вызовом какого-либо метода навигации будет производиться замена NavigationService, то, скорее всего, что-то реализовано некорректно. Пока для простоты обойдемся без lock, но я Вас предупредил.
Ниже представлены публичные методы навигации.
#region Public Methods
public static void Navigate(Page page, object context)
{
if (Instance._navService == null || page == null)
{
return;
}
Instance._navService.Navigate(page, context);
}
public static void Navigate(Page page)
{
Navigate(page, null);
}
public static void Navigate(string uri, object context)
{
if (Instance._navService == null || uri == null)
{
return;
}
var page = Instance._resolver.GetPageInstance(uri);
Navigate(page, context);
}
public static void Navigate(string uri)
{
Navigate(uri, null);
}
#endregion
В коде выше Вы можете заметить использование "_resolver". В разделе IoC я про него расскажу. Если кратко, то это наипростейшая реализация Контейнера для Инверсии Управления.
В менеджере навигации реализовано подмножество навигационных методов из NavigationService, которого вполне достаточно для большинства простых случаев. Остается только подложить передаваемую ViewModel в свойство DataContext целевой страницы. Это делается в обработчике события Navigated (см. код ниже).
#region Private Methods
void _navService_Navigated(object sender, NavigationEventArgs e)
{
var page = e.Content as Page;
if (page == null)
{
return;
}
page.DataContext = e.ExtraData;
}
#endregion
В обработчике события Navigated делается попытка приведения контента Frame к типу Page. Таким образом, обработаны будут только переходы на Page. Все остальные отфильтруются. Если хотите, то можете убрать этот «железный занавес». В случае успешного приведения типов переданный в свойстве ExtraData аргументов события экземпляр ViewModel'и будет помещен в DataContext целевой страницы. Это все о менеджере навигации.
Осталось создать сборку с реализацией страниц и сборку ViewModel’ей. Еще я реализовал сборку Helpers, в которой разместил код реализации RelayCommand для ViewModel’ей. Если есть силы и время, переходите к следующим разделам с описанием реализации UI и ViewModel’ей. Если нет, то ниже кратко изложу, что еще нужно реализовать.
Для каждой страницы следует создать отдельную ViewModel. Эти, «частные», ViewModel’и инстанциируются в их родительской MainViewModel с использованием "Инверсии Управления" (см. раздел IoC). Главная ViewModel помещается в DataContext главного окна, но с таким же успехом ее можно было бы инстанциировать в виде статического ресурса XAML в словаре ресурсов главного окна или даже на уровне всего приложения. В таком случае в биндингах для DataContext придется указывать что-то типа Source={StaticResource MainViewModelDataSourceKey}. Но зато можно будет не беспокоиться о том, наследуется ли в нужном месте DataContext логического родителя.
В MainViewModel я создал несколько команд. Одну для перехода по указанному в CommandParameter строковому псевдониму страницы (переход без передачи контекста данных). Другие команды содержат в их делегате Execute переход по какому-то конкретному псевдониму целевой страницы с приемем через CommandParameter контекста данных. За деталями можете перейти на GitHub или продолжить чтение данной статьи.
Сборка ViewModels
В этой сборке представлена базовая ViewModel, которая реализует INotifyPropertyChanged.
public class BaseViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected void RaisePropertyChanged(string propertyName)
{
if (string.IsNullOrWhiteSpace(propertyName))
{
return;
}
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
Остальные ViewModel’и наследуются от нее и на данный момент предельно просты. Они содержат одно строковое свойство с уникальным именем (см. пример ниже).
public class Page1ViewModel : BaseViewModel
{
public string Page1Text
{
get { return "Hello, world!nSent from my iPage 1..."; }
}
}
MainViewModel уже значительно сложнее. Как я написал кратко в предыдущем разделе, она будет хранить частные ViewModel’и и реализовывать команды навигации. В моем случае частные ViewModel’и создаются лишь раз с использованием так называемого "Resolver'а", при инициализации MainViewModel. Поэтому я реализовал только геттеры этих ViewModel’ей.
public Page1ViewModel Page1ViewModel
{
get { return _p1ViewModel; }
}
public Page2ViewModel Page2ViewModel
{
get { return _p2ViewModel; }
}
public Page3ViewModel Page3ViewModel
{
get { return _p3ViewModel; }
}
Поля инициализируются в конструкторе MainViewModel:
_p1ViewModel = _resolver.GetViewModelInstance(Page1ViewModelAlias);
_p2ViewModel = _resolver.GetViewModelInstance(Page2ViewModelAlias);
_p3ViewModel = _resolver.GetViewModelInstance(Page3ViewModelAlias);
Команды в моем случае реализованы с указанием get и set, а инициализация их экземпляров помещена в отдельной функции. Наличие сеттеров команды позволяет мне подменить каждую команду снаружи текущей ViewModel. Такой подход позволяет, например, изменить реакцию диалогового окна на клик кнопки «ОК», если та привязана через Binding к соответствующей команде в его (диалога) внутренней ViewModel. Впрочем, такой сценарий весьма экзотичен и может быть реализован без сеттеров команд.
public ICommand GoToPathCommand
{
get { return _goToPathCommand; }
set
{
_goToPathCommand = value;
RaisePropertyChanged("GoToPathCommand");
}
}
public ICommand GoToPage1Command
{
get
{
return _goToPage1Command;
}
set
{
_goToPage1Command = value;
RaisePropertyChanged("GoToPage1Command");
}
}
private void InitializeCommands()
{
GoToPathCommand = new RelayCommand<string>(GoToPathCommandExecute);
GoToPage1Command = new RelayCommand<Page1ViewModel>(GoToPage1CommandExecute);
GoToPage2Command = new RelayCommand<Page2ViewModel>(GoToPage2CommandExecute);
GoToPage3Command = new RelayCommand<Page3ViewModel>(GoToPage3CommandExecute);
}
private void GoToPathCommandExecute(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var uri = new Uri(path);
Navigation.Navigate(uri);
}
private void GoToPage1CommandExecute(Page1ViewModel viewModel)
{
Navigation.Navigate(Navigation.Page1Alias, Page1ViewModel);
}
Обратите внимание, в качестве пути передается псевдоним целевой страницы. Эти псевдонимы я поместил в виде констант в менеджер навигации, но вообще лучшее место для них в XML-файле настроек или просто в каком-то текстовом словаре.
После выполнения команды GoToPage1Command будет осуществлен переход на страницу по указанному псевдониму, а в DataContext страницы будет положена ссылка на Page1ViewModel. Таким образом, нам не надо реализовывать дополнительную логику по получению данных обратно из целевой страницы. Она будет работать с хранилищем внутри нашей MainViewModel, поэтому все изменения мы будем получать автоматически еще до перехода обратно.
Вроде бы все с ViewModel’ями. Переходим к UI.
Главное окно и сборка Pages
Приведу снова для удобства вид тестового приложения.
Слева представлены четыре кнопки. Первая кнопка привязана к команде GoToPathCommand и выполняет переход на Page1 без контекста данных. После перехода на страницу без контекста данных вместо актуального значения из ViewModel'и будет подставлено значение из параметра FallbackValue объекта Binding. Остальные кнопки привязаны к «частным» командам с указанным в делегате команды псевдонимом требуемой страницы страницы.
<Window x:Class="Navigator.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="480" Width="640">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition />
</Grid.ColumnDefinitions>
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Hidden">
<StackPanel>
<Button Content="P 1 w/o data" Command="{Binding GoToPathCommand}" CommandParameter="pack://application:,,,/Pages;component/Page1.xaml"/>
<Button Content="Page 1" Command="{Binding GoToPage1Command}" CommandParameter="{Binding Page1ViewModel}"/>
<Button Content="Page 2" Command="{Binding GoToPage2Command}" CommandParameter="{Binding Page2ViewModel}"/>
<Button Content="Page 3" Command="{Binding GoToPage3Command}" CommandParameter="{Binding Page3ViewModel}"/>
</StackPanel>
</ScrollViewer>
<Frame x:Name="MainFrame" Grid.Column="1" Background="#CCCCCC"/>
</Grid>
</Window>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
Navigation.Service = MainFrame.NavigationService;
DataContext = new MainViewModel();
}
}
Сборка Pages содержит четыре страницы: Page1, Page2, Page3, Page404. Первые две просто содержат текстовые блоки, привязанные к свойству соответствующей частной ViewModel. Третью я немного усложнил, чтобы реализовать еще одну проблему MVVM, а именно задачу привязки ListBox.SelectedItems к ViewModel. Это отдельная тема, которая на мой взгляд заслуживает отдельной статьи. Для интереса можете заглянуть под спойлер разметки ниже.
<Page x:Class="Pages.Page3"
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"
xmlns:tkx="http://schemas.xceed.com/wpf/xaml/toolkit"
mc:Ignorable="d"
d:DesignHeight="400" d:DesignWidth="400">
<Grid>
<tkx:ChildWindow WindowState="Open" Caption="My Dialog" IsModal="True" WindowStartupLocation="Center">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Text="{Binding Page3Text, FallbackValue='No Data'}" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="24" FontWeight="Bold"/>
<StackPanel Grid.Row="1">
<TextBlock Text="Category:" Margin="5"/>
<ComboBox Text="Select..." Margin="5">
<ComboBoxItem Content="Category 1"/>
<ComboBoxItem Content="Category 2"/>
<ComboBoxItem Content="Category 3"/>
</ComboBox>
<TextBlock Text="Items:" Margin="5 10 5 5"/>
<ListBox Margin="5" SelectionMode="Multiple">
<ListBoxItem Content="Item 1"/>
<ListBoxItem Content="Item 2"/>
<ListBoxItem Content="Item 3"/>
<ListBoxItem Content="Item 4"/>
<ListBoxItem Content="Item 5"/>
<ListBoxItem Content="Item 6"/>
<ListBoxItem Content="Item 7"/>
<ListBoxItem Content="Item 8"/>
</ListBox>
</StackPanel>
</Grid>
</tkx:ChildWindow>
</Grid>
</Page>
На этой странице я разместил простенький диалог с выбором элементов из определенной категории. Это пример, приближенный к реальности. Вид диалога был показан на снимке главного окна выше. Обратите внимание, текстовый блок, кажется, лежит в диалоговом окне, но биндится к DataContext’у страницы напрямую, без всяких ухищрений. Это заслуга ChildWindow из WPF Toolkit Extended. Этот контрол на самом деле лишь имитирует поведение диалогового окна и является прямым потомком своего родителя в разметке XAML. Таким образом, DataContext наследуется в ChildWindow от Grid, в который я его поместил.
Кратко о проблеме привязки множественного выбора в ListBox. Чтобы вернуть во ViewModel список выбранных элементов ListBox’a я не могу использовать биндинг напрямую, т.к. свойство ListBox.SelectedItems не поддерживает биндинг. Чтобы решить эту проблему, можно отнаследовать от ListBox свой элемент управления, в котором добавить DependencyProperty. Однако есть более гибкий подход в контексте MVVM, о котором я и собираюсь написать в отдельной статье, если Вам это будет интересно.
IoC (Инверсия Управления)
К сожалению не могу подробно описать этот подход в данной статье. Объем и так велик. Но Вы можете почерпнуть нужные знания, например, из статей на хабре. Также множество ресурсов можно нагуглить. Если коротко, то «Инверсия Управления» это способ устранить прямые ссылки в одной сборке на другую сборку. Инжекция зависимостей выполняется специальными "Контейнерами", которые из конфигурационных файлов узнают какие конкретно классы и из каких сборок инициализировать для указываемого интерфейса и имени секции в конфиге. Нужно признаться, что в моем коде IoC не реализована полностью. Если честно, то и цели такой не было. Разумеется, концепцию IoC в коде я попытался отразить и попытался показать каким образом можно сделать код менее связным.
Ниже представлены интерфейсы контейнеров и их реализации.
namespace ViewModels.Interfaces
{
public interface IViewModelsResolver
{
INotifyPropertyChanged GetViewModelInstance(string alias);
}
}
namespace Navigator.Navigation.Interfaces
{
public interface IPageResolver
{
Page GetPageInstance(string alias);
}
}
Эти интерфейсы играют роль неких контрактов для различных реализаций контейнеров страниц и ViewModel'ей. На данный момент я сделал две реализации, которые Вы ни в коем случае не должны использовать в реальных проектах.
namespace Navigator.Navigation
{
public class PagesResolver : IPageResolver
{
private readonly Dictionary<string, Func<Page>> _pagesResolvers = new Dictionary<string, Func<Page>>();
public PagesResolver()
{
_pagesResolvers.Add(Navigation.Page1Alias, () => new Page1());
_pagesResolvers.Add(Navigation.Page2Alias, () => new Page2());
_pagesResolvers.Add(Navigation.Page3Alias, () => new Page3());
_pagesResolvers.Add(Navigation.NotFoundPageAlias, () => new Page404());
}
public Page GetPageInstance(string alias)
{
if (_pagesResolvers.ContainsKey(alias))
{
return _pagesResolvers[alias]();
}
return _pagesResolvers[Navigation.NotFoundPageAlias]();
}
}
}
namespace ViewModels
{
public class ViewModelsResolver : IViewModelsResolver
{
private readonly Dictionary<string, Func<INotifyPropertyChanged>> _vmResolvers = new Dictionary<string, Func<INotifyPropertyChanged>>();
public ViewModelsResolver()
{
_vmResolvers.Add(MainViewModel.Page1ViewModelAlias, () => new Page1ViewModel());
_vmResolvers.Add(MainViewModel.Page2ViewModelAlias, () => new Page2ViewModel());
_vmResolvers.Add(MainViewModel.Page3ViewModelAlias, () => new Page3ViewModel());
_vmResolvers.Add(MainViewModel.NotFoundPageViewModelAlias, () => new Page404ViewModel());
}
public INotifyPropertyChanged GetViewModelInstance(string alias)
{
if (_vmResolvers.ContainsKey(alias))
{
return _vmResolvers[alias]();
}
return _vmResolvers[MainViewModel.NotFoundPageViewModelAlias]();
}
}
}
Это просто «куклы» контейнеров, которые подлежать замене на нечто управляемое, например из библиотеки Unity. В качестве интерфейсов тоже лучше было бы использовать что-то наподобие IUnityContainer, но мне не хотелось утяжелять солюшен дополнительным референсом и усложнять восприятие своей реализации навигатора. Тем более Вы можете предпочесть любую другую библиотеку IoC вместо Unity.
Дополнительная литература
О синглтоне на Википедии
О синглтоне на Хабрахабре
О паттерне MVVM на Википедии
О паттерне MVVM на Хабрахабре
Автор: HomoLuden