Building a custom user interface for your Mac OS X status bar app in RubyMotion Dec 14 2013

Status bar apps are very useful, but what we've gone through so far in our series on building Mac OS X status bar apps with RubyMotion really only covers the basics, and our apps can often be much improved by implementing a more custom user interface than just a dropdown menu when you click on the item in the status bar.

The reasons you'd want to implement your own user interface are fairly varied, but generally speaking it gives you the ability to more easily show specific data, and handle interactions within the app in a way that better relates to what your app does.

In this article we're going to take the app we've built up in the previous couple of articles, and go from a fairly staid, boring menu to list the commits, to a far more vibrant and interesting custom panel that lists the commits in a much more visually pleasing manner, and includes the user avatar image. As we're building upon the existing codebase, and there is a fair bit of new code involved as well as changes to the existing code, we won't be running through every single line - instead I'll be focusing on the key implementation details, and all of the code is available as always on GitHub.

Custom panel

First things first, we need to setup a window that we can show when the status item is clicked. Instead of inheriting from NSWindow, we'll inherit from NSPanel, which is a special subclass of NSWindow that auto hides when it loses focus, and is generally perfect for a window that belongs to a background status bar app such as in this case.

The full definition of our PopupPanel custom window class is here. Essentially though, we're just setting up the window in our custom init method:

self.initWithContentRect([[0, 0], [POPUP_WIDTH, POPUP_HEIGHT]],
  styleMask: NSBorderlessWindowMask,
  backing: NSBackingStoreBuffered,
  defer: false)
self.title = NSBundle.mainBundle.infoDictionary['CFBundleName']
self.delegate = self
self.setBackgroundColor(NSColor.clearColor)
self.setOpaque(false)

The origin for the window isn't relevant (hence 0,0) as we'll be moving it to show under the status item anyway. We're using a borderless window, and then we're setting it to be transparent so it's invisible, allowing us to set a content view that'll do our custom pane shape drawing.

@background = PopupBackground.alloc.initWithFrame(self.frame)
self.setContentView(@background)

This is where we initialise that very custom popup background class - we'll look at how that works shortly.

The only other really interesting code here is the showHide method:

if self.isVisible
  self.orderOut(false)
else
  event_frame = NSApp.currentEvent.window.frame
  window_frame = self.frame
  window_top_left_position = CGPointMake(
    event_frame.origin.x + (event_frame.size.width / 2) - (window_frame.size.width / 2),
    event_frame.origin.y)

  self.setFrameTopLeftPoint(window_top_left_position)
  @background.setArrowX(window_frame.size.width / 2)

  NSApp.activateIgnoringOtherApps(true)
  self.makeKeyAndOrderFront(self)
end

