This tutorial builds a London map with two labelled markers, a highlighted route, an animated camera control, and an optional current-location layer. These pieces cover the interactions most location screens need before routing, place search, or live tracking is added.
You will configure Google Maps Platform separately for Android and iOS, create the map with google_maps_flutter, retain its controller, and model markers and polylines as sets. The permission path deliberately keeps the location layer disabled until the user grants access.
You need a Flutter project plus a Google Cloud project with billing enabled and the Maps SDK for Android and Maps SDK for iOS activated. Use distinct, platform-restricted API keys: mobile keys are embedded in application binaries, so restriction is more useful than pretending they can remain secret.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowInstall the map and permission packages
Add the official Google Maps plugin and a permission package from the project root. Allowing flutter pub add to resolve compatible releases avoids copying stale version constraints into pubspec.yaml.
flutter pub add google_maps_flutter
flutter pub add permission_handler
google_maps_flutter renders a native map view on Android and iOS. It does not request location permission for the application, which is why the example uses permission_handler before enabling the blue current-location layer.
Register the Android API key and location permission
Open android/app/src/main/AndroidManifest.xml. Add the permissions directly below the opening manifest element, then place the Google Maps metadata inside application.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:label="maps_demo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_ANDROID_MAPS_API_KEY" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
...
</activity>
</application>
</manifest>
Keep the activity and other generated entries already present in your project; the ellipsis only marks unchanged manifest content. Restrict the Android key by package name and SHA certificate fingerprint, and permit the Maps SDK for Android. Remember that debug and release builds normally use different signing certificates.
Provide the iOS key before Flutter registers plugins
In ios/Runner/AppDelegate.swift, import Google Maps and call GMSServices.provideAPIKey during application launch. Supply an iOS-specific key restricted to the Runner bundle identifier.
import Flutter
import GoogleMaps
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GMSServices.provideAPIKey("YOUR_IOS_MAPS_API_KEY")
GeneratedPluginRegistrant.register(with: self)
return super.application(
application,
didFinishLaunchingWithOptions: launchOptions
)
}
}
Current-location access also needs a human-readable explanation in ios/Runner/Info.plist. This text is displayed by iOS when permission is requested.
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to show your position on the map.</string>
Do not request background location for a screen that merely shows the user’s position. It adds platform declarations and review scrutiny without helping this use case.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowBuild the map, markers, and route together
The following lib/main.dart is a complete example. Its initialCameraPosition supplies the map’s first viewpoint, while stable marker and polyline identifiers let the plugin reconcile updates efficiently.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const MapsDemoApp());
}
class MapsDemoApp extends StatelessWidget {
const MapsDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'London map',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: const MapPage(),
);
}
}
class MapPage extends StatefulWidget {
const MapPage({super.key});
@override
State<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> {
static const LatLng _tateModern =
LatLng(51.5076, -0.0994);
static const LatLng _boroughMarket =
LatLng(51.5055, -0.0754);
GoogleMapController? _mapController;
bool _showMyLocation = false;
final Set<Marker> _markers = const {
Marker(
markerId: MarkerId('tate-modern'),
position: _tateModern,
infoWindow: InfoWindow(
title: 'Tate Modern',
snippet: 'Route starting point',
),
),
Marker(
markerId: MarkerId('borough-market'),
position: _boroughMarket,
infoWindow: InfoWindow(
title: 'Borough Market',
snippet: 'Route destination',
),
),
};
final Set<Polyline> _polylines = const {
Polyline(
polylineId: PolylineId('riverside-walk'),
points: [
_tateModern,
LatLng(51.5065, -0.0922),
LatLng(51.5055, -0.0850),
_boroughMarket,
],
color: Colors.indigo,
width: 6,
geodesic: true,
),
};
void _onMapCreated(GoogleMapController controller) {
_mapController = controller;
setState(() {});
}
Future<void> _showWholeRoute() async {
final controller = _mapController;
if (controller == null) {
return;
}
const bounds = LatLngBounds(
southwest: LatLng(51.5045, -0.1005),
northeast: LatLng(51.5085, -0.0740),
);
await controller.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 56),
);
}
Future<void> _enableLocation() async {
final status = await Permission.locationWhenInUse.request();
if (!mounted) {
return;
}
if (status.isGranted) {
setState(() {
_showMyLocation = true;
});
return;
}
final permanentlyDenied = status.isPermanentlyDenied;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
permanentlyDenied
? 'Location is disabled. Enable it in system settings.'
: 'Location permission was not granted.',
),
action: permanentlyDenied
? SnackBarAction(
label: 'Settings',
onPressed: () {
openAppSettings();
},
)
: null,
),
);
}
@override
void dispose() {
_mapController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('London landmarks')),
body: GoogleMap(
initialCameraPosition: const CameraPosition(
target: LatLng(51.5066, -0.0874),
zoom: 14,
),
markers: _markers,
polylines: _polylines,
myLocationEnabled: _showMyLocation,
myLocationButtonEnabled: _showMyLocation,
mapToolbarEnabled: false,
onMapCreated: _onMapCreated,
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
heroTag: 'location',
onPressed: _enableLocation,
icon: const Icon(Icons.my_location),
label: const Text('My location'),
),
const SizedBox(height: 12),
FloatingActionButton.extended(
heroTag: 'route',
onPressed:
_mapController == null ? null : _showWholeRoute,
icon: const Icon(Icons.route),
label: const Text('Show route'),
),
],
),
);
}
}
The route button remains disabled until onMapCreated supplies its controller. Disposing that controller with the state prevents an obsolete platform-view reference from surviving after the page is removed.
Treat markers as identified map data
A Marker is not just a coordinate. Its MarkerId determines identity across rebuilds, its position chooses the location, and its InfoWindow supplies the label shown after a tap. Use domain identifiers such as a place ID rather than a list index if markers can be inserted or reordered.
For live data, replace the set inside setState instead of mutating application records unpredictably. Hundreds or thousands of markers also require clustering or viewport-based loading; sending an entire database to the platform view on every rebuild will eventually make gestures stutter.
Choose the right CameraUpdate for each movement
animateCamera interpolates towards a new camera state, whereas moveCamera changes it immediately. CameraUpdate.newLatLngZoom suits a selected result, zoomIn handles relative controls, and newLatLngBounds is appropriate when every route point must remain visible.
Future<void> focusMarker(
GoogleMapController controller,
LatLng position,
) {
return controller.animateCamera(
CameraUpdate.newLatLngZoom(position, 16),
);
}
Bounds need a laid-out map because their padding is interpreted against the view size. Triggering a bounds update from a user control after onMapCreated, as the example does, avoids racing the initial layout.
Understand what the polyline represents
The four polyline points form a visible path, but they do not calculate a walking route. Each adjacent pair is joined by a segment. To trace roads, obtain an encoded route from a routing service, decode it into ordered LatLng values, and pass those values to Polyline.points.
Keep routing credentials appropriately restricted and avoid exposing a broadly privileged server key in the application. Also handle a directions response containing no route: an empty result should produce a useful message, not an index error or a stale line left on the map.
Enable myLocation only after authorisation
Setting myLocationEnabled to true before permission exists can produce a platform exception. The example starts with the layer disabled, requests when-in-use access after a deliberate tap, checks whether the widget is still mounted, and changes the map only for an approved status.
A denial is a valid outcome. The screen continues to show landmarks and its route without requiring location, while permanent denial offers a path to system settings. If the application genuinely needs the user’s coordinates for business logic, retrieve them through a location API; the map’s blue-dot layer is visual and does not expose the coordinate to Dart.
Diagnose a blank map before changing widget code
A grey or blank surface with Google branding usually means the native map loaded but authorisation failed. Confirm that the Android key sits inside application, the iOS key is supplied before plugin registration, both Maps SDKs are enabled, billing is active, and restrictions match the actual package name, bundle identifier, and signing certificate.
Android logcat commonly provides an authorisation failure plus the package and SHA fingerprint Google received. The Xcode console provides the corresponding iOS error. An emulator must include Google APIs, and both simulators and physical devices need network access; changing zoom, markers, or widget dimensions cannot repair an invalid credential.
Verify gestures, camera framing, and denial paths
Run flutter analyze, launch each platform, and verify that the two information windows open when their markers are tapped. Press Show route and confirm that both endpoints remain within the padded camera frame, then pan and pinch to ensure the native map still handles gestures.
flutter analyze
flutter run
Test location three ways: grant access, deny it once, and mark it permanently denied in system settings. Finally, test a release-signed Android build because a key restricted only to the debug SHA fingerprint can work locally yet display a blank production map. Never commit unrestricted production credentials merely to make that final check pass.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — follow the complete Flutter learning path on this site
- Flutter Layout Widgets Guide — place maps reliably within responsive screens
- Flutter Material Widgets Catalogue — add accessible controls and feedback around a map
- Flutter Animations Complete Guide — coordinate interface motion with camera transitions
- Dart Language and Flutter Guide — strengthen the collections and asynchronous code used by map features