0

The NSSwitch looks right now pretty pale:

enter image description here

I'm trying to change the tint colour like this on iOS:

enter image description here

But there is no tint colour section on IB like it is on iOS.

So I'm trying my luck in the code:

@IBOutlet weak var alwaysConnectedSwitch: NSSwitch!

override func viewWillAppear() {
    super.viewWillAppear()
    self.alwaysConnectedSwitch.layer?.backgroundColor = CGColor.init(gray: 1, alpha: 1)
}

But the self.alwaysConnectedSwitch.layer seems to be nil, so nothing is set.

Houman
  • 64,245
  • 87
  • 278
  • 460
  • Have a look at this [SO](https://stackoverflow.com/questions/4811942/uiswitch-something-similar-for-mac/58070331#58070331) post. Maybe you can use one of the libraries or see how its done. – Eelco Koelewijn Jan 04 '20 at 13:06
  • It's intriguing, but risky, as it hasn't been updated for three years. – Houman Jan 04 '20 at 13:13
  • 1
    Are you trying to change the disabled color or the enabled color? – Lucas Derraugh Jan 07 '20 at 17:54
  • Disabled colour. But happy to know both. Thanks – Houman Jan 07 '20 at 18:58
  • @Houman Looks like this gets super tricky as the color is determined by NSWidgetView (private) and it goes through an updateLayer pass. If I figure out how exactly it creates the specific color, I'll let you know, but there is no public API. NSSwitch doesn't use NSCell for it's drawing either, so there aren't many hooks. Whatever the solution would be, it would end up utilizing private API. – Lucas Derraugh Jan 07 '20 at 20:07
  • @Houman Well I found a reliable swizzling approach that works if you're interested. There may be a way of subclassing NSSlider to vend a particular view color but it will be private API. – Lucas Derraugh Jan 08 '20 at 01:46
  • @LucasDerraugh Thanks I have no experience with the private API. Will Apple accept it? A shame that NS classes are not as extensive as their counter UI classes. – Houman Jan 08 '20 at 20:51
  • @Houman It would not be accepted into the Mac App Store. You could distribute it outside of the App Store though. My advice if you are targeting the App Store would be to use a different custom control that looks similar and gives you what you want, or just to use what Apple gives you. I'll try to post my private solution anyways if you want to see it. – Lucas Derraugh Jan 08 '20 at 22:11
  • Since the introduction of SwiftUI it's pretty easy to display a `Toggle` in any desired color in an `NSHostingView`. – vadian Jan 15 '23 at 18:53

3 Answers3

2

If you absolutely need to change the style, I'd recommend a 3rd-party custom control that lets you customize the look as you desire; some viable suggestions can be found here. As of 10.15, NSSwitch simply doesn't have the customization you desire. If you don't care about details, stop reading now

That said, it's interesting to dissect the components of NSSwitch. Unlike many Cocoa controls, there is no backing NSCell for this control. The documentation says this explicitly:

NSSwitch doesn’t use an instance of NSCell to provide its functionality. The cellClass class property and cell instance property both return nil, and they ignore attempts to set a non-nil value.

So instead, it is a rather modern control made up of layers that draw the content. There are 3 NSWidgetViews which are private NSView subclasses.

enter image description here

These views mostly utilize -[NSView updateLayer] to draw into their backing CALayer desired values based on what they're fed via -[NSWidgetView setWidgetDefinition:]. This method passes in a dictionary of values that defines how NSWidgetView should draw into the layer. An example dictionary for the back-most view:

{
    kCUIPresentationStateKey = kCUIPresentationStateInactive;
    kCUIScaleKey = 2;
    kCUIUserInterfaceLayoutDirectionKey = kCUIUserInterfaceLayoutDirectionLeftToRight;
    size = regular;
    state = normal;
    value = 0;
    widget = kCUIWidgetSwitchFill;
}

Unfortunately, this means that the style is mostly determined by predefined strings as seen by widget = kCUIWidgetSwitchFill; It gets quite involved in how this fill color or disabled color is drawn depending on system color (dark/light) or highlight color. The colors are tied to NSAppearance and there isn't a clear way of overriding any of these.

One (not recommended, seriously don't do this) solution is to swizzle out the updateLayer call for NSWidgetView and do your own additional layer customization in the cases that you desire. An example written in Swift:

/// Swizzle out NSWidgetView's updateLayer for our own implementation
class AppDelegate: NSObject, NSApplicationDelegate {

    /// Swizzle out NSWidgetView's updateLayer for our own implementation
    func applicationWillFinishLaunching(_ notification: Notification) {
        let original = Selector("updateLayer")
        let swizzle = Selector("xxx_updateLayer")
        if let widgetClass = NSClassFromString("NSWidgetView"),
            let originalMethod = class_getInstanceMethod(widgetClass, original),
            let swizzleMethod = class_getInstanceMethod(NSView.self, swizzle) {
            method_exchangeImplementations(originalMethod, swizzleMethod)
        }
    }
}

extension NSView {
    @objc func xxx_updateLayer() {
        // This calls the original implementation so all other NSWidgetViews will have the right look
        self.xxx_updateLayer()

        guard let dictionary = self.value(forKey: "widgetDefinition") as? [String: Any],
            let widget = dictionary["widget"] as? String,
            let value = (dictionary["value"] as? NSNumber)?.intValue else {
            return
        }

        // If we're specifically dealing with this case, change the colors and remove the contents which are set in the enabled switch case
        if widget == "kCUIWidgetSwitchFill" {
            layer?.contents = nil;
            if value == 0 {
                layer?.backgroundColor = NSColor.red.cgColor;
            } else {
                layer?.backgroundColor = NSColor.yellow.cgColor;
            }
        }
    }
}

Again, DON'T DO THIS! You're bound to break in the future, Apple will reject this in an App Store submission, and you're much more safe with a custom control that does exactly what you want without mucking with private API.

Lucas Derraugh
  • 6,929
  • 3
  • 27
  • 43
  • Very helpful, thanks Lucas. I will look into custom controls then. Is there at least a chance to set the border? `self.alwaysConnectedSwitch.layer?.borderColor = .black` Even that doesn't work. – Houman Jan 09 '20 at 21:27
  • I actually went with the first suggestion in the link you pasted. I picked a checkbox, which makes sense too. But it also comes with issues. https://stackoverflow.com/questions/59672767/how-to-remove-the-white-box-around-the-nsbutton-checkbox-when-disabled – Houman Jan 09 '20 at 22:12
1

Since the introduction of SwiftUI and NSHostingView you can take advantage of the flawless integration of SwiftUI Views in AppKit/UIKit.

  • First create a simple SwiftUI view with a Toggle and an @ObservedObject property which represents the view controller. You can specify any tint color.

    struct SwitchView: View {
        @ObservedObject var model : ViewController
    
        var body: some View {
            Toggle("Switch", isOn: $model.isOn)
                .toggleStyle(.switch)
                .tint(.orange)
        }
    }
    
  • In the NSViewController adopt ObservableObject, import SwiftUI, add a @Published property isOn, an IBOutlet for the NSView and initialize the NSHostingView lazily

    import SwiftUI
    
    class ViewController : NSViewController, ObservableObject {
    
        @IBOutlet weak var switchView: NSView!
    
        @Published var isOn = false
    
        lazy var toggleView = NSHostingView(rootView: SwitchView(model: self))
    
  • In Interface Builder drag an NSView in the canvas, resize it and connect it to switchView

  • To attach the hosting view to the IBOutlet add a generic method attachHostingView und call it in awakeFromNib

    override func awakeFromNib() {
          super.awakeFromNib()
          attachHostingView(toggleView, to: switchView)
    }
    
    fileprivate func attachHostingView<V : View>(_ hostingView: NSHostingView<V>, to superView: NSView) {
        hostingView.translatesAutoresizingMaskIntoConstraints = false
        superView.addSubview(hostingView)
        NSLayoutConstraint.activate([
            hostingView.leadingAnchor.constraint(equalTo: superView.leadingAnchor),
            hostingView.trailingAnchor.constraint(equalTo: superView.trailingAnchor),
            hostingView.topAnchor.constraint(equalTo: superView.topAnchor),
            hostingView.bottomAnchor.constraint(equalTo: superView.bottomAnchor),
        ])
    }
    
  • There are three options to perform an action when the switch changes the state: The didSet property observer of isOn, the Combine framework or the onChange modifier in the SwiftUI view calling a custom method in the view controller.

That's it. Enjoy a switch with the tint color of your choice.

vadian
  • 274,689
  • 30
  • 353
  • 361
  • Be aware that in SwiftUI on the mac, the preview is incorrect: it shows a gray switch. But running the actual app shows the correct tint color. – Frederic Adda May 31 '23 at 07:28
0

I'm not understanding exactly wich kind of problems you're facing, but this is working for me:

enter image description here

override func viewDidLoad() {
    super.viewDidLoad()


    zzz.layer!.backgroundColor = NSColor.systemRed.cgColor
    zzz.layer!.masksToBounds = true
    zzz.layer!.cornerRadius = 10

This yelds

enter image description here

You may wish to play/adjust other border settings like borderWidth or borderColor