Building News Feed for iOS app with Rails backend
Motivation
Let’s implement news feed for my Groser iOS app, it’s a front end to our same day grocery delivery service in Moscow. When shoppers pick order in the store they update product prices and create new products. This activity will generate events which our clients can view inside iOS app, discover what’s new and find out about price changes.
The idea is to prompt users to return to the app and act on news feed events by ordering groceries.
Create static feed
To start we’ll create Api::NewsFeedController in Rails app:
rails g controller Api/V1/NewsFeed --no-helper --no-assets --no-controller-specs --no-view-specs --no-views
I prefer to build iOS UI first and work on returning real news feed events. So we return JSON of static news items and move on to start working on iOS app.
Let’s start wtih 2 basic kinds of events for now
- New Product. Happens when shopper creates new product which was missing from our catalog
- Price Change. So clients can bargain hunt without visiting store in person
class Api::V1::NewsFeedController < Api::V1::ApiController
respond_to :json
def index
p = Product.find(44806)
events = [
{ event: 'new_product',
product: p.as_json,
time_ago: time_ago_in_words(p.created_at) },
{ event: 'price_updated',
product: p.as_json,
time_ago: time_ago_in_words(p.created_at),
old_price:
number_to_currency(55.0, locale: :ru) },
{ event: 'price_updated',
product: p.as_json,
time_ago: time_ago_in_words(p.created_at),
old_price: number_to_currency(75.0, locale: :ru) },
{ event: 'new_product',
product: p.as_json,
time_ago: time_ago_in_words(p.created_at) }
]
render :json => events
end
end
Format date and currency on backend so that clients won’t have to do that
Building iOS feed consumer
Fire up Xcode and create new Swift class NewsFeedViewController inherit it from UICollectionViewController
class NewsFeedViewController : UICollectionViewController {
}
Drag onto storyboard UICollectionViewController and set it’s class to NewsFeedViewController. We’ll show each event in news feed as a list of collection view cells.
Rough mockup of news feed with 3 item types
Model & Network Layer
Create GRNewsFeedEvent Objective C class to represent news feed events. We use Objective C for model classes because I use Mantle for JSON to models mapping and it isn’t compatible with Swift 2.0. I’ll prefer Swift for new code and use Objective C only when necessary.
GRNewsFeedEvent.h
#import "GRProduct.h"
typedef NS_ENUM(NSUInteger, GRNewsFeedEventType) {
GRNewsFeedPriceChangeEvent = 1,
GRNewsFeedNewProductEvent = 2,
};
@interface GRNewsFeedEvent : MTLModel <MTLJSONSerializing>
@property (nonatomic, readonly) GRNewsFeedEventType eventType;
@property (nonatomic, strong, readonly) GRProduct *product;
@property (nonatomic, copy, readonly) NSString *timeAgo;
@property (nonatomic, copy, readonly) NSString *oldPrice;
@end
GRNewsFeedEvent.m
#import "GRNewsFeedEvent.h"
@implementation GRNewsFeedEvent
+ (NSDictionary *)JSONKeyPathsByPropertyKey {
return @{
@"eventType": @"event",
@"product": @"product",
@"timeAgo": @"time_ago",
@"oldPrice": @"old_price"
};
}
+ (NSValueTransformer *)eventTypeJSONTransformer {
NSDictionary *events = @{
@"price_updated": @(GRNewsFeedPriceChangeEvent),
@"new_product": @(GRNewsFeedNewProductEvent),
};
return [MTLValueTransformer reversibleTransformerWithForwardBlock:^(NSString *event) {
return events[event];
} reverseBlock:^(NSNumber *event) {
return [events allKeysForObject:event].lastObject;
}];
}
+ (NSValueTransformer *)productJSONTransformer {
return [NSValueTransformer mtl_JSONDictionaryTransformerWithModelClass:GRProduct.class];
}
@end
Next up, add new method to network service GRGroserService to request all news feed events (it will be disastrous in production, we’ll add paging later)
GRGroserService.m
- (void)loadNewsFeedCompletion:(GRClientCompletionBlock)completion {
NSDictionary *parameters = [self buildParameters:nil];
[self GET:@"news_feed" parameters:parameters completion:^(OVCResponse *response, NSError *error) {
completion(response.result, error);
}];
}
Method buildParameters creates request parameters with device id, user access token for signed in user, timestamp, etc for logging.
Setup JSON to model mapping with Overcoat, it acts as a glue between Mantle and AFNetworking returning parsed models from REST requests.
+ (NSDictionary *)modelClassesByResourcePath {
return @{
@"news_feed": GRNewsFeedEvent.class
};
}
Completion block passed to loadNewsFeedCompletion will receive NSArray of GRNewsFeedEvents.
Event presentation
Create new UICollectionViewCell descendant for each event type. Add initialization method to load data from model into view
Layout collection view cells in storyboard and setup subview outlets
We’ll use similar layout for New Product and Price Change cells
Format product name, price and size using NSAttributedString. Display price and size under product name, without using second label and setting up contraints. Also make a thin grey border around cell in awakeFromNib.
NewProductEventCell.swift
class NewProductEventCell: UICollectionViewCell {
@IBOutlet var imageView: UIImageView!
@IBOutlet var nameLabel: UILabel!
@IBOutlet var timeAgoLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
layer.borderWidth = 0.5;
layer.borderColor = UIColor.init(red:0.882, green:0.905, blue:0.900, alpha: 1.0).CGColor
}
func setup(event: GRNewsFeedEvent) {
let product = event.product
imageView.sd_setImageWithURL(product.imageSmallURL, placeholderImage: UIImage.init(named: "missing-item"))
timeAgoLabel.text = event.timeAgo
let details = "\(product.priceString) • \(product.sizeString)"
let attributes = [
NSForegroundColorAttributeName: nameLabel.textColor,
NSFontAttributeName: nameLabel.font
]
let attributedTitle = NSMutableAttributedString.init(string: "\(product.name)\n\(details)", attributes: attributes)
let paragraphStyle = NSMutableParagraphStyle.init()
paragraphStyle.paragraphSpacingBefore = 6
attributedTitle.setAttributes([
NSForegroundColorAttributeName: UIColor.init(white:0.568, alpha:1.000),
NSParagraphStyleAttributeName : paragraphStyle],
range:NSMakeRange(event.product.name.characters.count, details.characters.count));
nameLabel.attributedText = attributedTitle
}
}
Display events in UICollectionView
Returning to our view controller. Implement UICollectionViewDataSource protocol and make cells as wide as our view in viewDidLoad.
class NewsFeedViewController: UICollectionViewController {
var events : [GRNewsFeedEvent]?
override func viewDidLoad() {
super.viewDidLoad()
// Set item width equal to view width
let layout = self.collectionView!.collectionViewLayout as! UICollectionViewFlowLayout
layout.itemSize = CGSizeMake(self.view.frame.size.width, layout.itemSize.height)
}
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return events!.count
}
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let event = events[indexPath.row]
var cell: UICollectionViewCell
switch event.eventType {
case .NewProductEvent:
cell = collectionView.dequeueReusableCellWithReuseIdentifier("NewProductCell", forIndexPath: indexPath)
(cell as! NewProductEventCell).setup(event)
case .PriceChangeEvent:
cell = collectionView.dequeueReusableCellWithReuseIdentifier("PriceChangeCell", forIndexPath: indexPath)
(cell as! PriceChangeEventCell).setup(event)
}
return cell
}
}
Time to request news feed events from backend. Call following method from viewDidLoad.
func loadEvents() {
GRGroserService.shared().loadNewsFeedCompletion({
// Define capture list and make self reference weak, to avoid retain cycles
[weak self] (events: AnyObject!, error: NSError!) -> Void in
if let strongSelf = self {
if (error == nil) {
strongSelf.events = events as? [GRNewsFeedEvent]
strongSelf.collectionView?.reloadData()
}
}
})
}
Again, you don’t have to know everything before starting to work. In this case I didn’t know how to define weak self reference for closure with arguments in Swift (I’m still learning it), quick Google search and I found Stack Overflow answer.
Wire up NewsFeedViewController with TabBarController
let feedController = UINavigationController.init(rootViewController: mainStoryboard.instantiateViewControllerWithIdentifier("NewsFeedViewController"))
feedController.tabBarItem = UITabBarItem.init(title: "НОВОСТИ", image: UIImage.init(named: "tab-feed"), tag: 0)
Run the app
I declared events var as optional and when tried to unwrap it in collectionView:numberOfItemsInSection I got EXC_BAD_ACCESS exception. From my Objective C days I got used that nil is equivalent to 0, so if events is nil, call to count will be evaluated as nil and be casted to 0.
override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return events!.count
}
Swift is strongly typed language, so I had to initialize events var as empty array.
var events : [GRNewsFeedEvent] = []
Yay, at least it doesn’t crash :)
Not what I expected, let’s see what is happening. To start I’ll add more pleasant background color to CollectionView, and make cells’ background white.
Next, It seems that server returns HTTP 500 error, because I use time_ago_in_words view helper inside NewsFeedController, update it to include ActionView::Helpers::DateHelper and ActionView::Helpers::NumberHelper for number_to_currency helper.
class Api::V1::NewsFeedController < Api::V1::ApiController
include ActionView::Helpers::DateHelper
include ActionView::Helpers::NumberHelper
It’s nice to know that app is doing something, and actually works. Show UIActivityIndicator while events are being loaded. And here is the result.
Groser News Feed
Making it real
I realized that product’s price will keep changing after creation, and using current product’s price in news feed is not correct. We have to keep track new product’s price in news event.
I’ll start by creating NewsItem model in our rails app
rails g model NewsItem product:references attributes:json --no-test-framework
Obviously each news item is related to Product so we reference it, everything else will be kept in Postgres json field. To keep up with Rails’s convention I’ll rename NewsFeedController to NewsItemsController.
Sometimes product has to be deleted, because it’s a duplicate, remove related news items as well.
product.rb
class Product < ActiveRecord::Base
has_many :news_items, dependent: :destroy
end
Generating NewsItems for new products
To generate NewsItem for new products I’ll use Product’s after_create callback. But to keep Product model focused, I’ll use Publish/Subscribe mechanism. Since I already use Whisper gem in other parts of the project, include Wisper::Publisher into Product model and broadcast product_created event.
class Product < ActiveRecord::Base
include Wisper::Publisher
has_many :news_items, dependent: :destroy
after_create :broadcast_product_created
private
def broadcast_product_created
broadcast(:product_created, self, self.unit_price)
end
end
I can’t subscribe to product_created event on the Product instance before it’s created and subscribing after creation is too late. Thankfully Whisper has mechanism for temporary global observers, let’s use it.
class Api::V1::Shoppers::ProductsController < ApplicationController
def create
# Globally subscribe listener for the duration of a block
Wisper.subscribe(ProductUpdatesListener.new) do
product = Product.create(product_params)
end
respond_with product
end
end
class ProductUpdatesListener
def product_created(product, price)
attributes = {
event: :new_product,
price: price
}
NewsItem.create(product: product, attributes: attributes)
end
end
Generating NewsItems for product price updates
In the same vein, let’s create NewsItems for product price updates. Update Product definition with after_update callback.
class Product < ActiveRecord::Base
include Wisper::Publisher
has_many :news_items, dependent: :destroy
after_create :broadcast_product_created
before_update :broadcast_product_price_updated
private
def broadcast_product_created
broadcast(:product_created, self, self.unit_price)
end
def broadcast_product_price_updated
broadcast(:product_price_updated, self, self.unit_price, self.unit_price_was) if self.store_price_changed?
end
end
I use unit_price, and track store_price changes because unit_price is the calculated price shown to consumer, and store_price is what will change when shopper updates price while picking an order in store.
Make price_updated event more informative and extend it with change percentage and direction.
product_updates_listener.rb
def product_price_updated(product, price, old_price)
relative_change = price - old_price
percent = (relative_change / old_price) * 100).abs.round
attributes = {
event: :price_updated,
price: price,
old_price: old_price,
percent: percent,
discounted: relative_change < 0
}
NewsItem.create(product: product, attributes: attributes)
end
Update NewsItemsController to return NewsItems
Order NewsItems from most to least recent and render them as JSON using JBuilder. Since we use views to generate JSON, view helper includes also gone from the controller.
NewsItemsController
class Api::V1::NewsItemsController < Api::V1::ApiController
respond_to :json
def index
@news_items = NewsItem.includes(:product).order('created_at DESC')
respond_with @news_items
end
end
views/api/news_items/index.json.jbuilder
json.array!(@items) do |news_item|
json.extract! news_item, :product
json.extract! news_item.attributes, :event, :percent, :discount
json.price number_to_currency(news_item.attributes['price'], locale: :ru)
json.old_price number_to_currency(news_item.attributes['old_price'], locale: :ru) if news_item.attributes['old_price']
json.time_ago time_ago_in_words(news_item.created_at)
end
Seed with real news items
Here we are going to use treasure trove of historic data we’ve been tracking on server to create NewsItems. First step is to write rake task to generate NewsItems with new Products for the last 30 days.
To avoid code duplication extract specific NewItem creation from ProductUpdatesListener into NewItem class methods.
NewsItem
class NewsItem < ActiveRecord::Base
belongs_to :product
validates_presence_of :product
def self.new_product(product, store_price, created_at = nil)
parameters = {
event: :new_product,
price: product.marked_up_price(store_price)
}
create(product: product, parameters: parameters, created_at: created_at)
end
def self.price_updated(product, store_price, old_store_price)
relative_change = store_price - old_store_price
percent = ((relative_change / old_store_price) * 100).abs.round
parameters = {
event: :price_updated,
price: product.marked_up_price(store_price),
old_price: product.marked_up_price(old_store_price),
percent: percent,
discounted: relative_change < 0
}
create(product: product, parameters: parameters)
end
end
In the following snippet we select all products which where created in the last month for the only store we deliver from, and create NewsItems for each Product. Product always contains current price, however it may have been created with another price. We try to find WarehouseProductPrice for this product created on the same date which keeps history of product price updates.
lib/tasks/news_feed.rake
namespace :news_items do
desc "Seed news feed with items"
task seed: :environment do
store = Store.find_by_name('Ашан')
# Generate NewsItem new_product items
Product.where('created_at >= ?', 1.month.ago.beginning_of_day).each do |product|
warehouse_product_price = WarehouseProductPrice.where('DATE(created_at) = ? AND product_id = ?', product.created_at.to_date, product.id).first
price = warehouse_product_price ? warehouse_product_price.price : product.store_price
NewsItem.new_product(product, price, product.created_at)
end
# Generate NewsItem price_updated items
WarehouseProductPrice.includes(:product)
.where('warehouse_product_prices.created_at >= ?', 1.month.ago.beginning_of_day)
.order('warehouse_product_prices.created_at DESC')
.group_by {|item| item.product }
.each do |product, items|
items.each_cons(2) do |price_after, price_before|
NewsItem.price_updated(product, price_after.price, price_before.price)
end
end
end
end
WarehouseProductPrice keeps history of product price updates and NewsItem requires previous product price. We iterate over WarehouseProductPrices with sliding window of 2 elements. Since we ordered records from most recently created to least recently, first item in pair is the price after update and second is the price before update.
I pass created_at attribute when creating NewsItem, otherwise NewsItem.created_at will be current date. Final version of NewsItem:
class NewsItem < ActiveRecord::Base
belongs_to :product
validates_presence_of :product
def self.new_product(product, store_price, created_at = nil)
parameters = {
event: :new_product,
price: product.marked_up_price(store_price)
}
create(product: product, parameters: parameters, created_at: created_at)
end
def self.price_updated(product, store_price, old_store_price, created_at = nil)
relative_change = store_price - old_store_price
percent = ((relative_change / old_store_price) * 100).abs.round
parameters = {
event: :price_updated,
price: product.marked_up_price(store_price),
old_price: product.marked_up_price(old_store_price),
percent: percent,
discounted: relative_change > 0
}
create(product: product, parameters: parameters, created_at: created_at)
end
end
Let’s see how it all works
rake news_items:seed
ActiveRecord::DangerousAttributeError: attributes is defined by Active Record
First error I see, is that I used existing attribute name NewsItem.attributes, rename it to NewsItem.parameters and update NewsItem and JBuilder template. Now rake news_items:seed
generates NewsItems from history.
Enhance news feed
I want to let users easily tell which CollectionView cell is new product and which is price update. Also I will show if price rose or declined and by how much, using text and color.
Create NewsItemsHelper view helper and format title of the NewsItem.
app/helpers/api/v1/news_items_helper.rb
module Api::V1::NewsItemsHelper
def news_item_title(news_item)
case news_item.params[:event]
when 'new_product'
"Новый продукт: "
when 'price_updated'
if news_item.params[:discounted]
"Скидка #{news_item.params[:percent]}%: "
else
"Подорожание #{news_item.params[:percent]}%: "
end
end
end
end
I also added two new convience methods to NewsItem. price_updated?
needed for conditional JSON generation, and params
allows access to NewsItem.parameters by symbol.
def price_updated?
params[:event] == 'price_updated'
end
def params
@params ||= parameters.with_indifferent_access
end
views/api/news_items/index.json.jbuilder
json.array!(@news_items) do |news_item|
json.extract! news_item, :product
json.title news_item_title(news_item)
json.extract! news_item.params, :event
json.time_ago time_ago_in_words(news_item.created_at)
json.price number_to_currency(news_item.params[:price], locale: :ru)
if news_item.price_updated?
json.extract! news_item.params, :percent, :discounted
json.old_price number_to_currency(news_item.params[:old_price], locale: :ru)
end
end
Quick check that everything works as expected. Instead of curl
I use httpie that formats response nicely.
http localhost:3000/api/v1/news_items.json
[
{
"event": "new_product",
"price": "65,53 р.",
"product": {
"container": "bulk",
"id": 44957,
"name": "Ананас",
"shelf_id": 945,
"unit": "kg",
"unit_price": 195.0,
"unit_size": 1.0
},
"time_ago": "6ч",
"title": "Новый продукт: "
},
]
Refactor iOS app news items related code
After finishing with our server side let’s make naming consistent with our server side model, individual events become News Items and one CollectionView cell can present both NewsItem types.
- Rename
GRNewsFeedEvent
toGRNewsItem
, andGRNewsFeedEventType
toGRNewsItemType
. - Rename
NewProductEventCell
toNewsItemCell
- Remove
PriceChangeEventCell
collection view cell and handle NewsItem title formatting inNewsItemCell
- Rename NewsItems REST route from
news_feed
tonews_items
in our networking code - Change CollectionViewCell reuse identifier to
NewsItemCell
NewsItemCell.swift
class NewsItemCell: UICollectionViewCell {
@IBOutlet var imageView: UIImageView!
@IBOutlet var nameLabel: UILabel!
@IBOutlet var timeAgoLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
layer.borderWidth = 0.5;
layer.borderColor = UIColor.init(red:0.882, green:0.905, blue:0.900, alpha: 1.0).CGColor
}
func setup(event: GRNewsItem) {
imageView.sd_setImageWithURL(event.product.imageSmallURL, placeholderImage: UIImage.init(named: "missing-item"))
timeAgoLabel.text = event.timeAgo
nameLabel.attributedText = buildName(event)
}
func buildName(event: GRNewsItem) -> NSAttributedString {
let product = event.product
let details = buildDetails(event)
let attributes = [
NSForegroundColorAttributeName: nameLabel.textColor,
NSFontAttributeName: nameLabel.font
]
let attributedTitle = NSMutableAttributedString.init(string: "\(event.title)\(product.name)\n\(details)", attributes: attributes)
let paragraphStyle = NSMutableParagraphStyle.init()
paragraphStyle.paragraphSpacingBefore = 6
let beginningOfDetails = attributedTitle.length - details.characters.count
let titleRange = NSMakeRange(0, event.title.characters.count - 1)
// Format event title
attributedTitle.addAttribute(NSFontAttributeName, value: UIFont.init(name: "HelveticaNeue-Medium", size: nameLabel.font.pointSize)!, range: titleRange)
// Format product details
attributedTitle.setAttributes([
NSForegroundColorAttributeName: UIColor.init(white:0.568, alpha:1.000),
NSParagraphStyleAttributeName : paragraphStyle],
range:NSMakeRange(beginningOfDetails, details.characters.count - 1));
nameLabel.attributedText = attributedTitle
switch event.eventType {
case .NewProduct:
// We are good here
break
case .PriceChange:
let color = event.discounted.boolValue ? UIColor( red: 0.2199, green: 0.652, blue: 0.4592, alpha: 1.0 ) : UIColor( red: 0.8055, green: 0.2338, blue: 0.2122, alpha: 1.0 )
attributedTitle.addAttribute(NSForegroundColorAttributeName, value: color, range: titleRange)
// Strike through old price
attributedTitle.addAttribute(NSStrikethroughStyleAttributeName, value: NSUnderlineStyle.StyleSingle.rawValue, range: NSMakeRange(beginningOfDetails, event.oldPrice.characters.count - 1))
}
return attributedTitle
}
func buildDetails(event: GRNewsItem) -> String {
var details: String
switch event.eventType {
case .NewProduct:
details = "\(event.price) • \(event.product.sizeString)"
case .PriceChange:
details = "\(event.oldPrice)\n\(event.price) • \(event.product.sizeString)"
}
return details
}
}
I hit an exception NSUnknownKeyException reason: UICollectionViewCell setValue:forUndefinedKey: this class is not key value coding-compliant for the key imageView
, probably because there was some reference in storyboard to removed 2nd collection view cell, hit Shift + Command + K
to clean project, built again and exception has gone. After couple of finishing touches our News Feed looks like this.
News Feed after implementing all of the above steps
Empty state
Let’s remove confusion around what News Feed is supposed to display when there are no news items or connection is offline and news items can’t be loaded. I’ll use DZNEmptyDataSet Cocoapod.
Implement DZNEmptyDataSetSource and DZNEmptyDataSetDelegate protocols. Below is the partial update of NewsFeedViewController with parts relevant to DZNEmptyDataSet protocols’ implementation. I added new boolean property to hide empty data set view while we are waiting for network response. And added button to allow user to manually reload news feed if it’s empty.
NewsFeedViewController.swift
class NewsFeedViewController: UICollectionViewController, DZNEmptyDataSetSource, DZNEmptyDataSetDelegate {
internal var isLoading = false
override func viewDidLoad() {
collectionView!.emptyDataSetSource = self
collectionView!.emptyDataSetDelegate = self
}
func loadEvents() {
isLoading = true
startActivityIndicator()
collectionView!.reloadEmptyDataSet()
GRGroserService.shared().loadNewsFeedCompletion({
// Define capture list and make self reference weak, to avoid retain cycles
[weak self] (events: AnyObject!, error: NSError!) -> Void in
if let strongSelf = self {
strongSelf.isLoading = false
strongSelf.stopActivityIndicator()
if (error == nil) {
strongSelf.events = events as! [GRNewsItem]
strongSelf.collectionView?.reloadData()
}
strongSelf.collectionView?.reloadEmptyDataSet()
}
})
}
func titleForEmptyDataSet(scrollView: UIScrollView!) -> NSAttributedString! {
let attributes = [NSFontAttributeName: UIFont(name: "HelveticaNeue", size: 20.0)!,
NSForegroundColorAttributeName: UIColor(red:0.435, green:0.463, blue:0.479, alpha:1.000)]
return NSAttributedString.init(string: "Нет изменений в каталоге товаров", attributes: attributes)
}
func descriptionForEmptyDataSet(scrollView: UIScrollView!) -> NSAttributedString! {
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .ByWordWrapping
paragraph.alignment = .Center
let attributes = [NSFontAttributeName: UIFont.systemFontOfSize(14.0),
NSForegroundColorAttributeName: UIColor(red:0.519, green:0.531, blue:0.543, alpha:1.000),
NSParagraphStyleAttributeName: paragraph]
return NSAttributedString.init(string: "Здесь вы можете узнать о добавлении новых товаров в каталог и об изменении цен на существующие товары", attributes: attributes)
}
func imageForEmptyDataSet(scrollView: UIScrollView!) -> UIImage! {
return UIImage.init(named: "blank-state-newsfeed")
}
func buttonTitleForEmptyDataSet(scrollView: UIScrollView!, forState state: UIControlState) -> NSAttributedString! {
return NSAttributedString.init(string: "Загрузить снова", attributes: [NSForegroundColorAttributeName: GRTheme.emptyStateButtonColor()])
}
func configureButtonAppearance(button: UIButton!) {
button.tintAdjustmentMode = .Dimmed;
button.layer.borderColor = GRTheme.emptyStateButtonColor().CGColor;
button.layer.borderWidth = 1.0;
button.layer.cornerRadius = 3.0;
button.tintColor = GRTheme.emptyStateButtonColor();
}
func emptyDataSetShouldDisplay(scrollView: UIScrollView!) -> Bool {
return !isLoading
}
func emptyDataSetDidTapButton(scrollView: UIScrollView!) {
loadEvents()
}
func offsetForEmptyDataSet(scrollView: UIScrollView!) -> CGPoint {
return CGPoint(x: 0, y: -self.tabBarController!.tabBar.frame.height / 2)
}
}
News feed empty state
Pull to refresh
Right now news feed loads one time when user opens the tab. For the impatient ones, who would like to refresh news feed at will we add Pull to Refresh control, the one where you pull down on scrollable view to refresh it’s contents. I stumbled on MAGearRefreshControl which I immediately liked.
MAGearRefreshControl Sample
I tried to install it using Cocoapods, but control requires minimum iOS 8, and I have to support iOS 7 for some time. I’ll just import source file into Xcode project.
Next, implement MAGearRefreshDelegate protocol and configure colors.
func setupRefreshControl() {
navigationController?.navigationBar.translucent = false
refreshControlView = MAGearRefreshControl(frame: CGRectMake(0, -collectionView!.bounds.height, collectionView!.frame.width, collectionView!.bounds.height))
refreshControlView.backgroundColor = UIColor(red: 0.1221, green: 0.4543, blue: 0.3261, alpha:1.0) //UIColor(red: 34/255.0, green: 75/255.0, blue: 150/255.0, alpha: 1.0)
// UIColor.initRGB(92, g: 133, b: 2316)
refreshControlView.addInitialGear(12, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 1.0), radius:16)
refreshControlView.addLinkedGear(0, nbTeeth:16, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 0.8), angleInDegree: 30)
refreshControlView.addLinkedGear(0, nbTeeth:32, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 0.4), angleInDegree: 190)
refreshControlView.addLinkedGear(1, nbTeeth:40, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 0.4), angleInDegree: -30)
refreshControlView.addLinkedGear(2, nbTeeth:24, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 0.8), angleInDegree: -190)
refreshControlView.addLinkedGear(3, nbTeeth:10, color: UIColor(red:57.0/255, green: 237.0/255, blue: 166.0/255, alpha: 1.0), angleInDegree: 40)
refreshControlView.setMainGearPhase(0)
refreshControlView.delegate = self
refreshControlView.showBars = false
collectionView!.addSubview(refreshControlView)
}
// MARK: - UIScrollViewDelegate protocol conformance
override func scrollViewDidScroll(scrollView: UIScrollView) {
refreshControlView.MAGearRefreshScrollViewDidScroll(scrollView)
}
override func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
refreshControlView.MAGearRefreshScrollViewDidEndDragging(scrollView)
}
// MARK: - MAGearRefreshDelegate protocol conformance
func MAGearRefreshTableHeaderDataSourceIsLoading(view: MAGearRefreshControl) -> Bool {
return isLoading
}
func MAGearRefreshTableHeaderDidTriggerRefresh(view: MAGearRefreshControl) {
loadEvents()
}
I also added boolean argument to loadEvents(showActivityIndicator: Bool)
to disable activity indicator when update was triggered by refresh control.
News Feed Pull to Refresh
Show related product
It would be strange to see product price updates and won’t be able to act on it. Final piece, show ProductViewController when user taps news item.
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
let event = events[indexPath.row]
let navController = UIStoryboard.init(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("ProductNavViewController") as! UINavigationController
let controller = navController.topViewController as! GRProductViewController
controller.order = GRShoppingCart.shared()
controller.product = event.product;
presentViewController(navController, animated: true, completion: nil)
}
Production News Feed
Show maximum 50 events for the last month.
class Api::V1::NewsItemsController < Api::V1::ApiController
respond_to :json
def index
@news_items = NewsItem.includes(:product)
.order('created_at DESC')
.where('created_at >= ?', 1.month.ago.beginning_of_day)
.limit(50)
respond_with @news_items
end
end
I use Capistrano for code deployment, so I need to add deploy.rb action to run rake task on server.
desc 'Seed news items'
task :seed_news_items do
on roles(:app) do
within release_path do
with rails_env: fetch(:rails_env) do
execute :rake, 'news_items:seed'
end
end
end
end
Commit, deploy code to production and seed news items.
cap production deploy
cap production deploy:seed_news_items
And here is the final result:
Production News Feed
As I expected there is a lot of noise, shoppers make mistakes and reverse price updates, or price deviates too little, there is no sense in displaying such noise.
I added filter to discard price updated news items if percetage change less than 3%:
return if percent <= 3
And now we are done!
Final production News Feed