PageView.builder를 사용해 이미지/비디오 미디어 리스트를 스와이프
이미지 뷰어: InteractiveViewer로 핀치 줌, 팬 기능 구현
비디오 뷰어: VideoPlayerController + GestureDetector로 재생/탭 제어
확대 여부를 감지하기 위해 onInteractionUpdate를 통해 isZoomed 상태 추적
isZoomed일 경우 NeverScrollableScrollPhysics로 PageView 스크롤을 제한하는 방식 사용
핀치 줌 시 스와이프가 차단되어야 함
줌 상태가 아닌 경우에는 자유롭게 좌우 넘김 가능해야 함
이미지가 아직 줌되고 있는 도중에 좌우 드래그를 시도하면, PageView가 먼저 인식하고 페이지가 넘어가버림
isZoomed 상태는 줌이 확실히 끝난 뒤에만 반응하므로 초기 핀치 시도에 대한 제어가 불가능
Flutter는 모든 제스처 인식기(GestureRecognizer) 가 하나의 Arena에 모여 "경쟁"하며, 더 일찍, 더 강력한 제스처가 이벤트를 가져간다:
좌우 스와이프 HorizontalDragGestureRecognizer (PageView)
핀치 줌 ScaleGestureRecognizer (InteractiveViewer)
복사
편집
physics: ctl.isZoomed.value
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
isZoomed는 ScaleUpdateDetails에서 scale > 1.0일 때만 true로 변경됨
핀치를 시작하기 전에 이미 PageView가 제스처를 가져가 버리면 스크롤 차단이 무의미
선택한 도구: extended_image
extended_image는 이미지 뷰잉에 특화된 고성능 위젯이며, 내부적으로 제스처 간 우선순위, 충돌, 동시 제스처 등 GestureArena의 한계를 우회하는 로직이 포함되어 있음
이미지 확대 중에는 자동으로 스크롤 차단됨
내부적으로 GestureDetector를 제어하여 PageView가 arena에서 이기지 못하게 처리
ExtendedImageMode.gesture
확대/축소, 팬, 더블탭 줌을 통합적으로 제공
비디오는 기존 방식 유지
FutureBuilder + VideoPlayer 조합을 유지하며, 전체 구조에 통합
변경 전 (PageView + InteractiveViewer)
PageView.builder(
controller: ctl.pageController,
physics: ctl.isZoomed.value
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
...
)
변경 후 (ExtendedImageGesturePageView)
ExtendedImageGesturePageView.builder(
controller: ctl.pageController,
itemCount: ctl.mediaItems.length,
onPageChanged: ctl.updateIndex,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemBuilder: (ctx, idx) {
final item = ctl.mediaItems[idx];
if (item.type == MediaType.IMAGE) {
return ExtendedImage.network(
item.url,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
enableLoadState: true,
initGestureConfigHandler: (_) => GestureConfig(
minScale: 1.0,
maxScale: 4.0,
initialScale: 1.0,
animationMinScale: 0.7,
animationMaxScale: 4.0,
inPageView: true, // 핵심
cacheGesture: false,
),
);
}
return FutureBuilder<VideoPlayerController>(
future: ctl.futureAt(idx),
builder: (_, snap) {
if (!snap.hasData) return const OringLoadingIndicator().center();
final vc = snap.data!;
final screenWidth = MediaQuery.of(ctx).size.width;
final aspectRatio = vc.value.size.width / vc.value.size.height;
final height = screenWidth / aspectRatio;
return Stack(
children: [
Center(
child: VideoPlayer(vc)
.constrained(width: screenWidth, height: height),
),
Positioned.fill(
child: GestureDetector(
onTap: ctl.toggleControls,
behavior: HitTestBehavior.translucent,
child: Container(color: Colors.transparent),
),
),
],
);
},
);
},
)
기준 기존 방식 (InteractiveViewer) extended_image 방식
제스처 충돌 GestureArena에서 스와이프가 승리 GestureArena를 우회 또는 내부 제어
줌 중 페이지 넘김 방지 수동으로 physics 조절 → 지연됨 자동 제어 (즉시 적용)
더블탭 줌 직접 구현해야 함 기본 포함
팬 제스처 제한적 고급 팬 + 스프링 복구 포함
유지보수 제스처 복잡성 ↑ 명확한 역할 분리로 ↓
기존 Flutter 구조에서는 PageView와 InteractiveViewer의 제스처 충돌이 근본적으로 해결되지 않음
extended_image의 ExtendedImageGesturePageView는 이러한 제스처 충돌 문제를 설계 차원에서 해결
특히 GestureConfig.inPageView = true 옵션은 줌 상태일 때 스크롤 차단이라는 핵심 기능을 제공
전체 구조는 유지하면서도, 이미지 줌 시 UX를 훨씬 자연스럽고 직관적으로 개선