Skip to content

Commit 375b7ee

Browse files
authored
feat(iOS)!: change default animation curve & duration (#2477)
## Description > [!note] > A big chunk of discussion for these changes is under initial PR by @kirillzyusko, please see: #2413 Associated PR in `react-navigation`: * react-navigation/react-navigation#12233 Recently in #2413 @kirillzyusko noticed that our iOS animations (in particular `simple_push` and `slide_from_left`) do not resemble native slide-in animation as much as we wish and as we claim in our type definitions / guide for library authors. The approach suggested by @kirillzyusko in #2413 is as follows: We add new prop (draft name) `animationInterpolation`; when specified it allows to set `Interpolation.DEFAULT` which would use default `UISpringTimingParameters` used by default by UIKit. This solution has advantage of enabling easy extension in form of exposing more timing animation curves. At the same time it comes with disadvantage: setting default system params (spring animation) disables ability to specify animation duration, effectively disabling our `transitionDuration` prop (exposed as `animationDuration` by react-navigation). I don't want that ☝🏻 I want too keep `animationDuration` working as is, therefore we need to approximate default spring timing curve as closely as possible using damping ratio (initializer with damping ratio allows for control over final transition duration). According to Matt Neuburg's "Programming iOS 14" the params for default spring are as follows: - mass = 3, - stiffness = 1000, - damping = 500 We can compute damping ratio as: damping / (2 * sqrt(stiffness * mass)) => giving us approximately 4,56 (overdamping) <- and this is what we'll use now. > [!important] > Important side-effect of the refactor is the fact that `animationDuration` now impacts duration of the completion animation **after the gesture has been cancelled** during interactive dismissal. I've decided on keeping this behaviour, but it has both pros and cons. Any feedback on this would be welcome. See video below (animation duration set to 2000ms). https://github.com/user-attachments/assets/a13b2e5d-7b90-4597-a33a-956f2f393cd9 ## Changes The default animation time change applies to all animations. Maybe we should consider applying it only to animations for which we use new spring'y timing curves. The animation curve change applies to `simple_push`, `slide_from_left`, `slide_from_right`. The rest of animations kept EaseInOut curve. ## Test code and steps to reproduce I've played around on test `Test1072`. ## Before / After |Animation|Before|After| |----------|------------|-------| |`simple_push`|<video width="454" alt="image" src="">|<video width="452" alt="image" src="https://github.com/user-attachments/assets/4fb45c2f-d77b-4737-b5ee-8b406b90c15f">| |`fade`|<video width="454" alt="image" src="">|<video width="454" alt="image" src="https://github.com/user-attachments/assets/59114dd5-bc45-4933-ab02-869b35e1725c">| |`slide_from_bottom`|<video width="454" alt="image" src="">|<video width="454" alt="image" src="https://github.com/user-attachments/assets/4580fe9f-112d-4ead-8377-68c1caaf6d46">| ## Improvement possibilities > [!note] > 1. fade_from_bottom works ugly - it looks like the screen underneath disappears immediately - we should look into it > 2. add possibility of describing custom transition curves (new API idea), or at least expose some presets > 3. add prop to control "completion transition duraction" ## Checklist - [x] Included code example that can be used to test this change - [ ] Ensured that CI passes
1 parent 960873a commit 375b7ee

File tree

12 files changed

+343
-142
lines changed

12 files changed

+343
-142
lines changed

android/src/paper/java/com/facebook/react/viewmanagers/RNSScreenManagerDelegate.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void setProperty(T view, String propName, @Nullable Object value) {
9191
mViewManager.setStackAnimation(view, (String) value);
9292
break;
9393
case "transitionDuration":
94-
mViewManager.setTransitionDuration(view, value == null ? 350 : ((Double) value).intValue());
94+
mViewManager.setTransitionDuration(view, value == null ? 500 : ((Double) value).intValue());
9595
break;
9696
case "replaceAnimation":
9797
mViewManager.setReplaceAnimation(view, (String) value);

guides/GUIDE_FOR_LIBRARY_AUTHORS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ When using `vertical` option, options `fullScreenSwipeEnabled: true`, `customAni
297297

298298
### `transitionDuration` (iOS only)
299299

300-
Changes the duration (in milliseconds) of `slide_from_bottom`, `fade_from_bottom`, `fade` and `simple_push` transitions on iOS. Defaults to `350`.
300+
Changes the duration (in milliseconds) of `slide_from_bottom`, `fade_from_bottom`, `fade` and `simple_push` transitions on iOS. Defaults to `500`.
301301

302302
The duration of `default` and `flip` transitions isn't customizable.
303303

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#import <UIKit/UIKit.h>
2+
#import "RNSScreenStackAnimator.h"
3+
4+
NS_ASSUME_NONNULL_BEGIN
5+
6+
@interface RNSPercentDrivenInteractiveTransition : UIPercentDrivenInteractiveTransition
7+
8+
@property (nonatomic, nullable) RNSScreenStackAnimator *animationController;
9+
10+
@end
11+
12+
NS_ASSUME_NONNULL_END
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#import "RNSPercentDrivenInteractiveTransition.h"
2+
3+
@implementation RNSPercentDrivenInteractiveTransition {
4+
RNSScreenStackAnimator *_animationController;
5+
}
6+
7+
#pragma mark - UIViewControllerInteractiveTransitioning
8+
9+
- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext
10+
{
11+
[super startInteractiveTransition:transitionContext];
12+
}
13+
14+
#pragma mark - UIPercentDrivenInteractiveTransition
15+
16+
// `updateInteractiveTransition`, `finishInteractiveTransition`,
17+
// `cancelInteractiveTransition` are forwared by superclass to
18+
// corresponding methods in transition context. In case
19+
// of "classical CA driven animations", such as UIView animation blocks
20+
// or direct utilization of CoreAnimation API, context drives the animation,
21+
// however in case of UIViewPropertyAnimator it does not. We need
22+
// to drive animation manually and this is exactly what happens below.
23+
24+
- (void)updateInteractiveTransition:(CGFloat)percentComplete
25+
{
26+
if (_animationController != nil) {
27+
[_animationController.inFlightAnimator setFractionComplete:percentComplete];
28+
}
29+
[super updateInteractiveTransition:percentComplete];
30+
}
31+
32+
- (void)finishInteractiveTransition
33+
{
34+
[self finalizeInteractiveTransitionWithAnimationWasCancelled:NO];
35+
[super finishInteractiveTransition];
36+
}
37+
38+
- (void)cancelInteractiveTransition
39+
{
40+
[self finalizeInteractiveTransitionWithAnimationWasCancelled:YES];
41+
[super cancelInteractiveTransition];
42+
}
43+
44+
#pragma mark - Helpers
45+
46+
- (void)finalizeInteractiveTransitionWithAnimationWasCancelled:(BOOL)cancelled
47+
{
48+
if (_animationController == nil) {
49+
return;
50+
}
51+
52+
UIViewPropertyAnimator *_Nullable animator = _animationController.inFlightAnimator;
53+
if (animator == nil) {
54+
return;
55+
}
56+
57+
BOOL shouldReverseAnimation = cancelled;
58+
59+
id<UITimingCurveProvider> timingParams = [_animationController timingParamsForAnimationCompletion];
60+
61+
[animator pauseAnimation];
62+
[animator setReversed:shouldReverseAnimation];
63+
[animator continueAnimationWithTimingParameters:timingParams durationFactor:(1 - animator.fractionComplete)];
64+
65+
// System retains it & we don't need it anymore.
66+
_animationController = nil;
67+
}
68+
69+
@end

ios/RNSScreenStack.mm

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#import "RCTTouchHandler+RNSUtility.h"
2121
#endif // RCT_NEW_ARCH_ENABLED
2222

23+
#import "RNSPercentDrivenInteractiveTransition.h"
2324
#import "RNSScreen.h"
2425
#import "RNSScreenStack.h"
2526
#import "RNSScreenStackAnimator.h"
@@ -149,7 +150,7 @@ @implementation RNSScreenStackView {
149150
NSMutableArray<RNSScreenView *> *_reactSubviews;
150151
BOOL _invalidated;
151152
BOOL _isFullWidthSwiping;
152-
UIPercentDrivenInteractiveTransition *_interactionController;
153+
RNSPercentDrivenInteractiveTransition *_interactionController;
153154
__weak RNSScreenStackManager *_manager;
154155
BOOL _updateScheduled;
155156
#ifdef RCT_NEW_ARCH_ENABLED
@@ -869,7 +870,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
869870

870871
switch (gestureRecognizer.state) {
871872
case UIGestureRecognizerStateBegan: {
872-
_interactionController = [UIPercentDrivenInteractiveTransition new];
873+
_interactionController = [RNSPercentDrivenInteractiveTransition new];
873874
[_controller popViewControllerAnimated:YES];
874875
break;
875876
}
@@ -916,7 +917,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
916917
if (_interactionController == nil && fromView.reactSuperview) {
917918
BOOL shouldCancelDismiss = [self shouldCancelDismissFromView:fromView toView:toView];
918919
if (shouldCancelDismiss) {
919-
_interactionController = [UIPercentDrivenInteractiveTransition new];
920+
_interactionController = [RNSPercentDrivenInteractiveTransition new];
920921
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
921922
[self->_interactionController cancelInteractiveTransition];
922923
self->_interactionController = nil;
@@ -929,6 +930,10 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
929930
});
930931
}
931932
}
933+
934+
if (_interactionController != nil) {
935+
[_interactionController setAnimationController:animationController];
936+
}
932937
return _interactionController;
933938
}
934939

@@ -1111,7 +1116,7 @@ - (void)startScreenTransition
11111116
{
11121117
if (_interactionController == nil) {
11131118
_customAnimation = YES;
1114-
_interactionController = [UIPercentDrivenInteractiveTransition new];
1119+
_interactionController = [RNSPercentDrivenInteractiveTransition new];
11151120
[_controller popViewControllerAnimated:YES];
11161121
}
11171122
}

ios/RNSScreenStackAnimator.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
@interface RNSScreenStackAnimator : NSObject <UIViewControllerAnimatedTransitioning>
44

5-
- (instancetype)initWithOperation:(UINavigationControllerOperation)operation;
5+
/// This property is filled whenever there is an ongoing animation and cleared on animation end.
6+
@property (nonatomic, strong, nullable, readonly) UIViewPropertyAnimator *inFlightAnimator;
7+
8+
- (nonnull instancetype)initWithOperation:(UINavigationControllerOperation)operation;
9+
10+
/// In case of interactive / interruptible transition (e.g. swipe back gesture) this method should return
11+
/// timing parameters expected by animator to be used for animation completion (e.g. when user's
12+
/// gesture had ended).
13+
///
14+
/// @return timing curve provider expected to be used for animation completion or nil,
15+
/// when there is no interactive transition running.
16+
- (nullable id<UITimingCurveProvider>)timingParamsForAnimationCompletion;
17+
618
+ (BOOL)isCustomAnimation:(RNSScreenStackAnimation)animation;
719

820
@end

0 commit comments

Comments
 (0)