* Fixed the objectSpecifier of `AIStatusItem`s to be based on the unique ID rather...
[adiumx.git] / Source / AIAnimatingListOutlineView.m
blobde8bb853b0b7a70a95007a8d49fb98bcb2a4dcc3
1 //
2 //  AIAnimatingListOutlineView.m
3 //  Adium
4 //
5 //  Created by Evan Schoenberg on 6/8/07.
6 //
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;
17 @end
19 /*!
20  * @class AIAnimatingListOutlineView
21  * @brief An outline view which animates changes to its order
22  *
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.
26  */
27 @implementation AIAnimatingListOutlineView
29 #if !DISABLE_ALL_ANIMATION
31 - (void)_initAnimatingListOutlineView
33         allAnimatingItemsDict  = [[NSMutableDictionary alloc] init];
34         animationsCount = 0;
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];
44         }
46     return self;
49 - (id)initWithFrame:(NSRect)frame
51     if ((self = [super initWithFrame:frame])) {
52                 [self _initAnimatingListOutlineView];
53         }
54         
55         return self;
58 - (void)dealloc
60         [animations makeObjectsPerformSelector:@selector(stopAnimation)];
61         [animations release];
63         [allAnimatingItemsDict release];
64         [super dealloc];
67 #pragma mark Enabling
68 - (void)setEnableAnimation:(BOOL)shouldEnable
70         enableAnimation = shouldEnable;
73 - (BOOL)enableAnimation
75         return enableAnimation;
78 #pragma mark Rect determination
79 /*!
80  * @brief Return the current rect for an item at a given row
81  *
82  * This is the same as rectOfRow but is slightly faster if an NSValue pointer for the item is already known.
83  *
84  * @result The rect in which the row is currently displayed
85  */
86 - (NSRect)currentDisplayRectForItemPointer:(NSValue *)itemPointer atRow:(int)rowIndex
88         NSDictionary *animDict = [allAnimatingItemsDict objectForKey:itemPointer];
89         NSRect rect;
91         if (animDict) {
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) );
100         } else {
101                 rect = [self unanimatedRectOfRow:rowIndex];
102         }
103         
104         return rect;    
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
114  */
115 - (NSRect)rectOfRow:(int)rowIndex
117         if (animationsCount > 0) {
118                 return [self currentDisplayRectForItemPointer:[NSValue valueWithPointer:[self itemAtRow:rowIndex]] atRow:rowIndex];
120         } else {
121                 return [super rectOfRow:rowIndex];
122         }
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
129  */
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;
137                 
138                 for (int i = 0; i < count; i++) {
139                         NSRect rowRect = [self rectOfRow:i];
141                         if (!foundLowest) {
142                                 if (NSIntersectsRect(rowRect, inRect)) {
143                                         foundLowest = YES;      
144                                 } else {
145                                         range.location += 1;
146                                         range.length -= 1;
147                                 }
148                         } else {
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;
154                                         }
155                                 }
156                         }
157                 }
158                 
159                 return range;
161         } else {
162                 return [super rowsInRect:inRect];
163         }
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.
170  */
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
184  */
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];
191                 //Add each child
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];
195                 }
196         }
198         int index = [self rowForItem:item];
199         if (index != -1) [dict setObject:[NSNumber numberWithInt:index] forKey:[NSValue valueWithPointer:item]];
201         return dict;
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.
211  * 
212  * @result A dictionary of indexes keyed by pointers to items
213  */
214 - (NSDictionary *)saveCurrentIndexesForItem:(id)item
216         NSEnumerator *enumerator;
217         id oldItem;
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]
229                                                                   forKey:oldItem];                      
230         }
231         
232         animationsCount++;
234         return oldDict;
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.
246  */
247 - (void)updateForNewIndexesFromOldIndexes:(NSDictionary *)oldDict forItem:(id)item recalculateHedge:(BOOL)recalculateHedge duration:(NSTimeInterval)duration
249         NSEnumerator *enumerator;
250         id oldItem;
251         NSDictionary *newDict = [self indexesForItemAndChildren:item dict:nil];
252         NSMutableDictionary *animatingRowsDict = [NSMutableDictionary dictionary];
253         
254         if (recalculateHedge) {
255                 [self willChangeValueForKey:@"totalHeight"];
256                 animationHedgeFactor = NSZeroSize;
257         }
259         //Compare differences
260         enumerator = [oldDict keyEnumerator];
261         while ((oldItem = [enumerator nextObject])) {
262                 NSNumber *oldIndex = [oldDict objectForKey:oldItem];
263                 NSNumber *newIndex = [newDict objectForKey:oldItem];
264                 if (newIndex) {
265                         int oldIndexInt = [oldIndex intValue];
266                         int newIndexInt = [newIndex intValue];
267                         if (oldIndexInt != newIndexInt) {
268                                 [animatingRowsDict setObject:oldIndex
269                                                                           forKey:oldItem];
270                                 
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);
279                                         }
280                                 }
281                         } else {
282                                 [[allAnimatingItemsDict objectForKey:oldItem] setObject:[NSNumber numberWithFloat:1.0f]
283                                                                                                                                  forKey:@"progress"];
284                         }
286                 } else {
287                         //The item is no longer in the outline view
288                         [allAnimatingItemsDict removeObjectForKey:oldItem];
289                 }
290         }
292         if ([animatingRowsDict count]) {
293                 AIOutlineViewAnimation *animation = [AIOutlineViewAnimation listObjectAnimationWithDictionary:animatingRowsDict
294                                                                                                                                                                                          delegate:self];
295                 [animation setDuration:duration];
296                 [animation startAnimation];
297                 [animations addObject:animation];
299         } else {
300                 //This was incremented in saveCurrentIndexesForItem:, but we didn't end up actually creating an animation for it
301                 animationsCount--;
302         }
303         
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.
314  */
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
331                                                                                                         atRow:newIndex];
332                 [self setNeedsDisplayInRect:oldFrame];
334                 //Update the actual progress
335                 [animDict setObject:[NSNumber numberWithFloat:currentValue]
336                                          forKey:@"progress"];
338                 //We'll need to redisplay after updating to the new location
339                 newFrame = [self currentDisplayRectForItemPointer:itemPointer
340                                                                                                         atRow:newIndex];
341                 [self setNeedsDisplayInRect:[self currentDisplayRectForItemPointer:itemPointer
342                                                                                                                                          atRow:newIndex]];
344                 //Track how much Y-space we're requiring at this point
345                 if (NSMaxY(newFrame) > maxRequiredY) {
346                         maxRequiredY = NSMaxY(newFrame);
347                 }
348         }
349         
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];
355         } else {
356                 animationHedgeFactor.height = 0;
357         }
359         [self didChangeValueForKey:@"totalHeight"];
363  * @brief Animation ended
364  */
365 - (void)animationDidEnd:(NSAnimation*)animation
367         animationsCount--;
368         if (animationsCount == 0) {
369                 [self willChangeValueForKey:@"totalHeight"];
370                 animationHedgeFactor = NSZeroSize;
371                 [self didChangeValueForKey:@"totalHeight"];
372         }
374         [animation stopAnimation];
375         [animations removeObject:animation];
378 #pragma mark Intercepting changes so we can animate
380 - (void)reloadData
382         if (enableAnimation) {
383                 NSDictionary *oldDict = [self saveCurrentIndexesForItem:nil];
384                 
385                 //If items are expanded or collapsed during reload, we don't want to animate that
386                 disableExpansionAnimation = YES;
387                 [super reloadData];
388                 disableExpansionAnimation = NO;
389                 
390                 [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:YES duration:LIST_OBJECT_ANIMATION_DURATION];
392         } else {
393                 [super reloadData];             
394         }
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];
404         } else {
405                 [super reloadItem:item reloadChildren:reloadChildren];
406         }
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];
416         } else {
417                 [super reloadItem:item];                
418         }
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];
429                         
430                         [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:YES duration:EXPANSION_DURATION];
431                 } else {
432                         [super expandItem:item];                
433                 }
435         } else {
436                 [super expandItem:item];
437         }
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.
448  */
449 - (void)collapseItem:(id)item
451         if (enableAnimation) {
452                 if (!disableExpansionAnimation) {
453                         NSDictionary *oldDict = [self saveCurrentIndexesForItem:nil];
454                         
455                         [self willChangeValueForKey:@"totalHeight"];
456                         
457                         //Maintain space for the animation to display
458                         int numChildren = [[self dataSource] outlineView:self numberOfChildrenOfItem:item];
459                         
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;
464                         }
465                         
466                         //Actually collapse the item
467                         [super collapseItem:item];
468                         
469                         [self didChangeValueForKey:@"totalHeight"];
470                         
471                         //Now animate the movement
472                         [self updateForNewIndexesFromOldIndexes:oldDict forItem:nil recalculateHedge:NO duration:EXPANSION_DURATION];
473                 } else {
474                         [super collapseItem:item];
475                 }
477         } else {
478                 [super collapseItem:item];
479         }
482 #endif
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.
491  */
492 - (int)totalHeight
494         return [super totalHeight] + animationHedgeFactor.height;
497 #endif
499 @end