iOS의 PlatformView(google_maps_flutter의 UiKitView)가 라우트 복귀(pop) 직후 재부착될 때 첫 탭을 내부 초기화에 소모해 GoogleMap.onTap이 호출되지 않는 순간이 있었다. 복귀 직후 1회용 투명 오버레이로 첫 탭을 가로채 복원 로직을 직접 실행하고, no-op 카메라 이동으로 제스처 연결을 안정화해 문제를 해결했다. Android에는 영향이 없었다.
iOS는 UiKitView가 자체 제스처 파이프라인을 가진다. 라우트 pop 직후 첫 입력이 내부 재정렬/가드(카메라 이동 중 무시 등)에 잡혀 사라질 수 있다. 안드로이드는 하이브리드 컴포지션 경로가 달라 영향이 덜하다.
또한, iOS의 PlatformView(google_maps_flutter의 UiKitView)는 라우트 복귀(pop) 직후 재부착될 때 첫 터치가 제스처 초기화에 소모되어 무시되는 사례가 보고돼 있다. 원인은 iOS 제스처 우선순위와 플랫폼뷰 재부착 시점의 충돌이다. Flutter가 iOS 네이티브 뷰를 임베드할 때 이런 제약이 존재함은 공식 문서와 관련 이슈들로 확인된다.
플로우: 마커 탭 → 바텀시트 아이템 탭 → 상세 진입 → 뒤로 → 지도 배경 탭으로 이전 상태 복원
실제 현상: Android는 한 번 탭으로 즉시 복원됐다. iOS는 두 번 눌러야 복원됐다.
google_maps_flutter 같이 네이티브 UIView 기반 지도 + 스크롤 가능한 바텀시트가 같은 화면에 존재했다.
상세에서 뒤로(pop) 직후 지도 배경을 탭하면 첫 탭에서 onTap이 불리지 않았다. 두 번째부터 정상 동작했다.
iOS는 Flutter 위에 임베드된 UiKitView가 자체 제스처 파이프라인을 가진다. 라우트 복귀 시 뷰가 재부착/재정렬되며 첫 입력이 내부 초기화에 소비될 수 있었다.
지도 내부 가드(줌 중 차단, 카메라 이동 중 차단 등)를 함께 쓰고 있었다면, 복귀 직후 가드 플래그가 남아 첫 탭을 무시하는 경우도 겹칠 수 있었다.
GoogleMap.onTap에 로그를 넣어 첫 탭 호출 유무를 확인했다.
복원 엔트리(restoreToBaseline)에 로그를 넣어 가드 통과 여부를 확인했다.
onCameraMoveStarted, onCameraIdle에 로그를 넣어 pop 직후 내부 상태 변화를 확인했다.
첫 탭: onTap 로그가 없었다 → 플랫폼뷰가 첫 탭을 삼켰다
두 번째 탭: onTap + 복원 로그가 정상 출력됐다
복귀 직후 1회용 투명 오버레이로 첫 탭을 가로채 복원 실행
iOS에서 상세 → 뒤로 복귀 시 오버레이를 한 프레임 동안만 켠다. 사용자가 지도 배경을 탭하면 오버레이가 탭을 받아 바로 복원 함수를 호출하고 자신을 끈다. 지도 제스처 파이프라인을 거치지 않으므로 첫 탭 유실을 회피했다.
no-op 카메라 이동으로 제스처 연결 안정화
복귀 직후 마지막 카메라 상태를 동일 값으로 moveCamera 했다. 시각적 변화 없이 iOS 플랫폼뷰 제스처 연결이 안정화됐다.
내부 가드 초기화
복귀 시점에 blockTapRestoreDueToZoom, isUserInteracting 같은 가드 플래그를 초기화했다.
// imports
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
class MapHomePage extends StatefulWidget {
const MapHomePage({super.key});
State<MapHomePage> createState() => _MapHomePageState();
}
class _MapHomePageState extends State<MapHomePage> with RouteAware {
GoogleMapController? _map;
CameraPosition? _lastCamera;
bool _oneShotTapOverlay = false; // iOS 복귀 직후 1회만 켠다
bool _sheetOpen = false; // 바텀시트 열림 여부(앱 상태에 맞게 설정)
bool _blockTapRestoreDueToZoom = false;
bool _isUserInteracting = false;
Future<void> _restoreToBaseline() async {
// 선택 해제, 바텀시트 닫기 등 복원 로직
}
Widget build(BuildContext context) {
final bottomInset = _sheetOpen ? _sheetHeight : 0.0; // 측정값 주입(아래 참고)
return Stack(
children: [
GoogleMap(
onMapCreated: (c) => _map = c,
initialCameraPosition: const CameraPosition(
target: LatLng(37.5665, 126.9780),
zoom: 14,
),
onTap: (pos) {
// 정상 경로: 오버레이가 꺼진 이후엔 onTap으로도 복원
if (_blockTapRestoreDueToZoom || _isUserInteracting) return;
_restoreToBaseline();
},
onCameraMove: (cp) => _lastCamera = cp,
onCameraMoveStarted: () => _isUserInteracting = true,
onCameraIdle: () => _isUserInteracting = false,
),
// iOS 복귀 직후 1회용 오버레이: 바텀시트 윗부분까지만 덮는다
if (_oneShotTapOverlay && !_sheetOpen)
Positioned.fromRect(
rect: Rect.fromLTWH(0, 0, MediaQuery.of(context).size.width,
MediaQuery.of(context).size.height - bottomInset),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
setState(() => _oneShotTapOverlay = false);
_restoreToBaseline();
},
),
),
// 바텀시트 예시
Align(
alignment: Alignment.bottomCenter,
child: _BottomSheet(
onHeight: (h) => _sheetHeight = h, // 높이 측정
onOpenChanged: (open) => setState(() => _sheetOpen = open),
),
),
],
);
}
double _sheetHeight = 0;
Future<void> pushDetail() async {
// 상세 진입 전에 필요한 정리
final result = await Navigator.of(context).pushNamed('/detail');
// result 사용 여부는 선택 사항
await _onReturnFromDetail();
}
Future<void> _onReturnFromDetail() async {
if (!mounted) return;
if (Platform.isIOS) {
// 1) no-op 카메라 이동: 동일 카메라로 moveCamera
if (_map != null && _lastCamera != null) {
await _map!.moveCamera(
CameraUpdate.newCameraPosition(_lastCamera!),
);
}
// 2) 가드 초기화
_blockTapRestoreDueToZoom = false;
_isUserInteracting = false;
// 3) 한 프레임 뒤 오버레이 on
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _oneShotTapOverlay = true);
});
}
}
}
class _BottomSheet extends StatelessWidget {
const _BottomSheet({
required this.onHeight,
required this.onOpenChanged,
});
final ValueChanged<double> onHeight;
final ValueChanged<bool> onOpenChanged;
Widget build(BuildContext context) {
return _MeasureSize(
onChange: (size) => onHeight(size.height),
child: DraggableScrollableSheet(
initialChildSize: 0.15,
minChildSize: 0.12,
maxChildSize: 0.6,
builder: (ctx, controller) {
onOpenChanged(controller.positions.isNotEmpty &&
controller.positions.first.pixels > 1);
return Material(
elevation: 8,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: ListView(
controller: controller,
children: const [
SizedBox(height: 400, child: Center(child: Text('Sheet'))),
],
),
);
},
),
);
}
}
class _MeasureSize extends SingleChildRenderObjectWidget {
const _MeasureSize({required this.onChange, super.child});
final ValueChanged<Size> onChange;
RenderObject createRenderObject(BuildContext context) =>
_MeasureSizeRender(onChange);
}
class _MeasureSizeRender extends RenderProxyBox {
_MeasureSizeRender(this.onChange);
final ValueChanged<Size> onChange;
Size? _oldSize;
void performLayout() {
super.performLayout();
if (size != _oldSize) {
_oldSize = size;
WidgetsBinding.instance.addPostFrameCallback((_) => onChange(size));
}
}
}
Navigator 결과로 즉시 복원: 상세에서 Navigator.pop(context, ResetIntent)로 신호를 보내 복귀 즉시 복원하는 방식도 썼다. 플로우가 간단하고 강력하다. 다만 “사용자 탭으로 복원한다”는 기존 UX를 유지하려고 본 이슈에서는 오버레이 방식을 채택했다.
첫 탭 무시 플래그: RouteObserver.didPopNext에서 ignoreNextTap = true로 두고 onTap 첫 호출을 버리는 방법도 있다. 플랫폼뷰가 첫 탭을 아예 삼키면 콜백이 오지 않아서 복원 트리거가 없다는 한계가 있었다.
gestureRecognizers 커스터마이즈: EagerGestureRecognizer로 탭 선점도 가능하다. 바텀시트 스크롤과 충돌 가능성이 높아 본 구성에서는 배제했다.
iOS에서 복귀 후 첫 탭만으로 즉시 복원됐다.
Android 동작은 변경하지 않았다.
지도/바텀시트 제스처 충돌 없이 안정적으로 동작했다.
iOS 복귀 직후 onTap 미호출 현상이 오버레이 경로로 대체됐는가
오버레이가 바텀시트 상단까지만 덮는가
no-op moveCamera가 화면 흔들림 없이 적용됐는가
가드 플래그가 복귀 시 초기화됐는가
Android에서 회귀가 없는가
문제의 본질은 플랫폼뷰 재부착 타이밍과 첫 입력 소모였다. 가장 확실한 회피는 복귀 프레임에 한정된 오버레이로 첫 탭을 가로채는 것이었다. 여기에 no-op 카메라 이동과 가드 초기화를 결합해 재현율을 0으로 만들었다. 같은 구성(네이티브 지도 + 스크롤 시트)을 쓰는 iOS 화면이라면 동일한 접근이 그대로 통한다.