Home iOS & Swift Tutorials

Document-Based Apps Using SwiftUI

SwiftUI makes it easier than ever to create document-based apps that work with the iOS document interaction system. In this tutorial, you’ll learn how to create a SwiftUI document-based meme-maker app.

4.7/5 6 Ratings

Version

  • Swift 5, iOS 14, Xcode 12

Documents are central to computing, and SwiftUI document-based apps make it easier than ever to work with the iOS document interaction system, based on integrating your app with all the cool features of the Files app.

In this tutorial, you’ll work on MemeMaker, an app that lets you create your own memes and persist them as their own meme document type.

You’ll learn about the following topics:

  • What are Uniform Type Identifiers (UTI)?
  • Which components comprise SwiftUI document-based apps?
  • How do you define your own document with a unique extension?
  • How do you run SwiftUI document-based apps on iOS/iPadOS and macOS?

Without further ado, it’s time to dive in.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Build and run. This is what the app looks like:

Screenshot of the starter project: Recents Tab

Tap the + button in the upper-right corner to create a new document. A text editor will open with “Hello, world!” shown. Change the text to SwiftUI rocks! and close the document by tapping the back button, which is in the upper-left corner.

Note: At the time of writing, there are a couple of bugs related to SwiftUI document-based apps. Sometimes you’ll have trouble seeing buttons like the back button or the add item button in the navigation bar because they’re tinted white by default.

Switch to the Browse tab to find the document you just created. The tab looks like this:

Screenshot of the starter project: Browse Tab

Open the new file by tapping it. The text editor opens and you can read the text you entered.

This is a good starting point for creating an editor for memes. You’ll modify this app so that instead of working with raw text, it works with a meme document type. This is where UTIs come in.

Defining Exported Type Identifiers

A Unique Type Identifier or UTI is, in Apple’s words, a “unique identifier for a particular file type, data type, directory or bundle type.” For instance, a JPEG image is a particular file type, and it’s uniquely identified by the UTI string public.jpeg. Likewise, a text file written in the popular Markdown markup language is uniquely identified by the UTI net.daringfireball.markdown.

What is the value of UTIs? Because UTIs are unique identifiers, they provide an unambiguous way for your app to tell the operating system what kind of documents it’s able to open and create. Since iOS doesn’t ship with built-in support for a “meme” document, you’ll add a new UTI to your app for meme files. This is straightforward in Xcode.

Before diving into code, you need to make some changes to the project setup.

Select the MemeMaker (iOS) target in your project settings, select the Info tab and expand the Exported Type Identifiers section.

This is the place to define the type and metadata of your document. Currently, this is still set up for a text document.

Make the following changes:

  • Change Description to A meme created with MemeMaker. You can see the description e.g. in the information window of Finder.
  • Change Identifier to com.raywenderlich.MemeMaker.meme. Other apps can use this identifier to import your documents.
  • Change Conforms to “public.data, public.content”. These are UTIs, and they describe the type of data your UTI is using. In programming parlance, you can think of these as the protocols your UTI conforms to. There are many types you can use, such as public.data or public.image. You’ll find a list of all available UTIs in Apple’s documentation or on Wikipedia.
  • Change Extension to meme. This is the .meme file extension that’s added to the documents you create with MemeMaker.

Configuring exported type identifiers

Great! Now you’re ready to create documents with your new extension, .meme. :]

Using a DocumentGroup

DocumentGroup is a scene presenting the system UI for handling documents. You can see how it looks in the screenshots above. SwiftUI makes it super easy to use the document browser. All that’s needed is to follow the code found in MemeMakerApp.swift:

DocumentGroup(newDocument: MemeMakerDocument()) { file in
  ContentView(document: file.$document)
}

DocumentGroup has two initializers when handling documents: init(newDocument:editor:) and init(viewing:viewer:). The first one allows you to create new documents and edit existing documents, while the second one is only able to view files. Because you want to create and edit memes, the starter project uses the first initializer.

The initializer receives the document it should show. In this case, you’re initializing a new empty MemeMakerDocument, which you’ll work on later. The initializer also receives a closure that builds the file editing view.

Working With a File Document

FileDocument is the base protocol for a document that an app can read and write to the device. This protocol contains two static properties: readableContentTypes and writableContentTypes. Both are UTType arrays defining the types the document can read and write, respectively. Only readableContentTypes is required, because writableContentTypes defaults to readableContentTypes as well.

FileDocument also requires an initializer taking a FileDocumentReadConfiguration. This configuration bundles a document’s type in the form of UTType, along with a FileWrapper containing its content.

Finally, any class or struct conforming to FileDocument needs to implement fileWrapper(configuration:). It’s called when a document is written, and it takes a FileDocumentWriteConfiguration as a parameter, which is similar to the read configuration, but used for writing.

This may sound like a lot of work, but don’t worry. In this section of the tutorial, you’ll look at how to use these two configurations.

Defining Exported UTTypes

Open MemeMakerDocument.swift. At the top of the file, you’ll find an extension on UTType that defines the type the starter project is using.

Replace this extension with the following code:

extension UTType {
  static let memeDocument = UTType(
    exportedAs: "com.raywenderlich.MemeMaker.meme")
}

In the code above, you’re defining memeDocument as a new UTType so that you can use it in the next step.

Still in MemeMakerDocument.swift, find readableContentTypes. As mentioned before, this defines a list of UTTypes the app can read and write. Replace the property with this new code:

static var readableContentTypes: [UTType] { [.memeDocument] }

This sets the new type you created earlier as a type that MemeMakerDocument document can read. Since writableContentTypes defaults to readableContentTypes, you don’t need to add it.

Creating the Data Model

Before you can continue working on MemeMakerDocument, you need to define the meme it works with. Create a new Swift file called Meme.swift in the Shared group and select both checkboxes in Targets so it’ll be included in both the iOS and the macOS targets.

Setting targets for Meme.swift

Add the following code:

struct Meme: Codable {
  var imageData: Data?
  var topText: String
  var bottomText: String
}

MemeMaker will save a Meme to disk. It conforms to Codable, so you can convert it to Data and back using JSONEncoder and JSONDecoder. It also wraps all the information needed to represent a Meme: two strings and an image’s data.

Open MemeMakerDocument.swift again and find this code at the beginning of the class:

var text: String
  
init(text: String = "Hello, world!") {
  self.text = text
}

MemeMakerDocument can now hold the actual Meme instead of text. So replace these lines with the following code:

// 1
var meme: Meme

// 2
init(
  imageData: Data? = nil, 
  topText: String = "Top Text", 
  bottomText: String = "Bottom Text"
) {
  // 3 
  meme = Meme(
    imageData: imageData, 
    topText: topText, 
    bottomText: bottomText)
}

This is what’s happening in the code above:

  1. This is the meme represented by an instance of MemeMakerDocument.
  2. You define an initializer for MemeMakerDocument. The initializer receives the data for an image and both the top and bottom text.
  3. Finally, you initialize a new Meme given these parameters.

At this point, you’ll see errors in your code. Don’t worry — there are a couple of additional changes you need to make to encode and decode a document when saving and loading a file.

Encoding and Decoding the Document

First, make a change to fileWrapper(configuration:). Replace the method body with these lines:

let data = try JSONEncoder().encode(meme)
return .init(regularFileWithContents: data)

This converts the meme to data and creates a WriteConfiguration that the system uses to write this document to disk.

Next, replace the body of init(configuration:) with the following code:

guard let data = configuration.file.regularFileContents else {
  throw CocoaError(.fileReadCorruptFile)
}
meme = try JSONDecoder().decode(Meme.self, from: data)

The app calls this initializer when an existing document is opened. You try to get the data from the given ReadConfiguration and convert it to an instance of Meme. If the process fails, the initializer will throw an error which the system deals with.

You’ve now added support for reading and writing custom meme documents to your app. However, the user still can’t see any of this since you’re not showing a meme editor. You’ll solve that problem in the next section.

Providing a Custom Editor

Currently, the app uses a TextEditor. The template for SwiftUI document-based multi-platform apps starts with this view. It’s used to present editable and scrollable text.

Screenshot of TextEditor with the initial text

TextEditor isn’t suitable for creating and editing memes, so you’ll create your own view to edit a MemeMakerDocument.

Before you start creating your new editor view, you’ll remove the old one. Open ContentView.swift and replace body with an empty view:

Spacer()

This makes sure you don’t get compiler errors while building up your new editor.

Creating the Image Layer

The editor will consist of two subviews. You’ll create these before creating the actual editor.

The first one is ImageLayer, a view that’s representing the image. Create a new SwiftUI View file in Shared called ImageLayer.swift and select both checkboxes for MemeMaker (iOS) and MemeMaker (macOS) in Targets. Replace the two structs in the file with the following:

struct ImageLayer: View {
  // 1
  @Binding var imageData: Data?

  // 2
  var body: some View {
    NSUIImage.image(fromData: imageData ?? Data())
      .resizable()
      .aspectRatio(contentMode: .fit)
  }
}

// 3
struct ImageLayer_Previews: PreviewProvider {
  static let imageData = NSUIImage(named: "AppIcon")!.data

  static var previews: some View {
    ImageLayer(imageData: .constant(imageData))
      .previewLayout(.fixed(width: 100, height: 100))
  }
}

Here’s what the code above is doing:

  1. ImageLayer has a SwiftUI binding to the meme image’s data. In a later step, MemeEditor will pass the data to this view.
  2. Its body consists of an NSUIImage, a view you initialize with the image data. You may wonder what this view is. It’s a typealias for UIImage on iOS and NSImage on macOS, together with an extension. It allows for one common type for images, which has the same methods and properties on both platforms. You can find it in the NSUIImage_iOS.swift file in the iOS group and NSUIImage_macOS.swift in the macOS group. It uses the correct type depending on whether you’re running the MemeMaker (iOS) or MemeMaker (macOS).
  3. Finally, you add a preview to support Xcode’s previewing feature.

Take a look at the preview to make sure your view is showing an image:

A preview of a SwiftUI image view

Now that you are showing the image, you can move on to showing the text!

Creating the Text Layer

TextLayer is the second subview, and it positions the top and bottom text above the image. Again, create a new SwiftUI View file in Shared and call it TextLayer.swift. Remember to check MemeMaker (iOS) and MemeMaker (macOS) as Targets.

Replace the generated TextLayer struct with this:

struct TextLayer<ImageContent: View>: View {
  @Binding var meme: Meme
  let imageContent: () -> ImageContent
}

TextLayer has two properties: meme, holding the Meme that’s shown; and imageContent. imageContent is a closure to create another view inside of TextLayer‘s body. Note that you declared the view as a generic struct where the the image content view can be anything that conforms to View.

Next, add the body to the view:

var body: some View {
  ZStack(alignment: .bottom) {
    ZStack(alignment: .top) {
      imageContent()
      MemeTextField(text: $meme.topText)
    }

    MemeTextField(text: $meme.bottomText)
  }
}

You use two ZStacks in body to place the top text at the top of the image and the bottom text at its bottom. To show the image, you call the closure passed to your TextLayer view. To show the text, you use MemeTextField, a normal TextField set up in your starter project to show formatted text.

Finally, replace the preview with the following:

struct TextLayer_Previews: PreviewProvider {
  @State static var meme = Meme(
    imageData: nil,
    topText: "Top Text Test",
    bottomText: "Bottom Text Test"
  )

  static var previews: some View {
    TextLayer(meme: $meme) {
      Text("IMAGE")
        .frame(height: 100)
    }
  }
}

Take a look at the preview:

SwiftUI custom document editor preview

Right now it’s not looking like much of a meme. Not to worry, in the next section, you’ll combine both the image and text layers to create MemeEditor.

Creating a Meme Editor

All the files you created before are independent of the platform. But MemeEditor will use different platform-specific methods to import images based on whether the app runs on iOS/iPadOS or macOS.

In a later step, you’ll create another MemeEditor to show on macOS, but for now, start with the iOS and iPadOS version. Create a new SwiftUI view file, MemeEditor_iOS.swift. This time it shouldn’t be in the Shared group but in iOS. Remember to check only the MemeMaker (iOS) target.

Replace the view in the file with the following code:

struct MemeEditor: View {
  @Binding var meme: Meme
  @State var showingImagePicker = false
  @State private var inputImage: NSUIImage?
}

MemeEditor has a binding to the meme it presents together with two properties. You’ll use showingImagePicker to decide when to present the image picker that lets your user select an image. You will then store the image in inputImage.

Next, add a new method to the struct to store the input image:

func loadImage() {
  guard let inputImage = inputImage else { return }
  meme.imageData = inputImage.data
}

Now you can add the body inside the view:

var body: some View {
  // 1
  TextLayer(meme: $meme) {
    // 2
    Button {
      showingImagePicker = true
    } label: {
      if meme.imageData != nil {
        ImageLayer(imageData: $meme.imageData)
      } else {
        Text("Add Image")
          .foregroundColor(.white)
          .padding()
          .background(Color("rw-green"))
          .cornerRadius(30)
          .padding(.vertical, 50)
      }
    }
  }
  // 3
  .sheet(isPresented: $showingImagePicker, onDismiss: loadImage) {
    UIImagePicker(image: $inputImage)
  }
}

Here’s what’s going on in the body:

  1. First, create a new TextLayer and pass both a binding to meme and a closure to create the ImageLayer.
  2. In this closure, define a button that sets showingImagePicker to true when tapped. Use the ImageLayer defined above as its label or show a button if the meme doesn’t yet contain an image.
  3. Use sheet to show a UIImagePicker whenever showingImagePicker is set to true. UIImagePicker is a wrapper around UIImagePickerController to make it usable with SwiftUI. It allows users to select an image from their device, and it calls loadImage whenever the picker is dismissed.

Next, replace the preview in the file with the following:

struct MemeEditor_Previews: PreviewProvider {
  @State static var meme = Meme(
    imageData: nil,
    topText: "Top Text Test",
    bottomText: "Bottom Text Test"
  )

  static var previews: some View {
    MemeEditor(meme: $meme)
  }
}

Your preview should now show a test of your view:

SwiftUI custom document editor preview

Finally, open ContentView.swift. Replace the contents of body with the following code, which is a dedicated meme editor as opposed to a text editor:

MemeEditor(meme: $document.meme)

Here you replaced TextEditor with the new MemeEditor. You pass the document’s meme to MemeEditor, letting user manipulate and work on a meme.

Finally, after all this coding, MemeMaker is ready to run on an iPhone! Select the MemeMaker (iOS) scheme and build and run. Create a new document, which looks like this:

Creating a new document with MemeMaker

Now you can choose a funny image, add some text and improve your meme-making skills. Try to create a funny meme like this one:

Skeleton waiting for app review

Good work! :]

Using the App on macOS

A big advantage of SwiftUI is that you can use it on all Apple platforms. But although you used NSUIImage, there are still some changes you need to make before you can run MemeMaker on macOS.

Implementing a MemeEditor for macOS

Because MemeEditor uses UIImagePickerController, you can’t use it on macOS. Instead, you’ll create another version of MemeEditor that’s used when running the app on macOS. It’ll use NSOpenPanel to let the user select an image as the background of the meme.

But thanks to SwiftUI, most of the views can stay the same. You can reuse both ImageLayer and TextLayer. The only difference is how the user selects an image.

Create a new SwiftUI View file in the macOS group and call it MemeEditor_macOS.swift. Only check the MemeMaker (macOS) target. Replace the contents of this file with the following code:

import SwiftUI

struct MemeEditor: View {
  @Binding var meme: Meme

  var body: some View {
    VStack {
      if meme.imageData != nil {
        TextLayer(meme: $meme) {
          ImageLayer(imageData: $meme.imageData)
        }
      }

      Button(action: selectImage) {
        Text("Add Image")
      }
      .padding()
    }
    .frame(minWidth: 500, minHeight: 500)
  }

  func selectImage() {
    NSOpenPanel.openImage { result in
      guard case let .success(image) = result else { return }
      meme.imageData = image.data
    }
  }
}

Here, you create a similar view to the one you created earlier for iOS. This time, though, you add a separate button to call selectImage. selectImage uses NSOpenPanel to let your user pick an image. If the selection succeeds, you store the new image data in the meme.

Finally, add a preview to the bottom of the file:

struct MemeEditor_Previews: PreviewProvider {
  @State static var meme = Meme(
    imageData: nil, 
    topText: "Top Text", 
    bottomText: "Bottom Text"
  )

  static var previews: some View {
    MemeEditor(meme: $meme)
  }
}

Build and run. (You’ll need macOS 11.0 or higher.) This is what the app looks like:

MemeMaker on macOS

You can create the same meme on macOS:

Skeleton waiting for app review

Without any extra work, the Mac app already has a working menu with shortcuts. For example, you can use Command-N to create a new document and Command-S to save the document, or you can undo your last change with Command-Z.

A menu is already setup.

Isn’t it amazing how easy it is to create an app that uses documents and runs on both iOS and macOS? :]

Where to Go From Here?

You can download the completed project files by clicking the Download Materials button at the top or bottom of this tutorial.

Documents are a central part of many good apps. And now with SwiftUI, it’s even easier to build document-based apps for iOS, iPadOS and macOS.

If you want to dive deeper into SwiftUI document-based apps, see Apple’s Build document-based apps in SwiftUI video.

For more information about SwiftUI, check out the SwiftUI: Getting Started tutorial or the SwiftUI by Tutorials book.

To create a document-based UIKit app, you’ll find more information in the Document-Based Apps Tutorial: Getting Started article.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

4.7/5

Add a rating for this content

6 ratings

More like this

Contributors

Comments