TableViewPlayground/ATComplexOutlineController.m
/* |
Copyright (C) 2017 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
The main controller for the "Complex Outline View" example window. |
*/ |
#import "ATComplexOutlineController.h" |
#import "ATTableCellView.h" |
#import "ATColorView.h" |
#import "ATDesktopEntity.h" |
@interface ATComplexOutlineController () |
@property (strong) ATDesktopFolderEntity *rootContents; |
@property (weak) IBOutlet NSOutlineView *outlineView; |
@property (weak) IBOutlet NSPathControl *pathCtrlRootDirectory; |
@property (strong) IBOutlet NSDateFormatter *sharedDateFormatter; |
@property (strong) ATDesktopEntity *itemBeingDragged; |
@end |
#pragma mark - |
@implementation ATComplexOutlineController |
- (NSString *)windowNibName { |
return @"ATComplexOutlineWindow"; |
} |
- (void)windowDidLoad { |
[super windowDidLoad]; |
// Setup our content to be the contents of the Desktop Pictures folder. |
NSURL *picturesURL = |
[[NSFileManager defaultManager] URLsForDirectory:NSLibraryDirectory inDomains:NSLocalDomainMask].lastObject; |
picturesURL = [picturesURL URLByAppendingPathComponent:@"Desktop Pictures"]; |
_rootContents = [[ATDesktopFolderEntity alloc] initWithFileURL:picturesURL]; |
[self.outlineView reloadData]; |
[self.outlineView registerForDraggedTypes:@[(id)kUTTypeURL]]; |
[self.outlineView setDraggingSourceOperationMask:NSDragOperationEvery forLocal:NO]; |
} |
#pragma mark - NSOutlineView |
- (NSInteger)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item { |
if (item == nil) { |
return self.rootContents.children.count; |
} else if ([item isKindOfClass:[ATDesktopFolderEntity class]]) { |
return ((ATDesktopFolderEntity *)item).children.count; |
} else { |
return 0; |
} |
} |
- (id)outlineView:(NSOutlineView *)outlineView child:(NSInteger)index ofItem:(id)item { |
if (item == nil) { |
return (self.rootContents.children)[index]; |
} else { |
return (((ATDesktopFolderEntity *)item).children)[index]; |
} |
} |
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item { |
return [item isKindOfClass:[ATDesktopFolderEntity class]]; |
} |
- (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item { |
// Every regular view uses bindings to the item. The "Date Cell" needs to have the date extracted from the fileURL |
if ([tableColumn.identifier isEqualToString:@"DateCell"]) { |
id dateValue; |
if ([[item fileURL] getResourceValue:&dateValue forKey:NSURLContentModificationDateKey error:nil]) { |
return dateValue; |
} else { |
return nil; |
} |
} |
return item; |
} |
- (BOOL)outlineView:(NSOutlineView *)outlineView isGroupItem:(id)item { |
return [item isKindOfClass:[ATDesktopFolderEntity class]]; |
} |
- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item { |
if ([item isKindOfClass:[ATDesktopFolderEntity class]]) { |
// Everything is setup in bindings |
return [outlineView makeViewWithIdentifier:@"GroupCell" owner:self]; |
} else { |
NSView *result = [outlineView makeViewWithIdentifier:tableColumn.identifier owner:self]; |
if ([result isKindOfClass:[ATTableCellView class]]) { |
ATTableCellView *cellView = (ATTableCellView *)result; |
// setup the color; we can't do this in bindings |
cellView.colorView.drawBorder = YES; |
cellView.colorView.backgroundColor = [item fillColor]; |
} |
// Use a shared date formatter on the DateCell for better performance. Otherwise, it is encoded in every NSTextField |
if ([tableColumn.identifier isEqualToString:@"DateCell"]) { |
[(id)result setFormatter:self.sharedDateFormatter]; |
} |
return result; |
} |
return nil; |
} |
#pragma mark - Drag and Drop |
- (id <NSPasteboardWriting>)outlineView:(NSOutlineView *)outlineView pasteboardWriterForItem:(id)item { |
return (id <NSPasteboardWriting>)item; |
} |
- (void)outlineView:(NSOutlineView *)outlineView draggingSession:(NSDraggingSession *)session willBeginAtPoint:(NSPoint)screenPoint forItems:(NSArray *)draggedItems { |
_itemBeingDragged = nil; |
// If only one item is being dragged, mark it so we can reorder it with a special pboard indicator |
if (draggedItems.count == 1) { |
_itemBeingDragged = draggedItems.lastObject; |
} |
} |
- (NSDictionary *)_pasteboardReadingOptions { |
// Only file urls that contain images or folders |
NSMutableArray *fileTypes = [NSMutableArray arrayWithObject:(id)kUTTypeFolder]; |
[fileTypes addObjectsFromArray:[NSImage imageTypes]]; |
NSDictionary *options = @{NSPasteboardURLReadingFileURLsOnlyKey: @YES, NSPasteboardURLReadingContentsConformToTypesKey: fileTypes}; |
return options; |
} |
/* When validating the contents of the pasteboard, it is best practice to use -canReadObjectForClasses:arrayWithObject:options: since it is possible for it to avoid reading and creating objects for every pasteboard item. |
*/ |
- (BOOL)_containsAcceptableURLsFromPasteboard:(NSPasteboard *)draggingPasteboard { |
return [draggingPasteboard canReadObjectForClasses:@[[NSURL class]] options:[self _pasteboardReadingOptions]]; |
} |
- (NSDragOperation)outlineView:(NSOutlineView *)outlineView validateDrop:(id <NSDraggingInfo>)info proposedItem:(id)item proposedChildIndex:(NSInteger)index { |
// Only let dropping on the entire table or a folder |
if (item == nil || [item isKindOfClass:[ATDesktopFolderEntity class]]) { |
// If the sender is ourselves, then we accept it as a move or copy, depending on the modifier key |
if ([info draggingSource] == outlineView) { |
BOOL isCopy = [info draggingSourceOperationMask] == NSDragOperationCopy; |
if (isCopy) { |
info.animatesToDestination = YES; |
return NSDragOperationCopy; |
} else { |
if (self.itemBeingDragged) { |
// We have a single item being dragged to move; validate if we can move it or not |
// A move is only valid if the target isn't a child of the thing being dragged. We validate that now |
id itemWalker = item; |
while (itemWalker) { |
if (itemWalker == self.itemBeingDragged) { |
return NSDragOperationNone; // Can't do it! |
} |
itemWalker = [outlineView parentForItem:itemWalker]; |
} |
return NSDragOperationMove; |
} else { |
// For multiple items, we do a copy and don't allow moving |
info.animatesToDestination = YES; |
return NSDragOperationCopy; |
} |
} |
} else { |
// Only accept drops that have at least one URL on the pasteboard which contains an image or a folder |
if ([self _containsAcceptableURLsFromPasteboard:[info draggingPasteboard]]) { |
info.animatesToDestination = YES; |
return NSDragOperationCopy; |
} |
} |
} |
return NSDragOperationNone; |
} |
// Multiple item dragging support. Implementation of this method is required to change the drag images into what we want them to look like when over our view |
- (void)outlineView:(NSOutlineView *)outlineView updateDraggingItemsForDrag:(id <NSDraggingInfo>)draggingInfo { |
if ([draggingInfo draggingSource] != outlineView) { |
// The source isn't us, so update the drag images |
// We will be doing an insertion; update the dragging items to have an appropriate image. We also iterate over generic pasteboard items, and set the imageComponentsProvider to nil so they will fade out. |
NSArray *classes = @[[ATDesktopEntity class], [NSPasteboardItem class]]; |
// Create a copied temporary cell to draw to images |
NSTableColumn *tableColumn = self.outlineView.outlineTableColumn; |
// Create a new cell frame based on the basic attributes |
NSRect cellFrame = NSMakeRect(0, 0, tableColumn.width, outlineView.rowHeight); |
// Subtract out the intercellSpacing from the width only. The rowHeight is sans-spacing |
cellFrame.size.width -= outlineView.intercellSpacing.width; |
// Grab a basic view to use for creating sample images and data; we will reuse it for each dragged item |
ATTableCellView *tableCellView = [outlineView makeViewWithIdentifier:tableColumn.identifier owner:self]; |
__block NSInteger validCount = 0; |
[draggingInfo enumerateDraggingItemsWithOptions:0 forView:self.outlineView classes:classes searchOptions:@{} usingBlock:^(NSDraggingItem *draggingItem, NSInteger index, BOOL *stop) { |
if ([draggingItem.item isKindOfClass:[ATDesktopEntity class]]) { |
ATDesktopEntity *entity = (ATDesktopEntity *)draggingItem.item; |
draggingItem.draggingFrame = cellFrame; |
draggingItem.imageComponentsProvider = ^(void) { |
// Force the image to be generated right now, instead of lazily doing it |
if ([entity isKindOfClass:[ATDesktopImageEntity class]]) { |
((ATDesktopImageEntity *)entity).image = [[NSImage alloc] initByReferencingURL:entity.fileURL]; |
} |
// Setup the cell with this temporary data |
tableCellView.objectValue = entity; // This is what bindings normally does for us. Our sub-views are bound to this value. |
tableCellView.frame = cellFrame; |
// Ask the cell view for the image components from that cell |
return tableCellView.draggingImageComponents; |
}; |
validCount++; |
} else { |
// Non-valid item (a generic NSPasteboardItem). |
// Make the drag images go away |
draggingItem.imageComponentsProvider = nil; |
} |
}]; |
draggingInfo.numberOfValidItemsForDrop = validCount; |
} |
} |
- (void)_performInsertWithDragInfo:(id <NSDraggingInfo>)info parentItem:(ATDesktopFolderEntity *)destinationFolderEntity childIndex:(NSInteger)childIndex { |
// NSOutlineView's root is nil |
id outlineParentItem = destinationFolderEntity == self.rootContents ? nil : destinationFolderEntity; |
NSInteger outlineColumnIndex = [self.outlineView.tableColumns indexOfObject:self.outlineView.outlineTableColumn]; |
// Enumerate all items dropped on us and create new model objects for them |
NSArray *classes = @[[ATDesktopEntity class]]; |
__block NSInteger insertionIndex = childIndex; |
[info enumerateDraggingItemsWithOptions:0 forView:self.outlineView classes:classes searchOptions:[self _pasteboardReadingOptions] usingBlock:^(NSDraggingItem *draggingItem, NSInteger index, BOOL *stop) { |
// the item is our new model object -- created by the classes via the pasteboard reading support |
ATDesktopEntity *entity = (ATDesktopEntity *)draggingItem.item; |
// Add it to the model |
[destinationFolderEntity.children insertObject:entity atIndex:insertionIndex]; |
// Tell the outlineview of the change |
[self.outlineView insertItemsAtIndexes:[NSIndexSet indexSetWithIndex:insertionIndex] inParent:outlineParentItem withAnimation:NSTableViewAnimationEffectGap]; |
// Update the final frame of the dragging item |
NSInteger row = [self.outlineView rowForItem:entity]; |
draggingItem.draggingFrame = [self.outlineView frameOfCellAtColumn:outlineColumnIndex row:row]; |
// Insert all children one after another |
insertionIndex++; |
}]; |
} |
- (void)_performDragReorderWithDragInfo:(id <NSDraggingInfo>)info parentItem:(ATDesktopFolderEntity *)destinationFolderEntity childIndex:(NSInteger)childIndex { |
ATDesktopFolderEntity *oldParent = [self.outlineView parentForItem:self.itemBeingDragged]; |
if (oldParent == nil) oldParent = self.rootContents; |
NSInteger fromIndex = [oldParent.children indexOfObject:self.itemBeingDragged]; |
[oldParent.children removeObjectAtIndex:fromIndex]; |
if (oldParent == destinationFolderEntity) { |
// Consider the item being deleted before it is being inserted. |
// This is because we are inserting *before* childIndex, and *not* after it (which is what the move API does). |
if (fromIndex < childIndex) { |
childIndex--; |
} |
} |
[destinationFolderEntity.children insertObject:self.itemBeingDragged atIndex:childIndex]; |
// NSOutlineView doesn't have a way of setting the root item |
if (oldParent == self.rootContents) oldParent = nil; |
if (destinationFolderEntity == self.rootContents) destinationFolderEntity = nil; |
[self.outlineView moveItemAtIndex:fromIndex inParent:oldParent toIndex:childIndex inParent:destinationFolderEntity]; |
} |
- (BOOL)outlineView:(NSOutlineView *)outlineView acceptDrop:(id <NSDraggingInfo>)info item:(ATDesktopEntity *)item childIndex:(NSInteger)childIndex { |
ATDesktopFolderEntity *destinationFolderEntity = nil; |
if (item == nil) { |
destinationFolderEntity = self.rootContents; |
} else if ([item isKindOfClass:[ATDesktopFolderEntity class]]) { |
destinationFolderEntity = (ATDesktopFolderEntity *)item; |
} else { |
NSAssert(NO, @"Internal error: expecting a folder entity for dropping onto!"); |
} |
// If it was a drop "on", then we add it at the start |
if (childIndex == NSOutlineViewDropOnItemIndex) { |
childIndex = 0; |
} |
[self.outlineView beginUpdates]; |
// Are we copying the data or moving something? |
if (self.itemBeingDragged == nil || [info draggingSourceOperationMask] == NSDragOperationCopy) { |
// Yes, this is an insert from the pasteboard (even if it is a copy of itemBeingDragged) |
[self _performInsertWithDragInfo:info parentItem:destinationFolderEntity childIndex:childIndex]; |
} else { |
[self _performDragReorderWithDragInfo:info parentItem:destinationFolderEntity childIndex:childIndex]; |
} |
[self.outlineView endUpdates]; |
_itemBeingDragged = nil; |
return YES; |
} |
- (void)_removeItemAtRow:(NSInteger)row { |
id item = [self.outlineView itemAtRow:row]; |
ATDesktopFolderEntity *parent = (ATDesktopFolderEntity *)[self.outlineView parentForItem:item]; |
if (parent == nil) { |
parent = self.rootContents; |
} |
NSInteger indexInParent = [parent.children indexOfObject:item]; |
[parent.children removeObjectAtIndex:indexInParent]; |
if (parent == self.rootContents) { |
parent = nil; |
} |
[self.outlineView removeItemsAtIndexes:[NSIndexSet indexSetWithIndex:indexInParent] |
inParent:parent |
withAnimation:NSTableViewAnimationEffectFade | NSTableViewAnimationSlideLeft]; |
} |
#pragma mark - Actions |
- (IBAction)pathCtrlValueChanged:(id)sender { |
NSURL *url = self.pathCtrlRootDirectory.objectValue; |
self.rootContents = [[ATDesktopFolderEntity alloc] initWithFileURL:url]; |
[self.outlineView reloadData]; |
} |
- (IBAction)btnDeleteRowClicked:(id)sender { |
NSInteger row = [self.outlineView rowForView:sender]; |
if (row != -1) { |
// Take care of the case of the user clicking on a row that was in the middle of being deleted |
[self _removeItemAtRow:row]; |
} |
} |
- (IBAction)btnDeletedSelectedRowsClicked:(id)sender { |
[self.outlineView beginUpdates]; |
[self.outlineView.selectedRowIndexes enumerateIndexesWithOptions:NSEnumerationReverse usingBlock:^(NSUInteger index, BOOL *stop) { |
[self _removeItemAtRow:index]; |
}]; |
[self.outlineView endUpdates]; |
} |
- (IBAction)btnInCellClicked:(id)sender { |
NSInteger row = [self.outlineView rowForView:sender]; |
ATDesktopEntity *entity = [self.outlineView itemAtRow:row]; |
[[NSWorkspace sharedWorkspace] selectFile:entity.fileURL.path inFileViewerRootedAtPath:@""]; |
} |
- (IBAction)btnDemoMove:(id)sender { |
// Move the selected item down one |
NSInteger selectedRow = self.outlineView.selectedRow; |
if (selectedRow != -1) { |
id item = [self.outlineView itemAtRow:selectedRow]; // retain the item as we are removing it from our array |
// Grab the parent for this item |
ATDesktopFolderEntity *parent = [self.outlineView parentForItem:item]; |
// The parent may be nil, so we use the root if it is |
if (parent == nil) { |
parent = self.rootContents; |
} |
// Find out where it currently is |
NSInteger indexInParent = [parent.children indexOfObject:item]; |
// Then remove it |
[parent.children removeObjectAtIndex:indexInParent]; |
// Move it one index further down, or back to the start, if it would already be at the end. |
NSUInteger targetIndexInParent = indexInParent + 1; |
if (targetIndexInParent > parent.children.count) { |
targetIndexInParent = 0; // back to the start |
} |
[parent.children insertObject:item atIndex:targetIndexInParent]; |
// Tell outlineview about our change to our model; but of course, it uses 'nil' as the root item so we have to move back to nil if we were using the root as the parent. |
if (parent == self.rootContents) { |
parent = nil; |
} |
[self.outlineView moveItemAtIndex:indexInParent inParent:parent toIndex:targetIndexInParent inParent:parent]; |
} else { |
NSAlert *alert = [[NSAlert alloc] init]; |
alert.messageText = NSLocalizedString(@"Select something!", @""); |
alert.informativeText = NSLocalizedString(@"Select a row for an example of moving it down...", @""); |
[alert addButtonWithTitle:NSLocalizedString(@"OK", @"")]; |
[alert runModal]; |
} |
} |
- (IBAction)btnDemoBatchedMoves:(id)sender { |
// Swap all the children of the first two expandable items |
ATDesktopFolderEntity *firstParent = nil; |
ATDesktopFolderEntity *secondParent = nil; |
for (ATDesktopEntity *entity in _rootContents.children) { |
if ([entity isKindOfClass:[ATDesktopFolderEntity class]]) { |
ATDesktopFolderEntity *folderEntity = (ATDesktopFolderEntity *)entity; |
if (firstParent == nil) { |
firstParent = folderEntity; |
} else { |
secondParent = folderEntity; |
break; |
} |
} |
} |
if (firstParent && secondParent) { |
[self.outlineView beginUpdates]; |
// Move all the first children to the second array |
for (NSUInteger i = 0; i < firstParent.children.count; i++) { |
[self.outlineView moveItemAtIndex:0 inParent:firstParent toIndex:i inParent:secondParent]; |
} |
// Move all the children from the second to the first. We have to account for the fact that we just moved all the first items to this one. |
NSInteger childrenOffset = firstParent.children.count; |
for (NSUInteger i = 0; i < secondParent.children.count; i++) { |
[self.outlineView moveItemAtIndex:childrenOffset inParent:secondParent toIndex:i inParent:firstParent]; |
} |
// Do the changes on our model, and tell the OV we are done |
NSMutableArray *firstParentChildren = firstParent.children; |
firstParent.children = secondParent.children; |
secondParent.children = firstParentChildren; |
[self.outlineView endUpdates]; |
} else { |
NSAlert *alert = [[NSAlert alloc] init]; |
alert.messageText = NSLocalizedString(@"Expand something!", @""); |
alert.informativeText = NSLocalizedString(@"Couldn't find two parents to do demo move with. Expand some items!", @""); |
[alert addButtonWithTitle:NSLocalizedString(@"OK", @"")]; |
[alert runModal]; |
} |
} |
- (IBAction)chkbxFloatGroupRowsClicked:(id)sender { |
BOOL checked = ((NSButton *)sender).state == 1; |
self.outlineView.floatsGroupRows = checked; |
} |
- (IBAction)clrWellChanged:(id)sender { |
NSColor *color = [sender color]; |
self.outlineView.backgroundColor = color; |
} |
@end |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-04-14