SwiftUI: Pause and Resume animation

Viewed 98

The idea is this: an object has a start position (A) and an end position (B). When the start button is pressed, the object moves from Ax, Ay to Bx, By. At an arbitrary point in time, you can stop its movement and continue.

I tried to implement this with a timer that moves the object every n time periods. It works, but the problem is that the timer eats up a lot of memory and when ten objects are created, the application freezes.

I do not ask you to do the task for me, but I will be very grateful if you tell me where to dig

Code currently in use

typealias RemainingDurationProvider<Value: VectorArithmetic> = (Value) -> TimeInterval
typealias AnimationWithDurationProvider = (TimeInterval) -> Animation
extension Animation {
  static let instant = Animation.linear(duration: 0.0001)
}
struct PausableAnimationModifier<Value: VectorArithmetic>: AnimatableModifier {
  @Binding var binding: Value
  @Binding var paused: Bool
  private let targetValue: Value
  private let remainingDuration: RemainingDurationProvider<Value>
  private let animation: AnimationWithDurationProvider
  var animatableData: Value
  init(binding: Binding<Value>, targetValue: Value, remainingDuration: @escaping RemainingDurationProvider<Value>, animation: @escaping AnimationWithDurationProvider, paused: Binding<Bool>) {
    _binding = binding
    self.targetValue = targetValue
    self.remainingDuration = remainingDuration
    self.animation = animation
    _paused = paused
    animatableData = binding.wrappedValue
  }
  func body(content: Content) -> some View {
    content
      .onChange(of: paused) { isPaused in
        if isPaused {
          withAnimation(.instant) {
            binding = animatableData
          }
        } else {
          withAnimation(animation(remainingDuration(animatableData))) {
            binding = targetValue
          }
        }
      }
  }
}
extension View {
  func pausableAnimation<Value: VectorArithmetic>(binding: Binding<Value>, targetValue: Value, remainingDuration: @escaping RemainingDurationProvider<Value>, animation: @escaping AnimationWithDurationProvider, paused: Binding<Bool>) -> some View {
    self.modifier(PausableAnimationModifier(binding: binding, targetValue: targetValue, remainingDuration: remainingDuration, animation: animation, paused: paused))
  }
}
struct TestView: View {
  @State private var isPaused = false
  @State private var offsetX: Double = .zero
  @State private var startOffsetX: Double = .zero
  @State private var endOffsetX: Double = 200.0
  @State private var offsetY: Double = .zero
  @State private var startPositionY: Double = .zero
  @State private var endPositionY: Double = 500.0
  private let duration: TimeInterval = 6
  private var remainingDurationForX: RemainingDurationProvider<Double> {
    { currentAngle in duration * (1 - (currentAngle - startOffsetX) / (endOffsetX - startOffsetX)) }
  }
  private var remainingDurationForY: RemainingDurationProvider<Double> {
    { currentAngle in duration * (1 - (currentAngle - startPositionY) / (endPositionY - startPositionY)) }
  }
  private let animation: AnimationWithDurationProvider = { duration in
      .linear(duration: duration)
  }
  var body: some View {
    VStack {
      ZStack {
        VStack {
          HStack {
            Rectangle()
              .frame(width: 50, height: 50)
              .offset(x: offsetX, y: offsetY)
              .pausableAnimation(binding: $offsetX,
                        targetValue: endOffsetX,
                        remainingDuration: remainingDurationForX,
                        animation: animation,
                        paused: $isPaused)
              .pausableAnimation(binding: $offsetY,
                        targetValue: endPositionY,
                        remainingDuration: remainingDurationForY,
                        animation: animation,
                        paused: $isPaused)
            Spacer()
          }
          Spacer()
        }
      }
      HStack {
        ControllButton(text: “Start”, action: {
          offsetX = startOffsetX
          offsetY = startPositionY
          withAnimation(animation(duration)) {
            offsetX = endOffsetX
            offsetY = endPositionY
          }
        })
        ControllButton(text: “NewPosition”, action: {
          startOffsetX = endOffsetX
          startPositionY = endPositionY
          endOffsetX = 300
          endPositionY = 30
        })
        ControllButton(text: isPaused ? “Resume” : “Pause”, action: {
          isPaused = !isPaused
        })
        ControllButton(text: “Stop”, action: {
          offsetX = .zero
          offsetY = .zero
        })
      }
      .padding(.bottom)
    }
  }
}
struct ControllButton: View {
  var text: String
  var action: () -> ()
  var body: some View {
    Button(text) {
      action()
    }
    .padding()
    .background(Color.yellow)
    .frame(height: 35)
    .cornerRadius(10)
  }
}
1 Answers

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.

Related