Skip to content

Repository files navigation

ShapeUp Logo

Swift Compatibility Platform Compatibility License - MIT Version GitHub last commit Documentation Mastodon Bluesky

Overview

A Swift Package that makes SwiftUI shapes easier to build, transform, inset, and animate.

Create corners using relative anchor points, apply corner styles, move/rotate/scale/mirror them with ease, add notches along any edge, and wrap them in a CornerShape that is automatically insettable and animatable

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 15+, macOS 12+, watchOS 9+, tvOS 15+, and visionOS 1+.

  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

Documentation

Full API documentation is hosted on the Swift Package Index.

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

RectAnchor

RectAnchor references a relative location in a CGRect like .topLeft, .bottomRight, and .relative(x: 0.2, y: 0.8). It's similar to UnitPoint but it uses left and right instead of leading and trailing as it cannot respond to locale changes.

Inside the path(in:) method of a SwiftUI Shape, points often use positions relative to the rect parameter. ShapeUp adds a subscript on CGRect that uses RectAnchor to clarify this code.

func path(in rect: CGRect) -> Path {
    // Current method
    let point1 = CGPoint(x: rect.minX, y: rect.minY)
    let point2 = CGPoint(x: rect.minX + (rect.width * 0.4), y: rect.minY + (rect.height * 0.7))
    
    // ShapeUp method
    let point1 = rect[.topLeft]
    let point2 = rect[0.4, 0.7]
    ...
}

When you need several points, points(_:) accepts a RectAnchorArrayBuilder closure containing anchors or relative coordinate tuples.

let points = rect.points {
    .topLeft
    (0.4, 0.7)
    .bottomRight
}

CGPoint

CGPoint conforms to Vector2Transformable, so single points or arrays can be moved, rotated, flipped, scaled, or inset. Convert the transformed points to corners to create a Path that draws lines between them.

func path(in rect: CGRect) -> Path {
    [
        rect[.topLeft].moved(dx: 10),
        rect[.right],
        rect[0.7, 1.0]
    ]
    .moved(dx: 100, dy: 50)
    .rotated(.degrees(45), anchor: .center)
    .flipped(mirrorLineStart: .topLeft, mirrorLineEnd: .bottomLeft)
    .scaledPositions(2.5, anchor: .bottomRight)
    .insetPoints(5)
    .corners
    .path()
}

Corner

What if you wanted to turn those points into rounded corners? Just add a .rounded style!

func path(in rect: CGRect) -> Path {
    [
        rect[.topLeft].moved(dx: 10),
        rect[.right],
        rect[0.7, 1.0]
    ]
    .corners(.rounded(radius: 20))
    .path()
}

Adding a CornerStyle to an array of CGPoint changes it into an array of Corner. You can also apply different styles to individual corners.

func path(in rect: CGRect) -> Path {
    [
        rect[.topLeft]
            .moved(dx: 10)
            .rounded(radius: 20),
            
        rect[.right],
        
        rect[0.7, 1.0]
            .cutout(radius: .relative(0.4))
    ]
    .path()
}

Arrays of corners with styles can be awkward to format. Corners can build an array of corners using CornerArrayBuilder (similar to ViewBuilder) so you can omit all those commas. You can also include a Notch, which is added between the nearest corners before and after it. Corner lookup is cyclic, so a leading or trailing notch is added between the last and first corners.

func path(in rect: CGRect) -> Path {
    Corners {
        rect[.topLeft]
            .moved(dx: 10)
            .rounded(radius: 20)
            
        rect[.right]
        
        rect[0.7, 1.0]
            .cutout(radius: .relative(0.4))
    }
    .path()
}
let corners = Corners {
    rect[.topLeft]
    rect[.topRight]
    Notch(.triangle, length: 30, depth: 15)
    rect[.bottomRight]
    rect[.bottomLeft]
}

CornerStyle

Many different styles can be used on a Corner to define its shape.

.automatic The initial style for corners created without an explicit style. It renders as a point unless resolved by defaultCornerStyle(_:).

Pink triangle with a point corner .point An explicit point corner with no properties. Unlike .automatic, it is preserved when applying a default style.

Pink triangle with a rounded corner .rounded(radius: RelatableValue, style: CornerStyle.RoundingStyle = .circular) A rounded corner with a radius and .circular or .continuous rounding.

