A React.js Pattern for your RubyMotion app
Post summary: I build a "Bitcoin price" RubyMotion iOS app and show how it can be made to follow more of a React.js pattern.
At ClearSight we are venturing beyond Rails and into the realm of React.js and Ember.js client-side JavaScript frameworks. The experience has been both painful and enlightening. Most worthwhile learning experiences are a bit of both.
In this post, I'm going to show how you normally build an app. All of the usual actions (initialization, asynchronous data load, user touches) will affect the view in some way, and the cognitive load will increase. Then I'll refactor using the lessons learned from React.js and show how the cognitive load is reduced drastically.
For this discussion, let's say that we're building an app that retrieves the current Bitcoin price and converts it to various currencies.
Building an app the normal way
We're going to use RedPotion (currently 1.0.0), which is ProMotion and RMQ and several other useful gems pulled together into one.
Start off by generating the app:
$ gem install redpotion
$ potion create bitcoin-app
$ cd bitcoin-app
$ bundle
$ rake pod:install
Build the screen
Generate a new screen using RedPotion's generator:
$ potion create screen bit_coin
This will create a screen file, a stylesheet, and a spec file. We're going to skip the spec file for now, but we will definitely revisit it when we make this more like React.js.
Let's build a very simplified UI using RedPotion's RMQ append
command.
# app/screens/bit_coin_screen.rb
class BitCoinScreen < PM::Screen
title "Bitcoin Price"
stylesheet BitCoinScreenStylesheet
def on_load
append(UILabel, :bitcoin_price).data("Loading...")
append(UILabel, :last_fetched_date).data("...")
end
end
Update your stylesheet
This will be a very simple stylesheet.
# app/stylesheets/bit_coin_screen_stylesheet.rb
class BitCoinScreenStylesheet < ApplicationStylesheet
def root_view(st)
st.background_color = color.white
end
def bitcoin_price(st)
st.frame = {
left: 20,
from_right: 20,
top: 100,
height: 50,
}
st.text_alignment = :center
st.font = UIFont.boldSystemFontOfSize(40.0)
end
def last_fetched_date(st)
st.frame = {
left: 20,
from_right: 20,
below_previous: 100,
height: 50,
}
st.text_alignment = :center
st.font = UIFont.systemFontOfSize(24.0)
end
end
Update the App Delegate
Since this is a single-screen app, your AppDelegate will be very tiny:
class AppDelegate < PM::Delegate
def on_load(app, options)
return true if RUBYMOTION_ENV == "test"
open BitCoinScreen
end
end
Run the app
Run rake
and you should get an app that looks like this:
(Note: if you get "ERROR! Can't locate iPhoneSimulator SDK 8.1" then just go delete the app.sdk_version
line in the Rakefile.)
Add BTC currency fetching
We already have AFMotion enabled in our Gemfile (add it if it's not there with gem "AFMotion"
). So let's fetch the data using the BitcoinAverage Price Index API. In your screen:
# app/screens/bit_coin_screen.rb
class BitCoinScreen < PM::Screen
title "Bitcoin Price"
stylesheet BitCoinScreenStylesheet
def on_load
@bitcoin_prices = {}
@bitcoin_price = nil
@last_fetched = nil
@currency = "USD"
append(UILabel, :bitcoin_price).data("Loading...")
append(UILabel, :last_fetched_date).data("...")
load_prices
end
def load_prices
AFMotion::JSON.get("https://api.bitcoinaverage.com/all") do |result|
if result.success?
@bitcoin_prices = result.object
@bitcoin_price = @bitcoin_prices[@currency]["global_averages"]["last"]
find(:bitcoin_price).data("#{@bitcoin_price} #{@currency}")
find(:last_fetched_date).data(Time.now.strftime("%b %e, %l:%M %p"))
else
mp result
end
end
end
end
This time, when we run rake
we get some data!
Now let's add a button that changes the currency every time you press it. Easy enough, just add this to your on_load
:
# app/screens/bit_coin_screen.rb
class BitCoinScreen < PM::Screen
title "Bitcoin Price"
stylesheet BitCoinScreenStylesheet
def on_load
@bitcoin_prices = {}
@bitcoin_price = nil
@last_fetched = nil
@currency = "USD"
@currencies = [ "USD", "AUD", "CAD", "EUR" ]
append(UILabel, :bitcoin_price).data("Loading...")
append(UILabel, :last_fetched_date).data("...")
append(UIButton, :cycle_currency).data("USD").on(:tap) do
# Get next currency
@currency = @currencies.rotate(@currencies.index(@currency) + 1).first
@bitcoin_price = @bitcoin_prices[@currency]["global_averages"]["last"]
find(:bitcoin_price).data("#{@bitcoin_price} #{@currency}")
end
load_prices
end
# ... omitted
end
Update your stylesheet to set the frame for this new button:
# app/stylesheets/bit_coin_screen_stylesheet.rb
class BitCoinScreenStylesheet < ApplicationStylesheet
# ... omitted
def cycle_currency(st)
st.frame = {
left: 20,
from_right: 20,
below_previous: 100,
height: 50,
}
st.font = UIFont.systemFontOfSize(24.0)
st.color = UIColor.blackColor
end
end
When the user taps the button, it'll cycle through a few currencies and display their prices.
Run it and tap the button.
Here's a link to the code up to this point:
https://github.com/jamonholmgren/bitcoin-app/tree/normal
Refactoring to a React.js pattern
The app works fine, but there are several places that interact with the UI when something happens to reflect the changes.
One of the concepts that I keep coming back to is the idea of the UI being a function of application state; in other words, given a particular set of data, your UI should always look the same. This is one of the fundamental concepts taken from React.js. They refer to this as "one-way data flow".
Let's take a look at what happens when we apply the principle that the UI should reflect the current application state.
What would our app state look like? Let's use a simple hash, updating our on_load
method:
def on_load
@state = {
bitcoin_prices: {},
last_fetched: nil,
currency: "USD",
currencies: [ "USD", "AUD", "CAD", "EUR" ],
}
set_state(@state)
load_prices
end
The line before load_prices
is a new method call, set_state
. Let's build it:
def set_state(state)
# Build the UI initially if it hasn't been built yet
build_initial_ui if find(:bitcoin_price).length == 0
bitcoin_price = bitcoin_price_for_currency(state)
# Set all the UI elements to reflect the current state
find(:bitcoin_price).data("#{bitcoin_price} #{state[:currency]}")
find(:last_fetched_date).data(state[:last_fetched_date])
find(:cycle_currency).data(state[:currency])
end
def build_initial_ui
append(UILabel, :bitcoin_price)
append(UILabel, :last_fetched_date)
append(UIButton, :cycle_currency).on(:tap) do
rotate_currency
end
end
def rotate_currency
@state[:currency] = @state[:currencies].rotate(@state[:currencies].index(@state[:currency]) + 1).first
set_state(@state)
end
def bitcoin_price_for_currency(state)
return "Loading" unless state[:bitcoin_prices][state[:currency]]
state[:bitcoin_prices][state[:currency]]["global_averages"]["last"]
end
There's a lot of familiar code. It checks to see if the views have been built yet and builds them if not. Then, it sets all the state data (like current bitcoin price, currency, button title). If the button is tapped, it calls rotate_currency
which just rotates the currency and calls set_state
again to update the UI.
The load_prices
method gets simpler. Check this out:
def load_prices
AFMotion::JSON.get("https://api.bitcoinaverage.com/all") do |result|
if result.success?
@state[:bitcoin_prices] = result.object
@state[:last_fetched_date] = Time.now.strftime("%b %e, %l:%M %p")
set_state(@state)
else
mp result
end
end
end
There's only one method that manipulates the UI -- set_state
. It can take any state hash that has the proper structure, so the screen just manipulates the @state
hash and calls set_state(@state)
anytime something has changed.
Run it and you'll find that the app works just like before.
Here's a link to the diff:
https://github.com/jamonholmgren/bitcoin-app/commit/fcd635384e4b8e24425faeec5fd3cbbbd3a47367
And the branch:
https://github.com/jamonholmgren/bitcoin-app/tree/react
What's the benefit?
It's about the same amount of code. I'm sure we could refactor the original to bring it in line with the React-like version. So, what benefits do we gain from this approach?
The main benefit is cognitive. The only way the UI ever gets updated is with set_state
, so if you have a UI element that isn't updating properly, you know where to look -- either in set_state
or in something manipulating @state
and calling set_state
.
Another benefit is that the app state is very encapsulated, in one hash. You could serialize that when you exit the app and then unserialize. When you call set_state
with the unserialized data, you'll be right back where you left off. You could even implement an undo feature in not too many lines of code or track history.
You can also provide UI abstraction much easier, such as iPhone and iPad UI versions, or even iOS and Android. Look at this:
def set_state(state)
update_ui_android(state) if android?
update_ui_iphone(state) if ios? && iphone?
update_ui_ipad(state) if ios? && ipad?
end
Consider how complex a UI update would be in a cross-platform app without this single point of abstraction.
And lastly, you can test the UI so much easier. Let's write a test.
Testing
With the first example, there would need to be a lot of mocking or something like instance_variable_set
to put the screen into a state that could be tested. With this, you can easily test the UI.
Go into your spec folder and edit the bit_coin_screen_spec.rb
file like this:
# spec/screens/bit_coin_screen_spec.rb
describe 'BitCoinScreen' do
it "sets the current bitcoin price in USD" do
screen = BitCoinScreen.new
screen.set_state({
bitcoin_prices: {
"USD" => { "global_averages" => { "last" => 2.52 } },
},
last_fetched_date: "2015-03-21",
currency: "USD",
currencies: [ "USD" ],
})
screen.find(:bitcoin_price).data.should == "2.52 USD"
screen.find(:last_fetched_date).data.should == "2015-03-21"
screen.find(:cycle_currency).data.should == "USD"
end
end
Run that spec. It should pass:
BitCoinScreen
- sets the current bitcoin price in USD
1 specifications (3 requirements), 0 failures, 0 errors
It's a fast and effective way to test UI elements. We aren't even mounting the screen in the simulator, but rather just testing it in memory. The speed gains are pretty impressive.
Looking forward
This is just the beginning. The RedPotion team is talking about how to implement a more sophisticated and idiomatic version of this in future versions of RedPotion. Perhaps a render
method in your layout and some sort of view hierarchy diffing mechanism would make sense.
For ClearSight, we find that the set_state
method is where our front end engineers and back end engineers meet. The front end can mock up the data they need to build the UI and call set_state
, and the back end can hook up the real data and call set_state
without worrying about what happens after that all that much. It's a great way to meet in the middle.
If you're interested in being involved in the discussion, I've opened a Github issue on RedPotion. Or let me know what you think on Twitter!
Hat tip to @hackflow for the great article, Boiling React down to a few lines of jQuery, which provided the inspiration for this blog post. Also thanks to Matt Green, Darin Wilson, and Matthew Sinclair for their feedback on early drafts.