Skip to content

[Bug]: Significant lag/drift with MarkerView on Android when panning #4135

@andrewmyersdev

Description

@andrewmyersdev

Mapbox Implementation

Mapbox

Mapbox Version

11.16.2

React Native Version

0.81.5

React Native Architecture

New Architecture (Fabric/TurboModules)

Platform

Android

@rnmapbox/maps version

10.2.10

Standalone component to reproduce

import React, { useEffect, useRef, useState, useMemo } from 'react';
import { View, StyleSheet, Animated, Text, SafeAreaView, TextInput, TouchableOpacity, Keyboard } from 'react-native';
import Mapbox from '@rnmapbox/maps';

Mapbox.setAccessToken('pk.ey...');

const LONDON_COORDINATES = [-0.1278, 51.5074];

const POOL_SIZE = 2000;

const generateMarkers = (count: number) => {
  const markers = [];
  for (let i = 0; i < count; i++) {
    markers.push({
      id: `marker-${i}`,
      coordinate: [
        LONDON_COORDINATES[0] + (Math.random() - 0.5) * 0.2, // Increased spread slightly
        LONDON_COORDINATES[1] + (Math.random() - 0.5) * 0.2,
      ],
    });
  }
  return markers;
};

// Generate a pool of markers
const MARKER_POOL = generateMarkers(POOL_SIZE);

const PulsingMarker = ({ isAnimating }: { isAnimating: boolean }) => {
  const scale = useRef(new Animated.Value(1)).current;
  const opacity = useRef(new Animated.Value(1)).current;

  useEffect(() => {
    let pulse: Animated.CompositeAnimation;

    if (isAnimating) {
      pulse = Animated.loop(
        Animated.parallel([
          Animated.sequence([
            Animated.timing(scale, {
              toValue: 2,
              duration: 1500,
              useNativeDriver: false, // MarkerView children on Android might need this off or handled carefully
            }),
            Animated.timing(scale, {
              toValue: 1,
              duration: 0,
              useNativeDriver: false,
            }),
          ]),
          Animated.sequence([
            Animated.timing(opacity, {
              toValue: 0,
              duration: 1500,
              useNativeDriver: false,
            }),
            Animated.timing(opacity, {
              toValue: 1,
              duration: 0,
              useNativeDriver: false,
            }),
          ]),
        ])
      );
      pulse.start();
    } else {
      scale.setValue(1);
      opacity.setValue(1);
    }

    return () => {
      if (pulse) {
        pulse.stop();
      }
    };
  }, [scale, opacity, isAnimating]);

  return (
    <View style={styles.markerContainer}>
      <Animated.View
        style={[
          styles.ring,
          {
            transform: [{ scale }],
            opacity,
          },
        ]}
      />
      <View style={styles.marker} />
    </View>
  );
};

export default function App() {
  const [markerCount, setMarkerCount] = useState(30);
  const [inputText, setInputText] = useState('30');
  const [isAnimating, setIsAnimating] = useState(true);

  const visibleMarkers = useMemo(() => {
    // Clamp to pool size
    const count = Math.min(markerCount, POOL_SIZE);
    return MARKER_POOL.slice(0, count);
  }, [markerCount]);

  const handleApply = () => {
    const count = parseInt(inputText, 10);
    if (!isNaN(count) && count >= 0) {
      setMarkerCount(count);
      Keyboard.dismiss();
    }
  };

  return (
    <View style={styles.container}>
      <Mapbox.MapView style={styles.map}>
        <Mapbox.Camera
          zoomLevel={11}
          centerCoordinate={LONDON_COORDINATES}
        />
        {visibleMarkers.map((marker) => (
          <Mapbox.MarkerView
            key={marker.id}
            id={marker.id}
            coordinate={marker.coordinate}
          >
            <PulsingMarker isAnimating={isAnimating} />
          </Mapbox.MarkerView>
        ))}
      </Mapbox.MapView>
      <SafeAreaView style={styles.controlsContainer}>
        <Text style={styles.label}>Marker Count: {markerCount}</Text>
        <View style={styles.inputContainer}>
          <TextInput
            style={styles.input}
            keyboardType="number-pad"
            value={inputText}
            onChangeText={setInputText}
            placeholder="Enter count"
            placeholderTextColor="#ccc"
          />
          <TouchableOpacity style={styles.button} onPress={handleApply}>
            <Text style={styles.buttonText}>Apply</Text>
          </TouchableOpacity>
        </View>
        <TouchableOpacity
          style={[styles.button, styles.toggleButton]}
          onPress={() => setIsAnimating(!isAnimating)}
        >
          <Text style={styles.buttonText}>
            {isAnimating ? 'Stop Animation' : 'Start Animation'}
          </Text>
        </TouchableOpacity>
      </SafeAreaView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  map: {
    flex: 1,
  },
  controlsContainer: {
    position: 'absolute',
    top: 60,
    left: 20,
    right: 20,
    backgroundColor: 'rgba(0, 0, 0, 0.8)',
    borderRadius: 16,
    padding: 16,
    gap: 12,
  },
  label: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
    textAlign: 'center',
  },
  inputContainer: {
    flexDirection: 'row',
    gap: 12,
  },
  input: {
    flex: 1,
    backgroundColor: 'rgba(255, 255, 255, 0.1)',
    borderRadius: 8,
    borderWidth: 1,
    borderColor: 'rgba(255, 255, 255, 0.2)',
    paddingHorizontal: 16,
    paddingVertical: 12,
    color: 'white',
    fontSize: 16,
  },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    paddingHorizontal: 24,
    justifyContent: 'center',
    alignItems: 'center',
  },
  toggleButton: {
    backgroundColor: '#34C759',
    paddingVertical: 12,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  markerContainer: {
    width: 50,
    height: 50,
    alignItems: 'center',
    justifyContent: 'center',
  },
  ring: {
    position: 'absolute',
    width: 20,
    height: 20,
    borderRadius: 10,
    backgroundColor: 'rgba(255, 0, 0, 0.5)',
  },
  marker: {
    width: 20,
    height: 20,
    borderRadius: 10,
    backgroundColor: 'red',
    borderWidth: 2,
    borderColor: 'white',
  },
});

Observed behavior and steps to reproduce

I'm experiencing a significant synchronization issue with MarkerView on Android using @rnmapbox/maps. When panning or zooming the map, the markers lag behind the map movement, appearing to "float" or drift away from their coordinate before snapping back into place once the map movement stops. This does not happen on iOS, where the markers track perfectly.

I initially tried using PointAnnotation, but I needed to implement a continuous pulsing animation. Since PointAnnotation renders its children to a static bitmap on Android (preventing continuous animation), I switched to MarkerView to support the Animated views. However, the performance/synchronization on Android is creating a poor user experience.

What I've observed:

PointAnnotation: Renders correctly and sticks to map, but animations (Animated.View) are frozen/static on Android because of the bitmap snapshotting.
MarkerView: Animations play perfectly, but the view position lags significantly behind the map camera updates during gestures.
Has anyone found a workaround to improve the synchronization of MarkerView on Android, or a way to get performant continuous animations working with PointAnnotation (or another method) without the drift?

Thanks!

Expected behavior

No response

Notes / preliminary analysis

No response

Additional links and references

No response

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