Skip to content
Open
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
8 changes: 8 additions & 0 deletions ios/RNNScreenTransition.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
#import "RNNEnterExitAnimation.h"
#import "RNNOptions.h"
#import "SharedElementTransitionOptions.h"
#import "Text.h"

@class UIViewController;

@interface RNNScreenTransition : RNNOptions

Expand All @@ -10,13 +13,18 @@
@property(nonatomic, strong) ElementTransitionOptions *bottomTabs;
@property(nonatomic, strong) NSArray<ElementTransitionOptions *> *elementTransitions;
@property(nonatomic, strong) NSArray<SharedElementTransitionOptions *> *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
52 changes: 52 additions & 0 deletions ios/RNNScreenTransition.mm
Original file line number Diff line number Diff line change
@@ -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 <UIKit/UIKit.h>

@implementation RNNScreenTransition

Expand All @@ -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;
}
Expand All @@ -38,17 +49,58 @@ - (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 {
return (self.topBar.hasAnimation || self.content.hasAnimation || self.bottomTabs.hasAnimation ||
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<RNNLayoutProtocol> *rnnSourceVC =
(UIViewController<RNNLayoutProtocol> *)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) {
Expand Down
7 changes: 7 additions & 0 deletions ios/UINavigationController+RNNCommands.mm
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#import "RNNErrorHandler.h"
#import "RNNScreenTransition.h"
#import "UINavigationController+RNNCommands.h"
#import "UIViewController+LayoutProtocol.h"
#import <React/RCTI18nUtil.h>

typedef void (^RNNAnimationBlock)(void);
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/OptionsProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
OptionsSearchBar,
OptionsTopBar,
StackAnimationOptions,
StackPushAnimationOptions,
StatusBarAnimationOptions,
TopBarAnimationOptions,
ViewAnimationOptions,
Expand Down Expand Up @@ -390,7 +391,7 @@ export class OptionsProcessor {

private processPush(
key: string,
animation: StackAnimationOptions,
animation: StackPushAnimationOptions,
parentOptions: AnimationOptions
) {
if (key !== 'push') return;
Expand Down
28 changes: 26 additions & 2 deletions src/interfaces/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down
39 changes: 39 additions & 0 deletions website/docs/api/options-animations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
57 changes: 57 additions & 0 deletions website/docs/docs/style-animations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,63 @@ options: {
</TabItem>
</Tabs>

### 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
<View nativeID={`product-thumb-${item.id}`} style={styles.thumbnail}>
<Image source={item.image} style={styles.image} />
</View>
```

#### 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).
Expand Down