.continuous corners preserve the requested nominal radius with zero-curvature joins where the corner meets its edges. At 90 degrees its unconstrained profile is fitted to SwiftUI's rendered continuous rounded rectangle — a very close approximation, not an exact match — and at other angles its curvature distribution is generalized from there to preserve the same nominal radius. See .addContinuousCurve() if you want to draw the same curve directly on a Path.

Edge joins of a continuous corner sit farther from the corner point than a circular corner with the same radius, so on small or tightly packed shapes they can reach past a neighbouring corner's edge join and cause artifacts.

Pink triangle with a concave cut corner .concave(radius: RelatableValue, concaveInset: CGFloat = 0) A concave corner is like an inverted rounded corner where the radius determines the start and end points of the cut. The concave inset value is the inset of the concave radius and is automatically adjusted when insetting this corner.

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 corner styles 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 corner styles can be used on the three resulting corners of the cut. (Again, you can continue nesting recursively.)

Lastly, a custom corner uses the radius to determine the top left and bottom right corners of a rhombus in which you can add any number of relative corners to draw your shape.

.custom(radius: 20) {
    RelativeCorner.topLeft
    RelativeCorner.bottom.rounded(radius: .relative(0.4))
    RelativeCorner.topRight
}

Use cornerStyle(_:) to replace the style on an individual corner or selected shape corners. Use defaultCornerStyle(_:) on a value containing multiple corners to style automatic corners while preserving explicit styles.

CornerRectangle()
    .cornerStyle(.point, shapeCorner: .topRight)
    .defaultCornerStyle(.rounded(radius: 20))

Types conforming to CornerStylable implement transformCornerStyles(_:) once, which supplies defaultCornerStyle(_:). The narrower CornerStyled protocol supplies cornerStyle(_:) and changingRadius(to:) to Corner and RelativeCorner that contain a single style. Arrays of these values provide indexed style replacement, cornerStyles(_:), and the cornerStyles property. Transformations apply to each direct style contained by the value without separately visiting nested styles.

RelatableValue

The corner radius for any corner style uses a RelatableValue. This is a value that stores either an absolute or a relative value. This type is used in several other ShapeUp types and you can use it in your types to add a relative option to your 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 radius so that it will scale proportionally.

let cornerStyle1: CornerStyle = .rounded(radius: .absolute(20))
let cornerStyle2: CornerStyle = .rounded(radius: .relative(0.2))

RelatableValue conforms to ExpressibleByIntegerLiteral and ExpressibleByFloatLiteral. This means you can omit .absolute() when writing absolute values.

let cornerStyle1: CornerStyle = .rounded(radius: 20)
let cornerStyle2: CornerStyle = .rounded(radius: .relative(0.2))

Internally, the final value is determined by running the value(using total:) function. Absolute values are unchanged and relative values are calculated using the maximum radius that would fit the corner given the length of the two sides and the angle.

CornerShape

An alternative to SwiftUI Shape where shapes are built from an array of Corners. The resulting shape automatically conforms to InsettableShape with no additional work.

CornerShape only draws straight lines between its styled corners, so if you want Bezier curves between corners you will need to use Shape and addOpenCornerShape instead.

How to build a CornerShape

  • Set insetAmount to zero (this property is used to automatically inset the CornerShape).
  • The closed property determines if your path will close or be left open.
  • Write a method that returns an array of corners.
struct MyCornerShape: CornerShape {
    var insetAmount: CGFloat = .zero
    var closed = true
   
    func corners(in rect: CGRect) -> [Corner] {
        rect[.topLeft]
            .moved(dx: 10)
            .rounded(radius: 20)
            
        rect[.right]
        
        rect[0.7, 1.0]
            .cutout(radius: .relative(0.4))
    }
}

Using A CornerShape

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

MyCornerShape()
    .fill()

The corners can also be accessed directly for use in a regular SwiftUI Shape

func path(in rect: CGRect) -> Path {
    var path = Path()
    
    // ...Draw some quad curves or similar complex shapes
    
    let corners: [Corner] = MyCornerShape()
        .corners(in: rect)
        .inset(by: 10)
        .addingNotch(Notch(.rectangle, depth: 5), afterCornerIndex: 0)
        
    path.addOpenCornerShape(
        corners,
        previousPoint: path.currentPoint,
        nextPoint: rect[.center],
        moveToStart: false
    )
    
    // ...Draw some more
    
    return path
}

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 is @Sendable, so captured view state will not drive animation the way it can with RelativeCornerCustom.

