@ohmi/react-native-safe-area-view
Version:
JS only version of SafeAreaView for supporting iPhone X safe area insets.
398 lines (317 loc) • 9.7 kB
JavaScript
import React, { Component } from 'react';
import {
Dimensions,
InteractionManager,
Platform,
StyleSheet,
Animated,
StatusBar,
} from 'react-native';
import hoistStatics from 'hoist-non-react-statics';
import withOrientation from './withOrientation';
// See https://mydevice.io/devices/ for device dimensions
const X_WIDTH = 375;
const X_HEIGHT = 812;
const XSMAX_WIDTH = 414;
const XSMAX_HEIGHT = 896;
const PAD_WIDTH = 768;
const PAD_HEIGHT = 1024;
// const { height: D_HEIGHT, width: D_WIDTH } = Dimensions.get('window');
const getResolvedDimensions = () => {
const { width, height } = Dimensions.get('window');
if (width === 0 && height === 0) return Dimensions.get('screen');
return { width, height };
};
const { height: D_HEIGHT, width: D_WIDTH } = getResolvedDimensions();
const isIPhoneX = (() => {
if (Platform.OS === 'web') return false;
return (
Platform.OS === 'ios' &&
((D_HEIGHT === X_HEIGHT && D_WIDTH === X_WIDTH) ||
(D_HEIGHT === X_WIDTH && D_WIDTH === X_HEIGHT)) ||
((D_HEIGHT === XSMAX_HEIGHT && D_WIDTH === XSMAX_WIDTH) ||
(D_HEIGHT === XSMAX_WIDTH && D_WIDTH === XSMAX_HEIGHT))
);
})();
const isIPad = (() => {
if (Platform.OS !== 'ios' || isIPhoneX) return false;
if (D_HEIGHT > D_WIDTH && D_WIDTH < PAD_WIDTH) {
return false;
}
if (D_WIDTH > D_HEIGHT && D_HEIGHT < PAD_WIDTH) {
return false;
}
return true;
})();
let _customStatusBarHeight = null;
const statusBarHeight = (isLandscape, forceInset) => {
if (_customStatusBarHeight !== null) {
return _customStatusBarHeight;
}
if (Platform.OS === 'android') {
if (global.Expo) {
return global.Expo.Constants.statusBarHeight;
} else {
return 0;
}
}
if (Platform.OS === 'harmony') {
if (forceInset?.top === 'never') {
return 0;
} else {
return isLandscape ? 0 : 35;
}
}
if (isIPhoneX) {
return isLandscape ? 0 : 44;
}
if (isIPad) {
return 20;
}
return isLandscape ? 0 : 20;
};
const doubleFromPercentString = percent => {
if (!percent.includes('%')) {
return 0;
}
const dbl = parseFloat(percent) / 100;
if (isNaN(dbl)) return 0;
return dbl;
};
class SafeView extends Component {
static setStatusBarHeight = height => {
_customStatusBarHeight = height;
};
state = {
touchesTop: true,
touchesBottom: true,
touchesLeft: true,
touchesRight: true,
orientation: null,
viewWidth: 0,
viewHeight: 0,
};
componentDidMount() {
this._isMounted = true;
InteractionManager.runAfterInteractions(() => {
this._onLayout();
});
}
componentWillUnmount() {
this._isMounted = false;
}
componentWillReceiveProps() {
this._onLayout();
}
render() {
const { forceInset = false, isLandscape, style, showTop, ...props } = this.props;
const safeAreaStyle = this._getSafeAreaStyle();
return (
<Animated.View
ref={c => (this.view = c)}
pointerEvents='box-none'
{...props}
onLayout={this._onLayout}
style={safeAreaStyle}
/>
);
}
_onLayout = (...args) => {
if (!this._isMounted) return;
if (!this.view) return;
const { isLandscape } = this.props;
const { orientation } = this.state;
const newOrientation = isLandscape ? 'landscape' : 'portrait';
const insets = this.props.forceInset || {};
if (orientation && orientation === newOrientation) {
return;
}
const { width: WIDTH, height: HEIGHT } = getResolvedDimensions();
// getNode() is not necessary in newer versions of React Native
const node = typeof this.view.measureInWindow === 'function' ? this.view : this.view.getNode();
node.measureInWindow((winX, winY, winWidth, winHeight) => {
if (!this.view) {
return;
}
let realY = winY;
let realX = winX;
if (realY >= HEIGHT) {
realY = realY % HEIGHT;
} else if (realY < 0) {
realY = (realY % HEIGHT) + HEIGHT;
}
if (realX >= WIDTH) {
realX = realX % WIDTH;
} else if (realX < 0) {
realX = (realX % WIDTH) + WIDTH;
}
const touchesTop = realY === 0;
const touchesBottom = realY + winHeight >= HEIGHT;
const touchesLeft = realX === 0;
const touchesRight = realX + winWidth >= WIDTH;
this.setState({
touchesTop,
touchesBottom,
touchesLeft,
touchesRight,
orientation: newOrientation,
viewWidth: winWidth,
viewHeight: winHeight,
});
});
};
// this.view._component.measureInWindow((winX, winY, winWidth, winHeight) => {
// if (!this.view) {
// return;
// }
// let realY = winY;
// let realX = winX;
// if (realY >= HEIGHT) {
// realY = realY % HEIGHT;
// } else if (realY < 0) {
// realY = realY % HEIGHT + HEIGHT;
// }
// if (realX >= WIDTH) {
// realX = realX % WIDTH;
// } else if (realX < 0) {
// realX = realX % WIDTH + WIDTH;
// }
// const touchesTop = realY === 0;
// const touchesBottom = realY + winHeight >= HEIGHT;
// const touchesLeft = realX === 0;
// const touchesRight = realX + winWidth >= WIDTH;
// this.setState({
// touchesTop,
// touchesBottom,
// touchesLeft,
// touchesRight,
// orientation: newOrientation,
// viewWidth: winWidth,
// viewHeight: winHeight,
// });
// if (this.props.onLayout) this.props.onLayout(...args);
// });
// };
_getSafeAreaStyle = () => {
const { touchesTop, touchesBottom, touchesLeft, touchesRight } = this.state;
const { forceInset, isLandscape } = this.props;
const {
paddingTop,
paddingBottom,
paddingLeft,
paddingRight,
viewStyle,
} = this._getViewStyles();
if (viewStyle.height > StatusBar.currentHeight) {
viewStyle.height = StatusBar.currentHeight + 20
}
const style = {
...viewStyle,
paddingTop: touchesTop ? this._getInset('top') : 0,
paddingBottom: touchesBottom ? this._getInset('bottom') : 0,
paddingLeft: touchesLeft ? this._getInset('left') : 0,
paddingRight: touchesRight ? this._getInset('right') : 0,
marginTop: Platform.OS === 'harmony' ? this._getInset('top') : 0,
};
if (forceInset) {
Object.keys(forceInset).forEach(key => {
let inset = forceInset[key];
if (inset === 'always') {
inset = this._getInset(key);
}
if (inset === 'never') {
inset = 0;
}
switch (key) {
case 'horizontal': {
style.paddingLeft = inset;
style.paddingRight = inset;
break;
}
case 'vertical': {
style.paddingTop = inset;
style.paddingBottom = inset;
break;
}
case 'left':
case 'right':
case 'top':
case 'bottom': {
const padding = `padding${key[0].toUpperCase()}${key.slice(1)}`;
style[padding] = inset;
break;
}
}
});
}
return style;
};
_getViewStyles = () => {
const { viewWidth } = this.state;
// get padding values from style to add back in after insets are determined
// default precedence: padding[Side] -> vertical | horizontal -> padding -> 0
let {
padding = 0,
paddingVertical = padding,
paddingHorizontal = padding,
paddingTop = paddingVertical,
paddingBottom = paddingVertical,
paddingLeft = paddingHorizontal,
paddingRight = paddingHorizontal,
...viewStyle
} = StyleSheet.flatten(this.props.style || {});
if (typeof paddingTop !== 'number') {
paddingTop = doubleFromPercentString(paddingTop) * viewWidth;
}
if (typeof paddingBottom !== 'number') {
paddingBottom = doubleFromPercentString(paddingBottom) * viewWidth;
}
if (typeof paddingLeft !== 'number') {
paddingLeft = doubleFromPercentString(paddingLeft) * viewWidth;
}
if (typeof paddingRight !== 'number') {
paddingRight = doubleFromPercentString(paddingRight) * viewWidth;
}
return {
paddingTop,
paddingBottom,
paddingLeft,
paddingRight,
viewStyle,
};
};
_getInset = key => {
const { isLandscape, forceInset } = this.props;
switch (key) {
case 'horizontal':
case 'right':
case 'left': {
return isLandscape ? (isIPhoneX ? 44 : 0) : 0;
}
case 'vertical':
case 'top': {
return statusBarHeight(isLandscape, forceInset);
}
case 'bottom': {
return isIPhoneX ? (isLandscape ? 24 : 34) : 0;
}
}
};
}
const SafeAreaView = withOrientation(SafeView);
export default SafeAreaView;
const withSafeArea = function (forceInset = {}) {
return (WrappedComponent) => {
class withSafeArea extends Component {
render() {
return (
<SafeAreaView style={{ flex: 1 }} forceInset={forceInset}>
<WrappedComponent {...this.props} />
</SafeAreaView>
);
}
}
return hoistStatics(withSafeArea, WrappedComponent);
};
}
export { withSafeArea };