2015-03-02

Object-oriented State pattern with view controllers in Objective-C and iOS

I have a UIViewController subclass that is controlling a view hierarchy which represents a portion of the UI. That portion of the UI can be in one of three modes: browse, edit, or empty. These modes can change back and forth at run time by user actions.

Right now I have code in every method of the view controller that looks like this:

NSAssert(self.mode == SBModeBrowse || self.mode == SBModeEmpty, @"Should not be called in SBMode %d", self.mode);

And this:

if (self.mode != SBModeEmpty)
{
    [self performScrollCompletion];
}

And so on.

Even worse, I'm probably missing a few asserts and if-thens, and the code just happens to work at the moment.

It seems the object-oriented pattern that solves this situation is the State pattern.

It took me a while to figure out how to do this with UIViewController subclasses because the state behavior will affect UIControls wired up via IBOutlets to the view controller itself. How are the behavior classes supposed to manipulate these objects?

Also, I did not read the State pattern explanation closely enough, so I started off trying to subclass my view controller subclasses instead of merely adding a property to them for a behavior object.

I made a simple sandbox project to teach myself how to add the State pattern to view controllers. Here is my specification.
  • Display a UILabel with a number.
  • Display a UIButton.
  • Display a UIStepper.
  • Start in Browse mode.
  • In Browse mode:
    • Only button allows user taps.
    • Only label and button showing.
    • Button title is "Edit" and tapping it goes to Edit mode.
    • Stepper is invisible and does not allow taps.
  • In Edit Mode:
    • Stepper is visible and allows taps.
    • Stepper adjusts number in label.
    • Button title is "Browse" and tapping it does to Browse mode.
  • When exiting Edit mode, if the number in the label is less than 1, then enter Empty mode.
  • In Empty Mode:
    • Stepper is invisible and no taps allowed.
    • Label background is red.
    • Button tap moves to Edit mode.
Here are the classes and code to accomplish this by using the State pattern and view controllers.

InfoKeys.h


#ifndef SandBox3_InfoKeys_h
#define SandBox3_InfoKeys_h
static NSString *const keyButton = @"button";
static NSString *const keyStepper = @"stepper";
static NSString *const keyLabel = @"label";
#endif

ViewControlller.h


@class Behavior;
@interface ViewController : UIViewController
@property (weak, nonatomic) IBOutlet UIButton *button;
@property (weak, nonatomic) IBOutlet UILabel *label;
@property (weak, nonatomic) IBOutlet UIStepper *stepper;
@property (strong, nonatomic) Behavior *behavior;
@property (assign, nonatomic) uint16_t labelValue;
- (IBAction)buttonTapped:(id)sender;
- (IBAction)stepperTapped:(id)sender;
@end

ViewController.m


#import "ViewController.h"
#import "Behavior.h"
#import "InfoKeys.h"

@implementation ViewController
- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    self.behavior = [[Behavior alloc] initWithInfo:@{keyButton: self.button, keyLabel: self.label, keyStepper: self.stepper}];
}

- (IBAction)buttonTapped:(id)sender
{
    [self.behavior buttonTapped:@{keyButton: sender, keyStepper: self.stepper, keyLabel: self.label}];
}

- (IBAction)stepperTapped:(id)sender
{
    [self.behavior stepperTapped:@{keyStepper: sender, keyLabel: self.label}];
}
@end

Behavior.h


@interface Behavior : NSObject
- (instancetype)initWithInfo:(NSDictionary *)info;
- (void)buttonTapped:(NSDictionary *)info;
- (void)stepperTapped:(NSDictionary *)info;
@end

Behavior.m


#import "Behavior.h"
#import "BehaviorBrowse.h"
#import "BehaviorEdit.h"
#import "BehaviorEmpty.h"
#import "InfoKeys.h"

