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:
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
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 ComboBox
es 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.