Skip to content

Instantly share code, notes, and snippets.

@lrz
Last active November 30, 2018 14:43
Show Gist options
  • Save lrz/8f2c4350ac80d032dad8 to your computer and use it in GitHub Desktop.
Save lrz/8f2c4350ac80d032dad8 to your computer and use it in GitHub Desktop.

RubyMotion Workshop Exercice

We are going to write two simple apps in RubyMotion: a basic timer app for iOS, and one for Android as well. These will be two different projects and code bases.

The goal is to allow you, for each mobile platform, to understand how to create a basic app, to call the native APIs, and interact with the RubyMotion toolchain. The projects will not share any code on purpose.

We will start with the iOS version.

Timer.app (iOS)

Create a new project

Let's create a new project by using the motion create command.

$ motion create timer-ios
$ cd timer-ios

Make sure to cd into the directory that has been created. You will see that a bunch of files have been created as well. A Rakefile has been created, this is the central configuration file of the project.

The rake config task will dump the entire project configuration. Feel free to open the Rakefile, you will see that it only sets the app.name variable, the rest of the project configuration is generated for you by RubyMotion.

The rake -T command will list all the Rake tasks available.

If you type rake, the project will build and start in the iOS simulator. You should see an empty window, which is expected, you haven't written any code yet. If you open the app/app_delegate.rb file you will see that RubyMotion generated code that creates a window with an empty view controller in it. We will have to override this.

Create the TimerController class

View controllers are iOS classes that manage the views that make a portion (screen) of the user interface of your app. The UIViewController is fully documented by Apple: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewController_Class

We will now create a UIViewController subclass called TimerController. Create the app/timer_controller.rb file with your favorite editor and add the following in it:

class TimerController < UIViewController
  def viewDidLoad
    puts "TimerController loaded"
  end
end

Here, we are subclassing the native iOS UIViewController class and overriding its #viewDidLoad method, which will be called when the view controller has been loaded. We will just leave a debug statement here for now.

Now, edit the app/app_delegate.rb file to have the following content instead:

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    @window = UIWindow.alloc.initWithFrame(UIScreen.mainScreen.bounds)
    @window.rootViewController = TimerController.alloc.init
    @window.rootViewController.wantsFullScreenLayout = true
    @window.makeKeyAndVisible
    true
  end
end

This will create a UIWindow with the same size as the screen, then create an instance of our TimerController class and assign it as the root controller of the window. Then, we will simply make the window visible.

If you type rake again, you should see the debug statement printed in the console.

Create a label

Change the TimerController#viewDidLoad method with the following:

  def viewDidLoad
    margin = 20

    @state = UILabel.new
    @state.font = UIFont.systemFontOfSize(30)
    @state.text = 'Tap to start'
    @state.textAlignment = UITextAlignmentCenter
    @state.textColor = UIColor.whiteColor
    @state.backgroundColor = UIColor.clearColor
    @state.frame = [[margin, 200], [view.frame.size.width - margin * 2, 40]]
    view.addSubview(@state)
  end
end

This piece of code creates a new UILabel object, which is basically a user interface label widget. We configure the label for a given font, initial text string, text alignment, text color and background color, all using native properties of the control.

The UILabel class is documented here: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UILabel_Class/

Finally, we provide a frame for the label; the position and size of the label in the coordinate system of its superview (here, the main view of the view controller).

The documentation for the UIView#frame property is available here: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIView_Class/index.html#//apple_ref/occ/instp/UIView/frame

If you type rake you should see your label displayed in the iOS simulator.

REPL views selection

Now might be a good time to show off a great feature of the RubyMotion REPL; the ability to select views within the iOS simulator.

Once you see the Ruby interactive prompt, maintain the COMMAND key and move your cursor over the iOS simulator window, and over the label. You will see that the UILabel instance is reflected in the REPL, and if you click it will be set as the current REPL object (self).

From there, feel free to examine the UILabel instance. Try for instance to change its color to yellow, by typing self.textColor = UIColor.yellowColor.

You can also change the label frame. Try calling self.frame= with a different Array object. (Keep it mind that it has to be an array of 2 arrays which should each contain 2 numeric values).

Create a button

Add the following lines in your TimerController#viewDidLoad method, right after the @state label is created.

    @action = UIButton.buttonWithType(UIButtonTypeRoundedRect)
    @action.setTitle('Start', forState:UIControlStateNormal)
    @action.setTitle('Stop', forState:UIControlStateSelected)
    @action.addTarget(self, action:'actionTapped', forControlEvents:UIControlEventTouchUpInside)
    @action.frame = [[margin, 260], [view.frame.size.width - margin * 2, 40]]
    view.addSubview(@action)

This will create a new button, provide custom titles for normal and selected states, assign a target and action to the button, and set the frame of the button right below the label.

The addTarget:action:forControlEvents: method is the most interesting here; it configures the button so that a given message will be sent to a given object if a given event happens. Here, we want the button to send the actionTapped message on self when it receives a touch-up event from the system.

If you run rake you should see the button, but if you tap on it the program will crash with the following exception:

2015-06-03 02:37:03.156 timer-ios[7309:13962462] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[TimerController actionTapped]: unrecognized selector sent to instance 0x10f50a030'

This is obviously expected, TimerController#actionTapped does not exist yet.

Let's provide a simple implementation for now:

  def actionTapped
    puts "tap!"
  end

Now, if you run rake again, you should see the message in the terminal if you tap the button.

The UIButton class is documented here: https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIButton_Class. However, you won't see the documentation for addTarget:action:forControlEvents:, how is that? (Hint: check the subclasses).

Create a timer

Now let's add the final piece of code; the timer. You can replace the #actionTapped method with the following:

  def actionTapped
    if @timer
      @timer.invalidate
      @timer = nil
    else
      @duration = 0
      @timer = NSTimer.scheduledTimerWithTimeInterval(0.1, target:self, selector:'timerFired', userInfo:nil, repeats:true)
    end
    @action.selected = [email protected]?
  end

This code, when ran for the first time, will create a new NSTimer object, configured with a 0.1 second interval. The timer will send the timerFired message to self, and will repeat indefinitely.

At this stage you should be able to look up the documentation for the NSTimer class by yourself!

If the code is ran a second time, we will invalidate the timer, as we want the button to start and stop the timer.

Notice the final line in the method, which reverses the selected state of the button. This is necessary so that the "Stop" and "Start" titles are set appropriately.

If you run rake you will get a well-deserved runtime exception. We haven't implemented the #timerFired method on our controller, which the timer will try to call.

  def timerFired
    @state.text = "%.1f" % (@duration += 0.1)
  end

And this is all! The timer app should be fully functional at this point.

Reference

RubyMotion for iOS Runtime Guide: http://www.rubymotion.com/developers/guides/manuals/cocoa/runtime

RubyMotion for iOS Project Management Guide: http://www.rubymotion.com/developers/guides/manuals/cocoa/project-management

The full source code of that sample is available here: https://github.com/HipByte/RubyMotionSamples/tree/master/ios/Timer

Timer.apk (Android)

Let's move on to Android now!

Create a new project

We will create a new project, this time for Android. Run the following command:

$ motion create --template=android android-timer
$ cd android-timer

As you can see, in order to create an Android project in RubyMotion, the --template=android argument has to be provided to motion create, since RubyMotion will create iOS projects by default.

You can cd to the directory that has been created and examine the files in there. As with iOS, the Rakefile will contain the project configuration for you. The rake -T command will dump all the Rake tasks available, and rake config will print the project configuration.

If you run the rake command you will see a build error immediately:

./build/Development-22/AndroidManifest.xml:2: Tag <manifest> attribute package has invalid character '-'.

It seems that Android will not let us use the - character in our application name! Fix that in the Rakefile and run rake again. It should build the app and run it in the Genymotion emulator. (If it doesn't, make sure Genymotion is properly installed and started first, then try again).

Create a label

Change the app/main_activity.rb file with the following:

class MainActivity < Android::App::Activity
  def onCreate(savedInstanceState)
    super

    layout = Android::Widget::LinearLayout.new(self)
    layout.orientation = Android::Widget::LinearLayout::VERTICAL

    @label = Android::Widget::TextView.new(self)
    @label.text = 'Tap to start'
    @label.textSize = 80.0
    @label.gravity = Android::View::Gravity::CENTER_HORIZONTAL
    layout.addView(@label)

    self.contentView = layout
  end
end

In this code we create an Android::Widget::LinearLayout view, which lets us pack subviews vertically. This class is documented here: http://developer.android.com/reference/android/widget/LinearLayout.html

We also create an Android::Widget::TextView object which will be our label; we provide a text string, a proper size and configure its gravity as horizontal (which means it will be horizontally centered). This class is documented here: http://developer.android.com/reference/android/widget/TextView.html

It is important to assign the layout as the content view of the activity.

If you run rake again you should see your label appearing in the emulator.

Create a button

In the #onCreate method, add the following code after setting the @label variable:

    @button = Android::Widget::Button.new(self)
    @button.text = 'Start'
    @button.onClickListener = self
    layout.addView(@button)

This code will create, as expected, an Android::Widget::Button object. The class is documented here: http://developer.android.com/reference/android/widget/Button.html

We provide a text value and we assign the click listener property as self. Now this is important, the Java runtime will now assume that our MainActivity class implements the OnClickListener interface, which is documented here: http://developer.android.com/reference/android/view/View.OnClickListener.html

If you type rake now the project will crash at runtime because we do not conform to that interface yet.

As you can see from the documentation, this interface only contains one method: #onClick. We can provide a simple implementation for now:

  def onClick(view)
    puts "click"
  end

Now, running rake should show your label and button, and clicking on the button should print the message in the terminal.

Create a timer

Now we can rewrite our #onClick method to do something more interesting, such as starting a timer! Use the following:

  def onClick(view)
    if @timer
      @timer.cancel
      @timer = nil
      @button.text = 'Start'
    else
      @timer = Java::Util::Timer.new
      @counter = 0
      task = TimerTask.new
      task.activity = self
      @timer.schedule task, 0, 100
      @button.text = 'Stop'
    end
  end

As with the iOS version, we will create a new Java::Util::Timer object the first time this code runs, and invalidate it the second time.

In this snippet we are creating an instance of a class that does not exist yet: TimerTask. Why are we doing this? If you check the Java::Util::Timer#schedule documentation (at http://developer.android.com/reference/java/util/Timer.html) you will see that it expects a Java::Util::TimerTask object, which is expected to be subclassed.

Let's create our own subclass:

class TimerTask < Java::Util::TimerTask
  attr_accessor :activity
  
  def run
  end
end

You can paste this code either in the app/main_activity.rb file or in a new .rb file, it does not matter much.

If you type rake the application should keep working as before, except that our TimerTask#run method will now be called several times.

Change the label from the timer handler

Here is the problem: our TimerTask#run method will be called from another thread of execution, and we can't do UI work on secondary threads. If we do, there will be a runtime exception.

We need to figure out a way to set the text of our label from the main thread instead. For this, Android provides a convenience class, called Android::Os::Handler, documented here: http://developer.android.com/reference/android/os/Handler.html

Once created, an Android::Os::Handler object keeps a reference to the thread where it has been created. Then, any thread can call its #post method and pass an object that implements the Java::Lang::Runnable interface (we will come to that later), and it will be called from the original thread.

So, all we have to do is create an Android::Os::Handler object from the main thread. We will do this at the very beginning of the MainActivity#onCreate method, and also defining an attr_reader method to expose it.

class MainActivity < Android::App::Activity
  attr_reader :handler

  def onCreate(savedInstanceState)
    @handler = Android::Os::Handler.new
    super
    # ...
  end
end

Then, we can update our TimerTask#run method:

class TimerTask < Java::Util::TimerTask
  attr_accessor :activity

  def run
    # This method will be called from another thread, and UI work must
    # happen in the main thread, so we dispatch it via a Handler object.
    @activity.handler.post -> { @activity.updateTimer }
  end
end

As you can see, we call the #post method of our handler object and provide a Ruby Proc object as its argument. In RubyMotion for Android, Proc conveniently implements the Java::Lang::Runnable interface.

In this block, we can send a message back to the activity to update its timer. Here we used #updateTimer which we will need to define:

  def updateTimer
    @label.text = "%.1f" % (@counter += 0.1)
  end

And done! Our timer app should now be complete! Try running rake and make sure it works as expected!

Reference

RubyMotion for Android Runtime Guide: http://www.rubymotion.com/developers/guides/manuals/android/runtime

RubyMotion for Android Project Management Guide: http://www.rubymotion.com/developers/guides/manuals/android/project-management

The full source code of that sample is available here: https://github.com/HipByte/RubyMotionSamples/tree/master/android/Timer

Challenges

As you can see, writing user interface components programmatically can be complicated and tedious. The iOS APIs are verbose. The RubyMotion community has been working on high-level abstractions. RedPotion is an effort to collect useful RubyMotion for iOS gems, while BluePotion does the same for RubyMotion for Android.

A nice user interface builder DSL is RubyMotionQuery (RMQ). Check out http://rubymotionquery.com and try re-implementing the user interface of the iOS timer app with it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment