React Native: How to stop map markers from re-rendering on every state update - reactjs

I have a component that has a map with multiple custom markers for various locations and a carousel with cards for those same locations. When a user presses a marker, it should show the callout and show the location's name next to the marker (but outside of the callout).
However, because I update the state in onRegionChangeComplete, if the user moves the map and then quickly presses the marker (before the state finishes updating from calling setState in onRegionChangeComplete), then the markers will re-render before firing the onPress event, and the event is never fired.
One solution might be to use shouldComponentUpdate, however, the docs state that it should only be used for performance optimization and not to prevent re-renders (https://reactjs.org/docs/react-component.html#shouldcomponentupdate), but more importantly, my componentDidUpdate function has some conditional logic dependent on the region set in shouldComponentUpdate, as well as other conditional actions, so I don't want to prevent re-rendering of the entire component, just unnecessary re-rendering of the markers.
I'm also using the performance optimization mentioned in https://github.com/react-native-community/react-native-maps/issues/2082 of wrapping the makers in a component that implements shouldComponentUpdate and getDerivedStateFromProps, however, I'm not entirely sure this is doing anything because it seems like the parent component is just recreating all of my optimized markers rather than using their optimizations to handle re-rendering. Also, even if I don't use a wrapped marker but a conventional custom marker, I still have the same issues.
I've also opened an issue for this on react-native-maps but haven't gotten a response yet: https://github.com/react-native-community/react-native-maps/issues/2860
My 'onRegionComplete' function that updates state when map is moved. I removed a few other conditional state updates for brevity:
onRegionChangeComplete = (region) => {
const nextState = { };
nextState.region = region;
if (this.state.showNoResultsCard) {
nextState.showNoResultsCard = false;
}
.
.
.
this.setState({ ...nextState });
this.props.setSearchRect({
latitude1: region.latitude + (region.latitudeDelta / 2),
longitude1: region.longitude + (region.longitudeDelta / 2),
latitude2: region.latitude - (region.latitudeDelta / 2),
longitude2: region.longitude - (region.longitudeDelta / 2)
});
}
MapView using the more conventional marker (not the optomized version):
<MapView // show if loaded or show a message asking for location
provider={PROVIDER_GOOGLE}
style={{ flex: 1, minHeight: 200, minWidth: 200 }}
initialRegion={constants.initialRegion}
ref={this.mapRef}
onRegionChange={this.onRegionChange}
onRegionChangeComplete={this.onRegionChangeComplete}
showsUserLocationButton={false}
showsPointsOfInterest={false}
showsCompass={false}
moveOnMarkerPress={false}
onMapReady={this.onMapReady}
customMapStyle={mapStyle}
zoomTapEnabled={false}
>
{this.state.isMapReady && this.props.places.map((place, index) => {
const calloutText = this.getDealText(place, 'callout');
return (
<Marker
tracksViewChanges
key={Shortid.generate()}
ref={(ref) => { this.markers[index] = ref; }}
coordinate={{
latitude: place.getLatitude(),
longitude: place.getLongitude()
}}
onPress={() => { this.onMarkerSelect(index); }}
anchor={{ x: 0.05, y: 0.9 }}
centerOffset={{ x: 400, y: -60 }}
calloutOffset={{ x: 8, y: 0 }}
calloutAnchor={{ x: 0.075, y: 0 }}
image={require('../../Assets/icons8-marker-80.png')}
style={index === this.state.scrollIndex ? { zIndex: 2 } : null}
>
{this.state.scrollIndex === index &&
<Text style={styles.markerTitle}>{place.getName()}</Text>}
<Callout onPress={() => this.onCalloutTap(place)} tooltip={false}>
<View style={{
borderColor: red,
width: 240,
borderWidth: 0,
borderRadius: 20,
paddingHorizontal: 8,
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'center'
}}
>
<Text style={styles.Title}>Now:</Text>
<View style={{
width: 240,
flexDirection: 'column',
justifyContent: 'space-evenly',
alignItems: 'flex-start',
paddingHorizontal: 8,
flex: 1
}}
>
{calloutText.Text}
</View>
</View>
</Callout>
</Marker>
);
})
}
</MapView>
My function for the marker's on press event:
onMarkerSelect(index) {
this.setState({ scrollIndex: index });
this.carousel._component.scrollToIndex({
index,
animated: true,
viewOffset: 0,
viewPosition: 0.5
});
this.markers[index].redrawCallout();
}
Updating state and then quickly pressing a marker will cause the onPress event not to fire. Also, the markers are re-rendered/recreated every time a parent component is updated. (I say recreated because it seems like the markers are re-rendering without even firing shouldComponentUpdate or componentDidUpdate).
Is there any way to update state in onRegionChangeComplete without forcing the markers to re-render?

For anyone else who happens to have this problem, the issue was that I was randomly generating the keys for the markers, causing the parent component to create new markers each time it was re-rendered.
Specifically, the line key={Shortid.generate()} was the problem.

Related

Using Hooks API: does React respect setState order?

I have fairly nonexistent knowledge in react but I'm learning as I go. I learned the basics back in school, with class components (classic React), but now I'm delving into the Hooks API (mainly because I find it easier to learn and manage, although there seems to be more tricks involved regarding async behavior). So my question might seem silly.
I found this thread regarding setState behavior on the same topic, but this is regarding class components.
In my current application, I'm trying to set three different states using an event handler. It seems that the last state is set immediately, whereas the other two states remain undefined for a bit before changing to a real value. I'm using React-Native components for mobile development, so you'll see snippets in the code such as <SafeAreaView>.
export default App = () => {
const [ destLong, setDestLong ] = useState();
const [ destLat, setDestLat ] = useState();
const [ startNav, setStartNav ] = useState(false);
const [ locations, setLocations ] = useState([
{
name: 'IKEA',
long: '-74.00653395444186',
lat: '40.68324646680103',
},
{
name: 'JFK Intl. Airport',
long: '-73.78131423688552',
lat: '40.66710279890186',
},
{
name: 'Microcenter',
long: '-74.00516039699959',
lat: '40.67195933297655',
}
]);
const startNavigation = (goinglong, goinglat) => {
setDestLong(goinglong);
setDestLat(goinglat);
setStartNav(true);
}
return (
<SafeAreaView style={styles.container}>
{ startNav ?
<MapView
destLong = {destLong}
destLat = {destLat}
/>
:
<View style={styles.buttonContainer}>
<ScrollView>
{
locations.map((location, i) => {
return(
<Card
style={styles.card}
key={i}
title={ location.name }
iconName="home"
iconType="Entypo"
description={ location.long + ", " + location.lat }
onPress={() => startNavigation(location.long, location.lat)}
/>
);
})
}
</ScrollView>
</View>
}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
buttonContainer: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center'
},
logo: {
width: '50%',
height: '50%',
resizeMode: 'contain'
},
card: {
marginBottom: 10,
}
});
This throws an error, because MapView is expecting destLong and destLat to render properly. When I console log inside my startNavigation function, it seems that it immediately updates the state for startNav to true onPress, but destLong and destLat remain undefined for a few cycles before being set.
I've tried a different approach like this:
useEffect(() => {
setStartNav(true);
}, [destLong]);
const startNavigation = (goinglong, goinglat) => {
setDestLong(goinglong);
setDestLat(goinglat);
}
But it just crashes the app (my guess is infinite loop).
I've also tried removing the startNav state altogether and rendering <MapView> on destLong like this
{ destLong ?
<MapView
destLong = {destLong}
destLat = {destLat}
/>
:
<View style={styles.buttonContainer}>
...
</View>
}
But that did not work either.
Which brings me to this question: does the Hooks API respect the order of setState, or is each other carried out asynchronously? From my understanding it's the latter. But then, how do you handle this behavior?
I'm adding my comment here as well since I am unable to add proper formatting to my comment above.
Setting a state via useState is actually asynchronous, or rather the state change is enqueued and it will then return its new value after a re-render. This means that there is no guarantee in what order the states will be set. They will fire in order, but they may not be set in the same order.
You can read more here: https://dev.to/shareef/react-usestate-hook-is-asynchronous-1hia, as well as here https://blog.logrocket.com/a-guide-to-usestate-in-react-ecb9952e406c/#reacthooksupdatestate
In your case I would use useState and useEffect like this:
useEffect(() => {
if(destLong && destLat && !startNav) {
setStartNav(true);
}
}, [destLong, destLat, startNav]);
const startNavigation = (goinglong, goinglat) => {
setDestLong(goinglong);
setDestLat(goinglat);
}
With that said, I think you could further simplify your code by omitting the startNav state altogether and update your conditional render:
{ (destLat && destLong) ?
<MapView
destLong = {destLong}
destLat = {destLat}
/>
:
<View style={styles.buttonContainer}>
...
</View>
}
The above should have the same effect since you have two states that are undefined to begin with, and when they are both defined you want to render something and use their values.
And if you want to display the options again you can set the states to undefined again by doing setDestLat(undefined) and setDestLong(undefined)

React Native saves old Text Input data

So I have a text input that saves to a setState on each change. However if I hit the cancel button which takes me back a page, and then I go back to the first page, my changes are still on the first page.
I feel that this might be the phone that is saving cache or something on my page but how can I make this value reset?
I am also using react navigation for my page navigation
<TextInput
ref={ref => this.companyNameInput = ref}
onChangeText={text => { setCompanyNameInput(text); setSaveFlag(true);}}
style={{
position: 'absolute',
left: 150,
color: 'rgba(0, 159, 150, 1)'
}}
>
<Text12
style={{
color: 'rgba(0, 159, 150, 1)',
padding: 0,
marginBottom: 0
}}
>
{companyNameInput}
</Text12>
</TextInput>
You can reset your text input when you press cancel button
let companyNameInput = null; //if this is functional component else class then use this
const onCancel = () => {
companyNameInput.clear()
}
Or you can set event listener on blur of current screen like this
useEffect(() => {
const unsubscribe = props.navigation.addListener('blur', () => {
onCancel();
})
}. [props.navigation])
you can also clear your state variable by this
setCompanyNameInput('');
NOTE: if you are using class component then use componentWillUnmount instead of useEffect, "this" only works on class based component

Pull Scrollview to reveal View - React Native

I'm trying to build something similar to IMessage's and WhatsApp's header in react native, where users can pull down to reveal a search bar in the header.
I have been able to pull down to reveal a hidden input, but because the scrollview's y value becomes negative on pull, it will bounce back to y = 0 and prevent the input from sticking to the top. I have tried using both translateY and scaleY to reveal the hidden input.
class List extends Component {
scrollY = new Animated.Value(0)
render() {
const translateY = this.props.scrollY.interpolate({
inputRange: [ -50, 0 ],
outputRange: [ 50, 0 ],
extrapolate: 'clamp',
})
return (
<>
<Animated.View style={[
styles.container,
{ transform: [ { translateY } ] },
]}>
<Input />
</Animated.View>
<Animated.ScrollView
onScroll={Animated.event(
[ { nativeEvent: { contentOffset: { y: this.scrollY } } } ],
{ useNativeDriver: true }
)}
scrollEventThrottle={16}
>
{...}
</Animated.ScrollView>
</>
)
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.white,
width: windowWidth,
height: 50,
position: 'absolute',
top: -50,
zIndex: -99,
},
});
I found this Stack Overflow post that has been useful to reference but it is IOS specific Pull down to show view
I solved this by using contentOffset and without any animations. I needed to make sure the scrollview was at least the size of the phone's windowHeight and then used contentOffset to push the initial y value of the Scrollview to the size of the header
<ScrollView
ListHeaderComponent={() => (
<Header headerHeight={hiddenHeaderHeight} />
)}
contentContainerStyle={{ minHeight: windowHeight }}
contentOffset={{ y: hiddenHeaderHeight }}
...
This solution works for a Flatlist as well.
One thing to note is contentOffset is an ios specific prop
check out this medium article. It provides a detailed explanation of how to do something similar to your desired behavior.

How to center two markers with react native map view

I got two markers at two different coordinates.
How do people normally center two markers so that you can see two marker at the same time on map, regardless how far away they are.
<MapView
ref={ (map)=> this.map = map}
initialRegion={this.state.init}
style={css.map}>
{ this.state.marker1 !==0 && <MapView.Marker
coordinate={{
'latitude':this.state.marker1.lat,
'longitude':this.state.marker1.lng}}
/>}
{ this.state.marker2 !==0 && <MapView.Marker
coordinate={{
'latitude':this.state.marker2.lat,
'longitude':this.state.marker2.lng}}
/>}
</MapView>
You can use the fitToSuppliedMarkers or fitToCoordinates method of the MapView.
https://github.com/react-native-community/react-native-maps/blob/master/docs/mapview.md
For anyone like me who comes across this, what you need is fitToSuppliedMarkers from MapView and identifiers from Marker. Here is an example: https://github.com/react-native-maps/react-native-maps/blob/master/example/src/examples/FitToSuppliedMarkers.tsx
You need to use fitToSuppliedMarkers or fitToCoordinates method of the MapView in ComponentDidMount or useEffect and make sure you put it in a setTimeout or it will cause performance problems.
useEffect(() => {
if (!destination) return;
setTimeout(() => {
mapRef?.current?.fitToSuppliedMarkers(["destination", "origin"], {
edgePadding: { top: 70, right: 70, bottom: 70, left: 70 },
});
}, 500);
// console.log("2", destination);
}, [origin, destination]);
Note edgePadding is Google Maps only
https://github.com/react-native-maps/react-native-maps/blob/master/docs/mapview.md

