How to expand and collapse NSSplitView subviews wi

2020-02-16 20:16发布

问题:

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.

回答1:

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;
}


回答2:

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


回答3:

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];
    } 
}


回答4:

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:

  1. 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.
  2. When using AutoLayout, if you find yourself setting frames manually, you're fighting the system. You set constraints; the autolayout engine sets frames.
  3. 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.
  4. 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.



回答5:

Solution for macOS 10.11.

Main points:

  1. NSSplitViewItem.minimumThickness depends of NSSplitViewItem .viewController.view width/height, if not set explicitly.

  2. NSSplitViewItem .viewController.view width/height depends of explicitly added constraints.

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