Merge pull request #1 from ReactiveCocoa/base-view-model

Add RVMViewModel base class
This commit is contained in:
Josh Abernathy
2013-07-01 08:38:04 -07:00
7 changed files with 498 additions and 0 deletions

View File

@@ -27,6 +27,14 @@
D0948B3C17815E7300BA8F23 /* SenTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0948B3B17815E7300BA8F23 /* SenTestingKit.framework */; };
D0948B3D17815E7800BA8F23 /* SenTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0948B3B17815E7300BA8F23 /* SenTestingKit.framework */; };
D0948B3F17815E9700BA8F23 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0948B3E17815E9700BA8F23 /* UIKit.framework */; };
D0948B551781610600BA8F23 /* RVMViewModel.h in Headers */ = {isa = PBXBuildFile; fileRef = D0948B531781610600BA8F23 /* RVMViewModel.h */; settings = {ATTRIBUTES = (Public, ); }; };
D0948B561781610600BA8F23 /* RVMViewModel.h in Headers */ = {isa = PBXBuildFile; fileRef = D0948B531781610600BA8F23 /* RVMViewModel.h */; settings = {ATTRIBUTES = (Public, ); }; };
D0948B571781610600BA8F23 /* RVMViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B541781610600BA8F23 /* RVMViewModel.m */; };
D0948B581781610600BA8F23 /* RVMViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B541781610600BA8F23 /* RVMViewModel.m */; };
D0948B5A1781618A00BA8F23 /* RVMViewModelSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */; };
D0948B5B1781618A00BA8F23 /* RVMViewModelSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */; };
D0948B5E178161A800BA8F23 /* RVMTestViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B5D178161A800BA8F23 /* RVMTestViewModel.m */; };
D0948B5F178161A800BA8F23 /* RVMTestViewModel.m in Sources */ = {isa = PBXBuildFile; fileRef = D0948B5D178161A800BA8F23 /* RVMTestViewModel.m */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -221,6 +229,11 @@
D0948AE417815B4200BA8F23 /* Expecta.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Expecta.xcodeproj; path = External/ReactiveCocoa/external/expecta/Expecta.xcodeproj; sourceTree = "<group>"; };
D0948B3B17815E7300BA8F23 /* SenTestingKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SenTestingKit.framework; path = Library/Frameworks/SenTestingKit.framework; sourceTree = DEVELOPER_DIR; };
D0948B3E17815E9700BA8F23 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS6.1.sdk/System/Library/Frameworks/UIKit.framework; sourceTree = DEVELOPER_DIR; };
D0948B531781610600BA8F23 /* RVMViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RVMViewModel.h; sourceTree = "<group>"; };
D0948B541781610600BA8F23 /* RVMViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMViewModel.m; sourceTree = "<group>"; };
D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMViewModelSpec.m; sourceTree = "<group>"; };
D0948B5C178161A800BA8F23 /* RVMTestViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RVMTestViewModel.h; sourceTree = "<group>"; };
D0948B5D178161A800BA8F23 /* RVMTestViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMTestViewModel.m; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -311,6 +324,7 @@
isa = PBXGroup;
children = (
D0948A4B178159AD00BA8F23 /* ReactiveViewModel.h */,
D0948B4A178160FC00BA8F23 /* View Model */,
D0948A45178159AD00BA8F23 /* Supporting Files */,
);
path = ReactiveViewModel;
@@ -341,6 +355,8 @@
D0948A5C178159AD00BA8F23 /* ReactiveViewModelTests-Info.plist */,
D0948A5D178159AD00BA8F23 /* InfoPlist.strings */,
D0948ABC17815B1800BA8F23 /* ReactiveViewModelTests-Prefix.pch */,
D0948B5C178161A800BA8F23 /* RVMTestViewModel.h */,
D0948B5D178161A800BA8F23 /* RVMTestViewModel.m */,
);
name = "Supporting Files";
sourceTree = "<group>";
@@ -412,6 +428,7 @@
D0948ABD17815B2000BA8F23 /* Specs */ = {
isa = PBXGroup;
children = (
D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */,
);
name = Specs;
sourceTree = "<group>";
@@ -451,6 +468,15 @@
name = Products;
sourceTree = "<group>";
};
D0948B4A178160FC00BA8F23 /* View Model */ = {
isa = PBXGroup;
children = (
D0948B531781610600BA8F23 /* RVMViewModel.h */,
D0948B541781610600BA8F23 /* RVMViewModel.m */,
);
name = "View Model";
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -459,6 +485,7 @@
buildActionMask = 2147483647;
files = (
D0948ABA17815AF100BA8F23 /* ReactiveViewModel.h in Headers */,
D0948B551781610600BA8F23 /* RVMViewModel.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -467,6 +494,7 @@
buildActionMask = 2147483647;
files = (
D0948ABB17815AF100BA8F23 /* ReactiveViewModel.h in Headers */,
D0948B561781610600BA8F23 /* RVMViewModel.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -756,6 +784,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0948B571781610600BA8F23 /* RVMViewModel.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -763,6 +792,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0948B5A1781618A00BA8F23 /* RVMViewModelSpec.m in Sources */,
D0948B5E178161A800BA8F23 /* RVMTestViewModel.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -770,6 +801,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0948B581781610600BA8F23 /* RVMViewModel.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -777,6 +809,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D0948B5B1781618A00BA8F23 /* RVMViewModelSpec.m in Sources */,
D0948B5F178161A800BA8F23 /* RVMTestViewModel.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,74 @@
//
// RVMViewModel.h
// ReactiveViewModel
//
// Created by Josh Abernathy on 9/11/12.
// Copyright (c) 2012 GitHub. All rights reserved.
//
#import <Foundation/Foundation.h>
@class RACSignal;
// Adapts a domain model to be user-presentable, and implements behaviors that
// drive the UI.
@interface RVMViewModel : NSObject
// The model which the view model is adapting for the UI.
@property (nonatomic, readonly, strong) id model;
// Whether the view model is currently "active."
//
// This generally implies that the associated view is visible. When set to NO,
// the view model should throttle or cancel low-priority or UI-related work.
//
// This property defaults to NO.
@property (nonatomic, assign, getter = isActive) BOOL active;
// Observes the receiver's `active` property, and sends the receiver whenever it
// changes from NO to YES.
//
// If the receiver is currently active, this signal will send once immediately
// upon subscription.
@property (nonatomic, strong, readonly) RACSignal *didBecomeActiveSignal;
// Observes the receiver's `active` property, and sends the receiver whenever it
// changes from YES to NO.
//
// If the receiver is currently inactive, this signal will send once immediately
// upon subscription.
@property (nonatomic, strong, readonly) RACSignal *didBecomeInactiveSignal;
// Calls -initWithModel: with a nil model.
- (instancetype)init;
// Creates a new view model with the given model.
//
// model - The model to adapt for the UI. This argument may be nil.
//
// Returns an initialized view model, or nil if an error occurs.
- (instancetype)initWithModel:(id)model;
// Subscribes (or resubscribes) to the given signal whenever
// `didBecomeActiveSignal` fires.
//
// When `didBecomeInactiveSignal` fires, any active subscription to `signal` is
// disposed.
//
// Returns a signal which forwards `next`s from the latest subscription to
// `signal`, and completes when the receiver is deallocated. If `signal` sends
// an error at any point, the returned signal will error out as well.
- (RACSignal *)forwardSignalWhileActive:(RACSignal *)signal;
// Throttles events on the given signal while the receiver is inactive.
//
// Unlike -forwardSignalWhileActive:, this method will stay subscribed to
// `signal` the entire time, except that its events will be throttled when the
// receiver becomes inactive.
//
// Returns a signal which forwards events from `signal` (throttled while the
// receiver is inactive), and completes when `signal` completes or the receiver
// is deallocated.
- (RACSignal *)throttleSignalWhileInactive:(RACSignal *)signal;
@end

View File

@@ -0,0 +1,173 @@
//
// RVMViewModel.m
// ReactiveViewModel
//
// Created by Josh Abernathy on 9/11/12.
// Copyright (c) 2012 GitHub. All rights reserved.
//
#import "RVMViewModel.h"
#import <libkern/OSAtomic.h>
#import <ReactiveCocoa/EXTScope.h>
#import <ReactiveCocoa/ReactiveCocoa.h>
// The number of seconds by which signal events are throttled when using
// -throttleSignalWhileInactive:.
static const NSTimeInterval RVMViewModelInactiveThrottleInterval = 1;
@interface RVMViewModel ()
// Improves the performance of KVO on the receiver.
//
// See the documentation for <NSKeyValueObserving> for more information.
@property (atomic) void *observationInfo;
@end
@implementation RVMViewModel
#pragma mark Properties
// We create many, many view models, so these properties need to be as lazy and
// memory-conscious as possible.
@synthesize didBecomeActiveSignal = _didBecomeActiveSignal;
@synthesize didBecomeInactiveSignal = _didBecomeInactiveSignal;
- (void)setActive:(BOOL)active {
// Skip KVO notifications when the property hasn't actually changed. This is
// especially important because self.active can have very expensive
// observers attached.
if (active == _active) return;
[self willChangeValueForKey:@keypath(self.active)];
_active = active;
[self didChangeValueForKey:@keypath(self.active)];
}
- (RACSignal *)didBecomeActiveSignal {
if (_didBecomeActiveSignal == nil) {
@weakify(self);
_didBecomeActiveSignal = [[[RACObserve(self.active)
filter:^(NSNumber *active) {
return active.boolValue;
}]
map:^(id _) {
@strongify(self);
return self;
}]
setNameWithFormat:@"%@ -didBecomeActiveSignal", self];
}
return _didBecomeActiveSignal;
}
- (RACSignal *)didBecomeInactiveSignal {
if (_didBecomeInactiveSignal == nil) {
@weakify(self);
_didBecomeInactiveSignal = [[[RACObserve(self.active)
filter:^ BOOL (NSNumber *active) {
return !active.boolValue;
}]
map:^(id _) {
@strongify(self);
return self;
}]
setNameWithFormat:@"%@ -didBecomeInactiveSignal", self];
}
return _didBecomeInactiveSignal;
}
#pragma mark Lifecycle
- (id)init {
return [self initWithModel:nil];
}
- (id)initWithModel:(id)model {
self = [super init];
if (self == nil) return nil;
_model = model;
return self;
}
#pragma mark Activation
- (RACSignal *)forwardSignalWhileActive:(RACSignal *)signal {
NSParameterAssert(signal != nil);
RACSignal *activeSignal = RACObserve(self.active);
return [[RACSignal
createSignal:^(id<RACSubscriber> subscriber) {
RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable];
__block RACDisposable *signalDisposable = nil;
RACDisposable *activeDisposable = [activeSignal subscribeNext:^(NSNumber *active) {
if (active.boolValue) {
signalDisposable = [signal subscribeNext:^(id value) {
[subscriber sendNext:value];
} error:^(NSError *error) {
[subscriber sendError:error];
}];
if (signalDisposable != nil) [disposable addDisposable:signalDisposable];
} else {
[signalDisposable dispose];
[disposable removeDisposable:signalDisposable];
signalDisposable = nil;
}
} error:^(NSError *error) {
[subscriber sendError:error];
} completed:^{
[subscriber sendCompleted];
}];
if (activeDisposable != nil) [disposable addDisposable:activeDisposable];
return disposable;
}]
setNameWithFormat:@"%@ -forwardSignalWhileActive: %@", self, signal];
}
- (RACSignal *)throttleSignalWhileInactive:(RACSignal *)signal {
NSParameterAssert(signal != nil);
signal = [signal replayLast];
return [[[[[RACObserve(self.active)
takeUntil:[signal ignoreValues]]
combineLatestWith:signal]
throttle:RVMViewModelInactiveThrottleInterval valuesPassingTest:^ BOOL (RACTuple *xs) {
BOOL active = [xs.first boolValue];
return !active;
}]
reduceEach:^(NSNumber *active, id value) {
return value;
}]
setNameWithFormat:@"%@ -throttleSignalWhileInactive: %@", self, signal];
}
#pragma mark NSKeyValueObserving
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
// We'll generate notifications for this property manually.
if ([key isEqual:@keypath(RVMViewModel.new, active)]) {
return NO;
}
return [super automaticallyNotifiesObserversForKey:key];
}
- (void)setNilValueForKey:(NSString *)key {
// Ignore attempts to set primitive properties to nil. This is commonly
// caused by RACObserve noticing an intermediate key change.
//
// See https://github.com/ReactiveCocoa/ReactiveCocoa/issues/631.
}
@end

View File

@@ -6,3 +6,4 @@
// Copyright (c) 2013 GitHub. All rights reserved.
//
#import "RVMViewModel.h"

View File

@@ -0,0 +1,12 @@
//
// RVMTestViewModel.h
// ReactiveViewModel
//
// Created by Josh Abernathy on 9/12/12.
// Copyright (c) 2012 GitHub. All rights reserved.
//
#import "RVMViewModel.h"
@interface RVMTestViewModel : RVMViewModel
@end

View File

@@ -0,0 +1,12 @@
//
// RVMTestViewModel.m
// ReactiveViewModel
//
// Created by Josh Abernathy on 9/12/12.
// Copyright (c) 2012 GitHub. All rights reserved.
//
#import "RVMTestViewModel.h"
@implementation RVMTestViewModel
@end

View File

@@ -0,0 +1,192 @@
//
// RVMViewModelSpec.m
// ReactiveViewModel
//
// Created by Josh Abernathy on 9/11/12.
// Copyright (c) 2012 GitHub. All rights reserved.
//
#import "RVMTestViewModel.h"
SpecBegin(RVMViewModel)
__block RVMTestViewModel *viewModel;
beforeEach(^{
viewModel = [[RVMTestViewModel alloc] initWithModel:@"foobar"];
});
describe(@"active property", ^{
it(@"should default to NO", ^{
expect(viewModel.active).to.beFalsy();
});
it(@"should send on didBecomeActiveSignal when set to YES", ^{
__block NSUInteger nextEvents = 0;
[viewModel.didBecomeActiveSignal subscribeNext:^(RVMViewModel *viewModel) {
expect(viewModel).to.beIdenticalTo(viewModel);
expect(viewModel.active).to.beTruthy();
nextEvents++;
}];
expect(nextEvents).to.equal(0);
viewModel.active = YES;
expect(nextEvents).to.equal(1);
// Indistinct changes should not trigger the signal again.
viewModel.active = YES;
expect(nextEvents).to.equal(1);
viewModel.active = NO;
viewModel.active = YES;
expect(nextEvents).to.equal(2);
});
it(@"should send on didBecomeInactiveSignal when set to NO", ^{
__block NSUInteger nextEvents = 0;
[viewModel.didBecomeInactiveSignal subscribeNext:^(RVMViewModel *viewModel) {
expect(viewModel).to.beIdenticalTo(viewModel);
expect(viewModel.active).to.beFalsy();
nextEvents++;
}];
expect(nextEvents).to.equal(1);
viewModel.active = YES;
viewModel.active = NO;
expect(nextEvents).to.equal(2);
// Indistinct changes should not trigger the signal again.
viewModel.active = NO;
expect(nextEvents).to.equal(2);
});
describe(@"signal manipulation", ^{
__block NSMutableArray *values;
__block NSArray *expectedValues;
__block BOOL completed;
__block BOOL deallocated;
__block RVMTestViewModel * (^createViewModel)();
beforeEach(^{
values = [NSMutableArray array];
expectedValues = @[];
completed = NO;
deallocated = NO;
createViewModel = ^{
RVMTestViewModel *viewModel = [[RVMTestViewModel alloc] initWithModel:nil];
[viewModel.rac_deallocDisposable addDisposable:[RACDisposable disposableWithBlock:^{
deallocated = YES;
}]];
viewModel.active = YES;
return viewModel;
};
});
afterEach(^{
expect(deallocated).will.beTruthy();
expect(completed).to.beTruthy();
});
it(@"should forward a signal", ^{
@autoreleasepool {
RVMTestViewModel *viewModel __attribute__((objc_precise_lifetime)) = createViewModel();
RACSignal *input = [RACSignal createSignal:^ id (id<RACSubscriber> subscriber) {
[subscriber sendNext:@1];
[subscriber sendNext:@2];
return nil;
}];
[[viewModel
forwardSignalWhileActive:input]
subscribeNext:^(NSNumber *x) {
[values addObject:x];
} completed:^{
completed = YES;
}];
expectedValues = @[ @1, @2 ];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
viewModel.active = NO;
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
viewModel.active = YES;
expectedValues = @[ @1, @2, @1, @2 ];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
}
});
it(@"should throttle a signal", ^{
@autoreleasepool {
RVMTestViewModel *viewModel __attribute__((objc_precise_lifetime)) = createViewModel();
RACSubject *subject = [RACSubject subject];
[[viewModel
throttleSignalWhileInactive:[subject startWith:@0]]
subscribeNext:^(NSNumber *x) {
[values addObject:x];
} completed:^{
completed = YES;
}];
expectedValues = @[ @0 ];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
[subject sendNext:@1];
expectedValues = @[ @0, @1 ];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
viewModel.active = NO;
// Since the VM is inactive, these events should be throttled.
[subject sendNext:@2];
[subject sendNext:@3];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
expectedValues = @[ @0, @1, @3 ];
expect(values).will.equal(expectedValues);
expect(completed).to.beFalsy();
// After reactivating, we should still get this event.
[subject sendNext:@4];
viewModel.active = YES;
expectedValues = @[ @0, @1, @3, @4 ];
expect(values).will.equal(expectedValues);
expect(completed).to.beFalsy();
// And now new events should be instant.
[subject sendNext:@5];
expectedValues = @[ @0, @1, @3, @4, @5 ];
expect(values).to.equal(expectedValues);
expect(completed).to.beFalsy();
[subject sendCompleted];
expect(values).to.equal(expectedValues);
expect(completed).to.beTruthy();
}
});
});
});
SpecEnd