lukecsmith.co.uk

Drawing a Shape with Paths and Arcs in SwiftUI

Luke, 16 Feb 2022

Ive just been spending a bit longer than should be necessary, creating a shape for the app Im working on currently. So I thought I'd share and clarify a few things for myself and anyone else. The shape looks like this:

The shape I made

Having started doing graphics coding with BBC Basic in the 80s, my brain has various different coordinate spaces in it (for eg .. 0,0 used to be bottom left of the screen in those days). So for clarity, the SwiftUI coordinate space works like this:

(0, 0) in SwiftUI is the top left of the screen. Positive increases in values for x and y move the position to the right, and downwards, respectively.

To create the path and fill it for the shape above, first I need a Shape object:

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

Notice that the path function above has a CGRect parameter. The size of that CGRect doesnt really matter - that is something that SwiftUI will decide when presenting the shape. For example, I could present the shape with a fixed frame, to set the width and height parameters of that CGRect. I could just fix one of those, eg the height, and SwiftUI will automatically make the object fill the width - etc. This makes the shape totally flexible, so I could present it very tall and narrow, or very wide and squashed, as I need to. So when drawing the path, I dont really deal here with set numbers of pixels, I just deal with fractions of the given params of the CGRect, ie the width and the height.

So now to start actually drawing the shape. In my brain I see the shape starting in the bottom left corner, with an arc that covers that rounded top left corner of the box. So the first thing to do within my path above is to move to the bottom left corner, so I can begin the drawing (the move function here doesnt create an actual line).

path.move(to: CGPoint(x: 0, y: rect.height))

Setting the y parameter here as rect.height, means I go to the bottom of the box, bearing in mind that the larger the y param, the further down the box I am (coordinates starting in the top left).

Then I need an arc (or part of a circle) that goes up and to the right. Arcs are created with five parameters. First is the arc centre - ie if this was a full circle, then this point would be the centre of the circle. Next comes the radius value of the arc, and then the beginning and ending of the arc, in terms of degrees (or radians). Finally we specify whether to draw clockwise, or anti-clockwise.

In my head, angles are normally measured starting from the north position on the circle, top centre. But not here. You start counting the angle from the East position, so mid right. Theres also a further complication: clockwise is not actually clockwise! It is effectively anti-clockwise for our purposes. This caused me some confusion at first. For more info on why this is, see here.

So to draw the arc, starting from mid left position (West), and moving up to the top centre position (North), I need this:

Important to notice here that both x and y are set to rect.height. So Im at the bottom of the box, but not necessarily the bottom right of the box - i use the same value for both x and y.

let arcCentre = CGPoint(x: rect.height, y: rect.height)
path.addArc(center: arcCentre, 
            radius: rect.height, 
            startAngle: .degrees(180), 
            endAngle: .degrees(270), 
            clockwise: false)

To complete the box, I need to draw lines across, down and back to the origin of the shape. That looks like this:

// to top right
path.addLine(to: CGPoint(x: rect.width, y: 0))

// to bottom right
path.addLine(to: CGPoint(x: rect.width, y: rect.height))

// to bottom left
path.addLine(to: CGPoint(x: 0, y: rect.height))

And thats all you need for the Shape object. The complete code looks like this:

struct TopHandleBarBackgroundShape: Shape {
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        // to bottom left
        path.move(to: CGPoint(x: 0, y: rect.height))
        
        let arcCentre = CGPoint(x: rect.height, y: rect.height)
        path.addArc(center: arcCentre, radius: rect.height, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
         
        // to top right
        path.addLine(to: CGPoint(x: rect.width, y: 0))
        
        // to bottom right
        path.addLine(to: CGPoint(x: rect.width, y: rect.height))
        
        // to bottom left
        path.addLine(to: CGPoint(x: 0, y: rect.height))
        
        return path
    }
}

Finally you can present that in SwiftUI, like any other view. You can then use .fill as its a Shape object, eg:

TopHandleBarBackgroundShape()
    .fill(.red)
    .frame(height:50)

Ive set the height to 50 px, but not the width. So my shape will fill the space horizontally, but be no more than 50px high. This creates the final shape Im after.

Hope this quick run through is off use. Theres a few gotchas there which are easy to miss.

Tagged with: