14
votes

I want to fake a titlebar (bigger and with a different color), so my way until now is the following:

I added a NSView directly below the titlebar and then I set the titlebar to transparent with this code:

self.window.titlebarAppearsTransparent = true
self.window.styleMask |= NSFullSizeContentViewWindowMask    

The next step is, that I subclassed the NSView to add some drawing methods (background etc.) and especially the code, so that I can use the complete NSView for moving the window (therefore I use this code: https://stackoverflow.com/a/4564630/2062613)

This is the result:

WindowScreen

Now the next thing I want to do is to vertically center the traffic light buttons in this new titlebar. I know, that I can access the buttons with self.window.standardWindowButton(NSWindowButton.CloseButton) (for example). But changing the frame.origin of one of the button doesn't have any effect.

How can I change the origin.y value of the buttons?

UPDATE

I discovered, that the window resizing re-arranges the buttons. Now I decided to add the buttons as subviews to my fake titlebar, because moving the origin in the titlebar cuts off the buttons (it's obviously limited to the titlebar rect).

This works, but strangely the mouseover effect of the buttons still remains in the titlebar. Look at this screen:

Second screen

This is actually my code:

func moveButtons() {
    self.moveButtonDownFirst(self.window.standardWindowButton(NSWindowButton.CloseButton)!)
    self.moveButtonDownFirst(self.window.standardWindowButton(NSWindowButton.MiniaturizeButton)!)
    self.moveButtonDownFirst(self.window.standardWindowButton(NSWindowButton.ZoomButton)!)
}

func moveButtonDownFirst(button: NSView) {
    button.setFrameOrigin(NSMakePoint(button.frame.origin.x, button.frame.origin.y+10.0))
    self.fakeTitleBar.addSubview(button)
}
3
I think this is Apple-internal voodoo. I seem to remember having seen iTunes with the traffic light arranged vertically.qwerty_so
Maybe something like this: Find the view that originally contained the buttons. Find the tracking area for the buttons using -[NSView trackingAreas] (I guess that there is just one tracking area for the 3 buttons). Get its object attributes. Remove that tracking area. Add a new one for your fake title bar, with the same attributes except for the rectangle.JWWalker
Tried it with button.superview.trackingAreas[0] and button.superview.removeTrackingArea(...). No effect :(Lupurus
Why don't you hide the buttons and make them yourself? Just an idea, not sure if even possible.Jasper
This works fine except we enter full screen.Sunil Chauhan

3 Answers

6
votes

You need to add toolbar and change window property titleVisibility. Here more details NSWindow Style Showcase.

let customToolbar = NSToolbar()
window?.titleVisibility = .hidden
window?.toolbar = customToolbar

enter image description here

2
votes

Swift 4.2 version (without Toolbar).

Idea behind:

  • We adjusting frames of standard window buttons without changing superview.
  • To prevent clipping we need increase height of title bar. This can be achieved by adding transparent title bar accessory.
  • When window goes to full screen we hiding title bar accessory.
  • When window goes out of full screen we showing title bar accessory.
  • Additionally we need to adjust layout of UI elements shown alongside standard buttons in full screen mode.

Normal screen.

enter image description here

Full screen mode.

enter image description here

Real application

enter image description here


File FullContentWindow.swift

public class FullContentWindow: Window {

   private var buttons: [NSButton] = []

   public let titleBarAccessoryViewController = TitlebarAccessoryViewController()
   private lazy var titleBarHeight = calculatedTitleBarHeight
   private let titleBarLeadingOffset: CGFloat?
   private var originalLeadingOffsets: [CGFloat] = []

   public init(contentRect: NSRect, titleBarHeight: CGFloat, titleBarLeadingOffset: CGFloat? = nil) {
      self.titleBarLeadingOffset = titleBarLeadingOffset
      let styleMask: NSWindow.StyleMask = [.closable, .titled, .miniaturizable, .resizable, .fullSizeContentView]
      super.init(contentRect: contentRect, styleMask: styleMask, backing: .buffered, defer: true)
      titleVisibility = .hidden
      titlebarAppearsTransparent = true
      buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton].compactMap {
         standardWindowButton($0)
      }
      var accessoryViewHeight = titleBarHeight - calculatedTitleBarHeight
      accessoryViewHeight = max(0, accessoryViewHeight)
      titleBarAccessoryViewController.view.frame = CGRect(dimension: accessoryViewHeight) // Width not used.
      if accessoryViewHeight > 0 {
         addTitlebarAccessoryViewController(titleBarAccessoryViewController)
      }
      self.titleBarHeight = max(titleBarHeight, calculatedTitleBarHeight)
   }

   public override func layoutIfNeeded() {
      super.layoutIfNeeded()
      if originalLeadingOffsets.isEmpty {
         let firstButtonOffset = buttons.first?.frame.origin.x ?? 0
         originalLeadingOffsets = buttons.map { $0.frame.origin.x - firstButtonOffset }
      }
      if titleBarAccessoryViewController.view.frame.height > 0, !titleBarAccessoryViewController.isHidden {
         setupButtons()
      }
   }

}

extension FullContentWindow {

   public var standardWindowButtonsRect: CGRect {
      var result = CGRect()
      if let firstButton = buttons.first, let lastButton = buttons.last {
         let leadingOffset = firstButton.frame.origin.x
         let maxX = lastButton.frame.maxX
         result = CGRect(x: leadingOffset, y: 0, width: maxX - leadingOffset, height: titleBarHeight)
         if let titleBarLeadingOffset = titleBarLeadingOffset {
            result = result.offsetBy(dx: titleBarLeadingOffset - leadingOffset, dy: 0)
         }
      }
      return result
   }

}

extension FullContentWindow {

   private func setupButtons() {
      let barHeight = calculatedTitleBarHeight
      for (idx, button) in buttons.enumerated() {
         let coordY = (barHeight - button.frame.size.height) * 0.5
         var coordX = button.frame.origin.x
         if let titleBarLeadingOffset = titleBarLeadingOffset {
            coordX = titleBarLeadingOffset + originalLeadingOffsets[idx]
         }
         button.setFrameOrigin(CGPoint(x: coordX, y: coordY))
      }
   }

   private var calculatedTitleBarHeight: CGFloat {
      let result = contentRect(forFrameRect: frame).height - contentLayoutRect.height
      return result
   }
}

File FullContentWindowController.swift

open class FullContentWindowController: WindowController {

   private let fullContentWindow: FullContentWindow
   private let fullContentViewController = ViewController()

   public private (set) lazy var titleBarContentContainer = View().autolayoutView()
   public private (set) lazy var contentContainer = View().autolayoutView()

   private lazy var titleOffsetConstraint =
      titleBarContentContainer.leadingAnchor.constraint(equalTo: fullContentViewController.contentView.leadingAnchor)

   public init(contentRect: CGRect, titleBarHeight: CGFloat, titleBarLeadingOffset: CGFloat? = nil) {
      fullContentWindow = FullContentWindow(contentRect: contentRect, titleBarHeight: titleBarHeight,
                                            titleBarLeadingOffset: titleBarLeadingOffset)
      super.init(window: fullContentWindow, viewController: fullContentViewController)
      contentWindow.delegate = self
      fullContentViewController.contentView.addSubviews(titleBarContentContainer, contentContainer)

      let standardWindowButtonsRect = fullContentWindow.standardWindowButtonsRect

      LayoutConstraint.withFormat("V:|[*][*]|", titleBarContentContainer, contentContainer).activate()
      LayoutConstraint.pin(to: .horizontally, contentContainer).activate()
      LayoutConstraint.constrainHeight(constant: standardWindowButtonsRect.height, titleBarContentContainer).activate()
      LayoutConstraint.withFormat("[*]|", titleBarContentContainer).activate()
      titleOffsetConstraint.activate()

      titleOffsetConstraint.constant = standardWindowButtonsRect.maxX
   }

   open override func prepareForInterfaceBuilder() {
      titleBarContentContainer.backgroundColor = .green
      contentContainer.backgroundColor = .yellow
      fullContentViewController.contentView.backgroundColor = .blue
      fullContentWindow.titleBarAccessoryViewController.contentView.backgroundColor = Color.red.withAlphaComponent(0.4)
   }

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

extension FullContentWindowController {

   public func embedTitleBarContent(_ viewController: NSViewController) {
      fullContentViewController.embedChildViewController(viewController, container: titleBarContentContainer)
   }

   public func embedContent(_ viewController: NSViewController) {
      fullContentViewController.embedChildViewController(viewController, container: contentContainer)
   }
}

extension FullContentWindowController: NSWindowDelegate {

   public func windowWillEnterFullScreen(_ notification: Notification) {
      fullContentWindow.titleBarAccessoryViewController.isHidden = true
      titleOffsetConstraint.constant = 0
   }

   public  func windowWillExitFullScreen(_ notification: Notification) {
      fullContentWindow.titleBarAccessoryViewController.isHidden = false
      titleOffsetConstraint.constant = fullContentWindow.standardWindowButtonsRect.maxX
   }
}

Usage

let windowController = FullContentWindowController(contentRect: CGRect(...),
                                                   titleBarHeight: 30,
                                                   titleBarLeadingOffset: 7)
windowController.embedContent(viewController) // Content "Yellow area"
windowController.embedTitleBarContent(titleBarController) // Titlebar "Green area"
windowController.showWindow(nil)
0
votes

My answer involves a bit of the answers from @Vlad and @Lupurus. To change the buttons position a simple call to a function func moveButton(ofType type: NSWindow.ButtonType) in the NSWindow subclass handles the moving.

Note: in my case I just need the buttons to be lower a bit by 2px.

To handle the normal case (not fullscreen) I have just overridden the function func standardWindowButton(_ b: NSWindow.ButtonType) -> NSButton? of NSWindow to move the buttons as needed before they are returned.

Note: better code would have a separate method to compute the new frame and storing the new value would be stored somewhere else

To handle the animation properly when coming back from fullscreen we need to override the func layoutIfNeeded() method of NSWindow, this method will be called when needed by the animation returning from fullscreen.

We need to keep the updated frames in NSWindow. A nil value will trigger frames recomputations.

You need to keep the updated frame in the NSWindow window:

    var closeButtonUpdatedFrame: NSRect?
    var miniaturizeButtonUpdatedFrame: NSRect?
    var zoomButtonUpdatedFrame: NSRect?

    public override func layoutIfNeeded() {
        super.layoutIfNeeded()

        if closeButtonUpdatedFrame == nil {
            moveButton(ofType: .closeButton)
        }

        if miniaturizeButtonUpdatedFrame == nil {
            moveButton(ofType: .miniaturizeButton)
        }

        if zoomButtonUpdatedFrame == nil {
            moveButton(ofType: .zoomButton)
        }
    }

    override public func standardWindowButton(_ b: NSWindow.ButtonType) -> NSButton? {

        switch b {
        case .closeButton:
            if closeButtonUpdatedFrame == nil {
                moveButton(ofType: b)
            }
        case .miniaturizeButton:
            if miniaturizeButtonUpdatedFrame == nil {
                moveButton(ofType: b)
            }
        case .zoomButton:
            if zoomButtonUpdatedFrame == nil {
                moveButton(ofType: b)
            }
        default:
            break
        }
        return super.standardWindowButton(b)
    }

    func moveButton(ofType type: NSWindow.ButtonType) {

        guard let button = super.standardWindowButton(type) else {
            return
        }

        switch type {
        case .closeButton:
            self.moveButtonDown(button: button)
            closeButtonUpdatedFrame = button.frame
        case .miniaturizeButton:
            self.moveButtonDown(button: button)
            miniaturizeButtonUpdatedFrame = button.frame
        case .zoomButton:
            self.moveButtonDown(button: button)
            zoomButtonUpdatedFrame = button.frame
        default:
            break
        }
    }

    func moveButtonDown(button: NSView) {

        button.setFrameOrigin(NSMakePoint(button.frame.origin.x, button.frame.origin.y-2.0))
    }

To handle the full screen case, we need to put some code in the NSWindowDelegate, in my case this delegate is the NSWindowController instance. This code will force the func layoutIfNeeded() method to recompute the buttons frames when coming from fullscreen:


    public func windowWillExitFullScreen(_ notification: Notification) {

        self.window.closeButtonUpdatedFrame = nil
        self.window.miniaturizeButtonUpdatedFrame = nil
        self.window.zoomButtonUpdatedFrame = nil
    }

Et voilà!

In my testing, this code handles all cases.