Skip to content

Instantly share code, notes, and snippets.

@ChangJoo-Park
Created April 2, 2025 00:04
Show Gist options
  • Save ChangJoo-Park/ed89f82517e46326991aa4e3756a700f to your computer and use it in GitHub Desktop.
Save ChangJoo-Park/ed89f82517e46326991aa4e3756a700f to your computer and use it in GitHub Desktop.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.blue),
home: const ExampleUsage(),
);
}
}
/// 위젯을 감싸서 탭 이벤트를 처리하는 커스텀 래퍼 위젯
/// 기본적으로 자식 위젯의 정확한 크기만큼 탭 이벤트를 처리하며,
/// 필요시 추가 탭 범위를 지정할 수 있지만 화면 공간은 차지하지 않음
class TapWrapper extends SingleChildRenderObjectWidget {
/// 탭 이벤트 콜백
final VoidCallback? onTap;
/// 더블 탭 이벤트 콜백
final VoidCallback? onDoubleTap;
/// 길게 누르기 이벤트 콜백
final VoidCallback? onLongPress;
/// 추가 탭 범위 (기본 자식 위젯 크기에 추가되는 범위)
/// top, right, bottom, left 값으로 구성
final EdgeInsets tapPadding;
/// 자식 위젯 크기에만 정확히 맞출지 여부
/// true면 tapPadding이 무시되고 자식 위젯 크기에만 정확히 맞춤
final bool exactChildSize;
/// 히트 테스트 동작 방식
final HitTestBehavior hitTestBehavior;
/// 디버그 모드에서 탭 영역 시각화 여부
final bool showTapArea;
/// 디버그 영역의 색상
final Color debugAreaColor;
/// 디버그 영역의 투명도 (0.0 ~ 1.0)
final double debugAreaOpacity;
const TapWrapper({
Key? key,
required Widget child,
this.onTap,
this.onDoubleTap,
this.onLongPress,
this.tapPadding = EdgeInsets.zero,
this.exactChildSize = false,
this.hitTestBehavior = HitTestBehavior.translucent,
this.showTapArea = false,
this.debugAreaColor = Colors.red,
this.debugAreaOpacity = 0.2,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderTapWrapper(
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPress: onLongPress,
tapPadding: tapPadding,
exactChildSize: exactChildSize,
hitTestBehavior: hitTestBehavior,
showTapArea: showTapArea,
debugAreaColor: debugAreaColor,
debugAreaOpacity: debugAreaOpacity,
);
}
@override
void updateRenderObject(BuildContext context, RenderTapWrapper renderObject) {
renderObject
..onTap = onTap
..onDoubleTap = onDoubleTap
..onLongPress = onLongPress
..tapPadding = tapPadding
..exactChildSize = exactChildSize
..hitTestBehavior = hitTestBehavior
..showTapArea = showTapArea
..debugAreaColor = debugAreaColor
..debugAreaOpacity = debugAreaOpacity;
}
}
class RenderTapWrapper extends RenderProxyBox {
VoidCallback? onTap;
VoidCallback? onDoubleTap;
VoidCallback? onLongPress;
EdgeInsets tapPadding;
bool exactChildSize;
HitTestBehavior hitTestBehavior;
bool showTapArea;
Color debugAreaColor;
double debugAreaOpacity;
// 탭 인식을 위한 변수들
Offset? _tapDownPosition;
int _tapCount = 0;
int _lastTapTime = 0;
bool _longPressDetected = false;
// 가장 최근에 탭된 위치 (디버그용)
Offset? _lastTapPosition;
bool _isCurrentlyPressed = false;
RenderTapWrapper({
this.onTap,
this.onDoubleTap,
this.onLongPress,
this.tapPadding = EdgeInsets.zero,
this.exactChildSize = false,
this.hitTestBehavior = HitTestBehavior.translucent,
this.showTapArea = false,
this.debugAreaColor = Colors.red,
this.debugAreaOpacity = 0.2,
RenderBox? child,
}) : super(child);
/// 자식 위젯의 크기에 탭 패딩을 적용한 실제 탭 가능 영역을 계산
Rect get _effectiveHitTestRect {
if (exactChildSize) {
return Offset.zero & size;
}
// 자식 위젯 크기에 tapPadding을 추가한 확장된 영역
return Rect.fromLTRB(
-tapPadding.left,
-tapPadding.top,
size.width + tapPadding.right,
size.height + tapPadding.bottom,
);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// 정확한 자식 크기로만 확인할지, 확장된 탭 범위까지 확인할지 결정
final bool isInTapArea = _effectiveHitTestRect.contains(position);
if (!isInTapArea && hitTestBehavior == HitTestBehavior.deferToChild) {
return false;
}
// 자식에게 히트 테스트 기회 제공
final bool hitTarget = super.hitTest(result, position: position);
if (hitTarget) {
result.add(BoxHitTestEntry(this, position));
return true;
}
if (isInTapArea) {
// opaque나 translucent 모드일 때 확장된 영역까지 히트 테스트 성공으로 처리
if (hitTestBehavior != HitTestBehavior.deferToChild) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
// 이벤트 위치가 확장된 탭 영역 내에 있는지 확인
final isInTapArea = _effectiveHitTestRect.contains(event.localPosition);
if (!isInTapArea) {
return;
}
// 디버그용 터치 위치 업데이트
_lastTapPosition = event.localPosition;
if (event is PointerDownEvent) {
_isCurrentlyPressed = true;
_tapDownPosition = event.localPosition;
_longPressDetected = false;
// 길게 누르기 이벤트 처리 (500ms)
if (onLongPress != null) {
Future.delayed(const Duration(milliseconds: 500), () {
if (_tapDownPosition != null && !_longPressDetected) {
_longPressDetected = true;
onLongPress?.call();
}
});
}
// 탭 시각화를 위해 다시 그리기
if (showTapArea) {
markNeedsPaint();
}
} else if (event is PointerUpEvent) {
_isCurrentlyPressed = false;
if (_tapDownPosition != null && !_longPressDetected) {
final now = DateTime.now().millisecondsSinceEpoch;
// 더블 탭 인식 (300ms 이내 두 번째 탭)
if (now - _lastTapTime < 300) {
_tapCount++;
if (_tapCount == 2) {
onDoubleTap?.call();
_tapCount = 0;
_lastTapTime = 0;
} else {
_lastTapTime = now;
}
} else {
_tapCount = 1;
_lastTapTime = now;
// 일정 시간 후에 싱글 탭인지 확인
if (onTap != null && onDoubleTap != null) {
Future.delayed(const Duration(milliseconds: 300), () {
if (_tapCount == 1) {
onTap?.call();
_tapCount = 0;
}
});
} else if (onTap != null) {
// 더블 탭이 없으면 바로 탭 이벤트 발생
onTap?.call();
_tapCount = 0;
}
}
}
_tapDownPosition = null;
// 탭 시각화를 위해 다시 그리기
if (showTapArea) {
markNeedsPaint();
// 터치 효과 1초 후 사라지게 하기
Future.delayed(const Duration(seconds: 1), () {
_lastTapPosition = null;
markNeedsPaint();
});
}
} else if (event is PointerCancelEvent) {
_isCurrentlyPressed = false;
_tapDownPosition = null;
_tapCount = 0;
// 탭 시각화를 위해 다시 그리기
if (showTapArea) {
markNeedsPaint();
}
} else if (event is PointerMoveEvent) {
// 포인터 이동 시 시각화 업데이트
if (showTapArea && _isCurrentlyPressed) {
markNeedsPaint();
}
}
}
@override
void paint(PaintingContext context, Offset offset) {
// 먼저 자식 위젯을 그립니다
super.paint(context, offset);
// 디버그 모드 또는 showTapArea가 활성화되어 있을 때 탭 영역을 시각화
if (showTapArea) {
final canvas = context.canvas;
// 확장된 탭 영역 그리기
if (!exactChildSize && tapPadding != EdgeInsets.zero) {
final Paint areaPaint = Paint()
..color = debugAreaColor.withOpacity(debugAreaOpacity)
..style = PaintingStyle.fill;
final rect = _effectiveHitTestRect.shift(offset);
canvas.drawRect(rect, areaPaint);
final Paint borderPaint = Paint()
..color = debugAreaColor.withOpacity(0.8)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawRect(rect, borderPaint);
}
// 마지막 탭 위치 표시
if (_lastTapPosition != null) {
final Paint circlePaint = Paint()
..color = Colors.blue.withOpacity(0.6)
..style = PaintingStyle.fill;
// 탭 위치에 원 그리기
canvas.drawCircle(
offset + _lastTapPosition!,
_isCurrentlyPressed ? 15.0 : 10.0,
circlePaint,
);
// 원 테두리
final Paint circleBorderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
canvas.drawCircle(
offset + _lastTapPosition!,
_isCurrentlyPressed ? 15.0 : 10.0,
circleBorderPaint,
);
}
}
}
// 디버그 모드에서는 별도의 시각화 추가
@override
void debugPaintSize(PaintingContext context, Offset offset) {
super.debugPaintSize(context, offset);
// 디버그 모드에서 확장된 탭 영역을 시각화 (showTapArea가 false여도 디버그 모드에서는 표시)
assert(() {
if (!exactChildSize && tapPadding != EdgeInsets.zero && !showTapArea) {
final Paint paint = Paint()
..color = const Color(0x33FF0000)
..style = PaintingStyle.fill;
final rect = _effectiveHitTestRect.shift(offset);
context.canvas.drawRect(rect, paint);
final Paint borderPaint = Paint()
..color = const Color(0xFFFF0000)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
context.canvas.drawRect(rect, borderPaint);
}
return true;
}());
}
}
// 사용 예시
class ExampleUsage extends StatefulWidget {
const ExampleUsage({Key? key}) : super(key: key);
@override
State<ExampleUsage> createState() => _ExampleUsageState();
}
class _ExampleUsageState extends State<ExampleUsage> {
bool _showTapArea = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('확장된 TapWrapper 예시'),
actions: [
// 디버그 영역 표시 토글 스위치
Switch(
value: _showTapArea,
onChanged: (value) {
setState(() {
_showTapArea = value;
});
},
),
const SizedBox(width: 8),
const Text('디버그 영역'),
const SizedBox(width: 16),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IgnorePointer(
child: Container(width: 100, height: 100, color: Colors.red),
),
TapWrapper(
tapPadding: const EdgeInsets.all(20),
showTapArea: _showTapArea,
debugAreaColor: Colors.purple,
onTap: () {
print('중앙 위젯 탭되었습니다!');
},
child: Container(
width: 100,
height: 100,
color: Colors.green,
child: const Center(
child: Text(
'주변 확장',
style: TextStyle(color: Colors.white),
),
),
),
),
IgnorePointer(
child: Container(width: 100, height: 100, color: Colors.black),
),
],
),
const SizedBox(height: 40),
// 기본 사용법 - 정확한 자식 크기
TapWrapper(
exactChildSize: true,
showTapArea: _showTapArea,
onTap: () {
print('정확한 크기의 위젯이 탭되었습니다!');
},
child: Container(
width: 200,
height: 100,
color: Colors.blue,
child: const Center(
child: Text('정확한 크기', style: TextStyle(color: Colors.white)),
),
),
),
const SizedBox(height: 40),
// 확장된 탭 영역 사용
TapWrapper(
tapPadding: const EdgeInsets.all(30),
showTapArea: _showTapArea,
debugAreaColor: Colors.green,
debugAreaOpacity: 0.3,
onTap: () {
print('확장된 영역이 탭되었습니다!');
},
child: Container(
width: 100,
height: 100,
color: Colors.green,
child: const Center(
child: Text(
'주변 30픽셀 확장',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
),
const SizedBox(height: 40),
// 비대칭 확장 탭 영역 사용
TapWrapper(
tapPadding: const EdgeInsets.only(
left: 50,
right: 30,
top: 10,
bottom: 40,
),
showTapArea: _showTapArea,
debugAreaColor: Colors.orange,
hitTestBehavior: HitTestBehavior.opaque,
onTap: () {
print('비대칭 확장 영역이 탭되었습니다!');
},
onDoubleTap: () {
print('비대칭 영역이 더블 탭되었습니다!');
},
child: Container(
width: 200,
height: 100,
color: Colors.orange,
child: const Center(
child: Text('비대칭 확장', style: TextStyle(color: Colors.white)),
),
),
),
],
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment