Search code examples
animationswiftuitransitionxcode15ios17

How to move ZStack from bottom when View is presented SwiftUI iOS 17.4?


I'm trying to show ZStack with animation from bottom when View is presented on screen. Code below doesn't work. I'm new and I don't know what is wrong with this? Where the withAnimation should be, and where transition ?

Code looks like this:

                ZStack {
                    RoundedRectangle(cornerRadius: 15, style: .continuous)
                        .fill(.white)
                        .frame(width:UIScreen.main.bounds.width - 32, height: 250)
                        .background(.ultraThinMaterial,
                                    in: RoundedRectangle(cornerRadius: 15, style: .circular))
                        .opacity(0.4)
                        .shadow(radius: 5, x:0, y: 3)
                    
                    VStack(alignment: .leading, spacing: 10) {
                        Text("Login")
                            .font(.title3)
                            .foregroundColor(Color(.systemGray))
                        
                        TextField("Enter login", text: $login)
                            .textFieldStyle(.roundedBorder)
                            .submitLabel(.continue)
                        
                        Text("Password")
                            .font(.title3)
                            .foregroundColor(Color(.systemGray))
                        
                        SecureField("Enter password", text: $password)
                            .textFieldStyle(.roundedBorder)
                            .submitLabel(.done)
                        Spacer()
                        
                        Button(action: {
                            
                        }, label: {
                            Text("Enter")
                                .frame(maxWidth: .infinity, maxHeight: 40)
                                .background(Color(.systemYellow))
                                .clipShape(.buttonBorder)
                                .foregroundStyle(Color(.black))
                                .opacity(0.6)
                        })
                    }
                    .padding()
                    .frame(width:UIScreen.main.bounds.width - 32, height: 250)
                }
                .padding()
                .onAppear {
                    withAnimation(.easeInOut(duration: 5)) {
                        isPresented.toggle()
                    }
                }
                .transition(AnyTransition.move(edge: .bottom))
                .zIndex(0)
            }




Solution

  • There is no transition happening because the content is already visible when it first appears.

    To fix:

    • Nest the ZStack inside some other container (a Group will do).
    • Move the onAppear callback to the container.
    • Make the visibility of the ZStack conditional on the state variable isPresented.

    Currently, the flag isPresented is being toggled in .onAppear. There is a possibility, that this may cause it to disappear when presented a second time (depending on whether the state variable retains its state). I would suggest setting it explicitly to true instead. You could use an .onDisappear callback to set it back to false, if you need to.

    The zIndex might not be needed either, try deleting it.

    Group {
        if isPresented {
            ZStack {
                // ...
            }
            .padding()
            .transition(AnyTransition.move(edge: .bottom))
        }
    }
    .onAppear {
        withAnimation(.easeInOut(duration: 5)) {
            isPresented = true
        }
    }
    .onDisappear {
        isPresented = false
    }
    

    EDIT Following from your comment, here is a fully updated standalone example to demonstrate it working.

    On the issue of the flag, I suspect that what is happening is that you are toggling isPresented somewhere else in your code. In the example below, I have deliberately renamed the flag to isShowing, so that there is no chance of it being updated from elsewhere.

    Regarding the animation, if you want the panel to slide up from the bottom edge of the screen then extra (empty) height needs to be added, equivalent to the space between the panel and the bottom edge. This extra height can be computed if the height of the screen is known.

    The updated code below also includes the following changes:

    • Whenever you need to know the size of the screen, you should use a GeometryReader to measure it, because UIScreen.main is deprecated. Also, UIScreen.main doesn't work well with iPad split-screen. So the top-level container is now a GeometryReader.
    • In fact, the GeometryReader is only needed for finding the height of the screen. If you want the panel to use the full width - 32pt, then you can just set maxWidth: .infinity and apply horizontal padding of 16pt.
    • Instead of using a ZStack, the RoundedRectangle can simply be applied as .background to the VStack. This way, it automatically has the same size.
    • You were previously using a semi-transparent white background with a material behind it and a shadow effect. It was actually the shadow effect that was making it look gray, but if you looked closely, it was also giving it a stripe around the border. In the example below, the background has been simplified to use a solid shade of gray.
    • The modifier .foregroundColor is also deprecated, use .foregroundStyle instead.
    • You were creating every Color from a UIColor. For the standard colors, you can just use the definitions provided by Color.
    • You will probably want to shorten the duration of the animation.
    struct ContentView: View {
        private let panelHeight: CGFloat = 250
        @State private var isShowing = false
        @State private var login = ""
        @State private var password = ""
        var body: some View {
            GeometryReader { screen in
                if isShowing {
                    VStack(alignment: .leading, spacing: 10) {
                        Text("Login")
                            .font(.title3)
                            .foregroundStyle(.gray)
    
                        TextField("Enter login", text: $login)
                            .textFieldStyle(.roundedBorder)
                            .submitLabel(.continue)
    
                        Text("Password")
                            .font(.title3)
                            .foregroundStyle(.gray)
    
                        SecureField("Enter password", text: $password)
                            .textFieldStyle(.roundedBorder)
                            .submitLabel(.done)
    
                        Spacer()
    
                        Button {} label: {
                            Text("Enter")
                                .frame(maxWidth: .infinity, maxHeight: 40)
                                .background(.yellow)
                                .clipShape(.buttonBorder)
                                .foregroundStyle(.black)
                                .opacity(0.6)
                        }
                    }
                    .padding()
                    .frame(height: panelHeight)
                    .frame(maxWidth: .infinity)
                    .background {
                        RoundedRectangle(cornerRadius: 15)
                            .fill(Color(white: 0.94))
                            .shadow(radius: 5, x:0, y: 3)
                    }
                    .padding(.horizontal, 16)
                    .frame(
                        height: (screen.size.height + panelHeight) / 2,
                        alignment: .bottom
                    )
                    .transition(.move(edge: .bottom))
                }
            }
            .ignoresSafeArea()
            .onAppear {
                withAnimation(.easeInOut(duration: 5)) {
                    isShowing = true
                }
            }
            .onDisappear {
                isShowing = false
            }
        }
    }
    

    Animation