次の方法で共有


UI 最前線

タッチ コントロールの複雑さ

Charles Petzold

コード サンプルのダウンロード

Charles Petzold私は、トースターのしくみを調べるために、トースターを分解してしまうような子どもでした。最近になってようやく、オペレーティング システムやアプリケーション プログラムを逆アセンブルすることから卒業しました。

これまで、それほど多く逆アセンブルを行ってきたわけではありません。どちらかといえば、特に興味深い UI を見たときなど、それを独自にコーディングする方法を考えようとする方が多かったと思います。もちろん、これはエンジニアリングの演習ですが、自身を美術館に出向き優れた作品を模写する画学生のように見立てるのも好きです。自身のコードでは、当然、できる限り簡潔にしようと努め、既存の要素やコントロールを使用します。しかし、たいてい、自ら挑戦することを好み、そこから何かを学び取ることを望んでいます。

最近、Windows Phone 7 を調査していたところ、日付と時刻を設定するページ (図 1 参照) に興味を抱きました。これらのコントロール (公には利用できません) は、興味深いタッチ インターフェイスに思えました。[これらのコントロールは、このコラムの公開後に、Windows Phone Toolkit 向けの Silverlight でリリースされました。これらのコントロールは、silverlight.codeplex.com/releases/view/52297 (英語) から入手できます。編集者注]

image: Windows Phone 7 Date Picker image: Windows Phone 7 Time Picker

図 1 Windows Phone 7 の日付と時刻の選択ツール

ここで私は考え始めました。私がこれらのコントロールをコーディングするとしたらどのようにするだろう。これまでの数回のコラムで、Windows Presentation Foundation (WPF) におけるマルチタッチを調査してきたので、今回はコントロールを模写する第一弾としてこのプラットフォームをターゲットにすることに決めました。うまくいけば、そのコードを Silverlight に移行することを考えています。

コントロールの調査

まず、Windows Phone 7 の日付または時刻のページの 1 つに移動すると、ページの中央に揃えられた灰色の四角形の中に現在の設定が表示されます。これらの四角形の 1 つにタッチすると、その上下に他の選択肢の一覧がポップアップされます。ほとんどの一覧は、循環リストになっています。たとえば、図 1 でおわかりのように、12 月の次は 1 月です。日、時、分でも循環リストが使用されています。ただし、年では循環リストは使用されていません (1601 から 3000 の範囲から選択します)。また、(ご覧のように) 午前 (AM)、午後 (PM) も選択できます。

リストボックスとは大きく異なります。通常のリストボックスでは、現在選択されている項目が強調表示されますが、スクロールすると選択項目が完全に画面外にスクロールされ見えなくなることがあります。しかし、この日付と時刻のコントロールでは、選択されている項目が常に中央に表示されます。この中央にある領域は、昔ながらのスロット マシンのホイールのような機能 (ここでは "バンド" と呼びます) を備えた一種の "ウィンドウ" と考えることができます。

これらのコントロールは、タッチ操作に反応して次の 3 通りの動作をします。

  • 単純にバンドに表示されている別の項目 (図 1 の 12 月など) をタップすると、画面から指を離したときにその項目は強調表示されてから中央のウィンドウ領域に移動します。
  • タップするのではなく、バンド内の項目を指で押さえながら上下に移動することができます。移動している最中はバンド内の項目は強調表示されません。指を離すと、その時点で中央のウィンドウに最も近い項目が強調表示され、中央に移動します。
  • 3 種類目のインターフェイスは慣性が関係します。項目のバンドが移動中に画面から指を離すと、バンドは速度を落としながら移動を続けます。移動の停止寸前に中央のウィンドウに最も近い項目が強調表示され、中央に移動します。状況に応じて、停止直前に移動方向が反転することもあります。私には非常に自然な動きに見えたこのちょっとした効果は、これらのコントロールを複製するときに、"興味深い" 課題になるだろうと思っていました。

全体のアーキテクチャ

もう 1 つの "興味深い" 課題は項目の循環リストに関係していて、12 月の後に 1 月が、12:00 の後に 1:00 がくる点です。こうした循環バンドは、特に慣性と組み合わさると、デザイン上の重要な側面になります。バンドはどちらの方向へも移動でき、項目は慣性が働いている方向へ移動を続け、終端がきても移動を止めることはありません。

数日間考えあぐねた結果、循環リストの解決策として、カスタム パネルよりも適切な解決策を思い付きませんでした。そこで、わかりやすい名前の WrappableStackPanel というカスタム パネルを作成することにします。このようなパネルでは、上から下に子コントロールが並ぶこともあれば、先頭ではない子コントロールが最初に来たり、前にあるはずの子コントロールが後ろにあるはずの子コントロールよりも後になったりします。もちろん、WPF では特定の子コントロールの複数のバージョンをレンダリングすることは許されていないため、スタックの両端は常に決まっていることになります。

WrappableStackPanel には、パネルの先頭に表示すべき子コントロールのインデックスを示す (StartIndex のような) プロパティが必要です。残りの子コントロールには順番にインデックスが付けられ、最後のコントロールに達したら子コントロール 0 に戻ります。一般には、子コントロールがパネルの上端に正確に揃うことはありません。一番上のコントロールは、その一部がパネルの上端を超えてしまうのが普通です。このことから、StartIndex プロパティは整数値ではなく、浮動小数点値になります。

しかし、そのためコントロール全体ではおそらく 2 つの異なる方法で機能することになります。1 つのモードでは、WrappableStackPanel が項目をホストする必要があります。つまり、WrappableStackPanel の位置がコントロールと相対に固定されます。パネルは、自身と相対に子コントロールの位置を決めます。もう 1 つは、StackPanel の通常の動作が適切な場合のモードです (年や、AM/PM の選択など)。このモードでは、子コントロールが StackPanel と相対に固定されます。その後 StackPanel がそのコントロールと相対にスクロールされます。

このコントロールは、ListBox から派生することになるのでしょうか。ここでは、ListBox からは派生しないことに決めました。ListBox には固有の選択ロジックが組み込まれていますが、目的とするコントロールの選択ロジックとは異なります。ListBox から派生した場合、既存の選択ロジックに取り組むときに、おそらく、助けになるよりも、妨げになる方が多いことがわかります。

しかし、コントロールに含まれる項目のコレクションをコントロール自体に管理させ、特に、こうした項目を表示するためのテンプレートを XAML で定義できるようにしたいと思います。そのためには、ItemsControl が必要になります。ItemsControl は Selector の親クラスで ListBox や ComboBox の派生元のクラスです。そのため、このクラスは、選択ロジックを必要としない場合、またはプログラマがカスタマイズした選択ロジックを処理する場合に、コレクションを表示するための明白な選択肢です。これこそ、まさに必要なクラスです。

ListBox の既定のコントロール テンプレートには ScrollViewer が含まれていますが、ItemsControl には含まれていません。ItemsControl が表示した項目をスクロールする必要がある場合は、ItemsControl 自体をすべて ScrollViewer 内に配置します。

この状況には既存の ScrollViewer は使用できません。既存の ScrollViewer でも慣性は処理されますが、特定の項目を中央に向かわせるロジックはありません。今回実際のコーディングで最初に試したのは、ScrollViewer の代用として WindowedScrollViewer というクラスを作成することでした。このクラスでは、月、日、または年のいずれかを含む ItemsControl をホストします。WindowedScrollViewer の MeasureOverride メソッドでは、ItemsControl の全サイズと表示される項目数を容易に取得できるようにします。この 2 つから各項目の均一の高さがわかります。この情報を使用すれば、操作イベントに基づいて、ItemsControl を自身と相対に移動できます。

この方法は、ItemsControl で回転する項目のバンドを表示する必要がないときは、適切に機能します。この場合は、ItemsControl の ItemsPanel プロパティに、WrappableStackPanel を参照する ItemsPanelTemplate を設定する必要があります。大きな問題は、ビジュアル ツリーの中央に位置する ItemsControl を通じて WrappableStackPanel と通信する WindowedScrollViewer にあります。

これは難しいように思えます。さらに、ここで作成した WindowedScrollViewer は通常の ScrollViewer とは異なり、独自の外観をもたないことも徐々に問題になってきました。このビューアーは、自身と相対に ItemsControl をスライドすること以外は何も行いません。

そこで、この方法を取りやめ、ItemsControl からクラスを派生して、そこですべてを行うことにしました。つまり、WindowedItemsControl というクラスを用意し、そこに選択ロジック、操作の処理、スクロール、およびオプションの WrappableStackPanel との通信を実装します。

今月のコラムのダウンロード可能なソース コードは、TouchDatePickerDemo というソリューションで、同名のプロジェクトと Petzold.Controls というライブラリ プロジェクトが付属しています。このライブラリ プロジェクトには、WindowedItemsControl (3 つのファイルに分かれています)、WrappableStackPanel、および TouchDatePicker という UserControl の派生クラスが含まれています。TouchDatePicker は、3 つの WindowedItemsControls (月、日、年) を、DatePresenter クラストと DateTime というプロパティと組み合わせます。

図 2 に、実行中の TouchDatePicker を示します。TextBlock が DateTime プロパティにバインドされていて、現在選択している日付を表示します。

image: The TouchDatePicker Control in Action

図 2 動作中の TouchDatePicker コントロール

TouchDatePicker は、基本的には月、日、年の 3 つの列を備えたグリッドです。図 3 は、月を処理する最初の WindowedItemsControl の XAML を示しています。DataContext は DatePresenter 型のオブジェクトで、WindowedItemsControl タグ自体から参照される AllMonths プロパティと SelectedMonth プロパティを保持しています。AllMonths プロパティは MonthInfo オブジェクトのコレクションで、SelectedMonth も MonthInfo 型です。MonthInfo クラスには MonthNumber プロパティと MonthName プロパティがあり、これらのプロパティは DataTemplate で参照されています。WrappableStackPanel は最後の方で参照されています。

図 3 3 つの TouchDatePicker コントロールのうちの 1 つ

<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 によって管理される項目のコレクションは、オブジェクト型の項目を受け取るように定義されています。これらの各項目は、ItemsControl の ItemTemplate に設定された DataTemplate を基にレンダリングされます。

ListBox では、もう少し処理を行います。ListBox では、選択ロジックを実装する目的で、各項目を ListBoxItem コントロールにラップします。ListBoxItem には、IsSelected プロパティ、Selected イベント、Unselected イベントがあり、項目が選択されているかどうかを表示する役割があります。

WindowedItemsControl の目標は、どのような種類のラッパーも使わずに選択ロジックを実装することでした。また、この実装を行う際に、選択済みの項目の外観をすべて XAML で定義できるようにすることでした。そのため、WindowedItemsControl には SelectedIndex プロパティと SelectedItem プロパティを用意しました。SelectedIndex プロパティは、内部で使用し、項目をどこに位置付けるかを決定します。SelectedItem プロパティは、項目のコレクション内で SelectedIndex に対応する特定のオブジェクトを表します。

図 3 の XAML では、SelectedItem プロパティは DateTemplate プロパティ内で次のように参照されています。

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

同じ DataTemplate 内で、項目自体は次のように単純な Binding 式で参照できます。

<Binding />

この 2 つのバインディングで参照されるオブジェクトが等しければ、DataTemplate は項目が選択されていることを示す特殊なマークアップを保持します。この場合は、灰色の網掛けの背景に淡色表示されていないテキストを表示するマークアップになります。

通常、XAML では、2 つのバインディングが同じオブジェクトを参照しているかどうかを判断することはできませんが、今回、この目的のため特別にマルチバインディング コンバーターを作成しました。このコンバーターは EqualsToVisibilityMultiConverter という名前で、2 つのオブジェクトが等しいかどうかに応じて 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 を非表示にすることを考え、コンバーターに 1 つパラメーターを追加しました。このパラメーターを "true" に設定すると、Visibility はマルチバインディング コンバーターからの値を切り替えて返します。

しかし、選択状態の項目の表示と、IsActive プロパティに関連付けた項目のバンド全体の表示を切り替えることも必要でした。IsActive が true であれば、すべての項目を表示する必要があります。IsActive が false であれば、選択状態の項目だけを表示する必要があります。マルチバインディング コンバーターに、ブール型の 3 番目のオブジェクト用の機能を追加しました。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 で実装できました。

パネルとの通信

WindowedItemsControl では、ItemsPanel を通じて設定されたパネルが WrappableStackPanel の場合に、異なるスクロール ロジックを実装する必要があります。これをどのように指示すればよいでしょう。また、どのようにして情報をパネルに伝えればよいでしょう。

パネルの種類は、ごく簡単に判断できます。OnItemsPanelChanged プロパティをオーバーライドして、ItemsPanel プロパティが変化したら必ず、古い ItemsPanelTemplate と新しい ItemsPanelTemplate を指定してこのメソッドを呼び出します。この新しいテンプレートの LoadContent を呼び出し、テンプレートでパネルのインスタンスを取得します。

ただし、LoadContent から返されるパネルは、ItemsControl によって実際に使用されるパネルと同じインスタンスではありません。この手法は、パネルが特定の種類のパネルかどうかを判断する場合のみ適していますが、そのパネルと通信する場合は不適切です。

ItemsControl からそのパネルのプロパティを設定するのであれば、別の手法が必要です。このような場合、ビジュアル ツリーを順番に調べて実際のパネルを見つけるか、継承可能なイベントを使用することができます。

ここでは継承可能なイベントを使用します。WindowedItemsControl では、依存関係プロパティによってサポートされる StartIndex プロパティを定義しました。このプロパティには FrameworkPropertyMetadataOptions の継承フラグを設定します (この継承フラグを機能させるには、標準の依存関係プロパティではなく、添付プロパティを登録する必要があることもよく知られています)。

以下にその方法を示します。

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 プロパティの値を使用して、どの項目がパネルの最上部に表示され、その項目がどの程度パネル上部からはみ出しているかを判断します。

操作のイベント

コラムの冒頭で疑問を呈したように、実際の操作イベントを実装する作業は全体の中でも困難な部類に入ります。コラムの前半で分析した 3 種類のタッチ イベントは実に的を射ていることが判明しました。これらのイベントはすべて、別々に処理しなければならないことは間違いありません (コードは WindowedItemsControl.Manipulation.cs ファイルにあります)。

3 種類のタッチ イベントを判断する大半の処理は、オーバーライドした OnManipulationInertiaStarting で行われます。このイベントは、ユーザーの指が画面から離れたことを示します。

ManipulationInertiaStarting イベントに続いて、ManipulationDelta イベントを一切挟まずに ManipulationStarting イベントが発生する場合は、タップ操作が行われたことを示します。ManipulationInertiaStarting イベントのコードでは、タップされた項目を中央に移動するために必要な距離を判断してから、この移動を一定時間 (ここでは 250 ミリ秒) 内に実現するために必要な速度を求めます。次に、慣性の量を求めるために、イベント引数の TranslationBehavior プロパティの DesiredDisplacement プロパティと InitialVelocity プロパティを初期化します。

ユーザーは項目をタップしただけであることに留意してください。ユーザーが項目を移動したわけではないので、実際には慣性も速度もありません。しかし、ManipulationInertiaStarting イベントでは慣性のパラメーターを設定することで、移動が行われていなくても、オブジェクトを強制的に移動することができます。操作イベントを処理するコンテキストでは、スクロールのためにアニメーションや CompositionTarget.Rendering を使用するよりも、この手法の方がはるかに簡単です。

ユーザーが手動でバンドを移動している場合のロジックもまったく同様ですが、新しく選択した項目を超えてバンドを移動するほどの速度はないため、その項目は明白になります。ここでも同様に、項目を適切な位置に移動させるために、DesiredDisplacement プロパティと InitialVelocity プロパティを設定することだけが必要です。

コードは、実際に慣性が生じるときに、実に面倒になります。新しく選択される項目は、回転が減速し、ほぼ停止状態になるまでわかりません。ここでは、新しい選択項目が実際に中央を超えてスクロールされたかどうかや、方向を反転する必要があるかどうかを判断するために、速度を分析しなければなりません。最初に作成したコードではスクロールが前後に数回反転し、実際に希望する動きにはまったくなりませんでした。

結果に思うこと

完成したコントロールは、最終版には程遠く、"最初の草案" にしか思えないことを認めなければなりません。Windows Phone 7 に実装されているバージョンほど滑らかでも、自然でもなく、快適性にかけています。たとえば、コントロールを押下してアクティブにすると、Windows Phone 7 バージョンのバンドは視覚にフェードインしてからフェードアウトします。ここで作成したのはポップアップするだけです。

しかし、よい体験をしたと思います。優れたマルチタッチのコーディングは、見た目よりもはるかに難しいことを確信しました。

Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。新しく執筆した「Programming Windows Phone 7」が 2010 年秋にダウンロード可能な電子書籍として無償で公開される予定です。

この記事のレビューに協力してくれた技術スタッフの Doug Kramer に心より感謝いたします。