Resizing NSWindow to match view controller size in storyboard - macos

I am working on Xcode 6.1.1 on OSX 10.10. I am trying out storyboards for Mac apps. I have a NSTabViewController using the new NSTabViewControllerTabStyleToolbar tabStyle and it is set as the default view controller for the window controller. How do I make my window resize according to the current selected view controller?
Is it possible to do entirely in Interface Builder?
Here is what my storyboard looks like:

The auto layout answer is half of it. You need to set the preferredContentSize in your ViewController for each tab to the fitting size (if you wanted the tab to size to the smallest size satisfying all constraints).
override func viewWillAppear() {
super.viewWillAppear()
preferredContentSize = view.fittingSize
}
If your constraints are causing an issue below try first with a fixed size, the example below sets this in the tab item's view controller's viewWillAppear function (Swift used here, but the Objective-C version works just as well).
override func viewWillAppear() {
super.viewWillAppear()
preferredContentSize = NSSize(width: 400, height: 280)
}
If that works, fiddle with your constraints to figure out what's going on

This solution for 'toolbar style' tab view controllers does animate and supports the nice crossfade effect. In the storyboard designer, add 'TabViewController' in the custom class name field of the NSTabViewController. Don't forget to assign a title to each viewController, this is used as a key value.
import Cocoa
class TabViewController: NSTabViewController {
private lazy var tabViewSizes: [String : NSSize] = [:]
override func viewDidLoad() {
// Add size of first tab to tabViewSizes
if let viewController = self.tabViewItems.first?.viewController, let title = viewController.title {
tabViewSizes[title] = viewController.view.frame.size
}
super.viewDidLoad()
}
override func transition(from fromViewController: NSViewController, to toViewController: NSViewController, options: NSViewController.TransitionOptions, completionHandler completion: (() -> Void)?) {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.5
self.updateWindowFrameAnimated(viewController: toViewController)
super.transition(from: fromViewController, to: toViewController, options: [.crossfade, .allowUserInteraction], completionHandler: completion)
}, completionHandler: nil)
}
func updateWindowFrameAnimated(viewController: NSViewController) {
guard let title = viewController.title, let window = view.window else {
return
}
let contentSize: NSSize
if tabViewSizes.keys.contains(title) {
contentSize = tabViewSizes[title]!
}
else {
contentSize = viewController.view.frame.size
tabViewSizes[title] = contentSize
}
let newWindowSize = window.frameRect(forContentRect: NSRect(origin: NSPoint.zero, size: contentSize)).size
var frame = window.frame
frame.origin.y += frame.height
frame.origin.y -= newWindowSize.height
frame.size = newWindowSize
window.animator().setFrame(frame, display: false)
}
}

The window containing a toolbar style tab view controller does resize without any code if you have auto layout constraints in your storyboard tab views (macOS 11.1, Xcode 12.3). I haven't tried other style tab view controllers.
If you want to resize with animation as in Finder, it is sufficient to add one override in your tab view controller. It will resize the window with system-calculated resize animation time and will hide the tab view during resize animation:
class PreferencesTabViewController: NSTabViewController {
override func transition(from fromViewController: NSViewController, to toViewController: NSViewController, options: NSViewController.TransitionOptions = [], completionHandler completion: (() -> Void)? = nil) {
guard let window = view.window else {
super.transition(from: fromViewController, to: toViewController, options: options, completionHandler: completion)
return
}
let fromSize = window.frame.size
let toSize = window.frameRect(forContentRect: toViewController.view.frame).size
let widthDelta = toSize.width - fromSize.width
let heightDelta = toSize.height - fromSize.height
var toOrigin = window.frame.origin
toOrigin.x += widthDelta / 2
toOrigin.y -= heightDelta
let toFrame = NSRect(origin: toOrigin, size: toSize)
NSAnimationContext.runAnimationGroup { context in
context.duration = window.animationResizeTime(toFrame)
view.isHidden = true
window.animator().setFrame(toFrame, display: false)
super.transition(from: fromViewController, to: toViewController, options: options, completionHandler: completion)
} completionHandler: { [weak self] in
self?.view.isHidden = false
}
}
}
Please adjust closure syntax if you are using Swift versions older than 5.3.

