Providing Access to Directories in iOS with Bookmarks

August 21, 2021

In iOS 13 and later, apps can access directories that are outside of the app’s sandbox. And the app can save a bookmark to that location. In this project we’re going to allow the user to select multiple folders and save them using bookmarks.

Start the Project

Make a new project. I’ll call mine called “BookmarkDirectories”.

new project

new project 2

In ContentView.swift add some UI to show a list of folders (or directories) and a button to open the document picker so the user can add a new folder to the list. For now, I’ll add some dummy data so we can get an idea of what it will look like. And I’ll add some placeholder text in the sheet.

import SwiftUI

struct ContentView: View {
    @State var showFilePicker = false

    var body: some View {
        NavigationView {
            List {
                Section {
                    // TODO: replace this with the list of directories from the bookmarks
                    Text("Folder 1")
                    Text("Folder 2")
                }
            
                Button {
                    showFilePicker = true
                } label: {
                    Label("Add Folder", systemImage: "plus")
                }
                .sheet(isPresented: $showFilePicker) {
                    // TODO: show a document picker here
                    Text("Show document picker here")
                }
            }
            .navigationTitle("Folders")
            .listStyle(InsetGroupedListStyle())
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

It should look like this and open a sheet when you tap the button:

start 1

start 2

Show the Document Picker

Apple has a documentation page called Providing Access to Directories, and it says the framework is UIKit. This is important because there are some things that SwiftUI can’t do yet. And according to a video I found on Hacking With Swift called Wrapping a UIViewController in a SwiftUI view, “…you need to learn to talk to UIKit if you want to add more advanced functionality.” The whole video/article is great and worth the watch/read.

He explains that to wrap a UIKit ViewController we have to create a struct that conforms to the UIViewControllerRepresentable protocol.

On the UIViewControllerRepresentable page, it says “Use a UIViewControllerRepresentable instance to create and manage a UIViewController object in your SwiftUI interface.”

So let’s do that. Create a new Swift file called DocumentPicker.swift and make a struct named DocumentPicker that conforms to UIViewControllerRepresentable. You also have to import SwiftUI.

import SwiftUI

struct DocumentPicker: UIViewControllerRepresentable {
}

Xcode will say that it doesn’t conform. Take its suggestion and let it add the protocol stubs. It will add a line that says typealias UIViewControllerType = type. Change the type to UIDocumentPickerViewController. Then Xcode will still say that it doesn’t conform. Take its suggestion again to add protocol stubs. This time, it will add two methods, makeUIViewController, and updateUIViewController. Notice that the return type of makeUIViewController is UIDocumentPickerViewController. Now, you can delete the typealias line because Swift can figure out the type from the return type of the function.

func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
    code
}
    
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {
    code
}

Delete the “code” placeholder from updateUIViewController because, although we have to have this function to conform, we don’t need to do anything with it. Inside makeUIViewController, we need to add some code to return a UIDocumentPickerViewController.

On the Providing Access to Directories article it has this code to ask the user to select a directory.

// Create a document picker for directories.
let documentPicker =
   UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
documentPicker.delegate = self

// Set the initial directory.
documentPicker.directoryURL = startingDirectory

// Present the document picker.
present(documentPicker, animated: true, completion: nil)

But all we need to do is make the documentPicker and return it.

func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
    let documentPicker =
        UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
    return documentPicker
}

Now that we have a DocumentPicker struct, which is a valid SwiftUI view, you can use it in ContentView.swift. Replace the placeholder text in the sheet with DocumentPicker()

When you run the app and tap on the button, it should open the document picker, but when you select a folder, it does nothing except dismiss the sheet. This is because we aren’t receiving any information about which document the user selected. To do this, we need to use a Coordinator.

Use a Coordinator to get the selected folder

Back on the UIViewControllerRepresentable page, it says “When you want your view controller to coordinate with other SwiftUI views, you must provide a Coordinator instance to facilitate those interactions.” And under Topics, there is a section called “Providing a Custom Coordinator Object” that mentions a function called makeCoordinator()

Add a makeCoordinator method to the DocumentPicker struct that returns a Coordinator, add a nested Coordinator class, and inside makeUIViewController, assign the coordinator to the documentPicker.delegate.

import SwiftUI

struct DocumentPicker: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let documentPicker =
            UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
        documentPicker.delegate = context.coordinator
        return documentPicker
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {

    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject, UIDocumentPickerDelegate {
        
    }
}

Since we are assigning an instance of the Coordinator class to the documentPicker delegate, it must conform to UIDocumentPickerDelegate, and because of that, it must first conform to NSObject.

Now we need to make the coordinator actually do something when the user picks a document. On the UIDocumentPickerDelegate page, there is a function called documentPicker(UIDocumentPickerViewController, didPickDocumentsAt: [URL]). There is also a deprecated function that returns just one URL instead of an array. Don’t accidentally use that one. The easiest way to add this to your code is start typing didPick, then select the correct one. For now, add a print statement:

class Coordinator: NSObject, UIDocumentPickerDelegate {
    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
	    // TODO: save this url to a bookmark
        print(urls[0])
    }
}

When you run the app and select a folder, it will print out a URL to the console in Xcode. For example, when I select a folder called “Inbox” in iCloud Drive it looks like this:

file:///private/var/mobile/Library/Mobile%20Documents/com~apple~CloudDocs/Inbox/

Next, we need to save this URL to a bookmark instead of printing it to the console.

Save Directory as a Bookmark

What is a bookmark? According to Apple’s documentation on Bookmarks, it’s a piece of data that you can convert a security-scoped URL into. You can then convert a bookmark back into a security-scoped URL later. Why not just store the plain URL? Well, a bookmark lets you find the current location of the URL even if the user moves or renames the resource, or if the user relaunches your app or restarts the system.

Let’s make a new Swift file called BookmarkController.swift. Inside of this file, make a class that conforms to ObservableObject so that it can publish an array of URLs for the SwiftUI view to use. We need a property called urls that will contain an array of URLs. We will also need a method called addBookmark.

import SwiftUI

class BookmarkController: ObservableObject {
    @Published var urls: [URL] = []	
    
    func addBookmark(for url: URL) {
        // Generate a UUID
        // Convert URL to bookmark
        // Save the bookmark into a file (the name of the file is the UUID)
        // Add the URL to the urls array
    }
}

To add this to the environment, go back to BookmarkDirectoriesApp.swift and add an instance of this class to the environment:

import SwiftUI

@main
struct BookmarkDirectoriesApp: App {
    @StateObject var bookmarkController = BookmarkController()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(bookmarkController)
        }
    }
}

Most of the code we need for the addBookmark function is on the Providing Access to Directories page under “Save the URL as a Bookmark”, but we need to add a few other things like creating a UUID for the file name, getting the location to save the bookmark, and adding the URL to the urls array. When you append the URL to the array, you can wrap that in a withAnimation block so that it will animate into the view. We have to import SwiftUI because of the withAnimation line. The function should look like this now:

    func addBookmark(for url: URL) {
        do {
            // Start accessing a security-scoped resource.
            guard url.startAccessingSecurityScopedResource() else {
                // Handle the failure here.
                return
            }
            
            // Make sure you release the security-scoped resource when you finish.
            defer { url.stopAccessingSecurityScopedResource() }
            
            // Generate a UUID
            let uuid = UUID().uuidString
            
            // Convert URL to bookmark
            let bookmarkData = try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil)
            // Save the bookmark into a file (the name of the file is the UUID)
            try bookmarkData.write(to: getAppSandboxDirectory().appendingPathComponent(uuid))
            
            // Add the URL and UUID to the urls array
            withAnimation {
                urls.append(url)
            }
        }
        catch {
            // Handle the error here.
            print("Error creating the bookmark")
        }
    }

I also added a private helper method to get the app’s sandbox directory, which is where we store the bookmark files.

private func getAppSandboxDirectory() -> URL {
    FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}

Now in the DocumentPicker.swift it can use the BookmarkController to save the directory that the user picked. First, add an @EnvironmentObject at the top of the struct. Since the nested coordinator class doesn’t have access to the bookmarkController’s properties we need to give it access. To give it access, make an initializer for the coordinator that takes a DocumentPicker as an argument and pass in self when making the Coordinator. Then, instead of printing the URL, we can call parent.bookmarkController.addBookmark(for: urls[0]) .

import SwiftUI

struct DocumentPicker: UIViewControllerRepresentable {
    @EnvironmentObject private var bookmarkController: BookmarkController
    
    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
        let documentPicker =
            UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
        documentPicker.delegate = context.coordinator
        return documentPicker
    }

    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {

    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIDocumentPickerDelegate {
        var parent: DocumentPicker
        
        init(_ parent: DocumentPicker) {
            self.parent = parent
        }
		
        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            parent.bookmarkController.addBookmark(for: urls[0])
            
        }
    }
}

Now when you run the app and select a folder, if you look in the app’s sandbox directory (using an app like OpenSim makes this easy), you’ll see that it create a file.

bookmark file

You can even open it with a text editor if you want to see the contents of the file. It will have some text that mostly looks like gibberish, but you’ll see the URL in there if you look closely.

book0$Usersadamgarrett-harrisLibrary	Developer
CoreSimulatorDevices$8FEA2CC9-1C52-4C04-87EF-34E45605C2BFdata
ContainersSharedAppGroup$5042B85C-3783-4F02-B625-3A71D5485089File Provider StorageTEst8 <L`xॿ‘‰Ù @8S˜x˛xk∆l∆m∆QôcRôcÊI´J´ÂK´fiX´∂d´÷ƒÄ8åú¨ºÃ‹Ï¸,<L\A√`‡]fr≠	file:///Macintosh HD - Data† htA¡fiDÄ$635E43C1-455E-453C-8A79-02CE65BDEB55ÖÔ/NSURLDocumentIdentifierKey¸mcfp:/biZXiDYzMIZAUlsolN9EQazrsK9kg4_kfBllhQLgvJM=/com.apple.FileProvider.LocalStorage//fid=209765590¿˛ˇˇˇLlº@¨ t ‹ Ï (    T0 Äp ∏–àĨ

Retrieve Bookmarks

To show the bookmarks to the user, access the bookmarkController as an environment variable. Add @EnvironmentObject var bookmarkController: BookmarkController to the top of the ContentView struct and pass a new BookmarkController to the ContentView in the preview:

ContentView()
    .environmentObject(BookmarkController())

Then use a ForEach to show each url in the list:

ForEach(bookmarkController.urls, id: \.self) { url in
    Text(url.lastPathComponent)
}

When you run the app and add a folder, it will show up in the list, but when you restart the app it won’t be there. That’s because we’re never loading up the existing bookmarks when the app starts. Another problem is that our preview in Xcode now shows zero folders, and I want to be able to preview what it will look like in the app. Let’s fix both of those problems.

To load up the existing bookmarks into the urls array, we need to write a loadAllBookarks function in our BookmarkController. It needs to get all the bookmark files from our app’s sandbox directory, loop over all of them using compactMap, and for each of them, add the URL into the urls array.

func loadAllBookmarks() {
    // Get all the bookmark files
    // Map over the bookmark files
        // Get the URL from each bookmark
        // Return URL
}

On the Providing Access to Directories page, it has a code snippet for getting the URL from a bookmark:

do {
    let bookmarkData = try Data(contentsOf: getMyURLForBookmark())
    var isStale = false
    let url = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale)
    
    guard !isStale else {
        // Handle stale data here.
        return
    }
    
    // Use the URL here.
}
catch let error {
    // Handle the error here.
}

We can use this code inside a compactMap():

func loadAllBookmarks() {
    // Get all the bookmark files
    let files = try? FileManager.default.contentsOfDirectory(at: getAppSandboxDirectory(), includingPropertiesForKeys: nil)
    // Map over the bookmark files
    self.urls = files?.compactMap { file in
        do {
            let bookmarkData = try Data(contentsOf: file)
            var isStale = false
            // Get the URL from each bookmark
            let url = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &isStale)
            
            guard !isStale else {
                // Handle stale data here.
                return nil
            }
            
            // Return URL
            return url
        }
        catch let error {
            // Handle the error here.
            print(error)
            return nil
        }
    } ?? []
}

To call this function when the app starts, let’s call it in the BookmarkController’s initializer.

init() {
    loadAllBookmarks()
}

That works great, but it doesn’t help with our preview. We can add a parameter with a default value to optionally load some dummy data for the preview:

init(loadFakeData: Bool = false) {
    if loadFakeData {
        urls = [
            URL(string: "some/path/Notes")!,
            URL(string: "some/path/Family%20Notes")!,
        ]
    } else {
        loadAllBookmarks()
    }
}

Then in the ContentView preview code, you can have one preview with fake data and one without.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(BookmarkController(loadFakeData: true))
        
        ContentView()
            .environmentObject(BookmarkController())
    }
}

When you open the app, it will load up the existing bookmarks and you can add more. A big thanks to my friend Elaine who helped me figure all of this out. The final code for this step can be found on GitHub.

Next time, I’ll show you how to delete bookmarks and how to show their contents when you tap on one.


Profile picture

Written by Adam Garrett-Harris, a podcaster and software engineer in Utah. You should follow him on Twitter