## SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

# 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 is the same as the challenge project from the previous chapter, with the exception that, just for a change, your project now contains preview data of giraffes rather than hedgehogs.

## Shapes

Skills you’ll learn in this section: predefined shapes

``````static var previews: some View {
VStack {
Rectangle()
Circle()
Capsule()
Ellipse()
}
}
``````

## Paths

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

``````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_Previews: PreviewProvider {
static let currentShape = Triangle()

static var previews: some View {
currentShape
.background(Color.yellow)
}
}
``````

``````func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
var path = Path()
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)
``````

``````.previewLayout(.sizeThatFits)
``````

### 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)
center: CGPoint(x: rect.midX, y: rect.midY),
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 180),
clockwise: true)
``````
``````static let currentShape = Cone()
``````

``````path.addLine(to: CGPoint(x: rect.midX, y: rect.height))
path.addLine(to: CGPoint(x: rect.midX + radius, y: rect.midY))
path.closeSubpath()
``````

### 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
}
}
``````

``````path.move(to: CGPoint(x: 0, y: rect.midY))
to: CGPoint(x: rect.width, y: rect.midY),
control: CGPoint(x: rect.midX, y: 0))
to: CGPoint(x: 0, y: rect.midY),
control: CGPoint(x: rect.midX, y: rect.height))
path.closeSubpath()
``````
``````static let currentShape = Lens()
``````

## Strokes and Fills

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

``````.stroke(lineWidth: 5)
``````

### 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]))
``````

``````.stroke(
Color.primary,
style: StrokeStyle(lineWidth: 10, lineJoin: .round))
``````

## Selecting an Element

Skills you’ll learn in this section: borders; clip shapes

``````@Published var selectedElement: CardElement?
``````
``````.onTapGesture {
store.selectedElement = element
}
``````
``````.onTapGesture {
store.selectedElement = nil
}
``````
``````.onDisappear {
store.selectedElement = nil
}
``````
``````func isSelected(_ element: CardElement) -> Bool {
store.selectedElement?.id == element.id
}
``````
``````.border(
Settings.borderColor,
width: isSelected(element) ? Settings.borderWidth : 0)
``````

### Clip Shapes Modal

Now that you can select an element, you’ll apply a clip shape selected from frames displayed on a modal view.

``````enum Shapes {
}
``````
``````static let shapes: [Shape] = [Circle(), Rectangle()]
``````
``````Use of protocol 'Shape' as a type must be written 'any Shape'
``````

## 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 `View`s 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"))
]
``````

### A Type Erased Array

➤ In Shapes.swift, add a new property to `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 FrameModal: View {
@Environment(\.dismiss) var dismiss
// 1
@Binding var frameIndex: Int?
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)
// 5
.onTapGesture {
frameIndex = index
dismiss()
}
}
}
}
}
}
``````
``````struct FrameModal_Previews: PreviewProvider {
static var previews: some View {
FrameModal(frameIndex: .constant(nil))
}
}
``````

## Adding the Frame Picker Modal to the Card

Skills you’ll learn in this section: conditional modifiers; disabling button

``````@EnvironmentObject var store: CardStore
@State private var frameIndex: Int?
``````
``````case .frameModal:
FrameModal(frameIndex: \$frameIndex)
.onDisappear {
if let frameIndex {
card.update(
store.selectedElement,
frameIndex: frameIndex)
}
frameIndex = nil
}
``````

### Adding the Frame to the Card Element

➤ Open CardElement.swift in the Model group and add a new property to `ImageElement`:

``````var frameIndex: Int?
``````
``````mutating func update(_ element: CardElement?, frameIndex: Int) {
if let element = element as? ImageElement,
let index = element.index(in: elements) {
var newElement = element
newElement.frameIndex = frameIndex
elements[index] = newElement
}
}
``````
``````.clipShape(Shapes.shapes[0])
``````

### Conditional Modifiers using @ViewBuilder

You applied `.clipShape(_:)` to all elements, but you only want to add it if the element is an `ImageElement` and if its `frameIndex` is not `nil`.

``````// 1
private extension ImageElementView {
// 2
@ViewBuilder
func clip() -> some View {
// 3
if let frameIndex = element.frameIndex {
// 4
let shape = Shapes.shapes[frameIndex]
self
.clipShape(shape)
} else { self }
}
}
``````
``````ImageElementView(element: element)
.clip()
``````

``````.contentShape(shape)
``````

### Disabling the Frames Button

It doesn’t make sense to show the list of clip frames unless you have selected an element ready for clipping. So, until you select an element, the Frames button should be disabled.

``````@EnvironmentObject var store: CardStore
``````
``````.environmentObject(CardStore())
``````
``````func defaultButton(_ selection: ToolbarSelection) -> some View {
Button {
modal = selection
} label: {
ToolbarButton(modal: selection)
}
}
``````
``````case .frameModal:
defaultButton(selection)
.disabled(
store.selectedElement == nil
|| !(store.selectedElement is ImageElement))
default:
defaultButton(selection)
``````

## Challenges

### Challenge 1: Create new Shapes

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

### 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 overcome this, you’ll replace the border with the stroked frame in an overlay.

## 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`.
• `Path`s 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`.
• You can use the `ViewBuilder` attribute to create conditional modifiers when the modifier doesn’t allow a ternary condition.
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