react-native-walkthrough-tooltip
Version:
An inline wrapper for calling out React Native components via tooltip
439 lines (390 loc) • 12.4 kB
JavaScript
import React, { Component } from "react";
import PropTypes from "prop-types";
import {
Dimensions,
InteractionManager,
Modal,
TouchableWithoutFeedback,
View
} from "react-native";
import rfcIsEqual from "react-fast-compare";
import {
Point,
Size,
Rect,
swapSizeDimmensions,
makeChildlessRect,
computeCenterGeometry,
computeTopGeometry,
computeBottomGeometry,
computeLeftGeometry,
computeRightGeometry
} from "./geom";
import styleGenerator from "./styles";
import TooltipChildrenContext from "./tooltip-children.context";
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 "top":
return "bottom";
case "bottom":
return "top";
case "right":
return "left";
case "left":
return "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,
content: <View />,
displayInsets: {},
isVisible: false,
onClose: () => {
console.warn(
"[react-native-walkthrough-tooltip] onClose prop no provided"
);
},
placement: "center", // falls back to "top" if there ARE children
showChildInTooltip: true,
supportedOrientations: ["portrait", "landscape"],
useInteractionManager: false,
useReactNativeModal: true
};
static propTypes = {
allowChildInteraction: PropTypes.bool,
arrowSize: PropTypes.shape({
height: PropTypes.number,
width: PropTypes.number
}),
backgroundColor: PropTypes.string,
childContentSpacing: PropTypes.number,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
closeOnChildInteraction: PropTypes.bool,
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
displayInsets: PropTypes.shape({
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number
}),
isVisible: PropTypes.bool,
onClose: PropTypes.func,
placement: PropTypes.oneOf(["top", "left", "bottom", "right", "center"]),
showChildInTooltip: PropTypes.bool,
supportedOrientations: PropTypes.arrayOf(PropTypes.string),
useInteractionManager: PropTypes.bool,
useReactNativeModal: PropTypes.bool
};
constructor(props) {
super(props);
const { isVisible, useInteractionManager } = props;
this.isMeasuringChild = false;
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,
readyToComputeGeom: false,
waitingToComputeGeom: false,
measurementsFinished: false,
windowDims: Dimensions.get("window")
};
}
componentDidMount() {
if (this.state.waitingForInteractions) {
this.measureChildRect();
}
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() {
Dimensions.removeEventListener("change", this.updateWindowDims);
}
static getDerivedStateFromProps(nextProps, prevState) {
const nextState = {};
// 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;
}
// 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;
}
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),
readyToComputeGeom: false,
waitingToComputeGeom: false,
measurementsFinished: false
},
() => {
setTimeout(() => {
this.measureChildRect();
}, 500); // give the rotation a moment to finish
}
);
};
doChildlessPlacement = () => {
this.onMeasurementComplete(
makeChildlessRect({
displayInsets: this.state.displayInsets,
placement: this.state.placement, // MUST use from state, not props
windowDims: this.state.windowDims
})
);
};
measureContent = (e) => {
const { width, height } = e.nativeEvent.layout;
const contentSize = new Size(width, height);
if (!this.state.readyToComputeGeom) {
this.setState({
waitingToComputeGeom: true,
contentSize
});
} else {
this.setState({ contentSize }, () => {
this._updateGeometry();
});
}
if (React.Children.count(this.props.children) === 0) {
this.doChildlessPlacement();
}
};
onMeasurementComplete = (rect) => {
this.setState(
{
childRect: rect,
readyToComputeGeom: true,
waitingForInteractions: false
},
() => {
this.isMeasuringChild = false;
this._updateGeometry();
}
);
};
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);
this.onMeasurementComplete(childRect);
}
);
} else {
this.doChildlessPlacement();
}
}
};
if (this.props.useInteractionManager) {
InteractionManager.runAfterInteractions(() => {
doMeasurement();
});
} else {
doMeasurement();
}
};
_updateGeometry = () => {
const { contentSize } = this.state;
const geom = this.computeGeometry({ contentSize });
const { tooltipOrigin, anchorPoint, placement, adjustedContentSize } = geom;
this.setState({
tooltipOrigin,
anchorPoint,
placement,
readyToComputeGeom: undefined,
waitingToComputeGeom: false,
measurementsFinished: true,
adjustedContentSize
});
};
computeGeometry = ({ contentSize, placement }) => {
const innerPlacement = placement || this.state.placement;
const { arrowSize, childContentSpacing } = this.props;
const { childRect, displayInsets, windowDims } = this.state;
const options = {
displayInsets,
childRect,
windowDims,
arrowSize:
innerPlacement === "top" || innerPlacement === "bottom"
? arrowSize
: swapSizeDimmensions(arrowSize),
contentSize,
childContentSpacing
};
// special case for centered, childless placement tooltip
if (
innerPlacement === "center" &&
React.Children.count(this.props.children) === 0
) {
return computeCenterGeometry(options);
}
switch (innerPlacement) {
case "bottom":
return computeBottomGeometry(options);
case "left":
return computeLeftGeometry(options);
case "right":
return computeRightGeometry(options);
case "top":
default:
return computeTopGeometry(options);
}
};
renderChildInTooltip = () => {
const { height, width, x, y } = this.state.childRect;
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.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
});
const hasChildren = React.Children.count(this.props.children) > 0;
return (
<TouchableWithoutFeedback onPress={this.props.onClose}>
<View style={generatedStyles.containerStyle}>
<View style={[generatedStyles.backgroundStyle]}>
<View style={generatedStyles.tooltipStyle}>
{hasChildren ? <View style={generatedStyles.arrowStyle} /> : null}
<View
onLayout={this.measureContent}
style={generatedStyles.contentStyle}
>
{this.props.content}
</View>
</View>
</View>
{hasChildren && this.props.showChildInTooltip
? this.renderChildInTooltip()
: null}
</View>
</TouchableWithoutFeedback>
);
};
render() {
const { children, isVisible, useReactNativeModal } = this.props;
const hasChildren = React.Children.count(children) > 0;
const showTooltip = isVisible && !this.state.waitingForInteractions;
return (
<React.Fragment>
{useReactNativeModal ? (
<Modal
transparent
visible={showTooltip}
onRequestClose={this.props.onClose}
supportedOrientations={this.props.supportedOrientations}
>
{this.renderContentForTooltip()}
</Modal>
) : null}
{/* This renders the child element in place in the parent's layout */}
{hasChildren ? (
<View ref={this.childWrapper} onLayout={this.measureChildRect}>
{children}
</View>
) : null}
{!useReactNativeModal && showTooltip
? this.renderContentForTooltip()
: null}
</React.Fragment>
);
}
}
export default Tooltip;