You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter自定义Google Map标记弹窗卡片,替代默认MarkerInfoWindow

解决Google Maps自定义标记弹窗居中显示的问题

嘿,我看你已经搞定了带图片和按钮的自定义卡片,现在就差把它从屏幕底部移到中部,像默认的MarkerInfoWindow那样显示对吧?咱们来一步步调整代码:

1. 调整卡片定位,从底部移到屏幕中部

原来的卡片用Positioned(bottom: 0)固定在底部,咱们换成Align组件实现居中定位,视觉上更贴近默认InfoWindow的位置:

把原来底部的Positioned代码替换成:

Align(
  alignment: Alignment.center,
  child: Container(
    height: 200.0,
    width: MediaQuery.of(context).size.width * 0.9, // 缩小宽度更贴合InfoWindow的紧凑感
    child: PageView.builder(
      controller: _pageController,
      itemCount: camDetails.length,
      itemBuilder: (BuildContext context, int index) {
        return _coffeeShopList(index);
      },
    ),
  ),
)

2. 给Marker绑定点击事件,点击时切换对应卡片

原来的Marker没有关联卡片切换逻辑,咱们在创建Marker时添加onTap回调,点击标记直接跳转到对应的卡片页面:

修改initState里的Marker创建代码:

@override
void initState() {
  super.initState();
  // 用asMap获取索引,方便绑定点击事件
  camDetails.asMap().forEach((index, element) {
    allMarkers.add(Marker(
        markerId: MarkerId(element.camTitle),
        draggable: false,
        infoWindow: InfoWindow.noTitle(), // 隐藏默认InfoWindow,用自定义卡片替代
        position: element.locationCoords,
        onTap: () {
          _pageController?.animateToPage(
            index,
            duration: const Duration(milliseconds: 400),
            curve: Curves.easeInOut,
          );
        }));
  });
  _pageController = PageController(initialPage: 1, viewportFraction: 0.9) // 调整viewportFraction让卡片间距更合理
    ..addListener(_onScroll);
}

3. 添加InfoWindow风格的小三角(可选)

如果想要更还原默认InfoWindow的样式,可以给卡片加一个指向Marker的小三角,在_coffeeShopList的Stack里添加如下代码:

// 放在Center(child: Container(...))的后面
Positioned(
  bottom: -10, // 让三角刚好卡在卡片下方
  left: 0,
  right: 0,
  child: Center(
    child: Transform.rotate(
      angle: 45 * pi / 180, // 旋转45度形成三角形
      child: Container(
        width: 20,
        height: 20,
        color: Colors.white,
        boxShadow: const [
          BoxShadow(
            color: Colors.black54,
            offset: Offset(0.0, 4.0),
            blurRadius: 10.0,
          ),
        ],
      ),
    ),
  ),
)

完整修改后的代码

import 'dart:math';
import 'package:earth_cam/model/cams.dart';
import 'package:earth_cam/splash/splash_screen.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class MapView extends StatefulWidget {
  @override
  _MapViewState createState() => _MapViewState();
}

class _MapViewState extends State<MapView> {
  GoogleMapController? _controller;
  List<Marker> allMarkers = [];
  PageController? _pageController;
  int? prevPage;
  bool isClicked = false;

  @override
  void initState() {
    super.initState();
    // 绑定Marker点击切换卡片事件
    camDetails.asMap().forEach((index, element) {
      allMarkers.add(Marker(
          markerId: MarkerId(element.camTitle),
          draggable: false,
          infoWindow: InfoWindow.noTitle(), // 隐藏默认InfoWindow
          position: element.locationCoords,
          onTap: () {
            _pageController?.animateToPage(
              index,
              duration: const Duration(milliseconds: 400),
              curve: Curves.easeInOut,
            );
          }));
    });
    _pageController = PageController(initialPage: 1, viewportFraction: 0.9)
      ..addListener(_onScroll);
  }

  void _onScroll() {
    if (_pageController?.page?.toInt() != prevPage) {
      prevPage = _pageController?.page?.toInt();
      moveCamera();
    }
  }

  _coffeeShopList(index) {
    return AnimatedBuilder(
      animation: _pageController!,
      builder: (BuildContext context, Widget? widget) {
        double value = 1;
        if (_pageController!.position.haveDimensions) {
          value = _pageController!.page! - index;
          value = (1 - (value.abs() * 0.3) + 0.06).clamp(0.0, 1.0);
        }
        return Center(
          child: SizedBox(
            height: Curves.easeInOut.transform(value) * 125.0,
            width: Curves.easeInOut.transform(value) * 350.0,
            child: widget,
          ),
        );
      },
      child: InkWell(
        onTap: () {
          moveCamera();
        },
        child: Stack(children: [
          Center(
            child: Container(
              margin: const EdgeInsets.symmetric(
                horizontal: 10.0,
                vertical: 20.0,
              ),
              height: 200.0,
              width: 275.0,
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(10.0),
                  boxShadow: const [
                    BoxShadow(
                      color: Colors.black54,
                      offset: Offset(0.0, 4.0),
                      blurRadius: 10.0,
                    ),
                  ]),
              child: Container(
                  decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(10.0), color: Colors.white),
                  child: Row(children: [
                    Container(
                        height: 90.0,
                        width: 90.0,
                        decoration: BoxDecoration(
                            borderRadius: const BorderRadius.only(
                                bottomLeft: Radius.circular(10.0),
                                topLeft: Radius.circular(10.0)),
                            image: DecorationImage(
                                image: NetworkImage(
                                    camDetails[index].thumbNail),
                                fit: BoxFit.cover))),
                    const SizedBox(width: 5.0),
                    Column(
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            camDetails[index].camTitle,
                            style: const TextStyle(
                                fontSize: 12.5, fontWeight: FontWeight.bold),
                          ),
                          Text(
                            camDetails[index].address,
                            style: const TextStyle(
                                fontSize: 12.0, fontWeight: FontWeight.w600),
                          ),
                          Container(
                            width: 150,
                            child: RaisedButton.icon(
                              elevation: 12,
                              shape: RoundedRectangleBorder(
                                borderRadius: new BorderRadius.circular(
                                    15),
                              ),
                              onPressed: () {
                                Navigator.push(context, MaterialPageRoute(builder: (context)=>const SplashScreen()));
                              },
                              icon: const Icon(
                                Icons.play_arrow, color: Colors.white,
                              ),
                              label: const Text(
                                'Play', style: TextStyle(color: Colors.white),
                              ),
                              color: Colors.redAccent,
                            ),
                          )
                        ])
                  ]))),
          ),
          // 添加InfoWindow风格的底部三角
          Positioned(
            bottom: -10,
            left: 0,
            right: 0,
            child: Center(
              child: Transform.rotate(
                angle: 45 * pi / 180,
                child: Container(
                  width: 20,
                  height: 20,
                  color: Colors.white,
                  boxShadow: const [
                    BoxShadow(
                      color: Colors.black54,
                      offset: Offset(0.0, 4.0),
                      blurRadius: 10.0,
                    ),
                  ],
                ),
              ),
            ),
          )
        ])),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1B2D45),
      appBar: AppBar(
        centerTitle: true,
        title: const Text('All Live Cams'),
        leading: IconButton(
            icon: const Icon(
              Icons.arrow_back, color: Colors.white,
            ),
            onPressed: null),
        actions: [
          IconButton(
            icon: const Icon(
              Icons.do_not_disturb_off, color: Colors.white, size: 25,
            ),
            onPressed: null),
        ],
      ),
      body: Stack(
        children: <Widget>[
          Container(
            height: MediaQuery.of(context).size.height - 50.0,
            width: MediaQuery.of(context).size.width,
            child: GoogleMap(
              compassEnabled: true,
              mapToolbarEnabled: false,
              zoomControlsEnabled: false,
              myLocationEnabled: true,
              initialCameraPosition: const CameraPosition(
                  target: LatLng(40.7128, -74.0060), zoom: 12.0),
              markers: Set.from(allMarkers),
              onMapCreated: mapCreated,
            ),
          ),
          // 自定义卡片居中显示
          Align(
            alignment: Alignment.center,
            child: Container(
              height: 200.0,
              width: MediaQuery.of(context).size.width * 0.9,
              child: PageView.builder(
                controller: _pageController!,
                itemCount: camDetails.length,
                itemBuilder: (BuildContext context, int index) {
                  return _coffeeShopList(index);
                },
              ),
            ),
          ),
          isClicked ? Container(
            padding: const EdgeInsets.only(top: 30),
            alignment: Alignment.topRight,
            child: RaisedButton.icon(
              elevation: 20,
              shape: RoundedRectangleBorder(
                borderRadius: new BorderRadius.only(
                    bottomLeft: Radius.circular(20), topLeft: Radius.circular(20)),
              ),
              onPressed: () {
                print('YT cam Clicked');
                setState(() {
                  isClicked = false;
                });
              },
              icon: const Icon(
                Icons.play_arrow, color: Colors.white,
              ),
              label: const Text(
                'YT Cam', style: TextStyle(color: Colors.white),
              ),
              color: Colors.redAccent,
            ),
          ) : Container(
            padding: const EdgeInsets.only(top: 30),
            alignment: Alignment.topRight,
            child: RaisedButton.icon(
              elevation: 20,
              shape: RoundedRectangleBorder(
                borderRadius: new BorderRadius.only(
                    bottomLeft: Radius.circular(20), topLeft: Radius.circular(20)),
              ),
              onPressed: () {
                print('IP cam Clicked');
                setState(() {
                  isClicked = true;
                });
              },
              icon: const Icon(
                Icons.linked_camera, color: Colors.white,
              ),
              label: const Text(
                'IP Cam', style: TextStyle(color: Colors.white),
              ),
              color: Colors.indigo,
            ),
          )
        ],
      ),
    );
  }

  void mapCreated(controller) {
    setState(() {
      _controller = controller;
    });
  }

  moveCamera() {
    final pageIndex = _pageController?.page?.toInt() ?? 0;
    _controller?.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
        target: camDetails[pageIndex].locationCoords,
        zoom: 14.0, bearing: 45.0, tilt: 45.0)));
  }
}

这样修改后,你的自定义卡片就会显示在屏幕中部,点击Marker还能自动切换到对应的卡片,同时保留了原来的滑动切换和相机移动逻辑。如果觉得居中位置不合适,还可以调整Align的alignment参数,比如用Alignment(0, -0.2)让它稍微靠上一点。

内容的提问来源于stack exchange,提问作者Al Mamun

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.07 11:42:47