How to expand and collapse NSSplitView subviews with animation?

前端 未结 5 2025
终归单人心
终归单人心 2020-12-23 17:50

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 ov

相关标签:
5条回答
  • 2020-12-23 18:25

    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;
    }
    
    0 讨论(0)
  • 2020-12-23 18:27

    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];
        } 
    }
    
    0 讨论(0)
  • 2020-12-23 18:31

    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
    
    0 讨论(0)
  • 2020-12-23 18:40

    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.

    0 讨论(0)
  • 2020-12-23 18:46

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

    0 讨论(0)
提交回复
热议问题