Cocoa File system Browser

This post details how to implement a file system browser with file images in Cocoa, bypassing some problems of a first intuitive implementation derived according to the NSBrowser and NSBrowserCell class documentation.

The purpose

Navigating the file system to pick files and locations is a basic task that is useful in many applications. Unfortunately when developing for MacOS there isn’t a prebuilt widget that can be dropped into the user interface, but some work has to be done in order to achieve the following interface.

NSBrowser displaying filesystem content

This post is a possible approach to implement a file browsing widget with icons for folders and files.

NSBrowser

This navigator is based on a single Cocoa view that is called NSBrowser. This is a generic component that can be used to display hierarchically ordered data into several columns.

In order to create the interface drag an instance of NSBrowser into the window and configure the proper outlet in the view controller that manages it, then set the controller as the delegate of the NSBrowser into inteface builder and start implementing the NSBrowserDelegate protocol inside the delegate controller in order to provide the data to display.

I’ve called my controller class FileSelectionController, and I’ve added a variable for the default file manager that will be used later to retrieve the names from the file system.

class FileSelectionController: NSViewController{
    @IBOutlet weak var theFileBrowser: NSBrowser!
    let manager = FileManager.default
}

Extend URL for getting information

The representation of files will be supported by the URL class. In order to easily work with the filesystem it is convenient to add the following extensions that add some properties and functions to the class, including the image associated to each resource.

extension URL {

    func isDirectory() -> Bool {
        return (try? resourceValues(
        		forKeys: [.isDirectoryKey]
        	))?.isDirectory ?? false
    }

    func fileExists() -> Bool {
        let fileman = FileManager.default
        return fileman.fileExists(atPath: self.path)
    }

    var fileIcon : NSImage {
        return (try? resourceValues(
        		forKeys: [.effectiveIconKey]
        	))?.effectiveIcon as? NSImage ?? NSImage()
    }
}

Implement delegate methods

First we can set the root object for the browser, pointing to the root directory of the filesystem.

extension FileSelectionController : NSBrowserDelegate {

    // MARK: NSBrowserDelegate
    func rootItem(for browser: NSBrowser) -> Any? {
        let url = URL(fileURLWithPath: NSOpenStepRootDirectory())
        return url
    }

    //...
}

Next we must return the number of children for a particular item in the tree, this is easily achieved invoking the default filesystem manager, passing the count of the content of the directory represented by the node that is being examined.

Note that the node has to be cast to an URL, as the NSBrowserDelegate protocol is generic and passes the item as a generic pointer of type Any?.

func browser(_ browser: NSBrowser, numberOfChildrenOfItem item: Any?) -> Int {
    if let url = item as? URL {
        do {
            let urls = try manager.contentsOfDirectory(
                at: url,
                includingPropertiesForKeys: nil,
                options: FileManager.DirectoryEnumerationOptions.skipsHiddenFiles)
            return urls.count
        }
        catch let error as NSError{
            print (error.localizedDescription)
        }
    }
    return 0
}

Next, using essentially the same code that fetches the contents of a directory we return specific children of a node from the list of files that are contained by the item.

    
func browser(_ browser: NSBrowser, child index: Int, ofItem item: Any?) -> Any {
    if let url = item as? URL {
        do {
            let urls = try manager.contentsOfDirectory(
                at: url,
                includingPropertiesForKeys: nil,
                options: FileManager.DirectoryEnumerationOptions.skipsHiddenFiles)
            return urls[index]
        }
        catch let error as NSError{
            print (error.localizedDescription)
        }
    }
    return URL(fileURLWithPath: "") //fallback
}

A more efficient approach to the above two methods would be caching the results of the last query to the filesystem in order to return the same results without interrogating the file manager every time a child node needs to be populated and the count of children be computed, provided that the manager itself is not already caching the data.

Finally, the last two delegate methods simply advise the browser if a node has to be displayed as a leaf node (i.e. wihout children) and what is the value to display a specific item. In order to return if a node is a leaf node we return whether the file is not a directory, using the URL extension created before to easily get this property, and to get the display value we return the last path component of the URL, which is the filename.

    
func browser(_ browser: NSBrowser, isLeafItem item: Any?) -> Bool {
    if let url = item as? URL{
        if url.fileExists() {
            return !url.isDirectory()
        }
    }
    return true
}


func browser(_ browser: NSBrowser, objectValueForItem item: Any?) -> Any? {
    if let u = item as? URL {
        return u.lastPathComponent
    }
    return "ERR"
}

With all this the NSBrowser is able to display the filenames and the hierarchy of the filesystem, and the user is able to navigate across folders, manage selections and explore the disc, but the display is still rather dull, not having the file icon next to each name.

NSBrowser displaying just file names

Adding icons, first attempt

The documentation of NSBrowser asserts that it is using the NSBrowserCell class to represent each node, and if you look at the cell class api, you find that this cell can be configured to have both a text and an image.

In order to provide the image we then use the willDisplayCell delegate method, that is called before the display of a specific cell with the cell instance and position of the corresponding item.

The intuitive approach is then to get the item instance interrogating the browser itself, get its image via the URL extension that was described before and set it as the image property of the cell.

func browser(_ sender: NSBrowser, willDisplayCell cell: Any, atRow row: Int, column: Int) {
    if let u = sender.item(atRow: row, inColumn: column) as? URL,
        let theCell = cell as? NSBrowserCell {
        theCell.image = u.fileIcon
    }
}

Unfortunately all this does not work: the delegate method fails at casting the cell instance as a NSBrowserCell, asserting that it has been passed a NSTextFieldCell instance instead. This is weird, because invoking cellClass() on it returns NSBrowserCell indeed, and explicitly setting the cell class to be used via the setCellClass() method has no effect to solve the problem.

Adding icons, working solution

What I have found out to be working instead is to subclass NSBrowserCell and to setup this new dummy class as the cell that the browser is using to render content.

The new cell class simply overrides the initializers without adding new functionality.

class FileCell: NSBrowserCell {
    override init(imageCell i: NSImage?) {
        super.init(imageCell: i)
    }

    override init(textCell s: String) {
        super.init(textCell: s)
    }

    required init(coder c: NSCoder) {
        super.init(coder: c)
    }
}

In order to setup the NSBrowser to use this new class, the rootItem delegate method has to setup the cell type by calling setCellClass on the browser instance.

This configuration step may not work inside the viewDidLoad method as it is too early in the interface creation process and the cell type could be setup back to default. The rootItem method instead is late enough in the NSBrowser initialization routine and it is called just once, so no overhead is unnecessarily added.

The willDisplayCell delegate method shall also be adapted to take into account the cast to the new class:

func rootItem(for browser: NSBrowser) -> Any? {

    browser.setCellClass(FileCell.self)

    let url = URL(fileURLWithPath: NSOpenStepRootDirectory())
    return url
}

//....

func browser(_ sender: NSBrowser, willDisplayCell cell: Any, atRow row: Int, column: Int) {
    if let u = sender.item(atRow: row, inColumn: column) as? URL,
        let theCell = cell as? FileCell {
        theCell.image = u.fileIcon
    }
}

Now images can be displayed by the NSBrowser using the new FileCell.

First images can be finally displayed

Fixing icon size and file arrow

Of course a couple problems are now to be fixed in this new version: first the icon size is easily put to the required 16x16 dimension by adding to the URL extensions the capability to return the correct size, and adapting the willDisplayCell delegate method to use this new URL property instead of fileIcon:

extension URL {

	//.. other extension properties and functions

    var smallFileIcon: NSImage{
        let icon : NSImage = fileIcon
        icon.size = NSMakeSize(16.0, 16.0)
        return icon
    }
}

And lastly the double arrow can be removed by setting up the cell having the isLeaf property set to true, so that the inner cell representation does not draw the extra symbol indicating that the cell has children to browse. This could be done in the willDisplayCell method itself, or better inside each of the custom FileCell class initializers, resulting in the final file browser!

Final display

Conclusion

It is still a mystery why the standard NSBrowserCell cannot be used by itself and needs subclassing to yield picture drawing, but at least with a quick subclass a good representation can be achieved anyways.

With respect to the Finder program the custom cells implemented in this way have still a too small padding for the icons, this perhaps can be fixed in the subclass by further customizing its behavior, but for now this display is good enough for the purposes of file selection.

Download

A working project with the code of this article is available for download here.

Comments