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
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.
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?
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:
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.
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. :-)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With