[Flutter] InteractiveViewer와 ViewportIndicator 구현 삽질기

2025. 6. 30. 16:54Programming/Flutter

반응형

문제의 시작

Flutter로 InteractiveViewer를 사용해서 큰 이미지나 콘텐츠를 확대/축소할 수 있는 기능을 구현할 일이 생겼다. 사용자가 2배, 3배로 확대해서 볼 수 있게 하는 건데, 문제는 확대한 상태에서 사용자가 현재 어느 부분을 보고 있는지 알기 어렵다는 것이었다.

요구사항은 간단했다: "확대했을 때 현재 보고 있는 영역을 작은 박스로 표시해주세요. 포토샵 네비게이터 패널처럼요."

처음엔 "아, 그거 쉽지"라고 생각했는데... 막상 구현해보니 생각보다 복잡했다. 특히 좌표계 변환 부분에서 꽤 삽질을 했다.

왜 ViewportIndicator가 필요한가?

사용자가 큰 이미지나 영상을 확대할 때의 문제점을 생각해보자:

  • 🔍 2배 확대하면 전체의 1/4만 화면에 보임
  • 📍 현재 어느 부분을 보고 있는지 파악하기 어려움
  • 🤔 원하는 영역으로 이동하기 위해 여러 번 시행착오 필요

ViewportIndicator는 이런 문제를 해결해주는 작은 도우미다. 마치 게임의 미니맵처럼 전체 중에서 현재 보고 있는 영역을 시각적으로 보여준다.

삽질 과정 정리

ViewportIndicator를 구현하면서 깨달은 건, 이게 생각보다 복잡한 문제라는 것이다. 단순히 작은 박스 하나 그리는 게 아니라 여러 개의 좌표계를 다루고 실시간으로 동기화해야 하는 작업이었다.

필요한 핵심 요소들

먼저 어떤 것들이 필요한지 파악해보자:

1. TransformationController

  • InteractiveViewer의 확대/축소/이동 상태를 추적
  • 이게 핵심이다. 사용자가 제스처를 할 때마다 변화를 알아차려야 함

2. GlobalKey

  • 위젯들의 실제 크기를 알아내기 위한 키
  • 처음엔 이게 왜 필요한지 몰랐는데, 나중에 보니 필수였다

3. CustomPainter

  • 실제로 ViewportIndicator를 그리는 부분
  • Canvas API를 직접 다뤄야 함

4. 좌표계 변환 로직

  • 실제 콘텐츠 좌표 → ViewportIndicator 좌표 변환
  • 이 부분에서 가장 많이 삽질했다...

전체적인 구조는 이렇다:

InteractiveViewer
├── 실제 콘텐츠 (영상/이미지)
└── ViewportIndicator (작은 박스)
    └── 현재 보이는 영역 표시

실제 구현 과정

1. ViewportIndicator 위젯 만들기 - 첫 번째 삽질

처음에는 간단하게 생각했다. "그냥 작은 박스 하나 만들어서 현재 위치 표시하면 되겠지?"

하지만 막상 구현하려니 문제가 하나둘 보이기 시작했다. 일단 InteractiveViewer의 변환 상태를 어떻게 실시간으로 추적할지부터 막막했다.

/// InteractiveViewer용 ViewportIndicator 위젯
/// 만들면서 깨달은 건데, 이게 생각보다 복잡하다...
class ViewportIndicator extends StatefulWidget {
  /// 이놈이 핵심이다. InteractiveViewer와 공유해서 실시간으로 변환 상태를 받아온다
  final TransformationController transformController;

  /// 실제 콘텐츠(영상, 이미지 등)의 크기를 알아내기 위한 키
  /// 처음엔 왜 필요한지 몰랐는데, 좌표 변환할 때 반드시 필요했다
  final GlobalKey contentKey;

  /// InteractiveViewer 컨테이너의 크기를 알아내기 위한 키
  /// 이것도 마찬가지로 좌표 변환에 필수
  final GlobalKey containerKey;

  /// 현재 보고 있는 영역을 표시할 색상 (보통 밝은 색)
  final Color indicatorColor;

  /// 안 보이는 영역의 배경 색상 (보통 어두운 색)
  final Color backgroundTint;

  /// ViewportIndicator 테두리 색상
  final Color outlineColor;

  const ViewportIndicator({
    super.key,
    required this.transformController,
    required this.contentKey,
    required this.containerKey,
    this.indicatorColor = const Color(0xB3FFFFFF), // 반투명 흰색으로 설정
    this.backgroundTint = const Color(0x80000000), // 반투명 검은색으로 설정
    this.outlineColor = Colors.white,
  });

  @override
  State<ViewportIndicator> createState() => _ViewportIndicatorState();
}

class _ViewportIndicatorState extends State<ViewportIndicator> {
  /// InteractiveViewer 컨테이너 크기 (이놈 때문에 한참 헤맸다)
  Size? _containerSize;

  /// 실제 콘텐츠 크기 (이것도 마찬가지)
  Size? _contentSize;

  @override
  void initState() {
    super.initState();

    // 처음에 크기 정보가 null이어서 계속 오류가 났다
    // addPostFrameCallback을 써야 위젯이 완전히 그려진 후에 크기를 가져올 수 있다
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _refreshSizes();
    });

    // 이 리스너가 핵심이다. 사용자가 확대/축소/이동할 때마다 호출된다
    widget.transformController.addListener(_onTransformUpdate);
  }

  @override
  void dispose() {
    // 메모리 누수 방지를 위해 리스너를 제거합니다
    widget.transformController.removeListener(_onTransformUpdate);
    super.dispose();
  }

  /// 변환(확대/축소/이동)이 발생했을 때 호출되는 콜백
  void _onTransformUpdate() {
    // 변환이 일어날 때마다 크기 정보를 다시 확인하고 업데이트합니다
    // 이렇게 하는 이유는 때로는 변환과 함께 레이아웃이 변경될 수 있기 때문입니다
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _refreshSizes();
    });
  }

  /// 컨테이너와 콘텐츠의 크기를 새로 가져와서 상태를 업데이트하는 함수
  void _refreshSizes() {
    // GlobalKey를 통해 실제 렌더링된 위젯의 크기 정보를 가져옵니다
    final newContainerSize = widget.containerKey.currentContext?.size;
    final newContentSize = widget.contentKey.currentContext?.size;

    // 크기가 변경된 경우에만 setState를 호출하여 불필요한 리빌드를 방지합니다
    if (newContainerSize != _containerSize || newContentSize != _contentSize) {
      if (mounted) { // 위젯이 아직 트리에 마운트되어 있는지 확인
        setState(() {
          _containerSize = newContainerSize;
          _contentSize = newContentSize;
        });
      }
    }
  }

  /// 이 함수가 진짜 핵심이다. 좌표 변환 로직이 여기 다 들어있다
  /// 처음 만들 때 이 부분에서 제일 많이 삽질했다...
  Rect _computeVisibleArea(Size contentSize, Size containerSize, Matrix4 transform) {
    // 현재 확대 비율 (1.0이면 원본 크기, 2.0이면 2배 확대)
    final scaleFactor = transform.getMaxScaleOnAxis();

    // 확대 안 했으면 ViewportIndicator 표시할 필요 없다
    if (scaleFactor <= 1.0) return Rect.zero;

    // 현재 화면에 보이는 영역 크기 계산
    // 2배 확대했으면 전체의 1/2만 보인다는 뜻
    final visibleWidth = containerSize.width / scaleFactor;
    final visibleHeight = containerSize.height / scaleFactor;

    // 현재 보이는 영역의 시작점 좌표 계산
    // 이 부분이 처음엔 헷갈렸다. 음수로 나오는 이유를 한참 고민했음
    final translation = transform.getTranslation();
    final visibleLeft = -translation.x / scaleFactor;
    final visibleTop = -translation.y / scaleFactor;

    // ViewportIndicator의 크기를 컨테이너 크기의 1/6로 설정합니다
    // 이 비율은 필요에 따라 조정할 수 있습니다 (1/4, 1/8 등)
    final indicatorWidth = containerSize.width / 6;
    final indicatorHeight = containerSize.height / 6;

    // 실제 콘텐츠 좌표계를 ViewportIndicator 좌표계로 변환하는 비율을 계산합니다
    final scaleX = indicatorWidth / contentSize.width;
    final scaleY = indicatorHeight / contentSize.height;

    // 최종적으로 ViewportIndicator에서 표시될 뷰포트 영역을 반환합니다
    return Rect.fromLTWH(
      visibleLeft * scaleX,      // ViewportIndicator에서의 X 좌표
      visibleTop * scaleY,       // ViewportIndicator에서의 Y 좌표  
      visibleWidth * scaleX,     // ViewportIndicator에서의 너비
      visibleHeight * scaleY,    // ViewportIndicator에서의 높이
    );
  }

  @override
  Widget build(BuildContext context) {
    // AnimatedBuilder를 사용하여 TransformationController의 변화에 따라
    // 자동으로 ViewportIndicator를 다시 그리도록 합니다
    return AnimatedBuilder(
      animation: widget.transformController,
      builder: (context, child) {
        // 크기 정보가 아직 없으면 빈 위젯을 반환합니다
        // 이는 초기 로딩 중에 오류를 방지하기 위함입니다
        if (_contentSize == null || _containerSize == null) {
          return const SizedBox.shrink();
        }

        // 현재 변환 상태를 가져와서 뷰포트 영역을 계산합니다
        final transform = widget.transformController.value;
        final visibleRect = _computeVisibleArea(_contentSize!, _containerSize!, transform);

        // ViewportIndicator의 크기를 계산합니다
        final indicatorWidth = _containerSize!.width / 6;
        final indicatorHeight = _containerSize!.height / 6;

        // ViewportIndicator의 UI를 구성합니다
        return Container(
          width: indicatorWidth,
          height: indicatorHeight,
          decoration: BoxDecoration(
            // 테두리를 그려서 ViewportIndicator 영역을 명확히 구분합니다
            border: Border.all(color: widget.outlineColor, width: 1.0),
            borderRadius: BorderRadius.circular(4.0),
          ),
          child: ClipRRect(
            // 모서리를 둥글게 처리합니다
            borderRadius: BorderRadius.circular(3.0),
            child: CustomPaint(
              // 실제 뷰포트 영역을 그리는 CustomPainter를 사용합니다
              painter: _ViewportPainter(
                visibleArea: visibleRect,
                backgroundTint: widget.backgroundTint,
                indicatorColor: widget.indicatorColor,
              ),
            ),
          ),
        );
      },
    );
  }
}

