vb.netdatagridviewdatagridviewcolumn

DataGridView column of arbitrary controls per row (or replicate that appearance)


I have a DataGridView which contains, among other things, a "Feature" column and a "Value" column. The "Feature" column is a DataGridViewComboBoxColumn. What I would like to do, is manipulate the "Value" column, such that it can be one of a number of controls, depending on the item selected in the "Feature" column on any given row. So, for example :

Feature Value Cell Behaviour
A Combobox with a pre-determined set of options, specific to Feature A
B Combobox with a different set of pre-determined options, specific to Feature B
C Checkbox
D Textbox (free-format)
X etc. etc.

My initial naive approach to this (which I never really expected to work but figured I'd try anyway...) was to programmatically manipulate the specific value cell in the grid whenever the Feature combobox on the same row was changed :

Private Sub dgv_CellValueChanged(sender As Object, e As DataGridViewCellEventArgs) Handles dgv.CellValueChanged
    Dim changedCell As DataGridViewCell = CType(dgv.Rows(e.RowIndex).Cells(e.ColumnIndex), DataGridViewCell)
    If changedCell.ColumnIndex = cboFeatureColumn.Index Then
        Dim cboChangedFeature = CType(changedCell, DataGridViewComboBoxCell)
        If cboChangedFeature.Value IsNot Nothing Then ConfigureValueField(cboChangedFeature)
    End If
End Sub

Private Sub ConfigureValueField(cboFeature As DataGridViewComboBoxCell)
    Dim cllValueField As DataGridViewCell = dgv.Rows(cboFeature.RowIndex).Cells(valueColumn.Index)
    Dim featureID As Integer = dgv.Rows(cboFeature.RowIndex).Cells(featureIDColumn.Index).Value
    Dim matches = From row In dtbFeatureList Let lookupID = row.Field(Of Integer)("ID") Where lookupID = featureID
    Dim strFieldControl As String = ""
    If matches.Any Then strFeatureControl = matches.First().row.Field(Of String)("FieldControl")
    Select Case strFieldControl
        Case "Checkbox"
            ' Do something

        Case "Textbox"
            ' Do something

        Case "Combobox"
            Dim cboConfigured As New DataGridViewComboBoxCell
            Dim dvOptions As New DataView(dtbFeatureValueList)
            dvOptions.RowFilter = "[FeatureID] = " & featureID
            Dim dtbOptions As DataTable
            dtbOptions = dvOptions.ToTable
            With cboConfigured
                .DataSource = dtbOptions
                .ValueMember = "Value"
                .DisplayMember = "ValueText"
                .DisplayStyle = DataGridViewComboBoxDisplayStyle.ComboBox
                .ReadOnly = False
            End With
            cllValueField = cboConfigured
    End Select
End Sub

But this (probably, obviously to many) doesn't work; for starters it throws a DataError Default Error Dialog :

The following exception occurred in the DataGridView: System.FormatException: DataGridViewComboBoxCell value is not valid To replace this default dialog please handle the DataError event.

...which I can trap and handle (i.e. ignore!) easily enough :

Private Sub dgv_DataError(sender As Object, e As DataGridViewDataErrorEventArgs) Handles dgv.DataError
    e.Cancel = True
End Sub

...and the resulting cell does have the appearance of a combobox but nothing happens when I click the dropdown (no list options appear) I suspect there are a whole host of DataErrors being thrown; to be quite honest, I'm not entirely comfortable with ignoring exceptions like this without handling them properly...

The only alternative option I can think of is to add separate columns for each possible value type (so add a DataGridViewComboBoxColumn for combos, a DataGridViewCheckBoxColumn for checkboxes, a DataGridViewTextBoxColumn for textboxes etc.) but then all of my values are scattered across multiple columns instead of all under a single heading which will be really confusing to look at.

And I really want to retain the appearance of comboboxes (set list of options) versus checkboxes (boolean values) versus textboxes (free-format text), as it makes it a lot easier for users to differentiate the values.

I read somewhere that it may be possible to derive my own custom column class, inheriting the native DataGridViewColumn class, but I couldn't find any examples of how this was done. I've done something similar with a customised DataGridViewButtonColumn but literally just to change the appearance of the buttons across the entire column, not the functionality of the individual cells within it.

Would anybody have any suggestions as to how it might be possible to have a mix of controls in the same column, configured specifically to the row in which they reside?

EDIT

So, I followed the walkthrough at : https://learn.microsoft.com/en-us/dotnet/desktop/winforms/controls/how-to-host-controls-in-windows-forms-datagridview-cells?view=netframeworkdesktop-4.8&redirectedfrom=MSDN

And I've added four new classes to my project as follows :

  1. UserControl class : a basic control which contains a textbox, a combobox and a checkbox, and basic methods for showing/hiding each one as appropriate, configuring the combobox if necessary etc. By default, the UserControl should display as an empty textbox.

  2. CustomConfigurableCellEditingControl : derived from the UserControl in #1

  3. DataGridViewCustomConfigurableCell : container for the EditingControl in #2 and derived from DataGridViewTextBoxCell

  4. DataGridViewCustomConfigurableColumn : container for the Cell in #3 and derived from DataGridViewColumn

Public Class UserControl

Private displayControl As String
Private displayValue As String
Private myValue As String

    Public Sub New()

        ' This call is required by the designer.
        InitializeComponent()

        ' Add any initialization after the InitializeComponent() call.
        displayControl = "Textbox"
        displayValue = ""
        refreshDisplay()

    End Sub

    Public Property ControlToDisplay As String
        Get
            Return displayControl
        End Get
        Set(value As String)
            displayControl = value
        End Set
    End Property

    Public Property ValueToDisplay As String
        Get
            Return displayValue
        End Get
        Set(value As String)
            displayValue = value
        End Set
    End Property

    Public Property Value As String
        Get
            Return myValue
        End Get
        Set(value As String)
            myValue = value
        End Set
    End Property

    Public Sub refreshDisplay()
        Select Case displayControl
            Case "Textbox"
                With ucTextBox
                    .Text = displayValue
                    .Visible = True
                End With
                ucComboBox.Visible = False
                ucCheckbox.Visible = False
            Case "Combobox"
                With ucComboBox

                    .Visible = True
                End With
                ucTextBox.Visible = False
                ucCheckbox.Visible = False
            Case "Checkbox"
                With ucCheckbox
                    .Checked = myValue
                    .Visible = True
                End With
                ucTextBox.Visible = False
                ucComboBox.Visible = False
        End Select
    End Sub

    Public Sub configureCombobox(dtb As DataTable, valueMember As String, displayMember As String, style As ComboBoxStyle)
        With ucComboBox
            .DataSource = dtb
            .ValueMember = "ID"
            .DisplayMember = "FriendlyName"
            .DropDownStyle = style
        End With
    End Sub

End Class

Class CustomConfigurableCellEditingControl
    Inherits UserControl
    Implements IDataGridViewEditingControl

    Private dataGridViewControl As DataGridView
    Private valueIsChanged As Boolean = False
    Private rowIndexNum As Integer

    Public Sub New()

    End Sub

    Public Property EditingControlFormattedValue() As Object _
        Implements IDataGridViewEditingControl.EditingControlFormattedValue

        Get
            'Return Me.Value.ToShortDateString()
            Return Me.valueIsChanged.ToString
        End Get

        Set(ByVal value As Object)
            Me.Value = value
        End Set

    End Property

    Public Function GetEditingControlFormattedValue(ByVal context As DataGridViewDataErrorContexts) As Object _
        Implements IDataGridViewEditingControl.GetEditingControlFormattedValue

        Return Me.valueIsChanged.ToString

    End Function

    Public Sub ApplyCellStyleToEditingControl(ByVal dataGridViewCellStyle As DataGridViewCellStyle) _
        Implements IDataGridViewEditingControl.ApplyCellStyleToEditingControl

        Me.Font = dataGridViewCellStyle.Font

    End Sub

    Public Property EditingControlRowIndex() As Integer _
        Implements IDataGridViewEditingControl.EditingControlRowIndex

        Get
            Return rowIndexNum
        End Get
        Set(ByVal value As Integer)
            rowIndexNum = value
        End Set

    End Property

    Public Function EditingControlWantsInputKey(ByVal key As Keys,
        ByVal dataGridViewWantsInputKey As Boolean) As Boolean _
        Implements IDataGridViewEditingControl.EditingControlWantsInputKey

    End Function

    Public Sub PrepareEditingControlForEdit(ByVal selectAll As Boolean) _
        Implements IDataGridViewEditingControl.PrepareEditingControlForEdit

        ' No preparation needs to be done.

    End Sub

    Public ReadOnly Property RepositionEditingControlOnValueChange() As Boolean _
        Implements IDataGridViewEditingControl.RepositionEditingControlOnValueChange

        Get
            Return False
        End Get

    End Property

    Public Property EditingControlDataGridView() As DataGridView _
        Implements IDataGridViewEditingControl.EditingControlDataGridView

        Get
            Return dataGridViewControl
        End Get
        Set(ByVal value As DataGridView)
            dataGridViewControl = value
        End Set

    End Property

    Public Property EditingControlValueChanged() As Boolean _
        Implements IDataGridViewEditingControl.EditingControlValueChanged

        Get
            Return valueIsChanged
        End Get
        Set(ByVal value As Boolean)
            valueIsChanged = value
        End Set

    End Property

    Public ReadOnly Property EditingControlCursor() As Cursor _
        Implements IDataGridViewEditingControl.EditingPanelCursor

        Get
            Return MyBase.Cursor
        End Get

    End Property

    Protected Overrides Sub OnValueChanged(ByVal eventargs As EventArgs)

        ' Notify the DataGridView that the contents of the cell have changed.
        valueIsChanged = True
        Me.EditingControlDataGridView.NotifyCurrentCellDirty(True)
        MyBase.OnValueChanged(eventargs)

    End Sub

End Class

Public Class DataGridViewCustomConfigurableCell
    Inherits DataGridViewTextBoxCell

    Public Sub New()

    End Sub

    Public Overrides Sub InitializeEditingControl(ByVal rowIndex As Integer,
        ByVal initialFormattedValue As Object,
        ByVal dataGridViewCellStyle As DataGridViewCellStyle)

        ' Set the value of the editing control to the current cell value.
        MyBase.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle)

        Dim ctl As CustomConfigurableCellEditingControl = CType(DataGridView.EditingControl, CustomConfigurableCellEditingControl)

        ' Use the default row value when Value property is null.
        If (Me.Value Is Nothing) Then
            ctl.Value = CType(Me.DefaultNewRowValue, String)
        Else
            ctl.Value = CType(Me.Value, String)
        End If

    End Sub

    Public Overrides ReadOnly Property EditType() As Type
        Get
            ' Return the type of the editing control that Cell uses.
            Return GetType(CustomConfigurableCellEditingControl)
        End Get
    End Property

    Public Overrides ReadOnly Property ValueType() As Type
        Get
            ' Return the type of the value that Cell contains.
            Return GetType(String)
        End Get
    End Property

    Public Overrides ReadOnly Property DefaultNewRowValue() As Object
        Get
            Return ""
        End Get
    End Property

End Class

Imports System.Windows.Forms

Public Class DataGridViewCustomConfigurableColumn
    Inherits DataGridViewColumn

    Public Sub New()
        MyBase.New(New DataGridViewCustomConfigurableCell())
    End Sub

    Public Overrides Property CellTemplate() As DataGridViewCell
        Get
            Return MyBase.CellTemplate
        End Get
        Set(ByVal value As DataGridViewCell)

            ' Ensure that the cell used for the template is a Custom Configurable Cell.
            If (value IsNot Nothing) AndAlso Not value.GetType().IsAssignableFrom(GetType(DataGridViewCustomConfigurableCell)) Then
                Throw New InvalidCastException("Must be a Custom Configurable Cell")
            End If
            MyBase.CellTemplate = value

        End Set
    End Property

End Class

But... I'm still none the wiser as to how I populate, display, manipulate etc. The code compiles fine, but I just get a blank column. I can't see any controls, I can't see any values and I can't seem to "trap" any of the events that should manipulate them?

With dgv
    cfgValueColumn = New DataGridViewCustomConfigurableColumn With {.DisplayIndex = valueColumn.Index + 1}
    With cfgValueColumn
        .HeaderText = "Custom Value"
        .Width = 300
    End With
    .Columns.Add(cfgValueColumn)

    Dim cfgCell As DataGridViewCustomConfigurableCell
    For Each row As DataGridViewRow In .Rows
        cfgCell = CType(row.Cells(cfgValueColumn.Index), DataGridViewCustomConfigurableCell)
        With cfgCell
            .Value = row.Cells(valueColumn.Index).Value
        End With
    Next
End With

Solution

  • Your main approach is correct if you just need to change the type of the given cell based on the ComboBox selection.

    When the value changes of the ComboBox cell:

    Here's a working example.

    Private Sub dgv_CellValueChanged(
                    sender As Object,
                    e As DataGridViewCellEventArgs) Handles dgv.CellValueChanged
        If e.RowIndex < 0 Then Return
    
        If e.ColumnIndex = dgvcTypeSelector.Index Then
            Dim cmb = DirectCast(dgv(e.ColumnIndex, e.RowIndex), DataGridViewComboBoxCell)
            Dim selItem = cmb.FormattedValue.ToString()
            Dim valSelCell = dgv(dgvcValueSelector.Index, e.RowIndex)
    
            valSelCell.Dispose()
    
            Select Case selItem
                Case "Text"
                    valSelCell = New DataGridViewTextBoxCell With {
                        .Value = "Initial value If any."
                    }
                Case "Combo"
                    valSelCell = New DataGridViewComboBoxCell With {
                        .ValueMember = "Value",
                        .DisplayMember = "ValueText",
                        .DataSource = dt,
                        .Value = 2 ' Optional...
                        }
                Case "Check"
                    valSelCell = New DataGridViewCheckBoxCell With {
                        .ValueType = GetType(String),
                        .Value = True
                    }
                    valSelCell.Style.Alignment = DataGridViewContentAlignment.MiddleCenter
                Case "Button"
                    valSelCell = New DataGridViewButtonCell With {
                        .Value = "Click Me!"
                    }
                    valSelCell.Style.Alignment = DataGridViewContentAlignment.MiddleCenter
            End Select
    
            dgv(dgvcValueSelector.Index, e.RowIndex) = valSelCell
        ElseIf e.ColumnIndex = dgvcValueSelector.Index Then
            Dim cell = dgv(e.ColumnIndex, e.RowIndex)
            Console.WriteLine($"{cell.Value} - {cell.FormattedValue}")
        End If
    End Sub
    
    Private Sub dgv_CurrentCellDirtyStateChanged(
                sender As Object,
                e As EventArgs) Handles dgv.CurrentCellDirtyStateChanged
        If dgv.IsCurrentCellDirty Then
            dgv.CommitEdit(DataGridViewDataErrorContexts.Commit)
        End If
    End Sub
    
    ' If you need to handle the Button cells.
    Private Sub dgv_CellContentClick(
                    sender As Object,
                    e As DataGridViewCellEventArgs) Handles dgv.CellContentClick
        If e.RowIndex >= 0 AndAlso
            e.ColumnIndex = dgvcValueSelector.Index Then
            Dim btn = TryCast(dgv(e.ColumnIndex, e.RowIndex), DataGridViewButtonCell)
            If btn IsNot Nothing Then
                Console.WriteLine(btn.FormattedValue)
            End If
        End If
    End Sub
    

    Note, I've changed the value type of the check box cell by ValueType = GetType(String) to avoid throwing exceptions caused by the different value types of the check box and main columns. I'd assume the main column here is of type DataGridViewTextBoxColumn. So you have String vs. Boolean types. If you face problems in the data binding scenarios, then just swallow the exception.

    Private Sub dgv_DataError(
                    sender As Object,
                    e As DataGridViewDataErrorEventArgs) Handles dgv.DataError
        If e.RowIndex >= 0 AndAlso e.ColumnIndex = dgvcValueSelector.Index Then
            e.ThrowException = False
        End If
    End Sub
    

    SOSO73734207