Home iOS & Swift Books macOS by Tutorials

13
Adding the Interface Written by Sarah Reichelt

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

In the previous chapter, you learned about Terminal commands, how to run them in Terminal and how to run them using Swift. Now, you’re going to take your knowledge and apply it to an app that provides a graphical user interface to some features of the sips command.

Since you’re now an experienced macOS app developer, you don’t need to start from scratch. The starter project has all the UI, but you have to make it work.

In this chapter, you’ll add multiple options for selecting files and folders, and you’ll apply the general functions you created in the last chapter to more specific commands.

You’ll work on an app called ImageSipper that’ll give you controls for editing single images, as well as the ability to generate thumbnails for a complete folder of image files.

The Starter Project

Go to the folder for this chapter in the downloaded materials and open the starter project. Build and run to see what you’re working with:

Starter project
Starter project

The app window has a tab view with two tabs, each offering a different image editing feature. There’s a terminal output view at the side, so you can see what Terminal commands the app uses and what it gets back.

Most of the controls are inactive, and since there’s no way to select an image file yet, you can’t do much.

Head back to Xcode and look at the groups and files in the Project navigator:

Project files and groups
Project files and groups

Going through the groups in this list:

  • Views: ContentView is the main window view, containing a TabView and the TerminalView. The TabView contains ImageEditView and ThumbsView.

  • Components: These are subviews used by the main views. CustomImageView formats an Image view. The two Controls views provide the input fields and buttons at the bottom of each of the views in the TabView. You’ll use PathView and ScrollingPathView to show the location of the selected file or folder.

  • Models: Picture is a structure to hold the image data that you read using sips. PicFormat is an enumeration listing the supported image formats.

  • Utilities: CommandRunner is a class wrapped round the functions you wrote in the playground, along with a method for publishing the output. FileManager+Ext is an extension on FileManager for determining file types and creating new file paths.

Separating components and utilities like this makes them more reusable in other projects.

Since now you have the app running, it’s time to make it functional.

Choosing Files and Folders

The first step before you can edit any images is to allow your users to select an image file or a folder of images.

// 1
let openPanel = NSOpenPanel()
openPanel.message = "Select an image file:"

// 2
openPanel.canChooseDirectories = false
openPanel.allowsMultipleSelection = false
openPanel.allowedContentTypes = [.image]

// 3
openPanel.begin { response in
  if response == .OK {
    // 4
    imageURL = openPanel.url
  }
}
Selecting an image file.
Bukisfoky eh ajila diro.

Selecting Folders

While you’re setting up panels, open ThumbsView.swift and fill selectImagesFolder() with:

let openPanel = NSOpenPanel()
openPanel.message = "Select a folder of images:"

// 1
openPanel.canChooseDirectories = true
openPanel.canChooseFiles = false
openPanel.allowsMultipleSelection = false

openPanel.begin { response in
  if response == .OK {
    // 2
    folderURL = openPanel.url
  }
}
Selecting a folder of images.
Xixiyjajx o xuqziv eq eletey.

Dragging and Dropping

How about allowing users to drag and drop image files or folders into the views? SwiftUI makes detecting drops easy, but working out the URLs from the dropped data is a bit obscure.

// 1
func loadURL(from data: Data?) {
  // 2
  guard
    let data = data,
    let filePath = String(data: data, encoding: .utf8),
    let url = URL(string: filePath) else {
      return
    }

  // 3
  imageURL = url
}
 @State private var dragOver = false

Handling the Drop

Finally, you can add this onDrop modifier to Image, replacing // onDrop here:

// 1
.onDrop(
  of: ["public.file-url"],
  isTargeted: $dragOver
) { providers in
  // 2
  if let provider = providers.first {
    // 3
    provider.loadDataRepresentation(
      forTypeIdentifier: "public.file-url") { data, _ in
        // 4
        loadURL(from: data)
    }
  }
  // 5
  return true
}
Dragging and dropping an image file.
Jkawqoch anj ltetzeft ap izonu bihe.

Dropping Folders

This gives you drag and drop for CustomImageView. Now, you can apply the same technique to ThumbsView.

func loadURL(from data: Data?) {
  guard
    let data = data,
    let filePath = String(data: data, encoding: .ascii),
    let url = URL(string: filePath) else {
      return
    }
  if FileManager.default.isFolder(url: url) {
    folderURL = url
  }
}
@State private var dragOver = false
.onDrop(
  of: ["public.file-url"],
  isTargeted: $dragOver
) { providers in
  if let provider = providers.first {
    provider.loadDataRepresentation(
      forTypeIdentifier: "public.file-url") { data, _ in
        loadURL(from: data)
    }
  }
  return true
}
Dragging and dropping a folder.
Dhoghicp ocy vtufhavt u nivbaz.

Showing the File Path

You now have multiple ways of getting images or folders into the app. But once they’re in, there’s nothing to show you where those files are.

ScrollingPathView(url: $imageURL)
ScrollingPathView(url: $folderURL)
Displaying the path.
Xutymefecz wsi pibm.

// 1
class Coordinator {
  // 2
  @objc func handleDoubleClick(sender: NSPathControl) {
    // 3
    if let url = sender.clickedPathItem?.url {
      // 4
      NSWorkspace.shared.selectFile(
        url.path,
        inFileViewerRootedAtPath: "")
    }
  }
}
func makeCoordinator() -> Coordinator {
  return Coordinator()
}
pathControl.target = context.coordinator
pathControl.doubleAction = #selector(Coordinator.handleDoubleClick)
Opening a Finder window.
Ivolayw i Qefmid nijyif.

Using sips

You now know a lot more about file dialogs, about dragging and dropping files and about file paths. But isn’t it time to start editing some images? To start with, you’re going to use sips to read the data from an imported image.

import SwiftUI

// 1
class SipsRunner: ObservableObject {
  // 2
  var commandRunner = CommandRunner()

  // 3
  var sipsCommandPath: String?

  func checkSipsCommandPath() async -> String? {
    if sipsCommandPath == nil {
      sipsCommandPath = await commandRunner.pathTo(command: "sips")
    }
    return sipsCommandPath
  }

  // 4
  func getImageData(for imageURL: URL) async -> String {
    // 5
    guard let sipsCommandPath = await checkSipsCommandPath() else {
      return ""
    }

    // 6
    let args = ["--getProperty", "all", imageURL.path]
    let imageData = await commandRunner
      .runCommand(sipsCommandPath, with: args)
    return imageData
  }
}
@StateObject var sipsRunner = SipsRunner()
.environmentObject(sipsRunner)

Reading Image Information

Open ImageEditView.swift and add this line at the top of the structure:

@EnvironmentObject var sipsRunner: SipsRunner
ImageEditView(selectedTab: .constant(.editImage))
  .environmentObject(SipsRunner())
// 1
guard
  let imageURL = imageURL,
  FileManager.default.isImageFile(url: imageURL)
else {
  return
}

// 2
let imageData = await sipsRunner.getImageData(for: imageURL)

// 3
picture = Picture(url: imageURL, sipsData: imageData)
Showing image information.
Nfehogz izulu ofxodmedaow.

Showing the Terminal Output

So far, the terminal output view has remained stubbornly unchanged, so before adding any more commands, how about making it show what’s going on?

@EnvironmentObject var sipsRunner: SipsRunner
TerminalView(commandRunner: sipsRunner.commandRunner)
@ObservedObject var commandRunner: CommandRunner
Text(commandRunner.output)
commandRunner.clearOutput()
TerminalView(commandRunner: CommandRunner())
Terminal output
Ralhaqok eiytiw

Resizing Images

You’ve assembled a lot of the components of the app, so it’s finally time to resize some images. This requires a new SipsRunner method.

// 1
func resizeImage(
  picture: Picture,
  newWidth: String,
  newHeight: String,
  newFormat: PicFormat
) async -> URL? {
  // 2
  guard let sipsCommandPath = await checkSipsCommandPath() else {
    return nil
  }

  // 3
  let fileManager = FileManager.default
  let suffix = "-> \(newWidth) x \(newHeight)"
  var newURL = fileManager.addSuffix(of: suffix, to: picture.url)
  newURL = fileManager.changeFileExtension(
    of: newURL,
    to: newFormat.rawValue
  )

  // 4
  let args = [
    "--resampleHeightWidth", newHeight, newWidth,
    "--setProperty", "format", newFormat.rawValue,
    picture.url.path,
    "--out", newURL.path
  ]

  // 5
  _ = await commandRunner.runCommand(sipsCommandPath, with: args)

  // 6
  return newURL
}
@EnvironmentObject var sipsRunner: SipsRunner
// 1
guard let picture = picture else {
  return
}

// 2
imageURL = await sipsRunner.resizeImage(
  picture: picture,
  newWidth: picWidth,
  newHeight: picHeight,
  newFormat: picFormat)

The Mac Sandbox Again

It looks like everything is in place, but wait just one moment… There’s a problem with the Mac sandbox.

Turn off the sandbox.
Wofs ofy dpa fegdbec.

Resizing an image.
Wubaveck av ukiku.

Locking the Aspect Ratio

If you’ve used SwiftUI’s Image view, you’ll be familiar with aspect ratios. When displaying an image in SwiftUI, you set .aspectRatio(contentMode: .fit) or .aspectRatio(contentMode: .fill) to make it look right.

