Skip to content

Commit

Permalink
feat: interactive keyboard (android) (#133)
Browse files Browse the repository at this point in the history
## 📜 Description

Added Interactive Keyboard dismissing on Android 11+.

## 💡 Motivation and Context

Initially I planned to control the keyboard properties (`opacity` and
`position`) via animated properties (`Animated.Value`). But it turned
out, that every update of animated value is not scheduled on UI thread
and since we need to call `controller.setInsetsAndAlpha` on UI thread we
need to wrap it in `UiThreadUtil.runOnUiThread`. It works, but since it
creates a new thread too frequently CPU is overused (CPU usage is too
high, as a result battery gets drained faster and animation is not very
smooth).

The second idea was to add JSI `interpolate` function, which can be
called directly on UI thread. I thought about usage of worklets
(`react-native-worklets`/`react-native-reanimated`) or writing pure JSI
function. But in the end I decided to postpone it, since right now I
need to support both (paper and fabric) architectures and it seems like
JSI is changing time to time (depends on a react-native version) and I
thought that it may be complicated to support such code.

Taking everything from above into consideration I've decided to
implement `interpolate` function in a native language (kotlin). It works
well for both architectures, since there is no direct communication
between JS and Native thread, though it brings own restrictions, such as
limited customization (you have only two pre-defined variants of the
function and you can not write your own implementation in JS code).
However I think to rewrite it to JSI in the future to make it more
customizable.

## 📢 Changelog

### JS
- added specs for new `KeyboardGestureArea` component;
- added example (paper&fabric) screen;
- added `onKeyboardMoveInteractive` view callback and `onInteractive`
handler for `useKeyboardHandler` hook;

### Android
- added `KeyboardGestureArea` view;
- changed target api to 33;
- added `androidx.dynamicanimation:dynamicanimation-ktx` library;
- added `InteractiveKeyboardProvider` which acts as a singleton to store
keyboard state;
- added `Interpolator` interface and `IosInterpolator` and
`LinearInterpolator` implementation;

## 🤔 How Has This Been Tested?

Tested manually on:
- Pixel 7 Pro (Android 13, real device);
- Pixel 3A (Android 13, emulator);

## 📸 Screenshots (if appropriate):

|linear|iOS|
|------|---|
|<video
src="https://user-images.githubusercontent.com/22820318/229305276-fe666f30-90c3-4cf0-9197-16bf8d7c7c89.mp4"
/>|<video
src="https://user-images.githubusercontent.com/22820318/229305352-9927ff93-7c9a-4fd3-95cb-73627cee77f2.mp4"
/>|

## 📝 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko authored Apr 3, 2023
1 parent f117cf9 commit b2e70e5
Show file tree
Hide file tree
Showing 33 changed files with 1,153 additions and 17 deletions.
1 change: 1 addition & 0 deletions FabricExample/src/constants/screenNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export enum ScreenNames {
EXAMPLES_STACK = 'EXAMPLES_STACK',
EXAMPLES = 'EXAMPLES',
NON_UI_PROPS = 'NON_UI_PROPS',
INTERACTIVE_KEYBOARD = 'INTERACTIVE_KEYBOARD',
}
10 changes: 10 additions & 0 deletions FabricExample/src/navigation/ExamplesStack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Events from '../../screens/Examples/Events';
import AwareScrollView from '../../screens/Examples/AwareScrollView';
import StatusBar from '../../screens/Examples/StatusBar';
import NonUIProps from '../../screens/Examples/NonUIProps';
import InteractiveKeyboard from '../../screens/Examples/InteractiveKeyboard';

export type ExamplesStackParamList = {
[ScreenNames.ANIMATED_EXAMPLE]: undefined;
Expand All @@ -17,6 +18,7 @@ export type ExamplesStackParamList = {
[ScreenNames.AWARE_SCROLL_VIEW]: undefined;
[ScreenNames.STATUS_BAR]: undefined;
[ScreenNames.NON_UI_PROPS]: undefined;
[ScreenNames.INTERACTIVE_KEYBOARD]: undefined;
};

const Stack = createStackNavigator<ExamplesStackParamList>();
Expand All @@ -41,6 +43,9 @@ const options = {
[ScreenNames.NON_UI_PROPS]: {
title: 'Non UI Props',
},
[ScreenNames.INTERACTIVE_KEYBOARD]: {
title: 'Interactive keyboard',
},
};

const ExamplesStack = () => (
Expand Down Expand Up @@ -75,6 +80,11 @@ const ExamplesStack = () => (
component={NonUIProps}
options={options[ScreenNames.NON_UI_PROPS]}
/>
<Stack.Screen
name={ScreenNames.INTERACTIVE_KEYBOARD}
component={InteractiveKeyboard}
options={options[ScreenNames.INTERACTIVE_KEYBOARD]}
/>
</Stack.Navigator>
);

Expand Down
108 changes: 108 additions & 0 deletions FabricExample/src/screens/Examples/InteractiveKeyboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { StackScreenProps } from '@react-navigation/stack';
import React, { useEffect, useState } from 'react';
import { Text, TextInput, View } from 'react-native';
import {
KeyboardGestureArea,
useKeyboardHandler,
} from 'react-native-keyboard-controller';
import Reanimated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';

import Message from '../../../components/Message';
import { history } from '../../../components/Message/data';
import { ExamplesStackParamList } from '../../../navigation/ExamplesStack';
import styles from './styles';

const AnimatedTextInput = Reanimated.createAnimatedComponent(TextInput);

const useKeyboardAnimation = () => {
const progress = useSharedValue(0);
const height = useSharedValue(0);
useKeyboardHandler({
onMove: (e) => {
'worklet';

progress.value = e.progress;
height.value = e.height;
},
onInteractive: (e) => {
'worklet';

progress.value = e.progress;
height.value = e.height;
},
});

return { height, progress };
};

type Props = StackScreenProps<ExamplesStackParamList>;

function InteractiveKeyboard({ navigation }: Props) {
const [interpolator, setInterpolator] = useState<'ios' | 'linear'>('linear');
const { height } = useKeyboardAnimation();

useEffect(() => {
navigation.setOptions({
headerRight: () => (
<Text
style={styles.header}
onPress={() =>
setInterpolator(interpolator === 'ios' ? 'linear' : 'ios')
}
>
{interpolator}
</Text>
),
});
}, [interpolator]);

const scrollViewStyle = useAnimatedStyle(
() => ({
transform: [{ translateY: -height.value }, ...styles.inverted.transform],
}),
[]
);
const textInputStyle = useAnimatedStyle(
() => ({
height: 50,
width: '100%',
backgroundColor: '#BCBCBC',
transform: [{ translateY: -height.value }],
}),
[]
);
const fakeView = useAnimatedStyle(
() => ({
height: height.value,
}),
[]
);

return (
<View style={styles.container}>
<KeyboardGestureArea
style={styles.content}
interpolator={interpolator}
allowToShowKeyboardFromHiddenStateBySwipeUp
>
<Reanimated.ScrollView
showsVerticalScrollIndicator={false}
style={scrollViewStyle}
>
<View style={styles.inverted}>
<Reanimated.View style={fakeView} />
{history.map((message, index) => (
<Message key={index} {...message} />
))}
</View>
</Reanimated.ScrollView>
</KeyboardGestureArea>
<AnimatedTextInput style={textInputStyle} />
</View>
);
}

export default InteractiveKeyboard;
22 changes: 22 additions & 0 deletions FabricExample/src/screens/Examples/InteractiveKeyboard/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { StyleSheet } from 'react-native';

export default StyleSheet.create({
container: {
justifyContent: 'flex-end',
flex: 1,
},
header: {
color: 'black',
marginRight: 12,
},
inverted: {
transform: [
{
rotate: '180deg',
},
],
},
content: {
flex: 1,
},
});
5 changes: 5 additions & 0 deletions FabricExample/src/screens/Examples/Main/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ export const examples: Example[] = [
info: ScreenNames.NON_UI_PROPS,
icons: '🚀',
},
{
title: 'Interactive keyboard (WIP)',
info: ScreenNames.INTERACTIVE_KEYBOARD,
icons: '👆📱',
},
];
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Keyboard manager which works in identical way on both iOS and Android.
- missing `keyboardWillShow` / `keyboardWillHide` events are available on Android 😍
- module for changing soft input mode on Android 🤔
- reanimated support 🚀
- interactive keyboard dismissing (planned) 👆📱
- interactive keyboard dismissing (WIP) 👆📱
- and more is coming... Stay tuned! 😊

## Installation
Expand Down
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,5 @@ dependencies {
}
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.5.0-beta03'
implementation 'androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03'
}
4 changes: 2 additions & 2 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
KeyboardController_kotlinVersion=1.6.21
KeyboardController_compileSdkVersion=29
KeyboardController_targetSdkVersion=29
KeyboardController_compileSdkVersion=33
KeyboardController_targetSdkVersion=33

android.useAndroidX=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.reactnativekeyboardcontroller

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.KeyboardGestureAreaManagerDelegate
import com.facebook.react.viewmanagers.KeyboardGestureAreaManagerInterface
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.react.views.view.ReactViewManager
import com.reactnativekeyboardcontroller.managers.KeyboardGestureAreaViewManagerImpl
import com.reactnativekeyboardcontroller.views.KeyboardGestureAreaReactViewGroup

class KeyboardGestureAreaViewManager(mReactContext: ReactApplicationContext) :
ReactViewManager(),
KeyboardGestureAreaManagerInterface<ReactViewGroup> {
private val manager = KeyboardGestureAreaViewManagerImpl(mReactContext)
private val mDelegate = KeyboardGestureAreaManagerDelegate(this)

override fun getDelegate(): ViewManagerDelegate<ReactViewGroup?> {
return mDelegate
}

override fun getName(): String = KeyboardGestureAreaViewManagerImpl.NAME

override fun createViewInstance(context: ThemedReactContext): KeyboardGestureAreaReactViewGroup {
return manager.createViewInstance(context)
}

@ReactProp(name = "interpolator")
override fun setInterpolator(view: ReactViewGroup, value: String?) {
manager.setInterpolator(view as KeyboardGestureAreaReactViewGroup, value ?: "linear")
}

@ReactProp(name = "allowToShowKeyboardFromHiddenStateBySwipeUp")
override fun setAllowToShowKeyboardFromHiddenStateBySwipeUp(
view: ReactViewGroup,
value: Boolean,
) {
manager.setScrollKeyboardOnScreenWhenNotVisible(view as KeyboardGestureAreaReactViewGroup, value)
}

@ReactProp(name = "allowToDragKeyboardFromShownStateBySwipes")
override fun setAllowToDragKeyboardFromShownStateBySwipes(view: ReactViewGroup?, value: Boolean) {
manager.setScrollKeyboardOffScreenWhenVisible(view as KeyboardGestureAreaReactViewGroup, value)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.reactnativekeyboardcontroller

object InteractiveKeyboardProvider {
var shown = false
var isInteractive = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class KeyboardAnimationCallback(
// having such check allows us not to dispatch unnecessary incorrect events
// the condition will be executed only when keyboard is opened and changes its size
// (for example it happens when user changes keyboard type from 'text' to 'emoji' input
if (isKeyboardVisible && isKeyboardVisible() && !isTransitioning && Build.VERSION.SDK_INT >= 30) {
if (isKeyboardVisible && isKeyboardVisible() && !isTransitioning && Build.VERSION.SDK_INT >= 30 && !InteractiveKeyboardProvider.isInteractive) {
val keyboardHeight = getCurrentKeyboardHeight()

this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight))
Expand Down Expand Up @@ -133,9 +133,10 @@ class KeyboardAnimationCallback(
} catch (e: ArithmeticException) {
// do nothing, send progress as 0
}
Log.i(TAG, "DiffY: $diffY $height $progress")
Log.i(TAG, "DiffY: $diffY $height $progress ${InteractiveKeyboardProvider.isInteractive}")

this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", height, progress))
val event = if (InteractiveKeyboardProvider.isInteractive) "topKeyboardMoveInteractive" else "topKeyboardMove"
this.sendEventToJS(KeyboardTransitionEvent(view.id, event, height, progress))

return insets
}
Expand All @@ -144,7 +145,19 @@ class KeyboardAnimationCallback(
super.onEnd(animation)

isTransitioning = false
this.persistentKeyboardHeight = getCurrentKeyboardHeight()
// if keyboard becomes shown after interactive animation completion
// getCurrentKeyboardHeight() will be `0` and isKeyboardVisible will be `false`
// it's not correct behavior, so we are handling it here
val isKeyboardShown = InteractiveKeyboardProvider.shown
if (!isKeyboardShown) {
this.persistentKeyboardHeight = getCurrentKeyboardHeight()
} else {
// if keyboard is shown after interactions and the animation has finished
// then we need to reset the state
InteractiveKeyboardProvider.shown = false
}
val isKeyboardVisible = isKeyboardVisible || isKeyboardShown

this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow", getEventParams(this.persistentKeyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", this.persistentKeyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0))
}
Expand Down
Loading

0 comments on commit b2e70e5

Please sign in to comment.