Skip to content

Commit

Permalink
[Android][Keyboard] More consistent inequality check to compute keybo…
Browse files Browse the repository at this point in the history
…ard state (#5874)

<!-- Thanks for submitting a pull request! We appreciate you spending
the time to work on these changes. Please follow the template so that
the reviewers can easily understand what the code changes affect. -->

## Summary

I have found the `useAnimatedKeyboard` hook to be particularly useful.
Beyond the height value, I often use the state value too, which allows
me to show or hide elements based on the keyboard's state. In [this
example](https://github.com/antFrancon/reanimated-keyboard-state), I
combine it with a regular React Native `KeyboardAvoidingView` to display
a toolbar above my keyboard.

The issue I encountered is that the computed Android keyboard state
becomes invalid after opening and then closing the keyboard for the
first time. After some investigation, it appears that the keyboard gets
a negative height upon closing, which disrupts some logic inside
`keyboard.java`:
```ts
// onAnimationStart
mState = mHeight == 0 ? KeyboardState.OPENING : KeyboardState.CLOSING

// onAnimationEnd
mState = mHeight == 0 ? KeyboardState.CLOSED : KeyboardState.OPEN;
```

As a result, the keyboard state cycle appears to be `0 (UNKNOWN) > 1
(OPENING) > 2 (OPEN) > 3 (CLOSING) > 2 (OPEN)` instead of the expected
`0 (UNKNOWN) > 1 (OPENING) > 2 (OPEN) > 3 (CLOSING) > 4 (CLOSED)`.

| Ios    | Android |
| -------- | ------- |
|
![ios-keyboard](https://github.com/software-mansion/react-native-reanimated/assets/72255317/2115900f-3655-4204-a84a-64499a2869b1)
|
![android-keyboard](https://github.com/software-mansion/react-native-reanimated/assets/72255317/6f1f0571-5845-472e-966a-09c3d586d354)
|


There may be an issue with the fact that the height gets a negative
value, but this PR does not intend to address it. Instead, I would like
to make the code more robust regarding keyboard state computation by
using an inequality check, specifically `mHeight <= 0`, instead of a
strict equality check `mHeight == 0`.

## Test plan

I have added a [ready-to-play
repository](https://github.com/antFrancon/reanimated-keyboard-state).

It essentially implements the code described above. I've added some logs
to illustrate that the keyboard state is incorrect on Android when
opening and closing the keyboard. You can also observe that the keyboard
gets a negative height after closing.

```ts
import React from 'react';
import {
  KeyboardAvoidingView,
  ScrollView,
  StatusBar,
  StyleSheet,
  Text,
  TextInput,
  View,
  useColorScheme,
} from 'react-native';
import Animated, {
  KeyboardState,
  SharedValue,
  runOnJS,
  useAnimatedKeyboard,
  useAnimatedProps,
  useAnimatedStyle,
} from 'react-native-reanimated';
import {
  SafeAreaProvider,
  SafeAreaView,
  initialWindowMetrics,
  useSafeAreaInsets,
} from 'react-native-safe-area-context';

import {Colors} from 'react-native/Libraries/NewAppScreen';

function App(): React.JSX.Element {
  const isDarkMode = useColorScheme() === 'dark';
  const backgroundColor = isDarkMode ? Colors.darker : Colors.lighter;

  const styles = StyleSheet.create({
    root: {flex: 1, backgroundColor},
    container: {flex: 1},
  });

  return (
    <SafeAreaProvider style={styles.root} initialMetrics={initialWindowMetrics}>
      <StatusBar
        barStyle={isDarkMode ? 'light-content' : 'dark-content'}
        backgroundColor={backgroundColor}
        translucent
      />
      <SafeAreaView style={styles.container}>
        <AppContent isDarkMode={isDarkMode} />
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

interface AppContentProps {
  isDarkMode: boolean;
}

const AppContent: React.FC<AppContentProps> = ({isDarkMode}) => {
  const styles = StyleSheet.create({
    container: {
      flex: 1,
    },
    content: {
      flex: 1,
      justifyContent: 'flex-end',
      backgroundColor: isDarkMode ? Colors.black : Colors.white,
      padding: 16,
    },
    input: {
      height: 40,
      paddingHorizontal: 8,
      borderRadius: 4,
      borderColor: 'gray',
      borderWidth: 1,
    },
  });

  const topInset = useSafeAreaInsets().top;
  const keyboardVerticalOffset = topInset + 8;

  return (
    <ScrollView
      contentInsetAdjustmentBehavior="automatic"
      contentContainerStyle={styles.container}>
      <View style={styles.content}>
        <KeyboardAvoidingView
          behavior="padding"
          keyboardVerticalOffset={keyboardVerticalOffset}>
          <Toolbar />
          <TextInput placeholder="Type here..." style={styles.input} />
        </KeyboardAvoidingView>
      </View>
    </ScrollView>
  );
};

const Toolbar: React.FC = () => {
  const styles = StyleSheet.create({
    toolbar: {
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      height: 100,
      backgroundColor: 'red',
      transform: [{translateY: -120}],
      justifyContent: 'center',
      alignItems: 'center',
    },
  });

  const keyboard = useAnimatedKeyboard({isStatusBarTranslucentAndroid: true});
  const animatedProps = useAnimatedProps(() => ({
    pointerEvents: isKeyboardOpen(keyboard.state, keyboard.height)
      ? ('box-none' as const)
      : ('none' as const),
  }));
  const animatedStyle = useAnimatedStyle(() => ({
    opacity: isKeyboardOpen(keyboard.state, keyboard.height) ? 1 : 0,
  }));

  return (
    <Animated.View
      style={[styles.toolbar, animatedStyle]}
      animatedProps={animatedProps}>
      <Text>Toolbar visible when keyboard is open</Text>
    </Animated.View>
  );
};

/* export */
export default App;

/* utils */
function isKeyboardOpen(
  state: SharedValue<KeyboardState>,
  height: SharedValue<number>,
): boolean {
  'worklet';
  runOnJS(debug)('Keyboard State', state.value);
  runOnJS(debug)('Keyboard Height', height.value);
  return (
    state.value === KeyboardState.OPEN || state.value === KeyboardState.OPENING
  );
}

/* debug */
function debug(label: string, value: string | number): void {
  console.log('🐞', label, ':', value);
}
```
  • Loading branch information
antFrancon committed May 10, 2024
1 parent a97685f commit 021e751
Showing 1 changed file with 3 additions and 3 deletions.
Expand Up @@ -23,7 +23,7 @@ public void updateHeight(WindowInsetsCompat insets) {
int systemBarBottomInset = insets.getInsets(SYSTEM_BAR_TYPE_MASK).bottom;
int keyboardHeightDip = contentBottomInset - systemBarBottomInset;
int keyboardHeight = (int) PixelUtil.toDIPFromPixel(Math.max(0, keyboardHeightDip));
if (keyboardHeight == 0 && mState == KeyboardState.OPEN) {
if (keyboardHeight <= 0 && mState == KeyboardState.OPEN) {
/*
When the keyboard is being canceling, for one frame the insets show a keyboard height of 0,
causing a jump of the keyboard. We can avoid it by ignoring that frame and calling
Expand All @@ -38,15 +38,15 @@ public void onAnimationStart() {
if (mActiveTransitionCounter > 0) {
mState = mState == KeyboardState.OPENING ? KeyboardState.CLOSING : KeyboardState.OPENING;
} else {
mState = mHeight == 0 ? KeyboardState.OPENING : KeyboardState.CLOSING;
mState = mHeight <= 0 ? KeyboardState.OPENING : KeyboardState.CLOSING;
}
mActiveTransitionCounter++;
}

public void onAnimationEnd() {
mActiveTransitionCounter--;
if (mActiveTransitionCounter == 0) {
mState = mHeight == 0 ? KeyboardState.CLOSED : KeyboardState.OPEN;
mState = mHeight <= 0 ? KeyboardState.CLOSED : KeyboardState.OPEN;
}
}
}

0 comments on commit 021e751

Please sign in to comment.