Close
Type at least 1 character to search
Back to top

The Swift Priority Queue Tutorial.

 

Use a Priority Queue to Track 20*20 Collisions.

Animate with a SwiftUI Canvas Embedded in a Timeline View.

Last time we ended with a discussion of the practical implementation of a Priority Queue.

This time we’ll see it in action.

Download the source to follow along:  https://github.com/fastestapp/Collide

The Priority Queue can solve a difficult problem: How to handle collisions between 20 particles on a screen where in reality you need to calculate the possible collisions between each particle and every other particle. This is approximately 20 * 19 collisions.

Today we’ll consider only the simple case where 20 particles can collide with 4 walls.

We’ll use a TimelineView to update our SwiftUI app every screen refresh. And we’ll only examine particles that bounce off walls — we won’t yet consider collisions between particles.

Consider each of the seven files in the project in turn. 

1. CollideApp.swift This is the boilerplate app file.

You can look at other resources if you want details on this file.

2. ContentView.swift. This contains the TimelineView and the Canvas for drawing.

We’ll consider each of these in turn. They are each valuable things to learn. 

In SwiftUI we can create a view that updates itself according to a schedule. This is a TimelineView. The main variable we want to control in a TimelineView is the schedule. The schedule is the frequency of view refresh. 

The TimelineView schedule can be set to update in a continuous fashion or at custom intervals. 

In this project, we want to update the schedule using a default frequency optimal for animations.

Consider our ContentView:

import SwiftUI

struct ContentView: View {

    @StateObject var particleSystem = ParticleSystem()

    

    var body: some View {

        ZStack {

            Color(.blue)

            TimelineView(.animation) { timeline in

                Canvas { context, size in

                    particleSystem.update(date: timeline.date)   

                    let baseTransform = context.transform

                    for particle in particleSystem.particles {

                        context.translateBy(x: particle.x, y: particle.y)

                        context.draw(particleSystem.image, at: .zero)

                        context.transform = baseTransform

                    }

                }

            }

            VStack {

                PriorityQueueCount()

                Spacer()

                    .frame(width: 100, height: 50)

            }

        }

        .ignoresSafeArea()

        .statusBarHidden()

        .onAppear{

            particleSystem.startup()

        }

    }

}

The code from this file shows how we create a TimelineView with the .animation schedule. And it shows how we send a ‘timeline’ value to the closure. This value contains the timeline.date that we capture to update our particle system. More generally, you would use this date to update some parameter of your canvas that changes during the animation.

The TimelineView itself does not return a View. It merely sets the redraw interval for whatever view is inside its closure. 

In our case, we are using another specialized view called a Canvas, inside that TimelineView. 

The Canvas might be familiar to you. It’s really a CGGraphicsContext in disguise. It is not technically necessary to provide a Canvas here, since we could generate the animations without a Canvas. But it will likely provide better performance for complex animations. 

Inside the closure for the Canvas is where we’ll do all the work. But even so, you can see it’s only a few lines of code at this point. 

First, we update the particle system. This means that we are assiging a new x and y coordinate for each particle in the system. We will see that the new coordinates are just slightly displaced from the current coordinates. They are moved along the same direction by some small amount that depends on the speed of the particle. 

Second, after we calculate the new positions of all the particles, we assign the baseTransform. Think of this as recording a snapshot of all of the current positions.

Third we go through each particle and translate its position by the newly updated x and y coordinates and we draw these to the context.

Fourth we apply the changes to the context by telling it to transform in comparison to what it was originally. Transform using the original baseTransform.  

That’s the functional part of the ContentView. 

The code also adds two bits of Text near the bottom that provide us with a notion of how big the priority queue is, and how many times we insert a new collision event into the queue. If you follow the video closely, you’ll see that after each collision, we calculate the next collision event and insert that into the priority queue. 

3. Particle.swift

Let’s look at the particle. It conforms to Hashable and Equatable. It has four important properties in the x and y coordinates, and the angle and speed. 

