Skip to content

Commit

Permalink
[RNKC-061] - introduce low-level useKeyboardHandler hook (#87)
Browse files Browse the repository at this point in the history
## 📜 Description

Added new hook `useKeyboardHandler` that has gradual `height`/`progress`
values on iOS. Also it has a little bit different API than already
existing hooks. See **Motivation and Context** section to get more
insights of why this approach was chosen.

## Related issues
-
#85
-
#56

## 💡 Motivation and Context

The current limitation of this library on iOS was the fact, that
`height` and `progress` values didn't have intermediate values. So if
you are trying to animate NonUiProps, such as `height`, `width` etc. -
you get instant transitions, that looks not well. That's where
`useKeyboardHandler` comes into play.

This hook allows you to get intermediate values. Unfortunately values in
`onMove` handler are not perfectly synchronised with the keyboard
positions (I did a big research on how to get intermediate values and a
little bit later I will open a new issue describing all nuances I
found). Since these values are not synchronized - it makes it impossible
to add them in already existing `useKeyboardAnimation` hooks, since all
chat-like apps will have unsynchronised keyboard/text input animations
and most of all iOS users will notice it.

That's why I decided to go in a little bit different way and introduce
new hook. In my opinion this hook gives minimal (but at the same time
very powerful) API. You can control every aspect of the keyboard
movement, detect start/end of the animations and get intermediate
values.

Later I will write a new docs for this hook where I will highlight all
nuances of the usage. Current iOS implementation was highly inspired by
https://github.com/JunyuKuang/KeyboardUtility

## 📢 Changelog

### JS
- added two new methods for `KeyboardControllerView`:
`onKeyboardMoveStart` and `onKeyboardMoveEnd`;
- added new hook `useKeyboardHandler`;
- added new example;

### iOS
- watch keyboard position during animation via `CADisplayLink`;

## 🤔 How Has This Been Tested?

Tested manually on:
- iPhone 11 (iOS 15.5);
- Pixel 3 (API 32);

## 📸 Screenshots (if appropriate):

### Lottie example (iOS)

|Before|After|
|-------|-----|
|<video
src="https://user-images.githubusercontent.com/22820318/195279279-b1284f86-3053-4ef8-a3d2-b18741677f06.mp4">|<video
src="https://user-images.githubusercontent.com/22820318/195278696-278a81ed-9893-4c63-82e3-8372c76be4c7.mp4">|

### Non UI props

|Before|After|
|------|-----|
|<video
src="https://user-images.githubusercontent.com/22820318/195279766-777da551-a81d-40f2-be38-f9a1bda298f5.mp4">|<video
src="https://user-images.githubusercontent.com/22820318/195279862-c5a07328-b226-4578-93bf-057d3a6740cb.mp4">|

## 📝 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko committed Oct 13, 2022
1 parent 7d3c7c4 commit a603416
Show file tree
Hide file tree
Showing 26 changed files with 477 additions and 50 deletions.
1 change: 1 addition & 0 deletions FabricExample/src/constants/screenNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export enum ScreenNames {
STATUS_BAR = 'STATUS_BAR',
EXAMPLES_STACK = 'EXAMPLES_STACK',
EXAMPLES = 'EXAMPLES',
NON_UI_PROPS = 'NON_UI_PROPS',
}
10 changes: 10 additions & 0 deletions FabricExample/src/navigation/ExamplesStack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import ReanimatedChat from '../../screens/Examples/ReanimatedChat';
import Events from '../../screens/Examples/Events';
import AwareScrollView from '../../screens/Examples/AwareScrollView';
import StatusBar from '../../screens/Examples/StatusBar';
import NonUIProps from '../../screens/Examples/NonUIProps';

type ExamplesStackParamList = {
[ScreenNames.ANIMATED_EXAMPLE]: undefined;
[ScreenNames.REANIMATED_CHAT]: undefined;
[ScreenNames.EVENTS]: undefined;
[ScreenNames.AWARE_SCROLL_VIEW]: undefined;
[ScreenNames.STATUS_BAR]: undefined;
[ScreenNames.NON_UI_PROPS]: undefined;
};

const Stack = createStackNavigator<ExamplesStackParamList>();
Expand All @@ -36,6 +38,9 @@ const options = {
headerShown: false,
title: 'Status bar manipulation',
},
[ScreenNames.NON_UI_PROPS]: {
title: 'Non UI Props',
},
};

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

Expand Down
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 @@ -19,4 +19,9 @@ export const examples: Example[] = [
info: ScreenNames.STATUS_BAR,
icons: '🔋',
},
{
title: 'Non UI Props',
info: ScreenNames.NON_UI_PROPS,
icons: '🚀',
},
];
57 changes: 57 additions & 0 deletions FabricExample/src/screens/Examples/NonUIProps/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { View } from 'react-native';
import { TextInput } from 'react-native-gesture-handler';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import Reanimated, {
interpolate,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';

function useGradualKeyboardAnimation() {
const height = useSharedValue(0);
const progress = useSharedValue(0);

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

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

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

return { height, progress };
}

function NonUIProps() {
const { height, progress } = useGradualKeyboardAnimation();

const rStyle = useAnimatedStyle(() => {
return {
backgroundColor: 'gray',
height: height.value,
width: interpolate(progress.value, [0, 1], [100, 200]),
};
});

return (
<View style={{ flex: 1 }}>
<TextInput
style={{ width: '100%', height: 50, backgroundColor: 'red' }}
/>
<Reanimated.View style={rStyle} />
</View>
);
}

export default NonUIProps;
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,16 @@ class KeyboardAnimationCallback(
val keyboardHeight = getCurrentKeyboardHeight()

this.emitEvent("KeyboardController::keyboardWillShow", getEventParams(keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, 1.0))

val animation = ValueAnimator.ofInt(-this.persistentKeyboardHeight, -keyboardHeight)
val animation = ValueAnimator.ofInt(this.persistentKeyboardHeight, keyboardHeight)
animation.addUpdateListener { animator ->
val toValue = animator.animatedValue as Int
this.sendEventToJS(KeyboardTransitionEvent(view.id, toValue, -toValue.toDouble() / keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", toValue, toValue.toDouble() / keyboardHeight))
}
animation.doOnEnd {
this.emitEvent("KeyboardController::keyboardDidShow", getEventParams(keyboardHeight))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveEnd", keyboardHeight, 1.0))
}
animation.setDuration(250).startDelay = 0
animation.start()
Expand All @@ -108,6 +110,7 @@ class KeyboardAnimationCallback(
this.emitEvent("KeyboardController::" + if (!isKeyboardVisible) "keyboardWillHide" else "keyboardWillShow", getEventParams(keyboardHeight))

Log.i(TAG, "HEIGHT:: $keyboardHeight")
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMoveStart", keyboardHeight, if (!isKeyboardVisible) 0.0 else 1.0))

return super.onStart(animation, bounds)
}
Expand All @@ -128,7 +131,7 @@ class KeyboardAnimationCallback(
val diff = Insets.subtract(typesInset, otherInset).let {
Insets.max(it, Insets.NONE)
}
val diffY = (diff.top - diff.bottom).toFloat()
val diffY = (diff.bottom - diff.top).toFloat()
val height = toDp(diffY, context)

var progress = 0.0
Expand All @@ -139,7 +142,7 @@ class KeyboardAnimationCallback(
}
Log.i(TAG, "DiffY: $diffY $height $progress")

this.sendEventToJS(KeyboardTransitionEvent(view.id, height, progress))
this.sendEventToJS(KeyboardTransitionEvent(view.id, "topKeyboardMove", height, progress))

return insets
}
Expand All @@ -150,6 +153,7 @@ class KeyboardAnimationCallback(
isTransitioning = false
this.persistentKeyboardHeight = getCurrentKeyboardHeight()
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))
}

private fun isKeyboardVisible(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.common.MapBuilder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.reactnativekeyboardcontroller.events.KeyboardTransitionEvent

class KeyboardControllerViewManagerImpl(reactContext: ReactApplicationContext) {
private val TAG = KeyboardControllerViewManagerImpl::class.qualifiedName
Expand Down Expand Up @@ -58,8 +57,12 @@ class KeyboardControllerViewManagerImpl(reactContext: ReactApplicationContext) {

fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
val map: MutableMap<String, Any> = MapBuilder.of(
KeyboardTransitionEvent.EVENT_NAME,
MapBuilder.of("registrationName", "onKeyboardMove")
"topKeyboardMove",
MapBuilder.of("registrationName", "onKeyboardMove"),
"topKeyboardMoveStart",
MapBuilder.of("registrationName", "onKeyboardMoveStart"),
"topKeyboardMoveEnd",
MapBuilder.of("registrationName", "onKeyboardMoveEnd"),
)

return map
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.RCTEventEmitter

class KeyboardTransitionEvent(private val viewId: Int, private val height: Int, private val progress: Double) : Event<KeyboardTransitionEvent>(viewId) {
override fun getEventName() = EVENT_NAME
class KeyboardTransitionEvent(viewId: Int, private val event: String, private val height: Int, private val progress: Double) : Event<KeyboardTransitionEvent>(viewId) {
override fun getEventName() = event

// TODO: All events for a given view can be coalesced?
override fun getCoalescingKey(): Short = 0
Expand All @@ -16,8 +16,4 @@ class KeyboardTransitionEvent(private val viewId: Int, private val height: Int,
map.putInt("height", height)
rctEventEmitter.receiveEvent(viewTag, eventName, map)
}

companion object {
const val EVENT_NAME = "topKeyboardMove"
}
}
1 change: 1 addition & 0 deletions example/src/constants/screenNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export enum ScreenNames {
LOTTIE = 'LOTTIE',
EXAMPLES_STACK = 'EXAMPLES_STACK',
EXAMPLES = 'EXAMPLES',
NON_UI_PROPS = 'NON_UI_PROPS',
}
10 changes: 10 additions & 0 deletions example/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 LottieAnimation from '../../screens/Examples/Lottie';
import NonUIProps from '../../screens/Examples/NonUIProps';

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

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

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

Expand Down
45 changes: 34 additions & 11 deletions example/src/screens/Examples/Lottie/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React from 'react';
import { StyleSheet, TextInput, View } from 'react-native';
import Lottie from 'lottie-react-native';
import { useKeyboardAnimation } from 'react-native-keyboard-controller';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import Reanimated, {
interpolate,
useAnimatedProps,
useSharedValue,
} from 'react-native-reanimated';

// animation is taken from lottie public animations: https://lottiefiles.com/46216-lock-debit-card-morph
import LockDebitCardMorph from './animation.json';
Expand All @@ -22,23 +27,41 @@ const styles = StyleSheet.create({
},
});

const ReanimatedLottieView = Reanimated.createAnimatedComponent(Lottie);

function LottieAnimation() {
const { progress } = useKeyboardAnimation();

const animation = progress.interpolate({
inputRange: [0, 1],
// 104 - total frames
// 7 frame - transition begins
// 35 frame - transition ends
outputRange: [7 / 104, 35 / 104],
const progress = useSharedValue(0);

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

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

const animatedProps = useAnimatedProps(() => {
return {
progress: interpolate(
progress.value,
[0, 1],
// 104 - total frames
// 7 frame - transition begins
// 35 frame - transition ends
[7 / 104, 35 / 104]
),
};
});

return (
<View style={styles.container}>
<Lottie
<ReanimatedLottieView
style={styles.lottie}
source={LockDebitCardMorph}
progress={animation}
animatedProps={animatedProps}
/>
<TextInput style={styles.input} />
</View>
Expand Down
5 changes: 5 additions & 0 deletions example/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.LOTTIE,
icons: '⚠️ 🎞',
},
{
title: 'Non UI Props',
info: ScreenNames.NON_UI_PROPS,
icons: '🚀',
},
];
57 changes: 57 additions & 0 deletions example/src/screens/Examples/NonUIProps/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { View } from 'react-native';
import { TextInput } from 'react-native-gesture-handler';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import Reanimated, {
interpolate,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';

function useGradualKeyboardAnimation() {
const height = useSharedValue(0);
const progress = useSharedValue(0);

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

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

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

return { height, progress };
}

function NonUIProps() {
const { height, progress } = useGradualKeyboardAnimation();

const rStyle = useAnimatedStyle(() => {
return {
backgroundColor: 'gray',
height: height.value,
width: interpolate(progress.value, [0, 1], [100, 200]),
};
});

return (
<View style={{ flex: 1 }}>
<TextInput
style={{ width: '100%', height: 50, backgroundColor: 'red' }}
/>
<Reanimated.View style={rStyle} />
</View>
);
}

export default NonUIProps;
4 changes: 4 additions & 0 deletions ios/.swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
disabled_rules:
- trailing_comma

line_length:
warning: 120
ignores_urls: true

excluded:
- Pods
Loading

0 comments on commit a603416

Please sign in to comment.