Flutter custom Google Map marker info window

24,379

Solution 1

I stumbled across the same problem just today, I couldn't get a multiline string to show properly in TextInfoWindow. I ended up circumventing the problem by implementing a modal bottom sheet (https://docs.flutter.io/flutter/material/showModalBottomSheet.html) that shows when you click on a marker, which in my case worked out quite nicely.

I can also imagine many use cases where you'd want to fully customize the marker's info window, but reading this issue on GitHub (https://github.com/flutter/flutter/issues/23938) it looks like it's currently not possible, because the InfoWindow is not a Flutter widget.

Solution 2

Stumbled across this problem and found a solution which works for me:

To solve it I did write a Custom Info Widget, feel free to customize it. For example with some shadow via ClipShadowPath.

Implementation

import 'dart:async';

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

import 'custom_info_widget.dart';

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

class PointObject {
  final Widget child;
  final LatLng location;

  PointObject({this.child, this.location});
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: "/",
      routes: {
        "/": (context) => HomePage(),
      },
    );
  }
}

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  PointObject point = PointObject(
    child:  Text('Lorem Ipsum'),
    location: LatLng(47.6, 8.8796),
  );

  StreamSubscription _mapIdleSubscription;
  InfoWidgetRoute _infoWidgetRoute;
  GoogleMapController _mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: Colors.green,
        child: GoogleMap(
          initialCameraPosition: CameraPosition(
            target: const LatLng(47.6, 8.6796),
            zoom: 10,
          ),
          circles: Set<Circle>()
            ..add(Circle(
              circleId: CircleId('hi2'),
              center: LatLng(47.6, 8.8796),
              radius: 50,
              strokeWidth: 10,
              strokeColor: Colors.black,
            )),
          markers: Set<Marker>()
            ..add(Marker(
              markerId: MarkerId(point.location.latitude.toString() +
                  point.location.longitude.toString()),
              position: point.location,
              onTap: () => _onTap(point),
            )),
          onMapCreated: (mapController) {
            _mapController = mapController;
          },

          /// This fakes the onMapIdle, as the googleMaps on Map Idle does not always work
          /// (see: https://github.com/flutter/flutter/issues/37682)
          /// When the Map Idles and a _infoWidgetRoute exists, it gets displayed.
          onCameraMove: (newPosition) {
            _mapIdleSubscription?.cancel();
            _mapIdleSubscription = Future.delayed(Duration(milliseconds: 150))
                .asStream()
                .listen((_) {
              if (_infoWidgetRoute != null) {
                Navigator.of(context, rootNavigator: true)
                    .push(_infoWidgetRoute)
                    .then<void>(
                  (newValue) {
                    _infoWidgetRoute = null;
                  },
                );
              }
            });
          },
        ),
      ),
    );
  }
 /// now my _onTap Method. First it creates the Info Widget Route and then
  /// animates the Camera twice:
  /// First to a place near the marker, then to the marker.
  /// This is done to ensure that onCameraMove is always called 

  _onTap(PointObject point) async {
    final RenderBox renderBox = context.findRenderObject();
    Rect _itemRect = renderBox.localToGlobal(Offset.zero) & renderBox.size;

    _infoWidgetRoute = InfoWidgetRoute(
      child: point.child,
      buildContext: context,
      textStyle: const TextStyle(
        fontSize: 14,
        color: Colors.black,
      ),
      mapsWidgetSize: _itemRect,
    );

    await _mapController.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(
            point.location.latitude - 0.0001,
            point.location.longitude,
          ),
          zoom: 15,
        ),
      ),
    );
    await _mapController.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(
            point.location.latitude,
            point.location.longitude,
          ),
          zoom: 15,
        ),
      ),
    );
  }
}

CustomInfoWidget:

import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:meta/meta.dart';

class _InfoWidgetRouteLayout<T> extends SingleChildLayoutDelegate {
  final Rect mapsWidgetSize;
  final double width;
  final double height;

