SwiftUI position and frame animation broken in iOS 14 - swiftui

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.

Related

SwiftUI animation not working using animation(_:value:)

In SwiftUI, I've managed to make a Button animate right when the view is first drawn to the screen, using the animation(_:) modifier, that was deprecated in macOS 12.
I've tried to replace this with the new animation(_:value:) modifier, but this time nothing happens:
So this is not working:
struct ContentView: View {
#State var isOn = false
var body: some View {
Button("Press me") {
isOn.toggle()
}
.animation(.easeIn, value: isOn)
.frame(width: 300, height: 400)
}
}
But then this is working. Why?
struct ContentView: View {
var body: some View {
Button("Press me") {
}
.animation(.easeIn)
.frame(width: 300, height: 400)
}
}
The second example animates the button just as the view displays, while the first one does nothing
The difference between animation(_:) and animation(_:value:) is straightforward. The former is implicit, and the latter explicit. The implicit nature of animation(_:) meant that anytime ANYTHING changed, it would react. The other issue it had was trying to guess what you wanted to animate. As a result, this could be erratic and unexpected. There were some other issues, so Apple has simply deprecated it.
animation(_:value:) is an explicit animation. It will only trigger when the value you give it changes. This means you can't just stick it on a view and expect the view to animate when it appears. You need to change the value in an .onAppear() or use some value that naturally changes when a view appears to trigger the animation. You also need to have some modifier specifically react to the changed value.
struct ContentView: View {
#State var isOn = false
//The better route is to have a separate variable to control the animations
// This prevents unpleasant side-effects.
#State private var animate = false
var body: some View {
VStack {
Text("I don't change.")
.padding()
Button("Press me, I do change") {
isOn.toggle()
animate = false
// Because .opacity is animated, we need to switch it
// back so the button shows.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
animate = true
}
}
// In this case I chose to animate .opacity
.opacity(animate ? 1 : 0)
.animation(.easeIn, value: animate)
.frame(width: 300, height: 400)
// If you want the button to animate when the view appears, you need to change the value
.onAppear { animate = true }
}
}
}
Follow up question: animating based on a property of an object is working on the view itself, but when I'm passing that view its data through a ForEach in the parent view, an animation modifier on that object in the parent view is not working. It won't even compile. The objects happen to be NSManagedObjects but I'm wondering if that's not the issue, it's that the modifier works directly on the child view but not on the passed version in the parent view. Any insight would be greatly appreciated
// child view
struct TileView: View {
#ObservedObject var tile: Tile
var body: some View {
Rectangle()
.fill(tile.fillColor)
.cornerRadius(7)
.overlay(
Text(tile.word)
.bold()
.font(.title3)
.foregroundColor(tile.fillColor == .myWhite ? .darkBlue : .myWhite)
)
// .animation(.easeInOut(duration: 0.75), value: tile.arrayPos)
// this modifier worked here
}
}
struct GridView: View {
#ObservedObject var game: Game
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: 4)
var body: some View {
GeometryReader { geo in
LazyVGrid(columns: columns) {
ForEach(game.tilesArray, id: \.self) { tile in
Button(action: {
tile.toggleSelectedStatus()
moveTiles() <- this changes their array position (arrayPos), and
the change in position should be animated
}) {
TileView(tile: tile)
.frame(height: geo.size.height * 0.23)
}
.disabled(tile.status == .solved || tile.status == .locked)
.animation(.easeInOut(duration: 0.75), value: arrayPos)
.zIndex(tile.status == .locked ? 1 : 0)
}
}
}
}
}

What's causing SwiftUI nested View items jumpy animation after the initial drawing?

I've recently encountered an issue in a container View that has a nested list of View items that use a repeatForever animation, that works fine when firstly drew and jumpy after a sibling item is added dynamically.
The list of View is dynamically generated from an ObservableObject property, and its represented here as Loop. It's generated after a computation that takes place in a background thread (AVAudioPlayerNodeImpl.CompletionHandlerQueue).
The Loop View animation has a duration that equals a dynamic duration property value of its passed parameter player. Each Loop has its own values, that may or not be the same in each sibling.
When the first Loop View is created the animation works flawlessly but becomes jumpy after a new item is included in the list. Which means, that the animation works correctly for the tail item (the last item in the list, or the newest member) and the previous wrongly.
From my perspective, it seems related to how SwiftUI is redrawing and there's a gap in my knowledge, that lead to an implementation that causes the animation states to scatter. The question is what is causing this or how to prevent this from happening in the future?
I've minimised the implementation, to improve clarity and focus on the subject.
Let's take a look into the Container View:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var engine: Engine
fileprivate func Loop(duration: Double, play: Bool) -> some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: play ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0,
lineCap: .round,
lineJoin: .round)
)
.animation(
self.audioEngine.isPlaying ?
Animation
.linear(duration: duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
var body: some View {
VStack {
ForEach (0 ..< self.audioEngine.players.count, id: \.self) { index in
HStack {
self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
}
}
}
}
}
In the Body you'll find a ForEach that watches a list of Players, a #Published property from Engine.
Have a look onto the Engine class:
Class Engine: ObservableObject {
#Published var players = []
func record() {
...
}
func stop() {
...
self.recorderCompletionHandler()
}
func recorderCompletionHandler() {
...
let player = self.createPlayer(...)
player.play()
DispatchQueue.main.async {
self.players.append(player)
}
}
func createPlayer() {
...
}
}
Finally, a small video demo to showcase the issue that is worth more than words:
For this particular example, the last item has a duration that is double the duration of the previous two, that have the same duration each. Although the issue happens regardless of this exemplified state.
Would like to mention that the start time or trigger time is the same for all, the .play a method called in sync!
Edited
Another test after following good practices provided by #Ralf Ebert, with a slight change given my requirements, toggle the play state, which unfortunately causes the same issue, so thus far this does seem to be related with some principle in SwiftUI that is worth learning.
A modified version for the version kindly provided by #Ralf Ebert:
// SwiftUIPlayground
import SwiftUI
struct PlayerLoopView: View {
#ObservedObject var player: MyPlayer
var body: some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
player.isPlaying ?
Animation
.linear(duration: player.duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
}
struct PlayersProgressView: View {
#ObservedObject var engine = Engine()
var body: some View {
NavigationView {
VStack {
ForEach(self.engine.players) { player in
HStack {
Text("Player")
PlayerLoopView(player: player)
}
}
}
.navigationBarItems(trailing:
VStack {
Button("Add Player") {
self.engine.addPlayer()
}
Button("Play All") {
self.engine.playAll()
}
Button("Stop All") {
self.engine.stopAll()
}
}.padding()
)
}
}
}
class MyPlayer: ObservableObject, Identifiable {
var id = UUID()
#Published var isPlaying: Bool = false
var duration: Double = 1
func play() {
self.isPlaying = true
}
func stop() {
self.isPlaying = false
}
}
class Engine: ObservableObject {
#Published var players = [MyPlayer]()
func addPlayer() {
let player = MyPlayer()
players.append(player)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
player.isPlaying = true
}
}
func stopAll() {
self.players.forEach { $0.stop() }
}
func playAll() {
self.players.forEach { $0.play() }
}
}
struct PlayersProgressView_Previews: PreviewProvider {
static var previews: some View {
PlayersProgressView()
}
}
The following demo was created by following the steps (the demo only shows after the stop all to keep it under 2mb maximum image upload in Stack Overflow):
- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)
Found an article reporting a similar issue:
https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/
I'll have to find a different approach instead of using .repeatForever
You need to make sure that no view update (triggered f.e. by a change like adding a new player) causes 'Loop' to be re-evaluated again because this could reset the animation.
In this example, I would:
make the player Identifiable so SwiftUI can keep track of the objects (var id = UUID() suffices), then you can use ForEach(self.engine.players) and SwiftUI can keep track of the Player -> View association.
make the player itself an ObservableObject and create a PlayerLoopView instead of the Loop function in your example:
struct PlayerLoopView: View {
#ObservedObject var player: Player
var body: some View {
ZStack {
Circle()
// ...
}
}
That's imho the most reliable way to prevent state updates to mess with your animation.
See here for a runnable example: https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift
This problem seems to be generated with the original implementation, where the .animation method takes a conditional and that's what causes the jumpiness.
If we decide not and instead keep the desired Animation declaration and only toggle the animation duration it works fine!
As follows:
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: player.isPlaying ? player.duration : 0.0)
.repeatForever(autoreverses: false)
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
}
.frame(width: 100, height: 100)
.padding()
Obs: The third element duration is 4x longer, just for testing
The result as desired:

SwiftUI - animating a new image inside the current view

