diff --git a/ios/RNNScreenTransition.h b/ios/RNNScreenTransition.h index cbe8edcd05..e1d80d3a08 100644 --- a/ios/RNNScreenTransition.h +++ b/ios/RNNScreenTransition.h @@ -2,6 +2,9 @@ #import "RNNEnterExitAnimation.h" #import "RNNOptions.h" #import "SharedElementTransitionOptions.h" +#import "Text.h" + +@class UIViewController; @interface RNNScreenTransition : RNNOptions @@ -10,13 +13,18 @@ @property(nonatomic, strong) ElementTransitionOptions *bottomTabs; @property(nonatomic, strong) NSArray *elementTransitions; @property(nonatomic, strong) NSArray *sharedElementTransitions; +@property(nonatomic, strong) Text *zoomFromId; +@property(nonatomic, strong) Bool *zoomEnabled; @property(nonatomic, strong) Bool *enable; @property(nonatomic, strong) Bool *waitForRender; @property(nonatomic, strong) TimeInterval *duration; - (BOOL)hasCustomAnimation; +- (BOOL)hasZoomTransition; - (BOOL)shouldWaitForRender; - (NSTimeInterval)maxDuration; +- (void)applyZoomToViewController:(UIViewController *)destination + fromSourceViewController:(UIViewController *)source; @end diff --git a/ios/RNNScreenTransition.mm b/ios/RNNScreenTransition.mm index 6a5dbb13bf..637f8f0c87 100644 --- a/ios/RNNScreenTransition.mm +++ b/ios/RNNScreenTransition.mm @@ -1,6 +1,12 @@ #import "RNNScreenTransition.h" +#import "BoolParser.h" #import "OptionsArrayParser.h" +#import "RNNElementFinder.h" +#import "RNNLayoutProtocol.h" #import "RNNUtils.h" +#import "TextParser.h" +#import "UIViewController+LayoutProtocol.h" +#import @implementation RNNScreenTransition @@ -19,6 +25,11 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self.elementTransitions = [OptionsArrayParser parse:dict key:@"elementTransitions" ofClass:ElementTransitionOptions.class]; + NSDictionary *zoom = dict[@"zoom"]; + if ([zoom isKindOfClass:[NSDictionary class]]) { + self.zoomFromId = [TextParser parse:zoom key:@"fromId"]; + self.zoomEnabled = [BoolParser parse:zoom key:@"enabled"]; + } return self; } @@ -38,6 +49,10 @@ - (void)mergeOptions:(RNNScreenTransition *)options { self.sharedElementTransitions = options.sharedElementTransitions; if (options.elementTransitions) self.elementTransitions = options.elementTransitions; + if (options.zoomFromId.hasValue) + self.zoomFromId = options.zoomFromId; + if (options.zoomEnabled.hasValue) + self.zoomEnabled = options.zoomEnabled; } - (BOOL)hasCustomAnimation { @@ -45,10 +60,47 @@ - (BOOL)hasCustomAnimation { self.sharedElementTransitions || self.elementTransitions); } +- (BOOL)hasZoomTransition { + if (self.hasCustomAnimation) { + return NO; + } + + NSString *fromId = [self.zoomFromId withDefault:@""]; + return [self.zoomEnabled withDefault:YES] && fromId.length > 0; +} + - (BOOL)shouldWaitForRender { return [self.waitForRender withDefault: [RNNUtils getDefaultWaitForRender]] || self.hasCustomAnimation; } +- (void)applyZoomToViewController:(UIViewController *)destination + fromSourceViewController:(UIViewController *)source { + if (![self hasZoomTransition]) { + return; + } + + if (@available(iOS 18.0, *)) { + NSString *fromId = [[self.zoomFromId withDefault:@""] copy]; + destination.preferredTransition = [UIViewControllerTransition + zoomWithOptions:nil + sourceViewProvider:^UIView *(UIZoomTransitionSourceViewProviderContext *context) { + UIViewController *sourceVC = context.sourceViewController ?: source; + if (![sourceVC conformsToProtocol:@protocol(RNNLayoutProtocol)]) { + return nil; + } + + UIViewController *rnnSourceVC = + (UIViewController *)sourceVC; + UIView *reactView = rnnSourceVC.presentedComponentViewController.reactView; + if (reactView == nil) { + return nil; + } + + return [RNNElementFinder findElementForId:fromId inView:reactView]; + }]; + } +} + - (NSTimeInterval)maxDuration { NSTimeInterval maxDuration = 0; if ([self.topBar maxDuration] > maxDuration) { diff --git a/ios/UINavigationController+RNNCommands.mm b/ios/UINavigationController+RNNCommands.mm index 2c5b1c470e..7ac929625a 100644 --- a/ios/UINavigationController+RNNCommands.mm +++ b/ios/UINavigationController+RNNCommands.mm @@ -1,5 +1,7 @@ #import "RNNErrorHandler.h" +#import "RNNScreenTransition.h" #import "UINavigationController+RNNCommands.h" +#import "UIViewController+LayoutProtocol.h" #import typedef void (^RNNAnimationBlock)(void); @@ -19,6 +21,11 @@ - (void)push:(UIViewController *)newTop self.navigationBar.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; } + RNNScreenTransition *pushTransition = newTop.resolveOptionsWithDefault.animations.push; + if (animated && [pushTransition hasZoomTransition]) { + [pushTransition applyZoomToViewController:newTop fromSourceViewController:onTopViewController]; + } + [self performBlock:^{ NSLog(@"About to push a controller %@", newTop); diff --git a/playground/ios/NavigationTests/RNNScreenTransitionTest.mm b/playground/ios/NavigationTests/RNNScreenTransitionTest.mm new file mode 100644 index 0000000000..605e3e2965 --- /dev/null +++ b/playground/ios/NavigationTests/RNNScreenTransitionTest.mm @@ -0,0 +1,89 @@ +#import "RNNScreenTransition.h" +#import "RNNUtils.h" +#import + +@interface RNNScreenTransitionTest : XCTestCase + +@end + +@implementation RNNScreenTransitionTest + +- (void)testParsesZoomFromId { + RNNScreenTransition *transition = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-1"}}]; + + XCTAssertEqualObjects(transition.zoomFromId.get, @"thumb-1"); + XCTAssertTrue(transition.hasZoomTransition); +} + +- (void)testZoomEnabledDefaultsToTrue { + RNNScreenTransition *transition = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-1"}}]; + + XCTAssertTrue(transition.hasZoomTransition); +} + +- (void)testZoomEnabledFalseDisablesZoomTransition { + RNNScreenTransition *transition = [[RNNScreenTransition alloc] + initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-1", @"enabled" : @NO}}]; + + XCTAssertFalse(transition.hasZoomTransition); +} + +- (void)testZoomTransitionRequiresNonEmptyFromId { + RNNScreenTransition *missingFromId = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{}}]; + RNNScreenTransition *emptyFromId = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @""}}]; + + XCTAssertFalse(missingFromId.hasZoomTransition); + XCTAssertFalse(emptyFromId.hasZoomTransition); +} + +- (void)testCustomContentAnimationTakesPrecedenceOverZoomTransition { + RNNScreenTransition *transition = [[RNNScreenTransition alloc] initWithDict:@{ + @"zoom" : @{@"fromId" : @"thumb-1"}, + @"content" : @{@"enter" : @{@"alpha" : @{@"from" : @0, @"to" : @1}}} + }]; + + XCTAssertTrue(transition.hasCustomAnimation); + XCTAssertFalse(transition.hasZoomTransition); +} + +- (void)testSharedElementTransitionTakesPrecedenceOverZoomTransition { + RNNScreenTransition *transition = [[RNNScreenTransition alloc] initWithDict:@{ + @"zoom" : @{@"fromId" : @"thumb-1"}, + @"sharedElementTransitions" : @[ @{@"fromId" : @"image-1", @"toId" : @"image-2"} ] + }]; + + XCTAssertTrue(transition.hasCustomAnimation); + XCTAssertFalse(transition.hasZoomTransition); +} + +- (void)testMergeOptionsUpdatesZoomFromIdAndEnabled { + RNNScreenTransition *transition = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-1"}}]; + RNNScreenTransition *updatedFromId = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-2"}}]; + RNNScreenTransition *disabled = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"enabled" : @NO}}]; + + [transition mergeOptions:updatedFromId]; + XCTAssertEqualObjects(transition.zoomFromId.get, @"thumb-2"); + XCTAssertTrue(transition.hasZoomTransition); + + [transition mergeOptions:disabled]; + XCTAssertFalse(transition.hasZoomTransition); +} + +- (void)testShouldWaitForRenderUsesDefaultForZoomOnlyAndCustomAnimationsWait { + RNNScreenTransition *zoomOnly = + [[RNNScreenTransition alloc] initWithDict:@{@"zoom" : @{@"fromId" : @"thumb-1"}}]; + RNNScreenTransition *customAnimation = [[RNNScreenTransition alloc] + initWithDict:@{@"content" : @{@"enter" : @{@"alpha" : @{@"from" : @0, @"to" : @1}}}}]; + + XCTAssertEqual(zoomOnly.shouldWaitForRender, [RNNUtils getDefaultWaitForRender]); + XCTAssertTrue(customAnimation.shouldWaitForRender); +} + +@end diff --git a/playground/ios/playground.xcodeproj/project.pbxproj b/playground/ios/playground.xcodeproj/project.pbxproj index 4e76a4d4b3..3513c25212 100644 --- a/playground/ios/playground.xcodeproj/project.pbxproj +++ b/playground/ios/playground.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 50C9A8D4240FB9D000BD699F /* RNNComponentViewController+Utils.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50C9A8D3240FB9D000BD699F /* RNNComponentViewController+Utils.mm */; }; 50CF233D240695B10098042D /* RNNBottomTabsController+Helpers.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50CF233C240695B10098042D /* RNNBottomTabsController+Helpers.mm */; }; 50FDEFBC258F5C5D008C9C3C /* RNNSearchBarOptionsTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = 50FDEFBB258F5C5D008C9C3C /* RNNSearchBarOptionsTest.mm */; }; + 57C883092EB720D100830800 /* RNNScreenTransitionTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = 57C883082EB720D100830800 /* RNNScreenTransitionTest.mm */; }; 6B102251DCC578519C2DC6A4 /* libPods-NavigationIOS12Tests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C10F72071A488F801E1F1116 /* libPods-NavigationIOS12Tests.a */; }; 8EB60A6CB93C527CC6A870A2 /* libPods-SnapshotTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E8B4CFA18A5ACE953124E129 /* libPods-SnapshotTests.a */; }; 9F9A3A9625260DA900AAAF37 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9F9A3A9525260DA900AAAF37 /* LaunchScreen.storyboard */; }; @@ -166,6 +167,7 @@ 50CF233C240695B10098042D /* RNNBottomTabsController+Helpers.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNNBottomTabsController+Helpers.mm"; sourceTree = ""; }; 50E4888A2427DA4800B11A8E /* StackOptionsTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = StackOptionsTest.mm; sourceTree = ""; }; 50FDEFBB258F5C5D008C9C3C /* RNNSearchBarOptionsTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNSearchBarOptionsTest.mm; sourceTree = ""; }; + 57C883082EB720D100830800 /* RNNScreenTransitionTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNNScreenTransitionTest.mm; sourceTree = ""; }; 60BCFCC02B7F812F00FCDB38 /* libLLVM.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libLLVM.dylib; path = usr/lib/libLLVM.dylib; sourceTree = SDKROOT; }; 60BCFCCA2B7F817400FCDB38 /* libReactNativeNavigation.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libReactNativeNavigation.a; sourceTree = BUILT_PRODUCTS_DIR; }; 7F8E255E2E08F6ECE7DF6FE3 /* Pods-playground.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-playground.release.xcconfig"; path = "Target Support Files/Pods-playground/Pods-playground.release.xcconfig"; sourceTree = ""; }; @@ -429,6 +431,7 @@ 5022EDCB240551EE00852BA6 /* RNNBottomTabsAppearancePresenterTest.mm */, E58D26342385888B003F36BA /* RNNTestRootViewCreator.h */, E58D263D2385888C003F36BA /* RNNTestRootViewCreator.mm */, + 57C883082EB720D100830800 /* RNNScreenTransitionTest.mm */, E58D26382385888B003F36BA /* RNNTransitionStateHolderTest.mm */, E58D263B2385888C003F36BA /* UITabBarController+RNNOptionsTest.mm */, E58D26252385888B003F36BA /* UIViewController+LayoutProtocolTest.mm */, @@ -979,6 +982,7 @@ E58D26582385888C003F36BA /* UITabBarController+RNNOptionsTest.mm in Sources */, E58D265A2385888C003F36BA /* RNNTestRootViewCreator.mm in Sources */, E58D26492385888C003F36BA /* RNNFontAttributesCreatorTest.mm in Sources */, + 57C883092EB720D100830800 /* RNNScreenTransitionTest.mm in Sources */, E58D26552385888C003F36BA /* RNNTransitionStateHolderTest.mm in Sources */, E58D26462385888C003F36BA /* UIViewController+LayoutProtocolTest.mm in Sources */, E58D26512385888C003F36BA /* RNNExternalComponentStoreTest.mm in Sources */, diff --git a/src/commands/OptionsProcessor.test.ts b/src/commands/OptionsProcessor.test.ts index 754256d66c..c52375efb4 100644 --- a/src/commands/OptionsProcessor.test.ts +++ b/src/commands/OptionsProcessor.test.ts @@ -890,6 +890,9 @@ describe('navigation options', () => { waitForRender: true, sharedElementTransitions: [], elementTransitions: [], + zoom: { + fromId: 'thumb-1', + }, }, }, }; @@ -899,9 +902,116 @@ describe('navigation options', () => { waitForRender: true, sharedElementTransitions: [], elementTransitions: [], + zoom: { + fromId: 'thumb-1', + }, }); }); }); + + it('preserves push zoom options', () => { + const options: Options = { + animations: { + push: { + zoom: { + fromId: 'thumb-1', + }, + }, + }, + }; + + uut.processOptions(CommandName.SetRoot, options); + + expect(options.animations!!.push).toStrictEqual({ + zoom: { + fromId: 'thumb-1', + }, + }); + }); + + it('preserves disabled push zoom options', () => { + const options: Options = { + animations: { + push: { + zoom: { + fromId: 'thumb-1', + enabled: false, + }, + }, + }, + }; + + uut.processOptions(CommandName.SetRoot, options); + + expect(options.animations!!.push).toStrictEqual({ + zoom: { + fromId: 'thumb-1', + enabled: false, + }, + }); + }); + + it('does not treat zoom as a legacy push animation shape', () => { + const options: Options = { + animations: { + push: { + zoom: { + fromId: 'thumb-1', + }, + content: { + alpha: { + from: 0, + to: 1, + }, + }, + topBar: { + alpha: { + from: 0, + to: 1, + }, + }, + bottomTabs: { + alpha: { + from: 0, + to: 1, + }, + }, + }, + }, + }; + + uut.processOptions(CommandName.SetRoot, options); + + expect(options.animations!!.push).toStrictEqual({ + zoom: { + fromId: 'thumb-1', + }, + content: { + enter: { + alpha: { + from: 0, + to: 1, + }, + }, + }, + topBar: { + enter: { + alpha: { + from: 0, + to: 1, + }, + }, + }, + bottomTabs: { + enter: { + alpha: { + from: 0, + to: 1, + }, + }, + }, + }); + }); }); describe('pop', () => { diff --git a/src/commands/OptionsProcessor.ts b/src/commands/OptionsProcessor.ts index 1fc0316c8d..eb00f3872e 100644 --- a/src/commands/OptionsProcessor.ts +++ b/src/commands/OptionsProcessor.ts @@ -20,6 +20,7 @@ import { OptionsSearchBar, OptionsTopBar, StackAnimationOptions, + StackPushAnimationOptions, StatusBarAnimationOptions, TopBarAnimationOptions, ViewAnimationOptions, @@ -390,7 +391,7 @@ export class OptionsProcessor { private processPush( key: string, - animation: StackAnimationOptions, + animation: StackPushAnimationOptions, parentOptions: AnimationOptions ) { if (key !== 'push') return; diff --git a/src/interfaces/Options.ts b/src/interfaces/Options.ts index 59102b855a..51f19f4eb6 100644 --- a/src/interfaces/Options.ts +++ b/src/interfaces/Options.ts @@ -829,6 +829,18 @@ export interface SharedElementTransition { interpolation?: Interpolation; } +export interface ZoomTransitionOptions { + /** + * `nativeID` of the view to zoom from when pushing, and zoom back to when popping. + * #### (iOS 18+ specific) + */ + fromId: string; + /** + * @default true + */ + enabled?: boolean; +} + export interface ElementTransition { id: string; alpha?: AppearingElementAnimation | DisappearingElementAnimation; @@ -1624,6 +1636,19 @@ export interface StackAnimationOptions { elementTransitions?: ElementTransition[]; } +/** + * Stack push animations. Extends {@link StackAnimationOptions} with iOS 18+ zoom support. + */ +export interface StackPushAnimationOptions extends StackAnimationOptions { + /** + * UIKit fluid zoom from a source view (`nativeID` must match `fromId`). + * Only used for `animations.push` — ignored on `pop`, `setStackRoot`, and Android. + * Mutually exclusive with `content` / `sharedElementTransitions` on the same push. + * #### (iOS 18+ specific) + */ + zoom?: ZoomTransitionOptions; +} + /** * Used for configuring command animations */ @@ -1638,9 +1663,8 @@ export interface AnimationOptions { setRoot?: ViewAnimationOptions | EnterExitAnimationOptions; /** * Configure the animation of the pushed screen - * #### (Android specific) */ - push?: StackAnimationOptions; + push?: StackPushAnimationOptions; /** * Configure what animates when a screen is popped */ diff --git a/website/docs/api/options-animations.mdx b/website/docs/api/options-animations.mdx index 5b0b414d94..02f3968b2d 100644 --- a/website/docs/api/options-animations.mdx +++ b/website/docs/api/options-animations.mdx @@ -3,3 +3,42 @@ id: options-animations title: Animations sidebar_label: Animations --- + +Animation options are declared on layout `options.animations`. See the [Animations guide](../docs/style-animations) for examples (stack, modal, shared elements, zoom). + +## Stack `push` / `pop` + +Stack command animations support `content`, `topBar`, `bottomTabs`, `sharedElementTransitions`, `elementTransitions`, and on iOS 18+ [`zoom`](#zoom-ios-18). + +## Zoom (iOS 18+) + +Declared under **`animations.push.zoom` only** when pushing onto a stack. Ignored on `animations.pop`, `animations.setStackRoot`, and Android. + +```js +animations: { + push: { + zoom: { + fromId: 'my-thumb', + enabled: true, // optional, default true + }, + }, +} +``` + +| Property | Type | Required | Platform | Description | +| -------- | ---- | -------- | -------- | ----------- | +| `fromId` | `string` | Yes | iOS 18+ | Matches `nativeID` on the source view in the screen being pushed from. | +| `enabled` | `boolean` | No | iOS 18+ | Default `true`. | + +See [Zoom transition (iOS 18+)](../docs/style-animations#zoom-transition-ios-18) for usage and behavior. + +## Shared element transitions + +Array under `animations.push.sharedElementTransitions` / `animations.pop.sharedElementTransitions`. Each item: + +| Property | Type | Description | +| -------- | ---- | ----------- | +| `fromId` | `string` | `nativeID` on the source screen | +| `toId` | `string` | `nativeID` on the destination screen | +| `duration` | `number` | Duration in ms | +| `interpolation` | `object` | Easing — see [Animations guide](../docs/style-animations#step-3---declare-the-shared-element-animation-when-pushing-the-screen) | diff --git a/website/docs/docs/style-animations.mdx b/website/docs/docs/style-animations.mdx index 4e6b79d137..0ae20ed839 100644 --- a/website/docs/docs/style-animations.mdx +++ b/website/docs/docs/style-animations.mdx @@ -113,6 +113,63 @@ options: { +### Zoom transition (iOS 18+) + +Use UIKit's system **fluid zoom transition** when pushing onto a stack: the tapped view morphs into the next screen. The transition is interactive — users can drag to slow, reverse, or dismiss. + +:::info Platform support +Configure under **`animations.push` only** (not `setStackRoot` or `pop`). Available on **iOS 18 and later**. Android ignores this option. Reverse zoom on pop is automatic when the detail screen is popped. +::: + +This uses `UIViewController.preferredTransition` under the hood. It is separate from [shared element transitions](#shared-element-transitions): you do not declare `sharedElementTransitions` or `content` animations for zoom. If those custom animations are set on the same push, they take precedence and zoom is not applied. + +#### Step 1 — Set `nativeID` on the source view + +Mark the view that should expand (thumbnail, card, hero image, etc.): + +```jsx + + + +``` + +#### Step 2 — Pass the same id in push options + +```jsx +const fromId = `product-thumb-${item.id}`; + +Navigation.push(componentId, { + component: { + name: 'ProductDetail', + passProps: { item }, + options: { + animations: { + push: { + zoom: { + fromId, + }, + }, + }, + }, + }, +}); +``` + +On pop, UIKit runs the reverse zoom automatically using the same `fromId` and `nativeID`. You do not need a separate `animations.pop` block for zoom. + +#### Options + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `fromId` | `string` | Yes | Must match the `nativeID` of the source view on the screen below. | +| `enabled` | `boolean` | No | Default `true`. Set `false` to skip zoom while keeping the option object. | + +#### Notes + +- The source view must be mounted and visible when the push runs. If RNN cannot resolve `fromId`, the push falls back to the default slide animation. +- `fromId` is resolved with the same mechanism as shared element `fromId` / `toId` (search in the source screen's React view hierarchy). +- Zoom is intended for list → detail flows. For fully custom cross-screen animations, use [shared element transitions](#shared-element-transitions). + ### Modal animations Modal animations are declared similarly to stack animations, only this time we animate the entire view and not only part of the UI (content).