Problem
How can I fix the jitter in my scrolling animation?
As seen in the animations below, there is a brief jitter every time the notes (black ovals) reach the vertical blue line, which makes it appear that the notes went backwards for a split second.
The scrolling animations are triggered by a series of CATransactions, and the jitter occurs every time one scrolling animation completes and another starts.
In the slow motion video below, it looks like there are actually two ovals on top of each other, one which stops and fades out while the other keeps scrolling. But, the code does not actually create one oval atop another.
The videos (gifs) are from an iPhone SE screen recording, not a simulator.
Problem Constraints:
A key objective of this animation is to have smooth, linear scrolling across each note, that begins and ends exactly as each note head reaches the blue line. The blue line represents the current point in time in accompanying music.
The scrolling durations and distances will vary, and these values are generated dynamically during the scrolling, so hard coding the scroll rate for the duration of execution will not work.
Attempted Solutions
- Setting an
isScrollingflag, to prevent new animations from starting before previous animations have completed, did not fix the jitter. - Setting the scrolling start time to happen slightly early (i.e. the length of 1 or 2 screen redraws), didn't work either.
- Doing 1 and 2 together slightly improved the problem, but did not fix it.
Code Snippet
A StaffLayer (defined below) controls the scrolling:
.scrollAcrossCurrentChordLayer()manages theCATransaction. This method is called by the.scrollTimerCADisplayLink.start()and.scrollTimermanage theCADisplayLink
Code heavily abbreviated for clarity
class StaffLayer: CAShapeLayer, CALayerDelegate {
var currentTimePositionX: CGFloat // x-coordinate of blue line
var scrollTimer: CADisplayLink? = nil
/// Sets and starts `scrollTimer`, which is a `CADisplayLink`
func start() {
scrollTimer = CADisplayLink(
target: self,
selector: #selector(scrollAcrossCurrentChordLayer)
)
scrollTimer?.add(
to: .current,
forMode: .defaultRunLoopMode
)
}
/// Trigger scrolling when the currentChordLayer.startTime has passed
@objc func scrollAcrossCurrentChordLayer() {
// don't scroll if the chord hasn't started yet
guard currentChordLayer.startTime < Date().timeIntervalSince1970 else { return }
// compute how far to scroll
let nextChordMinX = convert(
nextChordLayer.bounds.origin,
from: nextChordLayer
).x
let distance = nextChordMinX - currentTimePositionX // distance from note to vertical blue line
// perform scrolling in CATransaction
CATransaction.begin()
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(
name: kCAMediaTimingFunctionLinear
))
CATransaction.setAnimationDuration(
chordLayer.chord.lengthInSeconds ?? 0.0
)
bounds.origin.x += distance
CATransaction.commit()
// set currentChordLayer to next chordLayer
currentChordLayer = currentChordLayer.nextChordLayer
}
}


Make the CATransactions overlap
This seems like a hack, but it fixes the jitter.
If the CATransaction should shift origin by
xover a period ofyseconds, you can set the animation to go1.1 * xover a period of1.1 * yseconds. The the scroll rate is the same, but the second CATransaction starts before the first one has finished, and the jitter disappears.This can be achieved by a small modification of the original code:
I can't give a rigorous explanation for why this works. It may be related to the optimizations happening behind the scenes in CoreAnimation.
A drawback is that the overlap can interfere with subsequent animations if the overlap is too large, so this is not a good general purpose solution, just a hack.