Dragging and dropping into your RubyMotion Mac OS X app

May 20, 2015, by Elliott Draper

In this tutorial we’re going to look at how to setup your app to receive drag and drop events - so that you can receive data simply by the user dragging something into a predefined area of your application user interface.

This can be quite useful if your app needs a file input, requires a URL, or expects some text input, amongst other things. We’ll look at how to setup an area to accept the drag and drop, and how to receive and process data, separating files, from URLs, from plain old text. We’ll also look at how to update your user interface to feedback to the user the status of the drag and drop operation.

Setting up a space

Let’s setup a demo app that we’ll use for the rest of this tutorial:

motion create --template=osx DragAndDropDemo
cd DragAndDropDemo

Now before we do anything in AppDelegate, let’s setup a new view type, inheriting from NSImageView:

class DragAndDropView < NSImageView
end

This is what we’ll use as our area to receive drag and drops, and we’ll be building it out like a little control we can re-use and drop wherever it is needed. For our purposes, we’ll just make sure it sits in the center of our default app window created as part of the RubyMotion OS X template. Add our drag and drop view setup to the end of applicationDidFinishLaunching:

    @drag_and_drop = DragAndDropView.alloc.initWithFrame(CGRectZero)
    self.set_drag_and_drop_frame
    @mainWindow.contentView.addSubview(@drag_and_drop)

This sets up the view, and adds it to the window. As we’ve done in previous tutorials, we’ll handle resizing by managing the view frame in a separate method (set_drag_and_drop_frame) we can call from both the initial setup, and from the window resize handler. Speaking of that, let’s go ahead and make sure the AppDelegate is acting as the delegate for our @mainWindow by adding this to the bottom of the buildWindow method:

    @mainWindow.delegate = self

Then we’ll add the windowDidResize method to call the frame update too:

  def windowDidResize(sender)
    self.set_drag_and_drop_frame
  end

Lastly, we need our set_drag_and_drop_frame method to initialize the control frame:

  def set_drag_and_drop_frame
    window_size = @mainWindow.frame.size
    drag_and_drop_size = [200, 200]
    @drag_and_drop.frame = [
      [
        (window_size.width / 2.0) - (drag_and_drop_size[0] / 2.0),
        (window_size.height / 2.0) - (drag_and_drop_size[1] / 2.0)
      ],
      drag_and_drop_size
    ]
  end

So now we’ve got our view setup, we can run the app - our view doesn’t display or do anything, so it won’t look any different yet!

[code]

Setting up for user feedback

Before we implement our actual dragging functionality, we’ll add some custom code to provide better feedback to the user when something is being dragged in to the app. Our DragAndDropView is a subclass of the NSImageView, and we’ll want to keep the image rendering capability (as we’ll be using that later), but for now we can override the drawing of the control to provide a highlight stroke effect, and we’ll toggle that on and off as needed to indicate a drag in progress.

First things first, let’s keep a variable as to whether we should be rendering the highlight or not, and provide a helper method for turning the highlight on, and one for turning the highlight off:

  def highlight!
    @highlight = true
    self.needsDisplay = true
  end

  def unhighlight!
    @highlight = false
    self.needsDisplay = true
  end

After we set the variable, in each case we trigger a re-render by setting needsDisplay to true.

Then we’ll override drawRect to actually render the highlight:

  def drawRect(frame)
    super(frame)

    if @highlight
      NSColor.grayColor.set
      NSBezierPath.setDefaultLineWidth(5)
      NSBezierPath.strokeRect(frame)
    end
  end

As we can see here, we’re calling super to keep the existing NSImageView render functionality, but in addition to that, if the highlight functionality is enabled, we’re drawing a rectangle stroke effect in gray around the control itself.

Running the app right now won’t show anything different yet - we still need to handle drag operations!

Handling inputs

Now we need to customize our view so it indicates that it can act as a receiver for dragging, this is done with the NSDraggingDestination protocol. The nice thing about using RubyMotion is that we don’t need to explicitly declare protocols we want to implement in our classes, we just need to make sure we implement the required methods. In this case, it means adding four methods to our DragAndDropView control - draggingEntered, draggingExited, prepareForDragOperation and performDragOperation. Let’s take a look at each one in turn to figure out what they are doing, and how we can use them to implement the functionality we want, which to begin with will just be supporting the dragging of files into our app:

  def draggingEntered(info)
    self.highlight!
    NSDragOperationCopy
  end

