SwiftUI unexpected position changes during animation - animation

I have made a simple animation in SwiftUI which repeats forever, directly when starting the app.
However when running the code the animated circle goes to the left top and then back to its normal position while doing the rest of the animation. This top left animation is not expected and nowhere written in my code.
Sample code:
struct Loader: View {
#State var animateFirst = false
#State var animationFirst = Animation.easeInOut(duration: 3).repeatForever()
var body: some View {
ZStack {
Circle().trim(from: 0, to: 1).stroke(ColorsPalette.Primary, style: StrokeStyle(lineWidth: 3, lineCap: .round)).rotation3DEffect(
.degrees(self.animateFirst ? 360 : -360),
axis: (x: 1, y: 0, z: 0), anchor: .center).frame(width: 200, height: 200, alignment: .center).animation(animationFirst).onAppear {
self.animateFirst.toggle()
}
}
}
}
and then showing it in a view like this:
struct LoaderViewer: View {
var body: some View {
VStack {
Loader().frame(width: 200, height: 200)
}
}

I don't know exactly why this happens but I have a vague idea.
Because the animation starts before the view is fully rendered in the width an height, the view starts in the op left corner. The animation takes the starting position in the animation and the change to its normal position and keep repeating this part with the given animation. Which causes this unexpected behaviour.
I think it is a bug, but with the theory above I have created a workaround for now. In the view where we place the loader we have to wait till the parent view is fully rendered before adding the animated view (Loader view), we can do that like this:
struct LoaderViewer: View {
#State showLoader = false
var body: some View {
VStack {
if showLoader {
Loader().frame(width: 200, height: 200)
}
}.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
showLoader = true
}
}
}
}

Related

SwiftUI: Animated rotation in different sides

At first look question can seen ordinary but after spend few hour try out different techniques I still failed to achieve expected behavior... I want to control the rotation side of View depending in #State. If view in default position (chevron up) it should rotate down from the right side. If chevron is down it should rotate up from the left side.
And one more thing I discovered: in my work project I use Image instead SF Symbol with the same modification like in code example and by default it always rotates from the right side, but using SF Symbol it's going from left. I will be grateful if you explain why
struct Arrow: View {
#State var showView = false
var body: some View {
VStack {
Image(systemName: "chevron.up.circle")
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(.degrees(showView ? 180 : 360))
.animation(Animation.easeInOut(duration: 0.3), value: showView)
}
.onTapGesture { showView.toggle() }
}
}
To always rotate in the same direction, maintain a rotationAngle that always increases (or always decreases) while animating:
struct ContentView: View {
#State private var showView = false
#State private var rotationAngle: Double = 0
var body: some View {
VStack {
Image(systemName: "chevron.up.circle")
.resizable()
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: rotationAngle))
}
.onTapGesture {
showView.toggle()
withAnimation(.easeInOut(duration: 0.3)) {
rotationAngle += 180
}
rotationAngle = showView ? 180 : 0
}
}
}
Note: Use rotationAngle -= 180 to rotate in the other direction.

How to trigger SwiftUI animation via change of non-State variable

It's easy to have an animation begin when a view appears, by using .onAppear(). But I'd like to perform a repeating animation whenever one of the view's non-State variables changes. An example:
Here is a view that I would like to "throb" whenever its throbbing parameter, set externally, is true:
struct MyCircle: View {
var throbbing: Bool
#State var scale = 1.0
var body: some View {
Circle()
.frame(width: 100 * scale, height: 100 * scale)
.foregroundColor(.blue)
.animation(.easeInOut.repeatForever(), value: scale)
.onAppear { scale = 1.2 }
}
}
Currently the code begins throbbing immediately, regardless of the throbbing variable.
But imagine this scenario:
struct ContentView: View {
#State var throb: Bool = false
var body: some View {
VStack {
Button("Throb: \(throb ? "ON" : "OFF")") { throb.toggle() }
MyCircle(throbbing: throb)
}
}
}
It looks like this:
Any ideas how I can modify MyCircle so that the throbbing starts when the button is tapped and ends when it is tapped again?
You can use onChange to watch throbbing and then assign an animation. If true, add a repeating animation, and if false, just animate back to the original scale size:
struct MyCircle: View {
var throbbing: Bool
#State private var scale : CGFloat = 0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.scaleEffect(1 + scale)
.foregroundColor(.blue)
.onChange(of: throbbing) { newValue in
if newValue {
withAnimation(.easeInOut.repeatForever()) {
scale = 0.2
}
} else {
withAnimation {
scale = 0
}
}
}
}
}
There're two interesting things that i've just found when trying to achieve yours target.
Animation will be added when the value changed
Animation that added to the view cannot be removed, and we need to drop the animated view from the view hiearachy by remove its id, new view will be created with zero animations.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var throbbling = false
#State var circleId = UUID()
var body: some View {
VStack {
Toggle("Throbbling", isOn: $throbbling)
circle.id(circleId)
.scaleEffect(throbbling ? 1.2 : 1.0)
.animation(.easeInOut.repeatForever(), value: throbbling)
.onChange(of: throbbling) { newValue in
if newValue == false {
circleId = UUID()
}
}
}.padding()
}
#ViewBuilder
var circle: some View {
Circle().fill(.green).frame(width: 100, height: 100)
}
}
PlaygroundPage.current.setLiveView(ContentView())

SwiftUI tvOS button animation - how to stop y axis movement

this simple code of x offset animation causes a text to move also on y axis when the text is part of a button (on tvOS 14.7)
struct Animate: View
{
#State var scrollText: Bool = false
var body: some View {
Button(action: {}, label: {
Text("This is My Text !")
.offset(x: scrollText ? 0 : 200, y: 0)
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: true))
.onAppear {
self.scrollText.toggle()
}
})
}
}
see the animation behavior here:
marquee gone wrong
How can i stop the y axis movement ?
You always need to set a value for your animations. Otherwise, SwiftUI will animate all changes, include unwanted positioning.
struct Animate: View {
#State var scrollText: Bool = false
var body: some View {
Button(action: {}) {
Text("This is My Text !")
.offset(x: scrollText ? 0 : 200, y: 0)
.animation(Animation.linear(duration: 1).repeatForever(autoreverses: true), value: scrollText) /// only update animation when `scrollText` changes
.onAppear {
// DispatchQueue.main.async { /// you might also need this
self.scrollText.toggle()
// }
}
}
}
}
Result:
This video might also be helpful

How to add animation to sheet dismissal

Presenting a modal sheet in SwiftUI MacOS has a nice slide in animation, but when dismissed it just disappear.
I would like to add a slide out animation to the dismissal of the sheet.
Adding the slideout on the content of the sheet obviously doesn’t work, as the content slides out, but the sheet frame remains until dismissed.
I might be missing something obvious here, but how can I add animation to the sheet itself?
struct ModalSheet: View {
#Binding var visible: Bool
#State var isShowing: Bool = true
var body: some View {
let s = "Click me to dismiss " + String(isShowing);
VStack {
Text(s)
}
.frame(width: 200, height: 100, alignment: .center)
.background(Color.green.opacity(0.5))
.onTapGesture {
isShowing.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
visible.toggle()
}
}
.offset(y: self.isShowing ? 0 : CGFloat(-110)) // <-- this should be on the sheet itself
.animation(self.isShowing ? .none : .default)
}
}
struct ModalTestAnimation: View {
#State var visible: Bool = false;
var body: some View {
VStack {
Text("Click me")
.onTapGesture {
visible = true
}
}
.frame(width: 250, height: 150)
.sheet(isPresented: $visible, content: {
ModalSheet(visible: $visible)
})
}
}
I did take a look at this, but it doesn’t work for me
SwiftUI sheet not animating dismissal on macOS Big Sur

SwiftUI onAppear: Animation duration is ignored

I have found that this trim animation here animate path stroke drawing in SwiftUI works as expected if the MyLines view appears in the ContentView (root view) of the app or when it appears as destination of a navigation link. However, SwiftUI seems to ignore the animation duration if the view appears inside of a custom view - I created an overlay view that transitions in from the right.
I only took that line trimming animation as an example - this bug (if it is one) also seems to occur with other animations, e.g. some view changing its height on appear.
I tried changing the duration. if I double it (e.g. from 2 seconds to 4), the actual animation duration does not seem to change...
struct ContentView: View {
#State var showOverlay: Bool = false
var body: some View {
NavigationView {
VStack {
NavigationLink("My Lines (Nav Link)", destination: MyLines(height: 200, width: 250))
Button(action: {
self.showOverlay.toggle()
}, label: {
Text("My Lines (overlay)")
})
}
}.overlayView(content: {
VStack {
HStack{
Button(action: { self.showOverlay = false}, label: {
Text("Back")
})
Spacer()
}.padding(.top, 40).padding(.horizontal, 15)
MyLines(height: 200, width: 250)
Spacer()
}
}, background: {
Color(.systemBackground)
}, show: $showOverlay, size: nil, transition: AnyTransition.move(edge: .trailing).animation(.easeInOut(duration: 0.3)))
}
}
Here is the code of MyLine again - I deliberately delay the animation by 1 second so that it becomes clear that the problem is not caused by a too long transition of the overlay view in which the Line view exists.
import SwiftUI
struct MyLines: View {
var height: CGFloat
var width: CGFloat
#State private var percentage: CGFloat = .zero
var body: some View {
Path { path in
path.move(to: CGPoint(x: 0, y: height/2))
path.addLine(to: CGPoint(x: width/2, y: height))
path.addLine(to: CGPoint(x: width, y: 0))
}
.trim(from: 0, to: percentage) // << breaks path by parts, animatable
.stroke(Color.black, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: {
withAnimation(.easeOut(duration: 2.0)) {
self.percentage = 1.0
}
})
}.padding()
}
}
I have set up a complete project here that should illustrate the problem and also includes the overlay view:
https://github.com/DominikButz/TrimAnimationBug
By the way, this duration problem does not disappear after removing NavigationView and only use the overlay view.
You might wonder, why use the custom overlay instead of the boiler plate navigation? I found that NavigationView causes a lot of problems:
difficult to change the background colour
does not support custom transitions, e.g. with a matched geometry effect (the overlay view I created can appear with any type of transition)
causes weird crashes (e.g. related to matchedGeometryEffect)
prevents the .hideStatusBar modifier to work properly
etc. etc.
Sorry, just found the culprit. I had left an animation(.spring()) modifier on the Overlay View. that seems to confuse SwiftUI somehow. after removing the modifier, it works as expected. Seems it helps writing down the problem to see the solution more easily...

Resources