admin管理员组

文章数量:1122832

I have an app that allows the user to personalize the interface with a choice of theme colors. Selecting a color changes the app theme, allowing various elements to be colored accordingly (backgrounds, buttons, icons, etc.)

Until recently, I've resorted to a custom init in the main view to style buttons in a confirmation dialog that acts as primary action menu for the app.

Lately, I've noticed that the buttons in the confirmation dialog no longer respect the user selection of theme color (green in this example), even though no code changes have been made in this regard. Instead of showing the selected theme color, they show the default accent color. I suspect it's since the iOS 18 update or 18.1:

I tried using .tint and .foregroundStyle inside the dialog, outside the dialog, everything I could think of, but no luck.

Eventually, I came across .accentColor which does work, but only if applied in a higher wrapper view. However, it looks like it's deprecated in favor for .tint, which doesn't work.

So the question: is there any better and more future-proof way to style those confirmation dialog buttons, other than the deprecated .accentColor?

Here's the code reproducing the issue as seen in the screenshots. To see the dialog buttons in green, uncomment the .accentColor line:

import SwiftUI

struct ConfirmationDialogButtons: View {
    
    //State values
    @State private var showTintDialog = false
    @State private var showAccentDialog = false
    
    //Initializer
    init(){
        UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = UIColor(.green)
    }
    
    //Bodyjee
    var body: some View {
        VStack {
            Button("Open tint dialog") {
                showTintDialog.toggle()
            }
            .tint(.blue)
            
            Button("Open regular dialog") {
                showAccentDialog.toggle()
            }
            
        }
        .buttonStyle(.borderedProminent)
        .confirmationDialog("This dialog has buttons with green tint modifier", isPresented: $showTintDialog, titleVisibility: .visible) {
            Group {
                Button("Action 1") {}
                Button("Action 2") {}
                Button("Action 3") {}
            }
            .tint(.green) // <- this modifier has no effect on buttons in confirmation dialog
            .foregroundStyle(.green) // <- this modifier has no effect on buttons in confirmation dialog
        }
        .confirmationDialog("Select an action", isPresented: $showAccentDialog, titleVisibility: .visible) {
            VStack {
                Button("Action A") {}
                Button("Action B") {}
                Button("Action C") {}
            }
        }
        .tint(.green) // <- this modifier has no effect on buttons in confirmation dialog
        
        // .accentColor(.green) // <- THIS WORKS, BUT IT'S DEPRECATED - UNCOMMENT TO COLOR BUTTONS IN CONFIRMATION DIALOG
    }
}

#Preview {
    ConfirmationDialogButtons()
}

I have an app that allows the user to personalize the interface with a choice of theme colors. Selecting a color changes the app theme, allowing various elements to be colored accordingly (backgrounds, buttons, icons, etc.)

Until recently, I've resorted to a custom init in the main view to style buttons in a confirmation dialog that acts as primary action menu for the app.

Lately, I've noticed that the buttons in the confirmation dialog no longer respect the user selection of theme color (green in this example), even though no code changes have been made in this regard. Instead of showing the selected theme color, they show the default accent color. I suspect it's since the iOS 18 update or 18.1:

I tried using .tint and .foregroundStyle inside the dialog, outside the dialog, everything I could think of, but no luck.

Eventually, I came across .accentColor which does work, but only if applied in a higher wrapper view. However, it looks like it's deprecated in favor for .tint, which doesn't work.

So the question: is there any better and more future-proof way to style those confirmation dialog buttons, other than the deprecated .accentColor?

Here's the code reproducing the issue as seen in the screenshots. To see the dialog buttons in green, uncomment the .accentColor line:

import SwiftUI

struct ConfirmationDialogButtons: View {
    
    //State values
    @State private var showTintDialog = false
    @State private var showAccentDialog = false
    
    //Initializer
    init(){
        UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = UIColor(.green)
    }
    
    //Bodyjee
    var body: some View {
        VStack {
            Button("Open tint dialog") {
                showTintDialog.toggle()
            }
            .tint(.blue)
            
            Button("Open regular dialog") {
                showAccentDialog.toggle()
            }
            
        }
        .buttonStyle(.borderedProminent)
        .confirmationDialog("This dialog has buttons with green tint modifier", isPresented: $showTintDialog, titleVisibility: .visible) {
            Group {
                Button("Action 1") {}
                Button("Action 2") {}
                Button("Action 3") {}
            }
            .tint(.green) // <- this modifier has no effect on buttons in confirmation dialog
            .foregroundStyle(.green) // <- this modifier has no effect on buttons in confirmation dialog
        }
        .confirmationDialog("Select an action", isPresented: $showAccentDialog, titleVisibility: .visible) {
            VStack {
                Button("Action A") {}
                Button("Action B") {}
                Button("Action C") {}
            }
        }
        .tint(.green) // <- this modifier has no effect on buttons in confirmation dialog
        
        // .accentColor(.green) // <- THIS WORKS, BUT IT'S DEPRECATED - UNCOMMENT TO COLOR BUTTONS IN CONFIRMATION DIALOG
    }
}

