Flutter

Build a declarative map widget above the Mapbox flutter package mapbox_maps_flutter

Introduction

Have you ever tried to integrate Mapbox in one of your Flutter application ? When it comes to choose the package that will enable you showing and playing with a map from Mapbox in your app, you’ll probably think about using the officially developed one by the so-called company, which is mapbox_maps_flutter.

We recently chose to use it at BAM, and that’s not an obvious choice, since most of the articles you will find by typing “Mapbox Flutter” on Google don’t use this package. The reason we made that choice is that the popular package mapbox_gl has stopped to develop for a long time now, in favor of mapbox_maps_flutter.

So let’s say you also chose to use this package, you followed the installation step, and then you’re happy you have a map in your app. But when it comes to display annotations, set the camera position etc… you realize that the API is imperative, and you don’t like that. As a Flutter developer, you are probably more used to the declarative approach than the imperative one, and having to use a map controller and calling method to customize the map widget can easily produce code that will be hard to reuse or maintain.

The difference between imperative and declarative programming is that in the imperative approach we give explicit instructions, which means we tell how to achieve a task, versus in the declarative approach we tell what we want to achieve. In other words, in a declarative API, we write which result we want to obtain.This is what we do when declaring a widget, we pass values to its parameters that represent the expected result. In contrast, the map widget from mapbox_maps_flutter give us a map controller from which we can call methods that, for example, will describe how to add an annotation/marker on the map, but we can’t tell it directly what marker we want to display, and let him the responsibility of how to display it.

We recently faced this problem, and I will share with you how we managed to build a widget above ++code>MapWidget++/code> from mapbox_maps_flutter that has a declarative API and that allows us to easily reuse the map across the app.

The following implementation is inspired by the google_maps_flutter package that has a mixed API from which we can access a map controller and give imperative instructions but from which we can also pass a list of map elements we want to display like markers, polylines, etc… and that reacts to state updates to dynamically change what is shown on the map.

A simple map use case

We will take a basic example to go through the implementation. Here is what are the specifications we want our map widget to have :

  • We want a map that can display annotations given in entry.
  • Annotations will be blue or red circles.
  • I want to be able to filter the annotations on their color.

💡 An annotation (sometimes called a marker) is an element on a map that has a geographic position and displayed with a geometric shape or an image, for example.

A quick look at the Mapbox Flutter package

let’s dive into the package we want to use (mapbox_maps_flutter documentation), and see what we have to satisfy our specs.

We can specify a callback to ++code>MapWidget++/code> by implementing ++code>onMapCreated++/code>, this gives us a ++code>mapController++/code> object. With this we are able to create a circle annotation manager and call methods to create, update or delete circle annotations.

We have multiple methods to manage our annotations and here are the 3 basic ones :

  • ++code>CircleAnnotationOption++/code> : The options we can specify when creating an annotation (position, color, size etc…)
  • ++code>CircleAnnotation++/code>: The object created after creating an annotation. It has the same properties as ++code>CircleAnnotationOption++/code>, but with an extra id.

Using this widget directly can be painful when not used to this kind of imperative API.

A declarative API would have been more convenient, especially if we want to reuse a map widget at different places. A declarative API could be a widget to which we can simply give a list of ++code>CircleAnnotationOptions++/code>, and it would build the annotations on the map. Then, it would automatically update the annotations displayed on the map in reaction to a modification of the list in input.

A declarative API also has the advantage of abstracting some code we don’t want to write multiple time. If you noticed, the ++code>update++/code> and ++code>delete++/code> methods require the ++code>CircleAnnotation++/code> object, which is created by the ++code>create++/code> method. This means that we need to keep track of the annotations we create in order to manage them.A declarative widget of a map would do this job, then every user of this widget wouldn’t have to care about it.

Now, let’s build our declarative map.

Optimized implementation of our declarative map widget

For our widget to work, we will take advantage of the lifecycle of a stateful widget. Especially the ++code>didUpdateWidget++/code> method that triggers when the ++code>setState++/code> method is called in the actual widget or a parent widget.

If you need a reminder on how does the lifecycle of a stateful widget works, I highly recommend this article from Jelena Jovanoski.

Conception

Here is the big picture of what we want to have during build and update cycle for our widget called ++code>DeclarativeMap++/code> :

  1. On the first build, we render the ++code>MapWidget++/code> from ++code>mapbox_maps_flutter++/code> and from the ++code>mapController++/code> instance we create our annotations from the list we have in entry
  2. When the widget re-builds, ++code>onMapCreated++/code> is not called anymore, due to how ++code>MapWidget++/code> is implemented
  3. When ++code>didUpdateWidget++/code> is triggered, we want to compare the old annotations (++code>oldWidget.annotationOptionsList++/code>) with the new ones (++code>widget.annotationOptionsList++/code>). The result of this comparison returns 3 lists :
    1. The new annotations to add to the map
    2. The modified annotations to update on the map
    3. The deleted annotations to remove from the map
  4. To update the displayed annotations, we create 3 methods that will use ++code>mapbox_maps_flutter++/code> methods.

💡 The ++code>onMapCreated++/code> callback is trigger only at the first render of ++code>MapWidget++/code>. That is why we can initialize our annotations at this level, and this initialization won’t be executed after every new re-render.

Implement DeclarativeMap

Let’s initialize our widget :

What we’ve got here ?

  • We give in entry a list of ++code>CircleAnnotationOptions++/code> (notice that we use fast_immutable_collection to manipulate immutable lists).
  • In the ++code>onMapCreated++/code> callback, we :
    • Create the annotation manager
    • Store it in a variable to reuse it later
    • Initialize the annotations for the first time with the ++code>createMulti++/code> method.
  • Finally, we’ve got the beginning of the implementation of ++code>didUpdateWidget++/code>
    • A first precaution we take is to make sure we don’t do any operation if the old list of annotations and the new one are the same. We can do this easily with a simple ++code>==++/code> operator because we use ++code>IList++/code>.

React to state update by creating or deleting annotations

In our use case, we want to filter the blue and red dots on our map. For that, the parent widget of the ++code>DeclarativeMap++/code> will have to update the list of annotations it gives as an input.

The ++code>DeclarativeMap++/code> on his side, will have to detect the changes to know which annotations have been removed from the old list, and also to know which have been added. All other annotations will stay displayed on the map.

As said earlier, we will use the ++code>didUpdateWidget++/code> method to compare the two versions of the list of annotations, the one before the state update and the one after, since this method gives us the old version of the widget.

You will find the full implementation of the ++code>DeclarativeMap++/code> in this repository.

Added annotations

Detecting the new annotations to add on the map is quite straight forward. We need to check which annotations are in the new list but weren’t in the old one.The obstacle we get here is that ++code>CircleAnnotationOptions++/code> does not have a property “id”, it’s also the case for other types of annotations (points, polygon, etc…).We can then create a class ++code>ICircleAnnotationOptionsWithId++/code> that we will use instead of ++code>CircleAnnotationOptions++/code>, it will have two properties :

Now we can easily identify which annotation have been added by comparing the ids of both lists.

Removed annotations

To remove annotations we need to first compare ids to determine which annotations are not in the new list but were in the old one, as we would do for the added annotations.In a second time, we need to retrieve the ++code>CircleAnnotation++/code> that have been created from the corresponding options, because as seen earlier we need to pass the ++code>CircleAnnotation++/code> to the delete method.In order to do that, we will create a linker class that will link our options and the corresponding ++code>CircleAnnotation++/code> :

Now we need to keep track of a list of ++code>ICircleAnnotationLinker++/code> that will be a variable in the state of ++code>DeclarativeMap++/code>.

In ++code>DeclarativeMap++/code> we can then create a state variable to store our annotation linkers :

  • We created a ++code>addAnnotations++/code> method (check this commit to see the method implementation) that creates the annotations on the map and returns a list of linkers to be stored.
  • We can then update the state variable to store our linkers after the initial creation of the annotations on the map.

Now let’s implement ++code>didUpdateWidget++/code> to update our annotations on the map :

  • We created a method ++code>getChangesInAnnotationList++/code> that returns the list of annotations to add and the list of annotations to remove.
  • We pass these lists to a method ++code>setCircleAnnotations++/code> that will add and remove annotations from the map, and update the list of linkers accordingly.

Updated annotations

We did the create and delete methods to reflect the filter feature, but what about updating the annotations ?It’s not exactly in the scope of our initial specifications but let’s cover this part since it could be a common use case for a map (for example changing the color or size of an annotation when it’s tapped).

To detect the annotations that have been updated between the previous list and the new one, we need to compare both old and new ++code>CircleAnnotationOptions++/code>. For this, we can turn this class into a freezed class as a best practice, because we will do a  ++code>!=++/code> comparison.

Concerning the update method, it also needs to get the ++code>CircleAnnotation++/code> object in input but updated with the modified properties. To achieve this conveniently, we also turned the ++code>CircleAnnotation++/code> class into a freezed class, and we added to it a ++code>copyWithOptions++/code> method. It will return the ++code>CircleAnnotation++/code>, but with the options it was given. In other words, it merges the id of the annotation with the options it gets in entry.With this setup we can now, detect which annotations to update, update the annotation, get the annotation returned by the update method and update our annotation linker.

We update the method that detect changes in the annotation list :

We can now update the ++code>setCircleAnnotations++/code> method to take into account the updated annotations.

Let’s look at the ++code>updateAnnotations++/code> method :

Here we need to make sure of two things :

  • Update the annotation for Mapbox with the correct updated options. As said, we use a ++code>copyWithOptions++/code> custom method.
  • Return new linkers with the updated options and the updated annotation, this will enable us to update our list of linkers.

If you want to see the full commit, you can follow this link

We are now all set with the three methods we need : create, update and delete annotations. Our ++code>DeclarativeMap++/code> is ready to update its annotations for each re-render. It will do it in an optimized way, since it will manipulate the annotations only if needed.

Conclusion

What we’ve seen in this article is that by taking advantage of the lifecycle of a widget and writing some custom methods that mainly manipulate lists, you can get an easy-to-reuse-and-maintain map widget across your app.

It’s also a nice architecture to reduce the responsibilities of the map widget. It is not aware of any business logic, this logic will stay at parent level.

We are now able to see the advantage of having a declarative API for our map widget. We can pass to it the annotations/markers we want it to display, it will show it when it first builds, and it also reacts to state update to modify what is displayed. Furthermore, we can now easily reuse the ++code>DeclarativeMap++/code> widget, whereas if we used the ++code>MapWidget++/code> from the Mapbox package we would have to rewrite how to initialize and update the annotations we want to display by using the map controller.

If you want to get more explanations about declarative/functional programming, you can check these two videos :

I detailed a very basic example that only concerns ++code>CircleAnnotation++/code>. However, this pattern can be easily re-applied to other types of annotations (point, polyline, polygon…). Since all types of annotations are very similar, we could factorize this code to be agnostic and handle all kind of Mapbox elements.

It can also be added other features such as the “onTap” feature, meaning being able to pass an ++code>onTap++/code> callback to any annotations along with the options we already pass.

If you want to explore the full example and take it as a starter for your project, feel free to visit the repository : declarative_mapbox_map

On a personal point of view, implementing this pattern was a good way to understand the Flutter framework. Being able to see how to go from an imperative to a declarative API to fit the Flutter spirit. But also exploring the lifecycle of a widget and how a tree behaves between each re-render were all opportunities to learn.

Développeur mobile ?

Rejoins nos équipes