I am trying to animate my path with a move value, It works almost, but the needed update to path happens after animation is done! Which must happen before Animation! How can I make it possible?
PS: The issue is about Path, and I am trying solve it through path.
struct HatchedShap: Shape {
let dis: CGFloat
var move: CGFloat
var animatableData: CGFloat {
get { return move }
set { move = newValue }
}
func path(in rect: CGRect) -> Path {
return Path { path in
for index in -Int(move*2.0)...Int((rect.height)/dis) {
path.move(to: CGPoint(x: rect.minX, y: rect.minY + (CGFloat(index) + move)*dis))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + (CGFloat(index) + move)*dis))
}
}
}
}
use case:
struct ContentView: View {
#State private var move: CGFloat = CGFloat()
var body: some View {
HatchedShap(dis: 20.0, move: move)
.stroke(Color.red, style: StrokeStyle(lineWidth: 2.0))
.background(Color.black)
.frame(width: 100, height: 200)
.onTapGesture {
if (move == -0.5) { move = 0}
else { move = -0.5 }
}
.animation(Animation.linear(duration: 1.0), value: move)
}
}
I think using stride(from:through:by:) is much easier. You can then continue with the loop if it is out of the range of the rect. This example also nicely wraps around if the factor's absolute value is greater than 1.
Code:
struct HatchedShap: Shape {
let dis: CGFloat
var move: CGFloat
var animatableData: CGFloat {
get { return move }
set { move = newValue }
}
func path(in rect: CGRect) -> Path {
Path { path in
for start in stride(from: 0, through: rect.height, by: dis) {
let offset = start + dis * move.truncatingRemainder(dividingBy: 1)
guard 0 ... rect.height ~= offset else {
continue
}
path.move(to: CGPoint(x: rect.minX, y: rect.minY + offset))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + offset))
}
}
}
}
Results:
Factor of -0.5
Factor of -2
Small note:
You can simplify:
if (move == -0.5) {
move = 0
} else {
move = -0.5
}
To the following, which removes duplication & possibly faster because there is no branching (depends on if the compiler optimizes this):
move = -0.5 - move
In your path code, assign y to a let constant and check to make sure it is larger than 0 before adding the line to the path:
struct HatchedShap: Shape {
let dis: CGFloat
var move: CGFloat
var animatableData: CGFloat {
get { return move }
set { move = newValue }
}
func path(in rect: CGRect) -> Path {
return Path { path in
for index in -Int(move*2.0)...Int((rect.height)/dis) {
let y = rect.minY + (CGFloat(index) + move)*dis
if y > 0 {
path.move(to: CGPoint(x: rect.minX, y: y))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + (CGFloat(index) + move)*dis))
}
}
}
}
}
Related
There doesn't seem to be an intuitive way of moving a view/shape along a custom path, particularly a curvy path. I've found several libraries for UIKit that allow views to move on a Bézier Paths (DKChainableAnimationKit,TweenKit,Sica,etc.) but I am not that comfortable using UIKit and kept running into errors.
currently with swiftUI I'm manually doing it like so:
import SwiftUI
struct ContentView: View {
#State var moveX = true
#State var moveY = true
#State var moveX2 = true
#State var moveY2 = true
#State var rotate1 = true
var body: some View {
ZStack{
Circle().frame(width:50, height:50)
.offset(x: moveX ? 0:100, y: moveY ? 0:100)
.animation(Animation.easeInOut(duration:1).delay(0))
.rotationEffect(.degrees(rotate1 ? 0:350))
.offset(x: moveX2 ? 0:-100, y: moveY2 ? 0:-200)
.animation(Animation.easeInOut(duration:1).delay(1))
.onAppear(){
self.moveX.toggle();
self.moveY.toggle();
self.moveX2.toggle();
self.moveY2.toggle();
self.rotate1.toggle();
// self..toggle()
}
}
} }
It somewhat gets the job done, but the flexibility is severely limited and compounding delays quickly becomes a mess.
If anyone knows how I could get a custom view/shape to travel along the following path it would be very very much appreciated.
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
path.addLine(to: CGPoint(x: 90, y: 600))
}
.stroke()
The closest solution I've managed to find was on SwiftUILab but the full tutorial seems to be only available to paid subscribers.
Something like this:
OK, it is not simple, but I would like to help ...
In the next snippet (macOS application) you can see the basic elements which you can adapt to your needs.
For simplicity I choose simple parametric curve, if you like to use more complex (composite) curve, you have to solve how to map partial t (parameter) for each segment to the composite t for the whole curve (and the same must be done for mapping between partial along-track distance to composite track along-track distance).
Why such a complication?
There is a nonlinear relation between the along-track distance required for aircraft displacement (with constant speed) and curve parameter t on which parametric curve definition depends.
Let see the result first
and next to see how it is implemented. You need to study this code, and if necessary study how parametric curves are defined and behave.
//
// ContentView.swift
// tmp086
//
// Created by Ivo Vacek on 11/03/2020.
// Copyright © 2020 Ivo Vacek. All rights reserved.
//
import SwiftUI
import Accelerate
protocol ParametricCurve {
var totalArcLength: CGFloat { get }
func point(t: CGFloat)->CGPoint
func derivate(t: CGFloat)->CGVector
func secondDerivate(t: CGFloat)->CGVector
func arcLength(t: CGFloat)->CGFloat
func curvature(t: CGFloat)->CGFloat
}
extension ParametricCurve {
func arcLength(t: CGFloat)->CGFloat {
var tmin: CGFloat = .zero
var tmax: CGFloat = .zero
if t < .zero {
tmin = t
} else {
tmax = t
}
let quadrature = Quadrature(integrator: .qags(maxIntervals: 8), absoluteTolerance: 5.0e-2, relativeTolerance: 1.0e-3)
let result = quadrature.integrate(over: Double(tmin) ... Double(tmax)) { _t in
let dp = derivate(t: CGFloat(_t))
let ds = Double(hypot(dp.dx, dp.dy)) //* x
return ds
}
switch result {
case .success(let arcLength, _/*, let e*/):
//print(arcLength, e)
return t < .zero ? -CGFloat(arcLength) : CGFloat(arcLength)
case .failure(let error):
print("integration error:", error.errorDescription)
return CGFloat.nan
}
}
func curveParameter(arcLength: CGFloat)->CGFloat {
let maxLength = totalArcLength == .zero ? self.arcLength(t: 1) : totalArcLength
guard maxLength > 0 else { return 0 }
var iteration = 0
var guess: CGFloat = arcLength / maxLength
let maxIterations = 10
let maxErr: CGFloat = 0.1
while (iteration < maxIterations) {
let err = self.arcLength(t: guess) - arcLength
if abs(err) < maxErr { break }
let dp = derivate(t: guess)
let m = hypot(dp.dx, dp.dy)
guess -= err / m
iteration += 1
}
return guess
}
func curvature(t: CGFloat)->CGFloat {
/*
x'y" - y'x"
κ(t) = --------------------
(x'² + y'²)^(3/2)
*/
let dp = derivate(t: t)
let dp2 = secondDerivate(t: t)
let dpSize = hypot(dp.dx, dp.dy)
let denominator = dpSize * dpSize * dpSize
let nominator = dp.dx * dp2.dy - dp.dy * dp2.dx
return nominator / denominator
}
}
struct Bezier3: ParametricCurve {
let p0: CGPoint
let p1: CGPoint
let p2: CGPoint
let p3: CGPoint
let A: CGFloat
let B: CGFloat
let C: CGFloat
let D: CGFloat
let E: CGFloat
let F: CGFloat
let G: CGFloat
let H: CGFloat
public private(set) var totalArcLength: CGFloat = .zero
init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
p0 = from
p1 = control1
p2 = control2
p3 = to
A = to.x - 3 * control2.x + 3 * control1.x - from.x
B = 3 * control2.x - 6 * control1.x + 3 * from.x
C = 3 * control1.x - 3 * from.x
D = from.x
E = to.y - 3 * control2.y + 3 * control1.y - from.y
F = 3 * control2.y - 6 * control1.y + 3 * from.y
G = 3 * control1.y - 3 * from.y
H = from.y
// mandatory !!!
totalArcLength = arcLength(t: 1)
}
func point(t: CGFloat)->CGPoint {
let x = A * t * t * t + B * t * t + C * t + D
let y = E * t * t * t + F * t * t + G * t + H
return CGPoint(x: x, y: y)
}
func derivate(t: CGFloat)->CGVector {
let dx = 3 * A * t * t + 2 * B * t + C
let dy = 3 * E * t * t + 2 * F * t + G
return CGVector(dx: dx, dy: dy)
}
func secondDerivate(t: CGFloat)->CGVector {
let dx = 6 * A * t + 2 * B
let dy = 6 * E * t + 2 * F
return CGVector(dx: dx, dy: dy)
}
}
class AircraftModel: ObservableObject {
let track: ParametricCurve
let path: Path
var aircraft: some View {
let t = track.curveParameter(arcLength: alongTrackDistance)
let p = track.point(t: t)
let dp = track.derivate(t: t)
let h = Angle(radians: atan2(Double(dp.dy), Double(dp.dx)))
return Text("").font(.largeTitle).rotationEffect(h).position(p)
}
#Published var alongTrackDistance = CGFloat.zero
init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
track = Bezier3(from: from, to: to, control1: control1, control2: control2)
path = Path({ (path) in
path.move(to: from)
path.addCurve(to: to, control1: control1, control2: control2)
})
}
}
struct ContentView: View {
#ObservedObject var aircraft = AircraftModel(from: .init(x: 0, y: 0), to: .init(x: 500, y: 600), control1: .init(x: 600, y: 100), control2: .init(x: -300, y: 400))
var body: some View {
VStack {
ZStack {
aircraft.path.stroke(style: StrokeStyle( lineWidth: 0.5))
aircraft.aircraft
}
Slider(value: $aircraft.alongTrackDistance, in: (0.0 ... aircraft.track.totalArcLength)) {
Text("along track distance")
}.padding()
Button(action: {
// fly (to be implemented :-))
}) {
Text("Fly!")
}.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you worry about how to implement "animated" aircraft movement, SwiftUI animation is not the solution. You have to move the aircraft programmatically.
You have to import
import Combine
Add to model
#Published var flying = false
var timer: Cancellable? = nil
func fly() {
flying = true
timer = Timer
.publish(every: 0.02, on: RunLoop.main, in: RunLoop.Mode.default)
.autoconnect()
.sink(receiveValue: { (_) in
self.alongTrackDistance += self.track.totalArcLength / 200.0
if self.alongTrackDistance > self.track.totalArcLength {
self.timer?.cancel()
self.flying = false
}
})
}
and modify the button
Button(action: {
self.aircraft.fly()
}) {
Text("Fly!")
}.disabled(aircraft.flying)
.padding()
Finally I've got
The solution from user3441734 is very general and elegant. The reader will benefit from every second pondering the ParametricCurve and its arc length and curvature. It is the only approach I have found that can re-orient the moving object (the airplane) to point forward while moving.
Asperi has also posted a useful solution in Is it possible to animate view on a certain Path in SwiftUI
Here is a solution that does less, with less. It does use SwiftUI animation, which is a mixed blessing. (E.g. you get more choices for animation curves, but you don't get announcements or callbacks when the animation is done.) It is inspired by Asperi's answer in Problem animating with animatableData in SwiftUI.
import SwiftUI
// Use https://www.desmos.com/calculator/cahqdxeshd to design Beziers.
// Pick a simple example path.
fileprivate let W = UIScreen.main.bounds.width
fileprivate let H = UIScreen.main.bounds.height
fileprivate let p1 = CGPoint(x: 50, y: H - 50)
fileprivate let p2 = CGPoint(x: W - 50, y: 50)
fileprivate var samplePath : Path {
let c1 = CGPoint(x: p1.x, y: (p1.y + p2.y)/2)
let c2 = CGPoint(x: p2.x, y: (p1.y + p2.y)/2)
var result = Path()
result.move(to: p1)
result.addCurve(to: p2, control1: c1, control2: c2)
return result
}
// This View's position follows the Path.
struct SlidingSpot : View {
let path : Path
let start : CGPoint
let duration: Double = 1
#State var isMovingForward = false
var tMax : CGFloat { isMovingForward ? 1 : 0 } // Same expressions,
var opac : Double { isMovingForward ? 1 : 0 } // different meanings.
var body: some View {
VStack {
Circle()
.frame(width: 30)
// Asperi is correct that this Modifier must be separate.
.modifier(Moving(time: tMax, path: path, start: start))
.animation(.easeInOut(duration: duration), value: tMax)
.opacity(opac)
Button {
isMovingForward = true
// Sneak back to p1. This is a code smell.
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) {
isMovingForward = false
}
} label: {
Text("Go")
}
}
}
}
// Minimal modifier.
struct Moving: AnimatableModifier {
var time : CGFloat // Normalized from 0...1.
let path : Path
let start: CGPoint // Could derive from path.
var animatableData: CGFloat {
get { time }
set { time = newValue }
}
func body(content: Content) -> some View {
content
.position(
path.trimmedPath(from: 0, to: time).currentPoint ?? start
)
}
}
struct ContentView: View {
var body: some View {
SlidingSpot(path: samplePath, start: p1)
}
}
try this:
BUT: be careful: this is NOT running in preview, you have to run in on simulator/device
struct MyShape: Shape {
func path(in rect: CGRect) -> Path {
let path =
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
path.addLine(to: CGPoint(x: 90, y: 600))
}
return path
}
}
struct ContentView: View {
#State var x: CGFloat = 0.0
var body: some View {
MyShape()
.trim(from: 0, to: x)
.stroke(lineWidth: 10)
.frame(width: 200, height: 200)
.onAppear() {
withAnimation(Animation.easeInOut(duration: 3).delay(0.5)) {
self.x = 1
}
}
}
}
I'm trying to draw a line with markers at each point by using a Shape view in SwiftUI. I want the line to have a filled circle at each CGPoint on the line. The closest to this that I've gotten is by adding an arc at each point. Instead of using the arc, how can I add a Circle() shape at each point? I'm open to other approaches to accomplish this. My only requirements are to use SwiftUI and have the ability to interact with the markers.
import SwiftUI
struct LineShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
path.move(to: CGPoint(x: 0.0, y: (1 - values[0]) * Double(rect.height)))
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addLine(to: pt)
path.addArc(center: pt, radius: 8, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 360), clockwise: false)
}
return path
}
}
struct ContentView: View {
var body: some View {
LineShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.stroke(.red, lineWidth: 2.0)
.padding()
.frame(width: 400, height: 300)
}
}
You can make two different shapes, one for the line and one for the markers, and overlay them. Then you can also control their coloring individually:
struct LineShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
path.move(to: CGPoint(x: 0.0, y: (1 - values[0]) * Double(rect.height)))
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addLine(to: pt)
}
return path
}
}
struct MarkersShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addEllipse(in: CGRect(x: pt.x - 8, y: pt.y - 8, width: 16, height: 16))
}
return path
}
}
struct ContentView: View {
var body: some View {
LineShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.stroke(.red, lineWidth: 2.0)
.overlay(
MarkersShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.fill(.blue)
)
.frame(width: 350, height: 300)
}
}
According to previous posts and comments here is my solution:
First there is need to use extension for filling and setting shape stroke:
extension Shape {
public func fill<Shape: ShapeStyle>(_ fillContent: Shape, strokeColor: Color, lineWidth: CGFloat) -> some View {
ZStack {
self.fill(fillContent)
self.stroke(strokeColor, lineWidth: lineWidth)
}
}
}
Another thing is Scaler struct used to make scaling calculations:
struct Scaler {
let bounds: CGPoint
let maxVal: Double
let minVal: Double
let valuesCount: Int
var xFactor: CGFloat {
valuesCount < 2 ? bounds.x : bounds.x / CGFloat(valuesCount - 1)
}
var yFactor: CGFloat {
bounds.y / maxVal
}
}
Now it's time for real chart shape drawing:
struct ChartLineDotsShape: Shape {
let values: [Double]
let dotRadius: CGFloat
func path(in rect: CGRect) -> Path {
guard let maxVal = values.max(), let minVal = values.min() else { return Path() }
let scaler = Scaler(bounds: CGPoint(x: rect.width, y: rect.height), maxVal: maxVal, minVal: minVal, valuesCount: values.count)
return Path { path in
var valuesIterator = values.makeIterator()
let dx = scaler.xFactor
var x = 0.0
guard let y = valuesIterator.next() else { return }
path.move(to: CGPoint(x: x, y: calculate(y, scaler: scaler)))
draw(point: CGPoint(x: x, y: calculate(y, scaler: scaler)), on: &path)
x += dx
while let y = valuesIterator.next() {
draw(point: CGPoint(x: x, y: calculate(y, scaler: scaler)), on: &path)
x += dx
}
}
}
private func calculate(_ value: CGFloat, scaler: Scaler) -> CGFloat {
scaler.bounds.y - value * scaler.yFactor
}
private func draw(point: CGPoint, on path: inout Path) {
path.addLine(to: CGPoint(x: point.x, y: point.y))
path.addEllipse(in: CGRect(x: point.x - dotRadius * 0.5, y: point.y - dotRadius * 0.5, width: dotRadius, height: dotRadius))
path.move(to: CGPoint(x: point.x, y: point.y))
}
}
If there is single iteration loop lines dots drawing shape we can try to use it in some view:
public struct ChartView: View {
public var body: some View {
ChartLineDotsShape(values: [0.3, 0.5, 1.0, 2.0, 2.5, 2.0, 3.0, 6.0, 2.0, 1.5, 2.0], dotRadius: 4.0)
.fill(Color.blue, strokeColor: Color.blue, lineWidth: 1.0)
.shadow(color: Color.blue, radius: 2.0, x: 0.0, y: 0.0)
.frame(width: 320.0, height: 200.0)
.background(Color.blue.opacity(0.1))
.clipped()
}
}
Finally preview setup:
struct ChartView_Previews: PreviewProvider {
static var previews: some View {
ChartView()
.preferredColorScheme(.dark)
}
}
and viola:
I have just recently started using Swiftui and have the following question:
I would like to convert the current time to an analog view. But when I use the code below, I always get an error message at the angle. What is the reason that it does not work?
struct Hand: Shape {
let inset: CGFloat
let angle: Angle
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: inset, dy: inset)
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.midY))
path.addRoundedRect(in: CGRect(x: rect.midX - 4, y: rect.midY - 4, width: 8, height: 8), cornerSize: CGSize(width: 8, height: 8))
path.move(to: CGPoint(x: rect.midX, y: rect.midY))
path.addLine(to: position(for: CGFloat(angle.radians), in: rect))
return path
}
private func position(for angle: CGFloat, in rect: CGRect) -> CGPoint {
let angle = angle - (.pi/2)
let radius = min(rect.width, rect.height)/2
let xPosition = rect.midX + (radius * cos(angle))
let yPosition = rect.midY + (radius * sin(angle))
return CGPoint(x: xPosition, y: yPosition)
}
}
struct TickHands: View {
#State private var currentDate = Date()
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Hand(inset: 50, angle: currentDate.hourAngle)
.stroke(lineWidth: 4)
.foregroundColor(.black)
Hand(inset: 22, angle: currentDate.minuteAngle)
.stroke(lineWidth: 4)
.foregroundColor(.black)
Hand(inset: 10, angle: currentDate.secondAngle)
.stroke(lineWidth: 2)
.foregroundColor(.gray)
}
.onReceive(timer) { (input) in
self.currentDate = input
}}}
Based on the link you provided, It looks like the author of the article did not provide some extensions.
Add the following extensions to your code (preferable in a new file), and you should be good 🚀:
extension Date {
var hourAngle: Angle {
return Angle (degrees: (360 / 12) * (self.hour + self.minutes / 60))
}
var minuteAngle: Angle {
return Angle(degrees: (self.minutes * 360 / 60))
}
var secondAngle: Angle {
return Angle (degrees: (self.seconds * 360 / 60))
}
}
extension Date {
var hour: Double {
return Double(Calendar.current.component(.hour, from: self))
}
var minutes: Double {
return Double(Calendar.current.component(.minute, from: self))
}
var seconds: Double {
return Double(Calendar.current.component(.second, from: self))
}
}
There doesn't seem to be an intuitive way of moving a view/shape along a custom path, particularly a curvy path. I've found several libraries for UIKit that allow views to move on a Bézier Paths (DKChainableAnimationKit,TweenKit,Sica,etc.) but I am not that comfortable using UIKit and kept running into errors.
currently with swiftUI I'm manually doing it like so:
import SwiftUI
struct ContentView: View {
#State var moveX = true
#State var moveY = true
#State var moveX2 = true
#State var moveY2 = true
#State var rotate1 = true
var body: some View {
ZStack{
Circle().frame(width:50, height:50)
.offset(x: moveX ? 0:100, y: moveY ? 0:100)
.animation(Animation.easeInOut(duration:1).delay(0))
.rotationEffect(.degrees(rotate1 ? 0:350))
.offset(x: moveX2 ? 0:-100, y: moveY2 ? 0:-200)
.animation(Animation.easeInOut(duration:1).delay(1))
.onAppear(){
self.moveX.toggle();
self.moveY.toggle();
self.moveX2.toggle();
self.moveY2.toggle();
self.rotate1.toggle();
// self..toggle()
}
}
} }
It somewhat gets the job done, but the flexibility is severely limited and compounding delays quickly becomes a mess.
If anyone knows how I could get a custom view/shape to travel along the following path it would be very very much appreciated.
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
path.addLine(to: CGPoint(x: 90, y: 600))
}
.stroke()
The closest solution I've managed to find was on SwiftUILab but the full tutorial seems to be only available to paid subscribers.
Something like this:
OK, it is not simple, but I would like to help ...
In the next snippet (macOS application) you can see the basic elements which you can adapt to your needs.
For simplicity I choose simple parametric curve, if you like to use more complex (composite) curve, you have to solve how to map partial t (parameter) for each segment to the composite t for the whole curve (and the same must be done for mapping between partial along-track distance to composite track along-track distance).
Why such a complication?
There is a nonlinear relation between the along-track distance required for aircraft displacement (with constant speed) and curve parameter t on which parametric curve definition depends.
Let see the result first
and next to see how it is implemented. You need to study this code, and if necessary study how parametric curves are defined and behave.
//
// ContentView.swift
// tmp086
//
// Created by Ivo Vacek on 11/03/2020.
// Copyright © 2020 Ivo Vacek. All rights reserved.
//
import SwiftUI
import Accelerate
protocol ParametricCurve {
var totalArcLength: CGFloat { get }
func point(t: CGFloat)->CGPoint
func derivate(t: CGFloat)->CGVector
func secondDerivate(t: CGFloat)->CGVector
func arcLength(t: CGFloat)->CGFloat
func curvature(t: CGFloat)->CGFloat
}
extension ParametricCurve {
func arcLength(t: CGFloat)->CGFloat {
var tmin: CGFloat = .zero
var tmax: CGFloat = .zero
if t < .zero {
tmin = t
} else {
tmax = t
}
let quadrature = Quadrature(integrator: .qags(maxIntervals: 8), absoluteTolerance: 5.0e-2, relativeTolerance: 1.0e-3)
let result = quadrature.integrate(over: Double(tmin) ... Double(tmax)) { _t in
let dp = derivate(t: CGFloat(_t))
let ds = Double(hypot(dp.dx, dp.dy)) //* x
return ds
}
switch result {
case .success(let arcLength, _/*, let e*/):
//print(arcLength, e)
return t < .zero ? -CGFloat(arcLength) : CGFloat(arcLength)
case .failure(let error):
print("integration error:", error.errorDescription)
return CGFloat.nan
}
}
func curveParameter(arcLength: CGFloat)->CGFloat {
let maxLength = totalArcLength == .zero ? self.arcLength(t: 1) : totalArcLength
guard maxLength > 0 else { return 0 }
var iteration = 0
var guess: CGFloat = arcLength / maxLength
let maxIterations = 10
let maxErr: CGFloat = 0.1
while (iteration < maxIterations) {
let err = self.arcLength(t: guess) - arcLength
if abs(err) < maxErr { break }
let dp = derivate(t: guess)
let m = hypot(dp.dx, dp.dy)
guess -= err / m
iteration += 1
}
return guess
}
func curvature(t: CGFloat)->CGFloat {
/*
x'y" - y'x"
κ(t) = --------------------
(x'² + y'²)^(3/2)
*/
let dp = derivate(t: t)
let dp2 = secondDerivate(t: t)
let dpSize = hypot(dp.dx, dp.dy)
let denominator = dpSize * dpSize * dpSize
let nominator = dp.dx * dp2.dy - dp.dy * dp2.dx
return nominator / denominator
}
}
struct Bezier3: ParametricCurve {
let p0: CGPoint
let p1: CGPoint
let p2: CGPoint
let p3: CGPoint
let A: CGFloat
let B: CGFloat
let C: CGFloat
let D: CGFloat
let E: CGFloat
let F: CGFloat
let G: CGFloat
let H: CGFloat
public private(set) var totalArcLength: CGFloat = .zero
init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
p0 = from
p1 = control1
p2 = control2
p3 = to
A = to.x - 3 * control2.x + 3 * control1.x - from.x
B = 3 * control2.x - 6 * control1.x + 3 * from.x
C = 3 * control1.x - 3 * from.x
D = from.x
E = to.y - 3 * control2.y + 3 * control1.y - from.y
F = 3 * control2.y - 6 * control1.y + 3 * from.y
G = 3 * control1.y - 3 * from.y
H = from.y
// mandatory !!!
totalArcLength = arcLength(t: 1)
}
func point(t: CGFloat)->CGPoint {
let x = A * t * t * t + B * t * t + C * t + D
let y = E * t * t * t + F * t * t + G * t + H
return CGPoint(x: x, y: y)
}
func derivate(t: CGFloat)->CGVector {
let dx = 3 * A * t * t + 2 * B * t + C
let dy = 3 * E * t * t + 2 * F * t + G
return CGVector(dx: dx, dy: dy)
}
func secondDerivate(t: CGFloat)->CGVector {
let dx = 6 * A * t + 2 * B
let dy = 6 * E * t + 2 * F
return CGVector(dx: dx, dy: dy)
}
}
class AircraftModel: ObservableObject {
let track: ParametricCurve
let path: Path
var aircraft: some View {
let t = track.curveParameter(arcLength: alongTrackDistance)
let p = track.point(t: t)
let dp = track.derivate(t: t)
let h = Angle(radians: atan2(Double(dp.dy), Double(dp.dx)))
return Text("").font(.largeTitle).rotationEffect(h).position(p)
}
#Published var alongTrackDistance = CGFloat.zero
init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) {
track = Bezier3(from: from, to: to, control1: control1, control2: control2)
path = Path({ (path) in
path.move(to: from)
path.addCurve(to: to, control1: control1, control2: control2)
})
}
}
struct ContentView: View {
#ObservedObject var aircraft = AircraftModel(from: .init(x: 0, y: 0), to: .init(x: 500, y: 600), control1: .init(x: 600, y: 100), control2: .init(x: -300, y: 400))
var body: some View {
VStack {
ZStack {
aircraft.path.stroke(style: StrokeStyle( lineWidth: 0.5))
aircraft.aircraft
}
Slider(value: $aircraft.alongTrackDistance, in: (0.0 ... aircraft.track.totalArcLength)) {
Text("along track distance")
}.padding()
Button(action: {
// fly (to be implemented :-))
}) {
Text("Fly!")
}.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you worry about how to implement "animated" aircraft movement, SwiftUI animation is not the solution. You have to move the aircraft programmatically.
You have to import
import Combine
Add to model
#Published var flying = false
var timer: Cancellable? = nil
func fly() {
flying = true
timer = Timer
.publish(every: 0.02, on: RunLoop.main, in: RunLoop.Mode.default)
.autoconnect()
.sink(receiveValue: { (_) in
self.alongTrackDistance += self.track.totalArcLength / 200.0
if self.alongTrackDistance > self.track.totalArcLength {
self.timer?.cancel()
self.flying = false
}
})
}
and modify the button
Button(action: {
self.aircraft.fly()
}) {
Text("Fly!")
}.disabled(aircraft.flying)
.padding()
Finally I've got
The solution from user3441734 is very general and elegant. The reader will benefit from every second pondering the ParametricCurve and its arc length and curvature. It is the only approach I have found that can re-orient the moving object (the airplane) to point forward while moving.
Asperi has also posted a useful solution in Is it possible to animate view on a certain Path in SwiftUI
Here is a solution that does less, with less. It does use SwiftUI animation, which is a mixed blessing. (E.g. you get more choices for animation curves, but you don't get announcements or callbacks when the animation is done.) It is inspired by Asperi's answer in Problem animating with animatableData in SwiftUI.
import SwiftUI
// Use https://www.desmos.com/calculator/cahqdxeshd to design Beziers.
// Pick a simple example path.
fileprivate let W = UIScreen.main.bounds.width
fileprivate let H = UIScreen.main.bounds.height
fileprivate let p1 = CGPoint(x: 50, y: H - 50)
fileprivate let p2 = CGPoint(x: W - 50, y: 50)
fileprivate var samplePath : Path {
let c1 = CGPoint(x: p1.x, y: (p1.y + p2.y)/2)
let c2 = CGPoint(x: p2.x, y: (p1.y + p2.y)/2)
var result = Path()
result.move(to: p1)
result.addCurve(to: p2, control1: c1, control2: c2)
return result
}
// This View's position follows the Path.
struct SlidingSpot : View {
let path : Path
let start : CGPoint
let duration: Double = 1
#State var isMovingForward = false
var tMax : CGFloat { isMovingForward ? 1 : 0 } // Same expressions,
var opac : Double { isMovingForward ? 1 : 0 } // different meanings.
var body: some View {
VStack {
Circle()
.frame(width: 30)
// Asperi is correct that this Modifier must be separate.
.modifier(Moving(time: tMax, path: path, start: start))
.animation(.easeInOut(duration: duration), value: tMax)
.opacity(opac)
Button {
isMovingForward = true
// Sneak back to p1. This is a code smell.
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) {
isMovingForward = false
}
} label: {
Text("Go")
}
}
}
}
// Minimal modifier.
struct Moving: AnimatableModifier {
var time : CGFloat // Normalized from 0...1.
let path : Path
let start: CGPoint // Could derive from path.
var animatableData: CGFloat {
get { time }
set { time = newValue }
}
func body(content: Content) -> some View {
content
.position(
path.trimmedPath(from: 0, to: time).currentPoint ?? start
)
}
}
struct ContentView: View {
var body: some View {
SlidingSpot(path: samplePath, start: p1)
}
}
try this:
BUT: be careful: this is NOT running in preview, you have to run in on simulator/device
struct MyShape: Shape {
func path(in rect: CGRect) -> Path {
let path =
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
path.addLine(to: CGPoint(x: 90, y: 600))
}
return path
}
}
struct ContentView: View {
#State var x: CGFloat = 0.0
var body: some View {
MyShape()
.trim(from: 0, to: x)
.stroke(lineWidth: 10)
.frame(width: 200, height: 200)
.onAppear() {
withAnimation(Animation.easeInOut(duration: 3).delay(0.5)) {
self.x = 1
}
}
}
}
class GraphView: UIView {
private let axesDrawer = AxesDrawer()
private var scale: CGFloat = 1.0 { didSet { setNeedsDisplay() } }
private var axesCenter: CGPoint? { didSet { setNeedsDisplay() } }
private var positionX: CGFloat = 0 { didSet { setNeedsDisplay() } }
func changeScale(recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .Changed, .Ended:
scale *= recognizer.scale
recognizer.scale = 1.0
default: break
}
}
func moveAxes(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .Changed:
if axesCenter == nil {
axesCenter = CGPoint(x: self.frame.midX, y: self.frame.midY)
}
let changePoint = recognizer.translationInView(self)
axesCenter = CGPoint(x: axesCenter!.x + changePoint.x, y: axesCenter!.y + changePoint.y)
positionX += changePoint.x
recognizer.setTranslation(CGPoint(x: 0.0, y: 0.0), inView: self)
default:
break
}
}
func drawGraph() -> UIBezierPath {
let path = UIBezierPath()
let positionY = (axesCenter == nil) ? frame.height / 2 : (axesCenter?.y)! // find the vertical center
path.moveToPoint(CGPoint(x: self.frame.minX, y: positionY))
for i in Int(frame.minX)...Int(frame.maxX) {
let x = CGFloat(i) + positionX
let y = (sin(Double(i) * 0.1) * Double(axesDrawer.minimumPointsPerHashmark) * Double(scale)) + Double(positionY)
path.addLineToPoint(CGPoint(x: x, y: CGFloat(y)))
}
path.lineWidth = 1
return path
}
override func drawRect(rect: CGRect) {
axesDrawer.contentScaleFactor = self.contentScaleFactor
axesDrawer.drawAxesInRect(self.bounds, origin: axesCenter ?? CGPoint(x: self.frame.midX, y: self.frame.midY), pointsPerUnit: CGFloat(50) * scale)
UIColor.redColor().set()
drawGraph().stroke()
}
}
Moving towards:
left side
right side
I'm trying to draw "infinite" sine curve. The problem is that it doesn't redraw itself when I try to move around the view.
If you look at the links I provided, on the left side an infinite line appears, and If you go right the curve just stops.
How can I update UI for the curve to be redrawn every time I try to move around the view?