gmoraleda
gmoraleda

Reputation: 1943

SwiftUI: Creating custom alert

I'm trying to abstract an Alert which is being used in multiple places across my app.

I copied and pasted the implementation of func alert(isPresented: Binding<Bool>, content: () -> Alert) -> some View and tweaked it to adapt it to my usage:

extension View {
    func externalURLAlert(isPresented: Binding<Bool>, action: ()) -> some View {
        isPresented.wrappedValue ? AnyView(Alert(
            title: Text("alert.externalURL.title".localized),
            message: Text("alert.externalURL.message".localized),
            primaryButton: .cancel(),
            secondaryButton: .default(Text("alert.externalURL.openAction.title".localized)) {
                action
            }
        )) : AnyView(EmptyView())
    }
}

My plan is to call it on a View like .externalURLAlert(isPresented: $isPresented, action: someAction) but I'm failing to get the function to compile.

The error I'm getting is the following one:

Initializer 'init(_:)' requires that 'Alert' conform to 'View'

Upvotes: 5

Views: 15282

Answers (3)

Den
Den

Reputation: 3561

Demo screenshot

You can customize to your own design.



Demo.swift

import SwiftUI

struct DemoView: View {

    // MARK: - Value
    // MARK: Private
    @State private var isAlertPresented = false


    // MARK: - View
    // MARK: Public
    var body: some View {
        ZStack {
            Button {
                isAlertPresented = true

            } label: {
                Text("Alert test")
            }
        }
        .alert(title: "title", message: "message",
           primaryButton: CustomAlertButton(title: "Yes", action: { }),
           secondaryButton: CustomAlertButton(title: "No", action: {  }),
           isPresented: $isAlertPresented)
    }
}

#if DEBUG
struct DemoView_Previews: PreviewProvider {

    static var previews: some View {
        DemoView()
            .previewDevice("iPhone 11 Pro")
    }
}
#endif


CustomAlert.swift

import SwiftUI

struct CustomAlert: View {

    // MARK: - Value
    // MARK: Public
    let title: String
    let message: String
    let dismissButton: CustomAlertButton?
    let primaryButton: CustomAlertButton?
    let secondaryButton: CustomAlertButton?
    
    // MARK: Private
    @State private var opacity: CGFloat           = 0
    @State private var backgroundOpacity: CGFloat = 0
    @State private var scale: CGFloat             = 0.001

    @Environment(\.dismiss) private var dismiss


    // MARK: - View
    // MARK: Public
    var body: some View {
        ZStack {
            dimView
    
            alertView
                .scaleEffect(scale)
                .opacity(opacity)
        }
        .ignoresSafeArea()
        .transition(.opacity)
        .task {
            animate(isShown: true)
        }
    }

    // MARK: Private
    private var alertView: some View {
        VStack(spacing: 20) {
            titleView
            messageView
            buttonsView
        }
        .padding(24)
        .frame(width: 320)
        .background(.white)
        .cornerRadius(12)
        .shadow(color: Color.black.opacity(0.4), radius: 16, x: 0, y: 12)
    }

    @ViewBuilder
    private var titleView: some View {
        if !title.isEmpty {
            Text(title)
                .font(.system(size: 18, weight: .bold))
                .foregroundColor(.black)
                .lineSpacing(24 - UIFont.systemFont(ofSize: 18, weight: .bold).lineHeight)
                .multilineTextAlignment(.leading)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }

    @ViewBuilder
    private var messageView: some View {
        if !message.isEmpty {
            Text(message)
                .font(.system(size: title.isEmpty ? 18 : 16))
                .foregroundColor(title.isEmpty ? .black : .gray)
                .lineSpacing(24 - UIFont.systemFont(ofSize: title.isEmpty ? 18 : 16).lineHeight)
                .multilineTextAlignment(.leading)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }

    private var buttonsView: some View {
        HStack(spacing: 12) {
            if dismissButton != nil {
                dismissButtonView
    
            } else if primaryButton != nil, secondaryButton != nil {
                secondaryButtonView
                primaryButtonView
            }
        }
        .padding(.top, 23)
    }

    @ViewBuilder
    private var primaryButtonView: some View {
        if let button = primaryButton {
            CustomAlertButton(title: button.title) {
                animate(isShown: false) {
                    dismiss()
                }
            
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
                    button.action?()
                }
            }
        }
    }

    @ViewBuilder
    private var secondaryButtonView: some View {
        if let button = secondaryButton {
            CustomAlertButton(title: button.title) {
                animate(isShown: false) {
                    dismiss()
                }
        
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
                    button.action?()
                }
            }
        }
    }

    @ViewBuilder
    private var dismissButtonView: some View {
        if let button = dismissButton {
            CustomAlertButton(title: button.title) {
                animate(isShown: false) {
                    dismiss()
                }
        
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
                    button.action?()
                }
            }
        }
    }

    private var dimView: some View {
        Color.gray
            .opacity(0.88)
            .opacity(backgroundOpacity)
    }


    // MARK: - Function
    // MARK: Private
    private func animate(isShown: Bool, completion: (() -> Void)? = nil) {
        switch isShown {
        case true:
            opacity = 1
    
            withAnimation(.spring(response: 0.3, dampingFraction: 0.9, blendDuration: 0).delay(0.5)) {
                backgroundOpacity = 1
                scale             = 1
            }
    
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                completion?()
            }
    
        case false:
            withAnimation(.easeOut(duration: 0.2)) {
                backgroundOpacity = 0
                opacity           = 0
            }
    
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                completion?()
            }
        }
    }
}

#if DEBUG
struct CustomAlert_Previews: PreviewProvider {

    static var previews: some View {
        let dismissButton   = CustomAlertButton(title: "OK")
        let primaryButton   = CustomAlertButton(title: "OK")
        let secondaryButton = CustomAlertButton(title: "Cancel")

        let title = "This is your life. Do what you want and do it often."
        let message = """
                    If you don't like something, change it.
                    If you don't like your job, quit.
                    If you don't have enough time, stop watching TV.
                    """

        return VStack {
            CustomAlert(title: title, message: message, dismissButton: nil,           primaryButton: nil,           secondaryButton: nil)
            CustomAlert(title: title, message: message, dismissButton: dismissButton, primaryButton: nil,           secondaryButton: nil)
            CustomAlert(title: title, message: message, dismissButton: nil,           primaryButton: primaryButton, secondaryButton: secondaryButton)
        }
        .previewDevice("iPhone 13 Pro Max")
        .preferredColorScheme(.light)
    }
}
#endif


CustomAlertButton.swift

import SwiftUI

struct CustomAlertButton: View {

    // MARK: - Value
    // MARK: Public
    let title: LocalizedStringKey
    var action: (() -> Void)? = nil
    
    
    // MARK: - View
    // MARK: Public
    var body: some View {
        Button {
          action?()
        
        } label: {
            Text(title)
                .font(.system(size: 14, weight: .medium))
                .foregroundColor(.white)
                .padding(.horizontal, 10)
        }
        .frame(height: 30)
        .background(Color.purple)
        .cornerRadius(15)
    }
}


CustomAlertModifier.swift

import SwiftUI

struct CustomAlertModifier {

    // MARK: - Value
    // MARK: Private
    @Binding private var isPresented: Bool

    // MARK: Private
    private let title: String
    private let message: String
    private let dismissButton: CustomAlertButton?
    private let primaryButton: CustomAlertButton?
    private let secondaryButton: CustomAlertButton?
}


extension CustomAlertModifier: ViewModifier {

    func body(content: Content) -> some View {
        content
            .fullScreenCover(isPresented: $isPresented) {
                CustomAlert(title: title, message: message, dismissButton: dismissButton, primaryButton: primaryButton, secondaryButton: secondaryButton)
            }
    }
}

extension CustomAlertModifier {

    init(title: String = "", message: String = "", dismissButton: CustomAlertButton, isPresented: Binding<Bool>) {
        self.title         = title
        self.message       = message
        self.dismissButton = dismissButton
    
        self.primaryButton   = nil
        self.secondaryButton = nil
    
        _isPresented = isPresented
    }

    init(title: String = "", message: String = "", primaryButton: CustomAlertButton, secondaryButton: CustomAlertButton, isPresented: Binding<Bool>) {
        self.title           = title
        self.message         = message
        self.primaryButton   = primaryButton
        self.secondaryButton = secondaryButton
    
        self.dismissButton = nil
    
        _isPresented = isPresented
    }
}


ViewExtension.swift

import SwiftUI

extension View {

    func alert(title: String = "", message: String = "", dismissButton: CustomAlertButton = CustomAlertButton(title: "ok"), isPresented: Binding<Bool>) -> some View {
        let title   = NSLocalizedString(title, comment: "")
        let message = NSLocalizedString(message, comment: "")
    
        return modifier(CustomAlertModifier(title: title, message: message, dismissButton: dismissButton, isPresented: isPresented))
    }

    func alert(title: String = "", message: String = "", primaryButton: CustomAlertButton, secondaryButton: CustomAlertButton, isPresented: Binding<Bool>) -> some View {
        let title   = NSLocalizedString(title, comment: "")
        let message = NSLocalizedString(message, comment: "")
    
        return modifier(CustomAlertModifier(title: title, message: message, primaryButton: primaryButton, secondaryButton: secondaryButton, isPresented: isPresented))
    }
}

Upvotes: 18

Andrew_STOP_RU_WAR_IN_UA
Andrew_STOP_RU_WAR_IN_UA

Reputation: 11416

try the following:

extension View {

    @ViewBuilder
    func externalURLAlert(isPresented: Binding<Bool>, action: ()) -> some View {
       if isPresented.wrappedValue {
           Alert(
              title: Text("alert.externalURL.title".localized),
              message: Text("alert.externalURL.message".localized),
              primaryButton: .cancel(),
              secondaryButton: .default(Text("alert.externalURL.openAction.title".localized)) { action }
           )
      }

}

Plus as written in the error you need to make Alert conform to View.

Also try to use .sheet() -- this looks like you search for.

Usage sample: https://www.hackingwithswift.com/quick-start/swiftui/how-to-present-a-new-view-using-sheets

or any that you find by google -> "swiftUI usage .sheet"

Upvotes: 1

EmilioPelaez
EmilioPelaez

Reputation: 19874

The way modifiers work is by returning a modified version of the view they are called on. If you call Text("").foregroundColor(...), you receive a new Text view with a new foreground color. It's the same with an alert, if you call Text("").alert(..., you receive a Text view that can display an alert on top.

Your modifier, on the other hand, completely erases that hierarchy and replaces it with either an empty view, or an alert, but this alert has no information on where it should be presented on.

If what you want is to display a standardized alert, you should leverage the existing modifier with your own parameter, like this:

extension View {
    func externalURLAlert(isPresented: Binding<Bool>, action: ()) -> some View {
        self.alert(isPresented: isPresented) {
            Alert(
                title: Text("alert.externalURL.title".localized),
                message: Text("alert.externalURL.message".localized),
                primaryButton: .cancel(),
                secondaryButton: .default(Text("alert.externalURL.openAction.title".localized)) {
                    action()
                }
            )
        }
    }
}

Notice the use of self, because we want to maintain the hierarchy, and .alert(...) because we're using the existing system modifier that already knows how to display an alert.

Upvotes: 2

Related Questions