rnmapbox / maps

A Mapbox react native module for creating custom maps
MIT License
2.27k stars 849 forks source link

[Bug]: android MapView does not hide with overflow: 'hidden' when rendering two maps #2930

Closed FrederickEngelhardt closed 1 year ago

FrederickEngelhardt commented 1 year ago

Mapbox Implementation

Mapbox

Mapbox Version

10.13.1 (iOS), 10.13.0 android

Platform

Android

@rnmapbox/maps version

10.0.6

Standalone component to reproduce

import React, { FC, useRef, useMemo, useEffect, useCallback } from 'react'
import { View, useWindowDimensions, StyleSheet, Text } from 'react-native'
import {
  MapState,
  MapView,
  Camera,
  setTelemetryEnabled,
  StyleURL,
} from '@rnmapbox/maps'

const dragAreaWidth = 30

const styles = StyleSheet.create({
  viewStubForTopMap: {
    backgroundColor: 'blue',
    borderWidth: 80,
    borderRightColor: 'yellow', // this yellow should show up only when the overflow is shown on the far left
    borderLeftColor: 'green',
  },
  gestureContainer: { height: '100%', position: 'absolute', left: 0 },
  androidShadowContainer: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    left: 0,
    right: 0,
    elevation: 40,
  },
  androidShadow: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    width: 45,
    right: 0,
  },
  homeScreen: {
    flex: 1,
    flexDirection: 'column',
    alignItems: 'flex-end',
  },
  buttonRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    bottom: 20,
    height: 50,
    paddingLeft: 24,
    paddingRight: 24,
    position: 'absolute',
    width: '100%',
    zIndex: 60,
    elevation: 55,
  },
  button: {
    alignItems: 'center',
    justifyContent: 'center',
    height: 55,
    width: 55,
    borderRadius: 50,
  },
  buttonIcon: {
    height: 65,
    width: 65,
  },
  leftBottomMapContainer: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: 0,
    bottom: 0,
    backgroundColor: 'orange',
  },
  leftBottomMap: {
    zIndex: 0,
    elevation: 0,
  },
  mapOverlayContainer: {
    top: 0,
    bottom: 0,
    position: 'absolute',
    backgroundColor: 'rgba(0,0,0,0.5)',
    right: 0, // Forces map to always be bound to right side of container
    overflow: 'hidden', // hides the mapOverlay that bleeds out of this View
  },
  mapOverlay: {
    position: 'absolute',
    right: 0,
  },
  dragArea: {
    position: 'absolute',
    height: '100%',
    width: dragAreaWidth,
    zIndex: 999,
    elevation: 999,
    alignContent: 'center',
    justifyContent: 'center',
  },
  dragAreaIndicator: {
    position: 'absolute',
    zIndex: 9999,
    elevation: 9999,
    width: 8,
    right: 0,
    backgroundColor: 'magenta',
  },
  dragText: { fontSize: 24, backgroundColor: 'black', color: 'white' },
  dragAreaIndicatorButton: {
    zIndex: 50,
    height: '100%',
  },
  handleImage: {
    height: 80,
    borderRadius: 25,
    backgroundColor: 'rgba(0,0,0,0.7)',
    alignItems: 'center',
    justifyContent: 'center',
  },
  dragIcon: {
    width: 50,
    height: 50,
  },
})

enum MapSyncType {
  SYNC_TO_LEFT = 'syncToLeft',
  SYNC_TO_RIGHT = 'syncToRight',
}
/**
 * @note this example is the simplified version of a PanGesture and ReAnimated dragging for the overflow hidden view
 */
const MapBoxSimpleOverFlowIssuesExample: FC = () => {
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()

  const mapSizing = windowWidth

  const mapOnBottomRef = useRef<MapView>(null)
  const leftMapCamera = useRef<Camera>(null)
  const slideMapOnTop = useRef<MapView>(null)
  const dynamicSlideMapCamera = useRef<Camera>(null)
  const mapControlPriority = useRef()

  useEffect(() => {
    setTelemetryEnabled(false)
  }, [])

  const mapCameraProps = useMemo(
    () => ({
      animationDuration: 0,
      centerCoordinate: [-111.97664051270452, 33.42264330784292],
      maxZoomLevel: 20,
      minZoomLevel: 5,
      zoomLevel: 15,
    }),
    [],
  )

  const mapViewProps = useMemo(
    () => ({
      attributionEnabled: false,
      logoEnabled: false,
      scaleBarEnabled: false,
      pitchEnabled: false,
      rotateEnabled: false,
    }),
    [],
  )

  /**
   * Synchronizes both maps
   * @note This synchronization includes both user and non user trigger map changes
   */
  const handleMapSync = useCallback(
    (syncType: MapSyncType) => (state: MapState) => {
      const ref =
        syncType === MapSyncType.SYNC_TO_LEFT
          ? leftMapCamera
          : dynamicSlideMapCamera
      const camera = ref.current

      if (!state.gestures.isGestureActive || !camera) {
        return
      }

      if (
        mapControlPriority.current !== undefined &&
        mapControlPriority.current !== syncType
      ) {
        return
      }

      const { center, zoom } = state.properties

      if (!center || !zoom) {
        return
      }

      camera.setCamera({
        centerCoordinate: center,
        zoomLevel: zoom,
        animationDuration: 0,
      })
    },

    [],
  )

  const windowDimensions = useMemo(
    () => ({
      width: windowWidth,
      height: windowHeight,
    }),
    [windowHeight, windowWidth],
  )

  const ViewStubSimilarToMap = useMemo(() => {
    return (
      <View
        style={[
          styles.mapOverlay,
          styles.viewStubForTopMap,
          { width: windowWidth, height: windowHeight },
        ]}
      />
    )
  }, [windowHeight, windowWidth])

  const MapRenderedOnBottom = useMemo(
    () => (
      <>
        <MapView
          {...mapViewProps}
          style={[styles.leftBottomMap, windowDimensions]}
          ref={mapOnBottomRef}
          styleURL={StyleURL.Dark}
          onCameraChanged={handleMapSync(MapSyncType.SYNC_TO_RIGHT)}
        >
          <Camera {...mapCameraProps} ref={leftMapCamera} />
        </MapView>
      </>
    ),
    [handleMapSync, mapCameraProps, mapViewProps, windowDimensions],
  )

  /**
   * this map should be overlayed but clipped
   */

  const MapRenderedOnTop = useMemo(
    () => (
      <View style={[styles.mapOverlayContainer, { left: windowWidth / 2 }]}>
        {/* Comment in ViewStubSimilarToMap (and comment out MapView below) to see the difference, the same view will respect overflow: 'hidden' */}
        {/* {ViewStubSimilarToMap} */}
        <MapView
          {...mapViewProps}
          styleURL={StyleURL.Light}
          style={[
            {
              ...styles.mapOverlay,
              width: windowWidth,
              height: windowHeight,
            },
          ]}
          ref={slideMapOnTop}
          onCameraChanged={handleMapSync(MapSyncType.SYNC_TO_LEFT)}
        >
          <Camera {...mapCameraProps} ref={dynamicSlideMapCamera} />
        </MapView>
      </View>
    ),
    [
      handleMapSync,
      mapCameraProps,
      mapViewProps,
      windowHeight,
      windowWidth,
      ViewStubSimilarToMap,
    ],
  )

  return (
    <View style={styles.homeScreen}>
      <View style={[styles.leftBottomMapContainer, windowDimensions]}>
        {MapRenderedOnBottom}
      </View>
      <View
        pointerEvents='box-none'
        style={[styles.gestureContainer, { width: windowWidth }]}
      >
        <View style={[styles.dragArea, { right: windowWidth / 2 }]}>
          <Text style={styles.dragText}>{'<>'}</Text>
        </View>
        <View
          pointerEvents='none'
          style={[styles.androidShadowContainer, { right: 50 }]}
        >
          <View style={[styles.dragAreaIndicator, { height: windowHeight }]} />
        </View>
        {MapRenderedOnTop}
      </View>
    </View>
  )
}

export default MapBoxSimpleOverFlowIssuesExample

Observed behavior and steps to reproduce

On v9 with android the layers do no break out of react-native's overflow hidden.

Steps to reproduce

  1. https://github.com/rnmapbox/maps/blob/main/android/install.md follow the map libre steps for v9
  2. Remove v10 references for the native package.
  3. Keep the v10 lib on react-native node side.
  4. Swap out onCameraChanged for onRegionIsChanging since it doesnt work in v9.
  5. The view should hide the topmost map.

Expected behavior

Android and iOS map overlays should behave the same and both respect overflow: "hidden" view property to allow clipping the Map.

Android overlay does not clip views but iOS does in the v10 implementation.

Notes / preliminary analysis

I have not dived into the native code yet for android, but I have troubleshooted some alternatives.

If you need two maps and them to sync 1:1 and cannot do absolute overlaying

  1. Compute the amount of the map that is showing
  2. Center the map based on the offset

This seems a bit contrived and also would require branching android and ios code so hopefully overflow: 'hidden' properties can be fixed on v10.

Additional links and references

Related react-native commit that adds overflow native support. https://github.com/facebook/react-native/commit/b81c8b51fc6fe3c2dece72e3fe500e175613c5d4

mfazekas commented 1 year ago

@FrederickEngelhardt thanks for the report, can you try with changing the surfaceView attribute?! It's default was changed in v10: https://github.com/rnmapbox/maps/blob/main/docs/MapView.md#surfaceview

FrederickEngelhardt commented 1 year ago

I think that fixed it for this example.

Another thing to mention (related to android and view layers) is that things like react-native-view-shot are not able to capture a screenshot of both maps. Its the highest level map that win in these instances.

import { captureRef } from 'react-native-view-shot' And using a View ref that wraps both Maps will only show the Top one.

Library: https://github.com/gre/react-native-view-shot

Noting that even the captureScreen method does not work in this case.

mfazekas commented 1 year ago

@FrederickEngelhardt Sorry I'm not sure if it's an issue with rnmapbox or limitation of rn view-shot

FrederickEngelhardt commented 1 year ago

I think it related to view-shot. Thanks for closing this.

FrederickEngelhardt commented 1 year ago

Just following up here:

Solution:

  1. Use the overlay provided above.
  2. Render two images that swap out the map when a screenshot state value is set
  3. When taking a screenshot use mapRefs for both left and right and get the base64 image.
  4. Inject the base64 image into the Images that are rendered instead of the map
  5. Wait for both images to fire onLoadedEnd
  6. Fire off the screenshot capture method from react-native-view-shot). The views should be visually equivalent to the maps if all styles are preserved.
  7. Viola! Android works with two maps.