Resizing in Preview
Kusozicy ob Fhuvaat

Focusing on Edit Fields

Start by opening ImageEditControls.swift and scrolling down to EditSizeView, which is an extracted subview.

@FocusState private var widthFieldHasFocus: Bool
@FocusState private var heightFieldHasFocus: Bool
VStack {
  HStack {
    Text("Width:").frame(width: 50)
    TextField("", text: $picWidth)
      .focused($widthFieldHasFocus)   // NEW
      .frame(maxWidth: 60)
  }
  HStack {
    Text("Height:").frame(width: 50)
    TextField("", text: $picHeight)
      .focused($heightFieldHasFocus)   // NEW
      .frame(maxWidth: 60)
  }
}
// 1
.onChange(of: picWidth) { newValue in
  // 2
  if widthFieldHasFocus {
    // 3
    adjustAspectRatio(newWidth: newValue, newHeight: nil)
  }
}
// 4
.onChange(of: picHeight) { newValue in
  if heightFieldHasFocus {
    adjustAspectRatio(newWidth: nil, newHeight: newValue)
  }
}
Locked aspect ratio.
Gunser adrufj lefei.

Creating Thumbnails

When you’re running sips from Terminal, it can batch process files using wild cards. Look at this command:

sips --resampleHeight 600 *.png --out resized_images
let openPanel = NSOpenPanel()
openPanel.message = "Select the thumbnails folder:"

// 1
openPanel.canCreateDirectories = true
openPanel.canChooseDirectories = true
openPanel.canChooseFiles = false
openPanel.allowsMultipleSelection = false

openPanel.begin { response in
  if response == .OK, let url = openPanel.url {
    // 2
    Task {
      await createThumbs(in: url)
    }
  }
}

Adding a New sips Command

With this in place, you now need to supply the code to create the thumbnails, so open SipsRunner.swift and add this:

// 1
func createThumbs(
  in folder: URL,
  from imageURLs: [URL],
  maxDimension: String
) async {
  // 2
  guard let sipsCommandPath = await checkSipsCommandPath() else {
    return
  }

  // 3
  for imageURL in imageURLs {
    let args = [
      "--resampleHeightWidthMax", maxDimension,
      imageURL.path,
      "--out", folder.path
    ]

  // 4
    _ = await commandRunner.runCommand(sipsCommandPath, with: args)
  }
}

Calling the New Command

Go back to ThumbControls.swift and start by adding the EnvironmentObject to give it access to sipsRunner:

@EnvironmentObject var sipsRunner: SipsRunner
// 1
await sipsRunner.createThumbs(
  in: folder,
  from: imageURLs,
  maxDimension: maxDimension)

// 2
outputFolder = folder

// 3
showAlert = true

Showing an Alert

When you edit an image, the new image appears in the edit view. This shows the user the edit has worked. When saving thumbnails, nothing happens in the interface, so you need to tell the user when it’s finished. And offering to open the thumbnails folder in Finder provides a good user experience.

// 1
.alert(Text("Thumbnails created"), isPresented: $showAlert) {
  // 2
  if let outputFolder = outputFolder {
    // 3
    Button("Show in Finder") {
      NSWorkspace.shared.selectFile(
        outputFolder.path,
        inFileViewerRootedAtPath: "")
    }
  }

  // 4
  Button("OK") {}
} message: {
  // 5
  Text("\(imageURLs.count) thumbnails have been created.")
}
Thumbnail image.
Ctuyhkuex ugaxa.

Challenge

Challenge: Create your app icon using ImageSipper

In the downloaded assets for this chapter, open the app icon folder. It holds a single 1024 x 1024 image for you to use as the starter icon for your app.

Key Points

  • You previously used NSSavePanel to open a file dialog for saving files. NSOpenPanel is similar, but it’s used to select files or folders.
  • Drag and drop works well in SwiftUI. Getting the dropped data depends on what your drop accepts.
  • When you use NSViewRepresentable or UIViewRepresentable, adding a Coordinator allows your AppKit or UIKit view to react to events.
  • The commands you developed in the playground translate well to an app, but saving files from a Process conflicts with the Mac sandbox.
  • You can track the active edit field using focused and @FocusState.
  • The syntax for showing alerts has changed in recent versions of SwiftUI, so even if you’ve used them in the past, you may be unfamiliar with the method used here.

Where to Go From Here?

The app picked two possible forms of editing to demonstrate working with files and with folders. You may have a completely different use case for the sips command. Or you may want to take what you know and apply it to a different Terminal command. You now have all the tools you need to do either of those.

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.

© 2022 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.