It also has two critical functions. These calculate the time until the next wall collision. 

The time is the time along the diagonal line. 

And note that in our context, 0 radians or 2*pi radians is off to the right: due east. 

So for example, if our particle has an angle of 0.1 radians, then it is traveling to the right and just slightly down. We already know the distance from the particle to the right wall — it is the width of the screen minus the x coordinate of the particle. So we can calculate the distance along the angular path to the right wall. This is distanceToRight / cos(angle).

And likewise we can calculate the distance along the particle’s path to the wall using variations of cosine. 

The two functions calculate the time to the vertical wall and separately, the time to the horizontal wall. And whichever time is smaller is the wall we will hit next. 

    func timeUntilVertWallCollision() -> Double {

        let width = UIScreen.main.bounds.width

        let distanceToRight = width – x

        let distanceToLeft = x

        var hypotenuse = 0.0

        let actualVelocity = self.speed

        // going right:

        if ( self.angle >= (1.5 * .pi) && self.angle <= (2 * .pi) ) {

            // going right and up:

            hypotenuse = distanceToRight / cos((2 * .pi) – self.angle)

        } else if ( self.angle >= 0 && self.angle < (0.5 * .pi) ) {

            // going right and down:

            hypotenuse = distanceToRight / cos(self.angle)

        } else if ( self.angle >= (0.5 * .pi) && self.angle <= (.pi) ) {

            // going left and down:

            hypotenuse = distanceToLeft / cos(.pi – self.angle)

        } else if ( self.angle >= (.pi) && self.angle <= (1.5 * .pi) ) {

            // going left and up:

            hypotenuse = distanceToLeft / cos(self.angle – .pi)

        }

        

        let trueTime = abs(hypotenuse) / actualVelocity

        return trueTime

    }

4. ParticleSystem.swift

The ParticleSystem is of course where we create and update the positions of all the particles. 

In the createParticle() function, we simply assign a random angle and random coordinates to each particle. 

The angle is random but always between 0 and 90 degrees, so the particles all start out going down and to the right.

And the coordinates are random but in a range that starts them all near the upper left of the screen. The screen of my simulator is 1194 X 834. So starting values between 0 and 100 will all be in the upper left.

The update(date:) function is our first important function.

It goes through each of the particles in the particle system, calculates the time until each hits a wall, and inserts that time into the priority queue. 

Note that the function calculates the initial priority queue once here, and after that, the calculations are done in evaluateNextWallCollision() in the PriorityQueue.swift class itself. 

Particles spend most of their time moving in a straight line. Exactly how far depends on their velocity. This is calculated here:

particle.x += cos(particle.angle) * particle.speed  * timeBetweenContextUpdates

particle.y += sin(particle.angle) * particle.speed  * timeBetweenContextUpdates

Here is how the class creates particles and updates their positions:

    // Create particles with angles and positions randomized to a small range of values

    private func createParticle() -> Particle {

        let angleDegrees = Double.random(in: 0…90)

        let angleRadians = angleDegrees * .pi / 180

        

        return Particle (

            angle: angleRadians,

            speed: 300,

            xCoord: Double.random(in: 0…100),

            yCoord: Double.random(in: 0…80)

        )

    }

    

    func update(date: Date) {

        let timeBetweenContextUpdates = date.timeIntervalSince1970 – lastContextUpdate.timeIntervalSince1970

        lastContextUpdate = date

        

        // Here’s where we add a small amount of x and y distance to the position of each particle

        for particle in particles {

            // Here we figure the first collision of each particle after the initial particle creation:

            if !didInitialCheck {

                // Here we first calculate the time until the next vertical wall collision, where

                // a vertical wall is the left or right side:

                let hTime = particle.timeUntilVertWallCollision()

                let vTime = particle.timeUntilHorizWallCollision()

                let timeToHit = (hTime < vTime) ? hTime : vTime

                if timeToHit > 0 {

                    let updateDate = Date() + timeToHit

                    let particleUpdateEvent = ParticleUpdateEvent(P1: particle, P2: nil, updateTime: updateDate)

                    priorityQueue.insert(x: particleUpdateEvent)

                }

            }

            particle.x += cos(particle.angle) * particle.speed  * timeBetweenContextUpdates

            particle.y += sin(particle.angle) * particle.speed  * timeBetweenContextUpdates

        }

        

        didInitialCheck = true

        

        // Call this on a regular interval

        priorityQueue.runPriorityQueue()

    }

 

At the end, we call runPriorityQueue. Let’s look at that next:

5. PriorityQueue.swift

The PQ array in the PriorityQueue class holds all of the particle update events. Its order is maintained in reverse chronological order.

The oldest events are at the highest index number. This is roughly a first in-first out FIFO order. But not exactly. 

We call runPriorityQueue() on the TimelineView schedule. 

So it may happen 60 times a second — or whatever frequency the TimelineView is scheduled to update.

    public func runPriorityQueue() {

        var done: Bool = false

        

        if startingDate == nil {

            startingDate = Date()

        }

        

        while  !done && PQ.count > 1 {

            let updateEvent = PQ[PQ.count – 1]

            

            if updateEvent.updateTime <= Date() && updateEvent.p2 == nil {

                // This is a wall event, so we will hit the wall and then reverse angle while keeping the velocity the same

                if (updateEvent.p1.y > (UIScreen.main.bounds.height – 10) ) ||

                    (updateEvent.p1.y < 10) {

                    updateEvent.p1.angle = reverseAngleHWall(updateEvent.p1.angle)

                } else {

                    updateEvent.p1.angle = reverseAngleVWall(updateEvent.p1.angle)

                }

                PQ = deleteMinimum(PQ)

                

                evaluateNextWallCollision(updateEvent.p1)

            } else if updateEvent.updateTime <= Date() && updateEvent.p2 != nil{

                // This is a two particle collision event

                updateEvent.p1.angle = reverseAngleVWall(updateEvent.p1.angle)

                if let p2 = updateEvent.p2 {

                    p2.angle = reverseAngleVWall(p2.angle)

                }

                PQ = deleteMinimum(PQ)

            } else {

                done = true

            }

        }

    }

 

    // Add a new value to the Priority Queue and delete the maximum value if the size of the Priority Queue exceeds the max:

    public func insert<T: Comparable>(x: T) where T: ParticleUpdateEvent {

        // Insert new events at position 1, and sink them as far as needed. But this won’t be too far, because they are almost in order.

        // Previously we appended new events to the same end of the array that we were reading from, and thus they had to swim much further every time.

        PQ.insert(x, at: 0)

        PQ = sink(PQ, 0)

        insertionCount += 1

    }

    

Each time runPriorityQueue() is called, it does two things:

First, it evaluates whether the last ParticleUpdateEvent entry in the PQ array is scheduled to take place now or before now. If so, then we must execute it. 

The update event contains the time when it must be executed as well as the particle that must change course. 

 

Second, if the update event must be executed, then the particle reverses its angle as if it hit a wall. Because, it did hit a wall. 

 

Exactly what that new angle is depends on the incoming angle and which wall is being hit. But it’s all the same: reverse the angle. 

After we have reversed the angle, that means we have executed the update event, and we can delete it from the PQ array. 

That is almost everything! But one:

 

When we insert a new updateEvent into the priority queue, we insert it at index 0. 

And immediately after that we ‘swim’ the particle into its place in the array so the array is always maintained in order. The new events at the low index positions, the old events at the high index positions. 

We have to swim the update event into order each time because when a new update event is calculated, it may be a wall collision that is going to happen soon (because the wall is already close to the particle), or it may happen later (because the wall is far from the particle). 

That’s it! 

Next time we’ll consider the more complicated case of particles hitting each other. When that is possible, then the number of collisions rises and the importance of the priority queue becomes even greater.

The Collide App

 

Designers

James Robinson Douglas Stewart

Date