Triggering an animation when a boolean is true - swiftui

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

Related

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

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.

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

SwiftUI page control implementation

I need to implement something like an animated page control. And I don't want to use integration with UIKit if possible. I have pages array containing 4 views I need to switch between. I create the animation itself by changing the value of progress variable using timer. And I have the following code right now
#State var pages: [PageView]
#State var currentIndex = 0
#State var nextIndex = 1
#State var progress: Double = 0
var body: some View {
ZStack {
Button(action: {
self.isAnimating = true
}) { shape.onReceive(timer) { _ in
if !self.isAnimating {
return
}
self.refreshAnimatingViews()
}
}.offset(y: 300)
pages[currentIndex]
.offset(x: -CGFloat(pow(2, self.progress)))
pages[nextIndex]
.offset(x: CGFloat(pow(2, (limit - progress))))
}
}
It is animating great - current page is moved to the left until it disappears, and the next page is revealed from the right taking its place. At the end of animation I add 1 to both indices and reset progress to 0. But once the animation (well not exactly an animation - I just change the value of progress using timer, and generate every state manually) is over, the page with index 1 is swapped back to page with index 0. If I check with debugger, currentIndex and nextIndex values are correct - 1 and 2, but the page displayed after animation is always the one I started with (with index 0). Does anybody know why this is happening?
The whole code follows
struct ContentView : View {
let limit: Double = 15
let step: Double = 0.3
let timer = Timer.publish(every: 0.01, on: .current, in: .common).autoconnect()
#State private var shape = AnyView(Circle().foregroundColor(.blue).frame(width: 60.0, height: 60.0, alignment: .center))
#State var pages: [PageView]
#State var currentIndex = 0
#State var nextIndex = 1
#State var progress: Double = 0
#State var isAnimating = false
var body: some View {
ZStack {
Button(action: {
self.isAnimating = true
}) { shape.onReceive(timer) { _ in
if !self.isAnimating {
return
}
self.refreshAnimatingViews()
}
}.offset(y: 300)
pages[currentIndex]
.offset(x: -CGFloat(pow(2, self.progress)))
pages[nextIndex]
.offset(x: CGFloat(pow(2, (limit - progress))))
}.edgesIgnoringSafeArea(.vertical)
}
func refreshAnimatingViews() {
progress += step
if progress > 2*limit {
isAnimating = false
progress = 0
currentIndex = nextIndex
if nextIndex + 1 < pages.count {
nextIndex += 1
} else {
nextIndex = 0
}
}
}
}
struct PageView: View {
#State var title: String
#State var imageName: String
#State var content: String
let imageWidth: Length = 150
var body: some View {
VStack(alignment: .center, spacing: 15) {
Text(title).font(Font.system(size: 40)).fontWeight(.bold).lineLimit(nil)
Image(imageName)
.resizable()
.frame(width: imageWidth, height: imageWidth)
.cornerRadius(imageWidth/2)
.clipped()
Text(content).font(.body).lineLimit(nil)
}.padding(60)
}
}
struct MockData {
static let title = "Eating grapes 101"
static let contentStrings = [
"Step 1. Break off a branch holding a few grapes and lay it on your plate.",
"Step 2. Put a grape in your mouth whole.",
"Step 3. Deposit the seeds into your thumb and first two fingers.",
"Step 4. Place the seeds on your plate."
]
static let imageNames = [
"screen 1",
"screen 2",
"screen 3",
"screen 4"
]
}
in SceneDelegate:
if let windowScene = scene as? UIWindowScene {
let pages = (0...3).map { i in
PageView(title: MockData.title, imageName: MockData.imageNames[i], content: MockData.contentStrings[i])
}
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: ContentView(pages:
pages))
self.window = window
window.makeKeyAndVisible()
}
The following solution works. I think the problem was switching out views while SwiftUI tries to diff and update them is not something SwiftUI is good at.
So just use the same two PageView views and swap out their content based on the current index.
import Foundation
import SwiftUI
import Combine
struct PagesView : View {
let limit: Double = 15
let step: Double = 0.3
#State var pages: [Page] = (0...3).map { i in
Page(title: MockData.title, imageName: MockData.imageNames[i], content: MockData.contentStrings[i])
}
#State var currentIndex = 0
#State var nextIndex = 1
#State var progress: Double = 0
#State var isAnimating = false
static let timerSpeed: Double = 0.01
#State var timer = Timer.publish(every: timerSpeed, on: .current, in: .common).autoconnect()
#State private var shape = AnyView(Circle().foregroundColor(.blue).frame(width: 60.0, height: 60.0, alignment: .center))
var body: some View {
ZStack {
Button(action: {
self.isAnimating.toggle()
self.timer = Timer.publish(every: Self.timerSpeed, on: .current, in: .common).autoconnect()
}) { self.shape
}.offset(y: 300)
PageView(page: pages[currentIndex])
.offset(x: -CGFloat(pow(2, self.progress)))
PageView(page: pages[nextIndex])
.offset(x: CGFloat(pow(2, (self.limit - self.progress))))
}.edgesIgnoringSafeArea(.vertical)
.onReceive(self.timer) { _ in
if !self.isAnimating {
return
}
self.refreshAnimatingViews()
}
}
func refreshAnimatingViews() {
progress += step
if progress > 2*limit {
isAnimating = false
progress = 0
currentIndex = nextIndex
if nextIndex + 1 < pages.count {
nextIndex += 1
} else {
nextIndex = 0
}
}
}
}
struct Page {
var title: String
var imageName: String
var content: String
let imageWidth: CGFloat = 150
}
struct PageView: View {
var page: Page
var body: some View {
VStack(alignment: .center, spacing: 15) {
Text(page.title).font(Font.system(size: 40)).fontWeight(.bold).lineLimit(nil)
Image(page.imageName)
.resizable()
.frame(width: page.imageWidth, height: page.imageWidth)
.cornerRadius(page.imageWidth/2)
.clipped()
Text(page.content).font(.body).lineLimit(nil)
}.padding(60)
}
}
struct MockData {
static let title = "Eating grapes 101"
static let contentStrings = [
"Step 1. Break off a branch holding a few grapes and lay it on your plate.",
"Step 2. Put a grape in your mouth whole.",
"Step 3. Deposit the seeds into your thumb and first two fingers.",
"Step 4. Place the seeds on your plate."
]
static let imageNames = [
"screen 1",
"screen 2",
"screen 3",
"screen 4"
]
}