Skip to content

Commit

Permalink
Add Path, FrameList extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
mark-onyschuk committed Sep 19, 2024
1 parent 1e42719 commit 774a189
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 4 deletions.
5 changes: 5 additions & 0 deletions Sources/Geometry/Extensions/CoreGraphics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ extension CGVectorType {
dx * dx + dy * dy
}

/// Vector normal
var normal: Self {
self / length
}

/// Unit representation of the type.
public static var unit: Self {
Self(1, 1)
Expand Down
94 changes: 94 additions & 0 deletions Sources/Geometry/Extensions/RoundedPath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// RoundedPath.swift
// Three-Column-Layout
//
// Created by Mark Onyschuk on 10/11/23.
//

import SwiftUI

extension Path {

/// Creates a path from the union of `rects` and rounds its edges with a radius of up to `roundness` pixels.
///
/// Since the path is formed from the union of `rects`, abutting rectangles are grouped into a single rectangle.
/// - Parameters:
/// - rects: a collection of rects to round
/// - roundness: a maximum roundness in pixels
/// - Returns: a `Path`
public static func rects(
_ rects: some Collection<CGRect>, roundness: CGFloat
) -> Self? {
let rects = rects.filter { !$0.isEmpty }

if let first = rects.first {
let union = rects.dropFirst().reduce(CGPath(rect: first, transform: nil)) {
path, next in path.union(CGPath(rect: next, transform: nil))
}

var polys: [[CGPoint]] = []

union.applyWithBlock {
let elt = $0.pointee

switch elt.type {
case .moveToPoint:
polys = polys.dropLast()
polys.append([elt.points[0]])
case .addLineToPoint:
polys[polys.count - 1].append(elt.points[0])
case .closeSubpath:
polys.append([.zero])

default:
break
}
}

return Path {
path in for pts in polys {
var first: CGPoint? = nil
for i in 1...pts.count {
// fetch three points - the control point, its predecessor and successor points
let cp = pts[i % pts.count]

let prev = pts[i-1]
let next = pts[(i + 1) % pts.count]

// get normals between `cp` and both predecessor and successor points
let n1 = (cp - prev).normal
let n2 = (next - cp).normal

// get half-lengths between `cp` and both predecessor and successor points
let l1 = (cp - prev).length / 2
let l2 = (next - cp).length / 2

// calculate a rounding radius no greater than the half-lengths
let r = min(roundness, l1, l2)

// project start and end points along normals from `cp`
let p1 = cp - (n1 * r)
let p2 = cp + (n2 * r)

if first == nil {
first = p1
path.move(to: p1)
} else {
path.addLine(to: p1)
}

// quad-curve from start and end points, with `cp` as the control point
path.addQuadCurve(to: p2, control: cp)
}

if first != nil {
path.closeSubpath()
}
}
}
} else {
return nil
}
}
}

51 changes: 51 additions & 0 deletions Sources/Geometry/SwiftUI View Modifiers/FrameListModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// FrameListReader.swift
// Geometry
//
// Created by Mark Onyschuk on 9/18/24.
// Copyright © 2024 Dimension North Inc. All rights reserved.
//

import SwiftUI

private struct FrameList<ID>: PreferenceKey where ID: Hashable {
static var defaultValue: [ID: CGRect] {
[:]
}
static func reduce(value: inout [ID: CGRect], nextValue: () -> [ID: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { old, new in new })
}
}

private enum FrameListModifier<ID>: ViewModifier where ID: Hashable {
case reader(onChange: ([ID: CGRect])-> ())
case writer(id: ID, coordinates: CoordinateSpace)

public func body(content: Content) -> some View {
switch self {
case let .reader(onChange):
content.onPreferenceChange(FrameList<ID>.self, perform: onChange)
case let .writer(id, coordinates):
content.overlay(GeometryReader {
geom in Color.clear.preference(
key: FrameList<ID>.self,
value: [id: geom.frame(in: coordinates)]
)
})
}
}
}

extension View {
func frames<ID>(_ value: Binding<[ID: CGRect]>) -> some View where ID: Hashable {
readFrames { value.wrappedValue = $0 }
}

func writeFrame<ID>(id: ID, coordinates: CoordinateSpace) -> some View where ID: Hashable {
self.modifier(FrameListModifier<ID>.writer(id: id, coordinates: coordinates))
}

func readFrames<ID>(onChange: @escaping ([ID: CGRect])-> ()) -> some View where ID: Hashable {
self.modifier(FrameListModifier<ID>.reader(onChange: onChange))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import SwiftUI

private struct FrameReader: ViewModifier {
private struct FrameModifier: ViewModifier {
struct Frame: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
Expand Down Expand Up @@ -42,7 +42,7 @@ extension View {
/// - coordinates: a coordinate space
/// - Returns: a modified view
public func rect(_ value: Binding<CGRect>, in coordinates: CoordinateSpace = .global) -> some View {
self.modifier(FrameReader(coordinates: coordinates) {
self.modifier(FrameModifier(coordinates: coordinates) {
value.wrappedValue = $0
})
}
Expand All @@ -53,7 +53,7 @@ extension View {
/// - coordinates: a coordinate space
/// - Returns: a modified view
public func size(_ value: Binding<CGSize>, in coordinates: CoordinateSpace = .global) -> some View {
self.modifier(FrameReader(coordinates: coordinates) {
self.modifier(FrameModifier(coordinates: coordinates) {
value.wrappedValue = $0.size
})
}
Expand All @@ -64,7 +64,7 @@ extension View {
/// - coordinates: a coordinate space
/// - Returns: a modified view
public func origin(_ value: Binding<CGPoint>, in coordinates: CoordinateSpace = .global) -> some View {
self.modifier(FrameReader(coordinates: coordinates) {
self.modifier(FrameModifier(coordinates: coordinates) {
value.wrappedValue = $0.origin
})
}
Expand Down

0 comments on commit 774a189

Please sign in to comment.