Thursday, March 27, 2014

Пишем простой визард на WPF

В этой статье мы поговорим о том, как создать простой Wizard на WPF. Когда мы долгое время пишем на WPF, может возникнуть задача написания простого визарда, чтобы, например, задать настройки в программе, инициировать некоторые данные и т.д. Первым делом, когда нужно что-то реализовать, мы посмотрим, как это сделали до нас, чтобы основываться на чужом опыте. Одно из интересных решений для создания визарда предлагает Josh Smith, Creating an Internationalized Wizard in WPF. Этот автор является гуру по созданию WPF-приложений, и если вы до сих пор не сталкивались с его творчеством, рекомендую почитать его блог. Если вы увлекаетесь разработкой WPF-приложений, то вам должен понравиться этот блог.
Я продемонстрирую свой вариант создания простого визарда, без каких-либо заморочек, с детальным пошаговым разбором. Для того чтобы сделать визард, необходимо реализовать главное окно, в котором будет показана наша информация, а все ViewModel, которые будут привязанными к конкретным View, будут отображаться через ControlPresenter. Для начала создадим простое WPF-приложение, которое назовем SimpleWizard.
Будучи приверженцем паттерна MVVM, предпочту реализацию визарда с использованием данного паттерна. Поэтому если вы не знакомы с использованием данного паттерна в разработке приложений на WPF, рекомендую посмотреть статьи "Основы паттерна MVVM" и  "MVVM Part 2". В этой статье не будет рассказываться о сведениях про MVVM и о его использовании; здесь будет просто показано его практическое применение. Вот какой внешний вид будет у нашего визарда:
Исходный код главного окна приведен ниже.
<Window x:Class="SimpleWizard.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"       
        Title="{Binding Title}"
        Width="640"
           Height="520"
           ResizeMode="CanMinimize"
           WindowStartupLocation="CenterScreen"
           Background="{DynamicResource WindowBackgroundBrush}"
        UseLayoutRounding="True"
        >

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="9"/>
            <ColumnDefinition Width="615"/>
            <ColumnDefinition Width="10"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="9"/>
            <RowDefinition Height="428"/>
            <RowDefinition Height="52"/>
            <RowDefinition Height="5"/>
        </Grid.RowDefinitions>
        <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Row="1" Grid.Column="1">
            <ContentPresenter Content="{Binding CurrentPage}" DataContext="{Binding CurrentPage}"/>
        </ScrollViewer>
        <Separator
                    Height="17"
                    Margin="0,0,0,35"
                    Grid.Row="2"
                    VerticalAlignment="Bottom"
                    Grid.ColumnSpan="3"/>
        <Button Content="Back"
                    Margin="0,0,183,13"
                    VerticalAlignment="Bottom"
                     FontSize="12"
                    HorizontalAlignment="Right"
                    Width="75"
                    Grid.Row="2"
                    Grid.Column="1"
            Command="{Binding MovePreviousCommand}"
            />
        <Button
                    Margin="0,0,103,13"
                    VerticalAlignment="Bottom"
                    FontSize="12"
                    Grid.Row="2"
                    Grid.Column="1"
                    HorizontalAlignment="Right"
                    Width="75"
            Command="{Binding MoveNextCommand}"
            Style="{StaticResource moveNextButtonStyle}"
                />
       
        <Button Content="Cancel"
                    Margin="0,0,23,13"
                    VerticalAlignment="Bottom"
                    FontSize="12"
                    Grid.Row="2"
                    Grid.Column="1"
                    HorizontalAlignment="Right"
                    Width="75"
            Command="{Binding CancelCommand}" />

    </Grid>
</Window>
Первоначальный вид структуры проекта приведена ниже.
В папке Assets мы будем размещать стили, ресурсы и т.д. Пока там просто добавлен стиль внешнего вида визарда (цвет визарда).
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <SolidColorBrush x:Key="WinBackgroundBrush" Color="#FFF0F0F0"/>
    <SolidColorBrush x:Key="WindowBackgroundBrush" Color="#FFF0F0F0"/>
