Whatchoutalkinbout!?

The elusive "half-screen" modal is used to present the user with a set of choices (think UIAlertSheet) or a custom keyboard-like interface (below). The trick we'll be using today can be used for either!

In this context, when I say "keyboard", I'm I'm referring more to the animations than to the idea of keyboard keys, and to the idea that it presents itself in a context - in this case, when you press "Awesomeness", you are presented with an awesomeness picker.

There is no built-in Apple-blessed way to do this. We'll just be building this view hierarchy from scratch and using some simple animations to do pretty much what Apple does with their keyboard.

  • keyboard_slideup.png

    Keyboard and modal overlay

Oh I see... Neat!

First, quick setup. If you have ever written a rubymotion app anywhere, ever, you can follow along. Otherwise, uh, you should read up on this stuff.

app_delegate.rb
class AppDelegate

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

    @window.rootViewController = MyApplicationController.new

    true
  end

end
my_application_controller.rb
AwesomeLabels = [
                  "Really awesome",
                  "Awesome",  # default
                  "Pretty darn awesome",
                  "I would still call this awesome",
                  "Not awesome, really, but maybe a little",
                  "Okay, yeah, it's awesome",
                ]

class MyApplicationController < UIViewController

  def init
    super && self.tap {
      @awesomeness = 1
    }
  end

  def viewDidLoad
    @table_view = UITableView.grouped(self.view.bounds)   # !?
    @table_view.dataSource = self
    @table_view.delegate = self

    self.view << @table_view   # !?
  end

  def tableView(table_view, cellForRowAtIndexPath:index_path)
    cell = table_view.dequeueReusableCellWithIdentifier('Cell')

    unless cell
      cell = UITableViewCell.alloc.initWithStyle(:value1.uitablecellstyle,   # !?
                                 reuseIdentifier:'Cell')
      cell.textLabel.text = "Awesomeness"
    end

    cell.detailTextLabel.text = AwesomeLabels[@awesomeness]
    return cell
  end

  def tableView(table_view, titleForHeaderInSection:section)
    "Settings"
  end

  def tableView(table_view, numberOfRowsInSection:section)
    1
  end

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

end

Don't compile yet, it won't work. See those !?s? That's SugarCube stepping in to help me remember & not-have-to-write constant names like UITableViewCellStyleValue1, and to make code look like ruby instead of stinking like Cocoa (self.view << @table_view instead of self.addSubview(@table_view)).

So let's get to it. Let's install SugarCube.

RVM and Bundler

You should use these, they make life grand.

RVM, aka Ruby Version Manager

RVM is used to switch between different versions of Ruby and related environments. For rubymotion development, we will use ruby version 1.9.3, and we can create mini-environments for each project. Once you get rvm running, you should never sudo gem install again ever, unless you are installing something that is a command-line tool and should be available system-wide, like rake.

To install: brew install rvm

Now, to get an environment installed for this project, which I'll call "half-modal". First, specify ruby v1.9.3

> rvm use 1.9.3
Using /Users/yournamehere/.rvm/gems/ruby-1.9.3-p125

Neat. You could put this in .bashrc, or someplace like it.

To create a new environment wherein we can install gems, use rvm gemset create [name]

> rvm gemset create half-modal
'half-modal' gemset created (/Users/yournamehere/.rvm/gems/ruby-1.9.3-p125@half-modal).

In the future, activate it using rvm gemset use

> rvm gemset use half-modal
Using ruby-1.9.3-p125 with gemset half-modal

Do you have a way to run a command when you cd into a folder? It would be nifty-whiz-bang to have it automatically activate this version AND gemset when you cd into a project. I use a version of b that supports .b_hook files to accomplish this.

~ > b halfmodal
Using /Users/colinta/.rvm/gems/ruby-1.9.3-p125
Using ruby-1.9.3-p125 with gemset half-modal
~/rubymotion/halfmodal > cat .b_hook
rvm use 1.9.3
rvm gemset use half-modal

Moving on.

Bundler

Bundler's claim to fame is that it makes it easy for a team of developers to keep track of what packages each other are using. You create a Gemfile, which uses a bundler DSL to specify packages, and run bundle install to install the packages. A Gemfile.lock file will be created that contains the version numbers that are in use on the project. Don't touch that file, it will “just work”.

If you do need to update versions, use bundle update.

With your sparkly new gemset active, installing bundler is easy (and remember, you won't use sudo)

> gem install bundler
Fetching: bundler-1.1.5.gem (100%)
Successfully installed bundler-1.1.5
1 gem installed
Installing ri documentation for bundler-1.1.5...
Installing RDoc documentation for bundler-1.1.5...

And now we can create our Gemfile and install files from within.

> echo 'source "https://rubygems.org"'$'\n'$'\n''gem "sugarcube"' > Gemfile
> cat Gemfile
source "https://rubygems.org"

gem "sugarcube"

NOTE You don't have to use rubygems.org, bundler also supports local paths and git repos (and probably more, but that's all I ever need)

gem 'sugarcube', :path => '/Users/yournamehere/rubymotion/sugarcube'
gem 'sugarcube', :git => 'https://github.com/rubymotion/sugarcube'

And now install, sucka!

> bundle install
Fetching gem metadata from https://rubygems.org/...
Installing rake (0.9.2.2)
Installing sugarcube (0.7.2)
Using bundler (1.1.5)
Your bundle is updated! Use `bundle show [gemname]` to see where a bundled gem is installed.

Last step, we update our Rakefile to use bundler.

$:.unshift("/Library/RubyMotion/lib")
require 'motion/project'
require 'bundler'
Bundler.require


Motion::Project::App.setup do |app|
  app.name = 'Half-Modal'
end

That's all I've got to say about the RVM+Bundler work flow. Learn it, use it, love it. That's what it means to “respect yourself and your body”.

OK NOW you can compile

> b halfmodal
Using /Users/colinta/.rvm/gems/ruby-1.9.3-p125
Using ruby-1.9.3-p125 with gemset half-modal
> rake
     Build ./build/iPhoneSimulator-5.1-Development
   Compile ~/.rvm/gems/ruby-1.9.3-p125@half-modal/gems/sugarcube-0.7.2/lib/sugarcube/adjust.rb
   Compile ~/.rvm/gems/ruby-1.9.3-p125@half-modal/gems/sugarcube-0.7.2/lib/sugarcube/array.rb
   Compile ~/.rvm/gems/ruby-1.9.3-p125@half-modal/gems/sugarcube-0.7.2/lib/sugarcube/defaults.rb
   Compile ...
   Compile ./app/my_application_controller.rb
   Compile ./app/app_delegate.rb
    Create ./build/iPhoneSimulator-5.1-Development/Half-Modal.app
      Link ./build/iPhoneSimulator-5.1-Development/Half-Modal.app/Half-Modal
    Create ./build/iPhoneSimulator-5.1-Development/Half-Modal.app/Info.plist
    Create ./build/iPhoneSimulator-5.1-Development/Half-Modal.app/PkgInfo
    Create ./build/iPhoneSimulator-5.1-Development/Half-Modal.dSYM
  Simulate ./build/iPhoneSimulator-5.1-Development/Half-Modal.app
(main)>

And we get a grouped table with a label:

  • keyboard_table.png

    Grouped table style with one cell

When you touch the cell, it will highlight briefly. We are ready to start.

The modal

Our modal view is easy, let's add it:

my_application_controller.rb
def viewDidLoad
  # ...
  @modal_view = UIControl.alloc.initWithFrame(self.view.bounds)  # [[0, 0, 320, 460]], if you are the "show me the numbers" type
  @modal_view.backgroundColor = :black.uicolor(0.5)  # black, with alpha of 0.5
  @modal_view.alpha = 0.0  # hide the view

  self.view << @modal_view
end

If you ran the program at this point nothing would change. Our modal view is being hidden. Let's show it, but let's use the REPL, because the REPL is so damn fun. And, hey!, we're using SugarCube, so let's use the SugarCube::Adjust module to make this even cooler!

app_delegate.rb
include SugarCube::Adjust


class AppDelegate
  # ...

Now boot up the simulator, and at the prompt, type tree. Ohhhh, man, this is gonna be so cool...

(main)> tree
  0: . UIWindow(#115997760, {{0, 0}, {320, 480}})
  1: `-- UIView(#116024320, {{0, 20}, {320, 460}})
  2:     +-- UITableView(#152150528, {{0, 0}, {320, 480}})
  3:     |   +-- UITableViewCell(#116081008, {{0, 46}, {320, 46}})
  4:     |   |   +-- UIGroupTableViewCellBackground(#116112736, {{9, 0}, {302, 46}})
  5:     |   |   +-- UITableViewCellContentView(#116082288, {{10, 1}, {300, 43}})
  6:     |   |   |   +-- UILabel(#116094400, {{10, 11}, {119, 21}})
  7:     |   |   |   `-- UITableViewLabel(#116100640, {{215, 11}, {75, 21}})
  8:     |   |   `-- UIImageView(#116121184, {{10, 1}, {300, 10}})
  9:     |   `-- UITableHeaderFooterView(#116128080, {{0, 10}, {320, 36}})
 10:     |       `-- UITableHeaderFooterViewLabel(#116130192, {{19, 7}, {68, 21}})
 11:     `-- UIControl(#116049120, {{0, 0}, {320, 460}})
=> UIWindow(#115997760, # size=#>, )

Hey, what's up! It's our view hierarchy, starting at the Application window. But don't stop there! See the numbers? You can pass those into the adjust method, and then we can apply changes to it (I prefer the shorthand a, btw)

(main)> a 11
=> UIControl(#116049120, [[0.0, 0.0],{320.0 × 460.0}],  child of UIView #116024320)
(main)> a.fade_in   # make sure you are watching the simulator - better yet, turn on slow animations!
# and when you're done,
(main)> a.fade_out
  • keyboard_fadein.png

    Table with Modal overlay

The modal view will slowly fade in, and the table will become unusable. UIKit automatically enables/disables user interaction when the view is completely invisible.

fade_in/fade_out are provided by SugarCube.

The keyboard view

Our keyboard view hierarchy consists of:

  1. A UIView to hold all these wonderful views.
  2. A UINavigationBar, with 'Cancel' and 'Done' buttons
  3. A UIPickerView, with the “awesome” choices.

Container

@keyboard_view = UIView.alloc.initWithFrame([[0, 460], [320, 260]])  # y: 460, so offscreen, at the bottom.
self.view << @keyboard_view

Code speaks louder than words, but the objects we'll create are:

  • UINavigationBar
  • UINavigationItem
  • UIBarButtonItem for the left button
  • UIBarButtonItem for the right button
nav_bar = UINavigationBar.alloc.initWithFrame([[0, 0], [320, 44]])

item = UINavigationItem.new
item.leftBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(
    UIBarButtonSystemItemCancel,
    target: self,
    action: :cancel)

item.rightBarButtonItem = UIBarButtonItem.alloc.initWithBarButtonSystemItem(
    UIBarButtonSystemItemDone,
    target: self,
    action: :done)

nav_bar.items = [item]  # if you want, play with assigning more items.  I dunno what happens!
@keyboard_view << nav_bar

Picker

Bah, we'll need a delegate and data source. Personally, I like making little itty-bitty baby classes, instead of sticking everything in the UIViewController.

class AwesomePickerDelegate

  def numberOfComponentsInPickerView(picker_view)
    1
  end

  def pickerView(picker_view, numberOfRowsInComponent:section)
    AwesomeLabels.length
  end

  def pickerView(picker_view, titleForRow:row, forComponent:section)
    AwesomeLabels[row].to_s
  end

end

I put this class at the bottom of my_application_controller.rb.

Now create the picker and its delegate/dataSource:

@picker_delegate = AwesomePickerDelegate.new
@picker_view = UIPickerView.alloc.initWithFrame([[0, 44], [320, 216]])
@picker_view.showsSelectionIndicator = true
@picker_view.delegate = @picker_view.dataSource = @picker_delegate
@picker_view.selectRow(@awesomeness, inComponent:0, animated:false)
@keyboard_view << @picker_view

Important: Notice that the delegate is assigned to an instance variable? This is important, actually. If we don't do this, we will have a memory problem, because UIView classes do not retain their delegates or dataSources. Or, in ARC speak, they maintain a weak reference. So we need to maintain a pointer to these objects, else they will be picked up by the garbage collector and you're gonna have a bad time. M’kay?

OK, now how're we doing?

Let's see what our tree looks like:

(main)> tree
 0: . UIWindow(#147741488, {{0, 0}, {320, 480}})
 1: `-- UIView(#147746880, {{0, 20}, {320, 460}})
 2:     +-- UITableView(#120946176, {{0, 0}, {320, 480}})
 3:     |   +-- UITableViewCell(#147796544, {{0, 46}, {320, 46}})
 4:     |   |   +-- UIGroupTableViewCellBackground(#147802800, {{9, 0}, {302, 46}})
 5:     |   |   +-- UITableViewCellContentView(#147798560, {{10, 1}, {300, 43}})
 6:     |   |   |   +-- UILabel(#147799664, {{10, 11}, {119, 21}})
 7:     |   |   |   `-- UITableViewLabel(#147800288, {{215, 11}, {75, 21}})
 8:     |   |   `-- UIImageView(#115204128, {{10, 1}, {300, 10}})
 9:     |   `-- UITableHeaderFooterView(#115210032, {{0, 10}, {320, 36}})
10:     |       `-- UITableHeaderFooterViewLabel(#115218480, {{19, 7}, {68, 21}})
11:     +-- UIControl(#147756240, {{0, 0}, {320, 460}})
12:     `-- UIView(#147734656, {{0, 460}, {320, 260}})
13:         +-- UINavigationBar(#147756736, {{0, 0}, {320, 44}})
14:         |   +-- UINavigationBarBackground(#147757696, {{0, 0}, {320, 44}})
15:         |   +-- UINavigationItemView(#147760208, {{160, 21}, {0, 0}})
16:         |   +-- UINavigationButton(#147762832, {{5, 7}, {60, 30}})
17:         |   |   +-- UIImageView(#147774400, {{0, 0}, {60, 30}})
18:         |   |   `-- UIButtonLabel(#147756976, {{10, 7}, {40, 15}})
19:         |   `-- UINavigationButton(#147773344, {{265, 7}, {50, 30}})
20:         |       +-- UIImageView(#147776864, {{0, 0}, {50, 30}})
21:         |       `-- UIButtonLabel(#147775792, {{10, 7}, {30, 15}})
22:         `-- UIPickerView(#147761536, {{0, 44}, {320, 216}})
23:             +-- UIView(#147758528, {{0, 0}, {320, 216}})
24:             +-- _UIPickerWheelView(#115203440, {{11, 0}, {298, 216}})
25:             +-- UIPickerTableView(#120957952, {{11, 0}, {294, 216}})
26:             |   +-- UIPickerTableViewTitledCell(#147792640, {{0, 88}, {294, 44}})
27:             |   |   +-- UITableViewCellContentView(#147778192, {{0, 0}, {294, 44}})
28:             |   |   `-- UILabel(#147794800, {{9, 0}, {285, 44}})
29:             |   +-- UIPickerTableViewTitledCell(#147788976, {{0, 44}, {294, 44}})
30:             |   |   +-- UITableViewCellContentView(#147786784, {{0, 0}, {294, 44}})
31:             |   |   `-- UILabel(#147793984, {{9, 0}, {285, 44}})
32:             |   +-- UIPickerTableViewTitledCell(#147788560, {{0, 0}, {294, 44}})
33:             |   |   +-- UITableViewCellContentView(#147789920, {{0, 0}, {294, 44}})
34:             |   |   `-- UILabel(#147789808, {{9, 0}, {285, 44}})
35:             |   `-- UIImageView(#147781664, {{287, 123}, {7, 7}})
36:             +-- _UIOnePartImageView(#147786400, {{11, 0}, {298, 216}})
37:             `-- _UIPickerViewTopFrame(#147759008, {{0, 0}, {320, 216}})

We'll fade in the modal view again, and at the same time slide :up the keyboard (another SugarCube UIView extension that is handy).

(Btw, your tree indices may be different - if you scroll around the UITableView, you'll have two UIImageViews that I don't show here.)

(main)> modal = (a 11)
=> UIControl(#147756240, [[0.0, 0.0],{320.0 × 460.0}],  child of UIView #147746880)
(main)> keyboard = (a 12)
=> UIView(#147734656, [[0.0, 460.0],{320.0 × 216.0}],  child of UIView #147746880)
(main)> modal.fade_in ; keyboard.slide :up

If you're watching in the simulator, you will be pleased. End product looks like this:

  • keyboard_slideup.png

    Keyboard and modal overlay

Grand finale

Press the cancel button! CRASH! We'll fix that, and we'll make the awesome table cell activate our keyboard. Then we'll be done!

We will update the selection and show the picker, and fade in the modal, in the ...didSelectRowAtIndexPath... method:

def tableView(table_view, didSelectRowAtIndexPath:index_path)
  table_view.deselectRowAtIndexPath(index_path, animated:true)

  @modal_view.fade_in
  @keyboard_view.slide :up
end

The done and cancel methods save the choice (in done), and undo the slide/fade (in cancel).

def done
  @awesomeness = @picker_view.selectedRowInComponent(0)
  cancel
end

def cancel
  @modal_view.fade_out
  @keyboard_view.slide :down
end

Last touch - if you touch the modal screen, let's have it disappear. This is back in the viewDidLoad method.

@modal_view.on :touch do
  cancel
end

Now go forth and multiply!