9

I've tried everything I can think of, including all the suggestions I've found here on SO and on other mailing lists, but I cannot figure out how to programmatically collapse an NSSplitView pane with an animation while Auto Layout is on.

Here's what I have right now (written in Swift for fun), but it falls down in multiple ways:

@IBAction func toggleSourceList(sender: AnyObject?) {
    let isOpen = !splitView.isSubviewCollapsed(sourceList.view.superview!)
    let position = (isOpen ? 0 : self.lastWidth)

    if isOpen {
        self.lastWidth = sourceList.view.frame.size.width
    }

    NSAnimationContext.runAnimationGroup({ context in
        context.allowsImplicitAnimation = true
        context.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)
        context.duration = self.duration

        self.splitView.setPosition(position, ofDividerAtIndex: 0)
    }, completionHandler: { () -> Void in
    })
}

The desired behavior and appearance is that of Mail.app, which animates really nicely.

I have a full example app available at https://github.com/mdiep/NSSplitViewTest.

mdiep
  • 373
  • 2
  • 8

8 Answers8

24

Objective-C:

[[splitViewItem animator] setCollapse:YES]

Swift:

splitViewItem.animator().collapsed = true

From Apple’s help:

Whether or not the child ViewController corresponding to the SplitViewItem is collapsed in the SplitViewController. The default is NO. This can be set with the animator proxy to animate the collapse or uncollapse. The exact animation used can be customized by setting it in the -animations dictionary with a key of "collapsed". If this is set to YES before it is added to the SplitViewController, it will be initially collapsed and the SplitViewController will not cause the view to be loaded until it is uncollapsed. This is KVC/KVO compliant and will be updated if the value changes from user interaction.

Vasily
  • 3,740
  • 3
  • 27
  • 61
  • 1
    Unfortunately, `NSSplitViewItem` is part of a new API on 10.10. If were able to use that, I wouldn't have needed to ask the question. :) – mdiep Nov 10 '14 at 14:21
  • 3
    I understand. But NSSplitViewItem is only available on 10.10 and later. – mdiep Nov 11 '14 at 21:03
  • I don't quite get how to `The exact animation used can be customized by setting it in the -animations dictionary`. I just want to set a duration, not start and end values. I mean I can set a custom `CABasicAnimation`, but all I want to do is modify its duration. Also what key do I animate in there? `alpha`, `frame`... ? –  Apr 08 '19 at 11:34
2

I was eventually able to figure this out with some help. I've transformed my test project into a reusable NSSplitView subclass: https://github.com/mdiep/MDPSplitView

mdiep
  • 373
  • 2
  • 8
1

For some reason none of the methods of animating frames worked for my scrollview. I didn't try animating the constraints though.

I ended up creating a custom animation to animate the divider position. If anyone is interested, here is my solution:

Animation .h:

@interface MySplitViewAnimation : NSAnimation <NSAnimationDelegate>

@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];
        [self setDelegate:self];
    }
    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];
    } 
}
Jeff Pearce
  • 343
  • 2
  • 7
  • This assumes that the splitView is large enough to let you subtract 235 pixels from the right side. Suppose your user has sized the window down to 200px wide: this code falls down because it tries to set the divider at (200 - 235 = -35). The frame of the splitview has to increase. – Bryan Mar 11 '19 at 18:55
1
/// Collapse the sidebar
    func collapsePanel(_ number: Int = 0){
        guard number < self.splitViewItems.count else {
            return
        }
        let panel = self.splitViewItems[number]

        if panel.isCollapsed {
            panel.animator().isCollapsed = false
        } else {
            panel.animator().isCollapsed = true
        }

    }
Duncan Groenewald
  • 8,496
  • 6
  • 41
  • 76
1

I will also add, because it took me quite a while to figure this out, that setting collapseBehavior = .useConstraints on your NSSplitViewItem (or items) may help immensely if you have lots of constraints defining the layouts of your subviews. My split view animations didn't look right until I did this. YMMV.

Ryan
  • 4,425
  • 2
  • 23
  • 12
0

If you're using Auto-Layout and you want to animate some aspect of the view's dimensions/position, you might have more luck animating the constraints themselves. I've had a quick go with an NSSplitView but have so far only met with limited success. I can get a split to expand and collapse following a button push, but I've ended up having to try to hack my way around loads of other problems caused by interfering with the constraints. In case your unfamiliar with it, here's a simple constraint animation:

- (IBAction)animate:(NSButton *)sender {
    /* Shrink view to invisible */

    NSLayoutConstraint *constraint = self.viewWidthConstraint;
    [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) {

        [[NSAnimationContext currentContext] setDuration:0.33];
        [[NSAnimationContext currentContext] setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault]];
        [[constraint animator] setConstant:0];

    } completionHandler:^{
        /* Do Some clean-up, if required */

    }];

Bear in mind you can only animate a constraints constant, you can't animate its priority.

Paul Patterson
  • 6,840
  • 3
  • 42
  • 56
0

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 can activate needed constraints again.

See my comment for SO question How to expand and collapse NSSplitView subviews with animation?.

Vlad
  • 6,402
  • 1
  • 60
  • 74
0

This is a solution that doesn't require any subclasses or categories, works without NSSplitViewController (which requires macOS 10.10+), supports auto layout, animates the views, and works on macOS 10.8+.

As others have suggested, the solution is to use an NSAnimationContext, but the trick is to set context.allowsImplicitAnimation = YES (Apple docs). Then just set the divider position as one would normally.

#import <Quartz/Quartz.h>
#import <QuartzCore/QuartzCore.h>

- (IBAction)toggleLeftPane:(id)sender
{
    [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) {
        context.allowsImplicitAnimation = YES;
        context.duration = 0.25; // seconds
        context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut];

        if ([self.splitView isSubviewCollapsed:self.leftPane]) {
            // -> expand
            [self.splitView setPosition:self.leftPane.frame.size.width ofDividerAtIndex:0];
        } else {
            // <- collapse
            _lastLeftPaneWidth = self.leftPane.frame.size.width;
            // optional: remember current width to restore to same size
            [self.splitView setPosition:0 ofDividerAtIndex:0];
        }

        [self.splitView layoutSubtreeIfNeeded];
    }];
}

Use auto layout to constrain the subviews (width, min/max sizes, etc.). Make sure to check "Core Animation Layer" in Interface Builder (i.e. set views to be layer backed) for the split view and all subviews — this is required for the transitions to be animated. (It will still work, but without animation.)

A full working project is available here: https://github.com/demitri/SplitViewAutoLayout.

Demitri
  • 13,134
  • 4
  • 40
  • 41
  • 1
    This works, unless you’re trying to expand the last pane of a split view while that splitview pane is collapsed. In that case, the Divider is already at the max X or max Y position of the splitview’s frame. If the splitview is small enough so that the remaining (uncollapsed) panes are at their minimum size, you can’t just set the divider—the splitview’s frame needs to be enlarged. – Bryan Mar 11 '19 at 18:45
  • @Bryan - yes, this is true. An excellent discussion about this when you want to expand a pane by resizing the window can be found in the WWDC 2013 session "Best Practices for Cocoa Animation", Session 213, here: https://developer.apple.com/videos/play/wwdc2013/213/ (starting at 39:20). – Demitri Mar 12 '19 at 19:36