</ResourceDictionary>
Привязка данного файла ресурсов выполнена через App.xaml, реализацию которого мы приведем ниже. Поскольку мы увидели внешний вид визарда, осталось добавить нужные нам страницы, которые будут показаны в визарде.
Теперь добавим реализацию трех страниц и создадим для них соответствующие ViewModel.
Реализация первой страницы Page1:
<UserControl x:Class="SimpleWizard.View.Page1"
             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>
        <ListView x:Name ="library" ItemsSource="{Binding Books}" SelectedItem="{Binding SelectedBook}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <WrapPanel>
                        <TextBlock Text="Author: " />
                        <TextBlock Text="{Binding Author}" FontWeight="Bold" />
                        <TextBlock Text=", " />
                        <TextBlock Text="Caption: " />
                        <TextBlock Text="{Binding Title}" FontWeight="Bold" />
                        <TextBlock Text="Count: " />
                        <TextBlock Text="{Binding Count}" FontWeight="Bold" />
                    </WrapPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
    </Grid>
</UserControl>
На данной странице я взял реализацию электронной библиотеки, которою реализовывал ранее.
Реализация второй страницы Page2:
<UserControl x:Class="SimpleWizard.View.Page2"
             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>
            <TextBlock Text="Cup of tea"></TextBlock>
            <Button Content="Add new tea"></Button>
        </StackPanel>
    </Grid>
</UserControl>
Реализация третей страницы Page3:
<UserControl x:Class="SimpleWizard.View.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"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
        <StackPanel>
            <RadioButton Content="Red" Background="Red"></RadioButton>
            <RadioButton Content="Green" Background="Green"></RadioButton>
            <RadioButton Content="Blue" Background="Blue"></RadioButton>
        </StackPanel>
    </Grid>
</UserControl>
Реализация главной формы приведена ниже. Вы можете ее изменить сразу по желанию, поскольку ее реализация очень простая. Стили, которые приведены в данной форме, будут реализованы позже. Страницы визарда будут выведены через ControlPresenter. Код главной страницы приведен ниже.
<Window x:Class="SimpleWizard.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"       
        Title="{Binding Title}"
        Width="640"
           Height="520"
           ResizeMode="CanMinimize"
           WindowStartupLocation="CenterScreen"
           Background="{DynamicResource WindowBackgroundBrush}"
        UseLayoutRounding="True"
        >

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="9"/>
            <ColumnDefinition Width="615"/>
            <ColumnDefinition Width="10"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="9"/>
            <RowDefinition Height="428"/>
            <RowDefinition Height="52"/>
            <RowDefinition Height="5"/>
        </Grid.RowDefinitions>
        <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Grid.Row="1" Grid.Column="1">
            <ContentPresenter Content="{Binding CurrentPage}" DataContext="{Binding CurrentPage}"/>
        </ScrollViewer>
        <Separator
                    Height="17"
                    Margin="0,0,0,35"
                    Grid.Row="2"
                    VerticalAlignment="Bottom"
                    Grid.ColumnSpan="3"/>
        <Button Content="Back"
                    Margin="0,0,183,13"
                    VerticalAlignment="Bottom"
                    FontSize="12"
                    HorizontalAlignment="Right"
                    Width="75"
                    Grid.Row="2"
                    Grid.Column="1"
            Command="{Binding MovePreviousCommand}"
            />
        <Button
                    Margin="0,0,103,13"
                    VerticalAlignment="Bottom"
                    FontSize="12"
                    Grid.Row="2"
                    Grid.Column="1"
                    HorizontalAlignment="Right"
                    Width="75"
            Command="{Binding MoveNextCommand}"
            Style="{StaticResource moveNextButtonStyle}"
                />
       
        <Button Content="Cancel"
                    Margin="0,0,23,13"
                    VerticalAlignment="Bottom"
                    FontSize="12"
                    Grid.Row="2"
                    Grid.Column="1"
                    HorizontalAlignment="Right"
                    Width="75"
            Command="{Binding CancelCommand}" />

    </Grid>
</Window>
Теперь для этих страниц необходимо создать ViewModel, в которой будет реализована необходимая логика. Для этого создадим базовую модель, от которой будем наследоваться, для создания необходимых ViewModel.
public abstract class WizardBaseViewModel : NotifyModelBase
{
    public abstract string Title { get; }

    public abstract bool IsValid();

    bool _isCurrentPage;
    public bool IsCurrentPage
    {
        get { return _isCurrentPage; }
        set
        {
            if (value == _isCurrentPage)
                return;

            _isCurrentPage = value;
            OnPropertyChanged("IsCurrentPage");
        }
    }
}
Выше приведен базовый класс WizardBaseViewModel, который и будет отвечать за логику страниц в визарде. Более сложные программные системы, которые используют паттерн MVVM, в большинстве построены на наследовании, а не на композиции. Модель NotifyModelBase представляет собой класс, который реализует интерфейс INotifyPropertyChanged. Этот класс необходим, чтобы уведомить UI об изменении. Ниже приведена реализация этого класса.
public class NotifyModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    public void OnPropertyChanged(string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
    }
}
Модель первой страницы я назвал LibaryViewModel, так как, по сути, только в этой модели представления для примера реализована связь с моделью и вывод данных в представление.
public class LibraryViewModel : WizardBaseViewModel
{
    #region Constructor
    public LibraryViewModel()
    {
        _books = new ObservableCollection<IBook>();
        _books.Add(new LibraryBook { Author = "Jon Skeet", Title = "C# in Depth", Count = 3, SN = "ISBN: 9781617291340", Year = new DateTime(2013, 9, 10) });
        _books.Add(new LibraryBook { Author = "Martin Fowler", Title = "Refactoring: Improving the Design of Existing Code", Count = 2, SN = "ISBN-10: 0201485672", Year = new DateTime(1999, 7, 8) });
        _books.Add(new LibraryBook { Author = "Jeffrey Richter", Title = "CLR via C# (Developer Reference)", Count = 5, SN = "ISBN-10: 0735667454", Year = new DateTime(2012, 12, 4) });
    }
    #endregion

    private ObservableCollection<IBook> _books;
    public ObservableCollection<IBook> Books
    {
        get { return _books; }
        set
        {
            _books = value;
            OnPropertyChanged("Books");
        }
    }

    public override string Title
    {
        get
        {
            return "First Page";
        }
    }

    public override bool IsValid()
    {
        return true;
    }
}
Модель, которая связывается с представлением, выглядит следующим образом:
public interface IBook
{
    string Author { get; set; }
    string Title { get; set; }
    DateTime Year { get; set; }
    string SN { get; set; }
    int Count { get; set; }
}
public class LibraryBook : NotifyModelBase, IBook
{
    private string _author;
    public string Author
    {
        get { return _author; }
        set
        {
            _author = value;
            OnPropertyChanged("Author");
        }
    }

    private string _title;
    public string Title
    {
        get { return _title; }
        set
        {
            _title = value;
            OnPropertyChanged("Title");
        }
    }

    private DateTime _year;
    public DateTime Year
    {
        get { return _year; }
        set
        {
            _year = value;
            OnPropertyChanged("Year");
        }
    }

    private string _sn;
    public string SN
    {
        get { return _sn; }
        set
        {
            _sn = value;
            OnPropertyChanged("SN");
        }
    }

    private int _count;
    public int Count
    {
        get { return _count; }
        set
        {
            _count = value;
            OnPropertyChanged("Count");
        }
    }
}
Осталось привести модель представления для второй страницы визарда. 
public class PageTwoViewModel : WizardBaseViewModel
{
    public override string Title
    {
        get { return "Page 2"; }
    }

    public override bool IsValid()
    {
        return true;
    }
}
И для третьей страницы приведем модель представления.
public class PageThreeViewModel : WizardBaseViewModel
{
    public override string Title
    {
        get { return "Page 3"; }
    }

    public override bool IsValid()
    {
        return true;
    }
}
Чтобы сделать привязку модели представления с конкретным представлением, воспользуемся  обычным словарем ресурсов в WPF.
Ниже представлена реализация связывания с помощью DataTemplate.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:ViewModel="clr-namespace:SimpleWizard.ViewModel"
                    xmlns:View="clr-namespace:SimpleWizard.View">
    <DataTemplate DataType="{x:Type ViewModel:LibraryViewModel}">
        <View:Page1/>
    </DataTemplate>

    <DataTemplate DataType="{x:Type ViewModel:PageTwoViewModel}">
        <View:Page2 />
    </DataTemplate>

    <DataTemplate DataType="{x:Type ViewModel:PageThreeViewModel}">
        <View:Page3 />
    </DataTemplate>

    <Style TargetType="{x:Type Button}" x:Key="moveNextButtonStyle">
        <Setter Property="Content" Value="Next" />
        <Style.Triggers>
            <DataTrigger Binding="{Binding Path=IsOnLastPage}" Value="True">
                <Setter Property="Content" Value="Finish" />
            </DataTrigger>
        </Style.Triggers>
    </Style>

    <SolidColorBrush x:Key="WinBackgroundBrush" Color="#FFF0F0F0"/>
    <SolidColorBrush x:Key="WindowBackgroundBrush" Color="#FFF0F0F0"/>
</ResourceDictionary>
Осталось сделать несколько штрихов. Первым делом сделаем доступным данный словарь для всего приложения. Для этого перейдем в файл App.xaml и изменим эго следующим образом:
<Application x:Class="SimpleWizard.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             >
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Assets/Resource.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>
Изменения небольшие. Если вы сравните свой файл с тем, который изменил я, то увидите, что, по сути, убралась строчка StartupUri и добавилась строчка видимости для словаря. Поскольку мы убрали StartupUri, нам необходимо перейти в файл App.xaml.cs и переопределить метод OnStartup. Но перед этим нужно реализовать главную ViewModel, которая будет отвечать за переключение между страницами визарда. Это модель представления, которая будет исполнять роль контролера в модели MVC, но для паттерна MVVM.
public class MainViewModel : NotifyModelBase
{
    public MainViewModel()
    {
        CurrentPage = Pages[0];
    }

    /// <summary>
    /// Returns the command which, when executed, cancels the order
    /// and causes the Wizard to be removed from the user interface.
    /// </summary>
    private DelegateCommand _cancelCommand;
    public DelegateCommand CancelCommand
    {
        get
        {
            if (_cancelCommand == null)
                _cancelCommand = new DelegateCommand(arg => CancelOrder());

            return _cancelCommand;
        }
    }

    void CancelOrder()
    {
        OnRequestClose();
    }

    private DelegateCommand _moveNextCommand;
    public DelegateCommand MoveNextCommand
    {
        get
        {
            return _moveNextCommand ?? (_moveNextCommand = new DelegateCommand(
                                                                arg => MoveToNextPage(),
                                                                arg => CanMoveToNextPage));
        }
    }

    bool CanMoveToNextPage
    {
        get { return CurrentPage != null && CurrentPage.IsValid(); }
    }

    void MoveToNextPage()
    {
        if (CanMoveToNextPage)
        {
            if (CurrentPageIndex < Pages.Count - 1)
                CurrentPage = Pages[CurrentPageIndex + 1];
            else
                OnRequestClose();
        }
    }

    #region MovePreviousCommand

    /// <summary>
    /// Returns the command which, when executed, causes the CurrentPage
    /// property to reference the previous page in the workflow.
    /// </summary>
    private DelegateCommand _movePreviousCommand;
    public DelegateCommand MovePreviousCommand
    {
        get
        {
            return _movePreviousCommand ?? (_movePreviousCommand = new DelegateCommand(
                                                                        args => MoveToPreviousPage(),
                                                                        args => CanMoveToPreviousPage));
        }
    }

    bool CanMoveToPreviousPage
    {
        get { return 0 < CurrentPageIndex; }
    }

    void MoveToPreviousPage()
    {
        if (CanMoveToPreviousPage)
            CurrentPage = Pages[CurrentPageIndex - 1];
    }

    #endregion // MovePreviousCommand

    /// <summary>
    /// Returns the page ViewModel that the user is currently viewing.
    /// </summary>
    private WizardBaseViewModel _currentPage;
    public WizardBaseViewModel CurrentPage
    {
        get { return _currentPage; }
        private set
        {
            if (value == _currentPage)
                return;

            if (_currentPage != null)
                _currentPage.IsCurrentPage = false;

            _currentPage = value;

            if (_currentPage != null)
                _currentPage.IsCurrentPage = true;

            MovePreviousCommand.RaiseCanExecuteChanged();
            MoveNextCommand.RaiseCanExecuteChanged();
            OnPropertyChanged("Title");
            OnPropertyChanged("CurrentPage");
            OnPropertyChanged("IsOnLastPage");
        }
    }

    /// <summary>
    /// Returns true if the user is currently viewing the last page
    /// in the workflow.  This property is used by CoffeeWizardView
    /// to switch the Next button's text to "Finish" when the user
    /// has reached the final page.
    /// </summary>
    public bool IsOnLastPage
    {
        get { return CurrentPageIndex == Pages.Count - 1; }
    }

    /// <summary>
    /// Returns a read-only collection of all page ViewModels.
    /// </summary>
    private ReadOnlyCollection<WizardBaseViewModel> _pages;
    public ReadOnlyCollection<WizardBaseViewModel> Pages
    {
        get
        {
            if (_pages == null)
                CreatePages();

            return _pages;
        }
    }

    #region Events

    /// <summary>
    /// Raised when the wizard should be removed from the UI.
    /// </summary>
    public event EventHandler RequestClose;

    #endregion // Events

    #region Private Helpers

    void CreatePages()
    {
        _pages = new List<WizardBaseViewModel>
            {
                new LibraryViewModel(),
                new PageTwoViewModel(),
                new PageThreeViewModel()
            }.AsReadOnly();
    }

    public string Title
    {
        get { return CurrentPage == null ? string.Empty : CurrentPage.Title; }
    }

    int CurrentPageIndex
    {
        get
        {

            if (CurrentPage == null)
            {
                Debug.Fail("Why is the current page null?");
            }

            return Pages.IndexOf(CurrentPage);
        }
    }

    void OnRequestClose()
    {
        EventHandler handler = RequestClose;
        if (handler != null)
            handler(this, EventArgs.Empty);
    }
    #endregion
}
За создание страниц выступает метод CreatePages(). Событие RequestClose используется для уведомления о закрытии страницы. За активность кнопок Next и Back отвечают команды MovePreviousCommand и MoveNextCommand. После того как мы реализовали нашу основную модель представления, осталось переопределить метод OnStartup  в App.xaml.cs. Результат запуска мы можем посмотреть на экране.
Первая кнопка неактивна, так как с первой страницы мы не можем перейти назад.
На третьей странице мы с помощью стиля, который был приведен из ресурсного файла Resource.xaml, изменили название кнопки с Next на Finish.
<Style TargetType="{x:Type Button}" x:Key="moveNextButtonStyle">
    <Setter Property="Content" Value="Next" />
    <Style.Triggers>
        <DataTrigger Binding="{Binding Path=IsOnLastPage}" Value="True">
            <Setter Property="Content" Value="Finish" />
        </DataTrigger>
    </Style.Triggers>
</Style>
Выше приведен повтор стиля, который изменяет текст кнопки. 
На этой позитивной ноте мы завершим создание простого визарда. Очень надеюсь, что  после прочтения этой статьи у вас не возникнет проблем с созданием визарда.
Источники:

No comments:

Post a Comment