@implementation Behavior {
    Behavior *_mode;
}

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        // Prevent problems if subclasses call this method (for example, as super in overrides).
        if ([self isMemberOfClass:[Behavior class]]) {
            _mode = [[BehaviorBrowse alloc] initWithInfo:info];
        }
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    [_mode buttonTapped:info];
    UIStepper *stepper = info[keyStepper];
    // Coming back from Empty should allow mode to go to Edit so user can increase 0 value.
    if (stepper.value < 1 && ![_mode isKindOfClass:[BehaviorEmpty class]]) {
        _mode = [[BehaviorEmpty alloc] initWithInfo:info];
    }
    else {
        if ([_mode isKindOfClass:[BehaviorEdit class]]) {
            _mode = [[BehaviorBrowse alloc] init];
        }
        else { // Coming from Empty ends up here also.
            _mode = [[BehaviorEdit alloc] init];
        }
    }
}

- (void)stepperTapped:(NSDictionary *)info
{
    [_mode stepperTapped:info];
}
@end

BehaviorBrowse.m


#import "BehaviorBrowse.h"
#import "InfoKeys.h"

@implementation BehaviorBrowse

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        UIButton *button = info[keyButton];
        UILabel *label = info[keyLabel];
        UIStepper *stepper = info[keyStepper];
        label.text = [NSString stringWithFormat:@"%.0f", stepper.value];
        stepper.alpha = 0.0;
        stepper.userInteractionEnabled = NO;
        // Button title shows user way to go to opposite mode.
        [button setTitle:@"Edit" forState:UIControlStateNormal];
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    UIButton *button = (UIButton *)info[keyButton];
    // Button titles inform user of way back to the mode they are about to leave.
    [button setTitle:@"Browse" forState:UIControlStateNormal];
    UIStepper *stepper = (UIStepper *)info[keyStepper];
    [UIView animateWithDuration:0.3
                     animations:^(void){stepper.alpha = 1.0;}
                     completion:^(BOOL finished) {stepper.userInteractionEnabled = YES;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    NSAssert(NO, @"Stepper should not be tapable during browse mode.");
}

@end

BehaviorEdit.m


#import "BehaviorEdit.h"
#import "InfoKeys.h"

@implementation BehaviorEdit

- (void)buttonTapped:(NSDictionary *)info
{
    UIStepper *stepper = info[keyStepper];
    UIButton *button = info[keyButton];
    stepper.userInteractionEnabled = NO;
    // Let user know that button can bring them back to Edit from whatever mode comes next.
    [button setTitle:@"Edit" forState:UIControlStateNormal];
    [UIView animateWithDuration:0.3 animations:^(void){stepper.alpha = 0.0;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    UILabel *label = info[keyLabel];
    UIStepper *stepper = info[keyStepper];
    label.text = [NSString stringWithFormat:@"%.0f", stepper.value];
}

@end

BehaviorEmpty.m


#import "BehaviorEmpty.h"
#import "InfoKeys.h"

@implementation BehaviorEmpty

- (instancetype)initWithInfo:(NSDictionary *)info
{
    self = [super init];
    if (self) {
        UILabel *label = info[keyLabel];
        label.backgroundColor = [UIColor redColor];
        UIStepper *stepper = info[keyStepper];
        stepper.alpha = 0.0;
        stepper.userInteractionEnabled = NO;
    }
    return self;
}

- (void)buttonTapped:(NSDictionary *)info
{
    UIStepper *stepper = info[keyStepper];
    UIButton *button = info[keyButton];
    [button setTitle:@"Browse" forState:UIControlStateNormal];
    UILabel *label = info[keyLabel];
    label.backgroundColor = [UIColor lightGrayColor];
    [UIView animateWithDuration:0.3
                     animations:^(void){stepper.alpha = 1.0;}
                     completion:^(BOOL finished) {stepper.userInteractionEnabled = YES;}];
}

- (void)stepperTapped:(NSDictionary *)info
{
    NSAssert(NO, @"Stepper should not be tapable during empty mode.");
}


I am always interested in hearing better ways to do this.

No comments:

Post a Comment