Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to reset the close reason when close is cancelled

Question

Is it possible to reset the CloseReason provided by the FormClosingEventArgs in the FormClosing event of a modal dialog?

Symptoms

Setting the DialogResult of a modal dialog can result in an "incorrect" CloseReason if the close event have previously been cancelled.

Details

(The following code is just sample code to highlight the inconvenience)

Imagine I have a form with two buttons, OK and Cancel, displayed as a modal dialog.

Me.btnOk = New Button With {.DialogResult = Windows.Forms.DialogResult.OK}
Me.btnCancel = New Button With {.DialogResult = Windows.Forms.DialogResult.Cancel}

Me.AcceptButton = Me.btnOk
Me.CancelButton = Me.btnCancel

Any attempts to close the form will be cancelled.

If I click each button (including the [X] - close form button) in the following order, the close reasons will be as following:

Case 1

  • btnOk::::::::::: None
  • btnCancel::: None
  • X::::::::::::::::::: UserClosing

Now, if I repeat the steps you'll see that the UserClosing reason will persist:

  • btnOk::::::::::: UserClosing
  • btnCancel::: UserClosing
  • X::::::::::::::::::: UserClosing

Case 2

  • X::::::::::::::::::: UserClosing
  • btnCancel::: UserClosing
  • btnOk::::::::::: UserClosing

Same here. Once you click the X button the close reason will always return UserClosing.

Sample application

Public Class Form1

    Public Sub New()
        Me.InitializeComponent()
        Me.Text = "Test"
        Me.FormBorderStyle = Windows.Forms.FormBorderStyle.FixedDialog
        Me.MinimizeBox = False
        Me.MaximizeBox = False
        Me.ClientSize = New Size(75, 25)
        Me.StartPosition = FormStartPosition.CenterScreen
        Me.btnOpenDialog = New Button() With {.TabIndex = 0, .Dock = DockStyle.Fill, .Text = "Open dialog"}
        Me.Controls.Add(Me.btnOpenDialog)
    End Sub

    Private Sub HandleOpenDialog(sender As Object, e As EventArgs) Handles btnOpenDialog.Click
        Using instance As New CustomDialog()
            instance.ShowDialog()
        End Using
    End Sub

    Private WithEvents btnOpenDialog As Button

    Private Class CustomDialog
        Inherits Form

        Public Sub New()
            Me.Text = "Custom dialog"
            Me.ClientSize = New Size(400, 200)
            Me.StartPosition = FormStartPosition.CenterParent
            Me.FormBorderStyle = Windows.Forms.FormBorderStyle.FixedDialog
            Me.MinimizeBox = False
            Me.MaximizeBox = False
            Me.tbOutput = New RichTextBox() With {.TabIndex = 0, .Bounds = New Rectangle(0, 0, 400, 155), .ReadOnly = True, .ScrollBars = RichTextBoxScrollBars.ForcedBoth, .WordWrap = True}
            Me.btnExit = New Button With {.TabIndex = 3, .Text = "Exit", .Bounds = New Rectangle(10, 165, 75, 25), .Anchor = (AnchorStyles.Bottom Or AnchorStyles.Left)}
            Me.btnOk = New Button With {.TabIndex = 1, .Text = "OK", .Bounds = New Rectangle(237, 165, 75, 25), .Anchor = (AnchorStyles.Bottom Or AnchorStyles.Right), .DialogResult = Windows.Forms.DialogResult.OK}
            Me.btnCancel = New Button With {.TabIndex = 2, .Text = "Cancel", .Bounds = New Rectangle(315, 165, 75, 25), .Anchor = (AnchorStyles.Bottom Or AnchorStyles.Right), .DialogResult = Windows.Forms.DialogResult.Cancel}
            Me.Controls.AddRange({Me.tbOutput, Me.btnExit, Me.btnOk, Me.btnCancel})
            Me.AcceptButton = Me.btnOk
            Me.CancelButton = Me.btnCancel
        End Sub

        Private Sub HandleExitDialog(sender As Object, e As EventArgs) Handles btnExit.Click
            Me.exitPending = True
            Me.Close()
        End Sub

        Protected Overrides Sub OnFormClosing(e As FormClosingEventArgs)
            If (Not Me.exitPending) Then
                e.Cancel = True
                Me.tbOutput.Text += (String.Format("DialogResult={0}, CloseReason={1}{2}", Me.DialogResult.ToString(), e.CloseReason.ToString(), Environment.NewLine))
                Me.DialogResult = Windows.Forms.DialogResult.None
            End If
            MyBase.OnFormClosing(e)
        End Sub

        Private exitPending As Boolean

        Private WithEvents btnExit As Button
        Private WithEvents btnCancel As Button
        Private WithEvents btnOk As Button
        Private WithEvents tbOutput As RichTextBox

    End Class

End Class

Update

I was of the impression that if either the Form.AcceptButton or Form.CancelButton (IButtonControl) was clicked the close reason would be set to UserClosing, but this is not the case. In the following code you'll see that all it do is setting the DialogResult of the owning form to that of its own DialogResult.

Protected Overrides Sub OnClick(ByVal e As EventArgs)
    Dim form As Form = MyBase.FindFormInternal
    If (Not form Is Nothing) Then
        form.DialogResult = Me.DialogResult
    End If
    MyBase.AccessibilityNotifyClients(AccessibleEvents.StateChange, -1)
    MyBase.AccessibilityNotifyClients(AccessibleEvents.NameChange, -1)
    MyBase.OnClick(e)
End Sub

The Control class do have a property named CloseReason but it's defined as Friend, thus not accessible.

I also thought that setting the forms DialogResult would result in a WM message being sent, but all it does is setting a private field.

So I delved into reflector and followed the stack. The following image is a highly simplified illustration.

Stack

This is how the CheckCloseDialog method looks like:

Friend Function CheckCloseDialog(ByVal closingOnly As Boolean) As Boolean
    If ((Me.dialogResult = DialogResult.None) AndAlso MyBase.Visible) Then
        Return False
    End If
    Try
        Dim e As New FormClosingEventArgs(Me.closeReason, False)
        If Not Me.CalledClosing Then
            Me.OnClosing(e)
            Me.OnFormClosing(e)
            If e.Cancel Then
                Me.dialogResult = DialogResult.None
            Else
                Me.CalledClosing = True
            End If
        End If
        If (Not closingOnly AndAlso (Me.dialogResult <> DialogResult.None)) Then
            Dim args2 As New FormClosedEventArgs(Me.closeReason)
            Me.OnClosed(args2)
            Me.OnFormClosed(args2)
            Me.CalledClosing = False
        End If
    Catch exception As Exception
        Me.dialogResult = DialogResult.None
        If NativeWindow.WndProcShouldBeDebuggable Then
            Throw
        End If
        Application.OnThreadException(exception)
    End Try
    If (Me.dialogResult = DialogResult.None) Then
        Return Not MyBase.Visible
    End If
    Return True
End Function

As you can see the modal message loop checks the DialogResult in every cycle and if the conditions are met it will use the stored CloseReason (as observed) when creating the FormClosingEventArgs.

Summary

Yes, I know that the IButtonControl interface have a PerformClick method which you can call programmatically, but still, IMO this smells like a bug. If clicking a button is not a result of a user action then what is?

like image 962
Bjørn-Roger Kringsjå Avatar asked May 26 '14 14:05

Bjørn-Roger Kringsjå


2 Answers

It is pretty important to understand why this is behaving the way it does, you are liable to get yourself into trouble when you rely in the CloseReason too much. This is not a bug, it is a restriction due to the way Windows was designed. One core issue is the way the WM_CLOSE message is formulated, it is the one that sets the train in motion, first firing the FormClosing event.

This message can be sent for lots of reasons, you are familiar with the common ones. But that's not where it ends, other programs can send that message as well. You can tell the "flaw" from the MSDN Library article I linked to, the message is missing a WPARAM value that encodes the intent of the message. So there isn't any way for a program to provide a reasonable CloseReason back to you. Winforms is forced to guess at a reason. It is of course an entirely imperfect guess.

That's not where it ends, the DialogResult property is a problem as well. It will force a dialog to close when any code assigns that property. But again the same problem, there isn't any way for such code to indicate the intent of the assignment. So it doesn't, it leaves in internal Form.CloseReason property at whatever value it had before, None by default.

This was "properly" implemented in .NET 1.0, there was only the Closing event and it didn't give a reason at all. But that didn't work out so well either, apps that used it chronically prevented Windows from shutting down. They just didn't know that it was inappropriate to, say, display a message box. The .NET 2.0 FormClosing event was added as a workaround for that. But it needs to work with the imperfect guess.

It is important to rate the CloseReason values, some are very accurate and some are just guesses:

  • CloseReason.WindowsShutdown - reliable
  • CloseReason.ApplicationExitCall - reliable
  • CloseReason.MdiFormClosing - reliable, not very useful
  • CloseReason.FormOwnerClosing - reliable, not very useful
  • CloseReason.TaskManagerClosing - complete guess, will be returned when any program sends a WM_CLOSE message, not just Task Manager
  • CloseReason.UserClosing - complete guess, will also be returned when your program calls the Close() method for example
  • CloseReason.None - it just doesn't know.

Yes, Winforms not setting the CloseReason back to None when your FormClosing event handler cancels is arguably a bug. But it isn't the kind of bug that actually really matters. Since you can't treat UserClosing and None differently anyway.

like image 157
Hans Passant Avatar answered Nov 19 '22 05:11

Hans Passant


I would probably call that a bug.

As you mentioned, the CloseReason property is marked internal (or Friend in VB.Net terms) so one work-around to the problem is using Reflection to reset that value yourself:

Protected Overrides Sub OnFormClosing(e As FormClosingEventArgs)
  If Not exitPending Then
    e.Cancel = True
    tbOutput.AppendText(String.Format("DialogResult={0}, CloseReason={1}{2}", _
                        Me.DialogResult.ToString(), e.CloseReason.ToString(), _
                        Environment.NewLine))
    Dim pi As PropertyInfo
    pi = Me.GetType.GetProperty("CloseReason", _
                                BindingFlags.Instance Or BindingFlags.NonPublic)
    pi.SetValue(Me, CloseReason.None, Nothing)
  End If
  MyBase.OnFormClosing(e)
End Sub

No guarantee that this code would work on future versions of WinForms, but I'm guessing it's a safe bet these days. :-)

like image 2
LarsTech Avatar answered Nov 19 '22 07:11

LarsTech