Created
April 2, 2025 00:04
-
-
Save ChangJoo-Park/ed89f82517e46326991aa4e3756a700f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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