Страничная навигация в WinRT

154

До настоящего момента практически все приложения строились на базе одного экземпляра класса MainPage, производного от Page. В результате мы даже не замечали, что этот экземпляр MainPage задается свойству Content объекта типа Frame, а этот объект Frame задается свойству Content экземпляра класса Window. Эта иерархия сводится воедино в методе OnLaunched стандартного класса App. Код реализации (который будет приведен позднее) проверяет возможные ошибки и следит за тем, чтобы инициализация выполнялась только один раз, но по сути простейший случай выглядит так:

Frame mainFraim = new Frame();
Window.Current.Content = mainFraim;
mainFraim.Navigate(typeof(MainPage), e.Arguments);
Window.Current.Activate();

Класс Frame является производным от ContentControl, но свойство Content напрямую не задается. Вместо этого метод Navigate получает аргумент Type, который ссылается на класс, производный от Page. Метод Navigate() создает экземпляр этого типа (в данном случае MainPage); созданный, этот экземпляр становится свойством Content объекта Frame и основным центром взаимодействия с пользователем.

В ваших программах для перехода от одной страницы к другой будет использоваться метод Navigate. Он существует в двух версиях: версия в методе OnLaunched передает данные объекту Page, а другая версия этого не делает. (Вы увидите, как это работает, позднее.)

Класс Page определяет удобное свойство Frame, так что в классе, производном от Page, вызов Navigate может выглядеть так:

this.Frame.Navigate(pageType);

В многостраничном приложении метод Navigate часто вызывается многократно с разными аргументами типа. Во внутренней реализации класс Frame поддерживает стек посещенных страниц. Класс Frame также определяет методы GoBack и GoForward со свойствами CanGoBack и CanGoForward типа bool.

Проект SimplePageNavigation содержит уже не один, а два класса, производных от Page. В этом проекте также используется шаблон Blank App, так что класс MainPage, как обычно, создается Visual Studio. Чтобы добавить в проекте еще один класс, производный от Page, я выбрал команду Add New Item из меню Project, а затем выбрал в диалоговом окне Add New вариант Blank Page (не Basic Page!). Новому классу страницы было присвоено имя SecondPage.

Проект SimplePageNavigation демонстрирует разнообразные возможности перехода между страницами. Файл MainPage.xaml создает экземпляр TextBlock для идентификации страницы, TextBox для ввода текста и три кнопки с надписями «Перейти на вторую страницу», «Вперед» и «Назад»:

<Page ...>
    <Grid Background="#FF1D1D1D">
        <StackPanel>
            <TextBlock Text="Главная страница"
                       FontSize="60" Margin="40"
                       HorizontalAlignment="Center" />

            <TextBox Name="txt"
                     HorizontalAlignment="Center"
                     Width="340"
                     Margin="40" />

            <Button Content="Перейти на вторую страницу"
                    HorizontalAlignment="Center"
                    Margin="50"
                    Click="SecondPageButton_Click" />

            <Button Name="forwardBtn" 
                    Content="Вперед"
                    HorizontalAlignment="Center"
                    Margin="50"
                    Click="ForwardBtn_Click" />

            <Button Name="backBtn" 
                    Content="Назад"
                    HorizontalAlignment="Center"
                    Margin="50"
                    Click="BackBtn_Click" />
        </StackPanel>
    </Grid>
</Page>

Файл фонового кода использует переопределение OnNavigatedTo для установки и снятия блокировки кнопок перехода вперед и назад в зависимости от свойств CanGoForward и CanGoBack, определенных классом Frame. Три обработчика Click вызывают Navigate (со ссылкой на объект SecondPage), GoForward и GoBack:

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            InitializeComponent();
        }

        protected override void OnNavigatedTo(Windows.UI.Xaml.Navigation.NavigationEventArgs e)
        {
            forwardBtn.IsEnabled = this.Frame.CanGoForward;
            backBtn.IsEnabled = this.Frame.CanGoBack;
        }

        private void SecondPageButton_Click(object sender, RoutedEventArgs e)
        {
            Frame.Navigate(typeof(SecondPage));
        }

        private void ForwardBtn_Click(object sender, RoutedEventArgs e)
        {
            Frame.GoForward();
        }

        private void BackBtn_Click(object sender, RoutedEventArgs e)
        {
            Frame.GoBack();
        }
    }
}

Класс SecondPage содержит точно такой же код, за исключением того, что он использует метод OnGotoButtonClick для перехода к MainPage:

private void MainPageButton_Click(object sender, RoutedEventArgs e)
{
    Frame.Navigate(typeof(MainPage));
}
Создание простейшей навигации - страница входа

Кнопки перехода вперед и назад заблокированы. Если щелкнуть на кнопке «Перейти на вторую страницу», программа переходит к этой странице.

Создание простейшей навигации - вторая страница

Теперь кнопка «Назад» становится доступной и выполняет возврат к MainPage. Кнопка «Перейти на главную страницу» делает то же самое, но с одним отличием: если нажать «Назад» для перехода к MainPage, то кнопка «Вперед» станет доступной, а кнопка «Назад» - нет. Если нажать кнопку «Перейти на вторую страницу», то доступной становится кнопка «Назад» - но не кнопка «Вперед».

Прежде чем изучать поведение приложения более подробно, я хочу показать еще один способ снятия блокировки с кнопок «Вперед» и «Назад». Свойства CanGoBack и CanGoForward класса Frame могут быть источниками привязки:

<Page x:Name="page" ...>

    <Grid Background="#FF1D1D1D">
        <StackPanel>
            ...

            <Button Name="forwardBtn"
                    IsEnabled="{Binding Path=Frame.CanGoForward, ElementName=page}"
                    ... />

            <Button Name="backBtn" 
                    IsEnabled="{Binding Path=Frame.CanGoBack, ElementName=page}"
                    ... />
        </StackPanel>
    </Grid>
</Page>

В этом случае в нашей программе метод OnNavigatedTo становится лишним, но любая программа, реализующая страничную навигацию, обычно использует этот метод в других целях - как и его «родственника» OnNavigatedFrom.

Эксперименты с SimplePageNavigation - нажатия различных кнопок для перехода вперед, назад, ввод символов в полях TextBox - выявляют одну очень важную особенность навигации. При переходе от одной странице к другой - посредством вызова Navigate, GoForward или GoBack - поле TextBox изначально пусто. Это означает, что для каждого нажатия кнопки создается новый экземпляр MainPage или SecondPage. Данные, введенные в текстовом поле на предыдущей странице, теряются, потому что экземпляр Page, содержащий этот экземпляр TextBox, уничтожается.

Возможно, такое поведение станет неожиданным. Конечно, создание нового экземпляра при нажатии кнопки «Перейти на вторую страницу» или «Перейти на главную страницу» выглядит логично, но вы, вероятно, также предполагали, что при нажатии кнопки «Вперед» или «Назад» осуществляется переход к предыдущему экземпляру страницы. Однако это не так - новые экземпляры создаются в любом случае.

Класс Page определяет три виртуальных метода, упрощающих задачу организации навигации. Это методы с именами OnNavigatingFrom, OnNavigatedFrom (обратите внимание на различия в именах!) и OnNavigatedTo. Если зарегистрировать вызовы этих трех методов, а также вызовы конструктора классов Page и выдачу событий Loaded и Unloaded, определяется следующая последовательность действий при переходе от одной страницы к другой:

Старая страница Новая страница
OnNavigatingFrom
Конструктор
OnNavigatedFrom
OnNavigatedTo
Loaded
Unloaded

Эта последовательность происходит независимо от того, происходит ли переход в результате Navigate, GoForward или GoBack.

До этой статьи мы рассматривали экземпляр MainPage так, словно он существует на всем протяжении жизненного цикла приложения - и это действительно так, если это единственный класс, производный от Page. Но в многостраничных приложениях приходится учитывать возможность создания и уничтожения экземпляров классов, производных от Page. Постарайтесь проектировать свои классы, производные от Page, так, чтобы назначение обработчиков событий и получение ресурсов выполнялось во время OnNavigatedTo или Loaded, а отсоединение обработчиков и освобождение ресурсов - во время OnNavigatedFrom или Unloaded.

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

Простая альтернатива - задание свойству NavigationCacheMode объекта Page значения, отличного от используемого по умолчанию члена перечисления Disabled. Например:

public MainPage()
{
    InitializeComponent();
    NavigationCacheMode = NavigationCacheMode.Enabled;
}

Также возможен вариант Required, но в этой программе Enabled и Required работают одинаково. Когда вы задаете для объекта Page режим Enabled или Required, в приложении создается и кэшируется только один экземпляр каждого класса, производного от Page. Этот экземпляр используется повторно при каждом переходе к странице независимо от того, произошел ли он из-за Navigate, GoForward или GoBack. Последовательность вызовов методов и событий остается такой же, как в приведенной ранее таблице, при первом переходе к конкретному типу страницы; во всех последующих переходах используется та же последовательность, но без конструктора. Допускается задание разных значений NavigationCacheMode для разных классов Page.

Эта альтернатива может идеально подойти для архитектуры, в которой пользователь может от центральной страницы MainPage перейти к нескольким разным вторичным страницам, а потом снова вернуться к MainPage. Различие между режимами Enabled и Required заключается в том, что Enabled может разрешить уничтожение созданных экземпляров в том случае, если количество кэшированных страниц превысит свойство CacheSize объекта Frame, которое по умолчанию равно 10, но может быть изменено.

Однако в общем случае вы, вероятно, захотите создавать новый экземпляр для вызова Navigate, но использовать существующие экземпляры для GoForward и GoBack. Этот вариант не реализуется простым заданием свойства, но вскоре я покажу, как это делается.

Стек возврата

В доисторические времена у браузеров была кнопка Back, но не было кнопки Forward. Браузер реализовал функциональность кнопки Back очень простым способом: посещенные страницы сохранялись в хорошо знакомой структуре данных, называемой стеком. В контексте браузера он назывался стеком возврата (back stack): каждый раз, когда браузер переходил к новой странице, предыдущая страница заносилась в стек. Когда пользователь нажимал кнопку Back, браузер извлекал страницу из стека и переходил к ней. Если стек оставался пустым, кнопка Back становилась недоступной.

Реализация кнопки Forward несколько усложняет этот процесс. Вместо стека для хранения информации о посещенных страницах браузеру приходится использовать упорядоченный список (который, впрочем, по традиции часто называют стеком возврата). В этот список входит текущая страница. Каждый раз, когда браузер переходит к новой странице» он добавляет ее в конец списка. Но когда пользователь нажимает кнопку Back, текущая страница не удаляется из списка, а остается в нем на случай, если пользователь нажмет кнопку Forward.

Предположим, пользователь начинает со страницы, которую я назову Страница-0, и с нее переходит к Странице-1, а затем последовательно к Странице-2, Странице-3, Странице-4 и Странице-5. Стек возврата выглядит следующим образом (последняя посещенная страница находится наверху, а стрелкой помечена текущая страница):

Страница-5 <- 
Страница-4 
Страница-3 
Страница-2 
Страница-1 
Страница-0

Теперь пользователь четыре раза нажимает кнопку Back. Текущей становится Страница-1:

Страница-5 
Страница-4 
Страница-3 
Страница-2 
Страница-1 <-
Страница-0

После нажатия кнопки Forward текущей становится Страница-2:

Страница-5 
Страница-4 
Страница-3 
Страница-2 <-
Страница-1 
Страница-0

Очевидно, при помощи кнопок Back и Forward пользователь может как угодно переходить между этими шестью страницами. Когда текущая страница достигает нижней позиции, кнопка Back блокируется. Когда она занимает верхнюю позицию списка, блокируется кнопка Forward.

Но теперь предположим, что от Страницы-2 пользователь переходит к Странице-6. Целая часть списка теряется. Ранее она была зарезервирована для нажатий кнопки Forward, но после прямого перехода к новой странице возможность навигации по этим страницам теряется:

Страница-6 <-
Страница-2 
Страница-1 
Страница-0

Кнопка Forward становится недоступной. Блокировка будет снята только тогда, когда пользователь нажмет кнопку Back.

Класс Frame ведет внутренний стек возврата посещенных страниц. Приложение не может напрямую обращаться к этому стеку и даже получить его размер. Впрочем, можно узнать позицию текущей страницы в стеке возврата из свойства BackStackDepth, доступного только для чтения. Когда приложение только начинает выполняться и переходит к исходной странице, BackStackDepth возвращает значение 0. В четырех примерах, приведенных выше, значение BackStackDepth равно 5, 1, 2 и 3 соответственно.

Свойство BackStackDepth содержит важную информацию — оно позволяет конкретному классу страницы однозначно идентифицировать свой конкретный экземпляр. Давайте посмотрим, как это делается.

События навигации и восстановление страниц

