Animation with delay instead of using timer - swiftui

I have several Circles in swiftui, that react on a touchgesture.
I want them to increase the opac value and afterwards decrease it again:
HStack {
Spacer(minLength: 20.0)
Circle()
//#e74c3c
.foregroundColor(Color(red: 231.0/255.0, green: 76.0/255.0, blue: 60.0/255.0, opacity: buttonOpac[buttonColor.red.rawValue]))
.gesture(TapGesture()
.onEnded({if self.buttonActive {
self.buttonActive = false
self.blink(buttonColor.red)
} else {
print("button inactive")
}
})) ...
the method blink animates the change of the opac value:
func blink(_ buttonCol: buttonColor) {
self.currentNumber += 1
withAnimation(.easeOut(duration: blinkDuration)){
self.buttonOpac[buttonCol.rawValue] = 1.0
}
withAnimation(Animation.easeIn(duration: blinkDuration).delay(blinkDuration)){
self.buttonOpac[buttonCol.rawValue] = 0.5
}
withAnimation(Animation.linear.delay(2 * blinkDuration)){
self.buttonActive = true
}
}
For the time that this animation is active I want the circle to be disabled and not reacting on touches. I'll try to reach that by the #Status var buttonActive
This works only to the point, that setting this value to false on entry of the .onEnded block. Now I want to reset it in the blink method after the other animations have ended. But the delay modifier does not work for the third "animation". In fact the value is set immediately back to true so that the circle continues to react on touches.
Is the only way to reach the goal without the usage of timers?

Here are the declarations:
enum buttonColor: Int {
case red
case blue
case green
case yellow }
let blinkDuration = 0.5
#State private var buttonOpac: [Double] = [0.5, 0.5, 0.5, 0.5]
#State private var currentNumber = 0

Related

Swiftui longpress gesture onended and onchange not called on failure - is that right?

Encountering a problem in some hobby code in which a long-press appear to be misbehaving (that is, the previous action stopped happening ). I took the sample Apple code and started playing which revealed an anomaly (in my mind).
The anomaly is that if the gesture fails, then, whilst the underlying GestureState var is changed, neither the .onChange nor .onEnded modifiers are called. Similarly, .onEnded is ONLY invoked when the gesture succeeds. In a similar anomalous behavior, long-press, in isolation, appears to be detected and triggered, the instant the click happens. Which to me is contrary to the concept of a long-Press.
My sample code is below (modified Apple sample with print statements to track the sequence of events). If you click and hold the click until the duration expires then the associated actions occur as you expect, however, if you click, hold, and drag beyond the distance parameter or simply click and release, then the only place for detecting that failure is in the view that the gesture is attached to.
Does anyone know of a way of detecting a long-press failure?
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
LongPressGestureView()
}
.padding()
}
}
struct LongPressGestureView: View {
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
#State var scaleFactor = 1.0
var longPress: some Gesture {
LongPressGesture(minimumDuration: 4, maximumDistance: 100)
.updating($isDetectingLongPress) { currentState, gestureState,
transaction in
print("Gesture updating detected currentState: \(currentState)")
print("Gesture updating: saw state of completedLongPress: \(completedLongPress)")
gestureState = currentState
transaction.animation = Animation.easeIn(duration: 2.0)
}
.onChanged() { state in
print("Gesture onChange: saw state of isDetectingLongPress: \(state)")
print("Gesture onChange: saw state of completedLongPress: \(completedLongPress)")
}
.onEnded { finished in
print("Gesture onEnded detected completed: \(self.completedLongPress)")
self.completedLongPress = finished
}
}
var body: some View {
Circle()
.scale(scaleFactor)
.fill(self.isDetectingLongPress ?
Color.red :
(self.completedLongPress ? Color.green : Color.blue))
.frame(width: 100, height: 100, alignment: .center)
.gesture(longPress)
.onChange(of: self.isDetectingLongPress) {newState in
print("View: onChange isDetectingLongPress: \(newState)")
print("View: onChange isDetectingLongPress completedLongPress: \(completedLongPress)")
scaleFactor = newState ? 3.0 : 1.0
}
.onChange(of: self.completedLongPress) { state in
print("View: onChange completedLongPress isDetectingLongPress: \(isDetectingLongPress)")
print("View: onChange completedLongPress: \(completedLongPress)")
if (state) {
scaleFactor = 1.0
completedLongPress = false
}
}
.animation(.easeInOut(duration: 1.0), value: scaleFactor)
}
}

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))
}
}
}

Reset Path stroke animation

I have an array with the path of some different shapes. Let's say five rectangles.
Now I would like to animate one path until the user double tap on this shape. After double tapping the next shape of the array should be display and animated again.
struct TestView: View {
#State var percentage: CGFloat = .zero
#State var index: Int = 0
#State var pathArr = Array<Path>(repeating: Rectangle().path(in: CGRect(x: 0, y: 0, width: 100, height: 100)), count: 5)
var body: some View {
pathArr[index]
.trim(from: 0, to: percentage)
.stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round))
.onAppear {
withAnimation(Animation.easeInOut(duration: 2.0).repeatForever(autoreverses: false)){
percentage = 1.0
}
}
.onTapGesture(count: 2, perform: {index += 1; percentage = .zero})
}
}
The problem with my current approach is that the first shape will be displayed and animated correctly. But after double tapping the next paths won't be displayed and animated. Probably because of the percentage = .zero. When I delete this line the other paths are displayed and the animated. But now I have the problem that the animation state won't be resetted. That leads to the situation that when the animation is at 50% and I double tap the next shape starts with the animation at 50%. I would like to start each new displayed path at "0% animation".
Use the following gesture handler instead. Tested and worked with Xcode 12.4 / iOS 14.4.
.onTapGesture(count: 2, perform: {
percentage = .zero // >> stops current
DispatchQueue.main.async {
index += 1; percentage = 1.0 // << starts new
}
})

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

SwiftUI pause/resume rotation animation

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)
}
}