-
Notifications
You must be signed in to change notification settings - Fork 67
Tutorial
This goes on for a while, so here's a table of contents in case you want to skip ahead.
- Setting Things Up
- Defining a Model
- Adding "Columns" to a Model
- Fleshing Out the Tasks Model
- Hooking Your Model Up to a UITableViewController
- Now That You Have a Table, How Do You Add Data?
- About Adding New Data
$ motion tutorial
$ cd tutorial
Now create a .gitignore
so we can use git without getting a bunch of junk in our repository:
.repl_history
build
resources/*.nib
resources/*.momd
resources/*.storyboardc
.DS_Store
doc/**/*.*
doc
*.gem
Finally, let's add a few gems to sauce things up in a root directory file called Gemfile
:
source "https://rubygems.org"
gem "rake"
gem "motion-cocoapods", "1.3.0.rc1"
gem 'bubble-wrap'
gem "sugarcube"
gem "motion_model", :git => "[email protected]:sxross/MotionModel.git"
We'll talk just a bit about these as the tutorial goes along, but the focus is on MotionModel, so you should refer to the documentation on each of them for more information.
A bit more housekeeping before I go on. The reason for choosing a Gemfile
is so I can use Bundler, but letting Bundler install gems into the System Ruby directory is not necessarily a great idea. To localize my gems to a project, I use RVM. You can find out more about it here. It may look a little daunting, but once you're using it, you'll see that keeping your exact gem versions locked down using Bundler and RVM is quite useful. There are other tools than RVM for accomplishing this, but you should choose one. Once you have something in place for installing gems and Rubies someplace outside the System directory, install the most recent patch of Ruby 1.9.2. The reason for this is that RubyMotion is built on top of the 1.9.2 syntax.
Now, in your Rakefile
right under the require for motion/project
put this:
require 'bundler'
Bundler.require
And from the command line, do:
bundle install
If your system prompts you for a password, you didn't get RVM (or its substitute) installed right and/or active for this project.
The first thing I typically do is sketch out my data structure. Some people coming from an IB background may start with UI mockups, but I prefer to figure out what I need to store and how. Let's start with a test:
# spec/models/task_spec.rb
describe 'Task' do
it 'a Task can be instantiated' do
Task.create.should.is_a Task
end
end
Delete spec/main_spec.rb
because it's really of no use and then:
$ rake spec
This, of course, fails with the error:
Task
- can be instantiated [ERROR: NameError - uninitialized constant Task]
Of course, we don't yet have a Task
model. So let's create one in app/models/task.rb
.
class Task
end
and again, run the spec. Not the error has changed to:
Task
- can be instantiated [ERROR: NoMethodError - undefined method `create' for Task:Class]
So we have Task class, but it doesn't have a create
method. As create
is the "C" in CRUD, we can assume that there's a part of model behavior missing. This is where MotionModel comes in.
class Task
include MotionModel::Model
include MotionModel::ArrayModelAdapter
end
By simply including the MotionModel modules and a persistence mechanism, in this case ArrayModelAdapter, we can turn our Ruby class into something that can behave as a model. But we only demonstrated it by the side effect of it being able to create
a Task
. Let's move on.
I put "columns" in quotes because MotionModel is persistence-agnostic. That part is handled by its adapters. So the notion of columns is less accurate than "attributes". Here's how to add some:
# spec/models/task_spec.rb
# Add to your current spec
it 'can create a task with a task_name attribute' do
task = Task.create(task_name: 'Prepare Monthly Analysis of Sales')
task.task_name.should == 'Prepare Monthly Analysis of Sales'
end
As you would expect, there is another error:
- can create a task with a task_name attribute [ERROR: NoMethodError - undefined method task_name=]
NoMethodError: undefined method task_name=
Of course! task_name
wasn't part of the model. But how do we tell MotionModel we want this attribute to be part of the data model? Simple:
class Task
include MotionModel::Model
include MotionModel::ArrayModelAdapter
columns :task_name => :string # <= Add this!
end
You can create a model with attributes -- one that can be persisted -- with one line of code. Ok, not really, we needed the other stuff too, but we only added one line to create an attribute that behaves as a model attribute.
We're, of course, going to need some other attributes to adequately describe a task, so let's add a few.
it 'has the attributes required of a Task' do
task = Task.new
[:task_name, :details, :due_date, :created_at, :updated_at].each do |attribute|
task.should.respond_to attribute
end
end
Note: I'm not recommending you write a spec exactly like the one above. It does violate the principle of one test per example. But it's handy because you can require all the fields you need via spec in one fell swoop. Your testing methodology may differ. Of course, this one will fail because we haven't added these attributes, so we'll do that now.
class Task
include MotionModel::Model
include MotionModel::ArrayModelAdapter
columns :task_name => :string,
:details => :text,
:due_date => :date,
:created_at => :date,
:updated_at => :date
end
So there you have it -- a model that passes your specs. But what about adding a bit of functionality to the model. Let's say we need to know if a task is overdue.
Before we move on, let's add some other Ruby coolness in. This is why I love using Ruby -- all the cool stuff that makes my life easier. Open your Gemfile
and put this line in:
gem 'motion-redgreen'
Then type the command:
$ bundle install
Now, open your Rakefile
and add this to the config block:
Motion::Project::App.setup do |app|
# Use `rake config' to see complete project settings.
app.name = 'tutorial'
app.redgreen_style = :full # default: :focused <= Add this
end
Now, when you type:
rake spec
you get a wonderful color-coded output that congratulates you for a job well done and reminds you when you need to fix something.
Let's add the spec for overdue?
:
it 'knows if a task is not overdue' do
task = Task.create(task_name: 'Prepare Monthly Analysis of Sales', due_date: 1.day.hence)
task.should.not.be.overdue
end
it 'knows if a task is overdue' do
task = Task.create(task_name: 'Prepare Monthly Analysis of Sales', due_date: 1.day.ago)
task.should.be.overdue
end
Now when you run:
$ rake spec
the results are quite different:
- knows if a task is not overdue
[✗] This test has not passed: ERROR: NoMethodError - undefined method overdue?!
[ERROR: NoMethodError - undefined method overdue?]
BACKTRACE: NoMethodError: undefined method overdue?
model.rb:785:in `method_missing:': Task - knows if a task is not overdue
spec.rb:663:in `block in method_missing:'
spec.rb:646:in `satisfy:'
spec.rb:663:in `method_missing:'
spec.rb:279:in `block in run_spec_block'
spec.rb:403:in `execute_block'
spec.rb:279:in `run_spec_block'
spec.rb:294:in `run'
- knows if a task is overdue
[✗] This test has not passed: ERROR: NoMethodError - undefined method overdue?!
[ERROR: NoMethodError - undefined method overdue?]
BACKTRACE: NoMethodError: undefined method overdue?
model.rb:785:in `method_missing:': Task - knows if a task is overdue
spec.rb:663:in `block in method_missing:'
spec.rb:646:in `satisfy:'
spec.rb:663:in `method_missing:'
spec.rb:279:in `block in run_spec_block'
spec.rb:403:in `execute_block'
spec.rb:279:in `run_spec_block'
spec.rb:294:in `run'
The failure is the same as without red-green, but the output is ANSI-colored to draw your attention to what failed. Of course, that's the overdue?
method. You can now define this method in your model as follows:
def overdue?
due_date < Time.now
end
You are free to write these "virtual attributes" in your code to provide calculated results and so on.
Thus far, we haven't delved into making this a real iOS app. Here's where MotionModel really gets fun to use. First, let's put some skeleton code together to create a simple UITableViewController
as the main window.
I'm deliberately not explaining much about the UI code because that's not what MotionModel is about. There are plenty of other places to learn about iOS coding, so bear with me.
The first file you have to flesh out it your app_delegate.rb
. I'm going to put the code out here for you to work with.
class AppDelegate
attr_reader :window
def application(application, didFinishLaunchingWithOptions:launchOptions)
@window = UIWindow.alloc.initWithFrame UIScreen.mainScreen.bounds
load_data
@task_controller = TasksViewController.alloc.init
@navigation_controller = UINavigationController.alloc.initWithRootViewController(@task_controller)
@window.rootViewController = @navigation_controller
@window.makeKeyAndVisible
true
end
def load_data
Task.create task_name: "Get a Driver's License",
details: "Learn how to drive, read the manual, go and pass the test",
due_date: "2013-08-16"
end
end
There's no smoke and mirrors here. Just standard setup code. Except the call to load_data
. That's where we will handle initializing the data MotionModel is managing. In this case, I'm just putting one task of seed data so we can see it works. As you can see, creating tasks can be done without assigning to a variable -- there is the concept of a data store independent of your application, much as there would be in an SQL database.
Next, we'll create controllers/tasks_view_controller.rb
. This will be the controller that handles showing the tasks.
class TasksViewController < UITableViewController
def viewDidLoad
super
self.tableView.dataSource = self.tableView.delegate = self
@tasks = Task.all
end
def numberOfSectionsInTableView(view)
1
end
def tableView(view, numberOfRowsInSection:section)
Task.count
end
def tableView(view, cellForRowAtIndexPath:indexPath)
cell = view.dequeueReusableCellWithIdentifier(cell_identifier)
if not cell
cell = UITableViewCell.alloc.initWithStyle UITableViewCellStyleSubtitle, reuseIdentifier:cell_identifier
end
task = @tasks[indexPath.row]
cell.textLabel.text = task.task_name
cell.detailTextLabel.text = task.details
cell
end
def cell_identifier
'task_cell'
end
end
Let me step through this a bit. The way I look at viewDidLoad
is that it's a great place to initialize things that are not view-related (i.e., they don't need iOS to set up a window size or do any layout). In this case, I'm loading all the tasks into an instance variable so I can quickly index it when I deliver cells in my dataSource.
In tableView:numberOfRowsInSection:
I can just return Task.count to deliver the correct number of rows.
Finally, in tableView:cellForRowAtIndexPath:
, I'm just using the array contained in the instance variable to populate the view.
So let's see it:
$ rake
And there you have it. An iOS app with a table view that has the one seed data item.
That creating data items, counting them, and retrieving them is simple using MotionModel and that most of the code here focuses on iOS.
MotionModel issues notifications when you add, remove, or update data. You can observe these notifications. Why use notifications? For several reasons:
- Notifications allow for loose coupling. Your app does not need to know anything about MotionModel, or in fact, what caused MotionModel to make the change. So, if you have a background thread that periodically pulls data from the Internet and adds it to your data store, MotionModel will issue the proper notifications to alert any controllers that are watching that these events happened.
- Thread safe. Often, it is good to run expensive tasks like network data fetching, in a separate thread to keep the UI experience smooth. Notifications make sure that you are not depending on the data change happening in a particular thread, and the notification is issued only after the operation is complete, assuring you that it will be safe to read and/or write again.
Let's modify the controller a bit:
MotionModelDataDidChangeNotification = 'MotionModelDataDidChangeNotification'
class TasksViewController < UITableViewController
def viewDidLoad
super
self.tableView.dataSource = self.tableView.delegate = self
@task_model_change_observer = App.notification_center.observe MotionModelDataDidChangeNotification do |notification|
if notification.object.is_a?(Task)
reload_data
end
end
end
def viewWillDisappear(animated)
App.notification_center.unobserve @task_model_change_observer
end
def reload_data
@tasks = Task.all
self.tableView.reloadData
end
def numberOfSectionsInTableView(view)
1
end
def tableView(view, numberOfRowsInSection:section)
Task.count
end
def tableView(view, cellForRowAtIndexPath:indexPath)
cell = view.dequeueReusableCellWithIdentifier(cell_identifier)
if not cell
cell = UITableViewCell.alloc.initWithStyle UITableViewCellStyleSubtitle, reuseIdentifier:cell_identifier
end
task = @tasks[indexPath.row]
cell.textLabel.text = task.task_name
cell.detailTextLabel.text = task.details
cell
end
def cell_identifier
'task_cell'
end
end
I also made one modification to AppDelegate -- I moved the load_data
method down to a point where the view will already have loaded and begun listening for notifications.
class AppDelegate
attr_reader :window
def application(application, didFinishLaunchingWithOptions:launchOptions)
@window = UIWindow.alloc.initWithFrame UIScreen.mainScreen.bounds
@task_controller = TasksViewController.alloc.init
@navigation_controller = UINavigationController.alloc.initWithRootViewController(@task_controller)
@window.rootViewController = @navigation_controller
@window.makeKeyAndVisible
load_data
true
end
def load_data
Task.create task_name: "Get a Driver's License",
details: "Learn how to drive, read the manual, go and pass the test",
due_date: "2013-08-16"
end
end
I've included the whole class for the TasksViewController
, but here are the highlights of the modifications:
- Added an observer for the model change notifications using BubbleWrap's notification wrappers.
- Made sure I'm only listening for Task data modifications.
- Reload the data whenever there is a change.
I can rely on the notifications even for the data loaded initially because the view is loaded before I call load_data
in AppDelegate.
Let's take it for a spin:
$ rake
Now we have an iOS app with the one seed data entry, as expected. Now what? In the REPL, type this (make sure you can see both your terminal window and the simulator):
(main)> Task.create :task_name => 'Enroll in Particle Physics Class', :details => "I may be an overachiever... don't know."
=> Task#2:0x9de4fe0
Watch and marvel as the new Task is added into your table view!
Well, we learned that handling notifications is a gee-whiz, nifty way to keep your table view in sync with changes in MotionModel's data store.
At some point, you'll want to add some new data to your collection.
Let's pull in formation
using our Gemfile
to help us gather input. Add the following line to Gemfile
:
gem 'formotion'
Because we're embedding our UITableViewController
inside a UINavigationController
, the logical place for the add button is on the navigation bar, so let's make that come true. Add the following method to your TasksViewController
class:
def on_add(sender)
task = Task.new
task_input_controller = TaskInputController.alloc.initWithForm(task)
self.navigationController.pushViewController(task_input_controller, animated:true)
end