draggingEntered is fairly self explanatory - this fires when we drag something over the top of our control. In this case, we do two things - firstly we highlight our control to give the user some feedback that they are dragging into the right area, and secondly we need to return an indicator of what we can do with the data being given to us. In our case, we just want to display and acknowledge that we have it, and not have any effect on the original source of the data, and so we specify NSDragOperationCopy. You can inspect the info which is an object of type NSDraggingInfo, and validate it to decide what kind of operation (if any) you want to allow for it. If you want to be able to modify the source through the dragging action, there are other options such as NSDragOperationMove and NSDragOperationDelete. If you don’t want to allow the drag, you can return NSDragOperationNone instead.

  def draggingExited(info)
    self.unhighlight!
  end

This too is fairly straightforward - draggingExited fires when the drag leaves our control area, so we’ll unhighlight our control. A user might be dragging the object elsewhere, or have decided against the drag before releasing it.

  def prepareForDragOperation(info)
    self.unhighlight!
  end

prepareForDragOperation fires when the drag is released and is going to happen, so we can perform any preparatory steps. In this case, we need to unhighlight the control as the dragging itself is over, and it hasn’t left the control area to be able to fire draggingExited, so we can do that now.

  def performDragOperation(info)
    if info.draggingSource != self
      if NSImage.canInitWithPasteboard(info.draggingPasteboard)
        image = NSImage.alloc.initWithPasteboard(info.draggingPasteboard)
      else
        image = NSImage.imageNamed("file_icon")
      end
      self.setImage(image)
    end
  end

This is the meat of the actual drag operation - performDragOperation is where we can action and process the data. It’s worth noting that setting the image like this isn’t strictly necessary - if you implement the first three methods, but not performDragOperation, and test by dragging an image file over to the control, you’ll see it still works. It’s basically setting the image of the control to the incoming file by default. However, we want to ensure that other files are received, and that in those cases we display a file icon to let the user know we’ve received them, even if we can’t display them. If you try that without this method, you’ll see that it really only works for images. We also want to extend the functionality later on to do a whole lot more!

So we’re ensuring first of all that the source of the dragging operation isn’t ourselves (i.e. dragging the current image from the control), and then we see if it’s an image or not. If it is, we’ll display it, and if not, we’ll display the file icon instead.

Make sure to copy the file_icon.png from the code below into your resources folder for your app.

Fire it up and try dragging across files - images, and other types, and see how it reacts!

Initial drag receiver setup

[code]

Processing files

Now, you may have noticed that in the above, besides setting the image on the imageview if the dragged in file is an image, we’re not actually doing anything with the incoming data. So we’ll add a mechanism to our DragDropView that’ll bubble up some events we can hook into from our AppDelegate to handle the incoming data. But before we do, let’s setup some additional UI that we’ll use when handling those events, to feed back to the user.

We’ll add a label below our drag and drop field, which will serve two purposes - initially, to guide the user to dragging a file to the area above the label, and also to update with information about the data dragged in.

At the end of our applicationDidFinishLaunching, add the following:

    @label = NSTextField.alloc.initWithFrame(CGRectZero)
    @label.bezeled = false
    @label.drawsBackground = false
    @label.editable = false
    @label.selectable = false
    @label.alignment = NSCenterTextAlignment
    @label.stringValue = "Drag a file above"
    self.set_label_frame
    @mainWindow.contentView.addSubview @label

As we’ve seen before, this uses the NSTextField control, and tweaks a few settings to provide a read-only label for us to use.

We then need to add our set_label_frame method to setup the frame itself:

  def set_label_frame
    window_size = @mainWindow.frame.size
    label_size = [200, 50]
    @label.frame = [
      [(window_size.width / 2.0) - (label_size[0] / 2.0), @drag_and_drop.frame.origin.y - label_size[1]],
      label_size
    ]
  end

Then, we’ll add a call to set_label_frame to our resize handler windowDidResize:

  def windowDidResize(sender)
    self.set_drag_and_drop_frame
    self.set_label_frame
  end

Now if you fire up the app, you’ll see the label sat underneath our drag and drop control.

Label

[code]

Let’s extend our DragDropView to allow a delegate to be specified, and to raise an event on the delegate when we receive data. First of all, we need to add an attribute accessor to be able to specify the delegate class on the control:

class DragAndDropView < NSImageView
  attr_accessor :delegate