#Preview {
    ConfirmationDialogButtons()
}

Share Improve this question asked Nov 23, 2024 at 2:10 Andrei G.Andrei G. 1,0271 gold badge7 silver badges11 bronze badges 3
  • If future-proof is your priority, you should make your own confirmation dialog from scratch or use UIKit. – Sweeper Commented Nov 23, 2024 at 7:54
  • @Sweeper By future-proof, I merely meant something that won't simply break as soon as iOS 18.2 comes out. – Andrei G. Commented Nov 24, 2024 at 18:58
  • Okay, after some reverse-engineering, it seems like SwiftUI deliberately sets the tint colors of the alert actions to the default tint color. So I think the only options are UIKit, or swizzle the method where SwiftUI sets the tint colors. – Sweeper Commented Nov 24, 2024 at 20:58
Add a comment  | 

2 Answers 2

Reset to default 2

The default tint color is the "AccentColor" asset in your asset dialog. So if you just want to have one fixed accent color for all confirmation dialogs, you can just change that.


The confirmation dialog is implemented by a UIAlertController subclass called PlatformAlertController. In iOS 18.1, this implementation causes the tint colors of the button labels to be set to the "AccentColor" asset. Because the tint color is deliberately set, the UIView.appearance trick doesn't work.

This is similar to the behaviour of the accent color of list selection on macOS, which makes me suspect that perhaps in a future version of iOS, users might be able to select the global system tint color, like you can in macOS...

I would suggest just wrapping a view controller that presents a UIAlertController. Here is an example implementation:

struct UIKitActionSheet: UIViewControllerRepresentable {
    @Binding var isPresented: Bool
    let title: String?
    let message: String?
    
    struct Action {
        let title: String?
        let style: UIAlertAction.Style
        let handler: (() -> Void)?
    }
    
    let actions: [Action]
    
    func makeUIViewController(context: Context) -> UIViewController {
        context.coordinator.vc
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        context.coordinator.actions = actions
        context.coordinator.title = title
        context.coordinator.message = message
        context.coordinator.onClose = {
            isPresented = false
        }
        if isPresented {
            context.coordinator.present()
        }
    }
    
    func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIViewController, context: Context) -> CGSize? {
        .zero
    }
    
    @MainActor
    class Coordinator {
        let vc = UIViewController()
        var actions: [Action] = []
        var title: String?
        var message: String?
        var onClose: (() -> Void)?
        
        func present() {
            guard vc.presentedViewController == nil else { return }
            let actionSheet = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
            for action in actions {
                actionSheet.addAction(UIAlertAction(title: action.title, style: action.style) { _ in
                    action.handler?()
                    self.onClose?()
                })
            }
            
            vc.present(actionSheet, animated: true, completion: nil)
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
}

// usage example:

@State private var dialog = false

var body: some View {
    Button("Present UIKit Action Sheet") {
        dialog.toggle()
    }
    .background {
        UIKitActionSheet(isPresented: $dialog, title: "Dialog", message: "Some Message", actions: [
            .init(title: "Action 1", style: .default, handler: nil),
            .init(title: "Action 2", style: .default, handler: nil),
            .init(title: "Action 3", style: .default, handler: nil),
            .init(title: "Cancel", style: .cancel, handler: nil),
        ])
        .tint(.red)
    }
}

Notably, the UIAlertController respects the SwiftUI tint modifier.


A second approach is to swizzle UIAlertController.viewWillAppear.

extension UIViewController {
    @objc func viewWillAppearSwizzled(animated: Bool) {
        viewWillAppearSwizzled(animated: animated)
        ConfirmationDialogSwizzler.tintLabels(root: view)
    }
}

@objc
@MainActor
class ConfirmationDialogSwizzler: NSObject {
    static var tint: UIColor = .accent
    static var destructiveLabels: Set<String> = []
    
    static func tintLabels(root: UIView) {
        if let label = root as? UILabel, !destructiveLabels.contains(label.text ?? "") {
            root.tintColor = tint
        }
        for subview in root.subviews {
            tintLabels(root: subview)
        }
    }
    
    // call this exactly once, at the start of the app
    static func swizzleViewWillAppear() {
        let originalSelector = #selector(UIAlertController.viewWillAppear)
        let swizzledSelector = #selector(UIAlertController.viewWillAppearSwizzled)
        let originalMethod = class_getInstanceMethod(UIAlertController.self, originalSelector)!
        let swizzledMethod = class_getInstanceMethod(UIAlertController.self, swizzledSelector)!
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }
}

// usage example
@State private var dialog = false

var body: some View {
    Button("Present SwiftUI swizzled confirmation dialog") {
        ConfirmationDialogSwizzler.tint = .systemGreen
        ConfirmationDialogSwizzler.destructiveLabels = ["Action 3"]
        dialog.toggle()
    }
    .confirmationDialog("Dialog", isPresented: $dialog, titleVisibility: .visible) {
        Button("Action 1") {}
        Button("Action 2") {}
        Button("Action 3", role: .destructive) {}
    }
    .onAppear {
        ConfirmationDialogSwizzler.swizzleViewWillAppear()
    }
}

To use this, you need to set ConfirmationDialogSwizzler.tint to the tint color you want before presenting the confirmation dialog. .destructive buttons will be tinted to that color as well, so I included a destructiveLabels property to allow you to specify which labels to not tint.

As per @Sweeper's initial suggestion in the comments, I ended up creating a custom confirmation dialog from scratch, that seeks to emulate the default .confirmationDialog, with some extra customization options.

With this custom confirmation, I have control over the color of the button labels, plus additional control over:

  • The button spacing
  • The button corner radius
  • The button background color

The custom dialog still:

  • Allows for setting a dialog title
  • Respects the system color scheme (light/dark)

There may be some other features missing when compared to the default, but for my purposes, this is suitable at this stage.

Here's the full code, followed by some example usage and screenshots:

import SwiftUI

struct ConfirmationDialogButtons: View {
    
    //State values
    @State private var showButtonDialog = true
    
    //Body
    var body: some View {
        ZStack {
            VStack {
                Button("Open button dialog") {
                    showButtonDialog.toggle()
                }
            }
            .buttonStyle(.borderedProminent)
            .buttonDialog(
                title: "Some menu title text",
                isPresented: $showButtonDialog,
                labelColor: .green
            ) {
                Button("Action 1") {}
                Button("Action 2") {}
                Button("Action 3") {}
            }
        }
        .tint(.green)
    }
}

extension View {
    func buttonDialog(
        
        title: String = "",
        isPresented: Binding<Bool>,
        
        labelColor: Color? = nil,
        buttonSpacing: CGFloat? = nil,
        buttonBackground: Color? = nil,
        buttonCornerRadius: CGFloat? = nil,
        
        @ViewBuilder buttons: @escaping () -> some View
        
    ) -> some View {
        self
            .modifier(
                ButtonDialogModifier(
                    title: title,
                    isPresented: isPresented,
                    
                    labelColor: labelColor,
                    buttonSpacing: buttonSpacing,
                    buttonBackground: buttonBackground,
                    buttonCornerRadius: buttonCornerRadius,
                    
                    buttons: buttons
                )
            )
    }
}

struct ButtonDialogModifier<Buttons: View>: ViewModifier {
    
    //Parameters
    var title: String
    @Binding var isPresented: Bool
    
    var labelColor: Color
    var buttonSpacing: CGFloat
    var buttonBackground: Color
    var buttonCornerRadius: CGFloat
    var dialogCornerRadius: CGFloat
    
    @ViewBuilder let buttons: () -> Buttons
    
    //Default values
    private let defaultButtonBackground: Color = Color(UIColor.secondarySystemBackground)
    private let defaultCornerRadius: CGFloat = 12
    private var cancelButtonLabelColor: Color
    
    //Initializer
    init(
        title: String? = nil,
        isPresented: Binding<Bool>,
        
        labelColor: Color? = nil,
        buttonSpacing: CGFloat? = nil,
        buttonBackground: Color? = nil,
        buttonCornerRadius: CGFloat? = nil,
        dialogCornerRadius: CGFloat? = nil,
        
        buttons: @escaping () -> Buttons
    ) {
        //Initialize with default values
        self.title = title ?? ""
        self._isPresented = isPresented
        
        self.labelColor = labelColor ?? .accentColor
        self.buttonSpacing = buttonSpacing ?? 0
        self.buttonBackground = buttonBackground ?? defaultButtonBackground
        self.buttonCornerRadius = (buttonCornerRadius != nil ? buttonCornerRadius : self.buttonSpacing == 0 ? 0 : buttonCornerRadius) ?? defaultCornerRadius
        self.dialogCornerRadius = dialogCornerRadius ?? buttonCornerRadius ?? defaultCornerRadius
        
        self.buttons = buttons
        
        self.cancelButtonLabelColor = self.buttonBackground == defaultButtonBackground ? self.labelColor : self.buttonBackground
    }
    
    //Body
    func body(content: Content) -> some View {
        
        content
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .overlay {
                ZStack(alignment: .bottom) {
                    
                    if isPresented {
                        Color.black
                            .opacity(0.2)
                            .ignoresSafeArea()
                            .transition(.opacity)
                    }
                    
                    if isPresented {
                        
                        //Menu wrapper
                        VStack(spacing: 10) {
                            VStack(spacing: buttonSpacing) {
                                Text(title)
                                    .foregroundStyle(.secondary)
                                    .font(.subheadline)
                                    .frame(maxWidth: .infinity, alignment: .center)
                                    .padding()
                                    .background(Color(UIColor.secondarySystemBackground), in: RoundedRectangle(cornerRadius: buttonCornerRadius))
                                
                                // Apply style for each button passed in content
                                buttons()
                                    .buttonStyle(FullWidthButtonStyle(labelColor: labelColor, buttonBackground: buttonBackground, buttonCornerRadius: buttonCornerRadius))
                            }
                            .font(.title3)
                            .clipShape(RoundedRectangle(cornerRadius: dialogCornerRadius))
                            
                            //Cancel button
                            Button {
                                isPresented.toggle()
                            } label: {
                                Text("Cancel")
                                    .fontWeight(.semibold)
                            }
                            .buttonStyle(FullWidthButtonStyle(labelColor: cancelButtonLabelColor, buttonBackground: Color(UIColor.tertiarySystemBackground), buttonCornerRadius: dialogCornerRadius))
                        }
                        .font(.title3)
                        .padding(10)
                        .transition(.move(edge: .bottom))
                    }
                }
                .animation(.easeInOut, value: isPresented)
            }
    }
    
    //Custom full-width button style
    private struct FullWidthButtonStyle: ButtonStyle {
        
        //Parameters
        var labelColor: Color
        var buttonBackground: Color = Color(UIColor.secondarySystemBackground)
        var buttonCornerRadius: CGFloat
        
        //Body
        func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .frame(maxWidth: .infinity) // Make the button full width
                .padding()
                .background(buttonBackground, in: RoundedRectangle(cornerRadius: buttonCornerRadius))
                .opacity(configuration.isPressed ? 0.8 : 1.0) // Add press feedback
                .foregroundStyle(labelColor)
                .overlay(Divider(), alignment: .top)
        }
    }
}


