react-native-tooltip-2
Version:
Customizable, easy to use tooltip for React Native
355 lines (353 loc) • 14.2 kB
JavaScript
import React, { Component } from "react";
import { Dimensions, InteractionManager, Modal, TouchableWithoutFeedback, View, } from "react-native";
import rfcIsEqual from "react-fast-compare";
import { computeBottomGeometry, computeCenterGeometry, computeLeftGeometry, computeRightGeometry, computeTopGeometry, makeChildlessRect, Point, Rect, Size, swapSizeDimensions, } from "./geometry";
import styleGenerator from "./Tooltip.style";
import TooltipChildrenContext from "./TooltipChildren.context";
import { Placement } from "./Tooltip.type";
export { TooltipChildrenContext };
const DEFAULT_DISPLAY_INSETS = {
top: 24,
bottom: 24,
left: 24,
right: 24,
};
const computeDisplayInsets = (insetsFromProps) => Object.assign({}, DEFAULT_DISPLAY_INSETS, insetsFromProps);
const invertPlacement = (placement) => {
switch (placement) {
case Placement.TOP:
return Placement.BOTTOM;
case Placement.BOTTOM:
return Placement.TOP;
case Placement.RIGHT:
return Placement.LEFT;
case Placement.LEFT:
return Placement.RIGHT;
default:
return placement;
}
};
class Tooltip extends Component {
static defaultProps = {
allowChildInteraction: true,
arrowSize: new Size(16, 8),
backgroundColor: "rgba(0,0,0,0.5)",
childContentSpacing: 4,
children: null,
closeOnChildInteraction: true,
closeOnContentInteraction: true,
closeOnBackgroundInteraction: true,
content: <View />,
displayInsets: {},
disableShadow: false,
isVisible: false,
onClose: () => {
console.warn("[react-native-tooltip-2] onClose prop not provided");
},
placement: Placement.CENTER,
showChildInTooltip: true,
supportedOrientations: ["portrait", "landscape"],
useInteractionManager: false,
useReactNativeModal: true,
topAdjustment: 0,
horizontalAdjustment: 0,
accessible: true,
};
isMeasuringChild;
interactionPromise;
dimensionsSubscription;
childWrapper;
constructor(props) {
super(props);
const { isVisible, useInteractionManager = Tooltip.defaultProps.useInteractionManager, } = props;
this.isMeasuringChild = false;
this.interactionPromise = null;
this.dimensionsSubscription = null;
this.childWrapper = React.createRef();
this.state = {
// No need to wait for interactions if not visible initially
waitingForInteractions: isVisible && useInteractionManager,
contentSize: new Size(0, 0),
adjustedContentSize: new Size(0, 0),
anchorPoint: new Point(0, 0),
tooltipOrigin: new Point(0, 0),
childRect: new Rect(0, 0, 0, 0),
displayInsets: computeDisplayInsets(props.displayInsets),
// if we have no children, and place the tooltip at the "top" we want it to
// behave like placement "bottom", i.e. display below the top of the screen
placement: React.Children.count(props.children) === 0
? invertPlacement(props.placement)
: props.placement,
measurementsFinished: false,
windowDims: Dimensions.get("window"),
};
}
componentDidMount() {
this.dimensionsSubscription = Dimensions.addEventListener("change", this.updateWindowDims);
}
componentDidUpdate(prevProps, prevState) {
const { content, isVisible, placement } = this.props;
const { displayInsets } = this.state;
const contentChanged = !rfcIsEqual(prevProps.content, content);
const placementChanged = prevProps.placement !== placement;
const becameVisible = isVisible && !prevProps.isVisible;
const insetsChanged = !rfcIsEqual(prevState.displayInsets, displayInsets);
if (contentChanged || placementChanged || becameVisible || insetsChanged) {
setTimeout(() => {
this.measureChildRect();
});
}
}
componentWillUnmount() {
if (this.dimensionsSubscription?.remove) {
// react native >= 0.65.*
this.dimensionsSubscription.remove();
}
else {
// react native < 0.65.*
// @ts-ignore
Dimensions.removeEventListener("change", this.updateWindowDims);
}
if (this.interactionPromise) {
this.interactionPromise.cancel();
}
}
static getDerivedStateFromProps(nextProps, prevState) {
const nextState = {
placement: prevState.placement,
displayInsets: prevState.displayInsets,
measurementsFinished: prevState.measurementsFinished,
adjustedContentSize: prevState.adjustedContentSize,
};
// update placement in state if the prop changed
const nextPlacement = React.Children.count(nextProps.children) === 0
? invertPlacement(nextProps.placement)
: nextProps.placement;
if (nextPlacement !== prevState.placement) {
nextState.placement = nextPlacement || Placement.CENTER;
}
// update computed display insets if they changed
const nextDisplayInsets = computeDisplayInsets(nextProps.displayInsets);
if (!rfcIsEqual(nextDisplayInsets, prevState.displayInsets)) {
nextState.displayInsets = nextDisplayInsets;
}
// set measurements finished flag to false when tooltip closes
if (prevState.measurementsFinished && !nextProps.isVisible) {
nextState.measurementsFinished = false;
nextState.adjustedContentSize = new Size(0, 0);
}
if (Object.keys(nextState).length) {
return nextState;
}
return null;
}
updateWindowDims = (dims) => {
this.setState({
windowDims: dims.window,
contentSize: new Size(0, 0),
adjustedContentSize: new Size(0, 0),
anchorPoint: new Point(0, 0),
tooltipOrigin: new Point(0, 0),
childRect: new Rect(0, 0, 0, 0),
measurementsFinished: false,
}, () => {
setTimeout(() => {
this.measureChildRect();
}, 500); // give the rotation a moment to finish
});
};
doChildlessPlacement = () => {
this.onChildMeasurementComplete(makeChildlessRect({
displayInsets: this.state.displayInsets,
placement: this.state.placement,
windowDims: this.state.windowDims,
}));
};
measureContent = (e) => {
const { width, height } = e.nativeEvent.layout;
const contentSize = new Size(width, height);
this.setState({ contentSize }, () => {
this.computeGeometry();
});
};
onChildMeasurementComplete = (rect) => {
this.setState({
childRect: rect,
waitingForInteractions: false,
}, () => {
this.isMeasuringChild = false;
if (this.state.contentSize.width) {
this.computeGeometry();
}
});
};
measureChildRect = () => {
const doMeasurement = () => {
if (!this.isMeasuringChild) {
this.isMeasuringChild = true;
if (this.childWrapper.current &&
typeof this.childWrapper.current.measure === "function") {
this.childWrapper.current.measure((x, y, width, height, pageX, pageY) => {
const childRect = new Rect(pageX, pageY, width, height);
if (Object.values(childRect).every((value) => value !== undefined)) {
this.onChildMeasurementComplete(childRect);
}
else {
this.doChildlessPlacement();
}
});
}
else {
this.doChildlessPlacement();
}
}
};
if (this.props.useInteractionManager) {
if (this.interactionPromise) {
this.interactionPromise.cancel();
}
this.interactionPromise = InteractionManager.runAfterInteractions(() => {
doMeasurement();
});
}
else {
doMeasurement();
}
};
computeGeometry = () => {
const { arrowSize = Tooltip.defaultProps.arrowSize, childContentSpacing } = this.props;
const { childRect, contentSize, displayInsets, placement, windowDims } = this.state;
const options = {
displayInsets,
childRect,
windowDims,
arrowSize: placement === Placement.TOP || placement === Placement.BOTTOM
? arrowSize
: swapSizeDimensions(arrowSize),
contentSize,
childContentSpacing,
};
let geom = computeTopGeometry(options);
// Special case for centered, childless placement tooltip
if (placement === "center" &&
React.Children.count(this.props.children) === 0) {
geom = computeCenterGeometry(options);
}
else {
switch (placement) {
case Placement.BOTTOM:
geom = computeBottomGeometry(options);
break;
case Placement.LEFT:
geom = computeLeftGeometry(options);
break;
case Placement.RIGHT:
geom = computeRightGeometry(options);
break;
case Placement.TOP:
default:
break; // Computed just above if-else-block
}
}
const { tooltipOrigin, anchorPoint, adjustedContentSize } = geom;
this.setState({
tooltipOrigin,
anchorPoint,
placement,
measurementsFinished: childRect.width && contentSize.width,
adjustedContentSize,
});
};
renderChildInTooltip = () => {
let { height, width, x, y } = this.state.childRect;
if (this.props.horizontalAdjustment) {
x = x + this.props.horizontalAdjustment;
}
const onTouchEnd = () => {
if (this.props.closeOnChildInteraction) {
this.props.onClose();
}
};
return (<TooltipChildrenContext.Provider value={{ tooltipDuplicate: true }}>
<View onTouchEnd={onTouchEnd} pointerEvents={this.props.allowChildInteraction ? "box-none" : "none"} style={[
{
position: "absolute",
height,
width,
top: y,
left: x,
alignItems: "center",
justifyContent: "center",
},
this.props.childrenWrapperStyle,
]}>
{this.props.children}
</View>
</TooltipChildrenContext.Provider>);
};
renderContentForTooltip = () => {
const generatedStyles = styleGenerator({
adjustedContentSize: this.state.adjustedContentSize,
anchorPoint: this.state.anchorPoint,
arrowSize: this.props.arrowSize,
displayInsets: this.state.displayInsets,
measurementsFinished: this.state.measurementsFinished,
ownProps: { ...this.props },
placement: this.state.placement,
tooltipOrigin: this.state.tooltipOrigin,
topAdjustment: this.props.topAdjustment,
});
const hasChildren = React.Children.count(this.props.children) > 0;
const onPressBackground = () => {
if (this.props.closeOnBackgroundInteraction) {
this.props.onClose();
}
};
const onPressContent = () => {
if (this.props.closeOnContentInteraction) {
this.props.onClose();
}
};
return (<TouchableWithoutFeedback onPress={onPressBackground} accessible={this.props.accessible}>
<View style={generatedStyles.containerStyle}>
<View style={[
generatedStyles.backgroundStyle,
this.props.backgroundStyle,
]}>
<View style={generatedStyles.tooltipStyle}>
{hasChildren ? <View style={generatedStyles.arrowStyle}/> : null}
<View onLayout={this.measureContent} style={generatedStyles.contentStyle}>
<TouchableWithoutFeedback onPress={onPressContent} accessible={this.props.accessible}>
{this.props.content}
</TouchableWithoutFeedback>
</View>
</View>
</View>
{hasChildren && this.props.showChildInTooltip
? this.renderChildInTooltip()
: null}
</View>
</TouchableWithoutFeedback>);
};
render() {
const { children, isVisible, useReactNativeModal, modalComponent } = this.props;
const hasChildren = React.Children.count(children) > 0;
const showTooltip = isVisible && !this.state.waitingForInteractions;
const ModalComponent = modalComponent || Modal;
return (<React.Fragment>
{useReactNativeModal ? (<ModalComponent transparent visible={showTooltip} onRequestClose={this.props.onClose} supportedOrientations={this.props.supportedOrientations}>
{this.renderContentForTooltip()}
</ModalComponent>) : null}
{/* This renders the child element in place in the parent's layout */}
{hasChildren ? (<View ref={this.childWrapper} onLayout={this.measureChildRect} style={this.props.parentWrapperStyle}>
{children}
</View>) : null}
{!useReactNativeModal && showTooltip
? this.renderContentForTooltip()
: null}
</React.Fragment>);
}
}
Tooltip.defaultProps = Tooltip.defaultProps;
export default Tooltip;
//# sourceMappingURL=Tooltip.js.map