SwiftUI暂停/恢复旋转动画。

7

到目前为止,我看到了以下用于停止动画的技术,但我想要的是旋转视图停在当前的角度,而不是返回到0度。

struct DemoView: View {
    @State private var isRotating: Bool = false
    var foreverAnimation: Animation {
        Animation.linear(duration: 1.8)
            .repeatForever(autoreverses: false)
    }
    var body: some View {
        Button(action: {
            self.isRotating.toggle()
        }, label: {
            Text("").font(.largeTitle)
            .rotationEffect(Angle(degrees: isRotating ? 360 : 0))
            .animation(isRotating ? foreverAnimation : .linear(duration: 0))
        })
    }
}

似乎将旋转角度设为360或0不允许我将其冻结在中间角度(并最终从那里恢复)。有什么想法吗?
3个回答

17

在SwiftUI中暂停和恢复动画非常容易,只需在withAnimation块中确定动画类型即可。

你需要的是两个额外的信息:

  1. 动画暂停时的值是多少?
  2. 恢复时动画应该前往的值是多少?

这两个信息非常重要,因为请记住,SwiftUI视图只是表达您想要UI看起来像什么的方式,它们只是声明。除非您自己提供更新机制,否则它们不会保留UI的当前状态(例如当前旋转角度)。

虽然您可以引入计时器并在X毫秒的离散步骤中自行计算角度值,但没有必要。我建议让系统以其认为合适的方式计算角度值。

您唯一需要的是被通知该值,以便您可以存储它并用于设置正确的角度以进行暂停,并计算正确的目标角度以进行恢复。有几种方法可以做到这一点,我真的鼓励您阅读SwiftUI动画的三部分介绍,网址为https://swiftui-lab.com/swiftui-animations-part1/

您可以采取的一种方法是使用GeometryEffect。它允许您指定变换,旋转是基本变换之一,因此非常适合。它还遵循Animatable协议,因此我们可以轻松地参与SwiftUI的动画系统。

重要的部分是让视图知道当前的旋转状态,以便它知道我们在暂停时应该保持什么角度,并且在恢复时应该前往什么角度。这可以通过一个简单的绑定来完成,该绑定仅用于将中间值从GeometryEffect报告给视图。

下面是演示工作解决方案的示例代码:

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("")
        .font(.largeTitle)
        .modifier(PausableRotation(desiredAngle: desiredAngle, currentAngle: $currentAngle))
    })
  }
}

很好的解决方案,并且讲解得非常清楚。 - Germán
运行良好,但如果我尝试在 onDisappear 上暂停它,就会出现问题,所以我需要将动画移动到视图本身,如下:.animation(isRotating ? rotationAnimation : .linear(duration: 0), value: desiredAngle) - drasick

2

这是我的想法。也许不是最聪明的方法。
如果你想在中途停止,有一些使用计数和计时器的方法。

struct DemoView: View {
    @State private var degree: Double = 0
    @State private var amountOfIncrease: Double = 0
    @State private var isRotating: Bool = false

    let timer = Timer.publish(every: 1.8 / 360, on: .main, in: .common).autoconnect()

    var body: some View {
        Button(action: {
            self.isRotating.toggle()
            self.amountOfIncrease = self.isRotating ? 1 : 0
        }) {
            Text("").font(.largeTitle)
                .rotationEffect(Angle(degrees: self.degree))
        }
        .onReceive(self.timer) { _ in
            self.degree += self.amountOfIncrease
            self.degree = self.degree.truncatingRemainder(dividingBy: 360)
        }
    }
}

有趣。虽然使用计时器来完成这个任务感觉不太对...我想我别无选择! - Germán
你还有另一种选择 :) 你可以让系统在动画过程中计算中间角度值,并提供一个机制来通知这些中间值。然后,你可以存储最近的值,并在暂停时使用它来设置正确的状态,在恢复时计算所需的状态。请查看我的答案以查看可行的解决方案。 - siejkowski
@siejkowski会研究一下! - Germán

1

简而言之:使用计时器

我尝试了许多解决方案,这是对我最有效的解决方法!

struct RotatingView: View {

    /// Use a binding so parent views can control the rotation
    @Binding var isLoading: Bool
        
    /// The number of degrees the view is rotated by 
    @State private var degrees: CGFloat = 0.0

    /// Used to terminate the timer
    @State private var disposeBag = Set<AnyCancellable>()

    var body: some View {
        Image(systemName: "arrow.2.circlepath")
            .rotationEffect(.degrees(degrees))        
            .onChange(of: isLoading) { newValue in
                if newValue {
                    startTimer()
                } else {
                    stopTimer()
                }
            }
    }

    private func startTimer() {
        Timer
            .publish(every: 0.1, on: .main, in: .common)
            .autoconnect()
            .receive(on: DispatchQueue.main)
            .sink { _ in
                withAnimation {
                    degrees += 20 /// More degrees, faster spin
                }
            }
            .store(in: &disposeBag)
    }
    
    private func stopTimer() {
        withAnimation {
            /// Snap to the nearest 90 degree angle
            degrees += (90 - (degrees.truncatingRemainder(dividingBy: 90)))
        }
        /// This will stop the timer
        disposeBag.removeAll()
    }
}

使用您的视图

struct ParentView: View {

    @State isLoading: Bool = false

    var body: some View {
        RotatingView(isLoading: $isLoading)
    }    
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接