Make Scalable SwiftUI Shapes With Relative X and Y Points

Daily Coding Tip 013

There are times when we want to position something relative to the bounds of a rectangle.

This is common in UIKit, where every UIView has a frame property we can easily access. This frame comes in the form of a CGRect, which has many properties of its own: minX, midX and maxX, as well as the corresponding properties in the Y direction.

A frame is not as easy to obtain in SwiftUI, but we can get one in two ways.

The Shape protocol

When you create a shape, the Shape protocol requires you to provide a function called

path(in rect: CGRect) -> Path

This gives you a CGRect that your shape needs to be drawn in, and you are required to create a Path within that area.

The View protocol

You can obviously use Path on its own anywhere in your Views. To do this, you simply need to use a GeometryReader, which will allow you a method that generates a CGRect for the frame in either local or global scope.

Accessing relative points

When I draw complex shapes, I need to use decimals that are relative to the width of the area. In other words, if I want to go to a point 80% of the way across the frame horizontally, I multiply 0.8 by the width. In order to do this frequently and in a way that’s easy to understand when you read it, I create an extension of CGRect that will do this in a pleasant way for me.

extension CGRect {
func xMultiplied(by multiplier: CGFloat) -> CGFloat {
return self.width * multiplier
}
func yMultiplied(by multiplier: CGFloat) -> CGFloat {
return self.height * multiplier
}
}
view raw xMultiplied.swift hosted with ❤ by GitHub

Now when I want to get a relative value I simply use these methods on the frame in question.

For my shape, I’ve chosen a letter E, as this is a good example of a shape that needs relative positions for its points. If we were using fixed values, such as minX + 20, these would not scale when we scale our shape. This is why relative sizes and positions are so important.

extension CGRect {
/// Only necessary for my specific example
/// - Returns: The points we need to create the shape
var eShapePoints: [CGPoint] {
[CGPoint(x: xMultiplied(by: 0), y: yMultiplied(by: 0)),
CGPoint(x: xMultiplied(by: 0), y: yMultiplied(by: 1)),
CGPoint(x: xMultiplied(by: 1), y: yMultiplied(by: 1)),
CGPoint(x: xMultiplied(by: 1), y: yMultiplied(by: 0.8)),
CGPoint(x: xMultiplied(by: 0.2), y: yMultiplied(by: 0.8)),
CGPoint(x: xMultiplied(by: 0.2), y: yMultiplied(by: 0.6)),
CGPoint(x: xMultiplied(by: 0.8), y: yMultiplied(by: 0.6)),
CGPoint(x: xMultiplied(by: 0.8), y: yMultiplied(by: 0.4)),
CGPoint(x: xMultiplied(by: 0.2), y: yMultiplied(by: 0.4)),
CGPoint(x: xMultiplied(by: 0.2), y: yMultiplied(by: 0.2)),
CGPoint(x: xMultiplied(by: 1), y: yMultiplied(by: 0.2)),
CGPoint(x: xMultiplied(by: 1), y: yMultiplied(by: 0))]
}
}
struct EShapeView: View {
var body: some View {
GeometryReader { geometry in
let points = geometry.frame(in: .global).eShapePoints
Path {
path in
path.move(to: points[0])
for point in points {
path.addLine(to: point)
}
}
}
.frame(width: 50, height: 50)
}
}
struct EShape: Shape {
func path(in rect: CGRect) -> Path {
let points = rect.eShapePoints
return Path {
path in
path.move(to: points[0])
for point in points {
path.addLine(to: point)
}
}
}
}
view raw EShapeView.swift hosted with ❤ by GitHub

At the top I've included another extension of CGRect that gives me the points for my letter E. This probably isn’t the best practice, I’m just doing it to save space so that I can provide both a View and Shape example.

The procedure for creating the Path is roughly the same, but the Shape is provided a CGRect and the View has to create one using a GeometryReader.

Happy New Year 2021!


Get more Daily Coding Tips in your inbox!