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 Flutte
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.
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;
}
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.
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.
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
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 = 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,)
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!