Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apple-tests/RNCPageCurlStateMachineTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#import <XCTest/XCTest.h>
#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<NSString *> *Describe(NSArray<RNCPageCurlEffect *> *effects)
{
NSMutableArray<NSString *> *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
45 changes: 36 additions & 9 deletions apple/RNCPageCurlRenderer.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<MTLTexture> 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();
Expand All @@ -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<MTLTexture> 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;
}

Expand Down
84 changes: 84 additions & 0 deletions apple/RNCPageCurlStateMachine.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#import <Foundation/Foundation.h>

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<RNCPageCurlEffect *> *)enable;
- (void)teardown;

// messages from the manager
- (NSArray<RNCPageCurlEffect *> *)settled:(NSDictionary *)settle;
- (NSArray<RNCPageCurlEffect *> *)unsettled;
- (NSArray<RNCPageCurlEffect *> *)touchEnded;

// the finger
- (NSArray<RNCPageCurlEffect *> *)tapped;
- (NSArray<RNCPageCurlEffect *> *)turnBegan;
- (NSArray<RNCPageCurlEffect *> *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed;

// results of effects
- (NSArray<RNCPageCurlEffect *> *)bakeFinished:(BOOL)ok;
- (NSArray<RNCPageCurlEffect *> *)landingFinished:(BOOL)ok;
- (NSArray<RNCPageCurlEffect *> *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token;

// the bakes no longer match the page (resize, spine change)
- (NSArray<RNCPageCurlEffect *> *)rebakeRequested:(NSString *)reason;

@end

NS_ASSUME_NONNULL_END
82 changes: 82 additions & 0 deletions apple/RNCPageCurlStateMachine.m
Original file line number Diff line number Diff line change
@@ -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<RNCPageCurlEffect *> *)enable
{
return @[];
}

- (void)teardown
{
}

- (NSArray<RNCPageCurlEffect *> *)settled:(NSDictionary *)settle
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)unsettled
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)touchEnded
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)tapped
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)turnBegan
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)turnFinishedToward:(NSString *)direction completed:(BOOL)completed
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)bakeFinished:(BOOL)ok
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)landingFinished:(BOOL)ok
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)settleRequestFinished:(BOOL)ok token:(NSUInteger)token
{
return @[];
}

- (NSArray<RNCPageCurlEffect *> *)rebakeRequested:(NSString *)reason
{
return @[];
}

@end
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"android/build.gradle",
"android/gradle.properties",
"apple",
"apple-tests",
"ios",
"macos",
"windows",
Expand Down
7 changes: 7 additions & 0 deletions react-native-webview.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading