次の方法で共有


UI 最前線

ItemsControl の内と外

Charles Petzold

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

Charles PetzoldWindows Presentation Foundation (WPF) と Silverlight の能力と柔軟性を最も典型的に示すクラスは何かと尋ねられたら、私はまず、なんとばかげた質問なんだと言ってから、一瞬のためらいもなく「DataTemplate」と答えるでしょう。

基本的に、DataTemplate は要素とコントロールのビジュアル ツリーです。プログラマは、目に見えないデータ オブジェクトに視覚的外観を与えるために DataTemplate を使用します。ビジュアル ツリー内の要素のプロパティは、バインドを使用して、データ オブジェクトのプロパティにリンクされます。DataTemplate は、ItemsControl か ListBox (ItemsControl から派生するいずれかのクラス) 内のオブジェクトの外観を定義する目的でよく使用されますが、ボタンなどの、ContentControl の Content プロパティ、または ContentControl 派生クラスに設定されたオブジェクトの外観を定義することもできます。

DataTemplate (または、ControlTemplate や HierarchicalDataTemplate などの、他の種類の FrameworkTemplate 派生クラス) の作成は、数少ない、コードでは実行できない Silverlight のプログラミング作業の 1 つで、XAML を使用する必要があります。かつては、Framework-ElementFactory を使用して WPF テンプレート全体をコードで作成できましたが、実際に例を示したのは私だけだと思います (Microsoft Press から 2006 年に刊行されている『Applications = Code + Markup』の 11 章、13 章、および 16 章です)。現在では、この手法は推奨されません。

今回のコラムでご紹介するのは、ドラッグ アンド ドロップのバリエーションです。つまり、単にユーザーが ItemsControl から別の ItemsControl に項目を移動する方法ですが、私が主な目標に掲げたことは、このプロセス全体に、突然動いたり消えたりするものがない、自然に見える、100% 滑らかな外観を実装することです。もちろん、"自然な見た目" を作り上げるのには苦労することがほとんどで、プログラムを滑らかに見せようとするならば、表面下に存在するぎこちない動きのからくりをすべて見えないようにしなくてはなりません。

今回は、先月のコラム (「グリッドの外側を考える」) で示した手法を組み合わせたものと、このプログラム全体に不可欠な、ItemsControl と ContentControl 間で共有される概念である DataTemplate を使用します。

プログラムのレイアウト

このコラムに添えたダウンロード可能なコードには、ItemsControlTransitions という単体の Silverlight プロジェクトが含まれています。このプログラムは、私の Web サイト (charlespetzold.com/silverlight/ItemsControlTransitions2) から実行できます (この URL の末尾にある "2" については後で説明します)。この Silverlight プログラムの概念と WPF プログラムの概念は同じです。

プログラムには、2 つの ItemsControl が表示されていて、どちらも ScrollViewer に含まれています。左側の ItemsControl は、農産物を売る "市場" を表現しています。右側の ItemsControl は "買い物かご" です。市場から農産物を選んで、それを買い物かごに移動します。図 1 は、とうもろこし (Corn 項目) が市場から買い物かごに移されるようすを示しています。

Figure 1 The ItemsControlTransitions Display図 1 ItemsControlTransitions の表示

とうもろこしは市場から取り出されたにもかかわらず、左側の ItemsControl のとうもろこしがあった場所には空白が表示されたまままになっています。ユーザーが、買い物かごの ItemsControl の上に項目をドラッグする前にマウス ボタンを放してしまうと、プログラムではその項目が市場に戻されるというアニメーションが実行されます。項目が買い物かごにドロップされて初めて、空白になっていた場所が埋められるアニメーションが実行されます。買い物かごの側では、項目がドロップされる場所に応じて、項目を受け取るためにすきまを広げ、そこに項目が収められるというアニメーションが実行されます。

項目が市場から移動されたら、市場にはその項目がなくなりますが、このプログラムの詳細は簡単に変更できます。買い物かごから項目を取り除いて市場に戻す機能はありませんが、その機能や似た機能もかなり簡単に追加できます。

図 2 に、基本的なレイアウトを行っている XAML ファイルの大半を示します (欠けている部分は、後ほど説明する、7 つのアニメーションを実行する 2 つのストーリーボードです)。

図 2 XAML ファイルの基本レイアウト部分

<UserControl x:Class="ItemsControlTransitions.MainPage"   
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation" 
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  Name="this">
    <UserControl.Resources>
      <DataTemplate x:Key="produceDataTemplate">
        <Border Width="144"
          Height="144"
          BorderBrush="Black"
          BorderThickness="1"
          Background="AliceBlue"
          Margin="6">
          <Grid>
            <Grid.RowDefinitions>
              <RowDefinition Height="*" />
              <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>

            <Image Grid.Row="0"
              Source="{Binding Photo}" />
            <TextBlock Grid.Row="1"
              Text="{Binding Name}"
              HorizontalAlignment="Center" />
          </Grid>
        </Border>
      </DataTemplate>

        ...
        
    </UserControl.Resources>

    <Grid x:Name="LayoutRoot" Background="White">
        <ScrollViewer HorizontalAlignment="Left"
          Margin="48">
            <ItemsControl Name="market"
              ItemTemplate="{StaticResource produceDataTemplate}"
                Width="156"
                MouseLeftButtonDown="OnMarketItemsControlMouseLeftButtonDown" />
        </ScrollViewer>

        <ScrollViewer HorizontalAlignment="Right"
          Margin="48">
            <ItemsControl Name="basket"
              ItemTemplate="{StaticResource produceDataTemplate}"
            Width="156" />
        </ScrollViewer>

        <Canvas Name="dragCanvas">
          <ContentControl Name="dragControl"
            ContentTemplate="{StaticResource produceDataTemplate}"
            Visibility="Collapsed" />
        </Canvas>
    </Grid>
</UserControl>

Resources セクションには、農産物項目を表示する DataTemplate が含まれていて、このリソースへの参照は、2 つの ItemsControl の ItemTemplate プロパティに設定されています。

また、プログラムが占有する領域全体を Canvas で覆います。先月のコラムで、Canvas を使用して、UI の他の部分に "フロート" する必要がある項目をホストする方法をご説明しました。この Canvas の子要素は ContentControl のみで、その ContentTemplate も DataTemplate に設定しています。ですが、Visibility プロパティは Collapsed に設定しているので、最初は ContentControl は表示されません。

ContentControl から派生するコントロールは、WPF アプリケーションや Silverlight アプリケーションでは一般的ですが、ContentControl 自体はあまり見かけません。目的が DataTemplate を使用してオブジェクトを表示することのみであれば、ContentControl が非常に便利であるのがわかります。ContentControl は、見た目から言うと、ItemsControl に含まれる 1 つ項目によく似ています。

プログラムでは、まず (先月のコラムの ItemsControlPopouts プロジェクトの同じファイルを使用して) いくつかの農産物を小さな XML データベースに読み込み、ProduceItem 型のオブジェクトを、市場である ItemsControl に設定します。このクラスには、DataTemplate が各項目を表示するために参照する Name プロパティと Photo プロパティが含まれています。

ItemsControl からの項目の取り出し

市場の ItemsControl には、MouseLeftButtonDown に設定されたハンドラーがあります。プログラムでは、このイベントを受け取りしだい、項目が収まっている ItemsControl から項目を取り除いて、その項目がマウスを追跡できるようにする必要があります。ですが、実際には項目が ItemsControl から取り除かれなかった場合は、すきまが自動的に閉じられます。

先月のコラムでご説明したように、ItemsControl の ItemContainerGenerator プロパティにアクセスすると、ItemsControl の各項目を表示するために生成されたビジュアル ツリーと ItemsControl の各項目を関連付けることができるクラスを取得できます。このビジュアル ツリーには、ContentPresenter 型のルート要素があります。

私がとっさに思ったのは、TranslateTransform を ContentPresenter の RenderTransform プロパティに適用して、ItemsControl の外にフロートできるようにすることです。経験からわかっていましたが、これはまったく機能しません。問題は ItemsControl そのものではなく、子要素を必然的に内部にクリッピングする ScrollViewer です (このクリッピングの根拠の詳細については後で簡単に説明します)。

代わりに、プログラムでは、クリックされた ItemsControl の ProduceItem を ContentControl にコピーして、クリックされた項目の ContentPresenter に ContentControl を正確に配置します (プログラムでは、常に便利な TransformToVisual メソッドを使用することで、Canvas からの ContentPresenter の相対位置を取得できます)。既に説明したように、XAML ファイルでは、ContentControl の Visibility プロパティが Collapsed に設定されていますが、プログラムではそのプロパティを Visible に切り替えます。

これと同時に、ItemsControl の ContentPresenter が非表示になります。WPF では、Visibility プロパティを Hidden に設定するだけでこれを実行できます。これによって項目が非表示になりますが、要素のサイズはレイアウトのために保持されます。Silverlight の Visibility プロパティには Hidden オプションがなく、ContentPresenter の Visibility プロパティを Collapsed に設定すると、すきまが閉じられます。代わりに、単に Opacity プロパティを 0 に設定することで、Visibility の Hidden 設定を模倣できます。これならば、要素はそのままの状態で非表示になります。プログラムで実験すれば、ItemsControl の項目からドラッグ可能な ContentControl への遷移は認識できないことがおわかりになると思います。

この時点では、ItemsControl の ContentPresenter は空の穴しか表示しておらず、項目を表示する ContentControl はマウスで画面の周辺にドラッグできるようになっています。

項目のドロップ

Win16 API と Win32 API についての書籍を執筆していたころは、何章もかけて、スクロール バーを使用してウィンドウ枠に収まらないテキストを表示する方法について説明していました。(特に私にとって) 喜ばしいことは、現在では、単に ScrollViewer を使うだけで済むようになったことです。

ScrollViewer は、WPF と Silverlight のレイアウトにおいて重要な役割を果たしていますが、少々使いにくいときがあります。ScrollViewer には少し複雑な性質があり、このプログラムによってそのうちの 1 つが明らかになります。どんな問題か予想してみてください。

先ほど、ユーザーがマウスで画面の周辺に農産物項目を移動できるようにしました。買い物かごを表す ItemsControl 上のどこかに農産物項目をドロップすると、項目がそのコレクションの一部になります (このプロセスについては後で簡単に説明します)。それ以外の操作が行われると、プログラムでは、MainPage.xaml の returnToOriginStoryboard で 2 つのアニメーションを使用して、項目が元の位置に戻るというアニメーションを実行します。アニメーションの終わりには、ContentPresenter の Opacity プロパティが 1 に、ContentControl の Visibility プロパティが Collapsed に設定され、すべてが元に戻ってドラッグ イベントが終了します。

農産物項目が ItemsControl にドロップされているかどうか判断するために、プログラムでは、ドラッグされた ContentControl の場所とサイズを表す Rect オブジェクトと、ItemsControl の場所とサイズを表すもう 1 つの Rect オブジェクトを算出します。どちらの場合でも、プログラムでは TransformToVisual メソッドを使用して、ページからの相対でコントロールの左上隅座標 (0,0) を取得します。そして、ActualWidth プロパティ と ActualHeight プロパティを使用して、コントロールのサイズを取得します。それから Rect 構造の Intersect メソッドで、2 つの長方形の交点が算出されます。この交点は、どこかで重なり合っていれば空にはなりません。

これは、使用可能な垂直方向の空間に収まりきらない数の項目が ItemsControl に含まれている場合を除いて、うまく機能します。次に ScrollViewer によって、垂直スクロール バーが表示され、項目をスクロールできるようにして動作を開始します。ですが、ScrollViewer 内の ItemsControl は、実際にはユーザーの目に見えているサイズよりも大きく確保されており、ScrollViewer は ItemsControl の表示可能なウィンドウ ("ビューポート" と呼びます) だけを提供しています。その ItemsControl に取得する場所やサイズの情報は、常に ("エクステント" サイズと呼ばれている) 実際に確保されているサイズで、ビューポートのサイズではありません。

ScrollViewer が子要素をクリッピングする必要があるのはこのためです。Silverlight でしばらく作業したことがある方は、子要素のクリッピングに関していくぶん不明瞭な点があることをご存知かもしれません。親要素の境界を越えるためには、ほとんどの場合 RenderTransform を使用できますが、ScrollViewer は確実にクリッピングする必要があり、そうでないと適切に動作しません。

つまり、ItemsControl は ScrollViewer よりも大きくなったり小さくなったりする場合があるため、ItemsControl の実際のサイズを使用しても有効なドロップ先を決定することができません。このため、ここで作成したプログラムでは、スクロール バーによって占有されている領域を除外するために、ScrollViewer の垂直方向のサイズではなく ItemsControl の水平方向のサイズに基づいて、有効なドロップ場所となる長方形を決定しています。

ContentControl が ItemsControl にドロップされるとき、その状態は ContentControl が既存の項目 2 つと重なる場合、一連の項目の一番上か一番下にドロップされて 1 つの項目とだけ重なる場合、およびどの項目とも重ならない場合の 3 つがあります。新しい項目を、ドロップ位置に最も近いところに挿入するため、ItemsControl (および関連付けられた ContentPresenter オブジェクト) 内の項目を列挙して、新しい項目を挿入する位置の適切なインデックスを特定します (このインデックスを特定するのは、GetBasketDestinationIndex メソッドです)。項目の挿入後、その新しい項目と関連付けられた ContentPresenter には、初期の高さを 0 に、そして不透明度を 0 に設定するため、最初は表示されません。

この挿入に従って、プログラムでは transferToBasketStoryboard という 5 つのアニメーションを含むストーリーボードを開始します。このアニメーションとは、市場を表す ItemsControl 内で非表示になる ContentPresenter の高さを低くしていくアニメーション、買い物かごを表す ItemsControl に新しく作成される非表示の ContentPresenter の高さを高くしていくアニメーション、および ContentControl を適切な位置にスライドする Canvas.Left 添付プロパティと Canvas.Top 添付プロパティを変化させる 2 つのアニメーションです (5 つ目のアニメーションについては後で簡単に説明します)。図 3 は、ContentControl が挿入される場所に近づくとすきまが広がるようすを示しています。

Figure 3 The Animation to Move a New Item into Place図 3 新しい項目を適切な箇所に移動するアニメーション

アニメーションを終了するときに、新しい ContentPresenter の Opacity を 1 に、そして ContentControl の Visibility を Collapsed に設定するため、再び ScrollViewer 内で通常の 2 つの ItemsControl を扱うことになります。

一番上と一番下の問題

先ほどは、プログラムを試していただくために charlespetzold.com/silverlight/ItemsControlTransitions2 という URL を示しました。このプログラムの以前のバージョンは、最後に "2" がない、harlespetzold.com/silverlight/ItemsControlTransitions で実行できます。この以前のバージョンで、垂直のスクロール バーが表示されるようになるまで、買い物かごに農産物項目を移動してください。次に、農産物項目をもう 1 つドラッグして、ScrollViewer の一番下にまたがるように移動してください。マウス ボタンを放すと、ContentControl は ItemsControl の領域の表示されていない部分に向かって下に移動し、突然見えなくなります。項目は適切に挿入されますが (下にスクロールすると確認できます)、動作はあまり洗練されているとはいえません。

今度は、ScrollViewer をスクロールして、一番上の項目が一部だけ表示されるようにしてください。そして、市場から別の項目を移動して、一部だけ表示されている項目の前に挿入されるように配置します。新しい項目は、ItemsControl 内にスライドしますが、項目全体は表示されません。ItemsControl の一番下に移動したときの問題ほどは悪くはありませんが、なんらかの処置が必要です。

これを解決するには、ScrollViewer をプログラムからスクロールする方法が必要です。現在 ScrollViewer に適用されている垂直方向へのスクロール量は、VerticalOffset プロパティに保持されています。この数値は、ItemsControl 全体の一番上から、ScrollViewer の一番上に表示されているコントロールの場所までの正のオフセットです。

その VerticalOffset プロパティの変化をアニメーションにできたらよいと思いませんか。残念ながら、public なのは get アクセサーのみです。さいわい、ScrollViewer をプログラムからスクロールするのは可能で、ScrollToVerticalOffset というメソッドを呼び出す必要があります。

Silverlight のアニメーション機能を使用してこの簡単なスクロールを実行するために、MainPage そのものに、Scroll という依存関係プロパティを定義しました。XAML ファイルでは、次のように、ページに "this" という名前を付け、このプロパティが対象となるように transferToBasketStoryboard に 5 つ目のアニメーションを定義しました。

<DoubleAnimation x:Name="scrollItemsControlAnima"
                 Storyboard.TargetName="this"
                 Storyboard.TargetProperty="Scroll" />

OnMouseLeftButtonUp のオーバーライドは、このアニメーションの From 値と To 値を算出します ("Calculate ScrollViewer scrolling animation (ScrollViewer のスクロール アニメーションを算出します)" というコメントで始まるコード ブロックをコメント アウトすることで、この追加のアニメーションの効果を比較できます)。この Scroll プロパティの変化がアニメーションにされるため、プロパティの変化を処理するハンドラーは、変化する値を指定して ScrollViewer の ScrollToVerticalOffset メソッドを呼び出します。

滑らかな UI を目指して

はるか昔、コンピューターは現在のものよりたいへん遅く、画面上ではそれほど驚くようなことが起こりませんでした。現在、プログラムは、まばたきをするうちに外観をすっかり変えてしまう UI を実装できるようになりました。ですが、これでも不十分です。一瞬では何が起こったかわからないのがほとんどなので、UI の変化をわざと遅くして、遷移をより滑らかで自然にすることが大切です。Silverlight 4 には、"UI を滑らかにする" 機能がいくつか導入されていて、これについてもぜひご説明したいところですが、Silverlight 3 でも滑らかな UI を目指すことは可能です。

Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。現在、『Programming Windows Phone 7 Series』(Microsoft Press、2010 年) という、2010 年の秋に出版される予定の、無償でダウンロードできるオンライン書籍を執筆中です。プレビュー版が、彼の Web サイト (charlespetzold.com、英語) で公開中です。