Flutter自定义Google地图标记信息窗口

27

我正在使用Flutter开发Google地图标记。

在每个标记的点击事件中,我想展示一个自定义信息窗口,可以包含按钮、图片等内容。但是在Flutter中,有一个TextInfoWindow属性,只能接受String类型的值。

我该如何为地图标记的InfoWindow添加按钮、图片等内容呢?

6个回答

11

我遇到了这个问题,找到了适用于我的解决方案:

为了解决它,我编写了一个自定义信息小部件,可随意定制。例如,通过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,
        ),
      ),
    );
  }
}

定制信息小部件:

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


1
是否有可能始终显示自定义信息窗口而无需点击标记? - BIS Tech
不幸的是,它无法实现,整个逻辑也没有设计为这样。 - Markus Hein
有没有任何方法可以做到这一点? - BIS Tech
2
我会将整个对象设计为标记器,就像@Ashildr在他的解决方案中所做的那样。 - Markus Hein
1
非常好的答案!非常感谢!你应该将它上传到pub.dev!在on tap方法中创建新的相机位置的目的是什么?此外,为什么我们需要onCameraMove?谢谢。 - Jessica
显示剩余6条评论

10

今天我不小心遇到了同样的问题,我无法在TextInfoWindow中正确显示多行字符串。最终,我通过实现一个模态底部表单(https://docs.flutter.io/flutter/material/showModalBottomSheet.html)来规避这个问题,当您点击标记时,它会显示出来,对于我的情况效果很好。

我还可以想象许多使用情况需要完全自定义标记的信息窗口,但是阅读GitHub上的此问题(https://github.com/flutter/flutter/issues/23938),看起来目前还不可能,因为InfoWindow不是Flutter widget。


2
我有相同的使用情况,而且我也在考虑同样的解决方案。我认为Google不希望开发者使用信息窗口来展示大量信息,而是像他们在Google地图上做的那样使用底部工作表。即使在本机Android中,信息窗口也有点受限。 - alex

6

您可以将小部件制作的标记显示为自定义的“信息窗口”。基本上,您正在创建您的小部件的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 - bytes中,因此将其转换为位图。

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

例子


3
以下是我在项目中实现自定义信息窗口的4个步骤:
步骤1:为Google地图和自定义信息窗口创建一个堆栈。
Stack(
  children: <Widget>[
    Positioned.fill(child: GoogleMap(...),),
    Positioned(
      top: {offsetY},
      left: {offsetX},
      child: YourCustomInfoWidget(...),
    )
  ]
)

第二步:当用户点击标记时,使用以下函数在屏幕上计算标记位置:
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;

步骤四:禁用标记自动移动相机功能
当您点击时,禁用标记自动移动相机功能。
Marker(
   ...
   consumeTapEvents: true,)

3
这里有一个解决方案,可以创建不依赖于InfoWindow的自定义标记。虽然这种方法不允许您在自定义标记上添加按钮。
Flutter Google地图插件允许我们使用图像数据/资产来创建自定义标记。因此,该方法使用在Canvas上绘制自定义标记,并使用PictureRecorder将其转换为图片,随后由Google地图插件用于渲染自定义标记。
绘制Canvas并将其转换为可由插件使用的图像数据的示例代码。
void paintTappedImage() async {
    final ui.PictureRecorder recorder = ui.PictureRecorder();
    final Canvas canvas = Canvas(recorder, Rect.fromPoints(const Offset(0.00.0), const Offset(200.0200.0)));
    final Paint paint = Paint()
      ..color = Colors.black.withOpacity(1)
      ..style = PaintingStyle.fill;
    canvas.drawRRect(
        RRect.fromRectAndRadius(
            const Rect.fromLTWH(0.00.0152.048.0), const Radius.circular(4.0)),
        paint);
    paintText(canvas);
    paintImage(labelIcon, const Rect.fromLTWH(8832.032.0), canvas, paint,
        BoxFit.contain);
    paintImage(markerImage, const Rect.fromLTWH(24.048.0110.0110.0), canvas,
        paint, BoxFit.contain);
    final Picture picture = recorder.endRecording();
    final img = await picture.toImage(200200);
    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(488);
    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生成的新图像替换被点击的图像。示例代码来自Google Maps插件示例应用程序。
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++;
  }
}

参考资料:

如何将Flutter画布转换为图像

Flutter插件示例应用程序。

Google maps flutter plugin custom marker in action.


2
要创建一个基于小部件的信息窗口,您需要将小部件堆叠在Google地图上。借助ChangeNotifierProviderChangeNotifierConsumer的帮助,即使摄像机在Google地图上移动,您也可以轻松重建小部件。

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

完整的示例代码可以在我的博客上找到!


你可能需要在帖子中澄清你是否是链接网站的作者,以避免这篇帖子被视为垃圾邮件。 - user12986714
根据您链接的域名/URL与您的用户名相同或包含,您似乎已经链接到了自己的网站/您有关联的网站。如果是这样,请在您的帖子中必须披露它是您的网站。如果您不披露关联,那么就被视为垃圾邮件。请参见:什么是“好”的自我推广?自我推广的帮助中心。披露必须是明确的,但不需要正式。当它是您自己的个人内容时,可以简单地写成“在我的网站上...”,“在我的博客上...”等。 - Makyen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接