I have a View where I use a Picture(image) subview to display an image, which can come in different height and width formats.
The reference to the image is extracted from an array, which allows me to display different images in my View, by varying the reference. SwiftUI rearrange the content of view for each new image
I would like an animation on this image, say a scale effect, when the image is displayed
1) I need a first .animation(nil) to avoid animating the former image (otherwise I have an ugly fade out and aspect ratio deformation). Seems the good fix
2) But then I have a problem with the scaleEffect modifier (even if I put it to scale = 1, where it should do nothing)
The animation moves from image 1 to image 2 by imposing that the top left corner of image 2 starts from the position of top left corner of image 1, which, with different widths and heights, provokes a unwanted translation of the image center
This is reproduced in the code below where for demo purposes I'm using system images (which are not prone to bug 1))
How can I avoid that ?
3) In the demo code below, I trigger the new image with a button, which allows me to use an action and handle "scale" modification and achieve explicitly the desired effect. However in my real code, the image modification is triggered by another change in another view.
Swift knows that, hence I can use an implicit .animation modifier.
However, I can't figure out how to impose a reset of "scale" for any new image and perform my desired effect.
If I use onAppear(my code), it only works for the first image displayed, and not the following ones.
In the real code, I have a Picture(image) view, and Picture(image.animation()) does not compile.
Any idea how to achieve the action in the below code in the Button on an implicit animation ?
Thanks
import SwiftUI
let portrait = Image(systemName: "square.fill")
let landscape = Image(systemName: "square.fill")
struct ContentView: View {
#State var modified = false
#State var scale: CGFloat = 1
var body: some View {
return VStack(alignment: .center) {
Pictureclip(bool: $modified)
.animation(nil)
.scaleEffect(scale)
.animation(.easeInOut(duration: 1))
Button(action: {
self.modified.toggle()
self.scale = 1.1
DispatchQueue.main.asyncAfter(deadline: .now() + 1)
{self.scale = 1}
}) {
Text("Tap here")
.animation(.linear)
}
}
}
}
struct Pictureclip: View {
#Binding var bool: Bool
var body: some View {
if bool == true {
return portrait
.resizable()
.frame(width: 100, height: 150)
.foregroundColor(.green)
} else {
return landscape
.resizable()
.frame(width: 150, height: 100)
.foregroundColor(.red)
}
}
}
I have a semi answer to my question, namely points 1 & 2 (here with reference to two jpeg images in the asset catalog)
import SwiftUI
let portrait = Image("head")
let landscape = Image("sea")
struct ContentView: View {
#State var modified = false
#State var scale: CGFloat = 0.95
var body: some View {
VStack(){
GeometryReader { geo in
VStack {
Picture(bool: self.modified)
.frame(width: geo.size.width * self.scale)
}
}
Spacer()
Button(action: {
self.scale = 0.95
self.modified.toggle()
withAnimation(.easeInOut(duration: 0.5)){
self.scale = 1
}
}) {
Text("Tap here")
}
}
}
}
struct Picture: View {
var bool: Bool
var body: some View {
if bool == true {
return portrait
.resizable().aspectRatio(contentMode: .fit)
.padding(.all,6.0)
} else {
return landscape
.resizable().aspectRatio(contentMode: .fit)
.padding(.all,6.0)
}
}
}
This solution enables scaling without distorting the aspect ratio of the new image during the animation. But It does not work in a code where the image update is triggered in another view. I guess I have to restructure my code, either to solve my problem or to expose it more clearly.
Edit: a quick and dirty solution is to put the triggering code (here the action code in the button) in the other view. Namely, put in view B the code that animates view A, with a state variable passed to it (here, "scale"). I'm sure there are cleaner ways, but at least this works.
I am not sure about it, but maybe it can be helpful for you.
Use DataBinding structure. I use it like this:
let binding = Binding<String>(get: {
self.storage
}, set: { newValue in
self.textOfPrimeNumber = ""
self.storage = newValue
let _ = primeFactorization(n: Int(self.storage)!, k: 2, changeable: &self.textOfPrimeNumber)
})

How to stop SwiftUI DragGesture from dying when View content changes

In my app, I drag a View horizontally to set a position of a model object. I can also drag it downward and release it to delete the model object. When it has been dragged downward far enough, I indicate the potential for deletion by changing its appearance. The problem is that this change interrupts the DragGesture. I don't understand why this happens.
The example code below demonstrates the problem. You can drag the light blue box side to side. If you pull down, it and it turns to the "rays" system image, but the drag dies.
The DragGesture is applied to the ZStack with a size of 50x50. The ZStack should continue to exist across that state change, no? Why is the drag gesture dying?
struct ContentView: View {
var body: some View {
ZStack {
DraggableThing()
}.frame(width: 300, height: 300)
.border(Color.black, width: 1)
}
}
struct DraggableThing: View {
#State private var willDeleteIfDropped = false
#State private var xPosition: CGFloat = 150
var body: some View {
//Rectangle()
// .fill(willDeleteIfDropped ? Color.red : Color.blue.opacity(0.3))
ZStack {
if willDeleteIfDropped {
Image(systemName: "rays")
} else {
Rectangle().fill(Color.blue.opacity(0.3))
}
}
.frame(width: 50, height: 50)
.position(x: xPosition, y: 150)
.gesture(DragGesture()
.onChanged { val in
print("drag changed \(val.translation)")
self.xPosition = 150 + val.translation.width
self.willDeleteIfDropped = (val.translation.height > 25)
}
.onEnded { val in
print("drag ended")
self.xPosition = 150
}
)
}
}
You need to keep content, which originally captured gesture. So your goal can be achieved with the following changes:
ZStack {
Rectangle().fill(Color.blue.opacity(willDeleteIfDropped ? 0.0 : 0.3))
if willDeleteIfDropped {
Image(systemName: "rays")
}
}

In SwiftUI how can I animate a button offset when displayed

In SwiftUI, I want a button to appear from off screen by dropping in from the top into a final position when the view is initially displayed, I'm not asking for animation when the button is pressed.
I have tried:
Button(action: {}) {
Text("Button")
}.offset(x: 0.0, y: 100.0).animation(.basic(duration: 5))
but no joy.
If you would like to play with offset, this can get you started.
struct ContentView : View {
#State private var offset: Length = 0
var body: some View {
Button(action: {}) { Text("Button") }
.offset(x: 0.0, y: offset)
.onAppear {
withAnimation(.basic(duration: 5)) { self.offset = 100.0 }
}
}
}
I first suggested a .transition(.move(.top)), but I am updating my answer. Unless your button is on the border of the screen, it may not be a good fit. The move is limited to the size of the moved view. So you may need to use offset after all!
Note that to make it start way out of the screen, the initial value of offset can be negative.
First of all you need to create a transition. You could create an extension for AnyTransition or just create a variable. Use the move() modifier to tell the transition to move the view in from a specific edge
let transition = AnyTransition.move(edge: .top);
This alone only works if the view is at the edge of the screen. If your view is more towards the center you can use the combined() modifier to combine another transition such as offset() to add additional offset
let transition = AnyTransition
.move(edge: .top)
.combined(with:
.offset(
.init(width: 0, height: 100)
)
);
This transition will be for both showing and removing a view although you can use AnyTransition.asymmetric() to use different transitions for showing and removing a view
Next create a showButton bool (name this whatever) which will handle showing the button. This will use the #State property wrapper so SwiftUI will refresh the UI when changed.
#State var showButton: Bool = false;
Next you need to add the transition to your button and wrap your button within an if statement checking if the showButton bool is true
if (self.showButton == true) {
Button(action: { }) {
Text("Button")
}
.transition(transition);
}
Finally you can update the showButton bool to true or false within an animation block to animate the button transition. toggle() just reverses the state of the bool
withAnimation {
self.showButton.toggle();
}
You can put your code in onAppear() and set the bool to true so the button is shown when the view appears. You can call onAppear() on most things like a VStack
.onAppear {
withAnimation {
self.showButton = true;
}
}
Check the Apple docs to see what is available for AnyTransition https://developer.apple.com/documentation/swiftui/anytransition
Presents a message box on top with animation:
import SwiftUI
struct MessageView: View {
#State private var offset: CGFloat = -200.0
var body: some View {
VStack {
HStack(alignment: .center) {
Spacer()
Text("Some message")
.foregroundColor(Color.white)
.font(Font.system(.headline).bold())
Spacer()
}.frame(height: 100)
.background(Color.gray.opacity(0.3))
.offset(x: 0.0, y: self.offset)
.onAppear {
withAnimation(.easeOut(duration: 1.5)) { self.offset = 000.0
}
}
Spacer()
}
}
}
For those that do want to start from a Button that moves when you tap on it, try this:
import SwiftUI
struct ContentView : View {
#State private var xLoc: CGFloat = 0
var body: some View {
Button("Tap me") {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
}.offset(x: xLoc, y: 0.0)
}
}
Or alternatively (can replace Text with anything):
Button(action: {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
} )
{ Text("Tap me") }.offset(x: xLoc, y: 0.0)