In SwiftUI the animations inside a List are not functioning properly. However, when I replace the List with a ScrollView and a LazyVStack, the animations perform as expected. Is there a solution to fix this? I don't want to switch to LazyVStack because I'm using onMove and onDelete modifiers and some other List-specific stuff.
Environment: Xcode 14.2
struct SomeView: View {
#State var showColor = false
var body: some View {
List {
if showColor {
Color.green
.frame(width: 200, height: 200)
.transition(.scale)
}
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
Due to the way that the frame is updated for a list item, I am not really sure if there is a way to achieve the desired animation.
Two improvements I can think of, however, would be to add a VStack around the text and the color like so:
struct SomeView: View {
#State var showColor = false
var body: some View {
List {
VStack {
if showColor {
Color.green
.frame(width: 200, height: 200)
.transition(.scale)
}
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
}
An alternative improvement, which would make the animation smoother, would be to replace the if statement with a "conditional" frame like so.
struct ContentView: View {
#State var showColor = false
var body: some View {
List {
Color.green
.frame(width: showColor ? 200 : 0, height: showColor ? 200 : 0)
.transition(.scale)
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
By using a combination of both of those, you should be able to receive the best results for your use case. If the animations are still not good enough, I would consider changing the layout or not using a list.
Related
For example, this is what happening right now
struct ContentView: View {
#State var titleLable = "This is basic text"
#State var isTextAnimated: Bool = false
var body: some View {
VStack {
Text(titleLable)
.offset(y: isTextAnimated ? 300 : 0)
.animation(.linear)
Button {
isTextAnimated.toggle()
if isTextAnimated {
titleLable = "New text appeared"
} else {
titleLable = "This is basic text"
}
} label: {
Text("Press")
}
}
.padding()
}
The code above leads to this in Live Preview:
click there
This happens if text doesn't change its value ( I need this behaviour with changing ):
click there
One of the simplest way to achieve this animation is to embed two Text inside a ZStackand modify their opacity, and modify the ZStack's offset rather than the individual Texts. in this way both the offset and the change between two texts will get animated. here is my code:
struct HomeScreen: View {
#State var isTextAnimated: Bool = false
var body: some View {
ZStack{
Text("Hello")
.opacity(isTextAnimated ? 1 : 0)
Text("World")
.opacity(isTextAnimated ? 0 : 1)
}
.offset(y: isTextAnimated ? 150 : 0)
Button(action: {withAnimation{isTextAnimated.toggle()}}){
Text("Press")
}
}
}
To animate the position and the content of the Text label, you can use matchedGeometryEffect, as follows:
struct ContentView: View {
#State var isTextAnimated: Bool = false
#Namespace var namespace
var body: some View {
VStack {
if isTextAnimated {
Text("New text appeared")
.matchedGeometryEffect(id: "title", in: namespace)
.offset(y: 300)
} else {
Text("This is basic text")
.matchedGeometryEffect(id: "title", in: namespace)
}
Button {
withAnimation {
isTextAnimated.toggle()
}
} label: {
Text("Press")
}
}
.padding()
}
}
edit: I forgot to animate the text change
struct AnimationsView: View {
#State private var buttonWasToggled = false
#Namespace private var titleAnimationNamespace
var body: some View {
VStack {
if !buttonWasToggled {
Text("This is some text")
.matchedGeometryEffect(id: "text", in: titleAnimationNamespace)
.transition(.opacity)
} else {
Text("Another text")
.matchedGeometryEffect(id: "text", in: titleAnimationNamespace)
.transition(.opacity)
.offset(y: 300)
}
Button("Press me") {
withAnimation {
buttonWasToggled.toggle()
}
}
}
}
}
A good way to animate such change is to animate the offset value rather than toggle a boolean:
struct AnimationsView: View {
#State private var title = "This is basic text"
#State private var offset: CGFloat = 0
var body: some View {
VStack {
Text("Some text")
.offset(y: offset)
Button("Press me") {
withAnimation {
// If we already have an offset, jump back to the previous position
offset = offset == 0 ? 300 : 0
}
}
}
}
}
or by using a boolean value:
struct AnimationsView: View {
#State private var title = "This is basic text"
#State private var animated = false
var body: some View {
VStack {
Text("Some text")
.offset(y: animated ? 300 : 0)
Button("Press me") {
withAnimation {
animated.toggle()
}
}
}
}
}
Note the important withAnimation that indicates to SwiftUI that you want to animate the changes made in the block. You can find the documentation here
The .animation(...) is optional and used if you want to change the behavior of the animation, such as using a spring, changing the speed, adding a delay etc... If you don't specify one, SwiftUI will use a default value. In a similar fashion, if you don't want a view to animate, you can use add the .animation(nil) modifier to prevent SwiftUI from animating said view.
Both solutions provided result in the following behavior : https://imgur.com/sOOsFJ0
As an alternative to .matchedGeometryEffect to animate moving and changing value of Text view you can "rasterize" text using .drawingGroup() modifier for Text. This makes text behave like shape, therefore animating smoothly. Additionally it's not necessary to define separate with linked with .machtedGeometryEffect modifier which can be impossible in certain situation. For example when new string value and position is not known beforehand.
Example
struct TextAnimation: View {
var titleLabel: String {
if self.isTextAnimated {
return "New text appeared"
} else {
return "This is basic text"
}
}
#State var isTextAnimated: Bool = false
var body: some View {
VStack {
Text(titleLabel)
.drawingGroup() // ⬅️ It makes text behave like shape.
.offset(y: isTextAnimated ? 100 : 0)
.animation(.easeInOut, value: self.isTextAnimated)
Button {
isTextAnimated.toggle()
} label: {
Text("Press")
}
}
.padding()
}
}
More informations
Apple's documentation about .drawingGroup modifier
How do I toggle the presence of a button to be hidden or not?
We have the non-conditional .hidden() property; but I need the conditional version.
Note: we do have the .disabled(bool) property available, but not the .hidden(bool).
struct ContentView: View {
var body: some View {
ZStack {
Color("SkyBlue")
VStack {
Button("Detect") {
self.imageDetectionVM.detect(self.selectedImage)
}
.padding()
.background(Color.orange)
.foreggroundColor(Color.white)
.cornerRadius(10)
.hidden() // ...I want this to be toggled.
}
}
}
}
I hope hidden modifier gets argument later, but since then, Set the alpha instead:
#State var shouldHide = false
var body: some View {
Button("Button") { self.shouldHide = true }
.opacity(shouldHide ? 0 : 1)
}
For me it worked perfectly to set the frame's height to zero when you do not want to see it. When you want to have the calculated size, just set it to nil:
SomeView
.frame(height: isVisible ? nil : 0)
If you want to disable it in addition to hiding it, you could set .disabled with the toggled boolean.
SomeView
.frame(height: isVisible ? nil : 0)
.disabled(!isVisible)
You can utilize SwiftUI's new two-way bindings and add an if-statement as:
struct ContentView: View {
#State var shouldHide = false
var body: some View {
ZStack {
Color("SkyBlue")
VStack {
if !self.$shouldHide.wrappedValue {
Button("Detect") {
self.imageDetectionVM.detect(self.selectedImage)
}
.padding()
.background(Color.orange)
.foregroundColor(Color.white)
.cornerRadius(10)
}
}
}
}
}
The benefit of doing this over setting the opacity to 0 is that it will remove the weird spacing/padding from your UI caused from the button still being in the view, just not visible (if the button is between other view components, that is).
all the answers here works specifically for a button to be hidden conditionally.
What i think might help is making a modifier itself conditionally e.g:
.hidden for button/view, or maybe .italic for text, etc..
Using extensions.
For text to be conditionally italic it is easy since .italic modifier returns Text:
extension Text {
func italicConditionally(isItalic: Bool) -> Text {
isItalic ? self.italic() : self
}
}
then applying conditional italic like this:
#State private var toggle = false
Text("My Text")
.italicConditionally(isItalic: toggle)
However for Button it is tricky, since the .hidden modifier returns "some view":
extension View {
func hiddenConditionally(isHidden: Bool) -> some View {
isHidden ? AnyView(self.hidden()) : AnyView(self)
}
}
then applying conditional hidden like this:
#State private var toggle = false
Button("myButton", action: myAction)
.hiddenConditionally(isHidden: toggle)
You can easily hide a view in SwiftUI using a conditional statement.
struct TestView: View{
#State private var isVisible = false
var body: some View{
if !isVisible {
HStack{
Button(action: {
isVisible.toggle()
// after click you'r view will be hidden
}){
Text("any view")
}
}
}
}
}
It isn't always going to be a pretty solution, but in some cases, adding it conditionally may also work:
if shouldShowMyButton {
Button(action: {
self.imageDetectionVM.detect(self.selectedImage)
}) {
Text("Button")
}
}
There will be an issue of the empty space in the case when it isn't being shown, which may be more or less of an issue depending on the specific layout. That might be addressed by adding an else statement that alternatively adds an equivalently sized blank space.
#State private var isHidden = true
VStack / HStack
if isHidden {
Button {
if !loadVideo(),
let urlStr = drill?.videoURL as? String,
let url = URL(string: urlStr) {
player = VideoPlayerView(player: AVPlayer(), videoUrl: url)
playVideo.toggle()
}
} label: {
Image(playVideo ? "ic_close_blue" : "ic_video_attached")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50)
}
.buttonStyle(BorderlessButtonStyle())
}
.onAppear {
if shouldShowButton {
isHidden = false
} else {
isVideoButtonHidden = true
}
}
does anyone know why exactly views inside Group don't animate?
struct ContentView: View {
#State private var showRed = false
var body: some View {
NavigationView {
Group {
if showRed {
redView
} else {
blueView
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Toggle color") {
showRed.toggle()
}
}
}
}
}
#ViewBuilder
var redView: some View {
Text("")
.frame(width: 100, height: 100)
.background(.red)
.transition(.opacity.animation(.easeInOut(duration: 1)))
}
#ViewBuilder
var blueView: some View {
Text("")
.frame(width: 100, height: 100)
.background(.blue)
.transition(.opacity.animation(.easeInOut(duration: 1)))
}
}
No one seems to really know why this is and the best answer I've found is "There's no container to hold the transition" which doesn't really make sense given that Group's initializer is marked #ViewBuilder so it should return a real container view that can take modifiers like .toolbar in the sample above.
The transitions work if you either swap out Group with some Stack or if you wrap the conditionals inside the Group{} in another container like ZStack:
Group {
ZStack {
if showRed {
redView
} else {
blueView
}
}
}
Actually Group doesn't really create any real container, even if it has a ViewBuilder init, as said in documentation:
Use a group to collect multiple views into a single instance, without affecting the layout of those views
You can simply use a ZStack for this
ZStack {
if showRed {
redView
} else {
blueView
}
}
I want my button to look different after pressing the button. I am trying to do so by using a ZStack and an if statement. I don't understand why it won't toggle between a black button and a white button... P.S. I am getting experience using ObservableObject protocol.
class WelcomeButton: ObservableObject {
#Published var hasBeenPressed = false
}
struct WelcomeScreenButton: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
ZStack {
if welcomeButton.hasBeenPressed {
Circle()
.fill(Color(.white))
}
else {
Circle()
.fill(Color(.black))
}
}
}
}
struct ContentView: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
VStack {
Button(action: {welcomeButton.hasBeenPressed.toggle()})
{
WelcomeScreenButton()
}
.frame(width: 375, height: 375, alignment: .center)
}
}
}
You're very close, but you have to have the same instance of the WelcomeButton ObservableObject shared between the two. Right now, they're two separate instances, so when you update hasBeenPressed on one, the other one doesn't know to change its state.
struct WelcomeScreenButton: View {
#ObservedObject var welcomeButton : WelcomeButton //<-- here
var body: some View {
ZStack {
if welcomeButton.hasBeenPressed {
Circle()
.fill(Color(.white))
}
else {
Circle()
.fill(Color(.black))
}
}
}
}
struct ContentView: View {
#ObservedObject var welcomeButton = WelcomeButton()
var body: some View {
VStack {
Button(action: {welcomeButton.hasBeenPressed.toggle()})
{
WelcomeScreenButton(welcomeButton: welcomeButton) //<-- here
}
.frame(width: 375, height: 375, alignment: .center)
}
}
}
PS -- just as a general practice, it might be good to make the naming a little different just to keep things straight. WelcomeButton sounds like it's going to be a button. Naming it WelcomeButtonViewModel instead would make its intention more clear, although it wouldn't change the functionality at all.
I'm trying to create a really simple transition animation that shows/hides a message in the center of the screen by tapping on a button:
struct ContentView: View {
#State private var showMessage = false
var body: some View {
ZStack {
Color.yellow
VStack {
Spacer()
Button(action: {
withAnimation(.easeOut(duration: 3)) {
self.showMessage.toggle()
}
}) {
Text("SHOW MESSAGE")
}
}
if showMessage {
Text("HELLO WORLD!")
.transition(.opacity)
}
}
}
}
According to the documentation of the .transition(.opacity) animation
A transition from transparent to opaque on insertion, and from opaque
to transparent on removal.
the message should fade in when the showMessage state property becomes true and fade out when it becomes false. This is not true in my case. The message shows up with a fade animation, but it hides with no animation at all. Any ideas?
EDIT: See the result in the gif below taken from the simulator.
The problem is that when views come and go in a ZStack, their "zIndex" doesn't stay the same. What is happening is that the when "showMessage" goes from true to false, the VStack with the "Hello World" text is put at the bottom of the stack and the yellow color is immediately drawn over top of it. It is actually fading out but it's doing so behind the yellow color so you can't see it.
To fix it you need to explicitly specify the "zIndex" for each view in the stack so they always stay the same - like so:
struct ContentView: View {
#State private var showMessage = false
var body: some View {
ZStack {
Color.yellow.zIndex(0)
VStack {
Spacer()
Button(action: {
withAnimation(.easeOut(duration: 3)) {
self.showMessage.toggle()
}
}) {
Text("SHOW MESSAGE")
}
}.zIndex(1)
if showMessage {
Text("HELLO WORLD!")
.transition(.opacity)
.zIndex(2)
}
}
}
}
My findings are that opacity transitions don't always work. (yet a slide in combination with an .animation will work..)
.transition(.opacity) //does not always work
If I write it as a custom animation it does work:
.transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2)))
.zIndex(1)
I found a bug in swiftUI_preview for animations. when you use a transition animation in code and want to see that in SwiftUI_preview it will not show animations or just show when some views disappear with animation. for solving this problem you just need to add your view in preview in a VStack. like this :
struct test_UI: View {
#State var isShowSideBar = false
var body: some View {
ZStack {
Button("ShowMenu") {
withAnimation {
isShowSideBar.toggle()
}
}
if isShowSideBar {
SideBarView()
.transition(.slide)
}
}
}
}
struct SomeView_Previews: PreviewProvider {
static var previews: some View {
VStack {
SomeView()
}
}
}
after this, all animations will happen.
I believe this is a problem with the canvas. I was playing around with transitions this morning and while the don't work on the canvas, they DO seem to work in the simulator. Give that a try. I've reported the bug to Apple.
I like Scott Gribben's answer better (see below), but since I cannot delete this one (due to the green check), I'll just leave the original answer untouched. I would argue though, that I do consider it a bug. One would expect the zIndex to be implicitly assigned by the order views appear in code.
To work around it, you may embed the if statement inside a VStack.
struct ContentView: View {
#State private var showMessage = false
var body: some View {
ZStack {
Color.yellow
VStack {
Spacer()
Button(action: {
withAnimation(.easeOut(duration: 3)) {
self.showMessage.toggle()
}
}) {
Text("SHOW MESSAGE")
}
}
VStack {
if showMessage {
Text("HELLO WORLD!")
.transition(.opacity)
}
}
}
}
}
zIndex may cause the animation to be broken when interrupted. Wrap the view you wanna apply transition to in a VStack, HStack or any other container will make sense.
I just gave up on .transition. It's just not working. I instead animated the view's offset, much more reliable:
First I create a state variable for offset:
#State private var offset: CGFloat = 200
Second, I set the VStack's offset to it. Then, in its .onAppear(), I change the offset back to 0 with animation:
VStack{
Spacer()
HStack{
Spacer()
Image("MyImage")
}
}
.offset(x: offset)
.onAppear {
withAnimation(.easeOut(duration: 2.5)) {
offset = 0
}
}
Below code should work.
import SwiftUI
struct SwiftUITest: View {
#State private var isAnimated:Bool = false
var body: some View {
ZStack(alignment:.bottom) {
VStack{
Spacer()
Button("Slide View"){
withAnimation(.easeInOut) {
isAnimated.toggle()
}
}
Spacer()
Spacer()
}
if isAnimated {
RoundedRectangle(cornerRadius: 16).frame(height: UIScreen.main.bounds.height/2)
.transition(.slide)
}
}.ignoresSafeArea()
}
}
struct SwiftUITest_Previews: PreviewProvider {
static var previews: some View {
VStack {
SwiftUITest()
}
}
}