Как правило, когда программа вызывает GoBack или GoForward для возвращения к конкретной странице, пользователь должен увидеть содержимое ранее посещенной страницы. Вы уже знаете, что эта функциональность не реализуется автоматически: когда свойство NavigationCacheMode имеет значение по умолчанию Disabled, вызовы GoBack и GoForward всегда приводят к созданию нового экземпляра класса Page. В режиме Enabled или Required существующие экземпляры класса Page используются повторно, но они также используются повторно и для вызова Navigate.

Как упоминалось ранее, класс Page определяет три виртуальных метода, связанных с навигацией. Метод OnNavigatingFrom используется нечасто. Аргументы события относятся к типу NavigatingCancelEventArgs, позволяющему отменить переход.

Во время перехода от одной страницы к другой (в результате вызова Navigate, GoBack или GoForward) за вызовом OnNavigatedFrom первой страницы следует вызов OnNavigatedTo второй страницы. Оба эти метода имеют аргументы типа NavigationEventArgs. Эти аргументы также используются в других контекстах (например, в классе WebView), так что некоторые их свойства не актуальны в этих переопределениях. Свойства, играющие важную роль для событий навигации, перечислены ниже:

Свойство NavigationMode играет ключевую роль в реализации архитектуры, в которой для вызова Navigate создается новое содержимое страницы (если свойство NavigationMode равно New), но не в том случае, когда страница посещалась ранее, а переход осуществляется кнопками Back или Forward.

Все начинается с того, что класс, производный от Page, определяет поле для сохранения и восстановления своего состояния:

Dictionary<string, object> statePage;

Словарь используется практически так же, как словарь ApplicationData.LocalSettings, который я впервые представил в программе PrimitivePad в одной из предыдущих статей. Однако вместо сохранения настроек приложения по событию Application.Suspending и их восстановлению при повторном запуске приложения состояние страницы сохраняется в словаре в переопределении OnNavigatedFrom и восстанавливается в OnNavigatedTo.

Что такое «состояние страницы»? Совокупность данных, введенных пользователем, и всего, что этим вводом обусловлено: состояние флажков, переключателей, ползунков и особенно текстовых полей. В нашем примере приложения единственным действительно важным компонентом состояния страницы является содержимое элемента TextBox. Мы можем сохранить его в обработчике OnNavigatedFrom с соответствующим ключом:

statePage.Add("TextBoxText", txt.Text);

Состояние восстанавливается в OnNavigatedTo:

txt.Text = (string)statePage["TextBoxText"];

Возможно, есть и другие свойства TextBox, которые вам хотелось бы сохранить и восстановить (например, SelectionStart и SelectionLength), но пока мы не будем усложнять задачу. Процесс сохранения и восстановления состояния страницы в словаре абсолютно бесполезен, если для каждого события навигации будет создаваться новый экземпляр класса Page, потому что новый экземпляр pageState будет создаваться как часть новой страницы! Вам придется также добавить сохранение экземпляров этого словаря в другом словаре, определенного как статический, чтобы он совместно использовался всеми экземплярами этой страницы:

static Dictionary<int, Dictionary<string, object>> pages;

Значения в словаре представляют собой экземпляры типа Dictionary, который я назвал statePage. Ключи словаря являются значениями BackStackDepth, что позволяет связывать разные словари statePage с фиксированными позициями экземпляра страницы в стеке возврата.

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

Посмотрим, как работает эта схема в контексте простого приложения. Программа VisitedPageSave определяет класс с именем SaveStatePage. Для создания этого класса я использовал простой шаблон Class; файл XAML с ним не связывается. Класс является производным от Page, а два словаря определяются как защищенные, чтобы к ним можно было обращаться из производных классов:

using System.Collections.Generic;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Navigation;

namespace WinRTTestApp
{
    public class SaveStatePage : Page
    {
        static protected Dictionary<int, Dictionary<string, object>> pages =
                                new Dictionary<int, Dictionary<string, object>>();
                                
        protected Dictionary<string, object> pageState;

        //...
    }
}

Экземпляр статического словаря создается в его определении (или в статическом конструкторе), в отличие от экземплярного словаря. Как вы вскоре увидите, экземпляр может создаваться в переопределении OnNavigatedTo, если свойство NavigationMode равно New.

Я создал класс SecondPage так же, как в приложении SimplePageNavigation, но и в файле XAML, и в файлах фонового кода MainPage и SecondPage базовый класс Page был заменен на SaveStatePage. В остальном файлы MainPage.xaml и SecondPage.xaml ничем не отличаются от файлов приложения SimplePageNavigation.

Два файла фонового кода практически одинаковы. Ниже приведен файл MainPage.xaml.cs с уже знакомыми реализациями обработчиков Click для кнопок:

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public sealed partial class MainPage : SaveStatePage
    {
        public MainPage()
        {
            InitializeComponent();
        }

        // ...

        private void SecondPageButton_Click(object sender, RoutedEventArgs e)
        {
            Frame.Navigate(typeof(SecondPage));
        }

        private void ForwardBtn_Click(object sender, RoutedEventArgs e)
        {
            Frame.GoForward();
        }

        private void BackBtn_Click(object sender, RoutedEventArgs e)
        {
            Frame.GoBack();
        }
    }
}

Свойству NavigationCacheMode оставлено значение по умолчанию Disabled, так что по всем событиям навигации создается новый объект страницы.

В переопределении OnNavigatedTo в качестве целочисленного ключа статического словаря используется свойство BackStackDepth. Если режим NavigationMode отличен от New, метод просто использует этот ключ для получения словаря pageState, соответствующего этой позиции стека возврата, а затем использует словарь для инициализации страницы - в нашем примере поля TextBox:

protected override void OnNavigatedTo(NavigationEventArgs args)
{
    // Определение состояния блокировки кнопок
    forwardBtn.IsEnabled = this.Frame.CanGoForward;
    backBtn.IsEnabled = this.Frame.CanGoBack;

    // Создать ключ словаря
    int pageKey = this.Frame.BackStackDepth;

    if (args.NavigationMode != NavigationMode.New)
    {
        // Получение словаря состояния для текущей страницы
        pageState = pages[pageKey];

        // Получение состояния страницы из словаря
        txt.Text = pageState["TextBoxText"] as string;
    }

    base.OnNavigatedTo(args);
}

Но если свойство NavigationMode равно New, значит, переход к странице был выполнен вызовом Navigate, и страница должна рассматриваться как новая и неинициализированная. Дополнительная логика выполняется в реализации метода OnNavigatedTo из SaveStatePage, который вызывается в конце переопределений OnNavigatedTo в MainPage и SecondPage. Этот код создает новый словарь pageState и добавляет его в статический словарь pages:

namespace WinRTTestApp
{
    public class SaveStatePage : Page
    {
        static protected Dictionary<int, Dictionary<string, object>> pages =
                                new Dictionary<int, Dictionary<string, object>>();
                                
        protected Dictionary<string, object> pageState;

        protected override void OnNavigatedTo(NavigationEventArgs args)
        {
            if (args.NavigationMode == NavigationMode.New)
            {
                // Construct a dictionary key
                int pageKey = this.Frame.BackStackDepth;

                // Remove page key and higher page keys
                for (int key = pageKey; pages.Remove(key); key++) ;

                // Create a new page state dictionary and save it
                pageState = new Dictionary<string, object>();
                pages.Add(pageKey, pageState);
            }

            base.OnNavigatedTo(args);
        }
    }
}

Однако из статического словаря pages также необходимо удалить все возможные элементы с равными или большими значениями ключей BackStackDepth. Эти элементы возникают из-за вызовов GoBack, не имеющих парных вызовов GoForward. Чтобы инструкция for, удаляющая эти элементы, стала более понятной, стоит учесть, что для существующего ключа метод Remove класса Dictionary возвращает false:

for (int key = pageKey; pages.Remove(key); key++) ;

И в MainPage, и в SecondPage переопределение OnNavigatedFrom получается более простым; оно ограничивается сохранением состояния в существующем словаре pageState:

protected override void OnNavigatedFrom(NavigationEventArgs args)
{
    pageState.Clear();

    // Сохранить состояние страницы в словаре
    pageState.Add("TextBoxText", txt.Text);

    base.OnNavigatedFrom(args);
}

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

Чтобы проверить правильность работы программы, проще всего ввести 1,2,3 и т. д. в полях TextBox на последовательно открываемых страницах. Нажимая кнопки «Вперед» и «Назад», можно проследить за восстановлением состояния этих страниц.

Если приостановить, а затем продолжить выполнение этого приложения из Visual Studio, вы увидите, что все данные восстанавливаются правильно. Однако приложение ничего не сохраняет при приостановке, поэтому если приложение будет завершено во время приостановки, при следующем запуске оно откроется в новом, несохраненном состоянии. Вероятно, это не то, на что вы рассчитывали, но проблема решается достаточно просто.

Пройди тесты
Лучший чат для C# программистов