Review, rinse, repeat

We're going to build up yet another UITableView, and we'll be using the UINavigationController again while we do.

I would also like to revisit how the UITableView is reloaded. Previously I just manually tossed in a call to reloadData whenever the textfield was unfocused, but this is some pretty tight coupling.

Start with a Table, then make it navigable.

I'm going to first copy the code from my first UITableView tutorial, and then, just because it is sooo easy, and worth showing, I'll “wrap” that in a UINavigationController. Meaning, the app will start out with only a single UIViewController, but then I will instantiate a UINavigationController and move the view controller inside of it.

The AppDelegate looks like this for now. I'm just instantiating a view controller and assigning it to the window:

class AppDelegate

  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.makeKeyAndVisible

    @window.rootViewController = MyApplicationController.alloc.init

    true
  end

end

I'm starting with the exact same UITableView cell code in MyApplicationController, so I'm not going to repeat it here (it is in the github repo, and you can read about it in the original post).

I immediately add the code to deselect the touched cell, from the custom table cell tutorial. It should really have been in the original UITableView post, but oh well, too late now.

def tableView(tableView, didSelectRowAtIndexPath:indexPath)
  tableView.deselectRowAtIndexPath(indexPath, animated:true)
end

With this in place, we are ready to turn this into a UINavigationController-powered application.

Ready? It's gonna be a quick change, so watch for it!

class AppDelegate

  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.makeKeyAndVisible

    mine = MyApplicationController.alloc.init
    @window.rootViewController = UINavigationController.alloc.initWithRootViewController(mine)

    true
  end

end
--- app_delegate.rb
+++ app_delegate.rb (Unsaved)
@@ -4,7 +4,8 @@
     @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
     @window.makeKeyAndVisible

-    @window.rootViewController = MyApplicationController.alloc.init
+    mine = MyApplicationController.alloc.init
+    @window.rootViewController = UINavigationController.alloc.initWithRootViewController(mine)

     true
   end

And in the MyApplicationController class, be a gentleman and give the table a name:

class MyApplicationController < UIViewController

  def viewDidLoad
    self.title = "Editable Tableview"
    #...

Pretty cool, huh? total of three lines, we're out of the gates.

  • tableview_in_a_nav.png

    Tableview In a Nav Png

Editing rows

Let's add a view to edit a row. This will be used later to add a new row, so we'll have a little code to help with that.

When the user presses a row (aka "in the tableView(tableView, didSelectRowAtIndexPath:indexPath) method") we will lazily instantiate an EditPlayerController, assign the player to it, and then push it onto the stack. We will be reusing this view controller throughout the lifetime of the app, so we need to make sure we reset it whenever a new row is touched. Assigning nil will be a clue that we are adding, not editing, a player.

I'm grabbing code from a previous post where I played with a UINavigationController. Unlike in that post, I will not re-instantiate a new controller everytime they touch an item. Depending on how heavy that detail view is, I could see an argument for re-using one view or instantiating and destroying a fresh view every time (which also keeps the code simpler, I think).

I am also moving the UITextField initialization into custom methods. This is nice for two reasons: 1, that view code is better organized. 2, The player= method will be called before the viewDidLoad method is called, and it assigns the first and last names to the views. So using @first ||= ... makes this easy - whenever the view is needed, it will be there.

class EditPlayerController < UIViewController

  def viewDidLoad
    # labels and textfields for the first and last names go here
    self.view.backgroundColor = UIColor.groupTableViewBackgroundColor

    self.view.addSubview(self.first_field)
    self.view.addSubview(self.last_field)
  end

  def first_field
    @first ||= UITextField.alloc.initWithFrame([[10, 30], [300, 40]]).tap do |first|
      first.borderStyle = UITextBorderStyleRoundedRect
      first.font = UIFont.systemFontOfSize(14)
      first.minimumFontSize = 17
      first.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter
      first.adjustsFontSizeToFitWidth = true
      first.placeholder = "First Name"
      first.delegate = self
    end
  end

  def last_field
    @last ||= UITextField.alloc.initWithFrame([[10, 80], [300, 40]]).tap do |last|
      last.borderStyle = UITextBorderStyleRoundedRect
      last.font = UIFont.systemFontOfSize(14)
      last.minimumFontSize = 17
      last.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter
      last.adjustsFontSizeToFitWidth = true
      last.placeholder = "Last Name"
      last.delegate = self
    end
  end

  def player=(player)
    @player = player
    # we will be using this view to *add* a player, in which case player will be nil.
    if not player
      # assign empty or default values
      self.title = ""
      @first.text = ""
      @last.text = ""
    else
      # assign values from player
      self.title = "#{@player['first']} #{@player['last']}"
      @first.text = @player['first']
      @last.text = @player['last']
    end
  end

  # pressing return moves the cursor to the next text field
  def textFieldShouldReturn(text_field)
    if text_field == @first
      @last.becomeFirstResponder
    else
      @first.becomeFirstResponder
    end
  end

  # saves the changes
  def textFieldDidEndEditing(text_field)
    if @player
      if text_field == @first
        @player['first'] = text_field.text
      else
        @player['last'] = text_field.text
      end
    end
  end

end

I want to improve the way the tableview gets updated. Calling reloadData on the tableview manually (which is what I did in the previous post) seems very inelegant. I would prefer to use key-value observation to listen to changes to the player, and key-value binding to attach the text fields to the first and last name properties of the player.

But I could not easily do this. The observer methods never got fired, and the binding methods simply do not exist. Maybe this stuff is not ready in iOS? Not sure. Frustrating. The last recourse is not terrible, though, which is to use a notification center. When a player is changed or added, a "changed" notification is sent, and the tableview is updated accordingly. The upside is that I can re-use this method both during editing and when adding and removing players.

edit_player_controller.rb
def textFieldDidEndEditing(text_field)
  # ...
  NSNotificationCenter.defaultCenter.postNotificationName("players changed", object:self)
end
my_application_controller.rb
def viewDidLoad
  # ...
  NSNotificationCenter.defaultCenter.addObserver(self,
    selector: :playersChanged,
    name:@"players changed",
    object:nil)
end

def playersChanged
  @table.reloadData
end

At this point we have caught up with the previous post, and we are ready to...

Reorder and Delete Players

This had me scratching my head for a while, because there are a bunch of methods that need to be in place before it works.

First, we add an "edit" button to the navigation controller leftBarButtonItem property. Since we are adding this to the root view controller, there is no chance of a back button being there, otherwise you would have to add it to the rightBarButtonItem (alongside the "add" button, which we add below).

def viewDidLoad
  # ...
  self.navigationItem.leftBarButtonItem = self.editButtonItem
end

That'll add an edit button, and by default (since we use the built-in editButtonItem) it will toggle between "Edit" and "Done" when you press it, but that's all it does. We need to implement the setEditing(animated:) method, and in there, we tell the table view it is being edited. If you were not using a table view, but some custom view or views, you would set them up for "editing mode" in this method (the phone book app does this).

def setEditing(is_editing, animated:is_animated)
  # tell the table view we are (or aren't) in editing mode
  @table_view.setEditing(is_editing, animated:is_animated)
  # pass to super, which toggles the "Edit" and "Done" button label
  super
end

Now when you press Edit, you'll get the familiar delete button, but no reorder button. That's because the tableview has inspected its data source, and found that it lacks a tableView(moveRowAtIndexPath:toIndexPath:) method. Also, if you press delete, it just hangs there as if in mid touch. We have work to do!

  • tableview_delete_mode.png

    Tableview Delete Mode Png

Let's add the data source method, and while we're at it, we will implement the code that we need to actually reorder the rows in the @players array. If we don't, the code will still "work", until the table view gets a reloadData message, at which point the change (which was never actually saved) would be reverted. So heads up, you might hit your head against this for a while if you think it all "just works" the first time you give it a whirl.

def tableView(tableView, moveRowAtIndexPath:from_index_path, toIndexPath:to_index_path)
  # why is this an attribute, not a local variable?  good ol' rubymotion garbage
  # collection was breaking my balls here.  Using an attribute fixed it, so
  # there it is.  support ticket has been filed...
  @move = @players.delete_at(from_index_path.row)
  if @move
    @players.insert(to_index_path.row, @move)
  end
end

And now we have drag handles! To test this, reorder the rows and then press one to edit it. You'll find that the edit view and the row orders are in sync.

  • tableview_edit_mode.png

    Tableview Edit Mode Png

To delete a row, we'll do pretty much the same thing. Interestingly it is not a special "delete" method that gets called on the data source, but a generic tableView(commitEditingStyle:forRowAtIndexPath:) method, that can get called when a row is inserted or deleted. I don't understand the benefit / logic of this, especially since the insert action isn't even available by default. We will implement an add button, but it will not use this feature.

def tableView(tableView, commitEditingStyle:editing_style, forRowAtIndexPath:index_path)
  if editing_style == UITableViewCellEditingStyleDelete
    @players.delete_at(index_path.row)
    @table_view.deleteRowsAtIndexPaths([index_path], withRowAnimation:UITableViewRowAnimationAutomatic)
  end
end

Now reorder and delete to your hearts content. Which is, hopefully, twice, since we only have two rows...

If only we could have a feature where we could somehow...

Add a player!

We'll need an add button, which is pretty dang easy, and then we'll hook it up to the EditPlayerController, passing nil to player= method. To make it oh-so-iOS-y, we will display it in a modal, with a cancel button in the upper-right corner.

