Creating curvy natural movements

Recently, I experimented with playing with MeshGradient, which was introduced in iOS 18. Although Apple briefly talked about how to use it in the talk, which was helpful, I wanted to push it a little further just to get familiar with it and inspire myself for future projects.

I don’t want to get into too much detail here, so here is the result: just a tiny MeshGradient generator. The concept is simple. You map coordinates based on the specified number of points. Regardless of its simplicity, I was very happy with the result.

You can also change the points, colors, and you can show each point’s coordinates, which is somehow useful. Every single time I write in SwiftUI, I greatly appreciate how it is so simple and clean. All this was just 2 or 3 hours’ worth of work.

However, I wanted to push it further to look into the possibilities of visual design. Also, I wanted to train myself to create the logic, and this article is mainly about the implementation and thoughts behind it, so I animated each point to add a lava-lamp-like effect to it. Here is the result. It is beautiful, isn’t it?

So, to achieve this, I started very simply to see what I could do. (I am not a math genius, so I struggled a lot.) Thankfully, we have TimelineView that continuously emits a date flow, and I decided to go with it. The trick is rudimentary trigonometry, and each time the date is emitted, a random angle is generated. Below is the result.

 TimelineView(.animation(paused: paused)) { context in
    ZStack {
        ForEach(points.indices, id: \.self) { index in
            Circle()
                .fill(.black)
                .frame(width: 12, height: 6)
                .position(points[index].controlPoint)
                .onChange(of: context.date) { _, _ in
                    let random = CGFloat.random(in: 0...CGFloat.pi * 2)
                    let vector = CGVector(dx: cos(random), dy: sin(random))
                    
                    withAnimation(.spring) {
                        points[index].controlPoint.x += (vector.dx * amplitude)
                        points[index].controlPoint.y += (vector.dy * amplitude)
                        
                        if points[index].controlPoint.x < initialPoint.x - limit {
                            points[index].controlPoint.x = initialPoint.x - limit
                        } else if points[index].controlPoint.x > initialPoint.x + limit {
                            points[index].controlPoint.x = initialPoint.x + limit
                        } else if points[index].controlPoint.y < initialPoint.y - limit {
                            points[index].controlPoint.y = initialPoint.y - limit
                        } else if points[index].controlPoint.y > initialPoint.y + limit {
                            points[index].controlPoint.y = initialPoint.y + limit
                        }
                    }
                }
        }
    }
}

It was a bit too random and I needed to elaborate on it more. (With that said, it is fun to watch since it looks like they are bugs trying to run away). What’s missing in here is randomness for parameters such as speed and frequency that it takes turn, otherwise, it looks very constant althoug the angle is random (which is okay).

I just wondered if I reduce amplitude, it’ll solve the problem. but that just make movements slower and has nothing to do with making it look natural, so I had to change model in the way mentioned below.

struct Point {
    let id: UUID = UUID()
    var controlPoint: CGPoint = .zero
    var displacement: CGPoint = .zero
    var angle: Double = 0.0
    var freqency: Int = 0
    var speed: CGFloat = 0.0
    var xAmplitude: CGFloat = 0.0
    var yAmplitude: CGFloat = 0.0
    var clockwise: Bool = Bool.random()
}

I set the five parameters along with angle: frequency, speed, xAmplitude, yAmplitude, clockwise which is boolean to control the direction. Even though you would be confused by the numbers of parameters, the trick is relatively simple. Each date emitted, the angle is added by 0.05 which allows the point move on a perfect circle. However, that’s just an infinite movement, so adding randomly generated angle in the range of 0.001 to 0.05 at maximum is needed. After, random speed, xAmplitude and yAmplitude are multiplied, tweaks the direction and speed of the movement. Finally, at a random frequency, each point decides to move clockwise or counterclockwise depending on the frequency and clockwise value. and here is the completed logic below.

func updatePointPosition(index: Int, date: Date) {
    gradientPoints[index].freqency = Int.random(in: 4...7)
    if Int(date.timeIntervalSinceReferenceDate) % gradientPoints[index].freqency == 0 {
        gradientPoints[index].clockwise = Bool.random()
    }
    
    let angle = gradientPoints[index].angle
    let offset: Double = Double.random(in: 0.001...0.05)
    let newAngle = gradientPoints[index].clockwise ? angle + offset : angle - offset
    gradientPoints[index].speed = CGFloat.random(in: 0.01...2.0)
    gradientPoints[index].xAmplitude = CGFloat.random(in: 0.1...2.0)
    gradientPoints[index].yAmplitude = CGFloat.random(in: 0.1...2.0)
    
    let direction = CGVector(dx: cos(newAngle + CGFloat.random(in: 0.0...0.5)), dy: sin(newAngle + CGFloat.random(in: 0.0...0.5)))
    gradientPoints[index].displacement = CGPoint(
        x: direction.dx * gradientPoints[index].speed * gradientPoints[index].xAmplitude,
        y: direction.dy * gradientPoints[index].speed * gradientPoints[index].yAmplitude
    )
    gradientPoints[index].angle = newAngle
    
    withAnimation(.spring(response: 1.0, dampingFraction: 0.5, blendDuration: 0)) {
        gradientPoints[index].controlPoint.x += gradientPoints[index].displacement.x
        gradientPoints[index].controlPoint.y += gradientPoints[index].displacement.y
        
        if gradientPoints[index].controlPoint.x < initialPoint.x - limit {
            gradientPoints[index].controlPoint.x = initialPoint.x - limit
            gradientPoints[index].angle = Double.random(in: 0...Double.pi * 2)
        } else if gradientPoints[index].controlPoint.x > initialPoint.x + limit {
            gradientPoints[index].controlPoint.x = initialPoint.x + limit
            gradientPoints[index].angle = Double.random(in: 0...Double.pi * 2)
        } else if gradientPoints[index].controlPoint.y < initialPoint.y - limit {
            gradientPoints[index].controlPoint.y = initialPoint.y - limit
            gradientPoints[index].angle = Double.random(in: 0...Double.pi * 2)
        } else if gradientPoints[index].controlPoint.y > initialPoint.y + limit {
            gradientPoints[index].controlPoint.y = initialPoint.y + limit
            gradientPoints[index].angle = Double.random(in: 0...Double.pi * 2)
        }
    }
}

and the next is the result. Other noteworthy tricks are how the spring animation used here, how frequent each point should decide whether or not it moves clockwise, and when each point hits the boundary, it tries not to stick at the boundary by generating a new random angle.

It looks fun to watch doesn’t it? I am aware that there are still things to work on, but I am currently quite happy with what came out. It seems like the result is an elegant movement of firefly while the previous one are just flies.

Like I said, it is not perfect and I am pretty confident that there is more efficient way out there. For example, the points collide when they move, which create ugly gradients, also since the points can be also manipulated by the drag gesture, I could not get into shader of any other techniques (Maybe there are ways and I am too dumb to notice them.) But hey, it is completed and I learned something haha.

If you read this, and have a question or suggest any better way or techniques, please let me know via email, mastodon, or anything.