From e646fa1845f2613bd933a207776459f8cf8b7b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 10 Nov 2023 11:18:12 +0100 Subject: [PATCH] feat: allow Images/Image component in NativeUserLocation --- .../java/com/rnmapbox/rnmbx/RNMBXPackage.kt | 6 +- .../rnmbx/components/camera/RNMBXCamera.kt | 8 +- .../rnmbx/components/images/ImageManager.kt | 53 ++++ .../rnmbx/components/images/RNMBXImages.kt | 3 +- .../location/LocationComponentManager.kt | 92 +++--- .../location/RNMBXNativeUserLocation.kt | 270 ++++++++++++------ .../RNMBXNativeUserLocationManager.kt | 76 ++++- .../rnmbx/components/mapview/RNMBXMapView.kt | 4 + .../rnmbx/components/styles/RNMBXStyle.kt | 2 +- .../rnmbx/utils/DownloadMapImageTask.kt | 7 +- .../rnmbx/utils/extensions/ReadableArray.kt | 22 +- .../rnmbx/utils/extensions/ReadableMap.kt | 21 ++ .../rnmapbox/rnmbx/v11compat/Cancelable.kt | 15 + .../v10/com/rnmapbox/rnmbx/v11compat/Image.kt | 12 + .../com/rnmapbox/rnmbx/v11compat/Location.kt | 15 +- .../rnmapbox/rnmbx/v11compat/Cancelable.kt | 3 + .../v11/com/rnmapbox/rnmbx/v11compat/Image.kt | 18 ++ .../com/rnmapbox/rnmbx/v11compat/Location.kt | 4 +- ...NMBXNativeUserLocationManagerDelegate.java | 10 +- ...MBXNativeUserLocationManagerInterface.java | 4 +- docs/MapView.md | 1 + docs/NativeUserLocation.md | 48 +++- docs/docs.json | 36 ++- docs/examples.json | 29 +- example/android/build.gradle | 4 +- .../UserLocation/CustomNativeUserLocation.tsx | 66 +++++ example/src/examples/UserLocation/index.js | 2 +- ios/RNMBX/ImageManager.swift | 41 +++ ios/RNMBX/RNMBXFabricPropConvert.h | 41 +++ ios/RNMBX/RNMBXFabricPropConvert.mm | 131 +++++++++ ios/RNMBX/RNMBXImages.swift | 24 +- ios/RNMBX/RNMBXImagesComponentView.mm | 20 +- ios/RNMBX/RNMBXMapView.swift | 4 +- ios/RNMBX/RNMBXMapViewComponentView.mm | 2 + ios/RNMBX/RNMBXNativeUserLocation.swift | 247 +++++++++++++--- .../RNMBXNativeUserLocationComponentView.mm | 20 +- .../RNMBXNativeUserLocationViewManager.m | 5 +- ios/RNMBX/RNMBXUtils.swift | 9 +- ios/RNMBX/rnmapbox_maps-Swift.pre.h | 3 + rnmapbox-maps.podspec | 2 +- src/components/NativeUserLocation.tsx | 79 +++-- .../RNMBXNativeUserLocationNativeComponent.ts | 21 +- 42 files changed, 1173 insertions(+), 307 deletions(-) create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/images/ImageManager.kt create mode 100644 android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Cancelable.kt create mode 100644 android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Cancelable.kt create mode 100644 example/src/examples/UserLocation/CustomNativeUserLocation.tsx create mode 100644 ios/RNMBX/ImageManager.swift create mode 100644 ios/RNMBX/RNMBXFabricPropConvert.h create mode 100644 ios/RNMBX/RNMBXFabricPropConvert.mm diff --git a/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt b/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt index e306e970f9..971e27b34f 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/RNMBXPackage.kt @@ -53,9 +53,9 @@ class RNMBXPackage : TurboReactPackage() { fun getViewTagResolver(context: ReactApplicationContext) : ViewTagResolver { val viewTagResolver = viewTagResolver if (viewTagResolver == null) { - val viewTagResolver = ViewTagResolver(context) - this.viewTagResolver = viewTagResolver - return viewTagResolver + val result = ViewTagResolver(context) + this.viewTagResolver = result + return result } return viewTagResolver } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt index 7e92bab585..c461324faa 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXCamera.kt @@ -334,10 +334,10 @@ class RNMBXCamera(private val mContext: Context, private val mManager: RNMBXCame if (location?.puckBearingEnabled == true) { when (location.puckBearingSource) { - PuckBearingSource.HEADING -> { + PuckBearing.HEADING -> { UserTrackingMode.FollowWithHeading } - PuckBearingSource.COURSE -> { + PuckBearing.COURSE -> { UserTrackingMode.FollowWithCourse } else -> { @@ -468,12 +468,12 @@ class RNMBXCamera(private val mContext: Context, private val mManager: RNMBXCame when (mFollowUserMode ?: "normal") { "compass" -> { location.puckBearingEnabled = true - location.puckBearingSource = PuckBearingSource.HEADING + location.puckBearingSource = PuckBearing.HEADING followOptions.bearing(FollowPuckViewportStateBearing.SyncWithLocationPuck) } "course" -> { location.puckBearingEnabled = true - location.puckBearingSource = PuckBearingSource.COURSE + location.puckBearingSource = PuckBearing.COURSE followOptions.bearing(FollowPuckViewportStateBearing.SyncWithLocationPuck) } "normal" -> { diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/images/ImageManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/images/ImageManager.kt new file mode 100644 index 0000000000..7f6473075e --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/images/ImageManager.kt @@ -0,0 +1,53 @@ +package com.rnmapbox.rnmbx.components.images + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import com.rnmapbox.rnmbx.v11compat.Cancelable +import com.mapbox.maps.Image +import com.rnmapbox.rnmbx.v11compat.image.toMapboxImage + +/** +ImageManager helps to resolve images defined by any of RNMBXImages component. + */ + +fun interface Resolver { + fun resolved(name: String, image: Image) +} +class Subscription(val name:String, val resolver: Resolver, val manager: ImageManager): Cancelable { + + fun resolved(name: String, image: Image) { + resolver.resolved(name, image) + } + override fun cancel() { + manager.unsubscribe(this) + } +} + +class ImageManager { + var subscriptions: MutableMap> = hashMapOf() + + fun subscribe(name: String, resolved: Resolver) : Subscription { + val list = subscriptions.getOrPut(name) { mutableListOf() } + val result = Subscription(name, resolved, this) + list.add(result) + return result + } + fun unsubscribe(subscription: Subscription) { + var list = subscriptions[subscription.name] + list?.removeAll { it === subscription } + } + + fun resolve(name: String, image: Image) { + subscriptions[name]?.forEach { + it.resolved(name, image) + } + } + + fun resolve(name: String, image: Bitmap) { + resolve(name, image.toMapboxImage()) + } + + fun resolve(name: String, image: BitmapDrawable) { + resolve(name, image.bitmap) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImages.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImages.kt index 4845c96450..c703be45b3 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImages.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/images/RNMBXImages.kt @@ -212,6 +212,7 @@ class RNMBXImages(context: Context, private val mManager: RNMBXImagesManager) : val name = nativeImage.info.name if (!hasImage(name, map)) { val bitmap = nativeImage.drawable + mMapView!!.imageManager.resolve(name, nativeImage.drawable) style.addBitmapImage(nativeImage) mCurrentImages.add(name) } @@ -264,7 +265,7 @@ class RNMBXImages(context: Context, private val mManager: RNMBXImagesManager) : } } if (missingImages.size > 0) { - val task = DownloadMapImageTask(context, map, null) + val task = DownloadMapImageTask(context, map, mMapView!!.imageManager) val params = missingImages.toTypedArray() task.execute(*params) } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/location/LocationComponentManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/location/LocationComponentManager.kt index 6e19c6b770..c429ffcadd 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/location/LocationComponentManager.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/location/LocationComponentManager.kt @@ -16,7 +16,7 @@ import com.rnmapbox.rnmbx.v11compat.image.ImageHolder import com.rnmapbox.rnmbx.v11compat.image.toBitmapImageHolder import com.rnmapbox.rnmbx.v11compat.image.toByteArray import com.rnmapbox.rnmbx.v11compat.image.toImageData -import com.rnmapbox.rnmbx.v11compat.location.PuckBearingSource +import com.rnmapbox.rnmbx.v11compat.location.PuckBearing /** * The LocationComponent on android implements display of user's current location. @@ -53,9 +53,10 @@ class LocationComponentManager(mapView: RNMBXMapView, context: Context) { var bearingImage: ImageHolder?, // The image used as the middle of the location indicator. var topImage: ImageHolder?, // The image to use as the top layer for the location indicator. var shadowImage: ImageHolder?, // The image that acts as a background of the location indicator. - var puckBearingSource: PuckBearingSource?, // bearing source + var puckBearingSource: PuckBearing?, // bearing source var pulsing: Boolean = true, var scale: Double = 1.0, + var nativeUserLocation: Boolean = false, // LocaitonPuck managed by RNMBXNativeUserLocation ) { val enabled: Boolean get() = showUserLocation || followUserLocation @@ -98,49 +99,52 @@ class LocationComponentManager(mapView: RNMBXMapView, context: Context) { if (fullUpdate || newState.hidden != oldState.hidden || newState.tintColor != oldState.tintColor || - newState.bearingImage != oldState.bearingImage || - newState.topImage != oldState.topImage || - newState.shadowImage != oldState.shadowImage || - newState.scale != oldState.scale + newState.scale != oldState.scale || + newState.nativeUserLocation != oldState.nativeUserLocation ) { - if (newState.hidden) { - var emptyLocationPuck = LocationPuck2D() - val empty = - AppCompatResourcesV11.getDrawableImageHolder(mContext, R.drawable.empty) - emptyLocationPuck.bearingImage = empty - emptyLocationPuck.shadowImage = empty - emptyLocationPuck.topImage = empty - //emptyLocationPuck.opacity = 0.0 - locationPuck = emptyLocationPuck - pulsingEnabled = false - } else { - val tintColor = newState.tintColor - var topImage = newState.topImage - if (tintColor != null && topImage != null) { - val imageData = (topImage as ByteArray).toImageData().toByteArray() - val drawable = BitmapDrawable(mContext.resources, BitmapFactory.decodeByteArray(imageData, 0, imageData.size)) - drawable.setTint(tintColor) - topImage = drawable.toBitmapImageHolder() + if (!newState.nativeUserLocation) { + if (newState.hidden) { + var emptyLocationPuck = LocationPuck2D() + val empty = + AppCompatResourcesV11.getDrawableImageHolder(mContext, R.drawable.empty) + emptyLocationPuck.bearingImage = empty + emptyLocationPuck.shadowImage = empty + emptyLocationPuck.topImage = empty + //emptyLocationPuck.opacity = 0.0 + locationPuck = emptyLocationPuck + pulsingEnabled = false + } else { + val tintColor = newState.tintColor + var topImage = newState.topImage + if (tintColor != null && topImage != null) { + val imageData = (topImage as ByteArray).toImageData().toByteArray() + val drawable = BitmapDrawable( + mContext.resources, + BitmapFactory.decodeByteArray(imageData, 0, imageData.size) + ) + drawable.setTint(tintColor) + topImage = drawable.toBitmapImageHolder() + } + val scaleExpression = if (newState.scale != 1.0) { + interpolate { + linear() + zoom() + stop { + literal(0) + literal(newState.scale) + } + }.toJson() + } else null + + locationPuck = LocationPuck2D( + topImage = topImage, + bearingImage = newState.bearingImage, + shadowImage = newState.shadowImage, + scaleExpression = scaleExpression, + ) + pulsingEnabled = newState.pulsing + pulsingColor = tintColor ?: MAPBOX_BLUE_COLOR } - val scaleExpression = if (newState.scale != 1.0) { - interpolate { - linear() - zoom() - stop { - literal(0) - literal(newState.scale) - } - }.toJson() - } else null - - locationPuck = LocationPuck2D( - topImage = topImage, - bearingImage = newState.bearingImage, - shadowImage = newState.shadowImage, - scaleExpression = scaleExpression, - ) - pulsingEnabled = newState.pulsing - pulsingColor = tintColor ?: MAPBOX_BLUE_COLOR } } @@ -179,7 +183,7 @@ class LocationComponentManager(mapView: RNMBXMapView, context: Context) { fun showNativeUserLocation(showUserLocation: Boolean) { update { - it.copy(showUserLocation = showUserLocation) + it.copy(showUserLocation = showUserLocation, nativeUserLocation= showUserLocation) } } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocation.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocation.kt index eeb869349a..9b2d15cbb4 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocation.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocation.kt @@ -2,16 +2,35 @@ package com.rnmapbox.rnmbx.components.location import android.annotation.SuppressLint import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import com.mapbox.android.core.permissions.PermissionsManager +import com.mapbox.bindgen.Value +import com.mapbox.maps.Image +import com.mapbox.maps.MapView import com.mapbox.maps.MapboxMap import com.mapbox.maps.Style +import com.mapbox.maps.plugin.LocationPuck2D +import com.mapbox.maps.plugin.locationcomponent.location import com.rnmapbox.rnmbx.R import com.rnmapbox.rnmbx.components.AbstractMapFeature import com.rnmapbox.rnmbx.components.RemovalReason +import com.rnmapbox.rnmbx.components.images.ImageManager +import com.rnmapbox.rnmbx.components.images.Resolver +import com.rnmapbox.rnmbx.components.images.Subscription import com.rnmapbox.rnmbx.components.mapview.OnMapReadyCallback import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView +import com.rnmapbox.rnmbx.utils.BitmapUtils +import com.rnmapbox.rnmbx.utils.Logger import com.rnmapbox.rnmbx.v11compat.image.AppCompatResourcesV11 -import com.rnmapbox.rnmbx.v11compat.location.PuckBearingSource +import com.rnmapbox.rnmbx.v11compat.image.ImageHolder +import com.rnmapbox.rnmbx.v11compat.image.toDrawable +import com.rnmapbox.rnmbx.v11compat.image.toImageHolder +import com.rnmapbox.rnmbx.v11compat.location.* +import java.nio.ByteBuffer enum class RenderMode { GPS, COMPASS, NORMAL @@ -22,34 +41,127 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O private var mMap: MapboxMap? = null private var mRenderMode : RenderMode = RenderMode.NORMAL; private var mContext : Context = context - var mTopImage: String? = null + + private var imageManager: ImageManager? = null + + // region bearing + var androidRenderMode: RenderMode? = null + var puckBearing: PuckBearing? = null + var puckBearingEnabled: Boolean? = null + // endregion + + enum class PuckImagePart { + TOP, + BEARING, + SHADOW + } + + private var imageNames = mutableMapOf() + private var subscriptions = mutableMapOf() + private var images = mutableMapOf() + + var topImage: String? + get() = imageNames[PuckImagePart.TOP] + set(value) { imageNameUpdated(PuckImagePart.TOP, value) } + + var bearingImage: String? + get() = imageNames[PuckImagePart.BEARING] + set(value) { imageNameUpdated(PuckImagePart.BEARING, value) } + + var shadowImage: String? + get() = imageNames[PuckImagePart.SHADOW] + set(value) { imageNameUpdated(PuckImagePart.SHADOW, value) } + + var scale: Value? = null set(value) { field = value - applyChanges() + _apply() } - var mBearingImage: String? = null + + var visible: Boolean = true set(value) { field = value - applyChanges() + _apply() } - var mShadowImage: String? = null - set(value) { - field = value - applyChanges() + + private fun imageNameUpdated(image: PuckImagePart, name: String?) { + if (name != null) { + imageNames[image] = name + } else { + imageNames.remove(image) } - var mScale: Double = 1.0 - set(value) { - field = value - applyChanges() + subscriptions[image]?.let { + it.cancel() + } + subscriptions.remove(image) + + if (name == null) { + imageUpdated(image, null) + return } + imageManager?.let { subscribe(it, image, name) } + + } + + private fun imageUpdated(image: PuckImagePart, imageHolder: ImageHolder?) { + if (imageHolder != null) { + images[image] = imageHolder + } else { + images.remove(image) + } + _apply() + } + + private fun _apply() { + mMapView?.let { + it.mapView?.let { + _apply(it) + } + } + } + + private fun _apply(mapView: MapView) { + val location2 = mapView.location2; + + if (visible) { + if (images.isEmpty()) { + location2.locationPuck = + makeDefaultLocationPuck2D(mContext, androidRenderMode ?: RenderMode.NORMAL) + } else { + location2.locationPuck = LocationPuck2D( + topImage = images[PuckImagePart.TOP], + bearingImage = images[PuckImagePart.BEARING], + shadowImage = images[PuckImagePart.SHADOW], + scaleExpression = scale?.toJson() + ) + } + } else { + val empty = + AppCompatResourcesV11.getDrawableImageHolder(mContext, R.drawable.empty) + location2.locationPuck = LocationPuck2D( + topImage = empty, + bearingImage = empty, + shadowImage = empty + ) + } + + this.puckBearing?.let { + location2.puckBearing = it + } + this.puckBearingEnabled?.let { + location2.puckBearingEnabled = it + } + } + override fun addToMap(mapView: RNMBXMapView) { super.addToMap(mapView) mEnabled = true mapView.getMapboxMap() mapView.getMapAsync(this) mMapView?.locationComponentManager?.showNativeUserLocation(true) - applyChanges() + _fetchImages(mapView) + _apply() } override fun removeFromMap(mapView: RNMBXMapView, reason: RemovalReason): Boolean { @@ -63,12 +175,7 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O override fun onMapReady(mapboxMap: MapboxMap) { mMap = mapboxMap mapboxMap.getStyle(this) - applyChanges() - } - - fun setAndroidRenderMode(renderMode: RenderMode) { - mRenderMode = renderMode; - applyChanges(); + _apply() } @SuppressLint("MissingPermission") @@ -82,79 +189,64 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O mMapView?.locationComponentManager?.showNativeUserLocation(mEnabled) } - @SuppressLint("DiscouragedApi") - fun applyChanges() { - val useCustomImages = mTopImage != null || mBearingImage != null || mShadowImage != null - - val bearingImageResourceId = if (mBearingImage != null) { - context.resources.getIdentifier( - mBearingImage, - "drawable", - context.packageName - ) - } else if (useCustomImages) { - null - } else when (mRenderMode) { - RenderMode.GPS -> R.drawable.mapbox_user_bearing_icon - RenderMode.COMPASS -> R.drawable.mapbox_user_puck_icon - RenderMode.NORMAL -> R.drawable.mapbox_user_stroke_icon + // region fetch images and subscribe on updates + private fun subscribe(imageManager: ImageManager, image: PuckImagePart, name: String) { + subscriptions[image]?.let { + it.cancel() + subscriptions.remove(image) + Logger.e("RNMBXNativeUserLocation", "subscribe: there is alread a subscription for image: $image") } - val topImageResourceId = if (mTopImage != null) { - context.resources.getIdentifier( - mTopImage, - "drawable", - context.packageName - ) - } else if (useCustomImages) { - null - } else R.drawable.mapbox_user_icon - - val shadowImageResourceId = if (mShadowImage != null) { - context.resources.getIdentifier( - mShadowImage, - "drawable", - context.packageName - ) - } else if (useCustomImages) { - null - } else R.drawable.mapbox_user_icon_shadow - - val puckBearingSource = when (mRenderMode) { - RenderMode.GPS -> PuckBearingSource.COURSE - RenderMode.COMPASS -> PuckBearingSource.HEADING - RenderMode.NORMAL -> null + subscriptions[image] = imageManager.subscribe(name, Resolver { _, imageData -> + imageUpdated(image, imageData.toImageHolder()) + }) + } + + private fun removeSubscriptions() { + subscriptions.forEach { + it.value.cancel() } - val pulsing = mRenderMode == RenderMode.NORMAL - - - mMapView?.locationComponentManager?.let { locationComponentManager -> - // emulate https://docs.mapbox.com/android/legacy/maps/guides/location-component/ - locationComponentManager.update { state -> - state.copy( - bearingImage = bearingImageResourceId?.let { bearingImageResourceId -> - AppCompatResourcesV11.getDrawableImageHolder( - mContext, - bearingImageResourceId - ) - }, - topImage = topImageResourceId?.let { topImageResourceId -> - AppCompatResourcesV11.getDrawableImageHolder( - mContext, - topImageResourceId - ) - }, - shadowImage = shadowImageResourceId?.let { shadowImageResourceId -> - AppCompatResourcesV11.getDrawableImageHolder( - mContext, - shadowImageResourceId - ) - }, - puckBearingSource = puckBearingSource, - pulsing = pulsing, - scale = mScale - ) + subscriptions.clear() + } + + private fun _fetchImages(map: RNMBXMapView) { + map.mapView?.getMapboxMap()?.getStyle()?.let { style -> + imageNames.forEach { (part,name) -> + if (style.hasStyleImage(name)) { + style.getStyleImage(name)?.let { image -> + images[part] = image.toImageHolder() + } + } } } + + removeSubscriptions() + val imageManager = map.imageManager + this.imageManager = imageManager + imageNames.forEach { (part,name) -> + subscribe(imageManager, part, name) + } } + // endregion +} + +fun makeDefaultLocationPuck2D(context: Context, renderMode: RenderMode): LocationPuck2D { + return LocationPuck2D( + topImage = AppCompatResourcesV11.getDrawableImageHolder( + context, + R.drawable.mapbox_user_icon + ), + bearingImage = AppCompatResourcesV11.getDrawableImageHolder( + context, + when (renderMode) { + RenderMode.GPS -> R.drawable.mapbox_user_bearing_icon + RenderMode.COMPASS -> R.drawable.mapbox_user_puck_icon + RenderMode.NORMAL -> R.drawable.mapbox_user_stroke_icon + } + ), + shadowImage = AppCompatResourcesV11.getDrawableImageHolder( + context, + R.drawable.mapbox_user_icon_shadow + ) + ); } \ No newline at end of file diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocationManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocationManager.kt index 58801d1b4f..889009bdfc 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocationManager.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/location/RNMBXNativeUserLocationManager.kt @@ -1,11 +1,20 @@ package com.rnmapbox.rnmbx.components.location import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableType import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.viewmanagers.RNMBXNativeUserLocationManagerInterface +import com.google.gson.Gson +import com.google.gson.stream.JsonWriter +import com.mapbox.bindgen.Value +import com.mapbox.maps.extension.style.expressions.generated.Expression +import com.rnmapbox.rnmbx.utils.Logger +import com.rnmapbox.rnmbx.utils.extensions.toJsonArray +import java.io.StringWriter import javax.annotation.Nonnull +import com.rnmapbox.rnmbx.v11compat.location.* class RNMBXNativeUserLocationManager : ViewGroupManager(), RNMBXNativeUserLocationManagerInterface { @@ -16,31 +25,56 @@ class RNMBXNativeUserLocationManager : ViewGroupManager @ReactProp(name = "androidRenderMode") override fun setAndroidRenderMode(userLocation: RNMBXNativeUserLocation, mode: Dynamic) { + if (!mode.isNull) { + Logger.e("RNMBXNativeUserLocationManager", "androidRenderMode is deprecated, use puckBearing instead") + } when (mode.asString()) { - "compass" -> userLocation.setAndroidRenderMode(RenderMode.COMPASS) - "gps" -> userLocation.setAndroidRenderMode(RenderMode.GPS) - "normal" -> userLocation.setAndroidRenderMode(RenderMode.NORMAL) + "compass" -> userLocation.androidRenderMode = RenderMode.COMPASS + "gps" -> userLocation.androidRenderMode = RenderMode.GPS + "normal" -> userLocation.androidRenderMode = RenderMode.NORMAL + } + } + + @ReactProp(name = "puckBearing") + override fun setPuckBearing(view: RNMBXNativeUserLocation, value: Dynamic) { + when (value?.asString()) { + "heading" -> view.puckBearing = PuckBearing.HEADING + "course" -> view.puckBearing = PuckBearing.COURSE + null -> Unit + else -> + Logger.e("RNMBXNativeUserLocationManager", "unexpected value for puckBearing: $value") + } + } + + @ReactProp(name = "puckBearingEnabled") + override fun setPuckBearingEnabled(view: RNMBXNativeUserLocation, value: Dynamic) { + if (!value.isNull) { + if (value.type == ReadableType.Boolean) { + view.puckBearingEnabled = value.asBoolean() + } else { + Logger.e("RNMBXNativeUserLocationManager", "unexpected value for puckBearingEnabled: $value") + } } } @ReactProp(name = "topImage") override fun setTopImage(view: RNMBXNativeUserLocation, value: Dynamic?) { - view.mTopImage = value?.asString() + view.topImage = value?.asString() } @ReactProp(name = "bearingImage") override fun setBearingImage(view: RNMBXNativeUserLocation, value: Dynamic?) { - view.mBearingImage = value?.asString() + view.bearingImage = value?.asString() } @ReactProp(name = "shadowImage") override fun setShadowImage(view: RNMBXNativeUserLocation, value: Dynamic?) { - view.mShadowImage = value?.asString() + view.shadowImage = value?.asString() } @ReactProp(name = "scale", defaultDouble = 1.0) override fun setScale(view: RNMBXNativeUserLocation, value: Dynamic?) { - view.mScale = value?.asDouble() ?: 1.0 + view.scale = _convertToDoubleValueOrExpression(value, "scale") } @ReactProp(name = "iosShowsUserHeadingIndicator") @@ -48,6 +82,11 @@ class RNMBXNativeUserLocationManager : ViewGroupManager // iOS only } + @ReactProp(name = "visible") + override fun setVisible(view: RNMBXNativeUserLocation, value: Boolean) { + view.visible = value + } + @Nonnull override fun createViewInstance(@Nonnull reactContext: ThemedReactContext): RNMBXNativeUserLocation { return RNMBXNativeUserLocation(reactContext) @@ -56,4 +95,25 @@ class RNMBXNativeUserLocationManager : ViewGroupManager companion object { const val REACT_CLASS = "RNMBXNativeUserLocation" } -} \ No newline at end of file +} + + + +fun _convertToDoubleValueOrExpression(value: Dynamic?, name: String): Value? { + if (value == null) { + return null + } + return when (value.type) { + ReadableType.Array -> + Expression.fromRaw(Gson().toJson(value.asArray().toJsonArray())) + ReadableType.Number -> + Value.valueOf(value.asDouble()) + else -> { + Logger.e( + "RNMBXNativeUserLocationmanager", + "_convertToExpressionString: cannot convert $name to a double or double exrpession. $value" + ) + return null + } + } +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 1a99e2e279..7e42fcb8f4 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -80,6 +80,7 @@ import org.json.JSONObject import java.util.* import com.mapbox.maps.MapboxMap.*; +import com.rnmapbox.rnmbx.components.images.ImageManager import com.rnmapbox.rnmbx.v11compat.event.* import com.rnmapbox.rnmbx.v11compat.feature.* @@ -203,6 +204,9 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie */ public var offscreenAnnotationViewContainer: ViewGroup? = null + + public var imageManager = ImageManager() + private val mSources: MutableMap> private val mImages: MutableList private var mPointAnnotationManager: PointAnnotationManager? = null diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/styles/RNMBXStyle.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/styles/RNMBXStyle.kt index 08b6343b92..2a12696a7b 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/styles/RNMBXStyle.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/styles/RNMBXStyle.kt @@ -57,7 +57,7 @@ class RNMBXStyle(private val mContext: Context, reactStyle: ReadableMap?, map: M imageEntry(styleValue) ) ) - val task = DownloadMapImageTask(mContext, mMap, callback) + val task = DownloadMapImageTask(mContext, mMap, null, callback) task.execute(*images) } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/DownloadMapImageTask.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/DownloadMapImageTask.kt index 118023942e..88a212bdbc 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/utils/DownloadMapImageTask.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/DownloadMapImageTask.kt @@ -20,6 +20,7 @@ import com.facebook.imagepipeline.image.CloseableStaticBitmap import com.facebook.imagepipeline.request.ImageRequestBuilder import com.facebook.react.views.imagehelper.ImageSource import com.rnmapbox.rnmbx.components.images.ImageInfo +import com.rnmapbox.rnmbx.components.images.ImageManager import java.io.File import java.lang.ref.WeakReference import java.util.HashMap @@ -27,12 +28,13 @@ import com.rnmapbox.rnmbx.v11compat.image.* data class DownloadedImage(val name: String, val bitmap: Bitmap, val info: ImageInfo) -class DownloadMapImageTask(context: Context, map: MapboxMap, callback: OnAllImagesLoaded?) : +class DownloadMapImageTask(context: Context, map: MapboxMap, imageManager: ImageManager?, callback: OnAllImagesLoaded? = null) : AsyncTask, Void?, List>() { private val mContext: WeakReference private val mMap: WeakReference private val mCallback: OnAllImagesLoaded? private val mCallerContext: Any + private val mImageManager: WeakReference interface OnAllImagesLoaded { fun onAllImagesLoaded() @@ -97,7 +99,7 @@ class DownloadMapImageTask(context: Context, map: MapboxMap, callback: OnAllImag for (image in images) { bitmapImages[image.name] = image.bitmap val info = image.info - + mImageManager.get()?.resolve(image.name, image.bitmap) style.addBitmapImage(image.name, image.bitmap, info) } } @@ -112,6 +114,7 @@ class DownloadMapImageTask(context: Context, map: MapboxMap, callback: OnAllImag init { mContext = WeakReference(context.applicationContext) mMap = WeakReference(map) + mImageManager = WeakReference(imageManager) mCallback = callback mCallerContext = this } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableArray.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableArray.kt index d63f0a8231..83a78bcf5d 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableArray.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableArray.kt @@ -2,11 +2,16 @@ package com.rnmapbox.rnmbx.utils.extensions import android.graphics.RectF import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableType +import com.google.gson.JsonArray +import com.google.gson.JsonElement import com.mapbox.geojson.Point import com.mapbox.maps.ScreenCoordinate +import com.rnmapbox.rnmbx.utils.ConvertUtils import com.rnmapbox.rnmbx.utils.Logger -import java.lang.Float.min +import org.json.JSONArray import java.lang.Float.max +import java.lang.Float.min fun ReadableArray.toCoordinate() : Point { if (this.size() != 2) { @@ -36,3 +41,18 @@ fun ReadableArray.toRectF() : RectF? { max(getDouble(0).toFloat(), getDouble(2).toFloat()) ) } + +fun ReadableArray.toJsonArray() : JsonArray { + val result = JsonArray(size()) + for (i in 0 until size()) { + when (getType(i)) { + ReadableType.Map -> result.add(getMap(i).toJsonObject()) + ReadableType.Array -> result.add(getArray(i).toJsonArray()) + ReadableType.Null -> result.add(null as JsonElement?) + ReadableType.Number -> result.add(getDouble(i)) + ReadableType.String -> result.add(getString(i)) + ReadableType.Boolean -> result.add(getBoolean(i)) + } + } + return result +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableMap.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableMap.kt index cff9ba4994..a0e3f8133c 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableMap.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/extensions/ReadableMap.kt @@ -2,6 +2,10 @@ package com.rnmapbox.rnmbx.utils.extensions import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.rnmapbox.rnmbx.utils.ConvertUtils import com.rnmapbox.rnmbx.utils.Logger fun ReadableMap.forEach(action: (String, Any) -> Unit) { @@ -65,3 +69,20 @@ fun ReadableMap.getAndLogIfNotString(key: String, tag: String = "RNMBXReadableMa null } } + +fun ReadableMap.toJsonObject() : JsonObject { + val result = JsonObject() + val it = keySetIterator() + while (it.hasNextKey()) { + val key = it.nextKey() + when (getType(key)) { + ReadableType.Map -> result.add(key, getMap(key)!!.toJsonObject()) + ReadableType.Array -> result.add(key, getArray(key)!!.toJsonArray()) + ReadableType.Null -> result.add(key, null) + ReadableType.Number -> result.addProperty(key, getDouble(key)) + ReadableType.String -> result.addProperty(key, getString(key)) + ReadableType.Boolean -> result.addProperty(key, getBoolean(key)) + } + } + return result +} diff --git a/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Cancelable.kt b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Cancelable.kt new file mode 100644 index 0000000000..79f5434b69 --- /dev/null +++ b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Cancelable.kt @@ -0,0 +1,15 @@ +package com.rnmapbox.rnmbx.v11compat +/** + * Allows to cancel the associated asynchronous operation + * + * The associated asynchronous operation is not automatically canceled if this + * object goes out of scope. + */ +interface Cancelable { + /** + * Cancels the associated asynchronous operation + * + * If the associated asynchronous operation has already finished, this call is ignored. + */ + fun cancel() +} diff --git a/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Image.kt b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Image.kt index f0b84878d1..09dc68adb0 100644 --- a/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Image.kt +++ b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Image.kt @@ -1,6 +1,7 @@ package com.rnmapbox.rnmbx.v11compat.image; import android.content.Context +import android.content.res.Resources import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable @@ -55,3 +56,14 @@ class AppCompatResourcesV11 { } } } + +fun Image.toDrawable(): Drawable { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)) + + return BitmapDrawable(Resources.getSystem(), bitmap) +} + +fun Image.toImageHolder(): ImageHolder { + return toDrawable() +} diff --git a/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Location.kt b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Location.kt index 183973dae2..ad24cab5ad 100644 --- a/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Location.kt +++ b/android/src/main/mapbox-v11-compat/v10/com/rnmapbox/rnmbx/v11compat/Location.kt @@ -3,35 +3,32 @@ package com.rnmapbox.rnmbx.v11compat.location; import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.Manifest.permission.ACCESS_FINE_LOCATION import android.content.Context -import android.location.LocationManager import android.os.Looper import androidx.annotation.RequiresPermission import com.mapbox.maps.MapView import com.mapbox.maps.plugin.PuckBearingSource import com.mapbox.maps.plugin.locationcomponent.LocationComponentPlugin2 import com.mapbox.maps.plugin.locationcomponent.location2 as _location2 - - import com.mapbox.android.core.location.LocationEngineResult as _LocationEngineResult - import com.mapbox.android.core.location.LocationEngine as _LocationEngine import com.mapbox.android.core.location.LocationEngineCallback as _LocationEngineCallback import com.mapbox.android.core.location.LocationEngineRequest import com.mapbox.android.core.location.LocationEngineProvider -import com.mapbox.common.location.LocationService -import com.mapbox.common.location.LocationUpdatesReceiver -import com.mapbox.common.location.LocationServiceFactory import android.location.Location as _Location -//import com.mapbox.common.location.Location as _Location typealias Location = _Location; -typealias PuckBearingSource = com.mapbox.maps.plugin.PuckBearingSource +typealias PuckBearing = PuckBearingSource val MapView.location2 : LocationComponentPlugin2 get() = _location2 + +var LocationComponentPlugin2.puckBearing: PuckBearingSource + get() = this.puckBearingSource + set(value) { puckBearingSource = value } + typealias LocationEngine = _LocationEngine typealias LocationEngineResult = _LocationEngineResult typealias LocationEngineCallback = _LocationEngineCallback diff --git a/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Cancelable.kt b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Cancelable.kt new file mode 100644 index 0000000000..eec766234d --- /dev/null +++ b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Cancelable.kt @@ -0,0 +1,3 @@ +package com.rnmapbox.rnmbx.v11compat + +typealias Cancelable = com.mapbox.common.Cancelable; \ No newline at end of file diff --git a/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Image.kt b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Image.kt index 7fea165034..5fd12850b3 100644 --- a/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Image.kt +++ b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Image.kt @@ -1,8 +1,10 @@ package com.rnmapbox.rnmbx.v11compat.image; import android.content.Context +import android.content.res.Resources import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.graphics.drawable.VectorDrawable import androidx.annotation.DrawableRes import androidx.core.graphics.drawable.toBitmap @@ -12,6 +14,7 @@ import com.mapbox.maps.ImageHolder import com.mapbox.maps.Style import com.mapbox.maps.toMapboxImage import com.rnmapbox.rnmbx.components.images.ImageInfo +import java.nio.ByteBuffer import com.mapbox.maps.toMapboxImage as _toMapboxImage typealias ImageHolder = com.mapbox.maps.ImageHolder @@ -68,3 +71,18 @@ fun emptyImage(width: Int, height: Int): Image { width, height, DataRef.allocateNative(width * height * 4) ) } + +fun Image.toDrawable(): Drawable { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(data.buffer) + + return BitmapDrawable(Resources.getSystem(), bitmap) +} + +fun Image.toImageHolder(): ImageHolder { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(data.buffer) + + return ImageHolder.from(bitmap) +} + diff --git a/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt index 06f8c2a307..8cda49e735 100644 --- a/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt +++ b/android/src/main/mapbox-v11-compat/v11/com/rnmapbox/rnmbx/v11compat/Location.kt @@ -8,7 +8,7 @@ import com.mapbox.common.location.IntervalSettings import com.mapbox.common.location.LocationObserver import com.mapbox.common.location.Location as _Location import com.mapbox.maps.MapView -import com.mapbox.maps.plugin.PuckBearing +import com.mapbox.maps.plugin.PuckBearing as _PuckBearing import com.mapbox.maps.plugin.locationcomponent.LocationComponentPlugin import com.mapbox.maps.plugin.locationcomponent.location @@ -19,7 +19,7 @@ import com.mapbox.common.location.LocationServiceFactory import com.rnmapbox.rnmbx.utils.Logger import kotlin.math.absoluteValue -typealias PuckBearingSource = PuckBearing +typealias PuckBearing = _PuckBearing const val DEFAULT_FASTEST_INTERVAL_MILLIS: Long = 1000 const val DEFAULT_INTERVAL_MILLIS: Long = 1000 diff --git a/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerDelegate.java b/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerDelegate.java index 942733ca4c..004c7a9715 100644 --- a/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerDelegate.java +++ b/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerDelegate.java @@ -25,8 +25,11 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "androidRenderMode": mViewManager.setAndroidRenderMode(view, new DynamicFromObject(value)); break; - case "iosShowsUserHeadingIndicator": - mViewManager.setIosShowsUserHeadingIndicator(view, new DynamicFromObject(value)); + case "puckBearing": + mViewManager.setPuckBearing(view, new DynamicFromObject(value)); + break; + case "puckBearingEnabled": + mViewManager.setPuckBearingEnabled(view, new DynamicFromObject(value)); break; case "bearingImage": mViewManager.setBearingImage(view, new DynamicFromObject(value)); @@ -40,6 +43,9 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "scale": mViewManager.setScale(view, new DynamicFromObject(value)); break; + case "visible": + mViewManager.setVisible(view, value == null ? false : (boolean) value); + break; default: super.setProperty(view, propName, value); } diff --git a/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerInterface.java b/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerInterface.java index ca70656222..df0b5e5b54 100644 --- a/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerInterface.java +++ b/android/src/main/old-arch/com/facebook/react/viewmanagers/RNMBXNativeUserLocationManagerInterface.java @@ -14,9 +14,11 @@ public interface RNMBXNativeUserLocationManagerInterface { void setAndroidRenderMode(T view, Dynamic value); - void setIosShowsUserHeadingIndicator(T view, Dynamic value); + void setPuckBearing(T view, Dynamic value); + void setPuckBearingEnabled(T view, Dynamic value); void setBearingImage(T view, Dynamic value); void setShadowImage(T view, Dynamic value); void setTopImage(T view, Dynamic value); void setScale(T view, Dynamic value); + void setVisible(T view, boolean value); } diff --git a/docs/MapView.md b/docs/MapView.md index f5eda38fe7..43a6304096 100644 --- a/docs/MapView.md +++ b/docs/MapView.md @@ -730,3 +730,4 @@ Show the attribution and telemetry action sheet.
If you implement a custom a + diff --git a/docs/NativeUserLocation.md b/docs/NativeUserLocation.md index 81dd481988..e0b157d48b 100644 --- a/docs/NativeUserLocation.md +++ b/docs/NativeUserLocation.md @@ -24,10 +24,32 @@ Android render mode. - compass: triangle with heading - gps: large arrow +@deprecated use `puckBearing` for source and `bearingImage` for image @platform android +### puckBearing + +```tsx +'heading' | 'course' +``` +The bearing of the puck. + + - heading: Orients the puck to match the direction in which the device is facing. + - course: Orients the puck to match the direction in which the device is moving. + + + +### puckBearingEnabled + +```tsx +boolean +``` +Whether the puck rotates to track the bearing source. + + + ### iosShowsUserHeadingIndicator ```tsx @@ -36,51 +58,57 @@ boolean iOS only. A Boolean value indicating whether the user location annotation may display a permanent heading indicator. @platform ios +@deprecated use `puckBearingEnabled={true} puckBrearing="heading"` instead -### topImageAsset +### topImage ```tsx string ``` -The name of native image asset to use as the top layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android - +The name of image to use as the top layer for the location indicator. Images component should define this image. +[Custom Native UserLocation](../examples/UserLocation/CustomNativeUserLocation) -### bearingImageAsset +### bearingImage ```tsx string ``` -The name of native image asset to use as the middle layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android +The name of image to use as the middle layer for the location indicator. Images component should define this image. -### shadowImageAsset +### shadowImage ```tsx string ``` -The name of native image asset to use as the background0 for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android +The name of image asset to use as the background0 for the location indicator. Images component should define this image. ### scale ```tsx -number +T | Expression ``` -The size of the images, as a scale factor applied to the size of the specified image. +The size of the images, as a scale factor applied to the size of the specified image. Supports expressions based on zoom. +@example +["interpolate",["linear"], ["zoom"], 10.0, 1.0, 20.0, 4.0]] +@example +2.0 +[Custom Native UserLocation](../examples/UserLocation/CustomNativeUserLocation) ### visible ```tsx boolean ``` -Whether location icon is visible +Whether location icon is visible, defaults to true diff --git a/docs/docs.json b/docs/docs.json index bde1ba0abf..873e09676c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -4110,49 +4110,63 @@ "required": false, "type": "'normal' \\| 'compass' \\| 'gps'", "default": "none", - "description": "Android render mode.\n\n - normal: just a circle\n - compass: triangle with heading\n - gps: large arrow\n\n@platform android" + "description": "Android render mode.\n\n - normal: just a circle\n - compass: triangle with heading\n - gps: large arrow\n\n@deprecated use `puckBearing` for source and `bearingImage` for image\n@platform android" + }, + { + "name": "puckBearing", + "required": false, + "type": "'heading' \\| 'course'", + "default": "none", + "description": "The bearing of the puck.\n\n - heading: Orients the puck to match the direction in which the device is facing.\n - course: Orients the puck to match the direction in which the device is moving." + }, + { + "name": "puckBearingEnabled", + "required": false, + "type": "boolean", + "default": "none", + "description": "Whether the puck rotates to track the bearing source." }, { "name": "iosShowsUserHeadingIndicator", "required": false, "type": "boolean", "default": "none", - "description": "iOS only. A Boolean value indicating whether the user location annotation may display a permanent heading indicator.\n\n@platform ios" + "description": "iOS only. A Boolean value indicating whether the user location annotation may display a permanent heading indicator.\n\n@platform ios\n@deprecated use `puckBearingEnabled={true} puckBrearing=\"heading\"` instead" }, { - "name": "topImageAsset", + "name": "topImage", "required": false, "type": "string", "default": "none", - "description": "The name of native image asset to use as the top layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android" + "description": "The name of image to use as the top layer for the location indicator. Images component should define this image." }, { - "name": "bearingImageAsset", + "name": "bearingImage", "required": false, "type": "string", "default": "none", - "description": "The name of native image asset to use as the middle layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android" + "description": "The name of image to use as the middle layer for the location indicator. Images component should define this image." }, { - "name": "shadowImageAsset", + "name": "shadowImage", "required": false, "type": "string", "default": "none", - "description": "The name of native image asset to use as the background0 for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android" + "description": "The name of image asset to use as the background0 for the location indicator. Images component should define this image." }, { "name": "scale", "required": false, - "type": "number", + "type": "T \\| Expression", "default": "none", - "description": "The size of the images, as a scale factor applied to the size of the specified image." + "description": "The size of the images, as a scale factor applied to the size of the specified image. Supports expressions based on zoom.\n\n@example\n[\"interpolate\",[\"linear\"], [\"zoom\"], 10.0, 1.0, 20.0, 4.0]]\n@example\n2.0" }, { "name": "visible", "required": false, "type": "boolean", "default": "none", - "description": "Whether location icon is visible" + "description": "Whether location icon is visible, defaults to true" } ], "fileNameWithExt": "NativeUserLocation.tsx", diff --git a/docs/examples.json b/docs/examples.json index 053399fe65..c3e2be1f8e 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -109,6 +109,20 @@ "title": "User Location" }, "examples": [ + { + "metadata": { + "title": "Custom Native UserLocation", + "tags": [ + "NativeUserLocation", + "NativeUserLocation#topImage", + "NativeUserLocation#scale" + ], + "docs": "\n Demonstrates use of images to customize NativeUserLocation\n " + }, + "fullPath": "example/src/examples/UserLocation/CustomNativeUserLocation.tsx", + "relPath": "UserLocation/CustomNativeUserLocation.tsx", + "name": "CustomNativeUserLocation" + }, { "metadata": { "title": "Set Displacement", @@ -134,21 +148,6 @@ "relPath": "UserLocation/SetTintColor.js", "name": "SetTintColor" }, - { - "metadata": { - "title": "User Location Native Animated", - "tags": [ - "UserLocation", - "UserLocation#nativeTopImage", - "MapView#setCustomLocation", - "MapView#removeCustomLocationProvider" - ], - "docs": "\n Demonstrates native UserLocation being natively animated using a custom location provider\n " - }, - "fullPath": "example/src/examples/UserLocation/UserLocationNativeAnimated.tsx", - "relPath": "UserLocation/UserLocationNativeAnimated.tsx", - "name": "UserLocationNativeAnimated" - }, { "metadata": { "title": "User Location Padding", diff --git a/example/android/build.gradle b/example/android/build.gradle index 2f09674903..ed12da70bc 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -6,7 +6,7 @@ buildscript { if (project.hasProperty('RNMBX11') && project.getProperty('RNMBX11').toBoolean()) { RNMapboxMapsUseV11 = true - RNMapboxMapsVersion = '11.0.0-beta.5' + RNMapboxMapsVersion = '11.0.0-rc.1' } useCustomMapbox = false @@ -16,7 +16,7 @@ buildscript { kotlinVersion = '1.6.21' } else if (System.getenv('CI_MAP_IMPL').equals('mapbox11')) { RNMapboxMapsUseV11 = true - RNMapboxMapsVersion = '11.0.0-beta.5' + RNMapboxMapsVersion = '11.0.0-rc.1' RNMapboxMapsImpl = "mapbox" } diff --git a/example/src/examples/UserLocation/CustomNativeUserLocation.tsx b/example/src/examples/UserLocation/CustomNativeUserLocation.tsx new file mode 100644 index 0000000000..6dc5f3aa85 --- /dev/null +++ b/example/src/examples/UserLocation/CustomNativeUserLocation.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { SafeAreaView, View } from 'react-native'; +import { + MapView, + Camera, + UserTrackingMode, + NativeUserLocation, + Images, + Image, +} from '@rnmapbox/maps'; + +import { ExampleWithMetadata } from '../common/ExampleMetadata'; + +const styles = { matchParent: { flex: 1 } }; + +const UserLocationNativeAnimated = () => { + return ( + + + + + + + + + + + + ); +}; + +export default UserLocationNativeAnimated; + +const metadata: ExampleWithMetadata['metadata'] = { + title: 'Custom Native UserLocation', + tags: [ + 'NativeUserLocation', + 'NativeUserLocation#topImage', + 'NativeUserLocation#scale', + ], + docs: ` + Demonstrates use of images to customize NativeUserLocation + `, +}; +UserLocationNativeAnimated.metadata = metadata; diff --git a/example/src/examples/UserLocation/index.js b/example/src/examples/UserLocation/index.js index b91e28befa..d038f7a441 100644 --- a/example/src/examples/UserLocation/index.js +++ b/example/src/examples/UserLocation/index.js @@ -3,7 +3,7 @@ export { default as SetTintColor } from './SetTintColor'; export { default as UserLocationPadding } from './UserLocationPadding'; export { default as UserLocationRenderMode } from './UserLocationRenderMode'; export { default as UserLocationUpdates } from './UserLocationUpdates'; -export { default as UserLocationNativeAnimated } from './UserLocationNativeAnimated'; +export { default as CustomNativeUserLocation } from './CustomNativeUserLocation'; export const metadata = { title: 'User Location', diff --git a/ios/RNMBX/ImageManager.swift b/ios/RNMBX/ImageManager.swift new file mode 100644 index 0000000000..756506cc98 --- /dev/null +++ b/ios/RNMBX/ImageManager.swift @@ -0,0 +1,41 @@ +import MapboxMaps +/** + ImageManager helps to resolve images defined by any of RNMBXImages component. + */ +class ImageManager { + typealias Resolver = (String, UIImage) -> Void + var subscriptions: [String: [Subscription]] = [:] + + class Subscription : Cancelable { + var name: String + var resolved: Resolver + weak var manager: ImageManager? + + init(name: String, resolved: @escaping Resolver) { + self.name = name + self.resolved = resolved + } + + func cancel() { + manager?.unsubscript(subscription: self) + } + } + + func subscribe(name: String, resolved: @escaping Resolver) -> Subscription { + var subscription = Subscription(name: name, resolved: resolved) + var list = subscriptions[name] ?? [] + list.append(subscription) + subscriptions[name] = list + return subscription + } + + func unsubscript(subscription: Subscription) { + var list = subscriptions[subscription.name] ?? [] + list.removeAll { $0 === subscription } + subscriptions[subscription.name] = list + } + + func resolve(name: String, image: UIImage) { + subscriptions[name]?.forEach { $0.resolved(name, image) } + } +} diff --git a/ios/RNMBX/RNMBXFabricPropConvert.h b/ios/RNMBX/RNMBXFabricPropConvert.h new file mode 100644 index 0000000000..27b624533e --- /dev/null +++ b/ios/RNMBX/RNMBXFabricPropConvert.h @@ -0,0 +1,41 @@ +#pragma once + +/** + * + * 1. Requirest the following prelude + * const auto &oldViewProps = static_cast(*oldProps); + * const auto &newViewProps = static_cast(*props); + * + * 2. OPTION_PROPS are not set when the prop is undefined/null + */ + +NSNumber* RNMBXPropConvert_Optional_BOOL_NSNumber(const folly::dynamic &dyn, NSString* propertyName); +BOOL RNMBXPropConvert_Optional_BOOL(const folly::dynamic &dyn, NSString* propertyName); +NSString* RNMBXPropConvert_Optional_NSString(const folly::dynamic &dyn, NSString* propertyName); +id RNMBXPropConvert_Optional_ExpressionDouble(const folly::dynamic &dyn, NSString* propertyName); +BOOL RNMBXPropConvert_BOOL(const folly::dynamic &dyn, NSString* propertyName); + +#define RNMBX_OPTIONAL_RPOP_BOOL_NSNumber(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name) && !newViewProps.name.isNull()) { \ + _view.name = RNMBXPropConvert_Optional_BOOL_NSNumber(newViewProps.name, @#name); \ + } + +#define RNMBX_OPTIONAL_RPOP_BOOL(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name) && !newViewProps.name.isNull()) { \ + _view.name = RNMBXPropConvert_Optional_BOOL(newViewProps.name, @#name); \ + } + +#define RNMBX_OPTIONAL_RPOP_NSString(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name) && !newViewProps.name.isNull()) { \ + _view.name = RNMBXPropConvert_Optional_NSString(newViewProps.name, @#name); \ + } + +#define RNMBX_OPTIONAL_RPOP_ExpressionDouble(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name) && !newViewProps.name.isNull()) { \ + _view.name = RNMBXPropConvert_Optional_ExpressionDouble(newViewProps.name, @#name); \ + } + +#define RNMBX_RPOP_BOOL(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name)) { \ + _view.name = RNMBXPropConvert_BOOL(newViewProps.name, @#name); \ + } diff --git a/ios/RNMBX/RNMBXFabricPropConvert.mm b/ios/RNMBX/RNMBXFabricPropConvert.mm new file mode 100644 index 0000000000..722afa097c --- /dev/null +++ b/ios/RNMBX/RNMBXFabricPropConvert.mm @@ -0,0 +1,131 @@ +#ifdef RCT_NEW_ARCH_ENABLED + +#import +#import +#import + +#import "rnmapbox_maps-Swift.pre.h" +#import "RNMBXFabricPropConvert.h" + +BOOL RNMBXPropConvert_BOOL(const folly::dynamic &dyn, NSString* propertyName) { + switch (dyn.type()) { + case folly::dynamic::BOOL: + return dyn.getBool(); + default: + std::stringstream ss; + ss << dyn; + [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be a boolean but was: $d", + propertyName, + ss.str().c_str() + ]]; + return NULL; + } +} + +NSNumber* RNMBXPropConvert_Optional_BOOL_NSNumber(const folly::dynamic &dyn, NSString* propertyName) { + switch (dyn.type()) { + case folly::dynamic::NULLT: + return NULL; + case folly::dynamic::BOOL: + return [NSNumber numberWithBool:dyn.getBool()]; + default: + std::stringstream ss; + ss << dyn; + [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be a boolean or nil but was: $d", + propertyName, + ss.str().c_str() + ]]; + return NULL; + } +} + +BOOL RNMBXPropConvert_Optional_BOOL(const folly::dynamic &dyn, NSString* propertyName) { + switch (dyn.type()) { + case folly::dynamic::BOOL: + return dyn.getBool(); + default: + std::stringstream ss; + ss << dyn; + [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be a boolean or nil but was: $d", + propertyName, + ss.str().c_str() + ]]; + return NO; + } +} + +NSString* RNMBXPropConvert_Optional_NSString(const folly::dynamic &dyn, NSString* propertyName) { + switch (dyn.type()) { + case folly::dynamic::STRING: + return [NSString stringWithCString:dyn.getString().c_str() encoding:NSUTF8StringEncoding]; + case folly::dynamic::NULLT: + return nil; + default: + std::stringstream ss; + ss << dyn; + [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be a string or nil but was: %s", + propertyName, + ss.str().c_str() + ]]; + return nil; + } +} + + +id RNMBXPropConvert_ID(const folly::dynamic &dyn) +{ + switch (dyn.type()) { + case folly::dynamic::NULLT: + return nil; + case folly::dynamic::BOOL: + return dyn.getBool() ? @YES : @NO; + case folly::dynamic::INT64: + return @(dyn.getInt()); + case folly::dynamic::DOUBLE: + return @(dyn.getDouble()); + case folly::dynamic::STRING: + return [NSString stringWithCString:dyn.c_str() encoding:NSUTF8StringEncoding]; + case folly::dynamic::ARRAY: { + NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:dyn.size()]; + for (const auto &elem : dyn) { + id value = RNMBXPropConvert_ID(elem); + if (value) { + [array addObject:value]; + } + } + return array; + } + case folly::dynamic::OBJECT: { + NSMutableDictionary *dict = [[NSMutableDictionary alloc] initWithCapacity:dyn.size()]; + for (const auto &elem : dyn.items()) { + id key = RNMBXPropConvert_ID(elem.first); + id value = RNMBXPropConvert_ID(elem.second); + if (key && value) { + dict[key] = value; + } + } + return dict; + } + } +} + +id RNMBXPropConvert_Optional_ExpressionDouble(const folly::dynamic &dyn, NSString* propertyName) { + switch (dyn.type()) { + case folly::dynamic::ARRAY: + return RNMBXPropConvert_ID(dyn); + case folly::dynamic::DOUBLE: + return [NSNumber numberWithDouble:dyn.getDouble()]; + case folly::dynamic::INT64: + return [NSNumber numberWithInt:dyn.getInt()]; + default: + std::stringstream ss; + ss << dyn; + [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be an array or a number: %s", + propertyName, + ss.str().c_str() + ]]; + return nil; + } +} + +#endif diff --git a/ios/RNMBX/RNMBXImages.swift b/ios/RNMBX/RNMBXImages.swift index c4ee5f9427..ee08d569c2 100644 --- a/ios/RNMBX/RNMBXImages.swift +++ b/ios/RNMBX/RNMBXImages.swift @@ -22,6 +22,8 @@ open class RNMBXImages : UIView, RNMBXMapComponent { weak var style: Style? = nil + var imageManager: ImageManager? = nil + @objc public var onImageMissing: RCTBubblingEventBlock? = nil @@ -46,9 +48,18 @@ open class RNMBXImages : UIView, RNMBXMapComponent { typealias NativeImageInfo = (name:String, sdf: Bool, stretchX:[(from:Float, to:Float)], stretchY:[(from:Float, to:Float)], content: (left:Float,top:Float,right:Float,bottom:Float)? ); var nativeImageInfos: [NativeImageInfo] = [] + @objc public func addImageView(_ image: RNMBXImage) { + imageViews.append(image) + } + + @objc public func removeImageView(_ image: RNMBXImage) { + imageViews.removeAll { $0 == image } + image.images = nil + } + @objc open override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { if let image = subview as? RNMBXImage { - imageViews.insert(image, at: atIndex) + addImageView(image) } else { Logger.log(level:.warn, message: "RNMBXImages children can only be RNMBXImage, got \(optional: subview)") } @@ -57,8 +68,7 @@ open class RNMBXImages : UIView, RNMBXMapComponent { @objc open override func removeReactSubview(_ subview: UIView!) { if let image = subview as? RNMBXImage { - imageViews.removeAll { $0 == image } - image.images = nil + removeImageView(image) } super.removeReactSubview(subview) } @@ -71,6 +81,7 @@ open class RNMBXImages : UIView, RNMBXMapComponent { func addToMap(_ map: RNMBXMapView, style: Style) { self.style = style + imageManager = map.imageManager map.images.append(self) self.addNativeImages(style: style, nativeImages: nativeImageInfos) @@ -80,6 +91,7 @@ open class RNMBXImages : UIView, RNMBXMapComponent { func removeFromMap(_ map: RNMBXMapView, reason: RemovalReason) -> Bool { self.style = nil + imageManager = nil // v10todo return true } @@ -127,7 +139,10 @@ open class RNMBXImages : UIView, RNMBXMapComponent { } if missingImages.count > 0 { - RNMBXUtils.fetchImages(bridge, style: style, objects: missingImages, forceUpdate: true, loaded: { name in self.loadedImages.insert(name) } ,callback: { }) + RNMBXUtils.fetchImages(bridge, style: style, objects: missingImages, forceUpdate: true) { name, image in + self.loadedImages.insert(name) + self.imageManager?.resolve(name: name, image: image) + } } } @@ -252,6 +267,7 @@ open class RNMBXImages : UIView, RNMBXMapComponent { content: RNMBXImages.convert(content: imageInfo.content, scale: image.scale) ) } + imageManager?.resolve(name: imageName, image: image) } else { Logger.log(level:.error, message: "Cannot find nativeImage named \(imageName)") } diff --git a/ios/RNMBX/RNMBXImagesComponentView.mm b/ios/RNMBX/RNMBXImagesComponentView.mm index 4e3162edaf..ec6cb350c7 100644 --- a/ios/RNMBX/RNMBXImagesComponentView.mm +++ b/ios/RNMBX/RNMBXImagesComponentView.mm @@ -3,6 +3,8 @@ #import "RNMBXImagesComponentView.h" #import "RNMBXFabricHelpers.h" +#include "RNMBXImageComponentView.h" + #import #import #import @@ -68,7 +70,7 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { - const auto &newProps = static_cast(*props); + const auto &newProps = static_cast(*props); id images = RNMBXConvertFollyDynamicToId(newProps.images); if (images != nil) { _view.images = images; @@ -81,6 +83,22 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & [super updateProps:props oldProps:oldProps]; } +- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + if ([childComponentView isKindOfClass:[RNMBXImageComponentView class]] && ((RNMBXImageComponentView *)childComponentView).contentView) { + [_view addImageView:((RNMBXImageComponentView *)childComponentView).contentView]; + } + [super mountChildComponentView:childComponentView index:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + if ([childComponentView isKindOfClass:[RCTViewComponentView class]] && ((RCTViewComponentView *)childComponentView).contentView) { + [_view removeImageView:((RCTViewComponentView *)childComponentView).contentView]; + } + [super unmountChildComponentView:childComponentView index:index]; +} + @end Class RNMBXImagesCls(void) diff --git a/ios/RNMBX/RNMBXMapView.swift b/ios/RNMBX/RNMBXMapView.swift index cf23813d84..f2e2610d4c 100644 --- a/ios/RNMBX/RNMBXMapView.swift +++ b/ios/RNMBX/RNMBXMapView.swift @@ -99,6 +99,8 @@ class RNMBXCameraChanged : RNMBXEvent, RCTEvent { @objc(RNMBXMapView) open class RNMBXMapView: UIView { + var imageManager: ImageManager = ImageManager() + var tapDelegate: IgnoreRNMBXMakerViewGestureDelegate? = nil var eventDispatcher: RCTEventDispatcherProtocol @@ -575,7 +577,7 @@ open class RNMBXMapView: UIView { mapView.ornaments.options.scaleBar.margins = margins } } - + @objc override public func didSetProps(_ props: [String]) { if (_mapView == nil) { createMapView() diff --git a/ios/RNMBX/RNMBXMapViewComponentView.mm b/ios/RNMBX/RNMBXMapViewComponentView.mm index 9b88bd665c..77ed1dd485 100644 --- a/ios/RNMBX/RNMBXMapViewComponentView.mm +++ b/ios/RNMBX/RNMBXMapViewComponentView.mm @@ -223,6 +223,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } [super updateProps:props oldProps:oldProps]; + + [_view didSetProps:@[]]; } - (void)prepareForRecycle diff --git a/ios/RNMBX/RNMBXNativeUserLocation.swift b/ios/RNMBX/RNMBXNativeUserLocation.swift index c88afadb37..0b96e56a27 100644 --- a/ios/RNMBX/RNMBXNativeUserLocation.swift +++ b/ios/RNMBX/RNMBXNativeUserLocation.swift @@ -3,6 +3,7 @@ import MapboxMaps @objc public class RNMBXNativeUserLocation : UIView, RNMBXMapComponent { weak var map : RNMBXMapView! = nil + var imageManager: ImageManager? = nil let locationLayerId = "location-layer" @@ -10,76 +11,193 @@ public class RNMBXNativeUserLocation : UIView, RNMBXMapComponent { @objc public var iosShowsUserHeadingIndicator : Bool = false { - didSet { - if let map = self.map { _applySettings(map) } - } + didSet { _apply() } + } + + enum PuckImagePart: String { + case top + case bearing + case shadow } + var imageNames: [PuckImagePart: String] = [:] + var subscriptions: [PuckImagePart: ImageManager.Subscription] = [:] + var images: [PuckImagePart: UIImage] = [:] + @objc - var topImage : NSString? = nil { - didSet { - if let map = self.map { _applySettings(map) } - } + public var topImage : String? = nil { + didSet { imageNameUpdated(.top, topImage) } } @objc - var bearingImage : NSString? = nil { - didSet { - if let map = self.map { _applySettings(map) } - } + public var bearingImage : String? = nil { + didSet { imageNameUpdated(.bearing, bearingImage) } } @objc - var shadowImage : NSString? = nil { - didSet { - if let map = self.map { _applySettings(map) } - } + public var shadowImage : String? = nil { + didSet { imageNameUpdated(.shadow, shadowImage) } } @objc - var scale : NSNumber? = nil { - didSet { - if let map = self.map { _applySettings(map) } + public var scale : Any? = nil + + @objc + public var visible: Bool = false + + var _puckBearing: PuckBearing? = nil + + @objc + public var puckBearing: String? { + get { + switch (_puckBearing) { + case .heading: + return "heading" + case .course: + return "course" + case nil: + return nil + } + } + set(value) { + switch(value) { + case "heading": + _puckBearing = .heading + case "coures": + _puckBearing = .course + case nil: + _puckBearing = nil + default: + Logger.error("RNMBXNativeUserLocation puckBearing is uncrecognized: \(value)") + _puckBearing = nil + } } } - func _applySettings(_ map: RNMBXMapView) { - let location = map.mapView.location! - if (self.topImage != nil || self.bearingImage != nil || self.shadowImage != nil) { - location.options.puckType = .puck2D(Puck2DConfiguration( - topImage: self.topImage != nil ? UIImage(named: self.topImage! as String, in: .main, compatibleWith: nil)! : nil, - bearingImage: self.bearingImage != nil ? UIImage(named: self.bearingImage! as String, in: .main, compatibleWith: nil)! : nil, - shadowImage: self.shadowImage != nil ? UIImage(named: self.shadowImage! as String, in: .main, compatibleWith: nil)! : nil, - scale: self.scale != nil ? .constant(scale as! Double) : nil - )) + @objc + public var puckBearingEnabled: Bool = false + + @objc + override public func didSetProps(_ props: [String]) { + _apply() + } + + func imageNameUpdated(_ image: PuckImagePart, _ name: String?) { + imageNames[image] = name + if let subscription = subscriptions[image] { + subscription.cancel() + subscriptions.removeValue(forKey: image) + } + + guard let name = name else { + imageUpdated(image, nil) + return + } + + if let imageManager = imageManager { + subscribe(imageManager, image, name) + } + } + + func imageUpdated(_ image: PuckImagePart, _ uiImage: UIImage?) { + if let uiImage = uiImage { + images[image] = uiImage } else { - location.options.puckType = .puck2D(.makeDefault(showBearing: iosShowsUserHeadingIndicator)) - } - if (iosShowsUserHeadingIndicator) { - #if RNMBX_11 - location.options.puckBearing = .heading - #else - location.options.puckBearingSource = .heading - #endif - location.options.puckBearingEnabled = true + images.removeValue(forKey: image) + } + _apply() + } + + func toDoubleValue(value: Any?, name: String) -> Value? { + if value == nil { + return nil + } + switch value { + case let value as NSNumber: + return .constant(value.doubleValue) + case let value as Int: + return .constant(Double(value)) + case let value as Double: + return .constant(value) + case let value as Array: + do { + let data = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) + let decodedExpression = try JSONDecoder().decode(Expression.self, from: data) + return Value.expression(decodedExpression) + } catch { + Logger.error("toDoubleValue: \(name): unable to parse as expression \(value) with type: \(type(of:value))") + return Value.constant(0.0) + } + default: + Logger.error("toDoubleValue: \(name): has unknown type: \(type(of:value)) \(value) ") + return .constant(1.0) + } + } + + func _apply() { + guard let map = self.map else { + return + } + guard let mapView = map.mapView else { + Logger.error("RNMBXNativeUserLocation mapView was nil") + return + } + _apply(mapView) + } + + func _apply(_ mapView: MapView) { + guard let location = mapView.location else { + Logger.error("RNMBXNativeUserLocation location was nil") + return + } + + if (visible) { + if images.isEmpty { + location.options.puckType = .puck2D(.makeDefault(showBearing: puckBearingEnabled)) + } else { + location.options.puckType = .puck2D( + Puck2DConfiguration( + topImage: self.images[.top], + bearingImage: self.images[.bearing], + shadowImage: self.images[.shadow], + scale: toDoubleValue(value: scale, name: "scale") + ) + ) + } } else { - location.options.puckBearingEnabled = false + let emptyImage = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } + location.options.puckType = .puck2D( + Puck2DConfiguration( + topImage: emptyImage, + bearingImage: emptyImage, + shadowImage: emptyImage, + scale: Value.constant(1.0) + ) + ) + } + + + location.options.puckBearingEnabled = puckBearingEnabled + if let puckBearing = _puckBearing { + location.options.puckBearing = puckBearing } } func addToMap(_ map: RNMBXMapView, style: Style) { self.map = map - _applySettings(map) + + _fetchImages(map) + _apply() } func removeFromMap(_ map: RNMBXMapView, reason: RemovalReason) -> Bool { - let location = map.mapView.location! - location.options.puckType = nil - guard let mapboxMap = map.mapboxMap else { - return true + if let location = map.mapView.location { + location.options.puckType = nil + location.options.puckType = .none + } else { + Logger.error("RNMBXNativeUserLocation.removeFromMap: location is nil") } - let style = mapboxMap.style - location.options.puckType = .none + removeSubscriptions() self.map = nil return true @@ -89,3 +207,44 @@ public class RNMBXNativeUserLocation : UIView, RNMBXMapComponent { return true } } + +// MARK: fetch images and subscribe on updates + +extension RNMBXNativeUserLocation { + func subscribe(_ imageManager: ImageManager, _ image: PuckImagePart, _ name: String) { + if let subscription = subscriptions[image] { + subscription.cancel() + subscriptions[image] = nil + Logger.error("RNMBXNativeUserLocation.subscribe: there is already a subscription for image: \(image)") + } + + subscriptions[image] = imageManager.subscribe(name: name) { name, uiImage in + self.imageUpdated(image, uiImage) + } + } + + func removeSubscriptions() { + self.subscriptions.forEach { (part,subscription) in + subscription.cancel() + } + self.subscriptions.removeAll() + } + + func _fetchImages(_ map: RNMBXMapView) { + if let style = map.mapView?.mapboxMap?.style { + imageNames.forEach { (part, name) in + if style.imageExists(withId: name), let image = style.image(withId: name) { + images[part] = image + } + } + } + + let imageManager = map.imageManager + removeSubscriptions() + self.imageManager = imageManager + imageNames.forEach { (part,name) in + subscribe(imageManager, part, name) + } + } + +} diff --git a/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm b/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm index 483e790443..edff9a0f97 100644 --- a/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm +++ b/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm @@ -2,6 +2,7 @@ #import "RNMBXNativeUserLocationComponentView.h" #import "RNMBXFabricHelpers.h" +#import "RNMBXFabricPropConvert.h" #import #import @@ -53,13 +54,20 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps { - const auto &newProps = static_cast(*props); - id iosShowsUserHeadingIndicator = RNMBXConvertFollyDynamicToId(newProps.iosShowsUserHeadingIndicator); - if (iosShowsUserHeadingIndicator != nil) { - _view.iosShowsUserHeadingIndicator = iosShowsUserHeadingIndicator; - } - + const auto &oldViewProps = static_cast(*oldProps); + const auto &newViewProps = static_cast(*props); + + RNMBX_OPTIONAL_RPOP_NSString(puckBearing) + RNMBX_OPTIONAL_RPOP_BOOL(puckBearingEnabled) + RNMBX_OPTIONAL_RPOP_NSString(bearingImage) + RNMBX_OPTIONAL_RPOP_NSString(shadowImage) + RNMBX_OPTIONAL_RPOP_NSString(topImage) + RNMBX_OPTIONAL_RPOP_ExpressionDouble(scale) + RNMBX_RPOP_BOOL(visible) + [super updateProps:props oldProps:oldProps]; + + [_view didSetProps:@[]]; } @end diff --git a/ios/RNMBX/RNMBXNativeUserLocationViewManager.m b/ios/RNMBX/RNMBXNativeUserLocationViewManager.m index b618987638..142a2add49 100644 --- a/ios/RNMBX/RNMBXNativeUserLocationViewManager.m +++ b/ios/RNMBX/RNMBXNativeUserLocationViewManager.m @@ -7,7 +7,10 @@ @interface RCT_EXTERN_REMAP_MODULE(RNMBXNativeUserLocation, RNMBXNativeUserLocat RCT_EXPORT_VIEW_PROPERTY(topImage, NSString); RCT_EXPORT_VIEW_PROPERTY(bearingImage, NSString); RCT_EXPORT_VIEW_PROPERTY(shadowImage, NSString); -RCT_EXPORT_VIEW_PROPERTY(scale, NSNumber); +RCT_EXPORT_VIEW_PROPERTY(scale, NSArray); +RCT_EXPORT_VIEW_PROPERTY(visible, BOOL); +RCT_EXPORT_VIEW_PROPERTY(puckBearing, NSString); +RCT_EXPORT_VIEW_PROPERTY(puckBearingEnabled, BOOL); @end diff --git a/ios/RNMBX/RNMBXUtils.swift b/ios/RNMBX/RNMBXUtils.swift index bee3cec80f..8d5f4ed99c 100644 --- a/ios/RNMBX/RNMBXUtils.swift +++ b/ios/RNMBX/RNMBXUtils.swift @@ -8,9 +8,8 @@ class RNMBXUtils { RNMBXImageQueue.sharedInstance.addImage(url, scale: scale, bridge: bridge, handler: callback) } - static func fetchImages(_ bridge: RCTBridge, style: Style, objects: [String:Any], forceUpdate: Bool, loaded: @escaping (_ name:String) -> Void, callback: @escaping ()->Void) { + static func fetchImages(_ bridge: RCTBridge, style: Style, objects: [String:Any], forceUpdate: Bool, loaded: @escaping (String, UIImage) -> Void) { guard !objects.isEmpty else { - callback() return } @@ -19,9 +18,6 @@ class RNMBXUtils { let imageLoadedBlock = { () in imagesToLoad = imagesToLoad - 1; - if imagesToLoad == 0 { - callback() - } } for imageName in imageNames { @@ -55,8 +51,7 @@ class RNMBXUtils { if let image = image { logged("RNMBXUtils.fetchImage-\(imageName)") { try style.addImage(image, id: imageName, sdf:sdf, stretchX: stretchX, stretchY: stretchY, content: content) - loaded(imageName) - imageLoadedBlock() + loaded(imageName, image) } } } diff --git a/ios/RNMBX/rnmapbox_maps-Swift.pre.h b/ios/RNMBX/rnmapbox_maps-Swift.pre.h index 1362c76a34..a4ecb1c5d3 100644 --- a/ios/RNMBX/rnmapbox_maps-Swift.pre.h +++ b/ios/RNMBX/rnmapbox_maps-Swift.pre.h @@ -1,6 +1,9 @@ #import #import +#import +#import + @interface MapView : UIView @end diff --git a/rnmapbox-maps.podspec b/rnmapbox-maps.podspec index e8e04b7908..a519c48c00 100644 --- a/rnmapbox-maps.podspec +++ b/rnmapbox-maps.podspec @@ -228,7 +228,7 @@ Pod::Spec.new do |s| case $RNMapboxMapsImpl when 'mapbox' sp.source_files = "ios/RNMBX/**/*.{h,m,mm,swift}" - sp.private_header_files = 'ios/RNMBX/RNMBXFabricHelpers.h', 'ios/RNMBX/rnmapbox_maps-Swift.pre.h' + sp.private_header_files = 'ios/RNMBX/RNMBXFabricHelpers.h', 'ios/RNMBX/RNMBXFabricPropConvert.h', 'ios/RNMBX/rnmapbox_maps-Swift.pre.h' if new_arch_enabled sp.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } install_modules_dependencies(sp) diff --git a/src/components/NativeUserLocation.tsx b/src/components/NativeUserLocation.tsx index 4b33483a47..3cf2b6b543 100644 --- a/src/components/NativeUserLocation.tsx +++ b/src/components/NativeUserLocation.tsx @@ -1,6 +1,11 @@ import React, { memo } from 'react'; -import RNMBXNativeUserLocationNativeComponent from '../specs/RNMBXNativeUserLocationNativeComponent'; +import RNMBXNativeUserLocation, { + type NativeProps, +} from '../specs/RNMBXNativeUserLocationNativeComponent'; +import type { Expression } from '../utils/MapboxStyles'; + +type Value = T | Expression; export type Props = { /** @@ -10,68 +15,82 @@ export type Props = { * - compass: triangle with heading * - gps: large arrow * + * @deprecated use `puckBearing` for source and `bearingImage` for image * @platform android */ androidRenderMode?: 'normal' | 'compass' | 'gps'; + /** + * The bearing of the puck. + * + * - heading: Orients the puck to match the direction in which the device is facing. + * - course: Orients the puck to match the direction in which the device is moving. + */ + puckBearing?: 'heading' | 'course'; + + /** + * Whether the puck rotates to track the bearing source. + */ + puckBearingEnabled?: boolean; + /** * iOS only. A Boolean value indicating whether the user location annotation may display a permanent heading indicator. * * @platform ios + * @deprecated use `puckBearingEnabled={true} puckBrearing="heading"` instead */ iosShowsUserHeadingIndicator?: boolean; /** - * The name of native image asset to use as the top layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android + * The name of image to use as the top layer for the location indicator. Images component should define this image. */ topImage?: string; /** - * The name of native image asset to use as the middle layer for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android + * The name of image to use as the middle layer for the location indicator. Images component should define this image. */ bearingImage?: string; /** - * The name of native image asset to use as the background0 for the location indicator. Native asset are under Image.xcassets on iOS and the drawables directory on android + * The name of image asset to use as the background0 for the location indicator. Images component should define this image. */ shadowImage?: string; /** - * The size of the images, as a scale factor applied to the size of the specified image. + * The size of the images, as a scale factor applied to the size of the specified image. Supports expressions based on zoom. + * + * @example + * ["interpolate",["linear"], ["zoom"], 10.0, 1.0, 20.0, 4.0]] + * @example + * 2.0 */ - scale?: number; + scale?: Value; /** - * Whether location icon is visible + * Whether location icon is visible, defaults to true */ visible?: boolean; }; -const NativeUserLocation = memo((props: Props) => { - const { - bearingImageAsset: bearingImage, - shadowImageAsset: shadowImage, - topImageAsset: topImage, - androidRenderMode, - iosShowsUserHeadingIndicator, - scale, - visible, - } = props; +const defaultProps = { + visible: true, +} as const; - if (visible === false) { - return null; +const NativeUserLocation = memo((props: Props) => { + const { iosShowsUserHeadingIndicator, ...rest } = props; + let baseProps: NativeProps = { ...defaultProps }; + if (iosShowsUserHeadingIndicator) { + console.warn( + 'NativeUserLocation: iosShowsUserHeadingIndicator is deprecated, use puckBearingEnabled={true} puckBrearing="heading" instead', + ); + baseProps = { + ...baseProps, + puckBearingEnabled: true, + puckBearing: 'heading', + }; } - - return ( - - ); + const actualProps = { ...baseProps, ...rest }; + return ; }); export default NativeUserLocation; diff --git a/src/specs/RNMBXNativeUserLocationNativeComponent.ts b/src/specs/RNMBXNativeUserLocationNativeComponent.ts index c2bd38ac8f..86d44757f9 100644 --- a/src/specs/RNMBXNativeUserLocationNativeComponent.ts +++ b/src/specs/RNMBXNativeUserLocationNativeComponent.ts @@ -1,15 +1,24 @@ import type { HostComponent, ViewProps } from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { Expression } from '../utils/MapboxStyles'; + import { UnsafeMixed } from './codegenUtils'; +type Value = T | Expression; + +// see https://github.com/rnmapbox/maps/wiki/FabricOptionalProp +type OptionalProp = UnsafeMixed; + export interface NativeProps extends ViewProps { - androidRenderMode?: UnsafeMixed; - iosShowsUserHeadingIndicator?: UnsafeMixed; - bearingImage?: UnsafeMixed; - shadowImage?: UnsafeMixed; - topImage?: UnsafeMixed; - scale?: UnsafeMixed; + androidRenderMode?: OptionalProp; + puckBearing?: OptionalProp<'heading' | 'course'>; + puckBearingEnabled?: OptionalProp; + bearingImage?: OptionalProp; + shadowImage?: OptionalProp; + topImage?: OptionalProp; + scale?: UnsafeMixed>; + visible?: boolean; } export default codegenNativeComponent(