SwiftUI for Mini Desktop Apps: A Tutorial

A little while ago I wrote about the state of SwiftUI. While I was generally skeptical of the technology as a general purpose/widely used solution it still has some major strengths thanks to Apple integration and the Swift language. Here I want to look at something that it does very nicely: making a small GUI Mac desktop App. You can follow along in Xcode (which in later 13.x releases has improved a lot). I will try to make this accessible to non-Swift developers.

We will make a small App to configure a JSON file and save it; the kind of thing you might actually want to do on larger project. Also the kind of thing where getting it correct is important; something Swift's strong type system can help with.

Start

Create a new project, choose App within the macOS platform tab. The Document App is also something it does well (as in documents on your computer, how retro(!)), but today I'm going to focus on the simpler case. Choose SwiftUI for interface and Next. The main file will look like the below. We will just work in this file to keep things simple; Swift like many modern languages isn't picky about where you put code. You shouldn't do this for any non-trivial real projects.

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
    }
}

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

Hit "Cmd + R" to run the project. You should see a window with the text "Hello, world!".

Codability

Modern Swift often uses structs for data modelling. They have value semantics (rather than the reference semantics of classes) which can often make them easier to work with, especially when we want to avoid mutating data as that is harder to keep track of in our heads.

In Swift we can conform to a protocol (what most languages call implement an interface). There are some 'magic' protocols which the Swift compiler will conform to for us. Let's dive in and see an example:

struct Step: Codable {
  var id: String
  var name: String
  var kind: StepKind
}

But let's say we want to model that something is one of set of things, for these we use enums.

enum StepKind: String, Codable {
  case textEntry, numberEntry
}

Roughly speaking something can be Codable if it is composed of Codable things. Let's look at how we can encode this to JSON. First, let's create some sample data (Swift follows Haskell in wrapping something in [] to make it an array).

let sample: [Step] = [
  Step(id: "1", name: "Name", kind: .textEntry),
  Step(id: "2", name: "Age", kind: .numberEntry)
]

Now replace our UI with:

struct ContentView: View {
    var body: some View {
      VStack {
        Button {
          print(try! String(data: JSONEncoder().encode(sample), encoding: .utf8)!)
        } label: {
          Text("Encode")
        }

      }
    }
}

Let's unpack that:

  • print( Swift's general purpose print function.
  • try! Danger(!) but for small utility things this is okay. Tells Swift not to worry about this crashing.
  • String(data: Covert the data to a string.
  • JSONEncoder().encode(sample) The actual JSON encoding; which is very simple. Decoding is similar.
  • , encoding: .utf8) We must choose a text encoding for the string.
  • !) Danger(!) this might not succeed in general, so we are telling Swift again not to worry.

The above was very brief but perhaps you are willing to believe that:

  1. Swift can model data in a strict, concise and yet flexible way
  2. It can very easily convert data to (and from) JSON

UI and State

Let's create a simple UI to edit the data. In SwiftUI a convenient way to this (for non-simple cases) is with an ObservableObject. This is a class where we can listen to changes. Let's do it:

class AppState: ObservableObject {
  var steps: [Step] = []

  func updateStep(step: Step) {
    if let idx = steps.firstIndex(where: { $0.id == step.id }) {
      steps[idx] = step
      objectWillChange.send()
    }
  }

  func addStep() {
    steps.append(Step(id: UUID().uuidString, name: "", kind: .textEntry))
    objectWillChange.send()
  }
}

A couple of key things. We use objectWillChange to tell Swift that something has changed. So any UI that depends on the state can be updated. To update a step we look for first matching item and change it.

We could easily add things like a remove function and it would be very encapsulated and decoupled from our UI.

We can hook up to the UI with one line of code:

@StateObject var state = AppState()

this both sets up the state and observes it (on changes UI will update).

We are going to need one more thing though, SwiftUI uses a protocol called Identifiable to distinguish items in a collection. So we'll update our data code a little:

struct Step: Codable, Identifiable {
  var id: String
  var name: String
  var kind: StepKind
}

enum StepKind: String, Codable, Identifiable, CaseIterable {
  case textEntry, numberEntry

  var id: String {
    rawValue
  }
}

For the first one it is easy, we are actually already conforming. In the second case we must implement an id. We also add CaseIterable (this will be convenient later as it gives us a StepKind.allCases or simple way to iterate over the cases).

Wire up the UI

Let's build a UI. This can be quite concise, yet very clear and easy to change. Let's overhaul what we had. As before we start with:

struct ContentView: View {
  @StateObject var state = AppState()

  var body: some View {

Now we add in a ScrollView which will allow for lots of items, then a LazyVStack; this is a special view where items are lazily included on screen as needed.

    ScrollView {
      LazyVStack {

Now we iterate over the state.steps. This ForEach is a special view that works with a collections of Identifiable items.

        ForEach(state.steps) { item in
          Text(item.id)
        }
      }
    }

Let's set a size with a frame modifier.

    .frame(minWidth: 800, minHeight: 640)

And now add a Toolbar, the native MacOS header bar:

    .toolbar {
      ToolbarItemGroup {

We can add buttons to it, as before, but now they will have the correct appearance for a Toolbar.

        Button {

And wiring up our action is just:

          state.addStep()
        } label: {

We add a icon with SwiftUI's system image feature.

          Image(systemName: "plus")
        }

Finally tweak what we had before by using the state.steps to print our actual data.

        Button {
          print(try! String(data: JSONEncoder().encode(state.steps), encoding: .utf8)!)
        } label: {
          Text("Save")
        }
      }
    }}
}

Try running this. You should see a proper native looking UI. When you click on the plus button it will add a new item. When you click on Save it will print the data to the console.

Item Editor(s)

Let's create a component (in SwiftUI terms a View) to edit an item. The UI part is easy enough with a HStack (horizontal container) and a few form fields. To do a two way binding we add a binding helper to the AppState. While our state code will get a bit messier our UI code is fairly simple.

struct ItemEditor: View {
  @ObservedObject var state: AppState
  var id: String

  var body: some View {
    HStack {
      TextField("Name", text: state.textBinding(for: id))

      Picker("Kind", selection: state.kindBinding(for: id)) {
        ForEach(StepKind.allCases) {
          Text($0.rawValue).tag($0)
        }
      }
    }.padding()
  }
}

We set up the helper bindings (basically a getter and setter for each item) like:

func textBinding(for id: String) -> Binding<String> {
    if let step = steps.first(where: { $0.id == id }) {
      return Binding(get: { step.name }, set: {
        self.updateStep(step: Step(id: step.id, name: $0, kind: step.kind ))
      })
    } else {
      return Binding.constant("")
    }
  }

  func kindBinding(for id: String) -> Binding<StepKind> {
    if let step = steps.first(where: { $0.id == id }) {
      return Binding(get: { step.kind }, set: {
        self.updateStep(step: Step(id: step.id, name: step.name, kind: $0 ))
      })
    } else {
      return Binding.constant(.textEntry)
    }
  }

Save Files

Let's save the file. This is, like much Apple stuff non-obvious and horribly documented. First in the main target (browse to what seems like the top level item in the project), Targets, Signing & Capabilties, App Sandbox and choose in File Access, User Selected File: Read/Write. If you don't do that it won't work.

Let's create a save function and add it to the AppState. This is a basic thing that will work and that ignores problems:

func save() {
    let savePanel = NSSavePanel()
    savePanel.allowedContentTypes = [.json]
    savePanel.canCreateDirectories = true
    savePanel.isExtensionHidden = false
    savePanel.title = "Save steps"
    savePanel.message = "Choose where to save your steps"
    savePanel.nameFieldLabel = "Fle name:"

    let response = savePanel.runModal()
    if response == .OK {
      let stringToSave = String(data: try! JSONEncoder().encode(steps), encoding: .utf8)!
      try! stringToSave.write(to: savePanel.url!, atomically: true, encoding: .utf8)
    }
  }

We update the save button to:

Button {
    state.save()
} label: {
    Text("Save")
}

And we are basically done. We can distribute the App in a number of ways, including the Mac App Store. There are a few things like icons we should probably also take care of. But I want to try to demonstrate one of the main benefits of declarative UI, that we can come back to projects later and sanely modify them.

Making changes: deleting items

Here is a complete implementation of delete business logic:

func removeStep(id: String) {
    steps.removeAll { $0.id == id }
    objectWillChange.send()
  }

In our ItemEditor we add a button to delete the item.

Button {
  state.removeStep(id: id)
} label: {
  Image(systemName: "minus.square").resizable().aspectRatio(contentMode: .fit).frame(width: 24, height: 24)
}.buttonStyle(PlainButtonStyle())

Done. Nicely decoupled logic. Trivial wiring up.

Making changes: adding a hint

Above the LazyVStack add:

if state.steps.isEmpty {
    Text("Add steps via +")
        .font(.headline)
        .padding()
}

Done: when the list is empty you get a nice prompt.

Kind Labels

textEntry looks ugly let's fix that. Add

var label: String {
    switch self {
    case .textEntry:
      return "Text"
    case .numberEntry:
      return "Number"
    }
  }

In StepKind and update the Picker code to:

Picker("Kind", selection: state.kindBinding(for: id)) {
    ForEach(StepKind.allCases) {
        Text($0.label).tag($0)
    }
}

Complete Code

The entire code is below. While some things are a little hacky (though in many cases reasonably so for a small helper) it is overall actually quite concise and readable. A 'real' app that did some kind of structured data editing (e.g. a wizard/flow editor) wouldn't really be that much more complex.

And while many things are currently missing we are already supporting stuff like dark mode.

import SwiftUI

struct ContentView: View {
  @StateObject var state = AppState()

  var body: some View {
    ScrollView {
      if state.steps.isEmpty {
        Text("Add steps via +")
          .font(.headline)
          .padding()
      }
      LazyVStack {
        ForEach(state.steps) { item in
          ItemEditor(state: state, id: item.id)
        }
      }
    }
    .frame(minWidth: 800, minHeight: 640)
    .toolbar {
      ToolbarItemGroup {
        Button {
          state.addStep()
        } label: {
          Image(systemName: "plus")
        }

        Button {
          state.save()
        } label: {
          Text("Save")
        }
      }
    }}
}

struct ItemEditor: View {
  @ObservedObject var state: AppState
  var id: String

  var body: some View {
    HStack {
      TextField("Name", text: state.textBinding(for: id))

      Picker("Kind", selection: state.kindBinding(for: id)) {
        ForEach(StepKind.allCases) {
          Text($0.label).tag($0)
        }
      }

      Button {
        state.removeStep(id: id)
      } label: {
        Image(systemName: "minus.square").resizable().aspectRatio(contentMode: .fit).frame(width: 24, height: 24)
      }.buttonStyle(PlainButtonStyle())
    }.padding()
  }
}

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

struct Step: Codable, Identifiable {
  var id: String
  var name: String
  var kind: StepKind
}

enum StepKind: String, Codable, Identifiable, CaseIterable {
  case textEntry, numberEntry

  var id: String {
    rawValue
  }

  var label: String {
    switch self {
    case .textEntry:
      return "Text"
    case .numberEntry:
      return "Number"
    }
  }
}

let sample: [Step] = [
  Step(id: "1", name: "Name", kind: .textEntry),
  Step(id: "2", name: "Age", kind: .numberEntry)
]

class AppState: ObservableObject {
  var steps: [Step] = []

  func updateStep(step: Step) {
    if let idx = steps.firstIndex(where: { $0.id == step.id }) {
      steps[idx] = step
      objectWillChange.send()
    }
  }

  func addStep() {
    steps.append(Step(id: UUID().uuidString, name: "", kind: .textEntry))
    objectWillChange.send()
  }

  func removeStep(id: String) {
    steps.removeAll { $0.id == id }
    objectWillChange.send()
  }

  func textBinding(for id: String) -> Binding<String> {
    if let step = steps.first(where: { $0.id == id }) {
      return Binding(get: { step.name }, set: {
        self.updateStep(step: Step(id: step.id, name: $0, kind: step.kind ))
      })
    } else {
      return Binding.constant("")
    }
  }

  func kindBinding(for id: String) -> Binding<StepKind> {
    if let step = steps.first(where: { $0.id == id }) {
      return Binding(get: { step.kind }, set: {
        self.updateStep(step: Step(id: step.id, name: step.name, kind: $0 ))
      })
    } else {
      return Binding.constant(.textEntry)
    }
  }

  func save() {
    let savePanel = NSSavePanel()
    savePanel.allowedContentTypes = [.json]
    savePanel.canCreateDirectories = true
    savePanel.isExtensionHidden = false
    savePanel.title = "Save steps"
    savePanel.message = "Choose where to save your steps"
    savePanel.nameFieldLabel = "Fle name:"

    let response = savePanel.runModal()
    if response == .OK {
      let stringToSave = String(data: try! JSONEncoder().encode(steps), encoding: .utf8)!
      try! stringToSave.write(to: savePanel.url!, atomically: true, encoding: .utf8)
    }
  }
}