This is what gets called when the status menu button is pressed. We want it to act as a toggle, so we are using isVisible to determine the current state. If we are showing the window (i.e. it's not already visible), there is a bit more work to do. The important bit is that on the X axis we use the origin from the frame of the cause of the event, in this case the status menu item, and add half its own width to it to find the center of that menu item; we then remove half the width of our custom window panel to find the origin point on the X axis for our panel to ensure it'll appear centered underneath the status item. Then we set the arrow for the custom pane that points from our panel up to the status item - we'll see how that functions in a minute too.

The rest of the class just ensures the panel functions as a key window, so we can receive notification when it loses focus, and correctly hide it so that pressing the button again shows it as expected rather than leaving it in a weird state.

The other piece of the puzzle is our custom background view that we're using as the content view for our custom window, to render the panel background by doing some custom drawing. You can see that in full here. It's based on the excellent Objective-C code written for this blog post, and basically uses a Bezier path to draw a panel with rounded edges, and an arrow on the top side in the middle that'll appear to be pointing up at the status item. I won't run through all of the code here, as it's really just stepping through the various points (top of arrow, right of arrow, top right corner, bottom right corner, bottom left corner, top left corner, left of arrow) to draw the shape, and then to both fill the shape, as well as to draw an outline stroke.

The other bit of noteworthy code in this class is how we update it so that the arrow is set in the right spot and to redraw:

def setArrowX(value)
  @arrow_x = value
  self.setNeedsDisplay(true)
end

This updates the X value used in the drawing method, and setNeedsDisplay ensures that our draw method is hit again to re-draw. setArrowX is what we called from our PopupPanel, and so the rendering cycle for the custom window then is complete.

Right place, right time

We've actually already done the work in our PopupPanel class showHide method to calculate the right place to show the window, and to set the arrow on the custom pane - all that remains is to alter our AppDelegate and status menu setup to trigger the panel instead of a menu. We need to create the window within the AppDelegate:

@window = PopupPanel.alloc.initPopup

And then we'll remove this line that set the status menu as the menu to use for the status item:

@status_item.setMenu(@status_menu)

We'll then add these two lines to trigger a call to a custom event handler instead:

@status_item.setTarget(self)
@status_item.setAction('showHide:')

That event handler looks like this:

def showHide(sender)
  @unseen = 0
  self.showUnseenCommits
  @window.showHide(sender)
end

Now, we could have set the target to the @window itself, and called our showHide method directly there rather than through a delegate event handler on our AppDelegate. However, as we see above, in addition to simply toggling the window, we also have some work to do that pertains to things handled within the AppDelegate - we want to clear the unseen commits, so that they are "marked as read", in the same way that we did before when the menu was shown. Hence the abstraction within the AppDelegate to perform the additional functionality.

Collecting commits

Right now if you fire the app up, you should see a custom panel that gets shown right beneath our status bar item when we click on the status menu entry. However, there isn't any content in here just yet. Our background call to hit the GitHub API and pull out the right data is still working - we just need to take the data we have there, and use the very versatile NSCollectionView to render it nicely into our custom pane. The file that contains the majority of our code for this is here.

There are a few moving pieces to contend with. First of all, we'll define a Struct that'll contain the data we want to take from our call to GitHub, so we can store it in a neat way to use within the collection view:

Commit = Struct.new(:name, :repo, :message, :avatar, :url)

An NSCollectionView basically works off an array, and provided with a prototype object, will know how to generate the custom view for each object it finds in the array. So we define our custom view, CommitView, with some fairly easy to follow code that simply builds the three controls we're using - an NSBox to encapsulate the item with a neat visual boundary (and show the actor and repo for a commit as a heading), an NSImageView to show the relevant user avatar, and an NSTextField configured to function as a label to show the commit message. We then provide a method that takes in our object (an instance of our Commit struct), and will use the fields to populate these controls. The last code of interest in our custom commit view is that we're handling the click on it to open the commit page on GitHub directly from the view:

def mouseDown(event)
  NSWorkspace.sharedWorkspace.openURL(NSURL.URLWithString(@object.url))
end

The nice thing about this is that we no longer have to do a lookup to correlate the item clicked with a list of URLs - we're handling this event on the view that is tied to a specific object, an object we can easily re-access from the event handler to provide the URL to open.

After that, we need to then define our prototype, as a subclass of NSCollectionViewItem - this must implement loadView, which will create a new CommitView instance, and setRepresentedObject which is passed an object from our content array, and so calls through to our setViewObject on our custom view to setup the various controls with the right data. This means that the collection view will loop through the array of content objects, instantiating a collection view item using our prototype, which in turn creates an instance of our custom view, and passes the relevant commit object all the way through to the view to render correctly.

All that remains now is to setup the NSCollectionView itself within our custom panel, which we'll do in our AppDelegate. Where we previously created the window, below that we'll go ahead and setup a scroll view first of all:

scroll_view = NSScrollView.alloc.initWithFrame(NSInsetRect(@window.contentView.frame,
  PopupBackground::LINE_THICKNESS + SCROLL_VIEW_INSET, PopupBackground::ARROW_HEIGHT + SCROLL_VIEW_INSET))
scroll_view.hasVerticalScroller = true
@window.contentView.addSubview(scroll_view)

We're using a scroll view so that we can have content a lot longer than our custom pane (given that the idea is to keep it quite small to sit beneath the status menu item, and there might be a lot of commits to show). It's fairly easy to get going - we're setting the frame to be inset slightly from the content view frame, to avoid getting too close to the rounded corners and overlapping them, and we're making sure it's a vertical scroller so we can scroll up and down our commit list.

We're then going to setup our collection view using the scroll view frame, and we're giving it an instance of the commit prototype so that it'll know how to create our custom views. We then assign that to the scroll view:

@collection_view = NSCollectionView.alloc.initWithFrame(scroll_view.frame)
@collection_view.setItemPrototype(CommitPrototype.new)

scroll_view.documentView = @collection_view

The last piece of the puzzle is getting the data into the collection view each time it changes - here we need to delve into our checkForNewEvents method that processes our GitHub events. We can remove a fair bit of code now as things are nice and simple (you can see the exact changes in this diff). However, the most important thing is creating instances of our Commit struct for each commit with the right data from the GitHub API, and appending it to an array that we setup towards the beginning of checkForNewEvents:

@data << Commit.new(actor, repo, message, avatar, url)

And after we've finished processing all of the commits, we update the collection view quite easily:

@collection_view.setContent(@data)

Running the app now and clicking the menu item will show the custom panel, complete with the custom commit views for each commit found. Clicking an item will open it as it did before (and auto hide the panel).

Additional options

This all functions really well, but we've lost a little bit of potentially vital functionality - the menu options to show the about window, and to quit the app! The nice thing is we still have our menu with those options, and we can add a button to our custom panel that when clicked will show that exact same menu but as a context sensitive popup menu instead!

We just need to setup an NSButton within our AppDelegate and add it to the content view of our custom window, which we'll do beneath the scroll view:

@options_button = NSButton.alloc.initWithFrame(NSMakeRect(PopupPanel::POPUP_WIDTH - BUTTON_WIDTH, 0,
  BUTTON_WIDTH, BUTTON_HEIGHT))
@options_button.setTitle("Options")
@options_button.setButtonType(NSMomentaryLightButton)
@options_button.setBezelStyle(NSRoundedBezelStyle)
@options_button.setTarget(self)
@options_button.setAction('showMenu:')
@window.contentView.addSubview(@options_button)

And then we'll implement the showMenu event handler:

def showMenu(sender)
  NSMenu.popUpContextMenu(@status_menu, withEvent: NSApp.currentEvent, forView: sender)
end

Now when you run the app, you'll see we have an options button at the bottom, and clicking it reveals our menu with the about and quit functionality working just the same as before! This is a great way to still tuck away the standard stuff your app might need in a normal menu, but retain all of the power and customisation that building your own custom user interface will bring you.

Here is what our end result looks like:

Commit Tracker Menu with fancy UI

Phew, that was a quite a long tutorial this time! But we've seen that relatively speaking, it isn't a whole lot of code to implement a custom user interface that you can tweak to better suit your own app. You could tweak the PopupBackground drawing to do something different, and of course you don't have to use a scroll view and collection view to display the data - it depends on what you are showing. If you do use a collection view, you can customise your own item view that best suits the data you're showing. There are a number of different ways to use this approach, but end up with a result perfectly tailored to your app.

As we didn't cover every last line of code that was changed, and there were a few more general tweaks made to the app since our last article, you can view all of the changes in this diff over on GitHub.

Let me know if you enjoyed the article, or if you have any questions, comments or suggestions, either below, or @ejdraper on Twitter!

technicalrubymotioncodekickcode