Add A Custom Info Window to your Google Map Pins in Flutter

Roman Jaquez
Flutter Community
Published in
11 min readDec 2, 2019

In a simple, elegant, and yet non-intrusive way, you can customize the way you display information upon tapping on your Google Map Pins.

Dependencies: Google Flutter Maps package

Note: This post is a follow-up post on previous posts on how to add custom markers to your Google Maps and about adding route lines to Google Maps. This post also assumes you’ve set up your Flutter project using the Google Flutter Maps package. Github project for this tutorial here.

A more modern take on the Google Map’s marker info window

If you want to move past the retro look below on how to display Google Map’s contextual information related to a marker, and move into a more modern look like the one above, then read on ;).

Note: Throughout this post, I’ll be referring to this pin info window as a “pin info pill” due to its shape.

Let’s code!

Define the Layout Structure

I love how in Flutter you own every pixel drawn to the screen, therefore you can personalize how you show information from a Google Maps pin upon taping on it by creating your own widgets — and the best thing is that it’s so simple! This is what we’ll be building:

This is how I see the Flutter widget hierarchy as I build my apps.
I’ll be customizing the way a Google Map’s marker info window looks like upon tapping on a marker.

Pretty much this is how I use to help myself visualize the Flutter widget layout structure of this custom pin info pill I’ll be building — either a tree structure or a layered structure. Feel free to use whichever approach works best for you.

Start by making sure your dependencies and imports are properly fulfilled, and setting up some constants upfront for using throughout my app:

import ‘package:flutter/material.dart’;
import ‘package:google_maps_flutter/google_maps_flutter.dart’;
import ‘dart:async’;
const double CAMERA_ZOOM = 13;
const double CAMERA_TILT = 0;
const double CAMERA_BEARING = 30;
const LatLng SOURCE_LOCATION = LatLng(42.7477863, -71.1699932);
const LatLng DEST_LOCATION = LatLng(42.6871386, -71.2143403);

I created a model class that will hold the required metadata to supply the information pill with data. I need to display the picture of the user, the custom pin image, location information, the name of the location and for further customization, a label color (to match it up with the pin and give it a nice touch). I placed it right above my StatefulWidget for easy access (and for the sake of this demo — see all the way at the bottom for a custom widget implementation and how I extracted this into a separate file).

class PinInformation {
String pinPath;
String avatarPath;
LatLng location;
String locationName;
Color labelColor;
PinInformation({
this.pinPath,
this.avatarPath,
this.location,
this.locationName,
this.labelColor});
}
class MapPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => MapPageState();
}
class MapPageState extends State<MapPage> { ...(rest of the code shown will be here)...}

Proceed to set up some properties you’ll be using within your State class for handling your markers, and for holding the information about your custom markers.

Completer<GoogleMapController> _controller = Completer();
Set<Marker> _markers = {};
BitmapDescriptor sourceIcon;
BitmapDescriptor destinationIcon;
double pinPillPosition = -100;PinInformation currentlySelectedPin = PinInformation(
pinPath: ‘’,
avatarPath: ‘’,
location: LatLng(0, 0),
locationName: ‘’,
labelColor: Colors.grey);
PinInformation sourcePinInfo;
PinInformation destinationPinInfo;

Notice a property called pinPillPosition with a value of -100. This is me stating that the initial position of the pin information pill should be placed off the screen. Properties sourcePinInfo and destinationPinInfo will hold the values of the source and destination pins respectively, and once either one of them is tapped by the user, it should become the currentlySelectedPin.

I proceed to override the initState() method for my State class, and initially set up my custom marker icons.

@override
void initState() {
super.initState();
setSourceAndDestinationIcons();
}
void setSourceAndDestinationIcons() async {
sourceIcon = await BitmapDescriptor.fromAssetImage(
ImageConfiguration(devicePixelRatio: 2.5),
‘assets/driving_pin.png’);
destinationIcon = await BitmapDescriptor.fromAssetImage(
ImageConfiguration(devicePixelRatio: 2.5),
‘assets/destination_map_marker.png’);
}

Lay down the Widgets

Now, to the main part: overriding the build method, where I’ll be building the structure of this application.

Initiate by setting the default CameraPosition:

@override
Widget build(BuildContext context) {
CameraPosition initialLocation = CameraPosition(
zoom: CAMERA_ZOOM,
bearing: CAMERA_BEARING,
tilt: CAMERA_TILT,
target: SOURCE_LOCATION
);
}

Next, set up the root of this widget to be a Scaffold widget, since a Stack is not an appropriate root widget when building the page. Then I decided to add the Stack widget as the body of the Scaffold. The effect I’m trying to achieve is for my pin info pill to overlay the map, so a Stack widget works best in this situation.

I handle the onMapCreated event of the GoogleMap separately (we’ll get to that later) but the most important part of this section is allowing the user to tap anywhere on the map so they can dismiss the pin info pill, which I do by handling the onTap event on the GoogleMap widget. Inside of this handler, I change the value of pinPillPosition (which will drive the animation of the pin info pill widget) inside a setState event, so it triggers the rebuilding of the widget and dismisses the pin info pill.

@override
Widget build(BuildContext context) {
...return Scaffold(
body: Stack(
children: <Widget>[
GoogleMap(
myLocationEnabled: true,
compassEnabled: true,
tiltGesturesEnabled: false,
markers: _markers,
mapType: MapType.normal,
initialCameraPosition: initialLocation,
onMapCreated: onMapCreated,
// handle the tapping on the map
// to dismiss the info pill by
// resetting its position
onTap: (LatLng location) {
setState(() {
pinPillPosition = -100;
});
},

),
AnimatedPositioned(...)
] // end of Widget
) // end of Stack
); // end of Scaffold
} // end of build method

Now, let’s fill in the AnimatedPositionedWidget. This widget is nothing more than the animated version of the Positioned widget, and of this kind there are several convenient ones, referred to as implicitly animated widgets. We’re only interested in animating the bottom position for 200 milliseconds, in and out of the screen, driven by the property pinPillPosition. When this value changes (between 0 and -100), this will trigger the animation of sliding in and out of view.

Follow this link for more implicitly animated widgets.

The child of the AnimatedPositioned widget is an Align widget, which wraps one of the most important children in this hierarchy — the Container child, which will give the rounded edges and white background to our pin info pill, as well as shadows for a depth effect.

...(rest of the code omitted for brevity)...// wrap your component inside an AnimatedPosition widget
// to animate its position (driven by pinPillPosition value)
AnimatedPositioned(
bottom: pinPillPosition, right: 0, left: 0,
duration: Duration(milliseconds: 200),
// wrap it inside an Alignment widget to force it to be
// aligned at the bottom of the screen
child: Align(
alignment: Alignment.bottomCenter,
// wrap it inside a Container so we can provide the
// background white and rounded corners
// and nice breathing room with margins, a fixed height
// and a nice subtle shadow for a depth effect
child: Container(
margin: EdgeInsets.all(20),
height: 70,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(50)),
boxShadow: <BoxShadow>[
BoxShadow(
blurRadius: 20,
offset: Offset.zero,
color: Colors.grey.withOpacity(0.5)
)]
),
child: Row(...)
) // end of Container
) // end of Align
) // end of AnimatedPositioned
] // end of Stack Widget
); // end of Scaffold
} // end of build method

Let’s proceed to fill the Row widget. Inside of a Container widget, we want to place items horizontally, like so:

Therefore, a Row widget serves this purpose well.

...(rest of the code omitted for brevity)...// make the Container’s child a Row widget
// which will hold the three elements horizontally:
// the avatar image, the text and the pin image
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(...), // first widget
Expanded(...), // second widget
Padding(...) // third widget
]
...