Use autolayout. Set explicit size constraints on you views. Or once you have entered the UI into each tab view item's view set up the internal constraints such that they force view to be the size you want.

Related

Additional view in NSCollectionViewItem pauses dragEvent in CollectionViewController

I am trying to implement drop delegates on a NSCollectionViewController and having issues using a custom NSCollectionViewItem with an additional View Layer I've added onto the CollectionView Item. FWIW, The additional view is used draw a dashed border to indicate a drop area.
The drag event works fine on this collectionItem, and all other collectionItems without this view when it is hidden, but as soon as the drag event occurs on top of this view, the drag event pauses.
The drag event resumes as soon as the mouse is dragged outside of the view, but nothing happens if I release the drag while the mouse is over the view.
I would love to know what is happening here and how to prevent the custom view from "stealing" the mouse event from the CollectionViewContoller.
Delegate Method on DropViewController
func collectionView(_ collectionView: NSCollectionView, validateDrop draggingInfo: NSDraggingInfo, proposedIndexPath proposedDropIndexPath: AutoreleasingUnsafeMutablePointer<NSIndexPath>, dropOperation proposedDropOperation: UnsafeMutablePointer<NSCollectionView.DropOperation>) -> NSDragOperation {
print("1")
if proposedDropIndexPath.pointee.item <= self.destinationDirectoryArray.count {
if proposedDropOperation.pointee == NSCollectionView.DropOperation.on {
return .move
}
} else if proposedDropIndexPath.pointee.item == self.destinationDirectoryArray.count {
//There's some stuff here validating the URL removed for brevity. It works okay when the focus is outside the view, but happy to add back in if helpful
if proposedDropOperation.pointee == NSCollectionView.DropOperation.on {
return .move
}
}
return[]
}
Configuring Collection View
func configureCollectionView() {
let flowLayout = NSCollectionViewFlowLayout()
flowLayout.minimumInteritemSpacing = 8.0
flowLayout.minimumLineSpacing = 8.0
destinationCollectionView.delegate = self
destinationCollectionView.dataSource = self
destinationCollectionView.register(NSNib(nibNamed: "DestinationCollectionItem", bundle: nil), forItemWithIdentifier: directoryItemIdentifier)
destinationCollectionView.collectionViewLayout = flowLayout
destinationCollectionView.registerForDraggedTypes([.fileURL])
destinationCollectionView.setDraggingSourceOperationMask(NSDragOperation.move, forLocal: true)
}
Collection View Item Setup
class DestinationCollectionItem: NSCollectionViewItem {
#IBOutlet weak var backgroundLayer: NSView!
override func viewDidLoad() {
super.viewDidLoad()
self.highlightState = .none
view.wantsLayer = true
view.layer?.cornerRadius = 8.0
backgroundLayer.isHidden = true
}
}
Custom Border View - Applied custom class in Xib and linked to File's Owner
class BorderedView: NSView {
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let path : NSBezierPath = NSBezierPath(roundedRect: self.bounds, xRadius: 10.0, yRadius: 10.0)
path.addClip()
let dashHeight: CGFloat = 2
let dashLength: CGFloat = 7
let dashColor: NSColor = .lightGray
// setup the context
let currentContext = NSGraphicsContext.current!.cgContext
currentContext.setLineWidth(dashHeight)
currentContext.setLineDash(phase: 0, lengths: [dashLength])
currentContext.setStrokeColor(dashColor.cgColor)
// draw the dashed path
let cgPath : CGPath = CGPath(roundedRect: NSRectToCGRect(self.bounds), cornerWidth: 10.0, cornerHeight: 10.0, transform: nil)
currentContext.addPath(cgPath)
currentContext.strokePath()
}
}
Well - I solved this one pretty quick.
While I previously tried adding unregisterDraggedTypes() to the backgroundLayer, the issue turned out to also be occurring on the image layer. I applied it to both the Image and backgroundLayer and it works now.
Collection View Item Setup
class DestinationCollectionItem: NSCollectionViewItem {
#IBOutlet weak var backgroundLayer: NSView!
override func viewDidLoad() {
super.viewDidLoad()
self.highlightState = .none
view.wantsLayer = true
view.layer?.cornerRadius = 8.0
backgroundLayer.isHidden = true
backgroundLayer.unregisterDraggedTypes()
self.imageView?.unregisterDraggedTypes()
self.textField?.unregisterDraggedTypes()
}
}

How to make a scroll view programmatically in Xcode and add view in it?

I want to make a scroll view programmatically in xcode and want to add anchor constraints using safe area layout guide Auto Layout. And want to add some text views button and map init but could not find any proper way to do this. I have tried many codes. What is the proper code for this?
Please try below code for programmatically create Scroll view and add UIView inside XCode
Swift 4.0
import UIKit
class ViewController: UIViewController {
let scrollView: UIScrollView = {
let view = UIScrollView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
let myView: UIView = {
let view = UIView()
view.backgroundColor = .yellow
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
// add the scroll view to self.view
self.view.addSubview(scrollView)
// constrain the scroll view to 8-pts on each side
scrollView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 8.0).isActive = true
scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 8.0).isActive = true
scrollView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -8.0).isActive = true
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8.0).isActive = true
//Frame for UIView here
myView.frame = CGRect(x: 0, y: 0, width: self.view.frame.size.width, height: self.view.frame.size.height)
scrollView.addSubview(myView)
}
}

NSClickGestureRecognizer not working on NSStatusItem

Trying to recognize a right click on a NSStatusItem I got a suggestion ( Thanks to Zoff Dino ) to use a NSClickGestureRecognizer for that. But for some bizarre reason it isn't working as it should be. I am able to recognize a left click (buttonMask = 0x1) but not a right-click (buttonMask = 0x2). This is how I would like it to work but it isn't:
func applicationDidFinishLaunching(aNotification: NSNotification) {
// Insert code here to initialize your application
if let button = statusItem.button {
// Add right click functionality
let gesture = NSClickGestureRecognizer()
gesture.buttonMask = 0x2 // right mouse
gesture.target = self
gesture.action = "rightClickAction:"
button.addGestureRecognizer(gesture)
}}
func rightClickAction(sender: NSGestureRecognizer) {
if let button = sender.view as? NSButton {
NSLog("rightClick")
}
}
UPDATE:
I still did not manage to gets to work. Somehow it doesn't react on a right click (but changing the code on a left click) does. I guess some really simple issues are occurring that seem to block it from working. Even stranger is the fact that gesture.buttonMask = 0x1 works on the left click.
An alternative solution rather than NSClickGestureRecognizer is to attach a custom view to the status bar and handle the event from there.
The small disadvantage is you have to take care of the drawing and menu delegate methods.
Here a simple example:
Create a file StatusItemView a subclass of NSView
import Cocoa
class StatusItemView: NSView, NSMenuDelegate {
//MARK: - Variables
weak var statusItem : NSStatusItem!
var menuVisible = false
var image : NSImage! {
didSet {
if image != nil {
statusItem.length = image.size.width
needsDisplay = true
}
}
}
//MARK: - Override functions
override func mouseDown(theEvent: NSEvent) {
if let hasMenu = menu {
hasMenu.delegate = self
statusItem.popUpStatusItemMenu(hasMenu)
needsDisplay = true
}
}
override func rightMouseDown(theEvent: NSEvent) {
Swift.print(theEvent)
}
//MARK: - NSMenuDelegate
func menuWillOpen(menu: NSMenu) {
menuVisible = true
needsDisplay = true
}
func menuDidClose(menu: NSMenu) {
menuVisible = false
menu.delegate = nil
needsDisplay = true
}
//MARK: - DrawRect
override func drawRect(dirtyRect: NSRect) {
statusItem.drawStatusBarBackgroundInRect(bounds, withHighlight:menuVisible)
let origin = NSMakePoint(2.0, 3.0) // adjust origin if necessary
image?.drawAtPoint(origin, fromRect: dirtyRect, operation: .CompositeSourceOver, fraction: 1.0)
}
}
In AppDelegate you need a reference to the custom menu and an instance variable for the NSStatusItem instance
#IBOutlet weak var menu : NSMenu!
var statusItem : NSStatusItem!
In applicationDidFinishLaunching create the view and attach it to the status item. Be aware to set the image of the view after attaching it to make sure the width is considered.
func applicationDidFinishLaunching(aNotification: NSNotification) {
statusItem = NSStatusBar.systemStatusBar().statusItemWithLength(-1) // NSVariableStatusItemLength)
let statusItemView = StatusItemView(frame: NSRect(x: 0.0, y: 0.0, width: statusItem.length, height: 22.0))
statusItemView.statusItem = statusItem;
statusItemView.menu = menu
statusItem.view = statusItemView
statusItemView.image = NSImage(named: NSImageNameStatusAvailable)
}
The special case control-click to trigger the right-click function is not implemented.

