Any way to make SwiftUI animation smoother? - animation

Struggling a bit to achieve perfect smoothness of animation in SwiftUI. All suggestions are welcomed.
Example: stopwatch. Pretty basic setup of animating progress of something using simple shape and trim.
Note: code is simplified, e.g. #State private var progress: CGFloat can be an observable value from a viewModel. Point is that periodic state updates should be smoothly animated without any noticeable lag or jitter.
struct ContentView: View {
static let speed: TimeInterval = 0.5
var body: some View {
TimelineView(.periodic(from: .now, by: Self.speed)) { (context) in
Stopwatch(date: context.date)
.padding()
}
}
}
struct Stopwatch: View {
#State private var progress: CGFloat = 0
let date: Date
var body: some View {
Circle()
.trim(from: 0, to: progress)
.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
.foregroundColor(.green)
.animation(.linear(duration: ContentView.speed), value: progress)
.onChange(of: date) { (_) in
if progress == 1 {
progress = 0
}
else {
progress += 0.2
}
}
}
}
If you run the example on Simulator or on Device, you will be able to sometimes notice a small annoying jitter in animation if you follow the line with your eyes closely.
Unfortunately, GIF is not conveying what the problem actually is. Therefore, I encourage you to try it in xCode to see. For me, when I follow the line, I notice small jitter at ~6, ~11 and at ~13 o'clock. Seems like animation FPS is a bit low and in these points it dips even more. Any ideas how to make it more smooth? :)
PS: Jitter is also noticeable even without periodic state updates. Problem can be noticed (though arguably a bit less pronounced) by simply making:
Circle()
.trim(from: 0, to: animating ? 1 : 0)
.stroke(style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
.foregroundColor(.green)
.animation(.linear(duration: 3)
.repeatForever(autoreverses: true), value: animating)

Related

Animation issue when Long-Press is canceled - SwiftUI, iOS 14.5

I'm still learning and trying to understand the ins and outs of swiftUI. Currently, I focus on understanding gestures and animations and here is an issue that seems unsolvable at my experience level:
The problem:
I experience unexpected behavior if the longPressGesture is canceled, eg. lifting the finger before the minimumDuration of the longPress is exceeded.
The expected result:
In the example above you see me long pressing 2 times and afterward canceling the long-press once. I would expect the red progress bar to scale down again and keep its position centered if the longPress was canceled. But as you can see, the progress bar changes its position.
What is causing this issue and how could it be solved while still using swiftUI and GestureState
Here is the code:
struct DiamondPress: View {
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
var longPress: some Gesture {
LongPressGesture(minimumDuration: 2)
.updating($isDetectingLongPress) { currentState, gestureState, transaction in
gestureState = currentState
transaction.animation = Animation.linear(duration: 2.0)
}
.onEnded { _ in
self.completedLongPress.toggle()
}
}
var body: some View {
ZStack {
Color.offWhite
RoundedRectangle(cornerRadius: 12, style: .continuous)
.frame(width: 100, height: 100, alignment: .center)
.foregroundColor(.white)
.scaleEffect(completedLongPress ? 2.0 : 1.0)
.gesture(longPress)
.animation(.easeIn(duration: 0.2))
// here is the red progress bar:
Rectangle()
.frame(
width: !isDetectingLongPress ? CGFloat(5) : CGFloat(100),
height: 5, alignment: .center
)
.foregroundColor(.red)
}
.edgesIgnoringSafeArea(.all)
}
}
Thanks a bunch, any help or hint is highly appreciated.

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...

SwiftUI unexpected position changes during 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
}
}
}
}

How can you get a transition to a new view similar to how an App is opened in SwiftUI? [duplicate]

This question already has answers here:
swiftUI transitions: Scale from some frame - like iOS Homescreen is doing when opening an App
(3 answers)
Closed 2 years ago.
So when you open an app on your iPhone it transitions from your AppIcon into a view that looks completely different in a way that feels natural. I try to simulate the animation in the GIF image below. How would you achieve this effect in SwiftUI?
You might need to refresh to see the animation.
I will enhance this question with some sample code on how far I've got tonight, but perhaps someone else has a demo sooner. That would be awesome.
To give you a feeling on how animations work in SwiftUI and how to combine them, I made this little animation for you which consists of two parts:
struct SplashScreen: View {
#State private var circleAlpha = 1.0
#State private var sizeCircle: CGFloat = 0
#State private var textAlpha = 1.0
func handleAnimations() {
runAnimationPart1()
let deadline: DispatchTime = .now() + 1
DispatchQueue.main.asyncAfter(deadline: deadline) {
self.runAnimationPart2()
}
}
func runAnimationPart1() {
withAnimation(.easeIn(duration: 1)) {
textAlpha = 0
}
}
func runAnimationPart2() {
withAnimation(.easeIn(duration: 1)) {
sizeCircle = 1400
}
}
var body: some View {
ZStack {
Text("Your App Icon")
.font(.largeTitle)
.foregroundColor(Color.green)
.opacity(textAlpha)
Circle()
.fill(Color.blue)
.frame(width: sizeCircle, height: sizeCircle,
alignment: .leading)
.opacity(circleAlpha)
}
.onAppear() {
self.handleAnimations()
}
} }

How can I animate transition of a view insertion/removal? Insertion animation doesn't work

I can't make insertion transition to work in SwiftUI.
I have a Group which conditionaly displayes one of two views. When I'm trying to animate the transition, removal transition works but insertion doesn't - the view just appears right away without any animation.
I'm pasting below my view code. How can I make this work?
(Xcode 11.3.1)
struct TestView: View {
#State private var showView = false
var body: some View {
VStack {
Button(action: {
withAnimation {
self.showView.toggle()
}
}) {
Text("Tap")
}
Group {
if showView {
Color.red
.frame(width: 100, height: 100)
} else {
Color.blue
.frame(width: 100, height: 100)
.cornerRadius(50)
}
}
.transition(.asymmetric(insertion: .move(edge: .leading),
removal: .move(edge: .trailing)))
}
}
}
Edit:
As #Asperi pointed out in the comment, the code is correct but... it will only work when run on real device. Live previews in Xcode are buggy and apparently doesn't handle transitions very well.
So the answer for this question is simple: test on real device! :)
Well, ok, it is just observation from the SwiftUI Preview history and until now (can't say what will be in the next version), but - transitions do not work properly in Preview at all (static or Live - it looks like limitation, so just don't test them there.
Test transitions either on standalone Simulator or, what is preferable, on Real Device.

Resources