Uber Booking Animation

In June this year, while attending WWDC in San Jose, I saw this loading animation when using Uber to meet friends at Cupertino Library.

The animation was awesome!

I was curious about how they did it, so I did some exploration.

Preview

Well, the line animation still isn’t smooth enough. Does anyone have any better ideas?

Step by Step

Rotating the Map

For the following two reasons, I chose ο£ΏMap to be the default map for demonstration:

  • After WWDC19, the Simulator is built on top of the Meta and Core layers of MapKit, which utilize the GPU of the underlying Mac system.
  • Google Maps requires developer keys for access, which means you can’t directly run it on the simulator after forking the repository on GitHub.

ο£Ώ You can adjust the map interactions by embedding them in a UIView.animate block.

1
2
3
4
5
6
7
8
UIView.animate(withDuration: 1, delay: 0, options: .curveEaseInOut, animations: {  [weak self] in
self?.mapView.setCamera(MKMapCamera(lookingAtCenter: center, fromDistance: fromDistance, pitch: pitch, heading: 0), animated: true)
}) { b in
self.pinAnimation()
UIView.animate(withDuration: 180, delay: 0, options: [.curveLinear, .autoreverse], animations: { [weak self] in
self?.mapView.setCamera(MKMapCamera(lookingAtCenter: center, fromDistance: fromDistance, pitch: pitch, heading: heading), animated: true)
}, completion: nil)
}

Google Maps must use CATransaction.

1
2
3
4
CATransaction.begin()
CATransaction.setValue(NSNumber(float: 1.0), forKey: kCATransactionAnimationDuration)
// change the camera, set the zoom, whatever.
CATransaction.commit()

Pin Animation

We need two CAShapeLayer to create the animation, one for the circle and one for the pin.
Note that the circle disappears once it reaches the maximum value, and the pin has an easeIn curve.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//   Pin Animation
extension MainViewController {

func setUpLayers() {

circleLayer.lineWidth = 1.5
circleLayer.strokeColor = UIColor.white.cgColor
circleLayer.fillColor = UIColor.clear.cgColor
circleLayer.path = UIBezierPath(ovalIn: circleView.bounds).cgPath

circleLayer.shadowColor = UIColor.white.cgColor

pinLayer.fillColor = UIColor.white.cgColor
pinLayer.path = UIBezierPath(roundedRect: pinView.bounds, cornerRadius: 1).cgPath
pinLayer.opacity = 0.9
}

func pinAnimation() {
setUpLayers()

circleView.layer.addSublayer(circleLayer)

self.circleView.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
self.circleView.alpha = 1

UIView.animate(withDuration: 2, delay: 0, options: [.repeat], animations: { [weak self] in
self?.circleView.transform = CGAffineTransform(scaleX: 1, y: 1)
self?.circleView.alpha = 0

}, completion: nil)


pinView.layer.addSublayer(pinLayer)
UIView.animate(withDuration: 1, delay: 0, options: [.curveEaseIn, .repeat, .autoreverse], animations: { [weak self] in

self?.pinView.transform = CGAffineTransform(translationX: 0, y: -4)
self?.pinView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)


}, completion: nil)
}
}

Drawing Polyline on Map

ο£Ώ The map uses MKMapViewDelegate to handle the drawing.

1
2
3
4
5
6
7
8
9
10
11

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let overlay = overlay as? MKPolyline {
let polyline = MKPolylineRenderer(overlay: overlay)
polyline.strokeColor = .white
polyline.lineWidth = 1.5
return polyline
}

return MKOverlayRenderer(overlay: overlay)
}

This is how to add a polyline.

1
2
let currentSegment = MKPolyline(coordinates: subCoordinates, count: subCoordinates.count)
self.mapView.addOverlay(currentSegment)

Animating the Polyline

This is the tricky part.

When we see that the Uber animation is close enough, the polyline is drawn on the map by rotating it.

What I need to do is continue adding and removing polylines on the map to simulate animation.
Similarly, when it reaches the start and end of the pin, the line has a head and a tail.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func animate(route: [CLLocationCoordinate2D], duration: TimeInterval, completion: (() -> Void)? = nil) {
guard route.count > 0 else { return }
var currentStep = 0
let delta = 25, opt = 3.0
let totalSteps = route.count + delta
let stepDrawDur = duration / TimeInterval(totalSteps) * opt
var prePolyline: MKPolyline?

drawingTimer = Timer.scheduledTimer(withTimeInterval: stepDrawDur, repeats: true) { [weak self] timer in
defer { completion?() }
guard let self = self else {
timer.invalidate()
return
}

if let previous = prePolyline {
self.mapView.removeOverlay(previous)
prePolyline = nil
}

if currentStep > totalSteps {
timer.invalidate()
return
}

let start = currentStep-delta < 0 ? 0 : currentStep-delta
let end = currentStep > route.count ? route.count : currentStep

let subCoordinates = Array(route[start..<end])
let currentSegment = MKPolyline(coordinates: subCoordinates, count: subCoordinates.count)
self.mapView.addOverlay(currentSegment)

prePolyline = currentSegment
currentStep += Int(opt)
}
}

Here is the repository. If you have any better ideas, please let me know :)

Updated Information

Well, since the screen refresh rate is 60 fps, it should make total steps/duration β‰ˆ 60.

Translated by gpt-3.5-turbo