Skip to content

Commit

Permalink
feat(iOS)!: change default animation curve & duration (#2477)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
kkafar authored Nov 6, 2024
1 parent 960873a commit 375b7ee
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 142 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public void setProperty(T view, String propName, @Nullable Object value) {
mViewManager.setStackAnimation(view, (String) value);
break;
case "transitionDuration":
mViewManager.setTransitionDuration(view, value == null ? 350 : ((Double) value).intValue());
mViewManager.setTransitionDuration(view, value == null ? 500 : ((Double) value).intValue());
break;
case "replaceAnimation":
mViewManager.setReplaceAnimation(view, (String) value);
Expand Down
2 changes: 1 addition & 1 deletion guides/GUIDE_FOR_LIBRARY_AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ When using `vertical` option, options `fullScreenSwipeEnabled: true`, `customAni

### `transitionDuration` (iOS only)

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

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

Expand Down
12 changes: 12 additions & 0 deletions ios/RNSPercentDrivenInteractiveTransition.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#import <UIKit/UIKit.h>
#import "RNSScreenStackAnimator.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSPercentDrivenInteractiveTransition : UIPercentDrivenInteractiveTransition

@property (nonatomic, nullable) RNSScreenStackAnimator *animationController;

@end

NS_ASSUME_NONNULL_END
69 changes: 69 additions & 0 deletions ios/RNSPercentDrivenInteractiveTransition.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#import "RNSPercentDrivenInteractiveTransition.h"

@implementation RNSPercentDrivenInteractiveTransition {
RNSScreenStackAnimator *_animationController;
}

#pragma mark - UIViewControllerInteractiveTransitioning

- (void)startInteractiveTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
[super startInteractiveTransition:transitionContext];
}

#pragma mark - UIPercentDrivenInteractiveTransition

// `updateInteractiveTransition`, `finishInteractiveTransition`,
// `cancelInteractiveTransition` are forwared by superclass to
// corresponding methods in transition context. In case
// of "classical CA driven animations", such as UIView animation blocks
// or direct utilization of CoreAnimation API, context drives the animation,
// however in case of UIViewPropertyAnimator it does not. We need
// to drive animation manually and this is exactly what happens below.

- (void)updateInteractiveTransition:(CGFloat)percentComplete
{
if (_animationController != nil) {
[_animationController.inFlightAnimator setFractionComplete:percentComplete];
}
[super updateInteractiveTransition:percentComplete];
}

- (void)finishInteractiveTransition
{
[self finalizeInteractiveTransitionWithAnimationWasCancelled:NO];
[super finishInteractiveTransition];
}

- (void)cancelInteractiveTransition
{
[self finalizeInteractiveTransitionWithAnimationWasCancelled:YES];
[super cancelInteractiveTransition];
}

#pragma mark - Helpers

- (void)finalizeInteractiveTransitionWithAnimationWasCancelled:(BOOL)cancelled
{
if (_animationController == nil) {
return;
}

UIViewPropertyAnimator *_Nullable animator = _animationController.inFlightAnimator;
if (animator == nil) {
return;
}

BOOL shouldReverseAnimation = cancelled;

id<UITimingCurveProvider> timingParams = [_animationController timingParamsForAnimationCompletion];

[animator pauseAnimation];
[animator setReversed:shouldReverseAnimation];
[animator continueAnimationWithTimingParameters:timingParams durationFactor:(1 - animator.fractionComplete)];

// System retains it & we don't need it anymore.
_animationController = nil;
}

@end
13 changes: 9 additions & 4 deletions ios/RNSScreenStack.mm
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#import "RCTTouchHandler+RNSUtility.h"
#endif // RCT_NEW_ARCH_ENABLED

#import "RNSPercentDrivenInteractiveTransition.h"
#import "RNSScreen.h"
#import "RNSScreenStack.h"
#import "RNSScreenStackAnimator.h"
Expand Down Expand Up @@ -149,7 +150,7 @@ @implementation RNSScreenStackView {
NSMutableArray<RNSScreenView *> *_reactSubviews;
BOOL _invalidated;
BOOL _isFullWidthSwiping;
UIPercentDrivenInteractiveTransition *_interactionController;
RNSPercentDrivenInteractiveTransition *_interactionController;
__weak RNSScreenStackManager *_manager;
BOOL _updateScheduled;
#ifdef RCT_NEW_ARCH_ENABLED
Expand Down Expand Up @@ -869,7 +870,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer

switch (gestureRecognizer.state) {
case UIGestureRecognizerStateBegan: {
_interactionController = [UIPercentDrivenInteractiveTransition new];
_interactionController = [RNSPercentDrivenInteractiveTransition new];
[_controller popViewControllerAnimated:YES];
break;
}
Expand Down Expand Up @@ -916,7 +917,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
if (_interactionController == nil && fromView.reactSuperview) {
BOOL shouldCancelDismiss = [self shouldCancelDismissFromView:fromView toView:toView];
if (shouldCancelDismiss) {
_interactionController = [UIPercentDrivenInteractiveTransition new];
_interactionController = [RNSPercentDrivenInteractiveTransition new];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self->_interactionController cancelInteractiveTransition];
self->_interactionController = nil;
Expand All @@ -929,6 +930,10 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
});
}
}

if (_interactionController != nil) {
[_interactionController setAnimationController:animationController];
}
return _interactionController;
}

Expand Down Expand Up @@ -1111,7 +1116,7 @@ - (void)startScreenTransition
{
if (_interactionController == nil) {
_customAnimation = YES;
_interactionController = [UIPercentDrivenInteractiveTransition new];
_interactionController = [RNSPercentDrivenInteractiveTransition new];
[_controller popViewControllerAnimated:YES];
}
}
Expand Down
14 changes: 13 additions & 1 deletion ios/RNSScreenStackAnimator.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@

@interface RNSScreenStackAnimator : NSObject <UIViewControllerAnimatedTransitioning>

- (instancetype)initWithOperation:(UINavigationControllerOperation)operation;
/// This property is filled whenever there is an ongoing animation and cleared on animation end.
@property (nonatomic, strong, nullable, readonly) UIViewPropertyAnimator *inFlightAnimator;

- (nonnull instancetype)initWithOperation:(UINavigationControllerOperation)operation;

/// In case of interactive / interruptible transition (e.g. swipe back gesture) this method should return
/// timing parameters expected by animator to be used for animation completion (e.g. when user's
/// gesture had ended).
///
/// @return timing curve provider expected to be used for animation completion or nil,
/// when there is no interactive transition running.
- (nullable id<UITimingCurveProvider>)timingParamsForAnimationCompletion;

+ (BOOL)isCustomAnimation:(RNSScreenStackAnimation)animation;

@end
Loading

0 comments on commit 375b7ee

Please sign in to comment.