Maps with Flutter: Custom and dynamic popup over the marker

Loredana Petrea
Nov 12 · 7 min read

Hello, Flutter developer! In the mood for a riddle? I'll give you three clues: EXPLORE, MOVE, CONNECT. Do you have any idea what this is about? 🤔

In this article, we will explore the world from our Flutter application using the Mapbox plugin to personalize our map. To integrate the map into the app we will use the flutter_map library, proving how easy it is to add a marker and to attach a popup to a specific marker.

There are many other tutorials that help you integrate a map into your Flutter app, but this one will use the flutter_map library. What makes this article useful is that it proves how easy you can add a custom popup over a specific marker, a problem that created difficulties for many developers that use this library instead of google_maps_flutter. See https://github.com/johnpryan/flutter_map/issues/184 for more details.

Let’s start coding!

We will build the screen from the images above by following the next steps.

Step 1. Before creating a project, we need to customize our map using Mapbox Studio as shown in the images below. To do that, go to Mapbox, create an account or sign in if you already have one.

Mapbox

Once authenticated, go to Products -> Maps -> Get started and then select New style. Now, use your creativity. After you created a beautiful map, go to Share , choose Third party and copy the integration URL. You will use it later in the app.

Step 2. Create a Flutter project using your favorite IDE or the terminal.

flutter create custom_map

Step 3. Add the following dependencies inside thepubspec.yaml file and then run flutter packages get command in your terminal.

dependencies:
...
flutter_map: ^0.7.0+1
video_player: ^0.10.1+5
location: ^2.3.5
  • flutter_map is used to integrate the map into our app;

Step 4. Create a folder called assets in the root directory and put the necessary assets that you can download from Github inside it. Group them by categories (images, video) and then add their dependencies in the pubspec.yaml file as shown below. Don't forget to run flutter packages get.

assets:
- assets/images/ic_marker.png
- assets/images/ic_info_window.png
- assets/images/ic_blurred_gray_circle.png
- assets/video/bike_acrobatics.mp4

Step 5. Add the following lines of code inside main.dart

import 'package:flutter/material.dart';
import 'home_screen.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Custom map',
home: HomeScreen(),
);
}
}

Step 6. Inside thelib folder, create a new home_screen.dart file and build the HomeScreen class, a stateful widget that describes the user interface.

import 'package:flutter/material.dart';class HomeScreen extends StatefulWidget {
@override
State<StatefulWidget> createState() => HomeScreenState();
}
class HomeScreenState extends State<HomeScreen> {@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color.fromRGBO(28, 35, 38, 100),
body: _buildMap(),
appBar: _buildAppBar(),
);
}
}

Step 7. Implement the buildAppBar()method which is responsible for creating the AppBar widget with the MAP title.

AppBar _buildAppBar() {
return AppBar(
backgroundColor: Colors.white,
title: Text(
"Map".toUpperCase(),
style: TextStyle(fontSize: 19.0, color: Colors.black87),
),
automaticallyImplyLeading: false,
centerTitle: true,
);
}

Step 8. Implement the buildMap() method. As its name suggests, this method creates a FlutterMap that holds the custom map built using the Mapbox Studio.

LatLng _center = LatLng(40.762681, -73.832611);FlutterMap _buildMap() {
return FlutterMap(
options: new MapOptions(
minZoom: 15.0,
center: _center,
interactive: true,
),
layers: [
new TileLayerOptions(
urlTemplate:
'PASTE YOUR INTEGRATION URL HERE',
additionalOptions: {
'accessToken':
'PASTE YOUR ACCESS TOKEN HERE',
'id': 'PASTE YOUR MAPBOX ID HERE'
}),
new MarkerLayerOptions(markers: _buildMarkersOnMap()),
]);
}

The MapOptions widget is used to configure the map. We will set the minZoom property, the center of the map and the interactive parameter that allows the user to interact with the map. The TileLayerOptions widget defines the structure to create the URL for tiles and to add additional options such as access token and custom map id. The last line is responsible for adding the marker at a specific position (40.762681, -73.832611).

List<Marker> _buildMarkersOnMap() {
List<Marker> markers = List<Marker>();
var marker = new Marker(
point: _center,
width: 279.0,
height: 256.0,
builder: (context) => _buildCustomMarker(),
);
markers.add(marker);
return markers;
}

Step 9. Add the _buildCustomMarker() method. It returns a Stack with two children, the marker and the popup. We juggle with the visibility of these widgets when they are tapped using the boolean variable infoWindowVisible.

var infoWindowVisible = false;
GlobalKey<State> key = new GlobalKey();
Stack _buildCustomMarker() {
return Stack(
children: <Widget>[
popup(),
marker()
],
);
}
Opacity popup() {
return Opacity(
opacity: infoWindowVisible ? 1.0 : 0.0,
child: Container(
alignment: Alignment.bottomCenter,
width: 279.0,
height: 256.0,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage("assets/images/ic_info_window.png"),
fit: BoxFit.cover)),
child: CustomPopup(key: key),
),
);
}
Opacity marker() {
return Opacity(
child: Container(
alignment: Alignment.bottomCenter,
child: Image.asset(
'assets/images/ic_marker.png',
width: 49,
height: 65,
)),
opacity: infoWindowVisible ? 0.0 : 1.0,
);
}

