We are now familiar with all main iOS features, and we only need to
finish
superapp
to complete this Rubymotion tutorial.
Right now
superapp
looks pretty weird, and can only add new Mood
objects, and show Mood history in the table. I'm afraid nobody
would use this app.
To make it more useful, let's show a simple calendar in a
HomeScreen
. It will show selected month, and each
day cell will represent user's mood with a color: awesome day would be displayed with green color, a good day with blue, average with yellow, and
bad with red. Something like this:
Still does not look good. Unfortunately, it is not a design tutorial so that we will continue with this.
It would take a lot of time to make this calendar by ourselves. Let's go to Cocoapods and look for some calendar charts. After some seconds of searching, I've found TEAChart. It looks just like we want it too.
Note: Unfortunately TEAChart does not work well with AutoLayout, so I had to add some changes to it. In this tutorial, we will use this fork.
Let's think how
HomeScreen
will look like. I see it as a screen with Navigation Bar, Calendar,
and some labels that show statistics for a selected month, for example,
a number of good and bad days. Navigation bar would have current date
as a Title, and buttons to navigate between months.
Let's start with the hardest part - integrating
TEAChart
into the project. To do it, we need to add this
pod
into
Rakefile
. Since we are going to use customized version,
we need to tell
cocoapods
where to look for a repository:
app.pods do
pod 'TEAChart', git: 'https://github.com/savytskyi/TEAChart'
end
And now we just need to install it with
rake pod:install
.
Now we are going to change
HomeScreenLayout
. We will add a calendar to it,
small visual line separator (just for a better UI), and
labels that will show a number of bad and good days. For an easier Layout
customization, these labels will be inside a plain UIView. Here is a
mockup:
TEAContributionGraph
. So to add it to the layout,
we would need to write something like
add TEAContributionGraph, :calendar
.
Let's start!
class HomeScreenLayout < MotionKit::Layout
def layout
# adding calendar:
add TEAContributionGraph, :chart
# separator:
add UIView, :line
# bad days labels container:
add UIView, :bad do
add UILabel, :bad_number
add UILabel, :bad_label
end
# good days labels container:
add UIView, :good do
add UILabel, :good_number
add UILabel, :good_label
end
end
# we want to use same padding values everywhere:
def padding
15
end
def chart_style
weekday_label_format "EEEEEE"
# from TEAChart docs: use showDayNumbers to display day number
# in each day cell:
show_day_numbers true
constraints do
top.equals(:superview, :top).plus(80)
right.equals(:superview, :right).minus(padding)
left.equals(:superview, :left).plus(padding)
height('330')
end
end
def line_style
background_color 0xc4c4c4.uicolor
constraints do
top.equals(:chart, :bottom).plus(30)
left.equals(:superview, :left).plus(padding * 2)
right.equals(:superview, :right).minus(padding * 2)
height(1)
end
end
###
### reusable style - bad and good day number labels look almost the same
###
def mood_number
text "10"
font "Helvetica-Light".uifont(30)
text_alignment NSTextAlignmentCenter
constraints do
top.equals(:superview, :top).plus(5)
center_x.equals(:superview, :center_x)
end
end
# reusable style - bad and good day labels look almost the same
def mood_label
text_alignment NSTextAlignmentCenter
constraints do
bottom.equals(:superview, :bottom).minus(5)
center_x.equals(:superview, :center_x)
end
end
###
### labels container views
###
def good_style
constraints do
top.equals(:bad, :top)
left.equals(:superview, :center_x).plus(padding)
right.equals(:superview, :right).minus(padding)
height(65)
end
end
def bad_style
constraints do
top.equals(:line, :bottom).plus(10)
left.equals(:superview, :left).plus(padding)
right.equals(:superview, :center_x).minus(padding)
height(65)
end
end
###
### Bad and Good day statistics labels:
###
# number of days:
def bad_number_style
# using reusable style added earlier:
mood_number
end
# number of days:
def good_number_style
# using reusable style added earlier:
mood_number
end
# static label:
def bad_label_style
text "Bad days"
# using reusable style added earlier:
mood_label
end
# static label:
def good_label_style
text "Good days"
# using reusable style added earlier:
mood_label
end
end
Here you can find two new things: reusable styles and calendar properties.
Reusable styles are super easy: if you have multiple elements
that have same styles (constraints, fonts, texts, colors), you can
create one method that describes all typical styles, and then it can
be called in all methods that have same visual attributes. In this
example, bad days and good days labels are absolutely the same,
and the only thing that changes from label to label is its text.
Therefore, we have created
mood_label
and
mood_number
methods that define all common attributes for days labels.
Calendar properties can be found in
TEAChart Docs.
If you are an experienced developer, you can check header files
as well. In docs, you can find that
show_day_numbers
will add day number to each day cell, and
more.
Okay, we have the layout ready. Now we need to set the screen. We have many elements here, so let's deal with calendar first. As you can see in the docs, its main delegate methods are:
monthForGraph
- should return datevalueForDay(day)
- should return some number, which
is a value for a given day. In our case, it should return average
mood for a particular day.
numberOfGrades
- shows how many grades (or colors)
calendar will show.
colorForGrade
- helps customize the color for each grade.
will_appear
,
we would need to get Mood data for the chart as well. And only after
we will be able to show something in the graph:
def on_load
self.view.backgroundColor = :white.uicolor
check_local_notifications
@chart = @layout.get(:chart)
@chart.delegate = self
@bad_days = @layout.get(:bad_number)
@good_days = @layout.get(:good_number)
end
def will_appear
set_current_date
end
As you can see, here we got
:chart
,
:bad_number
and
good_number
elements from the layout, and saved them for future use. In
will_appear
,
we added calls to new methods that aren't written yet -
set_current_date
that will set up screen for a given date.
Let's implement this method:
def set_current_date(date=nil)
date = NSDate.now if date.nil?
@date = date
# make string look like "July '15":
string = date.string_with_format("MMMM") + " '" +
date.string_with_format("YY")
self.title = string
set_data
@chart.reloadData
end
Let's talk about the code above. In the method definition, you can see
date=nil
, which means that method can be called without
any arguments, and the app won't crash with a usual
wrong number of arguments (1 for 0)
error - they will be
set to a predefined value, which is in this example is
nil
. Then if the
date
is
nil
,
we will set it to a current date. It is important to save the date for a
future use, so we create an instance variable
@date
that can be accessed outside of the current method.
Then we want to set current date as a screen's title, which is shown
in a navigation bar. We want the title to look like "June '15".
Unfortunately, we can't make with one call of a
string_with_format
because of the
'
symbol so that we will concatenate three strings:
string with a month name (
MMMM
), string with a
'
symbol, and string with a short year (
YY
). Now we can change current screen's title to a new
string.
But this is not the end of the method. Since date has been modified,
we need to get Mood objects that were created for a given month. We
are going to do it in a new method called
set_data
:
def get_mood_objects(date)
min_date = @date.start_of_month
max_date = min_date.end_of_month
predicate = NSPredicate.predicateWithFormat "created_at < %@ AND created_at => %@", max_date, min_date
moods = Mood.with_predicate(predicate).to_a
# group moods by day
moods.group_by do |mood|
mood.created_at.string_with_format("dd")
end
end
def set_data
# reset all values:
@days = {}
bad_days = 0
good_days = 0
# get mood objects for a current date:
moods = get_mood_objects(@date)
# fill @days with new values for a given date:
moods.each do |day, day_moods|
# calculate average mood level for each day:
total = 0
day_moods.each { |mood| total += mood.level }
avg = total.to_f / day_moods.count.to_f
@days[day] = avg
# update number of good and bad days:
if avg < 2
bad_days += 1
elsif avg >= 3
good_days += 1
end
end
# show number of a good and bad days:
@bad_days.text = bad_days.to_s
@good_days.text = good_days.to_s
@chart.reloadData
end
Since calendar shows info for one month only, we need to get
Mood objects for this particular month. To do it, we will need to
get those object from the DB that have the
created_at
value between the beginning and the end
of a current month.
Since a user can create multiple Mood objects per one day, it would
be a good idea to use average mood value for the day. For example,
the user has been
sad in the morning (mood.level == 1). In the afternoon he felt
better (mood.level == 3), and in the evening after some exciting
news he started to feel great (mood.level == 4), it would not be right
to use first available value for a day, and average value would show
more accurate data. In this case, the average would be equal 2.66, which is
higher than "average" level.
We don't want to pollute
set_date
method, so we are going to retrieve Mood
objects and group them by a date in a separate
get_mood_objects(date)
method.
With
sugarcube
's
start_of_month
and
end_of_month
we get date ranges, and then with a predicate
created_at < max_date AND created_at => min_date
we retrieve Mood objects created during the current month.
To get average values for each day, we would need to group results
first. In Ruby, we can do it with a
group_by
operator. It iterates over all elements and
groups them by a value returned in a block. In our case we group them
by a day number string -
string_with_format("dd")
converts the date to a string that
represents day number. Result would look like this:
@grouped
=> {
"1" => [MoodObject1, MoodObject2],
"2" => [MoodObject3],
...
"31" => [MoodObjectXX]
}
When we have all objects grouped by day, we can finally get average
values for each day and calculate a number of good and bad days as well.
Inside of a
set_data
method we create
@days
hash, that will store mood values for each date.
To calculate average mood values and the number of good and bad days
we iterate over just retrieved
moods
hash. When iterating over a hash, Ruby will pass a key and its value
into the block. In our case key is a day number string, and value is
an array of mood objects for this day.
moods.each do |day, day_moods|
...
end
Calculating average value for each day should be pretty clear: we create
a
total
variable set to 0. Then when iterating over all
mood objects for a current day, we add mood's level to a
total
variable.
Then we divide it by a number of mood objects. And now we can recognize
whether the day is good or bad - if the average is less than 2, the day was,
unfortunately, bad. If the average is 3 or higher - the day was good. Mood
levels from 2 to 3 are average so that we won't count them.
total = 0
day_moods.each { |mood| total += mood.level }
avg = total.to_f / day_moods.count.to_f
@days[day] = avg
if avg < 2
bad_days += 1
elsif avg >= 3
good_days += 1
end
When we have all values calculated, we just need to set them and reload a calendar.
@bad_days.text = bad_days.to_s
@good_days.text = good_days.to_s
@chart.reloadData
Now we need to set
TEAChart
delegate methods to make the calendar work. There are 4 of them:
monthForGraph
- should return datevalueForDay(day)
- should return some number, which
is a value for a given day. In our case, it should return average
mood for a particular day.
numberOfGrades
- shows how many grades (or colors)
calendar will show.
colorForGrade
- helps customize the color for each grade.
def monthForGraph
@date
end
def valueForDay(day)
@days[day.to_s].to_f
end
def numberOfGrades
5
end
def colorForGrade(grade)
if grade == 0
0xB3B3B3.uicolor
elsif grade == 1
0xBC5C59.uicolor
elsif grade == 2
0xBCAF36.uicolor
elsif grade == 3
0x58B9BC.uicolor
else
0x48BC63.uicolor
end
end
def minimumValueForGrade(grade)
grade
end
The easiest method is
monthForGraph
- here we just return our current date,
because we want the calendar to render itself for a current month.
valueForDay(day)
is not that difficult as well. We just
need to access
@days
hash and retrieve a value for a given day. Here
we need to remember that
day
variable passed to the method is a number, but Hash
keys are strings. If we don't convert the number to a string, we will not
be able to get the value, so we have to convert it first. Another tricky
thing here is that some days may not have values, and
nil
will be
returned. Calendar expects its delegate to return a number, so we would
need to figure something out. We could add
if/else
condition, but in this case, it is easier to convert everything to Float.
If the value exists, it will be float anyway, since it is an average value.
If the value is
nil
, it will be converted to float as zero.
numberOfGrades
is also easy - it just returns a plain
number. We have four mood levels, and one other grade for empty days.
colorForGrade(grade)
is where we can customize
calendar's palette. You can try to use your own colors:
And last delegate method -
minimumValueForGrade(grade)
. Here we can specify
minimum values for each grade. In our case minimum value for each
grade is a grade itself:
Now we can try to launch the app:
Now we need to add a button to the navigation bar that will change
current date. I an going to use
UIStepper
- simple control with a
+
and
-
buttons. Each time user taps on one of them, we will
get its new value, and process it. Let's add a method that generates
it to
will_appear
:
def will_appear
set_current_date
set_bar_button
end
def set_bar_button
stepper = UIStepper.new
stepper.stepValue = 1
stepper.maximumValue = 0
stepper.minimumValue = -36
stepper.on(:touch) do |event|
value = stepper.value
new_date = NSDate.now + value.month
set_current_date new_date
end
bar_btn = UIBarButtonItem.alloc.initWithCustomView(stepper)
self.navigationItem.setRightBarButtonItems [ bar_btn ]
end
This small part of the code will help users to navigate between months.
First we create a stepper object. Stepper's value will be used to
navigate between months:
Now we need to process stepper's taps. One tap on stepper equals one month:
value.month
, and calculating new date:
value = stepper.value
new_date = NSDate.now + value.month
Now when we have the date that user want to display, we can pass it to
a previously written
set_current_date
method, which will update screen's title
to a name of a new month, will retrieve new Mood objects for a
given date and will render them into a calendar:
value = stepper.value
new_date = NSDate.now + value.month
set_current_date new_date
Don't forget to add stepper into the navigation bar. To do it we need to
create
UIBarButtonItem
with a custom view, where the custom view is the stepper.
It is time to open the app. User should now be able to change the date:
Now when we have a calendar working, let's commit all changes with git:
git add .
git commit -m "calendar added"
In this chapter we have successfully installed a third-party library with Cocoapods, learned how to reuse layout code, worked with a database, and more. At this point, we have a working application that allows a user to track his mood and shows him statistics for each month.