def viewDidLoad
  # ...
  self.navigationItem.rightBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(
      UIBarButtonSystemItemAdd,
      target: self,
      action: :addPlayer)

The addPlayer method will use a new instance of EditPlayerController. I know I said we'd be using the same one, but that was a damn lie. The reason is that the customizations that we will need to make in order for the modal window to look right are so much, that doing and undoing them for the edit vs add modes would be tedious. Instead, we will customize the add_player_controller when we create it. The code looks much better, trust me.

def add_player_controller
  @add_player_controller ||= EditPlayerController.new.tap do |ctlr|
      ctlr.navigationItem.leftBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(
          UIBarButtonSystemItemCancel,
          target: self,
          action: :cancelAddPlayer)

      ctlr.navigationItem.rightBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(
          UIBarButtonSystemItemDone,
          target: self,
          action: :doneAddPlayer)

      ctlr.navigationItem.rightBarButtonItem.enabled = false
  end
end

def addPlayer
  # assign the player (resets the inputs)
  self.add_player_controller.player = nil

  # create and customize the navigation controller.  This gives us an easy
  # place to put the "Cancel" and "Done" buttons, and a "New Player" title.
  ctlr = UINavigationController.alloc.initWithRootViewController(self.add_player_controller)
  ctlr.modalTransitionStyle = UIModalTransitionStyleCoverVertical
  ctlr.delegate = self

  self.presentViewController(ctlr, animated:true, completion:nil)
end

cancelAddPlayer and doneAddPlayer will both dismiss the dialog, and that is all that cancelAddPlayer will do, so let's get that out of the way:

def cancelAddPlayer
  self.dismissViewControllerAnimated(true, completion:nil)
end

In doneAddPlayer we also need to add the player, if it is returned successfully by the EditPlayerController#player method. If the entry is invalid, we return nil from that method. First, the doneAddPlayer method, then we'll move back into EditPlayerController class.

def doneAddPlayer
  if self.add_player_controller.player
    @players.push(self.add_player_controller.player)
    self.playersChanged
  end

  self.dismissViewControllerAnimated(true, completion:nil)
end

Back in EditPlayerController now, we will add some methods and conditions for adding a new player. First, let's write that player method:

def player
  first_name = self.first_field.text.to_s
  last_name = self.last_field.text.to_s

  if first_name.length + last_name.length > 0
    {
      'first' => first_name,
      'last' => last_name,
    }
  else
    nil
  end
end

Next, I want to enable/disable the "Done" button, depending on whether the user has entered a name (either name will do).

This turned out to be kind of a pain in the butt. You can either use the delegate method textField(shouldChangeCharactersInRange:replacementString:), which gets fired everytime the text changes, or you can register (and later unregister) for UITextFieldTextDidChangeNotification events.

The delegate method is screwy, because the edit hasn't actually happened yet, so you have to check the contents of the replacementString argument along with checking all the other inputs. Bleh. The notification way is only complicated by the requirement that we will need to register for the notification. Easy!

def textFieldDidBeginEditing(text_field)
  NSNotificationCenter.defaultCenter.addObserver(self,
      selector: "textFieldChanged",
      name: UITextFieldTextDidChangeNotification,
      object: nil)
end

def textFieldDidEndEditing(text_field)
  NSNotificationCenter.defaultCenter.removeObserver(self)
  # ...
end

Now we can check our text fields, and enable/disable the rightBarButtonItem button accordingly:

def textFieldChanged
  if not @player
    # enable or disable the button, depending on whether one of
    # the text fields has text or not
    self.navigationItem.rightBarButtonItem.enabled = (self.first_field.text.length + self.last_field.text.length > 0)
  end
end

Persistance (the easy way)

There are lots of ways to persist data, we're gonna go the super-stupid easy route and store our @players objects in a property list.

You can alternatively use a custom flat file (don't!), an SQLite database using the embedded engine (this is good), Core Data (god help you learn it, and using it without Xcode is annoying), or NSUserDefaults, but then there are curve balls about read-only objects, and that method is really meant to store application settings, not application data.

The documents folder is stored here:

docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true)[0]

Blech, I know. But there it is. Use BubbleWrap to make this more palatable. To get a specific file out of there, we use another verbose Cocoa method:

filename = docs.stringByAppendingPathComponent("filename.txt")

GEEZ, Cocoa! Let's make a Kernel method to do this for us. I put project-specific Kernel extensions in app_delegate.rb, you can put it wherever tickles your fancy. While we're at it, I will add an exists method to make sure the file exists.

class Kernel

  def document(filename)
    @docs ||= NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true)[0]
    @docs.stringByAppendingPathComponent(filename)
  end

  def exists(filename)
    NSFileManager.defaultManager.fileExistsAtPath(filename)
  end

end

DISCLAIMER: These names are short, and not namespaced, so I would not recommend just using this code in every project you write. Use BubbleWrap, it has methods like this.

Let's add our persist and load methods to MyApplicationController:

def plist
  document('editable_tableview.plist')
end

def persist
  @players.writeToFile(plist, atomically:true)
end

def load_players
  if exists(plist)
    @players = NSArray.alloc.initWithContentsOfFile(plist)
  else
    @players = [
      {'first' => 'firsty', 'last' => 'McLasty'},
      {'first' => 'humphrey', 'last' => 'bogart'},
    ]
    self.persist
  end
end

And we need to call persist anytime the data changes, so I will add it to the playersChanged method, and I will add calls to that method in tableView(moveRowAtIndexPath:toIndexPath:) and tableView(commitEditingStyle:forRowAtIndexPath:).

def playersChanged
  self.persist
  @table_view.reloadData
end

def tableView(tableView, commitEditingStyle:editing_style, forRowAtIndexPath:index_path)
  # ...
  self.playersChanged
end

def tableView(tableView, moveRowAtIndexPath:from_index_path, toIndexPath:to_index_path)
  # ...
  self.playersChanged
end

One quick note: When saving and restoring the @players array, the keys in each player Hash are written as Strings, rather than Symbols, which have no counterpart in Objective-C. So use strings, or write something that translates between your objects and the Foundation Framework classes.

If you run the app, add, edit, remove players, then quit (don't just go home and back, you're still running the app in memory), the changes should all be persisted nicely now. Whoopity-doo!

Like a Boss

Now let's make this thing look boss-like. A plain, unstyled table view is fine, but it is so easy to do app-wide styles using the UIAppearance class, you should be asking yourself "why not?". With just a couple images, and a few lines of code, we'll end up with this:

  • tableview_tintcolor.png

    Tableview Tintcolor Png

Or this!

  • tableview_backgroundimage.png

    Tableview Backgroundimage Png

UINavigationBar.appearance.tintColor

The first technique is just one line:

UINavigationBar.appearance.tintColor = UIColor.colorWithRed(0.3, green:0.0, blue:0.5, alpha: 0)  # fushcia

In real apps, I always use sugarcube, so this would be more like:

UINavigationBar.appearance.tintColor = :fuschia.uicolor

Background image... for BarMetrics?

To set the background image you will need to use a bar metric, which just specifies whether the view is in landscape or portrait. Here's the image I'm using:

  • tableview_background.png

    Tableview Background Png

Set it using the appearance delegate again:

UINavigationBar.appearance.setBackgroundImage(UIImage.imageNamed("table-bg"), forBarMetrics: UIBarMetricsDefault)

You can, and probably should, set the tint when using a custom background, but if you really want to, you can create images for the buttons, too. They have to be very particular sizes, and you'll need to create resizable images. AND you will need four images in total: button, button pressed, back button, back button pressed. Honestly, you should just set the tint and be done with it, let the default drawing do it's thing.

But, since I'm sure you're wondering:

  • tableview_btn_back_pressed.png

    Tableview Btn Back Pressed Png

  • tableview_btn_back.png

    Tableview Btn Back Png

  • tableview_btn_button_pressed.png

    Tableview Btn Button Pressed Png

  • tableview_btn_button.png

    Tableview Btn Button Png

back_normal = UIImage.imageNamed("btn-back").resizableImageWithCapInsets([0, 14, 0, 6])
back_highlighted = UIImage.imageNamed("btn-back-pressed").resizableImageWithCapInsets([0, 14, 0, 6])
bar_button_normal = UIImage.imageNamed("btn-button").resizableImageWithCapInsets([0, 5.5, 0, 5.5])
bar_button_highlighted = UIImage.imageNamed("btn-button-pressed").resizableImageWithCapInsets([0, 5.5, 0, 5.5])
UIBarButtonItem.appearance.setBackButtonBackgroundImage(back_normal, forState: UIControlStateNormal, barMetrics: UIBarMetricsDefault)
UIBarButtonItem.appearance.setBackButtonBackgroundImage(back_highlighted, forState: UIControlStateHighlighted, barMetrics: UIBarMetricsDefault)
UIBarButtonItem.appearance.setBackgroundImage(bar_button_normal, forState: UIControlStateNormal, barMetrics: UIBarMetricsDefault)
UIBarButtonItem.appearance.setBackgroundImage(bar_button_highlighted, forState: UIControlStateHighlighted, barMetrics: UIBarMetricsDefault)
  • tableview_background_allimages_1.png

    Tableview Background Allimages 1 Png

  • tableview_background_allimages_2.png

    Tableview Background Allimages 2 Png