CornerCustom { rect in
    rect[.topLeft]
        .moved(dx: 10)
        .rounded(radius: 20)
        
    rect[.right]
    
    rect[0.7, 1.0]
        .cutout(radius: .relative(0.4))
}
.fill()

RelativeCornerCustom

An alternative to CornerCustom that can use @State values and can therefore be animated. This shape takes an array of RelativeCorner, a type similar to Corner except its position is a relative anchor point with an absolute offset.

RelativeCornerCustom {
    RelativeCorner.topLeft
        .moved(dx: 10)  // absolute offsets are still possible
        .rounded(radius: 20)
        
    RelativeCorner.right
    
    RelativeCorner(x: 0.7, y: 1.0)
        .cutout(radius: .relative(0.4))
}
.fill()

Basic Shapes

CornerRectangle, CornerTriangle, and CornerPentagon are pre-built corner shapes where you can customize the style of any corner. Some parameters in these shapes also use RelatableValue to define point locations by relative values.

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()

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(position: .relative(0.5), length: .relative(0.2), depth: .relative(0.1))

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

NotchStyle

Rectangular is the default style but triangular notches are just as easy to make. Both styles can have custom corner styles applied to each corner.

/// Specify styles for each corner
Notch(.triangle, depth: 20)
    .cornerStyles([.rounded(radius: 10), .point, .straight(radius: 5)])

/// Or specify one style for all
Notch(length: .relative(0.2), depth: 50)
    .defaultCornerStyle(.rounded(radius: .relative(0.2)))

Custom NotchStyle

Notch styles are essentially arrays of RelativeCorner so you can create any shape you like.

Notch(depth: .relative(0.1)) {
    RelativeCorner.topLeft
    RelativeCorner.left
    RelativeCorner.bottom.rounded(radius: 15)
    RelativeCorner.right
    RelativeCorner.topRight
}

Add CornerShape

If you have a more complex shape with curves but still want to add corners you can use the .addOpenCornerShape() and .addClosedCornerShape() functions added to Path.

Both functions accept an array or a trailing CornerArrayBuilder closure:

var path = Path()

path.addOpenCornerShape(
    previousPoint: path.currentPoint,
    nextPoint: rect[.bottomRight]
) {
    rect[.topLeft]
    rect[.top].rounded(radius: 12)
    rect[.right]
}

path.addClosedCornerShape {
    rect[.topLeft]
    rect[.topRight]
    rect[.bottom].rounded(radius: 20)
}

Add Continuous Curve

.addContinuousCurve(tangent1End:tangent2End:radius:) adds a single continuous corner curve to a Path, the same way .addArc(tangent1End:tangent2End:radius:transform:) adds a circular one. It's useful when you're building a path by hand and want a .continuous-style corner without going through Corner or CornerShape.

var path = Path()
path.move(to: rect[.topLeft])
path.addContinuousCurve(
    tangent1End: rect[.top],
    tangent2End: rect[.right],
    radius: 20
)

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.

Properties and methods:

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

Array extensions:

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

Vector2Algebraic

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

Functions include: magnitude, magnitudeSquared, direction, normalized, addition, subtraction, multiplication or division with scalars, cross product, dot product, scalar projection, parallel component and perpendicular component.

Vector2Transformable

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

CGRect

Create a CGPoint or Corner from a relative anchor position RectAnchor of a CGRect.

/// Default will return a CGPoint
let point = rect[.topRight]

/// When a Corner type is required there is an overload that will return a Corner instead
let corners = Corners {
    rect[.topRight]
    rect[1.0, 0.2]
    rect[.bottom].rounded(radius: 20)
}

Scale and move CGRect

let transformedRect = rect
    .moved(dx: 40, dy: 2.5)
    .scaled(x: 2, y: 1.5, anchor: .center)

CGSize

Scale

let scaledSize: CGSize = size.scaled(3)

CGFrame

A coordinate frame defined by an origin and a vector for each axis. Similar to CGRect you can use it to convert RectAnchor positions within the frame to absolute points and is used internally to draw custom corner styles.

let frame = CGFrame(origin: .zero, xAxis: Vector2(dx: 10, dy: 0), yAxis: Vector2(dx: 10, dy: 8))
let center: CGPoint = frame[.center]
let edgeMidpoints = [RectAnchor.top, .right, .bottom, .left].points(in: frame)
let relativePoint: CGPoint = frame[1.0, 0.666]
let relativePoints = [
    RectAnchor.relative(x: 0.0, y: 0.7),
    .relative(x: 0.3, y: 1.0),
    .relative(x: 1.0, y: 0.0)
].points(in: frame)
CGFrame(origin: .zero, size: CGSize(width: 10, height: 20), rotation: .degrees(45))

SketchyLine

An 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

AnimatableProperties

Synthesize a type's animatableData using writable key paths.

Conforming to the Animatable protocol can be simple with the @Animatable macro but only properties conforming to VectorArithmetic will be animatable. Types that are not animatable will only warn you at runtime.

@Animatable
struct NotchedPolygon: Shape {
    // Animated by @Animatable
    var cornerRadius: CGFloat

    // Not animated by @Animatable. SwiftUI logs a runtime warning for these properties.
    var notch: Notch?
    var corners: [RelativeCorner]

    // ...
}

With AnimatableProperties you can conform to Animatable and animate types that conform to VectorArithmetic or Animatable (including other AnimatableProperties types) and any Optional wrappers or Array/Dictionary collections of those types.

struct NotchedPolygon: Shape, AnimatableProperties {
    var cornerRadius: CGFloat
    var notch: Notch?
    var corners: [RelativeCorner]

    static var animatableProperties: some AnimatableProperty<Self> {
        // All properties listed here will be animated. Unsupported properties will show compiler errors.
        \.cornerRadius
        \.notch
        \.corners
    }

    // ...
}

AnimatablePropertyGroup

Use AnimatablePropertyGroup when a set of properties should animate only while an identifier remains unchanged. When the identifier changes, the grouped properties keep their current values instead of receiving interpolated data. This is useful for enum-like types whose cases store different values.

enum StyleKind: Hashable {
    case rounded
    case concave
}

struct AnimatedStyle: AnimatableProperties {
    var kind: StyleKind
    var radius: CGFloat
    var inset: CGFloat

    static var animatableProperties: some AnimatableProperty<Self> {
        AnimatablePropertyGroup(id: \.kind) {
            \.radius
            \.inset
        }
    }
}

The identifier is included in the animation data and must conform to Hashable. In this example, radius and inset interpolate while kind remains the same. Changing kind applies its new values immediately. ShapeUp uses this behavior for corner styles so properties within the same style continue to animate without interpolating unrelated data when the style changes.

If your animation values need custom logic you can build your own animatableData and use some of the tools below:

AnimatableArray

Animate an array element by element using AnimatableArray. Arrays of VectorArithmetic values expose animatableArray, while arrays of Animatable values expose animatableValueArray.

Note: Only changes to existing elements can be animated. Adding or removing elements will not animate.

struct MyShape: Animatable {
    var corners: [Corner]

    var animatableData: AnimatableArray<Corner.AnimatableData> {
        get { corners.animatableValueArray }
        set { corners.animatableValueArray = newValue }
    }
}

AnimatableDictionary

Animate dictionary values by key using AnimatableDictionary. Dictionaries of VectorArithmetic values expose animatableDictionary, while dictionaries of Animatable values expose animatableValueDictionary.

Note: animatableDictionary and animatableValueDictionary update values for matching existing keys. They do not add or remove keys, so changes to the dictionary's keys are not animated.

struct MyShape: Animatable {
    var styles: [CornerRectangle.ShapeCorner: CornerStyle]

    var animatableData: AnimatableDictionary<CornerRectangle.ShapeCorner, CornerStyle.AnimatableData> {
        get { styles.animatableValueDictionary }
        set { styles.animatableValueDictionary = newValue }
    }
}

AnimatablePack

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

AnimatablePack is a back-deployable alternative to SwiftUI.AnimatableValues for animating any number of properties without nesting AnimatablePair types.

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

About

Make shapes and cut corners in SwiftUI

Topics

Resources

Stars

168 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages