2015年1月16日金曜日

WPFのScrollViewerやScrollBarのスクロール位置を同期させる

突然ですが、複数のScrollViewerでスクロール位置を同期させたいことってありませんか?

画面に入りきらないような大きな領域を画面に表示するのにScrollViewerはとても便利です。そして、それは自動でスクロールバーの表示のON/OFFからスクロール制御までやってくれるので、何も考えずにスクロールしたいコンテンツをScrollViewerの中に突っ込めばたいていの場合は片付きます。

そこで、複数のScrollViewerでスクロール位置を同期させたいことがしばしば出てきますよね。
例えば、2つの画像の比較ソフトを作るとき、2つの画像は同時にスクロールさせたいですよね。個別にそれぞれスクロールさせたんじゃ使い勝手が悪すぎます。
そのほかにも、例えばExcelの「ウィンドウ枠を固定」機能みたいな感じで、何かしらの表を表示させるとき、タイトル領域とそのテーブル領域でそれぞれ縦方向のみスクロールを同期したり、横方向のみスクロールを同期させたいことなんかがあると思います(ListViewとかを使えよっていうツッコミはとりあえず置いておいて)。

さて、この手の機能の実現をしようと思ったとき、WPFerならXAMLでScrollPosみたいなプロパティを2つのScrollViewer間でバインディングしてしまえばいいだろうっていう発想になるかと思います。
そもそもスクロール位置なんてUI上の問題のはずですから、ビジネスロジック等から分離してXAML内で済ませてしまいたいですよね。
そこでいろいろ調べてみるとScrollBarには確かにValueプロパティが存在してこれらをバインディングすることで複数のScrollBar間でのスクロール位置の同期はできますが、ScrollViewerにはそういったプロパティは無いんですね…。いや、正確にはContentVerticalOffsetContentHorizontalOffsetといったスクロール位置を取得できるプロパティはあります。しかし、これはセッターがprivateになっており、これを通してスクロール位置を更新することはできません。スクロール位置を変えるにはScrollToVerticalOffset()ScrollToHorizontalOffset()等のメソッドを呼び出さなければなりません。XAMLからメソッドを呼び出すのはとても難しいですね。

簡単な解決方法としては、コードビハインドでScrollViewerやScrollBarのイベントをキャッチして、その値を更新し同期させるということでしょう。しかし、XAMLからどのコントロール同士が同期しているのかが見通せなくなりますし、コードビハインドも、例えば4つのScrollViewerのうち2つずつが同期する場合などは本当にややこしいコードになってしまいそうです。こういうのは望ましくないですね。


そこでBehaviorがあるんですよ。


BehaviorはViewの振る舞いをC#等で記述できる仕組みです。コードビハインドほど特定のウィンドウやコントロールに密接にくっついているものではないので、ある程度の幅を持たせることができます。さらに、BehaviorはDependencyObjectを継承しているので依存プロパティを持たせられ、非常にXAMLとの親和性も高いです。

というわけで、まず、使う側から考えてみます。

<ScrollViewer HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden">
    <!-- 中略 -->
    <i:Interaction.Behaviors>
        <b:ScrollSyncronizingBehavior ScrollGroup="Group1" Orientation="Vertical" />
    </i:Interaction.Behaviors>
</ScrollViewer>

こんな風にScrollViewerにスクロールを同期させるBehaviorを設定してあげます。そして、そこにScrollGroupでグループ名を指定し、グループ名が同じScrollViewerやScrollBarは全部スクロールを同期するようにしています。Orientationはスクロールを同期させる向きです。縦横両方向とも同期したければ、このScrollSyncronizingBehaviorを縦と横2つ設定してやればいいでしょう。
こうして、複数個所のScrollViewerやScrollBarが同期させられれば便利ですね。

そのBehaviorの実装がこれになります。

public class ScrollSyncronizingBehavior : Behavior<Control>
{
    static Dictionary<string, List<Control>> SyncGroups = new Dictionary<string, List<Control>>();

    protected override void OnAttached()
    {
        base.OnAttached();

        AddSyncGroup(ScrollGroup);
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();

        RemoveSyncGroup(ScrollGroup);
    }

