Skip to content

Instantly share code, notes, and snippets.

@dfrobison
Created February 11, 2020 03:43
Show Gist options
  • Save dfrobison/4489c32297d086da4ce0aebbe88509d1 to your computer and use it in GitHub Desktop.
Save dfrobison/4489c32297d086da4ce0aebbe88509d1 to your computer and use it in GitHub Desktop.
[How To Drag A SwiftUI View Along A Specific Path] from https://github.com/kieranb662/SwiftUI_Drag_View_Along_Path
import SwiftUI
import simd
/// Data structure used to store CGPath commands for easier manipulation of individual components
struct PathCommand {
let type: CGPathElementType
let point: CGPoint
let controlPoints: [CGPoint]
}
// MARK: CGPath Extensions
extension CGPath {
/// Provides access to the information of each individual path command in order.
func forEach( body: @escaping @convention(block) (CGPathElement) -> Void) {
typealias Body = @convention(block) (CGPathElement) -> Void
let callback: @convention(c) (UnsafeMutableRawPointer, UnsafePointer<CGPathElement>) -> Void = { (info, element) in
let body = unsafeBitCast(info, to: Body.self)
body(element.pointee)
}
let unsafeBody = unsafeBitCast(body, to: UnsafeMutableRawPointer.self)
self.apply(info: unsafeBody, function: unsafeBitCast(callback, to: CGPathApplierFunction.self))
}
/// Returns an array of all path command data of the CGPath
func commands() -> [PathCommand] {
var pathCommands = [PathCommand]()
self.forEach(body: { (element: CGPathElement) in
let numberOfPoints: Int = {
switch element.type {
case .moveToPoint, .addLineToPoint:
return 1
case .addQuadCurveToPoint:
return 2
case .addCurveToPoint:
return 3
case .closeSubpath:
return 0
@unknown default:
return 0
}
}()
var points = [CGPoint]()
for index in 0..<(numberOfPoints) {
let point = element.points[index]
points.append(point)
}
let command = PathCommand(type: element.type, point: element.points[numberOfPoints - 1], controlPoints: points)
pathCommands.append(command)
})
return pathCommands
}
/// Convenience for accessing the value of the first point in the CGPath
func getStartPoint() -> CGPoint {
return commands()[0].point
}
}
// MARK: Look Up Table
/// # Sampled Path Points Look Up Table
///
/// Contains a reference to a sample of points for the given CGPath
///
/// Using the equations for the linear, quadratic and cubic bezier curve components, we can interpolate and sample locations within each segment of the Path.
/// After generating our sampled points, we keep a reference of the locations to compare with the views current location. We then find which point on the curve
/// is closest to our views current location.
///
class LookUpTable: ObservableObject {
/// Lookup table is an array containing real points for the path.
private(set) var lookupTable = [CGPoint]()
var cgPath: CGPath
init(path: Path) {
self.cgPath = path.cgPath
generateLookupTable()
}
func generateLookupTable() {
let commands = cgPath.commands()
var previousPoint: CGPoint?
let lookupTableCapacity = 200
let commandCount = commands.count
guard commandCount > 0 else {
return
}
let numberOfDivisions = lookupTableCapacity / commandCount
let divisions = 0...numberOfDivisions
for command in commands {
let endPoint = command.point
guard let startPoint = previousPoint else {
previousPoint = endPoint
continue
}
switch command.type {
case .addLineToPoint:
lookupTable.append(contentsOf: divisions.map {
lerp(t: Double($0) / Double(numberOfDivisions), p1: startPoint, p2: endPoint)
})
case .addQuadCurveToPoint:
lookupTable.append(contentsOf: divisions.map {
quadraticInterpolation(t: Double($0) / Double(numberOfDivisions), p1: startPoint, p2: command.controlPoints[0], p3: endPoint)
})
case .addCurveToPoint:
lookupTable.append(contentsOf: divisions.map {
cubicInterpolation(t: Double($0) / Double(numberOfDivisions), p1: startPoint, p2: command.controlPoints[0], p3: command.controlPoints[1], p4: endPoint)
})
case .closeSubpath:
lookupTable.append(contentsOf: divisions.map {
lerp(t: Double($0) / Double(numberOfDivisions), p1: startPoint, p2: lookupTable[0])
})
default:
break
}
previousPoint = endPoint
}
}
/// Calculates a point at given t value, where t in 0.0...1.0
private func lerp(t: Double, p1: CGPoint, p2: CGPoint) -> CGPoint {
let point = mix(simd_double2(x: Double(p1.x) ,y: Double(p1.y)), simd_double2(x: Double(p2.x) ,y: Double(p2.y)), t: t)
return CGPoint(x: point.x, y: point.y)
}
/// Calculates a point at given t value, on the quadractic bezier segment where t in 0.0...1.0
private func quadraticInterpolation(t: Double, p1: CGPoint, p2: CGPoint, p3: CGPoint) -> CGPoint {
let a = (1-t)*(1-t)*simd_double2(x: Double(p1.x) ,y: Double(p1.y))
let b = 2*(1-t)*t*simd_double2(x: Double(p2.x) ,y: Double(p2.y))
let c = Double(t*t)*simd_double2(x: Double(p3.x) ,y: Double(p3.y))
let final = a + b + c
return CGPoint(x: final.x, y: final.y)
}
/// Calculates a point at given t value, on the cubic bezier segment where t in 0.0...1.0
private func cubicInterpolation(t: Double, p1: CGPoint, p2: CGPoint, p3: CGPoint, p4: CGPoint) -> CGPoint {
let a = (1-t)*(1-t)*(1-t)*simd_double2(x: Double(p1.x) ,y: Double(p1.y))
let b = (1-t)*(1-t)*t*3*simd_double2(x: Double(p2.x) ,y: Double(p2.y))
let c = (1-t)*t*t*3*simd_double2(x: Double(p3.x) ,y: Double(p3.y))
let d = t*t*t*simd_double2(x: Double(p4.x) ,y: Double(p4.y))
let final = a + b + d + c
return CGPoint(x: final.x, y: final.y)
}
/// Finds the closest point on the curve to the drag gestures current offset.
/// May be faster if I use functions from the vForce library, but simd doesnt seem to have any performance issues
func getClosestPoint(fromPoint: CGPoint) -> CGPoint {
let minimum = {
(0..<lookupTable.count).map {
(distance: distance_squared(simd_double2(x: Double(fromPoint.x), y:Double(fromPoint.y)), simd_double2(x: Double(lookupTable[$0].x), y: Double(lookupTable[$0].y))), index: $0)
}.min {
$0.distance < $1.distance
}
}()
return lookupTable[minimum!.index]
}
}
/// # Follow Path View Modifier
/// Use this modifier on a view which you would like to constrain to a certain shaped path.
struct FollowPath: ViewModifier {
@ObservedObject var lookUpTable: LookUpTable
@State private var position: CGPoint = .zero
@State private var dragState: CGSize = .zero
var path: Path
func getDisplacement(closestPoint: CGPoint) -> CGSize {
return CGSize(width: closestPoint.x - position.x, height: closestPoint.y - position.y)
}
var gesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .named("follow"))
.onChanged { (value) in
let closestPoint = self.lookUpTable.getClosestPoint(fromPoint: value.location)
self.dragState = self.getDisplacement(closestPoint: closestPoint)
}.onEnded { (value) in
let closestPoint = self.lookUpTable.getClosestPoint(fromPoint: value.location)
let displacement = self.getDisplacement(closestPoint: closestPoint)
withAnimation(.linear) {
self.position.x += displacement.width
self.position.y += displacement.height
self.dragState = .zero
}
}
}
func body(content: Content) -> some View {
path.stroke(Color.blue, lineWidth: 1)
.overlay(content.gesture(gesture).position(position).offset(dragState))
.coordinateSpace(name: "follow")
.onAppear(perform: {
self.position = self.path.cgPath.getStartPoint()
})
}
init(_ path: Path) {
self.lookUpTable = LookUpTable(path: path)
self.path = path
}
}
extension View {
func constrainToPath(_ path: Path) -> some View {
self.modifier(FollowPath(path))
}
}
// MARK: View
/// # Draggable Path Constrained View
/// Creates a draggable circular view constrained to the given Path
struct PathConstrained: View {
@ObservedObject var lookup: LookUpTable
@State var position: CGPoint = .zero
@State var dragState: CGSize = .zero
var path: Path
init(_ path: Path) {
self.lookup = LookUpTable(path: path)
self.path = path
self.lookup.generateLookupTable()
}
func getDisplacement(closestPoint: CGPoint) -> CGSize {
return CGSize(width: closestPoint.x - position.x, height: closestPoint.y - position.y)
}
var gesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .named("MY"))
.onChanged { (value) in
let closestPoint = self.lookup.getClosestPoint(fromPoint: value.location)
self.dragState = self.getDisplacement(closestPoint: closestPoint)
}.onEnded { (value) in
let closestPoint = self.lookup.getClosestPoint(fromPoint: value.location)
let displacement = self.getDisplacement(closestPoint: closestPoint)
withAnimation(.linear) {
self.position.x += displacement.width
self.position.y += displacement.height
self.dragState = .zero
}
}
}
@State var myText: String = ""
var thumb: some View {
TextField("Testing", text: $myText)
.foregroundColor(.blue)
.frame(width: 100, height: 100, alignment: .center)
.gesture(gesture)
.position(x: position.x , y: position.y)
.offset(x: dragState.width, y: dragState.height)
}
var body: some View {
path
.stroke(Color.red, lineWidth: 2)
.overlay(thumb)
.coordinateSpace(name: "MY")
.onAppear(perform: {
self.position = self.path.cgPath.getStartPoint()
})
}
}
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
Path { (path) in
let w = rect.width
let h = rect.height
path.move(to: CGPoint(x: w/2, y: h/4))
path.addLine(to: CGPoint(x: 3*w/4, y: 3*h/4))
path.addLine(to: CGPoint(x: w/4, y: 3*h/4))
path.closeSubpath()
}
}
}
/// Examples of how to use the different constraint components.
struct ContentView: View {
var body: some View {
VStack {
PathConstrained(Triangle().path(in: CGRect(x: 0, y: 0, width: 250, height: 250)))
PathConstrained(Circle().path(in: CGRect(x: 0, y: 0, width: 250, height: 250)))
Ellipse()
.fill(LinearGradient(gradient: Gradient(colors: [.red, .orange]), startPoint: .topTrailing, endPoint: .bottomLeading))
.frame(width: 75, height: 50)
.constrainToPath(Rectangle().path(in: CGRect(x: 0, y: 0, width: 100, height: 250)))
.offset(x: 200, y: 0)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment