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:
RectAnchor, an enum for major anchor points in a rectangle or coordinate frame.- Extensions to quickly transform
CGPoint,CGRect, andCGSize Corner, aCGPointwithstyle.CornerStyleoptions:.automatic,.point,.rounded,.straight,.cutout,.concave, and.custom.- Basic shapes like
CornerRectangle,CornerTriangle, andCornerPentagonwith stylable corners. CornerShape, a protocol for making your own open or closed shapes out of an array of Corners.CornerCustom, for building corner shapes inline without making a new type.RelativeCornerCustom, for building animatable corner shapes from relative positions.- Add a
Notchof anyNotchStylebetween two corners. .addOpenCornerShape()or.addClosedCornerShape()for adding a few corners to a SwiftUIPath.addContinuousCurve(), aPathmethod for adding a single continuous corner curve between two tangent lines.Vector2, a type similar toCGPointbut used to do vector math.Vector2Representableprotocol that adds a.vectorproperty needed to conform to other Vector2-related protocols.Vector2Algebraicprotocol used to add vector algebra capabilities toVector2Vector2Transformableprotocol with methods for transforming arrays of points.RelatableValue, an enum used to store.relativeor.absolutevalues.CGFrame, a coordinate space or rhombus defined by an origin and vectors for each axisSketchyLine, an animatable lineShapethat aligns to frame edges and can extend beyond the frame..emboss()or.deboss()any SwiftUIShapeorView.AnimatablePropertiesfor synthesizing animation data from writable key paths andAnimatablePropertyGroupfor preventing interpolation between unrelated variants.AnimatableArray,AnimatableDictionary, andAnimatablePackto more easily construct complexanimatableDataproperties.
The Example folder has an app that demonstrates the features of this package.
This package is compatible with iOS 15+, macOS 12+, watchOS 9+, tvOS 15+, and visionOS 1+.
- In Xcode go to
File -> Add Packages - Paste in the repo's url:
https://github.com/ryanlintott/ShapeUpand select by version. - Import the package using
import ShapeUp
Full API documentation is hosted on the Swift Package Index.
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.
ShapeUp is open source and free but if you like using it, please consider supporting my work.
Or you can buy a t-shirt with the ShapeUp logo
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 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()
}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]
}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(_:).
.point
An explicit point corner with no properties. Unlike .automatic, it is preserved when applying a default style.
.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.
.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.
.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.)
.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.
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.
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.
- Set
insetAmountto zero (this property is used to automatically inset the CornerShape). - The
closedproperty 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))
}
}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
}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()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()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()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)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)))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
}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)
}.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
)A vector type used as an alternative to CGPoint that conforms to all the Vector2 protocols.
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: CornerArray extensions:
vectors: [Vector2]
points: [CGPoint]
corners(_ style: CornerStyle?) -> [Corner]
corners(_ styles: [CornerStyle?]) -> [Corner]
corners: [Corner]
bounds: CGRect
angles: [Angle]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.
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.
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)Scale
let scaledSize: CGSize = size.scaled(3)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))An animatable line Shape with ends that can extend and a position that can offset perpendicular to its direction.
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
)Extensions for InsettableShape and View that create an embossed or debossed effect.
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
}
// ...
}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:
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 }
}
}Animate dictionary values by key using AnimatableDictionary. Dictionaries of VectorArithmetic values expose animatableDictionary, while dictionaries of Animatable values expose animatableValueDictionary.
Note:
animatableDictionaryandanimatableValueDictionaryupdate 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 }
}
}*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() }
}
}

