VBA: ListBox Change 事件触发两次

VBA: ListBox Change event firing twice

我在 Excel 中有一个用户表单,其中的问题在列表框控件中编入索引。单击列表框中的项目会调用 Change 事件,该事件会根据已选择的项目填充其他控件的值。

用户可以更改文本框中的值。更改它们后,"Saved" 标志将针对该问题设置为 False。然后用户可以将问题保存到内存中;或离开问题。

如果用户在没有保存的情况下导航离开(通过单击列表框中的不同项目),我想向他们展示一个警告——让他们选择放弃他们未保存的更改;或保留当前选择,并还原他们刚刚单击的列表框选择。

如果选择"Abandon changes",则效果很好。但是,当我尝试恢复列表框选择时,它遇到了麻烦。我使用 "EventsOn" 布尔值来处理 Change 过程何时应该继续,以避免它调用自己。 这似乎在代码中的正确位置起作用。但是在恢复 EventsOn 和 Exit Sub 之后,Change 事件似乎又被称为.

我不知道为什么事件再次触发。这会导致用户第二次看到该选项。

由于涉及到其他窗体控件的细节,所以下面的代码被去掉了很多; loading/saving 来自数据库的数据;并处理 类 和字典。但是我保留了表单控件的相关逻辑:

Option Explicit
Dim NumberOfQuestions As Long
Dim EventsOn As Boolean
Dim SelectedListIndex As Long, CurrentQuestion As Long, QuestionSaved As Variant

Private Sub UserForm_Initialize()
    ' Stripped out lots of code here. Basically opens a recordset and loads values
    ReDim QuestionSaved(1 To NumberOfQuestions) As Boolean
    '
    For X = 1 To NumberOfQuestions
        lbox_QuestionList.AddItem "Question " & X ' Populate the listbox with items
        QuestionSaved(X) = True ' Flag the initial state as saved, for each question
        If Not X = rst.RecordCount Then rst.MoveNext
    Next X
    '
    ' Select the first question by default. Note that the Listbox ListIndex starts at 0, whereas the questions start at 1
    SelectedListIndex = 0
    CurrentQuestion = 1
    EventsOn = True
    lbox_QuestionList.ListIndex = SelectedListIndex
End Sub

Private Sub lbox_QuestionList_Change()
    ' Ensure this event does NOT keep firing in a loop, when changed programmatically
    If Not EventsOn Then Exit Sub
    '
    If Not QuestionSaved(CurrentQuestion) Then
        If MsgBox(Prompt:="Abandon changes to current question?", Title:="Current question not saved", Buttons:=vbYesNo + vbDefaultButton2) = vbYes Then
            ' Abandon changes = Yes
            ' Mark as saved
            QuestionSaved(CurrentQuestion) = True
            ' Then proceed to change as normal
            ' (If the user comes back to this question, it will be re-loaded from memory in its original form)
            ' This works okay
        Else
            ' Abandon changes = No
            EventsOn = False ' So this sub is not called again
            ' REVERT the ListBox selection. Do this by recalling the current question number, and applying it to the ListIndex
            SelectedListIndex = CurrentQuestion - 1 ' Remember that the index will be minus 1, due to indexing starting at 0
            lbox_QuestionList.ListIndex = SelectedListIndex
            EventsOn = True
            Exit Sub ' This should be the end of it. But somehow, it's not...
        End If
    End If
    ' Proceed with loading a new question according to the new selected ListIndex
    SelectedListIndex = lbox_QuestionList.ListIndex ' Recognise the current selection
    ' ListIndex starts at zero, so we need to add 1
    CurrentQuestion = SelectedListIndex + 1
    ShowQuestion CurrentQuestion
End Sub

Private Sub ShowQuestion(QuestionNumber As Long)
    ' Stripped out code for brevity. Basically loads details from a dictionary of classes, and populates into textboxes
End Sub

Private Sub cb_Save_Click()
    ' Stipped out code. Takes values of current text boxes and saves them into a class in a dictionary
    ' Mark the current question as saved:
    QuestionSaved(CurrentQuestion) = True
End Sub

''''''''''' Event handlers ''''''''''''''
Private Sub tb_Question_Change()
    DoChange
End Sub
' Several other form controls have similar events: all calling "DoChange" as below

Private Sub DoChange()
    If Not EventsOn Then Exit Sub
    QuestionSaved(CurrentQuestion) = False ' Flag the current question as NOT saved, if any changes are made to form values
End Sub

当然,我已经搜索过这个问题 - 但到目前为止还没有帮助我的答案:

我的代码逻辑似乎很合理。谜团是为什么要第二次调用 Change 事件,即使在 Exit Sub.

之后也是如此

经过一段时间的调查,似乎让列表框在其自身的更改事件中设置自己的列表索引(有效地递归调用它)会导致一些奇怪的后端问题。幸运的是,通过将该位迁移到它自己的函数中就很容易处理。经过一些试验,最好的方法是创建一个清除和重新填充列表框的函数,因此在您的用户窗体代码中创建这个函数:

Private Function PopulateListbox(Optional ByVal arg_lSelected As Long = -1)

    Me.lbox_QuestionList.Clear

    Dim X As Long '
    For X = 1 To NumberofQuestions
        lbox_QuestionList.AddItem "Question " & X ' Populate the listbox with items
        QuestionSaved(X) = True ' Flag the initial state as saved, for each question
        'If Not X = rst.RecordCount Then rst.MoveNext
    Next X

    Me.lbox_QuestionList.ListIndex = arg_lSelected

End Function

现在将您的 Initialize 事件调整为如下所示(请注意,您需要在此处定义 NumberofQuestions,然后在末尾调用新函数来填充列表框和 select 第一个条目):

Private Sub UserForm_Initialize()
    ' Stripped out lots of code here. Basically opens a recordset and loads values

    NumberofQuestions = 3  'This is where NumberofQuestions gets defined
    ReDim QuestionSaved(1 To NumberofQuestions)
    ReDim aAnswers(1 To NumberofQuestions)

    '
    ' Select the first question by default. Note that the Listbox ListIndex starts at 0, whereas the questions start at 1
    SelectedListIndex = 0
    CurrentQuestion = 1
    EventsOn = True
    PopulateListbox SelectedListIndex  'Call the new function and set the 1st selection

End Sub

最后,将您的 listbox_change 事件更新为如下所示(基本上只是将列表框条目的设置外包给新函数):

Private Sub lbox_QuestionList_Change()

    ' Ensure this event does NOT keep firing in a loop, when changed programmatically
    If Not EventsOn Then Exit Sub
    '
    If Not QuestionSaved(CurrentQuestion) Or aAnswers(CurrentQuestion) <> Me.tb_Question.Text Then   'I added the second condition for testing purposes, may not be necessary in your full code
        If MsgBox(Prompt:="Abandon changes to current question?", Title:="Current question not saved", Buttons:=vbYesNo + vbDefaultButton2) = vbYes Then
            ' Abandon changes = Yes
            ' Mark as saved
            QuestionSaved(CurrentQuestion) = True
            ' Then proceed to change as normal
            ' (If the user comes back to this question, it will be re-loaded from memory in its original form)
            ' This works okay
        Else
            ' Abandon changes = No
            EventsOn = False ' So this sub is not called again
            ' REVERT the ListBox selection. Do this by recalling the current question number, and applying it to the ListIndex
            SelectedListIndex = CurrentQuestion - 1 ' Remember that the index will be minus 1, due to indexing starting at 0
            PopulateListbox SelectedListIndex 'Call your new function here

            EventsOn = True
            Exit Sub ' This should be the end of it. But somehow, it's not...
        End If
    End If
    ' Proceed with loading a new question according to the new selected ListIndex
    SelectedListIndex = lbox_QuestionList.ListIndex ' Recognise the current selection
    ' ListIndex starts at zero, so we need to add 1
    CurrentQuestion = SelectedListIndex + 1
    ShowQuestion CurrentQuestion
End Sub

(诅咒 OP 让我脑子里出现这个问题!)

在我的测试中,我使用了以下用户窗体:

下面的代码使用了 ListBox1_AfterUpdate 事件,我相信它可能适合你。

Option Explicit

Private Const TOTAL_QUESTIONS As Long = 3
Private qSaved As Variant
Private selectedDuringTextboxChange As Long
Private eventsInProgress As Long

Private Sub ListBox1_AfterUpdate()
    Debug.Print "listbox clicked, item " & (ListItemSelected() + 1) & " selected"
    If eventsInProgress > 0 Then
        Debug.Print "   ... event in progress, exiting"
        eventsInProgress = eventsInProgress - 1
        Exit Sub
    End If

    If Not qSaved(selectedDuringTextboxChange) Then
        Dim answer As VbMsgBoxResult
        answer = MsgBox("Abandon changes?", vbYesNo + vbDefaultButton2)
        If answer = vbYes Then
            Debug.Print "yes, abandon the changes"
            qSaved(selectedDuringTextboxChange) = True
        Else
            Debug.Print "nope, keep the changes"
            '--- return to the previously selected list item
            eventsInProgress = eventsInProgress + 1
            UnselectAll
            ListBox1.Selected(selectedDuringTextboxChange - 1) = True
            ListBox1.ListIndex = selectedDuringTextboxChange - 1
        End If
    End If
End Sub

Private Sub QuitButton_Click()
    Me.Hide
End Sub

Private Sub SaveButton_Click()
    qSaved(ListBox1.ListIndex + 1) = True
End Sub

Private Sub TextBox1_Change()
    selectedDuringTextboxChange = ListBox1.ListIndex + 1
    qSaved(selectedDuringTextboxChange) = False
    Debug.Print "changed text for question " & selectedDuringTextboxChange
End Sub

Private Sub UserForm_Initialize()
    ReDim qSaved(1 To TOTAL_QUESTIONS)

    selectedDuringTextboxChange = 1
    With ListBox1
        Dim i As Long
        For i = 1 To TOTAL_QUESTIONS
            .AddItem "Question " & i
            qSaved(i) = True
        Next i
        .Selected(0) = True
    End With
    eventsInProgress = False
End Sub

Private Sub UnselectAll()
    eventsInProgress = eventsInProgress + 1
    With ListBox1
        Dim i As Long
        For i = 0 To .ListCount - 1
            .Selected(i) = False
        Next i
    End With
    eventsInProgress = eventsInProgress - 1
End Sub

Private Function ListItemSelected() As Long
    ListItemSelected = -1
    With ListBox1
        Dim i As Long
        For i = 0 To .ListCount - 1
            If .Selected(i) Then
                ListItemSelected = i
            End If
        Next i
    End With
End Function

Private Sub WhichListItem_Click()
    With ListBox1
        Dim i As Long
        For i = 0 To .ListCount - 1
            Debug.Print "listbox item(" & i & ") = " & .Selected(i)
        Next i
    End With
    Debug.Print "eventsInProgress = " & eventsInProgress
End Sub

我遇到了 Private Sub ListBox_Click() 运行 两次问题。

当我清除列表框属性中的 ControlSource 时,问题就解决了。我不得不添加一行代码专门将 列表框 中的值写入工作表中的单元格。起初单元格不会显示数据,所以我将范围名称设置为另一个单元格,这样就可以了。因此,然后我将新单元格拖放到原始位置。

我不明白问题出在哪里,但修复有效。

我遇到了类似的意外问题,所以也许有人会发现此结果有帮助。 在 multi-selection-enabled Listbox_Change 事件中,我检查了 currently-selected 项的值以查看它是否已被选中。

    Private Sub lstBox_Change()
        With lstBox
            If .Selected(.ListIndex) Then
                ' Call Method A.
            Else
                ' Call Method B.
            End If
        End With
    End Sub

选中列表后,它会正确检测到选择并调用 A-- 但是,当单步执行代码并到达 Change 事件的 End Sub 时,复选框会自动取消选中,并且 Change 事件会再次开火。请注意,我没有在 ListBox 本身中设置任何值;我只是检查当前项目是否已选中或未选中。但是,不知何故,这触发了它取消选择自己。 (此外,这似乎只发生在第一次调用 Change 事件时。此后它表现正常。)

我尝试了其他一些修复程序,但 BeforeUpdateAfterUpdate 似乎根本没有触发。当我将选择测试移到 If 语句之外并将结果放入布尔值时,问题就消失了:

    Private Sub lstBox_Change()
        With lstBox
            BooleanResult = (.Selected(.ListIndex) = True)
            If BooleanResult Then
                ' Call Method A.
            Else
                ' Call Method B.
            End If
        End With
    End Sub

此后,ListBox 的行为始终符合预期。