Skip to content

Commit

Permalink
Open sourced KeyboardAvoidingView
Browse files Browse the repository at this point in the history
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
  • Loading branch information
nicklockwood authored and Facebook Github Bot 8 committed Jun 7, 2016
1 parent d64368b commit 8b78846
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 0 deletions.
111 changes: 111 additions & 0 deletions Examples/UIExplorer/KeyboardAvoidingViewExample.js
Original file line number Diff line number Diff line change
@@ -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: '<KeyboardAvoidingView>',
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 (
<View style={styles.outerContainer}>
<Modal animationType="fade" visible={this.state.modalOpen}>
<KeyboardAvoidingView behavior={this.state.behavior} style={styles.container}>
<SegmentedControlIOS
onValueChange={this.onSegmentChange}
selectedIndex={this.state.behavior === 'padding' ? 0 : 1}
style={styles.segment}
values={['Padding', 'Position']} />
<TextInput
placeholder="<TextInput />"
style={styles.textInput} />
</KeyboardAvoidingView>
<TouchableHighlight
onPress={() => this.setState({modalOpen: false})}
style={styles.closeButton}>
<Text>Close</Text>
</TouchableHighlight>
</Modal>

<TouchableHighlight onPress={() => this.setState({modalOpen: true})}>
<Text>Open Example</Text>
</TouchableHighlight>
</View>
);
},

render() {
return (
<UIExplorerPage title="Keyboard Avoiding View">
<UIExplorerBlock title="Keyboard-avoiding views move out of the way of the keyboard.">
{this.renderExample()}
</UIExplorerBlock>
</UIExplorerPage>
);
},
});

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;
7 changes: 7 additions & 0 deletions Examples/UIExplorer/UIExplorerList.android.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions Examples/UIExplorer/UIExplorerList.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ const ComponentExamples: Array<UIExplorerExample> = [
key: 'ImageExample',
module: require('./ImageExample'),
},
{
key: 'KeyboardAvoidingViewExample',
module: require('./KeyboardAvoidingViewExample'),
},
{
key: 'LayoutEventsExample',
module: require('./LayoutEventsExample'),
Expand Down
189 changes: 189 additions & 0 deletions Libraries/Components/Keyboard/KeyboardAvoidingView.js
Original file line number Diff line number Diff line change
@@ -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<EmitterSubscription>),
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<any> {
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 (
<View ref={viewRef} style={[style, heightStyle]} onLayout={this.onLayout} {...props}>
{children}
</View>
);

case 'position':
const positionStyle = {bottom: this.state.bottom};
return (
<View ref={viewRef} style={style} onLayout={this.onLayout} {...props}>
<View style={positionStyle}>
{children}
</View>
</View>
);

case 'padding':
const paddingStyle = {paddingBottom: this.state.bottom};
return (
<View ref={viewRef} style={[style, paddingStyle]} onLayout={this.onLayout} {...props}>
{children}
</View>
);

default:
return (
<View ref={viewRef} onLayout={this.onLayout} style={style} {...props}>
{children}
</View>
);
}
},
});

module.exports = KeyboardAvoidingView;
1 change: 1 addition & 0 deletions Libraries/react-native/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'); },
Expand Down
1 change: 1 addition & 0 deletions Libraries/react-native/react-native.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down

13 comments on commit 8b78846

@GantMan
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Looking forward to this!

@mozillo
Copy link

@mozillo mozillo commented on 8b78846 Jul 4, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it!

@norfish
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool

@DylanYasen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome!

@bsiddiqui
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome but I've been having trouble using it - any plans on docs?

@wootwoot1234
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between setting behavior to 'height', 'position' and, 'padding'?

@GantMan
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

padding is the only one that works for me. Not sure the differences.

@wootwoot1234
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They all work for me but it's not clear to me when to use each one. I will say, they work work great. Thanks @nicklockwood

@DylanYasen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm currently using 'position' mode in my project. It works well enough, thanks for this!

@AzizAK
Copy link

@AzizAK AzizAK commented on 8b78846 Aug 27, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @nicklockwood, i'm using "position" and its very helpful!

@Arkanine
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @nicklockwood, how to use it with ScrollView? When keyboard disappear, the space still exists.
Thank you for the answer.

@chirag04
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been using this in production: https://gist.github.com/chirag04/d7a7d58f4afc9520a51f511ce7f67788

There is this which didn't work well for me: https://github.com/APSL/react-native-keyboard-aware-scroll-view

@Arkanine can you give feedback if either of them work for you?

@migueloller
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey!

I've been using this component in production now and there are 2 improvements that I'm wondering if they would help other people. If so, I wouldn't mind doing a PR implementing the improvements.

They are:

  • Remove the need to specify the keyboardVerticalOffset prop. It is possible to calculate this value using the measure method from NativeMethodsMixin.
  • Listen for keyboardDidShow and respond accordingly. This is important because a component could be mounted after the keyboard is shown and you still would want the view to avoid the keyboard.

I've implemented these two improvements on my end so I know that they work 100%.

Thoughts?

Please sign in to comment.