diff --git a/ReactiveViewModel.xcodeproj/project.pbxproj b/ReactiveViewModel.xcodeproj/project.pbxproj index 2cb0bed..5135b0a 100644 --- a/ReactiveViewModel.xcodeproj/project.pbxproj +++ b/ReactiveViewModel.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; + D0948B541781610600BA8F23 /* RVMViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMViewModel.m; sourceTree = ""; }; + D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMViewModelSpec.m; sourceTree = ""; }; + D0948B5C178161A800BA8F23 /* RVMTestViewModel.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RVMTestViewModel.h; sourceTree = ""; }; + D0948B5D178161A800BA8F23 /* RVMTestViewModel.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RVMTestViewModel.m; sourceTree = ""; }; /* 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 = ""; @@ -412,6 +428,7 @@ D0948ABD17815B2000BA8F23 /* Specs */ = { isa = PBXGroup; children = ( + D0948B591781618A00BA8F23 /* RVMViewModelSpec.m */, ); name = Specs; sourceTree = ""; @@ -451,6 +468,15 @@ name = Products; sourceTree = ""; }; + D0948B4A178160FC00BA8F23 /* View Model */ = { + isa = PBXGroup; + children = ( + D0948B531781610600BA8F23 /* RVMViewModel.h */, + D0948B541781610600BA8F23 /* RVMViewModel.m */, + ); + name = "View Model"; + sourceTree = ""; + }; /* 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; }; diff --git a/ReactiveViewModel/RVMViewModel.h b/ReactiveViewModel/RVMViewModel.h new file mode 100644 index 0000000..5f1ef2a --- /dev/null +++ b/ReactiveViewModel/RVMViewModel.h @@ -0,0 +1,74 @@ +// +// RVMViewModel.h +// ReactiveViewModel +// +// Created by Josh Abernathy on 9/11/12. +// Copyright (c) 2012 GitHub. All rights reserved. +// + +#import + +@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 diff --git a/ReactiveViewModel/RVMViewModel.m b/ReactiveViewModel/RVMViewModel.m new file mode 100644 index 0000000..50fc577 --- /dev/null +++ b/ReactiveViewModel/RVMViewModel.m @@ -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 +#import +#import + +// 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 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 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 diff --git a/ReactiveViewModel/ReactiveViewModel.h b/ReactiveViewModel/ReactiveViewModel.h index 6253b41..60e174b 100644 --- a/ReactiveViewModel/ReactiveViewModel.h +++ b/ReactiveViewModel/ReactiveViewModel.h @@ -6,3 +6,4 @@ // Copyright (c) 2013 GitHub. All rights reserved. // +#import "RVMViewModel.h" diff --git a/ReactiveViewModelTests/RVMTestViewModel.h b/ReactiveViewModelTests/RVMTestViewModel.h new file mode 100644 index 0000000..fdd23aa --- /dev/null +++ b/ReactiveViewModelTests/RVMTestViewModel.h @@ -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 diff --git a/ReactiveViewModelTests/RVMTestViewModel.m b/ReactiveViewModelTests/RVMTestViewModel.m new file mode 100644 index 0000000..4fab2a0 --- /dev/null +++ b/ReactiveViewModelTests/RVMTestViewModel.m @@ -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 diff --git a/ReactiveViewModelTests/RVMViewModelSpec.m b/ReactiveViewModelTests/RVMViewModelSpec.m new file mode 100644 index 0000000..c056fa8 --- /dev/null +++ b/ReactiveViewModelTests/RVMViewModelSpec.m @@ -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 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