Об авторе
О техническом рецензенте
Благодарности
Введение
Об этой книге
Обзор глав
Что необходимо для чтения этой книги
Исходный код примеров
От издательства
Глава 1. Введение в WPF
Аппаратное ускорение и WPF
WPF: высокоуровневый API-интерфейс
Архитектура WPF
WPF4
WPF Toolkit
Visual Studio 2010
Поддержка множества целевых платформ
Клиентский профиль .NET
Визуальный конструктор Visual Studio
Резюме
Глава 2. XAML
Разновидности XAML
Компиляция XAML
Основы XAML
Класс отделенного кода
Свойства и события в XAML
Сложные свойства
Расширения разметки
Присоединенные свойства
Вложенные элементы
Специальные символы и пробелы
События
Полный пример автоответчика
Использование типов из других пространств имен
Загрузка и компиляция XAML
Код и не компилированный XAML
Код и скомпилированный XAML
Только XAML
XAML 2009
Ссылки
Встроенные типы
Расширенное создание объектов
Резюме
Глава 3. Компоновка
Процесс компоновки
Контейнеры компоновки
Простая компоновка с помощью StackPanel
Выравнивание
Поля
Минимальные, максимальные и явные размеры
Элемент Border
WrapPanelHDockPanel
DockPanel
Вложение контейнеров компоновки
Grid
Округление компоновки
Объединение строк и колонок
Разделенные окна
Группы с общими размерами
UniformGnd
Координатная компоновка с помощью Canvas
InkCanvas
Примеры компоновки
Динамическое содержимое
Модульный пользовательский интерфейс
Резюме
Глава 4. Свойства зависимости
Регистрация свойства зависимости
Проверка свойств
Резюме
Глава 5. Маршрутизируемые события
Совместное использование маршрутизируемых событий
Генерация маршрутизируемого события
Обработка маршрутизируемого события
Маршрутизация событий
События WPF
События ввода
Ввод с клавиатуры .
Ввод с использованием мыши
Сенсорный многопозиционный ввод
Резюме
Глава 6. Элементы управления
Шрифты
Курсоры мыши
Элементы управления содержимым
Метки
Кнопки
Всплывающие подсказки
Специализированные контейнеры
Элементы управления содержимым с заголовками
Класс Tabltem
Класс Expander
Текстовые элементы управления
Выделение текста
Проверка правописания
Класс PasswordBox
Элементы управления списками
Класс ComboBox
Элементы управления, основанные на диапазонах значений
Класс ProgressBar
Элементы управления датами
Резюме
Глава 7. Класс Application
Наследование специального класса приложения
Останов приложения
События класса Application
Задачи приложения
Обработка аргументов командной строки
Доступ к текущему приложению
Взаимодействие между окнами
Приложение одного экземпляра
Ресурсы сборки
Извлечение ресурсов
Упакованные URI
Ресурсы в других сборках
Файлы содержимого
Локализация
Подготовка приложения для локализации
Процесс перевода
Резюме
Глава 8. Привязка элементов
Ошибки привязки
Режимы привязки
Привязка OneWayToSource
Привязка Defaut
Создание привязки в коде
Множественные привязки
Обновления привязок
Привязка к объектам, не являющимся элементами
Свойство RelativeSource
Свойство DataContext
Резюме
Глава 9. Команды
Модель команд WPF
КлассRoutedCommand
Класс RoutedUICommand
Библиотека команд
Выполнение команд
Привязки команд
Использование множества источников команд
Точная настройка текста команды
Вызов команды напрямую
Отключение команд
Элементы управления со встроенными командами
Расширенные команды
Использование одной и той же команды в разных местах
Использование параметра команды
Отслеживание и отмена команд
Резюме
Глава 10. Ресурсы
Иерархия ресурсов
Статические и динамические ресурсы
Неразделяемые ресурсы
Доступ к ресурсам в коде
Ресурсы приложения
Ресурсы системы
Словари ресурсов
Использование словаря ресурсов
Разделение ресурсов между сборками
Резюме
Глава 11. Стили и поведения
Установка свойств
Присоединение обработчиков событий
Множество уровней стилей
Автоматическое применение стилей по типу
Триггеры
Триггер события
Поведения
Модель поведений
Создание поведения
Использование поведения
Поддержка использования поведений во время проектирования в Expression Blend
Резюме
Глава 12. Фигуры, кисти и трансформации
Rectangle и Ellipse
Установка размеров и расположения фигур
Масштабирование фигур в Viewbox
Line
Polyline
Polygon
Наконечники и стыки линий
Пунктирные линии
Привязка к пикселям
Кисти
LinearGradientBrush
RadialGradientBrush
ImageBrush
Мозаичная кисть ImageBrush
VisualBrush
BitmapCacheBrush
Трансформации
Трансформация элементов
Прозрачность
Маски непрозрачности
Резюме
Глава 13. Классы Geometry и Drawing
Комбинирование фигур в GeometryGroup
Комбинирование объектов Geometry и CombinedGeometry
Кривые и прямые линии, представляемые с помощью PathGeometry
Мини-язык описания геометрии
Кадрирование геометрии
Рисунки
Экспорт рисунка
Резюме
Глава 14. Эффекты и класс Visual
Помещение визуальных объектов в оболочку элемента
Проверка попадания
Сложная проверка попадания
Эффекты
КлассDropShadowEffeet
Класс ShaderEffeet
Класс WriteableBitmap
Запись в WriteableBitmap
Более эффективная запись пикселей
Резюме
Глава 15. Основы анимации
Анимация на основе свойств
Базовая анимация
Анимация в коде
Одновременные анимации
Время жизни анимации
Класс TimeLine
Раскадровки
Триггеры событий
Перекрывающиеся анимации
Синхронизированные анимации
Управление воспроизведением
Отслеживание хода анимации
Плавность анимации
Режимы плавности
Классы функций плавности
Создание специальной функции плавности
Производительность анимации
Кэширование растровых изображений
Резюме
Глава 16. Расширенная анимация
Анимированные кисти
Анимация построителей текстур
Анимация ключевого кадра
Дискретные анимации ключевого кадра
Плавные ключевые кадры
Сплайновые анимации ключевого кадра
Анимация на основе пути
Анимация на основе кадра
Раскадровки в коде
Пользовательский элемент управления Bomb
Сброс бомб
Перехват бомбы
Подсчет бомб и очистка
Резюме
Глава 17. Шаблоны элементов управления
Что собой представляют шаблоны
Анализ элементов управления
Создание шаблонов элементов управления
Привязки шаблона
Триггеры, изменяющие свойства
Триггеры, использующие анимацию
Организация ресурсов для шаблонов
Применение шаблонов со стилями
Автоматическое применение шаблонов
Обложки, выбранные пользователем
Построение более сложных шаблонов
Модификация полосы прокрутки
Примеры шаблонов элементов управления
Визуальные состояния
Резюме
Глава 18. Пользовательские элементы
Построение базового пользовательского элемента управления
Определение маршрутизируемых событий
Добавление кода разметки
Использование элемента управления
Поддержка команд
Пристальный взгляд на UserControl
Создание элемента управления, лишенного внешнего вида
Рефакторинг кода разметки указателя цвета
Оптимизация шаблона элемента управления
Стили, специфичные для темы, и стиль по умолчанию
Поддержка визуальных состояний
Выбор частей и состояний
Шаблон элемента управления, принятый по умолчанию
Использование FlipPanel
Использование другого шаблона элемента управления
Пользовательские панели
Клон Canvas
Улучшенная панель WrapPanel
Рисованные элементы
Выполнение специального рисования
Элемент, выполняющий специальное рисование
Специальный декоратор
Резюме
Глава 19. Привязка данных
Построение объекта данных
Отображение привязанного объекта
Обновление базы данных
Уведомление об изменениях
Привязка к коллекции объектов
Вставка и удаление элементов коллекций
Привязка объектов ADO.NET
Привязка к выражению LINQ
Повышение производительности больших списков
Повторное использование контейнера элементов
Отложенная прокрутка
Проверка достоверности
Класс ExceptionValidationRule
КлассDataErrorValidationRule
Специальные правила проверки достоверности
Реакция на ошибки проверки достоверности
Получение списка ошибок
Отображение отличающегося индикатора ошибки
Проверка достоверности множества значений
Поставщики данных
Поставщик XmlDataProvider
Резюме
Глава 20. Форматирование привязанных данных
Преобразование данных
Что собой представляют конвертеры значений
Форматирование строк с помощью конвертера значений
Создание объектов с конвертером значений
Применение условного форматирования
Оценка множества свойств
Списочные элементы управления
Стили списков
Элемент ListBox c флажками или переключателями
Стиль чередующихся элементов
Селекторы стиля
Шаблоны данных
Более развитые шаблоны
Варьирование шаблонов
Селекторы шаблонов
Шаблоны и выбор
Изменение компоновки элемента
Элемент ComboBox
Резюме
Глава 21. Представления данных
Навигация в представлении
Создание представления декларативным образом
Фильтрация, сортировка и группирование
Фильтрация объекта DataTable
Сортировка
Группирование
Резюме
Глава 22. Элементы управления ListView, TreeView и DataGrid
Изменение размера столбцов
Шаблоны ячеек
Создание специального представления
Элемент управления TreeView
Привязка элемента управления TreeView к объекту DataSet
Оперативное создание узлов
Элемент управления DataGrid
Определение столбцов
Форматирование и стилизация столбцов
Форматирование строк
Детали строк
Закрепление столбцов
Выбор
Сортировка
Редактирование BDataGrid
Резюме
Глава 23. Окна
Позиционирование окна
Сохранение и восстановление информации о местоположении окна
Взаимодействие окон
Модель диалогового окна
Общие диалоговые окна
Непрямоугольные окна
Прозрачные окна с содержимым необычной формы
Перемещение окон нестандартной формы
Изменение размеров окон нестандартной формы
Шаблон элемента управления для окон
Эффект Aero Glass
Программирование для панели задач Windows 7
Изменение значков и окон предварительного просмотра, отображаемых в панели задач
Резюме
Глава 24. Страницы и навигация
Страничные интерфейсы
Класс Page
Гиперссылки
Размещение страниц во фрейме
Размещение страниц внутри другой страницы
Размещение страниц в веб-браузере
Хронология страниц
Хронология навигации
Добавление специальных свойств
Служба навигации
События навигации
Управление журналом
Добавление в журнал специальных элементов
Страничные функции
Приложения ХВАР
Создание приложения ХВАР
Развертывание приложения ХВАР
Обновление приложения ХВАР
Безопасность приложения ХВАР
Приложения ХВАР с полным доверием
Комбинирование приложений ХВАР и автономных приложений
Кодирование с обеспечением различных уровней безопасности
Эмуляция диалоговых окон с помощью элемента управления Popup
Вставка ХВАР-приложения в веб-страницу
Элемент управления WebBrowser
Построение дерева DOM
Написание сценариев для веб-страницы с помощью кода .NET
Резюме
Глава 25. Меню, панели инструментов и ленты
Элементы меню
Класс ContextMenu
Разделители меню
Панели инструментов и строки состояния
Элемент управления StatusBar
Ленты
Стилизация элемента управления Ribbon
Команды
Меню приложения
Вкладки, группы и кнопки
Изменение размеров элемента управления Ribbon
Панель быстрого запуска
Резюме
Глава 26. Звук и видео
Класс SoundPlayerAction
Системные звуки
Класс MediaPlayer
Элемент MediaElement
Обработка событий
Воспроизведение аудио с помощью триггеров
Воспроизведение множества звуков
Изменение громкости, баланса, скорости и позиции воспроизведения
Синхронизация анимации с аудио
Воспроизведение видео
Видео-эффекты
Речь
Распознавание текста
Резюме
Глава 27. Трехмерная графика
Трехмерные объекты
Камера
Дополнительные сведения о трехмерной графике
Более сложные фигуры
Коллекции Model3DGroup
Снова о материалах
Отображение текстур
Интерактивность и анимация
Вращения
Полеты
Шаровой манипулятор
Проверка попадания
Двухмерные элементы на трехмерных поверхностях
Резюме
Глава 28. Документы
Потоковые документы
Форматирование элементов вывода содержимого
Создание простого потокового документа
Блочные элементы
Строковые элементы
Программное взаимодействие с элементами
Выравнивание текста
Контейнеры потоковых документов, доступные только для чтения
Страницы и колонки
Загрузка документов из файла
Печать
Редактирование потокового документа
Сохранение файла
Форматирование выделенного текста
Получение отдельных слов
Фиксированные документы
Аннотации
Включение службы аннотаций
Создание аннотаций
Просмотр аннотаций
Реагирование на изменения аннотаций
Хранение аннотаций в фиксированном документе
Настройка внешнего вида наклеек
Резюме
Глава 29. Печать
Трансформация печатного вывода
Печать элементов без их отображения
Печать документа
Манипуляции страницами в печатном выводе документа
Специальная печать
Специальная печать с разбиением на страницы
Настройки и управление печатью
Печать диапазонов страниц
Управление очередью печати
Печать через XPS
Запись в документ XPS, находящийся в памяти
Печать непосредственно на принтер через XPS
Асинхронная печать
Резюме
Глава 30. Взаимодействие с Windows Forms
Смешивание окон и форм
Добавление окон WPF в приложение Windows Forms
Отображение модальных окон и форм
Отображение немодальных окон и форм
Визуальные стили элементов управления Windows Forms
Классы Windows Forms, которые не нуждаются во взаимодействии
Создание окон со смешанным содержимым
Размещение элементов управления Windows Forms в WPF
WPF и пользовательские элементы управления Windows Forms
Размещение элементов управления WPF в форме Windows Forms
Клавиши доступа, мнемоники и фокус
Отображение свойств
Резюме
Глава 31. Многопоточность
Класс DispatcherObject
Класс BackgroundWorker
Резюме
Глава 32. Модель дополнений
Конвейер дополнения
Структура каталогов дополнений
Подготовка решения, использующего модель дополнений
Приложение, использующее дополнения
Представление дополнения
Дополнение
Адаптер дополнения
Представление хоста
Адаптер хоста
Хост
Добавление новых дополнений
Взаимодействие с хостом
Визуальные дополнения
Резюме
Глава 33. Развертывание ClickOnce
Ограничения ClickOnce
Простая публикация ClickOnce
Запуск мастера публикации
Развернутая файловая структура
Установка приложения ClickOnce
Обновление приложения ClickOnce
Дополнительные параметры ClickOnce
Обновления
Ассоциации файлов
Параметры публикации
Резюме
Предметный указатель
Текст
                    Pro
WPF
in C# 2010:
Windows Presentation
Foundation in .NET 4
Matthew MacDonald
Apress®


WPF Windows Presentation Foundation в .NET 4 с примерами на С# 2010 ДЛЯ ПРОФЕССИОНАЛОВ Мэтью Мак-Дональд Москва • Санкт-Петербург • Киев 2011
ББК 32.973.26-018.2.75 М15 УДК 681.3.07 Издательский дом "Вильяме" Зав. редакцией СИ. Тригуб Перевод с английского Я.П. Волковой, А.А. Моргунова, Н.А. Мухина Под редакцией Ю.Н. Артпеменко По общим вопросам обращайтесь в Издательский дом "Вильяме" по адресу: info@williamspublishing.com, http://www.williamspublishing.com Мак-Дональд, Мэтью. М15 WPF 4: Windows Presentation Foundation в .NET 4.0 с примерами на С# 2010 для профессионалов. : Пер. с англ. — М. : ООО "И.Д. Вильяме", 2011. — 1024 с. : ил. — Парал. тит. англ. ISBN 978-5-8459-1657-0 (рус.) ББК 32.973.26-018.2.75 Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами, будь то электронные или механические, включая фотокопирование и запись на магнитный носитель, если на это нет письменного разрешения издательства APress, Berkeley, CA. Authorized translation from the English language edition published by APress, Inc., Copyright © 2010 by Matthew MacDonald. All rights reserved. No part of this work may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording, or by any information storage or retrieval system, without the prior written permission of the copyright owner and the publisher. Trademarked names may appear in this book. Rather than use a trademark symbol with every occurrence of a trademarked name, we use the names only in an editorial fashion and to the benefit of the trademark owner, with no intention of infringement of the trademark. Russian language edition is published by Williams Publishing House according to the Agreement with R&I Enterprises International, Copyright © 2011. Научно-популярное издание Мэтью Мак-Дональд WPF 4: Windows Presentation Foundation в .NET 4.0 с примерами на С# 2010 для профессионалов Верстка Т.Н. Артпеменко Художественный редактор ВТ. Павлютпин Подписано в печать 12.01.2011. Формат 70x100/16. Гарнитура Times. Печать офсетная. Усл. печ. л. 82,56. Уч.-изд. л. 71,4. Тираж 1500 экз. Заказ № 25054. Отпечатано по технологии CtP в ОАО "Печатный двор" им. А. М. ГЪрького 197110, Санкт-Петербург, Чкаловский пр., 15. ООО "И. Д. Вильяме", 127055, г. Москва, ул. Лесная, д. 43, стр. 1 ISBN 978-5-8459-1657-0 (рус.) © Издательский дом "Вильяме", 2011 ISBN 978-1-43-027205-2 (англ.) © by Matthew MacDonald, 2010
Оглавление Введение 20 Глава 1. Введение в WPF 25 Глава 2. XAML 46 Глава 3. Компоновка 81 Глава 4. Свойства зависимости 120 Глава 5. Маршрутизируемые события 133 Глава 6. Элементы управления 168 Глава 7. Класс Application 217 Глава 8. Привязка элементов 248 Глава 9. Команды 262 Глава 10. Ресурсы 288 Глава 11. Стили и поведения 302 Глава 12. Фигуры, кисти и трансформации 324 Глава 13. Классы Geometry и Drawing 361 Глава 14. Эффекты и класс Visual 381 Глава 15. Основы анимации 402 Глава 16. Расширенная анимация 441 Глава 17. Шаблоны элементов управления 469 Глава 18. Пользовательские элементы 508 Глава 19. Привязка данных 559 Глава 20. Форматирование привязанных данных 600 Глава 21. Представления данных 641 Глава 22. Элементы управления ListView, TreeView и DataGrid 657 Глава 23. Окна 696 Глава 24. Страницы и навигация 734 Глава 25. Меню, панели инструментов и ленты 780 Глава 26. Звук и видео 805 Глава 27. Трехмерная графика 827 Глава 28. Документы 866 Глава 29. Печать 914 Глава 30. Взаимодействие с Windows Forms 942 Глава 31. Многопоточность 964 Глава 32. Модель дополнений 976 Глава 33. Развертывание ClickOnce 998 Предметный указатель 1016
Содержание Об авторе 19 О техническом рецензенте 19 Благодарности 19 Введение 20 Об этой книге 21 Обзор глав 21 Что необходимо для чтения этой книги 24 Исходный код примеров 24 От издательства 24 Глава 1. Введение в WPF 25 Эволюция графики в Windows 25 DirectX: новый графический механизм 26 Аппаратное ускорение и WPF 26 WPF: высокоуровневый API-интерфейс 28 Независимость от разрешения 31 Архитектура WPF 36 Иерархия классов 37 WPF4 40 Новые средства 41 WPF Toolkit 42 Visual Studio 2010 42 Поддержка множества целевых платформ 42 Клиентский профиль .NET 43 Визуальный конструктор Visual Studio 44 Резюме 45 Глава 2. XAML 46 Особенности XAML 47 Графический интерфейс пользователя до WPF 47 Разновидности XAML 48 Компиляция XAML 48 Основы XAML 50 Пространства имен XAML 51 Класс отделенного кода 52 Свойства и события в XAML 55 Простые свойства и конвертеры типов 56 Сложные свойства 57 Расширения разметки 59 Присоединенные свойства 60 Вложенные элементы 61 Специальные символы и пробелы 64 События 65 Полный пример автоответчика 66 Использование типов из других пространств имен 67 Загрузка и компиляция XAML 69 Только код 70 Код и не компилированный XAML 72 Код и скомпилированный XAML 74 Только XAML 75 XAML 2009 76 Автоматическая привязка событий 77 Ссылки 78 Встроенные типы 78
Содержание 7 Расширенное создание объектов 79 Резюме 80 Глава 3. Компоновка 81 Понятие компоновки в WPF 81 Философия компоновки WPF 82 Процесс компоновки 83 Контейнеры компоновки 83 Простая компоновка с помощью StackPanel 85 Свойства компоновки 87 Выравнивание 88 Поля 88 Минимальные, максимальные и явные размеры 90 Элемент Border 92 WrapPanelHDockPanel 93 WrapPanel 93 DockPanel 94 Вложение контейнеров компоновки 96 Grid 98 Тонкая настройка строк и колонок 100 Округление компоновки 102 Объединение строк и колонок 103 Разделенные окна 104 Группы с общими размерами 107 UniformGnd 110 Координатная компоновка с помощью Canvas 110 Z-порядок 111 InkCanvas 112 Примеры компоновки 114 Колонка настроек 114 Динамическое содержимое 116 Модульный пользовательский интерфейс 117 Резюме 118 Глава 4. Свойства зависимости 120 Свойства зависимости 120 Определение свойства зависимости 121 Регистрация свойства зависимости 121 Проверка свойств 128 Резюме 132 Глава 5. Маршрутизируемые события 133 Знакомство с маршрутизируемыми событиями 133 Определение, регистрация и упаковка маршрутизируемых событий 134 Совместное использование маршрутизируемых событий 135 Генерация маршрутизируемого события 135 Обработка маршрутизируемого события 135 Маршрутизация событий 137 События WPF 145 События времени существования 145 События ввода 147 Ввод с клавиатуры . 149 Ввод с использованием мыши 154 Сенсорный многопозиционный ввод 159 Резюме 167
8 Содержание Глава 6. Элементы управления 168 Класс Control 169 Кисти фона и переднего плана 169 Шрифты 171 Курсоры мыши 176 Элементы управления содержимым 178 Выравнивание содержимого 181 Метки 183 Кнопки 183 Всплывающие подсказки 187 Специализированные контейнеры 194 КлассScrollViewer 194 Элементы управления содержимым с заголовками 197 Класс GroupBox 197 Класс Tabltem 197 Класс Expander 199 Текстовые элементы управления 202 Многострочный текст 202 Выделение текста 203 Проверка правописания 204 Класс PasswordBox 206 Элементы управления списками 206 КлассListBox 207 Класс ComboBox 210 Элементы управления, основанные на диапазонах значений 211 Класс Slider 211 Класс ProgressBar 212 Элементы управления датами 213 Резюме 216 Глава 7. Класс Application 217 Жизненный цикл приложения 217 Создание объекта Application 217 Наследование специального класса приложения 218 Останов приложения 220 События класса Application 221 Задачи приложения 223 Отображение экрана заставки 223 Обработка аргументов командной строки 224 Доступ к текущему приложению 225 Взаимодействие между окнами 226 Приложение одного экземпляра 227 Ресурсы сборки 233 Добавление ресурсов 234 Извлечение ресурсов 235 Упакованные URI 237 Ресурсы в других сборках 237 Файлы содержимого 238 Локализация 239 Создание локализуемых пользовательских интерфейсов 239 Подготовка приложения для локализации 240 Процесс перевода 241 Резюме 247
Содержание 9 Глава 8. Привязка элементов 248 Связывание элементов вместе 248 Выражение привязки 249 Ошибки привязки 249 Режимы привязки 250 Привязка OneWayToSource 252 Привязка Defaut 252 Создание привязки в коде 252 Множественные привязки 253 Обновления привязок 256 Привязка к объектам, не являющимся элементами 258 Свойство Source 258 Свойство RelativeSource 259 Свойство DataContext 260 Резюме 261 Глава 9. Команды 262 Общие сведения о командах 262 Модель команд WPF 264 Интерфейс ICommand 264 КлассRoutedCommand 265 Класс RoutedUICommand 266 Библиотека команд 267 Выполнение команд 268 Источники команд 268 Привязки команд 269 Использование множества источников команд 271 Точная настройка текста команды 272 Вызов команды напрямую 273 Отключение команд 274 Элементы управления со встроенными командами 276 Расширенные команды 278 Специальные команды 278 Использование одной и той же команды в разных местах 280 Использование параметра команды 281 Отслеживание и отмена команд 282 Резюме 287 Глава 10. Ресурсы 288 Общие сведения о ресурсах 288 Коллекция ресурсов 289 Иерархия ресурсов 290 Статические и динамические ресурсы 291 Неразделяемые ресурсы 293 Доступ к ресурсам в коде 294 Ресурсы приложения 294 Ресурсы системы 295 Словари ресурсов 296 Создание словаря ресурсов 296 Использование словаря ресурсов 297 Разделение ресурсов между сборками 298 Резюме 301 Глава 11. Стили и поведения 302 Основные сведения о стилях 302
10 Содержание Создание объекта стиля 306 Установка свойств 307 Присоединение обработчиков событий 308 Множество уровней стилей 310 Автоматическое применение стилей по типу 311 Триггеры 313 Простой триггер 313 Триггер события 315 Поведения 317 Получение поддержки для поведений 317 Модель поведений 318 Создание поведения 319 Использование поведения 321 Поддержка использования поведений во время проектирования в Expression Blend 322 Резюме 323 Глава 12. Фигуры, кисти и трансформации 324 Понятие фигур 324 Классы фигур 325 Rectangle и Ellipse 327 Установка размеров и расположения фигур 328 Масштабирование фигур в Viewbox 330 Line 333 Polyline 334 Polygon 335 Наконечники и стыки линий 336 Пунктирные линии 337 Привязка к пикселям 339 Кисти 340 SolidColorBrush 341 LinearGradientBrush 341 RadialGradientBrush 344 ImageBrush 345 Мозаичная кисть ImageBrush 347 VisualBrush 350 BitmapCacheBrush 351 Трансформации 352 Трансформация фигур 354 Трансформация элементов 355 Прозрачность 356 Как сделать элемент частично прозрачным 356 Маски непрозрачности 358 Резюме 360 Глава 13. Классы Geometry и Drawing 361 Классы Path и Geometry 361 Геометрии линий, прямоугольников и эллипсов 362 Комбинирование фигур в GeometryGroup 363 Комбинирование объектов Geometry и CombinedGeometry 365 Кривые и прямые линии, представляемые с помощью PathGeometry 367 Мини-язык описания геометрии 372 Кадрирование геометрии 374 Рисунки 375 Отображение рисунка 376
Содержание 11 Экспорт рисунка 379 Резюме 380 Глава 14. Эффекты и класс Visual 381 Классы Visual 381 Рисование объектов Visual 382 Помещение визуальных объектов в оболочку элемента 384 Проверка попадания 387 Сложная проверка попадания 389 Эффекты 392 BlurEffect 393 КлассDropShadowEffeet 393 Класс ShaderEffeet 395 КлассWriteableBitmap 396 Генерация растрового изображения 397 Запись в WriteableBitmap 398 Более эффективная запись пикселей 399 Резюме 401 Глава 15. Основы анимации 402 Что собой представляет анимация WPF 403 Анимация на основе таймера 403 Анимация на основе свойств 404 Базовая анимация 405 Классы анимации 405 Анимация в коде 408 Одновременные анимации 413 Время жизни анимации 413 Класс TimeLine 414 Раскадровки 417 Раскадровка 418 Триггеры событий 418 Перекрывающиеся анимации 421 Синхронизированные анимации 422 Управление воспроизведением 423 Отслеживание хода анимации 427 Плавность анимации 428 Использование функции плавности 429 Режимы плавности 430 Классы функций плавности 431 Создание специальной функции плавности 434 Производительность анимации 436 Желательная частота кадров 436 Кэширование растровых изображений 438 Резюме 440 Глава 16. Расширенная анимация 441 Еще раз о типах анимаций 441 Анимированные трансформации 442 Анимированные кисти 446 Анимация построителей текстур 449 Анимация ключевого кадра 450 Дискретные анимации ключевого кадра 451 Плавные ключевые кадры 452 Сплайновые анимации ключевого кадра 453
12 Содержание Анимация на основе пути 454 Анимация на основе кадра 456 Раскадровки в коде 459 DiaBHoe окно 460 Пользовательский элемент управления Bomb 461 Сброс бомб 463 Перехват бомбы 465 Подсчет бомб и очистка 466 Резюме 468 Глава 17. Шаблоны элементов управления 469 Логические и визуальные деревья 470 Что собой представляют шаблоны 474 Классы Chrome 477 Анализ элементов управления 478 Создание шаблонов элементов управления 481 Простая кнопка 481 Привязки шаблона 483 Триггеры, изменяющие свойства 484 Триггеры, использующие анимацию 487 Организация ресурсов для шаблонов 489 Рефакторинг шаблона элемента управления для кнопки 490 Применение шаблонов со стилями 491 Автоматическое применение шаблонов 494 Обложки, выбранные пользователем 495 Построение более сложных шаблонов 497 Вложенные шаблоны 497 Модификация полосы прокрутки 500 Примеры шаблонов элементов управления 504 Визуальные состояния 505 Резюме 507 Глава 18. Пользовательские элементы 508 Что собой представляют пользовательские элементы в WPF 509 Построение базового пользовательского элемента управления 512 Определение свойств зависимости 513 Определение маршрутизируемых событий 516 Добавление кода разметки 517 Использование элемента управления 519 Поддержка команд 519 Пристальный взгляд на UserControl 521 Создание элемента управления, лишенного внешнего вида 523 Рефакторинг кода указателя цвета 523 Рефакторинг кода разметки указателя цвета 524 Оптимизация шаблона элемента управления 526 Стили, специфичные для темы, и стиль по умолчанию 529 Поддержка визуальных состояний 531 Начало проектирования класса Flip Panel 532 Выбор частей и состояний 534 Шаблон элемента управления, принятый по умолчанию 535 Использование FlipPanel 542 Использование другого шаблона элемента управления 542 Пользовательские панели 544 Двухшаговый процесс компоновки 545 Клон Canvas 547
Содержание 13 Улучшенная панель WrapPanel 548 Рисованные элементы 551 Метод OnRender () 552 Выполнение специального рисования 553 Элемент, выполняющий специальное рисование 554 Специальный декоратор 556 Резюме 558 Глава 19. Привязка данных 559 Привязка пользовательских объектов к базе данных 559 Построение компонента доступа к данным 560 Построение объекта данных 563 Отображение привязанного объекта 563 Обновление базы данных 566 Уведомление об изменениях 566 Привязка к коллекции объектов 568 Отображение и редактирование элементов коллекции 569 Вставка и удаление элементов коллекций 572 Привязка объектов ADO.NET 573 Привязка к выражению LINQ 574 Повышение производительности больших списков 576 Виртуализация 577 Повторное использование контейнера элементов 578 Отложенная прокрутка 579 Проверка достоверности 579 Проверка достоверности в объекте данных 580 Класс ExceptionValidationRule 581 КлассDataErrorValidationRule 582 Специальные правила проверки достоверности 583 Реакция на ошибки проверки достоверности 586 Получение списка ошибок 586 Отображение отличающегося индикатора ошибки 587 Проверка достоверности множества значений 590 Поставщики данных 593 Поставщик ObjectDataProvider 594 Поставщик XmlDataProvider 597 Резюме 598 Глава 20. Форматирование привязанных данных 600 Еще раз о привязке данных 600 Преобразование данных 602 Свойство StringFormat 602 Что собой представляют конвертеры значений 604 Форматирование строк с помощью конвертера значений 604 Создание объектов с конвертером значений 606 Применение условного форматирования 608 Оценка множества свойств 610 Списочные элементы управления 611 Стили списков 614 СтильItemContainerStyle 614 Элемент ListBoxc флажками или переключателями 616 Стиль чередующихся элементов 618 Селекторы стиля 619 Шаблоны данных 622 Отделение и повторное использование шаблонов 624
14 Содержание Более развитые шаблоны 625 Варьирование шаблонов 628 Селекторы шаблонов 629 Шаблоны и выбор 632 Изменение компоновки элемента 636 Элемент ComboBox 638 Резюме 640 Глава 21. Представления данных 641 Объект представления 641 Извлечение объекта представления 642 Навигация в представлении 642 Создание представления декларативным образом 645 Фильтрация, сортировка и группирование 647 Фильтрация коллекций 647 Фильтрация объекта DataTable 650 Сортировка 651 Группирование 652 Резюме 656 Глава 22. Элементы управления ListView, TreeView и DataGrid 657 Элемент управления L i s tVi e w 658 Создание столбцов с помощью Grid View 659 Изменение размера столбцов 660 Шаблоны ячеек 661 Создание специального представления 663 Элемент управления TreeView 671 Привязка данных к элементу управления Т г е еVi e w 672 Привязка элемента управления TreeView к объекту DataSet 674 Оперативное создание узлов 675 Элемент управления DataGrid 678 Изменение размера и порядка следования столбцов 680 Определение столбцов 682 Форматирование и стилизация столбцов 686 Форматирование строк 688 Детали строк 690 Закрепление столбцов 691 Выбор 692 Сортировка 692 Редактирование BDataGrid 692 Резюме 695 Глава 23. Окна 696 Класс Window 696 Отображение окна 699 Позиционирование окна 700 Сохранение и восстановление информации о местоположении окна 701 Взаимодействие окон 703 Владение окнами 705 Модель диалогового окна 705 Общие диалоговые окна 706 Непрямоугольные окна 708 Простое окно нестандартной формы 708 Прозрачные окна с содержимым необычной формы 711 Перемещение окон нестандартной формы 712
Содержание 15 Изменение размеров окон нестандартной формы 713 Шаблон элемента управления для окон 714 Эффект Aero Glass 718 Программирование для панели задач Windows 7 722 Применение списков часто используемых элементов 723 Изменение значков и окон предварительного просмотра, отображаемых в панели задач 728 Резюме 733 Глава 24. Страницы и навигация 734 Общие сведения о страничной навигации 735 Страничные интерфейсы 735 Простое страничное приложение с элементом NavigationWindow 736 Класс Page 737 Гиперссылки 738 Размещение страниц во фрейме 741 Размещение страниц внутри другой страницы 742 Размещение страниц в веб-браузере 743 Хронология страниц 744 Более детальное рассмотрение URI-адресов в WPF 744 Хронология навигации 745 Добавление специальных свойств 747 Служба навигации 748 Программная навигация 748 События навигации 749 Управление журналом 751 Добавление в журнал специальных элементов 753 Страничные функции 757 Приложения ХВАР 760 Требования для приложений ХВАР 761 Создание приложения ХВАР 761 Развертывание приложения ХВАР 762 Обновление приложения ХВАР 764 Безопасность приложения ХВАР 765 Приложения ХВАР с полным доверием 766 Комбинирование приложений ХВАР и автономных приложений 767 Кодирование с обеспечением различных уровней безопасности 767 Эмуляция диалоговых окон с помощью элемента управления Popup 770 Вставка ХВАР-приложения в веб-страницу 772 Элемент управления WebBrowser 773 Навигация к странице 774 Построение дерева DOM 775 Написание сценариев для веб-страницы с помощью кода .NET 777 Резюме 779 Глава 25. Меню, панели инструментов и ленты 780 Меню 780 Класс Menu 781 Элементы меню 782 Класс ContextMenu 784 Разделители меню 785 Панели инструментов и строки состояния 786 Элемент управления Tool Bar 786 Элемент управления StatusBar 790
16 Содержание Ленты 791 Добавление элемента управления Ribbon 792 Стилизация элемента управления Ribbon 793 Команды 794 Меню приложения 795 Вкладки, группы и кнопки 798 Изменение размеров элемента управления Ribbon 800 Панель быстрого запуска 802 Резюме 804 Глава 26. Звук и видео 805 Воспроизведение WAV-аудио 805 Класс SoundPlayer 806 Класс SoundPlayerAction 807 Системные звуки 808 Класс MediaPlayer 808 Элемент MediaElement 810 Программное воспроизведение аудио 810 Обработка событий 811 Воспроизведение аудио с помощью триггеров 812 Воспроизведение множества звуков 814 Изменение громкости, баланса, скорости и позиции воспроизведения 815 Синхронизация анимации с аудио 817 Воспроизведение видео 818 Видео-эффекты 819 Речь 822 Синтез речи 822 Распознавание текста 824 Резюме 826 Глава 27. Трехмерная графика 827 Основы трехмерной графики 828 Окно просмотра 828 Трехмерные объекты 829 Камера 836 Дополнительные сведения о трехмерной графике 840 Текстурирование и нормали 841 Более сложные фигуры 844 Коллекции Model3DGroup 845 Снова о материалах 847 Отображение текстур 849 Интерактивность и анимация 852 Трансформации 853 Вращения 854 Полеты 854 Шаровой манипулятор 857 Проверка попадания 858 Двухмерные элементы на трехмерных поверхностях 862 Резюме 865 Глава 28. Документы 866 Документы 866 Потоковые документы 867 Потоковые элементы 868 Форматирование элементов вывода содержимого 870
Содержание 17 Создание простого потокового документа 871 Блочные элементы 873 Строковые элементы 878 Программное взаимодействие с элементами 884 Выравнивание текста 887 Контейнеры потоковых документов, доступные только для чтения 888 Изменение масштаба 889 Страницы и колонки 890 Загрузка документов из файла 893 Печать 893 Редактирование потокового документа 894 Загрузка файла 894 Сохранение файла 896 Форматирование выделенного текста 897 Получение отдельных слов 899 Фиксированные документы 901 Аннотации 902 Классы аннотаций 902 Включение службы аннотаций 903 Создание аннотаций 905 Просмотр аннотаций 908 Реагирование на изменения аннотаций 911 Хранение аннотаций в фиксированном документе 911 Настройка внешнего вида наклеек 912 Резюме 913 Глава 29. Печать 914 Базовая печать 914 Печать элемента 915 Трансформация печатного вывода 917 Печать элементов без их отображения 919 Печать документа 920 Манипуляции страницами в печатном выводе документа 923 Специальная печать 925 Печать с помощью классов визуального уровня 926 Специальная печать с разбиением на страницы 928 Настройки и управление печатью 933 Поддержка настроек печати 933 Печать диапазонов страниц 934 Управление очередью печати 934 Печать через XPS 937 Создание документа XPS для предварительного просмотра перед печатью 938 Запись в документ XPS, находящийся в памяти 939 Печать непосредственно на принтер через XPS 939 Асинхронная печать 940 Резюме 941 Глава 30. Взаимодействие с Windows Forms 942 Оценка способности к взаимодействию 942 Средства, которые отсутствуют в WPF 943 Смешивание окон и форм 945 Добавление форм к приложению WPF 945 Добавление окон WPF в приложение Windows Forms 946 Отображение модальных окон и форм 946
18 Содержание Отображение немодальных окон и форм 947 Визуальные стили элементов управления Windows Forms 947 Классы Windows Forms, которые не нуждаются во взаимодействии 948 Создание окон со смешанным содержимым 952 Зазор между WPF и Windows Forms 952 Размещение элементов управления Windows Forms в WPF 954 WPF и пользовательские элементы управления Windows Forms 956 Размещение элементов управления WPF в форме Windows Forms 957 Клавиши доступа, мнемоники и фокус 959 Отображение свойств 961 Резюме 963 Глава 31. Многопоточность 964 Многопоточность 964 Диспетчер 965 Класс DispatcherObject 966 Класс BackgroundWorker 968 Резюме 975 Глава 32. Модель дополнений 976 Выбор между MAF и MEF 976 Конвейер дополнения 977 Как работает конвейер 978 Структура каталогов дополнений 980 Подготовка решения, использующего модель дополнений 981 Приложение, использующее дополнения 982 Контракт 983 Представление дополнения 984 Дополнение 984 Адаптер дополнения 985 Представление хоста 986 Адаптер хоста 986 Хост 987 Добавление новых дополнений 990 Взаимодействие с хостом 990 Визуальные дополнения 995 Резюме 997 Глава 33. Развертывание ClickOnce 998 Что собой представляет развертывание приложения 999 Модель установки ClickOnce 1000 Ограничения ClickOnce 1001 Простая публикация ClickOnce 1002 Настройка издателя и продукта 1003 Запуск мастера публикации 1004 Развернутая файловая структура 1008 Установка приложения ClickOnce 1009 Обновление приложения ClickOnce 1010 Дополнительные параметры ClickOnce 1011 Версия публикации 1011 Обновления 1012 Ассоциации файлов 1013 Параметры публикации 1014 Резюме 1015 Предметный указатель 1016
Об авторе Мэтью МакДональд — автор, преподаватель и обладатель звания Microsoft MVP. Он написал свыше десятка книг по программированию в .NET, включая Pro Silverlight 3 in C# (Silverlight 3 с примерами на С# для профессионалов, ИД "Вильяме", 2010 г.), Pro ASP.NET 3.5 in C# [Microsoft ASP.NET 3.5 с примерами на С# 2008 для профессионалов, 2-е изд., ИД "Вильяме", 2008 г.), а также предыдущее издание этой книги — Pro WPF in С# 2008 [WPF: Windows Presentation Foundation в .NET 3.5 с примерами на С# 2008 для профессионалов, 2-е изд., ИД "Вильяме", 2008 г.). Проживает в Торонто с женой и двумя дочерьми. 0 техническом рецензенте Фабио Клаудио Феррачати — плодовитый писатель на темы передовых технологий. Фабио внес вклад в более чем десяток книг по .NET, C#, Visual Basic и ASP.NET. Имеет звание .NET Microsoft Certified Solution Developer (MCSD) и живет в Риме. Благодарности Ни один автор не может завершить книгу без коллектива помощников. Я глубоко признателен всей команде из Apress, включая Энн Коллетт (Anne Collett), которая сопровождала это издание на протяжении всей работы, Ким Уимпсет (Kim Wimpsett) и Мэрилин Смит (Marylin Smith), которые быстро и качественно выполняли редактирование текста, а также многим другим людям, которые занимались версткой, рисованием иллюстраций и вычиткой окончательной копии. Особую признательность хочу выразить Гарри Корнеллу (Gary Cornell) за его неоценимые консультации по проекту. Фабио Клаудио Феррачати и Кристоф Насарре (Christophe Nasarre) заслужили моей искренней благодарности за проницательные и поучительные комментарии. Я также благодарен легиону преданных блогеров из разных команд WPF, которые никогда не забывали пролить свет на наиболее темные места WPF. Настоятельно рекомендую всем, кто хочет узнать больше о будущем WPF, следить за их записями. Наконец, я бы никогда не написал ни одной книги без поддержки жены и следующих замечательных людей: Нора, Разя, Пол и Хамид. Спасибо вам всем!
Введение Платформа .NET принесла с собой небольшую лавину новых технологий. Появился совершенно новый способ написания веб-приложений (ASP.NET), совершенно новый способ подключения к базам данных (ADO.NET), новые безопасные к типам языки (С# и VB.NEH4) и управляемая исполняющая среда (CLR). Не последнее место занимала и технология Windows Forms — библиотека классов для построения Windows- приложений. Хотя Windows Forms — зрелый и полнофункциональньгй набор инструментов, он был тесно привязан к некоторым частям внутреннего устройства Windows, которые не слишком изменились за последние 10 лет. Что более существенно, при создании визуального представления стандартных пользовательских интерфейсных элементов, таких как кнопки, текстовые поля, флажки и т.п., Windows Forms полагается на Windows API. В результате его ингредиенты мало поддаются настройке и изменениям. Например, чтобы построить стилизованную блестящую кнопку, придется создать специальный элемент управления и нарисовать каждый аспект этой новой кнопки (во всех разных состояниях), используя низкоуровневую модель рисования. Хуже того, обычные окна разрезаются на отдельные области, и каждому элементу управления отводится собственная такая область. В результате не существует такого хорошего способа рисования в одном элементе управления (например, эффекта свечения под кнопки), чтобы он распространялся на область, принадлежащую другому элементу. И не стоит даже думать о создании анимационных эффектов вроде вращающегося текста, мерцающих кнопок, сворачивающихся окон или активных предварительных просмотров, потому что каждая-деталь должна быть нарисована вручную. В Windows Presentation Foundation (WPF) эта ситуация изменилась за счет ввода модели с совершенно другим устройством. Хотя платформа WPF включает знакомые стандартные элементы управления, она рисует каждый текст, контур и фон самостоятельно. В результате WPF может предоставить намного более мощные средства, которые позволяют изменить визуализацию любой части экранного содержимого. С помощью этих средств можно изменить стиль обычных элементов управления, таких как кнопки, часто даже без написания кода. Кроме того, можно применять трансформации объектов для вращения, растяжения, масштабирования и сдвига любой части пользовательского интерфейса, и даже использовать встроенную систему анимации WPF, чтобы делать все это прямо на глазах пользователя. И поскольку механизм WPF визуализирует содержимое окна как часть одной операции, он может обрабатывать неограниченное количество слоев перекрывающихся элементов, даже имеющих нерегулярную форму и частичную прозрачность. В основе WPF лежит мощная инфраструктура, основанная на DirectX — API- интерфейсе графики с аппаратным ускорением, который обычно используется в современных компьютерных играх. Это означает возможность применения развитых графических эффектов, не платя за это производительностью, как это было в Windows Forms. Фактически даже становятся доступными такие расширенные средства, как поддержка видеофайлов и трехмерное содержимое. Используя эти средства (при наличии хорошего инструмента графического дизайна), можно создавать бросающиеся в глаза пользовательские интерфейсы и визуальные эффекты, которые были просто невозможны в Windows Forms.
Введение 21 Хотя новейшие средства видео, анимации и 3-D часто привлекают максимум внимания в WPF, важно отметить, что WPF можно применять для построения обычных Windows-приложений со стандартными элементами управления и привычным внешним видом. Фактически использовать стандартные элементы управления в WPF так же легко, как и в Windows Forms. Более того, WPF расширяет средства, адресованные именно бизнес-разработчикам, включая значительно усовершенствованную модель привязки данных, набор классов для печати содержимого и управления очередями печати, а также средства работы с документами для отображения огромных объемов форматированного текста. Доступна даже модель для построения приложений на основе страниц, которые гладко работают в Internet Explorer и могут запускаться с веб-сайта — и все это без привычных предупреждений о безопасности или надоедливых приглашений к установке. Вообще говоря, WPF комбинирует лучшее из мира Windows-разработки с новейшими технологиями для построения современных, графически развитых пользовательских интерфейсов. Хотя приложения Windows Forms будут еще жить долгие годы, разработчикам, приступающим к новым проектам Windows, сначала стоит обратить внимание HaWPF. Об этой книге Эта книга представляет собой углубленное руководство по WPF для профессиональных разработчиков, знакомых с платформой .NET, языком С# и средой разработки Visual Studio. Опыт работы с предыдущими версиями WPF не обязателен, хотя новые средства в книге специально выделены во врезках "Что нового?" в начале каждой главы. Книга предлагает полное описание каждого из основных средств WPF — от XAML (языка разметки, используемого для определения пользовательских интерфейсов WPF) до трехмерного рисования и анимации. По ходу чтения вы ознакомитесь с кодом, который включает работу с другими средствами .NET Framework, такими как классы ADO.NET, которые служат для запросов к базе данных. Эти средства здесь не рассматриваются. За дополнительной информацией о средствах .NET, которые не являются специфичными для WPF, обращайтесь к соответствующим книгам. Обзор глав Эта книга включает в себя 33 главы. Если вы только начинаете знакомство с WPF, лучше читайте их по порядку, поскольку более поздние главы опираются на приемы, продемонстрированные в ранних. Ниже приводятся краткие описания всех глав. Глава 1. Введение в WPF. Описана архитектура WPF, внутренние механизмы DirectX, а также новая, независимая от устройства система измерения, которая автоматически изменяет размеры пользовательских интерфейсов. Глава 2. XAML. Рассматривается стандарт XAML, который используется для определения пользовательских интерфейсов. Будет показано, зачем он был создан и как работает, а также, как создавать базовые окна WPF с помощью различных подходов к кодированию. Глава 3. Компоновка. Дается углубленное представление панелей компоновки, которые позволяют организовать элементы в окне WPF Будут рассматриваться различные стратегии компоновки и строится некоторые распространенные типы окон. Глава 4. Свойства зависимости. Описано использование в WPF свойств зависимости для обеспечения поддержки таких ключевых средств, как привязка данных и анимация.
22 Введение Глава 5. Маршрутизируемые события. Рассматривается использование в WPF маршрутизации событий с пузырьковым распространением и туннелированием через элементы пользовательского интерфейса. В главе также содержится описание базового набора событий мыши, клавиатуры и сенсорных панелей, поддерживаемых всеми элементами WPF. Глава 6. Элементы управления. Описаны элементы управления, знакомые каждому разработчику Windows, такие как кнопки, текстовые поля и метки, и их воплощение в WPF. Глава 7. Класс Application. Рассматривается модель приложений WPF. Будет показано, как создавать приложения одного экземпляра и приложения WPF, основанные на документах. Глава 8. Привязка элементов. Объясняется привязка данных в WPF Будет показано, как привязать объекты любого типа к пользовательскому интерфейсу. Глава 9. Команды. Описана модель команд WPF, которая позволяет связывать несколько элементов управления с одинаковым логическим действием. Глава 10. Ресурсы. Показано, как с помощью ресурсов встраивать двоичные файлы в сборку и многократно использовать важные объекты по всему пользовательскому интерфейсу. Глава 11. Стили и поведения. Рассматривается система стилей WPF, которая позволяет применять набор общих значений свойств к целой группе элементов управления. Глава 12. Фигуры, кисти и трансформации. Описана модель двухмерного рисования в WPF. Будет показано, как создавать фигуры, изменять элементы с помощью трансформаций и получать экзотические эффекты с помощью градиентов, укладки плиткой и изображениями. Глава 13. Классы Geometry и Drawing. Более глубоко рассматривается двухмерное рисование. Будет показано, как создавать сложные пути, включающие дуги и кривые, а также эффективно использовать сложную графику. Глава 14. Эффекты и класс Visual. Рассматривается программирование низкоуровневой графики. Будет показано, как применять эффекты в стиле Photoshop с помощью построителей текстур, вручную создавать растровые изображения, а также использовать визуальный уровень WPF для оптимизации рисования. Глава 15. Основы анимации. Описана платформа анимации WPF, которая позволяет интегрировать динамические эффекты в приложение, используя прямолинейную декларативную разметку. Глава 16. Расширенная анимация. Рассматриваются более сложные приемы анимации, такие как анимация ключевого кадра, анимация, основанная на пути, и анимация на основе кадров. Также предлагается детальный пример, демонстрирующий создание и управление динамической анимацией в коде. Глава 17. Шаблоны элементов управления. Показано, как придать совершенно новый вид (и новое поведение) любому элементу управления WPF, подключая специализированный шаблон. Также описано, как с помощью шаблонов создавать приложения со сменными обложками. Глава 18. Пользовательские элементы. Посвящена расширению существующих элементов управления WPF и созданию собственных. Будет предложено несколько примеров, включая основанный на шаблоне селектор цвета, переворачиваемую па-
Введение 23 нель, специальный контейнер компоновки и декоратор, который выполняет специальное рисование. Глава 19. Привязка данных. Рассматривается, как извлекать информацию из базы данных, вставлять в специальные объекты данных и привязывать эти объекты к элементам управления WPF. Также описаны приемы повышения производительности больших связанных с данными списков посредством виртуализации, а также перехват ошибок редактирования за счет проверки достоверности. Глава 20. Форматирование привязанных данных. Описаны некоторые трюки для превращения неформатированных данных в развитое экранное представление, включающее изображения, элементы управления и эффекты выбора. Глава 21. Представления данных. Объясняется использование представления в окне, привязанном к данным, для навигации по списку элементов данных и применения фильтрации, сортировки и группировки. Глава 22. Элементы управления ListView, TreeView и DataGrid. Дается экскурс по многофункциональным элементам управления WPF, включая ListView, TreeView и DataGrid. Глава 23. Окна. Рассматривается работа окон в WPF. Будет показано, как создавать окна неправильной формы и использовать "стеклянные" эффекты Windows Vista. Кроме того, будет реализовано большинства средств Windows 7 за счет настройки списков часто используемых элементов в панели задач, миниатюр и налагаемых значков. Глава 24. Страницы и навигация. Описано построение страниц в WPF и отслеживание хронологии навигации. Также будет показано, как строить основанные на браузере приложения WPF, которые могут быть запущены с веб-сайта. Глава 25. Меню, панели инструментов и ленты. Посвящена командно-ориентированным элементам управления, таким как меню и панели инструментов. Также демонстрируется более современный интерфейс на основе свободного загружаемого элемента управления Ribbon. Глава 26. Звук и видео. Описана поддержка мультимедиа в WPF. Будет показано, как управлять воспроизведением звука и видео, и как реализовать синхронизированные анимации и живые эффекты. Глава 27. Трехмерная графика. Рассматривается поддержка рисования трехмерных фигур в WPF. Будет показано, как создавать, трансформировать и анимировать трехмерные объекты, а также, как помещать интерактивные двухмерные элементы управления на трехмерные поверхности. Глава 28. Документы. Описана поддержка форматированных документов в WPF. Будет показано, как использовать потоковые документы для представления больших объемов текста в наиболее читабельном виде и фиксированные документы для отображения страниц, готовых к печати. Кроме того, рассматривается применение элемента управления RichTextBox для редактирования документа. Глава 29. Печать. Представлена модель печати WPF, которая позволяет выводить текст и фигуры в печатный документ. Будет также показано, как управлять настройками страниц и очередями печати. Глава 30. Взаимодействие с Windows Forms. Описаны способы комбинирования содержимого WPF и Windows Forms в пределах одного приложения и даже одного окна.
24 Введение Глава 31. Многопоточность. Рассматривается создание отзывчивых приложений WPF, которые выполняют длительные задачи в фоновом режиме. Глава 32. Модель дополнений. Показано, как создавать расширяемое приложение, которое может динамически обнаруживать и подгружать отдельные компоненты. Глава 33. Развертывание ClickOnce. Рассматриваются вопросы, связанны с развертыванием приложений WPF с помощью технологии ClickOnce. Что необходимо для чтения этой книги Для того чтобы запустить приложение WPF, на компьютере должна быть установлена система Windows 7, Windows Vista или Windows XP с Service Pack 2. Также понадобится .NET Framework 4. Чтобы создавать приложения WPF (и открывать примеры проектов, включенные в эту книгу), необходима среда Visual Studio 2010, которая включает .NET Framework 4. Существует еще одна возможность. Вместо использования любой версии Visual Studio строить и тестировать приложения WPF можно с помощью инструмента графического дизайна Expression Blend. В целом, Expression Blend предназначен для дизайнеров графики, которые большую часть времени занимаются созданием внешнего вида, в то время как Visual Studio — идеальная среда для работы программистов, пишущих код приложений. В книге предполагается применение Visual Studio. За дополнительными сведениями об Expression Blend следует обращаться к одной из специализированных книг. (Кстати, для создания приложений с помощью WPF 4 понадобится Expression Blend 4.) Исходный код примеров Исходный код всех рассматриваемых в настоящей книге примеров доступен на вебсайте издательства по адресу http://www.williamspublishing.com/. От издательства Вы, читатель этой книги, и есть главный ее критик и комментатор. Мы ценим ваше мнение и хотим знать, что было сделано нами правильно, что можно было сделать лучше и что еще вы хотели бы увидеть изданным нами. Нам интересно услышать и любые другие замечания, которые вам хотелось бы высказать в наш адрес. Мы ждем ваших комментариев и надеемся на них. Вы можете прислать нам бумажное или электронное письмо, либо просто посетить наш Web-сервер и оставить свои замечания там. Одним словом, любым удобным для вас способом дайте нам знать, нравится или нет вам эта книга, а также выскажите свое мнение о том, как сделать наши книги более интересными для вас. Посылая письмо или сообщение, не забудьте указать название книги и ее авторов, а также ваш обратный адрес. Мы внимательно ознакомимся с вашим мнением и обязательно учтем его при отборе и подготовке к изданию последующих книг. Наши координаты: E-mail: info@williamspublishing.com WWW: http://www.williamspublishing.com Информация для писем из: России: 127055, г. Москва, ул. Лесная, д. 43, стр. 1 Украины: 03150, Киев, а/я 152
ГЛАВА 1 Введение в WPF Windows Presentation Foundation (WPF) — это графическая система отображения для Windows. Платформа WPF спроектирована для .NET под влиянием таких современных технологий отображения, как HTML и Flash, и использует аппаратное ускорение. Она также является наиболее радикальным изменением в пользовательском интерфейсе Windows со времен Windows 95. В этой главе вы ознакомитесь с архитектурой WPF. Вы узнаете, как она справляется с различными разрешениями экрана, и получите общее представление о ее сборках и классах. Также будут рассмотрены новые средства, добавленные к WPF 4. Что нового? Если вы — опытный разработчик WPF, то можете сразу перейти к разделу "WPF 4" настоящей главы, в котором подытожены изменения, произошедшие в последнем выпуске WPF Эволюция графики в Windows Трудно оценить важность WPF, не принимая во внимание тот факт, что разработчики Windows-приложений в течение более 15 лет пользовались, по сути, одной и той же технологией отображения. Стандартное Windows-приложение при создании пользовательского интерфейса полагается на две основополагающие части операционной системы Windows: • User32 обеспечивает знакомый внешний вид и поведение таких элементов, как окна, кнопки, текстовые поля и т.п.; • GDI/GDI+ предоставляет поддержку рисования фигур, текста и изображений за счет дополнительного усложнения (и часто неважной производительности). С годами обе технологии совершенствовались, и API-интерфейсы, используемые разработчиками для взаимодействия с ними, значительно менялись. Но как бы ни разрабатывалось приложение — с помощью .NET и Windows Forms, (в прошлом) Visual Basic 6 или кода C++ на основе MFC — "за кулисами" работают одни и те же части операционной системы Windows. Новые платформы просто предоставляют улучшенные оболочки для взаимодействия с User32 и GDI/GDI+. Они могут быть более эффективными, менее сложными, могут включать некоторые заранее подготовленные средства, чтобы не приходилось создавать их самостоятельно, однако они не могут преодолеть фундаментальные ограничения системных компонентов, разработанных более 10 лет назад. На заметку! Базовое разделение ответственности между User32 и GDI/GDI+ было заложено свыше 15 лет назад в Windows 3.0. Конечно, часть User32 в те времена была просто User, поскольку тогда программное обеспечение еще не вошло в 32-разрядный мир.
26 Глава 1. Введение в WPF DirectX: новый графический механизм В Microsoft разработали один обходной путь для преодоления ограничений, присущих библиотекам User32 и GDI/GDI+. Этим путем является DirectX. Он начинался как "топорный", полный ошибок инструментальный набор для создания игр на платформе Windows. Главной его целью была скорость, и потому Microsoft тесно сотрудничала с производителями видеокарт, чтобы обеспечить для DirectX аппаратную поддержку, необходимую для отображения сложных текстур, специальных эффектов вроде частичной прозрачности и трехмерной графики. За годы, прошедшие с момента появления (вскоре после выхода Windows 95), механизм DirectX обрел зрелость. Теперь это неотъемлемая часть Windows, которая включает поддержку всех современных видеокарт. Однако API-интерфейс DirectX по-прежнему несет в себе наследие своих корней как средства для разработки игр. Из-за присущей DirectX сложности он почти никогда не использовался в традиционных Windows- приложениях (в частности, в бизнес-приложениях). Технология WPF в корне меняет ситуацию. Лежащая в основе WPF графическая технология — это не GDI/GDI+. Теперь это DirectX. Примечательно, что приложения WPF используют DirectX независимо от создаваемого типа пользовательского интерфейса. Это значит, что создается ли сложная трехмерная графика (DirectX's forte), или просто рисуются кнопки и простой текст — вся работа по рисованию проходит через конвейер DirectX. В результате даже самые заурядные бизнес-приложения могут использовать богатые эффекты вроде прозрачности и сглаживания. Также получается выигрыш от аппаратного ускорения, и это означает, что DirectX передает как можно больше работы узлу обработки графики (graphics processing unit — GPU), который представляет собой отдельный процессор на видеокарте. На заметку! Технология DirectX более эффективна, поскольку оперирует высокоуровневыми конструкциями вроде текстур и градиентов, которые могут отображаться непосредственно видеокартой. Компонент GDI/GDH- на это не способен, поэтому ему приходится преобразовывать их в инструкции рисования пикселей, и потому отображение проходит намного медленнее даже на современных видеокартах. Один компонент, который остается на сцене (в ограниченной степени) — это User32. Это объясняется тем, что WPF по-прежнему полагается на User32 в отношении таких служб, как обработка и маршрутизация ввода, а также определение того, какое приложение какой частью экрана владеет. Однако все рисование производится через DirectX. На заметку! Это наиболее существенное изменение в WPR Технология WPF — это не оболочка для GDI/GDI+. На самом деле это его замена — отдельный уровень, работающий через DirectX. Аппаратное ускорение и WPF Возможно, вам известно, что видеокарты различаются между собой в плане поддержки специализированных средств визуализации и оптимизации. К счастью, проблемой это не является, поскольку WPF обладает способностью выполнять всю работу с использованием программных вычислений вместо того, чтобы полагаться на встроенную поддержку видеокарты. На заметку! В отношении программной поддержки WPF существует одно исключение. Из-за слабой поддержки драйверов WPF выполняет сглаживание трехмерной графики только в случае, если приложение запущено под управлением Windows Vista или Windows 7 (и есть встроенный драйвер WDDM для установленной видеокарты).
Глава 1. Введение в WPF 27 Это значит, что при рисовании трехмерных фигур на компьютере с Windows XP вместо гладких линий будут получены ступенчатые ломаные. Однако для двумерной графики сглаживание обеспечивается всегда, независимо от операционной системы и поддержки драйверов. Наличие мощной видеокарты не дает абсолютной гарантии, что вы получите максимальную, с аппаратной поддержкой производительность на WPF. Программное обеспечение также играет важную роль. Например, WPF не может обеспечить аппаратного ускорения на видеокартах, если используются устаревшие драйверы. (Для устаревших видеокарт, такие драйверы, скорее всего, будут единственно доступными.) Технология WPF также обеспечивает более высокую производительность в средах операционных систем Windows Vista и Windows 7, где она может пользоваться преимуществами новой модели дисплейных драйверов Windows (Windows Display Driver Model — WDDM). Модель WDDM предлагает несколько важных усовершенствований по сравнению с Windows XP Display Driver Model (XPDM). Что более важно, WDDM позволяет запланировать несколько операций GPU одновременно и отображать страницы памяти видеокарты на обычную системную память, если вся память видеокарты израсходована. Запомните в качестве главного эмпирического правила: WPF предоставляет некоторого рода аппаратное ускорение всем драйверам WDDM и драйверам XPDM, созданным после ноября 2004 г., когда Microsoft издала новые руководства по разработке драйверов. Разумеется, уровень поддержки отличается. Когда инфраструктура WPF запускается в первый раз, она оценивает видеокарту и назначает ей рейтинг от 0 до 2, как описано во врезке "Уровни WPF". Среди обещаний, связанных с WPF, было и то, что вам не нужно беспокоиться о деталях и сложностях, связанных со специфическим аппаратным обеспечением. Технология WPF достаточно интеллектуальна, чтобы по возможности использовать аппаратную оптимизацию, но в случае неудачи все будет обработано программно. Поэтому если вы запустите WPF-приложение на компьютере с унаследованной видеокартой, интерфейс будет выглядеть так, как он был разработан. Конечно, программные альтернативы могут оказаться значительно медленнее, так что вы столкнетесь с тем, что компьютеры со старыми видеокартами не очень хорошо отрабатывают расширенные приложения WPF — особенно те, что включают сложную анимацию или другие сложные графические эффекты. На практике может быть принято решение упростить некоторые сложные эффекты в пользовательском интерфейсе, в зависимости от уровня аппаратной поддержки, доступной клиенту (определяется свойством RenderCapability.Tier). На заметку! Целью WPF является взвалить на видеокарту как можно больше работы, чтобы сложные графические процедуры ограничивались возможностями визуализации (узлом обработки графики), а не вычислительной мощностью процессора (центральным процессором компьютера). При таком подходе центральный процессор высвобождается для другой работы, видеокарта используется максимально эффективно и появляется возможность пользоваться преимуществами новых видеокарт по мере их появления. Уровни WPF Видеокарты значительно различаются между собой. Когда WPF оценивает видеокарту, то учитывает множество факторов, включая объем памяти видеокарты, поддержку построителей текстур (встроенные процедуры вычисления пиксельных эффектов наподобие прозрачности), вершинных построителей текстур (встроенные процедуры вычисления значений вершин треугольника, которые применяются при текстурировании трехмерных объектов). На основе всех этих деталей определяется значение уровня визуализации WPF.
28 Глава 1. Введение в WPF WPF распознает три уровня визуализации. 1. Уровень визуализации 0. Видеокарта не предоставляет никакого аппаратного ускорения. Это соответствует версии DirectX ниже 7.0. 2. Уровень визуализации 1. Видеокарта обеспечивает частичное аппаратное ускорение. Это соответствует версии DirectX выше 7.0, но ниже 9.0. 3. Уровень визуализации 2. Все средства, которые могут быть ускорены аппаратно, будут ускорены. Это отвечает версии DirectX 9.0 и выше. В некоторых ситуациях требуется программно проверить текущий уровень визуализации, чтобы выборочно отключить некоторые сложные графические средства на менее мощных картах. Для этого используется статическое свойство Tier класса System.Windows.Media. RenderCapability. Но здесь должен быть предпринят один трюк. Чтобы извлечь значение уровня из свойства Tier, необходимо выполнить сдвиг на 16 бит, как показано ниже: int renderingTier = (RenderCapability.Tier » 16); if (renderingTier == 0) {...} else if (renderingTier == 1) {...} Такое проектное решение допускает расширяемость. В будущих версиях WPF другие биты свойства Tier могут быть использованы для сохранения информации о поддержке других свойств, создавая в результате подуровни. За дополнительной информацией об аппаратно ускоряемых средствах WPF для уровней 1 и 2, а также за списками видеокарт соответствующих уровней обращайтесь по адресу http://msdn. microsoft.com/ru-ru/library/ms742196(v=VS. 100) .aspx. WPF: высокоуровневый API-интерфейс Даже если бы единственным достоинством WPF было аппаратное ускорение через DirectX, это уже стало бы значительным усовершенствованием, хоть и не революционным. Однако WPF на самом деле включает целый набор высокоуровневых служб, ориентированных на прикладных программистов. Ниже приведен список некоторых наиболее существенных изменений, которые привнесла с собой технология WPF в мир программирования Windows-приложений. • Веб-подобная модель компоновки. Вместо того чтобы фиксировать элементы управления на месте с определенными координатами, WPF поддерживает гибкий поток, размещающий элементы управления на основе их содержимого. В результате получается пользовательский интерфейс, который может быть адаптирован для отображения высоко динамичного содержимого или к разным языкам. • Богатая модель рисования. Вместо рисования пикселей в WPF вы имеете дело с примитивами — базовыми фигурами, блоками текста и прочими графическими ингредиентами. Кроме того, доступны такие новые средства, как действительно прозрачные элементы управления, возможность укладывания друг на друга множества уровней с разной степенью прозрачности, а также встроенная поддержка трехмерной графики. • Развитая текстовая модель. После многих лет нестандартной обработки текстов WPF наконец-то предоставляет Windows-приложениям возможность отображения расширенного стилизованного текста в любом месте пользовательского интерфейса. И если нужно отображать значительные объемы текста, для повышения читабельности можно воспользоваться развитыми средствами отображения документов, такими как переносы, разбиение на колонки и выравнивание.
Глава 1. Введение в WPF 29 • Анимация как первоклассная программная концепция. В WPF нет необходимости использовать таймер для того, чтобы заставить форму перерисовать себя. Вместо этого доступна анимация — неотъемлемая часть платформы. Анимация определяется декларативными дескрипторами, и WPF запускает ее в действие автоматически. • Поддержка аудио и видео. Прежние инструментальные наборы для построения пользовательских интерфейсов, такие как Windows Forms, были весьма ограничены в работе с мультимедиа. Однако WPF включает поддержку воспроизведения любого аудио- или видеофайла, поддерживаемого проигрывателем Windows Media, позволяя воспроизводить более одного медиафайла одновременно. Что еще больше впечатляет — WPF предоставляет в ваше распоряжение инструменты для интеграции видеосодержимого в остальную часть пользовательского интерфейса, позволяя выполнять такие экзотические трюки, как размещение видеоокна на поверхности вращающегося трехмерного куба. • Стили и шаблоны. Стили позволяют стандартизировать форматирование и многократно использовать его по всему приложению. Шаблоны дают возможность изменить способ отображения элементов, даже таких основополагающих, как кнопки. Построение интерфейса с обложками еще никогда не было таким простым. • Команды. Большинству пользователей известно, что не имеет значения, откуда они инициируют команду открытия (Open) — через меню или панель инструментов; конечный результат один и тот же. Теперь эта абстракция доступна коду — можно определять команды приложения в одном месте и привязывать их к множеству элементов управления. • Декларативный пользовательский интерфейс. Хотя можно конструировать окно WPF в коде, в Visual Studio используется другой подход. Содержимое каждого окна сериализуется в виде XML-дескрипторов в документе XAML. Преимущество состоит в том, что пользовательский интерфейс полностью отделяется от кода, и дизайнеры графики могут использовать профессиональные инструменты для редактирования файлы XAML, улучшая внешний вид всего приложения. (XAML — это сокращение от Extensible Application Markup Language (расширяемый язык разметки приложений), который описан в главе 2.) • Приложения на основе страниц. Используя WPF, можно строить браузер-подобные приложения, которые позволяют перемещаться по коллекции страниц, оснащенной кнопками навигации "вперед" и "назад". WPF автоматически обрабатывает все сложные детали, такие как хронология посещения страниц. Проект можно даже развернуть в виде браузерного приложения, которое выполняется внутри Internet Explorer. Технология Windows Forms продолжает существовать WPF — это платформа для будущего разработки пользовательских интерфейсов Windows-приложений. Однако она не заменит полностью Windows Forms. Во многих отношениях Windows Forms представляет собой кульминацию предшествующего поколения технологий отображения, построенных на основе GDI/GDI+ и User32. Так какую же платформу следует использовать при разработке нового Windows- приложения? Если вы начинаете с нуля, идеальным выбором будет WPF, поскольку она предлагает лучшие возможности для будущих расширений и лучшую жизнеспособность. Аналогично, если нужно одно из средств, которые в WPF доступны, a Windows Forms — нет, например, трехмерное рисование или страничная организация приложений, то имеет смысл перейти на новую платформу. С другой стороны, если вы сделали существенные вложения в бизнес-приложение на основе Windows Forms, то не стоит
30 Глава 1. Введение в WPF перекодировать его на WPF. В ближайшие годы поддержка платформы Windows Forms будет продолжаться. Возможно, лучшая часть истории состоит в том, что в Microsoft предприняли значительные усилия для построения уровня взаимодействия между WPF и Windows Forms (он играет роль, аналогичную уровню взаимодействия, который позволяет приложениям .NET продолжать пользоваться унаследованными компонентами СОМ). В главе 30 вы узнаете о том, как использовать эту поддержку элементов управления Windows Forms в приложениях WPF и наоборот WPF предлагает аналогичную надежную поддержку интеграции с более старыми приложениями в стиле Win32. DirectX также продолжает существовать Существует одна область, для которой WPF не слишком хорошо подходит — создание приложений с требованиями к графике реального времени, таких как эмуляторы сложных физических процессов или современные интерактивные игры. Поскольку для такого рода приложений нужна максимально возможная видеопроизводительность, необходимо программировать на более низком уровне и использовать DirectX напрямую. Библиотеки управляемого кода .NET для программирования DirectX доступны для загрузки на сайте http://msdn.microsoft.com/directx. На заметку! Начиная с WPF 3.5 SP1, в Microsoft начали разрушать некоторые границы между DirectX и WPF. Теперь можно создать содержимое DirectX и поместить его внутри приложения WPF. Фактически, можно даже создать на его основе кисть и использовать ее для рисования элемента управления WPF, или же сделать ее текстурой и отобразить на трехмерную поверхность WPF Тема интеграции WPF и DirectX выходит за рамки настоящей книги, поэтому обращайтесь за этим в документацию MSDN, начиная с http://msdn.microsoft.com/ru-ru/ library/system.windows.interop.d3dimage(v=VS.100) .aspx. Silverlight Как и .NET Framework в целом, WPF представляет собой технологию, ориентированную на Windows. Это значит, что приложения WPF могут использоваться только на компьютерах, работающих под управлением операционной системы Windows. Приложения WPF, основанные на браузерах, ограничены аналогичным образом — они работают только на компьютерах Windows, хотя поддерживают браузеры и Internet Explorer, и Firefox. Эти ограничения не изменятся: в конце концов, отчасти целью Microsoft в отношении WPF является использование широких возможностей компьютеров Windows и сохранение инвестиций в такие технологии, как DirectX. Однако технология Silverlight спроектирована как подмножество платформы WPF, работает в любом современном браузере (Firefox, Google Chrome и Safari) за счет использования подключаемого модуля, и открыта для других операционных систем, таких как Linux и Mac OS. Этот амбициозный проект вызвал значительный интерес среди разработчиков. Во многих отношениях технология Silverlight основана на WPF и включает в себя многие соглашения WPF (наподобие разметки XAML, которая рассматривается в следующей главе). Тем не менее, Silverlight не охватывает ряд областей, среди которых трехмерная графика и отображение форматированных документов. В будущих выпусках Silverlight могут появиться некоторые новые средства, но наиболее сложные из них — вряд ли. Конечной целью Silverlight является предоставление мощного ориентированного на разработчика конкурента Adobe Flash. Однако Flash обладает ключевым преимуществом — он используется в веб-приложениях повсеместно, и подключаемые модули Flash установлены почти везде. Чтобы заставить разработчиков перейти на новую, менее устоявшуюся технологию, Microsoft придется снабдить Silverlight средствами следующего
Глава 1. Введение в WPF 31 поколения, обеспечить основательную совместимость и непревзойденную проектную поддержку. На заметку! Silverlight имеет две потенциальные аудитории: веб-разработчики, которые хотят создавать более интерактивные приложения, и разработчики Windows, которые хотят расширить свои приложения. Более подробно технология Silverlight описана в книге Silverlight 3 с примерами на С# для профессионалов (ИД "Вильяме", 2010 г.). Можно также посетить веб-сайт http://silverlight.net. Независимость от разрешения Традиционные Windows-приложения связаны определенными предположениями относительно разрешения экрана. Обычно разработчики рассчитывают на стандартное разрешение монитора (вроде 1024x768 пикселей) и проектируют свои окна с учетом этого, стараясь обеспечить разумное поведение при изменении размеров в большую и меньшую сторону. Проблема в том, что пользовательский интерфейс в традиционных Windows- приложениях не является масштабируемым. В результате, если вы используете монитор с высоким разрешением, который располагает пиксели более плотно, окно приложения становится меньше и читать текст в нем труднее. Эта проблема особенно актуальна для новых мониторов, которые имеют высокую плотность пикселей и соответственно работают с более высоким разрешением. Например, легче встретить мониторы (особенно на портативных компьютерах), которые имеют плотность пикселей в 120 dpi или 144 dpi (точек на дюйм), чем более традиционные 96 dpi. При их встроенном разрешении эти мониторы располагают пиксели более плотно, создавая напрягающие глаз мелкие элементы управления и текст. В идеале приложения должны использовать более высокую плотность пикселей, чтобы отобразить больше деталей. Например, монитор с высоким разрешением может отображать одинакового размера значки панели инструментов, но использовать дополнительные пиксели для отображения мелкой графики. Подобным образом можно сохранить некоторую базовую компоновку, но обеспечить более высокую четкость деталей. По разным причинам такое решение было невозможно в прошлом. Хотя можно изменять размер графического содержимого, нарисованного в GDI/GDI+, компонент User32 (который генерирует визуальное представление распространенных элементов управления) не поддерживает реального масштабирования. WPF не страдает от этой проблемы, потому что самостоятельно визуализирует все элементы пользовательского интерфейса — от простых фигур до таких распространенных элементов управления, как кнопки. В результате если вы создаете кнопку шириной в 1 дюйм на обычном мониторе, она останется шириной в 1 дюйм и на мониторе с высоким разрешением. WPF просто визуализирует ее более детализировано, с большим количеством пикселей. Так выглядит картина в целом, но нужно уточнить еще несколько деталей. Самое важное, что следует осознать — WPF базирует свое масштабирование на системной установке DPI, а не на DPI физического дисплейного устройства. Это совершенно логично — в конце концов, если вы отображаете приложение на 100-дюймовом проекторе, то, скорее всего, отойдете подальше на несколько шагов и будете ожидать увидеть огромную версию окон. Конечно, не желательно, чтобы WPF масштабировал приложение, уменьшая его до "нормального" размера. Аналогично, если вы используете портативный компьютер с дисплеем высокого разрешения, то хотите увидеть несколько уменьшенные окна; это цена, которую приходится платить за то, чтобы уместить всю информацию на маленьком экране. Более того, у разных пользователей разные предпочтения на
32 Глава 1. Введение в WPF этот счет. Некоторым нужны расширенные подробности, в то время как другие хотят увидеть больше содержимого. Так каким же образом WPF определяет, насколько большим должно быть окно приложения? Краткий ответ состоит в том, что при вычислении размеров WPF использует системную установку DPI. Но чтобы понять, как это в действительности работает, необходимо более детально ознакомиться с системой измерений WPF. Единицы WPF Окно WPF и все элементы внутри него измеряются в независимых от устройства единицах. Такая единица определена как 1/96 дюйма. Чтобы понять, что это означает на практике, нужно рассмотреть пример. Предположим, что вы создаете в WPF маленькую кнопку размером 96x96 единиц. Если вы используете стандартную установку Windows DPI (96 dpi), то каждая независимая от устройства единица измерения соответствует одному реальному физическому пикселю. Это потому, что WPF использует следующее вычисление: [Размер в физических единицах] = [Размер в независимых от устройства единицах] х [DPI системы] = 1/96 дюйма х 96 dpi = 1 пиксель По сути, WPF предполагает, что ему нужно 96 пикселей, чтобы отобразить один дюйм, потому что Windows сообщает ему об этом через системную настройку DPI. Однако в действительности это зависит от применяемого дисплейного устройства. Например, рассмотрим 20-дюймовый жидкокристаллический монитор с максимальным разрешением в 1600x1200 пикселей. Используя теорему Пифагора, вы можете вычислить плотность пикселей для этого монитора, как показано ниже: Гг_т л Vl6002 +12002 пикселей ^ пп ^ [DPI экрана] = = 100 dpi 19дюймов В этом случае плотность пикселей составляет 100 dpi — немного больше того, что предполагает Windows. В результате на этом мониторе кнопка размером 96x96 пикселей будет несколько меньше одного дюйма. С другой стороны, рассмотрим 15-дюймовый жидкокристаллический монитор с разрешением 1024x768 пикселей. Здесь плотность пикселей составит около 85 dpi, поэтому кнопка размером 96x96 пикселей окажется размером немного больше 1 дюйма. В обоих случаях, если вы уменьшите размер экрана (скажем, переключившись на разрешение 800x600), то кнопка (и любой другой экранный элемент) станет пропорционально больше. Причина в том, что системная установка DPI останется 96 dpi. Другими словами, Windows продолжает предполагать, что 96 пикселей составляют дюйм, несмотря на то, что при меньшем разрешении потребуется существенно меньше пикселей. Совет. Возможно, вам известно, что жидкокристаллические мониторы создаются с единственным разрешением, которое называется естественным разрешением. При более низком разрешении монитору приходится использовать интерполяцию, чтобы заполнить лишние пиксели, в это может вызвать нерезкость. Чтобы получить наилучшее качество изображения, всегда лучше использовать естественное разрешение. Если хотите иметь более крупные окна, кнопки и текст, рассмотрите вместо этого возможность модификации системной установки DPI (как описано далее). Системная установка DPI До сих пор пример кнопки WPF работал точно так же, как любой другой интерфейсный элемент в Windows-приложении любого иного типа. Отличие проявляется при из-
Глава 1. Введение в WPF 33 менении вашей системной установки DPI. В предыдущем поколении Windows это средство иногда называли крупными шрифтами. Это потому, что системная установка DPI влияет на размер системных шрифтов, часто оставляя прочие детали неизменными. На заметку! Многие Windows-приложения не полностью поддерживают увеличенные установки DPI. В худшем случае увеличение системной установки DPI может привести к появлению окон, в которых некоторое содержимое увеличено, а другое — нет, что может привести к утере части содержимого или даже к нефункциональным окнам. Здесь поведение WPF отличается. WPF воспринимает системную установку DPI естественным образом и без особых затрат. Например, если вы измените системную установку DPI на 120 dpi (распространенный выбор пользователей экранов с большим разрешением), WPF предполагает, что для заполнения дюйма пространства нужно 120 пикселей. WPF использует следующее вычисление для определения того, как он должен транслировать логические единицы в физические пиксели устройства: [Размер в физических единицах] = [Размер в независимых от устройства единицах] х [DPI системы] =1/96 дюйма х 120 dpi =1,25 пикселя Другими словами, когда вы устанавливаете системную настройку DPI в 120 dpi, то механизм визуализации WPF предполагает, что одна независимая от устройства единица измерения соответствует 1,25 пикселя. Если вы отображаете кнопку 96x96, то физический ее размер составит 120x120 пикселей (потому что 96 х 1,25 = 120). Именно такого результата вы и ожидаете — кнопка размером в 1 дюйм имеет такой же размер на мониторе с повышенной плотностью пикселей. Такое автоматическое масштабирование было бы не слишком полезным, если бы касалось только кнопок. Но WPF использует независимые от устройства единицы для всего, что отображает, включая фигуры, элементы управления, текст и любые другие ингредиенты, которые помещаются в окно. В результате можно изменять системную установку DPI, как вам заблагорассудится, и WPF незаметно подгонит размеры окон приложения. На заметку! В зависимости от системной установки DPI вычисляемый размер пикселя может быть выражен дробным значением. Можно предположить, что WPF просто округляет все размеры до ближайшего пикселя. Однако по умолчанию WPF поступает несколько иначе. Если грань элементов приходится на точку между пикселями, WPF использует сглаживание, чтобы размыть эту грань. Это может показаться странным решением, но на самом деле оно вполне оправдано. Элементы управления не обязательно должны иметь прямые четкие грани, если для их отображения применяется специально отрисованная графика, поэтому некоторая степень сглаживания все равно необходима. Шаги для изменения системной установки DPI зависят от операционной системы. В следующих разделах объясняется, что следует делать, в зависимости от используемой операционной системы. Windows XP 1. Щелкните правой кнопкой мыши на рабочем столе и выберите в контекстном меню пункт Свойства. 2. В открывшемся диалоговом окне перейдите на вкладку Параметры и щелкните на кнопке Дополнительно.
34 Глава 1. Введение в WPF 3. На вкладке Общие выберите в списке Масштаб (количество точек на дюйм) вариант Обычный размер (96 точек/дюйм) или Крупный размер A20 точек/дюйм). Это две рекомендованных опции для Windows XP, потому что специальные установки DPI, скорее всего, не будут поддерживаться старыми программами. Чтобы попробовать установить собственное значение DPI, выберите вариант Особые параметры. Затем можно указать определенное значение в процентах (например, 175% увеличивает стандартное значение 96 dpi до 168 dpi). Windows Vista 1. Щелкните правой кнопкой мыши на рабочем столе и выберите в контекстном меню пункт Персонализация. 2. В списке ссылок слева щелкните на Корректировка размеров шрифта (DPI). 3. Выберите один из переключателей 96 точек/дюйм и 120 точек/дюйм либо щелкните на кнопке Другой размер шрифта, чтобы указать специальное значение DPI. После этого можно задать значение в процентах (например, 175% увеличивает стандартное значение 96 dpi до 168 dpi). Кроме того, здесь имеется флажок Использовать масштабы в стиле Windows XP, который описан во врезке "Масштабирование DPI в Windows Vista и Windows 7". Windows 7 1. Щелкните правой кнопкой мыши на рабочем столе и выберите в контекстном меню пункт Персонализация. 2. В списке ссылок внизу слева щелкните на Экран. 3. Выберите один из переключателей Мелкий (опция по умолчанию), Средний или Крупный. Эти опции также описаны в процентах масштабирования A00%, 125% или 150%) и на самом деле соответствуют значениями 96 dpi, 120 dpi и 144 dpi. Первые две соответствуют стандартам, имеющимся в Windows Vista и Windows XP, а третья — несколько больше. В качестве альтернативы можно щелкнуть на ссылке Другой размер шрифта (точек на дюйм) и указать специальное значение масштаба, как показано на рис. 1.1 (например, 175% увеличивает стандартное значение 96 dpi до 168 dpi). Кроме того, здесь имеется флажок Использовать масштабы в стиле Windows XP, который описана во врезке "Масштабирование DPI в Windows Vista и Windows 7". Выбор масштаба ggj] Для установки масштаба выберите процентное соотношение из списка или переместите ползунок с Масштаб от обычного размера: 1 | 1 1 ' 0 1 Segoe 1Д 9 пт, 96 пикселей на дюйм льзовать масштабы в иле Windows № помощью мыши. 100% - 1 1 2 •1 3 • ОК ] [ Отмена ) Рис. 1.1. Изменение системной установки DPI
Глава 1. Введение в WPF 35 Масштабирование DPI в Windows Vista и Windows 7 Поскольку старые приложения печально известны отсутствием поддержки высоких значений DPI, в Windows Vista появился новый прием, который получил название масштабирование растровых изображений (bitmap scaling). В Windows 7 это средство также поддерживается. Если вы запускаете приложение, которое не поддерживает высоких значений DPI, то Windows изменяет размер содержимого окна до желаемого DPI, как если бы это было просто графическое изображение. Преимущество такого решения в том, что приложению кажется, что оно работает при стандартных 96 dpi. ОС Windows незаметно транслирует ввод (такой как щелчки кнопками мыши) и маршрутизирует его в правильное место соответствующей "реальной" координатной системы. Алгоритм масштабирования, используемый Windows, достаточно хорош — он старается избегать размытости граней и использует аппаратную поддержку видеокарты, когда это позволяет увеличить скорость, но это неизбежно приводит к некоторой общей размытости изображения. К тому же это имеет серьезные ограничения, связанные с тем, что Windows не может распознать старые приложения, которые поддерживают высокие значения DPI. Поэтому приложения должны включать манифест или вызывать SetProcessDPIAware (в User32) для объявления о своей поддержке высоких значений DPI. Хотя WPF-приложения обрабатывают этот шаг корректно, приложения, разработанные до появления Windows Vista, не могут воспользоваться ни одним из подходов, и обречены на неидеальное масштабирование растровых изображений. Существуют два возможных решения. При наличии нескольких специфичных приложений, которые поддерживают высокие установки DPI, но не сообщают об этом, эту деталь можно сконфигурировать вручную. Для этого щелкните правой кнопкой мыши на ярлыке, запускающем приложение (в меню Пуск) и выберите в контекстном меню пункт Свойства. На вкладке Совместимость отметьте флажок Отключить масштабирование изображения при высоком разрешении экрана. Однако если придется конфигурировать много приложений, эти действия могут оказаться довольно утомительными. Другое возможное решение заключается в том, чтобы вообще отключить масштабирование растровых изображений. Для этого отметьте флажок Использовать масштабы в стиле Windows ХР в диалоговом окне Выбор масштаба, которое показано на рис. 1.1. Единственное ограничение этого подхода связано с тем, что могут существовать приложения, которые некорректно отображаются (и потому могут даже оказаться неработоспособными) при высоких установках DPI. По умолчанию флажок Использовать масштабы в стиле Windows XP отмечен для значений 120 dpi и менее, но не отмечен для значений свыше 120 dpi. Растровая и векторная графика Когда вы имеете дело с обычными элементами управления, то можете рассчитывать на независимость WPF от разрешения. WPF автоматически заботится о том, чтобы все имело правильные размеры. Однако если в приложении планируется использовать изображения, подобной уверенности быть не может. Например, в традиционных Windows- приложениях для команд панели инструментов применяются крошечные растровые изображения. В приложении WPF такой подход не идеален, потому что растровое изображение может отображать артефакты (размытые), которые будут масштабироваться вверх и вниз согласно системной установке DPI. Вместо этого при проектировании пользовательского интерфейса WPF даже самые мелкие значки обычно реализованы в векторной графике. Векторная графика определена как набор фигур, каждая из которых может быть легко масштабирована до любых размеров. На заметку! Разумеется, отображение векторной графики требует больше времени, чем отрисов- ка базового растрового изображения, но WPF включает набор приемов оптимизации, которые призваны снизить накладные расходы, всегда обеспечивая разумную производительность.
36 Глава 1. Введение в WPF Важность независимости от разрешения переоценить трудно. На первый взгляд это кажется очевидным, элегантным решением старой проблемы (что так и есть). Однако чтобы проектировать полностью масштабируемые интерфейсы, разработчики должны взять на вооружение новый образ мышления. Архитектура WPF Технология WPF использует многоуровневую архитектуру. На вершине ваше приложение взаимодействует с высокоуровневым набором служб, которые полностью написаны на управляемом коде С#. Действительная работа по трансляции объектов .NET в текстуры и треугольники Direct3D происходит "за кулисами", с использованием низкоуровневого неуправляемого компонента по имени milcore.dll. Библиотека milcore.dll реализована в неуправляемом коде потому, что ей требуется тесная интеграция с Direct3D, и вдобавок для нее чрезвычайно важна производительность. На рис. 1.2 показаны уровни, на которых построена работа приложения WPF. PresentationFramework.dll Управляемый API-интерфейс WPF PresentationCore.dll WindowsBase.dll / \ / X milcore.dll WindowsCodecs.dll i т Direct3D User32 Уровень медиа-интеграции Рис. 1.2. Архитектура WPF Ниже описаны ключевые компоненты, присутствующие на рис. 1.2. • PresentationFramework.dll содержит типы WPF верхнего уровня, включая те, что представляют окна, панели и прочие виды элементов управления. Также он реализует высокоуровневые программные абстракции, такие как стили. Большинство классов, которые вы будете использовать, находятся непосредственно в этой сборке. • PresentationCore.dll содержит базовые типы, такие как UIElement и Visual, от которых унаследованы все фигуры и элементы управления. Если вам не нужен полный уровень абстракции окон и элементов управления, можете опуститься ниже, на этот уровень, и продолжать пользоваться преимуществами механизма визуализации WPF. • WindowsBase.dll содержит еще более базовые ингредиенты, которые потенциально могут применяться вне WPF, такие как Dispatcher Object и Dependency Object, поддерживающие механизм свойств зависимости (эта тема будет детально рассмотрена в главе 4).
Глава 1. Введение в WPF 37 • milcore.dll — ядро системы визуализации WPF и фундамент уровня медиа- интеграции (Media Integration Layer — MIL). Его составной механизм транслирует визуальные элементы в треугольники и текстуры, которых ожидает Direct3D. Хотя milcore.dll считается частью WPF, это также важнейший компонент операционных систем Windows Vista и Windows 7. В действительности DWM (Desktop Window Manager — диспетчер окон рабочего стола) использует milcore.dll для отображения рабочего стола. На заметку! C6opKymilcore.dll иногда называют механизмом "управляемой графики". Подобно тому, как общеязыковая исполняющая среда (common language runtime — CLR) управляет жизненным циклом приложения .NET, milcore.dll управляет состоянием дисплея. И так же, как CLR избавляет от забот об освобождении объектов и восстановлению памяти, milcore.dll избавляет от необходимости думать о недействительности и перерисовке окна. Вы просто создаете объекты с содержимым, которое хотите отобразить, a milcore.dll рисует соответствующие части окна, когда оно перемещается, скрывается и раскрывается, сворачивается и восстанавливается, и т.д. • WindowsCodecs.dll —низкоуровневый API-интерфейс, обеспечивающий поддержку изображений (например, обработку, отображение и масштабирование растровых изображений и файлов JPEG). • Direct3D — низкоуровневый API-интерфейс, через который визуализируется вся графика в WPF. • User32 используется для определения того, какое место на экране к какой программе относится. В результате он по-прежнему вовлечен в WPF, но не участвует в визуализации распространенных элементов управления. Наиболее важный факт, который потребуется осознать, состоит в том, что Direct3D визуализирует все рисование в WPF. При этом не важно, установлена на компьютере видеокарта со скромными возможностями или же более мощная, используются базовые элементы управления или рисуется более сложное содержимое, запускается приложение в Windows ХР, Windows Vista или Windows 7. Даже двумерные фигуры и обычный текст трансформируются в треугольники и проходят по трехмерному конвейеру. Какие- либо обращения к GDI+ или User32 отсутствуют. Иерархия классов Читая эту книгу, большую часть времени вы потратите на изучение пространств имен и классов WPF. Но прежде чем начать, полезно взглянуть на общую иерархию классов, которые ведут к базовому набору элементов управления WPF На рис. 1.3 показан базовый обзор некоторых ключевых ветвей иерархии классов. Продвигаясь по главам этой книги, вы будете знакомиться с указанными (и связанными с ними) классами более подробно. В последующих разделах описаны основные классы из этой диаграммы. Многие из них ведут к целым ветвям элементов (таких как фигуры, панели и элементы управления). На заметку! Основные пространства имен WPF начинаются в System.Windows (например, System.Windows, System.Windows .Controls и System.Windows .Media). Единственным исключением являются пространства имен, начинающиеся с System.Windows. Forms, которые относятся к инструментам Windows Forms.
38 Глава 1. Введение в WPF Shape DispatcherObject i DependencyObject i Visual Условные обозначения Абстрактный класс Конкретный класс ж UlElement FrameworkElement I Control Panel ContentControl 4 ItemsControl Рис. 1.3. Фундаментальные классы WPF System. Threading. DispatcherObject Приложения WPF используют знакомую однопоточную модель (single-thread affinity — STA), а это означает, что весь пользовательский интерфейс принадлежит единственному потоку. Взаимодействовать с элементами пользовательского интерфейса из других потоков небезопасно. Чтобы содействовать работе этой модели, каждое WPF-приложение управляется диспетчером, координирующим сообщения (появляющиеся в результате клавиатурного ввода, перемещений курсора мыши и таких процессов платформы, как компоновка). Будучи унаследованным от DispatcherObject, каждый элемент пользовательского интерфейса может удостовериться, выполняется ли код в правильном потоке, и обратиться к диспетчеру, чтобы направить код в поток пользовательского интерфейса. Подробнее о модели многопоточности WPF речь пойдет в главе 31. System.Windows. DependencyObject В WPF центральный путь взаимодействия с экранными элементами пролегает через свойства. На ранней стадии цикла проектирования архитекторы WPF решили создать более мощную модель свойств, которая положена в основу таких средств, как уведомления об изменениях, наследуемые значения по умолчанию и более экономичное хранилище свойств. Конечным результатом стало средство свойств зависимости
Глава 1. Введение в WPF 39 (dependency property), с которым вы ознакомитесь в главе 4. За счет наследования от DependencyObject, классы WPF получают поддержку свойств зависимости. System.Windows.Media.Visual Каждый элемент, появляющийся в WPF, в основе своей является Visual. Класс Visual можно воспринимать как единственный объект рисования, инкапсулирующий в себе инструкции рисования, дополнительные подробности рисования (наподобие отсечения, прозрачности и настроек трансформации) и базовую функциональность (вроде проверки попадания). Класс Visual также обеспечивает связь между управляемыми библиотеками WPF и сборкой milcore.dll, которая визуализирует отображение. Любой класс, унаследованный от Visual, обладает способностью отображаться в окне. Если вы предпочитаете создавать свой пользовательский интерфейс с применением легковесного API-интерфейса, не обладающего высокоуровневыми средствами WPF, то можете программировать непосредственно с использованием объектов Visual, как описано в главе 14. System. Windows. UIElement Класс UIElement добавляет поддержку таких сущностей WPF, как компоновка (layout), ввод (input), фокус (focus) и события (events) — все, что команда разработчиков WPF называет аббревиатурой LIFE. Например, именно здесь определен двухшаговый процесс измерения и организации компоновки, о котором вы узнаете в главе 18. Здесь же щелчки кнопками мыши и нажатия клавиш трансформируются в более удобные события, такие как MouseEnter. Как и со свойствами, WPF реализует расширенную систему передачи событий, именуемую маршрутизируемыми событиями (routed events). В главе 5 будет показано, как она работает. И, наконец, UIElement добавляет поддержку команд (см. главу 9). Sys tern. Windows. FrameworkElemen t Класс FrameworkElement — конечный пункт в центральном дереве наследования WPF Он реализует некоторые члены, которые просто определены в UIElement. Например, UIElement устанавливает фундамент для системы компоновки WPF, но FrameworkElement включает ключевые свойства (вроде HorizontalAlignment и Margin), которые поддерживают его. UIElement также добавляет поддержку привязки данных, анимации и стилей — все они являются центральными средствами. System. Windows. Shapes. Shape От этого класса наследуются базовые фигуры, такие как Rectangle, Polygon, Ellipse, Line и Path. Эти фигуры могут использоваться наряду с более традиционными графическими элементами Windows вроде кнопок и текстовых полей. Построением фигур мы займемся в главе 12. System. Windows. Controls. Control Элемент управления (control) — это элемент, который может взаимодействовать с пользователем. К нему очевидным образом относятся такие классы, как Text Box, Button и ListBox. Класс Control добавляет дополнительные свойства для установки шрифта, а также цветов переднего плана и фона. Но наиболее интересная деталь, которую он предоставляет — это поддержка шаблонов, которая позволяет заменять стандартный внешний вид элемента управления собственным рисованием. Шаблоны элементов управления рассматриваются в главе 17.
40 Глава 1. Введение в WPF На заметку! В программировании с применением Windows Forms любой визуальный компонент в форме называется элементом управления. В WPF это не так. Визуальные единицы называются элементами (element), и только некоторые из них являются элементами управления (те, что могут принимать фокус и взаимодействовать с пользователем). Еще более запутывает эту систему то, что многие элементы определены в пространстве имен System.Windows.Controls, хотя они не унаследованы от System.Windows.Controls.Control и не могут считаться элементами управления. Примером может служить класс Panel. Sys tern. Windows. Controls. ContentControl Это базовый класс для всех элементов управления, которые имеют отдельный фрагмент содержимого. Сюда относится все — от скромной метки Label до окна Window. Наиболее впечатляющая часть этой модели (которая более детально описана в главе 6) заключается в том, что единственный фрагмент содержимого может быть чем угодно — от обычной строки до панели компоновки, содержащей комбинацию других фигур и элементов управления. System. Windows. Controls. ItemsControl Это базовый класс для всех элементов управления, которые отображают коллекцию каких-то единиц информации, вроде ListBox и TreeView. Списочный элемент управления замечательно гибок; например, используя встроенные средства класса ItemsControl, можно трансформировать обычный ListBox в список переключателей, список флажков, упорядоченный набор картинок или комбинацию совершенно разных элементов по своему выбору. Фактически в WPF все меню, панели инструментов и линейки состояния на самом деле являются специализированными списками, и классы, реализующие их, наследуются от ItemsControl. Вы начнете использовать списки в главе 19, когда пойдет речь о привязке данных. Их расширение вы изучите в главе 20, а наиболее специализированные списочные элементы управления — в главе 22. System. Windows. Controls. Panel Это базовый класс для всех контейнеров компоновки — элементов, которые содержат в себе один или более дочерних элементов и упорядочивают их в соответствии с определенными правилами компоновки. Эти контейнеры образуют фундамент системы компоновки WPF, и их использование — ключ к упорядочиванию содержимого наиболее привлекательным и гибким способом. Система компоновки WPF более детально рассматривается в главе 3. WPF4 WPF 4 — относительно новая технология. Частично она входила в несколько выпусков .NET и постепенно совершенствовалась. • WPF 3.0. Первая версия WPF вышла вместе с двумя другими технологиями: Windows Communication Foundation (WCF) и Windows Workflow Foundation (WF). Все вместе это называлось .NET 3.0. • WPF 3.5. ГЪд спустя, вышла новая версия WPF, как часть .NET Framework 3.5. Новые средства WPF в основном были слегка усовершенствованы, включая исправление ошибок и повышение производительности. • WPF 3.5 SP1. Когда вышел пакет обновлений .NET Framework Service Pack 1 (SP1), проектировщики WPF получили возможность добавить некоторые новые средства, подобные сглаженной графике (благодаря построителям текстуры) и изощренному элементу управления DataGrid.
Глава 1. Введение в WPF 41 • WPF 4. В последнем выпуске WPF появилось множество улучшений, включая ценные новые средства, построенные на базе существующей инфраструктуры WPF. Среди некоторых наиболее заметных изменений — улучшенная визуализация текста, более естественная анимация и поддержка средств Windows 7, таких как сенсорные возможности и новая панель задач. Новые средства В этой книге охвачены все концепции WPF, включая самые броские новые средства и базовые принципы, которые остаются неизменными с момента появления этой технологии. Однако если вы — опытный разработчик WPF, заглядывайте во врезки "Что нового?", предлагаемые в начале каждой главы. В них детализируется относительно новый материал, т.е. средства, которые появились в WPF 3.5 SP1 или WF 4. Если такой врезки нет, то, скорее всего, в главе рассматриваются устоявшиеся средства WPF, которые в последнем выпуске не изменились. Приведенный ниже список поможет идентифицировать ряд наиболее заметных изменений, произошедших со времени выхода WPF 3.0, а также отыскать главы, в которых обсуждается каждое из средств. • Новые элементы управления. Семейство элементов WPF продолжает расти. Теперь оно включает профессиональный выглядящий DataGrid (глава 22), стандартные DataPicker и Calendar (глава 6) и встроенный WebBrowser для просмотра HTML-разметки и веб-серфинга (глава 24). Отдельная загрузка также добавляет полезный элемент управления Ribbon (глава 25), который придает приложениям современный вид. • Усовершенствования двухмерной графики. Теперь визуальное представление каждого элемента может быть радикально изменено посредством эффектов в духе PhotoShop — через построители текстур (с использованием вплоть до версии 3 стандарта построителей текстуры). Разработчики, которые желают манипулировать индивидуальными пикселями вручную, могут также генерировать и модифицировать изображения с помощью класса WriteableBitmap. Оба средства рассматриваются в главе 14. • Облегчение анимации. Эти функции позволяют создавать более жизнеподобные анимации, которые прыгают, ускоряются и качаются естественным образом. Полное описание содержится в главе 15. • Диспетчер визуального состояния. Впервые появившийся в Silverlight, диспетчер визуального состояния (см. главу 17) облегчает изменение обложек элементов управления без необходимости понимания их внутреннего устройства и работы. • Windows 7. Новейшая операционная система от Microsoft добавила целый пакет новых средств. WPF включает естественную поддержку улучшенной панели задач, позволяя использовать списки переходов, перекрытия значков, уведомления о ходе работ и панели инструментов с миниатюрами (все это рассматривается в главе 23). При наличии соответствующего оборудования можно использовать поддержку WPF сенсорных возможностей Windows 7 (глава 5), которые позволяют с помощью жестов на сенсорном экране управлять визуальными объектами. • Улучшенная визуализация. В WPF продолжает улучшаться качество отображения за счет преодоления проблем, связанных с моделью рисования, не зависящей от разрешения монитора. В WPF 4 можно использовать округление компоновки, которое выравнивает контейнеры по границам пикселей, гарантируя чистое изображение (см. главу 3). То же самое можно сделать при визуализации текста, гарантируя его четкость даже при самых маленьких размерах (см. главу 6).
42 Глава 1. Введение в WPF • Кэширование растровых изображений. При правильном сценарии рабочую нагрузку процессора можно снижать, кэшируя сложную векторную графику в памяти видеокарты. Эта техника удобна, в частности, в случае использования анимации и описана в главе 16. • XAML 2009. В WPF появилась новая версия стандарта разметки XAML, используемого для объявления пользовательского интерфейса в окне или на странице. В нем добавлен ряд небольших улучшений, но, скорее всего, вы пока не захотите ими пользоваться, потому что стандарт не встроен в компилятор WPF XAML. Подробнее об этой ситуации читайте в главе 2. WPF Toolkit Прежде чем новый элемент управления найдет свое место в библиотеках WPF платформы .NET, он начинает свою жизнь в составе отдельной загрузки инструментального набора WPF Toolkit. Хотя WPF Toolkit не предсказывает будущего направления развития WPF, это замечательное место, где можно найти практичные компоненты и элементы, выходящие за рамки обычных выпусков WPF. Так, например, WPF не включает никаких инструментов построения диаграмм, а в WPF Toolkit вы найдете набор элементов для создания столбчатых, круговых, линейных и прочих диаграмм. В этой книге периодически встречаются ссылки на WPF Toolkit, когда имеет смысл указать на полезную часть функциональности, которая не доступна в ядре исполняющей среды .NET Для загрузки WPF Toolkit, ознакомления с его кодом либо изучения документации обратитесь по адресу http://wpf.codeplex.com. Там же вы найдете ссылки на другие управляемые Microsoft проекты WPF, включая WPF Features (куда входят экспериментальные средства WPF) и средства тестирования WPF Visual Studio 2010 Хотя пользовательские интерфейсы WPF можно строить вручную либо с помощью графического инструмента Expression Blend, большинство разработчиков начинают с Visual Studio и проводят в нем большую часть времени. В этой книге предполагается, что вы пользуетесь Visual Studio, и периодически объясняется, как применять Visual Studio для решения важнейших задач, таких как добавление ресурса, конфигурирование свойств проекта или создание сборки с библиотекой элементов управления. Однако много времени на исследование разнообразных средств времени проектирования тратиться не будет. Вместо этого внимание будет сосредоточено на лежащей в основе разметке и коде, что понадобится для создания профессиональных приложений. На заметку! Возможно, вы уже знаете, как создается проект WPF в Visual Studio, но стоит кратко напомнить. Сначала выберите пункт меню File о New о Project (Файл ^Создатьо Проект) Затем в открывшемся диалоговом окне выберите группу Visual C#c=>Windows (в дереве слева), а в ней — шаблон WPF Application (в списке справа). В главе 24 вы узнаете о более специализированном шаблоне WPF Browser Application. Выбрав каталог, введите имя проекта и щелкните на кнопке ОК. В результате получается базовая структура приложения WPF Поддержка множества целевых платформ В прошлом каждая версия Visual Studio была тесно привязана к определенной версии .NET. Версия Visual Studio 2010 свободна от этого ограничения и позволяет проектировать приложения, ориентированные на любую версию .NET— от 2.0 до 4. Хотя очевидно невозможно создать приложение WPF для .NET 2.0, в версиях .NET 3.0 и 3.5 поддержка WPF имеется. Выбор в качестве целевой платформы .NET 3.0 обеспе-
Глава 1. Введение в WPF 43 чивает наиболее широкую совместимость (т.к. приложения .NET 3.0 могут работать под управлением исполняющих сред .NET 3.0, 3.5 и 4). Выбор в качестве целевой платформы .NET 3.5 или .NET 4 открывает доступ к новейшим средствам WPF, имеющимся в .NET. При создании нового проекта в Visual Studio можно выбирать целевую версию .NET Framework в раскрывающемся списке, который расположен в верхней части диалогового окна New Project (Новый проект) прямо над списком шаблонов проектов (рис. 1.4). New Project Instated Templates л Visual С* Windows Web Office Cloud Reporting SharePoint Sirverlight ::ШШ) ; NET Framework 20 NET Framework ЗЛ NET Framework 35 e! ' <|М1?1ге|п?1|Г|ТПн^,,1> Sort by: Default bplication Visual C* Visual C* Type: Visual C* Windows Presentation Foundation client application ЧН Console Application ^gfj Class Library d WPF Browser Application Visual C* Location: Solution: Solution name: Wpf Application DADes ktop\ Create пел solution Bro, Create directory for solution Add to source control Рис. 1.4. Выбор целевой версии .NET Framework Целевую версию можно изменить в любой момент позже, дважды щелкнув на узле Properties (Свойства) в окне Solution Explorer (Проводник решения) и изменив выбор в списке Target Framework (Целевая платформа). Для обеспечения аккуратной поддержки множества целевых платформ Visual Studio 2010 включает ссылочные сборки для каждой версии .NET. Эти сборки содержат метаданные каждого типа, но ничего из кода, нужного для их реализации. Это значит, что Visual Studio 2010 может использовать ссылочную сборку для настройки средства IntelliSense и проверки ошибок, гарантируя, что вы не сможете использовать элементы управления, классы или члены, которые не доступны в выбранной версии .NET. Эти метаданные также используются для определения того, что должно появиться в окне Properties (Свойства) и браузере объектов (Object Browser), и т.д., гарантируя, что вся IDE-среда будет ограничена выбранной версией .NET Клиентский профиль .NET Как ни странно, доступны два способа выбрать в качестве цели WPF 4. Первый способ — построить приложение, которое требует стандартной установки полной платформы .NET Framework 4. Второй способ — построить приложение, которому требуется .NET Framework 4 Client Profile (Клиентский профиль .NET Framework 4). Клиентский профиль — это подмножество .NET Framework, которое требуется многофункциональным клиентским приложениями вроде WPF Сюда не входят средства серверной стороны, такие как ASP.NET, отладчики, средства разработки, компиляторы кода и унаследованные средства (подобные поддержке баз данных Oracle). Более важно то, что клиент имеет меньший размер, требуя загрузки около 30 Мбайт, в то время как полный комплект распространения .NET Framework занимает около 100 Мбайт.
44 Глава 1. Введение в WPF Естественно, если приложение ориентировано на .NET Framework 4 Client Profile, оно без проблем будет работать под управлением полной версии .NET Framework. Концепция клиентского профиля появилась в .NET 3.5 SP1. Однако в ней по-прежнему присутствуют несколько моментов, которые мешают ей стать стандартом. В .NET 4 были проведены работы по тонкой настройке средств, включаемых в комплект клиентского профиля, предполагая сделать его стандартным выбором для любого приложения. В Visual Studio 2010 большинство проектов автоматически нацелены на .NET Framework 4 Client Profile. (Именно это вы получаете, выбирая .NET Framework 4 в диалоговом окне New Project.) Изменив настройку Target Framework (Целевая платформа) в свойствах проекта, можно увидеть более подробный список, который имеет отдельные опции для полной версии .NET Framework 4 и .NET Framework 4 Client Profile. При выборе целевой версии .NET часто важно учитывать, насколько широко распространены различные исполняющие среды в настоящее время. В идеале пользователи должны иметь возможность запускать приложения, не требуя дополнительного шага по загрузке и установке. Ниже дано несколько советов, которые помогут принять правильное решение. • Windows Vista включает .NET Framework 3.0. • Windows 7 включает .NET Framework 3.5 SP1. • .NET Framework 4 Client Profile является рекомендуемым обновлением (через службу Windows Update) для Windows Vista и Windows 7. Для компьютеров Windows XP оно является необязательным. Визуальный конструктор Visual Studio Несмотря на тот факт, что Visual Studio является важнейшим инструментом для программирования с применением WPF, в предыдущих версиях был существенный пробел в доступных возможностях — они не предлагали графического визуального конструктора для создания пользовательского интерфейса. В результате разработчики были вынуждены писать код XAML вручную либо переключаться между Visual Studio и более ориентированным на дизайн инструментом Expression Blend. В Visual Studio 2010, наконец, этот недостаток был восполнен за счет появления мощного визуального конструктора для создания пользовательских интерфейсов WPF. Однако тот факт, что Visual Studio 2010 позволяет легко перетаскивать окна WPF на поверхность проектирования, не означает, что это нужно делать прямо сейчас или вообще когда-либо. Как будет показано в главе 3, в WPF используется гибкая и тонкая модель компоновки, которая позволяет применять разные стратегии для задания размеров и позиционирования элементов в рамках пользовательского интерфейса. Для получения нужного результата понадобится использовать корректную комбинацию контейнеров компоновки, правильно организовать их и должным образом сконфигурировать их свойства. Visual Studio может помочь в этом, но будет намного легче, если первым делом освоить основы разметки XAML и компоновки WPF Это позволит впоследствии просматривать код разметки, сгенерированный Visual Studio, и при необходимости модифицировать его вручную. Овладев синтаксисом XAML (глава 2) и ознакомившись с семейством контейнеров компоновки WPF (глава 3), вы сможете сами выбирать, каким образом создавать окна. Часть профессиональных разработчиков используют Visual Studio, часть — Expression Blend, есть те, кто пишет код XAML вручную, а есть те, кто применяет комбинацию перечисленных методов с последующим конфигурированием в визуальном конструкторе Visual Studio.
Глава 1. Введение в WPF 45 Резюме В этой главе был представлен начальный обзор WPF и тех возможностей, которые эта платформа предлагает Вы узнали о лежащей в ее основе архитектуре и кратко об основных классах. WPF — это будущее разработок Windows-приложений. Со временем WPF превратится в систему, подобную User32 и GDI/GDI+, к которой будут добавляться новые расширения и высокоуровневые средства. В конечном итоге WPF позволит проектировать приложения, которые было бы невозможно (или, по крайней мере, непрактично) построить средствами Windows Forms. Естественно, WPF несет в себе много революционных изменений. Однако есть несколько ключевых принципов, которые нужно немедленно сформулировать, поскольку они совершенно отличаются от тех, что лежат в основе предшествующих инструментов для построения пользовательского интерфейса Windows, таких как Windows Forms. Ниже перечислены эти принципы. • Аппаратное ускорение. Все рисование WPF выполняется через DirectX, что позволяет этой технологии пользоваться преимущества современных видеокарт. • Независимость от разрешения. Технология WPF настолько гибкая, что может автоматически выполнять масштабирование вверх и вниз, приспосабливаясь к предпочтениям монитора, в зависимости от системных установок DPI. • Отсутствие фиксированного внешнего вида элементов управления. В традиционной разработке для Windows существует огромная пропасть между элементами управления, которые можно подогнать под ваши нужды (они называются самостоятельно рисуемыми), и теми, которые визуализируются операционной системой, и чей внешний вид, по сути, фиксирован. В WPF все, начиная от базового Rectangle и до стандартного Button или более сложного Toolbar, рисуется посредством механизма визуализации и является полностью настраиваемым. По этой причине элементы управления WPF часто называют лишенными внешности — они определяют функциональность элемента управления, но не имеют жестко привязанной внешности. • Декларативный пользовательский интерфейс. В следующей главе мы рассмотрим XAML — стандарт языка разметки, который используется для определения пользовательских интерфейсов WPF Язык XAML позволяет строить окна без кода. Впечатляет то, что XAML не ограничивает фиксированным неизменным пользовательским интерфейсом. Можно применять такие средства, как привязка данных и триггеры, для автоматизации базового поведения пользовательского интерфейса (вроде текстовых полей, обновляющих себя, когда вы перемещаетесь по источнику записи, или меток, которые подсвечиваются при наведении на них курсора мыши) — и все это вообще без написания кода С#. • Рисование на основе объектов. Даже если планируется работать на низком визуальном уровне (вместо высокого уровня элементов), рисовать в терминах пикселей не придется. Вместо этого будут создаваться объекты фигур, a WPF будет поддерживать отображение в наиболее оптимизированной манере. Эти принципы будут демонстрироваться в действии на протяжении всей книги. Но прежде чем двигаться дальше, необходимо изучить еще один дополняющий стандарт. В следующей главе представлен XAML — язык разметки, предназначенный для определения пользовательских интерфейсов WPF
ГЛАВА 2 XAML XAML (Extensible Application Markup Language — расширяемый язык разметки приложений) представляет собой язык разметки, используемый для создания экземпляров объектов .NET. Хотя язык XAML — это технология, которая может быть применима ко многим различным предметным областям, его главное назначение — конструирование пользовательских интерфейсов WPF. Другими словами, документы XAML определяют расположение панелей, кнопок и прочих элементов управления, составляющих окна в приложении WPF. Маловероятно, что вам придется писать код XAML вручную. Вместо этого вы будете пользоваться инструментом, генерирующим необходимый код XAML. Если вы — дизайнер графики, скорее всего, таким инструментом будет программа графического дизайна вроде Expression Blend. Если же вы — разработчик, то наверняка начнете с Visual Studio. Поскольку оба инструмента поддерживают XAML, вы можете создать базовый пользовательский интерфейс в Visual Studio, а затем передать его команде дизайнеров, которые доведут его до совершенства, добавив специальную графику с помощью Expression Blend. Фактически такая способность интегрировать рабочий поток разработчиков и дизайнеров — одна из ключевых причин создания Microsoft языка XAML. В этой главе предлагается детальное введение в XAML. Будет рассмотрено его предназначение, общая архитектура и синтаксис. Поняв основные правила XAML, вы узнаете, что возможно и что невозможно в пользовательском интерфейсе WPF, и как при необходимости провести в нем ручные изменения. Что более важно — за счет исследования дескрипторов в XAML-документе WPF вы можете много узнать об объектной модели, которая положена в основу пользовательских интерфейсов WPF, и подготовиться к углубленному ее изучению. Что нового? В WPF 4 был представлен XAML 2009 — обновленная версия языка XAML, имеющая множество полезных усовершенствований. Однако есть некоторые ограничения- в настоящее время XAML 2009 можно использовать только в несвязанных файлах XAML. Хотя Visual Studio поддерживает и несвязанные, и скомпилированные файлы XAML (как будет показано в этой главе), скомпилированные файлы XAML являются стандартом. Они позволяют не только работать с моделью отделенного кода, давая возможность подключать код с минимальными усилиями, но также гарантируют, что скомпилированное приложение будет иметь меньший размер и загрузится немного быстрее. В связи с этим XAML 2009 не будет использоваться в примерах, рассматриваемых в книге. Тем не менее, вы сможете получить предварительное представление о расширениях XAML 2009 в разделе "XAML 2009". Эта информация подготовит к будущим выпускам WPF, поскольку XAML 2009 претендует на роль нового стандарта (как только в Microsoft найдут время для переписывания, тестирования и оптимизации XAML-компилятора WPF).
Глава 2. XAML 47 Особенности XAML Разработчики давно поняли, что создавать сложные, графически насыщенные приложения намного легче, если отделить графическую часть от лежащего в основе кода. Таким образом, художники могут заниматься графикой, а разработчики — кодом. Обе части могут проектироваться и совершенствоваться по отдельности, без проблем, связанных с множеством версий. Графический интерфейс пользователя до WPF В традиционных технологиях отображения не существовало простого способа отделить графическое содержимое от кода. Ключевая проблема приложений Windows Forms состоит в том, что каждая форма, которую вы создаете, целиком определяется в коде С#. При помещении элементов управления на поверхность проектирования и их конфигурировании Visual Studio молча вносит изменения в код соответствующего класса формы. К сожалению, дизайнеры графики не располагают инструментами, которые могут работать с кодом С#. Вместо этого художники вынуждены создавать и экспортировать свой продукт в растровом формате. Эти растровые изображения затем могут использоваться для оформления окон, кнопок и других элементов управления. Такой подход хорошо работает с простыми интерфейсами, которые мало изменяются с течением времени, но весьма ограничен в других сценариях. К его проблемам можно отнести перечисленные ниже. • Каждый графический элемент (фон, кнопка и т.п.) должен экспортироваться как отдельное растровое изображение. Это ограничивает возможности их комбинирования и применения динамических эффектов, таких как сглаживание, прозрачность и тени. • Значительная часть логики пользовательского интерфейса должна быть встроена в код разработчиком. Сюда относятся размеры кнопок, позиционирование, эффекты от перемещения курсора мыши и анимация. Дизайнер графики не может контролировать эти детали. • Не существует внутренней связи между разными графическими элементами, так что легко создать не соответствующие друг другу наборы изображений. Отслеживание всех этих элементов привносит дополнительную сложность. • Растровые изображения не могут изменяться в размерах без потери качества. По этой причине пользовательский интерфейс на основе растрового изображения зависит от разрешения. Это значит, что он не может быть адаптирован к большим мониторам и дисплеям высокого разрешения, что нарушает основы проектной философии WPF. Если вам когда-либо доводилось проходить через процесс проектирования приложений Windows Forms с использованием специальной графики в командной среде, вы, несомненно, сталкивались с массой разочарований. Даже если интерфейс спроектирован с нуля дизайнером графики, он должен быть воссоздан в коде С#. Обычно дизайнеру графики просто приходится подготавливать макет, который затем нужно мучительно транслировать в работающее приложение. В WPF эта проблема решается с помощью XAML. При проектировании WPF-прило- жения в Visual Studio создаваемое окно не транслируется в код. Вместо этого оно се- риализуется в набор дескрипторов XAML. После запуска приложения эти дескрипторы используются для генерации объектов, составляющих пользовательский интерфейс.
48 Глава 2. XAML На заметку! Важно понимать, что WPF не требует обязательного применения XAML. Нет причин, по которым система Visual Studio не могла бы использовать подход Windows Forms и сразу создавать операторы кода, конструирующие окна WPF. Но в этом случае окно будет "заперто" в среде Visual Studio и доступно только программистам. Другими словами, для WPF не требуется XAML. Однако XAML открывает возможности для кооперации, поскольку другие инструменты проектирования понимают формат XAML. Например, изобретательный дизайнер может использовать такой инструмент, как Expression Design, чтобы настроить графику для приложения WPF, или же инструмент вроде Expression Blend, чтобы построить для него изощренную анимацию. По окончании чтения этой главы имеет смысл ознакомиться с официальным документом от Microsoft, доступным по адресу http://windowsclient.net/wpf/white-papers/ thenewiteration.aspx, в котором предлагается обзор XAML, и объясняются некоторые способы кооперации разработчиков и дизайнеров при построении приложения WPF. Совет. XAML играет ту же роль для приложений Windows, что управляющие дескрипторы для веб- приложений ASP.NET. Отличие состоит в том, что синтаксис дескрипторов ASP.NET задуман похожим на HTML, так что дизайнеры могут создавать веб-страницы, используя обычные приложения для веб-дизайна, такие как FrontPage и Dreamweaver. Как и в WPF, сам код веб-страницы ASP.NET обычно размещается в отдельном файле, облегчая проектирование Разновидности XAML Существует несколько разных способов использования термина XAML. До сих пор он применялся для ссылки на весь язык XAML, предлагающий основанный на XML синтаксис для представления дерева объектов .NET. (Эти объекты могут быть кнопками и текстовыми полями в окне, а также специальным, определенным вами классом. Фактически XAML даже может использоваться на других платформах, чтобы представлять объекты, не имеющие отношения к .NET.) Существует несколько подмножеств XAML. • WPF XAML включает элементы, описывающие содержимое WPF, такое как векторная графика, элементы управления и документы. В настоящее время это наиболее важное применение XAML, и именно это его подмножество будет рассматриваться в настоящей книге. • XPS XAML — часть WPF XAML, определяющая XML-представление форматированных электронных документов. Она опубликована как отдельный стандарт XML Paper Specification (XPS). Вы узнаете о XPS в главе 28. • Silverlight XAML — подмножество WPF XAML, предназначенное для Silverlight- приложений. Silverlight — это межплатформенный браузерный подключаемый модуль, который позволяет создавать расширенное веб-содержимое с двумерной графикой, анимацией, аудио и видео. Дополнительная информация о Silverlight была дана в главе 1. Можно также посетить сайт http://silverlight.net. • WF XAML включает элементы, описывающие содержимое Windows Workflow Foundation (WF). Дополнительная информация о WF доступна на сайте http:// msdn.microsoft.com/ru-ru/netframework/aa663328.aspx. Компиляция XAML Создатели WPF знали, что XAML не только нужен для решения проблемы совместного проектирования, он также должен быть быстрым. И хотя такие основанные на XML
Глава 2. XAML 49 форматы, как XAML, гибки и легко переносимы на другие инструменты и платформы, они не всегда являются наиболее эффективным выбором. XML задуман как непротиворечивый, читабельный и прямолинейный, но не компактный формат В WPF этот недостаток преодолен посредством BAML (Binary Application Markup Language — двоичный язык разметки приложений). BAML — это не что иное, как двоичное представление XAML. Когда вы компилируете приложение WPF в Visual Studio, все файлы XAML преобразуются в код BAML, и этот код BAML затем встраивается в виде ресурса в финальную сборку DLL или ЕХЕ. Язык BAML поддерживает лексемъи а это значит, что длинные фрагменты XAML заменены короткими лексемами. И код BAML не только существенно меньше, но он также оптимизирован, чтобы быстрее интерпретироваться во время выполнения. Большинству разработчиков не приходится беспокоиться о преобразовании XAML в BAML, потому что компилятор это делает "за кулисами". Однако можно использовать XAML без предварительной компиляции. Это может иметь смысл в сценариях, когда часть пользовательского интерфейса должна быть применена прямо во время выполнения (например, извлечена из базы данных в виде блока дескрипторов XAML). В разделе "Загрузка и компиляция XAML' далее в главе будет показано, как это работает. Создание XAML в Visual Studio В этой главе мы рассмотрим детали разметки XAML. Разумеется, при проектировании приложения вручную писать код XAML не придется. Вместо этого с помощью инструмента, подобного Visual Studio, создается нужное окно методом перетаскивания. Потому столь подробное изучение синтаксиса XAML может показаться излишним. Тем не менее, это, безусловно, необходимо. Понимание XAML чрезвычайно важно для проектирования приложений WPF. Это поможет разобраться в ключевых концепциях WPF, таких как присоединенные свойства (настоящая глава), компоновка (глава 3), маршрутизируемые события (глава 4), модель содержимого (глава 6) и т.д. Что более важно — существует целый ряд задач, решение которых возможно только с помощью вручную написанного Kon.a~XAML либо в этом случае оно существенно облегчается. Ниже перечислены примеры таких задач. • Привязка обработчиков событий. Присоединение обработчиков событий в наиболее распространенных местах — например, к событию Click для Button — легко сделать в Visual Studio. Однако, однажды поняв, как события привязываются в XAML, можно создавать более изощренные соединения. Например, можно установить обработчик событий, реагирующий на событие Click каждой кнопки окна. Более подробно эта технология рассматривается в главе 5. • Написание выражений привязки данных. Привязка данных позволяет извлекать данные из объекта и отображать их в привязанном элементе. Чтобы установить это отношение и сконфигурировать его работу, в код разметки XAML понадобится добавить выражение привязки данных. Привязка данных рассматривается в главе 8. • Определение ресурсов. Ресурсы — это объекты, которые определяются в специальном разделе кода XAML, а затем многократно используются в разных местах кода разметки. Ресурсы позволяют централизовать и стандартизировать форматирование и создание невизуальных объектов, таких как шаблоны и анимации. Создание и использование ресурсов будет описано в главе 10. • Определение анимации. Анимация — распространенный ингредиент приложений XAML. Обычно они определяются в виде ресурсов, конструируются с использованием разметки XAML, а затем привязываются к другим элементам управления (либо инициируются в коде). В настоящее время в Visual Studio не предусмотрена поддержка создания анимации во время проектирования. Анимация рассматривается в главе 15. • Определение шаблонов элементов управления. Элементы управления WPF проектируются как лишенные внешнего вида; это значит, что вместо стандартных визуальных представлений
50 Глава 2. XAML можно подставлять собственные. Чтобы сделать это, понадобится создать собственный шаблон элемента управления, который представляет собой не что иное, как блок разметки XAML. Шаблоны элементов управления описаны в главе 17. Большинство разработчиков WPF используют комбинацию приемов, разрабатывая часть пользовательского интерфейса с помощью инструмента проектирования (Visual Studio или Expression Blend), а затем проводя тонкую настройку за счет ручного редактирования кода разметки. В главе 3 рассматриваются контейнеры компоновки, которые удобнее всего использовать для правильного размещения множества элементов управления в окне. Основы XAML Стандарт XAML достаточно очевиден, если понять несколько его основополагающих правил. • Каждый элемент в документе XAML отображается на экземпляр класса .NET. Имя элемента в точности соответствует имени класса. Например, элемент <Button> сообщает WPF, что должен быть создан объект Button. • Как и любой XML-документ, код XAML допускает вложение одного элемента внутрь другого. Как будет показано, XAML предоставляет каждому классу гибкость в принятии решения относительно того, как справиться с такой ситуацией. Однако вложение обычно является способом выразить включение (containment). Другими словами, если вы видите элемент Button внутри элемента Grid, то пользовательский интерфейс, возможно, включает Grid, содержащий внутри себя Button. • Свойства каждого класса можно устанавливать через атрибуты. Тем не менее, в некоторых ситуациях атрибуты не достаточно мощны, чтобы справиться с этой работой. В этих случаях понадобятся вложенные дескрипторы со специальным синтаксисом. Совет. Если вы — полный новичок в XML, то лучше изучить его основы и только затем заняться XAML. Для быстрого ознакомления с XML можно обратиться к бесплатному веб-руководству по адресу http://www.w3schools.com/xml. Прежде чем продолжить, взгляните на следующий простейший документ XAML, представляющий новое пустое окно (как оно создано в Visual Studio). Строки пронумерованы для облегчения ссылок на них. 1 <Window x:Class="WindowsApplicationl.Windowl" 2 xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" 3 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 4 Title="Windowl" Height=00" Width=00"> 5 6 <Grid> 7 </Grid> 8 </Window> Этот документ содержит всего два элемента — элемент верхнего уровня Window, который представляет все окно, и элемент Grid, куда можно поместить свои элементы управления. Хотя можно использовать любой элемент верхнего уровня, приложение WPF полагается только на несколько из них: • Window; • Page (похож на Window, но используется для приложений с возможностями навигации); • Application (определяет ресурсы приложения и начальные установки).
Глава 2. XAML 51 Как и во всех документах XML, может существовать только один элемент верхнего уровня. В предыдущем примере это означает, что закрытие элемента Window дескриптором </Window> завершает документ. Никакое дополнительное содержимое уже не допускается. Если вы посмотрите на открывающий дескриптор элемента Window, то найдете там несколько интересных атрибутов, в том числе имя класса и два пространства имен XML (рассматриваются в последующих разделах). Также вы обнаружите три свойства, показанные ниже: 4 Title="Windowl" Height=00" Width=00"> Каждый атрибут соответствует отдельному свойству в классе Window. В конечном итоге это инструктирует WPF о необходимости создать окно с заголовком Windowl размером 300x300 единиц. На заметку! Как известно из главы 1, в WPF используется относительная система измерения, которая не похожа на то, чего ожидает большинство разработчиков Windows. Вместо того чтобы позволить задавать размеры в физических пикселях, в WPF применяются независимые от устройства единицы, которые могут масштабироваться для заполнения разных разрешений монитора, и определены как 1/96 часть дюйма. Это значит, что окно размером 300x300 единиц из предыдущего примера будет визуализировано в виде окна 300x300 пикселей, если системная установка DPI составляет стандартные 96 dpi. Однако в системах с более высоким значением системного DPI будет использовано больше пикселей. Подробности были даны в главе 1. Пространства имен XAML Ясно, что не достаточно просто указать имя класса. Анализатору XAML также нужно знать пространство имен .NET, где находится этот класс. Например, класс Window может существовать в нескольких пространствах имен — он может ссылаться на класс System.Windows.Window, на класс в компоненте от независимого разработчика или же на класс, определенный в вашем приложении. Чтобы определить, какой именно класс нужен на самом деле, анализатор XAML проверяет пространство имен XML, к которому относится элемент. Вот как это работает. В примере документа, показанном ранее, определено два пространства имен: 2 xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" 3 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" На заметку! Пространства имен объявляются с помощью атрибутов. Эти атрибуты могут помещаться внутрь начального дескриптора любого элемента. Однако согласно принятым соглашениям все пространства имен, которые нужно использовать в документе, должны быть объявлены в самом первом дескрипторе, как это сделано в данном примере Как только пространство имен объявлено, оно может использоваться в любом месте документа. xmlns — это специализированный атрибут в мире XML, который зарезервирован для объявления пространств имен. В показанном выше фрагменте кода разметки объявлены два пространства имен, которые будут присутствовать в каждом создаваемом документе WPF XAML. • http://schemas.microsoft.com/winfx/2006/xaml/presentation — основное пространство имен WFP. Оно охватывает все классы WPF, включая элементы управления, которые применяются для построения пользовательских интерфейсов. В рассматриваемом примере это пространство имен объявлено без префикса
52 Глава 2. XAML пространства имен, поэтому становится пространством имен по умолчанию для всего документа. Другими словами, каждый элемент автоматически помещается в это пространство имен, если только не указано иное. • http://schemas.microsoft.com/winfx/2006/xaml — пространство имен XAML. Оно включает различные служебные свойства XAML, которые позволяют влиять на то, как интерпретируется документ. Данное пространство имен отображается на префикс х. Это значит, что его можно применять, помещая префикс пространства имен перед именем элемента (как в <х:ИмяЭлемента>). Как видите, пространство имен XML не соответствует какому-либо конкретному пространству имен .NET. Существует несколько причин, по которым создатели XML выбрали такое проектное решение. По существующему соглашению пространства имен XML часто имеют форму URI (как и в данном примере). Эти URI выглядят так, будто указывают на некоторое место в Интернете, хотя на самом деле это не так. Формат URI используется потому, что он делает маловероятным ситуацию, когда разные организации непреднамеренно создадут разные языки на базе XML с одинаковым пространством имен. Поскольку домен schemas.microsoft.com принадлежит Microsoft, только Microsoft использует его в названии пространства имен XML. Другая причина отсутствия отображения "один к одному" между пространствами имен XML, используемыми в XAML, и пространствами имен .NET заключается в том, что это могло бы значительно усложнить документы XAML. Проблема в том, что WPF содержит свыше десятка пространств имен (все начинаются с System.Windows). Если бы каждое пространство имен .NET отображалось на отдельное пространство имен XML, пришлось бы указывать корректное пространство имен для каждого используемого элемента управления, что быстро привело бы к путанице. Вместо этого создатели WPF предпочли скомбинировать все эти пространства имен .NET в единое пространство имен XML. Это работает, потому что внутри разных пространств имен .NET, образующих часть WPF, нет классов с одинаковыми именами. Информация пространства имен позволяет анализатору XAML находить правильный класс. Например, когда он просматривает элементы Window и Grid, то видит, что они помещены в пространство имен WPF по умолчанию. Затем он ищет соответствующие пространства имен .NET — до тех пор, пока не находит System.Windows.Window и System.Windows.Controls.Grid. Класс отделенного кода Язык XAML позволяет конструировать пользовательский интерфейс, но для создания функционирующего приложения необходим способ подключения обработчиков событий. XAML позволяет легко это сделать с помощью атрибута Class, показанного ниже: 1 <Window x:Class="WindowsApplicationl.Windowl" Префикс пространства имен х помещает атрибут Class в пространство имен XAML, что означает более общую часть языка XAML. Фактически атрибут Class сообщает анализатору XAML, чтобы он сгенерировал новый класс с указанным именем. Этот класс наследуется от класса, именованного элементом XML. Другими словами, этот пример создает новый класс по имени Windowl, который наследуется от базового класса Window. Класс Windowl генерируется автоматически во время компиляции. И здесь начинается самое интересное. Вы можете предоставить часть класса Windowl, которая будет объединена с автоматически сгенерированной частью этого класса. Указанная вами часть — блестящий контейнер для кода обработки событий.
Глава 2. XAML 53 На заметку! Эта "магия" возможна благодаря средству С#, известному под названием частичные классы (partial class). Частичные классы позволяют разделить класс на две или более отдельных части во время разработки, которые соединяются вместе в скомпилированной сборке. Частичные классы могут применяться во многих сценариях управления кодом, но более всего удобны, когда код должен объединяться с файлом, сгенерированным визуальным конструктором. Среда Visual Studio оказывает помощь, автоматически создавая частичный класс, куда можно поместить код обработки событий. Например, при создании приложения по имени WindowsApplicationl, содержащего окно по имени Windowl (как в предыдущем примере), Visual Studio начнет с создания следующего базового каркаса класса: namespace WindowsApplicationl { /// <summary> /// Логика взаимодействия для Windowl.xaml /// </summary> public partial class Windowl : Window { public Windowl() { InitializeComponent() ; } } } Во время компиляции приложения код XAML, определяющий пользовательский интерфейс (такой как Windowl.xaml), транслируется в объявление типа CLR, объединенного с логикой файла класса отделенного кода (подобного Windowl.xaml.cs), формируя один общий модуль. Метод InitializeComponent () В данный момент класс Windowl не содержит реальной функциональности. Однако он включает одну важную деталь — конструктор по умолчанию, который вызывает метод InitializeComponent (), когда создается экземпляр класса. На заметку! Метод InitializeComponent () играет ключевую роль в приложениях WPF. По этой причине никогда не следует удалять вызов InitializeComponent () из конструктора окна. В случае добавления к классу окна другого конструктора обязательно предусмотрите в нем вызов InitializeComponent (). Метод InitializeComponentO не видим в исходном коде, потому что генерируется автоматически при компиляции приложения. По существу все, что делает InitializeComponentO — это вызов метода LoadComponent () класса System. Windows.Application. Метод LoadComponent() извлекает код BAML (скомпилированный XAML) из сборки и использует его для построения пользовательского интерфейса. При разборе BAML он создает объекты каждого элемента управления, устанавливает их свойства и присоединяет все обработчики событий. На заметку! Если вам не терпится, загляните в конец главы. В разделе "Код и скомпилированный XAML" приведен код автоматически сгенерированного метода InitializeComponentO.
54 Глава 2. XAML Именование элементов Есть еще одна деталь, которая должна приниматься во внимание. В классе отделенного кода часто требуется программно манипулировать элементами управления. Например, необходимо читать либо изменять свойства, а также присоединять или отсоединять обработчики событий на лету. Чтобы обеспечить такую возможность, элемент управления должен включать XAML-атрибут Name. В предыдущем примере элемент Grid не содержит атрибут Name, поэтому манипулировать им в отделенном коде не получится. Ниже показано, как назначить имя элементу Grid: 6 <Grid x:Name="gridl"> 7 </Grid> Можно внести это изменение в документ XAML вручную или выбрать элемент в визуальном конструкторе Visual Studio и установить свойство Name в окне Properties (Свойства). 8 обоих случаях атрибут Name сообщит анализатору XAML о необходимости добавить поле следующего вида к автоматически сгенерированной части класса Windowl: private System.Windows.Controls.Grid gridl; Теперь с этим элементом можно взаимодействовать в коде класса Windowl указывая имя gridl: MessageBox.Show(String.Format("The grid is {0}x{l} units in size.", gridl.ActualWidth, gridl.ActualHeight)); Такая техника мало, что дает простому примеру, но становится намного важнее, когда требуется читать значения из элементов управления вводом, таких как текстовые поля и списки. Показанное ранее свойство Name является частью языка XAML и используется для того, чтобы помочь в интеграции класса отделенного кода. Из-за того, что многие классы определяют собственное свойство Name, происходит некоторая путаница. (Примером может служить базовый класс FrameworkElement, от которого наследуются все элементы WPF.) Анализаторы XAML элегантно справляются с этой проблемой. Можно либо установить XAML-свойство Name (используя префикс х:), либо свойство Name, относящееся к действительному элементу (опустив префикс). В любом случае результат один и тот же — указанное имя используется в файле автоматически сгенерированного кода и применяется для установки свойства Name. Это значит, что следующая разметка эквивалентна тому, что вы уже видели: <Grid Name="gridl"> </Grid> Такой трюк работает только в том случае, если включающий свойство Name класс оснащен атрибутом RuntimeNameProperty. Атрибут RuntimeNameProperty указывает на то, какое свойство должно трактоваться в качестве имени экземпляра этого типа. (Очевидно, обычно таким свойством является Name.) Класс FrameworkElement содержит атрибут RuntimeNameProperty, так что никаких проблем нет. Совет. В традиционном приложении Windows Forms каждый элемент управления имеет имя. В приложении WPF такого требования нет. Однако при создании окна перетаскиванием элементов на поверхность визуального конструктора Visual Studio каждому элементу назначается автоматически сгенерированное имя. Таково соглашение. Если вы не собираетесь взаимодействовать с элементом в коде, то можете удалить атрибут Name из кода разметки. В примерах, предлагаемых в настоящей книге, имена элементов обычно опускаются, если они не нужны, что сокращает код разметки.
Глава 2. XAML 55 Теперь у вас должно сформироваться базовое понимание того, как интерпретировать документ XAML, определяющий окно, и как документ XAML преобразуется в конечный скомпилированный класс (с добавлением любого написанного вами кода). В следующем разделе рассматривается синтаксис свойств более подробно, и будет показано, как привязывать к элементам обработчики событий. Свойства и события в XAML До сих пор мы рассматривали не слишком впечатляющий пример — пустое окно, содержащее пустой элемент управления Grid. Прежде чем двигаться дальше, стоит представить более реалистичное окно, включающее несколько элементов управления. На рис. 2.1 показан пример с автоответчиком на вопросы пользователя. Рис. 2.1. Спросите автоответчик и все узнаете Окно автоответчика включает четыре элемента управления: Grid (чаще всего используемый для организации компоновки в WPF), два объекта Text Box и один Button. Разметка, которая необходима для компоновки и конфигурирования этих элементов управления, существенно длиннее, чем в предыдущих примерах. Ниже приведен сокращенный листинг, в котором некоторые детали заменены многоточиями, чтобы продемонстрировать общую структуру. <Window x:Class="EightBall.Windowl" xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2 00 6/xaml" Title="Eight Ball Answer" Height=28" Width=12"> <Grid Name="gridl"> <Grid.Background> </Grid.Background> <Grid.RowDefinitions> </Grid.RowDefinitions> <TextBox Name="txtQuestion" ... > </TextBox> <Button Name="cmdAnswer" ... > </Button>
56 Глава 2. XAML <TextBox Name="txtAnswer" ... > </TextBox> </Grid> </Window> В следующем разделе мы рассмотрим части этого документа и по ходу дела изучим синтаксис XAML. На заметку! XAML не ограничен классами, входящими в WPR Код XAML можно применять для создания экземпляра любого класса, который подчиняется нескольким основным правилам. Об использовании собственных классов в XAML речь пойдет далее в этой главе. Простые свойства и конвертеры типов Как вы уже видели, атрибуты элемента устанавливают свойства соответствующего объекта. Например, текстовые поля в примере автоответчика конфигурируют выравнивание, поля и шрифт: <TextBox Name="txtQuestion" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" FontFamily="Verdana" FontSize=4" Foreground="Green" ... > Чтобы это заработало, класс System.Windows.Controls.TextBox должен предоставить следующие свойства: VerticalAlignment, HorizontalAlignment, FontFamily, FontSize и Foreground. В последующих главах вы ознакомитесь со специфическим значением этих свойств. Чтобы заставить эту систему работать, анализатор XAML должен выполнить больше работы, чем может показаться на первый взгляд. Значение в атрибуте XAML всегда представлено простой строкой. Однако свойства объекта могут быть любого типа .NET. В предыдущем примере было два свойства, использующих перечисления (VerticalAlignment и HorizontalAlignment), одна строка (FontFamily), одно целое число (FontSize) и один объект Brush (Foreground). Чтобы преодолеть зазор между строковыми значениями и не строковыми свойствами, анализатор XAML должен выполнить преобразование. Это преобразование осуществляется конвертерами типов — базовой частью инфраструктуры .NET, которая существует еще со времен .NET 1.0. По сути, конвертер типов играет только одну роль — он предоставляет служебные методы, которые могут преобразовывать определенный тип данных .NET в любой другой тип .NET, такой как строку в данном случае. При поиске нужного конвертера типа анализатор XAML предпринимает следующие два действия. 1. Проверяет объявление свойства в поисках атрибута TypeConverter. (Если атрибут TypeConverter присутствует, он указывает класс, выполняющий преобразование.) Например, когда используется такое свойство, как Foreground, .NET проверяет объявление свойства Foreground. 2. Если в объявлении свойства отсутствует атрибут TypeConverter, то анализатор XAML проверяет объявление класса соответствующего типа данных. Например, свойство Foreground использует объект Brush. Класс Brush (и его наследники) используют BrushConverter, потому что класс Brush оснащен объявлением атрибута TypeConverter (typeof (Br us hConverter)). Если в объявлении свойства или объявлении класса не оказывается ассоциированного конвертера типа, то анализатор XAML генерирует ошибку.
Глава 2. XAML 57 Эта система проста и гибка. Если вы устанавливаете конвертер типа на уровне класса, то этот конвертер применяется к каждому свойству, использующему этот класс. С другой стороны, если вы хотите обеспечить тонкую настройку работы конвертера типа для конкретного свойства, то вместо этого можете применять атрибут TypeConverter объявления свойства. Формально возможно использовать конвертеры типов в коде, но синтаксис при этом несколько мудреный. Почти всегда лучше непосредственно установить свойство — это не только быстрее, но также позволяет избежать потенциальных ошибок от опечаток в строках, которые не проявляются до момента выполнения. (Эта проблема не затрагивает XAML, поскольку разметка XAML анализируется и проверяется во время компиляции.) Конечно, прежде чем можно будет устанавливать свойства в элементе WPF, необходимо узнать немного больше о базовых свойствах WPF и типах данных. Именно этому и посвящены несколько последующих глав. На заметку! XAML, как и все языки на базе XML, является чувствительным к регистру символов. Это значит, что нельзя подставлять <button> вместо <Button>. Однако конвертеры типов обычно не чувствительны к регистру, а потому Foreground="White" и Foreground="white" дают одинаковый результат. Сложные свойства Как бы ни были удобны конвертеры типов, они подходят не для всех сценариев. Например, некоторые свойства являются полноценными объектами с собственными наборами свойств. Хотя можно создать строковое представление, которое будет использовать конвертер типа, этот синтаксис может оказаться трудным в применении, к тому же он подвержен ошибкам. К счастью, XAML предусматривает другой выбор: синтаксис "свойство-элемент*'. С помощью этого синтаксиса можно добавлять дочерний элемент с именем в форме РодительскийЭлемент.ИмяСвойства. Например, у Grid имеется свойство Background, которое позволяет указывать кисть, используемую для рисования области, находящейся под элементами управления. Чтобы применить сложную кисть — более совершенную, чем сплошное заполнение цветом, — понадобится добавить дочерний дескриптор по имени Grid.Background, как показано ниже: <Grid Name="gridl"> <Grid.Background> </Grid.Васkground> </Grid> Ключевая деталь, которая заставляет это работать — точка (.) в имени элемента. Это отличает свойства от других типов и вложенного содержимого. Однако еще один вопрос остается: как установить сложное свойство после его идентификации? Трюк заключается в следующем. Внутрь вложенного элемента можно добавить другой дескриптор, чтобы создать экземпляр определенного класса. В примере с автоответчиком (см. рис. 2.1) для фона применяется градиентная заливка. Чтобы определить нужный градиент, понадобится создать объект LinearGradientBrush. Согласно правилам XAML, можно создать объект LinearGradientBrush, используя элемент по имени LinearGradientBrush: <Grid Name="gridl"> <Grid.Background> <LinearGradientBrush>
58 Глава 2. XAML </LinearGradientBrush> </Grid.Васkground> </Grid> LinearGradientBrush является частью набора пространств имен WPF, так что для дескрипторов можно применять пространство имен XML по умолчанию. Однако просто создать LinearGradientBrush недостаточно; нужно также указать цвета градиента. Это делается заполнением свойства LinearGradientBrush. GradientStops коллекцией объектов GradientStop. Опять-таки, свойство GradientStops слишком сложное, чтобы его можно было установить только одним значением атрибута. Вместо этого следует положиться на синтаксис "свойство-элемент": <Grid Name="gridl"> <Grid.Background> <LinearGradientBrush> <LinearGradientBrush.GradientStops> </LinearGradientBrush.GradientStops> </LinearGradientBrush> </Grid.Васkground> </Grid> И, наконец, можно заполнить коллекцию GradientStops серией объектов GradientStop. Каждый объект GradientStop имеет свойства Offset и Color. Указать эти два значения можно с помощью обычного синтаксиса "свойство-элемент": <Grid Name="gridl"> <Gnd. Background> <LinearGradientBrush> <LinearGradientBrush.GradientStops> <GradientStop Offset=.00" Color="Red" /> <GradientStop Offset=.50" Color="Indigo" /> <GradientStop Offset="l.00" Color="Violet" /> </LinearGradientBrush.GradientStops> </LinearGradientBrush> </Grid.Васkground> </Grid> На заметку! Синтаксис "свойство-элемент" можно использовать для любого свойства. Однако если свойство имеет подходящий конвертер типа, обычно будет применяться более простой подход "свойство-атрибут". Это дает более компактный код. Любой набор дескрипторов XAML может быть заменен набором операторов кода, решающих ту же задачу. Показанные ранее дескрипторы, которые заполняют фон необходимым градиентом, эквивалентны следующему коду: LinearGradientBrush brush = new LinearGradientBrush(); GradientStop gradientStopl = new GradientStop(); gradientStopl.Offset = 0; gradientStopl.Color = Colors.Red; brush.GradientStops.Add(gradientStopl); GradientStop gradientStop2 = new GradientStop (); gradientStop2.Offset = 0.5; gradientStop2.Color = Colors.Indigo; brush.GradientStops.Add(gradientStop2);
Глава 2. XAML 59 GradientStop gradientStop3 = new GradientStop(); gradientStop3.Offset = 1; gradientStop3.Color = Colors.Violet; brush.GradientStops.Add(gradientStop3); gridl.Background = brush; Расширения разметки Для большинства свойств синтаксис свойств XAML работает исключительно хорошо. Но в некоторых случаях просто невозможно жестко закодировать значение свойства. Например, значение свойства должно быть установлено в уже существующий объект. Или может понадобиться установить значение свойства динамически, привязывая его к свойству в другом элементе управления. В обоих таких случаях необходимо использовать расширение разметки (markup extension) — специализированный синтаксис, устанавливающий свойство нестандартным образом. Расширения разметки могут применяться во вложенных дескрипторах или атрибутах XML, что встречается чаще. Когда они используются в атрибутах, то всегда окружаются фигурными скобками {}. Например, ниже показано, как можно использовать расширение разметки StaticExtension, позволяющее ссылаться на статическое свойство другого класса: <Button ... Foreground="{x:Static SystemColors.ActiveCaptionBrush}" > Расширения разметки используют синтаксис {КлассРасширенияРазметки Аргумент}. В этом случае расширением разметки служит класс StaticExtension. (По соглашению при ссылке на класс расширения последнее слово Extension можете опустить.) Префикс х указывает на то, что StaticExtension находится в одном из пространств имен XAML. Также вы встретите расширения разметки, являющиеся частью пространств имен WPF, но не имеющие префикса х. Все расширения разметки реализованы классами, производными от System. Windows .Markup.MarkupExtension. Базовый класс MarkupExtension чрезвычайно прост — он включает единственный метод ProvideValue (), получающий необходимое значение. Другими словами, когда анализатор XAML встречает предыдущий оператор, он создает экземпляр класса StaticExtension (передавая строку "SystemColors.ActiveCaptionBrush" в качестве аргумента конструктора), а затем вызывает ProvideValue(), чтобы получить объект, возвращенный статическим свойством SystemColors.ActiveCaption.Brush. Свойство Foreground кнопки cmdAnswer затем устанавливается равным извлеченному объекту. Конечный результат этого фрагмента XAML-разметки эквивалентен следующему коду: cmdAnswer.Foreground = SystemColors.ActiveCaptionBrush; Поскольку расширения разметки отображаются на классы, они могут также применяться в виде вложенных свойств, как уже известно из предыдущего раздела. Например, StaticExtension можно использовать со свойством Button.Foreground следующим образом: <Button ... > <Button.Foreground> <х:Static Member="SystemColors.ActiveCaptionBrush"></x:Static> </Button.Foreground> </Button> В зависимости от сложности расширения разметки и количества свойств, которые требуется установить, иногда синтаксис будет проще.
60 Глава 2. XAML Как и большинство расширений разметки, расширение StaticExtension должно вычисляться во время выполнения, потому что только тогда можно будет определить текущие системные цвета. Некоторые расширения разметки могут определяться во время компиляции. К ним относятся NullExtension (представляющее значение null) и TypeExtension (конструирующее объект, который представляет тип .NET). На протяжении этой книги будет приведено множество примеров расширений разметки в действии, в частности, когда пойдет речь о ресурсах и привязке данных. Присоединенные свойства Наряду с обычными свойствами XAML также включает концепцию присоединенных свойств (attached property) — свойств, которые могут применяться к нескольким элементам управления, но определены в другом классе. В WPF присоединенные свойства часто используются для управления компоновкой. Рассмотрим, как это работает. Каждый элемент управления обладает собственным набором внутренних свойств. (Например, текстовое поле имеет специфический шрифт, цвет текста и текстовое содержимое — все это определено свойствами FontFamily, Foreground и Text.) После помещения внутрь контейнера элемент управления получает дополнительные свойства, которые зависят от типа контейнера. (Например, если текстовое поле помещается внутрь экранной сетки, то нужно каким-то образом указать ячейку для помещения.) Эти дополнительные детали устанавливаются с использованием присоединенных свойств. Присоединенные свойства всегда имеют имя, состоящее из двух частей, в форме ОпределяемыйТип.ИмяСвойства. Этот синтаксис позволяет анализатору XAML отличать нормальные свойства от присоединенных. В примере с автоответчиком присоединенные свойства позволяют индивидуальным элементам управления размещать себя в разных строках (невидимой) сетки. <TextBox ... Grid.Row="> [Place question here.] </TextBox> <Button ... Grid.Row="> Ask the Eight Ball </Button> <TextBox ... Grid.Row="> [Answer will appear here.] </TextBox> Присоединенные свойства в действительности вообще свойствами не являются. На самом деле они транслируются в вызовы методов. Анализатор XAML вызывает статический метод, имеющий форму ОпределяемыйТип.SetИмяСвойства(). Например, в предыдущем фрагменте XAML определяемым типом является класс Grid, а свойством — Row, поэтому анализатор вызывает метод Grid.SetRow(). При вызове метода $^ИмяСвойства{) анализатор передает два параметра: модифицируемый объект и указанное значение свойства. Например, в случае установки свойства Grid.Row на элементе управления TextBox анализатор XAML выполняет следующий код: Gnd.SetRow(txtQuestion, 0); Этот шаблон (с вызовом статического метода определенного типа) удобен тем, что скрывает то, что происходит на самом деле. На первый взгляд этот код выглядит так, будто номер строки сохраняется в объекте Grid. Однако номер строки в действительности сохраняется в объекте, которого он касается, в данном случае — TextBox.
Глава 2. XAML 61 Этот фокус удается потому, что Text Box унаследован от базового класса Dependency Object, как и все элементы управления WPF. И, как вы узнаете в главе 4, класс Dependency Object предназначен для хранения практически неограниченной коллекции свойств зависимости. (Присоединенные свойства, о которых говорилось ранее — это специальный тип свойства зависимости.) Фактически метод Grid.SetRow() — это на самом деле сокращение, эквивалентное вызову метода DependencyObject.SetValue(): txtQuestion.SetValue(Grid.RowProperty, 0) ; Присоединенные свойства — центральный ингредиент WPF. Они действуют как система расширения общего назначения. Например, определяя свойство Row как присоединенное, вы гарантируете его применимость с любым элементом управления. Другой вариант — сделать его частью базового класса, такого как FrameworkElement, однако это усложнит жизнь. Это не только засорит общедоступный интерфейс свойствами, которые понадобятся только при определенных условиях (в данном случае — когда элемент используется внутри Grid), но также сделает невозможным добавление новых типов контейнеров, которые потребуют новых свойств. На заметку! Присоединенные свойства очень похожи на поставщики расширений в приложении Windows Forms. И те, и другие позволяют добавлять "виртуальные" свойства для расширения другого класса. Отличие в том, что вы должны создавать экземпляр поставщика расширений перед тем, как сможете использовать его, и значение расширенного свойства сохраняется в поставщике расширений, а не в расширяемом элементе управления. Механизм присоединенных свойств — наилучший выбор для WPF, потому что позволяет избежать проблем управления жизненным циклом (например, решая, когда следует уничтожать поставщика расширений). Вложенные элементы Как уже было показано, документы XAML представляют собой дерево элементов с высокой степенью вложенности. В рассмотренном примере элемент Window содержит элемент Grid, который, в свою очередь, содержит элементы TextBox и Button. XAML позволяет каждому элементу решать, как ему следует поступать с вложенными элементами. Это взаимодействие осуществляется при посредничестве одного из трех механизмов, запускаемых в описанном ниже порядке. • Если родительский элемент реализует интерфейс IList, анализатор вызывает ILi s t. Add (), передавая ему дочерний элемент. • Если родительский элемент реализует интерфейс IDictionary, анализатор вызывает I Dictionary. Add () и передает ему дочерний элемент. При использовании коллекции-словаря понадобится также устанавливать атрибут х:Кеу, чтобы назначить ключевое имя каждому элементу. • Если родительский элемент оснащен атрибутом ContentProperty, анализатор использует дочерний элемент, чтобы установить это свойство. Например, ранее в этой главе уже было показано, что LinearGradientBrush может содержать коллекцию объектов GradientStop, используя синтаксис вроде следующего: <LinearGradientBrush> <LinearGradientBrush.GradientStops> <GradientStop Offset=.00" Color=MRed" /> <GradientStop Offset=.50" Color=MIndigo" /> <GradientStop Of f set="l. 00" Color=,,Violet" /> </LinearGradientBrush.GradientStops> </LinearGradientBrush>
62 Глава 2. XAML Анализатор XAML распознает элемент LinearGradientBrush.GradientStops как сложное свойство, потому что оно включает точку. Однако ему нужно обработать внутренние дескрипторы (три элемента Gradient Stop) слегка по-другому. В этом случае анализатор распознает, что свойство GradientStops возвращает объект GradientStopCollection, a GradientStopCollection реализует интерфейс IList. Поэтому он предполагает (грубо), что каждый GradientStop должен быть добавлен к коллекции с помощью метода IList.AddO: GradientStop gradientStopl = new GradientStop(); gradientStopl.Offset = 0; gradientStopl.Color = Colors.Red; IList list = brush.GradientStops; list.Add(gradientStopl); Некоторые свойства могут поддерживать более одного типа коллекций. В этом случае вы должны добавить дескриптор, указывающий класс коллекции: <LinearGradientBrush> <LinearGradientBrush.GradientStops> <GradientStopCollection> <GradientStop Of f set= . 00" Color=,,Red" /> <GradientStop Offset=.50" Color="Indigo" /> <GradientStop Offset="l.00" Color="Violet" /> </GradientStopCollection> </LinearGradientBrush.GradientStops> </LinearGradientBrush> На заметку! Если по умолчанию коллекция установлена в null, понадобится включить дескриптор, указывающий класс коллекции, что обеспечит создание объекта коллекции. Если имеется экземпляр коллекции по умолчанию, который нужно просто заполнить, эту часть можно опустить Вложенное содержимое не всегда обозначает коллекцию. Например, рассмотрим элемент Grid, который содержит несколько других элементов управления: <Grid Name="gridl"> <TextBox Name="txtQuestion" . . . > </TextBox> <Button Name="cmdAnswer" . . . > </Button> <TextBox Name="txtAnswer" . . . > </TextBox> </Grid> Эти вложенные дескрипторы не соответствуют сложным свойствам, поскольку не включают точки. Более того, элемент Grid не является коллекцией и потому не реализует IList или IDictionary. Что Grid действительно поддерживает— так это атрибут ContentProperty, указывающий свойство, которое должно принимать любое вложенное содержимое. Технически атрибут ContentProperty применяется к классу Panel, от которого унаследован Grid, и выглядит он так: [ContentPropertyAttribute("Children") ] public abstract class Panel Это указывает на то, что для установки свойства Children должны применяться любые вложенные элементы. Анализатор XAML трактует свойство содержимого по-раз-
Глава 2. XAML 63 ному в зависимости от того, является ли оно свойством-коллекцией (и в этом случае реализует интерфейс IList или IDictionary). Так как свойство Panel.Children возвращает UIElementCollection, и поскольку UIElementCollection реализует IList, анализатор использует метод IList. Add О для добавления вложенного содержимого в сетку. Другими словами, когда анализатор XAML встречает приведенную выше разметку, он создает экземпляр каждого элемента и передает его Grid с помощью метода Grid. Children. Add(): txtQuestion = new TextBoxO ; gridl.Children.Add(txtQuestion); cmdAnswer = new Button (); gridl.Children.Add(cmdAnswer); txtAnswer = new TextBoxO ; gridl.Children.Add(txtAnswer); Что происходит дальше, полностью зависит от того, как элемент управления реализует свойство содержимого. Grid отображает все включенные в него элементы управления в невидимой компоновке строк и колонок, как будет показано в главе 3. Атрибут ContentProperty часто применяется в WPF. Он используется не только для контейнерных элементов управления (вроде Grid) и элементов, содержащих коллекцию визуальных элементов (таких как ListBox и TreeView), но также и для элементов управления, хранящих одиночное содержимое. Например, TextBox и Button способны содержать только один элемент или фрагмент текста, но они оба используют свойство содержимого для работы с вложенным содержимым: <TextBox Name="txtQuestion" ... > [Place question here.] </TextBox> <Button Name="cmdAnswer" ... > Ask the Eight Ball </Button> <TextBox Name="txtAnswer" . . . > [Answer will appear here.] </TextBox> В классе TextBox применяется атрибут ContentProperty для пометки свойства TextBox.Text. В классе Button использует атрибут ContentProperty для пометки свойства Button.Content. Анализатор XAML применяет указанный текст для установки этих свойств. Свойство TextBox.Text принимает только строковые значения. Однако Button. Content более интересно. Как будет показано в главе 6, свойство Content принимает любой элемент. Например, вот как выглядит кнопка, содержащая объект-фигуру: <Button Name="cmdAnswer" ... > <Rectangle Fill=,,Blue" Height=0" Width=,,100" /> </Button> Поскольку свойства Text и Content не используют коллекций, включать более одной части содержимого нельзя. Например, если вы попытаетесь вложить несколько элементов внутрь Button, то анализатор XAML сгенерирует исключение. Анализатор также сгенерирует исключение, если вы примените нетекстовое содержимое (такое как элемент Rectangle).
64 Глава 2. XAML На заметку! Как правило, все элементы управления, унаследованные от ContentControl, допускают единственный вложенный элемент Все элементы, унаследованные от itemsControl, позволяют применять коллекцию элементов, отображаемых на элемент управления некоторого типа (например, окно списка или дерево). Все элементы, унаследованные от Panel, являются контейнерами, используемыми для организации групп элементов управления Базовые классы ContentControl, ItemsControl и Panel используют атрибут ContentProperty. Специальные символы и пробелы Язык XAML ограничен правилами XML. Например, в XML особое внимание уделяется нескольким специальным символам вроде &, < и >. Если вы попытаетесь применить эти значения для установки содержимого элемента, то столкнетесь с проблемой, поскольку анализатор XAML предположит, что вы пытаетесь сделать что-то другое — например, создать вложенный элемент. Предположим, что требуется создать кнопку, которая содержит текст <Click Me>. Следующий код разметки работать не будет: <Button ... > <Click Me> </Button> Проблема в том, что код выглядит так, будто вы пытаетесь создать элемент по имени Click с атрибутом Me. Решение состоит в замене проблемных символов сущностными ссылками — специфическими кодами, которые анализатор XAML интерпретирует правильно. В табл. 2.1 перечислены символьные сущности, которые можно применять. Обратите внимание, что символьная сущность типа кавычки требуется только при установке значений с использованием атрибута, т.к. кавычка обозначает начало и конец значения атрибута. Таблица 2.1. Символьные сущности XML Специальный символ Символьная сущность Меньше (<) &lt; Больше (>) &gt; Амперсанд(&) &атр; Кавычка (") &quot; Ниже приведен правильный код разметки, в котором применяются соответствующие символьные сущности: <Button ... > &lt;Click Me&gt/ </Button> Когда анализатор XAML читает это, он правильно понимает, что вы хотите добавить текст <Click Me>, и передает строку с этим содержимым, дополняя ее угловыми скобками, свойству Button.Content. На заметку! Это ограничение — деталь XAML, которая не возникнет, если свойство Button. Content будет устанавливаться в коде. Конечно, в С# существует собственный специальный символ (обратный слэш), который должен быть защищен в строковых литералах по аналогичной причине.
Глава 2. XAML 65 Специальные символы — не единственная тонкость, с которой придется столкнуться в XAML. Другой проблемой является обработка пробелов. По умолчанию XML сокращает все пробелы, а это значит, что длинная строка пробелов, знаков табуляции и жестких переводов строки превращается в единственный пробел. Более того, пробел перед или после содержимого элемента будет полностью проигнорирован. Это можно наблюдать в примере EightBall. Текст на кнопке и два текстовых поля отделены от дескрипторов XAML жестким переводом строки и табуляцией, которые повышают читабельность разметки. Однако этот дополнительный пробел не появляется в пользовательском интерфейсе. Иногда это не то, что требуется. Например, в тексте кнопки могут понадобиться серии из нескольких пробелов. В таком случае в элементе должен использоваться атрибут xml:space="preserve". Атрибут xmlrspace — часть стандарта XML, являющаяся установкой в духе "все или ничего". Однажды включив его, вы предохраните все пробелы внутри элемента. Например, рассмотрим следующую разметку: <TextBox Name="txtQuestion" xml:space="preserve" ...> [There is a lot of space inside these quotation marks " " . ] </TextBox> В этом примере текст в текстовом поле будет включать жесткий перенос строки и знак табуляции перед текстом. Также он содержит серии пробелов внутри текста и перенос строки после текста. Если нужно только предохранить пробелы внутри, то придется применить менее читабельную разметку: <TextBox Name="txtQuestion" xml:space="preserve" ... >[There is a lot of space inside these quotation marks " ".]</TextBox> Трюк здесь состоит в том, что нужно убедиться в том, чтобы не было пробелов между открывающим символом > и содержимым или между содержимым и закрывающим символом <. Опять-таки, эта проблема касается только разметки XAML. При программной установке текста в текстовом поле будут использоваться все включенные пробелы. События До сих пор все атрибуты, которые вы видели, отображались на свойства. Однако атрибуты также могут быть использованы для присоединения обработчиков событий. Синтаксис при этом выглядит следующим образом: ИмяСобытия="ИмяМетодаОбработчикаСобытий" Например, элемент управления Button предоставляет событие Click. Присоединить обработчик событий можно так, как показано ниже: <Button . . . Click="cmdAnswer_Click"> Это предполагает наличие метода по имени cmdAnswer _ Click в классе отделенного кода. Обработчик событий должен иметь правильную сигнатуру (т.е. должен соответствовать делегату для события Click). Вот метод, который выполняет этот трюк: private void cmdAnswer_Click(object sender, RoutedEventArgs e) { this.Cursor = Cursors.Wait; // Значительная задержка... System.Threading.Thread.Sleep(TimeSpan.FromSeconds C));
66 Глава2.ХАМ1_ AnswerGenerator generator = new AnswerGenerator (); txtAnswer.Text = generator.GetRandomAnswer(txtQuestion.Text) ; this.Cursor = null; } Как вы могли заметить по сигнатуре этого обработчика событий, модель событий в WPF отличается от ранних версий .NET. Здесь поддерживается новая модель, которая полагается на маршрутизацию событий. Подробнее об этом речь пойдет в главе 5. Во многих ситуациях атрибуты используются для установки свойств и присоединения обработчиков событий в одном и том же элементе. WPF всегда работает в следующей последовательность: сначала устанавливается свойство Name (если оно есть), затем присоединяются любые обработчики событий и, наконец, устанавливаются свойства. Это значит, что любые обработчики событий, реагирующие на изменения свойств, будут запущены при первоначальной установке свойства. На заметку! Код (такой как обработчик события) допускается встраивать непосредственно в документ XAML, используя для этого элемент Code. Однако такая техника не является рекомендованной и не имеет какого-либо практического применения bWPF Этот подход не поддерживается в Visual Studio и книге не рассматривается При добавлении атрибута обработчика событий Visual Studio ассистирует с помощью средства IntelliSense. Как только введен символ равенства (например, после набора Click= в элементе <Button>), отображается раскрывающийся список со всеми подходящими обработчиками событий в классе отделенного кода (рис. 2.2). Если нужно создать новый обработчик для данного события, следует выбрать элемент <New Event Handler> (Новый обработчик событий) в начале списка. В качестве альтернативы можно присоединить и создать обработчики событий, используя вкладку Events (События) окна Properties (Свойства). Window 1 wMiiirl <- X П Ц Design /^ В XAML I ШВ® ф <TextBox VerticalAlignmentaB,,Stretch" HorizontalAlign»ent-,,Str—\ TextWrapping""Wrap" FontFamily"Verdana" FontSize- Grid^ow-^O" > [Place question here.] </TextBox> <Button VerticalAlignntent»wTop" HorizontalAlignment-"Left" Margin-0,0,0,20" Width—127" Height-,r23" Name-"cmdAf clic*"H Grii .af <New Event Handter> Азк the E: </Button> <TextBox Vcrtf J^^sEretch" HorizontalAligrunent*wStr TextWrapping""Wrapw ^ReadOnly'True* FontFamily«wVe Grid.Row-,'> (Answer will appear here.] </TextBox> Button (cmdAnswer) Window/Gnd/Button .—• ■' ■ ■ ■ — -——--—-—- _ Рис. 2.2. Присоединение события с помощью средства IntelliSense в Visual Studio Полный пример автоответчика После ознакомления с основами XAML теперь можно пройтись по определению окна, показанного на рис. 2.1. Ниже приведена полная разметка XAML.
Глава 2. XAML 67 <Window x:Class="EightBall.Windowl" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Eight Ball Answer" Height=28" Width=12" > <Grid Name="gridl"> <Grid.RowDefinitions> <RowDefinition Height="*" /> <RowDefmition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <TextBox VerticalAlignment="Stretch" HonzontalAlignment="Stretch" Margin=0,10,13,10" Name="txtQuestion" TextWrapping="Wrap" FontFamily="Verdana" FontSize=4" Grid.Row="> [Place question here.] </TextBox> <Button VerticalAlignment="Top" HorizontalAlignment="Left" Margin=0,0,0,20" Width=27" Height=3" Name="cmdAnswer" Click="cmdAnswer_Click" Grid.Row="l"> Ask the Eight Ball </Button> <TextBox VerticalAlignment="Stretch" HonzontalAlignment="Stretch" Margin=0,10,13,10" Name="txtAnswer" TextWrapping="Wrap" IsReadOnly="True" FontFamily="Verdana" FontSize=4" Foreground="Green" Gnd.Row="> [Answer will appear here.] </TextBox> <Gnd.Background> <LinearGradientBrush> <LinearGradientBrush.GradientStops> <GradientStop Offset=.00" Color="Red" /> <GradientStop Offset=.50" Color="Indigo" /> <GradientStop Offset="l.00" Color="Violet" /> </LinearGradientBrush.GradientStops> </LinearGradientBrush> </Grid.Васkground> </Gnd> </Window> Как упоминалось ранее, вряд ли вы захотите вручную вводить XAML-разметку для всего пользовательского интерфейса — это может оказаться невыносимо утомительно. Тем не менее, могут возникнуть веские причины для редактирования кода XAML с целью внесения изменений, которые трудно обеспечить в визуальном конструкторе. Кроме того, может понадобиться просмотреть код XAML, чтобы получить лучшее представление о работе окна. Использование типов из других пространств имен До сих пор демонстрировалось создание базового интерфейса в XAML с использованием классов, являющихся частью WPF. Однако XAML задуман как средство общего назначения для создания экземпляров объектов .NET, включая те, что находятся в пространствах имен, не относящихся к WPF, и те, которые вы создаете сами. Рассмотрение создания объектов, которые не предназначены для экранного отображения в окне XAML, может показаться странным, но существует немало ситуаций, когда это оправдано. Примером может служить случай, когда вы используете привязку данных и хотите нарисовать информацию из другого объекта, чтобы отобразить ее в
68 Глава 2. XAML элементе управления. Другой пример — когда требуется установить свойство объекта WPF с применением объекта, не относящегося к WPF. Например, WPF-элемент List Box можно заполнить объектами данных. List Box будет вызывать метод ToStringO, чтобы получить текст для отображения каждого элемента в списке. (Для более качественного отображения списка можно создать шаблон данных, который извлечет множество фрагментов информации и сформатирует их соответствующим образом. Эта техника рассматривается в главе 20.) Для того чтобы использовать класс, который не определен ни в одном из пространств имен WPF, понадобится отобразить пространство имен .NET на пространство имен XML. В XAML для этого предусмотрен специальный синтаксис: xmlns :Префикс="clr-namespace: ПространствоИмен; as зетЫу=ИмяСборки" Обычно это отображение пространства имен помещается в корневой элемент документа XAML — сразу после атрибутов, которые описывают пространства имен WPF и XAML. Выделенные курсивом части должны быть заполнены соответствующей информацией, как описано ниже. • Префикс — префикс XML, который будет использоваться для указания пространства имен в разметке XAML. Например, язык XAML использует префикс х. • ПространствоИмен — полностью квалифицированное название пространства имен .NET. • ИмяСборки — сборка, в которой объявлен тип, без расширения .dll. Проект должен ссылаться на эту сборку. Если используется сборка вашего проекта, этот параметр можно опустить. Например, ниже показано, как получить доступ к базовым типам пространства имен System и отобразить их на префикс sys: xmlns:sys="clr-namespace:System;assembly=mscorlib" А вот так можно получить доступ к типам, объявленным в пространстве имен MyProject текущего проекта, и отобразить их на префикс local: xmlns:local="clr-namespace:MyNamespace" Теперь для создания экземпляра класса в одном из этих пространств имен применяется префикс пространства имен: <local:MyObject ...></local:MyOb]ect> Совет. Помните, что можно использовать произвольный префикс пространства имен до тех пор, пока он согласован по всему документу XAML Однако префиксы sys и local обычно применяются при импорте пространства имен System и пространства имен для текущего проекта. Они используются на протяжении всей книги. В идеале каждый класс, который должен использоваться в XAML, будет иметь конструктор без аргументов. Если это так, то анализатор XAML сможет создавать соответствующий объект, устанавливать его свойства и присоединять любые обработчики событий, которые вы укажете. XAML не поддерживает параметризованных конструкторов, и все элементы в WPF включают конструкторы без аргументов. Вдобавок необходимо иметь возможность устанавливать все желаемые детали, используя общедоступные свойства. XAML не позволяет устанавливать общедоступные свойства или вызывать методы. Если класс, который планируется использовать, не имеет конструктора без аргументов, возможности некоторым образом ограничены. Если вы попытаетесь создать простой примитив (вроде строки, даты или числового типа), то сможете применить стро-
Глава 2. XAML 69 новое представление данных в качестве содержимого внутри дескриптора. Анализатор XAML затем использует конвертер типа для преобразования этой строки в соответствующий объект Ниже показан пример структуры DateTime: <sys:DateTime>10/30/2010 4:30 PM</sys:DateTime> Это работает, потому что класс DataTime применяет атрибут TypeConverter для привязки себя к DateTimeConverter. Конвертер DateTimeConverter распознает эту строку как корректный объект DateTime и преобразует его. При использовании этой техники нельзя применять атрибуты для установки любых свойств объекта. Если требуется создать класс, не имеющий конструктора без аргументов, и нет подходящего конвертера типов, которым можно было бы воспользоваться, значит, вам не повезло. На заметку! Некоторые разработчики преодолевают эти ограничения, создавая специальные классы-оболочки. Например, класс FileStream не включает конструктора без аргументов. Однако можно создать класс-оболочку, который его имеет. Этот класс-оболочка должен будет создать нужный объект FileStream в своем конструкторе, извлечь необходимую информацию и затем закрыть FileStream. Подобное решение не идеально, поскольку предполагает жесткое кодирование информации для конструктора класса, что усложняет обработку исключений. В большинстве случаев лучше будет манипулировать объектом с помощью небольшого кода обработки событий и полностью исключить его из XAML-разметки. В следующем примере все это собирается вместе. В нем префикс sys отображается на пространство имен System, после чего это пространство имен используется для создания объектов DateTime, которые применяются для заполнения списка. <Window x:Class="WindowsApplicationl.Windowl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:sys="clr-namespace:System;assembly=mscorlib" Width=00" Height=00" > <ListBox> <ListBoxItem> <sys:DateTime>10/13/2010 4:30 PM</sys:DateTime> </ListBoxItem> <ListBoxItem> <sys:DateTime>10/29/2010 12:30 PM</sys:DateTime> </ListBoxItem> <ListBoxItem> <sys:DateTime>10/30/2010 2:30 PM</sys:DateTime> </ListBoxItem> </ListBox> </Window> Загрузка и компиляция XAML Как вам уже известно, XAML и WPF — это две разные, хотя и взаимодополняющие технологии. В результате вполне возможно создать приложение WPF, в котором не используется XAML. В общем случае существуют три разных стиля кодирования, которые могут применяться при создании приложения WPF.
70 Глава 2. XAML • Только код. Это традиционный подход, используемый в Visual Studio для приложений Windows Forms. Пользовательский интерфейс в нем генерируется операторами кода. • Код и не компилированная разметка (XAML). Это специализированный подход, который имеет смысл в определенных сценариях, когда нужны исключительно динамичные пользовательские интерфейсы. При этом часть пользовательского интерфейса загружается из файла XAML во время выполнения с помощью класса XamlReader из пространства имен System.Windows.Markup. • Код и компилированная разметка (BAML). Это предпочтительный подход для WPF, поддерживаемый в Visual Studio. Для каждого окна создается шаблон XAML, и этот код XAML компилируется в BAML, после чего встраивается в конечную сборку. Во время выполнения скомпилированный BAML извлекается и используется для регенерации пользовательского интерфейса. В последующих разделах эти три модели рассматриваются более подробно. Только код Разработка на основе только кода — наименее распространенный (но полностью поддерживаемый) путь написания приложений WPF без применения какого-либо XAML- кода. Очевидным недостатком разработки на основе только кода является то, что этот вариант потенциально чрезвычайно утомителен. Элементы управления WPF не включают параметризованных конструкторов, поэтому даже добавление простой кнопки в окно требует нескольких строк кода. Одним потенциальным преимуществом разработки на основе только кода является ограниченное пространство для настройки. Например, можно сгенерировать форму, заполненную элементами управления вводом, на основе информации из записи базы данных, или же можно на основе какого-то условия принять решение, добавлять или подставлять элементы управления в зависимости от текущего пользователя. Все, что для этого потребуется — это логика проверки условия и ветвление. И напротив, когда используемые документы XAML встраиваются в сборку как фиксированные, неизменные ресурсы. На заметку! Несмотря на то что вы вряд ли будете создавать WPF-приложения на основе только кода, вы, скорее всего, воспользуетесь этим подходом для создания элемента управления WPF в некоторой точке, когда нужна адаптируемая порция пользовательского интерфейса. Ниже представлен код для скромного окна с единственной кнопкой и обработчиком событий (рис. 2.3). Когда создается окно, конструктор вызывает метод InitializeComponent (), который создает и конфигурирует кнопку и форму, а также присоединяет к ним обработчик событий. На заметку! Чтобы создать этот пример, вы должны кодировать класс Windowl с нуля (щелкните правой кнопкой мыши в Solution Explorer и выберите в контекстном меню пункт Add^Class (Добавить^Класс)). Выбирать пункт Add ^Window (Добавить^Окно) не следует, поскольку это добавит файл кода и шаблон XAML для окна, укомплектованный сгенерированным методом InitializeComponent(). using System.Windows; using System.Windows.Controls; using System.Windows.Markup; public class Windowl : Window { private Button buttonl;
Глава 2. XAML 71 public Windowl() { InitializeComponent (); } private void InitializeComponent () { // Сконфигурировать форму. this.Width = this.Height = 285; this.Left = this.Top = 100; this.Title = "Code-Only Window"; // Создать контейнер, содержащий кнопку. DockPanel panel = new DockPanelO ; // Создать кнопку. buttonl = new Button (); buttonl.Content = "Please click me."; buttonl.Margin = new Thickness C0); // Присоединить обработчик событий, buttonl.Click += buttonl_Click; // Поместить кнопку в панель. IAddChild container = panel; container.AddChild (buttonl) ; // Поместить панель в форму. container = this; . container.AddChild(panel); } private void buttonl_Click(object sender, RoutedEventArgs e) { buttonl.Content = "Thank you."; } } Концептуально класс Windowl в этом примере сильно напоминает форму из традиционного приложения Windows Forms. Он наследуется от базового класса Window и добавляет приватную переменную-член для каждого элемента управления. Для ясности этот класс выполняет свою работу по инициализации в выделенном методе InitializeComponent(). i Code-Only Window *£Ж Please click me. Рис. 2.3. Окно с одной кнопкой
72 Глава 2. XAML Для запуска этого приложения можете воспользоваться следующим методом Main(): public class Program : Application { [STAThreadO ] static void Main() { Program app = new Program(); app.MainWindow = new Windowl(); app.MainWindow.ShowDialog(); } } Код и не компилированный XAML Одним из наиболее интересных способов использования XAML является разбор его на лету с помощью XamlReader. Например, предположим, что вы начинаете со следующего содержимого в файле Windowl.xaml: <DockPanel xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation"> <Button Name="buttonl11 Margin=ll30">Please click me.</Button> </DockPanel> Во время выполнения можно загрузить это содержимое в активное окно для создания того же окна, что и на рис. 2.3. Ниже показан код, который делает это. using System.Windows; using System.Windows.Controls; using System.Windows.Markup; using System.10; public class Windowl : Window { private Button buttonl; public Windowl () { InitializeComponent (); } public Windowl(string xamlFile) { // Сконфигурировать формы, this.Width = this.Height = 285; this.Left = this.Top = 100; this.Title = "Dynamically Loaded XAML"; // Получить содержимое XAML из внешнего файла. DependencyObject rootElement; using (FileStream fs = new FileStream(xamlFile, FileMode.Open)) { rootElement = (DependencyObject)XamlReader.Load (fs); } // Вставить разметку в это окно, this.Content = rootElement; // Найти элемент управления с соответствующим именем. buttonl = (Button)LogicalTreeHelper.FindLogicalNode (rootElement, "buttonl"); // Присоединить обработчик событий, buttonl.Click += buttonl_Click; } private void buttonl_Click(object sender, RoutedEventArgs e) { buttonl.Content = "Thank you."; }
Глава 2. XAML 73 Здесь конструктор получает имя файла XAML в качестве аргумента (в данном случае — Windowl.xaml). Он открывает FileStream и использует метод Load() класса XamlReader для преобразования содержимого этого файла в DependencyObject, являющийся базой, от которой наследуются элементы управления WPF. Этот объект DependencyObject может быть помещен внутрь контейнера любого типа (например, Panel), но в данном примере он используется как содержимое для всего окна. На заметку! В этом примере из файла XAML загружается элемент — объект DockPanel. В качестве альтернативы можно было бы загрузить все окно XAML (как в примере с автоответчиком). В данном случае объект, возвращенный XamlReader.Load(), потребуется привести к типу Window и затем вызвать его метод Show() или ShowDialogO для того, чтобы отобразить его. Эта техника используется в примере XAML 2009, приведенном ниже в этой главе. Чтобы манипулировать элементом, например, кнопкой в файле Windowl.xaml, необходимо найти соответствующий объект — элемент управления в динамически загруженном содержимом. Для этих целей предназначен элемент LogicalTreeHelper. Он позволяет производить поиск по всему дереву объектов — элементов управления, погружаясь на столько уровней, на сколько необходимо, пока не будет найден объект с указанным именем. Обработчик затем присоединяется к событию Button.Click. Другая альтернатива предполагает использование метода FrameworkElement. FindName(). В данном примере корневым элементом является объект DockPanel. Подобно всем элементам управления в окне WPF, объект DockPanel наследуется от FrameworkElement. Это значит, что следующий код: buttonl = (Button)LogicalTreeHelper.FindLogicalNode (rootElement, "buttonl"); может быть заменен эквивалентным кодом: FrameworkElement frameworkElement = (FrameworkElement)rootElement; buttonl = (Button)frameworkElement.FindName("buttonl"); В этом примере файл Windowl.xaml распространяется вместе с исполняемым файлом приложения, в той же папке. Однако, несмотря на то, что он не компилируется как часть приложения, его можно добавить в проект Visual Studio. Это упростит управление данным файлом и позволит проектировать пользовательский интерфейс с помощью Visual Studio (предполагается использование расширения файла .xaml, которое Visual Studio распознает как документ XAML). Если вы используете такой подход, удостоверьтесь, что этот файл XAML не компилируется и не встраивается в проект подобно традиционному файлу XAML. После добавления его к проекту выберите этот файл в Solution Explorer и с помощью окна Properties установите свойство Build Action (Действие сборки) в None, a Copy to Output Directory (Копировать в выходной каталог) — в Copy always (Копировать всегда). Очевидно, что динамическая загрузка XAML не будет столь же эффективной, как компиляция XAML в BAML с последующей загрузкой BAML во время выполнения, особенно в случае сложного пользовательского интерфейса. Тем не менее, он открывает ряд возможностей для построения динамических пользовательских интерфейсов. Например, можно было бы создать оператор общего назначения для опроса, который читает файл формы из веб-службы и затем отображает соответствующие элементы управления (метки, текстовые поля, флажки и т.п.). Файл формы может быть обычным документом XAML с дескрипторами WPF, который загружается в существующую форму с помощью XamlReader. Чтобы собрать результаты, как только форма опроса заполнена, понадобится просто перечислить все элементы управления вводом и собрать их содержимое. Другое преимущество подхода со несвязанными файлами XAML в готовом проекте состоит в том, что они позволяют использовать улучшения в стандарте XAML 2009, описанные далее в этой главе.
74 Глава 2. XAML Код и скомпилированный XAML Вы уже видели наиболее распространенный способ использования XAML в примере автоответчика, показанном на рис. 2.1 и исследуемом на протяжении всей этой главы. Это метод, применяемый Visual Studio, который обладает рядом преимуществ, уже затронутых ранее в главе. • Часть работы автоматизирована. Нет необходимости выполнять поиск идентификатора с помощью LogicalTreeHelper или привязывать в коде обработчики событий. • Чтение BAML-кода во время выполнения происходит быстрее, чем чтение XAML- кода. • Упрощается развертывание. Поскольку BAML-код встраивается в сборку как один или более ресурсов, его невозможно потерять. • Файлы XAML могут редактироваться в других программах, таких как инструменты графического дизайна. Это открывает возможность лучшей кооперации между программистами и дизайнерами. (Вы также получаете это преимущество, когда используете не компилированный XAML, как описано в предыдущем разделе.) В Visual Studio используется двухэтапный процесс компиляции приложений WPF. Первый этап — компиляция XAML-файлов в BAML. Например, если проект включает файл по имени Windowl.xaml, то компилятор создаст временный файл Windowl.baml и поместит его в подпапку obj\Debug (в папке проекта). В то же время для окна создается частичный класс с использованием выбранного языка. Например, если применяется С#, то компилятор создаст файл по имени Windowl.g.cs в папке obj\Debug. Здесь g означает generated (сгенерированный). Частичный класс включает следующие вещи. • Поля для всех элементов управления в окне. • Код, загружающий BAML из сборки и тем самым создающий дерево объектов. Это случается, когда конструктор вызывает InitializeComponent(). • Код, который назначает соответствующий объект элемента управления каждому полю и подключает все обработчики событий. Это происходит в методе по имени Connect (), который вызывается анализатором BAML при каждом нахождении именованного объекта. Частичный класс не включает кода для создания экземпляра и инициализации элементов управления, потому что эта задача выполняется механизмом WPF, когда BAML- код обрабатывается методом Application.LoadComponent(). На заметку! В процессе компиляции компилятор XAML должен создать частичный класс. Это возможно, только если используемый вами язык поддерживает модель .NET Code DOM. Языки С# и VB поддерживают Code DOM, но если используется язык от независимого поставщика, следует убедиться, что эта поддержка доступна, прежде чем создавать приложения со скомпилированным XAML. Ниже приведен слегка сокращенный файл Windowl.g.cs из примера автоответчика, показанного на рис. 2.1. public partial class Windowl : System.Windows.Window, System.Windows.Markup.IComponentConnector { // Поля элементов управления. internal System.Windows.Controls.TextBox txtQuestion;
Глава 2. XAML 75 internal System.Windows.Controls.Button cmdAnswer; internal System.Windows.Controls.TextBox txtAnswer; private bool _contentLoaded; // Загрузить BAML. public void InitializeComponent () { if (_contentLoaded) { return; } _contentLoaded = true; System.Uri resourceLocater = new System. Un ( "windowl .baml" , System.UriKind.RelativeOrAbsolute); System.Windows.Application.LoadComponent (this, resourceLocater) ; } // Подключить каждый элемент управления, void System.Windows.Markup.IComponentConnector.Connect (int connectionId, object target) { switch (connectionld) { case 1: txtQuestion = ((System.Windows.Controls.TextBox) (target)); return; case 2: cmdAnswer = ((System.Windows.Controls.Button)(target)); cmdAnswer.Click += new System.Windows.RoutedEventHandler ( cmdAnswer_Click); return; case 3: txtAnswer = ((System.Windows.Controls.TextBox) (target)); return; } this._contentLoaded = true; } } Когда завершается этап компиляции XAML в BAML, Visual Studio использует компилятор соответствующего языка, чтобы скомпилировать код и сгенерированные файлы частичного класса. В случае приложения С# эту задачу решает компилятор csc.exe. Скомпилированный код становится единой сборкой (EightBall.exe), и BAML для каждого окна встраивается как отдельный ресурс. Только XAML В предыдущих разделах было показано, как использовать XAML из приложения на основе кода. Разработчики для .NET будут заниматься этим большую часть времени. Однако также возможно использовать файл XAML без создания кода. Это называется несвязанный XAML-файл. Несвязанные файлы XAML могут открываться непосредственно в Internet Explorer. (Предполагается, что платформа .NET Framework установлена.) На заметку! Если файл XAML использует код, он не может быть открыт в браузере Internet Explorer. Однако можно построить браузерное приложение под названием XBAR в котором это ограничение преодолено. За подробным описанием обращайтесь к главе 24. К этому моменту создание несвязанного XAML-файла может показаться относительно бесполезным. В конце концов, какой смысл в пользовательском интерфейсе без управляющего им кода? Однако, изучая XAML, вы откроете несколько средств, которые
76 Глава 2. XAML полностью декларативны. К ним относится анимация, триггеры, привязка данных и ссылки (которые могут указывать на другие несвязанные файлы XAML). Используя эти средства, можно строить очень простые XAML-файлы без кода. Они не будут выглядеть как завершенные приложения, но позволят делать несколько больше, чем статические страницы HTML. Чтобы попробовать несвязанную страницу XAML, внесите в файл .xaml следующие изменения. • Удалите атрибут Class из корневого элемента. • Удалите любые атрибуты, которые присоединяют обработчики событий (такие как атрибут Button.Click). • Измените имя открывающего и закрывающего дескриптора с Window на Page. Браузер Internet Explorer может отображать только страницы, а не отдельные окна. После этого дважды щелкните на файле .xaml, чтобы загрузить Internet Explorer. На рис. 2.4 показана преобразованная страница EightBall.xaml, которая входит в загружаемый код примеров для этой главы. В текстовом поле можно набирать текст, но поскольку в приложении отсутствует файл с отделенным кодом, щелчок на кнопке ни к чему не приводит. Чтобы создать более полезное браузерное приложение, которое может содержать код, необходимо применять подход, описанный в главе 24. 4$ D:\Code\Pro WPF\Chapter02\EightBallBrowserPage\Windowl xaml - Windows Internet ЕхркХ.№Ив|ВН1 tm& ГТ-^! 1 * D:\Code\ProWPF\Cnapter02\EightBallBrowserPat»' 4 Xi] Google fi •• n D:\Code\Pro WPF\Chapter02\EightB... ' D ' W " ': * p^e w ..Tools*- Рис. 2.4. Страница XAML в браузере XAML 2009 Как упоминалось ранее в этой главе, в WPF 4 появился новый стандарт под названием XAML 2009. Однако пока он не внедрен повсеместно. Чтобы получить преимущества XAML 2009 в настоящее время, необходимо использовать несвязанные, не скомпилированные файлы XAML, что не удовлетворяет большинство разработчиков.
Глава 2. XAML 77 Даже если вы решите не использовать XAML 2009, стоит кратко познакомиться с его средствами. Дело в том, что в конечном итоге в следующей версии WPF язык XAML 2009 превратится в полностью интегрированный скомпилированный стандарт. В следующем разделе вы ознакомитесь с его наиболее важными изменениями, и все они будут проиллюстрированы примерами кода. Имейте в виду, что средство IntelliSense в Visual Studio пометит некоторые из них как ошибки времени проектирования, потому что пока производится проверка кода на предмет соответствия существующему стандарту XAML. Однако во время выполнения они будут работать должным образом. Автоматическая привязка событий В показанном ранее примере несвязанного XAML в коде требовалось вручную подключать обработчики событий, а это означает, что код должен обладать детальными сведениями о содержимом файла XAML (таком как имена всех элементов, инициирующих события, которые необходимо обрабатывать). Стандарт XAML 2009 предлагает частичное решение проблемы. Его анализатор может автоматически подключать обработчики событий, если соответствующие методы-обработчики определены в корневом классе. Например, рассмотрим следующую разметку: <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <StackPanel> <Button Click="cmd_Click,,></Button> </StackPanel> </Window> Если передать этот документ методу XamlReader.LoadO, возникнет ошибка, потому что метод Window.cmd_Click() не существует. Но если создать собственный специальный класс, унаследованый от Window, скажем, XAML 2009Window, и применить показанную ниже разметку: <local:XAML 2009Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:NonCompiledXam1;assembly=NonCompiledXaml"> <StackPanel> <Button Click="cmd_Click"x/Button> </StackPanel> </local:XAML 2009Window> то анализатор сможет создать экземпляр класса XAML 2009Window и затем присоединит к событию Button.Click метод XAML 2009Window.cmd_Click() автоматически. Эта техника отлично работает с приватными методами, но если метод не существует (или не имеет правильной сигнатуры), генерируется исключение. Вместо загрузки XAML в конструкторе (как в предыдущем примере) класс XAML 2009Window использует собственный статический метод по имени LoadWindowFromXaml (). Такое проектное решение несколько лучше, потому что подчеркивает потребность в нетривиальном процессе, который необходим для создания объекта окна — в данном случае это открытие файла. При этом также можно организовать более ясную обработку исключений, если код не найдет или не получит доступа к файлу XAML. Причина в том, что генерировать исключение имеет больший смысл в методе, чем в конструкторе. Ниже показан полный код окна:
78 Глава 2. XAML public class XAML 2009Window : Window { public static XAML 2009Window LoadWindowFromXaml (string xamlFile) { // Получить содержимое XAML из внешнего файла. using (FileStream fs = new FileStream(xamlFile, FileMode.Open)) { XAML 2009Window window = (XAML 2009Window)XamlReader.Load(fs); return window; } } private void cmd_Click(object sender, RoutedEventArgs e) { MessageBox.Show("You clicked."); } } Для создания экземпляра этого окна необходимо вызвать статический метод LoadWindowFromXaml () в любом месте кода: Program арр = new Program(); app.MainWindow = XAML 2009Window.LoadWindowFromXaml("XAML 2009.xaml"); app.MainWindow.ShowDialog (); Возможно, вы уже заметили, что эта модель довольно похожа на встроенную модель Visual Studio, которая компилирует XAML. В обоих случаях весь код обработки событий помещается в специальный класс, унаследованный от элемента, который действительно нужен (обычно Window или Page). Ссылки В обычном языке XAML не существует простого способа ссылки одного элемента на другой. Лучшее решение состоит в привязке данных (как будет описано в главе 8), но для простых сценариев это слишком громоздко. В XAML 2009 задача упрощается за счет расширения разметки, которое специально предназначено для ссылок. В следующих двух фрагментах разметки показаны две ссылки, используемые для установки свойства Target объектов Label. Свойство Label.Target указывает на элемент управления вводом, который принимает фокус, когда пользователь нажимает горячую клавишу. В данном примере первое текстовое поле использует комбинацию <Alt+F>. Если пользователь нажимает эту комбинацию клавиш, фокус переходит к элементу управления txtFirstName, определенному следом. <Label Target="{x:Reference txtFirstName}">_FirstName</Label> <TextBox x:Name="txtFirstName"></TextBox> <Label Target="{x:Reference txtLastName}">_LastName</Label> <TextBox x :Name="txtLastName"x/TextBox> Встроенные типы Как уже известно, разметка XAML может обращаться почти к любому пространству имен, если сначала отобразить его на пространство имен XML. Многие новички в WPF удивляются, когда узнают о необходимости ручного отображения пространств имен для использования базовых типов из пространства System, таких как String, DateTime, TimeSpan, Boolean, Char, Decimal, Single, Double, Int32, Uri, Byte и т.д. Хотя это относительно небольшое препятствие, все же оно требует дополнительного шага с вашей стороны и привносит дополнительную путаницу: <sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib>A String</sys:String>
Глава 2. XAML 79 В XAML 2009 пространство имен XAML обеспечивает прямой доступ к этим типам данных, не требуя никаких излишних усилий: <x:String>A String</x:String> Можно также напрямую обращаться к обобщенным типам коллекций List и Dictionary. На заметку! При установке свойств элементов управления WPF данная проблема не возникает. Это объясняется тем, что конвертер значений берет строку и преобразует ее в соответствующий тип данных автоматически, как объяснялось ранее в этой главе. Однако бывают ситуации, когда конвертеры значений не работают, и нужны специфические типы данных. Одним из примеров может быть необходимость в применении простых типов данных для хранения ресурсов — объектов, которые можно многократно использовать в разметке и коде. Ресурсы рассматриваются в главе 10. Расширенное создание объектов Обычный язык XAML позволяет создавать почти любой тип — при условии, что у него имеется конструктор без аргументов. В XAML 2009 это ограничение снято и предлагаются два более мощных способа создания и инициализации объектов. Первый из них — возможность использования элемента <x:Arguments> для передачи аргументов конструктору. Например, предположим, что есть класс, не имеющий конструктора без аргументов: public class Person { public string FirstName { get; set; } public string LastName { get; set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } } Создать его экземпляр в XAML 2009 можно следующим образом: <local:Person> <х:Arguments> <х:String>Joe</x:String> <х:String>McDowell</x:String> </х:Arguments> </local:Person> Второй подход предусматривает применение статического метода (того же либо другого класса), который создает экземпляр необходимого объекта. Этот шаблон называется фабричным методом. Пример фабричного метода содержит класс Guid из пространства имен System, представляющий глобально уникальный идентификатор. Создать экземпляр Guid с помощью ключевого слова new нельзя, но можно вызвать метод Guid.NewGuidO, который вернет новый экземпляр: Guid myGuid = Guid.NewGuidO ; В XAML 2009 аналогичный прием можно использовать в разметке. Секрет кроется в атрибуте x:FactoryMethod. Ниже показано, как создать разметку Guid, исходя из предположения, что префикс пространства имен sys отображен на пространство имен System: <sys:Guid x:FactoryMethod="Guid.NewGuid"></sys:Guid>
80 Глава 2. XAML XAML 2009 также позволяет создавать экземпляры обобщенных коллекций, что в обычном XAML невозможно. (Хотя существует обходной путь — унаследовать специальный класс коллекции для использования в качестве оболочки и создать его экземпляр в XAML. Однако это быстро засорит код излишними одноразовыми классами.) В XAML 2009 атрибут TypeArguments предоставляет возможность передачи аргументов обобщенному классу. Например, предположим, что требуется создать список объектов Person. Это может обеспечить примерно такой код: List<Person> people = new List<Person> () ; people.Add (new Person("Joe", "McDowell"); В XAML 2009 аналогичный результат дает следующая разметка: <x:List x:TypeArguments="Person"> <local:Person> <x:Arguments> <x:String>Joe</x:String> <x:String>McDowell</x:String> </x:Arguments> </local:Person> </x:List> Если в классе Person имеется конструктор по умолчанию без аргументов, то разметка может выглядеть так: <x:List х:TypeArguments="Person"> <local:Person FirstName="Joe" LastName="McDowell" /> </x:List> Резюме В этой главе вы ознакомились с содержимым простого файла XAML и необходимым синтаксисом. • Были рассмотрены ключевые ингредиенты XAML, такие как конвертеры типов, расширения разметки и присоединенные свойства. • Было показано, как привязывать класс отделенного кода, который может обрабатывать события, инициируемые элементами управления. • Был рассмотрен процесс компиляции, который превращает стандартное приложение WPF в скомпилированный исполняемый файл. Были описаны три варианта этого процесса: создание приложения WPF из одного только кода, создание приложения WPF без ничего кроме XAML и загрузка XAML вручную во время выполнения. • Были кратко описаны изменения, произошедшие в XAML 2009. Хотя детали разметки XAML особо глубоко не рассматривались, вы узнали достаточно, чтобы оценить его преимущества. Далее внимание переключается на саму технологию WPF, которая таит в себе некоторые интересные сюрпризы. В следующей главе будет показано, как организовать элементы управления в реалистичных окнах с использованием панелей компоновки WPF
ГЛАВА 3 Компоновка Половина всех усилий при проектировании пользовательского интерфейса уходит на организацию содержимого, чтобы она была привлекательной, практичной и гибкой. Но по-настоящему сложной задачей является адаптация компоновки элементов интерфейса к различным размерам окна. В WPF компоновка формируется с использованием разнообразных контейнеров. Каждый контейнер обладает собственной логикой компоновки — некоторые укладывают элементы в стопку, другие распределяют их по ячейкам сетки и т.д. Если вы программировали с помощью Windows Forms, то будете удивлены, что компоновку на основе координат в WPF использовать не рекомендуется. Вместо этого упор делается на создание более гибких компоновок, которые могут адаптироваться к изменяющемуся содержимому, разным языкам и широкому разнообразию размеров окон. Для большинства разработчиков переход на технологию WPF с ее новой системой компоновки становится большим сюрпризом — и первой реальной сложностью. В этой главе будет показано, как работает модель компоновки WPF и как использовать базовые контейнеры компоновки. Кроме того, будут продемонстрированы примеры распространенной компоновки — от базового диалогового окна к разделенному окну изменяемого размера. Что нового? В WPF 4 используется прежняя гибкая система компоновки, но с добавлением одного небольшого штриха, который может избавить от серьезной проблемы. Это средство называется округлением компоновки, и оно гарантирует, что контейнеры компоновки не будут пытаться размещать компоненты по дробным координатам, что приводит к размытым фигурам и изображениям. Более подробно это описано в разделе "Округление компоновки" настоящей главы. Понятие компоновки в WPF Модель компоновки WPF отражает существенные изменения подхода разработчиков к проектированию пользовательских интерфейсов Windows. Чтобы понять новую модель компоновки WPF, стоит посмотреть на то, что ей предшествовало. В .NET 1.0 технология Windows Forms предоставляла весьма примитивную систему компоновки. Элементы управления были фиксированы на месте по жестко закодированным координатам. Единственными удобствами были привязка (anchoring) и стыковка (docking) — два средства, которые позволяли элементам управления перемещаться и изменять свои размеры вместе с их контейнером. Привязка и стыковка были незаменимы для создания простых окон изменяемого размера, например, с привязкой кнопок ОК и Cancel (Отмена) к нижнему правому углу окна, либо когда нужно было заставить элемент TreeView разворачиваться для заполнения всей формы. Однако они не могли
82 Глава 3. Компоновка справиться с более сложными задачами компоновки. Например, привязка и стыковка не позволяли организовать пропорциональное изменение размеров двухпанельных окон (с равномерным разделением дополнительного пространства между двумя областями). Они также не слишком помогали в случае высоко динамичного содержимого, например, когда нужно было дать возможность метке расширяться, чтобы вместить больше текста, что приводило к перекрытию соседних элементов управления. В .NET 2.0 пробел в Windows Forms был восполнен, благодаря двум новым контейнерам компоновки: FlowLayoutPanel и TableLayoutPanel. С использованием этих элементов управления можно создавать более изощренные интерфейсы в стиле веб- приложений. Оба контейнера компоновки позволяли содержащимся в них элементам управления увеличиваться, расталкивая соседние элементы. Это облегчило задачу создания динамического содержимого, создания модульных интерфейсов и локализации приложений. Однако панели компоновки выглядели дополнением к основной системе компоновки Windows Forms, использовавшей фиксированные координаты. Панели компоновки были элегантным решением, но все-таки несколько чужеродным. В WPF появилась новая система компоновки, большое влияние на которую оказала разработка с помощью Windows Forms. Эта система возвращается к модели .NET 2.0 (координатная компоновка с необязательными потоковыми панелями компоновки), сделав потоковую (flow-based) компоновку стандартной и предложив лишь рудиментарную поддержку координатной компоновки. Преимущества подобного сдвига огромны. Разработчики могут теперь создавать не зависимые от разрешения и от размеров интерфейсы, которые масштабируются на разных мониторах, подгоняют себя при изменении содержимого и легко поддерживают перевод на другие языки. Однако прежде чем вы воспользуетесь преимуществом этих изменений, следует перестроить образ мышления относительно компоновки. Философия компоновки WPF Окно WPF может содержать только один элемент. Чтобы разместить более одного элемента и создать практичный пользовательский интерфейс, нужно поместить в окно контейнер и добавлять элементы в этот контейнер. На заметку! Это ограничение обусловлено тем фактом, что класс Window унаследован от ContentControl, который более подробно рассматривается в главе 6. В WPF компоновка определяется используемым контейнером. Хотя есть несколько контейнеров, среди которых можно выбирать, "идеальное" окно WPF следует описанным ниже ключевым принципам. • Элементы (такие как элементы управления) не должны иметь явно установленных размеров. Вместо этого они растут, чтобы уместить свое содержимое. Например, кнопка увеличивается при добавлении в нее текста. Можно ограничить элементы управления приемлемыми размерами, устанавливая максимальное и минимальное их значение. • Элементы не указывают свою позицию в экранных координатах. Вместо этого они упорядочиваются своим контейнером на основе размера, порядка и (необязательно) другой информации, специфичной для контейнера компоновки. Для добавления пробелов между элементами служит свойство Margin. На заметку! Жестко закодированные размеры позиции — зло, потому что они ограничивают возможности по локализации интерфейса и значительно затрудняют работу с динамическим содержимым.
Глава 3. Компоновка 83 • Контейнеры компоновки "разделяют" доступное пространство между своими дочерними элементами. Они пытаются обеспечить для каждого элемента его предпочтительный размер (на основе его содержимого), если только позволяет свободное пространство. Они могут также выделять дополнительное пространство одному или более дочерним элементам. • Контейнеры компоновки могут быть вложенными. Типичный пользовательский интерфейс начинается с Grid — наиболее развитого контейнера, и содержит другие контейнеры компоновки, которые организуют меньшие группы элементов, такие как текстовые поля с метками, элементы списка, значки в панели инструментов, колонка кнопок и т.д. Хотя из этих правил существуют исключения, они отражают общие цели проектирования WPF. Другими словами, если вы последуете этим руководствам при построении WPF-приложения, то получите лучший и более гибкий пользовательский интерфейс. Если же вы нарушаете эти правила, то получите пользовательский интерфейс, который не очень хорошо подходит для WPF и его будет значительно сложнее сопровождать. Процесс компоновки Компоновка WPF происходит в два этапа: этап измерения и этап расстановки. На этапе измерения контейнер выполняет проход в цикле по дочерним элементам и опрашивает их предпочтительные размеры. На этапе расстановки контейнер помещает дочерние элементы в соответствующие позиции. Разумеется, элемент не может всегда иметь свои предпочтительные размеры — иногда контейнер недостаточно велик, чтобы обеспечить это. В таком случае контейнер должен усекать такой элемент для того, чтобы уместить его в видимую область. Как вы убедитесь, этой ситуации часто можно избежать, устанавливая минимальный размер окна. На заметку! Контейнеры компоновки не поддерживают прокрутку. Вместо этого прокрутка обеспечивается специализированным элементом управления содержимым ScrollViewer, который может применяться почти где угодно Дополнительные сведения об этом элементе ищите в главе 6. Контейнеры компоновки Все контейнеры компоновки WPF являются панелями, которые унаследованы от абстрактного класса System.Windows. Controls.Panel (рис. 3.1). Класс Panel добавляет небольшой набор членов, включая три общедоступных свойства, описанные в табл. 3.1. На заметку! Класс Panel также имеет внутренний механизм, который можно использовать при создании собственного контейнера компоновки. Но важнее то, что можно переопределить методы MeasureOverrideO и ArrangeOverride(), унаследованные от FrameworkElement, для изменения способа обработки панелью этапов измерения и расстановки при организации дочерних элементов. Создание специальных панелей рассматривается в главе 18. DispatcherObject i DependencyObject Visual UlElement ~~r~ FrameworkElement I Panel Условные обозначения с Абстрактный Л класс J Конкретный класс Рис. 3.1. Иерархия класса Panel
84 Глава 3. Компоновка Таблица 3.1. Общедоступные свойства класса Panel Имя Описание Background Кисть, используемая для рисования фона панели. Чтобы принимать события мыши, это свойство должно быть установлено в отличное от null значение. (Если вы хотите принимать события мыши, но не желаете отображать сплошной фон, просто установите прозрачный цвет фона — Transparent.) Базовые кисти рассматриваются в главе 6, а более развитые кисти — в главе 12 Children Коллекция элементов, находящихся в панели. Это первый уровень элементов — другими словами, это элементы, которые сами могут содержать в себе другие элементы IsItemsHost Булевское значение, которое равно true, если панель используется для отображения элементов, ассоциированных с ItemsControl (вроде узлов в TreeView или элементов списка ListBox). Большую часть времени вы даже не будете знать о том, что элемент управления списком используется "за кулисами" панелью для управления компоновкой элементов. Однако эта деталь становится более важной, если вы хотите создать специальный список, который будет располагать свои дочерние элементы другим способом (например, ListBox, отображающий графические изображения). Данный прием применяется в главе 20 Сам по себе базовый класс Panel — это не что иное, как начальная точка для построения других более специализированных классов. WPF предлагает набор производных от Panel классов, которые можно использовать для организации компоновки. Наиболее основные из них перечислены в табл. 3.2. Как и все элементы управления WPF, а также большинство визуальных элементов, эти классы находятся в пространстве имен System.Windows.Controls. Таблица 3.2. Основные панели компоновки Имя Описание StackPanel Размещает элементы в горизонтальном или вертикальном стеке. Этот контейнер компоновки обычно используется в небольших разделах крупного и более сложного окна WrapPanel Размещает элементы в последовательностях строк с переносом. В горизонтальной ориентации WrapPanel располагает элементы в строке слева направо, затем переходит к следующей строке. В вертикальной ориентации WrapPanel располагает элементы сверху вниз, используя дополнительные колонки для дополнения оставшихся элементов DockPanel Выравнивает элементы по краю контейнера Grid Выстраивает элементы в строки и колонки невидимой таблицы. Это один из наиболее гибких и широко используемых контейнеров компоновки UniformGrid Помещает элементы в невидимую таблицу, устанавливая одинаковый размер для всех ячеек. Данный контейнер компоновки используется нечасто Canvas Позволяет элементам позиционироваться абсолютно — по фиксированным координатам. Этот контейнер компоновки более всего похож на традиционный компоновщик Windows Forms, но не предусматривает средств привязки и стыковки. В результате это неподходящий выбор для окон переменного размера, если только вы не собираетесь взвалить на свои плечи значительный объем работы
Глава 3. Компоновка 85 Наряду с этими центральными контейнерами существует еще несколько более специализированных панелей, которые встречаются во многих элементах управления. К ним относятся панели, предназначенные для хранения дочерних элементов определенного элемента управления, такого как TabPanel (вкладки в TabControl), ToolbarPanel (кнопки в Toolbar) и ToolbarOverflowPanel (команды в раскрывающемся меню Toolbar). Имеется еще VirtualizingStackPanel, список элементов управления с привязкой данных которого используется для существенного сокращения накладных расходов, а также InkCanvas, который подобен Canvas, но обладает поддержкой перьевого ввода на Tablet PC. (Например, в зависимости от выбранного режима, InkCanvas поддерживает рисование с указателем для выбора экранных элементов. Хотя это не очень удобно, но InkCanvas можно использовать и на обычном компьютере с мышью.) Элемент управления InkCanvas рассматривается в этой главе, a VirtualizingStackPanel — в главе 19. Другие специализированные панели будут описаны далее, когда речь пойдет о соответствующих элементах управления. Простая компоновка с помощью StackPanel Панель StackPanel — один из простейших контейнеров компоновки. Она просто укладывает свои дочерние элементы в одну строку или колонку. Например, рассмотрим следующее окно, которое содержит стек из четырех кнопок: <Window х:Class=MLayout.SimpleStack" xmlns=Mhttp://schemas.microsoft.com/winfx/2006/xaml/presentation11 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title=MLayoutM Height=M223M Width=54" > <StackPanel> <Label>A Button Stack</Label> <Button>Button K/Button> <Button>Button 2</Button> <Button>Button 3</Button> <Button>Button 4</Button> </StackPanel> </Window> На рис. 3.2 показано полученное в результате окно. ■ т SimpleStack A Button Stack Button 1 Button 2 Button 3 Button 4 UalHI] Рис. 3.2. Панель StackPanel в действии
86 Глава 3. Компоновка Добавление контейнера компоновки в Visual Studio Этот пример сравнительно просто создается с использованием визуального конструктора Visual Studio. Начните с удаления корневого элемента Grid (если он есть). Затем перетащите в окно элемент stackPanel. После этого перетащите в окно другие элементы (метку и четыре кнопки), расположив их в желаемом порядке сверху вниз. Чтобы изменить порядок следования элементов в StackPanel, можно просто перетащить их в новые позиции. При создании пользовательского интерфейса в Visual Studio должны учитываться некоторые нюансы. Когда вы перетаскиваете элементы из панели инструментов в окно, Visual Studio добавляет ряд деталей в разметку. Среда Visual Studio автоматически назначает имя каждому новому элементу управления (что безвредно, но излишне). Также добавляются жестко закодированные значения Width и Height, а это ограничивает намного больше. Как уже говорилось ранее, явные размеры ограничивают гибкость пользовательского интерфейса. Во многих случаях лучше позволить элементам управления самостоятельно устанавливать свои размеры, подгоняя их к контейнеру. В данном примере фиксированные размеры вполне оправданы, т к для всех кнопок необходимо установить согласованную ширину. Однако более удачное решение состояло бы в том, чтобы позволить самой большой кнопке устанавливать свой размер самостоятельно, вмещая свое содержимое, а все остальные кнопки — растянуть до размеров большой, чтобы они соответствовали друг другу. (Такое решение, требующее применения Grid, описано далее в этой главе.) Но независимо от того, какой подход будет использоваться с кнопкой, почти наверняка понадобится избавиться от жестко закодированных величин Width и Height для StackPanel, чтобы она могла растягиваться и сжиматься, заполняя доступное пространство окна. По умолчанию панель StackPanel располагает элементы сверху вниз, устанавливая высоту каждого из них такой, которая необходима для отображения его содержимого. В данном примере это значит, что размер меток и кнопок устанавливается достаточно большим для спокойного размещения текста внутри них. Все элементы растягиваются на полную ширину StackPanel, которая равна ширине окна. Если вы расширите окно, StackPanel также расширится, и кнопки растянутся, чтобы заполнить ее. StackPanel может также использоваться для упорядочивания элементов в горизонтальном направлении за счет установки свойства Orientation: <StackPanel Orientation=llHorizontalll> Теперь элементы получают свою минимальную ширину (достаточную, чтобы уместить их текст) и растягиваются до полной высоты, чтобы заполнить содержащую их панель. В зависимости от текущего размера окна, это может привести к тому, что некоторые элементы не поместятся, как показано на рис. 3.3. г~ » • SimpleStack A Button Stack Button 1 l=>,ataJ| Button 2 Buttt } Рис. 3.3. Панель StackPanel с горизонтальной ориентацией
Глава 3. Компоновка 87 Ясно, что это не обеспечивает достаточной гибкости, необходимой реальному приложению. К счастью, с помощью свойств компоновки можно тонко настраивать способ работы StackPanel и других контейнеров компоновки. Свойства компоновки Хотя компоновка определяется контейнером, дочерние элементы тоже могут сказать свое слово. Панели компоновки взаимодействуют со своими дочерними элементами через небольшой набор свойств компоновки, перечисленных в табл. 3.3. Таблица 3.3. Свойства компоновки Наименование Описание HonzontalAlignment Определяет позиционирование дочернего элемента внутри контейнера компоновки, когда имеется дополнительное пространство по горизонтали. Доступные значения: Center, Left, Right или Stretch VerticalAlignment Определяет позиционирование дочернего элемента внутри контейнера компоновки, когда имеется дополнительное пространство по вертикали. Доступные значения: Center, Top, Bottom или Stretch Margin Добавляет некоторое пространство вокруг элемента. Свойство Margin — это экземпляр структуры System.Windows.Thickness, с отдельными компонентами для верхней, нижней, левой и правой граней MinWidth и Устанавливает минимальные размеры элемента. Если элемент слишком MinHeight велик, чтобы поместиться в его контейнер компоновки, он будет усечен MaxWidth и Устанавливает максимальные размеры элемента. Если контейнер MaxHeight имеет свободное пространство, элемент не будет увеличен сверх указанных пределов, даже если свойства HonzontalAlignment и VerticalAlignment установлены в Stretch Width и Height Явно устанавливают размеры элемента. Эта установка переопределяет значение Stretch для свойств HorizontalAlignment и VerticalAlignment. Однако данный размер не будет установлен, если выходит за пределы, заданные в MinWidth, MinHeight, MaxWidth и MaxHeight Все эти свойства унаследованы от базового класса FrameworkElement и потому поддерживаются всеми графическими элементами (виджетами), которые можно использовать в окне WPF. На заметку! Как известно из главы 2, различные контейнеры компоновки могут предоставлять присоединенные свойства своим дочерним элементам. Например, все дочерние элементы объекта Grid получают свойства Row и Column, позволяющие им выбирать ячейку, в которой они должны разместиться. Присоединенные свойства позволяют устанавливать информацию, специфичную для определенного контейнера компоновки. Однако свойства компоновки из табл. 3.3 носят достаточно общий характер, чтобы применяться ко многим панелям компоновки. Таким образом, эти свойства определены как часть базового класса FrameworkElement. Этот список свойств замечателен тем, чего он не содержит. Если вы ищете знакомые свойства позиционирования, вроде Top, Right и Location, вы не найдете их там. Причина в том, что большинство контейнеров компоновки (кроме Canvas) используют автоматическую компоновку и не позволяют явно позиционировать элементы.
88 Глава 3. Компоновка Выравнивание Чтобы понять, как работают эти свойства, еще раз взглянем на простую панель StackPanel, показанную на рис. 3.2. В этом примере — StackPanel с вертикальной ориентацией — свойство VerticalAlignment не имеет эффекта, потому что каждый элемент получает такую высоту, которая ему нужна, и не более. Однако свойство HorizontalAlignment является важным. Оно определяет место, где располагается каждый элемент в строке. Обычно HorizontalAlignment по умолчанию равно Left для меток и Stretch — для кнопок. Вот почему каждая кнопка целиком занимает ширину колонки. Эти детали можно изменять: <StackPanel> <Label HorizontalAlignment="Center">A Button Stack</Label> <Button HorizontalAlignment="Left">Button K/Button> <Button HorizontalAlignment="Right">Button 2</Button> <Button>Button 3</Button> <Button>Button 4</Button> </StackPanel> На рис. 3.4 показан результат. Первые две кнопки получают минимальные размеры и соответствующим образом выровнены, в то время как две нижние кнопки растянуты на всю StackPanel. Изменив размер окна, вы увидите, что метка остается в середине, а первые две кнопки будут прижаты каждая к своей стороне. На заметку! StackPanel также имеет собственные свойства HorizontalAlignment и VerticalAlignment. По умолчанию оба они установлены в Stretch, и потому StackPanel заполняет свой контейнер полностью. В данном примере это значит, что StackPanel заполняет окно. Если используются другие установки, максимальный размер StackPanel будет определяться самым широким элементом управления, содержащимся в нем. Поля В текущей форме примера StackPanel присутствует очевидная проблема. Хорошо спроектированное окно должно содержать не только элементы; оно также содержит немного дополнительного пространства между элементами. Чтобы ввести это дополнительное пространство и сделать пример StackPanel менее сжатым, можно установить поля вокруг элементов управления. При установке полей допускается указание одинаковой ширины для всех сторон, как показано ниже: <Button Margin=M5">Button 3</Button> В качестве альтернативы можно установить разные поля для каждой стороны элемента управления в порядке левое, верхнее, правое, нижнее: <Button Margin=M5,10,5,10">Button 3</Button> В коде поля устанавливаются с применением структуры Thickness: cmd.Margin = new ThicknessE); Определение правильных полей вокруг элементов управления — отчасти искусство, потому что необходимо учитывать, каким образом установки полей для соседних элементов управления влияют друг на друга. Например, если есть две кнопки, уложенные одна на другую, и верхняя кнопка имеет нижнее поле размером 5, а нижняя кнопка — верхнее поле размером 5, то между двумя кнопками получится пространство в 10 единиц.
Глава 3. Компоновка 89 • ' SimpleStack ^ ... A Button Stack Button 2] Button 3 Button 4 • 3 SimpleStack :on 1 A Button Sta Button 3 Button 4 ^Lj^ggyi ck Butt: ... шшДХ I 1 Рис. З.4. Панель StackPanel с выровненными кнопками Рис. 3.5. Добавление полей между элементами В идеале можно сохранять разные установки полей насколько возможно согласованными и избегать разных значений для полей разных сторон. Например, в примере со StackPanel имеет смысл использовать одинаковые поля для кнопок и самой панели: <StackPanel Margin=M3M> <Label Margin=11 HorizontalAlignment=llCenter"> A Button Stack</Label> <Button Margin=M3M HorizontalAlignment=llLeft">Button K/Button> <Button Margin=M3M HorizontalAlignment="Rightll>Button 2</Button> <Button Margin=M3">Button 3</Button> <Button Margin=M3">Button 4</Button> </StackPanel> Таким образом, общее пространство между двумя кнопками (сумма полей двух кнопок) получается таким же, как общее пространство между кнопкой и краем окна (сумма поля кнопки и поля StackPanel). На рис. 3.5 показано наиболее приемлемое окно, а на рис. 3.6 — как его изменяют установки полей. StackPanel. Margin. Left Button 1. Margin.Top Button 1 Button 1. Margin. Bottom Button2.Margin.Top Button2 Button2. Margin. Right StackPanel.Margin.Right StackPanel. Margin. Bottom Рис. З.6. Комбинирование полей
90 Глава 3. Компоновка Минимальные, максимальные и явные размеры И, наконец, каждый элемент включает свойства Height и Width, которые позволяют установить явные размеры. Однако предпринимать такой шаг — не слишком хорошая идея. Вместо этого при необходимости используйте свойства минимальных и максимальных размеров, чтобы зафиксировать элемент управления в нужных пределах размеров. Совет. Подумайте дважды, прежде чем устанавливать явные размеры в WPF. В хорошо спроектированной компоновке подобная необходимость возникать не должна. Если вы добавляете информацию о размерах, то рискуете создать хрупкую компоновку, которая не сможет адаптироваться к изменениям (вроде разных языков и размеров окна) и будет усекать содержимое. Например, можно решить, что кнопки в панели StackPanel должны растягиваться для ее заполнения, но иметь ширину не более 200 и не менее 100 единиц. (По умолчанию кнопки начинаются с минимальной ширины в 75 единиц.) Ниже показана разметка, которая для этого понадобится: <StackPanel Margin=M3M> <Label Margin=M3M HorizontalAlignment="Center"> A Button Stack</Label> <Button Margin=M3M MaxWidth=,,200" MinWidth=00">Button K/Button> <Button Margin=M3M MaxWidth=M2 00M MinWidth=M100M>Button 2</Button> <Button Margin=M3" MaxWidth=M200M MinWidth=M100M>Button 3</Button> <Button Margin=M3M MaxWidth=M200M MinWidth=M100M>Button 4</Button> </StackPanel> Совет. Здесь может возникнуть вопрос: а нет ли более простого способа установки свойств, стандартизированных для нескольких элементов, наподобие полей кнопок в рассмотренном примере? Ответ: стили — средство, позволяющее повторно использовать установки свойств и даже применять их автоматически. Более подробно стили обсуждаются в главе 11. Когда панель StackPanel изменяет размеры кнопки, она принимает во внимание несколько единиц информации. • Минимальный размер. Каждая кнопка всегда будет не меньше минимального размера. • Максимальный размер. Каждая кнопка всегда будет меньше максимального размера (если только вы не установите неправильно максимальный размер меньше минимального). • Содержимое. Если содержимое внутри кнопки требует большей ширины, то StackPanel попытается увеличить кнопку. (Для определения размера, необходимого кнопке, можно проверить свойство DesiredSize, которое вернет минимальную ширину или ширину содержимого — в зависимости от того, что из них больше.) • Размер контейнера. Если минимальная ширина больше, чем ширина StackPanel, то часть кнопки будет усечена. В противном случае кнопке не позволено будет расти шире, чем позволит StackPanel, несмотря на то, что она не сможет вместить весь текст на своей поверхности. • Горизонтальное выравнивание. Поскольку кнопка использует значение HorizontalAlignment, равное Stretch (по умолчанию), панель StackPanel попытается увеличить кнопку, чтобы она заполнила всю ширину панели.
Глава 3. Компоновка 91 Сложность понимания этого процесса заключается в том, что минимальный и максимальный размеры устанавливают абсолютные пределы. Без этих пределов панель StackPanel пытается обеспечить желаемый размер кнопки (чтобы вместить ее содержимое) и настройки выравнивания. На рис. 3.7 в некоторой степени проясняется то, как это работает в StackPanel. Слева представлено окно в минимальном размере. Кнопки имеют размер по 100 единиц каждая, и окно не может быть сужено, чтобы сделать их меньше. Если вы попытаетесь сжать окно от этой точки, то правая часть каждой кнопки будет усечена. (Такую возможность можно предотвратить применением свойства MinWidth к самому окну, так что окно нельзя будет сузить меньше минимальной ширины.) При увеличении окна кнопки также растут, пока не достигнут своего максимума в 200 единиц. Если после этого продолжать увеличивать окно, то с каждой стороны от кнопок будет добавляться дополнительное пространство (как показано на рисунке справа). а A Button Stack Button 1 Button 3 Button 4 • J SimpleStack I о ,0 I^I^l \ 1 WW—i A Button Stack ton 1 Button 2 Button 3 Button 4 Рис. З.7. Ограничение изменения размеров кнопок На заметку! В некоторых ситуациях может использоваться код, проверяющий, насколько велик элемент в окне. Свойства Height и Width в этом не помогут, т.к. отражают желаемые установки размера, которые могут не соответствовать действительному визуализируемому размеру. В идеальном сценарии элементам позволено менять размеры так, чтобы уместить свое содержимое, и тогда свойства Height и Width вообще устанавливать не надо. Узнать действительный размер, используемый при визуализации элемента, можно через свойства ActualHeight и ActualWidth. Однако помните, что эти значения могут меняться при изменении размера окна или содержимого элементов. Окна с автоматически устанавливаемыми размерами В данном примере присутствует один элемент с жестко закодированным размером: окно верхнего уровня, которое содержит в себе панель StackPanel (и все остальное внутри). По ряду причин жестко кодировать размеры окна по-прежнему имеет смысл • Во многих случаях требуется сделать окно меньше, чем диктует желаемый размер его дочерних элементов. Например, если окно включает контейнер с прокручиваемым текстом, нужно будет ограничить размер этого контейнера, чтобы прокрутка стала возможной. Не следует делать это окно чрезмерно большим, чтобы отпала необходимость в прокрутке, чего требует контейнер. (Прокрутка более подробно рассматривается в главе 6.)
92 Глава 3. Компоновка • Минимальные размеры окна могут быть удобны, но при этом не обеспечивать наиболее привлекательные пропорции. Другие размеры окна просто лучше выглядят • Автоматическое изменение размеров окна не ограничено размером области отображения на мониторе. В результате окно с автоматически установленным размером может оказаться слишком большим для просмотра. Однако окна с автоматически устанавливаемым размером вполне допустимы, и они имеют смысл, когда конструируется простое окно с динамическим содержимым. Чтобы включить автоматическую установку размеров окна, удалите свойства Height и Width и установите свойство Window. SizeToContent в WidthAndHeight. Окно сделает себя достаточно большим, чтобы уместить все содержимое. Можно также позволить окну изменять свой размер только в одном измерении, используя для свойства SizeToContent значение Width или Height. Элемент Border Border не является одной из панелей компоновки, но это удобный элемент, который часто будет использоваться вместе с ними. По этой причине имеет смысл ознакомиться с ним сейчас, пока мы не двинулись дальше. Класс Border предельно прост. Он принимает единственную порцию вложенного содержимого (которым часто является панель компоновки) и добавляет фон или рамку вокруг него. Для работы с Border понадобятся свойства, перечисленные в табл. 3.4. Таблица 3.4. Свойства класса Border Имя Описание Background С помощью объекта Brush устанавливает фон, который появляется под содержимым. Можно использовать сплошной цвет либо что-нибудь более экзотическое BorderBrush и Устанавливают цвет рамки, который появляется на границе объекта BorderThickness Border, и толщину рамки. Для отображения рамки потребуется установить оба свойства CornerRadius Позволяет скруглить углы рамки. Чем больше значение CornerRadius, тем заметнее эффект Padding Добавляет пространство между рамкой и содержимым внутри нее. (В отличие от этого, поля добавляют пространство вне рамки.) Ниже показана разметка для простой, слегка скругленной рамки вокруг группы кнопок в StackPanel: <Border Margin=M5M Padding=M5M Background="LightYellow11 BorderBrush=MSteelBlueM BorderThickness=,5,3,5" CornerRadius=M3M VerticalAlignment="Topll> <StackPanel> <Button Margin=M3M>One</Button> <Button Margin=M3M>Two</Button> <Button Margin=M3M>Three</Button> </StackPanel> </Border> Результат можно видеть на рис. 3.8. В главе 6 приводятся дополнительные сведения о кистях и цветах, которые можно использовать для установки BorderBrush и Background.
■ SimpleBorder ! Ш Ш ШШшШ Глава З. Компоновка 93 1,,.,. \CHZ |( • 1 One Two Three Рис. З.8. Базовая рамка На заметку! Формально Border — это декоратор, т.е. разновидность элемента, которая обычно используется для добавления некоторого рода графического оформления объекта. Все декораторы наследуются от класса System.Windows.Controls.Decorator. Большинство декораторов создано специально для оформления определенного рода элементов управления. Например, Button использует декоратор ButtonChrome, чтобы создать оригинальные скругленные углы и фон с тенью, в то время как ListBox использует декоратор ListBoxChrome. Существуют два более общих декоратора, которые полезны при построении пользовательских интерфейсов: Border обсуждается в этой главе, a Viewbox — в главе 12. WrapPanel и DockPanel Очевидно, что одна лишь панель StackPanel не может помочь в создании реалистичного пользовательского интерфейса. Чтобы довершить картину, панель StackPanel должна работать с другими более развитыми контейнерами компоновки. Только так получится создать полноценное окно. Наиболее изощренный контейнер компоновки — это Grid, который рассматривается далее в этой главе. Но сначала стоит взглянуть на WrapPanel и DockPanel — два простых контейнера компоновки, предлагаемых WPF. Они дополняют StackPanel другим поведением компоновки. WrapPanel Панель WrapPanel располагает элементы управления в доступном пространстве — по одной строке или колонке за раз. По умолчанию свойство WrapPanel.Orientation установлено в Horizontal; элементы управления располагаются слева направо, затем — в следующих строках. Установка значения Vertical для свойства WrapPanel. Orientation приводит к размещению элементов в нескольких колонках. Совет. Подобно StackPanel, панель WrapPanel действительно предназначена для управления мелкими деталями пользовательского интерфейса, а не компоновкой всего окна. Например, WrapPanel можно использовать для удержания вместе кнопок в элементе управления, подобном панели инструментов. Ниже приведен пример, в котором определяется последовательность кнопок с разными выравниваниями, помещенных в WrapPanel:
94 Глава 3. Компоновка <WrapPanel Margin=,,3"> <Button VerticalAlignment="Top">Top Button</Button> <Button MinHeight=0">Tall Button 2</Button> <Button VerticalAlignment="Bottom">Bottom Button</Button> <Button>Stretch Button</Button> <Button VerticalAlignment="Center,,>Centered Button</Button> </WrapPanel> На рис. 3.9 показано, что кнопки переносятся для заполнения текущего размера WrapPanel (определяемого размером окна, содержащего его). Как демонстрирует этот пример, WrapPanel в горизонтальном режиме создает серии воображаемых строк, высота каждой из которых определяется высотой самого крупного содержащегося в ней элемента. Другие элементы управления могут быть растянуты для заполнения строки или выровнены в соответствии со значением свойства VerticalAlignment. В примере, представленном слева на рис. 3.9, все кнопки выстроены в одну строку, причем некоторые растянуты, а другие выровнены по этой строке. В примере справа несколько кнопок выталкиваются на вторую строку. Поскольку вторая строка не включает слишком высоких кнопок, высота строки равна минимальной высоте кнопок. В результате не важно, какое значение VerticalAlignment используют кнопки в этой строке. .youtPanels 1^1'°> ШШ Top Button; £utton2 Bottom Button :—] Stretch Button |Centerea Button • ' LayoutPanels Tall Button 2 Bottom Bu Centered В [Bottom Button]Stretch Button 3uttoni Рис. З.9. Перенос кнопок На заметку! WrapPanel — единственная панель, которая не может дублироваться за счет хитроумного использования элемента Grid. DockPanel Панель DockPanel обеспечивает более интересный вариант компоновки. Эта панель растягивает элементы управления вдоль одной из внешних границ. Простейший способ представить это — вообразить панель инструментов, которая присутствует в верхней части многих Windows-приложений. Такие панели инструментов пристыковываются к верхней части окна. Как и в случае StackPanel, пристыкованные элементы должны выбрать один аспект компоновки. Например, если вы пристыковать кнопку к верхней части DockPanel, она растянется на всю ширину DockPanel, но получит высоту, которая ей понадобится (на основе своего содержимого и свойства MinHeight). С другой стороны, если пристыковать кнопку к левой стороне контейнера, ее высота будет растянута для заполнения контейнера, но ширина будет установлена по необходимости. Возникает закономерный вопрос: каким образом дочерние элементы выбирают сторону для пристыковки? Ответ: через присоединенное свойство по имени Dock, которое может быть установлено в Left, Right, Top или Bottom. Каждый элемент, помещаемый внутри DockPanel, автоматически получает это свойство.
Глава 3. Компоновка 95 Ниже приведен пример, который помещает по одной кнопке на каждую сторону DockPanel: <DockPanel LastChildFill="True"> <Button DockPanel.Dock="Top">Top Button</Button> <Button DockPanel.Dock="Bottom">Bottom Button</Button> <Button DockPanel.Dock="Left">Left Button</Button> <Button DockPanel.Dock="Right">Right Button</Button> <Button>Remaining Space</Button> </DockPanel> В этом примере также свойство LastChildFill устанавливается в true, что указывает DockPanel о необходимости отдать оставшееся пространство последнему элементу. Результат показан на рис. 3.10. SmpleDock i_5H ©.. _е" Button Top Button Remaining Space Right Button Рис. 3.10. Пристыковка к каждой стороне Ясно, что при такой пристыковке элементов управления важен порядок. В данном примере верхняя и нижняя кнопки получают всю ширину DockPanel, поскольку они пристыкованы первыми. Когда затем стыкуются левая и правая кнопки, они помещаются между первыми двумя. Если поступить наоборот, то левая и правая кнопки получат полную высоту сторон панели, а верхняя и нижняя станут уже, потому что им придется размещаться между боковыми кнопками. Можно пристыковать несколько элементов к одной стороне. В этом случае элементы просто выстраиваются вдоль этой стороны в том порядке, в котором они объявлены в разметке. И, если вам не нравится поведение в отношении растяжения и промежуточных пробелов, можете подкорректировать свойства Margin, HorizontalAlignment и VerticalAlignment, как делали это со StackPanel. Ниже для целей иллюстрации приведена модифицированная версия предыдущего примера. <DockPanel LastChildFill="True"> <Button DockPanel.Dock="Top">A Stretched Top Button</Button> <Button DockPanel.Dock="Top" HorizontalAlignment="Center"> A Centered Top Button</Button> <Button DockPanel.Dock="Top" HorizontalAlignment="Left"> A Left-Aligned Top Button</Button> <Button DockPanel.Dock="Bottom">Bottom Button</Button> <Button DockPanel.Dock="Left">Left Button</Button> <Button DockPanel.Dock="Right">Right Button</Button> <Button>Remaining Space</Button> </DockPanel>
96 Глава 3. Компоновка Поведение в отношении стыковки остается прежним. Сначала стыкуются верхние кнопки, затем стыкуется нижняя кнопка и, наконец, оставшееся пространство делится между боковыми кнопками, а последняя кнопка размещается в середине. На рис. 3.11 можно видеть полученное в результате окно. • SimpleDodc A Stretched Top Button |A Centered Top Button [A Left-Aligned Top Button Left Button Remaining Space Right Button Bottom Button ь, J Рис. 3.11. Стыковка нескольких элементов к верхней части окна Вложение контейнеров компоновки Панели StackPanel, WrapPanel и DockPanel редко используются сами по себе. Вместо этого они применяются для формирования частей интерфейса. Например, панель DockPanel можно использовать для размещения разных контейнеров StackPanel и WrapPanel в соответствующих областях окна. Например, предположим, что необходимо создать стандартное диалоговое окно с кнопками ОК и Cancel (Отмена) в нижнем правом углу, расположив большую область содержимого в остальной части окна. Существует несколько способов смоделировать этот интерфейс в WPF, но простейший вариант, при котором применяются описанные ранее панели, выглядит следующим образом. 1. Создайте горизонтальную панель StackPanel для размещения рядом кнопок ОК и Cancel. 2. Поместите панель StackPanel в DockPanel и пристыкуйте ее к нижней части окна. 3. Установите свойство DockPanel.LastChildFill в true, чтобы можно было использовать остаток окна для заполнения прочим содержимым. Сюда можно добавить другой элемент управления компоновкой либо просто обычный элемент управления Text Box (как в примере). 4. Установите значения полей, чтобы распределить пустое пространство. Ниже показана итоговая разметка: <DockPanel LastChildFill="True"> <StackPanel DockPanel.Dock="Bottom" HorizontalAlignment="Right" Orientation="Horizontal"> <Button Margin=0/10/2/10" Padding=">0K</Button>
Глава 3. Компоновка 97 <Button Margin=/10,10,10" Padding=">Cancel</Button> </StackPanel> <TextBox DockPanel.Dock="Top" Margin=0">This is a test.</TextBox> </DockPanel> В этом примере с помощью свойства Padding добавляется некоторое минимальное пространство между рамкой кнопки и ее внутренним содержимым ("ОК" или "Cancel"). На рис. 3.12 можно видеть полученное в результате диалоговое окно. * BasicDialogBox iSV^J ->ч| This is a test. СК Cancel Рис. 3.12. Базовое диалоговое окно На первый взгляд может показаться, что все это требует больше работы, чем точное размещение с использованием координат в традиционном приложении Windows Forms. Во многих случаях так оно и есть. Однако более высокие временные затраты на установку компенсируются легкостью, с которой можно в будущем изменять пользовательский интерфейс. Например, если вы решите, что кнопки ОК и Cancel должны размещаться по центру нижней части окна, достаточно просто изменить выравнивание содержащей их панели StackPanel: <StackPanel DockPanel.Dock="Bottom" HorizontalAlignment="Center" ... > Такой дизайн — простое окно с центрированными кнопками — уже демонстрирует результат, который был невозможен в Windows Forms из .NET 1.x (по крайней мере, невозможен без написания кода) и который требовал специализированных контейнеров компоновки в Windows Forms из .NET 2.0. И если вы когда-либо видели код для визуального конструктора, сгенерированный процессом сериализации Windows Forms, то согласились бы, что используемая здесь разметка яснее, проще и компактнее. Добавив стиль к этому окну (глава 11), можно еще более усовершенствовать его и удалить другие излишние детали (вроде установки полей), чтобы создать по-настоящему адаптируемый пользовательский интерфейс. Совет. При наличии дерева элементов с плотными вложениями очень легко потерять представление об общей структуре. В Visual Studio доступно удобное средство, показывающее древовидное представление элементов и позволяющее выбирать в нем нужный элемент для просмотра или модификации. Этим средством является окно Document Outline (Эскиз документа), которое открывается выбором пункта меню View=>Other Windows^Document Outline (Вид1^Другие окна1^Эскиз документа).
98 Глава 3. Компоновка Grid Элемент управления Grid — это наиболее мощный контейнер компоновки в WPF. Большая часть того, что можно достичь с помощью других элементов управления компоновкой, также возможно и в Grid. Контейнер Grid является идеальным инструментом для разбиения окна на меньшие области, которыми можно управлять с помощью других панелей. Фактически Grid настолько удобен, что при добавлении в Visual Studio нового документа XAML для окна автоматически добавляются дескрипторы Grid в качестве контейнера первого уровня, вложенного внутрь корневого элемента Window. Grid располагает элементы в невидимой сетке строк и колонок. Хотя в отдельную ячейку этой сетки можно поместить более одного элемента (и тогда они перекрываются), обычно имеет смысл помещать в каждую ячейку по одному элементу. Конечно, этот элемент сам может быть другим контейнером компоновки, который организует собственную группу содержащихся в нем элементов управления. Совет. Хотя Grid задуман как невидимый элемент, можно установить свойство Grid. ShowGridLines в true и получить наглядное представление о нем. Это средство на самом деле не предназначено для украшения окна. В действительности это средство для облегчения отладки, которое предназначено для того, чтобы наглядно показать, как Grid разделяет пространство на отдельные области. Благодаря ему, появляется возможность точно контролировать то, как Grid выбирает ширину колонок и высоту строк. Создание компоновки на основе Grid — двухшаговый процесс. Сначала выбирается необходимое количество колонок и строк. Затем каждому содержащемуся элементу назначается соответствующая строка и колонка, тем самым помещая его в правильное место. Колонки и строки создаются путем заполнения объектами коллекции Grid. ColumnDef initions и Grid.RowDef initions. Например, если вы решите, что требуется две строки и три колонки, то используйте следующие дескрипторы: <Grid ShowGridLines="True"> <Grid.RowDefinitions> <RowDef initionX/RowDef inition> <RowDefinition></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefmition></ColumnDefinition> <ColumnDef initionx/Column Definition> <ColumnDef initionX/Column Definition> </Grid.ColumnDefinitions> </Grid> В этом примере демонстрируется, что указывать какую-либо информацию в элементах RowDef in it ion или ColumnDef in it ion не обязательно. Если вы оставите их пустыми (как показано здесь), то Grid поровну разделит пространство между всеми строками и колонками. В данном примере каждая ячейка будет одного и того же размера, который зависит от размера включающего окна. Для помещения индивидуальных элементов в ячейку используются присоединенные свойства Row и Column. Оба эти свойства принимают числовое значение индекса, начинающееся с 0. Например, вот как можно создать частично заполненную кнопками сетку:
Глава 3. Компоновка 99 <Grid ShowGridLines="True"> <Button Grid.Row=" Grid.Column=">Top Left</Button> <Button Grid.Row=" Grid.Column="l">Middle Left</Button> <Button Gnd.Row=" 1" Grid.Column=">Bottom Right</Button> <Bu'tton Grid.Row="l" Grid.Column="l">Bottom Middle</Button> </Grid> Каждый элемент должен быть помещен в свою ячейку явно. Это позволяет помещать в одну ячейку более одного элемента (что редко имеет смысл) или же оставлять определенные ячейки пустыми (что часто бывает полезным). Это также означает возможность объявления элементов в любом порядке, как это сделано с последними двумя кнопками в этом примере. Однако более ясной разметка получится, если определять элементы управления строку за строкой, а в каждой строке — слева направо. Существует одно исключение. Если не указать значение для свойства Grid.Row, то оно предполагается равным 0. То же самое касается и свойства Grid.Column. Таким образом, если опущены оба атрибута элемента, он помещается в первую ячейку Grid. На заметку! Контейнер Grid помещает элементы в предопределенные строки и колонки. Это отличает его от таких контейнеров компоновки, как WrapPanel и StackPanel, создающих неявные строки и колонки в процессе размещения дочерних элементов. Чтобы создать сетку, состоящую из более чем одной строки и одной колонки, необходимо определить строки и колонки явно, используя объекты RowDefinition и ColumnDefinition. На рис. 3.13 показано, как эта простая сетка выглядит в разных размерах. Обратите внимание, что свойство ShowGridLines установлено в true, так что можно видеть границы между колонками и строками. Как и можно было бы ожидать, Grid предоставляет базовый набор свойств компоновки, перечисленных в табл. 3.3. Это значит, что можно добавлять поля вокруг содержимого ячейки, изменять режим установки размеров, чтобы элемент не рос, заполняя ячейку целиком, а также выравнивать элемент по одному из граней ячейки. Если вы заставите элемент иметь размер, превышающий тот, что может уместить ячейка, часть содержимого будет отсечена. • SimpleGnd т Top Left Middle Left Bottom Middle» Bottom Right • SimpleGnd Top Left Middle Left Bottom Middle -> ._.... Bottom Right | Рис. 3.13. Простая сетка
100 Глава 3. Компоновка Использование Grid в Visual Studio При использовании Grid на поверхности проектирования Visual Studio вы обнаружите, что он работает несколько иначе, чем другие контейнеры компоновки. При перетаскивании элемента на Grid среда Visual Studio позволяет поместить его в точную позицию. Visual Studio выполняет подобный фокус, устанавливая свойство Margin элемента. При установке полей Visual Studio использует ближайший угол. Например, если ближайшим к элементу является верхний левый угол Grid, то Visual Studio устанавливает верхнее и левое поля для позиционирования элемента (оставляя правое и нижнее поля равными 0). Если вы перетаскиваете элемент ниже, приближая его к нижнему левому углу, то Visual Studio устанавливает вместо этого нижнее и левое поля и устанавливает свойство VerticalAlignment в Bottom. Это очевидно влияет на то, как перемещается элемент при изменении размера Grid. Процесс установки полей в Visual Studio выглядит достаточно прямолинейным, но в большинстве случаев он приводит не к тому результату, который необходим. Обычно требуется более гибкая потоковая компоновка, которая позволяет некоторым элементам расширяться динамически, "расталкивая" соседей. В этом сценарии вы сочтете жесткое кодирование позиции свойством Margin совершенно негибким. Проблема усугубляется при добавлении множества элементов, потому что Visual Studio не добавляет автоматически новых ячеек. В результате все такие элементы помещаются в одну и ту же ячейку. Разные элементы могут выравниваться по разным углам Grid, что заставит их перемещаться друг относительно друга (и даже перекрывать друг друга) при изменении размеров окна. Однажды поняв, как работает Grid, вы сможете исправлять эти проблемы. Первый трюк заключается в конфигурировании Grid перед добавлением элементов за счет определения новых строк и колонок. (Коллекции RowDefinitions и ColumnDefinitions можно редактировать с использованием окна Properties (Свойства).) Однажды настроив Grid, вы можете перетаскивать в него нужные элементы и конфигурировать их настройки полей и выравнивание в окне Properties либо редактируя XAML-разметку вручную. Тонкая настройка строк и колонок Если бы Grid был просто коллекцией строк и колонок пропорциональных размеров, от него было бы мало толку. К счастью, он не таков. Чтобы открыть полный потенциал Grid, можно изменять способы установки размеров каждой строки и колонки. Элемент Grid поддерживает следующие стратегии изменения размеров. • Абсолютные размеры. Выбирается точный размер с использованием независимых от устройства единиц измерения. Это наименее удобная стратегия, поскольку она недостаточно гибка, чтобы справиться с изменением размеров содержимого, изменением размеров контейнера или локализацией. • Автоматические размеры. Каждая строка и колонка получает в точности то пространство, которое нужно, и не более. Это один из наиболее удобных режимов изменения размеров. • Пропорциональные размеры. Пространство разделяется между группой строк и колонок. Это стандартная установка для всех строк и колонок. Например, на рис. 3.13 вы увидите, что все ячейки увеличиваются пропорционально при расширении Grid. Для максимальной гибкости можно смешивать и сочетать эти разные режимы изменения размеров. Например, часто удобно создать несколько автоматически изменяющих размер строк и затем позволить одной или двум остальным строкам поделить между собой оставшееся пространство через пропорциональную установку размеров.
Глава 3. Компоновка 101 Режим изменения размеров устанавливается с помощью свойства Width объекта ColumnDef inition или свойства Height объекта RowDef inition, присваивая ему некоторое число или строку. Например, ниже показано, как установить абсолютную ширину в 100 независимых от устройства единиц: <ColumnDefinition Width=00"></ColumnDefinition> Чтобы использовать пропорциональное изменение размеров, указывается значение Auto: <ColumnDefinition Width="Autо"></ColumnDefinition> И, наконец, для активизации пропорционального изменения размеров задается звездочка (*): <ColumnDefinition Width="*"></ColumnDefinition> Этот синтаксис пришел из мира Интернета, где он применяется на страницах HTML с фреймами. Если вы используете смесь пропорциональной установки размеров с другими режимами, то пропорционально изменяемая строка или колонка получит все оставшееся пространство. Чтобы разделить оставшееся пространство неравными частями, можно назначить вес (weight), который должен указываться перед звездочкой. Например, если есть две строки пропорционального размера, и требуется, чтобы высота первой была равна половине высоты второй, необходимо разделить оставшееся пространство следующим образом: <RowDefinition Height="*"></RowDefinition> <RowDefinition Height=*"></RowDefinition> Это сообщит Grid о том, что высота второй строки должна быть вдвое больше высоты первой строки. Для разделения дополнительного пространства можно указывать любые числа. На заметку! Легко организовать программное взаимодействие между объектами ColumnDef inition и RowDef inition. Нужно просто знать, что свойства Width и Height — это объекты типа GetLength. Чтобы создать GridLength, представляющий определенный размер, просто передайте соответствующее значение конструктору GridLength. Для создания объекта GridLength, представляющего пропорциональный размер (*), необходимо передать число конструктору GridLength и значение GridUnitType.Start в качестве второго аргумента конструктора. Для обозначения автоматического изменения размера используется статическое свойство GridLength.Auto. С помощью этих режимов установки размеров можно продублировать тот же пример диалогового окна, показанного на рис. 3.12, используя вместо DockPanel контейнер Grid верхнего уровня для разделения окна на две строки. Ниже показана разметка, которая для этого понадобится: <Grid ShowGridLines="True"> <Grid.RowDefinitions> <RowDefinition Height="*"></RowDeflnition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions> <TextBox Margin=0" Grid.Row=">This is a test.</TextBox> <StackPanel Grid.Row=" HorizontalAlignment="Right" Orientation="Horizontal"> <Button Margin=0,10,2,10" Padding=">OK</Button> <Button Margin=,10,10,10" Padding=">Cancel</Button> </StackPanel> </Grid>
102 Глава 3. Компоновка Совет. В этом элементе Grid не объявлены какие-либо колонки. Такое сокращение можно применять, если Grid использует только одну колонку, размер которой устанавливается пропорционально (так что заполняет всю ширину Grid). Этот код разметки немного длиннее, но обладает тем преимуществом, что объявляет элементы управления в порядке их появления, что облегчает его понимание. Выбор такого подхода — просто вопрос персональных предпочтений. При желании вложенную панель StackPanel можно заменить элементом Grid с одной строкой и одной колонкой. На заметку! С помощью вложенных контейнеров Grid можно создать практически любой интерфейс. (Единственное исключение — строки с переносом колонок, использующие WrapPanel.) Однако когда вы имеете дело с небольшими разделами пользовательского интерфейса или расположением небольшого количества элементов, то часто проще применить более специализированные контейнеры StackPanel и DockPanel. Округление компоновки Без округления компоновки щая от разрешения система измерений. Хотя это обеспечивает гибкость для работы с различным оборудованием, иногда оно привносит некоторые сложности. Одной из них является тот факт, что элементы могут оказаться выровненными по межпиксельным границам. Другими словами, элементы будут позиционированы по дробным координатам, которые не совпадают с линией физических пикселей. Это П можете случиться в результате указания нецелых размеров для контейнеров компоновки. Однако подобная ситуация может возникнуть и тогда, когда она не ожидается, например, при создании Grid с пропорциональными размерами. Например, предположим, что есть контейнер Grid из двух столбцов, имеющий общую ширину в 200 пикселей. Если распределить ширину между столбцами поровну, каждый получит по 100 пикселей. Но если общая ширина составляет, скажем, 175 пикселей, то разделение между ними не столь ясно, и каждый столбец получает по 87,5 пикселя. Это значит, что второй столбец будет слегка смещен относительно обычных границ пикселей. Обычно это не представляет проблемы, но если столбец содержит один из элементов формы, рамку или графическое изображение, то содержимое может оказаться размытым, потому что WPF использует сглаживание в отношении того, что иначе имело бы резкие грани по границам пикселей. Проблема проиллюстрирована на рис. 3.14. Здесь показана увеличенная часть окна, которое содержит два контейнера Grid. В верхнем контейнере Grid не используется округление компоновки, в результате чего четкие границы прямоугольника внутри становятся размытыми при определенных размерах окна. Существует простое решение этой проблемы. Просто установите свойство Use Lay out Rounding контейнера компоновки в true: <Grid UseLayoutRounding="True"> После этого WPF будет обеспечивать размещение всего содержимого контейнера компоновки четко по ближайшим границам пикселей, исключая размытие. С округлением компоновки Рис. 3.14. Размытие границ при пропорциональном распределении размеров
Глава 3. Компоновка 103 Объединение строк и колонок Вы уже видели, как помещаются элементы в ячейки с использованием присоединенных свойств Row и Column. Можно также использовать еще два присоединенных свойства, чтобы растянуть элемент на несколько ячеек: RowSpan и ColumnSpan. Эти свойства принимают количество строк или колонок, которые должен занять элемент. Например, следующая кнопка займет все место, доступное в первой и второй ячейках первой строки: <Button Grid.Row=" Grid.Column=" Grid.RowSpan=">Span Button</Button> А эта кнопка растянется всего на четыре ячейки, охватив две колонки и две строки: <Button Grid.Row=" Grid.Column=" Grid.RowSpan=" Grid.ColumnSpan="> Span Button</Button> Объединение нескольких строк и колонок позволяет достичь некоторых интересных эффектов, и особенно удобно, когда требуется уместить в табличную структуру элементы, которые меньше или больше существующих ячеек. Используя объединение колонок, пример простого диалогового окна на рис. 3.12 можно переписать, оставив единственный Grid. Этот контейнер Grid делит окно на три колонки, растягивая текстовое поле на все три, и использует последние две колонки для выравнивания кнопок О К и Cancel (Отмена). <Grid ShowGridLines="True"> <Grid.RowDefinitions> » <RowDefmition Height="*"></RowDef1nition> <RowDefmition He1ght="Auto"></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefmition Width="*"></ColumnDefinition> <ColumnDefmition Width=MAuto"></ColumnDefinition> <ColumnDefmition Width="Auto"></ColumnDefinition> </Grid.ColumnDefinitions> <TextBox Margin=0u Grid.Row=" Grid.Column=" Grid.ColumnSpan="> This is a test.</TextBox> <Button Margin=0,10,2,10" Padding=" Grid.Row=" Grid.Column="l">OK</Button> <Button Margin=,10,10,10" Padding=" Grid.Row="l" Grid.Column=">Cancel</Button> </Grid> Большинство разработчиков согласится с утверждением, что такая компоновка непонятна. Ширины колонок определяются размером двух кнопок окна, что затрудняет добавление нового содержимого к существующей структуре Grid. Для внесения даже минимального дополнения к этому окну, скорее всего, понадобится создавать новый набор колонок. Как видите, при выборе контейнера компоновки для окна нужно не просто добиться корректного поведения компоновки, а необходимо также получить структуру компоновки, которую легко сопровождать и расширять в будущем. Хорошее эмпирическое правило заключается в использовании меньших контейнеров компоновки, подобных StackPanel для одноразовых задач компоновки, таких как организация группы кнопок. С другой стороны, если требуется применить согласованную структуру к более чем одной области окна (как с колонкой текстового поля, показанной ниже, на рис. 3.22), то в таком случае Grid — незаменимый инструмент для стандартизации компоновки.
104 Глава 3. Компоновка Разделенные окна Каждый пользователь Windows встречался с разделительными полосами — перемещаемыми разделителями, которые отделяют одну часть окна от другой. Например, в проводнике Windows слева находится список папок, а справа — список файлов. Перетаскивая разделительную полосу, можно устанавливать пропорции между этими двумя панелями в окне. В WPF полосы разделителей представлены классом GridSplitter и являются средствами Grid. Добавляя GridSplitter к Grid, вы предоставляете пользователю возможность изменения размеров строк и колонок. На рис. 3.15 показано окно, в котором GridSplitter находится между двумя колонками. Перетаскивая полосу разделителя, пользователь может менять относительные ширины обеих колонок. SolitWindow jo |&_Циа^] Left Left 4 Right Right ■ SplitWindow Left Left i v :1u: @.b£id| S Right i 1 : j Right ~ ,. .,' л.~. ) Рис. 3.15. Перемещение полосы разделителя Большинство программистов считают GridSplitter наиболее интуитивно понятной частью WPF. Чтобы разобраться, как использовать его для получения требуемого эффекта, нужно лишь немного поэкспериментировать. Ниже предлагается несколько подсказок. • GridSplitter должен быть помещен в ячейку Grid. Его можно поместить в ячейку с существующим содержимым — тогда понадобится настроить установки полей, чтобы они не перекрывались. Лучший подход заключается в резервировании специальной колонки или строки для GridSplitter, со значениями Height или Width, равными Auto. • GridSplitter всегда изменяет размер всей строки или колонки (в не отдельной ячейки). Чтобы сделать внешний вид GridSplitter соответствующим такому поведению, необходимо растянуть GridSplitter по всей строке или колонке, а не ограничиваться единственной ячейкой. Для этого используются свойства Row Span и ColumnSpan, которые рассматривались ранее. Например, GridSplitter на рис. 3.15 имеет значение RowSpan, равное 2. В результате он растягивается на всю колонку. Если вы не добавите эту установку, он появится только в верхней строке (где помещен), даже несмотря из. то, что перемещение разделительной полосы изменило бы размер всей колонки.
Глава 3. Компоновка 105 • Изначально GridSplitter настолько мал, что его не видно. Чтобы сделать его удобным, понадобится указать его минимальный размер. В случае вертикальной разделяющей полосы (вроде показанной на рис. 3.15) нужно установить VerticalAlignment в Stretch (чтобы он заполнил всю высоту доступной области), a Width — в фиксированный размер (например, 10 независимых от устройства единиц). В случае горизонтальной разделительной полосы следует установить HorizontalAlignment в Stretch, a Height — в фиксированный размер. • Выравнивание GridSplitter также определяет, будет ли разделительная полоса горизонтальной (используемой для изменения размеров строк) или вертикальной (для изменения размеров колонок). В случае горизонтальной разделительной полосы необходимо установить VerticalAlignment в Center (что принято по умолчанию), указав тем самым, что перетаскивание разделителя изменит размеры строк, находящихся выше и ниже. В случае вертикальной разделительной полосы (как на рис. 3.13) понадобится установить HorizontalAlignment в Center, чтобы изменять размеры соседних колонок. На заметку! Изменить поведение установки размеров можно через свойства ResizeDirection и ResizeBehavior объекта GridSplitter. Однако проще поставить это поведение в зависимость от установок выравнивания, что и принято по умолчанию. Еще не запутались? Чтобы закрепить эти правила, стоит взглянуть на реальный код разметки примера, показанного на рис. 3.15. В следующем листинге детали GridSplitter выделены полужирным. <Grid> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDef mitionx /RowDef in it ion> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefmition MinWidth=00"></ColumnDefinition> <ColumnDefinition Width="Auto"X/ColmnnDefinition> <ColumnDefmition MinWidth=0"x/ColumnDefinition> </Grid.ColumnDefinitions> <Button Grid.Row=" Grid.Column=" Margin=">Left</Button> <Button Grid.Row=" Grid.Column=" Margin=">Right</Button> <Button Grid.Row="l" Grid.Column=" Margin=">Left</Button> <Button Grid.Row="l" Grid.Column=" Margin=">Right</Button> <GridSplitter Grid.Row=" Grid.Column="l" Grid.RowSpan=" Width=" VerticalAlignment="Stretch" HorizontalAlignment="Center" Shows Preview=" False" X/GridSplitter> </Grid> Совет. Для создания правильного элемента GridSplitter не забудьте присвоить значения свойствам VerticalAlignment, HorizontalAlignment и Width (или Height). Эта разметка включает одну дополнительную деталь. Когда объявляется GridPlitter, свойство ShowPreview устанавливается в false. В результате, когда полоса разделителя перетаскивается от одной стороны к другой, колонки немедленно изменяют свой размер. Но если установить ShowPreview в true, то при перетаскивании отображается лишь серая тень, следующая за курсором мыши, которая показывает, куда попадет разделитель после отпускания кнопки мыши. Вплоть до этого момента колонки изменять размеры не будут. После получения фокуса элементом GridSplitter для изменения размера можно также использовать клавиши со стрелками.
106 Глава 3. Компоновка ShowPreview — не единственное свойство GridSplitter, которое доступно для установки. Можно также изменить свойство Draglncrement, если полоса разделителя должна перемещаться "шагами" (например, по 10 единиц за раз). Для управления минимально и максимально допустимыми размерами колонок просто устанавливаются соответствующие свойства в разделе ColumnDef initions, как было показано в предыдущем примере. Совет. Есть возможность изменить заливку GridSplitter, чтобы она не выглядела просто серым прямоугольником. Трюк заключается в использовании свойства Background, которое принимает значения простых цветов и более сложных кистей. Обычно Grid содержит не более одного GridSplitter. Тме не менее, можно вкладывать один Grid в другой, и при этом каждый из них будет иметь собственный GridSplitter. Это позволяет создавать окна, которые разделены на две области (например, на левую и правую панель), одна из которых (скажем, правая), в свою очередь, также разделена на два раздела (на верхний и нижний с изменяемыми размерами). Пример показан на рис. 3.16. DoubleSplitWindow Top Left Bottom Left Top Right Bottom Right Рис. 3.16. Окна с двумя разделителями Создать такое окно довольно просто, хотя управление тремя контейнерами Grid, которые здесь присутствуют, требует некоторых усилий: общий Grid, вложенный Grid слева и вложенный Grid справа. Единственный трюк состоит в том, чтобы установить GridSplitter в правильную ячейку и задать ему правильное выравнивание. Ниже показана полная разметка. <'-- Это Grid для целого окна. --> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition></ColumnDefinition> <ColumnDefinition Width="Auto"></ColumnDefinition> <ColumnDefinition></ColumnDefinition> </Grid.ColumnDefinitions> <!-- Это вложенный Grid слева. Он не делится разделителем. --> » DoubleSplitWindow Го* _ef: Bottom _ef: Top Right 1 Bottom Right
Глава 3. Компоновка 107 <Grid Grid.Column=" VerticalAlignment="Stretch"> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDefinition></RowDeflnition> </Grid.RowDefinitions> <Button Margin=" Grid.Row=">Top Left</Button> <Button Margin=" Grid.Row="l">Bottom Left</Button> </Grid> < '-- Это вертикальный разделитель, находящийся между двумя вложенными (правым и левым) Grid. --> <GridSplitter Grid.Column="l" Width=" НогizontalAlignment="Center" VerticalAlignment="Stretch" ShowsPreview="False"></GridSplitter> <'-- Это вложенный Grid справа. --> <Grid Grid.Column="> <Grid.RowDefinitions> <RowDef mitionx /RowDef inition> <RowDefmition Height="Auto"></RowDefinition> < RowDef mitionx /RowDef 1 nit ion> </Grid.RowDefinitions> <Button Grid.Row=" Margin=">Top Right</Button> <Button Grid.Row=" Margin=">Bottom Right</Button> < '-- Это горизонтальный разделитель, отделяющий верхнюю область от нижней. --> <GridSplitter Grid.Row="l" Height=" VerticalAlignment="Center" HorizontalAlignment="Stretch" Shows Preview="False"x/GridSplitter> </Grid> </Grid> Совет. Помните, что если Grid имеет всего одну строку или колонку, раздел RowDef mition может быть опущен. Кроме того, элементы, которые не имеют явно установленной позиции строки, предполагают значение Grid.Row, равное 0, и помещаются в первой строке. То же самое справедливо и в отношении элементов, для которых не указано Grid.Column Группы с общими размерами Как уже было указано, Grid содержит коллекцию строк и колонок, размер которых устанавливается явно, пропорционально или на основе размеров их дочерних элементов. Существует только один способ изменить размер строки или колонки — приравнять его размеру другой строки или колонки. Это выполняется с помощью средства, которое называется группы с общими размерами (shared size groups). Цель таких групп — поддержание согласованности между различными частями пользовательского интерфейса. Например, размер одной колонки может быть установлен в соответствии с ее содержимым, а размер другой колонки — в точности равным размеру первой. Однако реальное преимущество групп с общими размерами заключается в обеспечении одинаковых пропорций различным элементам управления Grid. Чтобы понять, как это работает, рассмотрим пример, показанный на рис. 3.17. Это окно оснащено двумя объектами Grid — один в верхней части окна (с тремя колонками) и один в его нижней части (с двумя колонками). Размер левой крайней колонки первого Grid устанавливается пропорционально ее содержимому (длинной текстовой строке). Левая крайняя колонка второго Grid имеет в точности ту же ширину, хотя меньшее
108 Глава 3. Компоновка содержимое. Дело в том, что они входят в одну размерную группу. Независимо от того, какое содержимое вы поместите в первую колонку первого Grid, первая колонка второго Grid останется синхронизированной. [ S-wedSizeGroup A very long bit of text ; More text ; A text box Some text in between the two grids- Short A text box Рис. 3.17. Два элемента Grid, разделяющие одно определение колонки Как демонстрирует этот пример, колонки с общими размерами могут принадлежать к разным элементам Grid. В этом примере верхний Grid имеет на одну колонку больше и потому оставшееся пространство в нем распределяется иначе. Аналогично колонки с общими размерами могут занимать разные позиции, так что можно создать отношение между первой колонкой одного Grid и второй колонкой другого. И очевидно, что колонки при этом могут иметь совершенно разное содержимое. Когда применяется группа с общими размерами, это все равно, как если бы создавалось одно определение колонки (или строки), используемое в более чем одном месте. Это не просто однонаправленная копия одной колонки в другую. В этом можно убедиться, изменив в предыдущем примере содержимое разделенной колонки второго Grid. Теперь колонка в первом Grid будет удлинена для сохранения соответствия (рис. 3.18). • SharedStzeGroup а . 3 A very long bit of text More text ■ A text box Some text in between the two grids... An even longer bit of text over here ; A text box Рис. 3.18. Колонки, разделяющие общий размер, остаются синхронизированными
Глава 3. Компоновка 109 Можно даже добавить GridSplitter к одному из объектов Grid. Когда пользователь будет изменять размер колонки в одном Grid, то соответствующая разделенная колонка из второго Grid также будет синхронно менять свой размер. Создать группы с общими размерами просто. Понадобится лишь установить свойство SharedSizeGroup в обеих колонках, используя строку соответствия. В текущем примере обе колонки используют группу по имени Text Label. <Grid Margin=" Background="LightYellow" ShowGridLines="True"> <Grid.ColumnDefinitions> <ColumnDefmition Width="Auto" SharedSizeGroup="TextLabel"></ColumnDefinition> <ColumnDefmition Width="Auto"></ColumnDefinition> <ColumnDefinition></ColumnDefinition> </Grid.ColumnDefinitions> <Label Margin=">A very long bit of text</Label> <Label Grid.Column="l" Margin=">More text</Label> <TextBox Grid.Column=" Margin=">A text box</TextBox> </Grid> <Grid Margin=" Background="LightYellow" ShowGridLines="True"> <Grid. ColumnDef mitions> <ColumnDefinition Width="Auto" SharedSizeGroup="TextLabel"></ColumnDefinition> <ColumnDefmition></ColumnDeflnition> </Grid.ColumnDefinitions> <Label Margin=">Short</Label> <TextBox Grid.Column="l" Margin=">A text box</TextBox> </Grid> Осталось упомянуть еще одну деталь. Группы с общими размерами не являются глобальными для всего приложения, потому что более одного окна могут непреднамеренно использовать одно и то же имя. Можно предположить, что группы с общими размерами ограничены текущим окном, но на самом деле платформа WPF еще более строга в этом отношении. Чтобы разделить группу, необходимо явно установить присоединенное свойство Grid.IsSharedSizeScope в true в контейнере высшего уровня, содержащем объекты Grid, который имеет колонки с общими размерами. В текущем примере верхний и нижний Grid входят в другой Grid, предназначенный для этой цели, хотя столь же просто можно применить другой контейнер, такой как DockPanel или StackPanel. Ниже показана разметка Grid верхнего уровня. <Grid Grid.IsSharedSizeScope="True" Margin="> <Grid.RowDefinitions> <RowDef initionx/RowDef inition> <RowDefmition Height="Auto"></RowDefinition> <RowDef in ltionx/RowDef inition> </Grid.RowDefinitions> <Grid Grid.Row=" Margin=" Background="LightYellow" ShowGridLines="True"> </Grid> <Label Grid.Row="l" >Some text in between the two grids ...</Label> <Grid Grid.Row=" Margin=" Background="LightYellow" ShowGridLines="True"> </Grid> </Grid> Совет. Для синхронизации отдельных Grid с заголовками колонок можно было бы использовать группу с общими размерами. Ширина каждой колонки может быть затем определена ее содержимым, которое разделит заголовок. Допускается даже поместить GridSplitter в заголовок, и тогда пользователь сможет перетаскивать его для изменения размера заголовка и всей лежащей ниже колонки.
110 Глава 3. Компоновка UniformGrid Существует элемент типа сетки, который идет вразрез со всеми правилами, изученными до сих пор — это UniformGrid. В отличие от Grid, элемент UniformGrid не требует (и даже не поддерживает) предопределенных колонок и строк. Вместо этого вы просто задаете значения свойствам Rows и Columns для установки его размеров. Каждая ячейка всегда имеет одинаковый размер, потому что доступное пространство делится поровну. И, наконец, элементы помещаются в соответствующую ячейку на основе порядка их определения. Нет никаких присоединенных свойств Row и Column, и нет никаких пустых ячеек. Ниже приведен пример наполнения UniformGrid четырьмя кнопками: <UniformGrid Rows=" Columns="> <Button>Top Left</Button> <Button>Top Right</Button> <Button>Bottom Left</Button> <Button>Bottom Right</Button> </UniformGrid> UniformGrid используется намного реже, чем Grid. Элемент Grid — это инструмент общего назначения для создания компоновки окон, от наиболее простых до самых сложных. UniformGrid намного более специализированный контейнер компоновки, который в первую очередь предназначен для размещения элементов в жесткой сетке (например, для построения игрового поля для ряда игр). Многие программисты WPF никогда не пользуются UniformGrid. Координатная компоновка с помощью Canvas Единственный контейнер компоновки, который пока еще не рассматривался — это Canvas. Он позволяет размещать элементы, используя точные координаты, что, вообще говоря, является плохим выбором при проектировании развитых управляемых данными форм и стандартных диалоговых окон, но ценным инструментом, когда требуется построить нечто другое (вроде поверхности рисования для инструмента построения диаграмм). Canvas также является наиболее легковесным из контейнеров компоновки. Это объясняется тем, что он не включает в себя никакой сложной логики компоновки, согласовывающей размерные предпочтения своих дочерних элементов. Вместо этого он просто располагает их в указанных позициях с точными размерами, которые нужны. Для позиционирования элемента в контейнере Canvas устанавливаются присоединенные свойства Canvas.Left и Canvas.Top. Свойство Canvas.Left задает количество единиц измерения между левой гранью элемента и левой границей Canvas. Свойство Canvas.Top устанавливает количество единиц измерения между вершиной элемента и левой границей Canvas. Как всегда, эти значения выражаются в независимых от устройства единицах измерения, которые соответствуют обычным пикселям, когда системная установка DPI составляет 96 dpi. На заметку! В качестве альтернативы вместо Canvas.Left можно использовать Canvas. Right, чтобы расположить элемент относительно правого края Canvas, и Canvas.Bottom вместо Canvas.Top — чтобы расположить его относительно низа. Одновременно использовать Canvas.Right и Canvas.Left или Canvas.Top и Canvas.Bottom нельзя. Дополнительно можно устанавливать размер элемента явно, используя его свойства Width и Height. Это чаще применяется для Canvas, чем с другими панелями, потому что Canvas не имеет собственной логики компоновки. (К тому же вы часто будете ис-
Глава 3. Компоновка 111 пользовать Canvas, когда понадобится точный контроль над расположением комбинации элементов.) Если свойства Width и Height не устанавливаются, элемент получит желательный для него размер; другими словами, он станет достаточно большим, чтобы уместить свое содержимое. Ниже приведен пример простого контейнера Canvas, включающего четыре кнопки. <Canvas> <Button Canvas.Left=0" Canvas.Top=0">A0,10)</Button> <Button Canvas.Left=20" Canvas.Top=0">A20,30)</Button> <Button Canvas.Left=0" Canvas.Top="80" Width=0" Height=0"> F0,80)</Button> <Button Canvas.Left=0" Canvas.Top=20" Width=00" Height=0"> G0,120)</Button> </Canvas> На рис. 3.19 показан результат. Если вы измените размеры окна, то Canvas растянется для заполнения всего доступного пространства, но ни один из элементов управления на его поверхности не изменит своего положения и размеров. Контейнер Canvas не имеет средства привязки или стыковки, которые доступны в координатных компоновках Windows Forms. Отчасти это объясняется легковесностью Canvas. Другая причина в том, чтобы предотвратить использование Canvas для целей, для которых он не предназначен (например, для компоновки стандартного пользовательского интерфейса). Подобно любому другому контейнеру компоновки, Canvas может вкладываться внутрь пользовательского интерфейса. Это значит, что Canvas можно использовать для рисования более детализированного содержимого в одной части окна и применять более стандартные панели WPF для остальных элементов. Совет. Если вы используете Canvas рядом с другими элементами, можно установить его свойство ClipToBounds в true. В результате элементы внутри Canvas, которые выходят за его пределы, будут усечены на гранях Canvas. (Это предотвратит перекрытие других элементов в окне.) Все прочие контейнеры компоновки всегда усекают свои дочерние элементы, выходящие за их границы, независимо от установки ClipToBounds. Z-порядок При наличии более одного перекрывающегося элемента с помощью присоединенного свойства Canvas.ZIndex можно управлять их расположением. Обычно все добавляемые элементы имеют одинаковый ZIndex — 0. Элементы с одинаковым ZIndex отображаются в том порядке, в каком они представлены в коллекции Canvas.Children, который основан на порядке их определения в разметке XAML. Элементы, объявленные позже в разметке, такие как кнопка G0,120), отображаются поверх элементов, объявленных ранее, вроде кнопки A20,30). За счет увеличения ZIndex любой элемент можно передвинуть на более высокий уровень. Это объясняется тем, что элементы с большими ZIndex всегда появляются поверх элементов с меньшими ZIndex. Используя этот подход, можно поменять уровни в компоновке из предыдущего примера на противоположные: Рис. 3.19. Явно позиционированные кнопки в Canvas
112 Глава 3. Компоновка <Button Canvas.Left=0" Canvas.Top="80" Canvas.ZIndex="l" Width=0" Height=0"> F0,80)</Button> <Button Canvas.Left=0" Canvas.Top=20" Width=00" Height=0"> G0,120)</Button> На заметку! Действительные значения, которые используется для свойства Canvas.ZIndex, не важны. Важно отношение значений ZIndex разных элементов между собой. Для ZIndex можно указывать любое положительное или отрицательное целое число. Свойство ZIndex в частности удобно, если нужно изменить позицию элемента программно. Просто вызовите Canvas. Set ZIndex () и передайте ему элемент, который необходимо модифицировать, и новое значение ZIndex. К сожалению, не предусмотрено метода BringToFront () или SendToBackO, так что на вас возлагается задача отслеживать максимальное и минимальное значения ZIndex, если планируется реализовать это поведение. InkCanvas В WPF также имеется элемент InkCanvas, который подобен Canvas в одних отношениях и совершенно отличается в других. Подобно Canvas, элемент InkCanvas определяет четыре присоединенных свойства, которые можно применить к дочерним элементам для координатного позиционирования (Top, Left, Bottom и Right). Однако лежащий в его основе механизм существенно отличается. Фактически InkCanvas не наследуется от Canvas, и даже не наследуется от базового класса Panel. Вместо этого он наследуется непосредственно от FrameworkElement. Главное предназначение InkCanvas заключается в обеспечении перьевого ввода. Перо (stylus) — это подобное карандашу устройство ввода, используемое в планшетных ПК. Однако InkCanvas работает с мышью точно так же, как и с пером. Поэтому пользователь может рисовать линии или выбирать и манипулировать элементами в InkCanvas с применением мыши. InkCanvas в действительности содержит две коллекции дочернего содержимого. Уже знакомая коллекция Children содержит произвольные элементы — как и Canvas. Каждый элемент может быть позиционирован на основе свойств Top, Left, Bottom и Right. Коллекция Strokes содержит объекты System.Windows.Ink.Stroke, представляющие графический ввод, который рисует пользователь в InkCanvas. Каждая нарисованная линия или кривая становится отдельным объектом Stroke. Благодаря этим двум коллекциям, InkCanvas можно использовать для того, чтобы позволить пользователю аннотировать содержимое (хранящееся в коллекции Children) пометками (хранящимися в коллекции Strokes). Например, на рис. 3.20 показан элемент InkCanvas, содержащий изображение, аннотированное дополнительными пометками. Ниже приведена разметка InkCanvas из этого примера, которая определяет изображение: <InkCanvas Name="inkCanvas" Background="LightYellow" EditingMode=,,Ink"> <Image Source="office.jpg" InkCanvas.Top=0" InkCanvas.Left=0" Width= 8 7" Height=19"x/Image> </InkCanvas> Пометки нарисованы пользователем во время выполнения. InkCanvas может применяться несколькими существенно отличающимися способами, в зависимости от значения, которое устанавливается для свойства InkCanvas. EditingMode. Возможные варианты этого значения перечислены в табл. 3.5.
Глава 3. Компоновка 113 [ 111Т ЦКИ11 Рис. 3.20. Добавление пометок в InkCanvas Таблица 3.5. Значения перечисления InkCanvasEditingMode Имя Описание Ink GestureOnly InkAndGesture EraseByStroke EraseByPoint Select None InkCanvas позволяет пользователю рисовать аннотации. Это режим по умолчанию. Когда пользователь рисует мышью или пером, появляются штрихи InkCanvas не позволяет пользователю рисовать аннотации, но привлекает внимание к некоторым предопределенным жестам (gesture), таким как перемещение пера в одном направлении или подчеркивание содержимого. Полный список жестов определен в перечислении System.Windows.Ink. ApplicationGesture InkCanvas позволяет пользователю рисовать штриховые аннотации и также распознает предопределенные жесты InkCanvas удаляет штрих при щелчке. Если у пользователя есть перо, он может переключиться в этот режим, используя его обратный конец. (Определить текущий режим можно, проверив значение доступного только для чтения свойства ActiveEditingMode, а для изменения режима, используемого обратным концом пера, необходимо модифицировать свойство EditingModelnverted.) InkCanvas удаляет часть штриха (точку штриха) при щелчке на соответствующей его части InkCanvas позволяет пользователю выбирать элементы, хранящиеся в коллекции Children. Чтобы выбрать элемент, пользователь должен щелкнуть на нем или обвести "лассо" выбора вокруг него. Как только элемент выбран, его можно перемещать, изменять размер или удалять InkCanvas игнорирует ввод с помощью мыши или пера InkCanvas инициирует события при изменении режима редактирования (ActiveEditingModeChanged), обнаружении жеста в режимах GestureOnly или InkAndGesture (Gesture), рисовании штриха (StrokeCollected), стирании штриха (StrokeErasing и StrokeErased), а также при выборе элемента или изменении его в режиме Select (SelectionChanging, SelectionChanged, SelectionMoving,
114 Глава 3. Компоновка SelectionMoved, SelectionResizing и SelectionResized). События, оканчивающиеся на ing, представляют действие, которое начинается, но может быть отменено установкой свойства Cancel объекта EventArgs. В режиме Select элемент InkCanvas предоставляет довольно удобную поверхность проектирования для перетаскивания содержимого и различных манипуляций им. На рис. 3.21 показан элемент управления Button в InkCanvas, когда он был выбран (слева) и затем перемещен и увеличен (справа). plelnkCanvas EditingMode: Select • .-h e о Рис. 3.21. Перемещение и изменение размеров элемента в InkCanvas Как бы ни был интересен режим Select, он не совсем подходит для построения рисунков или диаграмм. Вы увидите лучший пример того, как создается поверхность рисования, в главе 14. Примеры компоновки Итак, исследованиям интерфейсов контейнеров компоновки WPF было уделено достаточное время. Теперь стоит взглянуть на несколько завершенных примеров компоновки. Это даст лучшее представление о том, как работают различные концепции компоновки WPF (такие как размер по содержимому, растягивание и вложение) в реальных окнах приложений. Колонка настроек Контейнеры компоновки, подобные Grid, значительно упрощают задачу создания общей структуры окна. Например, рассмотрим окно с настройками, показанное на рис. 3.22. Это окно располагает свои индивидуальные компоненты — метки, текстовые поля и кнопки — в табличной структуре. Создание этой таблицы начинается с определения строк и колонок сетки. Строки достаточно просты — размер каждой просто определяется по высоте содержимого. Это значит, что вся строка получит высоту самого большого элемента, которым в данном случае является кнопка Browse (Обзор) из третьей колонки. <Grid Margin=,3,10,3"> <Grid.RowDefinitions> <RowDefmition Height="Auto"></RowDefinition> <RowDefmition Height="Auto"></RowDefinition> <RowDefmition Height="Auto"></RowDefinition> <RowDefmition Height="Auto"></RowDefinition> </Grid.RowDefinitions>
Глава 3. Компоновка 115 1 TextBoxColumn Home: сД Network: e:\Shared Browse Web: c:\ i Browse j Secondary: c:\ i Browse Рис. 3.22. Настройки папки в колонке Далее необходимо создать колонки. Размер первой и последней колонки определяется так, чтобы уместить их содержимое (текст метки и кнопку Browse соответственно). Средняя колонка получает все оставшееся пространство, а это значит, что она будет расти при увеличении размера окна, предоставляя больше места, чтобы видеть выбранную папку. (Если хотите ограничить ее ширину, можете указать свойство MaxWidth при определении колонки, как это делается с индивидуальными элементами.) <Gnd.ColumnDef initions> <ColumnDefmition Width= <ColumnDefmition Width= <ColumnDefinition Width= </Gnd.ColumnDef initions> Совет. Контейнер Grid требует некоторого минимального пространства — достаточного, чтобы уместить полный текст метки, кнопку просмотра и несколько пикселей в средней колонке, отобразив текстовое поле. Если установить размеры включающего окна меньше этих, то некоторое содержимое будет усечено. Как всегда, чтобы предотвратить такую ситуацию, имеет смысл использовать свойства окна MinWidth и MinHeight. При наличии базовой структуры остается просто разместить элементы в правильных ячейках. Однако также потребуется тщательно продумать поля и выравнивание. Каждый элемент нуждается в базовом поле (подходящим значением для него будет 3 единицы), чтобы создать небольшой отступ от края окна. Вдобавок метка и текстовое поле должны быть центрированы по вертикали, потому что их высота меньше, чем у кнопки Browse. И, наконец, текстовое поле должно использовать режим автоматической установки размера, растягиваясь для того, чтобы уместить всю колонку. Ниже показана разметка, которая понадобится для определения первой строки сетки. <Label Grid.Row=" Grid.Column=" Margin=" VerticalAlignment="Center">Home:</Label> <TextBox Grid.Row=" Grid.Column="l" Margin=" Height="Auto" VerticalAlignment="Center"></TextBox> <Button Grid.Row=" Grid.Column=" Margin=" Padding=">Browse</Button> "Auto"></ColumnDefinition> " *"></ColumnDef inition> "Auto"></ColumnDefinition> </Grid>
116 Глава 3. Компоновка Эту разметку можно повторить, добавив все строки, при этом просто увеличивая значение атрибута Grid.Row. Один факт, который не сразу очевиден, связан с тем, насколько гибким является это окно благодаря использованию элемента управления Grid. Ни один из индивидуальных элементов — метки, текстовые поля и кнопки — не имеют жестко закодированных позиций и размеров. В результате сетку легко модифицировать, просто изменяя элементы ColumnDef inition. Более того, если вы добавите строку, которая имеет более длинный текст метки (что потребует расширения первой колонки), вся сетка будет откорректирована автоматически, сохраняя согласованность, в том числе и для добавленных строк. Если понадобится добавить элементы между существующими строками, такие как разделительные линии между разными частями окна, можно сохранить те же колонки, но использовать свойство ColumnSpan для растяжения единственного элемента на большую область. Динамическое содержимое Как демонстрирует показанная выше колонка настроек, окна, использующие контейнеры компоновки WPF, легко поддаются изменениям и адаптации по мере развития приложения. И преимущество этой гибкости проявляется не только во время проектирования. Это также ценное приобретение, если нужно отобразить содержимое, изменяющееся динамически. Примером может служить локализованный текст — текст, который отображается в пользовательском интерфейсе и нуждается в переводе на разные языки для разных географических регионов. В приложениях старого стиля, опирающихся на координатные системы, изменение текста может разрушить внешний вид окна — в частности, потому, что краткие предложения английского языка становятся существенно длиннее на многих других языках. Даже если элементам позволено изменять свои размеры, чтобы вместить больший текст, это может нарушить общий баланс окна. На рис. 3.23 показано, как можно избежать этих неприятностей, если разумно применять контейнеры компоновки WPF: В этом примере пользовательский интерфейс имеет опции краткого и длинного текста. Когда используется длинный текст, кнопки, содержащие текст, изменяют свой размер автоматически, расталкивая соседнее содержимое. И поскольку кнопки измененного размера разделяют один и тот же контейнер компоновки (в данном случае — колонку таблицы), весь раздел пользовательского интерфейса изменяет свой размер. В результате получается, что кнопки сохраняют согласованный размер — размер самой большой из них. This »s a test that demonstrates how buttons adapt themselves to fit the content they contain when they aren't explicitly sized This behavior makes localization much easier I Ctos* 1 I ' I Рис. 3.23. Самонастраивающееся окно ШшЩ Layout This is a test that demonstrates how buttons adapt themselves to fit the N**1 content they contain when they aren t explicitly sized. This behavior makes Show Long Text localization much easier. Go to tne Next Window ->
Глава 3. Компоновка 117 Чтобы заставить это работать, окно оснащено таблицей из двух колонок и двух строк. Колонка слева принимает кнопки изменяемого размера, а колонка справа — текстовое поле. Нижняя строка используется для кнопки Close (Закрыть). Она находится в той же таблице, поэтому изменяет свой размер вместе с верхней строкой. Ниже показана полная разметка: <Grid> <Grid.RowDefinitions> <RowDefmition Height="*"></RowDefinition> <RowDefmition Height="Auto"></RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefmition Width="Auto"></ColumnDefinition> <ColumnDefinition Width="*"></ColumnDefinition> </Grid.ColumnDefinitions> <StackPanel Grid.Row=" Grid.Column="> <Button Name="cmdPrev" Margin=0,10,10,3">Prev</Button> <Button Name="cmdNext" Margin=0,3,10,3">Next</Button> <CheckBox Name="chkLongText" Margin=0,10,10,10" Checked="chkLongText_Checked" Unchecked="chkLongText_Unchecked"> Show Long Text</CheckBox> </StackPanel> <TextBox Grid.Row=" Grid.Column="l" Margin=,10,10,10" TextWrapping="WrapWithOverflow" Grid.RowSpan=">This is a test that demonstrates how buttons adapt themselves to fit the content they contain when they aren't explicitly sized. This behavior makes localization much easier.</TextBox> <Button Grid.Row="l" Grid.Column=" Name="crndClose" Margin=0,3,10,10">Close</Button> </Grid> Модульный пользовательский интерфейс Многие контейнеры компоновки успешно "заливают" содержимое в доступное пространство — так поступают StackPanel, DockPanel и WrapPanel. Одно из преимуществ этого подхода заключается в том, что он позволяет строить действительно модульные интерфейсы. Другими словами, можно подключать разные панели с соответствующими разделами пользовательского интерфейса, которые должны отображаться, и пропускать те, которые в данный момент не нужны. Все приложение будет должным образом подстраиваться, подобно портальному сайту в Интернете. На рис. 3.24 показан пример. Здесь в WrapPanel помещается несколько отдельных панелей. Пользователь может выбрать те панели, которые должны быть видимыми, используя флажки в верхней части окна. На заметку! Хотя для панели компоновки можно установить фон, определить границу вокруг нее нельзя. В этом примере данное ограничение преодолено за счет помещения каждой панели в оболочку элемента Border, очерчивающего точные размеры. Поскольку другие панели скрыты, оставшиеся реорганизуют себя, заполняя доступное пространство (и порядок, в котором они объявлены). На рис. 3.25 показана другая организация панелей. Чтобы скрыть или показать индивидуальные панели, нужен небольшой фрагмент кода, обрабатывающего щелчки на флажках. Хотя модель обработки событий WPF пока еще детально не рассматривалась (этой теме посвящена глава 5), забегая вперед, скажем, что трюк состоит в установке свойства Visibility: panel.Visibility = Visibility.Collapsed;
118 Глава 3. Компоновка ModularCcntent | у Pan«U J Pan*2 •/ Pane*3 / Pan* Pagel Pag«2 Thu rs a test of a text box that contains wrapped text Рис. 3.24. Серии панелей в WrapPanel ModularContent . ■ Pane»2 Pagel . Th« is a test of a text box that contains wrapped text ' Рис. 3.25. Сокрытие некоторых панелей Свойство Visibility — это часть базового класса UIElement, и потому поддерживается почти всеми объектами, которые помещаются в окно WPF. Оно принимает одно из трех значений перечисления System.Windows.Visibility, описанных в табл. 3.6. Таблица 3.6. Значения перечисления Visibility Значение Описание visible Элемент появляется в окне в нормальном виде Collapsed Элемент не отображается и не занимает места Hidden Элемент не отображается, но место за ним резервируется. (Другими словами, там, где он должен появиться, отображается пустое пространство.) Эта установка удобна, если вы хотите скрывать и показывать элементы, не меняя компоновки и относительного положения элементов в остальной части окна На заметку! Свойство Visibility можно использовать для динамической подгонки вариантов интерфейса. Например, можно создать сворачиваемую панель, отображаемую сбоку окна. Все, что потребуется сделать для этого — поместить содержимое этой панели в какой-то контейнер компоновки и соответствующим образом устанавливать его свойство Visibility. Остальное содержимое будет автоматически реорганизовано, чтобы заполнить доступное пространство. Резюме В этой главе был представлен детальный обзор новой модели компоновки WPF и показано, как размещать элементы в стеках, сетках и других структурах. Мы построили более сложные компоновки, используя вложенные комбинации контейнеров компоновки, добавив GridSplitter для создания разделенных окон изменяемого размера. Особое внимание было уделено причинам, вызвавшим все эти значительные изменения, а именно — преимуществам, которые получаются при поддержке, расширении и локализации пользовательского интерфейса. История с компоновкой далека от завершения. В следующих главах будет демонстрироваться множество новых примеров, в которых контейнеры компоновки применя-
Глава 3. Компоновка 119 ются для организации групп элементов. Также будет рассказано о нескольких дополнительных средствах, позволяющих организовать содержимое окна. • Специализированные контейнеры. Border, ScrollViewer и Expander предоставляют возможность создания содержимого, имеющего рамки, допускающего прокрутку и которое может быть свернуто. В отличие от панелей компоновки, эти контейнеры могут содержать только один фрагмент содержимого. Однако их легко использовать в сочетании с панелями компоновки, чтобы получить нужный эффект. В главе 6 эти контейнеры демонстрируются в действии. • Контейнер Viewbox. Нужен способ изменения размера графического содержимого (такого как графические изображения и векторная графика)? Viewbox — это еще один специализированный контейнер, который поможет в этом, обладая встроенным масштабированием. Первое знакомство с Viewbox произойдет в главе 12. • Компоновка текста. В WPF доступны инструменты для компоновки крупных блоков стилизованного текста. Можно использовать плавающие фигуры и списки, применять выравнивание, колонки и изощренную технологию переносов, чтобы получить замечательно изящный результат. В главе 28 показано, как это делается.
ГЛАВА 4 Свойства зависимости Каждый программист, работающий с .NET, знаком со свойствами (property) и событиями (event), которые являются основными компонентами объектной абстракции .NET. Почти никто не предполагал, что WPF — технология пользовательских интерфейсов — изменит какой-либо из этих основополагающих компонентов. Однако именно это и произошло. В данной главе вы узнаете, как WPF заменяет обычные свойства .NET высокоуровневым компонентом — свойствами зависимости (dependency property). Свойства зависимости более эффективно используют память и поддерживают дополнительные возможности: уведомления об изменениях и наследование значений свойств (возможность распространить стандартные значения вниз по дереву элементов). Они являются также основой для ряда ключевых возможностей WPF, например, анимации, привязки данных и стилей. К счастью, изменился только внутренний механизм, и свойства зависимости можно считывать и устанавливать точно так же, как и традиционные свойства .NET. На последующих страницах вы подробно ознакомитесь со свойствами зависимости. Вы научитесь определять их, регистрировать и использовать. Вы также узнаете, какие возможности они поддерживают и какие задачи решают. На заметку! Для освоения свойств зависимости необходим большой объем теоретических сведений, а вам это может показаться ни к чему (по крайней мере сейчас). Если вам не терпится приступить к созданию приложений, то можете перейти к последующим главам и вернуться сюда тогда, когда захотите более глубоко разобраться в механизме работы WPF и создавать свойства зависимости самостоятельно. Свойства зависимости Свойства зависимости являются совершенно новой, значительно более полезной, реализацией свойств. Без них вы не сможете работать с основными средствами WPF, такими как анимация, привязка данных и стили. Большинство свойств у элементов WPF являются свойствами зависимости. Во всех примерах, которые были приведены до настоящего момента, вы использовали свойства зависимости, даже не подозревая об этом. Это объясняется тем, что свойства зависимости разработаны таким образом, чтобы с ними можно было работать как с обычными свойствами. И все же свойства зависимости не являются обычными свойствами. Лучше всего представлять себе эти свойства как обычные (определяемые в .NET обычным образом), но с дополнительным набором возможностей WPF. В концептуальном отношении поведение свойств зависимости не отличается от поведения обычных свойств, однако pea-
Глава 4. Свойства зависимости 121 лизованы они по-другому. Причина проста: производительность. Если бы разработчики WPF просто внесли дополнительные возможности в систему свойств .NET, то им пришлось бы создать сложный и громоздкий слой для вашего кода. Рядовые свойства не могут поддерживать все характеристики свойств зависимости, не перегружая при этом систему. Свойства зависимости являются специфическим детищем WPF. Однако в библиотеках WPF они всегда заключены в оболочки обычных процедур свойств .NET. Это позволяет использовать их обычным образом даже в том коде, который не имеет понятия о системе свойств зависимости WPF. На первый взгляд странно, что новая технология упакована в старую, однако только так WPF может изменить такой фундаментальный ингредиент, как свойства, не нарушая структуру остального мира .NET Определение свойства зависимости Свойства зависимости приходится создавать гораздо реже, чем использовать. Тем не менее, существует множество причин, по которым вам придется создавать собственные свойства зависимости. Очевидно, они будут являться ключевым ингредиентом при создании пользовательского элемента WPF Но они понадобятся и тогда, когда необходимо добавить привязку данных, анимацию или какую-то другую возможность WPF во фрагмент кода, который иначе не смог бы поддерживать их. Создать свойство зависимости не очень сложно, хотя к синтаксису нужно привыкнуть. Он полностью отличается от синтаксиса обычного свойства .NET На заметку! Свойства зависимости можно добавлять только к объектам зависимости — классам, порожденных от DependencyObject. К счастью, большинство ключевых компонентов инфраструктуры WPF косвенно порождены от DependencyObject. Наиболее очевидным примером такого порождения являются элементы. Сначала нужно определить объект, который будет представлять свойство. Это экземпляр класса DependencyProperty. Информация о свойстве должна быть доступна постоянно и, возможно, даже другим классам (как обычно для элементов WPF). По этой причине объект DependencyProperty следует определить как статическое поле в связанном классе. Например, класс FrameworkElement определяет свойство Margin, доступное всем элементам. Конечно, Margin — это свойство зависимости. Это означает, что оно определяется в классе FrameworkElement следующим образом: public class FrameworkElement : UIElement, ... { public static readonly DependencyProperty MarginProperty; } Принято соглашение, что поле, представляющее свойство зависимости, имеет имя обычного свойства с добавлением слова Property в конце. Таким образом можно отделить определение свойства зависимости от имени самого свойства. Поле определено с ключевым словом readonly — это означает, что его значение можно задать только в статическом конструкторе для класса FrameworkElement, но это уже следующий шаг. Регистрация свойства зависимости Определение объекта DependencyProperty является лишь первым шагом. Чтобы его можно было задействовать, необходимо зарегистрировать свойство зависимости в
122 Глава 4. Свойства зависимости WPF. Это нужно сделать до использования данного свойства в коде, поэтому определение должно быть выполнено в статическом конструкторе связанного класса. WPF гарантирует, что объекты DependencyProperty не будут создаваться напрямую, так как класс DependencyObject не имеет общедоступного конструктора. Экземпляр DependencyObject может быть создан только посредством статического метода DependencyProperty.Register (). WPF также гарантирует невозможность изменения объектов DependencyProperty после их создания, т.к. все члены DependencyProperty доступны только для чтения, а их значения должны быть заданы в виде аргументов в методе Register (). В следующем фрагменте кода приведен пример создания DependencyProperty. Здесь класс FrameworkElement использует статический конструктор для инициализации MarginProperty: static FrameworkElement () { FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata( new Thickness(), FrameworkPropertyMetadataOptions.AffectsMeasure); MarginProperty = DependencyProperty.Register("Margin", typeof(Thickness), typeof(FrameworkElement), metadata, new ValidateValueCallback(FrameworkElement.IsMarginValid)); } Регистрация свойства зависимости осуществляется в два этапа. Сначала создается объект FrameworkPropertyMetadata, который указывает, какие службы вы хотите использовать со свойством зависимости (например, поддержку привязки данных, анимацию и ведение журнала). Затем свойство регистрируется, для чего вызывается метод DependencyProperty.Register(). Здесь нужно определить несколько ключевых ингредиентов: • имя свойства (в данном примере это Margin); • тип данных, используемый свойством (в данном примере это структура Thickness); • тип, которому принадлежит это свойство (в данном примере это класс FrameworkElement); • объект FrameworkPropertyMetadata с дополнительными параметрами свойства (необязательно); • обратный вызов, при котором производится проверка правильности свойства (необязательно). С первыми тремя ингредиентами все вроде бы ясно. Более интересными являются объект FrameworkPropertyMetadata и обратный вызов проверки. Объект FrameworkPropertyMetadata используется для настройки дополнительных возможностей создаваемого свойства зависимости. Большая часть свойств класса FrameworkPropertyMetadata представляет собой обычные логические флаги, которые устанавливаются для активации этих возможностей (по умолчанию все эти флаги имеют значения false). Но некоторые из них являются обратными вызовами, которые указывают на пользовательские методы, созданные для выполнения конкретных задач. Одно из таких свойств — FrameworkPropertyMetadata.DefaultValue —устанавливает стандартное значение, которое WPF будет применять при первоначальной инициализации свойства. В табл. 4.1 приведены все свойства FrameworkPropertyMetadata.
Глава 4. Свойства зависимости 123 Таблица 4.1. Свойства класса FrameworkPropertyMetadata Имя Описание Af fectsArrange, Af fectsMeasure, AffectsParentArrange и Af fectsParentMeasure Af fectsRender BindsTwoWayByDefault Inherits IsAmmationProhibited IsNotDataBindable Journal SubPropertiesDoNotAffectRender DefaultUpdateSourceTrigger DefaultValue CoerceValueCallback PropertyChangedCallback Если имеет значение true, то свойство зависимости может влиять на расположение смежных элементов (или родительского элемента) во время этапа измерения и этапа расстановки в операции компоновки. Например, свойство зависимости Margin заносит в Af fectsMeasure значение true — это означает, что при изменении полей элементов контейнер компоновки должен повторить этап измерения, чтобы определить новое размещение элементов Если имеет значение true, то свойство зависимости может влиять на внешний вид элемента, что требует перерисовки элемента Если имеет значение true, то свойство зависимости будет использовать не одностороннюю (по умолчанию), а двухстороннюю привязку данных. Однако при создании привязки можно явно указать ее поведение Если имеет значение true, то значение свойства зависимости распространяется по дереву элементов и может наследоваться вложенными элементами. Например, наследуемым свойством зависимости является Font: если указать его для элемента самого высокого уровня, то оно наследуется вложенными элементами, если не будет явно перекрыто собственными параметрами шрифта Если имеет значение true, то свойство зависимости нельзя использовать в анимации Если имеет значение true, то значение свойства зависимости нельзя устанавливать в выражении привязки Если имеет значение true, то в страничном приложении значение свойства зависимости будет сохранено в журнале (история посещенных страниц) Если имеет значение true, то WPF не будет выполнять перерисовку объекта при изменении одного из его под- свойств (свойства свойства) Устанавливает стандартное значение для свойства Binding.UpdateSourceTrigger, когда это свойство используется в выражении привязки. Свойство UpdateSourceTrigger определяет момент применения изменений привязанного значения. Свойство UpdateSourceTrigger можно установить вручную при создании привязки Устанавливает стандартное значение для свойства зависимости Обеспечивает обратный вызов, который пытается "исправить" значение свойства перед его проверкой Обеспечивает обратный вызов, который выполняется при изменении значения свойства
124 Глава 4. Свойства зависимости В последующих разделах обратные вызовы для проверки правильности и некоторые параметры метаданных будут рассмотрены более подробно. Кроме того, далее в книге будут встречаться примеры с демонстрацией их работы. Но вначале нужно разобраться, как обеспечить точно такой же доступ к свойству зависимости, как и к обычному свойству .NET. Добавление оболочки свойства На завершающем этапе создания свойства зависимости его нужно оформить в виде традиционного свойства .NET. Однако процедуры обычного свойства извлекают или задают значение приватного поля, а процедуры свойства WPF используют методы GetValueO и SetValueO, определенные в классе DependencyObject. Например: public Thickness Margin { set { SetValue(MarginProperty, value); } get { return (Thickness)GetValue(MarginProperty); } } При создании оболочки свойства необходимо включить только вызов методов SetValueO и GetValueO, как в предыдущем примере. Не нужно добавлять какой-то дополнительный код для проверки значений, генерации событий и т.п. Это связано с тем, что другие средства WPF могут обходить оболочку свойства и напрямую обращаться к методам SetValue () и GetValue (). (В качестве примера можно привести синтаксический анализ скомпилированного XAML-файла во время выполнения.) Методы SetValueO и GetValueO являются общедоступными. На заметку! Оболочка свойства не предназначена для проверки правильности данных или генерации события. Однако в WPF есть возможность выполнить такой код — это обратные вызовы свойства зависимости. Проверку следует выполнять в DependencyProperty. ValidateValueCallback, как было показано в предыдущем примере, а генерация событий — в FrameworkPropertyMetadata.PropertyChangedCallback, как будет показано в следующем разделе. Теперь у вас есть полностью готовое свойство зависимости, которое можно задавать подобно любому другому свойству .NET с помощью оболочки свойства: myElement.Margin = new Thickness E); Здесь есть одна особенность. Свойства зависимости подчиняются строгим правилам предшествования для определения текущих значений. Даже если вы не устанавливаете непосредственно свойство зависимости, оно уже может иметь значение: возможно, оно было присвоено во время привязки данных, определении стиля или анимации, или было унаследовано через дерево элементов. (О правилах предшествования речь пойдет в следующем разделе.) Однако если установить значение напрямую, оно перекроет существующее значение. Через некоторое время после этого вам может понадобиться удалить локально заданное значение, т.е. чтобы значение свойства было определено так, как если бы оно не было явно установлено. Понятно, что это невозможно сделать, присваивая новое значение. Придется воспользоваться другим методом, унаследованным от DependencyObject — методом ClearValue(). Вот как он работает: myElement .ClearValue (FrameworkElement .MarginProperty);
Глава 4. Свойства зависимости 125 Как WPF использует свойства зависимости На страницах этой книги вы увидите, что свойства зависимости необходимы самым разным средствам WPF. Тем не менее, все эти средства имеют две ключевых возможности, поддерживаемых каждым свойством зависимости — это уведомление об изменении и динамическое разрешение значений. Как ни странно, свойства зависимости не генерируют автоматически события, чтобы дать знать об изменении значения свойства. Вместо этого они запускают защищенный метод OnPropertyChangedCallback(). Он передает информацию двум службам WPF (привязка данных и триггеры) и вызывает метод PropertyChangedCallback, если он определен. Другими словами, если вы хотите выполнить действие в случае изменения свойства, у вас есть два варианта: можно создать привязку, которая использует значение свойства (см. главу 8), или написать триггер, который автоматически изменяет другое свойство или запускает анимацию (см. главу 11). Однако свойства зависимости не дают обобщенный способ запуска некоторого кода в ответ на изменение свойства. На заметку! При работе с созданным вами элементом управления можно воспользоваться механизмом обратного вызова свойства, чтобы реагировать на изменения свойства и даже генерировать событие. Многие обычные элементы управления используют этот прием для свойств, которые соответствуют информации, заданной пользователем. Например, элемент TextBox имеет событие TextChanged, a ScrollBar — событие ValueChanged. Элемент управления может реализовывать подобную функцию с помощью объекта PropertyChangedCallback, однако по соображениям производительности эта возможность в свойствах зависимости закрыта от обычного доступа. Второй возможностью, которая определяет характер работы свойств зависимости, является динамическое разрешение значения. Это означает, что при извлечении значения из свойства зависимости WPF учитывает несколько факторов. Такое поведение и объясняет название этих свойств — по сути, свойство зависимости зависит от нескольких поставщиков свойств, каждый из которых имеет свой уровень приоритета. При извлечении значения из свойства система свойств WPF выполняет ряд действий, которые дают окончательное значение. Сначала она определяет базовое значение свойства, учитывая следующие факторы, перечисленные в порядке возрастания приоритета. 1. Значение по умолчанию (задается объектом FrameworkPropertyMetadata). 2. Унаследованное значение (если установлен флаг FrameworkPropertyMetadata. Inherits, и где-то выше в иерархии элементу было присвоено значение). 3. Значение из стиля темы (см. главу 18). 4. Значение из стиля проекта (см. главу 11). 5. Локальное значение (то есть значение, заданное непосредственно в этом объекте с помощью кода или XAML). Как показывает этот список, при непосредственном присваивании значения переопределяется целая иерархия значений. Иначе значение берется из ближайшего применимого элемента выше в списке. На заметку! Одно из преимуществ этой системы состоит в ее значительной экономичности. Если значение свойства не было задано локально, WPF извлечет его значение из стиля, другого элемента, либо стандартного значения. При этом не требуется выделять память для хранения значения. Оценить эту экономичность можно, если добавить на форму несколько кнопок. Каждая кнопка имеет десятки свойств, которые вообще не занимают память, если заданы посредством одного из этих механизмов.
126 Глава 4. Свойства зависимости WPF придерживается приведенного выше списка, чтобы определить базовое значение свойства зависимости. Однако это базовое значение не обязательно является конечным значением, которое выбирается из свойства. Это связано с тем, что WPF рассматривает несколько других поставщиков, которые могут изменить значение свойства. Ниже описан четырехшаговый процесс, с помощью которого WPF определяет значение свойства. 1. Определяется базовое значение (как описано выше). 2. Если свойство задается выражением, производится вычисление этого выражения. На данный момент WPF поддерживает два типа выражений: привязка данных (см. главу 8) и ресурсы (см. главу 10). 3. Если данное свойство предназначено для анимации, применяется эта анимация. 4. Выполняется метод CoerceValueCallback для "корректировки" значения. (Применение этой техники описано ниже, в разделе "Проверка свойств".) По сути, свойства зависимости жестко связаны с небольшим набором служб WPF Если бы в данной инфраструктуре этого не было, они могли бы породить излишнюю сложность и добавить значительные накладные расходы. Совет. В будущих версиях WPF к свойствам зависимости будут добавлены дополнительные службы. При разработке пользовательских элементов (об этом речь пойдет в главе 18) вы, скорее всего, будете использовать свойства зависимости для большинства (если не всех) их общедоступных свойств. Совместно используемые свойства зависимости Некоторые классы совместно используют одно и то же свойство зависимости, даже если они имеют отдельные иерархии классов. Например, TextBlock. Font Family и Control.FontFamily указывают на одно и то же статическое свойство зависимости, которое определено в свойстве TextElement.FontFamilyProperty класса TextElement. Статический конструктор TextElement регистрирует свойство, а статические конструкторы TextBlock и Control просто повторно используют его, вызывая метод DependencyProperty. AddOwneг(): TextBlock.FontFamilyProperty = TextElement.FontFmamilyProperty.AddOwner(typeof(TextBlock)); Такую технологию можно применять при создании собственных пользовательских классов (если нужное свойство еще не определено в базовом классе — иначе вы получите его готовым). Можно также использовать перегрузку метода AddOwner (), что позволит определить обратный вызов проверки и новый объект FrameworkPropertyMetadata, который будет применяться только к этому новому использованию свойства зависимости. Повторное использование свойств зависимости может привести в WPF к некоторым странным побочным эффектам, особенно в стилях. Например, если применить стиль для автоматического задания свойства TextBlock.FontFamily, то это повлияет и на свойство Control.FontFamily, поскольку "за кулисами" оба класса используют одно и то же свойство зависимости. Действие этого феномена будет продемонстрировано в главе 12. Прикрепляемые свойства зависимости В главе 2 было рассказано о специальном типе свойства зависимости, называемом прикрепляемым свойством. Прикрепляемое свойство (attached property) — это свойство зависимости, которым управляет система свойств WPF. Его отличительной чертой яв-
Глава 4. Свойства зависимости 127 ляется тот факт, что прикрепляемое свойство применяется к классу, отличному от того, в котором оно определено. Наиболее характерный пример прикрепляемых свойств можно найти в контейнерах компоновки, описанных в главе 4. Например, класс Grid определяет прикрепляемые свойства Row и Column, которые задаются для содержащихся элементов и показывают их расположение. Точно так же класс DockPanel определяет прикрепляемое свойство Dock, a Canvas — прикрепляемые свойства Left, Right, Top и Bottom. Для определения прикрепляемого свойства используется метод RegisterAttachedO, aHeRegister(). Вот пример регистрации свойства Grid.Row: FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata ( 0, new PropertyChangedCallback(Grid.OnCellAttachedPropertyChanged)); Grid.RowProperty = DependencyProperty.RegisterAttached("Row", typeof (int), typeof(Grid) , metadata, new ValidateValueCallback(Grid.IsIntValueNotNegative)); Как и при использовании обычного свойства зависимости, здесь также можно определить объект FrameworkPropertyMetadata и ValidateValueCallback. При создании прикрепляемого свойства оболочка свойства .NET не определяется. Это связано с тем, что прикрепляемые свойства могут быть заданы в любом объекте зависимости. Например, свойство Grid.Row может быть задано в объекте Grid (если один объект Grid вложен в другой) или в каком-то другом элементе. Вообще-то свойство Grid.Row может быть задано в элементе, даже если это не экземпляр Grid — и даже если в дереве объектов вообще нет ни одного объекта Grid. Вместо применения оболочки свойства .NET для прикрепляемых свойств требуется пара статических методов, которые могут быть вызваны для установки и получения значения свойства. Эти методы используют знакомые вам методы SetValueO и GetValueO (унаследованные от класса DependencyObject). Статические методы должны иметь имена наподобие БеЬИмяСвойстваО и СеЬИмяСвойства{). Ниже показаны статические методы, реализующие прикрепляемое свойство Grid.Row. public static int GetRow(UIElement element) { if (element == null) { throw new ArgumentNullException (...); } return (int)element.GetValue(Grid.RowProperty); } public static void SetRow(UIElement element, int value) { if (element == null) { throw new ArgumentNullException (...); } element.SetValue(Grid.RowProperty, value); } А вот пример, позиционирующий элемент в первой строке сетки с помощью кода: Grid.SetRow(txtElement, 0); Но метод SetValueO или GetValueO можно вызвать напрямую, в обход статических методов: txtElement.SetValue(Grid.RowProperty, 0) /
128 Глава 4. Свойства зависимости Метод SetValue () имеет одну странную особенность. Хотя XAML не позволяет применять его, в коде можно использовать перегруженную версию метода SetValue (), чтобы прикрепить значение к любому свойству зависимости, далее если это свойство не определено как прикрепляемое. Например, вполне допустимым является следующий код: ComboBox comboBox = new ComboBox(); comboBox.SetValue(PasswordBox.PasswordCharProperty, "*") ; Здесь значение свойства PasswordBox. PasswordChar задается для объекта ComboBox, хотя PasswordBox.PasswordCharProperty зарегистрировано как обычное свойство зависимости, а не как прикрепляемое свойство. Это действие не изменит способ работы ComboBox — ведь код внутри ComboBox не будет искать значение свойства, о существовании которого ничего не известно — однако вы можете в своем коде работать со значением PasswordChar. Этот трюк применяется нечасто, но он позволяет глубже понять принцип работы системы свойств WPF и демонстрирует ее великолепную расширяемость. Он показывает также, что хотя прикрепляемые свойства регистрируются не как обычные свойства зависимости, а с помощью другого метода, WPF не проводит между ними различий. Единственным отличием является поведение синтаксического анализатора XAML. Если не зарегистрировать свойство как прикрепляемое, вы не сможете менять его значение в остальных элементах разметки. Проверка свойств При определении любого свойства необходимо учитывать возможность неверного задания его значения. Работая с обычными свойствами .NET, можно попытаться перехватить этот момент в методе установки значения. Но в случае свойств зависимости этот способ неприменим, т.к. свойство можно установить напрямую, с помощью метода SetValue () из системы свойств WPF. Вместо этого в WPF предусмотрены два способа защиты от неверно установленных значений: • ValidateValueCallback. Этот обратный вызов может принимать или отбрасывать новые значения. Обычно он применяется для обнаружения очевидных ошибок, которые нарушают ограничения свойства. Его можно передать в качестве аргумента при вызове метода DependencyProperty.Register (). • CoerceValueCallback. Этот обратный вызов может изменять введенные значения на более приемлемые. Обычно он применяется для обработки конфликтов между значениями свойств зависимости, установленных для одного и того же объекта. Такие значения могут быть верны порознь, но противоречить друг другу. Для использования этого обратного вызова передайте его в качестве аргумента конструктора при создании объекта FrameworkPropertyMetadata, который затем передается методу DependencyProperty.Register(). Вот как работают все эти части, когда приложение пытается установить значение свойства зависимости: 1. Вначале метод CoerceValueCallback получает возможность изменить полученное значение (обычно чтобы оно не противоречило значениям других свойств) или возвратить значение DependencyProperty.UnsetValue, которое вообще запрещает применение значения.
Глава 4. Свойства зависимости 129 2. Затем запускается метод ValidateValueCallback. Он возвращает true, что означает принятие значения как верного, или false, что означает отказ от применения значения. В отличие от CoerceValueCallback, метод ValidateValueCallback не имеет доступа к самому объекту, в котором выполняется попытка изменения свойства — то есть он не может анализировать значения других свойств. 3. И, наконец, если оба предыдущих этапа закончились успешно, запускается метод PropertyChangedCallback. В это время можно сгенерировать событие изменения, если нужно обеспечить уведомление других классов. Обратный вызов проверки Как уже было сказано, метод DependencyProperty. Register () принимает необязательный параметр с обратным вызовом проверки: MarginProperty = DependencyProperty.Register("Margin", typeof(Thickness), typeof(FrameworkElement), metadata, new ValidateValueCallback (FrameworkElement. IsMarginValid) ) ; Его можно использовать для выполнения проверки, которая обычно помещается в процедуру установки значения свойства. Этот обратный вызов должен указывать на метод, который принимает объект в качестве параметра и возвращает логическое значение. Значение true означает, что объект верен, a false — что неверен, и его следует отбросить. Проверка свойства FrameworkElement. Mar gin не представляет особого интереса, т.к. она зависит от внутреннего метода Thickness.IsValid(). Этот метод проверяет верность объекта Thickness в текущем контексте (когда он представляет краевое поле). Например, можно создать полностью корректный объект Thickness, который все-таки не годится для установки ширины поля — допустим, из-за отрицательных размеров. И если объект Thickness не может представлять краевое поле, то свойство IsMarginValid возвращает false: private static bool IsMarginValid(object value) { Thickness thicknessl = (Thickness) value; return thicknessl.IsValid(true, false, true, false); } У обратных вызовов проверки есть одно ограничение: они являются статическими методами и поэтому не имеют доступа к проверяемому объекту. В вашем распоряжении имеется лишь применяемое значение. Конечно, это облегчает их повторное использование, но делает невозможным создание процедуры проверки, которая должна учитывать другие свойства. Классический пример — элемент со свойствами Maximum и Minimum. Понятно, что нельзя присваивать Maximum значение, которое меньше Minimum. Но такую проверку невозможно выполнить в обратном вызове проверки, т.к. при каждом вызове доступно только одно свойство. На заметку! Эту проблему рекомендуется решать с помощью приведения значений. Приведение — это шаг, который выполняется перед проверкой и позволяет изменить значение, чтобы оно стало более приемлемым (например, увеличить значение Maximum, чтобы оно стало не меньше Minimum), или вообще запретить изменение. Шаг приведения выполняется с помощью другого обратного вызова, прикрепленного к объекту FrameworkPropertyMetadata, но этот объект будет описан в следующем разделе.
130 Глава 4. Свойства зависимости Обратный вызов приведения Метод CoerceValueCallback вызывается с помощью объекта FrameworkProperty Metadata. Вот, например: FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata(); metadata.CoerceValueCallback = new CoerceValueCallback(CoerceMaximum); DependencyProperty.Register("Maximum", typeof(double), typeof(RangeBase), metadata); Метод CoerceValueCallback позволяет обрабатывать зависящие друг от друга свойства. К примеру, у объектов ScrollBar имеются свойства Maximum, Minimum и Value; все они унаследованы от класса RangeBase. Один из способов сохранить их согласованность — использование приведения свойств. Например, при установке значения Maximum должно быть выполнено такое приведение, чтобы это значение было не меньше Minimum: private static object CoerceMaximum(DependencyObject d, object value) { RangeBase basel = (RangeBase)d; if (((double) value) < basel.Minimum) { return basel.Minimum; } return value; } Другими словами, если значение, применяемое к свойству Maximum, меньше, чем Minimum, то используется значение Minimum, а не применяемое значение. Обратите внимание: методу CoerceValueCallback передаются два параметра: применяемое значение и объект, к которому оно применяется. Аналогичное приведение можно выполнить при установке значения Value: оно не должно выходить за границы, определяемые значениями Minimum и Maximum, и это можно проверить с помощью следующего кода: internal static object ConstrainToRange(DependencyObject d, object value) { double newValue = (double)value; RangeBase basel = (RangeBase) d; double minimum = basel.Minimum; if (newValue < minimum) { return minimum; } double maximum = basel.Maximum; if (newValue > maximum) { return maximum; } return newValue; } Свойство Minimum вообще не выполняет приведение значения. Вместо этого при его изменении выполняется метод PropertyChangedCallback, который запускает приведение значений Maximum и Value:
Глава 4. Свойства зависимости 131 private static void OnMinimumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { RangeBase basel = (RangeBase)d; basel.CoerceMaximum(RangeBase.MaximumProperty); basel.CoerceValue(RangeBase.ValueProperty); } Аналогично, при установке и приведении значения Maximum дополнительно выполняется приведение свойства Value: private static void OnMaximumChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { RangeBase basel = (RangeBase)d; basel.CoerceValue(RangeBase.ValueProperty); basel.OnMaximumChanged((double) e.OldValue, (double)e.NewValue); } В результате получается, что при применении конфликтующих значений наибольший приоритет имеет Minimum, затем Maximum (с возможной корректировкой по значению Minimum), а затем — Value (с возможной корректировкой по значениям Maximum и Minimum). Цель этой несколько запутанной последовательности действий состоит в обеспечении того, что свойства объекта ScrollBar можно задавать в произвольном порядке без возникновения ошибки. Это важно на этапе инициализации, когда создается окно для документа XAML. Все управляющие элементы WPF гарантируют, что их свойства можно устанавливать в любой последовательности, и это никак не повлияет на их поведение. Если внимательно просмотреть приведенный выше код, то могут возникнуть вопросы. Например, рассмотрим следующий код: ScrollBar bar = new ScrollBar (); bar.Value = IOC- bar. Minimum = 1; bar.Maximum = 200; Сразу после создания объекта ScrollBar он имеет параметры Value = О, Minimum = О и Maximum = 1. После выполнения второй строки значение Value приводится к 1 (т.к. по умолчанию свойство Maximum имеет значение 1). Но в четвертой строке кода происходит интересное явление. При изменении свойства Maximum оно запускает приведение свойств Miniтити Value. Но это приведение действует на значения, указанные первоначально. То есть локальное значение 100 все еще хранится где-то в системе свойств зависимости WPF, и теперь, когда оно стало допустимым, его можно применить к свойству Value. Значит, в результате выполнения этой одной строки изменились два свойства. Вот более подробный протокол происходящего: ScrollBar bar = new ScrollBar (); bar.Value = 100; // (Сейчас bar.Value возратит 1.) bar.Minimum = 1; // (bar.Value все так же возвращает 1.) bar.Maximum = 200; // (А теперь bar.Value возвратит 100.)
132 Глава 4. Свойства зависимости Это поведение не зависит от времени задания свойства Maximum. Например, если при загрузке окна задать значение Value равным 100, а потом при щелчке пользователя на какой-то кнопке установить значение Maximum, то в этот момент значение Value восстановится до величины 100. (Единственный способ предотвратить это состоит в установке другого значения или удалении локального значения, которое было применено с помощью метода ClearValueO,наследуемого всеми элементами от класса Dependency Object.) Это поведение обусловлено системой разрешения свойств WPF, о которой уже было рассказано выше. WPF хранит точное локальное значение, установленное внутри, но она вычисляет, чему должно быть равно это значение (с учетом приведения и некоторых других соображений), при чтении свойства. На заметку! Программисты со стажем, которые работали с Windows Forms, могут помнить интерфейс ISupportlnitialize, который применялся для решения аналогичных задач при инициализации свойств: тогда последовательность изменений свойства оформлялась в виде пакетного процесса. В принципе, ISupportlnitialize можно использовать и с WPF (и синтаксический анализатор XAML не возражает против этого), но лишь немногие элементы WPF задействуют эту технику. Вместо нее подобные задачи рекомендуется решать с помощью приведения значений. Приведение удобнее по целому ряду причин Например, в отличие от ISupportlnitialize, оно решает и другие задачи, которые могут возникнуть при применении неправильного значения с помощью привязки данных или анимации. Резюме В этой главе мы детально рассмотрели свойства зависимости WPF. Сначала было показано, как определяются и регистрируются свойства зависимости, а затем — как они подключаются к остальным службам WPF. В следующей главе будет рассмотрена еще одна возможность WPF, которая расширяет базовую часть традиционной инфраструктуры .NET — маршрутизируемые события. Совет. Один из лучших способов изучить механизм WPF — просмотр кода для базовых элементов WPF, таких как Button, UIElement и FrameworkElement. Одним из наиболее удобных инструментов для этого является Reflector, доступный по адресу http://www.red-gate.com/ products/reflector. Он позволяет увидеть определения свойств зависимости, просмотреть код инициализирующего их статического конструктора и даже узнать, как они используются в коде класса. Этот инструмент позволяет также получить аналогичную низкоуровневую информацию о маршрутизируемых событиях, которые будут описаны в следующей главе.
ГЛАВА 5 Маршрутизируемые события В предыдущей главе была описана созданная в WPF новая система свойств зависимости, которая усовершенствовала традиционные свойства .NET, повысив их производительность и интегрировав новые возможности, такие как привязка данных и анимация. В данной главе вы познакомитесь со вторым усовершенствованием: заменой обычных событий .NET на высокоуровневые маршрутизируемые события (routed event). Маршрутизируемые события — это события с большими транспортными возможностями: они могут туннелироваться вниз и распространяться пузырьками наверх по дереву элементов и по пути запускать обработчики событий. Маршрутизируемые события позволяют обработать событие в одном элементе (например, в метке), хотя оно возникло в другом (например, в изображении внутри этой метки). Как и в случае свойств зависимости, маршрутизируемые события можно употреблять и традиционным способом — подключив обработчик событий с нужной сигнатурой — но все равно необходимо понимать принципы их работы, чтобы задействовать все их возможности. В данной главе вы познакомитесь с системой событий WPF и научитесь запускать и обрабатывать маршрутизируемые события. После освоения основ вы рассмотрите семейство событий, которые предоставляют элементы WPF: события для инициализации, щелчков мышью, нажатий клавиш и мультипозиционных сенсорных экранов. Что нового? Свойства зависимости и маршрутизируемые события работают в WPF 4 так же, как и в предыдущих версиях. Но в WPF 4 появилась совершенно новая возможность: захват данных, вводимых с сенсорных устройств нового поколения, которые поддерживают несколько касаний (например, планшетных компьютеров с усовершенствованными сенсорными экранами). Эти устройства будут рассмотрены ниже в данной главе, в разделе "Сенсорный многопозиционный ввод". Знакомство с маршрутизируемыми событиями Каждый разработчик, работающий в .NET, знаком с понятием события: это сообщение, которое посылается объектом (например, элементом WPF) для уведомления кода о том, что произошло что-то важное. WPF дополняет модель событий .NET новой концепцией маршрутизации событий. Маршрутизация позволяет событию возникать в одном элементе, а генерироваться в другом: например, щелчок на кнопке панели инструментов генерируется в панели инструментов, а затем в содержащем эту панель окне, и только тогда передается на обработку коду.
134 Глава 5. Маршрутизируемые события Маршрутизация событий дает возможность писать лаконичный и* понятный код, который может обрабатывать события в наиболее удобном для этого месте. Она необходима также для работы с моделью содержимого WPF, позволяющей создавать простые элементы (например, кнопки) из десятков отдельных ингредиентов, каждый из которых имеет свой собственный набор событий. Определение, регистрация и упаковка маршрутизируемых событий Модель событий WPF очень похожа на модель свойств WPF. Как и свойства зависимости, маршрутизируемые события представляются статическими полями, доступными только для чтения, которые регистрируются в статическом конструкторе и оформляются в виде стандартного определения события .NET. Например, WPF-класс Button предлагает знакомое событие Click, являющееся потомком абстрактного класса ButtonBase. Ниже показано, как определяется и регистрируется это событие. public abstract class ButtonBase : ContentControl, ... { // Определение события public static readonly RoutedEvent ClickEvent; // Регистрация события static ButtonBase () { ButtonBase.ClickEvent = EventManager.RegisterRoutedEvent( "Click", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof (ButtonBase)); } // Традиционная оболочка события public event RoutedEventHandler Click { add { base.AddHandler(ButtonBase.ClickEvent, value); } remove { base.RemoveHandler(ButtonBase.ClickEvent, value); } } } Свойства зависимости регистрируются посредством метода DependencyProperty. Register (), а для регистрации маршрутизируемых событий предназначен метод Eve nt Man age r. RegisterRoutedEvent (). При регистрации события нужно указать имя события, тип маршрутизации (об этом чуть позже), делегат, определяющий синтаксис обработчика события (в данном примере это RoutedEventHandler), и класс, которому принадлежит событие (в данном примере ButtonBase). Как правило, маршрутизируемые события упаковываются в обычные события .NET, чтобы сделать их доступными для всех языков .NET. Оболочка события добавляет и удаляет зарегистрированные вызывающие объекты с помощью методов AddHandlerO и RemoveHandler (), которые определены в базовом классе FrameworkElement и наследуются каждым элементом WPF.
Глава 5. Маршрутизируемые события 135 Совместное использование маршрутизируемых событий Как и в случае свойств зависимости, определение маршрутизируемых событий можно совместно использовать несколькими классами. К примеру, событие MouseUp используют два базовых класса: UIElement (начальная точка для обычных элементов WPF) HContentElement (начальная точка для элементов контента — отдельных частей содержимого, которые могут помещаться в документе потока). Событие MouseUp определено в классе System.Windows.Input.Mouse. Классы UIElement и ContentElement просто используют его с помощью метода RoutedEvent.AddOwner(): UIElement.MouseUpEvent = Mouse.MouseUpEvent.AddOwner(typeof(UIElement)); Генерация маршрутизируемого события Конечно, как и любое событие, определяющий класс должен где-то сгенерировать маршрутизируемое событие. Г^е именно — это уже детали реализации. Однако следует помнить, что ваше событие не возбуждается через традиционную оболочку событий .NET. Вместо этого используется метод RaiseEvent (), наследуемый каждым элементом от класса UIElement. Ниже представлен соответствующий код класса ButtonBase: RoutedEventArgs e = new RoutedEventArgs(ButtonBase.ClickEvent, this); base.RaiseEvent(e) ; Метод RaiseEvent () отвечает за генерацию события для каждого вызывающего объекта, который был зарегистрирован с помощью метода AddHandler (). Поскольку этот метод является общедоступным, вызывающим объектам предоставляется выбор: они могут зарегистрироваться напрямую, с помощью метода AddHandler (), либо воспользоваться оболочкой события. (В следующем разделе продемонстрированы оба подхода.) В любом случае они будут уведомлены о вызове метода RaiseEvent (). Все события WPF придерживаются знакомого вам условия о сигнатурах событий, существующего в .NET. Первый параметр каждого обработчика события содержит ссылку на объект, который сгенерировал событие (отправитель). Второй параметр — объект EventArgs, объединяющий все дополнительные детали, которые могут понадобиться. Например, событие MouseUp предоставляет объект MouseEventArgs, который показывает, какая кнопка мыши была нажата при возникновении события: private void img_MouseUp(object sender, MouseButtonEventArgs e) { } В приложениях Windows Forms для многих событий обычно применялся базовый класс EventArg, если им не требовалось передавать дополнительную информацию. В приложениях WPF ситуация иная, поскольку в них поддерживается модель маршрутизируемых событий. Если событию не нужно посылать какую-либо дополнительную информацию, то в WPF оно использует класс RoutedEventArgs, который содержит некоторые сведения о маршрутизации события. Если событию нужно передать дополнительную информацию, оно использует более специализированный объект, порожденный от RoutedEventArgs (как MouseButtonEventArgs в предыдущем примере). Поскольку каждый класс аргумента события WPF порожден от RoutedEventArgs, каждый обработчик события WPF имеет доступ к информации о маршрутизации события. Обработка маршрутизируемого события Как было сказано в главе 2, прикрепить обработчик события можно несколькими способами. Чаще всего для этой цели добавляется атрибут события в разметку XAML.
136 Глава 5. Маршрутизируемые события Данный атрибут события получает имя события, которое нужно обрабатывать, а его значением является имя метода обработчика события. Вот пример, в котором этот синтаксис применяется для прикрепления обработчика imgMouseUp к событию MouseUp элемента Image: <Image Source="happyface.jpg" Stretch="None" Name="img" MouseUp="img_MouseUp" /> Обычно (хотя и не обязательно) имя метода обработчика события имеет вид ИмяЭлементаИмяСобытия. Если элемент не имеет определенного имени (возможно, потому, что с ним не нужно взаимодействовать в любом другом месте кода), попробуйте использовать имя, которое он мог бы иметь: <Button Click="cmdOK Click">OK</Button> Совет. Может возникнуть желание прикрепить событие к высокоуровневому методу, выполняющему задачу. Однако вы получите большую гибкость при наличии дополнительного уровня кода для обработки событий. Например, щелчок на кнопке cmdUpdate не вызовет непосредственно метод UpdateDatabaseO. Вместо этого будет вызван обработчик события — например, cmdUpdate_Click() —который может вызвать метод UpdateDatabaseO, а уже тот сделает всю работу. Этот принцип позволяет изменить местонахождение кода базы данных, заменить кнопку обновления другим элементом управления, привязать несколько элементов управления к одному и тому же процессу — и все это при полной возможности изменять в последующем пользовательский интерфейс. Если необходим более простой способ работы с действиями, которые могут запускаться из нескольких разных мест в пользовательском интерфейсе (кнопки панели инструментов, команды меню и т.д.), понадобится добавить средство команд WPF, описанное в главе 9. Событие можно соединить и с кодом. Вот эквивалент приведенного выше кода разметки XAML: img.MouseUp += new MouseButtonEventHandler(img_MouseUp); Этот код создает объект делегата, имеющий правильную сигнатуру для события (в данном случае это экземпляр делегата MouseButtonEventHandler) и указывающий на метод imgMouseUp (). Затем он добавляет делегат в список зарегистрированных обработчиков для события img.MouseUp. Язык С# разрешает применять более лаконичный синтаксис, явным образом создающий подходящий объект делегата: img.MouseUp += img_MouseUp; Подход с использованием кода полезен тогда, когда нужно динамически создать элемент управления и прикрепить обработчик события в некоторый момент существования окна. Для сравнения скажем, что события, захватываемые в XAML, всегда присоединяются при первом создании экземпляра объекта окна. Этот подход позволяет также упростить и рационализировать код XAML, что исключительно полезно, если предполагается совместно использовать его не с программистами, а, скажем, с художниками- дизайнерами. Недостатком является большой объем шаблонного кода, который загромождает кодовые файлы. Подход, продемонстрированный в предыдущем коде, основан на оболочке события, которая вызывает метод UIElement.AddHandler(), как показано в предыдущем разделе. Вы можете связать событие напрямую, самостоятельно вызвав метод UIElement. AddHandler(), например: img.AddHandler(Image.MouseUpEvent, new MouseButtonEventHandler(img_MouseUp));
Глава 5. Маршрутизируемые события 137 При использовании этого подхода всегда приходится создавать подходящий тип делегата (например, MouseButtonEventHandler). Нельзя создать объект делегата неявно, как при захвате события через оболочку свойства, поскольку метод UIElement. AddHandler () поддерживает все события WPF и не знает, какой тип делегата вы хотите использовать. Некоторые разработчики предпочитают использовать имя класса, в котором определено событие, а не имя класса, сгенерировавшего событие. Ниже показан эквивалентный синтаксис, наглядно демонстрирующий определение события Mouse Up Event в классе UIElement. img.AddHandler(UIElement.MouseUpEvent, new MouseButtonEventHandler(img_MouseUp)); На заметку! Выбор подхода зависит от ваших предпочтений. Хотя у второго подхода есть недостаток: он не дает ясного представления о том, что класс Image обеспечивает событие MouseUpEvent. Такой код можно неправильно понять и предположить, что он прикрепляет обработчик, предназначенный для обработки MouseUpEvent во вложенном элементе. Об этой технологии мы поговорим в разделе "Прикрепляемые события" далее в этой главе. Если понадобится открепить обработчик события, то это можно сделать только в коде — например, с помощью операции -=: lmg.MouseUp -= img_MouseUp; Либо можно использовать метод UIElement.RemoveHandler(): lmg.RemoveHandler(Image.MouseUpEvent, new MouseButtonEventHandler(img_MouseUp)); Технически возможно прикрепить один и тот же обработчик к одному и тому же событию более одного раза. Обычно это происходит из-за ошибки при кодировании. (В этом случае обработчик события будет запущен несколько раз.) После удаления обработчика события, который был подключен дважды, событие все-таки запустит этот обработчик, но только один раз. Маршрутизация событий Как было сказано в предыдущей главе, многие элементы управления в WPF являются элементами управления содержимым, которые могут иметь разный тип и разный объем вложенного содержимого. Например, можно собрать графическую кнопку из отдельных графических элементов, создать метку, которая будет совмещать текст и рисунки, или поместить содержимое в специальный контейнер, чтобы его можно было прокручивать или сворачивать. И такой процесс "вкладывания" можно повторять столько раз, сколько уровней нужно получить. При этом возникает интересный вопрос. Например, предположим, что имеется метка, в которой имеется панель StackPanel, содержащая два текстовых блока и изображение: <Label BorderBrush="Black" BorderThickness="l"> <StackPanel> <TextBlock Margin="> Image and picture label </TextBlock> <Image Source="happyface.jpg" Stretch="None" /> <TextBlock Margin="> Courtesy of the StackPanel </TextBlock> </StackPanel> </Label>
138 Глава 5. Маршрутизируемые события Как вам уже известно, каждый ингредиент, помещаемый в окно WPF, так или иначе является наследником класса UIElement, включая Label, StackPanel, TextBlock и Image. Класс UIElement определяет несколько ключевых событий. Например, каждый класс, являющийся потомком UIElement, обеспечивает события MouseUp и MouseDown. А теперь подумайте, что произойдет при щелчке на изображении в такой метке. Понятно, что при этом возникнут события Image.MouseDown и Image.MouseUp. А если вам нужно обрабатывать все щелчки на метке одинаковым образом? То есть неважно, где щелкнул пользователь: на изображении, на тексте или на пустом месте в области метки. В любом из этих случаев нужно реагировать на щелчок с помощью одного и того же кода. Понятно, что к событиям MouseDown и MouseUp каждого элемента можно привязать один и тот же обработчик, однако это может загромоздить код и усложнить сопровождение разметки. WPF предлагает более удобное решение с помощью модели маршрутизируемых событий. Маршрутизируемые события бывают трех видов: • Прямые (direct) события подобны обычным событиям .NET. Они возникают в одном элементе и не передаются в другой. Например, прямым является событие MouseEnter, которое возникает, когда указатель мыши наводится на элемент. • Пузырьковые (bubbling) события поднимаются по иерархии содержания. Например, пузырьковым событием является MouseDown. Оно возникает в элементе, на котором был произведен щелчок, потом передается от этого элемента к родителю, затем к родителю этого родителя, и т.д., пока WPF не достигнет вершины дерева элементов. • Туннелируемые (tunneling) события опускаются по иерархии содержания. Они позволяют предварительно просматривать (и, возможно, останавливать) событие, прежде чем оно дойдет до подходящего элемента управления. Например, PreviewKeyDown позволяет перехватить нажатие клавиши, сначала на уровне окна, а затем в более специфических контейнерах, вплоть до элемента, содержавшего фокус в момент нажатия клавиши. При регистрации маршрутизируемого события с помощью метода EventManager. RegisterEventO ему передается значение из перечисления RoutingStrategy, которое задает необходимое поведение для события. Поскольку события MouseUp и MouseDown являются пузырьковыми событиями, вы уже можете определить, что произойдет в примере с составной меткой. При щелчке на довольном смайлике событие MouseDown возникнет в следующем порядке: 1. Image.MouseDown 2. StackPanel.MouseDown 3. Label.MouseDown После того как событие MouseDown возникнет в метке, оно передается следующему элементу управления (в данном случае это сетка Grid для разметки вмещающего окна), а затем его родителю (окно). Окно находится на самом верху иерархии содержания и в самом конце в последовательности пузырькового распространения события. Здесь последний шанс обработать пузырьковое событие наподобие MouseDown. Если пользователь отпускает кнопку мыши, в такой же последовательности возникает событие MouseUp. На заметку! В главе 24 вы научитесь создавать страничные WPF-приложения. В них контейнером самого верхнего уровня является не окно, а экземпляр класса Page.
Глава 5. Маршрутизируемые события 139 Пузырьковые события не обязательно обрабатывать в одном месте: например, ничто не мешает обрабатывать события MouseDown и MouseUp на каждом уровне. Однако, как правило, для каждой задачи выбирается наиболее подходящая маршрутизация событий. Класс RoutedEventArgs При обработке пузырькового события параметр отправителя содержит ссылку на последнее звено в цепочке. Например, если событие перед обработкой всплывает от изображения до метки, то параметр отправителя будет ссылаться на объект метки. В некоторых случаях требуется знать, где первоначально произошло событие. Эту информацию, а также другие подробности, можно получить из свойств класса RoutedEventArgs (которые перечислены в табл. 5.1). Поскольку все классы аргументов событий WPF являются наследниками RoutedEventArgs, эти свойства доступны в любом обработчике события. Таблица 5.1. Свойства класса RoutedEventArgs Имя Описание Source OriginalSource RoutedEvent Handled Указывает, какой объект сгенерировал событие. Если речь идет о событии клавиатуры, то это элемент управления, имевший фокус ввода в момент возникновения события (например, когда была нажата клавиша). В случае события мыши это самый верхний элемент под указателем мыши в момент возникновения события (например, когда был произведен щелчок кнопкой мыши) Указывает, какой объект первоначально сгенерировал событие. Как правило, совпадает с Source. Однако в некоторых случаях OriginalSource спускается глубже по дереву объектов, чтобы дойти до внутреннего элемента, являющегося частью элемента более высокого уровня. Например, если вы щелкнете кнопкой мыши близко к границе окна, то получите объект Window в качестве источника события и Border в качестве первоначального источника. Это объясняется тем, что window состоит из отдельных меньших элементов. Чтобы разобраться с этой сборной моделью более детально (и узнать, как ее можно изменить), обратитесь к главе 17, в которой рассказывается о шаблонах элементов управления Предоставляет объект RoutedEvent для события, сгенерированного вашим обработчиком события (например, статический объект UIElement.MouseUpEvent). Эта информация бывает полезна при обработке разных событий одним и тем же обработчиком Позволяет остановить процесс пузырькового распространения или тун- нелирования события. Если элемента управления заносит в свойство Handled значение true, событие прекращает продвижение и не будет возникать в любых других элементах. (В разделе "Обработка заблокированного события" будет описан один способ обхода этого ограничения.) Пузырьковые события На рис. 5.1 показано простое окно, которое демонстрирует пузырьковое распространение события. Если щелкнуть на какой-либо части метки, события будут возникать в порядке, перечисленном на текстовой панели ниже. На рис. 5.1 приведен вид этого окна сразу после щелчка пользователя на изображении внутри метки. Событие MouseUp проходит пять уровней и останавливается на пользовательской форме BubbledLabelClick.
140 Глава 5. Маршрутизируемые события Чтобы получить эту форму, нужно связать изображение и каждый элемент, стоящий над ним в иерархии элементов, с одним и тем же обработчиком события — методом SomethingClicked(). Вот как это делается в XAML: <Window х:Class="RoutedEvents.BubbledLabelClick" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="BubbledLabelClick" Height=59" Width=29" MouseUp="SomethingClieked" > <Grid Margin=" MouseUp="SomethingClicked"> <Grid.RowDefinitions> <RowDefinition Height="Auto"x/RowDefinition> <RowDefinition Height="*"x/RowDef inition> <RowDefinition Height="Auto"x/RowDefinition> <RowDefinition Height="Auto"x/RowDefinition> </Grid.RowDefinitions> <Label Margin=" Grid.Row=" HorizontalAlignment="Left" Background="AliceBlue" BorderBrush="Black" BorderThickness="l" МоиseUp="SomethingCliсked"> <StackPanel MouseUp="SomethingClieked"> <TextBlock Margin=" MouseUp="SomethingClicked"> Image and picture label</TextBlock> <Image Source="happyface.jpg" Stretch="None" MouseUp="SomethingClicked" /> <TextBlock Margin=" MouseUp="SomethingClicked"> Courtesy of the StackPanel</TextBlock> </StackPanel> </Label> <ListBox Grid.Row="l" Margin=" Name="lstMessages"x/ListBox> <CheckBox Grid.Row=" Margin=" Name="chkHandle"> Handle first event</CheckBox> <Button Grid.Row=" Margin=" Padding=" HorizontalAlignment="Right" Name="cmdClear" Click="cmdClear_Click">Clear List</Button> </Grid> </Window> Метод SomethingClicked() просто проверяет свойства объекта RoutedEventArgs и добавляет сообщение на текстовую панель: protected int eventCounter = 0; private void SomethingClicked(object sender, RoutedEventArgs e) ' { eventCounter++; string message = "#" + eventCounter.ToString() + ":\r\n" + " Sender: " + sender.ToString() + "\r\n" + " Source: " + e.Source + "\r\n" + " Original Source: " + e .OnginalSource; IstMessages.Items.Add(message); e.Handled = (bool)chkHandle.IsChecked; } На заметку! С технической точки зрения событие MouseUp предоставляет объект MouseButton EventArgs с дополнительной информацией о состоянии мыши в момент возникновения события. Однако класс MouseButtonEventArgs является наследником MouseEventArgs, который в свою очередь является наследником класса RoutedventArgs. Это позволяет использовать его при объявлении обработчика события (как показано здесь), если дополнительная информация о мыши не требуется.
Глава 5. Маршрутизируемые события 141 В этом примере есть еще один момент Если установить флажок chkHandle, метод SomethingClickedO присвоит свойству RoutedEventArgs.Handled значение true, что останавливает последовательность пузырькового распространения события сразу при его возникновении. Поэтому вы увидите в списке только первое событие, как показано на рис. 5.2. На заметку! Здесь нужно дополнительное приведение, т.к. свойство CheckBox.IsChecked является логическим значением, которое может принимать значение null (bool?, а не bool). Значение null представляет неопределенное состояние флажка, которое означает, что он и не установлен, и не сброшен. Эта особенность не используется в данном примере, поэтому достаточно простого приведения. Поскольку метод SomethingClickedO обрабатывает событие MouseUp, которое возникает в объекте Window, щелчки можно перехватывать в текстовой панели и на пустой поверхности окна. Однако событие MouseUp не возникает при щелчке на кнопке Clear (которая удаляет из текстовой панели все записи). Это связано с тем, что кнопке соответствует интересный фрагмент кода, который блокирует событие MouseUp и генерирует событие более высокого уровня Click. Одновременно флагу Handled присваивается значение true, что блокирует дальнейшее продвижение события MouseUp. Совет. В отличие от элементов управления Windows Forms, большинство элементов WPF не имеют события Click. Вместо этого у них есть более простые события MouseDown и MouseUp. Событие Click зарезервировано для кнопочных элементов управления ■. BubbledLabeClick Image and picture label Courtesy of the StackPanel •1: Sender. System Windows Controls.Image Source System.Windows.ControlsImage Original Source: Systerr..Windows.Controls.Image #2: Sender. System.Windows.ControlsStackPanel Source: System Windows.Contrcls.Image Original Source: System.Windows.Contrors.Image Sender System Windows Controls.LabeJ j Source System Windows Controls Image Original Source: System.Windows.ControlsImage *4 Sender System Windows.Controls.Grid Source: System.Windows.ControlsImage Original Source: System.Windows.Controlsimage #5: Sender: RouiedEvents BubbledlabelClick Source: System WindowsControls-Image Original Source: System.Windows.Controts.Image \ ] Handle first event Clear List Рис. 5.1. Пузырьковое распространение события после щелчка на изображении ■ BubbledLabelCiick ^i-ьЖШ Image and picture label •J Courtesy of the StackPanel = . Sender System.Windows.Controls Image Source: System Windows ControlsJmage Original Source: System .Windows.ControlsJmage V,;Hand|e first event Рис. 5.2. Пометка события как обработанного
142 Глава 5. Маршрутизируемые события Обработка заблокированного события Интересно, что существует способ получать события, которые отмечены как обработанные. Вместо прикрепления обработчика события посредством XAML следует использовать рассмотренный ранее метод AddHandler (). Этот метод имеет перегруженный вариант, который принимает логическое значение в третьем параметре. Если задать его равным true, вы получите событие, даже если для него был установлен флаг Handled: cmdClear.AddHandler(UIElement.MouseUpEvent, new MouseButtonEventHandler(cmdClear_MouseUp), true); Такое решение редко бывает удачным. Кнопка предназначена для блокирования события MouseUp по очень простой причине: чтобы избежать путаницы. Ведь в Windows принято, что "щелкнуть" на кнопке можно и с помощью клавиатуры, да еще несколькими способами. Если вы ошибочно будете обрабатывать в элементе Button событие MouseUp, а не события Click, то ваш код будет реагировать только на щелчки мышью, но не на эквивалентные клавиатурные действия. Прикрепляемые события Рассмотренная декоративная метка является довольно простым примером пузырькового распространения события, поскольку все элементы поддерживают событие MouseUp. Но многие элементы управления обладают собственными специальными событиями. Одним из таких примеров является кнопка: она добавляет событие Click, которое не определено ни в одном базовом классе. Здесь возникает интересный момент. Предположим, что стек кнопок упакован в элемент StackPanel, и необходимо обработать все щелчки на кнопках в одном обработчике события. Конечно, можно прикрепить события Click каждой кнопки к одному и тому же обработчику события. Однако событие Click поддерживает пузырьковое распространение событий, и это позволяет решить задачу более изящным способом. Все щелчки на кнопках можно обработать, реагируя на событие Click на более высоком уровне (например, на уровне элемента StackPanel). К сожалению, следующий — вроде бы очевидный — код работать не будет: <StackPanel Click="DoSomething" Margin="> <Button Name="cmdl">Command K/Button> <Button Name="cmd2">Command 2</Button> <Button Name="cmd3">Command 3</Button> </StackPanel> t Дело в том, что StackPanel не содержит событие Click, поэтому такой код вызовет ошибку во время синтаксического анализа XAML. Для решения этой задачи нужно использовать другой синтаксис с применением прикрепленных событий в виде ИмяКласса.ИмяСобытия. Вот исправленный вариант: <StackPanel Button.Click="DoSomething" Margin="> <Button Name="cmdl">Command K/Button> <Button Name="cmd2">Command 2</Button> <Button Name="cmd3">Command 3</Button> </StackPanel> Теперь обработчик события получит управление при щелчках на всех упакованных кнопках.
Глава 5. Маршрутизируемые события 143 На заметку! Событие Click определено в классе ButtonBase и наследуется классом Button. Если прикрепить обработчик события к ButtonBase.Click, то этот обработчик события будет использоваться при щелчке на любом элементе управления, порожденном от ButtonBase (включая классы Button, RadioButton и CheckBox). Но если прикрепить обработчик события к Button.Click, то он будет использоваться только для объектов Button. Прикрепляемое событие можно подключить и в коде, но тогда вместо операции += придется использовать метод UIElement.AddHandler(). Вот пример (здесь предполагается, что элемент StackPanel имеет имя pnlButtons): pnlButtons.AddHandler(Button.Click, new RoutedEventHandler(DoSomething)); Если несколько возможностей определить в обработчике события DoSomethingO, какая кнопка сгенерировала событие. Можно сравнить ее текст (возможны проблемы с локализацией) или ее имя (ненадежно, так как на этапе создания приложения невозможно перехватить ошибочно введенные имена). Лучше всего задать с помощью XAML у каждой кнопки свойство Name — тогда можно обратиться к соответствующему объекту посредством поля в классе окна и сравнить эту ссылку с отправителем события. Вот пример: private void DoSomething(object sender, RoutedEventArgs e) { if (sender == cmdl) { ... } else if (sender == cmd2) { ... } else if (sender == cmd3) { ... } } Существует еще один вариант: вместе с кнопкой отправить порцию информации, которую можно использовать в коде. Например, для каждой кнопки можно задать свойство Tag: <StackPanel Click="DoSomething" Margin="> <Button Name="cmdl" Tag="The first button.">Command K/Button> <Button Name="cmd2" Tag="The second button.">Command 2</Button> <Button Name="cmd3" Tag="The third button.">Command 3</Button> </StackPanel> После этого можно обращаться к свойству Tag в коде: private void DoSomething(object sender, RoutedEventArgs e) { object tag = ((FrameworkElement)sender).Tag; MessageBox.Show((string)tag); } Туннелируемые события Туннелируемые события работают так же, как и пузырьковые, но в обратном направлении. Например, если бы событие MouseUp было туннельным (а это не так), то при щелчке на изображении в примере с меткой событие MouseUp возникло бы сначала в окне, затем в элементе Grid, затем в StackPanel и так далее до достижения источника, т.е. изображения в метке. Туннелируемые события легко распознать: они начинаются на слово Preview. Более того, WPF обычно определяет события попарно. Это означает, что если имеется пузырьковое событие MouseUp, то, скорее всего, существует и туннелируемое событие PreviewMouseUp. Туннелируемые событие всегда возникает перед пузырьковым событием, как показано на рис. 5.3.
144 Глава 5. Маршрутизируемые события Туннельное событие PreviewMouseUp Корневой элемент (окно) S Промежуточный элемент Промежуточный элемент Источник события С Здесь ) ( ВОЗНИКЛО Л N последним ,-> Пузырьковое событие MouseUp Рис. 5.3. Туннелируемые и пузырьковые события * • Tunne»edKeyPress Image ana text label Type here: dj Sender RoutedEvents.TunnetedKeyPress Source: System. W»ndows.Controte TextBox Original Source: System.Winoows.Controls.TextBox Event: Keyboard PrevtewKeyDown #2: Sender System Windows Controte-Label Source System.Windows.Controls.TextBox Ongtnal Source: System. Windov».Contro»s. TextBox Event Keyboard.PreviewKeyDown #3 Sender System.WindowsControJsStackPanel Source: System.Windows.Controls TextBox Original Source System.Windows.Controls.TextBox Event Keyboard.PrevtewKeyOown #4: Sender System Windows Controls.DockPanel : Source: System.Windows.Controls TextBox Original Source: System.W»ndows.Controls.TextBox Event: Keyboard.PreviewKeyDown #5: Sender System Windows Controls TextBox Source System Windows ControlsTextBox Original Source: 5ystem.Windows.Controls.TextBox : Event Keyboard .PreviewKeyOown #6: Sender: System.Windows-Controls.TextBox Source: System Windows Controls TextBox Original Source System.Windows.Contrors.TextBox Event: Keyboard.KeyDown ] Handle first event Интересный момент: если пометить тун- нелируемое событие как обработанное, то пузырьковое событие не возникнет Это связано с тем, что оба события совместно используют один и тот же экземпляр класса RoutedEventArgs. Туннелируемые события полезны, если нужно выполнить предварительную обработку, связанную с определенными нажатиями клавиш, или отфильтровать некоторые события мыши. На рис. 5.4 показан результат проверки туннелирования на примере события PreviewKeyDown. Если нажать клавишу, когда фокус находится в текстовом поле, событие возникает сначала в этом поле, а затем спускается по иерархии. И если на каком-то этапе пометить событие PreviewKeyDown как обработанное, то пузырьковое событие Key Down не возникнет. Совет. Будьте аккуратны с пометкой туннелируе- мого события как обработанного. В зависимости от конструкции управляющего элемента, это может помешать ему обработать собственное (соответствующее пузырьковое) событие, чтобы выполнить какое-то действие или обновить свое состояние. Рис. 5.4. Туннелируемое нажатие клавиши
Глава 5. Маршрутизируемые события 145 Определение стратегии маршрутизации события Понятно, что разные стратегии маршрутизации влияют на способ использования событий А как определить, какой тип маршрутизации использует данное событие? С туннелируемыми событиями все просто. В соответствии с соглашениями, принятыми в .NET, туннелируемое событие всегда начинается со слова Preview (например, PreviewKeyDown). Однако похожего механизма различения пузырьковых и прямых событий не существует. Разработчикам, применяющим WPF, лучше всего найти описание события в документации по Visual Studio. В разделе "Routed Event Information" указываются статическое поле события, тип маршрутизации и сигнатура события. Эту же информацию можно получить программным способом, проверив статическое поле для события Например, свойство ButtonBase.ClickEvent.RoutingStrategy содержит перечислимое значение, которое сообщает, какой тип маршрутизации использует событие Click. События WPF Теперь вы знаете о том, как работают события WPF, и можно приступить к рассмотрению самых разнообразных событий, на которые вы можете реагировать в своем коде. Каждый элемент имеет много разнообразных событий, однако наиболее важные события обычно делятся на пять следующих категорий: • События времени существования. Возникают при инициализации, загрузке или выгрузке элемента. • События мыши. Возникают в результате действий мыши. • События клавиатуры. Возникают в результате действий клавиатуры (например, нажатие клавиши). • События пера. Возникают в результате использования пера (стилуса), которое заменяет мышь в планшетных ПК. • События одновременного касания. Возникают в результате прикасания к многопозиционному сенсорному экрану одним или несколькими пальцами. Поддерживаются только в Windows 7. Вместе события мыши, клавиатуры, пера и касания известны как события ввода. События времени существования Все элементы генерируют события при создании и освобождении. Эти события можно использовать для инициализации окна. События времени существования перечислены в табл. 5.2; все они определены в классе FrameworkElement. Таблица 5.2. События времени существования всех элементов Имя Описание Initialized Возникает после создания экземпляра элемента и установки его свойств в соответствии с разметкой XAML В этот момент элемент уже инициализирован, но другие части окна могут еще быть не инициализированными. Кроме того, еще не применены стили и привязка данных. Свойство Islnitialized имеет значение true. Данное событие является обычным событием .NET, а не маршрутизируемым Loaded Возникает после завершения инициализации всего окна и применения стилей и привязки данных. Это последний этап перед прорисовкой элемента В этот момент свойство IsLoaded имеет значение true Unloaded Возникает после освобождения элемента: или из-за закрытия содержащего его окна, или из-за удаления из окна данного элемента
146 Глава 5. Маршрутизируемые события Чтобы понять, как связаны между собой события Initialized и Loaded, полезно рассмотреть процесс прорисовки. FrameworkElement реализует интерфейс I Support Initialize, который предоставляет два метода управления процессом инициализации. Первый из них, Beginlnit(), вызывается сразу после создания экземпляра элемента. После этого вызова интерпретатор XAML устанавливает все свойства элемента (и добавляет любое содержимое). Второй метод, Endlnit(), вызывается после завершения инициализации, когда возникает событие Iniitialized. На заметку! Это несколько упрощенное описание. Интерпретатор XAML самостоятельно вызывает методы Beginlnit() и Endlnit(), как и должно быть. Однако если создать элемент вручную и добавить его в окно, то вы вряд ли будете использовать этот интерфейс. В этом случае элемент сгенерирует событие Initialized сразу после добавления его в окно, непосредственно перед событием Loaded. При создании окна каждая ветвь элементов инициализируется снизу вверх. Это означает, что глубоко вложенные элементы инициализируются до того, как будут инициализированы их контейнеры. Когда возникает событие Initialized, это означает, что дерево элементов от текущего элемента и ниже полностью инициализировано. Однако элемент, содержащий данный элемент, скорее всего, не инициализирован, и нет оснований предполагать, что инициализирована любая другая часть окна. После инициализации каждого элемента он размещается в контейнере, обрабатывается стилями и при необходимости привязывается к источнику данных. После возбуждения события Initialized для окна пора переходить к следующему этапу. После завершения процесса инициализации возникает событие Loaded. Оно распространяется в порядке, обратном событию Initialized: сначала событие Loaded генерируется во вмещающем окне, а затем его генерируют остальные вложенные элементы. Когда событие Loaded будет сгенерировано во всех элементах, окно станет видимым, и в нем будут прорисованы все элементы. События времени существования, перечисленные в табл. 5.2 — это еще не все. Содержащее окно имеет собственные события времени существования. Они перечислены в табл. 5.3. Таблица 5.3. События времени существования класса Window Имя Описание Sourcelnitialized Возникает при получении свойства HwndSource (но перед тем, как окно станет видимым). HwndSource — это дескриптор окна, который может понадобиться для вызова устаревших функций интерфейса Win32 API ContentRendered Возникает сразу после первой прорисовки окна. Здесь лучше не выполнять какие-либо изменения, которые могут повлиять на внешний вид окна, иначе придется выполнять еще одну прорисовку. (Используйте вместо него событие Loaded.) Однако событие ContentRendered означает, что окно является полностью видимым и готово для ввода Activated Возникает, когда пользователь переключается на это окно (например, из другого окна в данном приложении или вообще из другого приложения). Это событие возникает также во время первой загрузки окна. В концептуальном плане событие Activated является "оконным" эквивалентом события GotFocus для элементов управления
Глава 5. Маршрутизируемые события 147 Окончание табл. 5.3 Имя Описание Deactivated Возникает, когда пользователь выходит (т.е. переключается) из этого окна (например, переходит в другое окно в данном приложении или вообще в другое приложение). Это событие возникает также, когда пользователь закрывает окно, после события Closing, но перед событием Closed. В концептуальном плане событие Deactivated является "оконным" эквивалентом события LostFocus элементов управления Closing Возникает при закрытии окна — либо пользователем, либо программно с помощью метода Window.CloseO или Application.Shutdown(). Событие Closing позволяет отменить операцию и оставить окно открытым: достаточно присвоить свойству CancelEventArgs.Cancel значение true. Однако код не получит событие Closing, если приложение завершает работу вследствие того, что пользователь выключает компьютер или выходит из системы — для этого нужно обрабатывать событие Application.SessionEnding, которое описано в главе 7 Closed Возникает после закрытия окна. Объекты элемента еще доступны, а событие Unloaded еще не возникло. В этот момент можно выполнить зачистку, записать параметры для постоянного хранения (например, в конфигурационный файл или в системный реестр Windows) и т.д. Если вам нужно просто выполнить первичную инициализацию элементов управления, то наилучшим моментом для этого является событие Loaded. Как правило, все действия, связанные с инициализацией, можно выполнить в одном месте — обычно это обработчик события Window.Loaded. Совет. Для выполнения инициализации можно применять также конструктор окна (просто добавьте необходимый код сразу после вызова InntializeComponentO). Однако лучше всего использовать событие Loaded. Это связано с тем, что если в конструкторе Window возникнет исключение, оно будет сгенерировано во время разбора страницы интерпретатором XAML. В результате исключение будет упаковано в бесполезный объект XamlParseException (с исходным исключением в свойстве InnerException). События ввода События ввода — это события, которые возникают, когда пользователь работает с каким-то периферийным оборудованием: мышью, клавиатурой, пером или сенсорным экраном. События ввода могут передавать дополнительную информацию в специальном классе аргументов событий, который порожден от InputEventArgs. Иерархия наследования показана на рис. 5.5. Класс InputEventArgs добавляет только два свойства: Timestamp и Device. Свойство Timestamp содержит целочисленное значение, показывающее в миллисекундах время возникновения события. (Действительное время, представленное этим значением, особой роли не играет, но сравнение разных меток времени позволяет узнать, какое событие возникло первым. Большие временные метки означают более поздние события.) Свойство Device возвращает объект с более подробной информацией об устройстве, сгенерировавшем событие, которым может быть мышь, клавиатура или перо. Каждый из этих трех вариантов представлен отдельным классом, и все они порождены от абстрактного класса System.Windows.Input.InputDevice. В последующих разделах мы подробно рассмотрим обработку событий мыши, клавиатуры и сенсорных экранов в WPF-приложениях.
со СП ntA CD 2 rgs Eve ТЭ Route CO en tAr ven ш Input CO СП tAr с hEve \- i tArgs > ш nStarl atio mpu CO 2> tArgs £= CD dEv CD nStart atio npul CO ^ rgs nDeltaEventA atio mpu Л S ventArgs LU -o ete Q_ nCom atio ampul S ingEventArgs r Л Л nlnerti atio ampul S 1 1 l-n- 1 1 co ventArg StylusE -^ ntArgs uttonEve tylusB CO l tArg own Even tylusD CO 1 EventArgs sture ystemGe tylusS CO 1 ^ eEventArgs Mous ел tArg seButtonEven Мои ел tArg seWheelEven Мои ел tArg ryCursorEven Que ^ i i i ^ dEventArgs Keyboar ^^ ^щ eEventArgs ardFocusCha Keybo 1 entArgs KeyEv 1 ckEventArgs Л n eed Ll_ ndary Bou lation Mampi CO о vo о И < с CD > И in If) о s Q.
Глава 5. Маршрутизируемые события 149 Ввод с клавиатуры Когда пользователь нажимает клавишу, возникает целая серия событий. В табл. 5.4 эти события перечислены в порядке их возникновения. Таблица 5.4. События клавиатуры для всех элементов (в порядке их возникновения) Имя Тип маршрутизации Описание PreviewKeyDown KeyDown PreviewTextInput Textlnput PreviewKeyUp KeyUp Туннелирование Пузырьковое распространение Туннелирование Пузырьковое распространение Туннелирование Пузырьковое распространение Возникает при нажатии клавиши. Возникает при нажатии клавиши. Возникает, когда нажатие клавиши завершено, и элемент получает текстовый ввод. Это событие не возникает для тех клавиш, которые не "печатают" символы (например, оно не возникает при нажатии клавиш <Ctrl>, <Shift>, <Backspace>, клавиш управления курсором, функциональных клавиш и т.д.) Возникает, когда нажатие клавиши завершено, и элемент получает текстовый ввод. Это событие не возникает для тех клавиш, которые не "печатают" символы. Возникает при отпускании клавиши Возникает при отпускании клавиши Обработка событий клавиатуры отнюдь не так легка, как это может показаться. Некоторые элементы управления могут блокировать часть этих событий, чтобы выполнять свою собственную обработку клавиатуры. Наиболее ярким примером является элемент TextBox, который блокирует событие Textlnput, а также событие KeyDown для нажатия некоторых клавиш, таких как клавиши управления курсором. В подобных случаях обычно все-таки можно использовать туннелируемые события (PreviewTextlnput и PreviewKeyDown). Элемент TextBox добавляет одно новое событие — TextChanged. Это событие возникает сразу после того, как нажатие клавиши приводит к изменению текста в текстовом поле. Однако в этот момент новый текст уже видим в текстовом поле, потому отменять нежелательное нажатие клавиши уже поздно. Обработка нажатия клавиши Понять, как работают и используются события клавиатуры, лучше всего на примере. На рис. 5.6 показана программа, которая отслеживает и протоколирует все возможные нажатия клавиш, когда в фокусе находится текстовое поле. В данном случае показан результат ввода заглавной буквы S. Этот пример демонстрирует один важный момент. События PreviewKeyDown и KeyDown возникают при каждом нажатии клавиши. Однако событие Textlnput возникает только тогда, когда в элементе был "введен" символ. На самом деле это может означать нажатие многих клавиш. В примере, показанном на рис. 5.5, нужно нажать две клавиши, чтобы получить заглавную букву S: сначала клавишу <Shift>, а затем клавишу <S>. В результате получаются по два события KeyDown и KeyUp, но только одно событие Textlnput.
150 Глава 5. Маршрутизируемые события | ш KeyPressEvents j Type here: SJ ; Event: Keyboard.PreviewKeyDown Key LeftShift : Event: Keyboard.KeyOown Key: LeftShift ! Event: Keyboard.PreviewKeyOown Key: S i Event: Keyboard. Key Down Key: S j: Event: TextCompositlonManager.PreviewTextlnput Text: S [ | Event: TextBoxBase.TextChanged : Event: Keyboard.PreviewKeyUp Key. S i Event: Keyboard.Keytlp Key: S t Event: Keyboard.PreviewKeyUp Key: LeftShift i Event: Keyboard.KeyUp Key LeftShift V Ignore Repeated Keys dear List Рис. 5.6. Наблюдение за клавиатурой Каждое из событий PreviewKeyDown, KeyDown, PreviewKey и KeyUp передает объекту KeyEventArgs одну и ту же информацию. Самой важной ее частью является свойство Key, которое возвращает значение из перечисления System.Windows.Input.Key и идентифицирует нажатую или отпущенную клавишу. Ниже представлен код обработчика события, который работает с событиями клавиш из примера на рис. 5.6. private void KeyEvent(object sender, KeyEventArgs e) { string message = "Event: " + e. RoutedEvent + " " + " Key: " + e.Key; IstMessages.Items.Add(message); } Значение Key не учитывает состояние любой другой клавиши — например, была ли прижата клавиша <Shift> в момент нажатия <S>; в любом случае вы получите одно и то же значение Key (Key.S). Здесь присутствует одна сложность. В зависимости от настройки клавиатуры в Windows, удержание клавиши в прижатом состоянии приводит к повторам нажатия после короткого промежутка времени. Например, прижатие клавиши <S> приведет к вводу в текстовое поле целой серии символов S. Точно так же прижатие клавиши <Shift> приводит к повторам нажатия и возникновению серии событий KeyDown. В реальном примере при нажатии комбинации <Shift+S> текстовое поле сгенерирует серию событий KeyDown для клавиши <Shift>, потом событие KeyDown для клавиши <S>, событие Text Input (или событие TextChanged в случае текстового поля), а затем событие KeyUp для клавиш <Shift> и <S>. Если нужно игнорировать повторы нажатия клавиши <S>, то можно проверить, является ли нажатие результатом прижатия клавиши, с помощью свойства KeyEventArgs.IsRepeat: if ((bool)chklgnoreRepeat.IsChecked && e.IsRepeat) return; Совет. События PreviewKeyDown, KeyDown, PreviewKey и KeyUp больше подходят для написания низкоуровневого кода обработки ввода с клавиатуры (что редко бывает нужно — разве что в пользовательских элементах управления) и обработки нажатий специальных клавиш (например, функциональных).
Глава 5. Маршрутизируемые события 151 За событием KeyDown следует событие PreviewText Input. (Событие Text Input не возникает, поскольку элемент TextBox блокирует его.) В этот момент текст еще не отображается в элементе управления. Событие Textlnput обеспечивает код объекта TextCompositionEventArgs. Этот объект содержит свойство Text, которое дает обработанный текст, подготовленный к передаче элементу управления. Вот код добавления текста в список, показанный на рис. 5.6. private void Textlnput(object sender, TextCompositionEventArgs e) { string message = "Event: " + e .RoutedEvent + " " + " Text: " + e.Text; IstMessages.Items.Add(message); } В идеале событие PreviewText Input можно было бы использовать для выполнения проверки в элементах управления наподобие TextBox. Например, если вы создаете текстовое поле для ввода только чисел, можно проверить, не была ли введена при текущем нажатии клавиши буква, и установить флаг Handled, если это так. Увы — событие PreviewText Input не генерируется для некоторых клавиш, которые бывает нужно обрабатывать. Например, при нажатии клавиши пробела в текстовом поле событие PreviewText Input вообще пропускается. Это означает, что придется обрабатывать также событие PreviewKeyDown. К сожалению, трудно реализовать надежную логику проверки данных в обработчике события PreviewKeyDown, т.к. в наличии имеется только значение Key, а это слишком низкоуровневый фрагмент информации. Например, в перечислении Key различаются клавиши цифровой клавиатуры (блок, предназначенный для ввода только цифр) и обычной клавиатуры. Это означает, что в зависимости от того, где нажата клавиша с цифрой 9, вы получите или значение Key.D9, или значение Key.NumPad9. Проверка всех допустимых значений как минимум очень утомительна. Одним из выходов является использование класса Key Converter, который позволяет преобразовать значение Key в более полезную строку. Например, вызов функции KeyConverter.ConvertStringToStringO с любым из значений Key.D9 и Key.NumPad9 возвращает строковый результат "9". Вызов преобразования Key.ToStringO дает менее полезное имя перечисления (либо "D9", либо "NumPad9"): KeyConverter converter = new KeyConverter (); string key = converter.ConvertToString(e.Key); Однако использовать KeyConverter тоже не очень неудобно, поскольку приходится обрабатывать длинные строки (например, "Backspace") для тех нажатий клавиш, которые не приводят к вводу текста. Наиболее подходящим вариантом является обработка события PreviewTextlnput (где выполняется большая часть проверки) в сочетании с событием PreviewKeyDown для нажатий тех клавиш, которые не генерируют событие PreviewTextlnput в текстовом поле (например, пробела). Ниже показано простое решение. private void pnl_PreviewTextInput(object sender, TextCompositionEventArgs e) { short val; if ('Intl6.TryParse(e.Text, out val)) { // Пропуск нажатий не цифровых клавиш, е. Handled = true; } }
152 Глава 5. Маршрутизируемые события private void pnl_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Space) { // Пропуск пробела, который не генерирует событие PreviewTextlnput. е.Handled = true; } } Эти обработчики событий можно прикрепить к одиночному текстовому полю или подключить к контейнеру (например, к Stack Panel, содержащему несколько текстовых полей для ввода чисел) для получения большей эффективности. На заметку! Такое поведение при обработке нажатий клавиш может показаться чрезвычайно неудобным (и так оно и есть). Одной из причин, по которой TextBox не может обеспечить лучшую обработку нажатий клавиш, является то, что WPF ориентируется на привязку данных, которая позволяет подключать элементы управления наподобие TextBox к пользовательским объектам. При таком подходе проверка обычно выполняется привязанным объектом, об ошибках сигнализируют исключения, а неправильные данные генерируют сообщение об ошибке, которое появляется где-то в пользовательском интерфейсе. К сожалению, (пока) нет легкого способа сочетать высокоуровневую возможность привязки данных с низкоуровневой обработкой нажатий клавиш, необходимой для полного запрета ввода пользователем неверных символов. Фокус В мире Windows пользователь может работать в любой момент времени лишь с одним элементом управления. Элемент, который в данный момент получает нажатия клавиши пользователем, имеет фокус ввода. Иногда такой элемент выглядит немного по- другому. Например, кнопка WPF, имеющая фокус, приобретает синий оттенок. Чтобы элемент управления мог получать фокус, его свойство Focusable должно иметь значение true. По умолчанию это так для всех элементов управления. Интересно, что свойство Focusable определено как часть класса UIElement: это означает, что остальные элементы, не являющиеся элементами управления, тоже могут получать фокус. Обычно в классах, не являющихся элементами управления, свойство Focusable по умолчанию имеет значение false. Но ему можно присвоить значение true. Попробуйте сделать это на примере контейнера компоновки наподобие StackPanel: когда он получит фокус, вокруг панели появится пунктирная рамка. Чтобы переместить фокус с одного элемента на другой, пользователь может щелкнуть кнопкой мыши или воспользоваться клавишей <ТаЬ> и клавишами управления курсором. В предыдущих средах разработки программисты прилагали много усилий, чтобы клавиша <ТаЬ> передавала фокус понятным образом (обычно слева направо, а затем сверху вниз в окне), и чтобы при первом отображении окна фокус передавался нужному элементу управления. В WPF такая дополнительная работа требуется очень редко, т.к. тут для реализации последовательности переходов используется иерархическая компоновка элементов. По сути, при нажатии клавиши <ТаЬ> происходит переход к первому потомку в текущем элементе или, если текущий элемент не имеет (больше) потомков, к следующему элементу на том же уровне. Например, серия нажатий клавиши табуляции в окне, в котором имеются два контейнера StackPanel, перебирает все элементы управления в первом контейнере StackPanel, а затем все элементы управления во втором. Если необходимо управлять последовательностью переходов, можно задать свойство Tablndex каждого элемента управления, чтобы пронумеровать их в нужном порядке. Элемент с нулевым значением свойства Tablndex получает фокус первым, а
Глава 5. Маршрутизируемые события 153 затем — элементы с большим значением этого свойства (например, 1, 2, 3 и т.д.). При наличии нескольких элементов с одинаковыми значениями Tablndex WPF выполняет автоматическую передачу фокуса, когда фокус получает ближайший элемент в последовательности. Совет. По умолчанию свойство Tablndex во всех элементах управления имеет значение Int32. MaxValue. Это означает, что можно назначить определенный элемент в качестве стартовой точки в окне, присвоив его свойству Tablndex значение 0. Для остальных элементов в окне остается автоматическая навигация, и пользователь будет переходить по ним от данной стартовой точки в порядке определения этих элементов. Свойство Tablndex определено в классе Control, там же, где и IsTabStop. Свойству IsTabStop можно присвоить значение false, чтобы исключить элемент управления из последовательности переходов. Различие между IsTabStop и Focusable заключается в том, что элемент управления со свойством IsTabStop, равным false, может получить фокус другим путем — либо программно (когда в коде вызывается метод Focus ()), либо при щелчке кнопкой мыши. Элементы управления, являющиеся невидимыми или заблокированными (затенены) обычно не включаются в последовательность переходов и не активизируются, независимо от значений свойств Tablndex, IsTabStop и Focusable. Чтобы скрыть или заблокировать элемент управления, используются свойства Visibility и IsEnabled, соответственно. Получение состояния клавиши Когда происходит нажатие клавиши, часто бывает необходимо знать больше, чем просто какая именно клавиша была нажата. Кроме этого, важно знать, какие еще клавиши были прижаты в это же время. Это означает, что может понадобиться проанализировать состояние остальных клавиш, особенно модификаторов вроде <Shift>, <Ctrl> и <Alt>. События клавиш (PreviewKeyDown, KeyDown, PreviewKeyUp и KeyUp) позволяют получить эту информацию. Во-первых, объект KeyEventArgs содержит свойство KeyState, которое отражает свойство клавиши, сгенерировавшей событие. Есть еще одно, более полезное, свойство KeyboardDevice, которое предоставляет такую же информацию для любой клавиши на клавиатуре. Неудивительно, что свойство KeyboardDevice предоставляет экземпляр класса KeyboardDevice. Его свойства содержат информацию о том, какой элемент в данный момент имеет фокус (FocusedElement) и какие клавиши-модификаторы были прижаты в момент возникновения события (Modifiers). К клавишам-модификаторам относятся <Shift>, <Ctrl> и <Alt>; их состояние можно проверить с помощью следующего кода: if ( (e.KeyboardDevice.Modifiers & ModifierKeys.Control) == ModifierKeys.Control) { lbllnfo.Text = "You held the Control key."; } Класс KeyboardDevice тоже предоставляет несколько удобных методов, которые перечислены в табл. 5.5. Каждому из них нужно передать значение из перечисления Key. При использовании свойства KeyEventArgs.KeyboardDevice код получает состояние виртуальной клавиши — состояние клавиатуры в момент возникновения события. Оно не обязательно совпадает с текущим состоянием клавиатуры. Например, допустим, что пользователь вводит данные быстрее, чем выполняется их обработка в коде. При каждом возникновении события KeyPress вы будете иметь доступ к клавише, сгенерировавшей событие, а не к уже введенным символам. Как правило, именно такое поведение и нужно.
154 Глава 5. Маршрутизируемые события Таблица 5.5. Методы класса KeyboardDevice Имя Описание isKeyDown () Сообщает, была ли прижата данная клавиша в момент возникновения события isKeyUpO Сообщает, была ли отпущена (не прижата) данная клавиша в момент возникновения события IsKeyToggledO Сообщает, находилась ли данная клавиша во "включенном" состоянии в момент возникновения события. Это относится лишь к клавишам, которые могут быть включены или выключены: <Caps Lock>, <Scroll Lock> и <Num Lock> GetKeyStates () Возвращает одно или несколько значений из перечисления KeyStates и сообщает, является ли данная клавиша прижатой, отпущенной, включенной или выключенной. По сути, дублирует вызов методов IsKeyDown () и IsKeyUpO с передачей им той же клавиши Однако в событиях клавиатуры вы не ограничены получением лишь информации о клавише. Получать состояние клавиатуры можно в любой момент времени. Для этой цели предназначен класс Keyboard, который очень похож на класс KeyboardDevice, но состоит из статических членов. Вот пример использования класса Keyboard для проверки текущего состояния левой клавиши <Shift>: if (Keyboard.IsKeyDown(Key.LeftShift)) { lbllnfo.Text = "Левая клавиша Shift прижата."; } На заметку! Класс Keyboard содержит также методы, которые позволяют прикреплять обработчики событий клавиатуры уровня всего приложения: AddKeyDownHandler () и AddKeyUpHandler(). Однако применять эти методы не рекомендуется. Лучше реализовать функциональность уровня приложения с помощью системы команд WPF — так, как описано в главе 9. Ввод с использованием мыши События мыши выполняют несколько связанных задач. Самые главные события мыши позволяют реагировать на перемещение указателя мыши над элементами. Это события MouseEnter (возникает, когда указатель мыши появляется над элементом) и MouseLeave (происходит, когда указатель мыши покидает элемент). Оба они являются прямыми событиями (direct events), то есть не используют туннелирования или пузырькового распространения. Это значит, что они генерируются только в том элементе, где и возникает их причина. Такое поведение вполне согласуется со способом вложения элементов управления в окнах WPF. Например, пусть имеется панель Stack Panel, а в ней содержится кнопка, и вы наводите указатель мыши на эту кнопку. При этом событие MouseEnter возникнет сначала в элементе StackPanel (при появлении указателя в пределах панели), а затем в кнопке (когда указатель попадет на нее). А при перемещении указателя мыши в сторону возникнет событие MouseLeave: сначала в кнопке, а затем в StackPanel. Можно также реагировать на два события, которые возникают при перемещении указателя мыши: PreviewMouseMove (туннелируемое событие) и MouseMove (пузырьковое событие). Все эти события обеспечивают код одной и той же информацией: объектом MouseEventArgs. Свойства этого объекта позволяют узнать о состоянии кнопок мыши в момент возникновения события, а метод GetPosition() сообщает координаты
Глава 5. Маршрутизируемые события 155 указателя относительно указанного элемента. Ниже представлен пример, который выводит положение указателя мыши относительно формы в независимых от устройства единицах: private void MouseMoved(object sender, MouseEventArgs e) { Point pt = e.GetPosition(this); lbllnfo.Text = String.Format("You are at ({0}f{1}) in window coordinates", pt.X, pt.Y); В данном случае координаты отсчитываются от левого верхнего утла клиентской области (под строкой заголовка). На рис. 5.7 виден результат выполнения этого кода. Обратите внимание: координаты мыши в этом примере не являются целыми числами. Это объясняется тем, что данный снимок экрана был сделан в системе с разрешением 120 dpi, а не со стандартным 96 dpi. Как было сказано в главе 1, WPF автоматически масштабирует свои единицы, используя больше физические пиксели. Поскольку при этом размер экранного пикселя не совпадает с единицей в системе WPF, физическое положение указателя мыши можно преобразовать в дробное число единиц WPF, что и продемонстрировано здесь. Рис. 5.7. Наблюдение за местоположением мыши Совет. Класс UIElement содержит два свойства, полезных для определения местоположения указателя мыши. Свойство isMouseOver позволяет определить, находится ли указатель мыши над элементом или одним из его потомков, а свойство IsMouseDirectlyOver — выяснить, находится ли указатель мыши над самим элементом, а не над его потомком. Как правило, значения этих свойств используются в коде не для анализа и реагирования, а для создания триггеров стилей, которые автоматически изменяют элементы при перемещении указателя мыши над ними. Эта технология будет рассмотрена в главе 11. Щелчки кнопками мыши Щелчки кнопками мыши подобны нажатиям клавиш на клавиатуре. Однако существуют отдельные события для левой кнопки и правой кнопки. В табл. 5.6 эти события приведены в порядке их возникновения. Таблица 5.6. События щелчков кнопками мыши для всех элементов (в порядке их возникновения) Имя Тип маршрутизации Описание PreviewMouseLeftButtonDown и Туннелирование Возникает при нажатии кнопки мыши PreviewMouseRightButtonDown MouseLeftButtonDown Пузырьковое Возникает при нажатии кнопки мыши распространение PreviewMouseLef tButtonUp и Туннелирование Возникает при отпускании кнопки мыши PreviewMouseRightButtonUp MouseLeftButtonUpи MouseRightButtonUp Пузырьковое Возникает при отпускании кнопки мыши распространение
156 Глава 5. Маршрутизируемые события Помимо перечисленных, есть еще два события, которые реагируют на вращение колесика мыши: PreviewMouseWheel и MouseWheel. Все события кнопок мыши предоставляют объект MouseButtonEventArgs. Класс MouseButtonEventArgs порожден от класса MouseEventArgs (а это означает, что он содержит ту же информацию о координатах и состоянии кнопки) и добавляет несколько новых членов. Менее важными свойствами являются MouseButton (сообщает о том, какая кнопка сгенерировала событие) и ButtonState (сообщает, была ли кнопка в момент возникновения события нажата или отпущена). Более интересным свойством является ClickCount, которое сообщает, сколько раз был произведен щелчок кнопкой, позволяя различать одиночные щелчки (ClickCount имеет значение 1) и двойные щелчки (ClickCount имеет значение 2). Совет. Как правило, Windows-приложения выполняют действия тогда, когда кнопка мыши отпускается после щелчка (событие "Up", а не "Down"). Некоторые элементы добавляют высокоуровневые события мыши. Например, класс Control добавляет события PreviewMouseDoubleClick и MouseDoubleClick, которые заменяют событие MouseLef tButtonUp. Аналогично класс Button возбуждает событие Click, которое могут сгенерировать клавиатура или мышь. На заметку! Как и события нажатия клавиш, события мыши предоставляют информацию о том, в каком месте находился указатель и какие клавиши были прижаты в момент возникновения события. Для получения информации о текущей позиции указателя мыши и состоянии ее кнопок можно использовать статические члены класса Mouse, которые похожи на статические члены класса MouseButtonEventArgs. Захват мыши Обычно вскоре после того, как элемент получает событие нажатия кнопки мыши, он получает и соответствующее событие отпускания этой кнопки. Однако так бывает не всегда. Например, если щелкнуть на элементе и, не отпуская кнопку мыши, переместить указатель за пределы элемента, то элемент не получит событие отпускания кнопки. В некоторых ситуациях бывают нужны уведомления о событиях отпускания кнопки мыши, даже если указатель мыши покинул пределы элемента. Для этого нужно захватить (capture) мышь, вызвав метод Mouse.Capture() и передав ему соответствующий элемент. С этого момента вы будете получать события о нажатии и отпускании кнопок мыши — пока снова не вызовете метод Mouse.Capture() с пустой (null) ссылкой в качестве аргумента. Пока мышь захвачена, остальные элементы не будут получать события мыши. Это означает, что пользователь не сможет щелкать кнопками мыши в других местах окна, внутри текстовых полей и т.д. Захват мыши иногда используется для реализации элементов с возможностью перетаскивания и изменения размеров. В главе 23 будет приведен пример пользовательского окна, допускающего изменение размеров. Совет. При вызове метода Моиse.Capture () ему можно передать необязательный второй параметр. Обычно при вызове метода Mouse.Capture () используется CaptureMode.Element, что означает, что ваш элемент будет всегда получать события мыши. Однако можно передать и CaptureMode.SubTree, чтобы события мыши могли доходить до элемента, на котором был произведен щелчок кнопкой мыши, если этот элемент является потомком элемента, выполняющего захват Это бывает полезно, если уже используется пузырьковое распространение или туннелирование события для наблюдения за событиями мыши в дочерних элементах.
Глава 5. Маршрутизируемые события 157 В некоторых случаях захват мыши теряется не из-за действий приложения. Например, Windows может освободить мышь, если потребуется отобразить системное диалоговое окно. Это может случиться и в ситуации, если не освободить мышь после возникновения события, а пользователь переместит указатель, чтобы щелкнуть в окне другого приложения. В любом случае можно среагировать на потерю захвата мыши, обрабатывая событие LostMouseCapture для данного элемента. Пока мышь захвачена элементом, вы не можете взаимодействовать с другими элементами. (Например, пользователь не сможет щелкнуть на другом элементе окна.) Захват мыши обычно используется в краткосрочных операциях, таких как перетаскивание. На заметку! Вместо метода Mouse.Capture () можно использовать два метода из класса UIElement: CaptureMouseO и ReleaseMouseCapture(). Достаточно просто вызвать эти методы для нужного элемента. Единственным ограничением данного подхода является то, что он не позволяет использовать параметр CaptureMode.SubTree. Перетаскивание Операции перетаскивания (способ извлечения информации из одного места в окне и помещения ее в другое место) сегодня не являются столь распространенными, как несколько лет назад. Программисты постепенно перешли на другие методы копирования информации, которые не требуют удержания нажатой кнопки мыши (многим пользователям это удается с трудом). Программы, которые поддерживают перетаскивание, часто предлагают его как быстрый вариант для опытных пользователей, а не как стандартный способ работы. Операции перетаскивания в WPF не претерпели существенных изменений. Если вы использовали их в приложениях Windows Forms, то увидите, что программный интерфейс в WPF остался практически таким же. Ключевым отличием является то, что методы и события, используемые в операциях перетаскивания, сосредоточены в классе System. Windows.DragDrop и через него доступны другим классам (например, UIElement). По сути, операция перетаскивания выполняется в три этапа: 1. Пользователь щелкает на элементе (или выделяет некоторую область внутри него) и удерживает прижатой кнопку мыши. В этот момент сохраняется некоторая информация и начинается выполнение операции перетаскивания. 2. Пользователь перемещает указатель на другой элемент. Если этот элемент может принимать тип перетаскиваемого содержимого (например, изображение или фрагмент текста), указатель мыши принимает вид значка перетаскивания. Иначе указатель мыши принимает вид Перечеркнутого кружка. 3. Когда пользователь отпускает кнопку мыши, элемент получает информацию и принимает решение о дальнейшей ее судьбе. Эту операцию можно отменить, нажав клавишу <Esc> (когда кнопка мыши еще не отпущена). Вы можете попробовать, как работает перетаскивание, добавив в окно два объекта TextBox, т.к. элемент TextBox имеет встроенную логику для поддержки операции перетаскивания. Если выбрать фрагмент текста внутри текстового поля, то его можно перетащить в другое текстовое поле. Когда вы отпустите кнопку мыши, текст будет перемещен. Те же принципы распространяются и на взаимодействие приложений — например, можно перетащить кусок текста из документа Word в объект WPF TextBox, или наоборот. Иногда бывает необходимо разрешить перетаскивание между элементами, не обладающими такой встроенной возможностью. Например, нужно сделать так, чтобы пользователь мог перетаскивать содержимое из текстового поля на метку. Или создать при-
158 Глава 5. Маршрутизируемые события мер, показанный на рис. 5.8, где пользователь может перетаскивать текст из объекта Label или TextBoxHa другую метку. В такой ситуации придется обрабатывать события перетаскивания. •.gAndDrop И|И=1 ИИШ Drag from this TextBox Or this Label To this Label Рис. 5.8. Перетаскивание содержимого из одного элемента в другой У операции перетаскивания есть две стороны: источник и цель. Чтобы создать источник перетаскивания, нужно в некоторый момент вызвать метод DragDrop. DoDragDropO, чтобы начать операцию перетаскивания. В этот момент идентифицируется источник для операции перетаскивания, выбирается содержимое, которое нужно переместить, и задаются разрешенные при перетаскивании эффекты (копирование, перемещение и т.д.). Обычно метод DoDragDropO вызывается в ответ на событие MouseDown или PreviewMouseDown. Ниже показан пример, который начинает операцию перетаскивания при щелчке на метке. Для операции перетаскивания используется текстовое содержимое метки: private void lblSource_MouseDown(object sender, MouseButtonEventArgs e) { Label 1Ы = (Label)sender; DragDrop. DoDragDrop AЫ, 1Ы.Content, DragDropEffects.Copy); } Свойство AllowDrop элемента, который принимает данные, должно иметь значение true. Кроме того, этот элемент должен обработать событие Drop, чтобы оперировать данными: <Label Grid.Row=,,l" AllowDrop=,,True" Drop^'lblTarget^Drop'^Ciema^Labe^ Когда свойству AllowDrop присваивается значение true, элементу разрешается принимать любой тип информации. Если нужны большие возможности, можно обрабатывать событие DragEnter. В этот момент можно проверить тип перетаскиваемых данных, а затем определить тип разрешаемой операции. Следующий пример разрешает работать только с текстовым содержимым: если попытаться перетащить что-то, что невозможно преобразовать в текст, операция перетаскивания не будет разрешена, а указатель мыши примет вид запрещающего перечеркнутого кружка: private void lblTarget_DragEnter (object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.Text)) e.Effects = DragDropEffects.Copy; else e.Effects = DragDropEffects.None; }
Глава 5. Маршрутизируемые события 159 После завершения операции можно извлечь данные и работать с ними. Следующий код принимает перемещенный текст и вставляет его в метку: private void lblTarget_Drop(object sender, DragEventArgs e) { ( (Label)sender) .Content = e.Data.GetData(DataFormats.Text); } Операция перетаскивания позволяет обмениваться объектами любых типов. Этот вольный подход вполне годится для ваших приложений, но его не рекомендуется применять, если вам нужно сообщаться с другими приложениями. В этом случае следует использовать один из базовых типов данных (например, строки, целые числа и т.п.) или объект, который реализует интерфейс ISerializable или IDataObject (что позволит .NET передавать ваш объект в виде потока байтов и заново создавать объект в домене другого приложения). Интересным приемом является преобразование элемента WPF в XAML с последующей его реконструкцией в другом месте. Все, что для этого нужно — объекты XamlWriter и XamlReader, описанные в главе 2. На заметку! Если нужно передавать данные между приложениями, используйте класс System. Windows.Clipboard, который предлагает статические методы помещения данных в буфер обмена Windows и извлечения их в самых разных форматах. Сенсорный многопозиционный ввод Многопозиционный ввод — способ взаимодействия с приложением с помощью касания экрана. Он отличается от более традиционного перьевого ввода тем, что позволяет пользователю работать сразу несколькими пальцами. В наиболее сложном варианте многопозиционный ввод распознает жесты — особые способы движения нескольких пальцев пользователя как команды для выполнения одной из распространенных операций. Например, если прикоснуться к сенсорному экрану двумя пальцами, а затем свести их, то обычно это означает "уменьшить масштаб", а движение одного пальца вокруг другого означает "повернуть". А поскольку пользователь выполняет эти жесты непосредственно на окне приложения, то каждый жест естественно связывается с конкретным объектом. К примеру, простое приложение с поддержкой такого ввода может вывести на виртуальный рабочий стол несколько изображений и позволить пользователю упорядочивать их по своему желанию: перетаскивать, изменять размер и поворачивать каждое изображение. Такое приложение демонстрирует лишь небольшую часть возможностей, но WPF позволяет создать его практически без усилий. Совет. Список стандартных многопозиционных жестов, которые распознает Windows 7, приведен по адресу http://tinyurl.com/yawwhw2. Многие разработчики считают, что многопозиционный ввод постепенно станет обычным делом при взаимодействии с приложениями, по крайней мере, на таких устройствах, как настольные компьютеры и ноутбуки. Но пока поддержка подобного ввода ограничена небольшим набором сенсорных планшетов, настольных моноблоков и ЖК- мониторов. На момент написания этих строк список оборудования, поддерживающего многопозиционный ввод, находится по адресу http://tinyurl.com/y8pnsbu. Это осложняет жизнь разработчикам, которые хотят опробовать работу с многопозиционными приложениями. Пока лучшее, что можно посоветовать — купить дешевый ноутбук с многопозиционным сенсорным экраном. Однако, если приложить немного усилий, можно использовать эмулятор для моделирования многопозиционного ввода. Для этого необходимо подключить к компьютеру больше одной мыши и устано-
160 Глава 5. Маршрутизируемые события вить драйверы из проекта Multi-Touch Vista (с открытым исходным кодом, работает и с Windows 7). Для начала рекомендуем вам зайти на сайт http://multitouchvista. codeplex.com. Однако предупреждаем: возможно, вам придется пройти проверку с помощью обучающих видеофильмов, чтобы удостовериться, что (довольно запутанная) процедура установки выполнена правильно. На заметку! Некоторые приложения могут поддерживать многопозиционный ввод и в Windows Vista, но встроенная в WPF поддержка требует наличия Windows 7 — независимо от наличия соответствующего оборудования или эмулятора. Уровни поддержки многопозиционного ввода Вы уже видели, что WPF позволяет работать с вводом с клавиатуры и мыши как на высоком уровне (например, щелчки и изменения текста), так и на низком (движения мыши и нажатия клавиш). Это важно, т.к. некоторым приложениям необходимо гораздо более точное управление, чем другим. Все это относится и к многопозиционному вводу: WPF предоставляет три отдельных уровня его поддержки. • Простое касание. Это самый нижний уровень поддержки; он дает доступ ко всем касаниям пользователя. Его недостаток в том, что ваше приложение должно само сопоставлять отдельные сообщения касаний и интерпретировать их. Обработка простых касаний имеет смысл, если вы собираетесь не распознавать стандартные жесты, а реагировать на одновременные касания особым образом. Примером может служить программа рисования наподобие Windows 7 Paint, которая позволяет пользователям "рисовать" на сенсорном экране сразу несколькими пальцами. • Манипуляции. Этот уровень абстракции удобен для интерпретации простых касаний в виде осмысленных жестов — примерно так же, как элементы WPF интерпретируют последовательность событий MouseDown и Mouse Up как событие более высокого уровня MouseDoubleClick. Элементы WPF обычно распознают прокрутку, изменение масштаба, вращение и постукивание. • Поддержка, встроенная в элементы. Некоторые элементы уже реагируют на многопозиционные события, без необходимости написание специального кода. Например, элементы управления с прокруткой — такие как List Box, ListView, DataGrid, TextBox и ScrollViewer — поддерживают пальцевую прокрутку. В последующих разделах будут приведены примеры простых касаний и манипуляций с помощью жестов. Простые касания Как и в случае базовых событий мыши и клавиатуры, события касания встроены в низкоуровневые классы UIElement и ContentElement. Все они перечислены в табл. 5.7. Все эти события предоставляют объект TouchEventArgs, который содержит два важных члена. Во-первых, метод GetTouchPointO дает экранные координаты точки, где возникло событие касания (и еще другие, не так часто применяемые, данные — такие как размер точки контакта). Во-вторых, свойство TouchDevice возвращает объект TouchDevice. Фокус в том, что каждая точка контакта считается отдельным устройством. Поэтому если пользователь касается экрана двумя пальцами в разных местах (одновременно или один за другим), WPF считает, что это два устройства касания, и назначает каждому из них уникальный идентификатор. При перемещении пальцев и возникновения события касания код может различить эти точки касания с помощью свойства TouchDevice.Id.
Глава 5. Маршрутизируемые события 161 Таблица 5.7. События простых касаний для всех элементов Имя Тип маршрутизации Описание PreviewTouchDown TouchDown PreviewTouchMove TouchMove PreviewTouchUp TouchUp TouchEnter TouchLeave Туннелирование Пузырьковое распространение Туннелирование Пузырьковое распространение Туннелирование Пузырьковое распространение Нет Нет Возникает, когда пользователь касается данного элемента Возникает, когда пользователь касается данного элемента Возникает, когда пользователь коснулся данного элемента и передвигает палец Возникает, когда пользователь коснулся данного элемента и передвигает палец Возникает, когда пользователь поднимает палец, прекращая касание Возникает, когда пользователь поднимает палец, прекращая касание Возникает, когда точка контакта входит извне в пределы данного элемента Возникает, когда точка контакта выходит за пределы данного элемента Приведенный ниже пример демонстрирует этот механизм на простом примере программной обработки простых касаний (см. рис. 5.9). Когда пользователь касается холста окна, приложение добавляет на него небольшой овал, чтобы показать точку контакта. При перемещении пальца код сдвигает овал следом. ^_^/^^ Рис. 5.9. Перетаскивание овалов несколькими пальцами Этот пример отличается от аналогичного тестового примера для событий мыши тем, что пользователь может прикоснуться к экрану сразу несколькими пальцами, и при этом на экране возникнут несколько овалов, каждый из которых можно перемещать независимо от других.
162 Глава 5. Маршрутизируемые события Для создания данного пример необходимо обрабатывать события TouchDown, TouchUp и TouchMove: <Canvas x:Name="canvas" Background="LightSk:yBlueM TouchDown="canvas_TouchDown" TouchUp=Mcanvas_TouchUp" TouchMove="canvas_TouchMove"> </Canvas> Чтобы отслеживать все точки контакта, в переменной-члене окна понадобится хранить коллекцию каких-то объектов. Лучше всего хранить коллекцию объектов UIElement (по одному для каждого активного овала), индексируя их с помощью (целочисленных) идентификаторов устройства касания: private Dictionary<int, UIElement> movingEllipses = new Dictionary<int, UIElement> (); Когда пользователь впервые касается экрана, код создает и конфигурирует новый элемент Ellipse (который выглядит как небольшой кружок). Координаты овала берутся из точки касания, он добавляется в коллекцию, индексированную идентификаторами устройства касания, а затем прорисовывается на холсте: private void canvas_TouchDown(object sender, TouchEventArgs e) { // Создание овала для прорисовки в новой точке касания. Ellipse ellipse = new Ellipse (); ellipse.Width = 30; ellipse.Height = 30; ellipse.Stroke = Brushes.White; ellipse.Fill = Brushes.Green; // Позиционирование овала в точке касания. TouchPoint touchPoint = е.GetTouchPoint(canvas); Canvas.SetTop (ellipse, touchPoint.Bounds.Top); Canvas.SetLeft(ellipse, touchPoint.Bounds.Left); // Сохранение овала в активной коллекции. movingEllipses[e.TouchDevice.Id] = ellipse; // Добавление овала на холст, canvas.Children.Add(ellipse); } Когда пользователь после касания экрана передвигает палец, возникает событие TouchMove. В этот момент можно определить, какая точка двигается, с помощью идентификатора устройства касания. После этого коду остается только найти соответствующий овал и изменить его координаты: private void canvas_TouchMove (object sender, TouchEventArgs e) { // Получение овала, соответствующего текущей точке касания. UIElement element = movingEllipses[e.TouchDevice.Id]; // Перемещение овала в новую точку касания. TouchPoint touchPoint = е.GetTouchPoint(canvas); Canvas.SetTop(ellipse, touchPoint.Bounds.Top); Canvas.SetLeft(ellipse, touchPoint.Bounds.Left); } И, наконец, когда пользователь поднимает палец, овал удаляется из коллекции. По желанию его можно удалить и с холста.
Глава 5. Маршрутизируемые события 163 private void canvas_TouchUp(object sender, TouchEventArgs e) { // Удаление овала с холста. UIElement element = movingEllipses[e.TouchDevice.Id]; canvas.Children.Remove(element); // Удаление овала из коллекции отслеживания. movingEllipses.Remove(e.TouchDevice.Id); } На заметку! Класс UIElement добавляет также методы Captu reTouch () и ReleaseTouchCapture (), которые аналогичны методам CaptureMouse () и ReleaseMouseCapture(). После захвата элементом сенсорного ввода данный элемент получает все события касания от этого устройства касания, даже если события касания возникают в другой части окна. Но поскольку может существовать несколько устройств касания, сразу несколько различных элементов могут захватить сенсорный ввод, если каждый из них захватывает ввод от отличного от других устройства. Манипуляции Простые касания замечательно подходят для приложений, которые используют события касания самым непосредственным образом, как в примере с перетаскиванием овалов или в программе рисования. Но если вам понадобится поддерживать стандартные сенсорные жесты, простые касания не облегчат эту задачу. Например, для поддержки поворотов необходимо распознать две точки касания на одном элементе, отследить их движения и как-то определить, что одна из них вращается вокруг другой. А кроме этого, еще нужен код, который непосредственно отрабатывает соответствующий эффект вращения. К счастью, WPF не оставляет вас один на один с этой задачей. В ней имеется высокоуровневая поддержка для жестов, которая называется манипуляцией (manipulation) касаниями. Достаточно настроить элемент, чтобы он поддерживал манипуляции, установив его свойство IsManipulationEnabled равным true. После этого элемент будет реагировать на четыре события манипуляции: ManipulationStarting, ManipulationStarted, ManipulationDelta и ManipulationCompleted. На рис. 5.10 приведен пример манипуляции: три изображения первоначально упорядочены на холсте, а затем пользователь с помощью жестов прокрутки, поворота и изменения масштаба может перемещать, вращать, сжимать или увеличивать их. Для создания этого примера вначале нужно определить холст (Canvas) и поместить на него три элемента изображения (Image). Для упрощения события ManipulationStarting и ManipulationDelta будут обрабатываться в Canvas, после того как они всплывут из соответствующего элемента изображения. <Canvas x:Name="canvas" ManipulationStarting="image_ManipulationStarting" ManipulationDelta="image_ManipulationDeltaM> <Image Canvas.Top=0" Canvas.Left=0" Width=00" IsManipulationEnabled="True" Source="koala.jpg"> <Image.RenderTransform> <MatrixTransf ormx/MatrixTransf orm> </Image.RenderTransform> </Image> <Image Canvas.Top=0" Canvas.Left=50" Width=00" IsManipulationEnabled="True" Source="penguins.Dpg"> <Image.RenderTransform> <MatrixTransformx/MatrixTransform> </Image.RenderTransform> </Image>
164 Глава 5. Маршрутизируемые события <Image Canvas.Top=00" Canvas.Left=00" Width=00" IsManipulationEnabled="True" Source="tulips.jpg"> <Image.RenderTransform> <MatrixTransformx/MatrixTransform> </Image.RenderTransform> </Image> </Canvas> (■• м*пфииъог« Ьв^зУнШТ! Рис. 5.10. До и после: манипуляции с тремя изображениями с помощью многопозиционных жестов Обратите внимание на один момент в этой разметке. Каждый элемент изображения содержит объект MatrixTransform, который облегчает применение сочетаний манипуляций: перемещения, вращения и масштабирования. В данный момент объекты MatrixTransform не делают ничего, но они будут изменены в коде, когда возникнут события манипуляции. (Подробное описание работы преобразований будет приведено в главе 12.) Когда пользователь прикоснется к одному из изображений, возникнет событие ManipulationStarting. В это время нужно обеспечить контейнер манипуляции, который будет использоваться как точка отсчета для всех получаемых позднее координат манипуляций. В данном случае естественно выбрать в качестве контейнера холст, который содержит изображения. Кроме того, можно указать типы манипуляций, которые разрешены с изображениями. Если этого не сделать, WPF будет отслеживать все распознаваемые жесты: прокрутка, масштабирование и поворот.
Глава 5. Маршрутизируемые события 165 private void image_ManipulationStarting(object sender, ManipulationStartingEventArgs e) { // Указание контейнера (для отсчета координат). е.ManipulationContainer = canvas; // Указание разрешенных манипуляций, е.Mode = ManipulationModes.All; } Событие Manipulation Delta возникает тогда, когда выполняется (но не обязательно закончена) манипуляция. Например, если пользователь начал поворачивать изображение, событие Manipulation Delta будет постоянно возникать, пока поворот не будет завершен, т.е. пользователь поднимет пальцы. Текущее состояние жеста отображается в объекте ManipulationDelta, который доступен через свойство ManipulationDeltaEventArgs.DeltaManipulation. В этом объекте записываются количества масштабирования, вращения и прокрутки, которое нужно применить к объекту; эти количества доступны соответственно через свойства Scale, Rotation и Translation. Остается использовать приведенную информацию для настройки элемента в пользовательском интерфейсе. Теоретически данные о масштабе и вращении можно отрабатывать, изменяя размер и положение элемента. Но поворот таким образом не выполнить, да и код получается запутанным. Гораздо лучше использовать преобразования (transform) — объекты, которые позволяют математически изменить внешний вид любого элемента WPF. Для этого нужно взять информацию, которая содержится в объекте ManipulationDelta, и использовать ее для конфигурирования объекта MatrixTransform. Звучит сложновато, но необходимый для этого код по сути один и тот же для любого приложения, которое использует данную возможность. Выглядит он так: private void image_ManipulationDelta(object sender, ManipulationDeltaEventArgs e) { // Получение обрабатываемого объекта. UIElement element = (UIElement)e.Source; // Манипуляция с внешним видом элемента с помощью матрицы преобразования. Matrix matrix = ((MatrixTransform)element.RenderTransform).Matrix; // Получение объекта ManipulationDelta. ManipulationDelta deltaManipulation = e.DeltaManipulation; // Определение старого центра и выполнение всех предыдущих манипуляций. Point center = new Point(element.ActualWidth / 2, element.ActualHeight / 2); center = matrix.Transform(center); // Выполнение новой манипуляции масштабирования (если нужно). matrix.ScaleAt(deltaManipulation.Scale.X, deltaManipulation.Scale.Y, center.X, center.Y); // Выполнение новой манипуляции поворота (если нужно). matrix.RotateAt(е.DeltaManipulation.Rotation, center.X, center.Y); // Выполнение новой манипуляции прокрутки (если нужно). matrix.Translate(e.DeltaManipulation.Translation.X, е.DeltaManipulation.Translation.Y) ; // Формирование окончательной матрицы. ((MatrixTransform)element.RenderTransform).Matrix = matrix; Этот код позволяет выполнять манипуляции со всеми объектами, как на рис. 5.10.
166 Глава 5. Маршрутизируемые события Инерция В WPF имеется еще один уровень средств, основанный на поддержке базовых манипуляций — инерция (inertia). По сути, инерция позволяет выполнять более реалистичные, плавные манипуляции с элементами. Пока что в примере, показанном на рис. 5.10, изображение перестает двигаться сразу, как только пользователь прекращает движение пальцев в жесте прокрутки или отрывает их от экрана. Но при активной инерции движение будет еще немного продолжаться, плавно затухая. Это придает манипуляции вид и ощущение инерционности движения. Кроме того, инерция заставляет элементы отскакивать, когда выполняется попытка их перетаскивания через непреодолимую для них границу — т.е. они ведут себя как реальные физические объекты. Чтобы добавить в предыдущий пример инерцию, необходимо просто обрабатывать событие ManipulationlnertiaStarting. Как и любое другое событие манипуляции, оно начинается в одном из изображений и всплывает до объекта Canvas. Событие ManipulationlnertiaStarting возникает, когда пользователь заканчивает жест и "отпускает" элемент, т.е. поднимает пальцы. В этот момент с помощью объекта Manipula tionlnertiaStartingEventArgs можно определить текущую скорость — скорость, с которой элемент перемещался перед самым окончанием манипуляции — и задать необходимую скорость замедления. Вот пример, добавляющий инерцию в жесты прокрутки, масштабирования и поворота: private void image_ManipulationInertiaStarting (object sender, ManipulationlnertiaStartingEventArgs e) { // Если объект перемещается, гасим его скорость на // 10 дюймов в секунду каждую секунду. // Замедление = 10 дюймов * 96 единиц на дюйм / A000 миллисекунд) А2 е.TranslationBehavior = new InertiaTranslationBehavior(); е.TranslationBehavior.InitialVelocity = e.InitialVelocities.LinearVelocity; e.TranslationBehavior.DesiredDeceleration = 10.0 * 96.0 / A000.0 * 1000.0); // Уменьшение скорости масштабирования на 0.1 дюйма в секунду каждую секунду. // Замедление = 0.1 дюйма * 96 единиц на дюйм / A000 миллисекунд) А2 e.ExpansionBehavior = new InertiaExpansionBehavior(); е.ExpansionBehavior.InitialVelocity = e . InitialVelocities.ExpansionVelocity; e.ExpansionBehavior. DesiredDeceleration = 0.1 * 96 / 1000.0 * 1000.0; // Уменьшение скорости вращение на 2 поворота в секунду каждую секунду. // Замедление = 2 * 360 градуса / A000 миллисекунд)А2 е .RotationBehavior = new InertiaRotationBehavior (); е .RotationBehavior.InitialVelocity = e.InitialVelocities.AngularVelocity; e.RotationBehavior. DesiredDeceleration = 720 / A000.0 * 1000.0); } Чтобы элементы естественно отскакивали от преград, нужно проверять в событии ManipulationDelta, не попали ли они в недопустимое место. При обнаружении пересечения границы вы должны сообщить об этом, вызвав метод ManipulationDeltaEventArgs.ReportBoundaryFeedback(). Здесь возникает естественный вопрос: зачем писать такой объемный код манипуляции, если он представляет собой шаблонный фрагмент, необходимый всем, кто пишет код работы с многопозиционными сенсорными устройствами? Одно очевидное преимущество состоит в том, что он позволяет легко настроить некоторые детали (такие как величину замедления в настройке инерции). Однако во многих случаях вы получите именно то, что вам нужно, с помощью встроенной поддержки манипуляции — тогда обратитесь к проекту WPF Multltouch по адресу http://multitouch.codeplex.com. В нем
Глава 5. Маршрутизируемые события 167 имеются два удобных способа, позволяющих добавить поддержку манипуляции в контейнер, без необходимости ее написания: либо используя поведение, применяющее ее автоматически (см. главу 11), либо с помощью пользовательского элемента управления с жестко заданной логикой (см. главу 18). Но лучше всего загрузить этот код (бесплатно) с сайта и настроить его по своему желанию. Резюме В этой главе мы детально рассмотрели маршрутизируемые события. Сначала вы познакомились с маршрутизируемыми событиями и узнали, как они позволяют работать с событиями на разных уровнях — либо непосредственно в источнике, либо в содержащем его элементе. Далее вы узнали, как эти стратегии маршрутизации реализованы в элементах WPF, позволяя обрабатывать ввод данных с клавиатуры, мыши и сенсорного экрана. Возможно, вам уже не терпится приступить к написанию обработчиков событий, которые будут реагировать на обычные события вроде перемещения мыши, чтобы реализовать простые графические эффекты или как-то еще усовершенствовать пользовательский интерфейс. Но не торопитесь. Как будет показано в главе 11, вы можете автоматизировать многие простые программные операции с помощью декларативной разметки, используя стили и триггеры WPF. Однако сначала в следующей главе мы покажем вам, сколько наиболее фундаментальных элементов управления (например, кнопки, метки и текстовые окна) работают в WPF.
ГЛАВА 6 Элементы управления Вы уже освоили основные темы — компоновка, содержимое и обработка событий — и готовы к более подробному изучению семейства элементов WPF. В этой главе мы рассмотрим элементы управления (control), которые являются потомками класса System.Windows.Control. Сначала мы разберемся с базовым классом Control и узнаем, как он поддерживает кисти и шрифты. Затем мы изучим полный каталог элементов управления WPF. • Элементы управления содержимым. Эти элементы jyioryr содержать вложенные элементы, что дает им практически неограниченные визуальные возможности. К ним относятся классы Label, Button и ToolTip. • Элементы управления содержимым с заголовками. Эти элементы позволяют добавлять главный раздел содержимого и отдельную заглавную часть. Обычно они используются для упаковки больших блоков пользовательского интерфейса. К ним относятся классы Tabltem, GroupBox и Expander. • Элементы управления текстом. Этот небольшой набор элементов позволяет пользователям вводить текст. Текстовые элементы поддерживают ввод обычного текста (TextBox), паролей (PasswordBox) и форматированного текста (RichTextBox, который рассматривается в главе 28). • Элементы управления списком. Эти элементы отображают коллекцию элементов в виде списка. К ним относятся классы ListBox и ComboBox. • Элементы выбора из диапазона. У всех этих элементов есть общая черта: свойство Value, которое может принимать любое значение из заранее указанного диапазона. Примерами могут служить классы Slider и ProgressBar. • Элементы управления датами. В эту категорию входят два класса, позволяющие выбрать дату: Calendar и DatePicker. Есть еще несколько типов элементов управления, которые не упоминаются в этой главе —■ элементы для создания меню, инструментальных панелей и лент; элементы для вывода визуальных таблиц и древовидного отображения данных; и элементы для просмотра и редактирования форматированных документов. Эти более сложные элементы будут рассмотрены в других местах книги, по мере изучения соответствующих возможностей WPF. Что нового? WPF постоянно выполняет небольшие усовершенствования в классах управления, но в WPF 4 добавлено лишь несколько существенных изменений в элементы управления, описанные в данной главе. Наиболее важным из них является возможность WPF 4 выводить более четкий текст маленького размера (см. раздел "Режим форматирования текста"). В WPF 4 также появилась возможность использовать в элементе TextBox пользовательский словарь проверки правописания и добавлены два совершенно новых элемента для работы с данными: Calendar и DatePicker.
Глава 6. Элементы управления 169 Класс Control В окнах WPF полно разных элементов, но лишь некоторые из них являются элементами управления. В мире WPF элемент управления обычно описывается как элемент для интерактивной связи с пользователем — те. он может принимать фокус и получать входные данные от клавиатуры или мыши. Очевидными примерами таких элементов являются текстовые поля и кнопки. Однако различие не всегда бывает четким. Всплывающая подсказка считается элементом управления, т.к. она появляется и пропадает в зависимости от движений мыши. Метка считается элементом управления из-за ее поддержки мнемоники (нажатия клавиш, которые передают фокус соответствующим элементам). Все элементы управления происходят от класса System.Windows.Control, который наделяет их базовыми характеристиками: • они позволяют определять расположение содержимого внутри элемента управления; • они позволяют определять порядок передачи фокуса при использовании клавиши табуляции; • они позволяют отображать фон, передний план и рамку; • они позволяют форматировать размер и шрифт текстового содержимого. Кисти фона и переднего плана Все элементы управления имеют фон (background) и передний план (foreground). Как правило, фоном является поверхность элемента управления (например, белая или серая область внутри кнопки), а передним планом — текст. В WPF цвет (но не содержимое) этих двух областей определяется с помощью свойств Background и Foreground соответственно. Естественно ожидать, что свойства Background и Foreground должны использовать объекты цвета, как в приложениях на основе Windows Forms. Однако на самом деле эти свойства используют более универсальный объект — Brush (кисть). Он позволяет заливать содержимое фона и переднего плана сплошным цветом (с помощью кисти SolidColorBrush) или чем-то экзотическим (например, используя кисти LinearGradientBrush или TileBrush). В этой главе мы рассмотрим только простую кисть SolidColorBrush, а в главе 12 познакомимся с более сложными вариантами. Указание цветов в коде Предположим, что вы хотите задать внутри кнопки cmd поверхность голубого цвета. Вот код, позволяющий это сделать: cmd.Background = new SolidColorBrush(Colors.AliceBlue); Этот код создает новый объект SolidColorBrush с цветом, указанным с помощью статического свойства класса Colors. (Имена основаны на названиях цветов, которые поддерживаются большинством веб-браузеров.) Затем эта кисть определяется в качестве фоновой кисти кнопки, в результате чего фон кнопки становится светло-голубым. На заметку! Такой метод оформления кнопки не очень удобен. Он задает фоновый цвет кнопки в ее обычном состоянии (не нажата), но не изменяет цвет, который появляется при щелчке на кнопке (обычно более темный серый цвет). Чтобы настраивать все нюансы отображения кнопок, следует ознакомиться с шаблонами, которые рассматриваются в главе 17.
170 Глава 6. Элементы управления Можно также пользоваться системными цветами (они могут выбираться исходя из предпочтений пользователя) из перечисления System.Windows.SystemColors. Например: cmd.Background = new SolidColorBrush(SystemColors.ControlColor) ; Поскольку системные кисти используются часто, класс SystemColors предлагает также готовые свойства, возвращающие объекты SolidColorBrush. Ниже показано, как их применять. cmd.Background = SystemColors.ControlBrush; В обоих этих примерах имеется небольшая проблема. Если системный цвет будет изменен после выполнения этого кода, кнопка не будет обновлена, и новый цвет не будет применен. По сути, этот код делает мгновенный снимок текущего цвета или кисти. Чтобы программа могла изменять себя в ответ на изменения в конфигурации, необходимо применять динамические ресурсы, о которых пойдет речь в главе 10. Классы Colors и SystemColors предлагают удобные сокращения, однако это не единственный способ задать цвет. Вы можете, например, создать объект Color, указав значения R, G и В (красной, зеленой и синей составляющих). Каждое из этих значений является числом из диапазона 0-255: int red = 0; int green = 255, int blue = 0; cmd.Foreground = new SolidColorBrush(Color.FromRgb(red, green, blue)); Можно также сделать цвет частично прозрачным, используя значение альфа-канала и вызвав метод Color.FromArgb(). Значение альфа-канала, равное 255, соответствует полной непрозрачности, а значение 0 — полной прозрачности. RGB и scRGB Стандарт RGB полезен, поскольку применяется во многих других программах. Например, можно получить RGB-значение цвета в программе для рисования и использовать этот же цвет в WPF- приложении. Однако не исключено, что другие устройства (например, принтеры) могут поддерживать более широкий диапазон цветов. По этой причине был создан альтернативный стандарт scRGB, в котором каждый компонент цвета (альфа-канал, красный, зеленый и синий) представлен 64-битовыми значениями. Структура класса Color в WPF поддерживает оба подхода. Она включает как набор стандартных свойств RGB (A, R, G и В), так и набор свойств scRGB (ScA, ScR, ScG и ScB). Эти свойства связаны между собой, поэтому если задать свойство R, то соответственно изменится и свойство ScR. Взаимосвязь между значениями RGB и значениями scRGB не является линейной. Значение 0 в системе RGB соответствует значению 0 в scRGB, 255 в RGB соответствует 1 в scRGB, а все значения в диапазоне 0-255 в RGB представлены в scRGB как десятичные значения из диапазона от 0 до 1. Задание цветов в XAML При задании цвета фона или переднего плана в XAML можно воспользоваться удобным сокращением. Вместо определения объекта Brush можно указать наименование или значение цвета. Компилятор WPF автоматически создаст объект SolidColorBrush с выбранным цветом и будет применять этот объект для фона или переднего плана. Вот пример с использованием имени цвета: <Button Background="Red">A Button</Button> Он эквивалентен следующему многострочному фрагменту:
Глава 6. Элементы управления 171 <Button>A Button <Button.Background> <SolidColorBrush Color="Red" /> </Button.Background> </Button> Если нужен другой тип кисти (например, LinearGradientBrush), для рисования фона потребуется использовать более длинную форму. Если необходим код цвета, придется пользоваться менее удобным синтаксисом, в котором значения R, G и В представляются в шестнадцатеричном формате. Доступны два формата: #rrggbb или #aarrggbb (они отличаются тем, что второй формат содержит значение альфа-канала). Для задания значений A, R, G и В, нужно только по две цифры, поскольку все они представляются в шестнадцатеричной форме. Ниже показан пример, который создает тот же цвет, что и в предыдущем фрагменте кода, с помощью формата #aarrggbb: <Button Background=,,#FFFF0000">A Button</Button> Здесь значением альфа-канала является FF B55), значением красной составляющей — FF B55), а зеленая и синяя составляющие равны 0. На заметку! Кисти поддерживают автоматическое уведомление об изменениях. То есть если прикрепить кисть к элементу управления и изменить ее, элемент управления обновляет себя соответствующим образом. Это возможно потому, что кисти являются потомками класса System. Windows.Freezable. Название этого класса (freezable — замораживаемый) объясняется тем, что все такие объекты имеют два состояния: изменяемое состояние и состояние только для чтения ("замороженное"). Background и Foreground — не единственные свойства, которые можно определить с помощью кисти. Свойства BorderBrush и BorderThickness позволяют нарисовать рамку вокруг элементов управления (и некоторых других элементов вроде Border). Свойство BorderBrush принимает указанную кисть, а свойство BorderThickness — ширину рамки в не зависящих от устройства единицах. Чтобы рамка стала видимой, необходимо установить оба свойства. На заметку! Некоторые элементы управления не поддерживают использование свойств BorderBrush и BorderThickness. Объект Button игнорирует их полностью, поскольку определяет свой фон и рамку с помощью декоратора ButtonChrome. Однако кнопке можно придать новый облик (с указанной вами рамкой) с помощью шаблонов, о которых речь пойдет в главе 17. Шрифты Класс Control определяет небольшой набор свойств, связанных со шрифтами, которые определяют отображение текста в элементе управления. Эти свойства перечислены в табл. 6.1. На заметку! Класс Control не определяет свойства, использующие его шрифт. Многие элементы управления содержат свойство Text, но оно не определено как часть базового класса Control. Очевидно, что свойства шрифтов ничего не означают, если они не используются производным классом.
172 Глава 6. Элементы управления Таблица 6.1. Свойства шрифтов класса Control Имя Описание FontFamily Имя шрифта FontSize Размер шрифта в не зависящих от устройства единицах (по 1/96 дюйма). Это небольшое отклонение от традиции предназначено для поддержки новой модели прорисовки в WPF, которая не зависит от разрешения Обычные Windows- приложения измеряют шрифты с помощью пунктов (point), которые равны 1/72 дюйма на стандартном мониторе для ПК. Если вам нужно преобразовать размер шрифта WPF в более знакомый размер в пунктах, просто умножьте его на 3/4. Например, традиционные 38 пунктов эквивалентны 48 единицам в WPF FontStyle Наклон текста, представленный объектом FontStyle. Возможные значения свойства FontStyle содержатся в статических свойствах класса FontStyles, который включает написание символов Normal, Italic или Oblique. (Oblique — это искусственный способ создания курсивного текста на компьютере, в котором нет необходимого курсивного шрифта. Буквы берутся из обычного шрифта и скашиваются с помощью специального преобразования. Как правило, результат получается неважным.) FontWeight Плотность текста, представленная объектом FontWeight. Вначале свойство FontWeight устанавливается из статических свойств класса FontWeight. Наиболее популярен вариант Bold (жирный), хотя в некоторых гарнитурах имеются и другие, такие как Heavy, Light, ExtraBold и т.д Font St retch Коэффициент растяжения или сжатия текста, представленный объектом FontStretch. Возможные значения свойства FontStretch содержатся в статических свойствах класса FontStretches. Например, UltraCondensed сжимает текст до 50% от обычной ширины, a UltraExpanded растягивает его до 200%. Растяжение шрифта является особенностью ОрепТуре, которая не поддерживается многими гарнитурами. (Чтобы поэкспериментировать с этим свойством, попробуйте шрифт Rockwell, который поддерживает его.) Семейство шрифтов Семейство шрифтов (font family) представляет собой коллекцию родственных гарнитур. Например, Arial Regular, Arial Bold, Arial Italic и Arial Bold Italic входят в семейство шрифтов Arial. Типографские правила и символы для каждой вариации определяются отдельно, однако операционная система считает, что все они связаны между собой. Поэтому можно указать для какого-либо элемента шрифт Arial Regular, присвоить свойству FontWeight значение Bold и быть уверенными в том, что WPF переключится на гарнитуру Arial Bold. При выборе шрифта необходимо указывать полное имя семейства, как показано ниже: <Button Name="cmd11 FontFamily="Times New Roman" FontSize=8">A Button</Button> Почти то же делается и в коде: cmd.FontFamily = "Times New Roman"; cmd.FontSize = 8"; При указании FontFamily укороченные строки не годятся. То есть вместо полного имени Times New Roman нельзя указывать Times или Times New. Чтобы получить курсивный или жирный шрифт, можно (необязательно) указать полное имя гарнитуры: <Button FontFamily="Times New Roman Bold">A Button</Button>
Глава 6. Элементы управления 173 Но все-таки проще и удобнее получить требуемый вариант, просто указав имя семейства и другие свойства (такие как Font Style и FontWeight). Например, следующая разметка задает семейство шрифтов Times New Roman с плотностью FontWeights.Bold: <Button FontFamily="Times New Roman" FontWeight="Bold">A Button</Button> Выделение и оформление текста Некоторые элементы поддерживают и более сложные манипуляции с текстом с помощью свойств TextDecorations и Typography. Они позволяют украсить текст. Например, можно задать свойство TextDecorations с помощью статического свойства из класса TextDecorations. Оно предоставляет только четыре варианта выделения, каждый из которых позволяет добавить к тексту линию: Baseline (базовая линия), OverLine (надчеркивание), Strikethrough (зачеркивание) и Underline (подчеркивание). Свойство Typography является более сложным: оно предоставляет доступ к специальным вариантам гарнитур, которые имеются лишь у некоторых шрифтов. Это могут быть, например, различные выравнивания цифр, лигатуры (слитное написание букв) и капители. Возможности TextDecorations и Typography применяются в основном в содержимом потоковых документов, где служат для создания форматированных документов. (О документах речь пойдет в главе 28.) Однако такие навороты имеются и в классе TextBox. Кроме того, они поддерживаются элементом TextBlock — облегченной версией Label, которая прекрасно подходит для вывода небольших объемов многострочного текстового содержимого. Вряд ли вы будете использовать TextDecorations в элементе управления TextBox или изменять его свойство Typography, но вам вполне может понадобиться подчеркивание в TextBlock: <TextBlock TextDecorations="Underline">Underlined text</TextBlock> Если вы собираетесь поместить большой объем текстового содержимого в окно и хотите отформатировать отдельные его части (например, подчеркнуть важные слова), обратитесь к главе 28, в которой вы узнаете о многих потоковых элементах. Хотя потоковые элементы предназначены для использования в документах, их можно вставлять и в TextBlock. Наследование шрифтов Когда задается одно из свойств шрифта, его значение распространяется и на вложенные объекты. Например, если задать свойство Font Family для окна верхнего уровня, то каждый элемент управления в данном окне получит это же значение Font Family (если только в нем явным образом не определен другой шрифт). Это поведение похоже на концепцию свойств окружения (ambient properties) из Windows Forms, хотя основано на другом механизме. Оно работает потому, что свойства шрифтов являются свойствами зависимости, а одной из характеристик свойств зависимости является наследование значений свойств — которое и передает параметры шрифта всем вложенным элементам управления. Заметьте, что наследование значения свойств может проходить через элементы, которые даже не поддерживают это свойство. Например, предположим, что вы создаете окно, в котором имеется панель StackPanel, а внутри нее — три элемента Label. Вы можете задать свойство Font Size окна, поскольку класс Window порожден от класса Control. Вы не можете задать свойство Font Size для панели, поскольку она не является элементом управления. Но если для окна задать свойство Font Size, то значение этого свойства пройдет сквозь StackPanel до меток и изменит размер их шрифтов.
174 Глава 6. Элементы управления Кроме параметров шрифтов, наследование значений свойств используется и в некоторых других базовых свойствах. Так, наследование применяется свойством Foreground в классе Control. А вот свойство Background не использует наследование. (Хотя фон по умолчанию указывается пустой ссылкой, которая отображается большинством элементов управления в виде прозрачного фона. Это означает, что через него будет виден родительский фон.) В классе UIElement наследование поддерживается свойствами AllowDrop, IsEnabled и IsVisible. В классе FrameworkElement наследование поддерживается свойствами Culturelnfo и FlowDirection. На заметку! Свойство зависимости поддерживает наследование только если флаг FrameworkPropertyMetadata.Inherits имеет (не стандартное) значение true. Класс FrameworkPropertyMetadata и регистрация свойств подробно рассмотрены в главе 4. Подстановка шрифтов При указании шрифтов следует аккуратно подходить к выбору шрифта, который должен присутствовать на пользовательском компьютере. WPF может несколько помочь в этом вопросе благодаря системе альтернативных шрифтов. Она позволяет указать в свойстве Font Family список шрифтов, разделяя их запятыми. После этого WPF попытается выбрать шрифт из заданного списка. Приведенный ниже пример пытается использовать шрифт Technical Italic, а в случае его отсутствия будут выбран запасной вариант — Comic Sans MS или Arial: <Button FontFamily="Technical Italic, Comic Sans MS, Arial">A Button</Button> Если вдруг семейство шрифтов содержит запятую в своем имени, ее необходимо записать дважды. Между прочим, имеется возможность получить список всех шрифтов, установленных на текущем компьютере — с помощью статической коллекции SystemFontFamilies из класса System.Windows.Media.Fonts. Ниже показан пример, в котором эта коллекция используется для добавления шрифтов в окно списка: foreach (FontFamily fontFamily in Fonts.SystemFontFamilies) { 1stFonts.Items.Add(fontFamily.Source); } Объект FontFamily позволяет узнать и другие детали, такие как междустрочный интервал и родственные гарнитуры. На заметку! Одним из ингредиентов, отсутствующим в WPF, является диалоговое окно для выбора шрифта. Группа разработчиков WPF Text предложила два более привлекательных средства для выбора шрифта: версию без кода, использующую привязку данных (http://blogs. msdn.com/text/archive/2006/06/20/592777.aspx) и более сложную версию, которая поддерживает необязательные оформительские возможности, встречающиеся в некоторых шрифтах ОрепТуре (http://blogs.msdn.com/text/archive/2006/ll/01/sample- font-chooser.aspx). Встраивание шрифтов Другой возможностью при работе с необычными шрифтами является их встраивание в приложение. Тогда у приложения никогда не будет иметь проблем с нахождением требуемого шрифта.
Глава 6. Элементы управления 175 Процесс встраивания очень прост. Сначала нужно добавить в приложение файл шрифта (как правило, с расширением .ttf) и присвоить параметру Build Action значение Resource. (Это можно сделать в Visual Studio, выбрав файл шрифта в Solution Explorer и изменив значение Build Action в окне Properties (Свойства).) Затем, при использовании шрифта, нужно добавить перед именем семейства символьную последовательность ./#: <Label FontFamily=" . /#Bayern" FontSize=,,20">This is an embedded font</Label> WPF интерпретирует символы . / как указание на текущую папку. Чтобы понять, что это означает, необходимо разобраться с системой упаковки XAML. Как было сказано в главе 2, можно запускать автономные (так называемые несвязанные) XAML-файлы прямо в браузере, не компилируя их. Единственное условие состоит в том, что XAML-файл не может использовать файл отделенного кода. Текущие папки допустимы, так что WPF ищет файлы шрифтов, которые находятся в одном каталоге с XAML-файл ом, и делает их доступными для приложения. Однако чаще WPF-приложение компилируется в сборку .NET, а уже затем выполняется. В этом случае текущая папка все так же является местом хранения XAML-документа, только теперь документ компилируется и встраивается в сборку. WPF ссылается на откомпилированные ресурсы с помощью специального синтаксиса URI, который будет рассмотрен в главе 7. Все URI-адреса приложения начинаются с последовательности pack://application. Если создать проект под именем ClassicControls и добавить окно EmbeddedFont.xaml, то окно будет иметь следующий URI: pack://application:,,,/ClassicControls/embeddedfont.xaml Этот URI доступен в нескольких местах, в том числе и через свойство Font Family. BaseUri. WPF использует этот URI как базовое местоположение при поиске шрифтов. Таким образом, при использовании синтаксиса ./ в скомпилированном WPF- приложении будет выполняться поиск шрифтов, которые встроены в виде ресурсов вместе с откомпилированным XAML. После символов . / можно просто указать имя файла, однако обычно добавляется знак номера (#) и имя семейства шрифтов. В приведенном выше примере встроенный шрифт называется Вауегп. На заметку! Во встраивании шрифтов есть свои тонкости. Необходимо указать точное имя семейства шрифтов, а также выбрать корректное действие сборки для файла шрифта. Кроме того, Visual Studio в настоящее время не обеспечивает поддержку встроенных шрифтов на этапе разработки (это означает, что текст в элементе управления не будет отображаться выбранным шрифтом до запуска приложения). Пример правильного встраивания можно увидеть в данной главе. Встраивание шрифтов затрагивает и вопросы, связанные с лицензированием. К сожалению, большинство поставщиков шрифтов разрешают встраивать свои шрифты в документы (например, в файлы формата PDF), но не в приложения (например, сборки WPF), даже если встроенный шрифт WPF не является непосредственно доступным для конечного пользователя. WPF не пытается как-то лицензировать шрифты, и все же перед тем как распространять шрифт, убедитесь в том, что вы не нарушаете условия лицензии. О разрешениях на встраивание шрифтов можно узнать с помощью бесплатной утилиты просмотра свойств шрифтов Microsoft, которая доступна по адресу http://www. microsoft.com/typography/TrueTypeProperty21.mspx. После установки этой утилиты щелкните правой кнопкой мыши на любом файле шрифта и выберите в контекстном меню пункт Properties (Свойства), чтобы посмотреть более детальную информацию о нем. В частности, посмотрите на вкладке Embedding (Встраивание), разрешено ли встраи-
176 Глава 6. Элементы управления вание этого шрифта. Шрифты, помеченные как Installed Embedding Allowed (Разрешено инсталлированное встраивание), пригодны для использования в WPF-приложениях, а шрифты, помеченные как Editable Embedding Allowed (Разрешено редактируемое встраивание) могут оказаться не пригодными для этого. Информацию о лицензионном использовании какого-либо шрифта можно узнать у поставщика этого шрифта. Режим форматирования текста Прорисовка текста в WPF немного отличается от прорисовки в старых приложениях на основе GDI. В основном это отличие обусловлено применением в WPF независимой от устройства системы отображения, но появились также и значительные усовершенствования, которые позволяют тексту выглядеть яснее и четче, особенно на ЖК-мониторах. Однако прорисовка текста в WPF имеет и один известный недостаток. При мелких размерах шрифтов текст становится размазанным и содержит нежелательные артефакты (вроде цветного ореола вокруг краев символов). Такие проблемы не возникают при выводе текста с помощью GDI, т.к. в этом интерфейсе используется несколько приемов, предназначенных для повышения четкости мелкого текста. Например, GDI может изменять формы мелких букв, сдвигать их позиции и выравнивать по границам пикселей. Эти меры приводят к утрате гарнитурами некоторых особенностей, но они повышают удобство чтения с экрана в случае отображения мелкого текста. А как же решить задачу отображения мелкого текста в WPF? Лучше просто увеличить его размер (на мониторе с 96 dpi этот дефект пропадает при размере текста примерно в 15 независимых от устройства единиц) или использовать монитор с большим значением dpi, разрешения которого хватит на отображение четкого текста требуемого размера. Но поскольку эти варианты часто неприменимы на практике, в WPF 4 введена новая возможность: выборочное применение прорисовки текста в стиле GDI. Для отображения текста в стиле GDI нужно добавить к элементу, отображающему этот текст (например, TextBlock или Label), прикрепленное свойство TextOptions. TextFormattingMode и изменить его значение на Display (со стандартного Ideal). Например: <TextBlock FontSize=,,12" Margin=,,5"> This is a Test. Ideal text is blurry at small sizes. </TextBlock> <TextBlock FontSize=2" Margin=" TextOptions.TextFormattingMode="Display"> This is a Test. Display text is crisp at small sizes. </TextBlock> Учтите, что свойство TextFormattingMode годится в качестве решения только для небольшого шрифта. Если применять его для больших размеров (более 15 пунктов), то текст будет не таким четким, промежутки между символами — не такими ровными, а сами символы будут выглядеть не очень аккуратными. А если использовать еще и преобразование (см. главу 12), которое поворачивает, масштабирует размер или еще как- то изменяет вид текста, то всегда следует применять стандартный режим отображения текста в WPF. Ведь оптимизация в стиле GDI применяется к тексту перед такими преобразованиями. А после выполнения преобразования результат уже не будет выровнен по границам пикселей, и текст будет выглядеть размытым. Курсоры мыши В любом приложении обычно требуется изменять курсор мыши, чтобы он показывал, когда приложение занято, или отражал работу разных элементов управления. Курсор мыши можно задать для любого элемента с помощью свойства Cursor, унаследованного от класса FrameworkElement.
Глава 6. Элементы управления 177 Каждый курсор представляется объектом System.Windows.Input.Cursor. Получить объект Cursor проще всего с помощью статических свойств класса Cursors (из пространства имен System.Windows.Input). Они включают все стандартные указатели Windows, такие как песочные часы, рука, стрелки изменения размеров и т.д. Вот пример, где для текущего окна устанавливаются песочные часы: this.Cursor = Cursors.Wait; Теперь при перемещении курсора мыши над текущим окном он примет вид песочных часов (в Windows ХР) или вращающегося кружка (в Windows Vista и Windows 7). На заметку! Свойства класса Cursors используют курсоры, определенные в компьютере. Если пользователь изменит набор стандартных курсоров, то создаваемое приложение будет использовать эти измененные курсоры. При указании курсора в XAML не нужно использовать класс Cursors напрямую. Это объясняется тем, что преобразователь типов (TypeConverter) для свойства Cursor может распознавать имена свойств и выбирать соответствующий объект Cursor из класса Cursors. Это означает, что можно написать разметку, подобную нижеследующей, которая отображает курсор "справки" (сочетание стрелки и вопросительного знака), когда указатель мыши наведен на кнопку: <Button Cursor="Help">Help</Button> Возможно перекрытие параметров курсора, и тогда берется наиболее специальный курсор. Например, можно задать разные курсоры для кнопки и окна, в котором она находится. Курсор кнопки будет отображаться при перемещении указателя над кнопкой, а курсор окна — в любом другом участке окна. Из этого правила есть одно исключение. Родительский элемент может перекрыть параметры курсора своих потомков с помощью свойства ForceCursor. Если ему присвоено значение true, то свойство потомка Cursor игнорируется, а вместо него везде применяется родительское свойство Cursor. Если требуется применить параметры курсора к каждому элементу в каждом окне приложения, то свойство FrameworkElement.Cursor бесполезно. Вместо него вам следует использовать статическое свойство Mouse.OverrideCursor, которое переопределяет свойство Cursor каждого элемента: Mouse.OverrideCursor = Cursors.Wait; Чтобы отменить это перекрытие, действующее в рамках всего приложения, нужно присвоить свойству Mouse.OverrideCursor значение null. И, наконец, WPF без каких-либо проблем поддерживает пользовательские курсоры — как обычные (файлы .cur, просто небольшие картинки), так и анимированные (.ani). Чтобы задействовать пользовательский указатель, нужно передать имя нужного файла курсора или поток с данными курсора конструктору объекта Cursor: Cursor customCursor = new Cursor (Path . Combine (applicationDir, "stopwatch . am") ; this.Cursor = customCursor; Объект Cursor не поддерживает напрямую синтаксис URI, который позволяет другим элементам WPF (таким как Image) работать с файлами, хранящимися в скомпилированной сборке. Однако ничто не мешает добавить файл указателя в приложение в качестве ресурса, а затем извлечь его как поток, который можно использовать для создания объекта Cursor. Для этого предназначен метод Application.GetResourceStream(): StreamResourcelnfo sri = Application.GetResourceStream( new Uri("stopwatch.ani", UriKind.Relative));
178 Глава 6. Элементы управления Cursor customCursor = new Cursor(sri.Stream); this.Cursor = customCursor; Здесь подразумевается, что в проект добавлен файл stopwatch.ani, а параметру Build Action присвоено значение Resource. Метод GetResourceStream() будет рассмотрен в главе 7. Элементы управления содержимым Элемент управления содержимым — еще более специализированный вид элементов управления, который может хранить (и отображать) фрагмент содержимого. Технически элементы управления содержимым представляют собой элементы, которые могут содержать один вложенный элемент. Это ограничение на наличие лишь одного потомка и отличает элементы управления содержимым от контейнеров компоновки, которые могут содержать сколько угодно вложенных элементов. Совет. Конечно, есть способ поместить в один элемент управления содержимым много различного содержимого — для этого нужно упаковать все в один контейнер, такой как StackPanel или Grid. Например, сам класс window является элементом управления содержимым. Понятно, что окна обычно содержат много всякого содержимого, но все оно упаковано в один контейнер высокого уровня (обычно Grid). Как было сказано в главе 3, все контейнеры компоновки WPF порождены от абстрактного класса Panel, который обеспечивает содержание нескольких элементов. Аналогично, все элементы управления содержимым порождены от абстрактного класса ContentControl. Иерархия классов приведена на рис. 6.1. Как показано на рис. 6.1, некоторые распространенные элементы управления на самом деле являются элементами управления содержимым — к ним относятся, например, Label и ToolTip. Кроме того, все виды кнопок являются элементами управления содержимым, в том числе привычные Button, RadioButton и CheckBox. Существуют и более специализированные элементы управления, такие как ScrollViewer (позволяет создать прокручиваемую панель) и UserControl (позволяет повторно использовать пользовательскую группировку элементов управления). Даже класс Window, предназначенный для отображения всех окон приложения, сам является элементом управления содержимым. И, наконец, имеется подмножество элементов управления содержимым, которое проходит через дополнительный уровень наследования — они порождены от класса HeaderedContentControl. Эти элементы имеют и область содержимого, и область заголовка, где можно выводить какую-то заглавную информацию. К ним относятся элементы GroupBox, Tabltem (страница в TabControl) и Expander. На заметку! На рис. 6.1 не показано несколько элементов. Там нет элемента Frame, предназначенного для навигации (см. главу 24), и нескольких элементов, которые применяются внутри других управляющих элементов (такие как текстовая панель и строка состояния). Свойство Content Аналогично тому, как класс Panel добавляет коллекцию Children для хранения вложенных элементов, класс ContentControl добавляет свойство Content, которое принимает единственный объект.
Глава 6. Элементы управления 179 г V Label ButtonBase ToolTip DispatcherObject DependencyObject ♦ Visual V i J ♦ UlElement * FrameworkElement ♦ Control ♦ ContentControl ♦ N У Условные ^~" обозначения ~"^ с L_ Абстрактный ^ класс J Конкретный класс _J ScrollViewer UserControl Window I HeaderedContentControl ; t i GroupBox Tabltem Expander Рис. 6.1. Иерархия элементов управления содержимым
180 Глава 6. Элементы управления Свойство Content поддерживает объекты любых типов, но разделяет их на две группы и обрабатывает эти группы по-разному. • Объекты, не порожденные от UIElement. Элементы управления содержимым получают текст из таких элементов с помощью метода ToStringO, а затем выводят этот текст. • Объекты, порожденные от UIElement. Эти объекты (к которым относятся все визуальные элементы, входящие в WPF) выводятся внутри элемента управления содержимым с помощью метода UIElement.OnRender(). На заметку! Вообще-то метод OnRender () прорисовывает объект не сразу. Он просто генерирует графическое представление, которое WPF при необходимости рисует на экране. Чтобы разобраться во всем этом, рассмотрим обычную кнопку. До сих пор наши примеры с кнопками содержали просто текстовую строку: <Button Margin=">Text Content</Button> Эта строка задается в качестве содержимого кнопки и отображается на ее поверхности. Но на кнопке можно разместить и другие элементы — например, изображение с помощью класса Image: <Button Margin="> <Image Source="happyface.jpg" Stretch="None" /> </Button> Можно даже объединить текст и изображение, упаковав их в компоновочный контейнер наподобие StackPanel: <Button Margin=M> <StackPanel> <TextBlock Margin=M>Image and text button</TextBlock> <Image Source="happyface.jpg" Stretch="None" /> <TextBlock Margin=">Courtesy of the StackPanel</TextBlock> </StackPanel> </Button> ■ ' ButtonsWithContent L Text button Image and text button J Courtesy of the StackPanel Type something here: Text box in a button Рис. 6.2. Кнопки с различными видами вложенного содержимого На заметку! Текстовое содержимое можно размещать внутри элемента управления содержимым, т.к. синтаксический анализатор XAML преобразовывает его в строковый объект и использует для задания свойства Content. Однако строковое содержимое нельзя поместить непосредственно в компоновочный контейнер. Его нужно сначала упаковать в класс, порожденный от UIElement — например, TextBlock или Label. А если вам захочется создать ну совсем уж экзотическую кнопку, можно поместить на нее и другие элементы управления содержимым: текстовые поли и даже другие кнопки (которые также могут содержать другие вложенные элементы). Вряд ли подобный интерфейс когда- либо пригодится, но это возможно. Примеры таких кнопок приведены на рис. 6.2.
Глава 6. Элементы управления 181 Это та же модель содержимого, которая применяется для окон. Как и класс Button, класс Window допускает наличие одного вложенного элемента, который может быть текстом, произвольным объектом или элементом управления. На заметку! Одним из немногих элементов, которые не разрешены внутри элементов управления содержимым, является window. Окно при создании проверяет, является ли оно контейнером самого верхнего уровня. Если оно размещено в другом элементе, объект window генерирует исключение. Кроме свойства Content, класс ContentControl не содержит почти ничего нового. В нем имеется свойство HasContent, которое возвращает true при наличии в элементе содержимого, и ContentTemplate, которое позволяет создать шаблон, указав элементу, как нужно отображать нераспознанный объект. Класс ContentTemplate позволяет более осмысленно выводить объекты, порожденные HeoTUIElement. Пользуясь значениями различных свойств, можно, вместо простого вызова ToStringO для получения строки, организовать их в более сложную разметку. О шаблонах данных будет рассказано в главе 20. Выравнивание содержимого В главе 3 было рассказано о выравнивании различных элементов управления в контейнере с помощью свойств HorizontalAlignment и VerticalAlignment, определенных в базовом классе FrameworkElement. Но когда в элементе уже есть содержимое, необходимо рассмотреть другой уровень организации: выравнивание этого содержимого внутри границ элемента управления. Для этого предназначены свойства HorizontalContentAlignment и VerticalContentAlignment. Эти свойства могут принимать те же значения, что и свойства HorizontalAlignment и VerticalAlignment. Это означает, что содержимое можно выровнять по любой границе элемента: верхней (Тор), нижней (Bottom), левой (Left) или правой (Right)), разместить в центре (Center) или растянуть на все доступное место (Stretch). Эти параметры применяются непосредственно к вложенным элементам управления содержимым, но можно использовать несколько уровней вложения, чтобы создать компоновку произвольной сложности. Например, при вложении StackPanel в элементе Label свойство Label.HorizontalContentAlignment определяет расположение StackPanel, но всю остальную компоновку определяют параметры выравнивания и размера элемента StackPanel и его дочерних элементов. В главе 3 вы познакомились также со свойством Margin, которое позволяет добавить промежутки между смежными элементами. У элементов управления содержимым есть похожее свойство — Padding (отступ), которое вставляет промежутки между границами элемента и границами его содержимого. Чтобы понять, в чем разница, сравните две следующие кнопки: <Button>Absolutely No Padding</Button> <Button Padding=">Well Padded</Button> В кнопке без отступов (по умолчанию) текст прижат к краям кнопки. А кнопка с отступом в 3 единицы с каждой стороны получается более просторной. Эта разница показана на рис. 6.3. На заметку! Свойства HorizontalContentAlignment, VerticalContentAlignment и Padding определены в классе Control, а не в более конкретном классе ContentControl — т.к. могут быть элементы, не являющиеся элементами управления содержимым, но все-таки имеющие какое-то содержимое. Примером может служить TextBox: текст, который содержится в нем (в свойстве Text), оформляется с учетом применяемых к нему параметров выравнивания и отступа.
182 Глава 6. Элементы управления ■ PaddingComparisontSBJBi Absolutely No Padding Well Padded Рис. 6.З. Добавление отступов к содержимому кнопки Рис. 6.4. Кнопка с фигурами на ней Философия содержимого в WPF К этому моменту вы, возможно, уже стали задумываться, а стоит ли вообще связываться с моделью содержимого WPF. Конечно, кнопку можно украсить рисунком, но вряд ли понадобится вставлять в нее другие элементы управления и даже целые панели компоновки. Однако имеются несколько важных причин склониться к этой модели. Рассмотрим пример, приведенный на рис. 6.2 — простую кнопку с рисунком, где элемент Image содержится в элементе Button. Это подход далеко не идеален, т.к. битовые изображения не являются независимыми от разрешения. На дисплее с высоким разрешением картинка будет размазанной, т.к. WPF придется добавлять в нее с помощью интерполяции дополнительные пиксели, чтобы выдержать необходимый размер. Более сложные интерфейсы WPF используют для создания пользовательских кнопок не битовые изображения, а комбинации векторных фигур и другие графические фишки (как будет показано в главе 12). Этот подход замечательно гармонирует с моделью управления содержимым. Поскольку класс Button является элементом управления содержимым, у вас есть возможность заполнить его не только битовым, но и другим содержимым. Например, можно воспользоваться классами из пространства имен System.Windows.Shapes и рисовать на кнопках всякие векторные картинки. Следующий пример создает кнопку с двумя вытянутыми ромбами (как показано на рис. 6.4): <Button Margin=M> <Grid> <Polygon Points=00,25 125,0 200,25 125,50" Fill="LightSteelBlue" /> <Polygon Points=00,25 75,0 0,25 75,50" Fill="White"/> </Grid> </Button> Понятно, что в данном случае модель вложенного содержимого проще добавления дополнительных свойств в класс Button для поддержки различных видов содержимого. Эта модель не только повышает гибкость, но и позволяет не загромождать интерфейс класса Button. А поскольку все элементы управления содержимым поддерживают точно такое же вложение содержимого, то отпадает необходимость добавлять различные свойства содержимого в различные классы. (Разработчики Windows Fbrms столкнулись с этой проблемой в .NET 2.0, пытаясь усовершенствовать классы Button и Label для улучшения поддержки изображений и содержимого, объединяющего картинки и текст.) Вообще-то модель вложенного содержимого является результатом компромисса. Она упрощает модель классов для элементов, поскольку устраняет дополнительные слои на-
Глава 6. Элементы управления 183 следования и добавление свойств для различных видов содержимого. Но при этом приходится использовать несколько более сложную модель объектов — элементов, которые можно создать из других вложенных элементов. На заметку! Не всегда можно добиться нужного эффекта, изменяя содержимое управляющего элемента. Например, на кнопку можно поместить любое содержимое, но некоторые ее аспекты все равно не меняются — например, более темный фон кнопки, закругленные границы и эффект подсветки при наведении на нее указателя мыши. Правда, эти встроенные аспекты можно изменить, применив новый шаблон элемента. В главе 17 показано, как можно изменить все аспекты внешнего вида элемента с помощью шаблонов элементов. Метки Простейшим элементом управления содержимым является Label — метка. Как и любой другой элемент управления содержимым, она принимает одиночную порцию содержимого, которая размещается внутри нее. Отличительной чертой элемента Label является его поддержка мнемонических команд — нажатий клавиш, которые передают фокус соответствующему элементу управления. Для поддержки этой функции в элементе Label добавлено свойство Target. Чтобы задать это свойство, необходимо воспользоваться выражением привязки, которое указывает на другой элемент управления. Ниже показан необходимый для этого синтаксис: <Label Target="{Binding ElementName=txtA}">Choose _A</Label> <TextBox Name=MtxtA"></TextBox> <Label Target="{Binding ElementName=txtB}">Choose _B</Label> <TextBox Name="txtBM></TextBox> Символ подчеркивания в тексте метки указывает на клавишу быстрого доступа. (Если символ подчеркивания необходим в метке, нужно добавить два таких символа.) Все мнемонические команды работают при одновременном нажатии клавиши <Alt> и заданной клавиши быстрого доступа. Например, если в данном примере пользователь нажмет комбинацию <Alt+A>, то первая метка передаст фокус связанному с ней элементу управления — в данном случае txtA. Точно так же нажатие комбинации <Alt+B> приводит к передаче фокуса элементу txtB. На заметку! Если вам доводилось программировать с использованием Windows Forms, то вы, возможно, применяли для обозначения клавиши быстрого доступа символ амперсанда (&). В XAML для этой цели служит символ подчеркивания, поскольку символ амперсанда нельзя ввести в XML напрямую: вместо него приходится использовать неуклюжую комбинацию &атр,\ Обычно буквы клавиш быстрого доступа скрыты до тех пор, пока пользователь не нажмет <Alt> — тогда они отмечаются подчеркиванием (рис. 6.5). Однако это поведение зависит от настроек системы. Совет. Если нужно лишь вывести содержимое без поддержки мнемонических команд, можно воспользоваться более облегченным элементом TextBlock. В отличие от элемента Label, TextBlock поддерживает перенос текста с помощью свойства TextWrapping. Кнопки WPF распознает три типа кнопок: Button, CheckBox и RadioButton. Все эти кнопки являются наследниками класса ButtonBase.
184 Глава 6. Элементы управления Рис. 6.5. Клавиши быстрого доступа в метках Класс ButtonBase содержит лишь несколько членов. Он определяет событие Click и добавляет поддержку команд, которые позволяют подключать кнопки к высокоуровневым задачам приложений (об этом будет рассказано в главе 10). Наконец, класс ButtonBase добавляет свойство ClickMode, которое определяет, когда кнопка генерирует событие Click в ответ на действия мыши. По умолчанию используется значение ClickMode. Release, которое означает, что событие Click будет сгенерировано после нажатия и последующего отпускания кнопки мыши. Однако можно сделать и так, чтобы событие Click возникало сразу при нажатии кнопки мыши (ClickMode.Press) или даже когда указатель мыши будет наведен на кнопку и задержится над ней (ClickMode.Hover). На заметку! Все кнопки поддерживают клавиши доступа, которые работают подобно мнемоническим командам в элементе управления Label. Для обозначения клавиши доступа служит символ подчеркивания. Когда пользователь нажмет клавишу <Alt> и клавишу доступа, возникнет событие Click данной кнопки. Класс Button Класс Button представляет вездесущую кнопку Windows. Он добавляет всего два доступных для записи свойства: IsCancel и IsDefault. • Если свойство IsCancel имеет значение true, то эта кнопка будет работать в окне как кнопка отмены. Если нажать клавишу <Esc>, когда фокус находится в текущем окне, то сработает эта кнопка. • Если свойство IsDefault имеет значение true, то эта кнопка считается кнопкой по умолчанию (она еще называется кнопкой принятия). Ее поведение зависит от текущей позиции в окне. Если указатель мыши находится на элементе управления, отличном от Button (например, TextBox, RadioButton, CheckBox и т.д.), то кнопка по умолчанию будет выделена голубоватым оттенком — почти так, как если бы она находилась в фокусе. При нажатии клавиши <Enter>, сработает эта кнопка. Однако если навести указатель мыши на другой элемент управления Button, то голубоватым оттенком будет выделена текущая кнопка, и при нажатии <Enter> будет приведена в действие именно эта кнопка, а не кнопка по умолчанию. Многие пользователи используют такие клавиши быстрого доступа (особенно клавишу <Esc> для закрытия ненужного диалогового окна), поэтому есть смысл потратить время на определение этих деталей в каждом создаваемом вами окне. Но код обработки ■ * LabelTest Choose A Choose fi
Глава 6. Элементы управления 185 событий для кнопки по умолчанию и кнопки отмены придется написать вам, так как WPF не поддерживает это поведение. В некоторых случаях имеет смысл сделать одну и ту же кнопку в окне и кнопкой отмены, и кнопкой ло умолчанию. Примером может служить кнопка ОК в окне О программе. Однако в окне должна быть только одна кнопка отмены и одна кнопка по умолчанию. Если вы назначите несколько кнопок отмены, то нажатие клавиши <Esc> будет просто передавать фокус следующей кнопке по умолчанию, без ее активизации. А при наличии нескольких кнопок по умолчанию нажатие клавиши <Enter> приведет к непонятному поведению. Если в фокусе будет находиться элемент управления, отличный от Button, то при нажатии <Enter> фокус будет передан следующей кнопке по умолчанию. Если же в фокусе находится элемент управления Button, нажатие клавиши <Enter> активизирует его. Свойства IsDefault и IsDefaulted Класс Button содержит также загадочное свойство isDefaulted, которое доступно только для чтения. Оно возвращает значение true для кнопки по умолчанию, если фокус принадлежит другому элементу управления, не принимающий клавишу <Enter>. В этой ситуации нажатие <Enter> приведет к активизации кнопки. Например, элемент TextBox не принимает клавишу <Enter>, если свойство TextBox. AcceptsReturn не равно true. Если элемент управления TextBox со свойством TextBox. AcceptsReturn, равным true, находится в фокусе, то свойство IsDefaulted кнопки по умолчанию будет равно false. Если элемент TextBox со свойством AcceptsReturn, равным false, имеет фокус, то свойство IsDefaulted кнопки по умолчанию получает значение true. Свойство IsDefaulted возвращает значение false, когда кнопка имеет фокус, даже если при этом нажатие клавиши <Enter> активизирует кнопку. Вряд ли вы будете использовать свойство IsDefaulted, хотя оно позволяет написать некоторые типы триггеров стилей, о чем речь пойдет в главе 11. Если вам это не нужно, добавьте данное свойство в список малопонятных особенностей WPF, чтобы при случае озадачить своих коллег. Классы ToggleButton и RepeatButton Помимо Button, потомками класса ButtonBase являются еще три класса. • GridViewColumnHeader — заголовок столбца, реагирующий на щелчок мышью, если используется табличный элемент ListView. Элемент ListView рассматривается в главе 22. • RepeatButton, который в прижатом состоянии непрерывно генерирует события Click. Обычные кнопки генерируют событие Click только при полном щелчке на кнопке. • ToggleButton — кнопка с двумя состояниями (нажата и отпущена). Если щелкнуть на кнопке ToggleButton, она будет оставаться нажатой до тех пор, пока вы не щелкнете на ней снова. Иногда такое поведение называют залипающим щелчком (sticky click). Классы RepeatButton и ToggleButton определены в пространстве имен System. Windows.Controls.Primitives, что означает, что сами по себе они применяются редко. Как правило, они используются для построения более сложных элементов управления или расширения возможностей путем наследования. Например, класс RepeatButton используется для создания высокоуровневого элемента управления ScrollBar (который, в свою очередь, входит в состав еще более высокоуровневого элемента ScrollViewer). RepeatButton придает кнопкам со стрелками на концах полосы прокрутки их фирмен-
186 Глава 6. Элементы управления ное поведение: прокрутка продолжается, пока они нажаты. Точно так же ToggleButton применяется для порождения более полезных классов Checkbox и RadioButton, которые будут рассмотрены ниже. В то же время ни RepatButton, ни ToggleButton не являются абстрактными классами, поэтому их можно непосредственно применять в пользовательских интерфейсах. ToggleButton очень удобно использовать внутри элемента Tool Bar, который мы рассмотрим в главе 25. Класс Checkbox Кнопки CheckBox и RadioButton — кнопки другого вида. Они являются потомками класса ToggleButton, а это означает, что пользователь может включать и выключать их (отсюда и слово toggle в названии — "переключение"). В случае CheckBox включение элемента управления означает установку в нем флажка. Класс CheckBox не добавляет никаких членов, поэтому базовый интерфейс CheckBox определяется в классе ToggleButton. Более важно то, что ToggleButton добавляет свойство IsChecked. Свойство IsChecked является расширенным логическим, т.е. оно может принимать значения true, false или null. Понятно, что true представляет установленный флажок, a false — сброшенный. Значение null используется для представления неопределенного состояния, которое отображается в виде серого квадратика. Неопределенное состояние обычно служит для представления не заданных значений или областей, в которых возможны противоречия. Например, если имеется флажок, который позволяет применять жирный шрифт в текстовом приложении, а выбранный фрагмент содержит как жирный, так и обычный текст, можно присвоить флажку значение null, чтобы обозначить неопределенное состояние. Чтобы присвоить значение null в разметке WPF, нужно использовать расширение разметки Null: <CheckBox IsChecked="{x:Null}">А check box in indeterminate state</CheckBox> Наряду со свойством IsChecked класс ToggleButton добавляет свойство IsThreeState, которое определяет, может ли пользователь установить флажок в неопределенное состояние. Если свойство IsThreeState равно false (по умолчанию), то щелчки меняют состояние флажка между "установлен" и "сброшен", а неопределенное состояние можно задать только с помощью кода. Если свойство ThreeState равно true, то щелчки на флажке будут по очереди давать все три возможных состояния. Класс ToggleButton определяет также три события, которые возникают, когда флажок принимает одно из конкретных состояний: Checked, Unchecked и Indeterminate. В большинстве случаев удобнее объединить эту логику в одном обработчике события Click, которое наследуется от класса ButtonBase. Событие Click возникает при каждом изменении состояния кнопки. Класс RadioButton Класс RadioButton также порожден от класса ToggleButton и использует то же свойство IsChecked и те же события Checked, Unchecked и Indeterminate. Кроме того, RadioButton добавляет еще одно свойство GroupName, которое позволяет управлять группировкой переключателей. Обычно переключатели группируются их контейнером. Это означает, что если поместить три элемента RadioButton в панель StackPanel, то они формируют группу, из которой можно выбрать только один из них. А если поместить комбинацию переключателей в две разных панели StackPanel, получатся две независимые группы. Свойство GroupName позволяет переопределить это поведение. С его помощью можно создать несколько групп в одном контейнере или одну группу, которая будет охва-
Глава 6. Элементы управления 187 тывать несколько контейнеров. В любом случае это выполняется просто: достаточно присвоить "одногруппным" переключателям имя одной и той же группы. Рассмотрим пример: <StackPanel> <GroupBox Margin="> <StackPanel> <RadioButton>Group K/RadioButton> <RadioButton>Group K/RadioButton> <RadioButton>Group K/RadioButton> <RadioButton Margin=,10,0,0" GroupName="Group2">Group 2</RadioButton> </StackPanel> </GroupBox> <GroupBox Margin="> <StackPanel> <RadioButton>Group 3</RadioButton> <RadioButton>Group 3</RadioButton> <RadioButton>Group 3</RadioButton> <RadioButton Margin=,10,0,0" GroupName="Group2">Group 2</RadioButton> </StackPanel> </GroupBox> </StackPanel> Здесь имеются два контейнера с переключателями, но три группы. Последняя кнопка внизу каждой групповой панели входит в третью группу. В этом примере нарочно выдумана такая запутанная конструкция, однако в реальности могут существовать задачи, когда нужно аккуратно отделить какую-то кнопку, не теряя ее членство в группе. Совет. Для упаковки переключателей не обязательно применять контейнер GroupBox, хотя обычно это так. Этот контейнер отображает рамку и заголовок для группы кнопок. Всплывающие подсказки WPF предлагает гибкую модель для всплывающих подсказок (tooltip — желтые окошки, которые появляются при наведении указателя мыши на какой-то объект). Поскольку в WPF всплывающие подсказки относятся к элементам управления содержимым, в них можно поместить практически что угодно. Можно также настроить различные временные параметры, чтобы задать время, после которого подсказка появляется и исчезает. Самый простой способ вывода всплывающих подсказок — отнюдь не непосредственное использование класса ToolTip. Вместо этого достаточно просто определить свойство ToolTip нужного элемента. Свойство ToolTip определено в классе FramworkElement, поэтому оно доступно для любого элемента, которое может разместиться в окне WPF. Например, вот кнопка с простой всплывающей подсказкой: <Button ToolTip="This is my tooltip">I have a tooltip</Button> Если навести на нее курсор, то в знакомом желтом окошке появится текст: "This is my tooltip". Если нужно не такое простое содержимое всплывающей подсказки (например, комбинация вложенных элементов), придется выделить свойство ToolTip в отдельный элемент. Ниже показан пример, в котором для кнопки задается свойство ToolTip с помощью более сложного вложенного содержимого: <Button> <Button.ToolTip> <StackPanel> <TextBlock Margin=">Image and text</TextBlock>
188 Глава 6. Элементы управления <Image Source="happyface.ipg" Stretch="None" /> <TextBlock Margin=">Image and text</TextBlock> </StackPanel> </Button.ToolTip> <Button.Content>I have a fancy tooltip</Button.Content> </Button> Как и в предыдущем примере, WPF создает объект Tool Tip неявно. Но разница в том, что в данном случае объект ToolTip содержит панель StackPanel, а не простую строку. Результат показан на рис. 6.6. ! ■ ToolTipTest kfl»H^ ЩИ1| Image and text I H Image and text [ Рис. 6.6. Необычная всплывающая подсказка При наложении нескольких всплывающих подсказок приоритет имеет наиболее специальная из них. Например, если в предыдущем примере добавить всплывающую подсказку к контейнеру StackPanel, то именно она появится при наведении курсора на пустое место панели или элемента управления, у которого нет собственной всплывающей подсказки. На заметку! Не помещайте во всплывающую подсказку элементы управления, поддерживающие интерактивную связь с пользователями, т.к. окно ToolTip не может получить фокус. Например, если поместить в элемент ToolTip кнопку, эта кнопка будет отображаться, но не будет реагировать на щелчки. (Если вы попытаетесь щелкнуть на ней, ваш щелчок будет принят находящимся за ним окном.) Если вам нужно окно, которое похоже на всплывающую подсказку, но может содержать другие элементы управления, воспользуйтесь элементом Popup, который будет рассмотрен ниже в этой главе. Настройка параметров всплывающей подсказки В предыдущем примере было показано, как можно настроить содержимое всплывающей подсказки. А если нужно сконфигурировать ее другие параметры? На этот случай есть два варианта. Первый — явное определение объекта ToolTip. Это позволяет непосредственно задать разнообразные свойства ToolTip. ToolTip является элементом управления содержимым, поэтому для него можно настроить стандартные свойства, такие как Background (чтобы сменить желтый фоновый цвет), Padding и Font. Можно также изменить свойства, определенные в классе ToolTip (они перечислены в табл. 6.2). Большинство этих свойств предназначено для размещения подсказки точно в нужном месте. Ihave
Глава 6. Элементы управления 189 Таблица 6.2. Свойства класса ToolTip Имя Описание HasDropShadow Placement HorizontalOffset и VerticalOffset PlacementTarget PlacementRectangle CustomPopupPlacementCallback StaysOpen IsEnabledM IsOpen Определяет, имеет ли окно подсказки размытую темную тень, которая "приподнимает" его над находящимся под ним окном Определяет позицию подсказки с помощью одного из значений из перечисления PlacementMode. Значением по умолчанию является Mouse, которое означает, что верхний левый угол подсказки будет располагаться относительно текущей позиции указателя мыши. (Действительное положение подсказки может быть смещено от начальной точки с помощью свойств HorizontalOf fset и VerticalOffset.) Кроме того, местоположение подсказки можно задавать, указав абсолютные координаты на экране или относительно некоторого элемента (который нужно задается свойством PlacementTarget) Позволяют точно позиционировать окно подсказки. Допустимы как положительные, так и отрицательные значения Позволяет позиционировать окно подсказки относительно другого элемента. Чтобы использовать это свойство, свойство Placement должно иметь одно из следующих значений: Left, Right, Top или Bottom. (Это одна из границ элемента, по которой будет выравниваться подсказка.) Задает смещение окна подсказки. Работает почти так же, как и свойства HorizontalOf fset и VerticalOffset. Это свойство не сработает, если свойство Placement имеет значение Mouse Позволяет динамически позиционировать окно подсказки с помощью кода. Если свойство Placement имеет значение Custom, то это свойство определяет метод, вызываемый объектом ToolTip для получения местоположения подсказки. Метод обратного вызова получает три аргумента: popupSize (размер ToolTip), target- Size (размер PlacementTarget, если он используется) и offset (точка, которая создается на основе свойств HorizontalOf fset и VerticalOffset). Этот метод возвращает объект CustomPopupPlacement, который сообщает WPF, где поместить окно подсказки Не имеет никакого практического эффекта. Это свойство предназначено для создания всплывающего окна подсказки, которое остается открытым до тех пор, пока пользователь не щелкнет еще где-нибудь. Однако его перекрывает свойство ToolTipService.ShowDuration StaysOpen. Поэтому всплывающие подсказки всегда исчезают по истечении заданного промежутка времени (обычно примерно 5 секунд) или при сдвиге указателя мыши. Если вы хотите создать окно в виде всплывающей подсказки, которое будет оставаться открытым неопределенно долго, то проще всего использовать элемент Popup Позволяют управлять поведением подсказки с помощью кода. Свойство IsEnabled позволяет временно отключить всплывающую подсказку, a IsOpen — программно отображать и скрывать подсказку (или просто проверять, открыто ли ее окно)
190 Глава 6. Элементы управления Следующая разметка создает с помощью свойств ToolTip всплывающую подсказку без тени с прозрачным красным фоном, который позволяет видеть находящееся под ним окно (и элементы управления): <Button> <Button.ToolTip> <ToolTip Background="#60AA4030" Foreground="White" HasDropShadow="False" > <StackPanel> <TextBlock Margin=">Image and text</TextBlock> <Image Source="happyface.jpg" Stretch="None" /> <TextBlock Margin=">Image and text</TextBlock> </StackPanel> </ToolTip> </Button.ToolTip> <Button.Content>I have a fancy tooltip</Button.Content> </Button> В большинстве случаев вполне достаточно использовать стандартное размещение окна подсказки — в текущей позиции указателя мыши. Но разнообразные свойства ToolTip предлагают множество других вариантов размещения. Вот некоторые стратегии позиционирования подсказки. • Привязка к текущей позиции указателя мыши. Это стандартный способ, когда свойству Placement присваивается значение Mouse. Левый верхний угол окна подсказки размещается в левом верхнему углу невидимого "прямоугольника" вокруг курсора. • Привязка к позиции элемента под указателем мыши. Свойству Placement присваивается значение Left, Right, Top, Bottom или Center — т.е. указывается край элемента, который нужно использовать для привязки. Левый верхний угол контекстного окна указателя будет выровнен по этому краю. • Привязка к позиции другого элемента (или окна). Свойство Placement задается точно так же, как при выравнивании подсказки по текущему элементу (используются значения Left, Right, Top или Center). Затем с помощью свойства PlacementTarget указывается базовый элемент. Не забывайте указать нужный элемент с помощью синтаксиса {Binding ElementName=HM#}. • Определение смещения. Используется одна из вышеперечисленных стратегий, а также определяются свойства HorizontalOf f set и VerticalOf f set, чтобы получить небольшой сдвиг. • Использование абсолютных координат. Свойству Placement присваивается значение Absolute, а свойства HorizontalOffset и VerticalOffset (или PlacementRectangle) задают расстояние от окна подсказки до левого верхнего угла окна. • Расчет во время выполнения. Свойству Placement присваивается значение Custom. С помощью свойства CustomPopupPlacementCallback указывается созданный вами метод. На рис. 6.7 показаны разные способы расположения всплывающей подсказки. Обратите внимание, что при выравнивании окна подсказки по нижнему или правому краю элемента остается немного пустого места, из-за особенности измерения содержимого элементом ToolTip.
Глава 6. Элементы управления 191 Относительно указателя мыши Относительно стороны элемента Относительно элемента, со смещением ь. Подсказка Подсказка Подсказка Кнопка | Подсказка Подсказка | Кнопка Подсказка VerticalOffset *— HorizontalOffset - Рис. 6.7. Явное позиционирование всплывающей подсказки Настройка свойств ToolTipServlce Существуют свойства всплывающих подсказок, которые нельзя задать с помощью свойств класса ToolTip. Для этого предназначен другой класс — ToolTipService. Он позволяет задать длительность задержек при отображении всплывающей подсказки. Все свойства этого класса являются прикрепленными свойствами, поэтому их можно указывать прямо в дескрипторе элемента управления, как показано ниже: <Button ToolTipService.InitialShowDelay=Hl"> </Button> Класс ToolTipService определяет много тех же свойств, что и класс ToolTip. А это значит, что при работе со всплывающими подсказками, содержащими только текст, можно использовать более простой синтаксис. Вместо добавления вложенного элемента ToolTip, можно задать все, что необходимо, с помощью атрибутов: <Button ToolTip="This tooltip is aligned with the bottom edge" ToolTipService.Placement="Bottom">I have a tooltip</Button> Свойства класса ToolTipService перечислены в табл. 6.3. Таблица 6.3. Свойства класса ToolTipService Имя Описание InitialShowDelay ShowDuration BetweenShowDelay ToolTip Задает задержку (в миллисекундах) перед выводом подсказки после наведения указателя мыши на элемент Задает время (в миллисекундах), в течение которого будет отображаться подсказка, если пользователь не сдвинет указатель мыши Задает временной интервал (в миллисекундах), в течение которого пользователь может переходить от одной всплывающей подсказки к другой без задержки, определяемой свойством InitialShowDelay. Например, если свойство BetweenShowDelay равно 5000, то у пользователя будет пять секунд на то, чтобы навести указатель мыши на другой элемент управления, у которого имеется всплывающая подсказка. Если пользователь наведет указатель мыши на другой элемент в течение этих пяти секунд, то новая подсказка появится немедленно. Если же пользователь потратит больше пяти секунд, в действие вступит InitialShowDelay. В этом случае вторая подсказка появится после задержки, указанной в свойстве InitialShowDelay Задает содержимое всплывающей подсказки. Задание свойства ToolTipService.ToolTip эквивалентно заданию свойства FrameworkElement .ToolTip элемента
192 Глава 6. Элементы управления Окончание табл. 6.3 Имя Описание HasDropShadow Определяет, будет ли контекстное окно указателя иметь размытую тень, которая "приподнимает" его над находящимся под ним окном ShowOnDisabled Определяет поведение подсказки при отключении базового элемента. Если это свойство имеет значение true, то подсказка будет отображаться для отключенных элементов (т.е. элементов, свойство IsEnabled которых равно false). По умолчанию это свойство равно false, т.е. подсказка отображается только для активных элементов управления Placement, Позволяют управлять местоположением окна подсказки. Эти свой- PlacementTarget, ства работают точно так же, как и соответствующие свойства класса Placement Rectangle, ToolTipHorizontalOf fset HorizontalOffset и VerticalOffset В этом классе определены также два маршрутизируемых события: ToolTipOpening HToolTipClosing. Реагируя на эти события, можно заполнить окно подсказки актуальным содержимым или переопределить его поведение. Например, если в каждом из этих событий установить флаг handled, подсказки не будут отображаться или скрываться автоматически. Тогда вы сможете выводить и скрывать их вручную с помощью свойства IsOpen. Совет. Не стоит дублировать параметры всплывающих подсказок для нескольких элементов управления. Если вы хотите получить однотипное поведение всплывающих подсказок во всем приложении, используйте стили, чтобы настройки применялись автоматически (см. главу 11). К сожалению, значения свойства ToolTipService не наследуются, то есть если задать их на уровне окна или контейнера, они не будут распространяться на вложенные элементы. Класс Popup Элемент управления Popup имеет много общего с элементом Tool Tip, хотя ни один из них не является наследником другого. Как и ToolTip, элемент Popufc может содержать любой элемент WPF. (Это содержимое хранится в свойстве Popup. Chi Id, а не в свойстве Content, как во всплывающих подсказках.) Как и в элементе ToolTip, содержимое Popup может распространяться за пределы окна. И, наконец, местоположение элемента Popup можно задать с помощью тех же свойств, а показать и скрыть его можно с помощью того же свойства IsOpen. Но более важны различия между элементами Popup и ToolTip. • Popup не отображается автоматически. Чтобы этот элемент управления отобразился на экране, нужно установить свойство IsOpen. • Свойство Popup. StaysOpen по умолчанию имеет значение true, поэтому элемент управления Popup не исчезнет с экрана до тех пор, пока вы явным образом не присвоите свойству IsOpen значение false. Если присвоить свойству StaysOpen значение false, элемент Popup исчезнет с экрана, как только пользователь щелкнет где-нибудь на экране. На заметку! Всплывающее окно, остающееся открытым, может слегка раздражать, т.к. оно ведет себя наподобие отдельного автономного окна. Если сместить указатель мыши, это окно останется в исходной позиции. Такого поведения нет ни у элемента ToolTip, ни у Popup, у которого свойство StaysOpen имеет значение false: как только вы щелкнете кнопкой мыши, чтобы сдвинуть окно, всплывающая подсказка или всплывающее окно исчезнут с экрана.
Глава 6. Элементы управления 193 В классе Popup имеется свойство PopupAnimation, которое позволяет управлять процессом появления окна, когда его свойству Is Open присваивается значение true. Это свойство может принимать значения None (по умолчанию), Fade (постепенное увеличение непрозрачности), Scroll (непрозрачность плавно распространяется из левого верхнего угла окна) и Slide (окно скользит на свое место). Чтобы активировать любой из этих анимационных эффектов, необходимо, чтобы свойство AllowsTransparency также имело значение true. Элементы Popup могут принимать фокус. Значит, они могут содержать элементы интерактивной связи с пользователем (например, Button). Эта возможность является одной из ключевых причин использования элемента Popup вместо ToolTip. Элемент управления Popup определен в пространстве имен System.Windows. Controls.Primitives, так как он чаще всего используется в составе более сложных элементов управления. Элементы Popup не так элегантны, как другие элементы управления. Например, чтобы видеть содержимое, необходимо определить свойство Background, т.к. оно не наследуется от окна. Рамку тоже придется добавлять самостоятельно (для этого очень удобен элемент Border). You can use a Popup to provide a link for a specific term of interest. Раз уж элемент управления Popup нужно отобра- _. ; • " PopupTest жать вручную, его можно вообще полностью создавать в коде. Однако несложно определить его и в разметке XAML — нужно лишь не забыть включить свойство Name для обработки в коде. На рис. 6.8 показан пример. Когда пользователь наводит курсор на подчеркнутое слово, появляется всплывающее окно с дополнительной информацией и ссылкой, которая открывает окно веб-браузера. Чтобы создать это окно, понадобится элемент TextBlock с исходным текстом и элемент Popup с дополнительным содержимым, которое будет отображаться, когда пользователь наведет указатель мыши в нужное место. С технической точки зрения *— неважно, где определен дескриптор Popup, посколь- рИс. 6.8. Всплывающее окно ку он не связан ни с каким конкретным элементом с гиперссылкой управления. Это вы должны определить свойства для позиционирования элемента Popup. В данном примере всплывающее окно появляется в текущей позиции указателя мыши (что проще всего): <TextBlock TextWrapping="Wrap">You can use a Popup to provide a link for a specific <Run TextDecorations="Underline" MouseEnter="run_MouseEnter">term</Run> of interest. </TextBlock> <Popup Name="popLink" StaysOpen="False" Placement="Mouse" MaxWidth=00" PopupAnimation="Slide" AllowsTransparency="True"> <Border BorderBrush="Beige" BorderThickness=" Background="White"> <TextBlock Margin=0" TextWrapping="Wrap"> For more information, see <Hyperlink NavigateUri="http://en.wikipedia.org/wiki/Term" CIick="lnk_Click">Wikipedia</Hyperlink> </TextBlock> </Border> </Popup>
194 Глава 6. Элементы управления В этом примере имеются два элемента, которые вы могли раньше не видеть. Элемент Run позволяет применить форматирование к конкретной части элемента TextBlock: это порция содержимого потока (об этом будет рассказано в главе 28, при рассмотрении документов). Hyperlink позволяет задать текст, который реагирует на щелчок мышью. Об этом речь пойдет в главе 24, при изучении приложений со страничной организацией. Остался лишь относительно простой код, который показывает элемент Popup при наведении курсора на заданное слово, и код, который открывает веб-браузер при щелчке на ссылке: private void run_MouseEnter(object sender, MouseEventArgs e) { popLink.IsOpen = true; } private void lnk_Click(object sender, RoutedEventArgs e) { Process.Start(((Hyperlink)sender).NavigateUri.ToString()); } На заметку! Элемент Popup можно показывать и скрывать с помощью триггера — действия, которое выполняется автоматически, когда определенное свойство получает определенное значение. Нужно просто создать триггер, который реагирует на значение true свойства Popup. IsMouseOver и присваивает свойству Popup.IsOpen значение true. Подробнее об этом в главе 11. Специализированные контейнеры Элементы управления содержимым — это не только метки, кнопки и всплывающие подсказки. К ним относятся также специализированные контейнеры, предназначенные для оформления больших участков пользовательского интерфейса. В последующих разделах вы познакомитесь с некоторыми из этих более сложных элементов управления содержимым: ScrollViewer, GroupBox, Tabltem и Expander. А поскольку эти элементы могут содержать лишь один элемент, они обычно используются в сочетании с контейнерами компоновки. Класс ScrollViewer Прокрутка необходима, если нужно поместить большой объем содержимого в ограниченную область. Для обеспечения прокрутки в WPF необходимо упаковать соответствующее содержимое в элемент ScrollViewer. Объект ScrollViewer может содержать все, что угодно, но обычно это контейнер компоновки. Например, в главе 3 был приведен пример, в котором использовался элемент Grid для оформления текстов, текстовых панелей и кнопок в три колонки. Чтобы элемент Grid поддерживал прокрутку, его нужно просто упаковать в элемент ScrollViewer, как показано в этой слегка сокращенной разметке: <ScrollViewer> <Grid Margin=,3,10,3"> <Grid.RowDefinitions> </Grid.RowDefinitions> <Gnd.ColumnDef initions> </Grid.ColumnDefinitions>
Глава 6. Элементы управления 195 <Label Grid.Row=" Grid.Column=" Margin=" VerticalAlignment="Center">Home:</Label> <TextBox Grid.Row=" Grid.Column="l" Margin=" Height="Auto" VerticalAlignment="Center"></TextBox> <Button Grid.Row=" Grid.Column=" Margin=" Padding="> Browse</Button> </Grid> </ScrollViewer> Результат показан на рис. 6.9. Если в этом примере изменить размер окна, чтобы его размера хватило на все содержимое, полоса прокрутки станет не активной, хотя и останется видимой. Этим поведением можно управлять с помощью свойства VerticalScrollBarVisibility, которое принимает одно из значений из перечисления ScrollBarVisibility. Стандартное значение Visible обеспечивает постоянное присутствие вертикальной полосы прокрутки. Значение Auto применяется, если нужно, чтобы полоса прокрутки появлялась, когда она нужна, и исчезала, когда не нужна. А значение Disabled позволяет полностью скрывает полосу прокрутки. На заметку! Есть еще значение Hidden, которое похоже на, Disabled, но с небольшими отличиями. Во-первых, содержимое со скрытой полосой прокрутки все-таки можно прокручивать (например, с помощью клавиш со стрелками). Во-вторых, содержимое в ScrollViewer располагается по-другому. Используя Disabled, вы указываете, что содержимое в ScrollViewer может располагать только пространством самого элемента ScrollViewer. А использование Hidden указывает, что содержимое располагает неограниченным пространством. То есть оно может выходить за пределы прокручиваемой области. Обычно значение Hidden используется, если предполагается выполнять прокрутку с помощью другого механизма (например, пользовательских кнопок прокрутки, которые описаны ниже). А значение Disabled используется только если нужно временно запретить элементу ScrollViewer выполнять любые действия вообще. Элемент ScrollViewer поддерживает и горизонтальную прокрутку. Однако по умолчанию свойство HorizontalScrollBarVisibility имеет значение Hidden. Для использования горизонтальной прокрутки необходимо изменить это значение на Visible или Auto. Программная прокрутка Для прокрутки окна, изображенного на рис. 6.9, можно щелкнуть мышью на полосе прокрутки, можно поместить курсор над сеткой и использовать колесико мыши, можно перемещаться по элементам управления с помощью клавиши табуляции, либо можно щелкнуть где-то на пустом месте сетки и воспользоваться клавишами управления кур- стором. Если этого вам все еще мало, можно использовать методы класса ScrollViewer для программной прокрутки содержимого. • Наиболее понятны методы LineUpO и LineDownO, которые эквивалентны щелчкам на кнопках со стрелками на концах вертикальной полосы прокрутки для смещения вверх или вниз. • Можно также использовать методы PageUp() и PageDown(), которые прокручивают содержимое на один экран вверх или вниз и эквивалентны щелчкам на поверхности полосы прокрутки выше или ниже ползунка. • Существуют аналогичные методы для горизонтальной прокрутки: LineLeftO, LineRightO, PageLeftO и PageRight().
196 Глава 6. Элементы управления • И, наконец, имеются методы ScrollToXxxO для перемещения в некоторые конкретные места. Для вертикальной прокрутки это методы ScrollToEndO и ScrollToHomeO, которые выполняют сдвиг в конец или начало прокручиваемого содержимого, и ScrollToVerticalOffset(), который выполняет сдвиг в конкретную позицию. Имеются и горизонтальные варианты этих методов: ScrollToLeftEndO, ScrollToRightEndO и ScrollToHorizontalOffset(). На рис. 6.10 показан пример, где несколько специальных кнопок позволяют перемещаться по содержимому ScrollViewer. Каждая кнопка запускает простой обработчик события, который использует один из методов из вышеприведенного списка. Специальные способы прокрутки Встроенные в ScrollViewer способы прокрутки весьма полезны. Они позволяют выполнять плавную прокрутку любого содержимого — от сложных векторных изображений до сетки с элементами. Однако одной из наиболее интересных возможностей класса ScrollViewer является то, что он позволяет содержимому принимать участие в процессе прокрутки. Вот как это работает. • Помещаете прокручиваемый элемент внутри элемента ScrollViewer. Это может быть любой элемент, реализующий интерфейс IScrollInfo. • Указываете объекту ScrollViewer, что содержимое знает, как выполнять прокрутку — для этого нужно установить свойство ScrollViewer.CanContentScroll равным true. • При задействовании элемента ScrollViewer (с помощью полосы прокрутки, колесика мыши, методов прокрутки и т.д.) он вызывает соответствующие методы вложенного элемента с помощью интерфейса IScrollInfo. Затем этот элемент выполняет собственную специализированную прокрутку. На заметку! В интерфейсе IScrollInfo определен набор методов, которые реагируют на различные действия прокрутки. Например, он содержит многие методы прокрутки, имеющиеся в ScrollViewer, такие как LineUpO, LineDownO, PageUp() и PageDown(). Кроме того, в нем имеются методы реагирования на колесико мыши. Интерфейс IScrollInfo реализован в очень немногих элементах. Одним из них является контейнер StackPanel. В его реализации IScrollInfo используется логическая прокрутка, т.е. прокрутка, которая выполняет перемещение от элемента к элементу, а не от строки к строке. | ScrollableTextBoxColumn 1*^ Ноте: Network: Web: Secondary j Home: ^ & ****! i \ Browse ' Browse Browse Browse Browse j v | • ScrollableTextBoxColumn 1 oj®KH J Line Up [Line Down| Page Up Page Down Home: Network: Web: Secondary 1 Horn*- _ Browse Browse Browse Browse Rtr.v;- л j Hi • ^— j Рис. 6.9. Окно с возможностью прокрутки Рис. 6.10. Программная прокрутка
Глава 6. Элементы управления 197 Если поместить элемент StackPanel на ScrollViewer без установки свойства CanContentScroll, то вы получите обычное поведение. Прокрутка вверх и вниз выполняет сдвиги по нескольку пикселей. Но если задать свойство CanContentScroll равным true, то при каждом щелчке вниз будет выполняться прокрутка до начала следующего элемента: <ScrollViewer CanContentScroll="True"> <StackPanel> <Button Height=00">l</Button> <Button Height=00">2</Button> <Button Height=00">3</Button> <Button Height=00">4</Button> </StackPanel> </ScrollViewer> Система логической прокрутки StackPanel может вам нравиться или не нравиться. Но она абсолютно необходима для создания пользовательской панели с особым поведением прокрутки. Элементы управления содержимым с заголовками Одним из классов, порожденных от ContentControl, является HeaderedContentControl. Это просто контейнер с содержимым (один элемент, хранится в свойстве Content) и заголовком (также один элемент, хранится в свойстве Header). Наличие заголовка отличает HeaderedContentControl от описанных ранее элементов управления содержимым. От класса HeaderedContentControl порождены три класса: GroupBox, Tabltem и Expander. Они и будут рассмотрены в последующих разделах. Класс GroupBox Класс GroupBox — наиболее простой из трех элементов управления, порожденных от HeaderedContentControl. Он имеет вид прямоугольника с закругленными углами и заголовком. Ниже приведен пример, внешний вид которого показан на рис. 6.11: <GroupBox Header="A GroupBox Test" Padding=" Margin=" VerticalAlignment="Top"> <StackPanel> <RadioButton Margin=">0ne</RadioButton> <RadioButton Margin=">Two</RadioButton> <RadioButton Margin=">Three</RadioButton> <Button Margin=">Save</Button> </StackPanel> </GroupBox> Обратите внимание, что элементу GroupBox нужен контейнер компоновки (наподобие StackPanel) для размещения содержимого. Он часто используется для группировки небольшого количества взаимосвязанных элементов управления, таких как кнопки переключателя. Однако в классе GroupBox нет никаких встроенных функций, поэтому его можно применять где угодно и как угодно. (Объекты RadioButton группируются их размещением на какой-либо панели. При этом элемент GroupBox не обязателен, если вам не нужно их окаймление с заголовком и округлыми углами.) Класс Tabltem Объекты Tabltem представляют собой страницы в элементе TabControl. Единственным существенным членом, добавленным в класс Tabltem, является свойство IsSelected, которое указывает, видима ли данная вкладка в TabControl.
198 Глава 6. Элементы управления Вот разметка, необходимая для создания простого примера, приведенного на рис. 6.12: <TabControl Margin="> <TabItem Header="Tab One"> <StackPanel Margin="> <CheckBox Margin=">Setting One</CheckBox> <CheckBox Margin=">Setting Two</CheckBox> <CheckBox Margin=">Setting Three</CheckBox> </StackPanel> </TabItem> <TabItem Header="Tab Two"> </TabItem> </TabControl> Совет. Свойство TabStripPlacement позволяет разместить закладки на боковой стороне страничного элемента, а не (как обычно) сверху. Как и свойство Content, свойство Header может содержать объект любого типа. Объекты, порожденные от класса UIElement, выводятся с помощью графической прорисовки, а текстовые и другие объекты — с помощью метода ToStringO. Это означает, что можно создать групповую панель или вкладку, заголовок которой содержит графическое содержимое или другие произвольные элементы. Вот пример: <TabControl Margin="> <TabItem> <ТаЫ tem. Header> <StackPanel> <TextBlock Margin=">Image and Text Tab Title</TextBlock> <Image Source="happyface.jpg" Stretch="None" /> </StackPanel> </TabItem.Header> <StackPanel Margin="> <CheckBox Margin=">Setting One</CheckBox> <CheckBox Margin=">Setting Two</CheckBox> <CheckBox Margin=">Setting Three</CheckBox> </StackPanel> </TabItem> <TabItem Header="Tab Two"x/TabItem> </TabControl> • ' GroupBo... IE A GroupBox Test One D Two Three Save • * TabTest Tab One Tab Two Setting One Setting Two Setting Three Рис. 6.11. Простая панель группировки Рис. 6.12. Набор вкладок
Глава 6. Элементы управления 199 Результат (несколько аляповатый) показан на рис. 6.13. Класс Expander Setting One Setting Two Setting Three Наиболее экзотичным из всех элементов управления содержимым с заголовками является Expander (расширитель). Он содержит область содержимого, которую пользователь может показать или скрыть, щелкнув на кнопочке со стрелкой. Такие элементы часто встречаются в оперативных подсказках и на веб-страницах, чтобы уместить большой объем содержимого, но не завалить пользователя информацией, которая ему не нужна. На рис. 6.14 показано два представления окна с тремя элементами Expander. Слева все три расширителя свернуты, а справа они все развернуты. (Конечно, пользователи могут по своему желанию сворачивать или разворачивать любые расширители по отдельности.) Использовать объект Expander очень просто: нужно лишь упаковать в него все сворачиваемое содержимое. Обычно все расширители первоначально свернуты, но это можно изменить в разметке (или в коде), установив для них свойство IsExpanded. * GraphicalTabTittes Image and Text Tab Title Л Tab Two Рис. 6.13. Необычный заголовок вкладки j ExpandableContent ISilJ-У 1—i—i— v Region One v Region Two v Region Three ExpandableContent l-q i Q i IL i * Region One Hidden Bui ton One A Region Two Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam mi sapien. viverra et. lacinia varius, ullamcorper sed. sapien. Proin rutrum arcu vitae tellus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Pellentesque libero dui, eleifend faucibus, auctor at, aliquet a. nulla. Nunc eros. Phasellus mauris nisi, eleifend nee. adipiscing nee. luctus nee. lacus. Aliquam justo metus, vestibulum non, accumsan id, hendrerit at, nibh. Praesent accumsan urna quis tortor. Proin erat libero, facilisis nee, rhoncus ut, malesuada ut, ipsum. Donee id nibh. Л Region Three Hidden Button Two Рис. 6.14. Скрытие содержимого с помощью разворачиваемых областей
200 Глава 6. Элементы управления Вот разметка, которая создает пример, приведенный на рис. 6.14: <StackPanel> <Expander Margin=" Padding=" Header="Region One"> <Button Padding=">Hidden Button One</Button> </Expander> <Expander Margin=" Padding=" Header="Region Two" > <TextBlock TextWrapping="Wrap"> Lorem ipsum dolor sit amet, consectetuer adipiscing elit . . . </TextBlock> </Expander> <Expander Margin=" Padding=" Header="Region Three"> <Button Padding=">Hidden Button Two</Button> </Expander> </StackPanel> Кроме того, можно указать направление развертывания элементов. На рис. 6.14 использовано стандартное значение Down (вниз), но в свойстве ExpandDirection можно указать и направления Up (вверх), Left (влево) или Right (вправо). В свернутом расширителе стрелка всегда указывает направление развертывания содержимого. Жизнь станет забавнее, если использовать различные значения ExpandDirection, т.к. влияние на остальную часть пользовательского интерфейса зависит от типа контейнера. Некоторые элементы, такие как WrapPanel, просто сдвигают остальные элементы в сторону. Другие, такие как Grid, допускают пропорциональное или автоматическое изменение размера. На рис. 6.15 приведен пример сетки с четырьмя ячейками с различными вариантами развертывания. В каждой ячейке находится расширитель с отличным от других значением ExpandDirection. Столбцы имеют пропорциональный размер, что приводит к переносам текста в расширителях. (При автоматическом изменении размера элементы просто растягиваются до размера текста, и при этом могут стать больше окна.) Строки имеют автоматически изменяемую высоту и поэтому расширяются при развертывании содержимого. Класс Expander особенно удобен для применения в WPF, т.к. в WPF поощряется использование гибкой модели компоновки, которая может легко обрабатывать динамически меняющие размер области содержимого. Если нужно синхронизировать с расширителями другие элементы управления, можно обрабатывать события Expanded и Collapsed. Несмотря на названия ("развернуто" и "свернуто"), они возникают перед тем, как содержимое появится или исчезнет. Это дает возможность реализовать "ленивый" вариант загрузки. Например, если создание содержимого элемента Expander слишком трудоемко, можно подождать, пока оно появится, и затем выбрать его. Либо может понадобиться изменить содержимое перед его выводом на экран. В любом случае такие действия можно выполнить, реагируя на событие Expanded. На заметку! Если вам нравятся возможности класса Expander, но не нравится встроенный внешний вид, не огорчайтесь. Используя систему шаблонов WPF, можно полностью настроить стрелки развертывания и свертывания, чтобы они соответствовали стилю остальных элементов приложения. Об этом будет рассказано в главе 17. Обычно при развертывании расширителя он принимает такой размер, чтобы вместить все его содержимое. Это может привести к проблемам, если размера окна не хватает для размещения всего полностью развернутого содержимого.
Глава 6. Элементы управления 201 ■ т MultiDirecfonExpaoders v Region One ж Region Three < Region Two тш^тщ lorem ipsum dolor s*t amet consectetuef adipiscing elit. Nam mi sapien, vrvefra et lacmta vanus. uWamcorper sed, sapien Region Three LVectionExpanders *• Region One Lorem ipsum dolor sit amet consectetuer adipiscing elit. Nam mi sapten, vrverra et lacima vanus. ullamcorper sed, sapien. Lorem ipsum dolor sit amet, consectetuer adipiscing eht Nam mi sapten. vrverra et laorwa vanus, ullamcorper sed, sapien. v Region Three Region Two Lorem ipsum dolor sit amet consectetuer adipiscing elit Nam mi sapien, viverra et lactma vanus. ullamcorper sed. sapien Lorem ipsum dolor sit amet consectetuer adipiscing elit. Nam mi sapien, viverra et lacima vanus. ullamcorper sed, sapien Region Three ■ • MuitiDirectionExpandeTS * Region One Л Region Three Region Two Region Three Рис. 6.15. Развертывание в различных направлениях Такие проблемы можно устранить с помощью нескольких стратегий. • Укажите для окна минимальный размер (с помощью свойств MinWidth и MinHeight), чтобы оно вмещало все даже в минимальном состоянии. • Установите для окна свойство SizeToContent, чтобы оно автоматически увеличивалось в соответствии с размерами, необходимыми для вмещения развернутого или свернутого расширителя. Обычно это свойство имеет значение Manual, но можно указать параметры Width или Height, чтобы расширять или сжимать окно по какому-либо измерению в соответствии с его содержимым. • Ограничьте размер элемента Expander, жестко закодировав его свойства Height и Width. К сожалению, это может привести к усечению содержимого, если оно не войдет в расширитель. • Создайте прокручиваемую и развертываемую область с помощью элемента ScrollViewer.
202 Глава 6. Элементы управления Как правило, реализация этих советов не вызывает затруднений. Объяснений требует лишь совместное применение элементов Expander и ScrollViewer. Для этого придется жестко закодировать размер элемента ScrollViewer — иначе он просто расширится, чтобы вместить все содержимое. Вот, к примеру: <Expander Margin=" Padding=" Header="Region Two"> <ScrollViewer Height=0"> <TextBlock TextWrapping="Wrap"> </TextBlock> </ScrollViewer> </Expander> На первый взгляд, было бы здорово иметь систему, в которой расширитель может устанавливать размер области содержимого, исходя из наличия свободного места в окне. Однако это привело бы к очевидным усложнениям. (Например, как распределить это свободное место между несколькими областями при развертывании расширителя?) Потенциальным решением мог бы стать контейнер компоновки Grid, но, к сожалению, он плохо интегрируется с элементами Expander. Если попытаться сделать так, получатся некрасивые строки, которые не изменяют высоту правильным образом при свертывании расширителей. Текстовые элементы управления В WPF имеются три текстовых элемента управления: Text Box (текстовое поле), RichTextBox (текстовое поле с форматированием) и PasswordBox (поле ввода пароля). Элемент PasswordBox порожден непосредственно от класса Control. Элементы управления TextBox и RichTextBox являются наследниками промежуточного класса TextBoxBase. В отличие от рассмотренных нами элементов управления содержимым, текстовые поля ограничены в видах содержимого. TextBox всегда хранит строку (в свойстве Text). PasswordBox тоже содержит строку текста (в свойстве Password), однако использует объект SecureString для защиты от некоторых видов атак. Только элемент RichTextBox позволяет хранить более сложное содержимое — FlowDocument, которое может состоять из сложного сочетания элементов. В последующих разделах мы рассмотрим основные возможности TextBox, а в конце кратко рассмотрим средства безопасности PasswordBox. На заметку! Класс RichTextBox является усовершенствованным элементом управления, предназначенным для отображения объектов FlowDocument. Его использование описано в главе 28 Многострочный текст Как правило, элемент TextBox хранит одну строку текста. (Допустимое количество символов можно ограничить с помощью свойства MaxLength.) Однако часто бывает нужно многострочное текстовое окно для работы с большим объемом содержимого. Для этой цели нужно присвоить свойству TextWrapping значение Wrap или WrapWithOverf low. При значении Wrap текст всегда разрывается на краю элемента управления, даже если придется разбить слишком длинные слова. WrapWithOverf low позволяет некоторым строкам выйти за правый край, если не найдется подходящее место (например, пробел или дефис) для разбиения строки. Чтобы текстовое поле действительно содержало несколько строк, оно должно иметь достаточные размеры. Вместо указания жестко закодированной высоты (которая не по-
Глава 6. Элементы управления 203 дойдет для разных размеров шрифтов и может вызвать проблемы с компоновкой) лучше использовать свойства MinLines и MaxLines. Свойство MinLines определяет минимальное количество строк, которые должны отображаться в текстовом поле. Например, если этому свойству присвоить значение 2, то высота текстового поля будет равна высоте минимум двух строк текста. Свойство MaxLines задает максимальное количество отображаемых строк. Даже если текстовое окно будет развернуто до размеров контейнера (например, строка Grid с пропорциональным размером или последний элемент в DockPanel), оно не превысит заданный лимит. На заметку! Свойства MinLines и MaxLines не влияют на объем содержимого, которое можно поместить в текстовом поле Они просто придают нужный размер текстовому окну. В коде можно проверить свойство LineCount, чтобы узнать точно, сколько строк умещается в текстовом окне Если текстовое поле поддерживает переносы строк, то пользователь может ввести и больше текста, чем может быть отображено в видимых строках. По этой причине обычно имеет смысл добавить постоянно видимую (или отображаемую по запросу) полосу прокрутки, присвоив свойству VerticalScrollBarVisibil'ity значение Visible или Auto. (Можно задать и свойство HorizontalScrollBarVisibility, чтобы отображать реже используемую горизонтальную полосу прокрутки.) Иногда нужно, чтобы пользователь мог вводить разрывы строк в многострочном текстовом поле, нажимая клавишу <Enter>. (Обычно при нажатии клавиши <Enter> в текстовом поле срабатывает кнопка по умолчанию.) Чтобы текстовое поле поддерживало клавишу <Enter>, присвойте свойству AcceptsReturn значение true. Можно также задать свойство Accepts Tab, чтобы пользователь мог вставлять символы табуляции. Иначе при нажатии клавиши <ТаЬ> фокус будет передаваться следующему элементу управления в последовательности переходов с помощью клавиши табуляции. Совет. Класс TextBox содержит также много методов, которые позволяют программно перемещаться по текстовому содержимому небольшими или крупными шагами. Это LineUpO, LineDownO, PageUp (), PageDownO, ScrollToHomeO, ScrollToEndO и ScrollToLine (). Иногда текстовые поля создаются исключительно для отображения текста. В этом случае задайте свойство Is Readonly равным true, чтобы запретить редактирование текста. Это лучше блокирования текстового поля путем присваивания свойству IsEnabled значения false, т.к. заблокированное текстовое окно выводит серый текст (его труднее читать), не поддерживает выделение текста (или копирование в буфер обмена) и его прокрутку. Выделение текста Как известно, в любом текстовом поле можно выделить текст — щелкнув кнопкой мыши и переместив ее указатель или с помощью клавиш управления курсором при прижатой клавише <Shift>. Класс TextBox дает возможность определить или изменить выделенный в данный момент текст программным образом, используя свойства SelectionStart, SelectionLength и SelectedText. Свойство SelectionStart определяет позицию, с которой начинается выделение текста (нумерация с нуля). Например, если этому свойству присвоить значение 10, то первым выделенным символом будет одиннадцатый символ в текстовом поле. Свойство SelectionLength указывает общее количество выделенных символов. (Нулевое значение говорит о том, что не было выделено ни одного символа.) И, наконец, свойство SelectedText позволяет быстро проверить или изменить выделенный текст в тексто-
204 Глава 6. Элементы управления вом поле. Реагировать на изменение выделения можно с помощью обработки события SelectionChanged. На рис. 6.16 показан пример, который реагирует на это событие и выводит информацию о текущем выделении текста. В классе TextBox имеется также свойство AutoWordSelection, позволяющее управлять поведением выделения. Если оно равно true, то выделение в текстовом окне будет производиться по словам. Еще одной полезной функцией элементов TextBox является возможность отмены последних изменений. Такую отмену можно выполнить программно (используя метод UndoO) и с помощью сочетания клавиш <Ctrl+Z> — если свойство CanUndo не равно false. Совет. При программной обработке текста в текстовом поле можно использовать методы BeginChangeO и EndChangeO для оформления последовательности действий, которую элемент TextBox будет считать единым блоком изменений. Эти действия можно затем отменить одним шагом. Проверка правописания У элемента TextBox есть необычная возможность — встроенная проверка правописания, при которой нераспознанные слова подчеркиваются красной волнистой линией. Пользователь может щелкнуть правой кнопкой мыши на нераспознанном слове и выбрать из списка правильный вариант, как показано на рис. 6.17. Чтобы включить функцию проверки правописания в элементе TextBox, нужно просто задать свойство зависимости SpellCheck.IsEnabled: <TextBox SpellCheck.IsEnabled=MTrueM>...</TextBox> Проверка правописания встроена в WPF и не зависит от любого другого программного обеспечения (например, Office). Функция проверки правописания определяет необходимый словарь на основании выбранного пользователем языка ввода на клавиатуре. Можно явно указать словарь с помощью свойства Language элемента TextBox, унаследованного от класса FrameworkElement, или с помощью атрибута xmlilang в элементе <TextBox>. Однако в настоящее время проверка правописания ограничена четырьмя языками: английским, испанским, французским и немецким. Для французского и немецкого языков можно с помощью свойства SpellingReform указать, применять ли правила орфографии, введенные после 1990 г. : » TextBoxTest С£ЭкёяКя1 J The Ministry of Truth contained, it was said, three thousand rooms j above ground level, and corresponding ramifications below. I Scattered about London there were just three other butldings of j simitar appearance and size So completely did they dwarf the j surrounding architecture that from the roof of Victory Mansions you _ i! could see ail four of them simultaneously. They were the homes of the four Ministries between which the entire apparatus of i government was divided The Ministry of Truth, which concerned j: itself with news, entertainment, education, and the fine arts. The ; Ministry of Peace, which concerned itself with war. The Ministry of Love, which maintained law and order. And the Ministry of Plenty, » ; I Current selection: Selection from 363 to 104 is "They were the homes of the four Ministries И between which the entire apparatus of government was divided.' Рис. 6.16. Выделение текста Ир i was divided. The Ministry of Truth, which concerned itself with i news, entertainment education, and the fine arts. The Ministry of Peace, which concerned itself with war The Ministry of Love, j j which maintained law and order. And the Ministry of Plenty. J: which was responsible for economic affairs Their names, in : Newspeatc: ifaums. МШЦШ. ЫШш, and Mlfltftifnty. Miniature Miniatures Ignore All I Current selection: j j Selection from 7521< Cut Ctrl*X Copy Ctrl* С Paste Ctrti-V Рис. 6.17. Проверка правописания в текстовом поле
Глава 6. Элементы управления 205 В предыдущих версиях WPF функция проверки правописания не поддерживала пользовательскую настройку. В WPF 4 можно добавить список слов, которые не следует считать ошибочными (они также, когда нужно, будут включаться в предлагаемые варианты исправления при щелчке правой кнопкой). Для этого необходимо вначале создать файл Лексикона, который представляет собой просто текстовый файл с расширением .lex. Этот файл должен содержать список слов, по одному слову в строке в алфавитном порядке: acantholysis atypia bulla chromonychia dermatoscopy desquamation В этом примере слова используются независимо от текущего языка. Но можно указать, что лексикон должен применяться только к конкретному языку, добавив идентификатор локали. Вот как можно указать, что перечисленные слова должны использоваться, только если текущим языком является английский: #LID 1033 acantholysis atypia bulla chromonychia dermatoscopy desquamation Кроме этого, поддерживаются идентификаторы локалей 3082 (испанский), 1036 (французский) и 1031 (немецкий). На заметку! Возможность добавления пользовательского словаря не предназначена для использования дополнительных языков. Она просто добавляет перечисленные слова в уже поддерживаемый язык (например, английский). Например, такой дополнительный словарь можно использовать для распознавания имен или медицинских терминов. После создания файла лексикона проверьте свойство SpellCheck.IsEnabled для данного элемента Text Box: оно должно быть равно true. И теперь остается только с помощью свойства SpellCheck.CustomDictionaries прикрепить объект Uri, указывающий на пользовательский словарь. Если вы захотите указать его средствами XAML, как в нижеприведенном примере, вначале нужно импортировать пространство имен System, чтобы иметь возможность объявить объект Uri в разметке: <Window xmlns:sys="clr-namespace:System;assembly=system" ... > Можно использовать сразу несколько пользовательских словарей, только нужно добавить по объекту Uri для каждого. В любом таком объекте можно жестко закодировать путь к файлу на локальном диске или в сетевом каталоге. Но надежнее всего воспользоваться ресурсом приложения. Например, если в проект с именем SpellTest добавлен файл CustomWords.lex, а в его свойстве Build Action задано значение Resource (с помощью Solution Explorer), то можно использовать примерно такую разметку: <TextBox TextWrapping=MWrap" SpellCheck.IsEnabled="True" Text="Now the spell checker recognizes acantholysis and offers the right correction for acantholysi">
206 Глава 6. Элементы управления <SpellCheck.CustomDictionaries> <sys:Uri>pack://application:,,,/SpellTest;component/CustomWords.lex</sys:Uri> </SpellCheck.CustomDictionaries> </TextBox> Странный фрагмент pack://арplication:,,,/ в начале URI используется в WPF для указания на ресурс сборки. Более подробно о ресурсах будет рассказано в главе 7. Если понадобится загрузить файл лексикона из каталога приложения, проще всего создать необходимый URI в коде и добавить его в коллекцию SpellCheck. CustomDictionaries при инициализации окна. Класс PasswordBox Элемент PasswordBox похож на элемент Text Box, однако он отображает строку символов-кружочков, скрывающих настоящие символы. (С помощью свойства PasswordChar можно выбрать другой скрывающий символ.) Кроме того, PasswordBox не поддерживает работу с буфером обмена, поэтому вы не сможете скопировать содержащийся в нем текст. По сравнению с классом TextBox класс PasswordBox имеет более простой интерфейс. Как и TextBox, он содержит свойство MaxLength, методы Clear(), Paste() и SelectAllO, а также событие PasswordChanged, которое возникает в случае изменения текста. А главное отличие этого элемента управления от TextBox находится в его внутренностях. Свойство Password позволяет задать текст и прочитать его как обычную строку, но внутри элемент PasswordBox использует исключительно объект System. Security. Securest ring. SecureString — это текстовый объект, подобный обычной строке, но он хранится в памяти в зашифрованном виде. Ключ, который используется для шифрования строки, генерируется случайным образом и хранится в той части памяти, которая никогда не записывается на диск. Поэтому даже если произойдет поломка компьютера, злоумышленники не смогут извлечь данные пароля из файла подкачки страниц. В крайнем случае они найдут лишь зашифрованную форму. Класс SeruteString также имеет возможность освобождения по запросу. При вызове метода SecureString.Dispose() данные пароля, находящиеся в памяти, перезаписываются. Это гарантирует, что вся информация о пароле будет стерта из памяти, и никто не сможет ею воспользоваться. Естественно, PasswordBox вызывает метод Dispose() для хранимого объекта SecureString при уничтожении элемента управления. Элементы управления списками WPF содержит много элементов управления, работающих с коллекциями элементов: от простых элементов List Box и ComboBox, которые будут рассмотрены здесь, и до более специализированных элементов, таких как ListView, TreeView и ToolBar, которые будут рассмотрены в последующих главах. Все эти элементы управления являются потомками класса ItemsControl (а он порожден от класса Control). Класс ItemsControl содержит базовые механизмы, которые используются всеми элементами управления списками. Он предоставляет два способа заполнения списка элементов. Наиболее простым способом является добавление элементов прямо в коллекцию Items с помощью кода или XAML. Однако в WPF чаще применяется привязка данных. В этом случае свойству ItemsSource присваивается объект, содержащий коллекцию элементов данных, которые нужно отобразить. (О привязке данных речь пойдет в главе 19.) Иерархия классов, которая начинается с ItemsControls, несколько запутана. Одной ее большой ветвью являются селекторы (selector), к которым относятся ListBox, ComboBox и TabControl. Эти классы являются потомками класса Selector
Глава 6. Элементы управления 207 и имеют свойства, позволяющие определить выделенный в данный момент элемент (Selectedltem) или его позицию (Selectedlndex). Кроме того, имеются элементы управления, которые могут содержать списки элементов, но не поддерживают выбор. К ним относятся классы для меню, панелей инструментов и деревьев — все они порождены от Items Controls, но не являются селекторами. Чтобы использовать большинство возможностей любого наследника ItemsControl, необходимо использовать привязку данных. Это нужно делать даже тогда, когда не нужна выборка данных из базы или из внешнего источника данных. Привязка данных в WPF без проблем справляется с данными в различных формах, включая специальные объекты данных и коллекции. Однако пока что мы не будем рассматривать подробности привязки данных. Сейчас мы лишь бегло рассмотрим классы ListBox и ComboBox. Класс ListBox Класс ListBox представляет распространенный компонент среды Windows — списки переменной длины, которые позволяют пользователю выбрать один из элементов. На заметку! Класс ListBox допускает множественный выбор, если его свойству SelectionMode присвоить значение Multiple или Extended. В режиме Multiple можно выбрать любой элемент или отменить его выбор, щелкнув на нем. В режиме Extended необходимо прижать клавишу <Ctrl>, чтобы выбрать дополнительные элементы, или клавишу <Shift>, чтобы выбрать диапазон элементов. В любом виде списка с множественным выбором для получения всех выделенных элементов вместо свойства Selectedltem используется коллекция Selectedltems Чтобы добавить элементы в элемент ListBox, можно вложить в него элементы ListBoxItem. Например, вот элемент ListBox, который содержит список цветов: <ListBox> <ListBoxItem>Green</ListBoxItem> <ListBoxItem>Blue</ListBoxItem> <ListBoxItem>Yellow</ListBoxItem> <ListBoxItem>Red</ListBoxItem> </ListBox> В главе 2 было сказано, что разные элементы управления обрабатывают вложенное в них содержимое по-разному. Объект ListBox хранит все вложенные объекты в своей коллекции Items. ListBox является довольно гибким элементом управления. Он может хранить не только объекты ListBoxItem, но и любые произвольные элементы. Ведь класс ListBoxItem является наследником класса ContentControl, который позволяет хранить фрагменты вложенного содержимого. Если такой фрагмент является классом, порожденным от UIElement, то он будет отображен'в элементе ListBox. Если же это другой тип объекта, ListBox вызовет метод ToStringO и выведет полученный текст. Например, создать список с изображениями можно с помощью следующей разметки: <ListBox> <ListBoxItem> <Image Source=Mhappyface.jpg"></Image> </ListBoxItem> <ListBoxItem> <Image Source="happyface.jpg"></Image> </ListBoxItem> </ListBox>
208 Глава 6. Элементы управления Объекты ListBox способны неявно создавать необходимые им объекты ListBoxItem. Это означает, что объекты можете помещать прямо внутрь элемента ListBox. Ниже представлен более сложный пример, в котором вложенные объекты StackPanel используются для комбинирования текста и изображений: <ListBox> <StackPanel Orientation="Horizontal1^ <Image Source="happyface . jpg" Width=,,30" Height=,,30,,x/Image> <Label VerticalContentAlignment=llCenter">A happy face</Label> </StackPanel> <StackPanel Orientation="Horizontal1^ <Image Source="redx.jpg" Width=0" Height=0"></Image> <Label VerticalContentAlignment="Center">A warning sign</Label> </StackPanel> <StackPanel Orientation="Horizontal"> <Image Source="happyface . jpg" Width=0" Height=0"x/Image> <Label VerticalContentAlignment="Center">A happy face</Label> </StackPanel> </ListBox> В этом примере элемент StackPanel становится элементом списка, содержащегося в ListBoxItem. Эта разметка создает усовершенствованный список, показанный на рис. 6.18. j . {Ш A happy face ФЭ A wvarmng s»gn ; {Ш A happy face -... .. ,- Рис. 6.18. Список изображений На заметку! В данном примере цвет текста при выборе элемента не изменяется. Это не очень хорошо, поскольку черный текст на синем фоне прочитать трудно. Для устранения этой проблемы необходимо использовать шаблон данных (см. главу 20). Возможность заносить в списки произвольные элементы позволяет создавать различные основанные на списке элементы управления, не используя при этом другие классы. Например, в Windows Forms имеется специальный класс CheckedListBox, отображаемый как список с флажками около каждого элемента. В WPF для этого не нужен никакой специальный класс, поскольку его можно быстро создать с помощью стандартного объекта ListBox: <ListBox Name="lst11 SelectionChanged="lst_SelectionChanged11 Checkbox.Click="Ist_SelectionChanged"> <CheckBox Margin=ll3">Option K/CheckBox> <CheckBox Margin=,l>Option 2</CheckBox> </ListBox>
Глава 6. Элементы управления 209 При использовании списка, содержащего разные элементы, имейте в виду, что при считывании значения Selectedltem (а также коллекций Selectedltems и Items) вы не увидите объекты ListBoxItem — вместо них вы увидите те объекты, которые занесены в список. В примере с элементом CheckedListBox это означает, что Selectedltem предоставляет объект Checkbox. Например, ниже приведен код, который реагирует на событие SelectionChanged. Затем он получает выделенный в данный момент CheckBox и показывает, был ли этот элемент отмечен: private void lst_SelectionChanged(object sender, SelectionChangedEventArgs e) { if Ast.Selectedltem == null) return; txtSelection.Text = String.Format( "You chose item at position {0} . \r\nChecked state is {l}.11, 1st.Selectedlndex, ((CheckBoxIst.Selectedltem).IsChecked); } Совет. Если нужно найти выделенный в данный момент элемент, его можно прочитать непосредственно из свойства Selectedltem или Selectedltems, как показано здесь. Если нужно определить, с какого элемента был снят выбор (если такое вообще было), можно воспользоваться свойством Removedltems объекта SelectionChangedEventArgs. Аналогично, свойство Addedltems сообщает, какие элементы были добавлены в число выбранных. В режиме выбора одного элемента при изменении выбора всегда может быть добавлен лишь один элемент, и удален тоже только один элемент. В режиме множественного выбора или в расширенном режиме так бывает не всегда. В следующем фрагменте кода выполняется перебор коллекции элементов, чтобы найти среди них отмеченные. (Можно написать похожий код, который будет перебирать коллекцию выбранных элементов в списке множественного выбора с флажками.) private void cmd_ExamineAllitems(object sender, RoutedEventArgs e) { StringBuilder sb = new StringBuilder(); foreach (CheckBox item in 1st.Items) { if (item. IsChecked == true) { sb.Append(item.Content); sb. Append (" is checked.11); sb.Append("\r\n"); } } txtSelection.Text = sb.ToString(); } На рис. 6.19 показан список, в котором используется этот код. Помещая вручную элементы в список, вы должны сами решить, помещать ли их непосредственно или упаковать каждый из них в объект ListBoxItem. Второй подход часто более понятен, хотя и более трудоемок. Самое важное при этом — быть последовательным. Например, если поместить в список объекты StackPanel, то объект ListBox.Selectedltem будет иметь тип StackPanel. А если поместить туда объекты StackPanel, упакованные в объекты ListBoxItem, то объект ListBox.Selectedltem будет иметь тип ListBoxItem, что следует учесть в коде.
210 Глава 6. Элементы управления ! Current selection: You chose item at position 1. ! Checked state is True Examine All Items Рис. 6.19. Список флажков В классе ListBoxItem имеется небольшая дополнительная возможность: в нем определено свойство IsSelected, значение которого можно считывать (или устанавливать), и события Selected и Unselected, которые сообщают о выделении данного элемента. Однако похожие возможности можно получить с помощью членов класса ListBox — свойства Selectedltem (или Selectedltems) и события SelectionChanged. Интересно, что при использовании подхода с вложенными объектами существует технология получения оболочки ListBoxItem для конкретного объекта. Хитрость заключается в вызове метода ContainerFromElement(). Ниже показан код, который с помощью этого приема проверяет, был ли выделен первый элемент в списке: ListBoxItem item = (ListBoxItemIst.ContainerFromElement ( (DependencyObjectIst.Selectedltems[0]); MessageBox.Show("IsSelected: " + item.IsSelected.ToString()) ; Класс ComboBox Элемент ComboBox похож на элемент ListBox. Он хранит коллекцию объектов ComboBoxItem, которые создаются явным или неявным образом. Как и ListBoxItem, ComboBoxItem является элементом управления содержимым, который может хранить любой вложенный элемент. Основным различием классов ComboBox и ListBox является способ их отображения в окне. Элемент ComboBox использует раскрывающийся список, а это значит, что за один раз можно выбрать только один элемент. Если нужно сделать так, чтобы пользователь мог выбрать элемент в ComboBox, введя текст в текстовом поле, необходимо присвоить свойству IsEditable значение true. Кроме того, нужно сохранять только обычные текстовые объекты ComboBoxItem или объекты с осмысленным представлением ToString(). Например, если заполнить редактируемый раскрывающийся список объектами Image, то текст, который появится в верхней части, будет полностью определен именем класса Image, а это вряд ли то, что надо. Одним из ограничений элемента ComboBox является способ подгонки при автоматическом выборе размера. ComboBox выбирает такую ширину, чтобы вместить свое содержимое, т.е. изменяет размер при переходе от одного элемента к другому. К сожалению, нет легкого способа указать ComboBox принять размер наибольшего элемента. Вместо этого приходится указывать жестко закодированное значение свойства Width, что очень неудобно.
Глава 6. Элементы управления 211 Элементы управления, основанные на диапазонах значений В WPF имеются три элемента управления, использующих концепцию диапазонов (range). Такие элементы принимают числовое значение, которое находится в диапазоне между заданными минимальным и максимальным значениями. Эти элементы управления — ScrollBar, ProgressBar и Slider — порождены от класса RangeBase (который является наследником класса Control). Но хотя они используют одну и ту же абстракцию (диапазон), работают они по-разному. Класс RangeBase определяет свойства, перечисленные в табл. 6.4. Таблица 6.4. Свойства класса RangeBase Имя Описание Value Текущее значение элемента управления (которое должно находиться между минимумом и максимумом). По умолчанию оно начинается с 0. Обратите внимание: значение Value не является целочисленным — оно имеет тип double, т.е. может иметь дробные значения. Если нужно получать уведомления об изменении значения, можно реагировать на событие ValueChanged Maximum Верхний предел (максимальное допустимое значение) Minimum Нижний предел (минимальное допустимое значение) SmallChange Величина, на которую уменьшается или увеличивается значение свойства Value при "малом изменении". Смысл малого изменения зависит от элемента управления (и может вообще не использоваться). Для элементов ScrollBar и Slider это величина изменения значения при нажатиях клавиш управления курсором. Для элемента ScrollBar для этого можно применять кнопки со стрелочками на концах полосы прокрутки LargeChange Величина, на которую уменьшается или увеличивается значение свойства Value при "большом изменении". Смысл большого изменения зависит от элемента управления (и может вообще не использоваться). Для элементов управления ScrollBar и Slider это величина изменения значения при нажатиях клавиш <Page Up> и <Раде Down> или при щелчках на полосе с любой стороны ползунка (который указывает текущую позицию) Как правило, элемент управления ScrollBar не используется непосредственно. Чаще применяется элемент управления более высокого уровня ScrollViewer, который объединяет два элемента ScrollBar. Более удобными и зачастую более полезными являются Slider и ProgressBar. Класс Slider Класс Slider (движок) является специализированным элементом управления, который часто бывает очень полезным. Им можно воспользоваться для задания числовых значений в тех ситуациях, когда само число не особенно важно. Например, громкость в проигрывателе лучше всего устанавливать, сдвигая ползунок на линейке. Позиция ползунка показывает относительную громкость (нормально, тихо, громко), а представляющее ее число не представляет особого смысла для пользователя. Ключевые свойства класса Slider определены в классе RangeBase. Кроме них, можно использовать все свойства, перечисленные в табл. 6.5.
212 Глава 6. Элементы управления Таблица 6.5. Дополнительные свойства в классе Slider Имя Описание Orientation Delay и Interval TickPlacement TickFrequency Ticks IsSnapToTickEnabled IsSelectionRangeEnabled Устанавливает вертикальную или горизонтальную ориентацию элемента Управляет скоростью перемещения ползунка вдоль линейки, когда пользователь щелкает и удерживает нажатой клавишу мыши с любой стороны ползунка. Оба значения задаются в миллисекундах. Delay — это время, по истечении которого ползунок переместится на одну единицу (малое изменение) после щелчка, a Interval — время, после которого он продолжит перемещение, если удерживать прижатой кнопку мыши Определяет размещение отметок возле линейки, которые помогают визуализировать шкалу. По умолчанию свойство TickPlacement имеет значение None, и отметки не отображаются. При горизонтальной ориентации отметки можно поместить над линейкой (TopLeft) или под ней (BottomRight), а при вертикальной — слева (TopLeft) или справа (BottomRight). (Имена для свойства TickPlacement могут запутать, т.к. два значения используются для четырех вариантов расположения отметок.) Задает интервал между отметками, определяя их количество. Например, их можно помещать через каждые 5 числовых единиц, каждые 10 и т.д. Используется для помещения отметок в нерегулярных позициях. Просто добавьте в эту коллекцию по одному числу (типа double) для каждой отметки. Например, можно поместить отметки в позиции 1, 1.5, 2 и 10 на линейке, добавив эти числа Если равно true, то при перемещении ползунка он автоматически перепрыгивает к ближайшей отметке. По умолчанию это свойство равно false Если равно true, можно использовать числовой диапазон для затенения участка линейки прокрутки. Этот диапазон задается свойствами SelectionStart и SelectionEnd. Сам по себе диапазон выбора не имеет значения, но его можно использовать его для произвольных целей. Например, в проигрывателях затенение линейки иногда используется, чтобы показать процесс загрузки файла На рис. 6.20 показаны элементы управления Slider с разными параметрами отметок. Класс ProgressBar Элемент управления ProgressBar (индикатор прогресса) показывает ход выполнения длительной задачи. В отличие от движка, ProgressBar не является интерактивным элементом управления. Наоборот, за периодическое изменение значения свойства Value отвечает исключительно ваш код. (Строго говоря, по правилам WPF ProgressBar не должен считаться элементом управления, поскольку он не реагирует на действия мыши или ввод с клавиатуры.) Минимальная высота элементов ProgressBar равна четырем не зависящим от устройства единицам. Вы должны самостоятельно установить свойство Height (или поместить его в подходящий контейнер с фиксированными размерами), если хотите видеть большую полосу более традиционного вида.
Глава 6. Элементы управления 213 | Slider with Tide Marks (TkkFrequency» 10. TidcPlacement=8ottomRight) ' SJIdersCompared Normal Slider (Max= -laj iuu, vai=iu) Slider with Irregular Tick Marks (Ticks«0,5,10.15.25.50.100) Slider with a Selection Range (IsSelectK>nRangeEnabled=True SelectionStart=25, SelectionEnd=75) Рис. 6.20. Элементы Slider с различными вариантами отметок Элемент ProgressBar можно использовать для отображения "долгоиграющего" индикатора состояния, когда неизвестно, как долго будет длиться задача. Интересно (и странно), что для этого нужно присвоить свойству Islndeterminate значение true: <ProgressBar Height=8" Width=00" IsIndeterminate="True"> </ProgressBar> После установки Islndeterminate свойства Minimum, Maximum и Value уже не нужны, т.к. индикатор прогресса будет показывать периодический зеленый импульс, двигающийся слева направо — универсальное в среде Windows указание на выполнение какой-либо задачи. Эту разновидность индикатора удобно применять в строке состояния приложения, например, чтобы показать, что выполняется соединение с удаленным сервером для передачи информации. Элементы управления датами В WPF появились два элемента управления датами: Calendar и DatePicker. Оба они позволяют выбрать дату, т.е. день календаря. Элемент Calendar выводит календарь, похожий на тот, который использует операционная система Windows (например, при настройке системной даты). На нем показан лишь один месяц с возможностью переходить к соседнему месяцу (с помощью кнопочек со стрелками) или к конкретному месяцу (с помощью заголовка, который позволяет увидеть сразу целый год и выбрать нужный месяц). Элемент DatePicker занимает меньше места. Он имеет вид простого текстового поля, которое содержит строку с датой в длинном или коротком формате. Справа у него есть кнопочка со стрелкой, при щелчке на которой раскрывается полное представление календаря, идентичное элементу Calendar. Этот календарь выводится поверх другого содержимого, как и раскрывающийся список ComboBox. На рис. 6.21 показаны две модели отображения, которые поддерживает элемент Calendar, а также два формата дат в элементе DatePicker. Свойства классов Calendar и DatePicker позволяют определить даты, которые отображаются, и даты, которые можно выбирать (если они находятся в непрерывном диапазоне). Доступные свойства этих классов приведены в табл. 6.6.
214 Глава 6. Элементы управления ■ DateControls « Su 31 7 14 21 28 7 February. 2010 Mo Tu We Th Fr 12 3 4 5 8 9 10 11 12 IS 16 Q 18 19 22 23 24 25 26 12 3 4 5 8 9 10 11 12 ► Sa 6 13 20 27 6 13 4 Jan May Sep 2010 Feb Jun Oct Mar Jul Nov ► Apr Aug Dec 3/10/2010 Wednesday, March 10, 2010 4 March. 2010 ► Su Mo Tu We Th Fr Sa 28 1 2 3 4 5 6 7 8 9 [g] 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 1 2 3 4 5 6 7 8 9 10 El' sal TF Рис. 6.21. Примеры элементов Calendar и DatePicker Таблица 6.6. Свойства классов Calendar и DatePicker Свойство Описание DisplayDateStart и DisplayDateEnd BlackoutDates SelectedDate SelectedDates Устанавливают диапазон дат, которые выводятся в календаре — от самой ранней даты (DisplayDateStart) до самой поздней (DisplayDateEnd). Пользователь не может перейти к месяцам, в которых нет ни одной отображаемой даты. Чтобы разрешить все даты, можно занести в свойство DisplayDateStart значение DateTime.MinValue, а в свойство DisplayDateEnd — значение DateTime.MaxValue Содержит коллекцию дат, которые будут в календаре не активными, т.е. их невозможно выбрать. Если эти даты не входят в диапазон отображаемых дат, или одна из этих дат уже выбрана, возникает исключение. Чтобы запретить выбор любой прошедшей даты, вызовите метод BlackoutDates.AddDatesInPast() Предоставляет выбранную дату в виде объекта DateTime (или пустое значение, если не выбран ни один день). Его можно задать программно, щелчком на одном из дней в календаре или вводом строки в поле даты (элемента DatePicker). В представлении календаря выбранная дата отмечается затененным квадратиком, который виден лишь тогда, когда данный элемент имеет фокус ввода Содержит выбранные даты в виде коллекции объектов DateTime. Это свойство поддерживается классом Calendar и имеет смысл только тогда, когда установлено свойство SelectlonMode, позволяющее выбрать несколько дат
Глава 6. Элементы управления 215 Окончание табл. 6.6 Свойство Описание DisplayDate FirstDayOfWeek Определяет дату, которая выводится при первоначальном отображении календаря (с помощью объекта DateTime). Если она пуста, то выводится значение SelectedDate. Если пусты оба значения DisplayDate и SelectedDate, то используется текущая дата. Отображаемая дата определяет первоначально выводимый месяц в отображении календаря. Когда элемент имеет фокус, указанный день этого месяца выделяется квадратной рамочкой (которая отличается от затененного квадратика, применяемого для выбранной в данный момент даты) Определяет день недели, который выводится в начале (слева) каждой строки календаря IsTodayHighlighted Определяет, должна ли быть выделена в календаре текущая дата DisplayMode (только Calendar) SelectionMode (только Calendar) IsDropDownOpen (только DatePicker) SelectedDateFormat (только DatePicker) Задает первоначальный вид календаря. Если равно Month, то в календаре выводится один месяц. Если равно Year, то выводится список месяцев в текущем году (как будто пользователь щелкнул на заголовке месяца) После щелчка на конкретном месяце появится полное представление этого месяца Определяет вид разрешенного выбора даты. По умолчанию равно SingleDate, что означает разрешение выбора только одной даты. Другие варианты — None (выбор полностью запрещен), SingleRange (можно выбрать последовательный диапазон дат) и MultipleRange (допустимо любое сочетание дат). В режимах SingleRange и MultipleRange пользователь можно выбрать несколько дат с помощью мыши (наподобие перетаскивания) или щелкая на датах с прижатой клавишей <Ctrl>. Коллекцию всех выбранных дат можно получить с помощью свойства SelectedDates Определяет, должен ли быть открыт раскрывающийся список в элементе DatePicker. Это свойство можно задавать программно, чтобы показать или скрыть календарь Определяет отображение выбранной даты в текстовой части элемента DatePicker. Допустимые варианты — Short (краткий формат) или Long (длинный формат). Конкретный формат отображения даты зависит от региональных настроек клиентского компьютера. Например, при значении Short дата может быть выведена в формате гггг/мм/дд или дд/мм/гггг. Длинный формат обычно содержит названия месяца и дня Элементы управления датами также предоставляют несколько различных событий. Наиболее полезно из них событие SelectedDateChanged в классе DatePicker или аналогичное событие SelectedDatesChanged в классе Calendar, которые позволяют поддерживать выбор нескольких дат Реагируя на эти события, можно отменить выбор отдельных дат (например, дат, выпадающих на выходные): private void Calendar_SelectedDatesChanged (object sender, CalendarDateChangedEventArgs e) // Проверка всех добавленных элементов, foreach (DateTime SelectedDate in e.Addedltems) { if ((SelectedDate.DayOfWeek == DayOfWeek.Saturday) || (SelectedDate.DayOfWeek == DayOfWeek.Sunday))
216 Глава 6. Элементы управления { lblError.Text = "Weekends are not allowed"; // Удаление выбранной даты. ((Calendar)sender).SelectedDates.Remove(selectedDate); } } } Опробуйте это поведение с элементом Calendar, который поддерживает выбор одной или нескольких дат. Если он поддерживает выбор нескольких дат, попробуйте выбрать мышью целую неделю. Все выбранные даты будут отмечены, кроме выходных дней, выбор с которых будет снят автоматически. Класс Calendar содержит также событие DisplayDateChanged (когда пользователь переходит к новому месяцу). В классе DatePicker добавлены события CalendarOpened и CalendarClosed (которые возникают при раскрытии и сокрытии раскрывающегося списка) и DateValidationError (которое возникает при вводе пользователем строки, которая не представляет собой верную дату). Обычно неправильные значения отбрасываются, когда пользователь открывает представление календаря, но имеется возможность вывода текста, чтобы сообщить пользователю о проблеме: private void DatePicker_DateValidationError (object sender, DatePickerDateValidationErrorEventArgs e) { lblError.Text = " ' " + e.Text + 111 is not a valid value because " + e. Exception .Message; } Резюме В этой главе были рассмотрены базовые элементы управления WPF, в том числе базовые компоненты — метки, кнопки, текстовые поля и списки. Попутно вы ознакомились с некоторыми важными концепциями WPF, которые лежат в основе модели элементов управления: кисти, шрифты и содержимое. Большинство элементов управления WPF просты в использовании, но разработчикам, глубже понимающим их — и понимающим их взаимосвязь — будет легче создавать аккуратные и эффективные окна.
ГЛАВА 7 Класс Application Во время выполнения каждое приложение WPF представлено экземпляром класса System.Windows.Application. Этот класс отслеживает все открытые окна в приложении, решает, когда приложение должно быть остановлено, и инициирует события приложения, которые можно обрабатывать для выполнения инициализации и очистки. В этой главе детально рассматривается класс Application. Вы узнаете, как его использовать для решения таких задач, как перехват необработанных исключений, отображение экрана заставки и извлечение параметров командной строки. Будет даже рассмотрен пример обработки экземпляров и зарегистрированных типов файлов, что позволяет приложению управлять неограниченным количеством экземпляров. После описания структуры, лежащей в основе класса Application, будет показано, как создаются ресурсы сборок. Каждый ресурс — это фрагмент двоичных данных, встроенных в исполняемое приложение. Ресурсы являются блестящим репозиторием для изображений, звуков и даже локализованных данных для множества языков. Что нового? Единственное отличие модели приложения WPF 4 — появление средства для показа экрана заставки (splash screen), которое описано в разделе "Отображение экрана заставки". Жизненный цикл приложения В WPF приложение проходит через простой жизненный цикл. Вскоре после запуска приложения создается объект Application. Во время его выполнения возникают различные события приложения, которые можно отслеживать. И, наконец, когда объект приложения освобождается, приложение завершается. На заметку! WPF позволяет создавать полноценные приложения, создающие иллюзию работы в веб-браузере. Эти приложения называются ХВАР, и в главе 24 вы узнаете, как создавать их (и как использовать преимущества браузерной страничной навигационной системы). Однако относительно ХВАР-приложений следует отметить, что в них применяется тот же класс Application, генерируются те же события жизненного цикла и используются ресурсы сборки таким же способом, что и в стандартных приложениях на основе WPF. Создание объекта Application Простейший способ использования класса Application предусматривает его создание вручную. В следующем примере демонстрируется абсолютный минимум: точка входа в приложение (метод Main ()), которая создает окно по имени Windowl и запускает новое приложение.
218 Глава 7. Класс Application using System; using System.Windows; public class Startup { [STAThreadO ] static void Main() { // Создание приложения. Application app = new Application(); // Создание главного окна. Windowl win = new Windowl(); // Запуск приложения и отображение главного окна, app.Run(win); } } Передача окна методу Application .Run () приводит к тому, что это окно устанавливается в качестве главного и доступно во всем приложении через свойство Application. MainWindow. Метод Run() затем инициирует событие Application. Star tup и отображает главное окно. Аналогичного эффекта можно достичь с помощью более длинного кода: // Создать приложение. Application app = new Application(); // Создать, присвоить и показать главное окно. Windowl win = new Windowl(); app.MainWindow = win; win.Show(); // Активизировать приложение, app.Run(); Оба подхода обеспечивают приложению необходимые начальные установки. Запущенное подобным образом приложение продолжает работу до тех пор, пока главное окно и все его прочие окна не будут закрыты. В этот момент метод Run () вернет управление и, прежде чем завершится приложение, будет выполнен любой дополнительный код в Main(). На заметку! Чтобы запустить приложение с использованием метода Main(), необходимо определить класс, который содержит метод Main() в качестве стартового объекта в Visual Studio. Чтобы сделать это, дважды щелкните на узле Properties (Свойства) в Solution Explorer и измените выбор в списке Startup Object (Стартовый объект). Обычно это делать не понадобится, потому что Visual Studio создает метод Main () автоматически на основе шаблона приложения XAML. Этот шаблон приложения рассматривается в следующем разделе. Наследование специального класса приложения Хотя подход, описанный в предыдущем разделе (с созданием экземпляра базового класса Application и вызовом Run()), работает вполне удовлетворительно, при создании нового приложения WPF этот вариант в Visual Studio не используется. Вместо этого Visual Studio создает специальный класс, унаследованный от Application. В простом приложении такой подход не дает существенного эффекта. Однако если планируется обработка событий приложения, он предоставляет более изящную модель, потому что код обработки событий может быть помещен в класс, производный от Application.
Глава 7. Класс Application 219 Модель, реализованная в Visual Studio в отношении класса Application, по сути, та же, что и модель, применяемая для окон. Начальная точка — шаблон XAML, по умолчанию называемый App.xaml. Ниже показано, как примерно он выглядит (без раздела ресурсов, который рассматривается в главе 10). <Application x:Class="TestApplication.Арр" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" StartupUri="Windowl.xaml" > </Application> Вспомните из главы 2, что атрибут Class используется в XAML для создания класса, унаследованного от элемента. Таким образом, создается класс, унаследованный от Application, по имени TestApplication.App. (TestApplication — название проекта, совпадающее с пространством имен, в котором определен класс, а Арр — имя, используемое Visual Studio для специального класса, унаследованного от Application. При желании это имя класса можно заменить более выразительным.) Дескриптор Application не только создает специальный класс приложения, но также устанавливает свойство StartupUri для идентификации документа XAML, представляющего главное окно. В результате не понадобится явно создавать экземпляр этого окна в коде — анализатор XAML сделает это сам. Как и с окном, класс приложения определен в двух отдельных частях, которые объединяются вместе во время компиляции. Автоматически сгенерированная часть в проекте невидима, но она содержит точку входа Main() и код для запуска приложения. Выгладит это примерно так, как показано ниже. using System; using System.Windows; public partial class App : Application { [STAThreadO ] public static void Main() { TestApplication.App app = new TestApplication.App(); app.InitializeComponent(); app.Run(); } public void InitializeComponent () { this.StartupUri = new Un("Windowl.xaml", System.UriKind.Relative) ; } } Если вы действительно заинтересованы в том, чтобы увидеть специальный класс приложения, созданный шаблоном XAML, загляните в файл App.g.cs в папке objXDebug внутри каталога проекта. Единственное отличие между автоматически сгенерированным кодом, показанным здесь, и специальным классом приложения, который вы можете создать самостоятельно, состоит в том, что автоматически сгенерированный класс использует свойство StartupUri вместо установки свойства MainWindow или передачи главного окна в качестве параметра методу Run(). Применяя тот же самый формат URI, можно создать специальный класс приложения, использующий этот подход. Понадобится создать объект относительного URI, который именует документ XAML, находящийся в проекте. (Этот документ XAML компилируется и встраивается в сборку приложения в виде ресурса BAML. Именем ресурса является имя исходного файла XAML.
220 Глава 7. Класс Application В предыдущем примере приложение содержит ресурс по имени Windowl.xaml со скомпилированным XAML-кодом.) На заметку! Применяемая здесь система URI является обычным способом ссылки на ресурсы в приложении. Более подробно ее работа описана в разделе "Упакованные URI" далее в этой главе. Вторая часть специального класса приложения хранится в проекте в файле App.xaml.cs. Он содержит добавленный код обработки событий. Изначально этот файл пуст: public partial class App : Application { } Этот файл объединяется с автоматически сгенерированным кодом приложения благодаря механизму частичных классов. Останов приложения Обычно класс Application оставляет приложение активным до тех пор, пока открыто хотя бы одно окно. Если такое поведение не нужно, можно изменить значение свойства Application.ShutdownMode. При создании объекта Application вручную, свойство ShutdownMode должно быть установлено перед залуском Run(). Если используется файл App.xaml, можно просто установить свойство ShutdownMode в коде разметки XAML. Режим останова приложения может принимать три значения, перечисленные в табл. 7.1. Таблица 7.1. Значения перечисления ShutdownMode Имя Описание OnLastWindowClose Поведение по умолчанию — приложение выполняется до тех пор, пока существует хотя бы одно открытое окно. После закрытия главного окна свойство Application.MainWindow по-прежнему ссылается на объект, представляющий закрытое окно. (Дополнительно можно использовать код для переназначения свойства MainWindow, чтобы оно указывало на другое окно.) OnMainWindowClose Это традиционный подход — приложение остается активным только пока открыто главное окно OnExplicitShutdown Приложение не завершается (даже если все окна закрыты), пока не будет вызван метод Applic at ion. Shut down (). Такой подход может быть оправдан, если приложение является интерфейсом для долго выполняющейся задачи. Также он применяется, если для принятия решения о закрытии приложения должна использоваться более сложная логика (в этом случае будет вызываться метод Applicat ion. Shutdown ()) Например, чтобы применить подход OnMainWindowClose, внесите следующее изменение в файл App.xaml: <Application x:Class="TestApplication.App" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" StartupUri="Windowl.xaml" ShutdownMode="OnMainWindowClose"> </Application>
Глава 7. Класс Application 221 Независимо от того, какой способ останова используется, всегда есть возможность с помощью метода Applic at ion. Shut down () немедленно завершить приложение. (Разумеется, после вызова метода Shutdown () приложение не обязательно сразу завершится. Вызов Application. Shutdown () заставляет метод Applic at ion. Run () немедленно вернуть управление, но может существовать дополнительный код, который выполняется в методе Main() или реагирует на событие Application.Exit.) На заметку! Когда ShutdownMode равно OnMainWindowClose, и закрывается главное окно, объект Application автоматически закроет все прочие окна перед тем, как метод Run () вернет управление. То же самое верно, если вызывается Application.Shutdown (). Это важно, потому что окна могут иметь код обработки событий, который инициируется при их закрытии. События класса Application Изначально файл App.xaml.cs кода не содержит Хотя какой-либо код не обязателен, можно добавить код обработки событий приложения. Класс Application предоставляет небольшой набор полезных событий. В табл. 7.2 перечислены наиболее важные из них. В ней опущены события, используемые только в приложениях с навигацией (которые обсуждаются в главе 24). Таблица 7.2. События класса Application Имя Описание Startup Exit SessionEnding Activated Deactivated DispatcherUnhandledException Происходит после вызова метода Appl ication. Run () и непосредственно перед отображением главного окна (если вы передаете главное окно методу Run()) Происходит, когда приложение останавливается по любой причине, непосредственно перед возвратом из метода Run(). В этот момент отменить останов нельзя, хотя код метода Main() может повторно запустить приложение. Событие Exit можно использовать для установки целочисленного кода выхода, возвращаемого методом Run(). Происходит по завершении сеанса Windows, например, когда пользователь выходит из системы или выключает компьютер. (Выяснить, что конкретно произошло, позволяет СВОЙСТВО SessionEndingCancelEventArgs. ReasonSessionEnding.) Также можно отменить останов, присвоив SessionEndingCancelEventArgs.Cancel значение true. Если этого не делать, то WPF вызовет метод Application, shutdown () по завершении обработчика события Происходит, когда активизируется одно из окон приложения. Это случается, когда вы переключаетесь с другой программы Windows на это приложение. Также случается при первом показе окна Происходит при деактивизации окна приложения. Случается, когда вы переключаетесь на другую программу Windows. Происходит, когда возникает необработанное исключение в любом месте приложения (в главном потоке приложения). (Эти исключения перехватывает диспетчер приложения ) При реакции на это событие можно протоколировать критичные ошибки, можно даже нейтрализовать исключение и продолжить работу приложения, установив СВОЙСТВО DispatcherUnhandledExceptionEventArgs. Handled в true. Такой шаг должен предприниматься только при достаточной уверенности в том, что приложение находится в корректном состоянии и его работа может быть продолжена
222 Глава 7. Класс Application При обработке событий доступны два выбора: присоединение обработчика событий или переопределение соответствующего защищенного метода. Если вы предпочитаете обрабатывать события приложения, то не нужно использовать код делегата для привязки его в качестве обработчика. Вместо этого его можно присоединить с помощью атрибута в файле App.xml. Например, если имеется следующий обработчик событий: private void App_DispatcherUnhandledException (object sender, DispatcherUnhandledExceptionEventArgs e) { MessageBox.Show("An unhandled " + e.Exception.GetType().ToString() + " exception was caught and ignored."); e.Handled = true; } можно подключить его посредством такого XAML-кода: <Application x:Class="PreventSesslonEnd.App" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" StartupUri="Windowl.xaml" DispatcherUnhandledException="App_DispatcherUnhandledException" > </Application> Для инициализации каждого события приложения (из перечисленных в табл. 7.2) вызывается соответствующий метод. Имя метода совпадает с именем события, но с добавлением префикса On, так что Startup становится OnStartupO, Exit — OnExitO и т.д. Этот шаблон чрезвычайно широко распространен в .NET (и программисты Windows Forms также его узнают). Единственным исключением является событие DispatcherExceptionUnhandled — метода OnDispatcherExceptionUnhandled не существует, так что для этого события должен всегда применяться обработчик. Ниже приведен код специального класса приложения, переопределяющего OnSessionEnding и предотвращающего останов самого себя и системы при установленном флаге. public partial class App : Application { private bool unsavedData = false; public bool UnsavedData { get { return unsavedData; } set { unsavedData = value; } } protected override void OnStartup(StartupEventArgs e) { base.OnStartup (e); UnsavedData = true; } protected override void OnSessionEnding(SessionEndingCancelEventArgs e) { base.OnSessionEnding(e); if (UnsavedData) { e.Cancel = true; MessageBox.Show ( "The application attempted to be closed as a result of " + e.ReasonSessionEnding.ToString() + ". This is not allowed, as you have unsaved data."); } } }
Глава 7. Класс Application 223 При переопределении методов приложения будет хорошей идеей начинать с вызова реализации базового класса. Обычно реализация базового класса мало что делает помимо инициализации соответствующего события приложения. Очевидно, что более сложная реализация этой техники должна отображать не окно сообщения, а некоторого рода диалоговое окно подтверждения, которое даст пользователю возможность выбора — продолжить (и выйти из приложения и Windows) либо отменить останов. Задачи приложения Теперь, когда известно, каким образом объект Application вписывается в приложение WPF, вы готовы рассмотреть его применение в нескольких распространенных сценариях. В следующих разделах мы рассмотрим, как отображается экран заставки, как обрабатываются аргументы командной строки, как осуществляется поддержка взаимодействия между окнами, отслеживание документа и создание одного экземпляра приложения. На заметку! В следующих разделах описано поведение оконных приложений WPR Браузерные приложения WPF (XBAP) рассматриваются в главе 24. ХВАР имеют встроенный браузерный экран заставки, не могут принимать аргументы командной строки, не используют множество окон и не имеют смысла в качестве приложений с единственным экземпляром. Отображение экрана заставки Какими бы быстрыми ни были приложения WPF, они не стартуют мгновенно. При первом запуске приложения происходит задержка, пока общеязыковая исполняющая среда (CLR) инициализирует .NET и затем запускает приложение. Эта задержка не обязательно является проблемой. Обычно проходит небольшое время, и первое окно появляется. Однако если приходится выполнять шаги инициализации, требующие большего времени, или необходимо добиться более профессионального открытия и отображения графики, можно воспользоваться простым средством экрана заставки (splash screen), доступным в WPF. Ниже перечислены шаги по добавлению экрана заставки. 1. Добавьте в проект файл изображения (обычно .bmp, .png или .jpg). 2. Выберите этот файл в Solution Explorer. 3. Установите Build Action (Действие сборки) в SplashScreen. Теперь при следующем запуске приложения это изображение будет показано немедленно, в центре экрана. Как только исполняющая среда будет готова, после завершения метода Application _ Startup появится первое окно приложения, а экран заставки постепенно исчезнет (примерно за 300 миллисекунд). Это очень простое средство. Но имейте в виду, что экран заставки отображается без каких-либо украшений. Вокруг него не рисуется рамка, так что если она нужна, то следует поместить ее в само изображение. Не предусмотрена также возможность показа последовательности изображений либо анимации. Если же это необходимо, то должен применяться традиционный подход: создание стартового окна, которое выполняет код инициализации одновременно с отображением нужного графического содержимого. При добавлении экрана заставки компилятор WPF добавляет в автоматически сгенерированный файл App.g.cs код вроде следующего:
224 Глава 7. Класс Application SplashScreen splashScreen = new SplashScreen("splashScreenlmage.png"); // Показать экран заставки. // Параметр true заставляет splashScreen автоматически постепенно // исчезнуть после отображения первого окна. splashScreen.Show(true); // Запустить пиложение. MyApplication.Арр арр = new MyApplication.App(); арр.InitializeComponent(); арр.Run(); // Экран заставки начинает постепенно исчезать. Этот код можно написать и самостоятельно, не пользуясь действием сборки SplashScreen. Единственной деталью, которую можно изменить, является скорость исчезновения экрана заставки. Для этого методу SpashScreen.ShowO понадобится передать значение false (чтобы экран заставки не исчезал автоматически). Экран заставки затем можно закрыть за нужное время, вызвав метод SplashScreen.Close () и передав ему значение TimeSpan, указывающее длительность процесса угасания. Обработка аргументов командной строки Чтобы обработать аргументы командной строки, понадобится реагировать на событие Application. Startup. Аргументы передаются в виде массива строк через свойство StartupEventArgs.Args. Например, предположим, что необходимо загрузить документ, когда его имя передается в виде аргумента командной строки. В этом случае нужно прочитать аргументы командной строки и выполнить необходимую дополнительную инициализацию. В следующем примере реализован этот шаблон за счет реагирования на событие Application.Startup. Он не устанавливает в этой точке свойство Application. StartupUri — вместо этого экземпляр главного окна создается кодом. public partial class Арр : Application , { private static void App_Startup(object sender, StartupEventArgs e) { // Создать, но не показывать главное окно. FileViewer win = new FileViewer (); if (e.Args.Length > 0) { string file = e.Args[0]; if (System.10.File.Exists (file)) { // Конфигурировать главное окно. win.LoadFile(file); } } else { // (Выполнить альтернативную инициализацию, когда // никаких аргументов командной строки не указано.) } // Это окно будет автоматически установлено как Application .MainWindow.- win.Show(); } } Этот метод инициализирует главное окно, которое затем отображается по завершении метода Арр _ Startup(). Этот код предполагает, что в классе FileViewer имеется общедоступный метод (добавленный вами) по имени LoadFile(). Ниже приведен один
Глава 7. Класс Application 225 возможный пример этого метода, который просто читает (и отображает) текст указанного файла: public partial class FileViewer : Window { public void LoadFile(string path) { this.Content = File.ReadAllText(path); this.Title = path; } } Пример этой техники доступен в коде для этой главы. На заметку! Если вы — опытный программист, использующий Windows Forms, то код метода LoadFile () может показаться несколько странным. Он устанавливает свойство Content текущего экземпляра Window, которое определяет то, что окно отобразит в своей клиентской области. Довольно интересно, что окна WPF на самом деле являются разновидностью элемента управления содержимым (в том смысле, что наследуются от класса ContentControl). В результате этого они содержат (и отображают) единственный объект. Вы определяете, является ли этот объект строкой, элементом управления или (что полезнее) панелью, которая может содержать множество других элементов управления. В последующих главах модель содержимого, принятая в WPF, рассматривается более подробно. Доступ к текущему приложению В любом месте кода можно получить текущий экземпляр приложения, обратившись к свойству Application.Current. Это обеспечивает возможность простейшего взаимодействия между окнами, поскольку любое окно может получить доступ к текущему объекту Application и через него — к ссылке на главное окно: Window main = Application.Current.MainWindow; MessageBox.Show("The main window is " + main.Title); Конечно, для доступа к любым методам, свойствам или событиям, добавленным к специальному классу главного окна, придется привести объекта окна к соответствующему типу. Если главное окно представляет собой экземпляр специального класса MainWindow, можно применить следующий код: MainWindow main = (MainWindow)Application.Current.MainWindow; main.DoSomething() ; Можно также проверить содержимое коллекции Application.Windows, которая содержит ссылки на все открытые в данный момент окна: foreach (Window window in Application.Current.Windows) { MessageBox.Show(window.Title + " is open."); } На практике в большинстве приложений отдается предпочтение более структурированной форме взаимодействия между окнами. Если имеется несколько долго существующих окон, которые открыты одновременно, и которым нужно как-то взаимодействовать между собой, имеет больше смысла хранить ссылки на эти окна в специальном классе приложения. В этом случае всегда можно будет отыскать нужное окно. Аналогично, в приложении, основанном на документах, может быть предусмотрена коллекция, отслеживающая только окна документов, и ничего больше. Такой прием рассматривается в следующем разделе.
226 Глава 7. Класс Application На заметку! Окна (включая главное) добавляются в коллекцию Windows по мере отображения и удаляются из нее при закрытии. По этой причине положение окон в коллекции может меняться, и нельзя полагаться, что определенный объект окна будет найден в определенной позиции. Взаимодействие между окнами Как уже было показано, специальный класс приложения — отличное место для размещения кода, реагирующего на разные события приложения. Есть еще одно предназначение, которое замечательно подходит классу Application: хранение ссылок на важные окна, так чтобы одно окно могло обращаться к другому. Совет. Данный прием имеет смысл применять при наличии немодального окна, которое существует в течение длительного времени и доступно нескольким разным классам (а не только классу, создавшему его). Если просто отображается модальное диалоговое окно как часть приложения, то этот прием будет чрезмерным. В данной ситуации окно не может существовать очень долго, и только код, создающий окно, нуждается в доступе к нему (Чтобы прояснить разницу между модальными окнами, которые прерывают поток выполнения приложения до тех пор, пока они не будут закрыты, и немодальными окнами, которые этого не делают, обратитесь к главе 23.) Например, предположим, что требуется отслеживать все окна документов, с которыми работает приложение. В этом случае можно создать выделенную коллекцию в специальном классе приложения. Рассмотрим пример, в котором для хранения группы специальных оконных объектов используется обобщенная коллекция List. В этом примере каждое окно документа представлено экземпляром класса по имени Document. public partial class App : Application { private List<Document> documents = new List<Document> (); public List<Document> Documents { get { return documents; } set { documents = value; } } } Теперь, когда вы создаете новый документ, то должны просто не забыть добавить его к коллекции Documents. Вот обработчик события, который реагирует на щелчок кнопки и выполняет эту задачу: private void cmdCreate_Click(object sender, RoutedEventArgs e) { Document doc = new Document (); doc.Owner = this; doc . Show () ; ((App)Application.Current).Documents.Add(doc); } В качестве альтернативы можно было бы реагировать на событие вроде Window. Loaded в классе Document, гарантируя, что объект документа всегда зарегистрирует себя в коллекции Documents при его создании. На заметку! В этом коде также устанавливается свойство Window.Owner так, что все окна документов отображаются "поверх" создающего их главного окна. Дополнительные сведения о свойстве Owner будут представлены во время детального рассмотрения окон в главе 23.
Глава 7. Класс Application 227 Теперь эту коллекцию можно использовать в любом месте кода, чтобы проходить в цикле по всем документам и обращаться к их общедоступным членам. В этом случае класс Document включает специальный метод SetContentO для обновления отображения: private void cmdUpdate_Click(object sender, RoutedEventArgs e) { foreach (Document doc in ( (App)Application.Current) .Documents) { doc.SetContent("Refreshed at " + DateTime.Now.ToLongTimeString () + "."); } } Внешний вид этого приложения показан на рис. 7.1. Конечный результат не особо впечатляет, но взаимодействие достойно внимания — оно демонстрирует безопасный дисциплинированный способ взаимодействия окон через специальный класс приложения. Использовать свойство Windows удобно, т.к. оно строго типизировано и содержит только окна Document (а не коллекцию вообще всех окон приложения). Оно также дает возможность категоризации всех окон другим, более удобным способом, например, в коллекции Dictionary с ключевыми именами для облегчения поиска. В приложении на основе документов окна в коллекции можно индексировать по имени файла. {ZZZZjaT ■ Window].. Refreshed at 10:33:29 AM. ■ WndowTr* Г» i IL J J Рис. 7.1. Обеспечение взаимодействия окон На заметку! При взаимодействии между окнами не забывайте об объектно-ориентированных принципах — всегда используйте уровень специальных методов, свойств и событий, добавленных к классам окон. Никогда не открывайте прямой доступ к полям или элементам управления формы для других частей кода. Это может быстро привести к получению тесно связанного интерфейса, в котором одно окно глубоко вмешивается в работу другого, и расширять классы не удастся, не нарушая "нечеткой" взаимной зависимости между ними. Приложение одного экземпляра Обычно можно запускать произвольное количество копий приложения WPF. В некоторых сценариях это проектное решение совершенно оправдано. Однако в других случаях это может стать проблемой, особенно при построении приложений, основанных на документах. в WindowTracker
228 Глава 7. Класс Application Например, рассмотрим текстовый процессор Microsoft Word. Независимо от того, сколько документов открывается (и как они открываются), в каждый момент времени загружен только единственный экземпляр winword.exe. При открытии новых документов они появляются в новых окнах, но всеми окнами документов управляет единственное приложение. Такое решение представляет собой наилучший подход, когда требуется сократить накладные расходы, связанные с работой приложения, централизовать определенные средства (например, создать единый диспетчер очереди печати) либо интегрировать разнородные окна (например, предоставить средство, которое упорядочит все текущие открытые окна документов, расположив их рядом друг с другом). В WPF не предусмотрено встроенного решения для приложений одного экземпляра, но можно воспользоваться несколькими обходными маневрами. Базовый прием заключается в проверке существования другого запущенного экземпляра приложения при возникновении события Application.Startup. Простейший путь сделать это состоит в использовании системного мьютекса (объекта синхронизации, предоставляемого операционной системой, позволяющей межпроцессное взаимодействие). Этот подход прост, но ограничен — важнее всего то, что при этом не существует возможности взаимодействия нового экземпляра приложения с уже существующим. Это становится проблемой для приложений, основанных на документах, потому что новому экземпляру может понадобиться сообщить существующему экземпляру о необходимости открытия определенного документа, если он передан в командной строке. (Например, двойной щелчок на файле .doc в проводнике Windows при запущенном приложении Word должен приводить к загрузке в Word этого файла.) Это взаимодействие более сложно и обычно осуществляется через технологию Remoting или Windows Communication Foundation (WCF). Корректная реализация требует включения способа обнаружения удаленного сервера и его использования для передачи аргументов командной строки. Однако простейший подход, рекомендованный для WPF, предусматривает применение встроенной поддержки, которая предоставляется в Windows Forms, и изначально предназначалась для приложений Visual Basic. Этот подход обрабатывает все запутанные детали "за кулисами". Итак, каким образом можно воспользоваться средством, предназначенным для Windows Forms и Visual Basic, при управлении приложением WPF на С#? По сути, класс приложения старого стиля служит оболочкой для класса приложения WPF. Во время запуска приложения создается экземпляр класса приложения старого стиля, который затем создаст экземпляр класса приложения WPF Класс приложения старого стиля занимается управлением экземплярами, в то время как класс приложения WPF обслуживает реальное приложение. На рис. 7.2 показано взаимодействие этих частей. WindowsFormsApplicationBase (eMicrosoft.VisualBasic.ApplicationServices) OnStartup() OnStartupNextlnstance() - Создать- Активизировать Application (в System .Windows) OnStartup() LoadDocument() (специальный метод) - Создать - Рис. 7.2. Упаковка приложения WPF в оболочку WindowsFormsApplicationBase
Глава 7. Класс Application 229 Создание оболочки для приложения одного экземпляра Первый шаг заключается в добавлении ссылки на сборку Microsoft.VisualBasic.dll и наследовании специального класса от класса Microsof t.VisualBasic. ApplicationServices. WindowsFormsApplicationBase. В этом классе определены три важных члена, которые используются для управления экземплярами. • Свойство IsSinglelnstance позволяет создать приложение одного экземпляра. Это свойство устанавливается в true в конструкторе. • Метод OnStartupO инициируется при старте приложения. Этот метод переопределяется и в данной точке создается объект приложения WPF. • Метод OnStartupNextInstance () инициируется при запуске другого экземпляра приложения. Этот метод обеспечивает доступ к аргументам командной строки. В данной точке, скорее всего, будет вызван метод класса приложения WPF, чтобы отобразить новое окно, не создавая другого объекта приложения. Ниже приведен код специального класса, унаследованного от Windows Forms ApplicationBase. public class SinglelnstanceApplicationWrapper : Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase { public SinglelnstanceApplicationWrapper () { // Включить режим одного экземпляра. this . IsSinglelnstance = true; } // Создать класс приложения WPF. private WpfApp app; protected override bool OnStartup ( Microsoft.VisualBasic.ApplicationServices.StartupEventArgs e) { app = new WpfApp () ; app.Run(); return false; } // Управление множеством экземпляров, protected override void OnStartupNextlnstance( Microsoft.VisualBasic.ApplicationServices.StartupNextlnstanceEventArgs e) { if (e.CommandLine.Count > 0) { app.ShowDocument(e.CommandLine[0]); } } } Когда приложение запускается, этот класс создает экземпляр WpfApp, который представляет собой специальный класс приложения WPF (класс, унаследованный от System.Windows .Application). Класс WpfApp содержит некоторую стартовую логику, которая отображает главное окно, наряду со специальным методом ShowDocument (), который загружает окно документа для заданного файла. Каждый раз, когда имя файла передается SinglelnstanceApplicationWrapper в командной строке, SinglelnstanceApplicationWrapper вызывает WpfApp.ShowDocument(). Вот как выглядит код класса WpfApp:
230 Глава 7. Класс Application public class WpfApp : System.Windows.Application { protected override void OnStartup(System.Windows.StartupEventArgs e) { base.OnStartup (e); WpfApp.current = this; // Загрузить главное окно. DocumentList list = new DocumentList(); this.MainWindow = list; list.Show (); // Загрузить документ, переданный в качестве аргумента. if (e.Args.Length > 0) ShowDocument(e.Args[0]); } public void ShowDocument(string filename) { try { Document doc = new Document(); doc.LoadFile(filename); doc.Owner = this.MainWindow; doc.Show(); // Если приложение уже загружено, оно может быть невидимым. // Здесь выполняется попытка передать фокус новому окну, doc.Activate(); } catch { MessageBox.Show("Could not load document."); // He удается загрузить документ } } } Единственная деталь, которой здесь не хватает (помимо окон DocumentList и Document) — это точка входа приложения. Поскольку приложение должно создать класс SinglelnstanceApplicationWrapper перед классом WpfApp, приложение должно запускаться с помощью традиционного метода Main(), а не файла App.xaml. Ниже показан необходимый код. public class Startup { [STAThread] public static void Main(string [ ] args) { SinglelnstanceApplicationWrapper wrapper = new SinglelnstanceApplicationWrapper (); wrapper.Run(args); } } Эти три класса — SinglelnstanceApplicationWrapper, WpfApp и Startup — формируют базу для приложения WPF одного экземпляра. Используя эту основу, можно создать более изощренный пример. Например, в загружаемом коде для этой главы класс WpfApp модифицируется так, что поддерживает список открытых документов (как было показано ранее). Используя привязку данных WPF (средство, описанное в главе 19), окно DocumentList отображает текущие открытые документы. На рис. 7.3 показан пример приложения с тремя открытыми документами.
Глава 7. Класс Application 231 S ngielnstanceApplication i D:\CocJe\Pro WPF\Chapter03\SingleInstanceApplication\samplel.testDoc ; D:\Code\Pro WPf\Chapter03\SingteInstanceApplication\sampJe3.testDoc j O:\Code\Pro WPF\Chapter03\SingleInstanceApplication\sample2.testDoc ■ ' D:\Code\Pro WPf\Chapter03\,- This is sanpiel о S 22 ^ к, ^ ■ i ' D:\Code\Pro WPf\Chapter03\... i 5 !H . ?? fThis is sampte3. ■ ': DACode\Pro WPf\Chapter03\... I This is sanple2 ГаГ Рис. 7.3. Приложение одного экземпляра с центральным окном На заметку! Поддержка приложений одного экземпляра, в конце концов, появится в будущей версии WPF. А пока этот обходной путь обеспечивает ту же функциональность, требуя лишь небольшой дополнительной работы. Регистрация типа файла Чтобы протестировать приложение одного экземпляра, нужно зарегистрировать в Windows расширение файла (.testDoc) и ассоциировать его с приложением. После этого щелчок на файле .testDoc будет приводить к немедленному запуску приложения. Один из способов зарегистрировать этот тип файла вручную предусматривает использование проводника Windows. 1. Щелкните правой кнопкой на файле .testDoc и выберите в контекстном меню пункт Открыть с помощью...^Выбрать программу. 2. В диалоговом окне Выбор программы щелкните на кнопке Обзор, найдите ЕХЕ- файл своего приложения и дважды щелкните на нем. 3. Если не хотите делать приложение обработчиком по умолчанию для файлов этого типа, удостоверьтесь, что в диалоговом окне Выбор программы флажок Использовать выбранную программу всех файлов такого типа не отмечен. В этом случае запускать приложение двойным щелчком на файле не получится, но можно открывать файл щелчком на нем правой кнопкой мыши, выбором в контекстном меню пункта Открыть с помощью... и затем нужного приложения из списка. 4. Щелкните на кнопке ОК.
232 Глава 7. Класс Application Другой способ регистрации типа файла состоит в выполнении кода, который редактирует системный реестр. В примере SinglelnstanceApplication определен класс FileRegistrationHelper, который именно это и делает: string extension = ".testDoc"; string title = "SinglelnstanceApplication"; string extensionDescription = "A Test Document"; FileRegistrationHelper.SetFileAssociation( extension, title + "." + extensionDescription); Класс FileRegistrationHelper регистрирует расширение файла .testDoc, используя классы из пространства имен Microsoft.Win32. Полный код можно просмотреть в загружаемом коде примеров для этой главы. Процесс регистрации должен выполняться только однажды. После того, как регистрация завершена, любой двойной щелчок на файле с расширением .testDoc приводит к запуску SinglelnstanceApplication, и этот файл передается в виде аргумента командной строки. Если SinglelnstanceApplication уже запущен, то вызывается метод Single In stanceApplicationWrapper.OnStartupNex tin stance О, и существующее приложение загружает новый документ. Совет. При создании приложения, основанного на документах, с зарегистрированным типом файла может пригодиться средство списка переходов в Windows 7, которое более подробно рассматривается в главе 23. ОС Windows и контроль учетных записей пользователей Регистрация файла — это задача, которая обычно выполняется программой установки. Проблема с включением ее в код приложения состоит в том, что она требует повышенных привилегий, которых может не иметь пользователь, запустивший приложение. В частности, здесь вступает в действие средство контроля учетных записей пользователей (User Account Control — UAC) в Windows Vista и Windows 7. Фактически по умолчанию этот код завершится сбоем с генерацией исключения, связанного с безопасностью. С точки зрения UAC все приложения имеют один из трех уровней выполнения • aslnvoker. Приложение наследует маркер процесса от родительского процесса (процесса, запустившего его). Приложение не получит административных привилегий, если только пользователь специально не запросит их — даже если пользователь зарегистрирован как администратор. Этот уровень принимается по умолчанию. • requireAdministrator. Если текущий пользователь является членом группы Administrators (Администраторы), появится диалоговое окно подтверждения UAC. Как только пользователь подтвердит, приложение получит административные привилегии. Если же пользователь не является членом группы Administrators, появится диалоговое окно, где пользователь сможет ввести имя и пароль учетной записи, обладающей административными привилегиями. • highestAvailable. Приложение получает максимальные привилегии согласно членству в группах. Например, если текущий пользователь — член группы Administrators, то приложение получает административные привилегии (как только примет подтверждение UAC) Преимущество этого уровня выполнения в том, что приложение продолжит выполнение, если административные привилегии недоступны, в отличие от requireAdministrator. Обычно приложение выполняется с уровнем aslnvoker Чтобы запросить административные привилегии, при запуске необходимо щелкнуть правой кнопкой мыши на ЕХЕ-файле и выбрать в контекстном меню пункт Run As Administrator (Запуск от имени администратора). Чтобы получить административные привилегии при тестировании приложения в среде Visual Studio, потребуется щелкнуть правой кнопкой мыши на ярлыке Visual Studio и выбрать в контекстном меню пункт Run As Administrator.
Глава 7. Класс Application 233 Если приложению требуются административные привилегии, их можно запросить с помощью уровня выполнения requireAdministrator или highestAvailable. В любом случае понадобится создать манифест — файл с блоком ХМL-разметки, который будет встроен в скомпилированную сборку. Чтобы добавить манифест, щелкните правой кнопкой мыши на проекте в Solution- Explorer и выберите в контекстном меню пункт Add^New Item (Добавить1^Новый элемент). Укажите шаблон Application Manifest File (Файл манифеста приложения) и щелкните на кнопке Add (Добавить). Содержимое файла манифеста представлено относительно простым блоком XML-разметки: <?xml version=.0" encoding="utf-8"?> <asmvl:assembly manifestVersion=.0" xmlns="urn: schema s-m ic rosoft-com: asm. vl" xmlns: asmvl="urn: schema s-micr о soft-corn: asm. vl" xmlns:asmv2="urn:schemas-microsof t-com:asm.v2"> <assemblyldentity version=.0.0.0" name="MyApplication.app"/> <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> <security> <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm. v3"> <requestedExecutionLevel level="asInvoker" /> </requestedPrivileges> </security> </trustInfo> </asmvl:assembly> Для изменения уровня выполнения просто модифицируйте атрибут уровня элемента <requested ExcutionLevelx Допустимыми значениями являются aslnvoker, requireAdministrator и highestAvailable. В некоторых случаях административные привилегии должны запрашиваться только в определенных сценариях. В примере с регистрацией файла это может понадобиться при первом запуске приложения, когда оно нуждается в регистрации. Это позволит избежать ненужных предупреждений UAC. Простейший способ реализовать этот шаблон заключается в размещении кода, который требует повышенных привилегий, в отдельном исполняемом модуле, который можно вызывать при необходимости. Ресурсы сборки Ресурсы сборки в приложении WPF работают, по сути, точно так же, как и в других приложениях .NET. Базовая концепция заключается в добавлении файла к проекту, чтобы среда Visual Studio могла встроить его в скомпилированный файл приложения ЕХЕ или DLL. Главное отличие между ресурсами сборки WPF и ресурсами в других приложениях связано с системой адресации, которая используется для ссылки на них. На заметку! Ресурсы сборки также еще называют двоичными ресурсами, потому что они встраиваются в скомпилированную сборку (ЕХЕ- или DLL-файл проекта) в виде "непрозрачного" большого двоичного объекта. Работа двоичных ресурсов уже демонстрировалась в главе 2. Именно из-за них всякий раз, когда компилируется приложение, все XAML-файлы в проекте преобразуются в более удобные для синтаксического анализа BAML-файлы. Далее эти BAML-файлы вставляются в сборку в виде отдельных ресурсов. Собственные ресурсы добавляются так же легко.
234 Глава 7. Класс Application Solution Explorer - Solution 'AssemblyResour... Д j3 13 Solution AssemblyResources 3 AssemblyResources l ш Properties j j References - fc>Q£ Jj Blue hillsjpg Л Sunset.jpg -Ji Water lilies.jpg Л Winter.jpg £l Sounds «J ding.wav •d start.wav a • Appj<aml i • Windowl.xami Добавление ресурсов Рис. 7.4. Приложение с ресурсами сборки Для создания собственных ресурсов следует добавить в проект нужный файл и установить для его свойства Build Action (Действие компоновки) (в окне Properties (Свойства)) значение Resource (Ресурс). К счастью, больше ничего делать не понадобится. Для более удобной организации можно создать в проекте подпапки (щелкнув правой кнопкой мыши в окне Solution Explorer (Проводник решений) и выбрав в контекстном меню пункт Add^New Folder (Добавить1^ Новая папка)) и применять их для упорядочивания различных типов ресурсов. На рис. 7.4 показан пример, где несколько ресурсов изображения были сгруппированы в папке по имени Images, а два аудиофайла — в папке по имени Sounds. Ресурсы, добавляемые подобным способом, легко обновлять. Все, что для этого необходимо — заменить файл и скомпилировать приложение заново. Например, в случае создания такого проекта, как был показан на рис. 7.4, все новые файлы можно было бы скопировать в папку Images с помощью проводника Windows. Поскольку содержимое файлов, входящих в состав проекта, заменяется, выполнять какие-то специальные действия в Visual Studio не требуется (за исключением фактической компиляции приложения). Однако существует несколько вещей, которые не следует делать для того, чтобы использовать ресурсы сборки успешно. • Не следует устанавливать для свойства Build Action значение Embedded Resource (встроенный ресурс). Хотя все ресурсы сборки являются встроенными ресурсами по определению, действие Build Action приводит к размещению двоичных данных в более трудном для доступа месте. Поэтому для приложений WPF нужно всегда использовать просто значение Resource (Ресурс). • Не следует применять доступную в окне Project Properties (Свойства проекта) вкладку Resources (Ресурсы). WPF не поддерживает такой тип URI-идентификаторов ресурсов. Любознательные программисты обычно желают знать, что происходит с ресурсами, которые они встраивают в свои сборки. На самом деле WPF объединяет их все в один поток (вместе с ресурсами BAML). Этот один поток ресурсов получает имя в формате ИмяСборки. g. resources. Например, на рис. 7.4 приложение называется AssemblyResources, а поток ресурсов — AssemblyResources.g.resources. Чтобы просмотреть встроенные ресурсы в скомпилированной сборке, можно воспользоваться дизассемблером. К сожалению, .NET-средство ildasm такую функциональность не поддерживает. Тем не менее, по адресу http://www.red-gate.com/products/ reflector доступна для загрузки бесплатная и гораздо более элегантная утилита под названием .NET Reflector, которая позволяет исследовать ресурсы. На рис. 7.5 показаны ресурсы для проекта, который был продемонстрирован на рис. 7.4, отображенные с помощью утилиты .NET Reflector. Здесь виден ресурс BAML для единственного окна в приложении и все остальные изображения и аудиофайлы. Пробелы в именах файлов не создают никаких проблем в WPF, потому что среда Visual Studio достаточно интеллектуальна, чтобы правильно их интерпретировать.
Глава 7. Класс Application 235 Также можно будет заметить, что при компиляции приложения имена файлов преобразуются в нижний регистр. & Lutz Roeder^.NET Reflector £ife tfiew Iools Help ? C* **lZ • -J System.Windows. Forme stem.Xml -i PresentabonFrdmewxIc -i vvindowsBase ■J Fresentat»onCore ■ о uiAutomationProvider U System. Deployment -J PresentationUI a System.FTtnting J ReachFramework •J UIAutomabonTypes -» AssembtyResources • V\ AssembtyResources.exe _л Resources M Assembly Resources. Properties Resources resources Resource value >ges/water%20liltes.jpg ff d8 ff eO 00 10 4a 46 .. ages/blue420hHls.]pg ff d8 ff eO 00 10 4a 46 .. "tages/winter.jpg, Ijj Л sounds/ding.v/av ^ages/sunset.jpg sounds/start.wav ffdeff eO 00 10 4a 46 ... 52 49 46 46 d0 3b 01 00 ffd8ff eO 00 10 4a46 ... 52 49 46 46 24 29 03 00 (83794 bytes) B8521 bytes) A05542 bytes) ... (80856 bytes) G1189 bytes) ... B07148 bytes) // public resource Assembly-Resources.g.resources She: 578483 bytes Рис. 7.5. Ресурсы сборки, отображенные с помощью утилиты Reflector Извлечение ресурсов Очевидно, что в добавлении ресурсов нет ничего сложного, но как их на самом деле использовать? Здесь существует несколько подходов. Самый простой — извлечь объект StreamResourcelnfo, упаковывающий данные, и затем решить, что с ним делать. Сделать это можно в коде с помощью метода Application.GetResourceStream(). Например, ниже показан код, извлекающий объект StreamResourcelnfo для изображения winter.jpg: StreamResourcelnfo sri = Application.GetResourceStream( new Uri ("images/winter.jpg", UriKind.Relative)); Имея объект StreamResourcelnfo, можно получить два фрагмента информации. Свойство ContentType возвращает строку, описывающую тип данных — в настоящем примере это image/jpg. Свойство Stream возвращает объект UnmanagedMemoryStream, который можно использовать для считывания данных по одному байту за раз. Метод GetResourceStream() на самом деле представляет собой всего лишь вспомогательный метод, который упаковывает классы ResourceManager и ResourceSet. Эти классы являются главной частью системы ресурсов .NET Framework и существуют, начиная с версии 1.0. Без метода GetResourceStream() нужно было бы специально получать доступ к ресурсному потоку AssemblyName.g.resources (в котором хранятся все ресурсы WPF) и осуществлять поиск требуемого объекта. Ниже показан гораздо менее привлекательный код, решающий ту же самую задачу. Assembly assembly = Assembly.GetAssembly(this.GetType()); string resourceName = assembly.GetName() .Name + " .g"; ResourceManager rm = new ResourceManager(resourceName, assembly); using (ResourceSet set = rm.GetResourceSet(Culturelnfo.CurrentCulture, true, true))
236 Глава 7. Класс Application { UnmanagedMemoryStream s; // Второй параметр (true) выполняет поиск ресурса с учетом регистра. s = (UnmanagedMemoryStream)set.GetObject("images/winter.jpg", true); } Классы ResourceManager и ResourceSet также позволяют делать несколько вещей, которые нельзя делать с помощью одного только класса Application. Например, следующий фрагмент кода отображает имена всех вложенных ресурсов в потоке AssemblyName.g.resources: Assembly assembly = Assembly.GetAssembly(this.GetType()); string resourceName = assembly.GetName().Name + ".g"; ResourceManager rm = new ResourceManager(resourceName, assembly); using (ResourceSet set = rm.GetResourceSet(Culturelnfo.CurrentCulture, true, true)) { foreach (DictionaryEntry res in set) { MessageBox.Show(res.Key.ToString()); } } Классы, поддерживающие ресурсы Даже с помощью метода GetResourceStreamO все равно вряд ли захочется возиться с извлечением ресурсов напрямую. Проблема в том, что при таком подходе получается объект UnmanagedMemoryStream относительно низкого уровня, от которого мало толку. Наверняка понадобится преобразовать данные во что-нибудь более значимое вроде высокоуровневого объекта со свойствами и методами. WPF предлагает несколько классов, которые умеют работать с ресурсами. Вместо того чтобы вынуждать извлекать ресурсы (что засоряет код и делает его небезопасным в отношении типов), они принимают имя ресурса, который необходимо использовать. Например, если необходимо отобразить изображение Blue hills opg, можно воспользоваться такой разметкой: <Image Source="Images/Blue hills . jpg"x/Image> Обратите внимание, что обратная косая черта становится обычной косой чертой, поскольку именно такое соглашение WPF использует для URI-идентификаторов. (В принципе рабочим является и тот и другой вариант, но все-таки для согласованности рекомендуется применять обычную косую черту.) То же самое можно сделать и в коде. В случае элемента Image потребуется просто установить для свойства Source в качестве значения объект Bitmaplmage, указывающий на местонахождение изображения, которое требуется отобразить, в виде URI. Указать на полностью уточненный путь к файлу можно следующим образом: img.Source = new Bitmaplmage(new Uri(@"d:\Photo\Backgrounds\arch.jpg")); Но в случае применения относительного URI можно извлечь из сборки другой ресурс и передать его изображению безо всякого объекта UnmanagedMemoryStream: img.Source = new Bitmaplmage(new Uri("images/winter.jpg", UriKind.Relative)); Такой подход приведет к созданию URI, состоящего из базового URI приложения с добавленной в конце конструкцией images/winter.jpg. В большинстве случаев думать о таком синтаксисе URI не нужно — если придерживаться относительных URI, все будет работать без сучка и задоринки. Однако в некоторых случаях важно разбираться в сие-
Глава 7. Класс Application 237 теме URI более детально, особенно, когда требуется доступ к ресурсу, находящемуся в другой сборке. В следующем разделе синтаксис URI рассматривается более подробно. Упакованные URI WPF позволяет обращаться к скомпилированным ресурсам (вроде BAML-файла для страницы) с помощью синтаксиса упакованных URI (pack URI). В предыдущем разделе в элементе Image и дескрипторе для ссылки на ресурс использовался относительный URI: images/winter.jpg Эквивалентный ему более громоздкий абсолютный URI показан ниже: pack://application:, , ,/images/winter.jpg Такой абсолютный URI можно использовать при установке источника изображения, хотя никаких дополнительных преимуществ это не даст: img.Source = new Bitmaplmage(new Uri("pack://application:, ,,/images/winter.jpg")); Совет. В случае применения абсолютного URI можно использовать путь к файлу, UNC-путь к сетевому ресурсу, URL-адрес веб-сайта или упакованный URI, указывающий на ресурс сборки. Главное помнить о том, что если приложению не удастся извлечь ресурс из указанного места, сгенерируется исключение. Если URI был установлен в XAML, исключение сгенерируется на этапе создания страницы Синтаксис упакованного URI был позаимствован из стандарта XPS (XML Paper Specification). Причина его странного внешнего вида заключается в том, что он подразумевает вставку одного URI внутрь другого. Три запятых фактически представляют собой три отмененных косых черты. Другими словами, показанный выше упакованный URI содержит URI приложения, начинающийся с конструкции application://. Ресурсы в других сборках Упакованные URI также позволяют извлекать ресурсы, находящиеся в другой библиотеке (другими словами, в DLL-сборке, которую использует приложение). В таком случае должен использоваться следующий синтаксис: pack://application:,,,/AssemblyName;component/ResourceName Например, если изображение находится в ссылаемой сборке по имени ImageLibrary, потребуется использовать такой URI: img.Source = new Bitmaplmage ( new Uri("pack://application:,,,/ImageLibrary;component/images/winter.ipg")); Можно также поступить более практично, воспользовавшись эквивалентным относительным URI: img.Source = new Bitmaplmage ( new Uri("ImageLibrary/component/images/winter.jpg", UriKind.Relative)); Если применяется сборка со сложным именем, имя сборки можно заменить уточненной ссылкой на сборку, включающей версию, маркер открытого ключа либо то и другое вместе. Каждый фрагмент информации следует отделить точкой с запятой, а перед номером версии добавить букву v. Ниже показан пример только с номером версии: img.Source = new Bitmaplmage( new Uri("ImageLibrary;vl.25;component/images/winter.jpg", UriKind.Relative));
238 Глава 7. Класс Application А вот и пример с обоими элементами, т.е. с номером версии и маркером открытого ключа: img.Source = new Bitmaplmage ( new Uri("ImageLibrary;vl.25;dc64 2a7f5bd64912;component/images/winter.jpg", UriKind.Relative)); Файлы содержимого При добавлении файла в виде ресурса его следует поместить в скомпилированную сборку и удостовериться в том, что он будет доступен всегда. Такой подход является идеальным вариантом для развертывания и исключает возможные проблемы. Однако в некоторых ситуациях он непрактичен, а именно: • при желании изменить файл ресурса без повторной компиляции приложения: • когда файл ресурса имеет очень большой размер: • когда файл ресурса является необязательным и может не развертываться со сборкой: • когда ресурс представляет собой звуковой файл. На заметку! Как будет показано в главе 26, звуковые классы WPF не поддерживают ресурсов сборки. По этой причине способа для извлечения аудиофайла из потока ресурсов и его воспроизведения не существует — по крайней мере, без его предварительного сохранения. Таково ограничение лежащих в основе технологии компонентов, на которых основаны данные классы (а именно — интерфейса Win32 API и проигрывателя Media Player). Очевидно, что обойти эту проблему можно путем развертывания файлов вместе с приложением и добавления в приложение соответствующего кода для чтения этих файлов с жесткого диска. Однако WPF имеет удобную опцию, которая может упрощать выполнение этого процесса и заключается в специальной маркировке таких не скомпилированных файлов как файлов содержимого. Файлы содержимого в сборку не встраиваются. Тем не менее, WPF добавляет к сборке атрибут AssemblyAssociatedContentFile, который объявляет о существовании каждого файла содержимого. Этот атрибут также записывает информацию о размещении каждого файла содержимого по отношению к исполняемому файлу (показывая, находится ли файл содержимого в той же папке, что и исполняемый файл, или же в отдельной подпапке). Лучше всего то, что для использования файлов содержимого с элементами, умеющими работать с ресурсами, можно применять ту же самую систему URI. Чтобы проверить это, добавьте в свой проект какой-то звуковой файл, выделите его в окне Solution Explorer и измените значение свойства Build Action в окне Properties на Content (содержимое). Удостоверьтесь в том, что для параметра Copy to Output Directory (Копировать в выходной каталог) установлено значение Copy Always (Копировать всегда), чтобы звуковой файл копировался в каталог вывода при компоновке проекта. Теперь можно использовать относительный URI, чтобы указать MediaElement на свой файл содержимого: <MediaElement Name="Sound" Source="Sounds/start.wav" LoadedBehavior="Manual"></MediaElement> Пример приложения, использующего как ресурсы сборки, так и файлы содержимого, можно найти в загружаемом коде для этой главы.
Глава 7. Класс Application 239 Локализация Ресурсы сборки также оказываются очень кстати, и когда требуется локализовать окно. Используя ресурсы, вы позволяете элементам управления изменяться в соответствии с текущими параметрами культуры в Windows, что особенно удобно в случае таких элементов управления, как текстовые метки и изображения, которые чаще всего требуется переводить на различные языки. На некоторых платформах локализация осуществляется путем предоставления множества копий таких элементов пользовательского интерфейса, как таблицы строк и изображения. В WPF локализация не является столь же детальной. Здесь единицей локализации является XAML-файл (формально это скомпилированный BAML-pecypc, который встраивается в приложение). При желании поддерживать три различных языка, потребуется включить три BAML-pecypca. WPF будет выбирать из них подходящий на основании текущих настроек культуры на компьютере, на котором выполняется приложение. (Точнее — WPF будет использовать для принятия решения значение свойства CurrentUICulture в обслуживающем пользовательский интерфейс потоке.) Разумеется, в таком процессе не было бы особого смысла, если бы нужно было создавать (и развертывать) универсальную сборку со всеми локализованными ресурсами. Это было бы не лучше создания отдельных версий приложения для каждого языка, поскольку приложение приходилось бы полностью компоновать заново при каждой необходимости добавить поддержку для новой культуры (или необходимости настроить текст в одном из существующих ресурсов). К счастью, в .NET эта проблема решается с помощью подчиненных сборок (satellite assemblies), которые работают с приложением, но хранятся в отдельных подпапках. При создании локализованного WPF-приложения каждый локализованный BAML-pecypc помещается в отдельную подчиненную сборку. Для того чтобы приложение могло использовать эту сборку, она размещается в под- папке под основной папкой приложения вроде подпапки f r-FR, предназначенной для французского языка. После этого приложение может связываться с этой подчиненной сборкой автоматически путем использования технологии зондирования (probing), которая является частью .NET Framework, начиная с версии 1.0. Самая большая трудность в локализации приложения заключается в рабочем процессе — другими словами в том, как извлечь XAML-файлы из проекта, сделать их локализованными, скомпилировать в подчиненные сборки и затем вернуть обратно в приложение. Это самая запутанная часть "истории" локализации в WPF, поскольку никаких средств (включая Visual Studio), обладающих поддержкой локализации на этапе проектирования, пока не существует. Вероятнее всего такие средства появятся в будущем, а пока WPF все равно позволяет выполнять все, что необходимо для локализации приложения, просто с применением чуть большего количества усилий. Создание локализуемых пользовательских интерфейсов Прежде чем начинать что-либо переводить, сначала нужно разобраться с тем, как приложение будет реагировать на изменение содержимого. Например, если удвоить длину всего текста в пользовательском интерфейсе, как изменится общая компоновка окна? В случае если была разработана действительно гибкая компоновка (см. главу 3), никаких проблем возникнуть не должно. Интерфейс должен быть способен подстроиться в соответствии с динамическим содержимым. Ниже описаны некоторые рекомендуемые приемы, гарантирующие следование правильному пути. • Не применяйте жестко закодированные значения ширины или высоты (или, по крайней мере, не используйте их с элементами, которые содержат непрокручи- ваемое текстовое содержимое).
240 Глава 7. Класс Application • Устанавливайте для свойства Window. SizeToContent значение Width, Height или WidthAndHeight, так чтобы размер окна мог увеличиваться по мере необходимости. (Опять-таки, это является обязательным не всегда; все зависит от структуры окна, но в некоторых случаях это очень полезно.) • Используйте для просмотра текста большого объема элемент ScrollViewer. Другие моменты, которые должны учитываться при локализации В зависимости от языков, для которых должна проводиться локализация приложения, может понадобиться учесть и ряд других моментов. Хотя рассмотрение компоновки пользовательского интерфейса на разных языках выходит за рамки контекста настоящей книги, ниже перечислены некоторые основные детали, которые, возможно, должны быть учтены. • При желании локализовать приложение на язык, имеющий значительно отличающийся набор символов, понадобится использовать другой шрифт. Сделать это можно путем локализации в пользовательском интерфейсе свойства Font Family или применения сложного шрифта вроде Global User Interface, Global Sans Serif или Global Serif, каждый из которых поддерживает все языки. • Может также потребоваться обдумать, каким образом компоновка будет работать при раскладке "справа налево" (вместо стандартной английской раскладки "слева направо"). Например, в арабском и иврите используется раскладка "справа налево". Этим поведением можно управлять посредством установки на каждой странице или в каждом окне приложения свойства FlowDirection. Более подробную информацию о раскладках "справа налево" можно найти в справке Visual Studio, в разделе Bidirectional Features (Средства двунаправленности). Локализация — сложная тема. WPF предлагает работоспособное, но еще недостаточно зрелое решение. После того, как вы познакомитесь с основами, стоит заглянуть в документ Microsoft, посвященный локализации WPF, который доступен по адресу http://wpflocalization. codeplex.com вместе с кодом примеров. Можно ожидать, что в будущем поддержку локализации будет улучшена в инструментах проектирования, таких как Visual Studio и Expression Blend. Подготовка приложения для локализации Следующий шаг связан с включением поддержки локализации для проекта. Для этого потребуется внести только одно изменение, а именно — добавить в файл .csproj проекта где-нибудь в первом разделе <PropertyGroup> следующий элемент: <UICulture>en-US</UICulture> Это укажет компилятору, что языком (культурой) по умолчанию для приложения должен быть английский (США) (очевидно, что при необходимости можно выбрать другой язык). После внесения этой корректировки процесс компоновки изменится. При следующей компиляции приложения будет создана подпапка по имени en-US. Внутри этой папки будет находиться подчиненная сборка с таким же именем, как и у приложения, и расширением .resources.dll (например, LocalizableApplication.resources.dll). В этой сборке будут содержаться все скомпилированные BAML-ресурсы для приложения, которые раньше хранились в основной сборке приложения. Понятие культуры Формально приложение локализуется не для какого-то конкретного языка, а для культуры, в которой учитываются региональные отличия. Культуры обозначаются с помощью двух разделенных дефисом идентификаторов. Первый указывает язык, а второй — страну. Следовательно, fr-CA означает французский язык, на котором разговаривают в Канаде, a fr-FR — французский, на котором
Глава 7. Класс Application 241 общаются во Франции. Полный список имен культур и их идентификаторов можно найти в справке Visual Studio, в разделе, посвященном классу System.Globalization.Culturelnfо. Это предполагает детальную локализацию, что может оказаться чрезмерным К счастью, WPF позволяет локализовать приложение и на основе только языка. Например, при желании определить параметры так, чтобы они применялись для любого франкоговорящего региона, для культуры можно указать только идентификатор f г. Такой подход будет работать, если только не окажется доступной более специфическая культура, в точности соответствующая текущему компьютеру. Теперь при запуске данного приложения среда CLR будет автоматически искать подчиненные сборки в соответствующем каталоге на основании региональных параметров компьютера, и загружать подходящий локализованный ресурс. Например, при запуске приложения на компьютере с культурой f r-FR среда CLR будет искать подпапку f г- FR и использовать подчиненные сборки, которые обнаружит там. Это означает, что для добавления в локализованное приложение поддержки дополнительных культур, потребуется просто добавить соответствующие дополнительные подпапки и подчиненные сборки, не беспокоясь об исходном исполняемом файле приложения. Когда среда CLR начинает зондировать папки на предмет наличия подчиненной сборки, она следует нескольким простым правилам очередности. 1. Сначала она проверяет самый специфический из всех доступных каталог. Это означает, что CLR ищет подчиненную сборку, предназначенную для текущего языка и региона (вроде f r-FR). 2. Если CLR не удается обнаружить такой каталог, она начинает искать подчиненную сборку, предназначенную для текущего языка (такого как f г). 3. Если ей не удается найти и такой каталог, тогда генерируется исключение IOException. Этот список шагов является несколько упрощенным. Тем, кто решит применять глобальный кэш сборок (Global Assembly Cache — GAC) для совместного использования каких-то компонентов во всем компьютере, следует знать, что .NET фактически проверяет GAC в начале шагов 1 и 2. Другими словами, на первом шаге CLR-среда проверяет, нет ли нужной версии сборки в кэше GAC, и если есть, то использует именно ее. То же самое происходит и на шаге 2. Процесс перевода Когда вся необходимая для локализации инфраструктура готова, остается только создать соответствующие подчиненные сборки с альтернативными версиями окон (в форме BAML) и разместить их в подходящих папках. Очевидно, что для того, чтобы сделать это вручную, потребуется приложить немалые усилия. Более того, локализация обычно предполагает привлечение сторонних переводчиков для работы над исходным текстом. Понятное дело, что ожидать наличия у них навыков программирования, достаточных для того, чтобы свободно ориентироваться в проекте Visual Studio, недальновидно (да и доверять им код тоже не стоит). Из-за всего этого необходим какой-нибудь способ для управления процессом локализации. В настоящее время WPF предлагает частичное решение. Оно работает, но подразумевает использование командной строки и имеет одну незаконченную часть. В целом процесс выглядит так, как описано ниже. 1. Вы помечаете в приложении элементы, которые требуется локализовать. При желании можете также добавить комментарии для того, чтобы помочь переводчикам.
242 Глава 7. Класс Application 2. Далее вы извлекаете локализуемые детали в файл .cvs (текстовый файл с разделителями-запятыми) и отправляете его переводчику. 3. После получения переведенной версии этого файла вы снова запускаете утилиту LocBaml, чтобы сгенерировать необходимую подчиненную сборку. Все эти шаги более подробно рассматриваются в последующих разделах. Подготовка элементов разметки к локализации Первое, что необходимо сделать — добавить во все элементы, которые требуется локализовать, специальный атрибут Uid. Например: <Button x:Uid="Button_l" Margin=0" Padding=">A button</Button> Атрибут Uid играет роль подобную той, что выполняет атрибут Name — в данном случае он уникальным образом идентифицирует кнопку в контексте одного XAML- документа. Это позволяет указать локализованный текст только для этой кнопки. Однако существуют несколько причин того, почему в WPF используется атрибут Uid, a не значение Name: имя может быть не назначено, а устанавливаться в соответствии с различными соглашениями и применяться в коде. На самом деле свойство Name само по себе является локализуемым фрагментом информации. На заметку! Очевидно, что текст — это не единственная деталь, которую требуется локализовать. Также следует подумать о шрифтах, размерах шрифтов, полях, отступах, других связанных с выравниванием деталях, и т.д. В WPF каждое свойство, которое может понадобиться локализовать, помечено атрибутом System.Windows.LocalizabilityAttribute. Хотя и необязательно, но атрибут Uid лучше добавить к каждому элементу в каждом окне локализуемого приложения. Это может потребовать приложения массы дополнительных усилий, однако существует инструмент MSBuild, который умеет делать это автоматически. Он используется так: msbuild /trupdateuid LocalizableApplication.csproj Здесь предполагается, что атрибуты Uid должны быть добавлены в приложение под названием LocalizableApplication. Если требуется проверить, у всех ли элементов имеются атрибуты Uid (и не был ли какой-нибудь из них по случайности продублирован), можно запустить MSBuild следующим образом: msbuild /t:checkuid LocalizableApplication.csproj Совет. Инструмент MSBuild легче всего запускать из командной строки Visual Studio (выбрав в меню Start (Пуск) команду Programs^ Microsoft Visual Studio 2010 о Visual Studio ToolsoVisual Studio 2010 Command Prompt (nporpaMMbioMicrosoft Visual Studio 2010о Инструменты Visual StudiooКомандная строка Visual Studio)), так чтобы сразу же устанавливался путь для удобного доступа. После этого можно очень быстро перейти в папку проекта и запустить MSBuild. Генерируемые с помощью MSBuild атрибуты Uid устанавливаются в соответствии с именем элемента управления, к которому относятся. Например: <Button х:Uid="cmdDoSomething" Name="cmdDoSomething" Margin=0" Padding="> Если у элемента нет имени, MSBuild создает менее полезный атрибут Uid на основе имени класса с числовым суффиксом: <TextBlock x:Uid="TextBlock_l" Margin=0">
Глава 7. Класс Application 243 На заметку! Формально данный шаг означает глобализацию приложения, т.е. его подготовку к локализации на различные языки. Даже если прямо сразу локализовать приложение не планируется, все равно лучше подготовить его к локализации. Тогда в будущем приложение можно будет обновлять до другого языка просто за счет развертывания соответствующей сборки. Естественно, глобализация не стоит усилий, если не было потрачено время на анализ пользовательского интерфейса и проверку того, чтобы он имел гибкую компоновку, способную подстраиваться под изменение содержимого (вроде увеличения размеров заголовков на кнопках и т.п.). Извлечение локализуемого содержимого Для извлечения локализуемого содержимого всех элементов служит утилита командной строки LocBaml. В настоящее время LocBaml не поставляется в виде скомпилированного инструмента. Вместо этого по адресу http://msdn.microsoft.com/en-us/ library/ms771568(VS.100) .aspx доступен ее исходный код, который должен компилироваться вручную. Используя LocBaml, следует обязательно находиться именно в той папке, в которой содержится скомпилированная сборка (например, это может быть LocalizableApplication\bin\Debug). Чтобы извлечь список локализуемых деталей, нужно указать LocBaml на подчиненную сборку и задать параметр /parse, как показано ниже: locbaml /parse en-US\LocalizableApplication.resources.dll Утилита LocBaml выполняет в подчиненной сборке поиск всех имеющихся в ней скомпилированных BAML-ресурсов и генерирует файл . cvs с деталями. В данном примере этот файл .csv получит имя LocalizationApplication.resources.csv. Каждая строка в извлеченном файле представляет одно локализуемое свойство, которое применялось для того или иного элемента в документе XAML, и состоит из семи перечисленных ниже значений. • Имя BAML-pecypca (например, LocalizableApplication.g.en-US.resources: windowl.baml). • Идентификатор Uid элемента и имя свойства, подлежащего локализации. Например: StackPanel_l :System.Windows.FrameworkElement.Margin. • Категория локализации. Это значение берется из перечисления LocalizationCategory, которое помогает идентифицировать тип представляемого данным свойством содержимого (длинный текст, заголовок, шрифт, надпись на кнопке, всплывающая подсказка и т.д.). • Значение, показывающее, является ли данное Свойство читабельным (т.е. представленным в виде текста в пользовательском интерфейсе). Все читабельные значения должны локализоваться всегда, в то время как нечитабельные значения могут требовать, а могут и не требовать локализации. • Значение, показывающее, можно ли значение данного свойства изменять переводчику. Всегда равно True, если только специально не указано противоположное. • Дополнительные комментарии, которые были предоставлены для переводчика. Если комментарии не предоставлялись, это значение будет пустым. • Значение свойства. Это именно та деталь, которая и должна локализоваться. Например, предположим, что имеется окно, показанное на рис. 7.6. Его XAML-код разметки выглядит так:
244 Глава 7. Класс Application <Window x: Uid=,,Window_l" x: Clas s = " Local izableAppli cat ion . Window 1" xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="LocalizableApplication" Height=00" Width=00" SizeToContent="WidthAndHeight" > <StackPanel x:Uid="StackPanel_l" Margin=0"> <TextBlock x:Uid="TextBlock_l" Margin=0">One line of text.</TextBlock> <Button x:Uid="cmdDoSomething" Name="cmdDoSomething" Margin=0" Padding="> A button</Button> <TextBlock x:Uid="TextBlock_2" Margin=0"> This is another line of text.</TextBlock> </StackPanel> </Window> • "Local.zabL.i-P One line of text. A button This is another line of text. Рис. 7.6. Окно, которое может быть локализовано Пропустив эту разметку через LocBaml, вы получите информацию, показанную в табл. 7.3. (Для краткости имя BAML-pecypca было опущено, поскольку им всегда является одно и то же окно, ключ ресурса был сокращен так, чтобы в нем не присутствовали полностью уточненные имена, а комментарии, которые в данном случае пусты — просто исключены.) Именно здесь поддержка текущего средства и является несколько ограниченной. Маловероятно, что переводчик пожелает работать непосредственно с файлом .cvs, поскольку информация в этом файле представлена довольно неудобно. Поэтому здесь требуется еще одно средство, которое могло бы выполнить синтаксический анализ этого файла и позволить переводчику работать с ним более эффективно. Можно было бы построить средство, которое извлекает всю эту информацию, отображает значения, имеющее true в свойствах Readable и Modifiable, и позволяет пользователю редактировать соответствующее значение. Однако на момент написания этой книги в составе WPF подобного средства еще не было. Чтобы выполнить простой тест, откройте непосредственно сам этот файл (с помощью Notepad или Excel) и измените в нем последний фрагмент информации — значение, — предоставив вместо него переведенный текст, например: LocalizableApplication.g.en-US.resources:window1.baml, TextBlock_l:System.Windows.Controls.TextBlock.$Content, Text,True,True,, Une ligne de texte. На заметку! Это на самом деле одна строка кода, но здесь она разбивается для того, чтобы уместиться на печатной странице. Используемая культура на этом этапе не указывается. Это делается на следующем этапе, во время компиляции новой подчиненной сборки.
w ей 1-3 о. « 2 I Ф CO I- u CO О О >S О Ш о X 2 О) >» P) s с; га * о с; га о О. О) 2 со га s с; vo .со о ф О. 5 ,ч гН 1 .-ч Ш С rd си .V и fd -р ел * с о н -р fd и н iH а а < ш .—1 Л fd N -н iH fd и о J -р СП н ш PC с < -р -р X ш -р с о -р -р р -р X ш -р о ш с ш -р о с fd О гН <: гН 00 Ен ш р и Ен Ш Р И Ен Ш Р И Ен Ш Р И ^ ш р и Ен Ш Р И Ен Ш Р И Ен Ш Р U Ен Ш Р И ^ ш р и Ен Ш Р И Ен Ш Р И Ен Ш Р И Ен Ш Р И ^ Ф р и Ен Ш СЛ ■Н fd Dm Ш СЛ iH fd р4 ш СЛ iH fd Рм ш СЛ I—i fd Рм ш Р и Ен Ш СЛ --Ч fd Рм ш Р и Ен Ш СЛ <-\ fd Рм ш СЛ <-{ fd Рм ш Р и Ен Ш СЛ iH fd к4 ш с о 2 С О -н -р fd и -н .-ч а а < ш .-ч л fd N -Н iH fd и о J гН 2 О ТЗ С •н 12 -р с ш -р с о и </> гН 2 о тз с •н 12 ш iH -р н Ен ш .-ч -р -н Ен i о ТЗ с -н 15 гН 1 2 о тз с -н 12 ш с о 2 -р д: СП -н ш DC -р с ш е ш ■н и м и о 2 ш е fd и р4 гН 1 2 о ТЗ с н 12 ш с о 2 д: -р ТЗ -н 15 -р с ш е ш iH и .V и о 2 ш е fd и Р4 гН 1 2 о ТЗ с н 15 ш с о 2 -р с ш -р с о и о Ен ш N -н СЛ 2 о тз с -н 15 гН 1 2 о ТЗ с •н 15 ш с о argin N £ -р с ш е ш м и м и о э ш е fd и р4 гН 1 I—I ш с fd P-I м и fd -р СЛ -р X ш Ен -Р С Ш -Р С о и </> .V и о iH PQ -р X ш Ен гН 1 .V и О iH PQ -Р X ш Ен Ш С О rgin N fd S -Р с ш е ш iH и .V и о э ш е fd и Рм гН 1 .V и о iH PQ -Р X ш Ен С О -Р -Р Р PQ -Р С Ш -Р С о и <л- с о -р -р р PQ СП С -Н Л -Р ш е о СЛ о Q Ъ В и ш С О .Margin N -р с ш е ш iH и ^ и о э ш е fd и Рм сп с -н л -р ш е о СЛ о Q ТЗ е и ш с о 2 СП С -Н Т5 Т5 fd о. сп с -н л -р ш е о СЛ о Q Ъ В и -Р X ш Ен -Р С Ш -Р С о и </> .V и о iH PQ -Р X ш Ен CN 1 .V и О iH PQ -Р X ш Ен Ш С О rgin N fd S -Р с ш е ш I—I и м и о 2 ш е fd и Рм CN .V и о iH PQ -р X ш Ен
246 Глава 7. Класс Application Создание подчиненной сборки Теперь можно переходить к созданию подчиненных сборок для других культур. Для решения этой задачи нужна утилита LocBaml, но на этот раз ее следует использовать с параметром /generate. Не забывайте о том, что подчиненная сборка будет содержать альтернативную копию каждого завершенного окна в виде вложенного BAML-pecypca. Для того чтобы создать такие ресурсы, утилита locbaml должна просмотреть исходную подчиненную сборку, заменить все новые значения из переведенного файла . cvs и затем сгенерировать новую подчиненную сборку. А это означает, что утилите LocBaml понадобится указать на исходную подчиненную сборку и (с помощью параметра /trans:) на переведенный список значений. Ей также должна быть сообщена культура, представляемая этой сборкой (в параметре /cul:). Имейте в виду, что культуры определяются с помощью двухсоставных идентификаторов, которые перечислены в описании класса System. Globalization.CultureInfo. Ниже показан иллюстрирующий все это пример: locbaml /generate en-US\LocalizableApplication.resources.dll /trans:LocalizableApplication.resources.French.csv /cul:fr-FR /out:fr-FR Эта команда делает описанные ниже вещи. • Использует исходную подчиненную сборку en-US\LocalizedApplication. resources.dll. • Использует переведенный .сvs-файл French.cvs. • Использует культуру "французский язык (Франция)". • Помещает вывод в подпапку fr-FR (которая должна уже существовать). Хотя может показаться, что это должно выполняться неявным образом на основании используемой культуры, однако, данную деталь все-таки следует предоставлять обязательно. При выполнении этой команды утилита LocBaml создаст новую версию сборки LocalizableApplication.resources.dll с переведенными значениями и поместит ее в подпапку приложения с именем fr-FR. Теперь, в случае запуска данного приложения на компьютере, где для параметра культуры установлен французский язык (Франция), автоматически появится альтернативная версия окна. Изменить культуру можно в доступном через панель управления разделе Regional and Language Options (Язык и региональные стандарты) или, чтобы упростить тестирование, с помощью кода, изменяющего параметр культуры только текущего потока. Это нужно сделать до создания и отображения любых окон, поэтому имеет смысл использовать событие приложения или просто конструктор класса приложения, как показано ниже. public partial class App : System.Windows.Application { public App() { Thread.CurrentThread.CurrentUICulture = new Culturelnfo("fr-FR"); } } На рис. 7.7 можно видеть результат.
Глава 7. Класс Application 247 ! LocalizableAp.J. i в- tevrf Une ligne de texte Un bouton _J С est une autre ligne de texte. Рис. 7.7. Окно, локализованное для французского языка _ocal zableApJ. Une ligne de text С est une auti X Quelque chose de mauvais s'est produit OK Рис. 7.8. Использование локализованной строки Не все локализуемое содержимое определяется в пользовательском интерфейсе как локализуемое свойство. Например, может потребоваться сделать так, чтобы в случае возникновения неблагоприятной ситуации отображалось сообщение об ошибке. В XAML этого легче всего добиться с помощью объектных ресурсов (о которых более подробно речь пойдет в главе 10). Например, строки этого сообщения об ошибке можно было бы сохранить в виде ресурсов в конкретном окне, в ресурсах для всего приложения или в словаре ресурсов, предназначенном для совместного использования множеством приложений: <Window.Resources> <s:String x:Uid="s:String_l" x:Key="Error">Something bad happened.</s:String> </ Window.Resources > При запуске LocBaml строки в этом файле также добавляются в локализуемое содержимое. Далее во время компиляции эта информация, соответственно, добавляется в подчиненную сборку, что гарантирует отображение сообщений об ошибках на корректном языке (рис. 7.8). На заметку! Очевидным слабым местом в текущей системе является то, что в ней трудно подстраиваться под постоянно совершенствующийся пользовательский интерфейс. Утилита LocBaml всегда создает новый файл, так что в случае перемещения элементов управления в другие окна или замены одних элементов управления другими, скорее всего, придется создавать новый список переводов с нуля. Резюме В этой главе был дан детальный экскурс в модель приложений WPF. Для управления простым приложением WPF не нужно делать ничего, кроме создания экземпляра класса Application и вызова метода Run(). Однако в большинстве приложений строится специальный класс, унаследованный от Application. Как было показано, этот специальный класс является идеальным инструментом для обработки событий приложения и удачным местом для отслеживания окон приложения или реализации шаблона приложения одного экземпляра. Во второй части главы рассматривались ресурсы сборки, которые позволяют упаковывать двоичные данные и встраивать их в приложения. Вы также ознакомились с локализацией и узнали, как некоторые инструменты командной строки (msbuild.exe и locbaml.exe) позволяют создавать специфичные для культуры версии интерфейсов, избавляя от некоторой части ручной работы.
ГЛАВА 8 Привязка элементов В своей простейшей форме привязка данных — это отношение, которое сообщает WPF о необходимости извлечения некоторой информации из исходного объекта и использования его для установки свойства в целевом объекте. Целевое свойство — всегда свойство зависимости, и обычно оно находится в элементе WPF: в конце концов, конечной целью привязки данных WPF является отображение некоторой информации в пользовательском интерфейсе. Однако объектом-источником может быть почти что угодно, от другого элемента WPF до объекта ADO.NET (подобного DataTable и DataRow) или созданного вами объекта, хранящего только данные. В этой главе рассмотрение привязки данных начинается с простейшего подхода: привязка элемента к элементу. В главе 19 мы вернемся к теме привязки данных и ознакомимся с наиболее эффективным способом передачи информации из базы данных в формы данных. Связывание элементов вместе Простейший сценарий привязки данных подразумевает ситуацию, когда исходным объектом является элемент WPF, а исходным свойством — свойство зависимости. Причина в том, что свойство зависимости имеет встроенную поддержку уведомлений об изменениях, как было описано в главе 4. В результате, когда значение свойства зависимости изменяется в исходном объекте, привязанное свойство целевого объекта немедленно обновляется. Это именно то, что требуется, и происходит оно без необходимости построения любой дополнительной инфраструктуры. На заметку! Хотя привязка элемента к элементу является простейшим подходом, большинство разработчиков заинтересовано в нахождении самого общего подхода для реальных приложений. В общем, большая часть работы по привязке данных будет тратиться на привязку элементов к объектам данных. Это позволит отображать информацию, извлекаемую из внешнего источника (такого как база данных или файл). Однако привязка элемента к элементу также часто бывает полезной. Например, ее можно использовать для автоматизации способа, которым элементы взаимодействуют, так что когда пользователь модифицирует один элемент управления, другой элемент обновляется автоматически. Это ценное сокращение, которое избавляет от написания громоздкого и рутинного кода (и это прием, не доступный в предыдущем поколении приложений Windows Forns). Чтобы понять, как привязывать один элемент к другому, рассмотрим простое окно, показанное на рис. 8.1. Оно содержит два элемента управления: Slider (ползунок) и TextBlock (текстовый блок) с единственной строкой текста. Перемещение ползунка вправо приводит к немедленному увеличению размера шрифта текста, а перемещение влево — к уменьшению размера шрифта.
Глава 8. Привязка элементов 249 Ясно, что такое поведение нетрудно реализовать и в коде. Понадобилось бы просто реагировать на событие Slider.ValueChanged и копировать текущее значение из ползунка в TextBlock. Однако привязка данных делает это еще проще. Совет. Привязка данных обладает еще одним преимуществом: она позволяет создавать простые страницы XAML, которые можно выполнять в браузере без компиляции их в приложения. (Как известно из главы 2, если файл XAML имеет связанный файл отделенного кода, он не может быть открыт в браузере.) Выражение привязки При использовании привязки данных никаких изменений в исходный объект (в рассматриваемом примере это Slider) вносить не понадобится. Просто задайте нужный диапазон значений, как это делается обычно: <Slider Name="sliderFontSize11 Margin=M Minimum="l11 Maximum= 011 Value=011 TickFrequency="l11 TickPlacement="TopLef t"> </Slider> Привязка определена в элементе TextBlock. Вместо установки FontSize с использованием литерального значения применяется выражение привязки: <TextBlock Margin=0M Text="Simple Text" Name=,,lblSampleText" FontSize="{Binding ElementName=sliderFontSize, Path=Value } " > </TextBlock> Выражения привязки данных используют расширение разметки XAML (и потому помещаются в круглые скобки). В начале идет слово Binding, потому что создается экземпляр класса System.Windows.Data.Binding. Хотя объект Binding может быть сконфигурирован различными способами, в данной ситуации необходимо установить только два свойства: ElementName, которое указывает исходный элемент, и Path, указывающее свойство в исходном элементе. Вместо Property используется Path, потому что Path может указывать на свойство свойства (например, FontFamily.Source) или индексатор, используемый свойством (например, Content.Children[0]). Путь можете включать множество фрагментов, переходящих от свойства к свойству, и т.д. Чтобы сослаться на присоединенное свойство (свойство, которое определено в другом классе, но применяется к привязанному элементу), имя свойства должно быть указано в круглых скобках. Например, в случае привязки к элементу, помещенному в Grid, путь (Grid.Row) извлекает номер строки, в которой он находится. Ошибки привязки WPF не генерирует исключения для уведомления о проблемах привязки данных. Если указан несуществующий элемент или свойство, никакого сообщения об этом не будет; вместо этого данные просто не попадут в целевое свойство. На первый взгляд это может показаться кошмаром для отладки. К счастью, WPF выводит трассировочную информацию, которая детализирует сбои в привязке. Во время отладки приложения эта информация появляется в выходном окне Visual Studio. Например, попытка привязки к несуществующему свойству приводит к выводу в выходное окно следующего сообщения: System.Windows.Data Error: 35 : BindingExpression path error: 'Tex1 property not found on 'object' ''TextBox' (Name='txtFontSize')'. BindingExpression:Path=Tex; Dataltem='TextBox' (Name='txtFontSize ' );
250 Глава 8. Привязка элементов target element is 'TextBox1 (Name=I1); target property is 'Text1 (type 'String') System. Windows. Data Ошибка: 35 : ошибка пути BmdmgExpression: Свойство 'Тех' не найдено в 'объекте' ''TextBox' (Name='txtFontSize') '. BindingExpression:Path=Tex; Dataltem='TextBox ' (Name='txtFontSize'); целевой элемент - 'TextBox' (Name='') ; целевое свойство - 'Text' (типа 'String') Среда WPF также игнорирует любые исключения, которые генерируются при попытке читать исходное свойство, и молча поглощает исключение, возникающее, если исходные данные не могут быть приведены к типу данных целевого свойства. Однако есть и другой способ справиться с этой проблемой — можно сообщить WPF о необходимости изменения внешнего вида исходного элемента для индикации возникшей ошибки. Например, неверный ввод можно пометить значком с восклицательным знаком или рамкой красного цвета. Более подробно проверка достоверности рассматривается в главе 19. Режимы привязки Одним из ценных особенностей привязки данных является то, что цель обновляется автоматически, независимо от того, как модифицируется источник. В этом примере источник может быть модифицирован только в одном направлении — через взаимодействие пользователя с ползунком. Однако рассмотрим несколько усложненную версию этого примера, в которой добавляется несколько кнопок, причем каждая из них применяет предварительно установленное значение ползунка. Новое окно показано на рис. 8.2. В результате щелчка на кнопке Set to Large (Установить крупный) запускается следующий код: private void cmd_SetLarge(object sender, RoutedEventArgs e) { sliderFontSize.Value = 30; } Этот код устанавливает значение ползунка, которое, в свою очередь, изменяет размер шрифта текста через привязку данных. Это то же самое, как если бы вы двигали ползунок вручную. Тем не менее, следующий код не работает так хорошо: private void cmd_SetLarge(object sender, RoutedEventArgs e) { lblSampleText.FontSize = 30; } I i ElementToElementBinding l_E?JL Ш Simple Text » ElementToElementBinding Simple Text :£}.: Рис. 8.1. Элементы управления, объединенные привязкой данных Рис. 8.2. Программная модификация источника данных
Глава 8. Привязка элементов 251 Он устанавливает шрифт текстового поля непосредственно. В результате позиция ползунка не приводится в соответствие с новым значением. Хуже того, это разрушает привязку размера шрифта и заменяет его литеральным значением. Теперь, если передвинуть ползунок, текстовое поле вообще не изменится. Интересно, что существует способ заставить данные перемещаться в обоих направлениях: от источника к цели и от цели к источнику. Трюк заключается в установке свойства Mode объекта Binding. Ниже приведена усовершенствованная двунаправленная привязка, которая позволяет применять значения либо к источнику, либо к цели, и заставит противоположную часть привязки обновлять себя автоматически: <TextBlock Margin=,,10" Text="Simple Text" Name=,,lblSampleText" FontSize="{Binding ElementName=sliderFontSize, Path=Value, Mode=TwoWay} " > </TextBlock> В рассматриваемом примере нет причин применять двунаправленную привязку (которая требует больше накладных расходов), поскольку эту проблему можно решить с помощью кода. Однако рассмотрим вариант этого примера, включающий текстовое поле, в котором пользователь может точно устанавливать размер шрифта. Этому текстовому полю понадобится двунаправленная привязка, чтобы оно могло как применять пользовательские изменения, так и отражать последнее значение размера, когда оно изменяется другим путем. Данный пример будет представлен в следующем разделе. При установке свойства Binding.Mode можно использовать одно из пяти значений перечисления System.Windows.Data.BindingMode. В табл. 8.1 приведен их полный список. Таблица 8.1. Значения из перечисления BindingMode Имя Описание OneWay Целевое свойство обновляется при изменениях исходного свойства TwoWay Целевое свойство обновляется при изменениях исходного свойства, а исходное свойство обновляется при изменении целевого свойства OneTime Целевое свойство устанавливается изначально на основе значения исходного свойства. Однако с этого момента изменения игнорируются (если только привязка не устанавливается на совершенно другой объект или не вызывается BondingExpression.UpdateTargetO, как описано далее в этой главе). Обычно этот режим используется для сокращения накладных расходов, если известно, что целевое свойство не изменится OneWayToSource Подобно OneWay, но действует в обратном направлении. Исходное свойство обновляется, когда изменяется целевое свойство (что может показаться несколько странным), но целевое свойство никогда не обновляется Default Этот тип привязки зависит от целевого свойства. Это либо TwoWay (для устанавливаемых пользователем свойств, таких как TextBox.Text), либо OneWay (для всего остального). Все привязки используют данный подход, если только не указано иное На рис. 8.3 демонстрируется разница. Вы уже видели OneWay и TwoWay. Значение OneTime достаточно очевидно. Оставшиеся два варианта требуют ряда дополнительных исследований.
252 Глава 8. Привязка элементов Исходный объект г Свойство 1 N ) -^ ^ -Опе\ OneWay - OneWayToSource - TwoWay Целевой объект Свойство зависимости (устанавливается с помощью привязки) Рис. 8.3. Различные направления привязки свойств Привязка OneWayToSource Может возникнуть вопрос: зачем нужны две опции — и OneWay, и OneWayToSource? В конце концов, оба значения создают однонаправленную привязку, которая работает одинаковым образом. Единственное отличие в том, куда помещено выражение привязки. По сути, OneWayToSource позволяет поменять местами источник и цель, поместив выражение в то, что обычно считается источником привязки. Наиболее общая причина использования этого трюка состоит в установке свойства, которое не является свойством зависимости. Как упоминалось в начале этой главы, выражения привязки могут применяться только для установки свойств зависимости. Однако с помощью OneWayToSource это ограничение можно обойти, предоставляя в качестве свойства, поставляющего значение, свойство зависимости. Привязка Def aut Изначально кажется логичным предположить, что все привязки однонаправленные, если только явно не указано иное. (В конце концов, именно так работает простой пример с ползунком.) Но на самом деле это не так. Чтобы убедиться в этом, вернемся к примеру с привязанным текстовым полем и позволим редактировать текущий размер шрифта. Если убрать установку Mode=TwoWay, этот пример все равно будет работать точно также. Причина в том, что WPF использует разные значения Mode по умолчанию, в зависимости от привязываемого свойства. (Формально в каждом свойстве зависимости присутствует фрагмент метаданных — флаг FrameworkPropertyMetadata. BindsTwoWayByDefault, который указывает, какую привязку должно использовать свойство: однонаправленную или двунаправленную). Часто значение по умолчанию — именно то, что и нужно. Тем не менее, можно представить пример с текстовым полем только для чтения, которое пользователь не может изменять. В этом случае удается слегка сократить накладные расходы, установив режим однонаправленной привязки. В качестве общего эмпирического правила: всегда неплохо явно устанавливать режим. Даже в случае текстового поля стоит подчеркнуть, что нужна двунаправленная привязка, включив свойство Mode. Создание привязки в коде При построении окна обычно наиболее эффективно объявлять выражение привязки в разметке XAML с помощью расширения разметки Binding. Тем не менее, допускается также создавать привязку и в коде. Вот как можно создать привязку для элемента Text Bloc к, показанного в предыдущем примере:
Глава 8. Привязка элементов 253 Binding binding = new Binding(); binding.Source = sliderFontSize; binding.Path = new PropertyPath("Value"); binding.Mode = BindingMode.TwoWay; lblSampleText.SetBinding(TextBlock.FontSizeProperty, binding); Для удаления привязки в коде предусмотрены два статических метода класса BindingOperations. Метод ClearBindingO принимает ссылку на свойство зависимости, которое имеет привязку, подлежащую удалению, а метод ClearAllBindingsO удаляет все привязки данных элемента: BindingOperations.ClearAllBindings(lblSampleText); И ClearBindingO, и ClearAllBindingsO используют метод ClearValueO, который каждый элемент наследует от базового класса DependencuObject. Метод ClearValue () просто удаляет локальное значение свойства (которым в данном случае является выражение привязки). Привязка на основе разметки применяется намного чаще, чем программная привязка, потому что она яснее и требует меньше работы. В этой главе во всех примерах для создания привязок используется разметка. Однако код будет применяться для создания привязки в некоторых специализированных сценариях. • Создание динамических привязок. Если необходимо тонко настраивать привязку на основе другой информации времени выполнения или создавать разные привязки в зависимости от обстоятельств, имеет смысл делать это в коде. (В качестве альтернативы можно было бы определить все необходимые привязки в коллекции Resources окна и просто добавить код, который вызывает SetBinding() с соответствующим объектом привязки.) • Удаление привязки. Чтобы удалить привязку и получить возможность установки свойства обычным образом, понадобится помощь метода ClearBindingO или ClearAllBindingsO. Недостаточно просто присвоить новое значение свойству. В случае использования двунаправленной привязки установленное значение распространится на привязанный объект, и оба свойства останутся синхронизированными. На заметку! С помощью методов ClearBindingO и ClearAllBindingsO можно удалить любую привязку. Не имеет значения, применялась привязка программно или в коде XAML • Создание специальных элементов управления. Чтобы облегчить для других модификацию визуального представления специального элемента управления, который разрабатывается, определенные детали (такие как обработчики событий и выражения привязки данных) понадобится перенести в код разметки. В главе 18 представлен пользовательский элемент управления для выбора цвета, который использует код для создания своих привязок. Множественные привязки Хотя в предыдущий пример включена только одиночная привязка, останавливаться на этом не обязательно. При желании можно заставить TextBlock отображать текст из текстового поля, брать текущие цвета фона и переднего плана из отдельного списка цветов и т.д. Например: <TextBlock Margin=" Name="lblSampleText" FontSize="{Binding ElementName=sliderFontSize, Path=Value}" Text="{Binding ElementName=txtContent, Path=Text}" Foreground="{Binding ElementName=lstColors, Path=SelectedItem.Tag}" > </TextBlock>
254 Глава 8. Привязка элементов На рис. 8.4 показан элемент TextBlockc тремя привязками. Допускается также реализовать привязку данных. Например, можно создать выражение привязки для свойства TextBox.Text, связывающее его со свойством TextBlock.FontSize, которое содержит выражение привязки, связывающее со свойством Slider.Value. В этом случае, когда пользователь перетаскивает ползунок в новую позицию, значение передается от Slider в TextBlock и затем из TextBlock в TextBox. Хотя все работает прозрачно, более ясный подход состоит в том, чтобы привязать элементы как можно ближе к данным, которые они используют. В описанном здесь примере необходимо предусмотреть привязку и TextBlock, и TextBox непосредственно к свойству Slider.Value. Все становится немного более интересно, когда на целевое свойство должны оказывать влияние более одного источника, например, если нужно иметь две равноправные привязки, устанавливающие одно и то же свойство. На первый взгляд это кажется невозможным. Однако существует несколько способов решения. Простейший подход состоит в изменении режима привязки данных. Как уже известно, свойство Mode позволяет модифицировать способ работы привязки так, что данные передаются не только от источника к цели, но и от цели к источнику. С помощью такого приема можно создать несколько выражений привязки, устанавливающих одно и то же свойство. Последнее из них будет иметь эффект. Чтобы понять, как это работает, рассмотрим вариацию примера элемента — панели с ползунком, который включает текстовое поле, куда можно поместить точное значение размера шрифта. В этом примере (рис. 8.5) свойство TextBlock.FontSize может быть установлено двумя путями: перетаскиванием ползунка или вводом в текстовом поле размера шрифта. Все элементы управления синхронизированы так, что если вводится новое число в текстовом поле, размер шрифта текста примера изменяется и ползунок перемещается в соответствующую позицию. Как уже упоминалось, к свойству TextBlock.FontSize можно применять только одну привязку данных. Поэтому имеет смысл оставить свойство TextBlock.FontSize в том виде, как оно есть, чтобы оно привязывалось прямо к ползунку: <TextBlock Margin=0" Text="Simple Text" Name="lblSampleText" FontSize="{Binding ElementName=sliderFontSize, Path=Value, Mode=TwoWay}" > </TextBlock> * MultipleBmdings Here's my text) Blue Dark Blue Light Blue jQ Here's my text L f, 1 • ' ElementToElementBinding Simple Text Exact Size: 34( i Q IInuW - 1 Рис. 8.4. Элемент TextBlock с тремя привязками Рис. 8.5. Привязка двух свойств к размеру шрифта
Глава 8. Привязка элементов 255 Хотя добавить другую привязку к свойству Font Size нельзя, можно привязать новый элемент управления TextBox к свойству TextBlock.FontSize. Ниже показана необходимая для этого разметка: <TextBox Text="{Binding ElementName=lblSampleText, Path=FontSize, Mode=TwoWay}"> </TextBox> Теперь при каждом изменении свойства TextBlock.FontSize текущее значение будет вставляться в текстовое поле. Более того, значение в текстовом поле можно редактировать, применяя указанный размер шрифта. Обратите внимание, что для того, чтобы пример работал, свойство TextBlock.Text должно использовать двунаправленную привязку, передающую данные в обоих направлениях. В противном случае текстовое поле сможет отображать значение TextBlock.FontSize, но не позволит изменять его. С этим примером связано несколько нюансов. • Поскольку значение свойства Slider.Value имеет тип double, при перетаскивании ползунка получается дробное значение размера. Установив свойство TickFrequency в 1 (или в некоторый целочисленный интервал), В свойство InSnalToEnabled в true, можно ограничить значение ползунка только целыми величинами. • Текстовое поле позволяет вводить буквы и другие нечисловые символы. В таком случае значение текстового поля не сможет быть интерпретировано как число. В результате привязка данных молча потерпит неудачу, а значение размера шрифта станет равно 0. Другой подход мог бы состоять в обработке нажатий клавиш в текстовом поле, чтобы вообще предотвратить неправильный ввод, либо в использовании проверки достоверности, как описано в главе 19. • Изменения, которые вносятся в текстовое поле, не будут применены до тех пор, пока текстовое поле не потеряет фокус (например, когда с помощью клавиши <ТаЬ> происходит переход на другой элемент управления). Если такое поведение не подходит, можно обеспечить непрерывное обновление, используя свойство UpdateSourceTrigger объекта Binding, как вы узнаете далее в разделе "Обновления привязок". Интересно, что показанное здесь решение — не единственный способ привязки текстового поля. Столь же разумно конфигурировать текстовое поле таким образом, чтобы оно изменяло значение свойства Slider.Value вместо свойства TextBlock.FontSize: <TextBox Text="{Binding ElementName=sliderFontSize, Path=Value, Mode=TwoWay}"> </TextBox> Теперь изменение текстового поля инициирует изменение положения ползунка, которое затем установит новый размер шрифта текста. Опять-таки, данный подход работает только с двунаправленной привязкой данных. И, наконец, можно поменять местами роли ползунка и текстового поля, чтобы ползунок привязывался к текстовому полю. Для этого понадобится создать непривязанный элемент TextBox и назначить ему имя: <TextBox Name="txtFontSize" Text=0"> </TextBox> Затем можно привязать свойство Slider.Value, как показано ниже: <Slider Name="sliderFontSize" Margin=" Minimum="l" Maximum=0" Value="{Binding ElementName=txtFontSize, Path=Text, Mode=TwoWay}" TickFreguency="l" TickPlacement="TopLeft"> </Slider>
256 Глава 8. Привязка элементов Теперь ползунок под контролем. При отображении окна в первый раз значение свойства TextBox.Text извлекается и применяется для установки его значения Value. Когда пользователь перетаскивает ползунок в новую позицию, он использует привязку для обновления текстового поля. В качестве альтернативы пользователь может обновить значение ползунка (и размер шрифта текста примера), вводя значение в текстовое поле. На заметку! В случае привязки Slider.Value текстовое поле ведет себя несколько иначе, чем в предыдущих двух примерах. Любые изменения, которые вносятся в текстовое поле, применяются немедленно, вместо того, чтобы ожидать момента утери фокуса. Более подробно об этом речь пойдет в следующем разделе. Как видно из примера, двунаправленные привязки обеспечивает значительную гибкость. Их можно использовать для применения изменений от источника к цели и от цели к источнику. Допускается их применение в комбинации, что позволяет создать неожиданно сложные окна без какого-либо кода. Обычно решение относительно того, куда применять выражение привязки, диктуется логикой модели кодирования. В предыдущем примере, возможно, было бы больше смысла поместить привязку в свойство TextBox.Text вместо свойства Slider.Value, потому что текстовое поле — это необязательное дополнение к вполне готовому примеру, а не основной ингредиент, на который полагается ползунок. Также имело бы больше смысла привязать текстовое поле непосредственно к свойству TextBlock.FontSize вместо свойства Slider.Value. (Концептуально вы заинтересованы в том, чтобы видеть текущий размер шрифта, и ползунок — только один из способов его установки. Даже несмотря на то, что положение ползунка совпадает с размером шрифта, это — необязательная дополнительная деталь, если вы пытаетесь написать максимально ясную разметку.) Конечно, эти решения субъективны и определяются стилем кодирования. Наиболее важный урок состоит в том, что три подхода могут обеспечить одинаковое поведение. В следующих разделах мы рассмотрим две детали, касающиеся этого примера. Во-первых, речь пойдет о возможных выборах при установке направления привязки. Во-вторых, будет показано, каким образом точно указать WPF, когда имеет смысл обновлять исходное свойство при двунаправленной привязке. Обновления привязок В примере, который показан на рис. 8.5 (где TextBox.Text привязывается к TextBlock.FontSize) имеется еще один нюанс. При попытке изменить отображаемый размер шрифта, вводя значение в текстовое поле, ничего не происходит. Изменение не применяется до тех пор, пока не будет совершен переход на другой элемент. Это поведение отличается от поведения, которое демонстрировалось в примере с ползунком. Там новый размер шрифта применялся после перетаскивания ползунка в другую позицию, т.е. в переходе на другой элемент вообще не было необходимости. Чтобы понять это различие, следует повнимательнее присмотреться к выражениями привязки, которые используются этими двумя элементами управления. Когда применяется привязка One Way или TwoWay, измененное значение распространяется от источника к цели немедленно. В случае с ползунком есть однонаправленное выражение привязки в TextBlock. Таким образом, изменения в свойстве Slider.Value немедленно отражаются в свойстве TextBlock.FontSize. To же поведение имеет место в примере с текстовым полем: изменения в источнике (которым является TextBlock.FontSize) немедленно влияют на цель (TextBox.Text).
Глава 8. Привязка элементов 257 Однако изменения, протекающие в обратном направлении — от цели к источнику — не обязательно происходят немедленно. Вместо этого их поведение управляется свойством Binding.UpdateSourceTrigger, которое принимает одно из значений, описанных в табл. 8.2. Когда текст берется из текстового поля и используется для обновления свойства TextBlock.FontSize, это пример обновления цель-источник, которое использует поведение UpdateSourceTrigger. Lost Focus. Таблица 8.2. Значения перечисления UpdateSourceTrigger Имя Описание PropertyChanged Источник обновляется немедленно, когда изменяется целевое свойство Источник обновляется немедленно, когда изменяется целевое свойство и цель теряет фокус Источник не обновляется, пока не будет вызван метод BindingExpression.UpdateSourceO Поведение обновления определяется метаданными целевого свойства (формально — его свойства FrameworkPropertyMetadata. Def aultUpdateSourceTrigger). Для большинства свойств поведением по умолчанию будет PropertyChanged, хотя свойство TextBox.Text обладает поведением по умолчанию LostFocus Помните, что значения в табл. 8.2 не оказывают эффекта на обновление цели. Они просто управляют тем, как обновляется источник в привязках TwoWay и OneWayToSource. Вооружившись этим знанием, можно усовершенствовать пример с текстовым полем, чтобы изменения применялись к размеру шрифта по мере их ввода в текстовое поле: <TextBox Text="{Binding ElementName=txtSampleText, Path=FontSize, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Name="txtFontSize"></TextBox> Совет. Поведением по умолчанию свойства TextBox.Text является LostFocus просто потому, что текст в текстовом поле будет изменяться непрерывно в процессе пользовательского ввода, вызывая множественные обновления. В зависимости от того, как исходный элемент управления обновляет себя, режим обновления PropertyChanged может сделать приложение более медлительным. Вдобавок это может заставить исходный объект обновлять себя до завершения редактирования, что создаст проблемы при проверке достоверности. Для полного контроля над моментом обновления исходного объекта можно выбрать режим UpdateSourceTrigger.Explicit. Если воспользоваться этим подходом в примере с текстовым полем, то когда текстовое поле утратит фокус, ничего не произойдет. Вместо этого код должен будет вручную инициировать обновление. Например, можно было бы добавить кнопку Add (Добавить), которая вызовет метод BindingExpression. UpdateSourceO, инициируя немедленное обновление размера шрифта. Разумеется, прежде чем можно будет вызвать метод BindingExpression. UpdateSourceO, нужен способ получения объекта BindingExpression. Объект BindingExpression — это тонкая упаковка, которая содержит в себе две вещи: уже известный объект Binding (предоставленный через свойство BindingExpression. ParentBinding) и объект, привязанный от источника (BindingExpression.Dataltem). Вдобавок объект BindingExpression предоставляет два метода для запуска немедленного обновления одной части привязки: UpdateSourceO и UpdateTarget(). LostFocus Explicit Default
258 Глава 8. Привязка элементов Для получения объекта BindingExpression используется метод GetBindingExpression (), унаследованный каждым элементом от базового класса FrameworkElement, которому передается целевое свойство, имеющее привязку. Ниже приведен пример, в котором изменяется размер шрифта в TextBlock на основе текущего текста в текстовом поле: // Получить привязку, примененную к текстовому полю. BindingExpression binding = txtFontSize.GetBindingExpression(TextBox.TextProperty); // Обновить привязанный источник (the TextBlock). binding.UpdateSource (); Привязка к объектам, не являющимся элементами До сих пор добавлялись привязки, которые устанавливали связь между двумя элементами. Однако в приложениях, управляемых данными, чаще создаются выражения привязки, которые извлекают данные из невизуальных объектов. Единственное требование, которое должно при этом соблюдаться — информация, которую необходимо отобразить, должны храниться в общедоступных свойствах. Инфраструктура привязки данных WPF не может извлекать приватную информацию или читать общедоступные поля. При привязке к объекту, не являющемуся элементом, следует отказаться от свойства Binding.ElementName и применять вместо него одно из следующих свойств. 1. Source. Ссылка, указывающая на исходный объект; другими словами, это объект, поставляющий данные. 2. RelativeSource. Указывает на исходный объект, использующий объект Relative Sou r се, который позволяет базировать ссылку на текущем элементе. Это специализированный инструмент, который удобен при написании шаблонов элементов управления и шаблонов данных. 3. DataContext. Если источник не был указан с помощью свойства Source или RelativeSource, то среда WPF производит поиск в дереве элементов, начиная с текущего элемента. Она проверяет свойство DataContext каждого элемента и использует первый из них, который не равен null. Свойство DataContext исключительно полезно, когда нужно привязать несколько свойств одного объекта к разным элементам, потому что можно установить свойство DataContext высокоуровневого объекта контейнера, вместо его установки непосредственно на целевой элемент. В следующем разделе эти варианты описаны более подробно. Свойство Source Свойство Source достаточно прямолинейно. Единственный момент, который следует учитывать — объект данных должен быть сделан удобным для привязки. Как будет показано, для получения объекта данных существует несколько подходов: извлечь его из ресурса, генерировать программно или получить от поставщика данных. Простейший вариант — установить Source в некоторый готовый и доступный статический объект. Например, можно создать статический объект в коде и использовать его. Или же можно применить ингредиент из библиотеки классов .NET, как показано ниже: <TextBlock Text="{Binding Source={х:Static SystemFonts.IconFontFamily}, Path=Source}"></TextBlock> Это выражение привязки получает объект Font Family, который предоставлен свойством SystemFonts.IconFontFamily. (Обратите внимание, что для установки свойства Binding.Source понадобится помощь расширения разметки Static.) Затем свойство
Глава 8. Привязка элементов 259 Binding.Path устанавливается в свойство FontFamily.Source, которое выдает имя семейства шрифтов. Результатом будет единственная строка текста. В Windows Vista или Windows 7 имя шрифта выглядит как Segoe UI. Другой вариант состоит в привязке к объекту, который ранее создавался в виде ресурса. Например, следующая разметка создает объект FontFamily, указывающий на шрифт Calibri: <Window.Resources> <FontFamily x:Key="CustomFont">Calibri</FontFamily> </Window.Resources> Ниже показан элемент Text Block, привязанный к ресурсу: <TextBlock Text="{Binding Source={StaticResource CustomFont}, Path=Source } "> </TextBlock> После этого появляется текст "CalibrT. Свойство RelativeSource Свойство RelativeSource позволяет установить его в исходный объект на основе его отношения к целевому объекту. Например, свойством RelativeSource можно воспользоваться для привязки элемента к самому себе или для привязки к родительскому элементу, который находится в неизвестном количестве уровней выше в дереве элементов. Для установки свойства Binding . Relat lveSour се применяется объект RelativeSource. Это несколько усложняет синтаксис, поскольку нужно создать объект Binding и внутри него — вложенный объект RelativeSource. Один вариант состоит в использовании синтаксиса установки свойства вместо расширения разметки Binding. Например, в следующем коде создается объект Binding для свойства TextBlock. Text. Объект Binding использует RelativeSource, которое ищет родительское окно и отображает заголовок окна. <TextBlock> <TextBlock.Text> <Binding Path="Title"> <Binding.RelativeSource> <RelativeSource Mode="FindAncestor" AncestorType="{x:Type Window}" /> </Binding.RelativeSource> </Binding> </TextBlock.Text> </TextBlock> Для объекта RelativeSource выбран режим FindAncestor, который заставляет его осуществлять поиск вверх по дереву элементов до тех пор, пока не будет найден тип элемента, определенный свойством AncestorType. Наиболее общий способ записи этой привязки состоит в комбинировании ее в одну строку, используя расширения разметки Binding и RelativeSource, как показано ниже: <TextBlock Text="{Binding Path=Title, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}} }"> </TextBlock> Режим FindAncestor — один из четырех возможных вариантов при создании объекта RelativeSource. Все варианты кратко описаны в табл. 8.3.
260 Глава 8. Привязка элементов Таблица 8.3. Значения перечисления RelativeSourceMode Имя Описание Self Выражение привязывается к другому свойству того же элемента FindAncestor Выражение привязывается к родительскому элементу WPF будет проводить поиск вверх по дереву элементов, пока не найдет нужный родительский элемент. Чтобы указать родителя, необходимо также установить свойство AncestorType для индикации типа родительского элемента, который должен быть найден Дополнительно с помощью свойства AncestorLevel можно пропустить определенное количество совпадений указанного элемента. Например, если требуется привязка к третьему элементу типа ListBoxItem при восхождении вверх по дереву, то следует установить AncestorType={x: Type ListBoxItem} и AncestorLevel=3, тем самым пропуская первые два ListBoxItem. По умолчанию AncestorLevel равен 1, и поиск прекращается на первом найденном элементе Previous Data Выражение осуществляет привязку к предыдущему элементу данных в списке, привязанном к данным. Это можно использовать в элементе списка TemplatedParent Выражение осуществляет привязку к элементу, к которому применен шаблон. Этот режим работает, только если привязка находится внутри шаблона элемента управления или шаблона данных На первый взгляд свойство Relative Sou r се может показаться излишним усложнением разметки. В конце концов, почему бы просто не привязаться непосредственно к необходимому источнику, используя свойство Source или ElementName? Однако, это не всегда возможно, и обычно потому, что объект-источник и целевой объект находятся в разных частях разметки. Так получается при создании шаблонов элементов управления и шаблонов данных. Например, при построении шаблона данных, который изменяет способ представления элементов в списке, может понадобиться доступ к объекту List Box верхнего уровня, чтобы прочитать какое-то его свойство. Свойство DataContext В некоторых случаях имеется множество элементов, привязанных к одному объекту. Например, рассмотрим следующую группу элементов Text Bloc к, каждый из которых использует сходное выражение привязки для извлечения различных деталей о шрифте значков по умолчанию, включая промежутки между строками, стиль и вес первой гарнитуры (то и другое — просто Regular). Можете воспользоваться свойством Source для каждого из них, но это приводит к довольно длинной разметке: <StackPanel> <TextBlock Text="{Binding Source={x:Static SystemFonts.IconFontFamily}, Path=Source}"></TextBlock> <TextBlock Text="{Binding Source={x:Static SystemFonts.IconFontFamily}, Path=LineSpacing}"></TextBlock> <TextBlock Text="{Binding Source={x:Static SystemFonts.IconFontFamily}, Path=FamilyTypefaces[0].Style}"></TextBlock> <TextBlock Text="{Binding Source={x:Static SystemFonts.IconFontFamily}, Path=FamilyTypefaces[0].Weight}"></TextBlock> </StackPanel> В такой ситуации было бы яснее и удобнее определить источник привязки один раз с помощью свойства FrameworkElement.DataContext. В данном примере имеет смысл установить свойство DataContext элемента StackPanel, содержащего в себе все эле-
Глава 8. Привязка элементов 261 менты TextBlock. (Можно было бы также установить свойство DataContext на еще более высоком уровне, например, на уровне всего окна, но лучше определить его насколько возможно уже, чтобы яснее выразить намерения.) Установить свойство DataContext элемента можно таким же образом, как устанавливается свойство Binding.Source. Другими словами, можно встроить объект, извлечь его из статического свойства либо получить из ресурса, как показано ниже: <StackPanel DataContext="{x:Static SystemFonts.IconFontFamily} "> После этого выражения привязки упрощаются за счет исключения некоторой информации об источнике: <TextBlock Margin=" Text="{Binding Path=Source}"></TextBlock> Когда информация об источнике отсутствует в выражении привязки, WPF проверяет свойство DataContext элемента. Если оно равно null, WPF ищет в дереве элементов первый контекст данных, отличный от null. (Изначально свойства DataContext всех элементов равны null.) Если подходящий контекст данных обнаружен, то он используется для привязки. Если же нет, то выражение привязки не передает никакого значения целевому свойству. На заметку! При создании привязки с явно указанным источником в свойстве Source элемент использует этот источник вместо любого другого доступного контекста данных. Этот пример продемонстрировал, как создавать базовую привязку к объекту, который не является элементом. Однако чтобы использовать такой прием в реальном приложении, понадобятся некоторые дополнительные знания. В главе 19 будет показано, как отобразить информацию, извлеченную из базы данных, основываясь на описанных приемах привязки данных. Резюме В этой главе был представлен краткий обзор основ привязки данных. Вы узнали, как извлечь информацию из одного элемента и отобразить в другом, не написав ни единой строки кода. И хотя эта техника выглядит сейчас довольно скромно, ее знание позволит решать более сложные задачи, такие как изменение стиля элементов управления посредством специальных шаблонов (см. главу 17). В главах 19 и 20 навыки привязки данных будут существенно расширены. Вы узнаете, как отображать целые коллекции объектов данных в списке, обрабатывать редактирование с помощью проверки достоверности и превращать обычный текст в изощренно сформатированное отображение данных. Полученных до сих пор знаний о привязке данных будет достаточно, чтобы приступить к изучению последующих глав.
ГЛАВА 9 Команды В главе 5 рассказывалось о маршрутизируемых событиях, которые можно использовать для ответа на множество различных действий мыши и клавиатуры. Однако события являются компонентом довольно низкого уровня. В реальном приложении функциональные возможности делятся на задачи, имеющие более высокий уровень. Эти задачи могут инициироваться различными действиями и через различные элементы пользовательского интерфейса, включая главные меню, контекстные меню, клавиатурные комбинации и панели инструментов. В WPF позволяется определять такие задачи — называемые командами — и подключать элементы управления к ним, тем самым избегая необходимости писать повторяющийся код для обработки событий. Даже еще более важно то, что функция команд управляет состоянием пользовательского интерфейса, автоматически отключая элементы управления, когда связанные команды не доступны. Она также предоставляет центральное место для хранения (и локализации) текстовых заголовков команд. В этой главе речь пойдет о том, как использовать заготовленные классы команд в WPF, связывать их с элементами управления и определять собственные команды. Также здесь будут рассмотрены ограничения модели команд, такие как отсутствие журнала хронологии команд и отсутствие поддержки для используемой на уровне приложения функции Undo, а также показано, как создавать собственную систему для отслеживания и аннулирования команд. Общие сведения о командах В хорошо спроектированном Windows-приложении прикладная логика находится не в обработчиках событий, а закодирована в высокоуровневых методах. Каждый из этих методов представляет одну решаемую приложением "задачу". Каждая задача может полагаться на дополнительные библиотеки (вроде отдельно компилируемых компонентов, в которых инкапсулируется бизнес-логика или доступ к базам данных). Пример таких отношений показан на рис. 9.1. ШШШшШШШШШШ^ШШ File New j Open Save Print 1 Pi и it t ■ ■ ■ Щ Г Класс Window ^(Обработчик события M^ mnuPrint_Click() J ^/Обработчиксобытия |_ П^ window_KeyDown() J ^Обработчик события! \^ cmdPrint_Click() J Класс ApplicationTasks SaveDocumentO OpenDocument() Класс PrintServiceClass Рис. 9.1. Отображение обработчиков событий на задачу
Глава 9. Команды 263 Наиболее очевидным способом использования такого проектного решения является добавление обработчиков событий везде, где они нужны, и применение каждого из них для вызова соответствующего метода приложения. По сути, в таком случае код окна превращается в облегченную коммутационную панель, которая реагирует на ввод и пересылает запросы внутрь приложения. Хотя это решение вполне разумно, он не позволяет сэкономить на кодировании. Многие задачи приложения могут инициироваться по различным маршрутам, из- за чего часто все равно приходится писать несколько обработчиков событий, вызывающих один и тот же метод приложения. Именно в этом нет особой проблемы (потому что код коммутационной панели прост), но жизнь гораздо усложняется, когда приходится иметь дело с состоянием пользовательского интерфейса. Понять, о чем идет речь, поможет простой пример. Предположим, что есть программа, в состав которой входит метод по имени PrintDocument(). Этот метод может инициироваться четырьмя способами: через главное меню (выбором пункта меню File«=>Print (Файл ^Печать)), через контекстное меню (щелчком правой кнопкой мыши в пустой области и выбором в контекстном меню пункта Print (Печать)), с помощью клавиатурной комбинации (<Ctrl+P>) и посредством соответствующей кнопки панели инструментов. В определенных точках жизненного цикла приложения задача PrintDocumentO должна быть временно недоступной. Это подразумевает отключение соответствующих команд в двух меню и кнопки в панели инструментов так, чтобы на них нельзя было выполнять щелчок, а также игнорирование клавиатурной комбинации <Ctrl+P>. Написание в данном случае всего необходимого кода — очень непростая проблема. Даже еще хуже то, что ошибки в нем могут привести к тому, что различные блоки кода состояния будут перекрываться некорректно, оставляя элемент управления в активном состоянии даже тогда, когда он не должен быть доступен. Написание и отладка подобного кода является одним из наименее приятных аспектов разработки Windows-приложений. К удивлению многих опытных разработчиков Windows-приложений, в наборе инструментальных средств Windows Forms не было функциональности, которая могла бы облегчить решение подобных задач. Разработчики могли создавать необходимую инфраструктуру самостоятельно, но большинство из них предпочитало этого не делать. К счастью, WPF заполняет этот пробел, предлагая новую модель команд, в которой предоставляются два ключевых средства: • делегирование событий надлежащим командам; • поддержание включенного состояния элемента управления в синхронизированном виде с помощью состояния соответствующей команды. Предлагаемая в WPF модель команд является не настолько прямолинейной, как можно было ожидать. Для подключения к модели маршрутизируемых событий ей требуется несколько отдельных компонентов, о которых речь пойдет позже в главе. Однако в концептуальном плане она достаточно проста. На рис. 9.2 показано, как построение приложения на основе команд позволяет изменить проектное решение, показанное на рис. 9.1. Теперь каждое действие, которое инициирует печать (т.е. щелчок на соответствующей кнопке либо элементе меню и нажатие .клавиатурной комбинации <Ctrl+P>), отображается на одну и ту же команду. Эта команда с помощью привязки соединяется в коде со всего лишь одним обработчиком событий. Предлагаемая в WPF система команд представляет собой замечательное средство для упрощения проектирования приложений. Однако в ней все равно имеются кое-какие серьезные пробелы. В частности, WPF не поддерживает: • отслеживание команд (например, хронологию выполнявшихся команд); • невыполнимые команды; • команды, которые обладают состоянием и могут находиться в различных режимах (такие как команда, которая может быть включена и отключена).
264 Глава 9. Команды Класс Window LT Обработчик событий ^ pi4comnrandBinding_Executed{ )J Класс ApplicationTasks _ ( ,»_._АГЧ__. А,4 ^ | SaveDocumentO OpenDocumentO Класс PrintServiceClass PrintPageO Рис. 9.2. Отображение событий на команду Модель команд WPF Модель команд WPF состоит из удивительного количества подвижных частей. Ключевыми в ней являются четыре следующих компонента. • Команды. Команда представляет прикладную задачу и следит за тем, когда она может быть выполнена. Однако команды в действительности не содержат код, который выполняет эту задачу. • Привязки команд. Каждая такая привязка подразумевает соединение команды с имеющей к ней отношение прикладной логикой, отвечающей за обслуживание определенной области пользовательского интерфейса. Такое распределенное проектное решение очень важно, потому что одна и та же команда может использоваться в нескольких местах в приложении и иметь в каждом из них разное предназначение. Для целей обработки применяется одна и та же команда, но с разными привязками. • Источники команд. Источник команды приводит команду в действие. Например, источником команды может служить как элемент управления Menultem, так и элемент управления Button. Щелчок на любом из них будет приводить к выполнению привязанной к ним команды. • Целевые объекты команд. Целевым объектом команды называется элемент, в отношении которого команда должна выполняться. Например, команда Paste может вставлять текст в элемент управления Text Box, а команда OpenFile — отображать документ в элементе управления DocumentViewer. Целевой объект может быть важен, а может быть и неважен, что зависит от природы команды. В следующих разделах этой главы более подробно рассматривается первый из этих компонентов — команды WPF. Интерфейс ICommand В основе модели команд WPF лежит интерфейс System.Windows.Input .ICommand, определяющий способ, в соответствии с которым работают команды. Этот интерфейс включает в себя два метода и событие: public interface ICommand { void Execute(object parameter); bool CanExecute (object parameter); event EventHandler CanExecuteChanged; }
Глава 9. Команды 265 В простой реализации в методе Execute () должна содержаться прикладная логика, решающая задачу (например, печать документа). Однако, как будет показано в следующем разделе, WPF является немного более совершенной технологией. Она использует метод Execute!) для запуска более сложного процесса, который, в конечном счете, заканчивается возбуждением события, обрабатываемого в совершенно другом месте в приложении. Это дает разработчику возможность использовать готовые классы команд и включать в них собственную логику, а также обеспечивает гибкость применения одной команды (например, Print) в нескольких различных местах. Метод CanExecuteO возвращает информацию о состоянии команды, а именно — значение true, если она включена, и false, если она отключена. Методы Execute() и CanExecuteO принимают дополнительный объект-параметр, который можно применять для передачи с ними любой другой необходимой информации. И, наконец, событие CanExecuteChanged вызывается при изменении состояния. Для любых использующих данную команду элементов управления оно является сигналом о том, что им следует вызвать метод CanExecuteO и проверить состояние команды. Это часть связующего звена, которое позволяет источникам команд (вроде элемента управления Button или элемента управления Menu Item) автоматически включать себя, когда команда доступна, и отключать, когда она не доступна. Класс RoutedCommand При создании собственных команд реализовать интерфейс ICommand напрямую не обязательно. Вместо этого можно воспользоваться классом System.Windows. Input .RoutedCommand, который реализует этот интерфейс автоматически. Класс RoutedCommand является единственным классом в WPF, который реализует ICommand. Другими словами, все команды WPF представляют собой экземпляры класса RoutedCommand (или производного от него класса). Одна из ключевых концепций, лежащих в основе модели команд в WPF, состоит в том, что класс RoutedCommand не содержит никакой прикладной логики. Он просто представляет команду. Это означает, что один объект RoutedCommand обладает теми же возможностями, что и другой. Класс RoutedCommand добавляет дополнительную инфраструктуру для туннелирова- ния и пузырькового распространения событий. Если интерфейс ICommand инкапсулирует идею команды — действие, которое может инициироваться и быть или не быть доступным, то класс RoutedCommand изменяет команду так, чтобы она могла подобно пузырьку подниматься вверх по иерархии элементов WPF до подходящего обработчика событий. Зачем командам нужно пузырьковое распространение событий? Впервые взглянув на модель команд в WPF, трудно сразу же понять точно, почему команды WPF требуют механизма маршрутизируемых событий. В конце концов, разве не должен объект команды заботиться о ее выполнении независимо от того, как она вызывается? В случае непосредственного использования интерфейса ICommand для создания собственных команд это было бы так. Код понадобилось бы жестко связывать с командой, и он работал бы одинаково независимо от того, что конкретно активизировало команду. При этом в пузырьковом распространении событий не было бы необходимости. Однако в WPF используется набор заготовленных команд. Классы этих команд не содержат никакого реального кода. Они являются просто удобно определенными объектами, которые представляют общую задачу приложения (например, печать документа). Для выполнения действий над этими командами необходимо использовать привязку, вызывающую в коде соответствующее событие (см. рис. 9.2). Чтобы обеспечить обработку данного события в одном месте, даже если
266 Глава 9. Команды оно возбуждается разными источниками команд в одном и том же окне, как раз и необходима мощь пузырькового распространения событий. Из этого вытекает интересный вопрос: зачем вообще использовать заготовленные команды? Не лучше ли заставить делать всю работу специальные классы команд и не полагаться на обработчики событий? Во многом такое проектное решение было бы проще. Однако преимущество заготовленных команд состоит в том, что они предлагают гораздо более удобные возможности для интеграции. Например, предположим, что некий сторонний разработчик создал элемент управления DocumentView, использующий заготовленную команду Print. Если в приложении применяется такая же заготовленная команда, не придется прилагать никаких дополнительных усилий для включения в него возможности печати. С этой точки зрения команды являются важной частью подключаемой архитектуры WPF. Для поддержки маршрутизируемых событий класс RoutedCommand реализует интерфейс ICommand как приватный и затем добавляет немного отличающиеся версии его методов. Наиболее заметным изменением является то, что методы Execute () и CanExecuteO теперь принимают дополнительный параметр. Ниже показаны новые сигнатуры этих методов: public void Execute(object parameter, IlnputElement target) {• ..} public bool CanExecute(object parameter, IlnputElement target) {. ••} target представляет собой целевой элемент, в котором начинается обработка события. Это событие начинает обрабатываться в целевом элементе и затем поднимается вверх до находящихся на более высоком уровне контейнеров до тех пор, пока приложение не использует его для выполнения подходящей задачи. (Для обработки события Executed элементу необходима помощь еще одного класса, а именно — CommandBinding.) Помимо этого в классе RoutedCommand теперь еще предлагаются три новых свойства: Name (имя команды), OwnerType (класс, членом которого является команда) и InputGestures (коллекция любых нажатий клавиш, клавиатурных комбинаций или действий мыши, которые также могут использоваться для вызова команды). Класс RoutedUICommand Большинство команд, с которыми придется иметь дело, будут представлять собой не объекты RoutedCommand, а экземпляры класса RoutedUICommand, унаследованного от RoutedCommand. (В действительности все заготовленные команды, которые предлагаются в WPF, являются объектами RoutedUICommand.) Класс RoutedUICommand предназначен для команд с текстом, который должен отображаться где-нибудь в пользовательском интерфейсе (например, текстом для элемента меню или текстом подсказки для кнопки в панели инструментов). Класс RoutedUICommand добавляет одно свойство Text, в котором указывается текст, отображаемый вместе с данной командой. Преимущество подхода с определением текста вместе с командой (а не непосредственно в элементе управления) состоит в том, что это позволяет производить локализацию в одном месте. Если же текст команды никогда не отображается где-либо в пользовательском интерфейсе, то вполне подойдет и класс RoutedCommand. На заметку! Отображать в пользовательском интерфейсе текст RoutedUICommand вовсе не обязательно. Часто могут быть веские причины для использования какого-то другого текста. Например, вместо текста "Print Document" (Печать документа) может применяться просто "Print" (Печать), а в некоторых случаях текст вообще заменяется небольшим графическим изображением.
Глава 9. Команды 267 Библиотека команд Разработчики WPF учли тот факт, что в каждом приложении может использоваться огромное количество команд, а многие команды могут быть общими для множества приложений. Например, во всех приложениях, предназначенных для обработки документов, будут присутствовать собственные версии команд New (Создать), Open (Открыть) и Save (Сохранить). Поэтому для снижения затрат по созданию таких команд в состав WPF была включена библиотека базовых команд, в которой содержится свыше 100 команд. Все эти команды доступны через статические свойства пяти отдельных статических классов. • ApplicationCommands. Предоставляет общие команды, в том числе команды буфера обмена (такие как Сору (Копировать), Cut (Вырезать) и Paste (Вставить)) и команды обработки документов (вроде New (Создать), Open (Открыть), Save (Сохранить), SaveAs (Сохранить как), Print (Печать) и т.д.). • NavigationCommands. Предоставляет команды, применяемые для навигации, включая те, что предназначены специально для страничных приложений (наподобие BrowseBack (Назад), BrowseForward (Вперед) и NextPage (Переход)), а также команды для приложений, ориентированных на работу с документами (такие как IncreaseZoom (Масштаб) и Refresh (Обновить)). • EditingCommands. Предоставляет длинный список команд, предназначенных в основном для редактирования документов, в том числе команды перемещения (вроде MoveToLineEnd (Переход в конец строки), MoveLef tByWord (Переход влево на одно слово), MoveUpByPage (Переход на одну страницу вверх) и т.д.), выделения содержимого (такие как SelectToLineEnd (Выделение до конца строки), SelectLef t By Word (Выделение слова слева)) и изменения форматирования (наподобие ToggleBold (Выделение полужирным) и ToggleUnderline (Выделение подчеркиванием)) • ComponentCommands. Предоставляет команды, которые применяются компонентами пользовательского интерфейса, включая команды перемещения и выделения содержимого, подобные (и даже дублирующие) некоторым командам в классе EditingCommands. • MediaCommands. Предоставляет набор команд для работы с мультимедиа (такие как Play (Воспроизвести), Pause (Пауза), NextTrack (Переход к следующей композиции) и IncreaseVolume (Увеличение громкости)). Класс ApplicationCommands предлагает набор базовых команд, которые часто применяются в приложениях всех типов, поэтому с ними стоит вкратце ознакомиться: New (Создать) Delete (Удалить) Open (Открыть) Undo (Отменить) Save (Сохранить) Redo (Повторить) SaveAs (Сохранить как) Find (Найти) Close (Закрыть) Replace (Заменить) Print (Печать) SelectAll (Выделить все) PrintPreview (Предварительный просмотр) Stop (Остановить) Cancel Print (Отмена печати) ContextMenu (Контекстное меню) Сору (Копировать) CorrectionList (Список исправлений) Cut (Вырезать) Properties (Свойства) Paste (Вставить) Help (Справка)
268 Глава 9. Команды Например, ApplicationCommands.Open является статическим свойством, которое предоставляет объект RoutedUICommand. Этот объект представляет в приложении команду Open (Открыть). Поскольку ApplicationCommands.Open является статическим свойством, во всем приложении может существовать всего лишь один экземпляр команды Open. Однако применяться он может по-разному, в зависимости от его источника, т.е. места, где он встречается в пользовательском интерфейсе. Свойство RoutedUICommand.Text отображает имя каждой команды, добавляя, где нужно, пробелы между словами. Например, для команды ApplicationCommands. Select All оно отображает текст Select All (Выделить все). (Свойство Name содержит тот же самый текст, но без пробелов.) Свойство RoutedUICommand.OwnerType возвращает тип объекта для класса ApplicationCommands, поскольку команда Open является статическим свойством этого класса. Совет. Свойство Text команды можно изменять перед его привязкой к окну (например, с использованием кода в конструкторе окна или класса приложения). Поскольку команды представляют собой статические объекты, являющиеся глобальными для всего приложения, изменение текста-команды влияет на нее везде, где она встречается в пользовательском интерфейсе. В отличие от свойства Text, свойство Name модифицировать нельзя. Как уже упоминалось, эти отдельные объекты команд являются всего лишь маркерами, не имеющими реальной функциональности. Однако многие из них обладают одним дополнительным средством: привязками ввода по умолчанию. Например, команда ApplicationCommands.Open отображается на комбинацию клавиш <Ctrl+0>. После привязки этой клавиатурной комбинации к команде и ее добавления в окно в виде источника данной команды она становится активной, даже если команда не отображается нигде в пользовательском интерфейсе. Выполнение команд До сих пор подробно рассматривались команды, в том числе их базовые классы и интерфейсы, а также библиотека команд, предоставляемая WPF, но не приводилось ни одного примера применения этих команд. Как объяснялось ранее, объект RoutedUICommand не имеет никакой жестко закодированной функциональности. Он просто представляет команду. Для активизации этой команды необходим источник команды (или специальный код), а для ответа на нее — привязка команды, которая переадресует ее выполнение обычному обработчику событий. Об этих двух компонентах и пойдет речь в следующих разделах. Источники команд Команды в библиотеке команд доступны всегда. Самый простой способ их инициирования предусматривает их привязку к элементу управления, реализующему интерфейс ICommandSource, к числу которых относятся все элементы управления, унаследованные от ButtonBase (Button, CheckBox и т.д.), индивидуальные объекты ListBoxItem, элемент управления Hyperlink и элемент управления Menultem. Интерфейс ICommandSource определяет три свойства, которые описаны в табл. 9.1. Например, ниже показан код кнопки, которая связывается с командой ApplicationCommands.New посредством свойства Command: <Button Command="ApplicationCommands.New">New</Button>
Глава 9. Команды 269 Таблица 9.1. Свойства интерфейса ICommandSource Имя Описание Command Указывает на связанную команду. Это единственная обязательная деталь CommandParameter Предоставляет любые данные, которые должны отправляться вместе с командой CommandTarget Идентифицирует элемент, на котором должна выполняться команда Платформа WPF достаточно интеллектуальна в плане возможностей поиска по всем пяти описанным выше классам-контейнерам команд, благодаря чему предыдущую строку кода можно записать и короче: <Button Command="New">New</Button> Однако такой синтаксис менее явный и, следовательно, менее понятный, поскольку в нем не указан класс, содержащий команду. Привязки команд После присоединения команды к источнику команды происходит нечто интересное: источник команды автоматически отключается. Например, после создания кнопки New (Создать), показанной в предыдущем разделе, она появляется как затененная и недоступная для щелчка, как будто ее свойство IsEnabled установлено в false ' (рис. 9.3). Это объясняется тем, что кнопка запра- Рис 9>3. Команда без привязки шивает состояние команды, а поскольку команда не имеет присоединенной привязки, предполагается, что она отключена. Чтобы изменить такое положение дел, для команды понадобится создать привязку, которая указывает три следующие вещи. • Действие, которое должно выполняться при инициировании команды. • Способ, который должен использоваться для определения того, может ли команда быть выполнена. (Это необязательная деталь. Если она опущена, команда всегда считается доступной, пока имеется присоединенный обработчик событий.) • Область, на которую распространяется действие команды. Например, команда может быть ограничена единственной кнопкой или быть доступной во всем окне (что встречается чаще). Ниже показан фрагмент кода, в котором создается привязка для команды New. Этот код можно поместить в конструктор окна: // Создание привязки. CommandBinding binding = new CommandBinding(ApplicationCommands.New); // Присоединение обработчика событий, binding.Executed += NewCommand_Executed; // Регистрация привязки. this.CommandBindings.Add(binding); Обратите внимание, что готовый объект CommandBinding добавляется в коллекцию CommandBindings содержащего окна. Это работает через механизм пузырькового распространения событий. По сути, когда на кнопке выполняется щелчок, событие
270 Глава 9. Команды CommandBinding.Executed поднимается подобно пузырьку из кнопки к содержащим ее элементам-контейнерам. Хотя обычно все привязки добавляются в окно, свойство CommandBindings в действительности определено базовым классом UIElement. Это означает, что оно поддерживается любым элементом. Например, приведенный выше пример будет работать точно также даже в случае добавления привязки команды и прямо в код кнопки, использующей эту команду (хотя тогда ее не удастся применить для какого-то другого элемента более высокого уровня). Для достижения наибольшей гибкости привязки команд обычно добавляются в окно наивысшего уровня. Если одну и ту же команду необходимо использовать в нескольких окнах, привязки должны быть созданы в каждом из них. На заметку! Можно также обработать событие CommandBinding.PreviewExecuted, которое сначала возбуждается в контейнере наивысшего уровня (т.е. окне) и затем туннелируется до уровня кнопки. Как объяснялось в главе 4, туннелирование событий применяется для перехвата и остановки события перед его завершением. Если установить свойство RoutedEventArgs. Handled в true, событие Executed никогда не возникнет. В предыдущем коде предполагается, что в том же самом классе имеется готовый к получению команды обработчик событий по имени NewCommandExecuted. Ниже показан пример простого кода для отображения источника команды: private void NewCommand_Executed(object sender, ExecutedRoutedEventArgs e) { MessageBox.Show("New command triggered by " + e.Source.ToString ())/ } Теперь после запуска приложения кнопка будет отображаться как доступная (рис. 9.4). Щелчок на ней приводит к генерации события Executed, которое затем будет подниматься до уровня окна и обрабатываться показанным ранее обработчиком NewCommand_Executed(). Здесь WPF сообщит источник генерации события (кнопка). Объект ExecutedRoutedEventArgs также позволяет извлекать ссылку на команду, которая была вызвана (ExecutedRoutedEventArgs.Command), и любую дополнительную информацию, переданную вместе с ней (ExecutedRoutedEventArgs.Parameter). В рассматриваемом примере передача дополнительной информации не предусмотрена, поэтому значением ExecutedRoutedEventArgs.Parameter будет null. (Чтобы передать дополнительную информацию, нужно установить свойство CommandParameter источника команды. Чтобы передать часть информации из другого элемента управления, это свойство должно быть установлено с использованием выражения привязки, как будет показано далее в главе.) ■ ' TestNewCommand ,\ел New command triggered by System.Windows.Controls.Button: New OK Рис. 9.4. Команда с привязкой
Глава 9. Команды 271 На заметку! В этом примере обработчик событий, реагирующий на команду, находится внутри кода того же окна, в котором создается команда. Все правила хорошей организации кода применимы и в отношении этого примера; другими словами, окно, где необходимо, должно делегировать свою работу другим компонентам. Например, если команда подразумевает открытие файла, можно использовать специально созданный вспомогательный класс файла для сериа- лизации и десериализации данных. Аналогично, когда имеется команда обновления экрана данных, ее можно применять для вызова метода, который производит выборку необходимых данных. Еще раз просмотрите рис. 9.2. В предыдущем примере привязка команды генерировалась с помощью кода. Однако команды столь же легко привязывать и декларативно в XAML-разметке, что позволяет упростить файл отделенного кода. Ниже показана необходимая разметка: <Window х:Class="Commands.TestNewCommand" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="TestNewCommand"> <Window. CommandBindings> <CommandBinding Command="ApplicationCommands.New" Executed= "NewComniand__Executed" X/CommandBinding> </Window.CommandBindings> <StackPanel Margin="> <Button Padding=" Command="ApplicationCommands.New">New</Button> </StackPanel> </Window> К сожалению, в Visual Studio отсутствует поддержка определения привязок команд на этапе проектирования. Кроме того, предоставляется относительно слабая поддержка для подключения элементов управления и команд. Свойство Command для элемента управления можно устанавливать в окне Properties (Свойства), но при этом приходится вводить точное имя команды — никакого удобного раскрывающегося списка возможных вариантов команд здесь не предусмотрено. Использование множества источников команд Пример с кнопкой немного напоминает обходной путь для генерации обычного события. Однако дополнительный уровень команды начинает приобретать отчетливый смысл при добавлении большего количества использующих эту команду элементов управления. Например, можно добавить элемент меню, который тоже использует команду New: <Menu> <MenuItem Header="File"> <MenuItem Command=,,New"X/MenuItem> </MenuItem> </Menu> Обратите внимание, что данный объект Menultem не устанавливает свойство Header для команды New. Элемент управления Menultem достаточно интеллектуален, чтобы самостоятельно извлечь текст из команды в случае, если свойство Header не задано. (Элемент управления Button такой способностью не обладает.) Может показаться, что это довольно незначительное удобство, однако оно играет очень важную роль при локализации приложения для разных языков. В таком случае гораздо удобнее изменять текст в одном месте (за счет установки в командах свойства Text), чем отслеживать его во всех окнах.
272 Глава 9. Команды Класс Menultem обладает еще одной полезной особенностью. Он автоматически выбирает первую клавишу быстрого вызова команды, которая содержится в коллекции Command.InputBindings (если таковая имеется). В случае объекта ApplicationsCommands.New это означает, что в меню рядом с текстом будет появляться клавиатурная комбинация <Ctrl+N> (рис. 9.5). На заметку! Чего у Menultem нет, так это возможности автоматического отображения подчеркнутой клавиши доступа. Среда WPF не имеет средств, позволяющих узнать, какие команды могут размещаться в меню вместе, и, следовательно, определить, какие клавиши доступа лучше всего использовать. Например, для использования <N> в качестве клавиши быстрого доступа (она будет отображаться подчеркнутой при открытии меню с помощью клавиатуры, а ее нажатие позволит инициировать команду New) текст меню должен быть установлен вручную, а перед клавишей доступа должен находиться символ подчеркивания. То же самое необходимо сделать и при желании использовать клавишу быстрого доступа для кнопки Также обратите внимание, что создавать еще одну привязку команды для элемента меню не понадобится. Одна привязка, которая была создана в предыдущем разделе, теперь будет использоваться двумя разными элементами управления, и оба они будут передавать свою работу одному и тому же обработчику событий. Точная настройка текста команды Способность меню автоматически извлекать текст элемента команды может вызвать вопрос о том, а можно ли такое же делать с другими классами ICommandSource, например, с элементом управления Button? Можно, но это требует дополнительных усилий. В частности, для многократного использования текста команды предусмотрены два способа. Первый подразумевает извлечение текста прямо из статического объекта команды. В XAML-разметке это же можно делать с помощью расширения Static. Ниже показан пример кода, который извлекает имя команды New и использует его в качестве текста для кнопки: <Button Command="New" Content=" {x: Static ApplicationCommands .New} "></Button> Проблема такого подхода в том, что он предполагает просто вызов в отношении объекта команды метода ToStringO, который позволяет получить имя команды, но не ее текст. (В случае многословных команд лучше использовать текст, а не имя команды, потому что текст включает пробелы.) От данной проблемы можно избавиться, но это потребует гораздо больших усилий. Другая проблема связана со способом, которым одна кнопка дважды использует одну и ту же команду, что увеличивает вероятность случайного извлечения текста не из той команды. Поэтому более предпочтительным решением считается применение выражения привязки данных. Эта привязка данных выглядит несколько необычно, поскольку подразумевает выполнение привязки к текущему элементу, захват используемого объекта Command и извлечение его свойства Text. Весь необходимый (и довольно длинный синтаксис) показан ниже: <Button Margin=" Padding=" Command="ApplicationCommands.New" Content= "{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}" </Button> i TestNewCommand Ctr1*N Рис. 9.5. Элемент меню, который использует команду
Глава 9. Команды 273 Однако этот прием можно применять и другими, более изощренными способами. Например, можно устанавливать содержимое кнопки с небольшим изображением, но применять выражение привязки для отображения имени команды во всплывающем окне подсказки: <Button Margin=" Padding=" Command="ApplicationCommands.New" ToolTip="{Binding RelativeSource={RelativeSource Self}, Path=Command.Text}"> <Image ... /> </Button> Содержимое кнопки (которое здесь не показано) будет представлять собой фигуру или растровое изображение, отображаемое в виде миниатюры. Очевидно, что такой подход многословнее простого размещения текста команды непосредственно в коде разметки. Тем не менее, его стоит рассматривать как вариант, если планируется локализация приложения для разных языков. Текст для всех команд должен устанавливаться при запуске приложения (изменение текста после привязки команд не будет давать никакого эффекта, из-за того, что свойство Text не является свойством зависимости, а раз так, то никакого автоматического уведомления об изменениях, требующих обновления пользовательского интерфейса, происходить не будет). Вызов команды напрямую При запуске команды вы не ограничены использованием только классов, реализующих ICommandSource. Команду можно также и просто вызывать напрямую из любого обработчика событий с помощью метода Execute(). В таком случае необходимо всего лишь передавать значение параметра (или null) и ссылаться на целевой элемент: ApplicationCommands.New.Execute(null, targetElement); Целевой элемент — это просто элемент, в котором WPF начинает искать привязку команды. В качестве такого элемента можно применять как содержащее окно (имеющее привязку команды), так и вложенный элемент (вроде фактического элемента, инициировавшего данное событие). Проход через метод Execute () также можно осуществлять и в ассоциируемом объекте CommandBinding. В таком случае указывать целевой элемент не требуется, потому что им автоматически становится элемент, который предоставляет используемую коллекцию CommandBindings: this.CommandBindings[0] .Command.Execute (null)/ При таком подходе модель команд задействуется лишь наполовину. Подход позволяет инициировать команду, но не предоставляет возможности для реагирования на изменение ее состояния. При желании иметь такую функцию придется также обрабатывать событие RoutedCommand.CanExecuteChanged для обеспечения соответствующей реакции на изменение состояния команды с активного на неактивное и наоборот. При инициализации события CanExecuteChanged понадобится вызвать метод RoutedCommand. CanExecuteO для проверки того, находятся ли команды в пригодном для использования состоянии, и если нет, то отключать или изменять содержимое в соответствующей части пользовательского интерфейса. Поддержка команд в специальных элементах управления В состав WPF входит набор элементов управления, которые реализуют интерфейс ICommandSource и имеют возможность вызывать команды. (В WPF также включены элементы управления, которые имеют возможность обрабатывать команды, о чем более подробно рассказывается в разделе "Элементы управления со встроенными командами" далее в главе.) Несмотря
274 Глава 9. Команды на такую поддержку, может встретиться элемент управления, который нужно использовать с моделью команд, даже если он и не реализует ICommandSource. В такой ситуации самым простым вариантом будет обработка одного из событий этого элемента управления и запуск соответствующей команды из кода. Однако есть и другой вариант — создать новый специальный элемент управления со встроенной логикой для выполнения команды. В загружаемом коде для настоящей главы имеется пример, в котором такой прием используется для создания ползунка, инициирующего команду при изменении его значения. Этот элемент управления унаследован от класса Slider, который рассматривался в главе 6, реализует интерфейс ICommand, определяет свойства зависимости Command, CommandTarget и CommandParameter и внутренне отслеживает событие RoutedCommand.CanExecuteChanged. Хотя его код достаточно прост, в большинстве сценариев подобное решение является чрезмерным. Создание специального элемента управления в WPF представляет собой довольно серьезный шаг, и потому разработчики в основном предпочитают просто изменять стиль существующих элементов управления с помощью шаблонов (как будет показано в главе 17) вместо того, чтобы добавлять совершенно новый класс. Тем не менее, упомянутый пример заслуживает внимания, если разрабатывается специальный элемент управления, который должен быть снабжен поддержкой для команд. Отключение команд Преимущества модели команд по-настоящему проявляются при создании команды, способной менять свое состояние с активного на неактивное и наоборот Например, рассмотрим приложение с одним окном, показанное на рис. 9.6. Оно представляет собой простой текстовый редактор, состоящий из меню, панели инструментов и большого текстового поля (TextBox), который позволяет открывать файлы, создавать новые (пустые) документы и сохранять выполненную работу. В данном случае вполне логично сделать команды New (Создать), Open (Открыть), Save (Сохранить), - л ~ г, w w Save As (Сохранить как) и Close (Закрыть) доступ- Рис. 9.6. Простои текстовый редактор i r j у г t « j ными всегда. Однако другое проектное решение может требовать, чтобы команда Save становилась доступной только в случае изменения текста, когда он отличается от первоначального. По соглашению такую деталь можно отслеживать в коде с помощью простого булевского значения: private bool lsDirty = false; Этот флаг будет устанавливаться при любом изменении текста: private void txt_TextChanged(object sender, RoutedEventArgs e) { lsDirty = true; } Теперь необходимо позаботиться о попадании данной информации из окна в привязку команды, чтобы соответствующие элементы управления могли должным образом обновиться. Трюк заключается в обработке события CanExecute в привязке команды. Присоединить обработчик к этому событию можно либо в коде: CommandBinding binding = new CommandBinding(ApplicationCommands.Save); binding.Executed += SaveCommand_Executed; ■ SimpleDocument | File i New Open . ; , Paste This is some sample text]
Глава 9. Команды 275 binding. CanExecute += SaveCoiranand_CanExecute ; this.CommandBindings.Add(binding); либо декларативно: <Window.CommandBindings> <CommandBinding Command="ApplicationCommands.Save" Executed="SaveCommand_Executed" CanExecute="SaveComniand_CanExecute"> </CommandBinding> </Window.CommandBindings> В этом обработчике событий должна просто осуществляться проверка значения переменной isDirty и устанавливаться соответствующее значение для свойства CanExecuteRoutedEventArg.CanExecute: private void SaveCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e) { e.CanExecute = isDirty; } Если свойство isDirty равно false, команда будет отключаться, а если true — включаться. (Если флаг CanExecute не установлен, сохраняется самое последнее значение.) При использовании CanExecute важно помнить о следующем. Платформа WPF самостоятельно решает, когда вызывать метод RoutedCommand.CanExecute () для запуска обработчика событий и определения состояния команды. Диспетчер команд WPF делает это только тогда, когда обнаруживает изменение, которое считает значительным — например, в случае перемещения фокуса с одного элемента управления на другой или после выполнения команды. Элементы управления могут также инициировать событие CanExecuteChanged, указывающее WPF на необходимость оценки состояния команды заново — например, такое происходит при нажатии клавиши в текстовом поле. В целом событие CanExecute будет возбуждаться довольно часто, поэтому не следует использовать внутри него длительно выполняющийся код. Однако на состояние команды могут влиять и другие факторы. Так, в текущем примере флаг isDirty мог бы изменяться и в ответ на другое действие. Если обнаруживается, что состояние команды не обновляется в нужный момент, WPF можно принудить вызывать метод CanExecute () на всех используемых командах. Это делается вызовом статического метода CommandManager.InvalidateRequerySuggested(). После этого диспетчер команд будет генерировать событие RequerySuggested для уведомления всех присутствующих в окне источников команд (кнопок, элементов меню и т.д.). Эти источники команд затем повторно опросят связанные с ними команды и соответствующим образом обновят свое состояние. Ограничения команд WPF Команды WPF способны изменять только один аспект состояния связанного с ними элемента, а именно — значение его свойства isEnabled. Совсем не трудно представить ситуации, в которых требуется более сложное поведение. Например, может понадобиться создать команду PageLayoutView, которую можно было бы включать и отключать. При ее включении связанные с ней элементы управления должны соответствующим образом настраиваться. (Например, связанный элемент меню должен отмечаться флажком, а связанная кнопка в панели инструментов — подсвечиваться, как это происходит с элементом CheckBox в случае его добавления в ToolBar.) К сожалению, возможности отслеживать "отмеченное" состояние команды не существует. Это означает, что обрабатывать событие для этого элемента управления и обновлять его состояние и состояние других связанных элементов необходимо вручную.
276 Глава 9. Команды Простого решения упомянутой проблемы не существует. Даже если создать специальный класс, унаследованный от RoutedUICommand, и наделить его функциональностью отслеживания состояния "отмечен/не отмечен" (с возбуждением соответствующего события в случае его изменения), все равно понадобится заменить часть имеющей к нему отношение инфраструктуры. Например, нужно будет создать специальный класс CommandBinding, способный перехватывать уведомления от специальной команды, реагировать при изменении ее состояния "отмечен/не отмечен" и затем обновлять соответствующим образом все связанные с ней элементы управления. Кнопки-флажки являются очевидным примером состояния пользовательского интерфейса, которое выходит за рамки модели команд WPF. Однако подобное возможно и в других случаях. Например, может быть создана разделительная кнопка, поддерживающая переключение в разные режимы. Никакого способа для распространения подобного изменения среди других связанных элементов управления с помощью одной только модели команд опять-таки не будет. Элементы управления со встроенными командами Некоторые элементы управления вводом умеют обрабатывать события команд самостоятельно. Например, класс TextBox обрабатывает команды Cut, Copy и Paste (а также команды Undo и Redo и часть команд класса EditingCommands, которые позволяют выделять текст и перемещать курсор в разные позиции). Когда элемент управления имеет собственную жестко закодированную логику обработки команд, для обеспечения работы команд ничего делать не требуется. Например, добавление в простой текстовый редактор, показанный на рис. 9.6, следующих кнопок панели инструментов автоматически приводит к появлению возможности вырезания, копирования и вставки текста: <Тоо1Ваг> <Button Command="Cut">Cut</Button> <Button Command="Copy">Copy</Button> <Button Command="Paste">Paste</Button> </ToolBar> После этого можно будет щелкать на этих кнопках (когда фокус находится на элементе управления TextBox) и копировать, вырезать или вставлять текст из буфера обмена. Интересно, что элемент управления TextBox также обрабатывает событие CanExecute. Если в текстовом поле в текущий момент ничего не выделено, команды Cut и Сору не будут доступны. Если фокус установлен на элементе управления, который не поддерживает команды Copy, Cut и Paste (если только к нему не был специально присоединен собственный обработчик события CanExecute, включающий их), то все три команды сразу же автоматически отключаются. С этим примером связана одна интересная деталь. Команды Copy, CutnPaste обрабатываются тем элементом управления TextBox, на котором находится фокус. Однако в действие они приводятся соответствующей кнопкой в панели инструментов, которая представляет собой совершенно отдельный элемент. В данном примере этот процесс протекает гладко, потому что каждая кнопка размещается в панели инструментов, а класс ToolBar включает в себя встроенную логику, которая обеспечивает динамическую установку свойства CommandTarget его потомков в элемент управления, в текущий момент имеющий фокус. (Формально элемент ToolBar просматривает родительский элемент, которым является окно, и в этом контексте находит элемент управления, недавно имевший фокус, в данном случае — текстовое поле. Элемент управления ToolBar имеет отдельную область действия фокуса, и в этом контексте фокус находится на кнопке.)
Глава 9. Команды 277 Если кнопки размещены в другом контейнере (отличном от Tool Bar и Menu), такого преимущества не будет. То есть кнопки не будут работать до тех пор, пока для них вручную не будет установлено свойство CommandTarget. Это требует использования выражения привязки с именем целевого элемента. Например, в случае, если именем текстового поля является txtDocument, кнопки должны быть определены следующим образом: <Button Command="Cut" CommandTarget="{Binding ElementName=txtDocument}">Cut</Button> <Button Command="Copy" CommandTarget="{Binding ElementName=txtDocument}">Copy</Button> <Button Command="Paste" CommandTarget="{Binding ElementName=txtDocument}">Paste</Button> Другой, более простой вариант предусматривает создание новой области действия фокуса с использованием присоединенного свойства FocusManager.IsFocusScope. Это заставляет WPF при срабатывании команды искать элемент в области действия фокуса родительского элемента: <StackPanel FocusManager. IsFocusScope="True"> <Button Command="Cut">Cut</Button> <Button Command="Copy">Copy</Button> <Button Command="Paste">Paste</Button> </StackPanel> Такой подход обладает дополнительным преимуществом, поскольку позволяет применять одни и те же команды к множеству элементов управления в отличие от предыдущего примера, где свойство CommandTarget кодировалось жестким образом. Кстати, в элементах Menu и ToolBar свойство FocusManager.IsFocusScope по умолчанию установлено в true, но если нужно иметь более простое поведение при маршрутизации команд, не предусматривающее поиск находящегося в фокусе элемента в контексте родителя, FocusManager.IsFocusScope следует установить в false. В некоторых редких случаях оказывается, что элемент управления имеет встроенную поддержку команд, которая не должна быть включена. Для отключения этой поддержки на выбор доступны три способа. В идеале элемент управления будет предоставлять свойство, которое позволяет аккуратно отключить поддержку команд. Это гарантирует, что элемент управления удалит функциональность и соответствующим образом подстроит свое поведение. Например, элемент управления TextBox имеет свойство IsUndoEnabled, установив которое в false, можно аккуратно отключить функцию отмены (Undo). (Если IsUndoEnabled установлено в true, нажатие <Ctrl+Z> приводит к срабатыванию этой функции.) Если такой вариант не доступен, можно добавить новую привязку для команды, которую требуется отключить. Затем эта привязка определяет обработчик события CanExecute, который всегда возвращает false. Ниже приведен пример применения такого приема для отключения поддержки команды Cut в элементе управления TextBox: CommandBinding commandBinding = new CommandBinding( ApplicationCommands.Cut, null, SuppressCommand); txt.CommandBindings.Add(commandBinding); А вот код необходимого обработчика событий, устанавливающего состояние CanExecute: private void SuppressCommand(object sender, CanExecuteRoutedEventArgs e) { e.CanExecute = false; e.Handled = true; }
278 Глава 9. Команды Обратите внимание на установку флага Handled, что предотвращает выполнение элементом Text Box собственной обработки, при которой CanExecute может быть установлено в true. Этот подход не идеален. Он успешно отключает клавиатурную комбинацию <Ctrl+X> и команду Cut в контекстном меню элемента управления TextBox. Однако пункт Cut продолжает отображаться в контекстном меню в отключенном состоянии. Последним вариантом является удаление ввода, который приводит к инициированию команды, с помощью коллекции InputBindings. Например, ниже показан код, который служит для отключения клавиатурной комбинации <Ctrl+C>, приводящей к инициированию команды Сору в TextBox: KeyBinding keyBinding = new KeyBinding( ApplicationCommands .NotACommand, Key.C, ModifierKeys . Control) ; txt.InputBindings.Add(keyBinding); Трюк здесь связан с применением специального значения ApplicationCommands. NotACommand, представляющего собой команду, которая ничего не делает. Оно предназначено специально для отключения привязок ввода. При таком подходе команда Сору остается включенной. Ее по-прежнему можно вызывать через специально созданные кнопки (или контекстное меню элемента TextBox, если только не удалить его, установив свойство ContextMenu в null). На заметку! Для отключения функций всегда необходимо добавлять новые привязки команд или привязки ввода. Удалять существующие привязки не допускается. Причина в том, что существующие привязки в общедоступных коллекциях CommandBindings и InputBindings не появляются. Вместо этого они определяются с помощью отдельного механизма, который называется привязками классов. Более подробно о связывании команд с создаваемыми специальными элементами управления речь пойдет в главе 18. Расширенные команды Теперь, когда были представлены основные характеристики команд, пришла пора поговорить о более сложных реализациях. В следующих разделах будет показано, как использовать собственные команды, трактовать одну и ту же команда по-разному в зависимости от целевого элемента, а также работать с параметрами команд. Кроме того, рассматриваются вопросы обеспечения поддержки базовой функции отмены (Undo). Специальные команды Какими бы полными не были пять стандартных классов команд (ApplicationCommands, NavigationCommands, Ed'itingCommands, ComponentCommands и MediaCommands), они, очевидно, не могут предоставить абсолютно все, что нужно приложению. К счастью, в WPF довольно легко определять собственные специальные команды. Все, что для этого понадобится — это создать новый объект RoutedUICommand. Класс RoutedUICommand имеет несколько конструкторов. Экземпляр RoutedUICommand можно создавать без дополнительной информации, однако практически всегда необходимо задавать имя команды, текст команды и тип владения. Вдобавок может предоставляться сокращенная клавиатурная комбинация для помещения в коллекцию InputGestures. Наилучший подход предполагает следование примеру библиотек WPF и предоставлять специальные команды через статические свойства. Ниже показан пример с командой по имени Re query:
Глава 9. Команды 279 public class DataCommands { private static RoutedUICommand requery; static DataCommands () { // Инициализация команды. InputGestureCollection inputs = new InputGestureCollection (); inputs .Add (new KeyGesture (Key. R, Modifier-Keys . Control, "Ctrl+R") ) ; requery = new RoutedUICommand( "Requery", "Requery", typeof(DataCommands), inputs); } public static RoutedUICommand Requery { get { return requery; } } } Совет. Можно также модифицировать коллекцию RoutedCommand.InputGestures существующей команды, например, удаляя определенные привязки клавиш либо добавляя новые. Допускается даже добавлять привязки мыши, чтобы команда запускалась при одновременном нажатии определенной кнопки мыши и клавиши модификатора (хотя в таком случае привязку команды лучше размещать только в элементе, где должна производиться обработка действий мыши). После определения команде можно использовать в своих привязках команд точно так же, как любую из готовых команд, предлагаемых WPF. Тем не менее, есть одна особенность. Чтобы применять свою команду в XAML, сначала необходимо отобразить свое пространство имен .NET на пространство имен XML. Например, если класс находится в пространстве имен Commands (пространстве имен по умолчанию для проекта Commands), потребуется добавить следующую строку: xmlns:local="clr-namespace:Commands" В данном примере в качестве псевдонима для пространства имен был выбран local. Однако в принципе псевдонимом может быть любое слово, соответствующее стилю файла XAML. Теперь к команде можно получать доступ через пространство имен local: <CommandBinding Command="local:DataCommands.Requery" Executed="RequeryCommand_Excecuted"></CommandBinding> Ниже показан полный код примера простого окна с кнопкой, запускающей команду Requery. <Window x:Class="Commands.CustomCommand" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" Title="CustomCommand" Height=00" Width=00"> <Window.CommandBindings> <CommandBinding Command="local:DataCommands.Requery" Executed="RequeryCommand_Executed"></CommandBinding> </Window.CommandBindings> <Button Margin=" Command="local:DataCommands.Requery">Requery</Button> </Window> Для завершения этого примера осталось только реализовать в коде обработчик событий RequeryCommand_Executed(). Вдобавок с помощью события CanExecute можно избирательно включать или отключать эту команду.
280 Глава 9. Команды Совет. Во время применения специальных команд может понадобиться вызвать статический метод CommandManager.InvalidateRequerySuggestedO, чтобы заставить WPF заново оценить состояние команды В этом случае WPF сгенерирует событие CanExecute и обновит все источники, использующие эту команду. Использование одной и той же команды в разных местах Одной из ключевых концепций в модели команд WPF является область действия. Хотя фактически существует только одна копия каждой команды, эффект применения команды варьируется в зависимости от места ее инициации. Например, два текстовых поля поддерживают команды Cut, Copy и Paste, но соответствующая операция выполняется в том из них, на котором в текущий момент находится фокус. До сих пор еще не было показано, как добиться подобного поведения для создаваемых самостоя- Рис. 9.7. Текстовый редактор с воз- тельно команд. Для примера предположим, что можностью обработки двух файлов создается окно с пространством под два документа одновременно (рис. 9.7). Команды Cut, Copy и Paste, как обнаружится, будут автоматически работать с правильным текстовым полем. Однако команды New, Open и Save, реализованные самостоятельно, этого делать не будут. Проблема здесь в том, что при срабатывании события Executed для одной из этих команд совершенно не понятно, к какому из текстовых полей оно относится — к первому или второму. Хотя объект ExecutedRoutedEventArgs поддерживает свойство Source, оно отражает лишь элемент, который имеет привязку к команде (подобно ссылке на отправителя). А пока что все привязки команд присоединены к содержащему окну. Решить эту проблему можно, привязав команду в каждом текстовом поле по-разному с помощью коллекции CommandBindings, например: <TextBox.CommandBindings> <CommandBinding Command="ApplicationCommands.Save" Executed="SaveCommand_Executed" CanExecute="SaveCommand_CanExecute"x/CommandBinding> </TextBox.CommandBindings> Теперь текстовое поле обрабатывает событие Executed. Этой информацией можно воспользоваться в обработчике события, чтобы удостовериться в сохранении корректных данных: private void SaveCommand_Executed(object sender, ExecutedRoutedEventArgs e) { string text = ((TextBox)sender).Text; MessageBox.Show("About to save: " + text); lsDirty = false; } Показанная реализация имеет два недостатка. Первый состоит в том, что простой флаг isDirty теперь больше использовать нельзя, т.к. требуется следить не за одним, а за двумя текстовыми полями. Для этой проблемы существует несколько решений. ■ ' TwoDocument : Save j Document l| 1 Document 2 В 11 ■ >py >mhPTM Paste
Глава 9. Команды 281 Во-первых, можно воспользоваться свойством TextBox.Tag для сохранения флага lsDirty. Тогда при каждом вызове метода CanExecuteSaveO достаточно будет просто заглядывать в свойство Tag отправителя. Во-вторых, для сохранения значения isDirty можно создать приватную словарную коллекцию с индексацией по ссылке на элемент управления. Тогда при вызове метода CanExecuteSaveO достаточно будет просто найти в этой коллекции значение isDirty, которое принадлежит отправителю. Ниже показан код, который должен использоваться в таком случае: private Dictionary«Object, bool> isDirty = new Dictionary<Ob]ect, bool>(); private void txt_TextChanged(object sender, RoutedEventArgs e) { isDirty [sender] = true; } private void SaveCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e) { if (isDirty.ContainsKey(sender) && isDirty[sender]) { e.CanExecute = true; } else { e.CanExecute = false; } } Вторым недостатком текущей реализации является то, что она предусматривает создание двух привязок команд, а на самом деле требуется только одна. Это привносит беспорядок в файл XAML и усложняет его сопровождение. Проблема еще больше усугубляется при наличии большого количества таких команд, которые должны использоваться в обоих текстовых полях. Решение состоит в создании единой командной привязки и ее добавление в коллекцию CommandBindings обоих элементов TextBox. Реализовать это в коде не составит труда. Однако чтобы сделать это в XAML-разметке, потребуется использовать ресурсы WPF. Добавьте в верхнюю часть окна раздел, в котором создается необходимый объект CommandBinding с назначением ему имени ключа: <Window.Resources> <CommandBinding x: Кеу= "binding" Command="ApplicationCommands . Save" Executed="SaveCommand" CanExecute="CanExecuteSave"> </CommandBinding> </Window.Resources> Для вставки этого объекта в другое место внутри разметки используется расширение StaticResource, в котором указывается имя ключа: <TextBox.CommandBindings> <StaticResource ResourceKey="binding"x/StaticResource> </TextBox.CommandBindings> Использование параметра команды В приведенных до сих пор примерах параметр команды для передачи дополнительной информации не использовался. Тем не менее, некоторые команды всегда требуют предоставления дополнительной информации. Например, команда NavigationCommands. Zoom для изменения масштаба всегда ожидает процентное значение. Аналогично, нетрудно представить, что в определенных сценариях некоторые из уже использовавшихся команд тоже могут принимать допол-
282 Глава 9. Команды нительную информацию. Например, команде Save в текстовом редакторе, поддерживающем возможность обработки сразу двух файлов (см. рис. 9.7), требуется знать, какой из файлов выбрать при сохранении документа. Решение заключается в установке свойства CommandParameter. Это свойство можно устанавливать прямо на элементе управления ICommandSource (и даже использовать выражение привязки, которое извлекает значение из другого элемента управления). Например, ниже показано, как установить процент масштаба для кнопки, связанной с командой Zoom, читая значение из другого текстового поля: <Button Command="NavigationCommands.Zoom" CommandParameter="{Binding ElementName=txtZoom, Path=Text}"> Zoom To Value </Button> К сожалению, такой подход работает не всегда. Например, в текстовом редакторе, поддерживающем возможность обработки сразу двух файлов, кнопка Save (Сохранить) используется обоими элементами TextBox, но для каждого элемента управления TextBox должно применяться свое имя файла. В подобных ситуациях нужно либо сохранять информацию в другом месте (например, в свойстве TextBox.Tag или в отдельной коллекции с индексацией по именам файлов и текстовыми полями), либо инициировать команду программным образом примерно так, как показано ниже: ApplicationCommands.New.Execute(theFileName, (Button)sender); В обоих случаях параметр делается доступным в обработчике событий Executed через свойство ExecutedRoutedEventArgs.Parameter. Отслеживание и отмена команд В модели команд не хватает возможности делать команду обратимой. Хотя доступна команда ApplicationCommands.Undo, она обычно используется в элементах управления редактированием (таких как TextBox), которые поддерживают собственные журналы данных отката. Если функция Undo должна поддерживаться в масштабах всего приложения, потребуется отслеживать предыдущее состояние внутренним образом и восстанавливать его при поступлении команды Undo. К сожалению, расширить систему команд WPF непросто. Для присоединения специальной логики доступно относительно небольшое количество точек входа, да и те не документированы. Для создания универсальной функции Undo, которую можно было бы использовать многократно, должен быть создан совершенно новый набор классов "отменяемых" команд и специализированный тип привязки. По сути, систему команд WPF потребуется заменить собственной, созданной самостоятельно системой. Более удобным решением является разработка собственной системы для отслеживания и отмены команд, но с применением класса CommandManager для хранения хронологии выполнения этих команд. На рис. 9.8 показан пример реализации такого подхода. Окно на этом рисунке включает в себя два текстовых поля, в которых можно вводить любой текст, и поле списка, где отслеживается каждая команда, использованная в обоих текстовых полях. Щелчок на кнопке Reverse Last Action (Отменить последнее действие) позволяет отменить последнюю команду. Для создания такого решения необходимо использовать несколько новых приемов. Первой деталью является класс для отслеживания хронологии выполнения команд. Может прийти в голову создать специальную систему, которая будет хранить список последних выполнявшихся команд (возможно, даже создать производный класс ReversibleCommand с методом вроде UnexecuteO для аннулирования задачи, которая выполнялась последней).
Глава 9. Команды 283 MonitorCommands Cut Copy Paste Undo J Text typed here] 1 Text typed here. ! Paste Cut 1 • Paste : Backspace • Reverse Last Command Ю * if j Рис. 9.8. Функция Undo, действующая в масштабах всего приложения К сожалению, такая система работать не будет, потому что все команды в WPF трактуются как одиночные экземпляры. Это означает, что в приложении существует только по одному экземпляру каждой команды. Чтобы понять, в чем состоит проблема, представьте, что в приложении поддерживается команда EditingCommands.Backspace, и пользователь выполняет несколько таких команд подряд. Этот факт может регистрироваться за счет добавления команды Backspace в стек последних команд, но на самом деле в стек будет просто несколько раз добавлен один и тот же объект команды. В результате получается, что никакого простого способа для сохранения другой информации с этой же командой, например, символа, который был только что удален с ее помощью, не существует. Для хранения этого состояния придется создать собственную структуру данных. В рассматриваемом здесь примере для этого используется класс CommandHistoryltem. Каждый объект CommandHistoryltem отслеживает несколько фрагментов информации. • Имя команды. • Элемент, для которого была выполнена команда. В примере имеется два текстовых поля, так что таким элементом может быть одно из них. • Свойство, которое было изменено в целевом элементе. В примере это будет свойство Text класса TextBox. • Объект, который можно использовать для сохранения предыдущего состояния задействованного элемента (например, текста, который находился в текстовом поле перед выполнением команды). На заметку! Такое проектное решение является довольно хитроумным, поскольку предполагает сохранение информации о состоянии только для одного элемента. Для сохранения снимка состояния всего окна потребовалось бы гораздо больше памяти. При наличии больших порций данных (например, текстовых полей с десятками строк), накладные расходы, связанные с функцией Undo, перестают быть несущественными. В таких случаях следует ограничивать количество сохраняемых в журнале элементов либо применять более интеллектуальную (или более сложную) процедуру, предполагающую сохранение информации только об измененных данных, а не обо всех данных.
284 Глава 9. Команды Класс CommandHistoryltem также включает один метод — универсальный метод Undo(). Этот метод использует рефлексию для применения предыдущего значения к модифицированному свойству. Он прекрасно подходит для восстановления текста в элементе TextBox, нов более сложном приложении вместо него понадобиться иерархия классов CommandHistoryltem, каждый из которых способен отменять действия различных типов разным образом. Ниже приведен полный код класса CommandHistoryltem, в котором применяются автоматические свойства, поддерживаемые языком С#: public class CommandHistoryltem { public string CommandName { get; set; } public UIElement ElementActedOn { get; set; } public string PropertyActedOn { get; set; } public object PreviousState { get; set; } public CommandHistoryltem(string commandName) : this(commandName, null, "", null) { } public CommandHistoryltem(string commandName, UIElement elementActedOn, string propertyActedOn, object previousState) { CommandName = commandName; ElementActedOn = elementActedOn; PropertyActedOn = propertyActedOn; PreviousState = previousState; } public bool CanUndo { get { return (ElementActedOn != null && PropertyActedOn != ""); } } public void Undo() { Type elementType = ElementActedOn.GetType (); Propertylnfo property = elementType.GetProperty(PropertyActedOn); property.SetValue(ElementActedOn, PreviousState, nuil); } } Следующей необходимой деталью является команда, которая будет выполнять действие Undo в масштабах приложения. Команда ApplicationCommands .Undo на эту роль не подходит, поскольку она уже используется в отдельных элементах управления для другой цели (отмена последнего изменения при редактировании). Таким образом, понадобится создать новую команду: private static RoutedUICommand applicationUndo; public static RoutedUICommand ApplicationUndo { get { return MonitorCommands.applicationUndo; } } static MonitorCommands() { applicationUndo = new RoutedUICommand( "ApplicationUndo", "Application Undo", typeof(MonitorCommands)); }
Глава 9. Команды 285 В рассматриваемом примере эта команда определяется в классе окна по имени Monitor Commands. Пока что в коде нет ничего примечательного (за исключением разве что небольшого фрагмента с кодом рефлексии, который отвечает за выполнение операции отмены). Самой сложной частью является интеграция хронологии выполнения этой команды в модель команд WPF. В идеале было неплохо организовать все так, чтобы можно было отслеживать любую команду, каким бы образом она не инициировалась и какой бы привязкой не обладала. В неудачно спроектированном решении пришлось бы полагаться на совершенно новый набор специальных объектов-команд и встраивать в них эту логику или вручную обрабатывать события Executed каждой команды. Обеспечить реакцию на конкретную команду можно довольно легко, но как это сделать для любой команды? Секрет заключается в использовании класса CommandManager, который имеет несколько статических событий. К их числу относятся CanExecute, PreviewCanExecute, Executed и PreviewCanExecuted. В рассматриваемом примере наибольший интерес представляют два последних события, потому что они генерируются всякий раз, когда выполняется любая команда. Событие Executed подавляется классом CommandManager, но для него все равно можно добавить обработчик, вызвав метод UIElement .AddHandler () и передав ему значение true в качестве необязательного третьего параметра. Это позволит получать событие, даже несмотря на его обработку, как было описано в главе 4. Однако событие Executed генерируется после выполнения команды, а тогда уже слишком поздно сохранять информацию о состоянии задействованного элемента управления в журнале команд. Поэтому понадобится обеспечить реакцию не на это событие, а на PreviewExecuted, которое генерируется непосредственно перед ним. Ниже показан код, присоединяющий обработчик событий PreviewEvent в конструкторе окна и удаляющий его при закрытии окна. public MonitorCommands() { InitializeComponent(); this.AddHandler(CommandManager.PreviewExecutedEvent, new ExecutedRoutedEventHandler(CommandExecuted)); } private void window_Unloaded(object sender, RoutedEventArgs e) { this.RemoveHandler(CommandManager.PreviewExecutedEvent, new ExecutedRoutedEventHandler(CommandExecuted)); } При срабатывании события PreviewExecuted необходимо определить, привела ли к его инициации команда, которая заслуживает внимания. Если да, то можно создать элемент CommandHistoryltem и добавить его в стек Undo. Кроме того, необходимо остерегаться двух возможных проблем. Во-первых, после щелчка на кнопке в панели инструментов для выполнения той или иной команды в отношении текстового поля событие CommandExecuted инициируется дважды: один раз для кнопки в панели инструментов, а второй — для самого текстового поля. В приведенном коде дублирование записей в журнале Undo предотвращается за счет игнорирования команды в случае, если отправителем является ICommandSource. Во-вторых, команды, которые не требуется добавлять в журнал Undo, должны игнорироваться явным образом. К числу таких команд относится ApplicationUndo, позволяющая отменить предыдущее действие.
286 Глава 9. Команды private void CommandExecuted(object sender, ExecutedRoutedEventArgs e) { // Игнорировать источник кнопки меню. if (e.Source is ICommandSource) return; // Игнорировать команду ApplicationUndo. if (e.Command == MonitorCommands.ApplicationUndo) return; TextBox txt = e.Source as TextBox; if (txt != null) { RoutedCommand cmd = (RoutedCommand)e.Command; CommandHistoryltem historyltem = new CommandHistoryltem( cmd.Name, txt, "Text", txt.Text); ListBoxItem item = new ListBoxItem(); item.Content = historyltem; IstHistory.Items.Add(historyltem); } } В рассматриваемом примере все объекты CommandHistoryltem сохраняются в элементе ListBox. Для свойства DisplayMember этого элемента устанавливается значение Name, чтобы в нем отображалось свойство CommandHistoryltem.Name каждого объекта. В приведенном коде функция Undo поддерживается только в том случае, если команда инициируется для текстового поля. Однако этот код достаточно универсален, чтобы работать с любым текстовым полем в окне, т.е. его можно легко расширить для поддержки других элементов управления и свойств. И, наконец, последней деталью является код, который будет выполнять саму операцию отмены в масштабах приложения. В обработчике CanExecute можно сделать так, чтобы он выполнялся только при наличии в журнале Undo хотя бы одного элемента: private void ApplicationUndoCommand_CanExecute (object sender, CanExecuteRoutedEventArgs e) { if (IstHistory == null | | IstHistory. Items .Count == 0) e.CanExecute = false; else e. CanExecute = true; } Для отмены последнего изменения нужно просто вызвать для соответствующего объекта CommandHistoryltem метод Undo () и затем удалить его из списка: private void ApplicationUndoCommand_Executed(object sender, RoutedEventArgs e) { CommandHistoryltem historyltem = (CommandHistoryltem) IstHistory.Items[IstHistory.Items.Count - 1]; if (historyltem.CanUndo) historyltem.Undo(); IstHistory.Items.Remove(historyltem); } Хотя приведенный пример демонстрирует концепцию и представляет собой простое приложение с множеством элементов управления, которые полностью поддерживают функцию Undo, перед тем как применять подобный подход в реальном приложении, придется внести массу усовершенствований. Например, понадобится уделить время на улучшение обработчика событий CommandManager.PreviewExecuted, чтобы он игнорировал команды, которые точно не должны отслеживаться. (Сейчас, например,
Глава 9. Команды 287 события вроде выделения текста с помощью клавиатуры или нажатия клавиши пробела приводят к вызову команд.) Аналогично, может потребоваться добавить объекты CommandHistoryltem для действий, которые должны быть обратимыми, но не представлены командами, таких как ввод фрагмента текста и переход к другому элементу управления. И, наконец, возможно, имеет смысл ограничить журнал Undo хранением информации для определенного количества недавних команд. Резюме В настоящей главе рассматривалась модель команд WPF. Сначала было показано, как подключать элементы управления к командам, обеспечивать реакцию на срабатывание команд и обрабатывать команды различным образом в зависимости от того, где они инициируются. Затем объяснялось, каким образом создавать собственные команды и расширять систему команд WPF с помощью журнала команд и функции Undo. В целом модель команд WPF не является столь же простой, как и другие части архитектуры WPF. Способ, которым она встраивается в модель маршрутизируемых событий, требует наличия довольно сложного набора классов, и ее внутренние механизмы не являются расширяемыми. Тем не менее, эта модель команд все равно представляет собой значительный шаг вперед по сравнению с Windows Forms, где вообще не было никаких возможностей для работы с командами.
ГЛАВА 10 Ресурсы Система ресурсов WPF представляет собой просто способ поддержания вместе набора полезных объектов, таких как наиболее часто используемые кисти, стили или шаблоны, что существенно упрощает работу с ними. Несмотря на возможность создания и манипулирования ресурсами в коде, обычно они определяются в XAML-разметке. Как только ресурс определен, его можно использовать повсюду в остальной части разметки окна (а в случае, если он представляет собой ресурс приложения, то повсюду в остальной части приложения). Такой подход упрощает разметку, сокращает количество повторяющихся фрагментов кода и позволяет хранить детали, касающиеся пользовательского интерфейса (вроде цветовой схемы приложения), в центральном месте, чтобы в дальнейшем их было проще модифицировать. Ресурсы объектов также служат основой для многократного использования стилей WPF, как будет показано в следующей главе. На заметку! Не путайте ресурсы объектов WPF с ресурсами сборки, о которых рассказывалось в главе 7. Ресурсом сборки называется блок двоичных данных, который встраивается в скомпилированную сборку. Такой ресурс может использоваться для обеспечения приложения необходимым ему изображением или звуковым файлом. С другой стороны, ресурсом объекта называется объект .NET, который определяется в одном месте, а используется в нескольких других. Общие сведения о ресурсах В WPF ресурсы можно определять в коде или в различных местах внутри разметки (вместе с отдельными элементами управления, внутри отдельных окон или во всем приложении). Ресурсы обладают рядом важных преимуществ, которые перечислены ниже. • Эффективность. Ресурсы позволяют определять объект один раз и затем использовать его в нескольких местах внутри разметки. Это упрощает код и делает его намного эффективнее. • Сопровождаемостъ. Ресурсы позволяют переносить низкоуровневые детали форматирования (вроде размеров шрифтов) в центральное место, где их легко изменять. Это своего рода XAML-эквивалент создания констант в коде. • Адаптируемость. После отделения определенной информации от остальной части приложения и ее помещения в раздел ресурсов появляется возможность ее динамической модификации. Например, может понадобиться изменять детали ресурсов на основе пользовательских предпочтений или текущего языка.
Глава 10. Ресурсы 289 Коллекция ресурсов Каждый элемент включает свойство Resources, в котором хранится словарная коллекция ресурсов (представляющая собой экземпляр класса ResourceDictionary). Эта коллекция ресурсов может хранить объект любого типа с индексацией по строке. Хотя каждый элемент имеет свойство Resources (которое определено в классе FrameworkElement), чаще всего ресурсы определяются на уровне окна. Причина в том, что каждый элемент имеет доступ к ресурсам из собственной коллекции ресурсов, а также к ресурсам из коллекции ресурсов всех своих родительских элементов. Например, рассмотрим окно с тремя кнопками, показанное на рис. 10.1. Две из трех кнопок используют одну и ту же кисть — кисть изображения, которая закрашивает их мозаичным узором с улыбающимися рожицами. * Resources A Tiled Button A Normal Button Another Tiled Button ЩШШ] : 1! Рис. 10.1. Окно с повторно используемой кистью В данном случае очевидно, что у верхней и нижней кнопки должен быть одинаковый стиль. Однако не исключено, что позже может понадобиться изменить какие-то характеристики кисти изображения. По этой причине имеет смысл определить кисть в ресурсах окна и затем просто использовать ее заново необходимым образом. Ниже показано, как это можно сделать: <Window.Resources> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="happyface.jpg" Opacity=.3"> </ImageBrush> </Window.Resources> Детали кисти изображения здесь не особо важны (это более подробно рассматривается в главе 12). По-настоящему важную роль играет первый атрибут Key (предваряемый префиксом пространства имен х:, который обеспечивает размещение в пространстве имен XAML, а не WPF). В нем указано имя, под которым должна индексироваться кисть в коллекции Window.Resources. Использовать допускается любое имя. На заметку! В разделе ресурсов можно создавать экземпляр любого класса .NET (в том числе собственных классов), который является дружественным по отношению к XAML. Это означает, что он должен обладать несколькими базовыми характеристиками, такими как общедоступный конструктор без аргументов и свойства, допускающие запись.
290 Глава 10. Ресурсы Чтобы использовать ресурс в XAML-разметке, необходим какой-то способ, которым можно было бы на него ссылаться. Таким способом является использование расширения разметки. В действительности доступны два расширения разметки: одно предназначено для динамических ресурсов, а второе — для статических ресурсов. Статические ресурсы устанавливаются один раз, при первом создании окна. Динамические ресурсы применяются повторно в случае изменения ресурса. (Более подробно отличия между этими типами ресурсов рассматриваются далее в главе.) В рассматриваемом примере кисть изображения никогда не изменяется, поэтому статический ресурс является вполне подходящим вариантом. Ниже приведен код одной из кнопок, использующих данный ресурс: <Button Background="{StaticResource TileBrush}" Margin=" Padding=" FontWeight="Bold" FontSize=4"> A Tiled Button </Button> Ресурс извлекается и применяется для присваивания значения свойству Button. Background. To же самое можно проделать (правда, с чуть большими накладными расходами) с использованием динамического ресурса: <Button Background="{DynamicResource TileBrush}" Использовать для ресурса простой объект .NET действительно просто. Однако существует несколько тонких деталей, которые должны быть продуманы. О них пойдет речь в последующих разделах. Иерархия ресурсов Каждый элемент имеет собственную коллекцию ресурсов, и WPF производит рекурсивный поиск необходимого ресурса в дереве элементов. Благодаря этому, в текущем примере кисть изображения можно было бы перенести из коллекции Resources окна в коллекцию Resources содержащего все три кнопки элемента StackPanel без изменения способа работы приложения. Ее также можно было бы переместить в коллекцию Button. Re sources, но тогда потребовалось бы определять ее для каждой кнопки. Существует еще одна особенность, которую следует учесть. Статический ресурс всегда должен быть определен в коде разметки перед ссылкой на него. Это означает, что хотя размещение раздела Windows.Resources после основного содержимого формы (панели StackPanel, содержащей все кнопки) вполне допустимо, такое изменение приведет к нарушению работоспособности текущего примера. Столкнувшись с неизвестной статической ссылкой на ресурс, анализатор XAML генерирует исключение. (Эту проблему можно обойти за счет использования динамического ресурса, но веские причины для внесения дополнительных накладных расходов отсутствуют.) В результате, если необходимо поместить ресурс в элемент кнопки, код разметки должен быть немного перестроен, чтобы ресурс определялся до установки фона. Ниже показан один из возможных способов: <Button Margin=" Padding=" FontWeight="Bold" FontSize=4"> <Button.Resources> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 10 10" ImageSource="happyface.jpg" Opacity=.3"></ImageBrush> </Button.Resources> <Button.Background> <StatlcResource ResourceKey="TileBrush"/> </Button.Background> <Button.Content>Another Tiled Button</Button.Content> </Button>
Глава 10. Ресурсы 291 Синтаксис для расширения разметки статического ресурса в этом примере выглядит немного по-другому, поскольку устанавливается во вложенном элементе (а не в атрибуте). Ключ ресурса задается с использованием свойства ResourseKey для указания на правильный ресурс. Интересно то, что имена ресурсов можно использовать повторно, главное — не применять одно и то же имя более одного раза в рамках одной и той же коллекции. Это означает, что окно можно было бы создать и с помощью показанного ниже кода, где кисть изображения определяется в двух местах. <Window х:CIass="Resources.TwoResources" xmlns="http://scheraas.microsoft.com/winfx/200б/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" Title="Resources" Height=00" Width=00" > <Window.Resources> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="happyface.jpg" Opacity=.3"></ImageBrush> </Window.Resources> <StackPanel Margin="> <Button Background="{StaticResource TileBrush}" Padding=" FontWeight="Bold" FontSize=4" Margin=" >A Tiled Button</Button> <Button Padding=" Margin=" FontWeight="Bold" FontSize=4">A Normal Button</Button> <Button Background^"{DynamicResource TileBrush}" Padding=" Margin=" FontWeight="Bold" FontSize=4"> <Button.Resources> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="sadface.jpg" Opacity=.3"></ImageBrush> </Button.Resources> <Button.Content>Another Tiled Button</Button.Content> </Button> </StackPanel> </Window> В данном случае кнопка использует тот ресурс, который находит первым. Поскольку поиск начинается с собственной коллекции Resources, вторая кнопка использует изображение sadface.jpg, в то время как первая кнопка извлекает кисть из содержащего окна и работает с изображением happyface.jpg. Статические и динамические ресурсы Из-за предыдущего примера, в котором применялся статический ресурс (в роли которого выступала кисть изображения), могло сложиться впечатление, что статический ресурс невосприимчив ни к каким вносимым в него изменениям. Однако на самом деле это не так. Например, предположим, что на каком-то этапе после применения ресурса и отображения окна выполняется следующий код: ImageBrush brush = (ImageBrush)this.Resources["TileBrush"]; brush.Viewport = new Rect@, 0, 5, 5); В этом коде кисть извлекается из коллекции Window.Resources и подвергается кое- каким манипуляциям. (Формально код изменяет размер каждого фрагмента кисти, сжимая улыбающуюся рожицу и теснее упаковывая узор.) Может показаться, что никакой
292 Глава 10. Ресурсы реакции в пользовательском интерфейсе при выполнении этого кода быть не должно — в конце концов, это же статический ресурс. Однако изменение все-таки распространяется на две кнопки. Фактически кнопки обновляются и получают новое значение для свойства Viewport независимо от того, используют они кисть через статический или динамический ресурс. Причина в том, что класс Brush унаследован от класса по имени Freezable. Класс Freezable обладает средствами для отслеживания базовых изменений (и может "замораживаться" до доступного только для чтения состояния, если ему не нужно изменяться). Это означает, что при каждом изменении кисти в WPF все использующие эту кисть элементы управления обновляются автоматически. И не важно, получают они эту кисть через ресурс или нет. На этом этапе наверняка возникает вопрос о том, чем же тогда отличаются статические и динамические ресурсы? А вот чем: в случае статического ресурса объект извлекается из коллекции ресурсов только один раз. В зависимости от типа объекта (и способа, которым он используется) любые вносимые в этот объект изменения могут быть замечены сразу же. В случае динамического ресурса, однако, объект отыскивается в коллекции ресурсов при каждом возникновении в нем необходимости. Это означает, что под тем же самым ключом может размещаться и совершенно новый объект, и динамический ресурс будет подхватывать это изменение. Чтобы увидеть это отличие на примере, рассмотрим следующий код, в котором текущая кисть изображения заменяется совершенно новой кистью с однотонной заливкой голубого цвета: this.Resources["TileBrush"] = new SolidColorBrush(Colors.LightBlue); Динамический ресурс подхватит это изменение, а статический не будет иметь ни малейшего понятия о том, что его кисть была заменена в коллекции Resources какой- то другой, и, следовательно, продолжит пользоваться исходной кистью ImageBrush. На рис. 10.2 показан этот пример в окне, включающем динамический ресурс (верхняя кнопка) и статический ресурс (нижняя кнопка). Обычно накладные расходы, связанные с использованием динамического ресурса, не нужны, и приложение может прекрасно работать со статическим ресурсом. Исключением является ситуация, когда создаются ресурсы, зависящие от настроек Windows (например, системных цветов). В таком случае должны применяться динамические ресурсы, чтобы приложение могло реагировать на любое изменение в текущей цветовой схеме. (Если использовать в таком случае статические ресурсы, приложение будет работать со старой цветовой схемой до тех пор, пока пользователь его не перезапустит.) Более подробно об этом речь пойдет далее в главе. Динамические свойства рекомендуется использовать только в перечисленных ниже ситуациях. • Ресурс имеет свойства, которые зависят от параметров системы (таких как текущие цвета или шрифты Windows); • Планируется заменять объекты ресурсов программным образом (например, для реализации средства динамических обложек, как будет показано в главе 17). • Resources КЭЦЁ Uses a Dynamic Resource Change the Brush Uses a Static Resource Рис. 10.2. Динамический и статиче ский ресурсы
Глава 10. Ресурсы 293 Не стоит чрезмерно увлекаться динамическими ресурсами. Главная проблема в том, что изменение ресурса не обязательно приводит к обновлению пользовательского интерфейса. (В приведенном примере с кистью обновление происходит, благодаря способу, которым конструируются объекты кисти — в частности потому, что они имеют встроенную поддержку уведомлений.) Однако встречается масса случаев, когда динамическое содержимое должно отображаться в элементе управления так, чтобы этот элемент управления мог самостоятельно подстраиваться под изменение содержимого. В таких ситуациях лучше пользоваться привязкой данных. На заметку! В редких случаях динамические ресурсы также используются для ускорения первоначальной загрузки окна. Объясняется это тем, что статические ресурсы всегда загружаются при создании окна, в то время как динамические ресурсы загружаются при их первом использовании. Однако заметными улучшения становятся только в случае, если ресурс является слишком большим и сложным (при этом синтаксический анализ его кода разметки занимает ощутимое время). Неразделяемые ресурсы Обычно когда ресурс используется во множестве мест, применяется один и тот же экземпляр объекта. Такое поведение — называемое разделением — оказывается, как правило, тем, что нужно. Тем не менее, допустимо также указывать анализатору на необходимость создания отдельного экземпляра объекта при каждом его использовании. Разделение отключается с помощью атрибута Shared: <ImageBrush x:Key="TileBrush" x : Shared="False" . . . ></ImageBrush> Для применения неразделяемых ресурсов существует несколько веских причин. Рассматривать вариант использования неразделяемых ресурсов стоит, если в дальнейшем планируется изменять экземпляры ресурса по отдельности. Например, можно создать окно с несколькими кнопками, использующими одну и ту же кисть, но отключить разделение, чтобы иметь возможность изменять кисть каждой из кнопок отдельно. Такой подход является не особо распространенным, поскольку не эффективен. В этом случае лучше позволить всем кнопкам использовать одну и ту же кисть изначально, а затем создавать и применять новые объекты кисти по мере необходимости. Тогда иметь дело с накладными расходами, связанными с дополнительными объектами кисти, придется только там, где это действительно необходимо. Еще одна причина применения неразделяемых ресурсов — необходимость повторного использования объекта таким способом, который в противном случае невозможен. Например, это позволяет определить элемент (вроде Image или Button) как ресурс и затем отображать его в нескольких местах внутри окна. Опять-таки, обычно это не самый лучший подход. Например, для повторного использования элемента Image целесообразнее сохранить соответствующий фрагмент информации (такой как объект Bitmaplmage, идентифицирующий источник изображения) и затем разделить его между множеством элементов Image. Если просто нужно стандартизировать элементы управления так, чтобы они совместно использовали одни и те же свойства, гораздо лучше применять стили, которые подробно рассматриваются в следующей главе. Стили предоставляют возможность создавать идентичные или почти идентичные копии любого элемента, а также переопределять значения свойств, когда они не подходят, и присоединять конкретные обработчики событий, чего нельзя делать в случае простого клонирования элемента, использующего неразделяемый ресурс.
294 Глава 10. Ресурсы Доступ к ресурсам в коде Обычно ресурсы определяются и используются в разметке. Однако при необходимости с коллекцией ресурсов можно работать и в коде. Как уже было показано, элементы можно извлекать из коллекции ресурсов по имени. Однако при таком подходе должна использоваться коллекция ресурсов правильного элемента. Как упоминалось ранее, на разметку это ограничение не распространяется. Элемент управления вроде кнопки может извлекать ресурс, не зная точно, где тот определен. Когда свойству Background кнопки присваивается кисть, среда WPF проверяет коллекцию ресурсов кнопки на предмет наличия в ней ресурса по имени TileBrush, затем коллекцию ресурсов содержащего ее элемента StackPanel, после чего коллекцию ресурсов содержащего ее окна. (Как будет показано в следующем разделе, этот процесс фактически продолжается поиском в ресурсах приложения и системы.) Искать ресурс подобным образом можно с помощью метода FrameworkElement. FindResource(). Ниже приведен пример, в котором при срабатывании события Click производится поиск ресурса кнопки (или одного из ее контейнеров более высокого уровня): private void cmdChange_Click(object sender, RoutedEventArgs e) { Button cmd = (Button)sender; ImageBrush brush = (ImageBrush)sender.FindResource ( "TileBrush"); } Вместо FindResource () можно также использовать метод TryFindResourceO, который вместо генерации исключения, если ресурс не найден, возвращает ссылку null. Кстати, ресурсы допускается добавлять программно. Для этого необходимо выбрать элемент для размещения ресурса и вызвать метод Add () коллекции ресурсов. Тем не менее, гораздо чаще ресурсы определяются в разметке. Ресурсы приложения Элемент Window не является последним местом поиска ресурса. Если указан ресурс, который не удается найти ни в элементе управления, ни в одном из его контейнеров (вплоть до окна или страницы, содержащей этот элемент), WPF продолжает проверку в наборе ресурсов, которые были определены для приложения. В Visual Studio таковыми являются ресурсы, которые были определены в разметке внутри файла App.xaml: <Application x:Class="Resources.App" xmlns="http://schemas.microsoft.com/winfx/20 06/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="Menu.xaml" > <Application.Resources> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="happyface.jpg" Opacity=.3"> </ImageBrush> </Application.Resources> </Application> Несложно догадаться, что ресурсы приложения предоставляют прекрасную возможность для многократного использования объекта по всему приложению. В приведенном примере неплохим вариантом будет применение кисти изображения в более чем одном окне.
Глава 10. Ресурсы 295 На заметку! Прежде чем создавать ресурс приложения, оцените компромисс между сложностью и возможностью многократного использования. Добавление ресурса приложения предоставляет большую свободу в плане повторного использования, но и увеличивает степень сложности, поскольку не позволяет сразу же понять, в каких окнах применяется данный ресурс. (Концептуально это напоминает программу C++ старого стиля, с чрезмерно большим количеством глобальных переменных.) Ресурсы приложения рекомендуется создавать, если объект многократно используется повсеместно в приложении (например, во множестве окон). Если же он используется только в двух или трех местах, тогда лучше рассмотреть вариант с определением ресурса в каждом окне. Оказывается, что ресурсы приложения также не являются последним местом при выполнении элементом поиска ресурса. Если обнаружить ресурс в ресурсах приложения не удается, поиск продолжается в ресурсах системы. Ресурсы системы Как упоминалось ранее, динамические ресурсы главным образом предназначены для того, чтобы помочь приложению реагировать на изменения в системных настройках. При этом сразу возникает вопрос: как извлечь настройки системы и работать с ними в коде? Секрет кроется в наборе из трех классов SystemColors, SystemFonts и SystemParameters, которые расположены в пространстве имен System.Windows. Класс SystemColors предоставляет доступ к настройкам цвета. Класс SystemFonts обеспечивает доступ к настройкам шрифтов. Класс SystemParameters охватывает огромный список настроек, которые описывают стандартный размер различных экранных элементов, параметры клавиатуры и мыши, размер экрана, а также активные графические эффекты (вроде отбрасывания теней и отображения содержимого окон при перетаскивании). На заметку! Классы SystemColors и SystemFonts доступны в двух версиях, которые размещены в пространствах имен System.Windows и System.Drawing. Версии из System. Windows являются частью WPF Они используют правильные типы данных и поддерживают систему ресурсов. Версии из System. Drawing относятся к Windows Forms. В приложениях WPF они бесполезны. Все детали в классах SystemColors, SystemFonts и SystemParameters предоставляются через статические свойства. Например, свойство SystemColors.WindowTextColor дает структуру Color, которую можно использовать любым желаемым образом. Ниже приведен пример ее применения для создания кисти и заливки переднего плана элемента: label.Foreground = new SolidBrush(SystemColors.WindowTextColor); Однако эффективнее воспользоваться просто готовым свойством кисти: label.Foreground = SystemColors.WindowTextBrush; Доступ к статическим свойствам в WPF можно получать с помощью статического расширения разметки. Например, ниже показано, как установить основной фон того же самого элемента Label в XAML-разметке: <Label Foreground="{x:Static SystemColors.WindowTextBrush}"> Ordinary text </Label> В этом примере не используется ресурс. Кроме того, с ним связан один небольшой недостаток: при выполнении синтаксического анализа окна и создании метки кисть
296 Глава 10. Ресурсы создается на основании текущего "снимка" цвета текста в окне. В случае изменения цветов Windows во время работы приложения (после отображения окна, содержащего метку) метка не сможет обновиться. Приложения, которые ведут себя подобным образом, считаются несколько неаккуратными. Решить эту проблему установкой свойства Foreground непосредственно в объект кисти нельзя. Вместо этого его понадобится установить в объект DynamicResource, служащий оболочкой для данного системного ресурса. К счастью, во всех классах SystemXxx предоставляется дополнительный набор свойств, которые возвращают объекты ResourceKey — ссылки, позволяющие извлекать ресурс из коллекции ресурсов системы. Эти свойства имеют те же имена, что и обычные свойства, напрямую возвращающие объект, со словом Key в конце. Например, ключом ресурса для SystemColors. WindowTextBrush будет SystemColors.WindowTextBrushKey. На заметку! Ключи ресурсов представляют собой не простые имена, а ссылки, которые сообщают WPF о том, где следует искать конкретный ресурс. Класс ResourceKey является непрозрачным, т.е. он не показывает низкоуровневые детали о том, как идентифицируются ресурсы системы. Однако переживать о возможных конфликтах между собственными ресурсами и ресурсами системы не стоит, поскольку они размещаются в отдельных сборках и трактуются по-разному. Ниже показано, как использовать ресурс из класса SystemXxx: <Label Foreground="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"> Ordinary text </Label> Этот код разметки немного сложнее приведенного в предыдущем примере. Он начинается с определения динамического ресурса. Динамический ресурс, однако, не извлекается из коллекции ресурсов в приложении. Вместо этого применяется ключ, определенный свойством SystemColors.WindowTextBrushKey. Поскольку свойство является статическим, также должно использоваться статическое расширение разметки, чтобы анализатор смог понять намерения. После внесения такого изменения метка будет без проблем обновляться в случае изменения настроек системы. Словари ресурсов Чтобы разделить ресурсы между множеством проектов можно создать словарь ресурсов. Словарь ресурсов представляет собой просто XAML-документ, который всего лишь хранит необходимые ресурсы. Создание словаря ресурсов Ниже показан пример словаря ресурсов с одним ресурсом внутри: <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/20 06/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/20 06/xaml"> <ImageBrush x:Key="TileBrush" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="happyface.jpg" Opacity=.3"></ImageBrush> </ResourceDictionary> При добавлении словаря ресурсов в приложение удостоверьтесь, что свойство Build Action установлено в Page (страница); это принимается для всех XAML-файлов. В результа-
Глава 10. Ресурсы 297 те словарь ресурсов скомпилируется в формат BAML и обеспечит более высокую производительность. Однако вполне допустимо установить свойство Build Action словаря ресурсов в Resource (ресурс); в этом случае он будет встраиваться в сборку, но не компилироваться. Синтаксический анализ во время выполнения тогда будет проходить медленнее. Использование словаря ресурсов Чтобы использовать словарь ресурсов, где-нибудь в приложении его необходимо объединить с коллекцией ресурсов. Это можно делать в каком-то конкретном окне, однако чаще объединение осуществляется на уровне коллекции ресурсов приложения, как показано ниже. <Application x:Class="Resources.Арр" xmlns="http://schemas.microsoft.com/winfx/20 06/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/20 06/xaml" StartupUri="Menu.xaml" > <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="AppBrushes.xaml"/> <ResourceDictionary" Source="WizardBrushes.xaml"/> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources> </Application> В приведенном коде разметки объект ResourceDictionary создается явно. Коллекция ресурсов всегда представляет собой объект ResourceDictionary, но данный случай является одним из тех, в которых эта деталь должна быть обязательно указана явно, чтобы иметь возможность также устанавливать свойство ResourceDictionary.MergedDictionaries. Если не предпринять этот шаг, значением свойства MergedDictionaries будет null. MergedDictionaries — это коллекция объектов ResourceDictionary, которые будут использоваться для пополнения коллекции ресурсов. В рассматриваемом случае объектов ResourceDictionary два: первый определен в словаре ресурсов AppBrushes.xaml, а второй — в WizardBrushes.xaml. Чтобы добавить собственные ресурсы и включить их в словари ресурсов, необходимо просто разместить их перед или после раздела MergedProperties: <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="AppBrushes.xaml"/> <ResourceDictionary Source="WizardBrushes.xaml"/> </ResourceDictionary.MergedDictionaries> <ImageBrush x:Key="GraphicalBrushl" . . . X/ImageBrush> <ImageBrush x:Key="GraphicalBrush2" . . . X/ImageBrush> </ResourceDictionary> </Application.Resources> На заметку! Как упоминалось ранее, хранить ресурсы с одинаковыми именами в разных, но перекрывающихся коллекциях ресурсов вполне допустимо, однако объединять словари ресурсов, которые содержат ресурсы с одинаковыми именами — нет. При обнаружении дубликата во время компиляции приложения генерируется исключение XamlParseException. Одна из причин применения словарей ресурсов — определение одной или нескольких многократно используемых "обложек" приложения, которые можно применять к
298 Глава 10. Ресурсы элементам управления (об этом речь пойдет в главе 17). Еще одна причина связана с необходимостью сохранения содержимого, которое должно быть локализовано (такого как строки сообщений об ошибках). Разделение ресурсов между сборками Если необходимо использовать словарь ресурсов во множестве приложений, можно копировать и распространять содержащий его XAML-файл. Это самый простой подход, но он не поддерживает управление версиями. Более структурированный прием предусматривает компиляцию словаря ресурсов в отдельную сборку типа библиотеки классов и распространение его в виде такого компонента. При разделении скомпилированной сборки с одним или несколькими словарями ресурсов возникает еще одна трудность, а именно — нужен какой-нибудь способ для извлечения необходимого ресурса и его использования в приложении. Здесь возможны два варианта. Самым простым решением является использование кода, который создает соответствующий объект ResourceDictionary. Например, если словарь ресурсов находится в сборке типа библиотеки классов по имени ReusableDictionary.xaml, для его создания вручную можно воспользоваться следующим кодом: ResourceDictionary resourceDictionary = new ResourceDictionary(); resourceDictionary.Source = new Uri ( "ResourceLibrary/component/ReusableDictionarу.xaml", UriKind.Relative); В этом фрагменте кода применяется синтаксис упакованных URI, который рассматривался в главе 7. Код конструирует относительный URI, указывающий на скомпилированный XAML-pecypc по имени ReusableDictionary.xaml, который расположен в другой сборке. После создания объекта ResourceDictionary необходимый ресурс можно извлекать из коллекции вручную: cmd.Background = (Brush)resourceDictionary["TileBrush"]; Однако назначать ресурсы вручную не понадобится. После загрузки нового словаря ресурсов любые имеющиеся в окне ссылки Dynamic Re source будут автоматически вычисляться заново. Пример применения этого приема будет показан в главе 17 во время построения средства динамического создания обложек. Для тех, кто не желает писать никакого кода, доступен другой вариант. Можно использовать специально предназначенное для этой цели расширение разметки Component Re source Key. Данное расширение указывает WPF, что ресурс планируется разделять между сборками. На заметку! До этого момента демонстрировались только ресурсы, у которых для имен ключей использовались строки (такие как "TileBrush"). Строки являются самым типичным способом для именования ресурсов. Тем не менее, WPF обладает интеллектуальной функцией расширяемости ресурсов, которая активизируется автоматически, когда в качестве имен ключей применяются некоторые типы, не являющиеся строками. Например, в следующей главе будет показано, как использовать объект Туре для имени ключа стиля Это автоматически укажет WPF применять данный стиль к элементам соответствующего типа. Аналогично экземпляр ComponentResourceKey можно использовать в качестве имени ключа для любого ресурса, который должен быть разделен между сборками. Прежде чем двигаться дальше, необходимо позаботиться о назначении словарю ресурсов корректного имени. Для этого словарь ресурсов должен находиться в файле по имени generic.xaml, а этот файл — в подпапке Themes приложения. Ресурсы в файлах generic.xaml воспринимаются как часть темы по умолчанию и потому всегда делаются доступными. Этот прием будет встречаться еще не раз, в частности, во время построения специальных элементов управления в главе 18.
Глава 10. Ресурсы 299 На рис. 10.3 показано, как выглядит надлежащая организация файлов. Верхний проект, именуемый ResourceLibrary, включает файл generic.xaml в правильной папке. Нижний проект, имеющий имя Resources, включает ссылку на проект ResourceLibrary и потому может пользоваться содержащимися в нем ресурсами. Совет. Чтобы организовать наилучшим образом большое количество ресурсов, можно создавать отдельные словари ресурсов так, как было описано раньше. Однако эти словари должны быть обязательно включены в файл generic.xaml, чтобы к ним можно было получать доступ Следующим шагом является создание имени ключа для разделяемого ресурса, который хранится в сборке ResourceLibrary. В случае использования Component Re source Key необходимо предоставить два фрагмента информации — ссылку на соответствующий класс в сборке библиотеки классов и описательный идентификатор ресурса. Ссылка на класс — это часть той "магии", которая позволяет WPF разделять ресурс с другими сборками. При использовании этого ресурса сборки будут предоставлять ту же самую ссылку на класс и тот же самый идентификатор ресурса. То, как класс выглядит на самом деле, роли не играет, да и содержать код ему вовсе не обязательно. Сборка, в которой определен данный тип, представляет собой ту же сборку, в которой ComponentResourceKey будет искать ресурс. В примере, показанном на рис. 10.3, используется класс CustomResources, который не содержит какой-либо код: public class CustomResources {} Теперь можно создать имя ключа с использованием этого класса и идентификатора ресурса: х:Кеу="{ComponentResourceKey TypeInTargetAssembly={x:Type local:CustomResources }, ResourceId=SadTileBrush}" Ниже приведен весь необходимый для файла generic.xaml код разметки, который включает в себя единственный ресурс ImageBrush, использующий другое графическое изображение: <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2 0 0 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2 00 6/xaml" xmlns:local="clr-namespace:ResourceLibrary"> <ImageBrush x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:CustomResources}, ResourceId=SadTileBrush}" TileMode="Tile" ViewportUnits="Absolute" Viewport= 0 32 32" ImageSource="ResourceLibrary;component/sadface.jpg" Opacity=.3"> </ImageBrush> </ResourceDictionary> Solution Explorer - Solution 'Resources' B projects; - - 1 . ^3 1 'ZHi Solution Resources' B projects) fc 3 ResourceLiDrary л Properties t л References .^Themes il CustomResources.es _J sadface.jpg 3 Resources i м Properties « л References ♦ • App.xaml • * OynamicResource.xaml Л happyface.jpg • Menu-xaml - ResourceFromLibrary.xaml TwoResources.xaml • • WindowResourcejcaml Рис. 10.3. Разделение ресурсов с помощью библиотеки классов
300 Глава 10. Ресурсы В этом примере имеется одна неожиданная деталь — свойство ImageSource больше не устанавливается с использованием имени изображения (sadface.jpg)- Взамен применяется более сложный относительный URI, который четко указывает, что изображение является частью компонента ResourceLibrary. Такой шаг является обязательным, поскольку данный ресурс будет использоваться в контексте еще одного приложения. Если указать просто имя изображения, то приложение будет искать изображение только в собственных ресурсах. Именно потому и необходим относительный URI, указывающий на компонент, в котором хранится изображение. Теперь, когда словарь ресурсов создан, его можно использовать в еще одном приложении. Для начала нужно позаботиться об определении префикса для сборки библиотеки классов, как показано ниже: <Window x:CIass="Resources.ResourceFromLibrary" xmlns="http://schemas.microsoft.com/winfx/2 0 0 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2 0 0 6/xaml" xmlns:res="clr-namespace:ResourceLibrary;assembly=ResourceLibrary" . . . > Затем можно использовать объект DynamicResource, содержащий ComponentResourceKey. (В этом есть смысл, поскольку ComponentResourceKey является именем ресурса.) Применяемый клиентом ComponentResourceKey ничем не отличается от ComponentResourceKey из библиотеки классов. Вы предоставляете ссылку на тот же самый класс и тот же идентификатор ресурса. Единственное отличие заключается в том, что здесь нельзя применять тот же самый префикс пространства имен XML. В приведенном примере вместо префикса local используется res, чтобы подчеркнуть тот факт, что определение класса CustomResources размещается в другой сборке: <Button Background="{DynamicResource {ComponentResourceKey TypeInTargetAssembly={x:Type res:CustomResources}, ResourceId=SadTileBrush}}" Padding=" Margin=" FontWeight="Bold" FontSize=4"> A Resource From ResourceLibrary </Button> На заметку! При использовании ComponentResourceKey должен применяться динамический, а не статический ресурс. На этом рассматриваемый пример завершен. Тем не менее, есть еще один дополнительный шаг, который можно предпринять, чтобы упростить доступ к ресурсу. Можно определить статическое свойство, возвращающее корректный ключ ComponentResourceKey, который необходимо использовать. Обычно это свойство определяется в классе внутри компонента: public class CustomResources { public static ComponentResourceKey SadTileBrushKey { get { return new ComponentResourceKey ( typeof(CustomResources), "SadTileBrush"); } } } Теперь можно воспользоваться расширением разметки Static для доступа к этому свойству и применению ресурса без указания длинного ComponentResourceKey в коде разметки:
Глава 10. Ресурсы 301 <Button Background=" {DynamicResource {x: Static res : CustomResources . SadTileBrushKey}} " Padding=" Margin=" FontWeight="Bold" FontSize=4"> A Resource From ResourceLibrary </Button> Этот удобный сокращенный подход представляет собой, по сути, тот же прием, который применялся в описанных ранее классах SystemXxx. Например, в результате извлечения SystemColors.WindowTextBrushKey получается корректный объект ключа ресурса. Единственное отличие состоит в том, что он является экземпляром приватного класса SystemResourceKey, а не ComponentResourceKey. Оба эти класса происходят от одного и того же родителя — абстрактного класса ResourceKey. Резюме В настоящей главе было показано, как система ресурсов WPF позволяет многократно использовать одни и те же объекты в различных частях приложения. Вы узнали, как объявлять ресурсы в коде и разметке, как применять ресурсы системы и как разделять ресурсы между множеством приложений с помощью сборок библиотек классов. На этом рассмотрение ресурсов не завершается. Объектные ресурсы очень часто используются для хранения стилей— коллекций настроек для свойств, которые могут применяться к множеству элементов. Об определении стилей, сохранении их в виде ресурсов и повторном их использовании речь пойдет в следующей главе.
ГЛАВА 11 Стили и поведения Приложения WPF выглядели бы не особо привлекательно, если бы кнопки и прочие элементы управления могли получать только простой серый вид. К счастью, WPF поддерживает средства, которые позволяют оформлять базовые элементы со вкусом и стандартизировать внешний вид приложения. В настоящей главе речь пойдет о двух наиболее важных из них — о стилях и поведениях. Стили (style) представляют собой важный инструмент для организации и повторного использования вариантов форматирования. Вместо того чтобы заполнять XAML-файл повторяющимся кодом разметки для установки деталей вроде полей, отступов, цветов и шрифтов, можно просто создавать набор охватывающих все эти детали стилей, а затем применять эти стили по мере необходимости, устанавливая единственное свойство. Поведения (behavior) представляют собой более сложный механизм для повторного использования кода пользовательского интерфейса. Основная идея состоит в том, что поведение инкапсулирует некоторую общую часть функциональности пользовательского интерфейса (например, код, делающий элементы перетаскиваемыми). Полученное поведение легко присоединить к любому элементу с помощью всего пары строк XAML- разметки, что существенно экономит усилия по написанию и отладке кода. Что нового? Хотя стили в WPF 4 не изменились, поведения являются совершенно новым средством, которое появилось в последних версиях Expression Blend. Поведения формализуют шаблон проекта (обычно называемый присоединяемыми поведениями), который уже часто применяется в WPF-приложениях. Кроме того, они добавляют первоклассную поддержку этапа проектирования для пользователей Expression Blend. Основные сведения о стилях В предыдущей главе рассказывалось о системе ресурсов WPF, которая позволяет определять объекты в одном месте и затем повторно использовать их в других частях разметки. Хотя ресурсы можно применять для хранения самых различных объектов, чаще всего в них хранятся стили. Стилем называется коллекция значений свойств, которые могут применяться к элементу. Система стилей WPF играет ту же роль, которую играет стандарт каскадных таблиц стилей (Cascading Style Sheet — CSS) в HTML-разметке. Подобно CSS, стили WPF позволяют определять общий набор характеристик форматирования и применять его повсюду в приложении для обеспечения согласованного вида. Как и CSS, они могут работать автоматически, предназначаться для элементов конкретного типа и каскадироваться через дерево элементов. Однако стили являются более мощными, поскольку способны устанавливать любое свойство зависимости. Это означает, что их можно применять и для стандартизации не связанных с форматированием характеристик, таких как поведение какого-то элемента управления. Вдобавок стили WPF поддерживают триггеры, которые позволяют изменять стиль элемента управления при
Глава 11. Стили и поведения 303 изменении какого-то свойства (как будет показано далее в главе), и могут использовать шаблоны для переопределения встроенного внешнего вида элемента управления (см. главу 17). Освоив стили, вы обязательно начнете включать их во все разрабатываемые WPF- приложения. Чтобы понять, каким образом стили вписываются в приложения, следует рассмотреть простой пример. Предположим, что требуется стандартизировать используемый в окне шрифт. Простейший подход предусматривает установку свойств шрифта содержащего окна. Эти свойства, определенные в классе Control, включают Font Family, FontSize, FontWeight (для полужирного начертания), FontStyle (для курсивного начертания) и FontStretch (для сжатого и разрешенного вариантов). Благодаря функции наследования значений свойств, при установке этих свойств на уровне окна все элементы внутри окна получат одинаковые значения, если только те не будут переопределены в них явным образом. На заметку! Функция наследования значений свойств является лишь одной из многих дополнительных средств, которые могут предоставлять свойства зависимости Свойства зависимости рассматривались в главе 4. Теперь рассмотрим другую ситуацию. Предположим, что требуется заблокировать шрифт, который используется только в определенной части пользовательского интерфейса. Если есть возможность изолировать эти элементы в специальном контейнере (например, внутри одного элемента управления Grid или StackPanel), можно воспользоваться в точности тем же подходом и установить свойства шрифта этого контейнера. Однако в реальности все обычно оказывается не так просто. Например, может требоваться, чтобы все кнопки получили свою гарнитуру и размер шрифта независимо от настроек шрифта, используемых другими элементами. В таком случае необходим какой-то способ определения этих деталей в одном месте и затем повторного их использования везде, где они нужны. Ресурсы предлагают возможное, но несколько громоздкое решение. Поскольку объекта, представляющего шрифт, в WPF не существует (есть только коллекция связанных со шрифтом свойств), соответствующие ресурсы можно определять только так, как показано ниже: <Window.Resources> <FontFamily x:Key="ButtonFontFamily">Times New Roman</FontFamily> <sys:Double x:Key="ButtonFontSize">18</s:Double> <FontWeight x:Key="ButtonFontWeight">Bold</FontWeight> </Window.Resources> В этом фрагменте кода разметки в окно добавляются три ресурса: объект Font Family с именем шрифта, который должен использоваться, объект Double с числом 18 и перечислимое значение FontWeight .Bold. Здесь предполагается, что .NET-пространство имен System было отображено на префикс sys пространства имен XML: <Window xmlns:sys="clr-namespace:System;assembly=mscorlib" ... > Совет. При установке свойств с использованием ресурса важно, чтобы типы данных в точности совпадали. Конвертеры типов, которые применяются при установке значения атрибута напрямую, здесь отсутствуют. Например, при установке атрибута Font Family в элементе можно использовать строку "Times New Roman", потому что FontFamilyConverter автоматически создаст необходимый объект Font Family. Однако попытка установить свойство FontFamily с использованием строкового ресурса ничего подобного не произойдет, и анализатор XAML сгенерирует исключение.
304 Глава 11. Стили и поведения После определения всех необходимых ресурсов их можно использовать в элементе. Поскольку за время жизненного цикла приложения ресурсы никогда не изменяются, имеет смысл применять статические ресурсы: <Button Padding=" Margin=" Name="cmd" FontFamily="{StaticResource ButtonFontFamily}" FontWeight="{StaticResource ButtonFontWeight}" FontSize="{StaticResource ButtonFontSize}"> A Customized Button </Button> Этот пример работает и позволяет устранить из разметки детали, касающиеся шрифта. Однако он также приводит к появлению двух новых проблем. • Отсутствие четкого указания, что все три ресурса связаны между собой (за исключением разве что похожих имен). Это усложняет сопровождение приложения. Особенно это проявляется, когда необходимо установить дополнительные свойства для шрифта или поддерживать разные настройки шрифта для различных типов элементов. • Разметка, необходимая для использования ресурсов, слишком многословна. Фактически она оказывается не лаконичнее подхода, который заменяет (определение свойств шрифта прямо в элементе). Справиться с первой проблемой можно, определив специальный класс (вроде FontSettings), который объединяет все касающиеся шрифта детали вместе. Затем можно создать один объект FontSettings как ресурс и использовать его различные свойства в разметке. Тем не менее, это не устранит вторую проблему, к тому же потребует довольно много дополнительных усилий. Стили предлагают практически идеальное решение. Как показано ниже, можно определить единственный стиль, который упаковывает все свойства, подлежащие установке: <Window.Resources> <Style x:Key="BigFontButtonStyle"> <Setter Property="Control.FontFamily" Value="Times New Roman" /> <Setter Property="Control.FontSize" Value=8" /> <Setter Property="Control.FontWeight" Value="Bold" /> </Style> </Window.Resources> В этом коде разметки создается один ресурс — объект System .Windows. Style. В этом объекте размещается коллекция Setters с тремя объектами Setter, по одному для каждого свойства, которое подлежит установке. В каждом объекте Setter указывается имя свойства, на которое он влияет, и значение, которое он должен применять к этому свойству. Как и все ресурсы, объект стиля имеет ключевое имя, по которому его можно при необходимости извлекать из коллекции. В данном случае это ключевое имя выглядит как BigFontButtonStyle. (По общепринятому соглашению ключевые имена стилей обычно заканчиваются словом "Style".) Каждый элемент WPF может использовать одиночный стиль (или не использовать его). Стиль подключается к элементу через свойство Style (которое определено в базовом классе FrameworkElement). Например, чтобы сконфигурировать кнопку для использования созданного выше стиля, необходимо указать кнопке ресурс стиля: <Button Padding=11 Margin=11 Name="cmd11 Style="{StaticResource BigFontButtonStyle}"> A Customized Button </Button>
Глава 11. Стили и поведения 305 Разумеется, стиль можно устанавливать и программно. Все, что для этого понадобится — это просто извлечь стиль из ближайшей коллекции Resources с помощью уже знакомого метода FindResourceO. Ниже приведен код, который можно применить для объекта Button по имени cmd: cmd.Style = (Style)cmd.FindResource("BigFontButtonStyle"); На рис. 11.1 показано окно с двумя кнопками, которые используют специальный стиль BigFontButtonStyle. I ■ ; ReuseFontWithStyles А Customized Button Normal Content. A Normal Button j i More normal Content. | 1 Another Customized Button Рис. 11.1. Повторное использование настроек кнопки с помощью стиля На заметку! Стили задают первоначальный внешний вид элемента, но устанавливаемые ими характеристики могут быть переопределены. Например, если применить стиль BigFontButtonStyle и явно установить свойство FontSize, параметр FontSize в дескрипторе кнопки переопределит этот стиль. В идеале полагаться на такое поведение не стоит — вместо этого лучше создать больше стилей, которые позволяют устанавливать максимальное количество деталей на уровне стиля. В результате обеспечивается большая гибкость при настройке пользовательского интерфейса в будущем. Система стилей привносит массу преимуществ. Она не только позволяет создавать группы параметров, явно связанных между собой, но также упрощает код разметки, тем самым облегчая применение этих параметров. Лучше всего то, что стиль можно применять, не беспокоясь о том, какие свойства он устанавливает В предыдущем примере параметры шрифта были упакованы в стиль под названием BigFontButtonStyle. Если позже возникнет необходимость снабдить кнопки с крупным шрифтом полями и границами, можно очень легко добавить средства установки для свойств Padding и Margin. Все кнопки, использующие данный стиль, автоматически получат новые настройки стиля. Коллекция Setters является самым важным свойством класса Style. Вообще существует; пять ключевых свойств, которые кратко описаны в табл. 11.1. Все они более подробно рассматриваются далее в главе. Рассмотрев простой пример стиля в действии, можно переходить к более глубокому изучению модели стилей.
306 Глава 11. Стили и поведения Таблица 11.1. Свойства класса Style Свойство Описание Setters Коллекция объектов Setter или EventSetter, которые устанавливают значения для свойств и присоединяют обработчики событий автоматически Triggers Коллекция объектов, унаследованных от класса TriggerBase, которые позволяют автоматически изменять настройки стиля. Настройки стиля могут модифицироваться, например, при изменении значения какого-то другого свойства или при поступлении какого-нибудь события Resources Коллекция ресурсов, которые должны использоваться со стилями. Например, может понадобиться использовать единственный объект для установки нескольких свойств. В таком случае более эффективно создать объект как ресурс и затем использовать этот ресурс в объекте Setter (вместо создания этого объекта в виде части каждого Setter с применением вложенных дескрипторов) BasedOn Свойство, которое позволяет создавать более специализированный стиль, наследующий (и дополнительно переопределяющий) параметры другого стиля TargetType Свойство, которое идентифицирует тип элемента, к которому применяется данный стиль. Это свойство позволяет создавать объекты Setter, влияющие только на определенные элементы, а также объекты Setter, автоматически вступающие в силу для всех элементов подходящего типа Создание объекта стиля В предыдущем примере объект стиля определялся на уровне окна и затем повторно использовался в двух кнопках внутри этого окна. Хотя такое решение встречается довольно часто, единственно возможным вариантом оно, естественно, не является. Более точно направленные стили можно определять с использованием коллекции Resources их контейнера, такого как StackPanel или Grid. Чтобы многократно использовать стили по всему приложению, их можно определять с применением коллекции Resources приложения. Эта два подхода тоже являются общими. Строго говоря, применять стили и ресурсы вместе вовсе необязательно. Например, стиль определенной кнопки можно определить, заполнив ее коллекцию Style напрямую, как показано ниже: <Button Padding=11 Margin=ll> <Button.Style> <Style> <Setter Property="Control.FontFamily" Value="Times New Roman" /> <Setter Property="Control .FontSize" Value=,,18" /> <Setter Property="Control. FontWeight" Value="Bold11 /> </Style> </Button.Style> <Button.Content>A Customized ButtorK/Button.Content> </Button> Это работает, но очевидно является гораздо менее полезным, потому что лишает возможности разделения данного стиля с другими элементами. Такой подход не стоит потраченных усилий в случае, если стиль используется просто для установки определенных свойств (как в этом примере), поскольку гораздо проще устанавливать эти свойства напрямую. Иногда он оказывается полезным, скажем, когда
Глава 11. Стили и поведения 307 нужно, чтобы другое средство стилей применялось только к единственному элементу. Например, этот подход можно использовать для присоединения триггеров к элементу. Также он позволяет модифицировать часть шаблона элемента управления. (В таком случае используется свойство Setter.TargetName для применения объекта Setter к конкретному компоненту внутри элемента, такому как кнопки полосы прокрутки в окне списка. Более подробно этот прием рассматривается в главе 17.) Установка свойств Как было показано ранее, любой объект Style умещает в себе коллекцию объектов Setter. Каждый объект Setter устанавливает одно свойство в элементе. Единственное ограничение состоит в том, что объект Setter может изменять только свойство зависимости — другие свойства не могут быть модифицированы. В некоторых случаях значение свойства не может устанавливаться с использованием простой строки атрибута. Например, объект ImageBrush нельзя создать с nor мощью простой строки. В такой ситуации должен применяться уже знакомый трюк с XAML-разметкой, который заключается в замещении атрибута вложенным элементом, например: <Style x:Key=llHappyTiledElementStyle"> <Setter Property="Control.Background"> <Setter.Value> <ImageBrush TileMode="Tile11 ViewportUnits=,,Absolute" Viewport= 0 32 32" ImageSource="happyface.jpg" Opacity=.3"> </ImageBrush> </Setter.Value> </Setter> </Style> Совет. Если нужно повторно использовать одну и ту же кисть изображения в более чем одном стиле (или в более чем одном объекте Setter того же самого стиля), можно определить его как ресурс, который затем применять внутри стиля. Чтобы идентифицировать свойство, которое должно устанавливаться, необходимо предоставить имя класса и имя свойства. Имя класса, однако, не обязательно должно представлять тот класс, в котором данное свойство определено. Это может быть имя производного класса, который наследует свойство. Например, взгляните на следующую версию стиля BigFontButton, в которой ссылки на класс Control заменяются ссылками на класс Button: <Style x:Key=,,BigFontButtonStyle"> <Setter Property="Button.FontFamily" Value="Times New Roman" /> <Setter Property="Button. FontSize" Value=,,18" /> <Setter Property="Button. FontWeight" Value=,,Bold" /> </Style> Если подставить этот стиль в примере, который был показан на рис. 11.1, результат окажется тем же. В чем тогда разница? В данном случае разница состоит в способе обработки средой WPF других классов, которые могут включать те же самые свойства FontFamily, FontSize и FontWeight, но не наследоваться от Button. Например, если применить эту версию стиля BigFontButton к элементу управления Label, ничего не произойдет. WPF просто проигнорирует эти три свойства, поскольку к Label они неприменимы. Но если используется исходный стиль, то связанные со шрифтом свойства окажут влияние на элемент управления Label, потому что класс Label унаследован от Control.
308 Глава 11. Стили и поведения Совет. Факт игнорирования WPF неприменимых свойств означает возможность установки свойств, которые необязательно будут доступны в элементе, к которому применяется стиль. Например, если установлено свойство ButtonBase.lsCancle, оно будет вступать в силу только в случае установки стиля для кнопки. В WPF имеется несколько случаев определения одних и тех же свойств в более чем одном месте в иерархии элементов. Например, весь набор свойств шрифтов (вроде свойства FontFamily) определен как в классе Control, так и в классе TextBlock. Если создается стиль, который применяется к объектам TextBox и элементам, унаследованным от Control, может появиться идея записать разметку следующим образом: <Style x:Key=,,BigFontStyle"> <Setter Property="Button.FontFamily" Value="Times New Roman" /> <Setter Property="Button.FontSize" Value=8" /> <Setter Property="TextBlock. FontFamily" Value="Arial" /> <Setter Property="TextBlock.FontSize" Value=0" /> </Style> Однако это не даст желаемого эффекта. Проблема в том, что хотя свойства Button. FontFamily и TextBlock.FontFamily объявлены отдельно в соответствующих им базовых классах, оба они являются ссылками на одно и то же свойство зависимости. (Другими словами, свойства TextBlock.FontSizeProperty и Control.FontSizeProperty являются ссылками, которые указывают на один и тот же объект DependencyProperty. О возможности подобной проблемы упоминалось в главе 4.) В результате при использовании данного стиля WPF будет устанавливать свойство FontFamily и FontSize дважды. Параметры, применяемые последними (в рассматриваемом случае это шрифт Arial размером в 10 единиц), получают преимущество и применяются к обоим объектам, Button и TextBlock. Хотя эта проблема весьма специфична и для многих свойств никогда не возникает, важно не забывать о ней при создании стилей, которые применяют разное форматирование к различным типам элементов. Существует еще один прием, которым можно пользоваться для упрощения объявлений стилей. Если все свойства предназначены для элементов одного и того же типа, можно установить свойство TargetType объекта Style для указания класса, к которому будут применяться эти свойства. Например, создать стиль, предназначенный только для кнопок, можно следующим образом: <Style x:Key="BigFontButtonStyle" TargetType="Button"> <Setter Property="FontFamily" Value="Times New Roman" /> <Setter Property="FontSize" Value=8" /> <Setter Property="FontWeight" Value="Bold" /> </Style> Это относительно небольшое удобство. Как будет показано позже, свойство TargetType может также дублироваться в виде сокращения, что позволяет применять стили автоматически, если ключевое имя стиля опущено. Присоединение обработчиков событий Средства установки свойств являются наиболее общим ингредиентом в любом стиле, но можно также создать коллекцию объектов EventSetter, связывающих события с определенными обработчиками. Ниже показан пример присоединения обработчиков для событий MouseEnter и MouseLeave:
Глава 11. Стили и поведения 309 <Style x:Key=llMouseOverHighlightStyle"> <EventSetter Event="TextBlock.MouseEnter11 Handler="element_MouseElnter11 /> <EventSetter Event="TextBlock.MouseLeave11 Handler="element_MouseLeave11 /> <Setter Property="TextBlock. Padding" Value=,,/> </Style> А вот код для обработки этих событий: private void element_MouseEnter(object sender, MouseEventArgs e) { ( (TextBlock)sender) .Background = new SolidColorBrush(Colors.LightGoldenrodYellow); } private void element_MouseLeave(object sender, MouseEventArgs e) ( (TextBlock)sender) .Background = null; } ■ • EventSetter ; Hover over me. i Don t bother with me. i Hover over me. События MouseEnter и MouseLeave маршрутизируются напрямую, а это означает, что они не могут ни распространяться пузырьком вверх, ни туннелироваться вниз по дереву элементов. Чтобы применить эффект наведения курсора мыши к большому количеству элементов (например, изменить цвет фона элемента при наведении на него курсора мыши), обработчики событий MouseEnter и MouseLeave должны быть добавлены в каждый интересующий элемент. Обработчики событий, основанные на стилях, позволяют упростить эту задачу. Нужно просто применить единственный стиль, который может включать средства установки свойств и средства установки событий: <TextBlock Style="{StaticResource MouseOverHighlightStyle}"> Hover over me. </TextBlock> На рис. 11.2 показан простой пример, демонстрирующий применение этого приема в отношении трех элементов, два из которых используют стиль MouseOverHighlightStyle. Средства установки событий применяются в WPF редко. Для получения показанной здесь функциональности чаще используются триггеры событий, которые определяют необходимое действие декларативно (и потому не требуют написания кода). Триггеры событий предназначены для реализации анимационных эффектов, что делает их более полезными в создании эффектов при наведении курсора мыши. Средства установки событий мало подходят при обработке событий, использующих пузырьковое распространение. В такой ситуации обычно проще обрабатывать необходимое событие в элементе более высокого уровня. Например, чтобы привязать все кнопки в панели инструментов к одному и тому же обработчику событий Click, наилучшим подходом будет присоединение одиночного обработчика к элементу Toolbar, содержащему все кнопки. В этой ситуации средство установки событий только излишне усложнит код. Рис. 11.2. Обработка событий MouseEnter и MouseLeave С ПОМОЩЬЮ СТИЛЯ
310 Глава 11. Стили и поведения Совет. Во многих случаях гораздо понятнее явно определять все события, вообще избегая использования средств установки событий. Если необходимо связать несколько событий с одним и тем же обработчиком, лучше делать это вручную. Можно также использовать приемы, такие как присоединение обработчика событий на уровне контейнера и централизация логики с помощью команд (см. главу 9). Множество уровней стилей Хотя допускается определять неограниченное количество стилей на множестве различных уровней, каждый элемент WPF может использовать только один объект стиля за раз. Поначалу это может показаться ограничением, но в действительности это не так, благодаря наследованию значений свойств и наследованию стилей. Например, предположим, что группе элементов управления требуется назначить один и тот же шрифт без применения к каждому из них одного и того же стиля. В этом случае можно разместить нужные элементы управления в одной панели (или в контейнере другого типа) и установить стиль контейнера. При условии установки свойств, которые используют средство наследования значений, эти значения будут передаваться дочерним элементам. К числу свойств, которые поддерживают такую модель, относятся IsEnabled, IsVisible, Foreground и все свойства, связанные со шрифтом. В других ситуациях требуется создать стиль, основанный на другом стиле. Для использования наследования стилей необходимо установить атрибут BasedOn соответствующего стиля. Например, рассмотрим два следующих стиля: <Window.Resources> <Style x:Key=,,BigFontButtonStyle"> <Setter Property="Control.FontFamily" Value="Times New Roman" /> <Setter Property="Control .FontSize" Value=811 /> <Setter Property=,,Control.FontWeight" Value=,,Bold" /> </Style> <Style x :Key="EmphasizedBigFontButtonStyle11 BasedOn="{StaticResource BigFontButtonStyle}"> <Setter Property="Control.Foreground" Value="White" /> <Setter Property="Control.Background" Value="DarkBlue" /> </Style> </Window.Resources> Первый стиль (BigFontButtonStyle) определяет три свойства шрифта. Второй стиль (EmphasizedBigFontButtonStyle) получает их от BigFontButtonStyle и затем дополняет двумя свойствами, которые изменяют кисти переднего плана и фона. Такое состоящее из двух частей проектное решение предоставляет возможность применять одни только настройки шрифта либо комбинацию настроек шрифта и цвета. Это решение также позволяет создавать больше стилей, включающих в себя уже определенные детали шрифта или цвета (причем не обязательно вместе). На заметку! Свойство BasedOn можно использовать для создания целой цепочки унаследованных стилей. Главное помнить о том, что в случае установки одного и того же свойства дважды, последнее средство установки этого свойства (из производного класса, находящегося дальше всех в цепочке наследования) будет переопределять любые более ранние определения. На рис. 11.3 показан пример работы наследования стилей в простом окне, использующем оба стиля.
Глава 11 . Стили и поведения 311 ш ' Styielnheritance t ses BigFoutButtottSryle Normal Content. * Normal Button More normal Content. Kmphasi/eriBigl ontBiiHonStyle Рис. 11.3. Создание стиля, основанного на другом стиле Наследование стилей увеличивает сложность Хотя наследование стилей на первый взгляд выглядит очень удобным приемом, обычно оно не стоит затрачиваемых усилий. Причина в том, что наследование стилей влечет за собой те же проблемы, что и наследование кода, т.е. приводит к образованию зависимостей, которые делают приложение более хрупким. Например, в случае использования приведенной выше разметки одни и те же характеристики шрифта обязательно должны сохраняться для обоих стилей. Модификация стиля BigFontButtonStyle приводит также к изменению стиля EmphasizedBigFontButtonStyle — если только явно не добавить дополнительные средства установки, переопределяющие унаследованные значения. Эта проблема тривиальна в примере с двумя стилями, но существенно усложняется при наследовании стилей в реальных приложениях. Обычно стили делятся на категории на основе типов содержимого и ролей, которые это содержимое исполняет. Например, приложение для продаж может включать в себя стили наподобие ProductTitleStyle, ProductTextStyle, HighlightQuoteStyle, NavigationButtonStyle и т.д. Если основать стиль ProductTitleStyle на ProductTextStyle (возможно потому, что они оба разделяют один и тот же шрифт), то возникнут проблемы, когда позже понадобится применить к стилю ProductTextStyle настройки, которые не должны применяться к стилю ProductTitleStyle (например, разные поля). В таком случае придется определить эти настройки в стиле ProductTextStyle и явно переопределить их в стиле ProductTitleStyle. В конечном итоге это приведет к получению гораздо более сложной модели и весьма небольшому количеству настроек, которые действительно используются многократно. Не имея веских причин основывать один стиль на другом (например, если второй стиль является просто особым случаем первого и предусматривает изменение всего лишь нескольких характеристик из огромного количества унаследованных настроек), наследование стилей лучше не использовать. Автоматическое применение стилей по типу До сих пор было показано, как создавать именованные стили и ссылаться на них в коде разметки. Однако существует и другой подход. Стиль можно применять автоматически к элементам определенного типа.
312 Глава 11. Стили и поведения Реализуется это довольно просто. Все, что понадобится — это установить свойство TargetType так, чтобы оно указывало на подходящий тип (как описывалось ранее), и не использовать имя ключа. В этом случае WPF устанавливает имя ключа неявно с использованием расширения разметки Туре: х:Кеу="{х:Туре Button}" Теперь стиль автоматически применяется ко всем кнопкам, расположенным ниже в дереве элементов. Например, если определить стиль подобным образом для окна, он будет применяться к каждой кнопке в этом окне (при условии отсутствия далее в коде другого стиля, заменяющего его). Ниже показан пример с окном, в котором стили кнопок устанавливаются автоматически для получения такого же эффекта, как на рис. 11.1. <Window.Resources> <Style TargetType="Button"> <Setter Property="FontFamily11 Value="Times New Roman" /> <Setter Property="FontSize" Value=8" /> <Setter Property=MFontWeight" Value="Bold11 /> </Style> </Window.Resources> <StackPanel Margin="> <Button Padding=" Margin=">Customized Button</Button> <TextBlock Margin=">Normal Content.</TextBlock> <Button Padding=" Margin=" Style=" {x:Null}">A Normal Button</Button> <TextBlock Margin=">More normal Content.</TextBlock> <Button Padding=" Margin=ll5">Another Customized Button</Button> </StackPanel> В этом примере средняя кнопка явно заменяет стиль. Но вместо предоставления собственного нового стиля, она устанавливает свойство Style в null, что приводит к удалению стиля. Несмотря на удобство, автоматически применяемые стили усложняют решение. Ниже перечислено несколько возможных причин. • В сложном окне с множеством стилей и уровней стилей становится трудно отслеживать то, устанавливается данное свойство посредством наследования значений свойств или с помощью стиля (и какого именно). В результате изменение даже простой детали может потребовать просмотра разметки всего окна. • Форматирование в окне часто сначала является более общим, а потом постепенно усложняется. Если автоматические стили применялись на раннем этапе, скорее всего, их потребуется переопределять во многих местах с помощью явных стилей. Это значительно усложняет решение в целом. Гораздо проще создавать именованные стили для каждой комбинации желаемых характеристик форматирования и применять их по имени. • В случае создания автоматического стиля, например, для элемента TextBlock, обязательно потребуется модифицировать другие элементы управления, которые используют TextBlock (такие как управляемый шаблоном элемент ListBox). Во избежание подобных проблем лучше всего применять автоматические стили рассудительно. Если решено использовать автоматические стили для придания всему пользовательскому интерфейсу единого согласованного вида, старайтесь ограничить применение явно устанавливаемых стилей только особыми случаями.
Глава 11. Стили и поведения 313 Триггеры Одна из особенностей WPF связана с расширением того, что можно делать декларативно. Оказывается, что использовать стили, ресурсы и привязки данных, часто можно, не прибегая к помощи кода. Триггеры являются еще одним примером такой направленности WPF. С помощью триггеров можно автоматизировать процесс внесения простых изменений в стили, каковой обычно требует написания рутинной логики обработки событий. Например, можно обеспечить реакцию на изменение значения свойства и соответствующим образом автоматически подстроить стиль. Триггеры связываются со стилями через коллекцию Style.Triggers. Каждый стиль может иметь любое количество триггеров, а каждый триггер является экземпляром класса, унаследованного от System.Windows.TriggerBase. В табл. 11.2 перечислены классы триггеров, доступные в WPF. С использованием коллекции FrameworkElement.Triggers триггеры можно применять к элементам напрямую, без необходимости в создании стиля. Однако здесь имеется одно серьезное ограничение. Коллекция Triggers поддерживает только триггеры событий. (Никаких формальных оснований для этого ограничения нет; просто разработчики WPF не успели завершить данную функциональность и, скорее всего, сделают это в следующих версиях.) Таблица 11.2. Классы, унаследованные от TriggerBase Имя Описание Trigger Это простейшая форма триггера. Он следит за изменением в свойстве зависимости и затем использует средство установки для изменения стиля MultiTrigger Похож на Trigger, но поддерживает проверку множества условий. Этот триггер вступает в действие, только если удовлетворены все заданные условия DataTrigger Этот триггер работает с привязкой данных. Он похож на Trigger, но следит за изменением в любых связанных данных MultiDataTrigger Этот триггер объединяет множество триггеров данных EventTrigger Это наиболее сложный триггер. Он применяет анимацию, когда возникает соответствующее событие Простой триггер Простой триггер может быть присоединен к любому свойству зависимости. Например, реагируя на изменения в свойствах IsFocused, IsMouseOver и IsPressed класса Control, можно создать эффекты наведения курсора мыши и получения фокуса. Каждый простой триггер идентифицирует наблюдаемое свойство и ожидаемое значение. При появлении этого значения применяются средства установки, которые были сохранены в коллекции Trigger.Setters. (К сожалению, реализовать более сложную логику, вроде проверки, попадает ли значение в заданный диапазон, выполнять вычисления и т.д., нельзя. В таких случаях должен использоваться обработчик событий.) Ниже показан триггер, который ожидает получения кнопкой фокуса с клавиатуры и в этом случае устанавливает для нее фон темно-красного цвета: <Style x:Key=,,BigFontButton"> <Style.Setters> <Setter Property="Control.FontFamily" Value="Times New Roman" /> <Setter Property="Control.FontSize" Value=8" /> </Style.Setters>
314 Глава 11. Стили и поведения <Style.Triggers> <Trigger Property="Control. IsFocused" Value=llTrue"> <Setter Property="Control. Foreground" Value="DarkRed11 /> </Trigger> </Style.Triggers> </Style> Триггеры полезны тем, что для отмены их действия не требуется писать никакой логики. Как только триггер перестает быть действительным, элементу сразу же возвращается его обычный внешний вид. В приведенном примере это означает, что как только пользователь уберет с кнопки фокус, нажав клавишу <ТаЬ>, ее фон приобретет обычный серый цвет. На заметку! Чтобы понять, как это работает, необходимо вспомнить систему свойств зависимости, о которой рассказывалось в главе 4. Триггер, по сути, является одним из множества поставщиков свойств, способных переопределять значение, которое возвращает свойство зависимости. Однако исходное значение (как бы оно не устанавливалось — локально или с помощью стиля) все равно сохраняется. Как только триггер перестает действовать, значение, которое использовалось до его срабатывания, снова становится доступным. Допускается создавать множество триггеров, которые могут применяться к одному и тому же элементу одновременно. В случае если в этих триггерах устанавливаются разные свойства, никакой неоднозначности не возникнет. Если же в нескольких триггерах изменяется одно и то же свойство, предпочтение отдается триггеру, находящемуся последним в списке. Например, рассмотрим следующие триггеры, которые подстраивают элемент управления в зависимости от того, находится ли на нем фокус, наводится ли на него курсор мыши и выполняется ли на нем щелчок: <Style x:Key="BigFontButton"> <Style.Setters> </Style.Setters> <Style.Triggers> <Tngger Property="Control. IsFocused" Value="True"> <Setter Property="Control.Foreground" Value="DarkRed" /> </Trigger> <Tngger Property="Control. IsMouseOver" Value="True"> <Setter Property="Control.Foreground" Value="LightYellow" /> <Setter Property="Control.FontWeight" Value="Bold" /> </Trigger> <Tngger Property="Button . IsPressed" Value="True"> <Setter Property="Control.Foreground" Value="Red" /> </Trigger> </Style.Triggers> </Style> Очевидно, что курсор мыши может быть помещен на кнопку, на которой в текущий момент находится фокус. Это проблемы не представляет, поскольку данные триггеры предполагают изменение разных свойств. Но при выполнении щелчка на кнопке установить цвет переднего плана попытаются одновременно два триггера. Победит в этом случае триггер, который отвечает за свойство Button. IsPressed, поскольку он идет последним в списке. То, какой из триггеров сработает первым, роли не играет — например, WPF безразлично, что кнопка получает фокус перед выполнением на ней щелчка. Роль играет только порядок, в котором триггеры перечислены в коде разметки.
Глава 11. Стили и поведения 315 На заметку! В этом примере триггеры не являются единственными элементами, которые необходимы для придания кнопке привлекательного внешнего вида. Еще имеется шаблон элемента управления для кнопки, который ограничивает определенные возможности, касающиеся ее внешнего вида. Для получения наилучших результатов при настройке элементов в такой степени нужно использовать шаблон элемента управления. Однако шаблоны элементов управления не заменяют триггеры — в действительности они часто используют триггеры для получения преимуществ обеих технологий. В результате получаются элементы управления, которые могут полностью настраиваться и реагировать на наведение курсора мыши, щелчки и другие события, изменяя какой-то аспект своего внешнего вида. Чтобы создать триггер, срабатывающий только при соблюдении сразу нескольких условий, можно воспользоваться классом MutliTrigger. Этот класс имеет коллекцию Conditions, которая позволяет определять цепочки комбинаций свойств и значений. Ниже показан пример применения форматирования только в случае наведения на кнопку и фокуса и курсора мыши: <Style x:Key="BigFontButton"> <Style.Setters> </Style.Setters> <Style.Tnggers> <MultiTngger> <MultiTngger. Conditions> <Condition Property="Control.IsFocused" Value="True"> <Condition Property="Control.IsMouseOver" Value="True"> </MultiTrigger.Conditions> <MultiTrigger.Setters> <Setter Property="Control.Foreground" Value="DarkRed" /> </MultiTngger. Setters> </MultiTngger> </Style.Triggers> </Style> Здесь порядок, в котором объявляются условия, значения не имеет, поскольку для изменения фона требуется только то, чтобы они все возвращали true. Триггер события Если обычный триггер ожидает изменения свойства, то триггер события (EventTrigger) ожидает возникновения конкретного события. Может показаться, что на этом этапе применяются средства установки для изменения элемента, однако это не так. Вместо этого триггер событий требует предоставления последовательности действий, модифицирующих элемент управления. Эти действия используются для применения анимации. Хотя анимационные эффекты будут подробно рассматриваться в главе 15, получить общее представление о них можно уже сейчас. Ниже показан простой пример, в котором триггер событий ожидает"события MouseEnter и затем выполняет анимацию свойства Font Size кнопки, увеличивая размер шрифта до 22 единиц за 0,2 секунды. <Style x:Key="BigFontButtonStyle"> <Style.Setters> </Style.Setters> <Style.Triggers> <EventTrigger RoutedEvent="Mouse.MouseEnter"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard>
316 Глава 11. Стили и поведения <DoubleAnimation Duration=:0:0.2" Storyboard.TargetProperty="FontSize" To=2" /> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> В XAML каждый анимационный эффект должен определяться в раскадровке, которая предоставляет временную шкалу для анимации. Внутри раскадровки задается объект или объекты анимации, которые должны использоваться. Каждый объект анимации решает, по сути, одну и ту же задачу — модифицирует свойство зависимости на протяжении какого-то периода времени. В настоящем примере применяется предварительно определенный класс анимации по имени DoubleAnimation (который, как и все классы анимации, находится в пространстве имен System.Windows.Media.Animation). Класс DoubleAnimation позволяет постепенно изменять любое значение double (такое как Font Size) для получения целевого результата за определенный период времени. Поскольку значение double изменяется небольшими дробными величинами, визуально шрифт увеличивается постепенно. Фактический размер изменения зависит от общего промежутка времени и объема изменений, которые требуется внести. В данном примере размер шрифта изменяется с текущего значения до 22 единиц за 0,2 секунды. (Настраивая свойства класса DoubleAnimation, можно подогнать эти детали более точно и получить анимацию с ускорением или замедлением.) В отличие от триггеров свойств, действие триггеров событий необходимо обращать, чтобы элемент возвращался в свое исходное состояние. (Дело в том, что по умолчанию после завершения анимация остается активной и тем самым сохраняет за свойством последнее значение. Подробнее о том, как работает эта система, будет рассказываться в главе 15.) Чтобы вернуть размер шрифта в исходное состояние, в текущем примере в стиле используется триггер событий, который реагирует на событие MouseLeave и уменьшает шрифт до исходного размера за две секунды. Указывать целевой размер шрифта в данном случае не требуется: если он не указан, WPF предполагает, что кнопке нужно вернуть первоначальный размер шрифта, который был перед выполнением анимации. <EventTrigger RoutedEvent="Mouse.MouseLeave"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Duration=:0:1" Storyboard.TargetProperty="FontSize" /> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> </Style.Triggers> </Style> Интересно, что можно сделать так, чтобы анимация выполнялась при достижении свойством зависимости определенного значения. Это очень удобно в ситуации, когда необходимо запустить анимацию, а подходящего события нет. Для применения такого подхода необходим триггер свойств, рассмотренный в предыдущем разделе. Трюк состоит в том, что для этого триггера свойств средства установки
Глава 11. Стили и поведения 317 не предоставляются, а вместо этого устанавливаются свойства Trigger. Enter Act ions и Trigger.ExitActions. Оба эти свойства в качестве значения принимают коллекцию действий, таких как BeginStoryboard — действие, запускающее анимацию. Действия EnterActions выполняются при достижении свойством указанного значения, а действия ExitActions — при изменении указанного значения на другое. Более подробно использование триггеров событий и триггеров свойств для запуска анимационных эффектов рассматривается в главе 15. Поведения Стили предоставляют практический способ для повторного использования групп настроек свойств. Они являются замечательным первым шагом, который помогает получить согласованные, хорошо организованные интерфейсы, но также очень ограничены в плане возможностей. Проблема заключается в том, что настройки свойств представляют собой лишь небольшую часть инфраструктуры пользовательского интерфейса в любом типичном приложении. Даже самая простая программа обычно требует написания массы кода для пользовательского интерфейса, не имеющего ничего общего с функциональностью приложения. Во многих программах код, который применяется для решения задач в пользовательском интерфейсе (вроде запуска анимации, реализации визуальных эффектов, обслуживания состояния пользовательского интерфейса и поддержки таких необходимых в любом пользовательском интерфейсе средств, как перетаскивание, масштабирование и стыковка), значительно превышает бизнес-код как по размеру, так и по сложности. Большая часть этого кода носит обобщенный характер, а, значит, влечет за собой необходимость написания одних и тех же вещей в каждом создаваемом проекте WPF. И практически весь он является утомительным. В ответ на эту проблему создатели Expression Blend разработали средство под названием поведения. Идея очень проста: сначала создается поведение, которое инкапсулирует общую часть функциональности пользовательского интерфейса. Эта функциональность может быть как простой (запуск раскадровки или переход по ссылке), так и сложной (обработка сенсорного взаимодействия или моделирование столкновения с помощью механизма реального времени). Созданная функциональность затем может добавляться в элемент управления внутри любого приложения за счет подключения этого элемента к правильному поведению и установки свойств поведения. Использование поведения в Expression Blend требует немногим более чем операцию перетаскивания. На заметку! Специальные элементы управления являются еще одним приемом повторного использования функциональности пользовательского интерфейса в приложении (или в нескольких приложениях). Однако они должны разрабатываться в виде тесно связанного пакета визуальных элементов и кода. Несмотря на чрезвычайную мощность, в ситуациях, когда необходимо оснастить множество разных элементов управления схожей функциональностью (например, добавить эффект наведения курсора мыши к группе элементов), они не подходят. По этой причине стили, поведения и специальные элементы управления считаются дополнительными средствами. Получение поддержки для поведений Существует одна загвоздка. Инфраструктура для повторного использования общих блоков кода пользовательского интерфейса не является частью WPF. Вместо этого она поставляется в составе Expression Blend 3 (и Expression Blend 4). Объясняется это тем, что поведения впервые появились как средство времени проектирования для Expression Blend.
318 Глава 11. Стили и поведения Продукт Expression Blend по-прежнему остается единственным инструментом, который позволяет добавлять поведения простым их перетаскиванием на нужные элементы управления. Это не означает, что поведения полезны только в Expression Blend. Их можно создавать и использовать также в приложениях Visual Studio, причем с весьма небольшим количеством дополнительных усилий. Вместо применения окна Toolbox, понадобится просто написать соответствующий код разметки. Получить сборки, предоставляющие поддержку для использования поведений, можно двумя способами. • Установить Expression Blend 3 (или бесплатную ознакомительную версию, которая доступна по адресу http://www.microsoft.com/expression/try-it/Default.aspx). • Установить набор Expression Blend 3 SDK (доступный по адресу http://tinyurl. com/kkp4g8). В обоих случаях в папке c:\Program Files\Microsoft SDKs\Expression\Blend 3\ Interactivity\Libraries\WPF появятся две важных сборки. • System.Windows.Interactivity.dll. В этой сборке определены базовые классы, которые поддерживают поведения. Она является главной составляющей средства поведений. • Microsoft.Expression.Interactions.dll. В этой сборке содержатся полезные расширения, а также необязательные классы действий и триггеров, которые основаны на ключевых классах поведений. Модель поведений Средство поведений поставляется в двух версиях (обе они включены в Expression Blend и Expression Blend SDK). Одна версия предназначена для добавления поддержки поведений к Silverlight (многофункциональный клиентский подключаемый модуль для браузера от Microsoft), а вторая — для WPE Хотя обе они предлагают идентичные возможности, более естественно поведения вписываются в мир Silverlight, поскольку заполняют там больший пробел. В отличие от WPF, в Silverlight отсутствует поддержка триггеров, поэтому в сборках, реализующих поведения, также реализованы и триггеры. Однако в WPF триггеры поддерживаются, а потому наличие у поведений собственной системы триггеров, не совпадающей с моделью триггеров WPF, приводит к немалой путанице. Проблема заключается в том, что эти два компонента с похожими именами совпадают частично, но не полностью. В WPF самая важная роль триггеров состоит в построении гибких стилей и шаблонов элементов управления (см. главу 17). С помощью триггеров стили и шаблоны можно делать более интеллектуальными; например, можно применить визуальный эффект при изменении некоторого свойства. Тем не менее, система триггеров в Expression Blend имеет другое предназначение — она позволяет добавлять в приложения простую функциональность с использованием визуальных средств проектирования. Другими словами, триггеры в WPF служат для создания более мощных стилей и шаблонов элементов управления, а триггеры в Expression Blend — для быстрого построения приложений без написания кода. Итак, что все это означает для обычного разработчика приложений WPF? Ниже приведено несколько пояснений. • Модель поведений не является основной частью WPF, а потому она не настолько устоявшаяся, как стили и шаблоны. Другими словами, программировать WPF- приложения без применения поведений можно, но без стилей и шаблонов создать нечто сложнее демонстрационной программы "Hello World" не получится.
Глава 11. Стили и поведения 319 • Система триггеров, предлагаемая в Expression Blend, может представлять интерес при проведении большей части времени в Expression Blend или при разработке компонентов для других пользователей Expression Blend. Несмотря на то, что она имеет такое же название, как система триггеров WPF, никакого совпадения между ними нет и потому использовать можно обе системы. • Если вы не работаете с Expression Blend, можете вообще не обращать внимания на предлагаемую в этом продукте систему триггеров. Однако стоит ознакомиться с предлагаемыми Expression Blend полнофункциональными классами поведений. Причина в том, что поведения и более мощные, и более распространенные по сравнению с триггерами Expression Blend. Co временем в любом случае обнаружится какой-то компонент от стороннего производителя, включающий хорошо отлаженное поведение, которое можно использовать в собственных приложениях. (Например, в главе 5 рассматривалась сенсорная технология, и рассказывалось о поведении, которым можно воспользоваться, чтобы снабдить свои элементы со- _ ответствующей поддержкой.) Система триггеров Expression Blend в настоящей главе описываться не будет, но зато будут рассмотрены полнофункциональные классы поведений. Дополнительные сведения о триггерах Expression Blend, а также примеры применения поведений (некоторые из них предназначены для Silverlight, а не для WPF), можно найти в публикациях по адресу http://tinyurl.com/yfvakl3. В загружаемом коде для настоящей главы доступны два примера создания специальных триггеров. Создание поведения Поведения служат для инкапсуляции частей функциональности пользовательского интерфейса, чтобы их можно было применять к элементам без написания соответствующего кода вручную. По-другому поведение можно рассматривать как служба, предлагаемая для элементов. Эта служба обычно предусматривает прослушивание нескольких различных событий и выполнение множества связанных операций. Пример, доступный по адресу http://tinyurl.com/y922een, предоставляет поведение водяного знака для текстовых полей. Если текстовое поле является пустым и в текущий момент не имеет фокуса, отображается сообщение с подсказкой (вроде "[Enter text here ]" (Введите здесь текст)) и облегченным начертанием. При перемещении фокуса на текстовое поле поведение активизируется и удаляет текст водяного знака. Наилучшим способом получить представление о поведениях предусматривает создание собственного поведения. Предположим, что необходимо предоставить любому элементу возможность перетаскивания с помощью мыши в рамках контейнера Canvas. Основные шаги для обеспечения такой возможностью единственного элемента довольно просты: код прослушивает события мыши и изменяет присоединенные свойства, которые соответствующим образом устанавливают координаты элемента Canvas. Приложив еще немного усилий, этот код можно превратить в многократно используемое поведение, которое будет снабжать поддержкой перетаскивания любой элемент в любом контейнере Canvas. Прежде всего, создайте сборку библиотеки классов WPF (для настоящего примера подойдет имя CustomBehaviorsLibrary) и добавьте в ней ссылку на сборку System. Windows.Interactivity.dll. Затем создайте класс, унаследованный от Behavior — обобщенного класса, который принимает аргумент типа. Можно использовать либо этот аргумент типа для ограничения области действия поведения определенными элементами, либо UIElement или FrameworkElement для охвата всех элементов, как показано ниже: public class DraglnCanvasBehavior : Behavior<UIElement> { ... }
320 Глава 11. Стили и поведения На заметку! В идеале необходимость в самостоятельном создании поведений возникать не будет. Вместо этого будут использоваться готовые поведения, созданные другими разработчиками. Первым шагом при создании любого поведения является переопределение методов OnAttachedO и OnDetaching(). В методе OnAttachedO можно получить доступ к элементу, в котором размещено поведение (через свойство AssociatedObject), и присоединить обработчики событий. В методе OnDetachingO можно удалить эти обработчики событий. Ниже показан код, в котором поведение DraglnCanvasBehavior используется для наблюдения за событиями MouseLef tButtonDown, MouseMove и MouseLef tButtonUp: protected override void OnAttachedO { base.OnAttached() ; // Присоединение обработчиков событий. this.AssociatedObject.MouseLeftButtonDown += AssociatedObject_MouseLeftButtonDown; this.AssociatedObject.MouseMove += AssociatedObject_MouseMove; this.AssociatedObject.MouseLeftButtonUp += AssociatedObject_MouseLeftButtonUp; } protected override void OnDetachingO { base.OnDetaching(); // Отсоединение обработчиков событий. this.AssociatedObject.MouseLeftButtonDown -= AssociatedObject_MouseLeftButtonDown; this.AssociatedObject.MouseMove -= AssociatedObject_MouseMove; this.AssociatedObject.MouseLeftButtonUp -= AssociatedObject_MouseLeftButtonUp; } Последним шагом является помещение нужного кода в обработчики событий. Например, когда пользователь нажимает левую кнопку мыши, поведение DraglnCanvasBehavior начинает операцию перетаскивания, сохраняет смещение между левым верхним углом элемента и курсором мыши и захватывает мышь: // Отслеживание места размещения элемента в элементе управления Canvas, private Canvas canvas; // Отслеживание перетаскивания элемента, private bool isDragging = false; // Запись точной позиции, в которой нажата кнопка, private Point mouseOffset; private void AssociatedObject_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // Нахождение элемента управления Canvas, if (canvas == null) canvas = (Canvas)VisualTreeHelper.GetParent(this.AssociatedObject); // Начало режима перетаскивания. isDragging = true; // Получение позиции нажатия относительно элемента // (таким образом, координаты левого верхнего угла // элемента выглядят как @,0)) . mouseOffset = е . GetPosition(AssociatedObject);
Глава 11. Стили и поведения 321 // Захват мыши. Благодаря этому событие MouseMove будет продолжать // поступать даже в случае резкого смещения пользователем курсора // мыши с поверхности элемента. AssociatedObject.CaptureMouse (); } Когда элемент находится в режиме перетаскивания, а мышь перемещается, позиция элемента должна изменяться: private void AssociatedOb]ect_MouseMove(object sender, MouseEventArgs e) { if (lsDragging) { // Получение позиции элемента относительно Canvas. Point point = e.GetPosition(canvas); // Перемещение элемента. AssociatedObject.SetValue(Canvas.TopProperty, point.Y - mouseOffset.Y); AssociatedObject.SetValue(Canvas.LeftProperty, point.X - mouseOffset.X); } } И, наконец, при отпускании кнопки мыши операция перетаскивания должна быть завершена: private void AssociatedObject_MouseLeftButtonUp (object sender, MouseButtonEventArgs e) { if (lsDragging) { AssociatedObject.ReleaseMouseCapture (); lsDragging = false; } } Использование поведения Для тестирования созданного поведения понадобится создать новый проект WPF- приложения, добавить в него ссылку на библиотеку классов, в которой определен класс DraglnCanvasBehavior (созданный в предыдущем разделе), и сборку System.Windows. Interactivity.dll. Затем нужно отобразить оба пространства имен в XML-разметке. Предполагая, что класс DraglnCanvasBehavior содержится в библиотеке классов CustomBehaviorsLibrary, необходимый код разметки выглядит следующим образом: <Window xmlns:i= "clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity" xmlns:custom= "clr-namespace:CustomBehaviorsLibrary;assembly=CustomBehaviorsLibrary" ... > Чтобы использовать это поведение, его понадобится добавить в любой элемент внутри Canvas с помощью присоединяемого свойства Interact ion. Behaviors. Приведенная ниже разметка создает контейнер Canvas с тремя фигурами. Два элемента Ellipse используют поведение DraglnCanvasBehavior и потому могут перетаскиваться внутри Canvas, а элемент Rectangle это поведение не использует и, соответственно, перемещаться в Canvas не может <Canvas> <Rectangle Canvas.Left=0" Canvas.Top=0" Fill="Yellow" Width=0" Height=0"> </Rectangle> <Ellipse Canvas.Left=0" Canvas.Top=0" Fill="Blue" Width="80" Height=0">
322 Глава 11. Стили и поведения <i:Interaction.Behaviors> <custom: DraglnCanvasBehaviorx/custom: DragInCanvasBehavior> </i:Interaction.Behaviors> </Ellipse> <Ellipse Canvas.Left=M80" Canvas.Top=0" Fill="OrangeRed" Width=0" Height=0"> <i:Interaction.Behaviors> <custom: DraglnCanvasBehaviorx/custom: DragInCanvasBehavior> </i:Interaction.Behaviors> </Ellipse> </Canvas> На рис. 11.4 показан этот пример в действии. ■ MainWindow ,».л, „I J Рис. 11.4. Оснащение элементов возможностью перетаскивания с помощью поведения Но это еще не все. Если вы разрабатываете приложения в Expression Blend, то поведения предоставят даже еще более удобные возможности на этапе проектирования, одной из которых является возможность вообще избавиться от написания разметки. Поддержка использования поведений во время проектирования в Expression Blend В Expression Blend работа с поведениями сводится к операциям перетаскивания и конфигурирования. Первым делом необходимо позаботиться, чтобы в приложении присутствовала ссылка на сборку, в которой содержатся используемые поведения. (В рассматриваемом случае это сборка библиотеки классов, в которой определен класс DraglnCanvasBehavior.) Кроме того, должна присутствовать ссылка на сборку System. Windows.Interactivity.dll. Среда Expression Blend автоматически ищет поведения во всех указанных сборках и отображает их в панели Asset Library (Библиотека ресурсов); эта же панель используется для выбора элементов при проектировании страницы Silverlight. Также Expression Blend добавляет поведения из сборки Expression.Interactions.dll производства Microsoft, даже если в проекте не было на нее явной ссылки. Чтобы просмотреть доступные поведения, нарисуйте кнопку на поверхности проектирования страницы, щелкните на кнопке Asset Library и перейдите на вкладку Behaviors (Поведения), как показано на рис. 11.5.
Глава 11. Стили и поведения 323 Рис. 11.5. Действия, предлагаемые в Asset Library Чтобы добавить действие к элементу управления, перетащите его из Asset Library на нужный элемент управления (в рассматриваемом примере — на одну из фигур в tanvas). После этого Expression Blend автоматически создаст поведение, которое впоследствии можно конфигурировать (при наличии у него свойств). Резюме В этой главе было показано, как применять стили для многократного использования настроек форматирования к элементам. Вы также узнали об использовании поведений для разработки пакетов функциональности пользовательского интерфейса, которые можно подключать к любому элементу. Оба инструмента позволяют создавать более интеллектуальные и удобные в плане сопровождения пользовательские интерфейсы, в которых детали форматирования и сложная логика размещены в центральном месте, а не много раз повторяются по всему приложению.
ГЛАВА 12 Фигуры, кисти и трансформации Во многих технологиях построения пользовательских интерфейсов делается четкое различие между обычными элементами управления и специальным рисованием. Часто средства рисования применяются только в специализированных приложениях, например, в играх, визуализаторах данных, эмуляторах физических процессов и т.п. WPF предлагает совершенно иную философию. Здесь предварительно построенные элементы управления и пользовательская графика обрабатываются одинаково. Поддержка рисования WPF будет применяться не только для создания развитых визуальных компонентов для пользовательского интерфейса, она также позволит овладеть большинством других средств WPF, таких как анимация (глава 15) и шаблоны элементов управления (глава 17). Фактически поддержка рисования WPF одинаково важна независимо от того, создается ли новая замечательная игра либо просто добавляется лоск к обычному бизнес-приложению. В этой главе вы ознакомитесь со средствами рисования двумерной графики WPF, начиная с базовых элементов рисования фигур. Затем будет показано, как рисовать рамки и интерьеры с помощью кистей. Вы научитесь вращать, смещать и иным образом манипулировать фигурами и элементами с применением трансформаций. Наконец, вы узнаете, как делать фигуры и другие элементы частично прозрачными. Что нового? Базовые средства двумерной графики в версии WPF 4 не изменились. Единственным дополнением стала новая кисть BitmapCacheBrush (описанная в этой главе), которая позволяет преобразовывать сложное графическое содержимое в растровое изображение, которое кэшируется видеокартой. Эта технология повышает производительность в определенных специализированных сценариях. Понятие фигур Простейший способ нарисовать двухмерное графическое содержимое в пользовательском интерфейсе WPF заключается в использовании фигур — выделенных классов, представляющих простые линии, эллипсы, прямоугольники и многоугольники. Эти фигуры известны как графические примитивы. Эти базовые ингредиенты можно комбинировать для создания более сложной графики. Наиболее важная деталь, касающаяся фигур в WPF, состоит в том, что все они наследуются от FrameworkElement. В результате этого фигуры являются элементами. С этим фактом связан ряд важных последствий, которые описаны ниже.
Глава 12. Фигуры, кисти и трансформации 325 • Фигуры рисуют сами себя. Управлять процессом рисования и объявления фигуры недействительной не понадобится. Например, не потребуется вручную перерисовывать фигуру, когда перемещается содержимое, изменяется размер окна или меняются свойства фигур. • Фигуры организованы таким лее образом как и другие элементы. Другими словами, фигуры можно помещать в любой из контейнеров компоновки, о которых шла речь в главе 3. (Хотя вполне очевидно, что наиболее полезным контейнером будет Canvas, поскольку он позволяет размещать фигуры по определенным координатам, что важно для построения сложных рисунков, состоящих из множества частей.) • Фигуры поддерживают те лее события, что и другие элементы. Это означает, что предпринимать какие-то специальные действия, связанные с обработкой фокуса, нажатий клавиш, перемещений и щелчков кнопками мыши, не потребуется. Можно использовать тот же самый набор событий, что и с любым элементом, и получать аналогичную поддержку всплывающих подсказок, контекстных меню и операций перетаскивания. Такая модель значительно отличается от моделей, применявшихся в других технологиях построения пользовательских интерфейсов, таких как Windows Forms. Эти среды выполняют большую часть работы с использованием традиционной оконной модели (через User32), которая становится совершенно неэффективной, если ее применять к фрагментам графического содержимого вроде индивидуальных линий и квадратов. Вдобавок оконная модель требует, чтобы каждый элемент "владел" маленькой частью экрана, что затрудняет реализацию прозрачности и использование сглаживания граней непрямоугольных форм. Из-за этих ограничений более старые платформы используют для специального рисования низкоуровневую модель GDI/GDI+. Это требует больших усилий и предлагает гораздо меньше высокоуровневых средств. Совет. Как будет показано в главе 14, возможность низкоуровневого программирования в WPF сохранена — за счет использования визуального уровня. Эта облегченная модель повышает производительность, когда требуется создать огромное количество элементов (скажем, тысячи фигур), и вам не нужны все средства классов UIElement и FrameworkElement (вроде привязки данных и обработки событий). Однако программирование на визуальном уровне работает и на более высоком уровне, чем GDI/GDI+. Важнее всего то, что WPF управляет процессом перерисовки автоматически. Вы просто поставляете содержимое. Классы фигур Каждая фигура наследуется от абстрактного класса System.Windows.Shapes.Shape. На рис. 12.1 показана иерархия наследования классов фигур. Как видите, существует относительно небольшой набор классов, унаследованных от класса Shape. Фигуры Line, Ellipse и Rectangle являются простейшими. Polygon состоит из последовательной замкнутой серии соединенных друг с другом отрезков прямых линий. И, наконец, Path — не имеющий себе равных класс "все в одном", который может комбинировать базовые фигуры в едином элементе. Хотя класс Shape сам по себе не может ничего делать, в нем определен небольшой набор важных свойств, перечисленных в табл. 12.1.
326 Глава 12. Фигуры, кисти и трансформации DispatcherObject i DependencyObject i Visual i Условные обозначения Абстрактный класс Конкретный класс UlElement i FrameworkElement i Shape ♦ Rectangle Ellipse Line Polyline Polygon Path Рис. 12.1. Классы фигур WPF Таблица 12.1. Свойства класса Shape Имя Описание Fill Stroke StrokeThickness Устанавливает объект кисти, рисующей поверхность фигуры (все, что расположено в ее границах) Устанавливает объект кисти, рисующей границу фигуры Устанавливает толщину границы в единицах, независимых от устройства. При рисовании линии WPF разбивает ширину на каждую сторону. Поэтому линия толщиной в 10 единиц получает по 5 единиц пространства с каждой стороны от того места, где проходила бы линия толщиной в одну единицу. Если толщина линии задана нечетным количеством единиц, то на каждую сторону приходится дробное число единиц. Например, линия толщиной 11 единиц имеет по 5,5 единиц пространства на каждую сторону. Это в значительной мере гарантирует, что линия равномерно распределится по пикселям монитора, даже если он работает с разрешением 96 dpi, так что получится слегка расплывчатый, сглаженный контур. Можете воспользоваться свойством SnapsToDevicePixels, чтобы убрать это, если оно вам не по душе (как описано в разделе "Привязка к пикселям" далее в главе)
Глава 12. Фигуры, кисти и трансформации 327 Окончание табл. 12.1 Имя Описание StrokeStartLineCap и StrokeEndLineCap StrokeDashArray, StrokeDashOffset и StrokeDashCap StrokeLineJoin и StrokeMiterLimit Stretch DefiningGeometry GeometryTransform RenderedGeometry Определяют контур краев в начале и конце линии. Эти свойства имеют эффект только для фигур Line, Polyline и (иногда) Path. Все прочие фигуры замкнуты, а потому начальной и конечной точки не имеют Позволяют создавать заштрихованную границу вокруг фигуры. Можно управлять размером и частотой штриховки, а также контуром, ограничивающим начало и конец каждой штриховой линии Определяют контур углов фигуры. Технически эти свойства затрагивают вершины, где стыкуются разные линии, такие как углы Rectangle. Эти свойства не имеют эффекта для фигур без углов, подобных Line и Ellipse Определяет способ заполнения фигурой доступного пространства. Это свойство можно использовать для создания фигуры, которая распространяется на весь содержащий ее контейнер. Можно также принудительно растянуть фигуру по одному измерению, используя значение Stretch для свойств HorizontalAlignment и VerticalAlignment (унаследованных от класса FrameworkElement) Предоставляет объект Geometry для фигуры. Объект Geometry описывает координаты и размер фигуры без учета таких вещей из UIElement, как поддержка событий клавиатуры и мыши. Геометрия рассматривается в главе 13 Позволяет применять объект Transform, который изменяет координатную систему, используемую для рисования фигуры. Это дает возможность искажать, вращать или перемещать фигуру. Трансформации, в частности, полезны для анимации. Более подробно трансформации рассматриваются далее в этой главе Предоставляет объект Geometry, описывающий финальную, визуализированную фигуру- Геометрия описана в главе 13 В следующих разделах будут рассматриваться фигуры Rectangle, Ellipse, Line и Polyline. Попутно вы ознакомитесь со следующими фундаментальными действиями: • как устанавливать размер фигур и организовывать их в контейнере компоновки; • как управлять тем, какие области сложной фигуры заполняются; • как использовать линии штриховки и разные концы линий (или "наконечники"); • как аккуратно выравнивать фигуры по границам пикселей. • Более сложный класс Path описан в главе 13. Rectangle и Ellipse Классы Rectangle и Ellipse представляют две простейшие фигуры. Чтобы создать каждую из них, установите знакомые свойства Height и Width (унаследованные от FrameworkElement) для определения размера фигуры, а затем свойство Fill или Stroke (или оба), чтобы сделать фигуру видимой. Можно также использовать такие свойства, как MinHeight, MinWidth, HorizontalAlignment, VerticalAlignment и Margin.
328 Глава 12. Фигуры, кисти и трансформации На заметку! Если не установить свойство Stroke или Fill, фигура вообще не появится на экране. Ниже приведен пример размещения эллипса над прямоугольником с использованием контейнера StackPanel (рис. 12.2). <StackPanel> <Ellipse Fill="Yellow" Stroke="Blue" Height=0" Width=00" Margin=" HorizontalAlignment="Left"></Ellipse> <Rectangle Fill="Yellow" Stroke="Blue" Height=0" Width=00" Margin=" HorizontalAlignment="Left"></Rectangle> </StackPanel> -apes о Рис. 12.2. Две простые фигуры Класс Ellipse не добавляет новых свойств. Класс Rectangle добавляет два свойства: RadiusX и RadiusY. Когда они установлены в ненулевые значения, создаются симпатичные скругленные углы. Свойства RadiusX и RadiusY можно трактовать как описание эллипса, используемого для размещения в углах прямоугольника. Например, если установить значения обоих свойств в 10, то WPF нарисует углы как части окружности шириной в 10 единиц. По мере увеличения радиуса все большая часть прямоугольника будет закруглена. При увеличении RadiusY в большей мере, чем RadiusX, углы будут больше закруглены с левой и правой сторон и меньше — с верхней и нижней. Если увеличить свойство RadiusX до размера ширины прямоугольника, a RadiusY — до размера его высоты, прямоугольник превратится в обычный эллипс. На рис. 12.3 показано несколько прямоугольников со скругленными углами. ■ RoundedRectangles Corner radius of 5. Corner radius of 10 Corner radius of 10 (X) and 25 (Y). Corner radius of 100 (X) and 60 Of). Рис. 12.3. Прямоугольники со скругленными углами Установка размеров и расположения фигур Как уже известно, жестко закодированные размеры — обычно не лучший подход для создания пользовательских интерфейсов. .Они ограничивают возможности обработки динамического содержимого и затрудняют локализацию приложения для других языков. При рисовании фигур эти соображения не всегда применимы. Часто требуется более тонкий контроль над расположением фигур. Однако есть много случаев,
Глава 12. Фигуры, кисти и трансформации 329 когда можно обеспечить более высокую гибкость дизайна. Как Ellipse, так и Rectangle умеют изменять свой размер так, чтобы заполнять доступное пространство. Если свойства Height и Width не применяются, то размер фигуры устанавливается на основе ее контейнера. В предыдущем примере удаление значений Height и Width (и пропуск значений MinHeight и MinWidth) приведут к тому, что фигуры уменьшатся до минимальных размеров, поскольку размер Stack Panel установлен так, чтобы заполнялось все содержимое. Однако если заставить StackPanel принять полную ширину окна (установив свойство HorizontalAlignment в Stretch), затем также установить свойство HorizontalAlignment эллипса в Stretch и удалить свойство Width эллипса, то эллипс заполнит всю ширину окна. Более наглядный пример можно получить с помощью контейнера Grid. Если используются пропорциональные размеры строк (по умолчанию так оно и есть), то можно создать эллипс, заполняющий окно, с помощью такого упрощенного кода разметки: <Gnd> <Ellipse Fill="Yellow" Stroke="Blue"></Ellipse> </Grid> Здесь Grid заполняет все окно. Контейнер Grid содержит единственную строку с пропорциональным размером, которая заполняет его целиком. И, наконец, эллипс заполняет всю строку. Поведение, касающееся выбора размера, зависит от значения свойства Stretch (которое определено в классе Shape). По умолчанию оно установлено в Fill, что растягивает фигуру так, чтобы она заполнила весь контейнер, если ее размер не указан явно. Все возможные значения перечисления Stretch описаны в табл. 12.2. Таблица 12.2. Значения перечисления Stretch Имя Описание Fill Фигура растягивается по ширине и высоте для полного заполнения контейнера. (В случае явной установки высоты и ширины эта настройка не имеет эффекта.) None Фигура не растягивается. Если не установить ненулевые значения для ширины и высоты (с помощью свойств Height и Width или MinHeight и MinWidth), то фигура вообще не появится Uniform Ширина и высота увеличиваются пропорционально, пока фигура не достигнет границ контейнера. В случае эллипса получится самая большая окружность, которая умещается в окно. Если же применять это с прямоугольником, то получится максимально возможный квадрат. (При явной установке высоты и ширины фигура будет находиться в указанных границах. Например, если установить Width в 10 и Height в 100 для прямоугольника, то получится только квадрат размером 10x10.) UniformToFill Ширина и высота устанавливаются пропорционально, пока фигура не заполнит всю доступную высоту и ширину. Например, если поместить прямоугольник с такой установкой размера в окно 100x200 единиц, то получится прямоугольник размером 200x200, и часть его будет усечена. (В случае явной установки высоты и ширины фигура будет вписана в указанные размеры. Например, если установить Width в 10 и Height в 100 для прямоугольника, то получится прямоугольник 100x100, усеченный так, чтобы уместиться в область 10x100.) На рис. 12.4 демонстрируется разница между Fill, Uniform и UniformToFill.
330 Глава 12. Фигуры, кисти и трансформации Обычно значение Stretch, равное Fill — это то же самое, что установка HonzontalAlignment и VerticalAlignment в Stretch. Разница проявляется при установке фиксированных значений для Width и Height фигуры. В этом случае значения HorizontalAlignment и VerticalAlignment просто игнорируются. Однако установка Stretch все же дает эффект — она определяет то, как содержимое фигуры размещается в пределах заданных границ. Совет. В большинстве случаев размер фигуры устанавливается явно или ей позволяется растягиваться для заполнения отведенного пространства. Вместе оба подхода комбинироваться не будут. До сих пор было показано, как задавать размеры Rectangle и Ellipse, но как насчет точного их размещения? Фигуры WPF используют ту же систему компоновки, что и любой другой элемент. Однако некоторые контейнеры компоновки для этого не подходят. Например, StackPanel, DockPanel и WrapPanel часто оказываются вовсе не тем, что нужно, поскольку они предназначены для разделения элементов. Контейнер Grid несколько более гибкий, потому что позволяет разместить произвольное количество элементов в одной и той же ячейке (хотя и не дает возможности разместить квадраты и эллипсы в разных частях одной ячейки). Идеальным является контейнер Canvas, который требует явного указания координат каждой фигуры через присоединенные свойства Left, Top, Right и Bottom. Он предоставляет полный контроль над перекрытием фигур. <Canvas> <Ellipse Fill="Yellow" Stroke="Blue" Canvas.Left=00" Canvas.Top=0" Width=00" Height= 0"></Ellipse> <Rectangle Fill="Yellow" Stroke="Blue" Canvas.Left=0" Canvas.Top=0" Width=00" Height= 0"x/Rectangle> </Canvas> При использовании Canvas важен порядок следования дескрипторов. В предыдущем примере прямоугольник накладывается на эллипс, потому что эллипс идет в списке первым, а потому и рисуется первым (рис. 12.5). Масштабирование фигур в Viewbox Единственное ограничение при использовании Canvas заключается в том, что графика не сможет самостоятельно подгонять свои размеры к большему или меньшему окну. Это имеет смысл для кнопок (которые не меняют своего размера в любых ситуациях), но не обязательно для графического содержимого других типов. ' ■ shapes laeyJ^ lead] Рис. 12.5. Перекрытие фигур Рис. 12.4. Заполнение трех ячеек Grid в Canvas
Глава 12. Фигуры, кисти и трансформации 331 Например, можно создать сложную графику, которая должна изменять свои размеры, чтобы получить преимущество от доступного пространства. Для подобных ситуаций в WPF предусмотрено простое решение. Чтобы сочетать тонкий контроль Canvas с простым изменением размеров, следует использовать элемент Viewbox. Viewbox — это простой класс, унаследованный от Decorator (который очень похож на класс Border, рассматриваемый в главе 3). Он принимает единственный дочерний элемент, который растягивает или сжимает для заполнения доступного пространства. Естественно, этим дочерним элементом может быть контейнер компоновки, удерживающий множество фигур (или других элементов), которые могут синхронно изменять свои размеры. Однако чаще всего Viewbox применяют для векторной графики, а не для обычных элементов управления. Хотя в элемент Viewbox можно поместить одиночную фигуру, это не дает никаких реальных преимуществ. Напротив, Viewbox проявляет себя во всей красе, когда необходимо обернуть группу фигур, образующих общий рисунок. Обычно внутрь Viewbox помещается контейнер Canvas, а уже в него — нужные фигуры. В следующем примере во вторую строку Grid помещается Viewbox с контейнером Canvas. Элемент Viewbox занимает полную высоту и ширину строки. Строка занимает все свободное пространство, оставшееся после визуализации первой строки с автоматическим размером. Ниже приведена разметка. <Grid Margin="> <Grid.RowDefinitions> <RowDefmition Height="Auto"x/RowDefinition> <RowDefinition Height="*"></RowDefinition> </Grid.RowDefinitions> <TextBlock>The first row of a Grid.</TextBlock> <Viewbox Grid.Row="l" HorizontalAlignment="Left" > <Canvas Width=00" Height=50"> <Ellipse Fill="Yellow" Stroke="Blue" Canvas.Left=0" Canvas.Top=0" Width=00" Height=0" HorizontalAlignment="Left"></Ellipse> <Rectangle Fill="Yellow" Stroke="Blue" Canvas.Left=0" Canvas.Top=0" Width=00" Height = 0" HorizontalAlignment="Left"x/Rectangle> </Canvas> </Viewbox> </Grid> На рис. 12.6 показано, как Viewbox подстраивается при изменении размеров окна. Первая строка остается неизменной. Однако вторая строка расширяется для заполнения всего свободного пространства. Как видите, фигура в Viewbox изменяется пропорционально по мере увеличения окна. На заметку! Масштабирование, осуществляемое Viewbox, подобно масштабированию, которое вы видели в WPF, увеличивая значение DPI. Оно пропорционально изменяет каждый экранный элемент, включая изображения, текст, линии и фигуры. Например, если поместить внутрь Viewbox обычную кнопку, масштабирование затронет ее общий размер, текст внутри нее и толщину линии границы. В случае помещения внутрь Viewbox фигуры пропорционально изменяется внутренняя область и рамка, так что чем больше растет фигура, тем толще становится ее граница. По умолчанию Viewbox выполняет пропорциональное масштабирование, которое сохраняет пропорции его содержимого. В текущем примере это значит, что даже если фигура содержащей строки изменится (став шире или длиннее), фигуры внутри не будут искажены.
332 Глава 12. Фигуры, кисти и трансф| юрмации • V.ewteI&BS9 The first row of a grid. Рис. 12.6. Изменение размеров и элемент Viewbox Вместо этого Viewbox использует наибольший масштабный множитель, который позволяет уместиться внутри доступного пространства. Однако это поведение можно изменить с помощью свойства Viewbox.Stretch. По умолчанию оно установлено в Uniform, но допускается любое значение из перечисленных в табл. 12.2. Измените Viewbox.Stretch на Fill, и содержимое Viewbox будет растягиваться в обоих направлениях, чтобы занять полностью все доступное пространство, даже если при этом исходный рисунок окажется искаженным. С помощью свойства StretchDirection можно достичь несколько большего контроля. По умолчанию это свойство получает значение Both, но можно установить его в Up Only, чтобы создать содержимое, которое сможет расти, но не более своего исходного размера, и DownOnly, чтобы создать содержимое, которое может сжиматься, но не расти. Для того чтобы Viewbox продемонстрировал свою "магию" масштабирования, он должен получить две единицы информации: обычный размер, который должно иметь содержимое (как если бы оно не находилось внутри Viewbox), и новый размер, который должен быть для него установлен. Вторая деталь — новый размер — достаточно проста. Viewbox отводит внутреннему содержимому все доступное пространство, основываясь на значении своего свойства Stretch. Это значит, что чем больше Viewbox, тем больше содержимое. Первая деталь — обычный размер (без вмешательства Viewbox) — вычисляется по способу определения вложенного содержимого. В предыдущем примере Canvas задается явный размер 200x150 единиц. Таким образом, Viewbox масштабирует изображение от этого начального размера. Например, если эллипс изначально имеет ширину 100 единиц, т.е. заполняет половину выделенного Canvas пространства, то по мере роста Canvas элемент Viewbox сохраняет эти пропорции и эллипс продолжает занимать половину отведенного пространства. Однако посмотрим, что случится, если удалить свойства Width и Height из Canvas. Теперь Canvas задан размер 0x0 единиц, так что Viewbox не может изменить размер, и вложенное содержимое не появляется. (Совсем иначе обстоят дела при работе только с Canvas без Viewbox. Несмотря на то что Canvas также имеет размеры 0x0, содержащимся в нем фигурам разрешено рисовать себя за его пределами, до тех пор, пока свойство Canvas.ClipToBounds не установлено в true. Элемент Viewbox не настолько терпим к ошибке подобного рода.) | V.ewboxResize The first row of a grid. .. . .
Глава 12. Фигуры, кисти и трансформации 333 Теперь посмотрим, что произойдет, если поместить Canvas внутрь пропорционально изменяющей размер ячейки Grid и не указать размеры Canvas. Без использования Viewbox такой подход отлично работает — контейнер Canvas сжимается, чтобы уместиться в ячейке, и его содержимое остается видимым. Но если поместить все это содержимое в Viewbox, такая стратегия не работает. Элемент Viewbox не может определить начальный размер, а потому не может соответственно масштабировать Grid. Эту проблему можно обойти, поместив определенные фигуры (вроде Rectangle и Ellipse) непосредственно в контейнер, автоматически изменяющий размер (такой как Grid). Viewbox может затем вычислить минимальный размер, необходимый Grid для помещения своего содержимого, и затем масштабирует его соответствующим образом. Однако простейший способ получить нужный размер Viewbox — это поместить содержимое в элемент фиксированного размера, будь то Canvas, кнопка или что-то еще. Этот фиксированный размер становится начальным размером, который элемент Viewbox использует для своих вычислений. Такое жесткое кодирование размера не ограничит гибкости компоновки, поскольку Viewbox изменяет размер пропорционально, на основе доступного пространства своего контейнера компоновки. Line Фигура Line представляет отрезок прямой, соединяющий между собой две точки. Начальная и конечная точки задаются свойствами XI и Y1 (для первой точки) и Х2 и Y2 (для второй точки). Например, ниже показан код линии, простирающейся от @,0) до A0,100): <Line Stroke="Blue" X1=" Y1=" X2=0" Y2=00"></Line> Свойство Fill не имеет никакого эффекта для линии. Понадобится устанавливать свойство Stroke. Координаты, используемые для линии, отсчитываются относительно верхнего левого угла контейнера, в котором она содержится. Например, если поместить предыдущую линию в StackPanel, то координаты @,0) будут указывать на то место, где будет находиться элемент в контейнере StackPanel. Это может быть верхний левый угол окна, но, возможно, и нет. Если StackPanel использует ненулевое значение Margin, или же линии предшествуют другие элементы, то начало точки @,0) окажется на некотором расстоянии от вершины окна. Однако для линии вполне допустимо использовать отрицательные координаты. Фактически, можно указывать координаты, которые унесут линию за пределы выделенного пространства, и она будет нарисована поверх любой другой части окна. Это невозможно для фигур Rectangle и Ellipse, которые рассматривались до сих пор. Тем не менее, у такой модели есть недостаток, а именно: линия не может использовать модель "плавающего содержимого". Это значит, что для линии невозможно установить такие свойства, как Margin, HorizontalAlignment и VerticalAlignment — они не будут иметь никакого эффекта. То же ограничение касается фигур Polyline и Polygon. На заметку! Для линии можно использовать свойства Height, Width и Stretch, хотя это не особо принято. Базовая технология предусматривает применение Height и Width для определения пространства, выделенного под линию, с последующим использованием свойства Stretch для изменения размера линии, чтобы она уместилась в эту область. В случае помещения Line в контейнер Canvas присоединенные свойства позиции (такие как Тор и Left) по-прежнему применимы. Они определяют стартовую позицию линии. Другими словами, две координаты линии задают величину смещения. Рассмотрим следующую линию:
334 Глава 12. Фигуры, кисти и трансформации <Line Stroke="Blue" X1=" Y1=" X2=0" Y2=00" Canvas . Left=" Canvas . Top=00"x/Line> Линия простирается от @,0) до A0,100), используя систему координат, которая трактует точку E,100) в Canvas как @,0). Это делает ее эквивалентной линии, в которой свойства Тор и Left не используются: <Line Stroke="Blue" Xl=" Yl=00" X2=5" Y2=00"></Line> Применение свойства позиционирования при помещении Line в Canvas остается на ваше усмотрение. Часто за счет выбора подходящей начальной точки удается упростить рисование линии. Кроме того, это может облегчить перемещение частей рисунка. Например, рисовать несколько линий и других фигур в определенной позиции Canvas неплохо относительно некоторой близлежащей точки (указывая одинаковые координаты Тор и Left). Таким образом, при необходимости можно сместить в новую точку сразу часть рисунка. На заметку! Не существует способа создать кривую линию из фигур Line или Polyline. Вместо них понадобится более совершенный класс Path, который рассматривается в главе 13. Polyline Класс Polyline позволяет рисовать последовательность связанных отрезков прямых. В этом случае просто поставляется список координат X и Y с использованием свойства Points. Формально свойство Points требует объекта PointCollection, но эта коллекция заполняется в XAML-разметке с помощью лаконичного строкового синтаксиса. Нужно просто задать список точек, описанных координатами с разделителями (пробелом или запятой) между ними. Элемент Polyline может быть описан всего двумя точками. Например, ниже приведен пример фигуры Polyline, дублирующей показанную выше линию, которая простирается от E,100) до A5,200): <Polyline Stroke="Blue" Points= 100 15 200"></Polyine> Для улучшения читабельности помещайте запятую в каждую пару координат X и Y: <Polyline Stroke="Blue" Points=,100 15,200"></Polyline> А вот более сложный объект Polyline, начинающийся в точке A0,150). Точки монотонно распределяются слева направо, колеблясь между наибольшим значением E0, 160) и наименьшим G0, 130): <Canvas> <Polyline Stroke="Blue" StrokeThickness=" Points=0,150 30,140 50, 160 70,130 90,170 110,120 130,180 150,110 170,190 190,100 210,240" > </Polyline> </Canvas> Полученная в результате линия показана на рис. 12.7. Здесь может показаться, что было бы проще наполнить коллекцию Points программно с использованием некоторого цикла, автоматически увеличивающего значения X и Y. Это верно, если требуется создавать динамическую графику — например, диаграмму, изменяющую свою форму в зависимости от информации, извлеченной из базы данных. Но если просто нужно построить фиксированный фрагмент графического содержимого, вообще незачем беспокоиться об определенных координатах фигур. Вместо этого для рисования соответствующей графики применяется инструмент, подобный Expression Design, который позволяет экспортировать результирующую графику в XAML-разметку.
■ PolyfinesAndPolygons Глава 12. Фигуры, кисти и трансформации 335 1 ншШМ Рис. 12.7. Линия, состоящая из нескольких сегментов Рис. 12.8. Закрашенный многоугольник Polygon Класс Polygon — почти то же самое, что и Polyline. Как и Polyline, класс Polygon определяет коллекцию Points, принимающую список координат Единственное отличие в том, что Polygon добавляет финальный сегмент, соединяющий начальную и конечную точки. (Если финальная точка уже совпадает с начальной, то Polygon ничем от Polyline не отличается.) Внутреннюю часть полученной фигуры можно заполнить с использованием кисти Fill. На рис. 12.8 показан предыдущая фигура Polyline в виде Polygon, заполненной желтым цветом. На заметку! Формально можно также устанавливать свойство Fill для объекта Polyline. В этой ситуации Polyline закрашивает себя так, как если бы это был Polygon. Другими словами, как если бы он имел невидимый сегмент, соединяющий конечную точку с начальной. Этот эффект находит относительно ограниченное применение. В простой фигуре, где линии никогда не пересекаются, заполнить внутреннюю область легко. Однако иногда приходится иметь дело с более сложным Polygon, где не совсем очевидно, какие части находятся "внутри" фигуры (и должны быть закрашены), а какие — снаружи. Например, взглянем на рис. 12.9, на котором представлена линия, пересекающая более одной другой линии, оставляя в центре неправильную область, которую можно заполнить, а можно и не заполнять. Очевидно, для определения того, что именно нужно закрашивать, можно разбить этот рисунок на несколько меньших фигур. Но делать это вовсе не обязательно. Каждый элемент Polygon и Polyline имеет свойство Fill Rule, которое позволяет выбирать между двумя разными подходами в заполнении областей. По умолчанию FillRule установлено в EvenOdd. Для того чтобы решить, нужно ли заполнять область, WPF подсчитывает количество линий, которые требуется пересечь во время достижения внешней стороны фигуры. Если это число нечетно, область заполняется, если же четно — нет. Чтобы попасть в центральную область фигуры на рис. 12.9, понадобится пересечь две линии, поэтому область не закрашивается. В WPF также поддерживается правило заполнения Nonzero, которое немного сложнее. Следуя правилу Nonzero, WPF выполняет такой же подсчет линий, как и в EventOdd, но при этом принимает во внимание направление каждой из линий. Если количество линий, направленных в одну сторону (скажем, слева направо) равно количеству линий, направленных в противоположную сторону (справа налево), область не
336 Глава 12. Фигуры, кисти и трансформации Пересекаются две линии (четное число). Область не заполняется Пересекается одна линия (нечетное число). Область заполняется. Рис. 12.9. Определение областей заполнения при свойстве FillRule, установленном в EvenOdd Пересекаются две линии слева направо. Разница счетчиков не равна нулю. Область заполняется Рис. 12.10. Определение областей заполнения при свойстве FillRule, установленном в NonZero заполняется. Если разница между этими двумя количествами не равна нулю, область заполняется. Если установить свойство FillRule в Nonzero в фигуре из предыдущего примера, внутренняя область заполнится. Взглянув на рис. 12.10, вы поймете почему. (В данном примере точки пронумерованы в порядке их рисования, а стрелки показывают направление, в котором рисуется каждая из линий.) На заметку! Если количество линий нечетное, то разница между счетчиками не может быть равна нулю. Поэтому правило Nonzero всегда приводит к заполнению, как минимум, такого же числа областей, что и правило EvenOdd, плюс, возможно, еще несколько. Особенность правила Nonzero состоит в том, что его настройки заполнения зависят от того, каким образом рисуется фигура, а не от того, как она выглядит. Например, ту же самую фигуру можно нарисовать так, что ее центр не будет заполнен (хотя это и труднее — можно начать рисовать внутреннюю область, а затем нарисовать внешние лучи в обратном направлении). Вот как выглядит код разметки, который рисует звезду, показанную на рис. 12.10: <Polygon Stroke=,,Blue" StrokeThickness=,,l" Fill=,,Yellow" Canvas .Left=011 Canvas . Top=7511 FillRule="Nonzero11 Points=5,200 68,70 110,200 0,125 135,125"> </Polygon> Наконечники и стыки линий При рисовании фигур Line и Polyline можно указать форму начальной и конечной точек линии, используя свойства StartLineCapHEndLineCap. (Эти свойства не имеют эффекта в других фигурах, поскольку те замкнуты.) Изначально свойства StartLineCap и EndLineCap установлены в Flat, что означает немедленное завершение линии в ее конечных координатах. К другим возможным вариантам относятся Round (линия мягко скругляется), Triangle (обе стороны линии сводятся в точку) и Square (линия завершается четкой границей). Все эти значения добавляют линии длину — другими словами, они выводят ее за ту позицию, в которой
Глава 12. Фигуры, кисти и трансформации 337 она закончилась бы в противном случае. Дополнительное расстояние составляет половину толщины линии. На заметку! Единственное отличие между Flat и Square состоит в том, что линии, завершающиеся как Square, просто на половину толщины длиннее с каждой стороны. Во всех остальных отношениях они выглядят одинаково. На рис. 12.11 показаны различные варианты концов линий. Все фигуры, кроме Line, позволяют изменять вид и форму углов через свойство StrokeLineJoin. Здесь существует три варианта: Miter (значение по умолчанию) использует четкие грани, Bevel обрезает угол в точке сопряжения, a Round — плавно скругляет его. На рис. 12.12 можно видеть разницу между ними. При использовании граней Miter при толстых линиях и очень малых углах может получиться, что угол превышает половину толщины линии. Если вы установите толщину 3, то выступающий угол может в полтора раза превышать толщину линии. В последней линии на рис. 12.12 используется большое значение заострения с узким углом. Пунктирные линии Вместо рисования скучных сплошных линий на границах вашей фигуры можно использовать пунктирные (dashed) линии — т.е. линии, прерываемые пробелами в соответствии с указанным шаблоном. При создании пунктирной линии в WPF вы не ограничены определенным набором жестко заданных вариантов. Вместо этого можно выбирать длину сплошного сегмента и длину прерванного сегмента (пробела), устанавливая свойство StrokeDashArray. Например, рассмотрим следующую линию: <Polyline Stroke="Blue11 StrokeThickness=411 StrokeDashArray="l 2" Points=0,30 60,0 90,40 120,10 350,10"> </Polyline> ■ LineCaps Flat Line Cap Square Line Cap Round Line Cap Triangle Line Cap -ens V Bevel Line Join Round Line Join Miter Line Join Miter Line Join With Limit of 3 _ Рис. 12.11. Концы линий Рис. 12.12. Стыки линий
338 Глава 12. Фигуры, кисти и трансформации Здесь задана длина сплошного сегмента — 1 и ширина пробела — 2. Эти значения интерпретируются относительно толщины линии. Поэтому если линия имеет толщину 14 единиц (как в данном примере), то сплошная часть будет иметь длину 14 единиц, а ширина пропуска — 28. Линия повторяет этот шаблон на протяжении всей своей длины. С другой стороны, если поменять местами эти значения, задав их следующим образом: StrokeDashArray= 1" то получится линия с длиной сплошного участка в 28 единиц и шириной пропуска в 13 единиц. На рис. 12.13 показаны обе линии. Несложно заметить, что когда сегмент очень толстой линии приходится на угол, он может быть обрезан неровно. * DashedLines ♦ ♦♦ ^\ 4ШШ : шшшшшшшшшшш .— нянннвннн 1 шштт Dash Pattern 'l 2' Dash Pattern '2 1" Dash Pattern  0.2 3 0.2" Uneven Dash Pattern  0.5 2" Dash Pattern with Rounded Caps Рис. 12.13. Пунктирные линии Можно также использовать и нецелочисленные значения. Например, следующее значение StrokeDashArray вполне допустимо: StrokeDashArray= 0.2 3 0.2" Здесь применяется более сложная последовательность — штрих длиной 5x14, затем пробел шириной 0,2x15, за которым идет штрих длиной 3x14 и еще один — длиной 0,2x14. В конце этой последовательности линия повторяет тот же шаблон сначала. Интересная вещь происходит, когда StrokeDashArray передается нечетное количество значений. Рассмотрим пример: StrokeDashArray= 0.5 2" При рисовании этой линии WPF начинает с линии длиной в 3 толщины, за которой следует пробел в половину толщины линии, за которым идет штрих длиной в 2 толщины. Но когда шаблон повторяется, он начинается с пробела в 3 толщины, за ним идет линия длиной в половину толщины и опять пробел. По сути, пунктирная линия чередует шаблоны между сегментами линии и пробелами. Если нужно, чтобы шаблон пунктирной линии начинался с его середины, можно применить свойство StrokeDashOf f set, которое представляет собой начинающийся с О индекс, указывающий на одно из значений из StrokeDashArray. Например, если уста-
Глава 12. Фигуры, кисти и трансформации 339 новить StrokeDashOf f set равным 1 в предыдущем примере, то линия начнется с пробела в половину толщины. Установите 2 — и линия начнется с сегмента в 2 толщины. И, наконец, можно управлять внешним видом наконечников сегментов линии. Изначально они рисуются как прямой срез, но можно установить значение StrokeDashCap в Bevel, Square и Triangle — смысл этих значений рассматривался в предыдущем разделе. Не забывайте, что все эти настройки добавляют половину толщины линии в конец каждого штриха. Если не принять этого во внимание, можно получить штрихи, которые "наползают" друг на друга. Решение состоит в добавлении ширины пробела, чтобы компенсировать этот эффект Совет. При использовании свойства StrokeDashCap с линией (но не с фигурой) часто имеет смысл установить StartLineCap и EndLineCap в одно и то же значение. Это сделает внешний вид линии более согласованным. Привязка к пикселям Как известно, в WPF используется независимая от устройств система рисования. Размеры таких вещей, как шрифты и фигуры, указываются с применением "виртуальных" пикселей, которые совпадают с нормальными пикселями на обычных дисплеях с 96 dpi, но масштабируются для дисплеев с более высоким разрешением. Другими словами, если рисуется прямоугольник шириной в 50 пикселей, то в действительности он может отображаться большим или меньшим количеством пикселей — в зависимости от устройства. Такое преобразование между независимыми от устройства единицами и физическими пикселями происходит автоматически, и обычно думать о нем не приходится. Отношение между пикселями систем с разными показателями dpi редко выражается целым числом. Например, 50 пикселей на мониторе с 96 dpi превращаются в 62,4996 пикселей на мониторе с 120 dpi. (В этом нет никакой ошибки — фактически, WPF всегда позволяет использовать дробные значения двойной точности при указании значений в независимых от устройства единицах.) Очевидно, что нет способа поместить границу фигуры или линии в точку, находящуюся между пикселями. WPF компенсирует это посредством сглаживания. Например, при отображении красной линии длиной в 62,4992 пикселя WPF заполняет первые 62 пикселя нормально, а затем текстурирует 63-й пиксель цветом, значение которого находится между цветом линии (красным) и цветом фона. Однако здесь кроется ловушка. При рисовании прямых линий, прямоугольников или многоугольников с прямыми углами такое автоматическое сглаживание может привести к некоторой нечеткости границ фигуры. Можно было бы предположить, что данная проблема появляется только при запуске приложения на дисплее, имеющем разрешение, не равное 96 dpi. Однако это не обязательно так, поскольку размеры и положение фигур могут задаваться дробными числами и дробными координатами, что даст тот же эффект. И хотя дробные длины и координаты редко используются при рисовании фигур, но фигуры с изменяемым размером — т.е. такие, которые растягиваются вместе с размерами контейнера или которые помещаются в Viewbox — почти всегда рано или поздно принимают дробные размеры. Аналогично, линии нечетной толщины имеют дробное число пикселей на каждой стороне. Эффект размытой границы не всегда является проблемой. Фактически, если такое поведение не устраивает, можно заставить WPF не применять сглаживание для определенной фигуры. Вместо этого WPF будет округлять все измерения до ближайшего ряда пикселей устройства. Это средство, называемое привязкой к пикселям, задается установкой в true свойства SnapsToDevicePixels класса UIElement.
340 Глава 12. Фигуры, кисти и трансформации Чтобы увидеть разницу, взгляните на увеличенное окно на рис. 12.14, в котором сравниваются два прямоугольника. В нижнем прямоугольнике используется привязка к пикселям, а в верхнем — нет. Если внимательно присмотреться, то можно заметить тонкую линию более светлого цвета вдоль верхней и левой граней непривязанного прямоугольника. Непривязанный Привязанный Рис. 12.14. Эффект привязки к пикселям Кисти Кисти заполняют области — будь то фон, передний план или граница элемента, или штрих фигуры. Простейшим типом кисти является SolidColorBrush, которая рисует сплошным цветом. Когда в XAML-разметке устанавливается свойство Stroke или Fill фигуры, в действие вступает кисть SolidColorBrush. Ниже перечислено несколько фундаментальных фактов, связанных с кистями. • Кисти поддерживают уведомления об изменениях, поскольку они унаследованы от Freezable. В результате при изменении кисти любой элемент, использующий ее, автоматически перерисовывает себя. • Кисти поддерживают частичную прозрачность. Все, что понадобится сделать — это модифицировать свойство Opacity, чтобы проявился фон. Этот прием будет демонстрироваться в конце главы. • Класс SystemBrushes предоставляет доступ к кистям, которые используют цвета, определенные в системных настройках Windows для текущего компьютера. Хотя кисть SolidColorBrush, безусловно, удобна, существует несколько других классов кистей, унаследованных от System.Windows.Media.Brush, которые предлагают ряд экзотических эффектов. Все они перечислены в табл. 12.3. Таблица 12.3. Классы кистей Имя Описание SolidColorBrush LinearGradientBrush RadialGradientBrush Рисует область с использованием одного сплошного цвета Рисует область с использованием линейного градиентного заполнения, представляющего собой плавный переход от одного цвета к другому (и дополнительно к следующему, потом еще к одному и т.д.) Рисует область с использованием радиального градиентного заполнения, подобного линейному, но с тем отличием, что переход формируется в радиальном направлении от центральной точки
Глава 12. Фигуры, кисти и трансформации 341 Окончание табл. 12.3 Имя Описание ImageBrush Рисует область с использованием графического изображения, которое может растягиваться, масштабироваться или многократно повторяться DrawingBrush Рисует область с использованием объект Drawing. Этот объект может включать определенные вами фигуры и растровые изображения VisualBrush Рисует область с использованием объекта Visual. Поскольку все элементы WPF наследуются от класса Visual, эту кисть можно применять для копирования части пользовательского интерфейса (такого как поверхность кнопки) в другую область. Удобно для создания забавных эффектов вроде частичного отражения BitmapCacheBrush Рисует область с использованием кэшированного содержимого из объекта Visual. Это делает его похожим на VisualBrush, но этот класс более эффективен, если графическое содержимое должно повторно использоваться во множестве мест либо часто перерисовываться Класс DrawingBrush будет описан в главе 13, когда рассматриваются более оптимальные пути обращения с большим объемом графики. В этом разделе вы узнаете, как использовать кисти для заполнения областей градиентами, графическими изображениями и визуальным содержимым, скопированным из других элементов. На заметку! Все классы кистей находятся в пространстве имен System.Windows.Media. SolidColorBrush Объекты SolidColorBrush уже демонстрировались в работе с элементами управления в главе 6. В большинстве элементов управления установка свойства Foreground задает цвет текста, а свойства Background — цвет пространства вокруг него. Фигуры используют сходные, но другие свойства: Stroke — для рисования контура фигуры и Fill — для рисования ее внутренности. Как было показано ранее в этой главе, свойства Stroke и Fill можно устанавливать в XAML-разметке, используя имена цветов — в этом случае анализатор WPF автоматически создает соответствующий объект SolidColorBrush. Установить Stroke и Fill можно также в коде, но в этом случае объект SolidColorBrush придется создавать явно: // Создать кисть из именованного цвета cmd.Background = new SolidColorBrush(Colors.AliceBlue); // Создать кисть из системного цвета cmd.Background = SystemColors.ControlBrush; // Создать кисть на основе значений цвета int red = 0; int green = 255; int blue = 0; cmd.Foreground = new SolidColorBrush(Color.FromRgb(red, green, blue)); LinearGradientBrush Кисть LinearGradientBrush позволяет создавать смешанное заполнение, которое представляет собой переход от одного цвета к другому.
342 Глава 12. Фигуры, кисти и трансформации .. т D»agonal Linear Gradient With 0.5 Offset for White Horizontal Linear Gradient Reflected Gradient Multicolored Gradient Рассмотрим пример простейшего градиента. Он заштриховывает прямоугольник по диагонали от синего цвета (в левом верхнем углу) к белому (в правом нижнем углу): <Rectangle Width=50" Height=00"> <Rectangle.Fill> <LinearGradientBrush> <GradientStop Color="Blue" Offset=" /> <GradientStop Color="White" Offset="l" /> </LinearGradientBrush> </Rectangle.Fill> </Rectangle> Рис. 12.15. Прямоугольники с разными линейными градиентами Результат отражает верхний градиент на рис. 12.15. Для создания этого градиента необходимо добавить по одному объекту GradientStop для каждого цвета. Также нужно поместить каждый цвет в градиент, используя значение Offset от 0 до 1. В этом примере GradientStop для синего цвета имеет смещение 0, а это означает, что он располагается в самом начале градиента. GradientStop для белого цвета имеет смещение 1, что размещает его в конце. Изменяя эти значения, можно управлять плавностью перехода одного цвета в другой. Например, если установить GradientStop для белого цвета в 0.5, то градиент будет переходить от синего (в левом верхнем углу) к белому цвету в середине прямоугольника (точка между двумя углами). Правая сторона прямоугольника будет полностью белой (второй градиент на рис. 12.15). Предыдущий код разметки создает градиент с диагональным наполнением, который простирается от одного утла до другого. Однако может понадобиться создать градиент, который распространяется сверху вниз или слева направо, либо же воспользоваться другим диагональным утлом. Эти детали управляются свойствами Start Point и EndPoint класса LinearGradientBrush. Упомянутые свойства позволяют выбирать точку, в которой начинается первый цвет, и изменять точку, в которой переход цвета завершается чистым вторым цветом. (Промежуточная область заполняется плавным переходом одного цвета в другой.) Однако здесь имеется одна особенность. Координаты, используемые для указания начальной и конечной точек, не являются реальными координатами. Вместо этого LinearGradientBrush назначает точке в левом верхнем углу области, которую нужно заполнить, координату @,0), а точке в правом нижнем углу — координату A,1), независимо от реальной высоты и ширины области. Чтобы создать горизонтальное заполнение сверху вниз, можно указать начальную точку @,0) левого верхнего утла, а конечную точку — @,1), представляющую левый нижний угол. Чтобы создать вертикальное заполнение слева направо (без наклона), можно использовать стартовую точку @,0) и конечную точку A,0). На рис. 12.15 горизонтальный градиент — третий сверху. Есть возможность схитрить, указав начальную и конечную точки так, чтобы они не были выровнены по углам градиента. Например, можно протянуть градиент от @,0) до @, 0.5) — точки на середине левой стороны, на полпути вниз. Это создаст сжатый линейный градиент — один цвет начнется вверху, переходя во второй цвет на середине прямоугольника. Нижняя половина фигуры будет заполнена вторым цветом. Однако это
Глава 12. Фигуры, кисти и трансформации 343 поведение можно изменить с помощью свойства LinearGradientBrush.SpreadMethod. По умолчанию оно имеет значение Pad (означающее, что области вне градиента заполняются соответствующим сплошным цветом), но допускается присвоить этому свойству значение Reflect (для обращения градиента — переходом от второго цвета обратно к первому) или же Repeat (для дублирования той же цветовой последовательности). На рис. 12.15 эффект Reflect показан в четвертом сверху градиенте. LinearGradientBrush также позволяет создавать градиенты с более чем двумя цветами, добавляя более двух объектов GradientStop. Например, рассмотрим пример градиента, отображающего все цвета радуги: <Rectangle Width=50" Height=00"> <Rectangle.Fill> <LinearGradientBrush StartPoint=,0" EndPoint="l,l"> <GradientStop Color="Yellow" Offset=.0" /> <GradientStop Color="Red" Offset=.25" /> <GradientStop Color="Blue" Offset=.75" /> <GradientStop Color="LimeGreen" Offset=.0" /> </LinearGradientBrush> </Rectangle.Fill> </Rectangle> Единственная сложность связана с установкой соответствующего смещения для каждого GradientStop. Например, чтобы выполнить переход через пять цветов, можно назначить первому цвету смещение 0, второму — 0.25, третьему — 0.5, четвертому — 0.75 и пятому — 1. Если же нужно, чтобы цвета быстрее переходили друг в друга в начале и более плавно в конце, необходимо установить смещения 0, 0.1, 0.2, 0.4, 0.6 и 1. Следует помнить, что кисти не ограничены рисованием фигур. Кисть LinearGradientBrush может быть указана в любом месте, где используется SolidColorBrush — например, для заполнения фоновой поверхности элемента (через свойство Background), фонового цвета текста (используя свойство Foreground) или для заполнения границы (с помощью свойства BorderBrush). На рис. 12.16 показан пример заполненного градиентом элемента TextBlock. [ 17 GradientText M3EH1 This text jses a I jradient. V ..,, ,, ... ,., , . / Рис. 12.16. Использование LinearGradientBrush для установки свойства TextBlock.Foreground
344 Глава 12. Фигуры, кисти и трансформации RadialGradientBrush Кисть RadialGradientBrush работает подобно LinearGradientBrush. Она также принимает последовательность цветов с разными смещениями. Как и в случае с LinearGradientBrush, можно указывать произвольное количество цветов. Отличие состоит в том, как размещается градиент Для идентификации точки, с которой начинается первый цвет градиента, служит свойство GradientOrigin. По умолчанию оно хранит координату @.5, 0.5), представляющую середину заполняемой области. На заметку! Как и LinearGradientBrush, кисть RadialGradientBrush использует пропорциональную систему координат, в которой левый верхний угол области заполнения имеет координату @,0), а нижний правый угол — A,1). Это означает, что для размещения центральной точки градиента можно указать любую координату от @,0) до A,1). Фактически можно даже выйти за эти пределы и указать стартовую точку вне заполняемой области. Градиент распространяется из начальной точки в радиальном направлении. В конечном итоге он достигает границы внутреннего крута градиента, где завершается. Центр этого крута может совпадать или не совпадать с центром градиента, в зависимости от требуемого эффекта. Область за пределами границы внутреннего крута градиента заполняется последним цветом, определенным в коллекции RadialGradientBrush. Gradient Stops. На рис. 12.17 показано заполнение радиального градиента. Конец круга градиента Область заполнения градиента Область заполнения сплошным цветом Рамка вокруг области заполнения градиента Рис. 12.17. Заполнение радиального градиента Граница внутреннего крута градиента задается тремя свойствами: Center, RadiusX и RadiusY. По умолчанию свойство Center равно @.5, 0.5), что помещает центр ограничивающего крута в центр заполняемой области, в точку, совпадающую с началом градиента. Свойства RadiusX и RadiusY определяют размер ограничивающего крута и по умолчанию оба равны 0.5. Эти значения могут быть несколько непонятными — они измеряются относительно диагонали области заполнения (длины воображаемой линии, соединяющей левый верхний угол области заполнения с правым нижним). Это значит, что значение 0.5 описывает крут, радиус которого составляет половину диагонали. Если область заполнения имеет квадратную форму, то, применив теорему Пифагора, мож-
Глава 12. Фигуры, кисти и трансформации 345 но определить, что это составит примерно около 0.7 от ширины (или высоты) области. Таким образом, в случае заполнения квадратной области с установками по умолчанию градиент начинается в центре и простирается по радиусу примерно на 0,7 от стороны квадрата. На заметку! Если вы представите максимально возможный эллипс, который вписывается в область, то это и будет область, где градиент завершается вторым цветом. Заполнение радиальным градиентом — хороший выбор для окраски округлых фигур и создания световых эффектов. (Опытные дизайнеры графики используют комбинацию градиентов для создания кнопок с эффектом свечения.) Распространенный трюк заключается в небольшом смещении точки GradientOrigin для создания иллюзии глубины фигуры. Ниже приведен пример. <Ellipse Margin=" Stroke="Black" StrokeThickness="l" Width=00" Height=00"> <Ellipse.Fill> <RadialGradientBrush RadiusX="l" RadiusY="l" GradientOrigin=.7, 0 . 3"> <GradientStop Color="White" Offset=" /> <GradientStop Color="Blue" Offset="l" /> </RadialGradientBrush> </Ellipse.Fill> </Ellipse> На рис. 12.18 показан этот градиент рядом с обычным радиальным градиентом, имеющим стандартное значение GradientOrigin, равное @.5, 0.5). A Radial Gradient A Radial Gradient with an Offset Center Рис. 12.18. Радиальные градиенты ImageBrush Кисть ImageBrush позволяет заполнить область растровым изображением. Возможна работа с наиболее распространенными типами файлов, включая BMP, PNG, GIF и JPEG. Используемое изображение идентифицируется свойством ImageSource. Например, ниже показана кисть, которая рисует фон для Grid с применением изображения по имени logo.jpg, включенного в сборку в виде ресурса:
346 Глава 12. Фигуры, кисти и трансформации <Grid> <Grid.Background> <ImageBrush ImageSource="logo. jpg"></ImageBrush> </Grid.Васkground> </Grid> Свойство ImageBrush.ImageSource работает таким же образом, как свойство Source элемента Image, а это означает, что его можно также устанавливать с помощью URI, указывающего на ресурс, внешний файл или местоположение в Интернете. Кроме того, можно создать кисть ImageBrush, использующую определенное в XAML-разметке векторное содержимое, за счет указания объекта Drawinglmage в свойстве ImageSource. Этот подход позволяет сократить накладные расходы (избегая наиболее дорогостоящих классов-наследников Shape) и он также применяется, когда с помощью векторного изображения создается повторяющийся шаблон. Более подробно класс Drawinglmage рассматривается в главе 13. На заметку! Среда WPF учитывает любую информацию о прозрачности, которую находит в графическом изображении. Например, WPF поддерживает прозрачные области в файле GIF, а также прозрачные и полупрозрачные области в файле PNG. В этом примере кисть ImageBrush применяется для рисования фона ячейки. В результате изображение растягивается для того, чтобы заполнить область. Если размеры Grid больше размеров исходного изображения, в нем могут появиться артефакты измененного размера (вроде общей размытости). Если форма Grid не соответствует пропорциям изображения, последнее будет деформировано, чтобы заполнить отведенную область. Данным поведением можно управлять с помощью свойства ImageBrush.Stretch, присваивая ему одно из значений, перечисленных в табл. 12.2. Например, значение Uniform позволяет масштабировать графическое изображение для заполнения контейнера с сохранением его пропорций, a None — рисовать изображение с натуральным размером (в этом случае часть изображения может быть усечена). На заметку! Даже если Stretch установлено в None, изображение все равно может масштабироваться. Например, если установить системную настройку DPI в 120 dpi (известную как "крупный шрифт"), то WPF пропорционально увеличит растровое изображение. Это может привести к некоторой нечеткости, но все же такое решение лучше, чем изменение размеров изображения (и подгонка всего пользовательского интерфейса) на мониторах с другой настройкой DPI. Если изображение меньше заполняемой области, то оно выравнивается согласно значениям свойств AlignmentX и AlignmentY. Незаполненная область остается прозрачной. Это происходит в случае применения масштабирования Uniform к заполняемой области, которая имеет другую форму (в этом случае будут получены белые полосы сверху, снизу и по сторонам). Это также имеет место, когда используется None, а область заполнения больше изображения. С помощью свойства Viewbox можно вырезать небольшую часть изображения, которая интересует. Для этого понадобится указать четыре числа, описывающие прямоугольник, который нужно вырезать из исходного изображения. Первые два идентифицируют верхний левый угол вырезаемого прямоугольника, а оставшиеся два — его высоту и ширину. Единственная ловушка заключается в том, что Viewbox использует относительную координатную систему — подобно градиентным кистям. Эта система назначает левому верхнему углу изображения координаты @,0), а нижнему правому — (Ы). Чтобы понять, как работает Viewbox, взгляните на следующую разметку: <ImageBrush ImageSource="logo.jpg" Stretch="Uniform" Viewbox=.4,0.5 0.2,0.2"></ImageBrush>
Глава 12. Фигуры, кисти и трансформации 347 Здесь Viewbox начинается в точке @.4, 0.5), которая находится почти в середине изображения. (Формально координата X равна произведению 0.4 на ширину, a Y — произведению 0.5 на высоту.) Затем прямоугольник простирается в ширину на 20% оставшейся ширины и высоты изображения (прямоугольник имеет размеры 0.2, умноженное на ширину, и 0.2, умноженное на высоту изображения). Вырезанная часть затем растягивается или центрируется, в зависимости от свойств Stretch, AlignmentX и AlignmentY. На рис. 12.19 показаны два прямоугольника, использующие разные объекты ImageBrush для заполнения себя. Верхний прямоугольник показывает полное изображение, а нижний использует Viewbox для увеличения маленького фрагмента. Оба окружены сплошной черной рамкой. На заметку! Свойство Viewbox иногда полезно при повторном использовании частей одного и того же изображения для создания некоторых эффектов. Однако если заранее известно, что понадобится только часть изображения, очевидно, имеет смысл вырезать его в каком-нибудь графическом редакторе. ■ ImageBrushes I ' "I ft) ИЫИ Google i^J Canada О Рис. 12.19. Разные способы использования ImageBrush Мозаичная кисть ImageBrush Обычная кисть ImageBrush — это не все, что может понадобиться. Некоторые интересные эффекты можно получить, повторяя графическое изображение по поверхности кисти. При повторении изображения доступны два выбора. • Повторение фрагментов пропорционального размера. Область заполняется всегда одним и тем же количеством повторяющихся фрагментов. Такая мозаика растягивается и сжимается для заполнения области. • Повторение фрагментов фиксированного размера. Мозаика все время имеет один и тот же размер. Размер заполняемой области определяет количество необходимых мозаичных фрагментов. На рис. 12.20 сравнивается поведение двух вариантов кисти при изменении размеров заполняемого прямоугольника.
348 Глава 12. Фигуры, кисти и трансформации Рис. 12.20. Различные способы мозаичного заполнения прямоугольника Чтобы многократно повторить изображение в виде мозаики, понадобится установить свойство ImageSource (для идентификации исходного изображения, которое должно быть повторено), а также свойства Viewport, ViewportUnits и TileMode. Последние три свойства определяют размер мозаики и способ ее размещения. Для установки размера каждого фрагмента мозаики применяется свойство Viewport. Чтобы использовать фрагменты пропорционального размера, значение ViewportUnits должно быть установлено в RelativeToBoundingBox (по умолчанию так и есть). Затем размер фрагмента определяется в пропорциональной координатной системе от 0 до 1 по каждому измерению. Другими словами, мозаика, имеющая координаты левого верхнего угла @,0) и правого нижнего A,1), покрывает всю область заполнения. Для получения мозаичной структуры необходимо определить Viewport меньшего размера, чем общий размер площади заполнения, как показано ниже: <ImageBrush ImageSource="tile.jpg" TileMode="Tile" Viewport=,0 0.5,0.5"></ImageBrush> Это создает прямоугольник Viewbox, который начинается в левом верхнем углу области заполнения @,0) и простирается до середины @.5, 0.5). В результате область заполнения всегда будет покрыта четырьмя фрагментами мозаики, независимо от его размера. Такое поведение хорошо тем, что исключает опасность того, что часть мозаики придется "ломать" на границе фигуры. (Разумеется, это не касается случая, когда используется ImageBrush для заполнения нерегулярной области.) Поскольку размер фрагмента мозаики в данном примере задан относительно размера области заполнения, то большая область использует большие фрагменты, в результате чего может появиться некоторая нечеткость при их значительном увеличении. Кроме того, если заполняется область, не имеющая форму квадрата, то относительная координатная система будет соответственно искажена, так что каждая квадратная мозаика станет прямоугольной. Это поведение демонстрируется во втором фрагменте на рис. 12.20.
Глава 12. Фигуры, кисти и трансформации 349 Данное поведение можно изменить, модифицировав свойство Stretch (которое по умолчанию имеет значение Fill). Значение None гарантирует, что фрагменты мозаики не будут деформированы и сохранят свои первоначальные пропорции. Однако если заполняемая область не имеет форму квадрата, между фрагментами появятся пробелы. Этот вариант показан в третьей части рис. 12.20. Третий вариант заключается в использовании значения UniformToFill для свойства Stretch, при котором мозаика будет "обламываться" по краю, если это необходимо. Таким образом, повторяющиеся фрагменты изображения будут сохранять свои пропорции и между ними не останется никаких зазоров. Однако если заполняемая область не имеет формы квадрата, полное изображение мозаики увидеть не удастся. Автоматическое изменение размера мозаики — забавное средство, но за него приходится платить. Размер некоторых растровых изображений невозможно правильно изменить. В некоторой степени можно подготовиться к такой ситуации, применив растровое изображение, которое превышает по размерам нужное, однако такая техника может привести к нечеткости изображения при его уменьшении. Альтернативное решение состоит в определении размера мозаики в абсолютных координатах, на основе размера исходного изображения. Чтобы предпринять такой шаг, ViewportUnits устанавливается в Absolute (вместо RelativeToBoundBox). Ниже показан пример определения размеров элемента мозаики в 32x32 с началом в левом верхнем углу: <ImageBrush ImageSource="tile.jpg" TileMode="Tile" ViewportUnits="Absolute" Viewport=,0 32, 32"></ImageBrush> Такой тип мозаичной структуры показан в первом прямоугольнике на рис. 12.20. Его недостаток в том, что высота и ширина области заполнения должны делиться на 32. В противном случае получатся неполные фрагменты по краям. В случае применения ImageBrush для заполнения элемента изменяемого размера решить эту проблему невозможно и придется смириться с тем, что края фрагментов мозаики не всегда совпадут с краями заполняемой области. Все рассмотренные до сих пор мозаичные структуры использовали значение Tile свойства TileMode. Это можно изменить, задав альтернативное расположение фрагментов. В табл. 12.4 перечислены возможные варианты. Таблица 12.4. Значения перечисления TileMode Имя Описание Tile Копирует изображение по всей доступной области FlipX Копирует изображение, переворачивая каждый второй столбец вокруг вертикальной оси FlipY Копирует изображение, переворачивая каждый второй столбец вокруг горизонтальной оси FlipXY Копирует изображение, переворачивая каждый второй столбец вокруг вертикальной и горизонтальной осей Такое поведение часто оказывается полезным, если требуется сделать более незаметными стыки между соседними фрагментами мозаики. Например, если используется FilpX, соседние (по горизонтали) фрагменты всегда будут стыковаться бесшовным образом. На рис. 12.21 сравниваются разные опции организации структуры, которые можно применять.
350 Глава 12. Фигуры, кисти и трансформации Рис. 12.21. Различные варианты расположения фрагментов мозаики VisualBrush VisualBrush — необычная разновидность кисти, позволяющая брать визуальное содержимое элемента и использовать его для заполнения любой поверхности. Например, с помощью VisualBrush можно скопировать внешний вид кнопки из окна в любую другую область того же окна. Однако копия кнопки не будет реагировать на щелчки или проявлять какую-либо другую интерактивность. Это просто копия внешнего вида элемента. Например, следующий фрагмент разметки определяет кнопку и кисть VisualBrush, дублирующую эту кнопку: <Button Name="cmd" Margin=" Padding=">Is this a real button?</Button> <Rectangle Margin=" Height=00"> <Rectangle.Fill> <VisualBrush Visual="{Binding ElementName=cmd}"></VisualBrush> </Rectangle.Fill> </Rectangle> Хотя можно самостоятельно определить элемент, который должна использовать кисть VisualBrush, намного чаще применяется выражение привязки для ссылки на элемент в текущем окне, как это сделано в приведенном примере. На рис. 12.22 показана исходная кнопка (в верхней части окна) и несколько областей разной формы, которые закрашены посредством кисти VisualBrush, основанной на внешнем виде этой кнопки. VisualBrush отслеживает изменения во внешнем виде элемента. Например, если был скопирован внешний вид кнопки, а затем эта кнопка получит фокус, то VisualBrush перерисует область заполнения, отобразив сфокусированную кнопку. Кисть VisualBrush унаследована OTTileBrush, поэтому она также поддерживает средства сжатия, растяжения и переворачивания, о которых говорилось в предыдущем разделе. Скомбинировав эти детали с рассматриваемыми далее трансформациями, можно легко применять VisualBrush для получения содержимого элемента и манипулировать им до неузнаваемости. Поскольку содержимое VisualBrush не интерактивно, может возникнуть вопрос: для чего вообще нужна такая кисть? На самом деле VisualBrush полезна во многих ситуациях, когда требуется создать статическое содержимое, дублирующее "реальное", расположенное где-то в другом месте.
Глава 12. Фигуры, кисти и трансформации 351 Is this a real button? It'liiHfti Например, можно взять элемент, который со- aiBrosh держит в себе значительное количество вложенных элементов (даже целое окно), сжать его до меньших размеров и использовать в качестве активного предварительного представления. Некоторые программы, работающие с документами, используют это для показа форматирования. Браузер Internet Explorer применяет это средство для предварительного просмотра документов в представлении Быстрые вкладки (открывающегося по нажатию <Ctrl+Q>), а ОС Windows — для эскизного представления различных приложений в панели задач. Кисть VisualBrush допускается использовать в комбинации с анимацией для создания определенных эффектов (таких как миниатюрное изображение документа под главным окном приложения). VisualBrush также является основой одного из распространенных эффектов WPF, которым Рис" 12'22 Копирование визуального -: представления кнопки часто злоупотребляют — активного отражения, которое будет продемонстрировано в следующем разделе (и еще худшего — отражения видеосодержимого, которое будет показано в главе 26). BitmapCacheBrush Кисть BitmapCacheBrush во многих отношениях напоминает VisualBrush. В то время как VisualBrush предоставляет свойство Visual, ссылающееся на другой элемент, BitmapCacheBrush включает в себя свойство Target, которое служит той же самой цели. Ключевое отличие состоит в том, что BitmapCacheBrush берет визуальное содержимое (после того, как оно было изменено трансформациями, отсечением, эффектами и настройками прозрачности) и просит видеокарту сохранить его в видеопамяти. Таким образом, при необходимости содержимое может быть быстро перерисовано, не требуя дополнительной работы от WPF. Чтобы сконфигурировать кэширование растровых изображений, необходимо установить свойство BitmapCacheBrush.BitmapCache (используя объект BitmapCache). Вот его простейшее применение: <Button Name="cmd" Margin=" Padding=">Is this a real button?</Button> <Rectangle Margin=" Height=00"> <Rectangle.Fill> <BitmapCacheBrush Target="{Binding ElementName=cmd}" Bi tmapCache="Bi tmapCache"X/Bi tmapCacheBrush> </Rectangle.Fill> </Rectangle> С кистью BitmapCacheBrush связан существенный недостаток: начальный шаг по визуализации растрового изображения и копирования его в видеопамять требует небольшого, но заметного дополнительного времени. При использовании BitmapCacheBrush в окне, возможно, будет заметна задержка перед тем, как окно нарисует себя первый раз — в это время BitmapCacheBrush визуализирует и копирует свое растровое изображение. По данной причине BitmapCacheBrush не слишком помогает в традиционных окнах. Однако на кэширование растровых изображений стоит обратить внимание в случае интенсивного применения анимации в рамках пользовательского интерфейса. Дело в том, что анимация может заставить окно перерисовываться много раз в секунду.
352 Глава 12. Фигуры, кисти и трансформации Может оказаться, что сложное векторное содержимое быстрее нарисовать из кэширо- ванного растрового изображения, чем перерисовывать с нуля. Однако и в этой ситуации не стоит торопиться с использованием BitmapCacheBrush. Намного более вероятно, что кэширование будет применяться за счет установки высокоуровневого свойства UIElement.CacheMode каждого элемента, который требуется кэшировать (этот прием описан в главе 15). В этом случае WPF использует BitmapCahebrush "за кулисами", достигая того же эффекта, но с гораздо меньшими усилиями. С учетом изложенных деталей может показаться, что кисть BitmapCaheBrush не особенно полезна сама по себе. Однако она пригодится, когда есть один фрагмент сложного визуального содержимого, которое нужно рисовать во многих местах. В таком случае можно сэкономить память, кэшируя этот фрагмент однажды в объекте BitmapCaheBrush, вместо того, чтобы кэшировать его отдельно в каждом элементе. Опять-таки, возможно, что сэкономить не удастся, если только в пользовательском интерфейсе не используется также анимация. Более подробно о кэшировании растровых изображений и о том, когда его стоит применять, читайте в разделе "Кэширование растровых изображений" главы 15. Трансформации Огромный объем задач, связанных с рисованием, может быть упрощен благодаря применению трансформации (transform) — объекта, изменяющего способ рисования фигуры или элемента посредством скрытого сдвига используемой им координатной системы. В WPF трансформации представлены классами, унаследованными от абстрактного класса System.Windows.Media.Transform, которые перечислены в табл. 12.5. Таблица 12.5. Классы трансформаций Имя Описание Важные свойства TranslateTransform RotateTransform ScaleTransform SkewTransform MatrixTransform Смещает координатную систему на определенную величину. Эта трансформация удобна, когда нужно нарисовать ту же самую фигуру в разных местах Поворачивает координатную систему. Нормально нарисованные фигуры поворачиваются вокруг заданной точки Масштабирует координатную систему в большую и меньшую сторону, так что фигуры становятся больше или меньше. По измерениям X и Y можно применять разную степень масштабирования, в результате растягивая или сжимая фигуру Деформирует координатную систему, наклоняя ее на определенное число градусов. Например, нарисованный квадрат после трансформации превращается в параллелограмм Модифицирует координатную систему, используя матричное умножение с указанной матрицей. Это наиболее сложная трансформация — она требует определенной математической подготовки X, Y Angle, CenterX, CenterY ScaleX, ScaleY, CenterX, CenterY AngleX, AngleY, CenterX, CenterX Matrix
Глава 12. Фигуры, кисти и трансформации 353 Окончание табл. 12.5 Имя Описание Важные свойства TransformGroup Комбинирует несколько трансформаций — таким образом, что они могут применяться одновременно. Порядок применения трансформаций важен — он влияет на конечный результат. Например, вращение фигуры (с помощью RotateTransform) с последующим перемещением ее (посредством TranslateTransform) переместит фигуру в другое место, отличное от того, которое было бы в случае сначала перемещения фигуры, а потом ее поворота Формально все трансформации используют математические операции над матрицами для изменения координат фигуры. Однако применение предварительно построенных трансформаций — таких как TranslateTransform, RotateTransform, ScaleTransform и SkewTransform — проще, нежели использование MatrixTransform и попытки найти правильную матрицу для выполнения требуемой операции. В случае выполнения серии трансформаций с помощью TransformGroup среда WPF объединяет их вместе в единую трансформацию MatrixTransform, обеспечивая оптимальную производительность. На заметку! Все трансформации наследуются от Freezable (через класс Transform). Это значит, что они имеют автоматическую поддержку уведомлений об изменениях. В случае изменения трансформации, используемой фигурой, фигура немедленно перерисовывает себя. Трансформации — одна из тех причудливых концепций, которые оказываются чрезвычайно полезными в широком разнообразии различных контекстов. Ниже приведено несколько примеров. • Наклон фигуры. До сих пор вы были привязаны к горизонтально выровненным прямоугольникам, эллипсам, линиям и многоугольникам. Используя RotateTransform, можно повернуть координатную систему для более удобного создания определенных фигур. • Повторение фигуры. Многие рисунки строятся на использовании похожих фигур в нескольких разных местах. Применяя трансформацию, можно взять фигуру и переместить ее, повернуть, изменить ее размер и т.д. Совет. Для того чтобы использовать одну и ту же фигуру во многих местах, нужно дублировать ее в коде разметки (что далеко не идеально), использовать код (чтобы создавать фигуры программно) или применять фигуру Path, описанную в главе 13. Фигура Path принимает объекты Geometry, а такой объект геометрии можно хранить в виде ресурса, так что его можно многократно использовать в коде разметки. • Анимация. С помощью динамических трансформаций, таких как поворот фигуры, перемещение ее из одного места в другое и искажение, можно создавать множество сложных эффектов. Трансформации будут рассматриваться далее в книге, в частности, когда речь пойдет об анимации (глава 16) и манипулировании трехмерными объектами (глава 27). А пока все, что нужно — узнать, как применяется базовая трансформация к обычной фигуре.
354 Глава 12. Фигуры, кисти и трансформации Трансформация фигур Чтобы трансформировать фигуру, свойству RenderTransform присваивается объект трансформации, который требуется применить. В зависимости от используемого объекта трансформации нужно заполнить определенные свойства, чтобы сконфигурировать его, как описано в табл. 12.5. Например, для поворачивания фигуры необходимо использовать RotateTransform и указать угол в градусах. Ниже приведен пример поворота квадрата на 25 градусов. <Rectangle Width="80" Height=0" Stroke="Blue" Fill="Yellow" Canvas.Left=00" Canvas.Top=00"> <Rectangle.RenderTransform> <RotateTransform Angle=5" /> </Rectangle.RenderTransform> </Rectangle> Трансформация фигуры подобным образом осуществляется поворотом относительно ее начальной точки (левого верхнего угла). На рис. 12.23 показан поворот одного и того же квадрата на 25, 50, 75 и затем 100 градусов. Иногда требуется повернуть фигуру вокруг другой точки. RotateTransform, как и многие другие классы трансформаций, предоставляет свойства CenterX и CenterY. Их можно использовать для указания центральной точки, вокруг которой должен быть выполнен поворот. Ниже показано, как повернуть прямоугольник на 25 градусов вокруг его центральной точки: <Rectangle Width="80" Height=0" Stroke="Blue" Fill="Yellow" Canvas.Left=00" Canvas.Top=00"> <Rectangle.RenderTransform> <RotateTransform Angle=5" CenterX=5" CenterY=" /> </Rectangle.RenderTransform> </Rectangle> На рис. 12.24 показан результат применения той же серии поворотов, что и на рис. 12.23, но вокруг выделенной центральной точки. Существуют четкие ограничения на использование свойств CenterX и CenterY класса RotateTransform. Упомянутые свойства определяются в абсолютных координатах, а это означает, что необходимо точно знать, где находится центральная точка содержимого. В случае отображения динамического содержимого (например, изображения разных размеров или элементы, размеры которых могут изменяться) это пред- Рис. 12.23. Четырехкратный поворот прямоугольника Рис. 12.24. Поворот прямоугольника вокруг его центра
Глава 12. Фигуры, кисти и трансформации 355 ставляет проблему. К счастью, WPF предлагает решение в виде удобного свойства RenderTransformOrigin, поддерживаемого всеми фигурами. Это свойство устанавливает центральную точку, используя пропорциональную координатную систему, которая простирается от 0 до 1 по обоим измерениям. Другими словами, точка @,0) представляет левый верхний угол, а A,1) — правый нижний. (Если область фигуры не квадратная, координатная система соответствующим образом растягивается.) С помощью свойства RenderTransformOrigin поворачивать фигуру вокруг ее центральной точки можно с помощью примерно такой разметки: <Rectangle Width="80" Height=0" Stroke="Blue" Fill="Yellow" Canvas.Left=00" Canvas.Top=00" RenderTransformOrigin=.5,0.5"> <Rectangle.RenderTransform> <RotateTransform Angle=5" /> </Rectangle.RenderTransform> </Rectangle> Это работает потому, что точка @.5, 0.5) означает центр фигуры независимо от ее размера. На практике RenderTransformOrigin обычно более удобное свойство, чем CenterX и CenterY, хотя, в зависимости от существующих потребностей, можно использовать любой вариант (либо оба сразу). Совет. При установке свойства RenderTransformOrigin можно использовать значения больше 1 или меньше 0 для указания точки, расположенной за пределами фигуры. Например, эту технику можно применять с трансформацией RotateTransform для поворота фигуры по большой дуге вокруг очень удаленной точки, такой как E,5). Трансформация элементов Свойства RenderTransform и RenderTransformOrigin не ограничиваются фигурами. Фактически, класс Shape наследует их от класса UIElement, а это означает, что они поддерживаются всеми элементами WPF, включая кнопки, текстовые поля, TextBlock, контейнеры компоновки, заполненные содержимым, и т.д. Невероятно, но можно поворачивать, искажать и масштабировать любую часть пользовательского интерфейса WPF (хотя в большинстве случаев этого делать и не стоит). RenderTransform — не единственное свойство, касающееся трансформации, определенное в базовых классах WPF. В FrameworkElement также определено свойство LayoutTransform. Это свойство изменяет элемент тем. же самым образом, но выполняет свою работу перед проходом компоновки. В результате требуется немного больше накладных расходов, но это оправдано, когда контейнер компоновки используется для обеспечения автоматического размещения группы элементов управления. (Классы фигур также включают свойство LayoutTransform, но с ним редко приходится работать, поскольку обычно фигуры размещаются специальным образом с использованием контейнера вроде Canvas вместо применения автоматической компоновки.) Чтобы понять разницу, взгляните на рис. 12.25, на котором изображено два контейнера StackPanel (представленных заштрихованными областями), которые содержат повернутую Рис. 12.25. Повороты кнопок • RotateElement '. п по: » ; I'm not | V хУ
356 Глава 12. Фигуры, кисти и трансформации кнопку и нормальную кнопку. Повернутая кнопка в первом контейнере StackPanel использует подход RenderTransform. Панель StackPanel располагает две кнопки так, что первая из них позиционирована нормально, а поворот второй происходит непосредственно перед ее отображением. В результате повернутая кнопка перекрывает ту, что находится под ней. Во второй панели StackPanel повернутая кнопка применяет подход LayoutTransform. Контейнер StackPanel получает границы, необходимые для размещения повернутой кнопки, и располагает ее соответствующим образом. Существует несколько редких элементов, которые не могут быть трансформированы, потому что работа по их отображению не является встроенной в WPF. Примерами могут служить элемент WindowsFormsHost, который позволяет поместить элемент управления Windows Forms в окно WPF (этот трюк будет продемонстрирован в главе 30), а также элемент WebBrowser, который дает возможность отобразить HTML-содержимое. До определенной степени элементы WPF остаются в неведении о том, что они были модифицированы, когда устанавливаются свойства LayoutTransform и RenderTransform. В частности, трансформации не затрагивают свойства ActualHeight и ActualWidth элемента, которые продолжают хранить его ^трансформированные размеры. Это часть того, как WPF обеспечивает продолжение работы потоковой компоновки и отступов с тем же поведением, даже если применяется одна или более трансформаций. Прозрачность В отличие от многих старых технологий построения пользовательских интерфейсов (например, Windows Forms), WPF поддерживает настоящую прозрачность. Это значит, что если поместить несколько фигур (или других элементов) друг поверх друга и указать для них различные уровни прозрачности, то в результате будет получено именно то, чего следует ожидать. В простейшем виде это средство дает возможность создавать графический фон, который "просматривается" сквозь элементы, помещенные сверху. В более сложной форме это средство позволяет создавать многослойную анимацию и получать другие эффекты, которые было бы чрезвычайно трудно реализовать на других платформах. Как сделать элемент частично прозрачным Существует несколько способов сделать элемент полупрозрачным. • Установка свойства Opacity элемента. Каждый элемент, включая фигуры, наследует свойство Opacity (прозрачность) от базового класса UIElement. Прозрачность — это дробное значение от 0 до 1, где 1 означает полную непрозрачность (и принято по умолчанию), а 0 — полную прозрачность. Например, значение прозрачности 0,9 создает эффект 90% видимости A0% прозрачности). Установленная подобным образом прозрачность влияет на визуальное содержимое всего элемента. • Установка свойства Opacity кисти. Каждая кисть также наследует свойство Opacity от базового класса Brush. Устанавливая значение этого свойства от 0 до 1, можно управлять прозрачностью содержимого, которое рисует кисть, будь то сплошной цвет, градиент либо некоторого рода текстура или изображение. Поскольку для свойств Stroke и Fill фигуры используются разные кисти, можно задавать разную степень прозрачности для ее границы и поверхности. • Использование цвета, имеющего непрозрачное значение альфа-канала. Любой цвет, который имеет значение альфа-канала менее 255, является полупрозрачным.
Глава 12. Фигуры, кисти и трансформации 357 Например, можно установить полупрозрачный цвет для кисти SolidColorBrush и применять ее для рисования фона или переднего плана элемента. В некоторых ситуациях использование полупрозрачных цветов работает лучше, чем установка свойства Opacity. На рис. 12.26 показан пример с несколькими полупрозрачными слоями. • Окно имеет непрозрачный белый фон. • Панель StackPanel верхнего уровня, содержащая все элементы, имеет кисть ImageBrush с изображением. Свойство Opacity этой кисти уменьшено для осветления изображения, чтобы белый фон окна просматривался сквозь него. • Первая кнопка использует полупрозрачный красный цвет фона. (Для отображения этого цвета WPF "за кулисами" создает кисть Soli dColorB rush.) Изображение просматривается сквозь фон кнопки, но ее текст непрозрачен. • Метка (под первой кнопкой) используется в том виде как есть. По умолчанию все метки имеют полностью прозрачный фон. • Текстовое поле использует сплошной цвет текста и сплошной цвет контура, но полупрозрачный цвет фона. • Еще одна панель StackPanel под текстовым полем использует кисть TileBrush для создания шаблона из повторяющихся смайликов. TileBrush имеет уменьшенное значение Opacity, так что просматривается фон. Например, можно видеть изображение солнца в нижнем правом углу формы. • Во второй панели StackPanel находится элемент TextBlock с полностью прозрачным фоном (по умолчанию) и полупрозрачным белым текстом. Присмотревшись внимательно, можно заметить, что оба фона просматриваются сквозь некоторые буквы. Ниже показана XAML-разметка для этого окна. Рис. 12.26. Окно с несколькими полупрозрачными слоями
358 Глава 12. Фигуры, кисти и трансформации <StackPanel Margin="> <StackPanel.Background> <ImageBrush ImageSource="celestial.jpg" Opacity=.7" /> </StackPanel.Background> <Button Foreground="White" FontSize=6" Margin=0" BorderBrush="White" Background="#60AA4030" Padding=0">A Semi-Transparent Button</Button> <Label Margin=0" FontSize=8" FontWeight="Bold" Foreground="White"> Some Label Text</Label> <TextBox Margin=0" Backgrounds"#AAAAAAAA" Foreground-"White" BorderBrush="White">A semi-transparent text box</TextBox> <Button Margin=0" Padding=5" BorderBrush="White"> <Button.Background> <ImageBrush ImageSource="happyface.]pg" Opacity=.6" TileMode="Tile" Viewport=,0,0.1,0.3"/> </Button.Background> <StackPanel> <TextBlock Foregrounds"#75FFFFFF" TextAlignment="Center" FontSize=0" FontWeight="Bold" TextWrapping="Wrap"> Semi-Transparent Layers</TextBlock> </StackPanel> </Button> </StackPanel> Прозрачность — популярное средство WPF. Фактически благодаря простоте использования и эффективной работе, прозрачность превратилась в своего рода клише внутри WPF. По этой причине злоупотреблять им не стоит. Маски непрозрачности Свойство Opacity делает все содержимое элемента частично прозрачным. Свойство OpacityMask обеспечивает большую гибкость. Его можно использовать для того, чтобы сделать определенную область элемента прозрачной полностью или частично. OpacityMask позволяет достичь широкого разнообразия распространенных и экзотических эффектов. Например, с его помощью можно обеспечить плавное нарастание прозрачности фигуры. Свойство OpacityMask принимает любую кисть. Альфа-канал кисти определяет степень прозрачности. Например, если применить кисть SolidColorBrush с установленным прозрачным цветом для OpacityMask, то весь элемент полностью исчезнет. Если использовать SolidColorBrush с непрозрачным цветом, элемент останется полностью видимым. Прочие детали цвета (красный, зеленый и синий компоненты) не имеют значения и при установке свойства OpacityMask игнорируются. Применение OpacityMask с SolidColorBrush не имеет особого смысла, поскольку тот же эффект может быть достигнут проще — через свойство Opacity. Однако OpacityMask становится более удобным при использовании экзотических типов кистей, таких как LinearGradient или RadialGradientBrush. Используя градиент, который выполняет переход от сплошного к прозрачному цвету, можно создать эффект постепенного "исчезновения" поверхности элемента, как в следующем примере с кнопкой. <Button FontSize=4" FontWeight="Bold"> <Button.OpacityMask> <LinearGradientBrush StartPoint=,0" EndPoint="l,0"> <GradientStop Offset=" Color="Black"x/GradientStop> <GradientStop Offset="l" Color="Transparent"x/GradientStop> </LinearGradientBrush> </Button.OpacityMask> <Button.Content>A Partially Transparent Button</Button.Content> </Button> ♦
Глава 12. Фигуры, кисти и трансформации 359 На рис. 12.27 можно видеть эту кнопку поверх окна с изображением рояля. Рис. 12.27. Кнопка с переходом от сплошного цвета к прозрачности Свойство OpacityMask можно также использовать в сочетании с VisualBrush для создания эффекта отражения. Например, следующий код разметки создает один из наиболее распространенных эффектов WPF — текстового поля с отраженным текстом. По мере набора текста кисть VisualBrush рисует ниже отражение введенного текста. VisualBrush рисует прямоугольник, использующий свойство OpacityMask для постепенного затухания отражения, что отличает его от реального элемента, находящегося выше. <TextBox Name="txt" FontSize=0">Here is some reflected text</TextBox> <Rectangle Grid. Row="l" RenderTransformOngin="l, 0 . 5"> <Rectangle.Fill> <VisualBrush Visual="{Binding ElementName=txt}"></VisualBrush> </Rectangle.Fill> <Rectangle.OpacityMask> <LinearGradientBrush StartPoint=,0" EndPoint=,l"> <GradientStop Of f set= .3" Color="Transparent"x/GradientStop> <GradientStop Offset="l" Color="#44000000"x/GradientStop> </LinearGradientBrush> </Rectangle.OpacityMask> <Rectangle.RenderTransform> <ScaleTransform ScaleY="-l"x/ScaleTransform> </Rectangle.RenderTransform> </Rectangle> В этом примере LinearGradientBrush используется для перехода от полностью прозрачного цвета к частично прозрачному, чтобы сделать отраженное содержимое более туманным. Также добавляется трансформация RenderTransform для переворачивания прямоугольника, чтобы отражение было направлено сверху вниз. В результате этой трансформации остановки градиента должны быть определены в обратном порядке. На рис. 12.28 показан результат. Наряду с градиентными кистями и VisualBrush свойство OpacityMask часто используется вместе с кистью DrawingBrush, о которой речь пойдет в следующей главе. Она позволяет применять к элементу прозрачную область определенной формы.
360 Глава 12. Фигуры, кисти и трансформации Reflection Here is some reflected textj Рис. 12.28. Эффект отражения, созданный с помощью VisualBrush, OpacityMask и RenderTransform Резюме В этой главе детально рассматривалась поддержка базовой двухмерной графики в WPF. Сначала были описаны классы простых фигур. Затем было показано, как очерчивать и заполнять фигуры простыми и сложными кистями, как перемещать, изменять размеры, поворачивать и искажать фигуры с помощью трансформаций. В конце главы кратко рассматривалась прозрачность. На этом путешествие не завершается. В следующей главе вы ознакомитесь с Path — наиболее сложным из классов фигур, который позволяет комбинировать ранее изученные фигуры и добавлять к ним дуги и кривые. Также вы узнаете, как повышать эффективность графики с помощью WPF-объектов Geometry и Drawing, а также как экспортировать графические изображения из других программ.
ГЛАВА 13 Классы Geometry и Drawing В предыдущей главе было начато рассмотрение средств двухмерной графики. Вы узнали, как использовать простые классы-наследники Shape в комбинации с кистями и трансформациями для создания широкого разнообразия графических эффектов. Однако концепции, изученные до настоящего момента, все еще далеки от того, что понадобится для создания (и манипулирования) детализированных двухмерных сцен, построенных на основе векторной графики. Дело в том, что существует большая разница между прямоугольниками, эллипсами и многоугольниками и разновидностью картинок, которые можно видеть в многофункциональных графических приложениях. В настоящей главе вы повысите свою квалификацию, изучив несколько новых концепций. Вы узнаете о том, как создаются более сложные рисунки в WPF, как моделируются дуги и кривые, и как преобразовывать существующую векторную графику в требуемый формат XAML. Также будут обсуждаться наиболее оптимальные с точки зрения производительности способы работы со сложными изображениями — другими словами, речь пойдет о том, как сократить накладные расходы, связанные с управлением сотнями или тысячами фигур. Рассмотрение начнется с замены простых фигур, изученных в предыдущей главе, более мощным классом Path, который может включать в себя сложную геометрию. Классы Path и Geometry В предыдущей главе вы изучили ряд классов, унаследованных от Shape, включая Rectangle, Ellipse, Polygon и Polyline. Однако есть еще один класс-наследник Shape, который пока не рассматривался, хотя он намного мощнее прочих. Класс Path обладает способностью заключать в себе любую фигуру, группу фигур и более сложные ингредиенты вроде кривых. Класс Path имеет единственное свойство по имени Data, принимающее объект Geometry, который определяет фигуру (или фигуры), образующие путь. Создавать объект Geometry напрямую нельзя, поскольку этот класс является абстрактным. Вместо этого нужно использовать один из его наследников, перечисленных в табл. 13.1. Здесь может возникнуть вопрос: в чем же разница между путем и геометрией? Геометрия определяет фигуру. Путь позволяет рисовать фигуру. Поэтому объект Geometry определяет такие детали, как координаты и размер фигуры, в то время как объект Path применяет кисти Stroke и Fill, чтобы нарисовать ее. Класс Path также включает средства, унаследованные им от инфраструктуры UIElement, такие как обработка событий мыши и клавиатуры.
362 Глава 13. Классы Geometry и Drawing Таблица 13.1. Классы, унаследованные от Geometry Имя Описание LineGeometry Представляет прямую линию. Является геометрическим эквивалентом фигуры Line RectangleGeometry Представляет прямоугольник (необязательно — со скругленными углами). Является геометрическим эквивалентом фигуры Rectangle EllipseGeometry Представляет эллипс. Геометрический эквивалент фигуры Ellipse GeometryGroup Добавляет любое количество объектов Geometry к единственному пути, используя правило заполнения EvenOdd или NonZero для определения заполняемых областей CombinedGeometry Объединяет две геометрии в единую фигуру. Свойство CombineMode позволяет указать способ комбинирования составляющих PathGeometry Представляет более сложную фигуру, состоящую из дуг, кривых и линий, которая может быть как разомкнутой, так и замкнутой StreamGeometry Доступный только для чтения облегченный эквивалент PathGeometry. Класс StreamGeometry экономит память, поскольку не хранит в памяти сразу все индивидуальные сегменты пути. Однако однажды созданный объект не может быть модифицирован Однако классы геометрии вовсе не так просты, как может показаться на первый взгляд. Для начала, они наследуются от Freezable (через базовый класс Geometry), что обеспечивает им поддержку уведомлений об изменениях. В результате этого, если для создания пути применена геометрия, которая затем модифицируется, путь будет перерисован автоматически. Классы геометрии также могут использоваться для определения рисунков, к которым применяется кисть, что обеспечивает простой способ рисования сложного содержимого, которое не нуждается в средствах взаимодействия с пользователем класса Path. Эта возможность рассматривается в разделе "Рисунки" далее в настоящей главе. В последующих разделах рассматриваются все классы, унаследованные Geometry. Геометрии линий, прямоугольников и эллипсов Классы LineGeometry, RectangleGeometry и EllipseGeometry отображаются непосредственно на фигуры Line, Rectangle и Ellipse, которые должны быть знакомы по главе 12. Например, приведенный ниже код разметки, в котором используется элемент Rectangle: <Rectangle Fill="Yellow" Stroke="Blue" Width=00" Height=0"></Rectangle> можно преобразовывать в следующий код разметки, где применяется элемент Path: <Path Fill="Yellow" Stroke="Blue"> <Path.Data> <RectangleGeometry Rect=,0 100,50"></RectangleGeometry> </Path.Data> </Path> Единственное реальное отличие состоит в том, что фигура Rectangle принимает значения Height и Width, в то время как RectangleGeometry принимает четыре числа, описывающих размер и расположение прямоугольника. Первые два числа описывают координаты X и Y точки расположения левого верхнего угла, а последние два числа устанавливают ширину и высоту прямоугольника. Можно указать начало прямоугольни-
Глава 13. Классы Geometry и Drawing 363 ка в точке @,0) и получить тот же эффект, что и от обычного элемента Rectangle, или сместить прямоугольник, используя другие значения. Класс RectangleGeometry также включает свойства RadiusX и RadiusY, позволяющие скруглить углы (как было описано ранее). Аналогично, следующую линию: <Line Stroke="Blue" X1 = " Y1 = " X2=0" Y2 = 00"x/Line> можно преобразовать в объект LineGeometry: <Path Fill="Yellow" Stroke="Blue"> <Path.Data> <LineGeometry StartPoint=,0" EndPoint=0,100"></LineGeometry> </Path.Data> </Path> Показанный ниже эллипс: <Ellipse Fill="Yellow" Stroke="Blue" Width=00" Height=0" HonzontalAlignment="Left"></Ellipse> можно преобразовать в следующий объект Ellipse Geometry: <Path Fill="Yellow" Stroke="Blue"> <Path.Data> <EllipseGeometry RadiusX=0" RadiusY=5" Center=0, 25"x/EllipseGeometry> </Path.Data> </Path> Обратите внимание, что значения радиуса составляют просто половину значений ширины и высоты. С помощью свойства Center эллипс смещается. В данном примере центр размещается точно посредине описанного вокруг эллипса прямоугольника, так что он рисуется точно так же, как и фигура Ellipse. В общем, эти простые классы геометрии работают точно так же, как соответствующие им фигуры. Появляется дополнительная возможность смещения прямоугольников и эллипсов, но это не обязательно в случае помещения фигур на поверхность контейнера Canvas, который предоставляет возможность позиционирования фигур в заданных местах. Фактически, если это все, что нужно делать с объектами геометрии, то, скорее всего, связываться с элементом Path не имеет смысла. Отличие проявляется тогда, когда вы решите комбинировать более одного геометрического объекта в одном пути, как описано в следующем разделе. Комбинирование фигур в GeometryGroup Простейший способ комбинировать геометрические фигуры — воспользоваться объектом GeometryGroup и поместить внутрь него другие объекты, унаследованные от Geometry. Ниже приведен пример размещения эллипса рядом с квадратом: <Path Fill="Yellow" Stroke="Blue" Margin=" Canvas.Top=0" Canvas.Left=0"> <Path.Data> <GeometryGroup> <RectangleGeometry Rect=,0 100, 100"></RectangleGeometry> <EllipseGeometry Center=50,50" RadiusX=5" RadiusY=5"></EllipseGeometry> </GeometryGroup> </Path.Data> </Path> Эффект от этой разметки будет таким же, как от использования элементов Path — одного RectangleGeometry и одного EllipseGeometry (это то же самое, что и применение фигур Rectangle и Ellipse). Однако данный подход обладает одним преимущест-
364 Глава 13. Классы Geometry и Drawing вом. Два элемента заменяются одним, что сокращает накладные расходы, связанные с пользовательским интерфейсом. В общем случае окно, в котором используется меньшее количество элементов с более сложной геометрией, работает быстрее, чем окно с огромным числом элементов более простой геометрии. Этот эффект не слишком заметен в окне с несколькими десятками фигур, но становится существенным, когда речь идет о сотнях или тысячах фигур. Конечно, с комбинированием геометрий в один элемент Path связаны и отрицательные моменты, а именно: исчезает возможность индивидуальной обработки событий в разных фигурах. Вместо этого все события мыши инициируются элементом Path. Однако есть возможность манипулировать вложенными объектами RectangleGeometry и EllipseGeometry независимо, изменяя общий путь. Например, каждая геометрия предоставляет свойство Transform, которое можно применять для растягивания, деформации или поворота этой части пути. Другое преимущество геометрий проявляется в том, что одну и ту же геометрию можно повторно использовать в нескольких разных элементах Path. Никакого кода не понадобится — геометрия просто определяется в коллекции Resources, а ссылка на нее в пути производится через расширения разметки StaticExtension или DynamicExtension. Ниже приведен пример, в котором разметка из предыдущего фрагмента кода переписана для показа экземпляров CombinedGeometry в двух разных местах Canvas и с двумя разными цветами заполнения. <Window.Resources> <GeometryGroup x:Key="Geometry"> <RectangleGeometry Rect= , 0 100 ,100"></RectangleGeometry> <EllipseGeometry Center=50, 50" RadiusX=5" RadiusY=5"></ EllipseGeometry> </GeometryGroup> </Window.Resources> <Canvas> <Path Fill="Yellow" Stroke="Blue" Margin=" Canvas.Top=0" Canvas.Left=0" Data="{StaticResource Geometry}"> • </Path> <Path Fill="Green" Stroke="Blue" Margin=" Canvas.Top=50" Canvas.Left=0" Data="{StaticResource Geometry}"> </Path> </Canvas> Элемент GeometryGroup становится еще более интересным, когда фигуры пересекаются. Вместо простой трактовки рисунка как комбинации сплошных фигур, GeometryGroup использует свое свойство FillRule (которое может принимать значение EvenOdd или Nonzero, как описано в главе 12) для принятия решений о том, какие фигуры закрашивать. Посмотрим, что произойдет, если после кода разметки, показанного ранее, поместить эллипс поверх квадратам: <Path Fill="Yellow" Stroke="Blue" Margin=" Canvas.Top=0" Canvas.Left=0"> <Path.Data> <GeometryGroup> <RectangleGeometry Rect=,0 100,100"></RectangleGeometry> <EllipseGeometry Center=0,50" RadiusX=5" RadiusY=5"></ EllipseGeometry> </GeometryGroup> </Path.Data> </Path> Теперь этот код разметки создает квадрат с отверстием в нем в форме эллипса. Если изменить FillRule на Nonzero, то получится сплошной эллипс поверх сплошного квадрата — оба они закрашены желтым.
Глава 13. Классы Geometry и Drawing 365 Создать эффект квадрата с отверстием можно простым наложением белого эллипса на квадрат Однако класс GeometryGroup становится более полезным при наличии перекрытого содержимого, что типично для сложных рисунков. Поскольку эллипс трактуется как отверстие в фигуре (а не другая фигура с другим заполнением), любое содержимое, лежащее ниже, будет видно через это отверстие. Например, если добавить следующую строку текста: <TextBlock Canvas.Top=0" Canvas.Left=0" FontSize=5" FontWeight="Bold"> Hello There</TextBlock> Рис. 13.1. Путь, использующий две фигуры то получится результат, показанный на рис. 13.1. На заметку! Помните, что объекты рисуются в порядке их обработки. Другими словами, если нужно, чтобы текст появлялся под фигурой, добавьте в разметку Text Bloc k перед элементом Path. (Или, если для размещения содержимого используется Canvas или Grid, для расположения элементов можно соответствующим образом установить присоединенное свойство Panel.Zindex на элементах, как было описано в главе 3.) Комбинирование объектов Geometry и CombinedGeometry Класс GeometryGroup — неоценимый инструмент для построения сложных фигур из базовых примитивов (прямоугольник, эллипс и линия). Однако он не лишен очевидных ограничений. Этот класс отлично работает при создании фигур посредством рисования одной фигуры и последующего "вычитания" из нее других. Однако получить требуемый результат сложно, если границы фигур пересекают друг друга, кроме того, он не поможет, если требуется удалить часть какой-то фигуры. Класс CombinedGeometry предназначен для комбинирования перекрывающихся фигур, при этом ни одна фигура не содержит другую полностью. В отличие от GeometryGroup, класс CombinedGeometry принимает две геометрии, которые указываются в свойствах Geometryl и Geometry2. Он не имеет свойства FillRule — вместо этого он имеет гораздо более мощное свойство GeometryCombineMode, которое принимает одно из четырех значений, перечисленных в табл. 13.2. Таблица 13.2. Значения перечисления GeometryCombineMode Имя Описание Union Создает фигуру, включающую все области обеих геометрий Intersect Создает фигуру, содержащую области, которые принадлежат обеим геометриям Хог Создает фигуру, содержащую область, которая принадлежит любой из геометрий, но не обеим сразу. Это равносильно комбинированию двух фигур (с помощью Union) с удалением их общей части (Intersect) Exclude Создает фигуру, включающую все области из первой геометрии, исключая области, которые принадлежат второй геометрии Например, ниже показано, как объединить две фигуры для создания одной с общей областью, используя для этого GeometryCombineMode.Union: ■ GroupedShapes ^elloj kre
366 Глава 13. Классы Geometry и Drawing <Path Fill="Yellow" Stroke="Blue" Margin="> <Path.Data> <CombinedGeometry GeometryCombineMode="Union"> <CombinedGeometry.Geometryl> <RectangleGeometry Rect=,0 100,100"></RectangleGeometry> </CombinedGeometry.Geometryl> <CombinedGeometry.Geometry2> <EllipseGeometry Center="85,50" RadiusX=5" RadiusY=5"></EllipseGeometry> </CombinedGeometry.Geometry2> </CombinedGeometry> </Path.Data> </Path> • CombimngShapes Union Intersect Xot Exclude Полученную фигуру можно видеть на рис. 13.2 наряду с результатом комбинирования тех же фигур всеми другими возможными способами. Тот факт, что CombinedGeometry может комбинировать только две фигуры, может показаться существенным ограничением, но на самом деле это не так. Можно строить фигуру, которая включает десятки и более разных геометрий — для этого необходимо просто использовать вложенные объекты CombinedGeometry. Например, один объект CombinedGeometry может комбинировать два других объекта CombinedGeometry, которые сами могут комбинировать другие геометрии, и т.д. С применением этой техники можно строить весьма сложные детализированные фигуры. Чтобы понять, как это работает, рассмотрим простой запрещающий знак (круг, перечеркнутый косой линией), показанный на рис. 13.3. Хотя не существует примитивов WPF, которые представляют такую фигуру, ее можно достаточно быстро собрать с помощью объектов CombinedGeometry. Имеет смысл начать с рисования эллипса, представляющего внешнюю границу фигуры. Затем, применяя CombinedGeometry с GeometryCombineMode.Exclude, можно удалить эллипс меньшего размера изнутри. Вот какая разметка для этого понадобится: <Path Fill="Yellow" Stroke="Blue"> <Path.Data> <CombinedGeometry GeometryCombineMode="Exclude"> <CombinedGeometry.Geometryl> <EllipseGeometry Center=0,50" RadiusX=0" RadiusY=0"x/EllipseGeometry> </CombinedGeometry.Geometryl> <CombinedGeometry.Geometry2> <EllipseGeometry Center=0,50" RadiusX=0" RadiusY=0"x/EllipseGeometry> </CombinedGeometry.Geometry2> </CombinedGeometry> </Path.Data> </Path> Но это только часть пути — еще требуется добавить косую линию в середине. Простейший способ сделать это — добавить наклонный прямоугольник. Для этого можно воспользоваться объектом RectangleGeometry с трансформацией RotateTransform на 45 градусов: Рис. 13.2. Комбинирование фигур
Глава 13. Классы Geometry и Drawing 367 <RectangleGeometry Rect=4,5 10,90"> <RectangleGeometry.Transform> <RotateTransform Angle=5" CenterX=0" CenterY=0"></RotateTransform> </RectangleGeometry.Transform> </RectangleGeometry> На заметку! Для применения трансформации к геометрии используется свойство Transform (а не RenderTransform или LayoutTransform). Это связано с тем, что геометрия определяет фигуру, а любая трансформация всегда применяется до того, как путь используется в компоновке. Рис. 13.3. Несколько комбинированных фигур Завершающий шаг состоит в комбинировании этой геометрии с уже скомбинированной геометрией, создающей круг с отверстием. В этом случае для добавления прямоугольника к фигуре должен использоваться режим GeometryCombineMode .Union. Ниже показан полный код разметки для запрещающего знака. <Path Fill="Yellow" Stroke="Blue"> <Path.Data> <CombinedGeometry GeometryCombineMode="Union"> <CombinedGeometry.Geometryl> <CombinedGeometry GeometryCombineMode="Exclude"> <CombinedGeometry.Geometryl> <EllipseGeometry Center=0,50" RadiusX=0" RadiusY=0"x/EllipseGeometry> </CombinedGeometry.Geometryl> <CombinedGeometry.Geometry2> <EllipseGeometry Center=0,50" RadiusX= 0" RadiusY=0"></EllipseGeometry> </CombinedGeometry.Geometry2> </CombinedGeometry> </CombinedGeometry.Geometryl> <CombinedGeometry.Geometry2> <RectangleGeometry Rect=4,5 10,90"> <RectangleGeometry.Transform> <RotateTransform Angle=5" CenterX=0" CenterY=0"x/RotateTransform> </RectangleGeometry.Transform> </RectangleGeometry> </CombinedGeometry.Geometry2> </CombinedGeometry> </Path.Data> </Path> На заметку! Объект GeometryGroup не может влиять на кисти заполнения или границы, используемые для раскраски. Эти детали устанавливаются объектом Path. Следовательно, чтобы по- разному окрашивать части пути, понадобится создать отдельные объекты Path. Кривые и прямые линии, представляемые с помощью PathGeometry В PathGeometry содержится вся мощь геометрий. Этот объект может рисовать все, что и прочие объекты геометрии, плюс многое другое. Единственный связанный с ним недостаток — более длинный (и иногда сложный) синтаксис.
368 Глава 13. Классы Geometry и Drawing Каждый объект PathGeometry строится на основе одного или более объектов PathFigure (хранящихся в коллекции PathGeometry.Figures). Каждая фигура PathFigure представляет собой непрерывный набор связанных отрезков прямых и кривых линий, которые могут быть замкнуты или разомкнуты. Фигура замкнута, если конец последней линии фигуры соединяется с началом первой линии. Класс PathFigure имеет четыре ключевых свойства, описанные в табл. 13.3. Таблица 13.3. Свойства PathFigure Имя Описание StartPoint Точка начала первой линии фигуры Segments Коллекция объектов PathSegment, используемых для рисования фигуры IsClosed Если равно true, то WPF добавляет отрезок прямой, соединяющий начальную и конечную точки (если они не совпадают) IsFilled Если равно true, то область внутри фигуры заполняется кистью Path.Fill Пока все выглядит достаточно просто. PathFigure — это фигура, которая рисуется непрерывной линией, состоящая из ряда сегментов. Однако трюк заключается в том, что есть несколько типов сегментов, и все они наследуются от класса PathSegment. Некоторые из них просты как LineSegment, который рисует отрезок прямой. Другие, такие как BezierSegment, рисуют кривые, потому соответственно более сложны. При построении фигуры Можно смешивать различные типы сегментов. В табл. 13.4 перечислены классы сегментов, доступные для использования. Таблица 13.4. Классы PathSegment Имя Описание LineSegment Создает отрезок прямой линии между двумя точками ArcSegment Создает эллиптическую дугу между двумя точками BezierSegment Создает кривую Безье между двумя точками QuadraticBezierSegment Создает упрощенную форму кривой Безье, имеющую одну опорную точку вместо двух и вычисляемую быстрее PolyLineSegment Создает серию прямых линий. Вы можете получить тот же эффект, используя множество объектов LineSegment, но единственный объект PolyLineSegment более лаконичен PolyBezierSegment Создает серию кривых Безье PolyQuadraticBezierSegment Создает серию упрощенных квадратичных кривых Безье Прямые линии Простые линии создаются достаточно легко с использованием классов LineSegment и PathGeometry. Необходимо просто установить StartPoint и добавить по одному объекту LineSegment для каждой секции линии. Свойство LineSegment.Point идентифицирует конечную точку каждого сегмента. Например, следующий код разметки начинается с A0,100), рисует отрезок прямой до A00,100), затем рисует линию из этой точки до A00,50). Поскольку свойство PathFigure. IsClosed установлено в true, финальный сегмент линии добавляет соединение точек A00,50) с @,0). В результате получается прямоугольный треугольник.
Глава 13. Классы Geometry и Drawing 369 <Path Stroke="Blue"> <Path.Data> <PathGeometry> <PathFigure IsClosed="True" StartPoint=0,100"> <LineSegment Point=00,100" /> <LineSegment Point=00,50" /> </PathFigure> </PathGeometry> </Path.Data> </Path> На заметку! Помните, что каждый объект PathGeometry может содержать неограниченное количество объектов PathFigure. Это означает, что допускается создать несколько разомкнутых и замкнутых фигур, и все они будут трактоваться как части одного пути. Дуги Дуги немного интереснее, чем прямые линии. Конечная точка идентифицируется свойством ArcSegment.Point — так же, как в случае с LineSegment. Однако PathFigure рисует кривую линию из начальной точки (или конечной точки предыдущего сегмента) в конечную точку дуги. Эта соединительная кривая линия на самом деле является частью эллипса. Очевидно, что координат конечной точки не достаточно, чтобы нарисовать дугу, поскольку существует множество кривых (некоторые более пологие, некоторые более крутые), соединяющих две точки. Понадобится также указать размер воображаемого эллипса, частью которого является данная дуга. Это делается через свойство ArcSegment.Size, которое принимает радиусы X и Y эллипса. Чем больше размер воображаемого эллипса, тем более пологой будет кривая. На заметку! Для любых двух точек существует максимальный и минимальный практический размер эллипса. Максимум достигается тогда, когда эллипс настолько велик, что его сегмент, проходящий через заданные две точки, выглядит прямым. Увеличение размера сверх этого не дает никакого эффекта. Минимум достигается тогда, когда эллипс настолько мал, что эти две точки соединяются полным полукругом. Дальнейшее уменьшение размера не дает эффекта. РаССМОТрИМ Пример СОЗДаНИЯ ПОЛОГОЙ ДУГИ, ПОКа- i : S«mpteArc занной на рис. 13.4: <Path Stroke="Blue" StrokeThickness="> <Path.Data> <PathGeometry> <PathFigure IsClosed="False" ; \ StartPoint=0,100"> \^ <ArcSegment Point=50,150" Size=00,300" /> >v * </PathFigure> ^V. ^^r </PathGeometry> </Path.Data> </Path> Рис. 13.4. Простая дуга До сих пор все, что было сказано о дугах, выглядело простым. Однако дело в том, что даже при наличии начальной и конечной точек, а также размеров эллипса, информации для однозначного рисования дуги все еще не достаточно. В предыдущем примере используются два значения по умолчанию, которые могут и не подойти.
370 Глава 13. Классы Geometry и Drawing Чтобы понять суть проблемы, необходимо рассмотреть другие способы соединения двух точек дугой. Отметив две точки на эллипсе, станет ясно, что соединить их можно двумя способами — проходя по эллипсу по короткой или по длинной стороне (рис. 13.5). Малая дуга Начальная точка ^^ ^\ Конечная точка Большая дуга Рис. 13.5. Два способа прокладки дуги по эллипсу Установка направления осуществляется с помощью свойства ArcSegment. IsLargeArc, которое может принимать значение true или false. Значением по умолчанию является false, означающее выбор кратчайшей из двух дуг. Даже установив направление, остается одна неопределенность — где именно располагается эллипс. Например, предположим, что рисуется дуга, соединяющая точку слева с точкой справа по кратчайшей дуге. Кривая, соединяющая эти две точки, может протянуться вниз, затем вверх (как показано на рис. 13.4) или же наоборот — вверх и вниз. Полученная в результате дуга зависит от порядка определения двух точек дуги и свойства ArcSegment.SweepDirection, которое может принимать значение Counterclockwise (против часовой стрелки, что принято по умолчанию) или Clockwise (по часовой стрелке). Разница продемонстрирована на рис. 13.6. По часовой стрелке Начальная точка ^S^ ^ч^ Конечная точка Против часовой стрелки Рис. 13.6. Два способа расположения кривой Кривые Безье Кривые Безье соединяют два сегмента линии, используя сложную математическую формулу, которая включает две опорные точки, определяющие форму кривой. По причине чрезвычайной гибкости кривые Безье являются непременными ингредиентами почти любого приложения векторной графики, которое когда-либо было разработано. Не имея ничего, кроме начальной точки, конечной точки и двух опорных точек, можно получить неожиданно широкое разнообразие гладких кривых (включая петли). На рис. 13.7 демонстрируется классическая кривая Безье. Два маленьких кружка указывают опорные точки, а пунктирная линия соединяет каждую из них с концом линии, на который она оказывает влияние в большей степени.
Глава 13. Классы Geometry и Drawing 371 Даже не понимая всей математической подоплеки, достаточно легко "прочувствовать", как работает кривая Безье. По сути, всю "магию" обеспечивают две опорные точки. Они влияют на кривую двумя способами. • В начальной точке кривая Безье идет параллельно линии, соединяющей ее с первой опорной точкой. В конечной точке кривая идет параллельно линии, соединяющей ее с конечной точкой (между ними она является кривой). • Степень кривизны определяется расстоянием до двух опорных точек. Если одна из них находится дальше, она создает большее "напряжение" притяжения. Для определения кривой Безье в коде разметки задаются три точки. Первые две (BezierSegment.Point 1 и BezierSegment.Point2) — это опорные точки. Третья точка (BezierSegment .Point3) — конечная точка кривой. Как всегда, начальной точкой является начальная точка пути или конец предыдущего сегмента. Пример, показанный на рис. 13.7, включает три отдельных компонента, каждый из которых использует отдельный штрих, а потому требует отдельного элемента Path. Первый путь создает кривую, второй добавляет пунктирные линии, а третий — кружки, обозначающие опорные точки. Ниже приведен полный код разметки. <Canvas> <Path Stroke=,,Blue" StrokeThickness = M5M Canvas .Top=,,20"> <Path.Data> <PathGeometry> <PathFigure StartPoint=0,10"> <BezierSegment Pointl=30,30" Point2=0,140" Point3=50,150"></BezierSegment> </PathFigure> </PathGeometry> </Path.Data> </Path> <Path Stroke="Green" StrokeThickness=" StrokeDashArray= 2" Canvas.Top=0"> <Path.Data> <GeometryGroup> <LineGeometry StartPoint=0,10" EndPoint=30,30"></LineGeometry> <LineGeometry StartPoint=0,140" EndPoint=50,150"></LineGeometry> </GeometryGroup> </Path.Data> </Path> <Path Fill="Red" Stroke="Red" StrokeThickness="8" Canvas.Top=0"> <Path.Data> <GeometryGroup> <EllipseGeometry Center=30,30"></EllipseGeometry> <EllipseGeometry Center=0,140"></EllipseGeometry> </GeometryGroup> </Path.Data> </Path> </Canvas> Рис. 13.7. Кривая Безье
372 Глава 13. Классы Geometry и Drawing Попытка закодировать кривую Безье — верный способ потратить множество безуспешных часов на кодирование путем проб и ошибок. Гораздо проще нарисовать кривые (как и многие другие графические элементы) в специальной программе для рисования, оснащенной средствами экспорта в XAML, или в Microsoft Expression Blend. Совет. За дополнительными сведениями об алгоритме, лежащем в основе кривых Безье, можете обратиться к статье в Википедии, посвященной этой теме, по адресу http://ru.wikipedia. org/wiki/KpHBafl_Be3be. Мини-язык описания геометрии Примеры геометрии, продемонстрированные до сих пор, были сравнительно короткими, состоящими из нескольких точек. Более сложная геометрия концептуально описывается так же, но может потребовать сотен сегментов. Определение каждой линии, дуги и кривой в сложном пути чрезвычайно многословно, да и не нужно — в конце концов, весьма вероятно, что такие сложные пути будут генерироваться инструментом графического дизайна, а не писаться вручную, так что ясность разметки не так важна. Памятуя об этом, создатели WPF предусмотрели более краткий альтернативный синтаксис, который позволяет представлять детализированные фигуры с меньшим объемом кода разметки. Этот синтаксис часто называют мини-языком геометрии (а иногда мини-языком путей, поскольку он применяется к элементам Path). Выражения мини-языка представляют собой довольно длинные строки, содержащие серии команд. Эти команды читаются преобразователем типа, создающим соответствующую геометрию. Каждая команда — это одна буква, за которой необязательно следуют несколько порций числовой информации (вроде координат X и Y), разделенных пробелами. Каждая команда также отделяется пробелом от предыдущей. Например, чуть выше был описан базовый треугольник в виде замкнутого пути из двух прямолинейных сегментов. Вот какая разметка для этого понадобилась: <Path Stroke=MBlueM> <Path.Data> <PathGeometry> <PathFigure IsClosed=,,True" StartPoint=0,100"> <LineSegment Point=00,100" /> <LineSegment Point=00,50" /> </PathFigure> </PathGeometry> </Path.Data> </Path> Чтобы продублировать эту фигуру с применением мини-языка, придется написать следующее: <Path Stroke=MBlue" Data="M 10,100 L 100,100 L 100,50 ZM/> Этот путь использует последовательность из четырех команд. Первая команда (М) создает PathFigure и устанавливает начальную точку в A0, 100). Следующие две команды (L) создают прямолинейные сегменты. Заключительная команда (Z) завершает PathFigure и устанавливает свойство IsClosed в true. Команды в этой строке необязательны, как и пробелы между командами и их параметрами, однако должен присутствовать как минимум один пробел между соседними параметрами и командами. Это значит, что синтаксис может быть дополнительно сокращен до следующей менее читабельной формы: <Path Stroke=MBlue" Data=MM10 100 L100 100 L100 50 Z"/>
Глава 13. Классы Geometry и Drawing 373 При создании геометрии с применением мини-языка в действительности создается объект StreamGeometry, а не PathGeometry. В результате модифицировать эту геометрию позже в коде нельзя. Если это неприемлемо, можно создать PathGeometry явно, но использовать тот же синтаксис, что и при определении коллекции объектов PathFigure: "BlueM> <Path Stroke= <Path.Data> <PathGeometry Figures="M 10,100 L 100,100 L 100,50 Z" </Path.Data> </Path> /> Мини-язык геометрии очень легко выучить. В нем определен весьма небольшой набор команд, которые перечислены в табл. 13.5. Параметры выделены курсивом. Таблица 13.5. Команды мини-языка геометрии Команда Описание F значение М х,у L х,у Н х V у A radiusX, radiusY degrees isLargeArc, isClockwise x,y С xlfylfx2fy2fxfy Q xl, yl x,y S x2, y2f x, у Z Устанавливает свойство Geometry. FillRule. Используйте 0 для EvenOdd или 1 для NonZero. Эта команда должна располагаться в начале строки (если решено ее применять) Создает новый объект PathFigure для геометрии и устанавливает его начальную точку Эта команда должна использоваться перед любой другой за исключением F. Однако можно также использовать ее внутри последовательности рисования, чтобы переместить начало координатной системы (м означает "move" (переместить)) Создает сегмент LineSegment до указанной точки Создает горизонтальный сегмент LineSegment, используя указанное значение X и сохраняя неизменным Y Создает вертикальный сегмент LineSegment, используя указанное значение Y и сохраняя неизменным X Создает сегмент ArcSegment до указанной точки. Понадобится указать радиусы эллипса, описывающего дугу, угол дуги в градусах и булевские флаги, устанавливающие ранее описанные свойства IsLargeArc и SweepDirection Создает сегмент BezierSegment до указанной точки, используя опорные точки в (xl,yl) и (х2,у2) Создает сегмент QuadraticBezierSegment до указанной точки, с одной опорной точкой (xl,yl) Создает гладкий сегмент BezierSegment до указанной точки, с одной опорной точкой (xl,yl) Завершает текущую фигуру PathFigure и устанавливает свойство IsClosed в true. Если не хотите устанавливать IsClosed в true, то не используйте эту команду. Вместо нее применяйте команду м, если нужно начать новую фигуру PathFigure или завершить строку Совет. Существует еще один трюк, касающийся мини-языка геометрии. Если записать команду в нижнем регистре, то ее параметры будут трактоваться относительно предыдущей точки, а не как абсолютные координаты.
374 Глава 13. Классы Geometry и Drawing Кадрирование геометрии Как уже было показано, классы геометрии предоставляют наиболее мощный способ создания фигур. Однако геометрии не ограничены элементом Path. Они также используются везде, где нужно применить абстрактное определение фигуры (вместо рисования реальной конкретной фигуры в окне). Другое применение геометрии предусматривает установку свойства Clip, которое поддерживается всеми элементами. Свойство Clip позволяет ограничить внешние границы элемента, чтобы он уместился в определенную геометрию. Свойство Clip можно использовать для создания множества экзотических эффектов. Хотя обычно оно применяется для обрезания содержимого графического изображения в элементе Image, это свойство допускается использовать с любым элементом. Единственное условие: нужна замкнутая геометрия, если действительно необходимо увидеть что-либо; отдельные кривые и прямолинейные сегменты не слишком полезны в данном отношении. В следующем примере определяется одна геометрия, используемая для кадрирования двух элементов: элемента Image, содержащего растровое изображение, и стандартного элемента Button. Результат показан на рис. 13.8. gptuttj Рис. 13.8. Кадрирование двух элементов Вот как выглядит код разметки для этого примера: <Window.Resouгсеs> <GeometryGroup x: Key="clipGeometry11 FillRule=,lNonzero"> <EllipseGeometry RadiusX=M75M RadiusY=M50M Center=00,150Mx/EllipseGeometry> <EllipseGeometry RadiusX=,,100" RadiusY=,,25" Center=00,150"></EllipseGeometry> <EllipseGeometry RadiusX=M75M RadiusY=M130M Center=M140,140Mx/EllipseGeometry> </GeometryGroup> </Window.Resources> <Grid> <Gnd.ColumnDef initions> <ColumnDef mitionX/ColumnDef inition> <ColumnDef mitionx/Column Def inition> </Grid.ColumnDefinitions> <Button Clip="{StaticResource clipGeometry}">A button</Button> <Image Grid. Column="l" Clip=" { StaticResource clipGeometry}11 Stretch="None11 Source="creek. jpg"x/Image> </Grid> ■ * Clipping
Глава 13. Классы Geometry и Drawing 375 С кадрированием связано одно ограничение — размеры элемента во внимание не принимаются. Другими словами, если кнопка на рис. 13.8 станет больше или меньше при изменении размера окна, то кадрированная область останется прежней и отобразит другую часть кнопки. Возможным решением может быть помещение элемента в Viewbox, который обеспечит автоматическое масштабирование. Однако это приведет к тому, что все пропорционально поменяет размер, включая детали, которые необходимо изменить (вырезанная область и поверхность кнопки), и те, которые изменять не нужно (текст кнопки и линия, очерчивающая ее границу). В следующем разделе будут предоставлены дополнительные сведения об объектах Geometry и применении их для облегченного рисования, которое может осуществляться разнообразными способами. Рисунки Как уже известно, абстрактный класс Geometry представляет фигуру или путь. Абстрактный класс Drawing играет дополняющую роль. Он представляет двухмерные рисунки — другими словами, содержит всю информацию, которая нужна для вывода части векторного или растрового изображения. Хотя существует несколько типов классов рисунков, среди них GeometryDrawing — один из тех, которые работают с рассмотренными ранее геометриями. Он добавляет детали очерчивания границ и заполнения областей — т.е. описывает, как геометрия должна быть нарисована. Объект GeometryDrawing можно воспринимать как единственную фигуру в части векторного рисунка. Например, можно преобразовать стандартный файл .wmf в коллекцию объектов GeometryDrawing, готовых к вставке в пользовательский интерфейс. (Об этом речь пойдет в разделе "Экспорт рисунка" далее в главе.) Давайте рассмотрим простой пример. Ранее было показано, как определяется простой объект PathGeometry, представляющий треугольник: <PathGeometry> <PathFigure IsClosed="True" StartPoint=0,100"> <LineSegment Point=00,100" /> <LineSegment Point=00,50" /> </PathFigure> </PathGeometry> Этот объект PathGeometry можно применить для построения GeometryDrawing следующим образом: <GeometryDrawing Brush="Yellow"> <GeometryDrawing.Pen> <Pen Brush="Blue" Thickness="x/Pen> </GeometryDrawing.Pen> <GeometryDrawing.Geometry> <PathGeometry> <PathFigure IsClosed="True" StartPoint=0,100"> <LineSegment Point=00,100" /> <LineSegment Point=00,50" /> </PathFigure> </PathGeometry> </GeometryDrawing.Geometry> </GeometryDrawing> Здесь PathGeometry определяет фигуру (треугольник). GeometryDrawing задает внешний вид фигуры (желтый треугольник с синим контуром). Ни PathGeometry, ни GeometryDrawing не являются элементами, так что ни тот, ни другой нельзя использовать непосредственно для добавления самостоятельно нарисованного содержимого в
376 Глава 13. Классы Geometry и Drawing окно. Вместо этого нужно будет применять другой класс, поддерживающий рисование, что и будет описано в следующем разделе. На заметку! Класс GeometryDrawing вводит новую деталь: класс System.Windows.Media. Pen. Класс Pen (перо) предоставляет свойства Brush и Thickness, использованные в предыдущем примере, наряду со свойствами, относящимися к штрихам, о которых говорилось во время обсуждения фигур (StartLine, EndLineCap, DashStyle, DashCap, LineJoin и MiterLimit). Фактически большинство классов-наследников Shape внутренне применяют объекты Реп в своем коде рисования, но предлагают связанные с перьями свойства для непосредственного использования. GeometryDrawing — не единственный класс для рисования в WPF (хотя он и наиболее важный, когда речь идет о двухмерной векторной графике). Фактически класс Drawing предназначен для того, чтобы представлять все типы двухмерной графики, и только небольшая группа классов наследуется от него. Все они перечислены в табл. 13.6. Таблица 13.6. Классы, унаследованные от Drawing Класс Описание Свойства GeometryDrawing ImageDrawing VideoDrawing GlyphRunDrawing DrawingGroup Создает оболочку для геометрии с кистью, заполняющей ее, и пером, очерчивающим контур Создает оболочку для графического изображения (обычно растрового изображения из файла) с прямоугольником, определяющим его границы Комбинирует MediaPlayer, используемый для воспроизведения видео, с прямоугольником, задающим его границы. Подробности поддержки мультимедиа в WPF изложены в главе 26 Создает оболочку для низкоуровневого текстового объекта, известного как GlyphRun, с рисующей его кистью Комбинирует коллекцию объектов Drawing любого типа. DrawingGroup позволяет создавать составные рисунки и применять эффекты ко всей коллекции сразу, используя одно из его свойств Geometry, Brush, Pen ImageSource, Rect Player, Rect GlyphRun, ForegroundBrush BitmapEffect, BitmapEffeetInput, Children, ClipGeometry, GuidelineSet, Opacity, OpacityMask, Transform Отображение рисунка Поскольку классы-наследники Drawing не являются элементами, они не могут быть помещены в пользовательский интерфейс. Вместо этого для отображения рисунка должен использоваться один из трех классов, описанных в табл. 13.7. У всех этих классов есть нечто общее — они предоставляют возможность отображать двухмерное содержимое с меньшими накладными расходами. Например, предположим, что необходимо воспользоваться частью векторного рисунка для создания пиктограммы кнопки.
Глава 13. Классы Geometry и Drawing 377 Таблица 13.7. Классы для отображения рисунков Класс Унаследован от Описание Drawinglmage ImageSource Позволяет разместить рисунок внутри элемента Image DrawingBrush Brush Позволяет упаковать рисунок с кистью, которую можно использовать для заполнения любой поверхности DrawingVisual Visual Позволяет поместить рисунок в низкоуровневый визуальный объект. Визуальные объекты не требуют накладных расходов как настоящие элементы, но могут отображаться, если реализована необходимая инфраструктура. Подробнее об этом речь пойдет в главе 14 Наиболее удобный (и затратный в смысле ресурсов) способ сделать это — поместить Canvas внутрь кнопки, а затем поместить в Canvas набор элементов-наследников Shape: <Button ... > <Canvas ... > <Polyline ... > <Polyline ... > <Rectangle ... > <Ellipse ... > <Polygon ... > </Canvas> </Button> Как уже известно, если принять такой подход, то каждый элемент будет полностью независимым, с собственным пространством в памяти, обработкой событий и т.п. Более удачный подход заключается в сокращении количества элементов за счет применения элемента Path. Поскольку каждый путь имеет единственный штрих и заполнение, понадобится множество объектов Path, но все же, скорее всего, удастся несколько сократить количество элементов: <Button ... > <Canvas ... > <Path ... > <Path ... > <Path ... > </Canvas> </Button> Начав работать с элементом Path, вы переключаетесь от отдельных фигур к отдельным геометриям. Уровень абстракции можно поднять, если извлечь информацию о геометрии, штрихе и заполнении из пути и превратить ее в рисунок. Затем рисунки можно скомбинировать вместе в объект DrawingGroup и поместить этот объект в Drawinglmage, а его, в свою очередь — в элемент Image. Вот как будет выглядеть код разметки, описывающий этот процесс: <Button ... > <Image . . . > <Image.Source> <DrawingImage> <DrawingImage.Drawing> <DrawingGroup> <GeometryDrawing ... >
378 Глава 13. Классы Geometry и Drawing <GeometryDrawing . . . > <GeometryDrawing . . . > </DrawingGroup> </DrawingImage.Drawing> </DrawingImage> <Image.Source> </Image> </Button> Это существенное изменение. Оно не упростило код разметки, поскольку вместо каждого объекта Path был подставлен один объект GeometryDrawing. Однако это должно сократить количество элементов и соответственно снизить необходимый объем накладных расходов. В предыдущем примере создавался контейнер Canvas внутри кнопки и добавлялся отдельный элемент для каждого пути. Но в этом примере требуется всего один элемент Image внутри кнопки. Компромисс состоит в том, что больше нет возможности обрабатывать события для каждого отдельного пути (например, не удастся обнаруживать щелчки кнопкой мыши на отдельных областях рисунка). Но это вряд ли понадобится статическому изображению, которое помещается на кнопку. На заметку! Drawinglmage и ImageDrawing легко спутать — эти два класса WPF имеют очень похожие имена. Класс Drawinglmage служит для помещения рисунка внутрь элемента Image. Обычно он будет использоваться для помещения векторной графики в Image. Класс ImageDrawing полностью отличается; это класс, унаследованный от Drawing, который принимает содержимое — растровое изображение. В результате появляется возможность комбинирования объектов GeometryDrawing и ImageDrawing в одном DrawingGroup, и тем самым создания рисунка с векторным и растровым содержимым, которое можно использовать по своему усмотрению. Хотя Drawinglmage обеспечивает большую экономию, все же можно увеличить эффективности и удалить один элемент с помощью DrawingBrush. Одним из продуктов, использующих данный подход, является Expression Blend. Базовая идея заключается в помещении Drawinglmage в DrawingBrush, примерно так: <Button ... > <Button.Background> <DrawingBrush> <DrawingBrush.Drawing> <DrawingGroup> <GeometryDrawing . . . > <GeometryDrawing . . . > <GeometryDrawing . . . > </DrawingGroup> </DrawingBrush.Drawing> </DrawingBrush> </Button.Background> </Button> Подход с DrawingBrush — это не то же самое, что подход с Drawinglmage, показанный ранее, поскольку стандартные способы изменения размеров содержимого в Image отличаются. По умолчанию свойство Image.Stretch установлено в Uniform, что масштабирует изображение вверх и вниз для заполнения доступного пространства. По умолчанию свойство DrawingBrush.Stretch равно Fill, что может исказить изображение.
Глава 13. Классы Geometry и Drawing 379 При изменении свойства Stretch объекта DrawingBrush также можно уточнить установки Viewport, явно изменив местоположение и размер рисунка в заполняемой области. Например, следующий код разметки масштабирует рисунок, используемый кистью так, чтобы он занял 90% области заполнения: <DrawingBrush Stretch="Fill" Viewport=,0 0.9,0.9"> Это удобно для примера с кнопкой, так как оставляет некоторое пространство вокруг кнопки. Поскольку DrawingBrush не является элементом, он не может участвовать в процессе компоновки WPF. Это означает, что в отличие от Image, размещение содержимого в DrawingBrush не принимает во внимание Button.Padding. Совет. Использование объектов DrawingBrush также позволяет создавать некоторые эффекты, которые были бы невозможны в противном случае, такие как мозаика. Поскольку DrawingBrush унаследован от TileBrush, можно использовать свойство TileMode для повторения рисунка в шаблоне по всей заполняемой области. В главе 12 изложены все подробности об укладке мозаикой с помощью TileBrush. Одна причуда подхода на основе DrawingBrush заключается в том, что содержимое исчезает, когда курсор мыши перемещается над кнопкой и для рисования ее поверхности применяется новая кисть. Но когда используется подход на основе Image, рисунок остается нетронутым. Чтобы справиться с этой проблемой, нужно создать шаблон элемента управления для кнопки, который не будет перерисовывать фон таким же образом. Эта техника демонстрируется в главе 17. В любом случае, используется объект Drawinglmage сам по себе или в оболочке DrawingBrush, стоит подумать об улучшении разметки с применением ресурсов. Основная идея состоит в определении каждого объекта Drawinglmage или DrawingBrush в виде отдельного ресурса, на который затем можно ссылаться по мере необходимости. Это особенно хорошая мысль, если необходимо отобразить некоторое содержимое в более чем одном элементе или в более чем одном окне, потому что тогда можно просто повторно использовать ресурс, а не копировать целый блок разметки. Экспорт рисунка Хотя во всех этих примерах рисунки объявлялись встроенным образом, общепринятый подход предусматривает помещение некоторой части содержимого в словарь ресурсов, чтобы его можно было повторно использовать в разных местах приложения (а модифицировать — только в одном). Как разнести разметку по ресурсам — дело ваше, но чаще всего сохраняется словарь, заполненный объектами Drawinglmage или DrawingBrush. Можно также дополнительно выделить объекты Geometry и сохранить их как отдельные ресурсы. (Это удобно, когда одна и та же геометрия применяется в более чем одном рисунке, но с разными цветами.) Разумеется, лишь очень немногие разработчики станут кодировать рисунки вручную. Скорее, они воспользуются специальными инструментами графического дизайна, которые экспортируют необходимое содержимое в XAML. Большинство инструментов дизайна не поддерживают пока экспорт в XAML, хотя есть множество подключаемых модулей и конвертеров, которые заполняют пробел. Ниже представлены некоторые примеры. • http://www.mikeswanson.com/XAMLExport — бесплатные подключаемые модули XAML для Adobe Illustrator. • http://www.mikeswanson.com/swf2xaml — бесплатный XAML-конвертер для файлов Adobe Flash.
380 Глава 13. Классы Geometry и Drawing • Expression Design — программа для графического дизайна и иллюстраций от Microsoft; имеет встроенный экспорт в XAML. Она может читать широкое разнообразие файловых форматов векторной графики, включая .wmf (Windows Metafile Format), что позволяет импортировать существующие рисунки и экспортировать их в XAML. Однако даже в случае работы с одним из этих инструментов, знания геометрии и рисунков в WPF все равно важны по нескольким причинам. Первая причина в том, что многие программы позволяют выбирать, как экспортировать рисунок — в виде комбинации отдельных элементов в Canvas или же в виде коллекции ресурсов DrawingBrush или Drawinglmage. Обычно первый вариант выбирается по умолчанию, поскольку сохраняет больше возможностей. Однако в случае большого количества сложных рисунков или при желании сэкономить память, расходуемую на статическую графику вроде пиктограмм кнопок, намного лучше иметь дело с ресурсами DrawingBrush или Drawinglmage. Еще хорошо то, что эти форматы отделены от остального пользовательского интерфейса, так что их легко впоследствии обновлять, если возникнет необходимость. (На самом деле, можно даже помещать ресурсы DrawingBrush или Drawinglmage в отдельно скомпилированную сборку DLL, как описано в главе 10.) Совет. Чтобы сэкономить ресурсы в Expression Design, потребуется явно выбрать Resource Dictionary (Словарь ресурсов) вместо Canvas (Холст) в списке Document Format (Формат документа). Другая причина, по которой важно понимать внутреннюю "кухню" двухмерной графики, состоит в том, что ею намного легче манипулировать. Например, изменить стандартную двухмерную графику можно, модифицируя кисти, используемые для рисования различных фигур, применяя трансформации к индивидуальным геометриям, а также изменяя прозрачность всего слоя фигур (через объект DrawingGroup). Еще важнее то, что можно добавлять, удалять либо изменять индивидуальные геометрии. Эти приемы легко комбинировать с анимацией, о которой речь пойдет в главах 15 и 16. Например, очень легко поворачивать объект Geometry, модифицируя свойство Angle объекта RotateTransform, "проявлять" слой фигур, используя DrawingGroup.Opacity, или создавать эффект вращающегося градиента, выполняя анимацию кисти LinearGradientBrush, которая закрашивает GeometryDrawing. Совет. При желании можно исследовать ресурсы, применяемые в других приложениях WPF Базовый прием заключается в использовании инструмента вроде .NET Reflector (http:// www.red-gate.com/products/dotnet-development/reflector/) для нахождения сборки с ресурсами. Затем с помощью подключаемого модуля .NET Reflector (http:// reflectoraddins.codeplex.com) можно извлечь один из ресурсов ВАМ1_и декомпилировать его обратно в XAML Стоит заметить, что большинство компаний не особо благосклонно относятся к разработчикам, которые заимствуют их вручную созданную графику для использования в собственных приложениях. Резюме В этой главе более глубоко изучалась модель рисования двухмерной графики WPF. Глава начиналась со знакомства с классом Path — наиболее мощным из классов фигур WPF — и использующей его геометрической моделью. Затем было рассмотрено применение геометрии для построения рисунка и использование этого рисунка для отображения облегченной, не интерактивной графики. В следующей главе будет показано, как с помощью низкоуровневого класса Visual выполнять визуализацию вручную.
ГЛАВА 14 Эффекты и класс Visual Предыдущие две главы были посвящены ключевым концепциям двухмерной графики в WPF. Теперь, располагая основательными знаниями основ — фигур, кистей, трансформаций и рисунков, — стоит углубиться в изучение наиболее низкоуровневых графических средств. Обычно к этим средствам обращаются тогда, когда производительность становится проблемой или нужен доступ к индивидуальным пикселям (либо то и другое). В этой главе рассматриваются три технологии WPF, которые помогают в этом. • Класс Visual. Если требуется построить программу для рисования векторной графики или планируется создать полотно с тысячами фигур, которыми можно манипулировать индивидуально, система элементов WPF и классы фигур только замедлят работу. Вместо этого нужен более прямолинейный подход, который состоит в применении низкоуровневого класса Visual для осуществления визуализации вручную. • Построители текстур. Если необходимо применять сложные визуальные эффекты (такие как размытие и настройка цветов), то самый простой способ изменения индивидуальных пикселей состоит в применении построителя текстуры. Лучше всего то, что все построители текстур опираются на аппаратную поддержку для повышения производительности, и существует множество готовых эффектов, которые можно применить в своем приложении с минимальными усилиями. • Класс WriteableBitmap. Класс WrirteableBitmap хотя и требует намного больше работы, он позволяет получить полную власть над растровым изображением, в том смысле, что можно устанавливать и читать любые его пиксели. Это средство применяется в сценариях визуализации сложных данных (например, при отображении научных данных) либо для создания броских эффектов с нуля. Что нового? Хотя в предыдущих версиях WPF поддерживались растровые эффекты, в WPF 3.5 SP1 появилась новая модель эффектов. Теперь исходные растровые эффекты считаются устаревшими, потому что они не поддерживают аппаратного ускорения (и не будут поддерживать). В WPF 3.5 SP1 также был введен класс WriteableBitmap, который рассматривается в этой главе. Классы Visual В предыдущей главе были продемонстрированы лучшие способы обращения с небольшими объемами графического содержимого. За счет использования геометрии, рисунков и путей сокращаются накладные расходы, связанные с двухмерной графикой. Этот подход работает хорошо, даже если применяются сложные составные фигуры с многослойными эффектами и градиентными кистями.
382 Глава 14. Эффекты и класс Visual Однако такое проектное решение не подходит для приложений с интенсивной графикой, которым приходится отображать громадное число графических элементов. Например, подумайте о картографической программе, программе физического моделирования, демонстрирующей столкновение частиц, либо игре с прокруткой игрового поля. Проблемы, встающие перед такими приложениями, заключаются не в сложности графики, а в чудовищном количестве индивидуальных графических элементов. Даже если заменить элементы Path облегченными объектами Geometry, то накладные расходы все равно сведут на нет производительность приложения. Предлагаемое в WPF решение для такого рода ситуаций предусматривает использование низкоуровневой модели визуального уровня. Базовая идея состоит в том, что каждый графический элемент определяется как объект Visual, который чрезвычайно облегчен и требует меньших накладных расходов, чем объект Geometry или Path. После этого можно применять единственный элемент для отображения всех объектов Visual, присутствующих в окне. В следующих разделах будет показано, как создавать такие объекты, манипулировать ими и выполнять проверку попаданий (курсора мыши). Попутно будет построено базовое приложение для рисования векторной графики, которое позволит добавлять квадраты на поверхность рисования, выбирать их и перетаскивать по этой поверхности. Рисование объектов Visual Visual — это абстрактный класс, так что создавать его экземпляры нельзя. Вместо этого понадобится использовать один из его классов-наследников. К ним относится UIElement (корень модели элементов WPF), Viewport3DVisual (позволяющий отображать трехмерное содержимое, как описано в главе 27) и ContainerVisual (являющийся базовым контейнером, содержащим остальные объекты Visual). Но наиболее полезный класс-наследник — это DrawingVisual, унаследованный от ContainerVisual и добавляющий поддержку, необходимую для "рисования" графического содержимого, которое требуется поместить в визуальный объект. Чтобы нарисовать содержимое в DrawingVisual, вызывается метод DrawingVisual. RenderOpen(). Этот метод возвращает DrawingContext, который можно использовать для определения содержимого визуального объекта. Завершив рисование, следует вызвать DrawingContext.Close(). Вот как это выглядит: DrawingVisual visual = new DrawingVisual (); DrawingContext dc = visual.RenderOpen (); // Здесь выполняется рисование, dc.Close (); По сути, класс DrawingContext состоит из методов, которые добавляют некоторые графические детали к вашему визуальному объекту. Эти методы вызываются для рисования различных фигур, применения трансформаций, изменения прозрачности и т.п. В табл. 14.1 перечислены методы класса DrawingContext. Таблица 14.1. Методы DrawingContext Имя Описание DrawLine (), Рисует указанную фигуру в указанной точке, с заданным запол- DrawRectangleO, нением и контуром. Эти методы отражают фигуры, которые рас- DrawRoundedRectangleO сматривались в главе 12 и DrawEllipseO DrawGeometry () и Рисует более сложные объекты Geometry и Drawing DrawDrawingO
Глава 14. Эффекты и класс Visual 383 Окончание табл. 14.1 Имя Описание DrawTextO DrawImageO DrawVideoO Pop () PushClipO PushEffectO PushOpacityO и PushOpacityMask() PushTransformO Рисует текст в указанном месте. Передачей объекта FormattedText этому методу указывается текст, шрифт, заполнение и прочие детали. DrawTextO можно использовать для рисования текста с переносами, если установить свойство FormattedText.MaxTextWidth Рисует растровое изображение в указанной области (определенной в Rect) Отображает видеосодержимое (помещенное в объект-оболочку MediaPlayer) в определенной области. Все подробности отображения видео в WPF изложены в главе 26 Отменяет действие последнего вызова метода PushXxx(). Метод PushXxxO используется для временного применения одного или более эффектов, а метод Pop () — для его отмены Ограничивает рисование определенной областью. Содержимое, выходящее за его пределы, не рисуется Применяет BitmapEf feet к последующим операциям рисования Применяет новые установки прозрачности или маску прозрачности (см. главу 12), чтобы сделать последующие операции рисования частично прозрачными Устанавливает объект Transform, который будет применен к последующим операциям рисования. Трансформации могут быть использованы для масштабирования, перемещения, поворота или скоса содержимого Ниже показан пример создания визуального элемента, содержащего простой черный треугольник без заполнения. DrawingVisual visual = new DrawingVisual(); using (DrawingContext dc = visual.RenderOpen ()) { Pen drawingPen = new Pen(Brushes.Black, 3); dc.DrawLine(drawingPen, new Point @, 50), new PointE0, 0) ) ; dc.DrawLine(drawingPen, new Point E0, 0), new PointA00, 50)); dc.DrawLine(drawingPen, new Point @, 50), new PointA00, 50)); } Вызов методов DrawingContext на самом деле не приводит к рисованию визуального элемента — с их помощью просто определяется его внешний вид. По завершении операции вызовом Close () готовый рисунок помещается в визуальный элемент и предоставляется доступным только для чтения свойством DrawingVisual.Drawing. Среда WPF запоминает объект Drawing, так что при необходимости он может перерисовать окно. Порядок кода рисования важен. Более поздние операции могут нарисовать содержимое поверх того, что нарисовано ранее. Методы PushXxxO задают настройки, которые будут применены к будущим операциям рисования. Например, PushOpacityO можно использовать для изменения уровня прозрачности, который затронет все последующие операции рисования. Метод Pop О может быть вызван для отмены действия последнего метода PushXxxO. В случае вызова более одного метода PushXxxO отключать их действие по одному можно последовательными вызовами Pop ().
384 Глава 14. Эффекты и класс Visual После закрытия DrawingContext модифицировать визуальный объект больше нельзя. Однако можно применить трансформацию или изменить общую прозрачность визуального объекта (с помощью свойств Transform и Opacity класса DrawingVisual). Чтобы установить полностью новое содержимое, понадобится снова вызвать RenderOpenO и повторить процесс рисования. Совет. Многие методы рисования используют объекты Реп и Brush. Если вы планируете рисовать много визуальных объектов с одинаковым контуром и заполнением или собираетесь отображать один и тот же визуальный объект много раз (чтобы изменить его содержимое), стоит создать нужные объекты Реп и Brush заранее и хранить их на протяжении всего времени жизни окна. Визуальные объекты используются несколькими разными способами. В оставшейся части настоящей главы будет показано, как поместить DrawingVisual в окно и выполнить проверку попадания в него. Элемент DrawingVisual можно также применять для определения содержимого, которое нужно вывести на печать (глава 29). И, наконец, визуальные объекты можно использовать для отображения специальных элементов, переопределив метод OnRenderO, как объясняется в главе 18. Фактически, это точно повторяет работу классов фигур, которые рассматривались в главе 12. Например, ниже приведен код визуализации, с помощью которого элемент Rectangle рисует себя: protected override void OnRender(DrawingContext drawingContext) { Pen pen = base.GetPen (); drawingContext.DrawRoundedRectangle(base.Fill, pen, this._rect, this.RadiusX, this.RadiusY); } Помещение визуальных объектов в оболочку элемента Определение визуального объекта — наиболее важный шаг в программировании визуального уровня, но его не достаточно для того, чтобы в действительности отобразить его содержимое на экране. Для отображения визуального объекта понадобится помощь полноценного элемента WPF, который добавит его в визуальное дерево. На первый взгляд это может показаться сокращением преимуществ программирования визуального уровня — в конце концов, мы же хотели избежать элементов с их накладными расходами? Однако единственный элемент может отображать неограниченное количество вложенных в него элементов. Поэтому несложно создать окно, которое содержит в себе только один или два элемента, но при этом включает тысячи визуальных объектов. Ниже описаны шаги, необходимые для размещения визуальных объектов в элементе. • Вызов методов AddVisualChildO и AddLogicalChildO элемента для регистрации визуального объекта. Формально эти действия не являются необходимыми для того, чтобы визуальный элемент появился, но они нужны для того, чтобы гарантировать их корректное отслеживание, появление в визуальном и логическом дереве, а также взаимодействие с другими средствами WPF, такими как проверка попадания. • Переопределение свойства VisualChildrenCountH возврат количества добавленных визуальных объектов. • Переопределение метода GetVisualChildO и добавление кода, необходимого для возврата визуального элемента по индексу.
Глава 14. Эффекты и класс Visual 385 Переопределение VisualChildrenCount и GetVisualChildO, по сути, означает "заимствование" этого элемента. Если используется элемент управления содержимым, декоратор или панель, которая может содержать вложенные элементы, то эти элементы больше не будут визуализироваться. Например, переопределив эти два метода в пользовательском окне, вы не увидите остального содержимого этого окна. Вместо этого будут видны только добавленные визуальные объекты. По этой причине принято создавать выделенный пользовательский класс, служащий оболочкой для визуальных объектов, которые необходимо отобразить. Например, рассмотрим окно, показанное на рис. 14.1. Оно позволяет пользователю добавлять квадраты (каждый из которых является визуальным объектом) в специальный контейнер Canvas. ■ V.sualLayer Setect/Move i 1 " L_l Add Squwe X Delete Square □ □ [ft □ Рис. 14.1. Рисование визуальных объектов В левой части окна на рис. 14.1 расположена панель инструментов с тремя объектами RadioButton. Как будет показано в главе 25, элемент ToolBar изменяет способ отображения некоторых базовых элементов управления, таких как кнопки. Используя группу объектов RadioButton, можно создать набор взаимосвязанных кнопок. При щелчке на одной из них она выбирается и остается "нажатой", в то время как ранее выбранная кнопка возвращается к своему нормальному виду. В правой части окна, представленного на рис. 14.1, находится специализированный Canvas по имени DrawingCanvas, который хранит внутри себя коллекцию визуальных элементов. DrawingCanvas возвращает общее количество квадратов в свойстве VisualChildrenCount и использует метод GetVisualChildO для доступа к каждому визуальному объекту в коллекции. Вот как это реализовано: public class DrawingCanvas : Canvas { private List<Visual> visuals = new List<Visual> (); protected override int VisualChildrenCount { get { return visuals.Count; } } protected override Visual GetVisualChild(int index) { return visuals[index]; }
386 Глава 14. Эффекты и класс Visual Вдобавок DrawingCanvas включает методы AddVisualO и DeleteVisualO,.чтобы облегчить клиентскому коду задачу включения визуальных объектов в коллекцию, с соответствующим отслеживанием этого процесса: public void AddVisual(Visual visual) { visuals.Add(visual); base.AddVisualChild(visual); base.AddLogicalChild(visual) ; } public void DeleteVisual(Visual visual) { visuals.Remove(visual); base.RemoveVisualChiId(visual) ; base.RemoveLogicalChild(visual); } } DrawingCanvas не содержит логики рисования квадратов, их выбора и перемещения. Причина в том, что данная функциональность поддерживается на уровне приложений. Такой подход оправдан, потому что может существовать несколько инструментов рисования, и все они будут работать с одним контейнером DrawingCanvas. В зависимости от того, на какой кнопке щелкнул пользователь, он может получить возможность рисования фигур разного типа либо использовать разные линии для контуров и цвета заполнения. Все эти детали специфичны для окна; DrawingCanvas просто обеспечивает функциональность размещения ваших визуальных объектов, их визуализации и отслеживания. Ниже показано, как элемент DrawingCanvas объявляется в XAML-разметке окна: <local:DrawingCanvas x:Name="drawingSurface" Background="White" ClipToBounds="True" MouseLeftButtonDown="drawingSurface_MouseLeftButtonDown" MouseLeftButtonUp="drawingSurface_MouseLeftButtonUp" MouseMove="drawingSurface_MouseMove" /> Совет. Устанавливая белый (а не прозрачный) фон, можно перехватывать все щелчки кнопками мыши на поверхности Canvas. Рассмотрев контейнер DrawingCanvas, стоит обратиться к коду обработки событий, создающему квадраты. Начальная точка — обработчик события для MouseLef tButton. Здесь определяется, какая операция выполняется — создание квадрата, его удаление или выбор. Пока сосредоточимся на первой задаче. private void drawingSurface_MouseLeftButtonDown (object sender, MouseButtonEventArgs e) { Point pointClicked = e.GetPosition(drawingSurface); if (cmdAdd.IsChecked == true) { // Создание, рисование и добавление нового квадрата. DrawingVisual visual = new DrawingVisual (); DrawSquare (visual, pointClicked, false); drawingSurface.AddVisual(visual); } }
Глава 14. Эффекты и класс Visual 387 Действительная работа выполняется специальным методом по имени DrawSquare(). Такой подход удобен, поскольку рисование квадрата должно инициироваться в нескольких разных местах кода. Очевидно, что для первоначального создания квадрата понадобится DrawSquare(). Этот метод также будет использован при изменении внешнего вида квадрата по любой причине (например, при его выборе). Метод DrawSquareO принимает три параметра: объект DrawingVisual, который нужно рисовать, точку, указывающую левый верхний угол квадрата, и булевский флаг, указывающий, выбран ли данный квадрат в настоящий момент — в этом случае он получает другой цвет заполнения. Ниже показан предварительный код визуализации. // Константы рисования. private Brush drawingBrush = Brushes.AliceBlue; private Brush selectedDrawingBrush = Brushes.LightGoldenrodYellow; private Pen drawingPen = new Pen(Brushes.SteelBlue, 3); private Size squareSize = new Size C0, 30); private void DrawSquare(DrawingVisual visual, Point topLeftCorner, bool isSelected) { using (DrawingContext dc = visual.RenderOpen ()) { Brush brush = drawingBrush; if (isSelected) brush = selectedDrawingBrush; dc.DrawRectangle(brush, drawingPen, new Rect(topLeftCorner, squareSize)); } } Это все, что понадобится для отображения визуального элемента в окне: некоторый код, визуализирующий объект Visual, и контейнер, отслеживающий необходимые детали. Однако если к визуальным объектам нужно добавить интерактивность, придется приложить еще немного усилий, как будет показано в последующих разделах. Проверка попадания Приложение рисования квадратов не только позволяет пользователям рисовать квадраты, но также должно давать возможность перемещать и удалять существующие квадраты. Чтобы выполнять обе эти операции, код должен уметь интерпретировать щелчки кнопками мыши и находить визуальный элемент по месту щелчка. Данная задача называется проверкой попадания (hit testing). Для поддержки проверки попадания имеет смысл добавить в класс DrawingCanvas метод GetVisual (). Этот метод принимает точку и возвращает соответствующий DrawingVisual. Для выполнения этой работы он использует статический метод VisualTreeHelper.HitTest(). Ниже приведен полный код метода GetVisual (). public DrawingVisual GetVisual(Point point) { HitTestResult hitResult = VisualTreeHelper.HitTest (this, point); return hitResult.VisualHit as DrawingVisual; } В этом случае код игнорирует любой объект, не являющийся DrawingVisual, включая сам DrawingCanvas. Если щелчок не пришелся ни на один из квадратов, метод GetVisuaK) возвращает null. Средство удаления квадратов использует метод GetVisual (). Когда выбрана команда удаления и выполнен щелчок на квадрате, обработчик события MouseLeftButtonDown использует следующий код, чтобы удалить его:
388 Глава 14. Эффекты и класс Visual else if (cmdDelete.IsChecked == true) { DrawingVisual visual = drawingSurface.GetVisual(pointClicked); if (visual != null) drawingSurface.DeleteVisual(visual); } Похожий код поддерживает операцию перетаскивания, но ему необходим способ отслеживания того, что квадрат находится в процессе перетаскивания. Для этой цели служат три поля в классе окна: isDragging, selectedVisual и clickOf f set: private bool isDragging = false; private DrawingVisual selectedVisual; private Vector clickOffset; Когда пользователь выполняет щелчок на фигуре, поле lsDragged устанавливается в true, поле selectedVisual — в визуальный элемент, на котором совершен щелчок, а в clickOffset записывается смещение между левым верхним углом квадрата и точкой, где пользователь щелкнул. Ниже приведен код из обработчика события MouseLeftButtonDown. else if (cmdSelectMove.IsChecked == true) { DrawingVisual visual = drawingSurface.GetVisual(pointClicked); if (visual != null) { // Найти верхний левый угол квадрата. // Это делается путем просмотра текущих границ //и удалением половины границы (толщины пера). // Альтернативное решение может состоять в сохранении // верхней левой точки каждого визуального элемента в коллекции // DrawingCanvas и предоставлении этой точки при проверке попадания. Point topLeftCorner = new Point ( visual.ContentBounds.TopLeft.X + drawingPen.Thickness / 2, visual.ContentBounds.TopLeft.Y + drawingPen.Thickness / 2); DrawSquare(visual, topLeftCorner, true); clickOffset = topLeftCorner - pointClicked; isDragging = true; if (selectedVisual != null && selectedVisual '= visual) { // Выбор изменился. Очистить предыдущий выбор. ClearSelection () ; } selectedVisual = visual; } } Наряду с обычной работой этот код также вызывает DrawingSquareO для отображения DrawingVisual, применяя новый цвет. Код также включает другой специальный метод по имени CreateSelectionO для перерисовки ранее выбранного квадрата, чтобы вернуть его к нормальному виду. private void ClearSelection () { Point topLeftCorner = new Point ( selectedVisual.ContentBounds.TopLeft.X + drawingPen.Thickness / 2, selectedVisual.ContentBounds.TopLeft.Y + drawingPen.Thickness / 2) ; DrawSquare(selectedVisual, topLeftCorner, false); selectedVisual = null; }
Глава 14. Эффекты и класс Visual 389 На заметку! Следует помнить, что метод DrawSquare () определяет содержимое квадрата — он в действительности не выполняет рисования в окне. По этой причине не нужно заботиться о том, что можно нечаянно нарисовать поверх другого квадрата, который должен быть снизу. WPF управляет процессом рисования, гарантируя, что визуальные объекты будут нарисованы в том порядке, в каком их вернет метод GetVisualChildO (т.е. в порядке их определения внутри коллекции визуальных объектов). Затем понадобится переместить квадрат, который перетаскивает пользователь, и завершить операцию перетаскивания, когда пользователь отпустит левую кнопку мыши. Обе эти задачи решаются достаточно простым кодом обработки событий: private void drawingSurface_MouseMove(object sender, MouseEventArgs e) { if (lsDragging) { Point pointDragged = e.GetPosition(drawingSurface) + clickOffset; DrawSquare(selectedVisual, pointDragged, true); } } private void drawingSurface_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { lsDragging = false; } Сложная проверка попадания В предыдущем примере код проверки попадания всегда возвращает самый верхний визуальный элемент (или null-ссылку, если щелчок пришелся на пустое пространство). Однако класс VisualTreeHelper включает две перегрузки метода HitTestO, которые позволяют выполнить более сложную проверку попадания. Используя эти методы, можно извлечь все визуальные объекты, расположенные в определенной точке, даже если они находятся под другими визуальными объектами. Также можно найти все визуальные объекты, относящиеся к заданной геометрии. Чтобы воспользоваться таким более совершенным поведением проверки попадания, потребуется создать обратный вызов. Затем класс VisualTreeHelper выполнит проход по визуальным объектам сверху вниз (в порядке, обратном порядку их создания). Каждый раз при нахождении соответствия он будет выполнять обратный вызов с соответствующими деталями. После этого можно принять решение о прекращении поиска (в случае достаточно глубокого уровня) либо продолжении, пока остаются визуальные элементы. В приведенном ниже коде этот прием реализован за счет добавления метода GetVisualsO к классу DrawingCanvas. Метод GetVisualsO принимает объект Geometry, который он использует для проверки попадания. Он создает делегат обратного вызова, очищает коллекцию результатов проверки попадания и затем начинает процесс проверки, вызывая метод VisualTreeHelper.HitTest(). По завершении процесса он возвращает коллекцию всех найденных визуальных объектов. private List<DrawingVisual> hits = new List<DrawingVisual> () ; public List<DrawingVisual> GetVisuals(Geometry region) { // Очистить результаты предыдущего поиска. hits.Clear(); // Подготовить параметры для операции проверки // попадания (геометрию и обратный вызов).
390 Глава 14. Эффекты и класс Visual GeometryHitTestParameters parameters = new GeometryHitTestParameters(region); HitTestResultCallback callback = new HitTestResultCallback (this.HitTestCallback); // Поиск попаданий. VisualTreeHelper.HitTest(this, null, callback, parameters); return hits; } Совет. В этом примере обратный вызов реализуется отдельно определенным методом по имени HitTestResultCallback(). Как HitTestResultCallbackO, так и GetVisualsO используют коллекцию попаданий, так что ее нужно объявлять как поле-член. Однако это требование можно исключить за счет использования для обратного вызова анонимного метода, который может быть объявлен внутри GetVisualsO. Метод обратного вызова реализует поведение проверки попадания. Обычно объект HitTestResult предоставляет только одно свойство — VisualHit, но его можно привести к одному из двух типов-наследников, в зависимости от типа проводимой проверки попадания. При проверке точки на предмет попадания можно привести HitTestResult к PointHitTestResult, который представляет относительно неинтересное свойство PointHit, возвращающее исходную точку, используемую для выполнения проверки попадания. Но если осуществляется проверка попадания в объект Geometry, как в данном примере, HitTestResult можно привести к GeometryHitTestResult и получить доступ к свойству IntersectionDetail. Это свойство сообщает о том, покрывает ли геометрия полностью визуальный объект (Fullylnside), геометрия и объект просто перекрываются (Intersects), либо же проверяемая геометрия полностью находится внутри визуального объекта (FullyContains). И, наконец, в конце обратного вызова можно вернуть одно из двух значений из перечисления HitTestResultBehavior: Continue, чтобы продолжить поиск попаданий, или Stop — чтобы завершить процесс. private HitTestResultBehavior HitTestCallback(HitTestResult result) { GeometryHitTestResult geometryResult = (GeometryHitTestResult)result; DrawingVisual visual = result.VisualHit as DrawingVisual; // Попадание фиксируется, только если в точке найден объект // DrawingVisual, и он целиком находится в геометрии. if (visual '= null && geometryResult.IntersectionDetail == IntersectionDetail.Fullylnside) { hits.Add(visual); } return HitTestResultBehavior.Continue; } С использованием метода GetVisualsO можно создать сложный эффект рамки выбора, показанный на рис. 14.2. Здесь пользователь с помощью мыши рисует рамку вокруг группы квадратов. Затем приложение сообщает количество квадратов, попавших в область. Чтобы обеспечить выбор квадратов, окно просто добавляет еще один DrawingVisual в DrawingCanvas. Окно также сохраняет ссылку на рамку выбора в виде поля-члена, наряду с флагом isMultiSelecting, отслеживающим рисование рамки выбора, и поле по имени selectionSquareTopLef t, отслеживающее левый верхний угол текущей рамки выбора: private DrawingVisual selectionSquare; private bool isMultiSelecting = false; private Point selectionSquareTopLeft;
Глава 14. Эффекты и класс Visual 391 Рис. 14.2. Усовершенствованная проверка попадания Чтобы реализовать средство рамки выбора, понадобится добавить некоторый код к показанным ранее обработчикам событий. По щелчку кнопкой мыши необходимо создать рамку выбора, переключить isMultiSelecting в true и захватить мышь. Ниже приведен код, выполняющий эту работу в обработчике события MouseLef tButtonDown. else if (cmdSelectMultiple.IsChecked == true) { selectionSquare = new DrawingVisual (); drawingSurface.AddVisual(selectionSquare); selectionSquareTopLeft = pointClicked; isMultiSelecting = true; // Гарантировать получение события MouseLeftButtonUp, даже // если пользователь вышел за пределы Canvas. В противном // случае могут нарисоваться два квадрата сразу. drawingSurface.CaptureMouse(); Теперь при перемещении мыши можно проверять, активна ли в данный момент рамка выбора, и если да, то рисовать ее. Для этого понадобится следующий код в обработчике событий MouseMove: else if (isMultiSelecting) { Point pointDragged = e.GetPosition(drawingSurface); DrawSelectionSquare(selectionSquareTopLeft, pointDragged); } Собственно рисование происходит в выделенном методе по имени DrawSelectionSquare (), который немного похож на рассмотренный ранее DrawSquare(): private Brush selectionSquareBrush = Brushes.Transparent; private Pen selectionSquarePen = new Pen(Brushes.Black, 2); private void DrawSelectionSquare(Point pointl, Point point2) { selectionSquarePen.DashStyle = DashStyles.Dash; using (DrawingContext dc = selectionSquare.RenderOpen ()) { dc.DrawRectangle(selectionSquareBrush, selectionSquarePen, new Rect(pointl, point2)); }
392 Глава 14. Эффекты и класс Visual И, наконец, когда пользователь отпускает кнопку мыши, необходимо снова выполнить проверку попадания, отобразить окно сообщения и затем удалить выбранный квадрат. Для этого понадобится следующий код в обработчике события MouseLef tButtonUp: if (isMultiSelecting) { // Отобразить все квадраты в этой области. RectangleGeometry geometry = new RectangleGeometry( new Rect(selectionSquareTopLeft, e.GetPosition(drawingSurface)))/ List<DrawingVisual> visualsInRegion = drawingSurface.GetVisuals(geometry); MessageBox.Show(String.Format ("You selected {0} square(s) .", visualsInRegion.Count)); isMultiSelecting = false/ drawingSurface.DeleteVisual(selectionSquare); drawingSurface.ReleaseMouseCapture(); } Эффекты В WPF реализованы визуальные эффекты, которые можно применять к любому элементу. Целью эффектов является обеспечение простым декларативным способом улучшить визуальную привлекательность текста, изображений, кнопок и прочих элементов управления. Вместо того чтобы писать собственный код рисования, просто используется один из классов, унаследованных от Effect (из пространства имен System. Windows. Media.Effects), для получения мгновенных эффектов, таких как размытие, блеск и отбрасывание теней. В табл. 14.2 перечислены доступные эффекты. Таблица 14.2. Эффекты Имя Описание Свойства BlurEf f ect Размывает содержимое элемента Radius, KernelType, RenderingBias DropShadowEffect Добавляет прямоугольную тень, BlurRadius, Color, Direction, отбрасываемую элементом Opacity, ShadowDepth, RenderingBias ShadowEffect Применяет построитель текстуры, PixelShader определенный на языке HLSL (High Level Shading Language — высокоуровневый языктекстурирования) и уже скомпилированный Классы-наследники Effect, перечисленные в табл. 14.2, не следует путать с классами растровых эффектов, которые наследуются от класса BitmapEf feet из того же пространства имен. Хотя растровые эффекты имеют сходную модель программирования, с ними связано несколько существенных ограничений. • Растровые эффекты не поддерживают построителей текстур — наиболее мощного и гибкого способа создания многократно используемых эффектов. • Растровые эффекты реализованы в неуправляемом коде, поэтому требуют полностью доверенного приложения. Следовательно, применять растровые эффекты в приложениях ХВАР, основанных на браузерах, нельзя. • Растровые эффекты всегда визуализируются программным путем и не используют ресурсов видеокарты. Это делает их медленными, особенно когда приходится иметь дело с множеством элементов или элементами с большой визуальной поверхностью.
Глава 14. Эффекты и класс Visual 393 ■ BlurEffects fc^-ed ЫмвУ] Класс BitmapEf f ect ведет свою родословную от первой версии WPF, которая не включала класса Effect. Растровые эффекты оставлены лишь для обратной совместимости. В следующих разделах модель эффектов рассматривается более подробно. Будут продемонстрированы три унаследованных от Effect класса: BlurEf f ect, DropShadowEf f ect и ShaderEffeet. BlurEffect Простейший из классов эффектов WPF является BlurEffect. Он размывает содержимое элемента, как если смотреть на него через расфокусированную линзу. Степень размытия повышается увеличением значения свойства Radius (которое по умолчанию имеет значение 5). Чтобы использовать любой эффект, необходимо создать соответствующий объект эффекта и установить его в свойстве Effect соответствующего элемента: <Button Content="Blurred (Radius=2)" Padding=" Margin="> <Button.Effect> <BlurEffect Radius="></BlurEffect> </Button.Effect> </Button> Рис. 14.3. Размытые кнопки На рис. 14.3 показаны три разных варианта размытия (со значениями 2, 5 и 20 для свойства Radius), примененные к группе кнопок. Класс DropShadowEf feet Класс DropShadowEf feet позволяет добавить слегка сдвинутую тень позади элемента. Для управления этим эффектом предназначено несколько свойств, перечисленных в табл. 14.3. Таблица 14.3. Свойства DropShadowEf feet Имя Описание Color Устанавливает цвет отбрасываемой тени (по умолчанию — черный) ShadowDepth Определяет расстояние тени от содержимого в пикселях (по умолчанию — 5). Значение ShadowDepth, равное 0, создает эффект ореола вокруг содержимого BlurRadius Размывает отбрасываемую тень, подобно свойству Radius класса BlurEffect (по умолчанию равно 5) Opacity Делает отбрасываемую тень частично прозрачной, используя дробное значение между 1 (полностью непрозрачная, по умолчанию) и 0 (полностью прозрачная) Direction Указывает положение отбрасываемой тени относительно содержимого в виде значения угла от 0 до 360. Значение 0 позволяет разместить тень справа, а большие значения поворачивают тень против часовой стрелки. По умолчанию принято значение 315, которое размещает тень справа внизу от элемента
394 Глава 14. Эффекты и класс Visual На рис. 14.4 показано несколько разных эффектов отбрасывания тени для TextBlock. Разметка, описывающая эти эффекты, выглядит следующим образом: <TextBlock FontSize=0" Margin="> <TextBlock.Effect> <DropShadowEffectx/DropShadowEffect> </TextBlock.Effect> <TextBlock.Text>Basic dropshadow</TextBlock.Text> </TextBlock> <TextBlock FontSize=0" Margin="> <TextBlock.Effect> <DropShadowEffect Color="SlateBlue"></DropShadowEffect> </TextBlock.Effect> <TextBlock.Text>Light blue dropshadow</TextBlock.Text> </TextBlock> <TextBlock FontSize=0" Foreground="WhiteM Margin="> <TextBlock.Effect> <DropShadowEffect BlurRadius=5"></DropShadowEffect> </TextBlock.Effect> <TextBlock.Text>Blurred dropshadow with white text</TextBlock.Text> </TextBlock> <TextBlock FontSize=0" Foreground="Magenta" Margin="> <TextBlock.Effect> <DropShadowEffect ShadowDepth=M0"></DropShadowEffect> </TextBlock.Effect> <TextBlock.Text>Close dropshadow</TextBlock.Text> </TextBlock> <TextBlock FontSize=0" Foreground="LimeGreen" Margin="> <TextBlock.Effect> <DropShadowEffect ShadowDepth= 5"></DropShadowEffect> </TextBlock.Effect> <TextBlock.Text>Distant dropshadow</TextBlock.Text> </TextBlock> i DropShadowEffects Basic dropshadow Light blue dropshadow Close dropshadow Distant dropshadow Рис. 14.4. Различные отбрасываемые тени Класса для групповых эффектов не предусмотрено, а это означает, что применять к элементу можно только по одному эффекту за раз. Тем не менее, множественные эффекты можно эмулировать, добавляя их к контейнерам более высокого уровня (например, использовать эффект отбрасывания тени для TextBlock и поместить его в панель
Глава 14. Эффекты и класс Visual 395 StackPanel, к которой применен эффект размытия). В большинстве случаев лучше избегать такого обходного пути, потому что он увеличивает объем работы по визуализации и снижает производительность. Вместо этого следует поискать одиночный эффект, который сделает все, что нужно. Класс ShaderEffect Класс ShaderEf feet не представляет готового к использованию эффекта. Это — абстрактный класс, от которого наследуются специальные построители текстур. С помощью ShaderEf feet (или специального эффекта, унаследованного от него) появляется возможность пойти дальше простого размытия и отбрасывания теней. В противоположность ожиданиям, логика, реализующая построитель текстуры, не написана на С# непосредственно в классе эффекта. Построители текстур написаны на языке HLSL (High Level Shader Language — высокоуровневый язык текстурирования), который является частью DirectX. (Преимущества очевидны — поскольку DirectX и HLSL существуют уже много лет, за это время разработчики графики уже создали массу процедур для построителей текстур, которые можно использовать в собственном коде.) Для создания построителя текстуры понадобится писать код HLSL. Первый шаг состоит в установке комплекта для разработчика DirectX SDK (http://msdn.microsoft, com/en-us/directx/default.aspx). Это предоставит все необходимое для создания и компиляции кода HLSL в файлы .ps (с помощью инструмента командной строки fxc.exe), которые нужны для использования специального класса ShaderEf feet. Но более удобным выбором является инструмент Shazzam (http://shazzam-tool.com) — редактор файлов HLSL, включающий возможность их опробования на примерах изображений. Он также содержит несколько примеров построителей текстур, которые можно применять в качестве основы для создания собственных. Более опытные пользователи могут также попробовать средство FX Composer от NVidia (http://developer.nvidia. com/object/fx_composer_home.html) — инструмент создания теней, предназначенный для разработчиков современных игр и прочих экспертов в области графики. Хотя написание собственных файлов HLSL выходит за рамки настоящей книги, внимание будет уделено работе с существующими файлами HLSL. После компиляции файла HLSL в файл .ps его можно использовать в проекте. Просто добавьте файл в существующий проект WPF, выберите его в окне Solution Explorer и установите для него Build Action (Действие сборки) в Resource (ресурс). Останется еще создать специальный класс-наследник ShadowEffect, использующий этот ресурс. Например, ниже показан код для применения специального построителя текстуры, который скомпилирован в файл по имени Effect.ps: public class CustomEffect : ShaderEffect { public CustomEffect () { // Использовать для ссылки на ресурс синтаксис URI, описанный в главе 7. // AssemblyName;component/ResourceFileName Uri pixelShaderUri = new Uri ("Effect.ps", UnKind.Relative) ; // Загрузить информацию из файла .ps. PixelShader = new PixelShader(); PixelShader.UriSource = pixelShaderUri; } } После этого можно использовать специальный построитель текстуры в любом окне. Сначала сделайте пространство имен доступным, добавив следующее отображение: <Window xmlns:local="clr-namespace:CustomEffectTest" ...>
396 Глава 14. Эффекты и класс Visual Теперь создайте экземпляр специального класса эффекта и укажите его в свойстве Effect элемента: <Image> <Image.Effect> <local :CustomEf fectx/local :CustomEf fect> </Image.Effect> </Image> Пример можно несколько усложнить за счет использования построителя текстуры, принимающего некоторые входные аргументы. В этом случае понадобится создать соответствующие свойства зависимости, вызвав статический метод RegisterPixelShaderSamplerProperty(). Сложный построитель текстуры может быть настолько же мощным, как подключаемые модули, применяемые в программном обеспечении обработки графики вроде Adobe Photoshop. Он может делать все, что угодно: от добавления базовой отбрасываемой тени до импорта более сложных эффектов — вроде размывания, блеска, водяной ряби, тиснения, повышенной резкости и т.п. Построители текстур могут также создавать бросающиеся в глаза эффекты, когда они комбинируются с анимацией, изменяющей параметры во времени, как будет показано в главе 16. Совет. Собственноручное написание кода HLSL является наилучшим способом получения наиболее развитых построителей текстур только для ассов в программировании графики. Другим же следует обратить внимание на примеры HLSL или компоненты WPF от независимых поставщиков, которые предлагают специальные классы эффектов. Хорошим стандартом является свободно распространяемая библиотека Windows Prsentation Foundation Pixel Shader Effects Library, доступная по адресу http://codeplex.com/wpffx. Она включает длинный список изощренных эффектов, таких как водовороты, инверсия цветов и операции с пикселями. Вдобавок она содержит транзитивные эффекты, которые комбинируют построители текстур с возможностями анимации, описанными в главе 15. Класс WriteableBitmap WPF позволяет отображать растровые изображения с помощью элемента Image. Однако подобное отображение — дорога с односторонним движением. Приложение берет готовое растровое изображение, читает его и отображает в окне. Сам по себе элемент Image не позволяет ни создавать, ни редактировать информацию растрового изображения. Здесь на помощь приходит WriteableBitmap. Этот класс унаследован от BitmapSource — класса, который используется при установке свойства Image. Sour се (либо напрямую, во время установки графического изображения в коде, либо неявно, когда это делается в XAML-разметке). В то время как BitmapSource является доступным только для чтения отражением данных растрового изображения, объект WriteableBitmap — это модифицируемый массив пикселей, который открывает перед разработчиком множество интересных возможностей. На заметку! Важно понять, что WriteableBitmap — не лучший способ рисования графического приложения в большинстве приложений. Если нужна низкоуровневая альтернатива системе элементов WPF, следует начать с того, что демонстрировал класс visual ранее в этой главе Например, класс visual — блестящий инструмент для создания инструмента построения диаграмм или игры с анимацией. Класс WriteableBitmap больше подходит для приложений, в которых нужна манипуляция отдельными пикселями, например, генератору фракталов, акустическому анализатору, инструменту визуализации научных данных либо приложению, которое обра-
Глава 14. Эффекты и класс Visual 397 батывает низкоуровневые графические данные, полученные от внешнего аппаратного устройства (вроде веб-камеры). Хотя WriteableBitmap обеспечивает тонкий контроль, это довольно сложный класс, который требует написания большего объема кода, чем другие подходы Генерация растрового изображения Чтобы сгенерировать растровое изображение с помощью WriteableBitmap, необходимо предоставить несколько ключевых фрагментов информации: ширину и высоту в пикселях, разрешение DPI по обоим измерениям, а также формат графического изображения. Ниже приведен пример создания графического изображения размером с текущее окно: WriteableBitmap wb = new WriteableBitmap((int)this.ActualWidth, (int)this.ActualHeight, 96, 96, PixelFormats.Bgra32, null); Перечисление PixelFormats включает в себя длинный список пиксельных форматов, но только около половины из них являются записываемыми форматами и поддерживаются классом WriteableBitmap. Ниже описаны значения, которые можно использовать. • Bgra32. Этот формат (выбранный в текущем примере) использует 32-битный цвет sRGB. Это значит, что каждый пиксель представлен 32 битами, или 4 байтами. Первый байт представляет уровень синего канала (в виде числа от 0 до 256), второй байт — уровень зеленого канала, третий — красного, а четвертый — альфа- значение (где 0 означает полную прозрачность, а 255 — полную непрозрачность). Порядок цветов (blue, green, red, alpha — синий, зеленый, красный, альфа) соответствует буквам в имени Вдга32. • Вдг32. Этот формат использует 4 байта на пиксель, как и Вдга32. Отличие состоит в том, что канал альфа игнорируется. Данный формат можно применять, когда не требуется прозрачность. • Pbgra32. Этот формат использует 4 байта на пиксель, как и Вдга32. Отличие связано со способом обработки полупрозрачных пикселей. Для того чтобы оптимизировать производительность вычислений прозрачности, каждый байт цвета умножен в обратном порядке (premultiplied; отсюда Р в Pbgra32). Это значит, что каждый байт цвета умножен на значение альфа и разделен на 255. Поэтому частично прозрачный пиксель, имеющий значения В, G, R, А B55, 100, 0, 200) в Вдга32, будет представлен в Pbgra32 как B00, 78, 0, 200). • BlackWhite, Gray2, Gray4, Gray8. Это форматы черно-белого отображения и отображения с оттенками серого. Число, следующее за Gray, соответствует количеству битов на пиксель. Таким образом, эти форматы компактны, но не поддерживают цвета. • Indexedl, Indexed2, Indexed4, Indexed8. Это индексированные форматы, т.е. каждый пиксель указывает на значение в палитре цвета. При использовании одного из этих форматов в качестве последнего аргумента конструктора WriteableBitmap должен передаваться соответствующий объект ColorPalette. Число, следующее за Indexed, соответствует количеству битов на пиксель. Индексированные форматы компактны, но несколько сложнее в работе, и поддерживают меньше цветов — 2, 14, 16 или 256, соответственно. Первые три формата — Вдга32, ВдгЗ^ и РЬдга32 — используются чаще других.
398 Глава 14. Эффекты и класс Visual Запись в WriteableBitmap После создания объект WriteableBitmap имеет нулевые значения во всех байтах. По сути, это — большой черный прямоугольник. Для заполнения WriteableBitmap содержимым используется метод WritePixelsO, который копирует массив байтов в указанную позицию растрового изображения. Метод WritePixelsO можно вызывать для установки одиночного пикселя, всего растрового изображения либо выбранной прямоугольной области. Для извлечения пикселей из WriteableBitmap предназначен метод CopyPixels (), который передает нужные байты в байтовый массив. Методы WritePixelsO и CopyPixels() в сочетании не обеспечивают удобную программную модель, но это та цена, которую приходится платить за возможность низкоуровневого доступа к пикселям. Для успешного использования WritePixels () необходимо понимать формат изображения и то, как в нем кодируются пиксели в байты. Например, в 32-разрядном типе растрового изображения Bgra32 каждый пиксель требует 4 байта — по одному для синего, зеленого, красного и альфа компонентов. Ниже показано, как установить их вручную и затем передать в массив: byte blue =100; byte green = 50; byte red =50; byte alpha = 255; byte [ ] colorData = {blue, green, red, alpha}; Обратите внимание, что здесь важен порядок. В байтовом массиве должна выдерживаться последовательность синей, зеленой, красной и альфа-составляющей, согласно стандарту Bgra32. При вызове методу WritePixelsO передается экземпляр Int32Rect, указывающий прямоугольную область растрового изображения, которую требуется обновить. Int32Rect включает в себя четыре фрагмента информации: координаты X и Y верхнего левого угла обновляемой области, а также ее ширину и высоту. В следующем коде принимается массив colorData, показанный в предыдущем коде, который затем используется для установки первого пикселя в WriteableBitmap: // Обновить одиночный пиксель. Область начинается с @,0) //и имеет размер в 1 пиксель в ширину и 1 пиксель в высоту. Int32Rect rect = new Int32Rect@, 0, 1, 1) ; // Записать 4 байта из массива в растровое изображение, wb.WritePixels(rect, colorData, 4, 0) ; С помощью такого подхода можно создать код процедуры, которая генерирует WriteableBitmap. Понадобится лишь пройти в цикле по всем столбцам и строкам изображения, обновляя по одному пикселю за каждую итерацию. for (int x = 0; х < wb.PixelWidth; x++) { for (int у = 0; у < wb.PixelHeight; y++) { // Вычислить цвет пикселя по выбранной формуле. byte blue = . . . byte green = . . . byte red = . . . byte alpha = . . . // Создать байтовый массив. byte [ ] colorData = {blue, green, red, alpha}; // Указать позицию рисования пикселя. Int32Rect rect = new Int32Rect(x, y, 1, 1);
Глава 14. Эффекты и класс Visual 399 // Вычислить шаг. int stride = wb.PixelWidth * wb.Format.BitsPerPixel / 8; // Записать пиксель. wb.WritePixels(rect, colorData, stride, 0) ; } } Этот код содержит одну дополнительную деталь: вычисление шага (stride), которого требует метод WritePixels(). Формально он представляет собой количество байт, необходимых для отображения каждой строки данных пикселей. Его можно вычислить, умножая количество пикселей в строке на число бит, приходящихся на пиксель в используемом формате (обычно 4, как в формате Bgra32, который применялся в этом примере), и затем разделить результат на 8, чтобы преобразовать биты в байты. После завершения процесса генерации пикселей необходимо отобразить финальное растровое изображение. Обычно для этого используется элемент Image: img.Source = wb; Даже после записи и показа растрового изображения допускается читать и модифицировать пиксели в WriteableBitmap. Это дает возможность строить более специализированные процедуры для редактирования растровых изображений и проверки попадания в них. Более эффективная запись пикселей Хотя показанный в предыдущем разделе код успешно работает, на самом деле это — не лучший подход. Когда требуется писать большой объем данных за раз, или даже все изображение сразу, лучше иметь дело с более крупными частями. Дело в том, что вызов WritePixelsO сопряжен с некоторыми накладными расходами, и чем чаще он вызывается, тем больше задерживается приложение. На рис. 14.5 показано тестовое приложение, которое входит в набор примеров для настоящей главы. Оно создает динамическое растровое изображение, заполняя пиксели случайным шаблоном с регулярной сеткой. В коде эта задача решается двумя разными способами: с помощью пиксельного подхода, описанного в предыдущем разделе, и с использованием стратегии однократной записи, которая рассматривается ниже. Запустив приложение, вы заметите, что однократная запись работает намного быстрее. Рис. 14.5. Динамически сгенерированное растровое изображение
400 Глава 14. Эффекты и класс Visual Совет. Более практичный (и длинный) пример применения WriteableBitmap можно найти по адресу http://tinyurl.com/y8hnvsl; в нем класс WriteableBitmap используется для моделирования химической реакции. Чтобы обновлять более одного пикселя за раз, нужно разобраться, каким образом пиксели упакованы вместе в байтовом массиве. Независимо от используемого формата, буфер обновления будет содержать одномерный массив байтов. Этот массив содержит значения, описывающие пиксели в прямоугольной области изображения, начиная с левого верхнего угла и заполняя строки сверху вниз. Для нахождения определенного пикселя используется следующая формула, которая пропускает нужное количество строк вниз, и затем переходит к соответствующей позиции в строке: (у х wb.PixelWidth + х) х BytesPerPixel Например, для установки пикселя D0, 100) в растровом изображении Bgra32 (в котором на пиксель отводится 4 байта) применяется следующий код: int pixelOffset = D0 + 100 * wb.PixelWidth) * wb.Format.BitsPerPixel/8; pixels[pixelOffset] = blue; pixels[pixelOffset + 1] = green; pixels[pixelOffset + 2] = red; pixels[pixelOffset + 3] = alpha; С учетом этого полный код, который создает растровое изображение, показанное на рис. 14.5, сначала заполняет все данные в одном массиве, а затем копирует его в WriteableBitmap единственным вызовом WriteaPixels(): // Создать растровое изображение с размерами заполнителя для изображения. WriteableBitmap wb = new WriteableBitmap((int)img.Width, (int)lmg.Height, 96, 96, PixelFormats.Bgra32, null); // Определить квадрат обновления (с размерами, соответствующими всему изображению). Int32Rect rect = new Int32Rect@, 0, (int)lmg.Width, (int)lmg.Height); byte[] pixels = new byte[(int)img.Width * (int)lmg.Height * wb.Format.BitsPerPixel / 8]; Random rand = new Random () ; for (int у = 0; у < wb.PixelHeight; y++) { for (int x = 0; x < wb.PixelWidth; x++) { int alpha = 0; int red = 0; int green = 0; int blue = 0; // Определить цвет пикселя. if ( (x % 5 == 0) || (у % 7 == 0) ) { red = (int)((double)у / wb.PixelHeight * 255); green = rand.Next A00, 255); blue = (int) ((double)x / wb. PixelWidth * 255); alpha = 255; } else { red = (int) ((double)x / wb. PixelWidth * 255); green = rand.NextA00, 255); blue = (int)((double)у / wb.PixelHeight * 255); alpha = 50; }
Глава 14. Эффекты и класс Visual 401 int pixelOffset = (х + у * wb.PixelWidth) * wb.Format.BitsPerPixel/8; pixels[pixelOffset] = (byte)blue; pixels[pixelOffset + 1] = (byte)green; pixels[pixelOffset + 2] = (byte)red; pixels[pixelOffset + 3] = (byte)alpha; } // Скопировать байтовый массив в изображение за один шаг. int stride = (wb.PixelWidth * wb.Format.BitsPerPixel) / 8; wb.WritePixels(rect, pixels, stride, 0) ; } // Отобразить растровое изображение в элементе Image. img.Source = wb; В реальном приложении, скорее всего, будет выбран подход, который находится где-то между двумя описанными выше. Запись по одному пикселю за раз, когда нужно обновить большой кусок растрового изображения, не подойдет, потому что она окажется слишком медленной. Но хранить в памяти все данные изображения тоже годится, т.к. они занимают слишком много места. (В конце концов, изображение размером 1000x1000 пикселей, с 4 байтами на пиксель, потребует около 4 Мбайт памяти, что не особо много, но и не мало.) Вместо этого следует стараться писать большие порции изображения, а не индивидуальные пиксели, особенно при генерации всего растрового изображения за раз. Совет. Если требуется выполнять частые обновления данных изображения в WriteableBitmap и делать это из другого потока, можно еще более оптимизировать код, используя фоновый буфер WriteableBitmap. Базовый процесс выглядит следующим образом: с помощью метода Lock () зарезервировать фоновый буфер, получить указатель на этот буфер, обновить его, указать измененную область вызовом AddDirtyRectO и затем снять блокировку с фонового буфера методом Unlock(). Этот процесс потребует небезопасного кода, что выходит за рамки настоящей книги, но в разделе, посвященном WriteableBitmap, справочной системы Visual Studio представлен соответствующий пример. Резюме В этой главе рассматривались три темы, выходящие за рамки стандартной поддержки двумерной графики WPF. Сначала был описан низкоуровневый визуальный уровень, который представляет собой наиболее эффективный способ отображения графики в WPF. Вы видели, как с использованием визуального уровня строить базовое приложение рисования, в котором реализована изощренная логика проверки попадания. Затем рассказывалось о построителях текстур, изначально спроектированных для построения игр следующего поколения, которые теперь можно применять в любом приложении WPF. Вы убедились, что построители текстур не только просто использовать — также существует огромная библиотека свободно распространяемых построителей текстур, которые можно применять в разрабатываемых приложениях. И, наконец, был представлен класс WriteableBitmap — мощное, но более ограниченное средство, позволяющее создавать растровое изображение и непосредственно манипулировать им на уровне отдельных пикселей, из которых оно состоит.
ГЛАВА 15 Основы анимации Анимация позволяет создавать по-настоящему динамические пользовательские интерфейсы. Она часто используется для создания различных эффектов, например, пиктограмм, которые увеличиваются при перемещении над ними курсора мыши, вращающихся логотипов, прокручивающегося текста и т.п. Иногда такие эффекты выглядят чрезмерными. Но при правильном применении анимация может во многих отношениях усовершенствовать приложение. Она может повысить реактивность приложения, сделать его более естественным и интуитивно понятным. (Например, кнопка, сдвигающаяся при щелчке на ней, выглядит более реальной, физической кнопкой, а не просто очередным серым прямоугольником.) Анимация также может привлечь внимание к наиболее важным элементам и служить своеобразным проводником для пользователя в его странствиях по новому содержимому. (Например, приложение может рекламировать только что загруженное содержимое за счет мерцающей пиктограммы в строке состояния.) Анимация — центральная часть модели WPF. Это значит, что для приведения ее в действие не понадобится использовать таймеры и код обработки событий. Анимация создается декларативно, конфигурируется с помощью нескольких классов и запускается в действие вообще без написания кода С#. Также анимация совершенно незаметно интегрируется в обычные окна и страницы WPF. Например, кнопка, для которой выполнена анимация, заставляющая ее дрейфовать в окне, тем не менее, продолжает вести себя как кнопка. Ее можно стилизовать, она принимает фокус и на ней можно щелкнуть, запустив обычный код обработчика события. Именно это отличает анимацию от традиционных медиафайлов, таких как видео. (В главе 26 будет показано, как поместить видео-окно в приложение. Видео-окно — это полностью отдельная область приложения; она может воспроизводить видео, но не может взаимодействовать с пользователем.) В этой главе вы ознакомитесь с богатым набором классов анимации, предлагаемым WPF. Вы увидите, как применять их в коде и (что делается чаще) как конструировать и управлять ими через XAML. Попутно будет продемонстрирован широкий набор примеров анимации, включая "затухающие" окна, вращающиеся кнопки и разворачивающиеся элементы. Что нового? В WPF 4 появилось средство, именуемое плавностью анимации, использующее математические формулы для создания более естественных анимационных эффектов. Вы узнаете, как оно работает, в разделе "Плавность анимации". WPF 4 также поддерживает кэширование растровых изображений — форму аппаратного ускорения, позволяющего оптимизировать использование центрального процессора при работе с определенными типами анимации. Этот прием рассматривается в разделе "Кэширование растровых изображений".
Глава 15. Основы анимации 403 Что собой представляет анимация WPF В предыдущих Windows-ориентированных платформах (вроде Windows Forms и MFC) разработчикам приходилось создавать собственные системы анимации с нуля. Наиболее распространенный прием заключался в применении таймера в сочетании с некоторой специальной логикой рисования. В WPF правила игры изменились и была предложена новая система анимации, основанная на свойствах (property-based). Разница между ними объясняется в следующих двух разделах. Анимация на основе таймера Предположим, что необходимо заставить кусок текста вращаться в окне About приложения Windows Forms. Ниже представлен традиционный способ решения этой проблемы. 1. Создайте таймер, который срабатывает периодически (скажем, каждые 50 миллисекунд). 2. Когда таймер сработает, воспользуйтесь обработчиком событий для вычисления некоторых деталей анимации, таких как новый угол поворота. Затем сделайте недействительным все окно либо его часть. 3. Сразу после этого Windows попросит окно перерисовать свое содержимое, запустив специальный код рисования. 4. В коде рисования визуализируйте повернутый текст. Хотя решение на основе таймера реализовать не так трудно, интеграция его в обычное окно приложения неоправданно сложна. Ниже описаны некоторые проблемы, возникающие при этом. • Рисуются пиксели, а не элементы управления. Чтобы повернуть текст в Windows Forms, придется обратиться к низкоуровневой поддержке рисования GDI+. Сделать это не сложно, однако это плохо сочетается с обычными элементами окна вроде кнопок, текстовых полей, меток и т.п. В результате придется отделять анимируе- мое содержимое от элементов управления, и в анимацию не получится включить элементы, способные взаимодействовать с пользователем. Скажем, реализовать вращающуюся кнопку не удастся. • Подразумевается единственная анимация. Если понадобится иметь две анимации, выполняющиеся одновременно, то придется переписать весь код анимации — и это существенно его усложнит. В этом отношении WPF намного мощнее, что позволяет строить более сложные анимации, состоящие из отдельных простых анимаций. • Частота кадров анимации фиксирована. Она определяется таймером. В случае изменения периода таймера может понадобиться изменить код анимации (в зависимости от того, как выполняются вычисления). Более того, выбранная фиксированная частота кадров не всегда подходит для конкретной видеосистемы компьютера. • Сложность кода анимации растет в геометрической прогрессии. Пример с вращающимся текстом достаточно прост, но, скажем, перемещение маленького векторного изображения по определенному пути — задача более сложная. В WPF даже самая замысловатая анимация может быть определена в XAML-разметке (и сгенерирована с применением визуальных инструментов от независимых разработчиков).
404 Глава 15. Основы анимации Даже без поддержки анимации WPF пример с вращающимся текстом можно упростить. Это связано с тем, что WPF предлагает более совершенную графическую модель, которая гарантирует, что окно будет автоматически перерисовано, если в нем произошли какие-то изменения. Это означает, что беспокоиться об объявлении окна недействительным и его перерисовке не понадобится. Вместо этого достаточно выполнить перечисленные ниже шаги. 1. Создайте периодически срабатывающий таймер. (Для этого WPF предоставляет класс System.Windows.Threading.DispatherTimer, работающий в потоке пользовательского интерфейса.) 2. Когда срабатывает таймер, воспользуйтесь обработчиком событий для вычисления некоторых связанных с анимацией деталей вроде нового утла, поворота. Затем модифицируйте соответствующие элементы. 3. WPF заметит проведенные изменения в элементах окна и перерисует (а также кэ- ширует) новое содержимое окна. При таком решении не придется иметь дело с низкоуровневыми классами для рисования, а также отделять анимируемое содержимое от обычных элементов того же самого окна. Хотя это, безусловно, усовершенствование, однако анимации на основе таймера все-таки присущ ряд недостатков: она приводит к появлению не слишком гибкого кода, серьезному усложнению его в случае, когда требуются более сложные эффекты, а также не обеспечивает максимально возможной производительности. Вместо этого WPF включает высокоуровневую модель, которая позволяет сосредоточиться на определении анимации, не беспокоясь о способе ее отображения. Эта модель основана на инфраструктуре свойств зависимости, которая будет описана в следующем разделе. Анимация на основе свойств Часто анимацию воспринимают как последовательность кадров. Чтобы выполнить анимацию, эти кадры отображаются друг за другом, подобно мультипликации. В WPF используется совершенно другая модель. По сути, анимация WPF — это просто способ модифицировать значение свойства зависимости через интервалы времени. Например, чтобы заставить кнопку растягиваться и сжиматься, в анимации можно модифицировать ее свойство Width. Чтобы заставить ее мерцать, можно изменять свойства кисти LinearGradientBrush, используемой для ее фона. Секрет создания правильной анимации кроется в определении того, какие именно свойства должны изменяться. Если нужно внести изменения, которые не могут быть обеспечены модификацией свойств, значит, вам не повезло. Например, добавлять или удалять элементы в процессе анимации нельзя. Аналогично не получится попросить WPF перейти от начальной сцены к конечной (хотя некоторые обходные пути позволяют эмулировать такой эффект). И, наконец, анимацию можно применять только к свойствам зависимости, поскольку лишь эти свойства используют систему динамического разрешения свойств (описанная в главе 4), которая учитывает анимацию. На первый взгляд сосредоточенная на свойствах природа анимации WPF кажется чрезвычайно ограниченной. Однако поработав с WPF, вы обнаружите, что она на самом деле очень удобна. Фактически можно реализовать широкий диапазон эффектов анимации, используя общие свойства, которые поддерживаются всеми элементами. Но следует признать, что существует немало случаев, когда система анимации на основе свойств не работает. В качестве эмпирического правила необходимо отметить, что анимация на базе свойств — отличный способ добавить динамические эффекты к обыч-
Глава 15. Основы анимации 405 ным в других отношениях Windows-приложениям. Например, если вы хотите соорудить привлекательный фасад для интерактивного инструмента электронной торговли, анимация на основе свойств будет работать замечательно. Однако если анимация должна использоваться для обеспечения основной функциональности приложения и работать на протяжении всего времени его существования, то для этого понадобится нечто более мощное и гибкое. Например, при создании аркадной игры, требующей сложных вычислений для моделирования коллизий, понадобится более высокая степень контроля, чем может обеспечить анимация. В таких ситуациях придется сделать большую часть работы самостоятельно, используя низкоуровневую поддержку визуализации на основе кадров WPF, которая будет описана в главе 16. Базовая анимация Ранее уже было упомянуто основное правило анимации WPF: каждая анимация работает на основе отдельного свойства зависимости. Однако имеется и другое ограничение. Для осуществления анимации свойства (другими словами, для изменения его значения с течением времени) понадобится класс анимации, поддерживающий его тип данных. Например, свойство Button.Width использует тип данных double. Для его анимации применяется класс DoubleAnimation. Однако Button.Padding использует структуру Thickness, поэтому ему потребуется класс ThicknessAnimation. Это требование не так строго, как первое правило WPF-анимации, ограничивающее анимацию свойствами зависимости. Причина в том, что для свойства зависимости, которое не имеет соответствующего класса анимации, можно создать собственный класс анимации для его типа данных. Тем не менее, в пространстве имен System.Windows. Animation доступны классы для большинства используемых типов данных. Многие типы данных не имеют соответствующих классов анимации, поскольку это непрактично. Примером могут служить перечисления. Например, размещением элемента в панели компоновки можно управлять с помощью свойства HorizontalAlignment, которое принимает значения из перечисления HorizontalAlignment. Однако перечисление HorizontalAlignment предлагает только четыре значения (Left, Right, Center и Stretch), что существенно ограничивает его применение в анимации. Хотя есть возможность переключаться с одной ориентации на другую, плавно перенести элемент от одного типа выравнивания к другому не удастся. По этой причине класс анимацци для типа данных HorizontalAlignment не предусмотрен. Его можно создать самостоятельно, однако ограничение этими четырьмя значениями перечисления все равно остается. Анимация ссылочных типов обычно не выполняется, однако делается для их под- свойств. Например, все элементы управления имеют свойство Background, что позволяет устанавливать объект Brush, используемый для рисования фона. Применять анимацию для переключения от одной кисти к другой не слишком эффективно, но можно использовать анимацию для изменения свойств кисти, например, варьировать свойство Color объекта SolidColorBrush (применяя для этого класс ColorAnimation) или же свойство Offset объекта Gradient Stop в составе LinearGradient (с помощью класса DoubleAnimation). Это расширяет возможности анимации WPF, позволяя анимировать специальные аспекты внешнего вида элементов. Классы анимации На основе упомянутых типов анимации — DoubleAnimation и ColorAnimation — можно предположить, что все классы анимации называются в стиле MMHTnnaAnimation. Это близко к истине, но не совсем так.
406 Глава 15. Основы анимации На самом деле существуют два вида анимации — та, которая изменяет свойства последовательно от начального до конечного значения (этот процесс называется линейной интерполяцией], и та, что произвольно изменяет свойство от одного значения к другому. Примерами из первой категории могут служить DoubleAnimation и ColorAnimation; они используют интерполяцию для гладкого изменения значения. Однако интерполяция не имеет смысла при изменении определенных типов данных, таких как строки и объекты ссылочных типов. Вместо применения интерполяции эти типы данных изменяются скачкообразно в определенный момент времени, для чего применяется техника, называемая анимацией ключевого кадра (key frame animation). Все классы анимации ключевого кадра носят имена в форме MMHTnnaAnimationUsingKeyFrames — например, StringAnimationUsingKeyFrames и ObjectAnimationUsingKeyFrames. Некоторые типы данных имеют класс анимации ключевого кадра, но не имеют класса анимации методом интерполяции. Например, можно выполнять анимацию строки с использованием ключевых кадров, но не методом интерполяции. Однако каждый тип данных поддерживает анимацию ключевого кадра или же поддержка анимации вообще отсутствует. Другими словами, каждый тип данных, имеющий соответствующий ему класс анимации методом интерполяции (вроде DoubleAnimation и ColorAnimation), также имеет соответствующий тип анимации ключевого кадра (такой как DoubleAnimationUsingKeyFrames и ColorAnimationUsingKeyFrames). По правде говоря, есть еще один тип анимации. Третий тип называется анимацией на основе пути (path-based animation), и этот тип более специализирован, чем анимация методом интерполяции или анимация ключевого кадра. Анимация на основе пути модифицирует значение в соответствии с фигурой, описанной в объекте PathGeometry, и в первую очередь применяется для перемещения элемента по некоторому пути. Классы для анимации на основе пути имеют имена в стиле MMHTMnaAnimationUsingPath, например, DoubleAnimationUsingPath или PointAnimationUsingPath. На заметку! Хотя в настоящее время в WPF используются три подхода к анимации (линейная интерполяция, ключевые кадры и пути), ничто не мешает создавать классы анимации, которые модифицируют значения на основе совершенно другого подхода. Единственное требование — класс анимации должен модифицировать значения с течением времени. В конечном итоге, вот что можно обнаружить в пространстве имен System.Windows. Media.Animation: • 17 классов MMHTmiaAnimation, использующих анимацию методом интерполяции; • 22 класса MMHTmiaAnimationUsingKeyFrames, использующих анимацию ключевого кадра; • 3 класса MMHTnnaAnimationUsingPath, использующих анимацию на основе пути. Все эти классы анимации унаследованы от абстрактного класса MMHTnnaAnimation Base, реализующего несколько основополагающих аспектов. Он предоставляет основу для создания собственных классов анимации. Если тип данных поддерживает более одного типа анимации, то все его классы анимации наследуются от абстрактного базового класса. Например, DoubleAnimation и DoubleAnimationUsingKeyFrames —оба являются наследниками DoubleAnimationBase. На заметку! Этими 42 классами содержимое пространства имен System.Windows.Media. Animation не исчерпывается. Каждая анимация ключевого кадра также работает с собственным классом ключевого кадра и классом коллекции ключевых кадров Так что в сумме пространство имен System.Windows.Media.Animation содержит более 100 классов.
Глава 15. Основы анимации 407 Можете легко определить, какие типы данных имеют готовую поддержку анимации, быстро просмотрев эти 42 класса. Ниже представлен их полный список. BooleanAnimationUsingKeyFrames PointAnimationUsingKeyFrames ByteAnimation PointAnimationUsingPath ByteAnimationUsingKeyFrames Point3DAnimation CharAnimationUsingKeyFrames Point3DAnimationUsingKeyFrames ColorAnimation QuarternionAnimation ColorAnimationUsingKeyFrames Quartern ion An imationUsingKey Frames DecimalAnimation RectAnimation DecimalAnimationUsingKeyFrames RectAnimationUsingKeyFrames DoubleAnimation Rotation3DAnimation DoubleAnimationUsingKeyFrames Rotation3DAnimationUsingKeyFrames DoubleAnimationUsingPath SingleAnimation Intl6Animation SingleAnimationUsingKeyFrames Intl6AnimationUsingKeyFrames SizeAnimation Int32Animation SizeAnimationUsingKeyFrames Int32AnimationUsingKeyFrames StringAnimationUsingKeyFrames Int64Animation ThicknessAnimation Int64AnimationUsingKeyFrames ThicknessAnimationUsingKeyFrames MatrixAnimationUsingKeyFrames VectorAnimation MatrixAnimationUsingPath VectorAnimationUsingKeyFrames ObjectAnimationUsingKeyFrames Vector3DAnimation PointAnimation Ve сtor3DAnimationUsingKeyFrames Многие из этих типов самоочевидны и не требуют пояснений. Например, если вы разберетесь с классом DoubleAnimation, то не придется долго думать, чтобы понять SingleAnimation, Intl6Animation, Int32Animation, а также все прочие классы анимации, сопровождающие простые числовые типы, которые работают аналогично. Наряду с классами анимации простых числовых типов существует несколько классов, работающих с прочими базовыми типами данных (byte, bool, string и char), а также много больше, которые имеют дело с двумерными и трехмерными рисованными Drawing (Point, Size, Rect, Vector и т.д.). Кроме того, предусмотрены классы анимации для свойств Margin и Padding любого элемента (ThicknessAnimation), один для цвета (ColorAnimation) и один для любого объекта ссылочного типа (ObjectAnimationUsingKeyFrames). Многие из перечисленных классов анимации встретятся в примерах, рассматриваемых на протяжении этой главы. Беспорядочное пространство имен Animation Заглянув в пространство имен System.Windows.Media.Animation, вы будете несколько шокированы. Оно включает в себя различные классы анимации для самых разнообразных типов данных. В результате все это выглядит несколько неупорядоченным. Было бы неплохо иметь какой-то способ комбинации всех средств анимации в несколько классов ядра. И почему бы ни построить класс Animation<T>, который смог бы работать с любым типом данных? Однако, к сожалению, такая модель в настоящий момент невозможна по ряду причин. Главная из них состоит в том, что различные классы анимации могут выполнять свою работу несколько по-разному, а это требует разного кода. Например, способ перехода между полутонами одного цвета в классе ColorAnimation отличается от способа модификации отдельного числового значения в классе DoubleAnimation. Другими словами, хотя классы анимации и предоставляют одинаковый
408 Глава 15. Основы анимации общедоступный интерфейс, их внутренняя реализация может сильно отличаться. Интерфейсы этих классов1 стандартизованы через наследование, поскольку все классы анимации унаследованы от одних и тех же базовых классов (начиная с Animatable). Однако это еще не все. Естественно, многие классы анимации разделяют некоторый объем кода, а по некоторым вообще "плачут" обобщения, как, например, о сотне классов, используемых для представления ключевых кадров и коллекций ключевых кадров. В идеальном мире классы анимации отличались бы типом анимации, которую они выполняют, так что была бы возможность иметь дело с классами вроде NumericAnimation<T>, KeyFrameAnimation<T> или LinearInterpolationAnimation<T>. Можно предположить, что более глубокая причина того, что классы анимации не организованы подобным образом, связана с отсутствием прямой поддержки обобщений в XAML. Анимация в коде Как уже известно, наиболее распространенная техника анимации — это анимация методом интерполяции, при которой свойство плавно изменяется от начальной точки до конечной. Например, если установить в качестве начального значения 1, а конечного — 10, то свойство может быстро изменяться от 1 до 1.1, 1.2, 1.3 и т.д., пока не достигнет значения 10. Может возникнуть вопрос: как WPF определяет шаг инкремента для выполнения интерполяции? К счастью, это делается автоматически. WPF использует такой шаг инкремента, который необходим для выполнения плавной анимации при текущей частоте кадров видеосистемы. Стандартная частота, используемая WPF, составляет 60 кадров в секунду. (Далее в этой главе будет показано, как изменить эту настройку.) Другими словами, каждую одну шестидесятую часть секунды WPF вычисляет все анимируемые значения и обновляет соответствующие свойства. Простейший способ использования анимации предусматривает создание экземпляра одного из классов анимации, перечисленных выше, его конфигурирование и вызов метода BeginAnimationO элемента, который требуется модифицировать. Все элементы WPF наследуют метод BeginAnimationO, который является частью интерфейса IAnimatable, от базового класса UIElement. Другие классы, реализующие IAnimatable, включают ContentElement (базовый класс для битового содержимого потока документа) HVisual3D (базовый класс для трехмерных визуальных элементов). На заметку! Это не самый распространенный подход. Во многих ситуациях анимации создаются декларативно, с помощью XAML, как будет описано ниже в разделе "Раскадровки". Однако применение XAML немного более запутано, поскольку требует еще одного объекта, называемого раскадровкой (storyboard), для подключения анимации к соответствующему свойству. Анимации на основе кода также удобны в определенных сценариях, когда нужно использовать сложную логику для определения начального и конечного значений для анимации. На рис. 15.1 показан пример крайне простой анимации, расширяющей кнопку. После щелчка на кнопке WPF плавно раздвигает обе стороны кнопки, пока она не заполнит окно. Для создания этого эффекта используется анимация, модифицирующая свойство Width кнопки. Ниже приведен код, который создает и запускает эту анимацию при щелчке на кнопке. DoubleAnimation widthAmmation = new DoubleAnimation () ; widthAnimation.From = 160; widthAmmation .To = this.Width - 30; widthAnimation.Duration = TimeSpan.FromSecondsE) ; cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation);
Глава 15. Основы анимации 409 ■ ' SimpteAromation G<k and Make Me Grow ■ SimpteAntmation Cltck and Make Me Grow Рис. 15.1. Анимированная кнопка Есть три детали, которые составляют необходимый минимум для описания анимации, использующей линейную интерполяцию: начальное значение (From), конечное значение (То) и время, за которое анимация должна быть выполнена (Duration). В данном примере конечное значение основано на текущей ширине содержащего кнопку окна. Эти три свойства присутствуют во всех классах анимации методом интерполяции. Свойства From, To и Duration выглядят достаточно очевидными, но необходимо отметить несколько важных деталей. Эти свойства рассматриваются более подробно в последующих разделах. Свойство From Свойство From задает начальное значение для свойства Width. Если щелкать на кнопке несколько раз, то всякий раз ширина кнопки будет сбрасываться в 160, и анимация запустится вновь. Так будет, даже если щелкнуть на кнопке в процессе уже запущенной анимации. На заметку! В этом примере раскрывается еще одна сторона анимации WPF, а именно: всякое свойство зависимости должно обрабатываться только одной анимацией в каждый момент времени. Запуск второй анимации приводит к автоматической отмене первой. Во многих ситуациях не нужно, чтобы анимация всегда начиналась с исходного значения From. Обычно на то имеются две причины. • Есть анимация, которая может быть запущена многократно и при этом давать совокупный эффект. Например, требуется создать кнопку, которая становится чуть больше при каждом щелчке. • Есть анимации, которые могут перекрываться. Например, событие MouseEnter может использоваться для запуска анимации, расширяющей кнопку, а событие MouseLeave — для активизации дополняющей анимации, которая вернет кнопку к исходному размеру. (Это часто называют эффектом "рыбьего глаза".) Если многократно быстро перемещать курсор мыши на такую кнопку и обратно, то каждая анимация будет прерывать предыдущую, заставляя кнопку "прыгать" обратно к размеру, заданному в свойстве From.
410 Глава 15. Основы анимации Приведенный пример подпадает под вторую категорию. Если щелкнуть на кнопке в процессе ее роста, то ширина будет тут же сброшена до 160 пикселей, что может оказаться неприемлемым. Для решения этой проблемы просто исключите оператор, устанавливающий свойство From: DoubleAnimation widthAnimation = new DoubleAnimation(); widthAmmation.To = this.Width - 30; widthAnimation.Duration = TimeSpan.FromSecondsE); cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation); Существует одна сложность. Чтобы такой прием работал, анимируемое свойство должно иметь ранее установленное значение. В данном примере это означает, что кнопка должна иметь жестко закодированную ширину (заданную либо в дескрипторе кнопки, либо примененную установкой стиля). Проблема в том, что во многих контейнерах компоновки принято не указывать ширину, а позволять контейнеру управлять ею на основе свойств выравнивания элементов. В рассматриваемом случае применяется значение ширины по умолчанию, которое равно специальному значению Double.NaN (где NaN означает "not a number" — "не число"). Выполнять анимацию свойства, имеющего такое значение, с применением линейной интерполяции нельзя. Итак, каково же решение? Во многих случаях оно сводится к жесткому кодированию ширины кнопки. Как вскоре можно будет убедиться, анимация часто требует более точного управления размерами элементов и их позиционированием. Фактически, наиболее часто используемым контейнером компоновки для "анимируемого" содержимого является Canvas, поскольку он позволяет легко перемещать содержимое по своей поверхности (с возможностью перекрытия) и изменять его размер. Canvas также наиболее облегченный контейнер компоновки, поскольку ему не приходится выполнять никакой дополнительной работы по компоновке при изменении свойства, подобного Width. В текущем примере доступен и другой выбор. Извлечь текущее значение кнопки можно с использованием свойства ActualWidth, которое содержит текущую отображаемую ширину. Анимация свойства ActualWidth невозможна (оно доступно только для чтения), но можно использовать его для установки свойства From анимации: widthAnimation.From = cmdGrow.ActualWidth; Такой прием работает как с анимацией на основе кода (показанной в примере), так и с декларативной анимацией, которая рассматривается позже (что потребует применения выражения привязки для получения значения ActualWidth). На заметку! В этом примере важно пользоваться именно свойством ActualWidth, а не Width. Это связано с тем, что Width отражает желаемую ширину, которая была выбрана, а ActualWidth — используемую в данный момент текущую отображаемую ширину. В случае применения автоматической компоновки, скорее всего, значение Width не будет жестко кодироваться, так что свойство Width просто вернет Double.NaN, и при попытке запуска анимации возникнет исключение. При использовании текущего значения в качестве начальной точки анимации следует помнить об еще одной проблеме: это может изменить скорость анимации. Причина в том, что длительность анимации не подстраивается, чтобы учесть меньшую дистанцию между начальным и конечным значением. Например, предположим, что создается кнопка, которая не использует значение From и выполняет анимацию, начиная с текущей позиции. Если щелкнуть на кнопке в момент, когда она почти достигла своей максимальной ширины, начнется новая анимация. Эта анимация сконфигурирована так, чтобы длиться пять секунд (задано в свойстве Duration), и это несмотря на то, что до максимальной ширины ей останется несколько пикселей. В результате рост кнопки тут же замедлится.
Глава 15. Основы анимации 411 Этот эффект проявляется только при перезапуске анимации, которая почти завершена. Хотя это существенный недостаток, большинство разработчиков не пытаются написать код для его преодоления. Вместо этого просто считается, что такое поведение более-менее приемлемо. На заметку! Упомянутую проблему можно скомпенсировать, написав некоторую специальную логику, которая будет модифицировать длительность анимации, хотя это редко стоит потраченных усилий. Чтобы сделать это, необходимо сделать предположение относительно стандартного размера кнопки (что ограничит возможность повторного использования кода). Кроме того, для запуска этого кода анимация должна быть описана программно (а не декларативно, что общепринято, как вы вскоре убедитесь). Свойство То Точно так же, как можно опустить свойство From, можно не указывать и свойство То. Фактически можно не задавать оба свойства — и From, и То, — создав анимацию вроде следующей: DoubleAnimation widthAmmation = new DoubleAnimation(); widthAnimation.Duration = TimeSpan.FromSecondsE); cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation); На первый взгляд эта анимация выглядит как сложный способ не делать вообще ничего. Логично предположить, что поскольку опущены оба свойства From и То, они будут использовать одно и то же значение. Но между ними есть одно тонкое, однако, существенное отличие. В случае отсутствия From анимация использует текущее значение и учитывает состояние анимации. Например, если кнопка находится в процессе роста, значение From использует увеличенную ширину. Однако если не указано свойство То, анимация использует текущее значение, не принимая во внимание саму анимацию. По сути, это означает, что То принимает первоначальное значение — то, которое было последний раз установлено в коде, в дескрипторе элемента или с помощью стиля. (Это работает благодаря системе разрешения свойств WPF, которая в состоянии вычислить значение свойства на основе нескольких перекрывающихся поставщиков свойств, не отбрасывая никакой информации. Более подробно эта система описана в главе 4.) В примере с кнопкой это означает, что если запустить анимацию роста, а затем прервать ее анимацией, показанной ранее (возможно, щелчком на другой кнопке), то кнопка станет уменьшаться от того размера, до которого успела вырасти, и будет уменьшаться, пока не достигнет исходной ширины, указанной в разметке XAML. С другой стороны, если запустить этот код, когда никакая другая анимация не выполняется, то ничего не произойдет. Это объясняется тем, что значение From (анимируемая ширина) и значение То (исходная ширина) совпадают. Свойство By Вместо То можно использовать свойство By. Свойство By служит для создания анимации, которая изменяет значение на определенную величину, а не до определенной величины. Например, для создания анимации, которая увеличивает кнопку на 10 единиц больше ее текущего размера, служит следующий код: DoubleAnimation widthAnimation = new DoubleAnimation (); widthAnimation.By = 10; widthAnimation.Duration = TimeSpan.FromSeconds@.5); cmdGrowIncrementally.BeginAnimation(Button.WidthProperty, widthAnimation);
412 Глава 15. Основы анимации Такой подход не является необходимым в данном примере, поскольку того же результата можно достичь и с помощью простого вычисления при установке свойства То: widthAnimation.То = cmdGrowIncrementally.Width + 10; Однако значение By более осмысленно, когда анимация определяется в XAML- разметке, поскольку XAML не позволяет выполнять простые вычисления. На заметку! Значения By и From можно использовать совместно, но это не сократит объем работы Значение By просто добавляется к значению From для достижения значения То. Свойство By предоставляется большинством, хотя и не всеми классами, использующими интерполяцию. Например, оно не имеет смысла для нечисловых типов данных, таких как структура Color (применяемая в ColorAnimation). Есть только один способ получить аналогичное поведение без применения By — можно создать аддитивную анимацию, установив свойство Is Additive. После этого текущее значение будет автоматически добавляться к обоим значениям — From и То. Например, рассмотрим следующую анимацию: DoubleAnimation widthAnimation = new DoubleAnimation(); widthAnimation.From = 0; widthAnimation.To = -10; widthAnimation.Duration = TimeSpan.FromSeconds@.5); widthAnimation.IsAdditive = true; Она начинается с текущего значения и завершается на значении, уменьшенном на 10 единиц. С другой стороны, если используется показанная ниже анимация: DoubleAnimation widthAnimation = new DoubleAnimation(); widthAnimation.From = 10; widthAnimation.To = 50; widthAnimation.Duration = TimeSpan.FromSeconds@.5); widthAnimation.IsAdditive = true; то свойство перейдет к новому значению (которое на 10 единиц больше текущего) и затем будет расти до тех пор, пока не достигнет финальной величины, которая на 50 единиц больше текущего значения, существовавшего на момент старта анимации. Свойство Duration Свойство Duration достаточно очевидно — оно принимает временной интервал (в миллисекундах, минутах, часах или любых других желаемых единицах) между моментом запуска анимации и временем ее завершения. Хотя длительность анимации в предыдущих примерах установлена с использованием TimeSpan, свойство Duration на самом деле требует объекта Duration. К счастью, Duration и TimeSpan достаточно похожи, и структура Duration предусматривает неявное приведение, с помощью которого при необходимости можно преобразовать System.TimeSpan в System.Windows.Duration. Вот почему следующая строка кода вполне законна: widthAnimation.Duration = TimeSpan.FromSecondsE); Так зачем вводить целый новый тип? Duration также включает два специальных значения, которые не могут быть представлены объектом TimeSpan. Это Duration. Automatic и Duration.Forever. Ни одно из этих значений не применимо в текущем примере. (Automatic просто устанавливает анимацию в односекундную длительность, a Forever задает бесконечную длительность анимации, что предотвращает проявление какого-либо эффекта.) Однако эти значения могут оказаться удобными при создании более сложной анимации.
Глава 15. Основы анимации 413 Одновременные анимации Метод BeginAnimation() может использоваться для запуска более одной анимации одновременно. Метод BeginAnimationO возвращает управление почти мгновенно, позволяя применять код, подобный показанному ниже, чтобы выполнить анимацию одновременно двух свойств. DoubleAnimation widthAnimation = new DoubleAnimation (); widthAnimation.From = 160; widthAnimation.To = this.Width - 30; widthAnimation.Duration = TimeSpan.FromSecondsE); DoubleAnimation heightAnimation = new DoubleAnimation () ; heightAnimation.From = 40; heightAnimation.To = this.Height - 50; heightAnimation.Duration = TimeSpan.FromSeconds E); cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation); cmdGrow.BeginAnimation(Button.HeightProperty, heightAnimation); В данном примере две анимации не синхронизированы. Это значит, что ширина и высота не будут расти точно в течение одного интервала. (Обычно кнопка сначала растет в ширину, а потом — в высоту.) Это ограничение можно обойти, создав анимации, которые ограничены одной временной шкалой. Такой прием будет рассматриваться позже в главе, когда пойдет речь о раскадровках. Время жизни анимации Формально анимации WPF являются временными, а это означает, что они в действительности не изменяют значения лежащего в основе свойства. Пока анимация активна, она просто переопределяет значение свойства. Это связано со способом работы свойств зависимости (см. главу 4) и часто имеет скрытые детали, которые могут приводить к серьезной путанице. Однонаправленная анимация (как анимация роста кнопки) остается активной и после завершения ее работы. Это объясняется тем, что анимация должна удерживать ширину кнопки в новом размере. Это может привести к неожиданной проблеме, а именно: попытка модифицировать значение свойства в коде после завершения анимации никакого эффекта не дает. Причина в том, что код просто присваивает свойству новое локальное значение, но анимированное значение имеет приоритет перед ним. В зависимости от того, чего нужно достигнуть, эту проблему можно решить несколькими способами. • Создать анимацию, которая сбрасывает элементы в их исходное состояние. Это делается за счет не установки свойства То. Например, анимация уменьшения кнопки сокращает ее ширину к последнему установленному значению, после чего ширину можно изменять в коде. • Создать обратимую анимацию. Это делается посредством установки свойства AutoReverse в true. Например, когда завершается анимация увеличения кнопки, она запускается в обратном направлении, возвращая кнопку в исходное состояние. Общая длительность анимации при этом удваивается. • Изменить свойство FillBehavior. Изначально FillBehavior установлено в HoldEnd, а это означает, что когда анимация завершится, ее финальное значение будет применено к целевому свойству. Если изменить FillBehavior на Stop, то по завершении анимации свойство вернется к своему исходному значению. • Удалить объект анимации по ее завершении, обработав событие Completed объекта анимации.
414 Глава 15. Основы анимации Первые три способа изменяют поведение анимации. Так или иначе, они возвращают анимированному свойству его первоначальное значение. Если это не то, что нужно, воспользуйтесь последним способом. Прежде чем запустить анимацию, присоедините обработчик событий, который будет реагировать на завершение анимации: widthAnimation.Completed += animation_Completed; На заметку! Completed — это нормальное событие .NET, которое принимает обычный объект EventArgs с дополнительной информацией. Это не маршрутизируемое событие. Когда возникает событие Completed, можно вновь привести анимацию в действие, вызвав метод BeginAnimation(). Понадобится просто указать свойство и передать null-ссылку для объекта анимации: cmdGrow.BeginAnimation(Button.WidthProperty, null); При вызове BeginAnimation() свойство возвращается к значению, которое оно имело на момент запуска анимации. Если это не то, что требуется, можете запомнить значение, которое имело свойство перед анимацией, и затем установить его вручную, как показано ниже: double currentWidth = cmdGrow.Width; cmdGrow.BeginAnimation(Button.WidthProperty, null); cmdGrow.Width = currentWidth; Имейте в виду, что это изменит локальное значение свойства. Это может повлиять на работу других анимаций. Например, если для кнопки выполняется анимация без указания значения From, то в качестве начального используется установленное значение. В большинстве случаев это именно то, что нужно. Класс TimeLine Как было показано, каждая анимация вращается вокруг нескольких ключевых свойств. Вы уже ознакомились с некоторыми из них: From и То (представленными в классах анимации, использующих интерполяцию), а также Duration и FillBehavior (представленными во всех классах анимации). Перед тем, как двигаться дальше, стоит внимательнее взглянуть на свойства, с которыми придется работать. На рис. 15.2 показана иерархия наследования типов анимации WPF. В ней указаны базовые классы, но опущены 42 типа анимаций (вместе с соответствующими классами MMHTMnaAnimationBase). Иерархия классов включает три главных ветви, унаследованные от абстрактного класса TimeLine. Класс MediaTimeLine используется при воспроизведении аудио- и видеофайлов; это описано в главе 26. Класс AnimationTimeline служит для системы анимации на основе свойств, которая рассматривалась до сих пор. И, наконец, TimelineGroup позволяет синтезировать временные шкалы и управлять их воспроизведением. Это будет описано в разделе "Одновременные анимации'' далее в главе. Первые используемые члены появляются в классе Timeline, определяющем, помимо прочих, уже знакомое свойство Duration. Свойства этого класса перечислены в табл. 15.1. Хотя BeginTime, Duration, SpeedRatio и AutoReverse достаточно очевидны, некоторые другие свойства требуют более тщательного рассмотрения. В следующих разделах подробно описаны AccelerationRatio, DecelerationRatio и RepeatBehavior.
Глава 15. Основы анимации 415 DispatcherObject DependencyObject с 4 Freezable k Animatable 1 J ) Ч_ _ Условные шт обозначения Абстрактный класс Конкретный класс ^J Timeline MediaTimeline AnimationTimeline TimelineGroup DoubleAnimationBase ColorAnimationBase ParallelTimeline H DoubleAnimation Storyboard t_ T StringAnimationBase L DoubleAnimationUsingKeyFrames M DoubleAnimationUsingPath Рис. 15.2. Иерархия классов анимации Таблица 15.1. Свойства Timeline Имя Описание BeginTime Duration SpeedRatio Устанавливает задержку перед запуском анимации (как TimeSpan). Эта задержка добавляется к общему времени, так что пятисекундная анимация с пятисекундной задержкой займет в сумме десять секунд. Свойство BeginTime удобно для синхронизации разных анимаций, которые запускаются в одно и то же время, но должны выполнять свои действия последовательно Устанавливает длительность времени выполнения анимации, от старта до финиша, как объект Duration Увеличивает или уменьшает скорость анимации. Изначально SpeedRatio равно 1. Если его увеличить, то анимация завершится быстрее (например, SpeedRatio, равное 5, выполнит анимацию впятеро быстрее). Если уменьшить значение этого свойства, анимация замедлится (например, установка SpeedRatio в 0.5 приводит к получению анимации, выполняющейся вдвое дольше). Для получения того же результата можно также изменить свойство Duration анимации. Когда применяется задержка BeginTime, свойство SpeedRatio во внимание не принимается
416 Глава 15. Основы анимации Окончание табл. 15.1 Имя Описание AccelerationRatio и Делает анимацию нелинейной, так что она запускается медленно, за- DecelerationRatio тем происходит ускорение (за счет увеличения AccelerationRatio) либо замедление (при увеличении DecelerationRatio). Оба значения находятся в промежутке от 0 до 1 и начинаются с 0. Кроме того, сумма обоих величин не может превышать 1 AutoReverse Если это свойство равно true, то анимация будет запущена в обратном порядке, как только завершится. Если увеличить SpeedRatio, оно будет применено как к прямому воспроизведению анимации, так и к обратному Свойство BeginTime применяется только в самом начале анимации — задержки при запуске в обратном направлении не происходит FillBehavior Определяет то, что произойдет по завершении анимации. Обычно свойство остается зафиксированным в конечном значении (FillBehavior.HoldEnd), но можно также выбрать возврат к исходному значению (FillBehavior.Stop) RepeatBehavior Позволяет повторить анимацию заданное количество раз либо в течение указанного интервала времени. Конкретное поведение определяет объект RepeatBehavior, используемый для установки этого свойства Свойства AccelerationRatio и DecelerationRatio Свойства AccelerationRatio и DecelerationRatio позволяют сжать часть временной шкалы, так что она будет пройдена быстрее. Остальная часть временной шкалы будет сжата для компенсации этого, чтобы общее время осталось неизменным. Оба эти свойства представляют процентное значение. Например, AccelerationRatio, равное 0.3, указывает на то, что вы хотите потратить 30% общей длительности анимации на ускорение. Например, в десятисекундной анимации первые три секунды пройдут с ускорением, а остальные семь секунд — на постоянной скорости. (Очевидно, что скорость в эти последние семь секунд будет выше, чем в неускоренной анимации, поскольку необходимо выполнить медленный старт.) Если установить AccelerationRatio в 0.3 и DecelerationRatio в 0.3, то ускорение будет выполняться в первые 3 секунды, следующие 4 секунды пройдут на постоянной скорости, а в последние три секунды произойдет замедление. В таком случае ясно, что сумма AccelerationRatio и DecelerationRatio не может превышать 1, поскольку невозможно потратить больше 100% времени анимации на ее ускорение и замедление. Разумеется, можно установить AccelerationRatio в 1 (при этом скорость анимации будет расти от начала до ее конца) или же установить DecelerationRatio в 1 (анимация будет замедляться от начала до конца). Анимации с ускорением и замедлением часто используются для обеспечения более естественного поведения. Однако AccelerationRatio и DecelerationRatio предоставляют лишь относительный контроль. Например, эти свойства не дают возможности варьировать степень ускорения либо устанавливать ее специально. Если нужна анимация, использующая неравномерное ускорение, то придется определить последовательность анимаций и установить свойства AccelerationRatio и DecelerationRatio для каждой из них отдельно. Можно также воспользоваться анимацией ключевого кадра со сплайновыми кадрами (рассматривается в главе 16). Хотя такой прием обеспечивает определенную гибкость, отслеживание всех деталей затруднено, и для ее успешного применения желательно использовать инструмент дизайна, способный конструировать анимации.
Глава 15. Основы анимации 417 Свойство RepeatBehavior Свойство RepeatBehavior позволяет управлять повторениями анимации. Чтобы повторить анимацию фиксированное количество раз, передайте нужное число конструктору RepeatBehavior. Например, следующая анимация повторится дважды: DoubleAnimation widthAnimation = new DoubleAnimation () ;ч widthAnimation.To = this.Width - 30; widthAnimation.Duration = TimeSpan.FromSecondsE); widthAnimation.RepeatBehavior = new RepeatBehaviorB); cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation); После запуска этой анимации кнопка увеличится в размерах (в течение 5 секунд), прыжком вернется к исходному размеру и вырастет снова (опять в течение 5 секунд), заполнив всю ширину окна. Если установить AutoReverse в true, то поведение будет слегка отличаться. Полная анимация пройдет вперед и назад (т.е. кнопка вырастет, после чего уменьшится), а затем все повторится заново. На заметку! Анимации, использующие интерполяцию, предоставляют свойство IsCumulative, которое сообщает WPF, что нужно делать с каждым повтором. Если IsCumulative равно true, то анимация не повторяется от начала до конца. Вместо этого каждый последовательный ее проход добавляется к предыдущему. Например, в случае использования IsCumulative с анимацией, описанной выше, кнопка расширится в два раза больше за вдвое большее время. Другими словами, первая итерация выполнится нормально, а все последующие — так, будто свойство IsAdditive установлено в true. Вместо установки счетчика повторов свойство RepeatBehavior можно также применять его для установки интервала. Чтобы сделать это, просто передайте TimeSpan конструктору RepeatBehavior. Например, следующая анимация будет повторяться в течение 13 секунд: DoubleAnimation widthAnimation = new DoubleAnimation(); widthAnimation.To = this.Width - 30; widthAnimation.Duration = TimeSpan.FromSecondsE); widthAnimation.RepeatBehavior = new RepeatBehavior (TimeSpan. FromSeconds A3)) ; cmdGrow.BeginAnimation(Button.WidthProperty, widthAnimation); В данном примере свойство Duration указывает, что вся анимация занимает 5 секунд. В результате установка RepeatBehavior в 13 секунд вызовет два повтора и затем остановит рост кнопки на полпути при третьем проходе анимации (на отметке 3 секунды). Совет. Свойство RepeatBehavior можно использовать для выполнения только части анимации. Для этого понадобится указать дробную часть повторов или же применять значение TimeSpan, меньшее длительности анимации. И, наконец, можно заставить анимацию повторяться бесконечно, передав значение RepeatBehavior. Forever: widthAnimation.RepeatBehavior = RepeatBehavior.Forever; Раскадровки Как уже было показано, анимации WPF представлены группой классов анимации. Существенная информация, такая как начальное значение, конечное значение и длительность, задается с помощью нескольких свойств. Это очевидно делает их удобными
418 Глава 15. Основы анимации для использования в XAML-разметке. Что менее ясно — так это каким образом привязать анимацию к конкретному элементу и свойству, и как инициировать ее в нужное время. В результате, в любой декларативной анимации должны присутствовать два описанных ниже ингредиента. • Раскадровка. Это XAML-эквивалент метода BeginAmmation(). Он позволяет направить анимацию на корректный элемент и свойство. • Триггер события. Отвечает за изменением свойства или событие (такое как Click для кнопки) и управляет раскадровкой. Например, чтобы запустить анимацию, триггер события должен начать раскадровку. В последующих разделах вы узнаете, как работают оба ингредиента. Раскадровка Раскадровка (storyboard) — это усовершенствованная временная шкала. Ее можно применять для группирования множества анимаций и, кроме того, она позволяет управлять воспроизведением анимации — приостанавливать ее, прекращать и изменять текущую позицию. Однако самым базовым средством, предлагаемым классом StoryBoard, является способность указывать на определенное свойство и определенный элемент, используя свойства TargetProperty и TargetName. Другими словами, раскадровка заполняет пробел между анимацией и свойством, для которого должна осуществляться анимация. Ниже показано, как определить раскадровку, которая управляет анимацией DoubleAnimation: <Storyboard TargetName="cmdGrow" TargetProperty="Width"> <DoubleAnimation From=60" To=00" Duration=:0:5"></DoubleAnimation> </Storyboard> TargetProperty и TargetName представляют собой присоединенные свойства. Это означает, что их можно применять непосредственно к анимации: <Storyboard> <DoubleAnimation Storyboard.TargetName="cmdGrow" Storyboard.TargetProperty="Width" From=60" To=00" Duration=:0:5"></DoubleAnimation> </Storyboard> Приведенный синтаксис более распространен, поскольку он позволяет поместить несколько анимаций в одну раскадровку, но при этом позволяет каждой анимации воздействовать на разные элементы и свойства. Определение раскадровки — первый шаг в создании анимации. Чтобы действительно запустить эту раскадровку, понадобится триггер события. Триггеры событий Впервые триггеры событий упоминались в главе 11, когда речь шла о стилях. Стили предоставляют один из способов подключения триггера события к элементу. Тем не менее, триггер события может быть определен в четырех местах: • в стиле (коллекция Styles.Triggers); • в шаблоне данных (коллекция DataTemplate.Triggers); • в шаблоне элемента управления (коллекция ControlTemplate.Triggers); • непосредственно в элементе (коллекция FrameworkElement .Triggers).
Глава 15. Основы анимации 419 При создании триггера события нужно указать маршрутизируемое событие, которое запускает триггер, и действие (или действия), выполняемые триггером. В случае анимаций наиболее часто используемым действием является BeginStoryboard, которое эквивалентно вызову BeginAnimation(). В следующем примере коллекция Triggers кнопки применяется для присоединения анимации к событию Click. После щелчка кнопка начинает расти. <Button Padding=0" Name="cmdGrow" Height=0" Width=60" HorizontalAlignment="Center" VerticalAlignment="Center"> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To=00" Duration=:0:5"></DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> </Button.Triggers> <Button.Content> Click and Make Me Grow </Button.Content> </Button> Совет. Чтобы создать анимацию, которая запускается при первой загрузке окна, добавьте триггер события в коллекцию Window.Triggers, отвечающую на событие Window.Loaded. Свойство Storyboard.Target Property идентифицирует свойство, которое должно изменяться (в данном случае — Width). Если имя класса не указано, то раскадровка использует родительский элемент, которым является расширяемая кнопка. Чтобы установить присоединенное свойство (например, Canvas.Left или Canvas.Top), все свойство понадобится заключить в скобки, например: <DoubleAnimation Storyboard.TargetProperty="(Canvas.Left)" ... /> Свойство Storyboard.TargetName в данном примере не требуется. Если оно опущено, то раскадровка использует родительский элемент, которым является кнопка. На заметку! Все триггеры событий способны выполнять действия. Все действия представлены классами, унаследованными от System.Windows.TriggerAction. В настоящее время WPF включает очень небольшой набор действий, предназначенных для взаимодействия с раскадровкой и управления воспроизведением. Существует разница между представленным здесь декларативным подходом и подходом на основе только кода, который был продемонстрирован выше. В частности, значение То жестко закодировано в 300 единиц, а не установлено относительно содержащего кнопку окна. Чтобы использовать ширину окна, понадобится указать выражение привязки данных, вроде следующего: <DoubleAnimation Storyboard.TargetProperty="Width" То="{Binding ElementName=window/Path=Width}" Duration=:0:5"> </DoubleAnimation> Это все еще не дает в точности тот результат, который нужен. Здесь кнопка растет от текущего размера до полной ширины окна. Подход на основе только кода увеличива-
420 Глава 15. Основы анимации ет кнопку до размера на 30 единиц меньшего, чем полный размер, используя тривиальное вычисление. К сожалению, XAML не поддерживает встроенных вычислений. Одним из решений проблемы может быть построение интерфейса IValueConverter, который выполнит эту работу. К счастью, этот хитрый трюк несложен в реализации (и многим разработчикам приходится это делать). Пример применения такого подхода доступен по адресу http://tinyurl.com/y91glyu, а также в загружаемом коде для этой главы. На заметку! Другой вариант предусматривает создание в классе окна специального свойства зависимости, которое выполнит вычисление. После этого анимацию можно привязать к этому свойству зависимости. Более подробно о создании свойств зависимости читайте в главе 4. Теперь можно продублировать примеры, приведенные выше, создав триггеры и раскадровки и установив соответствующие свойства объекта DoubleAnimation. Присоединение триггеров к стилю Коллекция FrameworkElement.Triggers — довольно причудливая вещь. Она поддерживает только триггеры событий. Другие коллекции триггеров (Styles.Triggers, DataTemplate.Triggers и ControlTemplate.Triggers) являются более гибкими. Они поддерживают три базовых типа триггеров WPF: триггеры свойств, триггеры данных и триггеры событий. На заметку! Не существует никаких формальных причин, запрещающих FrameworkElement. Triggers поддерживать дополнительные типы триггеров, но эта функциональность не была реализована на момент выхода первой версии WPF. Использование триггеров событий — наиболее распространенный путь присоединения анимации. Однако это не единственный вариант. Применяя коллекцию Triggers в стиле, шаблоне данных либо в шаблоне элементов управления, можно также создать триггер свойства, который будет реагировать на изменение значения свойства. Например, ниже приведен стиль, который дублирует поведение рассмотренного выше примера. Он запускает раскадровку, когда IsPressed равно true. <Window.Resources> <Style x:Key="GrowButtonStyle"> <Style.Triggers> <Trigger Property="Button.IsPressed" Value="True"> <Trigger.EnterActions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To=50" Duration=:0:5"></DoubleAnimation> </Storyboard> </BeginStoryboard> </Trigger.EnterActions> </Trigger> </Style.Triggers> </Style> </Window.Resources> Присоединение действий к триггеру свойств осуществляется двумя способами. В коллекции Trigger.EnterActions можно установить действия, которые будут выполняться при изменении свойства на указанное значение (в предыдущем примере — когда IsPressed получает значение true), а в коллекции Trigger.ExitActions —действия, выполняемые при обратном изменении свойства (т.е. когда IsPressed вернется к значению false). Это удобный способ связать вместе пару взаимодополняющих анимаций.
Глава 15. Основы анимации 421 Вот как выглядит кнопка, использующая показанный выше стиль: <Button Padding=0" Name="cmdGrow" Height=0" Width=60" Style="{StaticResource GrowButtonStyle}" HonzontalAlignment="Center" VerticalAlignment="Center"> Click and Make Me Grow </Button> Помните, что использовать в стиле триггеры свойств не обязательно. Однако можно применять триггеры событий, как было показано в предыдущем разделе. И, наконец, определять стиль отдельно от использующей его кнопки также не обязательно (можно установить свойство Button.Style встроенным образом), но разделение на две части более распространено, и оно обеспечивает гибкость в применении одной и той же анимации к множеству элементов. На заметку! Триггеры также удобны, когда они встраиваются в шаблон элемента управления, что позволяет добавлять визуальные эффекты к стандартному элементу управления WPF. В главе 17 приведены многочисленные примеры шаблонов элементов управления, использующих анимацию, в том числе ListBox, анимирующий свои дочерние элементы с помощью триггеров. Перекрывающиеся анимации Раскадровка предоставляет возможность изменять способ работы с перекрывающимися анимациями — другими словами, когда вторая анимация применяется к свойству, которое уже анимируется. Это делается через свойство BeginStoryboard. HandoffBehavior. Обычно, когда анимации перекрываются, то вторая переопределяет первую немедленно. Такое поведение называется снимок и замена (snapshot-and-replace) и представляется значением SnapshotAndReplace из перечисления Handoff Behavior. Когда стартует вторая анимация, создается снимок свойства в текущем состоянии (полученном в результате первой анимации), первая анимация останавливается и заменяется новой анимацией. Единственным альтернативным значением перечисления HandoffBehavior является Compose, которое приводит к объединению второй анимации с временной шкалой первой анимации. Например, рассмотрим измененную версию примера ListBox, использующую Handoff Behavior. Compose при уменьшении элемента списка: <EventTrigger RoutedEvent="ListBoxItem.MouseLeave"> <EventTrigger.Actions> <BeginStoryboard HandoffBehavior="Compose"> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="FontSize" BeginTime=:0:0.5" Duration=:0:0.2"></DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> Если теперь переместить курсор мыши на ListBoxItem и обратно, можно заметить другое поведение. Убрав курсор мыши с элемента, вы увидите, что он продолжит увеличиваться до тех пор, пока не достигнет начала полусекундной задержки. Затем вторая анимация уменьшит элемент. Если не указано поведение Compose, то элемент будет просто ожидать, зафиксированный в своем текущем размере, в течение 0,5 секунды, пока не начнется вторая анимация.
422 Глава 15. Основы анимации Использование перечисления Handof f Behavior для композиции анимаций требует дополнительных накладных расходов. Это связано с тем, что часы, используемые для запуска исходной анимации, не могут быть остановлены при запуске второй анимации. Вместо этого она остается активной до тех пор, пока сборщик мусора не удалит ListBoxItem, или же пока не будет применена новая анимация на том же свойстве. Совет. Если производительность становится проблемой, команда разработчиков WPF рекомендует вручную освобождать часы анимаций, как только они будут завершены (а не ожидать, пока сборщик мусора найдет их). Для этого понадобится обработать событие типа Storyboard. Completed Затем необходимо вызвать BeginAnimationO для элемента, анимация которого только что завершилась, указав соответствующее свойство и null-ссылку на месте анимации. Синхронизированные анимации Класс Storyboard непрямо унаследован от TimeLineGroup, что дает ему возможность поддерживать более одной анимации. Лучше всего то, что эти анимации управляются как единая группа — в том смысле, что запускаются одновременно. Для примера рассмотрим приведенную ниже раскадровку. Она запускает две анимации: одну, работающую со свойством Width кнопки, и вторую, имеющую дело со свойством Height. Поскольку анимации сгруппированы в одной раскадровке, они увеличивают размеры кнопки в унисон, что дает более синхронизированный эффект, чем просто многократный вызов BeginAnimationO в коде. <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Width" To=00" Duration=:0:5"></DoubleAnimation> <DoubleAnimation Storyboard.TargetProperty="Height" To=00" Duration=:0:5"></DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> В этом примере обе анимации имеют одинаковую длительность, хотя это требование не обязательно. Единственное, что следует принимать во внимание для анимаций, завершающихся в разное время — это их свойство FillBehavior. Если FillBehavior анимации установлено в HoldEnd, она удерживает значение анимируемого свойства до тех пор, пока не завершатся все анимации, определенные в раскадровке. Если свойство FillBehavior раскадровки равно HoldEnd, то финальное анимированное значение удерживается независимо (до тех пор, пока новая анимация не заменит его или пока анимация не будет удалена вручную). Здесь становится ясно, в чем практическая польза свойств, описанных в табл. 15.1. Например, свойство SpeedRatio можно использовать для того, чтобы заставить одну анимацию в раскадровке выполняться быстрее остальных. С помощью свойства BeginTime можно сдвинуть одну анимацию относительно другой, чтобы она запускалась в определенный момент времени. На заметку! Поскольку StoryBoard наследуется от Timeline, можно использовать все свойства, описанные в табл. 15.1, чтобы настроить скорость, применять ускорение или замедление, ввести время задержки и т.д. Эти свойства касаются всех содержащихся анимаций, и все они дают кумулятивный эффект. Например, если установить Storyboard.SpeedRatio в 2, то данная анимация будет выполняться вдвое быстрее, чем обычно.
Глава 15. Основы анимации 423 Управление воспроизведением До сих пор в триггерах событий применялось одно действие — BeginStoryboard, которое запускает анимацию. Однако есть ряд других действий, позволяющих управлять созданной однажды раскадровкой. Эти действия, унаследованные от класса ControllableStoryboardAction, перечислены в табл. 15.2. Таблица 15.2. Классы действий для управления раскадровкой Имя Описание PauseStoryboard ResumeStoryboard StopStoryboard SeekStoryboard SetStoryboardSpeedRatio SkipStoryboardToFill Removestoryboard Приостанавливает воспроизведение анимации и сохраняет ее в текущей позиции Возобновляет воспроизведение приостановленной анимации Останавливает воспроизведение анимации и сбрасывает ее часы в начало Перепрыгивает в определенную точку временной шкалы анимации. Если анимация в данный момент воспроизводится, то воспроизведение продолжается с новой позиции. Если же анимация приостановлена, она остается приостановленной Изменяет SpeedRatio всей раскадровки (а не только одной анимации внутри нее) Перемещает раскадровку в конец ее временной шкалы. Этот период известен как область заполнения (fill region). Для стандартной анимации, у которой свойство FillBehavior установлено в HoldEnd, анимация продолжается для удержания финального значения Удаляет раскадровку, прерывая все текущие выполняющиеся анимации и возвращая свойства в исходные, установленные последний раз значения. Это дает тот же эффект, что и вызов BeginAnimation() для соответствующего элемента с null-объектом анимации На заметку! Остановка анимации не эквивалентна ее завершению (если только свойство FillBehavior не установлено в Stop). Это объясняется тем, что даже когда анимация достигает конца своей временной шкалы, она продолжает удерживать финальное значение свойства. Аналогично, когда анимация приостановлена, она продолжает удерживать текущее промежуточное значение свойства. Однако когда анимация остановлена окончательно, она более не применяет никакого значения, и свойство возвращается к своему исходному значению. Однако с использованием этих действий связан один недокументированный камень преткновения. Чтобы они успешно работали, все триггеры должны быть определены в одной коллекции Triggers. Если поместить действие BeginStoryboard в другую коллекцию триггеров, отличную от PauseStoryboard, то действие PauseStoryboard работать не будет Для демонстрации проектного решения, которое понадобится применить, рассмотрим пример. Возьмем окно, представленное на рис. 15.3. Оно накладывает два элемента Image точно в одной позиции, используя при этом сетку. Изначально видимым является только одно изображение — дневной снимок знаменитой достопримечательности Торонто. После запуска анимации прозрачность этого изображения снижается от 1 до О, постепенно проявляя ночной снимок с того же места. Эффект похож на последовательное отображение ряда фотографий, сделанных через равные промежутки времени.
424 Глава 15. Основы анимации Рис. 15.3. Управляемая анимация Ниже показана разметка, определяющая Grid с двумя изображениями: <Grid> <Image Source="night. jpg"x/Image> <Image Source="day. jpg" Name="imgDay"x/Image> </Grid> А вот анимация, которая постепенно заменяет одно изображение другим: <DoubleAnimation Storyboard.TargetName="imgDay" Storyboard.TargetProperty="Opacity" From="l" To=" Duration=:0:10"> </DoubleAnimation> Чтобы сделать этот пример еще более интересным, в него включено несколько кнопок в нижней части окна, которые позволяют управлять воспроизведением анимации. С помощью этих кнопок можно предпринимать обычные действия по воспроизведению медиафайлов — паузу, продолжение и останов. (Допускается добавить и другие кнопки, например, изменяющие скорость или указывающие определенную точку во времени.) Ниже показана разметка, определяющая эти кнопки: <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="> <Button Name=,,cmdStart">Start</Button> <Button Name="cmdPause">Pause</Button> <Button Name="cmdResume">Resume</Button> <Button Name=,,cmdStop">Stop</Button> <Button Name=IIcmdMiddle">Move To Middle</Button> </StackPanel> Обычно триггер события помещается в коллекцию Triggers каждой индивидуальной кнопки. Однако, как упоминалось ранее, это не работает с анимациями. Простейшее решение — определить все триггеры событий в одном месте, таком как коллекция Triggers содержащего кнопки элемента, и привязать их с помощью свойства EventTrigger.SourceName. Когда SourceName соответствует свойству Name кнопки, триггер применяется к этой кнопке. В рассматриваемом примере можно использовать коллекцию Triggers объекта StackPanel, содержащего кнопки. Однако часто проще иметь дело с коллекцией Triggers элемента верхнего уровня, в данном случае — окна. Это позволит перемещать кнопки в разные места пользовательского интерфейса, не теряя их функциональности.
Глава 15. Основы анимации 425 <Window.Triggers> <EventTrigger SourceName="cmdStart" RoutedEvent="Button.Click"> <BeginStoryboard Name="fadeStoryboardBegin"> <Storyboard> <DoubleAnimation Storyboard.TargetName="imgDay" Storyboard.ТаrgetProperty="Opacity" From="l" To=,,0" Duration=,,0:0:10"> </DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTngger> <EventTrigger SourceName="cmdPause" RoutedEvent="Button.Click"> <PauseStoryboard BeginstoryboardName="fadeStoryboardBegin"></Pausestoryboard> </EventTrigger> <EventTrigger SourceName="cmdResume" RoutedEvent="Button.Click"> <ResumeStoryboard BeginstoryboardName="fadeStoryboardBegin"></Resumestoryboard> </EventTrigger> <EventTrigger SourceName="cmdStop" RoutedEvent="Button.Click"> <StopStoryboard BeginStoryboardName="fadeStoryboardBegin"></StopStoryboard> </EventTrigger> <EventTrigger SourceName="cmdMiddle" RoutedEvent="Button.Click"> <Seekstoryboard BeginstoryboardName="fadeStoryboardBegin" Offset=:0:5"></SeekStoryboard> </EventTrigger> </Window.Triggers> Обратите внимание, что действию BeginStoryboard должно быть назначено имя (в примере — это fadeStoryboardBegin). Другие триггеры указывают это имя в свойстве BeginStoryboardName, чтобы привязаться к одной и той же раскадровке. При использовании действий раскадровки вы столкнетесь с одним ограничением. Свойства, которые они предоставляют (такие как SeekStoryboard.Offset и SetStoryboardSpeedRatio.SpeedRatio), не являются свойствами зависимости. Это ограничивает возможности по применению выражений привязки данных. Например, не получится автоматически прочитать свойство Slider.Value и применить его к действию SetStoryboardSpeedRatio.SpeedRatio, т.к. свойство SpeedRatio не принимает выражения привязки данных. Может показаться, что данное ограничение обходится за счет написания некоторого кода, использующего свойство SpeedRatio объекта Storyboard, но это не сработает. Когда анимация стартует, значение SpeedRatio читается и используется для создания таймера анимации. Если изменить его после этого момента, анимация будет продолжаться в нормальном темпе. Если требуется динамически изменять скорость и позицию воспроизведения, то единственным решением будет делать это в коде. Класс Storyboard предоставляет методы, обеспечивающие ту же функциональность, что и триггеры, описанные в табл. 15.2, втом числе Begin(), Pause(), Seek(), StopO, SkipToFilK), SetSpeedRatioQ HRemove(). Чтобы получить доступ к объекту Storyboard, необходимо удостовериться, что в коде разметки установлено свойство Name: <Storyboard Name="fadeStoryboard"> На заметку! Не путайте имя объекта Storyboard (которое необходимо для использования раскадровки в коде) с именем действия BeginStoryboard (которое требуется для привязки других действий триггера, манипулирующих раскадровкой). Для предотвращения конфиликтов можно принять соглашение по именованию, например, добавлять слово Begin в конец имени BeginStoryboard.
426 Глава 15. Основы анимации Теперь необходимо просто написать соответствующий обработчик событий и воспользоваться методами объекта Storyboard. (Вспомните, что простое изменение свойств раскадровки, подобных SpeedRatio, не даст никакого эффекта. Они просто конфигурируют настройки, которые применяются на момент запуска анимации.) Ниже приведен обработчик событий, реагирующий на перетаскивание ползунка Slider. Он получает значение ползунка (находящееся в пределах от 0 до 3) и использует его для применения нового масштаба скорости: private void sldSpeed_ValueChanged(object sender, RoutedEventArgs e) { fadeStoryboard.SetSpeedRatio (this, sldSpeed.Value); } Обратите внимание, что Set SpeedRatio () принимает два аргумента. Первый — это контейнер анимации верхнего уровня (в данном случае — текущее окно). Все методы раскадровки требуют этой ссылки. Второй аргумент — новая скорость. Эффект вытеснения В предыдущем примере реализован постепенный переход между двумя изображениями, который обеспечивается изменением свойства Opacity изображения, находящегося сверху. Другой распространенный способ перехода между изображениями заключается в вытеснении (wipe), которое позволяет постепенно открывать новое изображение поверх существующего. Основной трюк, связанный этим приемом, заключается в создании маски непрозрачности (opacity mask) для верхнего изображения. Вот пример: <Image Source="day.jpg" Name="imgDay"> <Image.OpacityMask> <LinearGradientBrush StartPoint=,0" EndPoint="l,0"> <GradientStop Offset=" Color="Transparent" x:Name="transparentStop" /> <GradientStop Offset=" Color="Black" x:Name="visibleStop" /> </LinearGradientBrush> </Image.OpacityMask> </Image> Маска непрозрачности использует градиент, определяющий две точки останова: Black (где изображение будет полностью видимым) и Transparent (где изображение будет полностью прозрачным). Изначально обе точки останова позиционируются на левой границе изображения. Поскольку точка останова видимости определена первой, она имеет приоритет, и изображение в ней будет полностью непрозрачным. Обратите внимание, что обе точки именованы, поэтому легко могут быть доступны анимации. Затем понадобится запустить анимацию для смещений кисти LinearGradientBrush В данном примере оба смещения движутся слева направо, приоткрывая нижнее изображение. Чтобы сделать этот пример более привлекательным, смещения не занимают одинаковую позицию в процессе движения. Вместо этого смещение видимости лидирует, а смещение прозрачности следует за ним с задержкой 0,2 секунды. В процессе анимации это создает смешанную область на границе области вытеснения. <Storyboard> <DoubleAnimation Storyboard.TargetName="visibleStop" Storyboard.TargetProperty="Offset" From=" To=.2" Duration=:0:1.2" ></DoubleAnimation> <DoubleAnimation Storyboard.ТагgetName="transparentStop" Storyboard.TargetProperty="Offset" BeginTime=:0:0.2" From=" To="l" Duration=:0:l" ></DoubleAnirnation> </Storyboard>
Глава 15. Основы анимации 427 Здесь присутствует одна необычная деталь. Точка останова видимости перемещается на 1,2 вместо 1, что отмечает правую грань изображения. Это гарантирует, что оба смещения будут двигаться с одинаковой скоростью, поскольку общее расстояние, которое должно покрыть каждое из них, пропорционально длительности анимации. Вытеснение обычно осуществляется слева направо или сверху вниз, но при использовании масок непрозрачности возможны и более изощренные эффекты. Например, для маски непрозрачности может применяться кисть DrawingBrush, геометрия которой модифицируется, чтобы приоткрыть нижнее изображение через какой-то мозаичный шаблон. Примеры анимации кистей приводятся в главе 16 Отслеживание хода анимации Проигрывателю анимации, показанному на рис. 15.3, все еще не хватает одного средства, которое присутствует в большинстве проигрывателей медиа, а именно: возможности определения текущей позиции. Чтобы сделать его более забавным, можно добавить некоторый текст, который показывает временное смещение, и панель прохождения, предоставляющую визуальную индикацию хода анимации. На рис. 15.4 показан усовершенствованный проигрыватель анимации с обеими деталями (вместе с ползунком для управления скоростью, о котором речь шла в предыдущем разделе). Рис. 15.4. Отображение позиции и хода анимации Добавить эти элементы достаточно просто. Сначала нужен элемент TextBlock, чтобы показать время, и элемент управления ProgressBar для отображения графической панели. Может показаться, что значение TextBlock и содержимое ProgressBar следует устанавливать с помощью выражения привязки данных, однако это не так. Дело в том, что единственный способ получения информации о текущем состоянии анимации от Storyboard заключается в применении таких методов, как GetCurrentTime () и GetCurrentProgress(). Нет никакой возможности получить ту же информацию через свойства. Простейшее решение предполагает реакцию на одно из событий раскадровки, перечисленных в табл. 15.3. •nation
428 Глава 15. Основы анимации Таблица 15.3. События Storyboard Имя Описание Completed Анимация достигла конечной точки CurrentGlobalSpeedlnvalidated Изменилась скорость либо анимация была временно приостановлена, возобновлена, остановлена или перемещена в новую позицию. Это событие также случается, когда таймер анимации запускается в обратном направлении (в конце реверсивной анимации), а также когда она ускоряется или замедляется CurrentStatelnvalidated Анимация запустилась или завершилась CurrentTimelnvalidated Таймер анимации переместился вперед на инкремент, изменяя анимацию. Это событие также случается, когда анимация запускается, останавливается или завершается RemoveRequested Анимация удалена. Анимируемое свойство будет впоследствии возвращено в исходное значение В рассматриваемой ситуации необходимым событием является CurrentTime Invalidated, которое инициируется при всяком продвижении таймера вперед. (Обычно это происходит 60 раз в секунду, но если код требует больше времени на выполнение, некоторые "тики" таймера могут быть потеряны.) Когда возникает событие CurrentTimelnvalidated, отправителем является объект Clock (из пространства имен System.Windows.Media.Animation). Объект Clock позволяет получить текущее время в виде TimeSpan и текущий показатель хода анимации как значение между 0 и 1. Ниже приведен код, который обновляет метку и панель прохождения. private void storyboard_CurrentTimeInvalidated(object sender, EventArgs e) { Clock storyboardClock = (Clock)sender; if (storyboardClock.CurrentProgress == null) { lblTime.Text = " [ [ stopped ]]"; progressBar.Value = 0; else { lblTime.Text = storyboardClock.CurrentTime .ToStnng () ; progressBar.Value = (double)storyboardClock.CurrentProgress; } } Совет. В случае использования свойства Clock.CurrentProgress никаких вычислений с целью определения значений для панели прохождения выполнять не понадобится Вместо этого просто сконфигурируйте ее с минимумом 0 и максимумом 1. В результате можно будет просто применять Clock.CurrentProgress для установки ProgressBar.Value, как показано в этом примере. Плавность анимации Один из недостатков линейной анимации состоит в том, что она часто выглядит механической и неестественной. Однако сложные пользовательские интерфейсы поддерживают анимационные эффекты, которые моделируют реальные системы. Например,
Глава 15. Основы анимации 429 они могут использовать чувствительные кнопки, которые быстро нажимаются при щелчке на них и плавно возвращаются в исходное положение, создавая иллюзию реального движения. Также могут применяться эффекты свертывания и развертывания, подобные реализованным в ОС Windows, где скорость, с которой окно растет или уменьшается, увеличивается по мере приближения окна к его окончательному размеру. На эти тонкие детали часто не обращают внимания, когда они реализованы, как следует. Тем не менее, неуклюжесть анимации, лишенной этих нюансов, сразу бросается в глаза. Секрет создания более естественной анимации состоит в варьировании скорости изменений. Вместо создания анимации, которая изменяет свойство с фиксированной постоянной скоростью, понадобится проектировать анимацию, которая в процессе ускоряется и замедляется. WPF предоставляет для этого несколько возможностей. В следующей главе вы узнаете об анимации на основе кадров и анимации ключевого кадра — двух приемах, которые обеспечивают более тонкое управление анимацией (и требуют существенно большего объема работы). Однако простейший способ построения более естественной анимации состоит в применении готовой функции плавности (easing function). При использовании функции плавности анимация определяется обычным образом с указанием начального и конечного значений свойства. Но в дополнение к этим деталям добавляется готовая математическая функция, которая изменяет ход анимации, заставляя ее ускоряться и замедляться в различных точках. Именно эта техника рассматривается в последующих разделах. Использование функции плавности Выгода применения функции плавности в том, что она требует меньше работы, чем другие подходы, такие как анимация на основе кадров и анимация ключевого кадра. Чтобы использовать плавность анимации, в свойстве EasingFunction аними- руемого объекта устанавливается экземпляр класса функции плавности (наследника EasingFunctionBase). Обычно необходимо настроить несколько свойств функции плавности. При этом для получения нужного эффекта может понадобиться экспериментировать с различными установками, но никакого кода не требуется, а нужна лишь небольшая порция дополнительной XAML-разметки. Например, рассмотрим две анимации, которые работают с кнопкой. Когда пользователь перемещает курсор мыши над кнопкой, небольшой фрагмент кода запускает в действие анимацию growStoryboard, растягивая кнопку на 400 единиц. Когда пользователь убирает курсор мыши с кнопки, кнопка возвращается к своим нормальным размерам. <Storyboard x :Name=llgrowStoryboard"> <DoubleAnimation Storyboard. TargetName=llcmdGrow" Storyboard. TargetProperty="Width11 To=00" Duration=:0:1.5"></DoubleAnimation> </Storyboard> <Storyboard x:Name="revertstoryboard"> <DoubleAnimation Storyboard. Tar getName="cmdGrow11 Storyboard. Tar get Proper ty=" Width" Duration=:0:3"></DoubleAnimation> </Storyboard> В таком виде анимации используют линейную интерполяцию, а это значит, что увеличение и уменьшение кнопки происходит простым механическим образом. Для получения более естественного эффекта можно добавить функцию плавности. В следующем примере добавляется такая функция по имени ElasticEase. В результате кнопка увеличивается чуть больше своего полного размера, потом возвращается к чуть меньше-
430 Глава 15. Основы анимации му размеру, вновь увеличивается больше полного размера (но немного меньше, чем в прошлый раз), возвращается к размеру поменьше, и т.д., повторяя этот цикл с затуханием колебаний. Свойство Oscillations управляет количеством колебаний. Класс ElasticEase предоставляет еще одно свойство, которое в данном примере не используется — Springiness. Чем больше его значение, тем сильнее затухание каждого последующего колебания (по умолчанию принято значение 3). <Storyboard х: Name=llgrowStoryboard"> <DoubleAnimation Storyboard. TargetName="cmdGrow11 Storyboard. Та г ge t Pr ope rty=" Width" To=00" Duration=:0:1.5"> <DoubleAnimat±on.EasingFunction> <ElasticEase EasingMode=" EaseOut11 Oscillations=ll10"X/ElasticEase> </DoubleAnimation.EasingFunction> </DoubleAnimation> </Storyboard> Чтобы в полной мере оценить разницу между этой разметкой и ранее приведенным примером, где функция плавности не применялась, стоит опробовать эту анимацию (или запустить примеры для этой главы). Изменения весьма заметны. Всего одна строка XAML-разметки — и простая анимация превращается из любительского в изящный эффект, который прилично смотрится в профессиональном приложении. На заметку! Поскольку свойство EasingFunction принимает одиночный объект функции плавности, комбинировать разные функции плавности в одной анимации нельзя. Режимы плавности Прежде чем рассматривать различные функции плавности, важно понять, когда применяется функция плавности. Каждый класс функции плавности наследуется от EasingFunctionBase и получает от него единственное свойство по имени EasingMode (режим плавности). Это свойство может иметь одно из трех разных значений: Ease In (применение эффекта в начале анимации), EaseOut (применение эффекта в конце анимации) и EaselnOut (применение эффекта в начале и конце анимации). В предыдущем примере анимация в growStoryboard использует режим EaseOut, т.е. последовательность затухающих колебаний появляется в конце анимации. Если отобразить на графике изменение ширины кнопки в процессе анимации, то получится нечто вроде показанного на рис. 15.5. На заметку! В случае применения функции плавности длительность анимации не изменяется. В случае анимации growStoryboard функция ElasticEase не просто изменяет способ завершения анимации — она также заставляет начальную часть анимации (когда кнопка растет нормально) выполняться быстрее, чтобы больше времени осталось на колебания в конце. Конечная ширина (значение То) I Начальная ширина Время ->► (значение From) Рис. 15.5. Колебания в конце с использованием режима EaseOut для ElasticEase
Глава 15. Основы анимации 431 Если переключить функцию ElasticEase на режим Ease In, колебания сместятся к началу анимации. Кнопка сократится чуть меньше своего начального размера, потом увеличится чуть больше, опять чуть меньше уменьшится, и продолжит этот цикл, постепенно увеличивая амплитуду, пока не увеличится до конечного размера. (Для управления количеством колебаний служит свойство ElasticEase.Oscillations.) На рис. 15.6 показан совершенно другой шаблон движения. Наконец, режим EaselnOut создает довольно странный эффект, с серией колебаний в первой половине анимации, за которой следует серия колебаний, завершающая ее. График показан на рис. 15.7. t Ширина кнопки Время ->► Рис. 15.6. Колебания в начале с использованием режима Easeln функции ElasticEase Время Рис. 15.7. Колебания в начале и конце с использованием режима EaselnOut функции ElasticEase Классы функций плавности В WPF доступны 11 функций плавности, и все они находятся в пространстве имен System.Windows.Media.Animation. Все они, а также их важные свойства, описаны в табл. 15.4. Помните, что каждая анимация также предоставляет свойство EasingMode, которое позволяет управлять влиянием анимации — в начале (Easeln), в конце (EaseOut) либо там и там (EaselnOut). Таблица 15.4. Функции плавности Имя Описание Свойства BackEase ElasticEase BounceEase В сочетании с Easeln возвращает анимацию назад, прежде чем запустить ее. В сочетании с EaseOut эта функция позволяет анимации слегка "заступить" и затем возвращает ее назад В сочетании с EaseOut заставляет анимацию выполнять затухающие колебания от максимума к минимуму и обратно. В случае применения с Easeln колебания происходят вокруг начального значения, постепенно усиливаясь Дает эффект, подобный ElasticEase, но с тем отличием, что амплитуда колебаний никогда не выходит за пределы начального или конечного значения Свойство Amplitude определяет степень "отступа" или "заступа" Значение по умолчанию равно 1, и его можно уменьшить (до любого значения больше 0), чтобы сократить или усилить эффект Свойство Oscillations управляет количеством колебаний анимации (по умолчанию равно 3), a Springiness — ускорением или замедлением колебаний (по умолчанию равно 3). Свойство Bounces управляет количеством раскачиваний анимации туда и обратно (по умолчанию равно 2), a Bounciness определяет, насколько быстро они растут или затухают (по умолчанию равно 2)
432 Глава 15. Основы анимации Окончание табл. 15.4 Имя Описание Свойства CircleEase CubicEase QuadraticEase QuarticEase QuinticEase SineEase PowerEase ExponentialEase Ускоряет (с помощью Easeln) или за- Нет медляет (с помощью EaseOut) анимацию, используя циклическую функцию Ускоряет (с помощью Easeln) или Нет замедляет (с помощью EaseOut) анимацию, используя функцию на основе куба времени. Эффект подобен CircleEase, но ускорение более плавное Ускоряет (с помощью Easeln) или Нет замедляет (с помощью EaseOut) анимацию, используя функцию на основе квадрата времени. Эффект подобен CubicEase, но ускорение более плавное Ускоряет (с помощью Easeln) или Нет замедляет (с помощью EaseOut) анимацию, используя функцию на основе времени в четвертой степени. Эффект подобен CubicEase и QuadraticEase, но ускорение более выраженное Ускоряет (с помощью Easeln) или Нет замедляет (с помощью EaseOut) анимацию, используя функцию на основе времени в четвертой степени. Эффект подобен CubicEase, QuadraticEase и QuinticEase, но ускорение более выраженное Ускоряет (с помощью Easeln) или замедляет (с помощью EaseOut) анимацию, используя функцию, включающую вычисление синуса. Ускорение очень плавное и ближе к линейной интерполяции, чем любые другие функции плавности Ускоряет (с помощью Easeln) или замедляет (с помощью EaseOut) анимацию, используя функцию возведения в степень f[t)-tp. В зависимости от значения экспоненты р, можно дублировать эффект от функций CubicEase, QuadraticEase, QuarticEase и QuinticEase Ускоряет (с помощью Easeln) или замедляет (с помощью EaseOut) анимацию, используя экспоненциальную функцию f(t) = (e(at) - 1)/(е(а) - 1) Нет Свойство Power устанавливает значение экспоненты в формуле. Используйте значение 2 для QuadraticEase (f[t) = t2), 3 — для CubicEase {f[t) = t3), 4 —для QuarticEase (f[t) = t4), 5 —для QuinticEase {f(t) = t ) или что-то другое. По умолчанию применяется 2 Свойство Exponent позволяет установить значение экспоненты (по умолчанию равно 2)
Глава 15. Основы анимации 433 Многие из функций плавности дают похожие, но тонко отличающиеся результаты. Для успешного использования плавности анимации необходимо правильно выбрать функцию плавности и корректно ее сконфигурировать. Часто этот процесс требует применения метода проб и ошибок. И здесь могут помочь два хороших ресурса. Во-первых, в документации WPF даны графические представления каждой функции плавности, которые показывают изменения анимируемого значения во времени. Исследование этих графиков — хороший способ прочувствовать, что делает каждая из функций плавности. На рис. 15.8 представлены графики для наиболее популярных функций плавности. BackEase EasingMode="Easeln" EasingMode="EaseOut" Easi ngMode=" EaselnOut" ElasticEase EasingMode="Easeln EasingMode="EaseOut" EasingMode=,,EaselnOu BounceEase EasingMode="Easeln" EasingMode="EaseOut" EasingMode="EaselnOut" CircleEase EasingMode="EaselnM EasingMode="EaseOut" EasingMode="EaselnOut" PowerEase EasingMode="Easeln" EasingMode="EaseOut" EasingMode=" EaselnOut" t t Рис. 15.8. Эффект от различных функций плавности
434 Глава 15. Основы анимации Во-вторых, Microsoft предлагает несколько примеров приложений, с которыми можно поэкспериментировать, используя различные функции плавности и разные значения свойств. Одним из наиболее наглядных является приложение Silverlight, которое можно запустить в браузере, посетив http://tinyurl.com/animationeasing. Оно позволяет наблюдать эффект любой функции плавности на примере падающего квадрата и показывает автоматически сгенерированную XAML-разметку, необходимую для воспроизведения эффекта. Создание специальной функции плавности Унаследовав новый класс от EasingFunctionBase и переопределив методы EaselnCoreO и CreatelnstanceCore (), можно создать собственный специальный эффект плавности. Это очень специализированная техника, потому что большинство разработчиков получают нужный результат, конфигурируя стандартные функции плавности (либо используя анимацию ключевого сплайна, как описано в следующей главе). Тем не менее, создание собственной функции плавности оказывается неожиданно простым. Почти вся логика, которую нужно при этом написать, выполняется в методе EaselnCoreO. Он получает значение нормализованного времени — по сути, значение от О до 1, представляющее ход анимации. Когда анимация начинается впервые, нормализованное время равно О. Затем оно растет, пока не достигнет значения 1 в конце анимации. protected override double EaselnCore(double normalizedTime) { ■ ■ ■ } В течение анимации среда WPF вызывает EaselnCore каждый раз, когда обновляет анимированное значение. Точная частота зависит от частоты кадров, однако можно рассчитывать на примерно 60 вызовов EaselnCoreO в секунду. Для выполнения функции плавности метод EaselnCoreO принимает нормализованное время и определенным образом его корректирует. Скорректированное значение, возвращаемое EaselnCoreO, затем используется для подстройки хода анимации. Например, если EaselnCoreO возвращает 0, анимация возвращается к своей начальной точке. Если EaselnCore () возвращает 1, анимация перепрыгивает в конечную точку. Тем не менее, метод EaselnCoreO не ограничивается этим диапазоном. Например, он может вернуть 1.5, заставив анимацию перегнать себя на дополнительные 50% (этот эффект уже демонстрировался в функции плавности типа ElasticEase). Ниже приведена версия EaselnCoreO, которая вообще ничего не делает. Она возвращает нормализованное значение, предоставляя анимации выполняться обычным образом, как если бы не было вообще никакой функции плавности: protected override double EaselnCore(double normalizedTime) { return normalizedTime; } А вот версия EaselnCoreO, которая дублирует функцию CubicEase, возводя в куб нормализованное время. Поскольку нормализованное время выражается дробным значением, возведение в куб порождает меньшую величину. Поэтому данный метод дает эффект начального замедления анимации и ускорения его по мере того, как нормализованное время (и его значение в кубе) приближается к 1: protected override double EaselnCore(double normalizedTime) { return Math.Pow(normalizedTime, 3) ; }
Глава 15. Основы анимации 435 На заметку! Плавность, которая реализуется в методе EaselnCodeO — это то, что получается при использовании значения Easeln для EasingMode. Интересно, что это все, что потребуется, поскольку среда WPF достаточно интеллектуальна, чтобы самостоятельно вычислить дополняющее поведение для режимов EaseOut и EaselnOut. Наконец, ниже показана функция плавности, которая делает нечто более интересное — смещает нормализованное значение на случайную величину, создавая эффект случайной дрожи. Амплитуда дрожания может изменяться (в пределах заданного диапазона) с помощью свойства зависимости Jitter, которое принимает значения от 0 до 2000. public class RandomJitterEase : EasingFunctionBase { // Сохранить генератор случайных чисел. private Random rand = new Random(); // Разрешить конфигурирование степени дрожания. public static readonly DependencyProperty JitterProperty = DependencyProperty.Register("Jitter", typeof (int), typeof(RandomJitterEase), new UIPropertyMetadata(lOOO) , new ValidateValueCallback(ValidateJitter)); public int Jitter { get { return (int)GetValue(JitterProperty); } set { SetValue(JitterProperty, value); } } private static bool ValidateJitter(object value) { int jitterValue = (int)value; return ( (jitterValue <= 2000) && (jltterValue >= 0) ) ; } // Выполнить функцию плавности. protected override double EaselnCore(double normalizedTime) { // Обеспечить отсутствие дрожи в финальном значении. if (normalizedTime == 1) return 1; // Сместить значение на случайную величину. return Math.Abs(normalizedTime - (double)rand.Next@,10)/B010 - Jitter)); } // Это обязательное переопределение просто представляет // актуальный экземпляр функции плавности, protected override Freezable CreatelnstanceCore () { return new RandomJitterEase(); } } Совет. Для просмотра значений плавности, которые вычисляются в процессе анимации, добавьте в EaselnCoreO вызов метода WriteLineO класса System.Diagnostinc.Debug. Это выведет указанное значение в окно Output (Вывод) по время отладки в Visual Studio. Использовать эту функцию плавности легко. Для начала отобразите соответствующее пространство имен в XAML-разметке: <Window x:Class="Animation.CustomEasingFunction" xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="CustomEasingFunction" Height=00" Width=00" xmlns:local="clr-namespace:Animation">
436 Глава 15. Основы анимации Затем создайте в разметке объект RandomJitterEase, как показано ниже: <DoubleAnimation Storyboard.TargetName="ellipse211 Storyboard.TargetProperty="(Canvas.Left) " To=00" Duration=,,0:0:10"> <DoubleAnimation.EasingFunction> <local:RandomJitterEase EasingMode="EaseIn" Jitter=000"> </local:RandomJitterEase> </DoubleAnimation.EasingFunction> </DoubleAnimation> Загружаемый код для этой главы содержит пример сравнения анимации без плавности (перемещение маленького эллипса по Canvas) с анимацией, использующей функцию RandomJitterEase. Производительность анимации Часто анимированный пользовательский интерфейс требует всего лишь создания и конфигурирования правильных объектов анимации и раскадровки. Однако в других сценариях — особенно, когда несколько анимаций происходят одновременно — возможно, придется уделить больше внимания производительности. Некоторые эффекты определенно могут привести к проблемам, например те, что включают видео, крупные растровые изображения и множественные уровни прозрачности; все это создает серьезную нагрузку на ваш центральный процессор. Небрежная их реализация может привести к заметным замедлениям либо к отбору ресурсов процессора у других приложений, выполняющихся в то же самое время. К счастью, в WPF предусмотрен ряд трюков, которые могут помочь в таких ситуациях. В следующих разделах вы научитесь снижать максимальную частоту кадров и кэ- шировать растровые изображения в видеокарте компьютера; эти два приема снижают нагрузку на процессор. Желательная частота кадров Как уже упоминалось ранее в главе, WPF пытается выполнять анимацию с частотой 60 кадров в секунду. Это гарантирует гладкую, плавную анимацию от начала до конца. Конечно, WPF может и не справиться с такой задачей. Если запущено множество сложных анимаций одновременно, и процессор или видеокарта не справляется с нагрузкой, общая частота кадров может снизиться (в лучшем случае) либо анимация начнет отображаться прыжками (в худшем случае). Редко когда требуется повышать частоту кадров, но может понадобиться ее снизить. Такое решение может быть продиктовано одной из двух причин; • анимация выглядит хорошо при низкой частоте кадров, так что ни к чему тратить дополнительные циклы процессора; • приложение выполняется на менее мощном процессоре или видеокарте, причем известно, что полноценная анимация с высокой частотой невозможна. На заметку! Иногда разработчики предполагают, что WPF включает код, масштабирующий частоту кадров, снижая ее для менее производительной видеоаппаратуры. На самом деле это вовсе не так. Вместо этого WPF всегда пытается достичь 60 кадров в секунду, если только не указано иначе. Чтобы оценить, как выполняется анимация, и удается ли WPF получить частоту 60 кадров в секунду на конкретном компьютере, можно воспользоваться инструментом Perforator, который входит в состав Microsoft Windows SDK v7.0. Ссылка на загрузку, инструкции по установке и документация доступны по адресу http://tinyurl.com/yfqottg.
Глава 15. Основы анимации 437 Отрегулировать частоту кадров достаточно просто. Для этого просто используется присоединенное свойство Timeline. DesiredFrameRate раскадровки, содержащей анимацию. Ниже приведен пример, сокращающий вдвое частоту кадров: <Storyboard Timeline.DesiredFrameRate=0"> На рис. 15.9 показано простое тестовое приложение, анимирующее передвигающийся по Canvas кружок. Приложение начинается с помещения объекта Ellipse на Canvas. Свойство Canvas.Cli^ToBounds устанавливается в true, так что границы кружка не выходят за границу Canvas на остальное поле окна. <Canvas ClipToBounds="True"> <Ellipse Name="ellipse" Fill="Red" Width=0" Height=0"></Ellipse> </Canvas> Для перемещения кружка по Canvas запускаются одновременно две анимации: одна обновляет свойство Canvas.Left (перемещая его слева направо), а другая изменяет свойство Canvas.Top (вызывая перемещение по вертикали). Анимация Canvas.Top обратима — как только кружок достигает наивысшей точки, он начинает падать обратно вниз. Анимация Canvas.Left не обратима, однако она выполняется вдвое медленнее, так что обе анимации перемещают кружок одновременно. Финальный трюк заключается в использовании свойства DecelerationRatio анимации Canvas.Top. Таким образом, кружок поднимается все медленнее, пока не достигнет вершины, что создает более реалистичный эффект. Ниже приведена полная разметка анимации: <Window.Resources> <BeginStoryboard x:Key="beginStoryboard"> <Storyboard Timeline.DesiredFrameRate= "{Binding ElementName=txtFrameRate,Path=Text}"> <DoubleAnimation Storyboard.TargetName="ellipse" Storyboard.TargetProperty="(Canvas.Left)" From=" To=00" Duration=:0:5"> </DoubleAnimation> <DoubleAnimation Storyboard.TargetName="ellipse" Storyboard.TargetProperty="(Canvas.Top)" From=00" To=" AutoReverse="True" Duration=:0:2.5" DecelerationRatio="> </DoubleAnimation> </Storyboard> </Beginstoryboard> </Window.Resources> Обратите внимание, что свойства Canvas.Left и Canvas.Top заключены в скобки. Это говорит о том, что они не принадлежат целевому элементу (эллипсу), а являются присоединенными свойствами. Кроме того, анимация определена в коллекции Resources окна. Это позволяет запускать анимацию более чем одним способом. В рассматриваемом примере анимация запускается при щелчке на кнопке Repeat (Повтор) и первой загрузке окна с помощью примерно такого кода: Рис. 15.9. Тестирование частоты кадров с помощью простой анимации
438 Глава 15. Основы анимации <Window. Tnggers> <EventTngger RoutedEvent="Window. Loaded"> <EventTrigger.Actions> <StatlcResource ResourceKey="beginStoryboard"></StaticResource> </EventTrigger.Actions> </EventTrigger> </Window.Triggers> Действительное назначение этого примера — испытать различные частоты кадров. Чтобы увидеть эффект от определенной частоты кадров, просто необходимо ввести соответствующее число в текстовом поле и щелкнуть на кнопке Repeat. После этого анимация запускается с новой частотой кадров (которая указывается выражением привязки данных), и тут же можно наблюдать результат. На более низких частотах кадров эллипс не движется гладко, а вместо этого беспорядочно скачет по поверхности Canvas. Можно также изменить в коде свойство Timeline.DesiredFrame. Например, уровень поддержки видеокарты можно определить, прочитав статическое свойство Render Capability.Tie г. На заметку! Приложив совсем немного усилий, можно также создать вспомогательный класс, который позволит применять некоторую логику в ХАМL-разметке. Один из примеров доступен по адресу http://tinyurl.com/yata5eu, где демонстрируется декларативное снижение частоты кадров на основе уровня поддержки видеокарты. Кэширование растровых изображений Кэширование растровых изображений заставляет WPF взять содержимое растрового изображения, как оно есть, и скопировать в память видеокарты. С этого момента видеокарта отвечает за манипуляции этим растровым изображением и обновление дисплея. Это происходит быстрее, чем когда всю работу выполняет WPF, непрерывно взаимодействуя с видеокартой. При правильном применении кэширование растровых изображений повышает производительность рисования приложения, но при неправильном приводит к пустой трате памяти видеокарты и даже снижению производительности. Поэтому, прежде чем прибегнуть к кэшированию растровых изображений, следует убедиться в его целесообразности. Ниже предложено несколько рекомендаций. • Если отображаемое содержимое нуждается в частой перерисовке, кэширование растровых изображений может быть оправдано. Дело в том, что каждая последующая перерисовка будет происходить быстрее. Примером может служить использование BitmapCacheBrush для рисования поверхности фигуры, пока некоторые другие анимированные объекты движутся сверху. Несмотря на то что фигура не изменяется, различные ее части скрываются и показываются, что требует перерисовки. • Если содержимое элемента меняется часто, то кэширование растровых изображений, скорее всего, не будет иметь смысла. Дело в том, что при каждом изменении визуального элемента среда WPF должна заново визуализировать растровое изображение и отправить его в кэш видеокарты, что требует времени. Это правило имеет некоторые исключения, поскольку определенные изменения не делают кэш недействительным. Примерами безопасных операций могут служить вращение и изменение масштаба элемента в рамках трансформации, кадрирования, изменения прозрачности либо применения эффекта. С другой стороны, изменение содержимого, компоновки и форматирования приводят к перерисовке растрового изображения.
Глава 15. Основы анимации 439 • Кэшируйте как можно меньший объем содержимого. Чем больше растровое изображение, тем больше времени требуется WPF для сохранения кэширован- ной копии и больше памяти расходуется на видеокарте. Как только память видеокарты будет заполнена, WPF придется вернуться к медленной программной перерисовке. Совет. Неудачная стратегия кэширования может создать больше проблем с производительностью, чем не полностью оптимизированное приложение. Поэтому не применяйте кэширование, если не походят приведенные выше рекомендации. Также пользуйтесь инструментом профилирования вроде Perforator (http://tinyurl.com/yfqottg) для проверки, повысила ли выбранная стратегия производительность. Для лучшего понимания необходимо поэкспериментировать с примером. На рис. 15.10 показан проект, входящий в состав загружаемого кода для настоящей главы. Здесь анимация перемещает простую фигуру — квадрат — по поверхности Canvas, которая содержит элемент Path со сложной геометрией. Рис. 15.10. Анимация поверх сложной порции векторной графики По мере передвижения квадрата по поверхности среда WPF вынуждена заново вычислять Path и заполнять пропущенные разделы. Это обеспечивает неожиданно большую нагрузку на центральный процессор, и анимация даже может стать прерывистой. Существует несколько способов решения этой проблемы. Один из них — заменить фон растровым изображением, которым WPF может управлять более эффективно. Более гибкий вариант предусматривает использование кэширования растровых изображений, которое сохраняет фон как интерактивный элемент. Чтобы включить кэширование растровых изображений, необходимо установить свойство CacheMode соответствующего элемента в BitmapCache. Каждый элемент поддерживает это свойство, что позволяет точно выбирать, какой именно элемент должен использовать кэширование: <Path CacheMode="BitmapCache" ...x/Path>
440 Глава 15. Основы анимации На заметку! Если кэшируется элемент, содержащий другие элементы, такой как контейнер компоновки, то все его подэлементы будут кэшированы в одном растровом изображении. Поэтому следует проявлять осторожность при добавлении кэширования к чему-либо вроде Canvas; делайте это только в том случае, если элемент Canvas мал и его содержимое неизменно. После внесения этого единственного простого изменения можно сразу заметить разницу. Окно станет появляться чуть медленнее. Зато анимация будет происходить более гладко, и нагрузка на центральный процессор существенно сократится. В этом легко удостовериться, заглянув в диспетчер задач: нередко значение загрузки, близкое к 100%, может сократиться до менее чем 20%. Обычно, когда включено кэширование растровых изображений, WPF делает снимок элемента в его текущих размерах и копирует его на видеокарту. Это может привести к проблемам, если впоследствии элемент увеличивается с помощью ScaleTransform. В таком случае увеличивается кэшированное растровое изображение, а не сам элемент, что может привести к ухудшению качества за счет укрупнения пикселей. Например, представьте, что в предыдущем примере вторая параллельная анимация увеличивает Path в десять раз по сравнению с его исходными размерами и затем возвращает первоначальные размеры. Чтобы гарантировать хорошее качество, можно кэшировать растровое изображение Path с размерами, в пять раз большими текущих размеров: <Path ...> <Path.CacheMode> <BitmapCache RenderAtScale="X/BitmapCache> </Path.CacheMode> </Path> Это решит проблему искажения пикселей. Кэшированное растровое изображение все равно меньше максимального анимированного размера Path (который в 10 раз больше начального), но видеокарта может удвоить размер растрового изображения с 5-кратного до 10-кратного без заметных потерь в качестве. И что более важно, это позволит приложению обойтись без значительного расхода видеопамяти. Резюме В настоящей главе детально рассматривалась поддержка анимации в WPF. Вы ознакомились с базовыми классами анимации и концепцией линейной интерполяции. Вы также увидели, как управлять воспроизведением одной или нескольких анимаций посредством раскадровки и как достигать более естественного эффекта с использованием функций плавности анимации. Теперь, владея основами, вы можете потратить больше времени для совершенствования в искусстве анимации — выборе свойств для анимации и модификации их для достижения требуемого эффекта. В следующей главе речь пойдет о том, как создавать разнообразные эффекты, применяя анимацию к трансформациям, кистям и построителям текстур. Вы также научитесь создавать анимации ключевого кадра, содержащие множество сегментов, и анимации на основе кадров, которые выходят за рамки стандартной модели анимации на основе свойств. Наконец, будет показано, как создавать и управлять раскадровками в коде, а не в XAML-разметке.
ГЛАВА 16 Расширенная анимация Теперь вы знакомы с основами системы анимации свойств WPF — как определяются анимации, как они подключаются к элементам и как управлять воспроизведением с помощью раскадровки. Теперь наступил подходящий момент, чтобы поближе ознакомиться с практическими приемами анимации, которые можно использовать в приложении. Начинается эта главу с рассмотрения того, что должно анимироваться для получения нужного результата. Будут показаны примеры, в которых анимируются трансформации, кисти и построители текстур. Затем вы узнаете, как анимация ключевого кадра и анимация на основе пути позволяет ускорять и замедлять анимацию, подобно тому, как это делается с функциями плавности анимации, но более гибким образом. После этого будет показано, как анимация на основе кадров дает возможность выйти за рамки традиционной модели анимации для создания сложных эффектов, подобных реалистичным столкновениям. И, наконец, рассматривается еще один пример — игра со сбрасыванием бомб, которая продемонстрирует, как интегрировать анимации в общий поток приложения, создавая и управляя ими с помощью кода. Что нового? В этой главе новые средства анимации не рассматриваются. Однако будут показаны некоторые примеры использования новых средств WPF, которые были описаны в предыдущих главах (такие как построители текстур и функции плавности анимации). Еще раз о типах анимаций Первая задача при создании любой анимации — выбор правильного свойства, подлежащего анимации. Определение разницы между результатом, которого необходимо достичь (например, перемещение элемента в пределах окна), и свойством, которое необходимо использовать (в нашем случае — Canvas.Left и Canvas.Top), не всегда очевидно. Ниже приведено несколько рекомендаций. • Если необходимо с помощью анимации заставить элемент появляться и исчезать, не используйте свойство Visibility (которое позволяет переключаться только между полной видимостью и полной невидимостью). Вместо этого обратитесь к свойству Opacity, чтобы можно было управлять видимостью плавно. • Если необходимо анимировать позицию элемента, рассмотрите использование Canvas. Этот объект предоставляет наиболее прямолинейные свойства (Canvas. Left и Canvas.Top) и требует минимума накладных расходов. В качестве альтернативы можно получить аналогичный эффект в других контейнерах компоновки, анимируя такие свойства, как Margin и Padding, с применением класса
442 Глава 16. Расширенная анимация ThicknessAnimation. Можно также анимировать MinWidth или MinHeight либо же колонку или строку в Grid. Совет. Многие эффекты анимации разработаны для постепенного показа элемента. Часто используется плавное проявление элемента до полной видимости или же распахивание его из маленькой точки. Однако существует и множество альтернатив. Например, можно сделать элемент расплывчатым, применив BlurEf feet, описанный в главе 14, а также анимировать свойство Radius для уменьшения расплывчатости, таким образом, плавно "наводя фокус" на элемент. • Наиболее часто применяемый вид анимации — это трансформации. Их можно использовать для перемещения или поворота элемента (TranslateTransform), вращения (RotateTransform), изменения размеров или сжатия (ScaleTransform) и т.п. При аккуратном применении они позволят обойтись без жесткого кодирования размеров и позиций в анимации. Они также минуют систему компоновки WPF, что делает их быстрее, чем другие подходы, которые имеют дело непосредственно с размером и позицией элемента. • Хороший способ изменения поверхности элемента посредством анимации предусматривает модификацию свойств кисти. Анимацию ColorAnimation можно использовать для изменения цвета или другого объекта анимации, чтобы трансформировать свойство более сложной кисти, например, смещение в градиенте. Показанные в главе примеры демонстрируют, как выполнять анимацию трансформаций и кистой, а также как использовать несколько других типов анимации. Вы узнаете, как создаются многомерные анимации ключевого кадра, анимации на основе пути, а также анимации на основе кадра. Анимированные трансформации Трансформации представляют собой один из наиболее мощных способов изменения элемента. При использовании трансформаций не просто изменяются границы элемента. При этом все визуальное представление элемента движется, опрокидывается, скашивается, растягивается, увеличивается, сжимается или вращается. Например, если выполняется анимация размеров кнопки с помощью ScaleTransform, то вся кнопка изменяется в размерах, включая ее рамку и внутреннее содержимое. Эффект получается более впечатляющий, чем когда осуществляется анимация только свойств Width и Height или свойства FontSize, затрагивающего текст кнопки. Как было показано в главе 12, каждый элемент способен применять трансформацию двумя разными способами: через свойства RenderTransform и LayoutTransform. Свойство RenderTransform более эффективно, поскольку применяется после того, как выполняется компоновка, и служит для трансформации финального отображаемого вывода. Свойство LayoutTransform применяется перед проходом компоновки, и в результате другие элементы управления оказываются переупорядоченными с целью заполнения контейнера. Изменение свойства LayoutTransform инициирует новую операцию компоновки (если только элемент не находится внутри Canvas — в этом случае RenderTransform и LayoutTransform эквивалентны). Чтобы использовать в анимации трансформацию, первым делом нужно определить трансформацию (анимация может изменить существующую трансформацию, но не создать новую). Например, предположим, что необходимо позволить кнопке вращаться. Для этого потребуется трансформация RotateTransform:
Глава 16. Расширенная анимация 443 <Button Content="A Button"> <RenderTransform> <RotateTransformX/RotateTransform> </RenderTransform> </Button> Кроме того, имеется триггер, который заставит кнопку вращаться, когда курсор мыши проходит над ней. Он использует целевое свойство Render Trans for m. Angle — другими словами, читает свойство кнопки RenderTransform и модифицирует свойство Angle определенного там объекта RenderTransform. Тот факт, что свойство RenderTransform может содержать широкое разнообразие объектов трансформации, каждый со своим набором свойств, не вызывает проблемы. До тех пор, пока используется трансформация, которая имеет свойство Angle, этот триггер будет работать. <EventTrigger RoutedEvent="Button.MouseEnter"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="RenderTransform.Angle" To=,,360" Duration= : 0 : 0 . 8" RepeatBehavior=,,Forever,,x/DoubleAnimation> </Storyboard> </BegmS t or yboard> </EventTrigger.Actions> </EventTrigger> Кнопка делает один оборот каждые 0,8 секунды и продолжает свое вращение бесконечно. Пока кнопка вращается, она остается полностью функциональной, например, на ней можно щелкнуть и обработать событие Click. Чтобы обеспечить вращение кнопки вокруг ее центральной точки (а не верхнего левого угла), понадобится установить свойство RenderTransformOrigin, как показано ниже: <Button RenderTransformOrigin=.5,0.5"> Вспомните, что свойство RenderTransformOrigin использует относительные единицы от 0 до 1, так что 0.5 представляет среднюю точку. Для остановки вращения может использоваться второй триггер, реагирующий на событие MouseLeave. В этот момент можно удалить раскадровку, выполняющую вращение, однако это заставит кнопку вернуться к своей исходной ориентации за один шаг. Более удачный подход заключается в запуске второй анимации, которая заменит первую. В этой анимации свойства То и From опущены, вследствие чего кнопка плавно развернется в свое исходное положение примерно на 0,2 секунды: <EventTrigger RoutedEvent="Button.MouseLeave"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation StoryDoard.TargetProperty="LayoutTransform.Angle" Duration=:0:0.2"></DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> Чтобы создать собственную вращающуюся кнопку, необходимо добавить оба эти триггера в коллекцию Button.Triggers. Или же можно поместить их (вместе с трансформацией) в стиль и применить этот стиль к произвольному количеству кнопок. Например, ниже представлен код разметки для окна, заполненного "вращающимися" кнопками (рис. 16.1).
444 Глава 16. Расширенная анимация <Window x:Class=,,Animation.RotateButton" ... > <Window.Resources> <Style TargetType="{x:Type Button}"> <Setter Property="HorizontalAlignment" Value="Center"></Setter> <Setter Property="RenderTransformOrigin" Value=.5,0.5"></Setter> <Setter Property="Padding" Value=0,15"></Setter> <Setter Property="Margin" Value="x/Setter> <Setter Property="LayoutTransform"> <Setter.Value> <RotateTransformx/RotateTransform> </Setter.Value> </Setter> <Style.Triggers> <EventTrigger RoutedEvent="Button.MouseEnter"> </EventTrigger> <EventTrigger RoutedEvent="Button.MouseLeave"> </EventTrigger> </Style.Triggers> </Style> </Window.Resources> <StackPanel Margin=" Button.Click="cmd_Clicked"> <Button>One</Button> <Button>Two</Button> <Button>Three</Button> <Button>Four</Button> <TextBlock Name="lbl" Margin="x/TextBlock> </StackPanel> </Window> Щелчок на любой кнопке в элементе TextBox приводит к отображению сообщения. Этот пример также дает отличный шанс понять разницу между RenderTransformn LayoutTransform. Модифицировав код для использования LayoutTransform, вы увидите, что другие кнопки отодвигаются, освобождая место активной кнопке для поворота (рис. 16.2). Например, если верхняя кнопка поворачивается, то все, находящиеся ниже, отодвинутся, чтобы не мешать ей. Разумеется, чтобы получить более полное представление о поведении кнопок в этом случае, стоит обратиться к загружаемому коду примеров. Рис. 16.1. Использование трансформации отображения ■ RotateButtonWithLayout ..' One I Four ; Рис. 16.2. Использование трансформации компоновки
Глава 16. Расширенная анимация 445 Множество анимированных трансформаций Разные трансформации можно легко комбинировать друг с другом. Фактически нужно просто установить свойство LayoutTransform или RenderTransform в экземпляр TransformGroup. В TransformGroup можно вкладывать произвольное число трансформаций. На рис. 16.3 показан интересный эффект, полученный в результате использования двух трансформаций. Окно документа начинается с маленькой пиктограммы в левом верхнем углу окна. Когда окно появляется, это содержимое вращается, расширяется и быстро становится видимым. Это концептуально подобно эффекту, используемому Windows при развертывании окна. В WPF можно задействовать этот трюк в любых элементах, применяющих трансформацию. Рис. 16.3. Содержимое, которое "запрыгивает" в представление Чтобы создать такой эффект, в TransformGroup определяются две трансформации, которые затем используются для установки свойства RenderTransform объекта Border, включающего все содержимое:
446 Глава 16. Расширенная анимация <Border.RenderTransform> <TransformGroup> <ScaleTransformx/ScaleTransf orm> <RotateTransf ormx/RotateTransform> </TransformGroup> </Border.RenderTransform> Анимация может взаимодействовать с обоими этими объектами трансформации за счет указания числового смещения @ — для ScaleTransform, появляющейся первой, и 1 — для последующей RotateTransform). Например, вот так выглядит анимация, увеличивающая содержимое: <DoubleAnimation Storyboard.TargetName="element" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleX" From=" To="l" Duration=:0:2" AccelerationRatio="l"> </DoubleAnimation> <DoubleAnimation Storyboard.TargetName="element" Storyboard.TargetProperty="RenderTransform.Children[0].ScaleY" From=" To="l" Duration=:0 :2" AccelerationRatio="l"> </DoubleAnimation> А вот — анимация в той же раскадровке, которая вращает его: <DoubleAnimation Storyboard.TargetName="element" Storyboard.TargetProperty="RenderTransform.Children[1].Angle" From=0" To=" Duration=:0:2"> </DoubleAnimation> На самом деле эта анимация несколько более сложная, нежели здесь показано. Например, здесь также присутствует анимация, увеличивающая в то же время значение свойства Opacity, и когда Border достигает полного размера, то кратко "отскакивает" назад, создавая более естественное впечатление. Создание временной шкалы для такой анимации и управление разными свойствами объектов анимации требует времени. В идеале подобные вещи лучше делать с применением инструментов графического дизайна, таких как Expression Blend, чем кодировать их вручную. Но еще лучше поручить это сторонним разработчикам, которые специализируются на таких вещах, чтобы они собрали всю логику в единую анимацию, которую впоследствии можно было бы использовать многократно и применять к своим объектам по мере необходимости. (На данный момент повторное использование этой анимации осуществляется за счет сохранения Storyboard как ресурса уровня приложения.) Такой эффект оказывается неожиданно полезным. Например, вы можете применять его, чтобы привлечь внимание к новому содержимому, вроде только что открытого пользователем файла. Возможным вариациям нет конца. Например, компания, занимающаяся розничной торговлей, может создать каталог продуктов, в котором сдвигаются панели с подробностями о продукте или в окне разворачивается "свиток" с изображением продукта, когда вы проводите курсором над именем соответствующего продукта. Анимированные кисти Анимированные кисти — еще одна распространенная техника в анимации WPF, которую реализовать столь же легко, как анимированные трансформации. Опять-таки, этот прием заключается в проникновении в определенное подсвойство, которое нужно изменить, используя для этого анимацию соответствующего типа. На рис. 16.4 показан пример, в котором изменяется кисть RadialGradientBrush. При запуске анимации центральная часть радиального градиента движется по эллиптической траектории, создавая некий трехмерный эффект. В то же время внешний цвет градиента изменяется от синего к черному.
Глава 16. Расширенная анимация 447 * ' AntmateRadialGradient С 4 Ш % ■ ' AntmateRadiaiGradient Рис, 16,4. Изменение радиального градиента Для реализадии этой анимации понадобятся анимации двух типов, которые пока еще не рассматривались. ColorAnimation обеспечивает плавный переход между двумя цветами, создавая тонкий эффект сдвига цвета. Point Animation позволяет перемещать точку из одного места в другое (по сути, это то же самое, что и одновременная модификация обеих координат X и Y с использованием отдельной анимации DoubleAnimation с линейной интерполяцией). Анимацию PointAnimation можно применять для деформации фигуры, состоящей из точек, или же изменять местоположение центра радиального градиента, как в данном примере. Ниже показан код разметки, определяющий эллипс и его кисть: <Ellipse Name="ellipse" Margin=" Grid.Row="l" Stretch="Uniform"> <Ellipse.Fill> <RadialGradientBrush RadiusX="l" RadiusY="l" GradientOngin= .7, 0 . 3"> <GradientStop Color="White" Offset="></GradientStop> <GradientStop Color="Blue" Offset="l"></GradientStop> </RadialGradientBrush> </Ellipse.Fill> </Ellipse> А вот две анимации, которые перемещают центральную точку и изменяют второй цвет: <PointAnimation Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Fill.GradientOrigin" From=.7,0.3" To=.3,0.7" Duration=:0:10" AutoReverse="True" RepeatBehavior="Forever"> </PointAnimation> <ColorAnimation Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Fill.GradientStops[1].Color" To="Black" Duration=:0:10" AutoReverse="True" RepeatBehavior="Forever"> </ColorAnimation> Варьируя цвета и смещения в LinearGradientBrush и RadialGradientBrush, можно создать огромное разнообразие завораживающих эффектов. Вдобавок градиентные кисти также имеют собственное свойство RelativeTransform, которое можно использовать для вращения, масштабирования, растяжения и наклона. Команда разработчиков WPF располагает забавным инструментом Gradient Obsession, который предназначен для построения анимации на основе градиентов. Он доступен (вместе с исходным кодом) по
448 Глава 16. Расширенная анимация адресу http://tinyurl.com/yc5fjpm. Некоторые дополнительные идеи можно почерпнуть в примерах от Чарльза Петцольда (Charles Petzold) по адресу http://tinyurl.com/ y92mf 8а, где изменяется геометрия разнообразных объектов DrawingBrush и создаются мозаичные шаблоны, которые преобразуются в различные фигуры. Кисть VisualBrush Как уже известно из главы 12, кисть VisualBrush позволяет захватить внешний вид любого элемента и использовать его для заполнения другой поверхности. Этой другой поверхностью может быть что угодно — от обычного прямоугольника до букв текста. На рис. 16.5 показан базовый пример. Сверху находится реальная кнопка. Ниже используется кисть VisualBrush для заполнения прямоугольника изображением этой кнопки, которая растягивается и вращается, создавая эффект разнообразных трансформаций. VisualBrush также открывает некоторые интересные возможности для анимации. Например, вместо анимации реального элемента можно выполнить анимацию простого прямоугольника, имеющего то же заполнение. Чтобы понять, как работает этот механизм, рассмотрим пример, приведенный выше на рис. 16.3, в котором элемент "вталкивается" в визуальное представление. Пока выполняется эта анимация, анимируемый элемент трактуется как любой другой элемент WPF, а это означает, что можно выполнить щелчок на кнопке внутри или прокрутить его содержимое с помощью клавиатуры (если успеете). В некоторых ситуациях это может привести к путанице. В других ситуациях — ухудшить производительность из-за дополнительных накладных расходов, необходимых для выполнения трансформации ввода (например, щелчков мыши) и передачи его исходному элементу. Воспроизвести этот эффект с помощью VisualBrush очень легко. Для начала потребуется создать другой элемент, который заполняет себя с использованием VisualBrush. Эта кисть VisualBrush должна нарисовать себя на основе внешнего вида элемента, который будет анимироваться (в данном примере элементом является именованная рамка). <Rectangle Name="rectangle"> <Rectangle.Fill> <VisualBrush Visual="{Binding ElementName=element}"> </VisualBrush> </Rectangle.Fill> <Rectangle.RenderTransform> <TransformGroup> <ScaleTransformx/ScaleTransform> <RotateTransformx/RotateTransform> </TransformGroup> </Rectangle.RenderTransform> </Rectangle> Чтобы поместить прямоугольник в позицию исходного элемента, их обоих можно вставить в одну и ту же ячейку Grid. Размер ячеек устанавливается равным размеру исходного элемента (рамки), а прямоугольник растягивается до его размеров. Другой выбор добавить перекрывающий контейнер Canvas поверх реального контейнера компоновки. (Затем можно привязать свойства анимации к свойствам ActualWidth и Actual Height реального элемента, лежащего в основе, чтобы гарантировать их совпадение.) После добавления прямоугольника понадобится просто подстроить анимации, чтобы анимировать его трансформации. Финальный шаг — по окончании анимации скрыть прямоугольник: private void storyboardCompleted (object sender, EventArgs e) { rectangle.Visibility = Visibility.Collapsed; }
Глава 16. Расширенная анимация 449 ■ BlurringButtons у—нчюш1! Two Рис. 16.5. Анимация элемента, за- Рис. 16.6. Анимация построителя крашенного VisualBrush текстуры Анимация построителей текстур В главе 14 вы ознакомились с построителями текстур — низкоуровневыми процедурами, применяющими эффекты стиля растровых изображений, такие как размытие, блеск и искажение любого элемента. Сами по себе построители текстур — интересный, но лишь отчасти полезный инструмент. Однако в комбинации с анимацией они становятся намного более многосторонними. Их можно использовать для создания привлекательных на взгляд переходов (например, размывая один элемент управления, скрывая его, а затем размывая на нем другой). Также их можно применять для создания впечатляющих интерактивных эффектов (например, усиливая блеск кнопки при наведении на нее курсора мыши). Лучше всего то, что анимировать свойства построителя текстуры столь же легко, как любые другие. На рис. 16.6 показана страница, основанная на показанном ранее примере вращающейся кнопки. Она содержит последовательность кнопок, и когда пользователь перемещает курсор мыши над одной из кнопок, запускается анимация. Отличие в том, что в данном примере анимация не вращает кнопку, а сокращает радиус размытия до 0. В результате получается, что при наведении курсора мыши элемент управления становится ярким и в фокусе. Код здесь тот же, что и в примере с вращающейся кнопкой. Каждой кнопке должен быть назначен эффект BlurEffeet вместо трансформации RotateTransform: <Button Content="A Button"> <Button.Effect> <BlurEffect Radius=0"X/BlurEffect> </Button.Effect> </Button> Соответственно понадобится изменить анимацию: <EventTrigger RoutedEvent="Button.MouseEnter"> <EventTngger. Act ions > <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Effect.Radius" To=" Duration= : 0:0. 4"X/DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTngger>
450 Глава 16. Расширенная анимация <EventTngger RoutedEvent="Button .MouseLeave"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="Effect.Radius" To=0" Duration= : 0 : 0 . 2"X/DoubleAnimation> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTngger> Тот же подход допускается применять и в обратном порядке — для подсветки кнопки. Например, можно было бы воспользоваться построителем текстуры, применяющим эффект блеска для подсветки кнопки, на которую наведен курсор мыши. Если вас интересует использование построителей текстур для анимации переходов страницы, обратите внимание на библиотеку WPF Shaders Effects Library по адресу http://codeplex. com/wpf f x. Она включает множество бросающихся в глаза построителей текстур, а также набор вспомогательных классов для выполнения переходов с их помощью. Анимация ключевого кадра Все виды анимации, которые были показаны до сих пор, используют линейную интерполяцию для перемещения из начальной точки в конечную. Но что, если понадобится создать анимацию, имеющую множество сегментов и движущуюся менее равномерно? Например, может понадобиться создать анимацию, сначала быстро задвигающую элементы в представление, а затем медленно перемещающую их в окончательные места. Такого эффекта можно достичь, создав последовательность двух анимаций и применив свойство BeginTime для запуска второй анимации по окончании первой. Однако существует более простой подход — использование анимации ключевого кадра. Анимация ключевого кадра (key frame animation) — это анимация, состоящая из множества коротких сегментов. Каждый сегмент в анимации представляет начальное, конечное и промежуточное значения. После запуска анимация плавно переходит от одного значения к другому. Например, рассмотрим анимацию Point, позволяющую перемещать центральную точку RadialGradientBrush из одного места в другое: <PointAnimation Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Fill.GradientOrigin" From=.7,0.3" To=.3,0.7" Duration=:0:10" AutoReverse="True" RepeatBehavior="Forever"> </PointAnimation> Этот объект PointAnimation может быть заменен эквивалентным PointAnimation UsingKeyFrames, как показано ниже: <PointAnimationUsingKeyFrames Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Fill.GradientOrigin" AutoReverse="True" RepeatBehavior="Forever"> <LinearPointKeyFrame Value=.7,0.3" KeyTime=:0:0"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.3,0.7" KeyTime=:0:10"></LinearPointKeyFrame> </PointAnimationUsingKeyFrames> Эта анимация содержит два ключевых кадра. Первый устанавливает значение Point при первом запуске анимации (если хотите использовать текущее значение, установленное в RadialGradientBrush, то этот ключевой кадр можно опустить). Второй ключевой кадр определяет конечное значение, которое достигается через 10 секунд. Объект
Глава 16. Расширенная анимация 451 PointAnimationUsingKeyFrames выполняет линейную интерполяцию для плавного перемещения от первого кадра ко второму — так же, как это делает PointAnimation с установленными значениями From и То. На заметку! Каждый ключевой кадр использует собственный объект анимации (вроде LinearPointKeyFrame). По большей части эти классы одинаковы — они включают свойство Value, хранящее целевое значение, и свойство Key Time, указывающее момент, когда кадр достигнет целевого значения. Единственное отличие связано с типом данных свойства Value. В LinearPointKeyFrame это Point, в DoubleKeyFrame — double и т.д. Используя последовательности ключевых кадров, можно построить и более интересный пример. Следующая анимация проводит центральную точку через серию позиций, достигаемых в разные моменты времени. Скорость перемещения центральной точки меняется в зависимости от того, насколько длительной является задержка между кадрами, и какую дистанцию ей нужно пройти. <PointAnimationUsingKeyFrames Storyboard.TargetName="ellipse" Storyboard.ТагдеtProperty="Fill.GradientOrigin" RepeatBehavior="Forever"> <LinearPointKeyFrame Value=.7,0.3" KeyTime=:0:0"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.3,0.7" KeyTime=:0:5"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.5,0.9" KeyTime=:0:8"></LinearPointKeyFrarne> <LinearPointKeyFrame Value=.9,0.6" KeyTime=:0:10"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.8,0.2" KeyTime=:0:12"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.7,0.3" KeyTime=:0:14"></LinearPointKeyFrame> </PointAnimationUsingKeyFrames> Эта анимация не является обратимой, но она повторяется. Чтобы исключить прыжки между финальным значением одной итерации и стартовым значением следующей, анимация завершается в той же точке, что и начинается. В главе 27 приведен другой пример анимации ключевого кадра. В нем используется анимация Point3DanimationUsingKeyFrames для перемещения камеры по трехмерной сцене и Vector3DanimationUsingKeyFrames для одновременного вращения камеры. На заметку! Использование анимации ключевого кадра не обеспечивает такую же мощность, как последовательное применение множества анимаций. Наиболее существенное отличие в том, что к каждому ключевому кадру нельзя применять разные значения AccelerationRatio и DecelerationRatio — можно указать только одно значение для всей анимации в целом. Дискретные анимации ключевого кадра В анимации ключевого кадра, показанной в предыдущем примере, используются линейные ключевые кадры. В результате происходит гладкий переход между значениями ключевых кадров. Другим вариантом являются дискретные ключевые кадры. В их случае никакой интерполяции не выполняется. Когда наступает время очередного ключа, свойство мгновенно принимает новое значение. Имена классов линейных ключевых кадров образуются в форме LinearТипДанных KeyFrame. Имена классов дискретных ключевых кадров строятся в соответствии со схемой DiscreteГилДаяяыхКеуГгате. Рассмотрим измененную версию примера RadialGradientBrush, в которой используются дискретные ключевые кадры: <PointAnimationUsingKeyFrames Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Fill.GradientOrigin" RepeatBehavior="Forever"> <DiscretePointKeyFrame Value=.7, 0.3" KeyTime=:0:0"></DiscretePointKeyFrame>
452 Глава 16. Расширенная анимация <DiscretePointKeyFrame Value=.3,0.7" KeyTime=:0:5"></DiscretePointKeyFrame> <DiscretePointKeyFrame Value=.5,0.9" KeyTime=:0:8"></DiscretePointKeyFrame> <DiscretePointKeyFrame Value=.9,0.6" KeyTime=:0:10"></DiscretePointKeyFrame> <DiscretePointKeyFrame Value=.8,0 .2" KeyTime=:0:12"></DiscretePointKeyFrame> <DiscretePointKeyFrame Value=.7,0.3" KeyTime=:0:14"x/DiscretePointKeyFrame> </PointAnimationUsingKeyFrames> При запуске этой анимации центральная точка будет перепрыгивать из одной позиции в другую в соответствующие моменты времени. Эффект получается достаточно резким. Все классы анимации ключевого кадра поддерживают дискретные ключевые кадры, но только некоторые из них поддерживают линейные ключевые кадры. Все зависит от типа данных. Типы данных, поддерживающие линейные ключевые кадры — те же самые, что поддерживают и линейную интерполяцию, и они предоставляют класс TMnMaHHHxAnimation. Примеры включают Point, Color и double. Типы данных, не поддерживающие линейную интерполяцию, включают строки и объекты. В главе 26 будет показан пример, использующий класс StringAnimationUsingKeyFrames для отображения разных частей текста в процессе анимации. Совет. В одной и той же анимации ключевого кадра можно комбинировать оба типа ключевых кадров — линейные и дискретные. Плавные ключевые кадры В предыдущей главе было показано, что функции плавности могут улучшить простые анимации. Несмотря на то что анимации ключевого кадра разделяются на несколько сегментов, каждый из этих сегментов использует обычную скучную линейную интерполяцию. Если это не то, что требуется, можно воспользоваться плавностью анимации для добавления ускорения и замедления к индивидуальным ключевым кадрам. Однако обычные линейные ключевые кадры и классы дискретных ключевых кадров не поддерживают этого средства. Взамен придется применять плавный ключевой кадр, такой как EasingDoubleKeyFrame, EasingColorKeyFrame и EasingPointKeyFrame. Каждый из них работает подобно своему линейному аналогу, но предоставляет дополнительное свойство EasingFunction. Ниже приведен пример, в котором плавность анимации используется для применения эффекта ускорения к первым 5 секундам анимации ключевого кадра: <PointAnimationUsingKeyFrames Storyboard.TargetName="ellipseBrush" Storyboard.ТагgetProperty="GradientOrigin" RepeatBehavior="Forever"> <LinearPointKeyFrame Value=.7,0.3" KeyTime=:0:0"></LinearPointKeyFrame> <EasingPointKeyFrame Value=.3,0.7" KeyTime=:0:5"> <EasingPointKeyFrame.EasingFunction> <CircleEaseX/CircleEase> </EasingPointKeyFrame.EasingFunction> </EasingPointKeyFrame> <LinearPointKeyFrame Value=.5,0.9" KeyTime=:0:8"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.9,0.6" KeyTime=:0:10"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.8,0 .2" KeyTime=:0:12"></LinearPointKeyFrame> <LinearPointKeyFrame Value=.7,0.3" KeyTime=:0:14"></LinearPointKeyFrame> </PointAnimationUsingKeyFrames> Комбинация ключевых кадров с функциями плавности анимации дает удобный способ моделирования сложных анимаций, но, возможно, еще не обеспечит нужную сте-
Глава 16. Расширенная анимация 453 пень управления. Вместо использования функции плавности анимации можно создать математическую формулу, которая описывает ход анимации. Этот прием рассматривается в следующем разделе. Сплайновые анимации ключевого кадра Существует еще один тип ключевых кадров: онлайновый ключевой кадр. Каждый класс, поддерживающий линейные ключевые кадры, поддерживает также и сплайновые ключевые кадры, и их имена формируются по шаблону SpliteTnn#<3HHb/xKeyFrame. Подобно линейным ключевым кадрам, сплайновые ключевые кадры применяют интерполяцию для гладкого перемещения от одного ключевого значения к другому. Отличие состоит в том, что каждый сплайновый ключевой кадр оснащен свойством KeySpline. Используя это свойство, вы определяете кубическую кривую Безье, которая формирует путь интерполяции. Хотя здесь довольно трудно получить нужный эффект (по крайней мере, без инструментов графического дизайна, которые могут в этом помочь), все же эта техника предоставляет возможность создавать более плавное ускорение и замедление, а также другие естественные движения. Как вы помните из главы 13, кривая Безье определяется начальной точкой, конечной точкой и двумя опорными точками. В случае ключевого сплайна стартовая точка всегда находится в начале координат @,0), а конечная точка — в A,1). Вы просто задаете две опорных точки. Создаваемая кривая описывает отношение между временем (осью X) и анимируемым значением (ось Y). Рассмотрим пример, демонстрирующий анимацию ключевого сплайна, сравнив движение двух эллипсов по поверхности Canvas. Первый эллипс использует Double Animation для медленного равномерного перемещения по окну. Второй эллипс применяет DoubleAnimationUsingKeyFrames с двумя объектами SplineDoubleKeyFrame. Оба они достигают конечной точки одновременно (через 10 секунд), но второй ускоряется и замедляется на протяжении своего пути, обгоняя и отставая от первого эллипса. <DoubleAnimation Storyboard.TargetName="ellipsel" Storyboard.TargetProperty="(Canvas.Left)" To=00" Duration=:0:10"> </DoubleAnimation> <DoubleAnimationUsingKeyFrames Storyboard.ТаrgetName="ellipse2" Storyboard.TargetProperty="(Canvas.Left)"> <SplineDoubleKeyFrame KeyTime=:0:5" Value=50" KeySpline=.25,0 0 . 5, 0 . 7"></SplineDoubleKeyFrarne> <SplineDoubleKeyFrame KeyTime=:0:10" Value=00" KeySpline= .25, 0 .8 0 .2, 0 . 4"x/SplineDoubleKeyFrame> </DoubleAnimationUsingKeyFrames> Наибольшее ускорение достигается вскоре после пятисекундной отметки, когда вступает в действие второй SplineDoubleKeyFrame. Его первая опорная точка соответствует относительно большому значению по оси Y, представляющему ход анимации @.8), с относительно малым значением по оси X, представляющей время. В результате эллипс ускоряется на протяжении малого расстояния, прежде чем снова замедлиться. На рис. 16.7 показана графическая траектория двух кривых, управляющих движением эллипса. Чтобы интерпретировать эти кривые, вспомните, что они отображают ход анимации сверху вниз. Посмотрев на первую кривую, вы увидите, что она описывает относительно равномерное движение вниз, с краткой паузой в начале и плавным выравниванием в конце. Однако вторая кривая ниспадает вниз относительно быстрее, достигая максимальной скорости, а затем в оставшейся части анимации выравнивается.
454 Глава 16. Расширенная анимация Рис. 16.7. График выполнения анимации ключевого сплайна Анимация на основе пути Анимация на основе пути использует объект PathGeometry для установки значения свойства. Хотя такая анимация может в принципе применяться для модификации любого свойства с правильным типом данных, но наиболее удобна для анимации свойств, описывающих позицию. Фактически классы анимации на основе пути, прежде всего, предназначены для того, чтобы помочь перемещать визуальные эффекты по некоторой траектории. Как известно из главы 13, объект PathGeometry описывает фигуру, которая может включать прямые линии, дуги и кривые. На рис. 16.8 демонстрируется пример с объектом PathGeometry, состоящим из двух дуг и сегмента прямой линии, которая соединяет последнюю определенную точку с начальной. Это формирует замкнутую траекторию, по которой равномерно движется маленькое векторное изображение. ■ ' PathBasedAnimation Рис. 16.8. Перемещение изображения по траектории Создать такой пример довольно легко. Первый шаг — задать путь, который необходимо использовать. В данном примере он описан как ресурс: <Window.Resources> <PathGeometry x:Key="path"> <PathFigure IsClosed="True"> <ArcSegment Point=00,200" Size=5,10" SweepDirection="Clockwise"x/ArcSegment> <ArcSegment Point=00,50" Size=,5"></ArcSegment>
Глава 16. Расширенная анимация 455 </PathFigure> </PathGeometry> </Window.Resources> Хотя это и не обязательно, в данном примере путь отображается. Таким образом, можно удостовериться, что изображение следует по определенному маршруту. Чтобы показать маршрут, достаточно добавить элемент Path, использующий заданную геометрию: <Path Stroke="Red" StrokeThickness="l" Data="{StaticResource path}" Canvas.Top=0" Canvas.Left=0"> </Path> Элемент Path помещается в Canvas наряду с элементом Image, который требуется перемещать по этому пути: <Image Name="image"> <Image.Source> <DrawingImage> <DrawingImage.Drawing> <GeometryDrawing Brush="LightSteelBlue"> <GeometryDrawing.Geometry> <GeometryGroup> <EllipseGeometry Center=0,10" RadiusX="9" RadiusY=" /> <EllipseGeometry Center=0,10" RadiusX=" RadiusY="9" /> </GeometryGroup> </GeometryDrawing.Geometry> <GeometryDrawing.Pen> <Pen Thickness="l" Brush="Black" /> </GeometryDrawing.Pen> </GeometryDrawing> </DrawingImage.Drawing> </DrawingImage> </Image.Source> </Image> Заключительный шаг — создание анимации, перемещающей картинку. Чтобы перемещать изображение, нужно изменять свойства Canvas.Left и Canvas.Top. Этот трюк выполняет анимация DoubleAnimationUsingPath, но их понадобится две — одна работает со свойством Canvas.Left, а другая — с Canvas.Top. Ниже показано полное описание раскадровки. <Storyboard> <DoubleAnimationUsingPath Storyboard.TargetName="image" Storyboard.TargetProperty="(Canvas.Left)" PathGeometry="{StaticResource path}" Duration=:0:5" RepeatBehavior="Forever" Source="X" /> <DoubleAnimationUsingPath Storyboard.TargetName="image" Storyboard.TargetProperty="(Canvas.Top)" PathGeometry="{StaticResource path}" Duration=:0:5" RepeatBehavior="Forever" Source="Y" /> </Storyboard> Как видите, при создании анимации на основе пути начальные и конечные значения не указываются. Вместо этого задается геометрия PathGeometry, которая должна использоваться в свойстве PathGeometry. Некоторые классы анимации на основе пути, такие как PointAnimationUsingPath, применяют к целевому свойству оба компонента — X и Y. Класс DoubleAnimationUsingPath лишен такой возможности, поскольку устанавливает единственное значение типа double. Вследствие этого также понадобится установить свойство БоигсевХиУ, чтобы указывать, когда вы используете координату пути X, а когда — Y.
456 Глава 16. Расширенная анимация Хотя анимация на основе пути может использовать траекторию, заданную кривой Безье, это несколько отличается от ее применения в анимации ключевого сплайна, о котором шла речь в предыдущем разделе. В анимации ключевого сплайна кривая Безье описывала отношение между ходом анимации и временем, позволяя создать анимацию с переменной скоростью. Но в анимации на основе пути коллекция отрезков и кривых, образующих путь, определяет значения, которые будут применяться для анимируемого свойства. На заметку! Анимации на основе пути всегда выполняются с постоянной скоростью. Для вычисления скорости WPF берет общую длину маршрута и указанную длительность. Анимация на основе кадра Наряду с системой анимации, основанной на изменении свойств, WPF предоставляет способ создания анимации на основе кадра, не используя ничего помимо кода. Все, что понадобится — реагировать на событие СоmpositionTarget.Rendering, которое возбуждается для получения содержимого каждого кадра. Это довольно низкоуровневый подход, который стоит применять только в том случае, когда выясняется, что стандартная модель анимации на основе изменения свойств не подходит для реализации существующего сценария (например, при построении простой игры с прокруткой экрана, создании анимации на основе физических законов либо моделировании таких эффектов, как огонь, снег или пузыри). Основная техника для построения анимации на базе кадра проста. Нужно просто присоединить обработчик событий к статическому событию CompositionTarget .Rendering. После этого WPF начнет непрерывно вызывать этот обработчик. (До тех пор, пока код отображения выполняется достаточно быстро, WPF будет вызывать его 60 раз в секунду.) В обработчике события визуализации создание и управление элементами в окне полностью возлагается на вас. Другими словами, всей работой понадобится управлять самостоятельно. Когда анимация завершится, обработчик события следует отключить. На рис. 16.9 показан достаточно простой пример. Здесь случайное количество кружков падают от верхней к нижней границе Canvas. Они падают с разной (случайно выбранной) скоростью, но по мере движения скорость каждого возрастает в одинаковой степени. Анимация завершается, когда все круги упадут вниз. Рис. 16.9. Анимация падающих кружков на основе кадра
Глава 16. Расширенная анимация 457 В данном примере каждый падающий кружок представлен элементом Ellipse. Специальный класс по имени Ellipselnfo сохраняет ссылку на эллипс и отслеживает детали, существенные для его физической модели. В данном случае здесь присутствует только одна единица информации — смещение эллипса по оси X. (Этот класс может быть легко расширен добавлением скорости по оси Y, дополнительной информации относительно ускорения и т.п.) public class Ellipselnfo { public Ellipse Ellipse get; set; public double VelocityY get; set; public Ellipselnfo (Ellipse ellipse, double velocityY) VelocityY = velocityY; Ellipse = ellipse; } Приложение отслеживает объект Ellipselnfo для каждого эллипса, используя для этого коллекцию. Существует еще несколько полей уровня окна, которые хранят различные подробности, используемые при вычислении падения эллипса. Их легко сделать настраиваемыми. private List<EllipseInfo> ellipses = new List<EllipseInfo> () ; private double accelerationY = 0.1; private int minStartingSpeed = 1; private int maxStartingSpeed = 50; private double speedRatio = 0.1; private int minEllipses = 20; private int maxEllipses = 100; private int ellipseRadius = 10; По щелчку на кнопке коллекция очищается, и обработчик события присоединяется к событию CompositionTarget.Rendering: private bool rendering = false; private void cmdStart_Clicked(object sender, RoutedEventArgs e) { if (!rendering) { ellipses.Clear(); canvas.Children.Clear(); CompositionTarget.Rendering += RenderFrame; rendering = true; } } Если эллипс не существует, код визуализации создаст его автоматически. Он создает случайное количество эллипсов (в данном случае — от 20 до 100) и устанавливает для каждого из них одинаковый размер и цвет. Эллипсы помещаются в верхнюю часть Canvas, но их смещение по оси X задается случайным образом.
458 Глава 16. Расширенная анимация private void RenderFrame(object sender, EventArgs e) { if (ellipses.Count == 0) { // Анимация запущена. Создать эллипсы. int halfCanvasWidth = (int)canvas.ActualWidth / 2; Random rand = new Random () ; int ellipseCount = rand.Next(minEllipses, maxEllipses+1); for (int i=0; l < ellipseCount; i++) { // Создание эллипса. Ellipse ellipse = new Ellipse (); ellipse.Fill = Brushes.LimeGreen; ellipse.Width = ellipseRadius; ellipse.Height = ellipseRadius; // Размещение эллипса. Canvas.SetLeft(ellipse, halfCanvasWidth + rand.Next(-halfCanvasWidth, halfCanvasWidth)); Canvas.SetTop(ellipse, 0); canvas.Children.Add(ellipse); // Отслеживание эллипса. Ellipselnfo info = new Ellipselnfo(ellipse, speedRatio * rand.Next(minStartingSpeed, maxStartingSpeed)); ellipses.Add(info); } } Если данный эллипс уже существует, код выполняет наиболее интересную работу по его анимации. Каждый эллипс слегка смещается с использованием метода Canvas. SetTopO. Размер смещения определяется назначенной ему скоростью. else { for (int i = ellipses .Count-1; i >= 0; i--) { Ellipselnfo info = ellipses [i]; double top = Canvas.GetTop (info.Ellipse) ; Canvas.SetTop(info.Ellipse, top + 1 * info.VelocityY); Для повышения производительности эллипсы удаляются из коллекции, как только достигают нижней части Canvas. Таким образом, после этого обрабатывать их уже не понадобится. Чтобы позволить выполнять эту работу, не теряя текущего места при прохождении коллекции, нужно организовать итерацию с конца коллекции к ее началу. Если эллипс пока не достиг нижней границы Canvas, код увеличивает его скорость. (В качестве альтернативы можно установить скорость на основе близости к нижней границе Canvas, тем самым создавая эффект "магнита".) if (top >= (canvas.ActualHeight - ellipseRadius*2)) { // Если эллипс достиг нижней части Canvas, // прекратить его анимацию. ellipses.Remove(info); } else { // Увеличить скорость.
Глава 16. Расширенная анимация 459 info.VelocityY += accelerationY; } И, наконец, если все эллипсы удалены из коллекции, обработчик событий удаляется, завершая анимацию: if (ellipses.Count == 0) { // Завершить анимацию. // Нет смысла продолжать вызывать этот метод, // когда ему нечего делать. CompositionTarget.Rendering -= RenderFrame; rendering = false; } } } } Очевидно, что эту анимацию можно расширить, заставив кружки подпрыгивать, разлетаться и т.п. Техника одна и та же — просто нужно реализовать более сложные формулы для вычисления скорости. Необходимо упомянуть об одном обстоятельстве, которое следует иметь в виду при построении анимации на основе кадра: она не зависит от времени. Другими словами, анимация может работать быстрее на быстрых компьютерах, поскольку частота кадров будет расти, и событие CompositionTarget.Rendering станет инициироваться чаще. Чтобы компенсировать этот эффект, потребуется написать код, принимающий во внимание текущее время. Наилучший способ начать работать с анимацией на основе кадра — исследовать довольно подробный пример анимации, входящий в состав WPF SDK (и также включенный в загружаемый код для этой главы). Он демонстрирует несколько практических эффектов и использует класс TimeTracker для реализации анимации на основе кадра, зависящей от времени. Раскадровки в коде В предыдущей главе было показано, как с помощью кода создавать простые разовые анимации и каким образом строятся более сложные раскадровки, заполненные множеством анимаций и элементов управления воспроизведением, посредством XAML- разметки. Но иногда имеет смысл выбрать более сложный маршрут раскадровки и выполнить всю необходимую работу в коде. Фактически это довольно распространенный сценарий. Он случается всякий раз, когда приходится иметь дело с множеством анимаций, и заранее не известно, сколько их, и как они должны быть сконфигурированы. (Именно так обстоят дела с простым примером игры со сбрасыванием бомб, которая рассматривается в этом разделе.) То же происходит, когда необходимо использовать одну и ту же анимацию в разных окнах, либо просто добиться гибкости, отделив все связанные с анимацией детали от разметки для облегчения повторного использования. Создать, сконфигурировать и запустить раскадровку в коде совсем нетрудно. Нужно лишь создать объекты анимации и раскадровки, добавить анимации к раскадровке и запустить раскадровку. По завершении анимации можно выполнить любую работу по очистке, реагируя на событие Storyboard.Completed. В следующем примере будет показано, как создать игру, показанную на рис. 16.10. Здесь задача игрока состоит в том, чтобы сбивать бомбы, которые сбрасываются с все возрастающей скоростью. Игрок должен щелкать на каждой бомбе для ее обезврежи-
460 Глава 16. Расширенная анимация вания. Когда достигается предел пропущенных бомб — по умолчанию, пяти — игра завершается. . в^о,^. А * * .. br-llffiy—] 4~| _Ж I Bomb Ого A bomb is released everyi.a seconds. Each bomb takes 34 seconds to fall. You have dropped о bombs and saved 10. Рис. 16.10. Ловля бомб В этом примере каждая сброшенная бомба имеет собственную раскадровку с двумя анимациями. Первая анимация сбрасывает бомбу (анимируя свойство Canvas.Top), a вторая слегка поворачивает ее вперед и назад, создавая реалистичный эффект Если пользователь щелкает на падающей бомбе, эти анимации завершаются и начинаются две другие, которые безопасно убирают бомбу с поверхности Canvas. Наконец, по завершении каждой анимации приложение проверяет, упала бомба или же была обезврежена пользователем, и соответствующим образом обновляет счетчик. В следующих разделах рассматривается создание всех частей этого примера. Главное окно Главное окно в примере BombDropper очень простое. Оно содержит элемент Grid, состоящий из двух столбцов. Слева находится элемент Border, который содержит элемент Canvas, представляющий игровое поле: <Border Grid.Column=ll0" BorderBrush="SteelBlue11 BorderThickness="l11 Margin=ll5"> <Grid> <Canvas x:Name="canvasBackground11 SizeChanged="canvasBackground_SizeChanged" MinWidth=0"> <Canvas.Background> <RadialGradientBrush> <GradientStop Color=,,AliceBlue" Offset=,,0"></GradientStop> <GradientStop Color=,,White" Of f set= . 7"></GradientStop> </RadialGradientBrush> </Canvas.Background> </Canvas> </Grid> </Border> При первой установке или изменении размеров Canvas со стороны пользователя выполняется следующий код, устанавливающий прямоугольник отсечения:
Глава 16. Расширенная анимация 461 private void canvasBackground_SizeChanged(object sender, SizeChangedEventArgs e) f // Установка прямоугольника отсечения, совпадающего // с текущей областью отображения Canvas. RectangleGeometry rect = new RectangleGeometry(); rect.Rect = new Rect@, 0, canvasBackground.ActualWidth, canvasBackground.ActualHeight); canvasBackground.Clip = rect; } Это нужно потому, что в противном случае Canvas будет рисовать свои дочерние элементы, даже если те расположатся вне области отображения. В игре с перехватом падающих бомб это позволило бы бомбам вылетать за пределы рамки, ограничивающей Canvas. На заметку! Поскольку пользовательский элемент управления определен без явных размеров, он может изменять свои размеры, подгоняя под размеры окна. Логика игры использует текущие размеры окна, не пытаясь компенсировать их каким-либо образом. Поэтому, если окно оказывается очень широким, бомбы будут разлетаться далеко в ширину, затрудняя игру. Аналогично, если окно будет очень высоким, бомбы будут падать быстрее, чтобы пройти свою траекторию за тот же интервал времени. Эту проблему можно обойти, разместив в центре пользовательского элемента управления прямоугольник фиксированных размеров. Однако окно с изменяемыми размерами делает пример более адаптируемым и интересным. В правой части главного окна находится панель, отображающая статистику игры, количество упавших и перехваченных бомб, а также кнопку запуска игры: <Border Grid.Column="l11 BorderBrush="SteelBlue11 BorderThickness="l11 Margin="> <Border.Background> <RadialGradientBrush GradientOngin="l,0.7" Center="l,0.7" RadiusX=,,l" RadiusY=,,l"> <GradientStop Color=,,Orange" Offset=,,0,,x/GradientStop> <GradientStop Color=,,White" Offset=,,l,,x/GradientStop> </RadialGradientBrush> </Border.Background> <StackPanel Margin=511 VerticalAlignment=llCenter" HorizontalAlignment=llCenter"> <TextBlock FontFamily="Impact" FontSize=,,35" Foreground=,,LightSteelBlue"> Bomb Dropper</TextBlock> <TextBlock x:Name=,,lblRate" Margin=, 30, 0, 0" TextWrapping=,,Wrap" FontFamily="Georgia" FontSize=,,14,,x/TextBlock> <TextBlock x:Name=lllblSpeed" Margin=, 30" TextWrapping="Wrap" FontFamily="Georgia" FontSize=4"></TextBlock> <TextBlock x:Name="lblStatus" TextWrapping="Wrap" FontFamily="Georgia" FontSize=0">No bombs have dropped.</TextBlock> <Button x:Name=l!cmdStart" Padding=" Margin=, 30" Width="80" Content="Start Game" Click="cmdStart_Click"></Button> </StackPanel> </Border> Пользовательский элемент управления Bomb Следующий шаг заключается в создании графического изображения бомбы. Хотя можно воспользоваться статическим изображением (с прозрачным фоном), все же лучше иметь дело с более гибкими фигурами WPF. За счет применения фигур появляется возможность изменять размеры бомб, не внося искажений, и выполнять анимацию индивидуальных частей рисунка. Бомба, продемонстрированная в этом примере, взята из доступной онлайновой коллекции миниатюр Microsoft Word. Она преобразована
462 Глава 16. Расширенная анимация в XAML-разметку за счет вставки ее в документ Word и сохранения документа в виде файла XPS; этот процесс описан в главе 12. Полная XAML-разметка, использующая комбинацию элементов Path, здесь не показана. При желании ее можно просмотреть в примере игры BombDropper, входящей в состав загружаемого кода для этой главы. Разметка XAML для класса Bomb слегка упрощена (удалены ненужные дополнительные элементы Canvas вокруг него и трансформации для масштабирования). Затем полученная XAML-разметка вставлена в новый пользовательский элемент управления по имени Bomb. Таким образом, главная страница может отображать бомбу, создавая пользовательский элемент управления Bomb и добавляя его в контейнер компоновки (такой как Canvas). Помещение графики в отдельный пользовательский элемент управления облегчает создание множества копий этой графики в рамках пользовательского интерфейса. Это также позволяет инкапсулировать связанную функциональность, добавляя ее к коду пользовательского элемента управления. В примере с бомбами в код добавляется только одна деталь — булевское свойство, указывающее на то, падает ли бомба в данный момент: public partial class Bomb: UserControl { public Bomb() { InitializeComponent(); } public bool IsFalling { get; set; } } Разметка для бомбы включает трансформацию RotateTransform, код анимации которой позволяет добавить падающей бомбе эффект раскачивания. Хотя создать и добавить RotateTransform можно было бы программно, имеет больше смысла определить его в файле XAML бомбы: <UserControl x:Class="BombDropper.Bomb" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <UserControl.RenderTransform> <TransformGroup> <RotateTransform Angle=0" CenterX=0" CenterY=0"X/RotateTransform> <ScaleTransform ScaleX=.5" ScaleY=.5"></ScaleTransforrn> </TransformGroup> </UserControl.RenderTransform> <Canvas> <•-- Здесь определены элементы Path, рисующие графику бомбы. --> </Canvas> </UserControl> Имея весь этот код, вставить объект бомбы в окно можно с помощью элемента <bomb:Bomb>, подобно тому, как это делается для пользовательского элемента управления Title (рассматривается в предыдущем разделе). Однако в данном случае намного целесообразнее создавать бомбы программно.
Глава 16. Расширенная анимация 463 Сброс бомб Для сброса бомбы приложение использует DispatcherTimer — таймер, который хорошо работает с пользовательским интерфейсом WPF, потому что инициирует события в потоке пользовательского интерфейса, избавляя от сложностей многопоточного программирования, описанных в главе 31. После указания интервала времени DispatcherTimer периодически инициирует событие Tick через этот заданный интервал. private DispatcherTimer bombTimer = new DispatcherTimer(); public MainWindow () { InitializeComponent(); bombTimer.Tick += bombTimer_Tick; } В игре BombDropper таймер сначала срабатывает каждые 1,3 секунды. Таймер запускается, когда пользователь щелкает на кнопке старта игры: // Счетчики сброшенных и перехваченных бомб, private int droppedCount = 0; private int savedCount = 0; // Сначала бомбы падают каждые 1.3 секунды, достигая "земли" за 3. 5 секунды, private double lnitialSecondsBetweenBombs = 1.3; private double initialSecondsToFall = 3.5; private double secondsBetweenBombs; private double secondsToFall; private void cmdStart_Click(object sender, RoutedEventArgs e) { cmdStart.IsEnabled = false; // Сброс игры. droppedCount = 0; savedCount = 0; secondsBetweenBombs = lnitialSecondsBetweenBombs; secondsToFall = initialSecondsToFall; // Запуск таймера сброса бомб. bombTimer.Interval = TimeSpan.FromSeconds(secondsBetweenBombs); bombTimer.Start(); } При каждом срабатывании таймера код создает новый объект Bomb и устанавливает его позицию в Canvas. Бомба помещается прямо над верхней границей Canvas, чтобы плавно входить в видимую область. Ей придается случайное положение по горизонтали, находящееся где-то между левой и правой границами: private void bombTimer_Tick(object sender, EventArgs e) { // Создать бомбу. Bomb bomb = new Bomb () ; bomb.IsFalling = true; // Позиционировать бомбу. Random random = new Random(); bomb.SetValue(Canvas.LeftProperty, (double)(random.Next@, (int)(canvasBackground.ActualWidth - 50)))); bomb.SetValue(Canvas.TopProperty, -100.0); // Добавить бомбу на Canvas. canvasBackground.Children.Add(bomb);
464 Глава 16. Расширенная анимация Затем код динамически создает раскадровку для анимации бомбы. Используются две анимации: одна заставляет бомбу падать, изменяя присоединенное свойство Canvas.Top, а другая раскачивает бомбу, изменяя угол трансформации вращением. Поскольку Storyboard.TargetElement и Storyboard.TargetProperty — это присоединенные свойства, они должны устанавливаться с помощью методов Storyboard. SetTargetElementO и Storyboard.SetTargetProperty(): // Присоединить событие щелчка мыши (для перехвата бомб). bomb.MouseLeftButtonDown += bomb_MouseLeftButtonDown; // Создать анимацию для падающей бомбы. Storyboard storyboard = new Storyboard(); DoubleAnimation fallAnimation = new DoubleAnimation(); fallAnimation.To = canvasBackground.ActualHeight; fallAnimation.Duration = TimeSpan.FromSeconds(secondsToFall) ; Storyboard.SetTarget(fallAnimation, bomb); Storyboard.SetTargetProperty(fallAnimation, new PropertyPath("(Canvas.Top) ") ) ; storyboard.Children.Add(fallAnimation); // Создать анимацию "раскачивания" бомбы. DoubleAnimation wiggleAnimation = new DoubleAnimation(); wiggleAnimation.To = 30; wiggleAnimation.Duration = TimeSpan.FromSeconds @.2); wiggleAnimation.RepeatBehavior = RepeatBehavior.Forever; wiggleAnimation.AutoReverse = true; Storyboard.SetTarget(wiggleAnimation, ((TransformGroup)bomb.RenderTransform) .Children[0] ) ; Storyboard.SetTargetProperty(wiggleAnimation, new PropertyPath("Angle") ) ; storyboard.Children.Add(wiggleAnimation); Обе анимации могут использовать функции плавности для демонстрации более реалистичного поведения, но в этом примере код усложняться не будет, поскольку вполне достаточно базовых линейных анимаций. Вновь созданная раскадровка сохраняется в коллекции типа словаря, что упрощает ее извлечение в других обработчиках событий. Коллекция сохраняется в виде поля в классе главного окна: // Позволяет находить раскадровку по бомбе. private Dictionary<Storyboard, Bomb> bombs = new Dictionary<Storyboard, Bomb>(); Ниже показан код добавления раскадровки в коллекцию: storyboards.Add(bomb, storyboard); Затем присоединяется обработчик событий, который реагирует на завершение анимации fallAnimation, что происходит, когда бомба достигает "земли". Наконец, раскадровка запускается и анимация приводится в действие: storyboard.Duration = fallAnimation.Duration; storyboard.Completed += storyboard_Completed; storyboard.Begin (); Код сброса бомб нуждается в одной последней детали. По мере хода игры она должна становиться все более трудной. Таймер начинает срабатывать чаще, бомбы падают гуще, и время их падения сокращается. Чтобы реализовать такое поведение, код таймера вносит изменения по истечении определенного интервала времени. По умолчанию
Глава 16. Расширенная анимация 465 BombDropper вносит поправки каждые 15 секунд. Вот поля, которые управляют этим поведением: // Вносить поправки каждые 15 секунд. private double secondsBetweenAdjustments = 15; private DateTime lastAdjustmentTime = DateTime.MinValue; // При каждой поправке вычитать по 0.1 секунды из обоих значений, private double secondsBetweenBombsReduction = 0.1; private double secondsToFallReduction = 0.1; А вот код, выполняемый в конце обработчика события DispatcherTimer,Tick, который проверяет, нужна ли поправка, и производящий ее при необходимости: // При необходимости внести поправку. if ( (DateTime.Now.Subtract(lastAdjustmentTime) .TotalSeconds> secondsBetweenAdjustments)) { lastAdjustmentTime = DateTime.Now; secondsBetweenBombs -= secondsBetweenBombsReduction; secondsToFall -= secondsToFallReduction; // (Формально необходимо предпринимать проверку на 0 или отрицательные значения. // Однако на практике этого не произойдет, поскольку игра // всегда закончится раньше.) // Установить таймер для сброса следующей бомбы в соответствующее время. bombTimer.Interval = TimeSpan.FromSeconds(secondsBetweenBombs); // Обновить сообщение о состоянии. lblRate.Text = String. Format ("A bomb is released every {0} seconds.11, secondsBetweenBombs); lblSpeed.Text = String. Format ("Each bomb takes {0} seconds to fall.11, secondsToFall); } } Теперь, когда весь код готов, он обладает достаточной функциональностью, чтобы сбрасывать бомбы с все увеличивающейся частотой. Однако игре пока недостает кода, который реагирует на сброшенные и перехваченные бомбы. Перехват бомбы Пользователь "ловит" бомбу, щелкая на ней до того, как она достигает нижней границы Canvas. Поскольку каждая бомба представлена отдельным экземпляром пользовательского элемента управления Bomb, перехватывать щелчки легко — нужно лишь обработать событие MouseLef tButtonDown, которое инициируется, когда происходит щелчок на любой части бомбы (но не на фоне, например, на углу элемента, вне окружности бомбы). Когда происходит щелчок на бомбе, первый шаг предусматривает получение соответствующего объекта бомбы и установку его свойства IsFalling, указывая, что она больше не падает. (Свойство IsFalling используется обработчиком события, который имеет дело с завершенными анимациями.) private void bomb_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // Получить бомбу. Bomb bomb = (Bomb) sender; bomb.IsFalling = false; // Запомнить ее текущую (анимированную) позицию, double currentTop = Canvas.GetTop(bomb);
466 Глава 16. Расширенная анимация Следующий шаг заключается в нахождении раскадровки, которая управляет анимацией этой бомбы, чтобы остановить ее. Для поиска раскадровки следует обратиться к коллекции, которую использует игра при отслеживании. В настоящее время в WPF не предусмотрено никакого стандартизованного способа находить анимации, работающие на заданном элементе. // Остановить падение бомбы. Storyboard storyboard = storyboards[bomb]; storyboard.Stop(); После щелчка на кнопке с помощью другого набора анимаций бомба убирается с экрана за счет отбрасывания ее вверх, влево или вправо (в зависимости от того, куда ближе). Хотя для реализации этого эффекта можно было бы создать совершенно новую раскадровку, игра Bomb Dropper очищает текущую раскадровку, используемую бомбой, и добавляет к ней новую анимацию. После этого новая раскадровка запускается: // Повторно использовать текущую раскадровку, но с новыми анимациями. // Запустить бомбу по новой траектории, анимируя Canvas.Top и Canvas.Left. storyboard.Children.Clear (); DoubleAnimation riseAnimation = new DoubleAnimation(); riseAnimation.From = currentTop; riseAnimation.To = 0; riseAnimation.Duration = TimeSpan.FromSeconds B); Storyboard.SetTarget(riseAnimation, bomb); Storyboard.SetTargetProperty(riseAnimation, new PropertyPath("(Canvas.Top) ") ) ; storyboard.Children.Add (riseAnimation); DoubleAnimation slideAnimation = new DoubleAnimation (); double currentLeft = Canvas.GetLeft (bomb); // Выбросить бомбу за ближайший край поля. if (currentLeft < canvasBackground.ActualWidth / 2) { slideAnimation.To = -100; } else { slideAnimation.To = canvasBackground.ActualWidth + 100; } slideAnimation.Duration = TimeSpan.FromSecondsA); Storyboard.SetTarget(slideAnimation, bomb); Storyboard.SetTargetProperty(slideAnimation, new PropertyPath("(Canvas.Left) ") ) ; storyboard.Children.Add(slideAnimation) ; // Запустить новую анимацию. storyboard.Duration = slideAnimation.Duration; storyboard.Begin(); } Теперь в игре имеется достаточно кода, чтобы сбрасывать бомбы и перемещать их за пределы экрана. Однако для отслеживания упавших и отброшенных бомб нужно реагировать на событие Storyboard.Completed, которое инициируется в конце анимации. Подсчет бомб и очистка Как было показано, DropBomber использует раскадровки двумя способами: для анимации падающей бомбы и анимации перехваченной бомбы. Завершение работы этих раскадровок можно было бы обработать разными обработчиками событий, но для простоты в DropBomber используется только один. Он отличает взорвавшуюся бомбу от перехваченной, проверяя свойство Bomb.IsFalling.
Глава 16. Расширенная анимация 467 // Завершить игру после пяти упавших бомб. private int maxDropped = 5; private void storyboard_Completed(object sender, EventArgs e) { ClockGroup clockGroup = (ClockGroup)sender; // Получить первую анимацию в раскадровке и воспользоваться //ею для нахождения анимированной бомбы. DoubleAnimation completedAnimation = (DoubleAnimation)clockGroup.Children[0].Timeline; Bomb completedBomb = (Bomb)Storyboard.GetTarget(completedAnimation); // Определить, упала бомба или отбита за пределы Canvas в результате щелчка. if (completedBomb.IsFalling) { droppedCount++; } else { savedCount++; } В любом случае после этого код обновляет отображаемую информацию о количестве упавших и отбитых бомб: // Обновить отображение. lblStatus.Text = String.Format("You have dropped {0} bombs and saved {1}.", droppedCount, savedCount); В этой точке код проверяет, не достигнуто ли предельное количество упавших бомб. Если да, игра завершается, таймер останавливается, а все бомбы и раскадровки удаляются: // Проверка условия завершения игры. if (droppedCount >= maxDropped) { bombTimer.Stop(); lblStatus.Text += "\r\n\r\nGame over."; // Найти все действующие раскадровки. foreach (KeyValuePair<Bomb, Storyboard> item in storyboards) { Storyboard storyboard = item.Value; Bomb bomb = item.Key; storyboard.Stop (); canvasBackground.Children.Remove(bomb); } // Очистить коллекцию раскадровок. storyboards.Clear (); // Позволить пользователю начать новую игру. cmdStart.IsEnabled = true; } else { // Очистить только эту бомбу и продолжить игру. Storyboard storyboard = (Storyboard)clockGroup.Timeline; storyboard.Stop(); storyboards.Remove(completedBomb); canvasBackground.Children.Remove(completedBomb); }
468 Глава 16. Расширенная анимация На этом код игры BombDropper завершен. Тем не менее, в игру можно внести ряд усовершенствований, некоторые из возможных перечислены ниже. 1. Анимироватъ эффект взрыва бомбы. Этот эффект мог бы заставить мерцать окружающее бомбу пространство либо разбросать мелкие кусочки шрапнели по всему Canvas. 2. Анимироватъ фон. Это сделать очень просто. Например, можно было бы создать линейный градиент, который сдвигается, создавая иллюзию движения, или же реализовать переход между двумя цветами. 3. Добавить глубину. Это проще, чем может показаться. Базовый прием состоит в том, чтобы создавать бомбы разного размера. Бомбы побольше должны иметь большее значение ZIndex, гарантируя, что они будут перекрывать бомбы меньших размеров, и также иметь меньшее время анимации, чтобы двигаться быстрее. Можно также сделать бомбы частично прозрачными, чтобы сквозь одну была видна другая. 4. Добавить звуковые эффекты. В главы 26 будет показано, как работать в WPF со звуком и другими медиаданными. Для обозначения взрывающихся или отбитых бомб можно применять подходящие звуковые эффекты. 5. Использовать функции плавности анимации. Если нужно, чтобы бомбы ускорялись при падении, отскакивали за пределы экрана или раскачивались более естественным образом, к используемым здесь анимациям следует добавить функции плавности. Функции плавности можно конструировать в коде так же легко, как и в XAML-разметке. 6. Тонко настраивать параметры. Можно точнее подгонять поведение (например, ввести переменные, которые устанавливают таймеры бомб, их траектории и частоту их изменений в процессе игры). Можно также привнести большую долю случайности (например, позволив отбитым бомбам вылетать из области Canvas слегка отличающимися способами). Резюме В настоящей главе были продемонстрированы приемы, необходимые для создания практических анимаций и их интеграции в приложения. Единственным недостающим ингредиентом является визуальная оценка эффекта анимации; другими словами, нужно убедиться, что они смотрятся так же красиво, как их код. Как вы видели в последних двух главах, модель анимации WPF на удивление богата. Однако получить нужный эффект не всегда удается просто. Чтобы анимировать различные части интерфейса как часть общей анимированной "сцены", нередко приходится писать некоторую часть разметки с взаимно зависимыми деталями, которые не всегда очевидны. В более сложных анимациях может понадобиться жесткое кодирование деталей для выполнения вычисления конечного значения для анимации. Когда необходим тонкий контроль над анимацией, например, при моделировании некоторой физической системы, потребуется управлять каждым шагом процесса, что обеспечивает только анимация на основе кадра. Будущее анимации WPF обещает появление высокоуровневых классов, построенных на базе изученных в настоящей главе. В идеале будет возможность подключать анимации к своему приложению, просто используя заранее разработанные классы, упаковывая элементы в специализированные контейнеры и устанавливая несколько присоединенных свойств. Действительная реализация, генерирующая нужный эффект — будь то плавное превращение одного изображения в другое или последовательность анимированных летающих объектов, которые формируют окно — будет предоставляться автоматически.
ГЛАВА 17 Шаблоны элементов управления В прошлом разработчики Windows были вынуждены выбирать между удобством и гибкостью. Для максимального удобства можно было использовать готовые элементы управления. Они работали достаточно хорошо, но предоставляли ограниченные возможности настройки, и почти всегда имели неизменный внешний вид. Иногда некоторые из них предлагали не слишком интуитивно понятный режим "самостоятельной отрисовки", который позволял разработчикам рисовать часть элемента управления в ответ на обратный вызов. Однако базовые элементы управления — кнопки, текстовые поля, флажки, окна списков и тому подобное — оставались полностью закрытыми для модификации. В результате разработчики, которые хотели создать что-то необычное, были вынуждены строить специальные элементы управления с нуля. Это представляло проблему — и не только потому, что необходимая специальная логика рисования пишется вручную медленно и трудно, но разработчикам таких элементов также приходилось реализовы- вать базовую функциональность с нуля (такую как выбор фрагмента в текстовом поле или обработка нажатий клавиш в кнопке). И даже если специальные элементы управления получались безупречными, вставка их в существующее приложение требовала значительного объема редактирования, что обычно влекло за собой изменения в коде (и дополнительные циклы тестирования). Короче говоря, специальные элементы управления были неизбежным злом — они представляли собой единственный путь получить современный, отличающийся интерфейс, но также несли с собой головную боль, связанную с интеграцией и поддержкой. Наконец-то в WPF была решена проблема настройки элементов управления с помощью стилей (о которых речь шла в главе 11) и шаблонов (рассмотрение которых начинается в этой главе). Причина столь успешной работы этих средств связана с совершенно иным способом реализации элементов управления в WPF. В прежних технологиях построения интерфейсов, таких как Windows Forms, часто используемые элементы управления на самом деле не были реализованы в управляемом коде .NET. Вместо этого классы элементов управления Windows Forms служили оболочками для базовых ингредиентов Win32 API, которые оставались неприкосновенными. Но, как уже известно, в WPF каждый элемент управления реализован в чистом коде .NET, на фоне поддержки Win32 API. В результате для WPF стало возможным предоставление механизмов (стилей и шаблонов), которые позволяют добираться до этих элементов и подстраивать их под собственные нужды. Фактически здесь слово подстраивать неверно, в чем вы убедитесь в этой главе. Элементы управления WPF допускают более радикальное перепроектирование, чем можно было себе представить.
470 Глава 17. Шаблоны элементов управления Что нового? В WPF 4 появилась новая модель визуального состояния, которая облегчает изменение стиля элементов управления. Эта модель была изначально представлена "младшим братом" WPF — платформой разработки приложений на основе браузера Silverlight 3. Однако в этом выпуске модель визуального состояния не была полностью включена в мир WPR Хотя она уже доступна при проектировании собственных элементов управления (см. главу 18), стандартный набор элементов управления WPF пока ее не поддерживает. Подробное обсуждение диспетчера визуального состояния ищите в разделе "Визуальные состояния" далее в этой главе. Логические и визуальные деревья Ранее в этой книге уже уделялось значительное внимание рассмотрению модели содержимого окна. Другими словами, было показано, как вставлять одни элементы внутрь других для построения полного окна. Например, рассмотрим исключительно простое окно с двумя кнопками, показанное на рис. 17.1. Чтобы создать это окно, элемент управления StackPanel вкладывается внутрь Window. В StackPanel помещаются два элемента управления Button, а внутрь каждого Button можно добавить некоторое содержимое по своему выбору (в данном Рис. 17.1. Окно с тремя элементами слУчае ~ Две строки). Вот необходимая для этого разметка: <Window x:Class="SimpleWindow.Windowl11 xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation11 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title=,,SimpleWindow" Height=,,338" Width=,,356" > <StackPanel Margin=ll5"> <Button Padding=11 Margin=ll5">First Button</Button> <Button Padding=,,5" Margin=,,5">Second Button</Button> </StackPanel> </Window> Множество добавленных элементов называется логическим деревом, и оно показано на рис. 17.2. Как программист WPF, вы будете тратить большую часть времени на построение логического дерева с последующей поддержкой его в коде обработчиков событий. Фактически, все средства, рассмотренные до сих пор (вроде наследования значений свойств, маршрутизации событий и стилизации), работают через логическое дерево. Однако в настройке элементов логическое дерево мало помогает. Очевидно, можно было бы заменить целый элемент другим элементом (например, подставить специальный класс FancyButton вместо текущего Button), но это потребовало бы больше работы и могло разрушить интерфейс приложения или его код. По этой причине в WPF предлагается визуальное дерево. Визуальное дерево — это расширенная версия логического дерева. Оно разбивает элементы на более мелкие части. Другими словами, вместо тщательно инкапсулированного черного ящика, такого как элемент управления Button, вы видите визуальные компоненты этой кнопки — рамку, которая обеспечивает кнопкам узнаваемый текстурированный фон (представленный классом ButtonChrome), контейнер внутри (ContentPresenter) и блок, хранящий текст кнопки (представленный знакомым классом TextBlock). На рис. 17.3 показана схема визуального дерева для окна из рис. 17.1.
Глава 17. Шаблоны элементов управления 471 Window StackPanel Условные обозначения Элемент платформы Другой тип Button Button String String Рис. 17.2. Логическое дерево окна SimpleWindow Условные обозначения Window StackPanel Элемент платформы Другой тип Button Button ButtonChrome ButtonChrome ContentPresenter ContentPresenter TextBlock TextBlock String ) ( String ) Рис. 17.3. Визуальное дерево окна SimpleWindow
472 Глава 17. Шаблоны элементов управления Все эти детали сами по себе являются элементами. Другими словами, каждая индивидуальная деталь такого элемента управления, как Button, представлена классом, унаследованным FrameworkElement. На заметку! Важно понимать, что существует более одного возможного расширения логического дерева в визуальное дерево. Такие детали, как используемые стили, установленные свойства, операционная система (Windows ХР или Windows 7/Vista), а также текущая тема Windows, могут влиять на способ отображения визуального дерева. Например, в предыдущем примере кнопка включает текстовое содержимое, в результате чего автоматически создает вложенный элемент TextBlock. Но, как известно, элемент управления Button — это элемент с содержимым, и потому может иметь внутри себя любой другой элемент, который вы пожелаете в него вставить. Пока все должно быть ясно. Было показано, что элементы WPF можно разбирать на меньшие части. Но какое преимущество это дает разработчику WPF? Визуальное дерево позволяет делать две полезные вещи. • Один из элементов в визуальном дереве может быть изменен с помощью стилей. Для выбора конкретного элемента с целью модификации служит свойство Style.TargetType. Можно даже использовать триггеры для автоматического внесения изменений, когда изменяется свойство элемента управления. Тем н"е менее, определенные детали модифицировать трудно или невозможно. • Для элемента управления можно создать новый шаблон. Шаблон элемента управления будет использоваться для построения визуального дерева именно так, как было запланировано. Довольно интересно, что WPF предоставляет два класса, которые позволяют просматривать логические и визуальные деревья — System.Windows.LogicalTreeHelper и System.Windows.Media.VisualTreeHelper. Класс LogicalTreeHelper уже встречался в главе 2, где он позволил привязать обработчики событий в приложении WPF к динамически загружаемому документу XAML. Класс LogicalTreeHelper предлагает относительно небольшой набор методов, перечисленных в табл. 17.1. Хотя эти методы могут иногда пригодиться, в большинстве случаев вместо них используются методы специфического FrameworkElement. Таблица 17.1. Методы класса LogicalTreeHelper Имя Описание FindLogicalNode () Ищет определенный элемент по имени, начиная с указанного, вниз по логическому дереву BringlntoView () Прокручивает элемент в область видимости (если он находится в прокручиваемом контейнере и в данный момент не виден). Метод FrameworkElement.BringlntoView () выполняет тот же трюк GetParent () Получает родительский элемент определенного элемента GetChildren() Получает дочерний элемент определенного элемента. Как известно из главы 2, разные элементы поддерживают разные модели содержимого. Например, панели поддерживают множество дочерних элементов, а элементы управления содержимым — только один дочерний элемент. Однако метод GetChildren() абстрагирован от этого различия и работает с обоими типами элементов
Глава 17. Шаблоны элементов управления 473 Класс VisualTreeHelper предлагает несколько похожих методов — GetChildrenCountO, GetChildO и GetParent () — наряду с небольшим набором методов, предназначенных для выполнения низкоуровневого рисования. (Например, здесь есть методы для проверки попадания и проверки границ, которые рассматривались в главе 14.) Класс VisualTreeHelper также предоставляет интересный способ исследования визуального дерева внутри приложения. С использованием метода GetChildO можно углубиться в визуальное дерево любого окна и отобразить его для наглядности. Это замечательное учебное пособие, не требующее ничего кроме порции рекурсивного кода. На рис. 17.4 показана одна из возможных реализаций. Здесь в отдельном окне отображается целое визуальное дерево, начиная с любого указанного объекта. В этом примере другое окно (по имени SimpleWindow) использует окно VisualTreeDisplay для отображения визуального дерева. : ■ SimpleWindow ~ И 1 » ■■■ fifSt,Button „ ,„.„,? | j Second Button j ■ VisualTreeDisplay л Windowl л Border л AdomerDecorator л ContentPresenter У ___ • <* StackPanel л Button л ButtonChrome * ContentPresenter TextBlock * Button ^ ButtonChrome л ContentPresenter TextBkxk Adomeftayer L_ ; ; : : ; — ~ ; J Рис. 17.4. Программный просмотр визуального дерева Окно по имени Windowl содержит элемент Border, который, в свою очередь, содержит AdomerDecorator. (Класс AdomerDecorator добавляет поддержку рисования содержимого на декоративном слое (AdornerLayer), представляющем собой специальную невидимую область, которая перекрывает содержимое вашего элемента. WPF использует декоративный слой для рисования таких деталей, как метка фокуса и индикаторы перетаскивания.) Внутри AdomerDecorator находится презентатор ContentPresenter, который хранит содержимое окна. Содержимое включает в себя StackPanel с двумя элементами Button, каждый из которых дополняет ButtonChrome (рисующий стандартный внешний вид кнопки). И, наконец, внутри ContentPresenter каждой кнопки находится TextBlock, который хранит текст, отображаемый в окне. На заметку! В рассматриваемом примере код строит визуальное дерево в другом окне. Помещение контейнера TreeView в то же окно, которое исследуется, приведет к изменению визуального дерева по мере наполнения TreeView элементами. Ниже показан полный код окна VisualTreeDisplay:
474 Глава 17. Шаблоны элементов управления public partial class VisualTreeDisplay : System.Windows.Window { public VisualTreeDisplay() { InitializeComponent(); } public void ShowVisualTree(DependencyObject element) { // Очистить дерево. treeElements.Items.Clear(); // Начать обработку элементов от корня. ProcessElement(element, null); } private void ProcessElement(DependencyObject element, TreeViewItem previousltem) { // Создать TreeViewItem для текущего элемента. TreeViewItem item = new TreeViewItem(); item.Header = element.GetType().Name; item.IsExpanded = true; // Проверить, следует ли добавить этот элемент к корню, // (если это первый элемент), или вложить его в другой. if (previousltem == null) { treeElements.Items.Add(item); } else { previousltem.Items.Add(item); } // Проверить, содержит ли данный элемент другие элементы. for (int i = 0; i < VisualTreeHelper.GetChildrenCount(element); i++) { // Рекурсивно обработать каждый вложенный элемент. ProcessElement(VisualTreeHelper.GetChild(element, 1) , item); } Добавив это дерево в проект, следующий код можно использовать с любым другим окном для отображения его визуального дерева: VisualTreeDisplay treeDisplay = new VisualTreeDisplay(); treeDisplay.ShowVisualTree(this); treeDisplay.Show(); Совет. Исследовать визуальное дерево другого приложения можно с помощью замечательной утилиты Snoop, которая доступна по адресу http://www.blois.us/Snoop. Она позволяет просматривать визуальное дерево любого функционирующего приложения WPF. Можно также просмотреть детали любого элемента, отследить любые маршрутизируемые события, просмотреть и даже модифицировать свойства элементов. Что собой представляют шаблоны Просмотр визуального дерева вызывает несколько интересных вопросов. Например, как элемент управления транслируется из логического дерева в расширенное представление визуального дерева?
Глава 17. Шаблоны элементов управления 475 Оказывается, каждый элемент управления имеет встроенное средство, определяющее способ его визуализации (как группу более фундаментальных элементов). Это средство называется шаблоном элемента управления (control template) и определяется с помощью блока XAML-разметки. На заметку! Каждый элемент управления WPF спроектирован как не имеющий внешнего вида, в том смысле, что его внешний вид может быть полностью переопределен. Неизменным остается лишь поведение элемента управления, которое жестко привязано к классу элемента (хотя часто оно может быть тонко настроено с помощью свойств). Когда принято решение воспользоваться элементом управления Button, это объясняется тем, что нужно поведение, подобное кнопке (другими словами, элемент, представляющий содержимое, на котором можно щелкнуть для активизации действия, и который может служить кнопкой по умолчанию или кнопкой отмены в окне). Однако можно практически произвольно изменять внешний вид кнопки и ее реакцию на наведение курсора и нажатие кнопок мыши, как и любой другой аспект внешности и визуального поведения. Ниже приведена упрощенная версия шаблона для популярного класса Button. В ней отсутствует объявление пространства имен XML, атрибуты, устанавливающие свойства вложенных элементов, и триггеры, определяющие поведение кнопки, когда она недоступна, имеет фокус или на ней совершен щелчок. <ControlTemplate ... > <mwt .-ButtonChrome Name="Chrome" ... > <ContentPresenter Content="{TemplateBinding ContentControl.Content}" ... /> </mwt:ButtonChrome> <ControlTemplate.Triggers> </ControlTemplate.Triggers> </ControlTemplate> Хотя пока еще не рассматривались классы ButtonChrome и ContentPresenteг, можно легко понять, что шаблон элемента управления предоставляет расширение, которое вы видели в визуальном дереве. Класс ButtonChrome определяет стандартные визуальные элементы кнопки, в то время как ContentPresenter хранит все содержимое. Для построения совершенно новой кнопки (как будет показано далее в этой главе) понадобится лишь создать новый шаблон элемента управления. Вместо ButtonChrome можно было бы использовать что-то другое, возможно, собственный специальный элемент или элемент, рисующий фигуру, вроде тех, что были описаны в главе 12. На заметку! Класс ButtonChrome унаследован от Decorator (как и класс Border). Это значит, что он спроектирован специально для того, чтобы создавать графическое оформление вокруг другого элемента, в данном случае — вокруг содержимого кнопки. Триггеры управляют изменениями кнопки при получении ею фокуса, при щелчке на ней и при запрете к ней доступа. В этих триггерах нет ничего особенно интересного. Вместо выполнения всей рутинной работы самостоятельно, триггеры фокуса и щелчка просто модифицируют свойства класса ButtonChrome, которые определяют внешний вид кнопки. <Trigger Property="UIElement.IsKeyboardFocused"> <Setter Property="mwt:ButtonChrome.RenderDefaulted" TargetName="Chrome"> <Setter.Value> <s:Boolean>True</s:Boolean> </Setter.Value> </Setter>
476 Глава 17. Шаблоны элементов управления <Tngger. Value > <s:Boolean>True</s:Boolean> </Trigger.Value> </Trigger> <Tngger Property="ToggleButton. IsChecked"> <Setter Property="mwt:ButtonChrome.RenderPressed" TargetName="Chrome"> <Setter.Value> <s:Boolean>True</s:Boolean> </Setter.Value> </Setter> <Trigger.Value> <s:Boolean>True</s:Boolean> </Trigger.Value> </Trigger> Первый триггер гарантирует, что когда кнопка получит фокус, то свойство RenderDefaulted будет установлено в true. Второй триггер обеспечивает установку свойства RenderPressed в true, когда на кнопке осуществляется щелчок. В любом случае класс ButtonChrome обновит себя соответствующим образом. Графические изменения, которые при этом происходят, слишком сложны, чтобы быть представленными в нескольких операторах установки свойств. Оба объекта Setter в этом примере используют свойство TargetName для работы с определенной частью шаблона элемента управления. Этот прием возможен только при работе с шаблоном элемента управления. Другими словами, нельзя написать триггер стиля, который использует свойство TargetName для доступа к объекту ButtonChrome, потому что имя Chrome не находится в области видимости стиля. Это лишь один из примеров большей мощи шаблонов по сравнению со стилями. Триггеры не всегда должны использовать свойство TargetName. Например, триггер для свойства IsEnabled просто изменяет цвет переднего плана любого текстового содержимого в кнопке. Этот триггер делает свою работу, устанавливая присоединенное свойство TextElement.Foreground без помощи класса ButtonChrome: <Trigger Property="UIElement.IsEnabled"> <Setter Property="TextElement.Foreground'^ <Setter.Value> <SolidColorBrush>#FFADADAD</SolidColorBrush> </Setter.Value> </Setter> <Trigger.Value> <s:Boolean>False</s:Boolean> </Trigger.Value> </Trigger> Аналогичное разделение ответственности вы увидите при построении собственных шаблонов элементов управления. Если повезет выполнить всю необходимую работу в триггерах, то может и не понадобится создавать специальные классы и добавлять код. С другой стороны, если нужно создать более сложное визуальное представление, то в этом случае может потребоваться наследование собственного класса Chrome. Класс ButtonChrome сам по себе не предусматривает никакой настройки — он обеспечивает стандартный, специфичный для текущей темы внешний вид кнопки. На заметку! Вся ХАМL-разметка, представленная в этом разделе, извлечена из стандартного шаблона элемента управления Button. В разделе "Анализ элементов управления" далее в главе будет показано, как просмотреть шаблон элемента управления по умолчанию.
Глава 17. Шаблоны элементов управления 477 Типы шаблонов Внимание в этой главе сосредоточено на шаблонах элементов управления, которые позволяют определять элементы, составляющие элемент управления. Однако на самом деле в мире WPF существует три типа шаблонов, и все они наследуются от базового класса FrameworkTemplate. Наряду с шаблонами элементов управления (представленными классом ControlTemplate) есть шаблоны данных (классы DataTemplate и HierarchicalDataTemplate), а также более специализированный шаблон панели для ItemsControl (ItemsPanelTemplate). Шаблоны данных используются для извлечения данных из объекта и отображения их в элементе управления содержимым либо в индивидуальных позициях списочного элемента. Шаблоны данных незаменимы в сценариях привязки данных и детально описаны в главе 20. В некоторой степени шаблоны данных и шаблоны элементов управления пересекаются. Например, оба типа шаблонов позволяют вставлять дополнительные элементы, применять форматирование и т.д. Однако шаблоны данных служат для добавления элементов внутрь существующего элемента управления. Предварительно определенные аспекты этого элемента управления при этом не изменяются. С другой стороны, шаблоны элементов управления открывают более широкие возможности, позволяя полностью переписать модель содержимого элемента управления. Наконец, шаблоны панелей применяются для управления компоновкой позиций в списочном элементе управления (элементов, унаследованных от класса ItemsControl). Например, их можно использовать для создания окон списков, которые располагают свои элементы слева направо и затем сверху вниз (вместо стандартного размещения сверху вниз в один столбец). Шаблоны панелей рассматриваются в главе 20. В пределах одного элемента управления определенно можно комбинировать разные типы шаблонов. Например, для создания блестящего окна списка, привязанного к определенному типу данных, расположения его элементов нестандартным образом и замены обычной рамки чем-то более выразительным, понадобится создать собственные шаблоны данных, шаблон панели и шаблон элемента управления. Классы Chrome Класс ButtonChrome определен в пространстве имен Microsoft.Windows.Themes, которое содержит относительно небольшой набор сходных классов, визуализирующих базовые детали Windows. Наряду с ButtonChrome он включает BulletChrome (для флажков и переключателей), ScrollChrome (для полос прокрутки), ListBoxChrome и SystemDropShadowChrome. Это наиболее низкий уровень общедоступного API- интерфейса элементов управления. На чуть более высоком уровне находится пространство имен System.Windows.Controls.Primitives, содержащее множество базовых элементов, которые можно использовать независимо, но гораздо чаще помещать в оболочки более удобных элементов управления. К ним относятся ScrollBar, ResizeGrip (для изменения размеров окна), Thumb (перетаскиваемая кнопка на полосе прокрутки), TickBar (дополнительный набор засечек на ползунке) и т.д. По сути, System.Windows. Controls.Primitives представляет готовые ингредиенты, которые можно применять в самых разных элементах управления, и которые не слишком полезны сами по себе, в то время как Microsoft.Windows.Themes содержит низкоуровневую логику рисования для визуализации этих деталей. Имеется еще одно отличие. Типы в System.Windows.Controls.Primitives, как и большинство типов WPF, определены в сборке PresentationFramework.dll. Однако типы, находящиеся в Microsoft.Windows.Themes, определены отдельно, в трех разных сборках: PresentationFramework.Aero.dll, PresentationFramework.Luna.dll и PresentationFramework.Royale.dll. Каждая из сборок включает собственную вер-
478 Глава 17. Шаблоны элементов управления сию класса ButtonChrome (и других классов Chrome), со слегка отличающейся логикой визуализации. Версия, которую использует WPF, зависит от операционной системы и настроек темы. На заметку! Внутренняя работа класса Chrome рассматривается в главе 18. Там же будет показано, как строить собственный класс Chrome со специальной логикой визуализации. Хотя шаблоны элементов управления часто рисуют в классах Chrome, они не всегда должны делать это. Например, элемент ResizeGrip (который создает сетку точек в нижнем правом углу окна с изменяемым размером) достаточно прост, чтобы его шаблон мог использовать классы рисования, которые знакомы из глав 12 и 13 — Path, DrawingBrush и LinearGradientBrush. Ниже показана (несколько запутанная) разметка, которую он использует. <ControlTemplate TargetType="{x:Type ResizeGrip}11 ... > <Grid Background="{TemplateBinding Panel.Background}" SnapsToDevicePixels=llTrue"> <Path Margin=,0,2,2" Data="M9, 0L11, 0 11,11 0,11 0,9 3,9 3,6 6,6 6,3 9,3z" HorizontalAlignment="Right" VerticalAlignment="Bottom"> <Path.Fill> <DrawingBrush ViewboxUnits="Absolute" TileMode="Tile" Viewbox=,0,3,3" Viewport=,0,3,3" ViewportUnits="Absolute"> <DrawingBrush.Drawing> <DrawingGroup> <DrawingGroup.Children> <GeometryDrawing Geometry="M0,0L2,0 2,2 0,2z"> <GeometryDrawing.Brush> <LinearGradientBrush EndPoint="l,0.75" StartPoint=,0.25"> <LinearGradientBrush.Gradientstops> <GradientStop Offset=.3" Color="#FFFFFFFF" /> <GradientStop Offset=.75" Color="#FFBBC5D7M /> <GradientStop Offset="l" Color=M#FF6D83A9" /> </LinearGradientBrush.GradientStops> </LinearGradientBrush> </GeometryDrawing.Brush> </GeometryDrawing> </DrawingGroup.Children> </DrawingGroup> </DrawingBrush.Drawing> </DrawingBrush> </Path.Fill> </Path> </Grid> </ControlTemplate> На заметку! Установку SnapsToDevicePixels можно часто видеть в предварительно построенных шаблонах элементов управления (и она также используется в создаваемых специальных шаблонах). Как известно из главы 12, SnapsToDevicePixels гарантирует, что по причине независимости от разрешения WPF однопиксельные линии не окажутся "между" пикселями, что создало бы нечеткую двухпиксельную линию. Анализ элементов управления После создания шаблона элемента управления (как будет показано в следующем разделе), шаблон заменяет существующий полностью. Несмотря на то что обеспечивается высокий уровень гибкости, также возникают сложности. В большинстве случаев
Глава 17. Шаблоны элементов управления 479 нужно просмотреть стандартный шаблон, используемый элементом управления, прежде чем создавать собственную адаптированную версию. В некоторых случаях создаваемый шаблон элемента управления может повторять стандартный лишь с небольшими отличиями. В документации WPF не приводится XAML-разметка стандартных шаблонов элементов управления. Однако необходимую информацию можно получить программно. Основная идея состоит в том, чтобы извлечь шаблон элемента из его свойства Template (которое определено как часть класса Control) и затем сериализовать его в XAML, используя класс XamlWriter. На рис. 17.5 показан пример программы, которая выводит все элементы управления WPF и позволяет видеть шаблоны каждого из них. • ControlTemplateBrowser [ ggjjI^^^^^^^^^Z- - ИНДИИ CheckBox ComboBox ComboBoxItem ContentContro» Context Menu DocumentV»ewer Expander FlowDocumentPageViewer FlowDocumentReader * FlowDocumentScroJIViewer Frame GndSphtter GridViewColumnHeader GroupBox Groupltem HeaderedContentControl HeaderedltemsControl ItemsControl iililil! PasswordBox ProgressBar RadioButton тШ </*wt:ButtonChrome> <ControlTe«plate.Triggers > <Trigger Property-"UIElenent.IsKeyboardFocused"> <Setter Property-"i»wt:ButtonChrome.Ren(JerDefaulte<r TargetNa«*-"Chro««"> <Setter.Value> < s:Boolean>True</s:Boolean> </Setter.Value> </Setter> <Trigger.Value> < s:Boolean>True</s:Boolean> </Trigger.Value> </Trigger> <Trigger Prooerty-"ToggleButton.IsChecked"> <Setter Property-"mwt:ButtonChro*e.RenderPressed" TargetNa«e-"Chro«>e"> <Setter.Value> <s:Boolean>True</s:Boolean> </Setter.Value> </Setter> <Trigger.Value> <s:8oolean>Troe</s:Boolean> </Trigger.Value> </Trigger> <Trigger Property-"UIEle«ent.IsEnabled"> <Setter Property-"TextElewent.Foreground"> <Setter.Value> <SolidColorBrush>#FFADAOAO</SolidColorBrush> </Setter.Value> </Setter> <Trigger.Value> <s:Boolean>False</s:Boolean> </Trigger.Value> </Trigger> </ControlTe*plate.Triggers> </ControlTemplate> E Рис. 17.5. Просмотр шаблонов элементов управления WPF Секрет построения этого приложения состоит в интенсивном использовании рефлексии — API-интерфейса .NET для исследования типов. Когда главное окно этого приложения загружается в первый раз, оно сканирует все типы в основной сборке PresentationFramework.dll (в которой определен класс Control). Затем эти типы добавляются в коллекцию, которая сортируется по именам типов, и результирующая коллекция привязывается к списку. private void Window_Loaded(object sender, EventArgs e) { Type controlType = typeof (Control); List<Type> derivedTypes = new List<Type> (); // Искать все типы в сборке, где определен класс Control. Assembly assembly = Assembly.GetAssembly(typeof(Control)); foreach (Type type in assembly.GetTypes()) { // Добавлять тип в список, только если это унаследованный от Control, // конкретный и общедоступный класс.
480 Глава 17. Шаблоны элементов управления if (type.IsSubclassOf(controlType) && !type.IsAbstract && type.IsPublic) { derivedTypes.Add(type); } } // Сортировать по типам. Специальный класс TypeComparer // упорядочивает типы по именам в алфавитном порядке. derivedTypes.Sort(new TypeComparer()); // Отобразить список типов. IstTypes.ItemsSource = derivedTypes; } Всякий раз, когда элемент управления выбирается в списке, в текстовом поле справа отображается соответствующий шаблон элемента управления. Этот шаг требует чуть больше работы. Первая сложность состоит в том, что шаблон элемента управления равен null, пока элемент не отобразится в окне. Используя рефлексию, код пытается создать экземпляр элемента управления и добавить его в текущее окно (хотя с Visibility, равным Collapse, так что он остается невидимым). Вторая сложность в том, что актуальный объект ControlTemplate нужно преобразовать в знакомую XAML-разметку. Эту задачу решает статический метод XamlWriter.SaveO, а в коде используются объекты XamlWriter и XmlWriterSetting для обеспечения отступов в XAML-разметке, что улучшит его читабельность. Весь код помещен в блок обработки исключений. Здесь перехватываются все проблемы, возникающие из-за элемента управления, который не может быть создан и добавлен в Grid (например, другой Window или Page). private void lstTypes_SelectionChanged(object sender, SelectionChangedEventArgs e) { try { // Получить выбранный тип. Type type = (Type)IstTypes.Selectedltem; // Создать экземпляр типа. Constructorlnfo info = type.GetConstructor(System.Type.EmptyTypes); Control control = (Control)info.Invoke (null); // Добавить его в Grid (оставив скрытым). control.Visibility = Visibility.Collapsed; grid.Children.Add(control); // Получить шаблон. ControlTemplate template = control.Template; // Получить XAML-разметку для шаблона. XmlWriterSettings settings = new XmlWriterSettings (); settings.Indent = true; StringBuilder sb = new StringBuilder(); XmlWriter writer = XmlWriter.Create(sb, settings); XamlWriter.Save(template, writer); // Отобразить шаблон. txtTemplate.Text = sb.ToString (); // Удалить элемент управления из Grid. grid.Children.Remove(control); } catch (Exception err) { // При генерации шаблона возникла ошибка. txtTemplate.Text = "<< Error generating template: " + err.Message + ">>"; } }
Глава 17. Шаблоны элементов управления 481 Это приложение было бы несложно расширить так, чтобы можно было редактировать шаблон в текстовом поле, преобразовывать обратно в объект ControlTemplate (используя XamlReader) и назначать его элементу управления для просмотра эффекта. Однако тестирование и совершенствование шаблонов элементов управления будет проводиться за счет их вставки в реальное окно, как описано в следующем разделе. Совет. При работе с Expression Blend можно воспользоваться удобным средством, которое позволяет редактировать шаблон любого элемента управления. (Формально это средство захватывает шаблон по умолчанию, создает копию его для элемента управления и позволяет редактировать эту копию). Щелкните правой кнопкой мыши на элементе управления в окне визуального конструктора и выберите в контекстном меню пункт Edit Control Parts (TemplateI^Edit a Copy (Редактировать части элемента управления (шаблон) о Редактировать копию). Копия шаблона элемента управления будет сохранена в виде ресурса (см. главу 10), так что будет предложено выбрать описательный ключ ресурса. Также понадобится выбрать между сохранением ресурса в текущем окне или в глобальных ресурсах приложения, что позволит использовать шаблон элемента управления по всему приложению. Создание шаблонов элементов управления К этому моменту было дано довольно много информации о работе шаблонов, но пока не приводились примеры их построения. В следующих разделах будет построена простая специальная кнопка, а в процессе вы узнаете несколько тонких подробностей, связанных с шаблонами элементов управления. Как уже было показано, базовый элемент управления Button использует класс ButtonChrome для рисования своего характерного фона и рамки. Одна из причин, по которой Button применяет ButtonChrome вместо рисования примитивов WPF, состоит в том, что стандартный вид кнопки зависит от нескольких очевидных характеристик (отключена, имеет фокус, находится во время совершения щелчка) и других тонких факторов (текущая тема Windows). Реализация подобного рода логики только с помощью триггеров была бы затруднительной. Однако при построении собственных специальных элементов управления, возможно, стандартизация и интеграция с темой не так важны. (На самом деле в WPF не столь строго подчеркивается необходимость стандартизации интерфейса, как в прежних технологиях построения пользовательских интерфейсов). Вместо этого больше внимания уделяется созданию привлекательных и оригинальных элементов управления, которые сочетаются с остальным пользовательским интерфейсом. По этой причине создавать классы, подобные ButtonChrome, может и не понадобится. Взамен с помощью уже знакомых элементов (в том числе элементов для рисования из глав 12 и 13, а также анимации, описанной в главах 15 и 16) будут проектироваться самодостаточные шаблоны элементов управления без написания кода. На заметку! Альтернативный подход описан в главе 18, в которой объясняется, как построить собственный класс Chrome со специальной логикой визуализации и интегрировать его в шаблон элемента управления. Простая кнопка Чтобы применить специальный шаблон элемента управления, необходимо просто установить свойство Template элемента. Хотя можно определить встроенный шаблон (поместив дескриптор шаблона элемента внутрь дескриптора самого элемента управления), этот подход редко бывает оправдан. Дело в том, что шаблон почти всегда нужно
482 Глава 17. Шаблоны элементов управления использовать многократно, создавая обложки для нескольких экземпляров одного и того же элемента управления. Шаблон элемента управления должен быть определен как ресурс, на который можно будет ссылаться с помощью Static Re source: <Button Margin=011 Padding=ll5" Templates" {StaticResource ButtonTemplate} "> A Simple Button with a Custom Template</Button> Этот подход не только облегчает создание множества настроенных кнопок, но также обеспечивает гибкость будущих модификаций шаблона элемента управления без нарушения остальной части интерфейса приложения. В данном примере ресурс ButtonTemlate помещается в коллекцию Resurces включающего окна. Однако в реальном приложении, скорее всего, будут использоваться ресурсы приложения. Причины описаны ниже, в разделе "Организация ресурсов для шаблонов". Базовый эскиз шаблона элемента управления выглядит следующим образом: <Window.Resources> <ControlTemplate x:Key="ButtonTemplate" TargetType="{x:Type Button}"> </ControlTemplate> </Window.Resources> Этот шаблон элемента управления устанавливает свойство TaregType, чтобы явно указать, что он предназначен для кнопок. С точки зрения стиля этому соглашению всегда стоит следовать. Для элементов с содержимым, таким как кнопка, это также обязательно, иначе ContentPresenter не будет работать. Чтобы создать шаблон базовой кнопки, понадобится нарисовать собственную границу и фон, после чего поместить внутрь кнопки ее содержимое. Два возможных кандидата на рисование контура — это классы Rectangle и Border. В следующем примере используется класс Border для комбинирования скругленного оранжевого контура с броским красным фоном и белым текстом: <ControlTemplate x:Key=,,ButtonTemplate" TargetType="{x:Type Button}"> <Border BorderBrush="Orange11 BorderThickness=11 CornerRadius=11 Background="Red11 TextBlock. Foreground=llWhite"> </Border> </ControlTemplate> Это обеспечит отображение фона, но еще нужно как-то вывести содержимое кнопки. Возможно, вы помните из прежнего материала, что класс Button содержит презентатор ContentPresenter в своем шаблоне элемента управления. ContentPresenter необходим всем элементам с содержимым; это маркер типа "Вставлять содержимое сюда", указывающий WPF, куда следует поместить содержимое: <ControlTemplate x:Key="ButtonTemplate11 TargetType="{x:Type Button}"> <Border BorderBrush=llOrange" BorderThickness=ll3" CornerRadius=" Background="Red11 TextBlock. Foreground=llWhite"> <ContentPresenter RecognizesAccessKey="True"></ContentPresenter> </Border> </ControlTemplate> Приведенный выше элемент ContentPresenter устанавливает свойство Recognizes AccessKey в true. Хотя это не обязательно, но кнопка будет поддерживать клавиши доступа — подчеркнутые буквы, клавиши которых можно нажимать для быстрого выбора кнопки. В рассматриваемом случае, если кнопка содержит текст вроде "Click Me", то пользователь может выбрать ее, нажав <Alt+M>. (Согласно стандартным настройкам Windows, подчеркивание скрывается, а ключ доступа — в данном случае м — отображается подчеркнутым, как только нажата клавиша <Alt>.) Если не установить
Глава 17. Шаблоны элементов управления 483 RecognizesAccessKey в True, эта деталь игнорируется, и все знаки подчеркивания трактуются как обычные подчеркивания, отображаясь как часть содержимого кнопки. На заметку! Если элемент управления унаследован от ContentControl, его шаблон будет включать ContentPresenter, который указывает местоположение содержимого. Если элемент управления унаследован от ItemsControl, его шаблон будет включать ItemsPresenter, который указывает местоположение панели, содержащей список элементов. В редких случаях элемент управления может использовать унаследованную версию одного из этих классов; например, шаблон элемента управления ScrollViewer использует ScrollContentPresenter, унаследованный от ContentPresenter. Привязки шаблона В этом примере присутствует одна небольшая проблема. Прямо сейчас добавленный дескриптор для кнопки указывает значение Margin, равное 10, и значение Padding, равное 5. Контейнер StackPanel обращает внимание на свойство кнопки Margin, но игнорирует Padding, оставляя содержимое кнопки вплотную прижатым к границам. Проблема в том, что свойство Padding не оказывает никакого эффекта, если только явно не используется в шаблоне. Другими словами, извлечь значение Padding и применить его для добавления некоторого пространства вокруг содержимого является обязанностью шаблона. К счастью, в WPF имеется инструмент, предназначенный специально для этой цели: привязки шаблона (template bindings). Используя привязку шаблона, шаблон может извлечь значение из элемента управления, к которому применен шаблон. В данном примере привязкой шаблона можно воспользоваться для извлечения значения свойства Padding и его использования для установки полей вокруг ContentPresenter: <ControlTemplate x:Key=,,ButtonTemplate" TargetType=" {x:Type Button }"> <Border BorderBrush=llOrange" BorderThickness=11 CornerRadius=" Background=llRed" TextBlock. Foreground=llWhite"> <ContentPresenterRecognizesAccessKey="True" Margin=" {TemplateBinding Padding} "X/ContentPresenter> </Border> </ControlTemplate> Это обеспечивает достижение требуемого эффекта — добавления некоторого пространства между контуром и содержимым. На рис. 17.6 показана замечательная новая кнопка. Рис. 17.6. Кнопка с настроенным шаблоном элемента управления
484 Глава 17. Шаблоны элементов управления Привязки шаблонов подобны обычным привязкам данных, но являются облегченными, поскольку специально спроектированы для использования в шаблоне элемента управления. Они поддерживают только однонаправленную привязку данных (другими словами, передачу информации от элемента управления шаблону, но не наоборот), и не могут применяться для визуализации информации из свойства класса, унаследованного от Freezable. Оказавшись в ситуации, когда привязка шаблона не работает, можно воспользоваться вместо нее полноценной привязкой данных. В главе 18 будет представлен простой элемент выбора цвета, который сталкивается с этой проблемой и применяет комбинацию привязок шаблонов и обычных привязок. На заметку! Привязки шаблонов поддерживают инфраструктуру отслеживания изменений WPF, которая встроена во все свойства зависимости. Это значит, что если модифицировать свойство элемента управления, то шаблон учтет это автоматически. Эта деталь особенно полезна при использовании анимаций, многократно изменяющих значение свойства за короткий промежуток времени. Единственный способ проверить, нужна ли привязка шаблона, состоит в том, чтобы просмотреть шаблон элемента управления, установленный по умолчанию. Заглянув в шаблон для класса Button, вы обнаружите, что он использует привязку шаблонов точно таким же образом, как этот специальный шаблон — берет свойство Padding, определенное в кнопке, и преобразует его в поле вокруг ContentPresenter. Кроме того, выясняется, что стандартный шаблон кнопки включает и другие привязки шаблона, которые не используются в простом настроенном шаблоне, такие как HonzontalAlignment, Vertical Alignment и Background. Это значит, что если установить эти свойства в кнопке, они не окажут никакого эффекта на простой специальный шаблон. На заметку! Формально ContentPresenter работает потому, что имеет привязку шаблона, которая устанавливает свойство ContentPresenter.Content в значение свойства Button.Content. Однако это неявная привязка, потому добавлять ее самостоятельно не понадобится. Во многих случаях обойтись без привязок шаблонов можно. Фактически привязывать свойство не нужно, если не планируется его использовать либо оно не должно изменять шаблон. Например, имеет смысл, чтобы простая кнопка устанавливала свойство Foreground текста в белый цвет и игнорировала любое значение, которое будет установлено в Background, поскольку цвет переднего плана и фона — внутренние части визуального представления кнопки. Существует еще одна причина, по которой без привязок шаблонов можно обойтись — элемент управления может быть не готов адекватно их поддерживать. Например, если вы когда-нибудь устанавливали свойство Background кнопки, то наверняка заметили, что этот фон не обрабатывается согласованно при нажатии кнопки (в действительности он исчезает и заменяется визуальным представлением нажатой кнопки по умолчанию). Специальный шаблон, показанный в этом примере, ведет себя аналогично. Хотя пока еще не определено никакого специального поведения, связанного с наведением курсора и нажатием кнопки мыши, как только эти детали будут добавлены, понадобится иметь полный контроль над цветами и их изменениями в различных состояниях. Триггеры, изменяющие свойства Если вы опробуете кнопку, которая была создан? i предыдущем разделе, то обнаружите, что в результате получилось одно сплошное разочарование. По сути все, что вышло — не более чем красный прямоугольник со скругленными углами; при наведении
Глава 17. Шаблоны элементов управления 485 на него курсор мыши и щелчка никакого визуального отклика не происходит. Кнопка совершенно инертна. Эту проблему легко решить, добавив к шаблону элемента управления триггеры. Впервые триггеры со стилями упоминались в главе 11. Как известно, триггеры можно использовать для изменения одного или более свойств в ответ на изменения другого свойства. Минимальный набор свойств, на которые понадобится отреагировать в кнопке — это IsMouseOver и IsPressed. Ниже приведена модифицированная версия шаблона элемента управления, который изменяет цвет в ответ на изменение этих свойств: <ControlTemplate х:Key="ButtonTemplate11 TargetType="{x:Type Button}"> <Border Name="Border" BorderBrush="Orange11 BorderThickness=11 CornerRadius = 11 Background="Red11 TextBlock. Foreground=llWhite"> <Con tent Presenter RecognizesAccessKey="True11 Margin="{TemplateBinding Padding}"></ContentPresenter> </Border> <ControlTemplate.Triggers> <Trigger Property="IsMouseOver" Value="True"> <Setter TargetName="Border" Property="Background" Value="DarkRed" /> </Trigger> <Trigger Property="IsPressed" Value="True"> <Setter TargetName="Border" Property="Background" Value="IndianRed" /> <Setter TargetName="Border" Property="BorderBrush" Value="DarkKhaki" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> Есть другое изменение, которое заставляет этот шаблон работать. Элементу Border назначено имя, и это имя используется для установки свойства TargetName каждого объекта Setter. Таким образом, Setter может обновить свойства Background и BorderBrush элемента Border, указанного в шаблоне. Использование имен — простейший способ гарантировать, что определенная часть шаблона будет обновлена. Можно было бы создать типизированное в отношении элементов правило, которое касалось бы всех элементов Border (поскольку известно, что у кнопки есть только одна граница), но данный подход и более ясен, и более гибок, если позже понадобится изменить шаблон. Каждой кнопке (и большинству других элементов управления) необходим еще один элемент — индикатор фокуса. Изменить существующую границу, добавив к ней эффект фокуса, не удастся, но можно легко добавить другой элемент, который воспроизводит его, и просто отображать или скрывать этот элемент с помощью триггера в зависимости от свойства Button.IsKeyboardFocused. Хотя эффект фокуса можно реализовать множеством разных способов, в показанном ниже примере просто добавляется прозрачный элемент Rectangle с прерывистым контуром. Rectangle не обладает способностью удерживать в себе дочерний элемент, так что нужно гарантировать перекрытие Rectangle другого содержимого. Простейший способ сделать это — поместить Rectangle и ContentPresenter в Grid с одной ячейкой. Вот как выглядит усовершенствованный шаблон с поддержкой фокуса: <ControlTemplate х:Key="ButtonTemplate" TargetType="{х:Type Button}"> <Border Name="Border" BorderBrush="Orange" BorderThickness=" CornerRadius=" Background="Red" TextBlock.Foreground="White"> <Grid> <Rectangle Name="FocusCue" Visibility="Hidden" Stroke="Black" StrokeThickness="l" StrokeDashArray="l 2" SnapsToDevicePixels="True" X/Rectangle> <ContentPresenter RecognizesAccessKey="True" Margin=" {TemplateBinding Padding} "X/ContentPresenter> </Grid>
486 Глава 17. Шаблоны элементов управления </Border> <ControlTemplate.Triggers> <Tngger Property="IsMouseOver11 Value="Truell> <Setter TargetName=llBorder11 Property=llBackground11 Value="DarkRed11 /> </Trigger> <Trigger Property="IsPressed11 Value=llTrue"> <Setter TargetName="Border11 Property="Background11 Value="IndianRed11 /> <Setter TargetName="Border11 Property="BorderBrush11 Value="DarkKhaki11 /> </Trigger> <Trigger Property="IsKeyboardFocused" Value=llTrue"> <Setter TargetName="FocusCue11 Property="Visibility11 Value= "Visible" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> Объект Setter находит элемент, который необходимо изменить, с использованием свойства TargetName (которое в данном примере указывает на прямоугольник FocusCue). На заметку! Такой прием сокрытия и отображения элементов в ответ на триггер является удобным строительным блоком для многих шаблонов. Его можно использовать для замены визуального представления элемента управления чем-то совершенно иным, когда изменяется его состояние. (Например, кнопка после щелчка может изменять свою форму с прямоугольной на эллиптическую, скрывая прямоугольник и показывая эллипс.) На рис. 17.7 показаны три кнопки, в которых применяется усовершенствованный шаблон. Вторая кнопка находится в фокусе (на что указывает пунктирный прямоугольник), а над третьей кнопкой в данный момент расположен курсор мыши. Рис. 17.7. Кнопки с поддержкой фокуса и наведения курсора мыши Чтобы покончить с этой кнопкой, потребуется добавить еще один дополнительный триггер, который изменяет фон кнопки (и, возможно, цвет текста), когда свойство IsEnabled кнопки принимает значение false: <Trigger Property="IsEnabled" Value="False"> <Setter TargetName="Border" Property="TextBlock.Foreground" Value="Gray" /> <Setter TargetName="Border" Property="Background" Value="MistyRose" /> </Trigger>
Глава 17. Шаблоны элементов управления 487 Чтобы это правило получило преимущество перед любыми другими конфликтующими установками, оно должно быть определено в конце списка триггеров. Таким образом, неважно, что свойство IsMOuseOver также равно true; триггер свойства IsEnabled имеет преимущество, и кнопка остается неактивной. Сравнение шаблонов и стилей Должно быть, вы заметили сходство между шаблонами и стилями. Оба позволяют изменить внешний вид элемента, обычно во всем приложении. Однако контекст применения стилей намного более ограниченный. Они могут изменять свойства элемента управления, но не заменяют полностью визуального дерева, составляющего различные элементы. Простая кнопка, которая была показана ранее, включает средства, которые невозможно воспроизвести одними только стилями. Хотя стили можно было использовать для установки фона кнопки, больше хлопот будет при изменении цвета фона, когда кнопка нажата, потому что встроенный шаблон для кнопки уже содержит триггер для этой цели. Также не получилось бы просто добавить прямоугольник фокуса. Шаблоны элементов управления — это открытая дверь для многих экзотических типов кнопок, которые немыслимо реализовать стилями. Например, вместо применения прямоугольного контура можно создать кнопку в виде эллипса либо воспользоваться путем для рисования более сложной фигуры. Все, что для этого понадобится — это классы рисования из главы 12. Остаток разметки — даже триггеры, которые переключают цвет фона из одного состояния в другое — потребуют относительно небольших изменений. Триггеры, использующие анимацию Как известно из главы 11, триггеры не ограничиваются установкой свойств. Триггеры событий можно использовать для запуска анимации в ответ на изменение определенных свойств. На первый взгляд это может показаться причудой, но на самом деле это — ключевой ингредиент для всех, кроме простейших элементов управления WPF. Например, вспомните кнопку, которую изучали до сих пор. В настоящий момент она просто переключает цвет при наведении на нее курсора мыши. Однако более современные кнопки могут запускать краткую анимацию для плавного перехода от одного цвета к другому, что создает тонкий, но изящный эффект. Аналогично, кнопка могла бы использовать анимацию для изменения прозрачности фокуса прямоугольника фокуса, быстро, но плавно "проявляя" его, когда кнопка получает фокус, вместо того, чтобы показывать его мгновенно, за один шаг. Другими словами, триггеры событий позволяют элементам управления изменять свое состояние более плавно и красиво, что придает им дополнительный лоск. Ниже показан усовершенствованный шаблон кнопки, в котором триггеры применяются для того, чтобы заставить цвет кнопки пульсировать (непрерывно переливаясь между красным и синим), когда на нее наведен курсор мыши. Когда курсор мыши перемещается в сторону, цвет кнопки возвращается к обычному с помощью отдельной анимации длительностью в одну секунду. <ControlTemplate x:Key="ButtonTemplate11 TargetType=" { х :Туре Button} "> <Border BorderBrush="Orange11 BorderThickness=ll3" CornerRadius=ll2" Background="Red11 TextBlock. Foreground="White11 Name=llBorder"> <Grid> <Rectangle Name="FocusCue" Visibility="Hidden11 Stroke="Black11 StrokeThickness="l11 StrokeDashArray="l 2" SnapsToDevicePixels=llTrueM ></Rectangle>
488 Глава 17. Шаблоны элементов управления <Con tent Presenter RecognizesAccessKey="True11 Margin="{TemplateBinding Padding}"></ContentPresenter> </Grid> </Border> <ControlTemplate.Triggers> <EventTrigger RoutedEvent="MouseEnter"> <BeginS toryboard> <S toryboard> <ColorAnimation Storyboard.TargetName="Border" Storyboard.TargetProperty="Background.Color" To="Blue" Duration=:0:1" AutoReverse="True" RepeatBehavior=" Forever "X/ColorAnimation> </Storyboard> </BeginStoryboard> </EventTrigger> <EventTrigger RoutedEvent="MouseLeave"> <BeginStoryboard> <Storyboard> <ColorAnimation Storyboard.TargetName="Border" Storyboard.TargetProperty="Background.Color" Duration= : 0:0. 5"X/ColorAnimation> </Storyboard> </BeginStoryboard> </EventTrigger> </ControlTemplate.Triggers> </ControlTemplate> Добавить анимацию наведения курсора мыши можно двумя эквивалентными способами — создав триггер, реагирующий на события MouseEnter и MouseLeave (как показано здесь), либо создав триггер свойства, который добавляет действия входа и выхода при изменении свойства IsMouseOver. В рассматриваемом примере для изменения кнопки используются два объекта ColorAnimation. Ниже перечислены другие задачи, которые может понадобиться решать с помощью анимации на основе EventTrigger. • Показывать или скрывать элемент. Для этого необходимо изменять свойство Opacity элемента в его шаблоне. • Изменять форму ила положение. С помощью трансформации TranslateTransform можно подстраивать позицию элемента (например, слегка сдвигая его, что создает впечатление нажатой кнопки). Для отображения реакции кнопки, когда пользователь наводит на нее курсор мыши, можно использовать трансформацию ScaleTransform или RotateTransform. • Изменение яркости или окраски. Для этого нужна анимация, которая работает с кистью, используемой для рисования фона. С помощью анимации ColorAnimation можно изменять цвета в SolidBrush, а более развитых эффектов можно добиться за счет анимации более сложных кистей, например, изменять один из цветов LinearGradientBrush (что делает шаблон элемента-кнопки по умолчанию) или сдвигать центральную точку RadialGradientBrush. Совет. Некоторые развитые эффекты яркости используют несколько слоев прозрачных элементов В этом случае анимация может модифицировать прозрачность одного слоя, чтобы проявить сквозь него другой.
Глава 17. Шаблоны элементов управления 489 Организация ресурсов для шаблонов При использовании шаблонов элементов управления понадобится принять решение относительно того, насколько широко они будут использоваться, и как нужно их применять — автоматически или явно. Первый вопрос предлагает подумать о том, где должны использоваться шаблоны. Например, будут ли они ограничены определенным окном? В большинстве ситуаций шаблоны элементов управления применяются к множеству окон и даже ко всему приложению. Во избежание многократного их определения, можно сделать это в коллекции Resources класса Application. Однако это порождает другой вопрос. Зачастую шаблоны элементов управления разделяются между приложениями. Одно приложение вполне может пользоваться шаблонами, которые разрабатывались отдельно. Тем не менее, приложение может иметь только один файл App.xaml и одну коллекцию Application.Resources. По этой причине лучше определять ресурсы в отдельных словарях ресурсов. Это обеспечит гибкость за счет приведения их в действие в определенных окнах внутри приложения. Это также позволит комбинировать стили, поскольку любое приложение может содержать несколько словарей ресурсов. Чтобы добавить словарь ресурсов в Visual Studio, щелкните правой кнопкой мыши на проекте в окне Solution Explorer, и выберите в контекстном меню пункт Add^New Item (Добавить1^ Новый элемент), после чего укажите Resource Dictionary (WPF) (Словарь ресурсов (WPF)). Со словарями ресурсов вы ознакомились в главе 10. Использовать их легко. К приложению понадобится лишь добавить новый файл XAML с примерно таким содержимым: <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2 00 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" > <ControlTemplate x:Key="ButtonTemplate11 TargetType="{x:Type Button}"> </ControlTemplate> </ResourceDictionary> Хотя все шаблоны можно было бы скомбинировать в одном файле словаря ресурсов, опытные разработчики предпочитают создавать отдельные словари ресурсов для каждого шаблона элемента управления. Дело в том, что шаблон элемента управления довольно быстро может стать достаточно сложным и обрасти целой кучей других связанных с ним ресурсов. Например, если шаблон кнопки находится в файле по имени Button.xaml в подпапке проекта по имени Resources, в файле App.xaml можно применяться такая разметка: Application x:Class="SimpleApplication.Арр" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" StartupUri="Windowl.xaml"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="Resources\Button.xaml" /> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources> </Application>
490 Глава 17. Шаблоны элементов управления Рефакторинг шаблона элемента управления для кнопки По мере совершенствования и расширения шаблона элемента управления может обнаружиться, что он содержит в себе множество разных деталей, включая специализированные фигуры, геометрии и кисти. Поэтому будет хорошей идеей вынести все эти детали из шаблона элемента управления и определить их в виде отдельных ресурсов. Одна из причин такого шага состоит в том, что в этом случае кисти будет легче использовать среди множества взаимосвязанных элементов управления. Например, пусть необходимо создать настроенные элементы управления Button, Checkbox и RadioButton, которые используют схожий набор цветов. Чтобы упростить решение этой задачи, можно создать отдельный словарь ресурсов для кистей (по имени Brushes.хат 1) и объединить его со словарями ресурсов для всех элементов управления (Button.xaml, CheckBox.xaml и RadioButton.xaml). Чтобы продемонстрировать этот прием в действии, рассмотрим следующую разметку. Она представляет полный словарь ресурсов для кнопки, включая ресурсы, которые использует шаблон элемента управления, сам шаблон элемента управления и правило стиля, применяющее шаблон к каждой кнопке в приложении. Это порядок, которому нужно следовать всегда, т.к. прежде чем ресурс можно будет использовать, он должен быть определен. (Если определить одну из кистей после шаблона, возникнет ошибка, потому что шаблон не сможет найти нужную ему кисть.) <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <!-- Ресурсы, используемые шаблоном. —> <RadialGradientBrush RadiusX=,,l" RadiusY=,,5" GradientOrigin= . 5, 0 . 3" x :Key=llHighlightBackground"> <GradientStop Color=,,White" Offset=,,0" /> <GradientStop Color="Blue" Offset=".4" /> </RadialGradientBrush> <RadialGradientBrush RadiusX=,,l" RadiusY=,,5" GradientOrigin= . 5, 0 . 3" x:Key=llPressedBackground"> <GradientStop Color="White" Offset=,,0" /> <GradientStop Color=,,Blue" Offset=,,l" /> </RadialGradientBrush> <SolidColorBrush Color=,,Blue" x:Key="DefaultBackground"></SolidColorBrush> <SolidColorBrush Color="Gray11 x: Key="DisabledBackgroundll></SolidColorBrush> <RadialGradientBrush RadiusX="l11 RadiusY=11 GradientOrigin= .5, 0 . 3" x:Key=llBorder"> <GradientStop Color=,,White" Offset=,,0" /> <GradientStop Color=,,Blue" Offset=,,l" /> </RadialGradientBrush> <!-- Шаблон элемента управления для кнопки. --> <ControlTemplate x:Key=,,GradientButtonTemplate" TargetType="{x:Type Button}"> <Border Name="Border11 BorderBrush=" {StaticResource Border}11 BorderThickness=11 CornerRadius=11 Background="{StaticResource DefaultBackground}" TextBlock. Foreground="Whitell> <Grid> <Rectangle Name="FocusCue11 Visibility="Hidden11 Stroke="Black11 StrokeThickness=lll" StrokeDashArray="l 2" SnapsToDevicePixels="Truell> </Rectangle> <ContentPresenter Margin=" {TemplateBinding Padding}11 RecognlzesAccessKey="True"></ContentPresenter> </Grid> </Border>
Глава 17. Шаблоны элементов управления 491 <ControlTemplate.Triggers> <Trigger Property="IsMouseOver11 Value=llTrue"> <Setter TargetName="Border11 Property="Background11 Value="{StaticResource HighlightBackground}" /> </Trigger> <Trigger Property="IsPressed11 Value=llTrue"> <Setter TargetName="Border11 Property="Background11 Value="{StaticResource PressedBackground}" /> </Trigger> <Trigger Property=llIsKeyboardFocused" Value="True"> <Setter TargetName="FocusCue11 Property=llVisibilityM Value=,,Visible">< /Setter > </Trigger> <Trigger Property="IsEnabled11 Value=llFalse"> <Setter TargetName="Border11 Property="Background11 Value="{StaticResource DisabledBackground}"></Setter> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </ResourceDictionary> На рис. 17.8 показана кнопка, которую определяет этот шаблон. В данном примере используется градиентная заливка, когда пользователь перемещает курсор мыши над кнопкой. Однако градиент всегда центрирован в середине кнопки. Чтобы создать более экзотический эффект, наподобие градиента, который следует за положением курсора мыши, придется воспользоваться анимацией или написать код. В главе 18 показан пример со специальным классом Chrome, реализующим этот эффект. Рис. 17.8. Кнопка с градиентом Применение шаблонов со стилями С показанным выше дизайном связано одно ограничение. Шаблон элемента управления по существу жестко кодирует некоторые детали, такие как цветовая схема. Это значит, что если понадобится использовать одинаковую комбинацию элементов в кнопке (Border, Grid, Rectangle и ContentPresenter) и организовать их сходным образом, но в другой цветовой схеме, то придется создать новую копию шаблона, ссылающуюся на другие ресурсы кистей.
492 Глава 17. Шаблоны элементов управления Это не обязательно является проблемой (в конце концов, детали компоновки и форматирования могут быть настолько связанными, что все равно они не будут разделяться). Однако это ограничит возможность повторного использования шаблона элемента управления. Если в шаблоне применяется сложная организация элементов, которые должны повторно использоваться с разнообразными деталями форматирования (обычно цветами и шрифтами), эти детали можно извлечь из шаблона и поместить в стиль. Чтобы решить указанные задачи, понадобится переработать шаблон. Вместо использования жестко закодированных цветов нужно извлекать информацию о свойствах элемента управления через привязки шаблона. В приведенном ниже примере определяется простой шаблон для забавной кнопки, которую вы видели ранее. Шаблон элемента управления трактует некоторые детали как фундаментальные неизменяемые ингредиенты, а именно — рамку фокуса и скругленный контур толщиной в 2 единицы. Кисти фона и контура являются конфигурируемыми. Единственный триггер, который остается — тот, что отображает рамку фокуса. <ControlTemplate x:Key=,,CustomButtonTemplate" TargetType="{x:Type Button}"> <Border Name=llBorder11 BorderThickness=11 CornerRadius = ll2" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"> <Grid> <Rectangle Name="FocusCue11 Visibility="Hidden11 Stroke="Black11 StrokeThickness="l11 StrokeDashArray="l 2" SnapsToDevicePixels=llTrue"> </Rectangle> <ContentPresenter Margin=" {TemplateBinding Padding}11 RecognlzesAccessKey="True"></ContentPresenter> </Grid> </Border> <ControlTemplate.Triggers> <Trigger Property="IsKeyboardFocused11 Value="True"> <Setter TargetName="FocusCue11 Property="Visibility11 Value=,,Visible,,x/Setter> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> Связанный стиль применяет этот шаблон элемента управления, устанавливает цвета контура и фона, а также добавляет триггеры, изменяющие фон в зависимости от состояния кнопки: <Style x:Key="CustomButtonStyle11 TargetType=" {x :Type Button} "> <Setter Property="Control.Template" Value="{StaticResource CustomButtonTemplate}"></Setter> <Setter Property="BorderBrush11 Value="{StaticResource Border}"></Setter> <Setter Property="Background11 Value="{StaticResource DefaultBackground}"></Setter> <Setter Property="TextBlock.Foreground" Value="White"x/Setter> <Style.Triggers> <Trigger Property="IsMouseOver11 Value="True"> <Setter Property="Background" Value="{StaticResource HighlightBackground}" /> </Trigger> <Trigger Property="IsPressed" Value="True"> <Setter Property="Background" Value="{StaticResource PressedBackground}" /> </Trigger>
Глава 17. Шаблоны элементов управления 493 <Trigger Property="IsEnabled11 Value="Falsell> <Setter Property="Background11 Value="{StaticResource DisabledBackground}"></Setter> </Trigger> </Style.Triggers> </Style> В идеале хорошо бы иметь все триггеры в шаблоне элемента управления, поскольку они представляют поведение элемента и используют стиль просто для установки базовых свойств. К сожалению, это невозможно, если нужно предоставить стилю возможность устанавливать цветовую схему. На заметку! В случае установки триггеров и в шаблоне элемента управления, и в стиле предпочтение получают триггеры стиля. Чтобы использовать этот новый шаблон, нужно установить свойство Style кнопки вместо свойства Template: <Button Margin=,,10" Padding=,,5" Style=" { StaticResource CustomButtonStyle } "> A Simple Button with a Custom Template</Button> Теперь можно создавать новые стили, использующие тот же шаблон, но с привязкой к другим кистям для применения новой цветовой схемы. Такой подход обладает одним существенным ограничением. Работать со свойством Setter.TargetName в стиле нельзя, поскольку стиль не содержит шаблона элемента управления (а просто ссылается на него). В результате стиль и его триггеры несколько ограничены. Они не могут углубляться в визуальное дерево, чтобы изменить аспект вложенного элемента. Вместо этого стиль должен установить свойство элемента управления, и элементу понадобится привязать свойство с применением привязки шаблона. Сравнение шаблонов элементов управления и специальных элементов управления Обе описанные здесь проблемы — необходимость определения поведения элемента управления в стиле с помощью триггеров и невозможность нацелиться на определенные элементы — можно обойти, создав специальный элемент управления. Например, можно было бы построить класс-наследник Button и добавить ему такие свойства, как HighlightBackground, DisabledBackground и PressedBackground. Затем эти свойства можно было бы привязать к шаблону элемента управления и просто установить их в стиле, без нужды в триггерах. Однако этот подход имеет свои недостатки. Он вынуждает использовать в интерфейсе другой элемент управления (такой как CustomButton вместо Button). Это усложняет задачу проектирования приложения. Обычно переход от специальных шаблонов элементов управления к специальным элементам управления осуществляется в одной из двух ситуаций. • Когда элемент управления представляет существенное изменение функциональности. Например, имеется специальная кнопка, и эта кнопка добавляет новую функциональность, которая требует новых свойств и методов. • Когда планируется распространять элемент управления в отдельной сборке библиотеки классов, чтобы ее можно было использовать (и настраивать) в широком диапазоне приложений. В этой ситуации понадобится более высокий уровень стандартизации, чем тот, что возможен только с шаблонами-. Необходимые сведения по созданию специальных элементов управления можно найти в главе 18.
494 Глава 17. Шаблоны элементов управления Автоматическое применение шаблонов В текущем примере каждая кнопка отвечает за привязку себя к соответствующему шаблону через свойство Template или Style. Это имеет смысл, если шаблон элемента управления используется для создания специфического эффекта в определенном месте приложения. Но это менее удобно, если необходимо сменить обложку каждой кнопки во всем приложении, обеспечив специальный внешний вид. В такой ситуации, скорее всего, понадобится, чтобы все кнопки приложения применяли шаблон автоматически. Для этого необходимо применить шаблон элемента управления посредством стиля. Трюк состоит в использовании типизированного стиля, который автоматически оказывает влияние на элементы соответствующего типа, и устанавливает их свойство Template. Ниже приведен пример стиля, который должен быть помещен в коллекцию ресурсов словаря для придания всем кнопкам нового вида: <Style TargetType="{x:Type Button}"> <Setter Property="Control.Template" Value="{StaticResource ButtonTemplate}" </Style> Это работает потому, что в стиле не указано имя ключа, а это значит, что вместо него используется тип элементов (Button). Помните, что от этого стиля можно отказаться, создав кнопку, которая явно устанавливает свое свойство Style в null: <Button Style="{x:Null}" ... ></Button> Совет. Этот прием работает еще лучше, если следовать рекомендуемым принципам проектирования и определить кнопку в отдельном словаре ресурсов. В такой ситуации стиль не вступит в действие, пока не будет добавлен дескриптор ResourceDictionary, который импортирует ресурсы во все приложение или определенное окно, как было описано ранее. Словарь ресурсов, содержащий комбинацию стилей на основе типов, часто называется темой. Возможности тем впечатляют. Темы позволяют взять готовое приложение WPF и полностью поменять обложки всех его элементов управления, вообще не затрагивая разметку пользовательского интерфейса. Все, что для этого понадобится — добавить в проект словари ресурсов и объединить их в коллекции Application.Resources внутри App.xaml. В Интернете несложно найти несколько тем и воспользоваться ими для обновления готового приложения WPF. Например, можно загрузить набор примеров тем в виде части выпуска WPF Futures по адресу http://wpf.codeplex.com/releases/view/14962. Предварительно ознакомиться с этими темами можно по адресу http://tinyurl.com/ ylojdry. Чтобы использовать тему, добавьте в проект файл .xaml, который содержит словарь ресурсов. Например, средства WPF Futures включают файл темы по имени ExpressionDark.xaml. Затем понадобится активизировать стили в приложении. Это можно сделать на уровне окон, но быстрее будет импортировать их на уровень приложения, добавив примерно такую разметку: <Application . . . > <Application.Resources> <ResourceDictionary Source="ExpressionDark.xaml"/> </Application.Resources> </Application> Теперь стили на основе типов, находящиеся в словаре ресурсов, вступят в полную силу и автоматически изменят внешность всех элементов управления в каждом окне приложения. Если вы — разработчик приложения, желающий построить новый пользовательский интерфейс, но не обладающий достаточной квалификацией, чтобы еде-
Глава 17. Шаблоны элементов управления 495 лать это самостоятельно, данный трюк позволит позаимствовать чужие наработки без особых усилий. Обложки, выбранные пользователем В некоторых приложениях может понадобиться изменять шаблоны динамически — обычно в соответствии с предпочтениями пользователя. Сделать это довольно просто, хотя процесс недостаточно хорошо документирован. Базовый прием состоит в загрузке нового словаря ресурсов во время выполнения и его использование для замены текущего словаря ресурсов. (При этом не обязательно заменять все ваши ресурсы, а только те, что задействованы в обложке.) Фокус состоит в извлечении объекта ResourceDictionary, который компилируется и встраивается в виде ресурса в приложение. Для загрузки нужных ресурсов легче всего применять класс ResourceManager, описанный в главе 10. Например, предположим, что созданы два ресурса, которые определяют альтернативные версии одного и того же шаблона элемента управления для кнопки. Один находится в файле GradientButton.xaml, а другой — в GradientButtonVariant.xaml. Оба файла для лучшей организации помещены в подпапку Resources текущего проекта. Теперь можно создать простое окно, использующее один из этих ресурсов через коллекцию Resources, как показано ниже: <Window.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="Resources/GradientButton.xaml"></ResourceDictionary> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Window.Resources> Замена разных словарей ресурсов осуществляется с помощью примерно такого кода: ResourceDictionary newDictionary = new ResourceDictionary (); newDictionary.Source = new Uri ( "Resources/GradientButtonVariant.xaml", UriKind.Relative); this.Resources.MergedDictionaries [0] = newDictionary; Этот код загружает словарь ресурсов по имени GradientButtonVariant и помещает его в первый слот коллекции MergedDictionaries. Он не очищает коллекцию MergedDictionaries (или любой другой из ресурсов окна), т.к. возможно, что существуют привязки к другим словарям ресурсов, которые должны продолжать использоваться. Он не добавляет нового элемента в коллекцию MergedDictionaries, потому что может возникнуть конфликт между одноименными ресурсами в разных коллекциях. Для изменения обложки всего приложения необходимо применить тот же подход, но при этом использовать словарь ресурсов приложения. Обновить этот словарь ресурсов можно с помощью следующего кода: Application.Current.Resources.MergedDictionaries [0] = newDictionary; Можно также загрузить словарь ресурсов, определенный в другой сборке, с использованием синтаксиса упакованных URI, который описан в главе 7: ResourceDictionary newDictionary = new ResourceDictionary (); newDictionary.Source = new Uri ( "ControlTemplateLibrary;component/GradientButtonVariant.xaml", UriKind.Relative) ; this.Resources.MergedDictionaries[0] = newDictionary;
496 Глава 17. Шаблоны элементов управления При загрузке нового словаря ресурсов все кнопки автоматически обновляются для использования нового шаблона. Если радикальная модификация элемента управления не нужна, в качестве части обложки могут применяться базовые стили. В этом примере предполагается, что ресурсы GradientButton.xaml и Gradient ButtonVariant.xaml используют типизированный в отношении элементов стиль для автоматического изменения кнопок. Как известно, существует и другой подход — на новый шаблон можно переключиться, вручную установив свойство Template или Style объектов Button. При таком подходе следует использовать ссылку DynamicResource вместо StaticResource. Если применяется StaticResource, шаблон кнопки не будет обновлен при смене обложки. На заметку! При использовании ссылки DynamicResource предполагается, что нужный ресурс находится где-то в иерархии ресурсов. Если это не так, ресурс просто игнорируется, и кнопки возвращаются к своему стандартному виду без генерации ошибки. Доступен и другой способ программной загрузки ресурсов. Можно создать класс отделенного кода для вашего словаря ресурсов, подобно тому, как создаете такие классы для окон. Затем вместо использования свойства ResourceDictionary.Source следует непосредственно создать экземпляр этого класса. Такой подход обладает преимуществом строгой типизации (нет шансов ввести неверный URI в свойстве Source), и он позволяет добавлять свойства, методы и прочую функциональность в класс ресурсов. Например, в главе 23 эта возможность используется для создания ресурса, имеющего код обработки событий для специального шаблона окна. Хотя создать класс отделенного кода для словаря ресурсов совсем не сложно, Visual Studio не делает это автоматически. Вместо этого понадобится добавить файл кода с частичным классом, который унаследован от Re sourceDictionary и вызывает InitializeComponent в своем конструкторе: public partial class GradientButtonVariant : ResourceDictionary { public GradientButtonVariant () { InitializeComponent(); } } Здесь используется имя класса GradientButtonVariant, и класс находится в файле GradientButtonVariant.xaml.cs. Файл XAML содержит в себе ресурс по имени GradientButtonVariant.xaml. He обязательно, чтобы имена были согласованы подобным образом, однако это хорошая идея, которая соответствует соглашению, используемому Visual Studio при создании окон и страниц. Следующий шаг — связывание класса со словарем ресурсов. Это делается добавлением атрибута Class к корневому элементу словаря ресурсов, подобно тому, как это производится для окна и для любого класса XAML. Затем указывается полностью квалифицированное имя класса. В данном примере проект называется ControlTemplates, и это будет пространством имен по умолчанию, потому полный дескриптор выглядит так: <ResourceDictionary x:Class="ControlTemplates.GradientButtonVariant" ... > Теперь можно использовать следующий код для создания словаря ресурсов и применения его в отношении окна: GradientButtonVariant newDictionary = new GradientButtonVariant(); this.Resources.MergedDictionaries[0] = newDictionary;
Глава 17. Шаблоны элементов управления 497 Если нужно, чтобы файл GradientButtonVariant.xaml.cs появился в Solution Explorer под файлом GradientButtonVariant.xaml, понадобится модифицировать файл проекта .csproj в текстовом редакторе. Отыщите в разделе <ItemGroup> файл отделенного кода и замените строку: <Compile Include="Resources\GradientButtonVariant.xaml.cs" /> следующим кодом: <Compile Include="Resources\GradientButtonVariant.xaml.cs"> <DependentUpon> Resources\GradientButtonVariant.xaml</DependentUpon> </Compile> Построение более сложных шаблонов Между шаблоном элемента управления и поддерживающим его кодом существует неявный контракт. В случае замены стандартного шаблона элемента управления собственным шаблоном необходимо обеспечить, чтобы новый шаблон отвечал всем требованиям кода реализации элемента управления. В простых элементах это сделать легко, поскольку они предъявляют мало (если вообще предъявляют) реальных требований к шаблону. В сложном элементе управления проблема несколько тоньше, потому что невозможно полностью отделить визуальное представление от реализации. В такой ситуации элемент управления должен делать какие-то предположения о визуальном отображении, независимо от того, насколько хорошо он спроектирован. Выше уже были показаны два примера требований, которые элемент управления может предъявлять к своему шаблону, с элементами-заполнителями (вроде ContentPresenter и ItemsPresenter) и привязками шаблона. В следующих разделах будут продемонстрированы еще два: элементы со специфическими именами (начинающимися с PART) и элементы, специально спроектированные для использования в шаблоне определенного элемента управления (такие как Track в ScrollBar). Чтобы создать успешный шаблон элемента управления, следует тщательно изучить стандартный шаблон интересующего элемента, разобраться, как используются в нем эти четыре приема, и затем продублировать их в собственных шаблонах. На заметку! Имеется и другой путь обеспечения удобного взаимодействия между элементами управления и их шаблонами. Можно создать собственный специальный элемент управления. В этом случае стоит обратная задача — требуется создать код, который использует шаблон стандартным способом и может успешно работать с шаблонами, поставляемыми другими разработчиками. Эта задача решается в главе 18 (которая является замечательным дополнением настоящей главы). Вложенные шаблоны Шаблон для элемента управления типа кнопки может быть разобран на несколько относительно простых частей. Тем не менее, многие другие шаблоны не столь просты. В некоторых случаях шаблон элемента управления будет содержать в себе множество элементов, которые потребуются также и любому специальному шаблону. А в некоторых случаях изменение внешнего вида элемента управления влечет за собой создание более чем одного шаблона. Например, предположим, что планируется изменить знакомый элемент управления ListBox. Первый шаг в создании такого примера состоит в проектировании шаблона для ListBox и (дополнительно) добавлении стиля, который применяет шаблон автоматически. Ниже показаны оба ингредиента, объединенные в одно целое:
498 Глава 17. Шаблоны элементов управления <Style TargetType="{х:Туре ListBox}"> <Setter Property=MTemplate"> <Setter.Value> <ControlTemplate TargetType="{x:Type ListBox}"> <Border Name=MBorderM Background="{StaticResource ListBoxBackgroundBrush}" BorderBrush="{StaticResource StandardBorderBrush}" BorderThickness=Ml?l CornerRadius=M3M> <ScrollViewer Focusable="FalseM> <IternsPresenter Margin=M2M></ItemsPresenter> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> Стиль использует две кисти для рисования границы и фона. Этот шаблон представляет собой упрощенную версию стандартного шаблона ListBox, но отказывается от класса ListBoxChrome в пользу простого Border. Внутри Border находится элемент ScrollViewer, обеспечивающий прокрутку списка, и ItemsPresenter, хранящий все элементы списка. Данный шаблон наиболее примечателен тем, что не позволяет делать, а именно — конфигурировать внешний вид индивидуальных позиций в списке. Без этой возможности выбранная позиция всегда отображается со знакомым фоном синего цвета. Чтобы изменить это поведение, понадобится добавить шаблон элемента управления для ListBoxItem — элемента с содержимым, который служит оболочкой для каждого индивидуального элемента в списке. Как и ListBox, шаблон ListBoxItem можно применить с использованием типизированный в отношении элементов стиля. Показанный ниже базовый шаблон помещает каждый элемент списка в невидимую рамку. Поскольку ListBoxItem — элемент с содержимым, для размещения содержимого внутри него предусмотрен ContentPresenter. Наряду с этим имеется триггер, реагирующий, когда на элемент наводится курсор мыши или на нем выполняется щелчок: <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="TemplateM> <Setter.Value> <ControlTemplate TargetType="{x:Type ListBoxItem}"> <Border ... > <ContentPresenter /> </Border> <ControlTemplate.Triggers> <EventTrigger RoutedEvent="ListBoxItem.MouseEnter"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <DoubleAnimation Storyboard.TargetProperty="FontSizeM To=0" Duration= : 0 : l"X/DoubleAnimation> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> <EventTrigger RoutedEvent="Li s tBoxItem.MouseLeave"> <EventTrigger.Actions> <BeginStoryboard>
Глава 17. Шаблоны элементов управления 499 <Storyboard> <DoubleAnimation Storyboard.TargetProperty="FontSize" BeginTime=M0 : 0:0.5" Duration= : 0 : 0 .2"X/DoubleAnimation> </S toryboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> <Trigger Property="IsMouseOver" Value=MTrue"> <Setter TargetName="BorderM Property=MBorderBrushM ... /> </Trigger> <Trigger Property=MIsSelected" Value=MTrueM> <Setter TargetName=MBorderM Property="Background11 . . . /> <Setter TargetName="Border" Property="TextBlock.Foreground" ... /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> ■ AmmationlnTemplate Вместе эти два шаблона позволяют создать окно списка, использующее анимацию для увеличения позиции, на которую в данный момент наведен курсор мыши. Поскольку каждый ListBoxItem может иметь собственную анимацию, при перемещении курсора мыши вверх и вниз по списку видно, что несколько элементов начинают расти и снова сжиматься, создавая привлекающий внимание эффект. (Еще более экстравагантного эффекта можно было бы добиться, с помощью анимированных трансформаций увеличивая и деформируя элемент, на который наведен курсор мыши.) Хотя невозможно показать этот эффект на одной картинке, на рис. 17.9 представлен экранный снимок этого списка после быстрого перемещения курсора мыши над несколькими элементами. Целиком пример шаблона ListBoxItem здесь не рассматривается, т.к. он состоит из множества простых частей, которые стилизуют ListBox, ListBoxItem и различные составные части ListBox (вроде полосы прокрутки). Важной частью является стиль, изменяющий шаблон ListBoxItem. В данном примере ListBoxItem увеличивается относительно медленно (за одну секунду), а затем быстро уменьшается (за 0,2 секунды). Перед началом анимации уменьшения имеется полусекундная задержка. Обратите внимание, что анимация уменьшения обходится без свойств From и То. То есть, она всегда уменьшает текст от его текущего размера до исходного. Поместив курсор мыши на ListBoxItem, затем передвинув его обратно, можно получить вполне ожидаемый результат — элемент будет увеличиваться, пока над ним находится курсор мыши, и уменьшаться, когда курсор мыши отодвигается в сторону. Рис. 17.9. Использование специального шаблона для ListBoxItem Совет. Как всегда, лучший способ обеспечить все эти различные соглашения состоит в том, чтобы с помощью средства просмотра шаблонов, которое было показано ранее в главе, исследовать шаблоны по умолчанию базовых элементов. Готовый шаблон можно скопировать и отредактировать, используя в качестве основы для разработки собственного шаблона.
500 Глава 17. Шаблоны элементов управления Модификация полосы прокрутки Существует один аспект окна списка, который пока остался нетронутым — полоса прокрутки, расположенная справа. Это часть элемента ScrollViewer, который является частью шаблона List Box. Несмотря на то что в данном примере переопределяется шаблон ListBox, элемент ScrollViewer в ScrollBar не изменяется. Чтобы настроить эту деталь, можно было бы создать шаблон ScrollViewer для использования cListBoxH затем указать этот специальный шаблон ScrollBar в качестве шаблона ScrollViewer. Однако доступен более простой способ — создать типизированный в отношении элементов стиль, который изменяет шаблон всех обнаруженных элементов управления ScrollBar. Это позволит избежать дополнительной работы по созданию шаблона ScrollViewer. Конечно, также следует подумать о том, как это проектное решение затронет остальную часть приложения. Если создать типизированный в отношении элементов стиль ScrollBar и добавить его в коллекцию Resources окна, то все элементы управления в этом окне получат новые стилизованные полосы прокрутки, если они используют ScrollBar, что может быть именно тем, что и требуется. С другой стороны, если нужно изменить только полосу прокрутки в ListBox, понадобится добавить типизированный в отношении элементов стиль ScrollBar в коллекцию ресурсов самого ListBox. И, наконец, чтобы изменить вид полос прокрутки во всем приложении, необходимо добавить стиль в коллекцию ресурсов внутри файла App.xaml. Элемент управления ScrollBar неожиданно сложен. В действительности он состоит из целой коллекции меньших частей, как показано на рис. 17.10. RepeatButton Дорожка ,L С RepeatButton (прозрачный) Бегунок полосы прокрутки RepeatButton (прозрачный) RepeatButton Рис. 17.10. Внутреннее устройство полосы прокрутки
Глава 17. Шаблоны элементов управления 501 Фон полосы прокрутки представлен классом Track — обычно это текстурированный прямоугольник, простирающийся на всю длину полосы. На концах полосы прокрутки находятся кнопки, которые позволяют перемещать на один инкремент вверх или вниз (либо влево или вправо). Это экземпляры класса RepeatButton, унаследованного от ButtonBase. Ключевое отличие между RepeatButton и обычным классом Button состоит в том, что удержание кнопки мыши нажатой на RepeatButton приводит к повторению события Click (это удобно для прокрутки). Посередине полосы прокрутки расположен элемент Thumb, представляющий текущую позицию прокручиваемого содержимого. И что самое интересное, пустое пространство с каждой стороны от ползунка — это на самом деле еще два объекта RepeatButton, являющиеся прозрачными. При щелчке на любом из них полоса прокрутки прокручивает содержимое на целую страницу (страница определена как часть прокручиваемого содержимого, умещающаяся в окно). Это дает хорошо знакомую возможность быстро перемещаться по прокручиваемому содержимому, щелкая на полосе по обе стороны от ползунка. Ниже показано, как выглядит шаблон вертикальной полосы прокрутки: <ControlTemplate x:Key="VerticalScrollBar" TargetType="{x:Type ScrollBar}"> <Grid> <Grid.RowDefinitions> <RowDefinition MaxHeight=8"/> <RowDefmition Height="*"/> <RowDefinition MaxHeight=8"/> </Grid.RowDefinitions> <RepeatButton Grid.Row=" Height=8" Style="{StaticResource ScrollBarLineButtonStyle}" Command="ScrollBar.LineUpCommand" > <Path Fill="{StaticResource GlyphBrush}" Data="M 04L84L40 Z"x/Path> </RepeatButton> <Track Name="PART_Track" Grid.Row="l" IsDirectionReversed="True" ViewportSize="> <Track.DecreaseRepeatButton> <RepeatButton Command="ScrollBar.PageUpCommand" Style="{StaticResource ScrollBarPageButtonStyle}"> </RepeatButton> </Track.DecreaseRepeatButton> <Track.Thumb> <Thumb Style="{StaticResource ScrollBarThumbStyle}"> </Thumb> </Track.Thumb> <Track.IncreaseRepeatButton> <RepeatButton Command="ScrollBar.PageDownCommand" Style="{StaticResource ScrollBarPageButtonStyle}"> </RepeatButton> </Track.IncreaseRepeatButton> </Track> <RepeatButton Grid.Row=" Height=8" Style="{StaticResource ScrollBarLineButtonStyle}" Command="ScrollBar.LineDownCommand" Content="M 00L44L80 Z"> </RepeatButton> <RepeatButton Grid.Row=" Height=8" Style="{StaticResource ScrollBarLineButtonStyle}" Command="ScrollBar.LineDownCommand">
502 Глава 17. Шаблоны элементов управления <Path Fill="{StaticResource GlyphBrush}" Data="M 00L44L80 Z"x/Path> </RepeatButton> </Grid> </ControlTemplate> Этот шаблон довольно прост, стоит лишь понять однажды составную структуру полосы прокрутки (показанную на рис. 17.10). Есть несколько ключевых моментов, которые следовало бы отметить. • Вертикальная полоса прокрутки состоит из элемента Grid с тремя строками. Верхняя и нижняя строки содержат кнопки (со стрелочками). Их размер фиксирован и составляет 18 единиц. Средняя часть, которая содержит дорожку, занимает остальное пространство. • Элементы RepeatButton на обоих концах используют одинаковый стиль. Единственное отличие связано со свойством Content, которое содержит элемент Path, рисующий стрелку, потому что верхняя кнопка отображает стрелку вверх, а нижняя — стрелку вниз. Для краткости эти стрелки представлены на мини-языке описания пути, представленном в главе 13. Прочие детали, такие как заполнение фона и окружность вокруг стрелки, определены в шаблоне элемента управления, который установлен в ScrollButtonLineStyle. • Обе кнопки привязаны к командам в классе ScrollBar (LineUpCommand и LineDownCommand). Команды обеспечивают выполнение работы. До тех пор, пока предоставляется кнопка, привязанная к одной из этих команд, не важно, как она именована, каким образом выглядит и какой конкретный класс использует. (Команды детально рассматриваются в главе 9.) • Дорожка имеет имя PART _ Track. Это имя должно использоваться, чтобы код класса ScrllBack мог успешно работать. Если просмотреть шаблон по умолчанию для класса ScrollBar (который похож на показанный выше, но только длиннее), можно заметить, что это имя присутствует и там. На заметку! Исследовав элемент управления с помощью рефлексии (или инструмента наподобие Reflector), можно увидеть атрибуты TemplatePart, которыми снабжено объявление класса. У каждой именованной части должен быть свой атрибут TemplatePart. Атрибут TemplatePart указывает имя ожидаемого элемента (в свойстве Name) и его класс (в свойстве Туре). В главе 18 будет показано, как применить атрибут TemplatePart к собственным классам элементов управления. • Свойство Track. Viewport Size установлено в 0. Это — специфическая деталь реализации в данном шаблоне. Она гарантирует, что Thumb всегда будет одного размера. (Обычно размер ползунка пропорционален содержимому, так что при прокрутке содержимого, которое почти целиком умещается в окно, ползунок будет намного больше.) • Элемент Track включает в себя два объекта RepeatButton (стиль которых определен отдельно) и Thumb. Опять-таки, эти кнопки привязаны к соответствующей функциональности с помощью команд. Также можно заметить, что шаблон использует ключевое имя, которое указывает на то, что это — вертикальная полоса прокрутки. Как известно из главы 11, установка ключевого имени стиля гарантирует, что оно не применяется автоматически, даже если также установлено свойство Та г get Ту ре. Причина использования этого подхода в данном примере связана с тем, что шаблон подходит только к полосам прокрутки с вертикальной ориентацией.
Глава 17. Шаблоны элементов управления 503 Другой типизированный в отношении элементов стиль использует триггер, который автоматически применяет шаблон элемента, если свойство ScrollBar.Orientation установлено в Vertical: <Style TargetType="{x:Type ScrollBar}"> <Setter Property="SnapsToDevicePixels" Value="True"/> <Setter Property="OverridesDefaultStyle" Value="true"/> <Style.Triggers> <Trigger Property="Orientation" Value="Vertical"> <Setter Property="Width" Value=8"/> <Setter Property="Height" Value="Auto" /> <Setter Property="Template" Value="{StaticResource VerticalScrollBar}" /> </Trigger> </Style.Triggers> </Style> Хотя горизонтальную полосу прокрутки можно было бы легко построить из тех же базовых частей, в рассматриваемом примере этот шаг не предпринимается (и потому остается нормально стилизованная горизонтальная полоса прокрутки). Последняя задача — заполнить стили, которые форматируют различные объекты RepeatButton и Thumb. Эти стили относительно просты, однако они изменяют стандартный вид полосы прокрутки. Для начала оформим Thumb в виде эллипса: <Style x:Key="ScrollBarThumbStyle" TargetType="{x:Type Thumb}"> <Setter Property="IsTabStop" Value="False"/> <Setter Property="Focusable" Value="False"/> <Setter Property="Margin" Value="l,0,1,0" /> <Setter Property="Background" Value="{StaticResource StandardBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource StandardBorderBrush}" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type Thumb}"> <Ellipse Stroke="{StaticResource StandardBorderBrush}" Fill="{StaticResource StandardBrush}"></Ellipse> </ControlTemplate> </Setter.Value>- </Setter> </Style> Затем нарисуем стрелки на обоих концах полосы прокрутки внутри симпатичных кружочков. Кружки определены в шаблоне элемента управления, в то время как стрелки представлены содержимым RepeatButton и вставлены в шаблон элемента управления с помощью ContentPresenter. <Style x:Key="ScrollBarLineButtonStyle" TargetType="{x:Type RepeatButton}"> <Setter Property="Focusable" Value="False"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type RepeatButton}"> <Grid Margin="l"> <Ellipse Name="Border" StrokeThickness="l" Stroke="{StaticResource StandardBorderBrush}" Fill="{StaticResource StandardBrush}"></Ellipse> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"></ContentPresenter> </Grid> <ControlTemplate.Triggers> <Trigger Property="IsPressed" Value="true"> <Setter TargetName="Border" Property="Fill"
504 Глава 17. Шаблоны элементов управления Value="{StaticResource PressedBrush}" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> Объекты Repeat But ton, отображаемые в полосе, изменены. Теперь они просто используют прозрачный фон, так что дорожка видна сквозь них: <Style x:Key="ScrollBarPageButtonStyle" TargetType="{x:Type RepeatButton}"> <Setter Property="IsTabStop" Value="False"/> <Setter Property="Focusable" Value="False"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type RepeatButton}"> <Border Background="Transparent" /> </ControlTemplate> </Setter.Value> </Setter> </Style> MultiPartTemplate Ш One j Two Three Four k z *e : Six | Seven j Eight . i : Рис. 17.11. Окно списка с настроенной полосой прокрутки В отличие от нормальной полосы прокрутки, в этом шаблоне нет фона, назначенного Track, что делает его прозрачным. И потому приятно тексту- рированный градиент окна списка просматривается сквозь него. На рис. 17.11 показан окончательный вид окна списка. Примеры шаблонов элементов управления Как теперь известно, создание нового шаблона для стандартного элемента управления может потребовать кропотливой работы. Причина в том, что все требования шаблона элемента управления не всегда очевидны. Например, типичная полоса прокрутки ScrollBar требует комбинирования двух объектов RepeatButton и Track. Другие шаблоны элементов управления нуждаются в специфических именах PART_. В случае специального окна нужно будет позаботиться о декоративном слое, потому что этого требуют некоторые элементы управления. Хотя все эти детали можно изучить, просматривая шаблон элемента, который у него есть по умолчанию, все же такие шаблоны зачастую усложнены и включают детали, которые не важны, а также привязки, которые все равно не понадобятся. К счастью, есть более простой способ: проект-пример ControlTemplateExamples (ранее известный как под названием "Simple Styles" — простые стили). Примеры шаблонов элементов управления включает простые шаблоны для всех стандартных элементов управления WPF, что предоставляет отличную начальную точку для проектирования любого специального элемента управления. В отличие от шаблонов элементов управления по умолчанию, примеры шаблонов используют стандартные цвета, выполняют всю работу декларативно (без классов Chrome) и обходятся без дополнительных частей, таких как привязки шаблонов для редко используемых свойств. Целью этих примеров шаблонов является предоставление разработчикам практичной
Глава 17. Шаблоны элементов управления 505 начальной точки, которой они могут воспользоваться для проектирования собственных графически расширенных шаблонов элементов управления. На рис. 17.12 показана приблизительно половина примеров из этого комплекта. Комплект примеров SimpleStyles входит в состав .NET Framework SDK. Он доступен для непосредственной загрузки по адресу: http://code.msdn.microsoft.eom/wpfsamples#controlcustomization Совет. Комплект SimpleStyles — один из "тайных сокровищ" WPF. Он предоставляет шаблоны, которые легче понять и расширить, чем шаблоны элементов управления по умолчанию. Если нужно расширить обычный элемент управления, обеспечив ему новую внешность, то начинать нужно именно с этого проекта. ListBox A Checked 10 Indeterminate j First Normal Item ! Second Normal Item \ Third Normal Item • Fourth Normal Kern 'FrnnNcwailtem . \Zi First Norma) Item Second Normal Item Tfcrd Normal Item Fourth Normal Item Fifth NonTWO Rad>o8utton textBox ^Normal Щ, Checked ^j Normal Slider JuLi ill m ЩЩЩ » Top One » Top Two ► Top Three ContextMeTR» ; Top One Top Two Top Three Pro§ essBai ■- ContextMaou Га •• УГЛЫ One *ac *hree Fou» Ixpender j-jTheistheHeaoer • Рис. 17.12. Элементы управления с простейшими стилями Визуальные состояния До сих пор вы изучали наиболее прямой (и наиболее популярный) способ построения шаблона элемента управления: использование смеси элементов, выражений привязки и триггеров. Элементы формируют общую визуальную структуру элемента управления. Привязки извлекают информацию из свойств класса элемента управления и применяют их к элементам внутри. Триггеры обеспечивают интерактивность, позволяя элементу управления изменять свою внешность при изменении состояния. Преимущества этой модели в ее исключительной мощи и гибкости. Можно изменять все, что требуется. Это не сразу видно на примере с кнопкой, потому что там
506 Глава 17. Шаблоны элементов управления шаблон элемента управления полагается на встроенные свойства вроде IsMouseOver и IsPressed. Но даже если бы эти свойства были недоступны, все равно можно было бы построить шаблон элемента управления, который изменяет себя в ответ на перемещения курсора мыши и щелчки. Трюк состоит в использовании триггеров событий, применяющих анимации. Например, можно добавить триггер события, который реагирует на Border.MouseOver запуском анимации, изменяющей цвет фона границы. Эта анимация даже не обязана выглядеть как анимация: если установить для нее длительность в О секунд, она произойдет мгновенно, как в случае изменения свойства, используемого сейчас. Фактически именно такой прием применяется во многих примерах профессиональных шаблонов. Несмотря на свои возможности, шаблоны на основе триггеров обладают одним недостатком: они требуют от проектировщика шаблона детального понимания работы элемента управления. Так, в примере с кнопкой проектировщик шаблона должен знать о существовании свойств IsMouseOver и IsPressed, а также о том, как их использовать. И это еще не все — например, большинство элементов управления должно визуально реагировать на перемещения курсора мыши, позволять отключение, получать фокус и поддерживать многие другие изменения состояния. Когда все эти состояния применяются в сочетании, может быть нелегко определить точно, как должен выглядеть элемент управления. Модель на основе триггеров также существенно усложняют переходы. Например, предположим, что необходимо создать кнопку, которая пульсирует при наведении на нее курсора мыши. Чтобы получить профессиональный результат, могут понадобиться две анимации — одна для изменения состояния кнопки от нормального до состояния с наведенным курсором мыши, а другая для создания после этого эффекта непрерывного пульсирования. Управление всеми этими деталями посредством шаблона на базе триггеров может оказаться непростой задачей. В WPF 4 появилось новое средство, именуемое визуальными стилями (visual style), которое призвано справиться с решением этой задачи. Используя именованные части (уже показанные ранее) и визуальные стили, элемент управления может предоставлять стандартизованный визуальный контракт Вместо знания устройства всего элемента управления проектировщик шаблона должен лишь понять правила визуального контракта. В результате намного легче становится спроектировать простой шаблон элемента управления, особенно когда он предназначен для элемента, с которым не приходилось работать ранее. Подобно тому, как элементы могут использовать атрибут Tempi ate Part для указания специфических именованных элементов (или частей), которые должен включать в себя шаблон элемента управления, они могут применять атрибут TemplateVisualState для указания поддерживаемых визуальных стилей. Например, обычная кнопка может предоставить примерно следующий набор визуальных состояний: [TemplateVisualState(Name="Normal", GroupName="CommonStates")] [TemplateVisualState(Name="MouseOver", GroupName="CommonStates")] [TemplateVisualState(Name="Pressed", GroupName="CommonStates")] [TemplateVisualState(Name="Disabled", GroupName="CommonStates")] [TemplateVisualState(Name="Unfocused", GroupName="FocusStates")] [TemplateVisualState(Name="Focused", GroupName="FocusStates")] public class Button : ButtonBase { • .. } Состояния объединяются в гриппы. Группы являются взаимно исключающими, а это означает, что элемент управления имеет только одно состояние в каждой группе. Например, показанная здесь кнопка поддерживает две группы состояний: CommandStates и FocusStates. В любой момент времени кнопка имеет одно из состояний группы CommandStates и одно из состояний группы FocusStates.
Глава 17. Шаблоны элементов управления 507 Например, если переход на кнопку был совершен клавишей <ТаЬ>, ее состоянием будет Normal (из CommandStates) и Focused (из FocusStates). Без групп состояний было бы нелегко справиться с этой ситуацией. Пришлось бы либо определять некоторое преимущество одних состояний над другими (так что кнопка в состоянии Mouseover теряла бы индикатор фокуса), либо создавать намного больше состояний (таких как FocusedNormal, UnfocusedNormal, FocusedMouseOver, UnfocusedMouseOver и т.д.). К этому моменту вы уже должны оценить привлекательность модели визуальных состояний. Сразу становится ясно, что шаблон элемента управления должен обработать шесть разных возможных состояний. Также известны имена каждого состояния, что является единственной существенной деталью. Не нужно знать свойства, предоставляемые классом Button, или понимать внутреннюю работу элемента управления. А лучше всего то, что в случае работы в Expression Blend становится доступной расширенная поддержка времени проектирования при создании шаблонов для элемента управления, который поддерживает визуальные состояния. Инструмент Expression Blend отобразит именованные части и визуальные состояния, поддерживаемые элементом управления (как определено атрибутами TemplatePart и TemplateVisualState), после чего можно будет добавить соответствующие элементы и раскадровки. В следующей главе рассматривается специальный элемент управления под названием FlipPanel, в котором модель визуальных состояний применяется на практике. Резюме В этой главе вы узнали, как с помощью приемов построения шаблонов менять обложки основных элементов управления WPF, таких как кнопка, без необходимости заново реализовывать базовую функциональность кнопки. Полученные специальные кнопки полностью поддерживают нормальное поведение кнопок — можно передвигаться по ним с помощью клавиши <ТаЬ>, щелкать на них, чтобы инициировать событие, использовать клавиши сокращенного доступа и т.д. Но лучше всего то, что новый шаблон кнопки может многократно использоваться по всему приложению, и в нужный момент быть заменен совершенно новым дизайном. Так что же еще следует узнать, прежде чем приступить к изменению внешнего вида базовых элементов управления WPF? Чтобы добиться оригинального внешнего вида элементов, скорее всего, понадобится уделить больше времени на изучение деталей рисования WPF (главы 12 и 13) и анимации (главы 15 и 16). Для вас может быть сюрпризом, что уже известные фигуры и кисти могут применяться при построении изощренных элементов управления с эффектами стеклянных бликов и мягкого свечения. Секрет — в комбинировании нескольких слоев фигур, каждая из которых использует свою градиентную кисть. Учиться строить такие эффекты лучше всего на примере шаблонов элементов управления, созданных другими. Ниже описаны два хороших источника, которые стоит изучить. • В Интернете можно найти множество эффектных кнопок — блестящих, светящихся и т.п. По адресу http://blogs.msdn.com/mgrayson/archive/2007/02/16/ creating-a-glass-button-the-complete-tutorial.aspx доступно целое руководство, которое проведет через процесс создания красивой блестящей кнопки в Expression Blend. • В статье в MSDN Magazine, посвященной шаблонам элементов управления, представлены примеры шаблонов, которые включают простые рисунки необычными способами. Например, элемент Checkbox заменяется тумблером "вверх-вниз", элемент ProgressBar — термометром и т.п. Загляните по адресу http://msdn. microsoft.com/ru-ru/magazine/ccl63497.aspx.
ГЛАВА 18 Пользовательские элементы В предыдущих платформах для разработки Windows-приложений пользовательские элементы управления играли центральную роль. Но в WPF акценты смещены. Пользовательские элементы управления по-прежнему остаются удобным способом построения специализированных графических элементов управления (widgets — вид- жеты), которые можно разделять между приложениями, но их наличие перестало быть обязательным требованием, когда необходимо расширить и перенастроить основные элементы управления. (Чтобы понять, насколько существенно это изменение, стоит отметить, что книга, предшествовавшая настоящей — Pro .NET 2.0 Windows Forms and Custom Controls — содержала девять глав, посвященных пользовательским элементам управления, плюс дополнительные примеры в других главах. А здесь вплоть до главы 18 пользовательские элементы управления ни разу не упоминались.) Благодаря встроенной поддержке стилей, элементов управления содержимым и шаблонов, акцент с пользовательских элементов в WPF снят. Все эти средства предоставляют каждому разработчику несколько возможностей улучшения и расширения стандартных элементов управления, без необходимости наследования новых классов элементов. Ниже перечислены эти возможности. • Стили. Для безболезненного повторного использования свойств элементов управления можно применять стиль. Можно даже применять эффекты, используя триггеры. Чтобы получить тот же результат в Windows Forms, разработчику приходилось копировать и вставлять код (что весьма непрактично) или наследовать свой пользовательский элемент управления, жестко кодируя логику установки свойств в конструкторе. • Элементы управления содержимым. Любой элемент, унаследованный от ContentControl, поддерживает вложенное содержимое. Используя такие элементы, можно быстро создавать составные элементы управления, включающие в себя группы других элементов. (Например, можно трансформировать обычную кнопку в кнопку с изображением или обычное окно списка в окно списка изображений.) • Шаблоны элементов управления. Все элементы управления WPF лишены внешнего вида, а это означает, что они имеют определенную закодированную функциональность, но их внешний вид определяется отдельно, через шаблоны. Заменив шаблон по умолчанию чем-нибудь новым, можно полностью обновить такие базовые элементы управления, как кнопки, флажки, переключатели и даже окна. • Шаблоны данных. Все классы, унаследованные от ItemsControl, поддерживают шаблоны данных, что позволяет создавать расширенное списочное представле-
Глава 18. Пользовательские элементы 509 ние некоторых типов объектов данных. Используя правильные шаблоны данных, можно отобразить каждый элемент с применением комбинации текста, графических изображений и даже редактируемых элементов — и все это в любом выбранном контейнере компоновки. По возможности всегда следует рассмотреть все эти варианты, прежде чем приниматься за создание собственного пользовательского элемента управления. Дело в том, что все перечисленные выше решения проще, легче в реализации и часто удобнее для повторного использования. Итак, когда же необходимо создавать пользовательский элемент управления? Пользовательский элемент — не лучший выбор, когда нужно просто подкорректировать внешний вид элемента, но вполне оправданный, когда его функциональность требуется существенно изменить. Например, есть причины существования в WPF отдельных классов TextBox и PasswordBox, потому что они обрабатывают нажатия клавиш по-разному, сохраняя данные внутри себя различными способами, по-разному взаимодействуя с другими компонентами, такими как буфер обмена, и т.п. Аналогично, если требуется получить элемент управления, который обладает собственным исключительным набором свойств, методов и событий, то придется строить его самостоятельно. В настоящей главе вы научитесь создавать пользовательские элементы управления и делать их полноценными "гражданами" сообщества классов WPF. Это значит, что вы будете оснащать их свойствами зависимости и маршрутизируемыми событиями, чтобы получить поддержку таких важных служб WPF, как привязка данных, стили и анимация. Кроме того, вы узнаете, как создать элемент, лишенный внешнего вида — управляемый шаблонами элемент, который позволяет его потребителю применять различные визуальные представления для большей гибкости. Что нового? В этой главе предложен полномасштабный пример применения новой визуальной системы состояния, которая была представлена в главе 17. В разделе "Поддержка визуальных состояний" рассматривается пользовательский элемент FlipPanel, использующий состояния и переходы. Что собой представляют пользовательские элементы в WPF Хотя пользовательский элемент можно построить в любом проекте WPF, обычно такие элементы размещаются в специально выделенной сборке — библиотеке классов (DLL). Это позволяет разделять работу с множеством приложений WPF. Чтобы гарантировать наличие всех необходимых ссылок на сборки и импорт всех нужных пространств имен, при создании приложения в Visual Studio в качестве типа проекта следует выбрать Custom Control Library (WPF) (Библиотека пользовательских элементов управления (WPF)). Внутри библиотеки классов можно создавать сколько угодно элементов управления. 'Совет. Как при разработке любой библиотеки классов, часто стоит помещать как саму библиотеку классов, так и приложение, использующее ее, в одно и то же решение Visual Studio. Это позволит легко модифицировать и отлаживать обе части вместе. Первый шаг в создании пользовательского элемента управления — это выбор корректного базового класса для наследования. В табл. 18.1 перечислены некоторые часто применяемые классы для создания пользовательских элементов управления, а на рис. 18.1 показано их расположение в иерархии элементов.
510 Глава 18. Пользовательские элементы Таблица 18.1. Базовые классы для создания пользовательских элементов Имя Описание FrameworkElement Это — самый низкий уровень, с которым обычно приходится иметь дело при создании пользовательского элемента. Обычно такой подход выбирается только тогда, когда нужно нарисовать его содержимое "с нуля" посредством переопределения OnRenderO и использования System. Windows.Media.DrawingContext. Это похоже на подход, который был продемонстрирован в главе 14, где пользовательский интерфейс конструировался на основе объектов Visual. Класс FrameworkElement предоставляет лишь самый базовый набор свойств и событий для элементов, которые не предназначены для взаимодействия с пользователем Control Этот класс чаще всего служит начальной точкой при построении элемента управления "с нуля". Это — базовый класс для всех взаимодействующих с пользователем графических элементов управления. Класс Control добавляет свойства для установки фона и переднего плана, а также шрифта и выравнивания содержимого. Кроме того, этот класс помещает себя в последовательность обхода по клавише <ТаЬ> (свойством isTabStop) и получает уведомления о двойном щелчке (через события MouseDoubleClick и PreviewMouseDoubleClick). Но что более важно, так это то, что класс Control определяет свойство Template, позволяющее заменять его внешний вид с неограниченной гибкостью ContentControl Это — базовый класс для элементов управления, которые могут отображаться как единое целое с произвольным содержимым. Содержимое может быть элементом пользовательского объекта, применяемого в сочетании с шаблоном. (Содержимое устанавливается свойством Content, а необязательный шаблон может быть представлен в свойстве ContentTemplate.) Многие элементы управления упаковывают специфический, ограниченный тип содержимого (вроде строки текста в текстовом поле). Поскольку эти элементы управления не поддерживают всех элементов, они не должны определяться как элементы управления с содержимым UserControl Это элемент управления с содержимым, который может быть сконфигурирован с применением поверхности времени проектирования. Хотя такой пользовательский элемент управления не настолько отличается от обычного элемента управления с содержимым, обычно он используется тогда, когда необходимо быстро повторно применить неизменный блок пользовательского интерфейса в более чем одном окне (вместо создания действительно отдельного элемента управления, который может быть перенесен из одного приложения в другое) itemsControl — базовый класс для элементов управления, служащих оболочками для списков элементов, но не поддерживающих выбор позиций, в то время как Selector — более специализированный базовый класс для элементов, поддерживающих выбор. Эти классы нечасто применяются для создания пользовательских элементов управления, поскольку средства шаблонов данных ListBox, ListView и TreeView обеспечивают достаточную гибкость Panel Базовый класс для элементов управления, обладающих логикой компоновки. Элемент с компоновкой может содержать в себе множество дочерних элементов и размещать их в соответствии с определенной семантикой компоновки. Часто панели включают присоединенные свойства, которые могут быть установлены в дочерние элементы для того, чтобы конфигурировать их расположение ItemsControl или Selector
Глава 18. Пользовательские элементы 511 Окончание табл. 18.1 Имя Описание Decorator Это базовый класс для элементов, служащих оболочками для других элементов и обеспечивающих графический эффект или определенное средство. Двумя яркими примерами могут служить Border, который рисует линию вокруг элемента, и Viewbox, масштабирующий свое содержимое динамически с использованием трансформаций. Среди других декораций — классы Chrome, служащие для снабжения знакомыми рамками и фоном часто используемых элементов управления, таких как кнопка Специфический Если необходимо усовершенствовать существующий элемент управле- класс элемента ния, можно наследоваться непосредственно от класса этого элемента, управления Например, можно создать элемент TextBox со встроенной логикой проверки достоверности (что будет продемонстрировано далее в настоящей главе). Однако прежде чем предпринять такой шаг, подумайте, нельзя ли достичь той же цели с помощью кода обработки событий или отдельного компонента. Оба подхода позволят отделить логику от элемента управления и применять ее в других элементах DispatcherObject DependencyObject Visual. Условные обозначения Абстрактный класс Конкретный класс UlElement HZ FrameworkElement Decorator Рис. 18.1. Базовые классы простых элементов и элементов управления
512 Глава 18. Пользовательские элементы ■ CustomControlsClient Й □ На заметку! Хотя допускается построить специальный элемент, который не является элементом управления, большинство пользовательских элементов, создаваемых в WPF, будут именно элементами управления. Это значит, что они смогут принимать фокус, а также взаимодействовать с пользовательскими нажатиями клавиш и действиями мыши. По этой причине термины пользовательские элементы и пользовательские элементы управления при разработке WPF- приложений часто являются взаимозаменяемыми. В этой главе будут приведены следующие примера пользовательских элементов управления: не имеющий внешнего вида инструмент выбора цвета, унаследованный непосредственно от класса Control; специализированная панель компоновки; пользовательский элемент, унаследованный от FrameworkElement и переопределяющий OnRender (). Многие примеры будут довольно длинными. Хотя почти весь код рассматривается в этой главе, возможно, имеет смысл загрузить примеры и исследовать их самостоятельно. Построение базового пользовательского элемента управления Хороший способ начать разработку пользовательских элементов управления — попробовать создать самый простой элемент В этом разделе мы начнем с создания базового указателя цвета. Позднее будет показано, как усовершенствовать этот элемент до более развитого элемента на основе шаблонов. Создание указателя цвета достаточно просто. В Интернете доступно несколько примеров такого инструмента, в том числе один в комплекте .NET Framework SDK (http://code.msdn.microsoft, com/wpfsamples). Тем не менее, создание собственного инструмента для выбора цвета остается полезным упражнением. Оно не только позволяет продемонстрировать широкое разнообразие важных концепций построения элементов управления, но также предоставляет практичный кусок функциональности. Для начала можно создать специализированное диалоговое окно для выбора цвета, подобное тому, что включено в Windows Forms. Но если необходим указатель цвета, который можно было бы интегрировать в различные окна, то пользовательский элемент управления — намного лучший вариант. Наиболее простой тип специализированного элемента — пользовательский элемент управления, который позволяет собрать комбинацию элементов подобно тому, как это делается при проектировании окна или страницы. Поскольку указатель цвета должен выглядеть несколько сложнее, чем примитивная группа существующих элементов управления с дополнительной функциональностью, он представляется наилучшим выбором. Обычный указатель цвета позволяет пользователю выбирать цвет щелчком где-то на поле цветового градиента либо указанием индивидуальных составляющих красного, зеленого и синего цветов. На рис. 18.2 показан базовый указатель цвета, который будет создан в этом разделе (в верхней части окна). Он состоит из трех элементов управления Slider для настройки цветовых составляющих, а также элементом Rectangle для предварительного отображения выбранного цвета. The new color is #FF7BA455 Рис. 18.2. Пользовательский элемент управления — указатель цвета
Глава 18. Пользовательские элементы 513 На заметку! Подход на основе пользовательских элементов управления обладает одним существенным недостатком — он ограничивает возможности настройки внешнего вида указателя цвета с целью адаптации к разным окнам, приложениям и пользователям. К счастью, ненамного сложнее сделать следующий шаг к элементу управления на основе шаблона, о чем рассказывается чуть позже. Определение свойств зависимости Первый шаг в создании указателя цвета — добавление пользовательского элемента управления в проект библиотеки элементов управления. Когда это делается, Visual Studio создает файл разметки XAML и соответствующий специальный класс, чтобы определить в них инициализацию и код обработки событий. Это то же самое, что приходится делать при создании нового окна или страницы. Единственное отличие в том, что контейнером верхнего уровня выступает класс UserControl: public partial class ColorPicker : System.Windows.Controls.UserControl { ... } Далее легче всего начать с проектирования общедоступного интерфейса, который пользовательский элемент управления предоставит'внешнему миру. Другими словами, нужно создать свойства, методы и события, которые будут поступать в этот элемент управления, и на которые будет опираться приложение, использующее его для взаимодействия с указателем цвета. Наиболее фундаментальной деталью является свойство Color. В конце концов, указатель цвета — не что иное, как специализированный инструмент для отображения и выбора цветового значения. Чтобы поддержать такие средства WPF, как привязка данных, стили и анимация, доступные для записи свойства элемента управления почти всегда должны быть свойствами зависимости. Как известно из главы 4, первый шаг в создании свойства зависимости — это определение статического поля для него с добавленным словом Property в конце его имени: public static DependencyProperty ColorProperty; Свойство Color позволит коду, использующему этот элемент управления, программно устанавливать или извлекать значение цвета. Однако ползунки в указателе цвета также позволят пользователю модифицировать по одному аспекту текущего цвета. Для реализации такого проектного решения можно применить обработчики событий, реагирующие на изменение положений ползунка и соответствующим образом обновляющие значение свойства Color. Но проще будет присоединить ползунки к этому свойству с помощью привязки данных. Чтобы сделать это, придется определить каждую составляющую цвета в виде отдельного свойства зависимости: public static DependencyProperty RedProperty; public static DependencyProperty GreenProperty; public static DependencyProperty BlueProperty; Хотя свойство Color будет хранить объект System.Windows.Media.Color, свойства Red, Green и Blue будут хранить индивидуальные байтовые значения, представляющие каждый из трех компонентов цвета. (Можно также добавить ползунок и свойство для установки альфа-значения, что позволит создавать частично прозрачные цвета, но в данном примере это не делается.) Определение статических полей для свойств — лишь первый шаг. Также понадобится статический конструктор элемента управления, который зарегистрирует свойства, указывая имя свойства, тип данных и класс элемента управления, владеющий данным
514 Глава 18. Пользовательские элементы свойством. Как было показано в главе 6, это позволит воспользоваться некоторыми специфическими средствами свойств (вроде наследования значений) за счет передачи объекта FrameworkPropertyMetadata с правильно установленными флагами. Также в этот момент можно присоединить обратные вызовы для проверки достоверности, коррекции значений и уведомлений об изменении значения. В указателе цвета нужно сделать только одно — добавить обратные вызовы, которые будут реагировать на изменение различных свойств. Это объясняется тем, что свойства Red, Green и Blue — на самом деле просто другое представление свойства Color, и при изменении любого из этих трех следует обеспечить синхронизацию последнего. Ниже приведен код статического конструктора, регистрирующего четыре свойства зависимости для указателя цвета. static ColorPicker () { ColorProperty = DependencyProperty.Register ( "Color", typeof(Color), typeof(ColorPicker), new FrameworkPropertyMetadata(Colors.Black, new PropertyChangedCallback (OnColorChanged))); RedProperty = DependencyProperty.Register( "Red", typeof(byte), typeof(ColorPicker), new FrameworkPropertyMetadata ( new PropertyChangedCallback(OnColorRGBChanged))); GreenProperty = DependencyProperty.Register ( "Green", typeof(byte), typeof(ColorPicker), new FrameworkPropertyMetadata ( new PropertyChangedCallback(OnColorRGBChanged))); BlueProperty = DependencyProperty.Register( "Blue", typeof(byte), typeof(ColorPicker), new FrameworkPropertyMetadata ( new PropertyChangedCallback (OnColorRGBChanged))); } Теперь, определив свойства зависимости, можно добавить стандартные оболочки для свойств, которые облегчают доступ к ним и обеспечивают возможность обращения из XAML-разметки: public Color Color get { return (Color)GetValue(ColorProperty); } set { SetValue(ColorProperty, value); } public byte Red get { return (byte)GetValue(RedProperty); } set { SetValue(RedProperty, value); } public byte Green get { return (byte)GetValue(GreenProperty); } set { SetValue(GreenProperty, value); } public byte Blue get { return (byte)GetValue(BlueProperty); } set { SetValue(BlueProperty, value); }
Глава 18. Пользовательские элементы 515 Вспомните, что оболочки свойств не должны содержать никакой логики, поскольку свойства могут устанавливаться и извлекаться непосредственно с помощью методов SetValueO и GetValueO базового класса DependencyObject. Например, логика синхронизации свойств в данном примере реализуется с использованием обратных вызовов, которые инициируются при изменении свойств через их оболочки, либо при прямом вызове SetValueO. Обратные вызовы изменения свойств отвечают за сохранение соответствия свойства Color текущим значениям Red, Green и Blue. Всякий раз, когда изменяется свойство Red, Green или Blue, свойство Color тоже соответствующим образом модифицируется: private static void OnColorRGBChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) { ColorPicker colorPicker = (ColorPicker)sender; Color color = colorPicker.Color; if (e.Property == RedProperty) color.R= (byte)e.NewValue; else if (e.Property == GreenProperty) color.G= (byte)e.NewValue; else if (e.Property == BlueProperty) color.В = (byte)e.NewValue; colorPicker.Color = color; } В случае установки свойства Color свойства Red, Green и Blue также обновляются: private static void OnColorChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e) { Color newColor = (Color)e.NewValue; ColorPicker colorPicker = (ColorPicker)sender; colorPicker.Red = newColor.R; colorPicker.Green = newColor.G; colorPicker.Blue = newColor.B; } Хотя на первый взгляд может показаться, что такой код инициирует бесконечную последовательность вызовов, когда каждое свойство будет изменять другое, на самом деле подобного не происходит. Это объясняется тем, что WPF не допускает повторного вхождения при обратных вызовах изменения свойств. Например, при изменении свойства Color инициируется метод OnClolorChanged(). Он модифицирует свойства Red, Green и Blue, генерируя три раза обратный вызов OnColorRGBChanged() (по одному для каждого из свойств). Однако OnColorRGBChanged() не вызовет еще раз OnClolorChanged(). Совет. Может случиться так, что для обработки свойств цвета будут применены принудительные обратные вызовы, о которых говорилось в главе 4. Однако такой подход нецелесообразен. Принудительные обратные вызовы свойств предназначены для взаимосвязанных свойств, которые могут переопределять или влиять друг на друга. Они не имеют смысла для свойств, представляющих одни и те же данные разными способами. Если вы примените принудительные свойства в данном примере, то станет возможно устанавливать разные значения свойств Red, Green и Blue, тем самым переопределяя цветовую информацию свойства Color. Поведение, которое в действительности нужно, заключается в установке свойств Red, Green и Blue и применении этой информации для постоянного изменения значения свойства Color.
516 Глава 18. Пользовательские элементы Определение маршрутизируемых событий Также может понадобиться добавить маршрутизируемые события, которые позволяют уведомлять потребителя элемента управления о том, что что-то произошло. В примере с указателем цвета удобно иметь событие, возбуждаемое при изменении цвета. Хотя это событие может быть определено как обычное событие .NET, применение маршрутизируемого события позволит организовать пузырьковое распространение и туннелирование, так что события смогут обрабатывать более высокоуровневые родители вроде содержащего элемент окна. Как и в случае свойств зависимости, первый шаг в определении маршрутизируемого события — это создание статического свойства для него, со словом Event, добавленным в конец имени: public static readonly RoutedEvent ColorChangedEvent; Затем можно зарегистрировать это событие в статическом конструкторе. При этом указывается имя события, стратегия маршрутизации, сигнатура и класс-владелец: ColorChangedEvent = EventManager.RegisterRoutedEvent ( "СоlorChanged", RoutingStrategy.Bubble, typeof(RoutedPropertyChangedEventHandler<Color>), typeof(ColorPicker)); Вместо того, чтобы разрабатывать новый делегат для сигнатуры события, иногда можно воспользоваться существующими делегатами. Два полезных делегата, которые могут пригодиться в этом случае — это RoutedEventHandler (для маршрутизируемых событий, не передающих никакой дополнительной информации) и RoutedPropertyChangedEventHandler (для маршрутизируемых событий, передающих старое и новое значения после изменения свойства). RoutedPropertyChangedEventHandler, используемый в предыдущем примере — это обобщенный делегат, параметризованный типом. В результате его можно применять с любыми данными свойств, не нарушая безопасности типов. Определив и зарегистрировав событие, понадобится создать стандартную оболочку для события .NET, которая примет событие. Эта оболочка события может быть использована для присоединения (и удаления) слушателей события: public event RoutedPropertyChangedEventHandler<Color> ColorChanged { add { AddHandler(ColorChangedEvent, value); } remove { RemoveHandler(ColorChangedEvent, value); } } Последняя деталь — код, возбуждающий событие в соответствующий момент. Этот код должен вызывать метод RaiseEventO, унаследованный от класса DependencyObject. В примере с указателем цвета нужно просто добавить следующие строки кода в конец метода OnColorChanged(): Color oldColor = (Color)e.OldValue; RoutedPropertyChangedEventArgs<Color> args = new RoutedPropertyChangedEventArgs<Color>(oldColor, newColor); args.RoutedEvent = ColorPicker.ColorChangedEvent; colorPicker.RaiseEvent(args); Вспомните, что обратный вызов OnColorChangedO инициируется при любой модификации свойства Color — будь то непосредственно или же при изменении компонентов цвета Red, Green и Blue.
Глава 18. Пользовательские элементы 517 Добавление кода разметки Теперь, когда общедоступный интерфейс пользовательского элемента управления определен, все, что осталось сделать — это написать код разметки, определяющий его внешний вид. В данном случае достаточно будет использовать базовый Grid, чтобы разместить в нем три элемента управления Slider и элемент Rectangle для предварительного просмотра цвета. Трюк состоит в выражении привязки данных, которое связывает эти элементы управления с соответствующими свойствами, без необходимости в написании кода обработки событий. В конце концов, в указателе цвета будут работать четыре выражения привязки данных. Три ползунка привязаны к свойствам Red, Green и Blue, разрешая их изменение в диапазоне от 0 до 255 (допустимые значения для байта). Свойство Rectangle.Fill устанавливается в SolidColorBrush, а свойство Color этой кисти привязано к свойству Color пользовательского элемента управления. Ниже приведен полный код разметки. <UserControl x:Class="CustomControls.ColorPicker" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Name="colorPicker"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"x/RowDefinition> <RowDefmition Height="Auto"x/RowDefinition> <RowDefmition Height="Auto"x/RowDefinition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinitionx/ColumnDef inition> <ColumnDefinition Width="Auto"x/ColumnDefinition> </Grid.ColumnDefinitions> <Slider Name="sliderRed" Minimum=" Maximum=55" Value=" {Binding ElementName=colorPicker, Path=Red} "x/Slider> <Slider Grid.Row="l" Name="sliderGreen" Minimum=" Maximum=55" Value=" {Binding ElementName=colorPicker, Path=Green} "x/Slider> <Slider Grid.Row=" Name="sliderBlue" Minimum=" Maximum=55" Value="{Binding ElementName=colorPicker,Path=Blue}"></Slider> <Rectangle Grid.Column="l" Grid.RowSpan=" Width=0" Stroke="Black" StrokeThickness="l"> <Rectangle.Fill> <SolidColorBrush Color="{Binding ElementName=colorPicker,Path=Color}"> </SolidColorBrush> </Rectangle.Fill> </Rectangle> </Grid> </UserControl> Код разметки для пользовательского элемента управления играет ту же роль, что и шаблон элемента управления для элемента, не имеющего внешнего вида. Если хотите сделать некоторые детали кода разметки конфигурируемыми, можете воспользоваться выражениями привязки, чтобы связать их с другими свойствами элемента управления. Например, в приведенном коде ширина элемента Rectangle жестко закодирована и составляет 50 единиц. Однако можно заменить его выражением привязки данных, которое будет устанавливать значение ширины прямоугольника по свойству зависимости пользовательского элемента управления. Таким образом, пользователь элемента сможет модифицировать это свойство, выбирая другую ширину. Аналогично можно поступить с цветом рамки и переменной ее толщины. Однако чтобы достичь настоящей гибкости
518 Глава 18. Пользовательские элементы элемента управления, лучше создать элемент, лишенный внешнего вида, и определить разметку в форме шаблона, как будет описано далее в настоящей главе. Иногда может быть отдано предпочтение выражению привязки для изменения одного из центральных свойств, которые уже определены в элементе управления. Например, класс UserControl использует свое свойство Padding для добавления отступа между внешней гранью и заданным внутренним содержимым. (Эта деталь реализуется через шаблон элемента управления для UserControl.) Однако также потребуется применить свойство Padding для установки отступов вокруг каждого ползунка: <Slider Name="sliderRed" Minimum=" Maximum=55" Margin=" {Binding ElementName=colorPicker,Path=Padding} " Value="{Binding ElementName=colorPicker,Path=Red}"></Slider> Аналогично можно получить настройки рамки для Rectangle из свойств BorderThickness и BorderBrush класса UserControl. Опять-таки, это сокращение, которое может быть оправдано при создании простых элементов управления, но которое можно усовершенствовать добавлением дополнительных свойств (например, SliderMargin, PreviewBorderBrush и PreviewBorderThickness) или созданием полноценного элемента управления, основанного на шаблоне. Именование пользовательских элементов управления В приведенном примере элементу UserControl верхнего уровня назначено имя (colorPicker). Это позволяет писать простые выражения привязки данных, которые связывают свойства в классе пользовательского элемента управления. Однако такой прием вызывает очевидный вопрос, а именно: что происходит, когда в окне (или на странице) создается экземпляр пользовательского элемента управления, которому назначается новое имя? К счастью, никаких ошибок при этом не возникает, поскольку пользовательский элемент управления выполняет инициализацию до того, как это сделает содержащее его окно. Сначала инициализируется пользовательский элемент управления, и подключаются его привязки данных. Затем инициализируется окно, и имя, указанное в разметке окна назначается пользовательскому элементу управления. Выражения привязки данных и обработчики событий в окне теперь могут применять определенное в окне имя для доступа к пользовательскому элементу управления, и все работает так, как ожидалось. Хотя это решение достаточно очевидно, можно заметить пару причуд, если используется код, проверяющий значение свойства UserControl.Name напрямую. Например, если проверить свойство Name в обработчике событий пользовательского элемента управления, то обнаружится, что в нем хранится имя, примеренное окном. Если не устанавливать имя в разметке окна, то пользовательский элемент управления сохранит первоначальное имя из своей разметки. Это же имя можно будет видеть при проверке свойства Name в коде окна. Ни одна из этих странностей не является проблемой, но лучший подход заключается в том, чтобы избегать именования пользовательского элемента управления в коде его разметки и применять свойство Binding.RelativeSource для поиска в дереве элементов до тех пор, пока не будет найден родитель UserControl. Ниже показан расширенный синтаксис, который делает это: <Slider Name="sliderRed" Minimum=" Maximum=55n Value="{Binding Path=Red, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type UserControl}} }"> </Slider> Этот же подход будет применяться при построении пользовательского элемента управления на основе шаблона в разделе "Рефакторинг кода разметки указателя цвета".
Глава 18. Пользовательские элементы 519 Использование элемента управления Когда создание элемента управления завершено, использовать его очень легко. Чтобы применить указатель цвета в другом окне, понадобится отобразить сборку и пространство имен .NET на пространство имен XML, как показано ниже: <Window x:Class="CustomControlsClient.ColorPickerUserControlTest" xmlns:lib="clr-namespace:CustomControls;assembly=CustomControls" ... > Используя определенное ранее пространство имен XML и имя класса пользовательского элемента управления, можно создать элемент точно так же, как в коде разметки создаются объекты любого другого типа. Можно также устанавливать его свойства и присоединять обработчики событий непосредственно в дескрипторе элемента управления: <lib:ColorPiekerUserControl Name="соlorPicker" Color="Beige" ColorChanged=" color Pi с ker_Co lor Changed ">< /lib -.Color Pi ckerUserControl> Поскольку свойство Color использует тип данных Color, а тип Color декорирован атрибутом TypeConverter, WPF знает, как использовать ColorConverter для превращения строкового наименования цвета в соответствующий объект Color перед установкой его в свойство Color. Код, обрабатывающий событие ColorChanged, достаточно прост: private void colorPicker_ColorChanged(object sender, RoutedPropertyChangedEventArgs<Color> e) { lblColor.Text = "The new color is " + e.NewValue.ToString (); } На этом создание пользовательского элемента управления завершено. Однако для полноты картины стоит добавить еще один штрих. В следующем разделе мы займемся расширением указателя цвета для поддержки команд WPF. Поддержка команд Многие элементы управления обладают встроенной поддержкой команд. Добавить такую поддержку к разрабатываемому элементу управления можно двумя способами. • Добавить привязки команд, которые свяжут элемент управления с определенными командами. В результате элемент сможет реагировать на команды без необходимости в дополнительном кодировании. • Создать новый объект RoutedUICommand для команды как статическое поле элемента управления, а затем добавить привязку к этой команде. Это позволит элементу управления автоматически поддерживать команды, еще не определенные в базовом наборе классов команд, о которых речь шла в главе 9. В следующем примере используется первый подход для добавления поддержки команды ApplicationCommands.Undo. Совет. За подробными сведениями о командах и создании собственных объектов RoutedUICommand обращайтесь в главу 9. Чтобы поддержать средство Undo в указателе цвета, необходимо отслеживать предыдущий цвет в поле-члене класса: private Color? previousColor; Тот факт, что это поле допускает пи 11-значения, имеет смысл, поскольку при первом создании элемента управления никакого предыдущего цвета еще не установлено.
520 Глава 18. Пользовательские элементы (Можно также программно очищать значение предыдущего цвета после действий, которые должны быть необратимыми.) Когда цвет изменяется, необходимо запомнить старое значение. Это можно сделать, добавив следующую строку в конец метода OnColorChangedO: colorPicker.previousColor = (Color)e.OldValue; Теперь имеется инфраструктура, необходимая для поддержки команды Undo. Все, что остается — это создать привязку команды, которая подключит элемент управления к команде и обработает события CanExecute и Executed. Лучшее место для создания привязки команды — момент первоначального создания элемента управления. Например, в следующем коде для добавления привязки команды ApplicationCommands.Undo используется конструктор указателя цвета: public ColorPicker () { InitializeComponent(); SetUpCommands (); } private void SetUpCommands () { // Установить привязки команд. CommandBinding binding = new CommandBinding(ApplicationCommands.Undo, UndoCommand_Executed, UndoCommand_CanExecute); this.CommandBindings.Add(binding); } Чтобы сделать команду функциональной, понадобится обработать событие CanExecute и разрешить команду, если имеется предшествующее значение: private void UndoCommand_CanExecute (object sender, CanExecuteRoutedEventArgs e) { e.CanExecute = previousColor.HasValue; } И, наконец, когда команда выполняется, можно заменить цвет предыдущим значением: private void UndoCommand_Executed (object sender, ExecutedRoutedEventArgs e) { this.Color = (Color)previousColor; } Команда Undo инициируется двумя другими способами. Можно применить стандартную клавиатурную привязку <Ctrl+Z>, когда соответствующий элемент в пользовательском элементе управления имеет фокус, или же добавить кнопку в клиентскую панель, которая будет инициировать команду, как показано ниже: <Button Command=,,Undo" CommandTarget="{Binding ElementName=colorPicker}"> Undo </Button> В любом случае текущее значение цвета отбрасывается и применяется предыдущее. Совет. В данном примере сохраняется только один уровень информации отмены. Однако легко создать стек отмены, который будет хранить последовательность старых значений. Для этого понадобится лишь сохранять значения Color в коллекции соответствующего типа. Коллекция Stack<T> из пространства имен System.Collections .Generic — подходящий выбор, поскольку реализует алгоритм "последний вошел — первый вышел", который как раз подходит для получения наиболее свежего предыдущего объекта Color при выполнении операции отмены.
Глава 18. Пользовательские элементы 521 Надежные команды Описанный выше прием представляет собой совершенно законный способ подключения команд к элементам управления, но элементы WPF и профессиональные элементы управления его не используют. Они применяют более надежный подход, присоединяя статические обработчики команд с помощью метода CommandManager. RegisterClassCommandBinding(). Основная проблема, связанная с реализацией, продемонстрированной в предыдущем разделе, заключается в том, что она использует общедоступную коллекцию CommandBindings. Это делает ее несколько хрупкой, поскольку клиент может легко модифицировать коллекцию CommandBindings. Подобное невозможно в случае применения метода RegisterClassCommandBinding(). И именно такой подход используют элементы управления WPF. Например, если посмотреть на коллекцию CommandBindings из TextBox, то в ней не будет никаких привязок для жестко закодированных команд Undo, Redo, Cut, Copy и Paste, поскольку они зарегистрированы как привязки классов. Техника довольно проста. Вместо создания привязок команд в конструкторе экземпляра, их понадобится создавать в статическом конструкторе с помощью примерно такого кода: CommandManager.RegisterClassCommandBinding(typeof(ColorPicker), new CommandBinding(ApplicationCommands.Undo, UndoCommand_Executed, UndoCommand_CanExecute)); Хотя этот код изменен незначительно, все же он демонстрирует существенный сдвиг. Поскольку ссылки на методы UndoCommand_Executed() и UndoCommand_CanExecute() присутствуют в конструкторе, оба они должны быть статическими. Чтобы извлечь данные экземпляра (такие как информацию о текущем и предшествующем цветах), нужно привести отправителя события к объекту ColorPicker и использовать его. Ниже приведен пересмотренный код обработки команд: private static void UndoCommand_CanExecute(object sender, CanExecuteRoutedEventArgs e) { ColorPicker ColorPicker = (ColorPicker)sender; e.CanExecute = ColorPicker.previousColor.HasValue; } private static void UndoCommand_Executed (object sender, ExecutedRoutedEventArgs e) { ColorPicker ColorPicker = (ColorPicker)sender; Color currentColor = ColorPicker.Color; ColorPicker.Color = (Color)ColorPicker.previousColor; } Кстати, этот подход не ограничивается командами. Если хотите привязать логику обработки событий к элементу управления, можете воспользоваться обработчиком событий класса с методом EventManager.RegisterClassHandler (). Обработчики событий класса всегда вызываются перед обработчиками событий экземпляра, позволяя легко подавлять события. Пристальный взгляд на UserControl Пользовательские элементы предлагают довольно-таки безболезненный, но в некотором отношении ограниченный способ создания специальных элементов управления. Чтобы понять — почему, давайте присмотримся к тому, как функционирует UserControl.
522 Глава 18. Пользовательские элементы "За кулисами" класс UserControl работает во многом подобно классу ContentControl, от которого он унаследован. В действительности ключевых отличий немного. • Класс UserControl изменяет некоторые значения по умолчанию. А именно: устанавливает IsTabStop и Focus able в false (так что он не занимает отдельного места в последовательности обхода по клавише <ТаЬ>), а также устанавливает HorizontalAlignment и VerticalAlignment в Stretch (вместо Left и Тор), в результате заполняя все доступное пространство. • Класс UserControl применяет новый шаблон элемента управления, состоящий из элемента Border, который упаковывает ContentPresenter. Элемент ContentPresenter хранит в себе содержимое, которое добавляется посредством кода разметки. • Класс UserControl изменяет источник маршрутизированных событий. Когда событие распространяется пузырьком или туннелируется от элемента управления, находящегося внутри пользовательского элемента, к элементу, находящемуся вне его, источник изменяется и указывает на пользовательский элемент управления вместо первоначального элемента. Это немного повышает степень инкапсуляции. (Например, при обработке события UIElement.MouseLeftButtonDown в контейнере компоновки, содержащем в себе созданный ранее указатель цвета, будет получено событие, когда выполняется щелчок кнопкой мыши внутри Rectangle. Однако источником этого события будет не Rectangle, а объект ColorPicker, содержащий этот Rectangle. Если создать тот же самый указатель цвета как обычный элемент с содержимым, то этого не будет — в данном случае на вас возлагается обязанность перехватывать событие в элементе управления, обрабатывать его и возбуждать повторно.) Наиболее существенное отличие пользовательских элементов от других типов специальных элементов управления заключается в способе их проектирования. Как и все элементы управления, пользовательские элементы имеют шаблон. Однако этот шаблон изменяется редко. Вместо этого создается код разметки как часть класса специального пользовательского элемента управления, и этот код разметки обрабатывается с применением метода InitializeComponentO при создании элемента. С другой стороны, элемент управления, не имеющий внешнего вида, не имеет и разметки. Все, что ему нужно — это шаблон. Обычный ContentControl имеет следующий упрощенный шаблон: <ControlTemplate TargetType="ContentControl"> <ContentPresenter ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}" Content="{TemplateBinding ContentControl.Content}" /> </ControlTemplate> Этот шаблон всего лишь наполняет полученным содержимым и применяет необязательный шаблон содержимого. Свойства вроде Padding, Background, HorizontalAlignment и VerticalAlignment не дают никакого эффекта, если только явно не привязать их. UserControl имеет похожий шаблон, но с несколькими дополнительными тонкостями. Наиболее очевидно то, что он добавляет элемент Border и привязывает его свойства к свойствам BorderBrush, BorderThickness, Background и Padding пользовательского элемента управления, чтобы они что-нибудь делали. Вдобавок ContentPresenter внутри привязывается к свойствам выравнивания. <ControlTemplate TargetType="UserControl"> <Border BorderBrush="{TemplateBinding Border.BorderBrush}"
Глава 18. Пользовательские элементы 523 BorderThickness="{TemplateBinding Border.BorderThickness}" Background="{TemplateBinding Panel.Background}" SnapsToDevicePixels="True" Padding="{TemplateBinding Control.Padding}"> <ContentPresenter HorizontalAlignment="{TemplateBinding Control.HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding Control.VerticalContentAlignment}" SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels } " ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}" Content="{TemplateBinding ContentControl.Content}" /> </Border> </ControlTemplate> Формально шаблон пользовательского элемента управления может быть изменен. В действительности можно переместить весь код разметки в шаблон, лишь слегка видоизменив ее. Но на самом деле делать это незачем: если необходим более гибкий элемент управления, в котором внешний вид отделен от интерфейса, предоставляемого классом этого элемента, то лучше создать собственный элемент управления, лишенный внешнего вида, как описано в следующем разделе. Создание элемента управления, лишенного внешнего вида Цель пользовательских элементов управления заключается в предоставлении поверхности визуального проектирования, дополненной шаблоном, что облегчает задачу определения элемента управления в обмен на будущую гибкость. В ситуации, когда функциональность пользовательского элемента управления вполне удовлетворяет, но нужно подстроить его визуальное представление, возникает проблема. Например, предположим, что необходимо использовать тот же самый указатель цвета, но предоставить ему другую обложку, которая больше соответствует существующему окну приложения. Некоторые аспекты пользовательского элемента управления удастся изменить через стили, но часть из них заблокирована внутри, будучи жестко закодированной в разметке. Например, нет никакой возможности переместить прямоугольник предварительного просмотра на левую сторону от ползунков. Решение этой проблемы состоит в создании элемента управления без внешнего вида, т.е. элемента управления на основе одного из базовых классов, не имеющих поверхности визуального проектирования. Такой элемент управления помещает свой код разметки в шаблон по умолчанию, который можно заменить, не затрагивая логики элемента управления. Рефакторинг кода указателя цвета Превратить рассмотренный выше указатель цвета в элемент управления без внешнего вида не так трудно. Первый шаг прост — нужно всего лишь изменить объявление класса, как показано ниже: public class ColorPicker : System.Windows.Controls.Control { ... } В этом примере класс ColorPicker наследуется от Control. Класс FrameworkElement не подходит, поскольку указатель цвета требует взаимодействия с пользователем, а другие высокоуровневые классы не могут точно описать его поведение. Например, указатель цвета не позволяет вставлять в него другое содержимое, а потому ContentControl тоже не годится.
524 Глава 18. Пользовательские элементы Код внутри класса ColorPicker точно такой же, как код пользовательского элемента управления (за исключением того факта, что из конструктора понадобится удалить вызов InitializeComponent ()). Вы следуете тому же подходу для определения свойств зависимости и маршрутизируемых событий. Единственное отличие связано с необходимостью сообщения WPF о том, что для класса элемента управления будет предоставлен новый стиль. Этот стиль будет обеспечен новым шаблоном элемента. (Если пропустить этот шаг, будет использован шаблон, определенный в базовом классе.) Чтобы сообщить WPF о том, что предоставляется новый стиль, следует вызвать метод OverrideMetadataO в статическом конструкторе класса. Этот метод вызывается на свойстве DefaultStyleKeyProperty, которое является свойством зависимости, определяющим стиль по умолчанию для элемента управления. Необходимый код выглядит так: DefaultStyleKeyProperty.OverrideMetadata(typeof(ColorPicker), new FrameworkPropertyMetadata(typeof(ColorPicker))); Можно предоставить другой тип, если нужно использовать шаблон другого класса элемента управления, но почти всегда для каждого из собственных пользовательских элементов управления будет создаваться специфический стиль. Рефакторинг кода разметки указателя цвета После добавления вызова OverrideMetadata понадобится просто подключить правильный стиль. Этот стиль должен быть помещен в словарь ресурсов по имени generic, xaml, который следует сохранить в папке Themes проекта. Таким образом, этот стиль будет распознан как стиль по умолчанию для элемента управления. Для добавления файла generic.xaml выполните следующие шаги. 1. Щелкните правой кнопкой мыши на проекте библиотеки классов в окне Solution Explorer и выберите в контекстном меню пункт Add^New Folder (Добавить1^ Новая папка). 2. Назовите новую папку Themes (темы). 3. Щелкните правой кнопкой мыши на папке Themes и выберите в контекстном меню пункт Add^New Item (Добавить1^ Новый элемент). 4. В диалоговом окне Add New Item (Добавление нового элемента) выберите вариант XML file template (Шаблон в виде файла XML), введите имя generic.xaml и щелкните на кнопке Add (Добавить). На рис. 18.3 можно видеть файл generic. xaml в папке Themes. Часто библиотека пользовательских элементов управления содержит несколько таких элементов. Чтобы держать их стили отдельно для облегчения редактирования, файл generic.xaml часто использует слияние словарей ресурсов. В следующей разметке показано содержимое файла generic.xaml, который извлекает ресурсы из ресурсного словаря ColorPicker.xaml в той же папке Themes библиотеки элементов управления по имени CustomControls. 1 Solution Explorer » П X 1 1 а и I 1^3 Solution 'CustomControls' B projects) 1 ^ C CustomControls ^ Properties t> OSi References d {& themes ;♦; Classicxaml § ColorPickerjtaml ■«■ FlipPanel.xaml jenericxaml <3fl ColorPicker.es > Q CotorPickerUserControljcaml <jjj CustomDrawnDecorator.es <2(J CustomDrawnElement.es <£] FlipPanel.es <jfl MaskedTextBox.cs <£) WrapBreakPanel.es 1 г> ^)S CustomControtsCKent Рис. 18.3. Приложение WPF и библиотека классов
Глава 18. Пользовательские элементы 525 <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="/CustomControls;component/themes/ColorPicker.xaml"> </ResourceDictionary> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> Стиль пользовательского элемента управления должен использовать атрибут TargetType для автоматического присоединения себя к указателю цвета. Ниже приведена базовая структура разметки, которая находится в файле ColorPicker.xaml: <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:CustomControls"> <Style TargetType="{x:Type local:ColorPicker}"> </Style> </ResourceDictionary> Стиль можно использовать для установки любых свойств в классе элемента управления (независимо от того, наследуются они от базового класса или добавлены вами). Однако наиболее полезная задача, которую выполняет стиль — это применение нового шаблона, определяющего визуальное представление по умолчанию для элемента управления. Преобразовать обычный код разметки (подобный тому, что используется для нашего указателя цвета) в шаблон элемента управления очень легко. Необходимо принять во внимание описанные ниже соображения. • При создании выражений привязки, которые связываются со свойствами родительского класса элемента управления, нельзя использовать свойство ElementName. Вместо него нужно применять свойство RelativeSource для указания того, что необходимо привязаться к родительскому элементу управления. Если все, что необходимо — это однонаправленная привязка, обычно можно использовать облегченное расширение разметки TemplateBinding вместо полноценного Binding. • Присоединять обработчики событий в шаблоне элемента управления не допускается. Вместо этого потребуется назначить элементам узнаваемые имена и присоединять к ним обработчики событий программно в конструкторе элемента управления. • Не именуйте элемент в шаблоне элемента управления, если только не хотите присоединить обработчик событий для программного взаимодействия с ним. При необходимости именования элемента называйте его в стиле PART_ИмяЭлемента. Принимая во внимание перечисленные соображения, можно создать следующий шаблон для указателя цвета. Наиболее важные изменения выделены полужирным. <Style TargetType=" {x:Type local .-ColorPicker} "> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:ColorPicker}"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"x/RowDefinition> <RowDefmition Height="Auto"X/RowDefinition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions>
526 Глава 18. Пользовательские элементы <Grid.ColumnDefinitions> <ColumnDef initionX/ColumnDef inition> <ColumnDefinition Width="Auto"></ColumnDefinition> </Grid.ColumnDefinitions> <Slider Minimum=" Maximum=55" Margin="{ TemplateBinding Padding}" Value="{Binding Path=Red, RelativeSource={RelativeSource TemplatedParent}}"> </Slider> <Slider Grid.Row="l" Minimum=" Maximum=55" Margin="{ TemplateBinding Padding}" Value="{Binding Path=Red, Re1ativeSource={RelativeSource TemplatedParent}}"> </Slider> <Slider Grid.Row=" Minimum=" Maximum=55" Margin="{ TemplateBinding Padding}" Value="{Binding Path=Red, RelativeSource={RelativeSource TemplatedParent}}"> </Slider> <Rectangle Grid.Column="l" Grid.RowSpan=" Margin="{ TemplateBinding Padding}" Width=0" Stroke="Black" StrokeThickness="l"> <Rectangle.Fill> <SolidColorBrush Color="{Binding Path=Color, RelativeSource={RelativeSource TemplatedParent}}"> </SolidColorBrush> </Rectangle.Fill> </Rectangle> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style> Как видите, некоторые выражения привязки заменены расширением TemplateBinding. Другие по-прежнему используют расширение Binding, но имеют свойство RelativeSource, указывающее на родителя шаблона (пользовательский элемент управления). Хотя и TemplateBinding, и Binding с RelativeSource из TemplatedParent служат одной и той же цели — извлечению данных из свойств пользовательского элемента управления, все же облегченный TemplateBinding более предпочтителен. Однако он не будет работать, если нужна двунаправленная привязка (как в случае ползунков) или когда осуществляется привязка к свойству класса, унаследованного от Freezable (вроде SolidColorBrush). Оптимизация шаблона элемента управления В нынешнем виде шаблон элемента управления — указателя цвета отвечает всем нуждам, и его можно использовать точно так же, как пользовательский элемент того же назначения. Однако еще остается возможность упрощения этого шаблона за счет удаления некоторых деталей. В данный момент всякий потребитель элемента управления, который пожелает применить специализированный шаблон, будет вынужден возиться с добавлением выражений привязки, чтобы обеспечить продолжение его работы. Это не трудно, но утомительно. Альтернатива состоит в конфигурировании всех выражений привязки в коде инициализации самого элемента управления. В этом случае шаблону не нужно будет указывать эти детали.
Глава 18. Пользовательские элементы 527 На заметку! Это тот же прием, который использовался, когда обработчики событий присоединялись к элементам, составляющим пользовательский элемент управления. Каждый обработчик событий добавлялся программно, вместо того чтобы применять атрибуты событий в шаблоне. Добавление имен частей Чтобы система заработала, код должен иметь возможность находить нужные ему элементы. Элементы управления WPF находят требуемые элементы по имени. В результате имена составляющих элементов становятся частью общедоступного интерфейса разрабатываемого элемента управления, а потому должны быть достаточно осмысленными. По существующему соглашению эти имена начинаются с конструкции PART_, за которой следует собственно имя элемента. В этом имени используются начальные заглавные буквы — как в именах свойств. PARTRedSlider — хороший выбор для именования элемента — ползунка красной составляющей цвета, в то время как PARTsldRed, PARTredSlider и RedSLider — неудачные варианты. Например, ниже показано, как можно подготовить три ползунка для программной привязки, удалив выражения привязки из свойства Value и добавив имя PART_: <Slider Name="PART_RedSlider" Minimum=" Maximum=55" Margin="{TemplateBinding Padding}"></Slider> <Slider Grid.Row=" Name="PART_GreenSlider" Minimum=" Maximum=55" Margin="{TemplateBinding Padding}"></Slider> <Slider Grid.Row=" Name="PART_BlueSlider" Minimum=" Maximum=55" Margin="{TemplateBinding Padding}"></Slider> Обратите внимание, что свойство Margin по-прежнему использует выражение привязки для добавления отступа, но это — несущественная деталь, которую можно опустить в пользовательском шаблоне (и заменить жестким кодированием отступа либо использованием другой компоновки). Чтобы обеспечить максимальную гибкость, элементу Rectangle имя не дается. Вместо этого имя назначается находящейся внутри кисти SolidColorBrush. Таким образом, средство предварительного просмотра цвета может применяться с любой фигурой или произвольным элементом — в зависимости от используемого шаблона. <Rectangle Grid.Column="l" Grid.RowSpan=" Margin="{TemplateBinding Padding}" Width=0" Stroke="Black" StrokeThickness="l"> <Rectangle.Fill> <SolidColorBrush x:Name="PART_PreviewBrush"></SolidColorBrush> </Rectangle.Fill> </Rectangle> Манипулирование частями шаблона Подключить выражения привязки можно было бы при инициализации элемента управления, но существует более удачный подход. В WPF есть выделенный метод OnApplyTemplateO, который должен быть переопределен, если необходимо осуществлять поиск элемента в шаблоне и присоединять обработчики событий или выражения привязки. В этом методе для нахождения нужного элемента применяется метод GetTemplateChildO (унаследованный от FrameworkElement). Если элемент для работы не найден, рекомендованный подход заключается в том, чтобы не делать ничего. Дополнительно можете добавить код, проверяющий, что элемент, если он присутствует, относится к корректному типу, и генерирующий исключение, если это не так. (Идея в том, что отсутствие элемента говорит о сознательном
528 Глава 18. Пользовательские элементы исключении определенного средства, в то время как некорректный тип элемента представляет ошибку.) Ниже показано, как подключить выражение привязки данных к отдельному ползунку в методе OnApplyTemplate(): public override void OnApplyTemplate () { base.OnApplyTemplate (); RangeBase slider = GetTemplateChild("PART_RedSlider") as RangeBase; if (slider '= null) { // Привязаться к свойству Red элемента управления //с использованием двунаправленной привязки. Binding binding = new Binding("Red"); binding.Source = this; binding.Mode = BindingMode.TwoWay; slider.SetBinding(RangeBase.ValueProperty, binding); } } Обратите внимание, что этот код использует класс System.Windows.Controls. Primitives.RangeBase (от которого унаследован Slider) вместо класса Slider. Причина в том, что класс RangeBase обеспечивает минимум требуемой функциональности, в данном случае — свойство Value. Сделав код насколько возможно обобщенным, потребителю элемента управления обеспечивается большая свобода. Например, благодаря этому, можно применить пользовательский шаблон, который вместо специальных ползунков использует другой элемент, унаследованный от RangeBase. Код привязки двух других ползунков почти идентичен. Код привязки Sol idColorBrush слегка отличается, поскольку SolidColorBrush не включает метода SetBindingO (который определен в классе FrameworkElement). Простой обходной путь состоит в создании выражения однонаправленной привязки свойства ColorPicker.Color. Таким образом, когда выбранный цвет в указателе цвета изменяется, кисть обновляется автоматически. SolidColorBrush brush = GetTemplateChild("PART_PreviewBrush") as SolidColorBrush; if (brush != null) { Binding binding = new Binding ("Color"); binding.Source = brush; binding.Mode = BindingMode.OneWayToSource; this.SetBinding(ColorPicker.ColorProperty, binding); } Чтобы увидеть преимущества внесенных в проектное решение изменений, понадобится создать элемент управления, использующий указатель цвета, но задать для него новый шаблон. На рис. 18.4 показан один из возможных вариантов. Документирование частей шаблона Остался еще последний штрих, который должен быть добавлен к предыдущему примеру. В руководствах по проектированию рекомендуется добавлять атрибут TemplatePart к объявлению элемента управления, чтобы документировать используемые в шаблоне имена частей и типы элементов, применяемых для каждой части. Формально этот шаг не является необходимым, но это — фрагмент документации, которая поможет другим разработчикам в использовании вашего класса (и которая может также применяться
Глава 18. Пользовательские элементы 529 инструментами визуального проектирования вроде Expression Blend, позволяющими строить специализированные шаблоны элемента управления). Ниже привежены атрибуты TemplatePart, которые потребуется добавить к классу элемента управления ColorPicker: [TemplatePart(Name="PART_RedSlider", Type=typeof(RangeBase))] [TemplatePart(Name = "PART_BlueSlider", Type=typeof(RangeBase))] [TemplatePart(Name="PART_GreenSlider", Type=typeof(RangeBase))] public class ColorPicker : System.Windows.Controls.Control { ... } Рис. 18.4. Пользовательский элемент управления — указатель цвета с двумя разными шаблонами Стили, специфичные для темы, и стиль по умолчанию Как уже было показано, ColorPicker получает шаблон элемента управления по умолчанию из файла по имени generic.xaml, который находится в папке Themes проекта. Это немного странное соглашение на самом деле является частью поддержки тем, встроенной в WPF. Папка Themes содержит стили по умолчанию для разрабатываемых элементов управления. Эти стили настроены для разных версий и тем операционной системы Windows. Если вы не заинтересованы в создании стилей, специфичных для тем, то все, что нужно — файл generic.xaml. Этот словарь ресурсов содержит запасные стили, используемые элементами управления, когда специфичные для темы файлы отсутствуют. Если же необходимо создавать элементы управления, внешность которых будет зависеть от текущей выбранной темы, а также изменяться в большей или меньшей степени, то для этого понадобится добавить необходимые файлы в папку Themes. В табл. 18.2 перечислены темы, которые можно устанавливать, и имена файлов, которые должны использоваться для словарей ресурсов. Если принято решение не применять файл для определенной темы, то элемент управления обратится к файлу generic.xaml в случае активизации этой темы. На заметку! Специфичные для тем словари ресурсов используются для установки стандартного стиля элемента управления (который должен содержать шаблон элемента по умолчанию). Однако независимо от того, какой используется стиль по умолчанию, специфичный для темы, вы всегда вольны заменить шаблон элемента управления, установив свойство Template объекта элемента управления.
530 Глава 18. Пользовательские элементы Таблица 18.2. Имена файлов специфичных для темы словарей ресурсов Операционная система Наименование базовой темы Имя цвета темы Имя файла Windows Vista или Windows 7 (стандартная) Windows XP (стиль Blue, стандартная) Windows XP (стиль Olive green) Windows XP (стиль Silver) Window XP Media Center Edition 2005 Windows XP (стиль Zune, отдельный выпуск) Windows XP или Windows Vista Aero Luna Luna Luna Royale Zune Classic NormalColor NormalColor Homestead Metallic Normal NormalColor Ae ro. Norma lColor.xaml Luna.NormalColor.xaml Luna.Homestead.xaml Luna.Metallie.xaml Royale. NormalColor. xaml Zune. Nor ma lColor.xaml Classic.xaml Набор заранее определенных тем сравнительно невелик (хотя в будущем он может быть пополнен). В настоящее время Windows Vista и Windows 7 поддерживают только две темы из этого списка — стандартную Аего и унаследованную Windows Classic. Совет. Не имеет значения, сохранит пользователь собственную тему под новым именем или применит собственную цветовую схему. Все разработанные пользователями темы базируются на одной из тем, перечисленных в табл. 18.2. Эта деталь определяет стиль элемента управления. При необходимости можно обратиться к текущим системным цветам (и даже применить их в шаблоне), используя системные ресурсы, которые предлагаются классом SystemColors. Если решено создать специфичный для темы внешний вид для элементов управления, следует начать с создания соответствующих словарей ресурсов с правильными именами файлов. Однако этого шага не достаточно, чтобы заставить эти стили работать в приложении. Также понадобится с помощью атрибута Themelnfo включить поддержку тем в сборке. Themelnfo — это атрибут уровня сборки, который принимает два параметра в своем конструкторе. Первый из них конфигурирует поддержку стиля, специфичного для темы, а второй — поддержку отката к generic.xaml. При создании в Visual Studio нового проекта WPF атрибут Themelnfo добавляется в файл AssemblyInfo.cs, который конфигурирует поддержку generic.xaml, но не специфичный для темы стиль. (Файл Assemblylnfo.cs можно найти в узле Properties (Свойства) окна Solution Explorer.) По умолчанию атрибут Themelnfo выглядит так: [assembly: Themelnfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] Чтобы включить специфичную для темы поддержку, измените этот атрибут следующим образом: [assembly: Themelnfo(ResourceDictionaryLocation.SourceAssembly, ResourceDictionaryLocation.SourceAssembly)] Хотя None и SourceAssembly — два наиболее часто используемых значения из перечисления ResourceDictionaryLocation, можно также применять ExternalAssembly. В этом случае WPF ищет сборку с именем файла ИмяСборки. ИмяТемы. dll в папке приложения. Например, в результате создания библиотеки по имени CustomControls.dll
Глава 18. Пользовательские элементы 531 ресурсы для стилей Windows 7/Vista будут находиться в сборке под названием CustomControls.Aero.dll. Стили Windows XP будут располагаться в сборках CustomControls.Luna.dll, CustomControls.Royale.dll и т.д. (Обратите внимание, что цветовая составляющая имени темы не используется. Вместо этого предполагается, что все специфичные для цвета темы помещены в одну сборку для каждой базовой темы.) Эта система ранее упоминалась во время рассмотрения в главе 17 классов Chrome, которые поддерживают такие элементы управления, как Button. Они используют ресурсы из сборок с именами вроде PresentationFramework.Aero.dll и PresentationFramework.Luna.dll. Сравнение стилей тем и стилей приложений Каждый элемент управления имеет стиль по умолчанию (или несколько зависимых от темы стилей по умолчанию). Для указания стиля по умолчанию, который должен применять пользовательский элемент управления, в статическом конструкторе класса элемента управления необходимо вызвать метод DefaultStyleKeyProperty.OverrideMetadata(). Если этого не сделать, то элемент просто будет использовать стиль по умолчанию, определенный для элемента управления, от которого унаследован ваш класс. В противоположность тому, чего можно ожидать, стиль темы по умолчанию не доступен через свойство Style. Все элементы управления в WPF возвращают null-ссылку для их свойства Style. Свойство Style зарезервировано для стиля приложения (см. главу 11). Установленный стиль приложения объединяется со стилем темы по умолчанию. В случае установки стиля приложения, конфликтующего со стилем по умолчанию, предпочтение отдается стилю приложения, который переопределит средство установки свойства или триггер в стиле по умолчанию. Однако не переопределенные детали остаются. Именно такое поведение и ожидается. Оно позволяет создавать стиль приложения, изменяющий только несколько свойств (например, шрифт текста кнопок), без необходимости удаления других существенных деталей, поддерживаемых в стиле темы по умолчанию (вроде шаблона элемента управления). Кстати, стиль по умолчанию можно извлечь программно. Чтобы сделать это, можно воспользоваться методом FindResourceO для поиска в иерархии ресурсов стиля с корректным ключом типа элемента. Например, для поиска стиля по умолчанию, применяемого к классу Button, используется следующий код: Style style = Application.Current.FindResource(typeof(Button)); Поддержка визуальных состояний Элемент управления ColorPicker демонстрирует хороший пример проектирования элемента управления. Поскольку его поведение и внешний вид тщательно разделены, другие дизайнеры могут создавать новые шаблоны, которые радикально изменят его внешность. Одной из причин простоты ColorPicker является отсутствие в нем концепции состояний. Другими словами, он не меняет своего внешнего вида в зависимости от наличия фокуса, наведенного курсора мыши, доступности и т.п. Элемент FlipPanel из следующего примера в этом отношении несколько отличается. Основная идея, положенная в основу FlipPanel, состоит в том, что он предоставляет две поверхности для размещения содержимого, из которых в каждый конкретный момент времени видимой является только одна. Чтобы увидеть другую, вам нужно "перевернуть" панель. Эффект "переворачивания" может быть настроен с помощью шаблона элемента управления, но эффект по умолчанию использует простое затухание, которое обеспечивает переход между передней и задней поверхностями (рис. 18.5).
532 Глава 18. Пользовательские элементы ' ■ FbpPendTest lf?J fi Ш#Ш 1 This is the front side of the Button One Burton Two j j Button Three Button Four I 1 :,:., 11 Г • "• FfcpPenefTetf 1сиУШ jtt II I This is the frortt side Ы the I FlipPanel. j | Button One , j $ i II RipPanefTest j ЩВяШяяв^ЯшШШ! This is the back side of the FlipPaneL f*p Beck to Front 0 Рис. 18.5. Переворачивание FlipPanel В зависимости от приложения, элемент FlipPanel можно применять для комбинации формы ввода данных с некоторой полезной документацией, чтобы предоставить простой или более сложный вид одних и тех же данных либо соединить вместе вопрос и ответ в какой-то простой игре. Переворачивание можно осуществлять программно (устанавливая свойство по имени Is Flipped) или же пользователь мог бы переворачивать панель, щелкая на подходящей кнопке (если только потребитель элемента управления не удалит ее из шаблона). Ясно, что шаблон элемента управления должен указывать два отдельных раздела: области содержимого передней и задней стороны FlipPanel. Однако есть одна дополнительная деталь, а именно — элементу FlipPanel нужен какой-нибудь способ переключения между двумя состояниями: перевернутым и не перевернутым. Это можно было бы сделать, добавив триггеры к шаблону. Один триггер скрывал бы переднюю панель и показывал вторую по щелчку на кнопке, в то время как другой возвращал бы ее обратно. При этом можно использовать любую приемлемую анимацию. Но за счет применения визуальных состояний потребителю элемента управления ясно указывается, что эти два состояния являются обязательной частью шаблона. Вместо написания триггеров для нужного свойства или события, потребитель элемента управления просто должен был бы оформить соответствующие анимации состояния — задача, которая еще более упрощается при использовании Expression Blend. Начало проектирования класса FlipPanel Если выделить суть, то FlipPanel окажется неожиданной простым элементом. Он состоит из двух областей содержимого, которые пользователь может наполнить единственным элементом (скорее всего, контейнером компоновки, содержащим набор элементов). Формально это значит, что элемент FlipPanel — не настоящая панель, потому что она не использует логики компоновки для организации группы дочерних элементов. Однако это вряд ли вызовет проблему, потому что структура FlipPanel ясна и интуитивно понятна. FlipPanel также включает в себя кнопку, позволяющую переключаться между двумя областями содержимого. Хотя можно создать специальный элемент управления, наследуя его от класса вроде ContentControl или Panel, класс FlipPanel наследуется непосредственно от базового класса Control. Если функциональность специализированного класса элемента управления не нужна, то это — лучшая отправная точка. Наследование от более простого класса FrameworkElement даст в результате нечто, лишенное стандартной инфраструктуры элемента и шаблона. public class FlipPanel : Control {...}
Глава 18. Пользовательские элементы 533 Первое, что понадобится сделать — это создать свойства для FlipPanel. Как почти все свойства в элементе WPF, это должны быть свойства зависимости. Ниже показано, как FlipPanel определяет свойство FrontContent, которое содержит элемент, отображаемый на передней поверхности: public static readonly DependencyProperty FrontContentProperty = DependencyProperty.Register("FrontContent", typeof(object), typeof(FlipPanel), null); Затем необходимо добавить традиционную процедуру свойства .NET, которая вызывает базовые методы GetValueO и SetValueO для изменения свойства зависимости. Ниже приведена реализация процедуры свойства для FrontContent: public object FrontContent { get { return base.GetValue(FrontContentProperty); } set { base.SetValue(FrontContentProperty, value); } } Свойство BackContent почти идентично: public static readonly DependencyProperty BackContentProperty = DependencyProperty.Register("BackContent", typeof(object), typeof(FlipPanel), null); public object BackContent { get { return base.GetValue(BackContentProperty); } set { base.SetValue(BackContentProperty, value); } } Остается добавить только одно существенное свойство: IsFlipped. Это свойство типа bool отслеживает текущее состояние FlipPanel (повернута панель или нет) и позволяет потребителю элемента управления переворачивать его программно: public static readonly DependencyProperty IsFlippedProperty = DependencyProperty.Register("IsFlipped", typeof(bool), typeof(FlipPanel), null); public bool IsFlipped { get { return (bool)base.GetValue(IsFlippedProperty); } set { base.SetValue(IsFlippedProperty, value); ChangeVisualState(true); } }
534 Глава 18. Пользовательские элементы Средство установки свойства IsFlipped вызывает специальный метод по имени ChangeVisualState(). Этот метод обеспечивает обновление изображения для соответствия текущему состоянию (повернута панель лицом или тылом). Код, который решает эту задачу, рассматривается чуть позже. FlipPanel наследует почти все необходимое от класса Control. Исключением является лишь свойство CornerRadius. Хотя класс Control включает свойства BorderBrush и BorderThickness, которые можно применять для рисования контура вокруг FlipPanel, ему недостает свойства CornerRadius для скругления квадратных углов, как это делает элемент Border. Реализовать такой же эффект в FlipPanel просто — необходимо добавить свойство зависимости CornerRadius и воспользоваться им для конфигурирования элемента Border в шаблоне FilpPanel по умолчанию: public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.Register("CornerRadius", typeof(CornerRadius), typeof(FlipPanel), null); public CornerRadius CornerRadius { get { return (CornerRadius)GetValue(CornerRadiusProperty); } set { SetValue(CornerRadiusProperty, value); } } Также нужно добавить стиль, применяющий шаблон по умолчанию к FlipPanel. Этот стиль помещается в словарь ресурсов generic.xaml, как и в ColorPicker. Ниже показана базовая структура: <Style TargetType="{x:Type local:FlipPanel}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:FlipPanel"> </ControlTemplate> </Setter.Value> </Setter> </Style> Остается одна последняя деталь. Чтобы заставить элемент управления выбрать стиль по умолчанию из файла generic.xaml, нужно вызвать метод DefaultStyleKeyProperty. OverrideMetadataO в статическом конструкторе FlipPanel: DefaultStyleKeyProperty.OvemdeMetadata (typeof (FlipPanel) , new FrameworkPropertyMetadata(typeof(FlipPanel))); Выбор частей и состояний Имея базовую структуру, можно идентифицировать части и состояния, которые будут использованы в шаблоне элемента управления. Ясно, что для FlipPanel требуются два состояния. • Нормальное. Эта раскадровка гарантирует видимость только содержимого переднего плана. Содержимое обратной стороны перевернуто, затенено или каким-то иным образом убрано с глаз. • Перевернутое. Эта раскадровка обеспечивает видимость только обратной стороны. Содержимое передней стороны удалено из видимости с помощью анимации. Вдобавок понадобятся две части. • FlipButton. Это кнопка, при щелчке на которой видимость переключается от передней панели к задней (или наоборот). FlipPanel обеспечивает это, обрабатывая события данной кнопки.
Глава 18. Пользовательские элементы 535 • FlipButtonAlternate. Это необязательный элемент, работающий таким же образом, как FlipButton. Его включение позволит потребителю элемента управления использовать два разных подхода в шаблоне пользовательского элемента управления. Первый способ — использование единой кнопки вне области переворачиваемого содержимого. Второй способ — помещение отдельных кнопок для переворачивания на обе стороны панели, в переворачиваемой области. На заметку! Внимательный читатель отметит здесь несколько запутанное проектное решение. В отличие от пользовательского элемента ColorPicker, именованные части FlipPanel не используют префикса имен PART_ (как PART_FlipButton). Дело в том, что система именования PART_ появилась раньше модели визуального состояния. В этой модели соглашения изменились в пользу более простых имен, хотя данный стандарт еще не устоялся и в будущем может измениться. А пока пользовательские элементы управления позволяют использовать атрибут TemplatePart для указания именованных частей. Можно было бы также добавить содержимое для фронтальной и обратной области. Однако элементу FlipPanel не нужно манипулировать этими областями непосредственно — до тех пор, пока шаблон включает анимацию, скрывающую или отображающую их в надлежащее время. (Другой вариант — определить эти части так, чтобы явно изменять их видимость в коде. В таком случае панель сможет переключаться между передней и задней областями содержимого, даже если никакая анимация не определена, скрывая одну часть и показывая другую. Для простоты в FlipPanel такой подход использоваться не будет.) Чтобы анонсировать факт, что FlipPanel использует эти части и состояния, необходимо применить атрибут TemplatePart к классу элемента управления, как показано ниже: [TemplateVisualState(Name = "Normal", GroupName="ViewStates")] [TemplateVisualState(Name = "Flipped", GroupName = "ViewStates")] [TemplatePart(Name = "FlipButton", Type = typeof(ToggleButton))] [TemplatePart(Name = "FlipButtonAlternate", Type = typeof(ToggleButton))] public class FlipPanel : Control { ... } Части FlipButton и FlipButtonAlternate ограничены — каждая из них может быть только экземпляром ToggleButton либо экземпляром класса, унаследованного от ToggleButton. (Как упоминалось в главе 6, ToggleButton — это кнопка, которая может находиться в одном из двух состояний. В случае элемента управления FlipPanel состояния ToggleButton соответствуют нормальному виду с видимой передней панелью и перевернутому виду с видимой задней панелью.) Совет. Для обеспечения более гибкой поддержки шаблона применяйте наименее специализированный тип элемента. Например, лучше использовать FrameworkElement, чем ContentControl, если только не требуется какое-нибудь свойство или поведение, предоставляемое ContentControl. Шаблон элемента управления, принятый по умолчанию Теперь можно поместить все эти части в шаблон элемента управления по умолчанию. Корневой элемент — это Grid с двумя строками, удерживающий область содержимого (в верхней строке) и кнопку для переворачивания (в нижней строке). Область содержимого заполняется двумя перекрывающимися элементами Border, которые представляют переднюю и заднюю стороны содержимого, но только один из них видим в каждый конкретный момент времени.
536 Глава 18. Пользовательские элементы Чтобы заполнить переднюю и заднюю области содержимого, FlipPanel использует ContentPresenter. Это почти такой же прием, который применялся в примере с пользовательской кнопкой, за исключением того, что здесь нужны два элемента ContentPresenter, по одному для каждой стороны FlipPanel. Элемент FlipPanel также включает отдельный элемент Border, в который помещен каждый ContentPresenter. Это позволяет потребителю элемента управления определить переворачиваемую область содержимого, устанавливая несколько простых свойств в FlipPanel (BorderBrush, BorderThickness, Background и CornerRadius), вместо того, чтобы добавлять Border вручную. Ниже показана базовая структура шаблона элемента управления по умолчанию: <ControlTemplate TargetType="{x:Type local:FlipPanel}"> <Grid> <Grid.RowDefinitions> <RowDefmition Height="Auto"></RowDefinition> <RowDefmition Height="Auto"x/RowDefinition> </Grid.RowDefinitions> <!-- Передняя область содержимого. --> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" Background="{TemplateBinding Background}"> <ContentPresenter Content="{TemplateBinding FrontContent}"> </ContentPresenter> </Border> <'-- Задняя область содержимого. --> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" Background="{TemplateBinding Background}"> <ContentPresenter Content="{TemplateBinding BackContent}"> </ContentPresenter> </Border> <!-- Кнопка переворачивания. --> <ToggleButton Grid.Row="l" x:Name="FlipButton" Margin=,10,0, 0"> </ToggleButton> </Grid> </ControlTemplate> При создании шаблона элемента управления по умолчанию лучше избегать жесткого кодирования деталей, которые потребитель этого элемента может захотеть настроить по-своему. Вместо этого необходимо применять выражения привязки шаблонов. В рассматриваемом примере устанавливаются несколько свойств с использованием выражений привязки шаблона: BorderBrush, BorderThickness, CornerRadius, Background, FrontContent и BackContent. Чтобы установить значения по умолчанию для этих свойств, понадобится добавить дополнительные средства установки к стилю по умолчанию элемента управления. Кнопка переворачивания Показанный в предыдущем примере шаблон элемента управления включает ToggleButton. Однако в результате он использует внешний вид ToggleButton, т.е. кнопка выглядит как совершенно обычная, с традиционным текстурированным фоном. Для FlipPanel это не подходит. Хотя можно поместить внутри ToggleButton любое содержимое, FlipPanel требует немного большего. Вместо стандартного фона необходимо менять внешность элементов внутри в зависимости от состояния ToggleButton. Как было показано на рис. 18.5,
Глава 18. Пользовательские элементы 537 кнопка ToggleButton определяет способ "переворачивания" содержимого (первоначально стрелка вправо, когда отображается лицевая сторона, и стрелка влево, когда отображается изнанка). Это помогает яснее понять назначение кнопки. Чтобы создать этот эффект, нужно спроектировать специальный шаблон элемента управления для ToggleButton. Этот шаблон элемента управления может включать элементы-фигуры, которые рисуют нужные стрелки. В данном примере ToggleButton рисуется с помощью элемента Ellipse (отображает кружок) и элемента Path (для стрелки), и оба они помещаются в Grid из одной ячейки: <ToggleButton Grid.Row="l" x:Name="FlipButton" RenderTransformOrigin=.5,0.5" Margin=,10,0,0" width=9" Height=9"> <ToggleButton.Template> <ControlTemplate> <Grid> <Ellipse Stroke="#FFA9A9A9" Fill = "AliceBlue"x/Ellipse> <Path Data="Ml,1.5L4.5,5 8,1.5" Stroke="#FF666666" StrokeThickness=" HonzontalAlignment="Center" VerticalAlignment="Center"x/Path> </Grid> </ControlTemplate> </ToggleButton.Template> </ToggleButton> Для ToggleButton требуется еще одна деталь — трансформация RotateTransform, которая поворачивает стрелку с одной стороны в другую. Эта RotateTransform будет использоваться при создании анимаций состояния: <ToggleButton.RenderTransform> <RotateTransform x :Name="FlipButtonTransform" Angle="-90"x/RotateTransform> </ToggleButton.RenderTransform> Определение анимаций состояния Анимации состояния — наиболее интересная часть шаблона элемента управления. Они являются ингредиентами, которые обеспечивают поведение "переворачивания". Кроме того, это детали, которые наиболее вероятно будут изменяться, если разработчик создаст специальный шаблон для FlipPanel. Для определения групп состояний понадобится добавить элемент VisualStateManager. VisualStateGroups в корневой узел шаблона элемента управления: <ControlTemplate TargetType="{x:Type local:FlipPanel}"> <Grid> <VisualStateManager.VisualStateGroups> </VisualStateManager.VisualStateGroups> </Grid> </ControlTemplate> На заметку! Чтобы к шаблону можно было добавить элемент VisualStateManager, шаблон должен использовать панель компоновки. Эта панель компоновки содержит оба объекта Visual для элемента управления и невидимый VisualStateManager. Диспетчер VisualStateManager определяет раскадровки с анимациями, которые может применять элемент управления, чтобы в надлежащий момент изменять свой внешний вид. Внутри элемента VisualStateManager можно создавать группы состояний, используя соответствующим образом именованные элементы VisualStateGroup. В каждую группу VisualStateGroup добавляется элемент VisualState для каждого визуального
538 Глава 18. Пользовательские элементы состояния. В случае FlipPanel имеется одна группа, которая содержит два визуальных состояния: <VisualStateManager.VisualstateGroups> <VisualStateGroup x:Name="ViewStates"> <VisualState x:Name="Normal"> </VisualState> </VisualStateGroup> <VisualStateGroup x:Name="FocusStates"> <VisualState x:Name="Flipped"> </VisualState> </VisualStateGroup> </VisualStateManager.VisualstateGroups> Каждое состояние соответствует раскадровке с одной или более анимациями. Если эти раскадровки существуют, они инициируются в соответствующие моменты времени. (Если нет, то элемент управления не должен генерировать ошибку.) В шаблоне элемента управления, принятом по умолчанию, анимации используют простое затухание для перехода от одной области содержимого к другой, и вращение для переключения стрелки ToggleButton, чтобы она указывала в другом направлении. Ниже показана разметка, которая решает обе задачи: <VisualState x:Name="Normal"> <Storyboard> <DoubleAnimation Storyboard.TargetName="BackContent" Storyboard.TargetProperty="Opacity" To=" Duration="x/DoubleAnimation> </Storyboard> </VisualState> <VisualState x:Name="Flipped"> <Storyboard> <DoubleAnimation Storyboard.TargetName="FlipButtonTransform" Storyboard.TargetProperty="Angle" To="90" Duration="x/DoubleAnimation> <DoubleAnimation Storyboard.TargetName="FrontContent" Storyboard.TargetProperty="Opacity" To=" Duration="x/DoubleAnimation> </Storyboard> </VisualState> Обратите внимание, что визуальные состояния устанавливают нулевую длительность анимации, а это значит, что анимация оставляет свой эффект в силе на постоянной основе. Это может показаться странным — в конце концов, разве не нужно более постепенное изменение, чтобы заметить анимационный эффект? В действительности такое проектное решение совершенно корректно, потому что визуальные состояния предназначены для определения внешности элемента управления, когда он находится в соответствующем состоянии. Например, перевернутая панель просто показывает свое фоновое содержимое, пока находится в перевернутом состоянии. Процесс переворачивания — это переход, который происходит непосредственно перед тем, как FlipPanel входит в перевернутое состояние, и он не является частью самого состояния. (Это различие между состояниями и переходами важно, потому что некоторые элементы управления имеют анимации, которые выполняются во время нахождения в каком-то состоянии. Например, вспомните пример кнопки из главы 17, которая обеспечивает пульсирование фона при наведении курсора мыши.) Определение переходов между состояниями Переход (transition) — это анимация, которая начинается с текущего состояния и заканчивается новым состоянием. Одно из преимуществ модели переходов состоит в том,
Глава 18. Пользовательские элементы 539 что создавать раскадровку для этой анимации не придется. Например, если добавить показанную ниже разметку, то WPF создаст анимацию длительностью 0,7 секунды для изменения прозрачности FlipPanel, обеспечив приятный эффект затухания: <VisualStateGroup x:Name="ViewStates"> <VisualStateGroup.Transitions> <VisualTransition GeneratedDuration= :0 : 0 . 7"X/VisualTransition> </VisualStateGroup.Transitions> <VisualState x:Name="Normal"> </VisualState> <VisualState x:Name="Flipped"> </VisualState> </VisualStateGroup> Переходы применяются к группам состояний. После определения переход должен быть добавлен в коллекцию VisualStateGroup.Transitions. В этом примере используется простейшая разновидность перехода — переход по умолчанию, который применяется ко всем изменениям состояния для данной группы. Переход по умолчанию удобен, но это "универсальное" решение, которое не всегда подходит. Например, может понадобиться, чтобы переходы FlipPanel происходили с разной скоростью, в зависимости от того, к какому состоянию они применяются. Чтобы настроить это, необходимо определить множество переходов и устанавливать свойство То для указания момента начала перехода. Например, если есть такие переходы: <VisualStateGroup.Transitions> <VisualTransition To="Flipped" GeneratedDuration=:0:0 . 5" /> <VisualTransition To="Normal" GeneratedDuration=:0:0.I" /> </VisualStateGroup.Transitions> то FlipPanel будет переключаться в состояние Flipper за 0,5 секунды и возвращаться в состояние Normal за 0,1 секунды. В этом примере показаны переходы, которые применяются при входе в определенные состояния, но можно также использовать свойство From, чтобы создать переход, применяемый при выходе из состояния. Используя свойства То и From в сочетании, можно создавать еще более изощренные переходы, которые применяются только при смене определенных состояний. WPF осуществляет поиск в коллекции переходов, чтобы выбрать наиболее специфический из них, и применяет только его. Для еще более тонкого контроля можно создавать специальные анимации переходов, которые заменяют собой автоматически сгенерированные переходы, обычно используемые WPF. Причин создания специальных переходов несколько: управление ходом анимации; использование функций плавности анимации; последовательный запуск нескольких анимаций; воспроизведение звука одновременно с анимацией. Чтобы определить специальный переход, внутрь элемента VisualTransition помещается раскадровка с одной или более анимациями. В примере с FlipPanel специальные переходы можно использовать для обеспечения быстрого поворота кнопки ToggleButton, но при более плавном затухании переворачиваемой области. <VisualStateGroup.Transitions> <VisualTransition GeneratedDuration=:0:0 . 7" To="Flipped"> <Storyboard> <DoubleAnimation Storyboard.TargetName="FlipButtonTransform" Storyboard.TargetProperty="Angle" To="90" Duration=:0:0.2"></DoubleAnimation> </Storyboard> </VisualTransition>
540 Глава 18. Пользовательские элементы <VisualTransition GeneratedDuration=:0 : 0 . 7" To="Normal"> <Storyboard> <DoubleAnimation Storyboard.TargetName="FlipButtonTransform" Storyboard.TargetProperty="Angle" To="-90" Duration=:0:0.2"></DoubleAnimation> </Storyboard> </VisualTransition> </VisualStateGroup.Transitions> На заметку! При использовании специального перехода все равно необходимо устанавливать свойство VisualTransition.GeneratedDuration для задания длительности анимации. Без этой детали VisualStateManager не сможет использовать переход и переключит элемент в новое состояние немедленно. (Действительное значение времени не оказывает влияния на специальный переход, потому что применяется только к автоматически генерированным анимациям.) К сожалению, многие элементы управления требуют специальных переходов, написание которых утомительно. Все равно придется сохранить некоторые анимации состояния нулевой длины, что также приводит к некоторому неизбежному дублированию деталей между визуальными состояниями и переходами. Связывание элементов После построения совершенного шаблона элемента управления следует позаботиться о внутренних механизмах FlipPanel, чтобы заставить его работать должным образом. Секрет кроется в методе OnApplyTemplate(), который также использовался для установки привязок ColorPicker. Метод OnApplyTemplateO для FlipPanel извлекает кнопку ToggleButton для частей FlipButton и FlipButtonAlternate, и присоединяет обработчики событий к каждой из них, чтобы они могли реагировать на щелчки для переворачивания элемента управления. Метод OnApplyTemplateO завершается вызовом специального метода ChangeVisualStateO, который обеспечивает соответствие визуального представления элемента управления его текущему состоянию. public override void OnApplyTemplateO { base.OnApplyTemplate (); // Привязка события ToggleButton.Click. ToggleButton flipButton = base.GetTemplateChild("FlipButton") as ToggleButton; if (flipButton '= null) flipButton.Click += flipButton_Click; // При необходимости разрешить две кнопки (по одной на каждую сторону панели). ToggleButton flipButtonAlternate = base.GetTemplateChild("FlipButtonAlternate") as ToggleButton; if (flipButtonAlternate '= null) flipButtonAlternate.Click += flipButton_Click; // Обеспечить соответствие визуальных элементов текущему состоянию, this.ChangeVisualState (false); } Совет. При вызове GetTemplateChild() необходимо указать строковое имя нужного элемента Во избежание возможных ошибок, эту строку можно объявить константной в элементе управления. Затем эту константу можно использовать в атрибуте TemplatePart и при вызове GetTemplateChild(). Ниже приведен очень простой обработчик события, который позволяет пользователю щелкать на ToggleButton и переворачивать панель:
Глава 18. Пользовательские элементы 541 private void flipButton_Click(object sender, RoutedEventArgs e) { this.IsFlipped = !this.IsFlipped; ChangeVisualState (true); } К счастью, вручную инициировать анимации состояния не понадобится. Точно также не нужно создавать или инициировать анимации переходов. Для смены одного состояния на другое вызывается статический метод VisualStateManager.GoToState(). При этом передается ссылка на объект элемента управления, состояние которого изменяется, имя нового состояния и булевское значение, определяющее, нужно ли показывать переход. Это значение должно быть true, если речь идет об инициированном пользователем изменении (например, когда пользователь щелкает HaToggleButton), и false — когда речь идет об установке свойства (например, при установке начального значения свойства IsFlipped в разметке страницы). Поддержка различных состояний элемента управления может быть запутанной. Чтобы избежать засорения кода элемента управления множественными вызовами GoToStateO, в большинстве элементов добавляется специальный метод, подобный ChangeVisualState() в FlipPanel. Этот метод отвечает за применение корректного состояния к каждой группе состояний. Код внутри него использует блок if (или оператор switch) для применения текущего состояния к каждой группе. Такой подход работает, поскольку вполне допустимо вызывать GoToState () с именем текущего состояния. В ситуации, когда текущее состояние и запрошенное состояние совпадают, ничего не происходит. Вот как выглядит код метода ChangeVisualState () для FlipPanel: private void ChangeVisualState(bool useTransitions) { if ('IsFlipped) { VisualStateManager.GoToState (this, "Normal", useTransitions); } else { VisualStateManager.GoToState (this, "Flipped", useTransitions); } } Обычно метод ChangeVisualState () (или его эквивалент) вызывается в следующих местах. • После инициализации элемента управления в конце метода On Apply Tempi ate (). • При реагировании на событие, которое представляет изменение состояния, такое как перемещение курсора мыши или щелчок HaToggleButton. • При реагировании на изменение свойства или на метод, который вызывается в коде. (Например, средство установки свойства IsFlipped вызывает ChangeVisualState () и всегда передает true, тем самым вызывая показ анимаций переходов. Чтобы предоставить потребителю элемента управления выбор, показывать переход или нет, можно добавить метод Flip (), который принимает тот же самый булевский параметр, который передается методу ChangeVisualState().) Как было сказано, элемент управления FlipPanel замечательно гибок. Например, его можно использовать без кнопки ToggleButton и переключать программно (например, когда пользователь щелкает на каком-то другом элементе управления). Кроме того, можно поместить одну или две кнопки переключения в шаблон элемента и предоставить пользователю возможность управлять ими.
542 Глава 18. Пользовательские элементы Использование FlipPanel Завершив с шаблоном элемента управления и кодом FlipPanel, можно приступить к его использованию в приложениях. Исходя из предположения, что добавлена необходимая ссылка на сборку, следует отобразить префикс XML на пространство имен, которое хранит специальный элемент управления: <Window x:Class="FlipPanelTest.Page" xmlns:lib="clr-namespace:FlipPanelControl;assembly=FlipPanelControl" . . . > После этого можно добавлять экземпляры FlipPanel на страницу. Ниже приведен пример, в котором создается панель FlipPanel, показанная ранее на рис. 18.5, с использованием контейнера StackPanel для наполнения элементами области содержимого переднего плана и Grid — для заднего: <lib:FlipPanel x:Name="panel" BorderBrush="DarkOrange" BorderThickness=" CornerRadius=" Margin=0"> <lib:FlipPanel.FrontContent> <StackPanel Margin="> <TextBlock TextWrapping="Wrap" Margin=" FontSize=6" Foreground="DarkOrange">This is the front side of the FlipPanel.</TextBlock> <Button Margin=" Padding=" Content="Button One"x/Button> <Button Margin=" Padding=" Content="Button Two"x/Button> <Button Margin=" Padding=" Content="Button Three"x/Button> <Button Margin=" Padding=" Content="Button Four"x/Button> </StackPanel> </lib:FlipPanel.FrontContent> <lib:FlipPanel.BackContent> <Grid Margin="> <Grid.RowDefinitions> <RowDefmition Height="Auto"x/RowDefinition> <RowDef mitionx/RowDef inition> </Grid.RowDeflnitions> <TextBlock TextWrapping="Wrap" Margin=" FontSize=6" Foreground="DarkMagenta">This is the back side of the FlipPanel.</TextBlock> <Button Grid.Row=" Margin=" Padding=0" Content="Flip Back to Front" HorizontalAlignment="Center" VerticalAlignment="Center" Click="cmdFlip_Click"x/Button> </Grid> </lib:FlipPanel.BackContent> </lib:FlipPanel> Щелчок на кнопке на задней стороне FlipPanel приводит к программному перевороту панели: private void cmdFlip_Click (object sender, RoutedEventArgs e) { panel.IsFlipped = !panel.IsFlipped; } Это дает тот же результат, что и щелчок на кнопке ToggleButton со стрелкой, которая определена как часть шаблона по умолчанию. Использование другого шаблона элемента управления Правильно спроектированные пользовательские элементы управления исключительно гибкие. В случае FlipPanel можно просто применить новый шаблон для изменения внешнего вида и расположения ToggleButton, а также анимационных эффектов, используемых при переключении между передней и задней областями содержимого. На рис. 18.6 показан такой пример. Здесь кнопка помещена в специальную линейку,
Глава 18. Пользовательские элементы 543 находящуюся внизу фронтальной стороны и в верхней части обратной стороны. Когда панель переворачивается, она делает это не так, как лист бумаги. Вместо этого содержимое передней панели просто исчезает, сдвигаясь вверх, при этом снизу одновременно "выезжает" содержимое обратной стороны. Когда происходит противоположное преобразование, содержимое обратной стороны уезжает вниз, а содержимое передней части — опускается сверху. Можно создать еще более привлекательный эффект, одновременно размывая содержимое уходящей части с помощью класса BlurEf feet. ■ FlipPaneiAJtemsteTemplate This is the front side of the FlipPanel. I v.. I Li"" HonooOn. Button T«o ■ — ТЕ! 1 Button Four ■ FkpPanetAhemateTempUte "isl^AQri This is the back side of the FlipPanel. Рис. 18.6. Панель FlipPanel с другим шаблоном элемента управления Ниже показана часть шаблона, которая определяет фронтальную область содержимого: <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" Background="{TemplateBinding Background}"> <Border.RenderTransform> <ScaleTransform x:Name="FrontContentTransform"x/ScaleTransform> </Border.RenderTransform> <Border.Effect> <BlurEffect x:Name="FrontContentEffect" Radius="></BlurEffect> </Border.Effect> <Grid> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDefmition Height="Auto"x/RowDefinition> </Grid.RowDefinitions> <ContentPresenter Content="{TemplateBinding FrontContent}"></ContentPresenter> <Rectangle Grid.Row="l" Stretch="Fill" Fill="LightSteelBlue"></Rectangle> <ToggleButton Grid.Row="l" x:Name="FlipButton" Margin=" Padding=5,0" Content="A" FontWeight="Bold" FontSize=2" HorizontalAlignment="Right"> </ToggleButton> </Grid> </Border> Область содержимого обратной стороны почти такая же. Она включает элемент Border, в котором имеется ContentPresenter с собственной кнопкой ToggleButton, расположенной в правой части текстурированного прямоугольника. Также определены необходимая трансформация ScaleTransform и эффект BlurEf feet, которые используются анимацией для смены панелей. Ниже приведен код разметки для анимации, переворачивающей панель. Полную разметку ищите в загружаемом коде примеров для настоящей главы.
544 Глава 18. Пользовательские элементы <VisualState x:Name="Flipped"> <Storyboard> <DoubleAnimation Storyboard.ТагдеtName="FrontContentTransform" Storyboard. TargetProperty="ScaleY" To="x/DoubleAnimation> <DoubleAnimation Storyboard.TargetName="FrontContentEffeet" Storyboard.TargetProperty="Radius" To=0"></DoubleAnimation> <DoubleAnimation Storyboard.TargetName="BackContentTransform" S tor yboard. Targe tProperty=" Scale Y" To="l"x/DoubleAnimation> <DoubleAnimation Storyboard.TargetName="BackContentEffeet" Storyboard.TargetProperty="Radius" To="></DoubleAnimation> </Storyboard> </VisualState> Поскольку анимация, которая изменяет фронтальную область содержимого, выполняется одновременно с анимацией, изменяющей обратную область содержимого, специальный переход для управления ими не понадобится. Пользовательские панели Итак, выше была продемонстрирована разработка с нуля двух пользовательских элементов управления — CololPicker и FlipPanel. В следующих разделах вы ознакомитесь с двумя более специализированными вариантами: наследованием пользовательской панели и построением элемента управления, рисующего себя специальным образом. Создание специальной пользовательской панели — специфическая, но довольно распространенная часть разработки пользовательских элементов управления. Как известно из главы 3, панели размещают в себе один или более дочерних элементов и реализуют специфическую логику компоновки для соответствующего их расположения. Пользовательские панели — важный ингредиент, который необходим, когда строится собственная система "отрывных" панелей инструментов и стыкуемых окон. Пользовательские панели часто удобны для создания составных элементов управления, которым нужна специфическая нестандартная компоновка — вроде забавных стыкуемых панелей инструментов. Вы уже знакомы с базовыми типами панелей, которые WPF предлагает для организации содержимого (StackPanel, DockPanel, WrapPanel, Canvas и Grid). Также вы видели, что некоторые элементы WPF используют собственные специализированные панели (вроде TabPanel, ToolBarOverf lowPanel и VirtualizingPanel). В Интернете можно найти намного больше примеров пользовательских панелей. Ниже перечислены некоторые их них, достойные внимания. • Специальный контейнер Canvas, позволяющий перетаскивать свои дочерние элементы без дополнительного кода обработки событий (http://www.codeproject. com/WPF/DraggingElementsInCanvas.asp). • Две панели, реализующие забавные эффекты в списке элементов (http://www. codeproject.com/WPF/Panels.asp). • Панель, использующая анимацию на основе кадров для трансформации одной компоновки в другую (http://j832.com/BagOTricks). В последующих разделах будет показано, как создается пользовательская панель, и будут рассмотрены два простых примера — базовый клон Canvas и расширенную версию WrapPanel.
Глава 18. Пользовательские элементы 545 Двухшаговый процесс компоновки Каждая панель использует один и тот же прием: двухшаговый процесс, отвечающий за изменение размеров и упорядочивание дочерних элементов. Первая стадия — измерение, когда панель определяет, насколько большими хотят быть ее дочерние компоненты. Вторая стадия — компоновка, когда каждый элемент получает свои границы. Необходимы два шага, поскольку панель должна учесть "пожелания" всех своих членов перед тем, как решить, как следует распорядиться доступным пространством. Логику этих двух шагов добавляется за счет переопределения методов со странными именами MeasureOverrideO и ArrangeOverrideO, которые определены в классе FrameworkElement как часть системы компоновки WPF. Их странные имена говорят о том, что методы MeasureOverrideO и ArrangeOverrideO заменяют логику, содержащуюся в методах MeasureCoreO и ArrangeCoreO, определенных в классе UIElement. Последние — не переопределяемы. Метод MeasureOverrideO Первый шаг состоит в определении с помощью метода MeasureOverrideO того, сколько пространства желает занять каждый дочерний элемент. Однако даже в методе MeasureOverrideO дочерние элементы не получают неограниченного пространства. В качестве абсолютного минимума дочерние элементы ограничены пространством, доступным в панели. Дополнительно их можно ограничить более строго. Например, Grid с двумя пропорционально размещенными строками предоставит каждому из дочерних элементов половину доступной высоты. StackPanel выделит все доступное пространство первому элементу, затем предоставит то, что осталось, второму, и т.д. Каждая реализация MeasureOverrideO отвечает за проход в цикле по коллекции дочерних элементов и вызов метода Measure () для каждого из них. При вызове методу Measure() передается ограничивающий прямоугольник — объект Size, определяющий максимально доступное пространство для дочернего элемента управления. К концу метода MeasureOverride () панель возвращает пространство, необходимое для отображения всех своих дочерних элементов и их желательные размеры. Ниже приведена базовая структура метода MeasureOverrideO, без специфических деталей, связанных с размерами: protected override Size MeasureOverride (Size constraint) { // Проверить все дочерние элементы. foreach (UIElement element in base.InternalChildren) { // Запросить у каждого дочернего элемента желательное для него // пространство, применяя ограничение availableSize. Size availableSize = new Size (...); element.Measure(availableSize); // (Здесь можно прочитать element.DesiredSize, // чтобы получить запрошенный размер.) } // Показать, сколько места требует данная панель. // Будет использовано для установки свойства DesiredSize панели, return new Size(...); } Метод Measure () не возвращает значения. После вызова Measure () свойство DesiredSize данного элемента содержит запрошенный размер. Эту информацию можно использовать в своих вычислениях для будущих дочерних элементов (и определения общего размера, необходимого панели).
546 Глава 18. Пользовательские элементы Вы должны вызвать Measure () для каждого дочернего элемента, даже если не хотите ограничивать размер этого элемента либо использовать его свойство DesiredSize. Многие элементы не отображают себя до тех пор, пока не будет вызван их метод Measure(). Чтобы предоставить дочернему элементу все пространство, которое он пожелает, передайте объект Size со значением Double.Positivelnfinity no обоим измерениям. (Такую стратегию использует ScrollViewer, поскольку он может обработать содержимое любого размера.) Дочерний элемент затем вернет размер пространства, необходимого его содержимому, или доступное пространство — в зависимости от того, что меньше. В конце процесса измерения контейнер компоновки должен вернуть желаемый размер. Желаемый размер простой панели можно вычислить, комбинируя желаемые размеры каждого дочернего элемента. На заметку! В качестве желаемого размера панели можно просто вернуть ограничение, переданное методу MeasureOverride (). Хотя кажется разумным взять весь доступный размер, это приводит к проблемам, если контейнер принимает объект Size со значением Double. Positivelnf inity хотя бы по одному из двух измерений (что означает "возьми столько места, сколько хочешь"). Хотя бесконечный размер допустим в качестве ограничения, он не допустим как результирующее значение, поскольку WPF не сможет определить, насколько большим должен быть элемент. Более того, не следует запрашивать больше пространства, чем нужно на самом деле. В противном случае это приведет к появлению лишнего пустого пространства, и элементы, добавленные позже, после панели компоновки, будут скапливаться внизу окна. Внимательный читатель может отметить, что существует близкое сходство между методом Measure (), вызываемым с каждым элементом, и методом MeasureOverrideO, определяющим первый шаг логики компоновки панели. В действительности Measure () запускает метод MeasureOverrideO. To есть, если поместить один контейнер компоновки внутрь другого, то при вызове Measure () получится общий размер, необходимый контейнеру компоновки и всем его дочерним элементам. Совет. Одной из причин того, что процесс измерения проходит в два шага (метод Measure(), запускающий метод MeasureOverrideO), является необходимость иметь дело с полями. При вызове методу Measure () передается все доступное пространство. Когда WPF вызывает MeasureOverrideO, он автоматически сокращает доступное пространство, чтобы принять во внимание размер полей (если только не был передан бесконечный размер). Метод ArrangeOverride () После того как все элементы измерены, наступает время разместить их в пределах доступного пространства. Система компоновки вызывает метод ArrangeOverride() панели, а панель вызывает метод Arrange () для каждого дочернего элемента, чтобы сообщить ему, сколько пространства ему выделено. (Как и можно было предположить, Arrange () запускает метод ArrangeOverride (), подобно тому, как Measure () инициирует вызов метода MeasureOverrideO.) При измерении элементов с помощью метода Measure() передается объект Size, задающий границы доступного пространства. При размещении элемента методом Arrange0 передается объект System.Windows.Rect, определяющий размер и положение элемента. В данный момент это похоже на то, как элемент располагается по координатам X и Y стиля Canvas, которые определяют расстояние между верхним левым углом контейнера компоновки и элементом.
Глава 18. Пользовательские элементы 547 На заметку! Элементы (и панели компоновки) вольны нарушать правила и пытаться рисовать за пределами выделенных им границ. Например, в главе 12 было показано, что Line может перекрывать соседние элементы. Однако обычные элементы должны соблюдать выделенные им границы. Вдобавок большинство контейнеров будут отсекать те дочерние элементы, которые выходят за их границы. Ниже приведена базовая структура метода ArrangeOverrideO без специфических деталей, связанных с вычислением размеров. protected override Size ArrangeOverride(Size arrangeSize) { // Перебрать все дочерние элементы. foreach (UIElement element in base.InternalChildren) { // Назначить дочернему элементу его границы. Rect bounds = new Rect(...); element.Arrange(bounds); // (Теперь вы можете прочитать element.ActualHeight и // element.ActualWidth, чтобы определить его размеры.) } // Определить, сколько места займет эта панель. // Эта информация будет использована для установки // свойств ActualHeight и ActualWidth панели, return arrangeSize; } При упорядочивании элементов нельзя передавать бесконечные размеры. Однако можно предоставить элементу его желаемый размер, передав значение свойства DesiredSize. Можно также дать элементу больше пространства, чем он требует. Фактически такое случается довольно часто. Например, вертикальная панель StackPanel предоставляет своим дочерним элементам столько высоты, сколько они требуют, но при этом выделяют им всю ширину самой панели. Аналогично Grid может использовать фиксированный или пропорциональный размер строк, который больше желаемого размера находящегося внутри элемента. И даже если расположить элемент в контейнере "размер по содержимому" (size-to-content), этот элемент может быть увеличен, если ему будет явно установлен размер через свойства Height и Width. Когда элемент делается больше, чем его желаемый размер, вступают в действие свойства HorizontalAlignment и VerticalAlignment. Содержимое элемента помещается где-то внутри отведенного ему пространства и его надо как-то выравнивать. Поскольку метод ArrangeOverrideO всегда принимает определенный размер (не бесконечный), можно вернуть переданный объект Size, чтобы установить финальный размер панели. Фактически многие контейнеры компоновки предпринимают этот шаг, чтобы занять все выделенное им пространство. (Здесь отсутствует опасность захватить слишком много места, которое может понадобиться другому элементу, поскольку шаг измерения системы компоновки гарантирует, что не будет выделено больше места, чем необходимо, если его не хватает.) Клон Canvas Самый быстрый способ понять работу этих двух методов — рассмотреть внутреннее устройство класса Canvas, который является простейшим контейнером компоновки. Чтобы создать собственную панель в стиле Canvas, нужно просто унаследовать класс от Panel и добавить методы MeasureOverride() и ArrangeOverrideO, показанные ниже: public class CanvasClone : System.Windows.Controls.Panel { ... }
548 Глава 18. Пользовательские элементы Контейнер Canvas помещает дочерние элементы там, где они хотят разместиться, и выделяет им столько места, сколько им нужно. В результате нет необходимости вычислять, сколько доступного пространства следует выделить. Это делает метод MeasureOverride() чрезвычайно простым. Каждому дочернему элементу выделяется бесконечное пространство: protected override Size MeasureOverride(Size constraint) { Size size = new Size (double . Positivelnf mity, double . Positivelnf mity) ; foreach (UIElement element in base.InternalChildren) { element.Measure (size); } return new Sized ; } Обратите внимание, что MeasureOverride () возвращает пустой объект SizeQ, а это означает, что Canvas вообще не требует никакого пространства. На вас возлагается задача указать явно размер Canvas или поместить его в контейнер компоновки, который растянется для того, чтоб заполнить все доступное пространство. Метод ArrangeOverrideO не намного сложнее. Чтобы определить правильное местоположение каждого элемента, Canvas использует присоединенные свойства (Left, Right, Top и Bottom). Как вы знаете из главы 4 (и увидите далее на примере WrapBreakPanel), присоединенные свойства реализованы двумя вспомогательными методами в определении класса: GetPropertyO и SetPropertyO. Рассматриваемый клон Canvas немного проще. Он имеет только два присоединенных свойства — Left и Тор (без излишних Right и Bottom). Ниже приведен код, используемый для размещения элементов. protected override Size ArrangeOverride (Size arrangeSize) { foreach (UIElement element in base.InternalChildren) { double x = 0; double у = 0; double left = Canvas.GetLeft(element); if ('DoubleUtil.IsNaN(left)) { x = left; } double top = Canvas.GetTop(element); if (!DoubleUtil.IsNaN(top)) { У = top; } element.Arrange(new Rect(new Point (x, y) , element.DesiredSize) ) ; } return arrangeSize; } Улучшенная панель WrapPanel Теперь, когда достаточно подробно изучена система панелей, стоит попробовать создать собственный контейнер компоновки, который добавит кое-что, отсутствующее в базовых панелях WPF. В этом разделе будет представлен пример, расширяющий возможности WrapPanel.
Глава 18. Пользовательские элементы 549 Панель WrapPanel выполняет простую функцию, которая оказывается весьма полезной. Она раскладывает свои дочерние элементы один за другим, переходя на следующую строку после заполнения текущей. В Windows Forms имеется подобный инструмент компоновки, называемый FlowLayoutPanel. В отличие от WrapPanel, панель FlowLayoutPanel обладает одной дополнительной возможностью — присоединенным свойством, которое могут использовать дочерние элементы для принудительного перевода строки. (Формально это не было присоединенным свойством, а свойством, добавленным поставщиком расширений, но эти две концепции являются аналогами.) Хотя WrapPanel не обеспечивает такой возможности, добавить ее нетрудно. Все, что для этого понадобится — это пользовательская панель с добавленным необходимым присоединенным свойством. Ниже показан код класса WrapBreakPanel с добавленным присоединенным свойством LineBreakBeforeProperty. Установленное в true, это свойство вставляет немедленный перенос строки перед элементом. public class WrapBreakPanel : Panel { public static DependencyProperty LineBreakBeforeProperty; static WrapBreakPanel () { FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata (); metadata.AffectsArrange = true; metadata.AffectsMeasure = true; LineBreakBeforeProperty = DependencyProperty.RegisterAttached ( "LineBreakBefore", typeof(bool), typeof(WrapBreakPanel), metadata); } } Подобно любому свойству зависимости, LineBreakBefore определяется как статическое поле и регистрируется в статическом конструкторе класса. Единственное отличие в том, что вместо Register () используется метод RegisterAttached(). Объект FrameworkPropertyMetadata для свойства LineBreakBefore специально указывает на то, что оно затрагивает процесс компоновки. В результате при каждой установке этого свойства будет инициирован новый проход компоновки. Присоединенные свойства не помещаются в нормальные оболочки свойств, поскольку они не устанавливаются в классе, определяющем их. Вместо этого потребуется предоставить два статических метода, которые смогут использовать метод DependencyObject.SetValue() для установки этого свойства в любой произвольный элемент. Код, необходимый для свойства LineBreakBefore, выглядит так: public static void SetLineBreakBefore(UIElement element, Boolean value) { element.SetValue(LineBreakBeforeProperty, value); } public static Boolean GetLineBreakBefore(UIElement element) { return (bool)element.GetValue(LineBreakBeforeProperty); } Единственное, что остается — принять во внимание это свойство при выполнении логики компоновки. Логика компоновки WrapBreakPanel основана на WrapPanel. Во время шага измерения элементы располагаются по строкам, так что при необходимости панель может вычислить размер общего пространства. Каждый элемент добавляется в текущую строку, если только он не слишком велик, чтобы уместиться в ней, и не установлено свойство LineBreakBefore. Ниже приведен полный код метода MeasureOvernde().
550 Глава 18. Пользовательские элементы protected override Size MeasureOverride(Size constraint) { Size currentLineSize = new Sized; Size panelSize = new Sized; foreach (UIElement element in base.InternalChildren) { element.Measure(constraint); Size desiredSize = element.DesiredSize; if (GetLineBreakBefore(element) || currentLineSize.Width + desiredSize.Width > constraint.Width) { // Перейти на новую строку (либо потому, что элемент требует, // либо потому, что закончилось место в текущей строке). panelSize.Width = Math.Max(currentLineSize.Width, panelSize.Width) ; panelSize.Height += currentLineSize.Height; currentLineSize = desiredSize; // Если элемент слишком широк, чтобы поместиться в ширину // строки, просто выделить ему отдельную строку, if (desiredSize.Width > constraint.Width) { panelSize.Width = Math.Max(desiredSize.Width, panelSize.Width); panelSize.Height += desiredSize.Height; currentLineSize = new Sized; } } else { // Продолжать добавление в текущую строку. currentLineSize.Width += desiredSize.Width; // Установить высоту строки по высоте ее максимального элемента. currentLineSize.Height = Math.Max(desiredSize.Height, currentLineSize.Height); } } // Вернуть размер, необходимый для размещения всех элементов. // Обычно это будет ширина ограничения, а высота // базируется на размерах элементов. // Однако если элемент шире, чем ширина панели, //то желаемая ширина будет шириной строки. panelSize.Width = Math.Max(currentLineSize.Width, panelSize.Width); panelSize.Height += currentLineSize.Height; return panelSize; } Ключевая деталь приведенного кода — проверка свойства LineBreakBefore. Это реализует дополнительную логику, которая не обеспечивается обычной панелью WrapPanel. Код ArrangeOverride () почти такой же, но несколько более утомительный. Отличие в том, что панель должна определить максимальную высоту строки (которая определяется по самому высокому элементу), прежде чем начать компоновку строки. Таким образом, каждый элемент получает полный объем доступного пространства, принимая во внимание полную высоту строки. Это тот же процесс, который применяется в компоновке обычной WrapPanel. Чтобы выяснить все подробности, обратитесь к загружаемому коду для данной главы. Использовать WrapBreakPanel просто. Ниже.приведен пример кода компоновки, демонстрирующего, что WrapBreakPanel корректно разделяет строки и вычисляет правильный желаемый размер на базе размеров дочерних элементов.
Глава 18. Пользовательские элементы 551 <StackPanel> <StackPanel.Resources> <Style TargetType=" {x.-Type Button }"> <Setter Property="Margin11 Value=ll3"></Setter> <Setter Property=,,Padding" Value=,,3,,x/Setter> </Style> </StackPanel.Resources> <TextBlock Padding=ll5" Background=llLightGray"> Content above the WrapBreakPanel. </TextBlock> <lib:WrapBreakPanel> <Button>No Break Here</Button> <Button>No Break Here</Button> <Button>No Break Here</Button> <Button>No Break Here</Button> <Button lib: WrapBreakPanel. LineBreakBefore="Tme" FontWeight=MBold"> Button with Break </Button> <Button>No Break Here</Button> <Button>No Break Here</Button> <Button>No Break Here</Button> <Button>No Break Here</Button> </lib:WrapBreakPanel> <TextBlock Padding=,,5" Background=,,LightGray"> Content below the WrapBreakPanel. </TextBlock> </StackPanel> Результат показан на рис. 18.7. i ' WrapPanelBreakTest ent above the WrapBreakPanel. н^уш»! :reakHere No Break Here No Break Here No Break Here 1 i Button with Break No Break nere No Break Here No Break Here Content below the WrapBreakPanel. No Break Here ' " 1 Рис. 18.7. Панель WrapBreakPanel в действии Рисованные элементы В предыдущем разделе начала раскрываться внутренняя "кухня" элементов WPF, а именно — методы MeasureOverrideO и ArrangeOverrideO, позволяющие включить любой элемент в систему компоновки WPF. В этом разделе будет показано, как элементы отображают сами себя. Большинство элементов WPF используют композицию для создания своего внешнего представления. Другими словами, типичный элемент строит себя из других, более
552 Глава 18. Пользовательские элементы фундаментальных элементов. На протяжении этой главы вы уже видели, как работает эта модель. Например, составные элементы пользовательского элемента управления определяются с помощью кода разметки, который обрабатывается таким же образом, как XAML-разметка пользовательского окна. Вы определяете визуальное дерево пользовательского элемента управления с применением управляющего шаблона. А при создании пользовательской панели какие-либо визуальные детали вообще не нужны. Составные элементы предоставляются потребителем и добавляются в коллекцию Children. Такой акцент отличается от того, что имел место в предшествующих технологиях построения пользовательских интерфейсов, таких как Windows Forms. В Windows Forms некоторые элементы управления рисуют себя, используя библиотеку User32, которая является частью Windows API, но наиболее специализированные элементы полагаются на классы рисования GDI+ для отображения себя "с нуля". Поскольку Windows Forms не предоставляет высокоуровневых графических примитивов, которые можно было бы добавить непосредственно к пользовательскому интерфейсу (вроде прямоугольников, эллипсов и путей WPF), всякий элемент управления, который нуждается в нестандартном визуальном представлении, требует специального кода отображения. Конечно, только композиция может завести вас так далеко. В конечном итоге, некоторые классы должны взять на себя ответственность за рисование содержимого. В WPF этот момент находится глубоко в дереве элементов. В типичном окне отображение состоит из индивидуальных фрагментов текста, фигур и растровых изображений, а не высокоуровневых элементов. Метод OnRender () Чтобы выполнить специальное отображение, элемент должен переопределить метод OnRender(), унаследованный от базового класса UIElement. Метод OnRender() не обязательно заменяет композицию. Некоторые элементы управления применяют OnRender () для рисования визуальных деталей и используют композицию для компоновки прочих элементов на своей поверхности. Примером может служить класс Border, который рисует свою рамку в методе OnRender(), и класс Panel, рисующий свой фон в методе OnRender (). Как Border, так и Panel поддерживают дочернее содержимое, и это содержимое отображается поверх специально отображаемых деталей. Метод OnRender () принимает объект DrawingContext, который предлагает набор полезных методов рисования содержимого. Впервые этот класс был упомянут в главе 14, когда он использовался для рисования содержимого объекта Visual. Ключевое отличие рисования в методе OnRender () состоит в том, что вы не создаете явно и не закрываете DrawingContext. Это связано с тем, что несколько разных методов OnRender () могут совместно использовать один и тот же DrawingContext. Например, элемент-наследник может выполнять некоторое специальное отображение и вызывать реализацию OnRender () в базовом классе для рисования дополнительного содержимого. Это работает, потому что WPF автоматически создает объект DrawingContext в начале этого процесса и закрывает его, когда он больше не нужен. На заметку! Формально метод OnRender () в действительности не рисует содержимое на экране. Вместо этого он рисует его в объекте DrawingContext, a WPF затем кэширует эту информацию. WPF определяет, когда элемент нуждается в перерисовке, и тогда выводит то, .что было создано в DrawingContext. В этом и состоит сущность графической системы WPR вы определяете содержимое, a WPF незаметно управляет его рисованием и обновлением. Наиболее удивительная деталь механизма отображения WPF заключается в том, что в действительности выполняют его совсем немного классов. Большинство классов построено на основе других, более простых классов, и нужно "нырнуть" достаточно глу-
Глава 18. Пользовательские элементы 553 боко в дерево элементов, чтобы найти класс, который действительно переопределяет метод OnRender (). Ниже описаны некоторые из таких классов. • Класс TextBlock. Всякий раз, когда рисуется текст, применяется объект TextBlock, использующий свой метод OnRender () для его отображения. • Класс Image. Класс Image переопределяет метод OnRender () для рисования содержимого изображения, используя метод DrawingContext.DrawImage(). • Класс MediaElement. Класс MediaElement переопределяет метод OnRender () для рисования видеокадра, если он используется для воспроизведения видеофайла. • Классы фигур. Базовый класс Shape переопределяет метод OnRender () для рисования своего внутреннего объекта Geometry с помощью метода DrawingContext. DrawGeometry (). Этот объект Geometry может представлять эллипс, прямоугольник или более сложные пути, состоящие из отрезков прямых и кривых линий, в зависимости от специфичного класса-наследника Shape. Многие элементы используют фигуры для рисования небольших визуальных деталей. • Классы Chrome. Классы, подобные ButtonChrome и ListBoxChrome, рисуют внешнее представление общего элемента управления и помещают указанное содержимое внутрь. Многие другие классы-наследники Decorator, такие как Border, также переопределяют метод OnRender (). • Классы панелей. Хотя содержимое панелей представлено ее дочерними элементами, метод OnRender () заботится о рисовании прямоугольника с цветом фона, если установлено свойство Background. Часто реализация OnRender () бывает обманчиво простой. Например, вот как выглядит код отображения любого класса-наследника Shape: protected override void OnRender(DrawingContext drawingContext) { this.EnsureRenderedGeometry() ; if (this._renderedGeometry != Geometry.Empty) { drawingContext.DrawGeometry(this.Fill, this.GetPen (), this._renderedGeometry)/ } } Вспомните, что переопределение OnRender () — не единственный способ отобразить содержимое и добавить его в пользовательский интерфейс. Можно также создать объект DrawingVisual и добавить его к UIElement с помощью метода AddVisualChildO (а также реализовать несколько других деталей, как было описано в главе 14). Затем можно вызвать DrawingVisual.RenderOpenO, чтобы извлечь DrawingContext и использовать его для рисования содержимого. Некоторые элементы применяют эту стратегию в WPF для отображения ряда графических деталей поверх остального содержимого элемента. Это можно видеть на примере указателей перетаскивания, индикаторов ошибок и рамок фокуса. Во всех этих случаях подход на основе DrawingVisual позволяет элементу рисовать поверх другого содержимого, а не под ним. Но все-таки в основном все отображение происходит в выделенном методе OnRender(). Выполнение специального рисования При создании собственных пользовательских элементов может быть отдано предпочтение переопределению метода OnRender () для рисования специального содержи-
554 Глава 18. Пользовательские элементы мого. Переопределить OnRenderO можно в элементе, включающем содержимое (чаще всего это класс-наследник Decorator), так, чтобы добавить некоторое графическое украшение вокруг содержимого. Или же можно переопределить OnRenderO в элементе, который не имеет никакого вложенного содержимого, чтобы нарисовать сразу все его визуальное представление. Например, можно создать собственный элемент, рисующий мелкие графические детали, который затем использовать в другом элементе управления через композицию. Примером в WPF может служить элемент TickBar, который рисует метки в Slider. Элемент TickBar встроен в визуальное дерево Slider через свой шаблон по умолчанию (наряду с Border и Track, которые включают два RepeatButton и Thumb). Возникает естественный вопрос: когда нужно использовать относительно низкоуровневый подход на основе OnRenderO, а когда применять композицию с другими классами (такими как элементы-наследники Shape) для рисования необходимого содержимого? Для решения потребуется оценить степень сложности необходимой графики и степень интерактивности, которую следует обеспечить. Например, рассмотрим класс ButtonChrome. В реализации WPF этого класса специальный код отображения принимает во внимание различные свойства, включая RenderDefaulted, RenderMouseOver и RenderPressed. Шаблон элемента управления по умолчанию для Button использует триггеры для установки этих свойств в соответствующее время, как было показано в главе 17. Например, это когда курсор мыши перемещается над кнопкой, класс Button использует триггер для установки свойства ButtonChrome.RenderMouseOverв true. Всякий раз при изменении свойств RenderDefaulted, RenderMouseOver или RenderPressed класс ButtonChrome вызывает базовый метод InvalidateVisualO, указывая, что его текущий внешний вид перестал быть актуальным. Затем WPF вызывает метод ButtonChrome.OnRenderO для получения нового графического представления. Если бы класс ButtonChrome применял композицию, такое поведение было бы труднее реализовать. Достаточно легко создать стандартный внешний вид для класса ButtonChrome, используя правильные элементы, но тогда понадобится больше работы для модификации, когда состояние кнопки изменяется. Пришлось бы динамически изменять все вложенные элементы, составляющие класс ButtonChrome, или же — если внешний вид изменится более радикально — скрывать один элемент и показывать другой на его месте. Большинство пользовательских элементов не нуждаются в специальной визуализации. Но если нужно отобразить сложные визуальные вещи, которые существенно изменяются при изменении свойств или выполнении некоторых действий, то подход на основе специальной визуализации может оказаться проще в применении и более легковесным. Элемент, выполняющий специальное рисование Зная, как работает метод OnRenderO, и когда его следует использовать, рассмотрим пользовательский элемент управления, который продемонстрирует его в действии. В показанном ниже коде определяется элемент по имени CustomDrawnElement, который демонстрирует простой эффект. Он рисует текстурированный фон с применением кисти RadialGradientBrush. Трюк состоит в том, что самая яркая точка, с которой начинается градиент, устанавливается динамически и следует за курсором мыши. Таким образом, когда пользователь перемещает курсор мыши над элементом, подсвеченная точка следует за ней, как показано на рис. 18.8.
Глава 18. Пользовательские элементы 555 ! '■ CustomDrawnElement НёЙ1ЯЁИГ) Рис. 18.8. Элемент, выполняющий специальное рисование Элемент CustomDrawnElement не нуждается в том, чтобы заключать в себе какое-то дочернее содержимое, поэтому он унаследован непосредственно от FrameworkElement. Он позволяет устанавливать только одно свойство — цвет фона градиента. (Цвет переднего плана жестко закодирован как белый, хотя это легко изменить.) public class CustomDrawnElement : FrameworkElement { public static DependencyProperty BackgroundColorProperty; static CustomDrawnElement() { FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata(Colors.Yellow); metadata.AffectsRender = true; BackgroundColorProperty = DependencyProperty.Register("BackgroundColor", typeof(Color), typeof(CustomDrawnElement), metadata); } public Color BackgroundColor { get { return (Color)GetValue(BackgroundColorProperty); } set { SetValue(BackgroundColorProperty, value); } } Свойство зависимости BackgroundColor специально помечено флагом FrameworkPropertyMetadata.AffectRender. В результате этого WPF автоматически вызывает OnRender() всякий раз при изменении цвета. Однако также понадобится обеспечить вызов метода OnRender (), когда курсор мыши перемещается в новую позицию. Это осуществляется вызовом метода InvalidateVisualO в нужные моменты: protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); this.InvalidateVisual(); } protected override void OnMouseLeave(MouseEventArgs e) { base.OnMouseLeave(e); this.InvalidateVisual(); }
556 Глава 18. Пользовательские элементы Единственное, что остается—код отображения. Он использует метод DrawingContext. DrawRectangle () для рисования фона элемента. Свойства ActualWidth и ActualHeight указывают финальные отображаемые размеры элемента. protected override void OnRender(DrawingContext dc) { base.OnRender(dc); Rect bounds = new Rect@, 0, base.ActualWidth, base.ActualHeight); dc.DrawRectangle(GetForegroundBrush(), null, bounds); } И, наконец, приватный вспомогательный метод по имени GetForegroundBrushO конструирует корректную кисть RadialGradientBrushHa основе текущей позиции курсора мыши. Чтобы вычислить центральную точку, понадобится преобразовать текущую позицию курсора мыши над элементом в относительную позицию от 0 до 1, которой ожидает RadialGradientBrush. private Brush GetForegroundBrushO \ { if ('IsMouseOver) { return new SolidColorBrush(BackgroundColor); } else { RadialGradientBrush brush = new RadialGradientBrush( Colors.White, BackgroundColor); // Получить позицию курсора мыши в независимых от устройства // единицах относительно самого элемента управления. Point absoluteGradientOrigin = Mouse.GetPosition (this); // Преобразовать координаты точки в пропорциональные (от 0 до 1) значения. Point relativeGradientOrigin = new Point( absoluteGradientOrigin.X / base.ActualWidth, absoluteGradientOrigin.Y / base.ActualHeight); // Изменить кисть. brush.GradientOrigin = relativeGradientOrigin; brush.Center = relativeGradientOrigin; return brush; } } } На этом пример завершен. Специальный декоратор Запомните главное правило: никогда не используйте специальное рисование в элементе управления. Если вы сделаете это, то тем самым нарушите основополагающий принцип элементов управления без внешнего вида в WPF. Проблема в том, что, однажды жестко закодировав некоторую логику рисования, вы гарантируете, что часть визуального представления элемента управления невозможно будет настроить с помощью шаблона. Намного лучший подход заключается в проектировании отдельного элемента, который рисует специализированное содержимое (вроде класса CustomDrawnElement из предыдущего примера), а затем применении его внутри шаблона по умолчанию для элемента управления. Такой подход используется во многих элементах управления WPF, и вы видели его в действии на примере элемента управления Button в главе 17.
Глава 18. Пользовательские элементы 557 Стоит мельком взглянуть, как можно адаптировать предыдущий пример, чтобы он мог работать как часть шаблона элемента управления. Элементы, выполняющие рисование самих себя, обычно играют две роли в шаблоне элемента управления: • рисуют некоторые мелкие графические детали (подобно стрелкам на кнопках прокрутки); • предоставляют более детализированный фон или рамку, окружающую другой элемент. Второй подход требует специального декоратора. Превратить CustomDrawnElement в рисующий себя элемент можно за счет внесения двух небольших изменений. Для начала унаследуйте его от Decorator: public class CustomDrawnDecorator : Decorator Затем переопределите метод OnMeasureO, чтобы указать требуемый размер. Просмотр всех своих дочерних элементов и добавление дополнительного пространства для декорации, а затем возврат суммарного размера является ответственностью всех декораторов. CustomDrawnDecorator не нуждается в дополнительном пространстве для рисования рамки. Вместо этого он просто устанавливает свой размер таким, какого требует его содержимое, используя для этого следующий код: protected override Size MeasureOverride(Size constraint) { UIElement child = this.Child; if (child != null) { child.Measure(constraint); return child.DesiredSize; } else { return new Size(); } } Однажды создав пользовательский декоратор, его можно применять в шаблоне элемента управления. Например, ниже представлен шаблон кнопки, который помещает градиентный фон, отслеживающий положение курсора мыши, за содержимым кнопки. Он использует привязку шаблона, чтобы гарантировать соответствие свойств выравнивания и дополнения. <ControlTemplate x:Key="ButtonWithCustomChrome"> <lib:CustomDrawnDecorator ВасkgroundCo1or="LightGreen"> <ContentPresenter Margin="{TemplateBinding Padding}" HonzontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" ContentTemplate="{TemplateBinding ContentControl.ContentTemplate}" Content="{TemplateBinding ContentControl.Content} " RecognizesAccessKey="True" /> </lib:CustomDrawnDecorator> </ControlTemplate> Теперь этот шаблон можно использовать для изменения стиля кнопок. Конечно, чтобы сделать такой декоратор более практичным, вероятно, стоит изменять его внешний вид по щелчку кнопкой мыши. Этого можно добиться с помощью триггеров, модифицирующих свойства класса Chrome. Исчерпывающие объяснения можно найти в главе 17.
558 Глава 18. Пользовательские элементы Резюме В настоящей главе подробно рассматривалась разработка пользовательского элемента управления в WPF. Вы увидели, как строятся базовые пользовательские элементы управления и расширяются существующие элементы управления WPF, а также как устроен "золотой стандарт" WPF — основанные на шаблонах элементы, лишенные внешнего вида. И, наконец, вы изучили специальное рисование и применение специально рисованного содержимого с элементами управления, основанными на шаблонах. Если вы планируете углубиться в мир разработки специализированных элементов управления, то найдете в Интернете некоторые замечательные примеры. Хорошей начальной точкой может стать набор примеров настройки элементов управления, включенный в .NET Framework SDK и доступный для отдельной загрузки по адресу http:// code.msdn.microsoft.com/wpfsamples#controlcustomization. Другой проект, который стоит загрузить и изучить — это проект примеров Bag-o-Tricks, предоставленный Кевином Муром (Kevin Moore), руководителем проекта в команде WPF, по адресу http:// j832.com/bagotricks. Этот проект включает все — от базового элемента управления датами, до панели со встроенной анимацией.
ГЛАВА 19 Привязка данных Привязка данных — это освященная годами традиция извлечения информации из объекта и отображения ее в пользовательском интерфейсе приложения без написания рутинного кода, который выполняет всю эту работу. Часто "толстые" клиенты используют двунаправленную привязку данных, что добавляет возможности "заталкивания'' информации из пользовательского интерфейса обратно в некоторый объект — опять же, с минимальным кодированием либо вообще без такового. Поскольку многие Windows-приложения связаны с данными (и все они в определенное время нуждаются во взаимодействии с данными), привязка данных находится в центре внимания такой технологии пользовательских интерфейсов, как WPF. Разработчики, пришедшие к WPF с опытом работы в Windows Forms, найдут в привязке данных WPF много схожего с тем, к чему они привыкли. Как и в Windows Fbrms, привязка данных WPF позволяет создавать привязки, которые извлекают информацию практически из любого свойства любого элемента. WPF также включает набор списочных элементов управления, которые могут обрабатывать целые коллекции информации и позволяют осуществлять навигацию по этой информации. Однако произошли и существенные изменения в способах привязки данных, которая происходит "за кулисами", появилась некоторая впечатляющая новая функциональность, а также возможности тонкой настройки. Многие концепции остались прежними, но код изменился. В этой главе рассматриваются способы применения привязки данных WPF Будут созданы декларативные привязки для извлечения нужной информации и отображения ее в элементах различных типов. Вы также узнаете, как подключать эту систему к базе данных. Что нового? Хотя основы привязки данных остаются прежними, в WPF 4 улучшена поддержка виртуализации и повторного использования контейнеров — два фактора, критичных для обеспечения хорошей производительности в очень больших списках. Об этом речь пойдет в разделе "Повышение производительности больших списков". Привязка пользовательских объектов к базе данных Когда разработчики слышат термин привязка данных, то они часто думают об одном специфическом ее применении — получение информации из базы данных и отображение ее на экране с минимальным объемом кодирования либо вообще без него. Как было показано в главе 8, привязка данных в WPF — намного более широкое понятие. Даже если приложение никогда не вступает в контакт с базой данных, все же сохраняется вероятность применения привязки данных для автоматизации взаи-
560 Глава 19. Привязка данных модействия элементов между собой или трансляции объектной модели в наглядное представление. Однако необходимо изучить множество деталей, касающихся привязки объектов, для чего будет рассмотрен традиционный пример, запрашивающий и обновляющий таблицу базы данных. В этой главе будет представлен пример, извлекающий каталог товаров. Первым шагом в построении этого примера станет создание специального компонента доступа к данным. На заметку! Загружаемый код для этой главы включает специальный компонент доступа к данным и сценарий, помещающий данные в базу, чтобы можно было протестировать все примеры. При отсутствии тестового сервера базы данных либо нежелании заниматься созданием новой базы, можно воспользоваться альтернативной версией компонента доступа к данным, также включенной в код. Эта версия просто загружает данные из файла, но предоставляет тот же набор классов и методов. Это отлично подходит для тестирования, но очевидно не годится для реального приложения. Построение компонента доступа к данным В профессиональных приложениях код работы с базой данных встроен в класс отделенного кода окна, но инкапсулирован в выделенном классе. Для еще лучшего разбиения на компоненты эти классы доступа к данным могут быть вообще исключены из приложения и скомпилированы в отдельный компонент DLL. Это справедливо, в частности, при написании кода доступа к базе данных (поскольку этот код имеет тенденцию быть чрезвычайно чувствительным к производительности), да и вообще это — признак хорошего проектного решения, независимо от местонахождения данных. Проектирование компонентов доступа к данным Независимо от того, как планируется использовать привязку данных (или она даже вообще не планируется), код доступа к данным всегда должен находиться в отдельных классах. Такой подход — единственный путь обеспечения возможности эффективного сопровождения, оптимизации, диагностики проблем и (необязательно) многократного использования кода работы с данными При создании класса доступа к данным необходимо следовать нескольким базовым правилам, которые описаны ниже. • Открывать и закрывать соединения быстро. Открывайте соединение с базой данных при каждом вызове метода и закрывайте перед завершением метода. Таким образом, соединения не останутся открытыми по неосторожности. Один из способов гарантировать закрытие соединения в надлежащее время — использовать блок using. • Реализовать обработку ошибок. Применяйте обработку ошибок, чтобы гарантировать закрытие соединений даже в случае возникновения исключений. • Следовать практике не поддерживающего состояние дизайна. Передавайте всю необходимую для метода информацию в его параметрах и возвращайте все извлеченные данные через значение возврата. Это позволит избежать усложнения во многих сценариях (например, если нужно создать многопоточное приложение или расположить компонент базы данных на сервере). • Хранить строку подключения в одном месте. В идеале таким местом должен быть конфигурационный файл приложения. Компонент базы данных, показанный в следующем примере, извлекает табличную информацию о товарах из базы данных Store, описывающей фиктивный магазин
Глава 19. Привязка данных 561 IBuySpy, которая поставляется вместе с рядом учебных примеров Microsoft. На рис. 19.1 показаны схемы двух таблиц из базы данных Store. Categories Column Name | Condensed Type a CategorylD int CategoryName nvarcharE0) v 1 ~ 1 V Column Name | Condensed Type ~ | ProductID nt CategorylD nt ModelNumber nvarcharE0) ModelName nvarcharE0) PvoducUttsagfc тоаг^п»{ЗД 1 UnitCost money Description nvarcharC800) ________ vl Рис. 19.1. Часть базы данных store Класс доступа к данным весьма прост — он предоставляет только один метод, позволяющий извлечь одну запись о товаре. Ниже показана фундаментальная структура. public class StoreDB { // Получить строку подключения из текущего конфигурационного файла. private string connectionString = Properties.Settings.Default.Store; public Product GetProduct(int ID) { Запрос выполняется с помощью хранимой процедуры по имени GetProduct. Строка соединения не кодируется жестко — вместо этого она извлекается из конфигурационного файла .config приложения. (Чтобы просмотреть или установить настройки приложения, дважды щелкните на узле Properties (Свойства) в окне Solution Explorer и перейдите на вкладку Settings (Настройка).) Когда другие окна нуждаются в данных, они вызывают метод StoreDB.GetProduct О для извлечения объекта Product. Специальный объект Product имеет единственное назначение — предоставлять информацию из одной строки таблицы Products. Он рассматривается в следующем разделе. Существует несколько способов сделать экземпляр StoreDB доступным окну приложения. • Окно может создавать экземпляр StoreDB всякий раз, когда ему понадобится доступ к базе данных. • Методы класса StoreDB можно сделать статическими. • Можно создать единственный экземпляр StoreDB и сделать его доступным через статическое свойство другого класса (следуя шаблону "фабрики"). Первые два варианта вполне уместны, но оба они ограничивают гибкость. Первый вариант исключает кэширование объектов данных для использования в нескольких окнах. Даже если вы не хотите применять кэширование, все же стоит так проектировать приложение, чтобы можно было легко реализовать кэширование в будущем. Аналогично, второй подход предполагает, что не будет никаких специфичных для экземпляра состояний, которые можно было бы удерживать в классе StoreDB. Хотя это хороший принцип проектирования, все же можно запоминать некоторые детали (такие как строка подключения). В случае преобразования класса StoreDB для использования
562 Глава 19. Привязка данных статических методов станет труднее обращаться к разным экземплярам базы данных Store в разных хранилищах данных. В конечном итоге третий вариант оказывается наиболее гибким. Он сохраняет подход "коммутатора", заставляя все окна работать через единственное свойство. Ниже приведен пример, который открывает доступ к экземпляру StoreDB всему классу Application: public partial class App : System.Windows.Application { private static StoreDB storeDB = new StoreDB (); public static StoreDB StoreDB { get { return storeDB; } } } В настоящей книге в первую очередь интересует, каким образом объекты данных могут быть привязаны к элементам WPF. Действительный процесс, имеющий дело с созданием и наполнением этих объектов данных (наряду с другими деталями реализации, такими как то, кэширует ли StoreDB данные между несколькими вызовами метода, использует ли хранимые процедуры вместо встроенных запросов, извлекает ли данные из локального XML-файла при отсутствии связи с базой, и т.д.), не является предметом внимания. Однако просто чтобы получить представление о том, что происходит, ниже приведен полный код. public class StoreDB { private string connectionString = Properties.Settings.Default.StoreDatabase; public Product GetProduct(int ID) { SqlConnection con = new SqlConnection(connectionString); SqlCommand cmd = new SqlCommand("GetProductBylD", con); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@ProductID", ID) ; try { con.Open (); SqlDataReader reader = cmd.ExecuteReader(CommandBehavior.SingleRow); if (reader.Read()) { // Создать объект Product, помещающий в оболочку текущую запись. Product product = new Product((string)reader["ModelNumber"] , (string)reader["ModelName"], (decimal)reader["UnitCost"], (string)reader["Description"], (string)reader["Productlmage"]); return(product); } else { return null; } } finally { con.Close (); } } }
Глава 19. Привязка данных 563 На заметку! В таком виде метод GetProductO не содержит кода обработки исключений, так что все они распространяются пузырьком в вызывающий код. Это разумное проектное решение, но вполне можно перехватывать исключения в GetProductO, выполнять необходимую очистку или протоколирование и затем повторно возбуждать исключение, чтобы уведомить вызывающий код о возникновении проблемы. Такой шаблон проектирования называется "Caller Inform" ("Информирование вызывающего"). Построение объекта данных Объект данных — это информационный пакет, предназначенный для отображения в пользовательском интерфейсе. Любой класс работает только при условии наличия у него общедоступных свойств (поля и приватные свойства не поддерживаются). Вдобавок, если этот объект должен использоваться для внесения изменений (через двунаправленную привязку), то такие свойства не могут быть доступными только для чтения. Ниже приведен код объекта Product, используемый StoreDB: public class Product { private string modelNumber; public string ModelNumber { get { return modelNumber; } set { modelNumber = value; } } private string modelName; public string ModelName { get { return modelName; } set { modelName = value; } } private decimal unitCost; public decimal UnitCost { get { return unitCost; } set { unitCost = value; } } private string description; public string Description { get { return description; } set { description = value; } } public Product(string modelNumber, string modelName, decimal unitCost, string description) { ModelNumber = modelNumber; ModelName = modelName; UnitCost = unitCost; Description = description; } } Отображение привязанного объекта Финальный шаг — создание объекта Product с последующей привязкой к элементам управления. Хотя объект Product можно создать и сохранить в ресурсах или ста-
564 Глава 19. Привязка данных тическом свойстве, оба подхода не имеют особого смысла. Вместо этого необходимо использовать StoreDB для создания соответствующего объекта во время выполнения с последующей привязкой к существующему окну. На заметку! Хотя декларативный подход без кода кажется более элегантным, существует немало веских причин добавлять немного кода в окна, привязанные к данным. Например, поскольку отправляются запросы в базу данных, возможно, понадобится поддерживать в коде соединение с ней, чтобы решать, как обрабатывать исключения и информировать пользователя о проблемах. Рассмотрим простое окно, показанное на рис. 19.2. Оно позволяет пользователю указывать код товара, после чего отображает соответствующий товар в контейнере Grid (в нижней части окна). jProductObject ! Product ID: 356 Model Number S'KYl Model Name: Edible Tape Unit Cost: Ш 3 9900 Description: The latest in personal survival gear, the STKY1 looks like a roll of ordinary off»ce tape, but can save your life in an emergency. Just remove the tape roll and place in a kettle of boiling water with mixed Щ vegetables and a ham shank. In just 90 minutes you jj$ have a great tasting soup that really sticks to your •*] ribs! Herbs and spices not included. ! ■J Рис. 19.2. Запрос товара Во время проектирования этого окна нет доступа к объекту Product, который поставит данные во время выполнения. Тем не менее, создавать привязки можно и без указания источника данных. Необходимо просто указать свойство класса Product, которое будет использовать каждый элемент. Ниже приведен код разметки для отображения объекта Product: <Grid Name="gridProductDetails"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"></ColumnDefinition> <ColumnDef initionx/ColumnDef inition> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="*"></RowDefinition> </Grid.RowDefinitions> <TextBlock Margin=">Model Number:</TextBlock> <TextBox Margin=" Grid.Column="l" Text="{Binding Path=ModelNumber}"></TextBox> <TextBlock Margin=" Grid.Row="l">Model Name:</TextBlock>
Глава 19. Привязка данных 565 <TextBox Margin=" Grid.Row="l" Grid.Column="l" Text="{Binding Path=ModelName}"></TextBox> <TextBlock Margin=" Grid.Row=">Unit Cost:</TextBlock> <TextBox Margin=" Grid.Row=" Grid.Column="l" Text="{Binding Path=UnitCost}"></TextBox> <TextBlock Margin=,1,1,0" Grid.Row=">Description:</TextBlock> <TextBox Margin=" Grid.Row=" Grid.Column=" Grid.ColumnSpan=M2" TextWrapping="Wrap" Text="{Binding Path=Description}"></TextBox> </Grid> Обратите внимание, что контейнер Grid, служащий оболочкой для всех этих деталей, имеет имя, так что им можно манипулировать в коде и завершить привязку данных. При первом запуске приложения никакая информация не отображается. Даже если определить необходимые привязки, никакого объекта источника не доступно. Когда пользователь щелкает на кнопке во время выполнения, с использованием класса StoreDB извлекаются данные о соответствующем товаре. Хотя каждую привязку можно создать программно, это не имеет особого смысла (и не сэкономит много кода по сравнению с заполнением элементов управления вручную). Однако свойство DataContext предлагает блестящую альтернативу. Если установить его для контейнера Grid, содержащего все выражения привязки данных, то все выражения будут использовать его для заполнения себя данными. Ниже приведен код обработки событий щелчка на кнопке пользователем: private void cmdGetProduct_Click(object sender, RoutedEventArgs e) { int ID; if (Int32.TryParse (txtlD.Text, out ID)) { try { gridProductDetails.DataContext = App.StoreDB.GetProduct(ID); } catch { // Ошибка подключения к базе данных MessageBox.Show("Error contacting database."); } } else { MessageBox.Show("Неверный ID."); } } Привязка с null-значениями Текущий класс Product предполагает получение полного комплекта данных о товаре. Однако таблицы базы данных часто включают поля, допускающие значения null, которые указывают на отсутствующую или неприменимую информацию. Это отражается в классах данных с помощью допускающих null типов данных для простых числовых значений или дат Например, в классе Product можно использовать decimal? вместо decimal. Естественно, ссылочные типы, такие как строки и полноценные объекты, всегда поддерживают значения null. Результат привязки значения null предсказуем — целевой элемент вообще ничего не отображает. Для числовых полей это поведение полезно, поскольку позволяет отличать отсутствующее значение (когда элемент не показывает ничего) от нулевого значения (когда показывается текст 0). Тем не менее, следует отметить, что есть возможность изменить способ отображения
566 Глава 19. Привязка данных WPF значений null, устанавливая свойство TargetNullValue в выражении привязки. Если сделать это, то вместо null будет отображаться указанное значение. Вот пример отображения текста [No Description Provided] (Описание не предоставлено), когда свойство Product. Decription равно null: Text="{Binding Path=Description, TargetNullValue=[No Description Provided]}" Квадратные скобки в тексте TargetNullValue не обязательны. В данном случае они служат для подсказки пользователю, что отображаемый текст не взят из базы данных. Обновление базы данных Для того чтобы включить обновления базы данных в этом примере, дополнительно ничего делать не понадобится. Как вы узнали раньше, свойство TextBox.Text использует двустороннюю привязку по умолчанию. В результате объект Product модифицируется в случае редактирования содержимого текстовых полей. (Формально каждое свойство обновляется при переходе на новое поле, поскольку в качестве режима обновления источника по умолчанию для свойства TextBox.Text установлен Lost Focus. Чтобы увидеть различные режимы обновления, поддерживаемые выражениями привязки, обращайтесь к главе 8.) Зафиксировать изменения в базе данных можно в любой момент. Все, что для этого понадобится — добавление метода UpdateProductO в класс StoreDB и кнопки Update (Обновить) в окно. При щелчке на ней код может получить текущий объект Product из контекста данных и воспользоваться им для фиксации обновления: private void cmdUpdateProduct_Click(object sender, RoutedEventArgs e) { Product product = (Product) gndProductDetails. DataContext; try { App.StoreDB.UpdateProduct(product); } catch { // Ошибка подключения к базе данных MessageBox.Show("Error contacting database."); } } С этим примером связана одна потенциальная загвоздка. После щелчка на кнопке Update фокус переходит к этой кнопке и все незафиксированные изменения в текстовых полях применяются к объекту Product. Однако если сделать кнопку Update кнопкой по умолчанию (установив свойство IsDefault в true), появится другая возможность. Пользователь сможет внести изменения в одно из полей и нажать клавишу <Enter>, чтобы запустить процесс обновления без фиксации последнего изменения. Во избежание такой ситуации необходимо явно передать фокус, прежде чем выполнять любой код базы данных: FocusManager.SetFocusedElement(this, (Button)sender); Уведомление об изменениях Пример с привязкой Product работает так хорошо потому, что каждый объект Product, по сути, фиксирован — он никогда не изменяется (за исключением ситуации, когда пользователь редактирует текст в одном из привязанных текстовых полей).
Глава 19. Привязка данных 567 Для простых сценариев, когда вы заинтересованы в первую очередь в отображении содержимого и разрешении пользователю редактировать его, такое поведение совершенно приемлемо. Однако несложно представить другую ситуацию, когда привязанный объект Product может модифицироваться где-то в другом месте кода. Например, щелчок на кнопке Increase Price (Увеличить цену) мог бы привести к выполнению следующего кода: product.UnitCost *= 1.1М; На заметку! Хотя объект Product можно извлечь из контекста данных, в этом примере предполагается, что Product также сохранен как переменная-член в классе окна, что упрощает код и требует меньшего количества приведений типов. Запустив этот код, вы обнаружите, что даже несмотря на то, что объект Product изменен, в текстовом поле остается старое значение. Это происходит потому, что текстовое поле не имеет никакой возможности узнать о том, что значение было изменено. Для решения этой проблемы применяются три подхода, которые описаны ниже. • Можно сделать каждое свойство класса Product свойством зависимости, используя синтаксис, который был показан в главе 6. (В рассматриваемом случае класс должен быть унаследован от DependencyObject.) Хотя такой подход заставляет WPF выполнять работу для вас (что прекрасно), он имеет больше смысла в элементах — классах с визуальным представлением в окне. Это не слишком естественный подход к классам данных вроде Product. • Можно инициировать событие для каждого свойства. В данном случае событие должно иметь имя ИмяСвойстваСЪ.ап^е& (например, UnitCostChanged). Генерирование события при изменении свойства — забота разработчика. • Можно реализовать интерфейс System.ComponentModel.INotifyPropertyChanged, что потребует единственного события по имени PropertyChanged. Затем понадобится инициировать это событие всякий раз, когда свойство изменяется, и указывать, какое именно свойство изменилось, передавая его имя в строке. При этом также генерация события при изменении свойств возлагается на разработчика, но уже не понадобится определять отдельное событие для каждого свойства. Первый подход полагается на инфраструктуру свойств зависимости WPF, в то время как второй и третий строятся на событиях. Обычно при создании объекта данных будет использоваться третий подход. Это простейший вариант для классов, отличных от элементов. На заметку! В действительности можно воспользоваться еще одним подходом. Если ожидаются изменения в привязанном объекте, а этот объект не поддерживает уведомления об изменениях любым другим допустимым способом, можно извлечь объект BindingExpression (применив метод FrameworkElement.GetBindingExpressionO) и вызвать BindingExpression. UpdateTargetO для активизации обновления. Очевидно, что это наиболее "корявое" решение — оно похоже на применение скотча, чтобы залатать дырку. Ниже приведено определение измененного класса Product, использующего интерфейс INotifyPropertyChanged, с кодом реализации события PropertyChanged: public class Product : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; public void OnPropertyChanged(PropertyChangedEventArgs e) {
568 Глава 19. Привязка данных if (PropertyChanged != null) PropertyChanged(this, e); } } Теперь просто понадобится инициировать событие PropertyChanged во всех установщиках значений свойств: private decimal unitCost; public decimal UnitCost { get { return unitCost; } set { unitCost = value; OnPropertyChanged(new PropertyChangedEventArgs("UnitCost")); } } Если используется эта версия класса Product из предыдущего примера, то получится ожидаемое поведение. При изменении текущего объекта Product новая информация появится в текстовом поле немедленно. Совет. В случае изменения нескольких значений можно вызвать метод OnPropertyChangedO и передать ему пустую строку. Это сообщит WPF о необходимости заново обработать выражения привязки, привязанные к любому свойству класса. Привязка к коллекции объектов Привязка к единственному объекту довольно проста. Но все становится намного интереснее, когда нужно привязаться к некоторой коллекции объектов, например, ко всем товарам в таблице. Хотя каждое свойство зависимости поддерживает привязку к одному значению, которая применялась до сих пор, привязка к коллекции требует несколько более интеллектуального элемента. В WPF все классы, унаследованные от Items Control, способны отображать целый список элементов. Возможностью привязки данных обладают ListBox, ComboBox, ListView и DataGrid (а также Menu и TreeView — для иерархических данных). Совет. Хотя может показаться, что WPF предлагает относительно небольшой набор списочных элементов управления, тем не менее, эти элементы предоставляют почти неограниченные возможности по отображению данных. Это связано с тем, что списочные элементы управления поддерживают шаблоны данных, которые позволяют непосредственно управлять отображением элементов списка. Подробнее о шаблонах данных будет рассказано в главе 20. Для поддержки привязки коллекций в классе ItemsControl определены три ключевых свойства, перечисленные в табл. 19.1. Здесь, возможно, возникает вопрос, какой именно тип коллекции можно поместить в свойство ItemSource?K счастью, практически любой. Все, что понадобится — это поддержка интерфейса IEnumerable, которую обеспечивают массивы, все типы коллекций и многие другие специализированные объекты, служащие оболочками для групп элементов. Однако поддержка, получаемая от базового интерфейса IEnumerable, ограничена привязкой только для чтения. Для редактирования коллекции (например, вставки и удаления), как вскоре будет показано, понадобится немного более сложная инфраструктура.
Глава 19. Привязка данных 569 Таблица 19.1. Свойства класса ItemsControl для привязки данных Имя Описание ItemsSource DisplayMemberPath ItemTemplate Указывает на коллекцию, содержащую все объекты, которые будут показаны в списке Идентифицирует свойство, которое будет применяться для создания отображаемого текста каждого элемента коллекции Принимает шаблон данных, который будет использован для создания визуального представления каждого элемента. Это свойство намного мощнее, чем DisplayMemberPath, и вы узнаете о его применении в главе 20 Отображение и редактирование элементов коллекции Рассмотрим окно, показанное на рис. 19.3, которое отображает список товаров. Когда товар выбран, информация о нем отображается в нижней части окна, где ее можно редактировать. (В данном примере GridSplitter позволяет подкорректировать место, выделенное для верхней и нижней части окна.) Чтобы создать этот пример, необходимо начать с построения логики доступа к данным. В данном случае метод StoreDB.GetProductsO извлекает список всех товаров базы из данных, используя хранимую процедуру GetProducts. Для каждой записи создается объект Product, который добавляется в обобщенную коллекцию List. (Здесь можно использовать любую коллекцию — например, массив или слабо типизированный ArrayList будут работать одинаково.) Ниже приведен код метода GetProducts(): public List<Product> GetProducts () • BindToCoHection •: Ram Racer 2000 j \ Edible Tape j; Escape Vehicle (Air) \ Extracting Tool Escape Vehicle (Water) :; Communications Device Multi-Purpose Rubber Band Universal Repair System Effective Flashlight ; The Incredible Versatile Paperclip WiM ^ Model Numt Modfl NMH Unit Cost: DuCfil * Persuade anyone to see your point of view* I Captivate your friends and enemies alike! Draw tne crime-scene or map out the chain of events. I All vou need is several years of tramino cr SqlConnection con = new SqlConnection(connectionString); SqlCormnand cmd = new SqlCormnand ("GetProducts", con) ; cmd.ComrnandType = CommandType . StoredProcedure; List<Product> products = new List<Product> () ; try Рис. 19.3. Список товаров con.Open (); SqlDataReader reader = cmd.ExecuteReader (); while (reader.Read()) { // Создать объект Product, являющийся // оболочкой для текущей записи. Product product = new Product((string)reader["ModelNumber"], (string)reader["ModelName"], (decimal)reader["UnitCost"], (string)reader["Description"], (string)reader["CategoryName"], (string) reader["Productlmage"]);
570 Глава 19. Привязка данных // Добавить в коллекцию, products.Add(product) ; } } finally { con.Close() ; } return products; } Когда осуществляется щелчок на кнопке Get Products (Извлечь товары), код обработки событий вызывает метод GetProductsO и применяет его в качестве ItemsSource для списка. Коллекция также сохраняется как переменная-член класса окна для облегчения доступа из любой части кода. private List<Product> products; private void cmdGetProducts_Click(object sender, RoutedEventArgs e) { products = App.StoreDB.GetProducts (); IstProducts.ItemsSource = products; } Это успешно наполняет список объектами Product. Однако список не знает, как отображать объект товара, поэтому он просто вызывает метод ToStringO. Поскольку этот метод не был переопределен в классе Product, это даст не впечатляющий результат — отображение полного квалифицированного имени класса для каждого элемента списка (рис. 19.4). Эту проблему можно решить тремя способами. • Установить свойство DisplayMemberPath списка. Например, установить его в ModelName, чтобы получить результат, показанный на рис. 19.4. • Переопределить метод ToStringO для возврата более полезной информации. Например, можно вернуть строку с номером и наименованием модели каждого элемента. Такой подход обеспечивает возможность отображения более одного свойства в списке (например, он замечательно подходит для комбинирования свойств FirstName и LastName в классе Customer). Однако и в этом случае управление представлением данных не является достаточным. • Применить шаблон данных. В этом случае можно отобразить любое размещение значений свойств (наряду с фиксированным текстом). Использование этого трюка описано в главе 20. Приняв решение, как будет отображаться информация в списке, можно заняться следующей проблемой — отображением подробностей текущего выбранного элемента списка в сетке, которая появляется под этим списком. С этой задачей можно справить- DataBinding.Product ; DataBinding.Product ; DataBinding.Product DataBinding.Product ;; DataBinding.Product I DataBinding.Product : DataBinding.Product •; DataBinding.Product DataBinding.Product DataBinding.Product iSindmg.Product Model Number: Model Name: O'mt Cost Description: i , -': || • --- Рис. 19.4. Бесполезный привязанный список
Глава 19. Привязка данных 571 ся, реагируя на событие SelectionChangedH вручную изменяя контекст данных сетки, но есть более быстрый способ, который вообще не требует никакого кода. Нужно просто установить выражение привязки для свойства Grid.DataContent, которое извлечет выбранный в списке объект Product, как показано ниже: <Grid DataContext="{Binding ElementName=lstProducts, Path=SelectedItem}"> </Grid> Когда окно отображается в первый раз, в списке ничего не выбрано. Свойство ListBox.Selectedltem равно null, а потому Grid.DataContext также равно null, и никакой информации не появляется. Как только выбран элемент в списке, контекст данных устанавливается в соответствующий объект, и вся информация тут же отображается. На удивление этот пример уже полностью функционален. Имеется возможность редактировать товары, перемещаться по списку и затем, вернувшись к предыдущей записи, удостовериться, что изменения были успешно зафиксированы. Фактически, можно даже изменить значение, которое затрагивает отображаемый в списке текст. Если модифицировать наименование модели и перейти с помощью клавиши <ТаЬ> на другой элемент управления, то соответствующая позиция в списке автоматически обновится. (Опытные разработчики оценят это преимущество, которого так не хватает приложениям Windows Forms.) Совет. Чтобы предотвратить возможность редактирования поля, установите свойство isLocked текстового поля в true или, что лучше — воспользуйтесь элементом управления, который разрешает только чтение (вроде TextBlock). Форма "главная-подробности" Как уже было показано, к свойству Selectedltem списка можно привязать другие элементы, чтобы отобразить дополнительные детали о выбранном элементе. Интересно то, что подобный прием позволяет строить формы "главная-подробности" для данных. Например, можно создать окно, которое показывает список категорий и список товаров. Когда пользователь выбирает в первом списке категорию, во втором списке отображаются только те товары, которые относятся к этой категории. Для этого понадобится родительский объект данных, который представляет коллекцию связанных дочерних объектов данных через некоторое свойство. Например, можно построить объект Category, предоставляющий свойство по имени Category.Products с товарами, относящимися к этой категории. (Пример класса Category с похожим дизайном приведен в главе 21.) Затем можно создать форму "главная-подробности", состоящую из двух списков. Заполните первый из них объектами Category. Чтобы показать связанные товары, привяжите второй список, отображающий товары, к свойству Selectedltem.Products первого списка. Это сообщит второму списку о необходимости взять текущий объект Category, извлечь его коллекцию связанных объектов Product и отобразить их. Пример применения взаимосвязанных данных представлен в главе 21, с TreeView, отображающим категоризированный список товаров. Разумеется, чтобы завершить этот пример с точки зрения приложения, нужно еще добавить некоторый код. Например, может понадобиться метод UpdateProductsO, принимающий коллекцию товаров и выполняющий соответствующие операторы. Поскольку обычный объект .NET не предоставляет никаких возможностей отслеживания изменений, в этой ситуации можете понадобиться элемент DataSet из ADO.NET
572 Глава 19. Привязка данных (это рассматривается позже в главе). В качестве альтернативы можно заставить пользователей обновлять записи по одной за раз. (Одним из вариантов может быть отключение списка при модификации текстового поля и принуждение пользователя отменять изменение щелчком на кнопке Cancel (Отмена) либо немедленно применять изменения щелчком на кнопке Update (Обновить).) Вставка и удаление элементов коллекций Одно из ограничений предыдущего примера заключается в том, что он не может отобразить изменения, внесенные в коллекцию. Он замечает измененные объекты Products, но не может обновить список в случае добавления нового элемента или удаления элемента в коде. Например, предположим, что добавлена кнопка Delete (Удалить), которая выполняет следующий код: private void cmdDeleteProduct_Click (object sender, RoutedEventArgs e) { products.Remove((Product)IstProducts.Selectedltem); } Удаленный элемент исключается из коллекции, но упорно остается видимым в привязанном списке. Чтобы включить отслеживание изменений коллекции, необходимо использовать коллекцию, реализующую интерфейс INotifyCollectionChanged. Большинство обобщенных коллекций этого не делают, в том числе и List<T>, которая применяется в приведенном примере. Фактически WPF включает единственную коллекцию, реализующую INotifyCollectionChanged — это класс ObservableCollection. На заметку! При наличии объектной модели, перенесенной из мира Windows Forms, можно воспользоваться эквивалентом ObservableCollection из Windows Forms, которым является BindingList. Коллекция BindingList реализует интерфейс IBindingList вместо INotifyCollectionChanged, который включает событие ListChanged, играющее ту же роль, что и событие INotifyCollectionChanged.CollectionChanged. Можно унаследовать собственную коллекцию от ObservableCollection, чтобы настроить по своему усмотрению ее работу, хотя это не обязательно. В данном примере достаточно заменить объект List<Product> на ObservableCollection<Product>, как показано ниже: public List<Product> GetProducts () { SqlConnection con = new SqlConnection(connectionString); SqlCommand cmd = new SqlCommand("GetProducts", con); cmd.CommandType = CommandType.StoredProcedure; ObservableCollection<Product> products = new ObservableCollection<Product>() ; В качестве типа возврата может быть оставлен List<Product>, потому что класс ObservableCollection унаследован от List. Чтобы сделать этот пример немного более универсальным, можно применить в качестве типа возврата ICollection<Proo!uct>, т.к. интерфейс ICollection включает в себя все необходимые члены. Теперь, если программно удалить или добавить элемент в коллекцию, список будет соответствующим образом обновлен. Естественно, по-прежнему придется создавать код доступа к данным, который происходит перед модификацией коллекции — например, код, удаляющий запись о товаре из лежащей в основе базы данных.
Глава 19. Привязка данных 573 Привязка объектов ADO.NET Все средства, применимые для специальных объектов, также работают с отключенными объектами данных ADO.NET. Например, можно создать такой же пользовательский интерфейс, как показанный на рис. 19.4, но использовать DataSet, DataTable и DataRow на заднем плане вместо специального класса Product и ObservableCollection. Чтобы попробовать это, начнем с рассмотрения версии, где метод Get Products () извлекает те же данные, но упаковывает их в DataTable: public DataTable GetProducts() { SqlConnection con = new SqlConnection (connectionStnng) ; SqlCommand cmd = new SqlCommand("GetProducts", con); cmd.CommandType = CommandType.StoredProcedure; SqlDataAdapter adapter = new SqlDataAdapter(cmd); DataSet ds = new DataSet (); adapter.Fill(ds, "Products"); return ds.Tables [0] ; } Этот объект DataTable можно извлечь и привязать к списку почти так же, как ObservableCollection. Единственное отличие состоит в том, что привязка непосредственно к самому объекту DataTable невозможна. Вместо этого придется пройти через посредника — DataView. Хотя DataView можно создать вручную, но каждый DataTable имеет готовый к применению объект DataView, доступный через свойство DataTable. DefaultView. На заметку! В этом ограничении нет ничего нового. Даже в приложении Windows Forms все привязки DataTable проходят через DataView. Разница в том, что в мире Windows Forms этот факт может быть скрыт. Это позволяет писать код, который выглядит так, будто привязка осуществляется непосредственно к DataTable, когда на самом деле используется объект DataView, взятый из свойства DataTable.DefaultView. Ниже показан необходимый код: private DataTable products; private void cmdGetProducts_Click(object sender, RoutedEventArgs e) { products = App.StoreDB.GetProducts(); IstProducts.ItemsSource = products.DefaultView; } Теперь список создаст отдельное вхождение для каждого объекта DataRow из коллекции DataTable.Rows. Чтобы определить, какое содержимое показано в списке, потребуется установить свойство DisplayMemberPath с именем поля, которое должно отображаться, или воспользоваться шаблоном данных (как описано в главе 20). Замечательный аспект этого примера заключается в том, что, однажды изменив код, извлекающий данные, никакие дополнительные модификации не понадобятся. Когда элемент выбирается в списке, то лежащий в основе Grid получает этот выбранный элемент для своего контекста данных. Код разметки, использованный с коллекцией ProductList, по-прежнему работает, поскольку имена свойств класса Product соответствуют именам полей DataRow. Другая замечательная особенность этого примера состоит в том, что дополнительные действия для реализации уведомлений об изменениях также не требуются. Дело в том, что класс DataView реализует интерфейс IBindingList, что позволяет ему
574 Глава 19. Привязка данных уведомлять инфраструктуру WPF о добавлении новых объектов DataRow и удалении существующих. Однако при удалении объекта DataRow следует быть немного внимательнее. Может показаться, что для удаления текущей выбранной записи подойдет код наподобие следующего: products.Rows.Remove((DataRow)IstProducts.Selectedltem); Такой код неверен по двум причинам. Во-первых, выбранный элемент в списке не является объектом DataRow. Это тонкая оболочка для DataRow, предоставленная DataView. Во-вторых, возможно, удалять DataRow из коллекции строк таблицы не следует. Вместо этого имеет смысл просто пометить его как удаленный, чтобы соответствующая запись в таблице базы данных была удалена только при фиксации изменений. Ниже приведен правильный код, который получает выбранный DataRowView, использует свойство Row для нахождения соответствующего объекта DataRow и вызывает метод Delete () для пометки строки с целью последующего удаления: ((DataRowView)IstProducts.Selectedltem).Row.Delete(); В этой точке запланированный к удалению DataRow исчезает из списка, даже несмотря на то, что формально он остается в коллекции DataTable.Rows. Так происходит потому, что установки фильтрации по умолчанию в DataView скрывают все удаленные строки. Дополнительные сведения о механизме фильтрации можно получить в главе 21. Привязка к выражению LINQ В WPF поддерживается язык интегрированных запросов (Language Integrated Query — LINQ), предлагающий синтаксис запросов общего назначения, который работает с широким разнообразием источников данных и тесно интегрирован в язык С#. Язык LINQ работает с любым источником, для которого имеется соответствующий поставщик LINQ. Поддержка, включенная в .NET, дает возможность использовать одинаково структурированные запросы LINQ для извлечения данных из коллекции, находящейся в памяти, из файла XML либо из базы данных SQL Server. Как и другие языки запросов, LINQ позволяет фильтровать, сортировать и трансформировать извлекаемые данные. Хотя LINQ выходит за рамки тематики настоящей книги, о нем можно многое узнать из простого примера. Например, предположим, что имеется коллекция объектов Product по имени products, и нужно создать вторую коллекцию, содержащую только те товары, цена которых превышает $100. Используя процедурный код, можно написать что-то вроде следующего: // Получить полный список товаров. List<Product> products = App. StoreDB.GetProducts (); // Создать вторую коллекцию с нужными товарами. List<Product> matches = new List<Product> (); foreach (Product product in products) { if (product.UnitCost >= 100) { matches.Add(product); } } С помощью LINQ получается следующее, намного более удобное выражение: // Получить полный список товаров. List<Product> products = App.StoreDB.GetProducts ();
Глава 19. Привязка данных 575 // Создать вторую коллекцию с подходящими товарами. IEnumerable<Product> matches = from product in products where product.UnitCost >= 100 select product; В этом примере используется LINQ to Collections, т.е. выражение LINQ служит для запроса данных из находящейся в памяти коллекции. Выражения LINQ используют набор новых ключевых слов языка, в том числе from, in и select. Эти ключевые слова LINQ составляют неотъемлемую часть языка С#. На заметку! Полное обсуждение LINQ не входит в перечень тем этой книги. За более детальной информацией обращайтесь к огромному каталогу примеров LINQ по адресу: http://tinyurl.com/y9vp4vu Язык LINQ вращается вокруг интерфейса IEnumerable<T>. Независимо от того, какие источники данных используются, каждое выражение LINQ возвращает объект, реализующий IEnumerable<T>. Поскольку интерфейс IEnumerable<T> расширяет IEnumerable, его можно привязать в окне WPF, как это делается с обычной коллекцией: IstProducts.ItemsSource = matches; В отличие от классов ObservableCollection и DataTable, в интерфейсе IEnumerable<T> не предусмотрены способы добавления или удаления элементов. Если нужна такая возможность, сначала придется преобразовать объект IEnumerable<T> в массив или коллекцию List, используя для этого метод ТоАггауО или ToList(). Ниже приведен пример использования ToListO для преобразования результатов запроса LINQ (показанного выше) в строго типизированную коллекцию List объектов Product: List<Product> productMatches = matches.ToList () ; На заметку! ToListO — метод расширения, а это значит, что он определен в другом классе, отличном оттого, в котором применяется. Формально ToListO определен во вспомогательном классе System.Linq.Enumerable и доступен всем объектам IEnumerable<T>. Однако он не будет доступен, если в текущем контексте отсутствует класс Enumerable, т.е. показанный выше код не сможет работать без импорта пространства имен System.Linq. Метод ToListO вызывает немедленную оценку выражения LINQ. В результате получается обычная коллекция, с которой можно работать привычным способом. Например, ее можно поместить в оболочку ObservableCollection и получать события уведомлений, так что любые изменения будут немедленно отражаться в связанных элементах управления: ObservableCollection<Product> productMatchesTracked = new ObservableCollection<Product>(productMatches); После этого коллекцию productMatchesTracked можно привязать к элементу управления в окне. Проектирование форм данных в Visual Studio Написание кода доступа к данным с заполнением десятков выражений привязки может отнимать много времени. При создании нескольких WPF-приложений, работающих с базами данных, скорее всего, обнаружится, что приходится вновь и вновь писать сходный код и разметку. Именно
576 Глава 19. Привязка данных по этой причине в Visual Studio включена возможность генерации кода доступа к данным и автоматической вставки элементов управления, привязанных к данным. Для использования этих средств, прежде всего, понадобится создать источник данных Visual Studio. (Источник данных — это определение, позволяющее Visual Studio распознать конечного поставщика данных и обеспечить службы генерации кода, использующие его.) Можно создать источник данных, в который упакована база данных, веб-служба, существующий класс доступа к данным или сервер SharePoint. Чаще всего создается сущностная модель данных, которая представляет собой набор сгенерированных классов, моделирующих таблицы базы данных, и позволяет опрашивать их подобно тому, как это делают компоненты доступа к данным, используемые в настоящей главе. Преимущества очевидны — сущностная модель данных позволяет избежать написания зачастую утомительного кода данных. Недостатки тоже очевидны — если необходимо, чтобы логика данных работала именно так, как нужно, придется потратить некоторое время на настройку, нахождение соответствующих точек расширения и разбор длинного кода. К примерам ситуаций, когда может понадобиться более тонкий контроль над логикой доступа к данным, можно отнести необходимость работы с хранимыми процедурами, кэширование результатов запросов, реализация специфической стратегии параллелизма либо протоколирование операций доступа к данным. Все это возможно с сущностной моделью данных, но потребует больше работы и может сократить преимущества автоматической генерации кода. Для создания источника данных выберите пункт меню Data^Add New Data Source (Данные1^Добавить новый источник данных) для запуска мастера Data Source Configuration (Конфигурирование источника данных), который предложит выбрать источник данных (в данном случае — базу данных) и затем запросит дополнительную информацию (такую как таблицы и поля). Добавив источник данных, можно использовать окно Data Source (Источник данных) для создания привязанных элементов управления. Базовый подход предельно прост. Сначала следует выбрать пункт меню Data^Show Data Sources (Данные1^Показать источники данных), чтобы открыть окно Data Source, в котором будут присутствовать указанные ранее таблицы и поля. Затем можно перетащить индивидуальное поле из окна Data Source на поверхность проектируемого окна (чтобы создать привязанный TextBlock, TextBox, ListBox или другой элемент управления) либо перетащить целую таблицу (чтобы создать привязанный элемент DataGrid или ListView). Средства форм данных WPF предоставляют простой и быстрый способ построения приложений, управляемых данными, но не объясняют, что в них на самом деле происходит. Они могут быть хорошим выбором, когда необходим простой просмотр данных или их редактирование, и нет желания тратить много времени на оттачивание средств и тонкую настройку пользовательского интерфейса. Часто они вполне подходят для построения удобных бизнес-приложений. Дополнительные сведения по этой теме можно найти в официальной документации по адресу http://msdn. microsoft.com/ru-ru/library/msl71923(v=VS. 100) .aspx. Повышение производительности больших списков Если приходится иметь дело с крупными объемами данных, например, десятками тысяч записей, то хорошая система привязки данных требует более сложных средств. Она должна уметь обрабатывать огромные объемы данных, не замедляя приложение и не поглощая невероятные объемы памяти. К счастью, списочные элементы управления WPF хорошо оптимизированы. В следующих разделах рассматриваются улучшения производительности больших списков, поддерживаемых списочными элементами управления WPF (т.е. всеми элементами, унаследованными от ItemsControl), включая непритязательные ListBox и ComboBox, а также более специализированные ListView, TreeView и DataGrid, которые будут описаны в главе 22.
Глава 19. Привязка данных 577 Виртуализация Наиболее важное из средств, предоставляемых списочными элементами управления WPF, является виртуализация пользовательского интерфейса — прием, при котором список создает контейнерные объекты только для текущих отображаемых элементов. Например, если есть элемент управления ListBox с 50 000 записей, но видимая область состоит только из 30 записей, то ListBox создаст лишь 30 объектов ListBoxItem (плюс несколько для обеспечения хорошей производительности прокрутки). Если бы ListBox не поддерживал виртуализацию пользовательского интерфейса, пришлось бы генерировать полный набор из 50 000 объектов ListBoxItem, которые заняли бы значительный объем памяти. И что более важно, размещение этих объектов отняло бы довольно много времени, и при установке свойства ListBox.ItemsSource происходила бы заметная задержка в работе приложения. На самом деле поддержка виртуализации не встроена в класс ListBox или ItemsControl. Вместо этого она зашита в контейнер VirtualizingStackPanel, который функционирует подобно StackPanel, за исключением дополнительных преимуществ поддержки виртуализации. ListBox, ListView и DataGrid автоматически используют VirtualizingStackPanel для расположения своих дочерних элементов. В результате для получения поддержки виртуализации никаких дополнительных действий предпринимать не понадобится. Однако ComboBox использует стандартный контейнер StackPanel без виртуализации. Если нужна поддержка виртуализации и в этом элементе управления, можно добавить ее, применив новый шаблон ItemsPanelTemplate, как показано ниже: <ComboBox> <ComboBox.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanelx/VirtualizingStackPanel> </ItemsPanelTemplate> </ComboBox.ItemsPanel> </ComboBox> TreeView (см. главу 22) — еще один элемент управления, поддерживающий виртуализацию, однако по умолчанию она отключена. Дело в том, что в ранних выпусках WPF панель VirtualizingStackPanel не поддерживала иерархические данные. Теперь она поддерживает, но в TreeView это средство отключено для обеспечения гарантированной обратной совместимости. К счастью, он включается простой установкой единственного свойства, и это рекомендуется делать для деревьев с большим объемом данных: <TreeView VirtualizingStackPanel.IsVirtualizing="True" ... > Множество факторов могут разрушить виртуализацию пользовательского интерфейса, причем иногда вопреки ожиданиям. • Помещение списочного элемента управления в ScrollViewer. Элемент управления ScrollViewer предоставляет окно для своего дочернего содержимого. Проблема в том, что дочернему содержимому отводится неограниченное "виртуальное" пространство. В этом виртуальном пространстве ListBox визуализирует себя в полный размер, отображая все свои дочерние элементы. В качестве побочного эффекта каждый элемент получает собственный занимающий память объект ListBoxItem. Эта проблема возникает каждый раз, когда ListBox помещается в контейнер, который не пытается ограничить свои размеры; например, то же самое случается, если поместить его в StackPanel вместо Grid. • Изменение шаблона списочного элемента управления и отказ от применения ItemsPresenter. Элемент ItemsPresenter использует шаблон
578 Глава 19. Привязка данных ItemsPanelTemplate, который указывает VirtualizingStackPanel. Если нарушить это отношение или самостоятельно изменить ItemsPanelTemplate так, чтобы он не использовал VirtualizingStackPanel, то средство виртуализации будет утрачено. • Использование группирования. Группирование автоматически конфигурирует список на использование прокрутки по пикселям, а не прокрутки по элементам. (Разница объясняется в главе 6 при описании элемента управления ScrollViewer.) Когда включена пиксельная прокрутка, виртуализация невозможна, по крайней мере, в настоящем выпуске WPF. • Не использование привязки данных. Это должно быть очевидным, но при заполнении списка программно — например, динамически создавая нужные объекты List Box Item — никакая виртуализация не происходит Разумеется, можно воспользоваться собственной стратегией оптимизации, такой как создание только необходимых объектов и только в то время, когда они нужны. Этот прием демонстрируется в действии в главе 22 на примере с элементом TreeView, использующим динамическое создание узлов для заполнения дерева каталогов. Имея дело с очень большим списком, для получения приемлемой производительности следует избегать подобных ситуаций. И даже когда применяется виртуализация пользовательского интерфейса, все равно приходится платить определенную цену за создание экземпляров объектов данных в памяти. Например, в примере с ListBox, который содержит 50 000 элементов, где-то имеются 50 000 объектов данных, каждый из которых хранит информацию об одном товаре, заказчике, записи в заказе или о чем- то еще. Чтобы оптимизировать эту часть приложения, стоит подумать о виртуализации данных — приеме, который подразумевает извлечение сразу множества записей. Виртуализация данных — более сложная техника, потому что в ней предполагается, что стоимость извлечения данных ниже, чем стоимость их обслуживания. Это может быть и не так, в зависимости от абсолютного размера данных и времени, необходимого на их извлечение. Например, если приложение постоянно подключено к сетевой базе данных для извлечения дополнительной информации о товарах по мере прокрутки списка пользователем, в конечном итоге может снизиться производительности прокрутки и увеличиться нагрузка на сервер базы данных. В настоящее время в WPF отсутствуют элементы управления или классы, поддерживающие виртуализацию данных. Однако это не мешает разработчикам самостоятельно создавать недостающие части, например, фиктивную коллекцию, которая имитирует хранение всех элементов, но не запрашивает источник данных заднего плана до тех пор, пока эти данные не понадобятся элементу управления. С серьезными примерами можно ознакомиться по адресам http://bea.stollnitz.com/blog/?p=344 и http:// bea.stollnitz.com/blog/?p=378. Повторное использование контейнера элементов Выпуск WPF 3.5 SP1 подкрепил средство виртуализации повторным использованием контейнера элементов. Обычно при прокрутке виртуализированного списка элемент управления непрерывно создает новые контейнерные объекты для хранения элементов, ставших видимыми. Например, при прокрутке ListBox из 50 000 элементов будет генерировать новые объекты ListBoxItem. Но если включить повторное использование элементов контейнера, то ListBox будет хранить относительно небольшой набор актуальных объектов ListBoxItem и просто повторно использовать их для разных строк, загружая в них новые данные по мере прокрутки. <ListBox VirtualizingStackPanel.VirtualizationMode="Recycling" ... >
Глава 19. Привязка данных 579 Повторное использование контейнера элементов повышает производительность прокрутки и сокращает потребление памяти, потому что нет необходимости в постоянном нахождении сборщиком мусора старых объектов и освобождения их. Опять-таки, это средство по умолчанию отключено для всех элементов управления кроме DataGrid, что позволяет обеспечить обратную совместимость. Однако, имея дело с большим списком, повторное использование контейнера элементов следует всегда включать. Отложенная прокрутка Чтобы еще более повысить производительность прокрутки, можно включить отложенную прокрутку. В этом случае, когда пользователь перетаскивает ползунок по линейке прокрутки, список не обновляется. Он обновляется только однажды — когда пользователь отпускает ползунок. Для сравнения: при обычной прокрутке список обновляется постоянно, пока ползунок перетаскивается, отображая изменяющуюся позицию. Как и в случае повторного использования контейнера элементов, отложенная прокрутка должна быть включена явно: <ListBox ScrollViewer.IsDeferredScrollingEnabled="True" ... /> Ясно, что здесь приходится идти на компромисс между отзывчивостью интерфейса и простотой использования. При наличии сложных шаблонов и больших объемов данных, отложенная прокрутка может оказаться предпочтительной для обеспечения высокой скорости. Но в противном случае пользователям понадобится возможность видеть, куда они попадают в процессе прокрутки. Проверка достоверности Еще одной ключевой составляющей любого сценария привязки данных является проверка достоверности. Другими словами, это логика, перехватывающая некорректные значения и отклоняющая их. Проверку достоверности можно встраивать непосредственно в элементы управления (например, реагируя на ввод в текстовом поле и отклоняя недопустимые символы), но такой низкоуровневый подход ограничивает гибкость. К счастью, WPF предоставляет средство проверки достоверности, работающее подобно системе привязки данных. Проверка достоверности предлагает два дополнительных варианта выбора для перехвата неверных значений. • Можно генерировать ошибки в своем объекте данных. Чтобы уведомить WPF об ошибке, просто сгенерируйте исключение в процедуре установки свойства. Обычно WPF игнорирует любые исключения, которые возбуждаются при установке свойств, но можно сконфигурировать его для отображения более полезных сведений. Другой выбор — реализовать интерфейс IDataErrorlnf о в классе данных, что даст возможность обозначать ошибки, не генерируя исключений. • Можно определить проверку достоверности на уровне привязки. Это обеспечивает гибкость для использования одной и той же проверки достоверности, независимо от элемента управления вводом. Еще лучше, что поскольку проверка достоверности определяется в отдельном классе, можно легко повторно использовать ее с множеством привязок, которые имеют дело с однотипными данными. Обычно первый подход применяется, если объекты данных уже обладают встроенной логикой проверки достоверности в своих процедурах установки свойств, и нужно воспользоваться преимуществами этой логики. Второй подход используется, когда логика проверки достоверности реализуется впервые, и ее необходимо многократно применять в разных контекстах с различными элементами управления. Однако некоторые
580 Глава 19. Привязка данных разработчики пользуются обоими приемами. Они применяют проверку достоверности в объекте данных для защиты от небольшого набора фундаментальных ошибок и реализуют проверку достоверности привязки для перехвата более широкого диапазона ошибок пользовательского ввода. На заметку! Проверка достоверности применяется только тогда, когда значение из целевого элемента используется для обновления источника. Другими словами, в ситуациях с привязкой TwoWay или OneWayToSource. Проверка достоверности в объекте данных Некоторые разработчики встраивают проверку ошибок непосредственно в свои объекты данных. Например, ниже приведена модифицированная версия свойства Product. UnitPrice, которое отклоняет отрицательные числа: public decimal UnitCost { get { return unitCost; } set { if (value < 0) // UnitCost не может быть отрицательным. throw new ArgumentException ("UnitCost cannot be negative.11); else { unitCost = value; OnPropertyChanged (new PropertyChangedEventArgs("UnitCost")); } } } Логика проверки достоверности, показанная в предыдущем примере, предотвращает отрицательные значения для цены, но не дает пользователю никакой информации о причинах проблемы. Как уже известно, WPF молча игнорирует ошибки привязки данных, которые происходят при установке и чтении свойств. В этом случае некорректное значение останется в текстовом поле — оно просто не появится в привязанном объекте данных. Чтобы изменить эту ситуацию, понадобится помощь класса ExceptionValidationRule, который рассматривается ниже. Объекты данных и проверка достоверности Хорошо это или плохо — помещать логику проверки достоверности в объект данных — является предметом непрекращающихся споров. Этот подход обладает определенными преимуществами — например, позволяет всегда перехватывать все ошибки, вызваны они неправильным пользовательским вводом, ошибкой программы, либо получены в результате вычислений на основе неверных данных. Однако здесь есть и недостаток — усложнение объектов данных и перемещение кода проверки достоверности, предназначенного для интерфейсной части приложения, вглубь модели данных заднего плана. При неаккуратном применении проверка достоверности свойств может помешать совершенно корректному использованию объекта данных. Это может также привести к несоответствиям и на самом деле к составным ошибкам данных. (Например, возможно, и не имеет смысла для UnitslnStock принимать значение -10, но если такое значение хранится в базе данных, все равно понадобится создать соответствующий объект Product, чтобы отредактировать это зна-
Глава 19. Привязка данных 581 чение в пользовательском интерфейсе.) Иногда подобные проблемы решаются созданием еще одного уровня объектов — например, в сложной системе разработчики могут построить развитую модель бизнес-объектов, находящуюся поверх уровня примитивных объектов данных. В данном примере классы StoreDB и Product спроектированы как часть компонента доступа к данным заднего плана. В этом контексте класс Product служит просто упаковкой, позволяющей передавать информацию от одного уровня кода к другому. По этой причине код проверки достоверности в действительности не относится к классу Product. Класс ExceptionValidationRule Класс ExceptionValidationRule — это предварительно построенное правило проверки достоверности, которое заставляет WPF сообщать обо всех исключениях. Чтобы использовать ExceptionValidationRule, его понадобится добавить в коллекцию Binding.ValidationRules, как показано ниже: <TextBox Margin=,,5" Grid. Row=,,2" Grid. Column=,,l"> <TextBox.Text> <Binding Path="UnitCostll> <Binding.ValidationRules> <Except l onValidationRulex/Except lonVal ida tionRule> </Binding.ValidationRules> </Binding> </TextBox.Text> </TextBox> В этом примере используется и конвертер значений, и правило проверки достоверности. Обычно проверка достоверности производится перед преобразованием значений, но в этом отношении ExceptionValidationRule является специальным случаем. Он перехватывает исключения, возникающие в любом месте, в том числе исключения, связанные с невозможностью приведения введенного значения к корректному типу данных, исключения, сгенерированные установщиком свойств, и исключения, сгенерированные конвертером значений. Итак, что же произойдет, если проверка достоверности не прошла? Ошибки проверки достоверности записываются с использованием присоединенных свойств класса System.Windows.Controls.Validation. Для каждого нарушенного правила проверки достоверности WPF предпринимает описанные ниже шаги. • Устанавливает присоединенное свойство Validation.HasErrorBtrueHa связанном элементе (в данном случае — элементе управления Text Box). • Создает объект ValidationErrorc подробностями об ошибке (возвращенными из метода ValidationRule.ValidateO) и добавляет его в присоединенную коллекцию Validation.Errors. • Если свойство Binding.NotifyOnValidationError установлено в true, WPF инициирует в элементе событие Validation.Error. В случае возникновения ошибки визуальное представление элемента управления также изменяется. WPF автоматически переключает шаблон, используемый элементом управления, когда его свойство Validation.HasError принимает значение true, на шаблон, определенный в свойстве Validation.ErrorTemplate. В текстовом поле новый шаблон окрашивает контур рамки в красный цвет. В большинстве случаев возникнет желание как-то усилить индикацию ошибки и выдать определенную информацию об ошибке, послужившей причиной проблемы. Можно использовать код, обрабатывающий событие Error, или же применить собственный шаблон элемента управления, который обеспечивает другую визуальную инди-
582 Глава 19. Привязка данных нацию. Но прежде чем опробовать любой из этих вариантов, стоит рассмотреть другой способ, которым WPF позволяет перехватывать ошибки — использование интерфейса IDataErrorlnfo в объектах данных и написание собственных специальных правил проверки достоверности. Класс DataErrorValidationRule Многие приверженцы объектно-ориентированного программирования предпочитают не генерировать исключения для индикации ошибок ввода. На то имеется несколько причин, включая следующие: ошибка пользовательского ввода не является исключительной ситуацией; ошибочные условия могут зависеть от взаимодействия значений множества свойств; иногда стоит сохранить некорректные значения для дальнейшей обработки вместо того, чтобы немедленно отклонять их. В мире Windows Forms разработчики могут использовать интерфейс IDataErrorlnfo (из пространства имен System.ComponentModel), чтобы избегать исключений, но сохранять код проверки достоверности в классе данных. Интерфейс IDataErrorlnfo был изначально спроектирован для поддержки Grid-подобных элементов управления, таких как DataGridView, однако он также работает и с универсальными решениями для выдачи отчетов об ошибках. Хотя интерфейс IDataErrorlnfo не поддерживался в первом выпуске WPF, он поддерживается в WPF 3.5. Интерфейс IDataErrorlnfo требует наличия двух членов: строкового свойства по имени Error и строкового индексатора. Свойство Error предоставляет общую строку описания всего объекта (которая может выглядеть как "Invalid Data" ("Неверные данные")). Строковый индексатор принимает имя свойства и возвращает соответствующую детальную информацию об ошибке. Например, если строковому индексатору передать "UnitCost", то можно получить ответ вроде "The UnitCost cannot be negative" ("Значение UnitCost не может быть отрицательным"). Ключевая идея состоит в том, что свойства устанавливаются нормально, без сложностей, а индексатор позволяет пользовательскому интерфейсу проверить недопустимые данные. Все логика обработки ошибок для целого класса централизована в одном месте. Ниже приведена пересмотренная версия класса Product, который реализует интерфейс IDataErrorlnfo. Хотя IDataErrorlnfo можно использовать для вывода сообщений проверки достоверности для широкого диапазона связанных с этой проверкой проблем, данная логика проверяет наличие ошибок только в одном свойстве — ModeNumber. public class Product : INotifyPropertyChanged, IDataErrorlnfo { private string modelNumber; public string ModelNumber { get { return modelNumber; } set { modelNumber = value; OnPropertyChanged(new PropertyChangedEventArgs("ModelNumber")); } } // Здесь осуществляется обработка ошибок, public string this[string propertyName] { get { if (propertyName == "ModelNumber") { bool valid = true;
Глава 19. Привязка данных 583 foreach (char с in ModelNumber) { if ('Char.IsLetterOrDigit(с)) { valid = false; break; } } if ('valid) // ModelNumber может содержать только буквы и цифры, return "The ModelNumber can only contain letters and numbers."; } return null; } } // WPF не использует это свойство, public string Error { get { return null; } } } Чтобы заставить WPF использовать интерфейс IDataErrorlnfo и применять его для проверки ошибок при модификации свойства, понадобится добавить DataErrorValidationRule к коллекции правил Binding.ValidationRules, как показано ниже: <TextBox Margin=,,5" Grid.Column=,,l"> <TextBox.Text> <Binding Path=llModelNumberll> <Binding.ValidationRules> <DataErrorValidationRuleX/DataErrorValidationRule> </Binding.ValidationRules> </Binding> </TextBox.Text> </TextBox> Кстати, можно комбинировать оба подхода, создавая объект данных, который генерирует исключения для некоторых типов ошибок и использует IDataErrorlnfo для сообщения о других. Нужно лишь позаботиться о применении как ExceptionValidationRule, так и DataErrorValidationRule. Совет. Платформа .NET предлагает два сокращения. Вместо добавления к привязке ExceptionValidationRule можно установить свойство Binding.ValidatesOnExceptions в true. Вместо добавления DataErrorValidationRule можно установить в true свойство Binding. ValidatesOnDataEr r or s. Специальные правила проверки достоверности Подход с применением специального правила проверки достоверности подобен использованию специального конвертера. В этом случае определяется класс-наследник ValidationRule (из пространства имен System.Windows.Controls) и его метод Validate () переопределяется для выполнения требуемой проверки достоверности. При необходимости можно добавить свойства, принимающие другие детали, которые влияют на проверку достоверности (например, правило, определяющее, что текст может включать свойство CaseSensitive булевского типа).
584 Глава 19. Привязка данных Ниже приведен полный код правила проверки достоверности, ограничивающего десятичные значения некоторым диапазоном. По умолчанию минимум устанавливается в О, а максимум — в наибольшее число, которое умещается в десятичный тип данных, поскольку это правило предназначено для работы с денежными значениями. Однако для максимальной гибкости обе эти детали являются конфигурируемыми через свойства. public class PositivePriceRule : ValidationRule { private decimal min = 0; private decimal max = Decimal.MaxValue; public decimal Min { get { return min; } set { min = value; } } public decimal Max { get { return max; } set { max = value; } } public override ValidationResult Validate(object value, Culturelnfo culturelnfo) { decimal price = 0; try { if (((string)value).Length > 0) price = Decimal.Parse((string)value, NumberStyles.Any, culture); } catch () { // Недопустимые символы. return new ValidationResult (false, "Illegal characters."); } if ( (price < Min) | | (price > Max) ) { // Выход за пределы диапазона, return new ValidationResult(false, "Not in the range " + Min + " to " + Max + "."); } else { return new ValidationResult(true, null); } } } Обратите внимание, что логика проверки достоверности использует перегруженную версию метода Decimal.Parse (), принимающего значение из перечисления Number Styles. Это связано с тем, что проверка достоверности всегда выполняется перед преобразованием. В случае применения к одному полю и средства проверки достоверности, и конвертера, необходимо обеспечить успех проверки достоверности при наличии символа валюты. Успех или неудача логики проверки достоверности определяется возвращенным объектом ValidationResult. Свойство IsValid указывает на успех проверки достоверности, и если проверка достоверности не прошла, то свойство ErrorContent предоставляет объект, описывающий проблему. В данном примере содержимым ошибки является строка, которая будет отображена в пользовательском интерфейсе, что является наиболее распространенным подходом.
Глава 19. Привязка данных 585 Как только правило проверки достоверности завершено, его можно применить к элементу, добавив в коллекцию Binding.ValidationRules. Ниже показан пример использования правила PositivePriceRule с установкой значения Maximum в 999.99: <TextBlock Margin=,,7" Grid. Row=,,2">Unit Cost: </TextBlock> <TextBox Margin=,,5" Grid. Row=,,2" Grid. Column=,,l"> <TextBox.Text> <Binding Path=,,UnitCost"> <Binding.ValidationRules> <local:PositivePriceRule Max="999.99" /> </Binding.ValidationRules> </Binding> </TextBox.Text> </TextBox> Часто отдельный объект правила проверки достоверности будет определяться для каждого элемента, использующего тот же самый тип правила. Это объясняется тем, что может понадобиться настраивать свойства проверки достоверности (такие как минимальное и максимальное значения в PositivePriceRule) для разных элементов индивидуально. Если известно, что нужно использовать абсолютно одинаковые правила проверки достоверности для более чем одной привязки, можно определить правило в виде ресурса и просто указывать на него в каждой привязке с помощью расширения разметки StaticResource. Коллекция Binding.ValidationRules может принимать неограниченное количество правил. Когда значение фиксируется в источнике, WPF проверяет каждое правило проверки достоверности по порядку. (Вспомните, что значение в текстовом поле фиксируется в источнике, когда текстовое поле теряет фокус, если только в свойстве UpdateSourceTrigger не указано иначе.) Если все проверки достоверности прошли успешно, то WPF затем вызывает конвертер (если он есть) и применяет значение к источнику. На заметку! Если добавить правило PositivePriceRule, за которым последует ExceptionValidationRule, то правило PositivePriceRule будет оценено первым. Оно перехватит ошибки выхода за пределы допустимого диапазона. Однако ExceptionValidationRule перехватывает ошибки приведения типа, которые могут быть вызваны неправильным вводом, который невозможно привести к десятичному значению (например, последовательность букв). В случае проверки достоверности с помощью PositivePriceRule поведение будет таким же, как и при использовании ExceptionValidationRule — текстовое поле помечается красным, устанавливаются свойства НаэЕггогиЕггоги генерируется событие Error. Чтобы снабдить пользователя каким-нибудь полезным откликом, потребуется добавить немного кода для настройки ErrorTemplate. Об этом речь пойдет в последующих разделах. Совет. Специальные правила проверки достоверности могут быть исключительно специфическими, ориентированными на выполнение определенных ограничений для специфических свойств, либо более общими, которые можно повторно использовать в разнообразных сценариях. Например, можно легко создать специальное правило проверки достоверности, которое проверяет строку на соответствие указанному регулярному выражению, с помощью .NET-класса System.Text.RegularExpressions.Regex. В зависимости от используемого регулярного выражения, это правило проверки достоверности можно применять к самым разнообразным текстовым данным — таким как адреса электронной почты, телефонные номера, IP-адреса и почтовые коды.
586 Глава 19. Привязка данных Реакция на ошибки проверки достоверности В предыдущем примере единственное указание на ошибку, которое получит пользователь — это граница красного цвета вокруг текстового поля. Чтобы представить больше информации, можно обработать событие Error, возникающее при любой установке или очистке ошибки. Однако сначала необходимо убедиться, что свойство Binding.Notif yOnValidationError установлено в true: <Binding Path="UnitCost11 NotifyOnValidationError=llTrue"> Событие Error является маршрутизируемым и использует пузырьковое распространение, так что его можно обработать для многих элементов управления, присоединив обработчик событий к родительскому контейнеру, как показано ниже: <Grid Name="gridProductDetails" Validation.Error="validationErrorll> Далее представлен код, реагирующий на это событие и отображающий окно сообщения с информацией об ошибке. (Более мягкий вариант реакции может предусматривать отображение всплывающей подсказки либо выдачу сообщения об ошибке где-то в другом месте окна.) private void validationError(object sender, ValidationErrorEventArgs e) { // Проверить, что ошибка добавлена (а не очищена). if (e.Action == ValidationErrorEventAction.Added) { MessageBox.Show(e.Error.ErrorContent.ToString ()); } } Свойство ValidationErrorEventArgs.Error предоставляет объект ValidationError, соединяющий воедино несколько полезных деталей, в том числе исключение, вызвавшее проблему (Exception), нарушенное правило проверки достоверности (ValidationRule), ассоциированный объект Binding (BindinglnError) и любую специальную информацию, возвращенную объектом ValidationRule (ErrorContent). В случае использования собственных правил проверки достоверности почти наверняка будет решено помещать информацию об ошибке в свойство ValidationError. ErrorContent. Если применяется ExceptionValidationRule, то свойство ErrorContent вернет свойство Message соответствующего исключения. Однако здесь таится ловушка. Если исключение произошло по причине того, что тип данных не может быть приведен к соответствующему значению, то ErrorContent работает ожидаемы образом и сообщает о проблеме. Однако если средство установки свойства в объекте данных генерирует исключение, то это исключение помещается в оболочку TargetlnvocationException, и ErrorContent предоставит текст из свойства TargetlnvocationException.Message, которое будет выглядеть как менее полезное предупреждение "Exception has been thrown by the target of an invocation" ("Исключение возбуждено целевым объектом вызова"). Таким образом, в случае использования для генерации исключений собственных средств установки свойств придется добавить код, проверяющий свойство InnerException объекта TargetlnvocationException. Если оно не равно null, можно извлечь первоначальный объект исключения и использовать его свойство Message вместо свойства ValidationError.ErrorContent. Получение списка ошибок В определенный момент может потребоваться список отложенных ошибок в текущем окне (или заданном контейнере из этого окна). Эта задача довольно проста; все, что нужно сделать — это пройтись по дереву элементов и проверить свойство Validation. HasError каждого элемента.
Глава 19. Привязка данных 587 Код следующей процедуры демонстрирует пример, который целенаправленно ищет недопустимые данные в объектах Text Box. В нем используется рекурсия для прохода по всей иерархии элементов. При этом информация об ошибках накапливается в единственном сообщении, которое отображается пользователю. private void cmdOK_Click(object sender, RoutedEventArgs e) { string message; if (FormHasErrors(message)) { // Ошибки есть. MessageBox.Show(message); } else { // Ошибок нет. Можно продолжать работу (например, // зафиксировать изменения в источнике данных). } } private bool FormHasErrors (out string message) { StringBuilder sb = new StringBuilder (); GetErrors(sb, gridProductDetails); message = sb.ToString (); return message '= ""; } private void GetErrors(StringBuilder sb, DependencyObject obj) { foreach (object child in LogicalTreeHelper.GetChildren (ob])) { TextBox element = child as TextBox; if (element == null) continue; if (Validation.GetHasError(element)) { sb.Append(element.Text + " has errors:\r\n"); foreach (ValidationError error in Validation.GetErrors(element)) { sb.Append(" " + error.ErrorContent.ToString()); sb.Append("\r\n") ; } } // Проверить дочерние объекты на наличие ошибок. GetErrors(sb, element); } } В более сложной реализации метод FormHasErrors () мог бы создавать коллекцию объектов с информацией об ошибках. Обработчик события cmdOK_Click() затем должен был бы отвечать за конструирование соответствующего сообщения. Отображение отличающегося индикатора ошибки Чтобы получить максимальную отдачу от проверки достоверности WPF, наверняка захочется создать собственный шаблон ошибок, который будет помечать ошибки соответствующим образом. На первый взгляд это может показаться довольно низкоуровневым способом сообщать об ошибках — в конце концов, стандартные шаблоны элементов управления предоставляют возможность настройки композиции элемента за считанные минуты. Однако шаблон ошибок не похож на обычный шаблон элемента управления.
588 Глава 19. Привязка данных Шаблоны ошибок используют декоративный слой (adorner layer), который является слоем рисования, существующим прямо над обычным содержимым окна. Используя этот слой, можно добавлять визуальные украшения для сигнализации об ошибке, не подменяя шаблона элемента управления и не изменяя компоновки окна. Стандартный шаблон ошибок для текстового поля работает, добавляя элемент Border красного цвета, который "парит" прямо над соответствующим текстовым полем (поле под ним остается неизменным). Шаблон ошибок можно использовать для добавления таких деталей, как изображения, текст либо графика иного рода, привлекающая внимание к проблеме. Ниже показан пример. Он определяет шаблон ошибки, использующий зеленую рамку, и добавляет звездочку рядом с элементом управления с неправильным вводом. Шаблон помещен в оболочку правила стиля, так что оно автоматически применяется ко всем текстовым полям текущего окна. <Style TargetType="{x:Type TextBox}"> <Setter Property="Validation.ErrorTemplate"> <Setter.Value> <ControlTemplate> <DockPanel LastChildFill="True"> <TextBlock DockPanel.Dock="Right" Foreground="Red" FontSize=4" FontWeight="Bold">*</TextBlock> <Border BorderBrush="Green" BorderThickness="l"> <AdornedElementPlaceholder></AdornedElementPlaceholder> </Border> </DockPanel> </ControlTemplate> </Setter.Value> </Setter> </Style> Функционирование этого приема обеспечивается AdornedElementPlaceholder. Он представляет сам элемент управления, который существует в слое элементов. Используя AdornedElementPlaceholder, можно упорядочить содержимое относительно лежащего ниже текстового поля. В результате рамка в этом примере размещается прямо поверх текстового поля, независимо от его размеров. Звездочка в этом примере помещается сразу справа (как показано на рис. 19.5). Get Product Edible Tape • EditProductObject : Product ID- 356 Model Number S Model Name: Unit Cost: Description: The latest in personal survival gear, the STKY1 looks like a roll of ordinary office tape, but can save your life in an emergency. Just remove the tape roll and place in a kettle of boiling water with -44^ Increase Price Upaat< Рис. 19.5. Сигнализация об ошибке с помощью шаблона ошибок
Глава 19. Привязка данных 589 Лучше всего то, что содержимое нового шаблона ошибки накладывается поверх существующего содержимого, не вызывая никаких изменений в компоновке исходного окна. (Фактически неосмотрительное добавление в декоративный слой слишком большого объема содержимого приводит к перекрытию прочих частей окна.) Совет. Если нужно, чтобы шаблон ошибок появлялся над элементом (а не где-то возле него), можно поместить и содержимое, и AdornerElementPlaceholder в одну ячейку Grid. В качестве альтернативы можно вообще опустить AdornerElementPlaceholder, но тогда утрачивается возможность точного позиционирования содержимого по отношению к лежащему ниже элементу. Этот шаблон ошибки все еще страдает от одной проблемы — он не предоставляет никакой дополнительной информации об ошибке. Чтобы показать эти детали, понадобится извлечь их с помощью привязки данных. Хорошим подходом может быть такой: взять содержимое первой ошибки и использовать его для текста всплывающей подсказки индикатора ошибки. Ниже приведен шаблон, который именно это и делает. <ControlTemplate> <DockPanel LastChildFill="True"> <TextBlock DockPanel.Dock="Right" Foreground="Red" FontSize=4" FontWeight="Bold" ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement.(Validation.Errors)[0].ErrorContent}" >*</TextBlock> <Border BorderBrush="Green" BorderThickness="l"> <AdornedElementPlaceholder Name="adornerPlaceholder'^ </AdornedElementPlaceholder> </Border> </DockPanel> </ControlTemplate> Значение Path в выражении привязки выглядит несколько витиевато и требует внимательного изучения. Источником этого выражения привязки является AdornedElementPlaceholder, определенный в шаблоне элемента управления: ToolTip="{Binding ElementName=adornerPlaceholder, ... Класс AdornedElementPlaceholder предоставляет ссылку на элемент внизу (в данном случае — объект TextBox с ошибкой) через свойство по имени AdornedElement: ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement . . . Чтобы извлечь действительную ошибку, необходимо проверить свойство Validation. Errors этого элемента. Однако свойство Validation.Errors должно быть помещено в скобки, поскольку оно является присоединенным свойством, а не свойством класса TextBox: ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement. (Validation.Errors) . . . И, наконец, с использованием индексатора нужно извлечь объект ValidationError из коллекции и затем получить значение свойства Error: ToolTip="{Binding ElementName=adornerPlaceholder, Path=AdornedElement.(Validation.Errors)[0].ErrorContent}" Теперь при перемещении курсора мыши над звездочкой будет отображаться сообщение об ошибке.
590 Глава 19. Привязка данных В качестве альтернативы можно отображать сообщение об ошибке в ToolTip из Border или самого Text Box, так что сообщение об ошибке появится, когда пользователь поместит курсор мыши над любой частью элемента управления. Этот трюк можно выполнить без помощи специального шаблона ошибок; все, что понадобится — это триггер на элементе управления TextBox, который отреагирует на присваивание Validation. HasError значения true и применит ToolTip с сообщением об ошибке. Вот пример: <Style TargetType="{x:Type TextBox}"> <Style.Triggers> <Trigger Property="Validation.HasError" Value="True"> <Setter Property="ToolTip" Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" /> </Trigger> </Style.Triggers> </Style> Полученный результат показан на рис. 19.6. ■ EditProductObject -щ&шт \ Product Ю: 356 Model Number: Model Name: Unit Cost: Description: The latest in personal survival gear, the STKY1 looks like a roll of ordinary office tape, but can save increase ''rice Update Get All Exceptions Рис. 19.6. Вывод сообщения об ошибке проверки достоверности во всплывающей подсказке Проверка достоверности множества значений Подходы, которые демонстрировались до сих пор, позволяли проверять индивидуальные значения. Однако есть много ситуаций, когда нужно выполнять проверку достоверности двух и более связанных значений. Например, объект Project не является допустимым, если значение его свойства StartDate оказывается больше чем EndDate. Объект Order не должен иметь в свойстве Status (состояние) значение Shipped (поставлен), а в свойстве ShipDate (дата поставки) значение null. Объект Product не может иметь значение свойства ManufacturingCost (себестоимость) больше чем RetailPrice (розничная цена). Примеров можно привести множество. Существуют разные способы проектирования приложений, позволяющие удовлетворить эти ограничения. В некоторых случаях удается построить более интеллектуальный пользовательский интерфейс. (Например, если некоторые поля не имеют смысла в зависимости от информации из других полей, их можно делать недоступными.) В других ситуациях эта логика будет встраиваться в класс данных (однако это не будет работать, если данные допустимы в некоторых ситуациях, но не приемлемы в определенной зада-
Глава 19. Привязка данных 591 че редактирования). И, наконец, можно использовать группы привязки (binding groups) для создания специальных правил проверки достоверности, которые применяются во всей системе привязки данных WPF. Базовая идея, лежащая в основе групп привязки, проста. Сначала создается специальное правило проверки достоверности, унаследованное от класса ValidationRule, как было показано ранее. Но вместо применения правила проверки достоверности к единому выражению привязки, оно присоединяется к контейнеру, хранящему все привязанные элементы управления. (Обычно это тот же контейнер, который имеет DataContext, установленный в объект данных.) Затем WPF использует его для проверки достоверности всего объекта данных по завершении редактирования, что известно под названием проверка достоверности на уровне элементов. Например, в показанной ниже разметке создается группа привязки для Grid установкой свойства BindingGroup (которое включают все элементы). После этого добавляется единое правило проверки достоверности по имени NoBlankProductRule. Правило автоматически применяется к привязанному объекту Product, хранящемуся в свойстве Grid.DataContext. <Grid Name="gridProductDetails" DataContext="{Binding ElementName=lstProducts, Path=SelectedItem}"> <Grid.BindingGroup> <BindingGroup x:Name="productBindingGroup"> <BindingGroup.ValidationRules> <local:NoBlankProductRule></local:NoBlankProductRule> </BindingGroup.ValidationRules> </BindingGroup> </Grid.BindingGroup> <TextBlock Margin=">Model Number:</TextBlock> <TextBox Margin=" Grid. Column="l" Text=" {Binding Path=ModelNumber } "> </TextBox> </Grid> В правилах проверки достоверности, показанных до сих пор, метод Validate () принимает единственное значение для проверки. Но при использовании групп привязки метод Validate () принимает вместо этого объект BindingGroup. Этот BindingGroup является оболочкой для привязанного объекта данных (в рассматриваемом случае — Product). Вот как в классе NoBlankProductRule начинается метод Validate(): public override ValidationResult Validate(object value, Culturelnfo culturelnfo) { BindingGroup bindingGroup = (BindingGroup)value; Product product = (Product)bindingGroup.Items [0] ; } Обратите внимание, что код извлекает первый объект из коллекции BindingGroup. Items. В этом примере имеется только один объект данных. Но возможно (хотя и редко) создавать группы привязки, которые применяются к разным объектам данных. В этом случае получается коллекция со всеми объектами данных. На заметку! Чтобы создать группу привязки, которая применяется к более чем одному объекту, необходимо установить свойство BindingGroup.Name, назначив группе привязки'описательное имя. Затем его можно указывать в свойстве BindingGroupName выражения привязки: Text="{Binding Path=ModelNumber, BindingGroupName=MyBindingGroup}" Таким образом, каждое выражение привязки явно указывает на группу привязки, и одну и ту же группу привязки можно использовать с выражениями, которые работают в отношении разных объектов данных.
592 Глава 19. Привязка данных Существует еще одно неожиданное отличие в работе метода Validate () с группами привязки. По умолчанию принимаемый объект данных — это первоначальный объект, без каких-либо новых изменений. Чтобы получить новые значения, которые нужно проверить, понадобится вызвать метод BindingGroup.GetValueO и передать ему объект данных и имя свойства: string newModelName = (string) bindingGroup.GetValue(product, "ModelName"); Такое проектное решение имеет смысл. Откладывая действительное применение нового значения к объекту данных, WPF гарантирует, что обновление не вызовет других обновлений или задач синхронизации в приложении, прежде чем это будет оправдано. Ниже приведен полный код правила проверки достоверности NoBlankProductRule: public class NoBlankProductRule : ValidationRule { public override ValidationResult Validate(object value, Culturelnfo culturelnfo) { BindingGroup bindingGroup = (BindingGroup)value; // Это товар с исходным значением. Product product = (Product)bindingGroup.Items [0] ; // Проверить новые значения. string newModelName = (string)bindingGroup.GetValue (product, "ModelName"); string newModelNumber = (string)bindingGroup.GetValue (product, "ModelNumber"); if ((newModelName == "") && (newModelNumber == "")) { // Товар требует указания ModelName или ModelNumber. return new ValidationResult (false, "A product requires a ModelName or ModelNumber."); } else { return new ValidationResult (true, null); } } } При использовании поэлементной проверки достоверности обычно приходится создавать тесно связанное правило вроде этого. Причина в том, что логика обычно не допускает простого обобщения (другими словами, вряд ли ее можно применить к похожему, но слегка отличающемуся случаю с другим объектом). При вызове GetValue () также должно использоваться специфическое имя свойства. В результате правила достоверности, создаваемые на уровне элементов, не могут быть столь же четкими, прямолинейными и многократно используемыми, как те, что создаются для проверки достоверности индивидуальных значений. В нынешнем виде рассматриваемый пример еще не завершен. Группы привязки используют транзакционную систему редактирования, а это означает, что необходимо формально фиксировать внесенные правки перед выполнением логики проверки достоверности. Простейший способ сделать это состоит в вызове метода BindingGroup. CommitEdit(). Это можно реализовать в обработчике события, который запускается по щелчку на кнопке либо при утере элементом управления фокуса: <Grid Name="gridProductDetails" TextBox.LostFocus="txt_LostFocus" DataContext="{Binding ElementName=lstProducts, Path=SelectedItem}"> Ниже показан код обработки события:
Глава 19. Привязка данных 593 private void txt_LostFocus(object sender, RoutedEventArgs e) { productBindingGroup.CommitEdit (); } Если проверка дает отрицательный результат, то весь Grid считается недостоверным и помечается красным контуром. Как и с элементами для редактирования вроде TextBox, внешность Grid можно изменить, модифицируя его свойство Validation. ErrorTempiate. На заметку! Проверка достоверности уровня элементов работает более гладко с элементом управления DataGrid, который будет рассматриваться в главе 22. Он обрабатывает тран- закционные аспекты редактирования, инициируя навигацию по полям, когда пользователь переходит от одной ячейки к другой, и вызывает метод BindingGroup.CommitEditO, когда пользователь переходит из одной строки в другую. Поставщики данных В большинстве продемонстрированных до сих пор примеров источник данных верхнего уровня применялся за счет программной установки свойства DataContext простого элемента или свойства ItemsSource списочного элемента. Вообще это наиболее гибкий подход, особенно, если объект данных конструируется другим классом (таким как StoreDB). Однако доступны и другие варианты. Один из возможных приемов состоит в определении объекта данных как ресурса окна (или другого контейнера). Это хорошо работает, если есть возможность сконструировать объект декларативно, но имеет гораздо меньше смысла, если во время выполнения нужно подключаться к внешнему хранилищу данных (подобному базе данных). Тем не менее, некоторые разработчики все равно используют этот подход (часто стараясь избегать написания кода обработки событий). Базовая идея состоит в создании объекта-оболочки, который принимает необходимые данные в своем конструкторе. Например, можно было бы создать раздел ресурсов следующим образом: <Window.Resources> <ProductListSourсе x:Key="products"></ProductListSouгсе> </Window.Resouгсеs> Здесь ProductListSource — класс, унаследованный от ObservableCollectiorK Products>. Таким образом, он обладает способностью хранить список товаров. У него есть также некоторая базовая логика в конструкторе, в котором для заполнения списка вызывается метод StoreDB.GetProducts(). Теперь другие элементы могут использовать для привязки следующую разметку: <ListBox ItemsSource="{StaticResource products}" ... > Поначалу этот подход кажется привлекательным, но он немного рискован. Добавляемая обработка ошибок должна помещаться в класс ProductListSource. Может даже понадобиться отобразить для пользователя сообщение, объясняющее проблему. Очевидно, что при таком подходе модель данных, код доступа к данным и код пользовательского интерфейса смешиваются в единый сплав, так что в нем нет особого смысла, когда нужен доступ к внешним ресурсам (файлам, базам данных и т.п.). Поставщики данных в некотором отношении являются расширением этой модели. Поставщик данных дает возможность привязаться непосредственно в объекту, который определен в разделе ресурсов разметки. Однако вместо привязки к самому объекту данных осуществляется привязка к поставщику данных, который может извлечь или сконструировать этот объект. Такой подход имеет смысл в случае полноценного поставщика
594 Глава 19. Привязка данных данных, обладающего способностью инициировать события в ответ на исключения и предоставляющего свойства, которые позволяют конфигурировать другие детали операции. К сожалению, поставщики данных, которые включены в WPF, пока не отвечают этому стандарту. Они слишком ограничены, чтобы возиться с ними в ситуациях с внешними данными (например, при извлечении информации из базы данных или файла). Они могут иметь смысл в более простых сценариях, например, поставщик данных мог бы использоваться для соединения вместе нескольких элементов управления, которые передают входную информацию в класс, вычисляющий результат. Однако в данной ситуации от них относительно мало толку, помимо возможности сократить код обработки событий в пользу кода разметки. Все поставщики данных унаследованы от класса System.Windows.Data. DataSourceProvider. В настоящее время WPF предлагает только два поставщика данных: • ObjectDataProvider, который получает информацию, вызывая метод другого класса; • XmlDataProvider, который получает информацию непосредственно из файла XML. Целью обоих этих объектов является позволить создать экземпляр объекта данных в XAML-разметке, не прибегая к коду обработки событий. На заметку! Существует еще один вариант: можно явно создать объект представления в виде ресурса в XAML-разметке, привязать элементы управления к этому представлению и заполнить его данными. Такой вариант особенно удобен, когда нужно настраивать представление, применяя сортировку и фильтрацию, хотя многие разработчики также предпочитают использовать его в качестве средства тестирования. Использование представлений рассматривается в главе 21. Поставщик ObjectDataProvider ObjectDataProvider позволяет получать информацию из другого класса в приложении. Он добавляет следующие средства. • Может создать необходимый объект и передать параметры конструктору. • Может вызвать метод на этом объекте и передать ему параметры метода. • Может создать объект данных асинхронно. (Другими словами, он может подождать, пока будет создано окно, и затем выполнить свою работу в фоновом режиме.) Например, вот как выглядит базовый поставщик ObjectDataProvider, который создает экземпляр класса StoreDB, вызывает его метод GetProductsO и делает данные доступными остальной части окна: <Window.Resouгсеs> «DbjectDataProvider x:Key="productsProvider" ObjectType="{x:Type local:StoreDB}" MethodName="GetProducts"></ObjectDataProvider> </Window.Resources> Теперь можно создать привязку, которая получает источник из ObjectDataProvider: <ListBox Name="lstProducts" DisplayMemberPath="ModelName" ItemsSource="{Binding Source={StaticResource productsProvider}}"></ListBox> Этот дескриптор выглядит так, будто привязывается к ObjectDataProvider, но ObjectDataProvider достаточно интеллектуален, чтобы понять, что на самом деле требуется привязка к списку товаров, возвращаемому методом GetProducts ().
Глава 19. Привязка данных 595 На заметку! Как и все поставщики данных, ObjectDataProvider предназначен для извлечения данных, но не обновления их. Другими словами, нет способа заставить ObjectDataProvider вызвать другой метод класса StoreDB, чтобы инициировать обновление. Это лишь один пример .недостаточной зрелости классов поставщиков данных WPF по сравнению с другими реализациями в других платформах, таких как элементы управления источниками данных в ASP.NET. Обработка ошибок В нынешнем виде рассматриваемый пример имеет огромный недостаток. Анализатор XAML создает окно и вызывает метод GetProductsO, чтобы установить привязку. Все идет гладко, если метод GetProductsO возвращает нужные данные, но результат не так хорош, если возникает необработанное исключение (например, база данных слишком занята или недоступна). В этот момент исключение распространяется пузырьком вверх от вызова InitializeComponent() в конструкторе окна. Код, отображающий окно, должен перехватить эту ошибку, что концептуально запутано. И не существует способа продолжить работу и показать окно — даже если перехватить исключение в конструкторе, остальная часть окна не будет инициализирована правильно. К сожалению, нет простого способа решить эту проблему. Класс Ob j ectDataProvider включает свойство IsInitialLoadEnabled, которое можно установить в false, чтобы предотвратить вызов GetProductsO при первоначальном создании окна. Тогда можно будет вызвать Refresh() позднее, чтобы инициировать вызов. Однако при использовании такого подхода выражение привязки даст сбой, потому что список не сможет извлечь свой источник данных. (Это отличается от большинства ошибок привязки данных, которые молча проглатываются, не инициируя исключений.) Так каково же решение? Можно сконструировать ObjectDataProvider программно, хотя при этом теряются преимущества от декларативной привязки, что вероятно является главной причиной применения ObjectDataProvider. Другое решение состоит в конфигурировании ObjectDataProvider для асинхронного выполнения работы, как описано в следующем разделе. В этой ситуации исключения вызывают молчаливый сбой (хотя трассировочное сообщение все равно будет выдано в окно Debug (Отладка) с детальным описанием ошибки). Асинхронная поддержка Большинство разработчиков сочтут, что для применения ObjectDataProvider есть совсем немного причин. Обычно легче просто привязаться непосредственно к объекту данных и добавить код, который вызовет класс, опрашивающий данные (такой как StoreDB). Однако есть одна причина, по которой предпочтение отдается ObjectDataProvider — для получения преимуществ асинхронного запроса данных: «DbjectDataProvider IsAsynchronous="True" ... > Разметка обманчиво проста. До тех пор, пока свойство ObjectDataProvider. IsAsynchronous установлено в true, поставщик ObjectDataProvider выполняет свою работу в фоновом потоке. В это время интерфейс ничем не связан. Как только объект данных сконструирован и возвращен из метода, ObjectDataProvider делает его доступным для всех привязанных элементов. Совет. Даже не используя ObjectDataProvider, все равно можно запустить код доступа к данным асинхронно. Трюк состоит в применении поддержки многопоточных приложений в WPR В этом поможет компонент BackgroundWorker, описанный в главе 31. При использовании BackgroundWorker появляется дополнительная поддержка отмены и отображения хода работ. Тем не менее, добавление BackgroundWorker в пользовательский интерфейс требует больше работы, чем простая установка свойства ObjectDataProvider.IsAsynchronous.
596 Глава 19. Привязка данных Асинхронная привязка данных В WPF также предлагается асинхронная поддержка через свойство isAsync каждого объекта Binding. Однако это средство намного менее удобно, чем асинхронная поддержка в ObjectDataProvider. Когда Binding.IsAsync устанавливается в true, WPF извлекает привязанное свойство из объекта данных асинхронно. Но сам объект данных все равно создается синхронно. Например, предположим, что для примера StoreDB создается асинхронная привязка, которая выглядит так: <TextBox Text="{Binding Path=ModelNumber, IsAsync=True}" /> Несмотря на использование асинхронной привязки, все равно придется ждать, пока код выполнит запрос к базе данных. Как только создана коллекция товаров, привязка запросит свойство Product.ModelNumber текущего объекта товара асинхронно. Это поведение дает небольшую выгоду, т.к. процедуры свойства в классе Product требуют незначительного времени на выполнение. Фактически все хорошо спроектированные объекты данных состоят из подобных легковесных свойств, так что это одна из причин, по которой команда разработчиков WPF испытывала серьезные сомнения в целесообразности предоставления свойства Binding.IsAsync Один из способов использования преимущества Binding.IsAsync состоит в построении специализированного класса, который включает длительно выполняющуюся логику в процедуру get свойства. Например, представим аналитическое приложение, которое привязано к модели данных. Этот объект данных может включать часть информации, которая вычисляется с использованием алгоритма, требующего значительного времени на выполнение. Это свойство можно было бы привязать с применением асинхронной привязки, а все остальные свойства — с помощью обычной синхронной привязки. Таким образом, некоторая информация в приложении появится немедленно, а дополнительная будет отображаться по мере готовности. Также WPF включает средство приоритетной привязки, построенное на основе асинхронной привязки. Приоритетная привязка позволяет применить несколько асинхронных привязок в соответствии со списком приоритетов. Более высокоприоритетные привязки запускаются первыми, но пока они работают, вместо них используются низкоприоритетные привязки. Ниже показан пример: <TextBox> <TextBox.Text> <PriorityBinding> <Binding Path="SlowSpeedProperty" IsAsync="True" /> <Binding Path="MediumSpeedProperty" IsAsync="True" /> <Binding Path="FastSpeedProperty" /> </ PriorityBinding> </TextBox.Text> </TextBox> Здесь предполагается, что текущий контекст данных содержит объект с тремя свойствами SlowSpeedProperty, MediumSpeedProperty и FastSpeedProperty. Привязки выстраиваются в порядке важности. В результате свойство SlowSpeedProperty всегда используется для установки текста, если оно доступно. Но если первая привязка находится в процессе чтения SlowSpeedProperty (другими словами, в процедуре get свойства есть длительно выполняющаяся логика), то вместо него применяется свойство MediumSpeedProperty. Если оно недоступно, используется свойство FastSpeedProperty. Чтобы такой подход работал, все привязки должны быть сделаны асинхронными (при этом текстовое поле будет пустым, пока значение не будет извлечено) или синхронными (и тогда окно будет заморожено, пока все синхронные привязки не завершат свою работу).
Глава 19. Привязка данных 597 Поставщик XmlDataProvider Поставщик XmlDataProvider предлагает быстрый и простой способ извлечения XML-данных из отдельного файла, местоположения в Интернете или ресурса приложения, а также обеспечения их доступности элементам приложения. XmlDataProvider спроектирован как доступный только для чтения (другими словами, он не предусматривает возможности фиксировать изменения) и не умеет работать с XML-данными, полученными из других ресурсов (таких как запись базы данных, сообщение веб-службы и т.п.). В результате XmlDataProvider является исключительно специфическим средством. Если ранее уже приходилось использовать .NET для работы с XML, то должно быть известно, что .NET предлагает богатый набор библиотек для чтения, записи и манипулирования XML. Можно использовать простые классы чтения и записи, которые позволяют проходить по XML-файлу и обрабатывать каждый элемент с помощью специального кода, можно применять XPath или DOM для поиска специфического содержимого и можно также использовать классы сериализаторов для преобразования целых объектов в XML-представление и обратно. Каждый из этих подходов обладает своими преимуществами и недостатками, но все они более мощные, чем XmlDataProvider. При наличии потребности в модификации XML или преобразовании данных XML в объектное представление, с которым можно работать в коде, лучше не пренебрегать интенсивной поддержкой XML, которая уже имеется в .NET. Тот факт, что данные хранятся в XML-виде, становится несущественной низкоуровневой деталью, которая не влияет на способ конструирования пользовательского интерфейса. (Интерфейс может быть просто привязан к объектам данных, как в примерах с использованием базы данных, которые приводились в этой главе ранее.) Однако если абсолютно необходим быстрый способ извлечения XML-содержимого, и требования относительно непритязательны, то поставщик XmlDataProvider будет вполне разумным выбором. Чтобы использовать XmlDataProvider, он должен быть определен и ориентирован на нужный файл за счет установки свойства Source: <XmlDataProvider x:Key="productsProvider" Source="store.xml"></XmlDataProvider> Можно также установить свойство Source программно (что важно, если имя файла заранее не известно). По умолчанию XmlDataProvider загружает XML-содержимое асинхронно, если только явно не установить свойство XmlDataProvider.IsAsynchronous в false. Ниже приведен фрагмент простого XML-файла, который используется в рассматриваемом примере. Он содержит в себе целый документ в элементе Products верхнего уровня и помещает каждый товар в отдельный элемент Product. Индивидуальные свойства каждого товара представлены как его вложенные элементы. <Products> <Product> <ProductID>355</ProductID> <CategoryID>16</CategoryID> <ModelNumber>RU007</ModelNumber> <ModelName>Rain Racer 2000</ModelName> <ProductImage>image.gif</ProductImage> <UnitCost>1499. 99</UmtCost> <Description>Looks like an ordinary bumbershoot ... </Description> </Product> <Product> <ProductID>356</ProductID> <CategoryID>2 0</CategoryID> <ModelNumber>STKYK/ModelNumber>
598 Глава 19. Привязка данных <ModelName>Edible Tape</ModelName> <ProductImage>image.gif</ProductImage> <UmtCost>3. 99</UmtCost> <Description>The latest in personal survival gear ... </Description> </Product> </Products> Для извлечения информации из XML-разметки используются расширения XPath — мощного стандарта, позволяющего извлекать только интересующие части документа. Хотя полное описание XPath выходит за рамки настоящей книги, кратко рассмотрим суть. XPath использует нотацию путей файловой системы. Например, путь / идентифицирует корень XML-документа, a /Products — корневой элемент по имени <Products>. Путь /Products/Product выбирает каждый элемент <Product> внутри элемента <Products>. При использовании XPath с XmlDataProvider первой задачей является идентификация корневого узла. В данном случае это означает выбор элемента <Products>, содержащего все данные. (Чтобы сосредоточиться на определенном разделе XML-документа, необходимо использовать другой элемент верхнего уровня.) <XmlDataProvider x:Кеу="productsProvider" Source="/store.xml" Xpath="/Products"></XmlDataProvider> Следующий шаг состоит в привязке списка. При работе с XmlDataProvider используется свойство Binding.XPath вместо свойства Binding.Path. Это позволяет погружаться в XML-разметку настолько глубоко, насколько нужно. Ниже показана разметка, которая извлекает элементы <Product>: <ListBox Name="lstProducts" Margin=" DisplayMemberPath="ModelName" ItemsSource="{Binding Source={StaticResource products}, XPath=Product}" > </ListBox> При установке в привязке свойства XPath следует помнить, что выражение является относительным текущей позиции в XML-документе. По этой причине в привязке списка не понадобится указывать полный путь /Products/Product. Вместо этого можно просто использовать относительный путь Product, который начинается от узла <Products>, выбранного XmlDataProvider. Наконец, необходимо привязать каждый из элементов, которые отображают детали товара. Опять-таки, записываемое выражение XPath вычисляется относительно текущего узла (которым будет элемент <Product> для текущего товара). Ниже приведен пример привязки к элементу <ModelNumber>: <TextBox Text="{Binding XPath=ModelNumber}"></TextBox> После внесения этих изменений получается пример на основе XML, который почти идентичен тому, что использует привязку на базе объектов, который рассматривался до сих пор. Единственное отличие в том, что все данные трактуются как обычный текст. Чтобы преобразовать их в другой тип данных или в другое представление, потребуется конвертер значений. Резюме В этой главе рассматривалась привязка данных. Вы узнали о том, как создаются выражения привязки данных, которые извлекают информацию из пользовательских объектов, и как изменения передаются обратно в источник. Также было показано, как использовать уведомление об изменениях, привязывать целые коллекции и привязываться к объектам DataSet из ADO.NET.
Глава 19. Привязка данных 599 Во многих отношениях привязка данных WPF задумана как универсальное решение для автоматизации взаимодействия элементов и отображения объектной модели приложения на его пользовательский интерфейс. Хотя WPF-приложения остаются все еще относительно новыми, те, что существуют на сегодняшний день, используют привязку данных намного чаще и более основательно, чем их предшественники из Windows Forms. В WPF привязка данных представляет собой нечто большее, чем необязательное излишество, и каждый профессиональный разработчик WPF должен овладеть ею. На этом исследование технологий работы с данными не заканчивается. Еще осталось рассмотреть несколько тем, имеющих к этому отношение. В следующих главах мы будем полагаться на базовые знания о привязке данных, полученные в этой главе и обратимся к нескольким новым темам. • Форматирование данных. Вы уже знаете, как получить данные, но не обязательно — как придать им соответствующий вид. В главе 20 будет показано, как форматировать числа и даты. Кроме того, вы ознакомитесь со стилями и шаблонами данных, которые позволяют настраивать отображение записей в списке. • Представления данных. В каждом приложении, использующем привязку данных, работают представления данных. Иногда эту часть "внутренней кухни" можно проигнорировать. Но если присмотреться к ней внимательнее, то ее можно использовать для реализации навигационной логики и применения фильтрации и сортировки. Эти вопросы будут рассматриваться в главе 21. • Расширенные элементы управления данными. Для более развитых отображений данных WPF предлагает элементы управления ListView, TreeView и DataGrid. Все они поддерживают привязку данных со значительной гибкостью. Эти элементы управления рассматриваются в главе 22.
ГЛАВА 20 Форматирование привязанных данных В главе 19 вы ознакомились с сущностью привязки данных WPF, т.е. с тем, как извлечь информацию из объекта и отобразить ее в окне, применив минимум кода. Попутно вы узнали, как сделать информацию редактируемой, как справляться с коллекциями объектов данных и как выполнять проверку достоверности для перехвата ошибочного ввода. Однако еще предстоит узнать немало нового. В этой главе исследования продолжаются, и будет рассмотрено несколько вещей, которые позволят строить лучше привязанные окна. Во-первых, будет описано преобразование данных — мощная и расширяемая система, которую использует WPF для исследования значений и их преобразования. Как вы узнаете, процесс преобразования предоставляет возможность применять условное форматирование и иметь дело с изображениями, файлами и другими типами специализированного содержимого. Во-вторых, вы узнаете, как форматировать целые списки данных. Сначала вы ознакомитесь с инфраструктурой, поддерживающей списки привязки, начиная с базового класса ItemsControl. Затем узнаете, как улучшить внешность списка с помощью стилей, наряду с триггерами, которые применяют альтернативное форматирование и подсветку выбора. Наконец, вы научитесь пользоваться наиболее мощным из всех инструментом форматирования — шаблонами данных, которые позволяют настраивать отображение каждого элемента списка в ItemsControl. Именно в шаблонах данных кроется секрет преображения простого списка в развитый презентационный инструмент со специальным форматированием, графическим содержимым и дополнительными элементами управления WPF. Что нового? В версии WPF 3.5 SP1 в систему привязки данных были внесены два существенных усовершенствования. Во-первых, новое свойство Binding.StringFormat делает возможным применение форматных строк .NET без специального конвертера значений (см. раздел "Свойство StringFormat"). Во-вторых, в класс ItemsControl добавлены свойства Alternationlndex и AlternationCount, которые позволяют применить чередующееся форматирование строк без селектора специального стиля (см раздел "Стиль чередующихся элементов"). Еще раз о привязке данных В большинстве сценариев привязка осуществляется не к одиночному объекту, а к целой коллекции. На рис. 20.1 показан знакомый пример — форма со списком товаров. Когда пользователь выбирает товар, справа отображается детальная информация о нем.
Глава 20. Форматирование привязанных данных 601 • BindCollection гкаШ Get Products Escape Vehicle (Air) Extracting Tool ; Escape Vehicle (Water) Communications Device Multi-Purpose Rubber Band Universal Repair System Effective Flashlight The Incredible Versatile Paperclip Toaster Boat Multi-Purpose Towelette Mighty Mighty Pen Perfect-Vision Glasses Model Number: _<4TLNT Model Name: -ersuasive Pencil Unit Cost: A 1.9900 Description Persuade anyone to see your point of view' Captivate your friends and enemies alike! Draw the crime-scene or map out the chain of events All you need is several years of training or copious amounts of natural talent. You're halfway there with the Persuasive Pencil Purchase this item with the Retro Pocket Protector Rocket Pack for optimum disguise Рис. 20.1. Просмотр коллекции товаров В главе 19 вы узнали, как именно строятся формы подобного рода. Напомним основные шаги. 1. Сначала необходимо создать список элементов, которые можно отображать в ItemsControl. Установите DisplayMemberPath для индикации свойства (или поля), которое должно быть показано для каждого элемента списка. Следующий список отображает имя модели для каждого элемента: <ListBox Name=stProducts" DisplayMemberPath=,,ModelName"></ListBox> 2. Чтобы заполнить список данными, укажите в свойстве ItemsSource коллекцию (или DataTable). Обычно этот шаг выполняется в коде при загрузке окна или щелчке пользователя на кнопке. В данном примере ItemsControl привязывается к коллекции ObservableCollection объектов Product: ObservableCollection<Product> products IstProducts.ItemsSource = products; App.StoreDB.GetProducts(); 3. Для отображения информации, специфичной для элемента, добавьте необходимый набор элементов управления, каждый с выражением привязки, идентифицирующим свойство или поле, которое должно отображаться. В рассматриваемом примере каждый элемент в коллекции — это объект Product. Вот как отобразить номер модели за счет привязки свойства Product.ModelNumber: <TextBox Text="{Binding Path=ModelNumber}"></TextBox> 4. Простейший способ подключения специфичных для позиции элементов к текущей выбранной позиции предусматривает помещение их в одиночный контейнер. Установите в свойстве DataContext контейнера ссылку на выбранный пункт списка: <Grid DataContext="{Binding ElementName=lstProducts, Path=SelectedItem}"> На этом обзор завершен. Однако пока еще не рассматривались способы настройки внешнего вида списка данных и полей данных. Например, не известно, как форматировать числовые значения, как создать список, отображающий несколько частей информации сразу (и должным образом организовать эти части), и как справиться с нетекстовым содержимым, таким как графические данные. Все эти задачи рассматриваются в настоящей главе.
602 Глава 20. Форматирование привязанных данных Преобразование данных При обычной привязке информация путешествует от источника к цели без каких- либо изменений. Это кажется логичным, но такое поведение не всегда подходит Часто источник данных может использовать низкоуровневое представление, которое не нужно отображать непосредственно в пользовательском интерфейсе. Например, можете понадобиться, чтобы числовые коды заменялись читабельными для человека строками, числа представлялись в укороченном виде, даты отображались в длинном формате и т.д. Если это так, то нужен какой-то способ преобразования этих значений в корректную отображаемую форму. И если применяется двунаправленная привязка, то также понадобится обратная операция — преобразование введенных пользователем данных в представление, подходящее для хранения в соответствующем объекте данных. К счастью, в WPF доступны два средства, которые могут оказать помощь. • Форматирование строк. Это средство позволяет преобразовать данные, представленные в виде текста — например, строки, которые содержат даты и числа, — за счет установки свойства Binding.StringFormat. Это удобный прием, который справляется, по крайней мере, с половиной задач форматирования. • Конвертеры значений. Это намного более мощное (и в некотором отношении более сложное) средство, позволяющее преобразовывать любой тип исходных данных в любой тип представления объекта, который может быть передан связанному элементу управления. Оба подхода рассматриваются в последующих разделах. Свойство StringFormat Форматирование строк — блестящий инструмент для форматирования чисел, которые нужно отобразить в виде текста. Например, возьмем свойство UnitCost из класса Product, представленного в предьщущей главе. UnitCost хранится как decimal, и в результате его отображения в текстовом поле можно наблюдать значение вроде 3.9900. Такой формат не только отображает больше десятичных знаков, чем нужно, но также он пропускает символ валюты. Интуитивно понятное представление должно выглядеть как $3.99. Простейшее решение состоит в установке свойства Binding.StringFormat. Для преобразования неформатированного текста в его отображаемое значение непосредственно перед его появлением в элементе управления WPF использует форматную строку. Не менее важно, что WPF в большинстве случаев применяет эту строку для обратного преобразования, взяв любые отредактированные данные и используя их для обновления привязанного свойства. При установке свойства Binding.StringFormat применяются стандартные форматные строки .NET вида {0:С}. Здесь 0 представляет первое значение, а С ссылается на строку формата, которой в данном случае является стандартный, специфичный для локали формат валюты, который преобразует 3.99в$3.99на компьютере, находящемся в США. Все выражение помещается в фигурные скобки. Ниже приведен пример применения форматной строки к полю UnitCost, чтобы его значение отображалось как денежное: <TextBox Margin=" Grid.Row=" Grid.Column="l" Text=" {Binding Path=UmtCost, StringFormat={ } { 0 : C} } "> </TextBox> Значение StringFormat предварено еще одной парой фигурных скобок {}. Вместе получается {}{0:С}, а не просто {0:С}. Эта несколько неуклюжая конструкция необходима, чтобы защитить строку. В противном случае анализатор XAML может быть введен в заблуждение фигурной скобкой в начале {0:С}. Кстати, управляющая последова-
Глава 20. Форматирование привязанных данных 603 тельность {} необходима, только когда значение StringFormat начинается со скобки. Взгляните на следующий пример, в котором перед каждым форматированным значением добавляется литеральный текст: <TextBox Margin=" Grid.Row=" Grid.Column="l" Text=" {Binding Path=UmtCost, StringFormat=The value is {0:C}.}"> </TextBox> Это выражение преобразует значение вроде 3.99 в "The value is $3.99". Поскольку первый символ в значении StringFormat — обычная буква, а не скобка, управляющая последовательность не требуется. Однако эта форматная строка работает только в одном направлении. Если пользователь попытается применить отредактированное значение, включающее литеральный текст (такое как "The value is $3.99"), обновление потерпит неудачу. С другой стороны, если пользователь выполнит редактирование, введя только числовое значение D.25) либо числовое значение с символом валюты ($4.25), то редактирование удастся, и выражение привязки преобразует его в отображаемый текст "The value is $4.25". Чтобы получить нужный результат с помощью свойства StringFormat, понадобится правильная строка формата. Все доступные форматные строки можно найти в справочной системе Visual Studio. В табл. 20.1 и 20.2 перечислены наиболее часто используемые опции, которые будут применять для числовых данных и дат соответственно. Таблица 20.1. Строки формата для числовых данных Тип Строка формата Пример Валюта Научный Е (экспоненциальный) Процентный Р Десятичный с фик- F? сированной точкой $1,234.50 Отрицательные значения представляются в скобках: ($1,234.50). Знак валюты специфичен для локали 1.234.50Е+004 45.6% Зависит от количества установленных десятичных разрядов. F3 форматирует значения в виде 123.400, a F0 форматирует значения подобно 123 Таблица 20.2. Строки формата для времени и дат Тип Строка формата Формат Короткая дата Длинная дата Длинная дата и короткое время Длинная дата и длинное время Сортируемый стандарт ISO Месяц и день Общий м G M/d/yyyy. Например: 10/30/2010 dddd, MMMM dd, yyyy. Например: Monday, January 31, 2011 dddd, MMMM dd, yyyy HH:mm aa. Например: Monday, January 31, 2011 10:00 AM dddd, MMMM dd, yyyy HH:mm:ss aa. Например: Monday, January 31, 2011 10:00:23 AM yyyy-MM-dd HH:mm:ss. Например: 2011-01-31 10:00:23 MMMM dd. Например: January 31 M/d/yyyy HH:mm:ss aa (зависит от локальных установок). Например: 10/30/2010 10:00:23 AM
604 Глава 20. Форматирование привязанных данных Списочные элементы управления WPF также поддерживают строковое форматирование для своих элементов. Чтобы использовать его, нужно просто установить свойство ItemStringFormat списка (унаследованное от базового класса ItemsControl). Ниже приведен пример со списком цен товаров: <ListBox Name="lstProducts" DisplayMemberPath="UnitCost" ItemStringFormat="{0:C}"> </ListBox> Форматированная строка автоматически передается привязке, которая захватывает текст каждого элемента списка. Что собой представляют конвертеры значений Свойство Binding.StringFormat создано для простого стандартного форматирования чисел и дат. Но во многих сценариях привязки требуется более мощный инструмент, который называется классом конвертера значений. Роль конвертера значений довольно очевидна. Он отвечает за преобразование исходных данных непосредственно перед их отображении в целевом элементе и (в случае двунаправленной привязки) и преобразование нового целевого значения непосредственно перед его применением к источнику. На заметку! Этот подход к преобразованию подобен способу, которым работает привязка данных в мире Windows Forms — через события привязки Format и Parse. Разница в том, что в приложении Windows Forms эта логика может быть закодирована повсюду — необходимо просто присоединить оба события к привязке. В WPF эта логика должна быть инкапсулирована в классе конвертера значений, что сделано для облегчения повторного использования. Конвертеры значений — исключительно удобная часть инфраструктуры привязки данных WPF. Их можно использовать несколькими удобными способами, которые перечислены ниже. • Форматирование данных для строкового представления. Например, можно преобразовывать число в строку валюты. Это — наиболее очевидное применение конвертеров значений, но, конечно же, не единственное. • Создание специфических типов объектов WPF. Например, можно прочитать блок двоичных данных и создать объект Bitmaplmage, который затем привязать к элементу Image. • Условное изменение свойства в элементе на основе привязанных данных. Например, можно создать конвертер значений, который изменяет цвет фона элемента для выделения значений из определенного диапазона. Форматирование строк с помощью конвертера значений Чтобы получить базовое представление о работе конвертера значений, вернемся к примеру форматирования денежной величины из предыдущего раздела. Хотя там использовалось свойство Binding. StringFormat, аналогичного (и даже большего) результата можно добиться с помощью конвертера значений. Например, можно округлять или усекать значения (изменяя 3.99 на 4), использовать словесное описание числа (изменив 1 000 000 на 1 million) и даже добавлять дилерскую маржу (увеличив 3.99 на 15%). Можно даже настроить работу обратного преобразования, заменяя введенные пользователем значения правильными значениями в привязанном объекте. Для создания конвертера значений потребуется выполнить четыре шага. 1. Создать класс, реализующий IValueConverter.
Глава 20. Форматирование привязанных данных 605 2. Добавить атрибут ValueConversion в объявление класса и указать исходный и целевой типы данных. 3. Реализовать метод Convert(), преобразующий данные из исходного формата в отображаемый формат 4. Реализовать метод ConvertBackO, выполняющий обратное преобразование значения из отображаемого формата в его "родной" формат. На рис. 20.2 можно видеть, как это работает. Объект-источник (объект данных) Свойство Конвертер значений < Convert!) ConvertBackf) > > Целевой объект (отображаемый элемент) Свойство зависимости (установленное привязкой) Рис. 20.2. Преобразование привязанных данных В случае преобразования десятичного значения в денежное можно воспользоваться методом Decimal.ToStringO, чтобы получить нужное отформатированное строковое представление. Для этого достаточно указать строку денежного формата "С": string currencyText = decimalPrice.ToString("С"); Этот код использует настройки культуры для текущего потока. Компьютер, настроенный для региона English (United States), работает с установкой локали en-US и отображает валюту со знаком доллара ($). Компьютер, сконфигурированный для другой локали, может отображать другой символ валюты. Если это не то, что нужно (например, необходимо, чтобы появлялся знак числа), можете указать культуру с помощью перегрузки метода ToString(): Culturelnfo culture = new Culturelnfo("en-US"); string currencyText = decimalPrice.ToString ("C", culture); Обратное преобразование от отображаемого формата к числовому немного сложнее. Методы Parse() и TryParseO типа Decimal — логичный способ выполнить эту работу, но обычно они не могут справиться со строками, включающими символы валюты. Решение заключается в применении перегруженных версий методов Parse () и TryParseO, которые принимают значение System.Globalization.NumberStyles. Применив стиль NumberStyles.Any, можно успешно избавиться от символа валюты, если он присутствует. Ниже приведен полный код конвертера значений, который имеет дело со значениями цены, хранимыми в свойстве Product.UnitCost. [ValueConversion(typeof(decimal), typeof(string))] public class PriceConverter : IValueConverter { public object Convert(object value, Type targetType, Culturelnfo culture) { decimal price = (decimal)value; return price.ToString("C", culture); object parameter,
606 Глава 20. Форматирование привязанных данных public object ConvertBack(object value, Type targetType, object parameter, Culturelnfo culture) { string price = value.ToString (culture); decimal result; if (Decimal.TryParse (price, NumberStyles.Any, culture, out result)) { return result; } return value; } } Чтобы ввести в действие этот конвертер, надо начать с отображения пространства имен проекта на префикс пространства имен XML, который можно применять в коде разметки. Ниже приведен пример, в котором используется префикс пространства имен и предполагается, что конвертер значений находится в пространстве имен DataBinding: xmlns:local="clr-namespace:DataBinding" Обычно этот атрибут будет добавляться к дескриптору <Windows>, который содержит всю разметку. Теперь необходимо просто создать экземпляр класса PriceConverter и присвоить его свойству Converter привязки. Чтобы сделать это, понадобится несколько более многословный синтаксис: <TextBlock Margin=" Grid.Row=">Unit Cost:</TextBlock> <TextBox Margin=" Grid.Row=" Grid.Column="l"> <TextBox.Text> <Binding Path="UnitCost"> <Binding.Converter> <local: PriceConverterx/local: PriceConverter> </Binding.Converter> </Binding> </TextBox.Text> </TextBox> Во многих случаях один и тот же конвертер используется для множества привязок. В этом случае не имеет смысла создавать отдельные экземпляры конвертера для каждой привязки. Вместо этого создайте один объект конвертера в коллекции Resources, как показано далее: <Window.Resources> <local:PriceConverter x:Key="PriceConverter"></local:PriceConverter> </Window.Resources> Затем можно указывать на него в привязке, используя ссылку StaticResource, как было описано в главе 10: <TextBox Margin=" Grid.Row=" Grid.Column="l" Text={Binding Path=UnitCost, Converter={StaticResource PriceConverter}"> </TextBox> Создание объектов с конвертером значений Конвертеры значений незаменимы, когда необходимо преодолеть зазор между способом сохранения данных в классах и способом их отображения в окне. Например, предположим, что есть графические данные, хранящиеся в виде массива байт в поле базы данных.
Глава 20. Форматирование привязанных данных 607 Существует возможность преобразования двоичных данных в объект System. Windows.Media.Imaging.Bitmaplmage и сохранения его как части объекта данных. Однако такое проектное решение может не подойти. Например, может понадобиться гибкость для создания более одного объектного представления изображения — возможно, потому, что библиотека данных используется как в приложениях WPF, так и в приложениях Windows Forms (где вместо этого применяется класс System.Drawing.Bitmap). В таком случае имеет смысл хранить низкоуровневую двоичную информацию в объекте данных и преобразовывать ее в WPF-объект Bitmaplmage с помощью конвертера значений. (Чтобы привязать его к форме в приложении Windows Forms, используйте события Format и Parse класса System.Windows. Forms. Binding.) Совет. Чтобы преобразовать двоичные данные в изображение, сначала нужно создать объект Bitmaplmage и прочитать данные изображения в MemoryStream. Затем можно вызвать метод Bitmaplmage.Beginlnit(), установить его свойство StreamSource, чтобы оно указывало на MemoryStream, и вызвать EndlnitO для завершения загрузки изображения. Таблица Products из базы данных Store не включает двоичных графических данных, но содержит поле Productlmage, хранящее имя файла с изображением товара. Во-первых, изображение может быть недоступным в зависимости от расхода памяти при работе приложения. Во-вторых, нет смысла в расходе дополнительной памяти для хранения изображения, если вы не собираетесь его показывать. Поле Productlmage включает имя файла, но не полный путь к этому файлу изображения, что дает определенную гибкость для размещения таких файлов в любом подходящем месте. Перед конвертером значений стоит задача создания URI, указывающего на файл изображения, на основе поля Productlmage и каталога, который будет использоваться для хранения таких файлов. Каталог задается в специальном свойстве по имени ImageDirectory, которое по умолчанию указывает на текущий каталог. Ниже приведен полный код ImagePathConverter, выполняющий преобразование. public class ImagePathConverter : IValueConverter { private string ImageDirectory = Directory.GetCurrentDirectory (); public string ImageDirectory { get { return ImageDirectory; } set { ImageDirectory = value; } } public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { string imagePath = Path.Combine(ImageDirectory, (string)value); return new Bitmaplmage(new Uri(imagePath)); } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.Culturelnfо culture) { throw new NotSupportedException(); } } Чтобы использовать этот конвертер, начните с добавления его в Resources. В этом примере свойство ImageDirectory не установлено, а это значит, что ImagePathConverter по умолчанию работает с текущим каталогом:
608 Глава 20. Форматирование привязанных данных ■ DisplayBoundlmages Communications Device тмявмшаиш Muttt-Purpose Rubber Band ; Universal Repair System Elective Flashlight Model Number i_<4TLN Model Name: ^ersuas 5 :' Hlf'^jjl ШШШШШШ 1 H Get Products ve Pencil <Window.Resources> <local:ImagePathConverter x:Key="ImagePathConverter"x/local:ImagePathConverter> </Window.Resources> Теперь легко создать выражение привязки, использующее этот конвертер значений: <Image Margin=" Grid.Row=" Grid.Column="l" Stretch="None" HorizontalAlignment="Left" Source= "{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> Это работает, поскольку свойство Image.Source ожидает объекта ImageSource, a класс Bitmaplmage унаследован от ImageSource. Результат показан на рис. 20.3. Этот пример можно усовершенствовать несколькими способами. Попытка создать Bitmaplmage, указывающий на несуществующий файл, вызовет исключение, которое будет получено при установке свойств DataContext, ItemsSource или Source. В качестве альтернативы можно добавить свойства в класс ImagePathConverter, которые позволят настроить это поведение. Например, можно предусмотреть свойство SuppressExceptions булевского типа. Если оно установлено в true, можно перехватывать исключения в методе Convert () и затем возвращать значение Binding.DoNothing (которое укажет WPF действовать так, как будто никакой привязки данных не установлено). Или же можно добавить свойство Defaultlmage, которое будет принимать Bitmaplmage. Тогда в случае возникновения исключений ImagePathConverter сможет вернуть изображение, выбранное по умолчанию. Также следует отметить, что этот конвертер поддерживает только однонаправленное преобразование. Причина в том, что изменить объект Bitmaplmage невозможно и применять его для обновления пути к изображению. Однако можно воспользоваться альтернативным подходом. Вместо возврата Bitmaplmage из ImagePathConverter можно просто вернуть полностью квалифицированный URI из метода Convert (), как показано ниже: return new Uri(imagePath); Это работает успешно, поскольку элемент Image использует конвертер типа для трансляции Uri в объект ImageSource, который ему в действительности нужен. Применив такой подход, впоследствии можно разрешить пользователю выбирать новый путь к файлу (возможно, с помощью элемента Text Box, значение которого устанавливается с использованием класса OpenFileDialog). В методе ConvertBackO можно затем извлечь имя файла и применять его для обновления пути к изображению, которое хранится в объекте данных. Применение условного форматирования Некоторые из наиболее интересных конвертеров значений не предназначены для форматирования данных для целей презентации. Вместо этого они служат для форматирования ряда других связанных с внешним видом аспектов элемента на основе правила данных. Рис. 20.3. Показ привязанных изображений
Глава 20. Форматирование привязанных данных 609 Например, предположим, что требуется выделить самые дорогостоящие товары, окрасив фон другим цветом. Эту логику легко инкапсулировать в следующем конвертере значений: public class PriceToBackgroundConverter : IValueConverter { public decimal MinimumPriceToHighlight { get; set; } public Brush HighlightBrush { get; set; } public Brush DefaultBrush { get; set; } public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { decimal price = (decimal)value; if (price >= MinimumPriceToHighlight) return HighlightBrush; else return DefaultBrush; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { throw new NotSupportedException (); } } Этот конвертер значений тщательно спроектирован с учетом возможности повторного использования. Вместо жесткого кодирования цвета для выделения задаются в XAML-размете кодом, который использует этот конвертер: <local:PriceToBackgroundConverter x:Key="PriceToBackgroundConverter" DefaultBrush="{x:Null}" HighlightBrush="Orange" MinimumPriceToHighlight=0"> </local:PriceToBackgroundConverter> Вместо цветов применяются кисти, так что можно создавать и более совершенные эффекты выделения, применяя градиенты и фоновые изображения. Чтобы сохранить стандартный прозрачный фон (таким образом, будет использоваться фон родительского элемента), просто установите свойство DefaultBrush или HighlightBrush в Null, как показано выше. Теперь все, что осталось сделать для использования этого конвертера — установить цвет некоторого элемента, подобного Border, который содержит все прочие элементы: <Border Background= "{Binding Path=UnitCost, Converter={StaticResource PriceToBackgroundConverter}}" . . . >
610 Глава 20. Форматирование привязанных данных Другие способы применения условного форматирования Использование специального IValueConverter — только один из возможных способов условного форматирования на основе объекта данных. Можно также применять триггеры данных в стиле, селекторе стиля и селекторе шаблона — все они будут описаны в следующей главе. Каждый из этих подходов обладает своими достоинствами и недостатками. Подход с IValueConverter работает лучше, когда нужно установить единственное свойство в элементе, базирующемся на привязанном объекте данных. Это легко, и они при этом синхронизируются автоматически. Если проводятся изменения в привязанном объекте данных, связанное с ним свойство изменяется немедленно. Триггеры данных столь же просты, однако они поддерживают только самую примитивную логику проверки на эквивалентность. Например, триггер данных может применить форматирование, касающееся товаров определенной категории, но не может применить форматирование на основе цены, превышающей определенное минимальное значение. Ключевое преимущество триггеров данных состоит в том, что их можно использовать для применения определенных типов эффектов форматирования и выбора без написания какого-либо кода. Селекторы стиля и селекторы шаблонов — наиболее мощное средство. Они позволяют сразу модифицировать множество свойств в целевом элементе и изменять способ представления элементов списка. Однако они привносят дополнительную сложность. К тому же понадобится добавлять код, который повторно применяет стили и шаблоны в случае изменения привязанных данных. Оценка множества свойств До сих пор выражения привязки использовались для преобразования одной части исходных данных в единый форматированный результат. И хотя изменить вторую часть уравнения (результат) не удастся, приложив немного усилий, можно создать привязку, которая вычисляет или комбинирует информацию из более чем одного исходного свойства. Первый трюк состоит в замене объекта Binding на MultiBinding. Затем в свойстве MultiBinding.StringFormat определяется организация привязанных свойств. Ниже приведен пример, который объединяет фамилию с именем и отображает результат в TextBlock: <TextBlock> <TextBlock.Text> <MultiBinding StringFormat="{1}, {0}"> <Binding Path="FirstName"></Binding> <Binding Path="LastName"></Binding> </MultiBinding> </TextBlock.Text> </TextBlock> В этом примере два поля используются, как они есть, в свойстве StringFormat. В качестве альтернативы можно применять форматные строки, чтобы изменить это. Например, при комбинировании с помощью MultiBinding текстового значения и значения валюты можно установить StringFormat в "{0} costs {1:C}". Если хотите сделать с этими двумя исходными полями что-то более изощренное, чем просто соединить их вместе, понадобится помощь конвертера значений. Такое прием позволяет производить вычисления (вроде умножения UnitPrice HaUnitsInStock) либо применять форматирование, которое принимает во внимание несколько деталей (таких как подсветка всех товаров с наивысшей ценой в указанной категории). Однако для это-
Глава 20. Форматирование привязанных данных 611 го конвертер значений должен реализовывать интерфейс IMultiValueConverter вместо IValueConverter. Рассмотрим пример, в котором MultiBinding использует свойства UnitCost и UnitsInStock исходного объекта и комбинирует их посредством конвертера значений: <TextBlock>Total Stock Value: </TextBlock> <TextBox> <TextBox.Text> <MultiBinding Converter="{StaticResource ValueInStockConverter}"> <Binding Path="UnitCost"></Binding> <Binding Path="UnitsInStock"x/Binding> </MultiBinding> </TextBox.Text> </TextBox> Интерфейс IMultiValueConverter определяет методы Convert () и ConvertBack() — аналогично интерфейсу IValueConverter. Главное отличие в том, что вместо единственного значения передается массив значений. Эти значения помещаются в том же порядке, как они определены в разметке. Таким образом, в предыдущем примере можно ожидать появления UnitCost первым, а за ним — UnitsInStock. Вот как выглядит код ValuelnStockConverter: public class ValuelnStockConverter : IMultiValueConverter { public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture) { // Вернуть общую стоимость всех товаров на складе, decimal unitCost = (decimal)values [0] ; int unitsInStock = (int)values [1] ; return unitCost * unitsInStock; } public object [] ConvertBack(object value, Type [ ] targetTypes, object parameter, System.Globalization.Culturelnfo culture) { throw new NotSupportedException(); } } Списочные элементы управления Форматирование строки и конвертеры значений — это все, что нужно для применения гибкого форматирования к индивидуальным привязанным значениям. Но привязанным спискам требуется нечто большее. К счастью, WPF предлагает несколько решений форматирования. Большинство из них встроено в базовый класс ItemsControl, от которого наследуются все списочные элементы управления, так что именно отсюда следует начать исследование форматирования списков. Как известно, класс ItemsControl определяет базовую функциональность для элементов управления, которые содержат в себе список элементов. Этими элементами могут быть вхождения в списке, узлы дерева, команды меню, кнопки в панели инструментов и т.п. На рис. 20.4 показана общая диаграмма классов ItemsControl в WPF.
612 Глава 20. Форматирование привязанных данных (S DispatcherObject J DependencyObject х Visual ze: UlElement Условные обозначения Г Абстрактный ] у класс J Конкретный класс FrameworkElement Control ItemsControl Selector HeaderedltemsControl TabControl H ToolBar H ComboBox 4 ListBox x H Menultem I MenuBase A -j Menu J ContextMenu TreeView 4 StatusBar TreeViewltem ListView Рис. 20.4. Классы, унаследованные от ItemsControl На заметку! Вы заметите, что некоторые оболочки элементов списков появляются в иерархии классов, унаследованных от ItemsControl. Например, здесь будут не только ожидаемые классы Menu и TreeView, но также Menultem и TreeViewltem. Причина в том, что эти классы обладают способностью содержать собственные коллекции подэлементов — именно это обеспечивает деревьям и меню их вложенную иерархическую структуру. С другой стороны, вы не найдете здесь ComboBoxItem и ListBoxItem, поскольку они не содержат коллекций вложенных элементов, и потому не наследуются от ItemsControl. В ItemsControl определены свойства, которые поддерживают привязку данных, и два ключевых средства форматирования: стили и шаблоны данных. Оба эти средства рассматриваются в последующих разделах. В табл. 20.3 содержится краткий обзор свойств, поддерживающих их. (Свойства перечисляются от самых базовых до наиболее сложных, причем в порядке их рассмотрения в этой главе.) Следующий элемент иерархии наследования ItemsControl — класс Selector, который добавляет простой набор свойств для определения (и установки) выбранного элемента. Не все классы ItemsControl поддерживают выбор. Например, выбор не имеет никакого значения для ToolBar или Menu, потому эти классы наследуются от ItemsControl, а не от Selector.
Глава 20. Форматирование привязанных данных 613 Таблица 20.3. Свойства класса ItemsControl, относящиеся к форматированию Наименование Описание ItemsSource DisplayMemberPath ItemsStringFormat ItemContainerStyle ItemContainerStyleSelector AlternationCount ItemTemplate ItemTemplateSelector ItemsPanel GroupStyle GroupStyleSelector Привязанный источник данных (коллекция DataView, которую необходимо отобразить в списке) Свойство, которое должно отображаться для каждого элемента данных. Для более сложного представления или комбинирования свойств используйте вместо него ItemsTemplate Строка формата .NET, которая, будучи установленной, используется для форматирования текста каждого элемента. Обычно этот прием используется для преобразования числовых значений или значений дат в подходящее видимое представление — точно так же, как это делает свойство Binding. StringFormat Стиль, позволяющий устанавливать свойства контейнера, который включает в себя каждый элемент. Контейнер зависит от типа списка (например, это ListBoxItem для класса ListBox и ComboBoxItem — для класса ComboBox). Объекты-оболочки создаются автоматически при наполнении списка StyleSelector, использующий код для выбора стиля оболочки каждого элемента в списке. Это позволяет назначать разным элементам списка различные стили. Специальный класс StyleSelector должен быть создан самостоятельно Число чередующихся наборов для данных. Например, при значении AlternationCount, равном 2, варьируется 2 стиля строк, при AlternationCount, равном 3, существует три чередующихся стиля строк и т.д. Шаблон, извлекающий соответствующие данные из привязанного объекта и организующий их в соответствующую комбинацию элементов управления DataTemplateSelector, использующий код для выбора шаблона каждого элемента в списке. Это позволяет назначать разным элементам различные шаблоны. Специальный класс DataTemplateSelector должен быть создан самостоятельно Определяет панель, созданную для хранения всех элементов списка. Все оболочки элементов добавляются в этот контейнер. Обычно для вертикальной ориентации списка (сверху вниз) применяется VirtualizingStackPanel Если используется группирование, это стиль, определяющий, как должна быть сформатирована каждая группа. При использовании группирования оболочки элементов (ListBoxItem, ComboBoxItem и т.п.) добавляются к оболочкам Groupltem, представляющим каждую группу, и эти группы затем добавляются в список. Группирование рассматривается в главе 21 StyleSelector, использующий код для выбора стиля каждой группы. Это позволяет назначать разным группам различные стили. Специальный класс StyleSelector должен быть создан самостоятельно К свойствам, которые добавляет класс Selector, относятся Selectedltem (выбранный объект данных), Selectedlndex (позиция выбранного элемента) и SelectedValue (свойство "значения" выбранного объекта данных, которое указывается установкой
614 Глава 20. Форматирование привязанных данных SelectedValuePath). Обратите внимание, что класс Selector не поддерживает множественный выбор — это добавляется к ListBox посредством свойств SelectionMode и Selectionltems (что по существу и все, что класс ListBox добавляет к этой модели). Стили списков В оставшейся части этой главы рассматриваются два средства, которые предоставляются всеми списочными элементами управления WPF: стили и шаблоны данных. Из двух этих инструментов стили — более простой (и менее мощный). Во многих случаях они позволяют добавить некоторое изощренное форматирование. В следующих разделах будет показано, как с помощью стилей форматировать элементы списка, применять чередующееся форматирование и применять любое условное форматирование в соответствии с заданными критериями. Стиль ItemContainerStyle В главе 11 вы узнали, что стили позволяют повторно использовать форматирование с похожими элементами в разных местах. Почти ту же роль играют стили списков — они позволяют применить набор характеристик форматирования к каждому из индивидуальных элементов списка. Это важно, потому что система привязки данных WPF генерирует объекты-элементы списка автоматически. В результате не так легко применить нужное форматирование к индивидуальным элементам. Решение обеспечивает свойство ItemsContainerStyle. Если свойство ItemsContainerStyle установлено, списочный элемент управления передаст его каждому своему элементу при его создании. В случае элемента управления ListBox каждый элемент представлен объектом ListBoxItem. (В ComboBox это ComboBoxItem и т.д.) Таким образом, любой стиль, который применяется посредством свойства ListBox.ItemContainerStyle, используется для установки свойств каждого объекта ListBoxItem. Ниже показан один из простейших эффектов, которые можно реализовать с помощью ListBoxItem. Он применяет серо-голубой фон к каждому элементу. Для отделения индивидуальных элементов друг от друга (вместо общего сливающегося фона) стиль также добавляет некоторое пространство под поля: <ListBox Name="lstProducts" Margin=" DisplayMemberPath="ModelName"> <ListBox.ItemContainerStyle> <Style> <Setter Property="ListBoxItem.Background" Value="LightSteelBlue" /> <Setter Property="ListBoxItem.Margin" Value=" /> <Setter Property="ListBoxItem. Padding" Value=,,5" /> </Style> </ListBox.ItemContainerStyle> </ListBox> Само по себе это не особенно интересно. Однако стиль становится немного более интересным с добавлением к нему триггеров. В следующем примере триггеры свойства изменяют цвет фона и добавляют сплошную рамку, когда свойство ListBoxItem. IsSelected получает значение true. Результат можно видеть на рис. 20.5. <ListBox Name="lstProducts" Margin=" DisplayMemberPath="ModelName"> <ListBox.ItemContainerStyle> <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="Background" Value="LightSteelBlue" /> <Setter Property="Margin" Value=" />
Глава 20. Форматирование привязанных данных 615 <Setter Property="Padding" Value=" /> <Style.Triggers> <Trigger Property=,,IsSelected" Value="True"> <Setter Property=,,Background" Value=,,DarkRed" /> <Setter Property="Foreground" Value="White" /> <Setter Property=,,BorderBrush" Value=MBlackM /> <Setter Property="BorderThickness" Value="l" /> </Trigger> </Style.Triggers> </Style> </ListBox.ItemContainerStyle> </ListBox> Для более ясной разметки в этом стиле используется свойство Style.TargetType, так что он может устанавливать свойства без включения имени класса в каждое средство установки. Такое применение триггеров особенно удобно, потому что в ListBox не предусмотрено никакого другого способа применить нужное форматирование к выбранному элементу. Другими словами, если не использовать стиль, останется стандартная синяя подсветка выбранного элемента. Далее в этой главе, во время использования шаблонов данных, чтобы полностью преобразить списки данных, стиль ItemsContainerStyle опять будет применяться для изменения эффекта выбранного элемента. Escape Vehicle (Water) lunications Device Persuasive Pencil Multi-Purpose Rubber Band Model Numl Model Name; ! Escape Vehicle (Water) Unit Cost Рис. 20.5. Использование триггеров стиля для изменения подсветки выбранного элемента
616 Глава 20. Форматирование привязанных данных Элемент ListBox с флажками или переключателями Стиль ItemsContainerStyle также важен, если нужно глубоко проникнуть в списочный элемент управления и изменить шаблон, используемый содержащимися в нем элементами. Например, этот прием можно использовать, чтобы заставить каждый ListBoxItem отображать переключатель или флажок рядом с текстом элемента списка. На рис. 20.6 и 20.7 демонстрируются два примера — один со списком, наполненным элементами RadioButton (из которых только один может быть выбран в каждый данный момент времени), а другой — со списком элементов CheckBox. Эти два решения похожи, но список с переключателями создать немного легче. • RadioButtonList О Rain Racer 2000 ' Edible Tape ; Escape Venule (Air) <• Extracting Tool Escape Vehicle (Water) Communications Device ' Persuasive Pencil Multi-Purpose Rubber Band Universal Repair System Elective Flashlight - 1 Get Selected Item Рис. 20.6. Список переключателей, использующий шаблон I CheckBoxList i 13 Rain Racer 2000 Ecibte Tape Escape Vehicle (Air) ::mg Tool Communications Device ,['.] Persuasive Pencil Multi-Purpose Rubber Band И Universal Repair System Effective Flashl»ght Рис. 20.7. Список флажков, использующий шаблон На заметку! На первый взгляд, использование шаблонов для изменения ListBoxItem может показаться лишней работой. В конце концов, эту проблему достаточно легко решить посредством композиции. Все, что понадобится сделать — наполнить ScrollViewer серией объектов CheckBox. Однако эта реализация не предоставляет той же программной модели. Нет простого способа пройтись по всем флажкам и, что важнее, такую реализацию нельзя использовать с привязкой данных. Базовая техника, применяемая в этом примере, состоит в замене шаблона элемента управления, используемого в качестве контейнера для каждого элемента списка. Модифицировать свойство ListBox.Template не понадобится, поскольку оно обеспечивает шаблон для ListBox. Вместо этого нужно модифицировать свойство ListBoxItem. Template. Здесь показан шаблон, который необходим для помещения каждого элемента списка в оболочку RadioButton: <ControlTemplate TargetType="{x:Type ListBoxItem}"> <RadioButton Focusable=MFalseM IsChecked="{Binding Path=IsSelected, RelativeSource={RelativeSource TemplatedParent},Mode=TwoWay}"> <Con tent Pre senterx/ Con ten t Presenter > </RadioButton> </ControlTemplate> Это работает, потому что RadioButton — элемент с содержимым, и может включать в себя любое содержимое. Хотя для получения содержимого можно было бы применить выражение привязки, намного более гибкое решение дает использование
Глава 20. Форматирование привязанных данных 617 элемента ContentPresenter, как показано здесь. ContentPresenter охватывает то, что обычно появляется в элементе — текстовое свойство (если применяется List Box. DisplayMemberPath) или более сложное представление данных (если используется свойство ListBox.ItemTemplate). Настоящий трюк — привязка выражения к свойству RadioButton.IsChecked. Это выражение извлекает значение свойства ListBoxItem.IsSelected, используя свойство Binding.RelativeSource. Таким образом, в результате щелчка на RadioButton с целью его выбора соответствующий ListBoxItem помечается как выбранный. В то же время все остальные становятся невыбранными. Это выражение привязки также работает и в противоположном направлении, а это означает, что установка выбора в коде приводит к тому, что соответствующий RadioButton будет помечен. Для завершения этого шаблона потребуется установить свойство RadioButton. Focusable в false. В противном случае будет возможен переход клавишей <ТаЬ> к текущему выбранному ListBoxItem, а затем — к самому RadioButton, в чем нет смысла. Чтобы установить свойство ListBoxItem.Template, нужно правило стиля, которое может добраться до нужного уровня. Благодаря свойству ItemsContainerStyle, сделать это легко: <Window.Resources> <Style x:Key="RadioButtonListStyle11 TargetType=" {x: Type ListBox}"> <Setter Property="ItemContainerStylell> <Setter.Value> <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="Margin11 Value=11 /> <Setter Property=llTemplate"> <Setter.Value> <ControlTemplate TargetType="{x:Type ListBoxItem}"> <RadioButton Focusable="False11 IsChecked="{Binding Path=IsSelected, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent} }"> <ContentPresenter></ContentPresenter> </RadioButton> </ControlTemplate> </Setter.Value> </Setter> </Style> </Setter.Value> </Setter> </Style> </Window.Resources> Хотя можно было бы установить свойство ListBox.ItemContainerStyle непосредственно, этот пример переносит его на более высокий уровень. Стиль, устанавливающий шаблон ListBox.ItemControl, помещен в другой стиль, применяющий данный стиль к свойству ListBox.ItemContainerStyle. Это делает шаблон многократно используемым, позволяя применять его к любому количеству объектов List Box. <ListBox Style="{StaticResource RadioButtonListStyle}" Name="lstProducts11 DisplayMemberPath=llModelName"> Тот же самый стиль можно также использовать для изменения других свойств ListBox. Создать ListBox, который содержит флажки (С he с к В ох), столь же легко. Фактически понадобится внести всего два изменения. Во-первых, заменить элемент RadioButton идентичным элементом CheckBox.
618 Глава 20. Форматирование привязанных данных Во-вторых, изменить свойство ListBox.SelectionMode, чтобы ращрешить множественный выбор. После этого пользователь сможет помечать столько элементов, сколько захочет. Вот правило стиля, которое превращает обычный ListBox в список флажков: <Style x:Key=MCheckBoxListStyle" TargetType="{x:Type ListBox}"> <Setter Property="SelectionMode11 Value=llMultiple"></Setter> <Setter Property="ItemContainerStylell> <Setter.Value> <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="Margin11 Value=" /> <Setter Property="Templatell> <Setter.Value> <ControlTemplate TargetType="{x:Type ListBoxItem}"> <CheckBox Focusable=llFalseM IsChecked="{Binding Path=IsSelected, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent} }"> <ContentPresenter></ContentPresenter> </CheckBox> </ControlTemplate> </Setter.Value> </Setter> </Style> </Setter.Value> </Setter> </Style> Стиль чередующихся элементов Один распространенный способ форматирования списка состоит в использовании чередующегося форматирования строк, другими словами, набора характеристик форматирования, которые отличаются в каждом втором элементе списка. Часто им задается слегка отличающийся цвет фона, чтобы строки были четко различимы, как показано на рис. 20.8. WPF предлагает встроенную поддержку чередующихся элементов через два свойства: AlternationCount и Alternationlndex. Свойство AlternationCount — это количество элементов, формирующих последовательность, после которой стиль переключается. По умолчанию свойство AlternationCount установлено в 0, и чередующееся форматирование не используется. Если вы установите AlternationCount в 1, стиль будет меняться после каждой строки, что позволит применить шаблон форматирования "четный-нечетный", показанный на рис. 20.8. Каждый экземпляр ListBoxItem получает Alternationlndex, который позволяет определить, как он пронумерован в последовательности чередующихся элементов. Если свойство AlternationCount установлено в 2, то первый элемент ListBoxItem получает Alternationlndex, равный 0, второй — 1, третий — снова 0, четвертый — 1, и т.д. Трюк состоит в использовании в стиле ItemContainerStyle триггера, который проверяет значение Alternationlndex и соответственно варьирует форматирование. ■ ! AlternatingBadcground Rain Racer 2000 Edible Tape i Escape Vehicle (Air) Extracting Tool Escape Vehicle (Water) ; Communications Device i Persuasive Pencil : Multi-Purpose Rubber Band : Universal Repair System i Effective Flashlight • The Incredible Versatile Paperclip ; Toaster Boat ; Multi-Purpose Towetette Minhtx/ Minhb/ Don | I . Рис. 20.8. Чередующаяся подсветка строк
Глава 20. Форматирование привязанных данных 619 Например, показанный ниже элемент управления List Box слегка изменяет цвет фона соседних элементов списка (если только элемент не выбран — в этом случае выигрывает более высокоприоритетный триггер для ListBoxItem.IsSelected): <ListBox Name="lstProducts11 Margin=11 DisplayMemberPath="ModelName11 AlternationCount=ll> <ListBox.ItemContainerStyle> <Style TargetType="{x:Type ListBoxItem}"> <Setter Property="Background" Value="LightSteelBlue11 /> <Setter Property="Margin11 Value=11 /> <Setter Property="Padding11 Value=11 /> <Style.Triggers> <Trigger Property="ItemsControl .Alternationlndex" Value="lll> <Setter Property="Background" Value=llLightBlueM /> </Trigger> <Trigger Property="IsSelected11 Value="True"> <Setter Property=llBackgroundM Value=llDarkRedM /> <Setter Property=llForegroundM Value=llWhiteM /> <Setter Property=,,BorderBrush" Value=,,Black" /> <Setter Property="BorderThickness11 Value="l11 /> </Trigger> </Style.Triggers> </Style> </ListBox.ItemContainerStyle> </ListBox> Обратите внимание, что Alternationlndex — присоединенное свойство, которое определено в классе List Box (формально — в классе ItemsControl, от которого он унаследован). Это свойство не определено в классе ListBoxItem, поэтому при использовании триггера стиля должно указываться имя класса. Интересно, что элемент с чередующимся стилем не обязательно должен быть каждым вторым. Вместо этого можно создавать более сложные варианты чередующегося форматирования, состоящие из последовательности в три и более элементов. Например, чтобы использовать три группы, установите для свойства AlternationCount значение 3 и напишите триггеры для любого из трех возможных значений Alternationlndex (О, 1 или 2). В списке элементы 1, 4, 7, 10 и т.д. получат значение Alternationlndex, равное 0. Элементы 2, 5, 8, 11 и т.д. — значение Alternationlndex, равное 1. И, наконец, элементы 3, 6, 9, 12 и т.д. — значение Alternationlndex, равное 2. Селекторы стиля Вы уже видели, как варьировать стиль на основе выбранного состояния элемента или его позиции в списке. Однако может понадобиться учесть множество других условий — критериев, зависящих от данных, а не от содержащего их контейнера ListBoxItem. Чтобы справиться с такой ситуацией, нужен способ указания для различных элементов совершенно разных стилей. К сожалению, это невозможно делать декларативно. Взамен придется построить специализированный класс, унаследованный от StyleSelector. Этот класс отвечает за исследование каждого элемента данных и выбор соответствующего стиля. Эта работа выполняется в методе SelectStyleO, который допускается переопределять. Вот как выглядит простейший селектор, выбирающий между двумя стилями: public class ProductByCategoryStyleSelector : StyleSelector { public override Style SelectStyle(object item, DependencyObject container) { Product product = (Product)item;
620 Глава 20. Форматирование привязанных данных Window window = Application.Current.MainWindow; if (product.CategoryName == "Travel") { return (Style)window.FindResource("TravelProductstyle"); } else { return (Style)window.FindResource("DefaultProductStyle"); } } } В этом примере товары, находящиеся в категории Travel, получают один стиль, а прочие товары — другой. В примере оба используемых стиля должны быть определены в коллекции Resources окна, с ключевыми именами Travel Product Style и DefaultProductStyle. Этот селектор стиля работает, хотя и не идеален. Одна проблема заключается в том, что код зависит от деталей разметки, а это означает, что есть зависимость, которая не обеспечивается во время компиляции, и потому легко может быть нарушена (например, в случае предоставления стилям неверных ключей ресурсов). Другая проблема в том, что этот селектор стиля жестко кодирует значение, которое ищет (в данном случае — имя категории), что ограничивает его повторное использование. Лучше было бы создать селектор стиля, использующий одно или более свойств, чтобы позволить указывать некоторые из этих деталей, такие как критерий, применяемый для оценки элементов данных, и стили, которые нужно использовать. Следующий селектор стиля также довольно прост, но исключительно гибок. Он может проверить любой объект данных, найти заданный приоритет и сравнить его с приоритетом другого значения, чтобы выбрать один из двух стилей. Свойство, значение свойства и стили специфицированы в виде свойств. Метод SelectStyleO использует рефлексию для нахождения правильного приоритета, в манере, подобной тому, как работает привязка данных при нахождении привязанных значений. Вот полный код: public class SingleCriteriaHighlightStyleSelector : StyleSelector { public Style DefaultStyle get; set; public Style HighlightStyle get; set; public string PropertyToEvaluate get; set; public string PropertyValueToHighlight get; set; public override Style SelectStyle(object item, DependencyObject container) Product product = (Product)item; // Использовать рефлексию для получения проверяемого свойства. Type type = product.GetType();
Глава 20. Форматирование привязанных данных 621 Propertylnfo property = type.GetProperty(PropertyToEvaluate); // Решить, должен ли товар быть выделенным // в списке на основе значения свойства. if (property.GetValue(product, null) .ToString () == PropertyValueToHighlight) { return HighlightStyle; } else { return DefaultStyle; } } } Чтобы это работало, понадобится создать два стиля, которые будут использоваться, а также создать и инициализировать экземпляр SingleCriteriaHighlight StyleSelector. Ниже приведены два похожих стиля, которые отличаются только цветом фона и форматированием с помощью полужирного начертания: <Window.Resources> <Style x:Key=,,DefaultStyle" TargetType=" {x: Type ListBoxItem} "> <Setter Property=llBackgroundM Value="LightYellow11 /> <Setter Property="Padding11 Value=11 /> </Style> <Style x:Key="HighlightStyle11 TargetType=" {x: Type ListBoxItem} "> <Setter Property="Background11 Value="LightSteelBlue11 /> <Setter Property="FontWeight11 Value="Bold11 /> <Setter Property=llPaddingM Value=" /> </Style> </Window.Resources> При создании SingleCriteriaHighlightStyleSelector указывается на эти два стиля. Можно также создать SingleCriteriaHighlightStyleSelector как ресурс (что удобно, если он должен повторно использоваться в нескольких местах) или же определить его встроенным образом в списочном элементе управления, как в следующем примере: <ListBox Name="lstProducts11 НогizontalContentAlignment="Stretch"> <ListBox.ItemContainerStyleSelector> <local:SingleCriteriaHighlightStyleSelector DefaultStyle="{StaticResource DefaultStyle}" HighlightStyle="{StaticResource HighlightStyle}" PropertyToEvaluate="CategoryName11 PropertyValueToHighlight="Travel" > </local:SingleCriteriaHighlightStyleSelector> </ListBox.ItemContainerStyleSelector> </ListBox> Здесь SingleCriteriaHighlightStyleSelector ищет свойство Category в привязанном элементе данных и использует HighlightStyle, если он содержит текст Travel. Результат показан на рис. 20.9. Процесс выбора стилей выполняется один раз, когда список привязывается в первый раз. Это становится проблемой при отображении редактируемых данных, когда в результате редактирования какой-то элемент данных перемещается из одной категории стиля в другую. В такой ситуации потребуется заставить WPF заново применить стили, а простого способа сделать это не существует. Подход на основе грубой силы состоит в
622 Глава 20. Форматирование привязанных данных удалении селектора стиля установкой свойства ItemContainerStyleSelector в null, с последующим его переназначением: StyleSelector selector = IstProducts.ItemContainerStyleSelector; IstProducts.ItemContainerStyleSelector = null; IstProducts.ItemContainerStyleSelector = selector; Можно организовать запуск этого кода автоматически в ответ на определенные изменения, обрабатывая такие события, как PropertyChanged (которое инициируется всеми классами, реализующими интерфейс INotifyPropertyChanged, включая Product), DataTable.RowChanged (если используются объекты данных ADO.NET) и в более общем случае — Binding.SourceUpdated (которое инициируется, только когда Binding.NotifyOnSourceUpdated равно true). При повторном присваивании селектора стиля WPF проверяет и обновляет каждый элемент в списке — процесс, не занимающий много времени в списках малых и средних размеров. ■ VanedStytes Extracting Tool (NOZ119) Communications Device (RED1) Persuasive Pencil (UC4TLNT) Murti-Purpose Rubber Band (NTMBS1) Universal Repair System (NE1RPR) Effective Flashlight (BRTLGT1) The Incredible Versatile Paperclip QNCPPRCLP) (TGFDA) Mighty Mighty Pen (WOWPEN) Perfect-Vision Glasses (ICNCU) ModeJNumbe' Model Name: Ra - Unit Cost Descnption: Looks like an ordinary bumbershoot but don't be fooled! Simply place Rain Racer's tip on the ground and press the release latch. Within seconds, this ordinary rain umbrella converts into a two- wheeled gas-powered mini- scooter. Goes from 0 to 60 in 7.5 seconds - even in a driving rain! Comes in blade, blue and candy-apple red. Change One Item Рис. 20.9. Список с двумя стилями элементов Шаблоны данных Стили предоставляют некоторые базовые возможности форматирования, но они не преодолевают наиболее существенные ограничения списков, которые демонстрировались до сих пор: независимо от того, как изменяется ListBoxItem, это всего лишь ListBoxItem, а не более сложная комбинация элементов. И поскольку каждый ListBoxItem поддерживает только одно привязанное поле (установленное в свойстве Di splay Member Path), нет способа создать развитый список, включающий в себя множество полей или графических изображений. Однако в WPF есть другой инструмент, который может преодолеть эти тесные рамки, позволив использовать комбинацию свойств привязанного объекта и скомпоновать его специфическим способом либо отобразить визуальное представление, более сложное, чем простая строка. Этим инструментом является шаблон данных. Шаблон данных (data template) — это фрагмент XAML-разметки, который определяет, как привязанный объект данных должен быть отображен.
Глава 20. Форматирование привязанных данных 623 Шаблоны данных поддерживают два типа элементов управления. • Элементы управления содержимым поддерживают шаблоны данных через свойство ContentTemlate. Шаблон содержимого используется для отображения того, что помещается в свойство Content. • Списочные элементы управления (элементы, унаследованные от ItemsControl) поддерживают шаблоны данных через свойство ItemTemplate. Этот шаблон используется для отображения каждого элемента списка из коллекции (или каждой строки из DataTable), которая применяется в качестве ItemsSource. Средство основанного на списке шаблона на самом деле базируется на шаблонах элементов управления с содержимым. Причина в том, что каждый элемент в списке помещен в оболочку элемента управления содержимым, такого как ListBoxItem для ListBox, ComboBoxItem для ComboBox и т.д. Какой бы шаблон не был указан для свойства ItemTemplate списка, он используется как ContentTemlate каждого элемента в списке. Так что же можно поместить внутрь шаблона данных? На самом деле все довольно просто. Шаблон данных — это простой блок XAML-разметки. Подобно любому другому блоку XAML-разметки, шаблон может включать любую комбинацию элементов. Он также должен включать одно или более выражений привязки, которые извлекают информацию для отображения. (В конце концов, если не предусмотреть никаких выражений привязки данных, то каждый элемент в списке будет выглядеть одинаково, от чего мало толку.) Лучший способ увидеть работу шаблона данных — начать с базового списка, который шаблон не использует. Например, рассмотрим следующее окно списка, которое было показано ранее: <ListBox Name="lstProducts11 DisplayMemberPath=llModelNamellx/ListBox> Того же эффекта можно добиться с окном списка, использующим шаблон данных: <ListBox Name=lllstProducts"> <ListBox.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding Path=ModelName}"></TextBlock> </DataTemplate> </ListBox.ItemTemplate> </ListBox> Когда список привязан к коллекции товаров (установкой свойства ItemsSource), для каждого Product создается отдельный ListBoxItem. Свойство ListBoxItem.Content устанавливается в соответствующий объект Product, a ListBoxItem.ContentTemplate — в шаблон данных, показанный ранее, который извлекает значение из свойства Product.ModelName и отображает его в TextBlock. До сих пор полученные результаты не впечатляли. Но теперь, переключившись на шаблон данных, практически исчезли границы в изобретательном представлении данных. Ниже приведен пример, в котором каждый элемент списка заключается в прямоугольник со скругленными углами, отображаются две порции информации, и используется форматирование полужирным начертанием для выделения номера модели: <ListBox Name=stProducts" НогizontalContentAlignment="Stretch"> <ListBox.ItemTemplate> <DataTemplate> <Border Margin=" BorderThickness=Ml" BorderBrush="SteelBlue" CornerRadius="> <Grid Margin="> <Grid.RowDefinitions>
624 Глава 20. Форматирование привязанных данных <RowDef initionx/RowDef inition> <RowDef initionx/RowDef inition> </Grid.RowDefinitions> <TextBlock FontWeight=,,BoldM Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Grid.Row=,,l" Text="{Binding Path=ModelName}"></TextBlock> </Grid> </Border> </DataTemplate> </ListBox.ItemTemplate> </ListBox> Когда этот список привязывается, для каждого товара создается отдельный объект Border. Внутри элемента Border находится Grid с двумя порциями информации, как показано на рис. 20.10. IJ DataTemplateList Get Products Ram Racer 2000 vtxr. » STKY1 i Edible Tape Model Name: Unit Cost: Description: ; Escape Vehicle (Air) NOZ119 PT109 Escape Vehicle Water) REOl Communications Device ■ J Рис. 20.10. Список, использующий шаблон данных Совет. При использовании для размещения индивидуальных элементов в списке объектов Grid можно работать со свойством SharedSizeGroup, которое было описано в главе 3. Применив свойство SharedSizeGroup (с описательным именем группы) к индивидуальным строкам или столбцам, можно обеспечить одинаковые размеры для каждого элемента. В главу 22 включен пример, использующий этот подход для построения развитого представления Listview, комбинирующего текстовое и графическое содержимое. Отделение и повторное использование шаблонов Подобно стилям, шаблоны часто объявляются как ресурс окна или приложения, который определен в списке, где он используется. Такое отделение часто более ясно, особенно если применяются длинные сложные шаблоны или множество шаблонов к одному и тому же элементу управления (как описано в следующем разделе). Это также дает возможность повторно использовать шаблоны в более чем одном списочном элементе управления или элементе с содержимым, если нужно представлять данные одинаковым образом в разных местах пользовательского интерфейса. Все, что потребуется — это определить шаблон данных в коллекции ресурсов и назначить ему ключевое имя.
Глава 20. Форматирование привязанных данных 625 Ниже приведен пример извлечения шаблона, показанного в предыдущем примере: <Window.Resources> <DataTemplate x: Key=llProductDataTemplatell> <Border Margin=11 BorderThickness="l11 BorderBrush="SteelBlue11 CornerRadius="> <Grid Margin=,,3"> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDef mitionx/RowDef inition> </Grid.RowDefinitions> <TextBlock FontWeight=,,Bold" Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Grid.Row="l" Text="{Binding Path=ModelName}"></TextBlock> </Grid> </Border> </DataTemplate> </Window.Resources> Теперь шаблон данных можно применить с использованием ссылки Static Re source: <ListBox Name="lstProducts11 HorizontalContentAlignment=" St retch" ItemTemplate="{StaticResource ProductDataTemplate}"></ListBox> Если нужно автоматически повторно использовать тот же шаблон данных в других типах элементов управления, можно воспользоваться другим интересным трюком — установить свойство DateTemplate.DataType, чтобы идентифицировать тип привязанных данных, для которых должен применяться шаблон. Например, предьщущий пример можно было бы изменить, исключив ключ и указав этот шаблон, как предназначенный для привязки объектов Product, независимо от того, где они появляются: <Window.Resources> <DataTemplate DataType="{x:Type local:Product}"> </DataTemplate> </Window.Resources> В разметке предполагается, что определен префикс пространства имен XML по имени local, отображенный на пространство имен проекта. Теперь этот шаблон будет использован с любым списочным элементом или элементом управления содержимым в данном окне, который привязан к объектам Product. Настройку ItemTemplate указывать не нужно. На заметку! Шаблоны данных не требуют привязки данных. Другими словами, использовать свойство ItemsSource для заполнения шаблона списка не понадобится. В предыдущих примерах добавлять объекты Product можно было декларативно (в XAML-разметке) или программно (вызывая метод ListBox.Items.Add()). В обоих случаях шаблон данных работает одинаково. Более развитые шаблоны Шаблоны данных могут быть замечательно самодостаточными. Наряду с базовыми элементами, такими как TextBlock и выражениями привязки данных, они также используют более изощренные элементы управления, присоединенные обработчики событий, преобразуют данные в различные представления, применяют анимацию и т.д. Стоит рассмотреть пару небольших примеров, которые демонстрируют, насколько мощными могут быть шаблоны данных. Прежде всего, в привязке данных могут быть использованы различные объекты конвертеров, чтобы преобразовывать данные в более удобное представление.
626 Глава 20. Форматирование привязанных данных В следующем примере применяется показанный ранее конвертер ImagePathConver ter для вывода изображений товаров в списке: <Window.Resources> <local:ImagePathConverter x:Key=llImagePathConverter"></local:ImagePathConverter> <DataTemplate x: Key=,,ProductTemplateM> <Border Margin=11 BorderThickness="l11 BorderBrush="SteelBlue11 CornerRadius="> <Grid Margin=,,3"> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDef initionx /RowDef inition> <RowDef in itionx/RowDef inition> </Grid.RowDefinitions> <TextBlock FontWeight=,,Bold" Text=" {Binding Path=ModelNumber} "> </TextBlock> <TextBlock Grid.Row=,,l" Text=" {Binding Path=ModelName} "></TextBlock> <Image Grid. Row=11 Grid. RowSpan=11 Source= "{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> </Grid> </Border> </DataTemplate> </Window.Resources> Хотя данная разметка не содержит ничего экзотического, в результате получается более интересный список (рис. 20.11). » DataTemplatelmages [ Ge: Products TCKLR1 Fake Moustache Translator JWLTRANS6 Interpreter Earrings GRTWTCH9 i 1 Mutti-Purpose Watch Ш1 ai Model Number: Mode \are Unit Cost: Description: Рис. 20.11. Список с графическим содержимым Другой удобный прием предусматривает размещение элементов управления непосредственно внутри шаблона. Например, на рис. 20.12 показан список категорий. Рядом с каждой категорией находится кнопка View (Просмотреть), щелчок на которой приводит к открытию другого окна с товарами только из данной категории.
Глава 20. Форматирование привязанных данных 627 » ' DataTemplateControis Communications Deception Protection Munitions Tools General View... V»ew... [vtew^.J View... View .. View... \t -ел . Рис. 20.12. Список с кнопками Основной фокус этого примера в обработке щелчков на кнопках. Очевидно, что все кнопки будут привязаны к одному и тому же обработчику событий, который определяется внутри шаблона. Тем не менее, понадобится определить, на каком элементе в списке был совершен щелчок. Одно возможное решение состоит в хранении некоторой идентифицирующей информации в свойстве Tag кнопки, как показано ниже: <DataTemplate> <Grid Margin=ll3"> <Grid.ColumnDefinitions> <ColumnDef initionx/ColumnDef inition> <ColumnDefinition Width=llAutollx/ColumnDefinition> </Grid.ColumnDefinitions> <TextBlock Text="{Binding Path=CategoryName}"></TextBlock> <Button Grid.Column=11 HorizontalAlignment="Right11 Padding=11 Click="cmdView_Clicked11 Tag=" {Binding Path=Ca tegory ID} ">View ...</Button> </Grid> </DataTemplate> Затем можно извлечь свойство Tag в обработчике cmdViewClicked: private void cmdView_Clicked(object sender, RoutedEventArgs e) { Button cmd = (Button)sender; int categorylD = (int)cmd.Tag; Эту информацию можно использовать для выполнения другого действия. Им может быть, например, открытие другого окна, отображающего товары, с передачей ему значения CategorylD, которое затем используется для фильтрации только товаров этой категории. (Простой способ реализации фильтра состоит в применении представлений данных, как описано в главе 21.) Если нужна вся информация о выбранном элементе данных, можно захватить весь объект данных, опустив свойство Path при определении привязки: <Button HorizontalAlignment="Right11 Padding="l11 Click="cmdView_Clicked11 Tag= " {Binding }"> View ...</Button> Теперь обработчик событий будет принимать объект Product (если привязывается коллекция объектов Product). В случае привязки к DataTable вместо этого получится объект DataRowView, который можно использовать для извлечения всех значений полей непосредственно, как это делалось бы с объектом DataRow.
628 Глава 20. Форматирование привязанных данных Передача целого объекта обладает другим преимуществом: она облегчает обновление выбора в списке. В текущем примере можно щелкнуть на кнопке в любой позиции списка, независимо от того, выбрана ли она в данный момент. Это ведет к потенциальной путанице, потому что пользователь может выбрать один элемент списка и щелкать на кнопке View для другого элемента. Когда пользователь возвращается в окно списка, первый элемент остается выбранным, несмотря на то, что в предыдущей операции использовался другой. Чтобы исключить путаницу, при щелчке на кнопке View полезно переместить выбор на новый элемент списка: Button cmd = (Button)sender; Product product = (Product)cmd.Tag; IstCategories.Selectedltem = product; Другой вариант — отображать кнопку View только для выбранного элемента. Этот прием предполагает модификацию или замену шаблона, используемого в списке, как будет описано в разделе "Шаблоны и выбор" далее в этой главе. Варьирование шаблонов Одно из ограничений шаблонов, которые встречалось до сих пор, состоит в том, что для списка может применяться только один шаблон. Но в некоторых ситуациях требуется гибкое представление различных элементов данных разными способами. Этого можно добиться несколькими способами. Ниже перечислены некоторые распространенные приемы. • Использование триггера данных. Триггер можно использовать для изменения свойства в шаблоне на основе значения свойства привязанного объекта. Триггеры данных работают подобно триггерам свойств, о которых известно из главы 11, за исключением того, что они не требуют свойств зависимости. • Использование конвертера значений. Класс, реализующий IValueConverter, может преобразовывать значение из привязанного объекта в значение, которое можно использовать для установки связанного с форматированием свойства в шаблоне. • Использование селектора шаблона. Селектор шаблона проверяет привязанный объект данных и выбирает между несколькими разными шаблонами. Триггеры данных часто обеспечивают простейший подход. Базовый прием состоит в установке свойства одного из элементов внутри шаблона на основе свойства элемента данных. Например, можно было бы изменить фон специального контура, который очерчивает каждый элемент списка, на основе свойства CategoryName соответствующего объекта Product. Ниже приведен пример, в котором товары из категории Tools выделяются красным цветом: <DataTemplate x:Key="DefaultTemplate"> <DataTemplate.Triggers> <DataTrigger Bindings"{Binding Path=CategoryName}" Value="Tools"> <Setter Property="ListBoxItem.Foreground" Value="Red"X/Setter> </DataTrigger> </DataTemplate.Triggers> <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" CornerRadius="> <Grid Margin="> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDef mitionx/RowDef inition> </Grid.RowDefinitions>
Глава 20. Форматирование привязанных данных 629 <TextBlock FontWeight="Bold" Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Grid.Row="l" Text="{Binding Path=ModelName}"></TextBlock> </Grid> </Border> </DataTemplate> Поскольку объект Product реализует интерфейс INotif yPropertyChanged (как описано в главе 19), любые его изменения обнаруживаются немедленно. Например, если модифицировать свойство CategoryNamy, чтобы исключить товар из категории Tools, текст в списке также изменится. Этот подход удобен, но в своей основе ограничен. Он не позволяет изменять сложные детали шаблона, а дает возможность лишь корректировать индивидуальные свойства элементов в шаблоне (или элементе-контейнере). Кроме того, как известно из главы 11, триггеры могут осуществлять проверку только на равенство — они не поддерживают более сложные условия сравнения. Это значит, что данный подход не может использоваться, например, для выделения цен, превышающих определенное значение. И если нужно выбирать из диапазона возможностей (например, предоставляя каждой категории товаров свой отличающийся цвет фона), придется написать по одному триггеру для каждого возможного значения, что достаточно утомительно. Другой вариант — создать один шаблон, который достаточно интеллектуален, чтобы подстраивать себя в соответствии с привязанным объектом. Для применения этого трюка обычно должен использоваться конвертер значений, который проверяет свойство в привязанном объекте и возвращает более подходящее значение. Например, можно было бы создать конвертер CategoryToColorConverter, который проверяет категорию товара и возвращает соответствующий объект Color. Подобным образом можно привязаться непосредственно к свойству CategoryName в шаблоне, как показано ниже: <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" CornerRadius=" Background= "{Binding Path=CategoryName, Converter={StaticResource CategoryToColorConverter}"> Как и с триггерами, подход на основе конвертера значений также предотвращает внесение динамических изменений, таких как замена части шаблона чем-то совершенно иным. Однако он позволяет реализовать более сложную логику форматирования. Вдобавок он дает возможность основывать единственное свойство форматирования на нескольких свойствах привязанного объекта данных, если вместо обычного IValueConverter используется интерфейс IMultiValueConverter. Совет. Конвертеры значений — удачный выбор, если планируется повторное использование логики форматирования в других шаблонах. Селекторы шаблонов Другой, более мощный вариант предусматривает назначение разным элементам полностью отличающихся шаблонов. Для этого понадобится создать класс, унаследованный от DataTemplateSelector. Селекторы шаблонов работают точно так же, как рассмотренные ранее селекторы стилей — они проверяют привязанный объект и выбирают подходящий шаблон на основе заданной логики. Ранее было показано, как строить селектор стилей, который ищет определенные значения и выделяет их стилем. Ниже приведен аналогичный селектор шаблонов, который отыскивает свойство (указанное в PropertyToEvaluate) и возвращает HighlightTemplate, если свойство соответствует установленному значению (заданному в PropertyValueToHighlight) или DefaultTemplate — в противном случае:
630 Глава 20. Форматирование привязанных данных public class SingleCriteriaHighlightTemplateSelector : DataTemplateSelector { public DataTemplate DefaultTemplate { get; set; } public DataTemplate HighlightTemplate get; set; public string PropertyToEvaluate get; set; public string PropertyValueToHighlight get; set; public override DataTemplate SelectTemplate(object item, DependencyObject container) Product product = (Product)item; // Использовать рефлексию для получения проверяемого свойства. Type type = product.GetType(); Propertylnfo property = type.GetProperty(PropertyToEvaluate); // Решить, должен ли товар быть выделен, на основе значения свойства. if (property.GetValue(product, null) .ToString () == PropertyValueToHighlight) { return HighlightTemplate; } else { return DefaultTemplate; } } } А вот разметка, создающая два шаблона и экземпляр SingleCriteriaHighlight TemplateSelector: <Window.Resources> <DataTemplate x:Key="DefaultTemplate"> <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" CornerRadius="> <Grid Margin="> <Grid.RowDefinitions> <RowDef mitionx/RowDef inition> <RowDef initionx/RowDef inition> </Grid.RowDefinitions> <TextBlock Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Grid.Row="l" Text="{Binding Path=ModelName}"></TextBlock> </Grid> </Border> </DataTemplate> <DataTemplate x:Key="HighlightTemplate"> <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" Background="LightYellow" CornerRadius="> <Grid Margin=">
Глава 20. Форматирование привязанных данных 631 <Grid.RowDefinitions> <RowDef initionx/RowDef inition> <RowDef mitionX/RowDef inition> <RowDef initionx/RowDef inition> </Qrid.RowDefinitions> <TextBlock FontWeight="Bold" Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Grid.Row="l" FontWeight="Bold" Text="{Binding Path=ModelName}"></TextBlock> <TextBlock Grid.Row=" FontStyle="Italic" HorizontalAlignment="Right"> *** Great for vacations ***</TextBlock> </Grid> </Border> </DataTemplate> </Window.Resources> Ниже показана разметка, применяющая селектор шаблонов: <ListBox Name=stProducts" HonzontalContentAlignment="Stretch"> <ListBox.ItemTemplateSelector> <local:SingleCriteriaHighlightTemplateSelector DefaultTemplate="{StaticResource DefaultTemplate}" HighlightTemplate="{StaticResource HighlightTemplate}" PropertyToEvaluate="CategoryName" PropertyValueToHighlight="Travel"> </local:SingleCriteriaHighlightTemplateSelector> </ListBox.ItemTemplateSelector> </ListBox> Как видите, селекторы шаблонов намного мощнее селекторов стилей, потому что каждый шаблон может отображать разные элементы, организованные в разной компоновке. В этом примере HighlightTemplate добавляет элемент TextBlock с дополнительной строкой текста в конце (рис. 20.13). Совет. Один недостаток этого подхода связан с тем, что, скорее всего, придется создавать несколько похожих шаблонов. В случае сложных шаблонов это может привести к большому объему дублирования. Чтобы облегчить сопровождение, не следует создавать слишком много шаблонов для одного списка; вместо этого используйте триггеры и стили для применения различного форматирования к шаблонам. • VanedTemplates Get**** • Great for vocations'" Edible Tape -^ Vehicle (Air) *•* Great for vacations' [ N02110 Vehicle (Water) *** Great for vacations'" Model Ni Model Name: Unit Cost: Description: m 111 Рис. 20.13. Список с двумя шаблонами данных
632 Глава 20. Форматирование привязанных данных Шаблоны и выбор В предыдущем примере шаблона присутствует небольшой недостаток. Проблема в том, что в показанных до сих пор шаблонах не принимался во внимание выбор в списке. В случае выбора элемента в списке WPF автоматически устанавливает свойства Foreground и Background контейнера элемента (в данном случае — объекта Li st Box Item). Для переднего плана выбран белый цвет, а для фона — синий. Foreground использует наследование свойств, так что любые элементы, добавляемые к шаблону, автоматически получают новый белый цвет, если только для них явно не будет указан новый цвет. Цвет фона (Background) не использует наследования свойства, но по умолчанию получает значение Transparent (прозрачный). Так, например, если есть прозрачная граница, то новый синий цвет просматривается сквозь нее. В противном случае применяется цвет, установленный в шаблоне. Это несоответствие может повлиять на форматирование нежелательным образом. На рис. 20.14 показан пример. ■ VartedTempJates Get Products Model Number STKY] STKY1 i Edible Tape Edir:ie Model Name: Ram Racer 2000 Unit Cost: 149? Э900 Descnp: Vehicle (Air) * Great for vacations *" J Looks like an ordinary bumbershoot but don't be fooled! Simply place Rain Racer s tip on the ground ' and press the release latch Within seconds, this ' ordinary rain umbrella converts into a two-wheeled ' gas-powered mini-scooter. Goes from 0 to 60 in 7.5 [ seconds - even in a driving rain! Comes in black, blue, , and candy-apple red. : PT109 F«r*«# V*h»rl* /W»t#r* Рис. 20.14. Нечитабельный текст в подсвеченном элементе списка Чтобы избежать этой проблемы, можно было бы жестко закодировать цвета, но тогда возникает другая сложность. Единственным указанием на то, что элемент выбран, будет синий фон вокруг прямоугольника со скругленными углами. Для решения этой проблемы следует воспользоваться знакомым свойством ItemContainerStyle, чтобы применить другое форматирование к выбранному элементу: <ListBox Name="lstProducts" HorizontalContentAlignment="Stretch"> <ListBox.ItemContainerStyle> <Style> <Setter Property="Control.Padding" Value="></Setter> <Style.Triggers> <Trigger Property="ListBoxItem.IsSelected" Value="True"> <Setter Property="ListBoxItem.Background" Value="DarkRed" /> </Trigger> </Style.Triggers> </Style> </ListBox.ItemContainerStyle> </ListBox> Показанный триггер устанавливает темно-красный фон для выбранного элемента. К сожалению, этот код не оказывает желаемого эффекта на список, использующий шаб-
Глава 20. Форматирование привязанных данных 633 лоны. Дело в том, что эти шаблоны включают элементы с разным цветом фона, которые отображаются над темно-красным фоном. Если не сделать их прозрачными, то останется тонкая красная грань вокруг области полей шаблона. Решение состоит в явной привязке фона в части шаблона к значению свойства ListBoxItem.Background. Это имеет смысл — в конце концов, вы теперь избавлены от выбора правильного цвета фона для выделения выбранного элемента. Понадобится лишь обеспечить его появление в нужном месте. Разметка, необходимая для реализации этого решения, несколько запутана. Причина в невозможности сделать это посредством простого выражения привязки, которое может просто привязываться к свойству в текущем объекте данных (в данном случае — объекте Product). Вместо этого нужно захватить фон из контейнера элемента (в данном случае — ListBoxItem). А это подразумевает использование свойства Binding.RelativeSource для поиска в дереве элементов первого совпадающего объекта ListBoxItem. Как только элемент найден, можно получить его цвет фона и воспользоваться им соответственно. Ниже приведен окончательный шаблон, использующий выбранный фон в области контура со скругленными углами. Элемент Border помещается внутри Grid с белым фоном, в это гарантирует, что выбранный фон не появится в области полей вне контура со скругленными углами. В результате получается более изящный стиль выбранного элемента, показанный на рис. 20.15. <DataTemplate> <Grid Margin=11 Background="White"> <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" CornerRadius=" Background="{Binding Path=Background, RelativeSource={ RelativeSource Mode=FindAncestor, AncestorType={x:Type ListBoxItem} }}"> <Grid Margin="> <Grid.RowDefinitions> <RowDef initionX/RowDef inition> <RowDef initionX/RowDef inition> </Grid.RowDefinitions> <TextBlock FontWeight="Bold11 Text=" {Binding Path=ModelNumber} "> </TextBlock> <TextBlock Grid.Row=,,l" Text=" {Binding Path=ModelName } "></TextBlock> </Grid> </Border> </Grid> </DataTemplate> Выбор и свойство SnapsToDevicePixels Потребуется внести еще одно изменение, которое будет гарантировать, что шаблон отобразится правильно на компьютерах с разными системными установками DPI (например, 120 dpi вместо стандартных 96 dpi). Для этого должно быть установлено в true свойство ListBox. SnapsToDevicePixels. Это гарантирует, что на границах списка не будет применяться сглаживание, если они окажутся между пикселями. Если не установить свойство SnapsToDevicePixels в true, могут возникнуть следы знакомого синего контура между границей шаблона и границей содержащего его элемента управления ListBox. (За дополнительной информацией о дробных пикселях и почему они появляются, когда DPI системы устанавливается отличным от стандартного значения 96 dpi, обращайтесь к обсуждению независимой от устройств системы координат WPF в главе 1.)
634 Глава 20. Форматирование привязанных данных ■ DataTemplateList «^.*=ЦИИИ1 Get Products ШШШШ STKY1 1 [Edible Tape . \\\m Escape VeWcleCAirl | J JNOZU9 j J Extracting Tool PT109 j [Escape Vehicle (Water) L(^ Model Number: RU007 Model Name: Ram Racer 2000 Unit Cost: ': 14Q9 9900 Descnption: jjj Looks like an ordinary Dumber shoot but don t be * 1 3 fooled! Simply place Rain Racer s tip on the ground j§! and press the release latch. Within seconds, this Щ ordinary rain umbrella converts into a two-wheeled •'■: gas-powered mini-scooter Goes from 0 to 60 in 7.5 X seconds - even in a driving rain! Comes in black, blue, ^ and candy-apple red. Рис. 20.15. Выделение выбранного элемента Этот подход — применение выражения привязки для изменения шаблона — работает хорошо, если можно извлечь нужное значение из контейнера элемента. Например, это замечательный прием, когда требуется получить цвета фона и переднего плана выбранного элемента. Однако это не особенно подходит, если нужно изменить шаблон более основательным образом. Например, предположим, что есть список товаров, показанный на рис. 20.16. При выборе товара из списка этот элемент расширяется от однострочного текста до рамки с изображением и полным описанием. Данный пример также комбинирует несколько других приемов, которые уже были показаны ранее, включая отображение содержимого элемента в шаблоне и использование привязки данных для установки цвета фона элемента Border, когда элемент выбран в списке. ■ ExpandingDataTemplate Document Transportation System [Hologram Cufflinks jstache Translator attaches between mouth to double as a language translator and identity concealer. Sophisticated tronics translate your voice intc the ?sired language. Wriggle your nose to toggle ftween Spanish, English, French, and Arabic. Excellent on diplomatic missions. Details-. , Interpreter Earrings i '■ Рис. 20.16. Расширение выбранного элемента
Глава 20. Форматирование привязанных данных 635 Чтобы создать список подобного рода, необходимо воспользоваться разновидностью техники, примененной в предыдущем примере. По-прежнему с помощью свойства RelativeSource класса Binding будет производиться поиск текущего ListBoxItem. Однако теперь извлекать цвет фона не понадобится. Вместо этого нужно проверить, выбран ли он. Если нет, дополнительную информацию можно скрыть, устанавливая соответствующие свойства Visibility. Этот прием подобен использованному в предыдущем примере, но не в точности совпадает с ним. В предыдущем примере можно было привязаться непосредственно к нужному значению, чтобы фон ListBoxItem стал фоном объекта Border. Однако в этом случае следует учесть свойство ListBoxItem.IsSelected и установить свойство Visibility другого элемента. Типы данных не соответствуют: IsSelected — это булевское значение, a Visibility — значение из перечисления Visibility. Поэтому привязать свойство Visibility к свойству IsSelected не получится (по крайней мере, без помощи специального конвертера данных). Решение состоит в использовании триггера, чтобы при изменении свойства IsSelected в ListBoxItem можно было модифицировать свойство Visibility контейнера. Место в разметке, куда помещается триггер, также отличается. Отныне неудобно помещать триггер в ItemsContainerStyle, потому что изменять видимость всего элемента не требуется. Вместо этого нужно скрыть только единственный раздел, так что триггер должен быть частью стиля, который применяется только к одному контейнеру. Ниже приведена несколько упрощенная версия шаблона, пока что без поведения автоматического расширения. Вместо этого она будет показывать всю информацию (включая изображение и описание) для каждого товара в списке. <DataTemplate> <Border Margin=" BorderThickness="l" BorderBrush="SteelBlue" CornerRadius="> <StackPanel Margin="> <TextBlock Text="{Binding Path=ModelName}"></TextBlock> <StackPanel> <TextBlock Margin=11 Text=" {Binding Path=Description} " TextWrapping="Wrap" MaxWidth=,,250" HorizontalAlignment="Left"> </TextBlock> <Image Source= "{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> <Button FontWeight="Regular" HorizontalAlignment="Right11 Padding="l11 Tag="{Binding}">View Details...</Button> </StackPanel> </StackPanel> </Border> </DataTemplate> Внутри Border находится панель StackPanel, включающая все содержимое. Внутри StackPanel располагается еще одна панель StackPanel, включающая содержимое, которое должно быть показано только для выбранных элементов, в том числе описание, изображение и кнопку. Чтобы скрыть эту информацию, понадобится установить стиль вложенной StackPanel с помощью триггера: <StackPanel> <StackPanel.Style> <Style> <Style.Triggers> <DataTrigger Bindings"{Binding Path«I«Selected, RelativeSource={ RelativeSource
636 Глава 20. Форматирование привязанных данных Mode=FindAncestor, AncestorType={x:Type ListBoxItem} И" Value=,,False"> <Setter Property="StackPanel.Visibility" Value="Collapsed" /> </DataTrigger> </Style.Triggers> </Style> </StackPanel.Style> <TextBlock Margin=11 Text=" {Binding Path=Description} " TextWrapping=,,Wrap" MaxWidth=,,250" HorizontalAlignment="Left"x/TextBlock> <Image Source= "{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> <Button FontWeight="Regular11 HorizontalAlignment="Right" Padding="l11 Tag="{Binding}">View Details...</Button> </StackPanel> В данном примере необходимо использовать DataTrigger вместо обычного триггера, т.к. свойство, которое нужно вычислить, находится в элементе-предке (ListBoxItem), и единственный способ добраться до него — это применить выражение привязки данных. Теперь, когда свойство ListBoxItem.IsSelected изменится на False, свойство StackPanel.Visibility изменится на Collapsed, скрывая дополнительные детали. На заметку! Формально дополнительные детали всегда присутствуют, но скрыты. В результате появляются дополнительные накладные расходы на генерацию этих элементов при первоначальном создании списка, а не при выборе элемента. В рассмотренном примере это не имеет особого значения, но такое проектное решение может повлечь за собой снижение производительности в случае построения очень длинного списка со сложным шаблоном. Изменение компоновки элемента Шаблоны данных предоставляют достаточный контроль над всеми аспектами представления элемента. Однако они не позволяют изменить организацию элементов относительно друг друга. Независимо от того, какие шаблоны и стили применяются, ListBox поместит каждый элемент в отдельную горизонтальную строку и сложит строки друг на друга в стопку, формируя список. Эту компоновку можно изменить, заменив контейнер, который используется списком для организации своих дочерних элементов. Для этого необходимо установить свойство ItemsPanelTemplate в блоке XAML, определяющем панель. Эта панель может быть любым классом, унаследованным от System.Windows.Controls.Panel. В следующем фрагменте разметки применяется WrapPanel в качестве оболочки вокруг доступной ширины элемента управления ListBox (рис. 20.17): <ListBox Margin=,3,7,10" Name="lstProducts" ItemTemplate="{StaticResource ItemTemplate}" ScrollViewer.HorizontaIScrollBarVisibility="Disabled"> <ListBox.ItemsPane1> <ItemsPanelTemplate> <WrapPanelx/WrapPanel> </ItemsPanelTemplate> </ListBox.ItemsPanel> </ListBox>
Глава 20. Форматирование привязанных данных 637 * Wrappedbst Get Products Correction Fluid Dilemma Resolution Device Nonexplosrve Cigar \ Hologram Cufflinks ent Transportation System i Hologram Cufflinl E E Fake Moustache Translator interpreter Earrings «ulti-Purpose Watch Рис. 20.17, Расположение элементов в отображаемой области списка Чтобы такой подход работал, понадобится также установить присоединенное свойство ScrollViewer.HorizontalScrollBarVisibility в Disabled. Это гарантирует, что ScrollViewer (который List Box использует автоматически) никогда не использует горизонтальную линейку прокрутки. Без этой детали WrapPanel получит бесконечную ширину, в которой разместит свои элементы, и этот пример станет эквивалентен горизонтальной панели StackPanel. С этим подходом связана одна загвоздка. Обычно большинство списочных элементов управления используют панель VirtualizingStackPanel вместо стандартной StackPanel. Как упоминалось в главе 19, VirtualizingStackPanel гарантирует эффективную обработку больших списков привязанных данных. При использовании VirtualizingStackPanel она создает элементы, которые необходимы для отображения набора текущих видимых элементов. Когда применяется StackPanel, она создает элементы, которые необходимы для построения списка целиком. Если источник данных включает тысячи (или более) элементов, то VirtualizingStackPanel потребит намного меньше памяти. Кроме того, она ведет себя лучше при наполнении и прокручивании списка, потому что в этом случае системе компоновки WPF приходится выполнять значительно меньше работы. Таким образом, не следует заменять установку нового ItemsPanelTemplate, если только список не используется для отображения относительно небольшого объема данных. Если ситуация граничная — например, нужно показать лишь несколько сотен элементов, но при этом применяется исключительно сложный шаблон — можете опробовать оба подхода, чтобы оценить, как изменяется производительность и расход памяти, и решить, какая стратегия лучше. Кстати, класс VirtualizingStackPanel унаследован от абстрактного класса VirtualizingPanel. Чтобы использовать панель другого типа, не прибегая к виртуализации, унаследуйте собственный специальный класс панели от VirtualizingPanel. К сожалению, создание надежной, профессионального уровня панели виртуализации — сложная задача, которая выходит за рамки настоящей книги. Если интересует ее решение, можете начать с чтения статьи по адресу http://tinyurl.com/mqtrdu. Или же приобретите одну из готовых панелей виртуализации, которые предлагают независимые разработчики.
638 Глава 20. Форматирование привязанных данных Элемент СошЬоВох Хотя стили и шаблоны данных встроены в класс ItemsControl и поддерживаются всеми списочными элементами управления WPF, все примеры, рассмотренные до сих пор, использовали стандартный ListBox. В этом нет ничего плохого — в конце концов, ListBox повсеместно настраиваемый элемент, который может легко обрабатывать списки флажков, изображений, форматированного текста либо различные комбинации всех этих типов содержимого. Однако другие списочные элементы управления предлагают ряд новых средств. В главе 22 вы узнаете о некоторых украшениях List View, TreeView и DataGrid. Но даже скромный элемент ComboBox обладает некоторыми дополнительными возможностями, которые рассматриваются в настоящем разделе главы. Подобно ListBox, элемент ComboBox является потомком класса Selector. Но в отличие от ListBox, ComboBox состоит из двух частей: поля выбора, которое показывает текущий выбранный элемент, и раскрывающегося списка, в котором можно делать выбор. Раскрывающийся список появляется при щелчке на стрелке вниз, находящейся на краю этого элемента. Если элемент находится в режиме "только для чтения" (по умолчанию), раскрывающийся список можно открыть, щелкнув в любом месте поля выбора. Наконец, раскрывающийся список можно открывать и закрывать программно, устанавливая его свойство IsDropDownOpen. Обычно элемент управления ComboBox отображает раскрывающийся список только для чтения, что означает, что его можно использовать для выбора элемента, но при этом вводить собственный произвольный текст нельзя. Однако это поведение легко изменить, установив свойство IsReadOnly в false, a IsEditable — в true. При этом поле выбора превратится в текстовое поле, в котором можно вводить произвольный текст. Элемент управления ComboBox предлагает рудиментарную форму автозавершения, которое дополняет текст по мере набора. (Это не следует путать с автозаполнением, которое можно видеть в программах вроде Internet Explorer: оно показывает полный список возможностей под текущим текстовым полем.) Вот как работает автозавершение: по мере набора в ComboBox среда WPF заполняет оставшуюся часть поля выбора первым подходящим предположительным значением. Например, если введено Gr, а в списке есть элемент Green, поле будет дополнено буквами ее п. Автоматически дополненный текст будет выбранным, так что его можно автоматически переписать, продолжив ввод. Если поведение автозавершения не требуется, просто установите свойство ComboBox.IsTextSearchEnabled в false. Это свойство унаследовано от базового класса ItemsControl и применяется ко многим другим списочным элементам управления. Например, если в ListBox свойство IsTextSearchEnabled установлено в true, можно вводить начальную часть элемента и перемещаться в его позицию. На заметку! В WPF не содержится никаких средств для использования отслеживаемых системой списков автозавершения, таких как список последних URL и файлов. Также не предусмотрена поддержка раскрывающихся списков автозавершения. Описанное до сих пор поведение ComboBox достаточно простое. Однако оно несколько изменяется, если список содержит более сложные объекты, а не простые строки текста. Помещать более сложные объекты в ComboBox можно двумя способами. Пербый состоит в добавлении их вручную. Как и в случае с ListBox, в ComboBox можно помещать любое содержимое. Например, если нужен список изображений и текста, соответствующие элементы просто помещаются в StackPanel, а та, в свою очередь, в объект ComboBoxItem. Что более практично — для вставки содержимого из объекта данных в предопределенную группу элементов можно использовать шаблоны данных.
Глава 20. Форматирование привязанных данных 639 При использовании нетекстового содержимого бывает не столь очевидным, что должно содержать поле выбора. Если свойство IsEditable установлено в false (по умолчанию), то поле выбора будет показывать точную визуальную копию элемента. Например, на рис. 20.18 показан элемент ComboBox, использующий шаблон данных, который включает текстовое и графическое содержимое. i ! ComboBoxSelectionBox RU007 Rain Racer 2000 TCKLR1 i Fake Moustache Translator JWLTRANS6 | Interpreter Earrings GRTWTCH9 Multi-Purpose Watch .l»i©ta£ad| m\ ■ ■ f ] ▼ 1 ! i Рис. 20.18. Элемент ComboBox, предназначенный только для чтения, который использует шаблоны На заметку! Важная деталь состоит в том, что отображает раскрывающийся список, а не что находится в его источнике данных. Например, предположим, что элемент управления ComboBox заполняется объектами Product, а свойство DisplayMemberPath устанавливается в ModelName; раскрывающийся список будет отображать ModelName для каждого элемента. Даже если раскрывающийся список получает свою информацию из группы объектов Product, разметка создает простой текстовый список. В результате поле выбора ведет себя ожидаемым образом. Оно будет отображать ModelName текущего товара, а если IsEditable равно true и Readonly — false, то позволит редактировать это значение. Пользователь не может взаимодействовать с содержимым, которое появляется в поле выбора. Например, если содержимое текущего выбранного элемента включает текстовое поле, ввести что-либо в нем не удастся. Если же оно содержит кнопку, щелкать на ней не получится. Вместо этого щелчок на поле выбора просто откроет раскрывающийся список. (К тому же есть масса причин, по которым не имеет смысла помещать взаимодействующие с пользователем элементы управления в раскрывающийся список.) Если свойство IsEditable равно true, поведение элемента управления ComboBox меняется. Вместо отображения копии выбранного элемента поле выбора отображает его текстовое представление. Чтобы создать текстовое представление объекта, WPF просто вызывает метод ToStringO на элементе. На рис. 20.19 показан пример с тем же раскрывающимся списком, что и на рис. 20.18. На этот раз отображаемым текстом DataBinding.Product будет полностью квалифицированное имя класса текущего выбранного объекта Product — т.е. то, что возвращается реализацией метода ToStringO по умолчанию, если только он не переопределен в классе данных.
640 Глава 20. Форматирование привязанных данных ; ■ ComboBoxSelectionBox TCKLR1 Fake Moustache 'ranslator JWLTRANS6 Interpreter Earrings GRTWTCH9 5lj Рис. 20.19. Редактируемый ComboBox, который использует шаблоны ■ ComboBoxSelectionBox шшшшшшш TCKLR1 Fake Moustache Trar JWLTRANS6 Interpreter Earnngs 1CRTWTCH9 slator ■ 1 Г ■ • Рис. 20.20. Отображение свойства в поле выбора Простейший способ решения этой проблемы предусматривает установку в присоединенном свойстве Test Search.Text Path имени свойства, которое должно быть использовано в качестве содержимого поля выбора. Вот пример: <ComboBox IsEditable="True" IsReadOnly="True" TextSearch.TextPath="ModelName" ...> Хотя IsEditable должно быть равно true, вам решать — нужно установить в false свойство IsReadOnly (чтобы позволить редактировать это свойство) или же оно должно быть true (чтобы предотвратить ввод пользователем произвольного текста). Результат показан на рис. 20.20. Совет. А что, если требуется отобразить более развитое содержимое, чем простой фрагмент текста, но при этом содержимое поля выбора должно отличаться от содержимого раскрывающегося списка? В ComboBox имеется свойство SelectionBoxItemTemplate, которое определяет шаблон, используемый для поля выбора. К сожалению, свойство SelectionBoxItemTemplate доступно только для чтения. Оно автоматически устанавливается в соответствии с текущим элементом, и указать другой шаблон нельзя. Тем не менее, можно создать совершенно новый элемент управления ComboBox, который вообще не использует SelectionBoxItemTemplate, Взамен этот шаблон элемента управления может жестко закодировать шаблон поля выбора либо извлечь его из коллекции Resources окна. Резюме В этой главе была подробно описана привязка данных — одна из основополагающих концепций WPF. Многие сценарии, рассмотренные в этой главе, можно было бы реализовать посредством кода. В WPF модель привязки данных (в сочетании с конвертерами значений, стилями и шаблонами данных) позволяет выполнять большую часть работы декларативно. Фактически привязка данных — это универсальный способ отображения информации любого типа, независимо от места ее хранения, способа отображения или возможности редактирования. Иногда эти данные извлекаются из базы данных заднего плана. В других случаях данные могут поступать от веб-службы, удаленного объекта, файловой системы либо же полностью генерироваться кодом. В конечном итоге это не важно: до тех пор, пока модель данных сохраняется постоянной, код пользовательского интерфейса и выражения привязки остаются прежними.
ГЛАВА 21 Представления данных Теперь, когда изучено искусство преобразования данных, применения стилей к элементам в списке и создания шаблонов, можно переходить к рассмотрению так называемых представлений данных (data view), которые способны незаметным образом координировать работу коллекций связанных данных. При использовании представлений данных можно добавлять логику навигации, а также реализовывать фильтры и средства сортировки и группирования. Объект представления Во время привязки коллекции (или объекта DataTable) к элементу управления ItemsControl "за кулисами" молча создается представление данных. Это представление данных размещается между источником данных и привязываемым элементом управления. Представление данных представляет собой своего рода "окно" в источник данных. В нем производится отслеживание текущего элемента, и поддерживаются такие возможности, как сортировка, фильтрация и группирование. Эти возможности поддерживаются независимым от объекта данных образом, а это означает, что одни и те же данные можно привязывать разными способами в разных частях окна (или даже разных частях приложения). Например, одну и ту же коллекцию данных можно было бы привязать к двум разным спискам, но отфильтровать их так, чтобы в них отображались разные записи. То, какой объект представления должен использоваться, зависит от типа объекта данных. Все представления унаследованы от класса CollectionView, но двумя специализированными реализациями являются ListCollectionView и BindingListCollectionView. Ниже описано как все работает. • Если источник данных реализует интерфейс IBindingList, создается объект представления BindingListCollectionView. Такое происходит, например, при привязке такого объекта ADO.NET, как DataTable. • Если источник данных реализует не IBindingList, a IList, создается объект представления ListCollectionView. Такое происходит, когда привязывается коллекция ObservableCollect.ion, например, список товаров. • Если источник данных не реализует ни IBindingList, ни IList, но зато реализует интерфейс IEnumerable, создается простейший объект представления CollectionView. Совет. В идеале третьего варианта следует избегать. Объект CollectionView демонстрирует низкую производительность в случае крупных элементов и операций, предусматривающих внесение изменений в источник данных (наподобие операций вставки и удаления). Как было показано в главе 19, если привязка осуществляется не к объекту данных ADO.NET, то практически всегда проще воспользоваться классом ObservableCollection.
642 Глава 21. Представления данных Извлечение объекта представления Для получения объекта представления, который используется в текущий момент, применяется статический метод GetDefaultViewO из класса System.Windows.Data. CollectionViewSource. При вызове методу GetDefaultViewO передается источник данных — коллекция или объект DataTable, который используется. Ниже показан пример извлечения представления для привязанной к списку коллекции товаров: ICollectionView view = CollectionViewSource.GetDefaultView(lstProducts.ItemsSource) ; Метод GetDefaultViewO всегда возвращает ссылку ICollectionView. За приведение объекта представления к типу соответствующего класса, такого как ListCollectionView или BindingListCollectionView, в зависимости от источника данных, отвечает программист. ListCollectionView view = (ListCollectionView)CollectionViewSource.GetDefaultView(IstProducts.ItemsSource) ; Навигация в представлении Одной из самых простых вещей, которые можно делать с объектом представления, является определение количества элементов в списке (с помощью свойства Count) и извлечение ссылки на текущий объект данных (Currentltem) либо индекса текущей позиции (CurrentPosition). Существует ряд методов, которые можно применять для перемещения от одной записи к другой, в том числе MoveCurrentToFirstO, MoveCurrentToLast(), MoveCurrentToNext(), MoveCurrentToPrevious() и MoveCurrentToPosition(). До сих пор необходимость в этих деталях не возникала, поскольку во всех приведенных примерах для перехода от одной записи к следующей применялся список. Но если нужно создать приложение для просмотра записей, может понадобиться предоставить собственные кнопки для навигации. На рис. 21.1 показан пример такого приложения. Привязываемые текстовые поля, в которых отображаются сведения о товаре, остаются теми же. Необходимо только, чтобы они указывали на соответствующее свойство: <TextBlock Margin=">Model Number:</TextBlock> <TextBox Margin=" Grid.Column="l" Text="{Binding Path=ModelNumber}"> </TextBox> :;3teCo:iect:on Model Number N0Z119 Model Name: L nit Cost: Description: High-tech rnniatunzed extracting tool Excellent 'or extricating * 'creign objects from your person Good for picking up really | tiny stuff, too! Cleverly disguised as a pair of tweezers. * п Рис. 21.1. Приложение для просмотра записей
Глава 21. Представления данных 643 Однако в примере отсутствуют списочные элементы управления, поэтому о навигации нужно позаботиться самостоятельно. Для простоты ссылку на представление можно сохранить в переменной-члене класса окна: private ListCollectionView view; В этом случае код приводит представление к соответствующему типу (ListCollectionView), а не использует ICollectionView. Интерфейс ICollectionView обеспечивает в основном ту же функциональность, но не имеет свойства Count, которое позволяет получить общее количество элементов в коллекции. При первой загрузке окна можно извлечь данные, поместить их в элемент DataContext окна и сохранить ссылку на представление: ICollection<Products> products = Арр.StoreDB.GetProducts(); this.DataContext = products; view = (ListCollectionView)CollectionViewSource.GetDefaultView(this.DataContext); view.CurrentChanged += new EventHandler(view_CurrentChanged); Во второй строке кода скрывается все та "магия", которая необходима для отображения коллекции элементов в окне. Она помещает всю коллекцию объектов Product в объект DataContext. Привязанные элементы управления на форме будут выполнять поиск вверх по дереву элементов до тех пор, пока не обнаружат этот объект. Разумеется, необходимо, чтобы выражения привязки привязывались к текущему элементу в коллекции, а не к самой коллекции, но платформа WPF достаточно интеллектуальна, чтобы сделать это автоматически. WPF предоставляет их с текущим элементом, тем самым избавляя от необходимости добавлять специальный код. В предыдущем примере имеется один дополнительный оператор кода. Он соединяет обработчик событий с событием CurrentChanged представления. При срабатывании этого события могут выполняться несколько полезных действий, например, включаться или отключаться кнопки перехода назад и вперед в зависимости от текущей позиции и отображаться информация о текущей позиции в TextBlock в нижней части окна. private void view_CurrentChanged(object sender, EventArgs e) { lblPosition.Text = "Record " + (view.CurrentPosition + 1) .ToStringO + 11 of " + view.Count .ToString () ; cmdPrev.IsEnabled = view.CurrentPosition > 0; cmdNext.IsEnabled = view.CurrentPosition < view.Count - 1; } Этот код выглядит вполне подходящим кандидатом на привязку данных и использование триггеров. Однако его логика просто является слишком сложной (частично из-за необходимости добавлять к индексу 1 для получения позиции отображаемой записи). Финальный шаг состоит в написании логики для кнопок перехода к предыдущей и следующей записи. Поскольку эти кнопки автоматически отключаются, когда они неприменимы, заботиться о перемещении перед первым элементом или после последнего элемента не понадобится. private void cmdNext_Click(object sender, RoutedEventArgs e) { view.MoveCurrentToNext(); } private void cmdPrev_Click(object sender, RoutedEventArgs e) { view.MoveCurrentToPrevious() ; }
644 Глава 21. Представления данных В качестве интересного дополнения можно добавить к форме списочный элемент управления, чтобы пользователь получил возможность как переходить по записям по очереди с помощью кнопок, так и перепрыгивать сразу на конкретный элемент с помощью списка (рис. 21.2). : » NavigateCollection Ram Racer 2000 Model Number - Model Name: Ram Racer 2000 Unit Cost Щ Я.499.99 Description: looks like an ordinary bumbershoot. but don t be fooled! Simply place Rain Racer s tip on the ground and press the release latch. Within seconds, this ordinary rain umbrella I converts into a two-wheeled gas-powerea mim-scooter. Goes Vi from 0 to 60 in 7.5 seconds - even in a driving rain' Comes in black blue, and candy-apple red. Record 1 of 41 Рис. 21.2. Приложение для просмотра записей с раскрывающимся списком В этом случае понадобится элемент ComboBox, который использует свойство ItemsSource (для извлечения всего списка товаров) и привязку для свойства Text (чтобы отображать правильный элемент): <ComboBox Name=lllstProducts" DisplayMemberPath=llModelName11 Text="{Binding Path=ModelName}" Select ionChanged=" Is t Product s_SelectionChanged"X/ComboBox> При первом извлечении коллекции товаров производится привязка списка: IstProducts.ItemsSource = products; Это может и не дать ожидаемого эффекта. По умолчанию элемент, выбираемый в ItemsControl, не синхронизируется с текущим элементом в представлении. Это означает, что при новом выборе в списке пользователь не переходит на новую запись, а изменяет свойство ModelName текущей записи. К счастью, существуют два простых способа решить эту проблему. Первый способ является грубым и заключается в перемещении на новую запись, когда в списке выбирается элемент. Необходимый для этого код выглядит следующим образом: private void lstProducts_SelectionChanged(object sender, RoutedEventArgs e) { view.MoveCurrentTo(IstProducts.Selectedltem); } Второй способ более прост и предусматривает установку свойства ItemsControl. IsSynchronizedWithCurrentltem в true. В этом случае выбираемый в текущий момент элемент будет автоматически синхронизироваться в соответствии с текущей позицией представления, без добавления какого-либо кода.
Глава 21. Представления данных 645 Использование списка поиска для редактирования Элемент управления ComboBox обеспечивает удобный способ для редактирования значений записей. В текущем примере в нем нет особого смысла, поскольку незачем присваивать одному товару точно такое же название, как у другого. Однако совсем не трудно представить себе и другие сценарии, в которых элемент управления ComboBox может служить прекрасным инструментом для редактирования. Например, в базе данных может присутствовать поле, принимающее одно из небольшого набора существующих значений. В таком случае используйте элемент управления ComboBox и привяжите его к соответствующему полю с помощью выражения привязки для свойства Text. Заполните ComboBox допустимыми значениями, установив его свойство ItemsSource в определенный список. Если значения в списке должны отображаться в одном виде (например, как текст), но храниться в другом (как числовые коды), просто добавьте к привязке свойства Text конвертер значений. Другим случаем, когда нужен список поиска, является наличие привязанных таблиц. Например, может понадобиться предоставить пользователю возможность выбора категории товаров в списке, отображающем все доступные категории. Базовый подход тот же: привязать свойство Text к подходящему полю и заполнить список опциями с помощью свойства ItemsSource. Для преобразования низкоуровневых уникальных идентификаторов в более значащие имена следует использовать конвертер значений. Создание представления декларативным образом В предыдущем примере использовалась простая схема, которая будет встречаться и далее в главе. Код извлекает представление, которое должно использоваться, и затем модифицирует его программно. Однако доступен и другой вариант: можно создать объект CollectionViewSource декларативно в XAML-разметке, после чего привязывать его к своим элементам управления (таких как список). На заметку! Формально CollectionViewSource представлением не является. Это вспомогательный класс, который позволяет извлекать представление (с использованием метода GetDefaultViewO, который демонстрировался в предыдущих примерах), и фабрика, которая может создавать представление, когда оно необходимо (как будет показано в этом разделе). Двумя наиболее важными свойствами класса CollectionViewSource являются View, служащее оболочкой для объекта представления, и Source, которое является оболочкой для источника данных. Дополнительные свойства SortDescriptions и GroupDescriptions отражают свойства представления с теми же именами, которые упоминались выше. Когда класс CollectionViewSource создает представление, он просто передает ему значения этих свойств. Класс CollectionViewSource также включает событие Filter, которое можно обработать для выполнения фильтрации. Эта фильтрация работает точно так же, как обратный вызов Filter, предоставляемый объектом представления, за исключением того, что она определяется в виде события, что позволяет легко привязывать к нему обработчик событий в XAML-разметке. Например, вернемся к предыдущему примеру, в котором товары разбивались на группы с использованием диапазонов цен. Вот как можно декларативно определить необходимый конвертер и класс CollectionViewSource: <local:PriceRangeProductGrouper x:Key="Price50Grouper" GroupInterval=0"/> <CollectionViewSource x:Key="GroupByRangeView"> <CollectionViewSource.SortDescriptions> <component:SortDescription PropertyName="UnitCost" Direction="Ascending"/> </CollectionViewSource.SortDescriptions>
646 Глава 21. Представления данных <CollectionViewSource.GroupDescriptions> <PropertyGroupDescription PropertyName="UnitCost" Converter="{StaticResource Price50Grouper}"/> </CollectionViewSource.GroupDescriptions> </CollectionViewSource> Обратите внимание, что класс SortDescription не принадлежит ни одному из пространств имен WPF. Поэтому для работы с ним должен быть добавлен следующий псевдоним пространства имен: xmlns:component="clr-namespace:System.ComponentModel;assembly=WindowsBase" После создания объекта CollectionViewSource к нему можно сразу же привязаться в списке: <ListBox ItemsSource="{Binding Source={StaticResource GroupByRangeView}}" ... > На первый взгляд это выглядит несколько странно. Кажется, будто бы элемент управления List Box привязывается непосредственно к объекту Collect ionViewSou r се, а не к предлагаемому им представлению (которое хранится в свойстве CollectionViewSource .View). Однако в модели привязки данных WPF для объекта CollectionViewSource делается специальное исключение. Когда он используется в выражении привязки, WPF запрашивает у него создание свого представления и затем привязывает это представление к соответствующему элементу. Декларативный подход на самом деле не экономит никаких усилий. В случае его применения все равно должен быть написан код для извлечения данных во время выполнения. Разница состоит лишь в том, что при таком подходе данные в этом коде должны передаваться в объект CollectionViewSource, а не прямо в список: ICollection<Product> products = App.StoreDB.GetProducts (); CollectionViewSource viewSource = (CollectionViewSource) this.FindResource("GroupByRangeView"); viewSource.Source = products; В качестве альтернативы можно создать коллекцию товаров в виде ресурса с использованием XAML-разметки, а затем привязать объект CollectionViewSource к этой коллекции декларативным образом. Однако все равно пришлось бы использовать код для заполнения коллекции товаров. На заметку! Для создания привязок данных без написания кода некоторые прибегают к сомнительным приемам. В одних случаях коллекция данных определяется и заполняется в XAML- разметке (жестко закодированными значениями). В других случаях код для заполнения объекта данных скрывается в конструкторе этого объекта. Оба эти подхода крайне непрактичны. Они были упомянуты лишь по причине частого применения для создания быстрых, импровизированных примеров привязки данных. После демонстрации подходов с использованием кода и с применением разметки для настройки представления наверняка возник вопрос, какой из них является лучшим проектным решением. Оба они в равной степени допустимы. Выбор зависит от того, где должны быть централизованы детали, касающиеся представления данных. Однако если необходимо использовать множество представлений, выбор подхода становится более важным. В этом случае рекомендуется определять все представления в разметке, а для переключения на подходящее представление использовать код. Совет. Создание множества представлений имеет смысл только в том случае, если эти представления сильно отличаются друг от друга (например, сгруппированы по совершенно разным критериям). Во многих случаях проще изменять информацию о сортировке или группировании для текущего представления.
Глава 21. Представления данных 647 Фильтрация, сортировка и группирование Как уже было показано, представления отслеживают текущую позицию в коллекции объектов данных. Эта очень важная задача, а поиск (или изменение) текущего элемента является одной из основных причин применения представления. Представления также предлагают ряд дополнительных функциональных средств, которые позволяют управлять полным набором элементов. В следующих разделах настоящей главы будет показано, как использовать представление для фильтрации элементов данных (с временным сокрытием тех из них, которые не должны быть видны), для применения сортировки (с изменением порядка следования элементов данных) и для применения группирования (и создания поднаборов, по которым можно перемещаться отдельно). Фильтрация коллекций Фильтрация позволяет отображать подмножество записей, которые отвечают конкретным условиям. Когда коллекция выступает в роли источника данных, фильтр устанавливается с помощью свойства Filter объекта представления. Реализация свойства Filter выглядит немного громоздко. Оно принимает делегат Predicate, указывающий на специальный метод фильтрации (который создается самостоятельно). Ниже показан пример подключения представления к методу по имени FilterProduct(): ListCollectionView view = (ListCollectionView) CollectionViewSource.GetDefaultView(IstProducts.IternsSource); view.Filter = new Predicate<ob]ect>(FilterProduct); Фильтрация проверяет одиночный элемент данных из коллекции и возвращает true в случае, если он должен присутствовать в списке, или false, если его там быть не должно. При создании объекта Predicate указываться тип объекта, который будет проверяться. Неудобство состоит в том, что представление ожидает использования экземпляра Predicate<object> — применять что-то более удобное (например, Predicate<Product>), избежав написания кода приведения типов, нельзя. Ниже показан простой метод, который позволяет отображать товары с ценой более $100: public bool FilterProduct(Object item) { Product product = (Product)item; return (product.UnitCost > 100); } Очевидно, что в жестком кодировании значений в условии фильтрации мало смысла. В реальном приложении фильтрация, скорее всего, будет выполняться динамическим образом на основе какой-то другой информации, например, критерия, предоставляемого пользователем, как показано на рис. 21.3. Для реализации такого сценария можно использовать две стратегии. В случае применения анонимного делегата, можно использовать встроенный метод фильтрации и тогда получать доступ к любым локальным переменным, которые находятся в области действия текущего метода. Ниже приведен пример: ListCollectionView view = (ListCollectionView) CollectionViewSource.GetDefaultView(IstProducts.ItemsSource); view.Filter = delegate(object item) { Product product = (Product)item; return (product.UnitCost > 100); }
648 Глава 21. Представления данных • FilterCollection 1ШШ (jet Products Cloaking Device Counterfeit Creation Wallet Document Transportation System Escape Vehicle (Water) Fake Moustache Translator Hologram Cufflinks Interpreter Earrings Multi-Purpose Watch Rain Racer 2000 Telescoping Comb Toaster Boat Model Numbe' Model Name: Cloak.ng Device t Cost: Д $9,999.99 ascription: Worried about detection on your covert mission? Confuse mission-threatening forces with this cloaking device. Powerful new features mcluae string -activated pre-programmed phrases such as "Danger* Danger!", "Reach for the sky'', and other anti-enemy expressions Hyper-reactive karate chop action deters even the most persistent villain. Price > Than 200 I' fitter .'.""."JJi Remove Filter Рис. 21.3. Фильтрация списка товаров Хотя это аккуратный и элегантный подход, в более сложных сценариях фильтрации может использоваться другая стратегия, предусматривающая создание специального класса фильтрации. Причина в том, что в таких ситуациях часто требуется выполнение фильтрации на основе нескольких различных критериев, которые должны изменяться в будущем. Класс фильтра упаковывает критерий фильтрации и метод обратного вызова, который выполняет саму фильтрацию. Ниже показан пример чрезвычайно простого класса фильтра для товаров, цена которых ниже минимальной. public class ProductByPriceFilter { public decimal MinimumPnce get; set; public ProductByPriceFilter(decimal minimumPrice) MinimumPnce = minimumPrice; public bool Filterltem(Object item) Product product = item as Product; if (product '= null) { return (product.UnitCost > MinimumPrice); } return false; Код создания фильтра ProductByPriceFilterer и его использования для применения фильтрации по минимальной цене выглядит следующим образом: private void cmdFilter_Click(object sender, RoutedEventArgs e) { decimal minimumPrice;
Глава 21. Представления данных 649 if (Decimal.TryParse(txtMinPrice.Text, out minimumPrice)) { ListCollectionView view = CollectionViewSource.GetDefaultViewAstProducts.IternsSource) as ListCollectionView; if (view != null) { ProductByPriceFilter filter = new ProductByPriceFilter(minimumPrice); view.Filter = new Predicate<ob]ect>(filter.Filterltem); } Может возникнуть идея построить разные фильтры для фильтрации различных типов данных, например, MinMaxFilter, StringFilter и т.д. Однако обычно гораздо удобнее создавать одиночный класс фильтра для каждого окна, в котором должна применяться фильтрация. Причина в том, что связывать несколько фильтров в цепочку не допускается. На заметку! Разумеется, можно было бы создать специальную реализацию, которая решит эту проблему — например, класс FilterChain, упаковывающий коллекцию объектов IFilter и вызывающий метод Filterltem() каждого из них для выяснения, должен ли быть исключен элемент. Однако такой дополнительный уровень излишне увеличивает объем и сложность кода. Если нужно модифицировать фильтр позже, не создавая заново объект ProductByPriceFilter, потребуется сохранить ссылку на объект фильтра как переменную-член класса окна. После этого свойства фильтра можно изменять. Однако также потребуется вызвать метод Refresh () объекта представления, чтобы принудительно отфильтровывать список заново. Ниже показан код настройки параметров фильтра при каждом срабатывании события TextChanged в текстовом поле, содержащем минимальную цену. private void txtMinPrice_TextChanged(object sender, TextChangedEventArgs e) { ListCollectionView view = CollectionViewSource.GetDefaultView(IstProducts.ItemsSource) as ListCollectionView; if (view != null) { decimal minimumPrice; if (Decimal.TryParse(txtMinPrice.Text, out minimumPrice) && (filter != null)) { filter.MinimumPrice = minimumPrice; view.Refresh(); } } Совет. По соглашению пользователь должен иметь возможность применять различные типы условий с использованием набора флажков. Например, могут быть предусмотрены флажки для фильтрации по цене, имени, номеру модели и т.д. Пользователь затем будет выбирать условия фильтрации для применения, отмечая соответствующие флажки. И, наконец, для удаления фильтра необходимо установить свойство Filter в null: view.Filter = null;
650 Глава 21. Представления данных Фильтрация объекта DataTable В случае объекта DataTable фильтрация работает по-другому. Если вы ранее имели дело с ADO.NET, то наверняка знаете, что каждый объект DataTable работает вместе с объектом DataView (который, как и DataTable, определен в пространстве имен System.Data вместе с другими ключевыми объектами данных ADO.NET). Объект DataView в ADO.NET играет во многом ту же самую роль, что и объект представления в WPF. Подобно представлению WPF, он позволяет фильтровать записи (по содержимому поля с помощью свойства RowFilter или по состоянию строки с помощью свойства RowStateFilter). Он также поддерживает сортировку через свойство Sort. В отличие от объекта представления WPF, объект DataView позицию в наборе данных не отслеживает. Он предоставляет дополнительные свойства, позволяющие блокировать возможности редактирования (такие как AllowDelete, AllowEdit и AllowNew). Вполне возможно изменять способ фильтрации списка данных путем извлечения привязанного объекта DataView и непосредственного изменения его свойств. (Вспомните, что стандартный объект DataView можно получить из свойства DataTable. Def aultView.) Однако лучше было бы настраивать фильтрацию через объект представления WPF, чтобы иметь возможность продолжать пользоваться той же моделью. Это возможно, но с некоторыми ограничениями. В отличие от ListCollectionView, объект BindingListCollectionView, который применяется с DataTable, не поддерживает свойства Filter. (BindingListCollectionView.CanFilter возвращает false, a попытка установить свойство Filter приводит к генерации исключения.) Вместо этого он предоставляет свойство CustomFilter. Свойство CustomFilter никакой своей работы не выполняет, оно просто берет указанную строку фильтра и использует ее для установки лежащего в основе свойства DataView.RowFilter. Свойство DataView.RowFilter является довольно простым в применении, но немного громоздким. Оно принимает строковое выражение фильтра, которое похоже на применяемое в конструкции WHERE запроса SELECT. Из-за этого необходимо следовать всем соглашениям SQL, например, помещать строковые значения и значения даты в одинарные кавычки ('). Множество условий должны объединяться в одну строку с помощью ключевых слов OR или AND. Ниже показан пример фильтрации объекта DataTable с записями о товарах, который повторяет приведенный ранее пример фильтрации коллекций: decimal minimumPrice; if (Decimal.TryParse(txtMinPrice.Text, out minimumPrice)) { BindingListCollectionView view = CollectionViewSource.GetDefaultView(IstProducts.ItemsSource) as BindingListCollectionView; if (view ' = null) { view.CustomFilter = "UnitCost > " + minimumPrice.ToString(); } } Обратите внимание, что в этом примере применяется обходной путь с преобразованием текста в текстовом поле txtMinPrice в десятичное значение и затем обратно в строку, которая и должна использоваться для фильтрации. Это требует чуть больших усилий, но исключает вероятность атак и ошибок с недопустимыми символами. Текст в поле txtMinPrice может содержать операции фильтра (=, <, >) и ключевые слова (AND, OR), что приведет к совершенно не той фильтрации, которая ожидалась. Это может происходить в результате преднамеренной атаки либо из-за ошибки пользователя.
Глава 21. Представления данных 651 Сортировка Представление также можно использовать и для реализации сортировки. Самым простым подходом является сортировка на основании значения одного или более свойств в каждом элементе данных. Применяемые поля указываются с помощью объектов System.ComponentModel.SortDescription. Каждый объект SortDescription указывает на поле, которое должно использоваться для сортировки, и направление, в котором она должна выполняться (по возрастанию или по убыванию). Добавляются эти объекты SortDescription в том порядке, в котором они должны применяться. Например, можно сделать так, чтобы сортировка сначала осуществлялась по категории, а затем — по названию модели. Ниже приведен пример применения простой сортировки по названию модели в порядке возрастания: ICollectionView view = CollectionViewSource.GetDefaultView(IstProducts.ItemsSource) ; view.SortDescriptions.Add( new SortDescription("ModelName", ListSortDirection.Ascending)); Поскольку в этом коде используется не специальный класс представления, а интерфейс ICollectionView, он работает одинаково хорошо, каким бы ни был тип привязываемого источника данных. В случае BindingListCollectionView (при привязке объекта DataTable) объекты SortDescription используются для создания сортировочной строки, которая применяется к лежащему в основе свойству DataView.Sort. На заметку! При наличии более одного объекта BindingListCollectionView, работающего с одним и тем же объектом DataView, оба будут использовать одинаковые параметры фильтрации и сортировки, потому что эти детали хранятся в DataView, а не в BindingListCollectionView. Если такое поведение не устраивает, можно создать для упаковки одного и того же объекта DataTable несколько представлений DataView. Как и следовало ожидать, при сортировке строк значения упорядочиваются по алфавиту, а числа — в порядке нумерации. Чтобы применить другой порядок сортировки, сначала нужно очистить существующую коллекцию SortDescription. Также еще возможно выполнение специальной сортировки, но только в случае использования ListCollectionView (а не BindingListCollectionView). Класс ListCollectionView предоставляет свойство CustomSort, которое принимает объект IComparer, сравнивающий два элемента данных и указывающий, какой из них больше. Такой подход удобен, если требуется разработать процедуру сортировки, комбинирующую свойства для получения ключа сортировки. В нем также есть смысл при наличии нестандартных правил сортировки. Например, может быть нужно, чтобы несколько первых символов в коде товара игнорировались, чтобы вычисление выполнялось по цене, чтобы поле перед сортировкой преобразовывалось в другой тип данных или другое представление, и т.д. Ниже показан пример, в котором сначала подсчитывается количество букв в названии модели, а затем полученное значение используется для определения порядка сортировки: public class SortByModelNameLength : IComparer { public int Compare(object x, object y) { Product productX = (Product)x; Product productY = (Product)y; return productX.ModelName.Length.CompareTo(productY.ModelName.Length); } }
652 Глава 21. Представления данных Вот код, подключающий IComparer к представлению: ListCollectionView view = (ListCollectionView) CollectionViewSource.GetDefaultView(IstProducts.IternsSourсе) ; view.CustomSort = new SortByModelNameLength(); В этом примере объект IComparer разработан так, чтобы он вписывался в конкретный сценарий. Если необходима возможность многократного использования объекта IComparer с похожими данными, но в разных местах, его можно обобщить. Например, класс SortByModelNameLength можно было бы заменить классом SortByTextLength. При создании экземпляра SortByTextLength код тогда должен был бы предоставлять имя используемого свойства (в виде строки), а метод Compare () мог бы с помощью рефлексии отыскивать его в объекте данных. Группирование Представления также позволяют применять группирование (во многом похожим на сортировку образом). Как и сортировка, группирование может выполняться простым путем (на основе значения одного свойства) и сложным (с использованием специального обратного вызова). Для выполнения группирования необходимо добавить объекты System.ComponentModel. PropertyGroupDescription в коллекцию CollectionView.GroupDescriptions. Ниже показан пример группирования товаров по названию категории: ICollectionView view = CollectionViewSource.GetDefaultView(IstProducts.ItemsSource); view.GroupDescriptions.Add(new PropertyGroupDescription("CategoryName")); На заметку! В этом примере предполагается, что класс Product имеет свойство по имени CategoryName. Однако более вероятно, что он будет иметь свойство Category (возвращающее связанный объект Category) или CategorylD (идентифицирующее категорию с помощью уникального идентификационного номера). И в том и в другом случае все равно можно будет использовать группирование, но только придется добавить конвертер значений, способный анализировать группируемую информацию (т.е. объект Category или свойство CategorylD) и возвращать правильный текст категории для использования с группой. Использование конвертера значений вместе с группированием будет демонстрироваться в следующем примере. В этом примере присутствует одна проблема. Хотя элементы и будут теперь упорядочиваться в отдельные группы на основе категорий, при взгляде на список увидеть, что было применено какое-то группирование, будет трудно. Фактически результат будет выглядеть так же, как и в случае, если бы производилась и сортировка по названию категории. В действительности же происходит нечто большее, просто увидеть это при параметрах по умолчанию невозможно. Когда используется группирование, для каждой группы создается отдельный объект Groupltem, и все эти объекты Groupltem добавляются в список. Groupltem представляет собой элемент управления содержимым, поэтому в каждом объекте Groupltem находится соответствующий контейнер (наподобие ListBoxItem) с фактическими данными. Чтобы группы были видны, можно просто сформатировать элемент Groupltem с целью его выделения (т.е. он должен отличаться от остальных элементов). Для этого можно использовать стиль, применяющий форматирование ко всем объектам Groupltem в списке. Одного форматирования, однако, будет не достаточно, например, может также понадобиться, чтобы для каждой группы отображался заголовок, а это уже требует применения шаблона. К счастью, класс ItemsControl позволяет легко решать обе задачи, благодаря свойству ItemsControl.GroupStyle, которое предос-
Глава 21. Представления данных 653 тавляет коллекцию объектов GroupStyle. Несмотря на имя, класс GroupStyle стилем не является. Он представляет собой просто удобный пакет, который включает в себя несколько полезных параметров для конфигурирования объектов Group Item. Свойства класса GroupStyle перечислены в табл. 21.1. Таблица 21.1. Свойства класса GroupStyle Имя Описание ContainerStyle ContainerstyleSelector HeaderTemplate HeaderTemplateSelector Panel Позволяет устанавливать стиль, который должен применяться к генерируемому для каждой группы объекту Groupltem Может использоваться вместо свойства ContainerStyle для предоставления класса, способного выбирать подходящий стиль на основе группы Позволяет создавать шаблон для отображения какого-нибудь содержимого в начале каждой группы Может применяться вместо свойства HeaderTemplate для предоставления класса, способного выбирать подходящий шаблон заголовка на основании группы Позволяет изменять шаблон, используемый для размещения групп. Например, вместо стандартного шаблона StackPanel может применяться шаблон WrapPanel для создания списка с мозаичным размещением групп слева направо и вниз В этом примере требуется, чтобы перед каждой группой отображался заголовок, для получения эффекта, показанного на рис. 21.4. ■ GroupList Get Products ModtJ Numtoei . Communications Device Pake Moustache Translator Interpreter Earrings Nonexplosive Cigar Persuasive Pencil Cloaking Device ' Correction Fluid Counterfeit Creation Wallet | Hologram Cufflinks ! Indentity Confusion Device i Contact Lenses : Document Transportation System Unit Cost: Description: 29390C '£ Contrary to popular spy lore, not all 9 cigars owned by spies explode! Best used 11! during mission briefings, our Nonexplosive Cigar is really a cleverty- Я disguised, top-of-the-line. precision laser |g pointer. Make your next presentation a Рис. 21.4. Группирование списка товаров Для добавления заголовка к группе необходимо установить свойство GroupStyle. HeaderTemplate. Это свойство можно заполнить любым простым шаблоном данных вроде тех, что показывались в главе 20. Внутри шаблона можно использовать любую комбинацию элементов и выражений привязки данных. Существует, однако, одна хитрость. При написании выражения привязки привязывание должно выполняться не в отношении объекта данных из списка (в рассматриваемом случае это объект Product), а в отношении объекта PropertyGroupDescription для этой
654 Глава 21. Представления данных группы. Это означает, что если нужно отобразить значение поля для этой группы (как показано на рис. 21.4), потребуется привязать свойство PropertyGroupDescription.Name, а не Product.CategoryName. Ниже показан весь шаблон. <ListBox Name="lstProducts" DisplayMemberPath="ModelName"> <ListBox.Groupstyle> <GroupStyle> <GroupStyle.HeaderTemplate> <DataTemplate> <TextBlock Text=" {Binding Path=Name}" FontWeight="Bold" Foreground="White" Васkground="LightGreen" Margin=,5,0,0" Padding="/> </DataTemplate> </GroupStyle.HeaderTemplate> </GroupStyle> </ListBox.Groupstyle> </ListBox> Совет. Свойство ListBox.GroupStyle фактически представляет собой коллекцию объектов GroupStyle и позволяет добавлять множество уровней группирования. Для этого потребуется добавить несколько объектов PropertyGroupDescription (в порядке, в котором должно применяться группирование и подгруппирование), а затем добавить соответствующие объекты GroupStyle для форматирования каждого уровня. Группирование часто используется вместе с сортировкой. Для сортировки групп понадобится всего лишь сделать так, чтобы первый используемый объект SortDescription осуществлял сортировку на основе поля группировки. Ниже показан код, в котором сначала в алфавитном порядке по названию категории сортируются категории, а затем в алфавитном порядке по имени модели сортируются товары в этой категории: view. SortDescriptions.Add(new SortDescription("CategoryName", ListSortDirection.Ascending)); view.SortDescriptions.Add(new SortDescription("ModelName", ListSortDirection.Ascending)); Одно из ограничений демонстрируемого здесь подхода с простым группированием заключается в том, что для выполнения группирования он требует наличия поля с дублированными значениями. Предыдущий пример будет работать потому, что многие продукты относятся к одной и той же категории и, соответственно, имеют дублированные значения в свойстве CategoryName. Однако при попытке выполнить группирование по какому-то другому фрагменту информации, например, по полю UnitCost, этот подход уже не будет работать столь же хорошо. В таком случае он приведет к созданию отдельной группы для каждого продукта. Для этой проблемы существует решение. Можно создать класс, анализирующий какой-то фрагмент информации и помещающий его в концептуальную группу с целью отображения. Такой прием очень часто используется для группирования объектов данных с числами или датами, разбиваемыми на определенные диапазоны. В текущем примере одну группу можно было бы создать для продуктов, цена которых составляет меньше $50, другую — для продуктов, цена которых находится в диапазоне от $50 до $100, и т.д., как показано на рис. 21.5. Для реализации такого решения потребуется предоставить конвертер значений, анализирующий поле в источнике данных (или множество полей, если реализовать конвертер типа IMultiValueConverter) и возвращающий заголовок группы. При условии использования одинакового заголовка группы для множества объектов данных, эти объекты будут помещаться в одну и ту же логическую группу.
Глава 21. Представления данных 655 • ' GroupInRanges L- JzLZL22£ Nonexptosive Cigar ($29.99) Global Navigational System ($29.99) Communications Device ($49.99) ; Contact Lenses ($59.99) I Cocktail Party Pal ($69.99) Bullet Proof Facial Tissue ($79.99) I Ultra Violet Attack Defender ($89.99) Eavesdrop Detector ($99.99) ; Rubber Stamp Beacon ($129.99) i Mighty Mighty Pen ($129.99) ! Perfect-Vision Glasses ($129.99) Extracting Tool ($199.00) Document Transportation System ($299.99) MurtJ:Purt)ose Watch ($3< Рис. 21.5. Группирование по диапазонам Следующий код иллюстрирует конвертер, создающий диапазоны цен, которые были показаны на рис. 21.5. Он спроектирован с определенной долей гибкости — в частности, в нем можно задавать размер диапазонов группирования. (На рис. 21.5 размер диапазона составляет 50 единиц.) public class PriceRangeProductGrouper : IValueConverter { public int Grouplnterval { get; set; } public object Convert(object value, Type targetType, object parameter, Culturelnfo culture) { decimal price = (decimal)value; if (price < Grouplnterval) { return String.Format(culture, "Less than {0:C}", Grouplnterval); } else int interval = (int)price / Grouplnterval; int lowerLimit = interval * Grouplnterval; int upperLimit = (interval + 1) * Grouplnterval; return String.Format(culture, "{0:С} to {1:С}", lowerLimit, upperLimit); } } public object ConvertBack(object value, Type targetType, object parameter, Culturelnfo culture) { throw new NotSupportedException("This converter is for grouping only."); }
656 Глава 21. Представления данных Чтобы сделать этот класс еще более гибким, обеспечив возможность его использования с другими полями, в него можно было бы добавить дополнительные свойства, позволяющие устанавливать фиксированную часть текста заголовка, и строку формата, который должен применяться при преобразовании числовых значений в текст заголовка. (В текущем коде предполагается, что числа должны интерпретироваться как денежные единицы, поэтому число 50 в заголовке превращается в $50.00.) Ниже показан код, в котором данный конвертер используется для применения группирования по диапазонам. Обратите внимание, что товары должны сначала сортироваться по цене, иначе они будут сгруппированы на основе своего места в списке. ICollectionView view = CollectionViewSource.GetDefaultView(IstProducts.IternsSource); view.SortDescriptions.Add(new SortDescription("UnitCost", ListSortDirection.Ascending)); PriceRangeProductGrouper grouper = new PriceRangeProductGrouper(); grouper.Grouplnterval = 50; view.GroupDescriptions.Add (new PropertyGroupDescription ("UnitCost", grouper)); В этом примере вся работа выполняется в коде, но конвертер и представление можно также создать и декларативно, поместив их в коллекцию Resources окна, как было показано ранее в этой главе. Резюме Представления являются последним фрагментом в головоломке под названием "привязка данных". Они образуют бесценный дополнительный уровень между данными и отображающими их элементами, позволяя управлять их местоположением в коллекции и реализовывать для них фильтрацию, сортировку и группирование. В каждом сценарии привязки данных обязательно применяется какое-нибудь представление. Отличаться может только то, каким образом оно применяется — неявно "за кулисами" или явным внутри кода. К этому моменту рассмотрены все ключевые принципы привязки данных (и даже немного больше). В следующей главе речь пойдет о трех элементах управления, которые предлагают дополнительные варианты для представления и редактирования привязанных данных— ListView, TreeView и DataGrid.
ГЛАВА 22 Элементы управления ListView, TreeView и DataGrid К настоящему моменту вы изучили широкий спектр технологий и приемов использования привязки данных WPF для отображения информации в необходимой форме. Во время изучения было приведено множество примеров, но все они были сосредоточены на применении только скромного элемента управления List Box. Благодаря возможностям расширения, предоставляемым стилями, шаблонами данных и шаблонами элементов управления, даже элемент управления List Box (и похожий на него ComboBox) может превращаться в удивительно мощный инструмент для отображения данных различными способами. Некоторые типы представлений данных, однако, реализовать с помощью одного только элемента управления ListBox довольно трудно. К счастью, в WPF поставляется несколько многофункциональных элементов для управления данными, которые заполняют все пробелы. • Элемент управления ListView. Этот элемент управления унаследован от обычного элемента управления ListBox и добавляет к нему поддержку для отображения данных по столбцам и возможность быстрого переключения между различными "представлениями", или режимами отображения, без повторной привязки данных и построения списка. • Элемент управления TreeView. Этот элемент управления представляет собой контейнер иерархического типа, т.е. позволяет обеспечивать многоуровневое отображение данных. Например, можно создать такой элемент управления TreeView, в котором группы категорий будут отображаться на первом уровне, а относящиеся к ним товары — каждый под узлом, представляющим соответствующую категорию • Элемент управления DataGrid. Элемент управления DataGrid является самым полнофункциональным инструментом для отображения данных в WPF. Он позволяет представлять данные в виде сетки со столбцами и строками, подобно элементу управления ListView, но в отличие от него обладает дополнительными возможностями для форматирования (вроде фиксации столбцов и стилизации отдельных строк), а также поддерживает редактирование данных на месте их размещения. Об этих трех ключевых элементах и пойдет речь в настоящей главе.
658 Глава 22. Элементы управления ListView, TreeView и DataGrid Что нового? В более ранних версиях WPF отсутствовал профессиональный элемент управления сеткой для редактирования данных. К счастью, в .NET 3.5 SP1 библиотека элементов управления пополнилась мощным элементом управления DataGrid. Элемент управления ListView Класс ListView — это специализированный класс спискового типа, предназначенный для отображения различных представлений одних и тех же данных. Он особенно удобен, когда требуется создать представление, состоящее из множества столбцов и отображающее о каждом элементе данных несколько различных фрагментов информации. Класс ListView унаследован от ListBox и дополняет его одной единственной деталью: свойством View. Свойство View представляет собой еще одну точку для создания многофункциональных списковых представлений. Если это свойство не установлено, элемент ListView ведет себя просто точно так же, как и его менее мощный предок — класс ListBox. Однако он становится гораздо более интересным, когда разработчик предоставляет объект представления, указывающий, каким должно быть форматирование и стили у элементов данных. Формально свойство View указывает на экземпляр любого класса, унаследованного от ViewBase (который представляет собой абстрактный класс). Класс ViewBase удивительно прост: в действительности это не более чем оболочка, объединяющая вместе два стиля. Один из этих стилей применяется к элементу управления ListView (и указывается в свойстве Def aultStyleKey), в то время как другой применяется элементам внутри ListView (и указывается в свойстве ItemContainerDef aultStyleKey). Свойства Def aultStyleKey и ItemContainerDef aultStyleKey фактически стиль не предоставляют, а вместо этого они просто возвращают указывающий на него объект ResourceKey. Сейчас наверняка интересно узнать, а зачем тогда вообще нужно свойство View — в конце концов, класс ListBox (как и все классы, унаследованные от ItemsControl) уже предлагает такие мощные возможности, как шаблоны данных и стили. Целеустремленные разработчики могут переделывать внешний вид элемента управления ListBox, предоставляя другой шаблон данных, другую панель компоновки и другой шаблон элемента управления. Вообще говоря, для создания настраиваемых списков с множеством столбцов использовать класс ListView со свойством View необязательно. Точно такого же эффекта можно добиться и самостоятельно с помощью поддерживаемых классом ListBox шаблонов и стилей. Однако свойство View является полезной абстракцией. Ниже перечислены некоторые основные преимущества. • Представления, пригодные для многократного использования. ListView выделяет все касающиеся представления детали в один объект. Это упрощает создание представлений, не зависящих от данных и пригодных для применения с более чем одним списком. • Множественные представления. Отделение элемента управления ListView от объектов View также упрощает переключение между множеством различных представлений в одном и том же списке. (Например, именно такая технология применяется в проводнике Windows для получения различных представлений файлов и папок пользователя.) Точно такую же функциональную возможность можно создать и за счет динамического изменения шаблонов и стилей, но построить единственный объект, инкапсулирующий все детали представления, все-таки гораздо легче.
Глава 22. Элементы управления ListView, TreeView и DataGrid 659 • Более удобная организация. Объект представления упаковывает два стиля: один для корневого элемента управления ListView и еще один — для отдельных элементов в списке. Поскольку эти стили упаковываются вместе, очевидно, что эти два фрагмента связаны между собой и могут разделять определенные детали и взаимозависимости. Например, для основанного на столбцах элемента управления ListView это имеет большой смысл, поскольку ему необходимо, чтобы размещение заголовков столбцов совпадало с размещением предназначенных для этих столбцов данных. Применение этой модели открывает замечательные возможности для создания ряда полезных готовых представлений, которыми бы могли пользоваться все разработчики. К сожалению, в настоящее время в состав WPF входит пока только один объект представления — объект GridView. Хотя его можно и удобно использовать для построения списков с множеством столбцов, при наличии каких-то других потребностей придется создавать собственное специальное представление. В последующих разделах будет показано, как делать то и другое. На заметку! Элемент GridView будет замечательным выбором, если нужно, чтобы отображаемые данные можно было конфигурировать, а представление в стиле сетки было одной из доступных для пользователя опций. Но если требуется, чтобы в сетке дополнительно поддерживались расширенная стилизация, выбор или редактирование, придется перейти к использованию полнофункционального элемента управления DataGrid, который подробно рассматривается далее в главе. Создание столбцов с помощью GridView GridView — это класс, который наследуется от ViewBase и предоставляет списковое представление с множеством столбцов. Определяются эти столбцы путем добавления в коллекцию GridViewColumns объектов GridViewColumn. GridView и GridViewColumn предлагают небольшой набор полезных методов, которые разработчик может использовать для настройки внешнего вида своего списка. Чтобы создать самый простой прямолинейный список (вроде Details (Список) в проводнике Windows), потребуется установить для каждого объекта GridViewColumn всего лишь два свойства: Header и DisplayMemberBinding. Свойство Header отвечает за размещаемый в верхней части столбца текст, а свойство DisplayMemberBinding содержит привязку, извлекающую из каждого элемента данных подлежащий отображению фрагмент информации. На рис. 22.1 показан простой пример с тремя столбцами информации о товаре. Ниже приведен код разметки, необходимый для определения трех используемых в этом примере столбцов. <ListView Margin=" Name="lstProducts"> <ListView.View> <GridView> <GridView.Columns> <GridViewColumn Header="Name" DisplayMemberBinding="{Binding Path=ModelName}" /> <GridViewColumn Header="Model" DisplayMemberBinding="{Binding Path=ModelNumber}" /> <GridViewColumn Header="Price" DisplayMemberBinding= "{Binding Path=UnitCost, StringFormat={}{0:C} } " /> </GridView.Columns> </GridView> </ListView.View> </ListView>
660 Глава 22. Элементы управления ListView, TreeView и DataGrid bic.stV.ew Name j Rain Racer 2000 Edible Tape 1 Escape Vehicle (Air) i Extracting Tool | Escape Vehicle (Water) ! Communications Device j Persuasive Pencil j Multi-Purpose Rubber Band ! Universal Repair System ; Effective Flashlight The Incredible Versatile Paperclip i Toaster Boat | Murti-Purpose Towelette Mighty Mighty Pen *ect-Vision Glasses Model RU007 STKY1 эзе NOZ119 PT109 RED1 IX4TLNT NTMBS1 NE1RPR BRTLGT1 INCPPRCLP DNTRPR TGFDA WOWPEN ICNCU Price $1,499.99 $399 $2.99 $199.00 $1.299.99 $49.99 $1.99 $1.99 $4.99 $9 99 $149 $19,999.98 $12.99 $129.99 $129.99 _ * : __- Рис. 22.1. Элемент управления ListView на основе сетки Здесь есть несколько важных моментов, на которые стоит обратить внимание. Во- первых, размер ни одного из столбцов не является жестко закодированным. Вместо этого GridView устанавливает размер столбцов в соответствии с размером самого широкого видимого элемента (или столбца заголовка, если тот занимает больше места в ширину), что вполне логично для основанного на гибкой компоновке мира WPF. (Конечно, при наличии огромных значений столбцов это немного усложняет дело. В таком случае можно попробовать упаковать текст, как будет рассказываться чуть позже в этой главе, в разделе "Шаблоны ячеек"). Во-вторых, свойство DisplayMemberBinding устанавливается с использованием полнофункционального выражения привязки, которое поддерживает все возможности, описанные в главе 20, в том числе форматирование строк и конвертеры значений. Изменение размера столбцов Изначально GridView делает каждый столбец настолько широким, насколько необходимо для того, чтобы в нем могло уместиться самое больше видимое значение. Однако размер любого столбца можно легко изменить, щелкнув и перетащив край его заголовка. Дважды щелкнув на крае заголовка столбца, можно заставить GridViewColumn изменить свой размер самостоятельно на основании какого-либо видимого в нем в текущий момент содержимого. Например, в случае обнаружения при прокрутке списка вниз элемента, усеченного из-за несоответствия его размера ширине столбца, можно просто дважды щелкнуть на правом крае заголовка этого столбца, и столбец автоматически расширится должным образом. Для более точного управления размером столбца при его объявлении можно указать конкретную ширину: <GridViewColumn Width=00" ... /> Это просто определяет начальный размер столбца. На возможность пользователя изменять размер столбца описанными выше способами это никак не влияет. К сожалению, свойства вроде MaxWidth и MinWidth класс GridViewColumn не поддерживает, так что ограничить пределы, до которых пользователь может изменять размеры столбцов, нельзя. Единственный вариант — предоставить новый шаблон для заголовка GridViewColumn и вообще отключить возможность изменения его размера.
Глава 22. Элементы управления ListView, TreeView и DataGrid 661 На заметку! Пользователь может также изменять порядок столбцов путем перетаскивания заголовка в новое место. Шаблоны ячеек Свойство GridViewColumn.DisplayMemberBinding не является единственным способом для отображения данных в ячейке. Вместо него можно использовать свойство CellTemplate, которое принимает шаблон данных. Этот шаблон данных отличается от рассмотренных в главе 20 только тем, что он применяется лишь к одному конкретному столбцу. При желании разработчика у каждого столбца может быть собственный шаблон данных. Шаблоны ячеек (CellTemplate) являются ключевым фрагментом головоломки при настройке GridView. К числу наиболее важных из предоставляемых ими возможностей относится перенос текста. Обычно текст в столбце переносится в однострочном элементе TextBlock. Однако эту деталь можно легко изменить, создав собственный шаблон данных со своей схемой деления: <GndViewColumn Header="Description" Width=00"> <GndViewColumn. CellTemplate> <DataTemplate> <TextBlock Text="{Binding Path=Description}" TextWrapping="Wrap"x/TextBlock> </DataTemplate> </GndViewColumn. CellTemplate> </GndViewColumn> Обратите внимание, что для того, чтобы перенос дал результат, ширину столбца необходимо ограничить с помощью свойства Width. Тогда в случае изменения пользователем размера столбца текст будет переноситься заново соответствующим образом. Ограничивать ширину элемента TextBlock не нужно, поскольку тогда текст будет иметь одинаковый размер, каким бы широкими или узким его не делал пользователь. Единственным ограничением в этом примере является то, что шаблон данных должен явным образом привязываться к отображаемому свойству. Поэтому создать шаблон, обеспечивающий перенос, и использовать его повторно для каждого фрагмента содержимого нельзя. Вместо этого понадобится создать отдельный шаблон для каждого поля. В данном примере это не проблема, но в случае создания более сложного шаблона, который можно было бы применять и к другим спискам (например, шаблона, преобразующего данные в изображение и отображающего его в элементе Image, или шаблона, использующего элемент управления Text Box для обеспечения возможности редактирования), это может оказаться утомительным. Простого способа для повторного использования любого шаблона на множестве столбцов не существует: вместо этого придется вырезать и вставлять шаблон, а затем изменять привязку. На заметку! Было бы совсем неплохо создать шаблон данных, включающий свойство DisplayMemberBinding. Тогда DisplayMemberBinding можно было бы применять для извлечения необходимого свойства, a CellTemplate — для форматирования находящегося в нем содержимого в подходящее визуальное представление. К сожалению, такой вариант просто не возможен. В случае, когда устанавливаются оба свойства DisplayMember и CellTemplate, класс GridViewColumn использует для установки содержимого ячейки свойство DisplayMember, а шаблон просто игнорирует. Возможности шаблонов данных не ограничиваются только возможностью настройки свойств элемента TextBlock. Шаблоны данных можно также использовать и для предоставления совершенно других элементов.
662 Глава 22. Элементы управления ListView, TreeView и DataGrid Например, в следующем столбце шаблон данных применяется для отображения рисунка. Конвертер ProductImagePath (демонстрировавшийся в главе 20) отвечает за загрузку соответствующего файла изображения из файловой системы. <GndViewColumn Header="Picture" > <GndViewColumn. CellTemplate> <DataTemplate> <Image Source= "{Binding Path=ProductImagePath,Converter={StaticResource ImagePathConverter}}"> </Image> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn> На рис. 22.2 показан элемент управления ListView, в котором используются оба эти шаблона для отображения текста с переносами и рисунка товара. ■ AdvancedListView totf^MM Hologram Cuffbnks Fake Moustache Transla TCKLR1 Interpreter Eamngs JWLTRANS6 Description Just pomt, and a turn of the wrist will project a hologram of you up to 100 yards away Sneaking past guards will be child's play when you've sent them on a wild goose chase. Note: Hologram adds ten pounds to your appearance. Fake Moustache Translator attaches between nose and mouth to double as a language translator and identity concealer Sophisticated electronics translate your voice into the desired language. Wriggle your nose to toggle between Spanish. English, French, and Arabic. Excellent on diplomatic missions The simple elegance of our stylrsh monosex earnngs accents any wardrobe, but their clean lines mask the sophisticated technology within Twist the lower half to engage a translator function that intercepts spoken words m any language and converts them to the wearer s native tongue. Warning: do not use in conjunction with our Fake Moustache Translator product as the resulting feedback loop makes any language sound like Pig Latin Picture Price SCQ?9U $459.99 Рис. 22.2. Столбцы, использующие шаблоны Совет. При создании шаблона данных можно определить его как внутристрочный (что и было сделано в двух предыдущих примерах) или сослаться на ресурс, определенный в другом месте. Поскольку шаблоны столбцов нельзя использовать повторно для различных полей, обычно наиболее правильным подходом является все-таки первый вариант. Как рассказывалось в главе 20, шаблоны можно варьировать, чтобы разные элементы данных получали разные шаблоны. Для этого необходимо создать селектор шаблонов, выбирающий подходящий шаблон на основании свойств находящегося в конкретной позиции объекта данных. Если такая функциональная возможность необходима, создайте селектор и примените его для установки свойства GridViewColumn. CellTemplateSelector. Пример селектора шаблона приводился в главе 20.
Глава 22. Элементы управления ListView, TreeView и DataGrid 663 Настройка заголовков столбцов Шаблоны ячеек не являются единственными шаблонами, которые можно применять с ListView. Можно также использовать и шаблоны заголовков столбцов для изменения внешнего вида заголовков столбцов, если стандартный прямоугольник с серой заливкой не устраивает. Пока что было показано только, каким образом настраивать внешний вид значений в каждой ячейке. Однако изменение внешнего вида заголовков столбцов еще не демонстрировалось. Тех, кого стандартные прямоугольники с серой заливкой не впечатляют, несомненно, обрадует тот факт, что содержимое и внешний вид заголовков столбцов можно изменять так же легко, как и значения столбцов. На самом деле существует даже несколько вариантов. При желании оставить стандартные прямоугольники с серой заливкой, но заполнить их своим собственным содержимым, можно установить свойство GridViewColumn.Header. В предыдущих примерах свойство Header использовалось с обычным текстом, но вместо него можно предоставить и, например, элемент StackPanel, упаковывающий элементы TextBlock и Image, и создать необычный заголовок с текстом и рисунком внутри. Если требуется заполнить заголовки столбцов собственным содержимым, но не указывать это содержимое отдельно для каждого столбца, можно воспользоваться свойством GridViewColumn. HeaderTemplate для определения подходящего шаблона данных. Этот шаблон данных привязывается к любому объекту, который был указан в свойстве GridViewColumn.Header, и представляет его соответствующим образом. Чтобы изменить форматирование конкретного заголовка столбца, необходимо с помощью свойства GridViewColumn.HeaderContainerStyle предоставить нужный стиль. Для переформатирования всех заголовков столбцов одинаковым образом следует использовать свойство GridView.ColumnHeaderContainerStyle. Но если необходимо полностью изменить внешний вид заголовка (например, заменить прямоугольник с серой заливкой на границу со скругленными углами и фоном голубого цвета), можно предоставить для него совершенно новый шаблон элемента управления. Чтобы сделать это для одного конкретного столбца, следует воспользоваться свойством GridViewColumn.HeaderTemplate, а для всех столбцов сразу — свойством GridView.ColumnHeaderTemplate. Вдобавок также можно применить и селектор шаблонов для выбора правильного шаблона для данного заголовка, установив свойство GridViewColumn.HeaderTemplateSelector или GridView. ColumnHeaderTemplateSelector. Создание специального представления Если GridView не отвечает существующим потребностям, можно создать собственное представление и расширить в нем возможности ListView. К сожалению, простым этот процесс назвать нельзя. Понять, в чем заключается проблема, можно только узнав немного больше о том, как работают представления. А работают они за счет переопределения двух защищенных свойств: DefaultStyleKey и ItemContainerDefaultKeyStyle. Каждое из этих свойств возвращает специальный объект под названием ResourceKey, который указывает на определенный в XAML стиль. Свойство DefaultStyleKey указывает на стиль, который должен применяться для конфигурирования ListView в целом, а свойство ItemContainerDefaultKeyStyle — на стиль, который должен использоваться для конфигурирования каждого имеющегося в этом ListView объекта ListViewItem. Хотя эти стили свободно могут настраивать любое свойство, обычно они выполняют свою работу путем замены шаблона ControlTemplate, который используется для ListView, и шаблона DataTemplate, применяемого для каждого ListViewItem.
664 Глава 22. Элементы управления ListView, TreeView и DataGrid Здесь как раз и возникают проблемы. Шаблон DataTemplate, отвечающий за отображение элементов, определяется в XAML-разметке. Давайте предположим, что вы хотите создать представление ListView, в котором бы для каждого элемента отображался фрагментированный мозаичным образом рисунок (плитка). Это довольно легко сделать с помощью DataTemplate: нужно всего лишь привязать свойство Source элемента Image к правильному свойству объекта данных. Но как узнать, какой объект данных предоставит пользователь? Если вы жестко закодируете имена свойств в виде части представления, степень его полезности снизится, и использовать его повторно в других сценариях станет невозможно. Альтернативный вариант — принуждение пользователя предоставлять именно объект DataTemplate — не позволяет упаковать в представление такой объем функциональности, из-за чего использовать его повторно станет бессмысленно. Совет. Прежде чем приступать к созданию специального представления, следует продумать, не удастся ли добиться точно такого же результата за счет просто использования правильного DataTemplate с ListBox или комбинации ListView/GridView. Зачем же прилагать столько усилий для создания специального представления, если все нужные функциональные возможности можно получить за счет изменения стиля ListView ( или даже ListBox)? Одна из главных причин связана с потребностью в получении списка, способного менять представления динамическим образом. Например, может быть необходим список товаров, допускающий просмотр в разных режимах в зависимости от выбора пользователя. Реализовать такое поведение можно динамической подстановкой различных объектов DataTemplate (и такой подход вполне разумен), но часто представлению необходимо изменять не только объект DataTemplate элемента ListViewItem, но и компоновку или общий внешний вид самого элемента управления ListView. Представление помогает прояснить взаимосвязь между этими деталями в исходном коде. В следующем примере демонстрируется создание сетки с возможностью плавного переключения с одного представления на другое. Начинается эта сетка с уже знакомого столбчатого представления, но также поддерживает еще два представления, которые показаны на рис. 22.3 и 22.4. Рис. 22.3. Представление ImageView
Глава 22. Элементы управления ListView, TreeView и DataGrid 665 • Data&nding Dilemma Resolution Device ■Kr N BPRECKEOO TIL J S1199 L ) Document Transportation System ^ J $29999 m 4^ш\ Fake Moustache Translator ^И I TCKLR1 ^ 1 $599.99 ] Multi-Purpose Watch |^&^^>) GRTWTCH9 ■lI S399.99 \ Nonexplosive Cigar LSRPTR1 $29.99 Hologram Cufflinks THNKDKE1 $799.99 Interpreter Earrings JWLTRANS6 $45959 Choose your view. IjjjjjjjjjjSjjjjj Рис. 22.4. Представление ImageDetailView Класс представления Первым шагом, необходимым при разработке данного примера, является создание класса специального представления. Этот класс должен наследоваться от ViewBase. Вдобавок он обычно (хотя и не всегда) должен переопределять свойства Def aultStyleKey и ItemContainerDef aultStyleKey и предоставлять ссылки на стили. В данном примере это представление называется TileView, поскольку его главной задачей является мозаичное отображение элементов во всем доступном пространстве. Для расположения содержащихся в нем объектов ListViewItem применяется WrapPanel. Это представление не называется ImageView потому, что мозаичное содержимое не является жестко закодированным и может вообще не включать никаких изображений. Вместо этого оно определяется с помощью шаблона, который предоставляется разработчиком при использовании TileView. Класс TileView применяет два стиля: TileView (применяется к ListView) и TileViewItem (применяется к ListViewItem). Вдобавок доступно свойство по имени ItemTemplate, позволяющее использующему TileView разработчику предоставить правильный шаблон данных. Этот шаблон затем вставляется внутрь каждого элемента ListViewItem и применяется для создания мозаичного содержимого. public class TileView : ViewBase { private DataTemplate ItemTemplate; public DataTemplate ItemTemplate get { return ItemTemplate; } set { ItemTemplate = value; } protected override object DefaultStyleKey get { return new ComponentResourceKey(GetType(), "TileView"); } protected override object ItemContainerDefaultStyleKey get { return new ComponentResourceKey(GetType(), "TileViewItem"); }
666 Глава 22. Элементы управления ListView, TreeView и DataGrid Как видно, класс TileView делает не особо много. Он просто предоставляет ссылку ComponentResourceKey, которая указывает на правильный стиль. О ComponentResourceKey уже рассказывалось в главе 10 при описании способов извлечения совместно используемых ресурсов из сборки DLL. ComponentResourceKey упаковывает два фрагмента информации: тип класса, который владеет стилем, и описательную строку Resourceld, которая идентифицирует ресурс. В данном примере типом класса для обоих ключей ресурсов является TileView. Описательные имена Resourceld не столь важны, но должны быть согласованными. В данном примере ключ стиля по умолчанию называется TileView, а ключ стиля для каждого элемента ListViewItem — TileViewItem. В следующем разделе эти стили рассматриваются более подробно. Стили представления Для того чтобы представление TileView работало так, как надо, WPF должна быть способна отыскивать подлежащие применению стили. Гарантировать, что стили будут доступны автоматически, можно, создав словарь ресурсов с именем generic.xaml. Этот словарь ресурсов следует разместить в подпапке проекта под названием Themes. WPF использует файл generic.xaml для извлечения ассоциируемых с классом стилей по умолчанию. (Об этой системе более подробно будет рассказываться в главе 18 при рассмотрении процесса разработки специальных элементов управления.) В данном примере в файле generic.xaml содержатся определения стилей, ассоциируемых с классом TileView. Чтобы установить ассоциацию между стилями и TileView, нужно присвоить каждому из этих стилей правильный ключ в словаре ресурсов generic.xaml. Вместо обычного строкового ключа WPF ожидает объект ComponentResourceKey, который должен соответствовать информации, возвращаемой свойствами DefaultStyleKey и ItemContainerDefaultStyleKey класса TileView. Ниже показана базовая структура словаря ресурсов generic.xaml с правильными ключами. <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:DataBinding"> <Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:TileView}, ResourceId=TileView}" TargetType=" {x:Type ListView}11 Based0n="{StaticResource {x:Type ListBox}}"> </Style> <Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:TileView}, ResourceId=TileViewItem}" TargetType="{x:Type ListViewItem}" Based0n="{StaticResource {x:Type ListBoxItem}}"> </Style> </ResourceDictionary> Нетрудно заметить, что ключ каждого стиля устанавливается так, чтобы он соответствовал информации, предоставляемой классом TileView. Вдобавок в стилях также устанавливаются свойства TargetType (для указания, какой элемент должен изменять данный стиль) и BasedOn (для наследования базовых параметров от более фундаментальных стилей, используемых с ListBox и ListBoxItem). Это экономит часть усилий и позволяет сфокусироваться на расширении этих стилей с помощью специальных параметров. Поскольку эти два стиля ассоциируются с TileView, именно они и будут применяться для конфигурирования ListView при присваивании свойству View объекта TileView.
Глава 22. Элементы управления ListView, TreeView и DataGrid 667 В случае использования другого объекта представления эти стили будут игнорироваться. Это и есть тот трюк, который заставляет представление ListView работать должным образом, т.е. плавно перестраиваться при каждом изменении значения свойства View. Стиль TileView, применяющийся к ListView, вносит три перечисленных ниже изменения. • Добавляет вокруг ListView немного отличающуюся границу. • Устанавливает для присоединенного свойства Grid.IsSharedSizeScope значение true. Это позволяет различным элементам списка использовать разделяемые параметры столбцов или строк, если они находятся в контейнере Grid (об этом средстве уже рассказывалось в главе 3). В данном примере это гарантирует наличие одинакового размера у всех элементов в детальном мозаичном представлении (imageDetailView). • Изменяет ItemsPanel со StackPanel на WrapPanel, разрешая мозаичное поведение. Ширина WrapPanel устанавливается в соответствии с шириной ListView. Ниже приведен полный код разметки для этого стиля. <Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:TileView}, ResourceId=TileView}" TargetType="{x:Type ListView}11 BasedOn="{StaticResource {x:Type ListBox}}"> <Setter Property="BorderBrush11 Value=llBlack"></Setter> <Setter Property="BorderThickness11 Value= . 5"></Setter> <Setter Property="Grid. IsSharedSizeScope" Value=,,True,,x/Setter> <Setter Property=llItemsPanel"> <Setter.Value> <IternsPanelTempiate> <WrapPanel Width="{Binding (FrameworkElement.ActualWidth), RelativeSource={RelativeSource AncestorType=ScrollContentPresenter}}"> </WrapPanel> </ItemsPanelTemplate> </Setter.Value> </Setter> </Style> Данные изменения являются относительно небольшими. В более сложном представлении мог бы применяться стиль, влияющий на шаблон элемента управления, используемый для представления ListView и изменяющий его гораздо более серьезным образом. Здесь как раз начинают проявляться преимущества модели представления. За счет изменения одного единственного свойства в ListView можно сделать так, чтобы через два стиля применялась целая комбинация связанных параметров. Стиль TileView, предназначенный для ListViewItem, изменяет несколько других деталей. Он устанавливает параметры отступов и выравнивания содержимого, а также, что особенно важно, объект DataTemplate, используемый для отображения содержимого. Полный код разметки для этого стиля выглядит следующим образом: <Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:TileView}, ResourceId=TileViewItem}" TargetType="{x:Type ListViewItem}" BasedOn="{StaticResource {x:Type ListBoxItem}}"> <Setter Property="Padding11 Value=ll3"/> <Setter Property="HorizontalContentAlignment" Value=llCenter"></Setter> <Setter Property="ContentTemplate11 Value="{Binding Path=View.ItemTemplate, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListView} } }"></Setter> </Style>
668 Глава 22. Элементы управления ListView, TreeView и DataGrid Следует иметь в виду, что для обеспечения максимальной гибкости данный стиль TileView проектируется так, чтобы использовался шаблон данных, предоставляемый разработчиком. Для применения этого шаблона стиль TileView должен сначала извлекать объект TileView (с помощью свойства ListView.View), а затем уже сам шаблон из свойства TileView.ItemTemplate. Этот шаг выполняться с помощью выражения привязки, которое производит поиск по дереву элементов (с использованием режима FindAncestor для RelativeSource) до тех пор, пока не обнаружит исходный элемент ListView. На заметку! Точно такого же результата можно было бы добиться и установкой свойства ListView.ItemTemplate, а не ListViewItem.ContentTemplate. В действительности это зависит только от персональных предпочтений. Использование элемента управления ListView Создав класс представления и вспомогательные стили, можно приступать к их использованию в элементе управления ListView. Чтобы применить специальное представление, нужно просто установить для свойства ListView.View в качестве значения экземпляр объекта представления, как показано ниже: <ListView Name=lllstProducts"> <ListView.View> <TileView . . . > </ListView.View> </ListView> Однако в данном примере демонстрируется элемент управления ListView, способный переключаться между тремя представлениями. Поэтому и экземпляров объектов представления должно быть три. Проще всего с этим справиться можно так: определить каждый объект представления отдельно в коллекции Windows.Resources, а затем загружать нужное представление при выполнении пользователем выбора в элементе управления ComboBox с помощью приведенного далее кода. private void lstView_SelectionChanged(object sender, SelectionChangedEventArgs e) { ComboBoxItem selectedltem = (ComboBoxItem)IstView.Selectedltem; IstProducts.View = (ViewBase)this.FindResource(selectedltem.Content); } Первое представление является довольно простым — оно подразумевает отображение данных в виде множества столбцов с использованием уже знакомого класса GridView, о котором рассказывалось ранее. Его разметка выглядит так: <GridView x:Key=llGridView"> <GridView.Columns> <GridViewColumn Header="Name11 DisplayMemberBinding="{Binding Path=ModelName}" /> <GridViewColumn Header="Model11 DisplayMemberBinding="{Binding Path=ModelNumber}" /> <GridViewColumn Header="Price11 DisplayMemberBinding="{Binding Path=UnitCost,StringFormat={}{0:C}}" /> </GridView.Columns> </GridView> Два других представления TileView более интересны. Оба они применяют шаблон для определения того, как должна выглядеть мозаика. Представление ImageView (см. рис. 22.3) использует элемент StackPanel, который предусматривает размещение рисунка товара над названием:
Глава 22. Элементы управления ListView, TreeView и DataGrid 669 <local :TileView x:Key=llImageView"> <local:TileView.ItemTemplate> <DataTemplate> <StackPanel Width=,,150" VerticalAlignment=,,Top"> <Image Source="{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> <TextBlock TextWrapping="Wrap11 HorizontalAlignment="Center11 Text="{Binding Path=ModelName}"></TextBlock> </StackPanel> </DataTemplate> </local:TileView.ItemTemplate> </local:TileView> Представление ImageDetailView использует состоящую из двух столбцов сетку. Слева размещается небольшая версия рисунка, а справа — более детальная информация о товаре. Второй столбец помещается в группу общего размера для того, чтобы все элементы имели одинаковую ширину (соответствующую ширине самого большого текстового значения). <local: TileView х: Key=llImageDetailView"> <local:TileView.ItemTemplate> <DataTemplate> <Grid> <Grid.ColumnDefinitions> <ColumnDefmition Width=llAuto"></ColumnDef inition> <ColumnDefinition Width="Auto11 SharedSizeGroup=llCol2"></ColumnDef inition> </Grid.ColumnDefinitions> <Image Margin=" Width=,,100" Source="{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> <StackPanel Grid.Column="l11 VerticalAlignment=llCenter"> <TextBlock FontWeight=,,Bold" Text=" {Binding Path=ModelName } "></TextBlock> <TextBlock Text="{Binding Path=ModelNumber}"></TextBlock> <TextBlock Text="{Binding Path=UnitCost, StringFormat={}{0:C}}"> </TextBlock> </StackPanel> </Grid> </DataTemplate> </local:TileView.ItemTemplate> </local:TileView> Конечно, объем кода для создания элемента управления ListView с несколькими режимами просмотра оказался большим, чем ожидалось. Однако пример готов, и можно создавать дополнительные представления (на основе класса TileView), которые имеют другие шаблоны элементов и предоставляют еще больше возможных вариантов просмотра. Передача информации представлению Классы представлений можно делать более гибкими путем добавления свойств, для которых пользователь мог бы устанавливать значения при работе с тем или иным представлением. Далее эти значения могут извлекаться стилем и применяться для конфигурирования объектов Setter. Например, в настоящий момент все выбираемые в TileView элементы выделяются малопривлекательным голубым цветом. Эффект является даже еще более неприятным, поскольку текст черного цвета с деталями о товарах становится более трудным для про-
670 Глава 22. Элементы управления ListView, TreeView и DataGrid чтения. Как рассказывалось в главе 17, устранить эти проблемы можно путем использования специального шаблона элемента управления с правильными триггерами. Но вместо того, чтобы жестко кодировать набор "приятных" цветов, лучше дать возможность указывать эту деталь самому пользователю представления. В случае TileView это можно сделать добавлением ряда следующих свойств: private Brush selectedBackground = Brushes.Transparent; public Brush SelectedBackground { get { return selectedBackground; } set { selectedBackground = value; } } private Brush selectedBorderBrush = Brushes.Black; public Brush SelectedBorderBrush { get { return selectedBorderBrush; } set { selectedBorderBrush = value; } } Теперь можно установить эти детали при создании экземпляра объекта: <local:TileView x:Key="ImageDetailView" SelectedBackground=llLightSteelBlue"> </local:TileView> Последний шаг — использовать эти цвета в стиле ListViewItem. Для этого необходимо добавить объект Setter, заменяющий объект ControlTemplate. В данном случае используется простая граница со скругленными углами и элемент ContentPresenter. Когда элемент выбирается, триггер срабатывает и применяет новую границу и цвета фона. <Style x:Key="{ComponentResourceKey TypeInTargetAssembly={x:Type local:TileView}, ResourceId=TileViewItem}" TargetType="{x:Type ListViewItem}" BasedOn="{StaticResource {x:Type ListBoxItem}}"> <Setter Property=llTemplate"> <Setter.Value> <ControlTemplate TargetType="{x:Type ListBoxItem}"> <Border Name="Border11 BorderThickness=lll" CornerRadius=ll3"> <ContentPresenter /> </Border> <ControlTemplate.Triggers> <Trigger Property="IsSelected?l Value=llTrue"> <Setter TargetName="Border11 Property="BorderBrush11 Value="{Binding Path=View.SelectedBorderBrush, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListView}}}"></Setter> <Setter TargetName="Border11 Property="Background11 Value="{Binding Path=View.SelectedBackground, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type ListView}}}"></Setter> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style>
Глава 22. Элементы управления ListView, TreeView и DataGrid 671 Увидеть, как выглядит такое поведение при выборе элемента, можно на рис. 22.3 и 22.4. На рис. 22.3 видно, что фон при выделении остается прозрачным, а на рис. 22.4 — что он становится светло-голубым. На заметку! К сожалению, такой способ передачи информации представлению все равно не позволяет сделать представление по-настоящему универсальным, а причиной тому является отсутствие возможности изменять шаблоны данных на основе этой информации. Элемент управления TreeView Элемент управления TreeView является одним из главных элементов Windows и встречается довольно часто во всем, начиная от программы для просмотра файлов (проводник Windows) и заканчивая библиотекой справочной информации по .NET. Предлагаемая в WPF реализация TreeView впечатляет, поскольку обладает всей необходимой поддержкой для привязки данных. Элемент управления TreeView, по сути, представляет собой специализированный класс ItemsControl, предназначенный для обслуживания объектов TreeView It em. Но в отличие от ListViewItem, объекты TreeViewItem не являются элементами управления содержимым. Вместо этого каждый из них представляет собой отдельный класс ItemsControl, способный хранить дополнительные объекты TreeViewItem. Такая гибкость позволяет создавать многоуровневые представления данных. На заметку! Формально класс TreeViewItem наследуется от класса HeaderedltemsControl, который, в свою очередь, унаследован от ItemsControl. Класс HeaderedltemsControl имеет дополнительное свойство Header, в котором размещается содержимое (обычно текст), которое должно отображаться для данного элемента в дереве. В WPF доступны два других класса HeaderedltemsControl — Menultem и ToolBar. Ниже приведена схема простейшего элемента управления TreeView, которой объявляется полностью в разметке: <TreeView> <TreeViewItem Header=llFruit"> <TreeViewItem Header="Orange"/> <TreeViewItem Header="Banana"/> <TreeViewItem Header="Grapefruit"/> </TreeViewItem> <TreeViewItem Header=llVegetables"> <TreeViewItem Header=llAubergine"/> <TreeViewItem Header="Squash"/> <TreeViewItem Header="Spinach"/> </TreeViewItem> </TreeView> Конструировать TreeView из объектов TreeViewItem вовсе не обязательно. На самом деле в TreeView разрешено добавлять практически любые элементы, включая кнопки, панели и изображения. Однако когда требуется отобразить нетекстовое содержимое, лучше все-таки использовать класс-оболочку TreeViewItem и предоставлять это содержимое через свойство TreeViewItem.Header. Это равнозначно добавлению элементов, отличных от TreeViewItem, непосредственно в TreeView, но упрощает управление специфическими деталями TreeView вроде выбора и разворачивания узлов. При желании отобразить не-UIElement объект его можно отформатировать за счет использования шаблонов данных со свойством HeaderTemplate или HeaderTemplateSelector.
672 Глава 22. Элементы управления ListView, TreeView и DataGrid ■ BoundTreeView . Привязка данных к элементу управления TreeView Обычно элемент управления TreeView не заполняется фиксированной информацией, жестко закодированной в коде разметки. Вместо этого все необходимые объекты TreeViewItem либо создаются программно, либо отображаются в виде коллекции с помощью привязки данных. В заполнении элемента управления TreeView данными нет ничего сложного: как и в случае любого элемента ItemsControl, для этого требуется просто установить свойство ItemsSource. Однако при таком подходе данными заполняется только первый уровень TreeView. Более интересный подход предусматривает применение в TreeView иерархических данных, имеющих нечто вроде вложенной структуры. Например, рассмотрим элемент управления TreeView, показанный на рис. 22.5. Первый уровень состоит из объектов Category, в то время как второй уровень отображает подпадающие под каждую из этих категорий объекты Product. Элемент управления TreeView позволяет легко отображать иерархические данные независимо от того, используются ли созданные вручную классы или ADO.NET-объекты DataSet. Главное — указать правильные шаблоны данных, разъясняющие отношения между различными уровнями данных. Например, предположим, что необходимо воспроизвести пример, показанный на рис. 22.5. Класс Product, необходимый для представления товаров, уже демонстрировался. Но для воссоздания этого примера нужен также и класс Category. Этот класс Category, как и Product, реализует интерфейс INotif yPropertyChanged для обеспечения уведомлений об изменениях. Единственной новой деталью является то, что он также предоставляет коллекцию объектов Product через свое свойство Product. INotifyPropertyChanged Communications * Decept»on Counterfeit Creation Wallet Cloaking Device Indentity Confus»on Device Correction Fluid Hologram Cufflinks Travel Protection * Munitions Multi-Purpose Rubber Band The Incredible Versatile Paperclip Mighty Mighty Pen Tools General Рис. 22.5. Элемент управления TreeView с уровнем категорий и уровнем товаров public class Category { private string categoryName; public string CategoryName { get { return categoryName; } set { categoryName = value; OnPropertyChanged (new PropertyChangedEventArgs("CategoryName")); } } private ObservableCollection<Product> products ; public ObservableCollection<Product> Products { get { return products; } set { products = value; OnPropertyChanged(new PropertyChangedEventArgs("Products")); } } public event PropertyChangedEventHandler PropertyChanged; public void OnPropertyChanged(PropertyChangedEventArgs e) { if (PropertyChanged '= null)
Глава 22. Элементы управления ListView, TreeView и DataGrid 673 PropertyChanged(this, e); } public Category(string categoryName, ObservableCollection<Product> products) { CategoryName = categoryName; • Products = products; } } Совет. Именно создание коллекции, предоставляющей другую коллекцию через свойство, как раз и является ключом к навигации по отношениям "родительский-дочерний" с помощью привязки данных в WPF. Например, вы можете привязать коллекцию объектов Category к одному списковому элементу управления и затем привязать другой списковый элемент управления к свойству Products выбранного в текущий момент объекта Category для отображения связанных объектов Product. Чтобы использовать этот класс Category, также необходимо внести изменения и в код доступа к данным, который демонстрировался в главе 19. Далее информация о товарах и категориях будет извлекаться из базы данных. В настоящем примере окно вызывает метод StoreDB.GetCategoriesAndProducts () для извлечения коллекции объектов Category, у каждого из которых имеется вложенная коллекция объектов Product. Затем эта коллекция Category привязывается к дереву для того, чтобы она отображалась на первом уровне: treeCategories.ItemsSource = App.StoreDB.GetCategoriesAndProducts(); Для отображения категорий необходимо предоставить шаблон TreeView. ItemTemplate, способный обрабатывать привязанные объекты. В данном примере требуется отобразить свойство CategoryName каждого объекта Category. Ниже показан шаблон данных, который делает это: <TreeView Name="treeCategories11 Margin=ll5"> <TreeView.ItemTemplate> hierarchical Da taTemplate> <TextBlock Text="{Binding Path=CategoryName}" /> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> Единственная необычная деталь здесь — то, что TreeView.ItemTemplate устанавливается не с помощью объекта DataTemlate, а с помощью объекта HierarchicalDataTemplate. У объекта HierarchicalDataTemplate имеется одно дополнительное преимущество: он может упаковывать второй шаблон, а затем, соответственно, извлекать коллекцию элементов из первого уровня и передавать их шаблону второго уровня. Все, что требуется — это установить свойство ItemsSource, чтобы оно указывало на свойство, имеющее дочерние элементы, а свойство ItemTemplate — чтобы оно указывало, как должен форматироваться каждый объект. Ниже показана переделенная версия шаблона данных: <TreeView Name="treeCategories11 Margin=ll5"> <TreeView.ItemTemplate> <HierarchicalDataTemplate ItemsSource="{Binding Path=Products}"> <TextBlock Text="{Binding Path=CategoryName}" /> <HierarchicalDataTemplate.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding Path=ModelName}" /> </DataTemplate> </HierarchicalDataTemplate.ItemTemplate>
674 Глава 22. Элементы управления ListView, TreeView и DataGrid </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> По сути, теперь имеется два шаблона, по одному для каждого уровня в дереве. Во втором шаблоне в качестве источника данных используется выбранный элемент из первого шаблона. Хотя этот код разметки работает довольно хорошо, шаблоны данных принято разделять и применять к объектам данных не по положению, а по типу данных. Понять, что это означает, поможет рассмотрение переделанной версии разметки для привязываемого к данным элемента управления TreeView, которая показана ниже. <Window х : Class=llDataBinding.BoundTreeView11 . . . xmlns:local="clr-namespace:DataBinding"> <Window.Resources> <HierarchicalDataTemplate DataType="{x:Type local:Category}" ItemsSource="{Binding Path=Products}"> <TextBlock Text="{Binding Path=CategoryName}"/> </HierarchicalDataTemplate> <HierarchicalDataTemplate DataType="{x:Type local:Product}"> <TextBlock Text="{Binding Path=ModelName}" /> </HierarchicalDataTemplate> </Window.Resources> <Grid> <TreeView Name="treeCategories11 Margin=ll5"> </TreeView> </Grid> </Window> В этом примере шаблон ItemTemplate в TreeView явным образом не устанавливается. Вместо этого подходящий шаблон ItemTemplate выбирается на основе типа данных привязываемого объекта. Аналогично в шаблоне Category не указывается шаблон ItemTemplate, который должен использоваться для обработки коллекции Products. Он тоже выбирается автоматически на основе типа данных. Теперь данный элемент управления TreeView способен показывать как список товаров, так и список категорий, содержащих группы товаров. В текущем примере эти изменения ничего нового не добавляют. Такой подход упрощает разметку и облегчает повторное использование шаблонов, но не влияет на способ отображения данных. Однако при наличии глубоко вложенных деревьев с более свободными структурами такое проектное решение оказывается просто бесценным. Например, предположим, что создается дерево объектов Manager, и каждый из этих объектов Manager имеет коллекцию Employees. В этой коллекции могут содержаться как обычные объекты Employee, так и другие объекты Manager, в свою очередь тоже содержащие дополнительные объекты Employee. В случае применения системы с основанными на типах шаблонами, каждому из этих объектов будет автоматически предоставляться тот шаблон, который подходит ему по его типу данных. Привязка элемента управления TreeView к объекту DataSet Элемент управления TreeView также можно использовать для отображения многоуровневого объекта DataSet, т.е. объекта DataSet с отношениями, связывающими один объект DataTable с другим. Например, ниже показан код процедуры, которая создает объект DataSet, заполняет его таблицей товаров и отдельной таблицей категорий и затем связывает эти две таблицы вместе с помощью объекта DataRelation.
Глава 22. Элементы управления ListView, TreeView и DataGrid 675 public DataSet GetCategoriesAndProductsDataSet () { SqlConnection con = new SqlConnection(connectionString); SqlCommand cmd = new SqlCommand("GetProducts", con) ; cmd.CoramandType = CommandType.StoredProcedure; SqlDataAdapter adapter = new SqlDataAdapter(cmd); DataSet ds = new DataSet(); adapter.Fill(ds, "Products"); cmd.CommandText = "GetCategories"; adapter.Fill(ds, "Categories"); // Установка отношения между этими таблицами. DataRelation relCategoryProduct = new DataRelation("CategoryProduct", ds.Tables["Categories"].Columns["CategorylD"], ds.Tables["Products"].Columns["CategorylD"]); ds.Relations.Add(relCategoryProduct); return ds; } Чтобы использовать этот код в TreeView, сначала нужно выполнить привязку к объекту DataTable, который должен применяться для первого уровня: DataSet ds = App.StoreDB.GetCategoriesAndProductsDataSet(); treeCategories.ItemsSource = ds.Tables["Categories"].DefaultView; Но как извлечь связанные строки? Ведь вызывать метод вроде GetChildRowsO из XAML-разметки нельзя. К счастью, в системе привязки данных WPF имеется встроенная поддержка для подобного сценария. Все, что требуется сделать — это указать в качестве ItemsSource для второго уровня имя объекта DataRelation. В данном примере объект DataRelation создавался с именем CategoryProduct, а, значит, разметка должна выглядеть следующим образом: <TreeView Name="treeCategories" Margin="> <TreeView.ItemTemplate> <HierarchicalDataTemplate ItemsSource=" {Binding CategoryProduct} "> <TextBlock Text="{Binding CategoryName}" Padding=" /> <HierarchicalDataTemplate.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding ModelName}" Padding=" /> </DataTemplate> </HierarchicalDataTemplate.ItemTemplate> </HierarchicalDataTemplate> </TreeView.ItemTemplate> </TreeView> Теперь этот пример будет работать точно так же, как и предыдущий, в котором использовались специальные объекты Product и Category. Оперативное создание узлов Элементы управления TreeView часто применяются для размещения больших объемов данных. Объясняется это тем, что TreeView обладает сворачиваемо-разворачи- ваемой структурой. Даже в случае прокрутки TreeView пользователем сверху донизу, видимой не обязательно будет вся доступная в нем информация. Информация, не являющаяся видимой, может вообще пропускаться в элементе управления TreeView, сокращая накладные расходы (и время, необходимое для его заполнения). Даже еще лучше то, что при открытии элемента TreeViewItem инициируется событие Expanded, а при закрытии — событие Collapsed. Этот момент очень удобно использовать для добав-
676 Глава 22. Элементы управления ListView, TreeView и DataGrid ления недостающих узлов или удаления тех, что уже больше не нужны. Такой подход называется оперативным (just-in-time) созданием узлов. Оперативное создание узлов может применяться в приложениях, в которых данные извлекаются из базы, но классическим примером, несомненно, является приложение, предназначенное для просмотра каталогов. В настоящее время большинство жестких дисков имеют огромную емкость и постоянно разрастающуюся структуру каталогов. Хотя элемент управления TreeView и можно заполнить структурой каталогов жесткого диска, этот процесс является удручающе медленным. Гораздо лучше, когда сначала отображается частично свернутое представление, и пользователю предлагается самостоятельно добираться до конкретных каталогов (рис. 22.6). При разворачивании каждого узла в дерево добавляются соответствующие подкаталоги, и протекает этот процесс практически мгновенно. : ■ DirectoryTreeV»ew * : SRecycleBin Boot Documents end Settings inetpub MSOCache л Program Files Adobe Bonuspfint Bradbury CE Remote Tools Common Frtes : Core» DrvX Id Google * Common Inbtt InstaliSh»etd Installation Information Internet Explorer IflVf Рис. 22.6. Разворачивание дерева каталогов В применении элемента управления TreeView с возможностью оперативного создания узлов для отображения папок на жестком диске нет ничего нового. (На самом деле, эта методика довольно подробно демонстрировалась в книге Pro .NET 2.0 Windows Forms and Custom Controls in C#, Apress, 2005 г.). Однако маршрутизация событий делает решение, предлагаемое в WPF, чуть более элегантным. Первый шаг заключается в добавлении в TreeView списка дисков при первой загрузке окна. Изначально узел для каждого диска представляется в свернутом виде. Буква диска отображается в заголовке, а объект Drivelnfo хранится в свойстве TreeViewItem. Tag для упрощения поиска вложенных каталогов в дальнейшем без воссоздания этого объекта. (Это увеличивает накладные расходы приложения, связанные с памятью, но при этом сокращает количество проверок безопасности доступа к файлам. Общий эффект является незначительным, но зато немного улучшает производительность и упрощает код.) Ниже приведен код, в котором TreeView заполняется списком дисков с помощью класса System. 10.Drivelnfo: foreach (Drivelnfo drive in Drivelnfo.GetDrives ()) { TreeViewItem item = new TreeViewItem () ; item.Tag = drive; item.Header = drive.ToString (); item.Items.Add("*"); treeFileSystem.Items.Add(item); }
Глава 22. Элементы управления ListView, TreeView и DataGrid 677 Данный код добавляет под узлом каждого диска указатель места заполнения (строку со звездочкой). Этот указатель не отображается, поскольку узел сначала находится в свернутом состоянии. При разворачивании узла этот указатель можно удалить и добавить на его месте список подкаталогов. На заметку! Указатель места заполнения представляет собой удобный инструмент, который можно использовать для определения того, развернул ли уже пользователь данную папку для просмотра ее содержимого. Однако его главной обязанностью является отображение рядом с каждым элементом специального значка, указывающего на возможность разворачивания. Без него пользователь просто не сможет разворачивать каталог и отображать содержащиеся в нем подпапки. Едли в каталоге нет никаких подпапок, этот значок будет просто исчезать при попытке пользователя развернуть его, что похоже на поведение проводника Windows при просмотре сетевых папок. Для реализации оперативного создания узлов необходимо обрабатывать событие TreeViewItem.Expanded. Поскольку это событие поддерживает пузырьковое распространение, обработчик событий можно присоединять прямо к элементу TreeView, чтобы он обрабатывал событие Expanded любого находящегося внутри него элемента TreeViewItem: <TreeView Name="treeFileSystem" TreeViewItem.Expanded="item_Expanded"> </TreeView> Ниже показан код, который обрабатывает это событие и заполняет следующий недостающий уровень дерева с помощью класса System. 10.Directorylnfo: private void item_Expanded(object sender, RoutedEventArgs e) { TreeViewItem item = (TreeViewItem)e.OriginalSource; item.Items.Clear() ; Directorylnfo dir; if (item.Tag is Drivelnfo) { Drivelnfo drive = (Drivelnfo)item.Tag; dir = drive.RootDirectory; } else { dir = (Directorylnfo)item.Tag; } try { foreach (Directorylnfo subDir in dir.GetDirectories ()) { TreeViewItem newltem = new TreeViewItem(); newItem.Tag = subDir; newltem.Header = subDir .ToStnng () ; newltem.Items.Add("*"); item.Items.Add(newltem); } } catch { // В этом коде может выдаваться исключение в случае отсутствия // необходимых разрешений для работы с файлом или каталогом. // Это исключение можно перехватывать и затем игнорировать. } }
678 Глава 22. Элементы управления ListView, TreeView и DataGrid В настоящее время приведенный код осуществляет обновление при каждом разворачивании элемента. При желании можно сделать так, чтобы обновление выполнялось только при первом разворачивании элемента и обнаружении указателя места заполнения. Это сократит объем работы, который должно будет выполнять приложение, но при этом также увеличит вероятность отображения устаревшей информации. В качестве альтернативы можно сделать так, чтобы обновление выполнялось при каждом выборе элемента, за счет обработки события Viewltem.Selected, или применить компонент вроде System.IO.FileSystemWatcher для ожидания уведомлений от операционной системы при добавлении, удалении или переименовании папки. Компонент FileSystemWatcher является единственным способом гарантировать, что обновление дерева каталогов будет осуществляться сразу же при появлении изменения, но чреват наибольшими накладными расходами. Создание усовершенствованных элементов управления TreeView Комбинируя с TreeView мощные возможности шаблонов элементов управления (о которых рассказывалось в главе 17), можно добиться много чего. Например, можно создавать элементы управления, радикально отличающиеся как по внешнему виду, так и по поведению, просто заменяя шаблоны для элементов управления TreeView и TreeViewItem. Внесение таких корректировок требует более глубокого изучения шаблонов. Начать можно с каких- нибудь интересных примеров. Например, в состав Visual Studio входит образец многостолбцового элемента управления TreeView, сочетающего в себе дерево и сетку. Просмотреть его можно, отыскав в справочной системе Visual Studio раздел TreeListView sample [WPF] (Пример элемента управления TreeListView [WPF]). Еще одним интригующим примером является предложенный Джошем Смитом (Josh Smith) эксперимент по компоновке, преобразующий TreeView в нечто больше похожее на организационную схему. Просмотреть полный код этого образца можно по следующему адресу: http://www.codeproject.com/KB/WPF/CustomTreeViewLayout.aspx. Элемент управления DataGrid DataGrid представляет собой элемент управления отображением данных, который извлекает информацию из коллекции объектов и визуализирует ее в сетке со строками и ячейками. Каждая строка соответствует отдельному объекту, а каждый столбец — свойству в этом объекте. Элемент управления DataGrid привносит столь необходимое разнообразие в возможности для работы с данными в WPF. Его модель столбцов предоставляет замечательную гибкость в плане форматирования. Его модель выбора позволяет разрешать пользователям выбирать одну строку, множество строк или некоторую комбинацию ячеек. Его поддержка редактирования является достаточно мощной для того, чтобы DataGrid можно было использовать в качестве универсального редактора для простых и сложных данных. Для создания элемента управления DataGrid на скорую руку можно пользоваться функцией автоматической генерации столбцов. Для этого необходимо установить свойство AutoGenerateColumns в true (значение по умолчанию): <DataGrid x:Name="gridProducts" AutoGenerateColumns="True"> </DataGrid> Затем DataGrid можно заполнить данными установкой свойства ItemsSource: gridProducts.DataSource = products; На рис. 22.7 показан элемент управления DataGrid, в котором используется функция автоматической генерации столбцов и коллекция объектов Product. В ходе авто-
Глава 22. Элементы управления ListView, TreeView и DataGrid 679 матической генерации столбцов в DataGrid применяется рефлексия для нахождения каждого общедоступного свойства в привязанном объекте данных. Для каждого обнаруженного свойства создается отдельный столбец. ■ DataGfidTest 1 ModelNumber N RU007 j STKY1 li P38 |: NOZ119 1] PT109 1 ! REDl : LK4TLNT '-Tw5s: | NE1RPR j; BRTLGT1 J: incpprclp |: DNTRPR i\ TGFDA ' ; WOWPtN j; ICNCU 1 UCARCKT Ц\ 0NT6CGHT ModctName Ram Racer 2000 Edible Tape Escape Vebcle (Air) Extracting Tool Escape Vehicle (Water) Communications Device Persuasive Релей Mutt-Purpose Rubber Band Universal Repair System Effective Flashlight The Incredible Versatile Paperclip Toaster Boat Multi- Purpose ToweJette Mighty Mighty Pen Perfect-Vision Glasses Pocket Protector Rocket Pack Counterfeit Creation Wallet ч 1499.9900 3.9900 29900 199.0C0C 1299 ИОВ 49.9900 1.9900 19900 49900 99900 1.4900 19999.9800 12.9900 129.9900 129990: 1.9900 999.9900 1 !тЩ*ЦШГ| Description Looks like an ordinary bumbershoot, but don't be * 11 The latest m personal survival gear, the STKY1 looi I In a jam. need a quick escape? Just whip out a she j High-tech miniaturized extracting tool Excellent H^\\ Camouflaged as styfcsh wmg tips, these shoes' ge. I Subversrvely stay m touch with this miniaturized «i ] Persuade anyone to see your point of view! Capul-J 1 One of our most popular items! A band of rubbei [ Few people appreciate the awesome repair posstfc ( The most powerful darkness-removal device offer- This 0. 01 oz piece of metal is the most versatile rt Turn breakfast into a high-speed chase! In additio Don't leave home without your monogrammed tc j Some spies claim this item is more powerful than | Avoid painful and potentially devastating laser eyi | Any debonair spy knows that this accoutrement is Don't be cauQht oenmiess in Praque without this 1 " ' Рис. 22.7. Элемент управления DataGrid с автоматически сгенерированными столбцами При отображении нестроковых свойств в элементе управления DataGrid вызывается метод ToStringO, который прекрасно работает для чисел, дат и других простых типов данных, но не столь же хорошо для более сложных объектов данных. (В данном случае имеет смысл определить столбцы явно, поскольку такой подход предоставляет возможность привязываться к подсвойству, использовать конвертер значений и применять шаблон для получения корректно отображаемого содержимого.) В табл. 22.1 перечислены некоторые из свойств, которые можно использовать для настройки внешнего вида элемента управления DataGrid. В следующих разделах будет показано, как обеспечить более точное форматирование с помощью стилей и шаблонов и как настраивать в DataGrid механизм сортировки и выбора. Таблица 22.1. Свойства для настройки отображения DataGrid Имя Описание RowBackground и Кисть, которая используется для закрашивания фона AlternatingRowBackground позади каждой строки (RowBackground), и фон чередующихся строк (AlternatingRowBackground). По умолчанию в DataGrid строкам с нечетными номерами назначается белый фон, а строкам с четными номерами — светло-серый фон ColumnHeaderHeight Высота (в независимых от устройства единицах) строки, отображающей заголовки столбцов в верхней части DataGrid RowHeaderWidth Ширина (в независимых от устройства единицах) столбца, содержащего заголовки строк. Этот столбец находится в самом левом углу сетки, где данные не отображаются. Он указывает на строку, которая является выбранной в текущий момент (посредством стрелки), и показывает, находится ли она в режиме редактирования (с помощью стрелки внутри круга)
680 Глава 22. Элементы управления ListView, TreeView и DataGrid Окончание табл. 22.1 Имя Описание ColumnWidth RowHeight GridLinesVisibility VerticalGridLinesBrush HorizontalGridLinesBrush HeadersVisibilitу HorizontalScrollBarVisibility и VerticalScrollBarVisibility Режим изменения размера, который применяется для установки ширины по умолчанию для каждого столбца в виде объекта DataGridLength. (Опции изменения размера рассматриваются в следующем разделе.) Высота каждой строки. Этот параметр полезен в случае, если в DataGrid планируется отображать множество строк текста или какое-то другое содержимое (такое как рисунки). В отличие от столбцов, строки не могут изменяться в размере пользователем Значение из перечисления DataGridGridlines, которое указывает, какие линии должны отображаться в сетке (Horizontal, Vertical, None, All) Кисть, которая используется для закрашивания линий сетки, отображаемых между столбцами Кисть, которая используется для закрашивания линий сетки, отображаемых между строками Значение из перечисления DataGridHeaders, которое указывает, какие заголовки должны отображаться (Column, Row, All, None) Значение из перечисления ScrollBarVisibility, которое указывает, должна ли полоса прокрутки отображаться только при необходимости (Auto), всегда (Visible) или никогда (Hidden). По умолчанию оба свойства установлены в Auto Изменение размера и порядка следования столбцов При отображении автоматически сгенерированных столбцов DataGrid пытается установить ширину каждого столбца в соответствии со значением свойства DataGrid. ColumnWidth. Для установки свойства ColumnWidth передается объект DataGridLength. В этом объекте можно указать точный размер (в независимых от устройства единицах) или задать специальный режим установки размера, предусматривающий самостоятельное выполнение элементом управления DataGrid некоторой работы. Точный размер устанавливается в свойстве ColumnWidth (в XAML-разметке) или передается в виде одного из аргументов конструктора при создании объекта DataGridLength (в коде): grid.ColumnWidth = new DataGridLengthA50); Специальные режимы задания размеров более интересны. Доступ к ним осуществляется через статические свойства класса DataGridLength. Ниже приведен пример применения выбираемого по умолчанию режима DataGridLength.SizeToHeader, который означает, что столбцы должны делаться настолько широкими, чтобы в них умещался текст заголовков: grid.ColumnWidth = DataGridLength.SizeToHeader; Другим популярным вариантом является режим DataGridLength.SizeToCells, при котором каждый столбец делается широким настолько, чтобы в нем умещалось самое широкое значение из тех, которые видны в текущий момент.
Глава 22. Элементы управления ListView, TreeView и DataGrid 681 Элемент управления DataGrid старается следовать интеллектуальному подходу в отношении задания размеров, когда пользователь начинает прокручивать данные. При появлении строки, содержащей более длинные данные, соответствующие столбцы расширяются так, чтобы эти данные могли в них уместиться. Такое автоматическое изменение размера работает лишь в одном направлении, т.е. при переходе к другим данным столбцы не сжимаются. Еще одним доступным специальным режимом является DataGridLength.Auto. Он работает точно так же, как DataGridLength.SizeToCells, но предусматривает расширение столбца таким образом, чтобы в нем умещалось самое большое отображаемое значение или текст заголовка столбца, в зависимости от того, что из них шире. DataGrid также позволяет использовать систему пропорционального изменения размеров, которая похожа на систему установки размеров с помощью звездочки, применяемую в панели компоновки Grid. Символ звездочки (*) представляет пропорциональное изменение размеров, а следующее за ним число — коэффициент, который должен использоваться для разделения доступного пространства (например, чтобы выделить первому столбцу в два раза больше пространства, чем второму, необходимо указать 2* и *). Для настройки такого соотношения или указания для столбцов разных значений ширины или разных режимов определения размеров необходимо явно устанавливать свойство Width для каждого объекта столбца. Явное определение и конфигурирование столбцов DataGrid рассматривается в следующем разделе. Автоматический выбор размеров для столбцов DataGrid — интересный и зачастую удобный вариант, однако он не всегда оказывается тем, что нужно. Вернемся к примеру DataGrid, показанному на рис. 22.7. В нем имеется столбец Description, отображающий длинную строку текста. Первоначально этот столбец Description делается очень широким, чтобы он мог уместить в себе такие данные, из-за чего он вытесняет все остальные столбцы. (На рис. 22.7 ширина столбца Description была вручную изменена пользователем до более разумного размера. Ширины всех остальных столбцов оставлены в исходном виде.) После изменения размеров столбца автоматическое увеличение его ширины во время просмотра не происходит. Совет. Естественно, заставлять пользователей возиться с нелепо широкими столбцами — не очень хорошая идея. По этой причине для каждого столбца может понадобиться указать вручную свою ширину или другой режим задания размеров. Для этого нужно явно определить столбцы и установить для них свойство DataGridColumn.Width. Значение, указанное в этом свойстве для столбца, переопределяет значение DataGrid.ColumnWidth, используемое по умолчанию. Явное определение столбцов рассматривается в следующем разделе. Обычно пользователи могут изменять размеры столбцов, перетаскивая их края в направлении нужного размера. Чтобы отключить эту возможность, необходимо установить свойство CanUserResizeColumns в false. Чтобы запретить изменять размеры определенного столбца, понадобится установить его свойство CanUserResize в false. Установка свойства MinWidth этого столбца позволяет задать минимально допустимую ширину для столбца. Элемент управления DataGrid также позволяет пользователям настраивать отображение столбцов. Можно не только изменять размеры столбцов, но и перетаскивать их из одной позиции в другую. Чтобы отключить возможность изменения порядка следования столбцов, необходимо установить в false свойство CanUserReorderColumns элемента DataGrid или свойство CanUserReorder конкретного столбца.
682 Глава 22. Элементы управления ListView, TreeView и DataGrid Определение столбцов За счет применения автоматически сгенерированных столбцов можно быстро получить элемент управления DataGrid, отображающий все необходимые данных. Однако при таком подходе теряется значительная степень контроля. Например, невозможно управлять тем, как столбцы должны упорядочиваться, насколько широкими они должны быть, как должны форматироваться значения внутри них и какой текст заголовка должен размещаться сверху. Гораздо более мощный подход предусматривает отключение функции автоматической генерации столбцов за счет установки свойства AutoGenerateColumns в false. Это позволит явно определить все необходимые столбцы и указать для них любые желаемые параметры и порядок отображения. Потребуется всего лишь заполнить коллекцию DataGrid.Columns правильными объектами столбцов. В настоящее время DataGrid поддерживает множество типов столбцов, которые представлены разными классами, унаследованными от одного общего класса DataGridColumn. • DataGridTextColumn. Этот столбец является стандартным выбором для большинства типов данных. Его значение преобразуется в текст и отображается в элементе управления TextBox. При редактировании строки этот элемент TexBlock заменяется стандартным текстовыми полем. • DataGridCheckBoxColumn. Этот столбец отображает флажок и чаще всего автоматически используется для булевских (или булевских, допускающих null) значений. Как правило, флажок доступен только для чтения, но при редактировании строки он превращается в обычный флажок. • DataGridHyperlinkColumn. Этот столбец отображает ссылку, на которой можно щелкать. В случае применения в сочетании навигационными контейнерами WPF, таким как Frame или NavigationWindow, позволяет переходить по другому URI (обычно на внешний веб-сайт). • DataGridComboBox. Этот столбец первоначально выглядит как DataGridTextColumn, но в режиме редактирования превращается в раскрывающийся список CombоВох. Он прекрасно подходит, когда необходимо ограничить возможные варианты редактирования небольшим набором допустимых значений. • DataGridTemplateColumn. Этот столбец пока что является самым мощным вариантом. Он позволяет определять шаблон данных для отображения значений столбцов, с той же степенью гибкости и возможностями, которые имеются при использовании шаблонов в списковом элементе управления. Его можно применять, например, для вывода данных изображения или для использования специализированного элемента WPF (наподобие раскрывающегося списка с допустимыми значениями или элемента DatePicker для значений даты). Ниже показан пример переделанного элемента управления DataGrid, который создает два столбца с названиями товаров и ценами. В нем также применяются более понятные заголовки для столбцов, а столбец Product расширен так, чтобы умещать предназначенные для него данные: <DataGrid x:Name="gridProducts" Margin=" AutoGenerateColumns="False"> <DataGnd.Columns> <DataGndTextColumn Header="Product" Width=75" Binding=" {Binding Path=ModelName } "></DataGndTextColumn> <DataGndTextColumn Header="Price" Binding=" {Binding Path=UmtCost} "></DataGndTextColumn> </DataGnd.Columns> </DataGrid>
Глава 22. Элементы управления ListView, TreeView и DataGrid 683 При определении столбца практически всегда указываются три детали — текст заголовка, который должен появляться вверху столбца, ширина столбца и привязка, с помощью которой извлекаются данные. Для установки свойства DataGridColumn.Header обычно используется простая строка, но это не обязательно. Заголовок столбца действует как элемент управления содержимым, и потому для свойства Header может быть указан любой элемент, в том числе изображение или панель компоновки с комбинацией элементов. Свойство DataGridColumn.Width поддерживает жестко закодированные значения и несколько режимов автоматической настройки размеров, в точности как и свойство DataGrid.ColumnWidth, рассмотренное в предыдущем разделе. Единственное отличие состоит в том, что свойство DataGridColumn.Width применяется к единственному столбцу, а свойство DataGrid.ColumnWidth задает значение по умолчанию для всей сетки. Значение свойства DataGridColumn.Width переопределяет значение свойства DataGrid.ColumnWidth. Выражение привязки является самой важной деталью, поскольку отвечает за предоставление корректной информации. Оно устанавливается в свойстве DataGridColumn. Binding. Этот подход отличается от применяемого в простых списковых элементах управления вроде ListBox и ComboBox. В них для данной цели служит свойство DisplayMemberPath, а не Binding. Подход со свойством Binding является более гибким, поскольку позволяет применять форматирование строк и конвертеры значений без перехода на использование полнофункционального шаблонного столбца. <DataGndTextColumn Header="Price" Binding= "{Binding Path=UmtCost, StnngFormat={ } { 0 :C} } "> </DataGndTextColumn> Совет. Столбцы можно динамически отображать и скрывать, модифицируя свойство Visibility соответствующего объекта столбца. Вдобавок, столбцы можно также перемещать в любой момент за счет изменения их значений Displaylndex. Класс DataGridCheckBoxColumn Класс Product не включает никаких булевских свойств, но если бы имел, то для них подошел бы класс DataGridCheckBoxColumn. Как и в DataGridTextColumn, в классе DataGridCheckBoxColumn для извлечения данных применяется свойство Binding. Здесь в роли данных выступает значение true или false, которое применяется для установки свойства Is Che eked размещающегося внутри элемента CheckBox. Класс DataGridCheckBoxColumn имеет свойство по имени Content, которое позволяет отобразить рядом с флажком дополнительное содержимое, и свойство IsThreeState, которое позволяет указать, должен ли флажок поддерживать неопределенное состояние вдобавок к отмеченному и неотмеченному состоянию. Если DataGridCheckBoxColumn используется для отображения информации из булевского значения, допускающего null, свойство IsThreeState имеет смысл установить в true. Это даст возможность щелчком переводить флажок в неопределенное состояние (при котором он отображается в слегка затененном виде), устанавливая привязанное значение в null. Класс DataGridHyperlinkColvmn Класс DataGridHyperlinkColumn позволяет отображать текстовые значения, каждое из которых содержит один URL-адрес. Например, если в классе Product есть свойство по имени ProductLink со значением вроде http://myproducts.com/info?productID=10432, можно отобразить эту информацию в DataGridHyperlinkColumn. Каждое привязанное
684 Глава 22. Элементы управления ListView, TreeView и DataGrid значение тогда будет отображаться с использованием элемента Hyperlink следующим образом: <Hyperlink NavigateUri="http: //myproducts . com/infot?productID=10432" >http://myproducts.com/info?productID=10432</Hyperlink> Пользователь получает возможность щелкать на гиперссылке и переходить на соответствующую страницу, при этом никакого специального кода писать не понадобилось. Однако здесь имеется одно существенное ограничение: такой прием с автоматической навигацией работает только в случае размещения объекта DataGrid в контейнере, который поддерживает события навигации, как, например, Frame или NavigationWindow. Оба эти элемента управления, а также Hyperlink, подробно рассматриваются в главе 24. Если нужен более универсальный способ для достижения похожего эффекта, следует воспользоваться классом DataGridTemplateColumn. С его помощью можно отобразить подчеркнутый текст с возможностью щелчка на нем (в действительности можно даже применить элемент управления Hyperlink) и обрабатывать эти события щелчка в коде. Обычно в DataGridHyperlinkColumn применяется один и тот же фрагмент информации для навигации и для отображения. Однако при желании их можно указать по отдельности. Для этого в свойстве Binding понадобится установить URL-адрес, а необязательное свойство ContentBinding использовать для извлечения отображаемого текста из какого-то другого свойства привязанного объекта данных. Класс Da taGridComboBoxColnmn Класс DataGridComboBoxColumn позволяет отображать обычный текст, но предоставляет более совершенное поведение в режиме редактирования — пользователь получает возможность выбирать желаемый вариант из списка доступных опций в элементе управления ComboBox. (Фактически, выбор ограничивается списком значений, поскольку непосредственный ввод текста в ComboBox не разрешен.) На рис. 22.8 показан пример выбора пользователем категории товара в DataGridComboBoxColumn. г — - ■ DataGndEditing | Product Rain Racer 2000 Edible Tape j Extracting Tool J Escape Vehicle (Water) Communications Device 1 Persuasive Pencil '] J Muiti-Purpose Rubber Band Universal Repair System 1! Effective Flashlight I j The Incredible Versable Paperch 11 ' liiiiMiiiBiiimiiiiiniiMMi r- Pnce $1.499.99 $3.99 $19900 $1299.99 $49.99 $1.99 $1.99 $4.99 $9.99 $149 Model Number RU007 STKV1 NOZ119 PT109 RED1 UC4TLNT NTMBS1 NE1RPR BRTLGT1 INCPPRCLP Category Travel General --a.e Communic Deception EZS3H Munitions Tools General Munitions Tools Tools Munitions Date Add. ] l - I ibons Щ Г т1| . [J Рис. 22.8. Выбор из списка допустимых значений При использовании DataGridComboBoxColumn понадобится решить, каким образом заполнять элемент ComboBox в режиме редактирования. Самым простым подходом является его заполнение вручную, в разметке. Например, вот как добавить в ComboBox список строк:
Глава 22. Элементы управления ListView, TreeView и DataGrid 685 <DataGridComboBoxColumn Header=MCategory" SelectedItemBinding="{Binding Path=CategoryName}"> <DataGridComboBoxColumn.ItemsSource> <col:ArrayList> <sys:String>General</sys:String> <sys:String>Communications</sys:String> <sys:String>Deception</sys:String> <sys:String>Munitions</sys:String> <sys:String>Protection</sys:String> <sys:String>Tools</sys:String> <sys:String>Travel</sys:String> </col:ArrayList> </DataGridComboBoxColumn.ItemsSource> </DataGridComboBoxColumn> Чтобы этот код разметки мог работать, потребуется отобразить префиксы sys и col на соответствующие пространства имен .NET: <Window ... xmlns:соl="clr-namespace:System.Collections;assembly=mscorlib" xmlns:sys="clr-namespace:System;assembly=mscorlib"> Это проектное решение вполне нормально работает, но наилучшим не является, поскольку детали данных в нем скрыты глубоко внутри разметки пользовательского интерфейса. К счастью, на выбор доступно несколько других вариантов. • Извлечение коллекции данных из ресурса. Эта коллекция может определяться в разметке (как в предыдущем примере) либо генерироваться в коде (как будет показано далее). • Извлечение коллекции ItemsSource из статического метода с использованием расширения разметки Static. Однако цельное проектное решение предусматривает вызов этого метода в классе окна, а не в классе данных. • Извлечение коллекции данных из ресурса ObjectProvider, который затем может вызвать класс доступа к данным. • Установка свойства DataGridComboBox.Column непосредственно в коде. Во многих ситуациях, значения, отображаемые в списке, не совпадают со значениями, которые должны быть сохранены в объекте данных. Типичным случаем является работа со связанными данными (например, заказами, которые связываются с товарами, записями об оплате, которые связываются с заказчиками, и т.д.). В примере StoreDB присутствует одно такое отношение между товарами и категориями. В этой серверной базе данных каждый товар связан с определенной категорией с помощью поля CategorylD. Этот факт не был отражен в упрощенной модели данных, которая использовалась во всех приведенных до сих пор примерах, где класс Product имеет свойство CategoryName (а не CategorylD). Преимущество такого подхода связано с удобством, поскольку вся видимая информация — такая как название категории для каждого товара — находится под рукой. Однако недостаток состоит в том, что свойство CategoryName в действительности редактировать нельзя, поэтому не существует простого способа переместить товар из одной категории в другую. В следующем примере рассматривается более реалистичный сценарий, при котором каждый класс Product имеет свойство CategorylD. Число CategorylD само по себе мало, что означает для пользователя приложения. Чтобы отобразить вместо него название категории, необходимо воспользоваться одним из следующих приемов: добавить дополнительное свойство CategoryName в класс Product (несколько неуклюжий подход); применить конвертер данных в привязках Categoryld (он мог бы находить соответст-
686 Глава 22. Элементы управления ListView, TreeView и DataGrid вующее название категории в кэшированием списке); отобразить столбец CategorylDc помощью DataGridComboBoxColumn (этот подход демонстрируется ниже). При таком подходе полный список объектов Category привязывается к свойству DataGridComboBoxColumn. Items Sour се: categoryColumn.IternsSource = App.StoreDb.GetCategories(); gridProducts.ItemsSource = App.StoreDb.GetProducts(); После этого конфигурируется элемент DataGridComboBoxColumn установкой трех следующих свойств: <DataGndComboBoxColumn Header="Category" x:Name="categoryColumn" DisplayMemberPath="CategoryName" SelectedValuePath=MCategoryID" SelectedValueBinding="{Binding Path=CategoryID}"></DataGridComboBoxColumn> Свойство DisplayMemberPath указывает столбцу, какой текст должен быть извлечен из объекта Category и отображен в списке. Свойство SelectedValuePath определяет, какие данные необходимо извлечь из объекта Category. Свойство SelectedValueBinding задает связанное поле объекта Product. Класс DataGridTemplateColumn Класс DataGridTemplateColumn позволяет использовать шаблон данных и предоставляет такие же возможности, как и рассмотренные ранее списковые элементы управления. Единственное отличие в классе DataGridTemplateColumn состоит в том, что он позволяет определять два шаблона: один для отображения данных (CellTemplate) и другой для их редактирования (CellEditingTemplate). Ниже приведен пример, в котором шаблонный столбец данных применяется для размещения миниатюрного изображения каждого товара в сетке (рис. 22.9): <DataGridTemplateColumn> <DataGndTemplateColumn . CellTemplate> <DataTemplate> <Image Stretch="None" Source= "{Binding Path=ProductImagePath, Converter={StaticResource ImagePathConverter}}"> </Image> </DataTemplate> </DataGndTemplateColumn. CellTemplate> </DataGndTemplateColumn> В этом примере предполагается, что в коллекцию UserControl.Resources добавлен конвертер значений ImagePathConverter: <UserControl.Resources> <local:ImagePathConverter x:Key="ImagePathConverter"></local:ImagePathConverter> </UserControl.Resources> Форматирование и стилизация столбцов Форматировать DataGridTextColumn можно таким же способом, что и элемент TextBlock, устанавливая свойства Foreground, FontFamily, FontSize, FontStyle и FontWeight. Однако DataGridTextColumn не поддерживает абсолютно все свойства, доступные у TextBlock. Например, в нем нельзя установить такое часто применяемое свойство, как Wrapping, чтобы создать столбец, отображающий несколько строк текста. Вместо этого понадобится использовать свойство ElementStyle. По существу свойство ElementStyle позволяет применить стиль к соответствующему элементу внутри ячейки DataGrid. В случае простого DataGridTextColumn этим элементом будет TextBlock, в случае DataGridCheckBoxColumn — флажок, а в случае DataGridTemplateColumn — любой элемент, который был создан в шаблоне данных.
Глава 22. Элементы управления ListView, TreeView и DataGrid 687 I DataGndTest Product Fake Moustache Translator Interpreter Earrings Muru-Purpose Watch Pnce $599.99 $459.99 $399.99 Model Number TCKLR1 JWLTRANS6 GRTWTCH9 ■ Ш ш *■ и a Рис. 22.9. Элемент управления DataGrid с изображениями в качестве содержимого Ниже приведен простой стиль, включающий перенос текста в столбце: <DataGndTextColumn Header="Descnption" Width=00" Binding="{Binding Path=Description}"> <DataGridTextColumn.Elementstyle> <Style TargetType="TextBlock"> <Setter Property="TextWrapping" Value="Wrap"></Setter> </Style> </DataGridTextColumn.Elementstyle> </DataGridTextColumn> Чтобы текст переносился, необходимо увеличить высоту строк. К сожалению, элемент управления DataGrid не способен изменять свои размеры так же гибко, как это умеют делать контейнеры компоновки в WPF. Поэтому потребуется задать фиксированную высоту для строк с помощью свойства DataGrid.RowHeight. Эта высота будет применяться ко всем строкам, каким бы ни был объем размещенного внутри них содержимого. На рис. 22.10 показан пример, в котором высота строк установлена в 70 единиц. I ■ DataGndTest Product FUn Racer 2000 Edible Tape Escape Vehicle (/ Extracting Tool Pnce $1499.99 $3.99 $2.99 $199.00 Model RU007 STKY1 P38 NOZll< Descnption Looks like an ordinary bumbershoot, but don't be fooled! Simply place Ram Racer's tip on the ground and press the release latch. Within seconds, this ordinary rain umbrella converts into a two-wheeled gas-powered mim- scooter. Goes from 0 to 60 in 7.5 seconds - even in a driving rami Comes m black, blue, and candy-apple red. The latest in personal survival gear the STKY1 looks like a roH of ordinary office tape, but can save your life in an emergency. Just remove the tape roH and place in a kettle of boding water with mixed vegetables and a ham shank. In just 90 minutes you have a great tasking soup that really sticks to your ribs! Herbs and spices not included. In a jam, need a quick escape? Just whip out a sheet of our patented P38 paper and, with a few quick folds, it converts into a lighter-than-air escape vehicle! Especially effective on windy days - no fuel required. Comes in several sizes including letter, legal, A10, and B52. High-tech miniaturized extracting tool. Excellent for extricating foreign objects from your person. Good for picking up really tiny stuff too! =' Рис. 22.10. Элемент управления DataGrid, в котором текст переносится
688 Глава 22. Элементы управления ListView, TreeView и DataGrid Совет. Чтобы применить один и тот же стиль к множеству столбцов, можно определить его в коллекции Resources и затем ссылаться на него в каждом столбце с использованием StaticResource. С помощью свойства EditingElementStyle стиль применяется к элементу, который задействован при редактировании столбца. В случае DataGridTextColumn этим элементом будет Text Box. ElementStyle и ElementEditingStyle и свойства столбцов предоставляют способ форматирования всех ячеек в любом конкретном столбце. Однако в некоторых случаях необходимо применить параметры форматирования к каждой ячейке в каждом столбце. Простейший способ сделать этого заключается в конфигурировании стиля для свойства DataGrid.RowStyle. Кроме того, DataGrid имеет небольшой набор дополнительных свойств, которые позволяют форматировать другие части сетки, такие как заголовки столбцов и заголовки строк. Все свойства перечислены в табл. 22.2. Таблица 22.2. Свойства DataGrid, основанные на стилях Свойство К чему применяется стиль ColumnHeaderStyle Элемент TextBlock, используемый для отображения заголовков столбцов в верхней части сетки RowHeaderStyle Элемент TextBlock, используемый для отображения заголовков строк DraglndicatorStyle Элемент TextBlock, используемый для отображения заголовка столбца во время его перетаскивания в новую позицию RowStyle Элемент TextBlock, используемый для отображения обычных строк (строк в столбцах, которые не были настроены специальным образом через свойство ElementStyle соответствующего столбца) Форматирование строк Устанавливая свойства объектов столбцов DataGrid, можно управлять форматированием целых столбцов. Во многих случаях полезно помечать строки со специфическими данными. Например, может понадобиться привлечь внимание к товарам с высокой ценой или доставкам с истекшим сроком исполнения. Подобного рода форматирование можно реализовать программно, обрабатывая событие DataGrid.LoadmgRow. Событие LoadingRow является очень мощным инструментом для форматирования строк. Оно предоставляет доступ к объекту данных текущей строки, позволяя выполнять простые проверки диапазонов, сравнения и более сложные операции. Оно также предоставляет доступ к объекту DataGridRow строки, что дает возможность форматировать строку и применять к ней другие цвета или другой шрифт. Однако форматировать какую-то одну ячейку в этой строке не удастся — для этого нужно использовать DataGridTemplateColumn и специальный конвертер значений. Событие LoadingRow генерируется один раз для каждой строки при ее появлении на экране. Преимущество такого подхода состоит в том, что приложение никогда не должно форматировать всю сетку: вместо этого событие LoadingRow срабатывает только для тех строк, которые видимы в текущий момент. Но есть также и недостаток. При прокручивании пользователем сетки событие LoadingRow срабатывает постоянно. Поэтому помещать в метод LoadingRow код, требующий значительного времени на выполнение, нельзя, иначе операция прокручивания в приложении будет постепенно замедляться вплоть до полного останова.
Глава 22. Элементы управления ListView, TreeView и DataGrid 689 Также существует один момент, который должен быть обязательно продуман — повторное использование контейнера элементов. Для снижения использования ресурсов памяти DataGrid предусматривает повторное применение тех же самых объектов DataGridRow для отображения новых данных при прокручивании сетки. (Именно поэтому событие называется LoadingRow, а не CreatingRow.) Если не соблюдать осторожность, DataGrid может загрузить данные в уже сформатированный объект DataGridRow. Для предотвращения такой ситуации нужно явно возвращать каждую строку в первоначальное состояние. В следующем примере элементам с высокой ценой придается ярко-оранжевый фон (рис. 22.11), а элементам с обычной ценой — стандартный белый фон: // Повторное использование объектов кисти для повышения // эффективности при отображении больших объемов данных. private SolidColorBrush highlightBrush = new SolidColorBrush(Colors.Orange) ; private SolidColorBrush normalBrush = new SolidColorBrush(Colors.White); private void gridProducts_LoadingRow(object sender, DataGridRowEventArgs e) { // Проверка объекта данных для строки. Product product = (Product)e.Row.DataContext; // Применение условного форматирования. if (product.UnitCost > 100) { e.Row.Background = highlightBrush; } else { // Восстановление используемого по умолчанию белого фона. // Это гарантирует, что использованные, сформатированные // объекты DataGrid вернутся к исходному внешнему виду. е.Row.Background = normalBrush; } } Следует иметь в виду, что доступен еще один вариант для форматирования на основе значений — использование конвертера значений, который анализирует привязанные данные и преобразует их в что-то другое. Этот прием особенно мощный, когда применяется в сочетании с DataGridTemplateColumn. 1 DataGridTest Product Edible Tape 1! Escape Vehicle (Air) Extracting Tool Escape Vehicle (Water) 1 j Communications Device 11 Persuasive Pencil 11 Mufti-Purpose Rubber Band Universal Repair System II Effective Flashlight 11 The Incredible Versatile Paperdi Toaster Boat 1 Mufti-Purpose Towelette ll'baJT v Price $3.99 $2.99 $19940 $1299.99 $49 oq $1.99 $1.99 $4.99 $9.99 $1.49 $19,999.98 $12.99 [о^^Ы1 Mode* Number С 1 STKY1 P3& NOZ119 PT109 red: LK4TLNT NTMBS1 NE1RPR BRTLGT1 INCPPRCLP DNTRPR TGFDA % InJE H ■ I Pi 0 - Tr Tl | D " » ) Рис. 22.11. Подсвечивание строк
690 Глава 22. Элементы управления ListView, TreeView и DataGrid Например, можно создать шаблонный столбец, содержащий элемент Text Bloc к, и привязать его свойство TextBlock.Background к конвертеру значений, который устанавливает цвет на основе цены. В отличие от продемонстрированного выше приема с применением события LoadingRow, такой подход позволяет применять форматирование только к ячейке, в которой содержится цена, а не ко всей строке. За более подробными сведениями обращайтесь в главу 20. На заметку! Форматирование, определяемое в обработчике событий LoadingRow, будет применяться только при загрузке строки. В случае редактирования строки этот код LoadingRow срабатывать не будет (по крайней мере, до тех пор, пока строка не исчезнет из поля зрения за счет прокручивания, а потом вернется обратно). Детали строк В DataGrid поддерживаются так называемые детали строк (row details) — отдельная дополнительная область, которая появляется непосредственно под значениями столбца для строки. Эта область предоставляет две дополнительных возможности, которые недоступны с при наличии одних только столбцов. • Она занимает всю ширину DataGrid и не врезается в отдельные столбцы, обеспечивая больше пространства для работы. • Ее можно сконфигурировать так, чтобы она появлялась только для избранных строк, что позволяет убирать лишние детали, когда в них нет необходимости. На рис. 22.12 показан элемент управления DataGrid, в котором используются оба поведения. В области деталей строк отображается текст описания товара, причем только для товара, выделенного в текущий момент. (■ DataGrid RowDetails г-вШ*) Product j Rain Racer 2000 j Edible Tape j Escape Vehicle (Air) ! Extracting Tool Price $1,499.99 $3.99 $2.99 $199.00 . DOCt чиггосг RU007 STKY1 P38 NOZ119 * (ESS Camouflaged as style* wing tips these shots get you out of a jam on the high seas instantly Exposed to water the pair transforms into speedy miniature Inflatable raft». Complete with 76 HP outboard motor, these hip heels wM whsk you to safety even m the roughest of teas Warning: Not recommended for beechweer 1 Communications Device | Persuasive Pencil 1 [ Multi-Purpose Rubber Band Universal Repair System 1 Effective Flashlight $49.99 $1.99 $1.99 $4.99 $9.99 RED1 LK4TLNT NTMBS1 NE1RPR BRTLGT1 1 M - ; Рис. 22.12. Использование области деталей строк Для создания этого примера необходимо сначала определить содержимое, которое должно отображаться в области деталей строк, установив свойство DataGrid. RowDetailsTemplate. В данном случае для этой области используется базовый шаблон, который включает элемент TextBlock, отображающий полное описание товара, и границу вокруг него: <DataGrid.RowDetailsTemplate> <DataTemplate> <Border Margin=011 Padding=011 BorderBrush= 'SteelBlue11 BorderThickness="
Глава 22. Элементы управления ListView, TreeView и DataGrid 691 CornerRadius="> <TextBlock Text=" {Binding Path=Description} " TextWrapping="Wrap11 FontSize=,,10"> </TextBlock> </Border> </DataTemplate> </DataGrid.RowDetailsTemplate> Можно также добавить элементы управления, которые позволяют выполнять различные задачи (например, извлечь дополнительную информацию о товаре, поместить товар в корзину для покупок, отредактировать сведения и т.д.). Конфигурирование поведения, касающегося отображения области деталей строк, осуществляется установкой свойства DataGrid.RowDetailsVisibilityMode. По умолчанию для этого свойства устанавливается значение VisibleWhenSelected, указывающее, что область деталей должна отображаться при выборе строки. Значение Visible указывает, что область деталей каждой строки должна отображаться сразу же. Значение Collapsed определяет, что область деталей не должна отображаться ни для одной строки — по крайней мере, до тех пор, пока значение RowDetailsVisibilityMode не будет изменено в коде (например, при выборе пользователем определенного типа строки). Закрепление столбцов Закрепляемый столбец остается на месте в левой части элемента управления DataGrid, даже при прокручивании содержимого вправо. На рис. 22.13 показано, что закрепленный столбец Product остается видимым во время прокручивания. Обратите внимание, что горизонтальная полоса прокрутки находится только под прокручиваемыми столбцами, но не под закрепленным. [ I DataGridTest ' ModelNumber ; 1MOR4ME I BHONST93 ■ ВМЕ007 j BPRECISEOO ; brtlgti ! BSUR2DUC I] C00LCMB1 i | CHEW99 | CITSME9 i DNTGCGHT I DNTRPR i FF007 1 GRTWTCH9 1 ICNCU Device Device ssue Wallet - a CategoryName Protection Deception Deception Too»$ Tools Protection General General Deception Deception Travel Tools Tools General «i V. . Ьг>.н=нии. Descnption Do your assignments ha * Disguised as typewriter : |) Never leave on an unde' | Facing a bnck wall? Stop' jj The most powerful dark; jj Being a spy can be danc ; Use the Telescoping Сен Survive for up to four di i Worried about detectiot | Don t be caught pennile Turn breakfast into a hie ! Worried that counteragi 1 In the tradition of famot j Avoid pamful and poter -■ j 11 ► . . . J 1 DataGndTest : ModelNumber ! 1MOR4ME j BHONST93 ; BME007 j BPRECISEOO ; brtlgti ! BSUR2DUC 1 C0OLCM81 • CHEW99 \\ CITSME9 II DNTGCGHT I DNTRPR j FF007 ! GRTWTCH9 | ICNCU ModdNamc Cocktail Party Pal Correction Fluid Indentity Confusion Device Dilemma Resolution Device Effective Flashlight Bullet Proof Facial Tissue Telescoping Comb Survival Bar Cloaking Device Counterfeit Creation Wallet Toaster Boat Eavesdrop Detector Multi-Purpose Watch Perfect-Vtsion Glasses [ <=n t£j ita£*d ' Categ on/Name Protection Deception Deception Tools Tools Protection General General Deception Deception Travel Tools Tools General , | ► \ Щ ^i _—. . .. „ -jj Рис. 22.13. Закрепление столбца Product Возможность закреплять (freezing) столбцы полезна, когда нужно, чтобы определенная информация (наподобие имени товара или уникального идентификатора) всегда оставалось видимой. Она включается установкой в свойстве DataGrid.FrozenColumnCount значения больше нуля. Например, установка в 1 приводит к закреплению первого столбца: <DataGrid x:Name="gridProducts11 Margin=11 AutoGenerateColumns="False11 FrozenColumnCount="> Закрепленные столбцы должны всегда размещаться в левой части сетки. Если закрепляется один столбец, это будет столбец, являющийся крайним слева, в случае закрепления двух столбцов это будут два первых столбца слева и т.д.
692 Глава 22. Элементы управления ListView, TreeView и DataGrid Выбор Как и в обычном списковом элементе управления, в DataGrid пользователь имеет возможность выбирать отдельные элементы. Когда это происходит, инициируется событие SelectionChanged, на которое можно отреагировать. Для выяснения того, какой объект данных является выбранным в текущий момент, можно использовать свойство Selectedltem. Если установить для свойства SelectionMode значение Extended, пользователь сможет выбирать сразу несколько строк. (Еще одним доступным вариантом является значение Single, которое применяется для этого свойства по умолчанию.) Выбор множества строк осуществляется при нажатой клавише <Shift> или <Ctrl>. Коллекция выбранных элементов хранится в свойстве Selectedltems. Совет. Установка выбора в коде производится с использованием свойства Selectedltem. Если это делается для элемента, который в текущий момент не находится в поле зрения, разумно сопровождать это вызовом метода DataGrid.ScrollIntoView(). Тогда DataGrid выполнит прокрутку вперед или назад, пока указанный элемент не станет видимым. Сортировка Элемент управления DataGrid имеет встроенную функцию сортировки, для использования которой требуется привязка к коллекции, реализующей интерфейс IList (такой как List<T> или ObservableCollection<T>). В этом случае элемент управления DataGrid автоматически получает базовые возможности сортировки. Для применения сортировки пользователь должен щелкать на заголовке столбца. Одиночный щелчок сортирует элементы столбца по возрастанию на основе типа данных (например, числа сортируются от 0 и дальше по мере увеличения, а буквы — в алфавитном порядке). Повторный щелчок приводит к сортировке в обратном порядке. Справа от заголовка столбца появляется стрелка, которая показывает, что в DataGrid произведена сортировка на основе значений в данном столбце. Стрелка, указывающая вверх, обозначает сортировку по возрастанию, а вниз — по убыванию. Пользователи могут выполнять сортировку сразу в нескольких столбцах, удерживая нажатой клавишу <Shift> во время щелчка. Например, удерживая нажатой клавишу <Shift> и щелкая на столбце Category, а затем на столбце Price, можно отсортировать товары по группам категорий в алфавитном порядке и упорядочить элементы внутри каждой группы по цене. Обычно в алгоритме сортировки DataGrid используются привязанные данные, которые отображаются в столбце, в чем есть смысл. Тем не менее, можно выбирать и другое свойство из привязанного объекта данных, устанавливая свойство SortMemberPath столбца. Для DataGridTemplateColumn использование этого свойства вообще обязательно, поскольку в этом классе нет свойства Binding для предоставления привязанных данных. В противном случае функция сортировки в столбце поддерживаться не будет. Для отключения функции сортировки во всей сетке необходимо установить свойство CanUserSortColumns в false. Для отключения ее в отдельном столбце понадобится установить в false свойство CanUserSort этого столбца. Редактирование в DataGrid Одним из главных удобств в элементе управления DataGrid является предлагаемая в нем поддержка редактирования. Каждая ячейка в DataGrid переключается в режим редактирования, когда пользователь выполняет на ней двойной щелчок. Ограничивать эту возможность DataGrid можно несколькими способами.
Глава 22 Элементы управления ListView, TreeView и DataGrid 693 • DataGrid.IsReadOnly. Если это свойство равно true, пользователи не могут выполнять редактирование. • DataGridColumn. IsReadOnly. Если это свойство равно true, пользователи не могут выполнять редактирование значений в данном столбце. • Свойства, предназначенные только для чтения. Если объект данных имеет свойство без средства установки (Setter), элемент управления DataGrid распознает это и отключает возможность редактирования в соответствующем столбце (как если бы свойство DataGridColumn.IsReadOnly было установлено в true). Аналогично, если свойство не является простым текстом, числом или датой, элемент управления DataGrid делает его доступным только для чтения (хотя эту ситуацию можно обойти за счет использования DataGridTemplateColumn, как будет показано ниже). То, что происходит при переводе ячейки в режим редактирования, зависит от типа столбца. В столбце DataGridTextColumn отображается текстовое поле (которое выглядит не очень гладко, заполняет собой всю ячейку и не имеет видимой границы). В столбце DataGridCheckBox отображается флажок, который можно отмечать и снимать отметку. Но самым интересным является столбец DataGridTemplateColumn. В нем можно заменить стандартное текстовое поле для редактирования более специализированным элементом управления вводом. Например, в показанном ниже столбце отображается дата. Двойной щелчок на нем приводит к появлению раскрывающегося элемента управления DatePicker (рис. 22.14), в котором уже выбрано текущее значение. <DataGridTemplateColumn Header="Date Added"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Margin=,,4" Text= "{Binding Path=DateAdded, Converter={StaticResource DateOnlyConverter}}"> </TextBlock> </DataTemplate> </DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellEditingTemplate> <DataTemplate> <DatePicker SelectedDate="{Binding Path=DateAdded, Mode=TwoWay}"> </DatePicker> </DataTemplate> </DataGridTemplateColumn.CellEditingTemplate> </DataGndTemplateColumn> ■ DataGridEditong Product III Pan Racer2000 II Edible Tape Escape Vehicle (Air) Extracting Tool ill Escape Vehicle (Water) Communications Device Persuasive Pencil 11 - - - - - - - - - 1 ^i Price $1.499.99 $3.99 $2.99 $199.00 $1,299.99 $49.99 $1.99 .. .. Model Number RU007 STKY1 P38 NOZ119 PT109 RED1 UC4TLNT - Category Date Added TravH Ц.-УШ.И m General Travel Tools Travel Communications Communications 4 February, i So Ho Tu We H 31 1 2 3 7 8 ГТ1 10 1 14 1$ lj^7 П 22 23 24 28 1 2 3 J 7 8 9 10 jca «10 ► ГЬ Fr Sa U 12 13 IB 19 20 2S Q 27 4 5 8 11 12 13 1 II i ~ -'J Рис. 22.14. Редактирование дат с помощью DatePicker
694 Глава 22. Элементы управления ListView, TreeView и DataGrid В DataGrid автоматически поддерживается та же базовая система проверки достоверности, о которой рассказывалось в главе 19 и которая реагирует на проблемы в системе привязки данных (вроде невозможности преобразования предоставляемого текста в нужный тип данных) либо исключения, выдаваемые средством установки свойства. Ниже приведен пример, в котором для проверки достоверности поля UnitCost применяется специальное правило: <DataGridTextColumn Header="Price"> <DataGridTextColumn.Binding> <Binding Path="UnitCost" StringFormat="{}{0:C}"> <Binding.ValidationRules> <local:PositivePriceRule Max="999.99" /> </Binding.ValidationRules> </Binding> </DataGridTextColumn.Binding> </DataGridTextColumn> Шаблон ErrorTemplate, применяемый для DataGridCell по умолчанию, предусматривает отображение рамки красного цвета вокруг недействительного значения, что очень похоже на поведение других элементов управления вводом, таких как Text Box. Проверка достоверности может быть реализована в DataGrid и другими способами. Один из них предусматривает использование событий DataGrid, связанных с редактированием, которые описаны в табл. 22.3. События перечислены в порядке их возникновения в DataGrid. Таблица 22.3. События DataGrid, связанные с редактированием Имя Описание BeginningEdit PreparingCellForEdit CellEditEnding RowEditEnding Возникает перед переводом ячейки в режим редактирования. В этот момент можно просмотреть столбец и строку, которые будут редактироваться, проверить значение ячейки и отменить эту операцию с использованием свойства DataGridBeginningEditEventArgs. Cancel Применяется для шаблонных столбцов. В этот момент можно выполнить любую инициализация, которая требуется для элементов управления редактированием. Для доступа к элементу в CellEditingTemplate используется свойство DataGridPreparingCellForEditEventArgs. EditingElement Возникает перед выходом ячейки из режима редактирования. DataGridCellEditEndingEvent Args. Edit Act ion позволяет узнать, пытается пользователь применить редактирование (например, нажимая клавишу <Enter> или щелкая на другой ячейке) или отменить его (нажатием клавиши <Escape>). В этот момент можно просмотреть новые данные и установить свойство Cancel для отката изменений Возникает при переходе пользователем на новую строку после редактирования текущей. Как и в случае CellEditEnding, в этот момент можно выполнить проверку достоверности и отменить изменения. Обычно проверка достоверности охватывает несколько столбцов, например, когда значение в одном столбце не должно быть больше значения в другом столбце
Глава 22. Элементы управления ListView, TreeView и DataGrid 695 Когда необходима логика проверки достоверности, специфичная для страницы (и потому включать ее в объекты данных нельзя), можно поместить ее в обработчики событий CellEditEnding и RowEditEnding. В обработчике CellEditEnding выполняется проверка правил, связанных со столбцом, а согласованность целой строки проверяется в обработчике событий RowEditEnding. Помните, что в случае отмены редактирования понадобится предоставить описание проблемы (обычно в элементе TextBlock где-то на странице). Резюме В этой главе подробно рассматривались классы ItemsControl, предоставляемые WPF. Было показано, как использовать класс ListView для создания списков с несколькими режимами просмотра, класс TreeView — для отображения иерархических данных и класс DataGrid — для просмотра и редактирования множества разнообразных данных в одном месте. Наиболее примечательным аспектом всех этих классов является то, что они унаследованы от одного базового класса ItemsControl, который и задает их основную функциональность. Тот факт, что во всех этих элементах управления используется та же самая модель содержимого, та же возможность для привязки данных и те же функции для применения стилей и шаблонов, представляет собой одну из замечательных особенностей WPF. В ItemsControl определены все необходимые базовые средства для любого спискового элемента управления WPF, даже для тех, которые упаковывают иерархические данные, наподобие TreeView. Единственным изменение в этой модели состоит в том, что дочерние элементы таких элементов управления (например, объекты TreeViewItem) сами являются объектами ItemsControl и способны содержать собственные дочерние элементы.
ГЛАВА 23 Окна Окна являются основными элементами в любом настольном приложении — настолько "основными", что в их честь даже была названа операционная система Windows. И хотя в WPF имеется модель для создания навигационных приложений, распределяющих задачи по отдельным страницам, окна все равно остаются преобладающей технологией для создания приложений. В этой главе речь пойдет о классе Window. Будут описаны различные способы отображения и размещения окон, организация взаимодействия между классами окон и перечень встроенных диалоговых окон, предлагаемых в WPF. Будут также рассматриваться окна с более экзотическими эффектами, такие как окна непрямоугольной формы, прозрачные окна и окна с эффектом Aero Glass. Наконец, будет продемонстрирована поддержка WPF программирования для панели задач Windows 7. Что нового? Одним из разочарований в прежних версиях WPF было отсутствие встроенной поддержки для новых функциональных средств Windows Vista. В WPF 4 это упущение ликвидировано, более того, теперь даже предлагается поддержка панели задач Windows 7. В разделе "Программирование для панели задач Windows 7" далее в главе будет показано, как использовать новые функции панели задач, такие как списки часто используемых элементов (Jump Lists), индикаторы выполнения (Progress Notification), налагаемые значки (Icon Overlays) и миниатюрные окна предварительного просмотра (Taskbar Previews). Класс window Как уже рассказывалось в главе 6, класс Window унаследован от класса ContentControl. Это означает, что он может содержать только одного потомка (каковым обычно является контейнер компоновки наподобие элемента управления Grid) и что его фон можно закрашивать с помощью кисти путем установки свойства Background. Можно еще также использовать и свойства BorderBrush и BorderThickness для добавления вокруг окна границы, но эта граница добавляется внутри оконной рамки (то есть по краю клиентской области). Оконную рамку можно вообще удалять путем установки для свойства WindowStyle значения None, что позволяет создавать полностью настраиваемое (т.е. имеющее специальную форму) окно, о чем более подробно будет рассказываться далее в этой главе, в разделе "Непрямоугольные окна". На заметку! Клиентская область — это область внутри окна. Именно в ней размещается содержимое К не клиентской области относится граница и строка заголовка в верхней части окна. За управление этой областью отвечает операционная система.
Глава 23. Окна 697 Помимо этого, класс Window имеет небольшой набор членов, знакомых любому программисту для Windows. Наиболее очевидными из них являются свойства, которые касаются внешнего вида и позволяют изменять способ отображения не клиентской части окна. Основные члены класса Window перечислены в табл. 23.1. Таблица 23.1. Основные свойства класса Window Имя Описание AllowsTransparency Icon Тор и Left ResizeMode Если установлено в true, класс Window позволяет другим окнам "проглядывать" через данное при условии, что для фона был установлен прозрачный цвет. В случае установки в false (поведение по умолчанию), находящееся позади данного окна содержимое не "просматривается", и прозрачный цвет фона визуализируется как черный. В случае использования в комбинации со свойством WindowsStyle, установленным в None, это свойство позволяет создавать окна, имеющие необычную форму (см. раздел "Непрямоугольные окна" далее в главе) Объект ImageSource, идентифицирующий значок, который должен использоваться для данного окна. Значки отображаются в левом верхнем углу окна (если в нем применяется один из стандартных стилей границ), в панели задач (если свойство ShowInTaskBar установлено в true) и в окне выбора, которое появляется, когда пользователь нажимает комбинацию клавиш <Alt+Tab> для перехода между работающими приложениями. Поскольку эти значки имеют разные размеры, в используемом для них файле .ico должны содержаться изображения с размерами как минимум 16x16 и 32x32 пикселя. В последних версиях ОС Windows (Windows Vista и Windows 7) добавлен новый стандарт для значков 48x48 и 256x256 пикселей, размер которых можно изменять. Если свойство Icon установлено в null, окно получает тот же значок, что и приложение (значок для которого можно указать в Visual Studio, дважды щелкнув на узле Properties (Свойства) в окне Solution Explorer и перейдя на вкладку Application (Приложение)). Если свойство вообще опущено, WPF для изображения окна будет использовать стандартный, но непримечательный значок Определяют расстояние между левым верхним углом окна и левыми верхними краями экрана (в независимых от устройства единицах). При изменении любого из них генерируется событие LocationChanged. Если свойство WindowStartupPosition установлено в Manual, значения этих свойств можно указывать до появления окна для определения, задавая его начальную позицию. Какое бы значение не использовалось для WindowStartupPosition, эти свойства можно устанавливать в любой момент после отображения окна, изменяя его текущую позицию Принимает значение перечисления ResizeMode, которое определяет, может ли пользователь изменять размеры окна. Также влияет на видимость кнопок, отвечающих за разворачивание и сворачивание окна. Чтобы полностью заблокировать размеры окна, используйте значение NoResize. Чтобы пользователь мог только сворачивать окно, применяйте значение CanMinimize. Чтобы пользователь мог изменять размер окна всеми возможными способами, указываете значение CanResize. Чтобы в правом нижнем углу окна отображалась еще и визуальная подсказка, указывающая, что размеры окна разрешено изменять, задавайте значение CanResizeWithGrip
698 Глава 23. Окна Окончание табл. 23.1 Имя Описание RestoreBounds ShowInTaskbar SizeToContent Title Topmost WindowStartupLocation WindowState WindowStyle Предоставляет границы окна. Если окно в текущий момент развернуто или свернуто, это свойство отражает границы, которые использовались последними перед развертыванием или свертыванием. Это чрезвычайно удобно, когда необходимо сохранить информацию о позиции и размерах окна, как будет показано позже в этой главе Если установлено в true, окно отображается в панели задач и списке, появляющемся после нажатия комбинации клавиш <Alt+Tab>. Обычно это свойство устанавливается в true только для главного окна приложения Позволяет создать окно, которое автоматически увеличивается в соответствии с размерами содержимого. Это свойство принимает значение перечисления SizeToContent. Чтобы отключить автоматическое изменение размеров окна, используйте значение Manual. Чтобы окно могло увеличиваться в различных направлениях в соответствии с размерами динамического содержимого, применяйте, соответственно, значение Height, Width или WidthAndHeight. При установке значения SizeToContent окно может увеличиваться в размерах в соответствии с содержимым так, что будет выходить за пределы экрана Заголовок, который отображается в строке заголовка окна (и в панели задач) Если установлено в true, окно всегда отображается поверх остальных окон в приложении (если только у них это свойство также не равно true). Такая настройка очень удобна для палитр, которые обычно должны "плавать" над другими окнами Принимает значение перечисления WindowStartupLocation. Для размещения окна в конкретной позиции с помощью свойств Left и Тор используйте значение Manual. Для размещения окна по центру экрана применяйте значение CenterScreen. Для размещения окна с учетом позиции того окна, которое его запустило, указывайте значение CenterOwner. При отображении немодального окна с использованием значения CenterOwner удостоверьтесь, что свойство Owner нового окна установлено перед тем, как показывать его Принимает значение перечисления WindowState. Информирует о том, в каком состоянии находится окно: развернутом, свернутом или обычном (и позволяет изменить его). При изменении значения этого свойства генерируется событие StateChanged Принимает значение перечисления WindowStyle, которое определяет внешний вид границы окна. Возможные значения: SingleBorderWindow (по умолчанию), ThreeDBorderWindow (граница визуализируется несколько иным образом в Windows XP), ToolWindow (отображается тонкая граница, удобная для "плавающих" окон с инструментами без кнопок сворачивания и разворачивания) и None (визуализируется очень тонкая приподнятая граница без области для строки заголовка). Увидеть разницу можно на рис. 23.1 О событиях жизненного цикла, которые генерируются при создании, активизации и выгрузке окна, уже рассказывалось (в главе 5). Помимо них класс Windows поддерживает события LocationChanged и WindowStateChanged, которые генерируются при изменении, соответственно, позиции и состояния (WindowState) окна.
Глава 23. Окна 699 WmdowStyte.Non* )W.ndowSty1e.StngleBorderWir>dow Wndo*Styte.Hane ШНЫвйЛ^Лфйшйштикм WmdowStykThreeDBofdefWindow MArtdoMSMt-TodWIndM WVmdowStyie.Thf<MDeorierW*dow Рис. 23.1. Использование различных значений для свойства WindowStyle: Windows Vista/Windows 7 (слева) и Windows XP (справа) Отображение окна Чтобы отобразить окно, необходимо создать экземпляр класса Window и вызвать метод Show() или ShowDialog(). Метод ShowDialogO отображает модальное окно. Модальные окна не позволяют пользователю получать доступ к родительскому окну, блокируя возможность использования в нем мыши и возможность ввода в нем каких-то данных до тех пор, пока модальное окно не будет закрыто. Вдобавок метод ShowDialogO не осуществляет возврат до тех пор, пока модальное окно не будет закрыто, так что выполнение любого находящегося после него кода на время откладывается. (Однако это не означает, что в данное время не может выполняться никакой другой код — например, при наличии запущенного таймера обработчик его событий все равно будет работать.) Наиболее часто применяемая в коде схема выглядит так: отображение модального окна, ожидание его закрытия и последующее выполнение над его данными какой-нибудь операции. Ниже показан пример использования метода ShowDialogO: TaskWindow winTask = new TaskWindow(); winTask.ShowDialog (); // Выполнение достигает этой точки после закрытия winTask. Метод Show () отображает немодалъное окно, которое не блокирует доступ пользователя ни к каким другим окнам. Более того, метод Show() производит возврат управления сразу после отображения окна, благодаря чему следующие за ним в коде операторы выполняются незамедлительно. Можно создавать и показывать сразу несколько немодальных окон, и пользователь может взаимодействовать со всеми ними одновременно. В случае применения немодальных окон иногда требуется код синхронизации, гарантирующий обновление информации во втором окне при внесении каких-то изменений в первом и тем самым исключающий вероятность работы пользователя с недействительными данными. Ниже показан пример использования метода Show(): MainWindow winMain = new MainWindowО; winMain.Show() ; // Выполнение достигает этой точки сразу же после отображения winMain. Модальные окна идеально подходят для предоставления пользователю приглашения сделать выбор, прежде чем выполнение операции сможет быть продолжено. Например, возьмем приложение Microsoft Word. Это приложение всегда отображает окна Options (Параметры) и Print (Печать) в модальном режиме, вынуждая пользователя принимать решение перед продолжением. С другой стороны, окна, предназначенные для поиска по тексту или проверки наличия в документе орфографических ошибок, Microsoft Word отображает в немодальном режиме, позволяя пользователю редактировать текст в основном окне документа, пока идет выполнение задачи.
700 Глава 23. Окна Закрывается окно точно так же просто, с помощью метода Close (). Альтернативным вариантом является сокрытие окна из вида путем использования метода Hide() или установки для свойства Visibility значения Hidden. И в том и в другом случае окно остается открытым и доступным для кода. Как правило, скрывать имеет смысл только немодальные окна. Дело в том, что при сокрытии модального окна код остается в "замороженном" состоянии до тех пор, пока окно не будет закрыто, а закрыть невидимое окно пользователь никак не сможет. Позиционирование окна Обычно размещать окно в каком-нибудь точно определенном месте на экране не требуется. В таких случаях можно просто указать для свойства WindowState значение CenterOwner и ни о чем не беспокоится. В других случаях, которые хоть встречаются реже, но все-таки бывают, должна задаваться точная позиция окна, что подразумевает использование значения Manual для свойства WindowState и указание точных координат в свойствах Left и Right. Иногда выбору подходящего месторасположения и размера для окна нужно уделять немного больше внимания. Для примера рассмотрим следующую ситуацию: вы случайно создали окно с размером, который является слишком большим для отображения на дисплее с низким разрешением. Если речь идет о приложении с единственным окном, тогда наилучшим решением будет создать окно с возможностью изменения размеров. Если же речь идет о приложении с несколькими плавающими окнами, то дело усложняется. Можно попробовать просто ограничить позиции окна теми, которые поддерживаются даже на самых маленьких мониторах, но это, скорее всего, будет раздражать пользователей мониторов более новых моделей (которые приобрели мониторы с большей разрешающей способностью специально для того, чтобы иметь возможность умещать на экране больше информации). Поэтому вероятнее всего придется принимать решение о наилучшем размещении окна во время выполнения. А для этого потребуется извлечь кое-какую базовую информацию о доступном оборудовании для отображения с помощью класса System.Windows.SystemParameters. Класс SystemParameters поддерживает огромный список статических свойств, которые возвращают информацию о различных параметрах системы. Например, его можно использовать для определения, включил ли пользователь помимо всего прочего функцию "горячего" отслеживания (hot tracking) и возможность перетаскивания целых окон. В случае окон класс SystemParameters особенно полезен, потому что предоставляет два свойства, которые возвращают информацию о размерах текущего экрана: FullPrimaryScreenHeight и FullPrimaryScreenWidth. Оба они довольно просты, что иллюстрируется в показанном ниже коде (центрирующем окно во время выполнения): double screeHeight = SystemParameters.FullPrimaryScreenHeight; double screeWidth = SystemParameters.FullPrimaryScreenWidth; this.Top = (screenHeight - this.Height) / 2; this.Left = (screenWidth - this.Width) / 2; Хотя этот код эквивалентен применению свойства WindowState со значением CenterScreen, он предоставляет гибкость, позволяя реализовать различную логику позиционирования и выполнять ее в подходящее время. Еще лучший вариант предусматривает применение прямоугольника SystemParameters. WorkArea для размещения окна в свободной области экрана. При вычислении рабочей области область, где пристыкована панель задач (и любые другие "полосы", стыкованные с рабочим столом), не учитывается.
Глава 23. Окна 701 double workHeight = SystemParameters.WorkArea.Height; double workWidth = SystemParameters.WorkArea.Width; this.Top = (workHeight - this.Height) / 2; this.Left = (workWidth - this.Width) / 2; На заметку! Оба примера с позиционированием окна характеризуются одним небольшим недостатком. Когда свойство Тор устанавливается для окна, которое уже является видимым, это окно незамедлительно перемещается и обновляется. То же самое происходит и при установке свойства Left в следующей строке кода. В результате пользователям с хорошим зрением может быть заметно, что окно перемещается дважды. К сожалению, класс Window не предоставляет метода, который бы позволял устанавливать оба этих свойства одновременно. Поэтому единственным решением является позиционирование окна после его создания, но перед его отображением с помощью метода Show() или ShowDialogO. Сохранение и восстановление информации о местоположении окна К числу типичных требований для окна относится и запоминание его последнего месторасположения. Эта информация может сохраняться как в конфигурационном файле пользователя, так и в системном реестре Windows. Если нужно сделать так, чтобы информация о расположении какого-то важного окна хранилась в конфигурационном файле конкретного пользователя, дважды щелкните на узле Properties (Свойства) в окне Solution Explorer и выберите раздел Settings (Параметры). После этого добавьте действующий только на уровне данного пользователя параметр с типом данных System.Windows.Rect, как показано на рис. 23.2. WpfAppScation ~ X AppKafen :hronue WewCode Application settings allow you to store and retneve property settings and other information for your application dynamically. For example, the application can save a user's color preferences then retrieve them the next time it runs. Learn more about apportion settings,.. Name Type Scope Value ► Hsystem.Windows.Rect | r | * Setting |stnng |* User User zM T \ Рис. 23.2. Свойство для сохранения информации о расположении и размерах окна При наличии такого параметра далее можно легко создать код, который будет автоматически сохранять информацию о размерах и расположении окна, например: Properties.Settings.Default.WindowPosition = win.RestoreBounds; Properties.Settings.Default.Save ();
702 Глава 23. Окна Обратите внимание, что в приведенном коде используется свойство RestoreBound, которое предоставляет корректные размеры (т.е. последний размер окна в обычном состоянии — не свернутом и не развернутом), даже если в текущий момент окно развернуто или свернуто. (Эта удобная функция не была напрямую доступной в Windows Forms и требовала вызова неуправляемой API-функции GetWindowPlacementQ.) Извлечь эту информацию, когда она необходима, тоже легко: try { Rect bounds = Properties.Settings.Default.WindowPosition; win. Top = bounds. Top"; win.Left = bounds.Left; // Восстановить размеры, только если они // устанавливались для окна вручную, if (win.SizeToContent == SizeToContent.Manual) { win.Width = bounds.Width; win.Height = bounds.Height; } } catch { MessageBox. Show ("No settings stored.11); // Нет сохраненных параметров. } Единственным ограничением при таком подходе является необходимость создавать отдельное свойство цдя каждого окна, у которого должна сохраняться информация о расположении и размерах. Если требуется, чтобы информация о расположении сохранялась у множества различных окон, тогда, возможно, лучше будет разработать более гибкую систему. Например, ниже показан вспомогательный класс, который сохраняет информацию о расположении для любого передаваемого ему окна в ключе реестра, хранящего имя этого окна. (Можно использовать и дополнительную идентификационную информацию, если нужно сохранить параметры для нескольких окон, имеющих одинаковые имена.) public class WindowPositionHelper { public static string RegPath = @"Software\MyApp\WindowBounds\"; public static void SaveSize(Window win) { // Создать или извлечь ссылку на ключ, // где будут храниться параметры. RegistryKey key; key = Registry.CurrentUser.CreateSubKey^RegPath + win.Name); key.SetValue("Bounds", win.RestoreBounds.ToString()); key.SetValue("Bounds", win.RestoreBounds.ToString(Culturelnfo.InvariantCulture) ) ; } public static void SetSize(Window win) { RegistryKey key; key = Registry.CurrentUser.OpenSubKey(RegPath + win.Name); if (key ! = null) { Rect bounds = Rect.Parse(key.GetValue("Bounds").ToString()); win.Top = bounds.Top; win.Left = bounds.Left;
Глава 23. Окна 703 // Восстановить размеры, только если они // устанавливались для окна вручную, if (win.SizeToContent == SizeToContent.Manual) { win.Width = bounds.Width; win.Height = bounds.Height; } } } } Чтобы использовать этот класс в окне, нужно вызвать метод Save Size () при закрытии окна и метод SetSizeO при его первом открытии. В каждом случае также обязательно передавать ссылку на окно, которое вспомогательный класс должен проверять. Обратите внимание, что в данном примере у каждого окна должно быть свое значение для свойства Name. Взаимодействие окон В главе 7 рассматривалась модель приложения WPF, и было впервые показано, как окна могут взаимодействовать между собой. Там было видно, что класс Application предоставляет два инструмента для получения доступа к другим окнам: свойство MainWindow и свойство Window. При желании отслеживать окна более специализированным образом — например, путем отслеживания экземпляров определенного класса Window, которые могут представлять документы — разработчик может добавлять в класс Application собственные статические свойства. Разумеется, получение ссылки на другое окно — это только полдела. Также необходимо определиться со способом взаимодействия. В принципе необходимость во взаимодействии окон следует сводить к минимуму, поскольку это излишне усложняет код. Однако если действительно требуется, чтобы значение элемента управления в одном окне изменялось на основе действия, выполняемого пользователем в другом окне, тогда, конечно, лучше создать в целевом окне специальный метод. Это гарантирует правильную идентификацию зависимости и добавит еще один уровень косвенности, упрощающий подгонку изменений в интерфейсе окна. Совет. Если два окна должны взаимодействовать между собой каким-то сложным образом, разрабатываются или развертываются отдельно либо подвержены изменениям, можно двинуться на шаг дальше и формализовать их взаимодействие, создав интерфейс с общедоступными методами и реализовав его в классе своего окна. На рис. 23.3 и 23.4 представлены два примера реализации такой схемы. На рис. 23.3 показано окно, которое вынуждает второе окно обновлять свои данные в ответ на щелчок на кнопке. Это окно не пытается напрямую изменить пользовательский интерфейс второго окна; вместо этого оно полагается на специальный промежуточный метод по имени DoUpdate(). Второй пример, показанный на рис. 23.4, иллюстрирует ситуацию, когда требуется обновление более одного окна. В этом случае действующее окно полагается на более высокоуровневый метод приложения, который вызывает методы, требуемые для обновления других окон (возможно, даже путем прохода по коллекции окон). Этот подход лучше, т.к. он работает на более высоком уровне. В подходе, показанном на рис. 23.3, действующему окну не нужно знать ничего конкретного об элементах управления в получающем окне. В подходе, приведенном на рис. 23.4, производится еще один шаг вперед: здесь действующему окну ничего не нужно знать даже о классе получающего окна.
704 Глава 23. Окна Действующее окно Обновить ЕРЕДАЕТ Получающее окно £ s Обработчик события кнопки ВЫЗЫВАЕТ Класс Window Специальный метод DoUpdate() Класс Window Рис. 23.3. Взаимодействие с одним окном Действующее окно Получающее окно Обновить Л S Обработчик события кнопки Класс Window 2 Специальный метод DoUpdateO Класс Window Специальный метод UpdateAllQ f ВЫЗЫВАЕТ Класс Application эд Специальный метод DoUpdateO Получающее окно ОБНОВЛЯЕТ Класс Window > Рис. 23.4. Взаимодействие одного окна со многими Совет. При взаимодействии между окнами очень часто полезным оказывается метод window.Activate(). Этот метод позволяет передавать команду активизации нужному окну Можно также использовать свойство Window.IsActive для проверки того, является ли данное окно в текущий момент единственным активным окном. В этом примере привязку можно ослабить. Вместо того, что вызывать метод в разных окнах, класс Application может просто возбуждать событие и позволить окнам самостоятельно выбирать, как на него реагировать. На заметку! Поддержка команд WPF может помочь абстрагировать логику приложения. Команды представляют собой специфические для приложений задачи и могут инициироваться любым способом. Подробнее об этом будет рассказываться в главе 9. Примеры, приведенные на рис. 23.3 и 23.4, показывают, как отдельные окна (обычно немодальные) могут инициировать друг в друге различные действия. Однако существу-
Глава 23. Окна 705 ют и более простые модели взаимодействия окон (такие как модели диалоговых окон), а также модели, которые дополняют данную (вроде моделей владения окнами). Именно о них и пойдет речь в следующих разделах. Владение окнами В .NET окно может "владеть" другими окнами. Окна, имеющие окно-владельца, удобно применять для плавающих окон панелей инструментов и окон команд. Одним из примеров такого окна является окно Find and Replace (Найти и заменить) в Microsoft Word. Когда окно-владелец сворачивается, окно, которым оно владеет, тоже автоматически сворачивается. Когда имеющее владельца окно перекрывает окно, которое им владеет, оно всегда отображается сверху. Для поддержки владения окна класс Window предлагает два свойства: Owner и OwnedWindows. Свойство Owner представляет собой ссылку, которая указывает на окно, владеющее текущим окном (если таковое имеется), а свойство OwnedWindows — коллекцию всех окон, которыми владеет текущее окно (опять-таки, если они есть). Настройка владения окна подразумевает просто установку свойства Owner, как показано ниже: // Создание нового окна. ToolWindow winTool = new ToolWindow(); // Назначение текущего окна владельцем. winTool.Owner = this; // Отображение окна, принадлежащего окну-владельцу. winTool.Show(); Окна, обладающие окном-владельцем, всегда отображаются как немодальные. Чтобы удалить такое окно, нужно всего лишь установить для его свойства Owner значение null. На заметку! WPF не включает системы для построения многодокументных приложений (Multiple Document Interface — MDI). Если необходимо более сложное управление окнами, придется разработать его самостоятельно (или приобрести нужный компонент у независимых разработчиков). Окно, имеющее владельца, может само владеть каким-нибудь другим окном, которое, в свою очередь, может владеть еще каким-нибудь окном и т.д. (хотя практическая пригодность такого проектного решения весьма сомнительна). Единственным ограничением является то, что окно не может владеть самим собой, а также то, что два окна не могут владеть друг другом. Модель диалогового окна Часто при отображении окна как модального пользователю предлагается сделать какой-нибудь выбор. Код, отображающий окно, дожидается получения результата этого выбора и затем выполняет на его основании соответствующее действие. Такое проектное решение называется моделью диалогового окна, а само отображаемое модальное окно — диалоговым окном Этот шаблон проектирования легко корректировать за счет создания в диалоговом окне общедоступного свойства. Такое свойство может устанавливаться, когда пользователь сделал выбор в диалоговом окне. Далее диалоговое окно может закрываться, а отображавший его код — проверять установленное для свойства значение и на его ос-
706 Глава 23. Окна нове определять, какое действие должно быть выполнено следующим. (Имейте в виду, что даже когда окно закрывается, объект окна и информация обо всех его элементах управления все равно существует до тех пор, пока ссылающаяся на него переменная не покинет область действия.) К счастью, такая инфраструктура уже отчасти жестко закодирована в классе Window. У каждого окна имеется уже готовое свойство DialogResult, которое может принимать значение true, false или null. Обычно значение true означает, что пользователь выбрал продолжение операции (например, щелкнул на кнопке ОК), а значение false — что он отменил операцию. Лучше всего, когда результаты диалогового окна возвращаются в вызывающий код в виде значения, возвращаемого методом ShowDialogO. Это позволяет создавать, отображать и анализировать результаты диалогового окна с помощью простого кода: DialogWindow dialog = new DialogWindow(); if (dialog.ShowDialog () == true) { // Пользователь разрешил действие. Продолжить1 } else { // Пользователь отменил действие. } На заметку! Использование свойства DialogResult не исключает возможности добавления в окно специальных свойств. Например, исключительно целесообразно использовать свойство DialogResult для информирования вызывающего кода о том, разрешено или отменено было действие, и для предоставления других важных деталей через специальные свойства. В случае обнаружения в свойстве DialogResult значения true вызывающий код далее может проверять эти другие свойства для извлечения необходимой ему информации. Также существует один сокращенный путь. Вместо того чтобы устанавливать свойство DialogResult вручную после выполнения пользователем щелчка на кнопке, можно назначить кнопку принятия (установкой для свойства IsDefault значения true). Тогда щелчок на этой кнопке будет автоматически приводить к установке свойства DialogResult в true. Подобным образом также можно предусмотреть кнопку отмены (за счет установки значения true для свойства IsCancel), в результате чего щелчок на ней будет автоматически приводить к установке свойства DialogResult в Cancel. (О свойствах IsDefault и IsCancel рассказывалось в главе 6 при рассмотрении кнопок.) На заметку! Модель диалогового окна в WPF отличается от той, что предлагается в Windows Forms. Там кнопки не предоставляют свойства DialogResult, из-за чего создавать можно только кнопки по умолчанию и кнопки отмены. Свойство DialogResult может принимать только значения true, false и null (последнее устанавливается для него изначально). Вдобавок щелчок на кнопке не приводит к автоматическому закрытию окна — код, выполняющий эту операцию, необходимо писать отдельно. Общие диалоговые окна Операционная система Windows включает много встроенных диалоговых окон, доступ к которым можно получать через API-интерфейс Windows. Для некоторых из них WPF предоставляет классы-оболочки.
Глава 23. Окна 707 На заметку! Существует веская причина того, что WPF не включает упаковщики для абсолютно всех API-интерфейсов Windows. Одной из задач WPF является отделение от Windows API для получения возможности использования в других средах (например, в браузере) или переноса на другие платформы. Также многие из встроенных диалоговых окон уже начинают устаревать и потому не должны применяться в современных приложениях. Вдобавок в версии Windows 7 предпочтение уже отдается использованию не диалоговых окон, а основанных на задачах панелей и навигации. Наиболее приметным из этих классов является класс System.Windows.MessageBox, который предоставляет статический метод Show(). Этот код можно использовать для отображения стандартных окон сообщений Windows. Ниже показана наиболее распространенная перегруженная версия этого метода: MessageBox.Show("You must enter a name.", "Name Entry Error", MessageBoxButton.OK, MessageBoxImage.Exclamation); // "Требуется ввести имя.", "Ошибка при вводе имени" Перечисление MessageBoxButton позволяет выбирать кнопки, которые должны отображаться в окне сообщений. К числу доступных вариантов относятся OK, OKCancel, YesNo и YesNoCancel. (Менее удобный для пользователя вариант AbortRetrylgnore не поддерживается.) Перечисление MessageBoxImage позволяет выбирать пиктограмму для окна сообщения (Information, Exclamation, Error, Hand, Question, Stop и т.д.). Для класса MessageBox в WPF предусмотрена специальная поддержка функций печати, подразумевающих использование класса PrintDialog (о котором более подробно будет рассказываться в главе 29), а также классов OpenFileDialog и SaveFileDialog в пространстве имен Micro soft. Win 32. Классы OpenFileDialog и SaveFileDialog получают дополнительные функциональные средства (часть из которых наследуются от класса FileDialog). Оба из них поддерживают строку фильтра, которая устанавливает разрешенные расширения файлов. Класс OpenFileDialog также предлагает свойства, которые позволяют проверять выбор пользователя (CheckFileExists) и предоставлять ему возможность выбирать сразу несколько файлов (Multiselect). Ниже показан пример кода, который отображает диалоговое окно OpenFileDialog и выбранные файлы в окне списка после закрытия этого диалогового окна. OpenFileDialog myDialog = new OpenFileDialog (); myDialog.Filter = "Image Files(*.BMP;*.JPG;*.GIF)|*.BMP;*.JPG;*.GIF" + " |A11 files (*.*) |*.*"; myDialog.CheckFileExists = true; myDialog.Multiselect = true; if (myDialog.ShowDialog () == true) { IstFiles.Items.Clear(); foreach (string file in myDialog.FileNames) { IstFiles.Items.Add(file); } } Элементов, позволяющих выбирать цвета, указывать шрифт и просматривать папки, здесь нет (хотя при использовании классов System.Windows.Forms из .NET 2.0 они доступны). На заметку! В предыдущих версиях WPF классы диалоговых окон всегда отображали диалоговые окна в старом стиле Windows XR В WPF 4 они были обновлены и оснащены поддержкой Windows Vista и Windows 7. Это означает то, что при запуске приложения под управлением более новой версии Windows диалоговые окна будут автоматически отображаться в современном стиле.
708 Глава 23. Окна Непрямоугольные окна Окна необычной формы часто являются товарным знаком современных прикладных приложений вроде редакторов фотографий, программ для создания кинофильмов и МРЗ-проигрывателей; скорее всего, они будут встречаться в WPF-приложениях даже более часто. В создании базового приложения нестандартной формы в WPF нет ничего сложного. Однако создание привлекательного профессионально выглядящего окна необычной формы требует немалых усилий — и, нередко, привлечения талантливого дизайнера графики для создания эскизов и фоновой графики. Простое окно нестандартной формы Базовая процедура для создания окна нестандартной формы подразумевает выполнение следующих шагов. 1. Установите для свойства Window.AllowsTransparency значение true. 2. Установите для свойства Window.WindowStyle значение None, чтобы скрыть не клиентскую область окна (рамку голубого цвета). Если этого не сделать, при попытке показать окно появится ошибка InvalidOperationException. 3. Установите для фона (свойства Background) прозрачный цвет (цвет Transparent, значение альфа-канала которого равно нулю). Или же сделайте так, чтобы для фона использовалось изображение, имеющее прозрачные области (с нулевым значением альфа-канала). Эти три шага эффективно удаляют стандартный внешний вид окна. Для обеспечения эффекта окна необычной формы далее необходимо предоставить какое-то непрозрачное содержимое, имеющее нужную форму. Здесь возможны перечисленные ниже варианты. • Предоставить фоновую графику, используя файл такого формата, который поддерживает прозрачность. Например, для фона можно использовать файл PNG. Это простой прямолинейный подход, и он очень удобен, если приходится работать с дизайнерами, которые не разбираются в XAML. Однако из-за того, что окно будет визуализироваться с большим количеством пикселей и более высокими системными параметрами DPI фоновая графика может приобрести искаженный вид. Это также может представлять проблему и в случае разрешения пользователю изменять размеры окна. • Использовать доступные в WPF функции для рисования формы, чтобы создать фон с векторным содержимым. Такой подход исключает потерю качества, какими бы ни были размеры окна и настройка DPI системы. Однако в этом случае наверняка потребуется использовать средство проектирования, поддерживающее XAML, такое как Expression Blend. • Использовать более простой WPF-элемент, имеющий необходимую форму. Например, окно с замечательными скругленными углами можно создать с помощью элемента Border. Такой подход позволяет создавать окна с современным внешним видом в стиле Office без применения каких-либо дизайнерских навыков. Ниже в качестве примера приведен код создания пустого прозрачного окна с применением первого подхода и предоставлением файла PNG для прозрачных областей.
Глава 23. Окна 709 <Window x:Class="Windows.TransparentBackground" ... Windowstуle="None" Al1owsTransparency="True" > <Window.Background> <ImageBrush ImageSource="squares .png"x/ImageBrush> </Window.Background> <Grid> <Grid.RowDefinitions> <RowDef mitionX/RowDef inition> <RowDefinitionx/RowDef inition> <RowDefinition></RowDefinition> <RowDefinitionx/RowDef inition> </Grid.RowDefinitions> <Button Margin=0">A Sample Button</Button> <Button Margin=0" Grid.Row=">Another Button</Button> </Grid> </Window> На рис. 23.5 показано это окно с расположенным за ним окном программы Notepad (Блокнот). Это окно необычной формы (состоящее из окружности и квадрата) имеет не только пробелы, сквозь которые может просматриваться находящееся за ним содержимое, но кнопки, которые выходят за границы изображения и накладываются на прозрачную область, из-за чего кажется, будто бы они существуют сами по себе, без окна. 3 errmsg.txt - Notepad S3 * Copyright Abandoned 1997 тех DataKonsult Ав & Monty program кв & Detron нв This file Is public domain and cones with no warranty of any Iclnd */ 1 "hashchk". "Isamchk", "NO". Г YES , "cant create file '\-.64- "can't create table '%-.f "can't create database ' "can't create database "can't drop database '% "Error dropping databas %d)". "Error dropping databas. %d)". "Error on delete of '%-.i "cant read record 1n sys* "can't get status of '%-.6s "Can't get working directГ "Can't lock file (errno: i "can't open file: '%-.64s! "can't find file: '%-.64s —t^rrno. "Can't read dir of *%-.64s' (errno: Xd)' "Cant change dir to '%-.64s' (errno: %c A Sample Button J Рис. 23.5. Окно нестандартной формы, использующее фоновый рисунок Те, кому приходилось программировать с использованием Windows Forms ранее, наверняка заметят, что окна нестандартной формы в WPF имеют более четкие края, особенно на изгибах. Все дело в том, что WPF умеет выполнять сглаживание между фоном окна и находящимся за ним содержимым для создания более гладкого края. На рис. 23.6 показано другое, более простое окно необычной формы. В этом окне используется элемент Border со скругленными углами для придания окну отчетливого внешнего вида. Компоновка тоже является упрощенной, поскольку исключает случайный выход содержимого за пределы границы, а размер границы может легко изменяться без наличия элемента Viewbox.
710 Глава 23. Окна Рис. 23.6. Окно нестандартной формы, использующее элемент Border В этом окне содержится элемент Grid с тремя строками, которые используются для строки заголовка, строки нижнего колонтитула и размещаемого между ними содержимого. В строке с содержимым находится еще один элемент Grid, который имеет другой фон и может содержать другие необходимые элементы (в текущий момент в нем находится только один единственный элемент TextBlock). Ниже показан код разметки, с помощью которого создается такое окно. <Window x:Class="Windows.ModernWindow" ... AllowsTransparency="True" Windowstyle="None" Васkground="Transparent" > <Border Width="Auto" Height="Auto" Name="windowFrame" BorderBrush="#3 95 984" BorderThickness="l" CornerRadius=,20,30,40" > <Border.Background> <LinearGradientBrush> <GradientBrush.Gradientstops> <GradientStopCollection> <GradientStop Color="#E7EBF7" Offset=.0"/> <GradientStop Color="#CEE3FF" Offset=.5"/> </GradientStopCollection> </GradientBrush.GradientStops> </LinearGradientBrush> </Border.Background> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"x/RowDefinition> <RowDef in itionx/RowDef inition> <RowDefinition Height="Auto"x/RowDefinition> </Grid.RowDefinitions> <TextBlock Text="Title Bar" Margin="l" Padding="x/TextBlock> <Grid Grid.Row="l" Background="#B5CBEF"> <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="White" FontSize=0">Content Goes Here</TextBlock> </Grid> <TextBlock Grid.Row=" Text="Footer" Margin="l,10,1,1" Padding=" HorizontalAlignment="Center"></TextBlock> </Grid> </Border> </Window>
Глава 23. Окна 711 Для завершения внешнего вида этого окна осталось создать только кнопки, имитирующие размещаемые в правом верхнем углу стандартные кнопки для разворачивания, сворачивания и закрытия окна. Прозрачные окна с содержимым необычной формы В большинстве случаев фиксированная графика в WPF для создания окон необычной формы не используется. Вместо этого в таких окнах просто применяется совершенно прозрачный фон, на котором затем размещается уже имеющее нужную форму содержимое. (Примером этого является показанная на рис. 23.5 кнопка, которая "нависает" над совершенно прозрачной областью.) Преимущество такого подхода заключается в том, что он является модульным. Окно может состоять из множества отдельных компонентов, представляющих собой первоклассные элементы WPF. Но даже еще более важно то, что такой подход позволяет пользоваться другими функциональными возможностями WPF и создавать по-настоящему динамические пользовательские интерфейсы. Например, он позволяет создавать содержимое необычной формы с возможностью изменения его размеров или применять анимацию для обеспечения непрерывно выполняющихся эффектов прямо внутри окна. Сделать такое очень не просто, если графика находится в одном статическом файле. На рис. 23.7 показан пример. Здесь окно содержит элемент Grid с единственной ячейкой. Эту ячейку совместно используют два элемента. Первый — элемент Path, который прори- This is a balloon-shaped совывает границу окна нестандартной формы и заливает ее window, градиентным узором, а второй — контейнер макета, в котором находится предназначенное для окна содержимое, перекрывающее элемент Path. В данном случае в качестве контейнера макета служит элемент StackPanel, нов принципе это может быть и какой-то другой элемент (например, еще один Grid или Canvas для абсолютного позиционировании на основе РиСш ^ Окно нестан- координат). В этом элементе StackPanel находится кнопка ДаРТН0И формы, исполь- , w, зующее элемент Path закрытия (со знакомым значком X) и текст. На заметку! Хотя на рис. 23.5 и 23.6 показаны разные примеры, они являются взаимозаменяемыми. То есть любой из них можно создать как с помощью подхода, подразумевающего использование фона, так и с помощью подхода, подразумевающего прорисовывание формы. Однако подход с прорисовыванием формы обеспечивает большую гибкость, если необходимо иметь возможность динамически изменять форму в будущем, и наилучшее качество, если требуется возможность изменять размер окна. Ключевым компонентом в данном примере является элемент Path, который создает фон. Он представляет собой простую векторную фигуру, которая состоит из ряда линий и дуг. Ниже приведен весь необходимый для его создания код разметки. <Path Stroke="DarkGray" StrokeThickness="> <Path.Fill> <LinearGradientBrush StartPoint=.2,0" EndPoint=.8,1" > <LinearGradientBrush.GradientStops> <GradientStop Color="White" Offset="></GradientStop> <GradientStop Color="White" Offset=.45"></GradientStop> <GradientStop Color="LightBlue" Offset=.9"></GradientStop> <GradientStop Color="Gray" Offset="l"></GradientStop> </LinearGradientBrush.GradientStops> </LinearGradientBrush> </Path.Fill>
712 Глава 23. Окна <Path.Data> <PathGeometry> <PathGeometry.Figures> <PathFigure StartPoint=0,0" IsClosed="True"> <LineSegment Point=40,0"/> <ArcSegment Point=60,20" Size=0,20" SweepDirection="Clockwise"/> <LineSegment Point=60,60"/> <ArcSegment Point=40,80" Size=0,20" SweepDirection="Clockwise"/> <LineSegment Point=0,80"/> <LineSegment Point=0,130"/> <LineSegment Point=0,80"/> <LineSegment Point=0,80"/> <ArcSegment Point=,60" Size=0,20" SweepDirection="Clockwise"/> <LineSegment Point=, 2"/> <ArcSegment Point=0,0" Size=0,20" SweepDirection="Clockwise"/> </PathFigure> </PathGeometry.Figures> </PathGeometry> </Path.Data> </Path> В текущий момент элемент Path имеет фиксированный размер (так же, как и окно), однако его размер несложно сделать изменяемым, поместив элемент в контейнер Viewbox, о котором говорилось в главе 12. Еще улучшить этот пример можно, придав кнопке для закрытия окна более убедительный внешний вид — например, с помощью векторного значка X, прорисовываемого на красной поверхности. Хотя для представления кнопки и обработки связанных с ней событий мыши можно было бы воспользоваться и отдельным элементом Path, лучше поступить следующим образом: изменить стандартный элемент управления Button путем применения шаблона (о чем более подробно будет рассказываться в главе 17) и затем сделать элемент Path, прорисовывающий значок X, частью этой измененной кнопки. Перемещение окон нестандартной формы Одним из ограничений окон нестандартной формы является то, что в них отсутствует неклиентская область со строкой заголовка, позволяющая пользователю легко перетаскивать окно по рабочему столу. В Windows Forms это причиняло определенные неудобства — приходилось либо обеспечивать реакцию на события мыши вроде MouseDown, MouseUp и MouseMove и перемещать окно вручную при выполнении пользователем щелчка и перетаскивания, либо переопределять метод WndProcO и обрабатывать низкоуровневое сообщение WMNCHITTEST. В WPF эта задача решается гораздо легче. Здесь в любое время можно инициировать режим перетаскивания окна путем вызова метода Window.DragMove(). Итак, для того чтобы позволить пользователю перетаскивать окно необычной формы, которое было показано в предыдущем примере, необходимо просто добавить и обработать для окна (или того элемента в этом окне, который затем будет выполнять ту же роль, что и строка заголовка) событие MouseLef tButtonDown: <TextBlock Text="Title Bar" Margin="l" Padding=" MouseLeftButtonDown="titleBar_MouseLeftButtonDown"></TextBlock> В обработчик события потребуется добавить только одну строку кода: private void titleBar_MouseLeftButtonDown (object sender, MouseButtonEventArgs e) { this.DragMove(); }
Глава 23. Окна 713 Теперь окно будет следовать за курсором мыши по экрану до тех пор, пока пользователь не отпустит кнопку мыши. Изменение размеров окон нестандартной формы Изменение размеров окна нестандартной формы — задача не из простых. Если форма окна хотя бы отчасти напоминает прямоугольник, наиболее простым подходом будет добавить в правом нижнем углу элемент захвата и изменения размера путем установки для свойства ResizeMode значения CanResizeWithGrip. Однако при размещении такого элемента предполагается, что окно имеет прямоугольную форму. Например, в случае создания окна с эффектом скругленных краев за счет использования объекта Border, как было показано на рис. 23.6, такой прием может и сработать. Элемент захвата и изменения размера появится в правом нижнем углу и, в зависимости от того, насколько скругленным был сделан этот угол, разместится в пределах поверхности окна, которому принадлежит. Но в случае создания окна более экзотической формы с применением, например, элемента Path (см. рис. 23.7), такой подход точно не сработает — элемент захвата и изменения размера "зависнет" в пустой области рядом с окном. Если добавление элемента захвата и изменения размера не подходит для окна данной формы или если требуется разрешить пользователю изменять размеры окна путем перетаскивания его краев, придется приложить немного дополнительных усилий. В принципе в таком случае существует два основных подхода. Первый — использовать .NET-функцию вызова платформы (P/Invoke) для отправки сообщения Win32, изменяющего размер окна, а второй — просто отслеживать позицию курсора мыши при перетаскивании пользователем окна в одну сторону и изменять размер вручную установкой свойства Width. Ниже рассматривается пример применения второго подхода. Прежде чем воспользоваться любым из этих подходов, нужно придумать способ для определения момента наведения пользователем курсора мыши на край окна. В WPF это легче всего сделать, разместив вдоль каждого края окна специальный элемент. Быть видимым этому элементу вовсе необязательно — на самом деле он даже может быть полностью прозрачным и позволять окну проглядывать сквозь него. Его единственная задачей будет перехват событий мыши. Лучше всего на эту роль подходит элемент Rectangle толщиной в 5 единиц. Ниже приведен пример размещения этого элемента так, чтобы он позволял изменять размер с правой стороны того окна со скругленными углами, которое было показано на рис. 23.6. <Grid> <Rectangle Grid.RowSpan=" Width=" VerticalAlignment="Stretch" HorizontalAlignment="Right" Cursor="SizeWE" Fi11="Transparent" MouseLeftButtonDown="window_initiateWiden" MouseLeftButtonUp="window_endWiden" MouseMove="window_Widen"x/Rectangle> </Grid> В данном случае элемент Rectangle размещается в верхней строке, но для свойства RowSpan получает значение 3. Благодаря этому он растягивается на все три строки и занимает всю правую сторону окна. В свойстве Cursor указывается тот курсор мыши, который должен появляться при наведении мыши на этот элемент. В данном случае это курсор изменения размера "запад-восток" — он имеет знакомую всем форму двухконечной стрелки, которая указывает вправо и влево. Обработчики событий элемента Rectangle переключают окно в режим изменения размера, когда пользователь щелкает на краю. Здесь необходимо захватить мышь для обеспечения уверенности в том, что события будут продолжать поступать даже в случае пере-
714 Глава 23. Окна мещения мыши за счет перетаскивания с поверхности прямоугольника в какую-нибудь сторону. Захват мыши снимается, когда пользователь отпускает левую кнопку мыши. bool lsWiden = false; private void window_initiateWiden(object sender, MouseEventArgs e) { lsWiden = true; } private void window_Widen(object sender, MouseEventArgs e) { Rectangle rect = (Rectangle)sender; if (lsWiden) { rect.CaptureMouse(); double newWidth = e.GetPosition(this).X + 5; if (newWidth > 0) this.Width = newWidth; private void window_endWiden (object sender, MouseEventArgs e) { lsWiden = false; // Обеспечение снятия захвата. Rectangle rect = (Rectangle)sender; rect.ReleaseMouseCapture() ; } На рис. 23.8 показано, как этот код выглядит в действии. Footer Рис. 23.8. Изменение размеров окна нестандартной формы Шаблон элемента управления для окон С помощью показанного до сих пор кода можно довольно легко создавать окна особой формы. Однако если нужно использовать новый стандарт окон для всего приложения, придется вручную стилизовать каждое окно, оснащая его границей одинаковой формы, областью заголовка, кнопками для закрытия и т.д. В такой ситуации удобнее поместить код разметки в шаблон элемента управления и затем использовать этот шаблон в отношении любого окна. (Шаблоны элементов управления подробно рассматривались в главе 17.)
Глава 23. Окна 715 Первым делом нужно изучить предлагаемый по умолчанию шаблон элемента управления для класса Window. В основном он довольно прост, но включает в себя одну, возможно, неожиданную деталь — элемент AdornerDecorator. Этот элемент создает поверх остальной части клиентского содержимого окна специальную область рисования, называемую декоративным слоем (adorner layer). Элементы управления WPF могут использовать этот слой для прорисовывания содержимого, которое должно налагаться поверх других элементов. К такому содержимому относятся небольшие графические индикаторы, отражающие фокус, помечающие ошибки проверки достоверности и указывающие путь при операциях перетаскивания. При создании специального окна необходимо позаботиться о наличии декоративного слоя, чтобы использующие его элементы управления могли продолжать функционировать. После этого можно приступать к определению базовой структуры, которую должен иметь шаблон окна. Ниже показан стандартный пример разметки, в которой предполагается создание окна, подобного показанному на рис. 23.8. <ControlTemplate x:Key="CustomWindowTemplate" TargetType="{x:Type Window}"> <Border Name="windowFrame" ... > <Grid> <Grid.RowDefinitions> <RowDefmition Height="Auto"></RowDefinition> <RowDef mitionx/RowDef inition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions> < '-- Строка заголовка. --> <TextBlock Text="{TemplateBinding Title}" FontWeight="Bold"x/TextBlock> <Button Style="{StaticResource CloseButton}" HorizontalAlignment="Right"></Button> <'-- Содержимое окна. --> <Border Grid.Row="l"> <AdornerDecorator> <ContentPresenterx/ContentPresenter> </AdornerDecorator> </Border> <'-- Нижний колонтитул. --> <ContentPresenter Grid.Row=" Margin=0" HorizontalAlignment="Center" Content="{TemplateBinding Tag}"></ContentPresenter> <!-- Элемент захвата и изменения размера. --> <ResizeGrip Name="WindowResizeGrip" Grid.Row=" НогizontalAlignment="Right" VerticalAlignment="Bottom" Visibility="Collapsed" IsTabStop="False" /> <!-- Невидимые прямоугольники, позволяющие изменять размеры путем перетаскивания. --> <Rectangle Grid.Row="l" Grid.RowSpan=" Cursor="SizeWE" VerticalAlignment="Stretch" HorizontalAlignment="Right" Fill="Transparent" Width="x/Rectangle> <Rectangle Grid.Row=" Cursor="SizeNS" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" Fill="Transparent" Height="x/Rectangle> </Grid> </Border> <ControlTemplate.Triggers> <Trigger Property="ResizeMode" Value="CanResizeWithGrip">
716 Глава 23. Окна <Setter TargetName="WindowResizeGrip" Property="Visibility" Value="Visible"></Setter> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> Элементом самого верхнего уровня в данном шаблоне является объект Border, который отвечает за рамку окна. Внутри него размещается элемент Grid с тремя строками. Содержимое элемента Grid распределяется следующим образом. • В самой верхней строке размещается строка заголовка, которая состоит из обычного элемента TextBlock, отображающего заголовок окна и кнопку закрытия. Привязка шаблона извлекает заголовок окна из свойства Window.Title. • В средней строке размещается вложенный элемент Border с остальной частью содержимого окна. Это содержимое вставляется с использованием элемента ContentPresenter. Элемент ContentPresenter помещается в оболочку AdornerDecorator, что гарантирует размещение поверх него декоративного слоя. • В третьей строке размещается еще один элемент ContentPresenter. Он извлекает свое содержимое не из свойства Window.Content с помощью стандартной привязки, а получает его явно из свойства Window.Tag. Обычно это содержимое представляет собой обычный текст, но может включать любой другой элемент. На заметку! Свойство Tag используется потому, что в классе Window не предусмотрено свойства, предназначенного для указания текста нижнего колонтитула. Другим возможным вариантом является создание специального класса, унаследованного от Window, и добавление к нему дополнительного свойства Footer. • В третьей строке также размещается элемент захвата и изменения размера. Он отображается триггером, когда свойство Window.ResizeMode установлено в CanResizeWithGrip. • И, наконец, последними идут два невидимых прямоугольника, которые размещаются вдоль правого и нижнего края элемента Grid (и, соответственно, самого окна). Они позволяют пользователю изменять размеры окна щелчком и перетаскиванием. В этом коде разметки не показаны малоинтересные стили элемента захвата и изменения размера (просто создает небольшой узор из точек) и кнопки закрытия окна (рисует небольшой символ X в красном квадрате). Разметка также не включает детали форматирования наподобие градиентной кисти, отвечающей за прорисовку фона, и свойств, отвечающих за создание скругленных углов границы окна. Полный код разметки можно найти в коде примеров для настоящей главы. Шаблон окна применяется с использованием простого стиля. Стиль также устанавливает три ключевых свойства класса Window, которые делают его прозрачным. Это позволяет создать границу окна и фон с помощью элементов WPF. <Style x:Key=,,CustomWindowChrome" TargetType=" {x: Type Window}"> <Setter Property="AllowsTransparency" Value="True"></Setter> <Setter Property="WindowStyle" Value=MNone"></Setter> <Setter Property="Background" Value="Transparent"></Setter> <Setter Property="Template" Value="{StaticResource CustomWindowTemplate}"></Setter> </Style>
Глава 23. Окна 717 Теперь все готово к использованию специального окна. Например, можно создать окно, которое устанавливает стиль и отображает базовое содержимое: <Window х:Class="ControlTemplates.CustomWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="CustomWindowTest" Height=00" Width=00" Tag="This is a custom footer" Style="{StaticResource CustomWindowChrome}"> <StackPanel Margin=0"> <TextBlock Margin=">This is a test.</TextBlock> //Это - тест. <Button Margin=" Padding=">0K</Button> </StackPanel> </Window> Здесь имеется лишь одна проблема. В настоящий момент окно не обладает большей частью базового поведения, которое требуется окнам. Например, его нельзя ни перетаскивать по рабочему столу, ни изменять в размерах, ни закрывать с помощью соответствующей кнопки. Для выполнения этих действий необходим соответствующий код. Существует два возможных способа для добавления необходимого кода: расширение примера до специального класса, производного от Window, либо создание класса отделенного кода для словаря ресурсов. Подход с созданием специального класса лучше с точки зрения инкапсуляции и позволяет расширять общедоступный интерфейс окна (например, добавлять полезные методы и свойства, пригодные для использования в приложении). Подход с созданием класса отделенного кода является относительно облегченной альтернативой и позволяет расширить возможности шаблона элемента управления, сохранив за приложением возможность продолжать пользоваться базовыми классами элементов управления. Именно этот подход и будет применяться. Создание класса отделенного кода для словаря ресурсов уже демонстрировалось в главе 17. После создания файла кода легко добавить необходимый код обработки событий. Единственная особенность связана с тем, что этот код будет выполняться в объекте словаря ресурсов, а не внутри объекта специального окна. Это означает, что использовать слово this для доступа к текущему окну нельзя. К счастью, существует простая альтернатива — свойство FrameworkElement.TemplatedParent. Например, для обеспечения возможности перетаскивания окна необходимо позаботится о перехвате события мыши в строке заголовка и запуске операции перетаскивания. Ниже показан переделанный элемент TextBlock, в котором подключается соответствующий обработчик событий: <TextBlock Margin="l" Padding=" Text="{TemplateBinding Title}" FontWeight="Bold" MouseLeftButtonDown="titleBar_MouseLeftButtonDown"x/TextBlock> Теперь можно добавить следующий обработчик событий в класс отделенного кода для словаря ресурсов: private void titleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { Window win = (Window) ( (FrameworkElement)sender) .TemplatedParent; win.DragMove(); } После этого можно аналогичным образом добавить код обработки событий для кнопки закрытия и прямоугольников, отвечающих за изменение размеров. Полный код разметки словаря ресурсов вместе с шаблоном, который можно применять к любому окну, включен в примеры для этой главы.
718 Глава 23. Окна Разумеется, еще осталось множество мелких деталей, которые понадобится реализовать, чтобы данное окно стало достаточно привлекательным для использования в современном приложении. Однако была продемонстрирована последовательность шагов, которые должны быть выполнены для построения сложного шаблона элемента управления с кодом, с получением результата, который в предшествующих платформах пользовательских интерфейсов требовал разработки специального элемента управления. Эффект Aero Glass Удивительно, но в WPF по-прежнему не предлагается встроенного способа для использования эффекта Aero Glass (прозрачность Аего), который впервые появился в Windows Vista и продолжает поддерживаться в Windows 7. Этот эффект обеспечивает окна размытыми стеклянными рамками, через которые можно видеть другие окна и их содержимое. Прежде чем двигаться дальше, важно отметить, что в среде Windows XP эффект Аего Glass работать не будет. Поэтому во избежание проблем понадобится писать код, проверяющий версию операционной системы и аккуратно отключающий этот эффект, когда он не поддерживается. Самым простым способом для определения, в среде ли Windows Vista происходит выполнение, является чтение статического свойства OSVersion класса System.Environment: if (Environment.OSVersion.Version.Major >= 6) { // Функциональные возможности Vista поддерживаются. } В приложениях, выполняющихся под управлением Windows Vista и Windows 7, эффект Аего Glass появляется автоматически в неклиентской области окна. В случае если приложение отображает стандартное окно со стандартной рамкой в WPF и выполняется на поддерживающем интерфейс Аего компьютере, окно получается с привлекательной полупрозрачной рамкой. На заметку! Для поддержки интерфейса Аего на компьютере должна быть установлена современная версия Windows, отличная от Windows XR Windows Vista Home Basic и Windows 7 Starter. Кроме того, должна поддерживаться соответствующая видеокарта, а также включена функция Aero Glass. В некоторых приложениях эффект Аего Glass распространяется и на клиентскую область окна. К числу таких приложений относится Internet Explorer, где стеклянный эффект захватывает и строку адреса, а также проигрыватель Windows Media, где стеклянный эффект применяется по отношению к элементам управления воспроизведением. Такие чудеса можно творить и в своих собственных приложениях, учитывая два описанных ниже ограничения. • Область размытого стекла всегда начинается с краев окна. Это означает, что создать стеклянную "вставку" где-нибудь в середине окна нельзя. Однако добиться такого эффекта можно, и просто разместив на стеклянной рамке полностью непрозрачные элементы WPF. • Не стеклянная область внутри окна всегда определяется как прямоугольник. В WPF нет классов для реализации такого эффекта. Вместо этого здесь требуется вызывать функцию DwmExtendFrameIntoClientArea()H3 Win32 API. (Префикс Dwm означает Desktop Window Manager — диспетчер окон рабочего стола, который отвечает за управление данным эффектом.) Вызов этой функции позволяет расширить рамку так,
Глава 23. Окна 719 чтобы она захватывала и клиентскую область окна, делая толще какой-то один или все края. Ниже показано, как импортировать функцию DwmExtendFramelntoClientAreaO, чтобы получить возможность ее вызова внутри приложения: [Dlllmport("DwmApi.dll")] public static extern int DwmExtendFramelntoClientArea( IntPtr hwnd, ref Margins pMarlnset); Также необходимо определить фиксированную структуру Margins: [StructLayout(LayoutKind.Sequential)] public struct Margins { public int cxLeftWidth; public int cxRightWidth; public int cyTopHeight; public int cyBottomHeight; } Здесь имеется потенциальная сложность. Как упоминалось ранее, в системе измерений WPF используются независимые от устройства единицы, размер которых устанавливается на основе настройки DPI системы. Однако в функции DwmExtendFramelntoClientAreaO применяются физические пиксели. Для гарантии того, что элементы WPF будут присоединяться к расширенной стеклянной рамке независимо от параметров DPI системы, эта настройка DPI должна учитываться при расчетах. Самой простой способ извлечь значения настройки DPI системы — воспользоваться классом System.Drawing.Graphics, который имеет два свойства — DpiX и DpiY, представляющие DPI для окна. Ниже показан вспомогательный метод, который берет дескриптор окна и набор единиц WPF и возвращает объект Margin с откорректированными соответствующим образом размерами в физических пикселях. public static Margins GetDpiAdjustedMargins(IntPtr windowHandle, int left, int right, int top, int bottom) { // Извлечение настройки DPI системы. System.Drawing.Graphics g = System.Drawing.Graphics.FromHwnd(windowHandle); float desktopDpiX = g.DpiX; float desktopDpiY = g.DpiY; // Установка полей. VistaGlassHelper.Margins margins = new VistaGlassHelper.Margins(); margins.cxLeftWidth = Convert.ToInt32 (left * (desktopDpiX / 96)); margins.cxRightWidth = Convert.ToInt32(right * (desktopDpiX / 96)); margins.cyTopHeight = Convert.ToInt32(top * (desktopDpiX / 96)); margins.cyBottomHeight = Convert.ToInt32(right * (desktopDpiX / 96)); return margins; } На заметку! К сожалению, класс System.Drawing.Graphics является частью Windows Forms. Поэтому для получения доступа к нему понадобится добавить ссылку на сборку System. Drawing.dll. Последнее, что осталось сделать — применить поля в отношении окна с помощью функции DwmExtendFramelntoClientAreaO. В следующем коде показан объединенный вспомогательный метод, который принимает параметры полей WPF и ссылку на окно WPF, после чего извлекает Win32-дескриптор окна, корректирует поля и пытается расширить стеклянную рамку.
720 Глава 23. Окна public static void ExtendGlass(Window win, int left, int right, int top, int bottom) { // Получение ДОт32-дескриптора для окна WPF. WindowInteropHelper windowlnterop = new WindowInteropHelper(win); IntPtr windowHandle = windowlnterop.Handle; // Подгонка полей таким образом, чтобы в них // учитывалась настройка DPI системы. Margins margins = GetDpiAdjustedMargins(windowHandle, left, right, top, bottom); // Расширение стеклянной рамки. int returnVal = DwmExtendFramelntoClientArea(windowHandle, ref margins); if (returnVal < 0) throw new NotSupportedException ("Operation failed.11); // Выполнить операцию не удалось } } В приводимом в настоящей главе примере кода все эти "ингредиенты" упаковываются в единственный класс по имени VistaGlassHelper, который может вызываться из любого окна. Для того чтобы код работал, он должен вызываться перед отображением окна. Идеальной возможностью для этого является событие Window.Loaded. Кроме того, нужно не забыть установить свойство Background в Transparent, чтобы стеклянная рамка просматривалась сквозь поверхность рисования WPF. На рис. 23.9 показан пример утолщения верхнего края стеклянной рамки. Л errmsg.txt - Notepad | Ш ШШ Format V.ew Help :/• Copyright Abandoned 1997 !& Monty Program KB & Detron This file is public domal J no warranty of any kind */ "hashchfc". :samchk\ "NO", "YES", "can't create file 'X-.64s' "can't create table "*-.64s1 "can't create database '%-.ф> "cant create database '%- exists". "can't drop database Л-.644 doesn't exist", "error dropping database (caV -.64s\ errno: Xd)", "Error dropping database (caj -.64s*, errno: Xd)". ■ VistaGlassWindow Some content that s docked to the top. A Button A Button Рис. 23.9. Расширение стеклянной рамки При создании такого окна содержимое в верхней части группируется в один элемент Border. Это позволяет замерить высоту границы (т.е. элемента Border) и использовать это значение для расширения стеклянной рамки. (Конечно, стеклянная рамка устанавливается только один раз, а именно — при первом создании окна. В случае изменения содержимого или размеров окна и увеличения или сжатия элемента Border этот элемент больше не будет присоединяться к стеклянной рамке.) Ниже показан весь необходимый для создания такого окна код разметки. <Window x:Class="Windows.VistaGlassWindow2" Loaded="window_Loaded11 Background="Transparent11 >
Глава 23. Окна 721 <Grid > <DockPanel Name="mainDock" LastChildFill="Truell> < '-- Элемент Border используется для вычисления визуализируемой высоты с полями. Содержимое topBar будет отображаться в расширенной стеклянной рамке. --> <Border Name="topBar" DockPanel.Dock="Top"> <StackPanel> <TextBlock Padding=ll5">Some content that's docked to the top. </TextBlock> <Button Margin=M5" Padding=M>A Button</Button> </StackPanel> </Border> <Border Background="White"> <StackPanel Margin="> <TextBlock Margin=,,5">Some text. </TextBlock> <Button Margin=M Padding=M5">A Button</Button> </StackPanel> </Border> </DockPanel> </Grid> </Window> Обратите внимание, что для фона второго элемента Border в этом окне, в котором размещается остальное содержимое, должен быть явно установлен белый цвет. В противном случае эта часть окна окажется абсолютно прозрачной. По этой же причине у второго элемента Border не должно быть никаких полей, иначе вокруг него будет отображаться прозрачная каемка. Когда окно загружается, оно вызывает метод ExtendGlassO и передает ему новые координаты. Обычно стеклянная рамка имеет 5 единиц в ширину, но в показанном далее коде ее верхний край утолщается. private void window_Loaded(object sender, RoutedEventArgs e) { try { VistaGlassHelper.ExtendGlass(this, 5, 5, (int)topBar.ActualHeight + 5, 5) ; } catch { // В случае выполнения этого кода в Windows XP // генерируется исключение DllNotFoundException. // В случае если не удается выполнение вызова // DwmExtendFramelntoClientArea (), генерируется // исключение NotSupportedException. this.Background = Brushes.White; } } При желании расширить стеклянный край так, чтобы он захватывал все окно целиком, следует просто передать для каждой стороны в качестве параметра поля значение -1. На рис. 23.10 показано, как будет выглядеть окно в таком случае. При использовании эффекта Aero Glass также необходимо учитывать и то, как внешний вид содержимого будет меняться в случае размещения окна поверх других фоновых изображений. Например, при размещении в стеклянной области окна текста черного цвета, этот текст будет легче читать на светлом фоне, чем на темном (хотя разборчивым он будет в обоих случаях). Для повышения удобочитаемости текста и обеспечения четкого отображения содержимого на разных фонах принято добавлять какой-нибудь эффект подсвечивания.
722 Глава 23. Окна ■ Copyright Abandoned 199. & Monty Program KB & Detror» This file 1s public domal NO WARRANTY of any kind ", "hasbchk", sajnchk,,> "no" , '"YES". "cant "can' "can* "can' exists" "can t drop database Л-.644 doesn't exist", !"Error dropping database (ca !-.64s\ errno: \d)", "Error dropping database (c» -.64s'. errno: \d)". create file %-.64s' create table \-.64s create database '%-. create database '%-. L ■ VistaGlassWindow This is sone text "his is a Button This is a CheckBox —__ Рис. 23.10. Окно со стеклянным эффектом по всей поверхности Например, черный цвет с белой подсветкой будет выглядеть одинаково разборчиво как на светлом, так и на темном фоне. Windows Vista предлагает неуправляемую функцию для рисования эффекта подсветки, которая называется DrawThemeTextEx (). Однако у WPF имеется множество собственных приемов, позволяющих получать точно такой же (если не лучше) результат. Двумя наиболее яркими примерами таких приемов являются использование декоративной кисти для закрашивания текста и добавление к тексту растрового эффекта. (Оба из них будут более подробно рассматриваться в главе 12.) Другие интересные функции DWM В примере использования эффекта Aero Glass было показано, как с помощью функции DwmExtendFramelntoClientAreaO можно создавать утолщенный стеклянный край (или даже вообще полностью стеклянное окно). Однако DwmExtendFramelntoClientAreaO является далеко не единственной полезной функцией в API-интерфейсе Windows. Множество других API-функций, имена которых начинаются с Dwm, позволяют взаимодействовать с диспетчером окон рабочего стола. Например, с помощью функции DwmlsCompositionEnabledO можно проверить, включен ли эффект Aero Glass, а функция DwmEnableBlurBehindWindow() позволяет применить стеклянный эффект к конкретной области в окне. Кроме того, также существует несколько функций, которые позволяют получать живое миниатюрное представление других приложений. Узнать самое необходимое обо всех этих функциях можно в MSDN-документации по диспетчеру окон рабочего стола, доступной по адресу http://tinyurl.com/333glv. Программирование для панели задач Windows 7 Хотя WPF не предлагает непосредственной поддержки эффекта Aero Glass, в отношении одной из прочих ключевых нововведений Windows 7 — переделанной панели задач — дела обстоят гораздо лучше. В WPF имеется не только базовая поддержка для списков часто используемых элементов (также называемых списками быстрого перехода или просто списками переходов (Jump Lists)), но и глубокая интеграция для связанных с панелью задач функций, которые позволяют управлять значками в панели задач и конфигурировать поведение отображаемых в ней для предварительного просмотра миниатюр. В следующих разделах показано, как использовать эти функции.
Глава 23. Окна 723 На заметку! Функции панели задач Windows 7 можно без опаски применять в приложениях, предназначенных для использования в более ранних версиях Windows. Любая разметка или код, предназначенный для взаимодействия с панелью задач Windows 7, безболезненно игнорируется в других операционных системах. Применение списков часто используемых элементов Списки часто используемых элементов (Jump Lists) представляют собой удобные мини-меню, которые открываются при выполнении щелчка правой кнопкой мыши на кнопке в панели задач. Они отображаются как для приложений, которые выполняются в текущий момент, так и для тех, которые в текущий момент не выполняются, но имеют закрепленные за ними кнопки в панели задач. Обычно такие списки предоставляют быстрый способ для открытия документа, который принадлежит соответствующему приложению, например, недавнего документа в Word или часто проигрываемой композиции в проигрывателе Windows Media. В случае некоторых программ, однако, они используются более изощренно и позволяют выполнять задачи, характерные для этих приложений. Поддержка отображения списков недавних документов В Windows 7 список часто используемых элементов добавляется для каждого работающего с документами приложения, при условии, что это приложение зарегистрировано для обработки определенного типа файлов. Например, в главе 7 был показан пример создания приложения с одним экземпляром (SinglelnstanceApplication), которое регистрировалось для обработки файлов типа .test Do с. Если запустить это приложение и щелкнуть правой кнопкой мыши на представляющей его кнопке в панели задач, для него откроется список недавно открывавшихся документов, показанный на рис. 23.11. Recent js| sample3.testDoc 1Й| sample2.testDoc 2l] sample! .testDoc 1 samplel (D:\Code\Pro WPRChapterOASir*gldnstanceApplication\bin\ \ *4 Debug) J v» Pin tn»s program to taskbar Q Close window , r « 1 ,ц§)у m L*e w* «, Рис. 23.11. Автоматически сгенерированный список недавно использовавшихся документов Щелчок на одном из недавних документов в таком автоматически сгенерированном списке приводит к запуску еще одного экземпляра приложения с передачей ему полного пути к документу в виде аргумента командной строки. Естественно, можно написать код, изменяющий это поведение, если оно не устраивает. Например, возьмем приложение SinglelnstanceApplication из главы 7. В случае открытия какого-то документа из его списка часто используемых элементов новый экземпляр передает путь к файлу работающему в текущий момент приложению и затем завершает свою работу. В конечном итоге получается, что единственное приложение обрабатывает все файлы, как бы они не открывались — изнутри приложения или через список часто используемых элементов. Как упоминалось ранее, для получения поддержки, связанной с отображением списка недавних документов, приложение должно быть зарегистрировано для обработки
724 Глава 23. Окна соответствующего типа файлов. Для этого существуют два простых способа. Первый заключается в добавлении всех связанных с этим деталей в системный реестр Windows с использованием кода, как было описано в главе 7. Второй способ предусматривает выполнение той же операции вручную с помощью проводника Windows. Ниже перечислены шаги, которые понадобится выполнить во втором случае. 1. Щелкните правой кнопкой мыши на соответствующем файле (например, с расширением .test Doc). 2. Выберите в контекстном меню пункт Open With^Choose Default Program (Открыть с помощью^ Выбрать программу) для отображения диалогового окна Open With (Выбор программы). 3. Щелкните на кнопке Browse (Обзор), найдите .ехе-файл нужного приложения и выберите его. 4. Дополнительно снимите отметку с флажка Always use selected program to open this kind of file (Использовать ее для всех файлов такого типа). Быть используемой по умолчанию программой для того, чтобы получать поддержку отображения списка недавних документов, приложению вовсе не обязательно. 5. Щелкните на кнопке ОК. При регистрации типа файлов необходимо помнить о нескольких моментах. • При создании регистрации типа файлов Windows должен предоставляться точный путь к исполняемому файлу приложения. Таким образом, это нужно делать после размещения приложения в соответствующем месте, иначе при каждом перемещении файла приложения придется выполнять регистрацию заново. • Не беспокойтесь о захвате общих типов файлов. Если не делать приложение используемой по умолчанию программой для обработки данного типа файлов, то на функционировании Windows это никак не скажется. Например, вполне допускается зарегистрировать приложение для обработки файлов .txt. Когда пользователь откроет в нем файл .txt, он появится в списке недавних документов для этого приложения. Аналогично, если пользователь выберет документ в списке часто используемых элементов для данного приложения, Windows автоматически запустит это приложение. Однако двойной щелчок на любом файле .txt в окне проводника Windows все равно приводит к запуску приложения, которое назначено используемой по умолчанию программой для файлов .txt (обычно это приложение "Блокнот"). • При тестировании списков часто используемых элементов в Visual Studio понадобится отключить выполнение обслуживающего процесса Visual Studio. В случае выполнения этого процесса Windows проверяет типы файлов, зарегистрированные за обслуживающим процессом (скажем, YourApp.vshost.exe), а не самим приложением (YourApp.exe). Во избежание этой проблемы скомпилированное приложение должно запускаться прямо из окна проводника или через пункт меню Debug^Start Without Debugging (Отладка^Запустить без отладки). В обоих случаях во время тестирования списка часто используемых элементов отладка будет отключена. Совет. Чтобы остановить использование обслуживающего процесса Visual Studio на более длительный период, можно изменить конфигурацию проекта. Для этого дважды щелкните на узле Properties (Свойства) в окне Solution Explorer, перейдите на вкладку Debug (Отладка) и снимите отметку с флажка Enable the Visual Studio hosting process (Включить обслуживающий процесс Visual Studio).
Глава 23. Окна 725 В Windows для приложений не только автоматически предоставляется список недавних документов, но в нем также поддерживается возможность закрепления, которая позволяет пользователям добавлять в список самые важные документы и оставлять их там навсегда. Для закрепления документа в списке часто используемых элементов любого приложения необходимо выполнить щелчок на небольшом значке с изображением канцелярской кнопки. После этого Windows переместит выбранный файл в отдельную категорию, которая называется Закреплено. Чтобы удалить элемент из списка недавних документов, нужно щелкнуть на нем правой кнопкой мыши и выбрать в контекстном меню пункт Удалить из этого списка. Специальные списки часто используемых элементов Список недавно использовавшихся документов, который демонстрировался до сих пор, является встроенным в Windows и не требует никакой логики WPF. Однако в WPF предлагаются дополнительные возможности, которые позволяют брать этот список под свой контроль и заполнять его специальными элементами. Для этого потребуется просто добавить в файл App.xaml код разметки, определяющий раздел <JumpList.List>, как показано ниже: <Application x:Class="JumpLists" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/200 6/xaml" StartupUri="MainWindow.xaml"> <Application.Resources> </Application.Resources> <JumpList.JumpList> <JumpList> </JumpList> </JumpList.JumpList> </Application> При таком определении специального списка часто используемых элементов Windows перестает отображать список недавних документов. Чтобы вернуть его назад, необходимо явно указать это с помощью свойства JumpList.ShowRecentCategory: <JumpList ShowRecentCategory="True"> Можно также добавить свойство ShowFrequentCategory для отображения списка наиболее часто открываемых документов, за обработку которых отвечает данное приложение. Вдобавок можно создавать собственные элементы и помещать их в желаемую специальную категорию в списке часто используемых элементов. Для этого потребуется добавить в JumpList объекты JumpPath или JumpTask. Ниже показан пример добавления объекта JumpPath, который представляет документ: <JumpList ShowRecentCategory="True"> <JumpPath CustomCategory="Sample Documents" Path="c:\Samples\samples.testDoc"></JumpPath> </JumpList> При создании объекта JumpPath можно предоставить две детали. С помощью свойства CustomCategory указывается название категории, которая должна отображаться перед данным элементом в списке недавних документов. (В случае добавления нескольких элементов, принадлежащих одной и той же категории, они будут сгруппированы вме- .сте.) Если категория не указана, Windows будет использовать название Tasks (Задачи). С помощью свойства Path задается путь к файлу, указывающий на нужный документ. При этом должно использоваться полностью уточненное имя файла, файл должен суще-
726 Глава 23. Окна ствовать, а его тип должен соответствовать типу файлов, за обработку которых отвечает данное приложение. Если какое-то из перечисленных требований нарушено, элемент не появится в списке часто используемых элементов. При щелчке на элементе Jump Path происходит в точности то же самое, что и при щелчке на одном из файлов в разделе недавних документов. Windows запускает новый экземпляр приложения и передает ему путь к документу в виде аргумента командной строки. Объект JumpTask имеет несколько иное предназначение. Если объект JumpPath отображается на документ, то каждый объект JumpTask отображается на какое-то приложение. Ниже показан пример создания объекта JumpTask для встроенного в Windows приложения Notepad (Блокнот). <JumpList> <JumpTask CustomCategory="Other Programs" Title="Notepad" Description="Open a sample document in Notepad" ApplicationPath="c:\windows\notepad.exe" IconResourcePath="c:\windows\notepad.exe" Arguments=" c:\Samples\samples.testDoc "></JumpTask> </JumpList> Если JumpPath требовал предоставления всего лишь двух деталей, то JumpTask использует гораздо больше свойств, которые перечислены в табл. 23.2. Таблица 23.2. Свойства класса JumpTask Имя Описание Title Текст, который должен отображаться в списке часто используемых элементов Description Текст, который должен отображаться в виде подсказки при наведении курсора мыши на данный элемент ApplicationPath Путь к исполняемому файлу нужного приложения. Как и свойство, позволяющее указывать путь к документу в объекте JumpList, свойство ApplicationPath требует применения полностью уточненного имени IconResourcePath Путь к файлу, в котором содержится миниатюрный значок, который Windows будет отображать рядом с данным элементом в списке часто используемых элементов. Как ни странно, но в этом случае Windows не выбирает значок по умолчанию и не извлекать его из исполняемого файла приложения. Если нужно, чтобы рядом с элементом отображался соответствующий значок, следует установить это свойство IconResourcelndex Если ресурс приложения или значков, идентифицированный свойством IconResourcePath, содержит множество значков, в этом свойстве указывается индекс нужного значка WorkingDirectory Рабочий каталог, из которого будет запускаться приложение. Обычно это папка с документами для этого приложения ApplicationPath Параметр командной строки, который должен передаваться приложению, такой как имя открываемого файла Создание списка часто используемых элементов в коде Хотя список часто используемых элементов легко заполнить за счет добавления соответствующего кода разметки в файл App.xaml, с этим подходом связан один серьезный недостаток. Как было показано в предыдущем разделе, элементы JumpPath и
Глава 23. Окна 727 JumpTask требуют, чтобы путь к файлу указывался в полностью уточненном формате. Этот путь часто зависит от способа развертывания приложения и потому не должен кодироваться жестким образом. По этой причине принято создавать или изменять список часто используемых элементов программным образом. Для конфигурирования списка часто используемых элементов в коде применяются классы JumpList, JumpPath и JumpTask из пространства имен System.Windows.Shell. В следующем примере создается новый объект JumpPath, который должен позволить пользователю открывать приложение Notepad (Блокнот) для просмотра файла readme.txt, хранящегося в текущей папке приложения, где бы то ни было установлено. private void Application_Startup(object sender, StartupEventArgs e) { // Извлечение текущего списка часто используемых элементов. JumpList jumpList = new JumpList(); JumpList.SetJumpList(Application.Current, JumpList); // Добавление нового объекта JumpPath для файла в папке приложения, string path = Path.GetDirectoryName( System.Reflection.Assembly.GetExecutingAssembly().Location); path = Path.Combine (path, "readme.txt"); if (File.Exists(path)) { JumpTask jumpTask = new JumpTask(); jumpTask.CustomCategory = "Documentation"; jumpTask.Title = "Read the readme.txt"; jumpTask.ApplicationPath = @"c:\windows\notepad.exe"; jumpTask.IconResourcePath = @"c : \windows\notepad.exe"; JumpTask.Arguments = path; jumpList.Jumpltems.Add(jumpTask); } // Обновление списка часто используемых элементов. jumpList.Apply(); } На рис. 23.12 показан измененный список часто используемых элементов, который включает новый добавленный объект JumpTask. Documentation Read the readme.txt 0' ЗП Window*7_TaskBar v- Pin this program to taskbar Q Close window ШшЫУЛ Рис. 23.12. Список часто используемых элементов со специальным элементом JumpTask Запуск задач приложений из списка часто используемых элементов Пока что во всех приводившихся примерах список часто используемых элементов применялся либо для открытия документов, либо для запуска приложения, но ни разу для запуска задачи внутри уже работающего приложения. Это связано не с тем, что классы WPF, предназначенные для работы со списками часто используемых элементов, не предусматривают подобной возможности, а с тем, что так устроены сами списки. Для обхода этого препятствия необходимо использовать разновидность приема с одним экземпляром, который был показан в главе 7.
728 Глава 23. Окна Ниже описана базовая стратегия. • Когда возникает событие Application.Startup, создайте объект JumpTask, указывающий на нужное приложение. Укажите в свойстве Arguments вместо имени файла специальный код, которое приложение может распознать. Например, присвойте в качестве значения код @#StartOrder, чтобы данная задача передавала приложению инструкцию StartOrder (начать заказ). • Используйте код приложения одного экземпляра из в главе 7. При запуске второго экземпляра передавайте соответствующий аргумент командной строки первому экземпляру и завершайте новое приложение. • Когда первый экземпляр получает аргумент командной строки (в методе OnStartupNextlnstanceO) выполняйте соответствующую задачу. • Не забывайте удалять задачи из списка часто используемых элементов при срабатывании события Application.Exit, если только ассоциируемые с задачами команды не будут работать с тем же успехом при запуске приложения в первый раз. Базовый пример реализации этого подхода представлен в проекте JumpList ApplicationTask, который входит в состав кода для настоящей главы. Изменение значков и окон предварительного просмотра, отображаемых в панели задач Панель задач в Windows 7 имеет еще несколько улучшений, в том числе отображаемыми по желанию индикаторами выполнения и миниатюрными окнами предварительного просмотра. К счастью, WPF позволяет легко работать со всеми этими функциональными возможностями. Для получения доступа к любому из этих средств применяется класс Taskbarltemlnf о, который находится в том же пространстве имен System.Windows.Shell, что и классы для работы со списками часто используемых элементов. Каждое окно обладает возможностью иметь один ассоциируемый объект Taskbarltemlnfo, который можно создавать с помощью XAML за счет добавления в класс окна следующего кода разметки: <Window х:Class="JumpLists.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height=00" Width=00"> <Grid> </Grid> <Window.Taskbarltemlnfo> <TaskbarItemInfo x:Name="taskBarItem"X/Tas]cbarItemInfo> </Window.Taskbarltemlnfo> </Window> Этот шаг сам по себе ничего не меняет ни в окне, ни в приложении, но позволяет далее использовать средства, описанные в последующих разделах. Усечение миниатюр предпросмотра Во многом подобно тому, как каждое приложение в Windows 7 автоматически снабжается поддержкой списков часто используемых элементов, оно также получает и миниатюрное окно предварительного просмотра, которое появляется при наведении пользователем курсора мыши на соответствующую кнопку в панели задач. Как обычно, в этом миниатюрном окне предварительного просмотра отображается уменьшенная вер-
Глава 23. Окна 729 сия клиентской области окна (т.е. все кроме границы окна). Но в некоторых случаях в нем может быть показана только часть содержимого окна. Преимущество такого подхода в том, что акцент делается лишь на наиболее существенной части окна. В случае окон особенно большого размера это позволяет делать содержимое, которое в противном случае будет слишком маленьким для прочтения, более разборчивым. Данное средство в WPF доступно с использованием свойства Taskbarltemlnfo. ThumbnailClipMargin. Это свойство позволяет определять объект Thickness, который задает размер поля между содержимым, отображающимся в миниатюре, и краями окна. На рис. 23.13 показан пример. При каждом щелчке на кнопке в данном приложении область отсечения смещается, чтобы включать только кнопку, на которой был совершен щелчок. На рис. 23.13 можно видеть внешний вид окна предварительного просмотра после щелчка на второй кнопке. ■ ThumbneitClippmg S*t the Pm Set the Preview on Button »2 Рис. 23.13. Окно с усеченной миниатюрой предварительного просмотра На заметку! Изменить миниатюру предварительного просмотра так, чтобы в ней отображались лишь выбранные графические объекты, нельзя. Единственное, что можно делать — это указать, что в ней должна отображаться какая-то часть окна. Для создания такого эффекта в коде должно быть учтено несколько деталей — координаты кнопки, ее размеры и размеры области содержимого окна (которые совпадают с размерами самого высокоуровневого элемента Grid по имени LayoutRoot, размещенного внутри окна и содержащего весь код разметки). После получения этих чисел для ограничения миниатюры предварительного просмотра до нужной области потребуется лишь несколько простых вычислений: private void cmdShnnkPreview_Click (object sender, RoutedEventArgs e) { // Найти позицию кнопки, на которой был выполнен щелчок, // в координатной плоскости окна. Button cmd = (Button)sender; Point locationFromWindow = cmd.TranslatePoint (new Point @, 0), this); // Определить ширину, добавляемую к каждой из сторон, double left = locationFromWindow.X; double top = locationFromWindow.Y;
730 Глава 23. Окна double right = LayoutRoot.ActualWidth - cmd.ActualWidth - left; double bottom = LayoutRoot.ActualHeight - cmd.ActualHeight - top; // Применить отсечение. taskBarltem.ThumbnailClipMargin = new Thickness(left, top, right, bottom); } Кнопки предпросмотра Некоторые приложения используют окно предварительного просмотра для совершенно другой цели — для отображения различных кнопок в небольшой области вида панели инструментов под миниатюрой предпросмотра. Одним из примеров таких приложений является проигрыватель Windows Media. При наведении курсора мыши на его значок в панели инструментов появляется окно предпросмотра, которое включает в себя кнопки для воспроизведения, приостановки, прокручивания вперед и прокручивания назад. Это предоставляет удобный способ для управления процессом воспроизведения без перехода в само приложение. Подобные кнопки называются кнопками предпросмотра (thumbnail buttons) и поддерживаются в WPF. Использовать их в WPF очень просто — нужно лишь добавить один или более объектов ThumbButtonlnfo в коллекцию Taskbarltemlnfo. ThumbButtonlnfos. Каждый объект ThumbButtonlnfo нуждается в изображении, которое предоставляется с помощью свойства ImageSource. Свойство Description позволяет указать текст, который будет отображаться в качестве всплывающей подсказки. Остается лишь подключить кнопку к соответствующему методу в приложении, обработав ее событие Click. Ниже приведен пример добавления кнопок для запуска и останова воспроизведения в проигрывателе: <TaskbarItemInfo x:Name="taskBarItem"> <TaskbarItemInfо.ThumbButtonlnfos> <ThumbButtonInfo ImageSource="play.png" Description="Play" // Воспроизведение Click="cmdPlay_Click"></ThjjmbButtonInfo> <ThumbButtonInfo ImageSource="pause.png" Description="Pause" // Пауза Click=,,cmdPause_Click"></ThumbButtonInfo> </TaskbarItemInfо.ThumbButtonlnfos> </TaskbarItemInfo> На рис. 23.14 показано, как эти кнопки отображаются под окном предварительного просмотра. На заметку! Имейте в виду, что кнопки, добавляемые в панель задач, будут отображаться только в Windows 7. Поэтому они должны дублировать функциональные возможности, которые уже присутствуют в разработанном окне, а не предоставлять новые. Ш ЛШ Рис. 23.14. Добавление кнопок в миниатюры предпросмотра
Глава 23. Окна 731 По мере выполнения приложением различных задач и перехода в различные состояния некоторые из отображаемых в панели задач кнопок могут становиться не подходящими. К счастью, поведением этих кнопок можно управлять с использованием небольшого набора свойств, которые перечислены в табл. 23.3. Таблица 23.3. Свойства класса ThumbButtonlnfo Имя Описание ImageSource Description Command, CommandParameter и CommandTarget Visibility IsEnabled Islnteractive IsBackgroundVisible DismissWhenClicked Позволяет указывать изображение для кнопки, которое должно быть встроено в приложение в виде ресурса. В идеале это должен быть файл .png, обладающий прозрачным фоном Позволяет указывать текст, который должен отображаться в виде подсказки при наведении пользователем на данную кнопку курсора мыши Позволяют указывать команду, к запуску которой должен приводить щелчок на кнопке. Могут применяться вместо события Click Позволяет скрывать и показывать кнопку Позволяет деактивизировать кнопку, т.е. делать так, чтобы она оставалась видимой, но выполнять на ней щелчок было нельзя Позволяет деактивизировать кнопку без затемнения ее внешнего вида. Это может быть удобно, если нужно, чтобы кнопка выступала в качестве разновидности индикатора состояния Позволяет отключать реакцию на наведение курсора мыши на кнопку. Если установлено в true (по умолчанию), Windows всегда подсвечивает кнопку и отображает вокруг нее границу при наведении на кнопку курсора мыши. Если установлено в false, ничего подобного не делается Позволяет создавать кнопку одноразового использования. Сразу же после выполнения щелчка на такой кнопке Windows удаляет ее из панели задач. (Для достижения большего контроля можно использовать специальный код для добавления или удаления кнопок на лету, но обычно гораздо проще отображать или скрывать их с помощью свойства Visibility.) Индикаторы выполнения Любой, кому приходилось копировать большой файл в проводнике Windows, видел, что в нем используется индикатор выполнения, закрашивающий фон кнопки в панели задач зеленым цветом. По мере продвижения операции копирования зеленый фон заполняет всю область кнопки в направлении слева направо подобно тому, как это происходит в панели индикации хода выполнении, до тех пор, пока операции не будет завершена. Однако не всем может быть известно, что данная функция доступна не только в проводнике Windows. На самом деле она встроена в Windows 7 и доступна для использования во всех разрабатываемых приложениях WPF. Для работы с ней необходимо использовать два таких свойства класса Taskbarltemlnfo — ProgressValue и ProgressState. Первоначально свойство ProgressState установлено в None, при этом индикатор выполнения не отображается. Указав для него значение TaskbarltemProgressState. Normal, можно получить окрашиваемый в зеленый цвет фон прогресса, аналогичный
732 Глава 23. Окна тому, что применяется в проводнике Windows. Свойство ProgressValue определяет его размер, от 0 (при котором он совершенно не заполнен) до 1 (при котором он полностью заполнен, т.е. свидетельствует о завершении выполнения операции). Например, если указать для свойства ProgressValue значение 0.5, то зеленым цветом будет заполнена ровно половина фона кнопки в панели задач. Помимо None и Normal, перечисление TaskbarltemProgressState содержит еще несколько значений: Pause, позволяющее отображать желтый фон вместо зеленого; Error, предназначенное для отображения красного фона; Indeterminate, которое можно использовать для отображения постоянно пульсирующего фона индикатора выполнения с игнорированием значения свойства ProgressValue. Последний вариант подходит для ситуаций, когда неизвестно, сколько времени потребуется для завершения выполнения текущей операции (например, при обращении к веб-службе). Налагаемые значки Последней функцией панели задач, которая предоставляется в Windows 7, является функция наложения, т.е. возможность добавлять небольшое изображение поверх значков, уже отображаемых в панели задач. Например, в приложении для мгновенного обмена сообщениями Windows Messenger различные налагаемые значки служат для уведомления о различных состояниях. Для использования налагаемого изображения необходимо иметь очень маленький файл .рпд или .ico с прозрачным фоном. Конкретные размеры в пикселях не важны, но очевидно, что изображение должно быть значительно меньше того, что применяется для кнопки в панели задач. После добавления этого изображения в проект показать его можно за счет установки свойства ТаskBarltemlnf о.Overlay. Чаще всего при этом используется ресурс изображения, который уже был определен в коде разметки: taskBarItem.Overlay = (ImageSource)this.FindResource("Workinglmage"); В качестве альтернативы можно применять синтаксис упакованных URI для указания на встроенный файл: taskBarltem.Overlay = new Bitmaplmage( new Uri ("pack://application:,,,/working.png") ) ; Чтобы полностью удалить изображение, перекрывающее значок кнопки в панели задач, необходимо установить свойство Overlay в null. На рис. 23.15 показано изображение .рпд кнопки паузы, используемое в качестве налагаемого поверх общего значка приложения WPF. Оно свидетельствует о том, что работа данного приложения в текущий момент приостановлена. Рис. 23.15. Отображение налагаемого изображения
Глава 23. Окна 733 Совет. Налагаемые изображения — это замечательный способ для предоставления дополнительной информации о текущем состоянии приложения. В предыдущих версиях Windows такой тип поведения чаще применялся в отношении значков, отображаемых в области уведомления (правый нижний угол панели задач). В Windows 7 значки, отображаемые в области уведомления, по умолчанию скрываются, поэтому для длительно работающих приложений имеет смысл оставлять значки в панели задач и использовать налагаемые поверх значки. Дополнительная поддержка работы с Windows 7 Хотя WPF предлагает довольно развитую поддержку использования функциональных средств Windows 7, все же есть несколько возможностей, которые остались без внимания. В пакете кода Windows API Code Pack, который доступен по адресу http://code.msdn.microsoft.com/ WindowsAPICodePack, поставляются библиотеки .NET, которые охватывают абсолютно все новые функциональные средства Windows 7. Эти библиотеки можно использовать с .NET 3.5 SP1 и получить базовую поддержку для применения списков часто используемых элементов, налагаемых значков, идентификаторов выполнения и кнопок предпросмотра. Но более интересно то, что их можно применять с версией WPF 4 и в этом случае получить поддержку дополнительных средств. • Миниатюры предпросмотра с вкладками (Tabbed Thumbnails). Это множественные окна предварительного просмотра, подобные тем, что в Internet Explorer отображаются для отдельных вкладок. • Элемент управления Explorer Browser. Позволяет размещать элементы управления в стиле проводника Windows прямо внутри одного из окон WPR • Диалоговые окна задач (Task Dialogs). Это специализированные окна, впервые появившиеся в Windows Vista, которые предлагают простой отшлифованный способ для представления информации и сбора базовых данных от пользователя с написанием минимального объема кода. Резюме В настоящей главе была рассмотрена предлагаемая в WPF модель окон. Вначале было показано, как размещать и изменять размеры окон, создавать окна, имеющие владельцев, и применять общие диалоговые окна. Затем объяснялись более сложные приемы, такие как создание окон необычной формы, специальных шаблонов окон и окон с эффектом Aero Glass. В конце главы была описана замечательная поддержка, добавленная в WPF 4 для программирования панели задач Windows 7. Вы узнали, что приложение WPF может автоматически получать базовый список часто используемых элементов и добавлять в него специальные элементы. Было показано, как отображать в миниатюре предварительного просмотра только определенную часть окна и удобные командные кнопки, использовать индикаторы выполнения и налагать значки поверх отображаемых в панели задач значков приложений.
ГЛАВА 24 Страницы и навигация В основе большинства традиционных приложений Windows лежит окно с различными панелями инструментов и меню. Панели инструментов и меню являются своего рода "двигателем" приложения — когда пользователь на них щелкает, происходит какое-то действие, и появляются другие окна. В документных приложениях может существовать несколько одинаковых по степени важности "главных" окон, которые открываются одновременно, но в целом модель та же. Пользователи проводят большую часть своего времени в каком-то одном месте и переходят в другие окна, только когда это необходимо. Приложения Windows являются настолько привычными, что порой даже бывает трудно представить, каким еще образом можно разработать приложение. Однако в Интернете используется совершенно иная модель навигации, основанная на страницах, и разработчики настольных приложений осознали, что она удивительно хорошо подходит для создания приложений определенных типов. Чтобы предоставить разработчикам возможность создавать настольные приложения в стиле веб-приложений, в состав WPF была включена собственная система страничной навигации, которая, как будет показано в настоящей главе, является удивительно гибкой по своей природе. В настоящее время страничная модель чаще всего применятся в простых, облегченных приложениях (или для реализации небольших наборов средств в более сложных оконных приложениях). Однако она является замечательным вариантом в тех случаях, когда требуется упростить процесс развертывания. Причина в том, что WPF позволяет создавать страничные приложения, способные выполняться внутри браузера Internet Explorer или Firefox. Это означает, что пользователи получают возможность запуска таких приложений, не выполняя их явную установку, а просто указывая в браузерах нужное расположение. Такая модель называется ХВАР, и она тоже рассматривается в этой главе. И, наконец, в главе описан элемент управления WebBrowser, предлагаемый в WPF, который позволяет размещать HTML-страницы в WPF-окне. Как будет показано, этот элемент управления предоставляет возможность не только отображать веб-страницы, но также программно исследовать их структуру и содержимое (с использованием модели HTML DOM). Кроме того, он даже позволяет приложению взаимодействовать с кодом JavaScript. Что нового? Элемент управления WebBrowser, рассматриваемый в конце главы, появился в версии WPF 3.5 SP1. Для получения похожей функциональности в предшествующих версиях WPF разработчики должны были использовать элемент управления WebBrowser из Windows Forms.
Глава 24. Страницы и навигация 735 Общие сведения о страничной навигации Обычное веб-приложение на вид значительно отличается от традиционного клиентского программного обеспечения с множеством функций. Пользователи веб-сайта проводят время-, перемещаясь с одной страницы на другую. Если не считать всплывающие рекламные сообщения, они никогда не видят одновременно более одной страницы. При решении задачи (например, размещении заказа или выполнении сложного поиска) им приходится проходить эти страницы в линейной последовательности от начала до конца. HTML не поддерживает сложных оконных возможностей настольных операционных систем, поэтому профессиональные веб-разработчики всегда полагаются на качественное проектное решение и понятные интерфейсы. Поскольку технологии веб-проектирования значительно усложнились, разработчики приложений Windows тоже начали замечать преимущества такого подхода. Но важнее то, что веб-модель является простой и хорошо отлаженной. Именно по этой причине новичками часто легче разобраться в использовании веб-сайтов, чем в работе с Windows-приложениями, хотя очевидно, что вторые обладают куда большим количеством возможностей. В последнее время разработчики начали эмулировать некоторые из соглашений Интернета в настольных приложениях. Программное обеспечение для финансовых операций, подобное Microsoft Money, является главным примером использования веб- интерфейсов, проводящих пользователей через набор задач. Однако построение таких приложений часто оказывается более сложным, чем традиционных оконных приложений, поскольку требует от разработчиков воссоздания базовых средств браузера, например, навигации. На заметку! В некоторых случаях разработчики создают веб-приложения, используя механизм браузера Internet Explorer. Именно такой подход применялся при построении Microsoft Money, но для разработчиков, не имеющих дело с продуктами Microsoft, он будет слишком сложным. Хотя Microsoft и предоставляет привязки для Internet Explorer вроде элемента управления WebBrowser, создание целого приложения на основе этой функциональности — далеко не простая задача, которая к тому же чревата потерей наилучших возможностей, предлагаемых традиционными Windows-приложениями. Благодаря WPF, больше нет причин искать какой-то компромисс, потому что в состав WPF входит встроенная модель страниц с уже готовыми средствами навигации. Лучше всего то, что эту модель можно применять для создания самых разнообразных страничных приложений, приложений, использующих только какие-то определенные страничные функции (например, в мастере или справочной системе), или приложений, функционирующих непосредственно в браузере. Страничные интерфейсы Чтобы создать страничное приложение в WPF, нужно перестать применять для пользовательских интерфейсов в качестве контейнера высшего уровня класс Window и вместо него переключиться на класс System.Windows.Controls.Page. Модель для создания страниц в WPF во многом похожа на модель для создания окон. Хотя создавать объекты страниц можно и с помощью одного лишь кода, обычно для каждой страницы создается файл XAML и файл отделенного кода. При компиляции этого приложения компилятор создает производный класс страницы, который объединяет написанный разработчиком код с генерируемыми автоматически связующими элементами (такими как поля, которые ссылаются на каждый именованный элемент на странице). Это тот же самый процесс, который был описан при рассмотрении компиляции оконных приложений в главе 2.
736 Глава 24. Страницы и навигация На заметку! Страницу можно добавлять в любой проект WPR Для этого в Visual Studio нужно выбрать в меню Project (Проект) пункт Add Page (Добавить страницу). Хотя страницы и являются самым высокоуровневым компонентом пользовательского интерфейса при проектировании приложения, во время его выполнения контейнером наивысшего уровня они уже не будут. Вместо этого они обслуживаются в другом контейнере. Именно в этом и состоит секрет гибкости, обеспечиваемой WPF в случае страничных приложений, ведь в качестве такого контейнера WPF позволяет использовать любой из нескольких следующих объектов: • объект NavigationWindow, который представляет собой немного видоизмененную версию класса Window; • объект Frame, находящийся внутри другого окна; • объект Frame, находящийся внутри другой страницы; • объект Frame, обслуживаемый непосредственно в Internet Explorer или Firefox. Все они более подробно рассматриваются далее в главе. Простое страничное приложение с элементом NavigationWindow В качестве примера простейшего страничного приложения давайте создадим следующую страницу: <Page x:C1ass="NavigationApplication.Page1" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" WindowTitle=,,Pagel" > <StackPanel Margin=,,3"> <TextBlock Margin=ll3"> This is a simple page. </TextBlock> <Button Margin=" Padding=M2M>0K</Button> <Button Margin=" Padding=,,2">Close</Button> </StackPanel> </Page> Теперь изменим содержимое файла App.xaml так, чтобы в качестве начальной страницы использовался файл этой страницы: <Application х:Class="NavigationApplication.Арр" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="Page1.xaml" > </Application> При запуске этого приложения среде WPF хватит "интеллектуальных способностей", чтобы понять, что указывается страница, а не на окно. Она автоматически создаст новый объект NavigationWindow для выполнения роли контейнера и отобразит страницу внутри него (рис. 24.1). Она также считает свойство WindowTitle и использует его значение в качестве заголовка окна. На заметку! Одно из отличий между страницей и окном заключается в том, что размер страницы обычно не устанавливается, поскольку он определяется обслуживающим ее контейнером (хостом). Если же для свойств Width и Height страницы все-таки указаны какие-то значения, страница делается именно такого размера, но часть ее содержимого может быть усечена, если размер окна хоста оказывается меньше, или размещена по центру доступного пространства, если его размер больше.
Глава 24. Страницы и навигация 737 ^agel | This is a simple page 11. L. 0< Close " J Рис. 24.1. Страница в контейнере NaviagationWindow Объект NavigationWindow более или менее похож на обычное окно, за исключением кнопок навигации "вперед" и "назад", которые отображаются в строке сверху. Поэтому нетрудно догадаться, что класс NavigationWindow унаследован от класса Window и имеет небольшой дополнительный набор связанных с навигацией свойств. Извлечь ссылку на содержащий объект NavigationWindow можно с помощью следующего кода: // Извлечение ссылки на окно, содержащее текущую страницу. NavigationWindow win = (NavigationWindow)Window.GetWindow(this); В конструкторе страницы данный код работать не будет, потому что на этом этапе страница пока еще не находится внутри своего контейнера, поэтому нужно дождаться хотя бы, когда возникнет событие Page.Loaded. Совет. По возможности подхода с применением NavigationWindow лучше вообще избегать и использовать вместо него свойства класса Page (и службу навигации, о которой еще будет рассказываться в этой главе). Иначе страница будет тесно связана с контейнером NavigationWindow, и потому ее нельзя будет использовать повторно в других хостах. При желании создать приложение, состоящее только из кода, для достижения эффекта, показанного на рис. 24.1, потребовалось бы создать как страницу, так и навигационное окно. Код, который пришлось бы для этого использовать, показан ниже: NavigationWindow win = new NavigationWindow() win.Content = new Pagel(); win.Show(); Класс Page Подобно Window, класс Page допускает наличие только единственного вложенного элемента. Однако класс Page не является элементом управления содержимым: он на самом деле унаследован непосредственно от класса FrameworkElement. Вдобавок класс Page является более простым и отлаженным, чем класс Window. Он имеет небольшой набор дополнительных свойств, которые позволяют настраивать его внешний вид, взаимодействовать с контейнером только определенным, ограниченным образом и применять навигацию. Все эти свойства перечислены в табл. 24.1. Также важно обратить внимание на отсутствующие компоненты — в классе Page нет эквивалентов для методов Hide () и Show(), доступных в классе Window. Если потребуется показать другую страницу, придется воспользоваться навигацией.
738 Глава 24. Страницы и навигация Таблица 24.1. Свойства класса Page Имя Описание Background Content Foreground, FontFamily и FontSize WindowWidth, WindowHeight и WindowTitle NavigationService KeepAlive ShowsNavigationUI Title Принимает кисть, которая позволяет устанавливать заливку для фона Принимает один элемент, который отображается на странице. Обычно в роли такого элемента выступает контейнер компоновки, такой как Grid или StackPanel Определяют внешний вид по умолчанию для текста внутри страницы. Значения этих свойств наследуются элементами внутри страницы. Например, если устанавливается заливка переднего плана и размер шрифта, по умолчанию содержимое внутри страницы получает эти же настройки Определяют внешний вид окна, в которое упаковывается страница. Эти свойства позволяют управлять хостом путем установки его ширины, высоты и заголовка. Однако они действуют только в том случае, если страница обслуживается в окне (а не во фрейме) Возвращает ссылку на объект NavigationService, которую можно использовать для отправки пользователя на другую страницу программным путем Определяет, должен ли объект страницы оставаться действующим после перехода пользователя на другую страницу. Это свойство более подробно рассматривается далее в главе (в разделе "Хронология навигации") при описании возможности восстановления страниц из хронологии навигации Определяет, должен ли хост для данной страницы отображать навигационные элементы управления (кнопки "назад" и "вперед"). По умолчанию имеет значение true Устанавливает имя, которое должно применяться для страницы в хронологии навигации. Хост не использует свойство Title для установки заголовка в строке заголовка: для этой цели у него есть свойство WindowTitle Гиперссылки Наиболее простой способ позволить пользователю перемещаться с одной страницы на другую — это гиперссылки. В WPF гиперссылки являются не отдельными, а внут- ристрочными потоковыми элементами, которые обязательно должны размещаться внутри другого поддерживающего их элемента. (Причина такого проектного решения связана с тем, что гиперссылки и текст часто используются вперемешку. Более подробно о потоковом содержимом и компоновке текста речь пойдет в главе 28.) Например, ниже показано объединение текста и ссылок в элементе TextBlock, который является самым практичным контейнером для гиперссылок: <TextBlock Margin=" TextWrapping="Wrap"> This is a simple page. Click <Hyperlink NavigateUri="Page2 .xaml">here</Hyperlink> to go to Page2. </TextBlock> При визуализации гиперссылки отображаются как хорошо знакомый подчеркнутый текст синего цвета (рис. 24.2). Щелчки на ссылке можно обрабатывать двумя способами: реагировать на событие Click и использовать код для выполнения какой-то задачи либо просто направлять пользователя на другую страницу.
Глава 24. Страницы и навигация 739 Рис. 24.2. Ссылка на другую страницу Однако существует и более простой подход. Класс Hyperlink также включает свойство NavigateUri, которое можно устанавливать так, чтобы оно указывало на любую другую страницу в приложении. В таком случае при щелчке на гиперссылке пользователи будут попадать на целевую страницу автоматически. На заметку! Свойство NavigateUri работает только в том случае, если гиперссылка размещается на странице. При желании использовать гиперссылку в оконном приложении, чтобы позволить пользователям выполнять какую-то задачу, переходить на веб-страницу или открывать новое окно, придется обрабатывать событие RequestNavigate и писать код самостоятельно. Гиперссылки не являются единственным способом для перехода с одной страницы на другую. NavigationWindow включает две заметные кнопки: "назад" и "вперед" (если только они не скрыты установкой свойства Page.ShowsNavigationUI в false). Щелкая на этих кнопках, пользователи могут перемещаться по навигационной последовательности на одну страницу назад или вперед. Как и в окне браузера, пользователи также могут щелкать на стрелке раскрывающегося списка, отображаемой по краям этих кнопок, и просматривать всю последовательность, а также "перепрыгивать" сразу на несколько страниц назад или вперед (рис. 24.3). Подробнее о том, как работает хронология страниц и какие у нее имеются ограничения, будет рассказываться далее в этой главе, в разделе "Хронология навигации". На заметку! В случае перехода на новую страницу, у которой свойство WindowTitle не установлено, окно сохраняет тот же заголовок, который был на предыдущей странице. Если свойство WindowTitle не устанавливается ни на одной странице, заголовок окна остается пустым. • РадеЗ v Current Page Page2 Pagel . Ilkrk here to go to Pagel l-.r-iiMmij . Рис. 24.3. Хронология посещенных страниц
740 Глава 24. Страницы и навигация Навигация по веб-сайтам Интересно то, что также можно создавать и гиперссылку, указывающую на веб-содержимое. Когда пользователь щелкает на такой ссылке, в области страницы загружается целевая веб-страница: <TextBlock Margin=" TextWrapping="Wrap"> Visit the website <Hyperlink NavigateUri="http://www.prosetech.com">www.prosetech.com</Hyperlink>. </TextBlock> Однако при использовании такого приема обязательно должен быть присоединен обработчик к событию Application.DispatcherUnhandledException или Application. NavigationFailed. Попытка посещения веб-сайта может оказаться неудачной, если компьютер не подключен к сети, сайт не доступен или веб-содержимое отсутствует. В таком случае сеть возвращает ошибку вроде 04: File Not Found" D04: файл не найден), которая воплощается в исключение WebException. Для аккуратной обработки этого исключения и предотвращения неожиданного завершения работы приложения оно должно быть нейтрализовано с помощью следующего обработчика: private void App_NavigationFailed(object sender, NavigationFailedEventArgs e) { if (e.Exception is System.Net.WebException) { MessageBox.Show("Website " + e.Uri.ToString () + " cannot be reached."); // He удается найти веб-сайт // Нейтрализовать ошибку, чтобы приложение продолжило свою работу, е. Handled = true; } } NavigationFailed — это всего лишь одно из нескольких навигационных событий, которые определены в классе Application. Полный список будет представлен в табл. 24.2. На заметку! Попав на веб-страницу, пользователи смогут щелкать на доступных на ней ссылках и переходить на другие веб-страницы, оставляя исходное содержимое далеко позади. В действительности, они смогут вернуться на вашу WPF-страницу, только если воспользуются хронологией навигации для возврата назад, или если эта страница будет отображаться в специальном окне (как будет показано в следующем разделе), и это окно будет включать элемент управления, позволяющий вернуться обратно к вашему содержимому. В случае отображения страниц с внешних веб-сайтов нельзя делать много вещей. Например, нельзя запретить пользователю переходить на какие-то конкретные страницы или сайты. Также нельзя и взаимодействовать с веб-страницей с помощью объектной модели документов HTML DOM (Document Object Model — объектная модель документа). Это означает, что сканировать страницу для поиска ссылок или изменять ее динамически тоже нельзя. Выполнение всех этих задач становится возможным только в случае использования элемента управления WebBrowser, который будет рассматриваться в конце главы. Навигация по фрагментам Последним приемом, который можно использовать с гиперссылкой, является навигация по фрагментам. Добавив знак # в конце NavigateUri, а за ним — имя элемента, можно сразу же переходить к конкретному элементу управления на странице. Однако такой прием работает, только если целевая страница является прокручиваемой (а тако-
Глава 24. Страницы и навигация 741 вой она является тогда, когда использует элемент управления ScrollViewer или обслуживается в веб-браузере). Ниже приведен соответствующий пример: <TextBlock Margin="> Review the <Hyperlink NavigateUri="Page2.xaml#myTextBox">full text</Hyperlink>. </TextBlock> Когда пользователь щелкает на этой ссылке, приложение переходит на страницу по имени Раде2 и прокручивает ее до элемента myTextBox. Страница прокручивается вниз до тех пор, пока элемент myTextBox не появится в самом ее верху (или насколько возможно близко к ее верхнему краю, что зависит от размера содержимого страницы и содержащего окна). Однако фокуса целевой элемент не получает. Размещение страниц во фрейме Элемент NavigationWindow является удобным контейнером, но не единственным вариантом. Страницы также можно размещать и непосредственно внутри других окон или даже внутри других страниц. Это подразумевает возможность создания чрезвычайно гибкой системы, поскольку означает, что одну и ту же страницу можно использовать многократно разными способами в зависимости от типа приложения, которое требуется создать. Чтобы вставить страницу внутрь окна, нужно воспользоваться классом Frame. Класс Frame представляет собой элемент управления содержимым, который может удерживать любой элемент, но особенно полезен именно в качестве контейнера для страницы. Он включает свойство под названием Source, которое указывает на отображаемую страницу XAML. Ниже показан код обычного окна, которое упаковывает кое-какое содержимое в элементе StackPanel и размещает элемент Frame в отдельном столбце: <Window x:Class="WindowPageHost.WindowWithFrame" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="WindowWithFrame" Height=00" Width=00" > <Grid Margin="> <Grid.ColumnDeflnitions> <ColumnDefinitionx/ColumnDefinition> <ColumnDefinitionx/ColumnDefinition> </Grid.ColumnDefinitions> <StackPanel> <TextBlock Margin=" TextWrapping="Wrap"> This is ordinary window content.</TextBlock> <Button Margin=" Padding=">Close</Button> </StackPanel> <Frame Grid.Column=" Source="Pagel.xaml" BorderBrush="Blue" BorderThickness="l"x/Frame> </Grid> </Window> На рис. 24.4 показан результат. Граница вокруг фрейма (элемента Frame) отображает содержимое страницы. Останавливаться на одном фрейме не обязательно. Можно легко создать окно с множеством фреймов и указать им всем на разные страницы. Как видно на рис. 24.4, в этом примере отсутствуют знакомые кнопки навигации. Дело в том, что для свойства Frame.NavigationUIVisibility по умолчанию устанавливается значение Automatic. Из-за этого навигационные кнопки появляются только тогда, когда в списке посещений уже присутствуют какие-то страницы. Чтобы проверить это, достаточно перейти на новую страницу, и внутри фрейма тут же появятся эти кнопки (рис. 24.5).
742 Глава 24. Страницы и навигация 1 WndOV.W:- This is ordinary window content. ___Op$e This is a simple page. Click tjeie to go to Page2. i W.ndowWithFrame Th»s is ordinary window content. i Рис. 24.4. Окно со страницей, вставленной во фрейм Рис. 24.5. Фрейм с кнопками для навигации Если свойство NavigationUIVisibility установить в Hidden, навигационные кнопки не будут отображаться никогда, а если в Visible — то будут отображаться с самого начала. Наличие навигационных кнопок внутри фрейма является хорошим проектным решением, если во фрейме находится содержимое, отделенное от основного потока приложения (например, он служит для отображения контекстно-зависимой справки или содержания последовательного руководства). Но в других случаях может понадобиться, чтобы они отображались в верхней части окна. Для этого потребуется изменить контейнер наивысшего уровня с Window на NavigationWindow. В таком случае окно будет включать навигационные кнопки. Находящийся внутри этого окна фрейм автоматически привяжет себя к этим кнопкам, благодаря чему пользователь получит внешний вид, подобный показанному на рис. 24.3, но с дополнительным содержимым. Совет. В окно можно добавлять столько объектов Frame, сколько нужно Например, с помощью трех отдельных фреймов несложно построить окно, которое позволит пользователю просматривать задачи приложения, справочную документацию и внешний веб-сайт. Размещение страниц внутри другой страницы Объекты Frame дают возможность создавать более сложные композиции окон. Как уже упоминалось в предыдущем разделе, в одном окне можно использовать сразу несколько фреймов (объектов Frame). Однако, помимо этого, фрейм также можно размещать внутри другой страницы и тем самым создавать так называемую вложенную страницу. В действительности этот процесс выглядит точно так же — объект Frame просто добавляется внутрь разметки страницы. Вложенные страницы представляют более сложную ситуацию в плане навигации. Например, предположим, что вы посещаете страницу и затем щелкаете на ссылке во вложенном фрейме. Что произойдет, если вы после этого щелкнете на кнопке возврата? По сути, все страницы во фрейме выстраиваются в один список. Так что при первом щелчке на кнопке возврата вы вернетесь на предыдущую страницу во вставленном фрейме, а если щелкнете на этой кнопке еще раз, то вернетесь на посещенную до этого родительскую страницу. Эта последовательность действий проиллюстрирована на рис. 24.6. Обратите внимание, что навигационная кнопка возврата назад становится доступной только на втором шаге.
Глава 24. Страницы и навигация 743 • EmbeddedPage ~~6аЙ@ М^*Г[ \ This page contains an embedded page in a frame. This is a simple page Click here to go to РадеЗ. Visit the website www.prosetech.com. _J Рис. 24.6. Навигация с вложенной страницей Такая модель навигации является по большей части достаточно понятной, поскольку предполагает наличие в списке предыдущих страниц по одному элементу для каждой посещенной страницы. Однако бывают случаи, когда вложенный фрейм играет менее важную роль, например, показывает различные представления одних и тех же данных или позволяет просматривать многочисленные страницы справочного содержимого. В таких случаях проход по всем страницам во вложенном фрейме может показаться неудобным или отнимающим много времени процессом. Может быть решено использовать навигационные элементы для управления навигацией только родительского фрейма, те. сделать так, чтобы при щелчке на кнопке возврата пользователь сразу же попадал на предьщушую родительскую страницу. Для получения такого эффекта потребуется установить для свойства JournalOwnership вложенного фрейма значение Owns Journal. Это заставит фрейм применять собственную, отдельную хронологию страниц, в результате чего он также по умолчанию получит собственные навигационные кнопки, позволяющие перемещаться назад и вперед именно по своему содержимому (рис. 24.7). Если эти кнопки не нужны, к свойству JournalOwnership можно будет добавить свойство NavigationUIVisibility и с его помощью просто скрыть их, как показано ниже: <Frame Source="Pagel.xaml" JournalOwnership="OwnsJournal" NavigationUIVisibility="Hidden" BorderTh-ickness=" 1" BorderBrush="Blue"></Frame> После этого вложенный фрейм будет восприниматься просто как фрагмент динамического содержимого внутри страницы. С точки зрения пользователя никаких навигационных возможностей у него не будет. Размещение страниц в веб-браузере Последний способ использования страничных приложений с возможностями навигации подразумевает применение Internet Explorer или Firefox. Однако такой подход требует создания приложения ХВАР (XAML Browser Application — браузерное приложение XAML). В Visual Studio приложение ХВАР представляет собой отдельный шаблон проекта и должно специально выбираться (вместо стандартного Windows-приложения WPF) при создании проекта. Более подробно модель ХВАР рассматривается позже в этой главе. ■ : EmbeddedPage This page contains an embedded page in a frame. This is a simple page. Click hfre to go to Page2.
744 Глава 24. Страницы и навигация ■ EmbeddedPage ч This page contains an embedded page in a frame. This is a simple page. Click here to go to Page2. beddedPage This page contains an embedded page in a frame. \\0 • This ts a simple page. Click heie to go to РадеЗ. [Visit the website www.prosetech.CQrn- Рис. 24.7. Вложенная страница, которая обладает своим журналом и поддерживает навигацию Получение окна с правильными размерами На самом деле существуют два типа страничных приложений, которые описаны ниже. • Автономные приложения Windows, в которых страницы используются для части или всего их пользовательского интерфейса. Такой подход следует применять при необходимости интегрировать в приложение мастер или желании создать простое приложение, ориентированное на решение каких-то задач. В таком случае навигационные и журнальные функции позволяют упростить кодирование. • Браузерные приложения (приложения ХВАР), которые обслуживаются в Internet Explorer или Firefox и имеют ограниченные полномочия. Такой подход следует применять, когда необходимо получить облегченную, основанную на веб-технологиях модель развертывания. В случае создания приложения, относящегося к первому типу, устанавливать свойство Application.StartupUri так, чтобы оно указывало на страницу, обычно не требуется. Вместо этого можно создать объект NavigationWindow вручную и затем загрузить первую страницу внутрь него (как было показано ранее) или же вставить страницы в специальное окно с помощью элемента управления Frame. Оба эти подхода предоставляют возможность задавать размер окна приложения, что является важным для придания приложению презентабельного вида при его первом запуске. В случае создания приложения, относящегося ко второму типу, никакой возможности для управления размером содержащего окна веб-браузера не предоставляется и потому обязательно потребуется установить свойство StartupUri так, чтобы оно указывало на страницу. Хронология страниц Теперь, когда уже известно о страницах и различных способах их размещения, можно переходить к более подробному рассмотрению используемой WPF модели навигации. В этом разделе речь пойдет о том, как именно работают гиперссылки WPF и каким образом восстанавливаются страницы, когда пользователь снова к ним возвращается. Более детальное рассмотрение URI-адресов в WPF Вам наверняка интересно узнать, как в действительности работают свойства вроде Application.StartupUri, Frame.Source и Hyperlink.NavigateUri. В приложении,
Глава 24. Страницы и навигация 745 которое состоит из несвязанных XAML-файлов и выполняется в браузере, этот процесс выглядит довольно просто: при щелчке на гиперссылке браузер интерпретирует ссылку на страницу как относительный URJ-адрес и ищет указанную XAML-страницу в текущей папке. Но в скомпилированном приложении страницы перестают быть доступными в виде отдельных ресурсов: они компилируются в BAML (Binary Application Markup Language — двоичный язык разметки приложений) и вставляются в сборку. Так как же на них ссылаться с помощью URJ? Эта система работает благодаря способу, которым WPF обращается к ресурсам приложения. При выполнении щелчка на гиперссылке в скомпилированном XAML-приложении URI все равно интерпретируется как относительный путь. Однако он является относительным по отношению к базовому URI приложения. Поэтому гиперссылка, указывающая на Pagel.xaml, фактически преобразуется в следующий упакованный URJ: pack://application:,,,/Pagel.xaml Синтаксис упакованных URI подробно описан в главе 7. Но самой важной деталью в нем является последняя часть, содержащая имя ресурса. Может возникнуть вопрос: почему так важно знать, как работают URI-адреса гиперссылок, если весь процесс проходит столь гладко? Главная причина состоит в том, что может потребоваться создать приложение, позволяющее переходить на XAML-страницы, которые хранятся в другой сборке. На самом деле для принятия такого проектного решения имеются веские основания. Поскольку страницы могут применяться в разных контейнерах, может возникнуть желание повторно использовать один и тот же набор страниц как в приложении ХВАР, так и в обычном приложении Windows. В таком случае можно развернуть просто две версии приложения — браузерную и настольную. Чтобы избежать дублирования кода, все страницы, которые планируется использовать повторно, следует поместить в отдельную сборку библиотеки классов (DLL), на которую затем можно сослаться в обоих проектах приложений. Это потребует внесения изменения в URI-адреса. При наличии страницы в одной сборке, указывающей на страницу в другой сборке, нужно будет использовать следующий синтаксис: pack://application:,,,/PageLibrary;component/Pagel.xaml Здесь компонент имеет имя PageLibrary, а путь ,,,PageLibrary;component/ Pagel.xaml указывает на скомпилированную и вставленную внутри него страницу Pagel.xaml. Конечно, абсолютный путь вряд ли будет использоваться. Вместо него гораздо целесообразнее применять в URI-адресах следующий относительный путь: /PageLibrary;component/Pagel.xaml Совет. При создании сборки SharedLibrary для получения правильных ссылок на сборки, импортированных пространств имен и настроек приложения лучше использовать шаблон проекта Custom Control Library (WPF) (Библиотека специальных элементов управления (WPF)). Хронология навигации Хронология страниц в WPF работает точно так же, как и в браузере. При каждом переходе на новую страницу текущая страница добавляется в список предыдущих страниц. При щелчке на кнопке возврата страница добавляется в список следующих страниц. В случае возврата на одну страницу и перехода с нее на новую страницу список следующих страниц очищается.
746 Глава 24. Страницы и навигация Поведение списков предыдущих и следующих страниц выглядит довольно просто, но внутренние механизмы, обеспечивающие работу этих списков, являются гораздо более сложными. Например, предположим, что вы посещаете страницу с двумя текстовыми полями, вводите в них что-нибудь и двигаетесь дальше. Если вы после этого вернетесь обратно на эту страницу, то увидите, что WPF восстанавливает состояние текстовых полей, т.е. в них отображается все, что было ранее введено. На заметку! Между возвращением на страницу через хронологию навигации и выполнением щелчка на ссылке, которая направляет на эту же самую страницу, существует большая разница. Например, при переходе со страницы Pagel на страницу Раде2 и затем снова на страницу Pagel с помощью соответствующих ссылок WPF создаст три отдельных объекта страницы. При втором отображении страница Pagel создастся как отдельный экземпляр с собственным состоянием. Однако в случае возврата к первому экземпляру Pagel за счет двукратного щелчка на кнопке возврата она будет видна в исходном состоянии. Может показаться, что WPF поддерживает состояние ранее посещенных страниц за счет удержания объекта страницы в памяти. Проблема такого подхода состоит в том, что связанные с памятью накладные расходы в сложном приложении с множеством страниц в таком случае могут быть слишком большими. По этой причине удержание объекта страницы не считается безопасной стратегией и вместо него при покидании страницы сохраняется информация о состоянии всех элементов управления, а объект страницы уничтожается. При возврате на эту страницу WPF создает ее заново (из исходного XAML-файла) и восстанавливает состояние ее элементов управления. Такая стратегия сопровождается меньшим количеством накладных расходов, поскольку для сохранения деталей о состоянии элементов управления требуется гораздо меньше памяти, чем для сохранения всей страницы вместе с визуальным деревом объектов. Глядя на эту систему, возникает интересный вопрос: как WPF решает, какие детали нужно сохранять? WPF анализирует все дерево элементов страницы и просматривает имеющиеся у этих элементов свойства зависимости. Свойства, которые должны быть сохранены, имеют небольшой фрагмент дополнительных метаданных — журнальный флаг, который указывает, что они должны помещаться в журнал навигации. Этот флаг устанавливается с помощью объекта FrameworkPropertyMetadata при регистрации свойства зависимости, как было описано в главе 4. Присмотревшись к системе навигации поближе, можно будет заметить, что у многих свойств нет журнального флага. Например, если установить свойство Content элемента управления содержимым или свойство Text элемента TextBlock с помощью кода, ни одна из этих деталей не будет восстановлена при возврате на страницу. То же самое будет и при динамической установке свойства Foreground или Background. Однако если установить свойство Text элемента TextBox, свойство IsSelected элемента CheckBox или свойство Selectedlndex элемента ListBox, то все эти детали сохранятся. Так что же можно предпринять, если такое поведение не подходит? Как быть в случае установки множества свойств динамическим образом и желании, чтобы вся эта информация сохранялась на страницах? Существует несколько возможных вариантов. Самый мощный предполагает применение свойства Page.KeepAlive, которое по умолчанию имеет значение false. Когда это свойство устанавливается в true, WPF не применяет описанный выше механизм сериализации. Вместо этого WPF оставляет объекты всех страниц в действующем состоянии. Благодаря этому, при возврате обратно страница оказывается в том же виде, в котором была ранее. Разумеется, у этого варианта есть один недостаток, заключающийся в увеличении накладных расходов, связанных с памятью, поэтому его следует применять только для нескольких страниц, которые действительно в нем нуждаются.
Глава 24. Страницы и навигация 747 Совет. В случае использования свойства KeepAlive для сохранения страницы в действующем состоянии, при следующем возврате к ней событие Initialized генерироваться не будет. (Для страниц, которые не сохраняются в действующем состоянии, а "возвращаются к жизни" с помощью системы журнализации WPF, такое событие будет инициироваться при каждом их-посещении пользователем.) Если такое поведение не подходит, тогда следует обработать события Unloaded и Loaded, которые генерируются всегда. Второй вариант — построить другое проектное решение, способное передавать информацию, где это необходимо. Например, можно создать страничные функции, предназначенные для возврата информации (рассматриваются далее в этой главе). Используя страничные функции вместе с дополнительной логикой инициализации, можно разработать собственную систему для извлечения из страницы важной информации и ее сохранения в подходящем месте. С хронологией навигации WPF связан еще один недостаток. Как будет показано далее в главе, можно написать код, который будет динамически создавать объект страницы и затем переходить на нее. В такой ситуации обычный механизм сохранения состояния страницы работать не будет. У WPF нет ссылки на XAML-документ страницы, поэтому ей не известно, как реконструировать страницу. (А если страница создается динамически, у нее может вообще не быть соответствующего XAML-документа.) В такой ситуации WPF всегда будет сохранять объект страницы в памяти, каким бы ни было значение свойства KeepAlive. Добавление специальных свойств Обычно все поля в классе страницы утрачивают свои значения при уничтожении данной страницы. Чтобы добавить в класс страницы какие-то специальные свойства и сделать так, чтобы они сохраняли свои значения, можно соответствующим образом устанавливать журнальный флаг. Однако подобное действие в отношении обычного свойства или поля невозможно. Поэтому в таком случае необходимо создать в классе страницы свойство зависимости. О свойствах зависимости уже рассказывалось в главе 4. Для создания свойства зависимости потребуется выполнить два шага. Во-первых, необходимо создать определение свойства зависимости, а во-вторых — добавить процедуру обычного свойства, устанавливающую или извлекающую значение этого свойства зависимости. Чтобы определить свойство зависимости, необходимо создать статическое поле, такое как показанное ниже: private static DependencyProperty MyPageDataProperty; По соглашению поле, определяющее свойство зависимости, должно обязательно иметь то же имя, что и обычное свойство, но со словом Property в конце. На заметку! В данном примере используется приватное свойство зависимости. Причина в том, что единственный код, которому требуется получать доступ к этому свойству, находится в классе страницы, где это свойство определено. В завершение необходим статический конструктор, регистрирующий определение свойства зависимости. Именно здесь указываются службы, которые должны применяться со свойством зависимости (вроде поддержки привязки данных, анимации и журнализации):
748 Глава 24. Страницы и навигация static PageWithPersistentData () { FrameworkPropertyMetadata metadata = new FrameworkPropertyMetadata(); metadata.Journal = true; MyPageDataProperty = DependencyProperty.Register( "MyPageDataProperty", typeof(string), typeof(PageWithPersistentData), metadata, null); } Затем можно создавать обычное свойство, упаковывающее данное свойство зависимости. Однако при написании процедур извлечения и установки следует использовать методы GetValueO и SetValueO, определенные в базовом классе DependencyObject: private string MyPageData { set { SetValue(MyPageDataProperty, value); } get { return (string)GetValue(MyPageDataProperty); } } Осталось только добавить все эти детали в одну страницу (в данном примере это PageWithPersistentData). После этого значение свойства MyPageData будет автоматически сериализироваться, когда пользователь покидает страницу, и восстанавливаться при его возвращении. Служба навигации Пока что все рассмотренные возможности навигации подразумевали в основном применение гиперссылок. Когда такой подход работает, он элегантен и прост. Однако в некоторых случаях бывает необходимо иметь больший контроль над процессом навигации. Например, гиперссылки прекрасно подходят, если страницы используются для воспроизведения фиксированной, линейной последовательности шагов, которую пользователь должен проходить от начала до конца (вроде мастера). Однако если нужно, чтобы пользователь выполнял только какие-то небольшие последовательности шагов и возвращался на общую страницу, или же требуется сконфигурировать последовательность шагов на основе каких-нибудь других деталей (например, предыдущих действий пользователя), необходимо нечто большее. Программная навигация Можно сделать так, чтобы значения свойств Hyperlink.NavigateUri и Frame. Source устанавливались динамически. Однако самый гибкий и мощный подход предполагает использование службы навигации WPF. Получать доступ к этой службе навигации можно через контейнер, в котором размещена страница (например, объект Frame или NavigationWindow), но такой подход ограничивает страницы использованием в контейнере только этого типа. Поэтому лучше всего получать доступ к службе навигации через статический метод NavigationService.GetNavigationService(). Этому методу передается ссылка на страницу, а он возвращает действующий объект NavigationService, который позволяет реализовать навигацию программно: NavigationService nav; nav = NavigationService.GetNavigationService(this); Этот код работает независимо от того, какой контейнер выбран для обслуживания страниц.
Глава 24. Страницы и навигация 749 На заметку! Объект NavigationService нельзя использовать ни в конструкторе страницы, ни на этапе срабатывания события Page.Initialized. Больше всего для этого подходит событие Page.Loaded В классе NavigationService определен набор методов для работы с навигацией. Наиболее часто используется метод Navigate (), который позволяет переходить на страницу по ее URI: nav.Navigate (new System.Uri ("Pagel .xarnl", UnKind.RelativeOrAbsolute) ) ; или за счет создания соответствующего объекта страницы: Pagel nextPage = new Pagel (); nav.Navigate(nextPage); По возможности лучше всегда применять URI, поскольку это позволяет системе журнализации WPF сохранять данные страницы, не удерживая в памяти все дерево ее объектов. Когда методу Navigate () передается объект страницы, в памяти сохраняется весь объект. Однако создание объекта страницы вручную может понадобиться, если в страницу необходимо передать какую-то информацию. Передать эту информацию можно либо с помощью специального конструктора класса страницы (что является наиболее распространенным подходом), либо вызовом специального метода в классе страницы после создания. Если в страницу добавлен новый конструктор, в нем должен обязательно вызываться метод InitializeComponent() для обработки кода разметки и создания объектов элементов управления. На заметку! Решив использовать программную навигацию, вы сами определяете, что применять: навигационные кнопки, гиперссылки или что-нибудь еще. Как правило, для принятия решения о том, на какую страницу следует переходить, в обработчике событий используется условный код. Навигация в WPF осуществляется асинхронным образом. Это значит, что запрос на навигацию можно отменять до того, как он будет выполнен, посредством вызова метода NavigationService.StopLoadingO. Вдобавок можно использовать метод Refresh() для повторной загрузки страницы. И, наконец, класс NavigationService также предоставляет методы GoBackO и GoForwardO, которые позволяют перемещаться по спискам предыдущих и следующих страниц. Это удобно в ситуации, когда разработчик создает собственные навигационные элементы управления. Оба эти метода генерируют исключение InvalidOperationException при попытке перехода на страницу, которой не существует (например, при попытке вернуться назад, находясь на первой странице). Во избежание таких ошибок перед вызовом соответствующих методов следует проверять значения булевских свойств CanGoBack и CanGoForward. События навигации Класс NavigationService также предоставляет полезный набор событий, которые можно использовать для реагирования на навигацию. Наиболее распространенной причиной реагирования на навигацию является необходимость выполнения по ее завершении какой-то задачи. Например, если страница размещается внутри рамки в обычном окне, может понадобиться, чтобы по завершении навигации в окне обновлялся текст строки состояния. Поскольку навигация осуществляется асинхронным образом, возврат из метода Navigate () происходит до появления целевой страницы. В некоторых случаях разница
750 Глава 24. Страницы и навигация во времени может быть значительной, например, как в случае перехода на несвязанную XAML-страницу, расположенную на веб-сайте (или на XAML-страницу, находящуюся в другой сборке, которая инициирует веб-загрузку), или в случае, когда страница включает длительно выполняющийся код в обработчике событий Initialized или Loaded. Процесс навигации в WPF описан ниже. 1. Определяется местонахождение страницы. 2. Извлекается информация о странице. (Если страница находится на удаленном сайте, тогда она на этом этапе загружается.) 3. Устанавливается местонахождение всех необходимых странице и связанных с ней ресурсов (например, изображений) и выполняется их загрузка. 4. Осуществляется синтаксический анализ страницы и генерируется дерево ее объектов. На этом этапе страница запускает события Initialized (если только она не восстанавливается из журнала) и Loaded. 5. Страница визуализируется. 6. Если URI включает фрагмент, WPF переходит сразу же к этому элементу. В табл. 24.2 перечислены события, генерируемые классом Navigation Service в течение этого процесса. Эти события навигации также предоставляются классом Application и навигационными контейнерами (такими как NavigationWindow и Frame). При наличии более одного навигационного контейнера это дает возможность обрабатывать процесс навигации в разных контейнерах по отдельности. Однако встроенного способа для обработки навигационных событий единственной страницы не существует. Таблица 24.2. События класса NavigationService Имя Описание Navigating Navigated NavigationProgress LoadCompleted FragmentNavigation Navigationstopped NavigationFailed Процесс навигации начинается. Это событие можно отменить и тем самым предотвратить выполнение навигации Процесс навигации начался, но целевая страница еще не извлечена Процесс навигации идет полным ходом, и часть данных страницы уже загружена. Это событие вызывается периодически для предоставления информации о ходе навигации. Оно предоставляет информацию об объеме данных, которые уже загружены (NavigationProgressEventArgs.BytesRead), и общем объеме данных, которые требуется загрузить (NavigationProgressEventArgs .MaxBytes). Это событие запускается после каждого извлечения данных объемом 1 Кбайт Страница прошла синтаксический анализ. Однако события Initialized и Loaded еще не были сгенерированы Страница готовится к прокручиванию до целевого элемента. Это событие срабатывает только в случае, если используется URI с информацией о фрагменте Процесс навигации был отменен с помощью метода StopLoadingO Процесс навигации не удался из-за того, что не получилось обнаружить или загрузить целевую страницу. Это событие можно использовать для нейтрализации исключения до того, как оно появится и превратится в необработанное исключение приложения. Для этого необходимо просто установить NavigationFailedEventArgs. Handled в true
Глава 24. Страницы и навигация 751 После присоединения к навигационному контейнеру службы навигации и обработчика событий он продолжает генерировать события при переходе со страницы на страницу (или до тех пор, пока обработчик событий не будет удален). В целом это означает, что навигацию легче всего обрабатывать на уровне приложения. События навигации нельзя подавлять с помощью свойства RoutedEventArgs. Handled. Причина в том, что они являются не маршрутизируемыми, а обычными событиями .NET. Совет. Навигационным событиям можно передавать данные из метода Navigate (). Нужно просто использовать ту из перегруженных версий метода Navigate (), которая в качестве параметра принимает дополнительный объект. Этот объект сделан доступным в событиях Navigated, NavigationStopped и LoadCompleted через свойство NavigationEventArgs. ExtraData. Данное свойство можно использовать, например, для отслеживания времени поступления запроса на навигацию. Управление журналом Описанные до этого приемы позволяют создать приложение с возможностями линейной навигации и сделать процесс навигации легко адаптируемым (например, применив условную логику, так чтобы пользователи по пути направлялись к разным шагам), но все равно ограничивают базовым подходом, подразумевающим проход от начала до конца. На рис. 24.8 показана такая топология навигации, которая является типичной при создании простых мастеров, основанных на описании задач. Пунктирными линиями обозначены интересующие нас шаги — когда пользователь покидает группу страниц, представляющих логическую задачу. > Г Начальная страница Navigate() ^ Назад 4 Navigate() w г Вызывающая страница 1 t_ i 1 Отмена 1 1 Страница 1 ^ 4 Готово Назад Navigate() w W 1 1 1 1 Страница 2 Рис. 24.8. Линейная навигация Если попробовать реализовать такое проектное решение с использованием процесса навигации WPF, то обнаружится, что в нем не хватает одной детали. В частности, нужно, чтобы по завершении процесса навигации пользователем (либо из-за отмены им операции на одном из этапов, либо из-за выполнения им задачи вручную) очищался список предыдущих страниц. Если работа приложения сконцентрирована вокруг одного главного окна, которое не основано на процессе навигации, это не проблема. При запуске пользователем задачи, связанной с переходом на
752 Глава 24. Страницы и навигация страницу, приложение может просто создать новый объект NavigationWindow, который позволяет сделать это. По завершении задачи этот объект может быть уничтожен. Однако если все приложение основано на навигации, тогда ситуация усложняется. В таком случае необходимо придумать способ для очистки списка ранее посещенных страниц при отмене или завершении задачи, чтобы пользователь не мог вернуться назад на один из промежуточных шагов. К сожалению, в WPF не позволяет получить высокий контроль над процессом навигации. Все, что имеется — это два метода в классе NavigationService: AddBackEntryO и RemoveBackEntry (). В рассматриваемом примере необходим метод RemoveBackEntry (), который берет самый недавний элемент в списке предыдущих страниц и удаляет его. Кроме того, RemoveBackEntryO возвращает объект JournalEntry, описывающий этот элемент Он сообщает URI-адрес (через свойство Source) и имя, которое тот имеет в журнале навигации (через свойство Name). He забывайте, что имя устанавливается на основе свойства Page.Title. Если по завершении задачи должно удаляться сразу несколько записей, потребуется вызывать метод RemoveBackEntryO несколько раз. Здесь возможны два варианта. При удалении всего списка предыдущих шагов достижение его конца определяется с помощью свойства CanGoBack: while (nav.CanGoBack) { nav.RemoveBackEntry(); } В качестве альтернативы можно удалять элементы до тех пор, пока не будет удалена начальная точка выполнения задачи. Например, если выполнение задачи начинается на странице Conf igureAppWizard.xaml, по его завершении можно использовать такой код: string pageName; while (pageName != "ConfigureAppWizard.xaml") { JournalEntry entry = nav.RemoveBackEntry(); pageName = System.10.Path.GetFileName(entry.Source.ToString()); } Этот код берет полный URI, который хранится в свойстве JournalEntry.Source, и усекает его до имени страницы с помощью статического метода GetFileNameO класса Path (который также эффективно работает и с URI). Использование свойства Title сделало бы кодирование более удобным, но не таким надежным. Поскольку заголовок страницы отображается в хронологии навигации и является видимым для пользователя, он представляет собой фрагмент информации, который в случае локализации приложения потребуется переводить на другие языки. А это чревато нарушением кода, который ожидает жестко закодированного заголовка страницы. И даже если приложение не планируется локализовать, нетрудно представить другой сценарий с изменением заголовка страницы, например, для того, чтобы тот был более понятным или более описательным. Кстати, все элементы в списке предыдущих и следующих страниц можно просматривать с помощью свойств BackStack и ForwardStack навигационного контейнера (вроде NavigationWindow или Frame). Однако получать эту информацию через класс NavigationService нельзя. В любом случае эти свойства предоставляют простые и доступные только для чтения объекты JournalEntry. Вносить изменения в списки они не позволяют, и поэтому реальная необходимость в них возникает крайне редко.
Глава 24. Страницы и навигация 753 Добавление в журнал специальных элементов Вместе с методом RemoveBackEntryO класс NavigationService также предоставляет метод AddBAckEntry (). Этот метод позволяет сохранять в списке предыдущих страниц "виртуальные" записи. Например, предположим, что имеется одна страница, которая позволяет пользователю выполнять довольно сложную задачу по конфигурированию. Если нужно сделать так, чтобы пользователь мог возвращаться к предыдущему состоянию этого окна, его можно сохранить с помощью метода AddBackEntry (). Несмотря на то что страница всего одна, она может иметь несколько связанных записей в списке. Вопреки возможным ожиданиям, при вызове метода AddBackEntryO объект JournalEntry передавать не требуется. (На самом деле класс JournalEntry имеет защищенный конструктор, поэтому создать его экземпляр не получится.) Вместо этого понадобится создать специальный класс, унаследованный от абстрактного класса System.Windows.Navigation.CustomContentState и сохраняющий всю необходимую информацию. Например, взгляните на приложение, показанное на рис. 24.9, которое позволяет перемещать элементы из одного списка в другой. i<- Remove Рис. 24.9. Динамический список Теперь предположим, что состояние этого окна должно сохраняться при каждом перемещении элемента из одного списка в другой. Первое, что потребуется — это класс, унаследованный от CustomContentState и отслеживающий эту необходимую информацию. В данном случае нужно просто записать содержимое обоих списков. Поскольку этот класс будет сохраняться в журнале (для того, чтобы страница могла при необходимости "восстанавливаться''), он должен допускать сериализацию. [SerializableO ] public class ListSelectionJournalEntry : CustomContentState { private List<String> sourceltems; private List<String> targetltems; public List<String> Sourceltems { get { return sourceltems; } }
754 Глава 24. Страницы и навигация public List<String> Targetltems { get { return targetltems; } } Это дает хорошее начало, но все равно еще нужно много чего сделать. Например, вряд ли захочется, чтобы страница появлялась в хронологии навигации с одним и тем же заголовком множество раз. Потребуется использовать какое-то более описательное имя. Для этого придется переопределить свойство JournalEntryName. В данном примере никакого очевидного и логичного способа для описания состояния обоих списков нет. Поэтому имеет смысл позволить странице самой выбирать имя при сохранении записи в журнале. В таком случае страница сможет добавлять описательное имя на основе самого последнего действия (вроде Added Blue или Removed Yellow). Для реализации такого проектного решения необходимо сделать свойство JournalEntryName зависимым от переменной, установить которую можно непосредственно в конструкторе: private string _journalName; public override string JournalEntryName { get { return _journalName; } } Система навигации WPF будет обращаться к свойству JournalEntryName для получения имени, которое должно быть показано в списке. Следующий шаг состоит в переопределении метода Replay (). WPF вызывает этот метод, когда пользователь переходит к записи в списке предыдущих или следующих страниц, позволяя применять предыдущее сохраненное состояние. Существуют два подхода, которые можно использовать в методе Replay (). Первый — извлечь ссылку на текущую страницу с помощью свойства NavigationService. Content, а затем привести эту страницу к типу соответствующего класса страницы и вызвать любой требуемый для реализации задуманного изменения метод. Второй подход (который иллюстрируются здесь) — полагаться на обратный вызов: private ReplayListChange replayListChange; public override void Replay(NavigationService navigationService, NavigationMode mode) { this.replayListChange (this); } Делегат ReplayListChange здесь не показан, но выглядит он довольно просто. Он представляет метод с одним параметром — объектом ListSelectionJournalEntry. Далее страница может извлекать информацию списка из свойств Sourceltems и Targetltems и восстанавливать состояние. После этого остается только создать конструктор, принимающий всю необходимую информацию: два списка элементов; заголовок, который должен использоваться в журнале; и делегат, который должен запускаться при необходимости в повторном применении состояния к странице.
Глава 24. Страницы и навигация 755 public ListSelectionJournalEntry( List<String> sourceltems, List<String> targetltems, string journalName, ReplayListChange replayListChange) { this.sourceltems = sourceltems; this.targetltems = targetltems; this.journalName = journalName; } } Чтобы добавить эту функциональность к странице, потребуется выполнить три описанных ниже шага. 1. Вызвать в подходящее время метод AddBackReferenceO для сохранения в хронологии навигации дополнительной записи. 2. Обработать обратный вызов ListSelectionJournalEntry для восстановления окна при проходе пользователя по хронологии. 3. Реализовать в своем классе страницы интерфейс IProvideCustomContentState и его единственный метод GetContentState(). При переходе пользователя на другую страницу в хронологии метод GetContentStateO вызывается службой навигации. Это позволяет вернуть экземпляр специального класса, который будет храниться как состояние текущей страницы. На заметку! Интерфейс IProvideCustomContentState является часто пропускаемой, но очень существенной деталью. Когда пользователь выполняет навигацию с помощью списка следующих или предыдущих страниц, должны происходить две вещи: страница должна добавить текущее представление в журнал (с помощью IProvideCustomContentState), а затем восстановить выбранное представление (с помощью обратного вызова ListSelectionJournalEntry). Для начала при каждом щелчке на кнопке Add (Добавить) нужно создать новый объект ListSelectionJournalEntry и вызвать метод AddBackReferenceO, чтобы предыдущее состояние сохранилось в хронологии. Этот процесс выносится в отдельный метод для того, чтобы его можно было использовать в нескольких местах на странице (например, при щелчке на кнопке Add (Добавить) или Remove (Удалить)): private void cmdAdd_Click(object sender, RoutedEventArgs e) { if (IstSource.Selectedlndex != -1) { // Определяем наиболее подходящее имя для использования //в хронологии навигации. NavigationService nav = NavigationService.GetNavigationService(this); string itemText = IstSource.Selectedltem.ToString(); string journalName = "Added " + itemText; // Обновляем журнал (с помощью приведенного ниже метода). nav.AddBackEntry(GetJournalEntry(journalName)); // Теперь вносим изменение. IstTarget.Items.Add(itemText); IstSource.Items.Remove(itemText); } private ListSelectionJournalEntry GetJournalEntry(string journalName) {
756 Глава 24. Страницы и навигация // Извлекаем информацию о состоянии обоих списков // (с помощью вспомогательного метода). List<String> source = GetListState (IstSource); List<String> target = GetListState(IstTarget); // Создаем с помощью этой информации специальный объект состояния. // Указываем обратному вызову на метод Replay в этом классе, return new ListSelectionJournalEntry ( source, target, journalName, Replay); } Похожий процесс используется при щелчке на кнопке Remove. Следующее, что потребуется сделать — обработать обратный вызов в методе ReplayO и обновить списки: private void Replay(ListSelectionJournalEntry state) { IstSource.Items.Clear (); foreach (string item in state.Sourceltems) { IstSource.Items.Add (item); } IstTarget.Items.Clear(); foreach (string item in state.Targetltems) { IstTarget.Items.Add(item); } } И, наконец, последний шаг — реализовать в странице интерфейс IProvideCustom ContentState: public partial class PageWithMultipleJournalEntries : Page, IProvideCustomContentState Интерфейс IProvideCustomContentState определяет единственный метод по имени GetContentState(). В этом методе необходимо сохранить состояние для страницы точно таким же образом, как и при щелчке на кнопке Add или Remove. Единственное отличие в том, что его не нужно добавлять с помощью метода AddBackReference(). Вместо этого его следует предоставить WPF через возвращаемое значение. public CustomContentState GetContentState () { // Мы не сохраняли самое последнее действие, поэтому // используем для заголовка просто имя страницы. return GetJournalEntry("PageWithMultipleJournalEntries"); } Не забывайте о том, что служба навигации WPF вызывает метод GetContentState(), когда пользователь переходит на другую страницу с помощью кнопки возврата назад или перехода вперед. WPF берет возвращаемый объект CustomContentState и сохраняет его в журнале для текущей страницы. Здесь возможна одна особенность: в случае выполнения пользователем нескольких действий и их отмены путем возврата назад в хронологии навигации, "отмененные" действия будут иметь в хронологи жестко закодированное имя страницы (PageWithMultipleJournalEntries), а не более описательное исходное имя (вроде Added Orange). Чтобы улучшить способ обработки этой детали, можно сохранить журнальное имя для страницы в классе страницы с помощью переменной экземпляра. В загружаемом коде этого примера такой дополнительный шаг реализован. На этом пример завершен. Теперь после запуска приложения и манипуляций со списками в хронологии будет отображаться несколько записей (рис. 24.10).
Глава 24. Страницы и навигация 757 ■ Menu v Current Page Removed Black Added Black Added Orange Added Blue Mam Menu : i Click bete to continue. >ЩРИНИ ] <-Remove! Blue Orange Рис. 24.10. Специальные записи в журнале Страничные функции Пока что демонстрировалось только то, как передавать информацию странице (за счет программного создания экземпляра страницы, его конфигурирования и последующей передачи методу NavigationService.Navigate ()), но еще не было показано, как возвращать информацию из страницы. Самый простой (и наименее структурированный) подход — сохранить информацию в какой-то статической переменной приложения, так чтобы она было доступна любому другому классу в программе. Однако такое проектное решение является далеко не самым лучшим, если требуется всего лишь способ для передачи простых фрагментов информации с одной страницы на другую, и не нужно, чтобы эта информация находилась в памяти в течение долгого времени. Добавление глобальных переменных в таком случае лишь усложнит вычисление зависимостей (какие переменные какими страницами используются), а также значительно затруднит многократное использование страниц и сопровождение приложения. Другой подход, предлагаемый в WPF, предусматривает применение класса PageFunction. Класс PageFunction представляет собой производную версию класса Page с возможностью возвращения результата. В некоторой степени класс PageFunction напоминает диалоговое окно, в то время как класс Page больше похож на обычное окно. Чтобы создать PageFunction в Visual Studio, щелкните правой кнопкой мыши на проекте в окне Solution Explorer и выберите в контекстном меню пункт Add^New Item (Добавить^ Новый элемент). Перейдите в категорию WPF, выберите шаблон Page Function (WPF) (Страничная функция (WPF)), введите имя требуемого файла и щелкните на кнопке Add (Добавить). Код разметки класса PageFunction практически идентичен тому, что используется для класса Page. Единственное отличие в том, что корневым элементом является <PageFunction>, а не <Раде>. Формально PageFunction является полиморфным классом. Он принимает единственный параметр, который указывает, данные какого типа должны использоваться для возвращаемого им значения. По умолчанию для каждого нового класса PageFunction этим параметром является строка (а это означает, что он должен возвращать одну строку). Однако эту деталь можно легко изменить, модифицировав значение атрибута TypeArguments в элементе <PageFunction>.
758 Глава 24. Страницы и навигация Ниже приведен пример, в котором класс PageFunction возвращает экземпляр специального класса по имени Product. Для поддержки такой структуры имя соответствующего пространства имен (NavigationApplication) в элементе <PageFunction> отображается на подходящий префикс XML (local), который затем используется при установке значения для атрибута Ту ре Arguments: <PageFunction xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2 00 6/xaml" xmlns:local="clr-namespace:NavigationApplication" x:Class="NavigationApplication.SelectProductPageFunction" x:TypeArguments="local:Product" Title="SelectProductPageFunction" > Кстати, в случае установки в коде разметки значения для атрибута TypeArguments указывать эту же информацию в объявлении класса не требуется. Вместо этого анализатор XAML будет генерировать правильный класс автоматически. Это означает, что для объявления приведенного выше класса PageFunction вполне достаточно использовать следующий код: public partial class SelectProductPageFunction { . .. } Хотя показанный ниже более явный код тоже будет нормально работать: public partial class SelectProductPageFunction: PageFunction<Product> { ... } При создании PageFunction в Visual Studio используется именно такой более явный синтаксис. По умолчанию все новые классы PageFunction, которые создает Visual Studio, наследуются от PageFunction<string>. Класс PageFunction требует, чтобы все выполняемые в нем процессы навигации обрабатывались программно. Поэтому при щелчке на кнопке или ссылке, приводящей к завершению задачи, код должен вызывать метод PageFunction.OnReturn(). После этого нужно, чтобы предоставлялся либо возвращаемый объект (экземпляр указанного в объявлении класса), либо значение null, указывающее, что задача не была выполнена. Ниже приведен пример с двумя обработчиками событий: private void lnkOK_Click(object sender, RoutedEventArgs e) { // Возвращаем информацию о выборе. OnReturn(new ReturnEventArgs<Product>(IstProducts.SelectedValue)); } private void lnkCancel_Click(object sender, RoutedEventArgs e) { // Указываем, что ничего не было выбрано. OnReturn(null); } В применении класса PageFunction тоже нет ничего сложного. Вызывающая страница должна создавать экземпляр PageFunction программно из-за необходимости в присоединении обработчика событий к событию PageFunction.Returned. (Этот дополнительный шаг необходим потому, что метод NavigationService .Navigate () является асинхронным и возвращается немедленно.) SelectProductPageFunction pageFunction = new SelectProductPageFunction(); pageFunction.Return += new ReturnEventHandler<Product>( SelectProductPageFunction_Returned); this.NavigationService.Navigate(pageFunction);
Глава 24. Страницы и навигация 759 Когда пользователь завершает работу с PageFunction и щелкает на ссылке, вызывающей метод OnReturn(), генерируется событие PageFunction.Returned. Возвращаемый объект становится доступным через свойство ReturnEventArgs.Result. private void SelectProductPageFunction_Returned(object sender, ReturnEventArgs<Product> e) { Product product = (Product)e.Result; if (e l= null) lblStatus.Text = "You chose: " + product.Name; } Обычно метод OnReturnO обозначает конец задачи. После этого у пользователя не должно быть возможности возврата обратно к PageFunction. Для этого можно воспользоваться методом NavigationService.RemoveBackEntry(), но имеется более простой подход. Каждый класс PageFunction также имеет свойство по имени RemoveFromJournal. Если установить это свойство в true, страница автоматически удаляется из хронологии при вызове метода OnReturn(). Добавление в приложение класса PageFunction даст возможность использовать другую топологию навигации, например, назначить одну страницу центральным ядром и позволить пользователям выполнять различные задачи через страничные функции, как показано на рис. 24.11. Вызывающая страница NavigateQ + OnRetum() Страница Страница Страница Рис. 24.11. Линейная навигация
760 Глава 24. Страницы и навигация Часто PageFunction будет вызывать другую страничную функцию. В этом случае для обработки процесса навигации по его завершении рекомендуется использовать последовательность связанных вызовов OnReturn(). Другими словами, если PageFunctionl вызывает PageFunction2, который затем обращается к PageFunction3, то когда PageFunction3 вызывает метод OnReturn(), запускается обработчик событий Returned в PageFunction2, который затем вызывает метод OnReturn(), приводящий к срабатыванию события Returned в PageFunctionl, в котором, наконец, вызывается метод OnReturn() для завершения всего процесса. В зависимости от проектного решения, может понадобиться, чтобы возвращаемый объект передавался по всей последовательности до тех пор, пока не достигнет корневой страницы. Приложения ХВАР Приложения ХВАР (XAML Browser Application — браузерное приложение XAML) — это приложения, которые выполняются внутри браузера. Они являются полноценными приложениями WPF, но имеют несколько основных отличий, описанных ниже. • ХВАР-приложения выполняются внутри окна браузера. Они могут занимать всю область изображения веб-страницы или размещаться где-то внутри обычного HTML- документа с помощью дескриптора <iframe> (как будет показано чуть позже). На заметку! Формально WPF-приложение любого типа, включая приложение ХВАР, запускается в виде отдельного процесса, управляемого общеязыковой исполняющей средой (Common Language Runtime — CLR). Нам кажется, что приложение ХВАР выполняется "внутри" браузера просто потому, что оно отображает все свое содержимое в окне браузера. Это отличает его от модели, применяемой в элементах управления ActiveX (и приложениях Silverlight), которые на самом деле загружаются внутри процесса браузера. • ХВАР-приложения обычно имеют ограниченные разрешения. Хотя приложение ХВАР можно сконфигурировать так, чтобы оно запрашивало разрешение на полный доступ, цель состоит в том, чтобы использовать приложение ХВАР в качестве облегченной модели развертывания, которая позволяет пользователям выполнять WPF-приложения без риска со стороны потенциально небезопасного кода. Приложению ХВАР предоставляются те же разрешения, что и приложению .NET, которое запускается из Интернета или локальной интрасети, к тому же и механизм, налагающий эти ограничения (безопасность доступа кода) — тот же самый. Это означает, что по умолчанию приложение ХВАР не может записывать файлы, взаимодействовать с ресурсами компьютера (такими как системный реестр), подключаться к базам данных или отображать полнофункциональные окна. • ХВАР-приложения не устанавливаются. При запуске приложения ХВАР оно загружается и помещается в кэш браузера. Однако в системе оно не устанавливается. Это обеспечивает веб-модель мгновенного обновления. Другими словами, при каждом возвращении пользователя к использованию данного приложения, происходит загрузка новейшей его версии, если она еще не находится в кэше. Преимущество приложений ХВАР состоит в том, что они позволяют работать безо всяких подсказок и приглашений. При наличии установленной версии .NET клиент может открыть приложение ХВАР в браузере и работать с ним точно так же, как с Java-аплетом, Flash-фильмом или оснащенной JavaScript-сценариями веб-страницей. Никакого приглашения выполнить установку и предупреждения, связанного с безопасностью, не появляется. Очевидно, что расплачиваться за это приходится следованием очень жестко ограниченной модели безопасности. Если приложению требуются боль-
Глава 24. Страницы и навигация 761 шие возможности (например, чтение или запись произвольных файлов, взаимодействие с базой данных, работа с системным реестром Windows и т.д.), его лучше строить как автономное Windows-приложение, обеспечив упрощенным (хотя и не полностью бесшовным) процессом развертывания с помощью ClickOnce (см. главу 33). Требования для приложений ХВАР В настоящее время приложения ХВАР способны запускать только два браузера: Internet Explorer (версии 6 или выше) и Firefox (версии 2 или выше). Для запуска любого приложения WPF, в том числе ХВАР, на клиентском компьютере должна быть обязательно установлена версия .NET Framework 3.0 или выше. Конкретная версия .NET зависит от того, какие функциональные средства WPF используются, и от того, какая версия .NET была выбрана в качестве целевой (см. главу 1). Эти требования не являются столь уж ограничивающими, как может показаться. Важно помнить о том, что ОС Windows Vista изначально включает в себя версию .NET 3.0, a Windows 7 — версию .NET 3.5 с пакетом обновлений SP1. Поэтому на компьютерах с этими операционными системами приложения ХВАР распознаются автоматически. Вдобавок пользователи, работающие в браузере Internet Explorer версии 7 (или выше) под управлением Windows XP, запускать приложения ХВАР, возможно, не смогут, но браузер все равно их распознает В таком случае при запросе пользователем файла .xbap браузер Internet Explorer предложит установить платформу .NET Framework (рис. 24.12). Microsoft .NET Framework Setup Would you like to download and install the Microsoft NET Framework? The content you are trying to access requires the Microsoft NET Framework. Would you like to download and install this from Microsoft Download Center now? More Information You can check on setup status and progress from the installer's tray icon. finstalNow] [ Cancel Рис. 24.12. Попытка запуска приложения ХВАР в Internet Explorer 7.0 без установленного компонента .NET Framework Создание приложения ХВАР Любое страничное приложение может стать приложением ХВАР, хотя Visual Studio для этого вынуждает создавать новый проект с помощью шаблона WPF Browser Application (Браузерное WPF-приложение). Разница кроется в четырех ключевых элементах в файле проекта .csproj: <HostInBrowser>True</HostInBrowser> <Install>False</Install> <ApplicationExtension>.xbap</ApplicationExtension> <TargetZone>Internet</TargetZone> Эти дескрипторы указывают WPF на то, что приложение должно обслуживаться в браузере (HostlnBrowser), кэшироваться вместе с другими файлами Интернета без постоянной установки в системе (Install), использовать расширение .xbap (ApplicationExtension) и запрашивать разрешения только для зоны Интернета (TargetZone). Четвертый дескриптор является необязательным: как будет показано да-
762 Глава 24. Страницы и навигация лее, формально допускается создание приложения ХВАР с большим количеством разрешений. Однако приложения ХВАР практически всегда выполняются с ограниченным набором разрешений, доступных в зоне Интернета, что является самым сложным аспектом для успешного программирования такого приложения. Совет. Файл .csproj также включает и другие связанные с приложением ХВАР дескрипторы, которые гарантируют правильный процесс отладки. Самый простой способ изменить приложение ХВАР на страничное приложение с автономным окном (или наоборот) — это создать новый проект желаемого типа и затем импортировать все страницы из старого проекта. После создания приложения ХВАР можно приступать к разработке страниц и кодировать их точно так же, как и при использовании NavigationWindow. Например, можно указать в свойстве StartupUri внутри файла App.xaml одну из страниц. В результате компиляции приложения будет сгенерирован файл .xbap. Затем этот файл .xbap можно запросить в Internet Explorer или Firefox. При условии, что в системе установлен компонент .NET Framework, приложение запустится автоматически в режиме с ограниченным доверием, как показано на рис. 24.13. 0 D:\Code\ jvtf «W Pro WPf\Chapter09\XBAP\bin\Debug\XBAPjcbap : * ter09\XBAP\bin\Debug\XBAP.xbap Ь XBAP.xbap \ * • 1 This is a simple page. Click here to go to РадеЗ. - Windows Internet. Google 0 * m ▼ •• ' Щ Computer | Protected Mode: Off 'Page .щашт] ■ » TcjoIs ▼ . . "....".... J Рис. 24.13. Приложение ХВАР в окне браузера Приложение ХВАР выполняется точно так же, как и обычное приложение WPF, если только не пытаться выполнить какие-то запрещенные действия (вроде отображения автономного окна). В случае запуска приложения в Internet Explorer версии 7 или выше места кнопок, отображаемых в NavigationWindow, занимают кнопки браузера, и они же и позволяют просматривать списки предыдущих и следующих страниц. В более ранних версиях Internet Explorer и в Firefox в верхней части страницы отображается совершенно другой набор навигационных кнопок (не столь удобный). Развертывание приложения ХВАР Хотя для приложения ХВАР можно создать программу установки (и запускать его с локального жесткого диска), необходимость в этом возникает редко. Взамен лучше просто скопировать скомпилированное приложение в какую-то сетевую папку или виртуальный каталог.
Глава 24. Страницы и навигация 763 На заметку! Добиться подобного эффекта можно и с применением несвязанных файлов XAML. Если приложение полностью состоит из страниц XAML и не имеет никаких файлов отделенного кода, его вообще не нужно компилировать. Вместо этого лучше просто разместить соответствующие файлы .xaml на веб-сервере и позволить пользователям переходить к ним напрямую. Конечно, очевидно, что несвязанные XAML-файлы не могут делать столько же, сколько их скомпилированные аналоги, но они вполне подходят, если требуется всего лишь отобразить документ, графику или анимацию, либо же если вся необходимая функциональность добавляется с помощью декларативных выражений привязки. К сожалению, развертывание приложения ХВАР не является столь же простым процессом, как просто копирование файла .xbap. В таком случае в одну и ту же папку понадобится скопировать три перечисленных ниже файла. • ИмяПриложения.ехе. В этом файле содержится скомпилированный IL-код, который имеется в любом приложении .NET. • ИмяПриложения.exe.manifest. Этот файл представляет собой XML-документ с перечнем требований данного приложения (наподобие версии .NET-сборок, использованных для компиляции кода). Если приложение работает с какими-то другими DLL-файлами, их можно делать доступными в том же самом виртуальном каталоге, что и приложение, и тогда они будут загружаться автоматически. • ИмяПриложения.xbap. Файл .xbap — это еще один XML-документ. Он представляет точку входа в приложение; другими словами, это файл, который пользователь должен запросить в браузере, чтобы установить данное приложение ХВАР. Код разметки в файле .xbap указывает на файл приложения и включает цифровую подпись, которая использует выбранный разработчиком для проекта ключ. После размещения этих файлов в подходящем месте можно пробовать запускать приложение, запросив его файл .xbap в Internet Explorer или Firefox. Находятся файлы на локальном жестком диске или на удаленном веб-сервере, не имеет значения — они запрашиваются совершенно одинаково. Совет. Несмотря на соблазн, запускать файл .ехе не следует. В этом случае ничего не произойдет. Вместо этого дважды щелкните на файле .xbap в проводнике Windows (или введите путь к этому файлу в адресной строке веб-браузера). В любом случае должны присутствовать все три файла, а браузер должен быть способен распознавать файлы с расширением .xbap. Начав загрузку файла .xbap, браузер отобразит страницу с информацией о ходе процесса загрузки (рис. 24.14). Этот процесс загрузки, по сути, представляет собой тот же процесс установки и подразумевает копирование приложения .xbap в локальный кэш файлов Интернета. При возвращении пользователя к этому же самому удаленному месту во время последующих посещений применяться будет версия, находящаяся в кэше (за исключением случая, если на сервере окажется более новая версия данного приложения ХВАР, о чем более подробно речь пойдет в следующем разделе). При создании нового приложения ХВАР среда Visual Studio также включает в него генерируемый автоматически файл сертификата с именем вроде ИмяПриложения_ TemporaryKey.pfx. В этом сертификате содержатся открытый и секретный ключи, которые используются для добавления в файл .xbap подписи. В случае публикации обновления для приложения его потребуется подписать с помощью точно такого же ключа, чтобы не нарушить целостность цифровой подписи. Вместо временного ключа может понадобиться создать собственный ключ (который затем можно разделять между проектами и защитить паролем). Для этого дважды щелкните на узле Properties (Свойства) под своим проектом в окне Solution Explorer и воспользуйтесь опциями на вкладке Signing (Подпись).
764 Глава 24. Страницы и навигация > D:\Code\Pro WPf\Chapter09\XBAP\bin\Debug\XBAPj(bap - Windows Internet... :oc:e\Pro WPF\Chapter09\XBAP ~\*t\ X Й Google . • ХВАР.ХОЭО Page < Bytes already downloaded: 0 KB Total bytes: 0 KB Cance. Dow A Computer I Protected Mode: Off Рис. 24.14. Запуск приложения .xbap в первый раз Обновление приложения ХВАР При отладке приложения ХВАР среда Visual Studio всегда компилирует приложение заново и загружает его самую последнюю версию в браузере. Никаких дополнительных шагов предпринимать не требуется. Однако этого не происходит, если приложение ХВАР запрашивается прямо в браузере. При запуске приложений ХВАР подобным образом существует потенциальная проблема. После повторной компиляции приложения, его развертывания в том же самом месте и повторного запроса в браузере вовсе не обязательно загружается обновленная версия. Вместо этого продолжает использоваться более старая копия приложения из кэша. То же самое происходит, даже если закрыть и заново открыть окно браузера, щелкнуть в окне браузера на кнопке Refresh (Обновить) и увеличить версию сборки приложения ХВАР. Можно вручную очистить кэш ClickOnce, но очевидно, что такое решение не является удобным. Взамен лучше обновить информацию о публикации, которая хранится в файле .xbap, чтобы браузер мог понять, что заново развернутое приложение ХВАР является его новой версией. Обновления версии сборки не достаточно для инициации процесса обновления — вместо этого должна быть обновлена опубликованная версия. На заметку! Этот дополнительный шаг обновления сведений о публикации является обязательным, потому что функция загрузки и помещения файла .xbap в кэш создается с использованием деталей из ClickOnce — технологии развертывания, о которой более подробно будет рассказываться в главе 33. ClickOnce использует версию публикации для определения того, когда должно быть применено обновление. Это позволяет компоновать приложение множество раз для целей тестирования (каждый раз с различным номером версии сборки), а опубликованную версию увеличивать только при развертывании нового выпуска. Самый простой способ скомпоновать приложение заново и применить новую версию публикации предусматривает выбор в меню Visual Studio пункта Builds Publish [ProjectName] (Скомпоновать1^Опубликовать [Имя проекта]) (и щелчок на кнопке Finish (Готово)). Использовать файлы публикации (которые находятся в папке Publish в каталоге проекта) не нужно, потому что новый сгенерированный файл .xbap в папке Debug или Release будет указывать на новую опубликованную версию. Все, что требуется — это развернуть файл .xbap (вместе с файлами .ехе и .manifest) в подходящем месте.
Глава 24. Страницы и навигация 765 При следующем запросе файла .xbap браузер загрузит новые файлы приложения и поместит их в кэш. Чтобы просмотреть текущую версию публикации, дважды щелкните на элементе Properties (Свойства) в окне Solution Explorer, перейдите на вкладку Publish (Публикация) и посмотрите настройки в разделе Publish Version (Версия публикации) в нижней части вкладки. Удостоверьтесь, что флажок Automatically Increment Revision with Each Publish (Автоматически увеличивать номер редакции с каждой публикацией) отмечен, чтобы опубликованная версия автоматически увеличивалась при публикации приложения. Это очевидным образом помечает его как новый выпуск. Безопасность приложения ХВАР Самое сложное при создании приложения ХВАР — это оставаться в рамках ограниченной модели безопасности. Обычно приложение ХВАР запускается с разрешениями зоны Интернета. Так происходит даже в том случае, если приложение ХВАР запускается с локального жесткого диска. В .NET Framework используется безопасность доступа кода (ключевое средство, которое предлагается, начиная с версии 1.0) для ограничения того, что разрешено делать приложению ХВАР. В целом ограничения соответствуют ограничениям аналогичного кода Java или JavaScript в рамках HTML-страницы. Например, визуализировать графику, проигрывать анимацию, использовать элементы управления, показывать документы и воспроизводить звуки разрешено, а получать доступ к ресурсам компьютера, таким как файлы, системный реестр Windows, базы данных и т.д. — нет. Один из простых способов узнать, разрешено ли выполнение того или иного действия — написать какой-то тестовый код и испробовать его. Также все необходимые детали можно найти в документации по WPF. В табл. 24.3 приведен краткий список наиболее важных поддерживаемых и запрещенных средств. Таблица 24.3. Ключевые средства WPF и зона Интернета Разрешено Не разрешено Все основные элементы управления, включая RichTextBox Окна Page, MessageBox и OpenFileDialog Изолированное хранилище Двух- и трехмерное рисование, аудио и видео, потоковые и XPS-документы, а также анимация Эмулируемая функция перетаскивания (код, реагирующий на события перемещения мыши) Веб-службы ASP.NET (.asmx) и службы WCF (Windows Communications Foundation) Элементы управления Windows Forms (через функции взаимодействия) Автономные окна и другие диалоговые окна (такие как SaveFileDialog) Доступ к файловой системе и системному реестру Растровые эффекты и построители текстур (вероятно, из-за того, что они полагаются на неуправляемый код) Функция перетаскивания Windows Наиболее сложные средства WCF (транспортировка не с помощью HTTP, соединения, инициируемые сервером, и протоколы WS-*) и взаимодействие с любым сервером, отличным от того, на котором обслуживается приложение ХВАР Что происходит при попытке воспользоваться функцией, которая не разрешена в зоне Интернета? Обычно приложение дает сбой при выполнении проблемного кода и генерирует исключение SecurityException.
766 Глава 24. Страницы и навигация На рис. 24.15 показан результат запуска обычного приложения ХВАР, которое пытается выполнить запрещенное действие, но не имеет кода обработки исключения SecurityException. | Л Ал error occurred m the application you were using - Windows Internet Explorer * \Code\ProWPF\Chapter09\XBAP\bin\DeDug\XBAPxDap - •• ,. Google Л An error occurred in the application yo._ ■ Fj * !f- • Page" .. Tools ▼ щЩ An error occurred in the application you were using You can try the following: • Restart the application • Click the "More Information" link below for details about this error. & More Information I 49 Computer | Protected Mode Off Рис. 24.15. Необработанное исключение в приложении ХВАР Приложения ХВАР с полным доверием Существует возможность создания приложения ХВАР, запускаемого с полным доверием, хотя поступать так не рекомендуется. Для этого дважды щелкните на узле Properties (Свойства) в окне Solution Explorer, перейдите на вкладку Security (Безопасность) и выберите переключатель This Is a Full Trust Application (Это приложение с полным доверием). Однако после этого пользователи не смогут запускать это приложение с веб-сервера или виртуального каталога. Вместо этого потребуется выполнить одно из описанных ниже действий. • Запускать приложение с локального жесткого диска. (Файл .xbap можно запускать подобно любому исполняемому файлу двойным щелчком на нем или на его ярлыке.) Может понадобиться предусмотреть программу установки для автоматизации процесса установки. • Добавить используемый для подписания сборки сертификат (который по умолчанию находится в файле .pf x) в хранилище Trusted Publishers (Надежные издатели) на целевом компьютере. Сделать это можно с помощью утилиты certmgr.exe. • Предоставить полное доверие веб-сайту или сетевому компьютеру, на котором будет развертываться файл .xbap. Для этого понадобится инструмент конфигурирования Microsoft .NET Framework Configuration Tool (он находится в разделе Administrative Tools (Администрирование) панели управления). Первый вариант наиболее прост. Тем не менее, все варианты подразумевают выполнение на каждом компьютере неудобного шага по конфигурированию или развертыванию, поэтому идеальными их назвать трудно. На заметку! Если приложению требуется полный набор разрешений, лучше рассмотреть вариант создания автономного приложения WPF и его развертывания с помощью технологии ClickOnce (см. главу 33). Реальная цель модели ХВАР заключается в предоставлении возможности создания WPF-эквивалента традиционному веб-сайту на основе HTML и JavaScript (или Flash).
Глава 24. Страницы и навигация 767 Комбинирование приложений ХВАР и автономных приложений До сих пор было показано, как иметь дело с приложениями ХВАР, способными запускаться с разным уровнем доверия. Однако существует еще одна возможность. Одно и то же приложение можно развертывать и как приложение ХВАР, и как автономное приложение, использующее контейнер NavigationWindow (о котором речь шла в начале главы). В такой ситуации тестировать разрешения вовсе не обязательно. Может быть достаточно написать условную логику, которая проверяет свойство BrowserlnteropHelper. IsBrowserHostedn предполагает, что обслуживаемое в браузере приложение автоматически запускается с набором разрешений зоны Интернета. Свойство IsBrowserHosted равно true, если приложение выполняется внутри браузера. К сожалению, переход с автономного приложения на приложение ХВАР и наоборот — задача непростая, т.к. Visual Studio не предоставляет прямой поддержки для этого. Однако другие разработчики уже создали средства, упрощающие этот процесс. Одним из таких средств является гибкий шаблон проекта Visual Studio, доступный по адресу http://scorbs.com/2006/06/04/vs-template-flexible-application. Он позволяет создавать единственный файл проекта и выбирать между приложением ХВАР и автономным приложением с помощью списка конфигурации сборки. Вдобавок он предоставляет константу компилятора, которую можно использовать для условной компиляции кода в любом из сценариев, и свойство приложения, которое можно применять для создания выражений привязки, условно показывающих или скрывающих определенные элементы на основе конфигурации сборки. Другой вариант — поместить страницы в допускающую многократное использование сборку библиотеки классов. Тогда можно подготовить два высокоуровневых проекта: один, создающий контейнер NavigationWindow и загружающий внутри него первую страницу, и другой, запускающий страницу напрямую как приложение ХВАР. Это упростит сопровождение решения, но, скорее всего, также потребует написания кое-какого условного кода, проверяющего свойство IsBrowserHosted и специфические объекты Code Access Permission. Кодирование с обеспечением различных уровней безопасности В некоторых ситуациях может потребоваться создать приложение, способное функционировать в разных контекстах безопасности, например, приложение ХВАР, которое могло бы запускаться как локально (с полным доверием), так и с веб-сайта. В таком случае главное — написать гибкий код, способный избегать неожиданных исключений SecurityException. Каждое отдельное разрешение в модели безопасности доступа кода представлено классом, унаследованным oTCodeAccessPermission. Этот класс как раз и можно применять для проверки того, выполняется ли код с должным разрешением. Секрет заключается в вызове метода CodeAccessPermission.Demand(), который запрашивает разрешение. Этот запрос не удается (приводя к генерации исключения SecurityException), если у приложения нет необходимого разрешения. Ниже показана простая функция, позволяющая выполнять проверку на предмет наличия должного разрешения: private bool CheckPermission(CodeAccessPermission requestedPermission) { try { // Попытка получить разрешение. requestedPermission.Demand ();
768 Глава 24. Страницы и навигация return true; } catch { return false; } } Эту функцию можно использовать в коде, подобном показанному ниже, где выполняется проверка на предмет того, разрешено ли вызывающему коду осуществлять запись в файл перед попыткой совершить такую операцию: // Создать разрешение на запись в файл. FilelOPermission permission = new FilelOPermission( FilelOPermissionAccess.Write, @"с:\highscores.txt" ) ; // Выполнить проверку на предмет наличия этого разрешения. if (CheckPermission(permission)) { // (Запись в файл разрешена.) } else { // (Запись в файл не разрешена. Ничего не делать // или отобразить соответствующее сообщение.) } Очевидным недостатком этого кода является то, что для управления обычным потоком программы он полагается на обработку исключений, чего делать не рекомендуется (т.к. это чревато неясностью кода и дополнительными накладными расходами). Альтернативный вариант — просто попробовать выполнить операцию (например, запись в файл) и затем перехватить любое возникшее в результате этого исключение SecurityException. Однако при таком подходе увеличивается риск возникновения проблемы на полпути до завершения задачи, когда выполнить восстановление или очистку становится труднее. Изолированное хранилище Во многих случаях есть возможность переключиться на менее мощную функциональность, если необходимое разрешение отсутствует. Например, хотя записывать данные в произвольных местах на жестком диске коду, выполняющемуся с разрешениями зоны Интернета, нельзя, работать с изолированным хранилищем все-таки можно. Изолированное хранилище представляет собой виртуальную файловую систему, которая позволяет записывать данные в небольшой относящийся к определенному пользователю и определенному приложению слот пространства. Реальное местоположение на жестком диске скрыто (т.е. узнать заранее, куда именно будут записываться данные, невозможно), а объем общего доступного пространства составляет 1 Мбайт. В Windows Vista и Windows 7 для размещения обычно применяется путь вида c:\Users\ [ИмяПользователя]\AppData\Local\IsolatedStorage\ [ИдентификаторСиЮ]. Данные в изолированном хранилище одного пользователя другим пользователям, не имеющим прав администратора, не доступны. На заметку! Изолированное хранилище является .NET-эквивалентом долговременных cookie- наборов в обычной веб-странице. Оно позволяет сохранять небольшие фрагменты информации в специальном месте, имеющем необходимую защиту от злонамеренных атак (таких как код, пытающийся заполнить жесткий диск или заменить системный файл).
Глава 24. Страницы и навигация 769 Изолированное хранилище подробно описано в справочной системе .NET. Однако в его использовании в принципе нет ничего сложного, поскольку оно предоставляет ту же самую основанную на потоках модель, что и обычный доступ к файлам. Все, что требуется — работать с типами из пространства имен System. 10.IsolatedStorage. Сначала обычно вызывается метод IsolatedStorageFile.GetUserStoreForApplicationO для извлечения ссылки на изолированное хранилище текущего пользователя и приложения. (Каждое приложение получает отдельное хранилище.) Затем этом месте создается виртуальный файл с помощью IsolatedStorageFileStream. Например: // Создать разрешение на запись в файл. string filePath = System.10.Path.Combine(appPath, "highscores.txt"); FilelOPermission permission = new FilelOPermission ( FilelOPermissionAccess.Write, filePath); // Выполнить проверку на предмет наличия этого разрешения, if (CheckPermission(permission)) { // Записать данные на локальный жесткий диск, try { using (FileStream fs = File.Create(filePath)) { WriteHighScores(fs) ; } } catch { . . . } } else { // Записать данные в изолированное хранилище, try { IsolatedStorageFile store = IsolatedStorageFile.GetUserStoreForApplication(); using (IsolatedStorageFileStream fs = new IsolatedStorageFileStream( "highscores.txt", FileMode.Create, store)) { WriteHighScores (fs); } } catch { . . . } } С помощью методов IsolatedStorageFile.GetFileNamesO и IsolatedStorageFile. GetDirectoryNames () осуществляется перечисление содержимого изолированного хранилища текущего пользователя и приложения. Следует иметь в виду, что в случае создания обычного приложения ХВАР, которое будет развертываться в веб-сети, уже известно, что разрешения FilelOPermission для локального жесткого диска (или любого другого места) не будет, а это значит, что применять показанный здесь условный код нет никакого смысла. Взамен лучше сделать так, чтобы код сразу же переходил к классам изолированного хранилища. Совет. Для определения объема доступного пространства в изолированном хранилище следует проверить значение свойства IsolatedStorageFile.AvailableFreeSpace. Понадобится предусмотреть код для проверки этой детали и предотвращать запись данных в случае, если доступного пространства недостаточно. Для увеличения объема данных, помещаемых
770 Глава 24. Страницы и навигация в изолированное хранилище, можно упаковать все операции, связанные с записью файлов, в Def lateStream или GZipStream. Оба типа определены в пространстве имен System. 10. Compress ion и предусматривают применение сжатия для сокращения количества байт, требуемых для сохранения данных. Эмуляция диалоговых окон с помощью элемента управления Popup Еще одним ограниченным функциональным средством в ХВАР является открытие вторичного окна. Во многих случаях вместо отдельных окон можно будет использовать навигацию и множество страниц и не вспоминать об этой возможности. Однако в некоторых случаях удобнее применять именно всплывающее окно для отображения какого- то сообщения или суммарных данных. В автономных Windows-приложениях для этой цели служит модальное диалоговое окно. В приложениях ХВАР можно использовать элемент управления Popup, который уже рассматривался в главе 6. В целом все выглядит довольно просто. Сначала нужно определить элемент управления Popup в коде разметки, установив для его свойства StaysOpen значение true, чтобы он оставался в открытом состоянии до тех пор, пока не будет закрыт. (Использовать свойство PopupAnimation или AllowsTransparency не имеет смысла, поскольку на веб-странице работать они не будут.) Затем следует включить подходящие кнопки, такие как ОК и Cancel (Отмена), и установить свойство Placement в Center, чтобы всплывающее окно появлялось посередине окна браузера. Ниже показан простой пример. <Popup Name="dialogPopUp" StaysOpen="True" Placement="Center" MaxWidth=00"> <Border> <Border.Background> <LinearGradientBrush> <GradientStop Color="AliceBlue" Offset="l"></GradientStop> <GradientStop Color="LightBlue" Offset="></GradientStop> </LinearGradientBrush> </Border.Background> <StackPanel Margin=" Background="White"> <TextBlock Margin=0" TextWrapping="Wrap"> Please enter your name. </TextBlock> <TextBox Name="txtName" Margin=0"></TextBox> <StackPanel Orientation="Horizontal" Margin=0"> <Button Click="dialog_cmdOK_Click" Padding=" Margin=,0,5,0">OK</Button> <Button Click="dialog_cmdCancel_Click" Padding=">Cancel</Button> </StackPanel> </StackPanel> </Border> </Popup> В подходящий момент (например, при выполнении щелчка на кнопке) нужно отключить остальную часть пользовательского интерфейса и отобразить элемент Popup. Для отключения пользовательского интерфейса необходимо установить в false свойство IsEnabled какого-то высокоуровневого контейнера вроде StackPanel или Grid. (Можно также указать для свойства Background страницы серый цвет, что привлечет внимание пользователя к окну Popup.) Чтобы отобразить элемент управления Popup, необходимо установить его свойство IsVisible в true. Ниже показан обработчик событий, отображающий элемент Popup, который был определен ранее:
Глава 24. Страницы и навигация 771 private void cmdStart_Click(object sender, RoutedEventArgs e) { DisableMainPage(); } private void DisableMainPage () { mainPage.IsEnabled = false; this.Background = Brushes.LightGray; dialogPopUp.IsOpen = true; } Когда пользователь щелкает на кнопке OK или Cancel (Отмена), окно Popup нужно закрыть, установив его свойство Is Visible в false и снова сделать доступной остальную часть пользовательского интерфейса: private void dialog_cmdOK_Click(object sender, RoutedEventArgs e) { // Скопировать имя из элемента Popup в основную страницу. lblName.Content = "You entered: " + txtName.Text; EnableMainPage(); } private void dialog_cmdCancel_Click(object sender, RoutedEventArgs e) { EnableMainPage(); } private void EnableMainPage() { mainPage.IsEnabled = true; this.Background = null; dialogPopUp.IsOpen = false; } На рис. 24.16 показан этот элемент Popup в действии. Щ D:\Code\Pro WPF\Chapter09\XBAP\b.n\D._ ^вШшШЯЁ И|()> ' * D:\Code\ProV - ' • ^"ХВАРлЬар . " ЕЗ " Click the button to show a Popup that looks like a modal window Please enter your name Simonej A Computer i Protected Mode: Off 4$ D:\Code\Pro WPF\Chapter09\XBAP\bm\D.. И! ,» * D:\Code\Pro V -'■♦*'л *XBAP.*bap Click the button to show a Popup that looks window. Stan You entered: Simone v nputer Protected Mode: Off 1iHrim-fM] i • Q - like a modal : Рис. 24.16. Эмуляция диалогового окна с помощью элемента управления Popup Подход с применением элемента управления Popup для создания такого обходного пути имеет одно серьезное ограничение. Для гарантии того, что элемент управления Popup не сможет использоваться для фальсификации настоящих системных диалоговых окон, размер его окна ограничивается размером окна браузера. При наличии большого
772 Глава 24. Страницы и навигация окна Popup и маленького окна браузера это может означать усечение части содержимого. Одно из решений, которое демонстрируется в примере кода для этой главы, предусматривает упаковку всего содержимого элемента управления Popup в ScrollViewer со свойством VerticalScrollBarVisibility, установленным в Auto. Существует еще один, даже еще более странный способ для отображения диалогового окна на странице WPF. Он подразумевает применение библиотеки Windows Forms из .NET 2.0. В частности, он позволяет безопасно создавать и отображать экземпляр класса System.Windows.Forms.Form (или любой специальной формы, унаследованной от Form), поскольку в таком случае разрешения на выполнение неуправляемого кода не требуется. На самом деле он даже позволяет отображать форму как немодальную, так, чтобы страница продолжала реагировать на действия пользователя. Единственный недостаток — в таком случае поверх формы автоматически появляется всплывающее окно с сообщением о безопасности, которое остается там до тех пор, пока пользователь на нем не щелкнет (рис. 24.17). Также имеются и ограничения в плане того, что можно отображать в форме. Элементы управления Windows Forms отображать можно, а содержимое WPF — нет. Пример использования этого приема можно найти в демонстрационном коде для этой главы. Ч1 Unknown Zone... L^djOJIHB !iT\ . i A Microsoft .NET Security Warning Never enter personal information or passwords into a window unless you can verify and trust the source of the request. • Source: Unknown Site 1 t-L- -L:JUUJ1J P ■-" Ш1 "J I" Рис. 24.17. Использование формы .NET 2.0 для получения диалогового окна Вставка ХВАР-приложения в веб-страницу Обычно ХВАР-приложение загружается прямо в браузере и потому занимает все доступное пространство. Однако допустим и другой вариант: можно делать так, чтобы ХВАР-приложение отображалось внутри HTML-страницы вместе с остальным HTML- содержимым. Все, что для этого требуется — это создать HTML-страницу и добавить в нее дескриптор <iframe>, указывающий на файл .xbap: <html> <head> <title>An HTML Page That Contains an XBAP</title> </head> <body> <hl>Regular HTML Content</hl> <iframe src="BrowserApplication. xbap"X/iframe> <hl>MoresHTML Content</hl> </body> </html> Методика с добавлением дескриптора <iframe> применяется относительно редко, но позволяет использовать несколько новых приемов. Например, она позволяет отображать более одного ХВАР-приложения в одном и том же окне браузера, а также создавать управляемые WPF графические элементы управления (гаджеты) для Windows Vista и Windows 7.
Глава 24. Страницы и навигация 773 На заметку! Приложения WPF не имеют прямой поддержки для графических элементов управления Windows, но их можно вставлять в такие элементы управления с помощью дескриптора <iframe>. Главный недостаток заключается в том, что накладные расходы, связанные с приложением WPF, выше таковых для обычной HTML- или JavaScript-страницы. Также еще имеются некоторые сложности и со способом, которым приложение WPF обрабатывает ввод мыши. Пример применения этой методики и подробное описание ее ограничений можно найти по адресу http://tinyurl.com/38e5se. Элемент управления WebBrowser Как можно было увидеть в настоящей главе, WPF стирает границы между традиционными настольными и веб-приложениями. За счет использования страниц можно создавать WPF-приложения с возможностями навигации в веб-стиле. ХВАР позволяют выполнять WPF-приложения внутри окна браузера. С применением элемента управления Frame можно проделывать обратное, размещая HTML-страницу в окне WPF. Однако при использовании элемента Frame для отображения HTML-содержимого все возможности по управлению содержимым утрачиваются. Не остается никакого способа для его инспектирования или для следования ему, когда пользователь переходит на новую страницу по щелчку на ссылке. И, конечно же, отсутствует возможность вызова методов JavaScript внутри HTML-страницы либо их обращения к коду WPF Вот здесь как раз и приходит на помощь элемент управления WebBrowser. Совет. Элемент управления Frame является прекрасным вариантом, когда необходим контейнер, способный гладко переключаться между содержимым WPF и HTML Элемент управления WebBrowser более удобен, когда нужно изучать объектную модель страницы, ограничивать или следить за страничной навигацией или создавать путь, через который код JavaScript и код WPF могут взаимодействовать. И WebBrowser, и Frame (с HTML-содержимым) отображают стандартное окно Internet Explorer. Это окно обладает всеми возможностями и средствами браузера Internet Explorer, включая поддержку JavaScript, Dynamic HTML, элементов управления ActiveX и подключаемых модулей. Однако дополнительных деталей вроде панели инструментов, адресной строки или строки информации о состоянии это окно не содержит (хотя все эти ингредиенты легко добавить к форме с использованием соответствующих элементов управления). Элемент управления WebBrowser не был написан с нуля в виде управляемого кода. Подобно Frame (когда он отображает HTML-содержимое), он является оболочкой для СОМ-компонента shdocvw.dll, который представляет собой часть Internet Explorer и включен в Windows. Как следствие, WebBrowser и Frame имеют несколько графических ограничений, которые отсутствуют у остальных элементов управления WPF. Например, не допускается размещать другие элементы поверх HTML-содержимого, отображаемого в этих элементах управления, равно как применять трансформации для скашивания или поворота. На заметку! В качестве средства, способность WPF отображать HTML-содержимое (с помощью Frame или WebBrowser) даже близко не настолько же полезно, как страничная модель или ХВАР. Тем не менее, в особых ситуациях при наличии готового HTML-содержимого, которое не хотелось бы заменять, это средство может пригодиться. Например, элемент WebBrowser может использоваться для отображения внутри приложения HTML-документации или для предоставления возможности переходить к функциональности, предлагаемой на веб-сайте сторонних разработчиков.
774 Глава 24. Страницы и навигация Навигация к странице После помещения элемента управления WebBrowser в окно ему должен быть указан какой-нибудь документ. Самый простой подход предполагает установку его свойства Source в соответствующий URL-адрес. В общем случае это свойство может принимать в качестве значения любой удаленный URL-адрес (наподобие http://mysite.com/mypage.html) и полностью уточненный путь к файлу (такой как file: ///c:\mydocument. text). URL-адрес может указывать на файл любого типа, который Internet Explorer способен открывать, хотя практически всегда WebBrowser используется для отображения HTML-страниц: <WebBrowser Source="http://www.prоsetech.com"></WebBrowser> На заметку! Элемент управления WebBrowser можно также направить на каталог. Например, если присвоить его свойству Source путь вроде f ile:///c:\, то в этом случае окно WebBrowser приобретет вид хорошо знакомого окна для просмотра файлов в стиле проводника Windows, позволяя открывать, копировать, вставлять и удалять файлы. Однако никаких событий или свойств, которые бы дали возможность ограничить эти функции (либо хотя бы отслеживать их), не предусмотрено, поэтому соблюдайте осторожность! Кроме свойства Source доступны также и методы навигации, которые перечислены в табл. 24.4. Таблица 24.4. Методы навигации в WebBrowser Метод Описание Navigate () NavigateToStringO NavigateToStream() GoBack() и GoForwardO RefreshO Выполняет переход по указанному URL-адресу. Перегруженная версия позволяет загружать документ в определенный фрейм, выполнять обратную отправку данных и передавать дополнительные HTTP-заголовки Загружает содержимое из переданной ему строки с полным HTML-содержимым веб-страницы. Это открывает интересные возможности, такие как извлечение HTML-текста из ресурса внутри приложения и его отображение Загружает содержимое из потока, который содержит HTML- документ. Это позволяет открывать файл и передавать его прямо в элемент управления WebBrowser для визуализации без удержания всего HTML-содержимого в памяти Переходят к предыдущему или следующему документу в хронологии навигации. Во избежание ошибок перед использованием этих методов необходимо проверять свойства CanGoBack и CanGoForward, т.к. попытка перейти к документу, которого не существует (например, перейти назад, находясь на первом документе в хронологии), вызывает генерацию исключения Перезагружает текущий документ Вся навигация WebBrowser является асинхронной. Это значит, что код приложения продолжает выполнение во время загрузки страницы. Элемент управления WebBrowser поддерживает небольшой набор событий, которые описаны ниже.
Глава 24. Страницы и навигация 775 • Событие Navigating срабатывает при установке нового URL-адреса или при выполнении пользователем щелчка на ссылке. URL-адрес можно инспектировать и отменять навигацию, устанавливая е.Cancel в true. • Событие Navigated срабатывает после Navigating непосредственно перед тем, как в WebBrowser начинается загрузка страницы. • Событие LoadCompleted срабатывает после завершения процесса загрузки страницы и представляет собой удобную возможность для обработки полученной страницы. Построение дерева DOM При использовании элемента управления WebBrowser допускается писать код С#, который позволяет проходить по дереву HTML-элементов на странице. Можно даже изменять, удалять или вставлять элементы с помощью программной модели, подобной HTML DOM, которая применяется в таких языках сценариев для веб-браузеров, как JavaScript. Эти приемы демонстрируются в последующих разделах. Прежде чем можно будет использовать модель DOM с WebBrowser, необходимо добавить ссылку на библиотеку Microsoft HTML Object Library (mshtml.tlb). Поскольку она является библиотекой СОМ-объектов, в Visual Studio понадобится сгенерировать для нее соответствующую управляемую оболочку. Для этого нужно выбрать в меню Project (Проект) пункт Add Reference (Добавить ссылку), перейти на вкладку СОМ, выбрать опцию Microsoft HTML Object Library (Библиотека объектов HTML от Microsoft) и щелкнуть на кнопке ОК. Стартовой точкой для исследования содержимого на веб-странице является свойство WebBrowser.Document. Оно дает объект HTMLDocument, который представляет одиночную веб-страницу в виде иерархической коллекции объектов IHTMLElement. Для каждого имеющегося на веб-странице дескриптора предусмотрен отдельный объект IHTMLElement, включая параграфы (<р>), гиперссылки (<а), изображения (<img>) и прочие знакомые ингредиенты HTML-разметки. Свойство WebBrowser.Document доступно только для чтения. Это означает, что связанный с ним объект HtmlDocument модифицировать можно, но создавать для него новый объект HtmlDocument "на лету" нельзя. Вместо этого понадобится либо установить свойство Source, либо вызывать метод Navigate () для загрузки новой страницы. После срабатывания события WebBrowser.LoadCompleted можно получать доступ к свойству Document. Совет. Построение объекта HTMLDocument занимает небольшое, но все же заметное время (в зависимости от размеров и сложности веб-страницы). Элемент управления WebBrowser на самом деле не создает объект HTMLDocument для страницы до тех пор, пока не будет предпринята первая попытка доступа к свойству Document. Каждый объект IHTMLElement имеет описанные ниже ключевые свойства. • Свойство tagName содержит сам дескриптор без квадратных скобок. Например, дескриптор привязки имеет вид <а href = "...">...</a>, поэтому его имя в tagName будет выглядеть как А. • Свойство id содержит значение атрибута id, если он был указан. Элементы идентифицируются с помощью уникальных атрибутов id, когда необходимости иметь возможность манипулировать ими в средстве автоматизации или серверном коде.
776 Глава 24. Страницы и навигация • Свойство children содержит коллекцию объектов IHTMLElement, по одному объекту для каждого имеющегося дескриптора. • Свойство innerHTML хранит полное содержимое дескриптора, включая любые вложенные в него дескрипторы вместе с их содержимым. • Свойство innerText хранит полное содержимое самого дескриптора и содержимое любых вложенных в него дескрипторов. Однако все HTML-дескрипторы отбрасываются. • Свойства outerHTML и outerText исполняют ту же роль, что и innerHTML и innerText, но включают текущий дескриптор (а не только его содержимое). Чтобы лучше понять свойства innerText, innertHTML и outerHTML, предположим, что есть следующий дескриптор: <p>Here is some <i>interesting</i> text.</p> Значение свойства innerText для этого дескриптора будет выглядеть так: Here is some interesting text. Значение свойства innerHTML — так: Here is some <i>interesting</i> text. И, наконец, значение свойства outerHTML включает в себя полностью весь дескриптор: <p>Here is some <i>interesting</i> text.</p> Вдобавок с помощью метода IHTMLElement.getAttribute() можно извлечь значение атрибута для элемента по имени. Для навигации по модели документа HTML-страницы нужно просто перемещаться по коллекции дочерних элементов каждого объекта IHTMLElement. В следующем коде эта задача выполняется в ответ на щелчок на кнопке. При этом создается дерево, отражающее структуру элементов и содержимого на странице (рис. 24.18). private void cmdBuildTree_Click(object sender, System.EventArgs e) { // Анализ страницы отнимет неизвестное время, // поэтому используется курсор в виде песочных // часов для уведомления пользователя, this.Cursor = Cursors.Wait; // Получение объекта DOM из WebBrowser. HTMLDocument dom = (HTMLDocument)WebBrowser.Document; // Обработать все HTML-элементы на странице и отобразить // их в элементе управления TreeView по имени treeDOM. ProcessElement(dom.documentElement, treeDOM.Items); this.Cursor = null; } private void ProcessElement(IHTMLElement parentElement, ItemCollection nodes) { // Пройти в цикле по коллекции элементов. foreach (IHTMLElement element in parentElement.children) { // Создать новый узел, отражающий имя дескриптора. TreeViewItem node = new TreeViewItem(); node.Header = "<" + element. tagName + ">"; nodes.Add(node);
Глава 24. Страницы и навигация 777 if ((element.children.length == 0) && (element.innerText != null)) { // Если в данном элементе не содержится никаких других элементов, // добавить любое оставшееся текстовое содержимое как новый узел, node.Items.Add(element.innerText); } else { // Если в данном элементе содержатся другие элементы, // обработать их рекурсивно. ProcessElement(element, node.Items); } } 1 Digital VKJeo С Web Design and Development 2 The Definitive Guide to PC- I Рис. 24. 18. Древовидная модель веб-страницы Если требуется найти конкретный элемент без прохождения через все уровни вебстраницы, на выбор доступны два более простых варианта. Во-первых, можно использовать коллекцию HTMLDocument.all, которая позволяет извлечь любой элемент на странице на основе его атрибута id. Во-вторых, для извлечения элемента, не имеющего атрибута id, служит такой метод HTMLDocument, как getElementsByTagName(). Написание сценариев для веб-страницы с помощью кода .NET Последний прием с элементом управления WebBrowser, который будет здесь показан, представляет нечто более интригующее — возможность реагировать на события веб-страницы в своем коде Windows. Элемент управления WebBrowser существенно упрощает решение этой задачи. Первым шагом является создание класса, который будет получать сообщения от кода JavaScript. Чтобы этот класс мог работать со сценариями, в его объявление должен быть обязательно добавлен атрибут ComVisible (из пространства имен System.Runtime. InteropServices): [ComVisible(true)] public class HtmlBridge { public void WebClick(string source) { MessageBox.Show("Received: " + source); }
778 Глава 24. Страницы и навигация Далее необходимо зарегистрировать экземпляр этого класса с WebBrowser. Делается это путем установки свойства WebBrowser.Ob]ectForScripting: public MainWindow() { InitializeComponent (); WebBrowser.Navigate("file:///" + System.10.Path.Combine( Path.GetDirectoryName(Application.ResourceAssemblу.Location), "sample.htm")); WebBrowser.ObjectForScripting = new HtmlBridge(); } Теперь демонстрационная веб-страница HTML сможет вызывать любой общедоступный метод класса HtmlBridge, в том числе HtmlBridge.WebClick(). Внутри веб-страницы используется код JavaScript для инициирования события. Трюк кроется в объекте window.external, который представляет связанный объект .NET. С помощью этого объекта указывается метод, который должен вызываться; например, для вызова общедоступного метода по имени HelloWorld в объекте .NET используемый синтаксис должен выглядеть как window.external.HelloWorld(). Внимание! При использовании JavaScript для инициирования события из веб-страницы, удостоверьтесь, что класс не содержит общедоступных методов, которые не имеют отношения к веб- доступу. Теоретически злоумышленник может найти исходный код HTML и изменить его, вызвав какой-то другой метод, отличный от того, который был предусмотрен разработчиком. В идеале для обеспечения безопасности в классе, способном работать со сценариями, должны содержаться только методы, имеющие отношение к веб-доступу. Для встраивания команды JavaScript в веб-страницу сначала необходимо выбрать, на какое событие веб-страницы реагировать. Большинство HTML-элементов поддерживает относительно немного событий, наиболее полезные из которых описаны ниже: • Событие on Focus, которое происходит при получении элементом управления фокуса • Событие onBlur, которое происходит при перемещении фокуса с элемента управления • Событие onClick, которое происходит при выполнении пользователем щелчка на элементе управления • Событие onChange, которое происходит при изменении пользователем значения определенных элементов управления • Событие onMouseOver, которое происходит при наведении пользователем указателя мыши поверх элемента управления. Для написания команды JavaScript, способной реагировать на одно из этих событий, необходимо добавить атрибут с таким именем к дескриптору элемента. Например, если есть следующий дескриптор изображения: <img border=" id="imgl" src="buttonC.црд" height=0" width=00"> можно добавить атрибут onClick, который обеспечит вызов метода HelloWorld() в связанном классе .NET при каждом выполнении щелчка на этом изображении: <img onClick="window.external.HelloWorld ()" border=" id="imgl" src="buttonC.ipg" height=0" width=00"> На рис. 24.19 показано приложение, которое объединяет в себе все описанные выше аспекты. В этом приложении элемент управления WebBrowser отображает локальный HTML-файл, содержащий четыре кнопки, каждая из которых представляет собой графическое изображение. Но когда пользователь щелкает на кнопке, изображение использует атрибут onClick для запуска метода HtmlBridge.WebClick():
Глава 24. Страницы и навигация 779 <img onClick="window.external.WebClick('Optionl') ' ... > После этого управление переходит BWebClick(). Этот метод мог бы отображать другую веб-страницу, открывать новое окно или изменять часть веб-страницы, но в данном примере он просто отображает окно с сообщением, подтверждающим получение события. В каждом изображении предусмотрена передача методу WebClick() жестко закодированной строки, идентифицирующей кнопку, которая привела к его вызову. • C*MWpfWrtW«vaScnpt em | Opdonl Ock hmrm to (earn about the now WobBrowsor control. | opaon2. CMibw««Mo*lbafl 1 mm] Recerved Opt»on2 OK [Information from • «rob щшдт. (a) 22 » Рис. 24.19. HTML-меню, которое обращается к коду .NET Внимание! Следует иметь в виду, что если только HTML-документ не компилируется в сборку в виде встроенного ресурса или не извлекается из какого-то безопасного места (вроде базы данных), он может подвергаться различного рода манипуляциям на стороне клиента. Например, в случае сохранения HTML-документов как отдельных файлов пользователи могут отредактировать их. Если это представляет проблему, следует воспользоваться приемами по встраиванию ресурсов, которые были описаны в главе 7. Файловые ресурсы очень легко создавать, извлекать в виде строк и затем отображать с применением метода WebBrowser. NavigateToString(). Резюме В этой главе подробно рассматривалась модель навигации WPF. Было показано, как создавать страницы, размещать их в различных контейнерах и применять навигацию WPF для перехода от одной страницы к другой. Также была описана модель ХВАР, которая позволяет создавать WPF-приложения в веб-стиле, выполняющиеся в браузере. Поскольку приложения ХВАР все равно требуют наличия .NET Framework, они не заменяют собой существующие веб-приложения. Однако они могут служить альтернативным способом предоставления пользователям Windows развитого содержимого и графики. И, наконец, в главе было показано, как встраивать веб-содержимое в WPF-приложение с помощью элемента управления WebBrowser и позволять коду сценария из веб-страницы запускать методы в WPF-приложении. На заметку! Те, кто планирует создавать приложения WPF, запускающиеся в веб-браузере через Интернет, могут рассмотреть вариант использования родственной WPF, но менее масштабной технологии под названием Silverlight. Хотя эта технология не является такой же мощной, как WPF, она содержит значительную часть модели WPF и предлагает дополнительную поддержку для межплатформенного применения. (Например, приложения Silverlight могут запускаться в браузере Safari на компьютере Мае.) Более подробную информацию о Silverlight можно найти по адресу http://silverlight.net или в книге Silverlight 3 с примерами на С# для профессионалов (ИД "Вильяме", 2010 г).
ГЛАВА 25 Меню, панели инструментов и ленты Определенный набор многофункциональных элементов управления встречается в приложениях практически любого типа, начиная с редакторов документов и заканчивая служебными системными утилитами. Об этих элементах управления и пойдет речь в настоящей главе. • Меню. Меню являются одним из самых старых элементов управления пользовательского интерфейса и за последние пару десятилетия изменились на удивление мало. В WPF предлагается развитая и понятная поддержка для использования как главных меню, так и вспльшающих контекстных меню. • Панели инструментов и строки состояния. Они украшают верх и низ бесчисленного количества приложений, порой даже тогда, когда в этом нет никакой необходимости. В WPF поддерживаются те и другие элементы управления с привычной для них степенью гибкости, которая дает возможность помещать внутрь них практический любой другой элемент управления. Однако в WPF многие дополнительные возможности у этих элементов отсутствуют. Например, они поддерживают возможность использования с ними дополнительных меню, но не предоставляют возможности для их отстыковки и пристыковки. • Ленты. Приложив чуть большее количество усилий, в верхнюю часть окна приложения можно добавить ленту в стиле Office. Это требует выполнения отдельной (бесплатной) загрузки, но позволяет получить кое-какие ценные встроенные средства вроде возможности настройки процесса изменения размеров, а также соответствующий компонент в стиле меню Office. Меню В WPF предлагаются два элемента управления меню: Menu (для главных меню) и Context Me nu (для всплывающих меню, присоединяемых к другим элементам). Как и для всех остальных классов, WPF выполняет визуализацию для элементов управления Menu и Context Me nu. Это означает, что упомянутые элементы управления не являются простыми оболочками Win32 и могут использоваться несколькими необычными способами. На заметку! Если класс Menu используется в приложении, обслуживаемом в браузере, он появляется в верхней части страницы. Окно браузера упаковывает страницу приложения и может как включать, так и не включать собственное меню, которое тогда будет отображаться совершенно отдельно.
Глава 25. Меню, панели инструментов и ленты 781 Класс Menu В WPF не делается никаких предположений по поводу того, где должно размещаться автономное меню. Обычно оно пристыковывается в верхней части окна с помощью элемента управления DockPanel или верхней строки элемента управления Grid либо просто растягивается по всей ширине окна. Однако на самом деле меню можно размещать где угодно, даже рядом с другими элементами управления (как показано на рис. 25.1). Более того, в окно можно добавлять столько меню, сколько нужно. Хотя особого смысла в этом и нет, но также существует возможность размещать строки меню стопками и вразброс по всему пользовательскому интерфейсу. • reeMixedMenus ] rii j (^ГДЁИП l | 1 An Ordinary Button A TextBox S A CheckBox File Hdp ■ML New Open Save Рис. 25.1. Разнообразные меню Такая свобода действий открывает кое-какие интересные возможности. Например, если вы создадите меню с одним высокоуровневым заголовком и стилизуете его под кнопку, получится всплывающее меню, вызываемое одиночным щелчком мыши (подобное открытому меню на рис. 25.1). Применение подобных уловок может помочь добиться в точности желаемого эффекта в сложном интерфейсе, а может оказаться и просто более изощренным способом запутывания пользователей. Класс Menu предоставляет дополнительно всего одно новое свойство: IsMainMenu. При значении true (которое является значением по умолчанию) нажатие клавиши <Alt> или <F10> приводит, как и в любом другом Windows-приложении, к перемещению фокуса на меню. Помимо этой небольшой детали у контейнера Menu имеется также несколько знакомых свойств ItemsControl — ItemsSource, DisplayMemberPath, ItemTemplate и ItemTemplateSelector, с помощью которых можно создавать привязываемые к данным меню. Можно также применять группирование, изменять компоновку элементов внутри меню и применять к элементам меню различные стили. Например, на рис. 25.2 показано прокручиваемое боковое меню. Чтобы создать такое меню, нужно указать для свойства ItemsPanel в качестве значения элемент StackPanel, изменить его фон и упаковать весь контейнер Menu в ScrollViewer. Очевидно, что во внешний вид меню и подменю можно вносить и более радикальные изменения с помощью триггеров и шаблонов элементов управления. Базовую логику стилизации можно посмотреть в используемом по умолчанию шаблоне элемента управления Menultem.
782 Глава 25. Меню, панели инструментов и ленты Элементы меню Все меню конструируются из объектов Menultem и Separator. Класс Menultem унаследован OTHeaderedltemsControl, поскольку каждый элемент меню имеет заголовок (в котором содержится предназначенный для него текст) и может умещать в себе коллекцию объектов Menultem (с помощью которой представляется подменю). Класс Separator просто отображает горизонтальную линию для разделения элементов меню. Ниже приведена простая комбинация объектов Menultem, которые создают элементарную структуру меню, показанную на рис. 25.3: <Menu> <MenuItem Header="File"> <MenuItem Header="New"></MenuItem> <MenuItem Header="Open"></MenuItem> <MenuItem Header="Save"></MenuItem> <Separator></Separator> <MenuItem Header="Exit"></MenuItem> </MenuItem> <MenuItem Header="Edit"> <MenuItem Header="Undo"></MenuItem> <MenuItem Header="Redo"></MenuItem> <Separator></Separator> <MenuItem Header="Cut"></MenuItem> | <MenuItem Header="Copy"></MenuItem> <MenuItem Header="Paste"></MenuItem> </MenuItem> </Menu> Как и в случае кнопок, здесь тоже можно использовать символ подчеркивания для обозначения клавиатурной комбинации <А1т.+клавиша быстрого вызова>. Для кнопок это часто считается необязательным, но что касается меню, то большинство пользователей ожидает, что сокращенные клавиатурные команды в них должны присутствовать. WPF позволяет нарушать многие из привычных правил структуризации меню. Например, внутри Menu или Menultem допускается размещать объекты, отличные от Menultem. Это дает возможность создавать меню, содержащие обычные элементы WPF, начиная от Checkbox и заканчивая DocumentViewer. По ряду причин размещение в меню объектов, отличных от Menultem, практически всегда является плохой идеей, поскольку приводит к появлению нескольких необычных проблем, которые нужно отслеживать и исправлять. Например, элемент TextBox в Menultem будет утрачивать фокус сразу же после перемещения мыши за пределы этого Menultem. 3asicMenu File Edit New Open Save Exit [: , ,, , llm Рис. 25.2. Элемент управления Menu внутри Рис. 25.3. Базовое меню StackPanel
Глава 25. Меню, панели инструментов и ленты 783 Если же действительно необходимо создать пользовательский интерфейс, включающий нечто вроде раскрывающихся меню с элементами управления, лучше рассмотреть вариант использования другого элемента (например, Expander) и его стилизации в соответствии с имеющимися потребностями. Элементы управления Menu и Menultems стоит применять только тогда, когда действительно требуется поведение, свойственное меню — другими словами, когда необходима группа активизируемых щелчком команд. На заметку! Если нужно, чтобы подменю после открытия оставались видимыми до тех пора, пока пользователь не щелкнет в каком-нибудь другом месте, установите свойство Menu Item. StaysOpenOnClick в true. Объекты Menultem можно также использовать и за пределами стандартных контейнеров Menu, ContextMenu и Menultem. Они будут вести себя точно так же, как и обычные элементы меню, т.е. будут подсвечиваться голубым при наведении на них курсора мыши и позволять выполнять щелчок для инициации действий. Однако получить доступ к любым содержащимся в них подменю не получится. Опять-таки, это одна из тех возможностей Menu, которая вряд ли будет пользоваться популярностью среди разработчиков. Для реагирования на щелчок на элементе Menultem можно использовать событие MenuItem.Click. Можно обеспечить его обработку для отдельных элементов, а можно просто присоединить к корневому дескриптору Menu соответствующий обработчик таких событий. Другой вариант — с помощью свойств Command, CommandParameter и CommandTarget соединить Menultem с объектом Command, как это делалось для кнопок в главе 9. Такой вариант особенно удобен, если пользовательский интерфейс включает несколько меню (например, главное меню и контекстное меню), в которых применяются одинаковые команды, или меню и панель инструментов с кнопками, аналогичными командам этого меню. Помимо текстового содержимого (которое предоставляется через свойство Header), объекты Menultem могут в действительности отображать еще несколько перечисленных ниже деталей: • миниатюрный значок в области поля сразу же слева от команды меню; • флажок в области поля (в случае указания сразу флажка и значка отображаться будет только флажок); • текст сокращенной клавиатурной команды справа от текста меню (например, текст Ctrl+O для команды Open (Открыть)). В указании всех этих деталей нет ничего сложного. Для отображения миниатюрного значка необходимо установить свойство Menultem.Icon. Интересно то, что свойство Icon способно принимать любой объект, что позволяет создавать для отображения даже миниатюрный векторный рисунок. Это дает возможность в полной мере воспользоваться преимуществами не зависящей от разрешения системы масштабирования WPF и отобразить больше деталей при более высоких настройках DPI системы. При желании иметь обычный значок, должен использоваться просто элемент Image с растровым изображением. Для отображения рядом с элементом меню флажка понадобится установить свойство Menultem.IsChecked в true. Вдобавок, если IsCheckable равно true, щелчок на данном элементе меню будет переводить его из отмеченного в неотмеченное состояние и наоборот. Однако способа организации группы элементов меню с взаимно исключающей отметкой не существует. Если необходим именно такой эффект, придется писать специальный код, снимающий отметки с других флажков при установке флажка рядом элементом.
784 Глава 25. Меню, панели инструментов и ленты Текст сокращенной клавиатурной команды для элемента меню указывается с помощью свойства MenuItem.InputGestureText. Однако простое отображение этого текста активным его не сделает. Об отслеживании нажатий соответствующих клавиш разработчик должен позаботиться сам. Практически всегда это требует немалой работы, поэтому элементы меню часто используются с командами, позволяющими обеспечить поведение сокращенных клавиатурных команд и InputGestureText за один шаг. Например, следующий элемент Menultem связывается с командой Applications Commands.Open: <MenuItem Command="ApplicationCommands. Open"x/MenuItem> Эта команда уже имеет клавиатурную комбинацию <Ctrl+0>, которая определена в коллекции команд RoutedUICommand.InputGestures. Поэтому в качестве текста сокращенной клавиатурной команды будет автоматически отображаться Ctrl+O, а нажатие этой комбинации клавиш будет, соответственно, автоматически приводить к инициированию этой команды (естественно, при условии наличия соответствующего обработчика событий). Если необходимая клавиатурная комбинация еще не была задана, разработчик может добавить ее в коллекцию InputGestures самостоятельно. Совет. Доступно также несколько полезных свойств, которые позволяют узнать текущее состояние Menultem: IsChecked, IsHighlighted, IsPressed и IsSubmenuOpen.Их можно использовать для написания триггеров, применяющих различные стили в ответ на определенные действия. Класс ContextMenu Как и Menu, класс ContextMenu содержит коллекцию объектов Menultem. Разница состоит лишь в том, что ContextMenu не может размещаться в окне. Вместо этого он может использоваться только для установки свойства ContextMenu другого элемента: <TextBox> <TextBox.ContextMenu> <MenuItem . . . > </MenuItem> </TextBox.ContextMenu> </TextBox> Свойство ContextMenu определено в классе FrameworkElement, поэтому оно поддерживается практически всеми элементами WPF. В случае, когда это свойство установлено для элемента, у которого имеется собственное контекстное меню, стандартное меню заменяется тем, что указано в этом свойстве. Если просто нужно удалить существующее меню, установите ContextMenu в null. После присоединения объекта ContextMenu к элементу он появляется автоматически, когда пользователь щелкает на этом элементе управления правой кнопкой мыши (или нажимает комбинацию клавиш <Shift+F10>, пока на элементе находится фокус). Если свойство IsEnabled элемента установлено в false, контекстное меню не будет появляться до тех пор, пока ему не будет явно разрешено это делать с помощью присоединенного свойства ContextMenuService.ShowOnDisabled: <TextBox ContextMenuService.ShowOnDisabled="True"> <TextBox.ContextMenu> </TextBox.ContextMenu> </TextBox>
Глава 25. Меню, панели инструментов и ленты 785 Разделители меню Separator — это стандартный элемент для разделения меню на группы связанных команд. Однако благодаря шаблонам элементов управления, содержимое разделителя является очень гибким. Используя разделитель и предоставляя новый шаблон, в меню можно добавлять другие, не активизируемые щелчком элементы, например, подзаголовки. Может показаться, что для добавления подзаголовка достаточно просто добавить в меню отличный от Menultem объект вроде TextBlock с каким-нибудь текстом. Однако при таком подходе этот добавленный элемент сохраняет поведение выбора меню, т.е. на него можно переходить с помощью клавиатуры, а при наведении на него курсора мыши края окрашиваются в голубой цвет. Поведение объекта Separator иное: он представляет собой фиксированный фрагмент содержимого, который не реагирует ни на команды, вводимые с клавиатуры, ни на действия, выполняемые с помощью мыши. Ниже приведен пример объекта Separator, который определяет текстовый заголовок: <Separator> <Separator.Template> <ControlTemplate> <Border CornerRadius=" Padding=" Background="PaleGoldenrod" BorderBrush="Black" BorderThickness="l"> <TextBlock FontWeight="Bold"> Editing Commands </TextBlock> </Border> </ControlTemplate> </Separator.Template> </Separator> Созданный в этом коде заголовок показан на рис. 25.4. К сожалению, Separator не является элементом управления содержимым, поэтому отделение отображаемого содержимого (например, строки текста) от используемого форматирования невозможно. Это означает, что при желании разнообразить текст разделителя один и тот же шаблон придется определять при каждом использовании этого разделителя. Немного упростить этот процесс позволит создание стиля разделителя, объединяющего в себе все свойства, которые должны устанавливаться для TextBlock внутри Separator, за исключением текста. • MenuWithSubheading Undo Redo Editing Commands Cut Copy Paste ^шмм. ' Рис. 25.4. Меню, которое включает фиксированный подзаголовок
786 Глава 25. Меню, панели инструментов и ленты Панели инструментов и строки состояния Панели инструментов и строки состояния являются одними из главных компонентов в мире Windows. И те, и другие представляют собой специализированные контейнеры, способные хранить коллекции элементов. Панели инструментов обычно состоят из кнопок, а строки состояния — из текста и других неинтерактивных индикаторов (вроде индикатора хода выполнения). Однако как панели инструментов, так и строки состояния используются с множеством различных элементов управления. В Windows Forms панели инструментов и строки состояния имеют собственную модель содержимого. И хотя размещать внутри них произвольные элементы управления с помощью упаковщика допускается, это далеко не простой процесс. В WPF у панелей инструментов и строк состояния подобного ограничения нет. Они поддерживают предлагаемую в WPF модель содержимого, которая позволяет добавлять в них любой элемент и обеспечивает несравнимую степень гибкости. Фактически никаких элементов, предназначенных специально для панелей инструментов или строк состояния, не существует. Все необходимые элементы уже доступны в базовой коллекции элементов WPF Элемент управления ToolBar Класс ToolBar в WPF обычно заполняется объектами Button, ComboBox, CheckBox, RadioButton и Separator. Поскольку все они (кроме Separator) являются элементами управления содержимым, внутри них можно размещать текстовое и графическое содержимое. Хотя допускается использовать и другие элементы, такие как Label и Image для добавления в ToolBar неинтерактивных элементов, эффект зачастую оказывается неоднозначным. Может возникнуть вопрос, а как тогда размещать такие стандартные элементы управления в панели инструментов без создания необычного визуального эффекта? Ведь содержимое, отображаемое в стандартных панелях инструментов Windows, выглядит немного не так, как аналогичное ему содержимое, которое отображается в окне. Например, кнопки в панели инструментов выглядят простыми и плоскими и не имеют ни границы, ни затененного фона. Поверхность панели инструментов проглядывает снизу, и при наведении на кнопку курсора мыши она подсвечивается голубым цветом. С точки зрения WPF кнопка в панели инструментов практически ничем не отличается от кнопки в окне: обе представляют собой активизируемые щелчком области, которые разработчик может использовать для выполнения какого-нибудь действия. Разница состоит лишь в визуальном внешнем виде. Поэтому идеальное решение — воспользоваться существующим классом Button и либо настроить должным образом различные его свойства, либо изменить шаблон элемента управления. Именно это и сделано в классе ToolBar: в нем переопределен стиль, используемый по умолчанию для потомков некоторых типов, включая кнопки. Однако все равно остается возможность вручную установить свойство ButtonStyle и получить собственную специальную кнопку для панели инструментов, но обычно добиться необходимого результата можно и просто настройкой содержимого кнопки. Класс ToolBar изменяет не только внешний вид многих из размещаемых в нем элементов управления, но также и поведение унаследованных от него классов Toggle Button, CheckBox и RadioButton. Элементы ToggleButton и CheckBox в ToolBar визуализируются как обычные кнопки, но при выполнении на них щелчка остаются выделенными (до тех пор, пока на них не будет снова совершен щелчок). RadioButton имеет похожий внешний вид, но снять выделение с этого элемента можно, только щелкнув на другом таком же элементе в группе. (Во избежание путаницы группу объектов RadioButton в панели инструментов всегда лучше отделять с помощью Separator.)
Глава 25. Меню, панели инструментов и ленты 787 Чтобы продемонстрировать, как все это работает, рассмотрим следующую простую разметку: <Тоо1Ваг> <Button Content="{StaticResource DownloadFile}"></Button> <CheckBox FontWeight="Bold">Bold</CheckBox> <CheckBox FontStyle="Italic">Italic</CheckBox> <CheckBox> <TextBlock TextDecorations="Underline">Underline</TextBlock> </CheckBox> <Separator></Separator> <ComboBox SelectedIndex="> <ComboBoxItem>100%</ComboBoxItem> <ComboBoxItem>50%</ComboBoxItem> <ComboBoxItem>25%</ComboBoxItem> </ComboBox> <Separator></Separator> </ToolBar> На рис. 25.5 показана эта панель инструментов в действии с двумя элементами управления Checkbox в отмеченном состоянии и отображающимся раскрывающимся списком. Рис. 25.5. Различные элементы управления в панели инструментов В примере на рис. 25.5 кнопки содержат только текст, однако обычно кнопки в Toolbar включают графическое содержимое. (Более того, текстовое и графическое содержимое также можно комбинировать, упаковав элементы Image и TextBlock или Label в элемент StackPanel с горизонтальной ориентацией.) В качестве графического содержимого могут использоваться растровые изображения (чреватые появлением при определенных разрешениях артефактов масштабирования), пиктограммы (чуть более лучший вариант из-за того, что позволяют предоставлять в одном файле несколько версий изображения с разными размерами) и векторные изображения (требующие написания наибольшего объема кода разметки, но обеспечивающие идеальный эффект при изменении размера). С элементом управления Toolbar связано несколько странностей. В отличие от других элементов управления, унаследованных от ItemsControl, он не предоставляет отдельный класс-оболочку. (Другими словами, класса вроде ToolBarltem не существует) Элементу Toolbar просто не требуется такой класс-оболочка для управления элементами, отслеживания выбора и т.д., как другим списковым элементам управления. Другая странность состоит в том, что он унаследован от HeaderedltemsControl, несмотря на то, что свойство Header не дает никакого эффекта. Решение о том, как использовать это свойство, возлагается на разработчика. Например, при наличии интерфейса с несколькими объектами Toolbar разработчик может позволить пользователю выбирать, какой из них должен отображаться, из контекстного меню, и тогда уже в этом меню с помощью свойства Header указывать имена этих панелей инструментов. В Toolbar есть еще одно интересное свойство: Orientation. Установив Toolbar. Orientation в Vertical, можно получить панель инструментов, располагаемую сверху
788 Глава 25. Меню, панели инструментов и ленты вниз и пристыкованную к одной из сторон окна. Однако все элементы в этой панели все равно будут иметь горизонтальную ориентацию (например, текст не будет повернут соответствующим образом), если только для их переворачивания не применить трансформацию LayoutTransform. Дополнительное меню Если в панели инструментов находится больше содержимого, чем может уместиться в окне, лишние элементы удаляются и помещаются в дополнительное меню (overflow menu), которое можно увидеть, щелкнув на указывающей вниз стрелке в конце панели инструментов. На рис. 25.6 показана та же панель инструментов, что была на рис. 25.5, но только в окне меньшего размера, что приводит к созданию дополнительного меню. I 9~ BasicToolbaTT^lQdBHBl | WJ Open Save Close Bold Italic Underline. 100% - j тг ■ Рис. 25.6. Дополнительное меню, создаваемое автоматически Элемент управления Toolbar добавляет элементы в дополнительное меню автоматически, начиная с конца (т.е. с последнего элемента). Однако это поведение можно настраивать, применяя к элементам в панели инструментов присоединенное свойство ToolBar.OverflowMode. Значение OverflowMode.Never позволяет сделать так, чтобы элемент вообще никогда не размещался в дополнительном меню, OverflowMode.AsNeeded — так, чтобы элемент размещался в дополнительном меню только в случае нехватки места, а Overf lowMode.Always — чтобы он всегда оставался в этом меню. (Например, в Visual Studio в дополнительном меню всегда размещается команда настройки Add or Remove Buttons (Добавить или удалить кнопки), а в Excel 2003 и Word 2003 — команды Show Buttons on Two Rows (Отображать кнопки на двух строках) и Show Buttons on One Row (Отображать кнопки на одной строке).) На заметку! В случае если контейнер панели инструментов (обычно окно) окажется меньше пространства, необходимого для отображения всех элементов Overf lowMode. Always, не уместившиеся элементы будут усечены по краям контейнера и станут недоступными для пользователя. Если в панели инструментов содержится более одного элемента Overf lowMode. AsNeeded, тогда Toolbar сначала удаляет те элементы, что находятся в конце этой панели. К сожалению, возможности назначения элементам панели инструментов относительных приоритетов не существует. Например, нельзя создать элемент, допускающий размещение в дополнительном меню, но не помещаемый туда до тех пор, пока не будут перемещены все остальные передвигаемые элементы. Также нельзя создать кнопки, способные корректировать свои размеры в соответствии с количеством доступного пространства, вроде тех, что предлагаются в Office 2007. Для устранения этих пробелов следует искать элементы управления от независимых разработчиков. Элемент управления ToolBarTray Хотя допускается добавлять в окно множество элементов управления Toolbar и управлять ими с помощью контейнера компоновки, в WPF есть класс, предназначенный
Глава 25. Меню, панели инструментов и ленты 789 специально для этой цели, который называется ToolBarTray. По сути, ToolBarTray хранит коллекцию объектов Toolbar (которые предоставляются через свойство Toolbars). Класс ToolBarTray упрощает размещение панелей инструментов на одной строке, также называемой полосой (band). Его можно сконфигурировать так, чтобы одни панели инструментов размещались в одной и той же полосе, а другие — в отдельных полосах. ToolBarTray отображает по всей площади ToolBar затененный фон. Но самым важным является то, что ToolBarTray дополнительно предоставляет поддержку для функции перетаскивания панелей инструментов. Если только свойство ToolBarTray.IsLocked не установлено в true, пользователь может переупорядочивать находящиеся в ToolBarTray панели инструментов, щелкая на специальном значке захвата с левой стороны. Панели инструментов можно передвигать как в пределах одной и той же полосы, так и перемещать в другие полосы. Однако перетаскивать панель инструментов из одного элемента управления ToolBarTray в другой нельзя. Чтобы заблокировать возможность перемещения панелей инструментов, необходимо установить в true присоединенное свойство ToolBarTray.IsLocked для соответствующих объектов ToolBar. На заметку! При перемещении панелей инструментов некоторая часть их содержимого может стать трудной для восприятия. Например, пользователь может переместить панель инструментов в позицию, практически не оставляющую места для соседней панели инструментов. В такой ситуации все загораживаемые элементы управления перемещаются в дополнительное меню. В ToolBarTray разрешено размещать столько объектов ToolBar, сколько нужно. По умолчанию все добавленные панели инструментов располагаются в самой верхней полосе в порядке слева направо. Первоначально каждая панель инструментов занимает всю необходимую ей ширину. (Если следующая панель инструментов не умещается, некоторые или все ее кнопки переносятся в дополнительное меню.) Для достижения большего контроля можно указать, какую именно полосу должна занимать данная панель инструментов, указав в свойстве Band числовой индекс (где 0 соответствует самой верхней полосе). Допускается также явно задать нужную позицию расположения внутри полосы с помощью свойства Bandlndex. В случае установки Bandlndex в 0 панель инструментов размещается в начале полосы. Ниже приведен пример кода разметки, который создает в ToolBarTray несколько панелей инструментов. Результат можно видеть на рис. 25.7. <Тоо1ВагТгау> <Тоо1Ваг> <Button>One</Button> <Button>Two</Button> <Button>Three</Button> </ToolBar> <ToolBar> <Button>A</Button> <Button>B</Button> <Button>C</Button> </ToolBar> <ToolBar Band=,,l"> <Button>Red</Button> <Button>Blue</Button> <Button>Green</Button> <Button>Black</Button> </ToolBar> </ToolBarTray>
790 Глава 25. Меню, панели инструментов и ленты • ' ToolbarTrays I One Two Three ABC I Red Blue Green Black Рис. 25.7. Группирование панелей инструментов в ToolBarTray Элемент управления StatusBar По сравнению с Toolbar, класс StatusBar является куда менее изощренным. Подобно Toolbar он удерживает любое содержимое (упаковывая его неявным образом в объекты StatusBarltem) и переопределяет используемые по умолчанию стили некоторых элементов для обеспечения более подходящей визуализации. Однако поддержки для перегруппирования элементов за счет перетаскивания и создания дополнительного меню элемент управления StatusBar не включает. В основном его применяют для отображения текста и графических индикаторов (изредка — панели хода выполнения). Элемент управления StatusBar не очень подходит, когда требуется использовать один из элементов, унаследованных от ButtonBase, или элемент ComboBox. Он не переопределяет стилей ни одного из этих элементов управления, из-за чего они выглядят в строке состояния так, будто бы находятся не на своем месте. При необходимости создать строку состояния, включающую именно такие элементы управления, лучше рассмотреть вариант стыковки к нижней части окна обычного элемента управления ToolBar. Возможно, именно из-за этой общей нехватки функциональности StatusBar и находится в пространстве имен System.Windows.Controls.Primitives, a не в более универсальном пространстве имен System.Windows.Controls, где определен элемент управления ToolBar. Тем, кто решит использовать строку состояния, стоит знать о следующем. Обычно элемент управления StatusBar компонует свои дочерние элементы слева направо с помощью объекта StackPanel с горизонтальной ориентацией. Однако в приложениях элементы строки состояния довольно часто имеют пропорциональные размеры или прикреплены к правой стороне строки состояния. Реализовать такой дизайн можно за счет указания того, что в строке состояния должна использоваться другая панель, с помощью свойства ItemsPanelTemplate, о котором впервые рассказывалось в главе 20. Один из способов получить пропорциональные или правильно выровненные элементы — применить в качестве контейнера компоновки элемент управления Grid. Единственной сложностью является то, что для установки свойства GridColumn подходящим образом дочерний элемент нужно обязательно упаковать в объект StatusBarltem. Ниже приведен пример, в котором один элемент Text Bloc k размещается в левой части StatusBar, а другой — в правой: <StatusBar Grid.Row="l"> <StatusBar.ItemsPanel> <IternsPanelTemplate> <Grid> <Gnd.ColumnDef initions> <ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefimtion Width="Auto"></ColumnDefinition> </Gnd.ColumnDef initions> </Grid>
Глава 25. Меню, панели инструментов и ленты 791 </ItemsPanelTemplate> </StatusBar.ItemsPanel> <TextBlock>Left Side</TextBlock> <StatusBarItem Grid.Column="l"> <TextBlock>Right Side</TextBlock> </StatusBarItem> </StatusBar> Здесь становится очевидным одно из главных преимуществ WPF: другие элементы управления могут извлекать пользу из базовой модели компоновки без необходимости ее воссоздания. В состав Windows Forms тоже входило несколько элементов управления, способных упаковывать нечто вроде пропорциональных элементов, в числе которых StatusBar и DataGridView. Несмотря на концептуальный сценарий, эти элементы управления были вынуждения включать собственную модель компоновки и добавлять собственные, касающиеся компоновки свойства для управления дочерними элементами. В WPF такого нет: здесь каждый элемент управления, который наследуется от ItemsControl, может использовать любую панель для организации своих дочерних элементов. Ленты На этом этапе может показаться, что возможности панелей инструментов в WPF являются недостаточными. Помимо двух встроенных средств — базовое дополнительное меню и возможность переупорядочения со стороны пользователя — никакими современными функциями они не обладают. Даже в наборе Windows Forms имеется средство, которое позволяет пользователям перетаскивать и пристыковывать панели инструментов в различных местах внутри окна. Причина, по которой панели инструментов не были усовершенствованы после выхода первой версии WPF, очень проста: они являются "вымирающим видом". Хотя в настоящий момент они все еще продолжают пользоваться относительной популярностью, наблюдается переход на более интеллектуальные элементы управления с вкладками, такие как Ribbon (лента), который впервые появился в Office 2007, а теперь занимает почетное место в Windows 7 и Office 2010. После создания элемента управления Ribbon у Microsoft возникла знакомая дилемма. Для улучшения продуктивности и согласованности всех Windows-приложений в Microsoft захотели каким-то образом стимулировать использование этого элемента управления. Но поскольку также хотели сохранить конкурентные преимущества, с выпуском API-интерфейсов, которые бы позволили это делать, торопиться не стали. В конечном итоге на исследование и усовершенствование версии Ribbon в Microsoft потратили тысячи часов, поэтому не удивительно, что результата пришлось дожидаться несколько лет. К счастью, период ожидания закончился, и теперь Microsoft сделала версию Ribbon доступной и для разработчиков WPF Хорошая новость состоит в том, что эта версия является совершенно бесплатной и обладает приличным объемом функциональности, в том числе разнообразными видами подсказок, раскрывающимися кнопками, элементами для запуска диалоговых окон, панелью быстрого доступа и настраиваемыми размерами. В состав .NET Framework, однако, элемент управления Ribbon не входит. Он предлагается для загрузки в виде отдельного компонента, который на момент написания книги считался версией для предварительного ознакомления. Его можно загрузить с веб-сайта лицензирования пользовательского интерфейса Office (Office UI Licensing) no адресу http://msdn.microsoft.com/officeui (поищите ссылку "Лицензирование поль-
792 Глава 25. Меню, панели инструментов и ленты зовательского интерфейса Office"). Пугаться терминологии не стоит: под лицензированием подразумевается просто предоставление своей контактной информации и принятие условий одностраничного соглашения, в котором требуется следовать рекомендациям по проектированию пользовательского интерфейса Office. (Другими словами, в Microsoft не хотят, чтобы элемент управления Ribbon использовался неподходящим образом). Описание рекомендаций по использованию элемента управления Ribbon доступно по адресу http://tinyurl.com/4dsbef. После загрузки компонента Ribbon появится одна скомпилированная сборка типа библиотеки классов под названием RibbonControlsLibrary.dll. Чтобы приступить к использованию элемента управления Ribbon, понадобится просто добавить ссылку на эту сборку в любое приложение WPF и продолжить чтение главы. Добавление элемента управления Ribbon Как и для любого другого элемента управления, который не является частью ключевых библиотек WPF, сначала необходимо отобразить содержащую Ribbon сборку на префикс XML: <Window x:Class="RibbonTest.MainWindow" ... xmlns:r= "clr-namespace:Microsoft.Windows.Controls.Ribbon;assembly=RibbonControlsLibrary"> После этого можно добавлять экземпляр элемента управления Ribbon в любое место окна: <r:Ribbon> </r:Ribbon> Пока что наилучшим вариантом считается размещение элемента управления Ribbon в верхней части окна с использованием панели Grid или Dock. Но прежде чем двигаться дальше, в окно самого верхнего уровня следует внести одно изменение. Причина в том, что элемент управления Ribbon просто не выглядит правильно в обычном окне — он размещается под рамкой окна, из-за чего возникает впечатление, что он был добавлен в последний момент. Никакой линии границы между рамкой окна и элементом управления Ribbon не проходит, и панель быстрого запуска (тоже размещенная в верхней части окна), вставляется прямо строку заголовка окна. В библиотеке RibbonControlsLibrary.dll для решения этой проблемы предлагается класс RibbonWindow, который наследуется от класса Window и интегрируется с элементом управления Ribbon более гладким образом. На рис. 25.8 для сравнения показано, чем он отличается. 1 *> & У 1 - Ribbon Test Home Cut Copy Paste Save Open Clipboard 1—i-^iitfaitf1 Close Рис. 25.8. Обычное окно (слева) и окно RibbonWindow (справа)
Глава 25. Меню, панели инструментов и ленты 793 Ниже приведена базовая структура специального окна, унаследованного от RibbonWindow, в котором элемент управления Ribbon размещен в верхней части с сохранением второй строки Grid для отображения фактического содержимого окна: <r:RibbonWindow x:Class="RibbonTest.MainWindow" xrtilns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height=50" Width=25" xmlns:r= 11 clr-namespace:Microsoft.Windows.Controls.Ribbon;assembly=RibbonControlsLibrary"> <Grid> <Grid.RowDefinitions> <RowDefmition Height="Auto"x/RowDefinition> <RowDef initionx/RowDef inition> </Grid.RowDefinitions> <r:Ribbon> </r:Ribbon> </Grid> </r: RibbonWindow> При использовании RibbonWindow удостоверьтесь, что класс отделенного кода, представляющий окно, не наследуется явно от класса Window. Если это так, замените его классом RibbonWindow или удалите эту часть объявления класса вообще, как показано ниже: public partial class MainWindow { . .. } Такой подход сработает, т.к. в автоматически сгенерированной части класса MainWindow уже присутствует правильный производный класс RibbonWindow, потому что он был указан в XAML. Во время применения RibbonWindow можно будет заметить одну особенность. Из-за отсутствия в окне строки заголовка (то, что похоже на строку заголовка, в действительности является частью элемента управления Ribbon), какой бы текст не устанавливался для свойства Window.Title, он будет игнорироваться. Для устранения этой проблемы необходимо просто установить тот же самый текст в свойстве Ribbon.Title. Элемент управления Ribbon на самом деле состоит из трех фрагментов: панели для быстрого запуска (размещенной сверху), меню приложения (доступное через кнопку, отображаемую в самом конце слева, перед всеми вкладками) и самой ленты с множеством вкладок. В настоящее время никакого способа для отключения какого-то из этих компонентов не существует, поэтому элемент управления Ribbon должен использоваться только в полном виде. Стилизация элемента управления Ribbon Существует еще одна деталь, которая должна быть продумана перед началом заполнения элемента управления Ribbon кнопками. Обычно элемент управления Ribbon стилизуется так, чтобы выглядеть подобно тому, как он выглядит в приложениях Windows 7 (например, в Paint). Это означает, что кнопка, представляющая меню приложения, визуализируется как квадрат и размещается прямо под границей окна и панелью для быстрого запуска, а фон элемента управления Ribbon сливается с фоном рамки окна. Другим возможным вариантом является использование стиля Office 2007, при котором применяется более яркий цвет для фона, кнопка меню приложения превращается в большой круг и перемещается в левый верхний угол окна. На рис. 25.9 продемонстрированы отличия.
794 Глава 25. Меню, панели инструментов и ленты 1 && 1 - Ribbo. Я^ИИ 1 Ноте Cut Copy Paste Clipboard |Q|B MM Save Open Close Ries iiSS?^5 Rlbb"- L^^^B -c->-e 1 i Cot Copy Paste ы Save Open Oose Рис. 25.9. Элемент управления Ribbon со стилем Windows 7 (слева) и стилем Office 2007 (справа) Для изменения внешнего вида элемента управления Ribbon можно было бы разработать собственный стиль, но это очень утомительное занятие (требующее довольно глубоких знаний внутренней структуры элементов в Ribbon). Лучше воспользоваться одной из готовых коллекций стилей, которые хранятся в сборке RibbonControlsLibrary.dll в виде словаря ресурсов и предоставляются через класс PopularApplicationSkins. Называются они Of f ice2007Blue, Of f ice2007Black и Of f ice2007Silver. Ниже приведен пример с применением черной обложки: public MainWindow() { this.Resources .MergedDictionaries .Add(PopularApplicationSkins.Office2007Black) ; InitializeComponent(); Команды В основе элемента управления Ribbon лежат команды. Эти команды приводят в действие все части элемента управления Ribbon, в том числе его кнопки, меню приложения и панель быстрогб запуска. В отличие от стандартных меню и панелей инструментов в WPF, в Ribbon перехватывать события Click, поступающие от входящих в его состав элементов управления, не разрешено. Преимущество такого проектного решения в том, что он позволяет поставлять в элементе управления Ribbon более развитую модель команд. (Как рассказывалось в главе 9, базовая модель команд в WPF является довольно скромной.) Недостаток решения в том, что оно не позволяет использовать специальные классы, унаследованные от RoutedCommand. В табл. 25.1 перечислены свойства, которые предлагаются в RibbonCommand в дополнение к свойствам RoutedCommand. Следует отметить, что эти свойства применяются не во всех случаях. Например, RibbonCommand часто используется для установки рисунка, который должен отображаться на кнопке меню приложения. Для этой команды свойства LargelmageSource и SmalllmageSource важны; а свойство LabelTitle игнорируется. Совет. Для получения качественных изображений при создании большинства приложений будет привлекаться дизайнер графики. Однако на этапе тестирования приложения вполне могут использоваться метки-заполнители и стандартные изображения, поставляемые в Visual Studio. Поищите файл VS2010ImageLibrary.zip в папке вроде c:\Program Files\Microsoft Visual Studio 10.0\Common7\VS2010ImageLibrary\1033.
Глава 25. Меню, панели инструментов и ленты 795 Таблица 25.1. Дополнительные свойства RibbonCommand Имя Описание LabelTitle LabelDescription SmalllmageSource LargelmageSource ToolTipTitle ToolTipDescription ToolTipImageSource ToolTipFooterTitle ToolTipFooterDescription ToolTipFooterlmageSource Текст, который отображается на элементе ленты Дополнительный текст, который используется с некоторыми компонентами Ribbon. Например, это свойство отвечает за отображение необязательного заголовка над подменю элементов Изображение, используемое при визуализации элемента в небольшом размере A6x16 пикселей на стандартном мониторе 96 dpi). Во избежание появления артефактов масштабирования при разной плотности пикселей рекомендуется использовать объект Drawinglmage вместо растрового изображения Изображение, используемое при визуализации элемента в большом размере C2x32 пикселя на стандартном мониторе 96 dpi). Во избежание появления артефактов масштабирования при разной плотности пикселей рекомендуется использовать объект Drawinglmage вместо растрового изображения Заголовок, который отображается в верхней части подсказки для данного элемента. В Ribbon поддерживается новая модель подсказок, которая позволяет отображать всплывающие подсказки с большим количеством деталей, включающие заголовок, описание и изображение (а также нижний колонтитул с теми же деталями). Все эти детали необязательны, и устанавливать нужно только желаемые из них Текст, который отображается в подсказке под заголовком Изображение, которое появляется в подсказке под заголовком слева от текстового описания Текст, который отображается в заголовке нижнего колонтитула внутри подсказки Текст, который отображается в нижнем колонтитуле внутри подсказки под заголовком Изображение, которое появляется слева от текста нижнего колонтитула внутри подсказки Меню приложения Самым простым способом приступить к работе с элементом управления Ribbon является заполнение его меню приложения. В основе меню приложения лежат два простых класса: RibbonApplicationMenu (унаследованный от MenuBase) и RibbonMenuItem (унаследованный от Menultem). Это определяет шаблон, который будет встречаться повсюду в настоящем разделе — класс Ribbon порождает специализированные версии базовых классов элементов управления WPF. Строго говоря, этот шаблон не идеален. Элементы управления ToolBar и StatusBar имеют более чистую модель, поскольку способны работать со стандартными элементами управления WPF, для которых просто задают другой стиль. Но элементу управления Ribbon требуется дополнительный уровень производных классов для того, чтобы поддерживать многие из его усовершенствованных функциональных возможностей. Например, классы RibbonApplicationMenu и RibbonApplicationMenuItem совершенствуют обычные классы меню добавлением поддержки для RibbonComand.
796 Глава 25. Меню, панели инструментов и ленты Для получения меню приложения потребуется создать новый объект Ribbon ApplicationMenu и установить его для свойства Ribbon.ApplicationMenu. Как не трудно догадаться, Ribbon.ApplicationMenu включает в себя коллекцию объектов Ribbon Ар plicationMenuItem, каждый из которых представляет собой отдельный активизируемый щелчком мыши элемент меню. Ниже приведен базовый пример, в котором создается меню приложения с тремя элементами внутри: <r:Ribbon Title="Ribbon Test"> <r:Ribbon.ApplicationMenu> <r:RibbonApplicationMenu> <r:RibbonApplicationMenuItem>...</r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem>...</r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem>...</r:RibbonApplicationMenuItem> </r:RibbonApplicationMenu> </r:Ribbon.ApplicationMenu> </r:Ribbon> Для конфигурирования каждой команды необходимо просто предоставить объект RibbonCommand. Этот объект указывает текст, который должен отображаться для данной команды в меню (через свойство LabelTitle), и дополнительную подсказку, которая должна для нее появляться (с использованием свойств ToolTipTitle, ToolTipDescription и т.д), дополнительное изображение (LargelmageSource) и обработчик событий, который должен срабатывать при выполнении на элементе меню щелчка (Executed). Как рассказывалось в главе 9, можно также обработать событие CanExecuted, чтобы сконфигурировать доступное или недоступное состояние команды. Ниже приведен пример, в котором меню заполняется тремя командами (без установки необязательных свойств, касающихся подсказки): <r:Ribbon Title="Ribbon Test"> <r:Ribbon.ApplicationMenu> <r:RibbonApplicationMenu> <r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem.Command> <r: RibbonCommand LabelTitle="_Close" Large Image S our ce=" images \close .png" Executed="Close_Executed" /> </r:RibbonApplicationMenuItem.Command> </r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem.Command> <r: RibbonCommand LabelTitle="_Open" Large Image S our ce=" images \open. png" Executed="Open_Executed" /> </r:RibbonApplicationMenuItem.Command> </r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem.Command> <r:RibbonCommand LabelTitle="_Save" LargeImageSource="images\save.png" Executed="Save_Executed" /> </r:RibbonApplicationMenuItem.Command> </r:RibbonApplicationMenuItem> </r:RibbonApplicationMenu> </r:Ribbon.ApplicationMenu> </r:Ribbon>
Глава 25. Меню, панели инструментов и ленты 797 Совет. В этом примере команды объявляются прямо там, где используются. Более удобный подход предполагает создание команд в виде ресурсов с последующим добавлением ссылок на эти ресурсы (с использованием расширения StaticResource) или присоединением их с помощью привязок команд (см. главу 9). Такой подход предоставляет возможность использовать команды более гибким образом — например, в ответ на другие действия, такие как сокращенные клавиатурные команды или щелчки на других элементах управления Ribbon. Он более подробно рассматривается в следующем разделе при определении команд для кнопок, отображаемых в элементе управления Ribbon. Объект RibbonApplicationMenu наивысшего уровня тоже нуждается в объекте RibbonCommand, хотя и не используется для запуска команды! Причиной являются несколько других свойств, таких как свойства, связанные с подсказкой, и свойства изображения (которые устанавливают изображение, появляющееся внутри кнопки меню приложения). В случае применения используемого по умолчанию стиля Windows 7 потребуется установить свойство Small Image, а для стилей Office 2007, которые предусматривают отображение большой кнопки приложения — свойство LargelmageSource. Также важно отметить, что любой RibbonApplicationMenuItem может хранить больше объектов RibbonApplicationMenuItem для создания подменю, которое отображается во втором столбце меню, как показано на рис. 25.10. 1 *v* I * ь About Exit Ribbon Test Save Options II SmreAs U5" _J Рис. 25.10. Элемент управления Ribbon с подменю При создании подменю можно установить свойство RibbonCommand.LabelTitle содержащего объекта RibbonApplicationMenuItem и тем самым указать заголовок, который должен отображаться сверху подменю (см. рис. 25.10). Ниже приведен необходимый код разметки: <r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem.Command> <r-.RibbonCommand LabelTitle="_Save" LargeImageSource="images\save .png" LabelDescript±on="Save Options" /> </r:RibbonApplicationMenuItem.Command> <r: RibbonApplicationMenuItem> <r: RibbonApplicationMenuItem. Command> <r:RibbonCommand LabelTitle="Save As" Executed="SaveAs_Executed" /> </r:RibbonApplicationMenuItem.Command> </r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem> <r:RibbonApplicationMenuItem.Command> <r:RibbonCommand LabelTitle="Save" Executed="Save_Executed" /> </r:RibbonApplicationMenuItem.Command> </r:RibbonApplicationMenuItem> </r:RibbonApplicationMenuItem>
798 Глава 25. Меню, панели инструментов и ленты На заметку! Даже когда подменю не отображается, в меню приложения все равно сохраняется второй столбец. Эта область резервируется для списка последних документов, вроде того, что отображается в последних версиях Office и Windows 7 Paint. Для создания собственного списка последних документов понадобится установить свойство RibbonApplicationMenu. RecentitemList. Если это не сделано, данная область останется пустой. Вкладки, группы и кнопки Для заполнения вкладок в элементе управления Ribbon применяется та же модель, что и для заполнения меню приложения, но с рядом дополнительных уровней. Первым делом, лента хранит коллекцию вкладок. В свою очередь, каждая вкладка имеет одну или более групп, которые представляют собой выделенные контуром, снабженные заголовком разделы ленты прямоугольной формы. И, наконец, каждая группа содержит один или более элементов управления Ribbon. На рис. 25.11 показана эта организация. ■ Ribbon , RibbonGroup (Clipboard) ( RibbonCommand (уровня группы) RibbonButton (Cut) f RibbonCommand J RibbonButton (Copy) [ RibbonCommand J RibbonButton (Panel) f RibbonCommand J RibbonTab (Home) RibbonTab RibbonTab Рис. 25.11. Вкладки, группы и кнопки Каждый из этих ингредиентов имеет соответствующий класс. Для создания ленты, как на рис. 25.11, сначала объявляются необходимые объекты RibbonTab, каждый из них заполняется объектами групп RibbonGroup и в каждую группу помещаются элементы управления Ribbon (вроде простой кнопки RibbonButton). Как и в меню приложения, каждый из элементов управления должен быть сопоставлен с объектом RibbonCommand, который определяет его текстовое и графическое содержимое, а также с обработчиками событий, которые должны использоваться для обработки щелчков и определения состояния команды. Вдобавок потребуется присоединить объект RibbonCommand к каждой группе. Этот объект имеет несколько специальных предназначений. Во-первых, свойство RibbonCommand.LabelTitle позволяет указать заголовок группы, который должен отображаться прямо под разделом соответствующей группы в элементе управления Ribbon. Во-вторых, с помощью свойства RibbonCommand.SmalllmageSource задается изображение, которое должно использоваться в случае нехватки пространства и сво-
Глава 25. Меню, панели инструментов и ленты 799 рачивания группы в одну кнопку, как показано на рис. 25.12. И, в-третьих, событие RibbonCommand.Executed позволяет создать модуль запуска диалогового окна. (Под модулем запуска диалогового окна подразумевается небольшой значок, который появляется в правом нижнем углу некоторых групп и который при выполнении на нем щелчка приводит к отображению диалогового окна с дополнительными опциями.) Для добавления такого элемента в группу необходимо установить свойство RibbonGroup. HasDialogLauncher в true и обработать щелчок на модуле запуска за счет реагирования на событие RibbonCommand.Executed. Ниже приведена часть разметки Ribbon, в которой определяется группа Clipboard (Буфер обмена) с тремя командами внутри: <r:Ribbon Title="Ribbon Test"> <r:RibbonTab Label="Home"> <r:RibbonTab.Groups> <r:RibbonGroup> <r:RibbonGroup.Command> <r:RibbonCommand LabelTitle="Clipboard" SmallImageSource="images/clipboard.png" /> </r:RibbonGroup.Command> <r:RibbonButton Command="{StaticResource CutCommand}" /> <r:RibbonButton Command="{StaticResource CopyCommand}" /> <r:RibbonButton Command="{StaticResource PasteCommand}" /> </r:RibbonGroup> </r:RibbonTab.Groups> </r:RibbonTab> </r:Ribbon> Как не трудно заметить, в этом примере объекты RibbonCommand не объявлены внутристрочно. Это рекомендуемый подход, поскольку он позволяет использовать одни и те же команды во множестве кнопок ленты, а также в меню приложения, панели быстрого запуска и непосредственно в приложении. Вместо этого объекты RibbonCommand сохраняются в разделе ресурсов окна: <r:RibbonWindow.Resources> <ResourceDictionary> <r:RibbonCommand x:Key="CutCommand" LabelTitle="Cut" ToolTipTitle="Cut" ToolTipDescription="Copies the selected text to the clipboard and removes it" SmallImageSource="images\cut.png" LargeImageSource="images\cut.png" CanExecute="CutCommand_CanExecute" Executed="CutCommand_Executed" /> <r:RibbonCommand x:Key="CopyCommand" ... /> <r:RibbonCommand x:Key="PasteCommand" ... /> </ResourceDictionary> </r:RibbonWindow.Resources> На заметку! Альтернативным, но в равной степени удобным подходом является использование специального класса команд и присоединение этих команд к элементам управления с помощью привязок. Такой более изощренный подход, который более приемлем для крупных проектов, демонстрировался в главе 9. В данном примере лента состоит полностью из объектов RibbonButton. Такой тип элемента управления Ribbon является наиболее распространенным. Однако WPF предлагает дополнительные варианты, которые перечислены в табл. 25.2. Как и в случае меню приложения, многие классы элементов управления Ribbon унаследованы от классов стандартных элементов управления WPF. В них просто реализуется интерфейс IRibbonControl для получения дополнительных возможностей.
800 Глава 25. Меню, панели инструментов и ленты Таблица 25.2. Классы элементов управления Ribbon Имя Описание RibbonButton RibbonCheckBox RibbonToggleButton RibbonDropDownButton RibbonSplitButton RibbonComboBox RibbonTextBox RibbonLabel RibbonSeparator Активизируемая щелчком кнопка с текстом и изображением, которая чаще всего применяется в элементе управления Ribbon Флажок, который может быть отмечен или не отмечен Кнопка, которая имеет два состояния: нажата и не нажата. Например, во многих программах кнопка такого вида используется для включения или отключения характеристик шрифта, связанных с начертанием — полужирное, курсивное и подчеркнутое Кнопка, которая позволяет раскрывать меню. Это меню заполняется объектами Menu Item с помощью коллекции RibbonDropDownButton Кнопка, которая похожа на RibbonDropDownButton, но в действительности имеет два раздела. Пользователь может щелкнуть на верхней ее части (с рисунком) и запустить команду или на нижней (с текстом и стрелкой вниз) и отобразить связанное с ней меню элементов. Например, кнопкой типа RibbonSplitButton является команда Paste в Word Встраивает в элемент управления Ribbon комбинированное окно, которое пользователь может применять для ввода текста или выбора точно таким же образом, как и стандартный элемент управления ComboBox Встраивает в элемент управления Ribbon текстовое поле, которое пользователь может применять для ввода текста точно таким же образом, как и стандартный элемент управления TextBox Встраивает в элемент управления Ribbon статический текст, подобный стандартному элементу управления Label. Особенно полезен, когда используются встроенные элементы управления вроде RibbonComboBox и RibbonTextBox и необходимо добавить для них описательные заголовки Рисует вертикальную линию между отдельными элементами управления (или группами элементов управления) в ленте Изменение размеров элемента управления Ribbon Одной из наиболее замечательных возможностей элемента управления Ribbon является его способность изменяться в размерах и подстраиваться под ширину окна за счет сокращения и переупорядочивания кнопок в каждой группе. При создании элемента управления Ribbon с помощью WPF базовая функция изменения размера предоставляется автоматически. Она встроена в RibbonWrapPanel и предусматривает использование разных шаблонов в зависимости от количества элементов в группе и ее размера. Например, в случае группы с тремя объектами RibbonButton при наличии достаточного пространства эти объекты будут отображаться слева направо. Если же пространства недостаточно, элементы управления, размещенные справа, будут сворачиваться до небольших значков, их текст — удаляться для высвобождения большего места, а вся группа — уменьшаться до одной кнопки, щелчок на которой приводит к отображению всех команд в раскрывающемся списке. На рис. 25.12 это можно видеть на примере элемента управления Ribbon с тремя копиями группы File. Первая группа находится в полностью развернутом состоянии, вторая — в частично свернутом,
Глава 25. Меню, панели инструментов и ленты 801 а третья — в полностью свернутом. (Следует отметить, что для создания такого примера элемент управления Ribbon должен быть явно сконфигурирован так, чтобы он не сворачивал первую группу. В противном случае он будет пытаться частично сворачивать каждую группу, прежде чем полностью свернет только какую-то одну из них.) I * V* I * Ribbon Test \\ Г'|1Я] Нота ^s Й М i Usav* y ЦШ К=1 «Я *> ML . хо^ ||^л Copy Piste Swe Open Oose Л "*« Cose Cpboard ftes t$ Ш Ь * I Save Open Oose I Рис. 25.12. Сжатие элемента управления Ribbon Для изменения размеров группы в ленте можно использовать несколько приемов. С помощью свойства RibbonTab.GroupSizeReductionOrder можно указать, какие группы должны сокращаться первыми, используя для обозначения каждой группы значение ее свойства LabelTitle. Например: <r:RibbonTab Label="Home" GroupSizeReductionOrder="Clipboard,Tasks,File"> При сокращении размеров окна все группы начнут постепенно сворачиваться. Однако первой до более компактного размера будет сворачиваться группа Clipboard, за ней — группа Tasks и т.д. Если размеры окна продолжают уменьшаться, запускается следующий круг переупорядочивания групп, первым в котором под сокращение подпадает группа Clipboard. Если свойство GroupSizeReductionOrder не установлено, первой сворачиваться всегда будет группа, занимающая крайнюю позицию справа. Более мощный подход предусматривает создание коллекции объектов RibbonGroupSizeDefinition, которая диктует, каким именно образом должно происходить сворачивание группы. Каждый объект RibbonGroupSizeDefinition представляет собой шаблон, в котором определяется один вариант компоновки и указывается, какие команды должны снабжаться большими значками, какие — небольшими и какие должны включать в себя отображаемый текст. Ниже приведен пример объекта RibbonControlSizeDef inition, в котором для группы, состоящей из четырех элементов управления, задается компоновка, придающая им насколько возможно большой размер: <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True" /> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True" /> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True" /> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True" /> </r:RibbonGroupSizeDefinition> Чтобы обрести контроль над процессом изменения размеров групп, необходимо определить множество таких объектов RibbonGroupSizeDefinition и упорядочить их в коллекции RibbonGroupSizeDef initionCollection от наибольшего к наименьшему. При сворачивании группы элемент управления Ribbon тогда сможет переключаться с одной компоновки на другую для высвобождения большего пространства и при этом сохранять желаемую разработчиком компоновку (гарантируя, что наиболее важные для разработчика элементы управления будут видимыми). Г
802 Глава 25. Меню, панели инструментов и ленты Саму коллекцию RibbonGroupSizeDef initionCollection обычно лучше размещать в разделе Ribbon.Resources, чтобы иметь возможность повторно использовать те же последовательности шаблонов для более чем одной группы. <r:Ribbon.Resources> <r:RibbonGroupSizeDefinitionCollection x:Key="RibbonLayout"> <!-- Все большие элементы управления. --> <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDefmition ImageSize="Large" IsLabelVisible="True"/> <r .-RibbonControlSizeDef init ion ImageSize="Large" IsLabelVisible="True"/> <r:RibbonControlSizeDefmition ImageSize="Large" IsLabelVisible="True"/> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True"/> </r:RibbonGroupSizeDefinition> <!-- Большой элемент управления по обоим концам с двумя небольшими элементами управления посередине. --> <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDef1nition ImageSize="Large" IsLabelVisible="True"/> <r:RibbonControlSizeDefinition ImageSize="Small" IsLabelVisible="True"/> <r:RibbonControlSizeDeflnition ImageSize="Small" IsLabelVisible="True"/> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True"/> </r:RibbonGroupSizeDefinition> <•-- To же, что раньше, но только без текста для небольших кнопок. --> <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDefinition ImageSize="Large" IsLabelVisible="True"/> <r:RibbonControlSizeDefmition ImageSize="Small" IsLabelVisible="False"/> <r : RibbonControlSizeDefinition ImageSize="Small" IsLabelVisible="False"/> <r : RibbonControlSizeDeflnition ImageSize="Large" IsLabelVisible = "True"/> </r:RibbonGroupSizeDefinition> <!-- Все небольшие кнопки. --> <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDeflnition ImageSize="Small" IsLabelVisible="True"/> <r : RibbonControlSizeDefinition ImageSize="Small" IsLabelVisible="False"/> <r:RibbonControlSizeDefinition ImageSize="Small" IsLabelVisible="False"/> <r:RibbonControlSizeDefmition ImageSize="Small" IsLabelVisible="True"/> </r:RibbonGroupSizeDefinition> <!-- Все небольшие кнопки без текста. --> <r:RibbonGroupSizeDefinition> <r:RibbonControlSizeDefmition ImageSize="Small" IsLabelVisible="False"/> <r:RibbonControlSizeDeflnition ImageSize="Small" IsLabelVisible="False"/> <r .-RibbonControlSizeDef init ion Image Size=" Small" IsLabelVisible="False"/> <r .-RibbonControlSizeDef init ion Image Size = " Small" IsLabelVisible="False"/> </r:RibbonGroupSizeDefinition> < ' -- Сворачивание всей группы до одной раскрывающейся кнопки. --> <r:RibbonGroupSizeDefmition IsCollapsed="True" /> </r:RibbonGroupSizeDefinitlonColleetion> </r:Ribbon.Resources> Панель быстрого запуска И, наконец, последним компонентом, который может использоваться в элементе управления Ribbon является панель быстрого запуска (Quick Access Toolbar — QAT). Эта панель представляет собой полосу с наиболее часто применяемыми кнопками, которая размещается либо прямо над, либо непосредственно под остальной частью элемента управления Ribbon, в зависимости от выбора пользователя.
Глава 25. Меню, панели инструментов и ленты 803 Панель быстрого запуска представляет объект QuickAccessToolBar, который может содержать в себе набор объектов RibbonButton. При определении RibbonCommand для этих объектов должен быть предоставлен только текст подсказки и небольшое изображение, потому что текстовые метки (TextLabel) и крупные изображения в них никогда не отображаются. Единственной новой деталью в панели быстрого запуска является меню настройки, которое появляется в результате щелчка на стрелке раскрывающегося списка в крайней справа ее части (рис. 25.13). Это меню можно использовать для настройки пользователями команд, появляющихся в панели быстрого запуска. Его можно вообще отключить, установив свойство QuickAccessToolBar.CanUserCustomize в false. А - Ribbon Test °% Customize Quick Access Toolbar • Redo Cut Copy Pi C'pfcodftf M Зе М I Show Below the Ribbon Minimize the Ribbon Рис. 25.13. Панель быстрого запуска Пользовательская настройка работает через присоединенное свойство RibbonQuick AccessToolBar.Placement. Здесь доступны три варианта. Указывайте значение InToolBar, если необходимо, чтобы команда появлялась только в панели быстрого запуска (но не в меню настройки), так что она всегда остается видимой. Используйте значение InCustomizeMenuAndToolBar, когда нужно, чтобы команда появлялась и в панели быстрого запуска, и в меню, настройки, тогда у пользователя будет возможность снимать с нее отметку и скрывать ее. Применяйте значение InCustomizeMenu, если требуется, чтобы команда появлялась в неотмеченном виде в меню настройки, но не в самом элементе управления Ribbon, тогда пользователь может ее при необходимости отобразить. Ниже приведено определение простой панели быстрого запуска: <r:Ribbon.QuickAccessToolBar> <r :RibbonQuickAccessToolBar CanUserCustomize="True">t <•-- Всегда видна и не может удаляться. --> <r:RibbonButton Command="{StaticResource UndoCommand}" r:RibbonQuickAccessToolBar.Placement="InToolBar" /> <•-- Видна, но может скрываться с помощью меню настройки. --> <r-.RibbonButton Command=" {StaticResource RedoCommand} " r:RibbonQuickAccessToolBar.Placement="InCustomizeMenuAndToolBar" /> <•-- He видна, но может отображаться с помощью меню настройки. --> <r:RibbonButton Command="{StaticResource SaveCommand}" r:RibbonQuickAccessToolBar.Placement="InCustomizeMenu" /> </r:RibbonQuickAccessToolBar> </r-.Ribbon. QuickAccessToolBar>
804 Глава 25. Меню, панели инструментов и ленты Резюме В этой главе рассматривались четыре элемента управления, которые постоянно используются в профессиональных приложениях Windows. Первые три из них — Menu, ToolBar и StatusBar — унаследованы от класса ItemsControl, о котором речь шла в главе 20. Но вместо данных они позволяют отображать группы команд меню, кнопки панели инструментов и элементы состояния. Это еще один пример того, как фундаментальные элементы управления вроде ItemsContol берутся в библиотеке WPF и используются для стандартизации целых ветвей в семействе элементов управления. Четвертым и последним элементом, который был описан в главе, является элемент управления Ribbon, который был впервые предложен в качестве замены для панели инструментов в Office 2007 и превратился в совершенно стандартный компонент в Windows 7. В составе .NET он не поставляется, а предлагается для загрузки в виде бесплатной библиотеки, что, несомненно, является замечательным достижением для разработчиков WPF. Ситуация стала гораздо лучше, чем она обстояла с ранними технологиями для разработки пользовательских интерфейсов производства Microsoft, такими как Windows Forms, в которых передовые функциональные возможности из пакета Office и других приложений Windows адаптировались недопустимо медленно.
ГЛАВА 26 Звук и видео В этой главе затрагиваются еще две области функциональности WPF — аудио и видео. Именно поддержка аудио в WPF стала заметным шагом вперед по сравнению с предыдущими версиями .NET, хотя она еще и далека от совершенства. WPF предоставляет возможность воспроизводить широкое разнообразие аудио-форматов, включая файлы МРЗ и все остальное, что поддерживает проигрыватель Windows Media. Однако звуковые возможности WPF пока еще много беднее DirectSound (расширенного звукового API-интерфейса DirectX), который позволяет применять динамические эффекты и помещать звуки в эмулируемое трехмерное пространство. WPF также недостает возможности получения спектральных данных, которые сообщат максимальный и минимальный уровни звука, что полезно при создании некоторых типов синхронизирующих эффектов и управляемой звуком анимации. Поддержка видео в WPF более впечатляющая. Хотя возможность воспроизведения видео (такого как файлы MPEG и WMV) не особо потрясающа, способ ее интеграции с остальной частью модели WPF весьма неплох. Например, видео можно применять для заполнения тысяч элементов за раз и комбинировать его с эффектами, анимацией, прозрачностью и даже трехмерными объектами. В этой главе будет показано, как интегрировать видео- и аудио-содержимое в приложения. Будет даже представлен краткий обзор поддержки синтеза и распознавания речи в WPF Но, прежде чем обратиться к более экзотичным примерам, начнем с рассмотрения базового кода, необходимого для воспроизведения скромного аудио в формате WAV. Воспроизведение WAV-аудио Платформа .NET Framework имеет небогатую историю поддержки звука. Версии 1.0 и 1.1 не предлагали никакого управляемого способа воспроизведения аудио, а когда долгожданная поддержка, наконец, появилась в .NET 2.0, она была представлена в форме не приводящего в восторг класса Sound Player (который можно найти в "малонаселенном" пространстве имен System.Media). Класс SoundPlayer довольно ограничен: он может воспроизводить только файлы в формате WAV, не поддерживает воспроизведения одновременно более одного звука и совсем не предоставляет возможностей управления никакими аспектами воспроизведения аудио (например, громкостью и балансом). Чтобы получить эти возможности, разработчики, использующие Windows Forms, вынуждены были работать с библиотекой неуправляемого кода quartz.dll. На заметку! Библиотека quartz.dll — ключевая часть DirectX, и она присутствует в проигрывателе Windows Media и операционной системе Windows. (Тот же компонент известен под названием DirectShow, а предыдущие версии назывались ActiveMovie.) Подробности использования quartz.dll в Windows Forms изложены в книге Pro .NET2.0 Windows Forms and Custom Controls in C# (Apress, 2005 г.).
806 Глава 26. Звук и видео Класс SoundPlayer поддерживается в приложениях WPF. Если смириться с его существенными ограничениями, то можно сказать, что он предлагает наиболее простой и легкий способ добавления работы с аудио в приложения. Класс SoundPlayer также упаковывается в класс SoundPlayerAction, который позволяет воспроизводить звук через декларативный триггер (вместо написания нескольких строк кода С# в обработчике событий). В следующих разделах будет представлен краткий обзор обоих классов, а затем уже описания более мощных WPF-классов MediaPlayer и MediaElement. Класс SoundPlayer Чтобы воспроизвести звук с помощью класса SoundPlayer, понадобится выполнить перечисленные ниже шаги. 1. Создать экземпляр SoundPlayer. 2. Указать звуковое содержимое, установив либо свойство Stream, либо свойство SoundLocation. Если есть объект Stream, содержащий звук в формате WAV, используйте свойство Stream. Если же есть путь к файлу или URL, указывающий на файл WAV, применяйте свойство SoundLocation. На заметку! Если аудио-содержимое хранится в виде двоичного ресурса и встроено в приложение, то потребуется доступ к нему в виде потока (см. главу 7) и использование свойства SoundPlayer.Stream. Причина в том, что SoundPlayer не поддерживает синтаксис упакованных URL в WPF. 3. Установив свойство Stream или SoundLocation, можно заставить SoundPlayer в действительности загрузить аудиоданные, вызвав метод Load() или LoadAsync(). Метод Load () наиболее прост — он останавливает выполнение кода до тех пор, пока весь звуковой фрагмент не будет загружен в память. LoadAsync () выполняет свою работу в другом потоке и по завершении инициирует событие LoadCompleted. На заметку! Формально использовать Load() или LoadAsync () не обязательно. Экземпляр SoundPlayer загружает аудиоданные по мере необходимости, когда вызывается метод Play () или PlaySync(). Однако явно загрузить аудио-фрагмент — хорошая идея; это не только позволит снизить накладные расходы при многократном воспроизведении, но также упростит обработку исключений, связанных с файловыми проблемами, отдельно от исключений, вызванных причинами, относящимися к процессу воспроизведения. 4. После этого можно вызвать PlaySyncO, который приостановит код на время воспроизведения аудио-фрагмента, или же применить Р1ау() для воспроизведения в другом потоке, обеспечивая интерфейсу приложения способность реагировать на действия пользователя. Единственный другой доступный вариант — это метод PlayLoopingO, воспроизводящий аудио-фрагмент асинхронно в бесконечном цикле (что идеально для саундтреков). Чтобы остановить текущее воспроизведение в любой момент, необходимо вызвать метод Stop(). В следующем фрагменте кода демонстрируется простейший подход к загрузке и асинхронному воспроизведению аудиофайла: SoundPlayer player = new SoundPlayer (); player.SoundLocation = "test.wav"; try { player.Load () ; player.Play ();
Глава 26. Звук и видео 807 catch (System.10.FileNotFoundException err) { // Если файл не найден, здесь возникнет ошибка. } catch (FormatException err) { // Здесь сгенерируется исключение FormatException, // если файл не будет иметь правильный аудиоформат WAV. } До сих пор в коде предполагалось, что аудиофайл присутствует в том же каталоге, что и скомпилированное приложение. Однако загружать SoundPlayer-аудио из файла не обязательно. Для коротких звуков, которые воспроизводятся в нескольких местах приложения, возможно, разумнее встроить звуковые файлы непосредственно в скомпилированную сборку в виде двоичных ресурсов (не путайте их с декларативными ресурсами, определяемыми в коде разметки XAML). Эта техника, которая обсуждается в главе 11, работает со звуковыми файлами так же хорошо, как и с графическими изображениями. Например, если добавить файл ding.wav как ресурс по имени Ding (просто перейдите к узлу Properties1^ Resources (СвойствамРесурсы) в окне Solution Explorer и воспользуйтесь поддержкой визуального конструктора), то можно будет применить следующий код для его воспроизведения: SoundPlayer player = new SoundPlayer (); player.Stream = Properties.Resources.Ding; player.Play(); На заметку! Класс SoundPlayer не слишком хорошо работает с большими аудиофайлами, поскольку он должен загрузить в память весь файл целиком. Может показаться, что данную проблему можно разрешить, разбив большой аудиофайл на куски, однако класс SoundPlayer не предназначен для этого. Не существует простого способа такой синхронизации SoundPlayer, чтобы он мог воспроизвести множество аудиофрагментов друг за другом, поскольку он не обеспечивает никаких средств для организации очередей. Всякий раз, когда вызывается метод PlaySoundO или Р1ау(), текущее воспроизведение останавливается. Обходные пути возможны, но намного лучше вместо этого воспользоваться классом MediaElement, который рассматривается далее в этой главе. Класс SoundPlayerAction Класс SoundPlayerAction позволяет более удобно использовать класс SoundPlayer. Класс SoundPlayerAction унаследован от TriggerAction (см. главу 11), который позволяет использовать его в ответ на любое событие. Ниже приведена разметка кнопки, применяющей SoundPlayerAction для подключения события Click к звуку. Триггер организован так, что его можно применить к множеству кнопок (если перенести его в коллекцию Resources). <Button> <Button.Content>Play Sound</Button.Content> <Button.Style> <Style> <Style.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <SoundPlayerAction Source="test. wav"x/SoundPlayerAction> </EventTrigger.Actions> </EventTrigger> </Style.Triggers> </Style></Button.Style> </Button>
808 Глава 26. Звук и видео При использовании SoundPlayerAction звук всегда воспроизводится асинхронно. Системные звуки Одной из особенностей операционной системы Windows является ее способность отображать аудиофайлы на определенные системные события. Наряду с SoundPlayer в WPF также предоставлен класс System.Media.SystemSounds, позволяющий получить доступ к наиболее часто используемым из этих звуков и задействовать их в собственных приложениях. Эта техника работает лучше, если все, что требуется — это простые короткие звуки, предназначенные для того, чтобы уведомить о завершении какой-то длительной операции или подать сигнал предупреждения. К сожалению, класс SystemSounds основан на функции MessageBeep из API- интерфейса Win32, в результате чего он обеспечивает доступ только к следующим общим системным звукам: • Asterisk (Вопросительный знак) • Веер (Уведомление о получении почты) • Exclamation (Восклицание) • Hand (Критическая ошибка) • Question (Вопрос) Класс SystemSounds предоставляет свойство для каждого из этих звуков, возвращающее объект SystemSound, который можно использовать для воспроизведения звука с помощью метода Р1ау(). Например, для воспроизведения звука Веер в коде служит следующая строка: SystemSounds.Beep.Play() ; Чтобы указать системе, какие файлы WAV следует применять для каждого системного звука, войдите в панель управления и выберите значок Звуки и аудиоустройства (в Windows ХР) либо Звук (в Windows Vista или Windows 7). Класс MediaPlayer Классы SoundPlayer, SoundPlayerAction и SystemSounds легко использовать, но все они относительно маломощные. В современном мире вместо исходного формата WAV намного более распространен сжатый формат звука МРЗ для всех целей, за исключением простейших звуков. Для воспроизведения МРЗ-аудио или MPEG-видео предназначены классы MediaPlayer и MediaElement. Оба они зависят от ключевых элементов технологии, предоставленной проигрывателем Windows Media. Класс MediaPlayer (находящийся в специфичном для WPF пространстве имен System.Windows.Media) — это WPF-эквивалент класса SoundPlayer. Хотя ясно, что он не настолько легковесен, все же он работает примерно так же. Объект MediaPlayer создается, с помощью метода Ореп() загружается аудиофайл, а вызовом метода Р1ау() запускается асинхронное воспроизведение. (Опция синхронного воспроизведения не предусмотрена.) Рассмотрим пример: private MediaPlayer player = new MediaPlayer ()•; private void cmdPlayWithMediaPlayer_Click(object sender, RoutedEventArgs e) { player .Open (new Un ("test .mp3", UriKind.Relative) ) ; player.Play(); }
Глава 26. Звук и видео 809 Существует несколько важных деталей, на которые следует обратить внимание в этом примере. • Media Player создается вне обработчика событий, поэтому он существует на протяжении жизненного цикла окна. Причина в том, что метод MediaPlayer.Close () вызывается тогда, когда объект MediaPlayer удаляется из памяти. Если создать объект MediaPlayer в обработчике событий, то он будет удален из памяти почти немедленно и, вероятно, вскоре после этого будет удален сборщиком мусора, и тогда будет вызван метод Close () и воспроизведение прервется. На заметку! Обязательно должен быть создан обработчик события Window.Unloaded, в котором вызывается метод Close () для остановки любого воспроизводящегося в данный момент звука при закрытии окна. • Местоположение файла указывается в виде URI. К сожалению, это не синтаксис упакованных URI, который был описан в главе 7, так что встроить аудиофайл и воспроизвести его с использованием класса MediaPlayer не получится. Это ограничение объясняется тем, что класс MediaPlayer построен на функциональности, которая не является встроенной в WPF, а предоставлена отдельным, неуправляемым компонентом проигрывателя Windows Media. • Код обработки исключений отсутствует. Это возмутительно, но методы Ореп() и Play () не генерируют исключения (в некоторой степени тому виной процессы асинхронной загрузки и воспроизведения). Взамен предлагается самостоятельно обрабатывать события MediaOpened и MediaFailed, чтобы определить, было ли запущено воспроизведение аудио. MediaPlayer достаточно прост, хотя обладает большими возможностями, чем SoundPlayer. Он предоставляет небольшой набор полезных методов, свойство и событий. Их полный перечень приведен в табл. 26.1. Таблица 26.1. Ключевые члены MediaPlayer Член Описание Balance Устанавливает баланс между левым и правым каналом как число от -1 (только левый канал) до 1 (только правый канал) Volume Устанавливает громкость в виде числе от 0 (полная тишина) до 1 (полная громкость). Значение по умолчанию равно 0.5 SpeedRatio Устанавливает повышенную скорость при воспроизведении звука (или видео). Значение по умолчанию равно 1, что означает нормальную скорость, в то время как 2 — двойную скорость, 10 — скорость, вдесятеро выше нормальной, 0.5 — половину нормальной скорости и т.д. Можно использовать любое положительное значение типа double HasAudio Указывает на то, содержит ли текущий загруженный медиафайл, со- и HasVideo ответственно, аудио- и видеосоставляющие. Для воспроизведения видео должен использоваться класс MediaElement, описанный в следующем разделе NaturalDuration, Указывают на то, идет ли воспроизведение на нормальной скорости, а NaturalvideoHeight также задают размер видео-окна. (Ниже будет показано, что допускает- и NaturalVideoWidth ся растягивать и сжимать видео для заполнения окон разного размера.) Position Объект TimeSpan, указывающий текущее местоположение в медиа- файле. Это свойство можно устанавливать для пропуска части файла и продолжения воспроизведения с указанного места
810 Глава 26. Звук и видео Окончание табл. 26.1 Член Описание DownloadProgress и Показывает процент загружаемого файла (удобно в тех случаях, ко- Buf feringProgress гда источником является URL, указывающий на местоположение в Интернете или на другом компьютере). Процент представлен в виде числа от 0 до 1 Clock Получает или устанавливает часы MediaClock, ассоциированные с проигрывателем. MediaClock используется только тогда, когда аудио синхронизируется с временной шкалой (примерно так же, как это делалось при синхронизации анимации с временной шкалой в главе 15). При использовании методов MediaPlayer для выполнения воспроизведения вручную это свойство равно null Open () Загружает новый медиафайл Play () Начинает воспроизведение. Не имеет никакого эффекта, если файл уже воспроизводится Pause () Временно приостанавливает воспроизведение, не меняя его позиции. Если вызвать Play () снова, то воспроизведение начнется с текущей позиции. Если воспроизведение не происходит, не дает никакого эффекта Stop () Останавливает воспроизведение и сбрасывает позицию на начало файла. Если снова вызвать Play (), то воспроизведение начнется с начала файла. Не имеет эффекта, если воспроизведение уже остановлено Используя эти члены класса, можно построить базовый полнофункциональный медиапроигрыватель. Однако программисты WPF обычно применяют другой, относительно более простой элемент, который рассматривается в следующем разделе — MediaElement. Элемент MediaElement MediaElement — это элемент WPF, служащий оболочкой функциональности класса MediaPlayer. Подобно всем элементам, MediaElement помещается непосредственно в пользовательский интерфейс. В случае применения MediaElement для воспроизведения аудио его расположение не имеет значения, но если воспроизводится видео, он должен быть размещен там, где планируется отображаться видео-окно. Простейший дескриптор MediaElement — это все, что понадобится для воспроизведения звука. Например, если добавить следующую разметку к пользовательскому интерфейсу: <MediaElement Source="test .mp3"x/MediaElement> то аудиофайл test.mp3 будет воспроизведен немедленно после загрузки (что более или менее совпадет с загрузкой окна). Программное воспроизведение аудио Обычно в тонком управлении воспроизведением необходимости нет. Например, может потребоваться, чтобы в определенный момент оно было запущено, постоянно воспроизводилось повторно и т.д. Один способ достичь этого заключается в применении методов класса MediaElement в надлежащий момент. Поведение MediaElement при запуске определяется его свойством LoadedBehavior, которое является одним из нескольких свойств, которые добавлены в классе MediaElement и отсутствуют в классе MediaPlayer.
Глава 26. Звук и видео 811 Свойство LoadedBehavior принимает любое значение из перечисления MediaState. По умолчанию оно равно Play, но можно также использовать Manual — в этом случае файл загружается, а за запуск воспроизведения в нужный момент отвечает код. Другое значение этого свойства — Pause, установка которого приостанавливает воспроизведение, но не позволяет использовать методы воспроизведения. (Вместо этого придется запускать воспроизведение с помощью триггеров и раскадровки, как будет описано в следующем разделе.) На заметку! Класс MediaElement также предоставляет свойство UnloadedBehavior, определяющее, что произойдет при выгрузке элемента. В данном случае единственным осмысленным выбором может быть Close, поскольку это закроет файл и освободит все системные ресурсы. Итак, чтобы воспроизвести аудиофайл программно, необходимо начать с изменения LoadedBehavior, как показано ниже: <MediaElement Source="test.mp3" LoadedBehavior="Manual" Name="media"> </MediaElement> Кроме того, понадобится выбрать имя, чтобы можно было взаимодействовать с медиа-элементом в коде. Обычно взаимодействие предусматривает вызов очевидных методов Play (), Pause() и Stop(). Также можно установить свойство Position, чтобы перемещаться по аудиозаписи. Ниже приведен простой обработчик событий, который осуществляет перемотку записи в начало и запускает воспроизведение: private void cmdPlay_Click(object sender, RoutedEventArgs e) { media.Position = TimeSpan.Zero; media.Play(); } Если этот код запускается во время воспроизведения, то его первая строка сбросит позицию в начало, и воспроизведение начнется оттуда. Вторая строка не будет иметь эффекта, поскольку медиафайл уже воспроизводится. Если же попытаться применить этот код в отношении MediaElement, у которого свойство LoadedBehavior не установлено в Manual, возникнет исключение. На заметку! В типичном медиапроигрывателе можно выполнять типовые команды вроде воспроизведения, паузы и останова более чем одним способом. Очевидно, что это отличное место для того, чтобы применить модель команд WPF. Фактически, для этого существует класс команд, который уже включает некоторую удобную инфраструктуру, и этот класс — System. Windows.Input.MediaCommands. Однако MediaElement не имеет никаких привязок команд по умолчанию, которые поддерживают класс MediaCommands. Другими словами, на вас возлагается задача написания логики обработки событий, которая реализует каждую команду и вызовет соответствующий метод MediaElement. В ваших силах организовать код так, чтобы несколько элементов пользовательского интерфейса были привязаны к одной и той же команде для сокращения дублирования кода. Более подробно команды рассматривались в главе 9. Обработка событий MediaElement не генерирует исключение, если не может найти или загрузить файл. Вместо этого предлагается обработать событие MediaFailed. К счастью, задача это несложная. Просто подкорректируйте дескриптор MediaElement: <MediaElement . . . MediaFailed="media MediaFailed"x/MediaElement>
812 Глава 26. Звук и видео Затем в обработчике событий с помощью свойства ExceptionRoutedEventArgs. ErrorException получите объект исключения, описывающий проблему: private void media_MediaFailed(object sender, ExceptionRoutedEventArgs e) { lblErrorText.Content = e.ErrorException.Message; } Воспроизведение аудио с помощью триггеров До сих пор переход от класса MediaPlayer к MediaElement не дал никаких преимуществ (помимо поддержки видео, о чем речь пойдет далее в этой главе). Однако при использовании MediaElement также появляется возможность управлять аудио декларативно, через XAML-разметку, а не код. Это делается с помощью триггеров и раскадровок, которые были описаны в главе 15, посвященной анимации. Единственный новый ингредиент — MediaTimeline, который управляет временной шкалой аудио- или видеофайла и работает совместно с MediaElement для координации воспроизведения. Класс MediaTimeline унаследован от Timeline и добавляет свойство Source, идентифицирующее аудиофайл, который предназначен для воспроизведения. Следующий код разметки демонстрирует простой пример. В нем используется действие BeginStoryboard для запуска воспроизведения звука, когда выполняется щелчок на кнопке. (Понятно, что с тем же успехом можно отреагировать и на другие события мыши и клавиатуры.) <Grid> <Grid.RowDefinitions> <RowDefinition Size="Auto"></RowDefinition> <RowDefinition Size="Auto"></RowDefinition> </Grid.RowDefinitions> <MediaElement x:Name="media"></MediaElement> <Button> <Button.Content>Click me to hear a sound.</Button.Content> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <MediaTimeline Source="soundA.wav" Storyboard.TargetName="media"></MediaTimeline> </Storyboard> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> </Button.Triggers> </Button> </Grid> Поскольку этот пример воспроизводит аудио, позиция MediaElement несущественна. В данном примере он размещается внутри Grid, перед Button. (Порядок не важен, поскольку во время выполнения программы MediaElement не будет иметь никакого визуального представления.) Когда осуществляется щелчок на кнопке, создается Storyboard с MediaTimeline. Обратите внимание, что источник в свойстве MediaElement.Source не указывается. Вместо этого источник передается через свойство MediaTimeline.Source.
Глава 26. Звук и видео 813 На заметку! Когда вы используете MediaElement в качестве цели для MediaTimeline, уже не имеет значения, установлены ли свойства LoadedBehavior и UnloadedBehavior. После применения MediaTimeline аудио или видео будет управляться таймером анимации WPF (конкретно — экземпляром класса MediaClock, который представлен в свойстве MediaElement. Clock). Для управления воспроизведением в MediaElement можно использовать единственный экземпляр Storyboard — другими словами, можно не только останавливать, но также временно приостанавливать и возобновлять воспроизведение. Например, рассмотрим крайне простой четырехкнопочный медиапроигрыватель, показанный на рис. 26.1. [ ■ т DeclarativePlayback j r-7 j R ftKJjl I | if 1[ )f ) Play Stop Pause Resume И ШШШВШшШШшШШшШШШШЯШ Рис. 26.1. Окно управления воспроизведением В этом окне используются элементы MediaElement, MediaTimeline и Storyboard. При этом Storyboard и MediaTimeline объявлены в коллекции Window.Resources: <Window.Resources> <Storyboard x:Key="MediaStoryboardResource"> <MediaTimeline Storyboard.TargetName="media" Source="test.mp3"> </MediaTimeline> </Storyboard> </Window.Resources> Единственная сложность состоит в том, что нужно не забыть определить все триггеры для управления раскадровкой в одной коллекции. Затем их можно присоединить к соответствующим элементам управления, используя свойство EventTrigger. SourceName. В данном примере все триггеры объявлены внутри панели StackPanel, содержащей кнопки. Вот эти триггеры и кнопки, использующие их для управления аудио: <StackPanel Orientation="Horizontal1^ <StackPanel.Triggers> <EventTrigger RoutedEvent="ButtonBase.Click" SourceName="cmdPlay"> <EventTrigger.Actions> <BeginStoryboard Name="MediaStoryboard" Storyboard="{StaticResource MediaStoryboardResource}"/> </EventTrigger.Actions> </EventTrigger> <EventTrigger RoutedEvent="ButtonBase.Click" SourceName="cmdStop"> <EventTrigger.Actions> <StopStoryboard BeginStoryboardName="MediaStoryboard"/> </EventTrigger.Actions> </EventTrigger> <EventTrigger RoutedEvent="ButtonBase.Click" SourceName="cmdPause"> <EventTrigger.Actions> <PauseStoryboard BeginStoryboardName="MediaStoryboard"/> </EventTrigger.Actions> </EventTrigger>
814 Глава 26. Звук и видео <EventTrigger RoutedEvent="ButtonBase.Click" SourceName="cmdResumell> <EventTrigger.Actions> <ResumeStoryboard BeginStoryboardName="MediaStoryboard"/> </EventTrigger.Actions> </EventTrigger> </StackPanel.Triggers> <MediaElement Name="media"></MediaElement> <Button Name="cmdPlay">Play</Button> <Button Name="cmdStop">Stop</Button> <Button Name="cmdPause">Pause</Button> <Button Name="cmdResume">Resume</Button> </StackPanel> Обратите внимание, что даже несмотря на то, что реализация MediaElement и MediaPlayer позволяют возобновить воспроизведение после паузы вызовом Play(), Storyboard работает иначе. Вместо этого требуется отдельное действие ResumeStoryboard. Если это не то, что нужно, можно добавить некоторый код к кнопке воспроизведения вместо применения декларативного подхода. На заметку! Загружаемый код для этой главы включает примеры декларативного окна медиапро- игрывателя и более гибкого окна медиапроигрывателя, управляемого кодом. Воспроизведение множества звуков Хотя в предыдущем примере демонстрировалось воспроизведение единственного медиафайла, нет никаких причин, которые помешали бы расширить его, добавив возможность одновременного воспроизведения нескольких аудиофайлов. В показанном ниже примере добавлены две кнопки, каждая из которых запускает воспроизведение собственного звука. Когда выполняется щелчок на кнопке, создается новый объект Storyboard, с новой MediaTimeline, которая используется для воспроизведения отдельного аудиофайла через один и тот же MediaElement. <Grid> <Grid.RowDefinitions> <RowDefinition Size="Auto"></RowDefinition> <RowDefinition Size="Auto"></RowDefinition> </Grid.RowDefinitions> <MediaElement x:Name="media"></MediaElement> <Button> <Button.Content>Click me to hear a sound.</Button.Content> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard> <MediaTimeline Source="soundA.wav" Storyboard.TargetName="media"></MediaTimeline> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> </Button.Triggers> </Button> <Button Grid.Row="l"> <Button.Content >Click me to hear a different sound.</Button.Content> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <EventTrigger.Actions>
Глава 26. Звук и видео 815 <BeginStoryboard> <Storyboard> <MediaTimeline Source="soundB.wav" Storyboard.TargetName="media"></MediaTimeline> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> </Button.Triggers> </Button> </Grid> В этом примере, если быстро щелкнуть на обеих кнопках подряд, обнаружится, что второй звук прервет воспроизведение первого. Это следствие применения одного и того же MediaElement для обеих временных шкал. Более гибкий (но и более ресурсоемкий) подход предусматривает использование отдельного MediaElement для каждой кнопки и установке каждой MediaTimeline на соответствующий MediaElement. (В этом случае можно задать свойство Source непосредственно в дескрипторе MediaElement, поскольку оно не изменяется.) Если теперь быстро щелкнуть подряд на двух кнопках, оба звука будут воспроизводиться одновременно. То же самое касается класса Media Player. Для воспроизведения нескольких аудиофайлов понадобится несколько объектов Media Player. Если решено использовать в коде MediaPlayer или MediaElement, то появляется шанс провести более разумную оптимизацию, которая, например, позволит воспроизводить одновременно только два звука, но не больше. Базовый прием заключается в определении двух объектов MediaPlayer с переключением между ними всякий раз, когда требуется запустить воспроизведение нового звука. (Отслеживать, какой объект использовался последний раз, можно с помощью переменной булевского типа.) Чтобы облегчить применение этого приема, можно поместить имена аудиофайлов в свойство Tag соответствующего элемента, так что коду обработки событий понадобится только найти нужный MediaPlayer, установить свойство Source и вызвать метод Р1ау(). Изменение громкости, баланса, скорости и позиции воспроизведения Для управления громкостью, балансом, скоростью и текущей позицией медиафай- ла MediaElement предлагает те же свойства, что и MediaPlayer (они были перечислены в табл. 26.1). На рис. 26.2 показано простое окно, расширяющее пример аудиопроигрывателя на рис. 26.1, с дополнительными элементами для управления этими параметрами. Ползунки громкости и баланса привязать проще всего. Поскольку Volume и Balance — свойства зависимости, их элементы управления можно подключить к MediaElement с помощью выражения двунаправленной привязки. Вот какая разметка понадобится: <Slider Grid.Row="l" Minimum=" Maximum="l" Value="{Binding ElementName=media, Path=Volume, Mode=TwoWay}"></Slider> <Slider Grid.Row=" Minimum="-1" Maximum="l" Value="{Binding ElementName=media, Path=Balance, Mode=TwoWay}"></Slider> Seek To: Рис. 26.2. Управление дополнительными параметрами воспроизведения
816 Глава 26. Звук и видео Хотя выражения двунаправленной привязки требуют некоторых дополнительных накладных расходов, они обеспечивают обратную связь: если свойства MediaElement будут изменены каким-то другим способом, эти ползунки останутся синхронизированными с текущими значениями свойств. Свойство SpeedRatio может быть подключено аналогичным образом: <Slider Grid.Row=" Minimum=" Maximum=" Value="{Binding ElementName=media, Path=SpeedRatio}"></Slider> Однако здесь есть несколько нюансов. Во-первых, SpeedRatio не задействовано в управляемом таймером аудио (где применяется MediaTimeline). Чтобы использовать его, придется установить свойство LoadedBehavior из SpeedRatio в Manual и принять управление воспроизведением на себя через соответствующие методы. Совет. В случае использования MediaTimeline от действия SetStoryboardSpeedRatio получается тот же эффект, что и от установки свойства MediaElement.SpeedRatio. Подробно обо всех этих деталях было рассказано в главе 15. Во-вторых, SpeedRatio не является свойством зависимости, и WPF не принимает уведомлений о его изменении. Это значит, что если свойство SpeedRatio модифицируется в коде, то ползунок соответствующим образом не обновляется. (Одним из обходных путей может быть изменение самого ползунка в коде вместо прямой модификации MediaElement.) На заметку! Изменение скорости воспроизведения аудио может исказить звук и вызвать появление эффектов вроде эха. И последняя деталь — текущая позиция, которая представлена свойством Position. Опять-таки, для установки свойства Position элемент MediaElement должен находиться в режиме Manual, что означает невозможность применения MediaTimeline. (При использовании TimeLine подумайте о применении действия BeginStoryboard вместе с Offset для установки требуемой позиции, как описано в главе 15.) Чтобы заставить это работать, не применяйте никаких привязок данных в ползунке: <Slider Minimum=" Name="sliderPosition" ValueChanged="sliderPosition_ValueChanged"></Slider> Для установки позиции ползунка при открытии медиафайла можно использовать код, подобный следующему: private void media_MediaOpened(object sender, RoutedEventArgs e) { sliderPosition.Maximum = media.NaturalDuration.TimeSpan.TotalSeconds; } Затем при перемещении ползунка можно перепрыгнуть в определенную позицию: private void sliderPosition_ValueChanged(object sender, RoutedEventArgs e) { // Приостановка воспроизведения перед переходом в другую позицию // исключит "заикания" при слишком быстрых движениях ползунка. media.Pause() ; media.Position = TimeSpan.FromSeconds(sliderPosition.Value); media.Play (); }
Глава 26. Звук и видео 817 Недостаток такого решения состоит в том, что ползунок не будет обновляться по мере воспроизведения. Если же это необходимо, придется прибегнуть к обходному маневру (вроде применения элемента DispatcherTimer, который будет выполнять периодическую проверку текущей позиции в процессе воспроизведения и соответственно обновлять положение ползунка). То же самое справедливо и при использовании MediaTimeline. По разным причинам привязаться напрямую к информации MediaElement.Clock невозможно. Вместо этого придется обрабатывать событие Storyboard.CurrentTimelnvalidated, как было показано в примере AnimationPlayer из главы 15. Синхронизация анимации с аудио В некоторых случаях может потребоваться синхронизировать другую анимацию с определенной точкой медиафайла (аудио или видео). Например, если есть длинный аудиофайл, инструктирующий о каком-то наборе шагов, может быть решено показывать разные изображения после каждой паузы. В зависимости от требований, проектное решение может получиться очень сложным и, возможно, для его упрощения и достижения лучшей производительности стоит сегментировать аудио в несколько файлов. Это позволит загружать новый аудиофрагмент и выполнять соответствующие действия одновременно, просто реагируя на соответствующее событие MediaEnded. В таких ситуациях понадобится синхронизировать нечто с продолжительным, непрерывным воспроизведением медиафайла. Один прием, позволяющий связать воспроизведение с другими действиями, состоит в применении анимации ключевого кадра (которая была представлена в главе 16). Эту анимацию можно поместить вместе с MediaTimeline в одну раскадровку. Подобным образом можно применить определенные смещения времени в анимации, которые будут соответствовать определенным моментам времени в аудиофайле. Фактически, есть возможность даже воспользоваться программой от независимых разработчиков, которая может аннотировать аудио и экспортировать список важных моментов времени. Затем эта информация применяется для установки времени каждого ключевого кадра. Используя анимацию ключевого кадра, важно установить свойство Storyboard. SlipBehavior в Slip. Это укажет, что анимация ключевого кадра не должна обгонять MediaTimeline, если происходит задержка воспроизведения. Это важно потому, что MediaTimeline может тормозить из-за буферизации (когда зависит от потока с сервера) или же, что бывает чаще, по причине задержки при загрузке. В следующем коде разметки демонстрируется базовый пример применения аудиофайла с двумя синхронизированными анимациями. Первая изменяет текст в метке по достижении определенного места в аудиофайле. Вторая показывает маленький кружок на полпути воспроизведения аудио, который пульсирует во времени за счет изменения свойства Opacity. <Window.Resources> <Storyboard x:Key="Board" SlipBehavior="Slip"> <MediaTimeline Source="sq3gml.mid" Storyboard.TargetName="media"/> <StringAnimationUsingKeyFrames Storyboard.TargetName="lblAnimated" Storyboard.TargetProperty="(Label.Content)" FillBehavior="HoldEnd"> <DiscreteStringKeyFrame Value="First note..." KeyTime=:0:3.4" /> <DiscreteStringKeyFrame Value="Introducing the main theme..." KeyTime=:0:5.8" /> <DiscreteStringKeyFrame Value="Imtating bass begins. . . " KeyTime=,,0:0:28.7" /> <DiscreteStringKeyFrame Value="Modulation'" KeyTime=:0:53.2" />
818 Глава 26. Звук и видео <DiscreteStringKeyFrame Value="Back to the original theme." KeyTime=:l:8" /> </StringAnimationUsingKeyFrames> <DoubleAnimationUsingKeyFrames Storyboard.TargetName="ellipse" Storyboard.TargetProperty="Opacity" BeginTime=:0:29.36" RepeatBehavior=0x"> <LinearDoubleKeyFrame Value="l" KeyTime=:0:0" /> <LinearDoubleKeyFrame Value=" KeyTime=:0:0.64" /> </DoubleAnimationUsingKeyFrames> </Storyboard> </Window.Resources> <Window.Triggers> <EventTrigger RoutedEvent="MediaElement.Loaded"> <EventTrigger.Actions> <BeginStoryboard Name="mediaStoryboard" Storyboard="{StaticResource Board}"> </Beginstoryboard> </EventTrigger.Actions> </EventTrigger> </Window.Triggers> ■ SynchronizedAnimatton Modutrior Position: 00:01:05.3510000 Чтобы сделать этот пример более интересным, в него также включен ползунок, позволяющий изменять позицию. Вы увидите, что даже при изменении позиции с использованием ползунка все три анимации выравниваются автоматически в соответствующую точку с помощью MediaTimeline. (Ползунок синхронизируется посредством события Storyboard.CurrentTimelnvalidated, а событие ValueChanged обрабатывается для поиска новой позиции после того, как пользователь передвинет ползунок с помощью мыши. Оба приема продемонстрированы в главе 15, в примере Animation Player.) На рис. 26.3 показана программа в действии. Рис. 26.3. Синхронизированные ВОСПрОИЗВвДвНИв ВИДеО Все, что было сказано о применении класса MediaElement, в равной степени касается и воспроизведения видеофайлов. Как и можно было ожидать, класс MediaElement поддерживает все видеоформаты, которые поддерживает проигрыватель Windows Media. Хотя поддержка и зависит от установленных кодеков, вполне можно рассчитывать на базовую поддержку форматов WMV, MPEG и AVI. Ключевое отличие при воспроизведении видеофайлов заключается в том, что здесь становятся важными визуальные свойства Media Player, а также свойства, касающиеся его компоновки. Важнее всего свойства Stretch и StretchDirection, определяющие, как масштабируется видео-окно для заполнения контейнера (эти свойства работают так же, как свойства Stretch и StretchDirection классов-наследников Shape). При установке значения Stretch можно использовать None для сохранения исходного размера, Uniform — чтобы растянуть его для заполнения контейнера без изменения пропорций, Fill — для растяжения по обоим измерениям до размеров контейнера (даже если это значит искажение пропорций) и UniformToFill — для растяжения изображения, чтобы оно уместилось в максимальное измерение контейнера, а пропорции сохранились (при этом, если пропорции видео-окна не будут совпадать с пропорциями контейнера, то часть видео-окна будет усечена).
Глава 26. Звук и видео 819 Совет. Предпочтительный размер MediaElement основан на исходных пропорциях видео. Например, если создать MediaElement со значением Stretch, равным Uniform (по умолчанию так и есть) и поместить его внутрь строки Grid со свойством Height, установленным в Auto, то строка будет подогнана по размеру так, чтобы вместить видео в его стандартном размере, без масштабирования. Видео-эффекты Поскольку MediaElements работает как любой другой элемент WPF, есть возможность манипулировать им несколько неожиданным образом. Ниже описаны примеры. • MediaElement можно использовать в качестве содержимого элемента управления, такого как кнопка. • Можно установить содержимое для тысяч элементов сразу с помощью множества объектов MediaElement, хотя это существенная вычислительная нагрузка на процессор. • Можно комбинировать видео с трансформациями через свойства LayoutTransf ormor или RenderTransform. Это позволит перемещать видео-окно, растягивать, наклонять или вращать его. Совет. Обычно для MediaElement трансформация RenderTransform предпочтительнее, чем LayoutTransformor, поскольку она более легковесная Кроме того, она принимает во внимание значение удобного свойства RenderTransformOr. ут, позволяя использовать относительные координаты для определенных трансформаций (т^их как вращение). • Можно устанавливать свойство Clipping объекта MediaElement так, чтобы обрезать видео-окно по определенной фигуре или пути и показывать только часть полного окна. • Можно устанавливать свойство Opacity, позволяя отображать другое содержимое сквозь видео-окно. Фактически, есть даже возможность сложить вместе в стопку несколько полупрозрачных видео-окон (с тяжелыми последствиями для производительности). • Можно использовать анимацию, чтобы динамически изменять свойство MediaElement (или одного из его трансформаций). • Можно копировать текущее содержимое видео-окна в другое место пользовательского интерфейса с использованием VisualBrush, что позволяет создавать специфические эффекты типа отражений. • Можно поместить видео-окно на трехмерную поверхность и использовать анимацию для перемещения его во время воспроизведения видео (как описано в главе 27). Например, следующий код разметки создает эффект отражения, показанный на рис. 26.4. Он делает это посредством создания объекта Grid, состоящего из двух строк. Верхняя строка содержит MediaElement, воспроизводящий видеофайл. Нижняя строка содержит Rectangle, который рисуется с помощью VisualBrush. Трюк в том, что VisualBrush принимает свое содержимое от видео-окна, расположенного над ним, используя выражение привязки. Видео-содержимое затем опрокидывается с использованием RelativeTransform, а затем плавно затухает сверху вниз с помощью градиента OpacityMask.
820 Глава 26. Звук и видео <Grid Margin=5" HorizontalAlignment="Center"> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDef mitionX/RowDef inition> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="Autо"></ColumnDefinition> </Grid.ColumnDefinitions> <Border BorderBrush="DarkGray" BorderThickness="l" CornerRadius="> <MediaElement x:Name="video" Source="test.mpg" LoadedBehavior="Manual" Stretch=,,Fill,,x/MediaElement> </Border> <Border Grid.Row="l" BorderBrush="DarkGray" BorderThickness="l" CornerRadius="> <Rectangle VerticalAlignment="Stretch" Stretch="Uniform"> <Rectangle.Fill> <VisualBrush Visual="{Binding ElementName=video}"> <VisualBrush.RelativeTransform> <ScaleTransform ScaleY=,,-l" CenterY=M0 . 5"></ScaleTransform> </VisualBrush.RelativeTransform> </VisualBrush> </Rectangle.Fill> <Rectangle.OpacityMask> <LinearGradientBrush StartPoint=, 0" EndPoint=,1"> <GradientStop Color=,,Black" Offset=,,0,,x/GradientStop> <GradientStop Color=,,Transparent" Of f set=M0 . 6"></GradientStop> </LinearGradientBrush> </Rectangle.OpacityMask> </Rectangle> </Border> </Grid> Рис. 26.4. Отраженное видео
Глава 26. Звук и видео 821 Этот пример работает очень хорошо. Эффект отражения влечет за собой те же накладные расходы, что и два видео-окна, поскольку каждый кадр должен копироваться в нижний прямоугольник. Вдобавок каждый кадр понадобится опрокинуть и добавить к нему затухание, чтобы создать эффект отражения. (Для выполнения таких трансформаций WPF использует промежуточную поверхность визуализации.) Но в современных компьютерах упомянутыми накладными расходами можно пренебречь. Это не так в случае других видеоэффектов. Фактически видео — одна из немногих областей WPF, где очень легко перегрузить процессор и получить неважно работающий пользовательский интерфейс. Средние компьютеры не могут обрабатывать более чем несколько видео-окон одновременно (конечно, в зависимости от размеров видеофайла — более высокое разрешение и большая частота кадров, естественно, означает больший объем данных, на обработку которых требуется больше времени). Класс VideoDrawing В WPF определен класс VideoDrawing, унаследованный от класса Drawing, о котором рассказывалось в главе 13. VideoDrawing может применяться для создания кисти DrawingBrush, которая, в свою очередь, служит для заполнения поверхности элемента, создавая тот же эффект, что был продемонстрирован выше в примере с VisualBrush. Однако есть одно отличие, которое может сделать подход с VideoDrawing более эффективным. Это связано с тем, что VideoDrawing использует класс Media Player, в то время как подход на основе VisualBrush требует применения класса MediaElement. Класс MediaPlayer не нуждается в управлении компоновкой, фокусом или другими деталями элемента, поэтому он более легковесный, чем MediaElement. В некоторых ситуациях использование VideoDrawing и DrawingBrush вместо MediaElement и VisualBrush помогает избежать необходимости в промежуточной поверхности визуализации и за счет этого повышает производительность (хотя проведенное тестирование не позволило заметить существенную разницу между этими двумя подходами). Применение VideoDrawing требует несколько больших усилий, поскольку MediaPlayer должен быть запущен в коде (вызовом метода Play О). Обычно все три объекта — MediaPlayer, VideoDrawing и DrawingBrush — будут создаваться в коде. Ниже приведен базовый пример, выводящий видео в фоне текущего окна. // Создать MediaPlayer. MediaPlayer player = new MediaPlayer(); player.Open(new Uri("test.mpg", UriKind.Relative)); // Создать VideoDrawing. VideoDrawing VideoDrawing = new VideoDrawing(); VideoDrawing.Rect = new RectA50, 0, 100, 100); VideoDrawing.Player = player; // Назначить DrawingBrush. DrawingBrush brush = new DrawingBrush(videoDrawing); this.Background = brush; // Запустить воспроизведение, player. Play (); Загружаемый код для этой главы содержит еще один пример, демонстрирующий видеоэффекты: анимация, вращающая видео-окно, в котором продолжается воспроизведение. Несмотря на необходимость стирать каждый видеокадр и перерисовывать новый под слегка измененным углом, такая анимация работает относительно неплохо на современных видеокартах, но вызывает заметное мерцание на картах послабее. Если
822 Глава 26. Звук и видео есть сомнения, то лучше ориентируйте пользовательский интерфейс на маломощный компьютер, чтобы посмотреть, справится ли он, а затем постепенно повышайте сложность эффектов либо отключайте их на слабых видеокартах. Речь Поддержка аудио и видео — ключевой "столп" платформы WPF. Тем не менее, WPF также включает библиотеки, вращающиеся вокруг двух менее широко используемых средств мультимедиа: синтеза и распознавания речи. Оба эти средства поддерживаются классами из сборки System.Speech.dll. По умолчанию Visual Studio не добавляет ссылок на эту сборку в новые проекты WPF, так что в своих проектах это понадобится делать самостоятельно. На заметку! Речь — это второстепенная часть WPF Хотя поддержка речи формально считается частью WPF, и она появилась, начиная с версии .NET Framework 3.0, пространства имен классов для поддержки речи начинаются с System.Speech, а не System.Windows. Синтез речи Синтез речи — это средство, генерирующее аудиосигнал разговора на основе предоставленного текста. Синтез речи не является встроенным средством в WPF — это средство доступности операционной системы Windows. Такие системные утилиты, как Narrator (облегченное средство чтения экрана), включены в Windows и используют синтез речи для помощи пользователям в навигации по основным диалоговым окнам. В общем случае, синтез речи может применяться для создания аудиоруководств и "говорящих" инструкций, хотя заранее записанное аудио обеспечивает более высокое качество. На заметку! Синтез речи имеет смысл, когда требуется создать аудио для динамического текста — другими словами, когда на момент компиляции неизвестно, какие слова должны быть произнесены во время выполнения. Но если аудио неизменно, то предварительно записанное легче использовать, это более эффективно, к тому же качество звука выше. Единственная причина отдать предпочтение синтезу речи — это когда нужно прочитать огромный объем текста, и предварительная его запись непрактична. Хотя все современные версии Windows имеют встроенный синтез речи, используемый ими компьютеризованный голос отличается. В Windows XP используется голос, похожий на голос робота и называемый Sam, в то время как в Windows Vista и Windows 7 включает более натуральный женский голос по имени Anna. Для всех этих систем доступны для загрузки и установки также дополнительные голоса. Воспроизведение речи обманчиво просто. Все, что понадобится — это создать экземпляр класса SpeechSynthesizer из пространства имен System.Speech.Synthesis и вызвать его метод Speak() со строкой текста, например: SpeechSynthesizer synthesizer = new SpeechSynthesizer() ; synthesizer.Speak ("Hello, world"); При использовании такого подхода — передаче простого текста объекту SpeechSynthesizer — в некоторой степени утрачивается контроль. Могут встречаться слова, которые произносятся неправильно, с неправильными ударениями или не с должной скоростью. Чтобы получить больший контроль над произносимым текстом, необходимо использовать класс PromptBuilder для конструирования определения речи. Вот как можно изменить предыдущий пример с полностью эквивалентным кодом, в котором применяется PromptBuilder:
Глава 26. Звук и видео 823 PromptBuilder prompt = new PromptBuilder(); prompt.AppendText("Hello, world"); SpeechSynthesizer synthesizer = new SpeechSynthesizer (); synthesizer.Speak(prompt); Этот код не дает никаких преимуществ. Однако класс PromptBuilder содержит в себе множество других методов, которые можно применять для настройки произношения текста. Например, можно акцентировать определенное слово (или несколько слов), используя перегруженную версию метода AppendTextO, которая принимает значение из перечисления PromptEmphasis. Хотя точный эффект от акцентирования слова зависит от используемого голоса, следующий код подчеркивает слово "are" в предложении "How are you?": PromptBuilder prompt = new PromptBuilder (); prompt.AppendText("How "); prompt.AppendText("are ", PromptEmphasis.Strong); prompt.AppendText("you") ; Метод AppendTextO имеет еще две перегруз! i: одна принимает значение PromptRate, которое позволяет увеличивать или уменьшать скорость, а другая — значение PromptVolume, позволяющее увеличивать или уменьшать громкость. Для изменения сразу нескольких деталей необходимо использовать объект PromptStyle. Этот объект является оболочкой для значений PromptEmphasis, PromptRate и PromptVolume. Можно задавать значения для всех трех параметров либо только для одного или двух, которые нужны. Чтобы использовать объект PromptStyle, понадобится вызвать PromptBuilder. BeginStyle(). Созданный объект PromptStyle затем применяется ко всему произносимому тексту, до тех пор, пока не будет вызван EndStyle(). Ниже приведен измененный пример, в котором используется акцентирование и изменение скорости для ударения на слове "are". PromptBuilder prompt = new PromptBuilder (); prompt.AppendText("How "); PromptStyle style = new PromptStyle (); style.Pate = PromptRate.ExtraSlow; style.Emphasis = PromptEmphasis.Strong; prompt.StartStyle(style) ; prompt.AppendText("are "); prompt.EndStyle() ; prompt.AppendText("you") ; На заметку! В случае вызова BeginStyleO позднее в коде должен быть вызван EndStyle(). Если забыть сделать это, возникнет ошибка времени выполнения. Перечисления PromptEmphasis, PromptRate и PromptVolume предлагают относительно грубый способ изменения гслоса. Пока нет возможности более тонкого контроля или внесения нюансов и специфически: оттенков живой речи в синтезируемый текст. Однако PromptBuilder включает метод AppendTextWithHindO, который позволяет иметь дело с телефонными номерами, датами, временем и словами, которые написаны прописью. Выбор указывается с использованием перечисления SayAs. Вот пример: prompt.AppendText("The word laser is spelt "); prompt.AppendTextWithHint("laser", SayAs.SpellOut); Это синтезирует предложение 'The word laser is spelt 1-a-s-e-r". Наряду с методами AppendTextO и AppendTextWithHint() класс PromptBuilder также включает небольшую коллекцию дополнительных методов для добавления в
824 Глава 26. Звук и видео поток обычного аудио (AppendAudioO), пауз заданной длительности (AppendBreakO), переключения голосов (StartVoice () и EndVoiceO), а также произношения текста в соответствии с определенным заданным фонетическим произношением (AppendTextWithPronounciationO). В действительности Prompt Builder — это оболочка для стандарта Synthesis Markup Language (SSML), описанного по адресу http://www.w3.org/TR/speech-synthesis. Будучи таковым, он разделяет все ограничения этого стандарта. Когда вызываются методы PromptBuilder, то "за кулисами" генерируется соответствующий код разметки SSML. Для просмотра финального SSML-представления кода можно вызвать PromptBuilder.ToXmlO в конце работы. Кроме того, можно также вызвать PromptBuilder.AppendSsml(), взяв существующий код разметки SSML и добавив его в поток чтения текста. Распознавание текста Распознавание текста — это средство трансляции в текст произносимой пользователем речи. Как и синтез речи, распознавание речи — средство операционной системы Windows. Распознавание текста встроено в Windows Vista и Windows 7, но не в Windows ХР. Пользователям Windows XP оно доступно в составе Office XP и более поздних версий, в пакете Windows XP Plus! Pack либо в бесплатном комплекте Microsoft Speech Software Development Kit. На заметку! Если в данный момент распознавание текста не запущено, панель инструментов распознавания речи появится, когда будет создан экземпляр класса SpeechRecognizer. Если при попытке создать экземпляр класса SpeechRecognizer не будет сконфигурировано распознавание речи по вашему голосу, то Windows автоматически запустит мастер, который проведет по необходимым шагам этого процесса. Распознавание речи — это также средство облегчения работы с Windows для людей с ограниченными физическими возможностями. Благодаря ему, такие люди могут голосом взаимодействовать с обычными элементами управления. Распознавание речи также позволяет использовать компьютер, не занимая руки, что очень удобно в некоторых ситуациях. Наиболее простой способ использовать распознавание речи — это создать экземпляр класса SpeechRecognizer из пространства имен System.Speech.Recognition. Затем можно присоединить обработчик к событию Speech Re cognized, которое инициируется всякий раз, когда произнесенное слово успешно преобразуется в текст: SpeechRecognizer recognizer = new SpeechRecognizer(); recognizer.SpeechRecognized += recognizer_SpeechReconized; В обработчике событий можно извлечь текст из свойства SpeechRecognized ЕventArgs.Result: private void recognizer_SpeechReconized(object sender, SpeechRecognizedEventArgs e) { MessageBox.Show ("You said:" + e.Result.Text); } Объект SpeechRecognizer служит оболочкой для СОМ-объекта. Чтобы избежать неприятных сюрпризов, он должен быть объявлен как переменная-член в классе окна (чтобы объект существовал до тех пор, пока существует окно), а при закрытии окна должен вызываться его метод Dispose () (чтобы освободить ресурсы, занятые распознавателем речи).
Глава 26. Звук и видео 825 На заметку! Класс SpeechRecognizer в действительности генерирует последовательность событий при обнаружении аудиосигнала. В начале инициируется SpeechDetected, если звук идентифицируется как речь. Затем один или более раз инициируется SpeechHypothesized, когда слова распознаются на основе опыта. И, наконец, SpeechRecognizer инициирует событие SpeechRecognized, если ему удается успешно обработать текст, либо SpeechRecognitionRejected — если нет. Событие SpeechRecognitionRejected включает информацию о предположении SpeechRecognizer относительно того, что может означать произнесенное слово, когда степень уверенности недостаточно высока, чтобы принять ввод. Обычно использовать распознавание речи в такой манере не рекомендуется. Это связано с тем, что WPF имеет собственное средство автоматизации пользовательского интерфейса (UI Automation), которое работает совместно с механизмом распознавания речи. При правильной конфигурации оно позволяет пользователям вводить текст в текстовых элементах управления и инициировать щелчки на кнопках при произнесении их автоматизированных имен. С применением SpeechRecognition можно добавить поддержку более специализированных команд в специфических сценариях. Это делается путем определения грамматики, основанной на спецификации Speech Recognition Grammar Specification (SRGS). Грамматика SRGS идентифицирует допустимые команды для приложения. Например, можно указать, что для команд используется только одно слово из небольшого набора (on или off], а эти слова разрешены только в определенных комбинациях (blue on, red on, blue off и т.д.). Грамматика SRGS может быть сконструирована двумя способами. Можно загрузить ее из документа SRGS, который описывает правила грамматики с применением синтаксиса на основе XML. Чтобы сделать это, нужно воспользоваться классом SrgsDocument из пространства имен System.Speech.Recognition.SrgsGrammar: SrgsDocument doc = new SrgsDocument("app_grammar.xml"); Grammar grammar = new Grammar(doc); recognizer.LoadGrammar(grammar); В качестве альтернативы грамматику можно сконструировать декларативно с применением GrammarBuilder. Класс GrammarBuilder играет роль, аналогичную рассмотренному в предыдущем разделе PromptBuilder — позволяет добавлять правила грамматики одно за другим, создавая постепенно полное описание. Например, ниже приведена декларативно сконструированная грамматика, принимающая ввод из двух слов, где первое слово имеет пять возможных вариантов, а второе — два: GrammarBuilder grammar = new GrammarBuilder (); grammar.Append(new Choices("red", "blue", "green", "black", "white")); grammar.Append(new Choices("on", "off")); recognizer.LoadGrammar(new Grammar(grammar)); Этот код разметки принимает команды вроде red on и green off. Альтернативный ввод, подобные yellow on или on red, не распознается. Объект Choices представляет SRGS-правило одно из, позволяющее пользователю говорить одно слово из диапазона допустимых. Это наиболее универсальный ингредиент, используемый при построении грамматики. Еще несколько дополнительных перегрузок метода GrammarBuilder.Append() принимают различный ввод. Можно передать обычную строку — в этом случае грамматика требует от пользователя произнесения именно этого слова. Можно передать строку, за которой следует значение из перечисления SubsetMatchingMode, требующее от пользователя произнесения определенной части слова или фразы. И, наконец, можно передать строку, за которой следует минимальное
826 Глава 26. Звук и видео и максимальное количество повторений. Это позволяет грамматике игнорировать одно и то же слово, когда оно повторяется несколько раз, а также позволяет сделать слово необязательным (задав минимальное число повторов 0). Грамматика, использующая все эти свойства, может стать достаточно сложной. Подробнее о стандарте SRGS и правилах грамматики читайте по адресу: http://www.w3.org/TR/speech-grammar Резюме В этой главе было показано, как интегрировать звук и видео в WPF-приложения. Рассматривались два разных способа управления воспроизведением медиафайлов — программно, с применением методов классов MediaPlayer или MediaTimeline, либо декларативно, используя раскадровку. Как всегда, наилучший подход зависит от существующих требований. Подход на основе кода предоставляет более высокую степень контроля и гибкости, но также заставляет управлять большим числом деталей и вносит дополнительную сложность. Общее правило можно сформулировать так: подход на основе кода лучше, когда нужен тонкий контроль над воспроизведением, однако если требуется комбинировать воспроизведение медиа с анимацией, то декларативный подход окажется намного проще.
ГЛАВА 27 Трехмерная графика Уже много лет разработчики используют DirectX и OpenGL для построения трехмерных интерфейсов. Однако сложная программная модель и серьезные требования к видеокартам были причиной того, что программирование трехмерной графики оставалось в стороне от основного потока заказных приложений и программного обеспечения для бизнеса. В WPF предлагается новая расширенная трехмерная модель, которая обещает в корне изменить ситуацию. Используя WPF, можно строить сложные трехмерные сцены на основе понятного кода разметки. Вспомогательные классы предоставляют средства проверки попадания, операции вращения с помощью мыши, а также другие фундаментальные строительные блоки. И практически любой современный компьютер может отображать трехмерное содержимое, благодаря способности WPF переходить к программной визуализации, когда поддержка со стороны видеокарты оказывается недостаточной. Наиболее заслуживающей упоминания характеристикой библиотек WPF для трехмерного программирования является то, что они спроектированы как ясное, согласованное расширение модели WPF, с основами которой вы уже ознакомились. Например, для рисования трехмерных поверхностей используется тот же самый набор классов кистей, чт,о и для рисования двухмерных фигур. Для вращения, деформации и перемещения трехмерных объектов применяется похожая модель, как и при выполнении тех же операций с двухмерным содержимым. Здесь предоставлена такая поддержка высокоуровневых средств WPF, которая делает трехмерную графику подходящей для любых целей — от привлекательных визуальных эффектов в простых играх до графической визуализации данных в бизнес-приложениях. (Есть только одна ситуация, в которой трехмерной модели WPF недостаточно — это сложные игры реального времени. В таком случае лучше обратиться к мощи DirectX.) Несмотря на то что модель трехмерной графики WPF неожиданно ясна и согласована, создание развитых трехмерных интерфейсов остается достаточно сложной задачей. Для того чтобы вручную закодировать трехмерную анимацию (или даже просто понять положенные в ее основу концепции), понадобится нечто большее, чем немного математики. А моделирование чего-либо, кроме самых тривиальных трехмерных сцен на основе вручную написанной XAML-разметки — это громадная, чреватая ошибками работа, намного более сложная, чем двухмерный эквивалент ручного создания векторного изображения XAML. По этой причине, скорее всего, стоит обратиться к инструментам от независимых разработчиков для создания трехмерных объектов, экспортировать их в XAML, а затем добавлять к своим приложениям. На эти темы написаны целые книги: математика трехмерного программирования, инструментальные средства и библиотеки трехмерной графики для WPF В настоящей главе вы узнаете достаточно для понимания модели WPF трехмерного рисования, создадите базовые трехмерные фигуры, спроектируете более сложные трехмерные сцены с
828 Глава 27. Трехмерная графика использованием инструмента трехмерного моделирования и воспользуетесь некоторым ценным кодом, который предоставлен командой создателей WPF, а также независимыми разработчиками. Основы трехмерной графики Трехмерная графика в WPF включает в себя следующие ингредиенты: • окно просмотра (viewport), содержащее трехмерное содержимое; • трехмерный объект; • источник света, освещающий часть трехмерной сцены; • камера, представляющая точку для наблюдения трехмерной сцены. Разумеется, наиболее сложные трехмерные сцены содержат множество объектов и могут включать множество источников света. (Можно также создать трехмерный объект, не требующий источника света, если он сам излучает свет.) Однако перечисленные ингредиенты представляют собой хорошую начальную точку. Трехмерную графику от двухмерной отличают, прежде всего, второй и третий компоненты. Новички в трехмерном программировании иногда предполагают, что трехмерные библиотеки — это лишь упрощенный способ создания трехмерного внешнего вида, такого как светящийся куб или вращающаяся сфера. Но если это все, что нужно, то для такого трехмерного рисования вполне можно ограничиться классами двухмерной графики, о которых рассказывалось ранее. В конце концов, нет причин, которые помешали бы использовать фигуры, трансформации и геометрии, рассмотренные в главах 12 и 13, чтобы сконструировать формы, выглядящие объемными. Фактически, это даже проще, чем то же самое, но с применением трехмерных библиотек. Так в чем же преимущество использования поддержки трехмерной графики в WPF? Первое преимущество состоит в том, что можно создавать эффекты, которые чрезвычайно сложно вычислить на основе эмулируемой трехмерной модели. Хорошим примером могут быть такие эффекты, как отражение, которое становится чрезвычайно сложным, когда приходится иметь дело с множеством источников света и разными материалами с различной отражающей способностью. Другое преимущество использования трехмерной графической модели заключается в том, что она позволяет взаимодействовать с изображением как с набором трехмерных объектов. Это значительно расширяет возможности программирования. Например, однажды построив требуемую трехмерную сцену, вы получаете простую возможность вращения объекта или вращения камеры вокруг объекта. Для выполнения той же работы на основе двухмерной программной модели потребуется чудовищный объем кода (и знаний математики). Давайте попробуем построить пример, включающий все перечисленные выше составляющие. В последующих разделах он будет постепенно изменяться. Окно просмотра Чтобы работать с трехмерным содержимым, понадобится контейнер, который может его в себе разместить. Этот контейнер представлен классом Viewport3D, находящимся в пространстве имен System.Windows.Controls. Класс Viewport3D унаследован от FrameworkElement и потому может быть размещен везде, где размещается любой обычный элемент. Например, его можно использовать в качестве содержимого окна либо страницы или же поместить внутрь более сложной компоновки. Класс Viewport3D лишь намекает на сложность трехмерного программирования. Он добавляет только два свойства: Camera, определяющее точку зрения на трехмерную сцену, и Children, содержащее все трехмерные объекты, которые должны помещаться на сцене. Достаточно интересно то, что источник света, освещающий сцену, сам является объектом в окне просмотра.
Глава 27. Трехмерная графика 829 На заметку! Среди унаследованных свойств класса Viewport3D одно является особенно важным — ClipToBounds. Если оно установлено в true (по умолчанию так и есть), то содержимое, выходящее за пределы окна просмотра, усекается. Если же упомянутое свойство равно false, это содержимое появляется поверх любых соседних элементов. Это поведение аналогично поведению свойства ClipToBounds класса Canvas. Однако при использовании Viewport3D есть одно существенное отличие: производительность. Установив Videport3D. ClipToBounds в false, можно в значительной мере увеличить производительность при визуализации сложной, часто обновляемой трехмерной сцены. Трехмерные объекты Окно просмотра может содержать в себе любой трехмерный объект, унаследованный от Visual3D (из пространства имен System.Windows.Media.Media3D, в котором находится подавляющее большинство трехмерных классов). Однако чтобы создать трехмерное представление, придется выполнить немного больше работы. В версии WPF 1.0 отсутствовала коллекция трехмерных фигур-примитивов. Когда был нужен куб, цилиндр или тор, приходилось строить их самостоятельно. Одним из наиболее удачных проектных решений команды разработчиков WPF, принятых при создании классов рисования трехмерной графики, была структуризация их способом, подобным структуризации классов рисования плоской графики. Это означает, что разобраться в назначении многих классов, образующих основу трехмерной графики (даже не зная еще, как их следует использовать), достаточно легко. В табл. 27.1 представлены соответствующие аналогии. Таблица 27.1. Сравнение классов 2-D и 3-D Класс 2-D Класс 3-D Примечания Visual Visual3D Geometry Geometry3D visual3D — базовый класс для всех трехмерных объектов (объектов, отображаемых внутри контейнера viewport3D). Подобно классу Visual, можно использовать класс visual3D для наследования от него облегченных трехмерных фигур или создания сложных трехмерных элементов управления, предоставляющих развитый набор событий и служб платформы. Однако не рассчитывайте на значительную помощь. Более вероятно, что вы воспользуетесь одним из классов-наследников Visual3D, таким как ModelVisual3D или ModelUIElement3D Класс Geometry — это абстрактный способ определения двухмерной фигуры. Геометрия часто используется для определения сложных фигур, состоящих из дуг, отрезков прямых и многоугольников. Класс Geometry3D — его трехмерный аналог, он представляет трехмерную поверхность. Однако в то время как существует несколько геометрий 2-D, WPF включает только один конкретный класс, унаследованный от Geometry3D — MeshGeometry3D. Класс MeshGeometry3D имеет важнейшее значение в трехмерном рисовании, поскольку он применяется для определения всех трехмерных объектов
830 Глава 27. Трехмерная графика Окончание табл. 27.1 Класс 2-D Класс 3-D Примечания GeometryDrawing GeometryModel3D Transform Transform3D Существует'несколько способов использования двухмерного объекта Geometry. Его можно упаковать в GeometryDrawing и применить для рисования поверхности элемента или содержимого Visual. Класс GeometryModel3D имеет то же назначение — он принимает Geometry3D, который затем может использоваться для заполнения Visual3D Уже известно, что трансформации 2-D — чрезвычайно удобное средство манипулирования элементами и фигурами всех видов и всеми способами, включая перемещение, деформацию и вращение. Трансформации также незаменимы для выполнения анимации. Классы, унаследованные от Transf orm3D, выполняют то же самое с трехмерными объектами. Фактически обнаружится неожиданно много похожих классов, таких как RotateTransform3D, ScaleTransform3D, TranslateTransform3D, Transform3DGroup HMatrixTransform3D. Конечно, возможности, предлагаемые дополнительным измерением, существенны, и трехмерные трансформации обеспечивают деформации и преобразования, которые выглядят совершенно иначе Поначалу может показаться, что распутать отношения между этими классами довольно трудно. По сути, Viewport3D содержит объекты Visual3D. Чтобы действительно предоставить Viewport3D некоторое содержимое, необходимо определить объект Geometry3D, описывающий фигуру, и упаковать его в GeometryModel3D. Затем можно использовать его как содержимое Visual3D. На рис. 27.1 демонстрируется это отношение. Этот двухшаговый процесс — определение фигур, которые должны использоваться, в абстрактном виде, а затем включение их в визуальное представление — является необязательным при двухмерном рисовании. Однако он обязателен для рисования трехмерной графики, поскольку в библиотеке отсутствуют предварительно построенные классы 3-D. (Члены команды создателей WPF, а также некоторые другие разработчики разместили в Интернете примеры кода, призванные заполнить данный пробел, но все это еще находится в процессе развития.) Двухшаговый процесс также важен потому, что трехмерные модели сложнее двухмерных. Например, когда создается объект Geometry3D, то не только указываются вершины фигуры, но также и материал, из которого она состоит. Разные материалы обладают различными свойствами в отношении отражения и поглощения света. ModelVisual3D (унаследован OTVisual3D) Содержимое Л GeometryModel3D Геометрия MeshGeometry (унаследован от Geometry3D) Рис. 27.1. Определение трехмерного объекта
Глава 27. Трехмерная графика 831 Геометрия Чтобы построить трехмерный объект, надо начать с построения геометрии. Как уже известно, для этой цели существует только один класс — MeshGeometry3D. Неудивительно, что объект MeshGeometry3D представляет сетку (mesh). Если раньше вы имели дело с трехмерным рисованием (или читали что-нибудь о технологиях, положенных в основу современных видеокарт), то должны знать, что трехмерные объекты строятся из треугольников. Это объясняется тем, что треугольники обеспечивают простейший и наиболее гранулярный способ описания поверхности. Треугольники просты, потому что каждый из них определяется всего тремя точками (вершинами в углах). Дуги и кривые поверхности определенно более сложны. Треугольники гранулярны, потому что все прочие фигуры с прямыми гранями (квадраты, прямоугольники и более сложные многоугольники) могут быть разбиты на коллекции треугольников. Хорошо это или плохо, но современное графическое оборудование и программирование графики строится на основе упомянутой базовой абстракции. Очевидно, что большинство трехмерных объектов не будут выглядеть как простые плоские треугольники. Вместо этого для их построения придется комбинировать треугольники, иногда всего несколько, но чаще — сотни и тысячи, соединенные друг с другом под разными углами. Такая комбинация треугольников образует пространственную сетку. При достаточном количестве треугольников можно, в конечном счете, создать иллюзию чего угодно, включая наиболее сложные поверхности. (Разумеется, следует учитывать фактор производительности, к тому же трехмерные сцены часто используют растровые изображения или двухмерную графику в треугольниках объемной сетки, создавая иллюзию сложной поверхности с минимальными накладными расходами. WPF поддерживает эту технику.) Представление о том, как определяется сетка, является одним из ключевых моментов трехмерного программирования. Класс MeshGeometry3D включает четыре свойства, описанные в табл. 27.2. Таблица 27.2. Свойства класса MeshGeometry3D Имя Описание Positions Содержит коллекцию всех точек, определяющих сетку. Каждая точка — это вершина треугольника. Например, если сетка состоит из 10 полностью различных треугольников, в этой коллекции будет содержаться 30 точек. Очень часто некоторые треугольники имеют общие грани, а это означает, что одна вершина может быть вершиной нескольких треугольников. Например, для описания куба требуется 12 треугольников (по два на каждую грань), но только 8 отличающихся точек. Общие вершины могут быть определены несколько раз, что еще более усложнит систему, поэтому лучше управлять текстурированием отдельных треугольников с помощью свойства Normals Trianglelndices Определяет треугольники. Каждый элемент этой коллекции представляет отдельный треугольник, ссылаясь натри точки из коллекции Positions Normals Представляет вектор для каждой вершины (каждой точки из коллекции Positions). Этот вектор указывает, как точка повернута для вычисления освещенности. Когда среда WPF текстурирует поверхность треугольника, она измеряет освещенность каждой из трех вершин, чтобы заполнить поверхность треугольника. Получение правильных нормальных векторов определяет разницу в текстурировании трехмерных объектов. Например, это позволяет выполнить плавный переход между соседними треугольниками или выделить их границу в виде четкой линии TextureCoordinates Определяет, как двухмерная текстура отображается на трехмерный объект, когда для его прорисовки используется visualBrush. Коллекция TextureCoordinates представляет двухмерную точку для каждой трехмерной точки из коллекции Positions
832 Глава 27. Трехмерная графика Текстурирование с нормалями и отображение текстур рассматриваются далее в этой главе. Сначала будет показано, как строить базовую сетку. Следующий пример демонстрирует простейшую сетку, состоящую из единственного треугольника. Используемые единицы измерения не имеют значения, поскольку можно перемещать камеру ближе или дальше, а также изменять размер или расположение индивидуальных трехмерных объектов, применяя трансформации. Что действительно важно здесь — так это координатная система, показанная на рис. 27.2. Как видите, оси X и Y имеют ту же ориентацию, что и при отображении двухмерной графики. Новой является ось Z. По мере увеличения значения Z точка отдаляется, по мере уменьшения — приближается. Рис. 27.2. Треугольник в трехмерном пространстве Ниже показан элемент MeshGeometry3D, который можно использовать для определения этой фигуры внутри трехмерной области. Объект MeshGeometry3D в этом примере не использует свойства Normals или TextureCoordinates, поскольку фигура очень проста и будет нарисована кистью SolidColorBrush: <MeshGeometry3D Positions="-l,0,0 0,1.0 1,0,0" Trianglelndices=,2,1" /> Очевидно, что здесь присутствуют только три точки, перечисленные одна за другой в свойстве Positions. Порядок их следования в коллекции Positions не имеет значения, поскольку свойство Trianglelndices ясно определяет треугольник. По сути, свойство Trianglelndices устанавливает только один треугольник, состоящий из точек с номерами О, 2 и 1. Другими словами, свойство Trianglelndices сообщает WPF, что нужно нарисовать треугольник, проведя линии от (-1,0,0) до A,0,0) и затем до @,1,0). Обратите внимание, что трехмерное программирование подчиняется нескольким тонким правилам, которые легко нарушить. При определении фигуры вы сталкиваетесь с первым из них, а именно: перечислять точки, составляющие фигуру, следует в направлении против часовой стрелки относительно оси Z. Данный пример следует этому правилу. Однако его легко нарушить, изменив Trianglelndices наО, 1, 2. В таком случае все равно определяется тот же самый треугольник, но вывернутый наизнанку. Другими словами, если посмотреть вниз по оси Z (как на рис. 27.2), то на самом деле будет видна изнанка треугольника.
Глава 27. Трехмерная графика 833 На заметку! Разница между передней частью трехмерной фигуры и ее изнанкой не так тривиальна, как может показаться. В некоторых случаях они могут рисоваться разными кистями. Или же изнанка вообще может не рисоваться, чтобы избежать расхода ресурсов на невидимую часть сцены. Если нечаянно указать точки фигуры в порядке движения часовой стрелки и не определить материал для изнанки фигуры, она просто исчезнет из трехмерной сцены. Геометрическая модель и поверхности После определения соответствующим образом геометрии MeshGeometry3Dee нужно поместить в оболочку GeometryModel3D. Класс GeometryModel3D имеет только три свойства: Geometry, Material и BackMaterial. Свойство Geometry принимает объект MeshGeometry3D, определяющий фигуру трехмерного объекта. В дополнение можно применять свойства Material и BackMaterial для определения поверхностей, из которых состоит фигура. Поверхность важна по двум причинам. Во-первых, она определяет цвет объекта (хотя можно использовать и более сложные кисти, рисующие текстуры вместо сплошного цвета). Во-вторых, она определяет то, как материал реагирует на свет. В WPF определены четыре класса материалов, каждый из которых унаследован от абстрактного класса Material из пространства имен System. Windows. Media. Media3D. Эти материалы перечислены в табл. 27.3. В данном примере используется DiffuseMaterial, который применяется наиболее часто, поскольку его поведение ближе всего к реальным поверхностям. Таблица 27.3. Классы материалов Имя Описание DiffuseMaterial Создает плоскую матовую поверхность, распределяющую свет равномерно во всех направлениях SpecularMaterial Создает блестящую обесцвеченную поверхность (типа металла или стекла). Отражает свет в противоположном направлении подобно зеркалу EmissiveMaterial Создает раскаленную поверхность, которая сама излучает свет (хотя этот свет не отражают другие объекты сцены) MaterialGroup Позволяет комбинировать более одного материала. Комбинируемые материалы накладываются друг на друга в том порядке, в каком добавлялись к MaterialGroup DiffuseMaterial предоставляет единственное свойство Brush, принимающее объект Brush, который нужно использовать для рисования поверхности трехмерного объекта. (В случае применения кисти, отличной OTSolidColorBrush, придется установить свойство MeshGeometry3D.TextureCoordinates для определения способа ее отображения на объект, как будет показано далее в этой главе.) Ниже показано, как сконфигурировать треугольник, чтобы он имел желтую матовую поверхность: <GeometryModel3D> <GeometryModel3D.Geometry> <MeshGeometry3D Positions="-l,0,0 0,1,0 1,0,0" Trianglelndices = ,2, 1" /> </GeometryModel3D.Geometry> <GeometryModel3D.Material> <DiffuseMaterial Brush="Yellow" /> </GeometryModel3D.Material> </GeometryModel3D>
834 Глава 27. Трехмерная графика В этом примере свойство BackMaterialHe установлено, так что с изнанки треугольник просто не будет виден. Все, что остается — это применить GeometryModel3D для установки свойства Content объекта ModelVisual3D и затем поместить его в окно просмотра. Но для того, чтобы увидеть объект, понадобятся еще две вещи: источник света и камера. Источники света Чтобы создать реалистически текстурированные трехмерные объекты, WPF использует модель освещения. Основная идея состоит в том, что к трехмерной сцене добавляется один (или несколько) источников света. Характер освещения объектов зависит от выбранного типа источника света, его положения, направления и интенсивности. Прежде чем погрузиться в изучение освещения WPF, важно понять, что модель освещения WPF ведет себя не так, как свет в реальном мире. Хотя система освещения WPF и предназначена для того, чтобы эмулировать реальный мир, вычисление правильного отражения — задача, требующая серьезных вычислительных ресурсов. WPF допускает ряд упрощений, гарантирующих практичность модели освещения, даже в случае ани- мированных трехмерных сцен с несколькими источниками света. К таким упрощениям относятся перечисленные ниже. • Эффекты освещения вычисляются для объектов индивидуально. Свет, отраженный от одного объекта, не отражается в другом объекте. Аналогично, объект не отбрасывает тени на другой объект, независимо от его местоположения. • Освещенность вычисляется для вершин каждого треугольника, а затем интерполируется по его поверхности. (Другими словами, WPF определяет интенсивность света в каждом углу, а затем сглаживает его для заполнения всего треугольника.) В результате объекты, состоящие из относительно небольшого количества треугольников, могут быть освещены неправильно. Чтобы обеспечить лучшее освещение, следует делить фигуры на сотни или даже тысячи треугольников. В зависимости от эффекта, которого вы пытаетесь достичь, может понадобиться каким-то образом обойти эти ограничения, комбинируя несколько источников света, используя разные материалы и даже добавляя дополнительные фигуры. Фактически получение наилучшего результата — это часть искусства проектирования трехмерных сцен. На заметку! Даже если не указать источник света, объект будет видимым. Однако без источника света все, что вы увидите — это сплошной черный силуэт. В WPF предлагаются четыре класса источников света, каждый из которых унаследован от абстрактного класса Light (табл. 27.4). В рассматриваемом примере мы ограничимся только одним DirectionalLight, который представляет наиболее распространенный тип освещения. Таблица 27.4. Классы источников света Имя Описание DirectionalLight Заполняет сцену параллельными лучами света, идущими в указанном направлении AmbientLight Наполняет сцену рассеянным светом PointLight Свет распространяется во все стороны из точечного источника SpotLight Свет распространяется из одной точки по конусу
Глава 27. Трехмерная графика 835 Вот как можно определить источник белого света DirectionalLight: <DirectionalLight Color="White" Direction="-l,-1,-1" /> В этом примере вектор, определяющий направление света, начинается в точке начала координат @,0,0) и продолжается до (-1,-1,-1). Это означает, что каждый луч света представляет собой прямую линию, направленную сверху слева и вниз направо. Это имеет смысл для данного примера, поскольку треугольник повернут под углом к направлению света (как показано на рис. 27.3). При вычислении направления света важен угол его падения, а не длина вектора. Это значит, что направление света (-2,-2,-2) эквивалентно нормализованному вектору (-1,-1,-1), поскольку описывает тот же угол. В данном примере направление источника света Рис 27 3. Путь прямого источни- не является перпендикулярным к поверхности тре- ка света в направлении (-1,-1,-1) угольника. Чтобы добиться такого эффекта, вектор света следует направить точно вниз по оси Z, используя направление @,0,-1). Но такое направление было бы несколько искусственным. Поскольку лучи света падают на треугольник под углом, его поверхность будет текстурированной, что создает более реалистичный эффект. На рис. 27.3 показано примерное направление прямого источника света (-1,-1,-1), как он падает на треугольник. Вспомните, что прямой источник света заполняет всю трехмерную сцену. На заметку! Прямой свет иногда сравнивают с солнечным. Причина в том, что лучи света, поступающие от расположенного далеко источника (такого как солнце), становятся почти параллельными. Все объекты, описывающие свет, непрямо наследуются от класса GeometryModel3D. Это значит, что их можно трактовать как трехмерные объекты и помещать внутрь ModelVisual3D, а также добавлять в окно просмотра. Рассмотрим пример окна просмотра, включающего как ранее показанный треугольник, так и источник света: <Viewport3D> <Viewport3D.Camera>...</Viewport3D.Camera> <ModelVisual3D> <ModelVisual3D.Content> <DirectionalLight Color="White" Direction="-l,-1,-1" /> </ModelVisual3D.Content> </ModelVisual3D> <ModelVisual3D> <ModelVisual3D.Content> <GeometryModel3D> <GeometryModel3D.Geometry> <MeshGeometry3D Positions="-l,0,0 0,1.0 1,0,0" TnangleIndices=, 2, 1" /> </GeometryModel3D.Geometry> <GeometryModel3D.Material> <DiffuseMaterial Brush="Yellow" /> </GeometryModel3D.Material> </GeometryModel3D> </ModelVisual3D.Content> </ModelVisual3D> </Viewport3D>
836 Глава 27. Трехмерная графика Существует одна деталь, которая опущена в этом примере, а именно: данное окно просмотра не включает камеру, определяющую точку наблюдения за сценой. Этим мы займемся в следующем разделе. Более подробно о трехмерном освещении Наряду с DirectionalLight, AmbientLight — еще один универсальный класс, описывающий освещение. Сам по себе AmbientLight дает плоское представление трехмерных фигур, но его можно комбинировать с другим источником света и добавить некоторую подсветку затененных областей. Секрет заключается в применении AmbientLight неполной интенсивности. Вместо использования белого AmbientLight применяйте одну треть от белого (установив свойство Color в #555555) или меньше. Также можно установить свойство Dif fuseMaterial.AmbientColor, чтобы управлять тем, насколько сильно AmbientLight будет влиять на материал в заданной сетке. Использование белого цвета (по умолчанию) дает наиболее сильный эффект, в то время как черный цвет создает впечатление материала, не отражающего рассеянный свет. DirectionalLight и AmbientLight — наиболее часто используемые виды освещения для простых трехмерных сцен. PointLight и SpotLight создают нужный эффект, только когда сетка состоит из огромного числа треугольников — обычно, порядка нескольких тысяч. Это связано с тем, как WPF текстурирует поверхности. Как уже известно, WPF экономит время на вычислении интенсивности освещения только в вершинах треугольника. Если фигура состоит из небольшого количества треугольников, такое приближение разрушает эффект Некоторые точки попадут в пределы PointLight и SpotLight, a другие — нет. В результате может получиться, что некоторые треугольники будут освещены, в то время как другие останутся в полной темноте. Вместо получения мягко очерченного круга света на объекте получится группа подсвеченных треугольников, в результате граница света окажется зубчатой. Проблема в том, что PointLight и SpotLight используются для создания мягких округлых световых эффектов, а для изображения круглой фигуры требуется очень много треугольников. (Чтобы создать идеальный круг, для каждого пикселя, лежащего на периметре круга необходим треугольник.) Если есть сетка, состоящая из сотен или тысяч треугольников, то модель частично освещенных треугольников проще приблизить к кругу и получить желаемый световой эффект. Камера Прежде чем трехмерная сцена будет отображена, понадобится расположить камеру в корректной позиции и ориентировать ее в правильном направлении. Это делается установкой в свойстве Viewport3D.Camera объекта Camera. По сути, камера определяет способ проекции трехмерной сцена на двухмерную поверхность окна отображения. В WPF определены три класса камер: наиболее часто используемый PerspectiveCamera и более экзотичные OrthographicCamera и MatrixCamera. Камера PerspectiveCamera отображает сцену так, что объекты, находящиеся дальше, всегда выглядят меньшими. Именно такого поведения большинство ожидает от трехмерной сцены. Камера OrthographicCamera выравнивает трехмерные объекты, так что сохраняется точный начальный масштаб, независимо от местоположения каждой фигуры. Это выглядит немного странно, но удобно для некоторых инструментов визуализации. Например, приложения, предназначенные для технического черчения, часто используют именно этот тип представления. (На рис. 27.4 продемонстрирована разница между PerspectiveCamera и OrthographicCamera.) И, наконец, MatrixCamera позволяет определить матрицу, используемую для трансформации данной трехмерной сцены в двухмерное представление. Это сложный инструмент, предназначенный для высокоспециализированных эффектов и для переноса кода из других платформ (вроде Direct3D), использующих камеру этого типа.
Глава 27. Трехмерная графика 837 Ортогональная проекция Перспективная проекция Рис. 27.4. Отображение перспективы камерами разного типа Выбор правильной камеры — относительно простая задача, но размещение и конфигурирование ее несколько сложнее. Первое, что должно быть указано — точка в трехмерном пространстве, где будет позиционирована камера, за счет установки ее свойства Position. Второй шаг — установка трехмерного вектора в свойстве LookDirection, указывающем ориентацию камеры. В типичной трехмерной сцене камера размещается чуть дальше одного из углов сцены с помощью свойства Position, а затем наклоняется для получения требуемого вида с помощью свойства LookDirection. На заметку! Позиция камеры определяет, насколько крупной будет сцена в окне просмотра. Чем ближе камера, тем больше масштаб. Вдобавок окно просмотра растягивается, чтобы вместить его контейнер и все его текущее содержимое. Например, если создать окно просмотра, заполняющее все окно, то за счет изменения размеров этого окна можно растягивать или сжимать всю сцену. Свойства Position и LookDirection должны устанавливаться в сочетании. Если использована Position для смещения камеры, но с помощью LookDirection камера не сориентирована в правильном направлении, то можно вообще не увидеть содержимого трехмерной сцены. Чтобы правильно ориентировать камеру, укажите точку, которую нужно видеть в прямоугольнике камеры. Направление просмотра может быть вычислено по следующей формуле: НаправяениеПросмотраКамеры = ЦентральнаяТочкаИнтереса - ПозицияКамеры В примере с треугольником камера размещается в левом верхнем углу с координатами (-2,2,2). Если предположить, что необходимо навести фокус на центральную точку сцены @,0,0), которая приходится на середину нижней грани треугольника, то направление просмотра вычисляется следующим образом: НаправяениеПросмотраКамеры = (О, 0, 0) - (-2, 2, 2) = B, -2, -2) Это эквивалентно нормализованному вектору A,-1,-1), поскольку он описывает то же направление. Как и в случае свойства Direction объекта DirectionalLight, здесь важно направление вектора, а не его длина. После установки свойств Position и LookDirection может также понадобиться установить Up Direction. Свойство Up Direction определяет наклон камеры. Изначально UpDirection имеет значение @,1,0), что означает направление прямо вверх, т.е. отсутствие наклона, как показано на рис. 27.5. Если немного сместить это направление, скажем, до @.25,1,0), то камера будет слегка повернута вокруг оси X (рис. 27.6). В результате трехмерные объекты окажутся немного наклоненными в противоположном направлении. Это как если при обозрении сцены наклонить голову.
838 Глава 27. Трехмерная графика Рис. 27.5. Позиционирование и наклон камеры Рис. 27.6. Другой способ наклона камеры Имея в виду все эти детали, можно определить PerspectiveCamera для отображения простой сцены с одним треугольником, которая была описана в предыдущих разделах: <Viewport3D> <Viewport3D.Camera> <PerspectiveCamera Position="-2,2, 2" LookDirection=,-2,-2" UpDirection=,1,0" /> </Viewport3D.Camera> </Viewport3D> Финальная сцена показана на рис. 27.7. Рис. 27.7. Полная трехмерная сцена с одним треугольником
Глава 27. Трехмерная графика 839 Линии осей На рис. 27.7 присутствует одна новая деталь: линии осей. Эти линии — прекрасное средство тестирования, поскольку наглядно показывают, как расположены оси координат. Если в результате визуализации трехмерной сцены ничего не появляется, то линии осей помогут идентифицировать проблему, которая может быть вызвана неправильным направлением камеры, ее неправильным позиционированием или же тем, что фигура повернута изнанкой (и потому невидима). К сожалению, в WPF не предусмотрено класса для рисования прямых линий. Вместо этого придется отображать длинные узкие треугольники. К счастью, есть инструмент, который может помочь в этом — удобный класс ScreenSpaceLines3D, решающий эту проблему. Этот класс доступен в свободно загружаемой библиотеке классов (с полным исходным кодом) по адресу http://www.codeplex.com/3DTools. Этот проект включает также несколько других полезных ингредиентов кода, среди которых Trackball, который будет описан в разделе "Интерактивность и анимация" далее в этой главе. Класс ScreenSpaceLines3D позволяет рисовать прямые линии неизменной толщины. Другими словами, эти линии имеют фиксированную толщину, независимо от местоположения камеры (они не становятся толще с приближением камеры и тоньше — с ее удалением). Это делает такие линии удобными для изображения каркасов, областей пространства, охватывающих области содержимого, векторных линий, указывающих нормали для вычисления освещения, и т.п. Все это особенно полезно при построении инструментов трехмерного дизайна, а также во время отладки приложений. В примере на рис. 27.5 класс ScreenSpaceLines3D используется для рисования линий осей. С камерой связано еще несколько важных свойств. Одно из них — FieldOfView, управляющее размером сцены, которую можно видеть сразу. FieldOfView подобно трансфокатору в камере — по мере уменьшения FieldOfView вы видите все меньший участок сцены (который затем увеличивается, заполняя собой Viewport 3D). С увеличением FieldOfView будет видна все большая часть сцены. Однако важно помнить, что изменение поля зрения — это не то же самое, что перемещение камеры ближе или дальше от объектов сцены. Меньшие поля зрения сокращают расстояние между ближними и дальними объектами, в то время как большие усиливают перспективные отличия между ближними и дальними объектами. (Если вы имели дело с реальными камерами, то должны знать об этом эффекте.) На заметку! Свойство FieldOfView применяется только к камере PerspectiveCamera. Класс OrthographicCamera включает свойство Width, служащее его аналогом. Свойство Width определяет просматриваемую область, но не изменяет перспективу, поскольку эффект перспективы в OrthographicCamera не используется. Классы камер также включают свойства NearPlaneDistance и FarPlaneDistance, устанавливающие мертвые зоны камеры. Объекты, находящиеся ближе, чем NearPlaneDistance, не появляются вообще, и объекты, расположенные дальше, чем FarPlaneDistance, также невидимы. Обычно NearPlaneDistance по умолчанию установлено в 0.125, a FarPlaneDistance — в Double.Positivelnfinity. Такие значения этих свойств делают этот эффект практически незаметным. Однако в некоторых случаях возникает необходимость изменить эти значения, чтобы предотвратить визуализацию артефактов. Наиболее часто встречающийся пример — это когда сложная сетка находится слишком близко к камере, что может привести к z-тушению (z-fighting; также известно под названием совмещения (stitching)). В этой ситуации видеокарта не в состоянии корректно определить, какие треугольники находятся ближе к камере и должны быть отображены. В результате смешиваются разные артефакты поверхности сетки.
840 Глава 27. Трехмерная графика Z-тушение обычно случается из-за ошибок округления чисел с плавающей точкой в видеокарте. Во избежание этой проблемы можно увеличить NearPlaneDistance, чтобы отсечь объекты, находящиеся чересчур близко к камере. Далее в этой главе будет показан пример анимации камеры, так что она "пролетит" через центр тора. Чтобы создать этот эффект без z-тушения, необходимо увеличить NearPlaneDistance. На заметку! Появление артефактов почти всегда является результатом того, что объекты находятся слишком близко к камере, а для свойства NearPlaneDistance установлено слишком большое значение. Подобные проблемы с весьма удаленными объектами и FarPlaneDistance случаются гораздо реже. Дополнительные сведения о трехмерной графике Все эти сложности с камерами, светом, материалами и геометрией сетки представляют собой огромный объем работы для отображения не особо впечатляющего треугольника. Однако это касалось основ поддержки трехмерной графики в WPF. В этом разделе речь пойдет о том, как использовать ее для построения более сложных фигур. Разобравшись с отображением примитивного треугольника, следующий шаг — создание сложной многогранной фигуры, состоящей из небольшой группы треугольников. В следующем примере будет создан код разметки для отображения куба, показанного на рис. 27.8. На заметку! Несложно заметить, что стороны куба, представленного на рис 27.8, имеют мягкие, сглаженные границы. К сожалению, если визуализация осуществляется в среде Windows XP, качества такого уровня не будет. Из-за упрощенной поддержки в ХР видеодрайверов WPF не пытается выполнить сглаживание граней трехмерных фигур, оставляя их зубчатыми. Первая задача в построении куба — это определение способа разбиения его на треугольники, которые распознает объект MeshGeometry. Каждый треугольник подобен простой двухмерной фигуре. Куб состоит из шести квадратных сторон. Каждая квадратная сторона требует для своего отображения двух треугольников. На рис. 27.9 показано, как можно разбить куб на треугольники. Рис. 27.9. Разбиение куба на Рис. 27.8. Трехмерный куб треугольники
Глава 27. Трехмерная графика 841 Чтобы сократить накладные расходы и повысить производительность в программе, формирующей трехмерные фигуры, принято избегать визуализации тех фигур, которые невидимы. Например, если известно, что задняя грань куба, показанного на рис. 27.8, никогда не будет видна, то нет причин определять треугольники для этой стороны. Однако в рассматриваемом примере определяются все стороны, чтобы куб можно было свободно вращать. Вот как выглядит объект MeshGeometry3D, описывающий куб: <MeshGeometry3D Positions=,0,0 10,0,0 0,10,0 10,10,0 0,0,10 10,0,10 0,10,10 10,10,10" Trianglelndices=,2,l 1,2,3 0,4,2 2,4,6 0,1,4 1,5,4 1,7,5 1,3,7 4,5,б 7, 6,5 2,6,3 3,6,7" /> Коллекция Positions определяет углы куба. Она начинается с четырех точек задней стороны (с z-координатой, равной О), а затем добавляет четыре точки передней стороны (где z = 10). Свойство Trianglelndices отображает эти точки на треугольники. Например, первый элемент в этой коллекции — О, 2, 1. Он описывает треугольник от первой точки @,0,0) до второй @,0,10) и третьей @,10,0). Это — один из двух треугольников, формирующих заднюю грань куба (индекс 1, 2, 3 описывает второй треугольник задней грани). Вспомните, что при определении треугольники должны определяться в направлении против часовой стрелки, чтобы их лицевая сторона смотрела вперед. Однако в кубе это правило нарушено. Квадраты передней стороны определяются в порядке против часовой стрелки (см. индексы 4, 5, 6 и 7, 6, 5), но поверхность задней стороны описана по часовой стрелке, включая индексы О, 2, 1 и 1, 2, 3. Это объясняется тем, что обратная сторона куба должна обращать свою лицевую сторону назад. Чтобы лучше представить это, предположим, что куб будет вращаться вокруг оси Y, так что обратная сторона переместится вперед. Теперь те треугольники, которые смотрели назад, будут повернуты вперед, что сделает их полностью видимыми, и получается именно то поведение, которое нужно. Текстурирование и нормали Есть еще одна проблема, связанная с сеткой куба, продемонстрированной в предыдущем разделе. Дело в том, что она не создает четко ограненного куба, показанного на рис. 27.8. Вместо этого получается куб, показанный на рис. 27.10, с явно видимыми стыками между треугольниками. • CubeMesh ^З^ЙёШШ| Рис. 27.10. Куб с артефактами освещенности
842 Глава 27. Трехмерная графика Эта проблема возникает из-за способа, которым WPF вычисляет освещение. Для того чтобы упростить процесс вычислений, WPF находит уровень освещенности каждой вершины фигура; другими словами, внимание уделяется только углам треугольников, а их поверхности заполняются переходными цветами. Хотя это обеспечивает приятную штриховку каждого треугольника, но может стать причиной появления других артефактов. Например, в этой ситуации это мешает равномерному окрашиванию двух треугольников, образующих сторону куба. Чтобы понять, почему так происходит, необходимо немного узнать о нормалях. Каждая нормаль определяет, как вершина ориентирована относительно источника света. В большинстве случаев нормаль должна быть перпендикулярна поверхности треугольника. На рис. 27.11 эта концепция иллюстрируется на примере передней поверхности куба. Передняя поверхность состоит из двух треугольников и четырех вершин. Каждая из этих четырех вершин должна иметь нормаль, направленную под прямым углом к поверхности квадрата. Другими словами, нормаль должна иметь направление @,0,1). Совет. Нормали можно представлять себе по-другому. Когда вектор нормали направлен противоположно вектору освещения, то поверхность будет освещена полностью. В данном примере это значит, что прямой свет, направленный в @,0,-1), полностью осветит переднюю грань куба, т.е. получится то, что ожидалось. Треугольники на других сторонах куба также должны иметь собственные нормали. В каждом случае нормали должны быть перпендикулярны поверхности. На рис. 27.12 показаны нормали на передней, верхней и правой гранях куба. Куб на рис. 27.12 — это тот же самый куб, что и показанный на рис. 27.8. Когда среда WPF текстурирует куб, она просматривает каждый треугольник по одному. Например, возьмем переднюю поверхность. Каждая точка встречает направленный свет одинаково. По этой причине каждая точка будет освещена одинаково. В результате, когда WPF распределяет освещенность на четыре угла, то создаст плоскую, равномерно окрашенную поверхность без текстурирования. Так почему же созданный куб ведет себя подобным образом в плане освещения? Виной тому общие точки в коллекции Positions. Хотя нормали определяют тексту- рирование треугольников, они определены только в вершинах треугольника. Каждая точка в коллекции Positions имеет только одну нормаль, определенную для нее. Это означает, что разделение одних и тех же точек между разными треугольниками также приводит к разделению их общих нормалей. Т 7 Рис. 27.11. Нормали передней стороны куба Рис. 27.12. Нормали на видимых сторонах куба
Глава 27. Трехмерная графика 843 Именно это произошло на рис. 27.10. Разные точки одной и той же стороны освещены по-разному, потому что они имеют разные нормали. A WPF сглаживает освещенность между этими точками, заполняя поверхность каждого треугольника. Это разумное поведение по умолчанию, но поскольку сглаживание выполняется для каждого треугольника, разные треугольники по освещенности не совпадают точно, в результате чего наблюдаются "стыки" цветов между ними. Один простой (однако утомительный) способ решения этой проблемы заключается в обеспечении того, чтобы ни одна точка не была разделена между разными треугольниками, а для этого объявлена несколько раз (по одному для каждого использования). Вот как будет выглядеть удлиненный код разметки, который обеспечит это: shGeometry3D Positions=,0,0 10,0,0 0,10,0 0,0,0 0,0,10 0,10,0 0,0,0 10,0,0 0,0,10 10,0,0 10,10,10 10,0,10 0,0,10 10,0,10 0,10,10 0,10,0 0,10,10 10,10,0 Trianglelndices=,2,1 1,2,3 4,5,6 6,5,7 8,9,10 9,11,10 12,13,14 12,15,13 16,17,18 19,18,17 20,21,22 22,21,23" /> 10,10,0 0,10,10 10,0,10 10,10,0 10,10,10 10,10,10 В этом примере такой шаг избавляет от необходимости кодировать нормали вручную. WPF корректно генерирует их, обеспечивая перпендикулярность каждой нормали к поверхности треугольника, как показано на рис. 27.11. В результате получается ограненный куб, изображенный на рис. 27.8. На заметку! Хотя этот код разметки несколько длиннее, накладные расходы существенно не изменились. Это связано с тем, что WPF всегда выполняет визуализацию трехмерной сцены как коллекции различных треугольников, независимо от того, есть у них общие точки в коллекции Positions или нет. Важно понимать, что обеспечивать такое соответствие нормалей нужно не всегда. В примере с кубом это необходимое требование, чтобы получить четкие грани. Однако может понадобиться и другой световой эффект, например, сглаженный куб, который, однако, лишен недостатка, связанного со стыками цветов. В этом случае векторы нормалей должны быть определены явно. Выбор правильных нормалей может быть непрост. Чтобы получить нужный результат, помните о следующих двух принципах. • Для вычисления нормали, перпендикулярной поверхности, вычислите векторное произведение векторов, составляющих любые две стороны треугольника. Однако не забывайте, что точки треугольника должны обходиться в направлении против часовой стрелки, чтобы нормаль была направлена от поверхности (а не в нее). • Если нужно согласованное выравнивание освещенности по поверхности, включающей более одного треугольника, удостоверьтесь, что все точки во всех треугольниках разделяют одну и ту же нормаль. Для вычисления нормали, необходимой поверхности, можно воспользоваться кодом С#. Ниже приведен пример кода процедуры, которая помогает вычислить нормаль, перпендикулярную поверхности треугольника, который задан координатами его вершин.
844 Глава 27. Трехмерная графика private Vector3D CalculateNormal(Point3D pO, Point3D pi, Point3D p2) { Vector 3D vO = new Vector 3D (pi. X - pO.X, pl.Y - pO.Y, pi. Z - pO.Z) ; Vector 3D vl = new Vector 3D (p2 .X - pl.X, p2 . Y - pl.Y, p2 . Z - pl.Z) ; return Vector3D.CrossProduct(vO, vl) ; } Затем понадобится вручную установить свойство Normals, заполнив его векторами. Помните, что к каждой позиции должна быть добавлена одна нормаль. В следующем примере цвета соседних треугольников, образующих общий прямоугольник, сглаживаются за счет разделения нормалей. Соседние треугольники на поверхности куба разделяют две общие точки. Поэтому нужно скорректировать освещенность только тех двух точек, которые не являются общими. Пока они соответствуют друг другу, текстурирование будет согласовано по всей общей поверхности: <MeshGeometry3D Positions=,0,0 10,0,0 0,10,0 10,10,0 0,0,10 10,0,10 0,10,10 10,10,10" Trianglelndices=,2,1 1,2,3 0,4,2 2,4,6 0,1,4 1,5,4 1,7,5 1,3,7 4,5, б 7,6,5 2, 6,3 3, 6,7" Normals=,1,0 0,1,0 1,0,0 1,0,0 0,1,0 0,1,0 1,0,0 1,0,0" /> Это позволит создать сглаженный куб, показанный на рис. 27.13. Теперь значительные части куба разделяют одну и ту же нормаль. Это создает исключительно гладкий эффект, который совершенно скрывает стыки сторон куба, делая их почти неразличимыми. [£ЗСиЬ«Ме* HI Рис. 27.13. Исключительно гладкий куб Данный эффект нельзя назвать корректным или некорректным — это зависит от того, какой эффект планировалось достичь. Например, четко выделенные грани создают более геометрический внешний вид, в то время как сглаженные стороны создают впечатление чего-то органического. Один часто применяемый трюк заключается в использовании сглаживания на многоугольнике, создавая впечатление сферы, цилиндра или другой криволинейной поверхности. Поскольку сглаживание скрывает грани фигуры, такой эффект работает исключительно хорошо. Более сложные фигуры Реалистические трехмерные сцены требуют использования сотен или тысяч треугольников. Например, один из возможных подходов к построению простой сферы за-
Глава 27. Трехмерная графика 845 ключается в разбиении сферы на плоские ленты с последующим разбиением каждой ленты на серию квадратов, как показано на рис. 27.14. Каждый из квадратов, в свою очередь, требует для своего отображения двух треугольников. Рис. 27.14. Два способа моделирования базовой сферы Чтобы построить такую нетривиальную сетку, понадобится сконструировать ее в коде или воспользоваться специализированной программой трехмерного моделирования. Подход на основе кода потребует серьезного математического аппарата. Для дизайнерского подхода необходимо сложное программное обеспечение трехмерной графики. К счастью, существует достаточно много средств для построения трехмерных сцен, которые можете использовать в WPF-приложениях. Ниже перечислены некоторые из них. • ZAM 3D — инструмент трехмерного моделирования, предназначенный специально для XAML. Доступен по адресу http://www.erain.com/Products/ZAM3D. • Blender — набор инструментов с открытым кодом для трехмерного моделирования. Доступен по адресу http://www.blender.org. Имеется также экспериментальный сценарий экспорта XAML на http://codeplex.com/xamlexporter. Все вместе они предоставляют развитую платформу для построения трехмерного содержимого для приложений WPF. • Подключаемые модули экспорта стали появляться для широкого диапазона программ профессионального трехмерного моделирования, таких как Maya и Lightwave. Список некоторых из них можно найти по адресу http:// blogs.msdn.com/mswanson/articles/WPFToolsAndControls.aspx. Все программы трехмерного моделирования включают базовые примитивы, такие как сферы, построенные из маленьких треугольников. Эти примитивы можно использовать для конструирования сцен. Программы также позволяют добавлять и позиционировать источники света и применять текстуры. Некоторые из них, например, ZAM 3D, также позволяют определять анимации, которые должны выполняться с объектами трехмерной сцены. Коллекции Model3DGroup При работе со сложными трехмерными сценами обычно придется размещать множество объектов. Как уже известно, Viewport3D может содержать множество объектов Visual 3D, каждый из которых использует свою сетку. Однако это не лучший способ построения трехмерной сцены. Вы получите намного лучшую производительность, создав как можно меньше сеток и комбинируя как можно больше содержимого в пределах каждой из них.
846 Глава 27. Трехмерная графика Очевидно, что существует еще одно соображение: гибкость. Если сцена разбита на отдельные объекты, можно организовать проверку попадания курсора, трансформировать и анимировать их по отдельности. Однако для достижения упомянутой гибкости создавать отдельные объекты Visual 3D не нужно. Вместо этого можно посредством класса Model3DGroup разместить несколько сеток в пределах одного Visual 3D. Класс Model3DGroup унаследован от Model3D (как и классы GeometryModel3D и Light). Однако он предназначен для группирования комбинаций пространственных сеток. Каждая сетка остается отдельным фрагментом сцены, которым можно манипулировать индивидуально. Например, рассмотрим трехмерный персонаж, представленный на рис. 27.15. Ниже приведена часть кода разметки, рисующая соответствующие сетки из словаря ресурсов. <ModelVisual3D> <ModelVisual3D.Content> <Model3DGroup x:Name="Scene" Transform="{DynamicResource SceneTR20}"> <AmbientLight ... /> <DirectionalLight . . . /> <DirectionalLight . . . /> <Model3DGroup x:Name="CharacterOR22"> <Model3DGroup x:Name="PelvisOR24"> <Model3DGroup x:Name="BeltOR26"> <GeometryModel3D x:Name="BeltOR2 6GR27" Geometry="{DynamicResource BeltOR2 6GR27}" Material="{DynamicResource ER_Vector Flat_Orange DarkMRlO}" BackMaterial="{DynamicResource ER_Vector Flat_Orange DarkMRlO}" /> </Model3DGroup> <Model3DGroup x:Name="TorsoOR29"> <Model3DGroup x:Name="TubesOR31"> <GeometryModel3D x:Name="TubesOR31GR32" Geometry="{DynamicResource TubesOR31GR32}" Material="{DynamicResource ER Default_MaterialMRl}" BackMaterial="{DynamicResource ER Default_MaterialMRl}"/> </Model3DGroup> </ModelVisual3D.Content> </ModelVisual3D> Вся сцена определена как единый объект ModelVisual3D, содержащий Model3DGroup. Этот Model36Group содержит другие вложенные объекты Model3DGroup. Например, Model3DGroup верхнего уровня содержит источники света и персонаж, в то время как Model3DGroup персонажа содержит другой Model3DGroup, включающий туловище, а также такие детали, как руки, имеющие ладони, которые, в свою очередь, содержат пальцы, и т.д. — в конце концов, эта иерархия заканчивается объектами GeometryModel3D, которые в действительности определяют объекты и их материал. В результате такого тщательно сегментированного иерархического дизайна (который получается при создании объектов инструментом вроде ZAM 3D) можно анимировать части тела индиви- Рис. 27.15. Трехмерный персонаж
Глава 27. Трехмерная графика 847 дуально, заставляя персонажа ходить, жестикулировать и т.п. (Чуть позже, в разделе "Интерактивность и анимация" мы еще вернемся к анимации трехмерных сцен.) На заметку! Помните, что для обеспечения минимального уровня накладных расходов нужно использовать минимальное количество сеток и минимальное число объектов ModelVisual3D. Объект Model3DGroup позволяет сократить количество используемых объектов ModelVisual3D (вообще нет причины заводить их больше одного), сохраняя гибкость для манипулирования частями сцены по отдельности. Снова о материалах До сих пор использовался только один из типов материалов, поддерживаемых WPF для конструирования трехмерных объектов. Несомненно, DiffuseMaterial — наиболее часто используемый тип материала; он рассеивает свет во всех направлениях, подобно объекту реального мира. При создании DiffuseMaterial просто указывается кисть в свойстве Brush. В рассмотренных ранее примерах использовались кисти сплошного цвета. Однако цвет, который будет виден, определяется цветом кисти и освещением. При наличии прямого, яркого освещения будет виден точный цвет кисти. Но если свет попадает на поверхность под углом (как в предыдущих примерах с треугольником и кубом), то будет виден более темный, текстурированный цвет. На заметку! Интересно, что WPF позволяет создавать частично прозрачные трехмерные объекты. Простейший подход предусматривает установку свойства Opacity кисти, используемой для окрашивания материала, в значение меньше 1. Типы SpecularMaterial и EmissiveMaterial работают немного по-разному. Оба дополнительно смешиваются с содержимым, расположенным под ними. По этой причине наиболее распространенный способ использования обоих типов материалов предусматривает их сочетание с DiffuseMaterial. Рассмотрим SpecularMaterial. Он отражает свет намного интенсивнее, чем DiffuseMaterial. Яркостью отраженного света можно управлять через свойство SpecularPower. При использовании малого числа свет будет отражаться мягче, независимо от того, под каким углом он падает на поверхность. Если указано большое число, то прямой свет будет отражаться ярче. Таким образом, малое значение SpecularPower дает размытый, рассеянный эффект, в то время как большое его значение — яркий, резкий. Если поместить SpecularMaterial на темную поверхность, то это даст эффект, подобный стеклу. Однако SpecularMaterial чаще используется для добавления бликов к DiffuseMaterial. Например, применение белого SpecularMaterial поверх DiffuseMaterial создает поверхность, похожую на пластик, в то время как темный SpecularMaterial и DiffuseMaterial создает впечатление металла. На рис. 27.16 представлены две версии тора (трехмерного кольца). Версия слева использует обычный DiffuseMaterial. Версия справа добавляет поверх него SpecularMaterial. При этом блики появляются в нескольких местах, потому что сцена включает два источника прямого света, который направлен по-разному. Чтобы скомбинировать две поверхности, их нужно упаковать BMaterialGroup. Ниже приведен код разметки, создающий изображение на рис. 27.16. <GeometryModel3D> <GeometryModel3D.Material> <MaterialGroup> <DiffuseMaterial> <DiffuseMaterial.Brush>
848 Глава 27. Трехмерная графика <SolidColorBrush Color="DarkBlue" /> </DiffuseMaterial.Brush> </DiffuseMaterial> <SpecularMaterial SpecularPower= 4"> <SpecularMaterial.Brush> <SolidColorBrush Color="LightBlue" /> </SpecularMaterial.Brush> </SpecularMaterial> </GeometryModel3D.Material> <GeometryModel3D.Geometry>...</GeometryModel3D.Geometry> <GeometryModel3D> Рис. 27.16. Добавление SpecularMaterial На заметку! Если поместить материал SpecularMaterial или EmissiveMaterial на белую поверхность, то вообще ничего не будет видно. Это.объясняется тем, что SpecularMaterial и EmissiveMaterial формируют свой цвет аддитивно, а белый цвет — это всегда смесь максимальной интенсивности красной, зеленой и синей составляющих. Чтобы увидеть полный эффект от SpecularMaterial и EmissiveMaterial, поместите их на черную поверхность (или используйте их поверх черного материала DiffuseMaterial). EmissiveMaterial ведет себя еще более странно. Он сам излучает свет, а это означает, что зеленый EmissiveMaterial, отображаемый над черной поверхностью, будет выглядеть как плоский зеленый силуэт, независимо от того, содержит ли сцена какие-то другие источники света. Опять-таки, можно получить и более интересный эффект, наложив EmissiveMaterial на Dif fuseMaterial. Из-за аддитивной природы EmissiveMaterial цвета смешиваются. Например, если поместить красный EmissiveMaterial поверх синего Dif fuseMaterial, то фигура окрасится к пурпурный оттенок. EmissiveMaterial добавит равномерно распределенный красный цвет по все поверхности фигуры, в то время как DiffuseMaterial будет текстурирован в соответствии с расположением источников света на сцене. Совет. Свет, исходящий от EmissiveMaterial, не распространяется на другие объекты. Чтобы создать эффект объекта, который освещает другие соседние объекты, стоит разместить источник света (такой как PointLight) по соседству с материалом EmissiveMaterial.
Глава 27. Трехмерная графика 849 Отображение текстур До сих пор для рисования объектов использовалась кисть SolidColorBrush. Однако WPF позволяет нарисовать объект DiffuseMaterial, применяя для этого любую кисть. Это означает, что можно закрашивать его градиентами (LinearGradientBrush и RadialGradientBrush), векторными или растровыми изображениями (ImageBrush) или же содержимым двухмерного элемента (VisualBrush). Но есть одна ловушка. Когда используется любая кисть кроме Soli dColorB rush, то должна быть указана дополнительная информация, сообщающая WPF, как следует отображать двухмерное содержимое кисти на закрашиваемую трехмерную поверхность. Эта информация передается через коллекцию MeshGeometry.TextureCoordinates. В зависимости от выбора, можно многократно повторить содержимое кисти, извлечь ее часть, растянуть, исказить или поступить иным образом, чтобы заполнить кривые или наклонные поверхности. Так каким же образом работает коллекция TextureCoordinates? Основная идея состоит в том, что каждая координата сетки требует соответствующей точки в TextureCoordinates. Координата сетки — это точка в трехмерном пространстве, в то время как точка в коллекции TextureCoordinates — это двухмерная точка, поскольку содержимое кисти всегда двухмерное. В следующих разделах будет показано, как использовать отображение текстур для вывода изображений и видео на трехмерную фигуру. Отображение ImageBrush Простейший способ разобраться в работе TextureCoordinates — это воспользоваться кистью ImageBrush, которая позволяет рисовать растровые изображения. Ниже приведен пример, в котором применяется сцена с деревьями в тумане. <GeometryModel3D.Matenal> <DiffuseMaterial> <DiffuseMaterial.Brush> <ImageBrush ImageSource="Tree.jpg"></ImageBrush> </DiffuseMaterial.Brush> </DiffuseMaterial> </GeometryModel3D.Material> В этом примере кисть ImageBrush используется для рисования поверхностей созданного ранее куба. В зависимости от установки коллекции TextureCoordinates, можно растянуть изображение, обернуть вокруг всего куба или же поместить отдельные копии на каждую сторону (как сделано в этом примере). Конечный результат показан на рис. 27.17. ■ чгеМаррюд ЧК^ЁЛКН Рис. 27.17. Текстурированный куб
850 Глава 27. Трехмерная графика На заметку! В этом примере добавляется одна дополнительная деталь. Ползунок в нижней части окна позволяет вращать куб, чтобы рассмотреть его с разных сторон. Это возможно благодаря трансформации, о которой речь пойдет в следующем разделе. Изначально коллекция TextureCoordinates пуста, и изображение отсутствует на трехмерной поверхности. Чтобы начать совершенствовать пример с кубом, сначала имеет смысл сосредоточиться на нанесении изображения только на одну его грань. В данном примере куб ориентирован так, что его левая сторона повернута к камере. Ниже приведено описание сетки для куба. Код двух треугольников, образующих его левую (переднюю) сторону, выделен полужирным. <MeshGeometry3D Positions=,0,0 0,0,0 0,0,0 10,0,0 0,0,10 0,10,0 Trianglelndices=" 0,2,1 4,5,6 8,9,10 12,13,14 16,17,18 20,21,22 10,0,0 0,0,10 10,0,0 10,10,10 10,0,10 0,10,10 1,2,3 6,5,7 9,11,10 12,15,13 19,18,17 22,21,23й 0,10,0 0,10,0 0,0,10 10,0,10 0,10,10 10,10,0 /> 10,10,0 0,10,10 10,0,10 10,10,0 10,10,10 10,10,10 Большая часть точек вообще не отображена. Фактически только следующие четыре отображенные точки описывают сторону куба, повернутую к камере: @,0,0) @,0,10) @,10,0) @,10,10) Поскольку это на самом деле плоская поверхность, ее покрытие относительно просто. Можно установить TextureCoordinates для этой поверхности, исключив измерения, имеющие значение 0 во всех четырех точках. (В данном примере это координата X, потому что видимая часть на самом деле находится на левой стороне куба.) Вот координаты TextureCoordinates, отвечающие этому требованию: @,0) @,10) A0,0) A0,10) Коллекция TextureCoordinates использует относительные координаты. Для простоты можно применять 1 для указания максимального значения. В данном примере такая трансформация проста: @,0) @,1) A,0) A,1) Подобная установка TextureCoordinates, по сути, сообщает WPF, что нужно взять точку @,0) в нижнем левом углу прямоугольника, представляющего содержимое кисти, и отобразить его на соответствующую точку @,0,0) трехмерного пространства. Аналогично необходимо взять нижний правый угол @,1) и отобразить его на @,0,10), потом верхний левый угол A,0) отобразить на @,10,0), а правый верхний A,1) — на @,10,10). Рассмотрим описание сетки куба, использующего это покрытие текстурой. Все прочие координаты в коллекции Positions отображаются на @,0), так что к этим областям текстура не применяется. <MeshGeometry3D Positions=,0,0 0,0,0 0,0,0 10,0,0 0,0,10 0,10,0 10,0,0 0,0,10 10,0,0 10,10,10 10,0,10 0,10,10 0,10,0 0,10,0 0,0,10 10,0,10 0,10,10 10,10,0 10,10,0 0,10,10 10,0,10 10,10,0 10,10,10 10,10,10
Глава 27. Трехмерная графика 851 Trianglelndices="..." TextureCoordinates=" 0,0 0,0 0,0 0,0 0,0 0,1 1,0 1,1 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0 0,0" /> Этот код разметки отображает текстуру на одну грань куба. Хотя это выполняется успешно, изображение повернуто вбок. Чтобы получить изображение сверху, понадобится изменить координаты следующим образом: 1,1 0,1 1,0 0,0 Этот процесс может быть продолжен для каждой стороны куба. Ниже приведен набор координат TextureCoordinates, который описывает многогранный куб, показанный на рис. 27.17. TextureCoordinates=M0,0 0,1 1,0 1,1 1,1 0,1 1,0 0,0 0,0 1,0 0,1 1,1 0,0 1,0 0,1 1,1 1,1 0,1 1,0 0,0 1,1 0,1 1,0 0,0" Понятно, что за счет экспериментирования с этими точками можно получить множество других эффектов. Например, возможно натянуть текстуру на более сложный объект вроде сферы. Поскольку сетки, описывающие объекты подобного рода, обычно состоят из сотен точек, вряд ли возникнет жк. ^чие записывать коллекцию TextureCoordinates вручную. Вместо этого можно обратите л к программам трехмерного моделирования (или к математической процедуре, которая проделает это во время выполнения). Чтобы применять разные кисти к разным частям сетки, придется разделить трехмерный объект на множество сеток, каждая из которых будет иметь свой материал, использующий свою, отличную от других кисть. Затем эти сетки комбинируются в один объект Model3DGroup для снижения накладных расходов. Видео и VisualBrush Обычные изображения — не единственный вид содержимого, которое можно отобразить на трехмерную поверхность. Допускается также отображать содержимое, которое изменяется, такое как градиентные кисти, имеющие анимированные значения. Один из часто применяемых приемов WPF — отображение на трехмерную поверхность видеоизображения. По мере его воспроизведения оно отображается в реальном времени на трехмерной поверхности. Достичь такого необычного эффекта неожиданно легко. Фактически, можно отображать видеокисть на поверхности куба с разной ориентацией, используя тот же набор координат TextureCoordinates, что и в предыдущем примере с отображением статичного изображения. Все, что потребуется для этого — заменить ImageBrush более совершенной кистью VisualBrush и применить в ней MediaElement. С помощью триггера событий можно даже запустить циклическое воспроизведение видео без необходимости написания кода. В следующем коде разметки создается кисть VisualBrush, выполняющая циклическое воспроизведение с одновременным вращением куба, показывая его под разными углами. (Об использовании анимации и вращения для достижения такого эффекта речь пойдет в следующем разделе.)
852 Глава 27. Трехмерная графика <GeometryModel3D.Matenal> <DiffuseMaterial> <DiffuseMaterial.Brush> <VisualBrush> <VisualBrush.Visual> <MediaElement> <MediaElement.Triggers> <EventTrigger RoutedEvent="MediaElement.Loaded"> <EventTrigger.Actions> <BeginStoryboard> <Storyboard > <MediaTimeline Source="test.mpg" RepeatBehavior="Forever" /> <DoubleAnimation Storyboard.TargetName=llrotate" Storyboard.TargetProperty="Angle" To=60" Duration=:0:5" RepeatBehavior="Forever" /> </Storyboard> </BeginStoryboard> </EventTrigger.Actions> </EventTrigger> </MediaElement.Triggers> </MediaElement> </Visua1Brush.Visual> </VisualBrush> </DiffuseMaterial.Brush> </DiffuseMaterial> </GeometryModel3D.Material> На рис. 27.18 показан экранный снимок этого примера в действии. Рис. 27.18. Отображение видео на нескольких трехмерных поверхностях Интерактивность и анимация Чтобы получить полное представление о трехмерной сцене, понадобится сделать ее динамической. Другими словами, должен существовать какой-нибудь способ модификации части сцены — автоматически или в ответ на действия пользователя. В конце концов, если не нужна динамическая трехмерная сцена, то лучше подготовить трехмерное изображение в какой-то программе для создания иллюстраций и затем экспортировать
Глава 27. Трехмерная графика 853 его в виде обычной векторной графики XAML. (Некоторые инструменты трехмерного моделирования вроде ZAM 3D позволяют это делать.) В следующих разделах вы научитесь манипулировать трехмерными объектами с использованием трансформаций, узнаете, как добавлять анимацию и как перемещать камеру. Также вы ознакомитесь с отдельно реализованным инструментом — классом Trackball, позволяющим интерактивно вращать трехмерную сцену. И, наконец, будет показано, как выполнять проверку попадания в трехмерной сцене, а также как помещать на трехмерную поверхность интерактивные двухмерные элементы, подобные кнопкам и текстовым полям. Трансформации Как и в случае двухмерного содержимого, наиболее впечатляющим и гибким способом изменения аспектов трехмерной сцены являются трансформации. Это особенно верно в случае трехмерной графики, поскольку приходится работать с классами относительно низкого уровня. Например, для масштабирования сферы придется сконструировать соответствующую геометрию и использовать ScaleTransform3D для ее анимации. При наличии трехмерного примитива сферы это может быть не обязательно, поскольку можно выполнить анимацию такого высокоуровневого свойства, как Radius. Трансформации — очевидный способ создания динамических эффектов. Однако прежде чем можно будет использовать трансформации, необходимо решить, как именно они должны применяться. Существует несколько возможных подходов. • Модифицировать трансформацию, примененную к Model3D. Это позволит изменять один аспект одного трехмерного объекта. Этот прием можно также использовать в Model3DGroup, поскольку данный класс унаследован от Model 3D. • Модифицировать трансформацию, применяемую к ModelVisual3D. Это позволяет изменить всю сцену. • Модифицировать трансформацию, которая применяется к источнику света. Это позволит изменять освещение сцены (например, создать эффект "рассвета"). • Модифицировать трансформацию, применяемую к камере. Это позволит перемещать камеру по сцене. Трансформации настолько удобны при разработке трехмерной графики, что стоит взять за привычку применять Transform3DGroup всякий раз, когда требуется трансформация. Это позволит добавлять дополнительные трансформации позднее, без изменения кода анимации. Программа трехмерного моделирования ZAM 3D всегда добавляет набор из четырех заготовок трансформаций к каждой группе Model3DGroup, так что объектом, представленным группой, можно манипулировать разными способами. <Model3DGroup.Transform> <Transform3DGroup> <TranslateTransform3D OffsetX=,,0M OffsetY=" OffsetZ=,,0,,/> <ScaleTransform3D ScaleX=,,l" ScaleY=,,l" ScaleZ="l"/> <RotateTransform3D> <RotateTransform3D.Rotation> <AxisAngleRotation3D Angle=" Axis= 1 0"/> </RotateTransform3D.Rotation> </RotateTransform3D> <TranslateTransform3D OffsetX=" OffsetY=" OffsetZ="/> </Transform3DGroup> </Model3DGroup.Transform> Обратите внимание, что показанный набор трансформаций включает два объекта TranslateTransform3D. Это связано с тем, что трансляция объекта перед его враще-
854 Глава 27. Трехмерная графика ни ем дает другой результат, чем его трансляция после вращения, и могут понадобиться оба эффекта. Другой полезный прием — именовать объекты трансформации в XAML с применением атрибута x:Name. Даже если объекты трансформации не имеют свойства имени, это создаст приватную переменную-член, которую можно будет использовать для облегчения доступа без необходимости длительного путешествия по иерархии объектов. Это особенно важно, поскольку, как указывалось выше, сложные трехмерные сцены часто имеют несколько уровней объектов Model3DGroup. Проход по этому дереву элементов вниз от ModelVisual3D верхнего уровня утомителен и чреват ошибками. Вращения Чтобы прочувствовать способы использования трансформаций, рассмотрим следующий код разметки. В нем применяется трансформация RotateTransform3D, которая позволяет вращать трехмерный объект вокруг указанных осей. В данном случае ось вращения направлена по оси Y координатной системы. <Mode1Visual3D.Transform> <RotateTransform3D> <RotateTransform3D.Rotation> <AxisAngleRotation3D x:Name="rotate" Axis= 1 0" /> </RotateTransform3D.Rotation> </RotateTransform3D> </ModelVisual3D.Transform> Используя именованное вращение, можно создать привязанный к данным ползунок (Slider), который позволит пользователям поворачивать куб вокруг его осей: <Slider Grid.Row=,,l" Minimum^'O" Мах1тит=м3 60м Orientation=,,Horizontal" Value="{Binding ElementName=rotate, Path=Angle}" ></Slider> Это вращение так же легко может использоваться в анимации. Рассмотрим пример анимации, вращающей тор (трехмерное кольцо) одновременно вокруг двух разных осей. Все начинается после щелчка на кнопке: <Button> <Button.Content>Rotate Torus</Button.Content> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <BeginStoryboard> <Storyboard RepeatBehavior="Forever"> <DoubleAnimation Storyboard.TargetName="ring" Storyboard. TargetProperty=,,rotatel" To=,,3 60" Duration= : 0 : 2 . 5"/> <DoubleAnimation Storyboard.ТаrgetName="ring" Storyboard.TargetProperty=,,rotate2" To=,,360" Duration= : 0 : 2 . 5"/> </Storyboard> </Beginstoryboard> </EventTrigger> </Button.Triggers> </Button> На рис. 27.19 показаны четыре снимка тора на разных стадиях вращения. Полеты Распространенный эффект, часто присутствующий в трехмерных сценах — перемещение камеры вокруг объекта. Эта задача концептуально проста для реализации в WPF: просто нужно использовать TranslateTransform для перемещения камеры.
Глава 27. Трехмерная графика 855 оо Рис. 27.19. Вращающаяся трехмерная фигура Однако должны быть учтены два обстоятельства. • Обычно камера будет перемещаться по некоторому маршруту, а не просто по прямой линии от начальной точки к конечной. Есть два способа добиться этого: можно использовать анимацию на основе пути, чтобы провести камеру по геометрически заданному маршруту, или же применить анимацию ключевого кадра, определяющую несколько меньших сегментов. • По мере движения камеры необходимо подстраивать направление ее взгляда. Чтобы сохранить ориентацию на объект, также придется анимировать свойство LookDirection. Следующий код разметки показывает анимацию, при которой камера "пролетает" через центр тора, оборачивается к его задней стороне и в конечном итоге возвращается в начальную точку. Чтобы увидеть эту анимацию в действии, обратитесь к примерам для этой главы. <StackPanel Orientation="Horizontal"> <Button> <Button.Content>Begin Fly-Through</Button.Content> <Button.Triggers> <EventTrigger RoutedEvent="Button.Click"> <BeginStoryboard> <Storyboard> <Point3DAnimationUsingKeyFrames Storyboard.TargetName="earnera" Storyboard.ТагgetProperty="Position"> <LinearPoint3DKeyFrame Value=,0.2,-Iм KeyTime=:0:10"/> <LinearPoint3DKeyFrame Value="-0.5,0.2,-1" KeyTime=:0:15"/> <LinearPointou/eyFrame Value="-0.5,0.5,0" KeyTime=:0:20"/> <LinearPoint3DKeyrrc-n^ Value=,0,2" KeyTime= : 0 :23"/> </Point3DAnimationUsingKeyFrames> <Vector3DAnimationUsingKeyFrames Storyboard.ТаrgetName="earnera" Storyboard.ТаrgetProperty="LookDirection"> <LinearVector3DKeyFrame Value="-1,-1,-3" KeyTime=:0:4"/> <LinearVector3DKeyFrame Value="-1,-1,3" KeyTime=:0:10"/> <LinearVector3DKeyFrame Value="l,0,3" KeyTime=:0:14"/> <LinearVector3DKeyFrame Value=,0,-1" KeyTime=:0:22"/> </Vector3DAnimationUsingKeyFrames> </Storyboard>
856 Глава 27. Трехмерная графика </BeginStoryboard> </EventTrigger> </Button.Triggers> </Button> </StackPanel> Чтобы сделать пример более интересным, можно одновременно запустить обе анимации (вращение, показанное ранее, и эффект полета, приведенный здесь), что заставит камеру пройти сквозь обод кольца во время его вращения. Можно также анимиро- вать свойство UpDirection камеры, покачивая ее во время движения: <Vector3DAnimation Storyboard.TargetName="camera" Storyboard.ТагgetProperty="UpDirection" From=,0,-Iм To=,0.1,-1" Duration=:0:0.5" AutoReverse="True" RepeatBehavior="Forever" /> Производительность 3-D Визуализация трехмерной сцены требует намного больше работы, чем визуализация двухмерной сцены. В случае анимации трехмерной сцены WPF пытается обновлять изменяемые части по 60 раз в секунду. В зависимости от сложности сцены, это легко может исчерпать ресурсы памяти видеокарты, что приведет к снижению частоты кадров, и анимация станет дергаться. Существует несколько базовых приемов для повышения производительности 3-D. Вот некоторые стратегии обращения с окном обзора, позволяющие сократить накладные расходы визуализации 3-D. • Если не нужно обрезать содержимое, выходящее за границы окна обзора, установите Viewport3D.ClipToBounds в false. • Если не нужно выполнять проверку попадания курсора в трехмерную сцену, установите Viewport3D.IsHitTestVisible в false. • Если не пугает снижение качества — зубчатые грани в трехмерных фигурах — установите внутри Viewport3D присоединенное свойство RenderOptions.EdgeMode в Aliased. • Если Viewport3D больше, чем необходимо, уменьшите его. Важно также обеспечить, насколько это возможно, правильное освещение трехмерной сцены. Ниже приведено несколько критичных советов по созданию наиболее эффективных сеток и моделей. • Где только возможно, создавайте одну сложную сетку вместо нескольких мелких. • Если в одной и той же сетке используются разные материалы, определите однажды объект MeshGeometry (как ресурс) и затем многократно используйте его для создания множества объектов GeometryModel3D. • Где только возможно, упаковывайте группы объектов GeometryModel3D в Model3DGroup и помещайте эту группу в единый объект ModelVisual3D. He создавайте отдельных объектов ModelVisual3D для каждого GeometryModel3D. • Не определяйте черных материалов (с использованием GeometryModel3D.BackMaterial), если только пользователь действительно не будет видеть изнанку объекта. Аналогично, при определении сеток постарайтесь исключить невидимые треугольники (как, например, нижнюю поверхность куба). • Отдавайте предпочтение сплошным кистям, градиентным кистям и кистям ImageBrush перед кистями DrawingBrush и VisualBrush — обе они требуют больших накладных расходов. При использовании DrawingBrush и VisualBrush для рисования статического содержимого можно кэшировать содержимое кистей для повышения производительности. Чтобы сделать это, установите присоединенное свойство кисти RenderOptions.CachingHint в Cache. Выполняя эти несложные рекомендации, можно обеспечить максимально возможную производительность при отображении трехмерной графики и максимально возможную частоту кадров для трехмерной анимации.
Глава 27. Трехмерная графика 857 Шаровой манипулятор Одним из наиболее востребованных способов поведения трехмерных сцен является возможность вращения объекта с помощью мыши. Самой распространенной реализацией такого поведения является так называемый виртуальный шаровой манипулятор (trackball), который присутствует во многих программах трехмерной графики. Хотя WPF не имеет встроенную реализацию виртуального шарового манипулятора, команда разработчиков WPF предоставила свободно распространяемый класс-пример, выполняющий эту функцию. Виртуальный шаровой манипулятор — это надежный, исключительно популярный фрагмент кода, включенный в большинство демонстрационных трехмерных приложений, предложенных командой WPF. Базовый принцип шарового манипулятора заключается в том, что пользователь щелкает кнопкой мыши где-то на поверхности трехмерного объекта и поворачивает его вокруг воображаемой центральной оси. Степень поворота зависит от расстояния перетаскивания мыши. Например, если щелкнуть посредине правой части Viewport 3D и перетащить курсор мыши влево, то трехмерная сцена повернется вокруг воображаемой вертикальной оси. Если продолжить перемещение мыши влево, то трехмерная сцена повернется на 180 градусов, отобразив заднюю сторону, как показано на рис. 27.20. Перетащите сюда Щелкните здесь Рис. 27.20. Изменение окна обзора с помощью виртуального шарового манипулятора Хотя кажется, что виртуальный шаровой манипулятор поворачивает трехмерную сцену, на самом деле он перемещает камеру. Камера всегда остается на одном и том же расстоянии от центральной точки трехмерной сцены. По сути, камера перемещается по поверхности большой сферы, включающей всю сцену. За описанием работы виртуального шарового манипулятора WPF и необходимых вычислений обращайтесь по адресу http://viewport3d.com/trackball.htm. Код виртуального шарового манипулятора вместе с упомянутыми ранее проектами трехмерных инструментов доступен для загрузки по адресу http://www.codeplex.com/3DTools. На заметку! Поскольку виртуальный шаровой манипулятор перемещает камеру, его не следует использовать совместно с собственной анимацией перемещения камеры. Однако его можно применять с анимированной трехмерной сценой (например, с описанной выше сценой, где вращается тор).
858 Глава 27. Трехмерная графика Использовать виртуальный шаровой манипулятор чрезвычайно легко. Все, что нужно для этого — поместить объект Viewport 3D в оболочку класса TrackballDecorator. Класс TrackballDecorator включен в проект трехмерных инструментов, так что начать следует с добавления XML-псевдонима для пространства имен: <Window xmlns:tools="clr-namespace:_3DTools;assembly=3DTools" ... > После этого легко добавить TrackballDecorator в код разметки: <tools:TrackballDecorator> <Viewport3D> </Viewport3D> </tools:TrackballDecorator> После выполнения этого шага автоматически становится доступной функциональность виртуального шарового манипулятора. Просто щелкайте кнопкой мыши и перетаскивайте. Проверка попадания Рано или поздно понадобится создать интерактивную трехмерную сцену — такую, где пользователь сможет щелкать на трехмерных фигурах для выполнения различных действий. Первым шагом в ее реализации является проверка попадания (hit testing) — процесс, с помощью которого перехватывается щелчок кнопкой мыши и определяется, в какой конкретно области он был совершен. Проверка попадания проста в двухмерном мире, но не так проста в Viewport3D. К счастью, WPF предлагает развитую поддержку трехмерной проверки попадания. Доступны три варианта выполнения такой проверки в трехмерной сцене. • Можно обрабатывать события мыши в окне просмотра (вроде MouseUp или MouseDown) и вызывать метод VisualTreeHelper.HitTest() для определения того, какой объект затронут событием. В первой версии WPF (вышедшей в .NET 3.0) это был единственно возможный подход. • Можно создать собственный трехмерный элемент управления, унаследовав собственный класс от абстрактного класса UIElement3D. Этот подход работает, но требует значительного объема работы. Реализовать все детали поведения UIElement нужно будет самостоятельно. • Можно заменить один из объектов ModelVisual3D объектом ModelUIElement3D. Класс ModelUIElement3D унаследован от UIElement3D. Он соединяет в себе универсальную трехмерную модель, которая использовалась до сих пор, с интерактивными возможностями элемента WPF, включая обработку мыши. Чтобы понять, как работает трехмерная проверка попадания, необходимо рассмотреть простой пример. В следующем разделе проверка попадания будет добавлена к знакомому примеру с тором. Проверка попадания в окне просмотра Чтобы использовать первый подход, понадобится присоединить обработчик событий к одному из событий мыши Viewport3D, такому как MouseDown: <Viewport3D MouseDown="viewport_MouseDown"> Здесь используется самый простой вариант кода проверки попадания. Он принимает текущую позицию курсора мыши и возвращает ссылку на ModelVisual3D верхнего уровня, перехватившего точку (если таковой имеется):
Глава 27. Трехмерная графика 859 private void viewport_MouseDown(object sender, MouseButtonEventArgs e) { Viewport3D viewport = (Viewport3D)sender; Point location = e.GetPosition(viewport); HitTestResult hitResult = VisualTreeHelper.HitTest(viewport, location); if (hitResult '= null && hitResult.VisualHit == ringVisual) { // Выполнен щелчок на кольце. } } Хотя этот код работает в простых примерах, обычно его недостаточно. Как уже известно, почти всегда лучше комбинировать несколько объектов в одном ModelVisual3D. Во многих случаях все объекты сцены будут помещены в один ModelVisual3D, потому такое попадание не предоставит достаточной информации. К счастью, если щелчок попадает на сетку, можно привести HitTestResult к более мощному объекту RayMeshGeometry3DHitTestResult. И тут уже можно определить, на какой объект Model3D пришелся щелчок, используя RayMeshGeometry3DHitTestResult: RayMeshGeometry3DHitTestResult meshHitResult = hitResult as RayMeshGeometry3DHitTestResult; if (meshHitResult != null && meshHitResult.ModelHit == ringModel) { // Выполнен щелчок на кольце. } Для еще более тонкой проверки попадания можно воспользоваться свойством MeshHit, чтобы определить, на какой сетке выполнен щелчок. В следующем примере код определяет, была ли затронута сетка, представляющая тор. Здесь присутствует некоторый трюк — оси вращения установлены так, что они проходят через центр тора, перпендикулярно воображаемой линии, соединяющей центр тора с точкой, в которой был выполнен щелчок. В результате получается эффект, будто тор получил "толчок" и отскочил от щелчка кнопкой мыши, слегка повернувшись в противоположном направлении. Ниже показан код, реализующий описанный выше эффект: private void viewport_MouseDown(object sender, MouseButtonEventArgs e) { Viewport3D viewport = (Viewport3D)sender; Point location = e.GetPosition(viewport); HitTestResult hitResult = VisualTreeHelper.HitTest(viewport, location); RayMeshGeometry3DHitTestResult meshHitResult = hitResult as RayMeshGeometry3DHitTestResult; if (meshHitResult != null && meshHitResult.MeshHit == ringMesh) { // Установить ось для вращения. axisRotation.Axis = new Vector3D( -meshHitResult.PointHit.Y, meshHitResult.PointHit.X, 0); // Запустить анимацию. DoubleAnimation animation = new DoubleAnimation (); animation.To =40; animation. DecelerationRatio = Inanimation. Duration = TimeSpan.FromSeconds@.15); animation.AutoReverse = true; axisRotation.BeginAnimation(AxisAngleRotation3D.AngleProperty, animation); } }
860 Глава 27. Трехмерная графика Этот подход к проверке попадания работает исключительно хорошо. Однако если сцена содержит большое количество трехмерных объектов, и необходимое взаимодействие с этими объектами простое (например, при наличии десятка кнопок), такой подход к проверке попадания требует больше работы, чем действительно нужно. В такой ситуации лучше воспользоваться классом ModelUIElement3D, который представлен в следующем разделе. Класс ModelUIElement3D ModelUIElement3D — это тип Visual3D. Как и все объекты Visual3D, он может быть помещен в контейнер Viewport 3D. На рис. 27.21 показана иерархия наследования для всех классов-наследников Visual3D. Три ключевых наследника Visual3D — это ModelVisual3D (который использовался до сих пор), UIElement3D (который определяет трехмерный эквивалент элемента WPF) и Viewport2DVisual3D (позволяющий помещать двухмерное содержимое в трехмерную сцену, как описано в разделе "Двухмерные элементы на трехмерных поверхностях" далее в главе). DispatcherObject DependencyObject 1 v^ Условные ш обозначения ш г Абстрактный Л ^ класс ) Конкретный класс _у Visual3D ModelVisual3D UlElementtD 1 Viewport2DVisual3D ModelUIEIement3D ContainerUIEIement3D Рис. 27.21. Визуальные классы 3-D Класс UIElement3D играет роль, аналогичную роли класса UIElement в двухмерном мире, добавляя поддержку событий мыши, клавиатуры и пера, наряду с отслеживанием фокуса. Однако UIElement3D не поддерживает никакой системы компоновки. Класс UIElement3D, его наследники, а также класс Viewport2DVisual3D — это нововведение WPF 3.5. Хотя можно создавать собственные трехмерные элементы, наследуя их от UIElement 3D, намного проще использовать готовые классы, унаследованные от UIElement3D, а именно — ModelUIElement3D и ContainerUIElement3D. Применение ModelUIElement3D не слишком отличается от использования хорошо знакомого класса ModelVisual3D. Класс ModelUIElement3D поддерживает трансформации (через свойство Transform) и позволяет определять его форму с помощью объекта GeometryModel3D (устанавливая свойство Model, а не Content, как это делается с ModelVisual3D).
Глава 27. Трехмерная графика 861 Проверка попадания с помощью ModelUlElement3D Прямо сейчас тор состоит из единственного ModelVisual3D, который содержит Model3DGroup. Эта группа включает геометрию тора и источник света, освещающий его. Для изменения примера с тором так, чтобы он использовал ModelUIElement3D, нужно просто заменить ModelVisual3D, представляющий тор, на ModelUIElement3D: <Viewport3D x:Name="viewport"> <Viewport3D.Camera>...</Viewport3D.Camera> <ModelUIElement3D> <ModelUIElement3D .Model> <Model3DGroup>...<Model3DGroup> </ModelUIElement3D.Model> </ModelUIElement3D> </Viewport3D> Теперь можно производить проверку попадания непосредственно с помощью ModelUIElement3D: <ModelUIElement3D MouseDown="ringVisual_MouseDown"> Отличие этого примера от предыдущего в том, что теперь событие MouseDown будет возникать только при щелчке на кольце (а не всякий раз, когда щелчок происходит в окне просмотра). Однако код обработки событий в этом примере все еще требует небольшой доработки для достижения нужного результата. Событие MouseDown предоставляет обработчику события стандартный объект MouseButtonEventArgs. Этот объект содержит стандартные детали события мыши, такие как точное время возникновения события, состояние кнопок мыши и метод GetPositionO, который позволяет определять координаты щелчка относительно любого элемента, реализующего интерфейс IlnputElement (такого как Viewport 3D или MouseUIElement3D). Во многих случаях эти двухмерные координаты — именно то, что нужно. (Например, они обязательны, если используется двухмерное содержимое на трехмерной поверхности, как описано в следующем разделе. В этом случае при каждом перемещении, изменении размеров или создании элементы позиционируются в двухмерном пространстве, которое затем отображается на трехмерную поверхность на основе предварительно установленных координат текстуры.) Однако в текущем примере важно получить трехмерные координаты на сетке тора, чтобы можно было создать соответствующую анимацию. Это значит, что придется воспользоваться методом VisualTreeHelper.HitTestO, как показано ниже: private void ringVisual_MouseDown(object sender, MouseButtonEventArgs e) { // Получить двухмерные координаты относительно окна просмотра. Point location = е.GetPosition(viewport); // Получить трехмерные координаты относительно сетки. RayMeshGeometry3DHitTestResult meshHitResult = (RayMeshGeometry3DHitTestResult)VisualTreeHelper.HitTest( viewport, location); // Создать анимацию. axisRotation.Axis = new Vector3D( -meshHitResult.PointHit.Y, meshHitResult.PointHit.X, 0) ; DoubleAnimation animation = new DoubleAnimation(); animation.To = 40; animation. DecelerationRatio = Inanimation. Duration = TimeSpan.FromSeconds@.15); animation.AutoReverse = true; axisRotation.BeginAnimation(AxisAngleRotation3D.AngleProperty, animation); }
862 Глава 27. Трехмерная графика Используя такое реалистичное поведение трехмерных фигур, можно создать настоящий трехмерный "элемент управления", например, кнопку, которая деформируется при щелчке на ней. Если просто нужно реагировать на щелчки на трехмерном объекте, не выполняя вычисления, связанные с сеткой, то можно вообще не использовать VisualTreeHelper. Тот факт, что инициировано событие MouseDown, сообщает о том, что был выполнен щелчок на торе. Совет. В большинстве случаев ModelUIElement3D предлагает простой подход к проверке попадания, который использует события мыши в окне просмотра. Если просто нужно определить, когда выполнен щелчок на определенной фигуре (например, имеется трехмерная фигура, представляющая кнопку и инициирующая какое-то действие), то класс ModelUIElement3D подходит для этого как нельзя лучше. С другой стороны, если необходимо произвести сложные вычисления с координатами щелчка или проверять все фигуры, существующие в месте щелчка (а не только самую верхнюю), то понадобится более сложный код проверки попадания, и скорее всего потребуется обеспечить реакцию на события мыши в окне просмотра. Класс Conta±nerUIElement3D Класс ContainerUIElement3D предназначен для представления единственного объекта, подобного элементу управления. Чтобы поместить в трехмерную сцену более одного ModelUI Element 3D и позволить пользователю взаимодействовать с ними независимо, потребуется создать объекты ModelUIElement3D и упаковать их в единственный экземпляр ContainerUIElement3D. Затем добавить этот ContainerUIElement3D может быть добавлен в окно просмотра. ContainerUIElement3D обладает еще одним преимуществом. Он поддерживает любую комбинацию объектов, унаследованных от Visual 3D. Это значит, что он может содержать обычные объекты ModelVisual3D, интерактивные объекты ModelUIElement3D и объекты Viewport2DVisual3D, которые представляют двухмерные элементы, помещенные в трехмерное пространство. Более подробно этот трюк описан в следующем разделе. Двухмерные элементы на трехмерных поверхностях Как уже известно, для помещения двухмерного содержимого кистей на трехмерную поверхность можно использовать отображения текстур. К этому приему можно прибегнуть для встраивания изображений и видео в трехмерные сцены. Используя VisualBrush, можно даже взять визуальное представление обычного элемента WPF (вроде кнопки) и поместить его в трехмерную сцену. Однако объект VisualBrush по определению ограничен. Как было показано, VisualBrush может копировать визуальное представление элемента, но не может его дублировать. Если использовать кисть VisualBrush для помещения изображения кнопки в трехмерную сцену, то просто получится трехмерное изображение кнопки. Другими словами, щелки на ней невозможны. Решение этой проблемы дает класс Viewport2DVisual3D. Этот класс упаковывает другой элемент и отображает его на трехмерную поверхность, используя отображение текстур. Объект Viewport 2D Visual 3D можно поместить непосредственно на Viewport3D, наряду с другими объектами Viewport3D (вроде ModelVisual3D и ModelUIElement3D). Однако элемент внутри Viewport2DVisual3D сохраняет свою интерактивность и поддерживает все средства WPF, к которым вы привыкли, включая компоновку, стилизацию, шаблоны, события мыши, технологию перетаскивания и т.д.
Глава 27. Трехмерная графика 863 На рис. 27.22 показан пример. Панель StackPanel, содержащая элементы TextBlock, Button и TextBox, помещена на одну из граней трехмерного куба. Пользователь вводит текст в TextBox, и можно наблюдать I-образный курсор, указывающий позицию ввода. Рис. 27.22. Интерактивные элементы WPF в трехмерной сцене Внутри Viewport3D можно разместить все обычные объекты ModelVisual3D. В примере, показанном на рис. 27.21, есть ModelVisual3D для куба. Чтобы поместить двухмерный элемент в эту сцену, используйте взамен объект Viewport2DVisual3D. Класс Viewport2DVisual3D включает свойства, перечисленные в табл. 27.5. Таблица 27.5. Свойства Viewport2DVisual3D Имя Описание Geometry Сетка, определяющая трехмерную поверхность Visual Двухмерный элемент, который будет помещен на трехмерную поверхность. Можно применять только один элемент, но вполне допустимо использовать панель-контейнер, чтобы упаковать несколько элементов вместе. В примере на рис. 27.22 используется элемент Border, содержащий панель StackPanel с тремя дочерними элементами Material Материал, используемый для отображения двухмерного содержимого. Обычно будет использоваться Dif f useMaterial. Чтобы материал мог отображать содержимое элемента, понадобится установить в true присоединенное свойство Viewport2DVisual3D. IsVisualHostMaterial на DiffuseMaterial Transform Transform3D или Transform3Dgroup, определяющие возможные изменения сетки (вращение, масштабирование, искривление и т.п.) Использование приема помещения двухмерного содержимого на трехмерную поверхность относительно просто, если только известно, что собой представляет отображение текстур (рассматривалось в разделе "Отображение текстур" ранее в главе). Ниже приведен код разметки, который создает элемент WPF, показанный на рис. 27.21.
864 Глава 27. Трехмерная графика <Viewport2DVisual3D> <Viewport2DVisual3D.Geometry> <MeshGeometry3D Positions=M0,0,10 0,0,0 0,10,10 0,10,0м TriangleIndices=,1,2 2,1,3" TextureCoordinates = Ml, 1 0,1 1,0 0,0м /> </Viewport2DVisual3D.Geometry> <Viewport2DVisual3D.Material> <DiffuseMaterial Viewport2DVisual3D.IsVisualHostMaterial=MTrueM /> </Viewport2DVisual3D.Material> <Viewport2DVisual3D.Visual> <Border BorderBrush=MYellowM BorderThickness=MlM> <StackPanel Margin=M10M> <TextBlock Margin=M3M>This is 2-D content on a 3-D surface.</TextBlock> <Button Margin=M3M>Click Me</Button> <TextBox Margin=M3M>[Enter Text Here]</TextBox> </StackPanel> </Border> </Viewport2DVisual3D.Visual> <Viewport2DVisual3D.Transform> <RotateTransform3D> <RotateTransform3D.Rotation> <AxisAngleRotation3D Angle=M{Binding ElementName=sliderRotate, Path=Value}" Axis=M0 1 0" /> </RotateTransform3D.Rotation> </RotateTransform3D> </Viewport2DVisual3D.Transform> </Viewport2DVisual3D> В данном примере свойство Viewport2DVisual3D.Geometry задает сетку, отражающую одну грань куба. TextureCoordinates сетки определяет то, как двухмерное содержимое (Border — оболочка StackPanel) должно быть отражено на трехмерной поверхности (грани куба). Отображение текстуры, используемое с Viewport2DVisual3D, работает точно так же, как отображение текстуры, использованное ранее с ImageBrush и VisualBrush. На заметку! При определении TextureCoordinates важно убедиться в том, что имеется элемент, повернутый к камере. WPF ничего не визуализирует для обратной стороны Viewport2DVisual3D, так что если заглянуть в его изнанку, элемент исчезнет. (Если это не то, что нужно, можно воспользоваться другим объектом Viewport2DVisual3D для создания содержимого изнанки.) В этом примере также используется трансформация RotateTransform3D, которая позволяет пользователю поворачивать куб с помощью ползунка под Viewport3D. Класс ModelVisual3D, представляющий куб, включает ту же самую трансформацию RotateTransform3D, так что куб и содержимое двухмерного элемента двигаются вместе. В нынешнем виде в этом примере отсутствует какая-либо обработка событий в содержимом Viewport2DVisual3D. Тем не менее, обработчик событий добавить нетрудно: <Button Margin=M3M Click=Mcmd_ClickM>Click Me</Button> Система WPF обрабатывает события мыши интеллектуальным образом. Она использует отображение текстуры для трансляции виртуальных трехмерных координат (в которых находится курсор мыши) на обычные двухмерные координаты без отображения текстур.
Глава 27. Трехмерная графика 865 С точки зрения элемента, события мыши в трехмерном мире точно такие же, как и в двухмерном. Эта часть "магии" удерживает решение как одно целое. Совет. За более изощренным примером двухмерного содержимого на трехмерной поверхности обращайтесь по адресу http://tinyurl.com/3cnfxx. Там предлагается вращающийся глобус, который позволяет устанавливать маркеры (с текстовым описанием) в произвольных местах. Все содержимое этого примера состоит из двухмерных элементов, отображенных на трехмерное пространство. Резюме Поддержка трехмерной графики — одна из жемчужин платформы WPF. Предыдущие высокоуровневые наборы инструментов разработки, такие как Windows Forms, вообще обходились без поддержки 3-D, оставляя ее фанатам DirectX. Фактически в трехмерных средствах WPF наиболее впечатляет простота работы с ними. Хотя можно создать сложный код, генерирующий и модифицирующий трехмерные сетки с привлечением серьезного математического аппарата, но с тем же успехом можно просто экспортировать трехмерные модели из инструмента проектирования и манипулировать ими с помощью простых трансформаций. Ключевые средства, подобные реализации виртуального шарового манипулятора и интерактивных двухмерных элементов, обеспечиваются высокоуровневыми классами, не требующими глубоких знаний. В этой главе был представлен обзор основ поддержки трехмерной графики в WPF, a также ряд независимых инструментов, которые появились после выхода в свет WPF 1.0. Однако программирование трехмерной графики — очень объемная тема, которая требует намного более глубокого погружения в теорию 3-D. Если интересует математика, положенная в основу разработки 3-D, стоит обратить внимание на книгу Флетчера Данна (Fletcher Dunn) 3D Math Primer for Graphics and Game Development (Wordware Publishing, 2002 г.). Доступны также книги, посвященные программированию трехмерной графики bWPF Простейший способ продолжить знакомство с миром 3-D — это обратиться к ресурсам и коду примеров, предоставленных командой WPF и другими независимыми разработчиками. Ниже приведен краткий список полезных ссылок, часть из которых уже упоминалась в этой главе. • http://www.codeplex.com/3DTools предлагает ценную библиотеку инструментов для разработчиков, выполняющих трехмерные работы в WPF, включая виртуальный шаровой манипулятор и класс ScreenSpaceLines3D, обсуждавшийся в этой главе. • http://blogs.msdn.com/mswanson/articles/WPFToolsAndControls.aspx предоставляет список инструментов WPF, включая программы трехмерного дизайна, которые используют XAML естественным образом и экспортируют сценарии, преобразующие в XAML другие форматы 3-D (включая Maya, LightWare, Blender и 3ds). • http://blogs.msdn.com/danlehen/archive/2005/10/16/481597.aspx включает классы, служащие оболочками сетки, которые необходимы для трех распространенных примитивов 3-D: конуса, сферы и цилиндра. • http://windowsclient.net/downloads/folders/wpfsamples/entry3743.aspx предоставляет проект SandBox3D, позволяющий загружать простые трехмерные сетки и манипулировать ими в трансформациях.
ГЛАВА 28 Документы Навыки работы с WPF, полученные вами к настоящему моменту, позволяют создавать окна и страницы, содержащие самые разнообразные элементы. Вывести фиксированный текст несложно — для этого нужно лишь добавить элементы TextBlock и Label. Однако элементы TextBlock и Label не годятся для отображения больших объемов текста (например, газетной статьи или подробных инструкций оперативной справки). Самые сложные проблемы с большими объемами текста возникают тогда, когда нужно максимально аккуратно поместить текст в окне с изменяемыми размерами. Например, если поместить огромный кусок текста в элемент TextBlock, а затем растянуть этот элемент по всей ширине окна, то в результате образуются длинные строки, читать которые просто сложно. А если объединить текст и рисунки с помощью обычных элементов TextBlock и Image, то при изменении размеров окна выравнивание может исказиться. Чтобы справиться со всеми этими трудностями, WPF предлагает набор высокоуровневых средств, которые работают с документами. Эти средства позволяют отображать большие объемы содержимого так, чтобы их было легко читать независимо от размеров окна, в котором они находятся. К примеру, WPF может переносить слова (если места мало) или оформлять текст в несколько колонок (если места достаточно). В этой главе вы научитесь использовать потоковые документы для вывода содержимого. Вы узнаете также, как позволить пользователям редактировать содержимое потоковых документов с помощью элемента управления RichTextBox. После этого мы кратко рассмотрим XPS — новую технологию от Microsoft, которая позволяет создавать документы, пригодные для печати. В конце главы речь пойдет об аннотациях — средстве, которое позволяет добавлять в документы комментарии и другие пометки и постоянно хранить их там. Документы Документы в WPF делятся на две крупных категории. • Фиксированные документы. К этой категории относятся набранные документы, пригодные к печати. Расположение всего содержимого фиксировано (например, нельзя изменить способ разрыва строк и переноса слов). Фиксированные документы можно читать с монитора, но в первую очередь они предназначены для печати на принтере. В принципе они эквивалентны PDF-файлам Adobe. В WPF имеется один тип фиксированных документов, в котором используется стандарт Microsoft XPS (XML Paper Specification — XML-спецификация печатных документов).
Глава 28. Документы 867 • Потоковые документы. Это документы, предназначенные для просмотра на экране монитора. Как и фиксированные документы, потоковые документы поддерживают расширенную компоновку. Однако WPF может оптимизировать потоковый документ на основе указанного способа просмотра документа: динамически компоновать содержимое на основе такой информации, как размеры окна просмотра, разрешение экрана и т.д. В принципе потоковые документы в основном используются для тех же целей, что и документы HTML, но обладают более совершенными возможностями компоновки текста. Понятно, что потоковые документы более важны для создания приложений, а фиксированные — для создания документов, которые нужно распечатывать без изменений (например, форм и публикаций). WPF обеспечивает поддержку обоих видов документов посредством различных контейнеров. Элементы DocumentViewer позволяют выводить фиксированные документы в окне WPF, a FlowDocumentReader, FlowDocumentPageViewer и FlowDocumentScrollViewer предоставляют разные способы отображения потоковых документов. Каждый из этих контейнеров доступен только для чтения. Однако в составе WPF имеются API для программного создания фиксированных документов, а элемент RichTextBox позволяет пользователям редактировать потоковое содержимое. В этой главе мы уделим основное внимание изучению потоковых документов и способам их применения в WPF-приложениях. Но в конце главы будут рассмотрены и более простые фиксированные документы. Потоковые документы В потоковом документе содержимое адаптируется к содержащему его контейнеру. Потоковое содержимое ориентировано на экранный просмотр и лишено многих недостатков простых HTML-документов. Обычно HTML-содержимое использует потоковую компоновку для заполнения окна браузера. (WPF упорядочивает элементы точно так же, если используется WrapPanel.) Такой подход является очень гибким, но он годится лишь для ограниченного набора размеров окон. Если развернуть окно на весь экран монитора с высоким разрешением (или, что еще хуже, широкоформатного монитора), получатся длинные неудобочитаемые строки. Эта проблема демонстрируется на рис. 28.1 на примере одной из веб-страниц Wikipedia. Wikipedia From AiHpedia the free enc,i-iopedia Wikipedia is a - nil hnjual based iu; alio -mg most articles to b-> rhanged t, almos Wikipedia *as launched as an t< ' nj> r „ и ли-п It was oeated c, i -11 to create and distnbule a multi lingual free »nc nub hi-i ,'lupprl a project The name an one ith access to the ",eb site 1 pioject on J-изг 1 ti! 1 as а со i j and J I Sangei iesi ,clopedia of the highest possible qualit, 8 a rurt s nam - '■anlF-ii, of the words -r = . are in Г. и and г / a Wikipedia г i г 11 tvith additional sei ers m -nplement to the expert jntten and no , gned fror to e тэг Currentl, Wikipedia has more than ft e million artirles in man^ languages including moie than ui inn There are 250 language editions of wikipedia and 16 of them ha e moie than been proposals for an English 0V0 01 print edi ranks among the top fifteen mo9t isited sites p both l|u 1 i and defunct mipediB on t'nr single person on the planet in 1 5 million in the f-'i 50 000 articles each The j. ion Since its inception Wikipedia has steadiK and many of its pages ha»e been -m< H or I. risen in popularity <• 1 bj other sites s i, I.H_L ПП I inr then о ' j- i idqo с h and has spawned ch as -n 'F qri i - - and is i ^ _ «ale i language "i and moi» th in has been di s written collaraoraluel - tf i and no ; operated trs has descnosd . jn half a million ir tnbuted on L\u serial sister projects Accaidmg the ь> .ii'ioedia as the pjri to Д1 hintn-irs 1 an effort 1 II ,.!П,| and there have i V ikipedia Рис. 28.1. Длинные строки в потоковом содержимом На многих сайтах с этой проблемой справляются с помощью какой-либо фиксированной компоновки, когда содержимое помещается в узкие колонки. (В WPF это тоже можно сделать, поместив содержимое в колонку в контейнере Grid и задав свойство ColumnDefinition.MaxWidth.) Это действительно повышает удобство чтения содержи-
868 Глава 28. Документы мого, но в больших окнах появляется пустое место. Такой вариант приведен на рис. 28.2 на примере веб-страницы газеты New York Times. Looking for a Gambit to Win at Google's Game «ANMU. There is a lot about the way Micisaaft has run its Internet business *» P*T that Steve Berkoivitz wants to change. But he is finding that redirecting 8 *»**.« **g* such a behemoth is slow going £ *e*4im ^, Eniarge тыс tmaoe "I'm used to being in companies where Щ I am in a rowboat and I stick an oar in 1 . ■■ ■ the water to change direction.'said Mr. ^|| ■ Berkowitz. who ran the Ask Jeeves ^Q g^gf 1 search engine until Microsoft hired him away in April to ^A ■ run its online services unit. 'Now I m in a cruise ship and I ^A I have to caD down. 'Hello, engine room! " he adds with an ■ echo in his voice 'Sometimes the connections to the engine ■ room aren t there." Рис. 28.2. Незанятое пространство в потоковом документе Для содержимого потокового документа в WPF добавлены усовершенствования этих современных подходов: лучший механизм разбиения на страницы, вывод в несколько колонок, более разумные алгоритмы переноса слов и обтекания текста, а также предпочтительные режимы отображения, выбираемые пользователем. В результате пользователю предоставляется более удобная среда для чтения больших объемов содержимого. Потоковые элементы Потоковый документ WPF создается с помощью сочетания потоковых элементов. Потоковые элементы существенно отличаются от элементов, с которыми вы имели дело до настоящего момента, тем, что они не являются наследниками знакомых вам классов UIElement и FrameworkElement. Они формируют совершенно отдельную ветвь классов, порожденных от ContentElement и FrameworkContentElement. Классы элементов вывода содержимого проще, чем классы элементов, не связанных с содержимым, которые рассматривались до сих пор в данной книге. Но они также поддерживают похожий набор базовых событий (включая события клавиатуры и мыши), операции перетаскивания, отображение всплывающих подсказок и инициализацию. Ключевым отличием элементов вывода содержимого от элементов, не связанных с содержимым, является то, что первые не выполняют свою прорисовку. Для этого им необходим контейнер, который сможет отобразить все элементы вывода содержимого. Такая отложенная прорисовка позволяет контейнеру выполнять разнообразную оптимизацию. Например, контейнер может выбрать наиболее подходящий способ разрыва строк текста в абзаце, даже если этот абзац является единственным элементом. На заметку! Элементы вывода содержимого могут принимать фокус, хотя обычно этого не происходит (т.к. свойство Focusable по умолчанию имеет значение false). Чтобы элемент вывода содержимого мог принимать фокус, присвойте этому свойству значение true, примените стиль типа элемента, изменяющий целую группу элементов, или создайте потомка вашего собственного специального элемента, свойство Focusable которого равно true. Примером элемента вывода содержимого, свойство Focusable которого имеет значение true, является элемент Hyperlink. На рис. 28.3 показана иерархия наследования элементов вывода содержимого.
Глава 28. Документы 869 Dispatcher-Object I DependencyObject i ContentElement Условные обозначения I Абстрактный класс •) Конкретный класс FrameworkContentElement FlowDocument TextElement TableColumn Block r-1 Paragraph Ust Listltem H TableCell Inline H TableRow 1— TableRowGroup Section H Table H BlockUIContainer Run Span LineBreak H InlineUIContainer AnchoredBlock | i i Figure Floater Рис. 28.3. Элементы вывода содержимого Элементы вывода содержимого делятся на две категории: • Блочные элементы. Могут применяться для группирования других элементов вывода содержимого. Например, Paragraph (абзац) является блочным элементом. Он может хранить текст разного формата. Каждый раздел текста с отличным форматом является отдельным элементом в абзаце. • Строковые элементы. Эти элементы находятся внутри блочных элементов (или других строковых элементов). Например, элемент Run (фрагмент) содержит текст, который затем может быть вложен в элемент Paragraph. Модель содержимого позволяет строить несколько уровней вложения. К примеру, внутрь элемента Underline можно поместить элемент Bold, чтобы создать жирный подчеркнутый текст. Аналогично можно создать элемент Section, а внутри него несколько элементов Paragraph, каждый из которых содержит разнообразные строковые элементы с текстовым содержимым. Все эти элементы определены в пространстве имен System. Windows.Documents.
870 Глава 28. Документы Совет. Если вы знакомы с HTML, то эта модель покажется вам весьма знакомой. В WPF принято очень много похожих соглашений (например, различие между блочными и строковыми элементами). Если же вы очень хорошо знаете HTML, можете попробовать воспользоваться на удивление мощным транслятором из HTML в XAML, который доступен по адресу http://wpf.netfx3.com/ f iles/folders/developer/entry816.aspx. Этот транслятор, реализованный на базе С#, позволяет использовать HTML-страницу в качестве заготовки потокового документа. Форматирование элементов вывода содержимого Хотя элементы вывода содержимого не разделяют ту же иерархию классов, что и элементы, не связанные с содержимым, у них есть много таких же свойств форматирования, как и у обычных элементов. В табл. 28.1 перечислены некоторые свойства, которые присущи элементам, не связанным с содержимым. Таблица 28.1. Базовые свойства форматирования для элементов вывода содержимого Имя Описание Foreground и Принимают кисти, которые будут применяться для прорисовки Background текста переднего плана и фоновой поверхности. Свойство Background можно задать для объекта FlowDocument, содержащего всю разметку документа FontFamily, Позволяют точно настроить шрифт, используемый для вывода Font Size, текста. Эти свойства можно задать для объекта FlowDocument, FontStretch, содержащего всю разметку документа FontStyle и FontWeight ToolTip Позволяет задать всплывающую подсказку, которая будет появляться на экране при наведении указателя мыши на данный элемент. Можно указать текстовую строку или полный объект ToolTip, как описано в главе 6 Style Указывает стиль, который следует использовать для автоматического задания свойств элемента Блочные элементы имеют дополнительные свойства, перечисленные в табл. 28.2. Таблица 28.2. Дополнительные свойства форматирования для блочных элементов Имя Описание BorderBrush и Позволяют создать рамку, которая будет отображаться по краям BorderThickness элемента Margin Задает промежуток между текущим элементом и его контейнером (или любыми соседними элементами). Если поле не задано, потоковые контейнеры добавят между блочными элементами и краями контейнера промежуток, по умолчанию равный 18 единицам. При желании можно явно задать меньшие промежутки. А чтобы сократить пробел между двумя абзацами, потребуется уменьшить и нижнее поле первого абзаца, и верхнее поле второго абзаца. Если нужно уменьшить поля всех абзацев, попробуйте использовать стиль типа элемента, который действует на все абзацы Padding Задает отступ между краями и любыми вложенными элементами. По умолчанию равно О
Глава 28. Документы 871 Окончание табл. 28.2 Имя Описание Text Alignment Задает горизонтальное выравнивание вложенного текстового содержимого (может принимать значения Left (влево), Right (вправо), Center (по центру) или Justify (по краям)). Обычно содержимое выравнивается по краям LineHeight Задает промежуток между строками во вложенном текстовом содержимом. Высота строки определяется в не зависящих от устройства пикселях. При отсутствии этого значения текст будет иметь одинарный промежуток, определяемый характеристиками используемого шрифта LineStackingStrategy Определяет промежуток между строками, содержащими шрифты разного размера. По умолчанию имеет значение MaxHeight: строка имеет такую высоту, как и самый высокий текст. Другое значение, BlockLineHeight, задает использование высоты из свойства LineHeight для всех строк, т.е. текст будет располагаться в соответствии с характеристиками шрифта абзаца. Если этот шрифт меньше самого большого шрифта в абзаце, то текст в некоторых строках может перекрываться. Если он имеет такой же размер или больше, то между некоторыми строками образуются дополнительные просветы Наряду со свойствами, описанными в этих двух таблицах, имеются еще некоторые дополнительные детали, которые можно указывать в отдельных элементах. Некоторые из них связаны с разбиением на страницы и колонки и будут рассмотрены в разделе "Страницы и колонки" далее в этой главе. Здесь мы упомянем следующие свойства. • TextDecorations. Это свойство имеется в элементах Paragraph и во всех элементах, порожденных от класса Inline. Оно принимает значения Strikethrough (зачеркивание), Overline (надчеркивание) или чаще Underline (подчеркивание). Можно даже объединить эти значения, чтобы получить в тексте несколько линий сразу, хотя такое встречается редко. • Typography. Этим свойством обладает элемент верхнего уровня FlowDocument, а также наследники классов TextBlock и Text Element. Оно предлагает объект Typography, который позволяет изменить самые разные детали прорисовки текста (большинство из них применимо только к шрифтам ОрепТУре). Создание простого потокового документа Теперь, когда вы уже имеете представление о том, что такое модель элементов вывода содержимого, можно приступить к сборке некоторых элементов вывода содержимого в простой потоковый документ. Потоковый документ создается с помощью класса FlowDocument. Visual Studio позволяет либо создать новый потоковый документ как отдельный файл, либо определить его внутри существующего окна, используя один из поддерживаемых контейнеров. Пока для создания потокового документа воспользуемся контейнером FlowDocumentScrollViewer. Ниже показан первоначальный вид разметки: <Window x:Class="Documents.FlowContent" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2 00 6/xaml" Title="FlowContent" Height=81" Width=25" > <FlowDocumentScrollViewer> <FlowDocument>
872 Глава 28. Документы </FlowDocument> </FlowDocumentScrollViewer> </Window> Совет. На данный момент не существует интерфейса WYSIWYG (What You See Is What You Get — что видишь, то и получаешь) для создания потоковых документов. Некоторые разработчики создают инструменты, которые могут преобразовывать файлы, написанные в Word 2007 XML (известном как WordML), в файлы XAML посредством разметки потокового документа. Однако такие средства не готовы к массовому производству. А пока можно создать простой текстовый редактор с помощью элемента управления RichTextBox (описанном в разделе "Редактирование потокового документа" далее в этой главе) и использовать его для создания содержимого потокового документа. Можно предположить, что теперь нужно лишь ввести текст внутри элемента FlowDocument, однако это невозможно. Высокоуровневый потоковый документ должен использовать элемент блочного уровня. Ниже показан пример с элементом Paragraph: <FlowDocumentScrollViewer> <FlowDocument> <Paragraph>Hello, world of documents.</Paragraph> </FlowDocument> </FlowDocumentScrollViewer> На количество элементов верхнего уровня нет ограничений. Поэтому для данного примера с двумя абзацами можно применить следующую разметку: <FlowDocumentScrollViewer> <FlowDocument> <Paragraph>Hello, world of documents.</Paragraph> <Paragraph>This is a second paragraph.</Paragraph> </FlowDocument> </FlowDocumentScrollViewer> На рис. 28.4 представлен результат. Линейка прокрутки добавляется автоматически. Шрифт (Segoe UI) выбирается из системных настроек Windows, а не из вмещающего окна. ■ FlowContent Hello, world of documents. This is a second paragraph. ,.",,;, '. . m :Jmi J Рис. 28.4. Заготовка потокового документа
Глава 28. Документы 873 На заметку! Обычно элемент FlowDocumentScrollViewer позволяет выделять текст (как в веб-браузере). Таким образом пользователь может копировать части документа в буфер обмена Windows и вставлять их в другие приложения. Если это нежелательно, присвойте свойству FlowDocumentScrollViewer.IsSelectionEnabled значение false. Блочные элементы Создать простейший документ нетрудно, но чтобы получить действительно полезный результат, потребуется овладеть разнообразными элементами. Среди них — пять блочных элементов, которые будут описаны в последующих разделах. Элементы Paragraph и Run Вы уже знакомы с элементом Paragraph (абзац), который представляет абзац текста. С технической точки зрения этот элемент не содержит текст: он содержит коллекцию строковых элементов Paragraph.Inlines. Из этого факта можно сделать два вывода. Во-первых, это означает, что абзац может содержать отнюдь не только текст. Во-вторых, это означает, что для того, чтобы абзац мог содержать текст, он должен содержать строковый элемент Run. А элемент Run уже содержит сам текст: <Paragraph> <Run>Hello, world of documents.</Run> </Paragraph> Этот более точный синтаксис не был задействован в предыдущем примере потому, что класс Paragraph сам неявно создает элемент Run, если поместить в него текст. Но в некоторых случаях бывает важно понимать, как ведет себя абзац. Предположим, что требуется извлечь текст из абзаца программным образом, и имеется следующая разметка: <Paragraph Name="paragraph">Hello, world of documents.</Paragraph> Оказывается, класс Paragraph не имеет свойства Text, и не существует способа получить текст из абзаца. Чтобы извлечь текст (или изменить его), нужно работать с вложенным объектом Run: ((Run)paragraph.Inlines.Firstlnline).Text = "Hello again."; Читабельность этого кода можно улучшить с помощью элемента Span, который позволяет заключить подлежащий изменению текст. Затем этому элементу можно присвоить имя и обращаться к нему напрямую. Элемент Span описан в разделе "Строковые элементы". На заметку! В WPF 4 введено небольшое усовершенствование в элемент Run. В предыдущих версиях свойство Run.Text было обычным свойством и поэтому не поддерживало привязку данных. В WPF 4 свойство Run.Text является свойством зависимости, что позволяет установить его с помощью выражения привязки данных. Класс Paragraph содержит свойство Textlndent, которое позволяет задавать величину отступа первой строки в не зависящих от устройства единицах. (По умолчанию оно имеет нулевое значение.) Кроме этого, класс Paragraph содержит еще несколько свойств, которые определяют разбиение строк в местах разрывов колонок и страниц. Об этих свойствах мы поговорим в разделе "Страницы и колонки" далее в этой главе.
874 Глава 28. Документы На заметку! В отличие от HTML, в WPF нет блочных элементов для заголовков. Вместо них нужно просто использовать абзацы с другими размерами шрифтов. Элемент List Элемент List (список) представляет маркированный или нумерованный список. Формат списка определяется с помощью свойства MarkerStyle. Возможные параметры перечислены в табл. 28.3. Кроме того, свойство MarkerOf f set позволяет задать расстояние между каждым элементом списка и его маркером. Таблица 28.3. Значения из перечисления TextMarkerStyle Имя Внешний вид Disc Сплошной кружок. Установлен по умолчанию Box Сплошной квадратик Circle Контурный кружок Square Контурный квадратик Decimal Возрастающее число A,2, 3). Как правило, начинается с 1, хотя с помощью свойства Startinglndex можно начать отсчет с большего числа. Несмотря на название, выводятся целые, а не дробные значения LowerLatin Строчная буква с автоматическим приращением (а, Ь, с и т.д.) UpperLatin Прописная буква с автоматическим приращением (А, В, С и т.д.) LowerRoman Строчная римское число с автоматическим приращением (i, ii, iii, iv и т.д.) UpperRoman Прописное римское число с автоматическим приращением (I, II, III, IV и т.д.) None Ничего В элемент List заключаются элементы Listltem, представляющие отдельные элементы списка. Однако каждый элемент Listltem должен содержать подходящий блочный элемент (например, Paragraph). Ниже показан пример, в котором создаются два списка, один с маркерами, а другой с цифрами: <Paragraph>Top programming languages:</Paragraph> <List> <ListItem> <Paragraph>C#</Paragraph> </ListItem> <ListItem> <Paragraph>C++</Paragraph> </ListItem> <ListItem> <Paragraph>Perl</Paragraph> </ListItem> <ListItem> <Paragraph>Logo</Paragraph> </ListItem> </List> <Paragraph Margin=,30,0,0">To-do list:</Paragraph> <List MarkerStyle="Decimal"> <ListItem> <Paragraph>Program a WPF application</Paragraph> </ListItem>
Глава 28. Документы 875 <ListItem> <Paragraph>Bake bread</Paragraph> </ListItem> </List> Результат показан на рис. 28.5. t FlowContent Top programming languages: • C# • C++ • Perl • Logo To-do list: 1. Program a \VPF application 2. Bake bread Рис. 28.5. Два списка Элемент Table Элемент Table (таблица) предназначен для отображения табличной информации. Он был создан по подобию элемента <table> из языка HTML. Чтобы создать таблицу, необходимо выполнить следующие действия: 1. Поместите в Table элемент TableRowGroup. Элемент TableRowGroup хранит группу строк, и каждая таблица состоит из одного или более элементов TableRowGroup. Сам по себе TableRowGroup ничего не делает. Однако если использовать несколько групп с разным форматированием, вы без труда сможете изменить общий внешний вид таблицы, не занимаясь форматированием каждой строки. 2. В элемент TableRowGroup поместите элементы TableRow для каждой строки. 3. В каждый элемент TableRow поместите элементы TableCell, представляющие элементы строки. 4. В каждый элемент TableCell поместите какой-либо блочный элемент (как правило, Paragraph). И вот в этот блочный элемент можно занести содержимое данной ячейки. Ниже представлены первые две строки таблицы, показанной на рис. 28.6: <Paragraph FontSize=0pt">Largest Cities in the Year 100</Paragraph> <Table> <TableRowGroup Paragraph.TextAlignment="Center"> <TableRow FontWeight="Bold" > <TableCell> <Paragraph>Rank</Paragraph> </TableCell> <TableCell> <Paragraph>Name</Paragraph> </TableCell>
876 Глава 28. Документы <TableCell> <Paragraph>Population</Paragraph> </TableCell> </TableRow> <TableRow> <TableCell> <Paragraph>K/Paragraph> </TableCell> <TableCell> <Paragraph>Rome</Paragraph> </TableCell> <ТаЫеСе11> <Paragraph>450,000</Paragraph> </TableCell> </TableRow> </TableRowGroup> </Table> : • FlowContent Largest Rank l 2 3 4 5 6 7 8 9 • V , Cities in the Year loo Name Rome Luoyang (Honan), China Seleucia (on the Tigris), Iraq Alexandria, Egypt Antioch, Turkey Anuradhapura, Sri Lanka Peshawar, Pakistan Carthage, Tunisia Suzhou, China Smyrna, Turkey Population 450,000 420,000 250,000 250,000 150,000 130,000 120,000 100,000 n/a 90,000 Рис. 28.6. Простая таблица На заметку! В отличие от Grid, ячейки в элементе Table заполняются по их позициям. Необходимо включить элемент TableCell для каждой ячейки в таблице и поместить каждую строку и значение в том порядке, в котором они должны отображаться. Если не указать явным образом ширину столбцов, WPF равномерно распределит пространство между всеми столбцами. Это поведение можно изменить, присвоив свойству Table.Rows набор объектов TableColumn и определив для каждого из них ширину Width. Ниже приведена разметка, которая позволяет в предыдущем примере сделать средний столбец в три раза шире, чем первый и последний: <Table.Columns> <TableColumn Width="*"x/TableColumn> <TableColumn Width=*"x/TableColumn> <TableColumn Width="*"x/TableColumn> </Table.Columns>
Глава 28. Документы 877 С таблицей можно выполнять и другие действия. Свойства ColumnSpan и RowSpan позволяют растягивать ячейки на несколько строк. С помощью свойства Cellspacing таблицы можно задать величину промежутка между ячейками. Кроме того, к отдельным ячейкам можно применить индивидуальное форматирование (например, разные цвета текста и фона). А вот поддержка рамки таблицы выполнена слабее. Можно воспользоваться свойствами BorderThickness и BorderBrush элемента TableCell, однако при этом прорисовывается отдельная рамка вокруг каждой ячейки. Эти рамки выглядят не очень аккуратно, если использовать их для групп смежных ячеек. У элемента Table имеются свойства BorderThickness и BorderBrush, но они позволяют нарисовать рамку только вокруг всей таблицы. Если вы надеялись получить более эффектное представление (например, добавить линии между колонками), то это не получится. Другое ограничение состоит в том, что размеры колонок нужно задавать явно или пропорционально (при помощи уже показанного синтаксиса со звездочкой). Однако применять два подхода одновременно нельзя. Например, невозможно создать две колонки фиксированной ширины и одну пропорциональную колонку, чтобы занять оставшееся пространство, как в элементе Grid. На заметку! Некоторые элементы вывода содержимого похожи на другие элементы, не связанные с содержимым. Однако элементы вывода содержимого предназначены исключительно для использования внутри потокового документа. К примеру, нет смысла заменять элемент Grid элементом Table. Элемент Grid предназначен для наиболее эффективной компоновки элементов управления в окне, a Table служит для представления текста в документе наиболее удобным для чтения способом. Элемент Section Элемент Section (раздел) не имеет собственного встроенного форматирования. Он используется для упаковки других блочных элементов и позволяет применить единый формат ко всей порции документа. Например, если нужно, чтобы несколько смежных абзацев имели одинаковый цвет фона и шрифт, можно упаковать эти абзацы в один раздел и задать свойство Sect ion. Background: <Section FontFamily="Palatino" Background="LightYellow"> <Paragraph>Lorem ipsum dolor sit amet... </Paragraph> <Paragraph>Ut enim ad minim veniam...</Paragraph> <Paragraph>Duis aute lrure dolor in reprehenderit...</Paragraph> </Section> Здесь используется то, что параметры шрифта наследуются содержащимися в разделе абзацами. Значение фона не наследуется, но фон каждого абзаца по умолчанию является прозрачным, и сквозь него виден фон раздела. Более того, можно задать свойство Section.Style, чтобы отформатировать раздел с помощью стиля: <Section Style="IntroText"> Элемент Section аналогичен элементу <div> в языке HTML. Совет. Во многих потоковых документах стили применяются для классификации форматирования содержимого на основе его типа. (Например, на книжном сайте с возможностью предварительного просмотра книг можно создать отдельные стили для заголовков, текста, для выделения врезок и фамилий авторов.) Затем для получения нужного форматирования достаточно определить эти стили.
878 Глава 28. Документы Элемент BlockUIContainer BlockUIContainer позволяет помещать элементы, не связанные с содержимым (классы-наследники UIElement), в документ, где должен помещаться блочный элемент. Например, с помощью BlockUIContainer можно добавить в документ кнопки, флажки и даже целые контейнеры компоновки наподобие StackPanelHGrid. Единственное ограничение — BlockUIContainer может иметь только один дочерний элемент. Вы можете удивиться: а зачем вообще помещать в документ элементы управления? Разве не лучше применять контейнеры компоновки для частей интерфейса, предназначенных для интерактивной связи с пользователем, и потоковую компоновку для объемных блоков содержимого, доступных только для чтения? Однако в реальных приложениях существует много типов документов, которые как-то должны взаимодействовать с пользователем (кроме элемента вывода содержимого Hyperlink). Например, если система потоковой компоновки использована для создания страниц оперативной справки, то можно добавить кнопку, запускающую некоторое действие. Вот пример расположения кнопки под абзацем: <Paragraph> Настроить возможности бзика можно с помощью диалогового окна Параметры Бзика. </Paragraph> <BlockUIContainer> <Button HonzontalAlignment="Left" Рас1с11пд=">0ткрыть Параметры B3HKa</Button> </BlockUIContainer> Обработчик события подключается к событию Button.Click обычным образом. Совет. Смешанное применение элементов вывода содержимого и обычных элементов, не связанных с содержимым, имеет смысл в документах, поддерживающим интерактивную связь с пользователем. Например, при создании приложения для опроса, в котором пользователи могут вводить ответы, можно воспользоваться расширенной компоновкой текста из модели потокового документа — тогда пользователь сможет вводить и/или выбирать значения с помощью обычных элементов управления. Строковые элементы WPF предлагает большой набор строковых элементов, которые можно помещать в блочные или другие строковые элементы. Большинство из них довольно просты. Все они перечислены в табл. 28.4. Таблица 28.4. Строковые элементы вывода содержимого Имя Описание Run Содержит обычный текст Допускает применение форматирования, хотя обычно для этого используется элемент Span. Элементы Run часто создаются неявно (например, при добавлении текста в абзац) Span Объединяет любое количество других строковых элементов. Обычно используется для особого форматирования части текста. Для этого элемент Run упаковывается в элемент Span, и задаются свойства элемента Span. (Можно просто поместить текст в элемент Span, a вложенный элемент Run будет создан автоматически.) Другая причина использования элемента Span — он позволяет легко найти и обработать конкретный фрагмент текста. Элемент Span аналогичен элементу <span> в языке HTML
Глава 28. Документы 879 Окончание табл. 28.4 Имя Описание Bold, Italic и Underline Hyperlink LineBreak InlineUIContainer Floater и Figure Применяют соответственно полужирное, курсивное и подчеркнутое форматирование. Эти элементы порождены от Span. Однако обычно лучше упаковать формируемый текст в элемент span, а затем с помощью свойства Span.Style указать стиль с нужным форматом. Тогда впоследствии можно будет легко изменять характеристики форматирования, не меняя разметку документа Представляет ссылку, реагирующую на щелчок мыши, внутри потокового документа. В оконном приложении в ответ на событие Click можно выполнить действие (например, вывести другой документ). В страничном приложении можно использовать свойство NavigateUri, чтобы пользователь мог переходить к произвольной странице (об этом рассказано в главе 24) Добавляет разрыв строки в блочный элемент. Прежде чем использовать разрыв строки, подумайте: возможно, лучше использовать большие значение Margin или Padding, чтобы увеличить промежутки между элементами Позволяет помещать элементы, не связанные с содержимым (наследники UIElement), туда, где должны быть строковые элементы (например, в элемент Paragraph). InlineUIContainer похож на BlockUIElement, но является строковым элементом, а не блочным Позволяют внедрять всплывающее окошко, в котором можно вывести важную информацию, рисунок или связанное содержимое (например, рекламные сообщения, ссылки, кодовые фрагменты и т.п.) Добавление пробелов Как правило, пробельные символы в XML сворачиваются. Поскольку XAML основан на языке XML, в нем действуют те же правила. Таким образом, если вставить в содержимое несколько пробелов подряд, они будут преобразованы в один пробел. Это означает, что разметка <РагадгарИ>привет всем</РагадгарИ> эквивалентна следующей разметке: <РагадгарИ>привет всем</РагадгарИ> Пробелы между содержимым и дескрипторами тоже сворачиваются. Поэтому строка разметки <Paragraph> Привет всем</РагадгарИ> также преобразуется в <Рагадгар11>Привет всем</РагадгарИ> Обычно это поведение имеет смысл. Оно позволяет делать отступы в разметке документа с помощью символов разрыва строки и табуляции, не меняя способ интерпретации содержимого. Знаки табуляции и разрывы строк обрабатываются так же, как и пробелы. Внутри содержимого они сворачиваются в один пробел, а по краям содержимого игнорируются. Однако у этого правила есть одно исключение. Если пробел стоит перед строковым элементом, WPF сохраняет этот пробел. (А если перед ним несколько пробелов, WPF свернет их в один пробел.) Это означает, что можно написать следующую разметку: <Paragraph>npM встрече говорят <Во1с1>Привет</Во1с1> . </Paragraph>
880 Глава 28. Документы Здесь пробел между строкой "При встрече говорят" и вложенным элементом Bold остается, что и требовалось. Однако если переписать разметку так, как показано ниже, пробел исчезнет: <Paragraph>npM встрече говорят<Во1с1> Привет</Во1с1> . </Paragraph> В этом случае в пользовательском интерфейсе появится текст "При встрече говорят- Привет". Между прочим, Visual Studio 2005 ошибочно игнорирует пробел в обоих примерах, если просматривать содержимое потокового документа в окне проектирования. Но в работающем приложении все станет на свои места. Иногда бывает необходимо вставить пробел туда, где он обычно игнорируется, или включить последовательность пробелов. В этом случае понадобится атрибут xmlispace со значением preserve, которое сообщает анализатору XML, что во вложенном содержимом нужно сохранять все пробельные символы: <Paragraph xml:space=llpreserve">3TO весьма разреженный текст</РагадгарИ> Это решение лучше, но оно не устраняет все проблемы. Теперь, когда синтаксический анализатор XML почтительно относится к пробельным символам, уже нельзя использовать разрывы строки и символы табуляции, чтобы сделать отступ в содержимом для облегчения чтения кода. В длинных абзацах это усложнит понимание разметки. (Естественно, проблемы не будет, если разметка потокового документа генерируется другим инструментом: тогда вам все равно, как выглядит преобразованная разметка XAML.) Поскольку атрибут xmlispace применим к любому элементу, с пробелами можно обращаться более выборочно. Например, следующая разметка сохраняет пробелы только во вложенном элементе Run: <Paragraph> <Run xml :space=llpreserve">3To весьма </Run> разреженный текст. </Paragraph> Элемент Floater Элемент Floater (плавающее окошко) позволяет выделить часть содержимого из главного документа. По сути, эта часть помещается в "окошко", которое плавает где-то в вашем документе (обычно у одной из сторон документа). На рис. 28.7 показан пример с одной строкой текста. ■* • FloatersAndF.gures . УшВИШйП It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. Atone "The hallway smelt of boiled cabbage" end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, more than a metre wide: the face of a man of about forty-five, | with a heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the i [ 1—li£k„JCiKU\.at.i-lba b**CLt_rvf\±imoe.it-.uric. cAL4rvrr\.juuxuelciriii—^anH..at Рис. 28.7. Плавающая цитата
Глава 28. Документы 881 Для его создания нужно просто поместить элемент Floater где-нибудь в другом блочном элементе (например, в абзаце). Сам элемент Floater также может содержать один или более блочных элементов. Ниже показана разметка, использованная для создания примера, показанного на рис. 28.7 (многоточиями отмечен пропущенный текст). <Paragraph> It was a bright cold day in April, and the clocks were striking thirteen . . . </Paragraph> <Paragraph>The hallway smelt of boiled cabbage and old rag mats. <Run xml:space="preserve"> </Run> <Floater Style="{StaticResource PullQuote}"> <Paragraph>"The hallway smelt of boiled cabbage"</Paragraph> </Floater> At one end of it a coloured poster, too large for indoor display . . . </Paragraph> А вот стиль, который используется данным элементом Floater: <Style x:Key=,,PullQuote"> <Setter Property="Paragraph.FontSize11 Value=,l30"></Setter> <Setter Property=" Paragraph. Font Style" Value=,,Italic,,x/Setter> <Setter Property="Paragraph . Foreground" Value="Green"x/Setter> <Setter Property="Paragraph. Padding" Value="x/Setter> <Setter Property="Paragraph.Margin" Value=,10,15,10"></Setter> </Style> Как правило, потоковый документ расширяет плавающее окошко так, чтобы все содержимое как раз уместилось в одной строке, а если это невозможно — чтобы содержимое полностью заняло по ширине колонку в окне документа. (В данном примере имеется только одна колонка, поэтому Floater занимает окно документа по всей его ширине.) Если вас это не устраивает, то с помощью свойства Width можно задать ширину в не зависящих от устройства единицах. А с помощью свойства HorizontalAlignment можно определить местоположение плавающего окна: по центру, слева или справа. Ниже показано, как можно создать плавающее окошко, расположенное слева (см. рис. 28.8): <Floater Style="{StaticResource PullQuote}" Width=05" HorizontalAlignment="Left"> <Paragraph>"The hallway smelt of boiled cabbage"</Paragraph> </Floater> Элемент Floater будет использовать указанную ширину, если не выходит за пределы окна документа (в этом случае элемент Floater займет всю ширину окна). • FloatwsAndFigures r.irt.-^IM It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too Urrht> hnllllJfni krge for indoor display, had i /it: iiuutuuy been tacked to the wal] и STTldlt of boiled depicted simply an enormous J face, more than a metre wide: CQUuQQC tne *ace °f a man °f about forty-five, with a heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during daylight hours. It was part of the economy drive in preparation for Hate Week. The flat was seven flights up, and Winston, who Рис. 28.8. Плавающее окошко, размещенное слева
882 Глава 28. Документы По умолчанию плавающее окошко, используемое для элемента Floater, является невидимым. Однако для него можно задать текстурированный фон (с помощью свойства Background) или рамку (с помощью свойств BorderBrush и BorderThickness), чтобы четко отделить его содержимое от остальной части документа. Можно также использовать свойство Margin, чтобы добавить промежуток между плавающим окошком и документом, и свойство Padding, чтобы добавить промежуток между краями окошка и его содержимым. На заметку! Как правило, свойства Background, BorderBrush, BorderThickness, Margin и Padding имеются только у блочных элементов. Но они определены и в классах Floater и Figure, которые представляют строковые элементы. Элемент Floater можно применять и для вывода рисунков. Но, как ни странно, не существует элемента вывода потокового содержимого, предназначенного для этой задачи. Поэтому элемент Image придется применять вместе с элементом BlockUIContainer или InlineUIContainer. Здесь есть один опасный момент. При вставке плавающего окошка, заключающего в себе изображение, потоковый документ предполагает, что рисунок должен быть по ширине таким же, как и вся колонка текста. Размер находящегося внутри элемента Image будет изменен, что может привести к возникновению проблем, если придется сильно уменьшить или увеличить растровое изображение. С помощью свойства Image. St retch можно запретить изменение размеров изображения, хотя в этом случае плавающее окошко все равно займет всю колонку по ширине — просто вокруг рисунка останутся пустые места. Единственным подходящим решением при внедрении растрового изображения в потоковый документ является указание фиксированных размеров плавающего окошка. После этого с помощью свойства Image. St retch можно определить, как будет меняться размер изображения в этом окошке. Вот пример: <Paragraph> It was a bright cold day in April, <Floater Width=,,100" Padding=, 0, 5, 0" HorizontalAlignment=,,Right"> <BlockUIContainer> <Image Source="BigBrother. jpg"x/Image> </BlockUIContainer> </Floater> and the clocks . . . </Paragraph> Результат показан на рис. 28.9. Обратите внимание, что изображение на самом деле находится в двух абзацах, но это не портит внешний вид документа. В потоковом документе текст обтекает все плавающие окошки. На заметку! Плавающее окошко с фиксированными размерами очень удобно при изменении масштаба документа. При этом изменяются и размеры плавающего окошка, после чего изображение внутри плавающего окошка может изменить свой размер (в зависимости от свойства Image. St retch) и полностью заполнить плавающее окошко или разместиться в его центре Элемент Figure Элемент Figure (рисунок) подобен Floater, однако он позволяет точнее управлять местоположением. Обычно используются плавающие окошки, которые дают WPF больше свободы в оформлении содержимого. Однако при работе со сложным документом и/ или форматированием можно использовать именно рисунки, чтобы плавающие окошки не смещались при изменении размеров окна, или чтобы поместить окна в определенную позицию.
Глава 28. Документы 883 • FioatersAndCgures маИЗ It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory- Mansions, though not quickly enough to I prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured "rrhp hnlliunil P°ster* to° ^аг8е f°r indoor displav, had been 1/lC IIU.IILVULJ tacke<j t() Ле waU u depicte(j simply an S/77e// of hoilctl enormous ^ace' rnore than a metre wide: the face of a man of about forty-five, with a heavy CCIDDCIQC black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during daylight hours. It was part of the economy drive in preparation for Hate Week. The flat was seven flights up. and Winston, who was thirty-nine and had a varicose ulcer above his right ankle, went slowly, resting several times on the way. On each landing, opposite the lift-shaft, the poster with the Рис. 28.9. Плавающее окошко с изображением Итак, в чем же отличие класса Figure от класса Floater? В табл. 28.5 описаны свойства, с которыми вам придется иметь дело. Здесь, однако, следует отметить, что многие из этих свойств (включая HorizontalAnchor, VerticalOffset и HorizontalOffset) не поддерживаются контейнером FlowDocumentScrol1 Viewer, используемым для отображения потокового документа. Для них необходим один или несколько специальных контейнеров, о которых речь пойдет в разделе "Контейнеры потоковых документов, доступные только для чтения". А пока замените дескриптор- т FlowDocumentScrollViewer дескрипторами FlowDocumentReader, если вы хотите испо!т эвать свойства позиционирования рисунка. Таблица 28.5. Свойства элемента Figure Имя Описание Width Height HorizontalAnchor VerticalAnchor Задает ширину рисунка. Размеры рисунка можно задавать точно так же, как и размеры плавающего окна: в не зависящих от устройства единицах. Кроме того, размеры рисунка можно задать относительно всего окна или текущей колонки. Например, в XAML можно написать .25 content", чтобы создать окошко с размером 25% от ширины окна или  Column", чтобы создать окошко, которое по ширине будет равно двум колонкам Задает высоту рисунка Ее также задать можно задавать в не зависящих от устройства единицах. (Для сравнения: плавающее окошко выбирает для себя такую высоту, которая необходима, чтобы все содержимое уместилось при заданной ширине.) Если с помощью свойств Width и Height создать плавающее окно, которое окажется слишком маленьким, то часть содержимого будет отброшена Заменяет свойство HorizontalAlignment класса Floater. Но кроме трех эквивалентных параметров (ContentLef t, ContentRight и ContentCenter) включает также параметры позиционирования рисунка на текущей странице (например, PageCenter) или столбце (например, ColumnCenter) Позволяет выровнять изображение по вертикали относительно текущей строки текста, текущей колонки или текущей страницы
884 Глава 28. Документы Окончание табл. 28.5 Имя Описание HorizontalOf f set Определяют выравнивание рисунка. Эти свойства позволяют сместить и VerticalOf f set рисунок от его базовой позиции. Например, при отрицательном значении VerticalOf f set рисунок будет смещен вверх на указанное количество единиц. Если с помощью этих свойств сместить рисунок от края окна, то освободившееся место будет занято текстом. (Если нужно добавить свободное место без текста у одной стороны рисунка, воспользуйтесь свойством Figure.Padding.) WrapDirection Определяет, как будет производиться обтекание текстом с одной стороны или с обеих сторон (с возможными пробелами) рисунка Программное взаимодействие с элементами Пока мы имели дело с примерами создания разметки, необходимой для потоковых документов. Но вас не должен удивлять тот факт, что потоковые документы можно создавать и программно. (Ведь именно это и делает компилятор XAML при чтении разметки вашего потокового документа.) Программная генерация потокового документа — довольно утомительное занятие, т.к. необходимо создавать множество различных элементов. Приходится вручную создавать каждый элемент XAML, а затем устанавливать его свойства, ведь ни один конструктор не сделает это за вас. Понадобится также создавать элементы Run для упаковки каждой порции текста, так как они не будут создаваться автоматически. Ниже приведен фрагмент кода, который создает документ с одним абзацем, часть текста в котором выделена жирным шрифтом. Затем он выводит документ в существующем контейнере FlowDocumentScrollViewer с именем docViewer. // Создание первой части фразы. Run runFirst = new Run () ; runFirst.Text = "Привет всем от "; // Создание жирного текста. Bold bold = new Bold() ; Run runBold = new Run (); runBold.Text = "динамически генерируемых"; bold.Inlines.Add(runBold); // Создание последней части фразы. Run runLast = new Run () ; runLast.Text = " документов"; // Добавление трех частей фразы в абзац по порядку. Paragraph paragraph = new Paragraph (); paragraph.Inlines.Add(runFirst); paragraph.Inlines.Add(bold) ; paragraph.Inlines.Add(runLast) ; // Создание документа и добавление в него этого абзаца. FlowDocument document = new FlowDocument() ; document.Blocks.Add(paragraph); // Вывод документа. docViewer.Document = document; В результате получится фраза "Привет всем от динамически генерируемых документов". „
Глава 28. Документы 885 Чаще всего вы не будете создавать потоковые документы программно. Однако может понадобиться создать приложение, которое будет просматривать части потокового документа и динамически изменять их. Это можно сделать точно так же, как и при взаимодействии с любыми другими элементами WPR реагируя на события элементов и назначая имена элементам, которые нужно будет изменять. Однако в потоковых документах используется глубокое вложение содержимого неизвестной заранее структуры, и возможно, придется пробраться через несколько уровней, чтобы найти нужное содержимое. (Имейте в виду, что содержимое всегда хранится в элементе Run, даже если он не объявлен явным образом.) В перемещении по структуре потокового документа вам помогут несколько приемов: • Чтобы добраться до блочных элементов в потоковом документе, используйте коллекцию FlowDocument.Blocks. Для перехода к первому или последнему блочному элементу имеются конструкции FlowDocument.Blocks.FirstBlock или FlowDocument. Blocks.LastBlock. • Чтобы перейти от одного блочного элемента к следующему (или предыдущему) блоку, используйте свойство Block.NextBlock (или Block.PreviousBlock). Можно также использовать коллекцию Block.SiblingBlocks для просмотра всех блочных элементов, находящихся на одном уровне. • Многие блочные элементы могут содержать другие элементы. Например, элемент List содержит коллекцию Listltem, элемент Section — коллекцию Blocks, a Paragraph — коллекцию Inlines. Если требуется изменить текст внутри потокового документа, то лучше всего выделить именно ту часть, которую нужно изменить (и не больше), с помощью элемента Span. Например, следующий потоковый документ выделяет в блоке текста заданные существительные, глаголы и наречия для их программного изменения. Тип выделения указывается с помощью дополнительной информации — строки, хранящейся в свойстве Span.Tag. Совет. Свойство Tag в любом элементе зарезервировано для использования программистом. Оно может содержать любое значение или объект. <FlowDocument Name="document"> <Paragraph FontSize=0" FontWeight="Bold"> Release Notes </Paragraph> <Paragraph> These are the release <Span Tag="Plural Noun">notes</Span> for <Span Tag="Proper Noun">Linux</Span> version 1.2.13. </Paragraph> <Paragraph> Read them <Span Tag="Adverb">carefully</Span>, as they tell you what this is all about, how to <Span Tag="Verb">boot</Span> the <Span Tag="Noun">kernel</Span>, and what to do if something goes wrong. </Paragraph> </FlowDocument> Такая конструкция позволяет создать простую игру Mad Libs, показанную на рис. 28.10. В этой игре пользователь может ввести значения для всех дескрипторов Span, прежде чем он увидит их в исходном документе. Затем исходные значения заменяются на введенные пользователем, просто чтобы посмеяться.
886 Глава 28. Документы j PtwelNoun: ! Proper Noun: i Adverb: j Verb: ; NOUn. ;iL,:...:... donkeys New York lovingly taste hotdog Generate i|-|'''r>—| ::.::iz.zz-i)! iss \-иШМШ ! Plural Noon: donkeys j Proper Noun: New York : Adverb: lovingly j Verb: taste j Noun: hotdog I —-—«—_, Release Notes These are the release donkeys for New York version 1.2.13. Read them lovingly, as they tell той what this is all about, how to taste the hotdog, and what to do if something goes wrong. Рис. 28.10. Динамическое изменение потокового документа Чтобы этот пример был как можно более общим, код не использует никаких конкретных знаний об используемом документе. Он написан так, что может выбирать именованные элементы Span из всех абзацев верхнего уровня в любом документе. Сначала он выбирает абзацы в коллекции Blocks, а затем элементы Span в коллекции Inlines каждого абзаца. Каждый раз, когда он находит объект Span, он создает текстовое поле, в котором пользователь сможет ввести новое значение, и добавляет его в таблицу над документом (вместе с описательной меткой). А для облегчения процесса замены каждое текстовое окно хранит ссылку (в свойстве TextBox.Tag) на элемент Run с текстом внутри соответствующего элемента Span: private void WindowLoaded(Object sender, RoutedEventArgs e) { // Очистка таблицы элементов управления текстовыми записями. gridWords.Children.Clear (); // Перебор абзацев. foreach (Block block in document.Blocks) { Paragraph paragraph = block as Paragraph; // Поиск объектов Span. foreach (Inline inline in paragraph.Inlines) { Span span = inline as Span; if (span != null) { // Создание ячейки в строке для данного термина. RowDef inition row = new RowDef mition () ; gridWords.RowDefinitions.Add(row); // Добавление описательной метки для данного термина. Label 1Ы = new Label (); 1Ы. Content = inline. Tag. ToString () + ":"; Grid.SetColumn(lbl, 0); Grid.SetRow(lbl, gridWords.RowDefinitions.Count - 1) ; gridWords.Children.Add(lbl); // Добавление текстового поля, в котором пользователь // может ввести значение для данного термина. TextBox txt = new TextBox();
Глава 28. Документы 887 Grid.SetColumn(txt, 1); Grid.SetRow(txt, gridWords.RowDefmitions.Count - 1); gridWords.Children.Add(txt); // Привязка текстового поля к элементу Run, где должен появиться текст, txt.Tag = span.Inlines.Firstlnline; } } } } После щелчка пользователя на кнопке Generate (Сгенерировать), кид перебирает все текстовые поля, которые были динамически добавлены на предыдущем этапе. Затем он копирует текст из текстового поля в соответствующий элемент Run в потоковом документе: private void cmdGenerate_Click(Object sender, RoutedEventArgs e) { foreach (UIElement child in gridWords.Childrer) { if (Grid.GetColumn(child) == 1) { TextBox txt = (TextBox)child; if (txt.Text != "") ( (Run)txt.Tag) .Text = txt.Text; } } docViewer.Visibility = Visibility.Visible; } Возможно, вы думали, что лучше сделать по-другому: просмотреть документ еще раз, и в каждый обнаруженный элемент Span вставлять соответствующий текст. Однако этот подход не годится, т.к. нельзя перебирать коллекцию строковых элементов в абзаце в то время, когда изменяется его содержимое. Выравнивание текста Вы, видимо, уже заметили, что текстовое содержимое в потоковом документе по умолчанию выравнивается так, что каждая строка растягивается от левого поля до правого. Это поведение можно изменить с помощью свойства TextAlignment, однако большинство потоковых документов в WPF выровнены по ширине. Чтобы улучшить читаемость выровненного по ширине текста, можно воспользоваться оптимальной компоновкой абзаца, которая распределяет пробелы максимально равномерно. Она позволяет избежать появления отвлекающих внимание "змеек" пробелов и неравномерно расставленных слов, что бывает в случае применения упрощенных алгоритмов выравнивания строк (например, используемых в веб-браузерах). На заметку! Простые алгоритмы выравнивания строк работают построчно. Оптимальная компоновка абзацев в WPF использует алгирнм тотального заполнения, который анализирует и последующие строки. Он выбирает такие разрывы строк, которые равномерно распределяют слова во всем абзаце, что в итоге дает оптимальный результат для всех строк. Обычно функция оптимальной компоновки абзаца не используется — наверно, из- за повышенной сложности алгоритма тотального заполнения. Однако в большинстве случаев отклик приложений (т.е. его поведение при изменении размера окна) такой же, как и при включенной оптимальной компоновке абзацев. Чтобы активизировать оптимальную компоновку абзацев, нужно присвоить свойству FlowDocument.IsOptimalParagraphEnabled значение true. На рис. 28.11 показа-
888 Глава 28. Документы на разница между потоковым документом с обычными абзацами (вверху) и потоковым документом с включенным алгоритмом тотальной подгонки (внизу). Unlike a standard line breaking algorithm which breaks the line without taking into account the line that may come after it, the total-fit algorithm breaks line by looking ahead on what may come later in the paragraph and make a single decision to break all the lines at once. Unlike a standard line breaking algorithm which I breaks the line without taking into account the line that may come after it, the total-fit algorithm breaks line by looking ahead on what may come later in the paragraph and make a single decision to break all the lines at once. _— • • • - . • Рис. 28.11. Обычное выравнивание (вверху) и оптимальное заполнение абзацев (внизу) Чтобы еще улучшить выравнивание текста, особенно в узких окнах, присвойте свойству FlowDocument.IsHyphenationEnabled значение true. Тогда WPF будет разрывать длинные слова там, где это необходимо, чтобы обеспечить небольшое расстояние между словами. Переносы слов хорошо уживаются с оптимальным заполнением абзацев и особенно важны при отображении в несколько колонок. WPF использует словарь переноса слов (английского языка), чтобы правильно вставлять дефисы (между слогами — например, "algo-rithm", а не "algori-thrrf). Контейнеры потоковых документов, доступные только для чтения WPF предоставляет три контейнера, доступных только для чтения, которые можно использовать для отображения потоковых документов: • FlowDocumentScrollViewer показывает весь документ с полосой прокрутки, которая позволяет перемещаться по документу, если он превышает размеры контейнера. Этот контейнер не поддерживает разбиение на страницы или несколько колонок (хотя, как и все контейнеры, он поддерживает печать и изменение масштаба). Во всех примерах, приведенных до настоящего момента, использовался контейнер FlowDocumentScrollViewer. • FlowDocumentPageViewer разбивает документ на страницы. Размер каждой страницы совпадает с доступным местом, а пользователь может переходить от одной страницы к другой. Контейнер FlowDocumentPageScrollViewer требует больших затрат, чем FlowDocumentScrollViewer (из-за дополнительных вычислений для разбивки содержимого на страницы). • FlowDocumentReader сочетает возможности контейнеров FlowDocument ScrollViewer и FlowDocumentPageViewer. Он позволяет пользователю выбрать, как будет выполняться чтение содержимого — с прокруткой или постранично. Кроме того, в нем есть функция поиска. Контейнер FlowDocumentReader требует больших затрат, чем другие контейнеры потоковых документов.
Глава 28. Документы 889 Для переключения с одного контейнера на другой нужно просто изменить объемлющий дескриптор. Например, ниже показан потоковый документ в контейнере FlowDocumentPageViewer: <FlowDocumentPageViewer> <TTlowDocument> <Рагадгар11>Привет от мира документов . </Paragraph> </FlowDocument> </FlowDocumentPageViewer> Каждый из этих контейнеров имеет дополнительные возможности: изменение масштаба, разбиение на страницы и печать. Они будут описаны в последующих разделах. Элемент TextBlock Для отображения небольшого объема потокового содержимого можно применять знакомый вам элемент TextBlock, который часто встречался в предыдущих главах. Хотя TextBlock часто используется для хранения обычного текста (тогда в нем создается объект Run для упаковки этого текста), в него можно поместить любую комбинацию строковых элементов. Они будут добавлены в коллекцию TextBlock.Inlines. Элемент TextBlock может выполнять многострочный вывод текста (с помощью свойства TextWrapping) и управлять обработкой текста, когда он не помещается в элементе TextBlock (с помощью свойства TextTrimming). В таких случаях лишний текст отсекается, но об этом можно сообщить с помощью многоточия. Возможны следующие параметры: • None. Текст усекается без многоточия. Фраза "This text is too big" может превратиться в "This text is to". • WordEllipse. Многоточие вставляется после последнего слова, которое умещается в элемент (например, "This text is ...") • CharacterEllipse. Многоточие вставляется после последнего символа, умещающегося в элемент (например, "This text is t..."). Элемент TextBlock не имеет возможностей прокрутки и разбиения на страницы, как у более мощных контейнеров FlowDocument. Поэтому его лучше использовать для вывода небольших порций содержимого, таких как управляющие метки и гиперссылки. TextBlock вообще не может содержать блочные элементы. Изменение масштаба Все три контейнера документов поддерживают функцию изменения масштаба (zooming), т.е. уменьшения или увеличения размера отображаемого содержимого. Свойство Zoom контейнера (например, FlowDocumentScrollViewer.Zoom) устанавливает размеры содержимого в виде процентного значения. Обычно начальным значением Zoom является 100, а значения Font Size соответствуют любым другим элементам в окне. Если увеличить значение Zoom до 200, размер текста будет удвоен. Аналогично, если уменьшить его до 50, размер текста будет уменьшен вдвое (можно задавать любое значение из этого диапазона). Понятно, что процентное значение масштабирования можно задавать вручную. Но можно изменять масштаб и программно, с помощью методов IncreaseZoomO и DecreaseZoomO, которые изменяют значение Zoom на величину, указанную в свойстве Zoomlncrement. Команды позволяют связать с этими функциями и другие элементы управления (см. главу 9). Однако лучше так не делать. Гораздо проще позволить пользователям самостоятельно задавать процент изменения масштаба по своему усмотре-
890 Глава 28. Документы нию. Контейнер FlowDocumentScrollViewer содержит для этого инструментальную панель с линейкой и ползунком. Чтобы сделать ее видимой, нужно присвоить свойству IsToolbarVisible значение true: <FlowDocumentScrollViewr,r MinZoom=0" MaxZoom=" Zoom=00" ZoomIncrement=" IsToolbarVisible= 1000" 'True" На рис. 28.12 показан потоковый документ с ползунком для изменения масштаба, расположенным внизу. ■ FloatersAndFigures « | а It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quicklv enough to prevent a swirl of grim- dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too •*77if> hnJhijnu ^гяе for indoor displav. had been tacked to the x in "uiiuuy wag It ^р^ д^. ^ enormous face> morf smelt of I cabbage* smelt of boiled ^ап a mrtre ^e: ^e *ace or a man °* a^°ut ' w fortv-five. with a he ._ i heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during dayHght hours. It was part of the economy dri\-e in preparation for Hate Week. The flat was seven flights up. and Winston, who was thirty-nine and had a varicose ulcer аЬол-е his right ankle, went slowly, resting several times on the way. On each landing, opposite the lift-shaft, the poster with Рис. 28.12. Изменение масштаба документа В контейнерах FlowDocumentPageViewer и FlowDocumentReader ползунок изменения масштаба виден всегда (но можно задать приращение масштаба, а также минимальное и максимальное значения масштабирования). Совет. Изменение масштаба влияет на размер всего, что задано в не зависящих от устройства единицах (а не только на размер шрифта). Например, если в потоковом документе применяются плавающие окошки или рисунки с явно заданной шириной, то эта ширина тоже будет пропорционально изменяться. Страницы и колонки Контейнер FlowDocumentPageViewer может разбивать длинный документ на отдельные страницы. Это облегчает чтение длинного содержимое документа. (При использовании прокрутки пользователям постоянно приходится останавливать чтение, прокручивать текст, а затем находить место, с которого нужно продолжить чтение. А если пользователи просматривают последовательность страниц, то они точно знают, где нужно начинать чтение — вверху каждой страницы.) Количество страниц зависит от размера окна. Например, если разрешить контейнеру FlowDocumentPageViewer занимать все окно, то количество страниц будет изменяться при изменении размеров окна, как показано на рис. 28.13. Если окно имеет достаточную ширину, контейнер FlowDocumentPageViewer разобьет текст для удобства чтения на несколько колонок (см. рис. 28.14). На рисунках 28.13 и 28.14 показано одно и то же окно. Просто это окно подстраивается под размер, чтобы наилучшим образом использовать доступное место.
Глава 28. Документы 891 ■ PagedContent шш Chapter l Part One It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors i 1 of 4 ► ■ ' PagedContent i Chapter 1 Part One It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, - Рис. 28.13. Динамическое переразбиение на страницы » PagedContent Chapter l Part One It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, more than a metre wide: the face of a man of about forty-five, with a heavy black moustache and ruggedly handsome features. Winston made for the stairs. It was no use trying the lift. Even at the best of times it was seldom working, and at present the electric current was cut off during daylight hours. It was part of the economy drive in preparation for Hate Week. The fiat was seven flights up, and Winston, who was thirty-nine and had a varicose ulcer above his right ankle, went slowly, resting several times on the way. On each landing, opposite the lift-shaft, the poster with the enormous face gazed from the 1 1 of 2 ► *-*- + Рис. 28.14. Автоматическая разбивка на колонки На заметку! Имейте в виду, что элементы Floater обычно растягиваются на всю ширину колонки. Их можно сделать уже, задав явным образом ширину, но не шире. А элементы Figure могут легко растянуться на несколько колонок. Стандартные настройки дают хорошее разбиение на страницы и колонки, но их можно изменять, чтобы получить желаемый результат. Есть две ключевые точки, которые позволяют выполнять расширение: класс FlowDocument, который хранит содержимое (его свойства перечислены в табл. 28.6), и отдельные элементы Paragraph в документе (их свойства перечислены в табл. 28.7). На заметку! Очевидно, что в некоторых случаях свойства элемента Paragraph, определяющие разрыв колонок, невыполнимы. Например, если абзац слишком большой и не входит на одну страницу, свойство KeepTogether будет проигнорировано, так как абзац все равно придется разбить.
892 Глава 28. Документы Таблица 28.6. Свойства класса FlowDocument для управления колонками Имя Описание ColumnWidth IsColumnWidthFlexible ColumnGap ColumnRuleWidth и ColumnRuleBrush Определяет предпочтительный размер текстовых колонок. Выступает в качестве минимального размера, а FlowDocumentPageViewer подстраивает ширину, чтобы максимально использовать место на странице Определяет, может ли контейнер документа изменять размер колонки. Если равно false, используется точная ширина колонки, определенная в свойстве ColumnWidth. Контейнер FlowDocumentPageViewer не создает частичные колонки, поэтому в результате могут возникнуть пустые места у правого края страницы (или с обеих сторон, если значение FlowDocumentMaxPageWidth меньше ширины окна документа). Если равно true (по умолчанию), то FlowDocumentPageViewer разобьет доступное место на равные колонки с шириной не меньше ColumnWidth Задает промежуток между колонками Позволяет вывести вертикальную линию между колонками. Для нее можно указать ширину и заполнение Таблица 28.7. Свойства абзаца для управления колонками Имя Описание KeepTogether KeepWithNext MinOrphanLines MinWindowLines Определяет, будет ли разбиваться абзац при разрыве страницы. Если равно true, абзац не разбивается и обычно переносится на следующую страницу. (Этот параметр имеет смысл для небольших объемов текста, которые нужно читать как одно целое.) Определяет, можно ли разделить пару абзацев при разрыве страницы. Если равно true, то при разрыве страницы данный абзац не отделяется от следующего. (Этот параметр удобен для заголовков.) Управляет разбиением абзаца при разрыве страницы. Если этот абзац разбивается при разрыве страницы, то данное значение задает минимальное количество строк, которые должны остаться на первой странице. Если для такого количество строк нет места, абзац будет целиком перенесен на следующую страницу Управляет разбиением абзаца при разрыве страницы. Если этот абзац разбивается при разрыве страницы, то данное значение задает минимальное количество строк, которые будут перенесены на вторую страницу. Контейнер FlowDocumentPageViewer может перенести строки с первой страницы на вторую, чтобы удовлетворить этому критерию Контейнер FlowDocumentPageViewer не является единственным контейнером, поддерживающим разбиение на страницы. FlowDocumentReader позволяет пользователю выбрать между режимом прокрутки (который работает точно так же, как и в FlowDocumentScrollViewer) и двумя страничными режимами. Можно просматривать текст по одной странице (как в контейнере FlowDocumentPageViewer) или по две страницы рядом. Для переключения между режимами просмотра нужно просто щелкнуть на соответствующем значке в правом нижнем углу инструментальной панели FlowDocumentReader.
Глава 28. Документы 893 Загрузка документов из файла В приведенных до настоящего момента примерах объект FlowDocument объявлялся внутри своего контейнера. Однако после создания превосходного средства для просмотра документов логично применять его и для просмотра другого содержимого (например, чтобы показывать различные темы в окне справки). Для этого придется динамически загружать содержимое в контейнер с помощью класса XamlReader из пространства имен System.Windows.Markup. К счастью, сделать это нетрудно. Ниже показан весь нужный для этого код (без обработки ошибок, связанных с доступом к файлу). using (FileStream fs = File.Open(documentFile, FileMode.Open)) { FlowDocument document = XamlReader.Load (fs) as FlowDocument; if (document == null) { MessageBox.Show("Ошибка при загрузке документа."); \ } else { flowContainer.Document = document; } } Так же несложно взять текущее содержимое FlowDocument и сохранить его в XAML- файле с помощью класса XamlWriter. Эта возможность не так полезна — ведь контейнеры, с которыми вы познакомились до настоящего времени, не разрешают пользователям выполнять изменения. Однако она позволяет программно изменять документ в зависимости от действий пользователей (например, если нужно сохранить текст из показанной ранее игры Mad Libs) или создать объект FlowDocument программным образом и сохранить его прямо на диске. Вот код, который преобразовывает объект FlowDocument в XAML: using (FileStream fs = File.Open(documentFile, FileMode.Create)) { XamlWriter.Save(flowContainer.Document, fs) ; } Печать Напечатать потоковый документ совсем нетрудно: нужен лишь метод Print () контейнера. (Все контейнеры потоковых документов поддерживают печать.) Метод Print () выводит диалоговое окно Print (Печать), в котором пользователь может выбрать принтер и задать параметры печати (например, количество копий), а затем отменить операцию или послать задачу на принтер. Печать, как и многие функции контейнеров потоковых документов, выполняется с помощью команд. Поэтому для связывания элемента управления с этой функцией не нужно писать код для вызова метода Print (). Достаточно просто использовать нужную команду: <Button Command="ApplicationCommands.Print" CommandTarget=ndocViewer">ne4aTb </Button> Наряду с печатью, контейнеры потоковых документов поддерживают также команды для поиска, изменения масштаба и навигации по страницам. Команды могут быть привязаны к клавиатуре. Например, команде Print по умолчанию соответствует комбинация клавиш <Ctrl+P>. Поэтому даже при отсутствии кнопки
894 Глава 28. Документы или кода для вызова метода Print () пользователь может запустить его и открыть окно Print. Если это нежелательно, удалите из команды привязку к этой комбинации. На заметку! Параметры печати потокового документа можно настраивать. Об этом, а также о печати других типов содержимого, рассказывается в главе 29. Редактирование потокового документа Все контейнеры потоковых документов, с которыми вы имели дело до настоящего момента, доступны только для чтения. Они идеально подходят для отображения содержимого документа, но не разрешают пользователям вносить в них изменения. К счастью, есть еще один элемент WPF, заполняющий этот пробел: элемент управления RichTextBox. Инструментальные пакеты для программирования уже давно содержат элементы управления, работающие с форматированным текстом. Однако элемент RichTextControl, включенный в WPF, существенно отличается от его предшественников. Он больше не ограничен устаревшим стандартом RTF, который можно встретить в программах обработки текстов. Вместо этого он хранит свое содержимое в виде объекта FlowDocument. Это важное изменение. В элемент RichTextBox по-прежнему можно загружать RTF- содержимое, но в нем используется гораздо более простая модель потокового содержимого, уже описанная в данной главе. Это позволяет значительно упростить программную обработку содержимого документа. Элемент RichTextBox предлагает также расширенную модель программирования с множеством точек расширяемости, где программист может добавить собственную логику — это позволит создать на основе RichTextBox собственный текстовый редактор. Единственным недостатком этого элемента является скорость работы. Он, как и большинство его предшественников, может работать медленно. Если вам нужно обрабатывать большие объемы данных, применять сложную логику для обработки нажатий клавиш или добавить такие эффекты, как автоматическое форматирование (наподобие подсветки синтаксиса в Visual Studio или подчеркивания при проверке правописания в Word), RichTextBox может и не обеспечить приемлемый уровень производительности. На заметку! Элемент RichTextBox не поддерживает все те возможности, которые имеются в контейнерах потоковых документов, доступных только для чтения. К примеру, в нем нет изменения масштаба, разбиения на страницы и колонки, а также поиска. Загрузка файла Чтобы опробовать элемент RichTextBox, можно объявить в нем один из уже знакомых вам потоковых документов: <RichTextBox> <FlowDocument> <Рагадгар11>Привет от мира редактируемых документов. </Paragraph> </FlowDocument> </RichTextBox> Но практичнее прочитать документ из файла, а затем вставить его в RichTextBox. Для этого можно воспользоваться тем же подходом, что и при загрузке и сохранении содержимого элемента FlowDocument перед отображением его в контейнере, доступном только для чтения — статическим методом XamlReader.Load(). Но может понадобиться
Глава 28. Документы 895 и еще одна возможность — загрузка и сохранение файлов в других форматах (а именно, .rtf-файлов). Для этой цели нужно использовать класс System.Windows.Documents. TextRange, в который упаковывается часть текста. TextRange — изумительно полезный контейнер, позволяющий преобразовывать файлы из одного формата в другой, а также применять форматирование (об этом речь пойдет в следующем разделе). Ниже представлен фрагмент простого кода, который преобразует . г t f-документ в выборку текста в объекте TextRange, после чего вставляет этот текст в RichTextBox: OpenFileDialog openFile = new OpenFileDialog(); openFile.Filter = "RichText Files (*.rtf)|*.rtf|All Files (*.*)I*.*"; if (openFile.ShowDialog () == true) { TextRange documentTextRange = new TextRange ( richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd); using (FileStream fs = File.Open(openFile.FileName, FileMode.Open)) { documentTextRange.Load(fs, DataFormats.Rtf); } } Обратите внимание: прежде чем что-либо сделать, необходимо создать контейнер TextRange для той части документа, которую нужно изменить. Даже если в документе на данный момент нет никакого содержимого, необходимо определить начальную и конечную точки выборки. Чтобы выделить весь документ, можно использовать свойства FlowDocument.ContentStart и FlowDocument.ContentEnd, которые предоставляют объекты TextPointer, необходимые для контейнера TextRange. После создания контейнера TextRange его можно заполнить данными с помощью метода Load(). Однако нужно еще указать строку, идентифицирующую тип формата данных, который требуется преобразовать. Можно использовать одно из следующих значений: • DataFormats.Xaml для потокового содержимого XAML; • DataFormats.Rtf для форматированного текста (как в предыдущем примере); • DataFormats.XamlPackage для потокового содержимого с внедренными изображениями; • DataFormats.Text для простого текста. На заметку! Формат DataFormats.XamlPackage практически совпадаете DataFormats. Xaml. Единственное отличие состоит в том, что DataFormats .XamlPackage хранит двоичные данные для внедренных изображений (которые отсутствуют в обычной сериал изации DataFormats. Xaml). Формат пакетов XAML не является настоящим стандартом: это просто средство WPR позволяющее упростить преобразование содержимого документа и поддерживающее другие полезные возможности, вроде вырезания и вставки или перетаскивания. Класс DataFormats содержит много дополнительных полей, но не поддерживает остальные. Например, невозможно преобразовать документ HTML в потоковое содержимое с помощью DataFormats.Html. И формат XAML, и RTF требуют полномочий на выполнение неуправляемого кода, а это означает, что их нельзя использовать в ситуациях с ограничением доверия (например, в браузерном приложении). Метод TextRange.Load() работает только в том случае, если указан правильный формат файла. Однако в реальности может понадобиться создать текстовый редактор, поддерживающий как XAML (для большей точности), так и RTF (для совместимости с
896 Глава 28. Документы другими программами, такими как текстовые процессоры). Обычно в таких случаях пользователю дается возможность указать формат файла, или же формат определяется на основе расширения файла, как показано ниже: using (FileStream fs = File.Open(openFile.FileName, FileMode.Open)) { if (Path.GetExtension(openFile.FileName).ToLower() ==".rtf") { documentTextRange.Load(fs, DataFormats.Rtf); } else { documentTextRange.Load (fs, DataFormats.Xaml); } } В этом коде возникнет исключение, если файл не найден, к нему нет доступа, либо его невозможно загрузить с помощью указанного формата. По всем этим причинам нужно поместить такой код в обработчик исключения. Помните, что независимо от способа, которым загружается содержимое документа, оно преобразуется в объект FlowDocument, чтобы его можно было отобразить в элементе управления RichTextBox. Чтобы точнее понять, о чем идет речь, можно написать простую программу, которая извлекает содержимое из объекта FlowDocument и преобразовывает его в текстовую строку с помощью конструкций XamlWriter и TextRange. Ниже показан пример, который выводит разметку для текущего потокового документа в другом текстовом поле. // Копирование содержимого документа в MemoryStream. using (MemoryStream stream = new MemoryStream()) { TextRange range = new TextRange (richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd) ; range.Save(stream, DataFormats.Xaml); stream.Position = 0; // Чтение содержимого из потока и вывод его в текстовом поле. using (StreamReader r = new StreamReader(stream)) { txtFlowDocumentMarkup.Text = r.ReadToEnd(); } } Этот прием чрезвычайно полезен в качестве средства отладки — он позволяет узнать, как изменяется разметка документа после ее редактирования. Сохранение файла Документ можно сохранить с помощью объекта TextRange. Ему надо передать пару объектов TextPointer, определяющих начало и окончание содержимого. Затем можно вызвать метод TextRange.Save() и указать требуемый формат экспорта (текст, XAML, пакет XAML или RTF) с помощью поля из класса DataFormats. Здесь форматы пакета XAML и RTF также требуют полномочий на выполнение неуправляемого кода. Следующий блок кода сохраняет документ в формате XAML, если имя файла не содержит расширения .rtf. (Другой, более явный подход — дать пользователю возможность применить средство сохранения, использующее XAML, и средство экспортирования, использующее RTF.)
Глава 28. Документы 897 SaveFileDialog saveFile = new SaveFileDialog(); saveFile.Filter = "XAML Files (*.xaml)|*.xaml|RichText Files (*.rtf)|*.rtf|All Files (*.*)!*.*"; if (saveFile.ShowDialog () == true) { // Создание контейнера TextRange для всего документа. TextRange documentTextRange = new TextRange ( richTextBox.Document.ContentStart, richTextBox.Document.ContentEnd); // Если такой файл существует, он перезаписывается, using (FileStream fs = File.Create(saveFile.FileName)) { if (Path.GetExtension(saveFile.FileName) .ToLower () == ".rtf") { documentTextRange.Save (fs, DataFormats.Rtf); } else { documentTextRange.Save (fs, DataFormats.Xaml); } } } Если для сохранения документа применяется формат XAML, то естественно предположить, что документ сохраняется как обыкновенный файл XAML с элементом верхнего уровня FlowDocument. Почти так, но не совсем. Элементом верхнего уровня должен быть Section. Как уже было сказано в этой главе, объект Section представляет собой многоцелевой контейнер, содержащий другие блочные элементы. В этом есть смысл: ведь объект TextRange представляет раздел выделенного содержимого. Однако не пытайтесь использовать метод TextRange.Load() при работе с другими файлами XAML, которые содержат элементы верхнего уровня FlowDocument, Page или Window, поскольку ни один из этих файлов не будет скомпилирован. (Точно так же файл документа не может связаться с файлом с вынесенным кодом или прикрепить какие-либо обработчики событий.) Если у вас есть файл XAML, в котором элементом верхнего уровня является FlowDocument, можно создать соответствующий объект FlowDocument с помощью метода XamlReader.LoadO, как это уже делалось раньше. Форматирование выделенного текста Вы можете многое узнать об элементе RichTextBox, создав простой редактор форматированного текста наподобие приведенного на рис. 28.15. Здесь кнопки инструментальной панели позволяют пользователю быстро применить жирное, курсивное и подчеркнутое форматирование. Однако наиболее интересной частью этого примера является расположенный ниже обычный элемент Text Box, который выводит разметку XAML для объекта FlowDocument, отображаемого в данный момент в элементе RichTextBox. Это поможет вам понять, как RichTextBox изменяет объект FlowDocument в ходе правок. На заметку! Чтобы сделать выделенный текст жирным, курсивным или подчеркнутым, не нужно писать код. Элемент RichTextBox поддерживает команды ToggleBold, Toggleltalic и ToggleUnderline из класса EditingCommands. Кнопки можно связать непосредственно с этими командами. Но данный пример демонстрирует и другие аспекты работы элемента RichTextBox. Это знание пригодится, когда вам понадобится обработать текст другим способом. (В загружаемом коде для этой главы продемонстрированы подходы как на основе кода, так и на основе команд.)
898 Глава 28. Документы hTextEditor ^ШКМ] I New Open Save В / U Show Flow Document XAML J to to to Chapter 1 j It was a bright cold day in April and the clocks were stnktng thirteen. Winston Smith, his chin i nuzzled into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from ! entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too ! large for indoor display, had been tacked to the wall. It depicted simply an enormous face, more ! I FontWeight= Bold PontSize= '20' TextDecorat»ons='Underline ><Run>Chapter 1</Run></ » j i Paragraph >< Paragraph >< Run > It was a </Run><Run FontWeight=Boldr > bright cold day j in April</Run><Run>, and the clocks were stnking thirteen. Winston Smith, his chin nuzzled ; into his breast in an effort to escape the vile wind, slipped quickly through the glass doors of j Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering i along with him.</Run></Paragraph><Paragraph><Run>The hallway smelt of boiled ; cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, i had been tacked to the wall. It depicted simply an enormous face, more than a metre wide: i the face of a man of about forty-five, with a heavy black moustache and ruggedly handsome . Рис. 28.15. Редактирование текста Все кнопки работают одинаково. Они используют свойство RichTextBox.Selection, которое предоставляет объект TextSelection, заключающий выбранный в данный момент текст. (TextSelection является немного более сложным наследником класса TextRange, который был продемонстрирован в предыдущем разделе.) Выполнять изменения с помощью объекта TextSelection не сложно, но и не очевидно. Самый простой подход заключается в применении метода ApplyPropertyValueO для изменения свойства зависимости в выделенном тексте. Например, можно применить жирное начертание к любым текстовым элементам в выделенном тексте с помощью следующего кода: richTextBox.Selection.ApplyPropertyValue(TextElement.FontWeightProperty, FontWeights.Bold); Этот вызов выполняет гораздо больше, чем может показаться на первый взгляд. Например, если обработать данным кодом небольшой фрагмент текста внутри большего абзаца, то окажется, что код автоматически создает строковый элемент Run, содержащий выделенный текст, а затем применяет к нему жирное начертание. Таким образом, эту же строку кода можно использовать для форматирования отдельных слов, целых абзацев и произвольных выделений текста, которые могут содержать несколько абзацев (в этом случае для каждого такого абзаца будет создан отдельный элемент Run). Естественно, приведенный код не является полным решением. Если нужно применить жирное начертание, то придется вначале использовать метод TextSelection. GetPropertyValueO, чтобы проверить, было ли уже оно применено: Object obj = richTextBox.Selection.GetPropertyValue (TextElement.FontWeightProperty); Здесь есть один нюанс. Если выделенный фрагмент содержит текст, который является полностью жирным или обычным, то будет возвращено свойство FontWeights.Bold или FontWeights.Normal. Однако если часть текста является жирным, а часть обычным текстом, то результатом будет DependencyProperty.UnsetValue. Вы должны сами решить, как обрабатывать текст смешанного формата. Можно не делать ничего, можно всегда применять форматирование или же принять решение на основе первого символа (именно так и поступает команда EditingCommands .ToggleBold).
Глава 28. Документы 899 Для этого необходимо создать новый контейнер TextRange, который будет содержать только начальную точку выделенного текста. Ниже показан код, в котором реализован последний подход, и который проверяет первый символ в неоднозначных случаях. Object obj = richTextBox.Selection.GetPropertyValue ( TextElement.FontWeightProperty); if (Ob] == DependencyProperty.UnsetValue) { TextRange range = new TextRange(richTextBox.Selection.Start, richTextBox.Selection.Start) ; obj = range.GetPropertyValue(TextElement.FontWeightProperty).ToString()); } FontWeight fontWeight = (FontWeight)obj; if (fontWeight == FontWeights .Bold) fontWeight = FontWeights.Normal; else fontWeight = FontWeights.Bold; richTextBox.Selection.ApplyPropertyValue( TextElement.FontWeightProperty, fontWeight); В некоторых случаях пользователь может применить или отменить жирное начертание, вообще не выделяя текст. Ниже представлен код, который сначала проверяет это условие, а затем определяет форматирование всего абзаца, содержащего этот текст. Затем плотность шрифта этого абзаца изменяется с полужирного на нормальный или наоборот: if (richTextBox.Selection.Text == "") { FontWeight fontWeight = richTextBox.Selection.Start.Paragraph.FontWeight; if (fontWeight == FontWeights.Bold) fontWeight = FontWeights.Normal; else fontWeight = FontWeights.Bold; richTextBox.Selection.Start.Paragraph.FontWeight = fontWeight; } Совет. Для получения простого неформатированного текста, который находится в выделенном фрагменте, используйте свойство TextRange.Text. Элемент RichTextBox содержит еще много других способов обработки текста. Например, классы TextRange и RichTextBox содержат свойства, которые позволяют получать смещения символов, подсчитывать количество строк и переходить от одного потокового элемента к другому в части документа. Для получения дополнительной информации обратитесь к справочной системе Visual Studio. Получение отдельных слов У элемента RichTextBox есть один недостаток: он не может выделять отдельные слова в документе. Совсем не трудно найти элемент потокового документа, существующий в данной позиции (как в предыдущем разделе), но единственный способ получить ближайшее слово — перебор символов до ближайшего пробела. Кодировать такой алгоритм утомительно, а внести ошибки легко.
900 Глава 28. Документы Один из разработчиков WPF — Праджакта Джоши (Prajakta Joshi) — опубликовал (по адресу http://blogs.msdn.com/prajakta/archive/2006/ll/01/navigate-words-in- richtextbox.aspx) неплохое решение для обнаружения промежутков между словами. С помощью этого кода можно быстро создать много интересных эффектов. Например, приведенная ниже программа выбирает очередное слово, когда пользователь щелкает правой кнопкой мыши, а затем выводит это слово в отдельном текстовом поле. Возможны и другие варианты: вывод всплывающего окна со словарным определением, запуск почтовой программы или переход по ссылке в веб-браузере и т.д. private void richTextBox_MouseDown(object sender, MouseEventArgs e) { if (e.RightButton == MouseButtonState.Pressed) { // Получение указателя TextPointer, ближайшего к указателю мыши. TextPointer location = richTextBox.GetPositionFromPoint( Mouse.GetPosition(richTextBox), true); I/ Получение ближайшего слова с помощью этого TextPointer. TextRange word = WordBreaker.GetWordRange(location); // Вывод слова. txtSelectedWord.Text = word.Text; } } На заметку! На самом деле этот код не взаимодействует с событием MouseDown, т.к. элемент RichTextBox перехватывает и подавляет события MouseUp и MouseDown. Вместо этого данный обработчик прикреплен к событию PreviewMouseDown, которое возникает перед событием MouseDown. Помещение объектов UIElement в элемент управления RichTextBox Как уже было сказано в этой главе, для помещения элементов, не связанных с содержимым (порожденных от UIElement), внутрь потокового документа можно использовать классы BlockUIContainer и InlineUIContainer. Но если применять эту технологию для добавления в RichTextBox интерактивных элементов (текстовые поля, кнопки, флажки, гиперссылки и т.д.), они будут автоматически заблокированы и текстурированы. Это поведение можно обойти и сделать так, чтобы RichTextBox разрешал работу содержащихся в нем элементов управления, как это делают контейнеры FlowDocument, доступные только для чтения. Для этого нужно просто присвоить свойству RichTextBox.IsDocumentEnabled значение true. Несмотря на простоту, следует тщательно все обдумать, прежде чем присвоить свойству IsDocumentEnabled значение true. После включения элементов управления в RichTextBox возникают самые разные сложности. Например, элемент управления можно удалить, а затем отменить удаление (с помощью комбинации <Ctrl+Z> или команды Undo), но при отмене удаления теряются их обработчики событий. Между соседними контейнерами можно вставить текст, но если попробовать вырезать и вставить блок содержимого, в котором имеются объекты UIElement, они будут отброшены. По этим и аналогичным причинам лучше не использовать элементы управления внутри RichTextBox.
Глава 28. Документы 901 Фиксированные документы Потоковые документы позволяют динамически компоновать сложное текстовое содержимое таким способом, который удобен для чтения на экране. Фиксированные документы — документы на основе XPS (XML Paper Specification — XML-спецификация бумажных документов) — гораздо менее гибки. Это документы, пригодные для печати, которые можно распространять и печатать на любом выводном устройстве в полном соответствии с первоначальным источником. В них используется точная, фиксированная компоновка, поддерживается внедрение шрифтов и исключается случайное изменение компоновки. XPS не просто часть WPF. Это стандарт, тесно интегрированный в системы Windows Vista и Windows 7. Обе версии Windows содержат драйвер печати, который может создавать документы XPS (в любом приложении) и средство просмотра для их отображения. Эти две части похожи на Adobe Acrobat, позволяя пользователям создавать и просматривать электронные документы, пригодные для печати, и добавлять аннотации. Кроме того, Microsoft Office 2007 и Microsoft Office 2007 позволяют сохранять документы в форматах XPS и PDF. На заметку! XPS-файлы на самом деле являются ZIP-файлами, содержащими библиотеку сжатых файлов: шрифтов, изображений и текстового содержимого для отдельных страниц (в том числе ХМL-разметку, похожую на XAML). Чтобы увидеть внутреннее содержимое XPS-файла, достаточно поменять его расширение на . zip и открыть его. Обзор формата XPS-файлов находится по адресу http://tinyurl.com/yg7jqjb. Вывести на экран документ XPS не труднее, чем потоковый документ. Единственным отличием является средство просмотра. Вместо использования одного из контейнеров FlowDocument (FlowDocumentReader, FlowDocumentScrollViewer или FlowDocumentPageViewer), применяется именованный DocumentViewer, в котором имеются элементы управления для поиска и изменения масштаба (рис. 28.16). Кроме того, он предлагает такой же набор свойств, методов и команд, как и контейнеры FlowDocument. ■ ., ШШ ЕВВПП ш CHAPTER 19 Documents Usmg the WPF skills you re picked op *o far. you can craft windows and pages tf a wide variety of elements Displaying fixed text is easy—you simply need to add TextBlock and Label elements to the mix However, the Label and TextBlock aren t a good solution if yon need to dupU volumes of text (like a newspaper article or detailed instructions for online help» I amounts of text are particularly problematic if you want your text to fit ш a resizal in the best possible way. For example, if you pile a Large swath of text into a Text! stretch it to fit a wide wmdow you'll end op with long bees thai are difficult to re Similarly, if you combine text and pictures usmg the ordinary TextBlock and Imaj elements, you'll find that they no longer Ime op correctly when the wmdow Chang To deal with these issues, WPF includes a set of higher-level features that wor щ -■". Tft*ff Ф-ffMfff ffffWf YHBfPiflHP^nf frTff .am*»1"** nf rontwr ■ Type fr.» - __: Рис. 28.16. Фиксированный документ
902 Глава 28. Документы Вот код, который позволяет загрузить XPS-файл в память и вывести его в контейнере DocumentViewer: XpsDocument doc = new XpsDocument("filename.xps", FileAccess.Read); docViewer.Document = doc.GetFixedDocumentSequence (); doc.Close (); В классе XpsDocument нет ничего особенного. Он предоставляет уже знакомый нам метод GetFixedDocumentSequence(), который возвращает ссылку на корень документа со всем его содержимым. Он также содержит метод AddFixedDocumentO для создания последовательности документов в новом документе и два метода для управления цифровыми подписями (SignDigitallyO и RemoveSignatureO). XPS-документы тесно связаны с концепцией печати. Отдельный XPS-документ имеет фиксированный размер страницы и компонует свой текст так, чтобы он занял доступное место. Как и при работе с потоковыми документами, имеется непосредственная поддержка печати фиксированных документов с помощью команды ApplicationCommands. Print. В главе 29 вы научитесь управлять многими аспектами печати и узнаете, как с помощью модели XPS можно создать простую функцию предварительного просмотра печатаемого документа. Аннотации В WPF имеется возможность вставки в потоковые и фиксированные документы аннотаций, т.е. комментариев и выделений цветом. Эти аннотации могут использоваться для обозначения различных редакций, выделения ошибок или пометки важных фрагментов. Во многих продуктах имеется множество различных видов аннотаций. Например, Adobe Acrobat позволяет вставлять в документ отметки о редакциях и геометрические фигуры. В WPF используются лишь два вида аннотаций: • Выделение цветом. Можно выделить часть текста, задав цвет ее фона. (В принципе при этом в WPF применяется частично прозрачный цвет поверх текста, но в результате пользователю кажется, что изменен фон.) • "Наклейки". Можно выделить часть текста и прикрепить к ней плавающее окошко, содержащее дополнительную текстовую или рукописную информацию. На рис. 28.17 приведен пример, который мы создадим в этом разделе. На нем показан потоковый документ с выделенной областью текста и двумя наклейками: с рукописным и текстовым содержимым. Все четыре контейнера документов WPF — FlowDocumentReader, FlowDocument ScrollViewer, FlowDocumentPageViewer и DocumentViewer — поддерживают аннотации. Но для их использования нужно выполнить два действия. Во-первых, необходимо вручную разрешить службу аннотирования с помощью небольшого кода инициализации. Во-вторых, потребуется добавить элементы управления (кнопки инструментальной панели), которые позволят пользователям добавлять поддерживаемые виды аннотаций. Классы аннотаций Система аннотаций в WPF основана на нескольких классах из пространств имен System.Windows.Annotations и System.Windows.Annotations.Storage. Вот основные из них: • AnnotationService. Этот класс управляет функцией аннотаций. Чтобы использовать аннотации, программист должен самостоятельно создать этот объект.
Глава 28. Документы 903 • AnnotationStore. Этот класс управляет хранением аннотаций. Он определяет несколько методов для создания и удаления отдельных аннотаций. Кроме того, в нем имеются события, позволяющие реагировать на создание или изменение аннотаций. Этот класс является абстрактным, и на данный момент у него только один наследник: класс XmlStreamStore. Этот класс преобразует аннотации в формат XML и позволяет сохранить их в любом потоке. • AnnotationHelper. Данный класс предоставляет небольшой набор статических методов для работы с аннотациями. Эти методы заполняют нишу между хранящимися аннотациями и контейнером документа. Большинство методов класса AnnotationHelper работают с текстом, выделенным в данный момент времени в контейнере документа (позволяя выделять его цветом, вставлять аннотации к нему или удалять существующие аннотации). Этот класс позволяет также находить в документе аннотированные места. В последующих разделах мы используем каждый из этих трех основных компонентов. Совет. Классы AnnotationStore и AnnotationHelper содержат методы для создания и удаления аннотаций. Однако методы класса AnnotationStore работают с текстом, выделенным в контейнере документа. По этой причине методы класса AnnotationStore больше подходят для программной работы с аннотациями (без взаимодействия с пользователем), а методы класса AnnotationHelper удобнее для реализации пользовательских изменений в аннотациях (например, добавление аннотации, когда пользователь выделил часть текста и щелкнул на кнопке). •\ FtowDocumemAnnotations !-:.: •■•» Ni >te Delete Noted) >. Show AH Annctat-on? Chapter l Part One It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the Г"~ 3"V through the glass doors of Victory Man! enough to prevent a swirl of gritty dust from The! coloij wall A reference to Winston Chufch.ll? page and old rag mats. At one end of it a indoor display, had been tacked to the }mous face, more than a metre wide: the face of a man of about forty-five, with a heavy black frnoustach^ and ruggedly handsome features. Winston made if trying the lift. Even at the best of times it wa present the electric current was cut off dura part of the economy drive in preparation fon Рис. 28.17. Аннотации в потоковом документе Включение службы аннотаций Перед работой с аннотациями необходимо включить службу аннотаций с помощью объектов AnnotationService и AnnotationStream. В примере, показанном на рис. 28.17, имеет смысл создать объект AnnotationService во время первоначальной загрузки окна. Создание службы выполняется просто: нужно
904 Глава 28. Документы лишь создать объект AnnotationService для программы чтения документа и вызвать метод AnnotationService.Enable(). Однако при вызове метода Enable() ему необходимо передать объект AnnotationStore. Объект AnnotationService управляет информацией аннотаций, a AnnotationStore управляет хранением этих аннотаций. Ниже показан код, который создает и включает аннотации. // Поток для хранения аннотаций, private MemoryStream annotationStream; // Служба аннотирования. private AnnotationService service; protected void window_Loaded(object sender, RoutedEventArgs e) { // Создание AnnotationService для контейнера документов. service = new AnnotationService(docReader); // Создание хранилища для аннотаций. annotationStream = new MemoryStream(); AnnotationStore store = new XmlStreamStore(annotationStream); // Включение аннотирования, service.Enable (store); } Обратите внимание: в этом примере аннотации хранятся в потоке MemoryStream. Поэтому они будут удалены сразу после уборки мусора MemoryStream. Если нужно сохранить аннотации, чтобы повторно применять их к исходному документу, то это можно сделать двумя способами. Вместо MemoryStream можно создать экземпляр FileStream, и тогда данные аннотаций будут записываться при их применении. Или же можно после закрытия документа скопировать данные, хранящиеся в MemoryStream, в другое место (например, в файл или запись базы данных). Совет. Если вы не уверены, было ли включено аннотирование для контейнера документов, можно использовать статический метод AnnotationService.GetServiceO и передать ему ссылку на контейнер документа. Если аннотирование еще не включено, этот метод возвращает пустую ссылку. В какой-то момент понадобится закрыть поток приложения и отключить службу аннотирования. В данном примере эти задачи выполняются, когда пользователь закрывает окно: protected void window_Unloaded(object sender, RoutedEventArgs e) { if (service '= null && service.IsEnabled) { // Сброс аннотаций в поток, service.Store.Flush(); // Отключение аннотирования, service.Disable(); annotationStream.Close(); } } Вот и все, что потребуется сделать, чтобы включить аннотирование в документе. Если при вызове метода AnnotationService.Enable () в объекте потока уже определены какие-либо аннотации, то они тут же появятся. Однако все равно нужно будет добавить элементы управления, с помощью которых пользователь сможет добавлять или удалять аннотации. Этому будет посвящен следующий раздел.
Глава 28. Документы 905 Совет. Каждый контейнер документа может иметь один экземпляр AnnotationService. Каждый документ должен иметь свой экземпляр AnnotationStore. При открытии нового документа нужно отключить AnnotationService, сохранить и закрыть текущий поток аннотаций, создать новый экземпляр AnnotationStore, а затем снова включить AnnotationService. Создание аннотаций Существуют два способа работы с аннотациями. Можно использовать один из методов класса AnnotationHelper, который позволяет создавать аннотации (CreateTextSticky NoteForSelectionO и CreatelnkStickyNoteForSelection ()), удалять их (Delete TextStickyNotesForSelection() и DeletelnkStickyNotesForSelectionO) и выделять текст цветом (CreateHighlightsEorSelection() и ClearHighlightsForSelectionO). Часть ForSelection в имени метода означает, что эти методы применяют аннотацию к тексту, выделенному в данный момент. Методы AnnotationHelper работают замечательно, но гораздо проще использовать соответствующие команды, предлагаемые классом AnnotationService. Эти команды можно связать непосредственно с кнопками пользовательского интерфейса. Именно такой подход и будет задействован в следующем примере. Чтобы использовать класс AnnotationService в XAML, нужно отобразить пространство имен System.Windows.Annotations на пространство имен XML, поскольку оно не является одним из основных пространств имен в WPF. Можно добавить следующее отображение: <Window х:Class="XpsAnnotations.FlowDocumentAnnotations" xmlns:annot= "clr-namespace:System.Windows.Annotations;assembly=PresentationFramework" . . . > Теперь можно создать кнопку, которая создает текстовое примечание для выделенной в данный момент части документа: <Button Command=llannot:AnnotationService.CreateTextStickyNoteCommand"> Текстовое примечание </Button> Если теперь пользователь щелкнет на этой кнопке, на экране появится зеленое окошко примечания. Пользователь может ввести текст внутри этого окошка. (Если с помощью команды CreatelnkStickyNoteCommand создать рукописное примечание, пользователь сможет рисовать внутри окошка.) На заметку! В данном элементе Button не задано свойство CommandTarget. Это объясняется тем, что кнопка располагается в панели инструментов. Как было сказано в главе 10, класс Toolbar может автоматически задать свойство CommandTarget для элемента, находящегося в фокусе. Естественно, если использовать ту же команду для кнопки, находящейся за пределами панели инструментов, нужно будет установить свойство CommandTarget так, чтобы оно указывало на средство просмотра документов. Наклейки не обязательно держать видимыми. Если щелкнуть на кнопке сворачивания в правом верхнем углу окошка примечания, оно исчезнет. Останется лишь выделенная часть документа, для которой создано примечание. Если навести указатель мыши на эту выделенную область, появится значок примечания (рис. 28.18) — щелчок на нем восстановит окошко наклейки. Объект AnnotationService сохраняет позицию каждого окошка примечания, поэтому если перетащить одно из них в какое-то другое место в документе, закрыть его, а затем открыть снова, окошко появится на прежнем месте.
906 Глава 28. Документы • ' FlowDocumcntAnnotations Text Not? Ink Note Delete Note(s) Chapter l Part One It was a bright cold day in April, and the clocks were striking thirteen. (Winston Sniitty his chin nuzzled into his breast in an effort to iiiiape the vile wind, slipped quickly through the glass doors of Victory Mansions, though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, И !lli [Si - * Рис. 28.18. "Скрытая" аннотация В предыдущем примере аннотация создана без информации об ее авторе. Если аннотации могут добавлять несколько пользователей, то почти наверняка понадобится хранить какую-то идентификацию. Для этого нужно передать строку, идентифицирующую автора, в качестве параметра команды: <Button Command="annot:AnnotationService.CreateTextStickyNoteCommand" CommandParameter="{StaticResource AuthorName}"> Текстовое примечание </Button> Здесь предполагается, что имя автора задано в виде ресурса: <sys : String x:Key="AuthorNamell> [Anonymous] </sys : String> Это позволяет указать имя автора при первоначальной загрузке окна, когда производится инициализация службы аннотирования. Можно использовать имя, указанное пользователем, которое неплохо было бы хранить в файле .config в виде параметра настройки приложения. А можно использовать приведенный ниже код, чтобы захватить имя учетной записи текущего пользователя Windows с помощью класса System. Security. Principal. Windows Identity: Windowsldentity identity = Windowsldentity.GetCurrent(); this.Resources["AuthorName"] = identity.Name; Чтобы создать окно, показанное на рис. 28.17, потребуется также создать кнопки, использующие команды CreatelnkStickyNoteCommand (для создания окошка рукописного примечания) и DeleteStickyNotesCommand (для удаления ранее созданных наклеек): <Button Command="annot:AnnotationService.CreatelnkStickyNoteCommand" CommandParameter="{StaticResource AuthorName}"> Рукописное примечание </Button> <Button Command="annot .-AnnotationService . DeleteStickyNotesCommand"> Удалить примечания </Button> Команда DeleteStickyNotesCommand удаляет все наклейки в выделенном в данный момент тексте. Но даже без такой кнопки пользователь сможет удалить аннотации с помощью меню Edit (Правка) в окне примечания (если только в окне примечания не используется другой шаблон управления, не включающий данную функцию).
Глава 28. Документы 907 Осталось создать кнопки для выделения цветом. Чтобы добавить выделение, используется команда CreateHighlightCommand с объектом Brush в качестве параметра CommandParameter. При этом нужно обязательно использовать кисть с частично прозрачным цветом. Иначе выделенное содержимое окажется совершенно невидимым, как показано на рис. 28.19. ■ FlowDocumemAnnotations жжшм Text Note Ink Nou» Delete Note(s) * Show АЙ Annotations ■ _ Chapter l Part One It was a bright cold day in April, and the clocks were striking thirteen. Winston Smith, his chin nuzzled into his breast in an effort to escape the vile wind, , though not quickly enough to prevent a swirl of gritty dust from entering along with him. The hallway smelt of boiled cabbage and old rag mats. At one end of it a coloured poster, too large for indoor display, had been tacked to the wall. It depicted simply an enormous face, : p i of 2 >[■) : - Рис. 28.19. Выделение содержимого с помощью непрозрачного цвета Например, если вы хотите использовать для выделения текста сплошной цвет #FF32CD32 ("лимонно-зеленый"), необходимо уменьшить значение альфа-канала, которое задается первыми двумя шестнадцатеричными цифрами. (Оно может быть от О до 255, где 0 соответствует полной прозрачности, а 255 — полной непрозрачности.) Например, цвет #54FF32CD32 позволяет получить полупрозрачный вариант лимонно- зеленого цвета, со значением альфа-канала 84 (то есть 5416). Следующая разметка определяет две кнопки: одну для выделения желтым цветом, и одну для выделения зеленым. Сама кнопка вместо текста содержит просто квадратик размером 15x15 с соответствующим цветом. CommandParameter определяет кисть SolidColorBrush, которая использует тот же цвет, но с уменьшенной непрозрачностью, что позволяет видеть текст: <Button Background=,,Yellow" Width=,,15" Height=,,15" Margin=, 0й Command=llannot: AnnotationService. CreateHighlightCommand1^ <Button.CommandParameter> <SolidColorBrush Color=,,#54FFFF00"></SolidColorBrush> </Button.CommandParameter> </Button> <Button Background=l,LimeGreenM Width=ll15" Height=511 Margin=, 0" Command="annot:AnnotationService.CreateHighlightCommand"> <Button.CommandParameter> <SolidColorBrush Color=,,#54 32CD32"x/SolidColorBrush> </Button.CommandParameter> </Button> И, наконец, добавим последнюю кнопку, чтобы удалять выделение цветом в выбранной области: <Button Command=llannot:AnnotationService.ClearHighlightsCommand"> Очистить выделения </Button>
908 Глава 28. Документы На заметку! При печати документа с аннотациями с помощью команды ApplicationCommands. Print аннотации выглядят так же, как и на экране. То есть свернутые аннотации будут выглядеть свернутыми, видимые аннотации будут напечатаны поверх содержимого (и могут закрывать другие части документа) и т.д. Если нужно распечатать документ без аннотаций, просто отключите службу аннотирования перед печатью. Просмотр аннотаций Иногда бывает нужно просмотреть все аннотации, прикрепленные к документу. На это может быть много разных причин: вывести сводный отчет по всем аннотациям, напечатать список аннотаций, экспортировать текст аннотации в файл и т.п. Объект AnnotationStore позволяет относительно легко получить список всех содержащихся аннотаций с помощью метода GetAnnotations (). После этого можно просмотреть каждую аннотацию в виде объекта Annotation: IList<Annotation> annotations = service.Store.GetAnnotations(); foreach (Annotation annotation in annotations) } Теоретически можно найти аннотации в некоторой части документа с помощью перегруженной версии метода GetAnnotationsO, который принимает объект ContentLocator. Но на практике это оказывается довольно сложной задачей, поскольку объект ContentLocator не очень удобен в использовании, да еще и нужно точно определить начальную позицию аннотации. Любой объект Annotation имеет свойства, перечисленные в табл. 28.8. Таблица 28.8. Свойства объекта Annotation Имя Описание Id Глобальный идентификатор (GUID), который однозначно идентифицирует данную аннотацию. Если известен GUID аннотации, то соответствующий объект Annotation можно получить с помощью метода AnnotationStore.GetAnnotation(). (Естественно, невозможно знать GUID существующей аннотации, если он предварительно не получен с помощью вызова метода GetAnnotations () или события AnnotationStore при создании или изменении аннотации.) AnnotationType Имя элемента XML, который определяет данный тип аннотации, в формате пространство_имен:локальное_имя Anchors Коллекция, содержащая ноль или более объектов AnnotationResource, которые показывают, к какому тексту относится аннотация Cargos Коллекция, содержащая ноль или более объектов AnnotationResource, которые хранят пользовательские данные аннотации. Это текст текстового примечания или штрихи рукописного примечания Authors Коллекция, содержащая ноль или более строк, указывающих, кто создал аннотацию CreationTime Дата и время создания аннотации LastModif icationTime Дата и время последнего изменения аннотации
Глава 28. Документы 909 Объект Annotation — это просто тонкая оболочка для данных XML, которые хранят саму аннотацию. Поэтому, в частности, трудно получить информацию из свойств Anchors и Cargos. Например, если нужно получить текст аннотации, потребуется просмотреть второй элемент в коллекции Cargos. Однако содержащийся в нем текст хранится в кодировке Base64 (это позволяет избежать проблем, если примечание содержит символы, запрещенные в содержимом элементов XML). Чтобы все-таки просмотреть этот текст, необходим примерно такой нудный код: // Проверка текстовой информации, if (annotation.Cargos.Count > 1) { // Декодирование текста примечания. string base64Text = annotation.Cargos [1] .Contents [0] .InnerText; byte[] decoded = Convert. FromBase64Stnng (base64Text) ; // Запись декодированного текста в поток. MemoryStream m = new MemoryStream(decoded); // Преобразование байтов текста в осмысленнную строку //с помощью объекта StreamReader. StreamReader r = new StreamReader(m); string annotationXaml = r.ReadToEnd(); r.Close(); // Вывод содержимого аннотации. MessageBox.Show(annotationXaml); } Этот код получает текст аннотации, упакованный в элементе XAML <Section>. Открывающий дескриптор <Section> содержит атрибуты, задающие множество различных типографических подробностей. Внутри элемента <Section> находится несколько элементов <Paragraph> и <Run>. На заметку! Подобно текстовой аннотации, рукописная аннотация тоже имеет коллекцию Cargos с более чем одним элементом. Однако в этом случае коллекция Cargos содержит данные о штрихах, а не декодируемый текст. Если предыдущий код применить к рукописной аннотации, окно сообщения будет пустым. Значит, если документ содержит текстовые и рукописные аннотации, необходимо проверять свойство Annotation.AnnotationType, чтобы убедиться, что это именно текстовая аннотация, и уже затем использовать данный код. Если нужно просто получить текст без сопутствующего кода XML, можно использовать класс XamlReader, чтобы выполнить его преобразование (и не использовать StreamReader). XML можно преобразовать в объект Section посредством следующего кода: if (annotation.Cargos.Count > 1) { // Декодирование текста примечания. string base64Text = annotation.Cargos [1] .Contents [0] .InnerText; byte[] decoded = Convert. FromBase64Stnng (base64Text) ; // Запись декодированного текста в поток. MemoryStream m = new MemoryStream(decoded); // Обратное преобразование XML в объект Section. Section section = XamlReader.Load(m) as Section; m.Close(); // Получение текста внутри объекта Section. TextRange range = new TextRange(section.Contentstart, section.ContentEnd);
910 Глава 28. Документы // Вывод содержимого аннотации. MessageBox.Show(range.Text); } Как видно из табл. 28.8, можно не только извлечь текст из аннотации, но и узнать, кто ее автор, а также дату создания и последнего изменения. Можно также получить информацию о том, где именно в документе прикреплена аннотация. Однако для этой задачи коллекция Anchors не очень подходит, поскольку она предлагает низкоуровневую коллекцию объектов Annotation Re source, заключающих в себе дополнительные данные XML. Лучше воспользоваться методом GetAnchorlnfoO из класса AnnotationHelper. Этот метод принимает аннотацию и возвращает объект класса, реализующего интерфейс I Anchor Info: IAnchorlnfo anchorlnfo = AnnotationHelper.GetAnchorlnfо(service, annotation); В интерфейсе IAnchorlnfo объединены AnnotationResource (свойство Anchor), аннотация (Annotation) и наиболее полезная информация — объект, представляющий местоположение аннотации в дереве документа (ResolvedAnchor). Свойство ResolvedAnchor имеет объектный тип, однако текстовые аннотации и выделения цветом всегда возвращают объекты TextAnchor. Объект TextAnchor описывает начальную точку прикрепленного текста (BoundingStart) и его конечную точку (BoundingEnd). Ниже показано, как можно определить выделенный текст для аннотации посредством IAnchorlnfo: IAnchorlnfo anchorlnfo = AnnotationHelper.GetAnchorlnfо(service, annotation); TextAnchor resolvedAnchor = anchorlnfo.ResolvedAnchor as TextAnchor; if (resolvedAnchor != null) { TextPointer startPointer = (TextPointer)resolvedAnchor.BoundingStart; TextPointer endPointer = (TextPointer)resolvedAnchor.BoundingEnd; TextRange range = new TextRange(startPointer, endPointer); MessageBox.Show(range.Text); } Объекты TextAnchor можно также использовать в качестве отправной точки для получения оставшегося дерева документа: // Прокрутка документа, чтобы был выведен абзац с текстом. TextPointer textPointer = (TextPointer)resolvedAnchor.BoundingStart; textPointer.Paragraph.BringlntoView(); Среди примеров в данной главе есть один, в котором этот прием используется для создания списка аннотаций. При выборе аннотации в списке аннотированная часть документа автоматически отображается в документе. В обоих этих случаях метод AnnotationHelper.GetAnchorlnfо () позволяет переходить от аннотации к аннотированному тексту, подобно тому как метод AnnotationStore. GetAnnotationO позволяет переходить от содержимого документа к аннотациям. Несмотря на относительную простоту просмотра существующих аннотаций, их обработка в WPF реализована слабее. Пользователь без труда может открыть наклейку, перетащить ее в новое место, изменить текст и т.п., однако программным образом эти задачи решить не так просто. Все свойства объекта Annotation доступны только для чтения. Не существует готовых и доступных способов изменения аннотаций, поэтому для редактирования аннотации приходится удалить и повторно создать ее. Это можно сделать с помощью методов AnnotationStore или AnnotationHelper (если аннотация прикреплена к выделенному в данный момент времени тексту). Однако оба подхода весьма трудоемки. При использовании AnnotationStore необходимо создать объект
Глава 28. Документы 911 Annotation вручную. При использовании AnnotationHelper требуется явно создать выделение, содержащее нужную часть текста, прежде чем создать аннотацию. Оба подхода трудоемки, и при их реализации легко внести ошибки. Реагирование на изменения аннотаций Вы уже знаете, как объекты AnnotationS£ore позволяют извлекать из документа аннотации (с помощью метода GetAnnotationsO) и обрабатывать их (с помощью методов DeleteAnnotation() и AddAnnotationO). Класс AnnotationStore имеет еще одну возможность — он генерирует события, информирующие об изменениях в аннотациях. AnnotationStore предоставляет четыре события: AnchorChanged (возникает при перемещении аннотации), AuthorChanged (возникает при изменении информации об авторе аннотации), CargoChanged (возникает при изменении данных аннотации, включая ее текст) и StoreContentChanged (возникает при создании, удалении или каком- либо изменении аннотации). Среди онлайновых примеров для этой главы имеется и пример отслеживания аннотации. Обработчик события StoreContentChanged запускается при выполнении изменений в аннотации. Он получает всю информацию об аннотации (с помощью метода GetAnnotationsO), а затем выводит текст аннотации в списке. На заметку! События аннотаций возникают после выполнения изменения. Это означает, что они не позволяют подключить пользовательскую логику, расширяющую действие аннотации. Например, невозможно добавить в аннотацию оперативную информацию или выборочно отменить попытку пользователя отредактировать или удалить аннотацию. Хранение аннотаций в фиксированном документе В предыдущих примерах использовались аннотации в потоковых документах. В таких случаях аннотации можно сохранять для работы с ними в будущем, однако их нужно хранить отдельно — например, в отдельном файле XML. Для фиксированного документа можно применять тот же подход, но есть дополнительная возможность — хранить аннотации прямо в файле документа XPS. Более того, в одном документе можно хранить даже несколько наборов разных аннотаций. Нужно лишь использовать поддержку пакетов в пространстве имен System. 10.Packaging. Как уже было сказано, каждый документ XPS на самом деле является архивом ZIP, который содержит несколько файлов. При сохранении аннотации в документе XPS на самом деле внутри архива ZIP создается еще один файл. Сначала необходимо выбрать URI для идентификации аннотаций. Ниже показан пример, в котором используется имя AnnotationStream: Uri annotationUri = PackUriHelper.CreatePartUri( new Uri("AnnotationStream", UriKind.Relative)); Затем нужно получить пакет Package для документа XPS с помощью статического метода PackageStore.GetPackage(): Package package = PackageStore.GetPackage(doc.Uri); После этого можно создать часть пакета, в которой будут храниться аннотации из документа XPS. Только нужно проверить, существует ли уже такая часть пакета аннотаций (если документ уже загружался, и в него добавлялись аннотации). Если она не существует, можно создать ее сейчас:
912 Глава 28. Документы PackagePart annotationPart = null; if (package.PartExists(annotationUri)) { annotationPart = package.GetPart(annotationUri); } else { annotationPart = package.CreatePart(annotationUri, "Annotations/Stream"); } И, наконец, необходимо создать объект AnnotationStore, который будет содержать часть пакета аннотации, а затем, как обычно, разрешить работу службы аннотирования: AnnotationStore store = new XmlStreamStore(annotationPart.GetStream()); service = new AnnotationService(docViewer); service.Enable(store); Для работы этой технологии необходимо открыть файл XPS в режиме FileMode. ReadWrite, а не в режиме FileMode.Read, чтобы аннотации можно было записывать в файл XPS. По той же причине во время работы службы аннотирования документ XPS должен быть открыт Закрыть документ XPS можно тогда, когда будет закрыто окно (или открыт новый документ). Настройка внешнего вида наклеек Окошки примечаний, которые появляются при создании текстового или рукописного примечания, являются экземплярами класса StickyNotControl, который определен в пространстве имен System.Windows.Controls. Как и для всех элементов управления WPF, внешний вид StickyNoteControl можно настроить с помощью средств настройки стилей или применив новый шаблон элемента управления. Например, нетрудно создать стиль, который можно применять ко всем экземплярам StickyNoteControl с помощью свойства Style.TargetType. Вот пример изменения цвета фона для всех экземпляров StickyNoteControl: <Style TargetType="{x:Type StickyNoteControl}"> <Setter Property="Background" Value="LightGoldenrodYellow"/> </Style> Для создания более динамичной версии StickyNoteControl можно написать триггер стиля, который будет реагировать на свойство StickyNoteControl.IsActive, которое равно true, если наклейка имеет фокус ввода. Чтобы получить дополнительные возможности для управления, можно использовать совершенно другой шаблон элемента управления для StickyNoteControl. Единственная хитрость заключается в том, что шаблон StickyNoteControl меняется в зависимости от того, что он хранит: текстовое примечание или рукописное. Если пользователю разрешено создавать оба типа примечаний, понадобится триггер, который будет выбирать один из двух шаблонов. Рукописные примечания должны содержать элемент InkCanvas, а текстовые — RichTextBox. В обоих случаях этот элемент должен иметь имя PART_ContentControl. Ниже показан стиль, который применяет простейший шаблон элемента управления для примечаний обоих типов. Он задает размеры окошка примечания и выбирает нужный шаблон на основе типа содержимого примечания. <Style x:Key="MinimumStyle" TargetType="{x:Type StickyNoteControl}"> <Setter Property="OverridesDefaultStyle" Value="true" /> <Setter Property="Width" Value=00" /> <Setter Property="Height" Value =00" />
Глава 28. Документы 913 <Style.Triggers> <Trigger Property="StickyNoteControl.StickyNoteType" Value="{x:Static StickyNoteType.Ink}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate> <InkCanvas Name="PART_ContentControl" Background="LightYellow" /> </ControlTemplate> </Setter.Value> </Setter> </Trigger> <Trigger Property="StickyNoteControl.StickyNoteType" Value="{x:Static StickyNoteType.Text}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate> <RichTextBox Name=,,PART_ContentControl" Background="LightYellow,,/> </ControlTemplate> </Setter.Value> </Setter> </Trigger> </Style.Triggers> </Style> Резюме Многие разработчики уже знают, что WPF предлагает новую модель для рисования, компоновки и анимации. Однако ее богатые возможности работы с форматированными документами часто остаются незамеченными. В этой главе вы узнали, как создавать потоковые документы, как компоновать текст внутри них самыми разными способами и как управлять отображением текста в разных контейнерах. Вы научились также использовать объектную модель FlowDocument для динамического изменения частей документа и рассмотрели элемент RichTextBox — фундамент для расширенных возможностей редактирования текста. Кроме того, были кратко рассмотрены фиксированные документы и класс XpsDocument. Модель XPS обеспечивает механизм для новой функции печати в WPF, которая является темой следующей главы.
ГЛАВА 29 Печать Печать в WPF существенно мощнее, чем была в Windows Forms. Те задачи, которые были неразрешимы с применением библиотек .NET, и которые вынуждали использовать Win32 API или WMI (такие как проверка очереди печати), теперь полностью поддерживаются с использованием классов из нового пространства имен System. Printing. Еще более радикальные изменения претерпела полностью пересмотренная модель печати, которая организует все кодирование вокруг единственного ингредиента: класса PrintDialog из пространства имен System.Windows.Controls. Класс PrintDialog позволяет отображать диалоговое окно Print (Печать), в котором пользователь может выбрать принтер и изменить его настройки, а также отправлять непосредственно на принтер элементы, документы и низкоуровневые визуальные компоненты. Вы этой главе будет показано, как использовать класс PrintDialog для создания правильно масштабированных и разбитых на страницы печатных документов. Базовая печать Хотя WPF включает в себя десятки классов, имеющих отношение к печати (большинство из которых находятся в пространстве имен System.Printing), существует одна начальная точка, которая призвана облегчить жизнь: класс PrintDialog. PrintDialog заключает в себе знакомое диалоговое окно Print, которое позволяет пользователю выбрать принтер и установить несколько стандартных настроек печати, такие как количество копий (рис. 29.1). Однако класс PrintDialog — это нечто больше, чем просто симпатичное окно; он также обладает встроенной способностью инициировать вывод на печать. Чтобы отправить задание на печать посредством класса PrintDialog, необходимо воспользоваться одним из двух описанных ниже методов. • PrintVisualO работает с любым классом, унаследованным от System.Windows. Media.Visual. Сюда относится любая графика, нарисованная вручную, и любой элемент, помещенный в окно. • PrintDocument() работает с любым объектом DocumentPaginator. Сюда относятся те, что используются для разбиения на страницы FlowDocument (или XpsDocument), и все специальные объекты DocumentPaginator, которые создаются для работы с данными. В следующих разделах рассматриваются разнообразные стратегии, которые можно применять для создания вывода на печать.
Глава 29. Печать 915 состо»«е: Lexmark 2600 Series FOE: Двойства Найти принтер... печать а файл двусторонняя печать Страиии* в все текущая : Введите номера или диапазоны страниц, разделенные запятыми. Например: 1,3,5-12 '^разобрать, по к Напечатать: Документ Все страмииы диапазон. Q 3 чисдострамицна листе: Итраница по размеру страницы: Текущий а Рис. 29.1. Отображение PrintDialog Печать элемента Простейший подход к печати состоит в использовании преимущества модели, которая уже используется для визуализации на экране. С помощью метода PrintDialog. Print Visual () можно отправлять любой элемент в окне (и все его дочерние элементы) прямо на принтер. Чтобы увидеть пример в действии, взгляните на окно, показанное на рис. 29.2. Оно содержит контейнер Grid, в котором располагаются вс ^чементы. В верхней строке находится контейнер Canvas, содержащий в себе элементы Т <tBlockH Path (которые визуализируются как прямоугольник с эллиптическим отверстием в центре). ■ j PrintVisua! (еНо) l к ere Print * '•-•'Т- •>"" Mj Рис. 29.2. Простой рисунок Отправить Canvas вместе со всем содержимым на принтер можно с помощью следующего фрагмента кода, выполняемого по щелчку на кнопке Print (Печать): PrintDialog printDialog = new PrintDialog(); if (printDialog.ShowDialog () == true) { printDialog.PrintVisual(canvas, "A Simple Drawing"); }
916 Глава 29. Печать Первый шаг предусматривает создание объекта PrintDialog. Следующий шаг — вызов ShowDialogO для отображения диалогового окна Print. Метод ShowDialogO возвращает булевское значение. Возврат true указывает на то, что пользователь щелкнул на кнопке OK, false — на кнопке Cancel (Отмена), а значение null означает, что диалоговое окно было просто закрыто без щелчка на какой-либо кнопке. При вызове методу PrintVisualO передаются два аргумента. Первый — это элемент, который необходимо напечатать, а второй — строка, используемая для идентификации задания печати. Она будет отображаться в очереди печати Windows (в столбце Document Name (Имя документа)). При печати подобным образом вывод особо не управляется. Элемент всегда располагается в левом верхнем углу страницы. Если элемент не включает ненулевых значений Margin, то граница содержимого может оказаться в непечатаемой области страницы и, следовательно, просто не будет распечатана. Недостаток контроля над полями — лишь первое из ограничений этого подхода. Кроме того, невозможно разбить длинное содержимое на страницы, поэтому если окажется, что оно не уместилось на одну страницу, оно будет просто потеряно за нижней ее гранью. Наконец, нельзя управлять масштабированием, используемым при визуализации задания для печати. Вместо этого WPF использует некоторую независимую от устройства систему визуализации, основанную на единицах величиной в 1/96 дюйма. Например, прямоугольник шириной в 96 единиц на мониторе будет иметь 1 дюйм в ширину (при условии, что применяется стандартная установка Windows в 96 dpi) и тот же 1 дюйм на печатной странице. Часто это меньше, чем требуется. На заметку! Очевидно, что WPF выведет намного больше деталей на печатную страницу, потому что вряд ли найдется принтер со столь малым разрешением, как 96 dpi (намного более распространены разрешения принтеров в 600 dpi и 1200 dpi). Однако WPF будет поддерживать на печатной копии тот же размер содержимого, что и на мониторе. На рис. 29.3 показан полностраничный печатный вывод Canvas из окна, представленного на рис. 29.2. Ullo 1 «г* Рис. 29.3. Напечатанный элемент
Глава 29. Печать 917 Причуды PrintDialog Класс PrintDialog скрывает в себе низкоуровневый внутренний класс .NET по имени win32PrintDialog, который, в свою очередь, заключает в себе диалоговое окно Print, представленный API-интерфейсом Win32. К сожалению, эти дополнительные уровни лишают гибкости. Потенциальная проблема заключена в способе, которым класс PrintDialog работает с модальными окнами. В недоступном коде Win32PrintDialog имеется логика, которая всегда делает диалоговое окно Print модальным по отношению к главному окну приложения. Это приводит к странной проблеме, когда отображается модальное окно из главного окна и затем из него вызывается метод PrintDialog.ShowDialogO. Хотя ожидается, что диалоговое окно Print будет модальным по отношению ко второму окну, на самом деле оно модально по отношению к главному окну, а это значит, что пользователь может вернуться ко второму окну и взаимодействовать с ним (даже щелкать на кнопке Print, открывая несколько экземпляров диалогового окна Print)! Несколько неуклюжее решение состоит в том, чтобы вручную изменить главное окно приложения на текущее окно, прежде чем вызвать PrintDialog.ShowDialogO, и затем по завершении немедленно переключить его обратно. Существует и другое ограничение, связанное с работой класса PrintDialog. Поскольку главный поток приложения владеет печатаемым содержимым, невозможно запустить печать в фоновом потоке. Это становится заметно, если выполнение логики печати занимает некоторое время. Есть два возможных решения. Когда визуальные компоненты, которые должны быть напечатаны, конструируются в фоновом потоке (а не извлекаются из существующего окна), то в фоновом потоке можно запустить и печать. Однако более простое решение предусматривает использование диалогового окна PrintDialog для того, чтобы позволить пользователю указать настройки печати, и затем с помощью класса XpsDocumentWriter выполнение действительной печати содержимого вместо вызова методов печати класса PrintDialog. Класс XpsDocumentWriter поддерживает возможность отправки содержимого на принтер асинхронно; это класс описан в разделе "Печать через XPS" далее в главе. Трансформация печатного вывода Возможно, вы помните (из главы 12), что за счет присоединения объекта Transform к свойству RenderTransform или LayoutTransform любого элемента можно изменить способ его визуализации. Объекты Transform помогают решить проблему негибкого вывода на печать, потому что с их помощью можно изменять размеры элемента (ScaleTransform), перемещать его по странице (TranslateTransform) либо делать то и другое (TransformGroup). К сожалению, визуальные компоненты могут размещать себя только одним способом в каждый конкретный момент времени. Это значит, что масштабировать элемент одним способом в окне, а другим — в выводе на печать не удастся. Вместо этого любой объект Transform, который будет применен, изменит и печатный вывод, и экранное представление элемента. Если не хотите идти на компромиссы, эту проблему можно обойти различными способами. Суть идеи — в применении трансформации непосредственно перед созданием печатного вывода с последующим ее удалением. Чтобы предотвратить появление на экране элемента с измененным размером, он может быть временно скрыт. Может показаться, что для сокрытия элемента следует изменить его свойство Visibility, но это скроет его как в окне, так и в печатном выводе, что очевидно не подходит. Возможное решение состоит в изменении Visibility родительского элемента (в данном примере — контейнера компоновки Grid). Это работает, потому что метод PrintVisualO учитывает только указанный элемент и его дочерние элементы, а не родительский.
918 Глава 29. Печать Ниже приведен код для вывода на печать элемента Canvas, показанного на рис. 29.2, которые имеет впятеро большие размеры: PrintDialog printDialog = new PrintDialog(); if (printDialog.ShowDialog () == true) { // Скрыть Grid. grid.Visibility = Visibility.Hidden; // Увеличить вывод в 5 раз. canvas.LayoutTransform = new ScaleTransformE, 5) ; // Напечатать элемент. printDialog.PrintVisual(canvas, "A Scaled Drawing"); // Удалить трансформацию и снова сделать элемент видимым, canvas.LayoutTransform = null; . grid.Visibility = Visibility.Visible; } В этом примере не хватает одной детали. Хотя Canvas (и его содержимое) растягивается, контейнер Canvas все равно использует информацию о компоновке из включающего его Grid. Другими словами, Canvas все равно полагает, что в его распоряжении лишь столько пространства, сколько отводит ему ячейка Grid, в которую он помещен. В рассматриваемом примере это не представляет проблемы, потому что Canvas не ограничивает себя доступным пространством (в отличие от некоторых других контейнеров). Однако проблемы возникнут в ситуации, когда есть текст, который должен заворачиваться в рамках печатной страницы, или если в Canvas используется фон (который в данном примере будет иметь меньшие размеры ячейки Grid, а не размеры всей области под Canvas). Решить эту проблему просто. После установки LayoutTransform (но перед печатью Canvas) понадобится инициировать процесс компоновки вручную, используя методы Measure() и Arrange(), наследуемые каждым элементом от класса UIElement. Трюк состоит в том, что при вызове этим методам передаются размеры страницы, и контейнер Canvas растягивается, чтобы заполнить страницу. (Кстати, поэтому устанавливается свойство LayoutTransform, а не RenderTransform, т.к. нужно, чтобы компоновка принимала во внимание новый растянутый размер.) Для получения размеров страницы служат свойства PrintableAreaWidth и PrintableAreaHeight. На заметку! На основе имен свойств имеет смысл предположить, что PrintableAreaWidth и PrintableAreaHeight отражают печатаемую область страницы, другими словами — часть страницы, которую принтер действительно печатает. (Большинство принтеров не могут печатать очень близко к границам листа, обычно из-за устройства подающих роликов.) Но в действительности PrintableAreaWidth и PrintableAreaHeight просто возвращают полную ширину и высоту страницы в независимых от устройства единицах. Для листа бумаги 8.5-TMS11 (А4) это будет 816 и 1056. (Разделив эти числа на 96 dpi, можно получить полный размер листа в дюймах.) В следующем примере демонстрируется использование свойств PrintableAreaWidth и PrintableAreaHeight. Для красоты у границ страницы остаются поля шириной 10 единиц (около 0,1 дюйма). PrintDialog printDialog = new PrintDialog(); if (printDialog.ShowDialog () == true) { // Скрыть Grid. grid.Visibility = Visibility.Hidden;
Глава 29. Печать 919 // Увеличить размер в 5 раз. canvas.LayoutTransform = new ScaleTransformE, 5); // Определить поля, int pageMargin = 5; // Получить размер страницы. Size pageSize = new Size (printDialog.PnntableAreaWidth — pageMargin * 2, printDialog.PrintableAreaHeight - 20); // Инициировать установку размера элемента, canvas.Measure(pageSize); canvas.Arrange(new Rect(pageMargin, pageMargin, pageSize.Width, pageSize.Height)); // Напечатать элемент. printDialog.PrintVisual(canvas, "A Scaled Drawing"); // Удалить трансформацию и вновь сделать элемент видимым, canvas.LayoutTransform = null; grid.Visibility = Visibility.Visible; } Конечным результатом будет печать любого элемента и масштабирование в соответствии с потребностями (см. полностраничный вывод на рис. 29.4). Этот подход работает исключительно хорошо, однако в его основе лежит несколько запутанный механизм. (jello) iere Рис. 29.4. Масштабированный печатный элемент Печать элементов без их отображения Поскольку способ, по которому отображаются данные в приложении, и способ, которым они должны выводиться на печать, часто отличаются, и иногда имеет смысл создать визуальный элемент программно (вместо использования того, что уже есть в существующем окне). Например, следующий код создает находящийся в памяти объект TextBlock, заполняет его текстом, устанавливает режим переноса, задает размер для заполнения печатной страницы и затем печатает ее:
920 Глава 29. Печать PrintDialog printDialog = new PrintDialog(); if (printDialog.ShowDialog () == true) { // Создать текст. Run run = new Run ("This is a test of the printing functionality " "in the Windows Presentation Foundation."); // Поместить его в TextBlock. TextBlock visual = new TextBlock(); TextBlock.Inlines.Add(run); // Использовать поля для получения рамки страницы, visual.Margin = new ThicknessA5); // Разрешить перенос для заполнения всей ширины страницы, visual.TextWrapping = TextWrapping.Wrap; // Увеличить TextBlock по обоим измерениям в 5 раз. // (В этом случае увеличение шрифта дало бы тот же эффект, // потому что TextBlock — единственный элемент.) visual.LayoutTransform = new ScaleTransformE, 5) ; // Установить размер элемента. Size pageSize = new Size (printDialog. PnntableAreaWidth, printDialog.PrintableAreaHeight); visual.Measure(pageSize); visual.Arrange(new Rect@, 0, pageSize.Width, pageSize.Height) ) , // Напечатать элемент. printDialog.PrintVisual(visual, "A Scaled Drawing"); } На рис. 29.5 показана печатная страница, созданная этим кодом. This is a test of the printing functionality in the Windows Presentation Foundation. Рис. 29.5. Многострочный текст в TextBlock Этот подход позволяет захватывать нужное содержимое из окна, но отдельно настраивать его внешнее представление для печати. Однако это не поможет, если содержимое должно быть разбито на страницы (понадобится техника печати, описанная в следующих разделах). Печать документа Метод PrintVisual(), может быть, наиболее многоцелевой метод печати, но класс PrintDialog также включает и другую возможность. С использованием метода PrintDocumentO можно печатать содержимое из потокового документа. Преимущество этого подхода в том, что потоковый документ может обрабатывать огромный объем сложного содержимого и разбивать его на множество страниц (как это делается на экране). Можно было предположить, что метод PrintDialog.PrintDocumentO требует объект FlowDocument, но на самом деле он принимает объект DocumentPaginator. Класс DocumentPaginator — это специализированный класс, единственное назначение ко-
Глава 29. Печать 921 торого — брать содержимое, разбивать его на множество страниц и поставлять каждую страницу по мере поступления запросов. Каждая страница представлена объектом DocumentPage, который на самом деле — просто оболочка для единственного объекта Visual с несколькими удобными средствами. В классе DocumentPage доступны только три дополнительных свойства: Size возвращает размер страницы, ContentBox — размер прямоугольника, в который помещается содержимое на странице после добавления полей, a BleedBox — область, куда помещаются торговые марки, водяные знаки и авторские логотипы, находящаяся вне границ страницы. Это значит, что PrintDocument() работает почти так же, как PrintVisual(). Отличие в том, что он печатает несколько визуальных компонентов — по одному на каждую страницу. На заметку! Хотя можно было бы разбить содержимое на отдельные страницы без помощи DocumentPaginator и выполнять повторяющиеся вызовы PrintVisualO, но это неудачный подход. В таком случае каждая страница становится отдельным заданием печати. Каким же образом получить объект DocumentPaginator для FlowDocument? Трюк заключается в приведении FlowDocument к IDocumentPaginatorSource с последующим использованием DocumentPaginator. Вот пример: PrintDialog printDialog = new PrintDialog (); if (printDialog.ShowDialog () == true) { printDialog.PrintDocument( ( (IDocumentPaginatorSource)docReader.Document) .DocumentPaginator, "A Flow Document"); } Этот код может привести к желаемому результату, а может и не привести, в зависимости от контейнера, в котором в данный момент находится документ. Если документ расположен в памяти (а не в окне) или если он хранится в RichTextBox либо FlowDocumentScrollViewer, этот код работает хорошо. Будет получен многостраничный печатный вывод с двумя столбцами (на стандартном листе формата А4 в портретной ориентации). Это тот же результат, который получается с помощью команды ApplicationCommands. Print. На заметку! Как известно из главы 9, некоторые элементы управления включают встроенную привязку команд. Контейнер FlowDocument (подобно использованному ранее FlowDocumentScrollViewer) входит в число таких элементов. Он обрабатывает команду ApplicationCommands.Print для выполнения базовой печати. Этот жесткий код печати подобен коду, показанному ранее, хотя использует объект XpsDocumentWriter, описанный в разделе "Печать через XPS" далее в главе. Однако если документ хранится в FlowDocument PageViewer или FlowDocumentReader, результат не так хорош. В этом случае документ разбивается на страницы так же, как текущее представление в контейнере. Таким образом, если для того, чтобы уместить содержимое в текущее окно, понадобится 24 страницы, то получатся именно 24 страницы печатного вывода, каждая с крошечным окошком с данными. Опять-таки, решение несколько запутано, но оно работает. (По сути — это то же решение, которое использует команда ApplicationCommands.Print.) Трюк состоит в том, чтобы заставить FlowDocument разбивать себя на страницы. Это можно сделать, установив свойства FlowDocument.PageHeight и FlowDocument.PageWidth в границы страницы, а не границы контейнера. (В таких контейнерах, как FlowDocumentScrollViewer, эти свойства
922 Глава 29. Печать не устанавливаются, потому что разбиение на страницы не используется. Вот почему средство печати работает без задержек — контейнер разбивает себя на страницы автоматически, когда создается печатный вывод.) FlowDocument doc = docReader.Document; doc.PageHeight = printDialog.PrintableAreaHeight; doc.PageWidth = printDialog.PrintableAreaWidth; printDialog.PrintDocument( ((IDocumentPaginatorSource)doc).DocumentPaginator, "A Flow Document"); Может также понадобиться установить такие свойства, как ColumnWidth и ColumnGap, чтобы получить нужное количество столбцов. В противном случае их будет столько же, как в текущем окне. Единственная проблема этого подхода в том, что как только эти свойства изменены, они применятся к контейнеру, который отображает документ. В результате получается сжатая версия документа, которая, скорее всего, будет слишком мала, чтобы его можно было прочитать в текущем окне. Правильное решение учитывает это, сохраняя значения, изменяя их и затем восстанавливая в исходном виде. Ниже приведен полный код, печатающий двухстолбцовый вывод с общими полями (добавленными через свойство FlowDocument.PagePadding): PrintDialog printDialog = new PrintDialog (); if (printDialog.ShowDialog () == true) { FlowDocument doc = docReader.Document; // Сохранить все имеющиеся настройки, double pageHeight = doc.PageHeight; double pageWidth = doc.PageWidth; Thickness pagePadding = doc.PagePadding; double columnGap = doc.ColumnGap; double columnWidth = doc.ColumnWidth; // Привести страницу FlowDocument в соответствие с печатной страницей, doc.PageHeight = printDialog.PrintableAreaHeight; doc.PageWidth = printDialog.PrintableAreaWidth; doc.PagePadding = new Thickness E0); // Использовать два столбца. doc.ColumnGap = 25; doc.ColumnWidth = (doc.PageWidth - doc.ColumnGap - doc.PagePadding.Left - doc.PagePadding.Right) / 2; printDialog.PrintDocument( ((IDocumentPaginatorSource)doc).DocumentPaginator, "A Flow Document"), // Восстановить старые настройки, doc.PageHeight = pageHeight; doc.PageWidth = pageWidth; doc.PagePadding = pagePadding; doc.ColumnGap = columnGap; doc.ColumnWidth = columnWidth; } Этот подход имеет ряд ограничений. Хотя можно изменять свойства, которые влияют на поля и количество столбцов, все же контроль ограничен. Конечно, можно программно модифицировать FlowDocument (например, временно увеличивая его Font Size), но не удастся подстроить такие детали печатного вывода, как номера страниц. В следующем разделе рассматривается один способ, позволяющий обойти это ограничение.
Глава 29. Печать 923 Печать аннотаций В WPF имеется два класса, унаследованные от DocumentPaginator. Класс FlowDocument Paginator разбивает на страницы потоковые документы — это то, что получается из свойства FlowDocument.DocumentPaginator. Класс FixedDocumentPaginator разбивает на страницы документы XPS и автоматически используется классом XpsDocument. Однако оба эти класса помечены как internal и недоступны коду. Взаимодействовать с этими классами разбиения на страницы можно через члены класса DocumentPaginator. WPF включает только один конкретный класс разбиения на страницы — AnnotationDocument Paginator, который служит для печати документа с ассоциированными аннотациями (аннотации обсуждаются в главе 28). Класс AnnotationDocumentPaginator является общедоступным, поэтому при необходимости можно создавать его экземпляры, чтобы инициировать печатный вывод аннотированного документа. Для использования AnnotationDocumentPaginator понадобится поместить существующий DocumentPaginator в новый объект AnnotationDocumentPaginator. Для этого нужно просто создать AnnotationDocumentPaginator и передать ему две ссылки. Первая ссылка — это исходный объект разбиения на страницы для документа, а вторая — хранилище аннотаций, содержащее все аннотации. Ниже показан пример: // Получить обычный объект разбиения на страницы. DocumentPaginator oldPaginator = ((IDocumentPaginatorSource)doc) .DocumentPaginator; // Получить (функционирующую) службу аннотации // для определенного контейнера документа. AnnotationService service = AnnotationService.GetService(docViewer); // Создать новый объект разбиения на страницы. AnnotationDocumentPaginator newPaginator = new AnnotationDocumentPaginator (oldPaginator, service.Store); Теперь можно печатать документ с наложенными аннотациями (в их текущем свернутом или развернутом состоянии), вызывая метод PrintDialog.PrintDocumentO и передавая ему объект AnnotationDocumentPaginator. Манипуляции страницами в печатном выводе документа Чтобы обрести больший контроль над печатью FlowDocument, можно создать собственный DocumentPaginator. Класс DocumentPaginator разделяет содержимое документа на отдельные страницы для вывода на печать (или отображения в средстве постраничного просмотра FlowDocument). Класс DocumentPaginator отвечает за возврат общего количества страниц на основе заданного размера страницы и предоставляет скомпонованное содержимое для каждой страницы в виде объекта DocumentPage. Объект DocumentPaginator не должен быть очень сложным: фактически он может просто упаковывать DocumentPaginator, который предоставлен FlowDocument, и позволяет ему выполнять всю необходимую работу по разбиению текста на страницы. Однако DocumentPaginator можно использовать для внесения небольших поправок, таких как добавление заголовка и нижнего колонтитула. Основной фокус состоит в перехвате каждого запроса страницы, который осуществляет PrintDialog, с последующим изменением этой страницы перед передачей дальше. Первой частью этого решения является построение класса HeaderFlowDocument Paginator, унаследованного от DocumentPaginator. Поскольку DocumentPaginator — абстрактный класс, HeaderFlowDocumentPaginator должен реализовать несколько методов. Тем не менее, HeaderFlowDocumentPaginator может передать большую часть работы стандартному DocumentPaginator, который предоставляется экземпляром FlowDocument.
924 Глава 29. Печать Ниже показана базовая структура класса HeaderFlowDocumentPaginator: public class HeaderedFlowDocumentPaginator : DocumentPaginator { // Реальный класс разбиения на страницы (выполняющий всю работу по разбиению). private DocumentPaginator flowDocumentPaginator; // Сохранить класс разбиения на страницы FlowDocument из заданного документа, public HeaderedFlowDocumentPaginator (FlowDocument document) flowDocumentPaginator = ((IDocumentPaginatorSource)document).DocumentPaginator; public override bool IsPageCountValid get { return flowDocumentPaginator.IsPageCountValid; } public override int PageCount get { return flowDocumentPaginator.PageCount; } public override Size PageSize get { return flowDocumentPaginator.PageSize; } set { flowDocumentPaginator.PageSize = value; } public override IDocumentPaginatorSource Source get { return flowDocumentPaginator.Source; } public override DocumentPage GetPage(int pageNumber) { ... } } Поскольку HeaderedFlowDocumentPaginator передает свою работу приватному DocumentPaginator, код не показывает, как работают свойства PageSize, PageCount и IsPageCountValid. Свойство PageSize устанавливается потребителем DocumentPaginator (кодом, использующим DocumentPaginator). Это свойство сообщает DocumentPaginator, сколько еще места доступно на каждой печатаемой странице (или на экране). Свойства PageCount и IsPageCountValid предоставляются потребителю DocumentPaginator для отображения результата разбиения на страницы. При каждом изменении PageSize объект DocumentPaginator заново вычисляет размер каждой страницы (далее в этой главе будет показан более полный DocumentPaginator, который был создан с нуля и включает детали реализации этих свойств). Метод Get Page () — место, где происходит действие. Это код вызывает метод GetPageO реального объекта DocumentPaginator и затем выполняет работу над страницей. Базовая стратегия состоит в извлечении объекта Visual из страницы и помещении его в новый объект ContainerVisual. К этому ContainerVisual можно впоследствии добавить нужный текст. Наконец, допускается создать новый класс DocumentPage, который упаковывает ContainerVisual с вновь вставленным заголовком. На заметку! Этот код использует программирование на визуальном уровне (глава 14). Причина в том, что необходим способ создания визуальных компонентов, представляющих печатный вывод. Полноценный механизм, связанный с элементами, включая обработку событий, свойства зависимости и т.п., здесь не нужен. Специальные процедуры печати (описанные в следующем разделе) будут почти всегда использовать программирование на визуальном уровне и классы ContainerVisual, DrawingVisualи DrawingContext.
Глава 29. Печать 925 Ниже показан полный код: public override DocumentPage GetPage(int pageNumber) { // Получить запрошенную страницу. DocumentPage page = flowDocumentPaginator.GetPage(pageNumber); // Поместить страницу в объект Visual. После этого можно // будет применять трансформации и добавлять другие элементы. ContainerVisual newVisual = new ContainerVisual(); newVisual.Children.Add(page.Visual); // Создать заголовок. DrawingVisual header = new DrawingVisual (); using (DrawingContext dc = header.RenderOpen ()) { Typeface typeface = new Typeface("Times New Roman"); FormattedText text = new FormattedText("Page " + (pageNumber + 1) .ToStringO, Culturelnf о .CurrentCulture, FlowDirection.LeftToRight, typeface, 14, Brushes.Black); // Оставить четверть дюйма пространства между краем страницы и текстом. dc.DrawText (text, nefw Point (96*0 .25, 96*0.25)); } // Добавить заголовок к объекту Visual. newVisual.Children.Add(header); // Поместить объект Visual в новую страницу. DocumentPage newPage = new DocumentPage(newVisual); return newPage; } В этой реализации предполагается, что добавление заголовка не приводит к изменению размеров страницы. Вместо этого предполагается, что на полях есть достаточно места, чтобы вместить заголовок. Если этот код применить с небольшими полями, заголовок будет напечатан поверх содержимого документа. Именно так работают заголовки в таких программах, как Microsoft Word. Они не считаются частью главного документа и позиционируются отдельно от главного содержимого документа. Здесь присутствует небольшой нюанс. Добавить объект Visual для страницы к ContainerVisual не удастся до тех пор, пока он отображается в окне. Обходной путь предусматривает его временное удаление из контейнера, выполнение печати и последующий возврат объекта на место. FlowDocument document = docReader.Document; docReader.Document = null; HeaderedFlowDocumentPaginator paginator = new HeaderedFlowDocumentPaginator(document); printDialog.PrintDocument(paginator, "A Headered Flow Document"); docReader.Document = document; Объект HeaderedFlowDocumentPaginator используется для печати, но не присоединен к FlowDocument, так что он не сможет изменить способ отображения документа на экране. Специальная печать На данный момент, скорее всего, должна быть ясна фундаментальная проблема, связанная с печатью в WPF. Можно воспользоваться быстрыми и "грязными" приемами, описанными в предыдущем разделе, и отправить содержимое окна на принтер, при этом даже слегка модифицировав его. Но если нужно построить первоклассное средство печати для приложения, то придется проектировать его самостоятельно.
926 Глава 29. Печать Печать с помощью классов визуального уровня Лучший способ сконструировать специальный вывод печати предусматривает работу с классами визуального уровня. Особенно полезны два следующих класса. • ContainerVisual — усеченный визуальный элемент, который может хранить коллекцию из одного или более объектов Visual (в своей коллекции Children). • DrawintVisual, унаследованный от ContainerVisual, который добавляет метод RenderOpen() и свойство Drawing. Метод RenderOpenO создает объект DrawingContext, который можно использовать для рисования визуального содержимого (такого как текст, фигуры и т.п.), а свойство Drawing позволяет получить окончательный результат в виде объекта DrawingGroup. Разобравшись с тем, как использовать эти классы, процесс создания специального вывода печати становится довольно простым. 1. Создайте Drawing Visual. (В менее общем случае может быть также создан ContainerVisual, в котором скомбинировано более одного рисованного объекта DrawingVisual на одной и той же странице.) 2. Вызовите DrawingVisual.RenderOpenO для получения объекта DrawingContext. 3. Воспользуйтесь методами DrawingContext для создания вывода. 4. Закройте DrawingContext (если DrawingContext заключен в блок using, этот шаг выполнится автоматически). 5. Воспользуйтесь PrintDialog.PrintVisualO для отправки визуального элемента на принтер. Этот подход не только обеспечивает более высокую гибкость, чем прием с печатью элемента, который применялся до сих пор, но также потребует меньше накладных расходов. Очевидно, что ключ к выполнению этой работы состоит в знании методов класса DrawingContext, которые он предлагает для создания вывода. В табл. 29.1 описаны методы, которые можно использовать. Методы PushXxxO особенно интересны, т.к. они применяют настройки, которые понадобятся для последующих операций рисования. Метод Pop () может использоваться для отмены последнего вызванного метода PushXxxO. В случае вызова нескольких методов PushXxxO отменять их можно по одному последовательными вызовами Pop (). Таблица 29.1. Методы DrawingContext Наименование Описание DrawLine (), Рисуют указанную фигуру в заданной позиции, с указанным конту- DrawRectangle (), ром и заполнением. Эти методы отражают фигуры, которые рас- DrawRoundedRectangleO сматривались в главе 12 и DrawEllipseO DrawGeometry () Рисует более сложные объекты Geometry и Drawing. Они были и DrawDrawingO показаны в главе 13 DrawText () Рисует текст в указанном месте. Текст, шрифт, заполнение и прочие детали задаются передачей объекта FormattedText этому методу. Если установить свойство FormattedText.MaxTextWidth, можно также использовать DrawText () для рисования текста с переносом
Глава 29. Печать 927 Окончание табл. 29.1 Наименование Описание Drawlmage () Рисует растровое изображение в указанной области (определенной Rect) Pop() Отменяет последний вызванный метод PushXxx(). Метод PushXxxO используется для временного применения одного или более эффектов, а метод Pop () — для их отмены PushClipO Ограничивает отображение указанной областью. Содержимое, которое выходит за рамки этой области, отсекается PushEffectO Применяет BitmapEf feet к последующим операциям рисования PushOpacity () Применяет новые установки прозрачности, чтобы сделать последующие операции рисования частично прозрачными PushTransformO Устанавливает объект Transform, который будет применен к последующим операциям рисования. Трансформацию можно использовать для масштабирования, смещения, поворота или искажения содержимого Итак, есть все ингредиенты, необходимые для создания печатного вывода (наряду с удобным математическим аппаратом для выбора оптимального размещения содержимого). В следующем коде этот прием используется для центрирования блока форматированного текста на странице и добавления контура вокруг нее: PrintDialog printDialog = new PrintDialog (); if (printDialog.ShowDialog () == true) { // Создать визуальный элемент для страницы. DrawingVisual visual = new DrawingVisual (); // Получить контекст рисования. using (DrawingContext dc = visual.RenderOpen ()) { // Определить текст, который необходимо печатать. FormattedText text = new FormattedText(txtContent.Text, Culturelnfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Calibri"), 20, Brushes.Black); // Указать максимальную ширину, в пределах которой выполнять перенос текста, text.MaxTextWidth = printDialog.PrintableAreaWidth / 2; // Получить размер выводимого текста. Size textSize = new Size(text.Width, text.Height); // Найти верхний левый угол, куда должен быть помещен текст. double margin = 96*0.25; Point point = new Point ( (printDialog.PrintableAreaWidth - textSize.Width) / 2 - margin, (printDialog.PrintableAreaHeight - textSize.Height) / 2 - margin); // Нарисовать содержимое, dc.DrawText(text, point); // Добавить рамку (прямоугольник без фона). dc.DrawRectangle(null, new Pen(Brushes.Black, 1), new Rect(margin, margin, printDialog.PrintableAreaWidth - margin * 2, printDialog.PrintableAreaHeight - margin * 2) ) ; } // Напечатать визуальный элемент. printDialog.PrintVisual(visual, "A Custom-Printed Page");
928 Глава 29. Печать Совет. Для улучшения этого кода, скорее всего, понадобится вынести логику рисования в отдельный класс (возможно, класс документа, служащий оболочкой для печатаемого содержимого). После этого можно будет вызывать метод этого класса для получения визуального элемента и передачи его методу PrintVisualO в обработчике событий внутри кода окна. На рис. 29.6 показан вывод. Verley wet deed: to begin •»**> "Ъ** il no dovM what*** «bout rtiet TtoragiiterotMibunal wm svjntd by the dereyme*. the ctert. the undertaker, end ibecKef mounter. Screoe» suited Л: end Scroofe's neme *«s food cpo- Cheofe. «or envUdo»- He cnoie to out Mi bend to. (Md VUrlev wm « dead и i door-юЛ Рис. 29.6. Специальный печатный вывод Специальная печать с разбиением на страницы Визуальный элемент не может охватывать несколько страниц. Если нужна многостраничная печать, придется использовать тот же класс, который применялся во время печати FlowDocument, т.е. DocumentPaginator. Отличие в том, что понадобится создать DocumentPaginator самостоятельно. И на этот раз не удастся переложить всю черновую работу на внутренний приватный объект DocumentPaginator. Реализация базового проектного решения DocumentPaginator достаточно проста. Потребуется добавить метод, разбивающий содержимое на страницы, и как-то внутренне сохранить информацию об этих страницах. Затем необходимо отреагировать на вызов GetPageO для предоставления страницы, нужной PrintDialog. Каждая страница генерируется как DrawingVisual, но DrawingVisual упакован в класс DocumentPage. Сложность заключается в разбиении содержимого на страницы. И здесь WPF не поможет — придется самостоятельно решать, как именно разбить содержимое. Некоторое содержимое разбить на страницы относительно легко (вроде длинной таблицы, которая будет показана в следующем примере), в то время как другие типы содержимого намного более проблематичны. Например, для печати длинного текстового документа потребуется пройтись по нему слово за словом, добавляя слова к строкам, а строки — к страницам. Нужно будет измерить каждую отдельную часть текста, чтобы удостовериться, умещается ли она в строку. И это только для разбиения текстового содержимого с использованием обычного выравнивания влево. Чтобы получить нечто подобное выравниванию с наилучшим заполнением, как то, что применяется для FlowDocument, лучше использовать метод PrintDialog.PrintDocument(), как было описано ранее.
Глава 29. Печать 929 Это позволит избежать огромного объема кодирования и ряда весьма специализированных алгоритмов. В следующем примере демонстрируется типичная не особо трудная задача по разбиению на страницы. Содержимое DataTable печатается в табличной структуре, с размещением каждой записи в отдельной строке. Строки разбиваются на страницы в зависимости от того, сколько строк умещается на странице при использовании выбранного шрифта. На рис. 29.7 показан конечный результат. №007 STKY1 N«119 РТ109 RED1 ШДТШТ NTMBS1 NE1RPR BRTIGT1 INCPPRCIP OWTRPR TGF0A WOWPEN KMCU UCARCKT 0NTGCGHT WRID00 C1TSMC9 ВМЕ007 SHAOC01 SQUKY1 CHCW99 C0OLCMB1 FF007 LNGWAON 1МОММЕ SQRTMC1 ICJCLAIYO0 CK»NURMINO «•in Racer 2000 torn up* Extracting Tool Escape v*n.<l* (Water) Communicatioris Dew* fVrwaw* Pencil Multi-Purpose Rubber Bend Universal Repair System Effective Flashlight The Incredible Versatile Paperclip Toaster Boat MuM-Purpose Tcnve«tte Mighty Mighty Per PeHect-Vhlon Glasses Pocliet Protector Rocket Pack Counterfeit Creation Wallet Global Navigational System Cloaking Device indentHy Confusion Device Ultra Violet Attack Defender Guard Dog Pacifier Survival Bar Telescoping Comb Eavesdrop Detector Escape Cord Cocktail Party Pal Remote foliage Feeder Contact lenses Telekinesis Spoon. Model Number ULOST007 BSUR2CHX NOeOOBOCMU BHONST93 BPRECISC00 ISRPTR1 CKT2112 THNKDKE1 TCICLR1 JWLTRAMS6 GRTWTCH9 Model Name Rubber Stamp Beacon Bullet Proof Facial Tissue Speed Bandages Correction Fluid DOemma Resolution Device Nonexplosive Ckjer Document Transportation System Hologram Cufflmks Fake Moustache Translator Interpreter Earrings Muro-Purpose Watch Рис. 29.7. Таблица данных, разбитая на две страницы В этом примере специальный объект DocumentPaginator содержит код для разнесения данные по страницам и код для печати каждой страницы в объект Visual. Хотя это можно было бы поместить в два класса (например, чтобы позволить некоторым данным печататься тем же образом, но с другой разбивкой на страницы), обычно это не делается, поскольку код, необходимый для вычисления размера страницы, тесно привязан к коду, который действительно печатает эту страницу. Специальная реализация DocumentPaginator очень длинная, поэтому она приводится фрагмент за фрагментом. Первым делом, StoreDataSetPaginator сохраняет некоторые важные детали в приватных переменных, включая DataTable, которая планируется для печати, и выбранный шрифт, его размер, размер страницы и размеры полей: public class StoreDataSetPaginator : DocumentPaginator { private DataTable dt; private Typeface typeface; private double fontSize; private double margin; private Size pageSize; public override Size PageSize { get { return pageSize; } set {
930 Глава 29. Печать pageSize = value; PaginateData (); } } public StoreDataSetPaginator(DataTable dt, Typeface typeface, double fontSize, double margin, Size pageSize) { this.dt = dt; this.typeface = typeface; this.fontSize = fontSize; this.margin = margin; this.pageSize = pageSize; PaginateData (); } Обратите внимание, что эти детали указаны в конструкторе и не могут быть изменены. Единственным исключением является свойство PageSize — обязательное абстрактное свойство класса DocumentPaginator. Можно было бы создать свойства для упаковки других деталей, если нужно позволить коду изменять эти делали после создания средства разбивки на страницы. Просто нужно не забыть вызывать PaginateData(), когда любая из этих деталей изменяется. PaginateData () — не обязательный член. Это просто удобное место для вычисления необходимого количества страниц. StoreDataSetPaginator разбивает на страницы свои данные, как только DataTable применяется в конструкторе. Когда запускается метод PaginateDataO, он измеряет объем необходимого пространства для строки текста и сравнивает его с размером страницы, чтобы определить, сколько строк уместится на каждой странице. Результат сохраняется в поле по имени rowsPerPage. private int rowsPerPage; private int pageCount; private void PaginateDataO { // Создать тестовую строку для измерения. FormattedText text = GetFormattedText ( "A" ); // Подсчитать строки, которые умещаются на странице. rowsPerPage = (int)((pageSize.Height-margin*2) / text.Height); // Оставить строку для заголовка rowsPerPage -= 1; pageCount = (int)Math.Ceiling((double)dt.Rows.Count / rowsPerPage); } В этом коде предполагается, что заглавной буквы 'А' будет достаточно для вычисления высоты строки. Однако это может быть не так для всех шрифтов, и в таком случае потребуется передать GetFormattedText () строку, использующую полный список всех символов, цифр и знаков препинания. На заметку! Для вычисления количества строк, которые умещаются на странице, применяется свойство FormattedText.Height. Свойство FormattedText.LineHeight, по умолчанию равное 0, не используется. Свойство LineHeight представлено для переопределения расстояния между строками по умолчанию, когда нужно нарисовать блок из нескольких строк текст. Однако если оно не установлено, то класс FormattedText полагается на собственное вычисление, которое использует свойство Height.
Глава 29. Печать 931 В некоторых случаях придется выполнять немного больше работы и сохранять специальный объект для каждой страницы (например, массив строк с текстом для каждой строки). Тем не менее, это не требуется в примере с StoreDtaSetPaginator, поскольку все строки одинаковы и нет никакого переноса текста, о котором стоило бы беспокоиться. PaginateData() использует приватный вспомогательный метод по имени GetFormattedText(). При печати текста обнаружится, что необходимо будет конструировать огромное количество объектов FormattedText. Эти объекты FormattedText будут всегда разделять одну и ту же культуру и опции потока текста слева направо. Во многих случаях они также будут использовать одинаковый шрифт. GetFormattedText () инкапсулирует эти детали и упрощает остальную часть кода. StoreDataPaginator применяет две перегруженные версии GetFormattedText(), одна из которых принимает другой шрифт для использования: private FormattedText GetFormattedText (string text) { return GetFormattedText(text, typeface); } private FormattedText GetFormattedText (string text, Typeface typeface) { return new FormattedText ( text, Culturelnfo.CurrentCulture, FlowDirection.LeftToRight, typeface, fontSize, Brushes.Black); } Теперь, имея количество страниц, можно реализовать остальные обязательные свойства DocumentPaginator: // Всегда возвращает true, потому что количество страниц обновляется // немедленно и синхронно, когда изменяется размер страницы. // Никогда не находится в неопределенном состоянии, public override bool IsPageCountValid get { return true; } public override int PageCount get { return pageCount; } public override IDocumentPaginatorSource Source get { return null; } He существует фабричного класса, который может создать этот специальный DocumentPaginator, поэтому свойство Source возвращает null. Последняя деталь реализации является самой длинной. Метод Get Page () возвращает объект DocumentPage для запрошенной страницы, со всеми необходимыми данными. Первый шаг — нахождение позиции, где будут начинаться два столбца. В этом примере размеры столбцов определены относительно ширины одной заглавной буквы "А", что является удобным упрощением, если необходимо обойтись без более детальных вычислений. public override DocumentPage GetPage(int pageNumber) {
932 Глава 29. Печать // Создать тестовую строку для измерения. FormattedText text = GetFormattedText("A"); double coll_X = margin; double col2_X = coll_X + text.Width * 15; Следующий шаг — поиск смещений, которые идентифицируют диапазон записей, относящихся к этой странице: // Вычислить диапазон строк, которые попадают в эту страницу, int minRow = pageNumber * rowsPerPage; int maxRow = minRow + rowsPerPage; Теперь можно начать операцию печати. Напечатать понадобится три элемента: заголовки столбцов, строку-разделитель и собственно строки таблицы. Подчеркнутый заголовок рисуется с использованием методов DrawTextO и DrawLineO класса DrawingContext. Для вывода строк код проходит в цикле от первой строки до последней, отображая текст из соответствующего объекта Data Row в двух столбцах, а затем увеличивая позицию по координате Y на величину, равную высоте строки текста. // Создать визуальный элемент для страницы. DrawingVisual visual = new DrawingVisual (); // Установить позицию в верхний левый угол печатаемой области. Point point = new Point(margin, margin); using (DrawingContext dc = visual.RenderOpen()) { // Нарисовать заголовки столбцов. Typeface columnHeaderTypefасе = new Typeface( typeface.FontFamily, FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); point.X = coll_X; text = GetFormattedText ("Model Number", columnHeaderTypeface); dc.DrawText(text, point); text = GetFormattedText ("Model Name", columnHeaderTypeface); point.X = col2_X; dc.DrawText(text, point); // Нарисовать линию подчеркивания. dc.DrawLine(new Pen(Brushes.Black, 2), new Point(margin, margin + text.Height), new Point(pageSize.Width - margin, margin + text.Height)); point.Y += text.Height; // Нарисовать значения столбцов. for (int l = minRow; i < maxRow; i++) { // Проверить конец последней (частично заполненной) страницы. if (i > (dt. Rows. Count - 1)) breaks- point. X = coll_X; text = GetFormattedText(dt.Rows[l]["ModelNumber"].ToString()); dc.DrawText(text, point); // Добавить второй столбец. text = GetFormattedText(dt.Rows[l] ["ModelName"].ToString()); point.X = col2_X; dc.DrawText (text, point); point.Y += text.Height; } } return new DocumentPage (visual, pageSize, new Rect(pageSize), new Rect(pageSize)); }
Глава 29. Печать 933 Теперь, когда StoreDataSetDocumentPaginator готов, его можно использовать всякий раз, когда понадобится напечатать содержимое DataTable со списком продуктов, как показано ниже: PrintDialog printDialog = new PrintDialog (); if (printDialog.ShowDialog () == true) { StoreDataSetPaginator paginator = new StoreDataSetPaginator(ds.Tables[0], new Typeface("Calibri"), 24, 96*0.75, new Size (printDialog. PnntableAreaWidth, printDialog. PrintableAreaHeight) ) ; printDialog.PrintDocument(paginator, "Custom-Printed Pages"); } Класс StoreDataSetDocumentPaginator обладает определенной встроенной гибкостью. Например, он может работать с различными шрифтами, полями и размерами страниц, однако он не может справиться с данными, имеющими другую схему. Ясно, что в библиотеке WPF еще есть место для удобного класса, который мог бы принимать данные, определения столбцов и строк, заголовки и нижние колонтитулы и т.п., после чего печатать корректно разбитую на страницы таблицу. Пока в WPF нет ничего подобного, но можно ожидать, что независимые поставщики предложат компоненты, которые заполнят этот пробел. Настройки и управление печатью До сих пор все внимание было сосредоточено на двух методах класса PrintDialog: PrintVisualO и PrintDocument(). Это все, что необходимо для получения удовлетворительного вывода на печать, но если нужно управлять настройками принтера и заданиями печати, понадобится еще кое-что. Начальной точкой будет класс PrintDialogO. Поддержка настроек печати В предыдущих примерах было показано, каким образом класс PrintDialog позволяет использовать принтер и его настройки. Однако если эти примеры будут использоваться для более чем одного вывода на печать, то станет заметной некоторая аномалия. При каждом возврате в диалоговое окно Print восстанавливаются настройки печати по умолчанию. Снова приходится выбирать принтер и полностью настраивать его. Имеется возможность сохранить эту информацию и повторно использовать ее. Один хороший подход состоит в сохранении PrintDialog как переменной-члена окна. Таким образом, создавать PrintDialog перед каждой новой операцией печати не понадобится — просто будет использоваться существующий объект. Это работает потому, что PrintDialog инкапсулирует выбор принтера и установки принтера через два свойства: PrintQueue и PrintTicket. Свойство PrintQueue ссылается на объект System.Printing.PrintQueue, который представляет очередь печати для выбранного принтера. Как будет показано в следующем разделе, PrintQueue также инкапсулирует значительный объем средств для управления принтером и его заданиями. Свойство PrintTicket ссылается на объект System.Printing.PrintTicket, который определяет настройки для задания печати. Он включает такие детали, как разрешение печати и двусторонняя печать. При желании настройки PrintTicket можно скорректировать программно. Класс PrintTicket даже включает в себя методы GetCmlStream() и SaveToO, позволяющие сериализовать билет (ticked) в поток, и конструктор, который позволяет пересоздать объект PrintTicket в потоке. Это интересная опция, когда требуется сохранить определенные настройки принтера между сеансами
934 Глава 29. Печать приложения. (Например, эта возможность пригодилась бы для создания средства "профилей печати".) До тех пор, пока свойства PrintQueue и PrintTicket остаются согласованными, выбранный принтер и его свойства будут одинаковыми при каждом открытии диалогового окна Print. Поэтому, если нужно многократно создавать диалог PrintDialog, можно просто устанавливать эти свойства, чтобы сохранять пользовательские настройки. Печать диапазонов страниц Одно из средств класса PrintDialog пока еще не рассматривалось. Можно позволить пользователю выбирать для печати только подмножество печатного вывода, используя текстовое поле Pages (Страницы) в области Page Range (Диапазон страниц). Текстовое поле позволяет пользователю указывать группу страниц, вводя начальную и конечную страницы (например, 4-6), или выбрать определенную страницу (например, 4). Оно не позволяет вводить несколько диапазонов страниц (наподобие 1-3,5). Текстовое поле Pages по умолчанию отключено. Чтобы включить его, перед вызовом ShowDialogO нужно установить свойство PrintDialog.UserPageRangeEnabled в true. Опции Selection (Выделенный фрагмент) и Current Page (Текущая страница) останутся отключенными, потому что они не поддерживаются классом PrintDialog. Установкой свойств MaxPage HMinPage ограничиваются страницы, которые пользователь может выбирать. После отображения диалогового окна Print можно определить, имеет ли пользователь возможность вводить диапазон страниц, проверив свойство PageRangeSelection. Если в нем указано значение UserPages, значит, диапазон страниц присутствует. Свойство PageRange представляет начальную страницу (PageRange.PageFrom) и конечную страницу (PageRange.PageTo). Код может принимать во внимание эти значения и печатать только запрошенные страницы. Управление очередью печати Обычно клиентское приложение обладает ограниченными возможностями взаимодействия с очередью печати. После того, как задание запущено, может понадобиться отобразить его состояние или (редко) предоставить возможность для приостановки, возобновления или отмены задания. Классы печати WPF идут много дальше этого уровня и позволяют строить инструменты, которые могут управлять локальной или удаленной очередями печати. Классы в пространстве имен System. Printing предлагают поддержку для управления очередью печати. Для выполнения большей части работы служат несколько ключевых классов, которые перечислены в табл. 29.2. Используя эти базовые ингредиенты, можно создать программу, которая инициирует вывод на печать без какого-либо вмешательства пользователя: PrintDialog dialog = new PrintDialog(); // Выбрать принтер по умолчанию. dialog.PrintQueue = LocalPrintServer.GetDefaultPrintQueue (); // Напечатать что-либо. dialog. PnntDocument (someContent, "Automatic Printout11); Можно также создать и применить объект PrintTicket к PrintDialog, чтобы сконфигурировать другие настройки печати. Что более интересно — можно углубиться во внутренности классов PrintServer, PrintQueue и PrintSystemJoblnfo для изучения того, что происходит.
Глава 29. Печать 935 Таблица 29.2. Классы управления печатью Наименование Описание PrintServer и LocalPrintServer PrintQueue PrintSystemJoblnfo Представляют компьютер, которые предоставляет принтеры или другое устройство. ("Другим устройством" может быть принтер с сетевым оборудованием, который служит сервером печати.) Используя класс PrintServer, можно получать коллекцию объектов PrintQueue для заданного компьютера. Можно также использовать класс LocalPrintServer, унаследованный от PrintServer, который всегда представляет текущий компьютер. Он добавляет свойство DefaultPrintQueue, с помощью которого можно получить (или установить) принтер по умолчанию, и статический метод GetDefaultPrintQueueO, который можно использовать без создания экземпляра LocalPrintServer Представляет конфигурированный принтер на сервере печати. Класс PrintQueue позволяет получить информацию о состоянии принтера и управлять его очередью печати. Можно также получить коллекцию объектов PrintQueueJoblnfo для этого принтера Представляет задание, которое было отправлено в очередь принтера. Можно получить информацию о его состоянии или удалить его На рис. 29.8 показан пример программы, которая позволяет просматривать очереди печати на текущем компьютере и видеть отложенные задания в каждой из них. Эта программа дает возможность выполнять некоторые базовые задачи управления принтером, такие как приостановка принтера (или задания печати), возобновление работы принтера (или задания печати), а также отмена задания или всех заданий в очереди. Исследуя работу этого приложения, можно разобраться в основах модели управления печатью WPF. ■ PrintQueues Microsoft XPS Document Writer Pax Brother HL-1650/70N BR-Script3 Adobe PDF Queue Status: Paused Pause Queue Resume Queue Purge Queue Refresh Queue i Job Status: Paused Job ' Purge Job Рис. 29.8. Просмотр очередей и заданий печати В этом примере используется единственный объект PrintServer, который создан как поле-член класса окна: private PrintServer printServer = new PrintServer () ;
936 Глава 29. Печать В случае создания объекта PrintServer без передачи аргументов конструктору такой PrintServer представляет текущий компьютер. В качестве альтернативы можно было бы передать путь UNC, указывающий на сервер печати в сети, как показано ниже: private PrintServer printServer = new PrintServer (@"\\Warehouse\PnntServer") ; Используя объект PrintServer, код захватывает список очередей печати, которые представляют принтеры, сконфигурированные на данном компьютере. Этот шаг прост; все, что понадобится — это вызвать метод PrintServer.GetPrintQueuesO при первой загрузке окна: private void Window_Loaded(object sender, EventArgs e) { IstQueues.DisplayMemberPath = "FullName"; IstQueues.SelectedValuePath = "FullName"; IstQueues.ItemsSource = printServer .GetPnntQueues () ; } Единственная часть информации, которую использует этот фрагмент кода — это PrintQueue.FullName. Однако в классе PrintQueue имеются и другие свойства. Можно получить настройки принтера по умолчанию (используя свойства DefaultPriority, DefaultPrintTicket и т.д.), информацию о состоянии и общую информацию (используя такие свойства, как QueueStatus и NumberOf Jobs), а также изолировать специфические проблемы с помощью булевских свойств IsXxx и HasXxx (вроде IsManualFeedRequired, IsWarmingUp, IsPaperJammed, IsOutOfPaper, HasPaperProblem и NeedUserlntervention). Текущий пример реагирует, когда принтер выбран в списке, отображая состояние этого принтера и затем извлекая все задания из его очереди. Эту работу выполняет метод PrintQueue.GetPrintJoblnfoCollection(). private void lstQueues_SelectionChanged(object sender, SelectionChangedEventArgs e) { try { PrintQueue queue = printServer.GetPrintQueue(IstQueues.SelectedValue.ToString()); lblQueueStatus.Text = "Queue Status: " + queue.QueueStatus.ToString(); 1stJobs.DisplayMemberPath = "JobName"; 1stJobs.SelectedValuePath = "Jobldentifier"; 1stJobs.ItemsSource = queue.GetPrintJoblnfoCollection (); } catch (Exception err) { MessageBox.Show(err.Message, "Error on " + IstQueues.SelectedValue.ToString()); } } Каждое задание представлено объектом PrintSystemJoblnfo. Когда задание выбрано в списке, следующий код отображает его состояние: private void 1stJobs_SelectionChanged(object sender, SelectionChangedEventArgs e) { if AstJobs.SelectedValue == null) { lblJobStatus.Text = " " ; }
Глава 29. Печать 937 else { PrintQueue queue = printServer.GetPrintQueue(IstQueues.SelectedValue.ToString()); PrintSystemJoblnfo job = queue.GetJob((intIstJobs.SelectedValue); lblJobStatus.Text = "Job Status: " + job.JobStatus.ToString(); } } Единственная деталь, которая осталась — это обработчики событий, которые манипулируют очередью или заданием, когда выполнен щелчок на кнопке в окне. Этот код исключительно прост. Все, что понадобится сделать — это получить ссылку на соответствующую очередь или задание, после чего вызвать соответствующий метод. Например, вот как приостановить PrintQueue: PrintQueue queue = printServer.GetPrintQueue(IstQueues.SelectedValue.ToString()); queue.Pause (); А так приостанавливается задание печати: PrintQueue queue = printServer.GetPrintQueue(IstQueues.SelectedValue.ToString ()); PrintSystemJoblnfo job = queue.GetJob((intIstJobs.SelectedValue); job.Pause() ; На заметку! Можно приостановить (и возобновить) целый принтер или отдельное задание. Обе задачи решаются через значок Устройства и принтеры в панели управления. Щелкните правой кнопкой мыши на значке принтера, чтобы приостановить или возобновить очередь, или дважды щелкните на значке принтера, чтобы просмотреть его задания, которыми можно манипулировать индивидуально. Очевидно, что потребуется добавить обработку ошибок, потому что задачи подобного рода не обязательно всегда будут успешны. Например, подсистема безопасности Windows может помешать прервать чужое задание печати, или может возникнуть ошибка, при попытке выполнить печать на сетевом принтере после потери сетевого соединения. WPF поддерживает довольно развитую функциональность, связанную с печатью. Если вы заинтересованы в использовании этой специализированной функциональности (возможно, для построения некоторого инструмента или запуска длительно выполняющейся фоновой задачи), почитайте о классах из пространства имен System.Printing в справочной системе Visual Studio. Печать через XPS Как известно из главы 28, в WPF поддерживаются два взаимодополняющих типа документов. Потоковые документы обрабатывают гибкое содержимое, которое "течет", заполняя любой указанный размер. Документы XPS хранят готовое для печати содержимое, основанное на фиксированном размере страницы. Содержимое "заморожено" на месте и сохраняет свою точную исходную форму. Как и можно было ожидать, распечатать документ XpsDocument довольно просто. Подобно FlowDocument, класс XpsDocument предоставляет DocumentPaginator. Однако объекту DocumentPaginator для XpsDocument мало что нужно делать, поскольку его содержимое уже скомпоновано на фиксированных, неизменных страницах. Ниже приведен код, который можно использовать для загрузки файла XPS в память, отображения его в DocumentViewer с последующей отправкой на принтер:
938 Глава 29. Печать // Отобразить документ. XpsDocument doc = new XpsDocument("filename.xps", FileAccess.ReadWrite); docViewer.Document = doc.GetFixedDocumentSequence(); doc.Close (); // Напечатать документ. if (printDialog.ShowDialog () == true) { printDialog.PrintDocument(docViewer.Document.DocumentPaginator, "A Fixed Document"); } Очевидно, что отображать фиксированный документ в DocumentViewer перед его печатью не обязательно. В коде этот шаг предусмотрен потому, что это наиболее распространенный вариант. Во многих сценариях документ XpsDocument загружается для предварительного просмотра и печатается только после того, как пользователь щелкнет на соответствующей кнопке. Как и средства просмотра для объектов FlowDocument, объект DocumentViewer также обрабатывает команду ApplicationCommands.Print, а это означает, что документ XPS можно отправлять из DocumentViewer на принтер без какого-либо кода. Создание документа XPS для предварительного просмотра перед печатью В WPF доступна вся необходимая поддержка программного создания документов XPS. Создание документа XPS концептуально похоже на печать некоторого содержимого: после построения документа XPS выбран фиксированный размер страницы и компоновка "заморожена". Так зачем же нужен дополнительный шаг? На то есть две причины. • Предварительный просмотр. Сгенерированный документ XPS может быть использован для предварительного просмотра с помощью DocumentViewer. Пользователь затем может решить, стоит ли его печатать. • Асинхронная печать. Класс XpsDocumentWriter включает как метод Write () для синхронной печати, так и метод WriteAsync (), который позволяет отправлять содержимое на принтер асинхронно. Для длительных и сложных операций печати асинхронный вариант предпочтителен. Он позволяет создавать более отзывчивые приложения. Базовый прием создания документа XPS предусматривает создание объекта XpsDocumentWriter с использованием статического метода XpsDocument. CreateXpsDocumentWriter(), например: XpsDocument XpsDocument = new XpsDocument("filename.xps", FileAccess.ReadWrite); XpsDocumentWriter writer = XpsDocument. CreateXpsDocumentWnter (xpsDocument) ; XpsDocumentWriter — усеченный класс, и его функциональность вращается вокруг методов Write() HWriteAsyncO, которые записывают содержимое в документ XPS. Оба эти метода многократно перегружены, позволяя писать разные типы содержимого, включая другой документ XPS, страницу, извлеченную из документа XPS, визуальный элемент (который позволяет записывать любой элемент) и DocumentPaginator. Вновь созданный документ XPS затем отображается в DocumentViewer, который служит для предварительного просмотра печати. using (FileStream fs = File.Open("FlowDocumentl.xaml", FileMode.Open)) { FlowDocument flowDocument = (FlowDocument)XamlReader.Load(fs); writer.Write(((IDocumentPaginatorSource)flowDocument).DocumentPaginator);
Глава 29. Печать 939 // Отобразить документ XPS в средстве просмотра. docViewer.Document = xpsDocument.GetFixedDocumentSequence(); xpsDocument.Close(); } Для получения визуального элемента или объекта разбивки на страницы в приложении WPF существует множество способов. Поскольку XpsDocumentWriter поддерживает все эти классы, он позволяет записывать любое содержимое WPF в документ XPS. Запись в документ XPS, находящийся в памяти Класс XpsDocument предполагает, что содержимое XPS записывается в файл. Это немного неудобно в ситуациях, подобных показанной выше, где документ XPS служит временным хранилищем, используемым для предварительного просмотра. Подобные проблемы происходят, когда содержимое XPS должно быть сериализовано в какое-то другое хранилище, например, в поле внутри записи базы данных. Это ограничение можно обойти и записать содержимое XPS непосредственно в Memory St r earn. Однако это потребует немного больше усилий, поскольку сначала понадобится создать пакет для содержимого XPS. Ниже приведен код, выполняющий эту работу: // Подготовиться к сохранению содержимого в памяти. MemoryStream ms = new MemoryStream (); // Создать пакет, используя статический метод Package.Open () . Package package = Package.Open(ms, FileMode.Create, FileAccess.ReadWrite); // Каждому пакету необходим URI. Использовать синтаксис pack://. // Действительное имя файла неважно. Uri documentUri = new Uri("pack://InMemoryDocument.xps"); // Добавить пакет. PackageStore.AddPackage(documentUri, package); // Создать документ XPS на основе этого пакета. В то же время // выбрать нужный уровень сжатия для содержимого в памяти. XpsDocument xpsDocument = new XpsDocument(package, CompressionOption.Fast, DocumentUri.AbsoluteUri) ; По завершении использования документа XPS можно закрыть поток для освобождения памяти. На заметку! Не используйте подход с хранением в памяти, если приходится работать с большим документом XPS (например, при генерации документа XPS из содержимого базы данных, с неизвестным количеством записей). Вместо этого следует воспользоваться методом вроде Path.GetTempFileNameO для получения подходящего временного пути и создать документ XPS в файле. Печать непосредственно на принтер через XPS Как уже известно из этой главы, поддержка печати в WPF построена на пути печати XPS. При использовании класса PrintDialog признаки этой низкоуровневой реальности могут быть и не видны. С другой стороны, если применяется XpsDocumentWriter, то не заметить их невозможно. До сих пор вся печать запускалась через класс PrintDialog. Поступать так не обязательно. В действительности PrintDialog делегирует реальную работу XpsDocumentWriter. Трюк состоит в создании XpsDocumentWriter, который упаковывает PrintQueue вместо FileStream. Код записи печатного вывода идентичен — в нем используются методы Write() и WriteAsync().
940 Глава 29. Печать Ниже приведен фрагмент кода, который отображает диалоговое окно Print, получает выбранный принтер и применяет его для создания XpsDocumentWriter, запускающего задание печати: string filePath = Path.Combine(appPath, "FlowDocumentl.xaml"); if (printDialog.ShowDialog () == true) { PrintQueue queue = printDialog.PrintQueue; XpsDocumentWriter writer = PrintQueue.CreateXpsDocumentWriter(queue); using (FileStream fs = File.Open(filePath, FileMode.Open)) { FlowDocument flowDocument = (FlowDocument)XamlReader.Load (fs); writer.Write(((IDocumentPaginatorSource)flowDocument).DocumentPaginator); } } Интересно, что в этом примере все равно используется класс PrintDialog. Однако это делается просто для отображения стандартного диалогового окна Print, в котором пользователь может выбрать принтер. Реальная печать осуществляется через XpsDocumentWriter. Асинхронная печать Класс XpsDocumentWriter упрощает асинхронную печать. Фактически предыдущий пример можно преобразовать для использования асинхронной печати, просто заменив вызов метода Write() вызовом WriteAsync(). На заметку! В Windows все задания печати выполняются асинхронно. Тем не менее, процесс отправки задания на печать происходит синхронно, если применяется метод Write (), и асинхронно — если WriteAsync(). Во многих случаях время на отправку задания на печать является несущественным, и это средство не понадобится. Другое дело, если требуется построить (и разбить на страницы) содержимое, которое должно печататься асинхронно — это зачастую наиболее затратная по времени операция, и если нужна такая возможность, то понадобится писать код, который запускает логику печати в фоновом потоке. Для относительного упрощения работы можно воспользоваться подходом, описанным в главе 31 (классом BackgroundWorker). Сигнатура метода WriteAsyncO соответствует сигнатуре метода Write (). Другими словами, WriteAsyncO принимает объект разбивки на страницы, визуальный элемент или один из нескольких других типов объектов. Вдобавок метод WriteAsyncO имеет перегрузки, принимающие дополнительный второй параметр с информацией о состоянии. Информация о состоянии может быть представлена любым объектом, который будет использоваться для идентификации задания печати. Этот объект предоставлен посредством объекта WritingCompletedEventArgs при возникновении события WritingCompleted. Это позволяет запускать сразу несколько заданий печати, обрабатывать событие WritingCompleted для каждого из них в одном и том же обработчике и определять, какое именно задание было запущено при каждом возникновении события. Выполнение асинхронного задания печати можно прервать, вызвав метод CancelAsync (). Класс XpsDocumentWriter также включает небольшой набор событий, которые позволяют реагировать на отправку задания печати, в том числе WritingProgressChanged, WritingCompleted и WritingCancelled. Имейте в виду, что событие WritingCompleted происходит тогда, когда задание печати записывается в очередь печати, но это не значит, что принтер немедленно начинает его печатать.
Резюме Глава 29. Печать 941 В этой главе была описана новая модель печати WPF. В начале вы ознакомились с универсальным классом PrintDialog, который предоставляет пользователям возможность конфигурирования настроек печати и позволяет приложению отправлять документ или визуальный элемент на принтер. После рассмотрения различных способов расширения PrintDialog и его использования с экранным или динамически сгенерированным содержимым была представлена низкоуровневая модель печати XPS. Затем рассматривался класс XpsDocumentWriter, который поддерживает PrintDialog и может применяться независимо. XpsDocumentWriter обеспечивает простой способ предварительного просмотра перед печатью (поскольку в WPF не предусмотрено специального элемента управления для предварительного просмотра) и позволяет отправлять задания на печать асинхронно.
ГЛАВА 30 Взаимодействие с Windows Forms В идеальном мире, как только разработчик освоил бы новую технологию, подобную WPF, он мог бы оставить прежнюю в прошлом. Все должно было бы разрабатываться только на основе нового, наиболее развитого инструментария, и не нужно было бы беспокоиться об унаследованном коде. Конечно, этот идеальный мир не имеет ничего общего с миром реальным, в котором есть две причины, которые вынуждают разработчиков WPF в определенный момент взаимодействовать с платформой Windows Forms: чтобы сохранить инвестиции в разработку существующего кода и чтобы скомпенсировать нехватку средств в WPF. В этой главе вы ознакомитесь с разными стратегиями взаимодействия Windows Forms и WPF. Будет рассмотрено использование обоих типов окон в едином приложении, а также продемонстрирован еще более эффектный трюк — смешивание содержимого из обеих платформ в одном окне. Но прежде чем погрузиться во взаимодействие WPF и Windows Forms, стоит сделать шаг назад и оценить причины, по которым вы должны (и не должны) использовать взаимодействие WPF. Оценка способности к взаимодействию Если вы потратили последние несколько лет на программирование в Windows Forms, то, скорее всего, есть несколько приложений и библиотека собственного кода, на которую вы полагаетесь. На данный момент не существует инструмента для трансформации интерфейсов Windows Forms в соответствующие интерфейсы WPF (и даже если бы такой инструмент существовал, он мог бы послужить лишь начальной точкой длинного и непростого процесса миграции). Конечно, нет необходимости "трансплантировать" приложение Windows Forms в WPF в новых проектах. Однако в жизни все не так просто. Может быть решено, что стоит добавить некоторое средство WPF (вроде трехмерной анимации) к существующему приложению Windows Forms. Или может понадобиться постепенный перевод приложения Windows Forms в среду WPF, причем по частям, выпуская новые обновленные версии. В любом случае поддержка взаимодействия в WPF может помочь выполнить такую миграцию постепенно, не отбрасывая в одночасье все, что было сделано ранее. Другая причина, по которой стоит рассмотреть взаимодействие — это когда нужно использовать средства, отсутствующие в WPF. Хотя в WPF набор средств расширен в тех областях, которых никогда не касалась платформа Windows Forms (таких как анимация, трехмерная графика и отображение форматированных документов), все же существуют несколько средств имеют более зрелую реализацию в Windows Forms. Это не значит,
Глава 30. Взаимодействие с Windows Forms 943 что вы должны заполнить пробел, используя элементы управления Windows Forms — в конце концов, может быть проще перестроить эти средства, использовать альтернативы или просто подождать новых выпусков WPF, — но все же возможность взаимодействия платформ выглядит заманчиво. Прежде чем начать смешивать элементы WPF с элементами управления Windows Forms, важно осознать общие цели. Во многих ситуациях разработчики сталкиваются с необходимостью выбора между пошаговым усовершенствованием приложения Windows Forms (и постепенным переводом его в мир WPF) и заменой его заново переписанным шедевром на WPF Очевидно, что первый подход быстрее и проще в тестировании и реализации. Однако в случае сложного приложения, которому требуются значительные внедрения средств WPF, может быть проще начать новый проект WPF и импортировать в него необходимые части старого приложения. На заметку! Как всегда, при переходе с одной платформы пользовательского интерфейса на другую приходится переносить именно пользовательский интерфейс. Все прочие детали, вроде кода доступа к данным, правил проверки достоверности, доступа к файлам и т.п., должны быть абстрагированы в отдельные классы (и, возможно, даже в отдельные сборки), которые будут подключены к интерфейсной части приложения WPF — так же просто, как к приложению Windows Forms. Конечно, подобное разбиение на компоненты не всегда возможно, а иногда некоторые детали (такие как привязка данных и стратегии проверки достоверности) вызывают необходимость в такой специализации классов, которая неизбежно ограничивает их многократное использование. Средства, которые отсутствуют в WPF Разрабатывая WPF-приложение, можно использовать элементы управления из Windows Forms, которые вы знаете и любите, и эквивалента которых нет в WPF. Как всегда, при этом следует тщательно взвесить возможные варианты и исследовать альтернативы, прежде чем обратиться к средствам взаимодействия платформ. В табл. 30.1 представлен обзор недостающих элементов управления и их возможных замен. Таблица 30.1. Недостающие элементы управления и средства WPF Элемент управления _ ~ ...__ Использовать Windows Forms Ближайший эквивалент WPF Windows Forms? LinkLabel Используйте встроенный элемент Hyperlink в Нет TextBlock. Как это делать — описано в главе 24 MaskedTextBox Эквивалентного элемента управления не сущест- Да вует (хотя можно построить собственный, применив для этого класс System.ComponentModel. MaskedTextProvider) DomainUpDown и Для эмуляции этих элементов управления Нет NumericUpDown используйте TextBox с двумя элементами RepeatButton CheckedListBox Если не используется привязка данных, то мож- Нет но поместить множество элементов CheckBox внутрь ScrollViewer. Если же привязка данных нужна, можно применить ListBox со специальным шаблоном элементов управления. Пример приведен в главе 20 (также и для заметы RadioButtonList)
944 Глава 30. Взаимодействие с Windows Forms Окончание табл. 30.1 Элемент управления Windows Forms Ближайший эквивалент WPF Использовать Windows Forms? PropertyGrid ColorDialog, FolderBrowserDialog, FontDialog, PageSetupDialog PrintPreviewControl и PrintPreviewDialog ErrorProvider, HelpProvider AutoComplete MDI Эквивалентного элемента управления не Да существует Эти компоненты могут быть использованы в WPF. Нет Однако большая часть этих общих диалоговых окон легко воссоздается в WPF, причем без старомодного внешнего вида. (В главе 18 приведен пример базового специализированного элемента для выбора цвета.) Здесь возможно несколько подходов в стиле Возможно "сделай сам". Простейший предусматривает программное конструирование документа FlowDocument, который затем можно отображать в средстве просмотра документов и отправлять на принтер. Хотя PrintPreviewControl и PrintPreviewDialog — более зрелые элементы, требующие меньше работы, применение их в WPF не рекомендуется. Это связано с тем, что при этом придется переключиться на старую модель печати Windows Forms. Разумеется, при наличии кода печати, использующего библиотеки Windows Forms, взаимодействие с ним позволит избежать лишней работы В WPF отсутствует поддержка поставщиков Да расширений Windows Forms. Если есть формы, использующие эти средства, их можно продолжать применять в приложении WPF посредством взаимодействия. Однако эти поставщики нельзя использовать для отображения сообщений об ошибках или контекстной справки для элементов управления WPF Хотя WPF включает функциональность Возможно AutoComplete в ComboBox (см. главу 20) через свойство IsTextSearchingenabled, это простое средство AutoComplete, которое заполняет поле предполагаемыми значениями только из текущего списка. Оно не предоставляет полного списка предполагаемых значений, как это делает AutoComplete из Windows Forms, равно как и не дает доступа к последним URL, записанным операционной системой. Использование Windows Forms для получения этой поддержки обычно чрезмерно — лучше отказаться от AutoComplete вовсе или построить самостоятельно WPF не поддерживает окна MDI. Тем не менее, Да система компоновки достаточно гибка, чтобы адаптировать широкое разнообразие специальных подходов, включая самодельные окна с вкладками. Однако это потребует определенных усилий. Если же действительно нужно MDI-приложение, лучше построить полноценное приложение Windows Forms, а не пытаться комбинировать WPF с Windows Forms
Глава 30. Взаимодействие с Windows Forms 945 На заметку! Подробную информацию о специфике Windows Forms, включая AutoComplete, поддержку MDI, модели печати и поставщики расширений, можно найти в книге Pro .NET 2.0 Windows Forms and Custom Controls in C# (Apress, 2005 г.). Как видно в табл. 30.1, некоторые элементы управления Windows Forms являются хорошими кандидатами на интеграцию, поскольку могут быть легко вставлены в окна WPF, а для воссоздания их своими силами может понадобиться много работы. К ним относятся MaskedTextBox и PropertyGrid и WebBrowser. Если создавались собственные элементы управления Windows Forms, они, скорее всего, также будут включены в этот список — другими словами, их легче перенести в WPF, нежели воссоздать с нуля. Существует большой набор элементов управления, которые в WPF не доступны, но имеют подходящие (а иногда и усовершенствованные) эквиваленты. К ним относятся CheckedListBox и ImageList. И, наконец, есть некоторые средства, которые просто недоступны в WPF, т.е. они не представлены в WPF, и не существует стратегии взаимодействия для их включения туда. Примерами могут служить поставщики расширений (ErrorProvider, HelpProvider, а также специальные пользовательские поставщики) и окна MDI. Если упомянутые средства нужны, их придется построить самостоятельно или обратиться к компонентам от независимых разработчиков, при этом миграция из Windows Forms в WPF потребует больше работы. Смешивание окон и форм Наиболее ясный способ интеграции содержимого WPF и Windows Forms состоит в их помещении в отдельные окна. Таким образом, приложение будет состоять из хорошо инкапсулированных классов окон, каждый из которых имеет дело только с одной технологией. Любые подробности взаимодействия обрабатываются в связующем коде — логике, создающей и отображающей окна. Добавление форм к приложению WPF Простейший подход к смешиванию окон и форм заключается в добавлении одной или более форм (из набора инструментов Windows Forms) к обычному в остальных отношениях WPF-приложению. Visual Studio позволяет это сделать легко — щелкните правой кнопкой мыши на имени проекта в окне Solution Explorer и выберите в контекстном меню пункт Add^New Item (Добавить1^ Новый элемент). После этого выберите слева категорию Windows Forms, затем — шаблон Windows Form. И, наконец, укажите имя файла для формы и щелкните на кнопке Add (Добавить). При первом добавлении формы Visual Studio добавит ссылки на все необходимые сборки Windows Forms, включая System. Windows.Forms.dll и System.Drawing.dll. Форма в проекте WPF строится точно так же, как в проекте Windows Forms. При открытии формы Visual Studio загружает обычный визуальный конструктор форм Windows Forms и наполняет панель инструментов элементами управления Windows Forms. Когда открывается файл XAML для окна WPF, вместо этого появляется знакомая поверхность визуального конструктора WPF Совет. Для лучшего разделения между содержимым WPF и Windows Forms можно предпочесть поместить "внешнее" содержимое в отдельную сборку библиотеки классов. Например, приложение Windows Forms может использовать окна WPF, определенные в отдельной сборке. Такой подход особенно оправдан, если планируется многократно использовать некоторые из этих окон как в приложениях Windows Forms, так и в WPF.
946 Глава 30. Взаимодействие с Windows Forms Добавление окон WPF в приложение Windows Forms Обратный трюк несколько сложнее. Visual Studio не позволяет напрямую создать новое окно WPF в приложении Windows Forms. (Другими словами, его не будет среди доступных шаблонов при щелчке правой кнопкой мыши на имени проекта и выборе в контекстном меню пункта Add^New Item.) Однако можно добавить существующие файлы .cs и .xaml, которые определяют окно WPF, из другого проекта. Чтобы сделать это, щелкните правой кнопкой мыши на имени проекта в окне Solution Explorer, выберите в контекстном меню пункт Add ^Existing Item (Добавить1^ Существующий элемент) и найдите оба эти файла. Также понадобится добавить ссылки на центральные сборки WPF (PresentationCore.dll, PresentationFramework.dll и WindowsBase.dll). Совет. Добавление всех необходимых ссылок WPF можно упростить. Если добавить пользовательский элемент управления WPF (который Visual Studio поддерживает), то это вынудит Visual Studio добавить все ссылки автоматически. Затем этот пользовательский элемент управления можно удалить из проекта. Чтобы добавить такой элемент WPF, щелкните правой кнопкой мыши на имени проекта, выберите в контекстном меню пункт Add■=>New Item, укажите в качестве категории WPF и затем выберите шаблон User Control (WPF) (Пользовательский элемент управления (WPF)). Если добавить окно WPF к приложению Windows Forms, оно трактуется правильно. После открытия его можно модифицировать с использованием визуального конструктора WPF. При построении проекта XAML-разметка компилируется и автоматически сгенерированный код объединяется с классом отделенного кода, как если бы это было полноценным WPF-приложением. Создание проекта, использующего формы и окна, не особенно сложно. Однако есть несколько дополнительных нюансов, которые следует учитывать при отображении этих форм и окон во время выполнения. Если нужно показать окно или форму модально (как это делается с диалоговыми окнами), то такая задача довольно проста и не требует изменения кода. Но если необходимо показывать окна в немодальном режиме, то понадобится написать дополнительный код, чтобы обеспечить корректную поддержку клавиатуры, как будет описано в последующих разделах. Отображение модальных окон и форм Отображение модальной формы в приложении WPF практически не требует усилий. Используется в точности такой же код, как в проекте Windows Forms. Например, если есть класс формы по имени Forml, то для его модального отображения применяете код, подобный следующему: Forml frm = new Forml () ; if (frm.ShowDialog () == System.Windows.Forms.DialogResult.OK) MessageBox.Show("You clicked OK in a Windows Forms form."); } • Вы заметите, что метод Form.ShowDialogO работает немного иначе, чем WPF-метод Window.ShowDialog(). В то время как Window.ShowDialogO возвращает true, false или null, метод Form.ShowDialogO возвращает значение из перечисления DialogResult. Обратная задача — отображение окна WPF из формы — столь же проста. Вы просто взаимодействуете с общедоступным интерфейсом класса окна, a WPF позаботится об остальном: Windowl win = new Windowl(); if (win.ShowDialog () == true) { MessageBox.Show("You clicked OK in a WPF window."); }
Глава 30. Взаимодействие с Windows Forms 947 Отображение немодальных окон и форм С отображением окна или формы в немодальном режиме все не так просто. Сложность состоит в том, что клавиатурный ввод принимается корневым приложением и должен быть доставлен в соответствующее окно. Чтобы это работало между содержимым WPF и Windows Forms, необходим какой-то способ передачи этих сообщений нужному окну или форме. Если необходимо показывать окно WPF в немодальном режиме изнутри приложения Windows Forms, то для этого должен использоваться статический метод ElementHost.EnableModelessKeyboardlnteropO. Также потребуется ссылка на сборку WindowsFormsIntegration.dll, которая определяет класс ElementHost в пространстве имен System.Windows .Formslntegration. (Более подробно класс ElementHost рассматривается далее в этой главе). Метод EnableModelessKeyboardlnteropO вызывается после создания окна, но перед его отображением. При вызове этому методу следует передать ссылку на новое окно WPF, как показано ниже: Windowl win = new Windowl (); ElementHost.EnableModelessKeyboardlnterop(win); win . Show () ; При вызове EnableModelessKeyboardlnteropO объект ElementHost добавляет фильтр сообщений в приложение Windows Forms. Этот фильтр сообщений перехватывает клавиатурные сообщения, когда активно окно WPh и пересылает их ему. Без этого элементы управления WPF просто не получат клавиатурного ввода. Для отображения немодального приложения Windowb Forms внутри приложения WPF применяется аналогичный метод WindowsFormsHost.Eii^~leWindowsFormsInterop(). Однако в этом случае не нужно передавать ссылку на отображаемую форму. Вместо этого метод просто вызывается один раз перед отображением любой формы. (Хорошим решением будет вызвать его при запуске приложения.) WindowsFormsHost.EnableWindowsFormsInterop(); Теперь можно отобразить форму в немодальном режиме безо дополнительных усилий: Forml frm = new Forml () ; frra.Show (); В отсутствие вызова EnableWindowsFormsInterop() форма будет отображаться, но не распознает никакого клавиатурного ввода. Например, нельзя будет пользоваться клавишей <ТаЬ> для перехода от одного элемента управления к другому. Этот процесс может быть расширен на несколько уровней. Например, можно создать окно WPF, отображающее форму (модально или не модально), а эта форма, в свою очередь, может показать окно WPF Хотя подобное не придется делать очень часто, все же это лучший подход, чем поддержка взаимодействия между разнородными элементами, о которой речь пойдет ниже. Такая поддержка позволяет интегрировать разные типы содержимого в одном окне, но не позволяет вкладывать на более чем один уровень вглубь (например, создать окно WPF, содержащее элемент управления Windows Forms, который, в свою очередь, содержал бы в себе элемент управления WPF). Визуальные стили элементов управления Windows Forms При отображении формы в приложении WPF такая форма использует старомодные (предшествующие Windows XP) стили кнопок и других распространенных элементов управления. Это связано с тем, что поддержка новых стилей должна быть явно вклю-
948 Глава 30. Взаимодействие с Windows Forms чена вызовом метода Application.EnableVisualStyles (). Обычно Visual Studio добавляет эту строку кода в метод Main() каждого нового приложения Windows Forms. Однако при создании приложения WPF эта деталь не включается. Чтобы решить эту проблему, просто вызовите метод EnableVisualStyles () перед отображением любого содержимого Windows Forms. Подходящее место для этого — при запуске приложения, как показано ниже: public partial class App : System.Windows.Application { protected override void OnStartup(StartupEventArgs e) { // Инициирует событие Startup, base.OnStartup (e); System.Windows.Forms.Application.EnableVisualStyles() ; } } Обратите внимание, что метод EnableVisualStyles() определен в классе System. Windows.Forms.Application, а не в классе System.Windows.Application, который формирует основу WPF-приложения. Классы Windows Forms, которые не нуждаются во взаимодействии Как известно, элементы управления Windows Forms имеют другую иерархию наследования, отличающуюся от элементов WPF Эти элементы не могут использоваться в окне WPF без взаимодействия. Однако существуют некоторые компоненты Windows Forms, которые не имеют такого ограничения. При наличии ссылки на необходимую сборку (обычно System.Wiiidows.Forms.dll) эти типы можно использовать без каких- либо специальных усилий. Например, можно применять классы диалоговых окон (ColorDialog, FontDialog, PageSetupDialogHT.n.) непосредственно. На практике это не совсем удобно, поскольку эти диалоговые окна несколько устарели и упаковывают структуры, являющиеся частью Windows Forms, а не WPF Например, в результате использования ColorDialog получается объект System.Drawing.Color вместо действительно нужного объекта System. Windows.Media.Color. To же касается применения FontDialog и PrintPreviewDialog, которые предназначены для работы с более старой моделью печати Windows Forms. Фактически, единственное диалоговое окно Windows Forms, без которого не обойтись, поскольку оно не имеет эквивалента в WPF — это FolderBrowserDialog из пространства имен Microsoft.Win32, позволяющее пользователю выбирать папки. Более полезные компоненты Windows Forms включают SoundPlayer, который можно использовать в качестве легковесного эквивалента MediaPlayer и MediaElement из WPF; BackgroundWorker (описанный в главе 31), который можно применять для безопасного управления асинхронными задачами; Notifylcon (описанный ниже), позволяющий отображать значок в системном лотке. Единственным недостатком использования Notifylcon в окне WPF является отсутствие поддержки во время проектирования. То есть придется вручную создать Notifylcon, присоединить обработчики событий и т.д. Указан значок в свойстве Icon, и затем установив Visible в true, вы увидите этот значок в системном лотке (рис. 30.1). По завершении приложения понадобится вызвать Dispose() для Notifylcon, чтобы немедленно удалить значок из системного лотка. Notifylcon использует некоторые части Windows Forms, например, контекстное меню Windows Forms, являющееся экземпляром класса System.Windows.Forms. ContextMenuStrip. Таким образом, даже если Notifylcon применяется в приложении WPF, понадобится определить его контекстное меню с помощью модели Windows Forms.
Глава 30. Взаимодействие с Windows Forms 949 Рис. 30.1. Значок в системном лотке Создание всех объектов для меню в коде и присоединение к нему обработчиков событий — не такое уж незначительное неудобство. К счастью, имеется более простое решение для построения приложения WPF, использующего Notifylcon. Можно создать класс компонента. Класс компонента — это пользовательский класс, унаследованный от System.ComponentModel.Component. Он обеспечивает два средства, которых не хватает обычным классам: поддержку детерминировано освобождаемых ресурсов (когда вызывается их метод Dispose()) и поддержку времени проектирования в Visual Studio. Каждый компонент получает поверхность проектирования (известную как лоток компонентов), где можно перетаскивать и конфигурировать другие классы, реализующие интерфейс IComponent, в том числе Windows Forms. Другими словами, лоток компонента может быть использован для построения и конфигурирования Notifylcon, дополненного контекстным меню и обработчиками событий. Ниже описаны шаги, которые должны быть проделаны, чтобы построить пользовательский компонент, упаковывающий экземпляр Notifylcon и включающий контекстное меню. 1. Откройте или создайте новый проект WPF. 2. Щелкните правой кнопкой мыши на имени проекта в окне Solution Explorer и выберите в контекстном меню пункт Add^New Item (Добавить1^ Новый элемент). Выберите шаблон Component Class (Класс компонента), укажите имя класса компонента и щелкните на кнопке Add (Добавить). 3. Поместите Notifylcon на поверхность проектирования компонента. (Элемент Notifylcon находится в разделе Common Controls (Общие элементы управления) панели инструментов.) 4. В этот момент Visual Studio добавит необходимую ссылку на сборку System. Windows.Forms.dll. Однако может не получиться добавить ссылку на пространство имен System.Drawing.dll, которое содержит основные типы Windows Forms. В таком случае эта ссылка должна быть добавлена вручную. 5. Поместите ContextMenuStrip на поверхность проектирования компонента (из раздела Menus & Toolbars (Меню и панели инструментов) панели инструментов). Это предоставит контекстное меню для Notifylcon. На рис. 30.2 показаны оба ингредиента в Visual Studio. 6. Выберите Notifylcon и сконфигурируйте в окне свойств. Понадобится установить следующие свойства: Text (текст подсказки, всплывающий при наведении курсора мыши на Notifylcon), Icon (значок, который появится в системном лотке) и ContextMenuStrip (контекстное меню, добавленное на предыдущем шаге).
950 Глава 30. Взаимодействие с Windows Forms Рис. 30.2. Поверхность проектирования компонента 7. Чтобы построить контекстное меню, щелкните правой кнопкой мыши на ContextMenuStrip и выберите в появившемся контекстном меню пункт Edit Items (Редактировать элементы). Откроется редактор коллекции, с помощью которого можно добавлять пункты меню (они должны быть помещены после корневого пункта меню). Назначьте им нужные имена, поскольку обработчики событий понадобится присоединять самостоятельно. 8. Чтобы просмотреть код класса компонента, щелкните правой кнопкой мыши на компоненте в окне Solution Explorer и выберите в контекстном меню пункт View Code (Показать код). (Не открывайте файл .Designer.cs. Он содержит код, сгенерированный Visual Studio автоматически, который комбинируется с остальной частью кода компонента посредством механизма частичных классов.) 9. Добавьте код для подключения обработчиков событий меню. Ниже приведен пример добавления обработчиков событий для двух пунктов меню — Close (Закрыть) и Show Window (Показать окно). public partial class NotifylconWrapper : Component { public NotifylconWrapper () { InitializeComponent(); // Присоединить обработчики событий. cmdClose.Click += cmdClose_Click; cmdShowWindow.Click += cmdShowWindow_Click; } // Использовать только один экземпляр окна. private Windowl win = new Windowl (); private void cmdShowWindow_Click(object sender, EventArgs e) {
Глава 30. Взаимодействие с Windows Forms 951 // Показать окно (и перенести его на передний план, если оно уже видимо) . if (win.WindowState == System.Windows.WindowState.Minimized) win.WindowState = System.Windows.WindowState.Normal; win . Show () ; win.Activate (); } private void cmdClose_Click(object sender, EventArgs e) { System.Windows.Application.Current.Shutdown (); } // Очистка этого компонента реализуется освобождением // всех содержащихся компонентов (включая Notifylcon) . protected override void Dispose(bool disposing) { if (disposing && (components l= null)) components.Dispose (); base.Dispose(disposing); } // (Код визуального конструктора не показан.) } Теперь, когда класс пользовательского компонента построен, нужно просто создать его экземпляр, когда понадобится показать Notifylcon. Это инициирует код визуального конструктора компонента, который создаст объект Notifylcon, сделав его видимым в системном лотке. Удаление значкг ^ системного лотка столь же просто — для этого необходимо вызвать метод Dispose() компонента. Это заставит его вызвать Dispose () для всех составляющих его компонентов, включая Notifylcon. Рассмотрим пример класса приложения, отображающего значок при запуске и удаляющего его при завершении приложения: public partial class App : System.Windows.Application { private NotifylconWrapper component; protected override void OnStartup(StartupEventArgs e) { base.OnStartup (e); this.ShutdownMode = ShutdownMode.OnExplicitShutdown; component = new NotifylconWrapper(); } protected override void OnExit(ExitEventArgs e) { base.OnExit(e) ; component.Dispose(); } } В завершение этого примера удостоверьтесь, что атрибут StartupUri удален из файла App.xaml. Таким образом, при запуске приложение отобразит Notifylcon, но не покажет никакого дополнительного окна ди *ex пор, пока пользователь не выберет соответствующий пункт в меню. Это пример полагается еще на один трюк. Единственное главное окно остается актуальным для всего приложения и отображается всякий раз, когда пользователь выбирает в меню пункт Show Window (Показать окно). Однако при закрытии пользователем окна возникает проблема. Существуют два возможных пути ее разрешения: можно пересоздавать окно при необходимости, когда пользователь в следующий раз выберет Show Window, или же можно перехватить событие Window.Closing и скрыть окно вместо его уничтожения. Вот как это делается:
952 Глава 30. Взаимодействие с Windows Forms private void window_Closing(object sender, CancelEventArgs e) { e.Cancel = true; this.WindowState = WindowState.Minimized; this.ShowInTaskbar = false; } Обратите внимание, что этот код не изменяет свойство Visibility окна и не вызывает метод Hide (), поскольку ни то, ни другое при закрытии окна невозможно. Вместо этого код сворачивает окно и затем удаляет его из панели задач. При восстановлении окна понадобится проверить его состояние и вернуть в его нормальное состояние вместе с кнопкой в панели задач. Создание окон со смешанным содержимым В некоторых случаях строгое разделение по окнам не подходит. Например, может потребоваться разместить содержимое WPF в существующей форме рядом с содержимым Windows Forms. Хотя эта модель концептуально запутана, WPF справляется с ней достаточно успешно. Фактически включение содержимого Windows Forms в приложение WPF (или наоборот) сделать проще, чем добавить содержимое ActiveX к приложению Windows Forms. В последнем случае Visual Studio должен генерировать класс-оболочку, который служит посредником между элементом управления ActiveX и кодом, занимаясь передачей от управляемого к неуправляемому коду. Этот класа-оболочка специфичен для компонента, т.е. каждый используемый элемент управления ActiveX требует отдельного класса- оболочки. А из-за причуд СОМ интерфейс, предоставляемый оболочкой, может не соответствовать в точности интерфейсу лежащего в основе компонента. При интеграции содержимого Windows Forms и WPF классы-оболочки не нужны. Вместо них применяется один из небольшого набора контейнеров, в зависимости от конкретного сценария. Эти контейнеры работают с любым классом, так что шаг генерации кода исключается. Такая упрощенная модель возможна, поскольку даже несмотря на значительное отличие технологий Windows Forms и WPF, обе они основаны на управляемом коде. Наиболее важное преимущество такого проектного решения заключается в том, что в своем коде можно непосредственно взаимодействовать с элементами управления Windows Forms и элементами WPF Уровень взаимодействия активизируется, только когда осуществляется визуализация содержимого окна. Это происходит автоматически, не требуя вмешательства разработчика. Также не приходится беспокоиться об обработке событий клавиатуры в немодальных окнах, поскольку используемые для организации взаимодействия классы (ElementHost и WindowsFormsHost) делают это автоматически. Зазор между WPF и Windows Forms Для того чтобы интегрировать WPF и Windows Forms в одном окне, нужно каким- то образом выделить часть окна для "чужого" содержимого. Например, совершенно разумно вставить трехмерную графику в приложение Windows Forms, поскольку ее можно разместить в отдельной области окна (или даже занять ею все окно). Однако вряд ли стоит перерисовать все кнопки приложения Windows Forms, сделав их элементами WPF, поскольку для каждой такой кнопки придется создавать отдельную область WPF Наряду с соображениями сложности есть еще некоторые вещи, которые невозможны при взаимодействии с WPF Например, не допускается комбинировать содержимое WPF и Windows Forms путем их перекрытия. Это значит, что нельзя заставить анима-
Глава 30. Взаимодействие с Windows Forms 953 цию WPF запустить летающий элемент над областью, за отображение которой отвечает Windows Forms. Соответственно, невозможно перекрыть частично прозрачное содержимое Windows Forms над областью WPF, чтобы смешать их вместе. И то, и другое является нарушением правила зазора (airspace rule), которое требует, чтобы WPF и Windows Forms всегда находились в отдельных областях окна, которыми они управляют эксклюзивно. На рис. 30.3 показано, что разрешено, а что — нет Разрешено Содержимое WPF Содержимое Windows Forms Не разрешено Содержимое WPF ^VJi Содержимое Windows Forms Рис. 30.3. Правило зазора Формально правило зазора является результатом того факта, что в окне, включающем содержимое WPF и Windows Forms, обе области имеют свои отдельные дескрипторы окна — hwnd. Каждый hwnd управляется, отображается и обновляется отдельно. Дескрипторы окна управляются операционной системой Windows. В классических Windows-приложениях каждый элемент управления представляет собой отдельное окно, а это значит, что каждый элемент управления владеет отдельной частью экрана. Очевидно, что "окна" такого рода не являются окнами верхнего уровня, которые перемещаются по экрану. Это просто самодостаточные области (прямоугольные или другие). В WPF принята совершенно другая модель — там есть один hwnd высшего уровня, и механизм WPF отвечает за отображение целого окна, что делает возможной более симпатичную визуализацию (например, такие эффекты, как динамическое сглаживание) и обеспечивает намного более высокую гибкость (например, визуальные элементы, которые рисуют свое содержимое, выходящее за их границы).
954 Глава 30. Взаимодействие с Windows Forms На заметку! Существует несколько элементов WPF, которые используют отдельные дескрипторы окна. К ним относятся меню, всплывающие подсказки и раскрывающиеся части комбинированных окон списков — всем им требуется возможность расширяться за пределы окна. Реализация правила зазора очень проста. Если поместить содержимое Windows Forms поверх содержимого WPF, обнаружится, что оно всегда будет находиться сверху, независимо от того, как оно объявлено в коде разметки, или от того, какой контейнер компоновки применяется. Причина в том, что содержимое WPF — это одно окно, а содержимое Windows Forms реализуется в виде отдельных окон, отображающихся поверх части окна WPF Если же поместить содержимое WPF в форму Windows Forms, результат будет несколько другим. Каждый элемент управления Windows Forms представляет собой отдельное окно, а потому имеет собственный дескриптор hwnd. Поэтому содержимое WPF может быть размещено где угодно по отношению к другим элементам управления Windows Forms, в зависимости от z-индекса (z-индекс определяется порядком добавления элементов в коллекцию Controls родителя, так что элементы, добавленные позже, появляются поверх добавленных раньше). Однако содержимое WPF имеет собственную отдельную область. Это значит, что нельзя применять прозрачность или другую технику для частичного перекрытия или комбинирования элемента с содержимым Windows Forms. Вместо этого содержимое WPF остается в собственной области. Размещение элементов управления Windows Forms в WPF Чтобы показать элемент управления Windows Forms в окне WPF, должен использоваться класс WindowsFormsHost из пространства имен System.Windows .Forms. Integration. Класс WindowsFormsHost — это элемент WPF (унаследованный от FrameworkElement), который способен содержать в себе ровно один элемент управления Windows Forms, указываемый в его свойстве Child. Достаточно легко создавать и использовать WindowsFormsHost программно. Однако в большинстве случаев проще сделать это декларативно в коде разметки XAML. Единственный недостаток состоит в том, что Visual Studio не предоставляет достаточной поддержки визуального конструирования для элемента управления WindowsFormsHost. Хотя его можно перетаскивать на поверхность окна, наполнять его содержимым (и отображать нужное пространство имен) придется вручную. Первый шаг заключается в отображении пространства имен System.Windows.Forms, чтобы можно было обратиться к нужному элементу управления Windows.Forms: <Window x:Class="InteroperabilityWPF.HostWinFormControl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns :wf="clr-namespace: System. Windows . Forms;assembly=System.Windows . Forms" Title="HostWinFormControl" Height=00" Width=00" > После этого можно создать WindowsFormsHost и элемент управления внутри него — точно так же, как это делается с любым другим элементом WPF. Ниже приведен пример применения MaskedTextBoxH3 Windows Forms: <Grid> <WindowsFormsHost> <wf :MaskedTextBox x:Name="maskedTextBox"x/wf :MaskedTextBox> </WindowsFormsHost> </Grid>
Глава 30. Взаимодействие с Windows Forms 955 На заметку! WindowsFormsHost может содержать в себе любой элемент управления Windows Forms (т.е. любой класс, унаследованный от System. Windows .Forms.Control). Он не может содержать компонентов Windows Forms, не являющихся элементами управления, вроде HelpProvider или Notifylcon. На рис. 30.4 показан элемент MaskedTextBox в окне WPF. Большую часть свойств MaskedTextBox можно установить прямо в коде разметки. Это связано с тем, что Windows Forms использует ту же инфраструктуру TypeConverter (которая рассматривалась в главе 2) для преобразования строк в значения свойств определенного типа. Это не всегда удобно — скажем, строковое представление типа может быть трудно вводить вручную, — но обычно имеется возможность конфигурировать элементы управления Windows Forms без обращения к коду. Например, ниже приведен MaskedTextBox, оснащенный маской, которая формирует пользовательский ввод семизначного телефонного номера с необязательным международным кодом: <wf .-MaskedTextBox x:Name="maskedTextBox" Mask=" (999) -000-0000"></wf :MaskedTextBox> Можно также применять расширения разметки XAML для заполнения null-значений, использовать статические свойства, создавать объекты типов либо использовать объекты, определенные в коллекции Resources окна. Ниже показан пример применения расширения типа для установки свойства MaskedTextBox.ValidatingType. В нем указано, что MaskedTextBox должен преобразовывать ввод (строку телефонного номера) в Int32, когда читается свойство Text или изменяется фокус. <wf:MaskedTextBox x:Name="maskedTextBox" Mask="(999)-000-0000" ValidatingType="{x:Type sys:Int32}"></wf:MaskedTextBox> Есть одно расширение разметки, которое не будет работать — это выражение привязки данных, поскольку оно требует свойства зависимости. (Элементы управления Windows Forms конструируются из обычных свойств .NET.) Если необходимо привязать свойство элемента управления Windows Forms к свойству элемента WPF, для этого существует несложный обходной маневр — просто установить свойство зависимости в элемент WPF и должным образом подкорректировать BindingDirection (подробности читайте в главе 8). И, наконец, важно отметить, что привязать события к элементу управления Windows Forms можно с помощью знакомого синтаксиса XAML. Ниже приведен пример, подключающий обработчик к событию MasklnputRejected, которое происходит, когда нажатие клавиши отклоняется из-за несоответствия маске: <wf:MaskedTextBox x:Name="maskedTextBox" Mask="(999)-000-0000" MaskInputRe]ected="maskedTextBox_MaskInputRe]ected"x/wf :MaskedTextBox> Очевидно, что это — не маршрутизируемые события, так что определить их на высших уровнях иерархии элементов не получится. Когда случается событие, обработчик отвечает на него, отображая сообщение об ошибке в другом элементе. В данном случае этим элементом будет метка WPF, расположенная где-то на форме: ► HostWinFormControl A23L-- Error. DigitExpected Рис. 30.4. Маскированное текстовое поле для ввода телефонного номера
956 Глава 30. Взаимодействие с Windows Forms private void maskedTextBox_MaskInputRe]ected (object sender, System.Windows.Forms.MasklnputRejectedEventArgs e) { lblErrorText.Content = "Error: " + e.RejectionHint.ToString() ; } Совет. Не импортируйте пространства имен Windows Forms (такие как System.Windows.Forms) в файл кода, который уже использует пространства имен WPF (подобные System.Windows. Controls). Классы Windows Forms и классы WPF имеют немало совпадающих имен. Базовые ингредиенты (вроде Brush, Pen, Font, Color, Size и Point) и часто используемые элементы (Button, TextBox и т.п.) присутствуют в обеих библиотеках. Чтобы предотвратить конфликты имен, лучше в окно импортировать только один набор пространств имен (пространства имен WPF — для окна WPF, пространства имен Windows Forms — для форм), а для доступа к остальным применять полностью квалифицированные имена. Этот пример иллюстрирует самое замечательное свойство взаимодействия WPF и Windows Forms: оно никак не затрагивает код. Манипулируете вы элементом управления Windows Forms или элементом WPF — вы всегда используете знакомый интерфейс класса для этого объекта. Уровень взаимодействия — это просто та "магия", которая позволяет обоим ингредиентам сосуществовать в одном окне. Никакого дополнительного кода не понадобится. На заметку! Для того чтобы элементы управления Windows Forms могли работать с более современными стилями, появившимися в Windows XP, при запуске приложения можно вызвать EnableVisualStylesO, как было описано в разделе "Визуальные стили элементов управления Windows Forms" ранее в главе. Содержимое Windows Forms отображается механизмом Windows Forms, а не WPF Поэтому свойства контейнера WindowsFormsHost, касающиеся отображения (такие свойства, как Transform, Clip и Opacity), не оказывают влияния на то, что находится внутри него. Это значит, что даже если установить трансформацию вращения, указать область отсечения и сделать содержимое полупрозрачным, никаких изменений не будет К тому же Windows Forms использует другую систему координат для установки размеров элементов управления, имеющую дело с физическими пикселями. В результате, если увеличить системную настройку DPI на своем компьютере, то содержимое WPF изменится на более детализированное, а компоненты Windows Forms — нет. WPF и пользовательские элементы управления Windows Forms Одним из наиболее существенных ограничений элемента WindowsFormsHost является то, что он может содержать в себе только один элемент управления Windows Forms. Чтобы скомпенсировать этот недостаток, можно воспользоваться элементом-контейнером Windows Forms. К сожалению, контейнер Windows Forms не поддерживает модель содержимого XAML, так что придется наполнять его программно. Намного лучший подход заключается в применении пользовательского элемента управления Windows Forms. Такой пользовательский элемент управления может быть определен в отдельной сборке, на которую можно будет ссылаться, или же можно добавить его непосредственно в проект WPF (используя знакомый пункт меню Add^New Item). Это позволяет совместить преимущества обоих миров — появляется полная поддержка времени проектирования для построения пользовательского элемента управления наряду с простым способом интеграции его в окно WPF Фактически пользовательский элемент управления предоставляет дополнительный уровень абстракции, подобный применению отдельного окна. Это объясняется тем, что
Глава 30. Взаимодействие с Windows Forms 957 содержащее его окно WPF не может получить доступ к индивидуальным элементам, составляющим пользовательский элемент управления. Вместо этого оно взаимодействует с высокоуровневыми свойствами, добавляемыми к пользовательскому элементу, которые могут модифицировать его изнутри. Это повышает степень инкапсуляции кода и упрощает его, поскольку ограничивает точки взаимодействия между окном WPF и содержимым Windows Forms. Это также облегчает переход к чистому решению на основе WPF в будущем, посредством простой замены WindowsFormsHost пользовательским элементом управления WPF, имеющим те же свойства. (И, опять-таки, можно далее совершенствовать дизайн и повышать гибкость приложения, перемещая пользовательский элемент управления в отдельную сборку библиотеки классов.) На заметку! Формально окно WPF может обращаться к элементам внутри пользовательского элемента управления через его коллекцию Controls. Однако для того, чтобы использовать такой "черный ход", придется написать чреватый ошибками код поиска определенного элемента по его строковому имени. Это всегда будет плохой идеей. Разрабатывая пользовательский элемент управления, имеет смысл максимально приблизить его поведение к содержимому WPF, чтобы упростить его интеграцию в компоновку окна WPF. Например, можно воспользоваться контейнерными элементами управления FlowLayoutPanel и TableLayoutPanel, чтобы содержимое внутри пользовательского элемента управления приспосабливалось к его размерам. Просто добавьте соответствующий элемент управления и установите его свойство Dock в DockStyle.Fill. Затем поместите в него нужные элементы управления. Более подробно использование элементов управления компоновкой Windows Forms (которые слегка отличаются от панелей компоновки WPF) рассматривается в книге Pro .NET 2.0 Windows Forms and Custom Controls in C# (Apress, 2005 г.). Взаимодействие с ActiveX Платформа WPF не имеет прямой поддержки взаимодействия с ActiveX. Однако Windows Forms обладает такой поддержкой в форме вызываемых оболочек времени выполнения (runtime callable wrappers — RCW) — динамически генерируемых классов, позволяющих управляемому приложению Windows Forms размещать в себе компоненты ActiveX. Хотя существуют некоторые странности взаимодействия .NET с СОМ, которые могут разрушить определенные элементы управления, такой подход работает достаточно хорошо в большинстве случаев. К тому же, он работает незаметно, если тот, кто создает компонент, также предоставит сборку первичного взаимодействия (primary interop assembly), представляющую собой сделанную вручную, тонко настроенную RCW- оболочку, которая исключает проблемы при взаимодействии. Но как это поможет, когда нужно разработать приложение WPF, использующее элемент управления ActiveX? В этом случае потребуется создать два уровня взаимодействия Сначала элемент управления ActiveX помещается в пользовательский элемент управления или форму Windows Forms. После этого пользовательский элемент управления помещается в окно WPF или отображается форма из приложения WPF Размещение элементов управления WPF в форме Windows Forms Обратный подход — размещение содержимого WPF в форме Windows Forms — столь же прост. В этой ситуации класс WindowsFormsHost не нужен. Вместо этого применяется класс System.Windows .Forms .Integration .ElementHost — часть сборки WindowsFormsIntegration.dll. Класс ElementHost обладает способностью упаковывать любой элемент WPF. Однако ElementHost — действительный элемент управления
958 Глава 30. Взаимодействие с Windows Forms Windows Forms, а это значит, что его можно помещать в форму наряду с прочим содержимым Windows Forms. В некоторых отношениях ElementHost более прямолинеен, чем WindowsFormsHost, поскольку каждый элемент управления в Windows Forms отображается как отдельное окно со своим дескриптором hwnd. Так что нет ничего сложного в том, что одно из этих окон будет отображаться механизмом WPF, а не User32/GDI+. Visual Studio предоставляет некоторую поддержку времени проектирования для элемента управления ElementHost, но только если содержимое WPF помещается в пользовательский элемент управления WPF Ниже описано, как это сделать. 1. Щелкните правой кнопкой мыши на имени проекта в окне Solution Explorer и выберите в контекстном меню пункт Add"=>New Item (Добавить1^Новый элемент). Выберите шаблон User Control (WPF) (Пользовательский элемент управления (WPF)), укажите имя класса компонента и щелкните на кнопке Add (Добавить). На заметку! В этом примере предполагается, что пользовательский элемент управления WPF помещается в проект Windows Forms. При наличии сложного пользовательского элемента управления необходимо придерживаться более структурированного подхода к размещению его в отдельной сборке библиотеки классов. 2. Добавьте нужные элементы управления WPF к новому пользовательскому элементу WPF Visual Studio предоставляет обычный уровень поддержки времени проектирования для этого шага, так что можно перетаскивать элементы WPF из панели инструментов, конфигурировать их в окне Properties (Свойства) и т.п. 3. Заново постройте проект (выбрав в меню пункт Build^Build Solution (Сборка^Сборка решения)). Применять пользовательский элемент управления WPF в форме нельзя до тех пор, пока он не будет скомпилирован. 4. Откройте форму Windows Forms, куда необходимо добавить пользовательский элемент управления WPF (или создайте новую форму, щелкнув правой кнопкой мыши на имени проекта в окне Solution Explorer и выбрав в контекстном меню пункт Add<=>Windows Form (Добавить■=>Windows-форма)). 5. Чтобы поместить пользовательский элемент WPF в форму, понадобится помощь элемента управления ElementHost. Элемент ElementHost появляется на вкладке WPF Interoperability (Взаимодействие с WPF) панели инструментов. Перетащите его на форму и установите нужные размеры. Совет. Для лучшего разделения будет хорошей идеей добавить ElementHost к определенному контейнеру вместо того, чтобы добавлять непосредственно в форму. Это облегчит отделение содержимого WPF от остальной части окна. Обычно будет использоваться Panel, FlowLayoutPanel или TableLayoutPanel. 6. Чтобы выбрать содержимое ElementHost, используйте контекстную метку (смарт- тег). Если смарт-тег невидим, его можно отобразить, выбрав ElementHost и щелкнув на стрелке в правом верхнем углу. В этом смарт-теге имеется раскрывающийся список Select Hosted Content (Выбрать содержимое). Используя этот список, можно выбрать необходимый пользовательский элемент управления WPF, как показано на рис. 30.5. 7. Хотя пользовательский элемент управления WPF появится в форме, редактировать здесь его содержимое не получится. Чтобы быстро перейти к соответствующему XAML-файлу, щелкните на ссылке Edit Hosted Content (Редактировать содержимое) в смарт-теге ElementHost.
Глава 30. Взаимодействие с Windows Forms 959 Рис. 30.5. Выбор WPF-содержимого для ElementHost Формально ElementHost может содержать элемент WPF любого типа. Однако смарт- тег ElementHost ожидает, что будет выбран пользовательский элемент управления, имеющийся в проекте (или в сборке, на которую есть ссылка). Чтобы использовать элемент управления другого типа, понадобится написать код, который добавит его к ElementHost программно. Клавиши доступа, мнемоники и фокус Взаимодействие WPF и Windows Forms работает, потому что оба типа содержимого могут быть тщательно разделены. Каждая область выполняет собственное отображение и обновление, и взаимодействует с мышью независимо. Однако такая изоляция не всегда желательна. Например, это приводит к потенциальным проблемам обработки клавиатуры, которая иногда должна выполняться глобально для всей формы в целом. Ниже перечислены некоторые примеры. • При переходе с помощью клавиши <ТаЬ> от последнего элемента управления в одной области ожидается, что фокус перейдет к первому элементу управления в следующей области. • Когда нажимается горячая клавиша для обращения к элементу управления (такому как кнопка), ожидается, что он отреагирует, независимо от того, в какой области окна находится. • Когда используется мнемоника метки, ожидается, что фокус перейдет к связанному с ней элементу управления. • Аналогично, если подавляется нажатие клавиши с использованием события Preview, то нужно, чтобы это работало в каждой области, независимо от того, какой элемент в данный момент находится в фокусе.
960 Глава 30. Взаимодействие с Windows Forms Хорошая новость заключается в том, что такого поведения можно добиться без особых усилий. Например, рассмотрим окно WPF, показанное на рис. 30.6. Оно включает две кнопки WPF (верхнюю и нижнюю), а также одну кнопку Windows Forms (посредине). 1 MnemontcTest 1ШК**Ш Use Alt+£ Use Att+fi. UseA!U£ Рис. 30.6. Три кнопки с горячими клавишами Код разметки выглядит следующим образом: <Grid> <Grid.RowDefinitions> <RowDef initionx/RowDef inition> <RowDef mitionx /RowDef inition> <RowDef mitionx/RowDef inition> </Grid.RowDefinitions> <Button Click=,,cmdClicked">Use Alt+_A</Button> <Windows Forms Host Grid . Row=lll"> <wf:Button Text="Use Alt+&amp;B" Click=,,cmdClicked,,x/wf :Button> </WindowsFormsHost> <Button Grid.Row=,,2M Click="cmdClicked,,>Use Alt+_C</Button> </Grid> На заметку! Синтаксис для идентификации ускоряющих клавиш в WPF слегка отличается (здесь используется подчеркивание) от принятого в Windows Forms (где применяется символ &, который в XML должен быть представлен как &атр;, потому что является специальным) Когда это окно появляется впервые, текст на всех кнопках выглядит нормально. Когда пользователь нажимает и удерживает клавишу <Alt>, все три символа активных клавиш подчеркиваются. Пользователь затем может инициировать любую из трех кнопок, нажав на клавиатуре <А>, <В> или <С> (продолжая удерживать <А1т>). Аналогичный прием работает и с мнемониками, которые позволяют меткам передавать фокус ближайшему элементу управления (обычно — текстовому полю). Можно также использовать <ТаЬ> для перехода по трем кнопкам окна, как если бы они все были элементами управления WPF — сверху вниз. И, наконец, тот же пример продолжает работать, если поместить комбинацию содержимого Windows Forms и WPF в форму Windows Forms. Поддержка клавиатуры не всегда так хороша, и при передаче фокуса могут возникать разные сложности. Ниже представлен список возможных проблем. 1. Хотя WPF поддерживает систему передачи нажатий клавиш, чтобы каждый элемент управления получил шанс обработать клавиатурный ввод, модели обработ-
Глава 30. Взаимодействие с Windows Forms 961 ки событий клавиатуры в WPF и Windows Forms отличаются. По этой причине невозможно принимать события клавиатуры от WindowsFormsHost, когда фокус находится внутри содержимого Windows Forms. Аналогично, если пользователь переходит от одного элемента управления к другому внутри WindowsFormsHost, не удастся получать события GotFocus и LostFocus из WindowsFormsHost. На заметку! Кстати, то же самое справедливо и в отношении событий мыши WPF Например, событие MouseMove не возбуждается для WindowsFormsHost, пока курсор мыши перемещается в пределах его границ. 2. Проверка достоверности Windows Forms не срабатывает, когда фокус перемещается от элемента управления внутри WindowsFormsHost к элементу, находящемуся за его пределами. Вместо этого она инициируется, только при перемещении между элементами управления внутри WindowsFormsHost. (Если вспомнить, что содержимое WPF и содержимое Windows Forms — по сути, отдельные окна, это выглядит совершенно оправданным, поскольку именно такого поведения можно ожидать при переключении между разными приложениями.) 3. Если окно сворачивается в то время, когда фокус находится где-то в пределах WindowsFormsHost, то фокус может не быть восстановлен при восстановлении окна. Отображение свойств Одной из наиболее неудобных деталей взаимодействия между WPF и Windows Forms является то, что они используют похожие, но отличающиеся свойства. Например, элементы управления WPF имеют свойство Background, позволяющее применять кисти для рисования фона. Элементы управления Windows Forms используют похожее свойство BackColor, заполняющее фон цветом на основе значения ARGB. Несоответствие между этими двумя свойствами очевидно, несмотря на то, что они часто применяются для установки одного и того же аспекта внешнего вида. По большей части это не представляет собой проблемы. Как разработчик, вы просто должны переключаться между двумя API-интерфейсами, в зависимости от того объекта, с которым работаете. Однако WPF немного облегчает решение этой задачи, благодаря средству, называемому трансляторами свойств. .Трансляторы свойств не позволяют писать код разметки в стиле WPF и заставлять его работать с элементами управления Windows Forms. Фактически трансляторы свойств несколько примитивнее. Они просто преобразуют несколько базовых свойств WindowsFormsHost (или ElementHost) из одной системы в другую, так чтобы их можно было применять к дочернему элементу управления. Например, если устанавливается свойство WindowsFormsHost.IsEnabled, то свойство Enabled элемента управления, находящегося внутри, модифицируется соответствующим образом. Это не обязательное средство (можно сделать то же самое, модифицируя свойство Enabled дочернего элемента непосредственно, вместо того, чтобы работать со свойством IsEnabled контейнера), но часто это позволяет сделать код яснее. Чтобы выполнить эту работу, классы WindowsFormsHost и ElementHost включают коллекцию PropertyMap, отвечающую за ассоциирование имени свойства с делегатом, который идентифицирует метод, выполняющий преобразование. Используя этот метод, система отображения свойств способна обработать такие преобразования, как BackColor в Background и наоборот. По умолчанию каждая такая коллекция наполняется стандартным набором ассоциаций (вы вольны создавать собственные или заменять существующие, но обычно такая возня на низком уровне не имеет особого смысла).
962 Глава 30. Взаимодействие с Windows Forms В табл. 30.2 перечислены стандартные преобразования при отображении свойств, которые предоставляют классы WindowsFormsHost и ElementHost. Таблица 30.2. Отображения свойств Свойство WPF Свойство Windows Forms Комментарии Foreground Background Cursor FlowDirection FontFamily, FontSize, FontStretch, FontStyle, FontWeight IsEnabled Padding Visibility ForeColor BackColor или Backgroundlmage Cursor RightToLeft Font Enabled Padding Visible Преобразует любую кисть ColorBrush в соответствующий объект Color. В случае GradientBrush используется цвет GradientStop с минимальным значением смещения. Для любого другого типа кисти цвет ForeColor не изменяется, а применяется установленный по умолчанию Преобразует любую кисть SolidColorBrush в соответствующий объект Color. Прозрачность не поддерживается. Если используется какая-то более экзотичная кисть, то WindowsFormsHost создает растровое изображение и присваивает его свойству Backgroundlmage Преобразует значение из перечисления visibility в булевское значение. Если Visibility равно Hidden, то свойство Visible устанавливается в true, чтобы размер содержимого можно было использовать для вычислений компоновки, однако само содержимое WindowsFormsHost не рисует. Если Visibility равно Collapsed, то свойство visible не изменяется (остается в текущем установленном значении или значении по умолчанию) и WindowsFormsHost содержимое не рисует На заметку! Отображения свойств работают динамически. Например, если свойство WindowsFormsHost.FontFamily изменяется, то конструируется объект Font и применяется к свойству Font дочернего элемента управления.
Глава 30. Взаимодействие с Windows Forms 963 Взаимодействие с Win32 В настоящее время, когда платформа Windows Forms достигла периода упадка, и никаких существенных усовершенствований для нее не планируется, трудно поверить, что эта технология родилась на свет всего несколько лет назад. WPF не ограничивается взаимодействием только с приложениями Windows Forms — если хотите работать с API-интерфейсом Win32 или помещать содержимое WPF в MFC-приложение на C++, то также можете это сделать. Разместить Win32 в WPF можно с использованием класса System. Windows. Interop.HwndHost, работающего аналогично классу windowsFormsHost. Те же ограничения, которые присущи windowsFormsHost, характерны и для HwndHost (например, правило зазора, причуды передачи фокуса и т.п.). На самом деле WindowsFormsHost унаследован от HwndHost. Класс HwndHost — это шлюз в традиционный мир приложений C++ и MFC. Однако он также позволяет интегрировать управляемое содержимое DirectX. В настоящее время WPF не включает средств взаимодействия с DirectX, и нельзя использовать библиотеки DirectX для отображения содержимого в окне WPF. Однако можно применять DirectX для построения отдельного окна и затем разместить его внутри окна WPF с помощью HwndHost. Хотя рассмотрение DirectX выходит за рамки настоящей книги, управляемые библиотеки DirectX можно загрузить по адресу http://msdn.microsoft.com/directx. Дополнением класса HwndHost является класс HwndSource. В то время как HwndHost позволяет поместить любой дескриптор hwnd в окно WPF, HwndSource упаковывает в hwnd любой визуальный компонент или элемент WPF, так что его можно вставить в приложение на базе Win32 вроде приложения MFC. Единственное условие — приложение должно иметь доступ к библиотекам WPF, состоящим из управляемого кода .NET. Это нетривиальная задача. Если используется приложение C++, то простейшим подходом будет применение Managed Extensions for C++ (Управляемые расширения C++). Тогда можно будет создавать содержимое WPF, упаковывать его в HwndSource, устанавливать свойство HwndHost.RootVisual на элемент верхнего уровня и затем помещать HwndSource в окно. Все необходимое для построения сложных интегрированных проектов и адаптации унаследованного кода можно найти в Интернете, а также в справочной системе Visual Studio. Резюме В этой главе была рассмотрена поддержка взаимодействия, позволяющая приложениям WPF отображать содержимое Windows Forms (и наоборот). Был кратко описан элемент WindowsFormsHost, которые дает возможность встраивать элемент управления Windows Forms в окно WPF, а также элемент Element Host, позволяющий вставлять элемент WPF в форму. Оба эти класса предоставляют простой и эффективный способ управления переходом от Windows Forms к WPF.
ГЛАВА 31 Многопоточность Как известно из предыдущих глав, платформа WPF коренным образом изменила почти все основы программирования для Windows. Она предложила новый подход ко всему — от определения содержимого окна до отображения трехмерной графики. Платформа WPF даже представила новые концепции, которые не являются очевидным образом сосредоточенными на пользовательском интерфейсе, такие как свойства зависимости и маршрутизируемые события. Разумеется, огромное количество задач кодирования выходят за рамки программирования пользовательского интерфейса, а потому они не подверглись изменениям в мире WPF. Например, приложения WPF используют те же классы, что и другие приложения .NET, при подключении к базам данных, манипуляциях с файлами и проведении диагностики. Также несколько средств попадают в область, лежащую где-то между традиционным программированием .NET и WPF Эти средства не ограничены строго рамками WPF-приложений, но имеют некоторые специфичные для WPF особенности. Примером может служить модель дополнений (add-in model), которая позволяет WPF- приложению динамически загружать и использовать отдельно скомпилированные компоненты с полезными порциями функциональности (она будет описана в следующей главе). В этой главе рассматривается многопоточность, которая позволяет WPF- приложению выполнять фоновую работу, сохраняя пользовательский интерфейс максимально отзывчивым. На заметку! Как многопоточность, так и модель дополнений сами по себе являются обширными темами, которым можно посвящать целые книги; поэтому в настоящей главе мы не станем слишком углубляться в эти средства. Однако вы получите некоторый базовый фундамент, который пригодится для их применения с WPF, и который послужит надежной основой для дальнейшего изучения. Многопоточность Многопоточность — это искусство выполнения более чем одного фрагмента кода одновременно. Целью многопоточности обычно является создание более отзывчивого интерфейса — такого, который не "замораживается" во время работы, — хотя многопоточность можно применять также и для того, чтобы полнее задействовать преимущества двухядерного процессора при выполнении ресурсоемких алгоритмов или другой работы одновременно с некоторой длительной операцией (например, чтобы выполнять некоторые вычисления в процессе ожидания ответа от веб-службы). В самом начале проектирования платформы WPF ее разработчики предусмотрели новую модель многопоточности. Эта модель, называемая арендой потоков (thread rent-
Глава 31. Многопоточность 965 al), позволила обращаться к объектам пользовательского интерфейса из любого потока. Чтобы сократить стоимость блокировки, группы взаимосвязанных объектов могут объединяться под одной блокировкой (называемой контекстом). К сожалению, такое проектное решение усложнило однопоточные приложения (которые должны учитывать контекст) и затруднило взаимодействие с унаследованным кодом (вроде Win32 API). В конечном итоге от этого подхода пришлось отказаться. В результате теперь WPF поддерживает модель однопоточного апартамента (single- threaded apartment — STA), которая очень похожа на ту, что используется в приложениях Windows Forms. С этой моделью связано несколько основных правил. • Элементы WPF обладают потоковой родственностью (thread affinity). Поток, который создает их, владеет ими, и другие потоки не могут взаимодействовать с ними напрямую. (Элемент— это объект WPF, отображаемый в окне.) • Объекты WPF, обладающие потоковой родственностью, наследуются от DispatcherObject в некоторой точке их иерархии классов. DispatcherObject включает небольшой набор членов, которые позволяют верифицировать, выполняется ли код в правильном потоке, чтобы использовать определенный объект, и (если нет) переключаться на другой поток. • На практике один поток выполняет все приложение и владеет объектами WPF Хотя можно использовать отдельные потоки, чтобы отображать отдельные окна, такое проектное решение встречается редко. В следующих разделах вы ознакомитесь с классом DispatcherObject и изучите простейший способ выполнения асинхронных операций в приложении WPF Диспетчер Диспетчер управляет работой, происходящей в WPF-приложении. Диспетчер владеет потоком приложения и управляет очередью элементов работы. Во время работы приложения диспетчер принимает новые запросы работы и выполняет их по одному. Формально диспетчер создается при первоначальном создании в новом потоке экземпляра класса, который наследуется от DispatcherObject. В случае создания отдельных потоков и использования их для отображения отдельных окон получается более одного диспетчера. Однако большинство приложений не усложняют картину и обходятся одним потоком пользовательского интерфейса и одним диспетчером. Затем они используют многопоточность для управления операциями с данными и другими фоновыми задачами. На заметку! Диспетчер — это экземпляр класса System.Windows.Threading.Dispatcher. Все связанные с диспетчером объекты также находятся в пространстве имен System. Windows.Threading, которое является новым в WPF. (Центральные классы для организации потоков, которые существуют со времен .NET 1.0, находятся в пространстве System. Threading.) Получить диспетчер для текущего потока можно через статическое свойство Dispatcher.CurrentDispatcher. Используя объект Dispatcher, можно присоединить обработчики событий, которые отвечают за необработанные исключения или реагируют на завершение диспетчера. Можно также получить ссылку на поток System. Threading.Thread, которым управляет диспетчер, завершить диспетчер или направить код на выполнение правильному потоку (прием, который будет описан в следующем разделе).
966 Глава 31. Многопоточность Класс DispatcherObject Большую часть времени вы не будете взаимодействовать с диспетчером напрямую. Однако немало времени придется тратить на использование экземпляров DispatcherObject, потому что каждый визуальный объект WPF наследуется от этого класса. DispatcherObject — это просто объект, привязанный к диспетчеру. Другими словами — объект, привязанный к потоку диспетчера. DispatcherObject имеет всего три члена, которые перечислены в табл. 31.1. Таблица 31.1. Члены класса DispatherObject Имя Описание Di spat her Возвращает диспетчер, управляющий данным объектом CheckAccess () Возвращает true, если код находится в правильном потоке для использования объекта; в противном случае возвращает false VerifyAccessO Ничего не делает, если код находится в правильном потоке для использования объекта; в противном случае генерирует исключение InvalidOperationException Объекты WPF часто вызывают VerifyAccessO, чтобы защитить себя. Они не вызывают Verif yAccess () в ответ на каждую операцию (поскольку это было бы слишком накладно по производительности), но вызывают этот метод достаточно часто, чтобы было маловероятным долго использовать объект из неверного потока. Например, следующий код реагирует на щелчок на кнопке, создавая новый объект System.Threading.Thread. Затем он использует этот поток для вызова небольшого фрагмента кода, который изменяет текстовое поле в текущем окне. private void cmdBreakRules_Click(object sender, RoutedEventArgs e) { Thread thread = new Thread(UpdateTextWrong); thread.Start(); } private void UpdateTextWrong() { // Эмулирует некоторую работу посредством пятисекундной задержки. Thread.Sleep(TimeSpan.FromSecondsE)); txt.Text = "Here is some new text.11; } \ Этот код специально задуман так, чтобы выдать сбой. Метод UpdateTextWrong () будет выполнен в новом потоке, которому не разрешен доступ к объектам WPF. В этом случае объект TextBox перехватывает нарушение, вызывая VerifyAccessO, при этом генерируется исключение InvalidOperationException. Чтобы исправить код, понадобится получить ссылку на диспетчер, владеющий объектом TextBox (тот же самый диспетчер, который владеет окном и всеми прочими объектами WPF в приложении). Получив доступ к этому диспетчеру, можно вызывать Dispatcher.Beginlnvoke(), чтобы маршализировать некоторый код потоку диспетчера. По сути, Beginlnvoke () планирует указанный код в качестве задачи для диспетчера. Затем диспетчер выполняет этот код. Ниже показан корректный код: private void cmdFollowRules_Click(object sender, RoutedEventArgs e) { Thread thread = new Thread(UpdateTextRight); thread.Start(); }
Глава 31. Многопоточность 967 private void UpdateTextRight () { // Эмулирует некоторую работу, происходящую с пятисекундной задержкой. Thread.Sleep(TimeSpan.FromSecondsE))/ // Получить диспетчер от текущего окна и использовать // его для вызова кода обновления. this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (ThreadStart) delegate() { txt.Text = "Here is some new text.11/ } ); } Метод Dispatcher.BeginlnvokeO принимает два параметра. Первый указывает свойство задачи. В большинстве случаев будет применяться DispatcherPriority. Normal, но можно также использовать более низкий приоритет, если есть задача, которая не обязательно должна быть завершена немедленно, и которую можно отложить до того момента, когда диспетчеру нечего будет делать. Например, это может иметь смысл, если нужно отобразить сообщение о состоянии длительно выполняющейся операции где- то в рамках пользовательского интерфейса. Можно использовать DispatcherPriority. Applicationldle, чтобы подождать, пока приложение завершит всю прочую работу, либо еще более "сдержанный" метод DispatcherPriority.Systemldle, чтобы подождать, пока вся система не придет в состояние ожидания, и центральный процессор не станет простаивать. Допускается также применять пониженный приоритет, чтобы отвлечь внимание диспетчера на что-то другое. Однако рекомендуется оставлять высокие приоритеты для событий ввода (таких как нажатия клавиш). Они должны обрабатываться почти постоянно, или же возникнет впечатление, что приложение несколько медлительно. С другой стороны, добавление нескольких миллисекунд ко времени выполнения фоновой операции не будет заметно, так что приоритет DispatcherPriority.Normal более оправдан в такой ситуации. Второй параметр BeginlnvokeO — это делегат, указывающий на метод с кодом, который необходимо выполнить. Этот метод может находиться где-то в другом месте кода, или же его можно определить встроенным (как в приведенном примере). Подход на основе встроенного кода хорош для простых операций, таких как обновление в одной строке. Однако если нужно использовать более сложный процесс для обновления пользовательского интерфейса, лучше будет вынести такой код в отдельный метод. На заметку! Метод BeginlnvokeO также возвращает значение, которое в данном примере не используется. BeginlnvokeO возвращает объект DispatcherOperation, который позволяет получить состояние операции маршализации и определить, когда код действительно было выполнен. Однако DispatcherOperation применяется редко, потому что код, который передается BeginlnvokeO, должен выполняться за очень короткое время. Помните, что длительная фоновая операция должна выполняться в отдельном потоке, а результат маршализироваться потоку диспетчера (и в этот момент будет обновлен пользовательский интерфейс, чтобы изменить разделяемый объект). Не имеет смысла выполнять длительно работающий код в методе, который передается BeginlnvokeO. Например, приведенный ниже слегка реорганизованный код работает, однако он менее практичен: private void UpdateTextRight() { // Получить диспетчер из текущего окна.
968 Глава 31. Многопоточность this.Dispatcher.Beginlnvoke(DispatcherPriority.Normal, (ThreadStart) delegate () { // Эмуляция некоторой длительной работы. Thread.Sleep(TimeSpan.FromSecondsE)); txt.Text = "Here is some new text."; } ) ; } Здесь проблема заключается в том, что вся работа происходит в потоке диспетчера. Это значит, что код займет диспетчер примерно так же, как это происходило бы в приложении без многопоточности. На заметку! Диспетчер также предоставляет метод Invoke (). Подобно Beginlnvoke (), он маршализирует указанный код потоку диспетчера. Но в отличие от Beginlnvoke(), метод Invoke () останавливает поток до тех пор, пока диспетчер выполняет код. Метод Invoke () можно использовать, если нужно приостановить асинхронную операцию до тех пор, пока от пользователя не поступит какой-нибудь отклик. Например, метод Invoke() можно вызвать для запуска фрагмента кода, отображающего диалоговое окно с кнопками ОК и Cancel (Отмена). После того как пользователь щелкнет на кнопке и маршализируемый код завершится, Invoke () вернет управление, и можно будет продолжить работу в соответствии с ответом пользователя. Класс BackgroundWorker Выполнять асинхронные операции можно разными способами. Ранее уже был показан один бесхитростный подход — создание нового объекта System.Threading.Thread вручную, применение асинхронного кода и запуск его методом Thread.Start(). Это мощный подход, потому что объект Thread ничего не задерживает. Можно создавать десятки потоков, устанавливать их приоритеты, управлять их состоянием (например, приостанавливать, возобновлять или прерывать их) и т.д. Однако с этим подходом также связана некоторая опасность. При обращении к разделяемым данным понадобится применять блокировку для предотвращения тонких ошибок. Если потоки создаются часто или в большом количестве, то тем самым возникают дополнительные накладные расходы. Приемы написания хорошего многопоточного кода и используемые при этом классы .NET не являются специфичными для WPF. В мире WPF может использоваться многопоточный код, аналогичный тому, что применяется в приложениях Windows Forms. В оставшейся части этой главы рассматривается один из наиболее простых и безопасных подходов: компонент System.ComponentModel.BackgroundWorker. Совет. Чтобы увидеть разные подходы — от простейшего до наиболее сложного — обратитесь к книге Programming .NET 2.0 Windows Forms and Custom Controls in C# (Apress, 2005 г.). Класс BackgroundWorker появился в .NET 2.0 и был предназначен для упрощения работы с потоками в приложениях Windows Forms. Однако BackgroundWorker в той же мере применим и в WPF Компонент BackgroundWorker предоставляет почти идеальный способ запуска длительно выполняющихся задач в отдельном потоке. Он использует диспетчер "за кулисами" и абстрагирует сложности маршализации с помощью модели событий. Как вы убедитесь, BackgroundWorker также поддерживает два дополнительных удобства: события продвижения и сообщения отмены. В обоих случаях детали многопоточности скрыты, что облегчает кодирование.
Глава 31. Многопоточность 969 На заметку! BackgroundWorker незаменим, если есть единственная асинхронная задача, которая выполняется в фоновом режиме от начала до конца (с необязательной поддержкой уведомлений о продвижении и возможностью отмены). Если же имеется в виду что-то еще, например, асинхронная задача, которая работает на протяжении всей жизни приложения, или асинхронная задача, взаимодействующая с приложением, пока оно выполняет свою работу, то придется спроектировать специальное решение, воспользовавшись поддержкой многопоточности .NET. Простая асинхронная операция Чтобы попробовать BackgroundWorker в действии, стоит рассмотреть пример приложения. Базовым ингредиентом любого тестирования является длительно выполняющийся процесс. В следующем примере используется распространенный алгоритм нахождения простых чисел в заданном диапазоне, называемый решетом Эратосфена, который был изобретен Эратосфеном примерно в 240 г. до нашей эры. В соответствие с этим алгоритмом, процесс начинается с составления списка всех целых числе в заданном диапазоне. Затем вычеркиваются все числа, кратные всем простым числам, меньшим или равным квадратному корню из максимального числа. Оставшиеся числа и будут простыми. В этом примере не будет приводиться теория, которая доказывает работоспособность решета Эратосфена, либо демонстрироваться тривиальный код, реализующий его. (Также не следует беспокоиться об оптимизации или сравнении с другими приемами.) Однако будет показано, как асинхронно выполнить алгоритм решета Эратосфена. Полный код доступен в загружаемых примерах для этой главы. Он принимает следующую форму: public class Worker { public static int[] FindPrimes(int fromNumber, int toNumber) { // Найти простые числа в диаразоне между fromNumber //и toNumber, вернув их в виде массива целых. } } Метод FindPrimes() принимает два параметра, которые ограничивают диапазон чисел. Затем код возвращает массив целых чисел, содержащий все простые числа из заданного диапазона. На рис. 31.1 показан пример, который будет построен. Окно позволяет пользователю выбрать диапазон чисел. Когда пользователь щелкает на кнопке Find Primes (Найти простые числа), поиск начинается, но проходит в фоновом режиме. По завершении поиска перечень простых чисел появляется в окне списка. Создание BackgroundWorker Чтобы использовать BackgroundWorker, следует начать с создания его экземпляра. При этом на выбор доступны два подхода. • Можно создать BackgroundWorker в коде и присоединить программно все обработчики событий. ■ Multithreading | From: i i To: 500000 Find Primes i i Results- l : 3 5 7 п 13 L7 19 23 29 ^i Hffliir' * Рис. 31.1. Завершенный поиск простых чисел
970 Глава 31. Многопоточность • Можно объявить BackgroundWorker в XAML-разметке. Преимущество такого подхода в возможности присоединения обработчиков событий через атрибуты. Поскольку BackgroundWorker не является видимым элементом WPF, его нельзя поместить куда угодно. Вместо этого его понадобится объявить как ресурс для окна. Оба подхода эквивалентны. В загружаемом коде примеров для этой главы используется второй подход. Первый шаг предусматривает обеспечение доступа к пространству имен System.ComponentModel в XAML-документе через импорт. Чтобы сделать это, понадобится отобразить пространство имен на префикс XML: <Window х:Class="Multithreading.ВасkgroundWorkerTest" xmlns="http://schemas.microsoft.com/winfx/200 6/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns : cm="clr- name space: System. ComponentModel ;assembly=System" . . . > Теперь можно создать экземпляр BackgroundWorker в коллекции Windows. Resources. При этом должно быть указано ключевое имя, чтобы позже можно было извлечь этот объект. В данном примере ключевым именем является backgroundWorker: <Window.Resources> <cm:BackgroundWorker x:Key="backgroundWorker"></cm:BackgroundWorker> </Window.Resources> Преимущество объявления BackgroundWorker в разделе Window.Resources заключается в том, что можно установить его свойства и присоединить обработчики событий посредством атрибутов. Например, ниже приведен дескриптор BackgroundWorker, который получится в конце примера, включающий поддержку уведомления о прохождении и возможности отмены, а также присоединяющий обработчики событий к DoWork, ProgressChanged и RunWorkerCompleted. <cm:BackgroundWorker x:Key="backgroundWorker" WorkerReportsProgress="True" WorkerSupportsCancellation="True" DoWork="backgroundWorker_DoWork" ProgressChanged="backgroundWorker_ProgressChanged" RunWorkerCompleted=llbackgroundWorker_RunWorkerCompleted"> </cm:BackgroundWorker> Чтобы получить доступ к этому ресурсу в коде, потребуется поместить его в коллекцию Resources. В данном примере окно выполняет этот шаг в своем конструкторе, так что весь код обработки событий легко доступен: public partial class BackgroundWorkerTest : Window { private BackgroundWorker backgroundWorker; public BackgroundWorkerTest () { InitializeComponent(); backgroundWorker = ((BackgroundWorker)this.FindResource("backgroundWorker" ) ) ; } } Запуск BackgroundWorker Первый шаг к использованию BackgroundWorker с примером поиска простых чисел состоит в создании специального класса, который позволит передать входные параметры BackgroundWorker. При вызове BackgroundWorker.RunWorkerAsyncO можете
Глава 31. Многопоточность 971 указать любой объект, который будет доставлен событию Do Wo r к. Тем не менее, допускается задавать только один объект, поэтому придется упаковать числа начала и конца диапазона в один класс, как показано ниже: public class FindPrimesInput { public int From { get; set; } public int To { get; set; } public FindPrimesInput (int from, int to) { From = from; To = to; Чтобы запустить сам BackgroundWorker, понадобится вызвать метод BackgroundWorker.RunWorkerAsyncO и передать объект FindPrimesInput. Ниже приведен код, который делает это, когда пользователь щелкает на кнопке Find Primes: private void cmdFind_Click(object sender, RoutedEventArgs e) { // Сделать недоступной эту кнопку и очистить предыдущие результаты. cmdFind.IsEnabled = false; cmdCancel.IsEnabled = true; IstPrimes.Items.Clear(); // Получить диапазон поиска. int from, to; if (!Int32.TryParse(txtFrom.Text, out from)) { MessageBox.Show("Invalid From value."); // Неверное значение From. return; } if (!Int32.TryParse(txtTo.Text, out to) ) { MessageBox. Show ("Invalid To value.11); // Неверное значение То. return; } // Начать поиск простых чисел в другом потоке. FindPrimesInput input = new FindPrimesInput(from, to) ; backgroundWorker.RunWorkerAsync(input); } Когда BackgroundWorker начинает работу, он захватывает свободный поток из пула потоков CLR и затем инициирует событие DoWork из этого потока. В обработчике события DoWork запускается длительно выполняющаяся задача. Однако следует соблюдать осторожность и не обращаться к разделяемым данным (таким как поля класса окна) или объектам пользовательского интерфейса. Как только работа будет завершена, BackgroundWorker инициирует событие RunWorkerCompleted, чтобы уведомить приложение. Это событие инициируется в потоке диспетчера, что позволит обратиться к разделяемым данным и пользовательскому интерфейсу, не порождая никаких проблем. Как только BackgroundWorker захватывает поток, он инициирует событие DoWork. Это событие можно обработать, вызвав метод Worker.FindPrimesO. Событие DoWork предоставляет объект DoWorkEventArgs, который является ключевым ингредиентом при извлечении и возврате информации. Входной объект извлекается через свойство DoWorkEventArgs.Argument, а результат возвращается за счет установки свойства DoWorkEventArgs.Result.
972 Глава 31. Многопоточность private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { // Получить входные значения. FindPrimesInput input = (FindPrimesInput)e.Argument; // Запустить поиск простых чисел и ждать. // Это длительная часть работы, но она не подвешивает // пользовательский интерфейс, поскольку выполняется в другом потоке. int [ ] primes = Worker.FindPrimes(input.From, input.To); // Вернуть результат, e.Result = primes; } По завершении метода BackgroundWorker инициирует RunWorkerCompleted EventArgs в потоке диспетчера. В этой точке можно извлечь результат из свойства RunWorkerCompletedEventArgs.Result. Затем можно обновить интерфейс и обратиться к переменным уровня окна без опаски. private void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { if (e.Error != null) { // Ошибка была сгенерирована обработчиком события DoWork. MessageBox . Show (e .Error .Message, "An Error Occurred11); } else { int [ ] primes = (int[])e.Result; foreach (int prime in primes) { IstPrimes.Items.Add(prime); } } cmdFind.IsEnabled = true; cmdCancel.IsEnabled = false; progressBar.Value = 0; } Обратите внимание, что не понадобился никакой код блокировки, и не было необходимости в использовании метода Dispatcher.BeginInvoke(). Объект BackgroundWorker обо всем позаботился сам. "За кулисами" BackgroundWorker использует несколько многопоточных классов, которые появились в .NET 2.0, в том числе AsyncOperationManager, AsyncOperation и SynchronizationContext. По сути, BackgroundWorker применяет AsyncOperationManager для управления фоновой задачей. AsyncOperationManager обладает встроенным интеллектом, а именно: он способен получить контекст синхронизации для текущего потока. В приложении Windows Forms AsyncOperationManager получает объект WindowsFormsSynchronizationContext, в то время как приложение WPF получает объект DispatcherSynchronizationContext. Концептуально эти классы выполняют одинаковую работу, но их внутреннее устройство отличается. Отслеживание продвижения BackgroundWorker также предоставляет встроенную поддержку первоначальной установки свойства BackgroundWorker.WorkerReportsProgress в true. На самом деле предоставление и отображение информации о продвижении — двухшаговый процесс. Первым делом коду обработки события DoWork необходимо вызвать метод
Глава 31. Многопоточность 973 BackgroundWorker.ReportProgress () и показать предполагаемый процент готовности (от 0% до 100%). Это можно делать редко или часто — как нравится. При каждом вызове ReportProgress() объект BackgroundWorker инициирует событие ProgressChanged. На это событие можно отреагировать, чтобы прочитать процент готовности и обновить пользовательский интерфейс. Поскольку событие ProgressChanged инициировано в потоке пользовательского интерфейса, в применении Dispatcher.BeginlnvokeO нет необходимости. Метод FindPrimesO сообщает о продвижении с приращением 1%, используя код вроде показанного ниже: int iteration = list.Length / 100; for (int 1=0; l < list.Length; i++) // Сообщить о продвижении, только если есть изменение в 1%. // Также не нужно выполнять вычисление, если нет BackgroundWorker или если он //не поддерживает уведомления о продвижении. if ( (i % iteration == 0) && (backgroundWorker != null) && backgroundWorker.WorkerReportsProgress) { backgroundWorker.ReportProgress(i / iteration); } } Как только свойство BackgroundWorker.WorkerReportsProgress установлено, с этого момента можно реагировать на уведомления о продвижении, обрабатывая событие ProgressChanged. В этом примере индикатор продвижения обновляется соответствующим образом: private void backgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e) { progressBar.Value = e.ProgressPercentage; На рис. 31.2 показан индикатор продвижения. Рис. 31.2. Отслеживание продвижения асинхронной задачи
974 Глава 31. Многопоточность Поддержка отмены С помощью BackgroundWorker столь же просто добавить поддержку отмены длительно выполняющейся задачи. Первый шаг состоит в установке в true свойства BackgroundWorker.WorkerSupportsCancellation. Чтобы запросить отмену, код должен вызвать метод BackgroundWorker.CancelAsyncQ. В этом примере отмена запрашивается при щелчке на кнопке Cancel (Отмена): private void cmdCancel_Click(object sender, RoutedEventArgs e) { backgroundWorker.CancelAsync(); } При вызове CancelAsync () ничего автоматически не происходит. Вместо этого код, выполняющий задачу, должен явно проверить запрос на отмену, выполнить необходимую очистку и вернуть управление. Ниже приведен код метода FindPrimesO, который проверяет запросы на отмену непосредственно перед сообщением о продвижении: for (int i = 0; i < list.Length; i++) { if ( (l % iteration) && (backgroundWorker l= null)) { if (backgroundWorker.CancellationPending) { // Возврат без какой-либо дальнейшей работы, return; } if (backgroundWorker.WorkerReportsProgress) { backgroundWorker.ReportProgress(i / iteration); } } } Код в обработчике события DoWork также должен явно установить свойство DoWorkEventArgs.Cancel в true, чтобы завершить отмену. Затем производится возврат из метода без попытки построить строку простых чисел. private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { FindPrimesInput input = (FindPrimesInput)e.Argument; int [ ] primes = Worker.FindPrimes(input.From, input.To, backgroundWorker); if (backgroundWorker.CancellationPending) { e. Cancel = true; return; } // Вернуть результат, e.Result = primes; } Даже при отмене операции событие RunWorkerCompleted все равно инициируется. В этой точке можно проверить, отменена ли задача, и предпринять соответствующую обработку. private void backgroundWorker_RunWorkerCompleted (object sender, RunWorkerCompletedEventArgs e) {
Глава 31. Многопоточность 975 if (e.Cancelled) { MessageBox.Show("Search cancelled."); } else if (e.Error != null) { // Обработчиком события DoWork была сгенерирована ошибка. MessageBox.Show(e.Error.Message, "An Error Occurred"); } else { int[] primes = (int[])e.Result; foreach (int prime in primes) { IstPrimes.Items.Add(prime); } } cmdFind.IsEnabled = true; cmdCancel.IsEnabled = false; progressBar.Value = 0; } Теперь компонент BackgroundWorker позволяет запускать поиск и останавливать его принудительно. Резюме Для проектирования безопасного и стабильного многопоточного приложения следует понимать правила многопоточности WPF. В этой главе вы ознакомились с этими правилами, узнали, как безопасно обновлять элементы управления из других потоков, а также увидели, как обеспечивать уведомления о продвижении, предоставлять поддержку отмены и упрощать многопоточность с помощью BackgroundWorker.
ГЛАВА 32 Модель дополнений Дополнения (add-ins, также называемые подключаемыми модулями (plug-ins)) — это отдельно компилируемые компоненты, которые приложение может находить, загружать и использовать динамически. Часто приложение спроектировано с учетом использования дополнений, так что оно может быть расширено в будущем без необходимости в модификации, перекомпиляции и повторном тестировании. Дополнения также предлагают гибкость в настройке отдельных экземпляров приложения для определенного рынка или клиента. Но наиболее частой причиной для использования модели дополнений является необходимость позволить независимым разработчикам расширять функциональность приложения. Например, дополнения к Adobe Photoshop предлагают широкий спектр эффектов обработки изображений. Дополнения к Firefox предоставляют расширенные средства веб-серфинга и совершенно новую функциональность. В обоих случаях такие дополнения создаются независимыми разработчиками. Со времен .NET 1.0 разработчики были вынуждены разрабатывать собственные системы дополнений. Два базовых ингредиента таких систем — это интерфейсы (позволяющие определять контракты, через которые приложение взаимодействует с дополнением, а дополнение — с приложением) и рефлексия (предоставляющая приложению возможность динамически обнаруживать и загружать дополнительные типы из отдельной сборки). Однако построение системы дополнений с нуля требует значительного объема работы. Понадобится продумать способ нахождения дополнений, а также обеспечить правильное управление ими (другими словами, чтобы они выполнялись в ограниченном контексте безопасности и при необходимости могли быть выгружены). К счастью, теперь .NET включает предварительно построенную модель дополнений, которая избавляет от забот подобного рода. Эта модель использует интерфейсы и рефлексию, подобно модели дополнений, которую вы, возможно, разрабатывали самостоятельно. При этом модель дополнений .NET берет на себя заботу о низкоуровневых механизмах выполнения таких утомительных задач, как обнаружение и размещение. В этой главе будет показано, как использовать модель дополнений в приложениях WPF. Выбор между MAF и MEF Прежде чем приступить к построению расширяемого приложения с дополнениями, придется решить одну неожиданную проблему. Дело в том, что .NET включает не одну платформу дополнений, а две. В .NET 3.5 появилась модель дополнений по имени Managed Add-In Framework (MAF). Но чтобы сделать картину более интересной (и сильнее ее запутать), в .NET 4 была добавлена новая модель под названием Managed Extensibility Framework (MEF). Разработчики, которым еще не так давно приходилось создавать собственную систему
Глава 32. Модель дополнений 977 дополнений, вдруг получили две совершенно отдельные технологии дополнений, разделяющий один и тот же фундамент. Так в чем же разница между ними? Из двух платформ MAF является более устойчивой. Она позволяет отделить дополнения от приложения, так что они не зависят ни от чего, кроме определенного интерфейса. Это обеспечивает желаемую гибкость в сценариях с поддержкой множества версий, например, если нужно изменить интерфейс, но продолжать поддерживать старые дополнения с целью обратной совместимости. MAF также позволяет приложению загружать дополнения в отдельный домен приложений, так что если они вдруг потерпят крах, это не затронет основное приложение. Все эти особенности означают, что MAF работает хорошо, если одна команда разработчиков занимается приложением, а другая (или несколько) — его дополнениями. Кроме того, MAF особенно хорошо подходит для поддержки дополнений от независимых поставщиков. Однако средства MAF имеют свою цену. MAF — сложная платформа, и настройка конвейера дополнения утомительна даже для простого приложения. И здесь на сцену выходит MEF. Это более легковесный вариант, который предназначен для того, чтобы сделать расширяемость настолько простой, как копирование связанных сборок в одну папку. Однако MEF построен на другой основополагающей философии, чем MAF. В то время как MAF представляет собой строгую, управляемую интерфейсами модель дополнений, MEF — более свободная система, позволяющая строить приложение в виде коллекции частей. Каждая часть может экспортировать функциональность, и любая часть может импортировать функциональность любой другой части. Эта система обеспечивает разработчикам намного более высокую степень гибкости, и особенно хорошо подходит для построения составных приложений (модульных программ, разрабатываемых одной командой разработчиков, но которые должны собираться разными способами, с различным набором реализованных средств для разных выпусков). Очевидная опасность состоит в том, что MEF слишком свободна, и плохо спроектированное приложение может быстро превратиться в клубок взаимозависимых частей. Если MAF выглядит более подходящей системой, продолжайте чтение, т.к. именно она рассматривается в настоящей главе. Платформа MEF более подробно описана на сайте сообщества MEF по адресу http://www.codeplex.com/MEF. Если же в действительности интересуют не дополнения, а составные приложения, то стоит обратить внимание на библиотеку Composite Application Library (CAL) от Microsoft, известную также под ее прежним названием — Prism. Хотя MEF — это решение общего характера для построения любого рода модульных приложений .NET, библиотека CAL специально ориентирована на WPF Эта библиотека включает средства, связанные с пользовательским интерфейсом, такие как возможность разрешения различным модулям взаимодействовать с событиями и отображать содержимое в отдельных областях. CAL также поддерживает "гибридные" приложения, которые могут компилироваться для платформы WPF или основанной на браузерах платформы Silverlight. Документация и загружаемые файлы CAL доступны по адресу http://tinyurl.com/51jve8, а ознакомительная статья — по адресу http://tinyurl.com/56m33n. Конвейер дополнения Ключевое преимущество модели дополнений состоит в том, что она избавляет от необходимости написания низкоуровневых задач, таких как обнаружение. Ключевым ее недостатком следует считать высокую сложность. Проектировщики .NET приложили немало усилий, чтобы сделать модель дополнений достаточно гибкой, и она справляется с разнообразными сценариями поддержки множества версий и размещения. В результате получилось, что для реализации модели дополнений в приложении должны
978 Глава 32. Модель дополнений быть созданы как минимум семь отдельных компонентов, даже если не планируется использовать ее наиболее сложные средства. Ядром модели дополнений является конвейер (pipeline) дополнения, представляющий собой цепь компонентов, которые позволяют принимающему приложению взаимодействовать с дополнением (рис. 32.1). На одном конце этого конвейера находится принимающее приложение (хост). На другом конце — дополнение. Между ними находятся пять компонентов, которые отвечают за взаимодействие. Приложение хост (ЕХЕ) Представление хоста (DLL) Граница домена приложения Адаптер стороны хоста (DLL) Адаптер стороны __ дополнения _ (DLL) Контракт (DLL) Дополнение (DLL) Представление дополнения (DLL) Рис. 32.1. Взаимодействие через конвейер дополнения На первый взгляд эта модель кажется несколько перегруженной. Более простой сценарий мог бы помещать единственный уровень (контракт) между приложением и дополнением. Однако дополнительные уровни (представления и адаптеры) позволяют модели дополнений быть более гибкой в определенных ситуациях (как описано во врезке "Более развитые адаптеры"). Как работает конвейер Контракт — это краеугольный камень конвейера дополнения. Он включает в себя один или более интерфейсов, которые определяют, как принимающее приложение может взаимодействовать с дополнениями и как дополнения могут взаимодействовать с принимающим приложением. Сборка контракта может также включать специальные сериализуемые типы, которые планируется использовать для передачи данных между принимающим приложением и дополнением. Конвейер дополнения спроектирован с учетом необходимой расширяемости и гибкости. И по этой причине принимающее приложение и дополнение не используют контракт напрямую. Вместо этого они применяют собственные соответствующие версии контракта, называемые представлениями (views). Принимающее приложение использует представление хоста, в то время как дополнение — представление дополнения. Обычно представление включает абстрактные классы, которые соответствуют интерфейсам в контракте. Хотя обычно они достаточно похожи, контракты и представления полностью независимы. Соединить эти две части вместе — задача адаптеров. Адаптеры выполняют это соединение, представляя классы, которые одновременно наследуются от классов представлений и реализуют интерфейсы контракта. На рис. 32.2 показано это проектное решение. По сути, адаптеры заполняют пробел между представлениями и интерфейсом контракта. Они отображают вызовы представления на вызовы интерфейса контракта. Также они отображают вызовы интерфейса контракта на соответствующий метод представления. Это несколько усложняет проектное решение, но добавляет дополнительный уровень гибкости.
Глава 32. Модель дополнений 979 Принимающее . приложение ^ w Ч F Г Класс адаптера ~Л стороны хоста ^ 4 - W ■ w С" Класс адаптера "Л стороны дополнения V ) i i i i i i •■ • наследует •' *■ реализует - i - реализует * : ! 1 V Y * Абстрактный класс представления хоста V J Интерфейс контракта ^ w ^ W Г 1 Y ... Класс дополнения • наследует- * Абстрактный класс представления дополнения Рис. 32.2. Отношения между классами в конвейере Чтобы понять, как работают адаптеры, рассмотрим, что происходит, когда приложение использует дополнение. Сначала принимающее приложение вызывает один из методов в представлении вида. Но вспомните, что представление хоста — это абстрактный класс. "За кулисами" приложение на самом деле вызывает метод хост-адаптера через представление хоста. (Это возможно, поскольку класс адаптера хоста наследуется от класса представления хоста.) Хост-адаптер затем вызывает соответствующий метод в интерфейсе контракта, который реализован адаптеров дополнения. И, наконец, адаптер дополнения вызывает метод в представлении дополнен^- Этот метод реализован дополнением, которое и выполняет реальную работу. Более развитые адаптеры Если нет особых потребностей в поддержке версий или хостинге, то адаптеры будут совершенно прямолинейны. Они просто передают работу по конвейеру. Однако адаптеры также являются важной точкой расширяемости для более изощренных сценариев. Примером может служить поддержка версий. Очевидно, что можно независимо обновлять приложение или его дополнения, не меняя способа их взаимодействия — до тех пор, пока используются одни и те же интерфейсы в контракте. Однако в некоторых случаях может понадобиться изменить интерфейсы, чтобы ввести новые средства. Это представляет некоторую проблему, потому что старые интерфейсы должны поддерживаться с целью обратной совместимости со старыми дополнениями. После нескольких ревизий получится сложная смесь похожих, но разных интерфейсов, и приложение должно будет распознавать и поддерживать их все. Благодаря модели дополнений, можно применить другой подход к обратной совместимости. Вместо ввода множества интерфейсов можно использовать единственный интерфейс в контракте и применять адаптеры для создания различных представлений. Например, дополнение версии 1 может работать с приложением версии 2 (которое представляет контракт версии 2) до тех пор, пока имеется адаптер дополнения, заполняющий пробел. Аналогично, если разрабатывается дополнение, которое использует контракт версии 2, его можно применять с исходной версией 1 приложения (и версией 1 контракта) за счет использования различных адаптеров дополнений. Аналогичный подход можно применить, когда есть специальные потребности хостинга. Например, можно использовать адаптеры для загрузки дополнений с разными уровнями изоляции, или даже разделять их между приложениями. Принимающее приложение и дополнение не обязаны знать об этих деталях, потому что их урегулируют адаптеры.
980 Глава 32. Модель дополнений Даже если не нужно создавать специальные адаптеры для реализации специализированных стратегий поддержки версий и хостинга, все равно эти компоненты должны быть включены. Однако все дополнения могут использовать одинаковые представления и адаптерные компоненты. Другими словами, решив проблему настройки полноценного конвейера для одного дополнения, затем можно добавлять новые дополнения, не прикладывая дополнительных усилий (рис. 32.3). Принимающее приложение -наследует- Класс адаптера стороны хоста i i i i i i «-!-► Класс адаптера стороны дополнения ri Класс дополнения Класс дополнения Абстрактный класс представления хоста реализует -, t - реализует ' i i „ ТУ . Интерфейс контракта Класс | дополнения ^Цедует Абстрактный класс представления дополнения Рис. 32.3. Множество дополнений, использующих один и тот же конвейер В последующих разделах будет показано, как реализовать конвейер дополнения для приложения WPF. Структура каталогов дополнений При использовании конвейера дополнения необходимо придерживаться строгой структуры каталогов. Это отдельная от приложения структура каталогов. Другими словами, вполне допустимо располагать приложение в одном месте, а все дополнения и компоненты конвейера — в другом. Однако компоненты дополнений должны быть организованы в специально именованных подкаталогах относительно друг друга. Например, если система дополнений использует корневой каталог c:\MyApp, то понадобятся следующие подкаталоги: c:\MyApp\AddInSideAdapters с :\MyApp\AddInViews c:\MyApp\Contracts c:\MyApp\HostSideAdapters c:\MyApp\AddIns Подкаталог Add Ins (последний в списке) должен иметь отдельный подкаталог для каждого дополнения, используемого приложением, например, c:\MyApp\AddIns\ MyFirstAddln, c:\MyApp\AddIns\MySecondAddIn и т.д. В этом примере предполагается, что исполняемый модуль приложения развернут в каталоге c:\MyApp. Другими словами, один и тот же каталог выполняет двойную функцию — как папка приложения, и как корень дополнений. Это распространенный вариант развертывания, хотя и не обязательный.
Глава 32. Модель дополнений 981 На заметку! Если вы внимательно рассмотрели диаграммы конвейеров, то должны были заметить, что существует подкаталог для каждого компонента кроме представлений стороны хоста. Это объясняется тем, что представления хоста используются напрямую принимающим приложением-хостом, поэтому они развертываются вместе с исполняемым приложением. (В данном примере это означает, что они находятся в c:\MyApp.) Представления дополнений так не развертываются, поскольку существует вероятность того, что несколько дополнений будут использовать одно и то же представление дополнений. Благодаря выделенной папке AddlnViews, понадобится развернуть (и обновить) только одну копию каждой сборки представления дополнения. Подготовка решения, использующего модель дополнений Структура папок дополнения обязательна. Если пропустить один из подкаталогов, перечисленных в предыдущем разделе, то при поиске дополнений возникнет исключение времени выполнения. В настоящее время Visual Studio не имеет шаблона для создания приложений, использующих дополнения. Поэтому на вас возлагается обязанность создать эти папки и настроить проект Visual Studio для их использования. Ниже описан простейший подход. 1. Создайте каталог верхнего уровня, который будет содержать все создаваемые в дальнейшем проекты. Его можно назвать, к примеру, c:\AddInTest. 2. Создайте в этом каталоге новый проект WPF для принимающего приложения- хоста. Неважно, как этот проект будет назван, однако он должен быть помещен в каталог верхнего уровня, который был создан на первом шаге (например, c:\AddInTest\HostApplication). 3. Добавьте новый проект библиотеки классов для каждого компонента конвейера и поместите их все в одно и то же решение. Как минимум, понадобится создать проект для одного дополнения (например, c:\AddInTest\MyAddIn), одного представления дополнения (c:\AddInTest\MyAddInView), одного адаптера стороны дополнения (c:\AddInTest\MyAddInAdapter), одного представления хоста (c:\AddInTest\HostView) и одного адаптера стороны хоста (c:\AddInTest\ HostAdapter). На рис. 32.4 показан пример из загружаемого кода для этой главы, который рассматривается в последующих разделах. Он включает приложение (под названием HostApplication) и два дополнения (с именами FadelmageAddln и NegativelmageAddln). На заметку! Формально не имеет значения, как будут названы проекты и каталоги при создании компонентов конвейера. Необходимая структура каталогов, которая была показана в предыдущем разделе, будет создана при построении приложения (если правильно установлены настройки проекта, как описано в следующих двух шагах). Однако чтобы упростить процесс конфигурирования, настоятельно рекомендуется создавать все каталоги проекта в каталоге верхнего уровня, установленном на первом шаге. 4. Теперь необходимо создать каталог сборки внутри каталога верхнего уровня. Именно здесь будут размещены все компоненты приложения и конвейера после компиляции. Этот каталог принято называть Output (например, c:\AddInTest\ Output). 5. По мере проектирования различных компонентов конвейера путь сборки каждого из них будет модифицироваться, чтобы компонент помещался в правильном подкаталоге. Например, адаптер дополнения должен быть скомпилирован в каталоге
982 Глава 32. Модель дополнений вроде c:\AddInTest\Output\AddInSideAdapters. Чтобы модифицировать путь сборки, дважды щелкните на узле Properties (Свойства) в окне Solution Explorer. Затем перейдите на вкладку Build (Сборка). В разделе Output (Выход) найдите текстовое поле по имени Output Path (Выходной путь). Необходимо использовать относительный выходной путь, находящийся на один уровень выше дерева каталогов и затем использующий каталог Output. Например, выходным путем для адаптера дополнения должен быть . .\Output\AddInSideAdapters. По мере построения каждого компонента в следующих разделах будет указано, какой путь сборки нужно использовать. На рис. 32.5 показано, как выглядит финальный результат, на основе решения, представленного на рис. 32.4. Solution Explorer - HostApplication в ^33 Solution SimpleAddln (8 projects) В до AddlnSideAdapter .±, £BA<*<flnView S s53 Contract Ф 53 FadelmageAddln t J3 HostSideAdapter i£ 51 HostView B- £0 NegativelmageAddln i= Рис. 32.4. Решение, использующее конвейер дополнения л SimpleAddln ; AddlnSideAdapter J> AddlnView > Jt Contracts , FadelmageAddln , HostApplication \ HostSideAdapter ^ HostView j.. NegativelmageAddln s Output ъ ± Addlns 4 AddlnSideAdapters . AddlnViews i» Contracts >, HostSideAdapters Рис. 32.5. Структура папок для решения, использующего конвейер дополнения Есть еще одно обстоятельство, которое должно быть учтено при разработке модели дополнений в Visual Studio. Имеются в виду ссылки. Некоторые компоненты конвейера нуждаются в ссылках на другие компоненты конвейера. Однако вы не хотите копировать ссылаемые сборки туда, где находятся сборки, содержащие ссылки на них. Вместо этого вы полагаетесь на систему каталогов модели дополнений. Чтобы предотвратить копирование ссылаемых сборок, необходимо выделить сборку в окне Solution Explorer (под узлом References (Ссылки)). Затем в окне Properties потребуется установить настройку Copy Local (Копировать локально) в False. При построении каждого компонента в следующих разделах будет указано, какие ссылки должны быть добавлены. Совет. Корректная конфигурация проекта дополнений требует некоторых усилий. Для начала можно воспользоваться примером, который обсуждается в этой главе и доступен в составе загружаемого кода. Приложение, использующее дополнения В последующих разделах будет создано приложение, использующее модель дополнений для поддержки различных способов обработки изображения (рис. 32.6). Сразу после запуска приложение перечисляет все дополнения, существующие в данный момент. Пользователь сможет выбрать одно из них из списка и применить для модификации текущего изображения.
■ ApplicationHost Глава 32. Модель дополнений 983 Fade Image Processor Supralmage Darkens the picture Negative Image Processor Imaginomics inverts colors to took like a photo negative Go Рис. 32.6. Приложение, использующее дополнения для манипулирования изображением Контракт создание Начальная точка определения конвейера дополнения для приложения сборки контракта. Сборка контракта определяет две вещи. • Интерфейсы, определяющие, как хост взаимодействует с дополнением, и как дополнение взаимодействует с хостом. • Специальные типы, которые применяются для обмена информацией между хостом и дополнением. Эти типы должны быть сериализуемыми. В примере, показанном на рис. 32.6, используется чрезвычайно простой контракт. Дополнение предоставляет метод по имени ProcessImageBytesO, который принимает байтовый массив с данными изображения, модифицирует его и возвращает модифицированный байтовый массив. Вот контракт, определяющий этот метод: [AddlnContract] public interface IlmageProcessorContract : IContract { } byte[] ProcessImageBytes(byte[] pixels); Создаваемый класс контракта должен быть унаследован от интерфейса IContract и также оснащен атрибутом AddlnContract. Как интерфейс, так и атрибут находятся в пространстве имен System. Addln.Cent -act. Чтобы иметь доступ к ним в сборке контракта, потребуется добавить ссылку на сборку System.AddIn.Contract.dll. Поскольку в примере с обработкой изображения не используется никаких специальных типов для передачи данных (а только обычные байтовые массивы), в сборке контракта типы не определяются. Байтовые массивы могут быть переданы между приложением-хостом и дополнением, потому что массивы и байты являются сериализуемыми. Единственный дополнительный шаг, который нужно предпринять — это конфигурирование каталога сборки. Сборка контракта должна быть помещена в подкаталог Contracts корня дополнений, а это означает, что в качестве выходного пути в текущем примере можно использовать . .\Output\Contracts.
984 Глава 32. Модель дополнений На заметку! В этом примере интерфейсы сохранены предельно простыми, чтобы более полно отразить суть. В реальных сценариях обработки изображений может быть предусмотрен метод, возвращающий список конфигурируемых параметров, которые влияют на то, как дополнение обрабатывает изображение. Каждое дополнение должно иметь собственные параметры. Например, фильтр, затемняющий изображение, может иметь настройку интенсивности, фильтр, выполняющий наклон изображения — настройку угла наклона и т.д. Принимающее приложение может затем применять эти параметры, вызывая метод ProcessImageBytes(). Представление дополнения Представление дополнения (add-in view) предоставляет абстрактный класс, отражающий сборку контракта, и используется на стороне дополнения. Создать этот класс просто: [AddlnBase] public abstract class ImageProcessorAddlnView { public abstract byte[] ProcessImageBytes(byte[] pixels); } Обратите внимание, что класс представления дополнения должен быть оснащен атрибутом AddlnBase. Этот атрибут находится в пространстве имен System.Addln.Pipeline. Сборка представления дополнения требует ссылки на сборку System.AddIn.dll, чтобы иметь к ней доступ. Сборка представления дополнения должна быть помещена в подкаталог AddlnViews корня дополнения, а это значит, что в данном примере можно использовать выходной путь . .\Output\AddInViews. Дополнение Представление дополнения — это абстрактный класс, который не реализует никакой функциональности. Чтобы создать полезное дополнение, необходим конкретный класс — наследник абстрактного класса представления. В этот класс можно затем добавлять код, который выполняет реальную работу (в рассматриваемом случае — обработка изображения). Следующее дополнение инвертирует значения цвета, чтобы создать эффект, подобный негативу. Ниже показан его полный код: [Addln("Negative Image Processor", Version = .0.0.0", Publisher = "Imaginomics", Description = "Inverts colors to look like a photo negative")] public class NegativelmageProcessor : AddlnView.ImageProcessorAddlnView { public override byte[] ProcessImageBytes (byte [ ] pixels) { for (int 1 = 0; l < pixels .Length - 2; iJ- + ) { // Предполагается 24-битный цвет — каждый пиксель описан // тремя байтами данных, pixels [i] = (byte) B55 - pixels [i]) ; pixels[i + 1] = (byte) B55 - pixels[i + 1]); pixels[i + 2] = (byte) B55 - pixels [i + 2]); } return pixels; } }
Глава 32. Модель дополнений 985 На заметку! В этом примере байтовый массив передается методу ProcessImageBytes () через параметр, модифицированный непосредственно, и затем передается обратно вызывающему коду в качестве возвращаемого значения. Однако когда метод ProcessImageBytes() вызывается из другого домена приложения, это поведение не так просто, как может показаться. Инфраструктура дополнений в действительности делает копию исходного байтового массива и передает эту копию в домен приложения дополнения. Как только байтовый массив модифицирован и возвращен из метода, инфраструктура дополнений копирует его обратно в домен приложения хоста. Если бы ProcessImageBytes () не возвращал модифицированного подобным образом массива байтов, то хост никогда бы не увидел измененных данных изображения. Чтобы создать дополнение, нужно просто унаследовать класс от абстрактного класса представления и оснастить его атрибутом Addln. Вдобавок можно использовать свойства атрибута Addln для применения имени, версии, издателя дополнения и его описания, как сделано здесь. Эта информация становится доступной хосту при обнаружении дополнений. Сборка дополнения требует двух ссылок: одну на сборку System.AddIn.dll и еще одну — на проект представления дополнения. Однако свойство Copy Local ссылки на представление дополнения должно быть установлено в False (как было описано ранее в разделе "Подготовка решения, использующего модель дополнений"). Причина в том, что представление дополнения не развертывается с самим дополнением. Вместо этого оно помещается в выделенный подкаталог AddlnViews. Дополнение должно быть помещено в собственный подкаталог внутри подкаталога Addlns корня дополнения. В рассматриваемом примере может применяться выходной путь вроде . .\Output\AddIns\NegativeImageAddIn. Адаптер дополнения Текущий пример обладает всей необходимой функциональностью дополнения, но все-таки еще остается пробел между дополнением и контрактом. Хотя представление дополнения моделируется по контракту, оно не реализует интерфейс контракта, используемого для взаимодействия между приложением и дополнением. Недостающий ингредиент — адаптер дополнения. Он реализует интерфейс контракта. Когда вызывается метод на интерфейсе контракта, он вызывает соответствующий метод в представлении дополнения. Ниже приведен код наиболее простого адаптера дополнения, который можно создать. [AddInAdapter] public class ImageProcessorViewToContractAdapter : ContractBase, Contract.IlmageProcessorContract { private AddlnView.ImageProcessorAddlnView view; public ImageProcessorViewToContractAdapter ( AddlnView.ImageProcessorAddlnView view) { this, view = view; } public byte[] ProcessImageBytes(byte[] pixels) { return view.ProcessImageBytes(pixels); } } Все адаптеры дополнений должны наследоваться от класса ContractBase (из пространства имен System.Addln.Pipeline). Класс ContractBase унаследован от
986 Глава 32. Модель дополнений Mar s h al By Re f Object, который позволяет адаптеру вызываться через границы домена приложения. Все адаптеры дополнения также должны быть оснащены атрибутом Add In Adapter (из пространства имен System.Addln. Pipeline). Более того, адаптер дополнения должен включать конструктор, принимающий в качестве аргумента экземпляр соответствующего представления. Когда инфраструктура дополнения создает адаптер дополнения, она автоматически использует этот конструктор и передает ему само дополнение. (Вспомните, что дополнение наследуется от абстрактного класса представления дополнения, ожидаемого конструктором.) Код просто должен сохранить это представление для последующего использования. Адаптер дополнения требует трех ссылок: одну на System.AddIn.dll, одну на System.Addln.Concract.dll и одну на проект контракта. Свойство Copy Local ссылки контракта должно быть установлено в False (как описано в разделе "Подготовка решения, использующего модель дополнений"). Сборка адаптера дополнения должна быть помещена в подкаталог AddlnSideAdapters корня дополнения, а это означает, что в рассматриваемом примере можно использовать выходной путь . .\Output\AddInSideAdapters. Представление хоста Следующий шаг — построение стороны хоста конвейера дополнения. Хост взаимодействует с представлением хоста. Подобно представлению дополнения, представление хоста — это абстрактный класс, который точно отражает интерфейс контракта. Единственное отличие в том, что он не требует никаких атрибутов. public abstract class ImageProcessorHostView { public abstract byte[] ProcessImageBytes (byte [ ] pixels); } Сборка хоста представления должна быть развернута вместе с приложением-хостом. Подправить выходной путь можно вручную (например, чтобы в текущем примере сборка представления хоста была размещена в папке . ДОutput). Или же, при добавлении ссылки на представление хоста в приложение-хост можно оставить свойство Copy Local равным True. Таким образом, представление хоста будет скопировано автоматически в тот же выходной каталог, что и приложение-хост Адаптер хоста Адаптер стороны хоста наследуется от представления хоста. Он принимает объект, реализующий контракт, который может затем использовать при вызове своих методов. Этот тот же процесс пересылки, который использует и адаптер дополнения, но в обратном направлении. В данном примере, когда приложение-хост вызывает метод ProcessImageBytesO представления хоста, оно в действительности вызывает ProcessImageBytes () в адаптере хоста. Адаптер хоста вызывает ProcessImageBytes () на интерфейсе контракта (и этот вызов переправляется через границы приложения, трансформируясь в вызов метода на адаптере дополнения). Ниже приведен полный код адаптера хоста. [HostAdapter] public class ImageProcessorContractToViewHostAdapter : HostView.ImageProcessorHostView { private Contract.IlmageProcessorContract contract; private ContractHandle contractHandle;
Глава 32. Модель дополнений 987 public ImageProcessorContractToViewHostAdapter ( Contract.IlmageProcessorContract contract) { this.contract = contract; contractHandle = new ContractHandle(contract); } public override byte[] ProcessImageBytes(byte[] pixels) { return contract.ProcessImageBytes(pixels); } } Обратите внимание, что адаптер хоста на самом деле использует два поля-члена. Он сохраняет ссылку на текущий объект контракта и также сохраняет ссылку на объект System.Addlns.Pipeline.ContractHandle. Объект ContractHandle управляет жизненным циклом дополнения. Если адаптер хоста не создает объект ContractHandle (и сохраняет ссылку на него), то дополнение будет освобождено немедленно по завершении кода конструктора. Когда приложение-хост попытается использовать дополнение, то получит исключение AppDomainUnloadedException. Проект адаптера хоста нуждается в ссылках на System.Add.dll и System.Addln. Contract.dll. Ему также необходимы ссылки на сборку контракта и сборку представления хоста (у обеих настройка Copy Local должна быть установлена в False). Выходной путь — подкаталог HostSideAdapters в корне дополнения (в рассматриваемом примере это ..\Output\HostSideAdapters). Хост Теперь, когда вся инфраструктура готова, последний шаг заключается в создании приложения, которое использует модель дополнений. Хотя хостом может служить исполняемое приложение .NET любого типа, в данном примере применяется приложение WPF. Хост нуждается только в одной ссылке, которая указывает на проект представления хоста. Представление хоста — это точка входа в конвейер дополнения. Фактически теперь, когда завершена нелегкая работа по реализации конвейера, хост может не беспокоиться о том, как он управляется. Ему нужно только найти доступные дополнения, активизировать те из них, которые он желает использовать, и затем вызывать методы представления хоста. Первый шаг — нахождение доступных дополнений — называется обнаружением (discovery). Оно осуществляется через статические методы класса System.Addln. Hosting.AddlnStore. Чтобы загрузить дополнения, просто указывается путь к корню дополнений и вызывается метод AddlnStore.Update(), как показано ниже: // В этом примере путь, из которого запускается // приложение, также является корнем дополнений, string path = Environment.CurrentDirectory; AddlnStore.Update(path); После вызова Update () система дополнений создаст два файла с кэшированной информацией. Файл по имени PipelineSegments.store будет помещен в корневой каталог дополнений. Этот файл включает информацию о различных представлениях и адаптерах. Файл по имени Addlns.store будет помещен в подкаталог Addlns, и он содержит информацию о доступных дополнениях. При добавлении новых представлений, адаптеров или дополнений эти файлы можно обновлять повторным вызовом AddlnStore. Update(). (Этот метод быстро возвратит управление, если никаких новых дополнений или компонентов конвейера не обнаружит.) Если есть причины ожидать проблем с существующими файлами дополнений, можно вместо этого вызывать метод AddlnStore. Rebuild(), который воссоздаст файлы дополнений заново.
988 Глава 32. Модель дополнений Как только файлы кэша созданы, можно выполнять поиск определенных дополнений. С помощью метода FindAddlnO осуществляется поиск одного определенного дополнения, а посредством метода FindAddlnsO — всех дополнений, которые соответствуют указанному представлению хоста. Метод FindAddlnsO возвращает коллекцию маркеров (tokens), каждый из которых представляет собой экземпляр класса System.Addln. Hosting. AddlnToken. IList<AddInToken> tokens = AddlnStore.FindAddlns ( typeof(HostView.ImageProcessorHostView), path); IstAddlns.ItemsSource = tokens; Для извлечения информации о дополнении предназначено несколько ключевых свойств (Name, Description, Publisher и Version). В приложении, обрабатывающем изображения (см. рис. 32.6), список маркеров привязан к элементу управления ListBox, и для каждого дополнения отображается некоторая базовая информация согласно следующему шаблону данных: <ListBox Name="lstAddIns" Margin="> <ListBox.ItemTemplate> <DataTemplate> <StackPanel Margin=, 3, 0, 8" HorizontalAlignment="StretchII> <TextBlock Text="{ Binding Path=Name}M FontWeight="Bold11 /> <TextBlock Text="{Binding Path=Publisher}" /> <TextBlock Text="{Binding Path=Description}" FontSize=,,10" FontStyle=,,Italic" /> </StackPanel> </DataTemplate> </ListBox.ItemTemplate> </ListBox> Экземпляр дополнения создается вызовом метода AddlnToken.Activate<T>. В текущем приложении пользователь щелкает на кнопке Go (Запуск) для активизации дополнения. Затем извлекается информация текущего изображения (показанного в окне), которая затем передается методу ProcessImageBytesO представления хоста. Вот как это работает: private void cmdProcessImage_Click(object sender, RoutedEventArgs e) { // Скопировать информацию изображения в байтовый массив. BitmapSource source = (BitmapSource)img.Source; int stride = source.PixelWidth * source.Format.BitsPerPixel/8; stride = stride + (stride % 4) * 4; int arraySize = stride * source.PixelHeight * source.Format.BitsPerPixel / 8; byte[] originalPixels = new byte[arraySize]; source .CopyPixels (onginalPixels, stride, 0) ; // Получить выбранный маркер дополнения. AddlnToken token = (AddlnToken)IstAddlns.Selectedltem; // Получить представление хоста. HostView.ImageProcessorHostView addin = token.Activate<HostView.ImageProcessorHostView>( AddlnSecurityLevel.Internet); // Использовать дополнение. byte[] changedPixels = addin.ProcessImageBytes(originalPixels); // Создать новый BitmapSource с данными измененного изображения и отобразить его. BitmapSource newSource = BitmapSource.Create (source.PixelWidth, source.PixelHeight, source.DpiX, source.DpiY, source.Format, source.Palette, changedPixels, stride); img.Source = newSource; }
Глава 32. Модель дополнений 989 При вызове метода AddInToken.Activate<T> "за кулисами" происходит несколько действий. 1. Создается новый домен приложения для дополнения. В качестве альтернативы можно загрузить дополнение в домен приложения-хоста или в совершенно отдельный процесс. Однако по умолчанию оно размещается в отдельном домене приложения в пределах текущего процесса, что обычно обеспечивает оптимальный компромисс между стабильностью и производительностью. Можно также выбрать уровень привилегий, предоставляемых новому домену приложения. (В данном примере они ограничены набором привилегий Интернет — существенно ограниченным набором прав, которые выдаются коду, запускаемому из Интернета.) 2. Сборка дополнения загружается в новый домен приложения. Затем создается экземпляр дополнения посредством рефлексии, используя конструктор без аргументов. Как уже можно было видеть, дополнение наследуется от абстрактного класса в сборке представления дополнения. В результате загрузка дополнения также загружает в новый домен приложения сборку представления дополнения. 3. Создается экземпляр адаптера дополнения в новом домене приложения. Дополнение передается адаптеру дополнения в качестве аргумента конструктора. (Дополнение указывается как представление дополнения.) 4. Адаптер дополнения делается доступным домену приложения-хоста (через удаленный прокси). Однако он указывается как реализованный им контракт. 5. В домене приложения-хоста создается экземпляр адаптера хоста. Адаптер дополнения передается адаптеру хоста через его конструктор. 6. Адаптер хоста возвращается приложению-хосту (указанному через представление хоста). Приложение теперь может вызывать методы представления хоста, чтобы взаимодействовать с дополнением через конвейер. Существуют и другие перегрузки метода Activate<T>, которые позволяют применять специальный набор привилегий (для тонкой настройки безопасности), определенный домен приложения (что удобно, если нужно запускать несколько дополнений в пределах одного домена приложения) и внешний процесс (что позволяет размещать дополнение в совершенно отдельной ЕХЕ-сборке приложения для обеспечения еще более высокой степени изоляции). Все эти примеры проиллюстрированы в справочной системе Visual Studio. На этом рассмотрение примера завершено. Приложение-хост теперь может обнаруживать доступные дополнения, активизировать их и взаимодействовать с ними через представление хоста. Жизненный цикл дополнения Вручную управлять жизненным циклом дополнения не понадобится. Система дополнений автоматически освобождает дополнение и завершает его домен приложения. В предыдущем примере дополнение освобождается, когда переменная, ссылающаяся на представление хоста, покидает область видимости. Чтобы сохранить дополнение активным в течение длительного времени, его можно присвоить переменной-члену класса окна. В некоторых ситуациях может понадобиться более высокая степень управляемости жизненным циклом дополнения. Модель дополнений предоставляет приложению-хосту возможность останавливать дополнение автоматически, используя класс AddlnController (из пространства имен System. Addln.Hosting), который отслеживает активные в настоящий момент дополнения. В AddlnController определен статический метод по имени GetAddlnCintrioller (),
990 Глава 32. Модель дополнений который принимает представление хоста и возвращает AddlnController для дополнения. Затем с помощью метода AddlnController.ShutDown() можно завершить его работу, как показано ниже: AddlnController controller = AddlnController.GetAddlnController(addin); controller.Shutdown(); В этой точке все адаптеры уничтожаются, дополнение освобождается и домен приложения этого дополнения завершается, если только он не содержит других дополнений. Добавление новых дополнений Используя одно и то же представление дополнения, можно создавать неограниченное количество отдельных дополнений. В этом примере их два, и они обрабатывают изображения двумя разными способами. Второе дополнение использует грубый алгоритм затемнения изображения посредством удаления части цвета из случайных пикселей. [Addin("Fade Image Processor", Version = .0.0.0", Publisher = "Supralmage", Description = "Darkens the picture")] public class FadelmageProcessor : AddlnView.ImageProcessorAddlnView { public override byte[] ProcessImageBytes(byte[] pixels) { Random rand = new Random () ; int offset = rand.Next @, 10); for (int i = 0; l < pixels. Length - 1 - offset; i++) { if ( (l + offset) % 5 == 0) { pixels [i] = 0; } } return pixels; } } В данном примере это дополнение компилируется в выходной путь . .\Output\ AddlnsXFadelmageAddln. Здесь нет необходимости создавать дополнительные представления или адаптеры. После развертывания этого дополнения (и последующего вызова метода Rebuild() или Update() класса AddlnStore) приложение-хост найдет оба дополнения. Взаимодействие с хостом В текущем примере хост полностью управляет дополнением. Однако отношения часто меняются. Распространенный пример касается дополнения, которое управляет некоторой областью функциональности приложения. Это особенно присуще визуальным дополнениям (тема следующего раздела), таким как специальные панели инструментов. Часто этот процесс, когда дополнению разрешается вызывать хост, называется автоматизацией. С концептуальной точки зрения автоматизация достаточно проста. Дополнению просто нужна ссылка на объект в домене приложения-хоста, которым оно может манипулировать через отдельный интерфейс. Однако упор системы дополнений на гибкость поддержки версий несколько усложняет реализацию этого приема. Единственного интерфейса хоста недостаточно, потому что он тесно связывает вместе хост и дополнение. Вместо этого нужно реализовать конвейер с представлениями и адаптерами.
Глава 32. Модель дополнений 991 Чтобы увидеть, в чем состоит вся сложность, рассмотрим слегка измененную версию приложения обработки изображений, которая показана на рис. 32.7. Она оснащена индикатором продвижения в нижней части окна, обновляемым по мере обработки данных дополнением. Рис. 32.7. Дополнение, сообщающее о продвижении Совет. Оставшийся материал этого раздела посвящен изменениям, которые должны быть внесены в приложение обработки изображений для поддержки автоматизации хоста. Чтобы увидеть, как эти части сочетаются вместе, и посмотреть на полный код, воспользуйтесь загружаемым кодом для этой главы. Чтобы это приложение работало, дополнению нужен какой-то способ передачи информации о продвижении приложению-хосту во время его работы. Первый шаг к реализации такого решения заключается в создании интерфейса, определяющего, как дополнение может взаимодействовать с хостом. Этот интерфейс должен быть помещен в сборку контракта (или отдельную сборку в папке Contracts). Ниже приведен интерфейс, описывающий, как дополнение должно сообщать о продвижении, посредством вызова метода по имени ReportProgress () в приложении- хосте: public interface IHostObjectContract : IContract { void ReportProgress(int progressPercent); } Как и интерфейс дополнения, интерфейс хоста должен наследоваться от IContract. В отличие от интерфейса дополнения интерфейс хоста не использует атрибут AddlnContract, поскольку не реализуется дополнением. Следующий шаг — это создание представления дополнения и представления хоста. Как и при проектировании дополнения, нужен просто абстрактный класс, который близко соответствует используемому интерфейсу. Чтобы использовать интерфейс IHostObjectContract, показанный ранее, понадобится просто добавить следующее
992 Глава 32. Модель дополнений определение класса — как в проект представления дополнения, так и в проект представления хоста: public abstract class HostObject { public abstract void ReportProgress (int progressPercent); } Обратите внимание, что определение класса не использует атрибут AddlnBase ни в одном из проектов. Действительная реализация метода ReportProgress () находится в приложении-хосте. Ему нужен класс, унаследованный от класса HostObject (в сборке представления хоста). Ниже приведен несколько упрощенный пример, использующий процент для обновления элемента управления ProgressBar. public class AutomationHost : HostView.HostObject { private ProgressBar progressBar; public Host(ProgressBar progressBar) { this.progressBar = progressBar; } public override void ReportProgress(int progressPercent) { progressBar.Value = progressPercent; } } Теперь есть механизм, который может использовать дополнение для передачи информации о продвижении приложению-хосту. Однако остается одна проблема — дополнение не имеет никакой возможности получить ссылку на HostObject. Эта проблема не возникает, когда приложение-хост использует дополнение, потому что у него есть средство обнаружения, с помощью которого можно искать дополнения. Однако не существует удобной службы, позволяющей дополнениям находить свой хост. Решение заключается в том, чтобы приложение-хост передало ссылку HostObject дополнению. Обычно этот шаг будет выполнен при первоначальной активизации дополнения. По соглашению метод, используемый приложением-хостом для передачи этой ссылки, часто называется InitializeO. Вот как выглядит обновленный контракт для дополнений приложения обработки изображений: [AddlnContract] public interface IlmageProcessorContract : IContract { byte[] ProcessImageBytes(byte [ ] pixels); void Initialize(IHostObjectContract hostObj); } При вызове InitializeO дополнение просто сохраняет ссылку для последующего использования. Затем оно может вызывать метод ReportProgress (), когда это понадобится, как показано ниже: [Addln] public class NegativelmageProcessor : AddlnView.ImageProcessorAddlnView { private AddlnView.HostObject host; public override void Initialize(AddlnView.HostObject hostObj) { host = hostObj ; }
Глава 32. Модель дополнений 993 public override byte[] ProcessImageBytes (byte [ ] pixels) { int iteration = pixels.Length / 100; for (int 1 = 0; l < pixels .Length - 2; i++) { - pixels[1] = (byte) B55 - pixels [1] ) ; pixels [1 + 1] = (byte) B55 - pixels[i + 1]); pixels[i + 2] = (byte) B55 - pixels[i + 2]); if (i % iteration = 0) host.ReportProgress(i / iteration); return pixels; } } До сих пор код не представлял никаких серьезных трудностей. Однако последний фрагмент — адаптеры — несколько сложнее всего, что было ранее. Теперь, когда к контракту дополнения добавлен метод Initialize (), также нужно добавить его в представления хоста и дополнения. Однако сигнатура метода не может соответствовать интерфейсу контракта. Дело в том, что метод Initialize () в интерфейсе ожидает в качестве аргумента IHostObjectContract. Представления, которые никак не связаны с контрактом, не имеют никакого понятия о IHostObjectContract. Вместо этого они используют описанный ранее абстрактный класс HostObject: public abstract class ImageProcessorHostView { public abstract byte[] ProcessImageBytes(byte[] pixels); public abstract void Initialize(HostObject host); Адаптеры представляют собой сложную часть системы. Они призваны заполнить пробел между абстрактным представлением HostObject и интерфейсом IHostObjectContract. Например, рассмотрим ImageProcessorContractToViewHostAdapter на стороне хоста. Он унаследован от абстрактного класса ImageProcessorHostView, в результате чего реализует версию Initialize(), принятую от экземпляра HostObject. Этот метод InitializeO должен преобразовать представление в контракт, после чего вызвать метод IHostObjectContract.Initialize(). Сложность заключается в создании адаптера, который реализует эту трансформацию (подобно адаптеру, который выполняет ту же трансформацию с представлением дополнения и интерфейсом дополнения). Ниже показан новый HostObjectViewToContractHostAdapter, который выполняет работу, и метод InitializeO, использующий его для перехода от класса представления к интерфейсу контракта. public class HostObjectViewToContractHostAdapter : ContractBase, Contract.IHostObjectContract { private HostView.HostObject view; public HostObjectViewToContractHostAdapter(HostView.HostObject view) { this.view = view; } public void ReportProgress(int progressPercent) { view.ReportProgress(progressPercent); } }
994 Глава 32. Модель дополнений [HostAdapter] public class ImageProcessorContractToViewHostAdapter : HostView.ImageProcessorHostView { private Contract.IlmageProcessorContract contract; private ContractHandle contractHandle; public override void Initialize(HostView.HostObject host) { HostObjectViewToContractHostAdapter hostAdapter = new HostObjectViewToContractHostAdapter(host); contract.Initialize(hostAdapter); } } Аналогичная трансформация имеет место в адаптере дополнения, но в обратном направлении. Здесь ImageProcessorViewToContractAdapter реализует интерфейс IlmageProcessorContract. Ему нужно взять объект IHostObjectContract, который он получает в своей версии метода InitializeO, и затем преобразовать контракт в представление. Затем он может передавать вызов наряду с вызовом метода InitializeO в представлении. Ниже показан код: [AddlnAdapter] public class ImageProcessorViewToContractAdapter : ContractBase, Contract.IlmageProcessorContract { private AddlnView.ImageProcessorAddlnView view; public void Initialize(Contract.IHostObjectContract hostObj) { view.Initialize(new HostObjectContractToViewAddlnAdapter(hostObj)); } } public class HostObjectContractToViewAddlnAdapter : AddlnView.HostObject { private Contract.IHostObjectContract contract; private ContractHandle handle; public HostObjectContractToViewAddlnAdapter( Contract.IHostObjectContract contract) { this.contract = contract; this.handle = new ContractHandle(contract); } public override void ReportProgress(int progressPercent) { contract.ReportProgress(progressPercent) ; } } Теперь, когда хост вызывает InitializeO на дополнении, он может пройти через адаптер хоста (ImageProcessorContractToViewHostAdapter) и адаптер дополнения (ImageProcessorViewToContractAdapter), прежде чем быть вызванным на самом дополнении. Когда дополнение вызывает метод ReportProgress (), он проходит те же этапы, но в обратном порядке. Сначала он проходит через адаптер дополнения (HostObjectContractToViewAddlnAdapter), а затем переходит к адаптеру хоста (HostObjectViewToContractHost Adapter).
Глава 32. Модель дополнений 995 Этот анализ завершает пример — отчасти. Проблема в том, что приложение-хост вызывает метол ProcessImageBytesO в главном потоке пользовательского интерфейса. В результате пользовательский интерфейс блокируется. Хотя вызовы ReportProgress () обрабатываются, а полоса продвижения обновляется, все же окно не обновляется до самого завершения процесса. Намного лучший подход предусматривает выполнение затратного по времени вызова ProcessImageBytesO в фоновом потоке — либо за счет создания объекта Thread вручную, либо с применением BackgroundWorker. Затем, когда пользовательский интерфейс должен быть обновлен (при вызове ReportProgress () и возврате окончательного изображения), необходимо воспользоваться методом Dispatcher.BeginlnvokeO для обратной маршализации вызова в поток пользовательского интерфейса. Все эти приемы уже демонстрировались в настоящей главе. Чтобы увидеть работу с потоками в этом примере, обратитесь к загружаемому коду для этой главы. Визуальные дополнения Учитывая тот факт, что WPF является визуальной технологией, наверняка интересует вопрос — как заставить дополнения генерировать пользовательский интерфейс? Это не такая простая задача. Проблема в том, что элементы пользовательского интерфейса в WPF не сериализуемы. Поэтому они не могут передаваться между приложением-хостом и дополнением. К счастью, проектировщики системы дополнений предусмотрели изощренный обходной путь. Решение заключается в том, чтобы позволить приложениям WPF отображать содержимое пользовательского интерфейса, размещенного в разных доменах приложений. Другими словами, приложение-хост может отображать элементы управления, которые на самом деле работают в домене дополнения. При взаимодействии с этими элементами управления (щелчки, ввод текста и т.п.) инициируются события в домене дополнения. Для передачи информации из дополнения в приложение и обратно используются интерфейсы контрактов, как было показано в предыдущих разделах. На рис. 32.8 показана эта техника в действии в модифицированной версии приложения обработки изображений. Когда выбрано дополнение, приложение-хост запрашивает у дополнения элемент управления с соответствующим содержимым. Этот элемент управления затем отображается в нижней части окна. ■ AppljcabonHost . Imaginomics I ft; гг."'' гс Ml Ш I - £ncc ""-утт -f Negative Image Add-In Intensity: Q Рис. 32.8. Визуальное дополнение
996 Глава 32. Модель дополнений В этом примере выбрано дополнение, превращающее изображение в негатив. Оно представляет пользовательский элемент управления, который упаковывает элемент Image (с предварительным просмотром эффекта) и элемент Slider. По мере перемещения ползунка Slider меняется интенсивность эффекта, и изображение предварительного просмотра изменяется. (Процесс обновления довольно медлительный по причине плохой оптимизации кода обработки изображения. Для достижения максимальной производительности можно было бы воспользоваться гораздо более совершенным алгоритмами, возможно, включающими небезопасные блоки кода.) Хотя механизм, выполняющий эту работу, довольно сложный, использовать его неожиданно легко. Ключевой ингредиент заключен в интерфейсе INativeHandleContract из пространства имен System.Addln.Contract. Он позволяет передавать дескриптор окна между дополнением и приложением-хостом. Ниже приведен пересмотренный интерфейс IlmageProcessorContract из сборки контракта. Он заменяет метод ProcessImageBytes () методом GetVisualO, принимающим те же данные изображения, но возвращающим порцию пользовательского интерфейса: [AddlnContract] public interface IlmageProcessorContract : IContract { INativeHandleContract GetVisual(Stream imageStream); } Интерфейс INativeHandleContract не применяется в классах представления, потому что он не пригоден для непосредственного использования в WPF-приложениях. Взамен него применяется вполне ожидаемый тип FrameworkElement. Ниже показано представление хоста: public abstract class ImageProcessorHostView { public abstract FrameworkElement GetVisual(Stream imageStream); } А это почти идентичное представление дополнения: [AddlnBase] public abstract class ImageProcessorAddlnView { public abstract FrameworkElement GetVisual(Stream imageStream); } Этот пример неожиданно похож на вариант с автоматизацией из предыдущего раздела. Здесь передается в контракт другой тип, нежели тот, что используется в представлениях. Опять-таки, для преобразований "контракт-представление" и "представление-контракт" должны применяться адаптеры. Однако на этот раз работу выполняет специализированный класс по имени FrameworkElementAdapter. FrameworkElementAdapter находится в пространстве имен System. Addln .Pipeline, но не является частью WPF, а входит в сборку System.Windows.Presentation.dll. В классе FrameworkElementAdapter определены два статических метода, выполняющие работу по преобразованию: ContractToViewAdapter () и ViewToContractAdapter(). Ниже показано, как с помощью метода FrameworkElement Adapters. ContractTo ViewAdapter () заполнить пробел в адаптере хоста: [HostAdapter] public class ImageProcessorContractToViewHostAdapter : HostView.ImageProcessorHostView { private Contract.IlmageProcessorContract contract; private ContractHandle contractHandle; public override FrameworkElement GetVisual(Stream imageStream)
Глава 32. Модель дополнений 997 { return FrameworkElementAdapters.ContractToViewAdapter ( contract.GetVisual(imageStream))/ } } А вот как метод FrameworkElementAdapters.ViewToContractAdapter () позволяет заполнить пробел в адаптере дополнения: [AddlnAdapter] public class ImageProcessorViewToContractAdapter : ContractBase, Contract.IlmageProcessorContract { private AddlnView.ImageProcessorAddlnView view; public INativeHandleContract GetVisual(Stream imageStream) { return FrameworkElementAdapters.ViewToContractAdapter( view.GetVisual(imageStream)); } } И последний штрих заключается в реализации метода GetVisual() в дополнении. В приложении обработки негативного изображения создается новый элемент управления по имени ImagePreview. Данные изображения передаются этому элементу, который устанавливает изображение предварительного просмотра и обрабатывает щелчки на ползунке. (Код пользовательского элемента управления здесь не показан, но все детали можно найти в загружаемых примерах для этой главы.) [Addln] public class NegativelmageProcessor : AddlnView.ImageProcessorAddlnView { public override FrameworkElement GetVisual(System.10.Stream imageStream) { return new ImagePreview(imageStream); } } Теперь, когда известно, как вернуть объект пользовательского интерфейса из дополнения, ничто не ограничивает разновидность содержимого, которое можно генерировать. Базовая инфраструктура — интерфейс INativeHandleContract и класс FrameworkElementAdapters — остаются неизменными. Резюме В этой главе рассматривалась многоуровневая модель дополнений. Вы узнали о том, как работает конвейер дополнения, почему он работает именно так, и каким образом создавать базовое дополнение, поддерживающее автоматизацию хоста и предоставляющее визуальное содержимое. О модели дополнений можно было бы сказать еще многое. Если вы планируете сделать дополнения ключевой частью профессиональных приложений, стоит присмотреться к специализированным сценариям поддержки версий и размещения, а также к развертыванию. Кроме того, не помешает изучить передовые приемы обращения с необработанными исключениями дополнений, а также узнать о том, как организовать более сложные взаимодействия между хостом и дополнением и между разными дополнениями. Чтобы изучить все детали, посетите блог команды разработчиков Microsoft, которые создавали систему дополнений, по адресу http://blogs.msdn.com/clraddins. Также может заинтересовать блок Джейсона Хи (Jason He), доступный по адресу http: // blogs.msdn.com/zifengh. Джейсон Хи является членом команды разработчиков, который описал свой опыт адаптации Paint.NET к применению модели дополнений.
ГЛАВА 33 Развертывание ClickOnce Рано или поздно вы отпустите свое приложение WPF в "свободное плаванье". Хотя существуют десятки разных способов передать готовое приложение с компьютера разработчика на настольный компьютер конечного пользователя, большинство приложений WPF используют одну из описанных ниже стратегий развертывания. • Запуск в браузере. Если создано WPF-приложение, состоящее из веб-страниц, его можно запускать прямо в браузере. Ничего устанавливать не понадобится. Однако приложение должно быть готово к тому, чтобы функционировать с очень небольшим набором привилегий. (Например, нельзя получать доступ к произвольным файлам, работать с системным реестром Windows, отображать всплывающие окна и т.д.) Такой подход был изложен в главе 24. • Развертывание через браузер. WPF-приложения тесно интегрированы со средством установки ClickOnce ("однократный щелчок"), которое позволяет пользователю запустить программу установки со страницы браузера. Лучше всего то, что приложения, установленные с помощью ClickOnce, могут быть сконфигурированы так, чтобы автоматически проверять наличие обновлений. Отрицательной стороной является то, что возможности по настройке установки ограничены, и нет никакого способа выполнить задачи конфигурирования системы (вроде модификации системного реестра Windows, создания базы данных и т.п.). • Развертывание с помощью традиционной программы установки. Этот подход все еще существует в мире WPF В случае выбора такого варианта придется решить, нужно ли создавать полноценный установочный пакет Microsoft Installer (MSI) или же обратиться к более простой (но и ограниченной) установке ClickOnce. Готовый установочный пакет можно распространять на CD-диске, во вложении сообщения электронной почты, через общедоступный сетевой ресурс и т.д. В настоящей главе рассматривается второй из перечисленных подходов: развертывание приложения с помощью модели ClickOnce. Что нового? Хотя ClickOnce была разработана как легковесная технология развертывания, которая не заменит полноценные программы установки, каждая новая версия добавляет к ней ряд новых возможностей. Как будет показано далее в этой главе, в .NET 4 установка ClickOnce может создавать значки рабочего стола и регистрировать типы файлов. Вдобавок .NET теперь устанавливает дополнение Firefox под названием Microsoft .NET Framework Assistant, которое позволяет пользователям запускать установки ClickOnce из браузеров Firefox и Internet Explorer (естественно, если это дополнение включено).
Глава 33. Развертывание ClickOnce 999 Что собой представляет развертывание приложения Хотя формально вполне допускается перемещать приложение .NET с одного компьютера на другой простым копированием содержащей его папки, все же профессиональные приложения часто требуют несколько большего. Например, может понадобиться добавить множество ярлыков в меню Start (Пуск), изменить настройки системного реестра либо установить дополнительные ресурсы (такие как специальный журнал регистрации событий или база данных). Для получения этих средств должна быть создана специальная программа установки. Существует много вариантов создания программ установки. Можно воспользоваться коммерчески распространяемым продуктом типа InstallShield либо создать установочный файл MSI с помощью шаблона Setup Project (Проект установки) в Visual Studio. Традиционные программы установки предлагают пользователю хорошо знакомый интерфейс мастера установки, с изобилием средств для передачи файлов и выполнения разнообразных конфигурационных действий. Другой вариант — применение системы развертывания ClickOnce, тесно интегрированной с WPF. Система ClickOnce обладает рядом ограничений (большинство из них связано с положенным в основу ClickOnce проектным решением), но обеспечивает два существенных преимущества: • поддержка установки со страницы браузера (которая может размещаться как во внутренней сети, так и в Интернете); • поддержка обновлений автоматической загрузки и установки обновлений. Этих двух средств может оказаться недостаточно, чтобы соблазнить разработчиков отказаться от использования полноценных программ установки. Но если интересует простое, легковесное развертывание, которое работает через Интернет и поддерживает автоматическое обновление, то в этом случае ClickOnce — просто идеальный вариант. Модель ClickOnce и частичное доверие Обычные приложения WPF требуют полного доверия (full trust), потому что им нужны права доступа к неуправляемому коду для создания окна WPR Это означает, что установка автономного приложения WPF посредством ClickOnce вызовет такое же препятствие со стороны системы безопасности, как и установка любого другого типа приложения из Интернета — в частности, браузер выдаст предупреждение безопасности. Если пользователь соглашается, устанавливаемое приложение получает возможность делать все, что разрешено делать в системе текущему пользователю. ClickOnce работает иначе с более старыми приложениями Windows Forms. Приложения Windows Forms могут быть сконфигурированы на использование частичного доверия и затем развернуты с помощью ClickOnce. В лучшем случае это означает, что пользователь может устанавливать частично доверенное приложение Windows Forms через ClickOnce без какого-либо предупреждения безопасности или повышения привилегий Может показаться, что подход Windows Forms лучше, но WPF предусматривает возможность комбинировать частично доверенное программирование и технологию установки ClickOnce. Фокус заключается в использовании модели ХВАР, описанной в главе 24. В такой ситуации приложение запускается в браузере, поэтому ему не нужно создавать каких-либо окон, и не нужны права доступа к неуправляемому коду. Более того, поскольку приложение доступно через URL (и затем кэшируется локально), пользователь всегда запускает последнюю, самую свежую версию. "За кулисами" автоматическая загрузка ХВАР применяет ту же технологию ClickOnce, которая рассматривается в этой главе. Приложения ХВАР в этой главе не описаны. За информацией о ХВАР и частично доверенном программировании обращайтесь в главу 24.
1000 Глава 33. Развертывание ClickOnce При проектировании ClickOnce предполагалось иметь дело с простыми, прямолинейными приложениями. Развертывание ClickOnce, в частности, подходит для производственных приложений и внутреннего программного обеспечения компании. Обычно такие приложения выполняют свою работу, опираясь на данные и службы, предоставляемые серверами приложений среднего уровня. Как следствие, им не требуется привилегированный доступ к локальному компьютеру. Эти приложения также развертываются в среде масштаба предприятия, которая может включать тысячи рабочих станций. В таких средах стоимость развертывания приложений и их обновления довольно значительна, особенно если все это должно поддерживаться администратором. В результате более важно предложить простой и прямолинейный процесс, нежели развитый пакет средств. Модель ClickOnce также может иметь смысл для прикладных приложений, которые распространяются через Интернет, в частности, если эти приложения требуют частого обновления и не предъявляют строгих требований к установке. Однако ограничения ClickOnce (такие как недостаток гибкости в настройке мастера установки) делают эту модель непрактичной для сложных прикладных приложений, которые выдвигают детализированные требования к установке или нуждаются во взаимодействии с пользователем при выполнении ряда тонких конфигурационных шагов. В таких случаях понадобится создавать специальное приложение установки. На заметку! Чтобы установить приложение WPF с помощью ClickOnce, на компьютере уже должна быть установлена исполняющая среда .NET Framework. Это требование проверяется при первом запуске установки ClickOnce. Если исполняющая среда .NET Framework не установлена, отображается окно сообщения, которое поясняет суть проблемы и предлагает установить .NET с веб-сайта Microsoft. Модель установки ClickOnce Хотя ClickOnce поддерживает несколько типов развертывания, общая модель разработана так, чтобы сделать веб-развертывание практичным и легким. Вот как это работает. Среда Visual Studio используется для публикации приложения ClickOnce на вебсервере. Затем пользователь переходит на автоматически сгенерированную страницу (по имени publish.htm), которая предлагает ссылку для установки приложения. Когда пользователь щелкает на этой ссылке, приложение загружается, устанавливается и добавляется в меню Start. На рис. 33.1 показан этот процесс. Компьютер Клиентский разработчика Сервер разработчика компьютер Рис. 33.1. Установка приложения ClickOnce Хотя ClickOnce — идеальный выбор для веб-развертывания, та же базовая модель подходит и для других сценариев, включая следующие: • развертывание приложения из общедоступного сетевого ресурса; • развертывание приложения с CD- или DVD-диска; • развертывание приложения на веб-сервере или общедоступном сетевом ресурсе с последующей отправкой по электронной почте ссылки на программу установки.
Глава 33. Развертывание ClickOnce 1001 Веб-страница установки не создается на общедоступном сетевом ресурсе, CD- или DVD-диске. Вместо этого пользователи устанавливают приложение непосредственным запуском программы setup.exe. Наиболее интересная часть развертывания ClickOnce заключается в его способе поддержки обновления. По сути дела, вы (как разработчик) управляете несколькими установками обновления. Например, вы можете сконфигурировать приложение для автоматической проверки наличия обновлений через определенные промежутки времени. Когда пользователь запускает приложение, в действительности он сначала запускает тонкую прослойку, которая проверяет наличие новой версии и предлагает пользователю загрузить ее. Можно даже сконфигурировать приложение для использования веб-подобного интерактивного режима. В этом случае приложение должно запускаться со специальной веб-страницы ClickOnce. Приложение по-прежнему кэшируется локально для достижения оптимальной производительности, но пользователи не смогут запустить его до тех пор, пока не подключатся к сайту, на котором это приложение опубликовано. Это гарантирует, что они всегда будут запускать последнюю, самую современную версию приложения. Ограничения ClickOnce Развертывание ClickOnce не позволяет повсеместное конфигурирование. Многие аспекты его поведения жестко фиксированы — либо чтобы гарантировать согласованное восприятие пользователем, либо для обеспечения политик безопасности, подходящих для предприятий. Ниже перечислены ограничения технологии ClickOnce. • Приложения ClickOnce устанавливаются для единственного пользователя. Установка приложения для всех пользователей рабочей станции невозможна. • Приложения ClickOnce всегда устанавливаются в управляемую системой папку, специфичную для пользователя. Изменить или повлиять на выбор папки, куда устанавливается приложение, нельзя. • Если приложения ClickOnce устанавливаются в меню Start, они получат всего два ярлыка: один запускает приложение, а другой — открывает в браузере справочную веб-страницу. Изменить это не удастся, равно как не получится добавить приложение ClickOnce в группу Startup (Автозагрузка), меню Favorites (Избранное) и т.п. • Нельзя изменять пользовательский интерфейс мастера установки. Это значит, что добавлять новые диалоговые окна, изменять текст в существующих окнах и т.п. невозможно. • Нельзя изменять страницу установки, сгенерированную приложениями ClickOnce. Однако после генерации можно отредактировать HTML-разметку вручную. • Развертывание ClickOnce не позволяет устанавливать разделенные компоненты в кэш глобальных сборок (GAC). • Развертывание ClickOnce не дает возможность выполнять специализированные действия (такие как создание базы данных или конфигурирование настроек реестра). Некоторые из перечисленных ограничений можно обойти. Например, приложение может быть сконфигурировано на добавление настроек реестра при его первом запуске на новом компьютере. Однако если предъявлены сложные требования к установке,
1002 Глава 33. Развертывание ClickOnce то намного лучше обратиться к созданию полноценной специальной программы установки. Можно воспользоваться инструментами от независимых разработчиков вроде InstallShield или же создать проект установки в Visual Studio. Наконец, следует отметить, что .NET позволяет построить специальную программу установки, использующую технологию развертывания ClickOnce. Это дает возможность проектировать сложные приложения установки, не жертвуя при этом средствами автоматического обновления, которые предлагаются ClickOnce. Однако есть и некоторые недостатки. Этот подход не только заставляет писать (и отлаживать) большой объем кода, но также требует использования унаследованных классов из комплекта инструментов Windows Forms для построения пользовательского интерфейса установки. Смешивание специальных приложений установки с ClickOnce выходит за рамки материала настоящей книги, но при желании можно ознакомиться с начальным примером по адресу http://msdn.microsoft.com/ru-ru/library/dd997001 (v=VS. 100) .aspx. Простая публикация ClickOnce Прежде чем приступить к публикации ClickOnce, понадобится некоторая базовая информация о проекте. Дважды щелкните на узле Properties (Свойства) в окне Solution Explorer и перейдите на вкладку Publish (Публикация). Вы увидите настройки, показанные на рис. 33.2. Смысл всех настроек будет объяснен далее в главе, а сначала следует указать некоторые базовые детали публикации. [ оо WpfAppikation3 • Microsoft Visual Studio 1 ' I П Ь^Я] Рис. 33.2. Настройки ClickOnce проекта
Глава 33. Развертывание ClickOnce 1003 Настройка издателя и продукта Приложению для установки понадобится базовая идентичность, включающая имя издателя и название продукта, которые могут быть использованы в запросах процесса установки и в ярлыках меню Start (Пуск). Чтобы указать эту информацию, щелкните на кнопке Options (Параметры). Откроется диалоговое окно Publish Options (Параметры публикации) с набором дополнительных настроек, разнесенных по нескольким группам. Как показано на рис. 33.3, в списке слева выбран элемент Description (Описание), а справа находятся текстовые поля, которые позволяют указать ключевые детали: Publisher Name (Имя издателя), Suite Name (Имя комплекта) и Product Name (Имя продукта). Publish Options Deployment Manifests File Associations Publish language: (Default) Publisher name: Acme Software Syite name Product name: ClickOnceTest Support URL: http://wvm.the4cnMSoftwarecompany.com £rror URL: t . I i IfLkari] » - •• Cancel Рис. 33.3. Указание некоторой базовой информации о проекте Эти детали важны, потому что они используются для создания иерархии меню Start. Если задается необязательное имя комплекта, ClickOnce создаст ярлык приложения в форме [Имя издателя]1^ [Имя комплекта]1^[Имя продукта]. Если имя комплекта не указано, ClickOnce создаст ярлык [Имя издателя]1^ [Имя продукта]. В примере, показанном на рис. 33.3, ярлык будет выглядеть как Acme Software^ClickOnceTest (рис. 33.4). Если указан URL-адрес поддержки, ClickOnce создаст дополнительный ярлык [Имя продукта] online support. Когда пользователь щелкнет на нем, запустится браузер и загрузит указанную страницу. URL-адрес ошибки — это ссылка на веб-сайт, который будет показан (в диалоговом окне), если при попытке установки приложения возникнет ошибка. Другие группы настроек рассматриваются в конце этой главы. Пока что нужно щелкнуть на кнопке О К после ввода имени издателя, имени продукта и прочих деталей. Рис 33.4. Ярлыки ClickOnce (для установок на рис. 33.3) >. Accessories Д, Acme Software f) ClickOnceTest online support J ClickOnceTest . Acoustica MrxcraVJk j» BookSmart J. Capture NX 2 i. FastStone Photo Resuer > FeedDemon I. Flac Ripper a. Free RAR Extract Frog Games 1. Maintenance I Microsoft Expression . Microsoft Headset ,. Microsoft Keyboard 4» Microsoft Office i. Microsoft Rich Tools | Mic rosoft Srtverligbt 3 SDK j. Microsoft SQL Server 2008
1004 Глава 33. Развертывание ClickOnce Запуск мастера публикации Простейший способ сконфигурировать настройки ClickOnce состоит в том, чтобы щелкнуть на кнопке Publish Wizard (Мастер публикации) внизу окна свойств, показанного на рис. 33.3. В результате запустится мастер, который проведет через несколько простых шагов, чтобы собрать важную информацию. Этот мастер не предоставляет доступ ко всем средствам ClickOnce, описанным в настоящей главе, однако это самый быстрый способ начать. Первым выбором в мастере публикации будет выбор местоположения для публикации приложения (рис. 33.5). Whtrt do you want to publish the application? Specify the location to publish this application http localhost ClicVOnceTestj J» You may publish the application to a web site, FTP server, or file path. Examples; Disk path: c:\deploy\myapplication Frte share WserverVmyapplication FTP server ftp://ftp.microsoft.com/myapplication Web site http://www.mkrosoftxom/myapplication Jtiext ' Рис. 33.5. Выбор местоположения публикации В выборе местоположения первой публикации приложения нет ничего особо важного, поскольку это не обязательно то же самое место, где позже будут размещаться установочные файлы. Другими словами, можно опубликовать в локальном каталоге и затем передать файлы на веб-сервер. Единственный нюанс — перед запуском мастера публикации необходимо знать конечное место расположения файлов, поскольку эту информацию придется указывать. Без нее не будет работать функция автоматического обновления. Разумеется, можно публиковать приложение непосредственно в конечное расположение, но это не обязательно. Фактически, локальное построение установки часто представляет собой простейший путь. Чтобы понять, как это работает, начните с выбора локального пути (например, C:\Temp\ClickOnceApp). Затем щелкните на кнопке Next (Далее). После этого возникает реальный вопрос — как пользователи будут устанавливать это приложение (рис. 33.6)? Выбор важен, потому что он влияет на стратегию обновления. Произведенный выбор будет зафиксирован в файле манифеста, поставляемом вместе с приложением. На заметку! Есть один случай, когда диалоговое окно, показанное на рис. 33.6, не отображается. Если в качестве местоположения публикации вводится виртуальный каталог на веб-сервере (другими словами, URL-адрес, начинающийся с http://), то мастер предположит, что это окончательное местоположение установки.
Глава 33. Развертывание ClickOnce 1005 Г Puofah Wizard '^У «Ё&0 j How will users insta I tiw application? From a ft* «te From а ЦМС path or frf* share Q From a £D-ROM or DVD-ROM < £revious blert > Finish Cancel Рис. 33.6. Выбор типа установки На рис. 33.6 показаны три доступных выбора. Можно создать установку на сетевом файловом ресурсе, на веб-сервере или же на CD- либо DVD-диске. В последующих разделах рассматриваются все варианты. Публикация на сетевом файловом ресурсе В этом случае все пользователи сети получат доступ к установке, перейдя по определенному пути UNC и запустив находящийся там файл по имени setup.exe. Пусть UNC — это сетевой путь в форме \\ИмяКомпьютера\ИмяОбщегоРесурса. Сетевой диск использовать нельзя, поскольку он зависит от настроек системы (так что разные пользователи могут иметь разные настройки сетевых дисков). Чтобы обеспечить автоматические обновления, инфраструктура ClickOnce должна знать точно, где она сможет найти установочные файлы, и это то же самое место, где будут развернуты обновления. Публикация для веб-сервера Установку для веб-сервера можно создать в локальной корпоративной сети или в Интернете. Visual Studio сгенерирует HTML-файл по имени publish.htm, который упростит процесс. Пользователь запросит эту страницу в своем браузере и щелкнет на ссылке для загрузки и установки приложения. Существует несколько способов передачи файлов на веб-сервер. Чтобы воспользоваться двухшаговым подходом (опубликовать файлы локально, а затем передать их в нужное место), просто следует скопировать файлы из локального каталога на веб-сервер, используя соответствующий механизм (вроде FTP). Удостоверьтесь, что при этом сохраняется структура каталогов. Для опубликования файлов непосредственно на веб-сервере, минуя предварительное тестирование, есть два выбора. Если применяется IIS, и текущая учетная запись пользователя обладает достаточными привилегиями для создания нового виртуального каталога на веб-сервере (или загрузки файлов в существующий виртуальный каталог), можно опубликовать файлы непосредственно на веб-сервер. Просто укажите путь к виртуальному каталогу на первом шаге мастера. Например, в качестве местоположения публикации можно использовать Ь^р://ИмяКомпьютера/ИмяВиртуальногоКаталога (в случае корпоративной сети) или Ь^р://ДоменноеИмя/ИмяВиртуальногоКаталога (для сервера, находящегося в Интернете). у
1006 Глава 33. Развертывание ClickOnce Также можно публиковать непосредственно на веб-сервер через FTP. В Интернете это часто бывает обязательным требованием (в отличие от корпоративной сети). В этом случае Visual Studio установит соединение с веб-сервером и передаст туда файлы ClickOnce no FTP. При этом для установки соединения будет запрошено имя пользователя и пароль. На заметку! FTP применяется для передачи файлов. Этот протокол не используется непосредственно в процессе установки. Идея состоит в том, чтобы загружаемые файлы стали видимыми на некотором веб-сервере, и пользователи могли установить приложение через файл publish.htm на этом веб-сервере. В результате, когда используется путь FTP на первом шаге мастера (рис. 33.5), все равно нужно указать соответствующий URL на втором шаге (рис. 33.6). Это важно, т.к. публикация ClickOnce должна вернуться в это местоположение для выполнения автоматических обновлений. Публикация на локальном веб-сервере В случае опубликования приложения в виртуальном каталоге локального компьютера необходимо удостовериться, что веб-сервер Internet Information Services (IIS) установлен с использованием элемента Программы и компоненты панели управления, который позволяет включать и отключать компоненты Windows. При установке IIS не забудьте включить опции Расширяемость .NET и Совместимость управления IIS 6 (что позволит Visual Studio взаимодействовать с IIS). Кроме того, в системах Windows Vista или Windows 7 для получения возможности публиковать в виртуальный каталог среда Visual Studio должна быть запущена от имени администратора. Проще всего сделать это, щелкнув правой кнопкой мыши на ярлыке Microsoft Visual Studio 2010 в меню Start и выбрав в контекстном меню пункт Запуск от имени администратора. Можно также настроить постоянный запуск Visual Studio от имени администратора, что представляет собой компромисс между удобством и безопасностью, который нужно тщательно взвесить. Для этого щелкните правой кнопкой мыши на ярлыке Visual Studio, выберите в контекстном меню пункт Свойства, в открывшемся диалоговом окне перейдите на вкладку Совместимость и отметьте флажок Выполнять эту программу от имени администратора. Публикация для CD- и DVD-диска В случае выбора публикации на установочный носитель вроде CD- или DVD-диска все равно понадобится решить, планируются ли автоматические обновления. Некоторые организации используют исключительно развертывание с помощью CD-дисков, в то время как другие применяют его в дополнение к веб-развертыванию или развертыванию через сетевые ресурсы. На третьем шаге мастера выбираются опции для этого конкретного случая (рис. 33.7). Здесь доступны следующие варианты. • Можно указать URL-адрес или путь UNC, по которому приложение будет искать обновления. Это предполагает, что приложение будет опубликовано в этом месте. • Можно опустить эту информацию и тем самым вообще отключить функцию автоматического обновления. • Можно опустить эту информацию, но заставить приложение ClickOnce использовать местоположение установки в качестве местоположения обновления. Например, при такой стратегии, если кто-нибудь устанавливает приложение из \\CompanyServer-B\MyClickOnceApp, то приложение автоматически будет про-
Глава 33. Развертывание ClickOnce 1007 верять это (и только это) местоположение на предмет обновлений при каждом запуске. Этот подход обеспечивает большую гибкость, но также связан с риском возникновения проблем (чаще всего связанные с невозможностью находить обновленные версии, если пользователь устанавливает из неверного пути). Мастер публикации не позволяет выбрать это поведение. Но если это поведение необходимо, следует установить параметр Exclude deployment provider URL (Исключить URL поставщика развертывания), как будет описано в разделе "Параметры публикации" далее в главе. Рис. 33.7. Поддержка автоматических обновлений На заметку! Мастер публикации не дает возможности установить частоту проверок обновлений. По умолчанию приложения ClickOnce проверяют наличие обновлений при каждом запуске. Если новая версия найдена, .NET приглашает пользователя установить ее перед запуском приложения. В разделе "Обновления" далее в главе будет показано, как изменить эту настройку. Интерактивный и автономный режимы В случае создания развертывания для веб-сервера или сетевого ресурса должна быть установлена дополнительная опция, показанная на рис. 33.8. Выбор по умолчанию заключается в создании приложения, доступного в интерактивном и автономном режиме, которое запускается независимо от того, может пользователь подключиться к месту публикации или нет. В этом случае ярлык для запуска приложения добавляется в меню Start. Если выбрано создание приложения, доступного только в интерактивном режиме, то пользователю для запуска приложения будет требоваться каждый раз обращаться к месту публикации. (На веб-странице publish.htm будет отображаться кнопка Run (Запуск) вместо Install (Установка).) Это исключит вероятность запуска старых версий приложения после применения обновлений. Данная часть модели развертывания аналогична веб-приложениям. Когда создается приложение, доступное только в интерактивном режиме, оно по-прежнему будет загружаться (в локально кэшируемое местоположение) при первом запуске.
1008 Глава 33. Развертывание ClickOnce Publish Wizard Will th« application be available offline? Q* ca« Yes, this application is available online or offline UMLB] m A shortcut vwll be added to the Start Menu, and the application can be uninstalled via Add/Remove Programs. No, this application is only available online No shortcut will be added to the Start Мели. The the publish location. < £rev,ous Next > application will be finish run directly from C-Kd Рис. 33.8. Поддержка использования в автономном режиме Таким образом, хотя первоначальное время запуска может быть больше (из-за первоначальной загрузки), приложение будет работать так же быстро, как любое обычное установленное Windows-приложение. Однако приложение не может быть запущено, если пользователь не подключен к сети или Интернету, что делает его неподходящим для мобильных пользователей (например, пользователей ноутбуков, которым не всегда доступно подключение к Интернету). Если выбрано создание приложения, которое поддерживает автономный режим, то программа установки добавит ярлык в меню Start. Пользователь может запустить приложение с помощью этого ярлыка, независимо от того, подключен компьютер к сети или нет. Если компьютер находится в интерактивном режиме, то приложение проверит наличие новых версий в месте, где опубликовано приложение. При наличии обновлений приложение предложит пользователю установить их. Позже будет показано, как сконфигурировать эту политику. На заметку! При публикации для CD-диска отсутствует выбор для создания приложения, доступного только в интерактивном режиме. Это — последний выбор в мастере публикации. Щелкните на кнопке Next, чтобы просмотреть итоговую информацию и затем на кнопке Finish (Готово) для генерации файлов развертывания и копирования их в место, указанное на первом шаге. Совет. В этот момент можно быстро переопубликовать приложение, щелкнув на кнопке Publish Now (Опубликовать сейчас) либо выбрав в меню пункт Builds Publish [Имя приложения] (Сборка^Опубликовать [Имя приложения]). Развернутая файловая структура Технология ClickOnce использует довольно простую структуру каталогов. Она создает файл setup.exe в указанном месте и подкаталог для приложения. Например, при развертывании приложения по имени ClickOnceText в каталоге с:\ ClickOnceTest появляются следующие файлы:
Глава 33. Развертывание ClickOnce 1009 с:\ClickOnceTest\setup.exe с:\ClickOnceTest\publish.htm с:\ClickOnceTest\ClickOnceTest.application с:\ClickOnceTest\ClickOnceTest_l_0_0_0.application с:\ClickOnceTest\ClickOnceTest_l_0_0_0\ClickOnceTest.exe.deploy с:\ClickOnceTest\ClickOnceTest_l_0_0_0\ClickOnceTest.exe.manifest Файл publish.htm присутствует только в случае публикации на веб-сервере. Файлы .manifest и .application хранят информацию о необходимых файлах, настройках обновлений и прочие детали. (С подробной информацией об этих файлах и их XML-файлом можно ознакомиться в справочной системе MSDN.) Файлы .manifest и .application снабжаются электронной подписью во время публикации, поэтому не могут модифицироваться вручную. В случае внесения изменений ClickOnce заметит разницу и откажется устанавливать приложение. По мере публикации новых версий приложения ClickOnce будет добавлять новые подкаталоги для каждой новой версии. Например, изменение опубликованной версии приложения на 1.0.0.1 приводит к появлению нового каталога: с:\ClickOnceTest\ClickOnceTest_l_0_0_l\ClickOnceTest.exe.deploy с:\ClickOnceTest\ClickOnceTest_l_0_0_l\ClickOnceTest.exe.manifest После запуска программа setup.exe отработает процесс установки всего обязательного программного обеспечения (такого как .NET Framework) и затем установит наиболее свежую версию приложения. Установка приложения ClickOnce Чтобы увидеть ClickOnce в действии при веб-развертывании, выполните описанные ниже шаги. 1. Удостоверьтесь, что установлены дополнительные компоненты веб-сервера IIS (как было описано во врезке "Публикация на локальном веб-сервере" ранее в этой главе). 2. Используя Visual Studio, создайте базовое Windows-приложение и скомпилируйте его. 3. Запустите мастер публикации (щелкнув на кнопке Publish Wizard (Мастер публикации) или выбрав в меню пункт Builds Publish [Имя приложения]) и укажите http://localhost/ClickOnceTest в качестве местоположения публикации. Часть URL, касающаяся местоположения, указывает на текущий компьютер. Если вебсервер IIS установлен и привилегий достаточно, Visual Studio сможет создать этот виртуальный каталог. 4. Выберите создание приложения, доступного в интерактивном и автономном режимах, и щелкните на кнопке Finish (Готово) для завершения работы мастера. Файлы будут развернуты в папке по имени ClickOnceTest в корне веб-сервера IIS (по умолчанию c:\Inetpub\wwwroot). 5. Запустите программу setup.exe непосредственно или загрузите страницу publish.htm (показанную на рис. 33.9) и щелкните на кнопке Install (Установка). Появится предупреждение безопасности, которое запросит подтверждения доверия приложению (подобно тому, как это происходит при загрузке в веб-браузер элемента управления ActiveX). 6. Если решено продолжить, приложение будет загружено и последует запрос о том, нужно ли установить его. 7. После установки приложение можно запустить через ярлык в меню Start либо удалить с помощью элемента Программы и компоненты панели управления.
1010 Глава 33. Развертывание ClickOnce 0 ClickOnceTest - Windows Internet ■xplorer £, http://mrtthewm/ChclcOn<eTe$t/pubto$h.htm * | ♦* | X 11 Google P "j I ^ Cl.ckOnceTest ClickOnceTest Name: ClickOnceTest Version: 1.0.0.0 Publisher: | Install | Dene ft » 0 - { - Page - Teds - I • .ntranet | Protected Mode: Off \ IOC: Рис. 33.9. Страница установки publish.htm Ярлык приложения ClickOnce не является стандартным ярлыком вроде тех, к которым вы привыкли. На самом деле это ссылка на приложение — текстовый файл с информацией об имени приложения и расположением файлов развертывания. Действительные программные файлы приложения располагаются в месте, которое трудно найти и невозможно контролировать. Местоположение следует такому шаблону: с:\Documents and Settings\[ИмяПользователя]\Local Settings\Apps\2.0\[...]\[...]\[...] Конечные три части этого пути состоят из непонятных, автоматически сгенерированных строк вроде C6VLXKCE.828. Понятно, что не предполагается непосредственное обращение к такому каталогу. Обновление приложения ClickOnce Чтобы увидеть, как приложение ClickOnce автоматически само себя обновляет, выполните следующие шаги, имея установку из предыдущего раздела. 1. Внесите небольшое, но заметное изменение в приложение (например, добавьте кнопку). 2. Перекомпилируйте приложение и заново опубликуйте его в то же самое местоположение. 3. Запустите приложение через меню Start. Приложение обнаружит новую версию и запросит подтверждение ее установки (рис. 33.10). 4. Как только обновление подтверждается, новая версия приложения будет установлена и запущена. В следующих разделах будет показано, как настроить некоторые дополнительные опции ClickOnce. На заметку! Обновления и загрузки обрабатываются механизмом ClickOnce — dfsvc.exe.
Глава 33. Развертывание ClickOnce 1011 Update Available Application update A new version of ClickOnceTest is available. Do you want to download it now? Name: CBckOnceTest From: matthewm L_SL JL i Stop 1 -ll Рис. 33.10. Обнаружение новой версии приложения ClickOnce Дополнительные параметры ClickOnce Мастер публикации представляет собой быстрый способ создания комплекта развертывания ClickOnce, но он не позволяет настроить все возможные параметры. Эти параметры доступны через вкладку Publish показанного ранее окна свойств приложения. Многие из этих настроек дублируют детали, которые вы уже видели в мастере. Например, первые два текстовых поля позволяют выбрать местоположение публикации (место, куда будут помещены файлы ClickOnce, как установлено на первом шаге мастера) и местоположение установки (место, из которого пользователь запустит установку, как это делается на втором шаге мастера). Настройка Install Mode (Режим установки) позволяет выбрать, должно приложение устанавливаться на локальном компьютере или запускаться в только в интерактивном режиме. Однако есть некоторые установки, которые в мастере не доступны; они рассматриваются в последующих разделах. Версия публикации В разделе Publish Version (Версия публикации) устанавливается версия приложения, которая сохраняется в файле манифеста ClickOnce. Это не то же самое, что версия сборки, которую можно указать на вкладке Application (Приложение), хотя можно установить их одинаковыми. Ключевое отличие в том, что версия публикации является критерием, служащим для определения доступности нового обновления. Если пользователь запускает версию 1.5.0.0 приложения, а при этом доступна версия 1.5.0.1, то инфраструктура ClickOnce отобразит диалоговое окно обновления, показанное на рис. 33.10. По умолчанию флажок Automatically Increment Revision with Each Publish (Автоматически увеличивать номер редакции с каждой публикацией) отмечен, и в этом случае финальная часть версии публикации (номер редакции) увеличивается на 1 после каждой публикации, так что 1.0.0.0 превращается в 1.0.0.1, затем в 1.0.0.2 и т.д. Если нужно опубликовать одну и ту же версию приложения во многих местоположениях, следует снять отметку с этого флажка. Однако имейте в виду, что средство автоматического обновления вступает в действие только тогда, когда обнаруживает более высокий номер версии. Отметка даты развернутых файлов влияния не оказывает (и ненадежна). Может показаться, что совершенно неэлегантно отслеживать отдельные номера версий сборки и публикации. Однако иногда это имеет смысл. Например, при тестировании приложения может понадобиться сохранить фиксированный номер версии сборки, чтобы предотвратить попадание к тестировщикам самой последней версии. В этом случае можно использовать ту же версию сборки, но оставить автоматически увеличиваемым номер версии публикации. Когда все готово к выпуску официального обновления, можно установить версию сборки и версию публикации одинаковыми. К тому же опуб-
1012 Глава 33. Развертывание ClickOnce линованное приложение может состоять из многих сборок с разными номерами версий. В этом случае было бы нереально применять номер версии сборки. Вместо этого инфраструктура ClickOnce должна рассматривать единственный номер версии, чтобы определить необходимость обновления. Обновления Щелкните на кнопке Updates (Обновления), чтобы отобразить диалоговое окно Application Updates (Обновления приложения), показанное на рис. 33.11. В этом окне можно выбрать стратегию обновлений. Application Updates "В J Jhe application should check for updates Choose *hen the application should check for updates: After the application starts Choose this option to speed up application start time. Updates will not be installed until the next ume the application is run. 9 Before the application starts Choose this option to ensure that users «vho are connected to the network always run with the latest updates. 3 Specify a minimum required version for this application MiQOO Update location (rf different than publish location): B,o. Рис. 33.11. Установка параметров обновления На заметку! Кнопка Updates не будет доступной, если создается приложение только в интерактивном режиме. Такое приложение всегда запускается из своего местоположения публикации на веб-сайте или сетевом ресурсе. Сначала необходимо указать, должно ли приложение проверять наличие обновлений, отметив флажок The application should check for updates (Приложении должно проверять наличие обновлений). Затем следует выбрать, когда должно происходить обновление. Для этого доступны два переключателя. • Before the application starts (Перед запуском приложения). Если использовать эту модель, то инфраструктура ClickOnce проверяет наличие обновлений приложения (на веб-сайте или сетевом ресурсе) при каждом запуске приложения пользователем. Если обновление обнаружено, оно устанавливается и затем запускается приложение. Эта опция — хороший выбор, если нужно гарантировать получение пользователем обновлений немедленно после их появления. • After the application starts (После запуска приложения). Если выбрать эту модель, то инфраструктура ClickOnce проверяет новые обновления после запуска приложения. Если обнаруживается обновленная версия, она устанавливается при следующем запуске приложения пользователем. Это — рекомендованная опция для большинства приложений, поскольку сокращает время загрузки.
Глава 33. Развертывание ClickOnce 1013 Если отдается предпочтение проверке после запуска приложения, то такая проверка осуществляется в фоновом режиме. Можно выбрать выполнение проверки при каждом выполнении приложения (по умолчанию так и есть) или проверять реже, через какой-то интервал времени, например, один раз в несколько часов, дней или недель. Можно также указать необходимую минимальную версию. Это можно использовать для того, чтобы сделать обновления обязательными. Например, если установить версию публикации 1.5.0.1 и минимальную версию 1.5.0.0, а затем опубликовать приложение, то любой пользователь, располагающий версией старше 1.5.0.0, будет вынужден выполнить обновление, прежде чем сможет запустить приложение (по умолчанию минимальная версия не устанавливается и все обновления необязательны). На заметку! Даже если указать минимальную версию и заставить приложение проверять наличие обновлений перед запуском, то пользователь сможет запустить старую версию приложения. Это происходит, когда он находится в автономном режиме — при этом проверка обновлений завершается сбоем, не сообщая об ошибке. Единственный способ обойти это ограничение — создать приложение, доступное только в интерактивном режиме. Ассоциации файлов Технология ClickOnce позволяет установить до восьми ассоциаций файлов. Это типы файлов, которые будут привязаны к приложению, чтобы двойной щелчок на файле соответствующего типа в Windows Explorer приводил к автоматическому запуску приложения. Чтобы создать ассоциацию файла, щелкните на кнопке Options (Параметры) внутри вкладки Publish (Публикация). Откроется диалоговое окно Publish Options (Параметры публикации). В списке слева выберите элемент File Associations (Ассоциации файлов). Появится таблица, в которой можно ввести информацию об ассоциации файла, как показано на рис. 33.12. Publish Options Description * Extension Description Ш TestDoc Files ProglD ClickOnceTest.testDocJ Icon PerfCenterCpl.i... 1 OK I Ciicd Рис. 33.12. Создание ассоциации файла Каждая такая ассоциация требует четырех порций информации: расширение файла, текстовое описание, идентификатор программы (ProglD) и значок. ProglD — это текстовый код, уникально идентифицирующий тип файла. По соглашению он должен базироваться на имени и версии приложения, как в случае MyApplication.testDoc.1.0, хотя на самом деле формат не имеет значения, если он уникален. Значок указывает на
1014 Глава 33. Развертывание ClickOnce файл в проекте. Для того чтобы этот файл был включен в установку ClickOnce, его понадобится выбрать в окне Solution Explorer и установить Build Action (Действие сборки) в Content (Содержимое). На заметку! Для ассоциации файла не нужно указывать только одну деталь — имя или путь программы. Дело в том, что ClickOnce уже располагает этой информацией. Используя ассоциации файлов с ClickOnce, следует помнить об одном важном моменте. В противоположность ожиданиям, когда пользователь дважды щелкает на зарегистрированном файле, он не передается приложению в качестве параметра командной строки. Вместо этого файл потребуется извлечь из текущего домена приложения, как показано ниже: string commandLineFile = AppDomain.CurrentDomain.Setuplnformation.ActivationArguments.ActivationData[0] ; Другой нюанс заключается в том, что местоположение файла передается в формате URI, как в file:///c:\MyApp\MyFile.testDoc. Это значит, что для получения действительного пути к файлу и очистке от защищенных пробелов (которые в URI транслируются в символ %20) понадобится следующий код: Uri fileUri = new Uri(commandLineFile); string filePath = Uri .UnescapeDataString(flleUri.AbsolutePath); После этого можно проверить существование файла и открыть его, как обычно. Параметры публикации Как уже упоминалось, щелчок на кнопке Options приводит к открытию диалогового окна Publish Options с дополнительными параметрами. Список слева позволяет выбрать необходимую группу настроек. Выше были описаны настройки из группы Description и File Associations. В табл. 33.1 перечислены параметры из группы Deployment (Развертывание), а в табл. 33.2 — параметры из группы Manifests (Манифесты). Таблица 33.1. Параметры из группы Deployment Параметр Описание Deployment web page (Развертывание веб-страницы) Automatically generate deployment web page after every publish (Автоматически генерировать веб-страницу развертывания после каждой публикации) Open deployment web page after publish (Открывать веб-страницу развертывания после публикации) Use ".deploy" file extension (Использовать файловое расширение .deploy) Устанавливает имя страницы установки при веб-развертывании (по умолчанию — publish.htm) Если установлен (по умолчанию), веб-страница пересоздается при каждой публикации Если установлен (по умолчанию), Visual Studio запускает страницу установки в браузере после успешной публикации, чтобы можно было ее протестировать. Если установлен (по умолчанию), веб-страница установки всегда имеет расширение файла .deploy. Изменять этот параметр не следует, т.к. это расширение файла зарегистрировано на веб-сервере I IS и заблокировано, чтобы предотвратить подделку злоумышленниками
Глава 33. Развертывание ClickOnce 1015 Окончание табл. 33.1 Параметр Описание For CD installations, automatically start Setup when CD is inserted (Для установок из CD-дисков автоматически запускать установку при вставке CD-диска) Verify files uploaded to a web server (Верифицировать файлы, загружаемые на веб-сервер) Если установлен, Visual Studio генерирует файл autorun.inf, чтобы обеспечить автоматический запуск программы установки немедленно после вставки CD-диска в привод Если установлен, процесс публикации загружает каждый файл после публикации для проверки возможности его загрузки. Если файл не может быть загружен, выдается уведомление, объясняющее проблему Таблица 33.2. Параметры из группы Manifests Параметр Описание Block application from being activated via a URL (Блокировать активизацию приложения через URL) Allow URL parameters to be passed to application (Разрешить передачу параметров URL приложению) Use application manifest for trust information (Использовать манифест приложения для подтверждения информации) Exclude deployment provider URL (Исключить URL поставщика развертывания) Create desktop shortcut (Создать ярлык на рабочем столе) Если установлен, пользователь сможет запускать приложение из меню Start только после установки, а не из веб-браузера Установка этой опции позволяет приложению получать информацию URL от браузера, который запустил его, такую как аргументы строки запроса. Извлечь URI можно через класс ApplicationDeployment из пространства имен System.Deployment.Application. Для этого необходимо воспользоваться свойством ApplicationDeployment.CurrentDeployment. ActivationUri Если установлен, можно заново подписать манифест приложения после публикации. Обычно это делается для использования сертификата с именем компании. Данная информация появится в сообщении о доверии, которое пользователь видит при установке приложения Если установлен, приложение будет автоматически проверять свое местоположение установки на предмет обновлений. Этот параметр можно использовать, если точное местоположение развертывания не известно, но, тем не менее, требуется автоматическое обновление ClickOnce Если установлен, в дополнение к значку в меню Start процесс установки создаст значок на рабочем столе Резюме В этой главе был проведен краткий экскурс в модель развертывания ClickOnce, которая впервые появилась в .NET 2.0 и остается хорошим выбором для развертывания автономных WPF-приложений. Как и ХВАР, ClickOnce влечет за собой некоторые компромиссы — например, придется смириться с тем, что некоторыми деталями клиентской конфигурации управлять не удастся. Но теперь, когда большинство компьютеров оснащено веб-браузерами, поддерживающими ClickOnce, стало очень удобно развертывать приложения со скромными требованиями к установке.
Предметный указатель А ActiveX, 957 В BAML (Binary Application Markup Language), 49 С ClickOnce, 998 D DirectX, 26 DOM (Document Object Model), 740 H HLSL (High Level Shader Language), 395 L LINQ (Language Integrated Query), 574 M MAF (Managed Add-In Framework), 976 MEF (Managed Extensibility Framework), 976 R RCW (Runtime Callable Wrappers), 957 s Silverlight, 30 STA (Single-threaded apartment), 965 V Visual Studio 2010, 42 w WDDM (Windows Display Driver Model), 27 Win32, 963 Windows Forms, 942 классы Windows Forms, 948 элементы управления Windows Forms, 956 WPF (Windows Presentation Foundation), 25 WPFTbolkit, 42 X XAML (Extensible Application Markup Language), 46; 76 XBAP (XAML Browser Application), 760 A Адаптер дополнения, 985 хоста, 986 Анимация, 353; 402; 852 базовая, 405 в коде, 408 классы анимации, 405 ключевого кадра, 450 сплайновая, 453 на основе кадра, 456 на основе пути, 454 на основе свойств, 404 на основе таймера, 403 перекрывающиеся анимации, 421 плавность анимации, 428 построителей текстур, 449 производительность анимации, 436 синхронизированные анимации, 422 расширенная, 441 Архитектура WPF, 36 Б Библиотека milcore.dll, 36 quartz.dll, 805 команд, 267 в Ввод многопозиционный, 159 Веб-сайт навигация по веб-сайтам, 740 Видео, 805 -эффекты, 819 Вкладки, 798 Всплывающие подсказки (tooltip), 187 г Геометрия, 831 геометрическая модель, 833 Гиперссылки, 738 Градиент радиальный, 345 Графика трехмерная, 827; 840 Группирование, 652 Группы, 798
Предметный указатель 1017 д Данные преобразование данных, 602 привязка данных, 559 асинхронная, 596 шаблоны данных, 622 Диспетчер, 965 Документы, 866 потоковые, 867 редактирование, 894 фиксированные, 901 Дополнения (add-ins), 976 визуальные, 995 добавление новых дополнений, 990 конвейер дополнения, 977 модель дополнений MAF, 976 MEF, 976 представление (view) дополнения, 984 Дуга, 369 3 Захват мыши, 156 Звук, 805 синтез речи, 822 системные звуки, 808 и Инерция (inertia), 166 Интерфейс ICommand, 264 ICommandSource, 269 К Камера, 836 Каскадная таблица стилей (CSS), 302 Кисти, 340 анимированные, 446 Класс AmbientLight, 834 AnnotationHelper, 903 AnnotationService, 902 AnnotationStore, 903 Application, 217; 221 BackgroundWorker, 968 BitmapCacheBrush, 341; 351 BlurEffect, 393 Border, 92 Button, 183; 184 Calendar, 214 Canvas, 547 CheckBox, 183; 186 CombinedGeometry, 362 ComboBox, 210 ContainerUIElement3D, 862 Container-Visual, 382 ContentControl, 40 ContentElement, 135 ContextMenu, 784 Control, 39; 169 DataEn-orValidationRule, 582 DataGridCheckBoxColumn, 683 DataGridComboBoxColumn, 684 DataGridHyperlinkColumn, 683 DataGridlemplateColumn, 686 Datalrigger, 313 DatePicker, 214 DiffuseMaterial, 833 DirectionalLight, 834 Dispatcher-Object, 966 Drawing, 376 DrawingBrush, 341; 377 DrawingGroup, 376 Drawinglmage, 377 DrawingVisual, 377 DropShadowEffect, 393 ElementHost, 947; 957 Ellipse, 327; 328 EllipseGeometry, 362 EmissiveMaterial, 833 EventTrigger, 313 ExceptionValidationRule, 581 Expander, 199 FlowDocument, 892 FrameworkElement, 39 FrameworkPropertyMetadata, 123 Geometry, 361; 829 Geometry3D, 829 GeometryDrawing, 376; 830 GeometryGroup, 362; 365 GeometryModel3D, 830 GlyphRunDrawing, 376 GroupBox, 197 GroupStyle, 653 HwndHost, 963 ImageBrush, 341; 345; 347 ImageDrawing, 376 ItemsControl, 40; 613 JumpTask, 726 KeyboardDevice, 154 Line, 333 LinearGradientBrush, 340 LineGeometry, 362 ListBox, 207 LocalPrintServer, 935
1018 Предметный указатель LogicalTreeHelper, 472 MaterialGroup, 833 MatrixTransform, 352 MediaPlayer, 808 Menu, 781 MeshGeometiy3D, 831 ModelUIElement3D, 860 MultiDataTrigger, 313 Multflrigger, 313 NavigationService, 750 Page, 737 Panel, 40; 84 PasswordBox, 206 Path, 361 PathFigure, 368 PathGeometiy, 362; 367 PathSegment, 368 PauseStoryboard, 423 PointLight, 834 Polygon, 335 Polyline, 334 Popup, 192 PrintDialog, 917 PrintQueue, 935 PrintServer, 935 PrintSystemJoblnfo, 935 ProgressBar, 212 RadialGradientBrush, 340; 344 RadioButton, 183; 186 RangeBase, 211 Rectangle, 327 RectangleGeometry, 362 RemoveStoryboard, 423 RepeatButton, 185 ResumeStoryboard, 423 RibbonButton, 800 RibbonCheckBox, 800 RibbonComboBox, 800 RibbonDropDownButton, 800 RibbonLabel, 800 RibbonSeparator, 800 RibbonSplitButton, 800 RibbonlextBox, 800 RibbonToggleButton, 800 RotateTransform, 352 RoutedCommand, 265 RoutedEventArgs, 139 RoutedUICommand, 266 ScaleTransform, 352 ScrollViewer, 194 SeekStoryboard, 423 SetStoryboardSpeedRatio, 423 ShaderEffect, 395 Shape, 39; 326 SkewTransform, 352 SkipStoryboardToFill, 423 Slider, 211 SolidColorBrush, 340 SoundPlayer, 805; 806 SoundPlayerAction, 807 SpecularMaterial, 833 SpotLight, 834 StopStoryboard, 423 StreamGeometry, 362 Style, 306 Tabltem, 197 ThumbButtonlnfo, 731 TimeLine, 414 TbggleButton, 185 TbolTip, 189 ToolTipService, 191 Transform, 830 Transform3D, 830 TransformGroup, 353 TranslateTransform, 352 Trigger, 313 UIElement, 39; 135; 382 UserControl, 521 VideoDrawing, 376; 821 Viewbox, 330 Viewport3D, 828 Viewport3DVisual, 382 Visual, 39; 381; 829 Visual3D, 829 VisualBrush, 341; 350 VisualTreeHelper, 473 Window, 696 WindowsFormsHost, 954 WrirteableBitmap, 381 WriteableBitmap, 396 XpsDocumentWriter, 940 Классы Windows Forms, 948 Клиентский профиль, 43 Ключевой кадр анимация ключевого кадра, 450 сплайновая, 453 плавный, 452 Кнопка, 183; 798 простая, 481 Коллекция ресурсов, 289 Команда, 262; 794 вызов команды напрямую, 273 отключение команд, 274 расширенная, 278 специальная, 278 элементы управления со встроенными командами, 276
Предметный указатель 1019 Компоновка, 81 контейнеры компоновки, 83 координатная с помощью Canvas, 110 свойства компоновки, 87 Конвертер значений, 602; 604 типов, 56 Контейнер, 81; 194 компоновки, 83 Контракт, 978; 983 Кривые Безье, 370 Л Ленты, 780; 791 Линия прямая, 368 м Маршрутизация событий, 133 Мастер Publish Wizard, 1004 Материалы, 847 Меню, 780 базовое, 782 дополнительное, 788 приложения, 795 разделители меню, 785 Метки, 183 Многопоточность, 964 Модель WDDM, 27 однопоточного апартамента (STA), 965 н Навигация по веб-сайтам, 740 о Обновления, 1012 Окно дескрипторы окна, 953 модальное, 946 немодальное, 947 со смешанным содержимым, 952 п Панель быстрого запуска, 802 инструментов, 780; 786 Перетаскивание, 157 Печать, 893; 914 асинхронная, 940 через XPS, 937 Поведение (behavior), 302; 317 модель поведений, 318 присоединяемое, 302 создание поведения, 319 Приведение значений, 129 Привязка данных, 559 форматирование привязанных данных, 600 Привязка элементов, 248 динамическая, 253 к объектам, не являющимся элементами, 258 множественные привязки, 253 удаление привязки, 253 Приложения ХВАР, 760 Прозрачность, 356 как сделать элемент частично прозрачным, 356 маски непрозрачности, 358 Прокрутка программная, 195 р Раскадровка (storyboard), 418 Ресурсы динамические, 291 коллекция ресурсов, 289 неразделяемые, 293 приложения, 294 системы, 295 словари ресурсов, 296 статические, 291 Рефлексия, 976 с Свет источники света, 834 классы источников света, 834 Свойство (property), 120 зависимости (dependency property), 120 компоновки, 87 присоединенное (attached property), 60 Событие (event), 120 ввода, 147 времени существования, 145 заблокированное, 142 клавиатуры, 145 маршрутизируемое (routed event), 133 прямое (direct), 138 пузырьковое (bubbling), 138 туннелируемые (tunneling), 138 мыши, 145 одновременного касания, 145 пера, 145
1020 Предметный указатель прикрепляемое, 142 прямое (direct event), 154 пузырьковое, 139 туннелируемое, 143 Сортировка, 651; 692 Стиль (style), 302 автоматическое применение стилей по типу, 311 наследование стилей, 311 создание на основе другого стиля, 311 т Таблица стилей каскадная (CSS), 302 Текст выравнивание текста, 887 распознавание текста, 824 Текстуры, 849 Технология ClickOnce, 1013 DirectX, 26; 30 GDI/GDI+, 25 Silverlight, 30 User32, 25 Windows Forms, 29 WPF, 26 WPF 4, 40 Тип встроенный, 78 конвертеры типов, 56 Трансформация (transform), 352 анимированная, 442 классы трансформаций, 352 фигур, 354 элементов, 355 Триггер, 194; 302; 313 изменяющий свойства, 484 использующий анимацию, 487 простой, 313 события, 315 Ф Файл создание ассоциации файла, 1013 Фигура, 324 классы фигур, 325 масштабирование фигур, 330 установка размеров и расположения фигур, 328 Фильтрация, 647 Форма модальная, 946 немодальная, 947 Форматирование привязанных данных, 600 "строк, 602; 688 с помощью конвертера значений, 604 X Хост, 987 ш Шаблон, 303; 474 автоматическое применение, 494 вложенные шаблоны, 497 данных (data template), 622 привязки шаблона, 483 элемента управления, 469; 475 оптимизация, 526 по умолчанию, 535 создание, 481 Шрифт, 171 э Экспорт рисунка, 379 Элемент управления (control), 39 DataGrid, 657; 678; 692 ListView, 657; 658 Ribbon, 793 StatusBar, 790 ToolBar, 786 TbolBaiTray, 788 TreeView, 657; 671 WebBrowser, 773 пользовательский, 509 со встроенными командами, 276 списками, 206 текстовый, 202 шаблоны элементов управления, 469 оптимизация, 526 по умолчанию, 535 Элементы управления Windows Forms, 956 я Язык BAML, 49 HLSL, 395 LINQ, 574 XAML, 46 XAML2009, 76 мини-язык геометрии, 372