I am trying to choreograph animations in SwiftUI. I want two colored bars to move in to the view with a delay between them and then move out of the view with the delays switched so that the removal is the reverse of the insertion.
I think I understand why the below code doesn't work, but I can't figure out how to make it do what I need:
import SwiftUI
struct TestAnimControl: View {
#State var show: Bool = false
#State var reverseDelay: Bool = false
var body: some View {
VStack {
Button(action:{
self.show.toggle()
}) {
Text("Animate")
.font(.largeTitle)
}
if show {
Rectangle()
.foregroundColor(.blue)
.frame(height: 100)
.transition(.move(edge: .trailing))
.animation(Animation.spring().delay(show ? 0.3 : 0.5))
Rectangle()
.foregroundColor(.red)
.frame(height: 100)
.transition(.move(edge: .trailing))
.animation(Animation.spring().delay(show ? 0.5 : 0.3))
}
}
}
}
When you run this and hit the button, the blue bar moves in and then the red bar moves in. Hit the button again and the blue bar moves out and then the red bar moves out. What I want is when you hit the button for removal, the red bar moves out and then the blue bar moves out, reverse of the way the bars came in. In this code I believe the ternary doesn't work because the animation is set when the Rectangle is created and the delay can't change after that. I may be wrong, but either way is there a way to do what I am trying to do?
Update: Xcode 13.4 / iOS 15.5
A proposed solution now is based on explicit animation with modification in every transaction so each transition have own parametrised variant of animation.
Main part:
Button(action:{
withAnimation {
self.show.toggle()
}
}) {
Text("Animate")
.font(.largeTitle)
}
if show {
Rectangle()
.foregroundColor(.blue)
.frame(height: 100)
.transition(.move(edge: .trailing))
.transaction {
$0.animation = Animation.spring().delay(delay1)
}
.onAppear { self.delay1 = 0.5 }
.onDisappear { self.delay1 = 0.3 }
Rectangle()
.foregroundColor(.yellow)
.frame(height: 100)
.transition(.move(edge: .trailing))
.transaction {
$0.animation = Animation.spring().delay(delay2)
}
.onAppear { self.delay2 = 0.3 }
.onDisappear { self.delay2 = 0.5 }
}
Test code on GitHub
Original:
!!! It does not work anymore with modern OS
Here is a solution - based on applied implicit animations to every transition. Tested with Xcode 11.4 / iOS 13.4
struct TestAnimControl: View {
#State var show: Bool = false
#State var reverseDelay: Bool = false
#State var blueDelay = 0.3
#State var redDelay = 0.5
var body: some View {
VStack {
Button(action:{
self.show.toggle()
}) {
Text("Animate")
.font(.largeTitle)
}
if show {
Rectangle()
.foregroundColor(.blue)
.frame(height: 100)
.transition(.move(edge: .trailing))
.animation(Animation.spring().delay(blueDelay))//(show ? 0.3 : 0.5))
.onAppear { self.blueDelay = 0.5 }
.onDisappear { self.blueDelay = 0.3 }
Rectangle()
.foregroundColor(.red)
.frame(height: 100)
.transition(.move(edge: .trailing))
.animation(Animation.spring().delay(redDelay))//(show ? 0.5 : 0.3))
.onAppear { self.redDelay = 0.3 }
.onDisappear { self.redDelay = 0.5 }
}
}
}
}
You cannot change delay value according to state value because Animation struct is marked as #frozen that has no effect on property observers - state properties (check it out https://docs.swift.org/swift-book/ReferenceManual/Attributes.html). The proper way to do is using DispatchQueue.main.asyncAfter(deadline:_:) to specify each delay. Here is my sample code...
struct ContentView: View {
#State var blue = false
#State var red = false
var body: some View {
VStack {
Button(action:{
let redDelay = self.red ? 0.3 : 0.5
DispatchQueue.main.asyncAfter(deadline: .now() + redDelay) {
self.red.toggle()
}
let blueDelay = self.blue ? 0.5 : 0.3
DispatchQueue.main.asyncAfter(deadline: .now() + blueDelay) {
self.blue.toggle()
}
}) {
Text("Animate")
.font(.largeTitle)
}
if blue {
Rectangle()
.foregroundColor(.blue)
.frame(height: 100)
.transition(AnyTransition.move(edge: .trailing))
.animation(Animation.spring())
}
if red {
Rectangle()
.foregroundColor(.red)
.frame(height: 100)
.transition(AnyTransition.move(edge: .trailing))
.animation(Animation.spring())
}
}
.animation(.spring())
}
}
Thanks. X_X
Related
I'm attempting to transition a button in a SwiftUI project such that, when the button is pressed, it will do a .move(edge: .trailing) and after approximately a half a second a different image for the button will come into view.
Here's what I have so far and as expected the image changes simultaneously. I'm curious if this is something that can be accomplished with asymmetric transitions. Trying to avoid using two different buttons that have animating offsets changing.
#State var delayedMove = false
#ViewBuilder
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation {
delayedMove.toggle()
}
}) {
if delayedMove {
selectImageButton
.animation(.linear.delay(delayedMove ? 0 : 1))
.transition(.move(edge: .trailing))
} else {
deleteImage
.animation(.linear.delay(!delayedMove ? 0 : 1))
.transition(.move(edge: .trailing))
}
}
.padding()
}
}
Yes you can use asymmetric, and no need to repeat the code. But Transition in SwiftUI needs 2 things to works correctly first a Group and Second an if & else, it is how it works right now maybe in future it changes but you can see the Code:
struct ContentView: View {
#State private var toggleButton = false
var body: some View {
HStack {
Button("Toggle Button") { toggleButton.toggle() }
Spacer()
Button(action: { toggleButton.toggle() }, label: {
Group {
if toggleButton {
Image(systemName: "trash")
}
else {
Image(systemName: "plus")
}
}
.transition(AnyTransition.asymmetric(insertion: AnyTransition.move(edge: .trailing), removal: AnyTransition.move(edge: .trailing)))
})
}
.padding()
.animation(Animation.linear(duration: 0.5), value: toggleButton)
}
}
You are quite close. I just split up the single if-else into 2 ifs, wrapped in a ZStack.
The button also makes secondaryDelay equal true for half a second, and then it switches back to false.
When the view leaves the hierarchy, that's when the transition starts. I worked out the way to do this by considering all the scenarios for the selectImageButton:
Showing - show: delayedMove = true, secondaryDelay = false
Starting to hide - hide: delayedMove = false, secondaryDelay = true
Hiding - hide: delayedMove = false, secondaryDelay = false
Starting to show - hide: delayedMove = true, secondaryDelay = true
From above, we only need to show when delayedMove && !secondaryDelay equals true. Similar method for other deleteImage.
Code:
struct ContentView: View {
#State var delayedMove = false
#State var secondaryDelay = false
var body: some View {
moveMe
}
#ViewBuilder
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation {
delayedMove.toggle()
secondaryDelay = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
secondaryDelay = false
}
}
}) {
ZStack {
if delayedMove && !secondaryDelay {
selectImageButton
.animation(.linear)
.transition(.move(edge: .trailing))
}
if !delayedMove && !secondaryDelay {
deleteImage
.animation(.linear)
.transition(.move(edge: .trailing))
}
}
}
.padding()
}
}
}
Result:
You can still use the same button and have the label animated with an offset if you want a clean and smooth animation like the first image going out and the other entering. you can try this view
#State var delayedMove = false
var moveMe: some View {
HStack {
Spacer()
Button(action: {
withAnimation (.linear(duration: 0.5)){
delayedMove.toggle()
}
}) {
ZStack{
Image(systemName: "trash")
.offset(x: delayedMove ? 0 : 200)
Image(systemName: "plus")
.offset(x: delayedMove ? 200 : 0)
}
.imageScale(.large)
}
.padding()
}
}
I have this code:
Group{
if self.done{
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(.gray)
.padding().overlay(Circle().stroke(Color.gray, lineWidth: 3)).opacity(0.6)
}
else{
Button(action: {self.showSearchTrash = 1}){
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(green)
.padding().overlay(Circle().stroke(green, lineWidth: 3).scaleEffect(1+self.animationAmount).animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)).onAppear {self.animationAmount = 0.1})
}
}
}.padding(.bottom, 5)
And the intention is that if self.done is false, the circle on the plus button will expand and contract indefinitely.
This works. However, if I use a toggle to set self.done to be true and then turn it back to false, the animation no longer occurs. I know that the issue is not with the toggle, because it does return to being green.
Also, the lack of . before green is intentional - I defined a specific Color green.
Any idea why the animation stops working/how to fix this?
Actually works fine as-is with Xcode 12.1 / iOS 14.1, so you might observe either bug of new iOS version or result of some other code.
Anyway, I would added turn-offs scaling on button disappear:
Button(action: {}){
Image(systemName: "plus").font(.system(size: 40)).foregroundColor(green)
.padding().overlay(Circle().stroke(green, lineWidth: 3).scaleEffect(1+self.animationAmount).animation(Animation.easeInOut(duration: 1).repeatForever(autoreverses: true)))
}
.onAppear {self.animationAmount = 0.1} // << put here !!
.onDisappear {self.animationAmount = 0} // << add this !!
You can specify Animation in the withAnimation block and create separate functions for starting/stopping the animation.
Here is a possible solution:
struct ContentView: View {
#State private var done = false
#State private var animationAmount: CGFloat = 0
var body: some View {
VStack {
Toggle("Done", isOn: $done)
plusImage
.opacity(done ? 0.6 : 1)
.foregroundColor(done ? .gray : .green)
}
.onAppear(perform: startAnimation)
.onChange(of: done) { done in
if done {
stopAnimation()
} else {
startAnimation()
}
}
}
var plusImage: some View {
Image(systemName: "plus")
.font(.system(size: 40))
.padding()
.overlay(
Circle()
.stroke(Color.gray, lineWidth: 3)
.scaleEffect(1 + animationAmount)
)
}
}
private extension ContentView {
func startAnimation() {
withAnimation(Animation.easeInOut(duration: 1).repeatForever()) {
animationAmount = 0.1
}
}
func stopAnimation() {
withAnimation {
animationAmount = 0
}
}
}
I am trying to make the yellow views disappear from bottom and top with a nice and smooth animation. (slide/move to top/bottom + fadding and keep the middle view taking the whole space)
This is my current state, and it is everything but smooth and nice haha. but it works.
import SwiftUI
struct ContentView: View {
#State var isInterfaceHidden: Bool = false
var body: some View {
VStack(spacing: 0, content: {
if !isInterfaceHidden {
Rectangle()
.id("animation")
.foregroundColor(Color.yellow)
.frame(height: 40)
.transition(AnyTransition.move(edge: .top).combined(with: .opacity).animation(.linear))
}
Rectangle()
.id("animation2")
.foregroundColor(Color.red)
.transition(AnyTransition.opacity.animation(Animation.linear))
/// We make sure it won't cover the top and bottom view.
.zIndex(-1)
.background(Color.red)
.onTapGesture(perform: {
DispatchQueue.main.async {
self.isInterfaceHidden.toggle()
}
})
if !isInterfaceHidden {
Rectangle()
.id("animation3")
.foregroundColor(Color.yellow)
.frame(height: 80)
.transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.linear))
}
})
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
Current animation:
Edit3: Thanks to #Andrew I was able to progress in a better state.
But now I have a sort of jerky animation.
Any thoughts?
I may have found a solution for you:
import SwiftUI
struct ContentView: View {
#State var isInterfaceHidden: Bool = false
var body: some View {
VStack(spacing: 0, content: {
if !isInterfaceHidden {
Rectangle()
.id("animation")
.foregroundColor(Color.yellow)
.frame(height: 40)
.transition(.topViewTransition)
}
Rectangle()
.id("animation2")
.foregroundColor(Color.red)
/// We make sure it won't cover the top and bottom view.
.zIndex(-1)
.background(Color.red)
.onTapGesture(perform: {
DispatchQueue.main.async {
self.isInterfaceHidden.toggle()
}
})
if !isInterfaceHidden {
Rectangle()
.id("animation3")
.foregroundColor(Color.yellow)
.frame(height: 80)
.transition(.bottomViewTransition)
}
})
.navigationBarTitle("")
.navigationBarHidden(true)
.animation(.easeInOut)
}
}
extension AnyTransition {
static var topViewTransition: AnyTransition {
let transition = AnyTransition.move(edge: .top)
.combined(with: .opacity)
return transition
}
static var bottomViewTransition: AnyTransition {
let transition = AnyTransition.move(edge: .bottom)
.combined(with: .opacity)
return transition
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Simply set Z index for the both of your yellow views anything higher than the default value of 1.0. This way SwiftUI will make sure they won't be covered by the red view.
The modifier to do that is .zIndex()
A simpler solution
struct Test: View {
#State private var isHiding = false
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.yellow)
.frame(width: 200, height: 100)
Rectangle()
.foregroundColor(.red)
.frame(width: 200, height: isHiding ? 100 : 80)
.onTapGesture {
withAnimation {
self.isHiding.toggle()
}
}
}
}
}
Question is simple: how in the world do i get a Text to animate properly?
struct ContentView: View {
#State var foozle: String = ""
var body: some View {
VStack() {
Spacer()
Text(self.foozle)
.frame(maxWidth: .infinity)
.transition(.opacity)
Button(action: {
withAnimation(.easeInOut(duration: 2)) {
self.foozle = "uuuuuuuuu"
}
}) { Text("ugh") }
Spacer()
}.frame(width: 320, height: 240)
}
}
The problem: the view insists on doing some dumb animation where the text is replaced with the new text, but truncated with ellipses, and it slowly expands widthwise until the entirety of the new text is shown.
Naturally, this is not an animation on opacity. It's not a frame width problem, as I've verified with drawing the borders.
Is this just another dumb bug in SwiftUI that i'm going to have to deal with, and pray that someone fixes it?
EDIT: ok, so thanks to #Mac3n, i got this inspiration, which works correctly, even if it's a little ugly:
Text(self.foozle)
.frame(maxWidth: .infinity)
.opacity(op)
Button(action: {
withAnimation(.easeOut(duration: 0.3)) {
self.op = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.foozle += "omo"
withAnimation(.easeIn(duration: 0.3)) {
self.op = 1
}
}
}
}) {
Text("ugh")
}
The problem is that SwiftUI sees Text view as the same view. You can use the .id() method on the view to set it. In this case I've just set the value to a hash of the text itself, so if you change the text, the entire view will get replaced.
struct ContentView: View {
#State var foozle: String = ""
var body: some View {
VStack() {
Spacer()
Text(self.foozle)
.id(self.foozle.hashValue)
.frame(maxWidth: .infinity)
.transition(.opacity)
Button(action: {
withAnimation(.easeInOut(duration: 2)) {
self.foozle = "uuuuuuuuu"
}
}) { Text("ugh") }
Spacer()
}.frame(width: 320, height: 240)
}
}
Transition works when view appeared/disappeared. In your use-case there is no such workflow.
Here is a demo of possible approach to hide/unhide text with opacity animation:
struct DemoTextOpacity: View {
var foozle: String = "uuuuuuuuu"
#State private var hidden = true
var body: some View {
VStack() {
Spacer()
Text(self.foozle)
.frame(maxWidth: .infinity)
.opacity(hidden ? 0 : 1)
Button(action: {
withAnimation(.easeInOut(duration: 2)) {
self.hidden.toggle()
}
}) { Text("ugh") }
Spacer()
}.frame(width: 320, height: 240)
}
}
If you want to animate on opacity you need to change opacity value on your text element.
code example:
#State private var textValue: String = "Sample Data"
#State private var opacity: Double = 1
var body: some View {
VStack{
Text("\(textValue)")
.opacity(opacity)
Button("Next") {
withAnimation(.easeInOut(duration: 0.5), {
self.opacity = 0
})
self.textValue = "uuuuuuuuuuuuuuu"
withAnimation(.easeInOut(duration: 1), {
self.opacity = 1
})
}
}
}
I've been experimenting with some SwiftUI layouts and one of the things that I wanted to try out was creating a simple circular progress ring. After playing around with the code for a while I managed to get everything working the way I was hoping for it to, at least for a prototype. The issue arrises when I embed this view inside a SwiftUI NavigationView. Now, every time I run the app in the canvas, simulator, or on a device, the initial loading of the progress ring has the entire view slowly sliding up into position.
This is a simple prototype, just messing around with the new SwiftUI tools. After some experimentation, I've found that if I remove the NavigationView the ring acts like it's meant to from the beginning. I'm not seeing an obvious reason for why this issue is occurring though.
import SwiftUI
struct ProgressRing_ContentView: View {
#State var progressToggle = false
#State var progressRingEndingValue: CGFloat = 0.75
var ringColor: Color = Color.green
var ringWidth: CGFloat = 20
var ringSize: CGFloat = 200
var body: some View {
TabView{
NavigationView{
VStack{
Spacer()
ZStack{
Circle()
.trim(from: 0, to: progressToggle ? progressRingEndingValue : 0)
.stroke(ringColor, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round, lineJoin: .round))
.background(Circle().stroke(ringColor, lineWidth: ringWidth).opacity(0.2))
.frame(width: ringSize, height: ringSize)
.rotationEffect(.degrees(-90.0))
.animation(.easeInOut(duration: 1))
.onAppear() {
self.progressToggle.toggle()
}
Text("\(Int(progressRingEndingValue * 100)) %")
.font(.largeTitle)
.fontWeight(.bold)
}
Spacer()
Button(action: {
self.progressRingEndingValue = CGFloat.random(in: 0...1)
}) { Text("Randomize")
.font(.largeTitle)
.foregroundColor(ringColor)
}
Spacer()
}
.navigationBarTitle("ProgressRing", displayMode: .inline)
.navigationBarItems(leading:
Button(action: {
print("Refresh Button Tapped")
}) {
Image(systemName: "arrow.clockwise")
.foregroundColor(Color.green)
}, trailing:
Button(action: {
print("Share Button Tapped")
}) {
Image(systemName: "square.and.arrow.up")
.foregroundColor(Color.green)
}
)
}
}
}
}
#if DEBUG
struct ProgressRing_ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ProgressRing_ContentView()
.environment(\.colorScheme, .light)
.previewDisplayName("Light Mode")
}
}
#endif
Above is the exact code that I'm currently working with. The actual animation of the ring sliding seems to be working how I expected it to, I'm just not sure why the entire ring itself is moving when embedded in a NavigationView.
You need to use explicit animations, instead of implicit. With implicit animations, any animatable parameter that changes, the framework will animate. Whenever possible, you should use explicit animations. Below is the updated code. Notice I remove the .animation() call and added two withAnimation() closures.
If you would like to expand your knowledge on implicit vs. explicit animations, check this link: https://swiftui-lab.com/swiftui-animations-part1/
struct ContentView: View {
#State var progressToggle = false
#State var progressRingEndingValue: CGFloat = 0.75
var ringColor: Color = Color.green
var ringWidth: CGFloat = 20
var ringSize: CGFloat = 200
var body: some View {
TabView{
NavigationView{
VStack{
Spacer()
ZStack{
Circle()
.trim(from: 0, to: progressToggle ? progressRingEndingValue : 0)
.stroke(ringColor, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round, lineJoin: .round))
.background(Circle().stroke(ringColor, lineWidth: ringWidth).opacity(0.2))
.frame(width: ringSize, height: ringSize)
.rotationEffect(.degrees(-90.0))
.onAppear() {
withAnimation(.easeInOut(duration: 1)) {
self.progressToggle.toggle()
}
}
Text("\(Int(progressRingEndingValue * 100)) %")
.font(.largeTitle)
.fontWeight(.bold)
}
Spacer()
Button(action: {
withAnimation(.easeInOut(duration: 1)) {
self.progressRingEndingValue = CGFloat.random(in: 0...1)
}
}) { Text("Randomize")
.font(.largeTitle)
.foregroundColor(ringColor)
}
Spacer()
}
.navigationBarTitle("ProgressRing", displayMode: .inline)
.navigationBarItems(leading:
Button(action: {
print("Refresh Button Tapped")
}) {
Image(systemName: "arrow.clockwise")
.foregroundColor(Color.green)
}, trailing:
Button(action: {
print("Share Button Tapped")
}) {
Image(systemName: "square.and.arrow.up")
.foregroundColor(Color.green)
}
)
}
}
}
}