Animate a view to slide up and hide on tap in SwiftUI

Viewed 4698

I created a banner modifier that displays a banner from the top. This animates well. However, when I tap to dismiss it, it does not animate at all, just hides even though the tap gesture action has withAnimation wrapping it.

struct BannerModifier: ViewModifier {
    @Binding var model: BannerData?
    
    func body(content: Content) -> some View {
        content.overlay(
            Group {
                if model != nil {
                    VStack {
                        HStack(alignment: .firstTextBaseline) {
                            Image(systemName: "exclamationmark.triangle.fill")
                            VStack(alignment: .leading) {
                                Text(model?.title ?? "")
                                    .font(.headline)
                                if let message = model?.message {
                                    Text(message)
                                        .font(.footnote)
                                }
                            }
                        }
                        .padding()
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .foregroundColor(.white)
                        .background(.red)
                        .cornerRadius(10)
                        .shadow(radius: 10)
                        Spacer()
                    }
                    .padding()
                    .animation(.easeInOut)
                    .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
                    .onTapGesture {
                        withAnimation {
                            model = nil
                        }
                    }
                    .gesture(
                        DragGesture()
                            .onChanged { _ in
                                withAnimation {
                                    model = nil
                                }
                            }
                    )
                }
            }
        )
    }
}

struct BannerData: Identifiable {
    let id = UUID()
    let title: String
    let message: String?
}

enter image description here

In the tap gesture, I wipe out the model but it does not animate. It only hides immediately. How can I animate it so it slide up which is opposite of how it slide down to display? It would be really nice if I can also make the drag gesture interactive so I can slide it out like the native notifications.

2 Answers

Removing view from hierarchy is always animated by container, so to fix your modifier it is needed to apply .animation on some helper container (note: Group is not actually a real container).

demo

Here is corrected variant

struct BannerModifier: ViewModifier {
    @Binding var model: BannerData?
    
    func body(content: Content) -> some View {
        content.overlay(
            VStack {               // << holder container !!
                if model != nil {
                    VStack {
                        HStack(alignment: .firstTextBaseline) {
                            Image(systemName: "exclamationmark.triangle.fill")
                            VStack(alignment: .leading) {
                                Text(model?.title ?? "")
                                    .font(.headline)
                                if let message = model?.message {
                                    Text(message)
                                        .font(.footnote)
                                }
                            }
                        }
                        .padding()
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .foregroundColor(.white)
                        .background(Color.red)
                        .cornerRadius(10)
                        .shadow(radius: 10)
                        Spacer()
                    }
                    .padding()
                    .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
                    .onTapGesture {
                        withAnimation {
                            model = nil
                        }
                    }
                    .gesture(
                        DragGesture()
                            .onChanged { _ in
                                withAnimation {
                                    model = nil
                                }
                            }
                    )
                }
            }
            .animation(.easeInOut)         // << here !!
        )
    }
}

Tested with Xcode 12.1 / iOS 14.1 and test view:

struct TestBannerModifier: View {
    @State var model: BannerData?
    var body: some View {
        VStack {
            Button("Test") { model = BannerData(title: "Error", message: "Fix It!")}
            Button("Reset") { model = nil }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .modifier(BannerModifier(model: $model))
    }
}

This is a slightly improoved version of the banner posted by Asperi Hope it helps someone.

import SwiftUI

public class BannerData {
    public enum BannerType {
        case warning, error, success

        var textColor: Color {
            switch self {
            case .warning:
                return .black
            case .error:
                return .white
            case .success:
                return .white
            }
        }

        var backgroundColor: Color {
            switch self {
            case .warning:
                return .yellow
            case .error:
                return .red
            case .success:
                return .green
            }
        }

        var icon: String {
            switch self {
            case .warning:
                return "exclamationmark.triangle.fill"
            case .error:
                return "exclamationmark.circle.fill"
            case .success:
                return "checkmark.circle.fill"
            }
        }
    }

    var type: BannerType = .success
    let title: String
    let message: String?

    public init(title: String, message: String? = nil, type: BannerType) {
        self.title = title
        self.message = message
        self.type = type
    }
}

public struct BannerModifier: ViewModifier {
    @Binding var model: BannerData?

    public init(model: Binding<BannerData?>) {
        _model = model
    }

    public func body(content: Content) -> some View {
        content.overlay(
            VStack {
                if model != nil {
                    VStack {
                        HStack(alignment: .firstTextBaseline) {
                            Image(systemName: model?.type.icon ?? BannerData.BannerType.success.icon)
                            VStack(alignment: .leading) {
                                Text(model?.title ?? "")
                                    .font(.headline)
                                if let message = model?.message {
                                    Text(message)
                                        .font(.footnote)
                                }
                            }
                            Spacer()
                        }
                        .padding()
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .foregroundColor(.white)
                        .background(model?.type.backgroundColor ?? .clear)
                        .cornerRadius(10)
                        .shadow(radius: 10)
                        Spacer()
                    }
                    .padding()
                    .transition(AnyTransition.move(edge: .top).combined(with: .opacity))
                    .onTapGesture {
                        withAnimation {
                            model = nil
                        }
                    }
                    .gesture(
                        DragGesture()
                            .onChanged { _ in
                                withAnimation {
                                    model = nil
                                }
                            }
                    )
                }
            }
            .animation(.spring())
        )
    }
}
Related