24

I have gone through the thread:

binding two VerticalScrollBars one to another

it has almost helped to achieve the goal but still there is something missing. It is that moving the scrollbars left-right or up-down gives expected behavior of scrolling in both of my scrollviewers but when we try to scroll using/clicking arrow buttons at the ends of these scrollbars in scrollviewers only one scrollviewer is scrolled which is not the expected behavior.

So what else we need to add/edit to solve this?

Community
  • 1
  • 1
Vikram_
  • 350
  • 1
  • 2
  • 12

5 Answers5

46

One way to do this is using the ScrollChanged event to update the other ScrollViewer

<ScrollViewer Name="sv1" Height="100" 
              HorizontalScrollBarVisibility="Auto"
              ScrollChanged="ScrollChanged">
    <Grid Height="1000" Width="1000" Background="Green" />
</ScrollViewer>

<ScrollViewer Name="sv2" Height="100" 
              HorizontalScrollBarVisibility="Auto"
              ScrollChanged="ScrollChanged">
    <Grid Height="1000" Width="1000" Background="Blue" />
</ScrollViewer>

private void ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        if (sender == sv1)
        {
            sv2.ScrollToVerticalOffset(e.VerticalOffset);
            sv2.ScrollToHorizontalOffset(e.HorizontalOffset);
        }
        else
        {
            sv1.ScrollToVerticalOffset(e.VerticalOffset);
            sv1.ScrollToHorizontalOffset(e.HorizontalOffset);
        }
    }
ΩmegaMan
  • 29,542
  • 12
  • 100
  • 122
Eirik
  • 4,135
  • 27
  • 29
  • 1
    edit your code: `sv2.ScrollToHorizontalOffset` to `sv1.ScrollToHorizontalOffset` when `(sender == sv1)` is false – Epsil0neR Mar 01 '13 at 07:57
  • Thanks! that's it..! all as expected behavior now. – Vikram_ Mar 01 '13 at 08:18
  • 5
    I had to add `if (e.VerticalChange == 0 && e.HorizontalChange == 0) { return; }` in order to avoid them constantly resetting back to 0 due to the other times this event handler is called (i.e. viewport size change, extent). – E-rich Dec 17 '15 at 18:37
11

The question is for WPF, but in case anyone developing UWP stumbles upon this, I had to take a slightly different approach.
In UWP, when you set the scroll offset of the other scroll viewer (using ScrollViewer.ChangeView), it also triggers the ViewChanged event on the other scroll viewer, basically creating a loop, causing it to be very stuttery, and not work properly.

I resolved this by using a little time-out on handling the event, if the object being scrolled is not equal to the last object that handled the event.

XAML:

<ScrollViewer x:Name="ScrollViewer1" ViewChanged="SynchronizedScrollerOnViewChanged"> ... </ScrollViewer>
<ScrollViewer x:Name="ScrollViewer2" ViewChanged="SynchronizedScrollerOnViewChanged"> ... </ScrollViewer>

Code behind:

public sealed partial class MainPage
{
    private const int ScrollLoopbackTimeout = 500;

    private object _lastScrollingElement;
    private int _lastScrollChange = Environment.TickCount;

    public SongMixerUserControl()
    {
        InitializeComponent();
    }

    private void SynchronizedScrollerOnViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
    {
        if (_lastScrollingElement != sender && Environment.TickCount - _lastScrollChange < ScrollLoopbackTimeout) return;

        _lastScrollingElement = sender;
        _lastScrollChange = Environment.TickCount;

        ScrollViewer sourceScrollViewer;
        ScrollViewer targetScrollViewer;
        if (sender == ScrollViewer1)
        {
            sourceScrollViewer = ScrollViewer1;
            targetScrollViewer = ScrollViewer2;
        }
        else
        {
            sourceScrollViewer = ScrollViewer2;
            targetScrollViewer = ScrollViewer1;
        }

        targetScrollViewer.ChangeView(null, sourceScrollViewer.VerticalOffset, null);
    }
}

Note that the timeout is 500ms. This may seem a little long, but as UWP apps have an animation (or, easing, really) in their scrolling (when using the scroll wheel on a mouse), it causes the event to trigger for a few times within a few hundred milliseconds. This timeout seems to work perfectly.

René Sackers
  • 2,395
  • 4
  • 24
  • 43
  • 3
    Bravo Mate! Works perfectly with my UWP and I found an answer under the WPF question. Kudos – iam.Carrot Dec 23 '16 at 06:35
  • 1
    Thanks so much for this! It worked perfectly. In my case I have 3 scrollviewers, essentially recreating a grid with frozen first column and first row. Just had to expand your solution to include 3 scrollers, with one going horizontal and one vertical, and the third both ways. There's a slight lag and it gets a little confused if you quickly switch to scrolling a different one with mouse wheel, but it fixes itself pretty easily. – Graham Jun 18 '20 at 23:03
  • @Graham yeah that's the whole issue with the animations in those things. You can try playing with the timeout to see if it gets better or worse. – René Sackers Jun 20 '20 at 13:21
1

If it can be useful, here's a behavior (for UWP, but it's enough to get the idea); using a behavior helps to decouple view and code in a MVVM design.

using Microsoft.Xaml.Interactivity;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

public class SynchronizeHorizontalOffsetBehavior : Behavior<ScrollViewer>
{
    public static ScrollViewer GetSource(DependencyObject obj)
    {
        return (ScrollViewer)obj.GetValue(SourceProperty);
    }

    public static void SetSource(DependencyObject obj, ScrollViewer value)
    {
        obj.SetValue(SourceProperty, value);
    }

    // Using a DependencyProperty as the backing store for Source.  This enables animation, styling, binding, etc...
    public static readonly DependencyProperty SourceProperty =
        DependencyProperty.RegisterAttached("Source", typeof(object), typeof(SynchronizeHorizontalOffsetBehavior), new PropertyMetadata(null, SourceChangedCallBack));

    private static void SourceChangedCallBack(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        SynchronizeHorizontalOffsetBehavior synchronizeHorizontalOffsetBehavior = d as SynchronizeHorizontalOffsetBehavior;
        if (synchronizeHorizontalOffsetBehavior != null)
        {
            var oldSourceScrollViewer = e.OldValue as ScrollViewer;
            var newSourceScrollViewer = e.NewValue as ScrollViewer;
            if (oldSourceScrollViewer != null)
            {
                oldSourceScrollViewer.ViewChanged -= synchronizeHorizontalOffsetBehavior.SourceScrollViewer_ViewChanged;
            }
            if (newSourceScrollViewer != null)
            {
                newSourceScrollViewer.ViewChanged += synchronizeHorizontalOffsetBehavior.SourceScrollViewer_ViewChanged;
                synchronizeHorizontalOffsetBehavior.UpdateTargetViewAccordingToSource(newSourceScrollViewer);
            }
        }
    }

    private void SourceScrollViewer_ViewChanged(object sender, ScrollViewerViewChangedEventArgs e)
    {
        ScrollViewer sourceScrollViewer = sender as ScrollViewer;
        this.UpdateTargetViewAccordingToSource(sourceScrollViewer);
    }

    private void UpdateTargetViewAccordingToSource(ScrollViewer sourceScrollViewer)
    {
        if (sourceScrollViewer != null)
        {
            if (this.AssociatedObject != null)
            {
                this.AssociatedObject.ChangeView(sourceScrollViewer.HorizontalOffset, null, null);
            }
        }
    }

    protected override void OnAttached()
    {
        base.OnAttached();
        var source = GetSource(this.AssociatedObject);
        this.UpdateTargetViewAccordingToSource(source);
    }
}

Here's how to use it:

<ScrollViewer
      HorizontalScrollMode="Enabled"
      HorizontalScrollBarVisibility="Hidden"
      >
           <interactivity:Interaction.Behaviors>
              <behaviors:SynchronizeHorizontalOffsetBehavior Source="{Binding ElementName=ScrollViewer}" />
           </interactivity:Interaction.Behaviors>                                       
</ScrollViewer>
<ScrollViewer x:Name="ScrollViewer" />
Starnuto di topo
  • 3,215
  • 5
  • 32
  • 66
1

Well, I made an implementation based on https://www.codeproject.com/Articles/39244/Scroll-Synchronization but it's I think neater.

There's a synchronised scroll token that holds references to the things to scroll. Then there's the attached property that is separate. I haven't figured out how to unregister because the reference remains - so I left that unimplemented.

Eh, here goes:

public class SynchronisedScroll
{

    public static SynchronisedScrollToken GetToken(ScrollViewer obj)
    {
        return (SynchronisedScrollToken)obj.GetValue(TokenProperty);
    }
    public static void SetToken(ScrollViewer obj, SynchronisedScrollToken value)
    {
        obj.SetValue(TokenProperty, value);
    }
    public static readonly DependencyProperty TokenProperty =
        DependencyProperty.RegisterAttached("Token", typeof(SynchronisedScrollToken), typeof(SynchronisedScroll), new PropertyMetadata(TokenChanged));

    private static void TokenChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var scroll = d as ScrollViewer;
        var oldToken = e.OldValue as SynchronisedScrollToken;
        var newToken = e.NewValue as SynchronisedScrollToken;

        if (scroll != null)
        {
            oldToken?.unregister(scroll);
            newToken?.register(scroll);
        }
    }
}

and the other bit

public class SynchronisedScrollToken
{
    List<ScrollViewer> registeredScrolls = new List<ScrollViewer>();

    internal void unregister(ScrollViewer scroll)
    {
        throw new NotImplementedException();
    }

    internal void register(ScrollViewer scroll)
    {
        scroll.ScrollChanged += ScrollChanged;
        registeredScrolls.Add(scroll);
    }

    private void ScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        var sendingScroll = sender as ScrollViewer;
        foreach (var potentialScroll in registeredScrolls)
        {
            if (potentialScroll == sendingScroll)
                continue;

            if (potentialScroll.VerticalOffset != sendingScroll.VerticalOffset)
                potentialScroll.ScrollToVerticalOffset(sendingScroll.VerticalOffset);

            if (potentialScroll.HorizontalOffset != sendingScroll.HorizontalOffset)
                potentialScroll.ScrollToHorizontalOffset(sendingScroll.HorizontalOffset);
        }
    }
}

Use by defining a token in some resource accessible to all the things that need to be scroll synchronised.

<blah:SynchronisedScrollToken x:Key="scrollToken" />

And then use it wherever you need it by:

<ListView.Resources>
    <Style TargetType="ScrollViewer">
        <Setter Property="blah:SynchronisedScroll.Token"
                Value="{StaticResource scrollToken}" />
    </Style>
</ListView.Resources>

I've only tested it when scrolling vertically and it works for me.

Peter pete
  • 704
  • 1
  • 7
  • 17
0

In following up on Rene Sackers code listing in C# for UWP, here is how I addressed this same issue in VB.Net for UWP with a timeout to avoid the staggering effect because of one Scroll Viewer Object firing the event because it's view was changed by the code and not by user interaction. I put a 500 Millisecond timeout period which works well for my application.

Notes: svLvMain is a scrollviewer (for me it is the main window) svLVMainHeader is a scrollviewer (for me it is the header that goes above the main window and is what I want to track along with the main window and vice versa). Zooming or scrolling either scrollviewer will keep both scrollviewers in sync.

Private Enum ScrollViewTrackingMasterSv
    Header = 1
    ListView = 2
    None = 0
End Enum

Private ScrollViewTrackingMaster As ScrollViewTrackingMasterSv
Private DispatchTimerForSvTracking As DispatcherTimer    

Private Sub DispatchTimerForSvTrackingSub(sender As Object, e As Object)
    ScrollViewTrackingMaster = ScrollViewTrackingMasterSv.None
    DispatchTimerForSvTracking.Stop()
End Sub

Private Sub svLvTracking(sender As Object, e As ScrollViewerViewChangedEventArgs, ByRef inMastScrollViewer As ScrollViewer)
    Dim tempHorOffset As Double
    Dim tempVerOffset As Double
    Dim tempZoomFactor As Single

    Dim tempSvMaster As New ScrollViewer
    Dim tempSvSlave As New ScrollViewer

    Select Case inMastScrollViewer.Name
        Case svLvMainHeader.Name

            Select Case ScrollViewTrackingMaster
                Case ScrollViewTrackingMasterSv.Header
                    tempSvMaster = svLvMainHeader
                    tempSvSlave = svLvMain

                    tempHorOffset = tempSvMaster.HorizontalOffset
                    tempVerOffset = tempSvMaster.VerticalOffset
                    tempZoomFactor = tempSvMaster.ZoomFactor

                    tempSvSlave.ChangeView(tempHorOffset, tempVerOffset, tempZoomFactor)

                    If DispatchTimerForSvTracking.IsEnabled Then
                        DispatchTimerForSvTracking.Stop()
                        DispatchTimerForSvTracking.Start()
                    End If

                Case ScrollViewTrackingMasterSv.ListView

                Case ScrollViewTrackingMasterSv.None
                    tempSvMaster = svLvMainHeader
                    tempSvSlave = svLvMain

                    ScrollViewTrackingMaster = ScrollViewTrackingMasterSv.Header
                    DispatchTimerForSvTracking = New DispatcherTimer()
                    AddHandler DispatchTimerForSvTracking.Tick, AddressOf DispatchTimerForSvTrackingSub
                    DispatchTimerForSvTracking.Interval = New TimeSpan(0, 0, 0, 0, 500)
                    DispatchTimerForSvTracking.Start()

                    tempHorOffset = tempSvMaster.HorizontalOffset
                    tempVerOffset = tempSvMaster.VerticalOffset
                    tempZoomFactor = tempSvMaster.ZoomFactor

                    tempSvSlave.ChangeView(tempHorOffset, tempVerOffset, tempZoomFactor)
            End Select


        Case svLvMain.Name

            Select Case ScrollViewTrackingMaster
                Case ScrollViewTrackingMasterSv.Header

                Case ScrollViewTrackingMasterSv.ListView

                    tempSvMaster = svLvMain
                    tempSvSlave = svLvMainHeader

                    tempHorOffset = tempSvMaster.HorizontalOffset
                    tempVerOffset = tempSvMaster.VerticalOffset
                    tempZoomFactor = tempSvMaster.ZoomFactor

                    tempSvSlave.ChangeView(tempHorOffset, tempVerOffset, tempZoomFactor)

                    If DispatchTimerForSvTracking.IsEnabled Then
                        DispatchTimerForSvTracking.Stop()
                        DispatchTimerForSvTracking.Start()
                    End If

                Case ScrollViewTrackingMasterSv.None
                    tempSvMaster = svLvMain
                    tempSvSlave = svLvMainHeader

                    ScrollViewTrackingMaster = ScrollViewTrackingMasterSv.ListView
                    DispatchTimerForSvTracking = New DispatcherTimer()
                    AddHandler DispatchTimerForSvTracking.Tick, AddressOf DispatchTimerForSvTrackingSub
                    DispatchTimerForSvTracking.Interval = New TimeSpan(0, 0, 0, 0, 500)
                    DispatchTimerForSvTracking.Start()

                    tempHorOffset = tempSvMaster.HorizontalOffset
                    tempVerOffset = tempSvMaster.VerticalOffset
                    tempZoomFactor = tempSvMaster.ZoomFactor

                    tempSvSlave.ChangeView(tempHorOffset, tempVerOffset, tempZoomFactor)
            End Select

        Case Else
            Exit Sub

    End Select


End Sub


Private Sub svLvMainHeader_ViewChanged(sender As Object, e As ScrollViewerViewChangedEventArgs) Handles svLvMainHeader.ViewChanged

    Call svLvTracking(sender, e, svLvMainHeader)

End Sub

Private Sub svLvMain_ViewChanged(sender As Object, e As ScrollViewerViewChangedEventArgs) Handles svLvMain.ViewChanged

    Call svLvTracking(sender, e, svLvMain)

End Sub