Skip to content

Add MoveToRegion animation control, MapSpan.FromLocations, and fix initial map animation#33982

Merged
jfversluis merged 5 commits intonet11.0from
feature/map-camera-zoom
Mar 3, 2026
Merged

Add MoveToRegion animation control, MapSpan.FromLocations, and fix initial map animation#33982
jfversluis merged 5 commits intonet11.0from
feature/map-camera-zoom

Conversation

@jfversluis
Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description

This PR adds camera and zoom control improvements to the Maps control:

1. MoveToRegion Animation Control (Fixes #21046)

  • Adds MoveToRegion(MapSpan region, bool animated) overload to IMap and Map
  • Android: Uses AnimateCamera when animated=true, MoveCamera when animated=false
  • iOS: Passes animated parameter to MKMapView.SetRegion
  • Fixes the Maui island fly-in animation: OnHandlerChanged now uses animated: false, so when you set an initial region, it appears instantly without the jarring animation from the Maui island default position

2. MapSpan.FromLocations - Best Fit Zoom (Fixes #10718)

  • Adds MapSpan.FromLocations(IEnumerable<Location>) static method
  • Computes a bounding box encompassing all provided locations with 10% padding
  • Single location falls back to 1km radius
  • Enables easy "zoom to fit all pins" scenarios

3. MoveToRegionRequest

  • New MoveToRegionRequest class wrapping MapSpan + bool Animated for handler communication
  • Backward compatible: handlers accept both MoveToRegionRequest and raw MapSpan

Related: Zoom Level Property (#11332)

  • The combination of MoveToRegion(animated) + MapSpan.FromLocations() + existing VisibleRegion provides comprehensive camera/zoom control
  • A dedicated ZoomLevel property may be added as a future enhancement

New Public API

// IMap (Core)
void MoveToRegion(MapSpan region, bool animated);

// Map (Controls)
public void MoveToRegion(MapSpan region, bool animated);

// MapSpan
public static MapSpan FromLocations(IEnumerable<Location> locations);

// MoveToRegionRequest (new class)
public MoveToRegionRequest(MapSpan? region, bool animated);
public MapSpan? Region { get; }
public bool Animated { get; }

Testing

  • 9 new unit tests (38 total map tests pass)
  • CameraZoomGallery sample page added
  • Verified on MacCatalyst and Android with visual confirmation

Part of Epic: Maps Control Improvements for .NET 11

Fixes #21046
Fixes #10718
Fixes #11332

Copilot AI review requested due to automatic review settings February 10, 2026 14:51
@jfversluis jfversluis added this to the .NET 11.0-preview1 milestone Feb 10, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the .NET MAUI Maps control camera API to support non-animated region moves, adds a helper to compute a “best fit” MapSpan from multiple locations, and updates samples/tests to exercise the new behavior (including avoiding the initial “Maui island fly-in” animation).

Changes:

  • Add IMap.MoveToRegion(MapSpan region, bool animated) and Map.MoveToRegion(MapSpan mapSpan, bool animated), plus a MoveToRegionRequest carrier for handler communication.
  • Add MapSpan.FromLocations(IEnumerable<Location>) to compute a padded bounding span for a set of locations.
  • Update Android/iOS handlers, unit tests, and the Controls sample gallery with a new “Camera & Zoom” page.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Core/maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (IMap overload, MoveToRegionRequest, MapSpan.FromLocations).
src/Core/maps/src/PublicAPI/net/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net).
src/Core/maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net-windows).
src/Core/maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net-tizen).
src/Core/maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net-maccatalyst).
src/Core/maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net-ios).
src/Core/maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt Declares new Core Maps public APIs (net-android).
src/Core/maps/src/Primitives/MoveToRegionRequest.cs Adds request type to convey region + animation flag to handlers.
src/Core/maps/src/Primitives/MapSpan.cs Adds MapSpan.FromLocations best-fit span computation.
src/Core/maps/src/Handlers/Map/MapHandler.iOS.cs Updates MoveToRegion command handling to support MoveToRegionRequest + animated flag.
src/Core/maps/src/Handlers/Map/MapHandler.Android.cs Updates MoveToRegion command handling to support MoveToRegionRequest + animated flag.
src/Core/maps/src/Core/IMap.cs Adds MoveToRegion(MapSpan, bool animated) to the IMap contract.
src/Controls/Maps/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt Declares new Controls Maps public API for Map.MoveToRegion(MapSpan, bool).
src/Controls/Maps/src/PublicAPI/net/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net).
src/Controls/Maps/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net-windows).
src/Controls/Maps/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net-tizen).
src/Controls/Maps/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net-maccatalyst).
src/Controls/Maps/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net-ios).
src/Controls/Maps/src/PublicAPI/net-android/PublicAPI.Unshipped.txt Declares new Controls Maps public API (net-android).
src/Controls/Maps/src/Map.cs Adds public MoveToRegion(MapSpan, bool) overload and adjusts handler invocation payload.
src/Controls/Maps/src/HandlerImpl/Map.Impl.cs Implements new IMap overload and invokes initial region move with animated: false.
src/Controls/tests/Core.UnitTests/MapTests.cs Adds unit tests for null handling and MoveToRegionRequest basics.
src/Controls/tests/Core.UnitTests/MapSpanTests.cs Adds unit tests for MapSpan.FromLocations behavior and argument validation.
src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/MapsGallery.cs Adds navigation entry for the new camera/zoom sample gallery page.
src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/CameraZoomGallery.xaml.cs Implements sample interactions demonstrating best-fit and animated vs instant camera moves.
src/Controls/samples/Controls.Sample/Pages/Controls/MapsGalleries/CameraZoomGallery.xaml Adds UI for “Fit All Pins”, “Animated”, and “Instant” behaviors.

Comment on lines +112 to +133
double minLat = double.MaxValue, maxLat = double.MinValue;
double minLon = double.MaxValue, maxLon = double.MinValue;

foreach (var loc in locationList)
{
minLat = Math.Min(minLat, loc.Latitude);
maxLat = Math.Max(maxLat, loc.Latitude);
minLon = Math.Min(minLon, loc.Longitude);
maxLon = Math.Max(maxLon, loc.Longitude);
}

double centerLat = (minLat + maxLat) / 2;
double centerLon = (minLon + maxLon) / 2;
double latDegrees = (maxLat - minLat) * 1.1; // 10% padding
double lonDegrees = (maxLon - minLon) * 1.1;

// Ensure minimum span
latDegrees = Math.Max(latDegrees, MinimumRangeDegrees);
lonDegrees = Math.Max(lonDegrees, MinimumRangeDegrees);

return new MapSpan(new Location(centerLat, centerLon), latDegrees, lonDegrees);
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MapSpan.FromLocations computes the longitude bounds using simple min/max. This fails for points that cross the antimeridian (e.g., 179° and -179°): maxLon - minLon becomes ~358°, then the MapSpan constructor clamps LongitudeDegrees to 180°, producing a span that may not include either point. The implementation needs to account for longitude wraparound (choose the smaller arc across ±180° and compute the correct center/span) and should have a unit test covering the dateline case.

Copilot uses AI. Check for mistakes.
@jfversluis
Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 3 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 26, 2026

Independent Code Review

Summary: Adds MoveToRegion(MapSpan, bool animated) overload, MoveToRegionRequest DTO, MapSpan.FromLocations() helper, and IMap.MoveToRegion(region, animated) interface method.

✅ What Looks Good

  • Backwards-compatible: existing MoveToRegion(MapSpan) still works, now wraps with animated: true
  • MoveToRegionRequest cleanly encapsulates the region+animated flag
  • FromLocations handles antimeridian crossing — thoughtful edge case
  • Initial load uses animated: false (correct — don't animate the initial region)
  • Fallback for MapSpan arg type in handler preserves compat with old callers
  • Good test coverage including FromLocations edge cases

🔴 Issues

1. IMap.MoveToRegion(MapSpan, bool) is a breaking interface change
Adding a new method to IMap breaks any existing external implementations of the interface. Consider adding a default interface implementation:

void MoveToRegion(MapSpan region, bool animated) => MoveToRegion(region);

2. MoveToRegionRequest constructor allows null region
The test new MoveToRegionRequest(null, false) confirms this, but then MapMoveToRegion handler checks request.Region != null and silently does nothing. Should this throw ArgumentNullException?

3. Code duplication
MoveToRegion(MapSpan) and MoveToRegion(MapSpan, bool) have identical null-check + _lastMoveToRegion assignment logic. The original should delegate to the new overload:

public void MoveToRegion(MapSpan mapSpan) => MoveToRegion(mapSpan, true);

🟡 Nits

  • FromLocations uses .ToList() which allocates — for large collections, consider iterating directly
  • The 10% padding in FromLocations is a reasonable default but is undiscoverable — consider documenting the constant or making it a parameter

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 26, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 33982

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 33982"

@jfversluis
Copy link
Copy Markdown
Member Author

Thanks for the review @kubaflo! Addressed the items:

Fixed in latest commit:

  • Code duplication — \MoveToRegion(MapSpan)\ now delegates to \MoveToRegion(MapSpan, bool)\ with \�nimated: true\
  • Antimeridian test — Added a unit test confirming \MapSpan.FromLocations\ handles 179°/-179° correctly. The antimeridian crossing was already handled by the existing code (lines 128-146 in MapSpan.cs) using a wrapped span comparison.

Responses to other points:

1. Breaking IMap interface change — \IMap\ is an internal contract between the MAUI control and its handler, not intended for external implementation. These are new .NET 11 APIs. A default interface implementation isn't needed since there are no external consumers.

2. MoveToRegionRequest allows null region — This is intentional. The handler checks for null and no-ops, which is the correct behavior when the map is first created and no region has been set yet. The initial \OnHandlerChanged\ call passes _lastMoveToRegion\ which starts as null.

3. FromLocations ToList() allocation — For typical use cases (< 100 pins), this is negligible. Using \ToList()\ enables \Count\ check and random access for the single-element optimization. A future enhancement could use \TryGetNonEnumeratedCount\ but the benefit doesn't justify the complexity for now.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 27, 2026

🔍 Round 2 Review — PR #33982 (MoveToRegion animated)

Updated with author response analysis

✅ Fixed Since Round 1

  • Code duplication eliminated: MoveToRegion(MapSpan) now delegates to MoveToRegion(MapSpan, bool) with animated: true.
  • MoveToRegionRequest wrapper class properly encapsulates region + animated flag.
  • Backward compatibility: Both Android and iOS handlers check for MoveToRegionRequest first, then fall back to bare MapSpan.
  • Null validation: Both overloads properly throw ArgumentNullException.
  • MapSpan.FromLocations: Antimeridian crossing test added. Single-location case handled. Proper null/empty validation.

⚠️ Issues & Author Responses

1. _lastMoveToRegion restore uses hardcoded animated: falseAcknowledged as intentional
On handler reconnection (e.g., after page navigation), the last region is restored without animation. This makes sense — you don't want to animate a restore/reconnect. The behavior is correct. ✅

2. Breaking IMap interface changeRetracted
Author explains IMap is an internal handler contract, not intended for external implementation. Consistent across all 8 PRs. ✅

3. MoveToRegionRequest.Region nullable mixed signalsRetracted
Author explains: _lastMoveToRegion starts as null. The initial OnHandlerChanged call creates new MoveToRegionRequest(_lastMoveToRegion, false) with a null region. The handler checks request.Region != null and no-ops. This is the correct flow for map initialization before any region is set. The nullable Region is intentional and well-handled. ✅

4. Test creates null Region requestRetracted
Connected to point 3 — this test validates the legitimate null-region scenario. ✅

👍 What Looks Good

  • MapSpan.FromLocations is genuinely useful — bounding box computation with antimeridian handling and 1.3x padding.
  • Animation parameter flows cleanly through the entire stack.
  • Good test coverage including null checks, animated vs non-animated, and FromLocations edge cases.
  • Backward compatibility with bare MapSpan argument ensures existing code keeps working.

Summary

All concerns were satisfactorily addressed. The nullable MoveToRegionRequest.Region was a deliberate design choice I didn't understand until the author explained the initialization flow. This PR is clean and well-architected.

@jfversluis
Copy link
Copy Markdown
Member Author

/azp run maui-pr

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Feb 27, 2026

📋 Round 3 — PR #33982 (MoveToRegion animated) — Final Recommendation

No outstanding issues. All Round 2 concerns were satisfactorily addressed. The MoveToRegionRequest design and MapSpan.FromLocations utility are well-architected.

Recommendation: Merge when CI passes. Clean and ready. The Build Analysis failure is infrastructure-level.

kubaflo
kubaflo previously approved these changes Feb 27, 2026
@kubaflo kubaflo enabled auto-merge (squash) February 27, 2026 13:33
@jfversluis jfversluis force-pushed the feature/map-camera-zoom branch 3 times, most recently from 594a755 to 854a3b4 Compare March 2, 2026 13:25
jfversluis and others added 5 commits March 2, 2026 20:31
…itial map animation

- Add MoveToRegion(MapSpan, bool animated) overload to IMap and Map
- Add MoveToRegionRequest class for handler communication
- Android: AnimateCamera vs MoveCamera based on animated flag
- iOS: MKMapView.SetRegion with animated parameter
- Add MapSpan.FromLocations() to compute best-fit span for pins
- Fix OnHandlerChanged to use animated:false (no Maui island fly-in)
- Add 9 unit tests for new functionality
- Add CameraZoomGallery sample page

Fixes #21046
Fixes #10718
Fixes #11332
- Handle longitude wraparound when locations span the antimeridian
- Choose the shorter arc across ±180° for correct center/span calculation
- MoveToRegion handler already supports both MapSpan and MoveToRegionRequest
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Reduce code duplication: MoveToRegion(MapSpan) now delegates to
  MoveToRegion(MapSpan, bool) with animated=true
- Add antimeridian crossing unit test to verify MapSpan.FromLocations
  correctly handles points near ±180° longitude

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jfversluis jfversluis force-pushed the feature/map-camera-zoom branch from 854a3b4 to 1cbd4f0 Compare March 2, 2026 19:35
@jfversluis jfversluis disabled auto-merge March 3, 2026 07:58
@jfversluis jfversluis enabled auto-merge March 3, 2026 07:58
@jfversluis jfversluis disabled auto-merge March 3, 2026 07:58
@jfversluis jfversluis merged commit 3a393bb into net11.0 Mar 3, 2026
25 of 29 checks passed
@jfversluis jfversluis deleted the feature/map-camera-zoom branch March 3, 2026 07:58
@github-actions github-actions bot locked and limited conversation to collaborators Apr 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants