Skip to content

Commit

Permalink
feat(map): Add location follow mode & zoom to location on first load
Browse files Browse the repository at this point in the history
On initial load, if there is no GPS location, the map will move to the last known location that the user was at
  • Loading branch information
gmaclennan committed May 4, 2019
1 parent 4aaf9b8 commit 8f7aeaf
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 68 deletions.
16 changes: 11 additions & 5 deletions src/frontend/context/LocationContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { withPermissions, PERMISSIONS, RESULTS } from "./PermissionsContext";
import type { PermissionResult, PermissionsType } from "./PermissionsContext";

const log = debug("mapeo:Location");
const STORE_KEY = "@MapeoPosition";
const STORE_KEY = "@MapeoPosition@1";

type PositionType = {
export type PositionType = {
// The timestamp of when the current position was obtained
timestamp: number,
// Whether the position is mocked or not
Expand Down Expand Up @@ -48,7 +48,7 @@ export type LocationContextType = {
// Whether the user has granted permissions to use location to this app
permission?: PermissionResult,
// This is the previous known position from the last time the app was open
savedPosition?: PositionType,
savedPosition?: PositionType | null,
// True if there is some kind of error getting the device location
error: boolean
};
Expand Down Expand Up @@ -113,7 +113,9 @@ class LocationProvider extends React.Component<Props, LocationContextType> {
this.updateStatus();
try {
const savedPosition = await AsyncStorage.getItem(STORE_KEY);
if (savedPosition != null && !this.state.position) {
if (savedPosition == null) {
this.setState({ savedPosition: null });
} else if (!this.state.position) {
this.setState({ savedPosition: JSON.parse(savedPosition) });
}
} catch (e) {
Expand Down Expand Up @@ -181,7 +183,11 @@ class LocationProvider extends React.Component<Props, LocationContextType> {
};

render() {
return <Provider value={this.state}>{this.props.children}</Provider>;
// Waiting until savedPosition has loaded before first render
// savedPosition will be null if it is loaded but there is no saved position
return this.state.savedPosition === undefined ? null : (
<Provider value={this.state}>{this.props.children}</Provider>
);
}
}

Expand Down
51 changes: 32 additions & 19 deletions src/frontend/screens/MapScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { NavigationScreenConfigProps } from "react-navigation";

import MapView from "../sharedComponents/MapView";
import ObservationsContext from "../context/ObservationsContext";
import LocationContext from "../context/LocationContext";
import { getMapStyleUrl, checkMapStyle } from "../api";

type Props = {
Expand Down Expand Up @@ -37,25 +38,37 @@ class MapStyleProvider extends React.Component<
}
}

const MapScreen = ({ onAddPress, navigation }: Props) => (
<View style={{ flex: 1 }}>
<ObservationsContext.Consumer>
{({ observations }) => (
<MapStyleProvider>
{styleURL => (
<MapView
observations={observations}
onAddPress={onAddPress}
onPressObservation={(observationId: string) =>
navigation.navigate("Observation", { observationId })
}
styleURL={styleURL}
/>
class MapScreen extends React.Component<Props> {
handleObservationPress = (observationId: string) =>
this.props.navigation.navigate("Observation", { observationId });

render() {
const { onAddPress } = this.props;

return (
<View style={{ flex: 1 }}>
<ObservationsContext.Consumer>
{({ observations }) => (
<LocationContext.Consumer>
{location => (
<MapStyleProvider>
{styleURL => (
<MapView
location={location}
observations={observations}
onAddPress={onAddPress}
onPressObservation={this.handleObservationPress}
styleURL={styleURL}
/>
)}
</MapStyleProvider>
)}
</LocationContext.Consumer>
)}
</MapStyleProvider>
)}
</ObservationsContext.Consumer>
</View>
);
</ObservationsContext.Consumer>
</View>
);
}
}

export default MapScreen;
89 changes: 84 additions & 5 deletions src/frontend/sharedComponents/MapView.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
// @flow
import React from "react";
import { View, StyleSheet } from "react-native";
import MapboxGL from "@react-native-mapbox/maps";
import debug from "debug";

// import type { MapStyle } from "../types";
import type { ObservationsMap } from "../context/ObservationsContext";
import AddButton from "./AddButton";
import debug from "debug";
import { LocationFollowingIcon, LocationNoFollowIcon } from "./icons";
import IconButton from "./IconButton";
import type {
LocationContextType,
PositionType
} from "../context/LocationContext";
import type { ObservationsMap } from "../context/ObservationsContext";

const log = debug("mapeo:MapView");

Expand Down Expand Up @@ -84,15 +91,24 @@ class ObservationMapLayer extends React.PureComponent<{
type Props = {
observations: ObservationsMap,
styleURL: string,
location: LocationContextType,
onAddPress: () => any,
onPressObservation: (observationId: string) => any
};

class MapView extends React.Component<Props> {
type State = {
// True if the map is following user location
following: boolean
};

class MapView extends React.Component<Props, State> {
static defaultProps = {
onAddPress: () => {},
onPressObservation: () => {}
};
state = { following: true };
map: any;
initialPosition: void | null | PositionType;

constructor(props: Props) {
super(props);
Expand All @@ -101,9 +117,20 @@ class MapView extends React.Component<Props> {
);
MapboxGL.setTelemetryEnabled(false);
log("accessToken set");
this.initialPosition =
props.location.position || props.location.savedPosition;
}

map: any;
// We only use the location prop (which contains the app GPS location) for the
// first render of the map. After that location updates come from the native
// map view, so we don't want to re-render this component every time there is
// a GPS update
shouldComponentUpdate(nextProps: Props, nextState: State) {
return (
shallowDiffers(this.props, nextProps, ["location"]) ||
shallowDiffers(this.state, nextState, ["location"])
);
}

handleObservationPress = (e: {
nativeEvent?: {
Expand Down Expand Up @@ -133,8 +160,24 @@ class MapView extends React.Component<Props> {
this.map = c;
};

handleRegionChange = (e: any) => {
// Any user interaction with the map switches follow mode to false
if (e.properties.isUserInteraction) this.setState({ following: false });
};

handleLocationPress = () => {
this.setState(state => ({ following: !state.following }));
};

render() {
const { observations, onAddPress, styleURL } = this.props;
const initialCoords = this.initialPosition
? [
this.initialPosition.coords.longitude,
this.initialPosition.coords.latitude
]
: [0, 0];
const initialZoom = this.initialPosition ? 8 : 0;
return (
<>
<MapboxGL.MapView
Expand All @@ -148,17 +191,53 @@ class MapView extends React.Component<Props> {
onPress={this.handleObservationPress}
compassEnabled={false}
styleURL={styleURL}
onRegionWillChange={this.handleRegionChange}
regionWillChangeDebounceTime={200}
>
<MapboxGL.UserLocation showUserLocation={true} />
{!this.state.following && (
<MapboxGL.UserLocation showUserLocation={true} />
)}
<MapboxGL.Camera
centerCoordinate={initialCoords}
zoomLevel={initialZoom}
followUserLocation={this.state.following}
followZoomLevel={12}
/>
<ObservationMapLayer
onPress={this.handleObservationPress}
observations={observations}
/>
</MapboxGL.MapView>
<AddButton onPress={onAddPress} />
<View style={styles.locationButton}>
<IconButton onPress={this.handleLocationPress}>
{this.state.following ? (
<LocationFollowingIcon />
) : (
<LocationNoFollowIcon />
)}
</IconButton>
</View>
</>
);
}
}

export default MapView;

// Shallow compare objects, but omitting certain keys from the comparison
function shallowDiffers(a: any, b: any, omit: string[]) {
for (let i in a) if (!(i in b)) return true;
for (let i in b) {
if (a[i] !== b[i] && omit.indexOf(i) === -1) return true;
}
return false;
}

const styles = StyleSheet.create({
locationButton: {
position: "absolute",
right: 20,
bottom: 20
}
});
41 changes: 2 additions & 39 deletions src/frontend/sharedComponents/icons/CategoryIcon.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
// @flow
import * as React from "react";
import { Image, StyleSheet, View } from "react-native";
import { Image } from "react-native";
import MaterialIcon from "react-native-vector-icons/MaterialIcons";

import Circle from "./Circle";
import { getIconUrl } from "../../api";
import type { IconSize } from "../../types";

type CircleProps = {
radius?: number,
children: React.Node
};

const Circle = ({ radius = 25, children }: CircleProps) => (
<View
style={[
styles.circle,
{
width: radius * 2,
height: radius * 2,
borderRadius: radius * 2
}
]}
>
{children}
</View>
);

type IconProps = {
size?: IconSize,
iconId?: string
Expand Down Expand Up @@ -72,21 +53,3 @@ export const CategoryCircleIcon = ({
<CategoryIcon {...props} />
</Circle>
);

const styles = StyleSheet.create({
circle: {
width: 50,
height: 50,
backgroundColor: "white",
borderRadius: 50,
borderColor: "#EAEAEA",
borderWidth: 1,
justifyContent: "center",
alignItems: "center",
shadowColor: "black",
shadowRadius: 5,
shadowOpacity: 0.5,
shadowOffset: { width: 0, height: 2 },
elevation: 3
}
});
46 changes: 46 additions & 0 deletions src/frontend/sharedComponents/icons/Circle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// @flow
import * as React from "react";
import { StyleSheet, View } from "react-native";
import type { Style } from "../../types";

type CircleProps = {
radius?: number,
children: React.Node,
style?: Style<typeof View>
};

const Circle = ({ radius = 25, style, children }: CircleProps) => (
<View
style={[
styles.circle,
{
width: radius * 2,
height: radius * 2,
borderRadius: radius * 2
},
style
]}
>
{children}
</View>
);

export default Circle;

const styles = StyleSheet.create({
circle: {
width: 50,
height: 50,
backgroundColor: "white",
borderRadius: 50,
borderColor: "#EAEAEA",
borderWidth: 1,
justifyContent: "center",
alignItems: "center",
shadowColor: "black",
shadowRadius: 5,
shadowOpacity: 0.5,
shadowOffset: { width: 0, height: 2 },
elevation: 3
}
});
19 changes: 19 additions & 0 deletions src/frontend/sharedComponents/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MaterialIcon from "react-native-vector-icons/MaterialIcons";
import FontAwesomeIcon from "react-native-vector-icons/FontAwesome";
import { Image, Text } from "react-native";

import Circle from "./Circle";
import { RED, DARK_GREY, MANGO, MEDIUM_GREY } from "../../lib/styles";
import type { Style } from "../../types";

Expand Down Expand Up @@ -159,3 +160,21 @@ export const ObservationListIcon = ({ size = 30, style }: ImageIconProps) => (
style={[{ width: size, height: size }, style]}
/>
);

export const LocationNoFollowIcon = ({
size = 30,
color = MEDIUM_GREY
}: FontIconProps) => (
<Circle radius={25}>
<MaterialIcon color={color} name="location-searching" size={size} />
</Circle>
);

export const LocationFollowingIcon = ({
size = 30,
color = "#4A90E2"
}: FontIconProps) => (
<Circle radius={25}>
<MaterialIcon color={color} name="my-location" size={size} />
</Circle>
);

0 comments on commit 8f7aeaf

Please sign in to comment.