Skip to content

Latest commit

 

History

History
356 lines (251 loc) · 16.9 KB

extensions.md

File metadata and controls

356 lines (251 loc) · 16.9 KB

The Path to Success

Natively, working with paths is a time-consuming task that cannot be referred to as fun. PureSwiftUIDesign aims to turn that on its head by providing a huge number of extensions and utilities that make even the most intricate path-drawing tasks a joy.

Type Extensions

There are several aspects that go into making this a reality and it starts with extensions on the fundamental types used heavily in path creation, namely Path, CGRect, CGSize, CGPoint, CGVector, and Angle. A lot of time has been spent bringing these types into a sort of synergy with a consistent design language that flows naturally between types to the point where you don't have to know all the available extensions, but can reasonably guess how they hang together.

It has been the goal to remove as much calculation code as possible from the design of the Path itself and the result is a more declarative approach to creating paths where little to no explicit calculation needs to be written to create anything from simple rectangles all the way up to elaborate designs that would have previously resulted in fairly impenetrable code.

Allow me to headline a few of the most useful extensions to give you an idea of how they fit together:

Angle

Taking a cue from the UnitPoint struct, Angle now has constants represented by .top, .leading, .bottomTrailing etc pointing in the directions you would expect. It's important to note that throughout PureSwiftUIDesign clockwise means clockwise so you don't have to do any weird visualizations in your head. Even though this is a break from the standard CoreGraphics library, I believe the increase in clarity more than justifies this break from absolute consistency.

In addition to the directional constants, you can now specify a fraction of a rotation by using the .cycle(fraction) function which returns an Angle representing the multiple of a single rotation:

let threeQuarterRotation: Angle = .cycles(0.75)

As well as constants, you can also create angles fluently by just typing using the .degrees property available on all major types. This small changes makes a huge difference to readability and can of course be used anywhere in Swift.

let angleTop: Angle = .top // -90 degrees
let angleTop: Angle = .bottomLeading // 135 degrees
let angleCustom: Angle = .cycles(0.75) // 270 degrees
let quarterRotation = 90.degrees
let halfRotation = 0.5.cycles // 180 degrees
let twoRotations = 2.cycles // 720 degrees

With these at your disposal, it becomes quite natural to refer to angles in this way. Once you have an Angle you can call trigonometric functions directly on it, like so:

let sinAngle = 30.degrees.sin

So if you absolutely need to perform trigonometric calculations in situ, you can see how this kind of thing can clean up your code substantially.

CGRect

CGRect is a real workhorse when it comes to constructing paths. Consequentially, it has gotten a load of attention to bring the most useful properties to your fingertips. You can easily create regional, inset, and scaled versions of existing CGRect structs by using the many new functions and properties. When I spoke of synergy before, this is at play here since, like Angle, CGRect has the same UnitPoint inspired properties available on Angle.

To see this at work, let's say you wanted to create a CGRect struct that occupied the bottom right section of your original CGRect. This is how you would do it:

// native SwiftUI
let newRect = CGRect(origin: CGPoint(x: rect.midX, y: rect.midY), size: CGSize(width: rect.width * 0.5, height: rect.height * 0.5))
path.addRect(newRect)

// PureSwiftUIDesign
path.rect(rect.scaled(0.5, at: rect.center))

And you get this:

As you can see from the native SwiftUI implementation there's a stark difference in even this simple case. These differences really add up as the complexity of the design increases. Let's up the stakes a little and say we wanted to do the same thing, but center the resulting rectangle, like so:

In doing this you quickly realise that the intent of the code is more and more obfuscated by the calculations you need to perform as well as the numerous labels:

// native SwiftUI
let newRect = CGRect(
  origin: CGPoint(x: rect.minX + rect.width * 0.25, y: rect.minY + rect.height * 0.25), 
  size: CGSize(width: rect.width * 0.5, height: rect.height * 0.5))
path.addRect(newRect)

// PureSwiftUIDesign
path.rect(rect.scaled(0.5, at: rect.center), anchor: .center)

In PureSwiftUIDesign you just say what you want to do, not how to do it.

I'd like to give one more example to really drive this point home, and to do that we're going to create this:

In native SwiftUI this is a process that involves various small calculations for origin offsets so let's take a look at the comparison:

