Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

18. Paths & Custom Shapes
Written by Caroline Begbie

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

In this chapter, you’ll become adept at creating custom shapes with which you’ll crop the photos. You’ll tap a photo on the card, which enables the Frames button. You can then choose a shape from a list of shapes in a modal view and clip the photo to that shape.

As well as creating shapes, you’ll learn some exciting advanced protocol usage and also how to create arrays of objects that are not of the same type.

The starter project

The starter project moves on from hedgehogs to giraffes.

A new published property in ViewState, called selectedElement, holds the currently selected element. In CardDetailView, tapping an element updates selectedElement and CardElementView shows a border around the selected element.

In CardBottomToolbar, the Frames button is disabled when selectedElement is nil, but enabled when you tap an element. Tapping the background color deselects the element and disables Frames again.

Currently when you tap Frames, a modal pops up with an EmptyView. You’ll replace this modal view with a FramePicker view where you’ll be able to select a shape.

➤ Build and run the project to see the changes.

A selected element enables the Frames button
A selected element enables the Frames button

Shapes

Skills you’ll learn in this section: predefined shapes

var body: some View {
  VStack {
    Rectangle()
    RoundedRectangle(cornerRadius: 25.0)
    Circle()
    Capsule()
    Ellipse()
  }
  .padding()
}
Five predefined shapes
Cobi jcazoqerer wkamak

Paths

Skills you’ll learn in this section: paths; lines; arcs; quadratic curves

Triangle
Ktoiryde

struct Triangle: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    return path
  }
}

Lines

➤ Create a triangle with the same coordinates as in the diagram above. Add this to path(in:) before return path:

//1 
path.move(to: CGPoint(x: 20, y: 30))
// 2
path.addLine(to: CGPoint(x: 130, y: 70))
path.addLine(to: CGPoint(x: 60, y: 140))
// 3
path.closeSubpath()
struct Shapes: View {
  let currentShape = Triangle() 

  var body: some View {
    currentShape
      .background(Color.yellow)
  }
}
Triangle Shape
Qhuedqsa Stele

Fixed Triangle
Sofoc Kdaijhjo

func path(in rect: CGRect) -> Path {
  let width = rect.width
  let height = rect.height
  var path = Path()
  path.addLines([
    CGPoint(x: width * 0.13, y: height * 0.2),
    CGPoint(x: width * 0.87, y: height * 0.47),
    CGPoint(x: width * 0.4, y: height * 0.93)
  ])
  path.closeSubpath()
  return path
}
currentShape
  .aspectRatio(1, contentMode: .fit)
  .background(Color.yellow)
Resizable Triangle
Rusikochi Pguocgxi

.previewLayout(.sizeThatFits)
Resized preview
Fedovon trimeup

Arcs

Another useful path component is an arc.

struct Cone: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    // path code goes here
    return path
  }
}
let radius = min(rect.midX, rect.midY)
path.addArc(
  center: CGPoint(x: rect.midX, y: rect.midY),
  radius: radius,
  startAngle: Angle(degrees: 0),
  endAngle: Angle(degrees: 180),
  clockwise: true)
let currentShape = Cone()
The arc
Mma exf

Describe an arc
Qoymroso ac olh

path.addLine(to: CGPoint(x: rect.midX, y: rect.height))
path.addLine(to: CGPoint(x: rect.midX + radius, y: rect.midY))
path.closeSubpath()
The completed cone
Pve rirbkufut zasi

Curves

As well as lines and arcs, you can add various other standard elements to a path, such as rectangles and ellipses. With curves, you can create any custom shape you want.

struct Lens: Shape {
  func path(in rect: CGRect) -> Path {
    var path = Path()
    // path code goes here
    return path
  }
}
Quadratic curve
Taiknulib qubbo

path.move(to: CGPoint(x: 0, y: rect.midY))
path.addQuadCurve(
  to: CGPoint(x: rect.width, y: rect.midY),
  control: CGPoint(x: rect.midX, y: 0))
path.addQuadCurve(
  to: CGPoint(x: 0, y: rect.midY),
  control: CGPoint(x: rect.midX, y: rect.height))
path.closeSubpath()
let currentShape = Lens()
Lens shape
Hoyx kfeho

Strokes and fills

Skills you’ll learn in this section: stroke; stroke style; fill

Stroke and fill
Zlruli abx narv

.stroke(lineWidth: 5)
Stroke
Ypsuhi

Stroke style

When you define a stroke, instead of giving it a lineWidth, you can give it a StrokeStyle instance.

currentShape
  .stroke(style: StrokeStyle(dash: [30, 10]))
StrokeStyle with dash
QgrihaVzcbo jegq rucn

Line caps
Nobo xotw

.stroke(
  Color.primary, 
  style: StrokeStyle(lineWidth: 10, lineJoin: .round))
.padding()
Line join
Bana riaf

Clip shapes modal

You’ve now created a few shapes and feel free to experiment with more. The challenge for this chapter will suggest a few shapes for you to try.

static let shapes: [Shape] = [Circle(), Rectangle()]
Protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements.

Associated types

Skills you’ll learn in this section: protocols with associated types; type erasure

Swift Dive: Protocols with associated types

Protocols with associated types (PATs) are advanced black magic Swift and, if you haven’t done much programming with generics, the subject will take some time to learn and absorb. Apple APIs use them everywhere, so it’s useful to have an overview.

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}
struct ContentView: View {
  var body: some View {
    EmptyView()
  }
}
protocol CardElement {
  var id: UUID { get }
  var transform: Transform { get set }
}
protocol CardElement {
  associatedtype ID
  var id: ID { get }
  var transform: Transform { get set }
}
struct NewElement: CardElement {
  let id = Int.random(in: 0...1000)
  var transform = Transform()
}
static let shapes: [Shape] = [Circle(), Rectangle()]

Type erasure

You are able to place different Views in an array by converting the View type to AnyView:

// does not compile
let views: [View] = [Text("Hi"), Image("giraffe")]  
// does compile
let views: [AnyView] = [
  AnyView(Text("Hi")), 
  AnyView(Image("giraffe"))
]
import SwiftUI

struct AnyShape: Shape {
  func path(in rect: CGRect) -> Path {
  }
}
private let path: (CGRect) -> Path
// 1
init<CustomShape: Shape>(_ shape: CustomShape) {
  // 2
  self.path = { rect in
    // 3
    shape.path(in: rect)
  }
}
path(rect)

A type erased array

➤ In Shapes.swift, add a new extension to Shapes:

extension Shapes {
  static let shapes: [AnyShape] = [
    AnyShape(Circle()), AnyShape(Rectangle()),
    AnyShape(Cone()), AnyShape(Lens())
  ]
}

Shape selection modal

Now that you have all your shapes in an array, you can create a selection modal, just as you did for your stickers.

struct FramePicker: View {
  @Environment(\.presentationMode) var presentationMode
  
  // 1 
  @Binding var frame: AnyShape?
  private let columns = [
    GridItem(.adaptive(minimum: 120), spacing: 10)
  ]
  private let style = StrokeStyle(
    lineWidth: 5,
    lineJoin: .round)

  var body: some View {
    ScrollView {
      LazyVGrid(columns: columns) {
      // 2
        ForEach(0..<Shapes.shapes.count, id: \.self) { index in
          Shapes.shapes[index]
          // 3
            .stroke(Color.primary, style: style)
            // 4
            .background(
              Shapes.shapes[index].fill(Color.secondary))
            .frame(width: 100, height: 120)
            .padding()
            // 5
            .onTapGesture {
              frame = Shapes.shapes[index]
              presentationMode.wrappedValue.dismiss()
            }
        }
      }
    }
    .padding(5)
  }
}
struct FramePicker_Previews: PreviewProvider {
  static var previews: some View {
    FramePicker(frame: .constant(nil))
  }
}
Shapes Listing
Hyizuy Colhaym

Add the frame picker modal to the card

➤ Open CardDetailView.swift and add a new property:

@State private var frame: AnyShape?
case .framePicker:
  FramePicker(frame: $frame)
    .onDisappear {
      if let frame = frame {
        card.update(
          viewState.selectedElement, 
          frame: frame)
      }
      frame = nil
    }

Add the frame to the card element

➤ Open CardElement.swift and add a new property to ImageElement:

var frame: AnyShape?
mutating func update(_ element: CardElement?, frame: AnyShape) {
  if let element = element as? ImageElement,
    let index = element.index(in: elements) {
      var newElement = element
      newElement.frame = frame
      elements[index] = newElement
  }
}

Add a modifier conditionally

➤ In ImageElementView, rename body to bodyMain.

var body: some View {
  if let frame = element.frame {
    bodyMain
      .clipShape(frame)
  } else {
    bodyMain
  }
}
Clipped giraffe
Mmehtef zesayda

Challenges

Challenge 1: Create new shapes

Practice creating new shapes and place them in the frame picker modal. Here are some suggestions:

Try these shapes
Bnc qfaro hmituy

Challenge 2: Clip the selection border

Currently, when you tap an image, it gets a rectangular border around it. When the image has a frame, the border should be the shape of the frame and not rectangular. To achieve this, you’ll replace the border with the stroked frame in an overlay.

A selected giraffe
E bepuszuc guwekci

Key points

  • The Shape protocol provides an easy way to draw a 2D shape. There are some built-in shapes, such as Rectangle and Circle, but you can create custom shapes by providing a Path.
  • Paths are the outline of the 2D shape, made up of lines and curves.
  • A Shape fills by default with the primary color. You can override this with the fill(_:style:) modifier to fill with a color or gradient. Instead of filling the shape, you can stroke it with the stroke(_:lineWidth:) modifier to outline the shape with a color or gradient.
  • With the clipShape(_:style:) modifier, you can clip any view to a given shape.
  • Associated types in a protocol make a protocol generic, making the code reusable. Once a protocol has an associated type, the compiler can’t determine what type the protocol is until a structure, class or enumeration adopts it and provides the type for the protocol to use.
  • Using type erasure, you can hide the type of an object. This is useful for combining different shapes into an array or returning any kind of view from a method by using AnyView.
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