22

In AppKit I would do this by assigning its key equivalent to be or making its cell the window's default. However, neither of these seems possible in SwiftUI, so how do I make a button the default window button?

A Cancel button in the normal grey look, and a Save button in the system accent color. The text "Like this" is inserted with an arrow pointing to the Save button.

4 Answers 4

32

macOS 11.0 / iOS 14:

As of Xcode 12 beta, new methods are exposed on Button() allowing assignment of keyEquivalent (either by enum case or explicit key and modifiers).

Setting as default:

Button( ... )
    .keyboardShortcut(.defaultAction)

Setting as cancel:

Button( ... )
    .keyboardShortcut(.cancelAction)
3
  • 1
    But this works only on menus, and doesnt works inside of window or sheet, so, this is not solution even for SwiftUI 2. Commented Aug 15, 2020 at 8:06
  • 1
    Works now in Xcode Version 12.2 beta 3 (12B5035g) and Big Sur bêta 11.0 (20A5395g)
    – u0cram
    Commented Oct 25, 2020 at 14:56
  • 3
    Does not work if you change your button style.
    – Marc T.
    Commented Jan 10, 2021 at 8:08
14

It's currently not possible. I have reported it to Apple.

However, for now, you can wrap NSButton.

Usage:

@available(macOS 10.15, *)
struct ContentView: View {
    var body: some View {
        NativeButton("Submit", keyEquivalent: .return) {
            // Some action
        }
            .padding()
    }
}

Implementation:

// MARK: - Action closure for controls

private var controlActionClosureProtocolAssociatedObjectKey: UInt8 = 0

protocol ControlActionClosureProtocol: NSObjectProtocol {
    var target: AnyObject? { get set }
    var action: Selector? { get set }
}

private final class ActionTrampoline<T>: NSObject {
    let action: (T) -> Void

    init(action: @escaping (T) -> Void) {
        self.action = action
    }

    @objc
    func action(sender: AnyObject) {
        action(sender as! T)
    }
}

extension ControlActionClosureProtocol {
    func onAction(_ action: @escaping (Self) -> Void) {
        let trampoline = ActionTrampoline(action: action)
        self.target = trampoline
        self.action = #selector(ActionTrampoline<Self>.action(sender:))
        objc_setAssociatedObject(self, &controlActionClosureProtocolAssociatedObjectKey, trampoline, .OBJC_ASSOCIATION_RETAIN)
    }
}

extension NSControl: ControlActionClosureProtocol {}

// MARK: -



@available(macOS 10.15, *)
struct NativeButton: NSViewRepresentable {
    enum KeyEquivalent: String {
        case escape = "\u{1b}"
        case `return` = "\r"
    }

    var title: String?
    var attributedTitle: NSAttributedString?
    var keyEquivalent: KeyEquivalent?
    let action: () -> Void

    init(
        _ title: String,
        keyEquivalent: KeyEquivalent? = nil,
        action: @escaping () -> Void
    ) {
        self.title = title
        self.keyEquivalent = keyEquivalent
        self.action = action
    }

    init(
        _ attributedTitle: NSAttributedString,
        keyEquivalent: KeyEquivalent? = nil,
        action: @escaping () -> Void
    ) {
        self.attributedTitle = attributedTitle
        self.keyEquivalent = keyEquivalent
        self.action = action
    }

    func makeNSView(context: NSViewRepresentableContext<Self>) -> NSButton {
        let button = NSButton(title: "", target: nil, action: nil)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.setContentHuggingPriority(.defaultHigh, for: .vertical)
        button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return button
    }

    func updateNSView(_ nsView: NSButton, context: NSViewRepresentableContext<Self>) {
        if attributedTitle == nil {
            nsView.title = title ?? ""
        }

        if title == nil {
            nsView.attributedTitle = attributedTitle ?? NSAttributedString(string: "")
        }

        nsView.keyEquivalent = keyEquivalent?.rawValue ?? ""

        nsView.onAction { _ in
            self.action()
        }
    }
}
6
  • Still not possible in pure SwiftUI. Hopefully, we'll get this at WWDC this June. Commented Feb 21, 2020 at 8:41
  • I really hope so, many features are missing like SearchBar, these Buttons and more. Does your solution colors the button aswell? Will try it out later. Thanks for the answer
    – davidev
    Commented Feb 21, 2020 at 8:52
  • If you KeyEquivalent.return, the button will be highlighted with the user's accent color (usually blue). Commented Feb 21, 2020 at 11:44
  • This is the best solution so far for Mac! Great job
    – davidev
    Commented Aug 19, 2020 at 15:39
  • It's been possible in native SwiftUI for a while now, since Xcode 12 beta. See my answer for details.
    – stef
    Commented Oct 26, 2020 at 19:51
2

iOS 15.0+ / macOS 12.0+

If you just want the style, use .borderedProminent to indicate the primary button:

Button( ... ) {
}.buttonStyle(.borderedProminent)

If you want it to respond to the Return key on iOS or macOS, use:

Button( ... ) {
}.keyboardShortcut(.defaultAction)

It will apply a .borderedProminent style on macOS, but not on iOS. If you want that style on iOS and macOS, use both settings:

Button( ... ) {
}.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
3
  • While that does color the button like I wanted, it doesn't make it the default button (e.g. the one which is automatically pressed if the user presses ↩ return)
    – Ky -
    Commented Apr 27, 2023 at 22:51
  • In that case, .keyboardShortcut(.defaultAction) will work. I just tested it working in a sheet for an "iOS macOS" app.
    – radley
    Commented Apr 28, 2023 at 6:03
  • Indeed that works well! Which is why that is the accepted answer, which was written by stef in 2020
    – Ky -
    Commented May 2, 2023 at 21:18
0

Here is a shorter, but less generic solution to create a primary button with return key equivalent, and default button blue tinting.

struct PrimaryButtonView: NSViewRepresentable {
    typealias NSViewType = PrimaryButton

    let title: String
    let action: () -> Void

    init(_ title: String, action: @escaping () -> Void) {
        self.title = title
        self.action = action
    }

    func makeNSView(context: Context) -> PrimaryButton {
        PrimaryButton(title, action: action)
    }

    func updateNSView(_ nsView: PrimaryButton, context: Context) {
        return
    }
}


class PrimaryButton: NSButton {
    let buttonAction: () -> Void

    init(_ title: String, action: @escaping () -> Void) {
        self.buttonAction = action
        super.init(frame: .zero)
        self.title = title
        self.action = #selector(clickButton(_:))
        bezelStyle = .rounded  //Only this style results in blue tint for button
        isBordered = true
        focusRingType = .none
        keyEquivalent = "\r"
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    @objc func clickButton(_ sender: PrimaryButton) {
        buttonAction()
    }
}
3
  • Very good solution. The Button takes all the available space. How can I change it, that takes only the space of the title?
    – mica
    Commented Jan 7, 2021 at 15:00
  • 1
    I do know whether there is a way to make the button size to the text. I explicitly set the frame of the PrimaryButtonView within the parent view.
    – jbaraga
    Commented Jan 10, 2021 at 17:48
  • although this is a good example of wrapping a NSView for this purpose, it has a bug with action not being called. it is missing setting target to self, hence clickButton function is never called. Commented Apr 20, 2023 at 12:38

Not the answer you're looking for? Browse other questions tagged or ask your own question.