react-native-keyboard-aware-scroll-view
Version:
A React Native ScrollView component that resizes when the keyboard appears.
577 lines (534 loc) • 19 kB
JavaScript
/* @flow */
import React from 'react'
import PropTypes from 'prop-types'
import {
Keyboard,
Platform,
UIManager,
TextInput,
findNodeHandle,
Animated
} from 'react-native'
import { isIphoneX } from 'react-native-iphone-x-helper'
import type { KeyboardAwareInterface } from './KeyboardAwareInterface'
const _KAM_DEFAULT_TAB_BAR_HEIGHT: number = isIphoneX() ? 83 : 49
const _KAM_KEYBOARD_OPENING_TIME: number = 250
const _KAM_EXTRA_HEIGHT: number = 75
const supportedKeyboardEvents = [
'keyboardWillShow',
'keyboardDidShow',
'keyboardWillHide',
'keyboardDidHide',
'keyboardWillChangeFrame',
'keyboardDidChangeFrame'
]
const keyboardEventToCallbackName = (eventName: string) =>
'on' + eventName[0].toUpperCase() + eventName.substring(1)
const keyboardEventPropTypes = supportedKeyboardEvents.reduce(
(acc: Object, eventName: string) => ({
...acc,
[keyboardEventToCallbackName(eventName)]: PropTypes.func
}),
{}
)
const keyboardAwareHOCTypeEvents = supportedKeyboardEvents.reduce(
(acc: Object, eventName: string) => ({
...acc,
[keyboardEventToCallbackName(eventName)]: Function
}),
{}
)
export type KeyboardAwareHOCProps = {
viewIsInsideTabBar?: boolean,
resetScrollToCoords?: {
x: number,
y: number
},
enableResetScrollToCoords?: boolean,
enableAutomaticScroll?: boolean,
extraHeight?: number,
extraScrollHeight?: number,
keyboardOpeningTime?: number,
onScroll?: Function,
update?: Function,
contentContainerStyle?: any,
enableOnAndroid?: boolean,
innerRef?: Function,
...keyboardAwareHOCTypeEvents
}
export type KeyboardAwareHOCState = {
keyboardSpace: number
}
export type ElementLayout = {
x: number,
y: number,
width: number,
height: number
}
export type ContentOffset = {
x: number,
y: number
}
export type ScrollPosition = {
x: number,
y: number,
animated: boolean
}
export type ScrollIntoViewOptions = ?{
getScrollPosition?: (
parentLayout: ElementLayout,
childLayout: ElementLayout,
contentOffset: ContentOffset
) => ScrollPosition
}
export type KeyboardAwareHOCOptions = ?{
enableOnAndroid: boolean,
contentContainerStyle: ?Object,
enableAutomaticScroll: boolean,
extraHeight: number,
extraScrollHeight: number,
enableResetScrollToCoords: boolean,
keyboardOpeningTime: number,
viewIsInsideTabBar: boolean,
refPropName: string,
extractNativeRef: Function
}
function getDisplayName(WrappedComponent: React$Component) {
return (
(WrappedComponent &&
(WrappedComponent.displayName || WrappedComponent.name)) ||
'Component'
)
}
const ScrollIntoViewDefaultOptions: KeyboardAwareHOCOptions = {
enableOnAndroid: false,
contentContainerStyle: undefined,
enableAutomaticScroll: true,
extraHeight: _KAM_EXTRA_HEIGHT,
extraScrollHeight: 0,
enableResetScrollToCoords: true,
keyboardOpeningTime: _KAM_KEYBOARD_OPENING_TIME,
viewIsInsideTabBar: false,
// The ref prop name that will be passed to the wrapped component to obtain a ref
// If your ScrollView is already wrapped, maybe the wrapper permit to get a ref
// For example, with glamorous-native ScrollView, you should use "innerRef"
refPropName: 'ref',
// Sometimes the ref you get is a ref to a wrapped view (ex: Animated.ScrollView)
// We need access to the imperative API of a real native ScrollView so we need extraction logic
extractNativeRef: (ref: Object) => {
// getNode() permit to support Animated.ScrollView automatically, but is deprecated since RN 0.62
// see https://github.com/facebook/react-native/issues/19650
// see https://stackoverflow.com/questions/42051368/scrollto-is-undefined-on-animated-scrollview/48786374
// see https://github.com/facebook/react-native/commit/66e72bb4e00aafbcb9f450ed5db261d98f99f82a
const shouldCallGetNode = !Platform.constants || (Platform.constants.reactNativeVersion.major === 0 && Platform.constants.reactNativeVersion.minor < 62)
if (ref.getNode && shouldCallGetNode) {
return ref.getNode()
} else {
return ref
}
}
}
function KeyboardAwareHOC(
ScrollableComponent: React$Component,
userOptions: KeyboardAwareHOCOptions = {}
) {
const hocOptions: KeyboardAwareHOCOptions = {
...ScrollIntoViewDefaultOptions,
...userOptions
}
return class
extends React.Component<KeyboardAwareHOCProps, KeyboardAwareHOCState>
implements KeyboardAwareInterface {
_rnkasv_keyboardView: any
keyboardWillShowEvent: ?Function
keyboardWillHideEvent: ?Function
position: ContentOffset
defaultResetScrollToCoords: ?{ x: number, y: number }
mountedComponent: boolean
handleOnScroll: Function
state: KeyboardAwareHOCState
static displayName = `KeyboardAware${getDisplayName(ScrollableComponent)}`
static propTypes = {
viewIsInsideTabBar: PropTypes.bool,
resetScrollToCoords: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired
}),
enableResetScrollToCoords: PropTypes.bool,
enableAutomaticScroll: PropTypes.bool,
extraHeight: PropTypes.number,
extraScrollHeight: PropTypes.number,
keyboardOpeningTime: PropTypes.number,
onScroll: PropTypes.oneOfType([
PropTypes.func, // Normal listener
PropTypes.object // Animated.event listener
]),
update: PropTypes.func,
contentContainerStyle: PropTypes.any,
enableOnAndroid: PropTypes.bool,
innerRef: PropTypes.func,
...keyboardEventPropTypes
}
// HOC options are used to init default props, so that these options can be overriden with component props
static defaultProps = {
enableAutomaticScroll: hocOptions.enableAutomaticScroll,
extraHeight: hocOptions.extraHeight,
extraScrollHeight: hocOptions.extraScrollHeight,
enableResetScrollToCoords: hocOptions.enableResetScrollToCoords,
keyboardOpeningTime: hocOptions.keyboardOpeningTime,
viewIsInsideTabBar: hocOptions.viewIsInsideTabBar,
enableOnAndroid: hocOptions.enableOnAndroid
}
constructor(props: KeyboardAwareHOCProps) {
super(props)
this.keyboardWillShowEvent = undefined
this.keyboardWillHideEvent = undefined
this.callbacks = {}
this.position = { x: 0, y: 0 }
this.defaultResetScrollToCoords = null
const keyboardSpace: number = props.viewIsInsideTabBar
? _KAM_DEFAULT_TAB_BAR_HEIGHT
: 0
this.state = { keyboardSpace }
}
componentDidMount() {
this.mountedComponent = true
// Keyboard events
if (Platform.OS === 'ios') {
this.keyboardWillShowEvent = Keyboard.addListener(
'keyboardWillShow',
this._updateKeyboardSpace
)
this.keyboardWillHideEvent = Keyboard.addListener(
'keyboardWillHide',
this._resetKeyboardSpace
)
} else if (Platform.OS === 'android' && this.props.enableOnAndroid) {
this.keyboardWillShowEvent = Keyboard.addListener(
'keyboardDidShow',
this._updateKeyboardSpace
)
this.keyboardWillHideEvent = Keyboard.addListener(
'keyboardDidHide',
this._resetKeyboardSpace
)
}
supportedKeyboardEvents.forEach((eventName: string) => {
const callbackName = keyboardEventToCallbackName(eventName)
if (this.props[callbackName]) {
this.callbacks[eventName] = Keyboard.addListener(
eventName,
this.props[callbackName]
)
}
})
}
componentDidUpdate(prevProps: KeyboardAwareHOCProps) {
if (this.props.viewIsInsideTabBar !== prevProps.viewIsInsideTabBar) {
const keyboardSpace: number = this.props.viewIsInsideTabBar
? _KAM_DEFAULT_TAB_BAR_HEIGHT
: 0
if (this.state.keyboardSpace !== keyboardSpace) {
this.setState({ keyboardSpace })
}
}
}
componentWillUnmount() {
this.mountedComponent = false
this.keyboardWillShowEvent && this.keyboardWillShowEvent.remove()
this.keyboardWillHideEvent && this.keyboardWillHideEvent.remove()
Object.values(this.callbacks).forEach((callback: Object) =>
callback.remove()
)
}
getScrollResponder = () => {
return (
this._rnkasv_keyboardView &&
this._rnkasv_keyboardView.getScrollResponder &&
this._rnkasv_keyboardView.getScrollResponder()
)
}
scrollToPosition = (x: number, y: number, animated: boolean = true) => {
const responder = this.getScrollResponder()
if (!responder) {
return
}
if (responder.scrollResponderScrollTo) {
// React Native < 0.65
responder.scrollResponderScrollTo({ x, y, animated })
} else if (responder.scrollTo) {
// React Native >= 0.65
responder.scrollTo({ x, y, animated })
}
}
scrollToEnd = (animated?: boolean = true) => {
const responder = this.getScrollResponder()
if (!responder) {
return
}
if (responder.scrollResponderScrollToEnd) {
// React Native < 0.65
responder.scrollResponderScrollToEnd({ animated })
} else if (responder.scrollToEnd) {
// React Native >= 0.65
responder.scrollToEnd({ animated })
}
}
scrollForExtraHeightOnAndroid = (extraHeight: number) => {
this.scrollToPosition(0, this.position.y + extraHeight, true)
}
/**
* @param keyboardOpeningTime: takes a different keyboardOpeningTime in consideration.
* @param extraHeight: takes an extra height in consideration.
*/
scrollToFocusedInput = (
reactNode: any,
extraHeight?: number,
keyboardOpeningTime?: number
) => {
if (extraHeight === undefined) {
extraHeight = this.props.extraHeight || 0
}
if (keyboardOpeningTime === undefined) {
keyboardOpeningTime = this.props.keyboardOpeningTime || 0
}
setTimeout(() => {
if (!this.mountedComponent) {
return
}
const responder = this.getScrollResponder()
responder &&
responder.scrollResponderScrollNativeHandleToKeyboard(
reactNode,
extraHeight,
true
)
}, keyboardOpeningTime)
}
scrollIntoView = async (
element: React.Element<*>,
options: ScrollIntoViewOptions = {}
) => {
if (!this._rnkasv_keyboardView || !element) {
return
}
const [parentLayout, childLayout] = await Promise.all([
this._measureElement(this._rnkasv_keyboardView),
this._measureElement(element)
])
const getScrollPosition =
options.getScrollPosition || this._defaultGetScrollPosition
const { x, y, animated } = getScrollPosition(
parentLayout,
childLayout,
this.position
)
this.scrollToPosition(x, y, animated)
}
_defaultGetScrollPosition = (
parentLayout: ElementLayout,
childLayout: ElementLayout,
contentOffset: ContentOffset
): ScrollPosition => {
return {
x: 0,
y: Math.max(0, childLayout.y - parentLayout.y + contentOffset.y),
animated: true
}
}
_measureElement = (element: React.Element<*>): Promise<ElementLayout> => {
const node = findNodeHandle(element)
return new Promise((resolve: ElementLayout => void) => {
UIManager.measureInWindow(
node,
(x: number, y: number, width: number, height: number) => {
resolve({ x, y, width, height })
}
)
})
}
// Keyboard actions
_updateKeyboardSpace = (frames: Object) => {
// Automatically scroll to focused TextInput
if (this.props.enableAutomaticScroll) {
let keyboardSpace: number =
frames.endCoordinates.height + this.props.extraScrollHeight
if (this.props.viewIsInsideTabBar) {
keyboardSpace -= _KAM_DEFAULT_TAB_BAR_HEIGHT
}
this.setState({ keyboardSpace })
const currentlyFocusedField = TextInput.State.currentlyFocusedInput ? findNodeHandle(TextInput.State.currentlyFocusedInput()) : TextInput.State.currentlyFocusedField()
const responder = this.getScrollResponder()
if (!currentlyFocusedField || !responder) {
return
}
UIManager.viewIsDescendantOf(
currentlyFocusedField,
responder.getInnerViewNode(),
(isAncestor: boolean) => {
if (isAncestor) {
// Check if the TextInput will be hidden by the keyboard
UIManager.measureInWindow(
currentlyFocusedField,
(x: number, y: number, width: number, height: number) => {
const textInputBottomPosition = y + height
const keyboardPosition = frames.endCoordinates.screenY
const totalExtraHeight =
this.props.extraScrollHeight + this.props.extraHeight
if (Platform.OS === 'ios') {
if (
textInputBottomPosition >
keyboardPosition - totalExtraHeight
) {
this._scrollToFocusedInputWithNodeHandle(
currentlyFocusedField
)
}
} else {
// On android, the system would scroll the text input just
// above the keyboard so we just neet to scroll the extra
// height part
if (textInputBottomPosition > keyboardPosition) {
// Since the system already scrolled the whole view up
// we should reduce that amount
keyboardSpace =
keyboardSpace -
(textInputBottomPosition - keyboardPosition)
this.setState({ keyboardSpace })
this.scrollForExtraHeightOnAndroid(totalExtraHeight)
} else if (
textInputBottomPosition >
keyboardPosition - totalExtraHeight
) {
this.scrollForExtraHeightOnAndroid(
totalExtraHeight -
(keyboardPosition - textInputBottomPosition)
)
}
}
}
)
}
}
)
}
if (!this.props.resetScrollToCoords) {
if (!this.defaultResetScrollToCoords) {
this.defaultResetScrollToCoords = this.position
}
}
}
_resetKeyboardSpace = () => {
const keyboardSpace: number = this.props.viewIsInsideTabBar
? _KAM_DEFAULT_TAB_BAR_HEIGHT
: 0
this.setState({ keyboardSpace })
// Reset scroll position after keyboard dismissal
if (this.props.enableResetScrollToCoords === false) {
this.defaultResetScrollToCoords = null
return
} else if (this.props.resetScrollToCoords) {
this.scrollToPosition(
this.props.resetScrollToCoords.x,
this.props.resetScrollToCoords.y,
true
)
} else {
if (this.defaultResetScrollToCoords) {
this.scrollToPosition(
this.defaultResetScrollToCoords.x,
this.defaultResetScrollToCoords.y,
true
)
this.defaultResetScrollToCoords = null
} else {
this.scrollToPosition(0, 0, true)
}
}
}
_scrollToFocusedInputWithNodeHandle = (
nodeID: number,
extraHeight?: number,
keyboardOpeningTime?: number
) => {
if (extraHeight === undefined) {
extraHeight = this.props.extraHeight
}
const reactNode = findNodeHandle(nodeID)
this.scrollToFocusedInput(
reactNode,
extraHeight + this.props.extraScrollHeight,
keyboardOpeningTime !== undefined
? keyboardOpeningTime
: this.props.keyboardOpeningTime || 0
)
}
_handleOnScroll = (
e: SyntheticEvent<*> & { nativeEvent: { contentOffset: number } }
) => {
this.position = e.nativeEvent.contentOffset
}
_handleRef = (ref: React.Component<*>) => {
this._rnkasv_keyboardView = ref ? hocOptions.extractNativeRef(ref) : ref
if (this.props.innerRef) {
this.props.innerRef(this._rnkasv_keyboardView)
}
}
update = () => {
const currentlyFocusedField = TextInput.State.currentlyFocusedInput ? findNodeHandle(TextInput.State.currentlyFocusedInput()) : TextInput.State.currentlyFocusedField()
const responder = this.getScrollResponder()
if (!currentlyFocusedField || !responder) {
return
}
this._scrollToFocusedInputWithNodeHandle(currentlyFocusedField)
}
render() {
const { enableOnAndroid, contentContainerStyle, onScroll } = this.props
let newContentContainerStyle
if (Platform.OS === 'android' && enableOnAndroid) {
newContentContainerStyle = [].concat(contentContainerStyle).concat({
paddingBottom:
((contentContainerStyle || {}).paddingBottom || 0) +
this.state.keyboardSpace
})
}
const refProps = { [hocOptions.refPropName]: this._handleRef }
return (
<ScrollableComponent
{...refProps}
keyboardDismissMode='interactive'
contentInset={{ bottom: this.state.keyboardSpace }}
automaticallyAdjustContentInsets={false}
showsVerticalScrollIndicator={true}
scrollEventThrottle={1}
{...this.props}
contentContainerStyle={
newContentContainerStyle || contentContainerStyle
}
keyboardSpace={this.state.keyboardSpace}
getScrollResponder={this.getScrollResponder}
scrollToPosition={this.scrollToPosition}
scrollToEnd={this.scrollToEnd}
scrollForExtraHeightOnAndroid={this.scrollForExtraHeightOnAndroid}
scrollToFocusedInput={this.scrollToFocusedInput}
scrollIntoView={this.scrollIntoView}
resetKeyboardSpace={this._resetKeyboardSpace}
handleOnScroll={this._handleOnScroll}
update={this.update}
onScroll={Animated.forkEvent(onScroll, this._handleOnScroll)}
/>
)
}
}
}
// Allow to pass options, without breaking change, and curried for composition
// listenToKeyboardEvents(ScrollView);
// listenToKeyboardEvents(options)(Comp);
const listenToKeyboardEvents = (configOrComp: any) => {
if (typeof configOrComp === 'object' && !configOrComp.displayName) {
return (Comp: Function) => KeyboardAwareHOC(Comp, configOrComp)
} else {
return KeyboardAwareHOC(configOrComp)
}
}
export default listenToKeyboardEvents