여기서 _computeVisibleArea 함수가 진짜 핵심이다. 이 부분 때문에 밤새 고생했다. 특히 좌표계 변환 공식을 이해하는 데 시간이 오래 걸렸다.

2. CustomPainter로 실제로 그리기 - 두 번째 삽질

위젯 구조는 만들었으니 이제 실제로 화면에 그려야 한다. 처음엔 "Canvas API 정도야 뭐 어렵겠어?"라고 생각했는데... 역시 만만치 않았다.

/// ViewportIndicator를 실제로 화면에 그리는 CustomPainter
/// Canvas API를 사용해서 배경과 현재 뷰포트 영역을 시각적으로 표현합니다
class _ViewportPainter extends CustomPainter {
  /// 현재 보이는 영역의 위치와 크기 정보
  final Rect visibleArea;

  /// 보이지 않는 영역(배경)의 색상
  final Color backgroundTint;

  /// 현재 보이는 영역(뷰포트)의 색상
  final Color indicatorColor;

  const _ViewportPainter({
    required this.visibleArea,
    required this.backgroundTint,
    required this.indicatorColor,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // 1단계: 전체 배경 영역을 그립니다
    // 이는 현재 보이지 않는 영역을 나타내며, 보통 어두운 색으로 표시합니다
    final backgroundPaint = Paint()
      ..color = backgroundTint           // 반투명한 검은색 등
      ..style = PaintingStyle.fill;      // 내부를 채우는 스타일

    // ViewportIndicator 전체 영역을 배경색으로 칠합니다
    canvas.drawRect(
        Rect.fromLTWH(0, 0, size.width, size.height), backgroundPaint);

    // 2단계: 현재 보이는 영역(뷰포트)을 그립니다
    // visibleArea가 Rect.zero인 경우는 확대하지 않은 상태이므로 뷰포트를 그리지 않습니다
    if (visibleArea != Rect.zero) {
      // 뷰포트 영역을 채울 Paint 객체를 생성합니다
      final indicatorPaint = Paint()
        ..color = indicatorColor         // 보통 밝은 색 (하얀색 등)
        ..style = PaintingStyle.fill;    // 내부를 채우는 스타일

      // 중요: 뷰포트 영역이 ViewportIndicator 경계를 벗어나지 않도록 클리핑합니다
      // 사용자가 극한으로 이동했을 때 ViewportIndicator 밖으로 나가는 것을 방지
      final clampedArea = Rect.fromLTWH(
        // X 좌표: 0과 ViewportIndicator 너비 사이로 제한
        visibleArea.left.clamp(0.0, size.width),

        // Y 좌표: 0과 ViewportIndicator 높이 사이로 제한  
        visibleArea.top.clamp(0.0, size.height),

        // 너비: 경계를 벗어나지 않도록 계산
        // (오른쪽 끝점을 제한) - (왼쪽 시작점을 제한) = 실제 그려질 너비
        (visibleArea.left + visibleArea.width).clamp(0.0, size.width) -
            visibleArea.left.clamp(0.0, size.width),

        // 높이: 경계를 벗어나지 않도록 계산  
        // (아래쪽 끝점을 제한) - (위쪽 시작점을 제한) = 실제 그려질 높이
        (visibleArea.top + visibleArea.height).clamp(0.0, size.height) -
            visibleArea.top.clamp(0.0, size.height),
      );

      // 클리핑된 영역이 유효한 크기를 가지는 경우에만 그립니다
      if (clampedArea.width > 0 && clampedArea.height > 0) {
        // 뷰포트 영역의 내부를 칠합니다
        canvas.drawRect(clampedArea, indicatorPaint);

        // 뷰포트 영역의 테두리를 그립니다
        // 이렇게 하면 뷰포트 영역이 더 명확하게 구분됩니다
        final borderPaint = Paint()
          ..color = indicatorColor.withAlpha(255)  // 완전 불투명하게
          ..style = PaintingStyle.stroke           // 테두리만 그리는 스타일
          ..strokeWidth = 1.0;                     // 1픽셀 두께
        canvas.drawRect(clampedArea, borderPaint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant _ViewportPainter oldDelegate) {
    // 이 메서드는 성능 최적화를 위해 필요합니다
    // 실제로 변경된 내용이 있을 때만 다시 그리도록 합니다
    return oldDelegate.visibleArea != visibleArea ||           // 뷰포트 위치/크기 변경
        oldDelegate.backgroundTint != backgroundTint ||        // 배경색 변경
        oldDelegate.indicatorColor != indicatorColor;          // 인디케이터 색상 변경
  }
}

CustomPainter에서 가장 중요한 건 paint 메서드다. 여기서 실제로 Canvas에 ViewportIndicator를 그린다. 특히 clamp 처리 부분에서 삽질을 좀 했는데, 사용자가 극한으로 이동했을 때 ViewportIndicator 밖으로 나가는 걸 방지하기 위함이다.

3. 실제 앱에 적용하기 - 세 번째 삽질

이제 만든 ViewportIndicator를 실제 앱에 넣어봐야 한다. 처음에는 단순하게 Stack으로 올리기만 하면 될 줄 알았는데...

/// ViewportIndicator를 사용하는 실제 페이지 예제
/// 큰 이미지나 지도 등을 확대/축소할 수 있는 뷰어를 만들 때 사용할 수 있습니다
class InteractiveContentPage extends StatefulWidget {
  @override
  State<InteractiveContentPage> createState() => _InteractiveContentPageState();
}

class _InteractiveContentPageState extends State<InteractiveContentPage> {
  /// InteractiveViewer와 ViewportIndicator가 공유할 변환 컨트롤러
  /// 이 하나의 컨트롤러를 통해 두 위젯이 동기화됩니다
  final TransformationController _transformController = TransformationController();

  /// 확대/축소 정보를 보여주는 인디케이터를 자동으로 숨기기 위한 타이머
  Timer? _scaleIndicatorTimer;

  /// 실제 콘텐츠(이미지, 지도 등)의 크기를 추적하기 위한 키
  final GlobalKey _contentKey = GlobalKey(debugLabel: 'ContentKey');

  /// InteractiveViewer 컨테이너의 크기를 추적하기 위한 키
  final GlobalKey _containerKey = GlobalKey(debugLabel: 'ContainerKey');

  @override
  void initState() {
    super.initState();
    // 변환(확대/축소/이동) 상태 변화를 감지하도록 리스너를 등록합니다
    _transformController.addListener(_onTransformChange);
  }

  /// ViewportIndicator와 배율 인디케이터의 표시 여부를 제어하는 상태
  bool _showScaleIndicator = false;

  /// 현재 확대/축소 배율을 저장하는 변수
  double _currentScale = 1.0;

  /// 사용자가 확대/축소/이동할 때마다 호출되는 콜백 함수
  void _onTransformChange() {
    if (!mounted) return; // 위젯이 dispose된 후 호출되는 것을 방지

    // 현재 변환 매트릭스에서 확대 배율을 추출합니다
    final Matrix4 matrix = _transformController.value;
    final double newScale = matrix.getMaxScaleOnAxis();

    setState(() {
      // 변환이 일어나면 인디케이터들을 보이도록 설정
      _showScaleIndicator = true;
      _currentScale = newScale;
    });

    // 만약 원본 크기(1.0x)로 돌아왔다면, 2초 후에 인디케이터를 자동으로 숨깁니다
    // 이는 UX 개선을 위한 기능입니다
    if (newScale == 1.0) {
      _scaleIndicatorTimer?.cancel(); // 기존 타이머가 있다면 취소
      _scaleIndicatorTimer = Timer(const Duration(seconds: 2), () {
        if (mounted) { // 타이머 콜백에서도 mounted 체크
          setState(() {
            _showScaleIndicator = false;
          });
        }
      });
    }
  }

  @override
  void dispose() {
    // 메모리 누수 방지를 위한 정리 작업
    _scaleIndicatorTimer?.cancel();                                // 타이머 정리
    _transformController.removeListener(_onTransformChange);       // 리스너 제거
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // 메인 InteractiveViewer - 사용자가 실제로 상호작용하는 부분
          InteractiveViewer(
            key: _containerKey,                              // 컨테이너 크기 추적용
            transformationController: _transformController,  // ViewportIndicator와 공유
            maxScale: 2.0,                                  // 최대 2배까지 확대 가능
            minScale: 1.0,                                  // 축소는 원본 크기까지만
            child: YourContentWidget(                       // 실제 표시할 콘텐츠
              key: _contentKey,                             // 콘텐츠 크기 추적용
            ),
          ),

          // ViewportIndicator - 현재 보이는 영역을 시각적으로 표시
          Positioned(
            top: 60,                                        // 상단에서 60px 아래
            right: 20,                                      // 오른쪽에서 20px 안쪽
            child: AnimatedOpacity(
              // 확대/축소 시에만 나타나고, 평상시에는 숨겨집니다
              opacity: _showScaleIndicator ? 1.0 : 0.0,
              duration: const Duration(milliseconds: 300),  // 부드러운 페이드 효과
              child: ViewportIndicator(
                transformController: _transformController,  // 변환 상태 공유
                contentKey: _contentKey,                    // 콘텐츠 크기 정보
                containerKey: _containerKey,                // 컨테이너 크기 정보
              ),
            ),
          ),

          // 배율 인디케이터 - 현재 확대 배율을 숫자로 표시 (예: "1.5x")
          Positioned(
            top: 20,                                        // ViewportIndicator보다 위쪽에 배치
            right: 20,
            child: AnimatedOpacity(
              opacity: _showScaleIndicator ? 1.0 : 0.0,     // ViewportIndicator와 함께 표시/숨김
              duration: const Duration(milliseconds: 300),
              child: Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: Colors.black.withOpacity(0.7),     // 반투명한 어두운 배경
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Text(
                  '${_currentScale.toStringAsFixed(1)}x',   // 소수점 1자리까지 표시
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

여기서 핵심은 하나의 TransformationController를 공유한다는 점이다. 이게 진짜 중요하다. 처음에 이걸 몰라서 각각 따로 만들었다가 동기화가 안 되어서 고생했다.

내가 겪은 실제 삽질 과정

구현하면서 마주친 문제들을 정리해보자:

1단계: 문제 발견

  • ViewportIndicator 만들어서 화면에 올렸는데 아무것도 안 보임
  • TransformationController 리스너는 잘 호출되는데 화면이 업데이트 안 됨

2단계: 첫 번째 시도

  • setState()를 호출했는데도 ViewportIndicator가 업데이트 안 됨
  • AnimatedBuilder 사용해봤지만 여전히 안 됨

3단계: 원인 파악

  • GlobalKey를 통한 크기 정보가 null로 나옴
  • addPostFrameCallback 없이 바로 크기에 접근했던 게 문제였음

4단계: 해결

  • addPostFrameCallback으로 렌더링 완료 후 크기 정보 가져오기
  • mounted 체크 추가해서 dispose 후 setState 방지
  • shouldRepaint 제대로 구현해서 성능 최적화

5단계: 최종 확인

  • 확대/축소 시 ViewportIndicator가 실시간으로 업데이트 됨
  • 경계 처리도 제대로 동작함
  • 메모리 누수 없이 깔끔하게 정리됨

삽질하면서 배운 것들

💡 GlobalKey의 활용법

GlobalKey는 단순히 위젯을 구분하는 용도가 아니라, 렌더링된 위젯의 실제 정보에 접근하는 강력한 도구입니다:

// 위젯의 실제 크기 얻기
final size = myKey.currentContext?.size;

// 위젯의 화면상 위치 얻기  
final renderBox = myKey.currentContext?.findRenderObject() as RenderBox?;
final position = renderBox?.localToGlobal(Offset.zero);

주의할 점: GlobalKey는 위젯이 완전히 렌더링된 후에만 정보를 제공하므로, addPostFrameCallback을 사용해서 안전하게 접근해야 해요.

🎛️ TransformationController의 마법

TransformationController는 Matrix4를 통해 복잡한 2D 변환 정보를 관리합니다:

final matrix = controller.value;
final scale = matrix.getMaxScaleOnAxis();        // 확대 배율
final translation = matrix.getTranslation();     // 이동 거리

이를 통해 사용자의 제스처를 실시간으로 추적하고 다른 위젯과 동기화할 수 있어요.

📐 좌표계 변환의 핵심 공식

가장 중요한 부분은 실제 콘텐츠 좌표를 ViewportIndicator 좌표로 변환하는 것입니다:

// 핵심 변환 공식
final scaleX = viewportIndicatorWidth / contentWidth;
final scaleY = viewportIndicatorHeight / contentHeight;

// 실제 적용
final indicatorX = realContentX * scaleX;
final indicatorY = realContentY * scaleY;

이 공식을 이해하면 다양한 크기의 콘텐츠에 대해 일관된 ViewportIndicator를 만들 수 있어요.

⚡ 성능 최적화 팁

  1. shouldRepaint 구현: 불필요한 다시 그리기 방지
  2. @override bool shouldRepaint(covariant _ViewportPainter oldDelegate) { return oldDelegate.visibleArea != visibleArea; // 실제 변경된 경우만 }
  3. Timer 활용: 사용자 경험을 위한 자동 숨김 기능
  4. mounted 체크: dispose 후 setState 호출로 인한 오류 방지

개발 과정에서 마주친 함정들 🕳️

1. 크기 정보가 null인 문제

위젯이 아직 렌더링되지 않은 상태에서 크기에 접근하면 null이 반환됩니다. 해결책:

WidgetsBinding.instance.addPostFrameCallback((_) {
  // 렌더링 완료 후 실행
});

2. 메모리 누수

리스너와 타이머를 제대로 정리하지 않으면 메모리 누수가 발생할 수 있어요:

@override
void dispose() {
  _timer?.cancel();
  _controller.removeListener(_listener);
  super.dispose();
}

3. 경계 처리

사용자가 극한으로 이동했을 때 ViewportIndicator가 영역을 벗어나는 문제:

final clampedArea = Rect.fromLTWH(
  area.left.clamp(0.0, size.width),  // 경계 내로 제한
  // ...
);

이런 곳에 활용할 수 있어요 🎯

  • 📸 이미지 뷰어: 대용량 이미지를 확대해서 볼 때
  • 🗺️ 지도 앱: 지도 탐색 시 현재 위치 표시
  • 📊 다이어그램 뷰어: 복잡한 차트나 플로우차트 탐색
  • 📄 PDF 뷰어: 문서 내 현재 위치 표시
  • 🎮 게임: 미니맵이 필요한 모든 게임

더 나아가기 🚀

현재 구현은 기본적인 ViewportIndicator지만, 다음과 같은 기능들을 추가해볼 수 있어요:

  1. 터치 인터랙션: ViewportIndicator를 터치해서 해당 위치로 이동
  2. 썸네일 표시: 실제 콘텐츠의 축소 미리보기 제공
  3. 애니메이션 효과: 부드러운 전환 애니메이션
  4. 멀티 레이어: 여러 개의 콘텐츠 레이어 동시 관리

마무리

ViewportIndicator 구현은 생각보다 복잡한 작업이었다. 단순히 작은 박스 하나 그리는 게 아니라 좌표계 변환, 실시간 동기화, 성능 최적화까지 고려해야 할 것들이 많았다.

가장 중요한 건 좌표계 변환 공식을 제대로 이해하는 것이다. 이 부분만 이해하면 나머지는 Flutter의 기본 위젯들로 충분히 해결할 수 있다.

그리고 혹시 ViewportIndicator를 만들다가 막힌다면, 당황하지 말고 차근차근 문제를 찾아보자. 대부분은 위에서 내가 겪은 삽질들 중 하나일 것이다:

  • GlobalKey로 크기 정보 가져올 때 addPostFrameCallback 빼먹기
  • TransformationController 리스너 정리 안 하기
  • shouldRepaint 제대로 구현 안 해서 성능 문제 발생
  • 경계 처리(clamp) 빼먹어서 ViewportIndicator가 화면 밖으로 나가기

이런 문제들만 주의하면 충분히 괜찮은 ViewportIndicator를 만들 수 있다. 실제로 적용해보니 사용자들이 확대/축소 기능을 훨씬 편하게 사용하는 걸 볼 수 있었다. 🎉

반응형