swiftmac-catalyst

How to add recent files with Mac-Catalyst


In default "File" menu, it has "Open Recent >" Menu Item and it is added automatically. Currently, If user open associated file from Finder, This recent items are added automatically (on Big Sur). But If user open from my App using UIDocumentPickerViewController, it doesn't add recent menuitem.

enter image description here

I want to add this menu item under "Open Recent >" and clear items from my code. Is there any help document or sample code? Thank you.


Solution

  • 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