Skip to content

Commit ee0334f

Browse files
[google_maps_flutter] Add CameraUpdate.newLatLngBoundsWithEdgeInsets on Android (#183439)
1 parent 736761d commit ee0334f

File tree

5 files changed

+236
-10
lines changed

5 files changed

+236
-10
lines changed

packages/google_maps_flutter/google_maps_flutter_android/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 2.19.6
22

3-
* Throws `UnsupportedError` for `CameraUpdate.newLatLngBoundsWithEdgeInsets` (not supported on Android).
3+
* Adds support for `CameraUpdate.newLatLngBoundsWithEdgeInsets`.
44

55
## 2.19.5
66

packages/google_maps_flutter/google_maps_flutter_android/lib/src/google_maps_flutter_android.dart

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'dart:async';
6+
import 'dart:math';
67

78
import 'package:flutter/foundation.dart';
89
import 'package:flutter/gestures.dart';
@@ -406,20 +407,134 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform {
406407
CameraUpdate cameraUpdate,
407408
CameraUpdateAnimationConfiguration configuration, {
408409
required int mapId,
409-
}) {
410+
}) async {
411+
var effective = cameraUpdate;
412+
if (cameraUpdate is CameraUpdateNewLatLngBoundsWithEdgeInsets) {
413+
final CameraPosition pos = await _computeBoundsWithEdgeInsets(
414+
cameraUpdate.bounds,
415+
cameraUpdate.padding,
416+
mapId,
417+
);
418+
effective = CameraUpdate.newCameraPosition(pos);
419+
}
410420
return _hostApi(mapId).animateCamera(
411-
_platformCameraUpdateFromCameraUpdate(cameraUpdate),
421+
_platformCameraUpdateFromCameraUpdate(effective),
412422
configuration.duration?.inMilliseconds,
413423
);
414424
}
415425

416426
@override
417-
Future<void> moveCamera(CameraUpdate cameraUpdate, {required int mapId}) {
427+
Future<void> moveCamera(
428+
CameraUpdate cameraUpdate, {
429+
required int mapId,
430+
}) async {
431+
var effective = cameraUpdate;
432+
if (cameraUpdate is CameraUpdateNewLatLngBoundsWithEdgeInsets) {
433+
final CameraPosition pos = await _computeBoundsWithEdgeInsets(
434+
cameraUpdate.bounds,
435+
cameraUpdate.padding,
436+
mapId,
437+
);
438+
effective = CameraUpdate.newCameraPosition(pos);
439+
}
418440
return _hostApi(
419441
mapId,
420-
).moveCamera(_platformCameraUpdateFromCameraUpdate(cameraUpdate));
442+
).moveCamera(_platformCameraUpdateFromCameraUpdate(effective));
443+
}
444+
445+
static const double _kMercatorTileSize = 256.0;
446+
447+
/// Computes a [CameraPosition] that fits [bounds] within the map viewport
448+
/// minus [padding].
449+
///
450+
/// Uses Web Mercator projection to calculate the zoom level and offset
451+
/// center from the current map dimensions.
452+
Future<CameraPosition> _computeBoundsWithEdgeInsets(
453+
LatLngBounds bounds,
454+
EdgeInsets padding,
455+
int mapId,
456+
) async {
457+
final double currentZoom = await getZoomLevel(mapId: mapId);
458+
final LatLngBounds region = await getVisibleRegion(mapId: mapId);
459+
final double scale = pow(2.0, currentZoom).toDouble();
460+
461+
double regionLngSpan =
462+
region.northeast.longitude - region.southwest.longitude;
463+
if (regionLngSpan <= 0) {
464+
regionLngSpan += 360;
465+
}
466+
final double mapWidthDp =
467+
regionLngSpan / 360.0 * _kMercatorTileSize * scale;
468+
469+
final double regionNeY = _mercatorY(region.northeast.latitude);
470+
final double regionSwY = _mercatorY(region.southwest.latitude);
471+
final double mapHeightDp =
472+
(regionSwY - regionNeY) * _kMercatorTileSize * scale;
473+
474+
final double availW = mapWidthDp - padding.left - padding.right;
475+
final double availH = mapHeightDp - padding.top - padding.bottom;
476+
477+
final LatLng ne = bounds.northeast;
478+
final LatLng sw = bounds.southwest;
479+
double lngSpan = ne.longitude - sw.longitude;
480+
if (lngSpan <= 0) {
481+
lngSpan += 360;
482+
}
483+
484+
final double neY = _mercatorY(ne.latitude);
485+
final double swY = _mercatorY(sw.latitude);
486+
final double latSpanMerc = (swY - neY).abs();
487+
488+
final double centerLat = (ne.latitude + sw.latitude) / 2;
489+
double centerLng = (ne.longitude + sw.longitude) / 2;
490+
if (ne.longitude < sw.longitude) {
491+
centerLng += 180;
492+
if (centerLng > 180) {
493+
centerLng -= 360;
494+
}
495+
}
496+
497+
if (availW <= 0 || availH <= 0) {
498+
return CameraPosition(
499+
target: LatLng(centerLat, centerLng),
500+
zoom: currentZoom,
501+
);
502+
}
503+
504+
final double zoomLng = _log2(
505+
availW / (lngSpan / 360.0 * _kMercatorTileSize),
506+
);
507+
final double zoomLat = _log2(availH / (latSpanMerc * _kMercatorTileSize));
508+
final double zoom = min(zoomLng, zoomLat);
509+
510+
final double targetScale = pow(2.0, zoom).toDouble();
511+
final double lngPerDp = 360.0 / (_kMercatorTileSize * targetScale);
512+
final double offsetLng = (padding.right - padding.left) / 2 * lngPerDp;
513+
514+
final double centerMercY = _mercatorY(centerLat);
515+
final double centerPxY = centerMercY * _kMercatorTileSize * targetScale;
516+
final double offsetPxY = centerPxY + (padding.bottom - padding.top) / 2;
517+
final double offsetLat = _inverseMercatorY(
518+
offsetPxY / (_kMercatorTileSize * targetScale),
519+
);
520+
521+
return CameraPosition(
522+
target: LatLng(offsetLat, centerLng + offsetLng),
523+
zoom: zoom,
524+
);
525+
}
526+
527+
static double _mercatorY(double lat) {
528+
final double latRad = lat * pi / 180;
529+
return (1 - log(tan(latRad) + 1 / cos(latRad)) / pi) / 2;
530+
}
531+
532+
static double _inverseMercatorY(double y) {
533+
return (2 * atan(exp(pi * (1 - 2 * y))) - pi / 2) * 180 / pi;
421534
}
422535

536+
static double _log2(double x) => log(x) / ln2;
537+
423538
@override
424539
Future<void> setMapStyle(String? mapStyle, {required int mapId}) async {
425540
final bool success = await _hostApi(mapId).setStyle(mapStyle ?? '');
@@ -1008,9 +1123,11 @@ class GoogleMapsFlutterAndroid extends GoogleMapsFlutterPlatform {
10081123
),
10091124
);
10101125
case CameraUpdateType.newLatLngBoundsWithEdgeInsets:
1011-
throw UnsupportedError(
1012-
'CameraUpdate.newLatLngBoundsWithEdgeInsets is not supported on Android. '
1013-
'Use CameraUpdate.newLatLngBounds with uniform padding instead.',
1126+
// Requires async platform calls (getZoomLevel, getVisibleRegion) to
1127+
// compute the polyfill, so it is handled in moveCamera/animateCamera
1128+
// before reaching this static sync method.
1129+
throw StateError(
1130+
'newLatLngBoundsWithEdgeInsets should be unreachable here.',
10141131
);
10151132
}
10161133
}

packages/google_maps_flutter/google_maps_flutter_android/test/google_maps_flutter_android_test.dart

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,4 +1673,113 @@ void main() {
16731673
PlatformMarkerType.marker,
16741674
);
16751675
});
1676+
1677+
group('newLatLngBoundsWithEdgeInsets polyfill', () {
1678+
void stubMapState(MockMapsApi api, {required double zoom}) {
1679+
when(api.getZoomLevel()).thenAnswer((_) async => zoom);
1680+
when(api.getVisibleRegion()).thenAnswer(
1681+
(_) async => PlatformLatLngBounds(
1682+
southwest: PlatformLatLng(latitude: -10, longitude: -20),
1683+
northeast: PlatformLatLng(latitude: 10, longitude: 20),
1684+
),
1685+
);
1686+
}
1687+
1688+
test(
1689+
'moveCamera with symmetric padding produces centered result',
1690+
() async {
1691+
const mapId = 1;
1692+
final (GoogleMapsFlutterAndroid maps, MockMapsApi api) = setUpMockMap(
1693+
mapId: mapId,
1694+
);
1695+
stubMapState(api, zoom: 5);
1696+
1697+
final bounds = LatLngBounds(
1698+
southwest: const LatLng(-1, -1),
1699+
northeast: const LatLng(1, 1),
1700+
);
1701+
await maps.moveCamera(
1702+
CameraUpdate.newLatLngBoundsWithEdgeInsets(
1703+
bounds,
1704+
const EdgeInsets.all(50),
1705+
),
1706+
mapId: mapId,
1707+
);
1708+
1709+
final verification = verify(api.moveCamera(captureAny));
1710+
final passedUpdate = verification.captured[0] as PlatformCameraUpdate;
1711+
final pos =
1712+
passedUpdate.cameraUpdate as PlatformCameraUpdateNewCameraPosition;
1713+
expect(pos.cameraPosition.target.latitude, closeTo(0, 0.01));
1714+
expect(pos.cameraPosition.target.longitude, closeTo(0, 0.01));
1715+
expect(pos.cameraPosition.zoom, greaterThan(0));
1716+
},
1717+
);
1718+
1719+
test('moveCamera with bottom-heavy padding shifts center upward', () async {
1720+
const mapId = 1;
1721+
final (GoogleMapsFlutterAndroid maps, MockMapsApi api) = setUpMockMap(
1722+
mapId: mapId,
1723+
);
1724+
stubMapState(api, zoom: 5);
1725+
1726+
final bounds = LatLngBounds(
1727+
southwest: const LatLng(-1, -1),
1728+
northeast: const LatLng(1, 1),
1729+
);
1730+
await maps.moveCamera(
1731+
CameraUpdate.newLatLngBoundsWithEdgeInsets(
1732+
bounds,
1733+
const EdgeInsets.only(bottom: 200),
1734+
),
1735+
mapId: mapId,
1736+
);
1737+
1738+
final verification = verify(api.moveCamera(captureAny));
1739+
final passedUpdate = verification.captured[0] as PlatformCameraUpdate;
1740+
final pos =
1741+
passedUpdate.cameraUpdate as PlatformCameraUpdateNewCameraPosition;
1742+
expect(
1743+
pos.cameraPosition.target.latitude,
1744+
lessThan(0),
1745+
reason:
1746+
'Bottom-heavy padding should shift the center south (negative latitude)',
1747+
);
1748+
expect(pos.cameraPosition.target.longitude, closeTo(0, 0.01));
1749+
});
1750+
1751+
test(
1752+
'moveCamera with right-heavy padding shifts center rightward',
1753+
() async {
1754+
const mapId = 1;
1755+
final (GoogleMapsFlutterAndroid maps, MockMapsApi api) = setUpMockMap(
1756+
mapId: mapId,
1757+
);
1758+
stubMapState(api, zoom: 5);
1759+
1760+
final bounds = LatLngBounds(
1761+
southwest: const LatLng(-1, -1),
1762+
northeast: const LatLng(1, 1),
1763+
);
1764+
await maps.moveCamera(
1765+
CameraUpdate.newLatLngBoundsWithEdgeInsets(
1766+
bounds,
1767+
const EdgeInsets.only(right: 200),
1768+
),
1769+
mapId: mapId,
1770+
);
1771+
1772+
final verification = verify(api.moveCamera(captureAny));
1773+
final passedUpdate = verification.captured[0] as PlatformCameraUpdate;
1774+
final pos =
1775+
passedUpdate.cameraUpdate as PlatformCameraUpdateNewCameraPosition;
1776+
expect(pos.cameraPosition.target.latitude, closeTo(0, 0.01));
1777+
expect(
1778+
pos.cameraPosition.target.longitude,
1779+
greaterThan(0),
1780+
reason: 'Right-heavy padding should shift the center eastward',
1781+
);
1782+
},
1783+
);
1784+
});
16761785
}

packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 2.19.0
22

3-
* Adds support for `CameraUpdate.newLatLngBoundsWithEdgeInsets` using native `GMSCameraUpdate.fitBounds(_:withEdgeInsets:)`.
3+
* Adds support for `CameraUpdate.newLatLngBoundsWithEdgeInsets`.
44

55
## 2.18.1
66

packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## 0.6.3
22

3-
* Adds support for `CameraUpdate.newLatLngBoundsWithEdgeInsets` using native `fitBounds` padding.
3+
* Adds support for `CameraUpdate.newLatLngBoundsWithEdgeInsets`.
44

55
## 0.6.2
66

0 commit comments

Comments
 (0)