本文章是由機器翻譯。

UI 最前線

觸控與回應

Charles Petzold

下載代碼示例

程式設計是一門工程學科,而不是一門科學或數學分支,因此一個問題只有一種正確解決方案的情況非常少。多樣性和變化是常有的事,探討這些替代方法而不是專注于一種特定的方法通常令人大開眼界。

In my article “Multi-Touch Manipulation Events in WPF” in the August issue of MSDN Magazine, I began exploring the exciting multi-touch support introduced into version 4 of the Windows Presentation Foundation (WPF).操作事件主要用於將多點觸控輸入整合到有用的幾何轉換以及説明實現延時。

在那篇文章中,我介紹了兩種相關方法來處理針對一系列 Image 元素的操作事件。在兩種情況下,實際事件都由 Window 類處理。一個程式定義了用於被操作元素的操作事件的處理常式。另一種方法說明了如何重寫 OnManipulation 方法,以便使相同事件通過視覺化樹進行路由。

自訂類方法

第三種方法也是可行的:可以為被操作元素定義一個重寫其自己的 OnManipulation 方法的自訂類,而不是將此任務留給容器元素來完成。該方法的優點是您可以使用 Border 元素或其他元素修飾自訂類,從而使其更具有吸引力;這些修飾還可用於在使用者觸摸可操作元素時提供可視回饋。

當經驗豐富的 WPF 程式師確定他們需要根據事件對控制項進行可視更改時,他們可能會想到 EventTrigger,但 WPF 程式師需要開始過渡到使用視覺狀態管理器。即使是從 UserControl 派生(這是我將使用的策略),實現起來也相當容易。

使用操作事件的應用程式或許應使可視回饋基於那些相同事件,而不是基於底層 TouchDown 和 TouchUp 事件。使用操作事件時,您需要使用 ManipulationStarting 或 ManipulationStarted 事件開始可視回饋。(您為此任務選擇哪個事件實際上沒有任何差別。)

但是,體驗這種回饋時,您首先會發現第一次觸摸某個元素時不會觸發 ManipulationStarting 和 ManipulationStarted 事件,只有在該元素開始移動時才會觸發這些事件。此行為是筆針介面遺留下來的,您需要通過對被操作元素設置以下附加屬性來進行更改:

Stylus.IsPressAndHoldEnabled="False"

現在,第一次觸摸某個元素時,將會觸發 ManipulationStarting 和 ManipulationStarted 事件。 您需要使用 ManipulationInertiaStarting 或 Manipulation­Completed 事件關閉可視回饋,這取決於您是希望在使用者的手指離開螢幕時結束回饋,還是希望在元素由於延時而停止移動後結束回饋。 如果您未使用延時(就像我不會在本文中使用一樣),則使用哪個事件無關緊要。

本文的可下載代碼包含在一個名為 TouchAndResponseDemos 的 Visual Studio 解決方案中,該解決方案包括兩個專案。 第一個專案的名稱為 FeedbackAndSmoothZ,它包括一個名為 ManipulablePictureFrame 的自訂 UserControl 派生物,用於實現操作邏輯。

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 物件自身。

图 1 顯示了完整的 ManipulablePictureFrame.xaml 檔。

圖 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”的邊框用於承載 ManipulablePictureFrame 類的子類。該子類可能是 Image 元素,但不必一定如此。兩個 Rectangle 元素在邊框四周繪製了一種類型為“圓齒形”的相框,並且第二個邊框用於可視回饋。

移動某個元素時,ManipulablePictureFrame.xaml 中的動畫會使圖片略為“變亮”(實際上這更像是“沖蝕”效果)並增加投影,如圖 2 所示。

圖 2 FeedbackAndSmoothZ 程式中突出顯示的元素

在觸摸事件期間,幾乎任何類型的簡單突出顯示都可以提供可視回饋。但是,如果您使用的是可以觸摸和操作的小元素,您將希望在觸摸這些元素時使它們變大,以便不會被使用者的手指完全遮蓋。(另一方面,如果您還允許使用者調整元素的大小,則您不會希望使元素變得更大來提供可視回饋。操作元素使其變為期望的大小,然後在您的手指離開螢幕時使元素略為縮小,這讓人很為難!)

您將會注意到,在使圖像縮小和變大時,相框也會相應地縮小或擴大。這種行為正確嗎?可能正確。也可能不正確。在本文將要結束時我會介紹這種行為的替代行為。

平滑 Z 過渡

在我上個月介紹的程式中,觸摸照片會導致該照片跳到前景。這正是我能想到的最簡單方法,它要求對所有 Image 元素設置新的 Panel.ZIndex 附加屬性。

簡單介紹一下:通常,當 Panel 的子項重疊時,它們將按照其在 Panel 的 Children 集合中的位置從背景到前景排列。但是,Panel 類定義了一個名為 ZIndex 的附加屬性,可以有效地取代子索引。(該名稱暗指與螢幕的傳統 XY 平面垂直的 Z 軸,從概念上說它是從螢幕移出的。)ZIndex 值較小的元素將位於背景中,而 ZIndex 值較大會將元素置於前景中。如果兩個或多個重疊的元素具有相同的 ZIndex 設置(預設情況下即是如此),則將使用 Children 集合中這些元素的子索引來確定它們的排列順序。

在先前的程式中,我使用了以下代碼來設置新的 Panel.ZIndex 值,其中的變數元素是正被觸摸的元素,而 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 且顯示在前景中。

遺憾的是,被觸摸的元素突然跳到前景,這種移動相當不自然。 有時,其他元素會同時交換位置。 (如果有四個重疊的元素並且您觸摸第一個元素,則該元素的 ZIndex 值將為 4,而其他元素的 ZIndex 值將分別為 1、2 和 3。 現在,如果您觸摸第四個元素,則第一個元素的 ZIndex 值將回到 0,並且會突然顯示在所有其他元素的後面。)

我的目的就是要避免元素突然對齊到前景和背景。 我希望獲得一種更平滑的效果,類似于一張照片從一堆照片下麵滑出然後滑落到最上面的過程。 我頭腦中開始將這些過渡想成是“平滑 Z”。沒有任何元素會跳到前景或背景,但當您四處移動元素時,該元素最終會位於所有其他元素的上方。 (ScatterView 控制項中實現了一種備用方法,可從網址為 scatterview.codeplex.com/releases/view/24159 的 CodePlex 中下載。 處理大量專案時,ScatterView 肯定是首選。)

實現此演算法時,我自己設置了一些條件。 首先,我不想維護從一個移動事件到下一個移動事件的狀態資訊。 也就是說,我不想分析被操作元素先前是否與另一個元素相交,但是否不再相交。 其次,我不想在 ManipulationDelta 事件期間執行記憶體分配,因為可能有大量這種工作。 第三,為了避免過於複雜,我想將相對 ZIndex 的更改僅限於被操作元素。

图 3 顯示了完整演算法。 該方法的關鍵點在於確定兩個同級元素在視覺上是否相交。 有幾種方法可以解決上述問題,但我使用的代碼(在 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 的 ZIndex 值大於元素 A 的 ZIndex 值時,被操作元素將無法滑動到元素 B 的上方。 此外,尚不提供對同時處理
兩個或多個元素的特殊支援。

不涉及轉換的操作

在目前為止我介紹的所有示例中,都使用了隨 ManipulationDelta 事件提供的資訊來更改被操作元素的 RenderTransform。 這不是唯一的選擇。 事實上,如果您不需要旋轉,則可以實現根本不涉及任何轉換的多點觸控操作。

這種“無轉換”方法涉及使用畫布作為被操作元素的容器。 然後,您可以通過設置 Canvas.Left 和 Canvas.Top 附加屬性在畫布上移動這些元素。 更改元素的大小要求使用先前已使用的相同百分比 Scale 值或絕對 Expansion 值來操作 Height 和 Width 屬性。

此方法的一個獨特優勢是您可以使用邊框來修飾被操作元素,並且在您更改元素大小時,該邊框自身不會變大和變小。

這種技術在 NoTransformManipulation 專案中進行了演示,該專案包括一個名為 NoTransformPictureFrame 的 UserControl 派生物,用於實現操作邏輯。

此新類中的相框遠不及 ManipulablePictureFrame 中的相框那麼精美。 先前的相框使用了虛線來產生圓齒形效果。 如果使這種相框變大以容納更大的子項,但不應用轉換,則線條粗細將保持不變,但虛線中的點數將會增加! 這看起來非常奇怪,並且對於實際程式來說,可能會過於讓人分心。 新檔中的相框就是一個圓角的簡單邊框。

在 NoTransformManipulation 專案的 MainPage.xaml 檔中,畫布上聚集了五個 NoTransformPictureFrame 物件,所有這些物件都包含 Image 元素並且都具有唯一的 Canvas.Left 和 Canvas.Top 附加屬性。 此外,我還將每個 NoTransformPictureFrame 的寬度指定為 200,但未指定高度。 調整 Image 元素的大小時,通常最好只指定一個維度,並讓元素選擇自己的其他維度以保持合適的長寬比。

NoTransformPictureFrame.xaml.cs 檔的結構類似于 ManipulablePictureFrame 代碼,但前者不需要轉換代碼。 OnManipulationDelta 重寫可調整 Canvas.Left 和 Canvas.Top 附加屬性並使用 Expansion 值來增大元素的 Width 屬性。 當縮放功能生效時,只需要一點點技巧,因為需要調整轉換因數以適應縮放的中心。

此外,還需要對在平滑 Z 過渡中發揮巨大作用的 AreElementsIntersecting 方法進行更改。 先前的方法構造了兩個 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;
}

遺留問題

在我討論操作事件時,我忽略了一個重要功能,而這個不容忽視的功能已越來越強大。該功能就是延時,我將在下一期中進行探討。

Charles Petzold 是《MSDN 雜誌》的長期特約編輯。他目前正在撰寫《Programming Windows Phone 7》,該書將在 2010 年秋季作為可免費下載的電子書發佈。現在,已通過其網站 charlespetzold.com.提供了預覽版本。

衷心感謝以下技術專家對本專欄的審閱: Doug Kramer 和 Robert Levy