Making the never-ending sorry tale of FMX menus a bit happier

For me, FireMonkey at present has three main showstopping limitations: an inability to draw text that is neither wonky (XP/Vista) nor overly ‘sharp’ (W7/W8) on Windows; a TMemo that isn’t fit for purpose on any platform; and a menu implementation that is poor on Windows, and hopelessly broken on OS X. Well, perhaps not quite ‘hopelessly’ – while the only proper solution would be to untangle the source and use native menus throughout, in the meantime, I have found some workarounds for specific issues: in particular, three concerning TMainMenu (= the FMX interface for the native menu bar) and two concerning TPopupMenu.

Problem: the Mac menu bar is owned by the application, but FMX thinks it should be owned by a form

In a traditional Mac application whose raison d’être is to view or edit individual files, it is possible for the application to be running yet with no windows open, in which case you just have the application’s menu bar showing when you Cmd+Tab to it. Alas, but TMainMenu cannot fathom such a thing, since it thinks it must always be attached in spirit to a specific form even if it is not attached visually, as it would be on Windows. Put a TMainMenu on a data module, then, and nothing happens.

To get around this, define an interposer class for TMainMenu that firstly overrides RecreateOSMenu to do nothing, and secondly provides an explicit Activate method:

type
  TMainMenu = class(FMX.Menus.TMainMenu, IItemsContainer)
  protected
    procedure RecreateOSMenu; override;
  public
    procedure Activate;
  end;

//...

procedure TMainMenu.Activate;
begin
  IFMXMenuService(TPlatformServices.Current.GetPlatformService(
    IFMXMenuService)).CreateOSMenu(Application.MainForm, Self);
end;

procedure TMainMenu.RecreateOSMenu;
begin
  { do nothing - Activate method now controls this }
end;

You still need to provide a target form for Windows, but the CreateOSMenu code on OS X just ignores it (at least for now!). Anyhow, with the Activate method defined, you can now put a TMainMenu on an auto-created data module and (if you want that) only have the menu appear when running on OS X (since a menu bar will be there anyway).

Problem: when a menu item’s state is changed (e.g., its Enabled or Visible property is toggled), the FMX main menu code rebuilds the whole menu

This is just a complete WTF, but anyhow, sanity can be found in providing an alternative implementation of the IFMXMenuService interface, in harness with an interposer class for TMenuItem. For the details, see here.

Problem: if an item on a main menu has been assigned an action that controls its core properties (Enabled, Visible, Text, etc.) and the action doesn’t get updated independently, the action’s OnUpdate handler will never get called

To see this problem in action, do this:

1. Create a new FMX HD application, add a TEdit and TButton to the form, followed by a TActionList and a TMainMenu.

2. Add an action called actCopy to the action list, set its Text property to ‘Copy’, and handle its OnUpdate and OnExecute functions like this:

procedure TForm1.actCopyExecute(Sender: TObject);
begin
  (Focused.GetObject as TEdit).CopyToClipboard;
end;

procedure TForm1.actCopyUpdate(Sender: TObject);
var
  Obj: TFmxObject;
begin
  if Focused = nil then
    actCopy.Enabled := False
  else
  begin
    Obj := Focused.GetObject;
    actCopy.Enabled := (Obj is TCustomEdit) and
      (TCustomEdit(Obj).SelLength > 0);
  end;
end;

3. Add an top level item to the main menu, and a second level item under it which then gets assigned actCopy to its Action property. If you are using the TMainMenu interposer class discussed previously, add a call to MainMenu1.Activate in a handler for the form’s OnCreate event.

4. Run the application; notice that when the menu is pulled down, the Copy item is enabled regardless of whether the TEdit has the focus and has text selected.

Now, because I’m only interested in TMainMenu on OS X, the fix I’m about to suggest is Mac-specific. The principle should be similar on Windows though. Secondly, this fix presupposes the previous one, i.e. the alternative IFMXMenuService implementation and TMenuItem interposer class that doesn’t rebuild everything from scratch just because a single Enabled property has changed. If you cause the main menu to be completely recreated when you are handling one of its events, then surprise surprise, the OS doesn’t like it, and will in fact terminate the application there and then in the OS X case.

Anyhow, those caveats aside, take a copy of FMX.Platform.Mac.pas, add the copy to the project, and make the following edits to it:

  • Find the NewNSMenu method declaration, and change the parameter from Text: string to Source: TMenuItem.
  • Amend all calls to NewNSMenu accordingly (i.e., pass in the menu item itself, not the menu item’s text). There’s one call you won’t be able to adjust for, so comment it out and use this code instead:
//LNewMenu := NewNsMenu(FMXAppName);
LNewMenu := TNSMenu.Wrap(TNSMenu.Alloc.initWithTitle(NSSTR(FMXAppName)));
  • Where FMXOSMenuItem and TFMXOSMenuItem are defined, add the following interface and class on similar lines:
  FMXOSMenu = interface(NSMenu)
  ['{E0653081-A641-4220-B7D3-2F1CF87E071E}']
    procedure menuNeedsUpdate(menu: Pointer); cdecl;
  end;

  TFMXOSMenu = class(TOCLocal, NSMenuDelegate)
  strict private
    FSource: TMenuItem;
  public
    constructor Create(const ASource: TMenuItem);
    destructor Destroy; override;
    function GetObjectiveCClass: PTypeInfo; override;
    procedure menuNeedsUpdate(menu: Pointer); cdecl;
  end;

constructor TFMXOSMenu.Create(const ASource: TMenuItem);
begin
  inherited Create;
  FSource := ASource;
  UpdateObjectID(NSMenu(Super).initWithTitle(NSSTR(DelAmp(ASource.Text))));
  NSMenu(Super).setDelegate(Self);
end;

destructor TFMXOSMenu.Destroy;
begin
  NSMenuItem(Super).Release;
  inherited;
end;

function TFMXOSMenu.GetObjectiveCClass: PTypeInfo;
begin
  Result := TypeInfo(FMXOSMenu);
end;

procedure TFMXOSMenu.menuNeedsUpdate(menu: Pointer); cdecl;
var
  I: Integer;
  Item: TMenuItem;
begin
  try
    for I := FSource.ItemsCount - 1 downto 0 do
    begin
      Item := FSource.Items[I];
      if Item.Action <> nil then
        Item.Action.Update;
    end;
  except //an unhandled exception here will kill the program
    Application.HandleException(ExceptObject);
  end;
end;
  • Amend the implementation of NewNSMenu itself to look like this:
function TPlatformCocoa.NewNSMenu(const Source: TMenuItem): NSMenu;
var
  AutoReleasePool: NSAutoreleasePool;
begin
  AutoReleasePool := TNSAutoreleasePool.Create;
  try
    Result := NSMenu(TFMXOSMenu.Create(Source).Super);
    FNSHandles.Add(AllocHandle(Result));
  finally
    AutoReleasePool.release;
  end;
end;
  • After ensuring OS X is the target platform, re-run the application: the ‘Copy’ menu item should now disable itself as appropriate.

The background to this code is that every top level menu item will be a NSMenu, and likewise every originating item in a multi-level menu setup, e.g. the ‘Recent’ item under ‘File’ that pops out a list of the recently-opened files. Any NSMenu can then be assigned a delegate object to handle certain events, one of which – menuNeedsUpdate – is called to allow you to update sub-items’ state as necessary. In our case, we have the delegate be NSMenu (or rather, a subclass of NSMenu) itself. In this capacity, the class then handles the menuNeedsUpdate message by asking sub-items’ actions to update themselves.

Now, there is a slight flaw in this fix, and in particular the TFMXOSMenu class, since in short, its destructor will never get called. This is an occupational hazzard of the fact that when you define an Objective-C class (as we do here) using the Delphi to Objective-C bridge, the reference counting of the core Objective-C/Cocoa part of instantiated objects and the reference counting of the Delphi part are completely separate from one another. Alas, but the TFMXOSMenuItem class of the original unit suffers from this same flaw. However, since the menu bar isn’t something you should be recreating at will – rather, it should be created at start up and left there until the application terminates – this isn’t a big deal in practice given OS X, like Windows, will reclaim all allocated resources when the process terminates anyhow.

Problem: TPopupMenu in FMX has no OnPopup event

With the VCL TPopupMenu, I frequently make use of its OnPopup event. Unfortunately no such event exists in the FMX equivalent. Nonetheless, an interposer class can fix that easily enough:

type
  TPopupMenu = class(FMX.Menus.TPopupMenu)
  strict private
    FOnPopup: TProc<TObject>; //or TNotifyEvent
  public
    procedure Popup(X: Single; Y: Single); override;
    property OnPopup: TProc<TObject> read FOnPopup write FOnPopup;
  end;

procedure TPopupMenu.Popup(X, Y: Single);
begin
  if Assigned(FOnPopup) then
    FOnPopup(Self);
  inherited;
end;

When the interposer class is in scope, you can now assign an OnPopup handler at runtime.

Problem: TPopupMenu forgets the order of hidden items

Say you have a popup menu whose items you conditionally hide (i.e., set their Visible properties to False) at various times. In such a situation, after every time the menu is shown, it will have pushed the hidden items to the top of the list, so that when you unhide them later, they will appear in a different position to what their were originally.

The underlying cause is that when a TPopupMenu is shown, it reassigns visible items’ Parent property to the control that will actually be ‘poping up’, before reassinging the properties back to itself once the popup closes. My preferred workaround is once more an interposer class, adding code to explicitly maintain the items’ order. Continuing with the previous example, the overridden Popup method should be rewritten to look something like this:

//TPopupMenu declaration as before...

type
  THiddenRec = record
    Item: TControl;
    Index: Integer;
  end;

procedure TPopupMenu.Popup(X, Y: Single);
var
  HiddenList: TList<THiddenRec>;
  Rec: THiddenRec;
  I: Integer;
begin
  if Assigned(FOnPopup) then
    FOnPopup(Self);
  //fix stock code not maintaining order of hidden items
  HiddenList := nil;
  try
    for I := ItemsCount - 1 downto 0 do
    begin
      Rec.Item := Items[I];
      if not Rec.Item.Visible then
      begin
        Rec.Index := Rec.Item.Index;
        if HiddenList = nil then
          HiddenList := TList<THiddenRec>.Create;
        HiddenList.Add(Rec);
        Rec.Item.Parent := nil;
      end;
    end;
    inherited;
    if HiddenList <> nil then
      for I := HiddenList.Count - 1 downto 0 do
      begin
        Rec := HiddenList[I];
        InsertObject(Rec.Index, Rec.Item);
      end;
  finally
    HiddenList.Free;
  end;
end;

That’s it for now. If nothing else, my patience is getting rather frayed…

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