Skip to content

Commit

Permalink
feat: eliminate one frame delay in onMove handler on iOS (#412)
Browse files Browse the repository at this point in the history
## 馃摐 Description

Improved precision of `onMove` handler to drive animation frame-in-frame
using this handler.

## 馃挕 Motivation and Context

<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->

This problem was introduced since
#87
was merged.

The thing is that we are reading `layer` properties using
`CADisplayLink`. The problem is that in this case we read always
out-of-date values, because `CADisplayLink` fires a callback before the
next frame - in this case we'll always read a value for a previous
frame. Thus we have always one frame delay.

The idea of this PR is to have an ability to read future values and use
them. For that I had to replicate how `SpringAnimation` calculates its
values (i. e. the math behind it, because default `CASpringAnimation`
class doesn't give such ability).

Now we know all variables - current timestamp, beginning of the
animation, next scheduled timestamp etc. so we can calculate the next
frame.

However it's also tricky, because in my observation:
- the algorithm how next frame will be rendered is different if we
compare simulator vs actual device (on a real device we have a bigger
delay);
- if debugger from XCode is attached, then frames are also calcualted
slightly different.

So the formula like `targetTimestamp - beginTime + duration` may work
good if XCode debugger is not attached, but will produce junky animation
when debugger is attached.

I struggled a lot and found that `CADisplayLink` can fire its updates
with monothonic intervals (i. e. 16ms) but the keyboard position can be
at this point of time in a different values (it can be 12ms or 20ms or
any other vlaue comparing to the previous frame) - see the image below
as an example of values captured on simulator:

<img width="1492" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/5fe49043-5410-479b-925b-98df77fe1426">

So in the end I decided to stick to slightly different approach - when
`CADisplayLink` callback is fired, then we read prev frame keyboard
position, and instead of calulation `duration` value based on absolute
time intervals we are doing reversing of current keyboard frame, i. e.
for a give keyboard position value we are caluclating the duration, and
only then calculate next frame as `durationFromKeyboardPosition +
frameDuration`. Such approach gives a decent result on a real device
(debug/release, debugger attached/detached etc.).

Last but not least - this PR improves precision for plain keyboard
show/hide movements. However on iOS in certain cases animation can be
not only `CASpringAniamtion` - right now I handle it in a PR in a
fallback way, i. e. if it's not a spring animation we fallback to the
mechanism introduced in
#87
and simply read `layer` properties.

Later on I'll cover this case as well and will synchronize animations
for all tye of animations.

> [!WARNING]
> I can not test this PR on `ProMotion` device because I don't have it
at the moment. However I'll get it back after **10.05.24**, so I'll test
it there and will see if any code adjustments are needed.

## 馃摙 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### Docs

- remove a reference about not perfectly synchronized `onMove` handler;

### E2E

- updated assets that were using `KeyboardAnimation` component;

### JS

- added `gray` circle in `KeyboardAnimation` component which is driven
by `onMove` handler;
- in `FlatList` example -> use `onMove` handler to handle a case when
layout animation is not schedule (when keyboard closed via
`keyboardShouldPersistTaps`)

### iOS

- added `core` folder;
- added `SpringAnimation` class;
- read animation from layer and try to cast it to `CASpringAnimation`;
- if internal `SpringAnimation` class is available, then calculate
keyboard position using this class;
- don't schedule global layout animations anymore since in this case
`onMove` will not be perfectly synchronized in any way (layout animation
will override our calculations);

## 馃 How Has This Been Tested?

Tested manually on:
- iPhone 11 (iOS 17.4.1)
- iPhone 6s (iOS 15.8)
- iPhone 14 Pro (iOS 17.4) <- I tested it during development, however I
made some changes in final code and can not test it again because this
device is not with me at the moment - I'll test later when device comes
back to me
- iPhone 15 Pro (iOS 17.4, simulator)

## 馃摳 Screenshots (if appropriate):

iPhone 11, iPhone 14, iPhone 6s

### KeyboardAnimation

|When|Simulator|iPhone 11|iPhone 6s|
|------|---------|----------|---------|
|Before|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/7ffa98ef-ef6b-400b-b9b9-3152a7e50dba">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/24730eb5-4d7a-48bd-8ca8-991d20a109d3">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/58977da1-4857-4740-8f05-c34bbbb83df4">|
|After|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/8e076557-8d36-41be-8981-b7343709a90b">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/63aa154b-2995-4e68-8c96-5bfef4e13127">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/2d0332b3-4c84-4692-9303-2e3108a04f53">|

### KeyboardAvoidingView

|When|Simulator|iPhone 11|iPhone 6s|
|------|---------|----------|---------|
|Before|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/1d3cbb2c-b614-4c70-a5f1-a0d1478207a6">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/aa2e597a-5d37-4450-8523-65d94bfd3c9e">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/388a4798-b42d-4cf2-bdd7-be12523a6601">|
|After|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/907dd2bd-df9a-4972-86c4-f75c29de476e">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/00eb7f89-bc55-4271-bb43-a54b9dfa86f8">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/8a137694-39ab-4bc2-98bf-21d492708f12">|

### KeyboardAwareScrollView

|When|Simulator|iPhone 11|iPhone 6s|
|------|---------|----------|---------|
|Before|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/bddeb811-fc12-4d5b-8f85-a0e33924b0b7">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/96e46247-300a-43e0-8c75-5aca6e1d941f">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/d7311494-d88a-4b50-bdc0-2ca097e3919f">|
|After|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/f9b63624-f1a2-4576-97e8-ecb58d870c61">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/f8178b53-1276-4404-ae9d-efa77b4f5492">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/b535a0ee-2385-4397-97f5-5cad3eb0535c">|

## 馃摑 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko committed May 2, 2024
1 parent 590ff6e commit 27da1d0
Show file tree
Hide file tree
Showing 24 changed files with 409 additions and 90 deletions.
2 changes: 1 addition & 1 deletion FabricExample/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,6 @@ SPEC CHECKSUMS:
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
Yoga: ff1d575b119f510a5de23c22a794872562078ccf

PODFILE CHECKSUM: c42ea1b5c5b333fdc293023c586cccdd7080801f
PODFILE CHECKSUM: 802cba1de01f270fb7f8acc01d301b8df2e5c762

COCOAPODS: 1.14.3
121 changes: 85 additions & 36 deletions FabricExample/src/components/KeyboardAnimation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,103 @@
import React from "react";
import { Animated, TextInput, View } from "react-native";
import { useKeyboardAnimation } from "react-native-keyboard-controller";
import { Animated, TextInput, TouchableOpacity, View } from "react-native";
import {
KeyboardController,
useKeyboardAnimation,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import Reanimated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";

import styles from "./styles";

const useGradualKeyboardAnimation = () => {
const height = useSharedValue(0);

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

height.value = e.height;
},
onEnd: (e) => {
"worklet";

height.value = e.height;
},
},
[],
);

return height;
};

export default function KeyboardAnimation() {
const { height, progress } = useKeyboardAnimation();
const keyboard = useGradualKeyboardAnimation();

const gradual = useAnimatedStyle(
() => ({
width: 50,
height: 50,
backgroundColor: "gray",
borderRadius: 25,
transform: [{ translateY: -keyboard.value }],
}),
[],
);

return (
<View style={styles.container}>
<View>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "green",
borderRadius: 25,
transform: [
{
translateX: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
}),
},
],
}}
/>
</View>
<View>
<TextInput
testID="keyboard_animation_text_input"
style={{
width: 200,
marginTop: 50,
height: 50,
backgroundColor: "yellow",
}}
/>
<View style={[styles.row, styles.center]}>
<TouchableOpacity
activeOpacity={1}
style={styles.container}
onPress={() => KeyboardController.dismiss()}
>
<>
<View>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "red",
backgroundColor: "green",
borderRadius: 25,
transform: [{ translateY: height }],
transform: [
{
translateX: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
}),
},
],
}}
/>
</View>
<View>
<TextInput
testID="keyboard_animation_text_input"
style={{
width: 200,
marginTop: 50,
height: 50,
backgroundColor: "yellow",
}}
/>
<View style={[styles.row, styles.center]}>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "red",
borderRadius: 25,
transform: [{ translateY: height }],
}}
/>
<Reanimated.View style={gradual} />
</View>
</View>
</View>
</View>
</>
</TouchableOpacity>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from "react";
import { FlatList, TextInput, View } from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useKeyboardHandler } from "react-native-keyboard-controller";
import Animated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";

import Message from "../../../components/Message";
import { history } from "../../../components/Message/data";
Expand All @@ -17,8 +20,30 @@ const RenderItem: ListRenderItem<MessageProps> = ({ item, index }) => {
return <Message key={index} {...item} />;
};

const useGradualAnimation = () => {
const height = useSharedValue(0);

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

height.value = e.height;
},
onEnd: (e) => {
"worklet";

height.value = e.height;
},
},
[],
);

return { height };
};

function ReanimatedChatFlatList() {
const { height } = useReanimatedKeyboardAnimation();
const { height } = useGradualAnimation();

const fakeView = useAnimatedStyle(
() => ({
Expand Down
6 changes: 0 additions & 6 deletions docs/docs/api/hooks/keyboard/use-keyboard-handler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,6 @@ export const onMoveCode = (
</div>
<div className="mobile">{onMoveCode}</div>

:::info Not precise values
There is no corresponding events in iOS for this hook. So values will not be perfectly synchronized with the keyboard.

The same is applied to Android < 11 - these OS versions don't have API for getting keyboard positions during an animation.
:::

#### `onInteractive`

export const onInteractiveCode = (
Expand Down
Binary file modified e2e/kit/assets/android/e2e_emulator/DisabledKeyboardIsHidden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/android/e2e_emulator/DisabledKeyboardIsShown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/android/e2e_emulator/EnabledKeyboardIsHidden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/android/e2e_emulator/EnabledKeyboardIsShown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 15 Pro/DisabledKeyboardIsHidden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 15 Pro/DisabledKeyboardIsShown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 15 Pro/EnabledKeyboardIsHidden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified e2e/kit/assets/ios/iPhone 15 Pro/EnabledKeyboardIsShown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
ReferencedContainer = "container:KeyboardControllerExample.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
Expand Down
2 changes: 1 addition & 1 deletion example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,6 @@ SPEC CHECKSUMS:
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
Yoga: ff1d575b119f510a5de23c22a794872562078ccf

PODFILE CHECKSUM: f85941551be0ad454b5c3cc990b9d1f8add7366b
PODFILE CHECKSUM: 99c5edd78bbd8964f66a818b60d5e3aba4adc048

COCOAPODS: 1.14.3
121 changes: 85 additions & 36 deletions example/src/components/KeyboardAnimation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,103 @@
import React from "react";
import { Animated, TextInput, View } from "react-native";
import { useKeyboardAnimation } from "react-native-keyboard-controller";
import { Animated, TextInput, TouchableOpacity, View } from "react-native";
import {
KeyboardController,
useKeyboardAnimation,
useKeyboardHandler,
} from "react-native-keyboard-controller";
import Reanimated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";

import styles from "./styles";

const useGradualKeyboardAnimation = () => {
const height = useSharedValue(0);

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

height.value = e.height;
},
onEnd: (e) => {
"worklet";

height.value = e.height;
},
},
[],
);

return height;
};

export default function KeyboardAnimation() {
const { height, progress } = useKeyboardAnimation();
const keyboard = useGradualKeyboardAnimation();

const gradual = useAnimatedStyle(
() => ({
width: 50,
height: 50,
backgroundColor: "gray",
borderRadius: 25,
transform: [{ translateY: -keyboard.value }],
}),
[],
);

return (
<View style={styles.container}>
<View>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "green",
borderRadius: 25,
transform: [
{
translateX: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
}),
},
],
}}
/>
</View>
<View>
<TextInput
testID="keyboard_animation_text_input"
style={{
width: 200,
marginTop: 50,
height: 50,
backgroundColor: "yellow",
}}
/>
<View style={[styles.row, styles.center]}>
<TouchableOpacity
activeOpacity={1}
style={styles.container}
onPress={() => KeyboardController.dismiss()}
>
<>
<View>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "red",
backgroundColor: "green",
borderRadius: 25,
transform: [{ translateY: height }],
transform: [
{
translateX: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
}),
},
],
}}
/>
</View>
<View>
<TextInput
testID="keyboard_animation_text_input"
style={{
width: 200,
marginTop: 50,
height: 50,
backgroundColor: "yellow",
}}
/>
<View style={[styles.row, styles.center]}>
<Animated.View
style={{
width: 50,
height: 50,
backgroundColor: "red",
borderRadius: 25,
transform: [{ translateY: height }],
}}
/>
<Reanimated.View style={gradual} />
</View>
</View>
</View>
</View>
</>
</TouchableOpacity>
);
}
Loading

0 comments on commit 27da1d0

Please sign in to comment.