Next up, we’ll add a method that’ll be used to call methods on our delegate, that’ll provide the necessary checks that we need. Add the following to our DragDropView as well:

  def send_delegate_event(name, arg)
    return if self.delegate.nil?
    return unless self.delegate.respond_to?(name.to_sym)

    self.delegate.send(name.to_sym, arg)
  end

This only sends the call to the specified delegate method if the delegate itself is set, and if an appropriate method is defined on the delegate to handle our event. We’re defining things like this because later on we’re going to add additional events for different types of data that we can receive, and ultimately we might want our AppDelegate to only respond to certain events, and not to error if we don’t answer the call for other events. This is similar to how delegates work for built-in controls and events - we can set AppDelegate as the delegate for a text field for example, and define methods to handle events such as text field editing ending - but if we don’t set the delegate, or don’t define the method to handle the event, we don’t end up with an error or a crash.

Now all that remains on the control side of things is to hook into the performDragOperation, extract the useful information and bubble that up as a delegate method call. In that method, and below our call to setImage, we’ll add the following:

      if info.draggingPasteboard.types.include?('NSFilenamesPboardType')
        files = info.draggingPasteboard.propertyListForType('NSFilenamesPboardType')
        self.send_delegate_event(:drag_received_for_file_paths, files)
      end

So here we’re validating what type of data we have and making sure we’ve received a drag that contains one or more filenames specified. As we’ll see later on, we’ll be adding to this to check for differing types of data, and thus extracting the data and raising events differently for each, allowing us to respond to different data sources in separate ways. In this case, if it is a pasteboard containing NSFilenamesPboardType, we can then load up the property list supplied for that pasteboard type, which then contains an array of the files dragged in, which is what we expose in our event call, named drag_received_for_file_paths.

Back in our AppDelegate, we now just need to set ourselves as the delegate for our drag and drop control, so where we define the @drag_and_drop instance, add:

    @drag_and_drop.delegate = self

Then, we’ll implement a method called drag_received_for_file_paths:

  def drag_received_for_file_paths(paths)
    @label.stringValue = "Received: #{paths.join(',')}"
  end

This just updates the label to list the file(s) that we’ve received in the drag operation. Try it out - drag one file, or a few files, and see it in action!

Handling files

[code]

Handling text

Let’s extend our DragAndDropView now to handle text dragged in too. If you fire it up and try dragging across some text from a text editor, or selected text from a browser, you’ll see that it doesn’t respond to the drag. None of our handling fires, and the data sort of “pings back”, indicating it’s not being received. This is because by default, the NSImageView we’re inheriting from for our control responds to certain drag types (i.e. files), but we have to tell it we want it to respond to additional types. Add the following method to our DragAndDropView class:

  def initWithFrame(frame)
    super(frame)

    self.registerForDraggedTypes([NSStringPboardType])

    self
  end

Here we’re overriding the initialization method, calling the original one first, and then taking the opportunity to register an additional dragged type - NSStringPboardType. This will allow us to receive text. Fire up the app now, and you’ll see dragging across some text works, in as much as the control is highlighted when we drag it across (as it is when we’re dragging files), and also if we drop it, we don’t see any errors in our console log. The data itself though isn’t processed in any way, so let’s do that by raising a new delegate event we can handle. In our performDragOperation, add the following to the end (but inside the outermost if statement):

      text = info.draggingPasteboard.stringForType(NSPasteboardTypeString)
      self.send_delegate_event(:drag_received_for_text, text) unless text.nil?

We’re using stringForType and passing in NSPasteboardTypeString to represent our request to see if there is any text in the dragging pasteboard. If there is, we publish that up to the delegate with a new event, drag_received_for_text.

Finally, as before, we just need to add our delegate event handler to AppDelegate:

  def drag_received_for_text(text)
    @label.stringValue = "Received: #{text}"
  end

Now if we fire up the app, and drag across some text, we’ll see it listed in the label. File dragging still works also, and any new drag just replaces the contents of the old one.

Handling text

[code]

Handling URLs

The last data type we’ll add in for this demo is dragging in URLs, for example dragging in a URL from a browser address bar. Again, as with the text, we need to register a new dragged type for this to work - NSURLPboardType. So we’ll update our call to registerForDraggedTypes to look like this:

    self.registerForDraggedTypes([NSStringPboardType, NSURLPboardType])

Next up, in performDragOperation, below the text handling code we just added, add the following:

      url = NSURL.URLFromPasteboard(info.draggingPasteboard)
      self.send_delegate_event(:drag_received_for_url, url.absoluteString) unless url.nil?

As with the text, this is fairly straightforward - we instantiate a URL from the pasteboard, and assuming we got one, we bubble that up to a new event drag_received_for_url.

And then the final step is to implement that event handler in AppDelegate:

  def drag_received_for_url(url)
    @label.stringValue = "Received: #{url}"
  end

Really simple again, we’re just responding to it and displaying the URL in the label. This means that by and large, our three event handlers are roughly the same, but in a real application you’d most likely go on to perform something slightly different for each data type, which is why we’ve structured it so that they are handled, raised and trapped as separate events.

Handling URLs

[code]

If you run the app now, you’ll see URL drags from the browser address bar work into our app now. Interestingly though, if you test file drags, you’ll see the URLs that are being displayed aren’t the original URLs, it’s an odd looking file:///.file/id=? URL. This is because the code we added for the URL handling will also work on the file drag operations too - effectively, both event types, drag_received_for_file_paths and drag_received_for_url will be raised, with drag_received_for_url handled last, and thus overwriting the label display with the funky looking URL.

Let’s switch out our URL handling for the following instead:

      url = info.draggingPasteboard.propertyListForType('public.url')
      self.send_delegate_event(:drag_received_for_url, url) unless url.nil?

[code]

Now if you run and test it, you’ll see that files display the proper path, while the URL dragging also works. It seems therefore that consulting the ‘public.url’ property on the pasteboard is a more accurate way to go and differentiate between those two types of dragging. When looking at the data for a specific type of drag operation, it’s worth either assigning the info var passed to us in performDragOperation to a variable that you can access and play with on the console while testing, or to log out the available types at least with a statement like this:

      NSLog("TYPES: #{info.draggingPasteboard.types.join(', ')}")

That will show you what types are available, and inspecting those further with propertyListForType will let you see what data is provided. It also seems that the text handling can read the URL as the string contents of the pasteboard, so you might be seeing both drag_received_for_url and drag_received_for_text fire with the URL passed as an argument - we can tidy up our handling still further to ensure we’re only ever raising one event. Below our setImage call in performDragOperation, replace what we have with the following:

      if info.draggingPasteboard.types.include?('NSFilenamesPboardType')
        files = info.draggingPasteboard.propertyListForType('NSFilenamesPboardType')
        self.send_delegate_event(:drag_received_for_file_paths, files)
      elsif info.draggingPasteboard.types.include?('public.url')
        url = info.draggingPasteboard.propertyListForType('public.url')
        self.send_delegate_event(:drag_received_for_url, url) unless url.nil?
      else
        text = info.draggingPasteboard.stringForType(NSPasteboardTypeString)
        self.send_delegate_event(:drag_received_for_text, text) unless text.nil?
      end

This now just means we’re matching in a sort of priority order - first filenames, then the URL, then finally falling back on the string contents of the pasteboard. As a result of daisy chaining the conditional statements though, once we have a match, no other processing happens, which results in a more reliable result for the consumer of these events, in this case, our AppDelegate.

[code]

More detailed URL handling

One thing that I spotted only when investigating the available types for a URL was that there was an additional type that included not just the URL, but the title of the webpage from the browser too! Let’s add that in as an additional event, as that could be quite useful. First of all, as this would be an event with two arguments (URL and title), let’s update our send_delegate_event helper method to support that:

  def send_delegate_event(name, *args)
    return if self.delegate.nil?
    return unless self.delegate.respond_to?(name.to_sym)

    self.delegate.send(name, *args)
  end

We’ve changed the arg to instead be *args, which acts as an argument list, that we’re then passing straight through when we call the delegate method. We can now have methods that have 1 argument, 2 arguments, or 10 arguments!

Now let’s add our extended URL handling - we’re going to add this to our daisy chained conditionals, above the existing URL support which we’ll leave in place in case there are any URL dragging operations that don’t provide this additional data (it will most likely depend on which browser or app you’re dragging the URL from):

      elsif info.draggingPasteboard.types.include?('WebURLsWithTitlesPboardType')
        url, title = info.draggingPasteboard.propertyListForType('WebURLsWithTitlesPboardType').flatten
        self.send_delegate_event(:drag_received_for_url_and_title, url, title)

As you can see here, we’re checking for a specific new type, WebURLsWithTitlesPboardType, and if that’s available, it’ll come back as an array in this format:

