Skip to content

Commit

Permalink
feat: Spring animation, over scroll and Android bug fix (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcoulter45 authored Aug 24, 2020
1 parent 9b5ca93 commit c6c0a3e
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 31 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,15 @@ This is the list of exclusive props that are meant to be used to customise the b
| `snapPoints` | yes | `Array<string \| number>` | Array of numbers and/or percentages that indicate the different resting positions of the bottom sheet (in dp or %), **starting from the top**. If a percentage is used, that would translate to the relative amount of the total window height. If you want that percentage to be calculated based on the parent available space instead, for example to account for safe areas or navigation bars, use it in combination with `topInset` prop |
| `initialSnapIndex` | yes | `number` | Index that references the initial resting position of the drawer, **starting from the top** |
| `renderHandle` | yes | `() => React.ReactNode` | Render prop for the handle, should return a React Element |
| `animationConfig` | no | `string` | `timing` (default) or `spring` |
| `onSettle` | no | `(index: number) => void` | Callback that is executed right after the bottom sheet settles in one of the snapping points. The new index is provided on the callback |
| `animatedPosition` | no | `Animated.Value<number>` | Animated value that tracks the position of the drawer, being: 0 => closed, 1 => fully opened |
| `animationConfig` | no | `{ duration: number, easing: Animated.EasingFunction }` | Timing configuration for the animation, by default it uses a duration of 250ms and easing fn `Easing.inOut(Easing.linear)` |
| `topInset` | no | `number` | This value is useful to provide an offset (in dp) when applying percentages for snapping points |
| `innerRef` | no | `RefObject` | Ref to the inner scrollable component (ScrollView, FlatList or SectionList), so that you can call its imperative methods. For instance, calling `scrollTo` on a ScrollView. In order to so, you have to use `getNode` as well, since it's wrapped into an _animated_ component: `ref.current.getNode().scrollTo({y: 0, animated: true})` |
| `containerStyle` | no | `StyleProp<ViewStyle>` | Style to be applied to the container (Handle and Content) |
| `friction` | no | `number` | Factor of resistance when the gesture is released. A value of 0 offers maximum * acceleration, whereas 1 acts as the opposite. Defaults to 0.95 |
| `enableOverScroll` | yes | `boolean` | Allow drawer to be dragged beyond lowest snap point |


### Inherited
Expand All @@ -193,11 +196,11 @@ import { useDimensions } from '@react-native-community/hooks'

const useOrientation = () => {
const { width, height } = useDimensions().window;

if (height > width) {
return 'portrait'
}

return 'landscape'
}

Expand Down
124 changes: 95 additions & 29 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import Animated, {
startClock,
stopClock,
sub,
spring,
timing,
Value,
} from 'react-native-reanimated';
Expand All @@ -70,9 +71,19 @@ const Easing: typeof EasingDeprecated = EasingNode ?? EasingDeprecated;
const FlatListComponentType = 'FlatList' as const;
const ScrollViewComponentType = 'ScrollView' as const;
const SectionListComponentType = 'SectionList' as const;
const TimingAnimationType = 'timing' as const;
const SpringAnimationType = 'spring' as const;

const DEFAULT_SPRING_PARAMS = {
damping: 50,
mass: 0.3,
stiffness: 121.6,
overshootClamping: true,
restSpeedThreshold: 0.3,
restDisplacementThreshold: 0.3,
};

const { height: windowHeight } = Dimensions.get('window');
const DRAG_TOSS = 0.05;
const IOS_NORMAL_DECELERATION_RATE = 0.998;
const ANDROID_NORMAL_DECELERATION_RATE = 0.985;
const DEFAULT_ANIMATION_DURATION = 250;
Expand Down Expand Up @@ -129,6 +140,7 @@ interface TimingParams {
position: Animated.Value<number>;
finished: Animated.Value<number>;
frameTime: Animated.Value<number>;
velocity: Animated.Node<number>;
}

type CommonProps = {
Expand Down Expand Up @@ -159,13 +171,6 @@ type CommonProps = {
* 1 => fully opened
*/
animatedPosition?: Animated.Value<number>;
/**
* Configuration for the timing reanimated function
*/
animationConfig?: {
duration?: number;
easing?: Animated.EasingFunction;
};
/**
* This value is useful if you want to take into consideration safe area insets
* when applying percentages for snapping points. We recommend using react-native-safe-area-context
Expand All @@ -181,16 +186,46 @@ type CommonProps = {
* Style to be applied to the container.
*/
containerStyle?: Animated.AnimateStyle<ViewStyle>;
/*
* Factor of resistance when the gesture is released. A value of 0 offers maximum
* acceleration, whereas 1 acts as the opposite. Defaults to 0.95
*/
friction: number;
/*
* Allow drawer to be dragged beyond lowest snap point
*/
enableOverScroll: boolean;
};

type TimingAnimationProps = {
animationType: typeof TimingAnimationType;
/**
* Configuration for the timing reanimated function
*/
animationConfig?: Partial<Animated.TimingConfig>;
};

type SpringAnimationProps = {
animationType: typeof SpringAnimationType;
/**
* Configuration for the spring reanimated function
*/
animationConfig?: Partial<Animated.SpringConfig>;
};

type Props<T> = CommonProps &
(FlatListOption<T> | ScrollViewOption | SectionListOption<T>);
(FlatListOption<T> | ScrollViewOption | SectionListOption<T>) &
(TimingAnimationProps | SpringAnimationProps);

export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
static defaultProps = {
topInset: 0,
friction: 0.95,
animationType: 'timing',
innerRef: React.createRef<AnimatedScrollableComponent>(),
enableOverScroll: false,
};

/**
* Gesture Handler references
*/
Expand Down Expand Up @@ -268,17 +303,22 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {

constructor(props: Props<T>) {
super(props);
const { initialSnapIndex, animationConfig } = props;
const { initialSnapIndex, animationType } = props;

const animationDriver = animationType === 'timing' ? 0 : 1;
const animationDuration =
animationConfig?.duration || DEFAULT_ANIMATION_DURATION;
(props.animationType === 'timing' && props.animationConfig?.duration) ||
DEFAULT_ANIMATION_DURATION;

const ScrollComponent = this.getScrollComponent();
// @ts-ignore
this.scrollComponent = Animated.createAnimatedComponent(ScrollComponent);

const snapPoints = this.getNormalisedSnapPoints();
const openPosition = snapPoints[0];
const closedPosition = snapPoints[snapPoints.length - 1];
const closedPosition = this.props.enableOverScroll
? windowHeight
: snapPoints[snapPoints.length - 1];
const initialSnap = snapPoints[initialSnapIndex];
this.nextSnapIndex = new Value(initialSnapIndex);

Expand Down Expand Up @@ -333,7 +373,15 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
const isAnimationInterrupted = and(
or(
eq(handleGestureState, GestureState.BEGAN),
eq(drawerGestureState, GestureState.BEGAN)
eq(drawerGestureState, GestureState.BEGAN),
and(
eq(this.isAndroid, 0),
eq(animationDriver, 1),
or(
eq(drawerGestureState, GestureState.ACTIVE),
eq(handleGestureState, GestureState.ACTIVE)
)
)
),
clockRunning(this.animationClock)
);
Expand Down Expand Up @@ -417,7 +465,7 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
const endOffsetY = add(
this.lastSnap,
this.translationY,
multiply(DRAG_TOSS, this.velocityY)
multiply(1 - props.friction, this.velocityY)
);

this.calculateNextSnapPoint = (i = 0): Animated.Node<number> | number =>
Expand All @@ -436,43 +484,55 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
this.calculateNextSnapPoint(i + 1)
);

const runTiming = ({
const runAnimation = ({
clock,
from,
to,
position,
finished,
velocity,
frameTime,
}: TimingParams) => {
const state = {
finished,
velocity: new Value(0),
position,
time: new Value(0),
frameTime,
};

const animationParams = {
const timingConfig = {
duration: animationDuration,
easing: animationConfig?.easing || DEFAULT_EASING,
easing:
(props.animationType === 'timing' && props.animationConfig?.easing) ||
DEFAULT_EASING,
toValue: new Value(0),
};

const config = {
const springConfig = {
...DEFAULT_SPRING_PARAMS,
...((props.animationType === 'spring' && props.animationConfig) || {}),
toValue: new Value(0),
...animationParams,
};

return [
cond(and(not(clockRunning(clock)), not(eq(finished, 1))), [
// If the clock isn't running, we reset all the animation params and start the clock
set(state.finished, 0),
set(state.velocity, velocity),
set(state.time, 0),
set(state.position, from),
set(state.frameTime, 0),
set(config.toValue, to),
set(timingConfig.toValue, to),
set(springConfig.toValue, to),
startClock(clock),
]),
// We run the step here that is going to update position
timing(clock, state, config),
cond(
eq(animationDriver, 0),
timing(clock, state, timingConfig),
spring(clock, state, springConfig)
),
cond(
state.finished,
[
Expand Down Expand Up @@ -528,6 +588,7 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
set(handleOldGestureState, GestureState.END),
// By forcing that frameTime exceeds duration, it has the effect of stopping the animation
set(this.animationFrameTime, add(animationDuration, 1000)),
set(this.velocityY, 0),
stopClock(this.animationClock),
this.prevTranslateYOffset,
],
Expand All @@ -538,7 +599,7 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
clockRunning(this.animationClock)
),
[
runTiming({
runAnimation({
clock: this.animationClock,
from: cond(
this.isManuallySetValue,
Expand All @@ -549,6 +610,7 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
position: this.animationPosition,
finished: this.animationFinished,
frameTime: this.animationFrameTime,
velocity: this.velocityY,
}),
],
[
Expand All @@ -570,7 +632,7 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
);

this.position = interpolate(this.translateY, {
inputRange: [openPosition, closedPosition],
inputRange: [openPosition, snapPoints[snapPoints.length - 1]],
outputRange: [1, 0],
extrapolate: Extrapolate.CLAMP,
});
Expand Down Expand Up @@ -715,15 +777,19 @@ export class ScrollBottomSheet<T extends any> extends Component<Props<T>> {
const { method, args } = imperativeScrollOptions[
this.props.componentType
];
// @ts-ignore
const node = this.props.innerRef.current?.getNode();

if (
(this.props.componentType === 'FlatList' &&
node &&
node[method] &&
((this.props.componentType === 'FlatList' &&
(this.props?.data?.length || 0) > 0) ||
(this.props.componentType === 'SectionList' &&
this.props.sections.length > 0) ||
this.props.componentType === 'ScrollView'
(this.props.componentType === 'SectionList' &&
this.props.sections.length > 0) ||
this.props.componentType === 'ScrollView')
) {
// @ts-ignore
this.props.innerRef.current?.getNode()[method](args);
node[method](args);
}
})
),
Expand Down

0 comments on commit c6c0a3e

Please sign in to comment.