Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

A ViewModel pattern for iOS apps with ReactiveCocoa

I'm working on integrating RAC into my project with the goal of creating a ViewModel layer that will allow easy caching/prefetching from the network (plus all of the other benefits of MVVM). I'm not especially familiar with MVVM or FRP yet, and I'm trying to develop a nice, reusable pattern for iOS development. I have a couple of questions about this.

First, this is sort of how I've added a ViewModel to one of my views, just to try it out. (I want this here to reference later).

In ViewController viewDidLoad:

@weakify(self)

//Setup signals
RAC(self.navigationItem.title) = self.viewModel.nameSignal;
RAC(self.specialtyLabel.text) = self.viewModel.specialtySignal;
RAC(self.bioButton.hidden) = self.viewModel.hiddenBioSignal;
RAC(self.bioTextView.text) = self.viewModel.bioSignal;

RAC(self.profileImageView.hidden) = self.viewModel.hiddenProfileImageSignal;    

[self.profileImageView rac_liftSelector:@selector(setImageWithContentsOfURL:placeholderImage:) withObjectsFromArray:@[self.viewModel.profileImageSignal, [RACTupleNil tupleNil]]];

[self.viewModel.hasOfficesSignal subscribeNext:^(NSArray *offices) {
    self.callActionSheet = [[UIActionSheet alloc] initWithTitle:@"Choose Office" delegate:self cancelButtonTitle:nil destructiveButtonTitle:nil otherButtonTitles:nil];
    self.directionsActionSheet = [[UIActionSheet alloc] initWithTitle:@"Choose Office" delegate:self cancelButtonTitle:nil destructiveButtonTitle:nil otherButtonTitles:nil];
    self.callActionSheet.delegate = self;
    self.directionsActionSheet.delegate = self;
}];

[self.viewModel.officesSignal subscribeNext:^(NSArray *offices){
    @strongify(self)
    for (LMOffice *office in offices) {
        [self.callActionSheet addButtonWithTitle: office.name ? office.name : office.address1];
        [self.directionsActionSheet addButtonWithTitle: office.name ? office.name : office.address1];

        //add offices to maps
        CLLocationCoordinate2D coordinate = {office.latitude.doubleValue, office.longitude.doubleValue};
        MKPointAnnotation *point = [[MKPointAnnotation alloc] init];
        point.coordinate = coordinate;
        [self.mapView addAnnotation:point];
    }

    //zoom to include all offices
    MKMapRect zoomRect = MKMapRectNull;
    for (id <MKAnnotation> annotation in self.mapView.annotations)
    {
        MKMapPoint annotationPoint = MKMapPointForCoordinate(annotation.coordinate);
        MKMapRect pointRect = MKMapRectMake(annotationPoint.x, annotationPoint.y, 0.2, 0.2);
        zoomRect = MKMapRectUnion(zoomRect, pointRect);
    }
    [self.mapView setVisibleMapRect:zoomRect animated:YES];
}];

[self.viewModel.openingsSignal subscribeNext:^(NSArray *openings) {
    @strongify(self)
    if (openings && openings.count > 0) {
        [self.openingsTable reloadData];
    }
}];

ViewModel.h

@property (nonatomic, strong) LMProvider *doctor;
@property (nonatomic, strong) RACSubject *fetchDoctorSubject;

- (RACSignal *)nameSignal;
- (RACSignal *)specialtySignal;
- (RACSignal *)bioSignal;
- (RACSignal *)profileImageSignal;
- (RACSignal *)openingsSignal;
- (RACSignal *)officesSignal;

- (RACSignal *)hiddenBioSignal;
- (RACSignal *)hiddenProfileImageSignal;
- (RACSignal *)hasOfficesSignal;

ViewModel.m

- (id)init {
    self = [super init];
    if (self) {
        _fetchDoctorSubject = [RACSubject subject];

        //fetch doctor details when signalled
        @weakify(self)
        [self.fetchDoctorSubject subscribeNext:^(id shouldFetch) {
            @strongify(self)
            if ([shouldFetch boolValue]) {
                [self.doctor fetchWithCompletion:^(NSError *error){
                    if (error) {
                        //TODO: display error message
                        NSLog(@"Error fetching single doctor info: %@", error);
                    }
                }];
            }
        }];
    }
    return self;
}

- (RACSignal *)nameSignal {
    return [RACAbleWithStart(self.doctor.displayName) distinctUntilChanged];
}

- (RACSignal *)specialtySignal {
    return [RACAbleWithStart(self.doctor.primarySpecialty.name) distinctUntilChanged];
}

- (RACSignal *)bioSignal {
    return [RACAbleWithStart(self.doctor.bio) distinctUntilChanged];
}

- (RACSignal *)profileImageSignal {
    return [[[RACAbleWithStart(self.doctor.profilePhotoURL) distinctUntilChanged]
            map:^id(NSURL *url){
                if (url && ![url.absoluteString hasPrefix:@"https:"]) {
                    url = [NSURL URLWithString:[NSString stringWithFormat:@"https:%@", url.absoluteString]];
                }
                return url;
            }]
            filter:^BOOL(NSURL *url){
                return (url != nil && ![url.absoluteString isEqualToString:@""]);
            }];
}

- (RACSignal *)openingsSignal {
    return [RACAbleWithStart(self.doctor.openings) distinctUntilChanged];
}

- (RACSignal *)officesSignal {
    return [RACAbleWithStart(self.doctor.offices) distinctUntilChanged];
}

- (RACSignal *)hiddenBioSignal {
    return [[self bioSignal] map:^id(NSString *bioString) {
        return @(bioString == nil || [bioString isEqualToString:@""]);
    }];
}

- (RACSignal *)hiddenProfileImageSignal {
    return [[self profileImageSignal] map:^id(NSURL *url) {
        return @(url == nil || [url.absoluteString isEqualToString:@""]);
    }];
}

- (RACSignal *)hasOfficesSignal {
    return [[self officesSignal] map:^id(NSArray *array) {
        return @(array.count > 0);
    }];
}

Am I right in the way I'm using signals? Specifically, does it make sense to have bioSignal to update the data as well as a hiddenBioSignal to directly bind to the hidden property of a textView?

My primary question comes with moving concerns that would have been handled by delegates into the ViewModel (hopefully). Delegates are so common in iOS world that I'd like to figure out the best, or even just a moderately workable, solution to this.

For a UITableView, for example, we need to provide both a delegate and a dataSource. Should I have a property on my controller NSUInteger numberOfRowsInTable and bind it to a signal on the ViewModel? And I'm really unclear on how to use RAC to provide my TableView with cells in tableView: cellForRowAtIndexPath:. Do I just need to do these the "traditional" way or is it possible to have some sort of signal provider for the cells? Or maybe it's best to leave it how it is, because a ViewModel shouldn't really be concerned with building the views, just modifying the source of the views?

Further, is there a better approach than my use of a subject (fetchDoctorSubject)?

Any other comments would be appreciated as well. The goal of this work is to make a prefetching/caching ViewModel layer that can be signalled whenever needed to load data in the background, and thus reduce wait times on the device. If anything reusable comes out of this (other than a pattern) it will of course be open source.

Edit: And another question: It looks like according to the documentation, I should be using properties for all of the signals in my ViewModel instead of methods? I think I should configure them in init? Or should I leave it as-is so that getters return new signals?

Should I have an active property as in the ViewModel example in ReactiveCocoa's github account?

like image 648
Evan Cordell Avatar asked Jul 09 '13 14:07

Evan Cordell


2 Answers

The view model should model the view. Which is to say, it shouldn't dictate any view appearance itself, but the logic behind whatever the view appearance is. It shouldn't know anything about the view directly. That's the general guiding principle.

On to some specifics.

It looks like according to the documentation, I should be using properties for all of the signals in my ViewModel instead of methods? I think I should configure them in init? Or should I leave it as-is so that getters return new signals?

Yes, we typically just use properties that mirror their model properties. We'd configure them in -init kinda like:

- (id)init {
    self = [super init];
    if (self == nil) return nil;

    RAC(self.title) = RACAbleWithStart(self.model.title);

    return self;    
}

Remember that view models are just models for a specific use. Plain old objects with plain old properties.

Am I right in the way I'm using signals? Specifically, does it make sense to have bioSignal to update the data as well as a hiddenBioSignal to directly bind to the hidden property of a textView?

If the bio signal's hiddenness is driven by some specific model logic, it'd make sense to expose it as a property on the view model. But try not to think of it in view terms like hiddenness. Maybe it's more about validness, loading, etc. Something not tied to specifically how it's presented.

For a UITableView, for example, we need to provide both a delegate and a dataSource. Should I have a property on my controller NSUInteger numberOfRowsInTable and bind it to a signal on the ViewModel? And I'm really unclear on how to use RAC to provide my TableView with cells in tableView: cellForRowAtIndexPath:. Do I just need to do these the "traditional" way or is it possible to have some sort of signal provider for the cells? Or maybe it's best to leave it how it is, because a ViewModel shouldn't really be concerned with building the views, just modifying the source of the views?

That last line is exactly right. Your view model should give the view controller the data to display (an array, set, whatever), but your view controller is still the table view's delegate and data source. The view controller creates cells, but the cells are populated by data from the view model. You could even have a cell view model if your cells are relatively complex.

Further, is there a better approach than my use of a subject (fetchDoctorSubject)?

Consider using a RACCommand here instead. It'll give you a nicer way of handling concurrent requests, errors, and thread-safety. Commands are a pretty typical way of communicating from the view to the view model.

Should I have an active property as in the ViewModel example in ReactiveCocoa's github account?

It just depends on whether you need it. On iOS it's probably less commonly needed than OS X, where you could have multiple views and view models allocated but not "active" at once.

Hopefully this has been helpful. It looks like you're heading in the right direction generally!

like image 97
joshaber Avatar answered Oct 14 '22 09:10

joshaber


For a UITableView, for example, we need to provide both a delegate and a dataSource. Should I have a property on my controller NSUInteger numberOfRowsInTable and bind it to a signal on the ViewModel?

The standard approach, as described by joshaber above is to manually implement the datasource and delegate within your view controller, with the view model simply exposing an array of items each of which represents a view model which backs a table view cell.

However, this results in a lot of boiler-plate in your otherwise elegant view controller.

I have created a simple binding helper that allows you to bind an NSArray of view models to a table view with just a few lines of code:

// create a cell template
UINib *nib = [UINib nibWithNibName:@"CETweetTableViewCell" bundle:nil];

// bind the ViewModels 'searchResults' property to a table view
[CETableViewBindingHelper bindingHelperForTableView:self.searchResultsTable
                        sourceSignal:RACObserve(self.viewModel, searchResults)
                        templateCell:nib];

It also handles selection, executing a command when a row is selected. The complete code is over on my blog. Hope this helps!

like image 22
ColinE Avatar answered Oct 14 '22 08:10

ColinE