aphid
aphid

Reputation: 1163

Getting and setting a winForms ListView scroll position

How can I:

Here it can be assumed that the listView is a simple plain textual list view (Not the big/small pictures version(s)).

While there are a lot of answers pointing to using the EnsureVisible function, all this does is jump around randomly to ensure the item indicated is visible. If a certain 'scroll position' is desired, and one has no idea beforehand what the visible item(s) should be (because the underlying data might change), then this method is of little use.

I've also found suggestions of using the win32-api, specifically using setScrollInfo and setScrollWindowEx. Unfortunately, setScrollInfo only really ephemerally modifies the scroll bar itself and leaves the window content unchanged, and is only really useful for those implementing their own special scroll behaviours. setScrollWindowEx should in theory work via adjusting viewports and blitting in new content, but when used with this object said function will cheerily report that it was succesful without doing anything and reporting it has redrawn a rectangle of size [0, 0] at position [0, 0] in [w,h],[x,y] notation. In other words, the internal state of a ListView isn't very accessible.

Upvotes: 0

Views: 1810

Answers (1)

aphid
aphid

Reputation: 1163

I found a partial solution to this problem. Suppose ListView is being extended/subclassed. First, import a function from user32.dll; GetScrollInfo, as follows:

Declare Function GetScrollInfo Lib "user32" Alias "GetScrollInfo" (ByVal hWnd As IntPtr, _
    ByVal n As Integer, ByRef lpScrollInfo As SCROLLINFO) As Integer
Structure SCROLLINFO
    Dim cbSize As Integer
    Dim fMask As Integer
    Dim nMin As Integer
    Dim nMax As Integer
    Dim nPage As Integer
    Dim nPos As Integer
    Dim nTrackPos As Integer
End Structure
Private Const SB_HORZ As Integer = 0
Private Const SB_VERT As Integer = 1

This can be used to get the scroll position of the window as a Y-offset. The scroll position is defined in fantasy-scroll-units, which are definable however the callee wants them to be, as long as they're 32-bit integers. The 'scroll action' need not be euclidean either, but the units are used to size the scroll bar; e.g. the size of the draggable element or the 'page size' is defined as the size of a window in 'scroll units' divided by the size of the total scroll bar, with a pre-set hardcoded minimum size, but how much each click or drag or mousewheel movement scrolls is coded by the element using the scroll bar.

That leads to the definition of this SCROLLINFO structure. It has seven members;

  1. nMin and nMax. nMin <= nPos <= nMax is automatically clamped by the scrollBar. When the scrollbar is fully at the top, then nPos = nMin. Fully down at the bottom, nPos = nMax.
  2. cbSize is the size of this structure. Typically cbSize = 28 when using the win32 api.
  3. fMask can be used to retrieve only part of the values. To retrieve all, fMask = 7. Values of:
    1. 0x01: Retrieves the nMin, nMax
    2. 0x02: Retrieves nPage
    3. 0x04: Retrieves nPos and nTrackPos
  4. nPage denotes the (relative) size of the scroll knob: nPage / (nMax - nMin) would be the height relative to the size of the bar for a y-scrollbar.
  5. nPos is the current position of the scrollbar.
  6. nTrackPos is the same, but is updated during a click&drag operation, allowing you to have a form element that moves while you drag the scrollbar (instead of the default; when you release it), like most text editors do.

The first trick is to figure out exactly how the imaginary units map to pixels. Fortunately for a ListView this appears rather straightforward: The following definition gets 'close enough';

 Public Function getScrollPos() As Integer
     Dim si As SCROLLINFO = Me.getScrollInfo()
     Dim x As Integer = GetScrollInfo(Me.Handle, SB_VERT, si)
     ' The Windows Scrollbar magicunit is the same as pixels, off by exactly one window height. 
     ' Adding nPage gets you a pixel based location. 
     Return si.nPos + si.nPage
 End Function

Next, we have to use this information and set the Y-value of the listView. This is the hard part; there's no ready-to-use method for this task in basic windows forms. However, each item does have a function to get its Y position. But, the naïve method of simply using Me.items(Y/17) (Note; 17 is the default height) does not work at all, because the Me.Items member variable is not sorted with the display.

Public Sub setScrollPos(ByVal y As Integer)
    Dim si As SCROLLINFO = Me.getScrollInfo()
    Dim x As Integer = GetScrollInfo(Me.Handle, SB_VERT, si)
    If (y > si.nMax) Then
        y = Math.Max(si.nMax, 0)
    End If
    Dim itemHeight As Integer = Me.Items(0).GetBounds(ItemBoundsPortion.Entire).Height
    Dim iClosest = 0
    Dim iTmp = 0
    Dim delta = Integer.MaxValue
    ' "Stupidly clever" solution by simply trying all items out and finding out which one is closest to the Y value. 
    ' Implemented as there is no "sorted index" exposed by ListViewItemCollection. (No idea why...)
    If Me.Items.Count > 0 Then
        For i As Integer = 0 To Me.Items.Count - 1 Step 1
            iTmp = Math.Abs(Me.Items.Item(i).Position.Y - y)
            If iTmp < delta Then
                delta = iTmp
                iClosest = i
            End If
        Next 'i
    End If
    Me.TopItem = Me.Items.Item(iClosest)
    Me.Invalidate()
End Sub

Here, loop over each item, find out which one has the best fitting y-coordinate for our measurement, then set that as the top. The solution is 'stupid' in the sense that it does a calculation that the form needs to do anyway in order to render itself correctly: it needs to know the order its list elements are rendered in. It's also not quite correct: if the top intended visible line is (part of) a "group separator", then our setScrollPos function will instead find the nearest ListViewItem and display that up top. It also cannot handle any sub-line movement and can be thus off by (up to) half a line height, by rounding to a whole line.

Upvotes: 1

Related Questions