Uimarkuptextprintformatter and MAC Catalyst

How to add recent files with Mac-Catalyst

In macOS Big Sur, UIDocument.open() automatically adds opened files to the "Open Recents" menu. However, menu items do not have a file icon (they do in AppKit!).
You can check Apple's sample Building a Document Browser-Based App for an example that uses UIDocumentBrowserViewController and UIDocument.

Getting the real thing is quite a bit more complicated, and involves calling Objective-C methods. I am aware of two methods to populate the "Open Recent" menu—manually using UIKit+AppKit, or "automatically" using private AppKit APIs. The latter should also work in earlier versions of Mac Catalyst (prior to Big Sur), but is more buggy in UIKit.

Since you cannot use AppKit in a Mac Catalyst app directly, there are two options:

  1. Create an app bundle that uses Swift or Objective-C to bridge to AppKit, and load the bundle from the app.
  2. Call the AppKit APIs from the UIKit-based app using strings. I am using the Dynamic package for that.

Populate the "Open Recents" Menu Manually

Example shown below calling AppKit from Mac Catalyst.

class AppDelegate: UIResponder, UIApplicationDelegate {
override func buildMenu(with builder: UIMenuBuilder) {
guard builder.system == .main else { return }

var recentFiles: [UICommand] = []
if let recentFileURLs = ObjC.NSDocumentController.sharedDocumentController.recentDocumentURLs.asArray {
for i in 0..<(recentFileURLs.count) {
guard let recentURL = recentFileURLs.object(at: i) as? NSURL else { continue }
guard let nsImage = ObjC.NSWorkspace.sharedWorkspace.iconForFile(recentURL.path).asObject else { continue }
guard let imageData = ObjC(nsImage).TIFFRepresentation.asObject as? Data else { continue }
let image = UIImage(data: imageData)?.resized(fittingHeight: 16)
guard let basename = recentURL.lastPathComponent else { continue }
let item = UICommand(title: basename,
image: image,
action: #selector(openDocument(_:)),
propertyList: recentURL.absoluteString)
recentFiles.append(item)
}
}

let clearRecents = UICommand(title: "Clear Menu", action: #selector(clearRecents(_:)))
if recentFiles.isEmpty {
clearRecents.attributes = [.disabled]
}
let clearRecentsMenu = UIMenu(title: "", options: .displayInline, children: [clearRecents])

let recentMenu = UIMenu(title: "Open Recent",
identifier: nil,
options: [],
children: recentFiles + [clearRecentsMenu])
builder.remove(menu: .openRecent)

let open = UIKeyCommand(title: "Open...",
action: #selector(openDocument(_:)),
input: "O",
modifierFlags: .command)
let openMenu = UIMenu(title: "",
identifier: nil,
options: .displayInline,
children: [open, recentMenu])
builder.insertSibling(openMenu, afterMenu: .newScene)
}

@objc func openDocument(_ sender: Any) {
guard let command = sender as? UICommand else { return }
guard let urlString = command.propertyList as? String else { return }
guard let url = URL(string: urlString) else { return }
NSLog("Open document \(url)")
}

@objc func clearRecents(_ sender: Any) {
ObjC.NSDocumentController.sharedDocumentController.clearRecentDocuments(self)
UIMenuSystem.main.setNeedsRebuild()
}

The menu won't refresh automatically. You have to trigger a rebuild by calling UIMenuSystem.main.setNeedsRebuild(). You have to do that whenever you open a document, e.g. in the block provided to UIDocument.open(), or save a document. Below is an example:

class MyViewController: UIViewController {
var document: UIDocument? // set by the parent view controller
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

// Access the document
document?.open(completionHandler: { (success) in
if success {
// Display the document
} else {
// Report error
}

// 500 ms is probably too long
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UIMenuSystem.main.setNeedsRebuild()
}
})
}
}

Populate the Menu Automatically (AppKit)

The following example uses:

  1. NSMenu's private API _setMenuName: to set the name of the menu so it is localized, and
  2. NSDocumentController's _installOpenRecentMenus to install the "Open Recent" menu.
- (void)setupRecentMenu {
NSMenuItem *clearMenuItem = [self _findMenuItemWithName:@"Open Recent" in:NSApp.mainMenu.itemArray];
if (!clearMenuItem) {
NSLog(@"Warning: 'Open Recent' menu not found");
return;
}
NSMenu *openRecentMenu = [[NSMenu alloc] initWithTitle:@"Open Recent"];
[openRecentMenu performSelector:NSSelectorFromString(@"_setMenuName:") withObject:@"NSRecentDocumentsMenu"];
clearMenuItem.submenu = openRecentMenu;

[NSDocumentController.sharedDocumentController valueForKey:@"_installOpenRecentMenus"];
}

- (NSMenuItem * _Nullable)_findMenuItemWithName:(NSString * _Nonnull)name in:(NSArray<NSMenuItem *> * _Nonnull)array {
for (NSMenuItem *item in array) {
if ([item.title isEqualToString:name]) {
return item;
}
if (item.hasSubmenu) {
NSMenuItem *subitem = [self _findMenuItemWithName:name in:item.submenu.itemArray];
if (subitem) {
return subitem;
}
}
}
return nil;
}

Call this in your buildMenu(with:) method:

class AppDelegate: UIResponder, UIApplicationDelegate {
override func buildMenu(with builder: UIMenuBuilder) {
guard builder.system == .main else { return }

let open = UIKeyCommand(title: "Open...",
action: #selector(openDocument(_:)),
input: "O",
modifierFlags: .command)
let recentMenu = UIMenu(title: "Open Recent",
identifier: nil,
options: [],
children: [])
let openMenu = UIMenu(title: "",
identifier: nil,
options: .displayInline,
children: [open, recentMenu])
builder.remove(menu: .openRecent)
builder.insertSibling(openMenu, afterMenu: .newScene)

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.myObjcBridge?.setupRecentMenu()
}
}

However, I am seeing some issues with this method. The icons seems to be off (they're bigger), and the "Clear Menu" command is not disabled after it's used for the first time. Rebuilding the menu fixes the issue.

Update 30 Dec. 2020

macCatalyst 14 (Big Sur) does install the "Open Recent" menu, but the menu doesn't have icons.

Using the Dynamic package turned out to be noticabaly slow. I implemented the same logic in Objective-C as per Peter Steinberg's talk. While this worked, I noticed the icons were too big, and I couldn't find a way to fix that.

Furthermore, using AppKit's private APIs, the "Open Recent" string doesn't get automatically localized (but the "Clear Menu" does!).

My current approach is:

  1. Use an app bundle (in Objective-C) that
    a) Uses NSDocumentController to query the recent files.
    b) Uses NSWorkspace to get the icon for the file.
  2. The buildMenu method calls the bundle, gets the files/icons and creates the menu items manually.
  3. The app bundle loads the NSImageNameMenuOnStateTemplate system image and provides this size to the macCatalyst app so it can rescale the icons.

Note that I haven't implemented the logic for secure bookmarks (not familiar with this, need to investigate further). Peter talks about this.

Obviously, I will need to provide translations for the strings myself. But that's OK.

Here's the relevant code from the app bundle:


@interface RecentFile: NSObject<RecentFile>
- (instancetype)initWithURL: (NSURL * _Nonnull)url icon:(NSImage *)image;
@end

@implementation AppKitBridge
@synthesize recentFiles;
@synthesize menuIconSize;
@end

- (instancetype)init {
// ...
NSImage *templateImage = [NSImage imageNamed:NSImageNameMenuOnStateTemplate];
self->menuIconSize = templateImage.size;
}

- (NSArray<NSObject<RecentFile> *> *)recentFiles {
NSArray<NSURL *> *recents = [[NSDocumentController sharedDocumentController] recentDocumentURLs];
NSMutableArray<SGRecentFile *> *result = [[NSMutableArray alloc] init];
for (NSURL *url in recents) {
if (!url.isFileURL) {
NSLog(@"Warning: url '%@' is not a file URL", url);
continue;
}
NSImage *icon = [[NSWorkspace sharedWorkspace] iconForFile:[url path]];
RecentFile *f = [[RecentFile alloc] initWithURL:url icon:icon];
[result addObject:f];
}
return result;
}

- (void)clearRecentFiles {
[NSDocumentController.sharedDocumentController clearRecentDocuments:self];
}

Then populate the UIMenu from the macCatalyst code:

@available(macCatalyst 13.0, *)
func createRecentsMenuCatalyst(openDocumentAction: Selector, clearRecentsAction: Selector) -> UIMenuElement {
var commands: [UICommand] = []
if let recentFiles = appKitBridge?.recentFiles {
for rf in recentFiles {
var image: UIImage? = nil
if let cgImage = rf.image {
image = UIImage(cgImage: cgImage).scaled(toHeight: menuIconSize.height)
}
let cmd = UICommand(title: rf.url.lastPathComponent,
image: image,
action: openDocumentAction,
propertyList: rf.url.absoluteString)
commands.append(cmd)
}
}
let clearRecents = UICommand(title: "Clear Menu", action: clearRecentsAction)
if commands.isEmpty {
clearRecents.attributes = [.disabled]
}
let clearRecentsMenu = UIMenu(title: "", options: .displayInline, children: [clearRecents])

let menu = UIMenu(title: "Open Recent",
identifier: UIMenu.Identifier("open-recent"),
options: [],
children: commands + [clearRecentsMenu])
return menu
}

Sources

  • Peter Steinberg's talk Shipping a Mac Catalyst app: The good, the bad and the ugly
  • Appe's Accessing Actions Using Menu Elements
  • stuffmc's AppKitified GitHub repo that shows how to create and load bundles.

WKWebView content is too small when running as a Catalyst app on Mac

Are you using the Mac or iPad idiom for your Catalyst app? The “iPad” idiom, which is the default (and only option for apps that support Catalina), will downscale your entire app to 77% it’s original size. Read more here: https://developer.apple.com/documentation/uikit/mac_catalyst/choosing_a_user_interface_idiom_for_your_mac_app

My guess is that your app is being downscaled, including the web content. You could fix that by switching to the Mac idiom. If that’s not an option for whatever reason, maybe you could try scaling the web content back up with CSS?



Related Topics



Leave a reply



Submit