In the first part of this series we started out by setting up the document type and export the UTI for the system to know about it. We also implemented methods to read the index from a file wrapper as well as persisting it to a new one. These steps where sufficient that we ended up with all the file manipulation candy (reverting to earlier versions, new doc, etc.) functional.
I promised that we would get go something more interesting today. We’ll be wiring up an NSCollectionView to show thumbnails and names of our images contained in shoebox documents. Then we need to dive into pasteboard as well drag-and-drop functionality to be able to manipulate those shoebox images. We want to be able to drag images from Desktop into shoeboxes and – time permitting – also be able to change their order by dragging as well.
Please let me know if this kind of tutorial is of interest to you by using the Flattr button and/or sharing it in your favorite social network.
To whet your appetite, this is what the app will look like by the end of this episode.
To make it interesting right away we shall enable the user to drag an image file from desktop into a document. But first we need to set up a collection view. With iOS 6 Apple has given us UICollectionView, on OS X we find NSCollectionView being its ancient ancestore because it has been on the Mac since 10.5.
NSCollectionView, NSArrayController and Data Binding
Collection Views are awesome because they take care of the nitty gritty of arranging multiple subviews automatically for us. On iOS you interact with them via delegate and datasource protocol methods. On Mac there is a paradigm called data binding. You can have objects basically watch some value (usually in a controller) and update themselves if they find that the bound value has changed.
You can either directly set the content property of the NSCollectionView or create a bind it to an NSArrayController’s arrangedObjects method. Array controllers have the ability of automatically sorting the array elements by a given sort key. Also you can change the element sort order separately from the order the objects have in the content array. I’m sure that there are other ways to achieve the ordering, but this is how Apple is demonstrating it in their IconCollection sample.
We open Interface Builder with Document.xib, remove the existing static label and drag a Collection View from the Object Library (right side panel) onto the Window. Resize it flush with the Window.
Please note that adding the collection view also automatically added a Scroll View around it as well as a “Collection View Item” which is connected to the itemPrototype property of NSCollectionView. This has an outlet view which is connected to the single lonely View item at the bottom of the list. This will be the canvas to construct our item representations later.
While we’re here we also add an NSArrayController by the same method. Apple has it between the Window and the Objects header, so shall we. When designing XIBs for Mac there is an additional tap for the bindings, resembling a water wheel. (Don’t ask me why Apple thinks that this is a good visual representation of the bindings concept)
We bind the array controller to “File’s Owner” which here is a proxy for Document instances. In part 1 we defined the mutable array for our items as public property there and so we can set the Model Key Path to “items” and auto-completion will show you that it found that.
Next to be bound is the collection view. Select it (and not the scroll view) and adjust the bindings there. We bind the Content to “Array Controller”, Controller Key to “arrangedObjects”. Selection Indexes we also bind to “Array Controller” and Controller Key there to “selectionIndexes”.
NSCollectionView now observes the arrangedObjects in the array controller which in turn gets its data from the items property of Document. For each such item the collection view creates a Collection View Item, including the View attached to it. Now let’s set up those.
Select the lonesome View in the tree panel. Add a “Label” (NSTextField) from the object library and change it to center the text. For the image drag an “Image Well” and position it above the Label. This is a bit strange because this is still a regular NSImageView as you can see when clicking on the third inspector tab. On the fourth tab we remove the default Becel border so that our images have no border.
There are two ways to have the individual data-bound items fill in the label and image. You can either create a NSCollectionViewItem subclass and override the newItemForRepresentedObject in some responsible UIViewController. But data binding provides the much easier alternative.
Using the Bindings inspector we can set the Value for the Label and Image View to be derived from the representedObject. So we bind it to “Collection View Item” and set Model Key path to “representedObject.thumbnailImage” and “representedObject.fileName” respectively.
All of this goes via Key-Value-Coding – hence the “Key Path” – so this is why we added the DocumentItem model class with a fileName and a thumbnailImage property. Each representedObject will be a DocumentItem instance and these bindings allow the collection item prototype view to retrieve its values for display.
The final piece of setup we need is to have an outlet for the collection view in our Document class so that we can message to it. A nice fast way to achieve that is to open the Assistant Editor which editing the XIB in the main editor. This will show the header of the File’s Owner and allow you to Ctrl-Drag elements into the header.
In the resulting popup we set up the outlet and this adds the appropriate code and links for us.
Data Binding is one really great thing on Mac that we don’t have on iOS. It builds on KVC and KVO, both of which are also on iOS. So we can keep our fingers crossed that Apple will add that to iOS at some point in the future.
Accepting Dragged Files
I hope that your patience has held until here with no way yet to see if the setup in Interface Builder and all these bindings are actually correct. Now we finally get to add stuff to our collection view by accepting dragged files from outside our app.
To make our Collection Views accept dragged files we need to tell it which UTIs we are willing to bother with. This is best done in Document in the first method that is called after the NIB was loaded by adding to windowControllerDidLoadNib:
- (void)windowControllerDidLoadNib:(NSWindowController *)aController { [super windowControllerDidLoadNib:aController]; // register types that we accept NSArray *supportedTypes = [NSArray arrayWithObjects:@"com.drobnik.shoebox.item", NSFilenamesPboardType, nil]; [self.collectionView registerForDraggedTypes:supportedTypes]; // from external we always add [self.collectionView setDraggingSourceOperationMask:NSDragOperationCopy forLocal:NO]; // from internal we always move [self.collectionView setDraggingSourceOperationMask:NSDragOperationMove forLocal:YES]; } |
Ignore the other types for the time being, the important one here is NSFilenamesPboardType which is an array of file URLs. This is the type used by Finder/Desktop to drag a list of one or more files. The types you set here are the ones that the collection view will accept.
There is one more thing you need to quickly set up in the Document.xib. There are certain delegate methods that collection view needs to see implemented so that dragging will work. For this purpose we have to connect the Collection View’s delegate outlet with the File’s Owner, i.e. the Document. You know, Ctrl-Drag from the collection view up onto the File’s Owner and choose delegate. Alternatively you could do that in code right next to the dragged type registration.
We need to implement a delegate method that evaluates a proposed drop operation.
- (NSDragOperation)collectionView:(NSCollectionView *)collectionView validateDrop:(id )draggingInfo proposedIndex:(NSInteger *)proposedDropIndex dropOperation:(NSCollectionViewDropOperation *)proposedDropOperation { if (!draggingInfo.draggingSource) { // comes from external return NSDragOperationCopy; } // comes from internal return NSDragOperationMove; } |
The proposedDropOperation is a simple value which can either be NSCollectionViewDropOn if you are dragging onto an existing item or NSCollectionViewDropBefore if you are dragging to an empty space between elements. With an empty collection view the first image dropped causes proposedIndex to be 0 and proposedDropOperation to be Before.
The return value will inform the OS which badge to add to the dragged icon. NSDragOperationCopy will add a green plus, NSDragOperationMove shows nothing, NSDragOperationLink shows a link symbol. By returning NSDragOperationNone you can refuse certain drops based on the passed draggingInfo. This info contains a link to a special pasteboard that holds the dragged items and other info to base your decision on.
For our purposes we respond that we want he Copy icon to show when the drag operation originated outside of the collection view and a Move to occur if our app is the source.
The validateDrop method informs others that our collection view would be possible willing to accept dragged items. In case something is actually dropped we also need to handle.
- (void)insertFiles:(NSArray *)files atIndex:(NSInteger)index { NSMutableArray *insertedObjects = [NSMutableArray array]; for (NSURL *URL in files) { // add file to our bundle [_fileWrapper addFileWithPath:[URL path]]; // create model object for it DocumentItem *newItem = [[DocumentItem alloc] init]; newItem.fileName = [[URL path] lastPathComponent]; newItem.document = self; // add to our items [insertedObjects addObject:newItem]; } // send KVO message so that the array controller updates itself [self willChangeValueForKey:@"items"]; [self.items insertObjects:insertedObjects atIndexes:[NSIndexSet indexSetWithIndex:index]]; [self didChangeValueForKey:@"items"]; // mark document as dirty [self updateChangeCount:NSChangeDone]; } - (BOOL)collectionView:(NSCollectionView *)collectionView acceptDrop:(id < NSDraggingInfo >)draggingInfo index:(NSInteger)index dropOperation:(NSCollectionViewDropOperation)dropOperation { NSPasteboard *pasteboard = [draggingInfo draggingPasteboard]; NSMutableArray *files = [NSMutableArray array]; for (NSPasteboardItem *oneItem in [pasteboard pasteboardItems]) { NSString *urlString = [oneItem stringForType:(id)kUTTypeFileURL]; NSURL *URL = [NSURL URLWithString:urlString]; if (URL) { [files addObject:URL]; } } if ([files count]) { [self insertFiles:files atIndex:index]; } return YES; } |
The draggingPasteboard method of draggingInfo gives us access to the temporary pasteboard that holds the dragged items. We retrieve the string for URLs, convert it into URLs and if we got a valid URL we add it to our list. You can query an NSPasteboardItem by UTI type for either string, data or property list.
The insertFiles helper method creates a file wrapper for each passed URL, creates a DocumentItem model object and then inserts the bunch of objects at the correct index in the items array. Note the use of willChangeValueForKey: and didChangeValueForKey: to send a KVO message to trigger an update from NSArrayController watching the “items” key path.
Calling updateChangeCount tells the system that a change was made and upon doing that an “Edited” label appears in the window title bar. Please forgive the extreme simplicity of this code. For one it does not check if the passed files are really images. It also does nothing to deal with a file being added a second time. These tidbits are left to the reader’s imagination.
We also need to implement the method by which a thumbnail is retrieved for each image. For sake of simplicity we don’t bother with resizing those as NSImageView takes care of this for us. Also on Macs we typically don’t face the same kinds of memory constraints as we do on iOS devices.
- (NSImage *)thumbnailImageForName:(NSString *)fileName { // get the correct fileWrapper for the image NSFileWrapper *fileWrapper = [_fileWrapper.fileWrappers objectForKey:fileName]; // create an image NSImage *image = [[NSImage alloc] initWithData:fileWrapper.regularFileContents]; return image; } |
Et voilá! We can now drag single or multiple images into the Shoebox and for each added file an item appears at the location were you let go of it. Also try resizing of the entire window to see how the collection view automatically redistributes the contents.
What’s even cooler is that the app already supports auto-saving and reverting to earlier versions. After having made some modifications and saving these you can go to the Revert To option in the Menu to get the familiar Time Machine interface for selecting an earlier version to restore.
This chapter dealt with handling of dragging files from outside of the app into an NSCollectionView acting as drop target.
As a prerequisite to local drag-drop-reordering we need to enable selection on the collection view and also do something so that the selection becomes visible to the user. So we enable selection and multiple selection in interface builder.
The collection view keeps track of selected elements in the selectionIndexes property and calls the setSelected method on each item. We need to implement our own NSView subclass that knows how to draw a selection. This class replaces the default NSView for the item prototype, so you have to change that in interface builder as well.
DocumentItemView.h
@interface DocumentCollectionItemView : NSCollectionViewItem @property (nonatomic, assign, getter = isSelected) BOOL selected; @end |
DocumentItemView.m
#import "DocumentItemView.h" @implementation DocumentItemView { BOOL _selected; } - (void)drawRect:(NSRect)dirtyRect { // Drawing code here. NSRect drawRect = NSInsetRect(self.bounds, 5, 5); if (self.selected) { [[NSColor blueColor] set]; [NSBezierPath strokeRect:drawRect]; } } #pragma mark Properties - (void)setSelected:(BOOL)selected { _selected = selected; [self setNeedsDisplay:YES]; } @synthesize selected = _selected; @end |
We also need to create a NSCollectionItemView subclass so that changes to the selected state of that are forwarded to our prototype view.
DocumentItemView.m
#import "DTCollectionItemView.h" #import "DocumentItemView.h" @implementation DTCollectionItemView - (void)setSelected:(BOOL)selected { [super setSelected:selected]; // forward selection to the prototype view [(DocumentItemView *)self.view setSelected:selected]; } |
Update both classes in the interface builder and you will find that selected images now display a blue border. You selected multiple items by dragging a rectangle around them or by pushing the usual keyboard modifiers and clicking on individual items. (Shift to extend range, CMD to add/remove individual items)
Reordering by Dragging
Since we’re having fun lets enhance the dragging functionality further to also allow changing of the sort order of the items in a Shoebox document.
You can specifically allow certain items to be dragged by implementing the collectionView:canDragItemsAtIndexes:withEvent: delegate method. If you omit that then all items are draggable. To support dragging from the collection view it also needs to be selectable, which we already established above.
Typically you want to teach your model object how to represent itself on the pasteboard in a variety of multiple formats aka UTIs. Model objects need the <NSPasteboardWriting> added in the header to be accepted by NSPasteboard and also several methods implemented. Here is a very crude implementation for this tutorial.
#pragma mark NSPasteboardWriting - (NSArray *)writableTypesForPasteboard:(NSPasteboard *)pasteboard { // support sending of index for local and file URL for external dragging return [NSArray arrayWithObjects:@"com.drobnik.shoebox.item", kUTTypeFileURL, nil]; } - (id)pasteboardPropertyListForType:(NSString *)type { if ([type isEqualToString:@"com.drobnik.shoebox.item"]) { // get index from Document NSUInteger index = [self.document indexOfItem:self]; // simplicty: just put the item index in a string return [NSString stringWithFormat:@"%ld", index]; } if ([type isEqualToString:(NSString *)kUTTypeFileURL]) { NSURL *URL = [self.document URLforTemporaryCopyOfFile:self.fileName]; return [URL pasteboardPropertyListForType:(id)kUTTypeFileURL]; } return nil; } - (NSPasteboardWritingOptions)writingOptionsForType:(NSString *)type pasteboard:(NSPasteboard *)pasteboard { return 0; // all types immediately written } |
In writeableTypesForPasteboard: you return an NSArray that lists all the UTIs that this document is able to represent itself. In writingOptionsForType:pasteboard: you specify which types are immediately pasted (0) or only promised (NSPasteboardWritingPromised). Promised values are deferred until somebody actually asks for them. Because I don’t quite understand how to properly implement this promising concept yet, I chose to simply put everything on the pasteboard right away. A method on document gives me the index of an item, another creates a temporary copy of an image and returns its file URL.
- (NSUInteger)indexOfItem:(DocumentItem *)item { return [self.items indexOfObject:item]; } - (NSURL *)URLforTemporaryCopyOfFile:(NSString *)fileName { // get the correct fileWrapper for the image NSFileWrapper *fileWrapper = [_fileWrapper.fileWrappers objectForKey:fileName]; // temp file name NSString *tmpFileName = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; // write it there [fileWrapper.regularFileContents writeToFile:tmpFileName atomically:NO]; return [NSURL fileURLWithPath:tmpFileName]; } |
Especially the latter method is an ideal candidate for promising because even when only dragging locally you end up creating lots of temporary image copies.
When you add an object that supports NSPasteboardWriting then it will first be queried what types it wants to support, then for each type it is asked whether it will provide it right away or only promise delivery. Finally the method to provide data for one type is called for each type. Typically you will want to return an NSData object with the encoded representation inside, but for strings the pasteboard will do that for us. Valid return values here are: NSArray, NSData, NSDictionary, or NSString objects—or any combination thereof.
Ok, finally we need to enhance our acceptDrop method to also deal with internal dragging.
- (BOOL)collectionView:(NSCollectionView *)collectionView acceptDrop:(id <NSDraggingInfo>)draggingInfo index:(NSInteger)index dropOperation:(NSCollectionViewDropOperation)dropOperation { NSPasteboard *pasteboard = [draggingInfo draggingPasteboard]; if ([pasteboard.types containsObject:@"com.drobnik.shoebox.item"]) { // internal drag, an array of indexes NSMutableArray *draggedItems = [NSMutableArray array]; NSMutableIndexSet *draggedIndexes = [NSMutableIndexSet indexSet]; for (NSPasteboardItem *oneItem in [pasteboard pasteboardItems]) { NSUInteger itemIndex = [[oneItem stringForType:@"com.drobnik.shoebox.item"] integerValue]; [draggedIndexes addIndex:itemIndex]; // removing items before insertion reduces it if (index>itemIndex) { index--; } DocumentItem *item = [self.items objectAtIndex:itemIndex]; [draggedItems addObject:item]; } [self willChangeValueForKey:@"items"]; [self.items removeObjectsAtIndexes:draggedIndexes]; for (DocumentItem *oneItem in draggedItems) { [self.items insertObject:oneItem atIndex:index]; } [self didChangeValueForKey:@"items"]; // mark document as dirty [self updateChangeCount:NSChangeDone]; return YES; } // ... handling of file URLs } |
This code is a bit more complex because when removing objects by index the subsequent insertion index also changes. If we only supported dragging single items then this would be a bit simpler. You again see the will/didChangeValueForKey trickery to get the Document to know that it was changed.
With this code in place we also implemented yet another kind of drag operation: drag images from a document window onto Desktop. The “com.drobnik.shoebox.item” representation is simply the indexes in the item array that are being dragged.
There is another flaw that becomes apparent only if you open multiple Document windows. You can also drag images from one Document into another, but this fails miserably. A quick idea as to how to resolve that might be to give each Document a GUID and also encode the parent document GUID with each item. Then upon seeing that the item belongs to another document you would fall back to the file URL method.
Another enhancement idea would be to also accept the public.image UTI so that you can drag any kind of image from controls that vend images directly into Shoeboxes.
Wrap Up
Our Shoebox app is taking shape and now we can even drag around images, into, out of and to change their sort order. There might be many additional things we could tweak that only become apparent when working with multiple documents. But we can pat ourselves on the back because the first giant leap has been made towards our first document-based app on OS X.
Let me know by Flattr or social sharing of these articles that you appreciate me taking so much time to find out and document how we iOS developers can also begin to play with the big boys who make mac apps.
Categories: Recipes
For some reason this doesn’t work, when I follow this tutorial, objects inserted in self.objects, do not get inserted :S
The object I try to insert in the function – (void)insertFiles:(NSArray *)files atIndex:(NSInteger)index, doesn’t get inserted. Already checked if it should be a null object…
Otherwise great tutorial 🙂 !
Nwm, I just forgot @synthesize items = _items 🙂
Great tutorial :D!
Why there is no download to sample project?
This is a great tutorial and a genius starting point despite I’m still not able to drag the items (?).
@DW: got to the first tut. there is a link to his GitHub example project, otherwise here is the URL directly to this project https://github.com/Cocoanetics/Examples/tree/master/Shoebox