Building an Odometer for Playlist Counts in SwiftUI
For a playlist count feature in an upcoming app I wanted it to update a little more prominently as a feedback when skipping a song or adding new items to the queue. This would also apply to every normal “next song” transition, but in that case few people are likely to watch.
I knew about SwiftUI’s .contentTransition(.numericText()) but was dismayed that the number always rolled in from below, which didn’t make much sense for the usual case of -1 when the next song started playing. Turns out I was not using it correctly: One just needs to pass in the count as the value parameter, and then the effect would align the direction accordingly.
Unfortunately before finding that tiny fix, I recalled an article by Livsy about building an Odometer in SwiftUI building atop that inbound effect. The main point was that SwiftUI directly goes from current to next, whereas for a more interactive feel it can be nice to count up (or down) to show the intermediate steps. That approach works well and is a nice and compact improvement atop the core behavior.
But what kept bothering me was that SwiftUI would always use a blurred transition between the numbers instead of a real “wrap around wheel” style like we’ve seen on the iOS date / time picker.
Custom Odometer
So I set out to build my own variant based on that effect. While trying out various approaches to handle this, the most important parts were breaking up the number into separate digits to be able to control them individually and then building the animation for each one. Each digit view contains the numbers 0-9 in a wrap around way using a combined effect of rotation3DEffect, offset, scale and opacity. To keep it clean only the active digit is shown on rest, and the adjacent one becomes visible while it animates in place.
Then came a lot of playing around with the widget and adjusting the animation curves. For the default transition (±1) I just let SwiftUI handle it across 1 or 2 digits using .animation(…, value: digit). For bigger changes we step through all the intermediate numbers at a slightly faster clip, but still slow enough that one can make out the individual steps. I capped this at 20 which is plenty for the current use-case and didn’t become boring yet (though maybe I am currently too enamoured with this view). For distances beyond that we enter a kind of “reset” where each digit just rotates to the destination in the fastest way possible (not necessarily up or down as given by the count change). Here we step through with aligned timing/distances, so one digit/wheel might settle before the others (which to me makes sense if the distance was shorter). In order to manage this transition and “trust” the animation without stepping through many cases in testing each time I opted to first model the transitional steps on a data layer (which could easily be tested), and then just apply each successive step after a short timeout.
Demo
The final piece looks as follows (at the bottom, with the stock animations shown atop for comparison):
.contentTransition(.numericText()), .contentTransition(.numericText(value: count)), and OdometerViewSource Code
And this is the code to implement this: