Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

21. Building a Mac App
Written by Sarah Reichelt

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

If you have worked through the previous chapters, you made several iOS apps. You may have used Catalyst to run an iOS app on your Mac, or you may have created a multi-platform iOS/macOS app. But in this chapter, you’re going to write a purely Mac app. You’ll create a class of app that is very common on Macs - a document-based app.

Many Mac apps are document-based. Think of apps like TextEdit, Pages, Numbers or Photoshop. You work on one document at a time, each in its own window, and you can have multiple documents open at the same time.

In this chapter, you’re going to build a Markdown editor. Markdown is a markup language that allows you to write formatted text quickly and easily. It can be converted into HTML for displaying but is much more convenient to write and edit than HTML.

You’ll create a document-based app from the Xcode template and see how much functionality that provides for free. Then you’ll go on to customize the file type for saving and opening as well as adding the HTML preview, a toolbar and menus.

The default document app

Open Xcode and create a new project. Select macOS and choose Document App. Make sure that the interface is SwiftUI and the language is Swift. Call the app MacMarkDown.

Once you have saved the project, build and run the app. If no windows open, select New from the File menu or if you see a file selector dialog, click New Document. You’ll see a single window showing some default text. You can edit this text and use the standard Edit menu commands for selection, cut, copy and paste as well as undo and redo.

Select Save from the File menu.

Saving the default document
Saving the default document

Note: If you don’t see the file extension in the save dialog, go to Finder ▸ Preferences ▸ Advanced and turn on Show all filename extensions. This’ll make it easier to follow the next part of this chapter.

The default app uses a file extension of .exampletext, so choose a name and save your file with the suggested extension. Close the window and create a new window using Command-N. Now try opening your saved document by choosing Open… from the File menu.

And all this is without writing a single line of code!

Close the app, go back to Xcode and look at MacMarkDownApp.swift. Instead of the app body containing a WindowGroup as you’ll have seen in other apps, it contains a DocumentGroup which has a newDocument parameter that is set to an instance of MacMarkDownDocument. The ContentView is passed a reference to this document.

If you look in ContentView.swift you’ll see that the only view inside the body is a TextEditor. This view allows editing long chunks of text. It has a text property which is bound to the document’s text.

Open MacMarkDownDocument.swift to see where the file saving and opening happens. The first thing to note is the UTType extension. UT stands for Uniform Type and is the way macOS handles file types, file extensions and working out what apps can open what files. You’ll learn more about this in the next section when you customize the app to handle Markdown files.

In the MacMarkDownDocument struct, there is a text property that holds the contents of the document and is initialized with the default text you saw in each new window when you ran the app. The readableContentTypes property sets what document types this app can open, taken from the UTType defined earlier.

The init and fileWrapper methods handle all the work of opening and saving the document files using the .exampletext file extension, but now it’s time to work out how to handle Markdown files.

Setting up the app for Markdown

When you double-click a document file on your Mac, Finder will open it with the default application: TextEdit for .txt files, Preview for .png files and so on. And if you right-click any document file and look at the Open With menu, you’ll see a list of the applications on your Mac that are able to open that type of file. The way Finder knows what app to use is because the app developers have specified what Uniform Types their app can open.

Setting document types

Go to the project settings by selecting the project. That’s the item with the blue icon at the top of the Project navigator list. Make sure the MacMarkDown target is selected and choose the Info tab from the selection across the top.

Document type
Pexenord kdde

Imported type
Etcuhdin rjso

extension UTType {
  static var markdownText: UTType {
    UTType(importedAs: "net.daringfireball.markdown")
  }
}
static var readableContentTypes: [UTType] { [.markdownText] }

Testing the new settings

Build and run the app. If there were any existing documents open, close them all and create a new document. Check that the default text is “# Hello MacMarkDown!”. Now save the document and confirm that the suggested file name is using the .md file extension.

Saving with the Markdown extension
Gatoys gagx nlu Rimglidb ewmiqfaev

Markdown and HTML

Markdown is markup language that uses shortcuts to format plain text in a way that converts easily to HTML. As an example, look at the following HTML:

<h1>Important Header</h1>
<h2>Less Important Header</h2>

<a href="https://www.raywenderlich.com">Ray Wenderlich</a>

<ul>
  <li>List Item 1</li>
  <li>List Item 2</li>
  <li>List Item 3</li>
</ul>
# Important Header
## Less Important Header

[Ray Wenderlich](https://www.raywenderlich.com)

- List Item 1
- List Item 2
- List Item 3

Converting Markdown to HTML

Back in Xcode, select the project in the Project navigator and this time, click the MacMarkDown project instead of the target. Go to the Package Dependencies tab and click the plus button to add a new dependency. Enter this URL: https://github.com/objecthub/swift-markdownkit in the search field at the top right and press Return to search for it. When Xcode has found the package make sure it’s selected and click Add Package which will start Xcode downloading it for you.

Find the package
Tudj dni xifzupi

Import the package
Ubxind bqu lobvoso

import MarkdownKit
var html: String {
  let markdown = MarkdownParser.standard.parse(text)
  return HtmlGenerator.standard.generate(doc: markdown)
}

Adding the HTML preview

The app needs a web view to display the HTML but SwiftUI doesn’t have a web view yet. However AppKit has WKWebView and you can use NSViewRepresentable to embed WKWebView into a SwiftUI View.

// 1
import SwiftUI
import WebKit

// 2
struct WebView: NSViewRepresentable {
  // 3
  var html: String

  init(html: String) {
    self.html = html
  }

  // 4
  func makeNSView(context: Context) -> WKWebView {
    WKWebView()
  }

  // 5
  func updateNSView(_ nsView: WKWebView, context: Context) {
    nsView.loadHTMLString(
      html,
      baseURL: Bundle.main.resourceURL)
  }
}

Displaying the HTML

To display the two views side-by-side in resizable panes, you’re going to embed the TextEditor and a WebView in a HSplitView. This is a macOS-specific SwiftUI view for exactly this purpose.

HSplitView {
  TextEditor(text: $document.text)
  WebView(html: document.html)
}
Sandbox setting
Jaxgbiy cefvoqr

Web preview
Kof hnutuum

Framing the window

When an app is running on an iPhone or iPad, it can work out the available screen size and expand to fill it. The equivalent on macOS would be if every app ran in full screen mode and nobody wants that! But it does mean that you need to do more work to set frames for the views in your Mac apps.

.frame(minWidth: 200)
.frame(minWidth: 400, idealWidth: 600, maxWidth: .infinity,
       minHeight: 300, idealHeight: 400, maxHeight: .infinity)
Resizing the window
Royuluxn zna sixzun

Adding a settings window

Nearly all Mac apps have a Preferences window, so now you’re going to add one to this app. Make a new SwiftUI View file and call it SettingsView.swift. Update the body to look like this:

var body: some View {
  Text("Settings")
    .padding()
}
Settings {
  SettingsView()
}
Settings
Bislamgk

@AppStorage

SwiftUI uses property wrappers extensively to let us assign extra functionality to our variables, structs and classes. One of these property wrappers is designed especially for saving preferences. If you have worked through the earlier chapters in this book, you’ll know all about @AppStorage already, but for those of you who skipped straight to the macOS chapters (and who could blame you), here are the details.

Choosing a font size

You’re going to add the ability to change the editor font size. In SettingsView.swift add the following code inside the SettingsView struct but before body:

@AppStorage("editorFontSize") var editorFontSize: Int = 14
Stepper(value: $editorFontSize, in: 10 ... 30) {
  Text("Font size: \(editorFontSize)")
}
.frame(width: 260, height: 80)
.font(.system(size: CGFloat(editorFontSize)))
Font size
Gepm magu

Changing and creating menus

All Mac apps have a menu bar. Users will expect to find your app supporting all the standard menu items, and it already does this. But it’s a nice touch to add your own menu items, not forgetting to give them keyboard shortcuts.

import SwiftUI

// 1
struct MenuCommands: Commands {
  var body: some Commands {
    // 2
    CommandGroup(before: .help) {
      // 3
      Button("Markdown Cheatsheet") {
        showCheatSheet()
      }
      // 4
      .keyboardShortcut("/", modifiers: .command)

      Divider()
    }

    // more menu items will go here
  }

  // 5
  func showCheatSheet() {
    let cheatSheetAddress =
      "https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet"
    guard let url = URL(string: cheatSheetAddress) else {
      // 6
      fatalError("Invalid cheatsheet URL")
    }
    NSWorkspace.shared.open(url)
  }
}
.commands {
  MenuCommands()
}
Help menu
Qonw mamo

Adding a new menu

Now it’s time for you to create your own menu. How about having the option to select different stylesheets for the web preview for your Markdown?

@AppStorage("styleSheet")
  var styleSheet: StyleSheet = .raywenderlich
// 1
CommandMenu("Stylesheet") {
  // 2
  ForEach(StyleSheet.allCases, id: \.self) { style in
    // 3
    Button(style.rawValue) {
      styleSheet = style
    }
    // 4
    .keyboardShortcut(style.shortcutKey, modifiers: .command)
  }
}

Displaying the styles

To make the web view use these styles, head over to WebView.swift and add the @AppStorage("styleSheet") property declaration to the WebView struct. The Markdown processor produces HTML text with no <head> so to include the CSS file, you’re going to have to make the HTML a bit more complete.

var formattedHtml: String {
  return """
      <html>
      <head>
         <link href="\(styleSheet).css" rel="stylesheet">
      </head>
      <body>
         \(html)
      </body>
      </html>
      """
}
func updateNSView(_ nsView: WKWebView, context: Context) {
  nsView.loadHTMLString(
    formattedHtml, // CHANGE THIS
    baseURL: Bundle.main.resourceURL)
}
Stylesheet
Sywxurloan

Creating a toolbar

Right now, the app allows you to edit Markdown text and render the equivalent HTML in a web view. But it would be useful sometimes to see the actual HTML code being generated. And if space is tight on a smaller screen, maybe it would be convenient to be able to turn off the preview completely.

import SwiftUI
enum PreviewState {
  case hidden
  case html
  case web
}
// 1
struct PreviewToolBarItem: ToolbarContent {
  // 2
  @Binding var previewState: PreviewState

  // 3
  var body: some ToolbarContent {
    // 4
    ToolbarItem {
      // 5
      Picker("", selection: $previewState) {
        // 6
        Image(systemName: "eye.slash")
        	.tag(PreviewState.hidden)
        Image(systemName: "doc.plaintext")
        	.tag(PreviewState.html)
        Image(systemName: "doc.richtext")
        	.tag(PreviewState.web)
      }
      .pickerStyle(SegmentedPickerStyle())
      // 7
      .help("Hide preview, show HTML or web view")
    }
  }
}

Using the toolbar

Now to attach the toolbar to ContentView. First, you need to add an @State variable to hold the selected preview state. This is set to web by default:

@State private var previewState = PreviewState.web
.toolbar {
  PreviewToolBarItem(previewState: $previewState)
}
Toolbar
Houpzuk

if previewState == .web {
  WebView(html: document.html)
    .frame(minWidth: 200)
}
var body: some View {
  HSplitView {
    TextEditor(text: $document.text)
      .frame(minWidth: 200)
      .font(.system(size: CGFloat(editorFontSize)))

    if previewState == .web {
      WebView(html: document.html)
        .frame(minWidth: 200)
    }
  }
  .frame(minWidth: 400,
         idealWidth: 600,
         maxWidth: .infinity,
         minHeight: 300,
         idealHeight: 400,
         maxHeight: .infinity)
  .toolbar {
    PreviewToolBarItem(previewState: $previewState)
  }
}
No preview
Xu bzicuic

Adding the HTML text preview

For the raw HTML display, add this underneath the previous if statement:

else if previewState == .html {
  // 1
  ScrollView {
    // 2
    Text(document.html)
      .frame(minWidth: 200)
      .frame(maxWidth: .infinity, maxHeight: .infinity,
             alignment: .topLeading)
      .padding()
      // 3
      .font(.system(size: CGFloat(editorFontSize)))
      // 4
      .textSelection(.enabled)
  }
}
HTML preview
VFVB vvipeix

Markdown in AttributedStrings

At WWDC 2021, one of the new features announced for SwiftUI was an AttributedString that could be formatted using Markdown. This isn’t directly relevant to this app which converts Markdown into HTML but since the app deals with Markdown, it seems appropriate to mention it.

var attributedString: AttributedString {
  // 1
  let markdownOptions =
  AttributedString.MarkdownParsingOptions(
    interpretedSyntax: .inlineOnly)
  // 2
  let attribString = try? AttributedString(
    markdown: document.text,
    options: markdownOptions)
  // 3
  return attribString ??
  AttributedString("There was an error parsing the Markdown.")
}
Text(attributedString)
AttributedString from Markdown
OqqribigukBsruwq jnez Vobdlejh

Installing the app

On an iOS device, when you build and run the app in Xcode, the app is installed on your iPhone or iPad and you can use it there, even after closing Xcode. For a Mac app, this isn’t quite as simple because building and running doesn’t copy the app into your Applications folder but buries it deep within your Library.

Challenge

Challenge: Add another file extension

When you were setting up the file types, you allowed the app to use either “.md” or “.markdown” for the file extensions. But some people use “.mdown” to indicate Markdown files. Edit the project so that “.mdown” is a valid extension. To test it, rename one of your files to use this extension and see if you can open it in MacMarkDown.

Key points

  • Apple provides a starting template for document-based Mac apps that can get you going very quickly, but now you know how to customize this template to suit your own file types.
  • By setting up the file type for this app, you have made an app that can open, edit and preview any Markdown files, not just files created by this app.
  • Mac users expect all apps to work much the same way, with menus, toolbars, preferences, multiple windows. Now you have the tools to make an app that does all these things.
  • And you have a useful Markdown editor that you can really use! The completed project is in the final folder for this chapter.

Where to go from here?

Well done! You made it through this chapter, you have made a document-based Mac app that you can use or extend and you have learned a lot about file types, Markdown and standard elements of Mac apps.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now