Reset Path stroke animation - swiftui

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

Related

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

SwiftUI View Jumps on Initial Drag

I am looking to have a process where a card starts at the bottom of the screen and can be dragged up and down (vertical axis only). I have an issue where on initial drag the card jumps from the bottom of the screen to the top. I have simplified the below to its most minimum to demonstrate the issue. I was thinking it was some sort of coordinate space issue, but changing between local and global doesn't help.
struct ContentView: View {
#State private var offset = CGSize(width: 0, height: UIScreen.main.bounds.height * 0.9)
var body: some View {
GeometryReader { geo in
Color(.red).edgesIgnoringSafeArea(.all)
Color(.orange)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(offset)
.animation(.spring())
.gesture(DragGesture()
.onChanged { gesture in
offset.height = gesture.translation.height
}
)
}
}
}
Again when the finger is first place at the top of the bottom orange view and the drag gesture begins upwards, the orange view jumps to the top of the red view.
You can separate the two different offsets to two different modifiers. First add .offset() with your starting amount. Then add another .offset() that will offset the view from the starting offset.
I also added an .onEnded gesture so that it doesn't lag when starting to drag a 2nd time. You'll probably want to change the .onEnded so that at the end of the gesture the view animates toward a locked position.
struct DragView: View {
#State private var offsetY: CGFloat = 0
#State private var lastOffsetY: CGFloat = 0
var body: some View {
GeometryReader { geo in
Color(.red)
.ignoresSafeArea()
Color(.orange)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: UIScreen.main.bounds.height * 0.8)
.offset(y: offsetY)
.animation(.spring())
.gesture(DragGesture()
.onChanged { gesture in
withAnimation(.spring()) {
offsetY = lastOffsetY + gesture.translation.height
}
}
.onEnded { _ in
lastOffsetY = offsetY
}
)
}
}
}

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 position and frame animation broken in iOS 14

I have a progress view whose progress / width can be animated. This animation works by itself. However, in some cases, the parent view changes its layout and a new view is added above the progress bar. Then, the progress view moves down to make space for the new view. This is also animated. When both animations happen in iOS 13, the progress view moves down and the width of the blue progress bar is animated at the same time. In iOS 14, the progress view moves down but the blue progress bar also uses its animation to animate the position change. In iOS 13 it was only used for animating the width. This makes the animation look as if the progress bar flies into the progress view and it is wrong and looks weird.
I reproduced the code I have in my app with the following code. This code was also used to record the video.
struct MainView: View {
#State var toggle = false
#State var trim: CGFloat = 0.2
var body: some View {
VStack {
Rectangle().frame(minHeight: 0, maxHeight: self.toggle ? 200 : 0)
ProgressBarView(trim: self.$trim)
Button(
action: {
withAnimation {
self.toggle.toggle()
self.trim = self.toggle ? 0.8 : 0.2
}
},
label: {
Text("Toggle")
})
}
}
}
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
var body: some View {
ZStack(alignment: .leading) {
GeometryReader { geo in
Rectangle()
.opacity(0.1)
.zIndex(0)
Rectangle()
.frame(
minWidth: 0,
maxWidth: self.grow
? geo.frame(in: .global).width * self.trim
: 0
)
.animation(Animation.easeOut(duration: 1.2).delay(0.5))
.foregroundColor(Color(UIColor.systemBlue))
}
}
.cornerRadius(10)
.frame(height: 20)
.onAppear(perform: {
self.grow = true
})
}
}
Update
This is the original and working animation with the same code on my iPad that is still running iOS 13.7. I just removed the delay and increased the time to make the different animations more obvious.
Update 2
There was some confusion when answering the question so I copied some frames and put them into a screenshot. I hope this helps to understand my question. On the left is the animation on iOS 14 with the unwanted behavior. As you can see the blue progress bar doesn't appear in some images. In other images, it is only partly visible. On the right is the iOS 13 animation. The frames on the image (and all other frames) show the blue progress bar fully visible. It is always completely visible and on top of the background.
I don't understand what change in iOS 14 (or maybe Swift 5.3 or whatever else) caused the animation to be different and I cannot find a workaround to this problem.
Hey there! I made few changes in the ProgressBarView struct. Let's get to the less important changes first.
Removed the GeometryReader and replaced it with screen.width. If you are using the bar inside a container in order to get the width of the container, use GeometryReader.
Changed the Rectangle to a capsule.
To the important one
I target the animation modifier of the bar to the Boolean variable that trigged the change of the progress bar, i.e. grow
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
let screen = UIScreen.main.bounds
var body: some View {
VStack {
ZStack(alignment: .leading) {
Capsule()
.foregroundColor(Color.black.opacity(0.1))
Capsule()
.frame(width: self.grow ? screen.width * trim : 0)
.foregroundColor(.blue)
.animation(Animation.easeOut(duration: 1).delay(0.5), value: self.grow)
}
.frame(width: screen.width, height: 20)
.onAppear {
self.grow = true
}
}
}
}
UPDATE 1.1
I added an animation to the ZStack for the initial increase of bar from 0 to 0.2
Changed the DispatchQueue delay to 0.18 sec.
The reason for the change in delay is because, since the expanding of the rectangle is changing the position of the bar in the y-direction, the animation effect with duration of 1.2 sec is being applied to the change in position which we don't want. Thus the delay of 0.18 starts the bar animation a bit after the expanding of the rectangle. This is a bit of a hack, but gets the work done. If I find a better solution, I'll update this answer with 'Update 2.0'. If you find a better solution, let me know.
Code
struct MainView: View {
#State var toggle = false
#State var trim: CGFloat = 0.2
var body: some View {
VStack {
Rectangle().frame(minHeight: 0, maxHeight: self.toggle ? 200 : 0)
ProgressBarView(trim: self.$trim)
Button(
action: {
withAnimation {
self.toggle.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
self.trim = self.toggle ? 0.8 : 0.2
}
}
},
label: {
Text("Toggle")
})
}
}
}
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
let screen = UIScreen.main.bounds
var body: some View {
ZStack(alignment: .leading) {
Capsule()
.opacity(0.1)
Capsule()
.frame(width: self.grow ? screen.width * trim : 0)
.foregroundColor(.blue)
.animation(Animation.easeOut(duration: 1.2), value: self.trim)
}
.frame(width: screen.width, height: 20)
.onAppear {
self.grow = true
}
.animation(.easeOut(duration: 0.5), value: grow)
}
}
I hope this helps you.

SwiftUI rotationEffect - How to go clockwise only?

(Xcode 11.4 · Swift 5 · iOS Target 13.4)
The stripped down SwiftUI iOS code below creates a View that contains a clock's smoothly rotating secondhand. It works fine except that the animation runs anticlockwise when the seconds value changes from 59 to 0. How can I force the secondhand to rotate clockwise only?
import SwiftUI
struct ClockView: View {
#State var now = Date()
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Pointer()
.stroke(Color.orange, lineWidth: 1)
.rotationEffect(Angle.degrees(360 * now.second / 60))
.animation(Animation.linear(duration: 1.0))
.frame(width: 300, height: 300)
}.onReceive(timer) { time in
self.now = time
}
}
}
struct ClockView_Previews: PreviewProvider {
static var previews: some View {
ClockView()
}
}
struct Pointer: Shape {
func path(in rect: CGRect) -> Path {
Path { p in
p.move(to: CGPoint(x: rect.midX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.midX, y: rect.midY))
}
}
}
extension Date {
var second: Double {
return Double(Calendar.current.dateComponents([.second], from: self).second ?? 0)
}
}
Try the code below. The strategy here is to turn off the animating of the moving hand once a minute. When the animation is off the angle can be adjusted without getting un-wanted wrap-around rotation. There might be an easier way to do this, but this definitely does the job.
struct ClockView: View {
#State var seconds : Double = 0
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Pointer()
.stroke(Color.orange, lineWidth: 1)
.rotationEffect(Angle.degrees(360 * seconds / 60))
.frame(width: 300, height: 300)
}.onReceive(timer) { time in
self.seconds = Date().second
if self.seconds == 60.0 {
withAnimation(.none) {
self.seconds = 0.01 }
}
withAnimation(Animation.linear(duration: 1.0)) {
self.seconds = round(self.seconds + 1)
}
}
}
}
Sorry to be late to the party, but I had the same issue with animating a boat whose course changed from 359º to 1º. Its not the direction of the animation that is the issue, but simply the fact that from SwiftUI's point of view there is only one way to get from 359 to 1.
However, the rotationEffect modifier will take any number as a parameter and will handle the conversion for you when drawing the needle, so there is no need to use modular arithmetic. Something like this seems to work in either direction
import SwiftUI
struct ClockView: View {
#State var nonModularAngle: Double = -10
#State var forward: Bool = true
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Pointer()
.stroke(Color.blue, lineWidth: 3)
.rotationEffect(Angle.degrees(nonModularAngle))
//.animation(Animation.linear(duration: 1.0))
.animation(.default, value: nonModularAngle)
.frame(width: 300, height: 300)
}.onReceive(timer) { time in
self.nonModularAngle += forward ? +1 : -1
print(nonModularAngle)
}
Button("Reverse", action: { forward.toggle() })
}
}