How to Pause an Animation in Swiftui

How to pause an animation in SwiftUI?

It looks like there is no control to stop animation.

As your requirement is to start and stop the draw in the middle of progress, one alternate solution is to use a Timer. The tricky point is to clear the arc based on the timer duration.

Here is the code I made a change in your MainView:

NOTE: Adjust the animation duration based on your choice.

struct MainView: View {
@State private var fillPoint = 1.0
@State private var animationDuration = 10.0
@State private var stopAnimation = true
@State private var countdownTimer: Timer?

private var ring: Ring {
let ring = Ring(fillPoint: self.fillPoint)
return ring
}

var body: some View {
VStack {
ring.stroke(Color.red, lineWidth: 15.0)
.frame(width: 200, height: 200)
.padding(40)
.animation(self.stopAnimation ? nil : .easeIn(duration: 0.1))
HStack {
Button(action: {
self.stopAnimation = false
self.countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { _ in
guard self.animationDuration > 0 else {
self.countdownTimer?.invalidate()
return
}
self.fillPoint = self.animationDuration/10
self.animationDuration -= 0.1
})
}) {
Text("Start")
}
Button(action: {
self.countdownTimer?.invalidate()
self.stopAnimation = true
}) {
Text("Stop")
}
}
}
}
}

SwiftUI pause/resume rotation animation

Pausing and resuming the animation in SwiftUI is really easy and you're doing it right by determining the animation type in the withAnimation block.

The thing you're missing is two additional pieces of information:

  1. What is the value at which the animation was paused?
  2. What is the value to which the animation should proceed while resumed?

These two pieces are crucial because, remember, SwiftUI views are just ways of expressing what you want your UI to look like, they are only declarations. They are not keeping the current state of UI unless you yourself provide the update mechanism. So the state of the UI (like the current rotation angle) is not saved in them automatically.

While you could introduce the timer and compute the angle values yourself in a discreet steps of X milliseconds, there is no need for that. I'd suggest rather letting the system compute the angle value in a way it feels appropriate.

The only thing you need is to be notified about that value so that you can store it and use for setting right angle after pause and computing the right target angle for resume. There are few ways to do that and I really encourage you to read the 3-part intro to SwiftUI animations at https://swiftui-lab.com/swiftui-animations-part1/.

One approach you can take is using the GeometryEffect. It allows you to specify transform and rotation is one of the basic transforms, so it fits perfectly. It also adheres to the Animatable protocol so we can easily participate in the animation system of SwiftUI.

The important part is to let the view know what is the current rotation state so that it know what angle should we stay on pause and what angle should we go to when resuming. This can be done with a simple binding that is used EXCLUSIVELY for reporting the intermediate values from the GeometryEffect to the view.

The sample code showing the working solution:

import SwiftUI

struct PausableRotation: GeometryEffect {

// this binding is used to inform the view about the current, system-computed angle value
@Binding var currentAngle: CGFloat
private var currentAngleValue: CGFloat = 0.0

// this tells the system what property should it interpolate and update with the intermediate values it computed
var animatableData: CGFloat {
get { currentAngleValue }
set { currentAngleValue = newValue }
}

init(desiredAngle: CGFloat, currentAngle: Binding<CGFloat>) {
self.currentAngleValue = desiredAngle
self._currentAngle = currentAngle
}

// this is the transform that defines the rotation
func effectValue(size: CGSize) -> ProjectionTransform {

// this is the heart of the solution:
// reporting the current (system-computed) angle value back to the view
//
// thanks to that the view knows the pause position of the animation
// and where to start when the animation resumes
//
// notice that reporting MUST be done in the dispatch main async block to avoid modifying state during view update
// (because currentAngle is a view state and each change on it will cause the update pass in the SwiftUI)
DispatchQueue.main.async {
self.currentAngle = currentAngleValue
}

// here I compute the transform itself
let xOffset = size.width / 2
let yOffset = size.height / 2
let transform = CGAffineTransform(translationX: xOffset, y: yOffset)
.rotated(by: currentAngleValue)
.translatedBy(x: -xOffset, y: -yOffset)
return ProjectionTransform(transform)
}
}

struct DemoView: View {
@State private var isRotating: Bool = false

// this state keeps the final value of angle (aka value when animation finishes)
@State private var desiredAngle: CGFloat = 0.0

// this state keeps the current, intermediate value of angle (reported to the view by the GeometryEffect)
@State private var currentAngle: CGFloat = 0.0

var foreverAnimation: Animation {
Animation.linear(duration: 1.8)
.repeatForever(autoreverses: false)
}

var body: some View {
Button(action: {
self.isRotating.toggle()
// normalize the angle so that we're not in the tens or hundreds of radians
let startAngle = currentAngle.truncatingRemainder(dividingBy: CGFloat.pi * 2)
// if rotating, the final value should be one full circle furter
// if not rotating, the final value is just the current value
let angleDelta = isRotating ? CGFloat.pi * 2 : 0.0
withAnimation(isRotating ? foreverAnimation : .linear(duration: 0)) {
self.desiredAngle = startAngle + angleDelta
}
}, label: {
Text("quot;)
.font(.largeTitle)
.modifier(PausableRotation(desiredAngle: desiredAngle, currentAngle: $currentAngle))
})
}
}

How to stop animation in the current state in SwiftUI

As @Schottky stated above, the offset value has already been set, SwiftUI is just animating the changes.

The problem is that when you tap the circle, onAppear is not called again which means enabled is not checked at all and even if it was it wouldn’t stop the offset from animating.

To solve this, we can introduce a Timer and do some small calculations, iOS comes with a built-in Timer class that lets us run code on a regular basis. So the code would look like this:

struct ContentView: View {
@State private var offset = 0.0
// 1
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
@State var animationDuration: Double = 5.0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.offset(y: CGFloat(self.offset))
.onTapGesture {
// 2
timer.upstream.connect().cancel()
}
.onReceive(timer) { _ in
// 3
animationDuration -= 0.1
if animationDuration <= 0.0 {
timer.upstream.connect().cancel()
} else {
withAnimation(.easeIn) {
self.offset += Double(-UIScreen.main.bounds.size.height + 300)/50
}
}
}
}
}
  1. This creates a timer publisher which asks the timer to fire every 0.1 seconds. It says the timer should run on the main thread, It should run on the common run loop, which is the one you’ll want to use most of the time. (Run loops lets iOS handle running code while the user is actively doing something, such as scrolling in a list or tapping a button.)

  2. When you tap the circle, the timer is automatically cancelled.

  3. We need to catch the announcements (publications) by hand using a new modifier called onReceive(). This accepts a publisher as its first parameter (in this came it's our timer) and a function to run as its second, and it will make sure that function is called whenever the publisher sends its change notification (in this case, every 0.1 seconds).

After every 0.1 seconds, we reduce the duration of our animation, when the duration is over (duration = 0.0), we stop the timer otherwise we keep decreasing the offset.

Check out triggering-events-repeatedly-using-a-timer to learn more about Timer

SwiftUI: Pause and Resume animation

Not sure the exact intent of your code, but here's something you could try:

struct ContentView: View {
@State var recs: [(AnyView, UUID)] = []
@State var isPaused: Bool = true
@State var shouldUpdate: Bool = false

var body: some View {
VStack {
ZStack {
ForEach(recs, id: \.1) { $0.0 }
}
Spacer()
HStack {
Button(isPaused ? "Start" : "Pause") {
isPaused.toggle()
}
Button("Add") {
recs.append(
(AnyView(
Rectangle()
.frame(width: 50, height: 50)
.pausableAnimation(
startingPosition: .init(x: .random(in: 0..<300), y: 0),
endingPosition: .init(x: .random(in: 0..<300), y: .random(in: 300..<700)),
isPaused: $isPaused,
shoudUpdate: $shouldUpdate
)
), UUID())
)
}
Button("Update") {
shouldUpdate = true
}
Button("Reset") {
isPaused = true
recs.removeAll()
}
}
.buttonStyle(.borderedProminent)
.tint(.orange)
}
}
}

extension CGPoint {
func isCloseTo(_ other: CGPoint) -> Bool {
(self.x - other.x) * (self.x - other.x) + (self.y - other.y) * (self.y - other.y) < 10
}
}

struct PausableAnimation: ViewModifier {
@State var startingPosition: CGPoint
@State var endingPosition: CGPoint
@Binding var isPaused: Bool
@Binding var shouldUpdate: Bool
private let publisher = Timer.TimerPublisher(interval: 0.1, runLoop: .main, mode: .default).autoconnect()
@State private var fired = 0

func body(content: Content) -> some View {
content
.position(startingPosition)
.animation(.linear, value: startingPosition)
.onReceive(publisher) { _ in
if !isPaused && !startingPosition.isCloseTo(endingPosition){
startingPosition.x += (endingPosition.x - startingPosition.x) / CGFloat(20 - fired)
startingPosition.y += (endingPosition.y - startingPosition.y) / CGFloat(20 - fired)
fired += 1
}
}
.onChange(of: shouldUpdate) { newValue in
if newValue == true {
updatePosition()
shouldUpdate = false
}
}
}

func updatePosition() {
endingPosition = .init(x: .random(in: 0..<300), y: .random(in: 0..<700))
fired = 0
}
}

extension View {
func pausableAnimation(startingPosition: CGPoint, endingPosition: CGPoint, isPaused: Binding<Bool>, shoudUpdate: Binding<Bool>) -> some View {
modifier(
PausableAnimation(
startingPosition: startingPosition,
endingPosition: endingPosition,
isPaused: isPaused,
shouldUpdate: shoudUpdate
)
)
}
}

Basically the idea here is in each PausableAnimation, a TimerPublisher is created to fire every 0.1 second. Anytime the publisher fires, it will move from startingPosition and endingPosition by 1/20 of the distance between them.

I used a CGPoint to keep both x and y information in a single variable. However, using two separate variables shouldn't change much of the code beside needing to pass in more data in the initializer.

I wrapped the modified view in a (AnyView, UUID), so I can add more of them into an array and display them through ForEach, dynamically.

Feel free to play around, I hope the idea is clear.

SwiftUI: Stop an Animation that Repeats Forever

I figured it out!

An animation using .repeatForever() will not stop if you replace the animation with nil. It WILL stop if you replace it with the same animation but without .repeatForever(). ( Or alternatively with any other animation that comes to a stop, so you could use a linear animation with a duration of 0 to get a IMMEDIATE stop)

In other words, this will NOT work: .animation(active ? Animation.default.repeatForever() : nil)

But this DOES work: .animation(active ? Animation.default.repeatForever() : Animation.default)

In order to make this more readable and easy to use, I put it into an extension that you can use like this: .animation(Animation.default.repeat(while: active))

Here is an interactive example using my extension you can use with live previews to test it out:

import SwiftUI

extension Animation {
func `repeat`(while expression: Bool, autoreverses: Bool = true) -> Animation {
if expression {
return self.repeatForever(autoreverses: autoreverses)
} else {
return self
}
}
}

struct TheSolution: View {
@State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation(Animation.default.repeat(while: active))
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}

struct TheSolution_Previews: PreviewProvider {
static var previews: some View {
TheSolution()
}
}

As far as I have been able to tell, once you assign the animation, it will not ever go away until your View comes to a complete stop. So if you have a .default animation that is set to repeat forever and auto reverse and then you assign a linear animation with a duration of 4, you will notice that the default repeating animation is still going, but it's movements are getting slower until it stops completely at the end of our 4 seconds. So we are animating our default animation to a stop through a linear animation.

How to STOP Animation().repeatForever in SwiftUI

Update - retested with Xcode 13.4 / iOS 15.5

Sample Image

The following should work, moved animation into overlay-only and added conditional to .default (tested with Xcode 11.2 / iOS 13.2)

Button("Start", action: { self.start.toggle() })
.font(.largeTitle)
.padding()
.foregroundColor(.white)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.green))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.green, lineWidth: 4)
.scaleEffect(start ? 2 : 0.9)
.opacity(start ? 0 : 1)
.animation(start ? Animation.easeOut(duration: 0.6)
.delay(1) // Add 1 second between animations
.repeatForever(autoreverses: false) : .default, value: start)
)

On GitHub

Stopping rotation animations in Swift UI

You need to reset animation to default, like below

demo

Text("wheee")
.rotationEffect(.degrees(go ? 360 : 0))
.animation(go ? style : .default) // << here !!

Tested with Xcode 12 / iOS 14

Delay a repeating animation in SwiftUI with between full autoreverse repeat cycles

A possible solution is to chain single pieces of animation using DispatchQueue.main.asyncAfter. This gives you control when to delay specific parts.

Here is a demo:

Sample Image

struct SimpleBeatingView: View {
@State private var isBeating = false
@State private var heartState: HeartState = .normal

@State private var beatLength: TimeInterval = 1
@State private var beatDelay: TimeInterval = 3

var body: some View {
VStack {
Image(systemName: "heart.fill")
.imageScale(.large)
.font(.largeTitle)
.foregroundColor(.red)
.scaleEffect(heartState.scale)
Button("isBeating: \(String(isBeating))") {
isBeating.toggle()
}
HStack {
Text("beatLength")
Slider(value: $beatLength, in: 0.25...2)
}
HStack {
Text("beatDelay")
Slider(value: $beatDelay, in: 0...5)
}
}
.onChange(of: isBeating) { isBeating in
if isBeating {
startAnimation()
} else {
stopAnimation()
}
}
}
}
private extension SimpleBeatingView {
func startAnimation() {
isBeating = true
withAnimation(Animation.linear(duration: beatLength * 0.25)) {
heartState = .large
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.25) {
withAnimation(Animation.linear(duration: beatLength * 0.5)) {
heartState = .small
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength * 0.75) {
withAnimation(Animation.linear(duration: beatLength * 0.25)) {
heartState = .normal
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + beatLength + beatDelay) {
withAnimation {
if isBeating {
startAnimation()
}
}
}
}

func stopAnimation() {
isBeating = false
}
}
enum HeartState {
case small, normal, large

var scale: CGFloat {
switch self {
case .small: return 0.5
case .normal: return 0.75
case .large: return 1
}
}
}

Stop swiftUI animation and skip to destination value

It is not clear what's the logic planned by step from scene-to-scene, but goal as it is postulated now in question can be achieved with the following body:

var body: some View {
VStack(spacing: 30) {
Text("Remaining:")
EmptyView().modifier(DoubleModifier(value: remaining)).id(sceneIndex)

Button(action: {
self.sceneIndex += 1
withAnimation(.linear(duration: 10)) {
self.remaining = 30
}
}) {
Text("Go to next scene")
}
}
}


Related Topics



Leave a reply



Submit