9781449380373
_address_book_example.html

Chapter 9. Address Book Example

The first full example in this book shows how to write an application around Twitter and the Address Book. The goal here is to import our Twitter contacts into our address book and learn new tricks as we go along.

Let’s begin by creating a MacRuby application using Xcode. For that, start Xcode and choose the MacRuby template. In the template settings, set the Product Name to TwitterContactImporter and use “clear” or “deselect” or “disable” the Document-Based and Core Data options.

User Interface

I personally find it easier to start by working on the UI, and then adding the required code. Click on the .xib file to edit the interface, then select the Window object, as shown in Figure 9.1, “Initial UI”.

Figure 9.1. Initial UI

Initial UI

We are going to add a button that will automatically import our Twitter contacts. Once the contacts are imported, we will display the list of our contacts and let the user search for a specific contact.

To start, we need to use an image to put on our button. Head to the Twitter logos and icons site and download the logo of your choice. Save the image as twitter_logo.png somewhere on your hard drive. Once the image is saved, drag and drop it in Xcode into the Resources folder. An option screen like the one shown in Figure 9.2, “Import image option screen” will open.

Figure 9.2. Import image option screen

Import image option screen

Warning

Make sure to select the destination box.

Now, drag a Square Button from the Object Library to the window view, as shown in Figure 9.3, “Addition of a square button”.

Figure 9.3. Addition of a square button

Addition of a square button

Let’s add our Twitter logo to our button. To do that, select the button and select the Attributes inspector. Open the drop-down menu for the Image attribute and choose twitter_logo. Now resize the button to make it bigger. Resizing the button is easy: just use the handles on the sides of the button. Once you are satisfied, make sure the TwitterContactImporter scheme is selected and click Run to compile your application and preview your UI (Figure 9.4, “Final view of the running application”). We can also use the document simulator (in the Editor menu), but since the MacRuby source code doesn’t have to be compiled, I think it’s just as easy and more reliable to run the real app.

Figure 9.4. Final view of the running application

Final view of the running application

If you try to close the window, you will notice that the application is still in the dock. Let’s add a line of code to exit the application once the main window is closed. Open AppDelegate.rb and add the following code at the bottom of the AppDelegate class (i.e., before the last “end”):

def windowWillClose(sender); exit; end

Before testing this code, we need to do one more thing. We need to tell our window to use our AppDelegate so our callback will work as expected. Edit the .xib file, right-click on the Window instance, and drag the window delegate outlet to the App Delegate object. At this point, the window will delegate everything to our AppDelegate instance, which is what we want, since that’s where we defined the windowWillClose delegate method. Depending on the application you write, you might want to write a window-specific delegate.

Run the application one more time, and notice that this time, when closing the window, the application exits.

Address Book

To use the AddressBook framework, we need first to load it. Edit rb_main.rb and on the line loading the Cocoa framework, add the following code:

framework 'AddressBook'

Earlier, in the section called “Method Missing”, we examined how to wrap the AddressBook Cocoa API to make it easier to work with. I wrote a bunch of basic wrappers for the AddressBook API. Let’s add them to our project.

Right-click the TwitterContactImporter folder in the Project navigator and choose New Group. Name the folder Wrappers. Download the wrapper files for this chapter from the book repository and drag them in the Wrappers folder (the wrapper files are named ab*_ and you will also see a file adding some helper methods to Ruby). When the option screen displays, choose to copy the items into the destination group. The files reopen some AddressBook classes and add some convenient methods. The files will be automatically loaded by the rb_main.rb file.

Next, we need to load the user’s address book and make sure the user marked a record as her own. Let’s start by creating an accessor for the user’s address book. We want to do that when the application finishes launching. Edit AppDelegte.rb, add a new attribute reader called address_reader, and set its value inside the applicationDidFinishLaunching method as follows:

class AppDelegate
  attr_accessor :window
  attr_reader :address_book

  def applicationDidFinishLaunching(a_notification)
    @address_book = ABAddressBook.sharedAddressBook
  end

  def windowWillClose(sender); exit; end
end

Let’s also add a method to alert the user if something goes wrong, such as, for instance, if she didn’t set a contact as her own:

def alert(title='Alert', message='Something went wrong: ')
  NSAlert.alertWithMessageText(title,
                               defaultButton: 'OK',
                               alternateButton: nil,
                               otherButton: nil,
                               informativeTextWithFormat: message).runModal
end

And now let’s verify that the user marked an account as hers. If not, let’s display a warning:

def check_me
  unless address_book.me
    alert "Address Book Problem",
    "You need to open the Address Book and mark a contact as yours. Choose a contact, 
    click on Card, and select 'Make this my card'."
  end
end

And call our newly created method from inside applicationDidFinishLaunching:

def applicationDidFinishLaunching(a_notification)
  @address_book = ABAddressBook.sharedAddressBook
  check_me
end

This is a good first step, but we also need to check that we set our Twitter nickname in the address book. Without this nickname representing the user’s Twitter account, we can’t find her contacts. Notice that only public Twitter accounts are supported.

Add the following code at the bottom of the check_me method:

if address_book.me.nickname.nil? || address_book.me.nickname == ''
  alert "Address Book Problem",
  "You need to set your Twitter account as your nickname in your contact card."
end

Great, so now we have now created a warning that will inform the user if her contact card doesn’t contain the information we need. But we also need to create a new contact group to store all the Twitter contacts and keep them somewhat isolated:

def create_twitter_group
  twitter_group =  address_book.groups.find{|g| g.name == 'Twitter'}
  if twitter_group
    twitter_group
  else
    group = ABGroup.alloc.init
    group.name = 'Twitter'
    puts "adding Twitter group"
    address_book << group
    address_book.save
    twitter_group
  end
end

We are going to use this method in a minute, when we call the web API.

Web API Call

Now that the Twitter information is ready, we can write the code that will be called to fetch the Twitter contacts when the user clicks our Twitter button.

In Cocoa terms, such a method is called an action. We will connect our button’s performClick event to a new method, passing as its argument the instance that calls the method. Here is the action code:

def import_twitter_contacts(sender)
  me = address_book.me
  twitter_group = create_twitter_group
  puts "Importing from Twitter"

  Thread.new{
    fetch_paginated_friends(me.nickname) do |friends|
      puts "Importing a batch of friends"
      add_twitter_friends(friends, twitter_group)
    end
    address_book.save
    puts "Import done"
  }

end

This code calls a method that we haven’t implemented yet, named fetch_paginated_friends. And for each batch of retrieved friends, we add them to the newly created group, using a new method called add_twitter_friends. Finally, when all the friends have been imported, the address book is saved.

The reason the code is wrapped in a new thread is so we don’t block the main loop. Because our application is very simple, using the main thread wouldn’t have been a big deal. Indeed, a user can’t do anything else in the application while we’re loading contacts, so making him wait until the import is done wouldn’t be too bad. However, this is a bad practice and, as you can see, running the code in a separate thread is really straightforward.

Let’s connect our button to this action. To do that, select the MainMenu.xib file, right-click on the App Delegate icon, and drag the circle attached to import_twitter_contacts (in the Received Action submenu) to the big button we added.

Now let’s implement the paginated fetching, using the Twitter API:

def fetch_paginated_friends(account, &block)
  cursor = '-1'
  until cursor == 0
    url_string = 
      "http://twitter.com/statuses/friends/#{account}.json?cursor=#{cursor}"
    url = NSURL.alloc.initWithString(url_string)
    json_friends   = NSMutableString.alloc.initWithContentsOfURL(url,
                                                      encoding:NSUTF8StringEncoding,
                                                      error:nil)
    friend_results = JSON.parse(json_friends)
    block.call(friend_results['users'])
    cursor = friend_results['next_cursor']
  end
end

The code isn’t complicated, but it mixes some Cocoa API calls with some advanced Ruby idioms. The first thing you might notice is that the second method argument starts with an ampersand and looks funny. It’s Ruby’s way of saying that this method expects to be called with a block. The method can’t be called without passing a block.

The Twitter API uses the principle of a cursor to paginate through the results. The first page starts at cursor –1 and each result set contains information about the previous or next cursor. When there aren’t any pages to iterate through, the cursor is set to 0. Because of this, we can write our code so it will keep calling the Twitter API until a response defines the next cursor as 0. The data coming back from the API is a string in the JSON format. To parse this data, we first need to require the JSON library at the top of the file, as follows:

require 'json'

That’s the library used to parse the body of the API response. We then extract a subset of this data and send it to our block, like this:

block.call(friend_results['users'])

This calls the passed block and passes it our data subset as a parameter. Look at how we called this method and used the block. See that the block’s parameter is called friends? Our implementation executes the passed block and replaces “friends” with the data subset so that it can be manipulated from within the block:

fetch_paginated_friends(me.nickname) do |friends|
  # do something with a subset of the friends
end

For our code to work, we also need to implement the add_twitter_friends method that is called from within the block:

def add_twitter_friends(friends, group)
  friends.each do |friend|
      # avoid duplicates
      if friend['screen_name']
        query = ABPerson.searchElementForProperty(KABNicknameProperty,
                      label:nil, key:nil, value:friend['screen_name'],
                      comparison:KABEqual)
        results = address_book.recordsMatchingSearchElement(query)
        contact = results.first || ABPerson.alloc.initWithAddressBook(address_book)
      else
        contact = ABPerson.alloc.initWithAddressBook(address_book)
      end

      first_name, last_name = friend['name'].split(' ')
      contact.firstName = first_name if first_name
      contact.lastName  = last_name if last_name
      contact.nickname  = friend['screen_name']
      urls = {'Twitter' => "http://twitter.com/#{friend['screen_name']}"}
      urls[KABHomePageLabel] = friend['url'] unless friend['url'].blank?
      contact.URLs  = urls.to_ab_multivalue

      contact.imageData = NSData.dataWithContentsOfURL(friend['profile_image_url'].\
                                                                              to_nsurl)

      notes = []
      notes << friend['description'] unless friend['description'].blank?
      notes << "Location: #{friend['location']}" unless friend['location'].blank?
      contact.note = notes.join("\n")

      group << contact
    end

end

Even though this method is a bit long, what it does is very simple. For each friend in the passed array, it checks whether a matching record already exists in the address book. If so, the method updates the record based on the information returned by Twitter, or creates a new record. We even download the profile image and assign it as the contact’s image. Once the method is done parsing the data, the updated/created contact is added to the group. Voilà!

Cleaning Up: Better Management of Widgets

The application we’ve thrown together is great, but we have a few issues to fix and features to add. First, a user might click multiple times on the button, and that would run our fetching code in multiple parallel threads. We obviously don’t want that. The other thing is that the user isn’t notified that the contacts are being fetched. Let’s fix that!

The first thing to do is give our code access to our big Twitter button. By now, you should know what we need to do:

  1. Add a button attribute accessor in our class so Xcode sees it as an outlet.

  2. In the Interface Builder, right-click the App Delegate icon and assign the button outlet to the big Twitter button.

In this section, instead of using the Interface Builder tool provided by Xcode, we are going to set the rest of the UI programmatically. This is mostly meant as an exercise and to show you that it is not that hard after all.

Now that we have access to our button, we want to hide the button after it was clicked and show a progress indicator. To do that, let’s create a method and dispatch it from the button action:

def make_user_wait
  button.hidden = true
  if @spinner
    @spinner.hidden = false
  else
    x = window.frame.size.width/2 - 32/2
    y = window.frame.size.height/2 - 32/2
    @spinner = NSProgressIndicator.alloc.initWithFrame([x,y,32,32])
    @spinner.style = NSProgressIndicatorSpinningStyle
    window.contentView.addSubview(@spinner)
  end
  @spinner.startAnimation(nil)
end

This code is a bit more complicated than it should be, but it shows how to display a view item in relation to another one. The first thing we do is hide the Twitter button Then, if the spinner already exists, we show it, otherwise we create an instance that we locate exactly at the middle of the window. To do that, we calculate the x and y positions by inspecting the window frame size and locating the indicator in the center, based on its size in pixels (32×32). We also need to start the spinning animation.

The only problem is that we tell the users to wait, but then don’t show them anything. Thankfully, Cocoa has a widget that lets you integrate a contact browser in your app. Let’s add it to our sample and display the contact browser once all the contacts are imported. Our import_twitter_contacts method will look like this:

def import_twitter_contacts(sender)


  make_user_wait
  me = address_book.me
  twitter_group = create_twitter_group
  puts "Importing from Twitter"

  Thread.new{
    fetch_paginated_friends(me.nickname) do |friends|
      puts "Importing a batch of friends"
      add_twitter_friends(friends, twitter_group)
    end
    address_book.save
    puts "Import done"
    display_contacts
  }

end

And display_contacts looks like this:

def display_contacts
  @spinner.stopAnimation(nil)
  @spinner.hidden = true
  frame = [0,0, window.frame.size.width, window.frame.size.height - 120]
  @picker = ABPeoplePickerView.alloc.initWithFrame(frame)
  window.contentView.addSubview(@picker)
end

Again, the code is straightforward. We stop the spinner and hide it. We then create an instance of ABPeoplePickerView that will be slightly shorter than the window’s height. Once the instance is created, we add it to our window’s content view.

The Extra Mile: Displaying More Information Through Notifications

What we have done already is really cool, but it would be even nicer to display some of the contact’s details while browsing them. We can do this by adding more UI elements and by observing the interaction with the ABPeoplePickerView.

This time, instead of creating UI elements and placing them programmatically on the window, let’s set our elements but hide them until we need them. Before we do that, let’s add some outlets for all of the UI elements and, while we are at it, add an attribute reader for the picker:

class AppDelegate
  attr_accessor :window, :button
  attr_reader :address_book, :picker
  attr_accessor :contact_name, :contact_image, :contact_twitter

  #...
end

Next, add an ImageWell instance of 100x100 on the top left of your UI. Edit the Image view’s settings and change the drop-down to make the view scale proportionally up or down. Then, to the right of the image, add two labels. Before you hide them all, right-click the app delegate icon and connect your three outlets to your new UI items. Now, go to each of them and set their drawing attributes to hidden.

Now we just need to write the code displaying the hidden UI items, setting up the notification center and its callback method:

def display_contacts
  @spinner.stopAnimation(nil)
  @spinner.hidden = true
  frame = [0,0, window.frame.size.width, window.frame.size.height - 120]
  @picker = ABPeoplePickerView.alloc.initWithFrame(frame)
  center = NSNotificationCenter.defaultCenter
  center.addObserver(self,
                     selector: "record_changed:",
                     name: ABPeoplePickerNameSelectionDidChangeNotification,
                     object:picker)
  picker.allowsMultipleSelection = false
  picker.addProperty KABNicknameProperty
  contact_image.hidden = false
  contact_name.hidden = false
  contact_twitter.hidden = false
  window.contentView.addSubview(picker)
end

In the section called “Notification Centers”, we covered the notification center and observers. The principle is simple: when the user changes the selected contact, the callback selector is called. The following code implements the callback method, which simply finds the selected record and sets the UI elements with the record’s data:

def record_changed(notification)
  selected = picker.selectedRecords || []
  if selected.empty?
    contact_image.image = nil
    contact_name.stringValue = ""
    contact_twitter.stringValue = ""
  else
    person = selected.first
    contact_image.image = NSImage.alloc.initWithData(person.imageData)
    contact_name.stringValue = "#{person.firstName} #{person.lastName}"
    contact_twitter.stringValue  = person.nickname || ''
  end
end

Our app is now finished. After running the app and playing a bit with it, you can open your AddressBook.app and see all your Twitter contacts being imported and their details becoming available, as shown in Figure 9.5, “My Twitter contacts imported and previewed”.

Figure 9.5. My Twitter contacts imported and previewed

My Twitter contacts imported and previewed

Site last updated on: November 9, 2011 at 10:00:57 AM PST
Cover for MacRuby: The Definitive Guide

View 1 comment

  1. Justin Sako – Posted Nov. 21, 2011

    It would be nice to have a reference to where to find the "book repository" here. I would also like to see a reference to the location of the book repository in the "Using Code Examples" section at the beginning of the book.

Add a comment

View 1 comment

  1. Justin Sako – Posted Nov. 21, 2011

    AppDelegate.rb is misspelled ("AppDelegte.rb") and I believe the new attribute reader is called "address_book", not "address_reader".

Add a comment

View 1 comment

  1. Sean DeNigris – Posted Dec. 20, 2011

    Xcode 4.1 had a bug where IB did not recognize outlets in MacRuby classes. It is corrected in Xcode 4.2. However, if you upgrade to 4.2, you must then reinstall MacRuby for MacRuby outlets to start working again.

    Edited on December 20, 2011, 2:47 p.m. PST

Add a comment