react-native-maps icon indicating copy to clipboard operation
react-native-maps copied to clipboard

onPanDrag doesn't fire while camera animating with animateCamera, cancel camera animation not implemented.

Open sspread opened this issue 2 years ago • 7 comments

Summary

I want to cancel camera animation when user interacts with map (pan/zoom).

I'm attempting to implement a feature like iOS maps where camera follows user location until user interacts with map. Location is passed to my component that wraps map view at some throttle interval (eg. 5 seconds) and my animation time matches that timing to allow for smooth movement.

Out of the box followsUserLocation would be ideal, but it 1) Is not smooth between location updates, 2) docs say it doesn't work on Android. So I'm essentially recreating the functionality with moving marker and camera.

Reproducible sample code

import { useEffect, useState, useCallback ,useRef } from 'react';
import MapView from 'react-native-maps';

const location = {
  latitude: 40.1000000,
  longitude: -84.1000000,
};

const initialCamera = {
  center: {
    latitude: 39.1000000,
    longitude: -86.1000000,
  },
  pitch: 0,
  heading: 0,
  altitude: 1_000_000,
}

export default function App() {
  const mapRef = useRef(null)
  const [userDidInteract, setUserDidInteract] = useState(false)

  const animateCamera = useCallback(() => {
    const c = {
      center: {
        latitude: location.latitude,
        longitude: location.longitude,
      },
      pitch: 0,
      heading: 0,
      altitude: 100_000,
    }
    mapRef.current.animateCamera(c, { duration: 5000 });
  }, []);

  useEffect(() => {
    setTimeout(() => {
      animateCamera()
    }, 1000)
  }, [animateCamera]);

  const onPanDrag = useCallback(async () => {
    // Doesn't fire during animation
    if (!userDidInteract) {
      const currentCamera = await mapRef.current.getCamera()
      // Cancel animation workaround
      mapRef.current.animateCamera(currentCamera, { duration: 1 });
      setUserDidInteract(true)
    }
  }, [userDidInteract])

  return (
    <MapView
      style={{flex: 1}}
      ref={mapRef}
      initialCamera={initialCamera}
      onPanDrag={onPanDrag}
    />
  );
}

Steps to reproduce

Animate camera and do a user pan gesture during animation.

Expected result

Expect onPanDrag to fire. Then, if it did, in the onPanDrag handler I'd like to cancel camera animation.

Actual result

onPanDrag callback never fires while animating. Aside, I'm able to achieve cancel with a workaround mapRef.current.animateCamera(currentCamera, { duration: 1 }). Duration 0 doesn't work as expected as it still blocks gesture callbacks for some duration after stopping.

React Native Maps Version

1.8.0

What platforms are you seeing the problem on?

iOS (Apple Maps)

React Native Version

0.72.7

What version of Expo are you using?

SDK 48

Device(s)

iPhone 14 Pro

Additional information

EXPO 49

sspread avatar Nov 26 '23 19:11 sspread

I've run across the same problem. I'm implementing a workaround that puts a transparent layer over the map and using onTouchStart I fire the function you pointed out to cancel the animation

mapRef.current.animateCamera(currentCamera, { duration: 1 });

I'm only rendering that hidden layer while camera animation occurs so you can pan and drag right after.

joshuadunning avatar Jan 06 '24 20:01 joshuadunning

That was one of my early workarounds, but I found behavior unacceptable: user has to tap, release, THEN pan vs immediately initiate a pan/zoom. I ended up driving my camera animation off of the marker animation progress with setCamera instead of animateCamera. Then no transparent layer necessary. Curious if you find a better way though.

sspread avatar Jan 07 '24 17:01 sspread

I agree that behavior is not user friendly. How are you calculating the camera center? Calculating the line between the old and new Coords and finding the position based on elapsed animation time?

joshuadunning avatar Jan 07 '24 19:01 joshuadunning

I'm running an animation loop on cameraAnimationProgress (0 to 1), attaching a listener to get callbacks for every animation progress tick. Before running loop, I get current camera with await mapRef.current.getCamera(). In the callback, calculate all my next camera attributes based on current camera, coords from instantaneous marker position (markerCoord.__getValue()) and camera animation progress fed to callback. Then set without animation mapRef.current.setCamera(nextCamera).

All this keeps camera glued to the marker. Animating TO the marker can be done with the same code, but running an animation on the camera before the loop. All this frees up onTouchStart to fire naturally so i can toggle the 'following' state. When that state changes, I stop animation, remove listeners, etc.

It's pretty complicated tbh. I can go into more detail if you want.

sspread avatar Jan 07 '24 20:01 sspread

Have you found this method that works smoothly for android? It's looking great on iOS. I am using the previous method I mentioned for Android still as Android seems to have a harder time keeping up with that listener and animating smoothly.

For anyone else who is reading this. You can hook into the AnimatedRegion class using AnimatedRegion.addListener(callback: (region: Region) => void)

I implemented it like this for iOS:

const [coord, setCoord] = useState(new AnimatedRegion({
    latitude,
    longitude,
    latitudeDelta,
    longitudeDelta
}))

// ....Attach listener when you want to follow a specific marker
if(isTrackingMarker){

  const newCoordinate = {
    latitude,
    longitude,
    latitudeDelta,
    longitudeDelta,
    duration
  }

  //only add one listener at a time
  if(listenerId !== null){
   const id = coord.addListener(animateCamera)
   setListenerId(v=>id)
  }
  coord.timing(newCoordinate).start()
}

//clearing listener on unmount
useEffect(() => {
    return ()=>{ 
     if(listenerId){
       coord.removeListener(listenerId)
     }
   }
  }, []);

const animateCamera = (r: Region) => {
  //If the camera reaches the new coordinate, unmount it
  if (r.latitude === coord.latitude && r.longitude === coord.longitude) {
    //clear listeners
    l.trace('clearing listeners for camera: ' + listenerIds.length);
    coord.removeListener(listenerId);
    setListenerId((v) => null);
  }
    const camera: Camera = {
      center: {
        latitude: r.latitude,
        longitude: r.longitude
      },
      pitch: 0,
      heading: 0,
      altitude: 1000,
      zoom: 17
    };
    mapRef.current.setCamera(camera, { duration: 1 });
};

joshuadunning avatar Jan 08 '24 23:01 joshuadunning

I haven't attempted Android, but I may accept degraded performance/behavior given its a much smaller user base. Is it Android or Google maps on iOS that's not smooth?

sspread avatar Jan 08 '24 23:01 sspread

Android specifically. The animation process is slightly different to animate markers for android. For now, our user base is less than 10% Android, so we are considering it an acceptable user experience hit for right now.

I will say the animation loop idea was fantastic! I didn't realize there was an addListener method right on the AnimatedRegion. And for the number of updates being performed, it really runs incredibly smooth.

joshuadunning avatar Jan 09 '24 07:01 joshuadunning

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If the issue remains relevant, simply comment Still relevant and the issue will remain open. Thank you for your contributions.

github-actions[bot] avatar Apr 09 '24 01:04 github-actions[bot]

Has anyone come up with a good solution for this, we are dealing with the exact same issue as @sspread

scottgrunerud avatar Jul 03 '24 19:07 scottgrunerud