Skip to content

Instantly share code, notes, and snippets.

@AndrewDongminYoo
Created August 1, 2025 01:33
Show Gist options
  • Save AndrewDongminYoo/0b8e4b4df91b87d2190480ed9e5d8725 to your computer and use it in GitHub Desktop.
Save AndrewDongminYoo/0b8e4b4df91b87d2190480ed9e5d8725 to your computer and use it in GitHub Desktop.
The Overlay Tooltip widget is a set of widgets for implementing an “Onboarding” or “Guided Tour” feature that walks users through a feature by displaying help (tooltips) sequentially over specific UI elements in your app.

💬 Overlay Tooltip 위젯

앱의 특정 UI 요소 위에 순차적으로 도움말(툴팁)을 표시하여 사용자에게 기능을 안내하는 '온보딩(Onboarding)' 또는 '가이드 투어(Guided Tour)' 기능을 구현하기 위한 위젯 세트입니다.

✨ 주요 기능

  • 순차적 툴팁 표시: displayIndex를 통해 지정된 순서대로 툴팁을 표시합니다.
  • 동적 위치 계산: GlobalKey를 사용하여 대상 위젯의 위치를 동적으로 찾아 툴팁을 정확하게 배치합니다.
  • 커스터마이징: 오버레이 색상, 애니메이션, 툴팁 위치 등 다양한 옵션을 제공합니다.
  • 흐름 제어: TooltipController를 통해 툴팁 표시를 시작, 중지, 이동하는 등 완벽하게 제어할 수 있습니다.

🧱 주요 구성 요소

  • OverlayTooltipScaffold: 툴팁 시스템을 활성화할 화면의 최상위 루트 위젯입니다.
  • TooltipController: 툴팁의 표시 상태와 순서를 관리하는 컨트롤러입니다.
  • OverlayTooltipItem: 툴팁을 표시할 대상 위젯을 감싸는 위젯입니다.

🚀 사용 방법

1단계: TooltipController 생성하기

가장 먼저, 툴팁의 상태를 관리할 TooltipController를 생성합니다. 일반적으로 StatefulWidgetState 내에서 관리합니다.

class _MyScreenState extends State<MyScreen> {
  late final TooltipController _tooltipController;

  @override
  void initState() {
    super.initState();
    _tooltipController = TooltipController(
      // 모든 툴팁이 표시된 후 호출될 콜백
      onDone: () {
        print('툴팁 표시 완료!');
      },
      // 툴팁 표시를 시작할 조건 (예: 3개의 툴팁 아이템이 모두 등록되었을 때)
      startWhen: (instantiatedCount) async {
        return instantiatedCount >= 3;
      },
    );
  }

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

  // ...
}

2단계: OverlayTooltipScaffold로 화면 감싸기

툴팁을 표시할 화면의 Scaffold 또는 루트 위젯을 OverlayTooltipScaffold로 감싸고, 생성한 컨트롤러를 전달합니다.

// _MyScreenState 내 build 메서드
@override
Widget build(BuildContext context) {
  return OverlayTooltipScaffold(
    controller: _tooltipController,
    builder: (context) => Scaffold(
      appBar: AppBar(title: const Text('Overlay Tooltip 예제')),
      body: ... // 3단계에서 설명할 위젯들
    ),
  );
}

3단계: OverlayTooltipItem으로 대상 위젯 감싸기

툴팁을 표시하고 싶은 각 위젯을 OverlayTooltipItem으로 감쌉니다. displayIndex로 순서를 지정하고, tooltip 빌더로 툴팁 위젯을 만듭니다.

// Scaffold의 body 부분
Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      // 첫 번째 툴팁
      OverlayTooltipItem(
        displayIndex: 0,
        tooltip: (controller) => TextButton(
          onPressed: controller.next,
          child: const Text('첫 번째 위젯입니다. 다음으로!'),
        ),
        child: const Text('여기를 보세요!'),
      ),
      const SizedBox(height: 50),

      // 두 번째 툴팁
      OverlayTooltipItem(
        displayIndex: 1,
        tooltipVerticalPosition: TooltipVerticalPosition.top,
        tooltip: (controller) => Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Text('이 버튼을 누르면 액션이 실행됩니다.'),
            TextButton(
              onPressed: controller.next,
              child: const Text('알겠습니다.'),
            ),
          ],
        ),
        child: ElevatedButton(
          onPressed: () {},
          child: const Text('중요한 버튼'),
        ),
      ),
      const SizedBox(height: 50),

      // 세 번째 툴팁 (마지막)
      OverlayTooltipItem(
        displayIndex: 2,
        tooltip: (controller) => TextButton(
          onPressed: controller.dismiss, // dismiss로 툴팁 종료
          child: const Text('마지막입니다. 닫기'),
        ),
        child: const Icon(Icons.info),
      ),
    ],
  ),
),

4단계: 툴팁 시작하기 (선택 사항)

TooltipControllerstartWhen 조건이 충족되면 툴팁이 자동으로 시작됩니다. 만약 수동으로 시작하고 싶다면, 원하는 시점에 start() 메서드를 호출하면 됩니다.

