Экспериментальные ИП

Касание и реакция

Чарльз Петцольд (Charles Petzold)

Загрузить образец кода

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

В своей статье «Multi-Touch Manipulation Events in WPF» в номере «MSDN Magazine» за август я начал исследовать интереснейшую поддержку мультисенсорного ввода (multi-touch support), введенную в Windows Presentation Foundation (WPF) версии 4. События Manipulation служат главным образом для интеграции мультисенсорного ввода в полезные геометрические преобразования и помогают реализовать инерцию.

В той статье я показал два подхода к обработки событий Manipulation для набора элементов Image. В обоих случаях реальные события обрабатывались классом Window. В одной программе были определены обработчики для событий Manipulation элементов, над которыми выполнялись операции. В другой было показано, как переопределить методы OnManipulation, чтобы заставить те же события проходить через дерево визуальных элементов.

Подход с применением собственного класса

Третий подход тоже имеет смысл: собственный класс можно определить для манипулируемых элементов (manipulable elements), и он будет переопределять собственные методы OnManipulation вместо того, чтобы оставлять эту работу элементу-контейнеру. Преимущество такого подхода в том, что вы получаете возможность сделать собственный класс чуток привлекательнее, дополнив его элементом Border или каким-то другим; подобные дополнения также можно использовать для обеспечения обратной визуальной связи при касании пользователем манипулируемого элемента.

Когда программисты — ветераны WPF считают, что им нужно визуально изменять элемент управления в зависимости от событий, они обычно думают об EventTrigger, но современные WPF-программисты должны начинать переход на Visual State Manager. Даже при наследовании от UserControl (я использую как раз такую стратегию) реализация довольно проста.

Приложение, использующее события Manipulation, вероятно, должно обеспечивать базовую визуальную обратную связь при этих же событиях, а не низкоуровневых событиях TouchDown и TouchUp. Применяя события Manipulation, вы начнете создавать визуальную обратную связь с помощью либо ManipulationStarting, либо ManipulationStarted. (На самом деле в данном случае нет никакой разницы, какое именно из этих двух событий вы выберете.)

Однако, экспериментируя с обратной связью, первое, что вы обнаружите, события ManipulationStarting и ManipulationStarted запускаются не при первом касании элемента, а только в момент начала его перемещения. Это поведение — наследие интерфейса перьевого ввода, и вы захотите его изменить, установив следующее подключаемое свойство для манипулируемого элемента:

Stylus.IsPressAndHoldEnabled="False"

Теперь события ManipulationStarting и ManipulationStarted будут срабатывать при первом касании элемента. Чтобы отключить визуальную обратную связь, используйте либо событие ManipulationInertiaStarting, либо событие Manipulation­Completed в зависимости от того, нужно ли вам прекращать обратную связь, когда пользователь поднимает палец от экрана или когда перемещение элемента останавливается из-за инерции. Если вы не применяете инерцию (как и я в этой статье), можете использовать любое событие.

Исходный код, который можно скачать для этой статьи, представляет собой единственное решение Visual Studio с именем TouchAndResponseDemos, содержащее два проекта. В проект FeedbackAndSmoothZ включен собственный производный UserControl с именем ManipulablePictureFrame, реализующий логику манипуляций.

ManipulablePictureFrame определяет единственное свойство типа Child и использует свой статический конструктор для переопределения значений по умолчанию для трех свойств: HorizontalAlignment, VerticalAlignment и крайне важного IsManipulationEnabled. Конструктор экземпляра вызывает InitializeComponent (как обычно), но потом устанавливает RenderTransform элемента управления в MatrixTransform, если у него еще нет такого значения.

При событии OnManipulationStarting класс Manipulable­PictureFrame вызывает:

VisualStateManager.GoToElementState(this, "Touched", false);

а при событии OnManipulationCompleted вызывает:

VisualStateManager.GoToElementState(this, "Untouched", false);

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

  • в методе OnManipulationStarting свойству ManipulationContainer присваивается родитель элемента;
  • метод OnManipulationDelta немного упрощен, потому что манипулируемым элементом является сам объект Manipulable­PictureFrame.

Полный текст файла ManipulablePictureFrame.xaml показан на рис. 1.

Рисунок 1 Файл ManipulablePictureFrame.xaml  

<UserControl x:Class="FeedbackAndSmoothZ.ManipulablePictureFrame"
             xmlns=
       "https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             Stylus.IsPressAndHoldEnabled="False"
             Name="this">
    
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup x:Name="TouchStates">
            <VisualState x:Name="Touched">
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="maskBorder"
                                     Storyboard.TargetProperty="Opacity"
                                     To="0.33" Duration="0:0:0.25" />
                    
                    <DoubleAnimation Storyboard.TargetName="dropShadow"
                                     Storyboard.TargetProperty=          
                  "ShadowDepth"
                                     To="20" Duration="0:0:0.25" />
                </Storyboard>
            </VisualState>
            <VisualState x:Name="Untouched">
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="maskBorder"
                                     Storyboard.TargetProperty="Opacity"
                                     To="0" Duration="0:0:0.1" />

                    <DoubleAnimation Storyboard.TargetName="dropShadow"
                                     Storyboard.TargetProperty=     
                      "ShadowDepth"
                                     To="5" Duration="0:0:0.1" />
                </Storyboard>
            </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>
    
    <Grid>
        <Grid.Effect>
            <DropShadowEffect x:Name="dropShadow" />
        </Grid.Effect>
        
        <!-- Holds the photo (or other element) -->
        <Border x:Name="border" 
                Margin="24" />
        
        <!-- Provides visual feedback -->
        <Border x:Name="maskBorder" 
                Margin="24" 
                Background="White" 
                Opacity="0" />
        
        <!-- Draws the frame -->
        <Rectangle Stroke="{Binding ElementName=this, Path=Foreground}" 
                   StrokeThickness="24" 
                   StrokeDashArray="0 0.9" 
                   StrokeDashCap="Round" 
                   RadiusX="24" 
                   RadiusY="24" />
        
        <Rectangle Stroke="{Binding ElementName=this, Path=Foreground}" 
                   StrokeThickness="8" 
                   Margin="16" 
                   RadiusX="24" 
                   RadiusY="24" />
    </Grid>
</UserControl>

Элемент Border с именем border служит хостом для дочернего элемента класса ManipulablePictureFrame. Вероятно, это будет элемент Image, но не обязательно. Два элемента Rectangle рисуют нечто вроде зубчатой рамки вокруг Border, а второй Border используется для визуальной обратной связи.

Пока элемент перемещается, анимации в ManipulablePictureFrame.xaml слегка «засвечивают» картинку — на самом деле это в большей мере эффект «размывания» — и увеличивают отбрасываемую ею тень, как показано на рис. 2.

Figure 2 A Highlighted Element in the FeedbackAndSmoothZ Program

Рисунок 2 Выделенный элемент в программе FeedbackAndSmoothZ

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

Вы заметите, что при уменьшении и увеличении изображений рамка соответственно сокращается или расширяется. Правильное ли это поведение? Возможно, да. Возможно, нет. Ближе к концу статьи я покажу альтернативный вариант поведения.

Плавные Z-переходы

В программах, которые я показывал в прошлом месяце, касание фотографии приводило к его переводу на передний план. Это был почти самый простой подход, который я только смог придумать, и он требовал настройки новых подключаемых свойств Panel.ZIndex для всех элементов Image.

Краткое напоминание: обычно, когда дочерние элементы в Panel перекрываются, они упорядочиваются от заднего до переднего плана, исходя из их позиции в наборе Children, принадлежащем Panel. Но в классе Panel определено подключаемое свойство ZIndex, которое в конечном счете заменяет индекс дочернего элемента. (Это имя указывает на то, что ось Z перпендикулярна обычной плоскости XY экрана и выходит из экрана.) Элементы с меньшими значениями ZIndex оказываются на заднем плане, а более высокое значение ZIndex выводит элемент на передний план. Если у двух и более перекрытых элементов одинаковые значения ZIndex (что и наблюдается по умолчанию), вместо них используются индексы этих элементов в наборе Children.

В более ранних программах я использовал следующий код для задания новых значений Panel.ZIndex, где переменная element является элементом, которого коснулся пользователь, а pnl (типа Panel) — родитель этого элемента и других дочерних элементов одного уровня:

for (int i = 0; i < pnl.Children.Count; i++)
     Panel.SetZIndex(pnl.Children[i],
        pnl.Children[i] == element ? pnl.Children.Count : i);

Этот код гарантирует, что элемент, которого коснулся пользователь, получает самое высокое значение ZIndex и появляется на переднем плане.

К сожалению, такой элемент выскакивает на передний план неестественно мгновенно — нет плавности. Иногда при этом другие элементы тоже меняют свою позицию в общем Z-порядке. (Если у вас есть четыре перекрытых элемента и вы касаетесь первого, его ZIndex становится равным 4, а остальные получают значения 1, 2 и 3. Теперь, если вы коснетесь четвертого элемента, первый вновь получит ZIndex, равный 0, и резко уйдет на задний план.)

Моей целью было избежать внезапных скачков элементов на передний или задний план. Я хотел добиться большей плавности, чтобы имитировать процесс выскальзывания фотоснимка из нижней части пачки и последующего перемещения поверх нее. Мысленно я стал рассматривать такие переходы как «плавный Z-алгоритм».Ничто не должно было мгновенно выскакивать на передний план или уходить на задний, но по мере смещения элемент в итоге оказывался бы поверх остальных элементов. (Альтернативный подход реализован в элементе управления ScatterView, который можно скачать с сайта CodePlex по ссылке scatterview.codeplex.com/releases/view/24159. ScatterView определенно предпочтительнее, если вы имеете дело с большим количеством элементов.)

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

Законченный вид DSV показан на рис. 3. При этом подходе крайне важно определять, пересекаются ли визуально два элемента одного уровня (sibling elements). Решить эту задачу можно несколькими способами, но я использовал код (в методе AreElementsIntersecting), который показался мне самым простым. Он повторно использует два объекта RectangleGeometry, хранящиеся как поля.

Рисунок 3 Плавный Z-алгоритм

// BumpUpZIndex with reusable SortedDictionary object
SortedDictionary<int, UIElement> childrenByZIndex = new 
SortedDictionary<int, UIElement>();

void BumpUpZIndex(FrameworkElement touchedElement, UIElementCollection siblings)
{
  // Make sure everybody has a unique even ZIndex
  for (int childIndex = 0; childIndex < siblings.Count; childIndex++)
  {
        UIElement child = siblings[childIndex];
        int zIndex = Panel.GetZIndex(child);
        Panel.SetZIndex(child, 2 * (zIndex * siblings.Count + childIndex));
  }

  int zIndexNew = Panel.GetZIndex(touchedElement);
  int zIndexCantGoBeyond = Int32.MaxValue;

  // Don't want to jump ahead of any intersecting elements that are on top
  foreach (UIElement child in siblings)
        if (child != touchedElement && 
            AreElementsIntersecting(touchedElement, (FrameworkElement)child))
        {
            int zIndexChild = Panel.GetZIndex(child);

            if (zIndexChild > Panel.GetZIndex(touchedElement))
                zIndexCantGoBeyond = Math.Min(zIndexCantGoBeyond, zIndexChild);
        }

  // But want to be in front of non-intersecting elements
  foreach (UIElement child in siblings)
        if (child != touchedElement && 
            !AreElementsIntersecting(touchedElement, (FrameworkElement)child))
        {
            // This ZIndex is odd, hence unique
            int zIndexNextHigher = 1 + Panel.GetZIndex(child);
            if (zIndexNextHigher < zIndexCantGoBeyond)
                zIndexNew = Math.Max(zIndexNew, zIndexNextHigher);
        }

  // Now give all elements indices from 0 to (siblings.Count - 1)
  Panel.SetZIndex(touchedElement, zIndexNew);
  childrenByZIndex.Clear();
  int index = 0;

  foreach (UIElement child in siblings)
        childrenByZIndex.Add(Panel.GetZIndex(child), child);

  foreach (UIElement child in childrenByZIndex.Values)
        Panel.SetZIndex(child, index++);
    }

// Test if elements are intersecting with reusable //       
RectangleGeometry objects
RectangleGeometry rectGeo1 = new RectangleGeometry();
RectangleGeometry rectGeo2 = new RectangleGeometry();

bool AreElementsIntersecting(FrameworkElement element1, FrameworkElement         
 element2)
{
 rectGeo1.Rect = new 
  Rect(new Size(element1.ActualWidth, element1.ActualHeight));
 rectGeo1.Transform = element1.RenderTransform;

 rectGeo2.Rect = new 
  Rect(new Size(element2.ActualWidth, element2.ActualHeight));
 rectGeo2.Transform = element2.RenderTransform;

 return rectGeo1.FillContainsWithDetail(rectGeo2) != IntersectionDetail.Empty;
}

Метод BumpUpZIndex выполняет довольно много работы. Сначала он проверяет, что все дочерние элементы одного уровня имеют уникальные значения ZIndex и что эти значения четные. Новый ZIndex для манипулируемого элемента не может быть выше любого значения ZIndex для любого элемента, который пересекается с ним и в данный момент находится поверх манипулируемого элемента. Учитывая это ограничение, код пытается присвоить новый ZIndex, превышающий все ZIndex элементов, которые не пересекаются с манипулируемым элементом.

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

В плавном Z-алгоритме есть пара проколов. Если манипулируемый элемент пересекает элемент A, но не пересекает элемент B, его нельзя плавно вывести поверх B, когда B имеет более высокое значение ZIndex, чем A. Кроме того, не учтена возможность манипулирования двумя и более
элементами одновременно.

Манипуляция без преобразований

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

Этот подход без преобразований включает использование Canvas в качестве контейнера для манипулируемых элементов. Затем вы можете перемещать элементы в Canvas, задавая значения подключаемых свойств Canvas.Left и Canvas.Top. Изменение размера элементов требует операций над свойствами Height и Width; при этом в них записываются либо те же относительные (в процентах) значения Scale, что и ранее, либо абсолютные значения Expansion.

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

Этот подход продемонстрирован в проекте NoTransformManipulation, включающем производный UserControl с именем NoTransformPictureFrame, который реализует логику манипуляций.

Рамка картинки в этом новом классе не столь замысловатая, как в ManipulablePictureFrame. В предыдущих случаях я использовал точечную линию, чтобы получить зубчатую рамку. Если вы сделаете такую рамку больше, чтобы в ней уместился увеличенный дочерний элемент, но не примените преобразование, толщина линии останется прежней, а количество точек в точечной линии возрастет! Это выглядит очень странно и вряд ли годится для реальной программы. Рамка картинки в новом файле — это простой Border со скругленными углами.

В файле MainPage.xaml проекта NoTransformManipulation на Canvas собраны пять объектов NoTransformPictureFrame; все они содержат элементы Image и все имеют уникальные значения подключаемых свойств Canvas.Left и Canvas.Top. Кроме того, я задал для каждого NoTransformPictureFrame значение Width, равное 200, но не настроил Height. При изменении размеров элементов Image обычно лучше всего указывать значение только по одной оси и позволить элементу самому выбирать значение по другой оси для сохранения корректного соотношения сторон.

Содержимое файла NoTransformPictureFrame.xaml.cs по своей структуре аналогично коду ManipulablePictureFrame с тем исключением, что в нем нет кода преобразования. Переопределенная версия OnManipulationDelta настраивает подключаемые свойства Canvas.Left и Canvas.Top, а затем использует значения Expansion для увеличения значения свойства Width элемента. При масштабировании приходится немного фокусничать, так как нужно подгонять коэффициенты пересчета (translation factors) для согласования с центром масштабирования.

Изменения потребовались и в методе AreElementsIntersecting, играющем важнейшую роль в плавных Z-переходах. В предыдущей версии метода конструировались два объекта RectangleGeometry, отражающие не преобразованные размеры двух элементов, а затем применялись параметры двух RenderTransform. Способ замены показан на рис. 4. В новой версии эти объекты RectangleGeometry основаны исключительно на реальных размерах смещения элемента, определяемого по значениям подключенных свойств Canvas.Left и Canvas.Top.

Рисунок 4 Альтернативный плавный Z-алгоритм для манипуляций без преобразований

bool AreElementsIntersecting(FrameworkElement element1, FrameworkElement element2)
{
    rectGeo1.Rect = new Rect(Canvas.GetLeft(element1), Canvas.GetTop(element1),
      element1.ActualWidth, element1.ActualHeight);

    rectGeo2.Rect = new Rect(Canvas.GetLeft(element2), Canvas.GetTop(element2),
      element2.ActualWidth, element2.ActualHeight);

    return rectGeo1.FillContainsWithDetail(rectGeo2) != IntersectionDetail.Empty;
}

Оставшиеся вопросы

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

Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор журнала «MSDN Magazine». Сейчас работает над книгой «Programming Windows Phone 7», которая будет опубликована как бесплатная электронная книга осенью 2010 г. Ее предварительный вариант можно скачать уже сейчас с его сайта charlespetzold.com.

Выражаю благодарность за рецензирование статьи экспертам  Дугу Креймеру (Doug Kramer) и Роберту Леви (Robert Levy).