Skip to content

[Bug]: onMapIdle is triggered during ShapeSource animation #4122

@roy-cuenca-itti

Description

@roy-cuenca-itti

Mapbox Implementation

Mapbox

Mapbox Version

10.2.6

React Native Version

0.81.5

React Native Architecture

Old Architecture (Paper/bridge)

Platform

iOS, Android

@rnmapbox/maps version

10.2.6

Standalone component to reproduce

1) Animated Component (ShapeSource):

import { useSafeRef as useRef } from '@/hooks/useSafeRef';
import { useTheme } from '@ittidigital/muv-design-system';
import Mapbox from '@rnmapbox/maps';
// @ts-expect-error
import along from '@turf/along';
// @ts-expect-error
import { lineString } from '@turf/helpers';
// @ts-expect-error
import lineSlice from '@turf/line-slice';
import { useEffect, useState } from 'react';
import { Animated } from 'react-native';

interface PolylineShapeProps {
  geoJSON: GeoJSON.Feature<GeoJSON.LineString>;
  distance: number;
}

export const PolylineShape = ({ geoJSON, distance }: PolylineShapeProps) => {
  const {
    palettes: { secondary },
  } = useTheme();

  const [currentRoute, setCurrentRoute] = useState(() =>
    lineString([
      geoJSON.geometry.coordinates[0],
      geoJSON.geometry.coordinates[1],
    ]),
  );

  const barDistance = distance * 0.15;
  const valueRef = useRef(() => new Animated.Value(-barDistance));
  useEffect(() => {
    const animation = Animated.loop(
      Animated.timing(valueRef.current, {
        toValue: distance,
        duration: 4000,
        useNativeDriver: false,
      }),
      { resetBeforeIteration: true },
    );
    animation.start();
    return () => animation.stop();
  }, [distance]);

  const fullLine = geoJSON.geometry;
  useEffect(() => {
    const progress = valueRef.current;
    progress.addListener(({ value }) => {
      const pointStart = along(fullLine, Math.max(value - barDistance, 0), {
        units: 'meters',
      });
      const pointEnd = along(
        fullLine,
        Math.min(value + barDistance, distance),
        {
          units: 'meters',
        },
      );
      const coords = lineSlice(pointStart, pointEnd, fullLine).geometry
        .coordinates;
      setCurrentRoute(lineString(coords));
    });

    return () => progress.removeAllListeners();
  }, [barDistance, distance, fullLine]);

  return (
    <>
      <Mapbox.ShapeSource id="routeSource" shape={geoJSON}>
        <Mapbox.LineLayer
          id="route"
          style={{
            lineWidth: 4,
            lineColor: secondary[200],
          }}
        />
      </Mapbox.ShapeSource>
      <Mapbox.ShapeSource id="routeOverSource" shape={currentRoute} lineMetrics>
        <Mapbox.LineLayer
          id="routeOver"
          style={{
            lineWidth: 6,
            lineColor: secondary[500],
            lineGradient: [
              'interpolate',
              ['linear'],
              ['line-progress'],
              0,
              secondary[200],
              1,
              secondary[500],
            ],
          }}
        />
      </Mapbox.ShapeSource>
    </>
  );
};

Componente Wrapper:

import * as polyline from '@/helpers/polyline';
// @ts-expect-error
import length from '@turf/length';
import { useMemo } from 'react';
import { PolylineShape } from './PolylineShape';

export interface MapPolylineProps {
  encoded: string;
}

export const MapPolyline = ({ encoded }: MapPolylineProps) => {
  const geoJSON: GeoJSON.Feature<GeoJSON.LineString> | undefined =
    useMemo(() => {
      if (!encoded) return undefined;
      try {
        const geometry = polyline.toGeoJSON(encoded);
        return {
          type: 'Feature',
          properties: {},
          geometry,
        };
      } catch {
        return undefined;
      }
    }, [encoded]);

  const distance = useMemo(() => {
    if (!geoJSON) return undefined;
    try {
      return length(geoJSON, { units: 'meters' });
    } catch {
      return undefined;
    }
  }, [geoJSON]);

  if (!geoJSON || !distance) {
    return null;
  }
  return <PolylineShape geoJSON={geoJSON} distance={distance} />;
};
  1. MapView component.
import Mapbox from '@rnmapbox/maps';
import { useLocation } from '@/hooks/useLocation';
import { View } from '@ittidigital/muv-design-system';
import { useRef } from 'react';
import { StyleSheet } from 'react-native';
import { Marker, Drivers, UserLocation, TripPoints } from './components';
import { useMapView } from '@/hooks/useMapView';
import { useMarkerOnMap } from '@/hooks/useMarkerOnMap';
import { useMapCenter } from '@/hooks/useMapCenter';
import { usePolylineFetch } from '@/hooks/usePolylineFetch';
import { defaultCenterMapValues } from '@/constants/map';
import { MapPolyline } from '../../molecules/map-polyline';

export interface MapViewProps {
  zoomLevel?: number;
  animationMode?: 'none' | 'moveTo' | 'easeTo' | 'flyTo';
  animatedUserLocation?: boolean;
  scrollEnabled?: boolean;
  zoomEnabled?: boolean;
  pitchEnabled?: boolean;
  rotateEnabled?: boolean;
}

export const MapView = ({
  zoomLevel = 14,
  animationMode = 'easeTo',
  animatedUserLocation = true,
  scrollEnabled = true,
  zoomEnabled = true,
  pitchEnabled = true,
  rotateEnabled = true,
}: MapViewProps) => {
  const mapRef = useRef<Mapbox.MapView>(null);
  const cameraRef = useRef<Mapbox.Camera>(null);
  const location = useLocation();
  const mapConfig = useMapView();

  const {
    showUserLocation,
    showDrivers,
    showMarker,
    showPolyline,
    isPaddingEnabled,
    defaultZoomLevel,
    isSonarActive,
  } = mapConfig;

  const { handleMapIdle } = useMarkerOnMap(cameraRef);

  useMapCenter({
    cameraRef,
  });

  const { encodedPolyline: fetchedPolyline } = usePolylineFetch();

  if (!location?.coords) {
    return null;
  }

  console.log('RENDER HERE');

  return (
    <View style={StyleSheet.absoluteFillObject}>
      <Mapbox.MapView
        ref={mapRef}
        style={StyleSheet.absoluteFillObject}
        styleURL={Mapbox.StyleURL.Street}
        logoEnabled={false}
        attributionEnabled={false}
        scaleBarEnabled={false}
        pitchEnabled={false}
        zoomEnabled={zoomEnabled}
        scrollEnabled={scrollEnabled}
        rotateEnabled={rotateEnabled}
        onMapIdle={value => {
          // TODO: Provitional solution to prevent function called <<<---
          if (showPolyline) {
            return;
          }
          handleMapIdle(value);
        }}
      >
        <Mapbox.Camera
          ref={cameraRef}
          zoomLevel={zoomLevel || defaultZoomLevel}
          centerCoordinate={[
            location.coords.longitude,
            location.coords.latitude,
          ]}
          animationMode={animationMode}
          padding={
            isPaddingEnabled ? defaultCenterMapValues.padding : undefined
          }
        />
        {showPolyline && (
          <>
            <MapPolyline encoded={fetchedPolyline} /> // <<<--- Render Animated Map Polyline
            <TripPoints isSonarActive={isSonarActive} />
          </>
        )}
      </Mapbox.MapView>
    </View>
  );
};

MapView.displayName = 'MapView';

Observed behavior and steps to reproduce

Summary

The main issue is that the onMapIdle callback fires on every single state update during the animation. With the provided component structure, we observe that the component triggers a re-render for every frame generated by the animation.

This behavior persists even when we attempt to adapt the official Mapbox web recommended implementation (see: Animate a line) to React Native; the onMapIdle event continues to fire unexpectedly.

- Steps to Reproduce:

  1. Render a MapPolyline component (or an animated ShapeSource) inside a <Mapbox.MapView>.

  2. Observe onMapIdle firing repeatedly.

Additional Information:

We have attempted to use onCameraChanged as an alternative, but it does not meet our specific functional requirements.

  • Goal:

We are looking to understand why onMapIdle is executing in the background when implementing this animation. We would also appreciate guidance on whether our implementation approach is incorrect, or if you could suggest a better pattern for animating map components without triggering this event loop.

We would appreciate any feedback or suggestions you might have.

Thanks in advance.

Expected behavior

The onMapIdle event should not fire continuously during ShapeSource or Layer animations. It should only trigger when camera movement (pan/zoom) completes or when the user stops interacting with the map, regardless of data updates on the map layers.

Notes / preliminary analysis

No response

Additional links and references

Suggested implementation:

https://docs.mapbox.com/mapbox-gl-js/example/animate-a-line/

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions