Thinking MVVM way with Objective-C

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,

  1. A view is responsible for creating views (Of course). Such as input fields, login button and activity indicator

  2. Our ReactiveCocoa binds some of the view inputs such as first and last name to viewModel, which is turn binds them to its own properties

  3. Every time input changes, a viewModel does validation and informs viewController if inputs are valid. In this way, viewController is saved from implementing logic to validate input and if handed over to viewModel

  4. When user presses login button, a RACCommand associated with it fires login request through viewModel. As soon as request if fired, a viewModel again informs viewController of ongoing network request. viewController then starts the spinner to indicate ongoing network request

  5. Once request is finished, viewModel gets an user object back from API and sets the loggedInUser property of type JKUser. A viewController 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