  _InfoWidgetRouteLayout(
      {@required this.mapsWidgetSize,
      @required this.height,
      @required this.width});

  /// Depending of the size of the marker or the widget, the offset in y direction has to be adjusted;
  /// If the appear to be of different size, the commented code can be uncommented and
  /// adjusted to get the right position of the Widget.
  /// Or better: Adjust the marker size based on the device pixel ratio!!!!)

  @override
  Offset getPositionForChild(Size size, Size childSize) {
//    if (Platform.isIOS) {
    return Offset(
      mapsWidgetSize.center.dx - childSize.width / 2,
      mapsWidgetSize.center.dy - childSize.height - 50,
    );
//    } else {
//      return Offset(
//        mapsWidgetSize.center.dx - childSize.width / 2,
//        mapsWidgetSize.center.dy - childSize.height - 10,
//      );
//    }
  }

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    //we expand the layout to our predefined sizes
    return BoxConstraints.expand(width: width, height: height);
  }

  @override
  bool shouldRelayout(_InfoWidgetRouteLayout oldDelegate) {
    return mapsWidgetSize != oldDelegate.mapsWidgetSize;
  }
}

class InfoWidgetRoute extends PopupRoute {
  final Widget child;
  final double width;
  final double height;
  final BuildContext buildContext;
  final TextStyle textStyle;
  final Rect mapsWidgetSize;

  InfoWidgetRoute({
    @required this.child,
    @required this.buildContext,
    @required this.textStyle,
    @required this.mapsWidgetSize,
    this.width = 150,
    this.height = 50,
    this.barrierLabel,
  });

  @override
  Duration get transitionDuration => Duration(milliseconds: 100);

  @override
  bool get barrierDismissible => true;

  @override
  Color get barrierColor => null;

  @override
  final String barrierLabel;

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return MediaQuery.removePadding(
      context: context,
      removeBottom: true,
      removeLeft: true,
      removeRight: true,
      removeTop: true,
      child: Builder(builder: (BuildContext context) {
        return CustomSingleChildLayout(
          delegate: _InfoWidgetRouteLayout(
              mapsWidgetSize: mapsWidgetSize, width: width, height: height),
          child: InfoWidgetPopUp(
            infoWidgetRoute: this,
          ),
        );
      }),
    );
  }
}

class InfoWidgetPopUp extends StatefulWidget {
  const InfoWidgetPopUp({
    Key key,
    @required this.infoWidgetRoute,
  })  : assert(infoWidgetRoute != null),
        super(key: key);

  final InfoWidgetRoute infoWidgetRoute;

  @override
  _InfoWidgetPopUpState createState() => _InfoWidgetPopUpState();
}

class _InfoWidgetPopUpState extends State<InfoWidgetPopUp> {
  CurvedAnimation _fadeOpacity;

  @override
  void initState() {
    super.initState();
    _fadeOpacity = CurvedAnimation(
      parent: widget.infoWidgetRoute.animation,
      curve: Curves.easeIn,
      reverseCurve: Curves.easeOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _fadeOpacity,
      child: Material(
        type: MaterialType.transparency,
        textStyle: widget.infoWidgetRoute.textStyle,
        child: ClipPath(
          clipper: _InfoWidgetClipper(),
          child: Container(
            color: Colors.white,
            padding: EdgeInsets.only(bottom: 10),
            child: Center(child: widget.infoWidgetRoute.child),
          ),
        ),
      ),
    );
  }
}

class _InfoWidgetClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    Path path = Path();
    path.lineTo(0.0, size.height - 20);
    path.quadraticBezierTo(0.0, size.height - 10, 10.0, size.height - 10);
    path.lineTo(size.width / 2 - 10, size.height - 10);
    path.lineTo(size.width / 2, size.height);
    path.lineTo(size.width / 2 + 10, size.height - 10);
    path.lineTo(size.width - 10, size.height - 10);
    path.quadraticBezierTo(
        size.width, size.height - 10, size.width, size.height - 20);
    path.lineTo(size.width, 10.0);
    path.quadraticBezierTo(size.width, 0.0, size.width - 10.0, 0.0);
    path.lineTo(10, 0.0);
    path.quadraticBezierTo(0.0, 0.0, 0.0, 10);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

