ListView get clicked ColumnHeader when horizontal Scrollbar is scrolled

116 views Asked by At

I want to add the functionality to change the ColumnHeader Captions and then save the result to a database.

Since the ListViewHitTestInfo class for some reason doesn't really work for me, I had to come up with a different approach.

I already have a solution where I loop through the ColumnHeaders and create a Rectangle for each ColumnHeader and then check, if the location, where I clicked, is in the Rectangle, which looks kinda like this:

        public static ColumnHeader GetClickedHeader(this ListView listView)
        {
            Point location = listView.PointToClient(Cursor.Position);
            List<ColumnHeader> columns = new List<ColumnHeader>();
            columns.AddRange(listView.Columns.Cast<ColumnHeader>());

            int actX = 0;
            foreach (ColumnHeader column in columns.OrderBy(x => x.DisplayIndex))
            {
                Rectangle rect = new Rectangle(actX, 0, column.Width, 25);
                if (rect.Contains(location))
                {
                    return column;
                }
                actX += column.Width;
            }

            return null;
        }

Now, my problem comes when I run the program. Since there are 30+ ColumnHeaders, it automatically "spawns" a horizontal scrollbar. When I scroll to the far right, my GetClickedHeader returns a ColumnHeader off by 2.

For example: A - B - C - D - E - F - G - H - I - J - K - L - M - N - O - P - Q - R - S - T - U - V - W - X - Y - Z

Let's say the normal view goes til ColumnHeader N, when I scroll to the right, so that I can see Z, the first visible Column would be 'L'. When I now right click on the ColumnHeader, it shows me ColumnHeader 'A' instead of ColumnHeader 'L'.

Is there a way to get the Scrollbars location or something like that?

Can someone help me out so that I can get the correct clicked ColumnHeader, regardless of where the scrollbar is located at that time?

Sorry if I made some grammar mistakes, English is not my first language.

Thanks in advance.

I already tried to get the horizontal scrollbar location, so that I can change the Indexes from the ColumnHeaders, so that I get the correct one that I clicked. But somehow I don't find anything for C# in particular.

2

There are 2 answers

0
dr.null On BEST ANSWER

You need here to translate the Cursor.Position to follow the moves of the horizontal scrollbar. The PointToClient method returns the same value regardless of the positions of the scrollbars and it depends on the control's ClientSize. The size does not include the size of the hidden area. Which means, according to the HS's position, the x-coordinates of the hidden columns either are less or greater than the x-coordinate of the current Cursor.Position.

To make it work, I suggest the following.

internal static class ListViewExtensions
{
    const int LVM_FIRST = 0x1000;
    const int LVM_GETHEADER = LVM_FIRST + 31;

    const int HDM_FIRST = 0x1200;
    const int HDM_GETITEMRECT = HDM_FIRST + 7;

    const int SB_HORZ = 0;

    // To get the handle of the header...
    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wp, IntPtr lp);

    // To get the column rectangles...
    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    static extern int SendMessage(IntPtr hWnd, int msg, int wParam, ref RECT rect);

    // To get the position of the horizontal scrollbar...
    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    static extern int GetScrollPos(IntPtr hWnd, int nBar);

    [StructLayout(LayoutKind.Sequential)]
    struct RECT
    {
        public int Left, Top, Right, Bottom;

        public RECT(int left, int top, int right, int bottom)
        {
            Left = left;
            Top = top;
            Right = right;
            Bottom = bottom;
        }

        public RECT(Rectangle r) : this(r.Left, r.Top, r.Right, r.Bottom) { }

        public int X => Left;
        public int Y => Top;
        public int Width => Right - Left;
        public int Height => Bottom - Top;

        public Rectangle ToRectangle() => new Rectangle(X, Y, Width, Height);
    }

    internal static ColumnHeader GetColumnFromCursorPosition(this ListView self)
    {
        var p = self.PointToClient(Cursor.Position);
        p.X += GetScrollPos(self.Handle, SB_HORZ); // <- Notice...
        return GetColumnFromPoint(self, p);
    }

    internal static ColumnHeader GetColumnFromPoint(this ListView self, Point point)
    {
        var headerPtr = SendMessage(self.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);

        if (headerPtr != IntPtr.Zero)
        {
            foreach (var col in self.Columns.Cast<ColumnHeader>())
            {
                var rec = new RECT();
                SendMessage(headerPtr, HDM_GETITEMRECT, col.Index, ref rec);
                if (rec.ToRectangle().Contains(point)) return col;                    
            }
        }

        return null;
    }
}

In a ContextMenuStrip.Opening event handler...

private void SomeCmnu_Opening(object sender, CancelEventArgs e)
{
    if (cmnu.SourceControl is ListView lv)
    {
        if (lv.GetColumnFromCursorPosition() is ColumnHeader col)
        {
            // ...
        }
        else
        {
            // ...
        }
    }
}
0
Jimi On

Alternative solution, using a custom ListView that handles its own Header Control, assigning the header's Handle to a NativeWindow.
When you press the right or left Mouse Button on the Header, the custom Control raises an event, OnHeaderMouseDown.
The custom HeaderMouseDownEventArgs returns:

  1. the Button that was clicked,
  2. the absolute position of the click inside the scrolling area,
  3. the translated location in Client coordinates
  4. the index of the Column that was selected.

You can handle this event to position a ContextMenuStrip, for example:

private void listView1_HeaderMouseDown(object sender, HeaderMouseDownEventArgs e) {
    if (e.Button == MouseButtons.Right) {
        [SomeContextMenuStrip].Show(listView1, e.ClientLocation);
    }
}

Note that, if the right Mouse Button is clicked on the Header, it also raises the MouseDown event, passing the client coordinates

Also to note, this is written for .NET 7

Custom ListView:

using System.Runtime.InteropServices;
using System.Windows.Forms;

internal class ListViewEx : ListView {
    const int LVM_GETHEADER = 0x1000 + 31;

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    internal static extern nint SendMessage(nint hWnd, int uMsg, nint wParam, nint lParam);

    HeaderHandler? lwHeaderHandler = null;
    public event EventHandler<HeaderMouseDownEventArgs>? HeaderMouseDown;
    public ListViewEx() { }

    protected override void OnHandleCreated(EventArgs e) {
        HookHeaderHandler();
        base.OnHandleCreated(e);
    }

    private void HookHeaderHandler() {
        if (!DesignMode && lwHeaderHandler is null) {
            var headerHwnd = SendMessage(this.Handle, LVM_GETHEADER, IntPtr.Zero, IntPtr.Zero);
            if (headerHwnd != IntPtr.Zero) {
                lwHeaderHandler = new HeaderHandler(headerHwnd);
                lwHeaderHandler.HeaderMouseDown += (_, e) => {
                    if (e.Button == MouseButtons.Right) {
                        var margs = new MouseEventArgs(e.Button, 1, e.ClientLocation.X, e.ClientLocation.Y, 0);
                        OnMouseDown(margs);
                    }
                    OnHeaderMouseDown(e);
                };
            }
        }
    }

    protected virtual void OnHeaderMouseDown(HeaderMouseDownEventArgs e) => 
        HeaderMouseDown?.Invoke(this, e);
}

Custom EventArgs:

public class HeaderMouseDownEventArgs : EventArgs {
    public HeaderMouseDownEventArgs(MouseButtons button, Point absLocation, Point cliLocation, int column) {
        Button = button; AbsoluteLocation = absLocation; ClientLocation = cliLocation; Column = column;
    }

    public MouseButtons Button { get; init; }
    public Point AbsoluteLocation { get; init; }
    public Point ClientLocation { get; init; }
    public int Column { get; init; }
}

NativeWindow handler:

internal partial class HeaderHandler : NativeWindow {
    public event EventHandler<HeaderMouseDownEventArgs>? HeaderMouseDown;
    public HeaderHandler(IntPtr handle) => AssignHandle(handle);

    protected internal virtual void OnHeaderMouseDown(HeaderMouseDownEventArgs e) =>
        HeaderMouseDown?.Invoke(this, e);

    protected override void WndProc(ref Message m) {
        switch (m.Msg) {
            case WM_LBUTTONDOWN:
            case WM_RBUTTONDOWN:
                var pos = new Point((int)(m.LParam) & 0xFFFF, ((int)(m.LParam) >> 16) & 0xFFFF);
                var hdTestInfo = new HD_HITTESTINFO(pos, 0, 0);
                SendMessage(this.Handle, HDM_HITTEST, 0, ref hdTestInfo);
                if (hdTestInfo.flags == HHT_ONHEADER) {
                    var button = (int)m.WParam == 1 ? MouseButtons.Left : MouseButtons.Right;
                    Control? lv = Control.FromChildHandle(this.Handle);
                    var cliPosition = lv is null ? Point.Empty : lv.PointToClient(Control.MousePosition);
                    var args = new HeaderMouseDownEventArgs(button, pos, cliPosition, hdTestInfo.iItem);
                    OnHeaderMouseDown(args);
                    m.Result = IntPtr.Zero;
                }
                break;
            default:
                break;
        }
        base.WndProc(ref m);
    }

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    internal static extern int SendMessage(nint hWnd, int uMsg, int wParam, [In, Out] ref HD_HITTESTINFO lParam);

    const int WM_LBUTTONDOWN = 0x0201;
    const int WM_RBUTTONDOWN = 0x0204;

    const int HDM_FIRST = 0x1200;
    const int HDM_HITTEST = HDM_FIRST + 6;
    const int HHT_ONHEADER = 0x02;

    [StructLayout(LayoutKind.Sequential)]
    internal struct HD_HITTESTINFO {
        public Point pt;
        public uint flags;
        public int iItem;
        public HD_HITTESTINFO(Point p, uint flags, int item) {
            pt = p; this.flags = flags; iItem = item;
        }
    }
}