    /// <summary>
    /// スクロールグループ
    /// </summary>
    public string ScrollGroup
    {
        get { return (string)this.GetValue(ScrollGroupProperty); }
        set { this.SetValue(ScrollGroupProperty, value); }
    }
    private static readonly DependencyProperty ScrollGroupProperty = DependencyProperty.Register(
        "ScrollGroup", typeof(string), typeof(ScrollSyncronizingBehavior), new FrameworkPropertyMetadata((d, e) => {
            ScrollSyncronizingBehavior me = (ScrollSyncronizingBehavior)d;

            me.RemoveSyncGroup((string)e.OldValue);
            me.AddSyncGroup((string)e.NewValue);
        })
    );

    /// <summary>
    /// スクロールの向き
    /// </summary>
    public Orientation Orientation
    {
        get { return (Orientation)this.GetValue(OrientationProperty); }
        set { this.SetValue(OrientationProperty, value); }
    }
    private static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
        "Orientation", typeof(Orientation), typeof(ScrollSyncronizingBehavior), new FrameworkPropertyMetadata()
    );

    /// <summary>
    /// 同期グループに追加するメソッド
    /// </summary>
    /// <param name="GroupName">グループ名</param>
    /// <returns>成功したかどうか</returns>
    bool AddSyncGroup(string GroupName)
    {
        if(!string.IsNullOrEmpty(ScrollGroup) && (this.AssociatedObject is ScrollViewer || this.AssociatedObject is ScrollBar)) {
            if(!SyncGroups.ContainsKey(GroupName))
                SyncGroups.Add(GroupName, new List<Control>());
            SyncGroups[GroupName].Add(this.AssociatedObject);

            ScrollViewer sv = this.AssociatedObject as ScrollViewer;
            ScrollBar sb = this.AssociatedObject as ScrollBar;

            if(sv != null)
                sv.ScrollChanged += ScrollViewerScrolled;
            if(sb != null)
                sb.ValueChanged += ScrollBarScrolled;
            
            return true;
        } else
            return false;
    }

    /// <summary>
    /// 同期グループから削除するメソッド
    /// </summary>
    /// <param name="GroupName">グループ名</param>
    /// <returns>成功したかどうか</returns>
    bool RemoveSyncGroup(string GroupName)
    {
        if(!string.IsNullOrEmpty(ScrollGroup) && (this.AssociatedObject is ScrollViewer || this.AssociatedObject is ScrollBar)) {
            ScrollViewer sv = this.AssociatedObject as ScrollViewer;
            ScrollBar sb = this.AssociatedObject as ScrollBar;
            
            if(sv != null)
                sv.ScrollChanged -= ScrollViewerScrolled;
            if(sb != null)
                sb.ValueChanged -= ScrollBarScrolled;

            SyncGroups[GroupName].Remove(this.AssociatedObject);
            if(SyncGroups[GroupName].Count == 0)
                SyncGroups.Remove(GroupName);
            
            return true;
        } else
            return false;
    }

    /// <summary>
    /// ScrollViewerの場合の変更通知イベントハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void ScrollViewerScrolled(object sender, ScrollChangedEventArgs e)
    {
        UpdateScrollValue(sender, Orientation == Orientation.Horizontal ? e.HorizontalOffset : e.VerticalOffset);
    }

    /// <summary>
    /// ScrollBarの場合の変更通知イベントハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void ScrollBarScrolled(object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        UpdateScrollValue(sender, e.NewValue);
    }
    
    /// <summary>
    /// スクロール値を設定するメソッド
    /// </summary>
    /// <param name="sender">スクロール値を更新してきたコントロール</param>
    /// <param name="NewValue">新しいスクロール値</param>
    void UpdateScrollValue(object sender, double NewValue)
    {
        IEnumerable<Control> others = SyncGroups[ScrollGroup].Where(p => p != sender);

        foreach(ScrollBar sb in others.OfType<ScrollBar>().Where(p => p.Orientation == Orientation))
            sb.Value = NewValue;
        foreach(ScrollViewer sv in others.OfType<ScrollViewer>()) {
            if(Orientation == Orientation.Horizontal)
                sv.ScrollToHorizontalOffset(NewValue);
            else
                sv.ScrollToVerticalOffset(NewValue);
        }                
    }
}

BehaviorはBehavior<T>を継承しますが、このTはBehaviorを設定するクラスになります。普段は特定のクラスを入れることが多いですが、今回はScrollViewerとScrollBarの両方に対応させるために、その共通の親クラスであるControlクラスを指定しています。

OnAttached()とOnDetaching()は、このBehaviorが特定のコントロールにアタッチ/デタッチされたときに呼ばれるメソッドです。ここで登録作業をしてあげます。
一方で、依存関係プロパティのScrollGroupでも、グループ名が変更されたときにその対応をそちらでしています。

グループ名の管理はstaticなDictionaryでやっていて、グループ名をキーに同じグループのインスタンスをListで保持するようにしています。そして、ScrollViewerならScrollChanged、ScrollBarならValueChangedイベントをハンドルするようにして、そこで同じグループ内の全インスタンスに対してスクロールをするメソッドを呼び出しています。


ちなみにですが、ScrollBarコントロールはスクロールバーのつまみの大きさを制御してやる必要があります。画面に対してコンテンツの大きさが大きければつまみは小さくなりますよね。
これはBehaviorをわざわざ使わなくてもXAML内のバインディングで可能です。

<ScrollViewer Name="scrollViewer1" />
<ScrollBar Orientation="Vertical" Maximum="{Binding ElementName=scrollViewer1, Path=ScrollableHeight}" Minimum="0"
           ViewportSize="{Binding ElementName=scrollViewer1, Path=ActualHeight}" />

ScrollViewerに適当な名前を付けてあげて、それをScrollBarのほうで参照しています。使うのはMaximum、Minimum、ViewportSizeです。MaximumとMinimumはスクロールするコンテンツの最大座標と最小座標で、ViewportSizeはそれを表示する画面領域のサイズです。なので、例えば縦方向ならばScrollViewerの上記のようなプロパティに対応しています。まあ、あとはSmallChangeやLargeChangeの設定をしてあげれば良いでしょう。

このようにScrollViewerとScrollBarのスクロール値までプロパティで同期できれば申し分なかったのですが、それはできなかったのでこうやってBehaviorで実現してみました。
BehaviorでScrollViewerのスクロール位置をセットもゲットもできる依存プロパティを用意してバインディングすればいいんじゃないか?と思う人もいるかもしれませんが、なぜかうまくいきませんでした。 なぜでしょうね。僕のコードのどこかが悪かったんでしょうかね。

試しに2枚の画像のスクロールを同期させるソフトを書いてみました。


2枚の画像が並んでいます。どのスクロールバーでも動かせますし、ScrollViewer上でマウスホイールでスクロールしてもタッチパネルでスクロールしても全部連動します。

一応、このプロジェクトもうpしておきました。
参考にどうぞ。

ScrollSync.zip (1,184KB)

1 件のコメント:

  1. WPF初心者のベルレと申します。
    現在業務アプリでWPFを採用して実装をしているところです。

    本記事のプログラムを参考に、並列で動くスクロールバーを私のソースコードでも実装することが出来ました。
    ありがとうございます。

    ただ、Behaviorのソースコードに問題があって、記事のコードから簡単な修正が必要でした。
    現在の私の環境では、Prismを使用しています。

    スクロールバーの並列動作系のイベント以外で、ScrollViewerに変更通知が発生するようなイベントがある場合。
    私の場合はコードビハインドでMouseMoveイベントを補足して、マウスの座標に追従するように画像の特定領域を線で描画するのを追加するといった内容でした。

    このとき、ScrollViewerにマウスが入ってくると、自動的にBehaviorの105行目のコード
    UpdateScrollValue(sender, Orientation == Orientation.Horizontal ? e.HorizontalOffset : e.VerticalOffset);
    が実行されることが原因で、UpdateScrollValueが二回実行されてしまい、ScrollViewerが左上端に移動してしまいました。

    ScrollChangedEventArgs eの中身を確認したところ、
    1回目はe.GetPosition()では"0"の値が入っていて、2回目は1回目の変更を反映させるために呼ばれたものでした。

    この問題を回避するため、105行目のコードを次のように置き換えたところ、上手く動作しました。
    ただ、HorizontalChange, VerticalChangeが0.0になる、という状況をそのまま使っていいのかというのは疑問なところです。

    if( e.HorizontalChange == 0.0 && e.VerticalChange == 0.0)
    {
    e.Handled = true;
    return;
    }
    else if (e.HorizontalChange != 0.0 && Orientation == Orientation.Horizontal)
    {
    UpdateScrollValue(sender, e.HorizontalOffset );
    }
    else if (e.VerticalChange != 0.0 && Orientation == Orientation.Vertical)
    {
    UpdateScrollValue(sender, e.VerticalOffset);
    }

    ちょっと分かりにくい内容となってしまい、申し訳ないのですが、時間があればサンプルコードでも確認できるかどうか試してみます。
    (私は現在Prismを使っていて、サンプルプロジェクトの変更がすぐに出来なかった点、ご容赦ください。)

    返信削除