FloatingActionButton(
  onPressed: () {
    // 0번 인덱스부터 툴팁을 시작합니다.
    _tooltipController.start(0);
  },
  child: const Icon(Icons.play_arrow),
)
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
// 🌎 Project imports:
import 'overlay_tooltip_scaffold.dart';
import 'tooltip_controller.dart';
@immutable
class OverlayTooltipItem extends StatefulWidget {
const OverlayTooltipItem({
super.key,
required this.child,
required this.displayIndex,
required this.tooltip,
this.absorbing = true,
this.tooltipVerticalPosition = TooltipVerticalPosition.bottom,
this.tooltipHorizontalPosition = TooltipHorizontalPosition.withWidget,
});
final Widget child;
final int displayIndex;
final Widget Function(TooltipController controller) tooltip;
final bool absorbing;
final TooltipVerticalPosition tooltipVerticalPosition;
final TooltipHorizontalPosition tooltipHorizontalPosition;
@override
State<OverlayTooltipItem> createState() => _OverlayTooltipItemState();
}
class _OverlayTooltipItemState extends State<OverlayTooltipItem> {
final GlobalKey widgetKey = GlobalKey();
@override
void initState() {
super.initState();
_registerTooltip();
}
@override
void didUpdateWidget(covariant OverlayTooltipItem oldWidget) {
super.didUpdateWidget(oldWidget);
_registerTooltip();
}
void _registerTooltip() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final model = OverlayTooltipModel(
absorbing: widget.absorbing,
child: widget.child,
tooltip: widget.tooltip,
widgetKey: widgetKey,
verticalPosition: widget.tooltipVerticalPosition,
horizontalPosition: widget.tooltipHorizontalPosition,
displayIndex: widget.displayIndex,
);
final scaffold = OverlayTooltipScaffold.of(context);
await scaffold.addTooltipModel(model);
} catch (e) {
debugPrint(e.toString());
}
});
}
@override
Widget build(BuildContext context) {
return Material(
key: widgetKey,
color: Colors.transparent,
child: widget.child,
);
}
}
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
// 🌎 Project imports:
import 'tooltip_controller.dart';
import 'tooltip_layout_widget.dart';
@immutable
class OverlayTooltipScaffold extends StatefulWidget {
const OverlayTooltipScaffold({
super.key,
required this.controller,
required this.builder,
this.overlayColor = Colors.black54,
this.tooltipAnimationDuration = const Duration(milliseconds: 500),
this.tooltipAnimationCurve = Curves.decelerate,
this.preferredOverlay,
});
final TooltipController controller;
final WidgetBuilder builder;
final Color overlayColor;
final Duration tooltipAnimationDuration;
final Curve tooltipAnimationCurve;
final Widget? preferredOverlay;
static OverlayTooltipScaffoldState of(BuildContext context) {
final result = context.findAncestorStateOfType<OverlayTooltipScaffoldState>();
if (result != null) {
return result;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'OverlayTooltipScaffold.of() called with a context '
'that does not contain an OverlayTooltipScaffold.',
),
ErrorDescription(
'No OverlayTooltipScaffold ancestor could be found starting '
'from the context that was passed to OverlayTooltipScaffold.of().',
),
context.describeElement('The context used was'),
]);
}
@override
OverlayTooltipScaffoldState createState() => OverlayTooltipScaffoldState();
}
class OverlayTooltipScaffoldState extends State<OverlayTooltipScaffold> {
Future<void> addTooltipModel(OverlayTooltipModel model) => widget.controller.addTooltipModel(model);
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: Builder(builder: widget.builder)),
AnimatedBuilder(
animation: widget.controller,
builder: (BuildContext context, Widget? _) {
final model = widget.controller.currentTooltip;
if (model == null) {
return const SizedBox.shrink();
}
final bounds = model.widgetKey.globalPaintBounds;
if (bounds == null) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: Stack(
children: [
widget.preferredOverlay ??
Container(
width: double.infinity,
height: double.infinity,
color: widget.overlayColor,
),
TweenAnimationBuilder<double>(
key: ValueKey(model.displayIndex),
tween: Tween(begin: 0, end: 1),
duration: widget.tooltipAnimationDuration,
curve: widget.tooltipAnimationCurve,
builder: (BuildContext _, double opacity, Widget? child) {
return Opacity(opacity: opacity.clamp(0, 1), child: child);
},
child: TooltipLayoutWidget(
model: model,
controller: widget.controller,
),
),
],
),
);
},
),
],
),
);
}
}
// 🎯 Dart imports:
import 'dart:async';
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
enum TooltipVerticalPosition { top, bottom }
enum TooltipHorizontalPosition { left, right, withWidget, center }
@immutable
class OverlayTooltipModel {
const OverlayTooltipModel({
required this.absorbing,
required this.child,
required this.widgetKey,
required this.tooltip,
required this.verticalPosition,
required this.horizontalPosition,
required this.displayIndex,
});
final bool absorbing;
final Widget child;
final GlobalKey widgetKey;
final Widget Function(TooltipController controller) tooltip;
final TooltipVerticalPosition verticalPosition;
final TooltipHorizontalPosition horizontalPosition;
final int displayIndex;
@override
String toString() {
return 'OverlayTooltipModel(displayIndex: $displayIndex)';
}
}
class TooltipController extends ChangeNotifier {
TooltipController({
required this.onDone,
required this.startWhen,
});
final VoidCallback onDone;
final Future<bool> Function(int instantiatedCount) startWhen;
final List<OverlayTooltipModel> _tooltipModels = [];
OverlayTooltipModel? _currentTooltip;
int _nextPlayIndex = 0;
OverlayTooltipModel? get currentTooltip => _currentTooltip;
int get nextPlayIndex => _nextPlayIndex;
Future<void> addTooltipModel(OverlayTooltipModel model) async {
final existing = _tooltipModels.indexWhere((e) => e.displayIndex == model.displayIndex);
if (existing >= 0) {
_tooltipModels[existing] = model;
} else {
final insertAt = _tooltipModels.indexWhere((e) => e.displayIndex > model.displayIndex);
if (insertAt >= 0) {
_tooltipModels.insert(insertAt, model);
} else {
_tooltipModels.add(model);
}
}
if (await startWhen(_tooltipModels.length) && _currentTooltip == null) {
start();
}
}
void _updateTooltip({
OverlayTooltipModel? model,
bool done = false,
int? playIndex,
}) {
if (playIndex != null) {
_nextPlayIndex = playIndex;
}
_currentTooltip = model;
if (done) {
onDone();
}
notifyListeners();
}
void start([int? displayIndex]) {
if (_tooltipModels.isEmpty) {
throw Exception(
'No overlay tooltip initialized. Consider calling start() after adding items or using startWhen.',
);
}
var idx = 0;
if (displayIndex != null) {
final found = _tooltipModels.indexWhere((e) => e.displayIndex == displayIndex);
idx = found >= 0 ? found : 0;
}
_updateTooltip(model: _tooltipModels[idx], playIndex: idx);
}
void next() {
final nextIdx = _nextPlayIndex + 1;
if (nextIdx < _tooltipModels.length) {
_updateTooltip(model: _tooltipModels[nextIdx], playIndex: nextIdx);
} else {
_updateTooltip(done: true);
}
}
void previous() {
if (_nextPlayIndex > 0) {
final prevIdx = _nextPlayIndex - 1;
_updateTooltip(model: _tooltipModels[prevIdx], playIndex: prevIdx);
}
}
void pause() => _updateTooltip();
void dismiss() => _updateTooltip(done: true);
}
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
// 🌎 Project imports:
import 'tooltip_controller.dart';
extension GlobalKeyRectExtension on GlobalKey {
Rect? get globalPaintBounds {
final renderBox = currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null && renderBox.attached) {
final topLeft = renderBox.localToGlobal(Offset.zero);
return topLeft & renderBox.size;
}
return null;
}
}
@immutable
class TooltipLayoutWidget extends StatelessWidget {
const TooltipLayoutWidget({
super.key,
required this.model,
required this.controller,
});
final OverlayTooltipModel model;
final TooltipController controller;
Rect _clampRect(Rect rect, BoxConstraints constraints) {
var left = rect.left;
var top = rect.top;
var right = rect.right;
var bottom = rect.bottom;
if (left < 0) {
right -= left;
left = 0;
}
if (top < 0) {
bottom -= top;
top = 0;
}
if (right > constraints.maxWidth) {
left -= right - constraints.maxWidth;
right = constraints.maxWidth;
}
if (bottom > constraints.maxHeight) {
top -= bottom - constraints.maxHeight;
bottom = constraints.maxHeight;
}
return Rect.fromLTRB(left, top, right, bottom);
}
@override
Widget build(BuildContext context) {
final rawBounds = model.widgetKey.globalPaintBounds;
if (rawBounds == null) {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bounds = _clampRect(rawBounds, constraints);
final isTop = model.verticalPosition == TooltipVerticalPosition.top;
final alignLeft = bounds.left <= (constraints.maxWidth - bounds.right);
final top = isTop ? null : bounds.bottom;
final bottom = isTop ? constraints.maxHeight - bounds.top : null;
final left = alignLeft ? bounds.left : null;
final right = alignLeft ? null : constraints.maxWidth - bounds.right;
final Widget tooltipChild;
if (model.horizontalPosition == TooltipHorizontalPosition.withWidget) {
tooltipChild = Column(
mainAxisSize: MainAxisSize.min,
children: [model.tooltip(controller)],
);
} else {
tooltipChild = Align(
alignment: switch (model.horizontalPosition) {
TooltipHorizontalPosition.center => Alignment.center,
TooltipHorizontalPosition.right => Alignment.centerRight,
TooltipHorizontalPosition.left => Alignment.centerLeft,
_ => Alignment.centerLeft,
},
child: model.tooltip(controller),
);
}
return Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: tooltipChild,
);
},
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment