FMX OnCloseQuery and OnClose woes on OS X – fixing QC 111362 (+ another bug)

If you’ve attempted to use FireMonkey to write an application targeting OS X, you may well have come across CloseQuery bugs – for me it’s generally been any dialog shown by a form’s OnCloseQuery handler having a tendancy to show twice, though you can also get the opposite problem, of the OnCloseQuery handler not being shown at all. QC 111362 (not by me) provides a clear test case of both: in the first instance, the user answering ‘yes’ to the save changes prompt leads to the prompt showing again, and in the second, assigning the ModalResult property of a button leads to OnCloseQuery not being raised at all when the button is clicked.

Having been bugging me for a while, I finally decided to investigate the problems properly. First up was the showing-twice issue. At first I thought it must be dialog-related, but it wasn’t:

  • As in the VCL, the FMX TForm has CloseQuery and Close methods, with the implementation of the latter calling the former. (Unlike in the VCL, FMX does not abstract out calling the OnClose event into a DoClose method. Instead, it is called directly inside Close. But I digress)
  • At the platform-specific level, FMX.Platform.pas calls CloseQuery in response to the windowShouldClose: message, which is good, and Close in response to windowWillClose:… which is bad. Why? Because as its name implies, by the time windowWillClose: is sent, the window is closing, not asking whether it should close. When the user clicks the red traffic light, the message order is therefore windowShouldClose:, and if that returns True, windowWillClose:. When OnCloseQuery has been handled without setting CanClose to False, this means it gets called a second time. Doh!
  • Instead of calling Close, the windowWillClose: handler should just invoke any assigned OnClose handler, if that. If a DoClose method had existed, perhaps the author would have at least thought whether Close rather DoClose is really appropriate…

Now, I say that windowWillClose: should call OnClose ‘if that’ because OnClose itself acts as a ‘are you really, really sure you want the form to close?’ event. This is because if a handler sets the Action var parameter to TCloseAction.caNone, in principle, the form should not now close:

procedure TForm2.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  if MessageDlg('Are you SURE you want the form to close?',
    TMsgDlgType.mtConfirmation, mbYesNo) <> mrYes then
    Action := TCloseAction.caNone;
end;

Predictably enough though, such code doesn’t work when targeting OS X, assuming Close is called because of the user attempting to close the form rather than the form being closed programmatically. More exactly, setting Action to anything beyond the default (caHide) doesn’t have any affect, because Cocoa has already decided what will happen. To make even OnClose work correctly, then, almost the entire logic needs to be placed in the windowShouldClose: handler, and the windowWillClose: handler left relatively bare.

As for the bug of OnCloseQuery not being raised at all, the problem there is specific to a modal form being closed due to its ModalResult property being set, either directly or via a button. Looking at the source, the CloseModal call inside TPlatformCocoa.ShowWindowModal looked suspicious, and sure enough, it was the problem:

  • As before, the FMX TForm mimics its VCL forebear in having a CloseModal method. In the VCL (and FMX/Windows), this is is called inside the modal message loop once a change in the form’s ModalResult property has been detected. CloseModal itself then calls CloseQuery to give the client code the chance to cancel the closure; if CloseQuery returns False, the ModalResult property is reset to mrNone (0). Back in the modal message loop, the ModalResult property is then checked a second time to allow any reset to take effect.
  • The author of TPlatformCocoa.ShowWindowModal misunderstood the purpose of CloseModal, apparently assuming it is some sort of notification method rather than a ‘last chance to cancel’ one. As such, CloseModal is called in the wrong place (too late), and ModalResult not rechecked.

Here’s how to fix these various bugs:

  • Take a copy of FMX.Platform.Mac.pas, and put it in your application’s main source directory (you can also explicitly add it to the project if you want).
  • Open the copy and head to the TFMXWindow.windowShouldClose method implementation. Change it look like this:
function TFMXWindow.windowShouldClose(Sender: Pointer): Boolean;
var
  Action: TCloseAction;                                  //!!!CCR
begin
  Result := False;
  if Application = nil then
    Exit;
  if Application.Terminated then
    Exit;
  try
    Result := Wnd.CloseQuery;
    if Result and Assigned(Wnd.OnClose) then             //!!!CCR
    begin                                                //!!!CCR
      Action := TCloseAction.caHide;                     //!!!CCR
      Wnd.OnClose(Wnd, Action);                          //!!!CCR
      if Action = TCloseAction.caMinimize then           //!!!CCR
      begin                                              //!!!CCR
        Result := False;                                 //!!!CCR
        Wnd.WindowState := TWindowState.wsMinimized;     //!!!CCR
      end                                                //!!!CCR
      else                                               //!!!CCR
        if (Application <> nil) and                      //!!!CCR
           (Wnd = Application.MainForm) then             //!!!CCR
          Result := (Action in [TCloseAction.caHide,     //!!!CCR
            TCloseAction.caFree])                        //!!!CCR
        else                                             //!!!CCR
        begin                                            //!!!CCR
          Result := (Action = TCloseAction.caHide);      //!!!CCR
          if Action = TCloseAction.caFree then Wnd.Release;
        end;                                             //!!!CCR
    end;
  except
    Application.HandleException(Self);
  end;
end;

The next method down is probably TFMXWindow.windowWillClose; in it, delete everything, before adding the following in its place:

procedure TFMXWindow.windowWillClose(
  notification: NSNotification);
begin
  if (Application <> nil) and (Application.MainForm = Wnd) then
    Application.Terminate;
end;

The addition preserves the ‘main form’ semantics we implicitly remove by not calling Close (which would otherwise enforce them).

The revised windowShouldClose and windowWillClose methods fix the double shows and OnClose not working properly; to fix the no-shows, head next to the implementation of TPlatformCocoa.ShowWindowModal. Find the call the AForm.CloseModal, and comment it out (it’s in the wrong place). Finally, find the check for AForm.ModalResult not being 0, and amend the code to look like this:

          if AForm.ModalResult <> 0 then
          begin
            AForm.CloseModal;                     //!!!CCR
            if AForm.ModalResult <> 0 then        //!!!CCR
            begin                                 //!!!CCR
              NSApp.stopModal;
              Continue;
            end;                                  //!!!CCR
          end;

To take effect, you may need to disable debug DCUs to ensure the revised unit gets picked up (Project|Options, Delphi Compiler > Compiling, uncheck ‘Use debug DCUs’ – you’ll only need to do this for the OS X debug configuration).

As a final point, please keep in mind I do not encourage invoking event handlers directly, as the code above does – the fact the FMX source doesn’t bother with dedicated event-calling methods half the time says nothing about the validity of such practice. The only reason I do it above is because I wish to avoid interface changes, and therfore, DCU incompatibilities.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s