Skip to content

ryanlintott/ShapeUp

Repository files navigation

ShapeUp Logo

Swift Compatibility Platform Compatibility License - MIT Version GitHub last commit Mastodon Twitter

Overview

A Swift Package that makes SwiftUI shapes easier to make by redefining them as an array of styled corners. (The logo above was created in 100 lines + SwiftUI Text)

Features:

Demo App

The Example folder has an app that demonstrates the features of this package.

Installation and Usage

This package is compatible with iOS 14+, macOS 11+, watchOS 7+, tvOS 14+, and visionOS.

  1. In Xcode go to File -> Add Packages
  2. Paste in the repo's url: https://github.com/ryanlintott/ShapeUp and select by version.
  3. Import the package using import ShapeUp

Is this Production-Ready?

Really it's up to you. I currently use this package in my own Old English Wordhord app.

Additionally, if you find a bug or want a new feature add an issue and I will get back to you about it.

Support This Project

ShapeUp is open source and free but if you like using it, please consider supporting my work.

ko-fi

Or you can buy a t-shirt with the ShapeUp logo

ShapeUp T-Shirt


Features

Corner

A point with a specified CornerStyle used to draw paths and create shapes.

Corner(.rounded(radius: 5), x: 0, y: 10)

Corners store no information about their orientation or where the previous and next points are located. When they're put into an array their order is assumed to be their drawing order. This means you can generate a Path from this array. By default this path is assumed to be closed with the last point connecting back to the first.

[
    Corner(.rounded(radius: 10), x: 0, y: 0),
    Corner(.cutout(radius: 5), x: 10, y: 0),
    Corner(.straight(radius: 5), x: 5, y: 10)
].path()

CornerStyle

An enum storing style information for a Corner. In all cases, the radius is a RelatableValue that can either be an absolue value or relative to the length of the shortest line from that corner.

Pink triangle with a point corner .point A simple point corner with no properties.

Pink triangle with a rounded corner .rounded(radius: RelatableValue) A rounded corner with a radius.

Pink triangle with a concave cut corner .concave(radius: RelatableValue, radiusOffset: CGFloat) A concave corner where the radius determines the start and end points of the cut and the radius offeset is the difference between the concave radius and the radius. The radiusOffset is mainly used when insetting a concave corner and is often left with the default value of zero.

Pink triangle with a straight cut corner .straight(radius: RelatableValue, cornerStyles: [CornerStyle] = []) A straight chamfer corner where the radius determines the start and end points of the cut. Additional cornerstyles can be used on the two resulting corners of the chamfer. (You can continue nesting recursively.)

Pink triangle with a cutout corner .cutout(radius: RelatableValue, cornerStyles: [CornerStyle] = []) A cutout corner where the radius determines the start and end points of the cut. Additional cornerstyles can be used on the three resulting corners of the cut. (Again, you can continue nesting recursively.)

Basic Shapes

CornerRectangle, CornerTriangle, and CornerPentagon are pre-built shapes where you can customize the style of any corner.

Examples:

CornerRectangle([
    .topLeft: .straight(radius: 60),
    .topRight: .cutout(radius: .relative(0.2)),
    .bottomRight: .rounded(radius: .relative(0.8)),
    .bottomLeft: .concave(radius: .relative(0.2))
])
.fill()

CornerTriangle(
    topPoint: .relative(0.6),
    styles: [
        .top: .straight(radius: 10),
        .bottomRight: .rounded(radius: .relative(0.3)),
        .bottomLeft: .concave(radius: .relative(0.2))
    ]
)
.stroke()
    
CornerPentagon(
    pointHeight: .relative(0.3),
    topTaper: .relative(0.1),
    bottomTaper: .relative(0.3),
    styles: [
        .topRight: .concave(radius: 30),
        .bottomLeft: .straight(radius: .relative(0.3))
    ]
)
.fill()

CornerShape

A protocol for creating shapes built from an array of Corners. The path and inset functions needed to conform to SwiftUI InsettableShape are already implemented.

How to build a CornerShape

  • Set insetAbount zero (this property is used if the shape inset).
  • Set the closed property to define if your shape should be closed or left open.
  • Write a function that returns an array of corners.
public struct MyShape: CornerShape {
    public var insetAmount: CGFloat = .zero
    public let closed = true
   
    public func corners(in rect: CGRect) -> [Corner] {
        [
            Corner(x: rect.midX, y: rect.minY),
            Corner(.rounded(radius: 5), x: rect.maxX, y: rect.maxY),
            Corner(.rounded(radius: 5), x: rect.minX, y: rect.maxY)
        ]
    }
}

Using A CornerShape

A CornerShape can be used in SwiftUI Views the same way as RoundedRectangle or similar.

MyShape()
    .fill()

The corners can also be accessed directly for use in a more complex shape

public func corners(in rect: CGRect) -> [Corner] {
    MyShape()
        .corners(in: rect)
        .inset(by: 10)
        .addingNotch(Notch(.rectangle, depth: 5), afterCornerIndex: 0)
}

CornerCustom

Sometimes you might want to make a shape inline without defining a new struct. CornerCustom is a CornerShape that takes a closure that returns an array of Corners. The closure itself needs to be Sendable so that it can be used to generate a path for a SwiftUI Shape.

CornerCustom { rect in
    [
        Corner(x: rect.midX, y: rect.minY),
        Corner(.rounded(radius: 10), x: rect.maxX, y: rect.maxY),
        Corner(.rounded(radius: 10), x: rect.minX, y: rect.maxY)
    ]
}
.strokeBorder(lineWidth: 5)

Notch

Sometimes you want to cut a notch in the side of a shape. This can be tricky to do when the line is at an odd angle but Notch makes it easy. A Notch has a NotchStyle, position, length and depth.

The following code adds a rectangular notch between the second and third corner. The addingNotch() function makes all the necessary calculations to add the corners representing that notch into the Corner array.

let notch = Notch(.rectangle, position: .relative(0.5), length: .relative(0.2), depth: .relative(0.1))

let corners = corners.addingNotch(notch, afterCornerIndex: 1)

NotchStyle

Two basic styles are .triangle and .rectangle and both allow customization of the corner styles for the 2 or 3 resulting notch corners.

/// Specify styles for each corner
let notch1 = .triangle(cornerStyles: [.rounded(radius: 10), .point, .straight(radius: 5)])
/// Or specify one style for all
let notch2 = .rectangle(cornerStyle: .rounded(radius: .relative(0.2))

There is also a .custom notch style that takes a closure that returns an array of Corners based on a CGRect that matches the size and orientation of the notch.

let notch = .custom { rect in
    [
        Corner(x: rect.minX, y: rect.minY),
        Corner(x: rect.minX, y: rect.midY),
        Corner(.rounded(radius: 15), x: rect.midX, y: rect.maxY),
        Corner(x: rect.maxX, y: rect.midY),
        Corner(x: rect.maxX, y: rect.minY)
    ]
}

Add CornerShape

Shapes made completely with corners have their limitations. Only straight lines and arcs are possible. If you want to use corners to draw only a portion of your shape you can do that too with .addOpenCornerShape() and .addClosedCornerShape() functions added to Path

Vector2

A vector type used as an alternative to CGPoint that conforms to all the Vector2 protocols.

Vector2Representable

A protocol that adds the vector: Vector2 property. Vector2, CGPoint, and Corner all conform to this and it's required for any other Vector2 protocols.

Other properties and methods:

point: CGPoint
corner(_ style:) -> Corner

Array extensions:

points: [CGPoint]
vectors: [Vector2]
corners(_ style: CornerStyle?) -> [Corner]
corners(_ styles: [CornerStyle?]) -> [Corner]
bounds: CGRect
center: CGPoint
anchorPoint(_ anchor: RectAnchor) -> CGPoint
angles: [Angle]

Vector2Algebraic

A protocol that adds vector math. Only applied to Vector2 by default but can be added to any other Vector2Representable if need be.

Functions include: magnitude, direction, normalized, addition, subtraction, and multiplication or division with scalars.

Vector2Transformable

A protocol that adds transformation functions (move, rotate, flip, inset) to any Vector2Representable or array of that type. Applied to Vector2, CGPoint, and Corner.

RectAnchor

An enum to indicate one of 9 anchor locations on a rectangle. It's primarily used to quickly get CGPoint values from CGRect

// Current method
let point = CGPoint(x: rect.minX, y: rect.minY)
// ShapeUp method
let point = rect.point(.topLeft)

This is especially helpful when getting an array of points

// Current method
let points = [
    CGPoint(x: rect.minX, y: rect.midY),
    CGPoint(x: rect.midX, y: rect.midY),
    CGPoint(x: rect.maxX, y: rect.maxY)
]
// ShapeUp method
let points = rect.points(.left, .center, .bottomRight)

RelatableValue

A handy enum that represents either a relative or absolute value. This is used in lots of situations throughout ShapeUp to give flexibility when defining parameters.

When setting a corner radius you might want a fixed value like 20 or you might want a value that's 20% of the maximum so that it will scale proportionally. RelatableValue gives you both of those options.

let absolute = RelatableValue.absolute(20)
let relative = RelatableValue.relative(0.2)

Later, the value is determined by running the value(using total:) function. Absolute values will always be the same but any relative values will be calculated. In the case of a corner radius, the total would be the maximum radius that would fit that corner given the length of the two lines and the angle of the corner.

let radius = relatableRadius.value(using: maxRadius)

For ease of use, RelatableValue conforms to ExpressibleByIntegerLiteral and ExpressibleByFloatLiteral. This means in many cases you can omit .absolute() when writing absolute values.

// Both are the same
let cornerStyle = .rounded(radius: .absolute(5))
let cornerStyle = .rounded(radius: 5)

SketchyLine

A animatable line Shape with ends that can extend and a position that can offset perpendicular to its direction.

image

Text("Hello World")
    .alignmentGuide(.bottom) { d in
        // moves bottom alignment to text baseline
        return d[.firstTextBaseline]
    }
    .background(
        SketchyLines(lines: [
            .leading(startExtension: -2, endExtension: 10),
            .bottom(startExtension: 5, endExtension: 5, offset: .relative(0.05))
        ], drawAmount: 1)
            .stroke(Color.red)
        , alignment: .bottom
    )

Emboss or Deboss

Extensions for InsettableShape and View that create an embossed or debossed effect.

image

AnimatablePack

*Xcode 16+, iOS 17+, macOS 14+, watchOS 10+, tvOS 17+

Animate lots of properties in a Shape using AnimatablePack instead of nesting AnimatablePair types

Here is an example of animatableData using AnimatablePair:

struct MyShape: Animatable {
    var animatableData: AnimatablePair<CGFloat, AnimatablePair<RelatableValue, Double>> {
        get { AnimatablePair(insetAmount, AnimatablePair(cornerRadius, rotation)) }
        set {
            insetAmount = newValue.first
            cornerRadius = newValue.second.first
            rotation = newValue.second.second
        }
    }
}

You can see how it would get quite large once you start adding more than a few properties. Here's how to use AnimatablePack instead:

struct MyShape: Animatable {
    var animatableData: AnimatablePack<CGFloat, RelatableValue, Double> {
        get { AnimatablePack(insetAmount, cornerRadius, rotation) }
        set { (insetAmount, cornerRadius, rotation) = newValue() }
    }
}