Thinking MVVM way with Objective-C
Today I am going to write about one of my most favorite iOS patterns lately. MVVM (Model-View-ViewModel). They say MVC has been in iOS realm for quite a bit. However, if you ask me, MVVM is going to be a big.
Advantage being, you can create viewModel once and then it can be used anywhere from Mac, iPhone, watch or even an AppleTV apps. It's also easier to test the functionality. Unlike regular MVC pattern, you don't have to worry about ViewController testing which in my opinion is nightmare and severely time consuming.
Let's begin now. Our MVVM demo is going to have 4 parts as follows,
1. User Model
2. View model for login
3. View Controller for login (Also called a view)
4. A class to make a (dummy) API request
Let's go through them one by one,
- User Model
#import "JKUser.h"
@implementation JKUser
- (instancetype)initWithDictionary:(NSDictionary*)userDictionary {
self = [super init];
if (!self) { return nil; }
// Properties associated with JKUser object.
_firstName = userDictionary[@"first_name"];
_lastName = userDictionary[@"last_name"];
_authToken = userDictionary[@"auth_token"];
return self;
}
// Custom description for our JKUser object.
- (NSString*)description {
return [NSString stringWithFormat:@"%@\n%@\n%@", self.firstName, self.lastName, self.authToken];
}
@end
- View model for login
#import "JKUserAPI.h"
#import "JKLoginViewModel.h"
@implementation JKLoginViewModel
- (instancetype)init {
self = [super init];
if (!self) { return nil; }
_loginButtonCommandAction = [[RACCommand alloc] initWithSignalBlock:^RACSignal *(id input) {
return [[JKUserAPI sharedAPIManager] userWithDictionary:@{@"first_name": self.firstName, @"last_name": self.lastName}];
}];
@weakify(self);
[[_loginButtonCommandAction.executionSignals flatten] subscribeNext:^(JKUser* x) {
@strongify(self);
self.loggedInUser = x;
}];
[_loginButtonCommandAction.executing subscribeNext:^(NSNumber* x) {
@strongify(self);
self.userLoadingInProgress = [x boolValue];
self.loginInputValid = ![x boolValue];
}];
[_loginButtonCommandAction.errors subscribeNext:^(NSError* x) {
NSLog(@"Error Occurred while logging user in %@", [x localizedDescription]);
}];
[[[RACSignal combineLatest:@[RACObserve(self, firstName), RACObserve(self, lastName)]] map:^NSNumber*(RACTuple* value) {
return @([value[0] length] > 0 && [value[1] length] > 0);
}] subscribeNext:^(id value) {
self.loginInputValid = [value boolValue];
}];
return self;
}
@end
- View Controller for login
#import "JKUser.h"
#import "JKLoginViewModel.h"
#import "ViewController.h"
@interface ViewController ()
// Out loginViewModel. Initializing out ViewController with the viewModel.
@property (nonatomic, strong) JKLoginViewModel* viewModel;
@property (nonatomic, strong) UIActivityIndicatorView* activityIndicatorView;
@end
@implementation ViewController
- (instancetype)initWithViewModel:(JKLoginViewModel *)viewModel {
self = [super initWithNibName:nil bundle:nil];
if (!self) { return nil; }
_viewModel = viewModel;
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Login";
self.view.backgroundColor = [UIColor whiteColor];
// An UIActivityIndicatorView which will begin animating while API request is in progress.
self.activityIndicatorView = [[UIActivityIndicatorView alloc] init];
self.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = NO;
self.activityIndicatorView.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge;
self.activityIndicatorView.color = [UIColor greenColor];
[self.activityIndicatorView setHidesWhenStopped:YES];
[self.view addSubview:self.activityIndicatorView];
// Input fields. Viz. First and Last name.
UITextField* firstNameField = [[UITextField alloc] init];
firstNameField.translatesAutoresizingMaskIntoConstraints = NO;
firstNameField.placeholder = @"First Name";
firstNameField.borderStyle = UITextBorderStyleLine;
[self.view addSubview:firstNameField];
UITextField* lastNameField = [[UITextField alloc] init];
lastNameField.translatesAutoresizingMaskIntoConstraints = NO;
lastNameField.placeholder = @"Last Name";
lastNameField.borderStyle = UITextBorderStyleLine;
[self.view addSubview:lastNameField];
// A login button associated with login screen.
UIButton* loginButton = [[UIButton alloc] init];
loginButton.translatesAutoresizingMaskIntoConstraints = NO;
[loginButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
loginButton.layer.borderColor = [UIColor lightGrayColor].CGColor;
loginButton.layer.borderWidth = 1.0;
[loginButton setTitle:@"Login" forState:UIControlStateNormal];
loginButton.rac_command = _viewModel.loginButtonCommandAction;
[self.view addSubview:loginButton];
// A label to show the details of logged in user.
UILabel* loggedInUserDetails = [[UILabel alloc] init];
loggedInUserDetails.translatesAutoresizingMaskIntoConstraints = NO;
loggedInUserDetails.textAlignment = NSTextAlignmentCenter;
loggedInUserDetails.numberOfLines = 0;
[self.view addSubview:loggedInUserDetails];
// We keep an observer on loginInputValid property associated with JKLoginViewModel viewModel. This property will indicate view if user provided inputs are valid or not.
[RACObserve(self.viewModel, loginInputValid) subscribeNext:^(id x) {
loginButton.enabled = [x boolValue];
loginButton.alpha = [x boolValue] ? 1.0 : 0.5;
}];
// Bind the user provided inputs to viewModel properties.
RAC(self, viewModel.firstName) = [firstNameField rac_textSignal];
RAC(self, viewModel.lastName) = [lastNameField rac_textSignal];
[[RACObserve(self.viewModel, loggedInUser) ignore:nil] subscribeNext:^(JKUser* x) {
loggedInUserDetails.text = x.description;
}];
@weakify(self);
// A loading indicator - Indicates if API request is currently underway.
[RACObserve(self.viewModel, userLoadingInProgress) subscribeNext:^(NSNumber* loading) {
@strongify(self);
if ([loading boolValue]) {
[self.activityIndicatorView startAnimating];
} else {
[self.activityIndicatorView stopAnimating];
}
}];
id topLayoutGuide = self.topLayoutGuide;
NSDictionary* views = NSDictionaryOfVariableBindings(topLayoutGuide, firstNameField, lastNameField, loginButton, loggedInUserDetails);
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:[topLayoutGuide]-20-[firstNameField(30)]-[lastNameField(30)]-40-[loginButton(30)]-[loggedInUserDetails(>=0)]" options:kNilOptions metrics:nil views:views]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[firstNameField]-|" options:kNilOptions metrics:nil views:views]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[lastNameField]-|" options:kNilOptions metrics:nil views:views]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[loginButton]-|" options:kNilOptions metrics:nil views:views]];
[self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[loggedInUserDetails]-|" options:kNilOptions metrics:nil views:views]];
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:_activityIndicatorView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterX multiplier:1.0 constant:0]];
[self.view addConstraint:[NSLayoutConstraint constraintWithItem:_activityIndicatorView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterY multiplier:1.0 constant:0]];
}
@end
- A class to make an API request
#import "JKUser.h"
#import "JKUserAPI.h"
@implementation JKUserAPI
// We create a singleton shareAPIManager to handle all out API interactions.
+ (instancetype)sharedAPIManager {
static JKUserAPI* shareAPI = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shareAPI = [[JKUserAPI alloc] init];
});
return shareAPI;
}
// Once we get userDictionary, we make (dummy) API call and assume API gives us all the information plus auth token back after login.
- (RACSignal*)userWithDictionary:(NSDictionary*)userDictionary {
return [RACSignal createSignal:^RACDisposable *(id subscriber) {
// Delay is added on purpose to simulate API call.
double delayInSeconds = 1.5;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
// Initialize the object JKUser with (dummy) dictionary object received from the server.
[subscriber sendNext:[[JKUser alloc] initWithDictionary:@{@"first_name": userDictionary[@"first_name"], @"last_name": userDictionary[@"last_name"], @"auth_token": @"123adasdsadasdasdasdd"}]];
[subscriber sendCompleted];
});
return nil;
}];
}
@end
This code is hosted on Github. You can clone or download it from JKLoginMVVMDemo repository on GitHub
To summarize, the way it works is as follows,
-
A
view
is responsible for creating views (Of course). Such as input fields, login button and activity indicator -
Our
ReactiveCocoa
binds some of the view inputs such as first and last name toviewModel
, which is turn binds them to its own properties -
Every time input changes, a
viewModel
does validation and informsviewController
if inputs are valid. In this way,viewController
is saved from implementing logic to validate input and if handed over toviewModel
-
When user presses login button, a
RACCommand
associated with it fires login request throughviewModel
. As soon as request if fired, aviewModel
again informsviewController
of ongoing network request.viewController
then starts the spinner to indicate ongoing network request -
Once request is finished,
viewModel
gets an user object back from API and sets theloggedInUser
property of typeJKUser
. AviewController
observing on this property gets an update and updates an UI accordingly as follows.
[[RACObserve(self.viewModel, loggedInUser) ignore:nil] subscribeNext:^(JKUser* x) {
loggedInUserDetails.text = x.description;
}];
In this way, we have achieved to separate out APIs, views, viewModel
logic (Inside viewModel
) and models (JKUser
for example). This enhances the testability of module and avoid any unwanted side effects. ReactiveCocoa
plays an important part in this transition to MVVM
and reasons about the way MVVM
is being used.
Any critics, opinions, improvement suggestions are welcome. If you find any issues with this article or code sample, please do let me know. I will do my best to address it as soon as possible