react-native-webview-leaflet
Version:
A React Native component that uses a React Native WebView to provide a Leaflet map.
576 lines (536 loc) • 17 kB
JavaScript
import React from "react";
import {
View,
StyleSheet,
ActivityIndicator,
WebView,
Platform,
Text
} from "react-native";
import PropTypes from "prop-types";
import Button from "./Button";
import AssetUtils from "expo-asset-utils";
const util = require("util");
const isValidCoordinates = require("is-valid-coordinates");
const uniqby = require("lodash.uniqby");
// look up these issues related to including index.html
// https://github.com/facebook/react-native/issues/8996
// https://github.com/facebook/react-native/issues/16133
// iOS version webview can reference the index.html asset like as a path
const INDEX_FILE_PATH = require(`./assets/dist/index.html`);
// DOES NOT WORKthis is required for Android Version
// const INDEX_FILE_ASSET_URI = AssetUtils.resolveAsync(rINDEX_FILE_PATH).uri;
// const INDEX_FILE = require(INDEX_FILE_PATH);
const MESSAGE_PREFIX = "react-native-webview-leaflet";
export default class WebViewLeaflet extends React.Component {
constructor(props) {
super(props);
this.state = {
mapLoaded: false,
webviewErrorMessages: [],
hasError: false,
hasErrorMessage: "",
hasErrorInfo: ""
};
this.indexFileAsset = undefined;
}
componentDidMount = async () => {
if (Platform.OS === "ios") {
this.indexFileAsset = await AssetUtils.resolveAsync(INDEX_FILE_PATH);
// console.log(this.indexFileAsset);
}
};
componentDidCatch(error, info) {
// Display fallback UI
this.setState({
hasError: true,
hasErrorMessage: error,
hasErrorInfo: info
});
}
componentDidUpdate = (prevProps, prevState) => {
// check that centerPosition prop exists,
// that the current centerPosition does not equal the previous one,
// and that the centerPosition is a valid lat, lng
// if so, send a message to the map to update its current position
if (
this.props.centerPosition &&
this.props.centerPosition.length == 2 &&
prevProps.centerPosition !== this.props.centerPosition
) {
if (
isValidCoordinates(
this.props.centerPosition[1],
this.props.centerPosition[0]
)
) {
this.sendMessage({ centerPosition: this.props.centerPosition });
// store the center position so that we can ensure the map gets it upon
// its loading since it is possible that the position might
// be availible before the map has been loaded
this.setState({ centerPosition: this.props.centerPosition });
} else {
console.warn(
"Invalid coordinates provided to centerPosition: ",
this.props.centerPosition
);
}
}
// handle updates to own position
if (
this.props.ownPositionMarker &&
this.props.ownPositionMarker.coords &&
this.props.ownPositionMarker.coords.length === 2 &&
JSON.stringify(prevProps.ownPositionMarker) !==
JSON.stringify(this.props.ownPositionMarker)
) {
if (
isValidCoordinates(
this.props.ownPositionMarker.coords[1],
this.props.ownPositionMarker.coords[0]
)
) {
console.log("****** sending position");
// this.sendMessage({ ownPositionMarker: this.props.ownPositionMarker });
// store the center position so that we can ensure the map gets it upon
// its loading since it is possible that the position might
// be availible before the map has been loaded
this.setState({ ownPositionMarker: this.props.ownPositionMarker });
} else {
console.warn(
"Invalid coordinates provided to ownPositionMarker: ",
this.props.ownPositionMarker.coords
);
}
}
// handle updates to map markers array
if (this.props.markers && prevProps.markers !== this.props.markers) {
// debugger;
let validLocations = this.props.markers.filter(marker => {
if (!marker || !marker.coords || marker.coords.length !== 2)
return false;
return isValidCoordinates(marker.coords[1], marker.coords[0]);
});
this.sendMessage({ locations: validLocations });
// store the center position so that we can ensure the map gets it upon
// its loading since it is possible that the position might
// be availible before the map has been loaded
this.setState({ locations: validLocations });
}
if (
this.props.useMarkerClustering &&
this.props.useMarkerClustering !== prevProps.useMarkerClustering
) {
this.sendMessage({ useMarkerClustering: this.props.useMarkerClustering });
}
// do the same for using map bounds
if (
this.props.hasOwnProperty("bounds") &&
this.props.bounds !== prevProps.bounds
) {
this.sendMessage({ bounds: this.props.bounds });
}
// do the same for using map boundsOptions
if (
this.props.hasOwnProperty("boundsOptions") &&
this.props.boundsOptions !== prevProps.boundsOptions
) {
this.sendMessage({ boundsOptions: this.props.boundsOptions });
}
// actions to be performed one time immediately after the map
// completes loading
if (!prevState.mapLoaded && this.state.mapLoaded) {
this.doPostMapLoadedActions();
}
};
doPostMapLoadedActions = () => {
// Here is our chance to send stuff to the map once it has loaded
// Create an object that will have the update that the map will
// get once it has loaded
let onMapLoadedUpdate = {
mapLayers: this.props.mapLayers
};
// Check the state for any items that may have been received prior to
// the map loading, and send them to the map
// check if we have a center position
if (
this.props.centerPosition &&
this.props.centerPosition.length === 2 &&
isValidCoordinates(
this.props.centerPosition[1],
this.props.centerPosition[0]
)
) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
centerPosition: this.props.centerPosition
};
}
// do the same for ownPostionMarker
if (
this.props.ownPositionMarker &&
this.props.ownPositionMarker.coords &&
this.props.ownPositionMarker.coords.length == 2 &&
isValidCoordinates(
this.props.ownPositionMarker.coords[1],
this.props.ownPositionMarker.coords[0]
)
) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
ownPositionMarker: this.props.ownPositionMarker
};
}
// do the same for map markers
if (this.props.markers) {
let validLocations = this.props.markers.filter(marker => {
if (!marker || !marker.coords || marker.coords.length !== 2)
return false;
return isValidCoordinates(marker.coords[1], marker.coords[0]);
});
onMapLoadedUpdate = {
...onMapLoadedUpdate,
locations: validLocations
};
}
// do the same for zoom
if (this.props.zoom) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
zoom: this.props.zoom
};
}
// do the same for using marker clustering
if (this.props.useMarkerClustering) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
useMarkerClustering: this.props.useMarkerClustering
};
}
// do the same for using map bounds
if (this.props.bounds) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
...{ bounds: this.props.bounds }
};
}
if (this.props.boundsOptions) {
onMapLoadedUpdate = {
...onMapLoadedUpdate,
...{ boundsOptions: this.props.boundsOptions }
};
}
if (Object.keys(onMapLoadedUpdate).length > 0) {
// console.log({ onMapLoadedUpdate });
this.sendMessage(onMapLoadedUpdate);
}
};
// data to send is an object containing key value pairs that will be
// spread into the destination's state
sendMessage = payload => {
if (this.state.mapLoaded) {
// only send message when webview is loaded
const message = JSON.stringify({
prefix: MESSAGE_PREFIX,
payload
});
// If the user has sent a centering messaging, then store the location
// so that we can refer to it later if the built in centering button
// is pressed
/* if (payload.centerPosition) {
this.setState({ centerPosition: payload.centerPosition });
} */
// console.log(`WebViewLeaflet: sending message: `, JSON.stringify(message));
this.webview.postMessage(message, "*");
}
};
//
handleMessage = data => {
let msgData;
// console.log({ data });
msgData = JSON.parse(data);
if (msgData.hasOwnProperty("prefix") && msgData.prefix === MESSAGE_PREFIX) {
// console.log(`WebViewLeaflet: received message: `, msgData.payload);
// if we receive an event, then pass it to the parent by calling
// the parent function wtith the same name as the event, and passing
// the entire payload as a parameter
if (
msgData.payload.event &&
this.props.eventReceiver.hasOwnProperty(msgData.payload.event)
) {
this.props.eventReceiver[msgData.payload.event](msgData.payload);
}
// WebViewLeaflet will also need to know of some state changes, such as
// when the mapComponent is mounted
else {
this.props.eventReceiver.setState({
state: {
...this.props.eventReceiver.state,
mapState: {
...this.props.eventReceiver.mapState,
...msgData.payload
}
}
});
}
}
};
validateLocations = locations => {
// confirm the location coordinates are valid
const validCoordLocations = locations.filter(location => {
return isValidCoordinates(location.coords[1], location.coords[0]);
});
// remove any locations that are already in the component state's "locations"
// create a new array containing all the locations
let combinedArray = [...this.state.locations, ...validCoordLocations];
// remove duplicate locations
const deDupedLocations = uniqby(combinedArray, "id");
this.sendLocations(deDupedLocations);
this.setState({ locations: deDupedLocations });
};
onError = error => {
this.setState({
webviewErrorMessages: [...this.state.webviewErrorMessages, error]
});
};
renderError = error => {
this.setState({
webviewErrorMessages: [...this.state.webviewErrorMessages, error]
});
};
renderLoadingIndicator = () => {
return (
<View style={styles.activityOverlayStyle}>
<View style={styles.activityIndicatorContainer}>
<ActivityIndicator
size="large"
animating={!this.props.eventReceiver.state.mapsState.mapLoaded}
/>
</View>
</View>
);
};
maybeRenderMap = () => {
try {
return (
<View style={{ flex: 1, overflow: "hidden" }}>
<WebView
style={{
...StyleSheet.absoluteFillObject
}}
ref={ref => {
this.webview = ref;
}}
/* source={INDEX_FILE} */
source={
Platform.OS === "ios"
? {uri: this.indexFileAsset.uri}
: { uri: this.indexFileAsset.uri }
}
startInLoadingState={true}
renderLoading={this.renderLoading}
renderError={error => {
console.log(
"RENDER ERROR: ",
util.inspect(error, {
showHidden: false,
depth: null
})
);
}}
javaScriptEnabled={true}
onError={error => {
console.log(
"ERROR: ",
util.inspect(error, {
showHidden: false,
depth: null
})
);
}}
scalesPageToFit={false}
mixedContentMode={"always"}
onMessage={event => {
if (event && event.nativeEvent && event.nativeEvent.data) {
this.handleMessage(event.nativeEvent.data);
}
}}
onLoadStart={() => {}}
onLoadEnd={() => {
if (this.props.eventReceiver.hasOwnProperty("onLoad")) {
this.props.eventReceiver.onLoad();
}
// Set the component state to showw that the map has been loaded.
// This will let us do things during component update once the map
// is loaded.
this.setState({ mapLoaded: true });
}}
domStorageEnabled={true}
/>
</View>
);
} catch (error) {
console.log({ error });
debugger;
/* return (
<View>
<Text>{JSON.stringify(error)}</Text>
</View>
); */
return null;
}
};
maybeRenderWebviewError = () => {
if (this.state.webviewErrorMessages.length > 0) {
return (
<View style={{ zIndex: 2000, backgroundColor: "orange", margin: 4 }}>
{this.state.webviewErrorMessages.map((errorMessage, index) => {
return <Text key={index}>{errorMessage}</Text>;
})}
</View>
);
}
return null;
};
maybeRenderErrorBoundaryMessage = () => {
if (this.state.hasError)
return (
<View style={{ zIndex: 2000, backgroundColor: "red", margin: 5 }}>
{util.inspect(this.state.webviewErrorMessages, {
showHidden: false,
depth: null
})}
</View>
);
return null;
};
renderCenterOnOwnPositionMarkerButton = () => {
if (this.props.ownPositionMarker) {
if (
this.props.ownPositionMarker.coords &&
this.props.ownPositionMarker.coords.length == 2 &&
isValidCoordinates(
this.props.ownPositionMarker.coords[1],
this.props.ownPositionMarker.coords[0]
)
) {
return (
<View
style={{
position: "absolute",
right: 10,
bottom: 20,
padding: 10
}}
>
<Button
onPress={() => {
this.sendMessage({
centerPosition: this.props.ownPositionMarker.coords
});
}}
text={"🎯"}
/>
</View>
);
}
return null;
} else {
console.warn(
"Prop 'ownPositionMarker' must be passed in order to display the center on own position button."
);
return null;
}
};
render() {
return (
<View
style={{
flex: 1
}}
>
<View
style={{
...StyleSheet.absoluteFillObject,
backgroundColor: "#fff1ad"
}}
>
{this.indexFileAsset ? this.maybeRenderMap() : null}
{this.maybeRenderErrorBoundaryMessage()}
{this.maybeRenderWebviewError()}
{this.props.centerButton
? this.renderCenterOnOwnPositionMarkerButton()
: null}
</View>
</View>
);
}
}
WebViewLeaflet.propTypes = {
defaultIconSize: PropTypes.array,
currentPosition: PropTypes.array,
locations: PropTypes.array,
onMapClicked: PropTypes.func,
onMarkerClicked: PropTypes.func,
onWebviewReady: PropTypes.func,
panToLocation: PropTypes.bool,
zoom: PropTypes.number,
showZoomControls: PropTypes.bool,
centerButton: PropTypes.bool,
showMapAttribution: PropTypes.bool,
currentPositionMarkerStyle: PropTypes.object,
onCurrentPositionClicked: PropTypes.func
};
WebViewLeaflet.defaultProps = {
defaultIconSize: [36, 36],
zoom: 5,
showZoomControls: true,
centerButton: false,
panToLocation: false,
showMapAttribution: false,
currentPosition: [38.89511, -77.03637],
currentPositionMarkerStyle: {
icon: "❤️",
animation: {
name: "beat",
duration: 0.25,
delay: 0,
interationCount: "infinite",
direction: "alternate"
},
size: [36, 36]
},
useMarkerClustering: false
};
const styles = StyleSheet.create({
activityOverlayStyle: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(255, 255, 255, .5)",
display: "flex",
justifyContent: "center",
alignContent: "center",
borderRadius: 5
},
activityIndicatorContainer: {
backgroundColor: "lightgray",
padding: 10,
borderRadius: 50,
alignSelf: "center",
shadowColor: "#000000",
shadowOffset: {
width: 0,
height: 3
},
shadowRadius: 5,
shadowOpacity: 1.0
},
button: Platform.select({
ios: {},
android: {
elevation: 4,
// Material design blue from https://material.google.com/style/color.html#color-color-palette
backgroundColor: "#2196F3",
borderRadius: 2
}
})
});