...
// native SwiftUI
let size = CGSize(width: rect.width * 0.25, height: rect.height * 0.25)
path.addRect(CGRect(origin: rect.origin, size: size))

let center = CGPoint(x: rect.midX, y: rect.midY)
let offsetForCenterRect = CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2)
path.addRect(CGRect(origin: offsetForCenterRect, size: size))

let bottomRight = CGPoint(x: rect.maxX, y: rect.maxY)
let offsetForBottomRight = CGPoint(x: bottomRight.x - size.width, y: bottomRight.y - size.height)
path.addRect(CGRect(origin: offsetForBottomRight, size: size))

// with PureSwiftUIDesign
let size = rect.sizeScaled(0.25)
path.rect(rect.origin, size)
path.rect(rect.center, size, anchor: .center)
path.rect(rect.bottomTrailing, size, anchor: .bottomTrailing)

Even is this relatively simple example, it's easy to get lost in the native SwiftUI code. In comparison I don't think it's overstating the point to say that the PureSwiftUIDesign version is practically WYSIWYG!In addition, as each CGRect struct is essentially its own coordinate system, they can be used to not only construct paths, but also to navigate the canvas.

Combining this capability to the power of layout guides opens the door to the true power of the PureSwiftUIDesign path construction framework.

Extracting points from CGRect

As I mentioned, in PureSwiftUIDesign CGRect has all the appropriate semantic constants associated with it. So you can obtain the top leading, center, and center of the trailing edge like this:

let topLeading = rect.topLeading
let center = rect.center
let trailing = rect.trailing

The are a couple of additional ways of extracting points from CGRect, the simplest and most powerful of whichway is to use the subscript on CGRect that takes two CGFloat arguments:

let topLeading = rect[0, 0]
let center = rect[0.5, 0.5]
let trailing = rect[1, 0.5]

which is how you can treat each rectangle as its own coordinate system.

You can also obtain x and y separately by using the relativeX and relativeY which also take the origin of the rectangle into account. These return the x or y position scaled along the width or height of the CGRect in question. So the same constants could be obtained like this:

let topLeading = CGPoint(rect.relativeX(0), rect.relativeY(0))
let center = CGPoint(rect.relativeX(0.5), rect.relativeY(0.5))
let trailing = CGPoint(rect.relativeX(1), rect.relativeY(0.5))

As you can see, there are many ways of interacting with the canvas of a rectangle reducing the need for in-place relative point calculations.

Insetting

CGRect structures can be inset in a variety of ways and they behave just like the native insets and padding in SwiftUI:

// inset entire rectangle by 10 points
let rect1 = rect.inset(10)

// inset horizontally
let rect2 = rect.hInset(10)

// inset vertically
let rect3 = rect.vInset(10)

// specific insets
let rect4 = rect.insetTop(5)
let rect5 = rect.inset([.top, .trailing], rect.halfWidth)

CGPoint

There are many ways of manipulating points within a canvas. A lot of attention has been given to the ability to derive points from other points in order to avoid the ubiquitous calculations usually associated with constructing paths. So, like with CGRect once you have a point it is simple to offset those points to the locations you require.

offset is the function you're going to be using most when working with points. Let's go over some of the most common techniques. The simplest is to offset a point by a two dimensional struct; CGVector, CGPoint, or CGSize.

// move the center of the canvas 100 points to the right and 50 points down
rect.center.offset(100, 50)
// or by supplying a vector, point or size:
rect.center.offset(CGSize(100, 50))
// or you can offset by half the size of the canvas:
rect.center.offset(rect.sizeScaled(0.5))

You can also restrict the offset to the x or y direction:

// offset the point by a tenth of width of the canvas
rect.center.xOffset(rect.widthScaled(0.1))

And if you really want to be fancy, you can offset a point by a radius and an angle:

for cycle in stride(from: 0, through: 0.9, by: 0.1) {
    let point = rect.center.offset(radius: 100, angle: .cycle(cycle))
    path.ellipse(point, .square(10), anchor: .center)
}

Which gives you:

I cover layout guides and their associated overlays here.

Feel free to browse the available extensions for CGPoint, CGVector, CGSize, and CGRect to see how they all tie-in to the architecture.

Static Initializers

For all the types mentioned, there are a handful of static initializers that make code cleaner and more descriptive. For example, it is not uncommon to want to offset something in just the X axis. Of course, You could use the various xOffset functions, but you could also pass a CGPoint with x value set to the value you want to use and with a y value set to 0. You can use static initializers to do this in a descriptive way:

// offset by 10 points in x
.offset(.x(10))
// offset vertically by 10 points
.offset(.y(10))
// or you could offset with a CGPoint like so
.offset(.point(20, 10))

So for CGPoint, CGVector, and CGSize you can use static initializers that are named after the properties of the structs in question. .dx, .dy, and .vector for CGVector, and .width, .height, and .size for CGSize. The advantage goes beyond just code clarity since you also benefit from faster code completion when using statics.

Path

Holding it all together is Path of course, and PureSwiftUIDesign provides a plethora of extensions to reduce friction between design and code all working hand in glove with the associated extensions for the CG structs previously mentioned. It's beneficial at this point to talk about the general approach to the API so you don't have to rely on knowing them all in order to reap the benefits:

Building Blocks

Anything that is described by a CGRect like rectangles, rounded-rectangles, and ellipses, will be described as having a containing CGRect or an origin and a size. And that's it. No argument labels since they don't add additional context. Any specializations like corner radius (for rounded rectangles) will come after this with argument labels. Next is the optional anchor argument followed by the transform that defaults to the .identity transform a la the native SwiftUI API. You can see this in action in the previous example.

Moving the Current Point and Drawing Lines

These operations have essentially matching APIs. When no argument label is provided, the argument is interpreted as the final destination for the movement or line drawn. For example, to move to the bottom trailing point of the canvas and then draw a line to the top trailing corner you would do the following:

move(rect.bottomTrailing)
line(rect.topTrailing)

// you can also do this in one call like so:
line(from: rect.bottomTrailing, to: rect.topTrailing)

In the second technique, argument labels indicate that the supplied arguments are not a destination, but something else described by those labels. You can, for example, use offsets to describe movement or to draw a line like this:

// you can also use CGVector or CGSize for these calls
move(offset: CGPoint(5, 10))
line(offset: CGPoint(100, 50))

which will use the current point for the path to extrapolate the destination. If you want to restrict movement in an axis, you can use the horizontal or vertical versions of these methods:

hMove(rect.center) // same result as: hMove(rect.center.x)
vLine(rect.top) // same result as: vLine(rect.top.y)

The at argument label can be used to draw lines at a specific location with a specific vector, or length and angle which can then be offset by an appropriate anchor point.

// line at 30 degrees centered on the center of the canvas
line(at: rect.center, length: 100, angle: 30.degrees, anchor: .center)
// a line along the left side of the canvas
vLine(at: rect.leading, length: rect.height, anchor: .center)

Shapes and Multiple Lines

There is also the capability to not only draw multiple lines at once, but also to add corner radii to where the points of the lines intersect. For example, you could use layout guides to capture the points of a star, and then draw the star by passing the points to the extension shape on Path which allows you to specify the corner radius for each point like so (as you can see I'm keeping the corner radius size agnostic by basing the value on a scaled value of the width of the CGRect):

private struct RoundedStar: Shape {
    
    func path(in rect: CGRect) -> Path {
        Path { path in

            let g = LayoutGuide.polar(rect, rings: [0.4, 1], segments: 10)
            
            var points: [CGPoint] = []
            
            for segment in 0..<g.yCount {
              // add each point to the array
                points.append(g[segment.isEven ? 1 : 0, segment])
            }
            
            // draw shape by passing point array and corner radius
            path.shape(points, cornerRadius: rect.widthScaled(0.03))
        }
    }
}

The result is as follows:

You can also pass an array of tuples of point with a corresponding corner radius so you're not restricted to a single corner radius per shape. For example, the following code:

private struct MyShape2: Shape {
    
    func path(in rect: CGRect) -> Path {
        Path { path in
        
            path.move(rect.top)
            
            path.shape(
                (rect.top, 10),
                (rect.bottomLeading, 30),
                (rect.bottomTrailing, 30)
            )
        }
    }
}

Produces the result:

You can also create partial shpaes with the lines extension on Path which works in a similar way to the shape extension but doesn't attempt to close the shape.

That covers the majority of the API, but I encourage you to check out the source if you want to know more.