From dba17075e2a44d5808e26ad269975bf7132c7425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sun, 26 Nov 2023 17:10:01 +0100 Subject: [PATCH] feat(nativeUserLocation): implement customizable pulsing (#3202) --- .../location/RNMBXNativeUserLocation.kt | 51 +++++++++++++++ .../RNMBXNativeUserLocationManager.kt | 8 +++ ...NMBXNativeUserLocationManagerDelegate.java | 3 + ...MBXNativeUserLocationManagerInterface.java | 1 + docs/NativeUserLocation.md | 27 ++++++++ docs/docs.json | 7 +++ .../UserLocation/CustomNativeUserLocation.tsx | 5 ++ ios/RNMBX/RNMBXFabricPropConvert.h | 16 +++-- ios/RNMBX/RNMBXFabricPropConvert.mm | 5 ++ ios/RNMBX/RNMBXNativeUserLocation.swift | 63 ++++++++++++++----- .../RNMBXNativeUserLocationComponentView.mm | 15 ++--- .../RNMBXNativeUserLocationViewManager.m | 1 + src/components/NativeUserLocation.tsx | 48 +++++++++++++- .../RNMBXNativeUserLocationNativeComponent.ts | 15 ++++- 14 files changed, 234 insertions(+), 31 deletions(-) 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 3e94dbeef..39153d60f 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 @@ -7,6 +7,9 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import com.facebook.react.bridge.ColorPropConverter +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.mapbox.android.core.permissions.PermissionsManager import com.mapbox.bindgen.Value import com.mapbox.maps.Image @@ -14,6 +17,7 @@ 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.LocationComponentConstants import com.mapbox.maps.plugin.locationcomponent.location import com.mapbox.maps.plugin.locationcomponent.R as LR import com.rnmapbox.rnmbx.R @@ -26,6 +30,8 @@ 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.utils.extensions.getAndLogIfNotBoolean +import com.rnmapbox.rnmbx.utils.extensions.getAndLogIfNotString import com.rnmapbox.rnmbx.v11compat.image.AppCompatResourcesV11 import com.rnmapbox.rnmbx.v11compat.image.ImageHolder import com.rnmapbox.rnmbx.v11compat.image.toDrawable @@ -85,6 +91,12 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O _apply() } + var pulsing: ReadableMap? = null + set(value) { + field = value + _apply() + } + private fun imageNameUpdated(image: PuckImagePart, name: String?) { if (name != null) { imageNames[image] = name @@ -153,6 +165,41 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O this.puckBearingEnabled?.let { location2.puckBearingEnabled = it } + + pulsing?.let { pulsing -> + pulsing.getAndLogIfNotString("kind")?.also { kind -> + if (kind == "default") { + location2.pulsingEnabled = true + } + } + if (pulsing.hasKey("color")) { + when (pulsing.getType("color")) { + ReadableType.Map -> + location2.pulsingColor = ColorPropConverter.getColor(pulsing.getMap("color"), mContext) + ReadableType.Number -> + location2.pulsingColor = pulsing.getInt("color") + else -> + Logger.e(LOG_TAG, "pusling.color should be either a map or a number, but was ${pulsing.getDynamic("color")}") + } + } + pulsing.getAndLogIfNotBoolean("isEnabled")?.let { enabled -> + location2.pulsingEnabled = enabled + } + if (pulsing.hasKey("radius")) { + when (pulsing.getType("radius")) { + ReadableType.Number -> + location2.pulsingMaxRadius = pulsing.getDouble("radius").toFloat() + ReadableType.String -> + if (pulsing.getString("radius") == "accuracy") { + location2.pulsingMaxRadius = LocationComponentConstants.PULSING_MAX_RADIUS_FOLLOW_ACCURACY + } else { + Logger.e(LOG_TAG, "Expected pulsing/radius to be a number or accuracy but was ${pulsing.getString("radius")}") + } + else -> + Logger.e(LOG_TAG, "Expected pulsing/radius to be a number or accuracy but was ${pulsing.getString("radius")}") + } + } + } } override fun addToMap(mapView: RNMBXMapView) { @@ -229,6 +276,10 @@ class RNMBXNativeUserLocation(context: Context) : AbstractMapFeature(context), O } } // endregion + + companion object { + const val LOG_TAG = "RNMBXNativeUserLocation" + } } fun makeDefaultLocationPuck2D(context: Context, renderMode: RenderMode): LocationPuck2D { 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 e609c58ad..26fba5c49 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,6 +1,7 @@ package com.rnmapbox.rnmbx.components.location import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager @@ -82,6 +83,13 @@ class RNMBXNativeUserLocationManager : ViewGroupManager view.visible = value } + @ReactProp(name = "pulsing") + override fun setPulsing(view: RNMBXNativeUserLocation, value: Dynamic) { + if (!value.isNull) { + view.pulsing = value.asMap() + } + } + @Nonnull override fun createViewInstance(@Nonnull reactContext: ThemedReactContext): RNMBXNativeUserLocation { return RNMBXNativeUserLocation(reactContext) 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 004c7a971..b0ef22ae6 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 @@ -46,6 +46,9 @@ public void setProperty(T view, String propName, @Nullable Object value) { case "visible": mViewManager.setVisible(view, value == null ? false : (boolean) value); break; + case "pulsing": + mViewManager.setPulsing(view, new DynamicFromObject(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 df0b5e5b5..6367f894a 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 @@ -21,4 +21,5 @@ public interface RNMBXNativeUserLocationManagerInterface { void setTopImage(T view, Dynamic value); void setScale(T view, Dynamic value); void setVisible(T view, boolean value); + void setPulsing(T view, Dynamic value); } diff --git a/docs/NativeUserLocation.md b/docs/NativeUserLocation.md index 32be74669..0a4e4cedd 100644 --- a/docs/NativeUserLocation.md +++ b/docs/NativeUserLocation.md @@ -103,6 +103,33 @@ The size of the images, as a scale factor applied to the size of the specified i [Custom Native UserLocation](../examples/UserLocation/CustomNativeUserLocation) +### pulsing + +```tsx +| { + /** + * Flag determining whether the pulsing circle animation. + */ + isEnabled?: boolean; + + /** + * The color of the pulsing circle. + */ + color?: number | ColorValue; + + /** + * Circle radius configuration for the pulsing circle animation. + * - accuracy: Pulsing circle animates with the `horizontalAccuracy` form the latest puck location. + * - number: Pulsing circle should animate with the constant radius. + */ + radius?: 'accuracy' | number; + } +| 'default' +``` +The configration parameters for sonar-like pulsing circle animation shown around the 2D puck. + + + ### visible ```tsx diff --git a/docs/docs.json b/docs/docs.json index df9774f55..f64d34f43 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -4204,6 +4204,13 @@ "default": "none", "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": "pulsing", + "required": false, + "type": "\\| {\n /**\n * Flag determining whether the pulsing circle animation.\n */\n isEnabled?: boolean;\n\n /**\n * The color of the pulsing circle.\n */\n color?: number \\| ColorValue;\n\n /**\n * Circle radius configuration for the pulsing circle animation.\n * - accuracy: Pulsing circle animates with the `horizontalAccuracy` form the latest puck location.\n * - number: Pulsing circle should animate with the constant radius.\n */\n radius?: 'accuracy' \\| number;\n }\n\\| 'default'", + "default": "none", + "description": "The configration parameters for sonar-like pulsing circle animation shown around the 2D puck." + }, { "name": "visible", "required": false, diff --git a/example/src/examples/UserLocation/CustomNativeUserLocation.tsx b/example/src/examples/UserLocation/CustomNativeUserLocation.tsx index 6dc5f3aa8..6dbdbb362 100644 --- a/example/src/examples/UserLocation/CustomNativeUserLocation.tsx +++ b/example/src/examples/UserLocation/CustomNativeUserLocation.tsx @@ -44,6 +44,11 @@ const UserLocationNativeAnimated = () => { topImage="topImage" visible={true} scale={['interpolate', ['linear'], ['zoom'], 10, 1.0, 20, 4.0]} + pulsing={{ + isEnabled: true, + color: 'teal', + radius: 50.0, + }} /> diff --git a/ios/RNMBX/RNMBXFabricPropConvert.h b/ios/RNMBX/RNMBXFabricPropConvert.h index 27b624533..fe266edeb 100644 --- a/ios/RNMBX/RNMBXFabricPropConvert.h +++ b/ios/RNMBX/RNMBXFabricPropConvert.h @@ -14,28 +14,34 @@ BOOL RNMBXPropConvert_Optional_BOOL(const folly::dynamic &dyn, NSString* propert 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); +NSDictionary* RNMBXPropConvert_Optional_NSDictionary(const folly::dynamic &dyn, NSString* propertyName); -#define RNMBX_OPTIONAL_RPOP_BOOL_NSNumber(name) \ +#define RNMBX_OPTIONAL_PROP_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) \ +#define RNMBX_OPTIONAL_PROP_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) \ +#define RNMBX_OPTIONAL_PROP_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) \ +#define RNMBX_OPTIONAL_PROP_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) \ +#define RNMBX_PROP_BOOL(name) \ if ((!oldProps.get() || oldViewProps.name != newViewProps.name)) { \ _view.name = RNMBXPropConvert_BOOL(newViewProps.name, @#name); \ } + +#define RNMBX_OPTIONAL_PROP_NSDictionary(name) \ + if ((!oldProps.get() || oldViewProps.name != newViewProps.name)) { \ + _view.name = RNMBXPropConvert_Optional_NSDictionary(newViewProps.name, @#name); \ + } diff --git a/ios/RNMBX/RNMBXFabricPropConvert.mm b/ios/RNMBX/RNMBXFabricPropConvert.mm index 722afa097..e5a67e99a 100644 --- a/ios/RNMBX/RNMBXFabricPropConvert.mm +++ b/ios/RNMBX/RNMBXFabricPropConvert.mm @@ -128,4 +128,9 @@ id RNMBXPropConvert_Optional_ExpressionDouble(const folly::dynamic &dyn, NSStrin } } +NSDictionary* RNMBXPropConvert_Optional_NSDictionary(const folly::dynamic &dyn, NSString* propertyName) +{ + return RNMBXPropConvert_ID(dyn); +} + #endif diff --git a/ios/RNMBX/RNMBXNativeUserLocation.swift b/ios/RNMBX/RNMBXNativeUserLocation.swift index 953ae1158..5dffb97bd 100644 --- a/ios/RNMBX/RNMBXNativeUserLocation.swift +++ b/ios/RNMBX/RNMBXNativeUserLocation.swift @@ -76,6 +76,9 @@ public class RNMBXNativeUserLocation: UIView, RNMBXMapComponent { @objc public var puckBearingEnabled: Bool = false + + @objc + public var pulsing: NSDictionary? = nil @objc override public func didSetProps(_ props: [String]) { @@ -151,20 +154,7 @@ public class RNMBXNativeUserLocation: UIView, RNMBXMapComponent { 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 { + if (!visible) { let emptyImage = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in } location.options.puckType = .puck2D( Puck2DConfiguration( @@ -174,9 +164,50 @@ public class RNMBXNativeUserLocation: UIView, RNMBXMapComponent { scale: Value.constant(1.0) ) ) + return + } else { + var configuration : Puck2DConfiguration = images.isEmpty ? + .makeDefault(showBearing: puckBearingEnabled) : Puck2DConfiguration( + topImage: self.images[.top], + bearingImage: self.images[.bearing], + shadowImage: self.images[.shadow]) + + if let scale = toDoubleValue(value: scale, name: "scale") { + configuration.scale = scale + } + + if let pulsing = pulsing { + if let kind = pulsing["kind"] as? String, kind == "default" { + configuration.pulsing = .default + } else { + var pulsingConfig = Puck2DConfiguration.Pulsing() + if let isEnabled = pulsing["isEnabled"] as? Bool { + pulsingConfig.isEnabled = isEnabled + } + + if let radius = pulsing["radius"] as? String { + if radius == "accuracy" { + pulsingConfig.radius = .accuracy + } else { + Logger.log(level: .error, message: "expected pulsing/radius to be either a number or accuracy but was \(radius)") + } + } else if let radius = pulsing["radius"] as? NSNumber { + pulsingConfig.radius = .constant(radius.doubleValue) + } + + if let color = pulsing["color"] as? Any { + if let uicolor = RCTConvert.uiColor(color) { + pulsingConfig.color = uicolor + } else { + Logger.log(level: .error, message: "expected color to be a color but was \(color)") + } + } + + configuration.pulsing = pulsingConfig + } + } + location.options.puckType = .puck2D(configuration) } - - location.options.puckBearingEnabled = puckBearingEnabled if let puckBearing = _puckBearing { location.options.puckBearing = puckBearing diff --git a/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm b/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm index edff9a0f9..35320efa0 100644 --- a/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm +++ b/ios/RNMBX/RNMBXNativeUserLocationComponentView.mm @@ -57,13 +57,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & 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) + RNMBX_OPTIONAL_PROP_NSString(puckBearing) + RNMBX_OPTIONAL_PROP_BOOL(puckBearingEnabled) + RNMBX_OPTIONAL_PROP_NSString(bearingImage) + RNMBX_OPTIONAL_PROP_NSString(shadowImage) + RNMBX_OPTIONAL_PROP_NSString(topImage) + RNMBX_OPTIONAL_PROP_ExpressionDouble(scale) + RNMBX_PROP_BOOL(visible) + RNMBX_OPTIONAL_PROP_NSDictionary(pulsing) [super updateProps:props oldProps:oldProps]; diff --git a/ios/RNMBX/RNMBXNativeUserLocationViewManager.m b/ios/RNMBX/RNMBXNativeUserLocationViewManager.m index 142a2add4..64526b37c 100644 --- a/ios/RNMBX/RNMBXNativeUserLocationViewManager.m +++ b/ios/RNMBX/RNMBXNativeUserLocationViewManager.m @@ -11,6 +11,7 @@ @interface RCT_EXTERN_REMAP_MODULE(RNMBXNativeUserLocation, RNMBXNativeUserLocat RCT_EXPORT_VIEW_PROPERTY(visible, BOOL); RCT_EXPORT_VIEW_PROPERTY(puckBearing, NSString); RCT_EXPORT_VIEW_PROPERTY(puckBearingEnabled, BOOL); +RCT_EXPORT_VIEW_PROPERTY(pulsing, NSDictionary); @end diff --git a/src/components/NativeUserLocation.tsx b/src/components/NativeUserLocation.tsx index bc4c50368..7f86ecfcd 100644 --- a/src/components/NativeUserLocation.tsx +++ b/src/components/NativeUserLocation.tsx @@ -1,4 +1,5 @@ import React, { memo } from 'react'; +import { processColor, type ColorValue } from 'react-native'; import RNMBXNativeUserLocation, { type NativeProps, @@ -66,6 +67,30 @@ export type Props = { */ scale?: Value; + /** + * The configration parameters for sonar-like pulsing circle animation shown around the 2D puck. + */ + pulsing?: + | { + /** + * Flag determining whether the pulsing circle animation. + */ + isEnabled?: boolean; + + /** + * The color of the pulsing circle. + */ + color?: number | ColorValue; + + /** + * Circle radius configuration for the pulsing circle animation. + * - accuracy: Pulsing circle animates with the `horizontalAccuracy` form the latest puck location. + * - number: Pulsing circle should animate with the constant radius. + */ + radius?: 'accuracy' | number; + } + | 'default'; + /** * Whether location icon is visible, defaults to true */ @@ -77,12 +102,14 @@ const defaultProps = { } as const; const NativeUserLocation = memo((props: Props) => { - const { iosShowsUserHeadingIndicator, ...rest } = props; - let baseProps: NativeProps = { ...defaultProps }; + const { iosShowsUserHeadingIndicator, pulsing, ...rest } = props; + const nativePulsing = pulsing ? _pulsingToNative(pulsing) : undefined; + let baseProps: NativeProps = { ...defaultProps, pulsing: nativePulsing }; if (iosShowsUserHeadingIndicator) { console.warn( 'NativeUserLocation: iosShowsUserHeadingIndicator is deprecated, use puckBearingEnabled={true} puckBearing="heading" instead', ); + baseProps = { ...baseProps, puckBearingEnabled: true, @@ -93,4 +120,21 @@ const NativeUserLocation = memo((props: Props) => { return ; }); +function _pulsingToNative( + pulsing: Props['pulsing'], +): NativeProps['pulsing'] | undefined { + if (pulsing === 'default') { + return { kind: 'default' }; + } + if (pulsing == null) { + return undefined; + } + const { color, isEnabled, radius } = pulsing; + return { + color: processColor(color), + isEnabled, + radius, + }; +} + export default NativeUserLocation; diff --git a/src/specs/RNMBXNativeUserLocationNativeComponent.ts b/src/specs/RNMBXNativeUserLocationNativeComponent.ts index 86d44757f..08fc5183e 100644 --- a/src/specs/RNMBXNativeUserLocationNativeComponent.ts +++ b/src/specs/RNMBXNativeUserLocationNativeComponent.ts @@ -1,4 +1,8 @@ -import type { HostComponent, ViewProps } from 'react-native'; +import type { + HostComponent, + ProcessedColorValue, + ViewProps, +} from 'react-native'; import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; import type { Expression } from '../utils/MapboxStyles'; @@ -10,6 +14,14 @@ type Value = T | Expression; // see https://github.com/rnmapbox/maps/wiki/FabricOptionalProp type OptionalProp = UnsafeMixed; +type Pulsing = + | { + isEnabled?: boolean; + radius?: 'accuracy' | number; + color?: ProcessedColorValue | null | undefined; + } + | { kind: 'default' }; + export interface NativeProps extends ViewProps { androidRenderMode?: OptionalProp; puckBearing?: OptionalProp<'heading' | 'course'>; @@ -19,6 +31,7 @@ export interface NativeProps extends ViewProps { topImage?: OptionalProp; scale?: UnsafeMixed>; visible?: boolean; + pulsing?: UnsafeMixed; } export default codegenNativeComponent(