GridViewのスクロール位置を復元するには?(その2)[Win 8]:WinRT/Metro TIPS
GridViewコントロールを継承するカスタム・コントロールを作成することで、「UIの仮想化」を有効にしたままGridViewコントロールのスクロール位置を復元する方法を解説。
powered by Insider.NET
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の仮想化の効果を確かめてみよう。次のようにダミー・データを追加するコードを変更し、前回は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
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
次に、ItemsPage.xamlファイル(=最初に表示される画面の定義)を開き、GridViewの定義部分を次のコードのように変更する(この前に一度、プロジェクトをビルドしておくとよい)。コントロールを「GridView」から「local:MyGridView」に変え*3、ScrollViewerSizeChangedイベント・ハンドラを追加するのだ。
*3 2012のプロジェクトのサブフォルダにMyGridViewクラスを作成した場合は、接頭辞「local:」を適宜改める。新規にサブフォルダを作ってそこに置いた場合には、ページの先頭に名前空間の宣言も必要だ。
<local:MyGridView
x:Name="itemGridView"
……
ScrollViewerSizeChanged="itemGridView_ScrollViewerSizeChanged"
/>
最後に、前回と同じようにしてスクロール位置の保存と復元を行う。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
これで完成だ。ほかの画面に遷移して戻ってきたときに、また、アプリを中断→終了させた後でまた起動したときに、スクロール位置が復元されることを確かめてほしい*4。
*4 最も右端までスクロールした場合は、残念ながら完全には復元できない。右端まで少し残した状態になってしまうのだ(上の画像)。スクロール位置は正しく保存されるのだが、その値を設定しても若干小さな値にセットされてしまう。ScrollViewerコントロールのScrollableWidthプロパティが実際よりなぜか若干小さな値になっており、その値に制限されてしまうようだ。
*5 前回の方法でグリッド上のマウス・ホイール操作ができなくなったのは、GridViewコントロールがマウス・ホイールのイベントを受け取ってしまい、スクロールを担っている外側のScrollViewerコントロールにイベントが伝わらなかったからだ。今回は、スクロールを担っているのは通常どおりにGridViewコントロールの中のScrollViewerコントロールであるから、マウス・ホイール操作の問題もなくなった。
●まとめ
GridViewコントロールを改造することで、UIの仮想化を有効にしたままスクロール位置の保存と復元ができた。表示するデータの数が多くなると見込まれるときには、UIの仮想化を有効に保っておくことが大切だ。UIの仮想化については次のドキュメントを参照してほしい。
Copyright© Digital Advantage Corp. All Rights Reserved.