Разбиение документа на страницы в WinRT

198

Итак, мы видели примеры использования RichTextBlock и RichTextBlockOverflow на паре коротких рассказов. Естественно спросить: сработает ли этот метод для целой повести? Давайте попробуем. Возьмем достаточно короткую повесть - например, «Сайлас Марнер» Джорджа Элиота. Вместо колонок текста программа SilasMarner отображает страницы, а для вывода RichTextBlock и RichTextBlockOverflow используется элемент FlipView.

Удивительно, насколько удачно FlipView имитирует внешний вид и поведение устройств для чтения электронных книг. Каждая страница может занимать весь экран (или почти весь экран), и для перехода между страницами в прямом или обратном направлении достаточно провести по экрану пальцем. Кроме того, я разместил в нижней части страницы элемент Slider, который дает наглядное представление о текущей позиции чтения и позволяет очень быстро переместиться к нужной странице.

Программа SilasMarner приспособлена для книжного, а не для альбомного режима, пожалуй, в большей степени, чем любая другая программа в этом руководстве. Как видно из рисунка, в альбомном режиме строки просто получаются слишком широкими для комфортного чтения:

Длинный текст в альбомном режиме

Если повернуть устройтво на 90 градусов, текст воспринимается гораздо лучше:

Длинный текст в книжном режиме

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

Запустите программу SilasMarner, структура которой будет описана далее, на планшете. Попробуйте повернуть его, включите режим Snap View и понаблюдайте за тем, сколько времени требуется элементам RichTextBlock и RichTextBlockOverflow на разбиение документа на страницы.

Одна из известных проблем ридеров - поддержание и отображение информативных номеров страниц. Каждый раз при пересчете количество страниц в документе может изменяться.

Программа SilasMarner сохраняет информацию в локальном хранилище. Это позволяет продолжить чтение с позиции, в которой оно было прервано, но номер страницы для этой цели не используется. Вместо этого сохраняется относительная позиция, вычисленная делением текущей страницы на общее количество страниц. Только это значение сохраняется между запусками программы:

public sealed partial class MainPage : Page
{
        double fractionRead;

        public MainPage()
        {
            this.InitializeComponent();

            // Сохранение и загрузка относительно позиции чтения
            IPropertySet propertySet = ApplicationData.Current.LocalSettings.Values;

            Application.Current.Suspending += (sender, args) =>
                {
                    propertySet["FractionRead"] = fractionRead;
                };

            if (propertySet.ContainsKey("FractionRead"))
                fractionRead = (double)propertySet["FractionRead"];
        }
        
        // ...
        
}

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

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

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition />
            <RowDefinition Height="auto" />
        </Grid.RowDefinitions>

        <StackPanel Grid.Row="0"
                    Orientation="Horizontal"
                    HorizontalAlignment="Center">
            <StackPanel.Resources>
                <Style TargetType="TextBlock">
                    <Setter Property="FontSize" Value="24" />
                </Style>
            </StackPanel.Resources>

            <TextBlock Text="&amp;#x201C;Silas Marner&amp;#x201D; автор George Eliot" />
            <TextBlock Text="&amp;#x00A0;&amp;#x2014; Страница&amp;#x00A0;" />
            <TextBlock Name="pageNumber" />
            <TextBlock Text="&amp;#x00A0;из&amp;#x00A0;" />
            <TextBlock Name="pageCount" />
        </StackPanel>

        <FlipView Name="flipView" 
                  Grid.Row="1"
                  Background="White"
                  SizeChanged="OnFlipViewSizeChanged"
                  SelectionChanged="OnFlipViewSelectionChanged">
            <FlipView.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal" />
                </ItemsPanelTemplate>
            </FlipView.ItemsPanel>
        </FlipView>
        
        <Slider Name="pageSlider"
                Grid.Row="2"
                Margin="24 12 24 0"
                ValueChanged="OnPageSliderValueChanged" />
    </Grid>
</Page>

Важнейшие действия в этой программе выполняются обработчиком SizeChanged элемента FlipView. В зависимости от размера FlipView программа должна сгенерировать необходимое количество элементов RichTextBlockOverflow.

Когда событие SizeChanged впервые инициируется после запуска программы, обработчик должен обратиться к файлу книги и разделить его на абзацы. В текстовых файлах проекта Гутенберг каждый абзац состоит из последовательности строк с жесткими разрывами строк. Границы абзацев обозначаются пустыми строками. Обработчик должен обработать текст и создать объекты Paragraph, которые добавляются в элемент RichTextBlock:

public sealed partial class MainPage : Page
{ 
    // ...
    
    private async void OnFlipViewSizeChanged(object sender, SizeChangedEventArgs e)
    {
            // Получение размера FlipView
            Size containerSize = e.NewSize;

            // Фактическое значение изменяется в ходе обработки,
            // поэтому необходимо его сохранить
            double saveFractionRead = fractionRead;

            // В первый раз после запуска программы
            if (flipView.Items.Count == 0)
            {
                // Загрузка текста книги из файла
                IList<string> bookLines = 
                    await PathIO.ReadLinesAsync("ms-appx:///Books/pg550.txt", UnicodeEncoding.Utf8);

                // Создание RichTextBlock
                RichTextBlock richTextBlock = new RichTextBlock
                {
                    FontSize = 22,
                    Foreground = new SolidColorBrush(Colors.Black)
                };

                // Создание абзацев
                Paragraph paragraph = new Paragraph();
                paragraph.Margin = new Thickness(12);
                richTextBlock.Blocks.Add(paragraph);

                foreach (string line in bookLines)
                {
                    // Конец абзаца - нужно создать новый объект Paragraph
                    if (line.Length == 0)
                    {
                        paragraph = new Paragraph();
                        paragraph.Margin = new Thickness(12);
                        richTextBlock.Blocks.Add(paragraph);
                    }
                    // Продолжение абзаца
                    else
                    {
                        string textLine = line;
                        char lastChar = line[line.Length - 1];

                        if (lastChar != ' ')
                            textLine += ' ';

                        if (line[0] == ' ')
                            paragraph.Inlines.Add(new LineBreak());

                        paragraph.Inlines.Add(new Run { Text = textLine });
                    }
                }

                // Размер RichTextBlock приводится к размеру FlipView
                flipView.Items.Add(richTextBlock);
                richTextBlock.Measure(containerSize);

                // Генерирование элементов RichTextBlockOverflow
                if (richTextBlock.HasOverflowContent)
                {
                    // Добавление первого элемента
                    RichTextBlockOverflow richTextBlockOverflow = new RichTextBlockOverflow();
                    richTextBlock.OverflowContentTarget = richTextBlockOverflow;
                    flipView.Items.Add(richTextBlockOverflow);
                    richTextBlockOverflow.Measure(containerSize);

                    // Добавление последующих элементов
                    while (richTextBlockOverflow.HasOverflowContent)
                    {
                        RichTextBlockOverflow newRichTextBlockOverflow = new RichTextBlockOverflow();
                        richTextBlockOverflow.OverflowContentTarget = newRichTextBlockOverflow;
                        richTextBlockOverflow = newRichTextBlockOverflow;
                        flipView.Items.Add(richTextBlockOverflow);
                        richTextBlockOverflow.Measure(containerSize);
                    }
                }
            }
            // ...
    }
    
    // ...
}

При последующих срабатываниях обработчика SizeChanged программа может просто очистить FlipView и начать все заново, но я решил попытаться повысить эффективность за счет добавления новых элементов RichTextBlockOverflow, если они необходимы, или удаления ненужных элементов:

private async void OnFlipViewSizeChanged(object sender, SizeChangedEventArgs e)
{
    // ...
    
    // Последующие события SizeChanged
    else
    {
        // Измерение размеров всех элементов FlipView
        foreach (object obj in flipView.Items)
        {
            (obj as FrameworkElement).Measure(containerSize);
        }

        // Генерирование новых элементов RichTextBlockOverflow в случае необходимости
        while ((flipView.Items[flipView.Items.Count - 1] 
            as RichTextBlockOverflow).HasOverflowContent)
        {
            RichTextBlockOverflow richTextBlockOverflow = 
            flipView.Items[flipView.Items.Count - 1] as RichTextBlockOverflow;
            RichTextBlockOverflow newRichTextBlockOverflow = new RichTextBlockOverflow();
            richTextBlockOverflow.OverflowContentTarget = newRichTextBlockOverflow;
            richTextBlockOverflow = newRichTextBlockOverflow;
            flipView.Items.Add(richTextBlockOverflow);
            richTextBlockOverflow.Measure(e.NewSize);
        }

        // Удаление избыточных элементов RichTextBlockOverflow
        while (!(flipView.Items[flipView.Items.Count - 2] 
            as RichTextBlockOverflow).HasOverflowContent)
        {
            flipView.Items.RemoveAt(flipView.Items.Count - 1);
        }
    }
    
    // ...
}

Однако я обнаружил (как, вероятно, обнаружите и вы), что эта логика приводит к вычислению недостаточного количества элементов RichTextBlockOverflow. Весь текст сохраняется, но часть лицензионной информации в конце файла пропадает. Я не знаю, почему это происходит. Обработка SizeChanged завершается инициализацией текста заголовка и Slider и последующим заданием свойству SelectedIndex объекта FlipView значения, основанного на fractionRead:

private async void OnFlipViewSizeChanged(object sender, SizeChangedEventArgs e)
{
    // ...
    
    // Инициализация заголовка и элемента Slider
    int count = flipView.Items.Count;
    pageNumber.Text = "1";           // Вероятно, скоро изменится
    pageCount.Text = count.ToString();
    pageSlider.Minimum = 1;
    pageSlider.Maximum = flipView.Items.Count;
    pageSlider.Value = 1;            // Вероятно, скоро изменится

    // Переход к приближенно вычисленной странице
    fractionRead = saveFractionRead;
    flipView.SelectedIndex = (int)Math.Min(count - 1, fractionRead * count);
}

По сути это основной код программы. Обработчик SelectionChanged элемента FlipView изменяет заголовок и Slider, а обработчик ValueChanged элемента Slider изменяет свойство SelectedIndex у FlipView:

public sealed partial class MainPage : Page
{
        // ...

        private void OnFlipViewSelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            int pageNum = flipView.SelectedIndex + 1;
            pageNumber.Text = pageNum.ToString();
            fractionRead = (pageNum - 1.0) / flipView.Items.Count;
            pageSlider.Value = pageNum;
        }

        private void OnPageSliderValueChanged(object sender, RangeBaseValueChangedEventArgs e)
        {
            flipView.SelectedIndex = Math.Min(flipView.Items.Count, (int)e.NewValue) - 1;
        }
}

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

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