Google maps with custom paint in Flutter

4 minutes read

This is a continuation of the Google maps and geolocation with Flutter post, where we created an app that would display a google map that moved with your current location. We also added a map marker at our start position.

Today we will add the ability to draw on top of the map using Flutters CustomPaint. If you only want to draw lines, circles and polygons this would be overkill and you should use the draw functions in the GoogleMap class. It works much like adding markers and you can look at many good examples over at the google_maps_flutter plugin’s example library.

However, I would like to be able to draw lines, shapes and even images or animations. For this purpose the CustomPaint widget is perfect. What we do is to simply add the GoogleMap widget in a Stack with a CustomPaint widget.

Widget build(BuildContext context) {
  return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(
        children: <Widget>[
          _position == null
              ? Text('Waiting for location . .')
              : GoogleMap(
                  liteModeEnabled: false,
                  compassEnabled: true,
                  mapType: MapType.hybrid,
                  markers: Set<Marker>.of(markers.values),
                  initialCameraPosition: CameraPosition(
                    target: LatLng(_position.latitude, _position.longitude),
                    zoom: 17,
                    tilt: 0,
                  ),
                  onMapCreated: (GoogleMapController controller) {
                    _mapController = controller;
                    // trigger initial update and paint of landmarks
                    _updateLandmarks();
                  },
                  onCameraMove: (CameraPosition position) {
                    _updateLandmarks();
                  },
                ),
          CustomPaint(
            foregroundPainter: MapPainter(_landmarksMap),
          ),
        ],
      ));
}

We create our own MapPainter class which extends CustomPainter and that is it, now we only have to decide what to paint. I want to mark custom landmarks on the map, lets start easy and draw a circle to show where the landmarks are and later replace them with images. We override the paint(Canvas canvas, Size size) method and loop through our objects we want to paint, in this case landmarks, we will take a detailed look at landmarks later.

class MapPainter extends CustomPainter {

  final Map<String, MapLandmark> _landmarksMap;
  Paint p;
  bool _debugPaint = false;

  MapPainter(this._landmarksMap) {

    p = new Paint()
      ..strokeWidth = 5.0
      ..color = Colors.orange
      ..style = PaintingStyle.stroke;

  }

  @override
  void paint(Canvas canvas, Size size) {

    if (_landmarksMap != null && _landmarksMap.length > 0) {
      _landmarksMap.forEach((id, value) {
        canvas.drawCircle(Offset(value.screenPoint.x.toDouble(), value.screenPoint.y.toDouble()), 8, p);
      });
    } else {
      canvas.drawCircle(Offset(70, 70), 25, p);
    }
  } //paint()

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

You can draw lines, circles, images, arcs, vertices and many other thing using the canvas but we keep it simple and draw a circle with canvas.drawCircle(...)

Landmarks are objects that can hold the information you need, but as a bare minimum it should hold the coordinates needed for drawing. This is what the MapLandmark object looks like in this example, you can also see a the ScreenPoint object used in MapLandmark:

class MapLandmark {

  MapLandmark(this.pos){
    screenPoint = ScreenPoint();
  }

  Position pos;
  ScreenPoint screenPoint;
}

class ScreenPoint {

  ScreenPoint({x = 0, y = 0});

  int x;
  int y;
}

Lets create two LandMarks, one for the current position, and and other at an arbitrary position placed relatively close. We can do this in for example the initState() method of our widget. I plan on gamifying this app later so I call them Player and Cache.

void _initLandmarks() {

  _landmarksMap = Map<String, MapLandmark>();
  _landmarksMap["Player"] = MapLandmark(Position(latitude: 55.4567, longitude: 36.923));
  _landmarksMap["Cache"] = MapLandmark(Position(latitude: 55.4589, longitude: 36.123));
  
}

At least the “Player” LandMark should be able to update its coordinates as we move around. We could call an update method when we get a new position from our geolocator stream. Since we move the camera every time our location changes we could also use the onCameraMove() callback in GoogleMap like this.

GoogleMap(
    liteModeEnabled: false,
    compassEnabled: true,
    mapType: MapType.hybrid,
    markers: Set<Marker>.of(markers.values),
    initialCameraPosition: CameraPosition(
      target: LatLng(_position.latitude, _position.longitude),
      zoom: 17,
      tilt: 0,
    ),
    onMapCreated: (GoogleMapController controller) {
      _mapController = controller;
      // trigger initial update and paint of landmarks
      _updateLandmarks();
    },
    onCameraMove: (CameraPosition position) {
      _updateLandmarks();
    },
  ),

When the position have changed we should simply update the LandMark to the new coordinates. This is when it gets a little tricky. We have latitude and longitude, but we do not know which X and Y coordinate that represents on the device screen.

Google has an article: Showing Pixel and Tile Coordinates explaining how to convert or project latitude and longitude to map pixel coordinates and then convert to device screen coordinates. Unfortunately this will not work for the Flutter plugin, because the plugin does not expose the complete API. There is however a method in the GoogleMapController we can call that will take care of the projection for us getScreenCoordinate(). If we call this method on our position and subtract coordinates of the top left corner of the map we will get device screen coordinates.

Important to note is that these coordinates does not take into consideration what DPI your screen has. We can remedy this by asking MediaQuery what our DPI is and divide by it to get the results we want.

void _updateLandmarks() async {

  double dpi = MediaQuery.of(context).devicePixelRatio;
  LatLngBounds bounds = await _mapController.getVisibleRegion();
  ScreenCoordinate topRight = await _mapController.getScreenCoordinate(bounds.northeast);
  ScreenCoordinate bottomLeft = await _mapController.getScreenCoordinate(bounds.southwest);

  _landmarksMap.values.forEach((element) async {
    ScreenCoordinate sc = await _mapController.getScreenCoordinate(LatLng(element.pos.latitude, element.pos.longitude));
    element.screenPoint.x = ((sc.x - bottomLeft.x) / dpi).floor();
    element.screenPoint.y = ((sc.y - topRight.y) / dpi).floor();
  });

  setState(() {});
}

Now we always have the correct device screen coordinates stored in LandMark.screenPoint and the CustomPainter will know where to draw them.

This code will be cleaned up a bit to be more effective. For example we should only update objects that can move and MediaQuery should only be called once not each time we update.

Leave a Reply

Your email address will not be published. Required fields are marked *