본문 바로가기
프론트엔드

Flutter OverlayEntry: 원리, 실전 사용법, 동적 오버레이 UI 구현 가이드

by devcomet 2025. 7. 29.
728x90
반응형

Flutter OverlayEntry implementation guide showing dynamic overlay UI components and popup widgets for mobile app development
Flutter OverlayEntry: 원리, 실전 사용법, 동적 오버레이 UI 구현 가이드

 

Flutter OverlayEntry는 다른 위젯 위에 독립적인 UI 요소를 동적으로 표시할 수 있는 강력한 도구로, 팝업, 툴팁, 드롭다운 등 다양한 커스텀 오버레이 구현에 필수적인 2025년 플러터 개발의 핵심 기술입니다.


OverlayEntry란 무엇인가?

플러터에서 OverlayEntry란 무엇인지 설명 이미지

 

Flutter에서 OverlayEntry는 다른 위젯들 위에 떠다니는 독립적인 UI 요소를 생성할 수 있는 클래스입니다.

기존 위젯 트리와는 별도로 작동하여 전체 앱 화면 위에 오버레이를 표시할 수 있습니다.

OverlayEntry overlayEntry = OverlayEntry(
  builder: (BuildContext context) {
    return Positioned(
      top: 100,
      left: 50,
      child: Material(
        color: Colors.blue,
        child: Text('Hello Overlay!'),
      ),
    );
  },
);

 

flutter overlayentry는 다음과 같은 특징을 가집니다

  • 독립적인 생명주기: 다른 위젯과 독립적으로 관리됩니다
  • 동적 위젯 추가/제거: 런타임에 동적으로 추가하고 제거할 수 있습니다
  • 상위 위젯 접근: 앱 전체의 Overlay에 접근하여 최상위 레이어에 표시됩니다
  • 애니메이션 적용: 복잡한 애니메이션과 상호작용을 구현할 수 있습니다

OverlayEntry vs 다른 UI 컴포넌트 비교

특성 OverlayEntry Dialog ModalBottomSheet Tooltip
생명주기 관리 수동 자동 자동 자동
위치 자유도 높음 중간 낮음 낮음
커스텀 애니메이션 완전 자유 제한적 제한적 제한적
복잡도 높음 낮음 낮음 낮음
성능 우수 보통 보통 우수

OverlayEntry의 핵심 원리

OverlayEntry의 핵심 원리 이미지

Widget 트리와의 관계

MaterialApp
├── Navigator
│   └── Overlay (자동 생성)
│       ├── Route 위젯들
│       └── OverlayEntry들 ← 여기에 삽입
└── 일반 Widget 트리

flutter 오버레이 구현에서 가장 중요한 것은 OverlayEntry가 기본 위젯 트리와는 별도의 ui 레이어에서 동작한다는 점입니다.

작동 메커니즘

  1. OverlayState 획득: Overlay.of(context)로 현재 Overlay 상태를 가져옵니다
  2. Entry 생성: OverlayEntry 객체를 생성하고 builder 함수를 정의합니다
  3. 삽입: overlayState.insert(entry)로 오버레이에 추가합니다
  4. 제거: entry.remove()로 오버레이에서 제거합니다

기본 OverlayEntry 구현 방법

기본 OverlayEntry 구현 방법 이미지

1. 단순 오버레이 생성

flutter overlayentry 사용법의 가장 기본적인 예제입니다

class SimpleOverlayExample extends StatefulWidget {
  @override
  _SimpleOverlayExampleState createState() => _SimpleOverlayExampleState();
}

class _SimpleOverlayExampleState extends State<SimpleOverlayExample> {
  OverlayEntry? _overlayEntry;

