@mtourj/react-native-keyboard-aware-scroll-view
Version:
A React Native ScrollView component that resizes when the keyboard appears.
554 lines (511 loc) • 18.2 kB
JavaScript
/* @flow */
import React from "react";
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 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,
isResetting: boolean,
};
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: false,
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
// see https://github.com/facebook/react-native/issues/19650
// see https://stackoverflow.com/questions/42051368/scrollto-is-undefined-on-animated-scrollview/48786374
if (ref.getNode) {
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)}`;
// 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();
// responder && responder.scrollResponderScrollTo({ x, y, animated });
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();
// responder && responder.scrollResponderScrollToEnd({ animated });
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;
}
if (this.state.isResetting) return;
setTimeout(() => {
if (!this.mountedComponent || this.state.isResetting) {
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, isResetting: true });
// 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);
}
}
setTimeout(() => {
this.setState({ isResetting: false });
}, this.props.keyboardOpeningTime || 0);
};
_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;