vb.netwinformsdatagridviewdatagridviewcombobox

Why is this event handler method being repeatedly called?


The DataGridViewComboBoxColumn control can be difficult to work with. I've been fighting various permutations of this code for two long days, so I've decided to throw in the towel and seek some assistance.

The latest incarnation of weirdness is a ComboBox event handler that fires an increasing number of times for a single user action. Oddly, the rate of increase is an exact doubling of the count just preceding (i.e. 1, 2, 4, 8, 16, 32, 64 etc.)

To start things off, I'll explain what I'm trying to accomplish and also clarify some terminology.

I have a Dictionary(Of Integer, String). In my domain rules, I'm calling its Key property Channel and its Value property Label. I'm mapping each KeyValuePair to a third String value called Target. The Dictionary(Of Integer, String) items are fixed—they exist as a visual aid for the user, so he can easily select the Target from a List(Of String).

I've settled on a DataGridView control to provide this functionality. I'm using three columns, like so:

enter image description here

Note that already-mapped Target list items are displayed in a nearly-invisible color, so as to discourage the user from trying to use them again. (This is where the event handler problem comes in—when an already-mapped Target is selected for mapping to a different Label.)

I'm including my full code base below, but for a quick look here's the event handler that repeats:

Private Sub ComboBox_SelectionChangeCommitted(Sender As ComboBox, e As EventArgs)
  ' '
  ' Look for other labels that have already been mapped to this target '
  ' '
  If Me.OtherTargetCells.Any(Function(Cell) Cell.FormattedValue = Sender.Text) Then
    If Me.IsInteractiveChange Then
      MsgBox("Target [] is already mapped to Label []. If you want to map Target [] to Label [], you must first set Label [] to [Not mapped].", MsgBoxStyle.Exclamation, Me.DataGridView.FindForm.Text)

      Me.IsInteractiveChange = False
      Sender.SelectedIndex = 0
      Me.IsInteractiveChange = True
    End If
  End If
End Sub

And here's how I'm wiring it all up:

Public Sub New()
  Task.Run(Sub()
             Dim oHandler As DataGridViewEditingControlShowingEventHandler

             While Me.DataGridView Is Nothing
             End While

             oHandler = New DataGridViewEditingControlShowingEventHandler(AddressOf DataGridView_EditingControlShowing)

             RemoveHandler Me.DataGridView.EditingControlShowing, oHandler
             AddHandler Me.DataGridView.EditingControlShowing, oHandler
           End Sub)
End Sub



Private Sub DataGridView_EditingControlShowing(Sender As DataGridView, e As DataGridViewEditingControlShowingEventArgs)
  Dim oComboBox As ComboBox

  If TypeOf e.Control Is ComboBox Then
    oComboBox = e.Control
    oComboBox.DrawMode = DrawMode.OwnerDrawFixed

    RemoveHandler oComboBox.DrawItem, New DrawItemEventHandler(AddressOf ComboBox_DrawItem)
    AddHandler oComboBox.DrawItem, New DrawItemEventHandler(AddressOf ComboBox_DrawItem)

    RemoveHandler oComboBox.SelectionChangeCommitted, New EventHandler(AddressOf ComboBox_SelectionChangeCommitted)
    AddHandler oComboBox.SelectionChangeCommitted, New EventHandler(AddressOf ComboBox_SelectionChangeCommitted)
  End If
End Sub

The repeat count multiplies when I select an already-mapped Target from a different list than previously (e.g. selecting twice from SCC doesn't increase the count, but selecting from SCC and then Scale does.)

I've tried many, many, many possible solutions for this—too many to list here and most of which I just don't remember—but none with any success.

What can I do to constrain the handler to fire only once for each selection change?


Mapping.TargetsColumn.vb

Namespace Mapping
  Public Class TargetsColumn
    Inherits DataGridViewComboBoxColumn

    Public Sub New()
      Task.Run(Sub()
                 Dim oHandler As DataGridViewEditingControlShowingEventHandler

                 While Me.DataGridView Is Nothing
                 End While

                 oHandler = New DataGridViewEditingControlShowingEventHandler(AddressOf DataGridView_EditingControlShowing)

                 RemoveHandler Me.DataGridView.EditingControlShowing, oHandler
                 AddHandler Me.DataGridView.EditingControlShowing, oHandler
               End Sub)
    End Sub



    Private Sub DataGridView_EditingControlShowing(Sender As DataGridView, e As DataGridViewEditingControlShowingEventArgs)
      Dim oComboBox As ComboBox

      If TypeOf e.Control Is ComboBox Then
        oComboBox = e.Control
        oComboBox.DrawMode = DrawMode.OwnerDrawFixed

        RemoveHandler oComboBox.DrawItem, New DrawItemEventHandler(AddressOf ComboBox_DrawItem)
        AddHandler oComboBox.DrawItem, New DrawItemEventHandler(AddressOf ComboBox_DrawItem)

        RemoveHandler oComboBox.SelectionChangeCommitted, New EventHandler(AddressOf ComboBox_SelectionChangeCommitted)
        AddHandler oComboBox.SelectionChangeCommitted, New EventHandler(AddressOf ComboBox_SelectionChangeCommitted)
      End If
    End Sub



    Private Sub ComboBox_DrawItem(Sender As ComboBox, e As DrawItemEventArgs)
      Dim sThisTarget As String
      Dim oForeColor As Color

      Dim _
        iSeparatorBottom,
        iSeparatorRight,
        iSeparatorLeft As Integer

      Dim _
        oSeparatorStart,
        oSeparatorStop As Point

      sThisTarget = DirectCast(Me.Items(e.Index), Target).Value

      iSeparatorBottom = e.Bounds.Bottom - 2
      iSeparatorRight = e.Bounds.Right
      iSeparatorLeft = e.Bounds.Left

      e.DrawBackground()

      If e.Index = 0 Then
        oSeparatorStart = New Point(iSeparatorLeft, iSeparatorBottom)
        oSeparatorStop = New Point(iSeparatorRight, iSeparatorBottom)
        oForeColor = SystemColors.HotTrack

        e.Graphics.FillRectangle(SystemBrushes.Control, e.Bounds)
        e.Graphics.DrawLine(SystemPens.ControlDark, oSeparatorStart, oSeparatorStop)
      Else
        If Me.OtherTargets.Contains(sThisTarget) Then
          oForeColor = SystemColors.ControlLight
        Else
          oForeColor = e.ForeColor
        End If
      End If

      Using oBrush As New SolidBrush(oForeColor)
        e.Graphics.DrawString(sThisTarget, e.Font, oBrush, e.Bounds)
      End Using

      If e.State.HasFlag(DrawItemState.Focus) Then e.DrawFocusRectangle()

      Me.DataGridView.FindForm.Text = sThisTarget
    End Sub



    Private Sub ComboBox_SelectionChangeCommitted(Sender As ComboBox, e As EventArgs)
      ' '
      ' Look for other labels that have already been mapped to this target '
      ' '
      If Me.OtherTargetCells.Any(Function(Cell) Cell.FormattedValue = Sender.Text) Then
        If Me.IsInteractiveChange Then
          MsgBox("Target [] is already mapped to Label []. If you want to map Target [] to Label [], you must first set Label [] to [Not mapped].", MsgBoxStyle.Exclamation, Me.DataGridView.FindForm.Text)

          Me.IsInteractiveChange = False
          Sender.SelectedIndex = 0
          Me.IsInteractiveChange = True
        End If
      End If
    End Sub



    Private ReadOnly Property OtherTargets As List(Of String)
      Get
        Return Me.OtherTargetCells.Select(Function(Cell) DirectCast(Cell.FormattedValue, String)).ToList
      End Get
    End Property



    Private ReadOnly Property CurrentTargetCell As DataGridViewCell
      Get
        Return Me.AllTargetCells(Me.DataGridView.CurrentRow.Index)
      End Get
    End Property



    Private ReadOnly Property AllTargetCells As List(Of DataGridViewCell)
      Get
        Dim oAllCells As IEnumerable(Of DataGridViewCell)
        Dim oRows As IEnumerable(Of DataGridViewRow)

        oRows = Me.DataGridView.Rows.Cast(Of DataGridViewRow)
        oAllCells = oRows.SelectMany(Function(Row) Row.Cells.Cast(Of DataGridViewCell))

        Return oAllCells.Where(Function(Cell) TypeOf Cell Is DataGridViewComboBoxCell).ToList
      End Get
    End Property



    Private ReadOnly Property OtherTargetCells As List(Of DataGridViewCell)
      Get
        Return Me.AllTargetCells.Where(Function(Cell) Cell.RowIndex <> Me.RowIndex).ToList
      End Get
    End Property



    Private ReadOnly Property RowIndex As Integer
      Get
        Return Me.DataGridView.CurrentRow.Index
      End Get
    End Property



    Private IsInteractiveChange As Boolean = True
    Private ReadOnly ComboBoxes As New Dictionary(Of Integer, ComboBox)
  End Class
End Namespace

Form1.vb

Public Class Form1
  Inherits Form

  Public Sub New()
    Dim oColTargets As Mapping.TargetsColumn
    Dim oTargets As IEnumerable(Of String)
    Dim oQuery As Func(Of Target, Boolean)
    Dim sChannel As String
    Dim oTarget As Target
    Dim oMaps As Dictionary(Of Integer, String)
    Dim oMap As Map

    Dim _
      oColChannels,
      oColLabels As DataGridViewTextBoxColumn

    Me.InitializeComponent()

    Me.Targets.Add(New Target("Not mapped"))

    sChannel = String.Empty
    oQuery = Function(Target) Target.Value = sChannel

    'oTargets = Reader.Client.Create.Call(Function(Service As Reader.IService) Service.GetChannelTargets)'
    oTargets = New List(Of String) From {"Scale", "SCC", "CO", "O2"}
    oTargets.ToList.ForEach(Sub(Target)
                              Me.Targets.Add(New Target(Target))
                            End Sub)

    'oMaps = Reader.Client.Create.Call(Function(Service As Reader.IService) Service.GetChannelMaps)'
    oMaps = New Dictionary(Of Integer, String) From {{3, "Test"}, {7, "SCC"}, {8, "Scale"}, {9, "CO"}, {10, "O2"}}
    oMaps.ToList.ForEach(Sub(Map)
                           sChannel = Map.Value

                           If Me.Targets.Any(oQuery) Then
                             oTarget = Me.Targets.Single(oQuery)
                           Else
                             oTarget = Me.Targets.First
                           End If

                           oMap = New Map With {
                            .Channel = Map.Key,
                            .Label = Map.Value,
                            .Target = oTarget
                           }

                           Me.Maps.Add(oMap)
                         End Sub)

    oColChannels = New DataGridViewTextBoxColumn With {
      .DataPropertyName = NameOf(Map.Channel),
      .AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      .HeaderText = NameOf(Map.Channel),
      .ReadOnly = True,
      .Name = NameOf(oColChannels)
    }

    oColLabels = New DataGridViewTextBoxColumn With {
      .DataPropertyName = NameOf(Map.Label),
      .AutoSizeMode = DataGridViewAutoSizeColumnMode.ColumnHeader,
      .HeaderText = NameOf(Map.Label),
      .ReadOnly = True,
      .Name = NameOf(oColLabels)
    }

    oColTargets = New Mapping.TargetsColumn With {
      .DataPropertyName = NameOf(Map.Target),
      .AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
      .DisplayMember = NameOf(Target.Value),
      .ValueMember = NameOf(Target.Self),
      .HeaderText = NameOf(Map.Target),
      .DataSource = Me.Targets,
      .Name = NameOf(oColTargets)
    }

    dgvMapping.AutoGenerateColumns = False
    dgvMapping.Columns.AddRange({oColChannels, oColLabels, oColTargets})

    For Each oColumn As DataGridViewColumn In dgvMapping.Columns
      oColumn.HeaderCell.Style.Alignment = DataGridViewContentAlignment.MiddleCenter

      If oColumn.Index = 0 Then
        oColumn.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter
      End If
    Next

    dgvMapping.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize
    dgvMapping.DataSource = New BindingList(Of Map)(Me.Maps)

    If dgvMapping.RowCount = 0 Then
      dgvMapping.Height = 150
    Else
      dgvMapping.Height = ((dgvMapping.RowCount + 0) * dgvMapping.Rows(0).Height) + dgvMapping.ColumnHeadersHeight
    End If
  End Sub



  Private Sub Form1_FormClosing(Sender As Form1, e As FormClosingEventArgs) Handles Me.FormClosing
    Dim oPolicy As Target = Me.Maps.First.Target
    Dim sName As String = Me.Maps.First.Channel
  End Sub



  Private Sub _dgvMapping_DataError(Sender As DataGridView, e As DataGridViewDataErrorEventArgs) Handles dgvMapping.DataError
    MsgBox(e.Exception.Message, MsgBoxStyle.Critical, Me.Text)
  End Sub



  Private Targets As New BindingList(Of Target)
  Private Maps As New List(Of Map)
End Class



Public Class Map
  Public Property Channel As Integer
  Public Property Label As String
  Public Property Target As Target
End Class



Public Class Target
  Public Sub New(Target As String)
    Me.Value = Target
  End Sub



  Public ReadOnly Property Self As Target
    Get
      Return Me
    End Get
  End Property



  Public ReadOnly Property Value As String
End Class

Form1.Designer.vb

<Global.Microsoft.VisualBasic.CompilerServices.DesignerGenerated()>
Partial Class Form1
  Inherits System.Windows.Forms.Form

  'Form overrides dispose to clean up the component list.'
  <System.Diagnostics.DebuggerNonUserCode()>
  Protected Overrides Sub Dispose(ByVal disposing As Boolean)
    Try
      If disposing AndAlso components IsNot Nothing Then
        components.Dispose()
      End If
    Finally
      MyBase.Dispose(disposing)
    End Try
  End Sub

  'Required by the Windows Form Designer'
  Private components As System.ComponentModel.IContainer

  'NOTE: The following procedure is required by the Windows Form Designer'
  'It can be modified using the Windows Form Designer.'
  'Do not modify it using the code editor.'
  <System.Diagnostics.DebuggerStepThrough()>
  Private Sub InitializeComponent()
    Me.dgvMapping = New System.Windows.Forms.DataGridView()
    CType(Me.dgvMapping, System.ComponentModel.ISupportInitialize).BeginInit()
    Me.SuspendLayout()
    ' '
    'dgvMapping'
    ' '
    Me.dgvMapping.AllowUserToAddRows = False
    Me.dgvMapping.AllowUserToDeleteRows = False
    Me.dgvMapping.AllowUserToOrderColumns = True
    Me.dgvMapping.AllowUserToResizeColumns = False
    Me.dgvMapping.AllowUserToResizeRows = False
    Me.dgvMapping.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize
    Me.dgvMapping.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnEnter
    Me.dgvMapping.Location = New System.Drawing.Point(12, 12)
    Me.dgvMapping.Name = "dgvMapping"
    Me.dgvMapping.RowHeadersVisible = False
    Me.dgvMapping.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect
    Me.dgvMapping.Size = New System.Drawing.Size(250, 150)
    Me.dgvMapping.TabIndex = 0
    ' '
    'Form1'
    ' '
    Me.AutoScaleDimensions = New System.Drawing.SizeF(6.0!, 13.0!)
    Me.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font
    Me.ClientSize = New System.Drawing.Size(800, 450)
    Me.Controls.Add(Me.dgvMapping)
    Me.Font = New System.Drawing.Font("Segoe UI", 8.0!, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, CType(0, Byte))
    Me.Name = "Form1"
    Me.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen
    Me.Text = "Form1"
    CType(Me.dgvMapping, System.ComponentModel.ISupportInitialize).EndInit()
    Me.ResumeLayout(False)

  End Sub

  Friend WithEvents dgvMapping As DataGridView
End Class

Solution

  • Fixed.

    I was instantiating a new event handler object for each AddHandler/RemoveHandler call.

    When I removed the instantiations and used simple expressions instead, the ComboBoxes started behaving correctly.

    Public Sub New()
      Task.Run(Sub()
                 While Me.DataGridView Is Nothing
                 End While
    
                 RemoveHandler Me.DataGridView.EditingControlShowing, AddressOf DataGridView_EditingControlShowing
                 AddHandler Me.DataGridView.EditingControlShowing, AddressOf DataGridView_EditingControlShowing
               End Sub)
    End Sub
    
    
    
    Private Sub DataGridView_EditingControlShowing(Sender As Object, e As DataGridViewEditingControlShowingEventArgs)
      Dim oComboBox As ComboBox
    
      If TypeOf e.Control Is ComboBox Then
        oComboBox = e.Control
        oComboBox.DrawMode = DrawMode.OwnerDrawFixed
    
        RemoveHandler oComboBox.DrawItem, AddressOf ComboBox_DrawItem
        AddHandler oComboBox.DrawItem, AddressOf ComboBox_DrawItem
    
        RemoveHandler oComboBox.SelectionChangeCommitted, AddressOf ComboBox_SelectionChangeCommitted
        AddHandler oComboBox.SelectionChangeCommitted, AddressOf ComboBox_SelectionChangeCommitted
      End If
    End Sub
    

    I had to relax the Sender parameter types to Object in the event handler methods, but that didn't carry any serious consequence.

    Private Sub DataGridView_EditingControlShowing(Sender As Object, e As DataGridViewEditingControlShowingEventArgs)
    End Sub
    
    Private Sub ComboBox_DrawItem(Sender As Object, e As DrawItemEventArgs)
    End Sub
    
    Private Sub ComboBox_SelectionChangeCommitted(Sender As Object, e As EventArgs)
    End Sub
    

    For what it's worth: I generally prefer to constrain the Sender parameter to the calling type, for more efficient coding, but that wasn't possible in this case. Nevertheless, the only impact was the need to cast the Sender in one place in one method body:

    Dim oQuery = Function(Cell) Cell.FormattedValue = DirectCast(Sender, ComboBox).Text
    

    It works as expected now.