SwiftUI - Animation with a curvilinear path [duplicate] - swift

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
}
}
}
}

Related

Updating a path before Animating it in SwiftUI

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

Current time is not displayed in the view

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

Drawing concentric circles in Swift with different Colors using a ForEach loop

I am using the SwiftUI and I want to use a ForEach loop that draws concentric circles that adjusts which circle is highlighted with a Slider. When I run the following code I get a one circle colored red when I should get the second circle to be blue because the Stepper is 2.0. How can I update the color of a view based on the value of a slider. I did not bother adding the slider yet because I wanted to get a constant value working first. Does anyone know how to get these concentric circles stacked on top of each other with one of them showing a different color than the rest depending on the value of a variable?
import SwiftUI
struct CircleView: View {
#State var color = Color.red
#State var stepperSelector = 2.0
var body: some View {
ZStack {
ForEach((0...5), id: \.self) { x in
Path { path in
var center = (x: 187, y:240)
var radius = Double(185) - Double(x) * Double(185 / 7)
let factor = Double(Int(radius)) / 5
path.move(to: CGPoint(x: center.x + Int(radius), y: center.y))
for i in stride(from: 0, to: 361, by: 1){
let center = CGPoint(x: 187, y: 240)
let i = Double(i)
let radians = i * Double.pi / 180
let x = radius * cos(radians) + Double(center.x)
let y = radius * sin(radians) + Double(center.y)
path.addLine(to: CGPoint(x: x, y: y))
}
}
.fill(self.color)
.onAppear{
if Int(self.stepperSelector) == Int(x) {
self.color = Color.blue
} else {
self.color = Color.red
}
}
}
}
}
}
struct CircleView_Previews: PreviewProvider {
static var previews: some View {
CircleView()
}
}
.fill will fill the inside of the path which is why the single red color circle output. Also, you can switch the color inline without using a separate state var.
struct CircleView: View {
#State var stepperSelector = 2.0
var body: some View {
ZStack {
ForEach((0...5), id: \.self) { x in
Path { path in
let center = (x: 187, y: 240)
let radius = Double(185) - Double(x) * Double(185 / 7)
path.move(to: CGPoint(x: center.x + Int(radius), y: center.y))
for i in stride(from: 0, to: 361, by: 1){
let center = CGPoint(x: 187, y: 240)
let i = Double(i)
let radians = i * Double.pi / 180
let x = radius * cos(radians) + Double(center.x)
let y = radius * sin(radians) + Double(center.y)
path.addLine(to: CGPoint(x: x, y: y))
}
}
.stroke(Int(self.stepperSelector) == Int(x) ? Color.blue : Color.red)
}
}
}
}
Output

How to move a view/shape along a custom path with swiftUI?

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
}
}
}
}

Designing your own button in SwiftUI

I'm trying to draw the following picture with each circle segment consisting of 3 buttons which can be clicked.
I've copied code from this forum, but when I try to use it, I get an error message and the code will not compile. Tried various versions (latest one printed), but none work. What am I doing wrong? Also, will the integers i and j be passed on to the call of the class CustomShapeButton?
import SwiftUI
import UIKit
struct ContentView: View {
static let segmentCount = 4
static let circleCount = 4
var i: Int = 0
var j: Int = 0
var NewButtons: CustomShapeButton
var body: some View {
ZStack {
ForEach(1..<ContentView.circleCount){ j in
ForEach(1..<ContentView.segmentCount){ i in
NewButtons = CustomShapeButton()
}
}
}
}
}
class CustomShapeButton: UIButton {
lazy var pantsShapeBezierPath: UIBezierPath = {
// Crate new path
let path = UIBezierPath()
var r = CGFloat(75.0)
r = CGFloat(50.0 + (CGFloat(j) - 1.0) * 50.0)
let center_x = CGFloat(200.0)
let center_y = CGFloat(200.0)
var arc_start = CGFloat(45.0 * CGFloat(Double.pi) / 180.0)
arc_start = CGFloat((45.0 + (CGFloat(i) - 1.0) * 90.0)) * CGFloat(Double.pi) / 180.0
let arc_length = CGFloat(90.0 * CGFloat(Double.pi) / 180.0)
let arc_width = CGFloat(45.0)
let line0Target_x = center_x + r * CGFloat(cos(Double(arc_start)))
let line0Target_y = center_y + r * CGFloat(sin(Double(arc_start)))
let line1Target_x = center_x + (r + arc_width) * CGFloat(cos(Double(arc_start + arc_length)))
let line1Target_y = center_x + (r + arc_width) * CGFloat(sin(Double(arc_start + arc_length)))
path.move(to: CGPoint(x: line0Target_x, y: line0Target_y))
path.addArc(center: CGPoint(x: center_x, y: center_y), radius: r, startAngle: Angle(radians: Double(arc_start)), endAngle: Angle(radians: Double(arc_start + arc_length)), clockwise: false)
path.addLine(to: CGPoint(x: line1Target_x, y: line1Target_y))
path.addArc(center: CGPoint(x: center_x, y: center_y), radius: (r + arc_width), startAngle: Angle(radians: Double(arc_start + arc_length)), endAngle: Angle(radians: Double(arc_start)), clockwise: true)
path.addLine(to: CGPoint(x: line0Target_x, y: line0Target_y))
path.close()
return path
}()
override func draw(_ rect: CGRect) {
super.draw(rect)
// Set shape filling color
UIColor.red.setFill()
// Fill the shape
pantsShapeBezierPath.fill()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Handling touch events
if (pantsShapeBezierPath.contains(point)) {
return self
} else {
return nil
}
}
Here you go. You just need to implement the button style first then use it. This result is 9 buttons. I would suggest having 9 functions for each button and not using the same one.
struct CustomShapeButtonStyle: ButtonStyle {
var j: Int
var i: Int
var fillColor: Color
func makeBody(configuration: Self.Configuration) -> some View {
GeometryReader { geometry in
Path { path in
var r = CGFloat(75.0)
r = CGFloat(50.0 + (CGFloat(self.j) - 1.0) * 50.0)
let center_x = CGFloat(200.0)
let center_y = CGFloat(200.0)
var arc_start = CGFloat(45.0 * CGFloat(Double.pi) / 180.0)
arc_start = CGFloat((45.0 + (CGFloat(self.i) - 1.0) * 90.0)) * CGFloat(Double.pi) / 180.0
let arc_length = CGFloat(90.0 * CGFloat(Double.pi) / 180.0)
let arc_width = CGFloat(45.0)
let line0Target_x = center_x + r * CGFloat(cos(Double(arc_start)))
let line0Target_y = center_y + r * CGFloat(sin(Double(arc_start)))
let line1Target_x = center_x + (r + arc_width) * CGFloat(cos(Double(arc_start + arc_length)))
let line1Target_y = center_x + (r + arc_width) * CGFloat(sin(Double(arc_start + arc_length)))
path.move(to: CGPoint(x: line0Target_x, y: line0Target_y))
path.addArc(center: CGPoint(x: center_x, y: center_y), radius: r, startAngle: Angle(radians: Double(arc_start)), endAngle: Angle(radians: Double(arc_start + arc_length)), clockwise: false)
path.addLine(to: CGPoint(x: line1Target_x, y: line1Target_y))
path.addArc(center: CGPoint(x: center_x, y: center_y), radius: (r + arc_width), startAngle: Angle(radians: Double(arc_start + arc_length)), endAngle: Angle(radians: Double(arc_start)), clockwise: true)
path.addLine(to: CGPoint(x: line0Target_x, y: line0Target_y))
path.closeSubpath()
}.fill(self.fillColor)
}
}
}
struct ContentView: View {
static let segmentCount = 4
static let circleCount = 4
var body: some View {
ZStack {
ForEach(1..<Self.circleCount) { j in
ForEach(1..<Self.segmentCount) { i in
Button(action: {
print("I was clicked")
}) {
Text("") // Just a placeholder
}.buttonStyle(CustomShapeButtonStyle(j: j, i: i, fillColor: Color(red: 177/255, green: 152/255, blue: 177/255)))
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Your code is mixing both UIKit and SwiftUI in an improper way. You can't do this in a body statement:
NewButtons = CustomShapeButton()
Everything inside of the body function has to be a View. This is an assignment statement.
Next, CustomShapeButton is a UIButton subclass. You can't use that here, either. If you want to use this random internet code (which I do not suggest), you will have to wrap it inside of a UIViewRepresentable. Instead, consider following Apple's guide on how to create custom path-based Views.
https://developer.apple.com/tutorials/swiftui/drawing-paths-and-shapes
Once you can draw the shapes that you want, wrap it in a Button:
Button(action: { /* do your action */ }) {
CircularCutoutShape()
}