react-native-material-kit
Version:
Bringing Material Design to React Native
596 lines (506 loc) • 15.1 kB
JavaScript
//
// MDL style textfield component.
//
// - @see [MDL Textfield](http://www.getmdl.io/components/index.html#textfields-section)
// - [Props](#props)
// - [Defaults](#defaults)
// - [Built-in builders](#builders)
//
// Created by ywu on 15/8/3.
//
const React = require('react-native');
const {
Component,
View,
TextInput,
PropTypes,
Animated,
} = React;
const MKPropTypes = require('../MKPropTypes');
const MKColor = require('../MKColor');
const utils = require('../utils');
const {getTheme} = require('../theme');
//
// ## <section id='FloatingLabel'>FloatingLabel</section>
// `FloatingLabel` component of the [`Textfield`](#Textfield).
//
class FloatingLabel extends Component {
constructor(props) {
super(props);
this.labelDim = {};
this.labelState = {
opacity: new Animated.Value(0),
top: new Animated.Value(0),
};
this.state = {
text: '',
};
}
componentWillMount() {
this.updateText(this.props.text);
}
componentWillReceiveProps(nextProps) {
this.updateText(nextProps.text);
}
updateText(text) {
this.setState({text});
}
measure(cb) {
if (this.refs.label) {
return this.refs.label.refs.node.measure(cb);
}
}
_onLabelLayout({nativeEvent: {layout: {width, height}}}) {
if (width !== this.labelDim.width || height !== this.labelDim.height) {
this.labelDim = {width, height};
}
}
setColor(color) {
// FIXME color has to be an int since RN v0.12, https://github.com/facebook/react-native/issues/3300
this.refs.label.setNativeProps({color: utils.parseColor(color)});
// use this with RN before 0.12
// this.refs.label.setNativeProps({color});
}
aniFloatLabel() {
if (!this.props.floatingLabelEnabled) {
return [];
}
this.setColor(this.props.highlightColor);
return [Animated.sequence([
Animated.timing(this.labelState.opacity, {
toValue: 1,
duration: this.props.opacityAniDur,
}),
Animated.timing(this.labelState.top, {
toValue: 0,
duration: this.props.floatingLabelAniDuration,
}),
])];
}
aniSinkLabel() {
if (!this.props.floatingLabelEnabled) {
return [];
}
this.setColor(this.props.tintColor);
return [Animated.sequence([
Animated.timing(this.labelState.top, {
toValue: this.labelDim.height + 5,
duration: this.props.floatingLabelAniDuration,
}),
Animated.timing(this.labelState.opacity, {
toValue: 0,
duration: this.props.opacityAniDur,
}),
])];
}
render() {
return (
<Animated.Text
ref="label"
pointerEvents="none"
style={[{
backgroundColor: MKColor.Transparent,
position: 'absolute',
top: this.labelState.top,
opacity: this.labelState.opacity,
marginBottom: this.props.floatingLabelBottomMargin,
},
this.props.floatingLabelFont,
]}
onLayout={this._onLabelLayout.bind(this)}
>
{this.state.text}
</Animated.Text>
);
}
}
// ## <section id='floatingLabelProps'>FloatingLabel Props</section>
FloatingLabel.publicPropTypes = {
// Enable floating label effect
floatingLabelEnabled: PropTypes.bool,
// Duration of floating transition, also affect underline animation
floatingLabelAniDuration: PropTypes.number,
// Spacing between floating label and input text
floatingLabelBottomMargin: PropTypes.number,
// [Font](MKPropTypes.html#font) of floating label
// FIXME causing warning: `typeChecker is not a function`
floatingLabelFont: MKPropTypes.font,
};
FloatingLabel.propTypes = {
...FloatingLabel.publicPropTypes,
// internal props
tintColor: PropTypes.string,
highlightColor: PropTypes.string,
opacityAniDur: PropTypes.number,
};
FloatingLabel.defaultProps = {
floatingLabelAniDuration: 200,
opacityAniDur: 30,
};
//
// ## <section id='Underline'>Underline</section>
// `Underline` component of the [`Textfield`](#Textfield).
//
class Underline extends Component {
constructor(props) {
super(props);
this.animatedLeft = new Animated.Value(0);
this.animatedLineLength = new Animated.Value(0);
this.state = {
lineLength: 0,
};
}
// update the length of stretched underline
updateLineLength(lineLength, cb) {
this.setState({lineLength}, cb);
}
// stretch the line, from center
aniStretchUnderline(focused) {
if (!(this.props.underlineEnabled && focused)) {
return [];
}
this.animatedLeft.setValue(this.state.lineLength / 2);
return [
Animated.timing(this.animatedLeft, {
toValue: 0,
duration: this.props.underlineAniDur,
}),
Animated.timing(this.animatedLineLength, {
toValue: this.state.lineLength,
duration: this.props.underlineAniDur,
}),
];
}
// collapse the the line to a single point at center
aniShrinkUnderline() {
if (!this.props.underlineEnabled) {
return [];
}
return [
Animated.timing(this.animatedLeft, {
toValue: this.state.lineLength / 2,
duration: this.props.underlineAniDur,
}),
Animated.timing(this.animatedLineLength, {
toValue: 0,
duration: this.props.underlineAniDur,
}),
];
}
render() {
return (
<View pointerEvents="none"
style={{
//top: -this.props.underlineSize,
height: this.props.underlineSize,
}}
>
<View // the default silver border
style={{
position: 'absolute',
backgroundColor: this.props.tintColor,
height: this.props.underlineSize,
width: this.state.lineLength,
}}
/>
{(() => {
// the colorful forefront line, animation enabled
if (this.props.underlineEnabled) {
return (
<Animated.View
style={{
position: 'absolute',
backgroundColor: this.props.highlightColor,
height: this.props.underlineSize,
left: this.animatedLeft,
width: this.animatedLineLength,
}}
/>
);
}
})()}
</View>
);
}
}
Underline.propTypes = {
// internal props
underlineEnabled: PropTypes.bool,
tintColor: PropTypes.string,
highlightColor: PropTypes.string,
underlineSize: PropTypes.number,
underlineAniDur: PropTypes.number,
};
Underline.defaultProps = {
underlineEnabled: true,
underlineAniDur: FloatingLabel.defaultProps.floatingLabelAniDuration,
underlineSize: 1,
};
//
// ## <section id='Textfield'>Textfield</section>
// `Textfield` component,
// which has floating label and underline effect.
// - TODO styling read-only & diabled mode
//
class Textfield extends Component {
constructor(props) {
super(props);
this.inputFrame = {};
this.state = {
inputMarginTop: 0,
};
}
set bufferedValue(v) {
this._bufferedValue = v;
}
get bufferedValue() {
return (this._bufferedValue || '').trim();
}
focus() {
this.refs.input.focus();
}
isFocused() {
return this.refs.input.isFocused();
}
componentWillMount() {
this.bufferedValue = this.props.value || this.props.text ||
this.props.defaultValue;
this._originPlaceholder = this.props.placeholder;
}
componentWillReceiveProps(nextProps) {
this.bufferedValue = nextProps.value || nextProps.text ||
this.props.defaultValue;
this._originPlaceholder = nextProps.placeholder;
}
componentDidMount() {
requestAnimationFrame(this._doMeasurement.bind(this));
}
_doMeasurement() {
this.refs.input.measure(this._onInputMeasured.bind(this));
if (this.props.floatingLabelEnabled) {
this.refs.floatingLabel.measure(this._onLabelMeasured.bind(this));
}
}
_onLabelMeasured(left, top, width, height) {
this.setState({inputMarginTop: height});
}
_onInputMeasured(left, top, width, height) {
Object.assign(this.inputFrame, {left, top, width, height});
this.refs.underline.updateLineLength(width, () => {
if (this.bufferedValue || this.isFocused()) {
this._aniStartHighlight(); // if input not empty, lift the label
}
});
}
// animation when textfield focused
_aniStartHighlight() {
if (this.props.floatingLabelEnabled) {
// hide fixed placeholder, if floating
this.setPlaceholder('');
// and show floating label
// FIXME workaround https://github.com/facebook/react-native/issues/3220
this.refs.floatingLabel.updateText(this._originPlaceholder);
}
// stretch the underline if enabled
const animations = this.refs.underline.aniStretchUnderline(this.isFocused());
// and lift the floating label, if enabled
if (this.props.floatingLabelEnabled) {
animations.push(...this.refs.floatingLabel.aniFloatLabel());
}
if (animations.length) {
Animated.parallel(animations).start();
}
}
// animation when textfield lost focus
_aniStopHighlight() {
// shrink the underline
const animations = this.refs.underline.aniShrinkUnderline();
// pull down the label, or keep position if input is not empty
if (this.props.floatingLabelEnabled && !this.bufferedValue) {
// input is empty, label should be pulled down
animations.push(...this.refs.floatingLabel.aniSinkLabel());
}
if (animations.length) {
Animated.parallel(animations).start(() => {
if (this.props.floatingLabelEnabled) {
// then show fixed placeholder
this.setPlaceholder(this._originPlaceholder);
// and hide floating label
// FIXME workaround https://github.com/facebook/react-native/issues/3220
if (!this.bufferedValue) {
this.refs.floatingLabel.updateText('');
}
}
});
}
}
setPlaceholder(placeholder) {
this.refs.input.setNativeProps({placeholder});
}
_onTextChange(text) {
this.bufferedValue = text;
this._callback('onTextChange', text);
this._callback('onChangeText', text);
}
_onFocus() {
this._aniStartHighlight();
this._callback('onFocus');
}
_onBlur() {
this._aniStopHighlight();
this._callback('onBlur');
}
_callback(name, e) {
if (this.props[name]) {
this.props[name](e);
}
}
render() {
let floatingLabel, defaultDim;
if (this.props.floatingLabelEnabled) {
// the floating label
floatingLabel = (
<FloatingLabel ref="floatingLabel"
{...utils.extractProps(this, FloatingLabel.propTypes)}
text={this.props.placeholder}
/>
);
defaultDim = {
width: 100,
height: 38,
};
} else {
defaultDim = {
width: 100,
height: 28,
};
}
const underlineProps = utils.extractProps(this, ['tintColor',
'highlightColor', 'underlineSize', 'underlineEnabled']);
const inputProps = utils.extractProps(this, {
...TextInput.propTypes,
password: 1,
});
return (
<View style={[defaultDim, this.props.style]} >
<TextInput // the input
ref="input"
{...inputProps}
style={[{
backgroundColor: MKColor.Transparent,
flex: 1,
alignSelf: 'stretch',
paddingTop: 2, paddingBottom: 2,
marginTop: this.state.inputMarginTop,
},
Textfield.defaultProps.textInputStyle,
this.props.textInputStyle,
]}
onChangeText={this._onTextChange.bind(this)}
onFocus={this._onFocus.bind(this)}
onBlur={this._onBlur.bind(this)}
/>
{floatingLabel}
<Underline ref="underline" // the underline
{...underlineProps}
underlineAniDur={this.props.floatingLabelAniDuration}
/>
</View>
);
}
}
// ## <section id='props'>Props</section>
Textfield.propTypes = {
// [TextInput Props](https://facebook.github.io/react-native/docs/textinput.html#props)...
...TextInput.propTypes,
// Alias to `value`
text: PropTypes.string,
// alias to `onChangeText`
onTextChange: PropTypes.func,
// Alias to `secureTextEntry`
password: PropTypes.bool,
// [Floating Label Props](#floatingLabelProps)...
...FloatingLabel.publicPropTypes,
// The highlighted bottom border effect
underlineEnabled: PropTypes.bool,
// The thickness of the Underline
underlineSize: PropTypes.number,
// Color of the highlighted underline, and also the floating label
highlightColor: PropTypes.string,
// Color of the un-highlighted underline, and the placeholder
// - TODO cursor color is not affected for now
// @see https://github.com/facebook/react-native/issues/1685
tintColor: PropTypes.string,
// Style applied to the `TextInput` component, ok to use `StyleSheet`
textInputStyle: PropTypes.any,
};
// ## <section id='defaults'>Defaults</section>
Textfield.defaultProps = {
tintColor: 'rgba(0,0,0,.12)',
highlightColor: getTheme().primaryColor,
floatingLabelFont: {
fontSize: getTheme().fontSize - 2,
},
style: {
width: 100,
height: 39,
},
textInputStyle: {
color: getTheme().fontColor,
fontSize: getTheme().fontSize + 2,
paddingLeft: 0,
paddingRight: 0,
},
underlineEnabled: true,
};
// --------------------------
// Builder
//
const {
Builder,
} = require('../builder');
//
// ## Textfield builder
//
class TextfieldBuilder extends Builder {
constructor() {
super();
this.withBackgroundColor(MKColor.Transparent);
}
// For compatibility with RN version older than 0.9.0.
// > Since [RN v0.9.0][], `TextInput` became a [controlled component][]
// [RN v0.9.0]: https://github.com/facebook/react-native/releases/tag/v0.9.0-rc
// [controlled component]: https://facebook.github.io/react/docs/forms.html#controlled-components
withDefaultValue(defaultValue) {
const propName = Textfield.propTypes.defaultValue ? 'defaultValue' : 'value';
this[propName] = defaultValue;
return this;
}
mergeStyle() {
super.mergeStyle();
if (!this.highlightColor) {
this.highlightColor = this.getThemeColor();
}
}
build() {
const BuiltTextfield = class extends Textfield {};
BuiltTextfield.defaultProps = Object.assign({}, Textfield.defaultProps, this.toProps());
return BuiltTextfield;
}
}
// define builder method for each prop
TextfieldBuilder.defineProps(Textfield.propTypes);
// ----------
// ## <section id="builders">Built-in builders</section>
//
function textfield() {
return new TextfieldBuilder();
}
function textfieldWithFloatingLabel() {
return textfield().withFloatingLabelEnabled(true);
}
// ## Public interface
module.exports = Textfield;
Textfield.Builder = TextfieldBuilder;
Textfield.textfield = textfield;
Textfield.textfieldWithFloatingLabel = textfieldWithFloatingLabel;