Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
### main

* Add `MapboxMap.location.setExternalLocation`/`.clearExternalLocation`, letting apps drive the location puck from a location source other than the platform's default GPS-based provider (e.g. an indoor-positioning SDK). Resolves [#1085](https://github.com/mapbox/mapbox-maps-flutter/issues/1085).

### 2.30.0

* Introduce experimental `RasterLayer.rasterColorScale` property, resulting in more precise visualization with long-tailed raster-array data source.
* Promote `SymbolLayer.symbolZOffset` to stable.
* Fix `PointAnnotation.iconImageCrossFade` and `PointAnnotationOptions.iconImageCrossFade` missing their `@Deprecated` annotation, so the analyzer and IDEs showed no warning. Both fields are deprecated in favor of `PointAnnotationManager.iconImageCrossFade`.

### 2.30.0-rc.1

* Add `LineLayer.lineBorderGradient` and `.lineBorderGradientExpression` to color a line's border along its length with a gradient driven by `line-progress`. Requires a GeoJSON source with `lineMetrics: true`.
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,19 @@ To use the 3D puck with model downloaded from Uri instead of the default 2D puck

You can find more examples of customization in the sample [app](example/lib/location_example.dart).

### External location provider
To drive the puck from a location source other than the platform's default (GPS-based) provider — for example, an indoor-positioning SDK — call `MapboxMap.location.setExternalLocation`. This registers a native location-provider override on first call; until then, the map behaves exactly as it does with the default provider.

```dart
mapboxMap.location.setExternalLocation(
latitude: 37.775,
longitude: -122.418,
heading: 90.0,
accuracy: 5.0);
```

Call `MapboxMap.location.clearExternalLocation()` to restore the default provider (e.g. falling back to GPS when leaving indoor coverage).

## Markers and annotations
Additional information is available in our [Flutter](https://docs.mapbox.com/flutter/maps/guides/markers-and-annotations/), [Android](https://docs.mapbox.com/android/maps/guides/annotations/), and [iOS](https://docs.mapbox.com/ios/maps/guides/annotations/) documentation.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.mapbox.maps.mapbox_maps

import com.mapbox.geojson.Point
import com.mapbox.maps.plugin.locationcomponent.LocationConsumer
import com.mapbox.maps.plugin.locationcomponent.LocationProvider
import java.util.concurrent.CopyOnWriteArrayList

/**
* A [LocationProvider] whose data comes from outside Mapbox's own location
* stack. Dart pushes updates into it via [LocationComponentController]'s
* `setExternalLocation`/`clearExternalLocation` platform-channel handlers,
* instead of Mapbox reading the device's location itself through
* [com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider].
*
* Registered with `mapView.location.setLocationProvider(...)` the first time
* `setExternalLocation` is called. Until then, the map behaves exactly as it
* does today (the default provider, unmodified).
*
* Note: unlike iOS's `Location`, this SDK's [LocationConsumer] has no floor
* concept at all — only [Point] and bearing/accuracy. Floor never flowed
* through Mapbox's location APIs on Android; callers that need floor-aware
* behavior (e.g. indoor-map puck opacity) handle it entirely separately,
* unaffected by this override.
*/
class ExternalLocationProvider : LocationProvider {
// Consumers come and go with puck visibility (same contract as any other
// LocationProvider) — a plain thread-safe list, since updates can arrive
// off the main thread depending on where the platform channel dispatches.
private val consumers = CopyOnWriteArrayList<LocationConsumer>()

override fun registerLocationConsumer(locationConsumer: LocationConsumer) {
consumers.add(locationConsumer)
}

override fun unRegisterLocationConsumer(locationConsumer: LocationConsumer) {
consumers.remove(locationConsumer)
}

/** Pushes a new position to every registered consumer. */
fun updateLocation(point: Point) {
consumers.forEach { it.onLocationUpdated(point) }
}

/** Pushes a new bearing/heading to every registered consumer. */
fun updateBearing(bearing: Double) {
consumers.forEach { it.onBearingUpdated(bearing) }
}

/** Pushes a new horizontal accuracy radius to every registered consumer. */
fun updateAccuracyRadius(radiusMeters: Double) {
consumers.forEach { it.onHorizontalAccuracyRadiusUpdated(radiusMeters) }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mapbox.maps.mapbox_maps

import android.content.Context
import com.mapbox.geojson.Point
import com.mapbox.maps.MapView
import com.mapbox.maps.mapbox_maps.mapping.applyFromFLT
import com.mapbox.maps.mapbox_maps.mapping.toFLT
Expand All @@ -9,6 +10,9 @@ import com.mapbox.maps.plugin.LocationPuck2D
import com.mapbox.maps.plugin.LocationPuck3D
import com.mapbox.maps.plugin.locationcomponent.createDefault2DPuck
import com.mapbox.maps.plugin.locationcomponent.location
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

class LocationComponentController(
private val mapView: MapView,
Expand All @@ -29,4 +33,95 @@ class LocationComponentController(
(mapView.location.locationPuck as? LocationPuck2D)?.let { cachedPuck2D = it }
(mapView.location.locationPuck as? LocationPuck3D)?.let { cachedPuck3D = it }
}

// Native location-provider override, so the puck can be driven by an
// externally-supplied combined GPS+indoor location provider instead of
// Mapbox's default provider.
private val externalLocationProvider = ExternalLocationProvider()
private var isOverrideActive = false
private var externalLocationChannel: MethodChannel? = null

/**
* Sets up the plain [MethodChannel] for `setExternalLocation`/
* `clearExternalLocation`. Deliberately not Pigeon-generated — Mapbox
* doesn't ship the Pigeon input specs for this plugin publicly, only the
* generated output, so this is a small hand-written channel kept isolated
* from the generated code to stay easy to rebase. Mirrors
* `LocationController.setUpExternalLocationChannel` on iOS.
*/
fun setUpExternalLocationChannel(messenger: BinaryMessenger, channelSuffix: String) {
val channel = MethodChannel(
messenger,
"plugins.flutter.io.mapbox_maps_flutter.externalLocation.$channelSuffix"
)
channel.setMethodCallHandler { call, result -> handleExternalLocationCall(call, result) }
externalLocationChannel = channel
}

private fun handleExternalLocationCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"setExternalLocation" -> setExternalLocation(call, result)
"clearExternalLocation" -> clearExternalLocation(result)
else -> result.notImplemented()
}
}

private fun setExternalLocation(call: MethodCall, result: MethodChannel.Result) {
val latitude = call.argument<Double>("latitude")
val longitude = call.argument<Double>("longitude")
if (latitude == null || longitude == null) {
result.error("invalid_args", "setExternalLocation requires latitude and longitude", null)
return
}

activateOverrideIfNeeded()
externalLocationProvider.updateLocation(Point.fromLngLat(longitude, latitude))

call.argument<Double>("heading")?.let { externalLocationProvider.updateBearing(it) }
call.argument<Double>("accuracy")?.let { externalLocationProvider.updateAccuracyRadius(it) }
// `floor` is intentionally not forwarded — see ExternalLocationProvider's
// doc comment: this SDK's LocationConsumer has no floor concept, unlike
// iOS's Location.floor. Callers that need floor-aware behavior (e.g.
// indoor-map puck opacity) handle it entirely separately from this
// override.

result.success(null)
}

/**
* Restores Mapbox's default location provider — "clear" means "go back to
* normal GPS." Intended usage: call this on a location-stream error, where
* presenting a stale synthetic position would be worse than falling back
* to GPS.
*/
private fun clearExternalLocation(result: MethodChannel.Result) {
if (isOverrideActive) {
isOverrideActive = false
mapView.location.setLocationProvider(defaultLocationProvider)
}
result.success(null)
}

// Mapbox lazily creates its own DefaultLocationProvider the first time the
// location component is enabled with no provider set (see
// LocationComponentPluginImpl). Capture whatever is active *before* we ever
// swap in our own, so clearExternalLocation has something real to restore.
private val defaultLocationProvider by lazy {
mapView.location.getLocationProvider()
?: com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider(context)
}

/**
* Registers `externalLocationProvider` with Mapbox on first use only —
* until `setExternalLocation` is called at least once, the map behaves
* exactly as it does today (default provider, unmodified).
*/
private fun activateOverrideIfNeeded() {
if (isOverrideActive) return
// Force evaluation before swapping so it captures the real default, not
// our own override.
defaultLocationProvider
isOverrideActive = true
mapView.location.setLocationProvider(externalLocationProvider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ class MapboxMapController(
animationController = AnimationController(mapboxMap, context)
annotationController = AnnotationController(mapView, messenger, this.channelSuffix)
locationComponentController = LocationComponentController(mapView, context)
// Hand-written channel (not Pigeon-generated, see
// LocationComponentController.setUpExternalLocationChannel) for the
// native location-provider override.
locationComponentController.setUpExternalLocationChannel(messenger, this.channelSuffix)
gestureController = GestureController(mapView, context)
interactionsController = InteractionsController(mapboxMap, context)
logoController = LogoController(mapView)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
@_spi(Experimental) import MapboxMaps
import Foundation

/// A `LocationProvider`/`HeadingProvider` whose data comes from outside Mapbox's
/// own location stack. Dart pushes updates into it via `LocationController`'s
/// `setExternalLocation`/`clearExternalLocation` platform-channel handlers
/// (see `LocationController.swift`) instead of Mapbox reading CoreLocation
/// itself through `AppleLocationProvider`.
///
/// Registered with `mapView.location.override(provider:)` the first time
/// `setExternalLocation` is called. Until then, the map behaves exactly as it
/// does today (default `AppleLocationProvider`, unmodified).
final class ExternalLocationProvider: NSObject {
// Held weakly, matching the contract documented on `MBXLocationProvider`/
// `LocationProvider`: observers come and go with puck visibility, and we
// must not be the reason one leaks.
private final class WeakLocationObserverBox {
weak var observer: LocationObserver?
init(_ observer: LocationObserver) { self.observer = observer }
}
private final class WeakHeadingObserverBox {
weak var observer: HeadingObserver?
init(_ observer: HeadingObserver) { self.observer = observer }
}

private var locationObservers: [WeakLocationObserverBox] = []
private var headingObservers: [WeakHeadingObserverBox] = []
private var lastLocation: Location?
private var lastHeading: Heading?

/// Pushes a new location to every registered observer (called from
/// `LocationController`'s `setExternalLocation` channel handler).
func update(location: Location) {
lastLocation = location
pruneLocationObservers()
for box in locationObservers {
box.observer?.onLocationUpdateReceived(for: [location])
}
}

/// Pushes a new heading/bearing to every registered observer. Only
/// relevant while the puck's `puckBearing` is configured as `.heading`
/// (the default `LocationComponentSettings` — see Mapbox's own puck
/// configuration docs).
func update(heading: Heading) {
lastHeading = heading
pruneHeadingObservers()
for box in headingObservers {
box.observer?.onHeadingUpdate(heading)
}
}

/// Drops cached state. Called when Dart clears the override, so a stale
/// location/heading doesn't linger if the override is later re-armed.
func clear() {
lastLocation = nil
lastHeading = nil
}

private func pruneLocationObservers() {
locationObservers.removeAll { $0.observer == nil }
}

private func pruneHeadingObservers() {
headingObservers.removeAll { $0.observer == nil }
}
}

extension ExternalLocationProvider: LocationProvider {
func getLastObservedLocation() -> Location? {
lastLocation
}

func addLocationObserver(for observer: LocationObserver) {
pruneLocationObservers()
locationObservers.append(WeakLocationObserverBox(observer))
}

func removeLocationObserver(for observer: LocationObserver) {
locationObservers.removeAll { $0.observer == nil || $0.observer === observer }
}
}

extension ExternalLocationProvider: HeadingProvider {
var latestHeading: Heading? { lastHeading }

func add(headingObserver: HeadingObserver) {
pruneHeadingObservers()
headingObservers.append(WeakHeadingObserverBox(headingObserver))
}

func remove(headingObserver: HeadingObserver) {
headingObservers.removeAll { $0.observer == nil || $0.observer === headingObserver }
}
}
Loading