You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Flutter Google Maps Android端标记物与气泡位置偏移,iOS端显示正常

Flutter Google Maps Android端标记物与气泡位置偏移,iOS端显示正常

嘿,我之前也踩过Google Maps Flutter插件在Android上的标记气泡位置坑,看了你提供的代码和环境信息,问题应该出在Android端坐标计算和视图适配的细节差异上——iOS因为渲染机制不同所以表现正常。下面我给你拆解问题原因,再附上修复后的完整代码。

问题环境确认

  • Flutter 版本:3.38.4 (stable)
  • Dart 版本:3.10.3
  • google_maps_flutter 版本:^2.7.0
  • 现象:Android端气泡无法对齐标记物上方,iOS端显示完全正常

核心问题分析

  1. 坐标基准不统一:Android端getScreenCoordinate返回的是Map视图的物理像素,而你直接用逻辑像素计算气泡位置,两者没有转换导致偏移。
  2. 视图偏移未考虑:Android端的Map视图会受AppBar、状态栏的影响,你获取的坐标没有减去这些视图的顶部偏移量。
  3. 渲染时机不对:Android端Map完全渲染稳定的时间比iOS长,30ms的延迟不足以确保坐标准确。

修复后的完整代码

我针对上面的问题修改了关键逻辑,你可以直接替换测试:

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

/// Simple test page to verify bubble centering above a marker.
/// Isolated from the main tour/stop logic.
class BubbleTestPage extends StatefulWidget {
  const BubbleTestPage({super.key});

  @override
  State<BubbleTestPage> createState() => _BubbleTestPageState();
}

class _BubbleTestPageState extends State<BubbleTestPage> {
  final Completer<GoogleMapController> _mapController = Completer();
  final GlobalKey _mapKey = GlobalKey();
  bool _mapReady = false;
  bool _showBubble = true;
  Offset? _bubbleOffset;
  LatLng? _currentBubbleAnchor;
  Timer? _bubblePositionUpdateTimer;

  // Test marker position (San Francisco)
  static const LatLng _testMarkerPosition = LatLng(37.7749, -122.4194);

  // Marker ID
  static const String _markerId = 'test_marker';

  @override
  void dispose() {
    _bubblePositionUpdateTimer?.cancel();
    super.dispose();
  }

  Future<void> _updateBubblePosition(LatLng? anchor) async {
    if (!_mapReady || anchor == null || !mounted) return;

    _currentBubbleAnchor = anchor;
    _bubblePositionUpdateTimer?.cancel();

    try {
      final ctl = await _mapController.future;
      final pixelRatio = MediaQuery.of(context).devicePixelRatio;
      final RenderBox mapBox = _mapKey.currentContext!.findRenderObject() as RenderBox;
      final mapOffset = mapBox.localToGlobal(Offset.zero);

      // 针对Android延长等待时间,确保地图完全稳定
      if (defaultTargetPlatform == TargetPlatform.android) {
        await Future.delayed(const Duration(milliseconds: 100));
      }

      final sc = await ctl.getScreenCoordinate(anchor);
      // 转换为逻辑像素,并减去地图视图的顶部偏移(AppBar+状态栏)
      final adjustedX = (sc.x.toDouble() / pixelRatio);
      final adjustedY = (sc.y.toDouble() / pixelRatio) - mapOffset.dy;

      if (!adjustedX.isFinite || !adjustedY.isFinite) return;

      final newOffset = Offset(adjustedX, adjustedY);
      debugPrint('Bubble Test - 修正后坐标: x=${adjustedX.toStringAsFixed(1)}, y=${adjustedY.toStringAsFixed(1)}');

      final isStillCurrent =
          _currentBubbleAnchor != null &&
          _currentBubbleAnchor!.latitude == anchor.latitude &&
          _currentBubbleAnchor!.longitude == anchor.longitude;

      if (mounted && isStillCurrent) {
        setState(() {
          _bubbleOffset = newOffset;
        });
      }
    } catch (e) {
      debugPrint('更新气泡位置出错: $e');
    }
  }

  void _debouncedUpdateBubblePosition(LatLng? anchor) {
    _bubblePositionUpdateTimer?.cancel();
    _bubblePositionUpdateTimer = Timer(const Duration(milliseconds: 150), () {
      _updateBubblePosition(anchor);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Bubble Centering Test'),
        actions: [
          IconButton(
            icon: Icon(_showBubble ? Icons.visibility : Icons.visibility_off),
            onPressed: () {
              setState(() {
                _showBubble = !_showBubble;
                if (_showBubble && _bubbleOffset == null) {
                  _updateBubblePosition(_testMarkerPosition);
                }
              });
            },
            tooltip: _showBubble ? 'Hide bubble' : 'Show bubble',
          ),
        ],
      ),
      body: Stack(
        clipBehavior: Clip.none,
        children: [
          GoogleMap(
            key: _mapKey,
            initialCameraPosition: const CameraPosition(
              target: _testMarkerPosition,
              zoom: 15,
            ),
            markers: {
              Marker(
                markerId: const MarkerId(_markerId),
                position: _testMarkerPosition,
                anchor: const Offset(0.5, 1.0), // 标记物锚点为底部中心
                icon: BitmapDescriptor.defaultMarkerWithHue(
                  BitmapDescriptor.hueRed,
                ),
                onTap: () {
                  setState(() {
                    _showBubble = true;
                  });
                  _updateBubblePosition(_testMarkerPosition);
                },
              ),
            },
            onMapCreated: (controller) async {
              if (!_mapController.isCompleted) {
                _mapController.complete(controller);
              }
              _mapReady = true;

              WidgetsBinding.instance.addPostFrameCallback((_) async {
                if (mounted && _showBubble) {
                  _currentBubbleAnchor = _testMarkerPosition;
                  await _updateBubblePosition(_testMarkerPosition);
                }
              });

              if (mounted) setState(() {});
            },
            onCameraMove: (position) {
              if (_showBubble) {
                _debouncedUpdateBubblePosition(_testMarkerPosition);
              }
            },
            onCameraIdle: () {
              _bubblePositionUpdateTimer?.cancel();
              if (_showBubble) {
                _updateBubblePosition(_testMarkerPosition);
              }
            },
            onTap: (_) {
              if (_showBubble) {
                setState(() {
                  _showBubble = false;
                });
              }
            },
          ),

          // 标记物中心指示器(调试用)
          if (_showBubble && _bubbleOffset != null)
            Positioned(
              left: _bubbleOffset!.dx - 10,
              top: _bubbleOffset!.dy - 10,
              child: Container(
                width: 20,
                height: 20,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  border: Border.all(color: Colors.yellow, width: 2),
                ),
                child: const Center(
                  child: Icon(
                    Icons.center_focus_strong,
                    color: Colors.yellow,
                    size: 12,
                  ),
                ),
              ),
            ),

          // 气泡覆盖层
          if (_showBubble && _bubbleOffset != null)
            _TestBubble(
              anchorPx: _bubbleOffset!,
              markerHeightLogical: 40.0,
              clearance: 4.0,
              onClose: () {
                setState(() {
                  _showBubble = false;
                });
              },
            ),
        ],
      ),
    );
  }
}

/// 测试气泡组件
class _TestBubble extends StatelessWidget {
  final Offset anchorPx;
  final double markerHeightLogical;
  final double clearance;
  final VoidCallback onClose;

  const _TestBubble({
    required this.anchorPx,
    this.markerHeightLogical = 40.0,
    this.clearance = 4.0,
    required this.onClose,
  });

  @override
  Widget build(BuildContext context) {
    const double w = 320;
    const double h = 120;
    const double r = 22;
    const double arrowW = 16;
    const double arrowH = 10;
    final pixelRatio = MediaQuery.of(context).devicePixelRatio;

    // 统一用逻辑像素计算位置
    final markerTop = anchorPx.dy - markerHeightLogical;
    final top = markerTop - clearance - arrowH - h;
    final left = anchorPx.dx - (w / 2);
    final arrowLeft = (w / 2) - (arrowW / 2);

    const Color base = Color(0xFF23323C);
    final Color cardColor = base.withAlpha(190); // 75%透明度

    return Positioned(
      left: left,
      top: top,
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          // 气泡主体
          SizedBox(
            width: w,
            height: h,
            child: Material(
              color: cardColor,
              elevation: 14,
              shadowColor: Colors.black.withAlpha(89),
              borderRadius: BorderRadius.circular(r),
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: SingleChildScrollView(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          const Expanded(
                            child: Text(
                              'Test Bubble',
                              style: TextStyle(
                                color: Colors.white,
                                fontSize: 14,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                          ),
                          GestureDetector(
                            onTap: onClose,
                            child: const Icon(
                              Icons.close,
                              color: Colors.white,
                              size: 18,
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 6),
                      const Text(
                        '气泡箭头应对齐标记物顶部中心',
                        style: TextStyle(color: Colors.white70, fontSize: 11),
                      ),
                      const SizedBox(height: 4),
                      Builder(
                        builder: (context) {
                          final bubbleCenterX = left + w / 2;
                          final actualArrowBottom = top + h;
                          final expectedArrowBottom = markerTop - clearance;

                          return Text(
                            '标记物底部Y: ${anchorPx.dy.toStringAsFixed(1)}\n'
                            '标记物顶部Y: ${markerTop.toStringAsFixed(1)}\n'
                            '气泡顶部Y: ${top.toStringAsFixed(1)}\n'
                            '箭头底部Y: ${actualArrowBottom.toStringAsFixed(1)}\n'
                            '预期箭头Y: ${expectedArrowBottom.toStringAsFixed(1)}\n'
                            'X轴偏差: ${(anchorPx.dx - bubbleCenterX).abs().toStringAsFixed(1)}px',
                            style: const TextStyle(
                              color: Colors.white60,
                              fontSize: 9,
                              fontFamily: 'monospace',
                            ),
                          );
                        },
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),

          // 气泡箭头
          Positioned(
            left: arrowLeft,
            top: h,
            child: CustomPaint(
              size: const Size(arrowW, arrowH),
              painter: _DownTrianglePainter(color: cardColor),
            ),
          ),
        ],
      ),
    );
  }
}

class _DownTrianglePainter extends CustomPainter {
  final Color color;
  const _DownTrianglePainter({required this.color});

  @override
  void paint(Canvas c, Size s) {
    final p = Paint()..color = color;
    final path = Path()
      ..moveTo(0, 0)
      ..lineTo(s.width / 2, s.height)
      ..lineTo(s.width, 0)
      ..close();
    c.drawShadow(path, Colors.black.withAlpha(89), 8, true);
    c.drawPath(path, p);
  }

  @override
  bool shouldRepaint(covariant _DownTrianglePainter old) => old.color != color;
}

测试验证点

修复后你可以重点测这几个场景:

  1. 页面首次加载时,气泡箭头是否严格对齐标记物顶部中心
  2. 拖动地图后,气泡是否能正确跟随标记物移动
  3. 缩放地图到不同级别,气泡位置是否保持准确
  4. 切换横竖屏后,气泡是否自动重新对齐

如果还有小偏移,可以微调markerHeightLogical的值(比如改成42.0),因为不同Android设备的默认Marker物理高度可能有细微差异。

火山引擎 最新活动