Flutter custom Google Map marker info window

2020-02-10 22:57发布

问题:

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.

回答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.



回答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;
}



回答3:

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.



回答4:

Bellow is 4 step I had implemented for custom InfoWindow on my project

Step 1: Create a stack for GoogleMap and Info Window Custom.

Stack(
    children: <Widget>[
        Positioned.fill(child: GoogleMap(...),),
        Positioned(
            top: {offsetY},
            left: {offsetX},
            child: YourCustomInfoWidget(...),
    ]

Step 2: When user click Marker calculator position of marker on screen with func:

screenCoordinate = await _mapController.getScreenCoordinate(currentPosition.target)

Step 3: Calculator offsetY, offsetX and setState.

Relate issue: https://github.com/flutter/flutter/issues/41653

devicePixelRatio = devicePixelRatio = Platform.isAndroid? MediaQuery.of(context).devicePixelRatio: 1.0;

offsetY = screenCoordinate?.y?.toDouble() ?? 0)/devicePixelRatio - infoWidget.size.width
offsetX = (screenCoordinate?.x?.toDouble() ?? 0)/devicePixelRatio - infoWidget.size.height,

Step 4: Disable Marker auto move camera when tap

Marker(
...
consumeTapEvents: true,)