[["url", "title"]]

Therefore we flatten it to get just a single array, and extract our URL and title, which we bubble up with a new delegate event, drag_received_for_url_and_title, and pass both arguments. To handle this now, we just need to implement the handler on our AppDelegate:

  def drag_received_for_url_and_title(url, title)
    @label.stringValue = "Received: #{url}"
    @mainWindow.title = title
  end

Here we’re once again putting the URL in the label to feed back to the user, but to make things a bit more interesting, we’re setting our actual app window title to the title we receive from the URL web page drag. If you test that now, you’ll see that in fact the window title changes to reflect the page that was dragged in!

[code]

Improving the visual feedback

The last thing we’ll look at in our demo app here is to improve the user visual feedback, so that the icon shown on the image view changes for different types of drag operation - right now, an image is displayed if it’s an image file, and anything else results in the file icon. Let’s add a text icon and a URL icon to differentiate between those drag operations also. It’ll take a bit of tweaking of our performDragOperation method, so here is how it should look in full to make this happen:

  def performDragOperation(info)
    if info.draggingSource != self
      image = NSImage.alloc.initWithPasteboard(info.draggingPasteboard)
        if NSImage.canInitWithPasteboard(info.draggingPasteboard)

      if info.draggingPasteboard.types.include?('NSFilenamesPboardType')
        files = info.draggingPasteboard.propertyListForType('NSFilenamesPboardType')
        self.send_delegate_event(:drag_received_for_file_paths, files)
      elsif info.draggingPasteboard.types.include?('WebURLsWithTitlesPboardType')
        url, title = info.draggingPasteboard.propertyListForType('WebURLsWithTitlesPboardType').flatten
        self.send_delegate_event(:drag_received_for_url_and_title, url, title)
        image = NSImage.imageNamed("url_icon")
      elsif info.draggingPasteboard.types.include?('public.url')
        url = info.draggingPasteboard.propertyListForType('public.url')
        self.send_delegate_event(:drag_received_for_url, url) unless url.nil?
        image = NSImage.imageNamed("url_icon")
      else
        text = info.draggingPasteboard.stringForType(NSPasteboardTypeString)
        self.send_delegate_event(:drag_received_for_text, text) unless text.nil?
        image = NSImage.imageNamed("text_icon")
      end

      image ||= NSImage.imageNamed("file_icon")
      self.setImage(image)
    end
  end

Breaking it down, we’re setting our image to be the image itself if the pasteboard is an image file. Otherwise, inside of our daisy chained conditionals for matching different drag types, we’re setting the appropriate image. For the files, we’re not setting an image - it’ll either already be set if it’s an image file, or we’ll be using the file icon as a default anyway, which we’ll come back to in a second.

For the two URL handling conditionals, and the string/text handling, we’re setting either the url_icon or text_icon image. Lastly, as we just mentioned, if no other image is set already, we’ll use the file_icon as a default so we show something, before setting the image as our final action now in performDragOperation.

As before, copy the additional icons (url_icon.png and text_icon.png) from the code below into the resources directory in your app before running it.

If you run the app now and try dragging in various different data sources, you’ll see that as well as the label (and for URLs with titles, the window title) changing, the icon will update to reflect the source too, which provides additional user feedback.

Better icon for handling text

Better icon for handling URLs

[code]

Wrapping up

So now we have a demo app that can receive multiple data types dragged over to it, providing feedback to the user that it can receive that drag, and then updating further when it’s received and processed the data. Additionally, we’ve done this with a re-usable drag and drop image view control, that handles the processing of the drag operations, provides visual feedback by changing the icon shown (or showing an image file), and bubbles up various events so that a consumer app can receive more data about drag events and act accordingly. From here you could build an app that took files and uploaded them, scanned text to provide useful analysis, or altered images in a common way for repeatable tasks!

If you’ve enjoyed the content here, remember that early access orders for my forthcoming book, Building Mac OS X apps with RubyMotion, open up on June 8th - make sure you’re subscribed to the mailing list so you can get a launch discount! Any comments or questions, please drop them below, or tweet me @kickcode or @ejdraper.


Vote on Hacker News



If you're in need of a developer for your RubyMotion or Ruby on Rails project, or have a web or mobile app idea that I can help bring to life efficiently and affordably, then contact us today!

Don't forget to follow us on Twitter, and like us on Facebook to stay up to date with the latest KickCode content.


blog comments powered by Disqus
Back to blog