How to scale the size of a button when the user holds down in Animated React / React Native?

I'm trying to create some code that increases the size of a button when the user holds it down then reverts back to the initial size when released.
I'm relatively new to React/RN and have searched tons of websites to find the result, but can't seem to find anything.
I can't tell whether I should be using PanResponder here or not. I also tried using Animated.timing, but the timing is hard-coded & not bound to the length of time that the user holds down the button. I tried Animated.spring, but again that's not bound to length of time that the user holds the button down.
I'll post a quick gif that replicates what I'm trying to go for.
https://imgur.com/a56pSQl
Here's what I have so far:
this.scaleAnimation = new Animated.value(3)
handlePress = () => {
Animated.spring(this.scaleAnimation, {
toValue: 4,
friction: 2,
tension: 160
}).start()
}
render() {
const pauseStyle = {
transform: [
{ scale: this.scaleAnimation }
]
}
return (
<TouchableWithoutFeedback onPress={this.handlePress}>
<Animated.View style={[ pauseStyle ]}>
<Ionicons name="md-pause" />
</Animated.View>
</TouchableWithoutFeedback>
)
}
Any takes are greatly appreciated :D
Please find the detailed answer with code snippets.
Add this to constructor of component
this.handlePressIn = this.handlePressIn.bind(this);
this.handlePressOut = this.handlePressOut.bind(this);
this.animatedValue = new Animated.Value(1);
This method is for scaling button with animation when button gets pressed
handlePressIn() {
Animated.spring(this.animatedValue, {
toValue: 3,
friction: 3,
tension: 40
}).start();
}
This method is for resetting button to its initial scale with animation when touch gets released
handlePressOut() {
Animated.spring(this.animatedValue, {
toValue: 1,
friction: 3,
tension: 40
}).start();
}
Render method
render() {
const animatedStyle = {
transform: [{ scale: this.animatedValue }]
}
return (
<TouchableWithoutFeedback
onPressIn={this.handlePressIn}
onPressOut={this.handlePressOut}
>
<Animated.View style={animatedStyle}>
<Text style={styles.text}>Press Me</Text>
</Animated.View>
</TouchableWithoutFeedback>
);
}
You can use different types of animations. Please find the link for reference:
https://facebook.github.io/react-native/docs/animated#configuring-animations

Resources