synchronize two NSScrollView - macos

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:

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()
}
}

NSTableView pretend to be active

I have NSTextView and I want to show autocompletion options using NSTableView (like Xcode autocompletion). The problem is that when textView is the first responder, tableView is shown as unfocused (which is true), but I want to pretend that it's also active. Is there an easy way to achieve this (having firstResponder textView and tableView with active cell selection color)?
So I managed to resolve the issue with lots of experiments.
Here are my solution:
setup NSTableView:
tableView.refusesFirstResponder = true
tableView.selectionHighlightStyle = .none
on NSTableCellView subclass implement following code:
override func awakeFromNib() {
super.awakeFromNib()
registerNotifications()
}
deinit {
unregisterNotifications()
}
private func registerNotifications() {
NotificationCenter.default.addObserver(self,
selector: #selector(selectionIsChanging),
name: NSNotification.Name.NSTableViewSelectionIsChanging,
object: nil)
}
private func unregisterNotifications() {
NotificationCenter.default.removeObserver(self)
}
#objc private func selectionIsChanging() {
if let row = superview as? NSTableRowView, row.isSelected == true {
self.backgroundColor = NSColor.alternateSelectedControlColor
} else {
self.backgroundColor = NSColor.clear
}
}

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 show only Toolbar on NSWindow

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.

How to expand and collapse NSSplitView subviews with animation?

Is it possible to animate the collapsing and expanding of NSSplitView subviews? (I am aware of the availability of alternative classes, but would prefer using NSSplitView over having animations.)
I am using the method - (void)setPosition:(CGFloat)position ofDividerAtIndex:(NSInteger)dividerIndex to perform the collapsing and expanding.
After some more trying, I found the answer: yes, it's possible.
The code below shows how it can be done. The splitView is the NSSplitView which is vertically divided into mainView (on the left) and the inspectorView (on the right). The inspectorView is the one that collapses.
- (IBAction)toggleInspector:(id)sender {
if ([self.splitView isSubviewCollapsed:self.inspectorView]) {
// NSSplitView hides the collapsed subview
self.inspectorView.hidden = NO;
NSMutableDictionary *expandMainAnimationDict = [NSMutableDictionary dictionaryWithCapacity:2];
[expandMainAnimationDict setObject:self.mainView forKey:NSViewAnimationTargetKey];
NSRect newMainFrame = self.mainView.frame;
newMainFrame.size.width = self.splitView.frame.size.width-lastInspectorWidth;
[expandMainAnimationDict setObject:[NSValue valueWithRect:newMainFrame] forKey:NSViewAnimationEndFrameKey];
NSMutableDictionary *expandInspectorAnimationDict = [NSMutableDictionary dictionaryWithCapacity:2];
[expandInspectorAnimationDict setObject:self.inspectorView forKey:NSViewAnimationTargetKey];
NSRect newInspectorFrame = self.inspectorView.frame;
newInspectorFrame.size.width = lastInspectorWidth;
newInspectorFrame.origin.x = self.splitView.frame.size.width-lastInspectorWidth;
[expandInspectorAnimationDict setObject:[NSValue valueWithRect:newInspectorFrame] forKey:NSViewAnimationEndFrameKey];
NSViewAnimation *expandAnimation = [[NSViewAnimation alloc] initWithViewAnimations:[NSArray arrayWithObjects:expandMainAnimationDict, expandInspectorAnimationDict, nil]];
[expandAnimation setDuration:0.25f];
[expandAnimation startAnimation];
} else {
// Store last width so we can jump back
lastInspectorWidth = self.inspectorView.frame.size.width;
NSMutableDictionary *collapseMainAnimationDict = [NSMutableDictionary dictionaryWithCapacity:2];
[collapseMainAnimationDict setObject:self.mainView forKey:NSViewAnimationTargetKey];
NSRect newMainFrame = self.mainView.frame;
newMainFrame.size.width = self.splitView.frame.size.width;
[collapseMainAnimationDict setObject:[NSValue valueWithRect:newMainFrame] forKey:NSViewAnimationEndFrameKey];
NSMutableDictionary *collapseInspectorAnimationDict = [NSMutableDictionary dictionaryWithCapacity:2];
[collapseInspectorAnimationDict setObject:self.inspectorView forKey:NSViewAnimationTargetKey];
NSRect newInspectorFrame = self.inspectorView.frame;
newInspectorFrame.size.width = 0.0f;
newInspectorFrame.origin.x = self.splitView.frame.size.width;
[collapseInspectorAnimationDict setObject:[NSValue valueWithRect:newInspectorFrame] forKey:NSViewAnimationEndFrameKey];
NSViewAnimation *collapseAnimation = [[NSViewAnimation alloc] initWithViewAnimations:[NSArray arrayWithObjects:collapseMainAnimationDict, collapseInspectorAnimationDict, nil]];
[collapseAnimation setDuration:0.25f];
[collapseAnimation startAnimation];
}
}
- (BOOL)splitView:(NSSplitView *)splitView canCollapseSubview:(NSView *)subview {
BOOL result = NO;
if (splitView == self.splitView && subview == self.inspectorView) {
result = YES;
}
return result;
}
- (BOOL)splitView:(NSSplitView *)splitView shouldCollapseSubview:(NSView *)subview forDoubleClickOnDividerAtIndex:(NSInteger)dividerIndex {
BOOL result = NO;
if (splitView == self.splitView && subview == self.inspectorView) {
result = YES;
}
return result;
}
Here's a simpler method:
http://www.cocoabuilder.com/archive/cocoa/304317-animating-nssplitpane-position.html
(Link above dead, new link here.)
Which says create a category on NSSplitView as follows, and then animate with
[[splitView animator] setSplitPosition:pos];
Works for me.
Category:
#implementation NSSplitView (Animation)
+ (id)defaultAnimationForKey:(NSString *)key
{
if ([key isEqualToString:#"splitPosition"])
{
CAAnimation* anim = [CABasicAnimation animation];
anim.duration = 0.3;
return anim;
}
else
{
return [super defaultAnimationForKey:key];
}
}
- (void)setSplitPosition:(CGFloat)position
{
[self setPosition:position ofDividerAtIndex:0];
}
- (CGFloat)splitPosition
{
NSRect frame = [[[self subviews] objectAtIndex:0] frame];
if([self isVertical])
return NSMaxX(frame);
else
return NSMaxY(frame);
}
#end
For some reason none of the methods of animating frames worked for my scrollview.
I ended up creating a custom animation to animate the divider position. This ended up taking less time than I expected. If anyone is interested, here is my solution:
Animation .h:
#interface MySplitViewAnimation : NSAnimation
#property (nonatomic, strong) NSSplitView* splitView;
#property (nonatomic) NSInteger dividerIndex;
#property (nonatomic) float startPosition;
#property (nonatomic) float endPosition;
#property (nonatomic, strong) void (^completionBlock)();
- (instancetype)initWithSplitView:(NSSplitView*)splitView
dividerAtIndex:(NSInteger)dividerIndex
from:(float)startPosition
to:(float)endPosition
completionBlock:(void (^)())completionBlock;
#end
Animation .m
#implementation MySplitViewAnimation
- (instancetype)initWithSplitView:(NSSplitView*)splitView
dividerAtIndex:(NSInteger)dividerIndex
from:(float)startPosition
to:(float)endPosition
completionBlock:(void (^)())completionBlock;
{
if (self = [super init]) {
self.splitView = splitView;
self.dividerIndex = dividerIndex;
self.startPosition = startPosition;
self.endPosition = endPosition;
self.completionBlock = completionBlock;
[self setDuration:0.333333];
[self setAnimationBlockingMode:NSAnimationNonblocking];
[self setAnimationCurve:NSAnimationEaseIn];
[self setFrameRate:30.0];
}
return self;
}
- (void)setCurrentProgress:(NSAnimationProgress)progress
{
[super setCurrentProgress:progress];
float newPosition = self.startPosition + ((self.endPosition - self.startPosition) * progress);
[self.splitView setPosition:newPosition
ofDividerAtIndex:self.dividerIndex];
if (progress == 1.0) {
self.completionBlock();
}
}
#end
I'm using it like this - I have a 3 pane splitter view, and am moving the right pane in/out by a fixed amount (235).
- (IBAction)togglePropertiesPane:(id)sender
{
if (self.rightPane.isHidden) {
self.rightPane.hidden = NO;
[[[MySplitViewAnimation alloc] initWithSplitView:_splitView
dividerAtIndex:1
from:_splitView.frame.size.width
to:_splitView.frame.size.width - 235
completionBlock:^{
;
}] startAnimation];
}
else {
[[[MySplitViewAnimation alloc] initWithSplitView:_splitView
dividerAtIndex:1
from:_splitView.frame.size.width - 235
to:_splitView.frame.size.width
completionBlock:^{
self.rightPane.hidden = YES;
}] startAnimation];
}
}
There are a bunch of answers for this. In 2019, the best way to do this is to establish constraints on your SplitView panes, then animate the constraints.
Suppose I have a SplitView with three panes: leftPane, middlePane, rightPane. I want to not just collapse the two panes on the side, I want to also want to dynamically resize the widths of various panes when certain views come in or go out.
In IB, I set up a WIDTH constraint for each of the three panes. leftPane and rightPane have widths set to 250 with a priority of 1000 (required).
In code, it looks like this:
#class MyController: NSViewController
{
#IBOutlet var splitView: NSSplitView!
#IBOutlet var leftPane: NSView!
#IBOutlet var middlePane: NSView!
#IBOutlet var rightPane: NSView!
#IBOutlet var leftWidthConstraint: NSLayoutConstraint!
#IBOutlet var middleWidthConstraint: NSLayoutConstraint!
#IBOutlet var rightWidthConstraint: NSLayoutConstraint!
override func awakeFromNib() {
// We use these in our animation, but want them off normally so the panes
// can be resized as normal via user drags, window changes, etc.
leftWidthConstraint.isActive = false
middleWidthConstraint.isActive = false
rightWidthConstraint.isActive = false
}
func collapseRightPane()
{
NSAnimationContext.runAnimationGroup({ (context) in
context.allowsImplicitAnimation = true
context.duration = 0.15
rightWidthConstraint.constant = 0
rightWidthConstraint.isActive = true
// Critical! Call this in the animation block or you don't get animated changes:
splitView.layoutSubtreeIfNeeded()
}) { [unowned self] in
// We need to tell the splitView to re-layout itself before we can
// remove the constraint, or it jumps back to how it was before animating.
// This process tells the layout engine to recalculate and update
// the frames of everything based on current constraints:
self.splitView.needsLayout = true
self.splitView.needsUpdateConstraints = true
self.splitView.needsDisplay = true
self.splitView.layoutSubtreeIfNeeded()
self.splitView.displayIfNeeded()
// Now, disable the width constraint so we can resize the splitView
// via mouse, etc:
self.middleWidthConstraint.isActive = false
}
}
}
extension MyController: NSSplitViewDelegate
{
final func splitView(_ splitView: NSSplitView, canCollapseSubview subview: NSView) -> Bool
{
// Allow collapsing. You might set an iVar that you can control
// if you don't want the user to be able to drag-collapse. Set the
// ivar to false usually, but set it to TRUE in the animation block
// block, before changing the constraints, then back to false in
// in the animation completion handler.
return true
}
final func splitView(_ splitView: NSSplitView, shouldHideDividerAt dividerIndex: Int) -> Bool {
// Definitely do this. Nobody wants a crappy divider hanging out
// on the side of a collapsed pane.
return true
}
}
You can get more complex in this animation block. For example, you could decide that you want to collapse the right pane, but also enlarge the middle one to 500px at the same time.
The advantage to this approach over the others listed here is that it will automatically handle cases where the window's frame is not currently large enough to accommodate "expanding" a collapsed pane. Plus, you can use this to change the panes' sizes in ANY way, not just expanding and collapsing them. You can also have all those changes happen at once, in a smooth, combined animation.
Notes:
Obviously the views that make up leftPane, middlePane, and rightPane never change. Those are "containers" to which you add/remove other views as needed. If you remove the pane views from the SplitView, you'll destroy the constraints you set up in IB.
When using AutoLayout, if you find yourself setting frames manually, you're fighting the system. You set constraints; the autolayout engine sets frames.
The -setPosition:ofDividerAtIndex: approach does not work well when the splitView isn't big enough to set the divider where you want it to be. For example, if you want to UN-collapse a right-hand pane and give it 500 width, but your entire window is currently just 300 wide. This also gets messy if you need to resize multiple panes at once.
You can build on this approach to do more. For example, maybe you want to set minimum and maximum widths for various panes in the splitView. Do that with constraints, then change the constants of the min and max width constraint as needed (perhaps when different views come into each pane, etc).
CRITICAL NOTE:
This approach will fail if any subview in one of the panes has a width or minimumWidth constraint that has a priority of 1000. You'll get a "can't satisfy constraints" notice in the log. You'll need to make sure your subviews (and their child views, all the way down the hierarchy) don't have a width constraint set at 1000 priority. Use 999 or less for such constraints so that the splitView can always override them to collapse the view.
Solution for macOS 10.11.
Main points:
NSSplitViewItem.minimumThickness depends of NSSplitViewItem .viewController.view width/height, if not set explicitly.
NSSplitViewItem .viewController.view width/height depends of explicitly added constraints.
NSSplitViewItem (i.e. arranged subview of NSSplitView) can be fully collapsed, if it can reach Zero dimension (width or height).
So, we just need to deactivate appropriate constrains before animation and allow view to reach Zero dimension. After animation we just need to activate needed constraints.
class SplitViewAnimationsController: ViewController {
private lazy var toolbarView = StackView().autolayoutView()
private lazy var revealLeftViewButton = Button(title: "Left").autolayoutView()
private lazy var changeSplitOrientationButton = Button(title: "Swap").autolayoutView()
private lazy var revealRightViewButton = Button(title: "Right").autolayoutView()
private lazy var splitViewController = SplitViewController()
private lazy var viewControllerLeft = ContentViewController()
private lazy var viewControllerRight = ContentViewController()
private lazy var splitViewItemLeft = NSSplitViewItem(viewController: viewControllerLeft)
private lazy var splitViewItemRight = NSSplitViewItem(viewController: viewControllerRight)
private lazy var viewLeftWidth = viewControllerLeft.view.widthAnchor.constraint(greaterThanOrEqualToConstant: 100)
private lazy var viewRightWidth = viewControllerRight.view.widthAnchor.constraint(greaterThanOrEqualToConstant: 100)
private lazy var viewLeftHeight = viewControllerLeft.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 40)
private lazy var viewRightHeight = viewControllerRight.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 40)
private lazy var equalHeight = viewControllerLeft.view.heightAnchor.constraint(equalTo: viewControllerRight.view.heightAnchor, multiplier: 1)
private lazy var equalWidth = viewControllerLeft.view.widthAnchor.constraint(equalTo: viewControllerRight.view.widthAnchor, multiplier: 1)
override func loadView() {
super.loadView()
splitViewController.addSplitViewItem(splitViewItemLeft)
splitViewController.addSplitViewItem(splitViewItemRight)
contentView.addSubviews(toolbarView, splitViewController.view)
addChildViewController(splitViewController)
toolbarView.addArrangedSubviews(revealLeftViewButton, changeSplitOrientationButton, revealRightViewButton)
}
override func viewDidAppear() {
super.viewDidAppear()
splitViewController.contentView.setPosition(contentView.bounds.width * 0.5, ofDividerAt: 0)
}
override func setupDefaults() {
setIsVertical(true)
}
override func setupHandlers() {
revealLeftViewButton.setHandler { [weak self] in guard let this = self else { return }
self?.revealOrCollapse(this.splitViewItemLeft)
}
revealRightViewButton.setHandler { [weak self] in guard let this = self else { return }
self?.revealOrCollapse(this.splitViewItemRight)
}
changeSplitOrientationButton.setHandler { [weak self] in guard let this = self else { return }
self?.setIsVertical(!this.splitViewController.contentView.isVertical)
}
}
override func setupUI() {
splitViewController.view.translatesAutoresizingMaskIntoConstraints = false
splitViewController.contentView.dividerStyle = .thin
splitViewController.contentView.setDividerThickness(2)
splitViewController.contentView.setDividerColor(.green)
viewControllerLeft.contentView.backgroundColor = .red
viewControllerRight.contentView.backgroundColor = .blue
viewControllerLeft.contentView.wantsLayer = true
viewControllerRight.contentView.wantsLayer = true
splitViewItemLeft.canCollapse = true
splitViewItemRight.canCollapse = true
toolbarView.distribution = .equalSpacing
}
override func setupLayout() {
var constraints: [NSLayoutConstraint] = []
constraints += LayoutConstraint.Pin.InSuperView.horizontally(toolbarView, splitViewController.view)
constraints += [
splitViewController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
toolbarView.topAnchor.constraint(equalTo: splitViewController.view.bottomAnchor),
toolbarView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
]
constraints += [viewLeftWidth, viewLeftHeight, viewRightWidth, viewRightHeight]
constraints += [toolbarView.heightAnchor.constraint(equalToConstant: 48)]
NSLayoutConstraint.activate(constraints)
}
}
extension SplitViewAnimationsController {
private enum AnimationType: Int {
case noAnimation, `default`, rightDone
}
private func setIsVertical(_ isVertical: Bool) {
splitViewController.contentView.isVertical = isVertical
equalHeight.isActive = isVertical
equalWidth.isActive = !isVertical
}
private func revealOrCollapse(_ item: NSSplitViewItem) {
let constraintToDeactivate: NSLayoutConstraint
if splitViewController.splitView.isVertical {
constraintToDeactivate = item.viewController == viewControllerLeft ? viewLeftWidth : viewRightWidth
} else {
constraintToDeactivate = item.viewController == viewControllerLeft ? viewLeftHeight : viewRightHeight
}
let animationType: AnimationType = .rightDone
switch animationType {
case .noAnimation:
item.isCollapsed = !item.isCollapsed
case .default:
item.animator().isCollapsed = !item.isCollapsed
case .rightDone:
let isCollapsedAnimation = CABasicAnimation()
let duration: TimeInterval = 3 // 0.15
isCollapsedAnimation.duration = duration
item.animations = [NSAnimatablePropertyKey("collapsed"): isCollapsedAnimation]
constraintToDeactivate.isActive = false
setActionsEnabled(false)
NSAnimationContext.runImplicitAnimations(duration: duration, animations: {
item.animator().isCollapsed = !item.isCollapsed
}, completion: {
constraintToDeactivate.isActive = true
self.setActionsEnabled(true)
})
}
}
private func setActionsEnabled(_ isEnabled: Bool) {
revealLeftViewButton.isEnabled = isEnabled
revealRightViewButton.isEnabled = isEnabled
changeSplitOrientationButton.isEnabled = isEnabled
}
}
class ContentViewController: ViewController {
override func viewDidLayout() {
super.viewDidLayout()
print("frame: \(view.frame)")
}
}

Resources