diff --git a/apple-tests/RNCPageCurlStateMachineTests.m b/apple-tests/RNCPageCurlStateMachineTests.m index 6d73be609..6c326280a 100644 --- a/apple-tests/RNCPageCurlStateMachineTests.m +++ b/apple-tests/RNCPageCurlStateMachineTests.m @@ -40,6 +40,27 @@ return lines; } +// the token of the settle request in `effects`, or 0 when there is none +static NSUInteger SettleRequestToken(NSArray *effects) +{ + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == RNCPageCurlEffectRequestSettle) { + return effect.token; + } + } + return 0; +} + +static BOOL Contains(NSArray *effects, RNCPageCurlEffectKind kind) +{ + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == kind) { + return YES; + } + } + return NO; +} + @interface RNCPageCurlStateMachineTests : XCTestCase @end @@ -60,6 +81,14 @@ - (void)bakeFirstPage [_machine bakeFinished:YES]; } +// first page baked, then a turn toward the next page landed; the manager has not settled yet +- (void)landTurn +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine turnFinishedToward:@"next" completed:YES]; +} + #pragma mark - the bake loop - (void)testStateNamesMatchTheLog @@ -131,4 +160,449 @@ - (void)testEveryStateChangeMovesTheSerial XCTAssertEqual(_machine.stateSerial, baking, @"a kept settle changes no state"); } +#pragma mark - failed bakes + +- (void)testAFailedBakeBlanksTheSlotsAndAsksForASettle +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testThreeFailedBakesInARowGoIdleWithoutAsking +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state idle (bakes kept failing)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testASuccessfulBakeResetsTheFailureCount +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + [_machine settled:Settle(4, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +- (void)testAKeptSettleIsBakedAfterAFailedBakeWithoutCountingTheFailure +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"bake page 4 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + // two counted failures leave the machine still asking; a counted kept one would have made three + [_machine bakeFinished:NO]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +#pragma mark - failed settle requests + +- (void)testAFailedSettleRequestBlanksTheSlotsAndGoesIdle +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertNotEqual(token, 0); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[@"blank slots", @"state idle (settle request failed)"])); +} + +- (void)testASuccessfulSettleRequestChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:YES token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testAFailedSettleRequestFromAnEarlierWaitChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger token = SettleRequestToken([_machine bakeFinished:NO]); + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testAFailedSettleRequestFromAnEarlierWaitForTheSameStateChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + NSUInteger first = SettleRequestToken([_machine bakeFinished:NO]); + [_machine settled:Settle(3, 1)]; + NSUInteger second = SettleRequestToken([_machine bakeFinished:NO]); + XCTAssertNotEqual(first, second); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:first]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +#pragma mark - turns + +- (void)testATurnBeginsOnlyWhenIdle +{ + [_machine enable]; + XCTAssertFalse(_machine.canBeginTurn); + XCTAssertEqualObjects(Describe([_machine turnBegan]), (@[])); + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; + XCTAssertTrue(_machine.canBeginTurn); + XCTAssertEqualObjects(Describe([_machine turnBegan]), (@[@"state animating (turn begins)"])); + XCTAssertFalse(_machine.canBeginTurn); +} + +- (void)testASettleDuringATurnIsKept +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAnimating); +} + +- (void)testACancelledTurnShowsTheCurrentPageAndRests +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:NO]), (@[@"show current", @"state idle (turn cancelled)"])); +} + +- (void)testACancelledTurnBakesTheKeptSettle +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine settled:Settle(4, 1)]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:NO]), + (@[@"show current", @"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testALandedTurnShowsTheLandedPageAndAsksTheManagerToLand +{ + [self bakeFirstPage]; + [_machine turnBegan]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:YES]), + (@[@"show landed next", @"state awaitingSettle (turn landed)", @"land next"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testALandedTurnDropsTheSettleKeptDuringIt +{ + [self bakeFirstPage]; + [_machine turnBegan]; + [_machine settled:Settle(9, 1)]; + [_machine turnFinishedToward:@"next" completed:YES]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testATurnFinishedWhileNotAnimatingChangesNothing +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine turnFinishedToward:@"next" completed:YES]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +#pragma mark - landing guard + +- (void)testASettleForThePageJustLeftIsDroppedAfterALanding +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testASettleForTheSamePageInAnotherChunkBakesAfterALanding +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 2)]), (@[@"state baking (settle)", @"bake page 3 chunk 2"])); +} + +- (void)testUnsettledEndsTheLandingGuard +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[])); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testAFailedLandingEndsTheGuardAndAsksForASettle +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine landingFinished:NO]), (@[@"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testASuccessfulLandingChangesNothing +{ + [self landTurn]; + XCTAssertEqualObjects(Describe([_machine landingFinished:YES]), (@[])); + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[]), @"the guard still holds"); +} + +- (void)testTheGuardEndsOnceAnotherPageBakes +{ + [self landTurn]; + [_machine settled:Settle(4, 1)]; + [_machine bakeFinished:YES]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +#pragma mark - taps + +- (void)testATapWaitsForItsResult +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine tapped]), (@[@"state awaitingTapResult (tap)"])); + XCTAssertFalse(_machine.canBeginTurn); +} + +- (void)testATapThatTurnedNoPageOpensInput +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine touchEnded]), (@[@"state idle (tap turned no page)"])); +} + +- (void)testASettleAfterATapBakes +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testUnsettledAfterATapWaitsForTheSettle +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[@"state awaitingSettle (unsettled)"])); +} + +- (void)testATapWhileBusyChangesNothing +{ + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine tapped]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testATouchEndWhileIdleChangesNothing +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine touchEnded]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +#pragma mark - unsettled and rebake requests + +- (void)testUnsettledWhileIdleWaitsForTheSettle +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[@"state awaitingSettle (unsettled)"])); +} + +- (void)testUnsettledDuringABakeChangesNothing +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine unsettled]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); +} + +- (void)testARebakeRequestWhileIdleWaitsAndAsks +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"state awaitingSettle (resize)", @"request settle"])); +} + +- (void)testARebakeRequestWhileWaitingOnATapWaitsAndAsks +{ + [self bakeFirstPage]; + [_machine tapped]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"state awaitingSettle (resize)", @"request settle"])); +} + +- (void)testARebakeRequestDuringABakeOnlyAsks +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine rebakeRequested:@"resize"]), (@[@"request settle"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 3 chunk 1"]), @"the settle that came back is kept"); +} + +- (void)testAFailedRebakeRequestWhileIdleOpensInputAgain +{ + [self bakeFirstPage]; + NSUInteger token = SettleRequestToken([_machine rebakeRequested:@"resize"]); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[@"blank slots", @"state idle (settle request failed)"])); +} + +#pragma mark - teardown + +- (void)testTeardownGoesIdleWithNothingKeptAndMovesTheSerial +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + NSUInteger before = _machine.stateSerial; + [_machine teardown]; + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); + XCTAssertNotEqual(_machine.stateSerial, before); + XCTAssertEqualObjects(_machine.summary, @"idle"); +} + +- (void)testTeardownClearsTheLandingGuard +{ + [self landTurn]; + [_machine teardown]; + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); +} + +- (void)testTeardownClearsTheFailureCount +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine teardown]; + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:NO]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[@"blank slots", @"state awaitingSettle (bake failed)", @"request settle"])); +} + +- (void)testResultsAfterATeardownChangeNothing +{ + [self landTurn]; + [_machine settled:Settle(4, 1)]; + NSUInteger token = SettleRequestToken([_machine rebakeRequested:@"resize"]); + [_machine teardown]; + NSUInteger serial = _machine.stateSerial; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[])); + XCTAssertEqualObjects(Describe([_machine bakeFinished:NO]), (@[])); + XCTAssertEqualObjects(Describe([_machine landingFinished:NO]), (@[])); + XCTAssertEqualObjects(Describe([_machine settleRequestFinished:NO token:token]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); + XCTAssertEqual(_machine.stateSerial, serial); +} + +#pragma mark - every ordering + +typedef NSArray *(^RNCPageCurlEvent)(RNCPageCurlStateMachine *machine, NSUInteger lastToken); + +// every event the controller can report, as a block the sweep applies to a machine +- (NSDictionary *)events +{ + return @{ + @"settled 3": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settled:Settle(3, 1)]; }, + @"settled 4": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settled:Settle(4, 1)]; }, + @"unsettled": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m unsettled]; }, + @"touchEnded": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m touchEnded]; }, + @"tapped": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m tapped]; }, + @"turnBegan": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnBegan]; }, + @"turn landed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnFinishedToward:@"next" completed:YES]; }, + @"turn cancelled": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m turnFinishedToward:@"next" completed:NO]; }, + @"bake ok": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m bakeFinished:YES]; }, + @"bake failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m bakeFinished:NO]; }, + @"landing ok": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m landingFinished:YES]; }, + @"landing failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m landingFinished:NO]; }, + @"settle request failed": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m settleRequestFinished:NO token:token]; }, + @"rebake": ^(RNCPageCurlStateMachine *m, NSUInteger token) { return [m rebakeRequested:@"resize"]; }, + }; +} + +// replays `sequence` on a fresh enabled machine and checks the invariants after every step +- (void)checkSequence:(NSArray *)sequence events:(NSDictionary *)events +{ + RNCPageCurlStateMachine *machine = [RNCPageCurlStateMachine new]; + [machine enable]; + NSUInteger bakesOutstanding = 0; + NSUInteger lastToken = 0; + NSUInteger lastSerial = machine.stateSerial; + BOOL settleExpected = YES; + NSString *trail = @"enable"; + for (NSString *name in sequence) { + trail = [trail stringByAppendingFormat:@" > %@", name]; + BOOL wasBaking = machine.state == RNCPageCurlStateBaking; + NSArray *effects = events[name](machine, lastToken); + + if ([name hasPrefix:@"bake "] && wasBaking) { + bakesOutstanding -= 1; + } + NSUInteger bakesStarted = 0; + for (RNCPageCurlEffect *effect in effects) { + if (effect.kind == RNCPageCurlEffectBake) { + bakesStarted += 1; + } + if (effect.kind == RNCPageCurlEffectRequestSettle) { + lastToken = effect.token; + settleExpected = YES; + } + if (effect.kind == RNCPageCurlEffectLand) { + settleExpected = YES; + } + } + bakesOutstanding += bakesStarted; + if ([name isEqualToString:@"unsettled"]) { + settleExpected = YES; + } + if ([name hasPrefix:@"settled"] && (bakesStarted > 0 || machine.state == RNCPageCurlStateBaking || machine.state == RNCPageCurlStateAnimating)) { + settleExpected = NO; + } + + XCTAssertLessThanOrEqual(bakesStarted, 1, @"%@: one bake per step", trail); + XCTAssertLessThanOrEqual(bakesOutstanding, 1, @"%@: at most one bake outstanding", trail); + XCTAssertEqual(machine.state == RNCPageCurlStateBaking, bakesOutstanding == 1, @"%@: baking exactly while a bake is outstanding", trail); + if (bakesStarted > 0) { + XCTAssertEqual(machine.state, RNCPageCurlStateBaking, @"%@: a bake starts only on entering baking", trail); + } + XCTAssertEqual(machine.canBeginTurn, machine.state == RNCPageCurlStateIdle, @"%@: input is blocked exactly when not idle", trail); + if (machine.state == RNCPageCurlStateAwaitingSettle) { + XCTAssertTrue(settleExpected, @"%@: waiting for a settle nothing will send", trail); + } + BOOL stateChanged = Contains(effects, RNCPageCurlEffectEnterState); + XCTAssertEqual(machine.stateSerial != lastSerial, stateChanged, @"%@: the serial moves exactly on a state change", trail); + XCTAssertGreaterThanOrEqual(machine.stateSerial, lastSerial, @"%@: the serial never goes back", trail); + lastSerial = machine.stateSerial; + } +} + +- (void)testEveryOrderingKeepsTheInvariants +{ + NSDictionary *events = [self events]; + NSArray *names = [events.allKeys sortedArrayUsingSelector:@selector(compare:)]; + const NSUInteger depth = 5; + NSUInteger count = 1; + for (NSUInteger i = 0; i < depth; i++) { + count *= names.count; + } + for (NSUInteger index = 0; index < count; index++) { + NSMutableArray *sequence = [NSMutableArray arrayWithCapacity:depth]; + NSUInteger rest = index; + for (NSUInteger i = 0; i < depth; i++) { + [sequence addObject:names[rest % names.count]]; + rest /= names.count; + } + [self checkSequence:sequence events:events]; + } +} + @end diff --git a/apple/RNCPageCurlStateMachine.m b/apple/RNCPageCurlStateMachine.m index 59da31d57..1ea7cb8df 100644 --- a/apple/RNCPageCurlStateMachine.m +++ b/apple/RNCPageCurlStateMachine.m @@ -1,82 +1,345 @@ #import "RNCPageCurlStateMachine.h" +static const NSUInteger RNCPageCurlMaxFailedBakes = 3; + NSString *RNCPageCurlStateName(RNCPageCurlState state) { + switch (state) { + case RNCPageCurlStateIdle: return @"idle"; + case RNCPageCurlStateAnimating: return @"animating"; + case RNCPageCurlStateBaking: return @"baking"; + case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; + case RNCPageCurlStateAwaitingTapResult: return @"awaitingTapResult"; + } return @"?"; } +@interface RNCPageCurlEffect () +@property (nonatomic, readwrite) RNCPageCurlEffectKind kind; +@property (nonatomic, readwrite) RNCPageCurlState state; +@property (nonatomic, readwrite, copy, nullable) NSString *reason; +@property (nonatomic, readwrite, copy, nullable) NSDictionary *settle; +@property (nonatomic, readwrite, copy, nullable) NSString *direction; +@property (nonatomic, readwrite) NSUInteger token; +@end + @implementation RNCPageCurlEffect + ++ (instancetype)effectOfKind:(RNCPageCurlEffectKind)kind +{ + RNCPageCurlEffect *effect = [self new]; + effect.kind = kind; + return effect; +} + +- (NSString *)description +{ + switch (self.kind) { + case RNCPageCurlEffectEnterState: return [NSString stringWithFormat:@"enter %@ (%@)", RNCPageCurlStateName(self.state), self.reason]; + case RNCPageCurlEffectBake: return [NSString stringWithFormat:@"bake page %@ chunk %@", self.settle[@"page"], self.settle[@"chunkIndex"]]; + case RNCPageCurlEffectBlankSlots: return @"blank slots"; + case RNCPageCurlEffectShowLandedPage: return [NSString stringWithFormat:@"show landed %@", self.direction]; + case RNCPageCurlEffectShowCurrentPage: return @"show current page"; + case RNCPageCurlEffectLand: return [NSString stringWithFormat:@"land %@", self.direction]; + case RNCPageCurlEffectRequestSettle: return [NSString stringWithFormat:@"request settle #%lu", (unsigned long)self.token]; + case RNCPageCurlEffectNote: return [NSString stringWithFormat:@"note: %@", self.reason]; + } + return @"?"; +} + @end -@implementation RNCPageCurlStateMachine +@implementation RNCPageCurlStateMachine { + // a settle that arrived while a bake or a turn was running; baked once that ends + NSDictionary *_keptSettle; + // the settle the slots were last baked from + NSDictionary *_bakedSettle; + // after a landed turn, a settle naming the page just left is from before the landing + BOOL _awaitingLanding; + NSUInteger _failedBakes; + NSUInteger _settleRequestToken; + NSUInteger _settleRequestSerial; +} + +- (instancetype)init +{ + if (self = [super init]) { + _state = RNCPageCurlStateIdle; + } + return self; +} - (BOOL)canBeginTurn { - return NO; + return _state == RNCPageCurlStateIdle; } - (NSString *)summary { - return @""; + NSMutableArray *parts = [NSMutableArray arrayWithObject:RNCPageCurlStateName(_state)]; + if (_keptSettle != nil) { + [parts addObject:[NSString stringWithFormat:@"kept page %@ chunk %@", _keptSettle[@"page"], _keptSettle[@"chunkIndex"]]]; + } + if (_awaitingLanding) { + [parts addObject:@"awaiting landing"]; + } + if (_failedBakes > 0) { + [parts addObject:[NSString stringWithFormat:@"%lu failed bakes", (unsigned long)_failedBakes]]; + } + return [parts componentsJoinedByString:@", "]; +} + +#pragma mark - effects + +- (RNCPageCurlEffect *)enter:(RNCPageCurlState)state reason:(NSString *)reason +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectEnterState]; + effect.state = state; + effect.reason = reason; + return effect; +} + +- (RNCPageCurlEffect *)note:(NSString *)reason +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectNote]; + effect.reason = reason; + return effect; +} + +- (RNCPageCurlEffect *)directed:(RNCPageCurlEffectKind)kind toward:(NSString *)direction +{ + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:kind]; + effect.direction = direction; + return effect; +} + +// the state changes once per call at most, so a list holds at most one enter effect +- (void)setState:(RNCPageCurlState)state reason:(NSString *)reason into:(NSMutableArray *)effects +{ + if (state == _state) { + return; + } + _state = state; + _stateSerial += 1; + [effects addObject:[self enter:state reason:reason]]; +} + +- (void)bake:(NSDictionary *)settle into:(NSMutableArray *)effects +{ + _keptSettle = nil; + _awaitingLanding = NO; + _bakedSettle = settle; + [self setState:RNCPageCurlStateBaking reason:@"settle" into:effects]; + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBake]; + effect.settle = settle; + [effects addObject:effect]; +} + +// the kept settle starts the next bake under the same cover; with none the machine rests +- (void)bakeKeptOrIdle:(NSString *)reason into:(NSMutableArray *)effects +{ + if (_keptSettle != nil) { + [self bake:_keptSettle into:effects]; + return; + } + [self setState:RNCPageCurlStateIdle reason:reason into:effects]; +} + +- (void)requestSettleInto:(NSMutableArray *)effects +{ + _settleRequestToken += 1; + _settleRequestSerial = _stateSerial; + RNCPageCurlEffect *effect = [RNCPageCurlEffect effectOfKind:RNCPageCurlEffectRequestSettle]; + effect.token = _settleRequestToken; + [effects addObject:effect]; +} + +// the manager is about to move the page: a machine resting on it waits for the settle +- (void)awaitSettleIfResting:(NSString *)reason into:(NSMutableArray *)effects +{ + if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { + [self setState:RNCPageCurlStateAwaitingSettle reason:reason into:effects]; + } } +#pragma mark - lifecycle + - (NSArray *)enable { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + _keptSettle = nil; + _bakedSettle = nil; + _awaitingLanding = NO; + _failedBakes = 0; + // nothing is baked yet; the manager's first settle starts the first bake + [self setState:RNCPageCurlStateAwaitingSettle reason:@"enabled" into:effects]; + return effects; } - (void)teardown { + _stateSerial += 1; + _state = RNCPageCurlStateIdle; + _keptSettle = nil; + _bakedSettle = nil; + _awaitingLanding = NO; + _failedBakes = 0; } +#pragma mark - messages from the manager + - (NSArray *)settled:(NSDictionary *)settle { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateBaking || _state == RNCPageCurlStateAnimating) { + _keptSettle = settle; + [effects addObject:[self note:[NSString stringWithFormat:@"settle kept until the %@ ends", RNCPageCurlStateName(_state)]]]; + return effects; + } + // posted during the turn, delivered after it landed: it names the page the reader just left + if (_awaitingLanding && [self settle:settle namesSamePageAs:_bakedSettle]) { + [effects addObject:[self note:@"settle for the page just left; dropped"]]; + return effects; + } + [self bake:settle into:effects]; + return effects; } - (NSArray *)unsettled { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // the manager posts this before it moves, so every settle after it is from after the landing + _awaitingLanding = NO; + [self awaitSettleIfResting:@"unsettled" into:effects]; + return effects; } - (NSArray *)touchEnded { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateAwaitingTapResult) { + [self setState:RNCPageCurlStateIdle reason:@"tap turned no page" into:effects]; + } + return effects; } +#pragma mark - the finger + - (NSArray *)tapped { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateIdle) { + [self setState:RNCPageCurlStateAwaitingTapResult reason:@"tap" into:effects]; + } + return effects; } - (NSArray *)turnBegan { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state == RNCPageCurlStateIdle) { + [self setState:RNCPageCurlStateAnimating reason:@"turn begins" into:effects]; + } + return effects; } - (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state != RNCPageCurlStateAnimating) { + [effects addObject:[self note:@"turn finished while not animating; ignored"]]; + return effects; + } + if (!completed) { + // the three bakes are still valid + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectShowCurrentPage]]; + [self bakeKeptOrIdle:@"turn cancelled" into:effects]; + return effects; + } + // the cover shows the landed page; the manager turns to it and its settle bakes every slot again + [effects addObject:[self directed:RNCPageCurlEffectShowLandedPage toward:direction]]; + // a settle from during the turn describes the page just left; the landing produces a fresh one + _keptSettle = nil; + _awaitingLanding = YES; + [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed" into:effects]; + [effects addObject:[self directed:RNCPageCurlEffectLand toward:direction]]; + return effects; } +#pragma mark - results of effects + - (NSArray *)bakeFinished:(BOOL)ok { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + if (_state != RNCPageCurlStateBaking) { + [effects addObject:[self note:@"bake finished while not baking; ignored"]]; + return effects; + } + if (ok) { + _failedBakes = 0; + [self bakeKeptOrIdle:@"baked" into:effects]; + return effects; + } + // the slots describe another page now + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBlankSlots]]; + if (_keptSettle != nil) { + [self bake:_keptSettle into:effects]; + return effects; + } + _failedBakes += 1; + if (_failedBakes >= RNCPageCurlMaxFailedBakes) { + // taps still turn pages; the next settle the manager sends on its own bakes again + [self setState:RNCPageCurlStateIdle reason:@"bakes kept failing" into:effects]; + return effects; + } + [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed" into:effects]; + [self requestSettleInto:effects]; + return effects; } - (NSArray *)landingFinished:(BOOL)ok { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // a landing that already produced its settle, or was torn down, has nothing left to repair + if (ok || !_awaitingLanding) { + return effects; + } + // the webview never moved, so the settle it sends names the page the turn started on + _awaitingLanding = NO; + [self awaitSettleIfResting:@"landing failed" into:effects]; + [self requestSettleInto:effects]; + return effects; } - (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + BOOL sameWait = _state == RNCPageCurlStateAwaitingSettle && token == _settleRequestToken && _stateSerial == _settleRequestSerial; + if (ok || !sameWait) { + return effects; + } + // no settle is coming: open input with nothing to curl onto until the manager settles on its own + [effects addObject:[RNCPageCurlEffect effectOfKind:RNCPageCurlEffectBlankSlots]]; + [self setState:RNCPageCurlStateIdle reason:@"settle request failed" into:effects]; + return effects; } - (NSArray *)rebakeRequested:(NSString *)reason { - return @[]; + NSMutableArray *effects = [NSMutableArray array]; + // a busy machine needs nothing more: the settle that comes back is kept and baked when it frees + [self awaitSettleIfResting:reason into:effects]; + [self requestSettleInto:effects]; + return effects; +} + +#pragma mark - helpers + +- (BOOL)settle:(NSDictionary *)settle namesSamePageAs:(NSDictionary *)other +{ + return other != nil && + [settle[@"page"] integerValue] == [other[@"page"] integerValue] && + [settle[@"chunkIndex"] integerValue] == [other[@"chunkIndex"] integerValue]; } @end diff --git a/apple/RNCWebViewPageCurl.m b/apple/RNCWebViewPageCurl.m index f12024415..cd63af7b2 100644 --- a/apple/RNCWebViewPageCurl.m +++ b/apple/RNCWebViewPageCurl.m @@ -1,13 +1,15 @@ #import "RNCWebViewPageCurl.h" #import "RNCPageCurlRenderer.h" +#import "RNCPageCurlStateMachine.h" #import BOOL RNCPageCurlLoggingEnabled = NO; +// holds at least this long carry an intent of their own (select, lift a link); shorter ones are tap decoration +static const NSTimeInterval RNCPageCurlIntentHoldSeconds = 0.3; static NSString *const RNCPageCurlSlotCurrent = @"current"; static NSString *const RNCPageCurlSlotPrevious = @"previous"; static NSString *const RNCPageCurlSlotNext = @"next"; -static const NSUInteger RNCPageCurlMaxFailedBakes = 3; // defaults for the tuning JSON; the Bookwise side documents each knob static NSDictionary *RNCPageCurlTuningDefaults(void) @@ -16,6 +18,8 @@ @"radiusFraction": @0.12, @"radiusMax": @90, @"bendInDistance": @30, + @"grabFloor": @0.3, + @"sweepGain": @2, @"settleGain": @2, @"tiltSoftness": @120, @"castWidthFloor": @0.5, @@ -43,31 +47,6 @@ typedef void (^RNCPageCurlStep)(void (^done)(BOOL ok)); -// the only state the controller keeps about the bake loop; input reaches the webview only when idle -typedef NS_ENUM(NSInteger, RNCPageCurlState) { - RNCPageCurlStateIdle, - // a finger is turning a sheet, or the release animation is playing; the DOM does not move - RNCPageCurlStateAnimating, - // the slots are being snapshotted; runs to completion, a settle that arrives meanwhile is kept for after - RNCPageCurlStateBaking, - // the manager is moving the page (a tap, a jump, or a landed curl's page turn); its settle starts the bake - RNCPageCurlStateAwaitingSettle, - // a tap reached the webview; the manager answers with a settle (page turned) or touchEnd (nothing turned) - RNCPageCurlStateAwaitingTapResult, -}; - -static NSString *RNCPageCurlStateName(RNCPageCurlState state) -{ - switch (state) { - case RNCPageCurlStateIdle: return @"idle"; - case RNCPageCurlStateAnimating: return @"animating"; - case RNCPageCurlStateBaking: return @"baking"; - case RNCPageCurlStateAwaitingSettle: return @"awaitingSettle"; - case RNCPageCurlStateAwaitingTapResult: return @"awaitingTapResult"; - } - return @"?"; -} - // the paper faded toward the opposite extreme so it reads as the reverse side in both themes static UIColor *RNCPageCurlFadedPaper(UIColor *paper) { @@ -85,16 +64,13 @@ static CGFloat RNCPageCurlEaseOut(CGFloat t) return 1 - inv * inv * inv; } -// Never recognizes; only reports raw touch begin/end so a tap can be told apart from a drag -// and a still hold can hand the touch back to WebKit's selection recognizers. +// Never recognizes; only reports raw touch begin/end so a tap can be told apart from a drag. @interface RNCPageCurlTouchObserver : UIGestureRecognizer @property (nonatomic, copy) void (^onTouchesBegan)(void); @property (nonatomic, copy) void (^onTouchesEnded)(void); -@property (nonatomic, copy) void (^onHeldStill)(void); @property (nonatomic, assign) CGPoint startPoint; @property (nonatomic, assign) CFTimeInterval startTime; @property (nonatomic, assign) BOOL moved; -@property (nonatomic, assign) NSUInteger touchSequence; // the first finger down; later fingers are ignored until it lifts @property (nonatomic, weak) UITouch *touch; @end @@ -111,16 +87,6 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event self.startPoint = [self.touch locationInView:self.view]; self.startTime = CACurrentMediaTime(); self.moved = NO; - self.touchSequence += 1; - NSUInteger sequence = self.touchSequence; - __weak __typeof(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf.touchSequence == sequence && !strongSelf.moved && - strongSelf.state == UIGestureRecognizerStatePossible && strongSelf.onHeldStill) { - strongSelf.onHeldStill(); - } - }); if (self.onTouchesBegan) { self.onTouchesBegan(); } @@ -134,8 +100,9 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event return; } CGPoint point = [touch locationInView:self.view]; - if (fabs(point.x - self.startPoint.x) > 8 || fabs(point.y - self.startPoint.y) > 8) { + if (!self.moved && (fabs(point.x - self.startPoint.x) > 8 || fabs(point.y - self.startPoint.y) > 8)) { self.moved = YES; + RNCPageCurlLog(@"[page-curl] touch moved past 8pt after %.0fms", (CACurrentMediaTime() - self.startTime) * 1000); } } @@ -203,21 +170,15 @@ @implementation RNCWebViewPageCurl { BOOL _enabled; BOOL _spread; BOOL _edgeEmitted; - RNCPageCurlState _state; + // the bake loop; the controller reports what happened and carries out the effects it returns + RNCPageCurlStateMachine *_machine; + NSHashTable *_knownHolds; // UIKit hit-tests once, at touch-down; only a touch it delivered inside the webview is one the // manager saw and will answer BOOL _touchReachedWebView; // a bake step still in flight from a torn-down controller stops; setSpine tears down and re-enables, // which _enabled alone cannot tell apart NSUInteger _teardownCount; - // bumped on every state change and on teardown; the settle watchdog checks it has not moved - NSUInteger _stateSerial; - // the turn just landed and the manager was asked to move; a settle for the page it left is stale - BOOL _awaitingLanding; - // bakes that failed back to back; at the cap the controller stops asking for another - NSUInteger _failedBakes; - // the latest settle that arrived while a bake or turn was running; applied once the controller is free - NSDictionary *_pendingSettle; // the page the webview rests on; pages count from 0 inside the chunk NSInteger _page; NSInteger _lastPage; @@ -256,6 +217,7 @@ - (instancetype)initWithHostView:(UIView *)hostView webView:(WKWebView *)webView if ((self = [super init])) { _hostView = hostView; _webView = webView; + _machine = [RNCPageCurlStateMachine new]; _slots = [NSMutableDictionary dictionary]; for (NSString *slot in @[RNCPageCurlSlotPrevious, RNCPageCurlSlotCurrent, RNCPageCurlSlotNext]) { _slots[slot] = [RNCPageCurlSlot new]; @@ -452,8 +414,7 @@ - (void)setEnabled:(BOOL)enabled [self applyColors]; [self applyTuning]; [self showCover]; - // nothing is baked yet; the manager's first settle starts the first bake - [self setState:RNCPageCurlStateAwaitingSettle reason:@"enabled"]; + [self apply:[_machine enable]]; __weak __typeof(self) weakSelf = self; _touchObserver = [RNCPageCurlTouchObserver new]; @@ -467,7 +428,7 @@ - (void)setEnabled:(BOOL)enabled } UIView *hit = strongSelf->_touchObserver.touch.view; strongSelf->_touchReachedWebView = hit != nil && strongSelf->_webView != nil && [hit isDescendantOfView:strongSelf->_webView]; - RNCPageCurlLog(@"[page-curl] touches began; state=%@ reachedWebView=%d", RNCPageCurlStateName(strongSelf->_state), strongSelf->_touchReachedWebView); + RNCPageCurlLog(@"[page-curl] touches began; state=%@ reachedWebView=%d", RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); [strongSelf makeWebPansYieldToCurl]; [strongSelf emit:@"touch" direction:nil detail:nil]; }; @@ -477,28 +438,16 @@ - (void)setEnabled:(BOOL)enabled return; } BOOL moved = strongSelf->_touchObserver.moved; - RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_state), + RNCPageCurlLog(@"[page-curl] touches ended; moved=%d state=%@ reachedWebView=%d", moved, RNCPageCurlStateName(strongSelf->_machine.state), strongSelf->_touchReachedWebView); - if (!moved && strongSelf->_touchReachedWebView && strongSelf->_state == RNCPageCurlStateIdle) { - [strongSelf setState:RNCPageCurlStateAwaitingTapResult reason:@"tap"]; + if (!moved && strongSelf->_touchReachedWebView && strongSelf->_machine.state == RNCPageCurlStateIdle) { + [strongSelf apply:[strongSelf->_machine tapped]]; [strongSelf emit:@"tap" direction:nil detail:nil]; } }; - _touchObserver.onHeldStill = ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf == nil || strongSelf->_pan == nil) { - return; - } - // a still hold is a text-selection intent, not a page drag: fail the curl pan so - // WebKit's long-press recognizers (which now wait on it) can proceed - if (strongSelf->_pan.state == UIGestureRecognizerStatePossible) { - RNCPageCurlLog(@"[page-curl] held still for 350ms; releasing the curl pan for this touch"); - strongSelf->_pan.enabled = NO; - strongSelf->_pan.enabled = YES; - } - }; [host addGestureRecognizer:_touchObserver]; + _knownHolds = [NSHashTable weakObjectsHashTable]; _pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(onPan:)]; _pan.maximumNumberOfTouches = 1; _pan.delegate = self; @@ -508,7 +457,10 @@ - (void)setEnabled:(BOOL)enabled } // touch scrolling off in every scroll view inside the webview (rescanned at each touch-down: WebKit -// adds more as chunks load) and its pans wait for the curl pan; not undone, the caller remounts +// adds more as chunks load) and its pans wait for the curl pan; not undone, the caller remounts. +// The curl pan itself waits for WebKit's long holds (the selection loupe at 0.5s, link lift at +// 0.65s): a hold fails as soon as the finger moves, so a drag still curls at once, and a still +// finger selects. The short holds (tap highlight at 0.12s) keep waiting on the pan like the pans do - (void)makeWebPansYieldToCurl { if (_webView == nil || _pan == nil) { @@ -516,6 +468,7 @@ - (void)makeWebPansYieldToCurl } NSMutableArray *stack = [NSMutableArray arrayWithObject:_webView]; NSUInteger recognizers = 0; + NSUInteger holds = 0; NSUInteger scrollViews = 0; while (stack.count > 0) { UIView *view = stack.lastObject; @@ -528,17 +481,27 @@ - (void)makeWebPansYieldToCurl } } for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { - BOOL isPan = [recognizer isKindOfClass:[UIPanGestureRecognizer class]]; - BOOL isLongPress = [recognizer isKindOfClass:[UILongPressGestureRecognizer class]]; - if ((isPan || isLongPress) && recognizer != _pan) { + if (recognizer == _pan) { + continue; + } + UILongPressGestureRecognizer *hold = [recognizer isKindOfClass:[UILongPressGestureRecognizer class]] ? (UILongPressGestureRecognizer *)recognizer : nil; + if (hold != nil && hold.minimumPressDuration >= RNCPageCurlIntentHoldSeconds) { + if (![_knownHolds containsObject:hold]) { + [_knownHolds addObject:hold]; + RNCPageCurlLog(@"[page-curl] curl pan waits on %@ (%.2fs, %.0fpt)", NSStringFromClass([hold class]), hold.minimumPressDuration, + hold.allowableMovement); + } + [_pan requireGestureRecognizerToFail:hold]; + holds += 1; + } else if (hold != nil || [recognizer isKindOfClass:[UIPanGestureRecognizer class]]) { [recognizer requireGestureRecognizerToFail:_pan]; recognizers += 1; } } [stack addObjectsFromArray:view.subviews]; } - RNCPageCurlLog(@"[page-curl] web scrolling off: %lu scroll views changed, %lu recognizers yield to the curl pan", - (unsigned long)scrollViews, (unsigned long)recognizers); + RNCPageCurlLog(@"[page-curl] web scrolling off: %lu scroll views changed, %lu recognizers yield to the curl pan, which waits on %lu holds", + (unsigned long)scrollViews, (unsigned long)recognizers, (unsigned long)holds); } - (void)setSpine:(NSString *)spine @@ -555,32 +518,11 @@ - (void)setSpine:(NSString *)spine } } -// bakes no longer match the page; the manager settles again once the page has painted. A busy -// controller needs nothing: the settle the manager sends is kept and applied when the controller frees +// bakes no longer match the page; the manager settles again once the page has painted - (void)requestRebake:(NSString *)reason { - RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_state)); - [self awaitSettleIfResting:reason]; - NSUInteger serial = _stateSerial; - __weak __typeof(self) weakSelf = self; - [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (ok || strongSelf == nil || strongSelf->_stateSerial != serial || strongSelf->_state != RNCPageCurlStateAwaitingSettle) { - return; - } - // no settle is coming: open input with nothing to curl onto until the manager settles on its own - [strongSelf blankAllSlots]; - [strongSelf setState:RNCPageCurlStateIdle reason:@"rebake request failed"]; - }]; -} - -// the manager is about to move the page: a controller resting on it (idle, or waiting on a tap's -// result) waits for the settle -- (void)awaitSettleIfResting:(NSString *)reason -{ - if (_state == RNCPageCurlStateIdle || _state == RNCPageCurlStateAwaitingTapResult) { - [self setState:RNCPageCurlStateAwaitingSettle reason:reason]; - } + RNCPageCurlLog(@"[page-curl] rebake requested (%@) state=%@", reason, RNCPageCurlStateName(_machine.state)); + [self apply:[_machine rebakeRequested:reason]]; } - (void)teardown @@ -588,13 +530,12 @@ - (void)teardown RNCPageCurlLog(@"[page-curl] teardown"); _enabled = NO; _teardownCount += 1; - _stateSerial += 1; - _awaitingLanding = NO; - _failedBakes = 0; + [_machine teardown]; [self stopAnimation]; if (_pan != nil) { [_pan.view removeGestureRecognizer:_pan]; _pan = nil; + _knownHolds = nil; } if (_touchObserver != nil) { [_touchObserver.view removeGestureRecognizer:_touchObserver]; @@ -606,8 +547,6 @@ - (void)teardown _inputBlocker = nil; _turnDirection = nil; _edgeEmitted = NO; - _pendingSettle = nil; - _state = RNCPageCurlStateIdle; } - (void)layoutWithBounds:(CGRect)bounds @@ -949,14 +888,11 @@ - (void)addNeighborStepsForPage:(NSInteger)page direction:(NSString *)direction // every slot from scratch; the settle says which page the webview rests on - (void)runBakeWithSettle:(NSDictionary *)settle { - _pendingSettle = nil; - _awaitingLanding = NO; _edgeEmitted = NO; _page = [settle[@"page"] integerValue]; _lastPage = [settle[@"totalPages"] integerValue]; _chunkIndex = [settle[@"chunkIndex"] integerValue]; _isLastChunk = [settle[@"isLastChunk"] boolValue]; - [self setState:RNCPageCurlStateBaking reason:@"settle"]; NSUInteger teardownCount = _teardownCount; CFTimeInterval start = CACurrentMediaTime(); RNCPageCurlLog(@"[page-curl] bake start page=%ld/%ld chunk=%ld last=%d", (long)_page, (long)_lastPage, (long)_chunkIndex, _isLastChunk); @@ -983,94 +919,74 @@ - (void)runBakeWithSettle:(NSDictionary *)settle return; } RNCPageCurlLog(@"[page-curl] bake %@ in %.1fms", ok ? @"done" : @"FAILED", (CACurrentMediaTime() - start) * 1000.0); - [strongSelf finishBake:ok]; + [strongSelf apply:[strongSelf->_machine bakeFinished:ok]]; }]; } -// a settle that arrived while the controller was busy starts the next bake under the same cover -- (void)bakePendingOrIdle:(NSString *)reason -{ - if (_pendingSettle != nil) { - RNCPageCurlLog(@"[page-curl] baking again: a settle arrived meanwhile"); - [self runBakeWithSettle:_pendingSettle]; - return; - } - [self setState:RNCPageCurlStateIdle reason:reason]; -} - -// a finished bake opens input; one that stopped early blanks its slots, which describe another page, -// then bakes a kept settle, asks the manager for a new one, or gives up at the cap -- (void)finishBake:(BOOL)ok -{ - if (ok) { - _failedBakes = 0; - [self bakePendingOrIdle:@"baked"]; - return; - } - [self blankAllSlots]; - if (_pendingSettle != nil) { - [self runBakeWithSettle:_pendingSettle]; - return; - } - _failedBakes += 1; - if (_failedBakes >= RNCPageCurlMaxFailedBakes) { - // taps still turn pages; the next settle the manager sends on its own bakes again - RNCPageCurlLog(@"[page-curl] %lu bakes failed in a row; no curl until the next settle", (unsigned long)_failedBakes); - [self setState:RNCPageCurlStateIdle reason:@"bakes kept failing"]; - return; - } - [self setState:RNCPageCurlStateAwaitingSettle reason:@"bake failed"]; - [self requestRebake:@"bake failed"]; -} - #pragma mark - messages from the content frame - (void)handleMessage:(NSDictionary *)message { NSString *type = message[@"type"]; - RNCPageCurlLog(@"[page-curl] message %@ (state=%@ pending=%d)", message, RNCPageCurlStateName(_state), _pendingSettle != nil); + RNCPageCurlLog(@"[page-curl] message %@ (%@)", message, _machine.summary); if (_renderer == nil) { return; } if ([type isEqualToString:@"settled"]) { [self emit:@"settled" direction:nil detail:[NSString stringWithFormat:@"page %@/%@ chunk %@", message[@"page"], message[@"totalPages"], message[@"chunkIndex"]]]; - if (_state == RNCPageCurlStateBaking || _state == RNCPageCurlStateAnimating) { - RNCPageCurlLog(@"[page-curl] settle kept until the %@ ends", RNCPageCurlStateName(_state)); - _pendingSettle = message; - return; - } - // posted during the turn, delivered after it landed: it names the page the reader just left - if (_awaitingLanding && [message[@"page"] integerValue] == _page && [message[@"chunkIndex"] integerValue] == _chunkIndex) { - RNCPageCurlLog(@"[page-curl] settle for the page just left; dropped"); - return; - } - [self runBakeWithSettle:message]; + [self apply:[_machine settled:message]]; return; } if ([type isEqualToString:@"unsettled"]) { - // the manager posts this before it moves, so every settle after it is from after the landing - _awaitingLanding = NO; - [self awaitSettleIfResting:@"unsettled"]; + [self apply:[_machine unsettled]]; return; } if ([type isEqualToString:@"touchEnd"]) { - if (_state == RNCPageCurlStateAwaitingTapResult) { - [self setState:RNCPageCurlStateIdle reason:@"tap turned no page"]; - } + [self apply:[_machine touchEnded]]; return; } RNCPageCurlLog(@"[page-curl] unknown message type %@", type); } -#pragma mark - state +#pragma mark - effects + +// carries out what the machine asked for, in order +- (void)apply:(NSArray *)effects +{ + for (RNCPageCurlEffect *effect in effects) { + switch (effect.kind) { + case RNCPageCurlEffectEnterState: + [self enterState:effect.state reason:effect.reason]; + break; + case RNCPageCurlEffectBake: + [self runBakeWithSettle:effect.settle]; + break; + case RNCPageCurlEffectBlankSlots: + [self blankAllSlots]; + break; + case RNCPageCurlEffectShowLandedPage: + [self rotateSlotsToward:effect.direction]; + [self showCover]; + break; + case RNCPageCurlEffectShowCurrentPage: + [self showCover]; + break; + case RNCPageCurlEffectLand: + [self landToward:effect.direction]; + break; + case RNCPageCurlEffectRequestSettle: + [self requestSettleWithToken:effect.token]; + break; + case RNCPageCurlEffectNote: + RNCPageCurlLog(@"[page-curl] %@", effect.reason); + break; + } + } +} -- (void)setState:(RNCPageCurlState)state reason:(NSString *)reason +- (void)enterState:(RNCPageCurlState)state reason:(NSString *)reason { - if (state != _state) { - RNCPageCurlLog(@"[page-curl] %@ -> %@ (%@)", RNCPageCurlStateName(_state), RNCPageCurlStateName(state), reason); - _stateSerial += 1; - } - _state = state; + RNCPageCurlLog(@"[page-curl] -> %@ (%@)", RNCPageCurlStateName(state), reason); // both overlays follow the state: idle is the webview, bare, with touches reaching it _inputBlocker.hidden = state == RNCPageCurlStateIdle; if (state == RNCPageCurlStateIdle) { @@ -1080,6 +996,43 @@ - (void)setState:(RNCPageCurlState)state reason:(NSString *)reason } } +// the manager turns to the landed page the way a margin tap does; past the chunk edge that is its own chunk switch +- (void)landToward:(NSString *)direction +{ + NSString *fn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; + RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, fn); + NSUInteger teardownCount = _teardownCount; + __weak __typeof(self) weakSelf = self; + [self callBridge:fn argument:nil completion:^(BOOL ok, id result) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil || teardownCount != strongSelf->_teardownCount) { + return; + } + [strongSelf apply:[strongSelf->_machine landingFinished:ok]]; + }]; + // only the manager's settle leaves this state; say so if it has not come + NSUInteger serial = _machine.stateSerial; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf != nil && strongSelf->_machine.stateSerial == serial) { + RNCPageCurlLog(@"[page-curl] no settle 3s after landing toward %@; still awaiting it", direction); + } + }); +} + +- (void)requestSettleWithToken:(NSUInteger)token +{ + NSUInteger teardownCount = _teardownCount; + __weak __typeof(self) weakSelf = self; + [self callBridge:@"invalidateNativePageCurl" argument:nil completion:^(BOOL ok, id result) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil || teardownCount != strongSelf->_teardownCount) { + return; + } + [strongSelf apply:[strongSelf->_machine settleRequestFinished:ok token:token]]; + }]; +} + - (BOOL)panActive { return _pan.state == UIGestureRecognizerStateBegan || _pan.state == UIGestureRecognizerStateChanged; @@ -1094,15 +1047,15 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer } // the curl pan claims every drag while the curl is on, so WebKit's pans never scroll the page // themselves; if the controller is busy the turn starts once it is idle - RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_state)); - return _state != RNCPageCurlStateAnimating; + RNCPageCurlLog(@"[page-curl] pan should begin? state=%@", RNCPageCurlStateName(_machine.state)); + return _machine.state != RNCPageCurlStateAnimating; } // the active pan's turn, from its original touch-down: the pan's own translation restarts from zero // where recognition began, and a pan that began while the controller was busy starts here once it is idle - (void)beginTurnForPan { - if (![self panActive] || _turnDirection != nil || _turnDeclined || _state != RNCPageCurlStateIdle) { + if (![self panActive] || _turnDirection != nil || _turnDeclined || !_machine.canBeginTurn) { return; } CGPoint touchDown = _touchObserver.startPoint; @@ -1122,11 +1075,11 @@ - (void)onPan:(UIPanGestureRecognizer *)pan { CGPoint location = [pan locationInView:_hostView]; RNCPageCurlLog(@"[page-curl] pan state=%ld location=%.1f,%.1f touchDown=%@ curlState=%@ turn=%@", (long)pan.state, location.x, location.y, - NSStringFromCGPoint(_touchObserver.startPoint), RNCPageCurlStateName(_state), _turnDirection); + NSStringFromCGPoint(_touchObserver.startPoint), RNCPageCurlStateName(_machine.state), _turnDirection); BOOL moving = pan.state == UIGestureRecognizerStateBegan || pan.state == UIGestureRecognizerStateChanged; if (_turnDirection == nil && moving) { - if (_state != RNCPageCurlStateIdle && pan.state == UIGestureRecognizerStateBegan) { - RNCPageCurlLog(@"[page-curl] pan began while %@; holding the drag until idle", RNCPageCurlStateName(_state)); + if (!_machine.canBeginTurn && pan.state == UIGestureRecognizerStateBegan) { + RNCPageCurlLog(@"[page-curl] pan began while %@; holding the drag until idle", RNCPageCurlStateName(_machine.state)); } [self beginTurnForPan]; return; @@ -1135,7 +1088,7 @@ - (void)onPan:(UIPanGestureRecognizer *)pan if (pan.state == UIGestureRecognizerStateEnded || pan.state == UIGestureRecognizerStateCancelled || pan.state == UIGestureRecognizerStateFailed) { _turnDeclined = NO; - RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_state)); + RNCPageCurlLog(@"[page-curl] pan ended without a turn (state=%@)", RNCPageCurlStateName(_machine.state)); } return; } @@ -1174,15 +1127,16 @@ - (void)beginTurn:(NSString *)direction from:(CGPoint)start BOOL next = [direction isEqualToString:RNCPageCurlSlotNext]; _turnDirection = direction; _turnReversed = !next && !_spread; - [self setState:RNCPageCurlStateAnimating reason:@"turn begins"]; + [self apply:[_machine turnBegan]]; [self showTurnScene:direction]; CGFloat width = _turnSheetRect.size.width; CGFloat height = _turnSheetRect.size.height; _turnRadiusMax = MIN([self tune:@"radiusMax"], width * [self tune:@"radiusFraction"]); _renderer.curlRadiusMax = _turnRadiusMax; - // the sheet is grabbed at its outer edge on the touch's row, wherever the touch lands, and that - // edge moves exactly as far as the finger does - _turnStart = CGPointMake(width, MIN(MAX(start.y - _turnSheetRect.origin.y, 0), height)); + // the sheet is grabbed under the finger, no closer to the spine than grabFloor of the width, and + // that point moves exactly as far as the finger does + CGFloat grabX = MIN(MAX([self localXFor:start.x], width * [self tune:@"grabFloor"]), width); + _turnStart = CGPointMake(grabX, MIN(MAX(start.y - _turnSheetRect.origin.y, 0), height)); _turnRest = _turnReversed ? [self turnedFinger] : _turnStart; _turnTouchDown = CGPointMake([self localXFor:start.x], start.y - _turnSheetRect.origin.y); _turnDrag = 0; @@ -1277,11 +1231,13 @@ - (void)applyFinger:(CGPoint)finger finger = CGPointMake(_turnStart.x - nx * distance, _turnStart.y - ny * distance); } CGFloat radius = MIN(wantedRadius, MAX(2 * (room - distance / 2) / M_PI, 0)); - // a zero drag is a flat sheet: while the bend forms, the fold starts past the outer edge and sweeps - // in, instead of sitting at the grab point with everything beyond it mirrored onto itself - CGFloat bendIn = MAX([self tune:@"bendInDistance"], 1); - CGFloat settle = MAX(0, 1 - distance / bendIn); - CGFloat foldOffset = settle * (_turnSheetRect.size.width - _turnStart.x + M_PI * radius / 2 + 4); + // a zero drag is a flat sheet: the fold starts past the outer edge and sweeps in to the grab point + // at sweepGain times the finger's speed, instead of appearing at the grab point with everything + // beyond it already folded over + CGFloat beyond = _turnSheetRect.size.width - _turnStart.x; + CGFloat sweepIn = MAX(MAX([self tune:@"bendInDistance"], 1), beyond / MAX([self tune:@"sweepGain"], 0.01)); + CGFloat settle = MAX(0, 1 - distance / sweepIn); + CGFloat foldOffset = settle * (beyond + M_PI * radius / 2 + 4); CGFloat foldX = _turnStart.x + nx * foldOffset - nx * (distance + M_PI * radius) / 2; _renderer.curlStart = CGPointMake(_turnStart.x + nx * foldOffset, _turnStart.y + ny * foldOffset); _renderer.curlFinger = CGPointMake(finger.x + nx * foldOffset, finger.y + ny * foldOffset); @@ -1364,41 +1320,12 @@ - (void)finishTurnCompleted:(BOOL)completed _turnDirection = nil; _edgeEmitted = NO; RNCPageCurlLog(@"[page-curl] turn %@ finished completed=%d", direction, completed); - if (!completed) { - [self showCover]; + if (completed) { + [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; + } else { [self emit:@"cancel" direction:nil detail:nil]; - // the three bakes are still valid - [self bakePendingOrIdle:@"turn cancelled"]; - return; } - [self emit:@"turn" direction:direction detail:[NSString stringWithFormat:@"from page %ld", (long)_page]]; - // the cover shows the landed page; the manager turns to it the way a margin tap does, and its - // settle bakes every slot again. Past the chunk edge that is the manager's own chunk switch - [self rotateSlotsToward:direction]; - [self showCover]; - // a settle from during the turn describes the page just left; the landing produces a fresh one - _pendingSettle = nil; - _awaitingLanding = YES; - [self setState:RNCPageCurlStateAwaitingSettle reason:@"turn landed"]; - NSString *fn = [direction isEqualToString:RNCPageCurlSlotNext] ? @"instantlyScrollPageDown" : @"instantlyScrollPageUp"; - RNCPageCurlLog(@"[page-curl] turn landed toward %@; %@", direction, fn); - __weak __typeof(self) weakSelf = self; - [self callBridge:fn argument:nil completion:^(BOOL ok, id result) { - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (!ok && strongSelf != nil) { - // the webview never moved, so the settle it sends names the page the turn started on - strongSelf->_awaitingLanding = NO; - [strongSelf requestRebake:@"landing failed"]; - } - }]; - // only the manager's settle leaves this state; say so if it has not come - NSUInteger serial = _stateSerial; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf->_stateSerial == serial) { - RNCPageCurlLog(@"[page-curl] no settle 3s after landing toward %@; still awaiting it", direction); - } - }); + [self apply:[_machine turnFinishedToward:direction completed:completed]]; } @end