GridViewのスクロール位置を復元するには?(その2)[Win 8]WinRT/Metro TIPS

GridViewコントロールを継承するカスタム・コントロールを作成することで、「UIの仮想化」を有効にしたままGridViewコントロールのスクロール位置を復元する方法を解説。

» 2013年03月28日 13時50分 公開
[山本康彦BluewaterSoft]
WinRT/Metro TIPS
業務アプリInsider/Insider.NET

powered by Insider.NET

「WinRT/Metro TIPS」のインデックス

連載目次

  GridViewコントロールのスクロール位置を復元するために、前回はScrollViewerコントロールの中にGridViewコントロールを格納し、ScrollViewerコントロールのHorizontalOffsetプロパティの値を利用した。しかし、この方法では、GridViewコントロールの「UIの仮想化」機能が無効になる。すると、データが多いときに表示やスクロールの処理が重くなる。また、GridViewコントロール上でマウスのホイールを使用してスクロールを行えない問題もあった。これらを解決するにはどうしたらよいだろうか?

 本稿では、GridViewコントロールを継承するカスタム・コントロールを作成することで、「UIの仮想化」を有効にしたままスクロール位置を復元する方法を解説する。本稿のサンプルは「Windows Store app samples:MetroTips #30」からダウンロードできる。

事前準備

 Windows 8(以降、Win 8)向けのWindowsストア・アプリを開発するには、Win 8とVisual Studio 2012(以降、VS 2012)が必要である。これらを準備するには、第1回のTIPSを参考にしてほしい。本稿では64bit版Win 8 ProとVS 2012 Express for Windows 8を使用している。

「UIの仮想化」とは?

 Windowsストア・アプリは、「UIの仮想化」によってアイテム・コンテナのインスタンス数を抑えている。

 Windows.UI.Xaml.Controls名前空間のGridViewコントロールやListView コントロールなどでは、個々のデータはアイテム・コンテナにバインドされて表示される。このとき、デフォルトでは「UIの仮想化」が有効になっており、アイテム・コンテナは画面に表示するために必要な個数+αしかインスタンスが作られないようになっている。

 例えば、次の図のようにGridViewコントロールを左へスクロールしたとき、左側へはみ出したアイテム・コンテナのインスタンスは、データを詰め替えられて右側に再配置されるのだ。

UIの仮想化により、アイテム・コンテナが再利用されるイメージ UIの仮想化により、アイテム・コンテナが再利用されるイメージ

 前回サンプル・アプリでデータの数を増やして、UIの仮想化の効果を確かめてみよう。次のようにダミー・データを追加するコードを変更し、前回は20個だったものを2000個に増やしてみる*1

*1 ScrollViewerコントロールを追加した状態(=前回の完成形)から始める場合には、XAMLデザイナでItemsPage.xamlファイルを開いていたら、コードを変更してビルドする前にXAMLデザイナを閉じておくこと。後述するようにアプリの動作が遅くなるのだが、XAMLデザイナでの表示も例外ではなく、表示し終わるまではVS 2012の動作も重くなってしまう。


public SampleDataSource()
{
  ……

  // ダミー・データを追加するコード
  for (int i = 7; i <= 2000; i++)  // 20→2000
  {
    string n = i.ToString();
    var g = new SampleDataGroup("Group-" + n,
                  "Group Title: " + n,
                  "Group Subtitle: " + n,
                  "Assets/DarkGray.png",
                  "Group Description: ");
    this.AllGroups.Add(g);
  }
}

Public NotInheritable Class SampleDataSource
  ……
  Public Sub New()
    ……
    ' ダミー・データを追加するコード
    For i As Integer = 7 To 2000  ' 20→2000
      Dim n As String = i.ToString()
      Dim g = New SampleDataGroup("Group-" + n,
                    "Group Title: " + n,
                    "Group Subtitle: " + n,
                    "Assets/DarkGray.png",
                    "Group Description: ")
      Me.AllGroups.Add(g)
    Next
  End Sub
End Class

前回のダミー・データを追加するコードを改修した(上:C#、下:VB)
20個だったダミー・データの数を2000個に変更した。

 ScrollViewerコントロールにGridViewコントロールを格納していない場合は、ダミー・データを2000個に増やしても起動時間の違いは気が付かないほどだし、スクロールもスムースである。これはUIの仮想化が効いているためだ。しかし、前回の記事に従ってScrollViewerコントロールを追加すると、アプリの起動に時間が掛かる(あるいは、メモリ不足で起動しなくなる)ようになり、スクロールの動きも重くなる(Visual Studio 2012の動作が重くなる場合もある)。これは、ScrollViewerコントロールの中にGridViewコントロールを入れるとUIの仮想化が無効にされてしまい、アイテム・コンテナのインスタンスがデータの数だけ(=2000個)生成されてしまうからだ。

GridViewコントロールの中のScrollViewerコントロール

 ところで、GridViewコントロールは単体で使ってもスクロールできる(デフォルト状態の分割アプリやグリッド・アプリを見れば分かるとおりだ)。これはどうなっているのだろうか?

 実は、GridViewコントロールは既定でその内部にScrollViewerコントロールを持っているのだ。そして、そのScrollViewerコントロールの中にアイテム・コンテナが配置されるのである。

 そこで、GridViewコントロールが既定で持っているこのScrollViewerコントロールを直接操作してスクロール位置を復元できれば、前回のようにGridViewコントロールをScrollViewerの中に入れる必要なく、問題は解決するだろう。

GetTemplateChildメソッド

 Windows.UI.Xaml.Controls名前空間にある全てのコントロールはGetTemplateChildメソッドを持っており、コントロールの内部にあるコントロールを取得できる*2。このメソッドを使えば、GridViewコントロールの中のScrollViewerコントロールを操作できるのだ。

 しかし、GetTemplateChildメソッドのスコープはprotected(VBではProtected)であるため、継承関係にないクラスからこのメソッドは呼び出せない。例えば、ItemsPage.xaml.csファイルで定義されるItemsPageクラスで「itemGridView.GetTemplateChild("ScrollViewer")」のような記述はできない。利用するにはコントロールを継承したクラスを作らねばならない。

*2 正確には、ビジュアル・ツリーの親子関係にある子を取得できる。見た目でコントロールの内部にあっても、ビジュアル・ツリーの親子関係になっていなければ取得できない。なお、本稿では説明しないが、VisualTreeHelperクラス(Windows.UI.Xaml.Media名前空間)を利用すれば、ビジュアル・ツリーをたどってコントロールを列挙したり、その名前を調べたりできる。


GridViewコントロールを継承してスクロール位置を復元するには?

 GridViewコントロールを継承したクラスを作り、GetTemplateChildメソッドで内部のScrollViewerコントロールを取得し、そのHorizontalOffsetプロパティを公開する。

 VS 2012のプロジェクトに新しいクラスを追加し、名前はMyGridViewクラスとする。次のコードのように、GridViewコントロールを継承させ、OnApplyTemplateメソッドをオーバーライドし、HorizontalOffsetプロパティとScrollToHorizontalOffsetメソッドを追加する。OnApplyTemplateメソッドの中では、GetTemplateChildメソッドを使って内部のScrollViewerコントロールを取得しメンバ変数に保持しておく。HorizontalOffsetプロパティとScrollToHorizontalOffsetメソッドは、メンバ変数に保持してあるScrollViewerコントロールの同名プロパティ/メソッドをそのまま公開する。また、ページ側でスクロール位置を復元するタイミングを取るために、ScrollViewerコントロールのSizeChangedイベントも公開しておく。

class MyGridView : GridView
{
  private ScrollViewer _sv;

  // テンプレートの適用直後にScrollViewerコントロールを取得し、
  // メンバ変数に保持しておく
  // また、ScrollViewerコントロールのSizeChangedイベントに
  // _sv_SizeChangedメソッドを登録しておく
  protected override void OnApplyTemplate()
  {
    base.OnApplyTemplate();

    _sv = (ScrollViewer)this.GetTemplateChild("ScrollViewer");
    _sv.SizeChanged += _sv_SizeChanged;
  }

  // HorizontalOffsetプロパティを公開する
  public double HorizontalOffset
  {
    get
    {
      return (_sv == null) ? 0.0 : _sv.HorizontalOffset;
    }
  }

  // ScrollToHorizontalOffsetメソッドを公開する
  public void ScrollToHorizontalOffset(double value)
  {
    if (_sv != null)
      _sv.ScrollToHorizontalOffset(value);
  }

  // ScrollViewerコントロールのSizeChangedイベントを公開する
  public event EventHandler<SizeChangedEventArgs> ScrollViewerSizeChanged;
  void _sv_SizeChanged(object sender, SizeChangedEventArgs e)
  {
    if (ScrollViewerSizeChanged != null)
      ScrollViewerSizeChanged.Invoke(this, e);
  }
}

Public Class MyGridView
  Inherits GridView

  Private _sv As ScrollViewer

  ' テンプレートの適用直後にScrollViewerコントロールを取得し、
  ' メンバ変数に保持しておく
  ' また、ScrollViewerコントロールのSizeChangedイベントに
  ' _sv_SizeChangedメソッドを登録しておく
  Protected Overrides Sub OnApplyTemplate()
    MyBase.OnApplyTemplate()

    _sv = Me.GetTemplateChild("ScrollViewer")
    AddHandler _sv.SizeChanged, AddressOf _sv_SizeChanged
  End Sub

  ' HorizontalOffsetプロパティを公開する
  Public ReadOnly Property HorizontalOffset As Double
    Get
      If (_sv Is Nothing) Then
        Return 0.0
      End If
      Return _sv.HorizontalOffset
    End Get
  End Property

  ' ScrollToHorizontalOffsetメソッドを公開する
  Public Sub ScrollToHorizontalOffset(value As Double)
    If (_sv IsNot Nothing) Then
      _sv.ScrollToHorizontalOffset(value)
    End If
  End Sub

  ' ScrollViewerコントロールのSizeChangedイベントを公開する
  Public Event ScrollViewerSizeChanged As EventHandler(Of SizeChangedEventArgs)
  Sub _sv_SizeChanged(sender As Object, e As SizeChangedEventArgs)
    RaiseEvent ScrollViewerSizeChanged(Me, e)
  End Sub
End Class

GridViewコントロールを継承して作ったクラス(上:C#、下:VB)

 次に、ItemsPage.xamlファイル(=最初に表示される画面の定義)を開き、GridViewの定義部分を次のコードのように変更する(この前に一度、プロジェクトをビルドしておくとよい)。コントロールを「GridView」から「local:MyGridView」に変え*3、ScrollViewerSizeChangedイベント・ハンドラを追加するのだ。

*3 2012のプロジェクトのサブフォルダにMyGridViewクラスを作成した場合は、接頭辞「local:」を適宜改める。新規にサブフォルダを作ってそこに置いた場合には、ページの先頭に名前空間の宣言も必要だ。


<local:MyGridView
        x:Name="itemGridView"
        ……
        ScrollViewerSizeChanged="itemGridView_ScrollViewerSizeChanged"
      />

最初の画面で使っているGridViewを、作成したMyGridViewに置き換えた(XAML)

 最後に、前回と同じようにしてスクロール位置の保存と復元を行う。ItemsPage.xaml.cs/.vbファイルを次のコードのように変更する。

public sealed partial class ItemsPage : MetroTips030CS.Common.LayoutAwarePage
{
  ……
  protected override void LoadState(Object navigationParameter, Dictionary<String, Object> pageState)
  {
    ……

    // 追加したコード
    if (pageState != null && pageState.ContainsKey("ScrollPosition"))
      _scrollPosition = pageState["ScrollPosition"] as double?;
  }

  ……

  // 以下は追加したコード

  private double? _scrollPosition;

  protected override void SaveState(Dictionary<string, object> pageState)
  {
    base.SaveState(pageState);
    pageState["ScrollPosition"] = this.itemGridView.HorizontalOffset;
  }

  private void itemGridView_ScrollViewerSizeChanged(object sender, SizeChangedEventArgs e)
  {
    if (_scrollPosition.HasValue)
    {
      this.itemGridView.ScrollToHorizontalOffset(_scrollPosition.Value);
      _scrollPosition = null;
    }
  }
}

Public NotInheritable Class ItemsPage
  Inherits Common.LayoutAwarePage
  ……
  Protected Overrides Sub LoadState(navigationParameter As Object, pageState As Dictionary(Of String, Object))

    ……

    ' 追加したコード
    If (pageState IsNot Nothing AndAlso pageState.ContainsKey("ScrollPosition")) Then
      _scrollPosition = pageState("ScrollPosition")
    End If
  End Sub

  ……

  ' 以下は追加したコード

  Private _scrollPosition As Nullable(Of Double)

  Protected Overrides Sub SaveState(pageState As Dictionary(Of String, Object))
    MyBase.SaveState(pageState)
    pageState("ScrollPosition") = Me.itemGridView.HorizontalOffset
  End Sub

  Private Sub itemGridView_ScrollViewerSizeChanged(sender As Object, e As SizeChangedEventArgs)
    If (_scrollPosition.HasValue) Then
      Me.itemGridView.ScrollToHorizontalOffset(_scrollPosition.Value)
      _scrollPosition = Nothing
    End If
  End Sub
End Class

画面のコードビハインドを修正した(上:C#、下:VB)

 これで完成だ。ほかの画面に遷移して戻ってきたときに、また、アプリを中断→終了させた後でまた起動したときに、スクロール位置が復元されることを確かめてほしい*4

完成した画面 完成した画面
UIの仮想化を有効にすることで2000個のデータでも軽快に動作する。また、グリッド上のマウス・ホイール操作でもスクロールするようになった(前回の方法ではスクロールしなかった)*5

*4 最も右端までスクロールした場合は、残念ながら完全には復元できない。右端まで少し残した状態になってしまうのだ(上の画像)。スクロール位置は正しく保存されるのだが、その値を設定しても若干小さな値にセットされてしまう。ScrollViewerコントロールのScrollableWidthプロパティが実際よりなぜか若干小さな値になっており、その値に制限されてしまうようだ。


*5 前回の方法でグリッド上のマウス・ホイール操作ができなくなったのは、GridViewコントロールがマウス・ホイールのイベントを受け取ってしまい、スクロールを担っている外側のScrollViewerコントロールにイベントが伝わらなかったからだ。今回は、スクロールを担っているのは通常どおりにGridViewコントロールの中のScrollViewerコントロールであるから、マウス・ホイール操作の問題もなくなった。


まとめ

 GridViewコントロールを改造することで、UIの仮想化を有効にしたままスクロール位置の保存と復元ができた。表示するデータの数が多くなると見込まれるときには、UIの仮想化を有効に保っておくことが大切だ。UIの仮想化については次のドキュメントを参照してほしい。

「WinRT/Metro TIPS」のインデックス

WinRT/Metro TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。