Skip to content

Commit

Permalink
fix: incorrect first frame when keyboard was resized (#316)
Browse files Browse the repository at this point in the history
## 📜 Description

Fixed incorrect first frame (when keyboard was resized).

## 💡 Motivation and Context

If we use `progress` directly for interpolation we may encounter a
situation when animation looks junky.

It happens because first frame is not calculated properly when we
interpolate value.

First of all let's define how `progress` value is calculated across
platforms when keyboard is resized:
- on iOS it'll be always `1` because keyboard changes size immediately;
- on Android prior to Android 11 we'll have intermediate values;
- on Android 11+ we don't have intermediate values, but to make it more
consistent across platforms I added a transition;

So to sum it up: on iOS `progress` is always `1` when keyboard gets
resized. On Android it will change the value, and the calculation
algorithm looks like: take latest keyboard frame and new final frame ->
divide current to new (in this case we always assure that final
`progress` value will be `1`.

However if we do interpolation directly on `progress` value we may
encounter some issues. Let's say keyboard changes size from `0` to `200`
and we do interpolation from `0` to `230` (`+30` to final keyboard
frame). Then keyboard gets resized from `200` to `220`. In this case
we'll interpolate from `0` to `250`. And initial frame will be 200 / 220
* 250 = 227 (but previous value was 220, so we will see a jump from 220
and 227).

To overcome this problem I added `useKeyboardInterpolation` hook. It
fixes this problem by changing interpolation approach. Basically when
keyboard appears/disappears it uses the same approach as
`progress`-based interpolation.

However when it comes to keyboard resizing it detects this moment and
changes interpolation rules: instead of using a provided range it will
use latest interpolated value as the beginning of `inputRange`, so in
our case discussed above new interpolation will be:

```tsx
{
  inputRange: [200, 220]
  outputRange: [230, 250]
}
```

In this case we will not have a jump and animation will be smooth.

Closes
#315

## 📢 Changelog

### JS

- added `useKeyboardInterpolation` hook;
- use `useKeyboardInterpolation` hook in
`KeyboardAvoidingView`/`KeyboardStickyView`;

## 🤔 How Has This Been Tested?

Tested manually on Pixel 7 Pro.

## 📸 Screenshots (if appropriate):

|Before|After|
|------|------|
|<video
src="https://private-user-images.githubusercontent.com/22820318/294164887-081cfd0d-e308-43de-b946-79c47c9fe9a0.mp4">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/68109c4b-d17c-40a1-876d-a74fad158efe">|
|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/93a783c9-3db2-41c8-9b92-7f307399729e">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/13bfb5c2-c1bf-47b5-9a84-a7d2ff5cd6a3">|

## 📝 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko authored Jan 5, 2024
1 parent 2b73c31 commit 133bfd8
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 12 deletions.
3 changes: 3 additions & 0 deletions src/components/KeyboardAvoidingView/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const useKeyboardAnimation = () => {
"worklet";

isClosed.value = e.height === 0;

progress.value = e.progress;
height.value = e.height;
},
},
[],
Expand Down
13 changes: 7 additions & 6 deletions src/components/KeyboardAvoidingView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { forwardRef, useCallback, useMemo } from "react";
import { View, useWindowDimensions } from "react-native";
import Reanimated, {
interpolate,
runOnUI,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";

import useKeyboardInterpolation from "../hooks/useKeyboardInterpolation";

import { useKeyboardAnimation } from "./hooks";

import type { LayoutRectangle, ViewProps } from "react-native";
Expand Down Expand Up @@ -75,6 +76,7 @@ const KeyboardAvoidingView = forwardRef<View, React.PropsWithChildren<Props>>(

return Math.max(frame.value.y + frame.value.height - keyboardY, 0);
}, [screenHeight, keyboardVerticalOffset]);
const { interpolate } = useKeyboardInterpolation();

const onLayoutWorklet = useCallback((layout: LayoutRectangle) => {
"worklet";
Expand All @@ -92,11 +94,10 @@ const KeyboardAvoidingView = forwardRef<View, React.PropsWithChildren<Props>>(
);

const animatedStyle = useAnimatedStyle(() => {
const bottom = interpolate(
keyboard.progress.value,
[0, 1],
[0, relativeKeyboardHeight()],
);
const bottom = interpolate(keyboard.height.value, [
0,
relativeKeyboardHeight(),
]);
const bottomHeight = enabled ? bottom : 0;

switch (behavior) {
Expand Down
11 changes: 5 additions & 6 deletions src/components/KeyboardStickyView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import React, { forwardRef, useMemo } from "react";
import Reanimated, {
interpolate,
useAnimatedStyle,
} from "react-native-reanimated";
import Reanimated, { useAnimatedStyle } from "react-native-reanimated";

import { useReanimatedKeyboardAnimation } from "../../hooks";
import useKeyboardInterpolation from "../hooks/useKeyboardInterpolation";

import type { View, ViewProps } from "react-native";

Expand Down Expand Up @@ -32,10 +30,11 @@ const KeyboardStickyView = forwardRef<
{ children, offset: { closed = 0, opened = 0 } = {}, style, ...props },
ref,
) => {
const { height, progress } = useReanimatedKeyboardAnimation();
const { height } = useReanimatedKeyboardAnimation();
const { interpolate } = useKeyboardInterpolation();

const stickyViewStyle = useAnimatedStyle(() => {
const offset = interpolate(progress.value, [0, 1], [closed, opened]);
const offset = interpolate(-height.value, [closed, opened]);

return {
transform: [{ translateY: height.value + offset }],
Expand Down
96 changes: 96 additions & 0 deletions src/components/hooks/useKeyboardInterpolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
interpolate as interpolateREA,
useSharedValue,
} from "react-native-reanimated";

import { useKeyboardHandler } from "../../hooks";

type KeyboardInterpolationOutput = [number, number];

/**
* Hook that can be used for interpolation keyboard movement. The main concern is the thing
* when keyboard is opened and gets resized on Android. Let's say we are interpolating from
* closed to open [0, 200] and we want to interpolate it to [0, 230] (to achieve nice parallax effect).
* Then let's say keyboard changes its height to 220 (and we want to interpolate the value to 250, +30
* to keyboard height). If we interpolate based on `progress` value, then we will have a jump on first frame:
* the last interpolated position was 230, now we will interpolate to 250, but first frame will be calculated
* as 200 / 220 * 250 = 227 (and last interpolated position was 230) so we will have a jump.
*
* This hook handles it, and when keyboard changes its size it does an interpolation as:
* [200, 220] -> [230, 250], i. e. we preserve last interpolated value and use it as initial value for interpolation
* and because of that we will not have a jump and animation will start from the last frame and will be smooth.
*
* @see https://github.com/kirillzyusko/react-native-keyboard-controller/issues/315
*/
const useKeyboardInterpolation = () => {
// keyboard heights
const nextKeyboardHeight = useSharedValue(0);
const prevKeyboardHeight = useSharedValue(0);
// save latest interpolated position
const lastInterpolation = useSharedValue(0);
// boolean flag indicating which output range should be used
const shouldUseInternalInterpolation = useSharedValue(false);

const interpolate = (
keyboardPosition: number,
output: KeyboardInterpolationOutput,
) => {
"worklet";

lastInterpolation.value = interpolateREA(
keyboardPosition,
[prevKeyboardHeight.value, nextKeyboardHeight.value],
shouldUseInternalInterpolation.value
? [lastInterpolation.value, output[1]]
: output,
);

return lastInterpolation.value;
};

useKeyboardHandler(
{
onStart: (e) => {
"worklet";

const keyboardWillBeHidden = e.height === 0;

// keyboard will be hidden
if (keyboardWillBeHidden) {
shouldUseInternalInterpolation.value = false;
prevKeyboardHeight.value = 0;
}

// keyboard will change its size
if (
// keyboard is shown on screen
nextKeyboardHeight.value !== 0 &&
// it really changes size (handles iOS case when after interactive keyboard gets shown again)
nextKeyboardHeight.value !== e.height &&
// keyboard is not hiding
!keyboardWillBeHidden
) {
prevKeyboardHeight.value = nextKeyboardHeight.value;
shouldUseInternalInterpolation.value = true;
}

// keyboard will show or change size
if (!keyboardWillBeHidden) {
nextKeyboardHeight.value = e.height;
}
},
onEnd: (e) => {
"worklet";

// handles case show -> resize -> hide -> show
// here we reset value to 0 when keyboard is hidden
nextKeyboardHeight.value = e.height;
},
},
[],
);

return { interpolate };
};

export default useKeyboardInterpolation;

0 comments on commit 133bfd8

Please sign in to comment.