Build a macOS app with SwiftUI

This post is about my first macOS app built with SwiftUI. I needed a utility to browse local images instead of using the Finder, I’m sure there are plenty of great alternatives out there but I thought I’d better make one to finally write a macOS app with SwiftUI. Last time I needed to make an app for the Mac I’m not sure Swift was released, let alone SwiftUI, and although AppKit isn’t bad you can’t easily share UI code with an iOS app.

The app

It is a simple image viewer. The left panels is used to display a list of thumbnails, once you select one you can see the picture on the right.
You can either open a directory via the menu command, or drag&drop a folder from Finder to the left pane. You can also have favourites.
As you can tell, the app is pretty simple, but I think it is a good starting point.
As usual, you can find everything on GitHub, so let’s get started!

Menu

This was the first thing I wanted to do, as it is something specific to macOS.
When you create a macOS app in Xcode, you have a menu and it turns out some of the commands do work fine out of the box. For example the Window menu lets you minimise the app, and it just works. The File menu can of course close your app, and can create a new window. In this particular example, I haven’t implemented multiple windows, if I leave the command to create a new window they all share the same model so if you open a folder, it opens in all windows. But you get the idea, basic stuff works without you having to write a single line of code.
I’m sure you want to add something custom though, the app has to do something, right? So let’s see how to add a couple of items in the menu.


@main
struct SimpleImageViewer: App {
    var coordinator:AppCoordinator
    var menuCommandsHandler:MenuCommandsHandler
    
    init() {
        coordinator = AppCoordinator()
        menuCommandsHandler = MenuCommandsHandler(coordinator: coordinator)
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView(coordinator: coordinator)
        }.commands {
            MenuCommands(commandsHandler: menuCommandsHandler)
        }
    }
}

This is the AppDelegate. You may want to refer to my previous article about writing SwiftUI only app if you want some background on @main. For the sake of this article, let’s just say this is your entry point. You Have a WindowGroup containing your main view ContentView and you can use .commands to provide some menu items.
Some examples show you the commands right here, but I think the app delegate should be as simple as possible, I don’t feel like putting too many UI related stuff here. That’s why I built a MenuCommands struct and just instantiate it here, you’ll find the menu items there.


struct MenuCommands: Commands {
    var commandsHandler:MenuCommandsHandler
    
    var body: some Commands {
        CommandGroup(replacing: CommandGroupPlacement.newItem) {
            // replace with nothing so we don't have to deal with multiple windows
        }
        CommandGroup(after: CommandGroupPlacement.newItem) {
            Button("Open...") {
                commandsHandler.openCommand()
            }
            Button("Show Favorites") {
                commandsHandler.showFavoritesCommand()
            }
        }
    }
}

This should look familiar to you, it is like I’m building a View, with the some keyword, but instead you read var body: some Commands.
If you wonder why, take a look at Scene and in particular to commands.


public func commands(@CommandsBuilder content: () -> Content) -> some Scene where Content : Commands

@_functionBuilder public struct CommandsBuilder {

    /// Builds an empty command set from an block containing no statements.
    public static func buildBlock() -> EmptyCommands

    /// Passes a single command group written as a child group through
    /// modified.
    public static func buildBlock(_ content: Content) -> Content where Content : Commands
}

You have to provide a @CommandsBuilder, and as you can see it is similar to ViewBuilder as it is a _functionBuilder but you have to provide Commands instead of View.
This is why you see some Commands instead of some View, like a normal SwiftUI view.


Sorry for the digression but I thought it was important to point it out.
You’re likely more interested in how to put a particular menu command, so let’s go ahead. In my sample app, the menu commands are placed under File and I want them to appear just below the new window item.
If you want to create a new top level item, you can do it by using CommandMenu instead of CommandGroup like in the example below.


CommandMenu("Favorites") {
    Button("Show Favorites") {
        commandsHandler.showFavoritesCommand()
    }
}

All right, we finally placed the items in the menu. Now we need to do something when the users click on Open. This is a macOS app, we expect to open the standard file dialog where we can select a file (in this example a folder) click Open and return to our app.


func openCommand() {
    let dialog = NSOpenPanel();

    dialog.title                    = "Choose a directory"
    dialog.showsResizeIndicator     = true
    dialog.showsHiddenFiles         = false
    dialog.allowsMultipleSelection  = false
    dialog.canChooseDirectories     = true
    dialog.canChooseFiles           = false

    if (dialog.runModal() ==  NSApplication.ModalResponse.OK) {
        if let url = dialog.url {
            coordinator.setDirectory(url)
        }
    } else {
        print("user cancelled")
        return
    }

remember to import AppKit if you need stuff from it, like NSOpenPanel in this example.
As you can see you can set properties to allow multiple selection, allow files or folder and show or hide hidden files.
Once the modal is closed you can either have the URL of the select file or nothing if cancel is selected.

Drag&Drop

The next thing I want to show you is how to implement drag&drop, so you can drag a folder to the window and open it in our app.
No need to use AppKit this time, as drag and drop is supported by SwiftUI. Take a look at FilesView


.onDrop(of: ["public.file-url"], delegate: self)

extension FilesView:DropDelegate {
    func performDrop(info: DropInfo) -> Bool {
        DropUtils.urlFromDropInfo(info) { url in
            if let url = url {
                viewModel.setDirectory(url)
            }
        }
        return true
    }
}

You can put the onDrop modifier in each view you want to support drag&drop. You need to pass a delegate, I provided an extension to the struct describing the view but you could have a different class handle the drop operation. If you want to know how to extract the URL from DropInfo look for the DropUtils class in my project.


class func urlFromDropInfo(_ info:DropInfo, completion: @escaping (URL?) -> Void)  {
    guard let itemProvider = info.itemProviders(for: [(kUTTypeFileURL as String)]).first else {
        completion(nil)
        return
    }

    itemProvider.loadItem(forTypeIdentifier: (kUTTypeFileURL as String), options: nil) {item, error in
        guard let data = item as? Data,
              let url = URL(dataRepresentation: data, relativeTo: nil) else {
            completion(nil)
            return
        }
        completion(url)
    }
}

Architecture

Now a quick word about how I chose to architect my app.
I try to avoid putting logic into views, or view controllers back in the UIKit days.
For example have a look at SingleEntryView. This is the view responsible to show a file entry with a thumbnail, the file name and a star acting as a button. If you press it, you toggle the favorite status of the entry.
I don’t want this particular view to know what the model is and how to toggle the status.


struct SingleEntryView: View {
    var buttonAction:() -> Void
    var entry:FileEntry
    
    var body: some View {
        if entry.isDir {
            HStack {
                Image(systemName: "folder")
                    .font(.largeTitle)
                Text(entry.name)
            }
        }
        else {
            HStack {
                ImageView(withURL: entry.fileURL, maxSize:300)
                Text(entry.name)
                Button(action: buttonAction, label: {
                    Image(systemName:favoriteImageName)
                }).buttonStyle(PlainButtonStyle())
            }
        }
    }
    
    private var favoriteImageName:String {
        entry.isFavorite == true ? "star.fill" : "star"
    }
}

buttonAction, the action to execute when the button is pressed, can be set when creating the view like you see below


SingleEntryView(buttonAction:emptyAction, entry: entry)
...
SingleEntryView(buttonAction:{
                        toggleFavorite(entry)
                    }, entry: entry)
...
private var emptyAction:() -> Void = {} // used on dir entries as you don't need an action

so the view creating SingleEntryView can hold the button logic. This views knows about the app coordinator and can call the appropriate action.
The main logic is performed by the AppCoordinator. This class creates an instance of FilesViewModel, and responds to menu commands and to other commands like the toggle button I mentioned above. This way you don’t need views to directly handle user input, and you can have a single (or multiple, if you want to use child coordinators) object handling all your app logic.

This was just a quick introduction to macOS app development, as I mentioned this example doesn’t handle multiple windows (I removed the menu item) but I’ll leave that to another article about Scene.

Happy coding 🙂