SwiftUI pause/resume rotation animation - swiftui

So far I've seen the following technique for stopping an animation, but what I'm looking for here is that the rotating view stops at the angle it was at the moment and not return to 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))
})
}
}
It seems having the rotation angle to be either 360 or 0 doesn't let me freeze it at an intermediate angle (and eventually resume from there). Any ideas?

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:
What is the value at which the animation was paused?
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("🐰")
.font(.largeTitle)
.modifier(PausableRotation(desiredAngle: desiredAngle, currentAngle: $currentAngle))
})
}
}

Here's my idea. It may not be the smart way.
If you want to stop at an intermediate angle, there are ways to use counting and timer.
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)
}
}
}

TLDR; Use a Timer!
I tried a bunch of solutions and this is what worked for me the best!
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()
}
}
Using Your View
struct ParentView: View {
#State isLoading: Bool = false
var body: some View {
RotatingView(isLoading: $isLoading)
}
}

Related

Print angle rotate gesture

I want print value angle rotate, but
Text (self.finaldegree)
don’t working.
Code:
#State var degree: Angle = Angle(degrees: 0)
#State var finaldegree: Angle = Angle(degrees: 0)
var body: some View {
VStack {
Image("Encoder_White1")
.padding(100)
.scaleEffect(2)
.rotationEffect(degree+finaldegree)
.gesture(RotationGesture()
.onChanged{ value in
degree = value
}
.onEnded{ value in
finaldegree = value
degree = Angle(degrees: 0)
})
}
}
Text (self.finaldegree)
It's not quite clear what you're trying to do, as there's a a lot of extra code that's probably not needed.
Text(self.finaldegree)
won't work by itself, as Text expects a String, or a value that can formatted as a String (e.g. Int, Double, Date). If you convert your angle into a double (e.g using .degrees), you can then format that for display, e.g.
Text(angle.degrees, format: .number)
A simplified solution to your code might be:
struct ContentView: View {
#State var angle: Angle = .zero
var body: some View {
VStack {
Image(systemName: "questionmark")
.padding(100)
.scaleEffect(4)
.rotationEffect(angle)
.gesture(
RotationGesture()
.onChanged{ value in
angle = value
}
)
Text("\(Text(angle.degrees, format: .number))°")
}
}
}

SwiftUI (Xcode 14.0.1) - Simultaneous Gestures (DragGesture & MagnificationGesture) - drag killed after magnification gesture activated

I am trying to achieve drag and magnification gestures take in place simultaneously. With the code below, I was able to achieve transitioning dragging to magnifying, however, when I release one finger after magnification, the drag gesture is killed even though one finger is still on the rectangle. In the Photos app in iOS, you can magnify and release one finger then you can still drag an image. I have looked at Simultaneous MagnificationGesture and DragGesture with SwiftUI. Has this been fixed since then? If not, is there any way to achieve this by using UIViewRepresentable?
import SwiftUI
struct DragAndMagnificationGestures: View {
#State private var currentAmount = 0.0
#State private var finalAmount = 1.0
#State private var dragGestureOffset: CGSize = .zero
var body: some View {
VStack {
let combined = dragGesture.simultaneously(with: magnificationGesture)
Rectangle()
.frame(width: 200, height: 200)
.scaleEffect(finalAmount + currentAmount)
.offset(dragGestureOffset)
.gesture(combined)
}
}
}
struct MultiGesture_Previews: PreviewProvider {
static var previews: some View {
DragAndMagnificationGestures()
}
}
extension DragAndMagnificationGestures {
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if finalAmount != 0 {
withAnimation(.spring()) {
dragGestureOffset = value.translation
}
}
}
.onEnded({ value in
withAnimation(.spring()) {
dragGestureOffset = .zero
}
})
}
private var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { amount in
currentAmount = amount - 1
}
.onEnded({ amount in
finalAmount += currentAmount
currentAmount = 0
})
}
}

Animate an animation's speed

I want the effect of a rotating record that has some ease to it whenever it starts and stops rotating. In code below the trigger is the isRotating Bool.
But I guess it's not possible to animate the speed of an animation?
struct PausableRotatingButtonStyle: ButtonStyle {
var isRotating: Bool
#State private var speed: Double = 1.0
#State private var degrees: Double = 0.0
var foreverAnimation: Animation {
Animation.linear(duration: 2)
.repeatForever(autoreverses: false)
.speed(speed)
}
func makeBody(configuration: Configuration) -> some View {
VStack {
Text("speed: \(speed.description)")
configuration.label
.rotationEffect(Angle(degrees: degrees))
.animation(foreverAnimation)
.onAppear {
degrees = 360.0
}
.onChange(of: isRotating) { value in
withAnimation(.linear) {
speed = value ? 1 : 0
}
}
}
}
}
struct TestRotatingButtonStyle_Previews: PreviewProvider {
static var previews: some View {
TestRotatingButtonStyle()
}
struct TestRotatingButtonStyle: View {
#State private var isPlaying: Bool = true
var body: some View {
VStack {
Button {
isPlaying.toggle()
} label: {
Text("💿")
.font(.system(size: 200))
}
.buttonStyle(PausableRotatingButtonStyle(isRotating: isPlaying))
}
}
}
}
If .easeOut and .spring options don't cut it, you can make a timing curve. This function accepts x and y values for two points (c0, c1).
These points define anchors that (choose a mental model verb: stretch/form/define) a cubic animation timing curve between the start and end points of your animation. (Just like drawing a path between 0,0 and 1,1. If this still sounds like gibberish, look at the objc.io link below for visuals.)
Image("wheel")
.animation(.timingCurve(0, 0.5, 0.25, 1, duration: 2))
An ease-in-out type curve could be .timingCurve(0.17, 0.67, 0.83, 0.67)
https://cubic-bezier.com/#.42,0,.58,1
You can read more via the objc.io guys.
https://www.objc.io/blog/2019/09/26/swiftui-animation-timing-curves/
Edit re: comment on speed
While timing is the intended API, you might be able to change speed in response to a binding from a GeometryEffect progress reporter.
In the animation below, I apply or remove the shadow beneath the ball based on the progress of the vertical-sin-wave-travel GeometryEffect. The progress value is between 0 and 1. (Takeoff/flight/landing is achieved by another boolean and animation curve for x-axis offset.)
[
/// Ball
.modifier(BouncingWithProgressBinding(
currentEffect: $currentEffectSize, // % completion
axis: .vertical,
offsetMax: flightHeight,
interationProgress: iteration
).ignoredByLayout())
struct BouncingWithProgressBinding: GeometryEffect {
#Binding var currentEffect: CGFloat // % completion
var axis: Axis
var offsetMax: CGFloat
var interationProgress: Double
var animatableData: Double {
get { interationProgress }
set { interationProgress = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let progress = interationProgress - floor(interationProgress)
let curvePosition = cos(2 * progress * .pi)
let effectSize = (curvePosition + 1) / ( .pi * 1.25 )
let translation = offsetMax * CGFloat(1 - effectSize)
DispatchQueue.main.async { currentEffect = CGFloat(1 - effectSize) }
if axis == .horizontal {
return ProjectionTransform(CGAffineTransform(translationX: translation, y: 0))
} else {
return ProjectionTransform(CGAffineTransform(translationX: 0, y: translation))
}
}
}

Triggering an animation when a boolean is true

I would like to make an animation that is like this: a ZStack with a view and over it, a purple rectangle, that initially has scale 0 and opacity 0 and is at the center of screen.
When the animation is triggered, two animations happen:
the rectangle's scale increases from 0 to 1 in 0.4 seconds.
the rectangle's opacity increases from 0 to 1 in 0.3 seconds and from 1 to 0 in 0.1 seconds.
This is me trying to do this:
struct ContentView : View {
#State private var scale: CGFloat = 0
#State private var scaleSeed: CGFloat = 0.1
#State private var counter: Int = 1
#State private var triggerFlashAnimation = false {
didSet {
scale = 0
counter = 1
}
}
var body: some View {
ZStack {
Text("Hello")
if triggerFlashAnimation {
Rectangle()
.background(Color.purple)
.scaleEffect(scale)
.onAppear {
scale += scaleSeed
counter += 1
}
}
}
I think this will animate the scale but this is very crude as I don't have any control over time.
I remember back in the day of CoreGraphics that you could define keyframes for time and values.
How do I do that with SwiftUI. I mean a precise animation defining keyframes and values for every parameter?
I googled around and found nothing.
SwiftUI doesn't have support for keyframes like we're used to in CoreAnimation (sources: https://stackoverflow.com/a/56908148/560942, https://swiftui-lab.com/swiftui-animations-part1/).
However, you can do a variation on chaining by using delay:
struct ContentView : View {
#State private var scale: CGFloat = 0
#State private var opacity: Double = 0
#State private var triggerFlashAnimation = false
func triggerAnimationActions() {
withAnimation(.linear(duration: 0.4)) {
scale = 1
}
withAnimation(.linear(duration: 0.3)) {
opacity = 1
}
withAnimation(Animation.linear(duration: 0.1).delay(0.3)) {
opacity = 0
}
// reset
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
triggerFlashAnimation = false
scale = 0
opacity = 0
}
}
var body: some View {
ZStack {
Text("Hello")
Button(action: { triggerFlashAnimation = true }) {
Text("Button")
}.onChange(of: triggerFlashAnimation) { (val) in
if val {
triggerAnimationActions()
}
}
if triggerFlashAnimation {
Rectangle()
.fill(Color.purple.opacity(opacity))
.frame(width: 100, height: 100)
.scaleEffect(scale)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
It's also probably worth looking into AnimatableModifier which lets you define an animatableData property where you can interpolate values, base exact frames on a certain state of the animation, etc, but, as far as I know, doesn't account for timing at all -- rather it just lets you set a state based on the precise value of the current animation. Good resource for reading about AnimatableModifier: https://www.hackingwithswift.com/quick-start/swiftui/how-to-animate-the-size-of-text

Image rotation animation causes the image to move

I want an image to rotate in place but the code I've written makes the image rotate but also move to a new place in the view. What am I doing wrong? I feel like I've tried everything:
struct loadingView: View {
#State private var spinArrow = false // Spinning Motion
#State private var dismissArrow = false // Opacity 1 - 0
#State private var displayBorder = false // StrokeBorder (lineWidth) 64 - 5
#State private var displayCheckmark = false // Trimpath from 1 - 0
var body: some View {
ZStack {
Image("arrowtocircle").resizable()
.frame(width:50, height: 50)
.rotationEffect(.degrees(spinArrow ? 720 : -360))
.animation(Animation.easeInOut(duration: 2))
.onAppear() {
self.spinArrow.toggle()
}
}.frame(width: 500, height:500)
}
}