Skip to content

Commit

Permalink
refactor: react on tag changes (#216)
Browse files Browse the repository at this point in the history
## 📜 Description

Improved `KeyboardAwareScrollView` example - now it react on `TextInput`
focus changes 🙂

## 💡 Motivation and Context

I highlighted key changes below:

### Interpolation depends on previous keyboard size

Before it was a fixed number (0). But since the size of the keyboard can
be different per different `TextInput` types - we have to interpolate
value from previous keyboard size to the new one. Otherwise the
animation when keyboard changes its size looks slightly ugly (will have
a jump in beginning).

Just as an example let's imagine, that the keyboard is changing size
from `200` to `300` and you need to scroll from `100` to `200`. The
current scroll position is `100` and you interpolate as before from 0 to
300. In this case, when keyboard size grows from 200 to 300 first
scrollTo will scroll to ~160. So 60% of the smooth transition will be
missed 😔

If we interpolate from `previousKeyboardSize`, then we will interpolate
from `200` to `300` (as expected) and we'll have a smooth transition for
all distance.

### Assure `TextInput` is not overlapped by `Keyboard` in `onEnd`
handler

Sometimes `onMove` handler can be missed. It happens in two cases:
- on iOS when TextInput was changed - keyboard transition is instant and
`onMove` will not be triggered;
- on Android when TextInput was changed and keyboard size wasn't
changed.

To handle these cases I've decided to run `scrollTo` in `onEnd` handler.
For plain transition it will not have any effect, because scroll
position already will be as the desired one.

However for cases above it'll handle TextInput focus changes and will
assure, that the `TextInput` is always above the Keyboard 🙂

### Back transition

To assure smooth back transition I've introduced
`scrollBeforeKeyboardMovement` (updated whenever `TextInput` becomes a
focus). Later this variable is used in interpolation, when keyboard
hides.

Also I do a layout substitution in `onEnd` handler:

```tsx
const prevLayout = layout.value;
layout.value = measureByTag(e.target);
// ...
layout.value = prevLayout;
```

This is needed because we need to remember "initial layout" (before
keyboard movement) in order to perform beautiful back transition.

> I'm more than sure, that this implementation is not perfect and still
has some bugs (it is still not clear how to handle a case, when you
scroll TextInput off-screen (under keyboard or under header - which back
transition do we need to apply in this case?)). But it resolves some of
the problems that were reported and shows how to use new API of the
library. So in order to unlock a release process I'm leaving this
implementation as is - maybe later I'll come up with more sophisticated
approach which will handle even more cases, but for now as an example
it's good to go.

## 📢 Changelog

### JS
- added documentation for `KeyboardAwareScrollView` describing the flow
of execution;
- removed combination of `useWorkletCallback` + direct `worklet`
directive;
- interpolate transition based
- run additional `scrollTo` in `onEnd` handler to assure that there will
be no overlap with `TextInput` and `Keyboard` - handles a case when
focus changed, but keyboard size was not changed;
- removed `console.log` that were used for debugging 🙂 

## 🤔 How Has This Been Tested?

Tested manually on:
- iPhone 14 Pro;
- Pixel 7 Pro;

## 📸 Screenshots (if appropriate):

|Before|After|
|------|-----|
|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/f9e41c28-0082-4dad-8495-57e48ee97c74">|<video
src="https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/c43d85ce-0cdb-4bc6-b269-895e3e094ad8">|

## 📝 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko committed Aug 23, 2023
1 parent c32acab commit 84f94eb
Showing 1 changed file with 75 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,44 @@ import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler';

const BOTTOM_OFFSET = 50;

/**
* Everything begins from `onStart` handler. This handler is called every time,
* when keyboard changes its size or when focused `TextInput` was changed. In
* this handler we are calculating/memoizing values which later will be used
* during layout movement. For that we calculate:
* - layout of focused field (`layout`) - to understand whether there will be overlap
* - initial keyboard size (`initialKeyboardSize`) - used in scroll interpolation
* - future keyboard height (`keyboardHeight`) - used in scroll interpolation
* - current scroll position (`scrollPosition`) - used to scroll from this point
*
* Once we've calculated all necessary variables - we can actually start to use them.
* It happens in `onMove` handler - this function simply calls `maybeScroll` with
* current keyboard frame height. This functions makes the smooth transition.
*
* When the transition has finished we go to `onEnd` handler. In this handler
* we verify, that the current field is not overlapped within a keyboard frame.
* For full `onStart`/`onMove`/`onEnd` flow it may look like a redundant thing,
* however there could be some cases, when `onMove` is not called:
* - on iOS when TextInput was changed - keyboard transition is instant
* - on Android when TextInput was changed and keyboard size wasn't changed
* So `onEnd` handler handle the case, when `onMove` wasn't triggered.
*
* ====================================================================================================================+
* -----------------------------------------------------Flow chart-----------------------------------------------------+
* ====================================================================================================================+
*
* +============================+ +============================+ +==================================+
* + User Press on TextInput + => + Keyboard starts showing + => + As keyboard moves frame by frame + =>
* + + + (run `onStart`) + + `onMove` is getting called +
* +============================+ +============================+ +==================================+
*
*
* +============================+ +============================+ +=====================================+
* + Keyboard is shown and we + => + User moved focus to + => + Only `onStart`/`onEnd` maybe called +
* + call `onEnd` handler + + another `TextInput` + + (without involving `onMove`) +
* +============================+ +============================+ +=====================================+
*
*/
const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
children,
...rest
Expand All @@ -27,6 +65,8 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
const fakeViewHeight = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const tag = useSharedValue(-1);
const initialKeyboardSize = useSharedValue(0);
const scrollBeforeKeyboardMovement = useSharedValue(0);

const { height } = useWindowDimensions();

Expand All @@ -47,9 +87,7 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
/**
* Function that will scroll a ScrollView as keyboard gets moving
*/
const maybeScroll = useWorkletCallback((e: number) => {
'worklet';

const maybeScroll = useWorkletCallback((e: number, animated = false) => {
fakeViewHeight.value = e;

const visibleRect = height - keyboardHeight.value;
Expand All @@ -58,13 +96,12 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
if (visibleRect - point <= BOTTOM_OFFSET) {
const interpolatedScrollTo = interpolate(
e,
[0, keyboardHeight.value],
[initialKeyboardSize.value, keyboardHeight.value],
[0, keyboardHeight.value - (height - point) + BOTTOM_OFFSET]
);
const targetScrollY =
Math.max(interpolatedScrollTo, 0) + scrollPosition.value;

scrollTo(scrollViewAnimatedRef, 0, targetScrollY, false);
scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated);
}
}, []);

Expand All @@ -73,31 +110,39 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
onStart: (e) => {
'worklet';

// keyboard will appear
if (e.height > 0 && keyboardHeight.value === 0) {
const keyboardWillChangeSize =
keyboardHeight.value !== e.height && e.height > 0;
const keyboardWillAppear = e.height > 0 && keyboardHeight.value === 0;
const keyboardWillHide = e.height === 0;
if (keyboardWillChangeSize) {
initialKeyboardSize.value = keyboardHeight.value;
}

if (keyboardWillHide) {
// on back transition need to interpolate as [0, keyboardHeight]
initialKeyboardSize.value = 0;
scrollPosition.value = scrollBeforeKeyboardMovement.value;
}

if (keyboardWillAppear || keyboardWillChangeSize) {
// persist scroll value
scrollPosition.value = position.value;
// just persist height - later will be used in interpolation
keyboardHeight.value = e.height;
}

// focus was changed
if (
tag.value !== e.target ||
(keyboardHeight.value !== e.height && e.height > 0)
) {
if (tag.value !== e.target || keyboardWillChangeSize) {
tag.value = e.target;

if (tag.value !== -1) {
// save position of focused text input when keyboard starts to move
layout.value = measureByTag(e.target);
console.log('UPDATED LAYOUT::', layout.value);
// save current scroll position - when keyboard will hide we'll reuse
// this value to achieve smooth hide effect
scrollBeforeKeyboardMovement.value = position.value;
}
}

// keyboard will appear or change its size
if (e.height > 0) {
// just persist height - later will be used in interpolation
keyboardHeight.value = e.height;
}
},
onMove: (e) => {
'worklet';
Expand All @@ -108,6 +153,17 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
'worklet';

keyboardHeight.value = e.height;
scrollPosition.value = position.value;

if (e.target !== -1 && e.height !== 0) {
const prevLayout = layout.value;
// just be sure, that view is no overlapped (i.e. focus changed)
layout.value = measureByTag(e.target);
maybeScroll(e.height, true);
// do layout substitution back to assure there will be correct
// back transition when keyboard hides
layout.value = prevLayout;
}
},
},
[height]
Expand Down

0 comments on commit 84f94eb

Please sign in to comment.