  void _showOverlay() {
    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        top: 100,
        left: 50,
        child: Material(
          elevation: 4,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            padding: EdgeInsets.all(16),
            child: Text(
              'Simple Overlay',
              style: TextStyle(fontSize: 16),
            ),
          ),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('OverlayEntry Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: _showOverlay,
              child: Text('Show Overlay'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _hideOverlay,
              child: Text('Hide Overlay'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _hideOverlay();
    super.dispose();
  }
}

2. 자동 제거 오버레이

flutter 동적 팝업을 구현할 때 유용한 자동 제거 기능

void _showAutoRemoveOverlay() {
  final overlayEntry = OverlayEntry(
    builder: (context) => Positioned(
      top: MediaQuery.of(context).size.height * 0.1,
      left: 20,
      right: 20,
      child: Material(
        color: Colors.orange.withOpacity(0.9),
        borderRadius: BorderRadius.circular(12),
        child: Container(
          padding: EdgeInsets.all(16),
          child: Text(
            '3초 후 자동으로 사라집니다',
            textAlign: TextAlign.center,
            style: TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    ),
  );

  Overlay.of(context).insert(overlayEntry);

  // 3초 후 자동 제거
  Timer(Duration(seconds: 3), () {
    overlayEntry.remove();
  });
}

고급 OverlayEntry 활용법

1. 위치 기반 동적 오버레이

flutter 커스텀 오버레이에서 가장 강력한 기능 중 하나는 특정 위젝에 따라 위치를 동적으로 조정하는 것입니다

class PositionBasedOverlay extends StatefulWidget {
  @override
  _PositionBasedOverlayState createState() => _PositionBasedOverlayState();
}

class _PositionBasedOverlayState extends State<PositionBasedOverlay> {
  final GlobalKey _buttonKey = GlobalKey();
  OverlayEntry? _overlayEntry;

  void _showContextMenu() {
    final RenderBox renderBox = 
        _buttonKey.currentContext!.findRenderObject() as RenderBox;
    final position = renderBox.localToGlobal(Offset.zero);
    final size = renderBox.size;

    _overlayEntry = OverlayEntry(
      builder: (context) => Positioned(
        left: position.dx,
        top: position.dy + size.height + 8,
        child: Material(
          elevation: 8,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            width: 200,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                ListTile(
                  leading: Icon(Icons.edit),
                  title: Text('편집'),
                  onTap: () => _hideOverlay(),
                ),
                ListTile(
                  leading: Icon(Icons.delete),
                  title: Text('삭제'),
                  onTap: () => _hideOverlay(),
                ),
                ListTile(
                  leading: Icon(Icons.share),
                  title: Text('공유'),
                  onTap: () => _hideOverlay(),
                ),
              ],
            ),
          ),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          key: _buttonKey,
          onPressed: _showContextMenu,
          child: Text('Context Menu'),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _hideOverlay();
    super.dispose();
  }
}

2. CompositedTransformTarget/Follower를 이용한 고급 위치 추적

overlayentry 예제에서 가장 정교한 위치 추적을 구현하는 방법

class AdvancedPositionOverlay extends StatefulWidget {
  @override
  _AdvancedPositionOverlayState createState() => _AdvancedPositionOverlayState();
}

class _AdvancedPositionOverlayState extends State<AdvancedPositionOverlay> {
  final LayerLink _layerLink = LayerLink();
  OverlayEntry? _overlayEntry;

  void _showAdvancedOverlay() {
    _overlayEntry = OverlayEntry(
      builder: (context) => CompositedTransformFollower(
        link: _layerLink,
        offset: Offset(0, 60), // 타겟 위젯 아래 60px
        child: Material(
          elevation: 4,
          borderRadius: BorderRadius.circular(12),
          child: Container(
            width: 250,
            padding: EdgeInsets.all(16),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  '고급 오버레이',
                  style: TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: 8),
                Text(
                  '이 오버레이는 타겟 위젯을 정확히 따라다닙니다.',
                  textAlign: TextAlign.center,
                ),
                SizedBox(height: 12),
                ElevatedButton(
                  onPressed: _hideOverlay,
                  child: Text('닫기'),
                ),
              ],
            ),
          ),
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Advanced Position Overlay')),
      body: Center(
        child: CompositedTransformTarget(
          link: _layerLink,
          child: ElevatedButton(
            onPressed: _showAdvancedOverlay,
            child: Text('Show Advanced Overlay'),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _hideOverlay();
    super.dispose();
  }
}

 


실전 프로젝트: 커스텀 드롭다운 구현

실전 프로젝트 - 커스텀 드롭다운 구현 이미지

 

flutter 팝업 만들기의 완성형인 커스텀 드롭다운을 구현해보겠습니다

class CustomDropdown extends StatefulWidget {
  final List<String> items;
  final String? value;
  final ValueChanged<String>? onChanged;
  final String hint;

  const CustomDropdown({
    Key? key,
    required this.items,
    this.value,
    this.onChanged,
    this.hint = 'Select an item',
  }) : super(key: key);

  @override
  _CustomDropdownState createState() => _CustomDropdownState();
}

class _CustomDropdownState extends State<CustomDropdown> {
  final GlobalKey _dropdownKey = GlobalKey();
  final LayerLink _layerLink = LayerLink();
  OverlayEntry? _overlayEntry;
  bool _isOpen = false;

  void _toggleDropdown() {
    if (_isOpen) {
      _closeDropdown();
    } else {
      _openDropdown();
    }
  }

  void _openDropdown() {
    _overlayEntry = OverlayEntry(
      builder: (context) => GestureDetector(
        behavior: HitTestBehavior.translucent,
        onTap: _closeDropdown,
        child: Stack(
          children: [
            // 전체 화면을 덮는 투명한 영역 (터치시 닫기)
            Positioned.fill(
              child: Container(color: Colors.transparent),
            ),
            // 실제 드롭다운 메뉴
            CompositedTransformFollower(
              link: _layerLink,
              offset: Offset(0, 60),
              child: Material(
                elevation: 8,
                borderRadius: BorderRadius.circular(8),
                child: Container(
                  width: _getButtonWidth(),
                  constraints: BoxConstraints(maxHeight: 200),
                  child: ListView.builder(
                    padding: EdgeInsets.zero,
                    shrinkWrap: true,
                    itemCount: widget.items.length,
                    itemBuilder: (context, index) {
                      final item = widget.items[index];
                      final isSelected = item == widget.value;

                      return InkWell(
                        onTap: () {
                          widget.onChanged?.call(item);
                          _closeDropdown();
                        },
                        child: Container(
                          padding: EdgeInsets.symmetric(
                            horizontal: 16,
                            vertical: 12,
                          ),
                          decoration: BoxDecoration(
                            color: isSelected 
                                ? Colors.blue.withOpacity(0.1)
                                : Colors.transparent,
                          ),
                          child: Row(
                            children: [
                              Expanded(
                                child: Text(
                                  item,
                                  style: TextStyle(
                                    color: isSelected 
                                        ? Colors.blue 
                                        : Colors.black87,
                                    fontWeight: isSelected 
                                        ? FontWeight.w600 
                                        : FontWeight.normal,
                                  ),
                                ),
                              ),
                              if (isSelected)
                                Icon(
                                  Icons.check,
                                  color: Colors.blue,
                                  size: 20,
                                ),
                            ],
                          ),
                        ),
                      );
                    },
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
    setState(() => _isOpen = true);
  }

  void _closeDropdown() {
    _overlayEntry?.remove();
    _overlayEntry = null;
    setState(() => _isOpen = false);
  }

  double _getButtonWidth() {
    final RenderBox? renderBox = 
        _dropdownKey.currentContext?.findRenderObject() as RenderBox?;
    return renderBox?.size.width ?? 200;
  }

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      link: _layerLink,
      child: InkWell(
        key: _dropdownKey,
        onTap: _toggleDropdown,
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey.shade300),
            borderRadius: BorderRadius.circular(8),
          ),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  widget.value ?? widget.hint,
                  style: TextStyle(
                    color: widget.value != null 
                        ? Colors.black87 
                        : Colors.grey.shade600,
                  ),
                ),
              ),
              Icon(
                _isOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
                color: Colors.grey.shade600,
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _closeDropdown();
    super.dispose();
  }
}

커스텀 드롭다운 사용법

class DropdownExample extends StatefulWidget {
  @override
  _DropdownExampleState createState() => _DropdownExampleState();
}

class _DropdownExampleState extends State<DropdownExample> {
  String? selectedValue;
  final List<String> items = [
    'Flutter',
    'React Native',
    'Xamarin',
    'Native Android',
    'Native iOS',
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Custom Dropdown Example')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '개발 프레임워크 선택:',
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.w600,
              ),
            ),
            SizedBox(height: 8),
            CustomDropdown(
              items: items,
              value: selectedValue,
              hint: '프레임워크를 선택하세요',
              onChanged: (value) {
                setState(() => selectedValue = value);
              },
            ),
            SizedBox(height: 20),
            if (selectedValue != null)
              Container(
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: Colors.blue.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(
                  '선택된 프레임워크: $selectedValue',
                  style: TextStyle(
                    color: Colors.blue.shade700,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

OverlayEntry 사용 시 주의사항과 최적화

1. 메모리 누수 방지

flutter overlayentry 사용법에서 가장 중요한 것은 메모리 누수 방지입니다

class SafeOverlayWidget extends StatefulWidget {
  @override
  _SafeOverlayWidgetState createState() => _SafeOverlayWidgetState();
}

class _SafeOverlayWidgetState extends State<SafeOverlayWidget> {
  OverlayEntry? _overlayEntry;

  @override
  void dispose() {
    // dispose에서 반드시 오버레이 제거
    _overlayEntry?.remove();
    super.dispose();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 페이지 이동 시에도 오버레이 제거
    _overlayEntry?.remove();
    _overlayEntry = null;
  }
}

2. Focus 관리

overlayentry 예제에서 텍스트 입력 필드를 사용할 때는 FocusScope가 필요합니다

OverlayEntry _createFocusableOverlay() {
  return OverlayEntry(
    builder: (context) => Positioned(
      top: 100,
      left: 50,
      child: FocusScope(
        node: FocusScopeNode(),
        child: Material(
          child: Container(
            width: 300,
            padding: EdgeInsets.all(16),
            child: TextField(
              autofocus: true,
              decoration: InputDecoration(
                hintText: '검색어를 입력하세요',
              ),
              onChanged: (value) {
                // 상태 변경 시 markNeedsBuild 호출
                _overlayEntry?.markNeedsBuild();
              },
            ),
          ),
        ),
      ),
    ),
  );
}

3. 화면 크기 변경 대응

2025 플러터 ui에서는 반응형 디자인이 중요합니다

OverlayEntry _createResponsiveOverlay() {
  return OverlayEntry(
    builder: (context) {
      final screenSize = MediaQuery.of(context).size;
      final isTablet = screenSize.width > 600;

      return Positioned(
        top: screenSize.height * 0.1,
        left: isTablet ? screenSize.width * 0.2 : 20,
        right: isTablet ? screenSize.width * 0.2 : 20,
        child: Material(
          borderRadius: BorderRadius.circular(12),
          child: Container(
            padding: EdgeInsets.all(isTablet ? 24 : 16),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  '반응형 오버레이',
                  style: TextStyle(
                    fontSize: isTablet ? 20 : 16,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: isTablet ? 16 : 12),
                Text('화면 크기에 따라 크기가 조정됩니다.'),
              ],
            ),
          ),
        ),
      );
    },
  );
}

애니메이션을 이용한 고급 오버레이

flutter 동적 ui 구현에서 애니메이션은 사용자 경험을 크게 향상시킵니다

class AnimatedOverlayExample extends StatefulWidget {
  @override
  _AnimatedOverlayExampleState createState() => _AnimatedOverlayExampleState();
}

class _AnimatedOverlayExampleState extends State<AnimatedOverlayExample>
    with TickerProviderStateMixin {
  OverlayEntry? _overlayEntry;
  late AnimationController _animationController;
  late Animation<double> _scaleAnimation;
  late Animation<double> _opacityAnimation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.elasticOut,
    ));

    _opacityAnimation = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(CurvedAnimation(
      parent: _animationController,
      curve: Curves.easeIn,
    ));
  }

  void _showAnimatedOverlay() {
    _overlayEntry = OverlayEntry(
      builder: (context) => AnimatedBuilder(
        animation: _animationController,
        builder: (context, child) {
          return Positioned.fill(
            child: Material(
              color: Colors.black.withOpacity(0.5 * _opacityAnimation.value),
              child: Center(
                child: Transform.scale(
                  scale: _scaleAnimation.value,
                  child: Container(
                    width: 300,
                    padding: EdgeInsets.all(20),
                    decoration: BoxDecoration(
                      color: Colors.white,
                      borderRadius: BorderRadius.circular(16),
                      boxShadow: [
                        BoxShadow(
                          color: Colors.black26,
                          blurRadius: 10,
                          offset: Offset(0, 5),
                        ),
                      ],
                    ),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Icon(
                          Icons.star,
                          size: 50,
                          color: Colors.amber,
                        ),
                        SizedBox(height: 16),
                        Text(
                          '애니메이션 오버레이',
                          style: TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                        SizedBox(height: 8),
                        Text(
                          '부드러운 애니메이션과 함께 나타납니다.',
                          textAlign: TextAlign.center,
                          style: TextStyle(color: Colors.grey.shade600),
                        ),
                        SizedBox(height: 20),
                        ElevatedButton(
                          onPressed: _hideAnimatedOverlay,
                          child: Text('닫기'),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );

    Overlay.of(context).insert(_overlayEntry!);
    _animationController.forward();
  }

  void _hideAnimatedOverlay() async {
    await _animationController.reverse();
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animated Overlay')),
      body: Center(
        child: ElevatedButton(
          onPressed: _showAnimatedOverlay,
          child: Text('Show Animated Overlay'),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _hideAnimatedOverlay();
    _animationController.dispose();
    super.dispose();
  }
}

성능 최적화 팁

1. markNeedsBuild 활용

flutter overlay 실전에서 상태 변경 시 성능을 위해 필요한 부분만 다시 빌드

class PerformantOverlay extends StatefulWidget {
  @override
  _PerformantOverlayState createState() => _PerformantOverlayState();
}

class _PerformantOverlayState extends State<PerformantOverlay> {
  OverlayEntry? _overlayEntry;
  String _searchText = '';

  void _updateSearch(String value) {
    _searchText = value;
    // 전체 위젯을 다시 빌드하지 않고 오버레이만 업데이트
    _overlayEntry?.markNeedsBuild();
  }

  OverlayEntry _createSearchOverlay() {
    return OverlayEntry(
      builder: (context) => Positioned(
        top: 100,
        left: 20,
        right: 20,
        child: Material(
          elevation: 4,
          borderRadius: BorderRadius.circular(8),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                onChanged: _updateSearch,
                decoration: InputDecoration(
                  hintText: '검색어 입력',
                  prefixIcon: Icon(Icons.search),
                ),
              ),
              if (_searchText.isNotEmpty)
                Container(
                  height: 200,
                  child: ListView.builder(
                    itemCount: _getFilteredResults().length,
                    itemBuilder: (context, index) {
                      final item = _getFilteredResults()[index];
                      return ListTile(
                        title: Text(item),
                        onTap: () => _selectItem(item),
                      );
                    },
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }

  List<String> _getFilteredResults() {
    // 검색 로직 구현
    final allItems = ['Flutter', 'Dart', 'Widget', 'OverlayEntry'];
    return allItems
        .where((item) => item.toLowerCase().contains(_searchText.toLowerCase()))
        .toList();
  }

  void _selectItem(String item) {
    print('Selected: $item');
    _hideOverlay();
  }

  void _showOverlay() {
    _overlayEntry = _createSearchOverlay();
    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Performant Overlay')),
      body: Center(
        child: ElevatedButton(
          onPressed: _showOverlay,
          child: Text('Show Search Overlay'),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _hideOverlay();
    super.dispose();
  }
}

2. maintainState 옵션 활용

복잡한 상태를 가진 위젯의 경우 maintainState: true 옵션을 사용

OverlayEntry _createStatefulOverlay() {
  return OverlayEntry(
    maintainState: true, // 상태 유지
    builder: (context) => Positioned(
      top: 50,
      left: 20,
      right: 20,
      child: StatefulOverlayContent(),
    ),
  );
}

class StatefulOverlayContent extends StatefulWidget {
  @override
  _StatefulOverlayContentState createState() => _StatefulOverlayContentState();
}

class _StatefulOverlayContentState extends State<StatefulOverlayContent> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 4,
      borderRadius: BorderRadius.circular(12),
      child: Container(
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              '상태가 유지되는 오버레이',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 16),
            Text(
              '카운터: $_counter',
              style: TextStyle(fontSize: 24),
            ),
            SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                ElevatedButton(
                  onPressed: () => setState(() => _counter--),
                  child: Text('-'),
                ),
                ElevatedButton(
                  onPressed: () => setState(() => _counter++),
                  child: Text('+'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

OverlayEntry vs OverlayPortal (Flutter 3.7+)

2025 플러터 ui 업데이트에서 새롭게 도입된 OverlayPortal과의 비교

OverlayPortal 장점

class OverlayPortalExample extends StatefulWidget {
  @override
  _OverlayPortalExampleState createState() => _OverlayPortalExampleState();
}

class _OverlayPortalExampleState extends State<OverlayPortalExample> {
  final OverlayPortalController _controller = OverlayPortalController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('OverlayPortal Example')),
      body: Center(
        child: OverlayPortal(
          controller: _controller,
          overlayChildBuilder: (context) {
            return Positioned(
              top: 100,
              left: 50,
              child: Material(
                elevation: 4,
                borderRadius: BorderRadius.circular(8),
                child: Container(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text('OverlayPortal로 만든 오버레이'),
                      SizedBox(height: 8),
                      ElevatedButton(
                        onPressed: () => _controller.hide(),
                        child: Text('닫기'),
                      ),
                    ],
                  ),
                ),
              ),
            );
          },
          child: ElevatedButton(
            onPressed: () => _controller.show(),
            child: Text('Show OverlayPortal'),
          ),
        ),
      ),
    );
  }
}

언제 어떤 것을 사용할까?

상황 추천 방법 이유
간단한 팝업/툴팁 OverlayPortal 더 간단한 API
복잡한 위치 제어 OverlayEntry 더 세밀한 제어 가능
InheritedWidget 의존 OverlayPortal 자동으로 상속받음
수동 생명주기 관리 OverlayEntry 더 유연한 제어

실전 활용 사례들

1. 토스트 메시지 시스템

flutter ui 팁으로 유용한 토스트 메시지 구현

class ToastManager {
  static OverlayEntry? _currentToast;

  static void showToast({
    required BuildContext context,
    required String message,
    Duration duration = const Duration(seconds: 3),
    ToastType type = ToastType.info,
  }) {
    // 기존 토스트가 있다면 제거
    _currentToast?.remove();

    _currentToast = OverlayEntry(
      builder: (context) => _ToastWidget(
        message: message,
        type: type,
        onDismiss: () {
          _currentToast?.remove();
          _currentToast = null;
        },
      ),
    );

    Overlay.of(context).insert(_currentToast!);

    // 자동 제거
    Timer(duration, () {
      _currentToast?.remove();
      _currentToast = null;
    });
  }
}

enum ToastType { success, error, warning, info }

class _ToastWidget extends StatefulWidget {
  final String message;
  final ToastType type;
  final VoidCallback onDismiss;

  const _ToastWidget({
    required this.message,
    required this.type,
    required this.onDismiss,
  });

  @override
  _ToastWidgetState createState() => _ToastWidgetState();
}

class _ToastWidgetState extends State<_ToastWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Offset> _slideAnimation;
  late Animation<double> _opacityAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: this,
    );

    _slideAnimation = Tween<Offset>(
      begin: Offset(0, -1),
      end: Offset.zero,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,
    ));

    _opacityAnimation = Tween<double>(
      begin: 0,
      end: 1,
    ).animate(_controller);

    _controller.forward();
  }

  Color _getBackgroundColor() {
    switch (widget.type) {
      case ToastType.success:
        return Colors.green;
      case ToastType.error:
        return Colors.red;
      case ToastType.warning:
        return Colors.orange;
      case ToastType.info:
        return Colors.blue;
    }
  }

  IconData _getIcon() {
    switch (widget.type) {
      case ToastType.success:
        return Icons.check_circle;
      case ToastType.error:
        return Icons.error;
      case ToastType.warning:
        return Icons.warning;
      case ToastType.info:
        return Icons.info;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: MediaQuery.of(context).padding.top + 20,
      left: 20,
      right: 20,
      child: SlideTransition(
        position: _slideAnimation,
        child: FadeTransition(
          opacity: _opacityAnimation,
          child: Material(
            color: Colors.transparent,
            child: Container(
              padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              decoration: BoxDecoration(
                color: _getBackgroundColor(),
                borderRadius: BorderRadius.circular(8),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black26,
                    blurRadius: 8,
                    offset: Offset(0, 2),
                  ),
                ],
              ),
              child: Row(
                children: [
                  Icon(
                    _getIcon(),
                    color: Colors.white,
                    size: 20,
                  ),
                  SizedBox(width: 12),
                  Expanded(
                    child: Text(
                      widget.message,
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 14,
                        fontWeight: FontWeight.w500,
                      ),
                    ),
                  ),
                  GestureDetector(
                    onTap: widget.onDismiss,
                    child: Icon(
                      Icons.close,
                      color: Colors.white,
                      size: 18,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

// 사용 예제
class ToastExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Toast Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => ToastManager.showToast(
                context: context,
                message: '성공적으로 저장되었습니다!',
                type: ToastType.success,
              ),
              child: Text('Success Toast'),
            ),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => ToastManager.showToast(
                context: context,
                message: '오류가 발생했습니다.',
                type: ToastType.error,
              ),
              child: Text('Error Toast'),
            ),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => ToastManager.showToast(
                context: context,
                message: '주의해주세요!',
                type: ToastType.warning,
              ),
              child: Text('Warning Toast'),
            ),
          ],
        ),
      ),
    );
  }
}

2. 이미지 줌 오버레이

flutter 커스텀 오버레이로 구현하는 이미지 뷰어

class ImageZoomOverlay {
  static void showImageZoom({
    required BuildContext context,
    required String imageUrl,
    String? heroTag,
  }) {
    final overlayEntry = OverlayEntry(
      builder: (context) => _ImageZoomWidget(
        imageUrl: imageUrl,
        heroTag: heroTag,
        onClose: () {},
      ),
    );

    Overlay.of(context).insert(overlayEntry);
  }
}

class _ImageZoomWidget extends StatefulWidget {
  final String imageUrl;
  final String? heroTag;
  final VoidCallback onClose;

  const _ImageZoomWidget({
    required this.imageUrl,
    this.heroTag,
    required this.onClose,
  });

  @override
  _ImageZoomWidgetState createState() => _ImageZoomWidgetState();
}

class _ImageZoomWidgetState extends State<_ImageZoomWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  final TransformationController _transformationController = 
      TransformationController();

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(milliseconds: 300),
      vsync: this,
    );
    _animation = Tween<double>(begin: 0, end: 1).animate(_controller);
    _controller.forward();
  }

  void _close() async {
    await _controller.reverse();
    widget.onClose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Material(
          color: Colors.black.withOpacity(0.9 * _animation.value),
          child: Stack(
            children: [
              // 배경 터치시 닫기
              Positioned.fill(
                child: GestureDetector(
                  onTap: _close,
                  child: Container(color: Colors.transparent),
                ),
              ),
              // 이미지
              Center(
                child: Hero(
                  tag: widget.heroTag ?? widget.imageUrl,
                  child: InteractiveViewer(
                    transformationController: _transformationController,
                    maxScale: 5.0,
                    child: Image.network(
                      widget.imageUrl,
                      fit: BoxFit.contain,
                    ),
                  ),
                ),
              ),
              // 닫기 버튼
              Positioned(
                top: MediaQuery.of(context).padding.top + 16,
                right: 16,
                child: IconButton(
                  onPressed: _close,
                  icon: Icon(
                    Icons.close,
                    color: Colors.white,
                    size: 30,
                  ),
                ),
              ),
              // 리셋 버튼
              Positioned(
                bottom: MediaQuery.of(context).padding.bottom + 16,
                right: 16,
                child: FloatingActionButton(
                  mini: true,
                  onPressed: () {
                    _transformationController.value = Matrix4.identity();
                  },
                  child: Icon(Icons.zoom_out_map),
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _transformationController.dispose();
    super.dispose();
  }
}

디버깅 및 문제 해결

1. 일반적인 문제들

플러터 ui 팁으로 알아두면 유용한 문제 해결 방법들

class DebugOverlayHelper {
  // 문제 1: OverlayEntry가 표시되지 않음
  static void debugOverlayVisibility(BuildContext context) {
    final overlay = Overlay.of(context);
    print('Overlay found: ${overlay != null}');

    final overlayEntry = OverlayEntry(
      builder: (context) {
        print('OverlayEntry builder called');
        return Positioned(
          top: 100,
          left: 100,
          child: Container(
            width: 100,
            height: 100,
            color: Colors.red,
            child: Text('Debug'),
          ),
        );
      },
    );

    overlay.insert(overlayEntry);
    print('OverlayEntry inserted');
  }

  // 문제 2: setState called during build 에러
  static void safeBuildOverlay(BuildContext context) {
    // 다음 프레임에서 실행
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final overlayEntry = OverlayEntry(
        builder: (context) => Positioned(
          top: 50,
          left: 50,
          child: Material(
            child: Text('Safe overlay'),
          ),
        ),
      );

      Overlay.of(context).insert(overlayEntry);
    });
  }

  // 문제 3: 메모리 누수 감지
  static void detectMemoryLeaks() {
    int overlayCount = 0;

    void trackOverlay(OverlayEntry entry) {
      overlayCount++;
      print('Active overlays: $overlayCount');

      entry.addListener(() {
        if (!entry.mounted) {
          overlayCount--;
          print('Overlay removed. Active overlays: $overlayCount');
        }
      });
    }
  }
}

2. 성능 모니터링

class OverlayPerformanceMonitor {
  static void monitorOverlayPerformance(OverlayEntry entry) {
    final stopwatch = Stopwatch();

    entry.addListener(() {
      if (entry.mounted) {
        stopwatch.start();
        print('Overlay mounted');
      } else {
        stopwatch.stop();
        print('Overlay lifecycle: ${stopwatch.elapsedMilliseconds}ms');
      }
    });
  }

  static Widget buildWithPerformanceTracking({
    required Widget child,
    required String name,
  }) {
    return Builder(
      builder: (context) {
        final renderTime = Stopwatch()..start();

        return LayoutBuilder(
          builder: (context, constraints) {
            renderTime.stop();
            print('$name render time: ${renderTime.elapsedMicroseconds}μs');
            return child;
          },
        );
      },
    );
  }
}

모범 사례 및 팁

1. 재사용 가능한 오버레이 컴포넌트

flutter widget 트리 관리를 위한 재사용 가능한 컴포넌트

abstract class BaseOverlay {
  OverlayEntry? _entry;

  void show(BuildContext context) {
    if (_entry != null) return; // 이미 표시 중이면 무시

    _entry = createOverlayEntry(context);
    Overlay.of(context).insert(_entry!);
  }

  void hide() {
    _entry?.remove();
    _entry = null;
  }

  bool get isShowing => _entry != null;

  OverlayEntry createOverlayEntry(BuildContext context);
}

class LoadingOverlay extends BaseOverlay {
  final String message;

  LoadingOverlay({this.message = '로딩 중...'});

  @override
  OverlayEntry createOverlayEntry(BuildContext context) {
    return OverlayEntry(
      builder: (context) => Material(
        color: Colors.black54,
        child: Center(
          child: Container(
            padding: EdgeInsets.all(20),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(8),
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                CircularProgressIndicator(),
                SizedBox(height: 16),
                Text(message),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// 사용법
class LoadingExample extends StatefulWidget {
  @override
  _LoadingExampleState createState() => _LoadingExampleState();
}

class _LoadingExampleState extends State<LoadingExample> {
  final LoadingOverlay _loadingOverlay = LoadingOverlay();

  void _simulateNetworkCall() async {
    _loadingOverlay.show(context);

    // 네트워크 호출 시뮬레이션
    await Future.delayed(Duration(seconds: 3));

    _loadingOverlay.hide();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Loading Overlay Example')),
      body: Center(
        child: ElevatedButton(
          onPressed: _simulateNetworkCall,
          child: Text('Start Loading'),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _loadingOverlay.hide();
    super.dispose();
  }
}

2. 전역 오버레이 관리자

flutter 동적 팝업 시스템을 위한 전역 관리자

class GlobalOverlayManager {
  static final GlobalOverlayManager _instance = GlobalOverlayManager._internal();
  factory GlobalOverlayManager() => _instance;
  GlobalOverlayManager._internal();

  final Map<String, OverlayEntry> _overlays = {};
  final List<String> _overlayStack = [];

  void showOverlay({
    required String id,
    required BuildContext context,
    required WidgetBuilder builder,
    bool dismissible = true,
  }) {
    // 이미 존재하는 오버레이 제거
    hideOverlay(id);

    final overlayEntry = OverlayEntry(
      builder: (context) => dismissible
          ? GestureDetector(
              onTap: () => hideOverlay(id),
              behavior: HitTestBehavior.translucent,
              child: Stack(
                children: [
                  Positioned.fill(
                    child: Container(color: Colors.transparent),
                  ),
                  builder(context),
                ],
              ),
            )
          : builder(context),
    );

    _overlays[id] = overlayEntry;
    _overlayStack.add(id);
    Overlay.of(context).insert(overlayEntry);
  }

  void hideOverlay(String id) {
    final entry = _overlays.remove(id);
    entry?.remove();
    _overlayStack.remove(id);
  }

  void hideAllOverlays() {
    for (final entry in _overlays.values) {
      entry.remove();
    }
    _overlays.clear();
    _overlayStack.clear();
  }

  void hideTopOverlay() {
    if (_overlayStack.isNotEmpty) {
      final topId = _overlayStack.last;
      hideOverlay(topId);
    }
  }

  bool isOverlayVisible(String id) => _overlays.containsKey(id);

  int get overlayCount => _overlays.length;

  List<String> get visibleOverlayIds => List.from(_overlayStack);
}

// 사용 예제
class GlobalOverlayExample extends StatelessWidget {
  final GlobalOverlayManager _overlayManager = GlobalOverlayManager();

  void _showNotification(BuildContext context) {
    _overlayManager.showOverlay(
      id: 'notification',
      context: context,
      builder: (context) => Positioned(
        top: 100,
        right: 20,
        child: Material(
          elevation: 4,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            width: 300,
            padding: EdgeInsets.all(16),
            child: Text('새로운 알림이 있습니다!'),
          ),
        ),
      ),
    );

    // 5초 후 자동 제거
    Timer(Duration(seconds: 5), () {
      _overlayManager.hideOverlay('notification');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Global Overlay Manager')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => _showNotification(context),
              child: Text('Show Notification'),
            ),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: _overlayManager.hideAllOverlays,
              child: Text('Hide All Overlays'),
            ),
          ],
        ),
      ),
    );
  }
}

마무리 및 추가 학습 자료

플러터 오버레이엔트리 - 마무리 및 실전 활용 사례 이미지

 

Flutter OverlayEntry는 강력하고 유연한 UI 컴포넌트 개발을 위한 필수 도구입니다.

이 가이드에서 다룬 flutter overlayentry 사용법을 마스터하면 다음과 같은 이점을 얻을 수 있습니다

핵심 포인트 요약

  1. 기본 개념 이해: OverlayEntry는 독립적인 UI 레이어에서 작동합니다
  2. 생명주기 관리: dispose()에서 반드시 오버레이를 제거해야 합니다
  3. 성능 최적화: markNeedsBuild()를 활용하여 필요한 부분만 업데이트합니다
  4. 애니메이션 적용: AnimationController와 함께 사용하여 부드러운 전환을 구현합니다
  5. 상호작용 구현: GestureDetector와 함께 사용하여 사용자 상호작용을 처리합니다

실무에서의 활용

  • 모달 구현: 커스텀 다이얼로그와 바텀시트 대체
  • 팝업 닫기: 외부 터치로 닫기 기능 구현
  • 상위 위젯 접근: 전체 앱 레벨에서의 UI 컨트롤
  • 코드 스니펫: 재사용 가능한 컴포넌트 라이브러리 구축

관련 참고 자료

더 깊이 있는 학습을 위한 공식 문서들

2025 플러터 ui 트렌드를 따라가며 지속적으로 학습하여 더 나은 사용자 경험을 제공하는 앱을 개발해보세요!

728x90
반응형