How to present a modal atop the current view in Swift

(Xcode6, iOS8, Swift, iPad)
I am trying to create a classic Web-like modal view, where the outside of the dialog box is "grayed-out." To accomplish this, I've set the alpha value of the backgroundColor of the view for the modal to 0.5, like so:
self.view.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.5)
The only problem is that when the modal becomes full-screen, the presenting view is removed. (Ref Transparent Modal View on Navigation Controller).
(A bit irritated at the concept here. Why remove the underlying view? A modal is, by definition, to appear atop other content. Once the underlying view is removed, it's not really a modal anymore. it's somewhere between a modal and a push transition. Wa wa wa... Anyway..)
To prevent this from happening, I've set the modalPresentationStyle to CurrentContext in the viewDidLoad method of the parent controller, and in Storyboard... but no luck.
self.modalPresentationStyle = UIModalPresentationStyle.CurrentContext
self.navigationController.modalPresentationStyle = UIModalPresentationStyle.CurrentContext
How do I prevent the presenting view from being removed when the modal becomes full screen?
tyvm.. more info below.
Also in Storyboard, like so (Presentation: Current Context)
Thx for your help... documentation below:
First, remove all explicit setting of modal presentation style in code and do the following:
In the storyboard set the ModalViewController's modalPresentation style to Over Current context
Check the checkboxes in the Root/Presenting ViewController - Provide Context and Define Context.
They seem to be working even unchecked.
You can try this code for Swift:
let popup : PopupVC = self.storyboard?.instantiateViewControllerWithIdentifier("PopupVC") as! PopupVC
let navigationController = UINavigationController(rootViewController: popup)
navigationController.modalPresentationStyle = UIModalPresentationStyle.OverCurrentContext
self.presentViewController(navigationController, animated: true, completion: nil)
For swift 4 latest syntax using extension:
extension UIViewController {
func presentOnRoot(`with` viewController : UIViewController){
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.modalPresentationStyle = UIModalPresentationStyle.overCurrentContext
self.present(navigationController, animated: false, completion: nil)
}
}
How to use:
let popup : PopupVC = self.storyboard?.instantiateViewControllerWithIdentifier("PopupVC") as! PopupVC
self.presentOnRoot(with: popup)
The only problem I can see in your code is that you are using CurrentContext instead of OverCurrentContext.
So, replace this:
self.modalPresentationStyle = UIModalPresentationStyle.CurrentContext
self.navigationController.modalPresentationStyle = UIModalPresentationStyle.CurrentContext
for this:
self.modalPresentationStyle = UIModalPresentationStyle.OverCurrentContext
self.navigationController.modalPresentationStyle = UIModalPresentationStyle.OverCurrentContext
This worked for me in Swift 5.0. Set the Storyboard Id in the identity inspector as "destinationVC".
#IBAction func buttonTapped(_ sender: Any) {
let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: Bundle.main)
let destVC = storyboard.instantiateViewController(withIdentifier: "destinationVC") as! MyViewController
destVC.modalPresentationStyle = UIModalPresentationStyle.overCurrentContext
destVC.modalTransitionStyle = UIModalTransitionStyle.crossDissolve
self.present(destVC, animated: true, completion: nil)
}
The problem with setting the modalPresentationStyle from code was that you should have set it in the init() method of the presented view controller, not the parent view controller.
From UIKit docs: "Defines the transition style that will be used for this view controller when it is presented modally. Set
this property on the view controller to be presented, not the presenter. Defaults to
UIModalTransitionStyleCoverVertical."
The viewDidLoad method will only be called after you already presented the view controller.
The second problem was that you should use UIModalPresentationStyle.overCurrentContext.
The only way I able to get this to work was by doing this on the presenting view controller:
func didTapButton() {
self.definesPresentationContext = true
self.modalTransitionStyle = .crossDissolve
let yourVC = self.storyboard?.instantiateViewController(withIdentifier: "YourViewController") as! YourViewController
let navController = UINavigationController(rootViewController: yourVC)
navController.modalPresentationStyle = .overCurrentContext
navController.modalTransitionStyle = .crossDissolve
self.present(navController, animated: true, completion: nil)
}
I am updating a simple solution. First add an id to your segue which presents modal. Than in properties change it's presentation style to "Over Current Context". Than add this code in presenting view controller (The controller which is presenting modal).
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
let Device = UIDevice.currentDevice()
let iosVersion = NSString(string: Device.systemVersion).doubleValue
let iOS8 = iosVersion >= 8
let iOS7 = iosVersion >= 7 && iosVersion < 8
if((segue.identifier == "chatTable")){
if (iOS8){
}
else {
self.navigationController?.modalPresentationStyle = UIModalPresentationStyle.CurrentContext
}
}
}
Make sure you change segue.identifier to your own id ;)

synchronize two NSScrollView

I read the document Synchronizing Scroll Views, and did exactly as the document, but there is an isssue.
I want to synchronize a NSTableView and a NSTextView. first let NSTableView monitor NSTextView, and everything is ok when I scroll the TextView, but when I try to scroll TableView, I found that the TableView will jump to another place(maybe backward several rows) at first, then continue to scroll from that place.
This issue still exists even after I let TextView monitor TableView.
anyone know what's the problem? can't I synchronize a TableView and a TextView?
Edited:
OK, now I found that the TableView will go back to the place since last scrolling. for example, TableView's top row is 10th row, then I scroll TextView, now TableView's top row is 20th row, and if I scroll TableView again, the TableView will go back to 10th row first, then start to scroll.
I just ran into this exact problem while troubleshooting a very similar situation (on Lion). I noticed that this only occurs when the scrollers are hidden -- but I verified that they still exist in the nib, and are still instantiated correctly.
I even made sure to call -[NSScrollView reflectScrolledClipView:], but it didn't make a difference. It really seems like this is a bug in NSScrollView.
Anyway, I was able to work around the issue by creating a custom scroller class. All I had to do was override the following class methods:
+ (BOOL)isCompatibleWithOverlayScrollers
{
// Let this scroller sit on top of the content view, rather than next to it.
return YES;
}
- (void)setHidden:(BOOL)flag
{
// Ugly hack: make sure we are always hidden.
[super setHidden:YES];
}
Then, I allowed the scrollers to be "visible" in Interface Builder. Since they hide themselves, however, they do no appear onscreen and they can't be clicked by the user. It's surprising that the IB setting and the hidden property are not equivalent, but it seems clear from the behavior that they are not.
This isn't the best solution, but it's the simplest workaround I've come up with (so far).
I had a quite similar problem.
I have 3 scrollviews to synchronize.
One that is a header that only scrolls horizontally.
One that is a side bar that only scrolls vertically.
One that is a content area below the header and to the right of the side bar.
The header and side bar should move with the content area.
The content area should move with the header or the side bar if either is scrolled.
Horizontal scrolling was never a problem.
Vertical scrolling was always causing the two views to scroll opposite directions.
The odd resolution I came to was to create a clipView subclass (which I already did, as you pretty much always need to if you want anything nice that doesn't come out of the box.)
In the clipView subclass, I add a property BOOL isInverted and in the override of isFlipped I return self.isInverted.
The weird thing is that these BOOL values for flippedness are set and match in all 3 views from the beginning.
It seems that scrolling machinery is indeed buggy.
My workaround that I stumbled upon was to sandwich the scroll synching code between calls to set both the side bar and content view unflipped and then update any vertical scrolling, then set both flipped again.
Must be some aging code in the scrolling machinery trying to support inverted scrolling...
These are the methods called by the NSNotificationCenter addObserver methods to observe the NSViewBoundsDidChangeNotification for the clipViews.
- (void)synchWithVerticalControlClipView:(NSNotification *)aNotification
{
NSPoint mouseInWindow = self.view.window.currentEvent.locationInWindow;
NSPoint converted = [self.verticalControl.enclosingScrollView convertPoint:mouseInWindow fromView:nil];
if (!NSPointInRect(converted, self.verticalControl.enclosingScrollView.bounds)) {
return;
}
[self.contentGridClipView setIsInverted:NO];
[self.verticalControlClipView setIsInverted:NO];
// ONLY update the contentGrid view.
NSLog(#"%#", NSStringFromSelector(_cmd));
NSPoint changedBoundsOrigin = self.verticalControlClipView.documentVisibleRect.origin;
NSPoint currentOffset = self.contentGridClipView.bounds.origin;
NSPoint newOffset = currentOffset;
newOffset.y = changedBoundsOrigin.y;
NSLog(#"\n changedBoundsOrigin=%#\n currentOffset=%#\n newOffset=%#", NSStringFromPoint(changedBoundsOrigin), NSStringFromPoint(currentOffset), NSStringFromPoint(newOffset));
[self.contentGridClipView scrollToPoint:newOffset];
[self.contentGridClipView.enclosingScrollView reflectScrolledClipView:self.contentGridClipView];
[self.contentGridClipView setIsInverted:YES];
[self.verticalControlClipView setIsInverted:YES];
}
- (void)synchWithContentGridClipView:(NSNotification *)aNotification
{
NSPoint mouseInWindow = self.view.window.currentEvent.locationInWindow;
NSPoint converted = [self.contentGridView.enclosingScrollView convertPoint:mouseInWindow fromView:nil];
if (!NSPointInRect(converted, self.contentGridView.enclosingScrollView.bounds)) {
return;
}
[self.contentGridClipView setIsInverted:NO];
[self.verticalControlClipView setIsInverted:NO];
// Update BOTH the control views.
NSLog(#"%#", NSStringFromSelector(_cmd));
NSPoint changedBoundsOrigin = self.contentGridClipView.documentVisibleRect.origin;
NSPoint currentHOffset = self.horizontalControlClipView.documentVisibleRect.origin;
NSPoint currentVOffset = self.verticalControlClipView.documentVisibleRect.origin;
NSPoint newHOffset, newVOffset;
newHOffset = currentHOffset;
newVOffset = currentVOffset;
newHOffset.x = changedBoundsOrigin.x;
newVOffset.y = changedBoundsOrigin.y;
[self.horizontalControlClipView scrollToPoint:newHOffset];
[self.verticalControlClipView scrollToPoint:newVOffset];
[self.horizontalControlClipView.enclosingScrollView reflectScrolledClipView:self.horizontalControlClipView];
[self.verticalControlClipView.enclosingScrollView reflectScrolledClipView:self.verticalControlClipView];
[self.contentGridClipView setIsInverted:YES];
[self.verticalControlClipView setIsInverted:YES];
}
This works 99% of the time, with only occasional jitter.
Horizontal scroll synch has no problems.
Swift 4 version which uses document view in auto-layout environment.
Based on Apple article Synchronizing Scroll Views with the difference that NSView.boundsDidChangeNotification temporary ignored on clip view when synchronising to other scroll view.
To hide vertical scroller reusable type InvisibleScroller is used.
File SynchronedScrollViewController.swift – View controllers with two scroll views.
class SynchronedScrollViewController: ViewController {
private lazy var leftView = TestView().autolayoutView()
private lazy var rightView = TestView().autolayoutView()
private lazy var leftScrollView = ScrollView(horizontallyScrolledDocumentView: leftView).autolayoutView()
private lazy var rightScrollView = ScrollView(horizontallyScrolledDocumentView: rightView).autolayoutView()
override func setupUI() {
view.addSubviews(leftScrollView, rightScrollView)
leftView.backgroundColor = .red
rightView.backgroundColor = .blue
contentView.backgroundColor = .green
leftScrollView.verticalScroller = InvisibleScroller()
leftView.setIntrinsicContentSize(CGSize(intrinsicHeight: 720)) // Some fake height
rightView.setIntrinsicContentSize(CGSize(intrinsicHeight: 720)) // Some fake height
}
override func setupHandlers() {
(leftScrollView.contentView as? ClipView)?.onBoundsDidChange = { [weak self] in
print("\(Date().timeIntervalSinceReferenceDate) : Left scroll view changed")
self?.syncScrollViews(origin: $0)
}
(rightScrollView.contentView as? ClipView)?.onBoundsDidChange = { [weak self] in
print("\(Date().timeIntervalSinceReferenceDate) : Right scroll view changed.")
self?.syncScrollViews(origin: $0)
}
}
override func setupLayout() {
LayoutConstraint.pin(to: .vertically, leftScrollView, rightScrollView).activate()
LayoutConstraint.withFormat("|[*(==40)]-[*]|", leftScrollView, rightScrollView).activate()
}
private func syncScrollViews(origin: NSClipView) {
// See also:
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/NSScrollViewGuide/Articles/SynchroScroll.html
let changedBoundsOrigin = origin.documentVisibleRect.origin
let targetScrollView = leftScrollView.contentView == origin ? rightScrollView : leftScrollView
let curOffset = targetScrollView.contentView.bounds.origin
var newOffset = curOffset
newOffset.y = changedBoundsOrigin.y
if curOffset != changedBoundsOrigin {
(targetScrollView.contentView as? ClipView)?.scroll(newOffset, shouldNotifyBoundsChange: false)
targetScrollView.reflectScrolledClipView(targetScrollView.contentView)
}
}
}
File: TestView.swift – Test view. Draws line every 20 points.
class TestView: View {
override init() {
super.init()
setIsFlipped(true)
}
override func setupLayout() {
needsDisplay = true
}
required init?(coder decoder: NSCoder) {
fatalError()
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let context = NSGraphicsContext.current else {
return
}
context.saveGraphicsState()
let cgContext = context.cgContext
cgContext.setStrokeColor(NSColor.white.cgColor)
for x in stride(from: CGFloat(20), through: bounds.height, by: 20) {
cgContext.addLines(between: [CGPoint(x: 0, y: x), CGPoint(x: bounds.width, y: x)])
NSString(string: "\(Int(x))").draw(at: CGPoint(x: 0, y: x), withAttributes: nil)
}
cgContext.strokePath()
context.restoreGraphicsState()
}
}
File: NSScrollView.swift - Reusable extension.
extension NSScrollView {
public convenience init(documentView view: NSView) {
let frame = CGRect(dimension: 10) // Some dummy non zero value
self.init(frame: frame)
let clipView = ClipView(frame: frame)
clipView.documentView = view
clipView.autoresizingMask = [.height, .width]
contentView = clipView
view.frame = frame
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = [.width, .height]
}
public convenience init(horizontallyScrolledDocumentView view: NSView) {
self.init(documentView: view)
contentView.setIsFlipped(true)
view.translatesAutoresizingMaskIntoConstraints = false
LayoutConstraint.pin(in: contentView, to: .horizontally, view).activate()
view.topAnchor.constraint(equalTo: contentView.topAnchor).activate()
hasVerticalScroller = true // Without this scroll might not work properly. Seems Apple bug.
}
}
File: InvisibleScroller.swift - Reusable invisible scroller.
// Disabling scroll view indicators.
// See: https://stackoverflow.com/questions/9364953/hide-scrollers-while-leaving-scrolling-itself-enabled-in-nsscrollview
public class InvisibleScroller: Scroller {
public override class var isCompatibleWithOverlayScrollers: Bool {
return true
}
public override class func scrollerWidth(for controlSize: NSControl.ControlSize, scrollerStyle: NSScroller.Style) -> CGFloat {
return CGFloat.leastNormalMagnitude // Dimension of scroller is equal to `FLT_MIN`
}
public override func setupUI() {
// Below assignments not really needed, but why not.
scrollerStyle = .overlay
alphaValue = 0
}
}
File: ClipView.swift - Customized subclass of NSClipView.
open class ClipView: NSClipView {
public var onBoundsDidChange: ((NSClipView) -> Void)? {
didSet {
setupBoundsChangeObserver()
}
}
private var boundsChangeObserver: NotificationObserver?
private var mIsFlipped: Bool?
open override var isFlipped: Bool {
return mIsFlipped ?? super.isFlipped
}
// MARK: -
public func setIsFlipped(_ value: Bool?) {
mIsFlipped = value
}
open func scroll(_ point: NSPoint, shouldNotifyBoundsChange: Bool) {
if shouldNotifyBoundsChange {
scroll(to: point)
} else {
boundsChangeObserver?.isActive = false
scroll(to: point)
boundsChangeObserver?.isActive = true
}
}
// MARK: - Private
private func setupBoundsChangeObserver() {
postsBoundsChangedNotifications = onBoundsDidChange != nil
boundsChangeObserver = nil
if postsBoundsChangedNotifications {
boundsChangeObserver = NotificationObserver(name: NSView.boundsDidChangeNotification, object: self) { [weak self] _ in
guard let this = self else { return }
self?.onBoundsDidChange?(this)
}
}
}
}
File: NotificationObserver.swift – Reusable Notification observer.
public class NotificationObserver: NSObject {
public typealias Handler = ((Foundation.Notification) -> Void)
private var notificationObserver: NSObjectProtocol!
private let notificationObject: Any?
public var handler: Handler?
public var isActive: Bool = true
public private(set) var notificationName: NSNotification.Name
public init(name: NSNotification.Name, object: Any? = nil, queue: OperationQueue = .main, handler: Handler? = nil) {
notificationName = name
notificationObject = object
self.handler = handler
super.init()
notificationObserver = NotificationCenter.default.addObserver(forName: name, object: object, queue: queue) { [weak self] in
guard let this = self else { return }
if this.isActive {
self?.handler?($0)
}
}
}
deinit {
NotificationCenter.default.removeObserver(notificationObserver, name: notificationName, object: notificationObject)
}
}
Result:

Resources