SwiftUI View struct without reloading - swift

I would like to create a starry background view in SwiftUI that has its stars located randomly using Double.random(), but does not reinitialise them and move them when the parent view reloads its var body.
struct ContentView: View {
#State private var showButton = true
var body: some View {
ZStack {
BackgroundView()
if showButton {
Button("Tap me"){
self.showButton = false
}
}
}
}
}
I define my background view as such.
struct BackgroundView: View {
var body: some View {
ZStack {
GeometryReader { geometry in
Color.black
ForEach(0..<self.getStarAmount(using: geometry), id: \.self){ _ in
Star(using: geometry)
}
LinearGradient(gradient: Gradient(colors: [.purple, .clear]), startPoint: .bottom, endPoint: .top)
.opacity(0.7)
}
}
}
func getStarAmount(using geometry: GeometryProxy) -> Int {
return Int(geometry.size.width*geometry.size.height/100)
}
}
A Star is defined as
struct Star: View {
let pos: CGPoint
#State private var opacity = Double.random(in: 0.05..<0.4)
init(using geometry: GeometryProxy) {
self.pos = CGPoint(x: Double.random(in: 0..<Double(geometry.size.width)), y: Double.random(in: 0..<Double(geometry.size.height)))
}
var body: some View {
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.onAppear(){
withAnimation(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
}
}
}
}
As one can see, a Star heavily relies on random values, for both its animation (to create a 'random' twinkling effect) as well as its position. When the parent view of the BackgroundView, ContentView in this example, gets redrawn however, all Stars get reinitialised, their position values change and they move across the screen. How can this best be prevented?
I have tried several approaches to prevent the positions from being reinitialised. I can create a struct StarCollection as a static let of BackgroundView, but this is quite cumbersome. What is the best way to go about having a View dependent on random values (positions), only determine those positions once?
Furthermore, the rendering is quite slow. I have attempted to call .drawingGroup() on the ForEach, but this then seems to interfere with the animation's opacity interpolation. Is there any viable way to speed up the creation / re-rendering of a view with many Circle() elements?

The slowness coming out from the overcomplicated animations setting in onAppear, you only need the self.opacity state change to initiate the animation, so please move animation out and add to the shape directly.
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.animation(Animation.linear(duration: 0.2).delay(Double.random(in: 0..<6)).repeatForever())
.onAppear(){
// withAnimation{ //(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
// }
}

Related

SwiftUI translate and rotate view with matchedGeometryEffect strange behavior

I'm trying to simulate dealing cards in SwiftUI. I'm testing with one card, and the goal is to animate the card in the center (image1) to one of the sides (image2). I would like that the animation would rotate and translate the card simultaneously, but with this code the card rotates immediately without animation and then it translates animatedly. Any idea to get the rotation and translation effects simultaneously into the animation?
import SwiftUI
struct Card: Identifiable {
var id: String {
return value
}
let value: String
var dealt: Bool = false
}
struct CardView: View {
let card: Card
var flipped: Bool = false
var body: some View {
ZStack {
Color.white
Text("\(card.value)")
.padding(4)
Color.red.opacity(flipped ? 0.0 : 1.0)
}
.border(.black, width: 2)
}
}
struct CardsTableView: View {
#Namespace private var dealingNamespace
#State var card = Card(value: "1", dealt: false)
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.clear)
.border(.black, width: 2)
.padding(10)
VStack {
ZStack {
centerCard
lateralCard
}
Spacer()
Button {
withAnimation(.linear(duration: 1)) {
card.dealt.toggle()
}
} label: {
Text("Deal")
}
.padding()
}
}
}
var centerCard: some View {
VStack {
Spacer()
if !card.dealt {
CardView(card: card)
.frame(width: 40, height: 70)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
Spacer()
}
}
var lateralCard: some View {
HStack {
Spacer()
if card.dealt {
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.frame(width: 40, height: 70)
.rotationEffect(.degrees(-90))
.transition(AnyTransition.asymmetric(insertion: .identity, removal: .opacity))
}
}
.padding(.trailing, 20)
}
}
The matchedGeometryEffect modifier doesn't know about the rotationEffect modifier, so neither view's rotation is animated during the transition. I'll explain how to get the animation you want in two ways: using transitions and using “slots”. Both solutions produce this animation:
Using transitions
You can use a custom .modifier transition to animate the rotation. I wouldn't do it this way, but since it has a similar structure as the code you posted, I'll explain it first.
For the sake of this answer, let's simplify CardView:
struct CardView: View {
var body: some View {
Text("C")
.foregroundColor(.black)
.padding()
.background(Color.white)
.border(Color.black, width: 1)
}
}
To animate rotation, we need a ViewModifier type that applies the rotation effect:
struct CardRotationModifier: ViewModifier {
var angle: Angle
func body(content: Content) -> some View {
content.rotationEffect(angle)
}
}
Here's CardTableView:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
VStack {
if !isSide {
topCard
}
Spacer()
}
HStack {
Spacer()
if isSide {
sideCard
}
}
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
And finally here are the top and side card views:
extension CardTableView {
var topCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .degrees(90)),
identity: CardRotationModifier(angle: .zero)))
}
var sideCard: some View {
CardView()
.matchedGeometryEffect(id: 0, in: namespace)
.transition(
.modifier(
active: CardRotationModifier(angle: .zero),
identity: CardRotationModifier(angle: .degrees(90))))
}
}
Note that the side card doesn't have a .rotationEffect. Instead, both cards have a transition that applies CardRotationModifier. SwiftUI applies the active modifier at the start of an entrance transition and the end of an exit transition. It applies the identity modifier at the end of an entrance transition, the start of an exit transition, and the entire time the view is “at rest” (present and not transitioning). So the top card normally has rotation zero, and the side card normally has rotation 90°, and each card is animated to the other's rotation during a transition.
What I don't like about this solution is that the transitions are configured specifically for moving a card between the top and side positions. The transition on the top position knows about the rotation of the side position, and vice versa. So what if you want to add a left-side position with a rotation of -90°? You've got a problem. Now you need to dynamically set the transition of each position based on where the card is moving from and to. Every position needs to know details of every other position, so it can be O(N) work to add another position.
Using slots
Instead, I would use what I think of as “slots”: put a hidden view at each possible position (“slot”) of a card. Then, use a view with a persistent identity to draw the card, and tell that persistent view to match the geometry of whichever slot it should occupy.
So, we need a way to identify each slot:
enum Slot: Hashable {
case top
case side
}
Now CardTableView lays out a subview for each slot, and a view for the card:
struct CardTableView: View {
#Namespace var namespace
#State var isSide = false
var body: some View {
ZStack {
topSlot
sideSlot
card
}
.padding()
.background(Color.mint)
.onTapGesture {
withAnimation(.linear) {
isSide.toggle()
}
}
}
}
Here are the slot subviews:
extension CardTableView {
var topSlot: some View {
VStack {
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.top, in: namespace)
Spacer()
}
}
var sideSlot: some View {
HStack {
Spacer()
CardView()
.hidden()
.matchedGeometryEffect(id: Slot.side, in: namespace)
}
}
}
And here is the card subview:
extension CardTableView {
var card: some View {
CardView()
.rotationEffect(isSide ? .degrees(90): .zero)
.matchedGeometryEffect(
id: isSide ? Slot.side : Slot.top,
in: namespace, isSource: false)
}
}
Notice that now there are no transitions anywhere, and none of the slots knows anything about the other slots. If you want to add another slot, it's a matter of defining another slot subview, adding that new slot subview to the CardTableView ZStack, and updating the card subview to know how to pose itself in the new slot. None of the existing slot subviews are affected. It's O(1) work to add a new slot.

Creating a drag handle inside of a SwiftUI view (for draggable windows and such)

I'm trying to learn SwiftUI, and I had a question about how to make a component that has a handle on it which you can use to drag around. There are several tutorials online about how to make a draggable component, but none of them exactly answer the question I have, so I thought I would seek the wisdom of you fine people.
Lets say you have a view that's like a window with a title bar. For simplicity's sake, lets make it like this:
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
}
}
}
I.e. the red part at the top is the title bar, and the main body of the component is the blue area. Now, this window view is contained inside another view, and you can drag it around. The way I've read it, you should do something like this (very simplified):
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.gesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
and that indeed lets you drag the component around (ignore for now that we're always dragging by the center of the image, it's not really the point):
However, this is not what I want: I don't want you to be able to drag the component around by just dragging inside the window, I only want to drag it around by dragging the red title bar. But the red title-bar is hidden somewhere inside of WindowView. I don't want to move the #State variable containing the position to inside the WindowView, it seems to me much more logical to have that inside ContainerView. But then I need to somehow forward the gesture into the embedded title bar.
I imagine the best way would be for the ContainerView to look something like this:
struct ContainerView: View {
#State private var loc = CGPoint(x:150, y:150);
var body: some View {
ZStack {
WindowView()
.frame(width:100, height:100)
.position(loc)
.titleBarGesture(DragGesture()
.onChanged { value in
loc = value.location
}
)
}
}
}
but I don't know how you would implement that .titleBarGesture in the correct way (or if this is even the proper way to do it. should the gesture be an argument to the WindowView constructor?). Can anyone help me out, give me some pointers?
Thanks in advance!
You can get smooth translation of the window using the offset from the drag, and then disable touch on the background element to prevent content from dragging.
Buttons still work in the content area.
import SwiftUI
struct WindowBar: View {
#Binding var location: CGPoint
var body: some View {
ZStack {
Color.red
.frame(height:25)
Text(String(format: "%.1f: %.1f", location.x, location.y))
}
}
}
struct WindowContent: View {
var body: some View {
ZStack {
Color.blue
.allowsHitTesting(false) // background prevents interaction
Button("Press Me") {
print("Tap")
}
}
}
}
struct WindowView: View, Identifiable {
#State var location: CGPoint // The views current center position
let id = UUID()
/// Keep track of total translation so that we don't jump on finger drag
/// SwiftUI doesn't have an onBegin callback like UIKit's gestures
#State private var startDragLocation = CGPoint.zero
#State private var isBeginDrag = true
init(location: CGPoint = .zero) {
_location = .init(initialValue: location)
}
var body: some View {
VStack(spacing:0) {
WindowBar(location: $location)
WindowContent()
}
.frame(width: 100, height: 100)
.position(location)
.gesture(DragGesture()
.onChanged({ value in
if isBeginDrag {
isBeginDrag = false
startDragLocation = location
}
// In UIKit we can reset translation to zero, but we can't in SwiftUI
// So we do book keeping to track startLocation of gesture and adjust by
// total translation
location = CGPoint(x: startDragLocation.x + value.translation.width,
y: startDragLocation.y + value.translation.height)
})
.onEnded({ value in
isBeginDrag = true /// reset for next drag
}))
}
}
struct ContainerView: View {
#State private var windows = [
WindowView(location: CGPoint(x: 50, y: 100)),
WindowView(location: CGPoint(x: 190, y: 75)),
WindowView(location: CGPoint(x: 250, y: 50))
]
var body: some View {
ZStack {
ForEach(windows) { window in
window
}
}
.frame(width: 600, height: 480)
}
}
struct ContentView: View {
var body: some View {
ContainerView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can just use .allowsHitTesting(false) on the blue view, which will ignore the touch gesture on that view. Hence, you can only drag on the red View and still have the DragGesture outside that view.
struct WindowView: View {
var body: some View {
VStack(spacing:0) {
Color.red
.frame(height:25)
Color.blue
.allowsHitTesting(false)
}
}
}
You can wrap the DragGesture into a ViewModifier:
struct MovableByBar: ViewModifier {
static let barHeight: CGFloat = 14
#State private var loc: CGPoint!
#State private var transition: CGSize = .zero
func body(content: Content) -> some View {
if loc == nil { // Get the original position
content.padding(.top, MovableByBar.barHeight)
.overlay {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
let frame = geo.frame(in: .local)
loc = CGPoint(x: frame.midX, y: frame.midY)
}
return Color.clear
}
}
} else {
VStack(spacing: 0) {
Rectangle()
.fill(.secondary)
.frame(height: MovableByBar.barHeight)
.offset(x: transition.width, y: transition.height)
.gesture (
DragGesture()
.onChanged { value in
transition = value.translation
}
.onEnded { value in
loc.x += transition.width
loc.y += transition.height
transition = .zero
}
)
content
.offset(x: transition.width,
y: transition.height)
}
.position(loc)
}
}
}
And use modifier like this:
WindowView()
.modifier(MovableByBar())
.frame(width: 80, height: 60) // `frame()` after it

SwiftUI onTapGesture interact with caller only

I've just started looking into SwiftUI and since it's so different I'm trying to wrap my head around basic concepts.
In this scenario, how would I go about changing the color only for the circle tapped?
ForEach(1...count, id: \.self) { _ in
Circle()
.foregroundColor(colors.randomElement()).opacity(0.2)
.frame(width: .random(in: 10...100), height: .random(in: 10...100))
.position(x: .random(in: self.stepperValue...400),
y: .random(in: self.stepperValue...400))
.saturation(2.0)
.onTapGesture(count: 1, perform: {
// Change color of circle that was tapped
print("Tapped")
})
.animation(.default) // Animate the change in position
}
You create a View that has the "Rows" individual properties
import SwiftUI
struct SampleColorChangeView: View {
//If the options are fixed no need to keep an eye on them
//You can move this down to the Row if you don't need to have them available here
let colors: [Color] = [.red,.blue,.gray, .yellow,.orange]
#State var count: Int = 10
var body: some View {
VStack{
ForEach(1...count, id: \.self) { _ in
RowView(colors: colors)
}
}
}
}
//Create a row View to observe individual objects
//You will do this with anything that you want to Observe independently
struct RowView: View {
let colors: [Color]
//#State observes changes so the View is updated
#State var color: Color = .blue
//This kind of works like the colors do you want one for each or a shared for all. Does the parent need access? You can move it up or keep it here
#State var stepperValue: CGFloat = 0
//The only change here is the reference to the individual Color
var body: some View {
Circle()
//You set the individual color here
.foregroundColor(color).opacity(0.2)
.frame(width: .random(in: 10...100), height: .random(in: 10...100))
.position(x: .random(in: self.stepperValue...400),
y: .random(in: self.stepperValue...400))
.saturation(2.0)
.onTapGesture(count: 1, perform: {
// Change color of circle that was tapped
color = colors.randomElement()!
print("Tapped")
})
.animation(.default) // Animate the change in position
//If you want to set a random color to start vs just having them all be the same Color you can do something like this
.onAppear(){
color = colors.randomElement()!
}
}
}
struct SampleColorChangeView_Previews: PreviewProvider {
static var previews: some View {
SampleColorChangeView()
}
}
Well there are two main options I see here
Make a custom view like
struct MyCircle: View {
#State var color: Color?
var body: some View {
Circle()
.foregroundColor(color)
.onTapGesture {
self.color = colors.randomElement()
}
}
}
and then integrate that or
Use a model for your color
struct MyView: View {
#State var colors = allColors.indices.compactMap { _ in allColors.randomElement() }
var body: some View {
ForEach(colors.indices) { index in
Circle()
.foregroundColor(colors[index])
.onTapGesture {
colors[index] = allColors.randomElement()
}
}
}
}
A state like this should preferably be in its own class which should be inserted as ObservedObject.

How to enable multiple gestures?

