Flutter Google Maps Custom Markers: Assets, Widgets, SVG, and Clustering
Build custom Google Maps markers in Flutter—assets, SVG, widget-to-bitmap, clustering, animations, and custom info windows—with performance tips.
Image used for representation purposes only.
Overview
Custom map markers are one of the fastest ways to make a Flutter app feel branded, legible, and useful. In this guide, you’ll learn multiple techniques to build and scale custom markers with google_maps_flutter: from simple asset icons to dynamic, widget‑rendered bitmaps, SVG rasterization, clustering, animations, and custom info windows. You’ll also get practical tips for performance, sizing, and platform quirks.
Prerequisites
- Flutter SDK installed and a working emulator/device
- A Google Cloud project with the Maps SDK for Android and iOS enabled
- API key configured for both platforms
Add the core dependency:
dependencies:
flutter:
sdk: flutter
google_maps_flutter: ^2.x.x
flutter_svg: ^2.x.x # Optional, for SVG → bitmap markers
Project setup: API key and permissions
- Android: place your key in AndroidManifest.xml inside the
tag.
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_API_KEY" />
- iOS: add your key in AppDelegate (if using iOS App lifecycle) or Info.plist (if supported by your setup). Also include a location usage string if you request location.
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby places.</string>
Rendering a basic map and marker
Start with a minimal map and a default marker.
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class BasicMapPage extends StatefulWidget {
const BasicMapPage({super.key});
@override
State<BasicMapPage> createState() => _BasicMapPageState();
}
class _BasicMapPageState extends State<BasicMapPage> {
static const _initialCamera = CameraPosition(
target: LatLng(37.422, -122.084),
zoom: 14,
);
final Set<Marker> _markers = {
const Marker(
markerId: MarkerId('default'),
position: LatLng(37.422, -122.084),
infoWindow: InfoWindow(title: 'Default Marker'),
)
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Basic Map')),
body: GoogleMap(
initialCameraPosition: _initialCamera,
markers: _markers,
myLocationButtonEnabled: false,
),
);
}
}
Custom markers from PNG assets
Use BitmapDescriptor.fromAssetImage to load a PNG. Provide size and devicePixelRatio for crisp rendering.
Future<BitmapDescriptor> loadMarkerAsset(String assetPath,
{Size size = const Size(64, 64), double pixelRatio = 3.0}) async {
return BitmapDescriptor.fromAssetImage(
ImageConfiguration(size: size, devicePixelRatio: pixelRatio),
assetPath,
);
}
Future<void> addRestaurantMarker() async {
final icon = await loadMarkerAsset('assets/markers/restaurant.png');
final marker = Marker(
markerId: const MarkerId('restaurant_1'),
position: const LatLng(37.4219, -122.0839),
icon: icon,
// Bottom-center of the icon touches the coordinate
anchor: const Offset(0.5, 1.0),
zIndex: 2, // ensure above clustered or generic pins
infoWindow: const InfoWindow(title: 'Cafe Luna'),
);
setState(() => _markers.add(marker));
}
Tips:
- Keep asset sizes power-of-two where possible (e.g., 64, 96, 128) and provide @2x/@3x variants if you need crisp UI on dense screens.
- Reuse BitmapDescriptor instances; don’t reload for every marker.
Tinting the default marker
For quick differentiation without custom art, change the hue.
final bluePin = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure);
Creating markers from Widgets (dynamic bitmaps)
When you need runtime content—badges, numbers, avatars—render a Flutter widget offstage and convert it to a bitmap. This lets you brand markers, show state, or match your design system exactly.
import 'dart:typed_data';
import 'dart:ui' as ui;
class WidgetMarkerMap extends StatefulWidget {
const WidgetMarkerMap({super.key});
@override
State<WidgetMarkerMap> createState() => _WidgetMarkerMapState();
}
class _WidgetMarkerMapState extends State<WidgetMarkerMap> {
final GlobalKey _markerKey = GlobalKey();
Uint8List? _markerBytes;
@override
void initState() {
super.initState();
// Defer until first frame so the offstage widget is laid out
WidgetsBinding.instance.addPostFrameCallback((_) => _captureMarker());
}
Future<void> _captureMarker() async {
final boundary = _markerKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final ui.Image image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
setState(() => _markerBytes = byteData!.buffer.asUint8List());
}
Widget _buildMarkerWidget(int count) {
return Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.indigo,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(blurRadius: 6, color: Colors.black26)],
),
alignment: Alignment.center,
child: Text('$count', style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold)),
);
}
@override
Widget build(BuildContext context) {
final markers = <Marker>{};
if (_markerBytes != null) {
markers.add(Marker(
markerId: const MarkerId('dynamic'),
position: const LatLng(37.422, -122.084),
icon: BitmapDescriptor.fromBytes(_markerBytes!),
anchor: const Offset(0.5, 0.5),
));
}
return Scaffold(
appBar: AppBar(title: const Text('Widget → Bitmap Marker')),
body: Stack(
children: [
GoogleMap(
initialCameraPosition: const CameraPosition(target: LatLng(37.422, -122.084), zoom: 14),
markers: markers,
),
// Offstage widget rendered once for capture
Offstage(
offstage: true,
child: RepaintBoundary(key: _markerKey, child: _buildMarkerWidget(12)),
),
],
),
);
}
}
Notes:
- Use a fixed logical size for the widget to control the final bitmap size.
- Adjust pixelRatio (e.g., 3.0) for sharp results on high‑density screens.
- Cache the bytes if you’ll reuse the same widget style with different labels.
SVG markers (crisp at multiple sizes)
If your design assets are SVG, rasterize them at runtime using flutter_svg.
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_svg/flutter_svg.dart' as svg;
Future<BitmapDescriptor> svgToBitmapDescriptor(
String asset,
{Size size = const Size(64, 64)},
) async {
final rawSvg = await rootBundle.loadString(asset);
final svgRoot = await svg.fromSvgString(rawSvg, rawSvg);
final picture = svgRoot.toPicture(size: size);
final image = await picture.toImage(size.width.toInt(), size.height.toInt());
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
}
Tip: If you recolor the SVG at runtime, ensure the SVG paths don’t have hardcoded fills, or programmatically override the color before rasterization.
Marker anchors and touch targets
- anchor: Offset(x, y) uses 0..1 where (0.5, 1.0) places the point at the bottom center.
- infoWindowAnchor controls where the info window attaches.
- Keep visual size ≥ 40–48 logical pixels for comfortable taps.
Marker(
markerId: const MarkerId('pin'),
position: const LatLng(37.4219, -122.0839),
icon: myIcon,
anchor: const Offset(0.5, 1.0),
infoWindowAnchor: const Offset(0.5, 0.0),
)
Custom info windows (fully styled overlays)
The built‑in InfoWindow is intentionally simple. For custom designs, overlay your own widget using a Stack and translate marker coordinates to screen points.
class CustomInfoWindowMap extends StatefulWidget {
const CustomInfoWindowMap({super.key});
@override
State<CustomInfoWindowMap> createState() => _CustomInfoWindowMapState();
}
class _CustomInfoWindowMapState extends State<CustomInfoWindowMap> {
GoogleMapController? _controller;
Offset? _overlayPos; // in logical pixels
LatLng? _selected;
Future<void> _showInfo(LatLng target) async {
if (_controller == null) return;
final screen = await _controller!.getScreenCoordinate(target);
// Convert platform pixels to Flutter logical pixels if needed
final dpr = MediaQuery.of(context).devicePixelRatio;
final logical = Offset(screen.x / dpr, screen.y / dpr);
setState(() {
_selected = target;
_overlayPos = logical;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: [
GoogleMap(
onMapCreated: (c) => _controller = c,
initialCameraPosition: const CameraPosition(target: LatLng(37.422, -122.084), zoom: 14),
markers: {
Marker(
markerId: const MarkerId('a'),
position: const LatLng(37.422, -122.084),
onTap: () => _showInfo(const LatLng(37.422, -122.084)),
consumeTapEvents: true,
),
},
onTap: (_) => setState(() => _overlayPos = null),
onCameraMove: (_) => setState(() => _overlayPos = null), // hide during moves
),
if (_overlayPos != null)
Positioned(
left: _overlayPos!.dx - 90,
top: _overlayPos!.dy - 150,
width: 180,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Cafe Luna', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 6),
const Text('Open • 0.2 mi'),
TextButton(onPressed: () {/* navigate */}, child: const Text('Directions')),
]),
),
),
),
]),
);
}
}
Notes:
- Recompute overlay position on camera movement/zoom.
- Consider adding a pointer/arrow graphic aligned with the marker’s anchor.
Clustering many markers
Maps with hundreds or thousands of markers benefit from clustering. A common approach in Flutter is using a clustering package that groups points by zoom level and provides cluster markers with counts.
Typical flow:
- Create a ClusterManager with your items (each item includes a LatLng and optional payload).
- Forward camera updates to the manager via onCameraMove and onCameraIdle.
- In the manager’s callback, update your marker Set with either cluster icons or individual pins.
Example (conceptual; adapt to your chosen package):
late final ClusterManager<MyItem> _clusterManager;
Set<Marker> _markers = {};
@override
void initState() {
super.initState();
_clusterManager = ClusterManager<MyItem>(
items,
_updateMarkers,
markerBuilder: _buildClusterMarker,
levels: const [1, 4, 8, 12, 16],
);
}
void _updateMarkers(Set<Marker> markers) => setState(() => _markers = markers);
Future<Marker> _buildClusterMarker(Cluster<MyItem> cluster) async {
if (cluster.isMultiple) {
final icon = await _buildClusterIcon(cluster.count); // widget→bytes or asset
return Marker(
markerId: MarkerId('cl_${cluster.getId()}'),
position: cluster.location,
icon: icon,
anchor: const Offset(0.5, 0.5),
);
}
final item = cluster.items.first;
return Marker(
markerId: MarkerId(item.id),
position: item.position,
icon: await myItemIcon(item),
anchor: const Offset(0.5, 1.0),
);
}
void _onCameraMove(CameraPosition pos) => _clusterManager.onCameraMove(pos);
void _onCameraIdle() => _clusterManager.updateMap();
Tips:
- Prebuild cluster icons for common counts (e.g., 10, 25, 50, 100+) to avoid repeated rasterization.
- Use zIndex so clusters sit below individual POIs.
Animating marker movement
The plugin doesn’t interpolate positions for you, but you can animate by updating a marker’s LatLng over time.
class MovingMarkerController {
final TickerProvider vsync;
late final AnimationController _ac;
late final Animation<double> _anim;
MovingMarkerController({required this.vsync, Duration duration = const Duration(seconds: 2)}) {
_ac = AnimationController(vsync: vsync, duration: duration);
_anim = CurvedAnimation(parent: _ac, curve: Curves.easeInOut);
}
LatLng _lerp(LatLng a, LatLng b, double t) => LatLng(
a.latitude + (b.latitude - a.latitude) * t,
a.longitude + (b.longitude - a.longitude) * t,
);
Future<void> animate({
required LatLng from,
required LatLng to,
required void Function(LatLng) onUpdate,
}) async {
_ac.addListener(() => onUpdate(_lerp(from, to, _anim.value)));
await _ac.forward(from: 0);
_ac.reset();
}
void dispose() => _ac.dispose();
}
Usage: each tick updates the marker’s position in your Set
Performance best practices
- Reuse BitmapDescriptor objects; keep them in a cache keyed by type/size/color.
- Avoid rebuilding the entire Set
on every minor state change. Compute a diff or structure your state to replace only the changed markers. - Debounce camera updates (e.g., clustering) until onCameraIdle.
- Precache images: precacheImage(AssetImage(‘assets/…’), context).
- Use smaller bitmaps where possible; a 64–96 px marker is often enough.
- For very large datasets, fetch progressively based on visibleRegion and zoom.
Theming and dark mode
- Provide light/dark versions of markers that contrast with the current map style.
- If you use a custom map style (JSON), test markers for contrast and recognizability.
Accessibility and UX
- Make markers large enough and spaced well for touch accuracy.
- Provide informative InfoWindows or custom overlays with clear labels.
- If essential, expose alternative list/detail views for screen‑reader users.
Troubleshooting
- Blank map: verify API key, that the correct SDKs are enabled, and that your SHA‑1/Bundle ID settings are correct.
- Asset markers not visible: check asset path in pubspec.yaml and that the anchor isn’t placing the icon offscreen.
- Blurry markers: raise pixelRatio or export assets at higher resolution.
- Mismatched overlay position: convert screen coordinates to logical pixels using devicePixelRatio as shown above.
Putting it all together
Below is a compact example that uses: an asset marker, a widget‑generated cluster icon, and a custom info window overlay.
class MapsDemo extends StatefulWidget {
const MapsDemo({super.key});
@override
State<MapsDemo> createState() => _MapsDemoState();
}
class _MapsDemoState extends State<MapsDemo> {
GoogleMapController? _controller;
final Set<Marker> _markers = {};
Uint8List? _clusterBytes;
BitmapDescriptor? _poiIcon;
Offset? _overlayPos;
@override
void initState() {
super.initState();
_initIcons();
WidgetsBinding.instance.addPostFrameCallback((_) => _makeClusterIcon());
}
Future<void> _initIcons() async {
_poiIcon = await BitmapDescriptor.fromAssetImage(
const ImageConfiguration(size: Size(64, 64), devicePixelRatio: 3.0),
'assets/markers/poi.png',
);
// Add a couple of POIs
setState(() {
_markers.addAll([
Marker(markerId: const MarkerId('p1'), position: const LatLng(37.422, -122.084), icon: _poiIcon!, onTap: () => _showInfo(const LatLng(37.422, -122.084))),
Marker(markerId: const MarkerId('p2'), position: const LatLng(37.424, -122.082), icon: _poiIcon!, onTap: () => _showInfo(const LatLng(37.424, -122.082))),
]);
});
}
Future<void> _makeClusterIcon() async {
final boundaryKey = GlobalKey();
final repaintBoundary = RepaintBoundary(
key: boundaryKey,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(color: Colors.indigo, shape: BoxShape.circle, boxShadow: const [BoxShadow(blurRadius: 6, color: Colors.black26)]),
alignment: Alignment.center,
child: const Text('12', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
),
);
// Mount offstage for one frame to capture
final overlay = OverlayEntry(builder: (_) => Offstage(offstage: true, child: Material(child: repaintBoundary)));
Overlay.of(context).insert(overlay);
await Future.delayed(const Duration(milliseconds: 16));
final boundary = boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
_clusterBytes = byteData!.buffer.asUint8List();
overlay.remove();
// Example cluster marker
setState(() {
_markers.add(Marker(
markerId: const MarkerId('cluster_1'),
position: const LatLng(37.423, -122.083),
icon: BitmapDescriptor.fromBytes(_clusterBytes!),
anchor: const Offset(0.5, 0.5),
));
});
}
Future<void> _showInfo(LatLng target) async {
if (_controller == null) return;
final sc = await _controller!.getScreenCoordinate(target);
final dpr = MediaQuery.of(context).devicePixelRatio;
setState(() => _overlayPos = Offset(sc.x / dpr, sc.y / dpr));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Custom Markers Demo')),
body: Stack(children: [
GoogleMap(
onMapCreated: (c) => _controller = c,
initialCameraPosition: const CameraPosition(target: LatLng(37.422, -122.084), zoom: 14),
markers: _markers,
onTap: (_) => setState(() => _overlayPos = null),
onCameraMove: (_) => setState(() => _overlayPos = null),
),
if (_overlayPos != null)
Positioned(
left: _overlayPos!.dx - 90,
top: _overlayPos!.dy - 150,
width: 180,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(mainAxisSize: MainAxisSize.min, children: const [
Text('Custom Info', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 6),
Text('Beautiful, branded content here.'),
]),
),
),
),
]),
);
}
}
Checklist for production
- Restrict your API key by package/bundle ID and optionally IP/address.
- Preload and cache marker icons.
- Provide dark/light variants and test contrast with your map style.
- Use clustering and visibleRegion‑based loading for large datasets.
- Profile on mid‑range devices; watch for jank when rasterizing widgets.
Conclusion
Custom markers transform a generic map into a branded, informative experience. Start with assets and tinted defaults, graduate to SVG and widget‑based bitmaps for dynamic UI, and scale with clustering and smart caching. With the patterns above, your Flutter maps will look great and stay fast across Android and iOS.
Related Posts
Flutter + Google Maps: A Complete Integration Guide
Integrate Google Maps in Flutter: setup keys, Android/iOS config, markers, directions, clustering, styling, and best practices.
Flutter PDF Form Filling: A Complete, Practical Tutorial
Learn how to load, fill, flatten, and save AcroForm PDFs in Flutter with code examples, best practices, and troubleshooting tips.
Flutter Geolocation Tracking App Tutorial: Maps, Routes, and Background Updates
Build a production-ready Flutter geolocation tracker with maps, live routes, distance, and background updates. Permissions, battery, and privacy included.