Let’s populate the first of the Row widget children: a Container, which is providing a slight left margin to its children and force their width. In this case, its child is a ClipOval, which is cropping its corresponding child widget (an Image widget) into a full circle. Notice how regardless of the size of the image, I make it fill the Image widget using a fit: BoxFit.cover (since the parent Container widget is forcing it to be 50x50).

Notice the first time i’m using the local property currentlySelectedPin and its corresponding property avatarPath which holds an image— once I tap on a pin, this property should get the corresponding pin information assigned to it, and thus displaying the image associated with the avatarPath property once Flutter detects the pinPillPosition property changed, triggering the chain of events that will display the information in the pin info pill.

...(rest of the code omitted for brevity)...// first child: a Container, so we can provide a left
// margin to the leftmost child
Container(
margin: EdgeInsets.only(left: 10),
width: 50, height: 50,
// this Container’s child will be a ClipOval,
// which in turn contains an Image as a child.
// A ClipOval is used so it can crop
// the image into a circle
child: ClipOval(
child:
Image.asset(
currentlySelectedPin.avatarPath,
fit: BoxFit.cover)
)
),
...

The second widget is an Expanded widget. I picked an Expanded widget as it is a convenient widget to use within a Column so it forces one of its cells to stretch to the utmost size it can get in relation to its siblings — this widget will stretch to occupy the whole middle space within this Row widget, and will push the first and third child widgets to the edges, but without squishing them — only until their respective widths are reached.

This widget has a Container as a child so I can add a slight left margin, and in turn this holds a Column widget — I’m using Text widgets to show the corresponding labels of the pin info pill and I want them laid out vertically.

Also notice how I’m using the currentlySelectedPin property which holds the rest of the pin info pill information I’m interested in showing to the user:

...(rest of the code omitted for brevity)...Expanded(
child: Container(
margin: EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
currentlySelectedPin.locationName,
style: TextStyle(
color: currentlySelectedPin.labelColor
)
),
Text(‘Latitude: ${currentlySelectedPin.location.latitude.toString()}’,
style: TextStyle(
fontSize: 12,
color: Colors.grey
)
),
Text(‘Longitude: ${currentlySelectedPin.location.longitude.toString()}’,
style: TextStyle(
fontSize: 12,
color: Colors.grey
)
)], // end of Column Widgets
), // end of Column
), // end of Container
), // end of Expanded

The third and last widget is a Padding widget, whose child is nothing more than an Image widget that will show the corresponding custom pin image depending on which one I press. The Padding widget is a simple widget to supply padding to the image (15px of padding all around it). Also notice how I use the currentlySelectedPin object and its child property pinPath to supply the Image with the corresponding image path to the pin.

...(rest of the code omitted for brevity)...Padding(
padding: EdgeInsets.all(15),
child: Image.asset(
currentlySelectedPin.pinPath,
width: 50, height: 50)
) // end of Padding
], // end of Row Widgets
), // end of Row
), // end of Container
), // end of Align
) // end of AnimatedPositioned
] // end of Stack Widget
) // end of Stack
); // end of Scaffold
} // end of build method

Handle the Events

Sweet! Once all my widgets are laid out, I proceed to handle the required events: the onMapCreated, which will trigger once the map completes its creation; this method places the pins on the map by calling a method called setMapPins(); then each corresponding pin’s onTap event is handled within this method, as well as handling the populating of the sourcePinInfo and destinationInfo objects. Once each corresponding pin is tapped by the user, the currentlySelectedPin object is assigned either the source or destination pin information, and the pinPillPosition variable is set to 0, which equates to moving the pin info pill widget inside the screen. All this happens inside a setState event, which will notify Flutter that a change happened and thus trigger a build of its widgets and reflect the new state:

void onMapCreated(GoogleMapController controller) {
_controller.complete(controller);
setMapPins();
}
void setMapPins() {
// add the source marker to the list of markers
_markers.add(Marker(
marker.markerId: MarkerId(‘sourcePin’),
position: SOURCE_LOCATION,
onTap: () {
setState(() {
currentlySelectedPin = sourcePinInfo;
pinPillPosition = 0;
});
}
,
icon: sourceIcon
));
// populate the sourcePinInfo object
sourcePinInfo = PinInformation(
locationName: “Start Location”,
location: SOURCE_LOCATION,
pinPath: “assets/driving_pin.png”,
avatarPath: “assets/friend1.jpg”,
labelColor: Colors.blueAccent
);
// add the destination marker to the list of markers
_markers.add(Marker(
marker.markerId: MarkerId(‘destPin’),
position: DEST_LOCATION,
onTap: () {
setState(() {
currentlySelectedPin = destinationPinInfo;
pinPillPosition = 0;
});
}
,
icon: destinationIcon
));
destinationPinInfo = PinInformation(
locationName: “End Location”,
location: DEST_LOCATION,
pinPath: “assets/destination_map_marker.png”,
avatarPath: “assets/friend2.jpg”,
labelColor: Colors.purple
);
}

Now, the application behaves as we want it to: tapping on either source or destination pill will slide the pin info pill up from underneath the screen and show the appropriate information; tapping anywhere on the map should dismiss the pill by setting its position off the screen with a slide-out animation. Tapping on either pin while the pin info pill is still showing should just swap out the information right before your eyes, but still leaves it on the screen. Clean and simple!

Note: Notice my map has the grayscale styles instead of the default Google Maps colors. I followed this tutorial to accomplish that grayscale look.

Bonus: Extract the Pin Info Pill into its own Widget

Making this component into a separate widget would be a lot more efficient for reusability and encapsulation! I made this by moving the PinInformation class into its own separate file, extracted the whole AnimatedPositioned widget and its children into a separate file which I called MapPinPillComponent and made into a StatefulWidget. I created two internal properties (pinPillPosition and currentlySelectedPin) and I pass them as arguments to this custom widget’s constructor, and access the values by accessing the StatefulWidget’s widget property within this component’s State class.

This is how this new custom widget looks like:

import ‘package:flutter/material.dart’;
import ‘package:app/models/pin_pill_info.dart’;
class MapPinPillComponent extends StatefulWidget {
double pinPillPosition;
PinInformation currentlySelectedPin;
MapPinPillComponent({
this.pinPillPosition,
this.currentlySelectedPin
});
@override
State<StatefulWidget> createState() =>
MapPinPillComponentState();
}
class MapPinPillComponentState extends State<MapPinPillComponent> { @override
Widget build(BuildContext context) {
return AnimatedPositioned(
bottom: widget.pinPillPosition,
... (rest of the code omitted)...
);
}
}

Now my MapPageState’s build method looks like this now after replacing it with my custom pin info pill widget called MapPinPillComponent:

...(rest of the code omitted for brevity)...return Scaffold(
body: Stack(
children: <Widget>[
GoogleMap(
myLocationEnabled: true,
compassEnabled: true,
tiltGesturesEnabled: false,
markers: _markers,
mapType: MapType.normal,
initialCameraPosition: initialLocation,
onMapCreated: onMapCreated,
onTap: (LatLng location) {
setState(() {
pinPillPosition = -100;
});
},
),
MapPinPillComponent(
pinPillPosition: pinPillPosition,
currentlySelectedPin: currentlySelectedPin
)

]
)
);
...

I hope this post has inspired you not to settle for less and customize your marker’s information and layout to your heart’s content, making your location-based applications stand out from the rest!

Please find this same sample project on the following Github Repo for your reference.

Happy Fluttering!

--

--

Roman Jaquez
Flutter Community

Flutter GDE / GDG Lawrence Lead Organizer / Follow me on Twitter @drcoderz — Subscribe to my YouTube Channel https://tinyurl.com/romanjustcodes