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/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).