GridView の仮想化

 ※Release Preview での状況は以下の記事を参照してください。
 Release Preview における GridView の問題点 (1) - rebuild

 現在提供されている Visual Studio 11 Beta で“グリッド アプリケーション”や“分割アプリケーション”のテンプレートを使ってアプリを作成すると、GridView が配置されます。
 この GridView の動作を確認してみると問題点があります。

 ひとつは、GridView 上の項目表示が仮想化されていない事です。
 ここでいう仮想化とは以前(ファイル一覧表示 - rebuild)取り上げたデータの仮想化(データを必要に応じて順次取得していく)とは違う話で、GridView に追加された項目を全て実体化(コントロールを作成)するのではなく、表示のために必要な部分のみ実体化し不要になった部分は破棄していく事によって使用する UI リソースを一定量に抑える仕組みです。

 もう一つは、GridView のカレント項目(枠が表示される)をキーボードで移動させていくと、カレントの位置に対応してスクロールせず、カレント項目が画面外になってしまう事です。

問題点の確認(仮想化)

 まず、問題点を確認します。

 仮想化の確認を行うためには、GridView にある程度の数の項目を追加する必要がありますので、SampleDataSource クラスのコンストラクタの最後に以下のコードを追加して、項目が10000個になるようにします。

for (int i = 7; i < 10000; i++)
{
    var group = new SampleDataGroup("Group-" + i.ToString(),
        "Group Title: " + i.ToString(),
        "Group Subtitle: " + i.ToString(),
        "Assets/DarkGray.png",
        "Group Description: ");
    this.ItemGroups.Add(group);
}

 次に、各項目の情報を取得しに来ている事を確認するために、SampleDataCommon クラスの Title プロパティの get にデバッグ出力を追加します。

public string Title
{
    get
    {
        Debug.WriteLine(_title);
        return this._title;
    }
    set { this.SetProperty(ref this._title, value); }
}

 仮想化が行われているのであれば、画面に表示される項目+α程度の数だけ取得しにきて、それ以外はスクロールして表示範囲が変わらない限り取得されないはずです。
 実行してみると、画面に表示される範囲を超えて取得に来ていて、1200個を超えたあたりで画面が勝手にスタート画面に戻ってしまいます(メモリ 2GB 環境で確認)。

 仮想化されない事による問題は、追加された項目分のコントロールを作成し配置し終わらないと UI がブロックされたままになってしまう点と、コントロールを全て配置しようとするため、UI 用のリソースが大量に使用される点です。
 確認用のサンプルで途中でスタート画面に戻ってしまうのは、リソースが不足した事が原因だと思われます。

※追記1
 スタート画面に戻ってしまうのは、リソースの問題よりも起動が一定時間たっても終わらない事の方が原因かもしれません(最初に表示されるページの初期化中に UI がブロックされたままになるので、起動処理が長時間行われている状態になっている)。

問題点の確認(スクロール)

 スクロールに関する問題は、仮想化確認用のサンプルで 10000 件追加していた箇所を、問題ない程度(100件等)に抑えれば確認できます。

 アプリ実行後、Tab キーでフォーカスを移動すると、GridView 上のカレント項目に枠が表示されます。
 この状態でカーソルキーで移動していくと、スクロールされずにカレント項目の枠が画面外に移動してしまいます。

 スクロールしない事による問題は、キーボード操作による使い勝手が悪くなる点です。

問題発生原因の推測

 このような問題が発生するのは、コントロールの配置に原因があると推測されます。
 テンプレートで生成された xaml を見ると、GridView は ScrollViewer の中に配置されています。

<ScrollViewer
    x:Name="itemGridScrollViewer"
    AutomationProperties.AutomationId="GridScrollViewer"
    Grid.Row="1"
    Margin="0,-4,0,0"
    Style="{StaticResource HorizontalScrollViewerStyle}">

    <GridView
        x:Name="itemGridView"
        AutomationProperties.AutomationId="ItemsGridView"
        AutomationProperties.Name="Items"
        Margin="116,0,116,46"
        ItemsSource="{Binding Source={StaticResource itemsViewSource}}"
        ItemTemplate="{StaticResource Standard250x250ItemTemplate}"
        SelectionMode="None"
        IsItemClickEnabled="True"
        ItemClick="ItemView_ItemClick"/>
</ScrollViewer>

 元々 GridView は内部に ScrollViewer を持っていて、追加された項目が多い場合は自分でスクロールを行うようになっていますし、項目の仮想化も行うようになっています(実際に仮想化を行っているのは GridView 内部に配置されている VirtualizingPanel から派生したクラスです)。
 しかし、ScrollViewer の中に GridView を置いてしまうと、GridView 自体は追加された項目全てを表示できると判断し、自分でスクロールを行わないようになると考えられます。

 このような状態になっているとすると、全ての項目が表示範囲に入ると判断されるので、仮想化処理自体は動作していても全てのコントロールを配置してしまい、仮想化されていない場合と同じ動きになってしまいます。
 また、スクロールが不要と判断しているので、カーソル移動でカレント項目が移動してもスクロールはされません。

解決方法

 推測した内容から考えると、解決するためには ScrollViewer の内部に GridView を配置するのをやめる必要があります。
 単純に ScrollViewer をなくしてしまうとマージンの取り方が変わってしまうので、GridView 内部の Panel も含めて変更を行います。

<GridView
        Grid.Row="1"
        x:Name="itemGridView"
        AutomationProperties.AutomationId="ItemsGridView"
        AutomationProperties.Name="Items"
        Margin="0,-4,0,0"
        ItemsSource="{Binding Source={StaticResource itemsViewSource}}"
        ItemTemplate="{StaticResource Standard250x250ItemTemplate}"
        SelectionMode="None"
        IsItemClickEnabled="True"
        ItemClick="ItemView_ItemClick">

    <GridView.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapGrid Margin="116,0,116,46"/>
        </ItemsPanelTemplate>
    </GridView.ItemsPanel>
</GridView>

 変更した状態で実行すると、10000 件追加した場合でも画面が表示され操作可能になります。

 最初に画面が表示された段階で取得されている項目は 24 個までで、以降は表示位置を移動する事で、表示範囲内に入る項目が取得されるようになります。

 また、キーボードでカレント項目を移動していくと、それに合わせてスクロールされるようになります。

注意点

 項目を 10000 件追加した場合でもとりあえず画面は表示されるようになりますが、この状態で正常に動作するわけではありません。
 試しにキーボードでスクロールさせていくと、7000 件程度情報取得が行われた段階でアプリが落ちます。

 これは、SampleDataCommon の Image プロパティが以下のように実装されており、情報の取得が行われると生成された Image が溜まってメモリが不足するためです。

public ImageSource Image
{
    get
    {
        if (this._image == null && this._imagePath != null)
        {
            this._image = new BitmapImage(new Uri(SampleDataCommon._baseUri, this._imagePath));
        }
        return this._image;
    }

    set
    {
        this._imagePath = null;
        this.SetProperty(ref this._image, value);
    }
}

 どうしても 10000 件追加する必要がある場合は、以下のように毎回 BitmapImage を作成して、保持しないようにする必要があります(保持していなければ、画面外に移動してコントロールが破棄された時に BitmapImage も消えるため、メモリが圧迫されない)。

public ImageSource Image
{
    get
    {
        if (this._image == null && this._imagePath != null)
        {
            return new BitmapImage(new Uri(SampleDataCommon._baseUri, this._imagePath));
        }
        return this._image;
    }

    set
    {
        this._imagePath = null;
        this.SetProperty(ref this._image, value);
    }
}

 ただし、10000 件も項目があるのであれば、一度に全て一覧に追加するのではなく、別の操作方法を考える必要があると思います。

ScrollViewer の取得

 以上のように、ScrollViewer の中ではなく直接 GridView を配置する事で仮想化が動作するようになりますが、デメリットとして ScrollViewer が見えなくなり、スクロール位置の取得等ができなくなってしまいます。

 この問題については、GridView が内部的に持っている ScrollViewer を取得する事で対応可能です。

 以下のように、UI 要素の子をたどって指定した型のオブジェクトを返すメソッドを追加する事で、このメソッドを使って GridView 内の ScrollViewer を取得する事ができます。

public static T GetChild<T>(DependencyObject obj) where T : FrameworkElement
{
    int count = VisualTreeHelper.GetChildrenCount(obj);
    for (int i = 0; i < count; i++)
    {
        var child = VisualTreeHelper.GetChild(obj, i);
        if (child is T)
        {
            return child as T;
        }

        var result = GetChild<T>(child);
        if (result != null)
        {
            return result;
        }
    }

    return null;
}

void ItemView_ItemClick(object sender, ItemClickEventArgs e)
{
    var sv = GetChild<ScrollViewer>(itemGridView);
    if (sv != null)
    {
        Debug.WriteLine("offset = " + sv.HorizontalOffset.ToString());
    }
}

 項目クリック時に ScrollViewer を取得して水平オフセットを参照してみましたが、正常に取得できていました。

補足

 今回は GridView について取り上げましたが、ListView についても同じだと思います。

 なお、解決方法を見るとテンプレートで生成される内容に問題があるように思われますが、標準的に用意されている物ですから、他に理由があって ScrollViewer 内に配置している可能性はあります。
 そのため、今後のリリースで問題点が修正される(ScrollViewer 内部に配置しても問題ないようになる)可能性もありますので、Visual Studio がアップデートされた場合は再度確認が必要です。

※追記2

 上記のように Panel にマージンを設定してしまうと、スクロールを止めた時に常に左側に余白が表示されてしまう事がわかりました。
 このような問題がない方法を書いている方がいらっしゃいましたのでご紹介しておきます。
 程よい余白を持ったGridViewを定義しよう - かずきのBlog@hatena