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.
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.
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:
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()
}
})
}
}
The following example uses:
NSMenu
's private API _setMenuName:
to set the name of the menu so it is localized, andNSDocumentController
'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.
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:
NSDocumentController
to query the recent files.
b) Uses NSWorkspace
to get the icon for the file.buildMenu
method calls the bundle, gets the files/icons and creates the menu items manually.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
}