I’m trying to create a lighting bolt using scenekit and I’m following this guide. So far I’ve got a vertical line in my scene using UIBezierPath with an extrusion to make it 3d but I’m not sure how to bend the “line” at the midpoint as described in the link.
func createBolt() {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 1))
path.close()
let shape = SCNShape(path: path, extrusionDepth 0.2)
let color = UIColor.red
shape.firstMaterial?.diffuse.contents = color
let boltNode = SCNNode(geometry: shape)
boltNode.position.z = 0
sceneView.scene.rootNode.addChildNode(boltNode)
}
Algorithm is pretty straightforward:
You start with list of 1 segment from A to B, then on each generation you split each segment on 2 segments by shifting middle point on random offset on his norm
struct Segment {
let start: CGPoint
let end: CGPoint
}
/// Calculate norm of 2d vector
func norm(_ v: CGPoint) -> CGPoint {
let d = max(sqrt(v.x * v.x + v.y * v.y), 0.0001)
return CGPoint(x: v.x / d, y: v.y / -d)
}
/// Splitting segment on 2 segments with middle point be shifted by `offset` on norm
func split(_ segment: Segment, by offset: CGFloat) -> [Segment] {
var midPoint = (segment.start + segment.end) / 2
midPoint = norm(segment.end - segment.start) * offset + midPoint
return [
Segment(start: segment.start, end: midPoint),
Segment(start: midPoint, end: segment.end)
]
}
/// Generate bolt-like line from `start` to `end` with maximal started frequence of `maxOffset`
/// and `generation` of split loops
func generate(from start: CGPoint, to end: CGPoint, withOffset maxOffset: CGFloat, generations: Int = 6) -> UIBezierPath {
var segments = [Segment(start: start, end: end)]
var offset = maxOffset
for _ in 0 ..< generations {
segments = segments.flatMap { split($0, by: CGFloat.random(in: -offset...offset)) }
offset /= 2
}
let path = UIBezierPath()
path.move(to: start)
segments.forEach { path.addLine(to: $0.end) }
return path
}
// MARK: - Example
let start = CGPoint(x: 10, y: 10)
let end = CGPoint(x: 90, y: 90)
let path = generate(from: start, to: end, withOffset: 30, generations: 5)
// MARK: - Helpers
func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
func / (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
return CGPoint(x: lhs.x / rhs, y: lhs.y / rhs)
}
func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
}
SceneKit make Lightning Bolts
This here gives you another approach how to create randomized, full 3D Lightning Bolts in SceneKit (thank you Harry!).
Create a new SceneKit project (for iOS) in Xcode using the default Game template - the one that shows the aircraft in 3D space - delete the aircraft and create an empty scene with a black background. Also globally define your sceneView (to be able to access it from other classes).
Add the following classes and extensions to a new Swift file (import SceneKit):
Classes
class LightningStrike:
class LightningStrike : Geometry {
var bolt:[Lightning] = []
var start = SCNVector3() // stores start position of the Bolt
var end = SCNVector3() // stores end position of the Bolt
static var delayTime = 0.0
override init() {
start = SCNVector3(0.0, +5.0, 0.0) // default, to be changed
end = SCNVector3(0.0, -5.0, 0.0) // default, to be changed
print("Lightning Strike initialized")
}
private func fadeOutBolt() {
for b in bolt {
SCNTransaction.begin()
SCNTransaction.animationDuration = 2.0
b.face.geometry?.firstMaterial?.transparency = 0.0
SCNTransaction.commit()
}
}
func strike() {
for b in bolt { b.face.removeFromParentNode() }
bolt.removeAll()
// Create Main Bolt
bolt.append(Lightning())
bolt[0].createBolt(start,end)
sceneView.scene?.rootNode.addChildNode(bolt[0].face)
// Create child Bolts
for _ in 0 ..< 15 { // number of child bolts
// let parent = Int.random(in: 0 ..< bolt.count) // random parent bolt, an other method
let parent : Int = 0
let start = bolt[parent].centerLine[10 + Int.random(in: 0 ..< 15)] // random node to start from off of parent, pay attention to: numSegments - changing numbers here can cause out of index crash
let length:SCNVector3 = bolt[parent].end.minus(start) // length from our start to end of parent
var end = SCNVector3()
end.x = start.x + length.x / 1.5 + Float.random(in: 0 ... abs(length.x) / 3) // adjust by playing with this numbers
end.y = start.y + length.y / 1.5 + Float.random(in: 0 ... abs(length.y) / 3) // adjust by playing with this numbers
end.z = start.z + length.z / 1.5 + Float.random(in: 0 ... abs(length.z) / 3) // adjust by playing with this numbers
bolt.append(Lightning())
let index = bolt.count-1
bolt[index].width = bolt[parent].width * 0.2
bolt[index].deviation = bolt[parent].deviation * 0.3
bolt[index].createBolt(start,end)
sceneView.scene?.rootNode.addChildNode(bolt[0].face)
}
// Reset delay time and schedule fadeOut
LightningStrike.delayTime = 0.0 // reset delay time
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.fadeOutBolt() }
// Here you can add a Sound Effect
}
deinit {
for b in bolt { b.face.removeFromParentNode() }
bolt.removeAll()
print("Lightning Strike deinitialized")
}
}
class Lightning:
class Lightning : Geometry {
let UNASSIGNED:Float = 999
var start = SCNVector3()
var end = SCNVector3()
var numSegments = Int() // use => 3,5,9,17,33,65
var width = Float()
var deviation = Float()
var vertices:[SCNVector3] = []
var normals:[SCNVector3] = []
var indices:[Int32] = []
var centerLine:[SCNVector3] = []
var face:SCNNode! = nil
override init() {
numSegments = 33 // 17
width = 0.1
deviation = 1.5
centerLine = Array(repeating: SCNVector3(), count: numSegments)
// indexed indices never change
var j:Int = 0
for i in 0 ..< numSegments-1 {
j = i * 3
indices.append(Int32(j + 0)) // 2 triangles on side #1
indices.append(Int32(j + 2))
indices.append(Int32(j + 3))
indices.append(Int32(j + 2))
indices.append(Int32(j + 5))
indices.append(Int32(j + 3))
indices.append(Int32(j + 2)) // side #2
indices.append(Int32(j + 1))
indices.append(Int32(j + 5))
indices.append(Int32(j + 1))
indices.append(Int32(j + 4))
indices.append(Int32(j + 5))
indices.append(Int32(j + 1)) // side #3
indices.append(Int32(j + 0))
indices.append(Int32(j + 4))
indices.append(Int32(j + 0))
indices.append(Int32(j + 3))
indices.append(Int32(j + 4))
}
}
func createNode() -> SCNGeometry {
for i in 0 ..< numSegments { centerLine[i].x = UNASSIGNED }
centerLine[0] = start
centerLine[numSegments-1] = end
var hop:Int = max(numSegments / 2,1)
var currentDeviation = deviation
while true {
for i in stride(from:0, to: numSegments, by:hop) {
if centerLine[i].x != UNASSIGNED { continue }
let p1 = centerLine[i-hop]
let p2 = centerLine[i+hop]
centerLine[i] = SCNVector3(
(p1.x + p2.x)/2 + Float.random(in: -currentDeviation ... currentDeviation),
(p1.y + p2.y)/2 + Float.random(in: -currentDeviation ... currentDeviation),
(p1.z + p2.z)/2 + Float.random(in: -currentDeviation ... currentDeviation))
}
if hop == 1 { break }
hop /= 2
currentDeviation *= 0.6
}
vertices.removeAll()
normals.removeAll()
// triangle of vertices at each centerLine node on XZ plane
let ss:[Float] = [ sin(0), sin(Float.pi * 2/3), sin(Float.pi * 4/3)]
let cc:[Float] = [ cos(0), cos(Float.pi * 2/3), cos(Float.pi * 4/3)]
var w = width
for i in 0 ..< numSegments {
for j in 0 ..< 3 {
vertices.append(SCNVector3(centerLine[i].x + cc[j] * w, centerLine[i].y, centerLine[i].z + ss[j] * w))
}
w *= 0.90 // bolt gets thinner towards endings
}
// normal for each vertex: position vs. position of neighbor on next node
var index1 = Int()
var index2 = Int()
func norm(_ v: SCNVector3) -> SCNVector3 {
let d = max(sqrt(v.x * v.x + v.y * v.y + v.z * v.z), 0.0001)
return SCNVector3(v.x / d, v.y / -d, v.z / d)
}
for i in 0 ..< numSegments {
for j in 0 ..< 3 {
index1 = i * 3 + j // point on current node
index2 = index1 + 3 // neighboring point on next node
if index2 >= vertices.count { index2 -= 6 } // last node references previous node instead
normals.append(norm(vertices[index1].minus(vertices[index2])))
}
}
let geoBolt = self.createGeometry(
vertices: vertices,
normals: normals,
indices: indices,
primitiveType: SCNGeometryPrimitiveType.triangles)
let boltMaterial : SCNMaterial = {
let material = SCNMaterial()
material.name = "bolt"
material.diffuse.contents = UIColor.init(hex: "#BAB1FFFF") // this is a very clear, almost white purple
material.roughness.contents = 1.0
material.emission.contents = UIColor.init(hex: "#BAB1FFFF") // this is a very clear, almost white purple
material.lightingModel = .physicallyBased
material.isDoubleSided = true
material.transparency = 0.0
return material
}()
geoBolt.firstMaterial = boltMaterial
// this makes the bolt not appearing all geometry at the same time - it's an animation effect
DispatchQueue.main.asyncAfter(deadline: .now() + LightningStrike.delayTime) {
boltMaterial.transparency = 1.0
}
LightningStrike.delayTime += 0.01665
// geoBolt.subdivisionLevel = 1 // give it a try or not...
return geoBolt
}
// Creates a Branch of the entire Bolt
func createBolt(_ nstart:SCNVector3, _ nend:SCNVector3) {
start = nstart
end = nend
face = SCNNode(geometry:createNode())
// This will add some glow around the Bolt,
// but it is **enourmous** performence and memory intense,
// you could try to add some SCNTechnique instead
// let gaussianBlur = CIFilter(name: "CIGaussianBlur")
// gaussianBlur?.name = "blur"
// gaussianBlur?.setValue(2, forKey: "inputRadius")
// face.filters = [gaussianBlur] as? [CIFilter]
sceneView.scene?.rootNode.addChildNode(face)
}
}
class Geometry:
class Geometry : NSObject {
internal func createGeometry(
vertices:[SCNVector3],
normals:[SCNVector3],
indices:[Int32],
primitiveType:SCNGeometryPrimitiveType) -> SCNGeometry
{
// Computed property that indicates the number of primitives to create based on primitive type
var primitiveCount:Int {
get {
switch primitiveType {
case SCNGeometryPrimitiveType.line:
return indices.count / 2
case SCNGeometryPrimitiveType.point:
return indices.count
case SCNGeometryPrimitiveType.triangles,
SCNGeometryPrimitiveType.triangleStrip:
return indices.count / 3
default : return 0
}
}
}
//------------------------
let vdata = NSData(bytes: vertices, length: MemoryLayout<SCNVector3>.size * vertices.count)
let vertexSource = SCNGeometrySource(
data: vdata as Data,
semantic: SCNGeometrySource.Semantic.vertex,
vectorCount: vertices.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SCNVector3>.size)
//------------------------
let ndata = NSData(bytes: normals, length: MemoryLayout<SCNVector3>.size * normals.count)
let normalSource = SCNGeometrySource(
data: ndata as Data,
semantic: SCNGeometrySource.Semantic.normal,
vectorCount: normals.count,
usesFloatComponents: true,
componentsPerVector: 3,
bytesPerComponent: MemoryLayout<Float>.size,
dataOffset: 0,
dataStride: MemoryLayout<SCNVector3>.size)
let indexData = NSData(bytes: indices, length: MemoryLayout<Int32>.size * indices.count)
let element = SCNGeometryElement(
data: indexData as Data, primitiveType: primitiveType,
primitiveCount: primitiveCount, bytesPerIndex: MemoryLayout<Int32>.size)
return SCNGeometry(sources: [vertexSource, normalSource], elements: [element])
}
}
Extensions:
for SCNVector3:
extension SCNVector3
{
func length() -> Float { return sqrtf(x*x + y*y + z*z) }
func minus(_ other:SCNVector3) -> SCNVector3 { return SCNVector3(x - other.x, y - other.y, z - other.z) }
func normalized() -> SCNVector3 {
let len = length()
var ans = SCNVector3()
ans.x = self.x / len
ans.y = self.y / len
ans.z = self.z / len
return ans
}
}
for UIColor:
extension UIColor {
public convenience init?(hex: String) {
let r, g, b, a: CGFloat
if hex.hasPrefix("#") {
let start = hex.index(hex.startIndex, offsetBy: 1)
let hexColor = String(hex[start...])
if hexColor.count == 8 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff000000) >> 24) / 255
g = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255
b = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255
a = CGFloat(hexNumber & 0x000000ff) / 255
self.init(red: r, green: g, blue: b, alpha: a)
return
}
}
}
return nil
}
}
Usage:
Init the class in your ViewController like so:
let lightningStrike = LightningStrike()
Also add a tap gesture recogniser (in viewDidLoad) for easy testing:
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
sceneView.addGestureRecognizer(tapGesture)
And the corresponding function that will trigger the Lightning Bolt:
#objc func handleTap(_ gestureRecognize: UIGestureRecognizer) {
lightningStrike.strike() // will fire a Lighting Bolt
}
Results:
Have fun with it.
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
}
}
}
}
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 creating multiple nodes automatically and I want to arrange them around me, because at the moment I'm just increasing of 0.1 the current X location.
capsuleNode.geometry?.firstMaterial?.diffuse.contents = imageView
capsuleNode.position = SCNVector3(self.counterX, self.counterY, self.counterZ)
capsuleNode.name = topic.name
self.sceneLocationView.scene.rootNode.addChildNode(capsuleNode)
self.counterX += 0.1
So the question is, how can I have all of them around me instead of just in one line?
Did someone of you have some math function for this? Thank you!
Use this code (macOS version) to test it:
import SceneKit
class GameViewController: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
let scene = SCNScene()
let scnView = self.view as! SCNView
scnView.scene = scene
scnView.allowsCameraControl = true
scnView.backgroundColor = NSColor.black
for i in 1...12 { // HERE ARE 12 SPHERES
let sphereNode = SCNNode(geometry: SCNSphere(radius: 1))
sphereNode.position = SCNVector3(0, 0, 0)
// ROTATE ABOUT THIS OFFSET PIVOT POINT
sphereNode.simdPivot.columns.3.x = 5
sphereNode.geometry?.firstMaterial?.diffuse.contents = NSColor(calibratedHue: CGFloat(i)/12,
saturation: 1,
brightness: 1,
alpha: 1)
// ROTATE ABOUT Y AXIS (STEP is 30 DEGREES EXPRESSED IN RADIANS)
sphereNode.rotation = SCNVector4(0, 1, 0, (-CGFloat.pi * CGFloat(i))/6)
scene.rootNode.addChildNode(sphereNode)
}
}
}
P.S. Here's a code for creating 90 spheres:
for i in 1...90 {
let sphereNode = SCNNode(geometry: SCNSphere(radius: 0.1))
sphereNode.position = SCNVector3(0, 0, 0)
sphereNode.simdPivot.columns.3.x = 5
sphereNode.geometry?.firstMaterial?.diffuse.contents = NSColor(calibratedHue: CGFloat(i)/90, saturation: 1, brightness: 1, alpha: 1)
sphereNode.rotation = SCNVector4(0, 1, 0, (-CGFloat.pi * (CGFloat(i))/6)/7.5)
scene.rootNode.addChildNode(sphereNode)
}
You need some math here
How I prepare the circle nodes
var nodes = [SCNNode]()
for i in 1...20 {
let node = createSphereNode(withRadius: 0.05, color: .yellow)
nodes.append(node)
node.position = SCNVector3(0,0,-1 * 1 / i)
scene.rootNode.addChildNode(node)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.arrangeNode(nodes: nodes)
}
func createSphereNode(withRadius radius: CGFloat, color: UIColor) -> SCNNode {
let geometry = SCNSphere(radius: radius)
geometry.firstMaterial?.diffuse.contents = color
let sphereNode = SCNNode(geometry: geometry)
return sphereNode
}
Math behind arrange the view into circle
func arrangeNode(nodes:[SCNNode]) {
let radius:CGFloat = 1;
let angleStep = 2.0 * CGFloat.pi / CGFloat(nodes.count)
var count:Int = 0
for node in nodes {
let xPos:CGFloat = CGFloat(self.sceneView.pointOfView?.position.x ?? 0) + CGFloat(cosf(Float(angleStep) * Float(count))) * (radius - 0)
let zPos:CGFloat = CGFloat(self.sceneView.pointOfView?.position.z ?? 0) + CGFloat(sinf(Float(angleStep) * Float(count))) * (radius - 0)
node.position = SCNVector3(xPos, 0, zPos)
count = count + 1
}
}
Note: In third image I have set.
let xPos:CGFloat = -1 + CGFloat(cosf(Float(angleStep) * Float(count))) * (radius - 0)
let zPos:CGFloat = -1 + CGFloat(sinf(Float(angleStep) * Float(count))) * (radius - 0)
That means if you need view around the camera then
use CGFloat(self.sceneView.pointOfView?.position.x ?? 0) or at the random place then provide the value
Output
I'm trying to achieve an fps control mechanism. The problem is that my camera starts moving in the wanted direction then jumps back and forth... also I can not figure out how to connect 2 or more axis. Here is my code:
func gameView(didReceiveMouseMovedEvent event: NSEvent) {
if let p = self.lastMouseP{
var x:CGFloat = 0
x = event.locationInWindow.x - p.x
var y:CGFloat = 0
y = event.locationInWindow.y - p.y
self.player.move(cameraByX: 0, y: x, z: y, w: 0)
}
self.lastMouseP = event.locationInWindow
}
And in player file:
func move(cameraByX x: CGFloat, y: CGFloat, z:CGFloat, w:CGFloat){
let rot = self.head.rotation
let xx = (x)*CGFloat(M_PI)/180.0 + rot.x
let yy = (y)*CGFloat(M_PI)/180.0 + rot.y
let zz = (z)*CGFloat(M_PI)/180.0 + rot.z
//let xr = SCNMatrix4MakeRotation(xx, 1, 0, 0)
let yr = SCNMatrix4MakeRotation(yy, 0, 1, 0)
let zr = SCNMatrix4MakeRotation(zz, 0, 0, 1)
self.head.transform = SCNMatrix4Mult(self.head.transform, zr)
}
I am trying to restructure this Github Swift project on Metaballs so that the circles are represented by SKShapeNodes that are moved around by SKActions instead of CABasicAnimation.
I am not interested in the various Metaball parameters (the handleLenRate, Spacing and so on) appearing in the viewController. I basically want to be able to specify a start and end position for the animation using SKActions.
I am not certain how to achieve this, especially how to replace the startAnimation function below with SKShapeNode's with SKActions:
func startAnimation() {
let loadingLayer = self.layer as! DBMetaballLoadingLayer
loadingAnimation = CABasicAnimation(keyPath: "movingBallCenterX")
loadingAnimation!.duration = 2.5
loadingAnimation!.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
loadingAnimation!.fromValue = NSValue(CGPoint:fromPoint)
loadingAnimation!.toValue = NSValue(CGPoint: toPoint)
loadingAnimation!.repeatCount = Float.infinity
loadingAnimation!.autoreverses = true
loadingLayer.addAnimation(loadingAnimation!, forKey: "loading")
}
Please see what I've been able to do below:
MBCircle class:
struct MBCircle {
var center: CGPoint = CGPointZero
var radius: CGFloat = 0.0
var frame: CGRect {
get {
return CGRect(x: center.x - radius, y: center.y - radius, width: 2 * radius, height: 2 * radius)
}
}
}
struct DefaultConfig {
static let radius: CGFloat = 15.0
static let mv: CGFloat = 0.6
static let maxDistance: CGFloat = 10 * DefaultConfig.radius
static let handleLenRate: CGFloat = 2.0
static let spacing: CGFloat = 160.0
}
GameScene class (representing both the DBMetaballLoadingLayer and DBMetaballLoadingView):
class GameScene: SKScene {
private let MOVE_BALL_SCALE_RATE: CGFloat = 0.75
private let ITEM_COUNT = 2
private let SCALE_RATE: CGFloat = 1.0//0.3
private var circlePaths = [MBCircle]()
var radius: CGFloat = DefaultConfig.radius
var maxLength: CGFloat {
get {
return (radius * 4 + spacing) * CGFloat(ITEM_COUNT)
}
}
var maxDistance: CGFloat = DefaultConfig.maxDistance
var mv: CGFloat = DefaultConfig.mv
var spacing: CGFloat = DefaultConfig.spacing {
didSet {
_adjustSpacing(spacing)
}
}
var handleLenRate: CGFloat = DefaultConfig.handleLenRate
var movingBallCenterX : CGFloat = 0.0 {
didSet {
if (circlePaths.count > 0) {
circlePaths[0].center = CGPoint(x: movingBallCenterX, y: circlePaths[0].center.y)
}
}
}
func _generalInit() {
circlePaths = Array(0..<ITEM_COUNT).map { i in
var circlePath = MBCircle()
circlePath.center = CGPoint(x: (radius * 10 + spacing) * CGFloat(i), y: radius * (1.0 + SCALE_RATE))
circlePath.radius = i == 0 ? radius * MOVE_BALL_SCALE_RATE : radius
circlePath.sprite = SKShapeNode(circleOfRadius: circlePath.radius)
circlePath.sprite?.position = circlePath.center
circlePath.sprite?.fillColor = UIColor.blueColor()
addChild(circlePath.sprite!)
return circlePath
}
}
func _adjustSpacing(spacing: CGFloat) {
if (ITEM_COUNT > 1 && circlePaths.count > 1) {
for i in 1..<ITEM_COUNT {
var circlePath = circlePaths[i]
circlePath.center = CGPoint(x: (radius*2 + spacing) * CGFloat(i), y: radius * (1.0 + SCALE_RATE))
}
}
}
func _renderPath(path: UIBezierPath) {
var shapeNode = SKShapeNode()
shapeNode.path = path.CGPath
shapeNode.fillColor = UIColor.blueColor()
addChild(shapeNode)
}
func _metaball(j: Int, i: Int, v: CGFloat, handeLenRate: CGFloat, maxDistance: CGFloat) {
let circle1 = circlePaths[i]
let circle2 = circlePaths[j]
let center1 = circle1.center
let center2 = circle2.center
let d = center1.distance(center2)
var radius1 = circle1.radius
var radius2 = circle2.radius
if (d > maxDistance) {
_renderPath(UIBezierPath(ovalInRect: circle2.frame))
} else {
let scale2 = 1 + SCALE_RATE * (1 - d / maxDistance)
radius2 *= scale2
_renderPath(UIBezierPath(ovalInRect: CGRect(x: circle2.center.x - radius2, y: circle2.center.y - radius2, width: 2 * radius2, height: 2 * radius2)))
}
if (radius1 == 0 || radius2 == 0) {
return
}
var u1: CGFloat = 0.0
var u2: CGFloat = 0.0
if (d > maxDistance || d <= abs(radius1 - radius2)) {
return
} else if (d < radius1 + radius2) {
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d))
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d))
} else {
u1 = 0.0
u2 = 0.0
}
let angle1 = center1.angleBetween(center2)
let angle2 = acos((radius1 - radius2) / d)
let angle1a = angle1 + u1 + (angle2 - u1) * v
let angle1b = angle1 - u1 - (angle2 - u1) * v
let angle2a = angle1 + CGFloat(M_PI) - u2 - (CGFloat(M_PI) - u2 - angle2) * v
let angle2b = angle1 - CGFloat(M_PI) + u2 + (CGFloat(M_PI) - u2 - angle2) * v
let p1a = center1.point(radians: angle1a, withLength: radius1)
let p1b = center1.point(radians: angle1b, withLength: radius1)
let p2a = center2.point(radians: angle2a, withLength: radius2)
let p2b = center2.point(radians: angle2b, withLength: radius2)
let totalRadius = radius1 + radius2
var d2 = min(v * handeLenRate, p1a.minus(p2a).length() / totalRadius)
d2 *= min(1, d * 2 / totalRadius)
radius1 *= d2
radius2 *= d2
let cp1a = p1a.point(radians: angle1a - CGFloat(M_PI_2), withLength: radius1)
let cp2a = p2a.point(radians: angle2a + CGFloat(M_PI_2), withLength: radius2)
let cp2b = p2b.point(radians: angle2b - CGFloat(M_PI_2), withLength: radius2)
let cp1b = p1b.point(radians: angle1b + CGFloat(M_PI_2), withLength: radius1)
let pathJoinedCircles = UIBezierPath()
pathJoinedCircles.moveToPoint(p1a)
pathJoinedCircles.addCurveToPoint(p2a, controlPoint1: cp1a, controlPoint2: cp2a)
pathJoinedCircles.addLineToPoint(p2b)
pathJoinedCircles.addCurveToPoint(p1b, controlPoint1: cp2b, controlPoint2: cp1b)
pathJoinedCircles.addLineToPoint(p1a)
pathJoinedCircles.closePath()
_renderPath(pathJoinedCircles)
}
func startAnimation() {
}
override func didMoveToView(view: SKView) {
_generalInit()
}
override func update(currentTime: CFTimeInterval) {
/* Called before each frame is rendered */
}
}
I didn't make any changes to the CGPointExtension class.
UPDATE
I am still trying to get the metaball effect, this is my progress so far based on suggestions from Alessandro Ornano:
import SpriteKit
extension CGPoint {
func distance(point: CGPoint) -> CGFloat {
let dx = point.x - self.x
let dy = point.y - self.y
return sqrt(dx * dx + dy * dy)
}
func angleBetween(point: CGPoint) -> CGFloat {
return atan2(point.y - self.y, point.x - self.x)
}
func point(radians radians: CGFloat, withLength length: CGFloat) -> CGPoint {
return CGPoint(x: self.x + length * cos(radians), y: self.y + length * sin(radians))
}
func minus(point: CGPoint) -> CGPoint {
return CGPoint(x: self.x - point.x, y: self.y - point.y)
}
func length() -> CGFloat {
return sqrt(self.x * self.x + self.y + self.y)
}
}
class GameScene: SKScene {
var dBCircle : SKShapeNode!
let radiusDBCircle: CGFloat = 10
let radiusBall: CGFloat = 15
var balls = [SKShapeNode]()
var distanceBtwBalls : CGFloat = 15
private let SCALE_RATE: CGFloat = 0.3
override func didMoveToView(view: SKView) {
// Some parameters
let strokeColor = SKColor.orangeColor()
let dBHeight = CGRectGetMaxY(self.frame)-84 // 64 navigationController height + 20 reasonable distance
let dBStartX = CGRectGetMidX(self.frame)-160 // extreme left
let dBStopX = CGRectGetMidX(self.frame)+160 // extreme right
let dBWidth = dBStopX - dBStartX
let totalBalls = 7 // first and last will be hidden
let ballArea = dBWidth / CGFloat(totalBalls-1)
distanceBtwBalls = ((ballArea-(radiusBall*2))+radiusBall*2)
// Create dbCircle
dBCircle = SKShapeNode.init(circleOfRadius: radiusDBCircle)
dBCircle.position = CGPointMake(CGRectGetMidX(self.frame), dBHeight)
dBCircle.strokeColor = strokeColor
dBCircle.name = "dBCircle"
dBCircle.fillColor = UIColor.clearColor()
addChild(dBCircle)
// Make static balls
for i in 0..<totalBalls {
let ball = SKShapeNode.init(circleOfRadius: radiusBall)
ball.position = CGPointMake(dBStartX+(distanceBtwBalls*CGFloat(i)), dBHeight)
ball.strokeColor = strokeColor
ball.name = "ball"
ball.fillColor = UIColor.clearColor()
balls.append(ball)
if i == 0 || i == totalBalls-1 {
ball.hidden = true
}
addChild(ball)
}
mediaTimingFunctionEaseInEaseOutEmulate(dBCircle,dBStartX: dBStartX,dBStopX: dBStopX)
}
func mediaTimingFunctionEaseInEaseOutEmulate(node:SKShapeNode,dBStartX:CGFloat,dBStopX:CGFloat) {
let actionMoveLeft = SKAction.moveToX(dBStartX, duration:1.7)
actionMoveLeft.timingMode = SKActionTimingMode.EaseInEaseOut
let actionMoveRight = SKAction.moveToX(dBStopX, duration:1.7)
actionMoveRight.timingMode = SKActionTimingMode.EaseInEaseOut
node.runAction(SKAction.repeatActionForever(SKAction.sequence([actionMoveLeft,actionMoveRight])))
}
override func update(currentTime: NSTimeInterval) {
var i = 0
self.enumerateChildNodesWithName("ball") {
node, stop in
let ball = node as! SKShapeNode
if CGRectContainsRect(ball.frame, self.dBCircle.frame) {
if (ball.actionForKey("zoom") == nil) {
let zoomIn = SKAction.scaleTo(1.5, duration: 0.25)
let zoomOut = SKAction.scaleTo(1.0, duration: 0.25)
let seq = SKAction.sequence([zoomIn,zoomOut])
ball.runAction(seq,withKey: "zoom")
}
}
i += 1
}
movingBeziers()
}
func _renderPath(path: UIBezierPath) {
let shapeNode = SKShapeNode(path: path.CGPath)
shapeNode.fillColor = UIColor.blueColor()
addChild(shapeNode)
}
func movingBeziers() {
_renderPath(UIBezierPath(ovalInRect: dBCircle.frame))
for j in 1..<balls.count {
self.latestTestMetaball(j, circleShape: dBCircle, v: 0.6, handleLenRate: 2.0, maxDistance: self.distanceBtwBalls)
}
}
func latestTestMetaball (j: Int, circleShape: SKShapeNode, v: CGFloat, handleLenRate: CGFloat, maxDistance: CGFloat) {
let circle1 = circleShape
let circle2 = balls[j]
let center1 = circle1.position
let center2 = circle2.position
let d = center1.distance(center2)
var radius1 = circle1.frame.width
var radius2 = circle2.frame.width
var u1: CGFloat = 0.0
var u2: CGFloat = 0.0
if (d > maxDistance || d <= abs(radius1 - radius2)) {
return
} else if (d < radius1 + radius2) {
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d))
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d))
} else {
u1 = 0.0
u2 = 0.0
}
let angle1 = center1.angleBetween(center2)
let angle2 = acos((radius1 - radius2) / d)
let angle1a = angle1 + u1 + (angle2 - u1) * v
let angle1b = angle1 - u1 - (angle2 - u1) * v
let angle2a = angle1 + CGFloat(M_PI) - u2 - (CGFloat(M_PI) - u2 - angle2) * v
let angle2b = angle1 - CGFloat(M_PI) + u2 + (CGFloat(M_PI) - u2 - angle2) * v
let p1a = center1.point(radians: angle1a, withLength: radius1)
let p1b = center1.point(radians: angle1b, withLength: radius1)
let p2a = center2.point(radians: angle2a, withLength: radius2)
let p2b = center2.point(radians: angle2b, withLength: radius2)
let totalRadius = radius1 + radius2
var d2 = min(v * handleLenRate, p1a.minus(p2a).length() / totalRadius)
d2 *= min(1, d * 2 / totalRadius)
radius1 *= d2
radius2 *= d2
let cp1a = p1a.point(radians: angle1a - CGFloat(M_PI_2), withLength: radius1)
let cp2a = p2a.point(radians: angle2a + CGFloat(M_PI_2), withLength: radius2)
let cp2b = p2b.point(radians: angle2b - CGFloat(M_PI_2), withLength: radius2)
let cp1b = p1b.point(radians: angle1b + CGFloat(M_PI_2), withLength: radius1)
let pathJoinedCircles = UIBezierPath()
pathJoinedCircles.moveToPoint(p1a)
pathJoinedCircles.addCurveToPoint(p2a, controlPoint1: cp1a, controlPoint2: cp2a)
pathJoinedCircles.addLineToPoint(p2b)
pathJoinedCircles.addCurveToPoint(p1b, controlPoint1: cp2b, controlPoint2: cp1b)
pathJoinedCircles.addLineToPoint(p1a)
pathJoinedCircles.closePath()
let shapeNode = SKShapeNode(path: pathJoinedCircles.CGPath)
shapeNode.fillColor = UIColor.blueColor()
addChild(shapeNode)
}
}
You can easily achieve this kind of animation using moveToX and the timingMode parameter.
New Swift 3 translation below at the end of this answer.
To make an example I use the Xcode Sprite-Kit "Hello, World!" official project demo:
class GameScene: SKScene {
override func didMoveToView(view: SKView) {
/* Setup your scene here */
let myLabel = SKLabelNode(fontNamed:"Chalkduster")
myLabel.text = "Hello, World!"
myLabel.fontSize = 15
myLabel.position = CGPoint(x:CGRectGetMidX(self.frame), y:CGRectGetMidY(self.frame))
self.addChild(myLabel)
mediaTimingFunctionEaseInEaseOutEmulate(myLabel)
}
func mediaTimingFunctionEaseInEaseOutEmulate(node:SKLabelNode) {
let actionMoveLeft = SKAction.moveToX(CGRectGetMidX(self.frame)-100, duration:1.5)
actionMoveLeft.timingMode = SKActionTimingMode.EaseInEaseOut
let actionMoveRight = SKAction.moveToX(CGRectGetMidX(self.frame)+100, duration:1.5)
actionMoveRight.timingMode = SKActionTimingMode.EaseInEaseOut
node.runAction(SKAction.repeatActionForever(SKAction.sequence([actionMoveLeft,actionMoveRight])))
}
}
Output:
Update (This part start to emulate the static ball and the dynamic ball moving left and right but without metaball animations)
class GameScene: SKScene {
var dBCircle : SKShapeNode!
let radiusDBCircle: CGFloat = 10
let radiusBall: CGFloat = 15
private let SCALE_RATE: CGFloat = 0.3
override func didMoveToView(view: SKView) {
// Some parameters
let strokeColor = SKColor.orangeColor()
let dBHeight = CGRectGetMaxY(self.frame)-84 // 64 navigationController height + 20 reasonable distance
let dBStartX = CGRectGetMidX(self.frame)-160 // extreme left
let dBStopX = CGRectGetMidX(self.frame)+160 // extreme right
let dBWidth = dBStopX - dBStartX
let totalBalls = 7 // first and last will be hidden
let ballArea = dBWidth / CGFloat(totalBalls-1)
let distanceBtwBalls = ((ballArea-(radiusBall*2))+radiusBall*2)
// Create dbCircle
dBCircle = SKShapeNode.init(circleOfRadius: radiusDBCircle)
dBCircle.position = CGPointMake(CGRectGetMidX(self.frame), dBHeight)
dBCircle.strokeColor = strokeColor
dBCircle.name = "dBCircle"
dBCircle.fillColor = UIColor.clearColor()
addChild(dBCircle)
// Make static balls
for i in 0..<totalBalls {
let ball = SKShapeNode.init(circleOfRadius: radiusBall)
ball.position = CGPointMake(dBStartX+(distanceBtwBalls*CGFloat(i)), dBHeight)
ball.strokeColor = strokeColor
ball.name = "ball"
ball.fillColor = UIColor.clearColor()
if i == 0 || i == totalBalls-1 {
ball.hidden = true
}
addChild(ball)
}
mediaTimingFunctionEaseInEaseOutEmulate(dBCircle,dBStartX: dBStartX,dBStopX: dBStopX)
}
func mediaTimingFunctionEaseInEaseOutEmulate(node:SKShapeNode,dBStartX:CGFloat,dBStopX:CGFloat) {
let actionMoveLeft = SKAction.moveToX(dBStartX, duration:1.7)
actionMoveLeft.timingMode = SKActionTimingMode.EaseInEaseOut
let actionMoveRight = SKAction.moveToX(dBStopX, duration:1.7)
actionMoveRight.timingMode = SKActionTimingMode.EaseInEaseOut
node.runAction(SKAction.repeatActionForever(SKAction.sequence([actionMoveLeft,actionMoveRight])))
}
override func update(currentTime: NSTimeInterval) {
var i = 0
self.enumerateChildNodesWithName("ball") {
node, stop in
let ball = node as! SKShapeNode
if CGRectContainsRect(ball.frame, self.dBCircle.frame) {
if (ball.actionForKey("zoom") == nil) {
let zoomIn = SKAction.scaleTo(1.5, duration: 0.25)
let zoomOut = SKAction.scaleTo(1.0, duration: 0.25)
let seq = SKAction.sequence([zoomIn,zoomOut])
ball.runAction(seq,withKey: "zoom")
}
}
i += 1
}
}
}
New update with metaball animation:
Finally I've realize this result, my goal is to make it very similar to the original :
is it possible to make some variations to times (for example zoomIn or zoomOut time values or actionMoveLeft, actionMoveRight time values), this is the code:
import SpriteKit
class GameScene: SKScene {
var dBCircle : SKShapeNode!
let radiusDBCircle: CGFloat = 10
let radiusBall: CGFloat = 15
private let SCALE_RATE: CGFloat = 0.3
override func didMoveToView(view: SKView) {
// Some parameters
let strokeColor = SKColor.orangeColor()
let dBHeight = CGRectGetMaxY(self.frame)-84 // 64 navigationController height + 20 reasonable distance
let dBStartX = CGRectGetMidX(self.frame)-160 // extreme left
let dBStopX = CGRectGetMidX(self.frame)+160 // extreme right
let dBWidth = dBStopX - dBStartX
let totalBalls = 7 // first and last will be hidden
let ballArea = dBWidth / CGFloat(totalBalls-1)
let distanceBtwBalls = ((ballArea-(radiusBall*2))+radiusBall*2)
// Create dbCircle
dBCircle = SKShapeNode.init(circleOfRadius: radiusDBCircle)
dBCircle.position = CGPointMake(CGRectGetMidX(self.frame), dBHeight)
dBCircle.strokeColor = strokeColor
dBCircle.name = "dBCircle"
dBCircle.fillColor = UIColor.clearColor()
addChild(dBCircle)
// Make static balls
for i in 0..<totalBalls {
let ball = SKShapeNode.init(circleOfRadius: radiusBall)
ball.position = CGPointMake(dBStartX+(distanceBtwBalls*CGFloat(i)), dBHeight)
ball.strokeColor = strokeColor
ball.name = "ball"
ball.fillColor = UIColor.clearColor()
if i == 0 || i == totalBalls-1 {
ball.hidden = true
}
addChild(ball)
}
mediaTimingFunctionEaseInEaseOutEmulate(dBCircle,dBStartX: dBStartX,dBStopX: dBStopX)
}
func mediaTimingFunctionEaseInEaseOutEmulate(node:SKShapeNode,dBStartX:CGFloat,dBStopX:CGFloat) {
let actionMoveLeft = SKAction.moveToX(dBStartX, duration:2.5)
actionMoveLeft.timingMode = SKActionTimingMode.EaseInEaseOut
let actionMoveRight = SKAction.moveToX(dBStopX, duration:2.5)
actionMoveRight.timingMode = SKActionTimingMode.EaseInEaseOut
node.runAction(SKAction.repeatActionForever(SKAction.sequence([actionMoveLeft,actionMoveRight])))
}
//MARK: - _metaball original function
func _metaball(circle2:SKShapeNode, circle1:SKShapeNode, v: CGFloat, handeLenRate: CGFloat, maxDistance: CGFloat,vanishingTime : NSTimeInterval = 0.015) {
let center1 = circle1.position
let center2 = circle2.position
let d = center1.distance(center2)
var radius1 = radiusDBCircle
var radius2 = radiusBall
if (radius1 == 0 || radius2 == 0) {
return
}
var u1: CGFloat = 0.0
var u2: CGFloat = 0.0
if (d > maxDistance || d <= abs(radius1 - radius2)) {
return
} else if (d < radius1 + radius2) {
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d))
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d))
} else {
u1 = 0.0
u2 = 0.0
}
let angle1 = center1.angleBetween(center2)
let angle2 = acos((radius1 - radius2) / d)
let angle1a = angle1 + u1 + (angle2 - u1) * v
let angle1b = angle1 - u1 - (angle2 - u1) * v
let angle2a = angle1 + CGFloat(M_PI) - u2 - (CGFloat(M_PI) - u2 - angle2) * v
let angle2b = angle1 - CGFloat(M_PI) + u2 + (CGFloat(M_PI) - u2 - angle2) * v
let p1a = center1.point(radians: angle1a, withLength: radius1)
let p1b = center1.point(radians: angle1b, withLength: radius1)
let p2a = center2.point(radians: angle2a, withLength: radius2)
let p2b = center2.point(radians: angle2b, withLength: radius2)
let totalRadius = radius1 + radius2
var d2 = min(v * handeLenRate, p1a.minus(p2a).length() / totalRadius)
d2 *= min(1, d * 2 / totalRadius)
radius1 *= d2
radius2 *= d2
let cp1a = p1a.point(radians: angle1a - CGFloat(M_PI_2), withLength: radius1)
let cp2a = p2a.point(radians: angle2a + CGFloat(M_PI_2), withLength: radius2)
let cp2b = p2b.point(radians: angle2b - CGFloat(M_PI_2), withLength: radius2)
let cp1b = p1b.point(radians: angle1b + CGFloat(M_PI_2), withLength: radius1)
let pathJoinedCircles = UIBezierPath()
pathJoinedCircles.moveToPoint(p1a)
pathJoinedCircles.addCurveToPoint(p2a, controlPoint1: cp1a, controlPoint2: cp2a)
pathJoinedCircles.addLineToPoint(p2b)
pathJoinedCircles.addCurveToPoint(p1b, controlPoint1: cp2b, controlPoint2: cp1b)
pathJoinedCircles.addLineToPoint(p1a)
pathJoinedCircles.closePath()
let shapeNode = SKShapeNode(path: pathJoinedCircles.CGPath)
shapeNode.strokeColor = SKColor.orangeColor()
shapeNode.fillColor = UIColor.clearColor()
addChild(shapeNode)
let wait = SKAction.waitForDuration(vanishingTime)
self.runAction(wait,completion: {
shapeNode.removeFromParent()
})
}
override func update(currentTime: NSTimeInterval) {
var i = 0
self.enumerateChildNodesWithName("ball") {
node, stop in
let ball = node as! SKShapeNode
let enlargeFrame = CGRectMake(ball.frame.origin.x-self.radiusBall*3,ball.frame.origin.y,ball.frame.width+(self.radiusBall*6),ball.frame.height)
if CGRectContainsRect(enlargeFrame, self.dBCircle.frame) {
if (ball.actionForKey("zoom") == nil) {
let zoomIn = SKAction.scaleTo(1.5, duration: 0.25)
zoomIn.timingMode = SKActionTimingMode.EaseInEaseOut
let zoomOut = SKAction.scaleTo(1.0, duration: 0.25)
let wait = SKAction.waitForDuration(0.8)
let seq = SKAction.sequence([zoomIn,zoomOut,wait])
ball.runAction(seq,withKey: "zoom")
}
}
self._metaball(ball, circle1: self.dBCircle, v: 0.6, handeLenRate: 2.0, maxDistance: 4 * self.radiusBall)
i += 1
}
}
}
//MARK: - Extensions
extension CGPoint {
func distance(point: CGPoint) -> CGFloat {
let dx = point.x - self.x
let dy = point.y - self.y
return sqrt(dx * dx + dy * dy)
}
func angleBetween(point: CGPoint) -> CGFloat {
return atan2(point.y - self.y, point.x - self.x)
}
func point(radians radians: CGFloat, withLength length: CGFloat) -> CGPoint {
return CGPoint(x: self.x + length * cos(radians), y: self.y + length * sin(radians))
}
func minus(point: CGPoint) -> CGPoint {
return CGPoint(x: self.x - point.x, y: self.y - point.y)
}
func length() -> CGFloat {
return sqrt(self.x * self.x + self.y + self.y)
}
}
Swift 3:
(I've made a little change to maxDistance: 4 * self.radiusBall with maxDistance: 5 * self.radiusBall to become more similar to the original but you can change it as you wish)
import SpriteKit
class GameScene: SKScene {
var dBCircle : SKShapeNode!
let radiusDBCircle: CGFloat = 10
let radiusBall: CGFloat = 15
private let SCALE_RATE: CGFloat = 0.3
override func didMove(to view: SKView) {
let label = self.childNode(withName: "//helloLabel") as? SKLabelNode
label?.removeFromParent()
self.anchorPoint = CGPoint.zero
// Some parameters
let strokeColor = SKColor.orange
let dBHeight = self.frame.midY
let dBStartX = self.frame.midX-260 // extreme left
let dBStopX = self.frame.midX+260 // extreme right
let dBWidth = dBStopX - dBStartX
let totalBalls = 7 // first and last will be hidden
let ballArea = dBWidth / CGFloat(totalBalls-1)
let distanceBtwBalls = ((ballArea-(radiusBall*2))+radiusBall*2)
// Create dbCircle
dBCircle = SKShapeNode.init(circleOfRadius: radiusDBCircle)
dBCircle.position = CGPoint(x:self.frame.midX, y:dBHeight)
dBCircle.strokeColor = strokeColor
dBCircle.name = "dBCircle"
dBCircle.fillColor = UIColor.clear
addChild(dBCircle)
// Make static balls
for i in 0..<totalBalls {
let ball = SKShapeNode.init(circleOfRadius: radiusBall)
ball.position = CGPoint(x:dBStartX+(distanceBtwBalls*CGFloat(i)), y:dBHeight)
ball.strokeColor = strokeColor
ball.name = "ball"
ball.fillColor = UIColor.clear
if i == 0 || i == totalBalls-1 {
ball.isHidden = true
}
addChild(ball)
}
mediaTimingFunctionEaseInEaseOutEmulate(node: dBCircle,dBStartX: dBStartX,dBStopX: dBStopX)
}
func mediaTimingFunctionEaseInEaseOutEmulate(node:SKShapeNode,dBStartX:CGFloat,dBStopX:CGFloat) {
let actionMoveLeft = SKAction.moveTo(x: dBStartX, duration:2.5)
actionMoveLeft.timingMode = SKActionTimingMode.easeInEaseOut
let actionMoveRight = SKAction.moveTo(x: dBStopX, duration:2.5)
actionMoveRight.timingMode = SKActionTimingMode.easeInEaseOut
node.run(SKAction.repeatForever(SKAction.sequence([actionMoveLeft,actionMoveRight])))
}
//MARK: - _metaball original function
func _metaball(circle2:SKShapeNode, circle1:SKShapeNode, v: CGFloat, handeLenRate: CGFloat, maxDistance: CGFloat,vanishingTime : TimeInterval = 0.015) {
let center1 = circle1.position
let center2 = circle2.position
let d = center1.distance(point: center2)
var radius1 = radiusDBCircle
var radius2 = radiusBall
if (radius1 == 0 || radius2 == 0) {
return
}
var u1: CGFloat = 0.0
var u2: CGFloat = 0.0
if (d > maxDistance || d <= abs(radius1 - radius2)) {
return
} else if (d < radius1 + radius2) {
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d))
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d))
} else {
u1 = 0.0
u2 = 0.0
}
let angle1 = center1.angleBetween(point: center2)
let angle2 = acos((radius1 - radius2) / d)
let angle1a = angle1 + u1 + (angle2 - u1) * v
let angle1b = angle1 - u1 - (angle2 - u1) * v
let angle2a = angle1 + CGFloat(M_PI) - u2 - (CGFloat(M_PI) - u2 - angle2) * v
let angle2b = angle1 - CGFloat(M_PI) + u2 + (CGFloat(M_PI) - u2 - angle2) * v
let p1a = center1.point(radians: angle1a, withLength: radius1)
let p1b = center1.point(radians: angle1b, withLength: radius1)
let p2a = center2.point(radians: angle2a, withLength: radius2)
let p2b = center2.point(radians: angle2b, withLength: radius2)
let totalRadius = radius1 + radius2
var d2 = min(v * handeLenRate, p1a.minus(point: p2a).length() / totalRadius)
d2 *= min(1, d * 2 / totalRadius)
radius1 *= d2
radius2 *= d2
let cp1a = p1a.point(radians: angle1a - CGFloat(M_PI_2), withLength: radius1)
let cp2a = p2a.point(radians: angle2a + CGFloat(M_PI_2), withLength: radius2)
let cp2b = p2b.point(radians: angle2b - CGFloat(M_PI_2), withLength: radius2)
let cp1b = p1b.point(radians: angle1b + CGFloat(M_PI_2), withLength: radius1)
let pathJoinedCircles = UIBezierPath()
pathJoinedCircles.move(to: p1a)
pathJoinedCircles.addCurve(to: p2a, controlPoint1: cp1a, controlPoint2: cp2a)
pathJoinedCircles.addLine(to: p2b)
pathJoinedCircles.addCurve(to: p1b, controlPoint1: cp2b, controlPoint2: cp1b)
pathJoinedCircles.addLine(to: p1a)
pathJoinedCircles.close()
let shapeNode = SKShapeNode(path: pathJoinedCircles.cgPath)
shapeNode.strokeColor = SKColor.orange
shapeNode.fillColor = UIColor.clear
addChild(shapeNode)
let wait = SKAction.wait(forDuration: vanishingTime)
self.run(wait,completion: {
shapeNode.removeFromParent()
})
}
override func update(_ currentTime: TimeInterval) {
var i = 0
self.enumerateChildNodes(withName: "ball") {
node, stop in
let ball = node as! SKShapeNode
let enlargeFrame = CGRect(x:ball.frame.origin.x-self.radiusBall*3,y:ball.frame.origin.y,width:ball.frame.width+(self.radiusBall*6),height:ball.frame.height)
if enlargeFrame.contains(self.dBCircle.frame) {
if (ball.action(forKey: "zoom") == nil) {
let zoomIn = SKAction.scale(to: 1.5, duration: 0.25)
zoomIn.timingMode = SKActionTimingMode.easeInEaseOut
let zoomOut = SKAction.scale(to: 1.0, duration: 0.25)
let wait = SKAction.wait(forDuration: 0.7)
let seq = SKAction.sequence([zoomIn,zoomOut,wait])
ball.run(seq,withKey: "zoom")
}
}
self._metaball(circle2: ball, circle1: self.dBCircle, v: 0.6, handeLenRate: 2.0, maxDistance: 5 * self.radiusBall)
i += 1
}
}
}
//MARK: - Extensions
extension CGPoint {
func distance(point: CGPoint) -> CGFloat {
let dx = point.x - self.x
let dy = point.y - self.y
return sqrt(dx * dx + dy * dy)
}
func angleBetween(point: CGPoint) -> CGFloat {
return atan2(point.y - self.y, point.x - self.x)
}
func point(radians: CGFloat, withLength length: CGFloat) -> CGPoint {
return CGPoint(x: self.x + length * cos(radians), y: self.y + length * sin(radians))
}
func minus(point: CGPoint) -> CGPoint {
return CGPoint(x: self.x - point.x, y: self.y - point.y)
}
func length() -> CGFloat {
return sqrt(self.x * self.x + self.y + self.y)
}
}
Assuming that your question is how to replicate the same behaviour with nodes and SKAction, I believe this should do it.
var shapeNode : SKNode?
func startAnimation(){
if let node = self.shapeNode {
//assume that we have a node initialized at some point and added to the scene at some x1,y1 coordinates
/// we define parameters for the animation
let positionToReach = CGPoint(x: 100, y: 100) /// some random position
let currentPosition = node.position /// we need the current position to be able to reverse the "animation"
let animationDuration = 2.5 //loadingAnimation!.duration = 2.5
/// we define which actions will be run for the node
let actionForward = SKAction.moveTo(positionToReach, duration: animationDuration)
let actionBackwards = SKAction.moveTo(currentPosition, duration: animationDuration)
// we needed two actions to simulate loadingAnimation!.autoreverses = true
/// we wrap the actions in a sequence of actions
let actionSequence = SKAction.sequence([actionForward, actionBackwards]) /// animations to repeat
/// we want to repeat the animation forever
let actionToRepeat = SKAction.repeatActionForever(actionSequence) ///loadingAnimation!.repeatCount = Float.infinity
/// showtime
node.runAction(actionToRepeat)
}
}
Let me know if I need to update any part as I haven't tested it. You still need to use your actual values and objects.
I have referred to referred to How would I repeat an action forever in Swift? while making this reply.