diff --git a/apple-tests/RNCPageCurlStateMachineTests.m b/apple-tests/RNCPageCurlStateMachineTests.m new file mode 100644 index 000000000..6d73be609 --- /dev/null +++ b/apple-tests/RNCPageCurlStateMachineTests.m @@ -0,0 +1,134 @@ +#import +#import "RNCPageCurlStateMachine.h" + +static NSDictionary *Settle(NSInteger page, NSInteger chunk) +{ + return @{@"type": @"settled", @"page": @(page), @"totalPages": @10, @"chunkIndex": @(chunk), @"isLastChunk": @NO}; +} + +// one line per effect the controller has to carry out; notes are log lines only +static NSArray *Describe(NSArray *effects) +{ + NSMutableArray *lines = [NSMutableArray array]; + for (RNCPageCurlEffect *effect in effects) { + switch (effect.kind) { + case RNCPageCurlEffectEnterState: + [lines addObject:[NSString stringWithFormat:@"state %@ (%@)", RNCPageCurlStateName(effect.state), effect.reason]]; + break; + case RNCPageCurlEffectBake: + [lines addObject:[NSString stringWithFormat:@"bake page %@ chunk %@", effect.settle[@"page"], effect.settle[@"chunkIndex"]]]; + break; + case RNCPageCurlEffectBlankSlots: + [lines addObject:@"blank slots"]; + break; + case RNCPageCurlEffectShowLandedPage: + [lines addObject:[NSString stringWithFormat:@"show landed %@", effect.direction]]; + break; + case RNCPageCurlEffectShowCurrentPage: + [lines addObject:@"show current"]; + break; + case RNCPageCurlEffectLand: + [lines addObject:[NSString stringWithFormat:@"land %@", effect.direction]]; + break; + case RNCPageCurlEffectRequestSettle: + [lines addObject:@"request settle"]; + break; + case RNCPageCurlEffectNote: + break; + } + } + return lines; +} + +@interface RNCPageCurlStateMachineTests : XCTestCase +@end + +@implementation RNCPageCurlStateMachineTests { + RNCPageCurlStateMachine *_machine; +} + +- (void)setUp +{ + _machine = [RNCPageCurlStateMachine new]; +} + +// enabled, first page baked, idle +- (void)bakeFirstPage +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine bakeFinished:YES]; +} + +#pragma mark - the bake loop + +- (void)testStateNamesMatchTheLog +{ + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateIdle), @"idle"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAnimating), @"animating"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateBaking), @"baking"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAwaitingSettle), @"awaitingSettle"); + XCTAssertEqualObjects(RNCPageCurlStateName(RNCPageCurlStateAwaitingTapResult), @"awaitingTapResult"); +} + +- (void)testEnablingWaitsForTheFirstSettle +{ + XCTAssertEqualObjects(Describe([_machine enable]), (@[@"state awaitingSettle (enabled)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateAwaitingSettle); +} + +- (void)testASettleStartsABake +{ + [_machine enable]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(3, 1)]), (@[@"state baking (settle)", @"bake page 3 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); +} + +- (void)testAFinishedBakeOpensInput +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"state idle (baked)"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateIdle); +} + +- (void)testASettleWhileIdleBakesAgain +{ + [self bakeFirstPage]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[@"state baking (settle)", @"bake page 4 chunk 1"])); +} + +- (void)testASettleDuringABakeIsKeptAndBakedAfter +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + XCTAssertEqualObjects(Describe([_machine settled:Settle(4, 1)]), (@[])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 4 chunk 1"])); + XCTAssertEqual(_machine.state, RNCPageCurlStateBaking); + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"state idle (baked)"])); +} + +- (void)testOnlyTheLatestSettleKeptDuringABakeIsBaked +{ + [_machine enable]; + [_machine settled:Settle(3, 1)]; + [_machine settled:Settle(4, 1)]; + [_machine settled:Settle(5, 1)]; + XCTAssertEqualObjects(Describe([_machine bakeFinished:YES]), (@[@"bake page 5 chunk 1"])); +} + +- (void)testEveryStateChangeMovesTheSerial +{ + NSUInteger initial = _machine.stateSerial; + [_machine enable]; + NSUInteger enabled = _machine.stateSerial; + [_machine settled:Settle(3, 1)]; + NSUInteger baking = _machine.stateSerial; + [_machine settled:Settle(4, 1)]; + XCTAssertNotEqual(initial, enabled); + XCTAssertNotEqual(enabled, baking); + XCTAssertEqual(_machine.stateSerial, baking, @"a kept settle changes no state"); +} + +@end diff --git a/apple/RNCPageCurlRenderer.mm b/apple/RNCPageCurlRenderer.mm index bd1e6da25..1b23690a6 100644 --- a/apple/RNCPageCurlRenderer.mm +++ b/apple/RNCPageCurlRenderer.mm @@ -390,9 +390,43 @@ - (void)buildMeshes return nil; } CFTimeInterval start = CACurrentMediaTime(); - // MTKTextureLoader refuses the snapshot's bitmap layout; redraw it as plain BGRA instead size_t width = CGImageGetWidth(cg); size_t height = CGImageGetHeight(cg); + + // a WKWebView snapshot is 32-bit RGBA with alpha last; a UIGraphicsImageRenderer image is BGRA with alpha + // first. Metal reads both as-is given the matching pixel format, so neither needs a redraw. + CGBitmapInfo info = CGImageGetBitmapInfo(cg); + CGImageAlphaInfo alpha = (CGImageAlphaInfo)(info & kCGBitmapAlphaInfoMask); + CGBitmapInfo order = info & kCGBitmapByteOrderMask; + MTLPixelFormat directFormat = MTLPixelFormatInvalid; + if (CGImageGetBitsPerPixel(cg) == 32 && CGImageGetBitsPerComponent(cg) == 8) { + if (order == kCGBitmapByteOrder32Little && (alpha == kCGImageAlphaPremultipliedFirst || alpha == kCGImageAlphaNoneSkipFirst)) { + directFormat = MTLPixelFormatBGRA8Unorm; + } else if ((order == kCGBitmapByteOrderDefault || order == kCGBitmapByteOrder32Big) && (alpha == kCGImageAlphaPremultipliedLast || alpha == kCGImageAlphaNoneSkipLast)) { + directFormat = MTLPixelFormatRGBA8Unorm; + } + } + + MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:directFormat != MTLPixelFormatInvalid ? directFormat : MTLPixelFormatBGRA8Unorm + width:width + height:height + mipmapped:NO]; + descriptor.usage = MTLTextureUsageShaderRead; + descriptor.storageMode = MTLStorageModeShared; + id texture = [self.device newTextureWithDescriptor:descriptor]; + + if (directFormat != MTLPixelFormatInvalid) { + CFDataRef data = CGDataProviderCopyData(CGImageGetDataProvider(cg)); + if (data != nil) { + [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:CFDataGetBytePtr(data) bytesPerRow:CGImageGetBytesPerRow(cg)]; + CFRelease(data); + RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms (direct %@)", width, height, (CACurrentMediaTime() - start) * 1000.0, directFormat == MTLPixelFormatRGBA8Unorm ? @"rgba" : @"bgra"); + return texture; + } + } + + // any other layout is redrawn as plain BGRA first + RNCPageCurlLog(@"[page-curl] metal: snapshot layout bpp=%zu bpc=%zu bytesPerRow=%zu bitmapInfo=0x%x", CGImageGetBitsPerPixel(cg), CGImageGetBitsPerComponent(cg), CGImageGetBytesPerRow(cg), (unsigned)info); size_t bytesPerRow = width * 4; void *bytes = calloc(height, bytesPerRow); CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); @@ -406,16 +440,9 @@ - (void)buildMeshes } CGContextDrawImage(context, CGRectMake(0, 0, width, height), cg); CGContextRelease(context); - MTLTextureDescriptor *descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm - width:width - height:height - mipmapped:NO]; - descriptor.usage = MTLTextureUsageShaderRead; - descriptor.storageMode = MTLStorageModeShared; - id texture = [self.device newTextureWithDescriptor:descriptor]; [texture replaceRegion:MTLRegionMake2D(0, 0, width, height) mipmapLevel:0 withBytes:bytes bytesPerRow:bytesPerRow]; free(bytes); - RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms", width, height, (CACurrentMediaTime() - start) * 1000.0); + RNCPageCurlLog(@"[page-curl] metal: texture %zux%zu in %.1fms (redrawn)", width, height, (CACurrentMediaTime() - start) * 1000.0); return texture; } diff --git a/apple/RNCPageCurlStateMachine.h b/apple/RNCPageCurlStateMachine.h new file mode 100644 index 000000000..80019ed35 --- /dev/null +++ b/apple/RNCPageCurlStateMachine.h @@ -0,0 +1,84 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// 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, +}; + +FOUNDATION_EXPORT NSString *RNCPageCurlStateName(RNCPageCurlState state); + +typedef NS_ENUM(NSInteger, RNCPageCurlEffectKind) { + // the state changed; `state` and `reason` + RNCPageCurlEffectEnterState, + // snapshot every slot around the page `settle` names, then report bakeFinished: + RNCPageCurlEffectBake, + // the slots describe another page: no direction curls until the next bake + RNCPageCurlEffectBlankSlots, + // the turn toward `direction` landed: the cover shows that page + RNCPageCurlEffectShowLandedPage, + // the turn was cancelled: the cover shows the page it started on + RNCPageCurlEffectShowCurrentPage, + // ask the manager to turn the page toward `direction`, then report landingFinished: + RNCPageCurlEffectLand, + // ask the manager to settle again, then report settleRequestFinished:token: with `token` + RNCPageCurlEffectRequestSettle, + // nothing to do; `reason` is a line for the log + RNCPageCurlEffectNote, +}; + +@interface RNCPageCurlEffect : NSObject +@property (nonatomic, readonly) RNCPageCurlEffectKind kind; +@property (nonatomic, readonly) RNCPageCurlState state; +@property (nonatomic, readonly, copy, nullable) NSString *reason; +@property (nonatomic, readonly, copy, nullable) NSDictionary *settle; +@property (nonatomic, readonly, copy, nullable) NSString *direction; +@property (nonatomic, readonly) NSUInteger token; +@end + +/** + * The page curl's bake loop as events in and effects out, with no views and no webview. + * The controller reports what happened and carries out the effects in order. + */ +@interface RNCPageCurlStateMachine : NSObject + +@property (nonatomic, readonly) RNCPageCurlState state; +// changes on every state change and on teardown +@property (nonatomic, readonly) NSUInteger stateSerial; +@property (nonatomic, readonly) BOOL canBeginTurn; +// state and what is being held, for the log +@property (nonatomic, readonly) NSString *summary; + +- (NSArray *)enable; +- (void)teardown; + +// messages from the manager +- (NSArray *)settled:(NSDictionary *)settle; +- (NSArray *)unsettled; +- (NSArray *)touchEnded; + +// the finger +- (NSArray *)tapped; +- (NSArray *)turnBegan; +- (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed; + +// results of effects +- (NSArray *)bakeFinished:(BOOL)ok; +- (NSArray *)landingFinished:(BOOL)ok; +- (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token; + +// the bakes no longer match the page (resize, spine change) +- (NSArray *)rebakeRequested:(NSString *)reason; + +@end + +NS_ASSUME_NONNULL_END diff --git a/apple/RNCPageCurlStateMachine.m b/apple/RNCPageCurlStateMachine.m new file mode 100644 index 000000000..59da31d57 --- /dev/null +++ b/apple/RNCPageCurlStateMachine.m @@ -0,0 +1,82 @@ +#import "RNCPageCurlStateMachine.h" + +NSString *RNCPageCurlStateName(RNCPageCurlState state) +{ + return @"?"; +} + +@implementation RNCPageCurlEffect +@end + +@implementation RNCPageCurlStateMachine + +- (BOOL)canBeginTurn +{ + return NO; +} + +- (NSString *)summary +{ + return @""; +} + +- (NSArray *)enable +{ + return @[]; +} + +- (void)teardown +{ +} + +- (NSArray *)settled:(NSDictionary *)settle +{ + return @[]; +} + +- (NSArray *)unsettled +{ + return @[]; +} + +- (NSArray *)touchEnded +{ + return @[]; +} + +- (NSArray *)tapped +{ + return @[]; +} + +- (NSArray *)turnBegan +{ + return @[]; +} + +- (NSArray *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed +{ + return @[]; +} + +- (NSArray *)bakeFinished:(BOOL)ok +{ + return @[]; +} + +- (NSArray *)landingFinished:(BOOL)ok +{ + return @[]; +} + +- (NSArray *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token +{ + return @[]; +} + +- (NSArray *)rebakeRequested:(NSString *)reason +{ + return @[]; +} + +@end diff --git a/package.json b/package.json index d602ef8aa..c19093b53 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "android/build.gradle", "android/gradle.properties", "apple", + "apple-tests", "ios", "macos", "windows", diff --git a/react-native-webview.podspec b/react-native-webview.podspec index 24e7a1371..ebac8235d 100644 --- a/react-native-webview.podspec +++ b/react-native-webview.podspec @@ -19,6 +19,13 @@ Pod::Spec.new do |s| s.source_files = "apple/**/*.{h,m,mm,swift}" + # opt in from a Podfile with :testspecs => ['Tests'] + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = "apple-tests/**/*.{h,m}" + # the tests are Objective-C, but the bundle links the pod's C++ dependencies + test_spec.library = "c++" + end + if defined?(install_modules_dependencies()) != nil install_modules_dependencies(s); else