Drawing Google Maps Tiles with Flutter

24 Sep, 2021 | 10 minutes read

Google Maps is ubiquitous technology found on mobile and web that almost everyone has used one way or another. The obvious question for the curious mind would be: So, how does it work?

So, how does it work?

Most of you know that the earth’s shape resembles a sphere while most device screens are flat, maps are flat. The first task is to convert longitude and latitude coordinates which are coordinates/points on the earth’s sphere to flat coordinates on the map i.e., planar coordinates and vice versa: planar coordinates like a tap on the screen to longitude and latitude.

This is accomplished with the Mercator Projection.

Now that we have a way of translating/projecting from sphere to flat surface and vice versa, how do we show the entire world in a handheld device or any other device with limited resources? Another problem is the zoom, it needs to be supported.

The problem is solved with tiles. At zoom level 0, the world is shown with 2^0 * 2^0 tiles which are 1*1=1 tile, at zoom level 1, the world is shown with 2^1 * 2^1 tiles which are 2*2=4 tiles, meaning gird of tiles with 2 columns and 2 rows of tiles. Consequently, at zoom level n, the world is shown with 2^n * 2^n tiles.

This division of the world image in separate tiles allows for the map to be presented in great detail at any zoom level without the need for loading one huge image of the entire world, which would be impossible anyway.

Depending on the size of the viewport of the Google Maps widget, only a few image tiles are needed at any given moment. These tiles can be fetched from the Google servers or cashed locally, it depends on the internal implementation of the map.

As an experienced developer, you might ask, why is this important? That is because the Google Maps API allows us to plug into this tiling system and provide additional tiles and stack them on top of the original map tiles. These tiles can be fetched from your own server or provided directly by you, for example, they can be drawn in real-time as the map requests them. When a request is made for a tile, you are given a zoom level, ‘x’ and ‘y’ coordinate of the tile and that’s it.

Just to clarify, let’s say that map API says, the zoom level is 3, ’x’ is 0 and ‘y’ is 5, this means that the map of the world is divided in to a grid of 8 columns and 8 rows. I’m about to draw on the screen the tile on column 0 and row 5, where tile with (0, 0) coordinate is at the top left corner of the map, now give me a tile if you have one so I can stack it on top of my own.

Don’t panic you have time, the process of providing a tile is asynchronous, so the map won’t freeze until you provide one.

If you register yourself as a tile provider it is not mandatory to provide every tile, for some zoom levels or tile coordinates you might have nothing to show and it is ok, you just say no tile for you now.

It is important to note that the map cashes the tiles you provide, and you don’t have the option to push or replace a particular tile at runtime, all you can do is to clear the entire Tile Overlay cash so your tile-providing function can be called again.

Tile overlay, tile provider, what’s up with that? Well as it happens when you create the map widget, you give it a Set of TileOverlay objects and each of them carries a TileProvider object, meaning you can stack multiple tiles on top of each other.

That was the high-level overview of the process, let’s dive into the details and some code samples.

The details and some code

Before we dive in deeper, I suggest that you create a new Flutter project and install the google_maps_flutter plugin. Make sure to go through the Getting Started section of the plugin, for details on getting an API key and enabling Google Map SDK for each platform.

Now that you have all set up, let’s discuss the terminology for the 4 coordinate systems that map API uses and hopefully explain them a bit better.

Longitude and Latitude values

Both values together reference a unique point on the surface of the earth. They are measured in degrees just like angles, here the tip of the angle is at the center of the earth. Latitude’s range is from -90 to 90 degrees and describes how much a point is to the South/North. Longitude’s range is from -180 to 180 degrees, and you guessed it, tells us about the East-West direction.

World coordinates

When you apply the Mercator Projection on a LatLng point you will get a point on the map at zoom level 0. That is a world coordinate made up of 2 floating point values.

When you are doing the projection, you will need to specify the size of the map or the world as the user sees it. Recommended values are 256 x 256 pixels or 512 x 512 pixels.

But that is too small for a map of the world! Well, it is not, we were talking about the world at zoom level 0 which is just 1 tile, as you zoom in the number of these tiles grows exponentially. I repeat, exponentially, it’s like a lot.

Pixel coordinates

At zoom level 0 when the world is just 1 tile, Pixel and World coordinates are the same.

As you zoom in, at each next zoom level the size of the world doubles in both ‘x’ and ‘y’ directions i.e., it scales up.

So, when zoomed in to level 1, the world would consist of 4 tiles, but any world coordinate that we have would reference a point on the first tile only. That is because the world has scaled up, but our coordinates stayed the same.

