Skip to content

Commit

Permalink
feat: not animated keyboard resize transitions (#376)
Browse files Browse the repository at this point in the history
## 📜 Description

Removed animated keyboard resize transitions on Android. Now it matches
iOS and is also instant.

## 💡 Motivation and Context

### Problem description

First of all I'd like to give a little bit context:
- transition prior to Android 11 are simulated (compat library listens
to `onAppyWindowInsets` and dispatches the animation that looks kind of
similar to keyboard show/hide);
- when keyboard is resized -> compat library still dispatches an
animated transition (but in fact keyboard frame is changed instantly);
- after Android 11 `onStart`/`onMove`/`onEnd` events are not dispatching
if keyboard is resized;
- instead we should listen to `onApplyWindowInsets` and do a
corresponding reactions on our own;
- in
#62
I decided to simulate behavior for Android < 11 and also do an animated
transition.

Initially for me it looked like a correct option. But devil is in
details and this approach brought new issues:

#### 1️⃣ Incorrect `progress` value when keyboard gets resized

The `progress` value should be only between 0 (closed) and 1 (opened).
If we do an animated keyboard resize, then we need to change `progress`
value accordingly. It would be strange to see `progress` as `1.3` if
keyboard gets bigger, so we always keep it in range of [0, 1].

> [!WARNING]
> The only one exception is when keyboard gets smaller, in this case for
first frame we increased instantly progress to >1 value (let's say
`1.2`) and then changed a progress to `1` (finish of animation), thus
when keyboard is not animated it'll always have a `progress` as `0` or
`1`.

In
#316
I fixed the problem by introducing additional hook, but solution wasn't
perfect, because:
- it's additional code that we need to support;
- people are not aware about this hook;
- it wasn't exported from the package (I knew I'm going to remove it
eventually) and it wasn't documented.

So animated transition for resize is really headache 🤯 

#### 2️⃣ Strange animation with `KeyboardStickyView` + switching between
different TextInput fields with different input types (i. e. text to
numeric)

The undesired animated resize effect comes into play when we have
various simultaneous animations. For example using `KeyboardToolbar`
when you press `next` button and keyboard changes it size from `text` to
`numeric` (becomes smaller). If everything wrapped in
`KeyboardAwareScrollView`, then we have several animations:
- keyboard toolbar is moving because keyboard was resized;
- `KeyboardAwareScrollView` is scrolling because a new input got a
focus.

And it looks very strange. You can see it in
#374

### Solution

#### 1️⃣ Overview

The solution is quite simple - react on keyboard resize instantly (i. e.
it shouldn't be an animated transition). In this case we can assure that
we match iOS behavior and we reduce API fragmentation across platforms.

So in this PR I reworked code and made resize transition instant one:
- first of all I deleted an animated transition from
`onKeyboardResized`, but it helps only for Android 11+;
- secondly I re-wrote `onStart` method for Android < 11 (I detect if
keyboard was resized and keep animation reference that should be ignored
- in `onEnd` I reset that value);
- it worked, but I discovered, that on Android < 11 we may have a
situation, where we dispatch many `onStart` animations (go to emoji ->
start to search them). If we keep only last reference - then other
(previously) started animations affect position and transition is very
janky:


https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/9c311ed7-6196-4aed-957a-bd04e3c73ebf

If we take only latest value - we'll e always one frame behind. It's not
perfect, but still better than race conditions:


https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/add753e7-42f9-41bb-a019-3659e07ecfca

That's the purpose behind `animationsToSkip` HashSet.

#### 2️⃣ new `onResize` handler

Initially I though to add additional `onResize` handler, but after some
research I thought that it could make API of library more complex
without explicit benefits. So I decided to use existing methods. However
it's a bit challenging, because by default on Android we listen only to
`onMove` events. Theoretically we can add a listener and update a value
on `onEnd` - I did it and on one of my projects it resulted to jumpy
behavior.

So for sake of backward compatibility I'm dispatching 3 events on
Android (onStart/onMove/onEnd) and two events on iOS (onStart/onEnd).

#### 3️⃣ One frame lag

After implementation I discovered that there is a one frame dealy
between actual keyboard size changes and the emitted event. After
digging into native code I found that it suffers from the same problem:

|1st frame|2nd frame|3rd frame|4th frame|
|---------|----------|----------|----------|
|<img width="473" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/6d4767f7-6ba0-4c92-a92f-9d51c046d32a">|<img
width="475" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/111e6465-df66-40d7-bded-cbd873809375">|<img
width="475" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/7e8fcced-cf2a-4e4a-ae6b-8c72b283c6af">|<img
width="474" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/97beaa36-5fb8-4e63-b3a4-e90ed03b29aa">|

And backward transition:

|1st frame|2nd frame|3rd frame|
|---------|----------|----------|
|<img width="472" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/84b36c0d-d23a-4b62-a6d2-d19879511dd3">|<img
width="474" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/84af0178-4541-432f-bf78-48449c28100a">|<img
width="475" alt="image"
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/d92e6a82-352b-41ba-9572-7d249b6986a7">|

#### 4️⃣ An ability to have an animated transitions

If for some reasons you still prefer to have an animated transition,
then you still can achieve it using `useKeyboardHandler` hook:

```tsx
const useKeyboardAnimation = () => {
  const height = useSharedValue(0);
  const shouldUpdateFromLifecycle = useSharedValue(true);

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

      // keyboard gets resized
      if (height.value !== 0 && e.height !== 0) {
        shouldUpdateFromLifecycle.value = false;
        height.value = withTiming(e.height, { duration: 250 }, () => {
          shouldUpdateFromLifecycle.value = true;
        });
      }
    },
    onMove: (e) => {
      "worklet";
      
      if (shouldUpdateFromLifecycle.value) {
        height.value = e.height;
      }
    },
  }, []);

  return { height };
}
```

Closes
#374

## 📢 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 -->

### JS

- removed `useKeyboardInterpolation` hook;
- use `progress` based interpolation in
`KeyboardStickyView`/`KeyboardAvoidingView`;

### Android

- removed `DEFAULT_ANIMATION_TIME`;
- do an instant transition if keyboard was resized;
- store running animations for resize in `HashSet` (`animationsToSkip`);
- ignore animations that are in `animationsToSkip` HashSet;
- dispatch instant `onStart`/`onMove`/`onEnd` events if keyboard was
resized;

## 🤔 How Has This Been Tested?

Tested manually on:
- Xiaomi Redmi Note 5 Pro (Android 9, real device);
- Pixel 7 Pro (Android 14, real device);
- Pixel 7 Pro (Android 14, emulator);
- Pixel 6 Pro (Android 9, emulator);

## 📸 Screenshots (if appropriate):

### Keyboard toolbar

|Before|After|
|-------|-----|
|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/25e270ca-11dd-41fd-8cd8-404ae1e91eb0">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/ee8fefcd-14a9-4df2-bf24-a9f447ebab1b">|

### Keyboard Avoiding View

|Before|After|
|------|-----|
|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/aa5989d4-97d4-400d-b40f-862ab882ad59">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/2016c5c6-2b05-4a5f-9492-f88db2462ad9">|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko committed Mar 10, 2024
1 parent 5a516aa commit 611ab80
Show file tree
Hide file tree
Showing 4 changed files with 64 additions and 182 deletions.
@@ -1,11 +1,9 @@
package com.reactnativekeyboardcontroller.listeners

import android.animation.ValueAnimator
import android.os.Build
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver.OnGlobalFocusChangeListener
import androidx.core.animation.doOnEnd
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
Expand All @@ -26,6 +24,7 @@ import com.reactnativekeyboardcontroller.interactive.InteractiveKeyboardProvider
import kotlin.math.abs

private val TAG = KeyboardAnimationCallback::class.qualifiedName
private val isResizeHandledInCallbackMethods = Build.VERSION.SDK_INT < Build.VERSION_CODES.R

class KeyboardAnimationCallback(
val view: ReactViewGroup,
Expand All @@ -38,11 +37,12 @@ class KeyboardAnimationCallback(

// state variables
private var persistentKeyboardHeight = 0.0
private var prevKeyboardHeight = 0.0
private var isKeyboardVisible = false
private var isTransitioning = false
private var duration = 0
private var viewTagFocused = -1
private var animation: ValueAnimator? = null
private var animationsToSkip = hashSetOf<WindowInsetsAnimationCompat>()

// listeners
private val focusListener = OnGlobalFocusChangeListener { oldFocus, newFocus ->
Expand Down Expand Up @@ -130,7 +130,7 @@ class KeyboardAnimationCallback(
// in this method
val isKeyboardSizeEqual = this.persistentKeyboardHeight == keyboardHeight

if (isKeyboardFullyVisible && !isKeyboardSizeEqual && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (isKeyboardFullyVisible && !isKeyboardSizeEqual && !isResizeHandledInCallbackMethods) {
Log.i(TAG, "onApplyWindowInsets: ${this.persistentKeyboardHeight} -> $keyboardHeight")
layoutObserver?.syncUpLayout()
this.onKeyboardResized(keyboardHeight)
Expand All @@ -139,6 +139,7 @@ class KeyboardAnimationCallback(
return insets
}

@Suppress("detekt:ReturnCount")
override fun onStart(
animation: WindowInsetsAnimationCompat,
bounds: WindowInsetsAnimationCompat.BoundsCompat,
Expand All @@ -158,6 +159,18 @@ class KeyboardAnimationCallback(
}

layoutObserver?.syncUpLayout()

// keyboard gets resized - we do not want to have a default animated transition
// so we skip these animations
val isKeyboardResized = keyboardHeight != 0.0 && prevKeyboardHeight != keyboardHeight
val isKeyboardShown = isKeyboardVisible && prevKeyboardHeight != 0.0
if (isKeyboardResized && isKeyboardShown && isResizeHandledInCallbackMethods) {
onKeyboardResized(keyboardHeight)
animationsToSkip.add(animation)

return bounds
}

context.emitEvent(
"KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow",
getEventParams(keyboardHeight),
Expand Down Expand Up @@ -186,8 +199,8 @@ class KeyboardAnimationCallback(
): WindowInsetsCompat {
// onProgress() is called when any of the running animations progress...

// ignore non-keyboard animation
runningAnimations.find { it.isKeyboardAnimation } ?: return insets
// ignore non-keyboard animation or animation that we intentionally want to skip
runningAnimations.find { it.isKeyboardAnimation && !animationsToSkip.contains(it) } ?: return insets

// First we get the insets which are potentially deferred
val typesInset = insets.getInsets(deferredInsetTypes)
Expand Down Expand Up @@ -254,6 +267,13 @@ class KeyboardAnimationCallback(
InteractiveKeyboardProvider.shown = false
}
isKeyboardVisible = isKeyboardVisible || isKeyboardShown
prevKeyboardHeight = keyboardHeight

if (animation in animationsToSkip) {
duration = 0
animationsToSkip.remove(animation)
return
}

context.emitEvent(
"KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow",
Expand Down Expand Up @@ -282,22 +302,10 @@ class KeyboardAnimationCallback(
}

/*
* In the method below we recreate the logic that used when keyboard appear/disappear:
* - we dispatch `keyboardWillShow` (onStart);
* - we dispatch change height/progress as animated values (onProgress);
* - we dispatch `keyboardDidShow` (onEnd).
* Method that dispatches necessary events when keyboard gets resized
*/
private fun onKeyboardResized(keyboardHeight: Double) {
if (this.animation?.isRunning == true) {
Log.i(TAG, "onKeyboardResized -> cancelling animation that is in progress")
// if animation is in progress, then we are:
// - removing listeners (update, onEnd)
// - updating `persistentKeyboardHeight` to latest animated value
// - cancelling animation to free up CPU resources
this.animation?.removeAllListeners()
this.persistentKeyboardHeight = (this.animation?.animatedValue as Float).toDouble()
this.animation?.cancel()
}
duration = 0

context.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight))
context.dispatchEvent(
Expand All @@ -308,48 +316,36 @@ class KeyboardAnimationCallback(
"topKeyboardMoveStart",
keyboardHeight,
1.0,
DEFAULT_ANIMATION_TIME,
0,
viewTagFocused,
),
)

val animation =
ValueAnimator.ofFloat(this.persistentKeyboardHeight.toFloat(), keyboardHeight.toFloat())
animation.addUpdateListener { animator ->
val toValue = animator.animatedValue as Float
context.dispatchEvent(
context.dispatchEvent(
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
"topKeyboardMove",
toValue.toDouble(),
toValue.toDouble() / keyboardHeight,
DEFAULT_ANIMATION_TIME,
viewTagFocused,
),
)
}
animation.doOnEnd {
context.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))
context.dispatchEvent(
"topKeyboardMove",
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
context.dispatchEvent(
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
KeyboardTransitionEvent(
surfaceId,
view.id,
"topKeyboardMoveEnd",
keyboardHeight,
1.0,
DEFAULT_ANIMATION_TIME,
viewTagFocused,
),
)
this.animation = null
}
animation.setDuration(DEFAULT_ANIMATION_TIME.toLong()).startDelay = 0
animation.start()
"topKeyboardMoveEnd",
keyboardHeight,
1.0,
0,
viewTagFocused,
),
)
context.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))

this.animation = animation
this.persistentKeyboardHeight = keyboardHeight
}

Expand Down Expand Up @@ -377,8 +373,4 @@ class KeyboardAnimationCallback(

return params
}

companion object {
private const val DEFAULT_ANIMATION_TIME = 250
}
}
13 changes: 6 additions & 7 deletions src/components/KeyboardAvoidingView/index.tsx
@@ -1,14 +1,13 @@
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 @@ -76,7 +75,6 @@ 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 @@ -94,10 +92,11 @@ const KeyboardAvoidingView = forwardRef<View, React.PropsWithChildren<Props>>(
);

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

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

import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";

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

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

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

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

return {
transform: [{ translateY: height.value + offset }],
Expand Down
109 changes: 0 additions & 109 deletions src/components/hooks/useKeyboardInterpolation.ts

This file was deleted.

0 comments on commit 611ab80

Please sign in to comment.