From 8b78846a9501ef9c5ce9d1e18ee104bfae76af2e Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Tue, 7 Jun 2016 07:42:50 -0700 Subject: [PATCH] Open sourced KeyboardAvoidingView Summary: KeyboardAvoidingView is a component we built internally to solve the common problem of views that need to move out of the way of the virtual keyboard. KeyboardAvoidingView can automatically adjust either its position or bottom padding based on the position of the keyboard. Reviewed By: javache Differential Revision: D3398238 fbshipit-source-id: 493f2d2dec76667996250c011a1c5b7a14f245eb --- .../UIExplorer/KeyboardAvoidingViewExample.js | 111 ++++++++++ Examples/UIExplorer/UIExplorerList.android.js | 7 + Examples/UIExplorer/UIExplorerList.ios.js | 4 + .../Keyboard/KeyboardAvoidingView.js | 189 ++++++++++++++++++ Libraries/react-native/react-native.js | 1 + Libraries/react-native/react-native.js.flow | 1 + 6 files changed, 313 insertions(+) create mode 100644 Examples/UIExplorer/KeyboardAvoidingViewExample.js create mode 100644 Libraries/Components/Keyboard/KeyboardAvoidingView.js diff --git a/Examples/UIExplorer/KeyboardAvoidingViewExample.js b/Examples/UIExplorer/KeyboardAvoidingViewExample.js new file mode 100644 index 000000000..e23a984f1 --- /dev/null +++ b/Examples/UIExplorer/KeyboardAvoidingViewExample.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule KeyboardAvoidingViewExample + */ +'use strict'; + +const React = require('React'); +const ReactNative = require('react-native'); +const { + KeyboardAvoidingView, + Modal, + SegmentedControlIOS, + StyleSheet, + Text, + TextInput, + TouchableHighlight, + View, +} = ReactNative; + +const UIExplorerBlock = require('./UIExplorerBlock'); +const UIExplorerPage = require('./UIExplorerPage'); + +const KeyboardAvoidingViewExample = React.createClass({ + statics: { + title: '', + description: 'Base component for views that automatically adjust their height or position to move out of the way of the keyboard.', + }, + + getInitialState() { + return { + behavior: 'padding', + modalOpen: false, + }; + }, + + onSegmentChange(segment: String) { + this.setState({behavior: segment.toLowerCase()}); + }, + + renderExample() { + return ( + + + + + + + this.setState({modalOpen: false})} + style={styles.closeButton}> + Close + + + + this.setState({modalOpen: true})}> + Open Example + + + ); + }, + + render() { + return ( + + + {this.renderExample()} + + + ); + }, +}); + +const styles = StyleSheet.create({ + outerContainer: { + flex: 1, + }, + container: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 20, + paddingTop: 20, + }, + textInput: { + borderRadius: 5, + borderWidth: 1, + height: 44, + paddingHorizontal: 10, + }, + segment: { + marginBottom: 10, + }, + closeButton: { + position: 'absolute', + top: 30, + left: 10, + } +}); + +module.exports = KeyboardAvoidingViewExample; diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 25279bdf6..c4c7aecc9 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -1,4 +1,11 @@ /** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * * The examples provided by Facebook are for non-commercial testing and * evaluation purposes only. * diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index 5aefa3288..3ab425b24 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -40,6 +40,10 @@ const ComponentExamples: Array = [ key: 'ImageExample', module: require('./ImageExample'), }, + { + key: 'KeyboardAvoidingViewExample', + module: require('./KeyboardAvoidingViewExample'), + }, { key: 'LayoutEventsExample', module: require('./LayoutEventsExample'), diff --git a/Libraries/Components/Keyboard/KeyboardAvoidingView.js b/Libraries/Components/Keyboard/KeyboardAvoidingView.js new file mode 100644 index 000000000..c5c1d9ba0 --- /dev/null +++ b/Libraries/Components/Keyboard/KeyboardAvoidingView.js @@ -0,0 +1,189 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule KeyboardAvoidingView + * @flow + */ +'use strict'; + +const Keyboard = require('Keyboard'); +const LayoutAnimation = require('LayoutAnimation'); +const Platform = require('Platform'); +const PropTypes = require('ReactPropTypes'); +const React = require('React'); +const TimerMixin = require('react-timer-mixin'); +const View = require('View'); + +import type EmitterSubscription from 'EmitterSubscription'; + +type Rect = { + x: number; + y: number; + width: number; + height: number; +}; +type ScreenRect = { + screenX: number; + screenY: number; + width: number; + height: number; +}; +type KeyboardChangeEvent = { + startCoordinates?: ScreenRect; + endCoordinates: ScreenRect; + duration?: number; + easing?: string; +}; +type LayoutEvent = { + nativeEvent: { + layout: Rect; + } +}; + +const viewRef = 'VIEW'; + +const KeyboardAvoidingView = React.createClass({ + mixins: [TimerMixin], + + propTypes: { + ...View.propTypes, + behavior: PropTypes.oneOf(['height', 'position', 'padding']), + + /** + * This is the distance between the top of the user screen and the react native view, + * may be non-zero in some use cases. + */ + keyboardVerticalOffset: PropTypes.number.isRequired, + }, + + getDefaultProps() { + return { + keyboardVerticalOffset: 0, + }; + }, + + getInitialState() { + return { + bottom: 0, + }; + }, + + subscriptions: ([]: Array), + frame: (null: ?Rect), + + relativeKeyboardHeight(keyboardFrame: ScreenRect): number { + const frame = this.frame; + if (!frame) { + return 0; + } + + const y1 = Math.max(frame.y, keyboardFrame.screenY - this.props.keyboardVerticalOffset); + const y2 = Math.min(frame.y + frame.height, keyboardFrame.screenY + keyboardFrame.height - this.props.keyboardVerticalOffset); + return Math.max(y2 - y1, 0); + }, + + onKeyboardChange(event: ?KeyboardChangeEvent) { + if (!event) { + this.setState({bottom: 0}); + return; + } + + const {duration, easing, endCoordinates} = event; + const height = this.relativeKeyboardHeight(endCoordinates); + + if (duration && easing) { + LayoutAnimation.configureNext({ + duration: duration, + update: { + duration: duration, + type: LayoutAnimation.Types[easing] || 'keyboard', + }, + }); + } + this.setState({bottom: height}); + }, + + onLayout(event: LayoutEvent) { + this.frame = event.nativeEvent.layout; + }, + + componentWillUpdate(nextProps: Object, nextState: Object, nextContext?: Object): void { + if (nextState.bottom === this.state.bottom && + this.props.behavior === 'height' && + nextProps.behavior === 'height') { + // If the component rerenders without an internal state change, e.g. + // triggered by parent component re-rendering, no need for bottom to change. + nextState.bottom = 0; + } + }, + + componentWillMount() { + if (Platform.OS === 'ios') { + this.subscriptions = [ + Keyboard.addListener('keyboardWillChangeFrame', this.onKeyboardChange), + ]; + } else { + this.subscriptions = [ + Keyboard.addListener('keyboardDidHide', this.onKeyboardChange), + Keyboard.addListener('keyboardDidShow', this.onKeyboardChange), + ]; + } + }, + + componentWillUnmount() { + this.subscriptions.forEach((sub) => sub.remove()); + }, + + render(): ReactElement { + const {behavior, children, style, ...props} = this.props; + + switch (behavior) { + case 'height': + let heightStyle; + if (this.frame) { + // Note that we only apply a height change when there is keyboard present, + // i.e. this.state.bottom is greater than 0. If we remove that condition, + // this.frame.height will never go back to its original value. + // When height changes, we need to disable flex. + heightStyle = {height: this.frame.height - this.state.bottom, flex: 0}; + } + return ( + + {children} + + ); + + case 'position': + const positionStyle = {bottom: this.state.bottom}; + return ( + + + {children} + + + ); + + case 'padding': + const paddingStyle = {paddingBottom: this.state.bottom}; + return ( + + {children} + + ); + + default: + return ( + + {children} + + ); + } + }, +}); + +module.exports = KeyboardAvoidingView; diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index 713becd5d..c9129314c 100644 --- a/Libraries/react-native/react-native.js +++ b/Libraries/react-native/react-native.js @@ -35,6 +35,7 @@ const ReactNative = { get Image() { return require('Image'); }, get ImageEditor() { return require('ImageEditor'); }, get ImageStore() { return require('ImageStore'); }, + get KeyboardAvoidingView() { return require('KeyboardAvoidingView'); }, get ListView() { return require('ListView'); }, get MapView() { return require('MapView'); }, get Modal() { return require('Modal'); }, diff --git a/Libraries/react-native/react-native.js.flow b/Libraries/react-native/react-native.js.flow index 191d3e222..b63a57cb5 100644 --- a/Libraries/react-native/react-native.js.flow +++ b/Libraries/react-native/react-native.js.flow @@ -33,6 +33,7 @@ var ReactNative = Object.assign(Object.create(require('ReactNative')), { Image: require('Image'), ImageEditor: require('ImageEditor'), ImageStore: require('ImageStore'), + KeyboardAvoidingView: require('KeyboardAvoidingView'), ListView: require('ListView'), MapView: require('MapView'), Modal: require('Modal'),