本文章是由機器翻譯。

UI 最前線

觸控式控制項的錯綜複雜之處

Charles Petzold

下載代碼示例

小時候,我會為了知道烤箱怎麼工作而把它拆得七零八落。多年以後我畢業了,開始拆起了作業系統和應用程式,也就是對它們進行反彙編。

現在,我已經不怎麼拆東西了。但有時,當我看到特別有趣的 UI 的時候,會嘗試瞭解自己如何能夠編寫這樣的 UI。當然,這本是一個工程工作,但我還是喜歡將它比作一門藝術,自己就是一名藝術學徒,要去藝術館把世界名畫自己畫一遍。當然,在我自己的代碼中,我力求簡化,並要充分利用已有的元素和控制項。但在多數情況下,我願意給自己來些挑戰,希望從中學到一些新技能。

我最近一直在研究 Windows Phone 7,並對其中的日期和時間設置頁面很著迷,如圖 1所示。這些饒有趣味的觸摸介面控制項(尚未公開推出)讓我怦然心動。[在本文刊登之後,在 Silverlight forWindows Phone 工具包中發佈了這些控制項。.可以從 silverlight.codeplex.com/releases/view/52297 下載該工具包。— 編者按。

 


圖 1 Windows Phone 7 日期和時間選取器

我開始想:換作是我,該如何編寫這些控制項的代碼?在過去幾期文章中,我一直在探討 Windows Presentation Foundation (WPF) 中的多點觸控,因此在我複製這些介面的首次嘗試中決定以該平臺為目標。如果成功,我會考慮將代碼移至 Silverlight。

控制項探討

在首次導航至 Windows Phone 7 中的一個日期或時間頁面時,會在頁面中心的灰色方塊中顯示當前設置。觸摸其中一個方塊,會在其上方和下方彈出其他選項清單。此清單通常迴圈顯示,如圖 1所示,十二月後面是一月。月中的日以及小時和分鐘也以迴圈清單形式顯示。年份採用非迴圈清單,範圍從 1601 年到 3000 年;AM 或 PM 選擇也採用非迴圈清單(見圖)。

與清單方塊明顯不一樣!傳統清單方塊高亮顯示當前選擇的專案,在滾動清單方塊時,選擇的專案有時會完全滾動到視圖之外。相比之下,這裡的日期和時間控制項始終在中心顯示選定專案,我開始將這些中心區域視為一類“視窗”,清單的功能則類似于舊式老虎機的機械卷軸,我開始將其稱為“選擇帶”。

這些控制項看似以三種不同方式回應觸控:

  • 如果您只輕擊選擇帶中的另一個可見專案(如圖 1中的月份“12”),則在手指離開螢幕時,該專案將高亮顯示並移動到中心視窗區域。
  • 除了輕擊,也可以通過指尖向上或向下移動一個專案選擇帶。在此期間,選擇帶內的任何專案都不會高亮顯示。只要抬起指尖,與視窗最近的專案便會高亮顯示,並進入中心。
  • 第三種介面類型涉及慣性功能。如果專案選擇帶在指尖離開螢幕時還在移動,它將繼續移動,但速度會變慢。在它即將停止時,與視窗最近的專案便會高亮顯示,並移至中心。如有必要,選擇帶有時會在停止之前的瞬間反轉方向。我不得不承認,這種細微的效果十分自然,這也是我在複製這些控制項時遇到的比較“有趣”的挑戰之一。

整體體系結構

這是專案迴圈清單提出的另一個“有趣”挑戰,也就是 12 月之後是 1 月,12:00 之後是 1:00。這些迴圈選擇帶是設計中非常關鍵的方面,特別是在結合慣性功能使用時。選擇帶可向任一方向轉動,慣性會將其推進到選擇帶中的任何專案,而不會無果而終。

各種想法在心裡徘徊數日,最後還是覺得自訂面板是最佳的迴圈清單解決方案,名稱 WrappableStackPanel 暗示了該面板的含義。這樣的面板有時會按自上而下的順序放置其子級,有時會以第一個子級之外的子級作為開始,並將較早的子級放在較晚的子級之後。當然,WPF 中禁止呈現特定子級的多個版本,所以堆疊始終會有明確的終點。

WrappableStackPanel 需要一個屬性(例如,名為 StartIndex 的屬性)來表示應在面板頂部顯示的子級的索引,其餘子級按順序跟隨並迴圈回子級零。通常不會有子級精確地與面板頂部對齊。最上面的子級通常部分位於面板頂部之上。這意味著 StartIndex 屬性將為浮點值,而非整數。

但這也意味著整個控制項可能以兩種不同的模式工作。一種模式需要用 WrappableStackPanel 承載專案,其中 WrappableStackPanel 的位置相對於控制項是固定的,面板負責處理子級相對於面板的定位。另一種模式適用于使用常規 StackPanel 即可滿足需要的情況(例如,對於“年份”或 AM/PM 選擇帶);在此模式中,子級相對於 StackPanel 是固定的,StackPanel 相對於控制項滾動。

此控制項是否派生自清單方塊?我決定不這樣做。清單方塊有自身的選擇邏輯,而我需要的控制項具有不同類型的選擇邏輯。如果從清單方塊派生,我可能會受現有選擇邏輯的羈絆,得不償失。

但我知道,我希望控制項維護自己的專案集合,特別是要允許我在 XAML 中定義一個用於顯示這些專案的範本。這個需求表明需要 ItemsControl 的加入。ItemsControl 是 Selector 的父類,ListBox 和 ComboBox 均從中派生。在無需選擇邏輯或在由程式師處理自訂選擇邏輯的情況下,ItemsControl 是用於顯示集合的順理成章的選擇。這正是我所需要的。

ListBox 的預設控制項範本包括一個 ScrollViewer;ItemsControl 則不包括。如果需要滾動 ItemsControl 顯示的專案,則需要將整個 ItemsControl 本身放在 ScrollViewer 中。

我知道無法將現有的 ScrollViewer 用於此任務。現有 ScrollViewer 雖然可以處理慣性功能,但不包含用於將特定專案至於中心位置的任何邏輯。我在實際編碼時,第一個嘗試是創建 ScrollViewer 的替代控制項 WindowedScrollViewer,用它託管一個包含月份、月中的日或年份的 ItemsControl。在其 MeasureOverride 方法中,此 WindowedScrollViewer 可以輕鬆獲取 IemsControl 的完整大小以及所顯示專案個數,因此可以獲得各個專案的統一高度,進而使用此資訊根據 Manipulation 事件相對於自身移動 ItemsControl。

當 ItemsControl 不需要顯示可包裝的專案選擇帶時,此方法效果不錯。在這些情況下,我需要將 ItemsControl 的 ItemsPanel 屬性設置為引用 WrappableStackPanel 的 ItemsPanelTemplate。重要問題在於 WindowedScrollViewer 如何通過位於可視樹中間的 ItemsControl 與 WrappableStackPanel 進行通信。

這個問題看起來很難解決。而且,我慢慢認識到,我的 WindowedScrollViewer 不像常規的 ScrollViewer 那樣具有自己的可視效果。我只能相對於 ItemsControl 自身滑動 ItemsControl。

我決定放棄這種方法,改為從 ItemsControl 中派生並在其中完成所有任務。我將派生的類稱為 WindowedItemsControl,它可實現選擇邏輯、操作處理、滾動以及與可選 WrappableStackPanel 的通信。

本文的可下載原始程式碼是一個名為 TouchDatePickerDemo 的解決方案,其中包括一個具有該名稱的專案和一個名為 Petzold.Controls 的庫專案。該庫專案中包括 WindowedItemsControl(分為三個檔)、WrappableStackPanel 和一個名為 TouchDatePicker 的 UserControl 派生類。TouchDatePicker 將三個 WindowedItemsControl(用於月、日和年)與一個 DatePresenter 類和一個名為 DateTime 的屬性組合起來。


图 2 顯示了運行中的 TouchDatePicker。TextBlock 與 DateTime 屬性綁定,以顯示當前選擇的日期。

圖 2 運行中的 TouchDatePicker 控制項

TouchDatePicker 基本上是一個包含三列的網格,三列分別顯示月、日和年。图 3 顯示了第一個 WindowedItemsControl 用於處理月份的 XAML。DataContext 是 DatePresenter 類型的物件,其中具有被 WindowedItemsControl 標記本身引用的 AllMonths 和 SelectedMonth 屬性。AllMonths 屬性是 MonthInfo 物件的集合,SelectedMonth 也屬於 MonthInfo 類型。MonthInfo 類具有名為 MonthNumber 和 MonthName 的屬性,您將看到 DataTemplate 中將引用這些屬性。WrappableStackPanel 在最下麵被引用。

圖 3 TouchDatePicker 控制項的三分之一

<local:WindowedItemsControl x:Name="monthControl"
  Grid.Column="0"             
  ItemsSource="{Binding AllMonths}"
  SelectedItem="{Binding SelectedMonth, Mode=TwoWay}"
  IsActiveChanged="OnWindowedItemsControlIsActiveChanged">

  <local:WindowedItemsControl.ItemTemplate>
    <DataTemplate DataType="local:MonthInfo">
      <Border Width="60" Height="60"
        BorderThickness="1"
        BorderBrush="{Binding ElementName=monthControl,
        Path=Foreground}"
        Margin="2">
        <Grid>
          <Rectangle Fill="{DynamicResource 
            {x:Static SystemColors.ControlLightBrushKey}}">
             <Rectangle.Visibility>
              <MultiBinding Converter="{StaticResource multiConverter}">
               <Binding />
                 <Binding ElementName="monthControl" Path="SelectedItem" />
              </MultiBinding>
            </Rectangle.Visibility>
          </Rectangle>

          <TextBlock Text="{Binding MonthNumber, StringFormat=D2}"
            VerticalAlignment="Center"
            FontSize="24"
            FontWeight="Bold" />

          <TextBlock Text="{Binding MonthName}" 
            VerticalAlignment="Bottom"
            FontSize="10" />

          <Rectangle Fill="#80FFFFFF">
            <Rectangle.Visibility>
              <MultiBinding Converter="{StaticResource multiConverter}"
                ConverterParameter="True">
                <Binding />
                <Binding ElementName="monthControl" Path="SelectedItem" />
              </MultiBinding>
            </Rectangle.Visibility>
          </Rectangle>
        </Grid>
                        
       <Border.Visibility>
         <MultiBinding Converter="{StaticResource multiConverter}">
           <Binding />
           <Binding ElementName="monthControl" Path="SelectedItem" />
           <Binding ElementName="monthControl" Path="IsActive" />
         </MultiBinding>
       </Border.Visibility>
     </Border>
    </DataTemplate>
  </local:WindowedItemsControl.ItemTemplate>

  <local:WindowedItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <local:WrappableStackPanel IsItemsHost="True" />
    </ItemsPanelTemplate>
  </local:WindowedItemsControl.ItemsPanel>
</local:WindowedItemsControl>

選擇邏輯

ItemsControl 所維護的 Items 集合經過定義可以接受類型物件的專案。 其中每個專案都基於 DataTemplate 來呈現,DataTemplate 設置為 ItemsControl 的 ItemTemplate 屬性。

ListBox 的功能比之略多。 ListBox 在一個 ListBoxItem 控制項中包裝它的每個專案,以實現選擇邏輯。 ListBoxItem 具有 IsSelected 屬性及 Selected 和 Unselected 事件,並負責顯示專案的選中狀態。

我使用 WindowedItemsControl 的目標是實現無任何包裝的選擇邏輯,並且它的實現方式須允許我完全在 XAML 中定義選定專案的可視效果。 為説明實現此目的,WindowedItemsControl 有一個 SelectedIndex 屬性(它在內部使用,以確定專案放置位置)和一個 SelectedItem 屬性,SelectedItem 是 Items 集合中與 SelectedIndex 對應的特定物件。

圖 3 所示的 XAML 中,在 DateTemplate 屬性中引用此 SelectedItem 屬性的方式如下:

<Binding ElementName="monthControl" Path="SelectedItem" />

在同一 DataTemplate 中,可用更簡單的 Binding 運算式來引用專案本身:

<Binding />

如果這兩個綁定引用的物件相同,則 DataTemplate 應通過某種特殊標記來指示選定的專案,在本例中為灰色背景網底和非灰顯文本。

在 XAML 中通常無法確定兩個綁定是否引用了同一個物件,但我為此專門編寫了一個多綁定轉換器。 此轉換器稱為 EqualsToVisibilityMultiConverter,它根據兩個物件是否相同返回 Visibility.Visible 或 Visibility.Hidden。 下麵演示了如何在 DataTemplate 中將其用於灰色背景:

<Rectangle Fill="{DynamicResource {x:Static SystemColors.ControlLightBrushKey}}">
    <Rectangle.Visibility>
        <MultiBinding Converter="{StaticResource multiConverter}">
            <Binding />
            <Binding ElementName="monthControl" Path="SelectedItem" />
        </MultiBinding>
    </Rectangle.Visibility>
</Rectangle>

它很管用!

遺憾的是,當我需要此綁定轉換器執行其他職責時,它會變得比較複雜。 我需要另一個 Rectangle 來灰顯未選定的專案,並希望對選定專案隱藏此 Rectangle,所以我添加了一個轉換器參數。 當此參數設置為“true”時,將交換來自多綁定轉換器的 Visibility 返回值。

但是,我還需要一個功能切換,以便只顯示選定專案,或者顯示與 IsActive 屬性關聯的整個專案選擇帶。 如果 IsActive 為 true,則需要顯示所有專案;如果 IsActive 為 false,則只需要顯示選定專案。 我在多綁定轉換器中添加了一個用於第三個布林類型物件的工具。 如果為 true,則多綁定轉換器始終返回 Visibility.Visible(除非該參數設置為“true”,此時會返回 Visibility.Hidden)。 此工具用於使整個專案可見或隱藏:

<Border.Visibility>
    <MultiBinding Converter="{StaticResource multiConverter}">
        <Binding />
        <Binding ElementName="monthControl" Path="SelectedItem" />
        <Binding ElementName="monthControl" Path="IsActive" />
    </MultiBinding>
</Border.Visibility>

雖然實際的多綁定轉換器並不複雜,但它的功能選擇有些混亂。 無論如何,我無需使用任何包裝,便能夠完全在 XAML 中實現選擇可視效果,這讓我很高興。

與面板通信

如果通過 ItemsPanel 屬性設置的面板是 WrappableStackPanel,則 WindowedItemsControl 需要實現不同的滾動邏輯。 那麼,如何確定是否為這種面板? 如何向此面板傳遞資訊?

確定面板類型的方法非常簡單:重寫 OnItemsPanelChanged 屬性。 只要 ItemsPanel 屬性發生更改,便對舊 ItemsPanelTemplate 和新 ItemsPanelTemplate 調用此方法。 對此新範本調用 LoadContent,您將在範本中獲取一個面板實例。

但是,LoadContent 返回的面板與 ItemsControl 正在實際使用的面板不是同一實例! 此方法只可用於確定面板是否屬於特定類型,不能用於與該面板通信。

如果 ItemsControl 希望設置該面板的屬性,需要使用其他方法。 它可通過遍歷可視樹來查找實際的面板,也可以使用可繼承的事件。

我選擇後面這種方法。 在 WindowedItemsControl 中,我定義了一個由相關性屬性支援的 StartIndex 屬性,並為其設置了 FrameworkPropertyMetadataOptions 的 Inherits 標記(眾所周知,要使此 Inherits 標記起作用,應註冊附加屬性而非普通的相關性屬性)。

如下所示:

public static readonly DependencyProperty StartIndexProperty =
    DependencyProperty.RegisterAttached("StartIndex",
        typeof(double),
        typeof(WindowedItemsControl),
        new FrameworkPropertyMetadata(0.0, 
            FrameworkPropertyMetadataOptions.Inherits));

當 ItemsPanel 為 WrappableStackPanel 時,WindowedItemsControl 只需為此 StartIndex 屬性設置一個值便可實現滾動。

WrappableStackPanel 向 StartIndex 附加屬性添加一個所有者,並設置 FrameworkPropertyMetadataOptions 標記 AffectsArrange。 ArrangeOverride 方法根據自 ItemsControl 繼承的 StartIndex 屬性值,確定面板頂部應顯示的專案以及該專案超出面板上方的尺寸。

Manipulation 事件

恰如我開始的懷疑,實現實際的 Manipulation 事件將是整個工作中最艱難的部分。 結果表明,我對三種類型觸控事件的分析是正確的。 這三種事件須分別進行處理。 (WindowedItemsControl.Manipulation.cs 檔中提供了代碼。)

用於確定這三種觸控事件的大部分工作都在 OnManipulationInertiaStarting 重寫方法中實現。 此事件指示使用者的指尖已離開螢幕。

如果在 ManipulationStarting 事件之後是 ManipulationInertiaStarting 事件,並沒有任何干預 ManipulationDelta 事件,則表示操作為一次點擊。 ManipulationInertiaStarting 事件中的代碼確定受擊專案移到中心需要經過的距離,然後確定在固定時間(設置為 250 毫秒)內完成此任務所需的速度。 然後,根據此慣性資料,為事件參數的 TranslationBehavior 屬性初始化 DesiredDisplacement 和 InitialVelocity 屬性。

請記住,使用者只是點擊了專案。 使用者並未移動專案,因此不存在實際速度或慣性! 但 ManipulationInertiaStarting 事件允許設置慣性參數,以強制未移動的物件也移動起來。 在處理 Manipulation 事件的上下文中,此方法比使用動畫或 CompositionTarget.Rendering 進行滾動要簡單得多。

如果使用者已手動移動選擇帶,實現邏輯也很相似。但由於沒有足夠的速度將選擇帶移過新選擇的專案,因此選擇了哪個專案會很明顯。 而且,只需設置 DesiredDisplacement 和 InitialVelocity 屬性,便可將其滑動入位。

當存在實際的慣性時會出現真正混亂的代碼,直到速度降低並且滾動幾乎停止時,才會知道新選擇的專案是哪個。 此時必須對速度進行分析,以檢查新選擇的專案實際是否會滾過中心,並在必要時反轉方向。 部分早期代碼實際上會來回多次反轉滾動,那種效果並不理想。

結果如何?

我必須承認,得到的控制項看起來更像是“初稿”,而不是最終版本。 它並不像 Windows Phone 7 中實現的版本那樣流暢自然,不夠優雅。 例如,當您通過按下控制項將其啟動時,Windows Phone 7 版本中的選擇帶會有淡入淡出效果。 我的選擇帶只是突然彈出。

然而,此次體驗令我很高興,也讓我更加相信,良好的多觸控代碼一定不會像一眼看上去那麼簡單。

Charles Petzold 是《MSDN 雜誌》的長期特約編輯。他的新書《Programming Windows Phone 7》將在 2010 年秋季作為可免費下載的電子書出版。

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