UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Adding strokeBorder() support with InsettableShape

Paul Hudson    @twostraws   

If you create a shape without a specific size, it will automatically expand to occupy all available space. For example, this will create a circle that fills our view, giving it a 40-point blue border:

struct ContentView: View {
    var body: some View {
        Circle()
            .stroke(.blue, lineWidth: 40)
    }
}

Take a close look at the left and right edges of the border – do you notice how they are cut off?

What you’re seeing here is a side effect of the way SwiftUI draws borders around shapes. If you handed someone a pencil outline of a circle and asked them to draw over that circle with a thick pen, they would trace the exact line of the circle – about half the pen would be inside the line, and half outside. This is what SwiftUI is doing for us, but where our shapes go to the edge of the screen it means the outside part of the border ends up beyond our screen edges.

Now try using this circle instead:

Circle()
    .strokeBorder(.blue, lineWidth: 40)

That changes stroke() to strokeBorder() and now we get a better result: all our border is visible, because Swift strokes the inside of the circle rather than centering on the line.

Previously we built an Arc shape like this:

struct Arc: Shape {
    var startAngle: Angle
    var endAngle: Angle
    var clockwise: Bool

    func path(in rect: CGRect) -> Path {
        let rotationAdjustment = Angle.degrees(90)
        let modifiedStart = startAngle - rotationAdjustment
        let modifiedEnd = endAngle - rotationAdjustment

        var path = Path()
        path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)

        return path
    }
}

Just like Circle, that automatically takes up all available space. However, this kind of code won’t work:

Arc(startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: true)
    .strokeBorder(.blue, lineWidth: 40)

If you open Xcode’s error message you’ll see it says “Value of type 'Arc' has no member 'strokeBorder’” – that is, the strokeBorder() modifier just doesn’t exist on Arc.

There is a small but important difference between SwiftUI’s Circle and our Arc: both conform to the Shape protocol, but Circle also conforms to a second protocol called InsettableShape. This is a shape that can be inset – reduced inwards – by a certain amount to produce another shape. The inset shape it produces can be any other kind of insettable shape, but realistically it should be the same shape just in a smaller rectangle.

To make Arc conform to InsettableShape we need to add one extra method to it: inset(by:). This will be given the inset amount (half the line width of our stroke), and should return a new kind of insettable shape – in our instance that means we should create an inset arc. The problem is, we don’t know the arc’s actual size, because path(in:) hasn’t been called yet.

It turns out the solution is pretty simple: if we give our Arc shape a new insetAmount property that defaults to 0, we can just add to that whenever inset(by:) is called. Adding to the inset allows us to call inset(by:) multiple times if needed, for example if we wanted to call it once by hand then use strokeBorder().

First, add this new property to Arc:

var insetAmount = 0.0

Now give it this inset(by:) method:

func inset(by amount: CGFloat) -> some InsettableShape {
    var arc = self
    arc.insetAmount += amount
    return arc
}

Important: This is one of the very few places where we need to use CGFloat, which is an archaic form of Double that, somewhat bizarrely, wormed its way into SwiftUI. It gets used in many other places too, but mostly Swift lets us use Double instead!

The amount parameter being passed in should be applied to all edges, which in the case of arcs means we should use it to reduce our draw radius. So, change the addArc() call inside path(in:) to be this:

path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2 - insetAmount, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)

With that change we can now make Arc conform to InsettableShape like this:

struct Arc: InsettableShape {

Note: InsettableShape actually builds upon Shape, so there’s no need to add both there.

TAKE YOUR SKILLS TO THE NEXT LEVEL If you like Hacking with Swift, you'll love Hacking with Swift+ – it's my premium service where you can learn advanced Swift and SwiftUI, functional programming, algorithms, and more. Plus it comes with stacks of benefits, including monthly live streams, downloadable projects, a 20% discount on all books, and free gifts!

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.7/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.