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.
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.
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.
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.
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).
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).
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.
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
Let's move on to Android now!
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).
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.
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.
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.
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!
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
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!