diff --git a/android/src/main/java/com/google/android/react/navsdk/MapViewController.java b/android/src/main/java/com/google/android/react/navsdk/MapViewController.java index 882b3b1..9ce0358 100644 --- a/android/src/main/java/com/google/android/react/navsdk/MapViewController.java +++ b/android/src/main/java/com/google/android/react/navsdk/MapViewController.java @@ -15,7 +15,12 @@ import android.annotation.SuppressLint; import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; import androidx.core.util.Supplier; import com.facebook.react.bridge.UiThreadUtil; import com.google.android.gms.maps.CameraUpdateFactory; @@ -42,9 +47,12 @@ import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; +import android.animation.ValueAnimator; +import android.view.animation.AccelerateDecelerateInterpolator; public class MapViewController { private GoogleMap mGoogleMap; @@ -304,6 +312,62 @@ public GroundOverlay addGroundOverlay(Map map) { return groundOverlay; } + /** + * Animates a marker to a new position with smooth movement. + * + * @param markerId The ID of the marker to move + * @param newPosition The new position to move the marker to + * @param duration The duration of the animation in milliseconds + */ + public void moveMarker(String markerId, Map newPosition, int duration) { + if (mGoogleMap == null) { + return; + } + + UiThreadUtil.runOnUiThread(() -> { + // Find the marker + Marker markerToMove = null; + for (Marker marker : markerList) { + if (marker.getId().equals(markerId)) { + markerToMove = marker; + break; + } + } + + if (markerToMove == null) { + return; // Marker not found + } + + final Marker finalMarker = markerToMove; + final LatLng startPosition = finalMarker.getPosition(); + final LatLng endPosition = ObjectTranslationUtil.getLatLngFromMap(newPosition); + + + // Create smooth animation + ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); + animator.setDuration(duration > 0 ? duration : 1000); // Default 1 second + animator.setInterpolator(new AccelerateDecelerateInterpolator()); + + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float progress = (float) animation.getAnimatedValue(); + + // Calculate intermediate position using linear interpolation + double lat = startPosition.latitude + (endPosition.latitude - startPosition.latitude) * progress; + double lng = startPosition.longitude + (endPosition.longitude - startPosition.longitude) * progress; + LatLng intermediatePosition = new LatLng(lat, lng); + + // Update marker position + finalMarker.setPosition(intermediatePosition); + } + }); + + animator.start(); + }); + } + + public void removeMarker(String id) { UiThreadUtil.runOnUiThread( () -> { @@ -357,6 +421,97 @@ public void removeGroundOverlay(String id) { } } + /** + * Adds a text marker on the map using a custom bitmap with text and background. + * + * @param optionsMap Configuration map containing text, position, styling options + * @return The created Marker containing the text with background + */ + public Marker addTextMarker(Map optionsMap) { + if (mGoogleMap == null) { + return null; + } + + // Extract text marker parameters + String text = CollectionUtil.getString("text", optionsMap); + if (text == null || text.isEmpty()) { + return null; // Text is required + } + + // Position parameters + LatLng position = ObjectTranslationUtil.getLatLngFromMap((Map) optionsMap.get("position")); + if (position == null) { + return null; // Position is required + } + + // Styling parameters with defaults + float fontSizeDp = (float) CollectionUtil.getDouble("fontSize", optionsMap, 14.0); + float fontSize = dpToPx(fontSizeDp); + String textColor = CollectionUtil.getString("textColor", optionsMap); + int textColorInt = Color.BLACK; + try { + if (textColor != null) textColorInt = Color.parseColor(textColor); + } catch (IllegalArgumentException ignored) {} + + String backgroundColor = CollectionUtil.getString("backgroundColor", optionsMap); + int bgColor = Color.WHITE; + try { + if (backgroundColor != null) bgColor = Color.parseColor(backgroundColor); + } catch (IllegalArgumentException ignored) {} + + float paddingDp = (float) CollectionUtil.getDouble("padding", optionsMap, 8.0); + float padding = dpToPx(paddingDp); // Convert dp to pixels for density-aware sizing + + // Border styling parameters + String borderColorStr = CollectionUtil.getString("borderColor", optionsMap); + int borderColor = Color.BLACK; + try { + if (borderColorStr != null) borderColor = Color.parseColor(borderColorStr); + } catch (IllegalArgumentException ignored) {} + + // Label text (optional) + String label = CollectionUtil.getString("label", optionsMap); + + // Label styling parameters (defaults to main text/background colors if not provided) + String labelTextColorStr = CollectionUtil.getString("labelTextColor", optionsMap); + int labelTextColor = textColorInt; // Default to main text color + try { + if (labelTextColorStr != null) labelTextColor = Color.parseColor(labelTextColorStr); + } catch (IllegalArgumentException ignored) {} + + String labelBackgroundColorStr = CollectionUtil.getString("labelBackgroundColor", optionsMap); + int labelBackgroundColor = bgColor; // Default to main background color + try { + if (labelBackgroundColorStr != null) labelBackgroundColor = Color.parseColor(labelBackgroundColorStr); + } catch (IllegalArgumentException ignored) {} + + // Generate bitmap with text and circle background + BitmapDescriptor bitmapDescriptor = createTextBitmap(text, textColorInt, bgColor, fontSize, padding, borderColor, label, labelTextColor, labelBackgroundColor); + if (bitmapDescriptor == null) { + return null; + } + + // Calculate anchor point to position circle center at the specified coordinates + float anchorU = 0.5f; // Center horizontally + float anchorV = calculateAnchorV(text, fontSize, padding, label); + + // Create marker options with custom text bitmap + MarkerOptions options = new MarkerOptions(); + options.icon(bitmapDescriptor); + options.position(position); + options.anchor(anchorU, anchorV); + + // Add optional title if provided + String title = CollectionUtil.getString("title", optionsMap); + if (title != null) { + options.title(title); + } + + Marker textMarker = mGoogleMap.addMarker(options); + markerList.add(textMarker); + return textMarker; + } + public void setMapStyle(String url) { Executors.newSingleThreadExecutor() .execute( @@ -518,6 +673,7 @@ public void clearMapView() { return; } + mGoogleMap.clear(); } @@ -544,6 +700,16 @@ public void setPadding(int top, int left, int bottom, int right) { } } + private float dpToPx(float dp) { + Activity activity = activitySupplier.get(); + if (activity != null) { + float density = activity.getResources().getDisplayMetrics().density; + return dp * density; + } + // Fallback to assuming mdpi (density = 1.0) if activity is not available + return dp; + } + private String fetchJsonFromUrl(String urlString) throws IOException { URL url = new URL(urlString); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); @@ -579,4 +745,203 @@ private LatLng createLatLng(Map map) { return new LatLng(lat, lng); } + + + + + + /** + * Calculates the vertical anchor point to position the circle center at the marker position. + * + * @param text The text to render in the circle + * @param fontSize The font size in pixels + * @param padding The padding around the text + * @param label Optional label text (can be null) + * @return The vertical anchor value (0-1) where 0 is top and 1 is bottom of the bitmap + */ + private float calculateAnchorV(String text, float fontSize, float padding, String label) { + try { + // Create paint to measure text + Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textPaint.setTextSize(fontSize); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + + // Measure text dimensions + Rect textBounds = new Rect(); + textPaint.getTextBounds(text, 0, text.length(), textBounds); + int textWidth = textBounds.width(); + int textHeight = textBounds.height(); + + // Calculate circle dimensions + int circleDiameter = (int) ((textWidth > textHeight ? textWidth : textHeight) + (padding * 2)); + float borderWidth = circleDiameter * 0.08f; + + // Calculate label height if label exists + int labelRectHeight = 0; + if (label != null && !label.isEmpty()) { + Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + labelPaint.setTextSize(fontSize * 0.6f); + Rect labelBounds = new Rect(); + labelPaint.getTextBounds(label, 0, label.length(), labelBounds); + labelRectHeight = (int) (labelBounds.height() + (padding * 0.8f)); + } + + // Calculate shadow offset and blur + float shadowOffset = 8f; + float shadowBlur = 32f; + + // Calculate total bitmap height and circle center position + float bitmapHeight = circleDiameter + (borderWidth * 2) + labelRectHeight; + bitmapHeight += shadowOffset + shadowBlur * 2; + float circleCenterY = (circleDiameter + (borderWidth * 2)) / 2f + shadowBlur; + + // Return anchor V (fraction of bitmap height where circle center is) + return circleCenterY / bitmapHeight; + } catch (Exception e) { + // Default to center if calculation fails + return 0.5f; + } + } + + /** + * Creates a bitmap with text on a background circle with border. + * Optionally draws a label text on a rectangle below the circle. + * The border width is automatically calculated as 8% of the circle diameter. + * + * @param text The text to render in the circle + * @param textColor The color of the text + * @param bgColor The background circle color + * @param fontSize The font size in pixels + * @param padding The padding around the text + * @param borderColor The border circle color + * @param label Optional label text to display below the circle (can be null) + * @return BitmapDescriptor containing the rendered text with circle background + */ + private BitmapDescriptor createTextBitmap(String text, int textColor, int bgColor, + float fontSize, float padding, int borderColor, String label, + int labelTextColor, int labelBackgroundColor) { + try { + // Create paint for text + Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(textColor); + textPaint.setTextSize(fontSize); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + + // Create paint for background + Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + bgPaint.setColor(bgColor); + + // Measure main text dimensions + Rect textBounds = new Rect(); + textPaint.getTextBounds(text, 0, text.length(), textBounds); + + int textWidth = textBounds.width(); + int textHeight = textBounds.height(); + + // Calculate circle dimensions + int circleDiameter = (int) ((textWidth > textHeight ? textWidth : textHeight) + (padding * 2)); + float borderWidth = circleDiameter * 0.08f; + + // Calculate label dimensions if label exists + boolean hasLabel = label != null && !label.isEmpty(); + int labelRectHeight = 0; + int labelRectWidth = 0; + Rect labelBounds = new Rect(); + Paint labelPaint = null; + + if (hasLabel) { + // Create paint for label text (smaller font size) + labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + labelPaint.setColor(labelTextColor); + labelPaint.setTextSize(fontSize * 0.6f); // 60% of main text size + labelPaint.setTypeface(Typeface.DEFAULT); + labelPaint.getTextBounds(label, 0, label.length(), labelBounds); + + labelRectHeight = (int) (labelBounds.height() + (padding * 0.8f)); // Smaller padding for label + labelRectWidth = (int) Math.max(circleDiameter + (borderWidth * 2), labelBounds.width() + padding); + } + + // Calculate shadow offset and blur (always enabled) + float shadowOffset = 8f; // 8dp offset for shadow + float shadowBlur = 32f; // Large blur radius for soft shadow + + // Calculate total bitmap dimensions (add space for shadow) + int bitmapWidth = hasLabel ? labelRectWidth : (int) (circleDiameter + (borderWidth * 2)); + bitmapWidth += (int) (shadowBlur * 2); // Add space for shadow blur + int bitmapHeight = (int) (circleDiameter + (borderWidth * 2) + labelRectHeight); + bitmapHeight += (int) (shadowOffset + shadowBlur * 2); // Add space for shadow + + // Create bitmap and canvas + Bitmap bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + // Calculate circle center (offset for shadow space) + float centerX = bitmapWidth / 2f; + float centerY = (circleDiameter + (borderWidth * 2)) / 2f + shadowBlur; + float radius = (circleDiameter - borderWidth) / 2f; + + // Add multi-layered shadow effect - draw multiple times with different blur radii + // This approximates the CSS multi-shadow effect + int shadowColor = Color.argb(20, 27, 0, 82); // rgba(27, 0, 82, 0.08) + + Paint shadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + shadowPaint.setColor(bgColor); + + // Layer shadows from largest to smallest + shadowPaint.setShadowLayer(32f, 0, 8f, shadowColor); + canvas.drawCircle(centerX, centerY, radius, shadowPaint); + shadowPaint.setShadowLayer(24f, 0, 4f, shadowColor); + canvas.drawCircle(centerX, centerY, radius, shadowPaint); + shadowPaint.setShadowLayer(16f, 0, 4f, shadowColor); + canvas.drawCircle(centerX, centerY, radius, shadowPaint); + shadowPaint.setShadowLayer(8f, 0, 2f, shadowColor); + canvas.drawCircle(centerX, centerY, radius, shadowPaint); + + // Draw background circle + canvas.drawCircle(centerX, centerY, radius, bgPaint); + + // Draw border circle + if (borderWidth > 0) { + Paint borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + borderPaint.setColor(borderColor); + borderPaint.setStyle(Paint.Style.STROKE); + borderPaint.setStrokeWidth(borderWidth); + canvas.drawCircle(centerX, centerY, radius, borderPaint); + } + + // Draw text centered on the circle + float textX = centerX - (textBounds.width() / 2f); + float textY = centerY + (textBounds.height() / 2f); + canvas.drawText(text, textX, textY, textPaint); + + // Draw label rectangle and text if label exists + if (hasLabel) { + float rectTop = circleDiameter + (borderWidth * 2) + shadowBlur; + float rectLeft = (bitmapWidth - labelRectWidth) / 2f; + float rectRight = rectLeft + labelRectWidth; + float rectBottom = rectTop + labelRectHeight; + float cornerRadius = padding * 0.5f; + + // Add shadow to label rectangle + Paint rectPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + rectPaint.setColor(labelBackgroundColor); + rectPaint.setShadowLayer(16f, 0, 4f, shadowColor); + + // Draw rounded rectangle for label + canvas.drawRoundRect(rectLeft, rectTop, rectRight, rectBottom, cornerRadius, cornerRadius, rectPaint); + + // Draw label text centered on rectangle + float labelX = centerX - (labelBounds.width() / 2f); + float labelY = rectTop + (labelRectHeight / 2f) - labelBounds.exactCenterY(); + canvas.drawText(label, labelX, labelY, labelPaint); + } + + + return BitmapDescriptorFactory.fromBitmap(bitmap); + } catch (Exception e) { + // Return null if bitmap creation fails + return null; + } + } + } diff --git a/android/src/main/java/com/google/android/react/navsdk/NavViewModule.java b/android/src/main/java/com/google/android/react/navsdk/NavViewModule.java index 1fe2e2c..265e5ae 100644 --- a/android/src/main/java/com/google/android/react/navsdk/NavViewModule.java +++ b/android/src/main/java/com/google/android/react/navsdk/NavViewModule.java @@ -171,6 +171,25 @@ public void addMarker(int viewId, ReadableMap markerOptionsMap, final Promise pr }); } + + + @ReactMethod + public void moveMarker(int viewId, String markerId, ReadableMap newPosition, int duration, + final Promise promise) { + UiThreadUtil.runOnUiThread( + () -> { + if (mNavViewManager.getGoogleMap(viewId) != null) { + mNavViewManager + .getFragmentForViewId(viewId) + .getMapController() + .moveMarker(markerId, newPosition.toHashMap(), duration); + promise.resolve(true); + } else { + promise.reject("NO_MAP", "Map not found for viewId: " + viewId); + } + }); + } + @ReactMethod public void addPolyline(int viewId, ReadableMap polylineOptionsMap, final Promise promise) { UiThreadUtil.runOnUiThread( @@ -233,6 +252,28 @@ public void addGroundOverlay(int viewId, ReadableMap overlayOptionsMap, final Pr }); } + @ReactMethod + public void addTextMarker(int viewId, ReadableMap textMarkerOptionsMap, final Promise promise) { + UiThreadUtil.runOnUiThread( + () -> { + if (mNavViewManager.getGoogleMap(viewId) == null) { + promise.reject(JsErrors.NO_MAP_ERROR_CODE, JsErrors.NO_MAP_ERROR_MESSAGE); + return; + } + Marker textMarker = + mNavViewManager + .getFragmentForViewId(viewId) + .getMapController() + .addTextMarker(textMarkerOptionsMap.toHashMap()); + + if (textMarker != null) { + promise.resolve(ObjectTranslationUtil.getMapFromMarker(textMarker)); + } else { + promise.reject("TEXT_MARKER_ERROR", "Failed to create text marker"); + } + }); + } + @Override public boolean canOverrideExistingModule() { return true; diff --git a/ios/react-native-navigation-sdk/NavViewController.h b/ios/react-native-navigation-sdk/NavViewController.h index 17c64cb..10c7061 100644 --- a/ios/react-native-navigation-sdk/NavViewController.h +++ b/ios/react-native-navigation-sdk/NavViewController.h @@ -71,6 +71,8 @@ typedef void (^OnArrayResult)(NSArray *_Nullable result); - (void)addGroundOverlay:(NSDictionary *)overlayOptions result:(OnDictionaryResult)completionBlock; - (void)addCircle:(NSDictionary *)circleOptions result:(OnDictionaryResult)completionBlock; - (void)addMarker:(NSDictionary *)markerOptions result:(OnDictionaryResult)completionBlock; +- (void)addTextMarker:(NSDictionary *)textMarkerOptions result:(OnDictionaryResult)completionBlock; +- (void)moveMarker:(NSString *)markerId newPosition:(NSDictionary *)newPosition duration:(NSInteger)duration; - (void)addPolygon:(NSDictionary *)polygonOptions result:(OnDictionaryResult)completionBlock; - (void)addPolyline:(NSDictionary *)options result:(OnDictionaryResult)completionBlock; - (GMSMapView *)mapView; diff --git a/ios/react-native-navigation-sdk/NavViewController.m b/ios/react-native-navigation-sdk/NavViewController.m index a24870a..762aa4a 100644 --- a/ios/react-native-navigation-sdk/NavViewController.m +++ b/ios/react-native-navigation-sdk/NavViewController.m @@ -524,6 +524,388 @@ - (void)addMarker:(NSDictionary *)markerOptions result:(OnDictionaryResult)compl completionBlock([ObjectTranslationUtil transformMarkerToDictionary:marker]); } +/** + * Helper method to create a UIImage with text on a background circle with border. + * @param text The text to render in the circle + * @param textColor The color of the text + * @param bgColor The background circle color + * @param fontSize The font size in points + * @param padding The padding around the text + * @param borderColor The border circle color + * @param label Optional label text to display below the circle (can be nil) + * @return UIImage containing the rendered text with circle background + */ +- (UIImage *)createTextBitmapWithText:(NSString *)text + textColor:(UIColor *)textColor + bgColor:(UIColor *)bgColor + fontSize:(CGFloat)fontSize + padding:(CGFloat)padding + borderColor:(UIColor *)borderColor + label:(NSString *)label + labelTextColor:(UIColor *)labelTextColor + labelBackgroundColor:(UIColor *)labelBackgroundColor { + // Set bold font + UIFont *font = [UIFont boldSystemFontOfSize:fontSize]; + NSDictionary *textAttributes = @{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: textColor + }; + + // Measure text dimensions + CGSize textSize = [text sizeWithAttributes:textAttributes]; + + // Calculate circle dimensions + CGFloat maxDimension = MAX(textSize.width, textSize.height); + CGFloat circleDiameter = maxDimension + (padding * 2); + CGFloat borderWidth = circleDiameter * 0.08; + + // Calculate label dimensions if label exists + CGFloat labelRectHeight = 0; + CGFloat labelRectWidth = 0; + CGSize labelSize = CGSizeZero; + UIFont *labelFont = nil; + + if (label && label.length > 0) { + labelFont = [UIFont systemFontOfSize:fontSize * 0.6]; + NSDictionary *labelAttributes = @{ + NSFontAttributeName: labelFont, + NSForegroundColorAttributeName: textColor + }; + labelSize = [label sizeWithAttributes:labelAttributes]; + labelRectHeight = labelSize.height + (padding * 0.8); + labelRectWidth = MAX(circleDiameter + (borderWidth * 2), labelSize.width + padding); + } + + // Calculate shadow offset and blur (always enabled) + CGFloat shadowOffset = 8.0; // 8pt offset for shadow + CGFloat shadowBlur = 32.0; // Large blur radius for soft shadow + + // Calculate total bitmap dimensions (add space for shadow) + CGFloat bitmapWidth = (label && label.length > 0) ? labelRectWidth : (circleDiameter + (borderWidth * 2)); + bitmapWidth += shadowBlur * 2; // Add space for shadow blur + CGFloat bitmapHeight = circleDiameter + (borderWidth * 2) + labelRectHeight; + bitmapHeight += shadowOffset + shadowBlur * 2; // Add space for shadow + + // Create bitmap context + UIGraphicsBeginImageContextWithOptions(CGSizeMake(bitmapWidth, bitmapHeight), NO, 0.0); + CGContextRef context = UIGraphicsGetCurrentContext(); + + // Calculate circle center + CGFloat centerX = bitmapWidth / 2.0; + CGFloat centerY = (circleDiameter + (borderWidth * 2)) / 2.0 + shadowBlur; + CGFloat radius = (circleDiameter - borderWidth) / 2.0; + + // Add multi-layered shadow effect - draw multiple times with different blur radii + // This approximates the CSS multi-shadow effect + UIColor *shadowColor = [UIColor colorWithRed:27.0/255.0 green:0 blue:82.0/255.0 alpha:0.08]; + + // Layer shadows from largest to smallest + CGContextSaveGState(context); + CGContextSetShadowWithColor(context, CGSizeMake(0, 8), 32, shadowColor.CGColor); + CGContextSetFillColorWithColor(context, bgColor.CGColor); + CGContextFillEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + CGContextRestoreGState(context); + + CGContextSaveGState(context); + CGContextSetShadowWithColor(context, CGSizeMake(0, 4), 24, shadowColor.CGColor); + CGContextSetFillColorWithColor(context, bgColor.CGColor); + CGContextFillEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + CGContextRestoreGState(context); + + CGContextSaveGState(context); + CGContextSetShadowWithColor(context, CGSizeMake(0, 4), 16, shadowColor.CGColor); + CGContextSetFillColorWithColor(context, bgColor.CGColor); + CGContextFillEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + CGContextRestoreGState(context); + + CGContextSaveGState(context); + CGContextSetShadowWithColor(context, CGSizeMake(0, 2), 8, shadowColor.CGColor); + CGContextSetFillColorWithColor(context, bgColor.CGColor); + CGContextFillEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + CGContextRestoreGState(context); + + // Draw background circle + CGContextSetFillColorWithColor(context, bgColor.CGColor); + CGContextFillEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + + // Draw border circle + if (borderWidth > 0) { + CGContextSetStrokeColorWithColor(context, borderColor.CGColor); + CGContextSetLineWidth(context, borderWidth); + CGContextStrokeEllipseInRect(context, CGRectMake(centerX - radius, centerY - radius, radius * 2, radius * 2)); + } + + // Draw text centered on the circle + CGFloat textX = centerX - (textSize.width / 2.0); + CGFloat textY = centerY - (textSize.height / 2.0); + NSAttributedString *attributedText = [[NSAttributedString alloc] initWithString:text attributes:textAttributes]; + [attributedText drawAtPoint:CGPointMake(textX, textY)]; + + // Draw label rectangle and text if label exists + if (label && label.length > 0) { + CGFloat rectTop = circleDiameter + (borderWidth * 2) + shadowBlur; + CGFloat rectLeft = (bitmapWidth - labelRectWidth) / 2.0; + CGFloat cornerRadius = padding * 0.5; + + // Add shadow to label rectangle + UIColor *shadowColor = [UIColor colorWithRed:27.0/255.0 green:0 blue:82.0/255.0 alpha:0.08]; + CGContextSaveGState(context); + CGContextSetShadowWithColor(context, CGSizeMake(0, 4), 16, shadowColor.CGColor); + + // Draw rounded rectangle for label + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(rectLeft, rectTop, labelRectWidth, labelRectHeight) + cornerRadius:cornerRadius]; + CGContextSetFillColorWithColor(context, labelBackgroundColor.CGColor); + [path fill]; + + CGContextRestoreGState(context); + + // Draw label text centered on rectangle + NSDictionary *labelAttributes = @{ + NSFontAttributeName: labelFont, + NSForegroundColorAttributeName: labelTextColor + }; + CGFloat labelX = centerX - (labelSize.width / 2.0); + CGFloat labelY = rectTop + (labelRectHeight - labelSize.height) / 2.0; + NSAttributedString *attributedLabel = [[NSAttributedString alloc] initWithString:label attributes:labelAttributes]; + [attributedLabel drawAtPoint:CGPointMake(labelX, labelY)]; + } + + // Get the image from the context + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return image; +} + +/** + * Helper method to calculate the vertical anchor point to position the circle center at the marker position. + * @param text The text to render in the circle + * @param fontSize The font size in points + * @param padding The padding around the text + * @param label Optional label text (can be nil) + * @return The vertical anchor value (0-1) where 0 is top and 1 is bottom of the bitmap + */ +- (CGFloat)calculateAnchorVWithText:(NSString *)text + fontSize:(CGFloat)fontSize + padding:(CGFloat)padding + label:(NSString *)label { + // Set bold font + UIFont *font = [UIFont boldSystemFontOfSize:fontSize]; + NSDictionary *textAttributes = @{NSFontAttributeName: font}; + + // Measure text dimensions + CGSize textSize = [text sizeWithAttributes:textAttributes]; + + // Calculate circle dimensions + CGFloat maxDimension = MAX(textSize.width, textSize.height); + CGFloat circleDiameter = maxDimension + (padding * 2); + CGFloat borderWidth = circleDiameter * 0.08; + + // Calculate label height if label exists + CGFloat labelRectHeight = 0; + if (label && label.length > 0) { + UIFont *labelFont = [UIFont systemFontOfSize:fontSize * 0.6]; + NSDictionary *labelAttributes = @{NSFontAttributeName: labelFont}; + CGSize labelSize = [label sizeWithAttributes:labelAttributes]; + labelRectHeight = labelSize.height + (padding * 0.8); + } + + // Calculate shadow offset and blur (always enabled) + CGFloat shadowOffset = 8.0; + CGFloat shadowBlur = 32.0; + + // Calculate total bitmap height and circle center position (accounting for shadow) + CGFloat bitmapHeight = circleDiameter + (borderWidth * 2) + labelRectHeight; + bitmapHeight += shadowOffset + shadowBlur * 2; + CGFloat circleCenterY = (circleDiameter + (borderWidth * 2)) / 2.0 + shadowBlur; + + // Return anchor V (fraction of bitmap height where circle center is) + return circleCenterY / bitmapHeight; +} + +- (void)addTextMarker:(NSDictionary *)textMarkerOptions result:(OnDictionaryResult)completionBlock { + // Extract required parameters + NSString *text = [textMarkerOptions objectForKey:@"text"]; + if (!text || text.length == 0) { + completionBlock(nil); + return; + } + + NSDictionary *position = [textMarkerOptions objectForKey:@"position"]; + if (!position) { + completionBlock(nil); + return; + } + + CLLocationCoordinate2D coordinatePosition = [ObjectTranslationUtil getLocationCoordinateFrom:position]; + + // Extract styling parameters with defaults + CGFloat fontSize = [[textMarkerOptions objectForKey:@"fontSize"] doubleValue]; + if (fontSize == 0) fontSize = 14.0; + + NSString *textColorStr = [textMarkerOptions objectForKey:@"textColor"]; + UIColor *textColor = textColorStr ? [UIColor colorWithHexString:textColorStr] : [UIColor blackColor]; + + NSString *backgroundColorStr = [textMarkerOptions objectForKey:@"backgroundColor"]; + UIColor *bgColor = backgroundColorStr ? [UIColor colorWithHexString:backgroundColorStr] : [UIColor whiteColor]; + + CGFloat padding = [[textMarkerOptions objectForKey:@"padding"] doubleValue]; + if (padding == 0) padding = 8.0; + + NSString *borderColorStr = [textMarkerOptions objectForKey:@"borderColor"]; + UIColor *borderColor = borderColorStr ? [UIColor colorWithHexString:borderColorStr] : [UIColor blackColor]; + + NSString *label = [textMarkerOptions objectForKey:@"label"]; + + // Label styling parameters (defaults to main text/background colors if not provided) + NSString *labelTextColorStr = [textMarkerOptions objectForKey:@"labelTextColor"]; + UIColor *labelTextColor = labelTextColorStr ? [UIColor colorWithHexString:labelTextColorStr] : textColor; + + NSString *labelBackgroundColorStr = [textMarkerOptions objectForKey:@"labelBackgroundColor"]; + UIColor *labelBackgroundColor = labelBackgroundColorStr ? [UIColor colorWithHexString:labelBackgroundColorStr] : bgColor; + + // Generate bitmap with text and circle background (shadow always enabled) + UIImage *icon = [self createTextBitmapWithText:text + textColor:textColor + bgColor:bgColor + fontSize:fontSize + padding:padding + borderColor:borderColor + label:label + labelTextColor:labelTextColor + labelBackgroundColor:labelBackgroundColor]; + + if (!icon) { + completionBlock(nil); + return; + } + + // Calculate anchor point to position circle center at the specified coordinates + CGFloat anchorU = 0.5; + CGFloat anchorV = [self calculateAnchorVWithText:text fontSize:fontSize padding:padding label:label]; + + // Create marker with custom icon + GMSMarker *marker = [GMSMarker markerWithPosition:coordinatePosition]; + marker.icon = icon; + marker.groundAnchor = CGPointMake(anchorU, anchorV); + + // Add optional title if provided + NSString *title = [textMarkerOptions objectForKey:@"title"]; + if (title) { + marker.title = title; + } + + marker.tappable = YES; + marker.userData = @[ [[NSUUID UUID] UUIDString] ]; + marker.map = _mapView; + + [_markerList addObject:marker]; + + completionBlock([ObjectTranslationUtil transformMarkerToDictionary:marker]); +} + +- (void)moveMarker:(NSString *)markerId + newPosition:(NSDictionary *)newPosition + duration:(NSInteger)duration { + if (!_mapView) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + // Find the marker + GMSMarker *markerToMove = nil; + for (GMSMarker *marker in self->_markerList) { + NSArray *userData = marker.userData; + if (userData && userData.count > 0) { + NSString *markerIdFromData = userData[0]; + if ([markerIdFromData isEqualToString:markerId]) { + markerToMove = marker; + break; + } + } + } + + if (!markerToMove) { + return; // Marker not found + } + + CLLocationCoordinate2D startPosition = markerToMove.position; + CLLocationCoordinate2D endPosition = [ObjectTranslationUtil getLocationCoordinateFrom:newPosition]; + + // Use CADisplayLink for smooth animation + NSTimeInterval animationDuration = duration > 0 ? (duration / 1000.0) : 1.0; + NSDate *startTime = [NSDate date]; + + // Create a display link for smooth animation + CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self + selector:@selector(updateMarkerPosition:)]; + + // Store animation data in a dictionary + NSMutableDictionary *animationData = [NSMutableDictionary dictionary]; + animationData[@"marker"] = markerToMove; + animationData[@"startLat"] = @(startPosition.latitude); + animationData[@"startLng"] = @(startPosition.longitude); + animationData[@"endLat"] = @(endPosition.latitude); + animationData[@"endLng"] = @(endPosition.longitude); + animationData[@"startTime"] = startTime; + animationData[@"duration"] = @(animationDuration); + animationData[@"displayLink"] = displayLink; + + // Store in marker's userData to access in update method + markerToMove.userData = @[markerId, animationData]; + + [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + }); +} + +- (void)updateMarkerPosition:(CADisplayLink *)displayLink { + // Find markers that are being animated + for (GMSMarker *marker in _markerList) { + NSArray *userData = marker.userData; + if (userData && userData.count > 1 && [userData[1] isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *animationData = userData[1]; + CADisplayLink *storedLink = animationData[@"displayLink"]; + + if (storedLink == displayLink) { + NSDate *startTime = animationData[@"startTime"]; + NSTimeInterval duration = [animationData[@"duration"] doubleValue]; + NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; + + if (elapsed >= duration) { + // Animation complete + double endLat = [animationData[@"endLat"] doubleValue]; + double endLng = [animationData[@"endLng"] doubleValue]; + marker.position = CLLocationCoordinate2DMake(endLat, endLng); + + // Restore original userData + marker.userData = @[userData[0]]; + + [displayLink invalidate]; + return; + } + + // Calculate progress with easing + CGFloat progress = elapsed / duration; + // Apply ease-in-out interpolation + progress = progress < 0.5 ? 2 * progress * progress : 1 - pow(-2 * progress + 2, 2) / 2; + + // Calculate intermediate position + double startLat = [animationData[@"startLat"] doubleValue]; + double startLng = [animationData[@"startLng"] doubleValue]; + double endLat = [animationData[@"endLat"] doubleValue]; + double endLng = [animationData[@"endLng"] doubleValue]; + + double lat = startLat + (endLat - startLat) * progress; + double lng = startLng + (endLng - startLng) * progress; + + marker.position = CLLocationCoordinate2DMake(lat, lng); + return; + } + } + } +} + - (void)addPolygon:(NSDictionary *)polygonOptions result:(OnDictionaryResult)completionBlock { GMSPath *path = [ObjectTranslationUtil transformToPath:polygonOptions[@"points"]]; diff --git a/ios/react-native-navigation-sdk/NavViewModule.m b/ios/react-native-navigation-sdk/NavViewModule.m index 9b05e2d..bb95f26 100644 --- a/ios/react-native-navigation-sdk/NavViewModule.m +++ b/ios/react-native-navigation-sdk/NavViewModule.m @@ -142,6 +142,48 @@ - (NavViewController *)getViewControllerForTag:(NSNumber *)reactTag { }); } +RCT_EXPORT_METHOD(addTextMarker + : (nonnull NSNumber *)reactTag textMarkerOptions + : (NSDictionary *)textMarkerOptions resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_main_queue(), ^{ + NavViewController *viewController = [self getViewControllerForTag:reactTag]; + if (viewController) { + [viewController addTextMarker:textMarkerOptions + result:^(NSDictionary *result) { + if (result) { + resolve(result); + } else { + reject(@"text_marker_error", @"Failed to create text marker", nil); + } + }]; + } else { + reject(@"no_view_controller", @"No viewController found", nil); + } + }); +} + +RCT_EXPORT_METHOD(moveMarker + : (nonnull NSNumber *)reactTag markerId + : (NSString *)markerId newPosition + : (NSDictionary *)newPosition duration + : (nonnull NSNumber *)duration resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + dispatch_async(dispatch_get_main_queue(), ^{ + NavViewController *viewController = [self getViewControllerForTag:reactTag]; + if (viewController) { + [viewController moveMarker:markerId + newPosition:newPosition + duration:[duration integerValue]]; + resolve(@(YES)); + } else { + reject(@"no_view_controller", @"No viewController found", nil); + } + }); +} + RCT_EXPORT_METHOD(addPolyline : (nonnull NSNumber *)reactTag polylineOptions : (NSDictionary *)polylineOptions resolver diff --git a/src/auto/useNavigationAuto.ts b/src/auto/useNavigationAuto.ts index 7f88c15..fb101f5 100644 --- a/src/auto/useNavigationAuto.ts +++ b/src/auto/useNavigationAuto.ts @@ -16,7 +16,7 @@ import { NativeModules } from 'react-native'; import type { MapViewAutoController, NavigationAutoCallbacks } from './types'; -import { useModuleListeners, type Location } from '../shared'; +import { useModuleListeners, type LatLng, type Location } from '../shared'; import type { MapType, CircleOptions, @@ -30,6 +30,7 @@ import type { CameraPosition, UISettings, Padding, + TextMarkerOptions, } from '../maps'; import { useMemo } from 'react'; @@ -191,6 +192,16 @@ export const useNavigationAuto = (): { const { top = 0, left = 0, bottom = 0, right = 0 } = padding; return NavAutoModule.setPadding(top, left, bottom, right); }, + + moveMarker: (markerId: string, position: LatLng, duration: number) => { + return NavAutoModule.moveMarker(markerId, position, duration); + }, + + addTextMarker: async ( + textMarkerOptions: TextMarkerOptions + ): Promise => { + return await NavAutoModule.addTextMarker(textMarkerOptions); + }, }), [moduleListenersHandler] ); diff --git a/src/maps/mapView/mapViewController.ts b/src/maps/mapView/mapViewController.ts index 85581a1..c5d8ae4 100644 --- a/src/maps/mapView/mapViewController.ts +++ b/src/maps/mapView/mapViewController.ts @@ -15,7 +15,7 @@ */ import { NativeModules } from 'react-native'; -import type { Location } from '../../shared/types'; +import type { LatLng, Location } from '../../shared/types'; import { commands, sendCommand } from '../../shared/viewManager'; import type { CameraPosition, @@ -33,6 +33,7 @@ import type { Padding, PolygonOptions, PolylineOptions, + TextMarkerOptions, } from './types'; const { NavViewModule } = NativeModules; @@ -80,6 +81,14 @@ export const getMapViewController = (viewId: number): MapViewController => { sendCommand(viewId, commands.removeMarker, [id]); }, + moveMarker: async ( + id: string, + position: LatLng, + duration: number = 1000 + ): Promise => { + return await NavViewModule.moveMarker(viewId, id, position, duration); + }, + removePolyline: (id: string) => { sendCommand(viewId, commands.removePolyline, [id]); }, @@ -92,6 +101,12 @@ export const getMapViewController = (viewId: number): MapViewController => { sendCommand(viewId, commands.removeCircle, [id]); }, + addTextMarker: async ( + textMarkerOptions: TextMarkerOptions + ): Promise => { + return await NavViewModule.addTextMarker(viewId, textMarkerOptions); + }, + setIndoorEnabled: (isOn: boolean) => { sendCommand(viewId, commands.setIndoorEnabled, [isOn]); }, diff --git a/src/maps/mapView/types.ts b/src/maps/mapView/types.ts index 966d38f..2488213 100644 --- a/src/maps/mapView/types.ts +++ b/src/maps/mapView/types.ts @@ -107,6 +107,35 @@ export interface PolylineOptions { visible?: boolean; } +/** + * Defines TextMarkerOptions for a text marker on the map. + * Text markers display text with a customizable background rectangle as a marker icon. + */ +export interface TextMarkerOptions { + /** The text content to display on the map. */ + text: string; + /** The position on the map where the text marker will be placed. */ + position: LatLng; + /** The font size of the text. Uses density-independent pixels (dp) on Android and points (pt) on iOS. */ + fontSize: number; + /** The color of the text in hex format (e.g., '#000000'). */ + textColor: string; + /** The background color of the circle in hex format (e.g., '#FFFFFF'). */ + backgroundColor: string; + /** The padding around the text. Uses density-independent pixels (dp) on Android and points (pt) on iOS. */ + padding: number; + /** The border color of the circle in hex format (e.g., '#000000'). */ + borderColor: string; + /** A text string that's displayed in an info window when the user taps the marker. */ + title?: string; + /** Optional label text to display below the circle on a rectangle background. */ + label?: string; + /** The text color for the label in hex format (e.g., '#000000'). Defaults to textColor if not provided. */ + labelTextColor?: string; + /** The background color for the label rectangle in hex format (e.g., '#FFFFFF'). Defaults to backgroundColor if not provided. */ + labelBackgroundColor?: string; +} + /** * Defines the styling of the base map. */ @@ -263,6 +292,15 @@ export interface MapViewController { */ removeMarker(id: string): void; + /** + * Moves a marker to a new position with smooth movement. + * + * @param id - String specifying the id property of the marker + * @param position - The new position for the marker + * @param duration - Duration of the animation in milliseconds (default: 1000) + */ + moveMarker(id: string, position: LatLng, duration?: number): Promise; + /** * Removes a polyline from the map. * @@ -284,6 +322,15 @@ export interface MapViewController { */ removeCircle(id: string): void; + /** + * Add a text marker to the map. + * Text markers display text with a customizable background rectangle as a marker icon. + * + * @param textMarkerOptions - Object specifying properties of the text marker, + * including text content, position, styling options. + */ + addTextMarker(textMarkerOptions: TextMarkerOptions): Promise; + /** * Enable or disable the indoor map layer. *