SwiftUI Animation Bug inside Scroll View - swift

I am relatively new to SwiftUI and I am trying to implement animated loader to my app. It works fine until I scroll my content inside scroll view down and then move back to trigger pull to refresh. Circles started to jump though they should only increase and decrease their size one by one.
My code is
struct ActivityIndicator: View {
var isShowing: Bool
#State private var shouldAnimate = false
init(isShowing: Bool) {
self.isShowing = isShowing
}
var body: some View {
HStack(alignment: .center) {
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever())
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever().delay(0.3))
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever().delay(0.6))
}
.opacity(self.isShowing ? 1 : 0)
.onAppear {
self.shouldAnimate = true
}
}
}
I have read few articles related to my case and it seems that I might had to use withAnimation (Explicit) instead .animation (Implicit) but I can't make it work properly.
Btw I connect my Activity Indicator to the scrollView using Loading View modifier and it looks like this
struct LoadingView: ViewModifier {
#Binding var isShowing: Bool
func body(content: Content) -> some View {
ZStack(alignment: .center) {
content
.disabled(self.isShowing)
.blur(radius: self.isShowing ? 3 : 0)
ActivityIndicator(isShowing: isShowing)
}
}
}
Any ideas and suggestions are appreciated I am really stuck. Thanks

Try to link all your animations to related state, like
var body: some View {
HStack(alignment: .center) {
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever(),
value: shouldAnimate) // << here !!

If you have any problems with the previous answer, try removing the .animation and replace it with withAnimation:
https://developer.apple.com/documentation/swiftui/withanimation(_:_:)
The advantage of that is that it does not affect other animations.

Related

How can make transition stay in own zIndex until ending the transition in SwiftUI?

I have code like this in below:
struct ContentView: View {
#State private var show: Bool = false
var body: some View {
ZStack {
Button("show") { show.toggle() }.foregroundColor(.black)
//.zIndex(1)
if (show) {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.transition(AnyTransition.asymmetric(insertion: .offset(x: 0, y: 300), removal: .offset(x: 0, y: 300)))
.onTapGesture { show.toggle() }
//.zIndex(2)
}
}
.frame(width: 300, height: 300)
.padding()
.animation(.linear(duration: 1.5), value: show)
}
}
The issue with this code is that in insertion view stays in correct zIndex(in Top layer) but in removal goes to wrong zIndex(in Bottom layer), I can correct this issue with using direct zIndex but the goal of this question is to find a way without using zIndex modifier, I thing it is possible but not sure how, maybe it has something to do with transition.
This behavior seems like a bug.
I think this is a side effect of the fact that as soon as you remove the Circle() View, it is immediately gone, and the animation happens after the fact. So upon removal, there is instantly just one item in the ZStack and it is on top.
A workaround is to not completely remove a view from the ZStack. This can be accomplished by wrapping the if show { } with an HStack, VStack, or ZStack:
struct ContentView: View {
#State private var show: Bool = false
var body: some View {
ZStack {
Button("show") { show.toggle() }.foregroundColor(.black)
//.zIndex(1)
HStack { // Note: VStack and ZStack also work
if (show) {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.transition(AnyTransition.asymmetric(insertion: .offset(x: 0, y: 300), removal: .offset(x: 0, y: 300)))
.onTapGesture { show.toggle() }
//.zIndex(2)
}
}
}
.frame(width: 300, height: 300)
.padding()
.animation(.linear(duration: 1.5), value: show)
}
}
In this case, while the Circle() View is immediately removed, the HStack to which it belongs remains, and it remains in the same position in the ZStack thus fixing the animation.
That said, I'm not sure why adding explicit .zIndex() modifiers helps.
Note: Wrapping if show() { } in a Group does not help because Group is not a View.

SwiftUI when animating the button with scaleEffect, lines remain along the border of the initial size

I am trying to create a button with an bounce animation affect on tap using SwiftUI. The code below does the correct animation and everything works fine in preview. But when running the same code in the simulator after the animation is done, I always end up with lines that are left at the original size boundary. What could be the problem?
XCode 13.3, iOS 15.4, Simulator iPhone 12 mini
import SwiftUI
struct ContinueButton: View {
let title: String
#State var isLoading: Bool = false
var body: some View {
Button(action: {
print("")
}) {
Text(title)
.font(.system(size: 20)).bold().italic()
.foregroundColor(.white)
.opacity(isLoading ? 0 : 1)
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.opacity(isLoading ? 1 : 0)
)
}
.buttonStyle(ScaleButtonStyle())
.disabled(isLoading)
}
}
public struct ScaleButtonStyle: ButtonStyle {
public func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.frame(height: 56)
.frame(maxWidth: .infinity, idealHeight: 56)
.background(Color.redOctober)
.cornerRadius(12)
.scaleEffect(configuration.isPressed ? 0.95 : 1)
.animation(.interpolatingSpring(mass: 1,
stiffness: 350,
damping: configuration.isPressed ? 8.35 : 5.35,
initialVelocity: 0.8))
}
}

SwiftUI view animates at unexpected path

I made the following PulsatingView:
struct PulsatingView: View {
#State private var opacity = 0.7
#State private var scale: CGFloat = 0.5
var body: some View {
VStack {
ZStack {
Circle()
.fill(Color.black.opacity(opacity))
.animation(.easeInOut(duration: 2).delay(1.5).repeatForever(autoreverses: false))
.frame(width: 7, height: 7)
.scaleEffect(scale)
.animation(.easeIn(duration: 2.5).delay(1).repeatForever(autoreverses: false))
Circle()
.fill(Color.black)
.frame(width: 7, height: 7)
}
}
.onAppear {
opacity = 0
scale = 5
}
}
}
It produces the following result: (click to see animation; it's a .gif)
However, when I add it to a view, inside a VStack for example, this happens: (click to see animation; it's a .gif)
I have no idea why this happens or how I can fix it. I've already tried adding modifiers such as .fixedSize(horizontal: true, vertical: true) or .frame(width: 50, height: 50) to it but that doesn't help.
Does anyone know what's going on?
Try using animation with joined value, like
Circle()
.fill(Color.black.opacity(opacity))
.animation(.easeInOut(duration: 2).delay(1.5).repeatForever(autoreverses: false), value: opacity) // << here !!
.frame(width: 7, height: 7)
.scaleEffect(scale)
.animation(.easeIn(duration: 2.5).delay(1).repeatForever(autoreverses: false), value: scale) // << here !!
Important: if it is started in NavigationView then animatable state should be activated postponed. The reason and investigation details was provided in https://stackoverflow.com/a/66643096/12299030.
This issue is not about your code it is because NavigationView to fix it we should use DispatchQueue.main.async I wanted refactor your code as well, hope help you, also you can avoid hard coded value as much as possible, for example you can apply the frame in outside of view:
struct ContentView: View {
var body: some View {
CircleView(lineWidth: 0.5, color: .red, scale: 5.0)
.frame(width: 7, height: 7)
}
}
struct CircleView: View {
let lineWidth: CGFloat
let color: Color
let scale: CGFloat
#State private var startAnimation: Bool = Bool()
var body: some View {
Circle()
.strokeBorder(style: .init(lineWidth: startAnimation ? .zero : lineWidth)).opacity(startAnimation ? .zero : 1.0)
.scaleEffect(startAnimation ? scale : 1.0)
.overlay( Circle() )
.foregroundColor(color)
.onAppear() { DispatchQueue.main.async { startAnimation.toggle() } }
.animation(.easeIn(duration: 2.5).delay(1).repeatForever(autoreverses: false), value: startAnimation)
}
}
I finally managed to find a solution to this on my own project - I found the only solution to get the animated view working within a NavigationView was to move the animation inside the onAppear call, like this:
Circle()
.fill(Color.black.opacity(opacity))
.onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: false)) {
opacity = 0
}
}
Hope this can help someone else if they get stuck on the same issue I ended up having!

Animating a flashing bell in SwiftUI

I am having problems with making a simple systemIcon flash in SwiftUI.
I got the animation working, but it has a silly behaviour if the layout of
a LazyGridView changes or adapts. Below is a video of its erroneous behaviour.
The flashing bell stays in place but when the layout rearranges the bell
starts transitioning in from the bottom of the parent view thats not there anymore.
Has someone got a suggestion how to get around this?
Here is a working example which is similar to my problem
import SwiftUI
struct FlashingBellLazyVGrid: View {
#State var isAnimating = false
#State var showChart = true
var body: some View {
let columns = [GridItem(.adaptive(minimum: 300), spacing: 50, alignment: .center)]
VStack {
Button(action: {
showChart.toggle()
}) {
VStack {
Circle()
.fill(showChart ? Color.green : Color.red)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
Text("Charts")
.foregroundColor(Color.primary)
}.frame(width: 150, height: 50)
}
ScrollView {
LazyVGrid (
columns: columns, spacing: 50
) {
ForEach(0 ..< 25) { item in
ZStack {
Rectangle()
.fill(Color.red)
.cornerRadius(15)
VStack {
HStack {
Text(/*#START_MENU_TOKEN#*/"Hello, World!"/*#END_MENU_TOKEN#*/)
Spacer()
Image(systemName: "bell.fill")
.foregroundColor(Color.yellow)
.opacity(self.isAnimating ? 1 : 0)
.animation(Animation.easeInOut(duration: 0.66).repeatForever(autoreverses: false))
.onAppear{ self.isAnimating = true }
}.padding(50)
if showChart {
Rectangle()
.fill(Color.green)
.frame(height: 200)
}
}
}
}
}
}
}
}
}
struct FlashingBellLazyVGrid_Previews: PreviewProvider {
static var previews: some View {
FlashingBellLazyVGrid()
}
}
how it looks like before you click the showChart button at the top
After you toggle the button it looks like the bells are erroneously moving into place from the bottom of the screen. and toggling it back to its original state doesn't resolve this bug subsequently.
[
Looks like the animation is basing itself off of the original size of the view. In order to trick it into recognizing the new view size, I used .id(UUID()) on the outside of the grid. In a real world application, you'd probably want to be careful to store this ID somewhere and only refresh it when needed -- not on every re-render like I'm doing:
struct FlashingBellLazyVGrid: View {
#State var showChart = true
let columns = [GridItem(.adaptive(minimum: 300), spacing: 50, alignment: .center)]
var body: some View {
VStack {
Button(action: {
showChart.toggle()
}) {
VStack {
Circle()
.fill(showChart ? Color.green : Color.red)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
Text("Charts")
.foregroundColor(Color.primary)
}.frame(width: 150, height: 50)
}
ScrollView {
LazyVGrid (
columns: columns, spacing: 50
) {
ForEach(0 ..< 25) { item in
ZStack {
Rectangle()
.fill(Color.red)
.cornerRadius(15)
VStack {
SeparateComponent()
if showChart {
Rectangle()
.fill(Color.green)
.frame(height: 200)
}
}
}
}
}
.id(UUID()) //<-- Here
}
}
}
}
struct SeparateComponent : View {
#State var isAnimating : Bool = false
var body: some View {
HStack {
Text("Hello, World!")
Spacer()
Image(systemName: "bell.fill")
.foregroundColor(Color.yellow)
.opacity(self.isAnimating ? 1 : 0)
.animation(Animation.easeInOut(duration: 0.66).repeatForever(autoreverses: false))
.onAppear{
self.isAnimating = true
}
}
.padding(50)
}
}
I also separated out the blinking component into its own view, since there were already problematic things happening with the existing logic with onAppear, which wouldn't affect newly-scrolled-to items correctly. This may need refactoring for your particular case as well, but this should get you started.

Dynamic row height in a SwiftUI form

I'm adding controls to a SwiftUI Form to assist the user enter data (and constrain the entries!). Although there is a lot to like about Forms, I've discovered that things that work nicely outside this container do very unexpected things inside it and it's not always obvious how to compensate for this.
The plan is to have the data field displayed as a single row. When the row is tapped, the control slides out from behind the data field - the row will need to expand (height) to accommodate the control.
I'm using Swift Playgrounds to develop the proof of concept (or failure in my case). The idea is to use a ZStack which will allow a nice sliding animation by overlaying the views and giving them a different zIndex and applying the offset when the data field view is tapped. Sounds simple but of course the Form row does not expand when the ZStack is expanded.
Adjusting the frame of the ZStack while expanding causes all sorts of weird changes in padding (or at least it looks like it) which can be compensated for by counter-offsetting the "top" view but this causes other unpredictable behaviour. Pointers and ideas gratefully accepted.
import SwiftUI
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { self.isDisclosed.toggle() }
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(y: isDisclosed ? 50 : 0)
.animation(.easeOut)
}
}
Spacer()
}
}
}
Collapsed stack
Expanded stack - view overlaps adjacent row
Result when adjusting ZStack vertical frame when expanded - top padding increases
Here is possible solution with fluent row height change (using AnimatingCellHeight modifier taken from my solution in SwiftUI - Animations triggered inside a View that's in a list doesn't animate the list as well ).
Tested with Xcode 11.4 / iOS 13.4
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { withAnimation { self.isDisclosed.toggle() } }
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 150 : 100))
Spacer()
}
}
}
Use alignmentGuide instead of offset.
...
//.offset(y: isDisclosed ? 50 : 0)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 50 : 0) })
...
offset doesn't affect its view's frame. that's why Form doesn't react as expected. On the contrary, alignmentGuide does.
I now have a working implementation using alignment guides as suggested by Kyokook. I have softened the somewhat jarring row height change by adding an opacity animation to the Stepper as it slides out. This also helps to prevent a slightly glitchy overlap of the row title when the control is closed.
struct ContentView: View {
// MARK: Logic state
#State private var years = 0
#State private var months = 0
#State private var weeks = 0
// MARK: UI state
#State var isStepperVisible = false
var body: some View {
Form {
Text("Row 1")
VStack {
// alignment guide must be explicit for the ZStack & all child ZStacks
// must use the same alignment guide - weird stuff happens otherwise
ZStack(alignment: .top) {
HStack {
Text("AGE")
.bold()
.font(.footnote)
Spacer()
Text("\(years) years \(months) months \(weeks) weeks")
.foregroundColor(self.isStepperVisible ? Color.blue : Color.gray)
}
.frame(height: 35) // TODO: Without this, text in HStack vertically offset. Investigate. (HStack align doesn't help)
.background(Color.white) // Prevents overlap of text during transition
.zIndex(3)
.contentShape(Rectangle())
.onTapGesture {
self.isStepperVisible.toggle()
}
HStack(alignment: .center) {
StepperComponent(value: $years, label: "Years", bounds: 0...30, isVisible: $isStepperVisible)
StepperComponent(value: $months, label: "Months", bounds: 0...12, isVisible: $isStepperVisible)
StepperComponent(value: $weeks, label: "Weeks", bounds: 0...4, isVisible: $isStepperVisible)
}
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isStepperVisible ? 40 : 0) })
}
}
Text("Row 3")
}
}
}
struct StepperComponent<V: Strideable>: View {
// MARK: Logic state
#Binding var value: V
var label: String
var bounds: ClosedRange<V>
//MARK: UI state
#Binding var isVisible: Bool
var body: some View {
ZStack(alignment: .top) {
Text(label.uppercased()).font(.caption).bold()
.frame(alignment: .center)
.zIndex(1)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
Stepper(label, value: self.$value, in: bounds)
.labelsHidden()
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isVisible ? 25 : 0) })
.frame(alignment: .center)
.zIndex(2)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
}
}
}
There is still some room for improvement here but on the whole I'm pleased with the result :-)
Thanks to both Kyokook (for putting me straight on offset()) and Asperi.
I think the Kyokook's solution (using AlignmentGuides) is simpler and would be my preference in that it's leveraging Apple's existing API and seems to cause less unpredictable movement of the views in their container. However, the row height changes abruptly and isn't synchronised. The animation in the Asperi's example is smoother but there is some bouncing of the views within the row (it's almost as if the padding or insets are changing and then being reset at the end of the animation). My approach to animation is a bit hit-and-miss so any further comments would be welcome.
Solution 1 (frame consistent, animation choppy):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
self.isDisclosed.toggle()
}
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 100 : 0) })
.animation(.easeOut)
Text("Row 3")
}
}
Text("Row 3")
}
}
}
Solution 2 (smoother animation but frame variance):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
withAnimation { self.isDisclosed.toggle() }
}
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 200 : 100))
}
Text("Row 3")
}
}
}
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height)
}
}