import Cocoa @IBDesignable open class CustomButton: NSButton { private let titleLayer = CATextLayer() private var isMouseDown = false public static func circularButton(title: String, radius: Double, center: CGPoint) -> CustomButton { with(CustomButton()) { $0.title = title $0.frame = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2) $0.cornerRadius = radius $0.font = .systemFont(ofSize: radius * 2 / 3) } } override open var wantsUpdateLayer: Bool { true } @IBInspectable override public var title: String { didSet { setTitle() } } @IBInspectable public var textColor: NSColor = .labelColor { didSet { titleLayer.foregroundColor = textColor.cgColor } } @IBInspectable public var activeTextColor: NSColor = .labelColor { didSet { if state == .on { titleLayer.foregroundColor = textColor.cgColor } } } @IBInspectable public var cornerRadius: Double = 0 { didSet { layer?.cornerRadius = cornerRadius } } @IBInspectable public var hasContinuousCorners: Bool = true { didSet { if #available(macOS 10.15, *) { layer?.cornerCurve = hasContinuousCorners ? .continuous : .circular } } } @IBInspectable public var borderWidth: Double = 0 { didSet { layer?.borderWidth = borderWidth } } @IBInspectable public var borderColor: NSColor = .clear { didSet { layer?.borderColor = borderColor.cgColor } } @IBInspectable public var activeBorderColor: NSColor = .clear { didSet { if state == .on { layer?.borderColor = activeBorderColor.cgColor } } } @IBInspectable public var backgroundColor: NSColor = .clear { didSet { layer?.backgroundColor = backgroundColor.cgColor } } @IBInspectable public var activeBackgroundColor: NSColor = .clear { didSet { if state == .on { layer?.backgroundColor = activeBackgroundColor.cgColor } } } @IBInspectable public var shadowRadius: Double = 0 { didSet { layer?.shadowRadius = shadowRadius } } @IBInspectable public var activeShadowRadius: Double = -1 { didSet { if state == .on { layer?.shadowRadius = activeShadowRadius } } } @IBInspectable public var shadowOpacity: Double = 0 { didSet { layer?.shadowOpacity = Float(shadowOpacity) } } @IBInspectable public var activeShadowOpacity: Double = -1 { didSet { if state == .on { layer?.shadowOpacity = Float(activeShadowOpacity) } } } @IBInspectable public var shadowColor: NSColor = .clear { didSet { layer?.shadowColor = shadowColor.cgColor } } @IBInspectable public var activeShadowColor: NSColor? { didSet { if state == .on, let activeShadowColor { layer?.shadowColor = activeShadowColor.cgColor } } } override public var font: NSFont? { didSet { setTitle() } } override public var isEnabled: Bool { didSet { alphaValue = isEnabled ? 1 : 0.6 } } public convenience init() { self.init(frame: .zero) } public required init?(coder: NSCoder) { super.init(coder: coder) setup() } override init(frame: CGRect) { super.init(frame: frame) setup() } // Ensure the button doesn't draw its default contents. override open func draw(_ dirtyRect: CGRect) {} override open func drawFocusRingMask() {} override open func layout() { super.layout() positionTitle() } override open func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let scale = window?.backingScaleFactor { layer?.contentsScale = scale titleLayer.contentsScale = scale } } private lazy var trackingArea = TrackingArea( for: self, options: [ .mouseEnteredAndExited, .activeInActiveApp ] ) override open func updateTrackingAreas() { super.updateTrackingAreas() trackingArea.update() } private func setup() { let isOn = state == .on wantsLayer = true layer?.masksToBounds = false layer?.cornerRadius = cornerRadius layer?.borderWidth = borderWidth layer?.shadowRadius = isOn && activeShadowRadius != -1 ? activeShadowRadius : shadowRadius layer?.shadowOpacity = Float(isOn && activeShadowOpacity != -1 ? activeShadowOpacity : shadowOpacity) layer?.backgroundColor = isOn ? activeBackgroundColor.cgColor : backgroundColor.cgColor layer?.borderColor = isOn ? activeBorderColor.cgColor : borderColor.cgColor layer?.shadowColor = isOn ? (activeShadowColor?.cgColor ?? shadowColor.cgColor) : shadowColor.cgColor if #available(macOS 10.15, *) { layer?.cornerCurve = hasContinuousCorners ? .continuous : .circular } titleLayer.alignmentMode = .center titleLayer.contentsScale = window?.backingScaleFactor ?? 2 titleLayer.foregroundColor = isOn ? activeTextColor.cgColor : textColor.cgColor layer?.addSublayer(titleLayer) setTitle() needsDisplay = true } public typealias ColorGenerator = () -> NSColor private var colorGenerators = [KeyPath: ColorGenerator]() /** Gets or sets the color generation closure for the provided key path. - Parameter keyPath: The key path that specifies the color related property. */ public subscript(colorGenerator keyPath: KeyPath) -> ColorGenerator? { get { colorGenerators[keyPath] } set { colorGenerators[keyPath] = newValue } } private func color(for keyPath: KeyPath) -> NSColor { colorGenerators[keyPath]?() ?? self[keyPath: keyPath] } override open func updateLayer() { animateColor() } private func setTitle() { titleLayer.string = title if let font { titleLayer.font = font titleLayer.fontSize = font.pointSize } needsLayout = true } private func positionTitle() { let titleSize = title.size(withAttributes: [.font: font as Any]) titleLayer.frame = titleSize.centered(in: bounds).roundedOrigin() } private func animateColor() { let isOn = state == .on let duration = isOn ? 0.2 : 0.1 let backgroundColor = isOn ? color(for: \.activeBackgroundColor) : color(for: \.backgroundColor) let textColor = isOn ? color(for: \.activeTextColor) : color(for: \.textColor) let borderColor = isOn ? color(for: \.activeBorderColor) : color(for: \.borderColor) let shadowColor = isOn ? (activeShadowColor ?? color(for: \.shadowColor)) : color(for: \.shadowColor) layer?.animate(\.backgroundColor, to: backgroundColor, duration: duration) layer?.animate(\.borderColor, to: borderColor, duration: duration) layer?.animate(\.shadowColor, to: shadowColor, duration: duration) titleLayer.animate(\.foregroundColor, to: textColor, duration: duration) } private func toggleState() { state = state == .off ? .on : .off animateColor() } override open func hitTest(_ point: CGPoint) -> NSView? { isEnabled ? super.hitTest(point) : nil } override open func mouseDown(with event: NSEvent) { isMouseDown = true toggleState() } override open func mouseEntered(with event: NSEvent) { if isMouseDown { toggleState() } } override open func mouseExited(with event: NSEvent) { if isMouseDown { toggleState() isMouseDown = false } } override open func mouseUp(with event: NSEvent) { if isMouseDown { isMouseDown = false toggleState() _ = target?.perform(action, with: self) } } } extension CustomButton: NSViewLayerContentScaleDelegate { public func layer(_ layer: CALayer, shouldInheritContentsScale newScale: CGFloat, from window: NSWindow) -> Bool { true } }