ComponentKitApplicationTests/CKComponentBoundsAnimationTests.mm (238 lines of code) (raw):

/* * Copyright (c) 2014-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. * */ #import <XCTest/XCTest.h> #import <ComponentKit/CKBuildComponent.h> #import <ComponentKit/CKCasting.h> #import <ComponentKit/CKComponentInternal.h> #import <ComponentKit/CKComponentScope.h> #import <ComponentKit/CKComponentScopeRoot.h> #import <ComponentKit/CKComponentScopeRootFactory.h> #import <ComponentKit/CKComponentSubclass.h> #import <ComponentKit/CKFlexboxComponent.h> @interface CKComponentBoundsAnimationTests : XCTestCase @end @interface CKBoundsAnimationRecordingView : UIView @property (nonatomic, readonly) BOOL animatedLastBoundsChange; @end @implementation CKBoundsAnimationRecordingView - (void)setBounds:(CGRect)bounds { [super setBounds:bounds]; _animatedLastBoundsChange = ![CATransaction disableActions]; } @end @interface CKBoundsAnimationComponent : CKComponent @end @implementation CKBoundsAnimationComponent + (instancetype)newWithIdentifier:(NSNumber *)identifier { CKComponentScope scope(self, identifier); return [super newWithView:{[CKBoundsAnimationRecordingView class]} size:{}]; } - (CKComponentBoundsAnimation)boundsAnimationFromPreviousComponent:(CKComponent *)previousComponent { return {.duration = 0.5}; } @end @implementation CKComponentBoundsAnimationTests - (void)testBoundsAnimationCorrectlyComputedWhenBuildingNewVersionOfComponent { CKComponent *(^block)(void) = ^{ return [CKBoundsAnimationComponent newWithIdentifier:@0]; }; const CKBuildComponentResult firstResult = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, block); const CKBuildComponentResult secondResult = CKBuildComponent(firstResult.scopeRoot, {}, block); XCTAssertEqual(secondResult.boundsAnimation.duration, 0.5); } - (void)testBoundsAnimationIsAppliedToViewsWhenUpdatingToNewVersionOfComponent { CKComponent *(^block)(void) = ^{ return [CKBoundsAnimationComponent newWithIdentifier:@0]; }; UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; const CKBuildComponentResult firstResult = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, block); const RCLayout firstLayout = [firstResult.component layoutThatFits:{{50, 50}, {50, 50}} parentSize:{}]; NSSet *firstMountedComponents = CKMountComponentLayout(firstLayout, container, nil, nil); const CKBuildComponentResult secondResult = CKBuildComponent(firstResult.scopeRoot, {}, block); const RCLayout secondLayout = [secondResult.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; NSSet *secondMountedComponents = CKMountComponentLayout(secondLayout, container, firstMountedComponents, nil); CKBoundsAnimationRecordingView *v = (CKBoundsAnimationRecordingView *)secondResult.component.viewContext.view; XCTAssertTrue(v.animatedLastBoundsChange); CKUnmountComponents(secondMountedComponents); } - (void)testBoundsAnimationIsNotAppliedToNewlyCreatedViews { UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; const CKBuildComponentResult firstResult = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, ^{ return CK::FlexboxComponentBuilder() .alignItems(CKFlexboxAlignItemsStretch) .child([CKBoundsAnimationComponent newWithIdentifier:@0]) .flexGrow(1) .build(); }); const RCLayout firstLayout = [firstResult.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; NSSet *firstMountedComponents = CKMountComponentLayout(firstLayout, container, nil, nil); const CKBuildComponentResult secondResult = CKBuildComponent(firstResult.scopeRoot, {}, ^{ return CK::FlexboxComponentBuilder() .alignItems(CKFlexboxAlignItemsStretch) .child([CKBoundsAnimationComponent newWithIdentifier:@0]) .flexGrow(1) .child([CKBoundsAnimationComponent newWithIdentifier:@1]) .flexGrow(1) .build(); }); const RCLayout secondLayout = [secondResult.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; NSSet *secondMountedComponents = CKMountComponentLayout(secondLayout, container, firstMountedComponents, nil); XCTAssertEqual([container.subviews count], 2u); XCTAssertTrue(((CKBoundsAnimationRecordingView *)container.subviews[0]).animatedLastBoundsChange); XCTAssertFalse(((CKBoundsAnimationRecordingView *)container.subviews[1]).animatedLastBoundsChange); CKUnmountComponents(secondMountedComponents); } - (void)test_WhenComponentBlocksImplicitAnimations_BoundsAnimationIsNotApplied { auto const f = ^CKComponent *{ return CK::ComponentBuilder() .viewClass([CKBoundsAnimationRecordingView class]) .blockImplicitAnimations(true) .build(); }; auto const bcr1 = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, f); auto const l1 = [bcr1.component layoutThatFits:{{50, 50}, {50, 50}} parentSize:{}]; auto const v = [[UIView alloc] initWithFrame:{{0, 0}, {50, 50}}]; auto const mc1 = CKMountComponentLayout(l1, v, nil, nil); auto const bcr2 = CKBuildComponent(bcr1.scopeRoot, {}, f); auto const l2 = [bcr2.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; __unused auto const _ = CKMountComponentLayout(l2, v, mc1, nil); auto const barv = CK::objCForceCast<CKBoundsAnimationRecordingView>(bcr2.component.viewContext.view); XCTAssertFalse(barv.animatedLastBoundsChange); } - (void)testBoundsAnimationIsNotAppliedWhenViewRecycledForComponentWithDistinctUniqueIdentifier { UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; const CKBuildComponentResult firstResult = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, ^{ return [CKBoundsAnimationComponent newWithIdentifier:@0]; }); const RCLayout firstLayout = [firstResult.component layoutThatFits:{{50, 50}, {50, 50}} parentSize:{}]; NSSet *firstMountedComponents = CKMountComponentLayout(firstLayout, container, nil, nil); const CKBuildComponentResult secondResult = CKBuildComponent(firstResult.scopeRoot, {}, ^{ return [CKBoundsAnimationComponent newWithIdentifier:@1]; }); const RCLayout secondLayout = [secondResult.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; NSSet *secondMountedComponents = CKMountComponentLayout(secondLayout, container, firstMountedComponents, nil); CKBoundsAnimationRecordingView *v = (CKBoundsAnimationRecordingView *)secondResult.component.viewContext.view; XCTAssertFalse(v.animatedLastBoundsChange); CKUnmountComponents(secondMountedComponents); } - (void)testBoundsAnimationIsNotAppliedToChildrenWhenViewRecycledForComponentWithDistinctUniqueIdentifier { UIView *container = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; const CKBuildComponentResult firstResult = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, ^{ CKComponentScope scope([CKFlexboxComponent class], @"foo"); return CK::FlexboxComponentBuilder() .viewClass([CKBoundsAnimationRecordingView class]) .alignItems(CKFlexboxAlignItemsStretch) .child(CK::ComponentBuilder() .viewClass([CKBoundsAnimationRecordingView class]) .build()) .flexGrow(1) .build(); }); const RCLayout firstLayout = [firstResult.component layoutThatFits:{{50, 50}, {50, 50}} parentSize:{}]; NSSet *firstMountedComponents = CKMountComponentLayout(firstLayout, container, nil, nil); const CKBuildComponentResult secondResult = CKBuildComponent(firstResult.scopeRoot, {}, ^{ // Change the scope identifier for the new version of the stack. This means that the outer view's bounds change // shouldn't be animated; crucially, the *inner* view's bounds change should *also* not be animated, even though // it is recycling the same view. // NB: We use a plain CKComponent, not a CKBoundsAnimationComponent; otherwise the scope tokens of the child will // be different, and we will avoid animating the child view for that reason instead of the changing parent scope. CKComponentScope scope([CKFlexboxComponent class], @"bar"); return CK::FlexboxComponentBuilder() .viewClass([CKBoundsAnimationRecordingView class]) .alignItems(CKFlexboxAlignItemsStretch) .child(CK::ComponentBuilder() .viewClass([CKBoundsAnimationRecordingView class]) .build()) .flexGrow(1) .build(); }); const RCLayout secondLayout = [secondResult.component layoutThatFits:{{100, 100}, {100, 100}} parentSize:{}]; NSSet *secondMountedComponents = CKMountComponentLayout(secondLayout, container, firstMountedComponents, nil); #if CK_ASSERTIONS_ENABLED CKBoundsAnimationRecordingView *v = (CKBoundsAnimationRecordingView *)secondResult.component.viewContext.view; CKBoundsAnimationRecordingView *subview = [[v subviews] firstObject]; RCAssertTrue(subview != nil && subview.animatedLastBoundsChange == NO); #endif CKUnmountComponents(secondMountedComponents); } #if CK_ASSERTIONS_ENABLED - (void)test_StoresComponentThatProducedBoundsAnimation { auto const f = ^{ return [CKBoundsAnimationComponent newWithIdentifier:@0]; }; auto const bcr1 = CKBuildComponent(CKComponentScopeRootWithDefaultPredicates(nil, nil), {}, f); auto const bcr2 = CKBuildComponent(bcr1.scopeRoot, {}, f); XCTAssertEqualObjects(bcr2.boundsAnimation.component, bcr2.component); } #endif @end @interface CKComponentBoundsAnimationTests_Equality : XCTestCase @end @implementation CKComponentBoundsAnimationTests_Equality - (void)test_WhenAllFieldsForDefaultModeAreEqual_IsEqual { auto const ba1 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .mode = CKComponentBoundsAnimationModeDefault, .options = UIViewAnimationOptionPreferredFramesPerSecond60, .timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.1 :0.9 :0.9 :0.1], }; auto const ba2 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .options = UIViewAnimationOptionPreferredFramesPerSecond60, .timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.1 :0.9 :0.9 :0.1], }; XCTAssert(ba1 == ba2); } - (void)test_WhenTimingFunctionsAreNotEqual_IsNotEqual { auto const ba1 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .mode = CKComponentBoundsAnimationModeDefault, .options = UIViewAnimationOptionPreferredFramesPerSecond60, .timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.1 :0.9 :0.9 :0.2], }; auto const ba2 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .options = UIViewAnimationOptionPreferredFramesPerSecond60, .timingFunction = [CAMediaTimingFunction functionWithControlPoints:0.1 :0.9 :0.9 :0.1], }; XCTAssert(ba1 != ba2); } - (void)test_WhenSpringParamsAreNotEqual_IsNotEqual { auto const ba1 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .mode = CKComponentBoundsAnimationModeSpring, }; auto const ba2 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .mode = CKComponentBoundsAnimationModeSpring, .springDampingRatio = 0.3, .springInitialVelocity = 10, }; XCTAssertFalse(ba1 == ba2); } - (void)test_WhenModeIsDefault_SpringProperiesAreIgnored { auto const ba1 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .mode = CKComponentBoundsAnimationModeDefault, }; auto const ba2 = CKComponentBoundsAnimation { .duration = 0.5, .delay = 0.2, .springDampingRatio = 0.3, .springInitialVelocity = 10, }; XCTAssert(ba1 == ba2); } @end