2 // AIAnimatingListOutlineView.m
5 // Created by Evan Schoenberg on 6/8/07.
8 #import "AIAnimatingListOutlineView.h"
9 #import "AIOutlineViewAnimation.h"
10 #import <Adium/AIObject.h>
12 #define DISABLE_ALL_ANIMATION FALSE
13 #define DISABLE_ANIMATE_EXPAND_AND_COLLAPSE TRUE
15 @interface AIAnimatingListOutlineView (PRIVATE)
16 - (NSRect)unanimatedRectOfRow:(int)rowIndex;
20 * @class AIAnimatingListOutlineView
21 * @brief An outline view which animates changes to its order
23 * Implementation inspired by Dan Wood's AnimatingTableView in TableTester, http://gigliwood.com/tabletester/
24 * Used with permission. AIAnimatingListOutlineView is licensed under the GPL, like Adium itself; Dan's tabletester code
25 * is BSD, with explicit double-licensing as GPL the parts used in this class.
27 @implementation AIAnimatingListOutlineView
29 #if !DISABLE_ALL_ANIMATION
31 - (void)_initAnimatingListOutlineView
33 allAnimatingItemsDict = [[NSMutableDictionary alloc] init];
35 animations = [[NSMutableSet alloc] init];
36 animationHedgeFactor = NSZeroSize;
37 enableAnimation = YES;
40 - (id)initWithCoder:(NSCoder *)aDecoder
42 if ((self = [super initWithCoder:aDecoder])) {
43 [self _initAnimatingListOutlineView];
49 - (id)initWithFrame:(NSRect)frame
51 if ((self = [super initWithFrame:frame])) {
52 [self _initAnimatingListOutlineView];
60 [animations makeObjectsPerformSelector:@selector(stopAnimation)];
63 [allAnimatingItemsDict release];
68 - (void)setEnableAnimation:(BOOL)shouldEnable
70 enableAnimation = shouldEnable;
73 - (BOOL)enableAnimation
75 return enableAnimation;
78 #pragma mark Rect determination
80 * @brief Return the current rect for an item at a given row
82 * This is the same as rectOfRow but is slightly faster if an NSValue pointer for the item is already known.
84 * @result The rect in which the row is currently displayed
86 - (NSRect)currentDisplayRectForItemPointer:(NSValue *)itemPointer atRow:(int)rowIndex
88 NSDictionary *animDict = [allAnimatingItemsDict objectForKey:itemPointer];
92 float progress = [[animDict objectForKey:@"progress"] floatValue];
93 NSRect oldR = [[animDict objectForKey:@"old rect"] rectValue];
94 NSRect newR = [self unanimatedRectOfRow:rowIndex];
96 //Calculate a rectangle between the original and the final rectangles.
97 rect = NSMakeRect(NSMinX(oldR) + (progress * (NSMinX(newR) - NSMinX(oldR))),
98 NSMinY(oldR) + (progress * (NSMinY(newR) - NSMinY(oldR))),
99 NSWidth(newR), NSHeight(newR) );
101 rect = [self unanimatedRectOfRow:rowIndex];
108 * @brief Return the current rect for a row
110 * If we're animating, this is somewhere between (progress % between) the old and new rects.
111 * If we're not, pass it to super.
113 * @result The rect in which the row is currently displayed
115 - (NSRect)rectOfRow:(int)rowIndex
117 if (animationsCount > 0) {
118 return [self currentDisplayRectForItemPointer:[NSValue valueWithPointer:[self itemAtRow:rowIndex]] atRow:rowIndex];
121 return [super rectOfRow:rowIndex];
126 * @brief What rows are in a given rect?
128 * When animating, the range has to be expanded to include rows which NSTableView would not expect to be in the rect
130 - (NSRange)rowsInRect:(NSRect)inRect
132 if (animationsCount > 0) {
133 //The rows in a given rect aren't necessarily sequential while we're animating. Too bad this doesn't return an NSIndexSet.
134 int count = [self numberOfRows];
135 NSRange range = NSMakeRange(0, count);
136 BOOL foundLowest = NO;
138 for (int i = 0; i < count; i++) {
139 NSRect rowRect = [self rectOfRow:i];
142 if (NSIntersectsRect(rowRect, inRect)) {
149 //Looking for the highest
150 if (NSIntersectsRect(rowRect, inRect)) {
151 //We need to reach here
152 if ((range.location + range.length) < i) {
153 range.length = i - range.location;
162 return [super rowsInRect:inRect];
167 * @brief Rect of the row if we weren't animating
169 * @result The rect in which the row would be displayed were all animations complete.
171 - (NSRect)unanimatedRectOfRow:(int)rowIndex
173 return [super rectOfRow:rowIndex];
176 #pragma mark Indexes, before and after
179 * @brief Return a dictionary of indexes keyed by pointers to items for item and all children
181 * This function uses itself recursively; when calling from outside, dict should be nil.
183 * @result The dictionary
185 - (NSMutableDictionary *)indexesForItemAndChildren:(id)item dict:(NSMutableDictionary *)dict
187 if (!dict) dict = [NSMutableDictionary dictionary];
188 if (!item || ([self isExpandable:item] &&
189 [self isItemExpanded:item])) {
190 int numChildren = [[self dataSource] outlineView:self numberOfChildrenOfItem:item];
192 for (int i = 0; i < numChildren; i++) {
193 id thisChild = [[self dataSource] outlineView:self child:i ofItem:item];
194 dict = [self indexesForItemAndChildren:thisChild dict:dict];
198 int index = [self rowForItem:item];
199 if (index != -1) [dict setObject:[NSNumber numberWithInt:index] forKey:[NSValue valueWithPointer:item]];
205 * @brief Create a dictionary of the current indexes, keyed by items, and configure before an animation starts
207 * Every row, regardles of whether it has changed (which we don't know yet), starts off at its current index ("old index")
208 * with a progress of 0% towards its new index.
210 * This is called before allowing super to perform an update.
212 * @result A dictionary of indexes keyed by pointers to items
214 - (NSDictionary *)saveCurrentIndexesForItem:(id)item
216 NSEnumerator *enumerator;
219 NSDictionary *oldDict = [self indexesForItemAndChildren:item dict:nil];
221 enumerator = [oldDict keyEnumerator];
222 while ((oldItem = [enumerator nextObject])) {
223 NSNumber *oldIndex = [oldDict objectForKey:oldItem];
224 [allAnimatingItemsDict setObject:[NSMutableDictionary dictionaryWithObjectsAndKeys:
225 oldIndex, @"old index",
226 oldIndex, @"new index", /* unchanged */
227 [NSValue valueWithRect:[self unanimatedRectOfRow:[oldIndex intValue]]], @"old rect",
228 [NSNumber numberWithFloat:0.0f], @"progress", nil]
238 * @brief Given old indexes, after an update has occurred, determine what needs to be animated
240 * Any item which is not at the same row as it was in oldDict has changed.
241 * allAnimatingItemsDict already has this item at 0% from the old row towards its new row.
243 * If the item has not changed, immediately set it to 100% progress.
245 * Finally, create and start an AIOutlineViewAnimation which will notify us as the animation progresses.
247 - (void)updateForNewIndexesFromOldIndexes:(NSDictionary *)oldDict forItem:(id)item recalculateHedge:(BOOL)recalculateHedge duration:(NSTimeInterval)duration
249 NSEnumerator *enumerator;
251 NSDictionary *newDict = [self indexesForItemAndChildren:item dict:nil];
252 NSMutableDictionary *animatingRowsDict = [NSMutableDictionary dictionary];
254 if (recalculateHedge) {
255 [self willChangeValueForKey:@"totalHeight"];
256 animationHedgeFactor = NSZeroSize;
259 //Compare differences
260 enumerator = [oldDict keyEnumerator];
261 while ((oldItem = [enumerator nextObject])) {
262 NSNumber *oldIndex = [oldDict objectForKey:oldItem];
263 NSNumber *newIndex = [newDict objectForKey:oldItem];
265 int oldIndexInt = [oldIndex intValue];
266 int newIndexInt = [newIndex intValue];
267 if (oldIndexInt != newIndexInt) {
268 [animatingRowsDict setObject:oldIndex
271 [[allAnimatingItemsDict objectForKey:oldItem] setObject:newIndex
272 forKey:@"new index"];
274 if (recalculateHedge) {
275 //If we're animating a row which will be starting off outside our bounds, set the hedge factor
276 if (oldIndexInt >= [self numberOfRows]) {
277 animationHedgeFactor.height += ([self currentDisplayRectForItemPointer:oldItem atRow:newIndexInt].size.height +
278 [self intercellSpacing].height);
282 [[allAnimatingItemsDict objectForKey:oldItem] setObject:[NSNumber numberWithFloat:1.0f]
287 //The item is no longer in the outline view
288 [allAnimatingItemsDict removeObjectForKey:oldItem];
292 if ([animatingRowsDict count]) {
293 AIOutlineViewAnimation *animation = [AIOutlineViewAnimation listObjectAnimationWithDictionary:animatingRowsDict
295 [animation setDuration:duration];
296 [animation startAnimation];
297 [animations addObject:animation];
300 //This was incremented in saveCurrentIndexesForItem:, but we didn't end up actually creating an animation for it
304 if (recalculateHedge)
305 [self didChangeValueForKey:@"totalHeight"];
308 #pragma mark AIOutlineViewAnimation callbacks
311 * @brief The animation for some rows (animatingRowsDict) has progressed
313 * Update the progress for those rows as tracked in allAnimatingItemsDict, then display.
315 - (void)animation:(AIOutlineViewAnimation *)animation didSetCurrentValue:(float)currentValue forDict:(NSDictionary *)animatingRowsDict
317 NSEnumerator *enumerator = [animatingRowsDict keyEnumerator];
318 NSValue *itemPointer;
319 float maxRequiredY = 0;
321 [self willChangeValueForKey:@"totalHeight"];
323 //Update progress for each item in animatingRowsDict
324 while ((itemPointer = [enumerator nextObject])) {
325 NSMutableDictionary *animDict = [allAnimatingItemsDict objectForKey:itemPointer];
326 int newIndex = [[animDict objectForKey:@"new index"] intValue];
327 NSRect oldFrame, newFrame;
329 //We'll need to redisplay the space we were in previously
330 oldFrame = [self currentDisplayRectForItemPointer:itemPointer
332 [self setNeedsDisplayInRect:oldFrame];
334 //Update the actual progress
335 [animDict setObject:[NSNumber numberWithFloat:currentValue]
338 //We'll need to redisplay after updating to the new location
339 newFrame = [self currentDisplayRectForItemPointer:itemPointer
341 [self setNeedsDisplayInRect:[self currentDisplayRectForItemPointer:itemPointer
344 //Track how much Y-space we're requiring at this point
345 if (NSMaxY(newFrame) > maxRequiredY) {
346 maxRequiredY = NSMaxY(newFrame);
350 //The hedge factor can now be updated to be minimal for the animation
351 if (maxRequiredY > [self totalHeight]) {
352 animationHedgeFactor.height = maxRequiredY - [self totalHeight];
353 } else if (maxRequiredY > [super totalHeight]) {
354 animationHedgeFactor.height = maxRequiredY - [super totalHeight];
356 animationHedgeFactor.height = 0;
359 [self didChangeValueForKey:@"totalHeight"];
363 * @brief Animation ended
365 - (void)animationDidEnd:(NSAnimation*)animation
368 if (animationsCount == 0) {
369 [self willChangeValueForKey:@"totalHeight"];
370 animationHedgeFactor = NSZeroSize;
371 [self didChangeValueForKey:@"totalHeight"];
374 [animation stopAnimation];
375 [animations removeObject:animation];
378 #pragma mark Intercepting changes so we can animate
382 if (enableAnimation) {
383 NSDictionary *oldDict = [self saveCurrentIndexesForItem:nil];
385 //If items are expanded or collapsed during reload, we don't want to animate that
386 disableExpansionAnimation = YES;
388 disableExpansionAnimation = NO;
390 [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:YES duration:LIST_OBJECT_ANIMATION_DURATION];
397 - (void)reloadItem:(id)item reloadChildren:(BOOL)reloadChildren
399 if (enableAnimation) {
400 NSDictionary *oldDict = [self saveCurrentIndexesForItem:item];
401 [super reloadItem:item reloadChildren:reloadChildren];
402 [self updateForNewIndexesFromOldIndexes:oldDict forItem:item recalculateHedge:YES duration:LIST_OBJECT_ANIMATION_DURATION];
405 [super reloadItem:item reloadChildren:reloadChildren];
409 - (void)reloadItem:(id)item
411 if (enableAnimation) {
412 NSDictionary *oldDict = [self saveCurrentIndexesForItem:item];
413 [super reloadItem:item];
414 [self updateForNewIndexesFromOldIndexes:oldDict forItem:item recalculateHedge:YES duration:LIST_OBJECT_ANIMATION_DURATION];
417 [super reloadItem:item];
421 #if !DISABLE_ANIMATE_EXPAND_AND_COLLAPSE
423 - (void)expandItem:(id)item
425 if (enableAnimation) {
426 if (!disableExpansionAnimation) {
427 NSDictionary *oldDict = [self saveCurrentIndexesForItem:nil];
428 [super expandItem:item];
430 [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:YES duration:EXPANSION_DURATION];
432 [super expandItem:item];
436 [super expandItem:item];
441 * @brief Collapse an item
443 * This one is a bit tricker. If the window or view will resize (using -[self totalHeight] as a guide) when the item is collapsed,
444 * it will cut off our animating-upward items in rows beneath it unless we set animationHedgeFactor to include
445 * the height of each row within item.
447 * As we animate, animationHedgeFactor will be decreased back toward 0.
449 - (void)collapseItem:(id)item
451 if (enableAnimation) {
452 if (!disableExpansionAnimation) {
453 NSDictionary *oldDict = [self saveCurrentIndexesForItem:nil];
455 [self willChangeValueForKey:@"totalHeight"];
457 //Maintain space for the animation to display
458 int numChildren = [[self dataSource] outlineView:self numberOfChildrenOfItem:item];
460 for (int i = 0; i < numChildren; i++) {
461 id thisChild = [[self dataSource] outlineView:self child:i ofItem:item];
462 animationHedgeFactor.height += [self currentDisplayRectForItemPointer:[NSValue valueWithPointer:thisChild]
463 atRow:[self rowForItem:thisChild]].size.height + [self intercellSpacing].height;
466 //Actually collapse the item
467 [super collapseItem:item];
469 [self didChangeValueForKey:@"totalHeight"];
471 //Now animate the movement
472 [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:NO duration:EXPANSION_DURATION];
474 [super collapseItem:item];
478 [super collapseItem:item];
484 #pragma mark Total height
487 * @brief Total height required by this view
489 * This is the only point of overlap with AIListOutlineView; otherwise, we are just an NSOutlineView subclass.
490 * Add the current animationHedgeFactor's height to whatever super says.
494 return [super totalHeight] + animationHedgeFactor.height;