Add an Open Recent Menu to a SwiftUI app

Why would you need to add an Open Recent menu? The most common reason is you created an app that doesn’t use the document architecture, and you want a way for your app’s users to access recent items.

Adding an Open Recent menu requires you to do the following things:

Using NSDocumentController to track recent items

The name NSDocumentController implies that it works with NSDocument. However, the parts of NSDocumentController that deal with the Open Recent menu require only an array of URLs. You can use NSDocumentController without creating a document app.

Use the shared instance to access the standard document controller.

NSDocumentController.shared

Use the recentDocumentURLs property to access the URLs of the recent items and fill the Open Recents menu. Call the noteNewRecentDocumentURL function to update the recent items after opening or creating an item. Call the clearRecentDocuments function to clear the recent items list and clear the Open Recent menu.

Initial version of the Open Recent menu

An initial version of an Open Recent menu looks like the following code:

struct OpenRecentMenu: View {
  @Environment(\.openWindow) private var openWindow
    
  var body: some View {
    Menu("Open Recent") {
      ForEach(NSDocumentController.shared.
        recentDocumentURLs, id: \.self) { url in
        
        Button(url.lastPathComponent) {
          // Item is a class in your app.
          let itemToOpen = Item(location: url)
          openWindow(value: itemToOpen)
          NSDocumentController.shared.
            noteNewRecentDocumentURL(url)
        }
      }
            
      Divider()
        Button("Clear Menu") {
          NSDocumentController.shared.
            clearRecentDocuments(nil)
        }
    }
  }
}

The code creates the Open Recent menu and fills it with recent items. There is one big problem. The Open Recent menu doesn’t update until you restart the app.

The reason the menu doesn’t update is SwiftUI doesn’t automatically update the menu when the shared document controller’s array of recent items changes.

To get the menu to update, you must add an @Observable class that holds the recent item URLs and pass that class to the menu view.

Create the @Observable class

The class has two requirements. First, it needs a property that holds the URLs of the recent items. Second, it needs a function to update the array from the shared document controller.

@Observable
class RecentItems {
  @MainActor static let shared = RecentItems()
  var items: [URL] = []
  
  init(items: [URL]) {
    self.items = items
  }
    
  @MainActor func refresh() {
    let docControllerRecents = NSDocumentController.
      shared.recentDocumentURLs
    if docControllerRecents != items {
      items = docControllerRecents
    }
  }
}

Supply the value NSDocumentController.shared.recentDocumentURLs to the RecentItems initializer to fill the Open Recent menu when your app launches.

When you open or add an item in your app, call the refresh function to update the Open Recent menu.

Add the @Observable class to the Menu

To use your @Observable class in the menu, you must add a @State property for the class to the App struct. Pass the value to the menu using a binding. Use the array in your @Observable class to fill the Open Recent menu.

The following code shows the updated Open Recent menu:

struct OpenRecentMenu: View {
  @Environment(\.openWindow) private var openWindow
  @Binding var recents: RecentItems
    
  var body: some View {
    Menu("Open Recent") {
      ForEach(recents.items, id: \.self) { url in
        Button(url.lastPathComponent) {
          let item = Item(location: url)
          openWindow(value: item)
          NSDocumentController.shared.
            noteNewRecentDocumentURL(url)
          recents.refresh()
        }
      }
            
      Divider()
        Button("Clear Menu") {
          NSDocumentController.shared.
            clearRecentDocuments(nil)
          recents.refresh()
        }
      }
  }
}

Get the Swift Dev Journal Newsletter

Subscribe and get exclusive articles, a free guide on moving from tutorials to making your first app, notices of sales on books, and anything I decide to add in the future.

    We won't send you spam. Unsubscribe at any time.