Search code examples
vb.netwinformswinapiscrollbarrichtextbox

Synchronize the Scroll position of two Controls with different content


I use this simple code to set the position of two Scrollbars of different RichTextBox Controls, at same time.
The trouble comes when the text of a RichTextBox is longer that the other.

Any suggestion? How can I calculate the percentage of the difference, to synchronize the scroll position of the two Controls, e.g., at the start/middle/end, at same time?

Const WM_USER As Integer = &H400
Const EM_GETSCROLLPOS As Integer = WM_USER + 221
Const EM_SETSCROLLPOS As Integer = WM_USER + 222
Declare Function SendMessage Lib "user32.dll" Alias "SendMessageW" (ByVal hWnd As IntPtr, ByVal msg As Integer, ByVal wParam As Integer, ByRef lParam As Point) As Integer

Private Sub RichTextBox1_VScroll(sender As Object, e As EventArgs) Handles RichTextBox1.VScroll
    Dim pt As Point
    SendMessage(RichTextBox1.Handle, EM_GETSCROLLPOS, 0, pt)
    SendMessage(RichTextBox2.Handle, EM_SETSCROLLPOS, 0, pt)
End Sub

Private Sub RichTextBox2_VScroll(sender As Object, e As EventArgs) Handles RichTextBox2.VScroll
    Dim pt As Point
    SendMessage(RichTextBox2.Handle, EM_GETSCROLLPOS, 0, pt)
    SendMessage(RichTextBox1.Handle, EM_SETSCROLLPOS, 0, pt)
End Sub

Solution

  • The procedure is described here:
    How to scroll a RichTextBox control to a given point regardless of caret position

    • You need to calculate the maximum Scroll value of your Controls

    • Consider the ClientSize.Height and the Font.Height: both play a role when we define the maximum scroll position. The max Vertical Scroll Value is defined by:

      MaxVerticalScroll = Viewport.Height - ClientSize.Height + Font.Height - BorderSize  
      

      where Viewport is the overall internal surface of a Control that includes all its content.
      It's often returned by the PreferredSize property (which belongs to the Control class), but, e.g., the RichTextBox, sets the PreferredSize before text wrapping, so it's just relative to the unwrapped text, not really useful here.
      You determine the base distance manually (as described in the link above), or use the GetScrollInfo() function. It returns a SCROLLINFO structure that contains the absolute Minimum and Maximum Scroll value and the current Scroll Position.

    • Calculate the relative difference of the two maximum scroll positions: this is the multiplier factor used to scale the two scroll positions, to generate a common relative value.

    Important: using the VScroll event, you have to introduce a variable that prevents the two Control from triggering the Scroll action of the counterpart over and over, causing a StackOverflow exception.
    See the VScroll event handler and the use of the synchScroll boolean Field.

    ▶ The SyncScrollPosition() method calls the GetAbsoluteMaxVScroll() and GetRelativeScrollDiff() methods that calculate the relative scroll values, then calls SendMessage to set the Scroll position of the Control to synchronize.
    Both accept TextBoxBase arguments, since RichTextBox derives from this base class, as the TextBox class, so you can use the same methods for both RichTextBox and TextBox Controls without any change.

    ▶ Use the SendMessage declaration you find here, among the others.

    Private synchScroll As Boolean = False
    
    Private Sub richTextBox1_VScroll(sender As Object, e As EventArgs) Handles RichTextBox1.VScroll
        SyncScrollPosition(RichTextBox1, RichTextBox2)
    End Sub
    
    Private Sub richTextBox2_VScroll(sender As Object, e As EventArgs) Handles RichTextBox2.VScroll
        SyncScrollPosition(RichTextBox2, RichTextBox1)
    End Sub
    
    Private Sub SyncScrollPosition(ctrlSource As TextBoxBase, ctrlDest As TextBoxBase)
        If synchScroll Then Return
        synchScroll = True
    
        Dim infoSource = GetAbsoluteMaxVScroll(ctrlSource)
        Dim infoDest = GetAbsoluteMaxVScroll(ctrlDest)
        Dim relScrollDiff As Single = GetRelativeScrollDiff(infoSource.nMax, infoDest.nMax, ctrlSource, ctrlDest)
    
        Dim nPos = If(infoSource.nTrackPos > 0, infoSource.nTrackPos, infoSource.nPos)
        Dim pt = New Point(0, CType((nPos + 0.5F) * relScrollDiff, Integer))
        SendMessage(ctrlDest.Handle, EM_SETSCROLLPOS, 0, pt)
        synchScroll = False
    End Sub
    
    Private Function GetAbsoluteMaxVScroll(ctrl As TextBoxBase) As SCROLLINFO
        Dim si = New SCROLLINFO(SBInfoMask.SIF_ALL)
        GetScrollInfo(ctrl.Handle, SBParam.SB_VERT, si)
        Return si
    End Function
    
    Private Function GetRelativeScrollDiff(sourceScrollMax As Integer, destScrollMax As Integer, source As TextBoxBase, dest As TextBoxBase) As Single
        Dim border As Single = If(source.BorderStyle = BorderStyle.None, 0F, 1.0F)
        Return (CSng(destScrollMax) - dest.ClientSize.Height) / (sourceScrollMax - source.ClientSize.Height - border)
    End Function
    

    Win32 methods declarations:

    Imports System.Runtime.InteropServices
    
    Private Const WM_USER As Integer = &H400
    Private Const EM_GETSCROLLPOS As Integer = WM_USER + 221
    Private Const EM_SETSCROLLPOS As Integer = WM_USER + 222
    
    <DllImport("user32.dll", CharSet:=CharSet.Auto, SetLastError:=True)>
    Friend Shared Function SendMessage(hWnd As IntPtr, msg As Integer, wParam As Integer, <[In], Out> ByRef lParam As Point) As Integer
    End Function
    
    <DllImport("user32.dll")>
    Friend Shared Function GetScrollInfo(hwnd As IntPtr, fnBar As SBParam, ByRef lpsi As SCROLLINFO) As Boolean
    End Function
    
    <StructLayout(LayoutKind.Sequential)>
    Friend Structure SCROLLINFO
        Public cbSize As UInteger
        Public fMask As SBInfoMask
        Public nMin As Integer
        Public nMax As Integer
        Public nPage As UInteger
        Public nPos As Integer
        Public nTrackPos As Integer
    
        Public Sub New(mask As SBInfoMask)
            cbSize = CType(Marshal.SizeOf(Of SCROLLINFO)(), UInteger)
            fMask = mask : nMin = 0 : nMax = 0 : nPage = 0 : nPos = 0 : nTrackPos = 0
        End Sub
    End Structure
    
    Friend Enum SBInfoMask As UInteger
        SIF_RANGE = &H1
        SIF_PAGE = &H2
        SIF_POS = &H4
        SIF_DISABLENOSCROLL = &H8
        SIF_TRACKPOS = &H10
        SIF_ALL = SIF_RANGE Or SIF_PAGE Or SIF_POS Or SIF_TRACKPOS
        SIF_POSRANGE = SIF_RANGE Or SIF_POS Or SIF_PAGE
    End Enum
    
    Friend Enum SBParam As Integer
        SB_HORZ = &H0
        SB_VERT = &H1
        SB_CTL = &H2
        SB_BOTH = &H3
    End Enum
    

    This is how it works:
    Note that the two Controls contain different text and also use a different Font:

    • Segoe UI, 9.75pt the Control above
    • Microsoft Sans Serif, 9pt the other

    ScrollBars Sychronize


    C# Version:

    private bool synchScroll = false;
    
    private void richTextBox1_VScroll(object sender, EventArgs e)
    {
        SyncScrollPosition(richTextBox1, richTextBox2);
    }
    
    private void richTextBox2_VScroll(object sender, EventArgs e)
    {
        SyncScrollPosition(richTextBox2, richTextBox1);
    }
    
    private void SyncScrollPosition(TextBoxBase ctrlSource, TextBoxBase ctrlDest) { 
        if (synchScroll) return;
        synchScroll = true;
    
        var infoSource = GetAbsoluteMaxVScroll(ctrlSource);
        var infoDest = GetAbsoluteMaxVScroll(ctrlDest);
        float relScrollDiff = GetRelativeScrollDiff(infoSource.nMax, infoDest.nMax, ctrlSource, ctrlDest);
    
        int nPos = infoSource.nTrackPos > 0 ? infoSource.nTrackPos : infoSource.nPos;
        var pt = new Point(0, (int)((nPos + 0.5F) * relScrollDiff));
        SendMessage(ctrlDest.Handle, EM_SETSCROLLPOS, 0, ref pt);
        synchScroll = false;
    }
    
    private SCROLLINFO GetAbsoluteMaxVScroll(TextBoxBase ctrl) {
        var si = new SCROLLINFO(SBInfoMask.SIF_ALL);
        GetScrollInfo(ctrl.Handle, SBParam.SB_VERT, ref si);
        return si;
    }
    
    private float GetRelativeScrollDiff(int sourceScrollMax, int destScrollMax, TextBoxBase source, TextBoxBase dest) {
        float border = source.BorderStyle == BorderStyle.None ? 0F : 1.0F;
        return ((float)destScrollMax - dest.ClientSize.Height) / ((float)sourceScrollMax - source.ClientSize.Height - border);
    }
    

    Declarations:

    using System.Runtime.InteropServices;
    
    private const int WM_USER = 0x400;
    private const int EM_GETSCROLLPOS = WM_USER + 221;
    private const int EM_SETSCROLLPOS = WM_USER + 222;
    
    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    internal static extern int SendMessage(IntPtr hWnd, int msg, int wParam, [In, Out] ref Point lParam);
    
    
    [DllImport("user32.dll")]
    internal static extern bool GetScrollInfo(IntPtr hwnd, SBParam fnBar, ref SCROLLINFO lpsi);
    
    
    [StructLayout(LayoutKind.Sequential)]
    internal struct SCROLLINFO {
        public uint cbSize;
        public SBInfoMask fMask;
        public int nMin;
        public int nMax;
        public uint nPage;
        public int nPos;
        public int nTrackPos;
    
        public SCROLLINFO(SBInfoMask mask)
        {
            cbSize = (uint)Marshal.SizeOf<SCROLLINFO>();
            fMask = mask; nMin = 0; nMax = 0; nPage = 0; nPos = 0; nTrackPos = 0;
        }
    }
    
    internal enum SBInfoMask : uint {
        SIF_RANGE = 0x1,
        SIF_PAGE = 0x2,
        SIF_POS = 0x4,
        SIF_DISABLENOSCROLL = 0x8,
        SIF_TRACKPOS = 0x10,
        SIF_ALL = SIF_RANGE | SIF_PAGE | SIF_POS | SIF_TRACKPOS,
        SIF_POSRANGE = SIF_RANGE | SIF_POS | SIF_PAGE
    }
    
    internal enum SBParam : int {
        SB_HORZ = 0x0,
        SB_VERT = 0x1,
        SB_CTL = 0x2,
        SB_BOTH = 0x3
    }