Search code examples
iosswiftanimationswiftuioffset

.repeatForever() does not work when value is updated in SwiftUI


I'm trying to make an idle animation in SwiftUI that gets triggered if there's no touch in the screen for 3 seconds. I made a little animation that goes up and down (y offset 15) when there's no touch for 3 seconds and goes back to its original position when a touch occurs. But the thing is, when it goes to its original positon, autoreverses doesn't get triggered. Here's how it looks like:

hmm

Go Live button:

struct GoLiveButton: View {

  @State private var animationOffset: CGFloat = 0
  @Binding var isIdle: Bool

  var body: some View {
    ZStack {
      Button(action: {} ) {
        Text("Go Live")
          .frame(width: 120, height: 40)
          .background(Color.black)
          .foregroundColor(.white)
          .clipShape(Capsule())
          .font(.system(size: 20))
          .shadow(color: .black, radius: 4, x: 4, y: 4)
      }
      .offset(y: animationOffset)
      .animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2).repeatForever(autoreverses: true), value: isIdle)
      .animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: !isIdle)
    }
    .onAppear {
      self.isIdle = true
      self.animationOffset = 15
    }
    .onChange(of: isIdle) { newValue in
      if newValue {
        self.animationOffset = 15
      }
      else {
        self.animationOffset = 0
      }
    }
  }
}

Here is the idle view:

struct StackOverflowView: View {

  @State private var timer: Timer?
  @State private var isIdle = false

  var body: some View {

    GeometryReader { geo in
      GoLiveButton(isIdle: $isIdle)
    }
    .onTapGesture {
      print("DEBUG: CustomTabView OnTapGesture Triggered")
      self.isIdle = false
      self.timer?.invalidate()
      self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
        self.isIdle = true
      }
    }
    .gesture(
      DragGesture().onEnded { _ in
        self.isIdle = false
        self.timer?.invalidate()
        self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
          self.isIdle = true
        }
      }
    )
  }
}

Solution

  • Here is an approach without AnimatableData:

    I added a second timer that just triggers the animations by offset (0, -15, 0, -15 ...) every 2 seconds, repeating forever.

    If isIdle changes to false, we just set offset to 0, and this will be animated too. We reset all timers. And again set the idle timer (3 secs) which when fires will start the animation timer (2 secs). voila.

    (I also restructured the GoLiveButton a little bit so it holds all relevant states in itself, and the parent view only has to control isIdle)

    struct GoLiveButton: View {
        
        @Binding var isIdle: Bool
        @State private var timer: Timer?
        @State private var animationTimer: Timer?
        @State private var animationOffset: CGFloat = 0
        
        var body: some View {
            ZStack {
                Button(action: {} ) {
                    Text("Go Live")
                        .frame(width: 120, height: 40)
                        .background(Color.black)
                        .foregroundColor(.white)
                        .clipShape(Capsule())
                        .font(.system(size: 20))
                        .shadow(color: .black, radius: 4, x: 4, y: 4)
                }
                .offset(y: animationOffset)
                .animation(.timingCurve(0.38, 0.07, 0.12, 0.93, duration: 2), value: animationOffset)
            }
            .onAppear {
                self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                    self.isIdle = true
                    animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
                        animationOffset = (animationOffset == 15) ? 0 : 15
                    }
                }
            }
            .onChange(of: isIdle) { newValue in
                if newValue == false {
                    // reset all
                    self.animationTimer?.invalidate()
                    self.timer?.invalidate()
                    animationOffset = 0
                    
                    self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                        self.isIdle = true
                        animationTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
                            animationOffset = (animationOffset == 15) ? 0 : 15
                        }
                    }
                }
            }
        }
    }
    
    
    struct ContentView: View {
        
        @State private var isIdle = false
        
        var body: some View {
            
            ZStack {
                // for background tap only
                Color.gray.opacity(0.2)
                    .onTapGesture {
                        print("tap")
                        self.isIdle = false
                    }
                
                GoLiveButton(isIdle: $isIdle)
            }
        }
    }