Eli
Eli

Reputation: 768

SwiftUI animate external binding with same `withAnimation` style

I have a component called Alert. It displays an icon, title and description in a consistent way. To show the Alert, I have an optional binding (if the binding is not passed in, it defaults to true which always shows the alert).

I would like for the alert to always animate in/out. There is a close button inside the alert, and when I wrap it with

withAnimation(.easeInOut, {
    self.isVisible?.wrappedValue.toggle()
})

the alert fades in/out and also nicely animates the surrounding elements.

The problem i have run into is, if the binding updates outside of the Alert (e.g. in the parent), then it does not animate. This makes sense as it is not wrapped with the withAnimation method. If I wrap where it updates with withAnimation then all works as expected. However, I want for the alert to control how it animates in/out, I don't want to rely on the place updating the binding to have to call withAnimation.


var isVisible: Binding<Bool> = .constant(true)

var body: some View {
    if self.isVisible.wrappedValue {
        VStack {
            // Alert component stuff here
        }
        // I found that if I add this modifier here, then the view will nicely animate/slide
        // into the surrounding view, but does not fade in/out
        .animation(.easeInOut)
    }
}

What I am essentially looking for, is how do I have my alert fade in/out and nicely slide into the surrounding views (like withAnimation does), but regardless of whether the binding is updated from the internal close button or an external parent.

UPDATE: Minimal code example - excuse some hacks i had to pull it out of the design system:


import SwiftUI

struct AlertStyles {
    var icon: Image;
    var color: Color;
    var background: Color;
}

enum AlertType: CaseIterable {
    case info
    
    var style: AlertStyles {
        switch self {
        case .info:
            return AlertStyles(
                icon: Image(systemName: "info.circle"),
                color: Color(red: 0.70, green: 0.49, blue: 0.00),
                background: Color(red: 1.00, green: 0.97, blue: 0.90)
            )
        }
    }
}

struct AlertBase: View {
    /// The text to use as the title of the alert
    var title: String;
    
    /// The type of the alert
    var type: AlertType = .info;
    
    /// Whether or not the alert is displaying inline
    var isInline: Bool = false;

    var body: some View {
        HStack(spacing: self.isInline ? CGFloat(8) : CGFloat(12)) {
            self.type.style.icon
                .resizable()
                .renderingMode(.template)
                .aspectRatio(contentMode: .fit)
                .foregroundColor(self.type.style.color)
                .frame(width: 20, height: 20)
            Text(self.title)
                .foregroundColor(Color(red: 0.09, green: 0.15, blue: 0.32))
                .font(.system(size: 14, weight: .semibold))
                .lineLimit(2)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

struct Alert: View {
    /// The type of the alert
    var type: AlertType = .info;
    
    /// The text to use as the title of the alert
    var title: String;
    
    /// An optional detail descriptor
    var description: String? = nil;
    
    /// An optional binding for whether or not the alert is visible
    /// **Note**: When binding is not present, the alert will
    /// always show
    var isVisible: Binding<Bool> = .constant(true)
    
    /// Whether or not the alert can be closed via a displayed
    /// close button
    var isClosable: Bool = false;
    
    var body: some View {
        if self.isVisible.wrappedValue {
            VStack(alignment: .leading, spacing: 4) {
                HStack {
                    AlertBase(title: self.title, type: self.type)
                    if isClosable {
                        Button(
                            action: {
                                // HERE: The behaviur of withAnimation is desired
//                                withAnimation(.easeInOut, {
                                    self.isVisible.wrappedValue.toggle()
//                                })
                            },
                            label: {
                                Image(systemName: "xmark")
                                    .renderingMode(.template)
                                    .foregroundColor(Color(red: 0.42, green: 0.45, blue: 0.56))
                                    .frame(
                                        width: 20,
                                        height: 20
                                    )
                            }
                        )
                    }
                }
                if let alertDescription = self.description {
                    Text(alertDescription)
                        .padding(.leading, 32)
                        .font(.system(size: 14))
                        .foregroundColor(Color(red: 0.27, green: 0.32, blue: 0.46))
                }
            }
            .padding(.vertical, 12)
            .padding(.horizontal, 16)
            .background(self.type.style.background)
            .cornerRadius(6)
        }
    }
}

struct Alert_InteractivePreview: View {
    
    @State private var isVisible: Bool = true;
    @State private var isVisible1: Bool = true;
    
    var body: some View {
        VStack(alignment: .center, spacing: 16) {
            Alert(
                type: .info,
                title: "Alert type warn",
                description: "This is the first description",
                isVisible: self.$isVisible,
                isClosable: true
            );
            Alert(
                type: .info,
                title: "Alert type warn",
                description: "This is the second description",
                isVisible: self.$isVisible1,
                isClosable: true
            );
            
            Button(action: {
                self.isVisible.toggle()
            }, label: {
                Text("Toggle Alert 1 Visibility")
            })
            Button(action: {
                self.isVisible1.toggle()
            }, label: {
                Text("Toggle Alert 2 Visibility")
            })
        }
        .padding()
    }
}

struct Alert_Previews: PreviewProvider {
    static var previews: some View {
        Alert_InteractivePreview()
    }
}

Upvotes: 2

Views: 713

Answers (1)

Asperi
Asperi

Reputation: 257729

Here is fixed code that gives the same effect as using withAnimation in button.

Tested with Xcode 12.5 / iOS 14.5

var body: some View {
    VStack {
        if self.isVisible.wrappedValue {
            VStack(alignment: .leading, spacing: 4) {

                // .. alert content here
            }
            .padding(.vertical, 12)
            .padding(.horizontal, 16)
            .background(self.type.style.background)
            .cornerRadius(6)
        }
    }
    .animation(.easeInOut)
}

Upvotes: 2

Related Questions