Diego Lavalle
Posted on August 12, 2020
Animations and transitions are decisively one of SwiftUI’s fortes. The framework allows us to go as deep as we want in handling specifics like timing function and duration parameters. Moreover it gives us sophisticated tools like the Animatable protocol to completely customize behavior. Here we are going to rely on the more constricted AnimatableModifier protocol to show how we can manually pause and resume and animation as well as adding the ability to reverse and loop over.
A Boring Animation
To focus on function rather than visuals we are going to design what is probably the simplest animation possible: a counter. We will display a number on screen which can be counting up or down within a range like some kind of stopwatch.
We’ll start by creating a basic ViewModifier containing a numeric value formatted to display as a zero-padded positive integer. The value is calculated as a percentage over a maximum which is taken as parameter.
struct CountModifier: ViewModifier {
var maxValue: CGFloat // Maximum count value
var timeDuration: Double // How long it will take to reach max, in seconds
private let percentValue: CGFloat = 0 // Percentage of the maximum count
// Counter value as integer
var value: Int {
Int(percentValue * maxValue)
}
func body(content: Content) -> some View {
Text("\(value, specifier: "%03d")") // Formatted count
.font(.system(.largeTitle, design: .monospaced))
.font(.largeTitle)
}
}
Notice how in this case we are simply ignoring the input view and returning a fresh body. So we are not really modifying anything but rather replacing it entirely. Additionally we added a timeDuration
argument which will be useful to customize the duration of the animation.
Adding External Controls
Outside of the numeric display we want to show a set of controls for pausing and reversing the count. These can be regular buttons which modify state variables defined on the main content view.
struct CountDownUp: View {
@State var isCounting = false // Whether we are counting (moving)
@State var isReversed = false // The direction of the count: up or down
var body: some View {
VStack {
EmptyView()
.modifier(
CountModifier(
maxValue: 100, // We'll count to 100
timeDuration: 10 // In 10 seconds
)
)
HStack {
Button(isCounting ? "⏸" : "▶️") {
isCounting.toggle() // Pause / resume
}
Button(isReversed ? "🔽" : "🔼") {
isReversed.toggle() // Count up / down
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
As an added touch we made the buttons adapt to the current status of the counter thus providing an instant feedback.
Communicating with the Modifier
Because the animation will change depending on the state determined by our external controls we'll necessarily have to pass these variables as bindings:
fileprivate struct CountModifier: ViewModifier {
var maxValue: CGFloat // Maximum count value
var timeDuration: Double
@Binding var percentage: CGFloat
@Binding var isCounting: Bool
@Binding var isReversed: Bool
private var percentValue: CGFloat // Percentage of maximum count
init(
maxValue: CGFloat,
timeDuration: Double,
percentage: Binding<CGFloat>,
isCounting: Binding<Bool>,
isReversed: Binding<Bool>
) {
self.maxValue = maxValue
self.timeDuration = timeDuration
_percentage = percentage // Bindings initialization
_isCounting = isCounting
_isReversed = isReversed
// The percent value is copied from the binding, later on it will be used for the animation
percentValue = percentage.wrappedValue
}
var value: Int {
Int(percentValue * maxValue)
}
func body(content: Content) -> some View {
Text("\(value, specifier: "%03d")")
.font(.system(.largeTitle, design: .monospaced))
.font(.largeTitle)
}
}
We also included a binding to a percentage
which indirectly initializes the internal percentValue
variable by copying its current value. Remember this percentage drives the number on display at any given time during the course of the animation.
The reason why this piece of state needs to be declared outside of the modifier will be revealed momentarily. For now let's just add percentage
to our root view:
struct CountDownUp: View {
@State var percentage = CGFloat(0)
…
var body: some View {
VStack {
EmptyView()
.modifier(
CountModifier(
…
percentage: $percentage,
…
)
…
Great! We now have state shared between controls and display but there is still no actual animation happening.
Animating the Counter
The way AnimatableModifier works, in order to animate a certain value we need to declare a writable computed property named animatableData
backed by some stored property. To turn CountModifier
into an AnimatableModifier we'll base our animatable data on the aforementioned percentage value.
struct CountModifier: AnimatableModifier {
var animatableData: CGFloat {
get { percentValue }
set { percentValue = newValue }
}
…
Apart from needing to use CGFloat
as opposed to regular double/single precision floating point scalars which is required by the protocol, the implementation of the property is as trivial as it gets.
Triggering the Animation
To kick-start the counter we will be adding an onChange
handler to watch over the contents of isCounting
. This is an iOS 14+ only feature. In the accompanying source code (see working example) you'll find a work-around for iOS 13 that uses PassthroughSubject
to send the start signal.
struct CountModifier: AnimatableModifier {
…
func body(content: Content) -> some View {
Text("\(value, specifier: "%03d")")
…
.onChange(of: isCounting) { _ in
handleStart()
}
}
func handleStart() -> () {
if isCounting {
withAnimation(.linear(duration: timeDuration)) {
self.percentage = 1
}
}
}
…
We handle isCounting
being set by using that binding directly to set off the animation.
Pausing and Resuming
Now in order to pause the animation we need to enrich our change handler for the isCounting
variable as it now can switch from on to off. We will also introduce a timeRemaining
property that will tell us exactly how long we have left on the full duration of the count. Finally for the sake of semantics we'll rename handleStart
into the more descriptive handleStartStop
:
struct CountModifier: AnimatableModifier {
…
var timeRemaining: Double {
timeDuration * Double(1 - percentValue)
}
func body(content: Content) -> some View {
Text("\(value, specifier: "%03d")")
…
.onChange(of: isCounting) { _ in
handleStartStop()
}
}
func handleStartStop() -> () { // Formerly handleStart()
if isCounting {
withAnimation(.linear(duration: timeRemaining)) {
self.percentage = 1
}
} else {
withAnimation(.linear(duration: 0)) {
self.percentage = percentValue
}
}
}
…
The thing to notice here is that this time remaining approach only works for linear animations. More advanced timing functions would absolutely complicate the calculation that enables us to resume movement after pausing.
Looping over
It would be nice if our counter doesn’t just stop when it reaches the limit. For this we are forced into hijacking body()
to check whether the percentage has reached its maximum value of 1, in which case we'll force it back to zero.
struct CountModifier: AnimatableModifier {
…
func body(content: Content) -> some View {
// Loop when we reach completion
if percentValue == 1 {
DispatchQueue.main.async {
self.percentage = 0
withAnimation(.linear(duration: self.timeDuration)) {
self.percentage = 1
}
}
}
return actualBody(content) // Original body of the view
}
// Moved the original body into a separate function
func actualBody(_ content: Content) -> some View {
Text("\(value, specifier: "%03d")")
…
}
…
After resetting the percentage we'll kick it off again with an asynchronous call. To keep things clean we'll move the previous body
function into actualBody
to keep taking advantage of the enhanced ViewBuilder syntax.
Shift into Reverse
Whether the counting is on or now, we want to be able to change the direction. For this we monitor isReversed
for changes and handle the new value using the same technique as before. We'll also need to alter the start/stop and the loop logic to take into consideration the current direction.
struct CountModifier: AnimatableModifier {
…
var timeRemaining: Double {
isReversed
? timeDuration * Double(percentValue)
: timeDuration * Double(1 - percentValue)
}
func body(content: Content) -> some View {
if (isReversed && percentValue == 0) || (!isReversed && percentValue == 1) {
DispatchQueue.main.async {
self.percentage = self.isReversed ? 1 : 0
withAnimation(.linear(duration: self.timeDuration)) {
self.percentage = self.isReversed ? 0 : 1
}
}
}
return actualBody(content)
}
func actualBody(_ content: Content) -> some View {
Text("\(value, specifier: "%03d")")
…
.onChange(of: isReversed) { _ in
handleReverse()
}
}
func handleStartStop() -> () {
if isCounting {
withAnimation(.linear(duration: timeRemaining)) {
self.percentage = isReversed ? 0 : 1
}
} else { … }
}
}
func handleReverse() -> () {
if isCounting {
withAnimation(.linear(duration: 0)) {
self.percentage = percentValue
}
withAnimation(.linear(duration: timeRemaining)) {
self.percentage = isReversed ? 0 : 1
}
}
}
}
Notably the time remaining was re-calculated by pondering the percentage or its inverse in the case of counting downwards.
Final Thoughts
This is a fun way of gaining control over events within a linear animation. It's definitely not the only one and might not even the preferred way as it does involve some hacks. But on the other hand it has as an advantage the simplicity of not having to deal with the Animatable protocol directly.
That's it, as always check out the associated Working Example for the complete source code and interactive demo.
FEATURED EXAMPLE: Count Down Up - Some kinda stopwatch
Posted on August 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.