到目前为止,我已经看到了以下用于停止动画的技术,但我在这里寻找的是旋转视图停止在当前的角度,而不是返回到 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 并不能让我将其冻结在中间角度(并最终从那里恢复)。有什么想法吗?
在 SwiftUI 中暂停和恢复动画非常简单,您可以通过确定
withAnimation
块中的动画类型来正确执行此操作。
您缺少的是另外两条信息:
这两部分至关重要,因为请记住,SwiftUI 视图只是表达您希望 UI 外观的方式,它们只是声明。除非您自己提供更新机制,否则它们不会保留 UI 的当前状态。因此 UI 的状态(如当前旋转角度)不会自动保存在其中。
虽然您可以引入计时器并以 X 毫秒的谨慎步骤自行计算角度值,但没有必要这样做。我建议让系统以合适的方式计算角度值。
您唯一需要的就是通知该值,以便您可以存储它并用于在暂停后设置直角并计算恢复的正确目标角度。有几种方法可以做到这一点,我真的鼓励您阅读 SwiftUI 动画的 3 部分介绍: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))
})
}
}
这是我的想法。这可能不是聪明的方法。
如果你想停在中间角度,有办法使用计数和计时器。
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)
}
}
}
Timer
!我尝试了很多解决方案,这对我来说是最好的!
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)
}
}
从 iOS 17 开始,这也可以使用
withAnimation
和 completion
回调来实现:
withAnimation
struct DemoView: View {
@State private var angleDegrees = 0
@State private var isRotating: Bool = false {
didSet {
if isRotating {
counter += 1
}
}
}
@State private var counter = 0 {
didSet {
withAnimation(.linear(duration: 0.01)) {
angleDegrees += 3
} completion: {
if isRotating {
counter += 1
}
}
}
}
var body: some View {
Button {
isRotating.toggle()
} label: {
Text("🐰")
.font(.largeTitle)
.rotationEffect(.degrees(Double(angleDegrees)))
}
}
}