GridViewのスクロール位置を復元するとある方法

@ITのGridViewのスクロール位置を復元するには?の記事を読んでいて、うーむ正攻法だなぁと感心していました。GetTemplateChildメソッドがprotectedなので、正攻法ではGridViewを継承したコントロールを作らざるを得ないのも事実です。ちょっとしたアプリに、新しいコントロールを作りたくない私の場合は、別の方策を考えてみます。最初に考えたのが、何はなくともReflectionです。リフレクションは、Windowsストアアプリだとprotectedメンバーを残念ながら取得することができません。じゃあ、どうしようかと考えていてXAMLのVisualTreeを辿れれば、何とかなるのではなかろいうかという考えです。試しにGridViewコントロールに対して、VisualTreeを調べてみるとScrollViewerコントロールのインスタンスが存在しています。後は、このインスタンスを取り出せば、何とかなるだろうと考えて作成したのが、以下のコードになります。

 static class VisualTreeExtension
{
  // 指定したChildオブジェト型のインスタンスを返します
  public static T GetChildObject<T>(DependencyObject start)
  {
    var children = GetDescendants(start);
    var x = children.OfType<T>().ToList();
    var i = x.FirstOrDefault();
    return i;
  }
  // Childコレクションを作成します
  internal static IEnumerable<DependencyObject> GetDescendants(
                  DependencyObject start)
  {
    var queue = new Queue<DependencyObject>();
    var count = VisualTreeHelper.GetChildrenCount(start);
    for (int i = 0; i < count; i++) // 1レベルの子要素を取得します
    {
        var child = VisualTreeHelper.GetChild(start, i);
        yield return child;
        queue.Enqueue(child);
    }
    while (queue.Count > 0)
    {
        var parent = queue.Dequeue();
        var childCount = VisualTreeHelper.GetChildrenCount(parent);
        for (int i = 0; i < childCount; i++)    // 2レベル以降の子要素を取得します
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            yield return child;
            queue.Enqueue(child);
        }
    }
  }
}

このGetChildObjectメソッドをGetChild<ScrollViewer>(itemGridViewer)のように呼び出せば、ScrollViewerのインスタンスを取得することができます。  それでは、GroupedItemsPage.xaml.csにどのように組み込むかを説明します。
基本的な考え方は、他のページへ遷移するタイミングでScrollViewer.HorizontalOffsetを保存しておいて、GroupedItemsPageへ戻ってきた時に保存したHorizontalOffsetを読みだして、移動するというものです。このために、メンバー変数を追加し、とItemGridViewのLoadedイベントとSizeChangedイベントに次のコードを記述します。

 ScrollViewer _sv;
double? _position;

// 起動時に移動させるロジック
private void itemGridView_SizeChanged(object sender, SizeChangedEventArgs e)
{
    if (_position.HasValue)
    {
        _sv = VisualTreeExtension.GetChildObject<ScrollViewer>(itemGridView);
        _sv.ScrollToHorizontalOffset(_position.Value);
        _position = null;
    }

}
// SaveStateで移動量を取得するためにScrollViewerのインスタンスを取得
private void itemGridView_Loaded(object sender, RoutedEventArgs e)
{
    _sv = VisualTreeExtension.GetChildObject<ScrollViewer>(itemGridView);
}

メンバー変数(_sv)にLoadSateメソッドではなく、LoadedイベントでScrollViewerのインスタンスを設定していることに注意してください。この理由は、OnNavigatedToイベントハンドラより呼び出されるLoadSateメソッドのタイミングでは、ScrollViewerなどのインスタンスが作成されていない場合があるためです。次に、ScrollViewerの移動量を保存するコードをSaveStateメソッドに記述し、LoadStateメソッドで保存した移動量を読みだすコードを記述します。 

 protected override void LoadState(Object navigationParameter, 
                        Dictionary<String, Object> pageState)
{
    // TODO: 問題のドメインでサンプル データを置き換えるのに適したデータ モデルを作成します
    var sampleDataGroups = SampleDataSource.GetGroups((String)navigationParameter);
    this.DefaultViewModel["Groups"] = sampleDataGroups;

    if (pageState != null)
    {
        if (pageState.ContainsKey("position"))
        {
            _position = pageState["position"] as double?;
        }
    }
}
protected override void SaveState(Dictionary<string, object> pageState)
{
    base.SaveState(pageState);
    if (_sv != null)
    {
        _position = _sv.HorizontalOffset;
        pageState["position"] = _position;
    }
}

これでScrollViewerの移動量を復元できるようになります。しかし、実際に色々と試した結果として、グリッドアプリケーションテンプレートはうまく動作しますが、NewsReaderテンプレートのようにデータモデルを非同期で読み込むパターンだと、データの読み込みが遅延している関係とUIの仮想化との組み合わせで、うまく動作しないことが多々あります。このような場合は、移動したいアイテムとなるデータオブジェクトのインスタンスを取得して、itemGridViewのSizeChangedイベントで、GridView.ScrollIntoView(アイテムオブジェクト)メソッドを使った方が良いでしょう。

また、VisualTreeを使ってオブジェクトを探すコードを自分で記述しない場合は、WinRT XAML Toolkitの GetFirstDescendantOfType<T>拡張メソッドを使うのも良いでしょう。