Monday, March 27, 2006

Overcoming a .NET ListView CheckBoxes quirk

In the Visual Studio Team Foundation version control UI, we display your pending changes in a ListView control.  That ListView has CheckeBoxes set to true as we allow you to perform a variety of operations on the checked items. 


At the same time we want you to be able to double click on an item in the list and have the file open in the editor. 


Unfortunately, the .NET ListView component automatically toggles the checked state of items when you double click on them.  I know that this is not the behavior of the underlying Win32 ListView control so it has to be something in the WinForms code.


At this point it's worth examing how this all works.  In a traditional C/C++ application, the ListView control sends WM_NOTIFY messages to the window that is the parent of the ListView.  This is typically a dialog box window.  In WinForms, events are exposed directly from the controls themselves.  So internally WinForms will take the WM_NOTIFY message and reflect it back to the child control and then the child control handles the message by firing events that you add your event handlers too.  This happens for other messages besides WM_NOTIFY - such as WM_COMMAND.


A few minutes with a program such as Spy++ will show you the message traffic.  When you double click a ListView item the underlying Win32 ListView sends a WM_NOTIFY message to the parent window (typically your Form).  The WinForms message handler for the parent window then reroutes the message back to the ListView by sending it a new message - WM_REFLECT + WM_NOTIFY.  The WinForms ListView message handler then dispatches it.  When the WinForms ListView sees a NM_DBLCLK notification it then sends a message (LVM_HITTEST) to the Win32 ListView control asking where the click occurred.  If it was on an item, the WinForms ListView code will then toggle the checked state of the item.


Since none of this behavior is exposed via the properties of the ListView control we'll have to work around it using less convenient means.  The solution I came up with for Team Foundation was to set a flag during the NM_DBLCLK notification that we're in the midst of a double click notification and then we intercept the LVM_HITTEST call and return that no item was found.

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using System.Windows.Forms;
 
namespace ListViewCheckBoxes
{
    class MyListView : ListView
    {     
        private bool m_doubleClickDoesCheck = true//  maintain the default behavior
        private bool m_inDoubleClickCheckHack = false;
 
        //****************************************************************************************
        // This function helps us overcome the problem with the managed listview wrapper wanting
        // to turn double-clicks on checklist items into checkbox clicks.  We count on the fact
        // that the base handler for NM_DBLCLK will send a hit test request back at us right away.
        // So we set a special flag to return a bogus hit test result in that case.
        //****************************************************************************************
        private unsafe void OnWmReflectNotify(ref Message m)
        {
            if (!DoubleClickDoesCheck && CheckBoxes)
            {
                NativeMethods.NMHDR* nmhdr = (NativeMethods.NMHDR *)m.LParam;
 
                if (nmhdr->code == NativeMethods.NM_DBLCLK)
                {
                    m_inDoubleClickCheckHack = true;
                }
            }
        }
 
        [SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermissionFlag.UnmanagedCode)]
        protected override void WndProc(ref Message m)
        {
            switch (m.Msg)
            {
                //  This code is to hack around the fact that the managed listview
                //  wrapper translates double clicks into checks without giving the
                //  host to participate.
                //  See OnWmReflectNotify() for more details.
                case NativeMethods.WM_REFLECT + NativeMethods.WM_NOTIFY:
                    OnWmReflectNotify(ref m);
                    break;
 
                //  This code checks to see if we have entered our hack check for
                //  double clicking items in check lists.  During the NM_DBLCLK
                //  processing, the managed handler will send a hit test message
                //  to see which item to check.  Returning -1 will convince that
                //  code not to proceed.
                case NativeMethods.LVM_HITTEST:
                    if (m_inDoubleClickCheckHack)
                    {
                        m_inDoubleClickCheckHack = false;
                        m.Result = (System.IntPtr)(-1);
                        return;
                    }
                    break;
            }
 
            base.WndProc(ref m);
        }
 
        [Browsable(true),
         Description("When CheckBoxes is true, this controls whether or not double clicking will toggle the check."),
         Category("My Controls"),
         DefaultValue(true)]
        public bool DoubleClickDoesCheck
        {
            get
            {
                return m_doubleClickDoesCheck;
            }
 
            set
            {
                m_doubleClickDoesCheck = value;
            }
        }
    }
 
    //****************************************************************************************
    //  This is stuff you would normally put in a separate file with all the other interop
    //  you have to work with.
    //****************************************************************************************
    public class NativeMethods
    {
        public const int WM_USER = 0x0400;
        public const int WM_REFLECT = WM_USER + 0x1C00;
        public const int WM_NOTIFY = 0x004E;
        public const int LVM_HITTEST = (0x1000 + 18);
        public const int NM_DBLCLK = (-3);
 
        [StructLayout(LayoutKind.Sequential)]
        public struct NMHDR
        {
            public IntPtr hwndFrom;
            public UIntPtr idFrom;
            public int code;
        }
    }
 
}
 

No comments: