しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Windows ストアアプリで画面遷移前のスクロール位置を記憶するための Behavior を作る

Windows ストアアプリでは以下のようにツリー状の階層構造をもったコンテンツを、その階層構造に合わせたグループにまとめてそれぞれ画面分割することが多いです

Hub - Section - Detail

そのため、戻り遷移をしてより上位の画面に移る場合は、必然的にそれまで見ていた詳細画面の親グループを意識することになるので、スクロール位置が元の位置に戻っていないとユーザーのスムーズな現在地点の把握を妨げてしまいます・・・

というわけで画面遷移前のスクロール位置を記憶したい・・・しかし Hub や GridView、ListView の ScrollViewer の HorizontalOffset や VerticalOffset プロパティは公開されていないんですよね;

どうするかといえばいろんな画面で使いそうなので専用の Behavior を作ることにしました

    /// <summary>
    /// 内部の ScrollViewer にバインド可能にするビヘイビア
    /// </summary>
    public class ScrollBarBindableBehavior : DependencyObject, IBehavior
    {
        #region HorizonalOffset 依存関係プロパティ
        /// <summary>
        /// 水平スクロール位置 依存関係プロパティ
        /// </summary>
        private static readonly DependencyProperty HorizontalOffsetOffsetProperty
            = DependencyProperty.Register("HorizontalOffset", typeof(double), typeof(ScrollBarBindableBehavior), new PropertyMetadata(0d, (s, e) =>
            {
                var control = s as ScrollBarBindableBehavior;
                if (control != null)
                {
                    control.OnHorizonalOffsetChanged();
                }
            }));

        /// <summary>
        /// 水平スクロール位置 変更イベントハンドラ
        /// </summary>
        private void OnHorizonalOffsetChanged()
        {
            if (this.isReady  && this.ScrollViewer != null)
            {
                this.ScrollViewer.ChangeView(this.HorizontalOffset, null, null, false);
            }
        }

        /// <summary>
        /// 水平スクロール位置
        /// </summary>
        public double HorizontalOffset
        {
            get { return (double)this.GetValue(HorizontalOffsetOffsetProperty); }
            set { this.SetValue(HorizontalOffsetOffsetProperty, value); }
        }
        #endregion //HorizonalOffset 依存関係プロパティ

        #region VerticalOffset 依存関係プロパティ
        /// <summary>
        /// 垂直スクロール位置 依存関係プロパティ
        /// </summary>
        private static readonly DependencyProperty VerticalOffsetProperty
            = DependencyProperty.Register("VerticalOffset", typeof(double), typeof(ScrollBarBindableBehavior), new PropertyMetadata(0d, (s, e) =>
            {
                var control = s as ScrollBarBindableBehavior;
                if (control != null)
                {
                    control.OnVerticalOffsetChanged();
                }
            }));

        /// <summary>
        /// 垂直スクロール位置 変更イベントハンドラ
        /// </summary>
        private void OnVerticalOffsetChanged()
        {
            if (this.isReady && this.ScrollViewer != null)
            {
                this.ScrollViewer.ChangeView(null, this.VerticalOffset, null, false);
            }
        }

        /// <summary>
        /// 垂直スクロール位置
        /// </summary>
        public double VerticalOffset
        {
            get { return (double)this.GetValue(VerticalOffsetProperty); }
            set { this.SetValue(VerticalOffsetProperty, value); }
        }
        #endregion //VerticalOffset 依存関係プロパティ

        #region ZoomFactor 依存関係プロパティ
        /// <summary>
        /// 拡大率 依存関係プロパティ
        /// </summary>
        private static readonly DependencyProperty ZoomFactorProperty
            = DependencyProperty.Register("ZoomFactor", typeof(float), typeof(ScrollBarBindableBehavior), new PropertyMetadata(1f, (s, e) =>
            {
                var control = s as ScrollBarBindableBehavior;
                if (control != null)
                {
                    control.OnZoomFactorChanged();
                }
            }));

        /// <summary>
        /// 拡大率 変更イベントハンドラ
        /// </summary>
        private void OnZoomFactorChanged()
        {
            if (this.isReady && this.ScrollViewer != null)
            {
                this.ScrollViewer.ChangeView(null, null, this.ZoomFactor, false);
            }
        }

        /// <summary>
        /// 拡大率
        /// </summary>
        public float ZoomFactor
        {
            get { return (float)this.GetValue(ZoomFactorProperty); }
            set { this.SetValue(ZoomFactorProperty, value); }
        }
        #endregion //ZoomFactor 依存関係プロパティ

        /// <summary>
        /// アタッチ対象のオブジェクト
        /// </summary>
        public DependencyObject AssociatedObject { get; set; }

        /// <summary>
        /// ScrollViewer
        /// </summary>
        public ScrollViewer ScrollViewer { get; set; }

        /// <summary>
        /// 表示完了フラグ
        /// </summary>
        private bool isReady = false;

        /// <summary>
        /// アタッチする
        /// </summary>
        /// <param name="associatedObject">アタッチ対象オブジェクト</param>
        public void Attach(DependencyObject associatedObject)
        {
            this.AssociatedObject = associatedObject;
            if (associatedObject is FrameworkElement)
            {
                ((FrameworkElement)associatedObject).SizeChanged += this.OnSizeChanged;
            }
        }

        /// <summary>
        /// 表示完了イベントハンドラ
        /// </summary>
        /// <param name="sender">イベント発行者</param>
        /// <param name="e">イベント引数</param>
        private void OnSizeChanged(object sender, SizeChangedEventArgs e)
        {
            this.ScrollViewer = FindChild<ScrollViewer>(this.AssociatedObject);
            if (this.ScrollViewer != null)
            {
                this.isReady = true;
                this.ScrollViewer.ChangeView(this.HorizontalOffset, this.VerticalOffset, this.ZoomFactor, false);
                this.ScrollViewer.ViewChanged += this.OnViewChanged;
            }
        }

        /// <summary>
        /// スクロールおよびズーム変更イベントハンドラ
        /// </summary>
        /// <param name="sender">イベント発行者</param>
        /// <param name="e">イベント引数</param>
        private void OnViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
        {
            if (this.ScrollViewer != null)
            {
                this.HorizontalOffset = this.ScrollViewer.HorizontalOffset;
                this.VerticalOffset = this.ScrollViewer.VerticalOffset;
                this.ZoomFactor = this.ScrollViewer.ZoomFactor;
            }
        }

        /// <summary>
        /// デタッチする
        /// </summary>
        public void Detach()
        {
            if (this.AssociatedObject is FrameworkElement)
            {
                ((FrameworkElement)this.AssociatedObject).SizeChanged -= this.OnSizeChanged;
            }
            if (this.ScrollViewer != null)
            {
                this.ScrollViewer.ViewChanged -= this.OnViewChanged;
            }
            this.AssociatedObject = null;
            this.ScrollViewer = null;
        }

        /// <summary>
        /// 指定した型の最初に見つかったビジュアル要素を返す
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="root">探索対象のビジュアル要素</param>
        /// <returns>見つかった場合はその要素</returns>
        private static T FindChild<T>(DependencyObject root) where T : FrameworkElement
        {
            int childNumber = VisualTreeHelper.GetChildrenCount(root);
            for (int i = 0; i < childNumber; i++)
            {
                DependencyObject child = VisualTreeHelper.GetChild(root, i);
                if (child != null && child is T)
                {
                    return child as T;
                }
                else
                {
                    return FindChild<T>(child);
                }
            }
            return null;
        }
    }

添付したコントロールの ScrollViewer を VisualTreeHelper で強引に引っこ抜いてプロパティを依存プロパティで公開しています

Hub や GridView、ListView はアタッチ直後は画面に展開されていないので、SizeChanged イベント後に引っこ抜かないといけないのがポイントでした

あとは Blend の以下のアセンブリ参照を追加したうえで

xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"

こんな感じに Horizontaloffset を ViewModel にバインドしておけば復元できました

<GridView 
    x:Name="itemGridView"
    AutomationProperties.AutomationId="ItemGridView"
    AutomationProperties.Name="Grouped Items"
    Grid.Row="1" 
    Padding="120,0,120,40"
    SizeChanged="OnSizeChanged"
    ItemsSource="{Binding Source={StaticResource groupedItemsViewSource}}"
    ItemTemplate="{StaticResource ItemTemplate}"
    IsItemClickEnabled="True"
    ItemClick="OnItemClick"
    SelectionMode="None"
    IsSwipeEnabled="false">
    
    <Interactivity:Interaction.Behaviors>
        <b:ScrollBarBindableBehavior HorizontalOffset="{Binding HorizontalOffset, Mode=TwoWay}"/>
    </Interactivity:Interaction.Behaviors>

</GridView>

ここでバインドする ViewModel は NavigationHelper.SaveStated イベントハンドラの中で PageState に保存しておくと、ストアアプリが Terminated から復帰する際もスクロール位置が維持されるのでグッドですね Hub → Spoke のような遷移の場合はスクロール位置をリセットする必要があります

Terminated については以下でもう少し詳しく書いたので気になる方はご参考ください

ストアアプリのターミネーターに対処せよ!(事件編)

ストアアプリのターミネーターに対処せよ!(解決編)