I'm new to swift and Xcode: I'm trying to write a cocoa app and can't seem to figure out how to completely hide the contents of my NSWindow. I'm using Swift on Xcode Version 6.3.2; UI designed using storyboards.
Background: I'm designing an app where some controls are shown to the user in the window's toolbar. One of these controls is a disclosure button used for toggling the contents: clicking the button allows the user to expand the window contents underneath the toolbar to see some views with additional details. Clicking the disclosure button again collapses the window contents, hiding the views contained in the window and displaying only the toolbar again. When the app loads, only the toolbar should be shown - all of the window contents should be hidden, as they are irrelevant at this point.
In my WindowController.swift, I have:
class WindowController: NSWindowController, NSToolbarDelegate {
override func windowDidLoad() {
super.windowDidLoad()
window?.hideContents()
}
And I've defined an NSWindow extension; in NSWindow.swift I have:
private let TOOLBAR_WIDTH:CGFloat = 350.0
private let TOOLBAR_HEIGHT:CGFloat = 75.0
private let CONTENT_WIDTH:CGFloat = 800.0
private let CONTENT_HEIGHT:CGFloat = 600.0
public extension NSWindow {
private var screenHeight : CGFloat {
get {
return NSScreen.mainScreen()!.frame.height
}
}
public func hideContents() {
let view = contentView as! NSView
view.hidden = true
view.needsDisplay = true
collapseContentsFrame()
}
private func collapseContentsFrame() {
var newFrame = NSRect(x:frame.origin.x, y:screenHeight, width: TOOLBAR_WIDTH, height: TOOLBAR_HEIGHT )
setFrame( newFrame, display: false, animate: false )
}
public func showContents() {
let view = contentView as! NSView
view.hidden = false
view.needsDisplay = true
expandContentsFrame()
}
private func expandContentsFrame() {
var newFrame = NSRect(x: frame.origin.x, y: screenHeight, width: CONTENT_WIDTH, height: CONTENT_HEIGHT )
setFrame( newFrame, display: true, animate: false )
}
}
This sort of works: it hides the contents of the views contained in the window, and shrinks the frame almost to the same size as the toolbar. However, there is still a wide empty rectangle being shown underneath the toolbar - looks like the window's frame isn't resizing properly (can't post the image as I don't have permission). I've tried tweaking the values on the new frame, but I can't seem to eliminate that extra space shown underneath the toolbar.
In the storyboard the window contents are linked to a TabViewController - not sure if this has something to do with it. Any suggestions/insights would be greatly appreciated.
Thanks to Max and Lucas for their suggestions. I tried removing the content view, keeping a reference to it in my WindowController, and then adding it back again to the window whenever the window is expanded. While this solved the problem at hand, it created new problems for me: I programmatically invoke a segue from the toolbar, but if the window has no content view, this segue invocation crashes.
The solution that ended up working for me involves temporarily removing the constraints on the content view when the window contents are hidden, and then adding the constraints back again when the content view becomes visible. For reference (in case somebody else runs into this in the future), here's what I've done:
In my WindowController.swift:
class WindowController: NSWindowController, NSToolbarDelegate {
var mainWindow:Window {
get {
return window! as! Window
}
}
override func windowDidLoad() {
super.windowDidLoad()
mainWindow.hideContents()
}
}
In a custom NSWindow subclass:
import Cocoa
class Window : NSWindow {
private let TOOLBAR_WIDTH:CGFloat = 350.0
private let TOOLBAR_HEIGHT:CGFloat = 75.0
private let CONTENT_WIDTH:CGFloat = 800.0
private let CONTENT_HEIGHT:CGFloat = 600.0
private var constraints:[NSLayoutConstraint] = [NSLayoutConstraint]()
override init( contentRect: NSRect,
styleMask windowStyle: Int,
backing bufferingType: NSBackingStoreType,
defer deferCreation: Bool) {
super.init( contentRect: contentRect, styleMask: windowStyle, backing: bufferingType, defer: deferCreation )
}
required init?( coder: NSCoder ) {
super.init( coder: coder )
}
private var screenHeight : CGFloat {
get {
return NSScreen.mainScreen()!.frame.height
}
}
private var view : NSView {
get {
return contentView as! NSView
}
}
func hideContents() {
view.hidden = true
collapseContentsFrame()
removeConstraints()
}
private func collapseContentsFrame() {
var newFrame = NSRect(x:frame.origin.x, y:screenHeight, width: TOOLBAR_WIDTH, height: TOOLBAR_HEIGHT )
setFrame( newFrame, display: false, animate: false )
}
private func removeConstraints() {
constraints = view.constraints as! [NSLayoutConstraint]
for constraint in constraints {
view.removeConstraint( constraint )
}
}
func showContents() {
view.hidden = false
expandContentsFrame()
addConstraints()
}
private func expandContentsFrame() {
var newFrame = NSRect(x: frame.origin.x, y: screenHeight, width: CONTENT_WIDTH, height: CONTENT_HEIGHT )
setFrame( newFrame, display: true, animate: false )
}
private func addConstraints() {
for constraint in constraints {
view.addConstraint( constraint )
}
}
}
Then in my Main.storyboard, I select the Window node in the document outline and bring up the identity inspector (CMD + ALT + 3). Then I specified my "Window" class in the "Custom Class" field.
Related
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()
}
}
I have an NSPageController containing 8 or so NSViewControllers. I want to have a semi transparent bottom bar when the mouse is inside of the window, and a semi transparent top bar that persists no matter where the mouse is.
I add the top bar and bottom bar to the view, along with constraints in NSPageControllers viewDidLoad() method.
They show up fine on the first page, but when I start to transition from one page to another, the new NSViewController is redrawn over the overlaying views and they disappear. I can verify that they are under the NSViewControllers because then I drag all the way to a specific side I can see them underneath.
Any ideas why this is happening / how I can avoid it?
Code:
class MyPageController: NSPageController {
// MARK: - Properties
fileprivate var mouseIsInside = false
fileprivate var tabBar: TabBar!
// MARK: - NSViewController
override func viewDidLoad() {
super.viewDidLoad()
// add tab bar, then hide it (mouse in or outside of window will restore current state)
tabBar = TabBar(frame: NSRect(x: 0, y:0, width: view.frame.size.width, height: 40))
addTabBar(withAnimation: false)
removeTabBar(withAnimation: false)
NSLayoutConstraint.activate([
tabBar.heightAnchor.constraint(equalToConstant: 40),
tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
tabBar.centerXAnchor.constraint(equalTo: view.centerXAnchor),
tabBar.widthAnchor.constraint(equalTo: view.widthAnchor)
])
delegate = self
transitionStyle = .horizontalStrip
arrangedObjects = ["0","1","2","3","4","5","6","7"]
selectedIndex = 0
view.wantsLayer = true
// register for mouse events
let trackingArea = NSTrackingArea(rect: view.bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil)
view.addTrackingArea(trackingArea)
}
}
// NSPageController Delegate
extension PageController: NSPageControllerDelegate {
func pageController(_ pageController: NSPageController, frameFor object: Any?) -> NSRect {
return NSInsetRect(view.frame, -1, 0)
}
func pageController(_ pageController: NSPageController, identifierFor object: Any) -> String {
return (object as? String)!
}
func pageController(_ pageController: NSPageController, viewControllerForIdentifier identifier: String) -> NSViewController {
return ViewController(id: identifier)
}
func pageControllerWillStartLiveTransition(_ pageController: NSPageController) {
Swift.print("pageControllerWillStartLiveTransition")
addTabBar(withAnimation: false)
}
func pageControllerDidEndLiveTransition(_ pageController: NSPageController) {
pageController.completeTransition()
addTabBar(withAnimation: false)
Swift.print("pageControllerWillEndLiveTransition")
}
}
// tabBar functions
extension PageController {
func addTabBar(withAnimation shouldAnimate: Bool) {
view.addSubview(tabBar)
tabBar.frame.size.width = view.frame.size.width
if mouseIsInside {
tabBar.showWithAnimation(shouldAnimate)
}
}
func removeTabBar(withAnimation shouldAnimate: Bool) {
tabBar.hideWithAnimation(shouldAnimate)
}
}
// Mouse Movements
extension PageController {
override func mouseEntered(with event: NSEvent) {
mouseIsInside = true
addTabBar(withAnimation: true)
}
override func mouseExited(with event: NSEvent) {
mouseIsInside = false
removeTabBar(withAnimation: true)
}
}
Thanks in advance!
This appear to be a not resolved bug. This fixed for me:
If the pageController is fullfilling the window's view, set to nil the contentView's background. This way the background we'll see is always the background of the pageController.
Sort views in this method:
func pageControllerDidEndLiveTransition(_ pageController: NSPageController) {
let controller = selectedViewController as! (YOUR_NSVIEWCONTROLLER_CLASS)
controller.view.superview?.addSubview(controller.view, positioned: .below, relativeTo: TOOLBAR)
}
Replace YOUR_NSVIEWCONTROLLER_CLASS for your ViewControllerclass name, and then replace TOOLBAR for the view that you want to see on top.
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.
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.
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: