From ea1516eeb991cedd2ea8d86d65fef4b102996b2b Mon Sep 17 00:00:00 2001 From: rowanbeentje Date: Mon, 23 Jul 2012 00:35:01 +0000 Subject: - Add a new SPSplitView class, intended to replace all BWSplitViews and so allow us to remove BWToolKit. Supports constraints and animated collapsible subviews configured in code, fixes crashes and exceptions if a window is closed while animations are taking place or scheduled to take place. - Replace the two vertical splitters in the table list (the filter splitter, and the table info splitter) with SPSplitView implementations as a test - Add a helper method in the new SPDateAdditions --- Source/SPSplitView.m | 1094 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1094 insertions(+) create mode 100644 Source/SPSplitView.m (limited to 'Source/SPSplitView.m') diff --git a/Source/SPSplitView.m b/Source/SPSplitView.m new file mode 100644 index 00000000..87392712 --- /dev/null +++ b/Source/SPSplitView.m @@ -0,0 +1,1094 @@ +// +// $Id$ +// +// SPSplitView.m +// sequel-pro +// +// Created by Rowan Beentje on April 25, 2012 +// Copyright (c) 2012 Rowan Beentje. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// More info at + +#import "SPSplitView.h" +#import "SPDateAdditions.h" + +@interface SPSplitView (Private_API) + +- (void)_initCustomProperties; +- (void)_ensureDefaultSubviewSizesToIndex:(NSUInteger)anIndex; + +- (NSArray *)_suggestedSizesForTargetSize:(CGFloat)targetSize respectingSpringsAndStruts:(BOOL)respectStruts respectingConstraints:(BOOL)respectConstraints; + +- (CGFloat)_startPositionOfView:(NSView *)aView; +- (CGFloat)_lengthOfView:(NSView *)aView; +- (void)_setStartPosition:(CGFloat)newOrigin ofView:(NSView *)aView; +- (void)_setLength:(CGFloat)newLength ofView:(NSView *)aView; + +- (BOOL)_isViewResizable:(NSView *)aView; +@end + +@interface SPSplitViewHelperView : NSView +{ + NSView *wrappedView; +} + +- (id)initReplacingView:(NSView *)aView; +- (void)restoreOriginalView; + +@end + +@interface SPSplitViewAnimationRetainCycleBypass : NSObject +{ + SPSplitView *parentSplitView; +} + +- (id)initWithParent:(SPSplitView *)aSplitView; +- (void)_animationStep:(NSTimer *)aTimer; + +@end + + +@implementation SPSplitView + +#pragma mark - +#pragma mark Setup and teardown + +- (id)initWithFrame:(NSRect)frameRect +{ + if ((self = [super initWithFrame:frameRect])) { + [self _initCustomProperties]; + } + return self; +} + +- (id)initWithCoder:(NSCoder *)coder +{ + if ((self = [super initWithCoder:coder])) { + [self _initCustomProperties]; + } + return self; +} + +- (void)awakeFromNib +{ + [super awakeFromNib]; + + delegate = [super delegate]; + [super setDelegate:self]; + + [self adjustSubviews]; + + [collapseToggleButton setState:(collapsibleSubviewCollapsed?NSOnState:NSOffState)]; +} + +- (void)dealloc +{ + [viewMinimumSizes release]; + [viewMaximumSizes release]; + + if (animationTimer) [animationTimer invalidate], [animationTimer release], animationTimer = nil; + if (animationRetainCycleBypassObject) [animationRetainCycleBypassObject release], animationRetainCycleBypassObject = nil; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [super dealloc]; +} + +#pragma mark - +#pragma mark Collapsible subview management + +/** + * Set the index of the collapsible subview; pass in NSNotFound as the index + * to unset an existing subview. + */ +- (void)setCollapsibleSubviewIndex:(NSUInteger)subviewIndex +{ + if (collapsibleSubviewIndex == subviewIndex) { + return; + } + + if (subviewIndex > [[self subviews] count]) { + [NSException raise:NSInternalInconsistencyException format:@"Specified a collpasible subview index which doesn't exist"]; + } + + // If an existing collapsible subview exists, and the view is collapsed, + // expand the old view before proceeding + if (collapsibleSubviewIndex != NSNotFound && collapsibleSubviewCollapsed) { + [self setCollapsibleSubviewCollapsed:NO animate:NO]; + } + + collapsibleSubviewIndex = subviewIndex; + [collapseToggleButton setState:NSOffState]; + collapsibleSubviewCollapsed = NO; +} + +/** + * Set a button which controls the state of the collapsible subview; if this is set, + * the button state will automatically be set as the subview collapses or expands. + * This can also be set using the IBOutlet. + */ +- (void)setToggleCollapseButton:(NSButton *)aButton +{ + collapseToggleButton = aButton; + [collapseToggleButton setState:(collapsibleSubviewCollapsed?NSOnState:NSOffState)]; +} + +/** + * Return whether the collapsible subview is collapsed or collapsing. + */ +- (BOOL)isCollapsibleSubviewCollapsed +{ + if (collapsibleSubviewIndex == NSNotFound) { + return NO; + } + + return collapsibleSubviewCollapsed; +} + +/** + * Return whether the specified subview is collapsed, overriding the original method for + * the collapsible subview set on this object; note that for the subview collapsible by + * this class, YES is returned only if the subview is fully collapsed, not when animating. + */ +- (BOOL)isSubviewCollapsed:(NSView *)subview +{ + NSUInteger subviewIndex = [[self subviews] indexOfObject:subview]; + if (collapsibleSubviewIndex == NSNotFound || subviewIndex != collapsibleSubviewIndex) { + return [super isSubviewCollapsed:subview]; + } + + return collapsibleSubviewCollapsed && !animationTimer; +} + +/** + * Toggle the collapse state, using animation. + */ +- (IBAction)toggleCollapse:(id)sender +{ + [self setCollapsibleSubviewCollapsed:!collapsibleSubviewCollapsed animate:YES]; +} + +/** + * Trigger a collapsible subview collapse or expand, optionally animating the transition. + * This is the master collapse/expand method, called by any other methods to perform the work. + */ +- (void)setCollapsibleSubviewCollapsed:(BOOL)shouldCollapse animate:(BOOL)shouldAnimate +{ + if (collapsibleSubviewIndex == NSNotFound || shouldCollapse == collapsibleSubviewCollapsed) { + return; + } + + collapsibleSubviewCollapsed = shouldCollapse; + [collapseToggleButton setState:(shouldCollapse?NSOnState:NSOffState)]; + + NSView *viewToAnimate = [[self subviews] objectAtIndex:collapsibleSubviewIndex]; + animationStartSize = [self _lengthOfView:viewToAnimate]; + + if (shouldCollapse) { + + // If collapsing, ensure the original view is wrapped in a helper view to avoid + // animation resizes of the contained view. (Uncollapses will already be wrapped.) + if (![viewToAnimate isMemberOfClass:[SPSplitViewHelperView class]]) { + [[[SPSplitViewHelperView alloc] initReplacingView:viewToAnimate] autorelease]; + viewToAnimate = [[self subviews] objectAtIndex:collapsibleSubviewIndex]; + } + + animationTargetSize = 0; + } else { + animationTargetSize = [self _lengthOfView:[[viewToAnimate subviews] objectAtIndex:0]]; + } + + // If not animating, update the view at once + if (!shouldAnimate) { + [self adjustSubviews]; + + // Otherwise, start an animation. + } else { + if (animationTimer) [animationTimer invalidate], [animationTimer release], animationTimer = nil; + if (animationRetainCycleBypassObject) [animationRetainCycleBypassObject release], animationRetainCycleBypassObject = nil; + animationStartTime = [NSDate monotonicTimeInterval]; + + // Determine the animation length, in seconds, starting with a quarter of a second + animationDuration = 0.25f; + + // Make it a slow animation if appropriate + if ([[NSApp currentEvent] type] == NSLeftMouseUp && [[NSApp currentEvent] modifierFlags] & NSShiftKeyMask) { + animationDuration *= 10; + } + + // Modify the duration by the proportion of any interrupted animation + CGFloat fullViewSize = [self _lengthOfView:[[viewToAnimate subviews] objectAtIndex:0]]; + if (shouldCollapse) { + animationDuration *= animationStartSize / fullViewSize; + } else { + animationDuration *= (animationTargetSize - animationStartSize) / fullViewSize; + } + + // Create an object to avoid NSTimer retain cycles + animationRetainCycleBypassObject = [[SPSplitViewAnimationRetainCycleBypass alloc] initWithParent:self]; + + // Start an animation at 30fps + animationTimer = [[NSTimer timerWithTimeInterval:(1.f/30.f) target:animationRetainCycleBypassObject selector:@selector(_animationStep:) userInfo:nil repeats:YES] retain]; + [[NSRunLoop currentRunLoop] addTimer:animationTimer forMode:NSRunLoopCommonModes]; + } +} + +#pragma mark - +#pragma mark Additional drag handle view + +/** + * Set an additional view, the frame rect of which will be used to provide an additional + * drag handle to reposition the *first* divider. + * This can also be set using the IBOutlet. + */ +- (void)setAdditionalDragHandleView:(NSView *)aView +{ + if ([aView window] != [self window]) { + [NSException raise:NSInternalInconsistencyException format:@"Additional drag handle must be in the same window as the split view"]; + } + + additionalDragHandleView = aView; +} + +#pragma mark - +#pragma mark Constraint management + +/** + * Set the minimum size of a view at the specified index. Note that indexes cannot be kept + * in sync with subsequent view deletions/additions, so these will continue to apply to the + * specified index and not the view originally at that index. + */ +- (void)setMinSize:(CGFloat)newMinSize ofSubviewAtIndex:(NSUInteger)subviewIndex +{ + [self _ensureDefaultSubviewSizesToIndex:subviewIndex]; + + // Verify against the max size + if (newMinSize > [[viewMaximumSizes objectAtIndex:subviewIndex] floatValue]) { + [NSException raise:NSInternalInconsistencyException format:@"Minimum size for a subview specified as larger than the maximum size"]; + } + + [viewMinimumSizes replaceObjectAtIndex:subviewIndex withObject:[NSNumber numberWithFloat:newMinSize]]; +} + +/** + * Set the minimum size of a view at the specified index. Note that indexes cannot be kept + * in sync with subsequent view deletions/additions, so these will continue to apply to the + * specified index and not the view originally at that index. + */ +- (void)setMaxSize:(CGFloat)newMaxSize ofSubviewAtIndex:(NSUInteger)subviewIndex +{ + [self _ensureDefaultSubviewSizesToIndex:subviewIndex]; + + // Verify against the max size + if (newMaxSize < [[viewMinimumSizes objectAtIndex:subviewIndex] floatValue]) { + [NSException raise:NSInternalInconsistencyException format:@"Maximum size for a subview specified as smaller than the minimum size"]; + } + + [viewMaximumSizes replaceObjectAtIndex:subviewIndex withObject:[NSNumber numberWithFloat:newMaxSize]]; +} + +#pragma mark - +#pragma mark Sizing + +/** + * adjustSubviews adjusts the sizes of the subviews so they fill up the splitview fully. + * With no constraints and no collapsible subviews, all the subviews are resized + * proportionally; however this override method handles constraints and collapsible subviews, + * as well as animating collapses when driven by a timer. + * + * When resizing starts, non-resizable subviews are first left at their default sizes, + * and other views are resized proportionally. If those views hit constraints set on the + * object via setMinSize: or setMaxSize:, the constraints are respected, and other views + * continue to be resized. + * + * If that resize process cannot cope with the size change, non-resizable subviews are + * resized, respecting constraints set via setMinSize: or setMaxSize:. + * + * If all constraints are hit, then resizing will start to exceed the constraints. + */ +- (void)adjustSubviews +{ + CGFloat totalAvailableSize = [self _lengthOfView:self]; + NSUInteger i, viewCount = [[self subviews] count]; + + // Amend the total length by non-hidden dividers + for (i = 0; i < viewCount - 1; i++) { + if (![self splitView:self shouldHideDividerAtIndex:i]) { + totalAvailableSize -= [self dividerThickness]; + } + } + + // Start by checking for valid sizes complying with all constraints + NSArray *viewSizes = [self _suggestedSizesForTargetSize:totalAvailableSize respectingSpringsAndStruts:YES respectingConstraints:YES]; + + // If that didn't produce a valid result, allow resizing of non-resizable views + if (!viewSizes) { + viewSizes = [self _suggestedSizesForTargetSize:totalAvailableSize respectingSpringsAndStruts:NO respectingConstraints:YES]; + } + + // If that still didn't produce a valid result, resort to resizing all views + if (!viewSizes) { + viewSizes = [self _suggestedSizesForTargetSize:totalAvailableSize respectingSpringsAndStruts:NO respectingConstraints:NO]; + } + + // Safety check + if ([viewSizes count] < [[self subviews] count]) { + [super adjustSubviews]; + return; + } + + CGFloat splitViewBreadth; + if ([self isVertical]) { + splitViewBreadth = [self frame].size.height; + } else { + splitViewBreadth = [self frame].size.width; + } + + // Apply the size changes to the views. + CGFloat originPosition = 0; + for (i = 0; i < viewCount; i++) { + NSView *eachSubview = [[self subviews] objectAtIndex:i]; + CGFloat viewSize = [[viewSizes objectAtIndex:i] floatValue]; + NSRect viewFrame = [eachSubview frame]; + + if ([self isVertical]) { + viewFrame.origin.x = roundf(originPosition); + viewFrame.size.width = roundf(viewSize); + viewFrame.size.height = splitViewBreadth; + } else { + viewFrame.origin.y = roundf(originPosition); + viewFrame.size.width = splitViewBreadth; + viewFrame.size.height = roundf(viewSize); + } + + [eachSubview setFrame:viewFrame]; + + originPosition += viewSize; + + if ((i + 1) < viewCount && ![self splitView:self shouldHideDividerAtIndex:(i + 1)]) { + originPosition += [self dividerThickness]; + } + } + + // Invalidate the cursor rects + [[self window] invalidateCursorRectsForView:self]; +} + +#pragma mark - +#pragma mark Delegate method overrides + +/** + * Handle requests to collapse a particular subview. If a subview is collapsible, + * by default this will return YES for that subview and NO for all others. + * The delegate can override this if necessary. + */ +- (BOOL)splitView:(NSSplitView *)splitView canCollapseSubview:(NSView *)subview +{ + if ([delegate respondsToSelector:@selector(splitView:canCollapseSubview:)]) { + return [delegate splitView:splitView canCollapseSubview:subview]; + } + + if (collapsibleSubviewIndex != NSNotFound && [[self subviews] objectAtIndex:collapsibleSubviewIndex] == subview) { + return YES; + } + + return NO; +} + +/** + * Handle requests as to whether a subview should be collapsed as a result of + * a double-click on a divider. If a subview is collapsible, by default this + * will return NO, but an animated collapse/expand will be triggered instead to + * perform the same action with animation. + * The delegate can override this if necessary. + */ +- (BOOL)splitView:(NSSplitView *)splitView shouldCollapseSubview:(NSView *)subview forDoubleClickOnDividerAtIndex:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:shouldCollapseSubview:forDoubleClickOnDividerAtIndex:)]) { + return [delegate splitView:splitView shouldCollapseSubview:subview forDoubleClickOnDividerAtIndex:dividerIndex]; + } + + // If there's no collapsible subview, don't allow collapse + if (collapsibleSubviewIndex == NSNotFound) { + return NO; + } + + // Ensure the divider is adjacent to the collapsible view + if ((NSUInteger)dividerIndex != collapsibleSubviewIndex && (NSUInteger)dividerIndex != (collapsibleSubviewIndex - 1)) { + return NO; + } + + // Trigger an animated collapse and prevent the original collapse + [self setCollapsibleSubviewCollapsed:YES animate:YES]; + return NO; +} + +/** + * While the collapsible subview is collapsed, hide the adjacent divider. + * + * Forwards requests on to the original delegate to allow overrides. + */ +- (BOOL)splitView:(NSSplitView *)splitView shouldHideDividerAtIndex:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:shouldHideDividerAtIndex:)]) { + return [delegate splitView:splitView shouldHideDividerAtIndex:dividerIndex]; + } + + // If there's no collapsible subview, or it's not hidden, don't hide any dividers + if (!collapsibleSubviewCollapsed || collapsibleSubviewIndex == NSNotFound) { + return NO; + } + + // Only hide one divider adjacent to the collapsible view + if ((collapsibleSubviewIndex == 0 && dividerIndex > 0) || (collapsibleSubviewIndex > 0 && (NSUInteger)(dividerIndex + 1) != collapsibleSubviewIndex)) { + return NO; + } + + // If the collapsible subview is fully collapsed, hide the divider + if (!animationTimer) { + return YES; + } + + return NO; +} + +/** + * Handle delegate requests for a minimum size for the splitview above or to the left of + * the supplied divider index, using the minimum constraints supplied via setMinSize: if + * present. + * + * Only the two views adjacent to the supplied divider index are currently considered. + * + * Forwards requests on to the original delegate to allow overrides. + */ +- (CGFloat)splitView:(NSSplitView *)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:constrainMinCoordinate:ofSubviewAt:)]) { + return [delegate splitView:splitView constrainMinCoordinate:proposedMinimumPosition ofSubviewAt:dividerIndex]; + } + + NSView *aView; + CGFloat preMinPosition = 0, postMaxPosition = 0; + + [self _ensureDefaultSubviewSizesToIndex:(dividerIndex + 1)]; + + // If the preceeding view is not resizable, treat it as fixed position + aView = [[self subviews] objectAtIndex:dividerIndex]; + if (![self _isViewResizable:aView]) { + preMinPosition = [self _startPositionOfView:aView] + [self _lengthOfView:aView]; + } else { + + // Check the minimum size of the preceeding view + CGFloat preMinSize = [[viewMinimumSizes objectAtIndex:dividerIndex] floatValue]; + if (preMinSize) { + preMinPosition = [self _startPositionOfView:aView] + preMinSize; + } + } + + // If the following view is not resizable, treat it as fixed position + aView = [[self subviews] objectAtIndex:(dividerIndex + 1)]; + if (![self _isViewResizable:aView]) { + postMaxPosition = [self _startPositionOfView:aView] - [self dividerThickness]; + } else { + + // Check the maximum size of the following view + CGFloat postMaxSize = [[viewMaximumSizes objectAtIndex:(dividerIndex + 1)] floatValue]; + if (postMaxSize != FLT_MAX) { + postMaxPosition = [self _startPositionOfView:aView] + [self _lengthOfView:aView] - postMaxSize - [self dividerThickness]; + } + } + + CGFloat suggestedMinimum = fmaxf(preMinPosition, postMaxPosition); + if (suggestedMinimum > proposedMinimumPosition) { + return suggestedMinimum; + } + + return proposedMinimumPosition; +} + +/** + * Handle delegate requests for a maximum size for the splitview above or to the left of + * the supplied divider index, using the maximum constraints supplied via setMaxSize: if + * present. + * + * Only the two views adjacent to the supplied divider index are currently considered. + * + * Forwards requests on to the original delegate to allow overrides. + */ +- (CGFloat)splitView:(NSSplitView *)splitView constrainMaxCoordinate:(CGFloat)proposedMaximumPosition ofSubviewAt:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:constrainMaxCoordinate:ofSubviewAt:)]) { + return [delegate splitView:splitView constrainMaxCoordinate:proposedMaximumPosition ofSubviewAt:dividerIndex]; + } + + NSView *aView; + CGFloat preMaxPosition = FLT_MAX, postMinPosition = FLT_MAX; + + [self _ensureDefaultSubviewSizesToIndex:(dividerIndex + 1)]; + + // If the preceeding view is not resizable, treat it as fixed position + aView = [[self subviews] objectAtIndex:dividerIndex]; + if (![self _isViewResizable:aView]) { + preMaxPosition = [self _startPositionOfView:aView] + [self _lengthOfView:aView]; + } else { + + // Check the maximum size of the preceeding view + CGFloat preMaxSize = [[viewMaximumSizes objectAtIndex:dividerIndex] floatValue]; + if (preMaxSize != FLT_MAX) { + preMaxPosition = [self _startPositionOfView:aView] + preMaxSize; + } + } + + // If the following view is not resizable, treat it as fixed position + aView = [[self subviews] objectAtIndex:(dividerIndex + 1)]; + if (![self _isViewResizable:aView]) { + postMinPosition = [self _startPositionOfView:aView] - [self dividerThickness]; + } else { + + // Check the minimum size of the following view + CGFloat postMinSize = [[viewMinimumSizes objectAtIndex:(dividerIndex + 1)] floatValue]; + if (postMinSize) { + postMinPosition = [self _startPositionOfView:aView] + [self _lengthOfView:aView] - postMinSize - [self dividerThickness]; + } + } + + CGFloat suggestedMaximum = fminf(preMaxPosition, postMinPosition); + if (suggestedMaximum < proposedMaximumPosition) { + return suggestedMaximum; + } + + return proposedMaximumPosition; +} + +/** + * If an additional drag handle is set - in the nib or in code - return its rect as an + * additional effective rect. + * + * Forwards requests on to the original delegate to allow overrides. + */ +- (NSRect)splitView:(NSSplitView *)splitView additionalEffectiveRectOfDividerAtIndex:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:additionalEffectiveRectOfDividerAtIndex:)]) { + return [delegate splitView:splitView additionalEffectiveRectOfDividerAtIndex:dividerIndex]; + } + + // If a view is set, return its frame in the splitview coordinate system + if (additionalDragHandleView) { + NSRect dragRect = [additionalDragHandleView frame]; + dragRect.origin = [self convertPoint:dragRect.origin fromView:[additionalDragHandleView superview]]; + return dragRect; + } + + return NSZeroRect; +} + +/** + * Listen to view resize delegate notifications, to track collapses triggered by dragging + * a view to zero size. + * + * Also forwards the event on to the delegate for further handling. + */ +- (void)splitViewDidResizeSubviews:(NSNotification *)notification +{ + + // If the collapsible subview was collapsed using (for example) a drag, + // track the collapse correctly. + if (collapsibleSubviewIndex != NSNotFound && !collapsibleSubviewCollapsed) { + if ([[[self subviews] objectAtIndex:collapsibleSubviewIndex] isHidden]) { + [[[self subviews] objectAtIndex:collapsibleSubviewIndex] setHidden:NO]; + [self setCollapsibleSubviewCollapsed:YES animate:NO]; + } + } + + // Do the same for expansions + if (collapsibleSubviewIndex != NSNotFound && collapsibleSubviewCollapsed) { + if (!animationTimer && [self _lengthOfView:[[self subviews] objectAtIndex:collapsibleSubviewIndex]]) { + [self setCollapsibleSubviewCollapsed:NO animate:NO]; + } + } + + if ([delegate respondsToSelector:@selector(splitViewDidResizeSubviews:)]) { + [delegate splitViewDidResizeSubviews:notification]; + } +} + +#pragma mark - +#pragma mark Delegate method forwarding + +- (CGFloat)splitView:(NSSplitView *)splitView constrainSplitPosition:(CGFloat)proposedPosition ofSubviewAt:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:constrainSplitPosition:ofSubviewAt:)]) { + return [delegate splitView:splitView constrainSplitPosition:proposedPosition ofSubviewAt:dividerIndex]; + } + + return proposedPosition; +} + +- (NSRect)splitView:(NSSplitView *)splitView effectiveRect:(NSRect)proposedEffectiveRect forDrawnRect:(NSRect)drawnRect ofDividerAtIndex:(NSInteger)dividerIndex +{ + if ([delegate respondsToSelector:@selector(splitView:effectiveRect:forDrawnRect:ofDividerAtIndex:)]) { + return [delegate splitView:splitView effectiveRect:proposedEffectiveRect forDrawnRect:drawnRect ofDividerAtIndex:dividerIndex]; + } + + return proposedEffectiveRect; +} + +- (BOOL)splitView:(NSSplitView *)splitView shouldAdjustSizeOfSubview:(NSView *)view +{ + if ([delegate respondsToSelector:@selector(splitView:shouldAdjustSizeOfSubview:)]) { + return [(id)delegate splitView:splitView shouldAdjustSizeOfSubview:view]; + } + + return YES; +} + +- (void)splitView:(NSSplitView *)splitView resizeSubviewsWithOldSize:(NSSize)oldSize +{ + if ([delegate respondsToSelector:@selector(splitView:resizeSubviewsWithOldSize:)]) { + return [delegate splitView:splitView resizeSubviewsWithOldSize:oldSize]; + } + + return [self adjustSubviews]; +} + +- (void)splitViewWillResizeSubviews:(NSNotification *)notification +{ + if ([delegate respondsToSelector:@selector(splitViewWillResizeSubviews:)]) { + [delegate splitViewWillResizeSubviews:notification]; + } +} + +@end + +#pragma mark - +#pragma mark Private API + +@implementation SPSplitView (Private_API) + +- (void)_initCustomProperties +{ + collapseToggleButton = nil; + additionalDragHandleView = nil; + + collapsibleSubviewIndex = NSNotFound; + collapsibleSubviewCollapsed = NO; + + animationStartTime = 0; + animationTimer = nil; + animationRetainCycleBypassObject = nil; + + // Set up the maximum and minimum length arrays. Note that because there are no + // notifications for subviews being removed, these cannot be kept in sync with the + // actual view count - so these are only set via index, not view, and length-checked + // on every use for safety. + NSUInteger l = [[self subviews] count]; + viewMinimumSizes = [[NSMutableArray alloc] initWithCapacity:l]; + viewMaximumSizes = [[NSMutableArray alloc] initWithCapacity:l]; + [self _ensureDefaultSubviewSizesToIndex:l-1]; +} + +/** + * Add default sizing information for a new subview up to at least a specified index; + * no maximum or minimum sizes for the subviews, but ensuring the arrays are set up. + */ +- (void)_ensureDefaultSubviewSizesToIndex:(NSUInteger)anIndex +{ + if ([viewMinimumSizes count] > anIndex) { + return; + } + + for (NSUInteger i = [viewMinimumSizes count]; i <= anIndex; i++) { + [viewMinimumSizes addObject:[NSNumber numberWithFloat:0]]; + [viewMaximumSizes addObject:[NSNumber numberWithFloat:FLT_MAX]]; + } +} + +#pragma mark - + +/** + * Generate an array of suggested view lengths along the split view lengthwise axis, + * respecting spring/strut or min/max size constraints as appropriate. + * If the supplied constraints cannot be respected, returns nil. + */ +- (NSArray *)_suggestedSizesForTargetSize:(CGFloat)targetSize respectingSpringsAndStruts:(BOOL)respectStruts respectingConstraints:(BOOL)respectConstraints +{ + NSUInteger i; + NSUInteger subviewCount = [[self subviews] count]; + NSView *eachSubview; + BOOL viewIsAnimating; + float viewLength, sizeDifference, totalGive, changedLength; + float totalCurrentSize = 0; + float resizeProportionTotal = 1.f; + float *originalSizes = malloc(subviewCount * sizeof(float)); + float *minSizes = malloc(subviewCount * sizeof(float)); + float *maxSizes = malloc(subviewCount * sizeof(float)); + BOOL *sizesCalculated; + float *resizeProportions; + NSMutableArray *outputSizes = [NSMutableArray arrayWithCapacity:subviewCount]; + + [self _ensureDefaultSubviewSizesToIndex:(subviewCount + 1)]; + + // Step through all the views, first getting a list of their initial sizes, as well as + // performing any animation-related cleanup + for (i = 0; i < subviewCount; i++) { + eachSubview = [[self subviews] objectAtIndex:i]; + viewLength = [self _lengthOfView:eachSubview]; + viewIsAnimating = (i == collapsibleSubviewIndex && animationTimer); + + // Determine the min and max sizes for this view. + if (i == collapsibleSubviewIndex && collapsibleSubviewCollapsed && !viewIsAnimating) { + minSizes[i] = 0.f; + maxSizes[i] = 0.f; + } else if (i == collapsibleSubviewIndex && !viewLength && animationTargetSize && [eachSubview isKindOfClass:[SPSplitViewHelperView class]]) { + minSizes[i] = animationTargetSize; + maxSizes[i] = animationTargetSize; + } else if (respectStruts && ![self _isViewResizable:eachSubview]) { + minSizes[i] = viewLength; + maxSizes[i] = viewLength; + } else if (respectConstraints) { + minSizes[i] = [[viewMinimumSizes objectAtIndex:i] floatValue]; + maxSizes[i] = [[viewMaximumSizes objectAtIndex:i] floatValue]; + } else { + minSizes[i] = 0.f; + maxSizes[i] = FLT_MAX; + } + + // If this isn't the collapsible subview, or if there's no collapse animation, measure + // the view and continue. + if (!viewIsAnimating) { + originalSizes[i] = viewLength; + totalCurrentSize += viewLength; + [outputSizes addObject:[NSNumber numberWithFloat:viewLength]]; + continue; + } + + // The collapsible subview is collapsing or uncollapsing. Prepare to update the sizes... + double currentTime = [NSDate monotonicTimeInterval]; + float animationProgress = (float)((currentTime - animationStartTime) / animationDuration); + if (animationProgress > 1) animationProgress = 1; + + // If the animation has reached the end, ensure completion tasks are run + if (animationProgress == 1) { + if (animationTimer) [animationTimer invalidate], [animationTimer release], animationTimer = nil; + if (animationRetainCycleBypassObject) [animationRetainCycleBypassObject release], animationRetainCycleBypassObject = nil; + + // If uncollapsing, restore the original view and remove the helper + if (!collapsibleSubviewCollapsed) { + [(SPSplitViewHelperView *)eachSubview restoreOriginalView]; + } + } + + // Calculate the size for this point in the animation + if (collapsibleSubviewCollapsed) { + viewLength = animationStartSize * (1 - animationProgress); + } else { + viewLength = animationStartSize + ((animationTargetSize - animationStartSize) * animationProgress); + } + viewLength = roundf(viewLength); + + // Max and min should always be clamped to the animated view size + minSizes[i] = viewLength; + maxSizes[i] = viewLength; + + // Insert the modified view size + totalCurrentSize += viewLength; + originalSizes[i] = viewLength; + [outputSizes addObject:[NSNumber numberWithFloat:viewLength]]; + } + + sizeDifference = targetSize - totalCurrentSize; + + // Compare the min/max lengths to the target length and see if there's sufficient give + // as well as working out the resize proportions + totalGive = 0; + for (i = 0; i < subviewCount; i++) { + if (sizeDifference > 0) { + if (maxSizes[i] == FLT_MAX) { + totalGive = FLT_MAX; + break; + } + totalGive += maxSizes[i] - originalSizes[i]; + } else { + totalGive += originalSizes[i] - minSizes[i]; + } + } + + // If there isn't sufficient give, return nil to allow a retry with fewer constraints + if (totalGive < fabsf(sizeDifference)) { + free(originalSizes); + free(minSizes); + free(maxSizes); + return nil; + } + + // Set up some arrays for fast lookups + sizesCalculated = malloc(subviewCount * sizeof(BOOL)); + resizeProportions = malloc(subviewCount * sizeof(float)); + + // Prepopulate them + for (i = 0; i < subviewCount; i++) { + sizesCalculated[i] = NO; + if (!totalCurrentSize) { + resizeProportions[i] = 0.f; + } else { + resizeProportions[i] = originalSizes[i] / totalCurrentSize; + } + } + + // In a loop, determine whether any constraints would be hit, and if so, match them + // and update remaining proportions. + BOOL iteratingConstraints = YES; + while (iteratingConstraints) { + iteratingConstraints = NO; + for (i = 0; i < subviewCount; i++) { + if (sizesCalculated[i]) continue; + + // Check whether the size constraints are reached for this view. If so, record the + // limited view size, and break the loop. + viewLength = originalSizes[i] + (sizeDifference * resizeProportions[i]/resizeProportionTotal); + if (viewLength > maxSizes[i] || viewLength < minSizes[i]) { + + // Track the change in size, if any + if (viewLength > maxSizes[i]) { + changedLength = maxSizes[i]; + } else { + changedLength = minSizes[i]; + } + sizeDifference = sizeDifference + originalSizes[i] - changedLength; + + // Alter the overall proportion total modifier + resizeProportionTotal -= resizeProportions[i]; + + // Amend the output size and prepare to re-loop from the start + [outputSizes replaceObjectAtIndex:i withObject:[NSNumber numberWithFloat:changedLength]]; + sizesCalculated[i] = YES; + iteratingConstraints = YES; + break; + } + } + + // If, after any changes, all the remaining subview proportions are 0, resize + // them equally. + BOOL allSubviewsZeroSized = YES; + for (i = 0; i < subviewCount; i++) { + if (sizesCalculated[i]) continue; + + if (resizeProportions[i] > 0.f) { + allSubviewsZeroSized = NO; + break; + } + } + if (allSubviewsZeroSized) { + resizeProportionTotal = 1.f; + for (i = 0; i < subviewCount; i++) { + if (sizesCalculated[i]) continue; + resizeProportions[i] = 1.f / subviewCount; + } + } + } + + // All constraints have now been dealt with; populate all other output sizes proportionally. + for (i = 0; i < subviewCount; i++) { + if (sizesCalculated[i]) continue; + + viewLength = originalSizes[i] + (sizeDifference * resizeProportions[i]/resizeProportionTotal); + [outputSizes replaceObjectAtIndex:i withObject:[NSNumber numberWithFloat:viewLength]]; + } + + // Clean up and return + free(originalSizes); + free(minSizes); + free(maxSizes); + free(sizesCalculated); + free(resizeProportions); + + return outputSizes; +} + +#pragma mark - + +/** + * Retrieve the start position of a view, using the lengthwise axis of the splitview. + */ +- (CGFloat)_startPositionOfView:(NSView *)aView +{ + if ([self isVertical]) { + return [aView frame].origin.x; + } + return [aView frame].origin.y; +} + +/** + * Retrieve the length of a view, along the lengthwise axis of the splitview. + */ +- (CGFloat)_lengthOfView:(NSView *)aView +{ + if ([self isVertical]) { + return [aView frame].size.width; + } + return [aView frame].size.height; +} + +/** + * Update the start position of a view, using the lengthwise axis of the splitview. + */ +- (void)_setStartPosition:(CGFloat)newOrigin ofView:(NSView *)aView +{ + if ([self isVertical]) { + return [aView setFrameOrigin:NSMakePoint(newOrigin, [aView frame].origin.y)]; + } + return [aView setFrameOrigin:NSMakePoint([aView frame].origin.x, newOrigin)]; +} + +/** + * Update the length of a view, along the lengthwise axis of the splitview. + */ +- (void)_setLength:(CGFloat)newLength ofView:(NSView *)aView +{ + if ([self isVertical]) { + return [aView setFrameSize:NSMakeSize(newLength, [aView frame].size.height)]; + } + return [aView setFrameSize:NSMakeSize([aView frame].size.width, newLength)]; +} + +#pragma mark - + +/** + * Determine whether the supplied view is defined as resizable along the split view's + * lengthwise axis in the xib files - whether springs/struts constrain resizing. + */ +- (BOOL)_isViewResizable:(NSView *)aView +{ + if ([self isVertical]) { + return ([aView autoresizingMask] & NSViewWidthSizable); + } + return ([aView autoresizingMask] & NSViewHeightSizable); +} + +@end + +#pragma mark - +#pragma mark Animation transition view class + +@implementation SPSplitViewHelperView + +/** + * Initialise the helper view with a specified view; the helper view replaces the + * specified view, adding it as a subview to maintain the same appearance, and then + * can be animated without affecting the contained view. + */ +- (id)initReplacingView:(NSView *)aView +{ + self = [super initWithFrame:[aView frame]]; + if (!self) return nil; + + // Retain the wrapped view while this view exists + wrappedView = [aView retain]; + + // Copy over the autoresizing mask from the wrapped view to this view, to keep the same + // draw appearance during the resize. + [self setAutoresizingMask:[wrappedView autoresizingMask]]; + + // Set the wrapped view to flexible margin, edge dependent on view ordering + if ([[[wrappedView superview] subviews] indexOfObject:wrappedView]) { + [wrappedView setAutoresizingMask:NSViewMinXMargin | NSViewMinYMargin]; + } else { + [wrappedView setAutoresizingMask:NSViewMaxXMargin | NSViewMaxYMargin]; + } + + // Swap the views + [[wrappedView superview] replaceSubview:wrappedView with:self]; + [wrappedView setFrameOrigin:NSMakePoint(0, 0)]; + [self addSubview:wrappedView]; + + return self; +} + +/** + * Restore the original view once the animation is complete. This should only + * be called when the view height has been restored. + */ +- (void)restoreOriginalView +{ + + // Safety checks + if (!wrappedView || ![self frame].size.height || ![self frame].size.width) { + return; + } + + // Check for a first responder to restore, using the "true" first responder for field editors + NSResponder *firstResponderToRestore = [[self window] firstResponder]; + if ([firstResponderToRestore respondsToSelector:@selector(isFieldEditor)] && [(NSText *)firstResponderToRestore isFieldEditor]) { + firstResponderToRestore = [(NSText *)firstResponderToRestore delegate]; + } + if (![firstResponderToRestore isKindOfClass:[NSView class]] || ![(NSView *)firstResponderToRestore isDescendantOf:wrappedView]) { + firstResponderToRestore = nil; + } + + // Restore the view's original resize mark now that the size changes are complete + [wrappedView setAutoresizingMask:[self autoresizingMask]]; + + // Replace this view with the original wrapped view + [wrappedView removeFromSuperview]; + [[self superview] replaceSubview:self with:wrappedView]; + + // Restore the first responder if appropriate + if (firstResponderToRestore) { + [[self window] makeFirstResponder:firstResponderToRestore]; + } + + [wrappedView release], wrappedView = nil; +} + +- (void)dealloc +{ + if (wrappedView) [wrappedView release], wrappedView = nil; + + [super dealloc]; +} + +@end + +#pragma mark - +#pragma mark Retain cycle avoidance class + +@implementation SPSplitViewAnimationRetainCycleBypass + +- (id)initWithParent:(SPSplitView *)aSplitView +{ + self = [super init]; + if (!self) return nil; + + // Keep a weak link to the parent + parentSplitView = aSplitView; + + return self; +} + +- (void)_animationStep:(NSTimer *)aTimer +{ + [parentSplitView adjustSubviews]; +} + +@end + -- cgit v1.2.3