Skip to content
This repository has been archived by the owner on May 7, 2022. It is now read-only.

Commit

Permalink
Feature/optimization (#11)
Browse files Browse the repository at this point in the history
* Optimize rerenders

* Move panHandlers to the library
  • Loading branch information
demchenkoalex authored Oct 23, 2020
1 parent c01a340 commit b3c6898
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 131 deletions.
54 changes: 15 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,50 +14,28 @@ Keyboard accessory (sticky) view for your React Native app. Supports interactive

This library depends on `react-native-safe-area-context`. If you use [React Navigation](https://reactnavigation.org) you probably already have it in your dependencies, so you're good to go. If not, please follow the instructions [here](https://github.com/th3rdwave/react-native-safe-area-context) to install it. Then run:

```bash
```sh
yarn add @flyerhq/react-native-keyboard-accessory-view
```

## Usage

```TypeScript
import {
KeyboardAccessoryView,
usePanResponder,
} from '@flyerhq/react-native-keyboard-accessory-view'
```ts
import { KeyboardAccessoryView } from '@flyerhq/react-native-keyboard-accessory-view'
import { GestureResponderHandlers } from 'react-native'
// ...
const { panHandlers, positionY } = usePanResponder()
const [contentBottomInset, setContentBottomInset] = useState(0)
const renderScrollable = (panHandlers: GestureResponderHandlers) => (
// Can be anything scrollable
<ScrollView keyboardDismissMode='interactive' {...panHandlers} />
)
// ...
return (
<>
// Can be anything scrollable
<ScrollView
contentContainerStyle={{
paddingBottom: contentBottomInset,
}}
keyboardDismissMode='interactive'
scrollIndicatorInsets={{ bottom: contentBottomInset }}
{...panHandlers}
/>
<KeyboardAccessoryView
onContentBottomInsetUpdate={setContentBottomInset}
panResponderPositionY={positionY}
>
// Your accessory view
</KeyboardAccessoryView>
</>
<KeyboardAccessoryView renderScrollable={renderScrollable}>
// Your accessory view
</KeyboardAccessoryView>
)
```

Let's break it down.

`usePanResponder` is used to track a finger position on a screen and to adjust `KeyboardAccessoryView` bottom position to go with a keyboard interactive dismiss. Under the hood, it creates [PanResponder](https://reactnative.dev/docs/panresponder) which tracks the Y position. It returns this position and `panHandlers` which should be spread under anything scrollable (e.g. `ScrollView`, `FlatList`). On Android, an empty object is returned in `panHandlers` since we don't have interactive dismiss and don't want to add unnecessary responders.

`KeyboardAccessoryView` optionally accepts `panResponderPositionY` returned from the `usePanResponder` hook. If you don't provide this value, the accessory view will not stick to the keyboard during interactive dismiss, but it will still work correctly on keyboard show/hide events. **If you don't need interactive dismiss support you don't need to provide `panResponderPositionY` and use `usePanResponder` hook.**

Additionally, `KeyboardAccessoryView` provides a `onContentBottomInsetUpdate` callback which can be used to position scrollable content above the keyboard.

### Handling wrong offsets

Sometimes when you use a tab bar or similar component, the accessory view does not work correctly. In order to fix this, you need to use a combination of next props: `contentContainerStyle`, `contentOffsetKeyboardClosed`, `contentOffsetKeyboardOpened` and `spaceBetweenKeyboardAndAccessoryView`.
Expand All @@ -66,25 +44,23 @@ First of all, you need to decide if you need this extra safe area margin at the

When the first step is done, you need to check if you have a space between the accessory view and the keyboard, when the latter is opened. If you do, pass the offset to the `spaceBetweenKeyboardAndAccessoryView` prop. Usually, it can be calculated based on a bottom safe area inset from [react-native-safe-area-context](https://github.com/th3rdwave/react-native-safe-area-context) and/or the height of the tab bar, for example.

Lastly, validate if the content above the accessory view has correct offsets, if no, you can adjust it using `contentOffsetKeyboardClosed` and `contentOffsetKeyboardOpened` props. Sometimes offsets are correct for the one keyboard state, use one of these props if this is the case. As with the `spaceBetweenKeyboardAndAccessoryView` prop, offsets are calculated based on the bottom safe area inset and/or the height of the tab bar, for example. Also, don't forget to check a scroll indicator. Sometimes you need to subtract or add something to the `contentBottomInset` value of `scrollIndicatorInsets` prop.
Lastly, validate if the content above the accessory view has correct offsets, if no, you can adjust it using `contentOffsetKeyboardClosed` and `contentOffsetKeyboardOpened` props. Sometimes offsets are correct for the one keyboard state, use one of these props if this is the case. As with the `spaceBetweenKeyboardAndAccessoryView` prop, offsets are calculated based on the bottom safe area inset and/or the height of the tab bar, for example.

## Props

### `KeyboardAccessoryView`

- `renderScrollable` (required) - accepts a `ReactNode`. Your scrollable component.

- `style` (optional) - accepts [View Style Props](https://reactnative.dev/docs/view-style-props). Use to style the view which includes both content container and safe area insets. A common use case will be setting `backgroundColor` so the content container and safe area insets are of the matching color.

- `contentContainerStyle` (optional) - accepts [View Style Props](https://reactnative.dev/docs/view-style-props). Use to style the content container, but not the safe area insets.

- `onContentBottomInsetUpdate` (optional) - accepts a function with a number parameter. See the description above.

- `panResponderPositionY` (optional) - accepts a number. See the description above.

- `contentOffsetKeyboardClosed` (optional) - accepts a number. Use to adjust content offset when the keyboard is open. Read more [here](#handling-wrong-offsets).

- `contentOffsetKeyboardOpened` (optional) - accepts a number. Use to adjust content offset when the keyboard is closed. Read more [here](#handling-wrong-offsets).

- `renderBackgroundNode` (optional) - accepts a function returning React node. This is useful when you want to have a custom node as a background (e.g. `<ImageBackground style={StyleSheet.absoluteFill} />` ). Remember about absolute positioning.
- `renderBackground` (optional) - accepts a function returning React node. This is useful when you want to have a custom node as a background (e.g. `<ImageBackground style={StyleSheet.absoluteFill} />` ). Remember about absolute positioning.

- `spaceBetweenKeyboardAndAccessoryView` (optional) - accepts a number. Use to adjust space between the accessory view and the keyboard, when the latter is open. Read more [here](#handling-wrong-offsets).

Expand Down
38 changes: 16 additions & 22 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {
KeyboardAccessoryView,
usePanResponder,
} from '@flyerhq/react-native-keyboard-accessory-view'
import React, { useState } from 'react'
import { KeyboardAccessoryView } from '@flyerhq/react-native-keyboard-accessory-view'
import React from 'react'
import {
FlatList,
GestureResponderHandlers,
SafeAreaView,
StyleSheet,
Text,
Expand All @@ -23,33 +21,29 @@ const data: Item[] = [...Array(20).keys()].map((value) => ({
}))

const App = () => {
const { panHandlers, positionY } = usePanResponder()
const [contentBottomInset, setContentBottomInset] = useState(0)

const keyExtractor = (item: Item) => item.id

const renderItem = ({ item }: { item: Item }) => (
<Text style={styles.text}>{item.message}</Text>
)

const renderScrollable = (panHandlers: GestureResponderHandlers) => (
<FlatList
data={data}
inverted
keyboardDismissMode='interactive'
keyExtractor={keyExtractor}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
{...panHandlers}
/>
)

return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<FlatList
contentContainerStyle={{
paddingBottom: contentBottomInset,
}}
data={data}
keyboardDismissMode='interactive'
keyExtractor={keyExtractor}
renderItem={renderItem}
scrollIndicatorInsets={{ bottom: contentBottomInset }}
showsHorizontalScrollIndicator={false}
{...panHandlers}
/>
<KeyboardAccessoryView
onContentBottomInsetUpdate={setContentBottomInset}
panResponderPositionY={positionY}
renderScrollable={renderScrollable}
style={styles.keyboardAccessoryView}
>
<TextInput style={styles.textInput} />
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
{
"name": "@flyerhq/react-native-keyboard-accessory-view",
"version": "1.6.0",
"version": "2.0.0",
"description": "Keyboard accessory (sticky) view for your React Native app. Supports interactive dismiss on iOS.",
"homepage": "https://github.com/flyerhq/react-native-keyboard-accessory-view#readme",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"author": "Oleksandr Demchenko <[email protected]>",
"contributors": [
"Vitalii Danylov <[email protected]>"
],
"license": "MIT",
"keywords": [
"keyboard-accessory",
Expand Down
124 changes: 68 additions & 56 deletions src/KeyboardAccessoryView.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import * as React from 'react'
import { Animated, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
import {
Animated,
GestureResponderHandlers,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useComponentSize, useKeyboardDimensions } from './hooks'
import {
useComponentSize,
useKeyboardDimensions,
usePanResponder,
} from './hooks'
import styles from './styles'

interface Props {
children?: React.ReactNode
contentContainerStyle?: StyleProp<ViewStyle>
contentOffsetKeyboardClosed?: number
contentOffsetKeyboardOpened?: number
onContentBottomInsetUpdate?: (contentBottomInset: number) => void
panResponderPositionY?: Animated.Value
renderBackgroundNode?: () => React.ReactNode
renderBackground?: () => React.ReactNode
renderScrollable: (panHandlers: GestureResponderHandlers) => React.ReactNode
spaceBetweenKeyboardAndAccessoryView?: number
style?: StyleProp<ViewStyle>
useListenersOnAndroid?: boolean
Expand All @@ -23,76 +33,78 @@ export const KeyboardAccessoryView = React.memo(
contentContainerStyle,
contentOffsetKeyboardClosed,
contentOffsetKeyboardOpened,
onContentBottomInsetUpdate,
panResponderPositionY,
renderBackgroundNode,
renderBackground,
renderScrollable,
spaceBetweenKeyboardAndAccessoryView,
style,
useListenersOnAndroid,
}: Props) => {
const { onLayout, size } = useComponentSize()
const { keyboardEndPositionY, keyboardHeight } = useKeyboardDimensions(
useListenersOnAndroid
)
const { onLayout, size } = useComponentSize()
const { panHandlers, positionY } = usePanResponder()
const { bottom, left, right } = useSafeAreaInsets()

const deltaY = Animated.subtract(
panResponderPositionY ?? new Animated.Value(0),
positionY,
keyboardEndPositionY
)
).interpolate({
inputRange: [0, Number.MAX_SAFE_INTEGER],
outputRange: [0, Number.MAX_SAFE_INTEGER],
extrapolate: 'clamp',
})

const offset =
size.height +
keyboardHeight +
(keyboardHeight > 0
? (contentOffsetKeyboardOpened ?? 0) - bottom
: contentOffsetKeyboardClosed ?? 0)

const { container, contentContainer } = styles({
bottom,
keyboardHeight,
left,
right,
})

const updateContentBottomInset = React.useCallback(() => {
onContentBottomInsetUpdate?.(
size.height +
keyboardHeight +
(keyboardHeight > 0
? (contentOffsetKeyboardOpened ?? 0) - bottom
: contentOffsetKeyboardClosed ?? 0)
)
}, [
bottom,
contentOffsetKeyboardClosed,
contentOffsetKeyboardOpened,
keyboardHeight,
onContentBottomInsetUpdate,
size,
])

React.useEffect(updateContentBottomInset)

return (
<Animated.View
style={StyleSheet.flatten([
{
bottom: Animated.subtract(
keyboardHeight > 0
? keyboardHeight + (spaceBetweenKeyboardAndAccessoryView ?? 0)
: 0,
deltaY.interpolate({
inputRange: [0, Number.MAX_SAFE_INTEGER],
outputRange: [0, Number.MAX_SAFE_INTEGER],
extrapolate: 'clamp',
})
),
},
container,
style,
])}
testID='container'
>
{renderBackgroundNode?.()}
<View
onLayout={onLayout}
style={StyleSheet.flatten([contentContainer, contentContainerStyle])}
<>
<Animated.View
style={{
paddingBottom: Animated.subtract(offset, deltaY),
}}
>
{renderScrollable(panHandlers)}
</Animated.View>
<Animated.View
style={StyleSheet.flatten([
{
bottom: Animated.subtract(
keyboardHeight > 0
? keyboardHeight + (spaceBetweenKeyboardAndAccessoryView ?? 0)
: 0,
deltaY
),
},
container,
style,
])}
testID='container'
>
{children}
</View>
</Animated.View>
{renderBackground?.()}
<View
onLayout={onLayout}
style={StyleSheet.flatten([
contentContainer,
contentContainerStyle,
])}
>
{children}
</View>
</Animated.View>
</>
)
}
)
18 changes: 5 additions & 13 deletions src/__tests__/KeyboardAccessoryView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
import { act, render } from '@testing-library/react-native'
import React from 'react'
import { NativeEventEmitter } from 'react-native'
import { NativeEventEmitter, ScrollView } from 'react-native'
import { keyboardOpenEvent } from '../../jest/fixtures'
import { KeyboardAccessoryView } from '../KeyboardAccessoryView'

const emitter = new NativeEventEmitter()

describe('keyboard accessory view', () => {
it('sticks to the bottom with a closed keyboard', () => {
expect.assertions(2)
const handleContentBottomInsetUpdate = jest.fn()
expect.assertions(1)
const { getByTestId } = render(
<KeyboardAccessoryView
onContentBottomInsetUpdate={handleContentBottomInsetUpdate}
/>
<KeyboardAccessoryView renderScrollable={() => <ScrollView />} />
)
const container = getByTestId('container')
expect(container.props.style).toHaveProperty('bottom', 0)
expect(handleContentBottomInsetUpdate).toHaveBeenLastCalledWith(0)
})

it('sticks to the keyboard top with an open keyboard', () => {
expect.assertions(2)
const handleContentBottomInsetUpdate = jest.fn()
expect.assertions(1)
const { getByTestId } = render(
<KeyboardAccessoryView
onContentBottomInsetUpdate={handleContentBottomInsetUpdate}
/>
<KeyboardAccessoryView renderScrollable={() => <ScrollView />} />
)
act(() => {
emitter.emit('keyboardWillChangeFrame', keyboardOpenEvent)
})
const container = getByTestId('container')
expect(container.props.style).toHaveProperty('bottom', 346)
expect(handleContentBottomInsetUpdate).toHaveBeenLastCalledWith(312)
})
})

0 comments on commit b3c6898

Please sign in to comment.