Skip to content

Commit

Permalink
chore: port KeyboardAwareScrollView to Fabric (#220)
Browse files Browse the repository at this point in the history
## 📜 Description

Ported `KeyboardAwareScrollView` example to `FabricExample` app.

## 💡 Motivation and Context

Basically I ported paper example to fabric. However there is two main
differences between implementations.

### `measure` expect shadow node instance

You can not call `measure(() => tag)` as in paper, because it expects to
see an associated instance of `ShadowNode`. There was two ways how to
achieve that:
- use `findShadowNodeByTag_DEPRECATED` - but it's deprecated and it's
obviously that better not to use (basically this method go through all
views until it finds a view for a given `tag` - I think it may hit
performance issues as well);
- take `ShadowNode` from a `ref` of `TextInput`.

I've decided to stick with 2nd approach (just because it's not
deprecated). For that purposes I added context where I store a map like:

```ts
{
  [tag]: () => shadowNodeOrTag
}
```

In `KeyboardAwarescrollView` I consume this context and extract function
by `viewTag`. After that this construction can be passed to `measure`
function.

> The implementation is cross-platform (i. e. it can be run on paper
too, but I've decided not to have identical code for now - I think the
approach will be changed anyway and in example I want to keep as simple
as possible example). After some tie I hope these examples will be
unified and will be less complex than now.

### `measure` doesn't measure layout in window

It seems like `measure` works differently across architectures. On Paper
it measures element within window boundaries, however on Fabric
architecture it measures relatively to ViewContainer (doesn't take
header height into consideration). I handled it by adding a simple
check, but in future we'll need to rework it, because right now it's not
very convenient to depend on other libraries.

> I know all this stuff looks complicated, but I've already did some
research on how to simplify the integration across architectures. I'm
just merging this PR as an example on how to use new API. Most likely
later the approach will be changed and we'll not need to add additional
context/conditionally add height of other UI elements etc.

## 📢 Changelog

### JS
- added `AwareScrollViewContext` context where I store map described
above;
- added internal `useAwareScrollView` hook that incapsulate TextInput
registration in provider + provides `measure` method;
- copied/pasted code from `example` to `FabricExample`

## 🤔 How Has This Been Tested?

Tested only on iPhone 14 Pro (iOS 16.4), Android lead to ANR (most
likely because of `scrollTo` on Fabric).

## 📸 Screenshots (if appropriate):


https://github.com/kirillzyusko/react-native-keyboard-controller/assets/22820318/8d7a7bac-783e-44b2-a316-b30c3ef43900

## 📝 Checklist

- [x] CI successfully passed
  • Loading branch information
kirillzyusko committed Aug 24, 2023
1 parent fc7dee9 commit 2cf490a
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 41 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import React, { FC, useCallback } from 'react';
import {
GestureResponderEvent,
ScrollViewProps,
useWindowDimensions,
} from 'react-native';
import { useResizeMode } from 'react-native-keyboard-controller';
import React, { FC } from 'react';
import { ScrollViewProps, useWindowDimensions } from 'react-native';
import Reanimated, {
MeasuredDimensions,
interpolate,
scrollTo,
useAnimatedRef,
Expand All @@ -15,23 +11,66 @@ import Reanimated, {
useWorkletCallback,
} from 'react-native-reanimated';
import { useSmoothKeyboardHandler } from './useSmoothKeyboardHandler';
import { AwareScrollViewProvider, useAwareScrollView } from './context';

const BOTTOM_OFFSET = 50;

const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
type KeyboardAwareScrollViewProps = ScrollViewProps;

/**
* 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<KeyboardAwareScrollViewProps> = ({
children,
...rest
}) => {
useResizeMode();

const scrollViewAnimatedRef = useAnimatedRef<Reanimated.ScrollView>();
const scrollPosition = useSharedValue(0);
const click = useSharedValue(0);
const position = useSharedValue(0);
const layout = useSharedValue<MeasuredDimensions | null>(null);
const fakeViewHeight = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const tag = useSharedValue(-1);
const initialKeyboardSize = useSharedValue(0);
const scrollBeforeKeyboardMovement = useSharedValue(0);

const { height } = useWindowDimensions();
const { measure } = useAwareScrollView();

const onScroll = useAnimatedScrollHandler(
{
Expand All @@ -42,34 +81,23 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
[]
);

const onContentTouch = useCallback((e: GestureResponderEvent) => {
// to prevent clicks when keyboard is animating
if (keyboardHeight.value === 0) {
click.value = e.nativeEvent.pageY;
scrollPosition.value = position.value;
}
}, []);

/**
* 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;
const point = (layout.value?.pageY || 0) + (layout.value?.height || 0);

if (visibleRect - click.value <= BOTTOM_OFFSET) {
if (visibleRect - point <= BOTTOM_OFFSET) {
const interpolatedScrollTo = interpolate(
e,
[0, keyboardHeight.value],
[0, keyboardHeight.value - (height - click.value) + BOTTOM_OFFSET]
[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 @@ -78,10 +106,39 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
onStart: (e) => {
'worklet';

if (e.height > 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 || keyboardWillChangeSize) {
tag.value = e.target;

if (tag.value !== -1) {
// save position of focused text input when keyboard starts to move
layout.value = measure(e.target);
// save current scroll position - when keyboard will hide we'll reuse
// this value to achieve smooth hide effect
scrollBeforeKeyboardMovement.value = position.value;
}
}
},
onMove: (e) => {
'worklet';
Expand All @@ -92,6 +149,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 = measure(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 All @@ -109,7 +177,6 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
ref={scrollViewAnimatedRef}
{...rest}
onScroll={onScroll}
onTouchStart={onContentTouch}
scrollEventThrottle={16}
>
{children}
Expand All @@ -118,4 +185,10 @@ const KeyboardAwareScrollView: FC<ScrollViewProps> = ({
);
};

export default KeyboardAwareScrollView;
export default function (props: KeyboardAwareScrollViewProps) {
return (
<AwareScrollViewProvider>
<KeyboardAwareScrollView {...props} />
</AwareScrollViewProvider>
);
}
29 changes: 29 additions & 0 deletions FabricExample/src/screens/Examples/AwareScrollView/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import { TextInputProps, TextInput as TextInputRN } from 'react-native';
import { randomColor } from '../../../utils';
import { useAwareScrollView } from './context';

const TextInput = React.forwardRef((props: TextInputProps, forwardRef) => {
const { onRef } = useAwareScrollView();

return (
<TextInputRN
ref={(ref) => {
onRef(ref);
if (typeof forwardRef === 'function') {
forwardRef(ref);
}
}}
placeholderTextColor="black"
style={{
width: '100%',
height: 50,
backgroundColor: randomColor(),
marginTop: 50,
}}
{...props}
/>
);
});

export default TextInput;
102 changes: 102 additions & 0 deletions FabricExample/src/screens/Examples/AwareScrollView/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
declare const _IS_FABRIC: boolean;
import React, {
Component,
RefObject,
useContext,
useMemo,
useRef,
} from 'react';
import { TextInput, findNodeHandle } from 'react-native';
import {
useWorkletCallback,
measure as measureREA,
useSharedValue,
} from 'react-native-reanimated';
import { useHeaderHeight } from '@react-navigation/elements';

type KeyboardAwareContext = {
handlersRef: {
current: Record<string, RefObject<Component>>;
};
handlers: {
value: Record<string, RefObject<Component>>;
};
};

const defaultValue: KeyboardAwareContext = {
handlersRef: { current: {} },
handlers: { value: {} },
};

const AwareScrollViewContext = React.createContext(defaultValue);

export const useAwareScrollView = () => {
const ctx = useContext(AwareScrollViewContext);
const headerHeight = useHeaderHeight();

const onRef = (ref: TextInput | null) => {
const viewTag = findNodeHandle(ref);
if (viewTag) {
const viewTagOrShadowNode = _IS_FABRIC
? // @ts-expect-error this API doesn't have any types
ref._internalInstanceHandle.stateNode.node
: viewTag;
ctx.handlersRef.current = {
...ctx.handlersRef.current,
[viewTag]: () => {
'worklet';

return viewTagOrShadowNode;
},
};
ctx.handlers.value = ctx.handlersRef.current;
}
};

const measure = useWorkletCallback(
(tag: number) => {
const ref = ctx.handlers.value[tag];

if (ref) {
const layout = measureREA(ref);
if (layout) {
return {
...layout,
pageY: _IS_FABRIC ? layout.pageY + headerHeight : layout.pageY,
};
} else {
return null;
}
} else {
return null;
}
},
[headerHeight]
);

return {
onRef,
measure,
};
};

export const AwareScrollViewProvider: React.FC<React.PropsWithChildren<{}>> = ({
children,
}) => {
const handlersRef = useRef({});
const handlers = useSharedValue({});

const value = useMemo(
() => ({
handlersRef,
handlers,
}),
[]
);

return (
<AwareScrollViewContext.Provider value={value}>
{children}
</AwareScrollViewContext.Provider>
);
};
12 changes: 2 additions & 10 deletions FabricExample/src/screens/Examples/AwareScrollView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import React from 'react';
import { TextInput } from 'react-native';
import { useResizeMode } from 'react-native-keyboard-controller';

import { randomColor } from '../../../utils';

import KeyboardAwareScrollView from './KeyboardAwareScrollView';
import TextInput from './TextInput';
import { styles } from './styles';

export default function AwareScrollView() {
Expand All @@ -16,13 +14,7 @@ export default function AwareScrollView() {
<TextInput
key={i}
placeholder={`${i}`}
placeholderTextColor="black"
style={{
width: '100%',
height: 50,
backgroundColor: randomColor(),
marginTop: 50,
}}
keyboardType={i % 2 === 0 ? 'numeric' : 'default'}
/>
))}
</KeyboardAwareScrollView>
Expand Down

0 comments on commit 2cf490a

Please sign in to comment.