【问题标题】:Flutter custom Google Map marker info windowFlutter 自定义谷歌地图标记信息窗口
【发布时间】:2019-06-03 21:01:56
【问题描述】:

我正在开发 Flutter 中的 Google Map Markers。

单击每个标记时,我想显示一个自定义信息窗口,其中可以包含按钮、图像等。但在 Flutter 中有一个属性 TextInfoWindow 只接受 String

如何实现将按钮、图像添加到地图标记的InfoWindow

【问题讨论】:

    标签: flutter flutter-layout


    【解决方案1】:

    我今天偶然发现了同样的问题,我无法在 TextInfoWindow 中正确显示多行字符串。我最终通过实现一个模态底部工作表 (https://docs.flutter.io/flutter/material/showModalBottomSheet.html) 来规避这个问题,该工作表会在您单击标记时显示,在我的情况下效果非常好。

    我还可以想象许多用例,您希望完全自定义标记的信息窗口,但在 GitHub (https://github.com/flutter/flutter/issues/23938) 上阅读此问题,目前似乎不可能,因为 InfoWindow 不是 Flutter 小部件.

    【讨论】:

    • 我有相同的用例,我正在考虑相同的解决方案。我认为谷歌不希望开发人员使用信息窗口来显示大量信息,而是使用他们在谷歌地图上所做的底部工作表。即使在原生 Android 中,信息窗口也有点小。
    【解决方案2】:

    偶然发现了这个问题并找到了适合我的解决方案:

    为了解决这个问题,我写了一个Custom Info Widget,随意定制它。例如通过ClipShadowPath 带有一些阴影。

    实施

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

    【讨论】:

    • 是否可以在没有点击标记的情况下始终显示您的自定义信息窗口?
    • 不幸的是它不会,整个逻辑也不是为了做到这一点而设计的。
    • 有什么办法吗?
    • 我会简单地将整个对象设计为标记。就像@Ashildr 在他的解决方案中所做的那样。
    • 很好的答案!非常感谢!你应该把它上传到 pub.dev!在 on tap 方法中制作新的相机位置的目的是什么?另外,为什么我们有onCameraMove?谢谢
    【解决方案3】:

    您可以将由小部件制成的标记显示为自定义“信息窗口”。基本上,您正在创建小部件的 png 图像并将其显示为标记。

    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),
                ),
              ),
            ),
          ),
        );
      }
    }
    

    如果您使用这种方法,您必须确保渲染小部件,否则这将不起作用。要将小部件转换为图像 - 必须渲染小部件才能进行转换。我将我的小部件隐藏在 Stack 中的地图下。

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

    最后一步是创建一个标记。从小部件传递的数据保存在 customMarkerInfo - 字节中,因此将其转换为位图。

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

    Example

    【讨论】:

      【解决方案4】:

      这是创建不依赖于 InfoWindow 的自定义标记的解决方案。 虽然,此方法不允许您在自定义标记上添加按钮

      Flutter google maps 插件让我们可以使用图像数据/资产来创建自定义标记。因此,这种方法使用Canvas 上的绘图来创建自定义标记,并使用PictureRecorder 将其转换为图片,稍后将由谷歌地图插件使用来呈现自定义标记。

      在 Canvas 上绘制并将其转换为插件可以使用的图像数据的示例代码。

      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);
        }
      

      一旦标记被点击,我们可以将点击的图像替换为从 Canvas 生成的新图像。来自谷歌地图插件示例应用程序的相同示例代码。

      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++;
        }
      }
      

      参考:

      How to convert a flutter canvas to Image.

      Flutter plugin example app.

      【讨论】:

        【解决方案5】:

        要创建基于小部件的信息窗口,您需要将小部件堆叠在谷歌地图上。在ChangeNotifierProviderChangeNotifierConsumer 的帮助下,即使相机在谷歌地图上移动,您也可以轻松地重建您的小部件。

        InfoWindowModel 类:

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

        完整示例可在我的blog 上找到!

        【讨论】:

        • 您可能需要在帖子中说明您是否是您所链接网站的作者,以避免此帖子被视为垃圾邮件的可能性。
        • 根据您的链接的域/URL 与您的用户名相同或包含您的用户名,您似乎已链接到您自己的网站/您所属的网站。如果这样做,您必须在您的帖子中披露这是您的网站。如果您不披露从属关系,则将其视为垃圾邮件。请参阅:What signifies "Good" self promotion?the help center on self-promotion。披露必须是明确的,但不需要是正式的。如果是您自己的个人内容,则可以是“在我的网站上……”、“在我的博客上……”等。
        【解决方案6】:

        下面是我在项目中为自定义 InfoWindow 实施的 4 个步骤

        第 1 步:为 GoogleMap 和自定义信息窗口创建堆栈。

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

        第 2 步:当用户使用 func 点击屏幕上标记的标记计算器位置时:

        screenCoordinate = await _mapController.getScreenCoordinate(currentPosition.target)
        

        第 3 步:计算器 offsetY、offsetX 和 setState。

        相关问题:https://github.com/flutter/flutter/issues/41653

        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;
        

        第 4 步:禁用点按时标记自动移动相机

        Marker(
           ...
           consumeTapEvents: true,)
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-08-21
          • 1970-01-01
          • 2018-01-19
          • 2016-05-22
          • 2023-03-21
          相关资源
          最近更新 更多