ScrollViewerのスクロール位置を同期する

#やりたいこと
複数のScrollViewerのスクロール位置を同期させる。ただし、同期する相手をグループに分けられるようにする。

#実装
スクロール位置を同期させるビヘイビアを作成する。

```c#:ビヘイビア
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using System.Windows;
using System.Windows.Controls;

namespace Sample {
    public class ScrollSynchronizer {
        //ScrollViewerとグループ名の対応リスト
        private static Dictionary<ScrollViewer, string> _scrollViewers 
            = new Dictionary<ScrollViewer, string>();

        //水平方向のスクロール位置のリスト
        private static Dictionary<string, double> _horizontalScrollOffsets 
            = new Dictionary<string, double>();

        //垂直方向のスクロール位置のリスト
        private static Dictionary<string, double> _verticalScrollOffsets 
            = new Dictionary<string, double>();

        //グループ名の添付プロパティ
        public static readonly DependencyProperty ScrollGroupProperty 
            = DependencyProperty.RegisterAttached(
                "ScrollGroup",
                typeof(string),
                typeof(ScrollSynchronizer),
                new PropertyMetadata(new PropertyChangedCallback(OnScrollGroupChanged))
            );

        public static void SetScrollGroup(DependencyObject obj, string scrollGroup)
            => obj.SetValue(ScrollGroupProperty, scrollGroup);

        public static string GetScrollGroup(DependencyObject obj)
            => (string)obj.GetValue(ScrollGroupProperty);

        private static void OnScrollGroupChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {
            var scrollViewer = d as ScrollViewer;

            if (scrollViewer == null)
                return;

            var oldGroup = (string)e.OldValue ?? "";
            var newGroup = (string)e.NewValue ?? "";

            if (oldGroup != "") {
                if (_scrollViewers.ContainsKey(scrollViewer)) {
                    //登録解除
                    scrollViewer.ScrollChanged -= ScrollViewer_ScrollChanged;
                    _scrollViewers.Remove(scrollViewer);
                }
            }

            if (newGroup != "") {
                if (_scrollViewers.ContainsValue(newGroup)) {
                    //既存のグループ名の場合は、現在のスクロール位置を反映する
                    scrollViewer.ScrollToHorizontalOffset(_horizontalScrollOffsets[newGroup]);
                    scrollViewer.ScrollToVerticalOffset(_verticalScrollOffsets[newGroup]);
                } else {
                    //新しいグループ名の場合は、スクロール位置を記録する
                    _horizontalScrollOffsets[newGroup] = scrollViewer.HorizontalOffset;
                    _verticalScrollOffsets[newGroup] = scrollViewer.VerticalOffset;
                }

                //ScrollViewerを登録
                _scrollViewers.Add(scrollViewer, newGroup);
                scrollViewer.ScrollChanged += ScrollViewer_ScrollChanged;
            }
        }

        private static void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) {
            var changedScrollViewer = sender as ScrollViewer;
            var group = _scrollViewers[changedScrollViewer];
            var scrollViewers = _scrollViewers.Where(s => s.Value == group && s.Key != changedScrollViewer);

            //垂直方向
            if (e.VerticalChange != 0) {
                _verticalScrollOffsets[group] = changedScrollViewer.VerticalOffset;

                //同じグループのScrollViewerにスクロール位置を反映
                scrollViewers
                    .Where(s => s.Key.VerticalOffset != _verticalScrollOffsets[group])
                    .Select(x => x.Key)
                    .ForEach(scrollViewer => scrollViewer.ScrollToVerticalOffset(_verticalScrollOffsets[group]));
            }

            //水平方向
            if (e.HorizontalChange != 0) {
                _horizontalScrollOffsets[group] = changedScrollViewer.HorizontalOffset;

                //同じグループのScrollViewerにスクロール位置を反映
                scrollViewers
                    .Where(s => s.Key.HorizontalOffset != _horizontalScrollOffsets[group])
                    .Select(x => x.Key)
                    .ForEach(scrollViewer => scrollViewer.ScrollToHorizontalOffset(_horizontalScrollOffsets[group]));
            }
        }
    }
}
```

#使い方

```xml:XAML
<Window x:Class="Sample.MainView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Sample"
        Title="MainView" Height="300" Width="300">
    <DockPanel Orientation="Vertical">
        <!--同期させたいScrollViewerに同じグループ名を指定する-->
        <ScrollViewer local:ScrollSynchronizer.ScrollGroup="Group1">
            <StackPanel>
                <Button Content="Button1" />
                <!--中略-->
                <Button Content="Button15" />
            </StackPanel>
        </ScrollViewer>
        <ScrollViewer local:ScrollSynchronizer.ScrollGroup="Group1">
            <StackPanel>
                <Button Content="Button1" />
                <!--中略-->
                <Button Content="Button15" />
            </StackPanel>
        </ScrollViewer>
    </DockPanel>
</Window>
```

#注意事項
スタイルで使用する場合は、ScrollViewerが入れ子になっていないか注意する。入れ子にしていないつもりでも、DataGridやComboBoxはScrollViewerを内蔵しているので、思わぬ影響が出ることがある。