Flutter OverlayEntry는 다른 위젯 위에 독립적인 UI 요소를 동적으로 표시할 수 있는 강력한 도구로, 팝업, 툴팁, 드롭다운 등 다양한 커스텀 오버레이 구현에 필수적인 2025년 플러터 개발의 핵심 기술입니다.
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의 핵심 원리
Widget 트리와의 관계
MaterialApp
├── Navigator
│ └── Overlay (자동 생성)
│ ├── Route 위젯들
│ └── OverlayEntry들 ← 여기에 삽입
└── 일반 Widget 트리
flutter 오버레이 구현에서 가장 중요한 것은 OverlayEntry가 기본 위젯 트리와는 별도의 ui 레이어에서 동작한다는 점입니다.
작동 메커니즘
- OverlayState 획득:
Overlay.of(context)
로 현재 Overlay 상태를 가져옵니다 - Entry 생성: OverlayEntry 객체를 생성하고 builder 함수를 정의합니다
- 삽입:
overlayState.insert(entry)
로 오버레이에 추가합니다 - 제거:
entry.remove()
로 오버레이에서 제거합니다
기본 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 사용법을 마스터하면 다음과 같은 이점을 얻을 수 있습니다
핵심 포인트 요약
- 기본 개념 이해: OverlayEntry는 독립적인 UI 레이어에서 작동합니다
- 생명주기 관리: dispose()에서 반드시 오버레이를 제거해야 합니다
- 성능 최적화: markNeedsBuild()를 활용하여 필요한 부분만 업데이트합니다
- 애니메이션 적용: AnimationController와 함께 사용하여 부드러운 전환을 구현합니다
- 상호작용 구현: GestureDetector와 함께 사용하여 사용자 상호작용을 처리합니다
실무에서의 활용
- 모달 구현: 커스텀 다이얼로그와 바텀시트 대체
- 팝업 닫기: 외부 터치로 닫기 기능 구현
- 상위 위젯 접근: 전체 앱 레벨에서의 UI 컨트롤
- 코드 스니펫: 재사용 가능한 컴포넌트 라이브러리 구축
관련 참고 자료
더 깊이 있는 학습을 위한 공식 문서들
2025 플러터 ui 트렌드를 따라가며 지속적으로 학습하여 더 나은 사용자 경험을 제공하는 앱을 개발해보세요!
'프론트엔드' 카테고리의 다른 글
Tiptap: 모던 WYSIWYG 에디터의 특징, 확장, 실전 활용 가이드 (0) | 2025.08.04 |
---|---|
PLAYWRIGHTMCP: Playwright 테스트 자동화와 Managed Compute Platform 연동 실전 가이드 (0) | 2025.07.28 |
프론트엔드 개발자를 위한 최신 Lint/Formatter 세팅 가이드 2025 (0) | 2025.06.24 |
Next.js 15 App Router 마이그레이션 후기: SSR/CSR 성능 차이 실전 분석 (0) | 2025.06.23 |
HTMX로 서버사이드 렌더링 현대화하기 - SPA 없는 동적 웹 (0) | 2025.06.23 |