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