The solution is simple, as you scale up the world, scale up the world coordinate by the same amount to get correct Pixel Coordinate on the map, formula:

pixelCoordinate = worldCoordinate * 2^zoomLevel

Tile coordinates

We talked a lot about tiles and their coordinates, nothing has changed so far.

But here it goes again: the world is presented as a grid of tiles, topmost leftmost tile has a coordinate value of (0, 0).

The code

With all this talk about coordinates and their different representations, one would expect to tackle that problem first, and we’ll do just that.

First, we shall create the MapVertex class, it shall hold the longitude, latitude and worldCoordinate representation of the point.

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

class MapVertex {
  final LatLng latLng;
  final Offset worldCoordinate;

  MapVertex(this.latLng, this.worldCoordinate);

LatLng as one might expect holds the latitude and longitude values, the world Coordinate Offset is a planar Point. It is called Offset and it holds two double precision floats that represent ‘x’ and ‘y’ but are named ‘dx’ and ‘dy’ where ‘d’ means delta because the thing is called Offset not a Point nor Vector.

In your application most probably you will get your coordinates as LatLng, for example, the user taps on the map, or probably from some custom backend. World coordinate shall be local value and it can be calculated only once because it will not change and it shall be used while drawing, so we don’t want to recalculate it many times unnecessarily.

The calculation shall be done during the creation of the object, and for good measure, we will provide both as factory constructors, MapVertex from LatLng and from world coordinate. 

import 'dart:math';

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

class MapVertex {
  final LatLng latLng;
  final Offset worldCoordinate;

  MapVertex(this.latLng, this.worldCoordinate);

  static const int tile_size = 256;
  static const _origin = tile_size / 2;
  static const double _pixelsPerLonDegree = tile_size / 360;
  static const double _pixelsPerLonRadian = tile_size / (2 * pi);

  factory MapVertex.fromLatLng(LatLng latLng) {
    double siny = sin((latLng.latitude * pi) / 180);
    siny = min(max(siny, -0.9999), 0.9999);
    double wX = tile_size * (0.5 + latLng.longitude / 360);
    double wY = tile_size * (0.5 - log((1 + siny) / (1 - siny)) / (4 * pi));
    return MapVertex(latLng, Offset(wX, wY));
  }

  factory MapVertex.fromWorldCoordinate(Offset worldCoordinate) {
    var lng = (worldCoordinate.dx - _origin) / _pixelsPerLonDegree;
    var latRadians = (worldCoordinate.dy - _origin) / -_pixelsPerLonRadian;
    var lat = (2 * atan(exp(latRadians)) - pi / 2) / (pi / 180);
    return MapVertex(LatLng(lat, lng), worldCoordinate);
  }
}

That was a good start, but we need to extend this object with more functionalities like getting the pixel coordinate and other stuff. These additional coordinates are dynamic, they depend on the zoom level, so they can’t be calculated only once, consequentially they will be implemented as functions. So let us create an extension and start extending, beginning with the pixel coordinate:

extension GMapExtensions on MapVertex {
  Offset pixelCoordinateForZoom(int zoom) {
    final int scale = 1 << zoom;
    return worldCoordinate * scale.toDouble();
  }

So far, we have covered 3 out of 4 coordinate systems, it’s time for the tile coordinate, in this context, for a particular zoom level to which tile does this point belongs:

  Point<int> tileCoordinateForZoom(int zoom) {
    final large = pixelCoordinateForZoom(zoom);
    return Point((large.dx / MapVertex.tile_size).floor(),
        (large.dy / MapVertex.tile_size).floor());
  }

We mentioned that we shall draw these points in the tile, so we need a point relative to the tile’s origin, not the world’s origin like the pixelCoordinate:

  Offset inTileCoordinate(int zoom) {
    final int scale = 1 << zoom;
    final wc = worldCoordinate * scale.toDouble();
    final int x = (wc.dx / MapVertex.tile_size).floor();
    final int y = (wc.dy / MapVertex.tile_size).floor();
    final dx = (wc.dx - MapVertex.tile_size * x);
    final dy = (wc.dy - MapVertex.tile_size * y);

    return Offset(dx, dy);
  }
  

Lastly, before we try to draw the point inside a tile, we need to know if the point belongs to it:

bool isInTile(int zoom, int x, int y) {
    Point<int> tile = tileCoordinateForZoom(zoom);
    return tile.x == x && tile.y == y;
  }
}

Drawing the Tiles

Now that we have dealt with modeling the coordinates, we are ready to start drawing tiles. This is accomplished by implementing the TileProvider interface:

class PointsTileProvider implements TileProvider {
  final List<MapVertex> verticies;
  final paint = Paint()..color = Colors.red;

  PointsTileProvider(this.verticies);

  @override
  Future<Tile> getTile(int x, int y, int? zoom) async {
    if (zoom == null) {
      return TileProvider.noTile;
    }
    final filteredVerticies = verticies.where((v) => v.isInTile(zoom, x, y));

    if (filteredVerticies.isNotEmpty) {
      final ui.PictureRecorder recorder = ui.PictureRecorder();
      final Canvas canvas = Canvas(recorder);
      for (var v in filteredVerticies) {
        canvas.drawCircle(
            v.inTileCoordinate(zoom), 3, paint);
      }
      final ui.Picture picture = recorder.endRecording();
      final Uint8List byteData = await picture
          .toImage(MapVertex.tile_size, MapVertex.tile_size)
          .then((ui.Image image) =>
              image.toByteData(format: ui.ImageByteFormat.png))
          .then((ByteData? byteData) => byteData!.buffer.asUint8List());
      return Tile(MapVertex.tile_size, MapVertex.tile_size, byteData);
    }
    return TileProvider.noTile;
  }
}

As you can see, we have created a PointsTileProvider which draws points on the tile. There is only one method to be implemented and that is the getTile which is called with parameters: x and y coordinates of the tile and map’s zoom level which is an optional value.

It’s quite strange that zoom is optional since this value together with the coordinates is essential in identifying the tile in question. That is why at the beginning of the function first we check to see if we have value for zoom, if not then we don’t return a tile.

Next, we filter out the vertices that don’t belong to this tile, if the resulting list is not empty, we draw each vertex as a small circle on the tile.

It might be interesting for you to see tile borders and their coordinates on the map, for example for debugging purposes, to do so you might implement yet another TileProvider, I did, and I named it DebugTileProvider. Here is its getTile function:

@override
  Future<Tile> getTile(int x, int y, int? zoom) async {
    if (zoom == null) {
      return TileProvider.noTile;
    }

    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder);
    final TextSpan textSpan = TextSpan(
      text: 'x=$x, y=$y',
      style: textStyle,
    );
    final TextPainter textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout(
      minWidth: 0.0,
      maxWidth: MapVertex.tile_size.toDouble(),
    );

    textPainter.paint(canvas, offset);
    canvas.drawRect(
        Rect.fromLTRB(0, 0, MapVertex.tile_size.toDouble(),
            MapVertex.tile_size.toDouble()),
        boxPaint);
    final ui.Picture picture = recorder.endRecording();
    final Uint8List byteData = await picture
        .toImage(MapVertex.tile_size, MapVertex.tile_size)
        .then((ui.Image image) =>
            image.toByteData(format: ui.ImageByteFormat.png))
        .then((ByteData? byteData) => byteData!.buffer.asUint8List());
    return Tile(MapVertex.tile_size, MapVertex.tile_size, byteData);
  }

Putting it all together

We have two tile providers, let us see if they’ll do their job right. Following is the code for the MapTilesPage widget which holds the actual GoogleMap widget:

class MapTilesPage extends StatefulWidget {
  final bool debug;

  const MapTilesPage({Key? key, this.debug = false}) : super(key: key);
  @override
  State<MapTilesPage> createState() => _MapTilesPageState();
}

class _MapTilesPageState extends State<MapTilesPage> {
  final _mapVertex = MapVertex.fromLatLng(LatLng(41.023812, 21.341795));
  final _overlays = Set<TileOverlay>();

  @override
  void initState() {
    if (widget.debug) {
      _overlays.add(TileOverlay(
        tileOverlayId: TileOverlayId('tile_overlay_0'),
        tileProvider: DebugTileProvider(),
      ));
    }
    _overlays.add(TileOverlay(
      tileOverlayId: TileOverlayId('tile_overlay_1'),
      tileProvider: PointsTileProvider([_mapVertex]),
    ));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GoogleMap(
        initialCameraPosition: CameraPosition(
          target: _mapVertex.latLng,
          zoom: 7.0,
        ),
        tileOverlays: _overlays,
      ),
    );
  }
}

We are drawing only one point on the map and the map is centered at that location. We can construct the page widget with debug flag set to true, which will add the DebugTileProvider as a TileOverlay to the map.

Finally, the main method:

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Google Maps Demo',
      home: const MapTilesPage(debug: true,),
    );
  }
}

That is all, now you know how to draw a point on the map with Flutter SDK at the exact location that you choose. Armed with this knowledge and the canvas at your disposal, amazing things are possible.