#Preview {
    ConfirmationDialogButtons()
}

Customization examples

Simple color label customization:

.buttonDialog(
    title: "Some menu title text",
    isPresented: $showButtonDialog,
    labelColor: .cyan // <- Simple button label color customization
) {
    Button("Action 1") {}
    Button("Action 2") {}
    Button("Action 3") {}
}

Dark mode support (follow system setting):

Button spacing:

.buttonDialog(
    title: "Some menu title text",
    isPresented: $showButtonDialog,
    labelColor: .cyan, // <- Button label color customization
    buttonSpacing: 10 // <- Button spacing
) {
    Button("Action 1") {}
    Button("Action 2") {}
    Button("Action 3") {}
}

Button corner radius:

.buttonDialog(
    title: "Some menu title text",
    isPresented: $showButtonDialog,
    labelColor: .cyan, // <- Button label color customization
    buttonSpacing: 10, // <- Button spacing
    buttonCornerRadius: 30 // <- Button corner radius
) {
    Button("Action 1") {}
    Button("Action 2") {}
    Button("Action 3") {}
}

Custom button background:

.buttonDialog(
    title: "Some menu title text",
    isPresented: $showButtonDialog,
    labelColor: .white, // <- Button label color customization
    buttonSpacing: 10, // <- Button spacing
    buttonBackground: .green, // <- Button background
    buttonCornerRadius: 30 // <- Button corner radius
) {
    Button("Action 1") {}
    Button("Action 2") {}
    Button("Action 3") {}
}

本文标签: swiftuiHow to style buttons in confirmation dialog in iOS 18Stack Overflow