Full version of MasterViewControl

После релиза UWP Microsoft показала как должны выглядить современные приложения на Windows 10. И так вопрос: что должно быть у приложения, чтобы оно соответствовало универсальной платформе? Канечно же адаптивный пользовательский интерфейс, так как UWP может запускаться на различных устройствах, с различной диагональю экрана (или без).

one_platform

Например, для рассмотрения я взял стандартный клиент для почты. У него имеется два состояния (на самом деле у данного клиента есть три состояния, но для моего примера важны только два):

“Narrow”

standart_client_state_1

“Wide”

standart_client_state_2

При этом, в разных состояниях меняется и UX мастер UI/UX:

“Narrow”

standart_client_state_2

“Wide”

standart_client_state_2

Покопаясь в интернете я нашел, что-то… А точнее описание такого паттерна поведения GUI, который называется Master/details pattern.

master_details_pattern

Т.е. есть уже готовое решение, которое можно использовать для написания приложения на UWP. Нужно всего лишь добавить шаблон, пару стилей и “вуаля” - всё готово. Но не всё так просто, как казалось на первый взгляд. Первом делом я расмотрел то, что предлагает Microsoft. А именно Master/details sample.

Дальше решение от Microsoft. Слабонервных попрошу уйти.

<Grid x:Name="LayoutRoot" Loaded="LayoutRoot_Loaded">
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="AdaptiveStates" CurrentStateChanged="AdaptiveStates_CurrentStateChanged">
            <VisualState x:Name="DefaultState">
                <VisualState.StateTriggers>
                    <AdaptiveTrigger MinWindowWidth="720" />
                </VisualState.StateTriggers>
            </VisualState>

        <VisualState x:Name="NarrowState">
            <VisualState.StateTriggers>
                   <AdaptiveTrigger MinWindowWidth="0" />
            </VisualState.StateTriggers>

            <VisualState.Setters>
                <Setter Target="MasterColumn.Width" Value="*" />
                <Setter Target="DetailColumn.Width" Value="0" />
                <Setter Target="MasterListView.SelectionMode" Value="None" />
            </VisualState.Setters>
        </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>

        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Grid.ColumnDefinitions>
            <ColumnDefinition x:Name="MasterColumn" Width="320" />
            <ColumnDefinition x:Name="DetailColumn" Width="*" />
        </Grid.ColumnDefinitions>

        <TextBlock
            Text="My Items"
            Margin="12,8,8,8"
            Style="{ThemeResource TitleTextBlockStyle}" />

        <ListView .../>

        <ContentPresenter .../>
</Grid>

Теперь можно выдохнуть.

Давайте поставим все точки над i. Что нужно делать-то? Взять Grid. Разбить этот Grid на несколько колонок. В нем определить VisualStateManager, которые будут реагировать на изменения размера. И в случае, когда ширина Grid’a будет меньше определенного размера, “захлопнуть” колонку с DetailView и показать DetailViewPage.

Почему это решение нельзя назвать полноценным MasterDetailView?

Во-первых, ContentPresenter’y был задан определенный шаблон, и из-за этого в DetailView можно поместить только элемент из ListView. Следовательно, можно забыть об использовании CommandBar’a в MasterView. Во-вторых, так как ContentPresenter представляет из себя обычный контейнер для отображения содержимого ListView, то можно забыть о навигации внутри DetailView (как это реализовано в стандартном почтовом клиенте). Microsoft как-бы сказали:”Вот так вот должно выглядить приложение. Вот такой паттерн нужно использовать. Но готового контрола для реализации этого паттерна в приложениях у нас нет, но вы можете его сделать сами! Никто Вам не мешает.”

Ок. Я так и сделаю.

Что мне нужно от full MasterDetailView?

Во-первых, у данного представления должно быть два рабочих состояния, как у стандартного клиента для почты. Во-вторых, в данном представлении должны отсутсвовать недочеты, которые есть в примере от Microsoft.

Может кто-нибудь уже написал что-то похожее? Откроем исходники Unigram потомучто!. Поведения приложения Unigram меня устраевает, но их решения слишком неоправданно сложное (кому интересно, может посмотреть в исходники). Разработчики данного приложения используют NavigationService, что для приложения с нестрогой MVVM архитектурой необязательно.

Ну что же? Раз решил давай напиши.

Мне нужен контрол, состоящий из двух частей: MasterView и DetailView. При этом в MastrView’e должен находится не только ListView, а любой элемент, который разработчик захочет. А DatailView должен содержать Frame, чтобы можно осуществлять навигацию по страницам. Для начала возьмем и создадим класс, который назавем, ну например, MasterDetailView и который наследуется от ContentControl. Почему наш класс должен наследоваться от ContentControl? В MasterDetailView у нас будет ContentPresenter. А как говорил один легендарный .net разработчик: “Лучший контейнер для ContentPresenter’a - это ContentControl”.

В конструкторе я пропишу следующее:

public MasterDetailView()
{
    DefaultStyleKey = typeof(MasterDetailView);

    Loaded += OnLoaded;
    Unloaded += OnUnloaded;
}

Для чего нам нужны обработчики для событий Loaded и Unloaded? В этих обработчиках я буду подписываться (и отписываться) на события кнопки Back, примерно так.

SystemNavigationManager.GetForCurrentView().BackRequested += OnBackRequested;

В методе OnBackRequested() я буду переходить на предыдущую страницу. Что нам ещё нужно? Мне нужно, чтобы внешний пользователь,тот кто использует мой контрол мог узнать текущие состоние. Для этого создадим обычный enum, который характеризует эти состояния.

public enum MasterDetailVisualState : byte
{
    Narrow,
    Wide
}

А в классе MasterDetailView будет свойство CurrentState, которое показывает текущее состояние. Теперь нам надо менять свойства CurrentState при изменении в VisualStateGroup. Для этого переопределим метод OnApplyTemplate() и я напишу в нем следующее.

protected override void OnApplyTemplate()
{
    _masterPresenter = (ContentPresenter)GetTemplateChild("MasterPresenter");
    _detailPresenter = (Frame)GetTemplateChild("DetailPresenter");
    _stateGroup = (VisualStateGroup)GetTemplateChild("AdaptiveStates");
    
    _stateGroup.CurrentStateChanged += OnCurrentStateChanged;
    CurrentState = _stateGroup.CurrentState.Name == "WideState" ? MasterDetailVisualState.Wide :
    MasterDetailVisualState.Narrow;
}

Здесь я подписываюсь на событие CurrentStateChanged у VisualStateGroup. Данное событие возникает, когда происходит изменение состония. В методе OnCurrentStateChanged() меняю значение у свойства CurrentState. Его реализация

private void OnCurrentStateChanged(object sender, VisualStateChangedEventArgs e)
{
    if(e.NewState.Name == "WideState")
    {
        CurrentState = MasterDetailVisualState.Wide;
    }

    if(e.NewState.Name == "NarrowState")
    {
        CurrentState = MasterDetailVisualState.Narrow;
    }
    
    UpdateView();
    ViewStateChanged?.Invoke(this, EventArgs.Empty);
}

Далее нужно реализовать метод, который бы изменял видимость у _masterPserenter и _detailPresenter.
В нем не будет ничего сложного. Назову-ка я его UpdateView().

private void UpdateView()
{
    if (CurrentState == MasterDetailVisualState.Filled)
    {
        _masterPresenter.Visibility = Visibility.Visible;
        _detailPresenter.Visibility = Visibility.Visible;
    }
    
    if (CurrentState == MasterDetailVisualState.Narrow && _detailFrame.Content == null)
    {
        _masterPresenter.Visibility = Visibility.Visible;
        _detailPresenter.Visibility = Visibility.Collapsed;
    }
    
    if (CurrentState == MasterDetailVisualState.Filled && _detailFrame.Content != null)
    {
        _masterPresenter.Visibility = Visibility.Collapsed;
        _detailPresenter.Visibility = Visibility.Visible;
    }
}

Ой! Чуть не забыл показать реализацию метода OnBackRequested().

private void OnBackRequested(object sender, BackRequestedEventArgs e)
{
    if (CurrentState == MasterDetailVisualState.Filled)
    {
        if (_detailFrame.CanGoBack)
        {
            _detailFrame.GoBack();
        }
    }
    else
    {
        if (_detailFrame.BackStack.Count > 1)
        {
            if (_detailFrame.CanGoBack)
            {
                _detailFrame.GoBack();
            }
        }
        else
        {
            _detailPresenter.Visibility = Visibility.Collapsed;
            _masterPresenter.Visibility = Visibility.Visible;
            _detailFrame.BackStack.Clear();
        }
    }
}

Теперь пожалуй один из главных моментов, так как у меня не используется NavigationService, то моему контролу нужно как-то переходить на другие страницы. Для это я создам в классе MasterDetailView метод Navigate(), который принимал был тип страницы и параметры, которые необходимы для перехода.

public void Navigate(Type typePage, object args)
{
    UpdateView();
    _detailFrame.Navigate(typePage, args);
}

Этот метод не идеален, но для примера сойдет. Теперь нужно определить шаблона для моего контрола. Определю-ка я его в нутри стиля.

<Style TargetType="Controls:MasterDetailView">
        <Setter Property="BorderBrush" Value="{ThemeResource SystemControlForegroundBaseLowBrush}"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="Controls:MasterDetailView">
                    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
                        <VisualStateManager.VisualStateGroups>
                            <VisualStateGroup x:Name="AdaptiveStates">
                                <VisualState x:Name="WideState">
                                    <VisualState.StateTriggers>
                                        <AdaptiveTrigger MinWindowWidth="820"/>
                                    </VisualState.StateTriggers>
                                    <VisualState.Setters>
                                        <Setter Target="MasterColumn.Width" Value="260*" />
                                        <Setter Target="DetailColumn.Width" Value="540*" />
                                        <Setter Target="MasterColumn.MinWidth" Value="72" />
                                        <Setter Target="MasterColumn.MaxWidth" Value="540" />
                                        <Setter Target="DetailPresenter.(Grid.Column)" Value="1"/>
                                    </VisualState.Setters>
                                </VisualState>
                                <VisualState x:Name="NarrowState">
                                    <VisualState.StateTriggers>
                                        <AdaptiveTrigger MinWindowWidth="0"/>
                                    </VisualState.StateTriggers>
                                </VisualState>
                            </VisualStateGroup>
                        </VisualStateManager.VisualStateGroups>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition x:Name="MasterColumn"/>
                            <ColumnDefinition x:Name="DetailColumn" Width="0"/>
                        </Grid.ColumnDefinitions>
                        <ContentPresenter x:Name="MasterPresenter"
                                          Content="{TemplateBinding Content}"
                                          ContentTemplate="{TemplateBinding ContentTemplate}"
                                          ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
                                          ContentTransitions="{TemplateBinding ContentTransitions}"
                                          DataContext="{TemplateBinding DataContext}"
                                          HorizontalContentAlignment="Stretch"
                                          VerticalContentAlignment="Stretch"/>

                        <Frame x:Name="DetailPresenter" Background="{TemplateBinding Background}">
                            <TextBlock Text="Ничего не выбрано, пожалуйста выберите" HorizontalAlignment="Center"  VerticalAlignment="Center"/>
                        </Frame>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
</Style>

Давай-те посмотрим на шаблон по внимательней. Что я изменил?

Во-первых, в качестве MasterView выступает ContentPresenter. А это означает, что в нем можно поместить всё что угодно. Во-вторых, в качестве DetailView выступает Frame (да здравствует навигация без NavigationService).

Итог:

“Narrow”

full_masterdetailview

“Wide”

full_masterdetailview

Чуть не забыл, я добавил капельку акрила.

Написано 23 февраля 2018