I have created an app that consists of the main view inside of which there are multiple views, one per shape. For the shape, I would like to enable some interaction in order to transform its properties upon tapping. As the transformation depends on the tap location as well, I have implemented it as DragGesture the following way:
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { gesture in
print("Tap location: \(gesture.startLocation)")
})
Besides the transformation of individual shapes, I wanted to enable the user to drag the whole content and/or resize it. Thus, in the main view, I have implemented a drag gesture and magnification gesture. As tapping and dragging are conflicting, I added an option to toggle the interaction mode between tapping and dragging. The transformation is enabled/disabled by checking the following condition inside shape view:
.allowsHitTesting(dragGestureMode == DragGestureEnum.TransformShape)
Nevertheless, sometimes when I try to resize the image, the gesture is interpreted as a drag gesture on a certain shape that performs the logic for tapping (dragging with minimum distance 0) on that shape instead of resizing (in case of dragging mode this is not a problem).
Below is the logic of the main view and the shape view which instances are laid inside main view:
struct MainView: View {
#EnvironmentObject var mainViewModel : MainViewModel
#State private var offset = CGSize.zero
#State private var draggedSize: CGSize = CGSize.zero
#State private var scale: CGFloat = 1.0
#State private var scaledSize: CGFloat = 1.0
var body: some View {
GeometryReader {
geometry in
ZStack {
ForEach(mainViewModel.shapeItemKeys, id: \.self){ id in
let shapeItem = $mainViewModel.shapeItemsByKey[id]
ShapeView(shapeItem: shapeItem, dragGestureMode: $mainViewModel.dragGestureMode)
}
}
.frame(width: min(geometry.size.width, geometry.size.height), height: min(geometry.size.width, geometry.size.height), alignment: .center)
.contentShape(Rectangle())
.offset(x: self.draggedSize.width, y: self.draggedSize.height)
.scaleEffect(self.scaledSize)
.gesture(
DragGesture()
.onChanged { gesture in
self.draggedSize = gesture.translation
self.draggedSize.width += self.offset.width
self.draggedSize.height += self.offset.height
}
.onEnded { _ in
self.offset = self.draggedSize
}
)
.gesture(MagnificationGesture()
.onChanged({ (scale) in
self.scaledSize = scale.magnitude
self.scaledSize *= self.scale
})
.onEnded({ (scaleFinal) in
self.scale = scaleFinal
print("New scale: \(self.scale)")
self.scale = self.scaledSize
}))
}
}
}
struct ShapeView: View {
#Binding var shapeItem: ShapeItem?
#Binding var dragGestureMode: DragGestureEnum
var layersToRemove: [Int] = []
init(shapeItem: Binding<ShapeItem?>, dragGestureMode: Binding<DragGestureEnum>) {
self._shapeItem = shapeItem
self._dragGestureMode = dragGestureMode
}
var body: some View {
ZStack {
shapeItem!.path
.foregroundColor(Color(shapeItem!.color))
.overlay(
ZStack {
// ... some logic
}
, alignment: .leading)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { gesture in
print("Tap location: \(gesture.startLocation)")
}
)
.allowsHitTesting(dragGestureMode == DragGestureEnum.TransformShape)
}
}
}
Does anyone know what is a good way to enable combinations of gestures ((dragging or resizing) or (tapping or resizing)) in this case (i.e. tap should be detected on shape view, drag or resize should be detected on the main view and tap on the individual shape and drag on the main view are exclusive) in order to get the expected user experience (prevent resizing gesture from being interpreted as tapping or dragging)?
You can use simultaneous gesture modifier, like
.simultaneousGesture(MagnificationGesture()
...

SwiftUI: Stop an Animation that Repeats Forever

I would like to have a 'badge' of sorts on the screen and when conditions are met, it will bounce from normal size to bigger and back to normal repeatedly until the conditions are no longer met. I cannot seem to get the badge to stop 'bouncing', though. Once it starts, it's unstoppable.
What I've tried:
I have tried using a few animations, but they can be classified as animations that use 'repeatForever' to achieve the desired effect and those that do not. For example:
Animation.default.repeatForever(autoreverses: true)
and
Animation.spring(response: 1, dampingFraction: 0, blendDuration: 1)(Setting damping to 0 makes it go forever)
followed by swapping it out with .animation(nil). Doesn't seem to work. Does anyone have any ideas? Thank you so very much ahead of time! Here is the code to reproduce it:
struct theProblem: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation( active ? Animation.default.repeatForever(autoreverses: true): nil )
.frame(width: 100, height: 100)
.onTapGesture {
self.active = !self.active
}
}
}
I figured it out!
An animation using .repeatForever() will not stop if you replace the animation with nil. It WILL stop if you replace it with the same animation but without .repeatForever(). ( Or alternatively with any other animation that comes to a stop, so you could use a linear animation with a duration of 0 to get a IMMEDIATE stop)
In other words, this will NOT work: .animation(active ? Animation.default.repeatForever() : nil)
But this DOES work: .animation(active ? Animation.default.repeatForever() : Animation.default)
In order to make this more readable and easy to use, I put it into an extension that you can use like this: .animation(Animation.default.repeat(while: active))
Here is an interactive example using my extension you can use with live previews to test it out:
import SwiftUI
extension Animation {
func `repeat`(while expression: Bool, autoreverses: Bool = true) -> Animation {
if expression {
return self.repeatForever(autoreverses: autoreverses)
} else {
return self
}
}
}
struct TheSolution: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation(Animation.default.repeat(while: active))
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct TheSolution_Previews: PreviewProvider {
static var previews: some View {
TheSolution()
}
}
As far as I have been able to tell, once you assign the animation, it will not ever go away until your View comes to a complete stop. So if you have a .default animation that is set to repeat forever and auto reverse and then you assign a linear animation with a duration of 4, you will notice that the default repeating animation is still going, but it's movements are getting slower until it stops completely at the end of our 4 seconds. So we are animating our default animation to a stop through a linear animation.
How about using a Transaction
In the code below, I turn off or turn on the animation depending on the state of the active
Warning: Be sure to use withAnimation otherwise nothing will work
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect(active ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true), value: active)
.frame(width: 100, height: 100)
.onTapGesture {
useTransaction()
}
}
func useTransaction() {
var transaction = Transaction()
transaction.disablesAnimations = active ? true : false
withTransaction(transaction) {
withAnimation {
active.toggle()
}
}
}
After going through many things, I found out something that works for me. At the least for the time being and till I have time to figure out a better way.
struct WiggleAnimation<Content: View>: View {
var content: Content
#Binding var animate: Bool
#State private var wave = true
var body: some View {
ZStack {
content
if animate {
Image(systemName: "minus.circle.fill")
.foregroundColor(Color(.systemGray))
.offset(x: -25, y: -25)
}
}
.id(animate) //THIS IS THE MAGIC
.onChange(of: animate) { newValue in
if newValue {
let baseAnimation = Animation.linear(duration: 0.15)
withAnimation(baseAnimation.repeatForever(autoreverses: true)) {
wave.toggle()
}
}
}
.rotationEffect(.degrees(animate ? (wave ? 2.5 : -2.5) : 0.0),
anchor: .center)
}
init(animate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) {
self.content = content()
self._animate = animate
}
}
Use
#State private var editMode = false
WiggleAnimation(animate: $editMode) {
VStack {
Image(systemName: image)
.resizable()
.frame(width: UIScreen.screenWidth * 0.1,
height: UIScreen.screenWidth * 0.1)
.padding()
.foregroundColor(.white)
.background(.gray)
Text(text)
.multilineTextAlignment(.center)
.font(KMFont.tiny)
.foregroundColor(.black)
}
}
How does it work?
.id(animate) modifier here does not refresh the view but just replaces it with a new one, so it is back to its original state.
Again this might not be the best solution but it works for my case.
There is nothing wrong in your code, so I assume it is Apple's defect. It seems there are many with implicit animations (at least with Xcode 11.2). Anyway...
I recommend to consider alternate approach provided below that gives expected behaviour.
struct TestAnimationDeactivate: View {
#State var active: Bool = false
var body: some View {
VStack {
if active {
BlinkBadge()
} else {
Badge()
}
}
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct Badge: View {
var body: some View {
Circle()
}
}
struct BlinkBadge: View {
#State private var animating = false
var body: some View {
Circle()
.scaleEffect(animating ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true))
.onAppear {
self.animating = true
}
}
}
struct TestAnimationDeactivate_Previews: PreviewProvider {
static var previews: some View {
TestAnimationDeactivate()
}
}
Aspid comments on the accepted solution that an Xcode update broke it. I was struggling with a similar problem while playing around with an example from Hacking with Swift, and
.animation(active ? Animation.default.repeatForever() : Animation.default)
was not working for me either on Xcode 13.2.1. The solution I found was to encapsulate the animation in a custom ViewModifier. The code below illustrates this; the big button toggles between active and inactive animations.
`
struct ContentView: View {
#State private var animationAmount = 1.0
#State private var animationEnabled = false
var body: some View {
VStack {
Button("Tap Me") {
// We would like to stop the animation
animationEnabled.toggle()
animationAmount = animationEnabled ? 2 : 1
}
.onAppear {
animationAmount = 2
animationEnabled = true
}
.padding(50)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
)
.modifier(AnimatedCircle(animationAmount: $animationAmount, animationEnabled: $animationEnabled))
}
}
}
struct AnimatedCircle: ViewModifier {
#Binding var animationAmount: Double
#Binding var animationEnabled: Bool
func body(content: Content) -> some View {
if animationEnabled {
return content.animation(.easeInOut(duration: 2).repeatForever(autoreverses: false),value: animationAmount)
}
else {
return content.animation(.easeInOut(duration: 0),value: animationAmount)
}
}
}
`
It may not be the best conceivable solution, but it works. I hope it helps somebody.