Solution 3

You can display marker made of widgets as custom 'info window'. Basically you are creating png image of your widget and displaying it as a marker.

import 'dart:typed_data';
import 'dart:ui';

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

class MarkerInfo extends StatefulWidget {
  final Function getBitmapImage;
  final String text;
  MarkerInfo({Key key, this.getBitmapImage, this.text}) : super(key: key);

  @override
  _MarkerInfoState createState() => _MarkerInfoState();
}

class _MarkerInfoState extends State<MarkerInfo> {
  final markerKey = GlobalKey();

  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) => getUint8List(markerKey)
        .then((markerBitmap) => widget.getBitmapImage(markerBitmap)));
  }

  Future<Uint8List> getUint8List(GlobalKey markerKey) async {
    RenderRepaintBoundary boundary =
        markerKey.currentContext.findRenderObject();
    var image = await boundary.toImage(pixelRatio: 2.0);
    ByteData byteData = await image.toByteData(format: ImageByteFormat.png);
    return byteData.buffer.asUint8List();
  }

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: markerKey,
      child: Container(
        padding: EdgeInsets.only(bottom: 29),
        child: Container(
          width: 100,
          height: 100,
          color: Color(0xFF000000),
          child: Text(
            widget.text,
            style: TextStyle(
              color: Color(0xFFFFFFFF),
            ),
          ),
        ),
      ),
    );
  }
}

If you use this approach you have to make sure you render the widget, because this will not work otherwise. For converting widgets to images - widget has to be rendered in order to convert it. I'm hiding my widget under the map in Stack.

return Stack(
        children: <Widget>[
          MarkerInfo(
              text: tripMinutes.toString(),
              getBitmapImage: (img) {
                customMarkerInfo = img;
              }),
          GoogleMap(
            markers: markers,
 ...

Last step is to create a Marker. Data passed from the widget is saved in customMarkerInfo - bytes, so convert it to Bitmap.

markers.add(
          Marker(
            position: position,
            icon: BitmapDescriptor.fromBytes(customMarkerInfo),
            markerId: MarkerId('MarkerID'),
          ),
        );

Example

Solution 4

Here’s a solution to create custom marker that doesn’t rely on InfoWindow. Although, this approch won’t allow you to add a button on custom marker.

Flutter google maps plugin lets us use image data / asset to create a custom marker. So, this approach uses drawing on Canvas to create a custom marker and using PictureRecorder to convert the same to a picture, which later on would be used by google maps plugin to render a custom marker.

Sample code to draw on Canvas and convert the same to Image data that can be used by the plugin.

void paintTappedImage() async {
    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder, Rect.fromPoints(const Offset(0.0, 0.0), const Offset(200.0, 200.0)));
    final Paint paint = Paint()
      ..color = Colors.black.withOpacity(1)
      ..style = PaintingStyle.fill;
    canvas.drawRRect(
        RRect.fromRectAndRadius(
            const Rect.fromLTWH(0.0, 0.0, 152.0, 48.0), const Radius.circular(4.0)),
        paint);
    paintText(canvas);
    paintImage(labelIcon, const Rect.fromLTWH(8, 8, 32.0, 32.0), canvas, paint,
        BoxFit.contain);
    paintImage(markerImage, const Rect.fromLTWH(24.0, 48.0, 110.0, 110.0), canvas,
        paint, BoxFit.contain);
    final Picture picture = recorder.endRecording();
    final img = await picture.toImage(200, 200);
    final pngByteData = await img.toByteData(format: ImageByteFormat.png);
    setState(() {
      _customMarkerIcon = BitmapDescriptor.fromBytes(Uint8List.view(pngByteData.buffer));
    });
  }

  void paintText(Canvas canvas) {
    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 24,
    );
    final textSpan = TextSpan(
      text: '18 mins',
      style: textStyle,
    );
    final textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout(
      minWidth: 0,
      maxWidth: 88,
    );
    final offset = Offset(48, 8);
    textPainter.paint(canvas, offset);
  }

  void paintImage(
      ui.Image image, Rect outputRect, Canvas canvas, Paint paint, BoxFit fit) {
    final Size imageSize =
        Size(image.width.toDouble(), image.height.toDouble());
    final FittedSizes sizes = applyBoxFit(fit, imageSize, outputRect.size);
    final Rect inputSubrect =
        Alignment.center.inscribe(sizes.source, Offset.zero & imageSize);
    final Rect outputSubrect =
        Alignment.center.inscribe(sizes.destination, outputRect);
    canvas.drawImageRect(image, inputSubrect, outputSubrect, paint);
  }

once the marker is tapped, we can replace the tapped image with the new image generated from Canvas. Sample code for the same taken from google maps plugin example app.

void _onMarkerTapped(MarkerId markerId) async {
  final Marker tappedMarker = markers[markerId];
  if (tappedMarker != null) {
    if (markers.containsKey(selectedMarker)) {
      final Marker resetOld =
      markers[selectedMarker].copyWith(iconParam: _markerIconUntapped);
      setState(() {
        markers[selectedMarker] = resetOld;
      });
    }
    Marker newMarker;
    selectedMarker = markerId;
    newMarker = tappedMarker.copyWith(iconParam: _customMarkerIcon);
    setState(() {
      markers[markerId] = newMarker;
    });
    tappedCount++;
  }
}

Reference:

How to convert a flutter canvas to Image.

Flutter plugin example app.

Google maps flutter plugin custom marker in action.

Solution 5

To create a widget-based info window you need to stack the widget on google map. With the help of ChangeNotifierProvider, ChangeNotifier, and Consumer you can easily rebuild your widget even when the camera moves on google map.

InfoWindowModel class:

class InfoWindowModel extends ChangeNotifier {
  bool _showInfoWindow = false;
  bool _tempHidden = false;
  User _user;
  double _leftMargin;
  double _topMargin;

  void rebuildInfoWindow() {
    notifyListeners();
  }

  void updateUser(User user) {
    _user = user;
  }

  void updateVisibility(bool visibility) {
    _showInfoWindow = visibility;
  }

  void updateInfoWindow(
    BuildContext context,
    GoogleMapController controller,
    LatLng location,
    double infoWindowWidth,
    double markerOffset,
  ) async {
    ScreenCoordinate screenCoordinate =
        await controller.getScreenCoordinate(location);
    double devicePixelRatio =
        Platform.isAndroid ? MediaQuery.of(context).devicePixelRatio : 1.0;
    double left = (screenCoordinate.x.toDouble() / devicePixelRatio) -
        (infoWindowWidth / 2);
    double top =
        (screenCoordinate.y.toDouble() / devicePixelRatio) - markerOffset;
    if (left < 0 || top < 0) {
      _tempHidden = true;
    } else {
      _tempHidden = false;
      _leftMargin = left;
      _topMargin = top;
    }
  }

  bool get showInfoWindow =>
      (_showInfoWindow == true && _tempHidden == false) ? true : false;

  double get leftMargin => _leftMargin;

  double get topMargin => _topMargin;

  User get user => _user;
}

Complete Example is available on my blog!

Share:
24,379

Related videos on Youtube

shalini
Author by

shalini

Updated on June 07, 2020

Comments

  • shalini
    shalini about 4 years

    I am working on Google Map Markers in Flutter.

    On the click of each Marker, I want to show a Custom Info Window which can include a button, image etc. But in Flutter there is a property TextInfoWindow which only accept String.

    How can i achieve adding buttons, images to the map marker's InfoWindow.

  • alex
    alex over 5 years
    I have the same use case and I was thinking to the same solution. I think Google doesn't want developers to use the info window to show a lot of information, but to use instead a bottom sheet like they do on Google maps. Even in native Android the info window is kind of gimped.

Related