Maybe you're wondering why am I creating a CustomPopup instance with a GlobalKey parameter? 🤔 The reason is that when we will tap the popup, it will be invisible and we should pause the video. Using the global key, we can access the controller inside the CustomPopup responsible for playing/pausing the video.

Step 10. Make the popup and the marker widgets tappable by wrapping them in a GestureDetector widget. To avoid duplicate code, we will update the builder parameter of the Marker widget inside the _buildMarkersOnMap() method as shown below:

builder: (context) => GestureDetector(
onTap: () {
setState(() {
if (key.currentState != null &&
(key.currentState as CustomPopupState).controller !=
null &&
(key.currentState as CustomPopupState)
.controller
.value !=
null &&
(key.currentState as CustomPopupState)
.controller
.value
.isPlaying) {
(key.currentState as CustomPopupState)
.controller
.pause();
(key.currentState as CustomPopupState).playerIcon =
Icons.play_arrow;
}
infoWindowVisible = !infoWindowVisible;
});
},
child: _buildCustomMarker()),

Whenever the marker or the popup is tapped, we check if the video is playing and we pause it.

Step 11. Build the custom popup inside a new dart file called custom_popup.dart.

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class CustomPopup extends StatefulWidget {
static CustomPopupState of(BuildContext context) =>
context.ancestorStateOfType(const TypeMatcher<CustomPopupState>());
CustomPopup({Key key}) : super(key: key);@override
State<StatefulWidget> createState() {
return CustomPopupState();
}
}
class CustomPopupState extends State<CustomPopup> {
Future<void> _initializeVideoPlayerFuture;
VideoPlayerController controller;
IconData playerIcon = Icons.play_arrow;
@override
void initState() {
super.initState();
controller = VideoPlayerController.asset(
'assets/video/bike_acrobatics.mp4',
);
_initializeVideoPlayerFuture = controller.initialize();

controller.setLooping(true);
}
@override
Widget build(BuildContext context) {
return _buildDialogContent();
}
Container _buildDialogContent() {
return Container(
padding: EdgeInsets.all(5.0),
width: 279.0,
height: 256.0,
child: Stack(
children: <Widget>[
_buildVideoContainer(),
Container(
margin: const EdgeInsets.only(top: 159.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildAvatar(),
_buildNameAndLocation(),
],
),
),
],
),
);
}
Widget _buildVideoContainer() {
return Container(
color: Colors.white,
height: 172.0,
child: Stack(
children: <Widget>[
FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
return snapshot.connectionState == ConnectionState.done ? VideoPlayer(controller) : Center(child: CircularProgressIndicator());
},
),
GestureDetector(
onTap: () {
setState(() {
if (controller.value.isPlaying) {
controller.pause();
playerIcon = Icons.play_arrow;
} else {
controller.play();
playerIcon = Icons.pause;
}
});
},
child: Stack(
children: <Widget>[
Center(
child: Image.asset('assets/images/ic_blurred_gray_circle.png'),
),
Center(
child: Container(
child: Icon(
playerIcon,
color: Color.fromRGBO(34, 43, 47, 100),
),
),
)
],
),
)
],
),
);
}
Container _buildAvatar() {
return new Container(
child: CircleAvatar(
backgroundImage: new NetworkImage("https://i.imgur.com/BoN9kdC.png"),
),
width: 55.0,
height: 55.0,
padding: const EdgeInsets.all(2.0),
decoration: new BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
));
}
Expanded _buildNameAndLocation() {
return Expanded(
child: Container(
margin: const EdgeInsets.only(left: 6.0, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(child: Text('Johnatan Lawrence')),
Text('1')
],
),
Row(
children: <Widget>[
Icon(
Icons.location_on,
color: Color.fromRGBO(102, 122, 133, 100),
size: 13.0,
),
Text("Blue Lake Park"),
Expanded(
child: Text(
'6',
textAlign: TextAlign.end,
),
)
],
),
],
),
),
);
}
}

As you can see inside the initState()method, the VideoPlayerController object is initialized and the Future is stored for later use in the _buildVideoContainer() method. This method builds the video at the top of the popup using a FutureBuilder widget. We use this widget because it assures that the VideoPlayer is instantiated only after the video player controller object is initialized. The snapshot parameter returns the connection state and, if the connection is done successfully, we build the VideoPlayer object. Otherwise, a CircularProgressIndicator is shown. Do not forget to call dispose() on the video controller when the CustomPopup is removed from the tree permanently.

@override
void dispose() {
super.dispose();
controller.dispose();
}

Step 12. Before running the project, you have to make sure that your gradle.properties file contains this to have everything working with AndroidX:

org.gradle.jvmargs=-Xmx1536M
android.enableJetifier=true
android.useAndroidX=true

Step 13. Run the app on your device or emulator and enjoy the result.

That’s it! The full code is available on Github. For any suggestions or questions leave me a comment below and if this article helped you, give it a clap.

https://www.zipperstudios.co

Zipper Studios is a group of passionate engineers helping startups and well-established companies build their mobile products. Our clients are leaders in the fields of health and fitness, AI, and Machine Learning. We love to talk to likeminded people who want to innovate in the world of mobile so drop us a line here.

Zipper Studios

At Zipper Studios we help startups and well established companies build their mobile products. (www.zipperstudios.co)

Loredana Petrea

Written by

Android developer at Zipper Studios

Zipper Studios

At Zipper Studios we help startups and well established companies build their mobile products. (www.zipperstudios.co)

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade