최근 많은 앱에서 사용자 참여도를 높이기 위해 복권이나 룰렛 같은 게임 요소를 도입하고 있습니다. 특히 토스의 복권 기능은 직관적인 UI와 부드러운 애니메이션으로 많은 사랑을 받고 있죠.
이번 포스트에서는 Flutter를 사용해서 토스 복권과 비슷한 스크래치 복권을 구현하는 방법을 단계별로 알아보겠습니다.
- 가독성있게 해야하는데 빨리 해본다고 코드가 지저분한점 양해 바랄게요 ㅠ ...
영상 미리보기
프로젝트 구조
먼저 전체적인 구조를 살펴보겠습니다:
lib/
├── module/
│ └── main/
│ └── earn/
│ └── muppitto/
│ └── muppitto_view.dart # 메인 복권 페이지
1. 메인 복권 페이지 구현
먼저 복권을 구매하고 긁을 수 있는 메인 페이지를 만들어보겠습니다.
@RoutePage()
class MuppittoView extends ConsumerStatefulWidget {
const MuppittoView({super.key});
@override
ConsumerState<MuppittoView> createState() => _MuppittoViewState();
}
class _MuppittoViewState extends ConsumerState<MuppittoView> {
Timer? _winnerTimer;
int _currentWinnerIndex = 0;
// 실시간 당첨자 데이터 -> 임시 데이터
final List<String> _winners = [
"231123***이 5,000P에 당첨됐어요!",
"JKDQS231***이 1,000P에 당첨됐어요!",
"abc123***이 10,000P에 당첨됐어요!",
];
@override
void initState() {
super.initState();
_startWinnerTicker();
}
void _startWinnerTicker() {
_winnerTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
setState(() {
_currentWinnerIndex = (_currentWinnerIndex + 1) % _winners.length;
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorStyles.bg,
body: Column(
children: [
MPAppbar(context, back: true, text: "뭅피또"),
_buildWinnerTicker(),
Expanded(
child: MPSingleScroll(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
_buildTitle(),
_buildTicketImage(),
_buildScratchButton(),
_buildPrizeInfo(),
],
),
),
),
),
],
),
);
}
}
실시간 당첨자 티커
사용자의 흥미를 끌기 위해 상단에 실시간 당첨자 정보를 보여주는 티커를 구현했습니다
Widget _buildWinnerTicker() {
return Container(
width: double.infinity,
height: 40.h,
color: ColorStyles.orange,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Text(
_winners[_currentWinnerIndex],
key: ValueKey(_currentWinnerIndex),
style: SUITE.bold.set(size: 12, color: Colors.white),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
),
),
);
}
2. 스크래치 다이얼로그 구현
복권을 긁는 핵심 기능은 별도의 다이얼로그에서 구현했습니다. 이 부분이 가장 복잡하면서도 중요한 부분입니다.
class _ScratchDialog extends StatefulWidget {
@override
_ScratchDialogState createState() => _ScratchDialogState();
}
class _ScratchDialogState extends State<_ScratchDialog> {
final List<Offset> _scratchedPoints = [];
bool _isScratching = false;
bool _isCompleted = false;
bool _isLoading = true;
String _guideText = "복권을 준비하고 있어요...";
Map<String, dynamic>? _scratchResult;
Offset? _lastTouchPoint;
@override
void initState() {
super.initState();
_initializeScratchTicket();
}
API 연동 및 결과 미리 결정
실제 복권과 마찬가지로 사용자가 긁기 전에 이미 당첨 여부가 결정되어야 합니다
Future<void> _initializeScratchTicket() async {
try {
// 실제 API 호출 예시:
// final result = await ref.read(muppittoServiceProvider).scratchTicket();
// 임시 코드: API 호출 시뮬레이션
await Future.delayed(const Duration(seconds: 2));
final random = DateTime.now().millisecondsSinceEpoch % 100;
final selectedResponse = random < 50
? _exampleResponses[DateTime.now().millisecondsSinceEpoch % 5] // 당첨
: _exampleResponses[5]; // 꽝
setState(() {
_scratchResult = selectedResponse;
_isLoading = false;
_guideText = "복권을 긁어주세요!";
});
} catch (e) {
// 에러 처리 로직
setState(() {
_isLoading = false;
_guideText = "오류가 발생했습니다. 다시 시도해주세요.";
});
}
}
3. 스크래치 영역 구현
스크래치 기능의 핵심은 CustomPainter와 GestureDetector를 조합하여 구현합니다
Widget _buildScratchArea() {
return Container(
width: 280.w,
height: 180.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.r),
child: Stack(
children: [
_buildRewardInfo(), // 당첨 정보 (하단)
if (!_isCompleted) _buildScratchMask(), // 마스킹 레이어 (중간)
if (!_isCompleted) _buildTouchDetector(), // 터치 감지 (상단)
],
),
),
);
}
터치 감지 및 굵기 처리
Widget _buildTouchDetector() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (details) {
setState(() {
_isScratching = true;
_guideText = "계속 긁어보세요!";
});
_lastTouchPoint = null;
_addScratchPoint(details.localPosition);
},
onPanUpdate: (details) {
_addScratchPoint(details.localPosition);
},
onPanEnd: (details) {
setState(() {
_isScratching = false;
});
_checkScratchCompletion();
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
);
}
스크래치 포인트 관리
터치 포인트를 관리하여 부드러운 긁기를 구현해봤습니다.
+ 진동 이벤트
void _addScratchPoint(Offset point) {
// 유효한 영역 체크 ( 위 긁는 쪽 UI 사이즈 반영 2번 참고 )
if (point.dx < 0 || point.dx > 280.w || point.dy < 0 || point.dy > 180.h) {
return;
}
// 이전 포인트와 거리 체크 (너무 가까우면 추가하지 않음)
if (_lastTouchPoint != null) {
final distance = (point - _lastTouchPoint!).distance;
if (distance < 3.0) {
return;
}
}
// 진동 피드백
HapticFeedback.lightImpact();
_scratchedPoints.add(point);
_lastTouchPoint = point;
setState(() {
_checkScratchProgressLive();
});
}
4. CustomPainter로 마스킹 레이어 구현
스크래치 효과의 핵심인 마스킹 레이어를 CustomPainter로 구현합니다
class ScratchCardPainter extends CustomPainter {
final List<Offset> scratchedPoints;
final double scratchRadius;
ScratchCardPainter({
required this.scratchedPoints,
this.scratchRadius = 20.0,
});
@override
void paint(Canvas canvas, Size size) {
// BlendMode.clear가 제대로 작동하도록 saveLayer 사용
canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), Paint());
// 1단계: 전체 마스킹 레이어 그리기
final Paint maskPaint = Paint()..color = Color(0xFFB8C5D6);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), maskPaint);
// 2단계: 긁은 부분을 투명하게 만들기
final Paint clearPaint = Paint()
..blendMode = BlendMode.clear
..style = PaintingStyle.fill;
for (final point in scratchedPoints) {
canvas.drawCircle(point, scratchRadius, clearPaint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
if (oldDelegate is! ScratchCardPainter) return true;
return oldDelegate.scratchedPoints.length != scratchedPoints.length ||
oldDelegate.scratchRadius != scratchRadius;
}
// 긁힌 면적 비율 계산
double getScratchedPercentage(Size size) {
if (scratchedPoints.isEmpty) return 0.0;
final totalArea = size.width * size.height;
final scratchedArea = scratchedPoints.length *
(math.pi * scratchRadius * scratchRadius);
return math.min(scratchedArea / totalArea, 1.0);
}
}
5. 진행도 체크 및 자동 완료
사용자가 충분히 긁었을 때 자동으로 완료 처리하는 로직입니다
void _checkScratchProgressLive() {
if (_scratchedPoints.isEmpty) return;
final painter = ScratchCardPainter(scratchedPoints: _scratchedPoints);
final scratchedPercentage = painter.getScratchedPercentage(Size(280.w, 180.h));
// 90% 면적 긁으면 완료
if (scratchedPercentage >= 0.90) {
_completeScratching();
}
// 70% 면적 이상 긁으면 유도 메시지
else if (scratchedPercentage >= 0.70) {
_guideText = "조금만 더 긁어보세요!";
}
// 40% 면적 이상 긁으면 격려 메시지
else if (scratchedPercentage >= 0.40) {
_guideText = "잘하고 있어요! 계속 긁어보세요!";
}
}
void _completeScratching() {
HapticFeedback.mediumImpact();
setState(() {
_isCompleted = true;
_guideText = _scratchResult!['isWin'] ? "축하합니다!" : "아쉽네요";
});
// 0.5초 후 결과 다이얼로그 표시
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.of(context).pop();
_showResultDialog();
}
});
}
6. 결과 다이얼로그
긁기가 완료되면 당첨 여부에 따라 다른 UI를 보여줍니다
void _showResultDialog() {
final isWinner = _scratchResult!['isWin'];
final rankName = _scratchResult!['rankName'];
final amount = _scratchResult!['amount'];
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Dialog(
backgroundColor: Colors.transparent,
child: Container(
height: isWinner ? 280.h : 240.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.r),
),
child: Column(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(24.w),
child: _buildResultContent(isWinner, rankName, amount),
),
),
_buildConfirmButton(),
],
),
),
),
);
}
이렇게 구현했습니다.
추가적으로 알면 좋을것들을 마지막으로 정리해봤습니다.
성능 최적화
터치 포인트 최적화
너무 많은 터치 포인트가 생성되지 않도록 거리 기반 필터링을 적용했습니다
if (_lastTouchPoint != null) {
final distance = (point - _lastTouchPoint!).distance;
if (distance < 3.0) {
return; // 3픽셀 이상 떨어져야 추가
}
}
실시간 업데이트
사용자에게 즉각적인 피드백을 주기 위해 실시간으로 상태를 업데이트합니다
setState(() {
_checkScratchProgressLive();
});
마무리
이렇게 Flutter에서 토스 복권 스타일의 스크래치 복권을 구현해봤습니다. 핵심 요소들을 정리하면
- CustomPainter + BlendMode.clear: 마스킹 레이어와 투명 처리
- GestureDetector: 정밀한 터치 감지
- 실시간 진행도 체크: 부드러운 사용자 경험
- 햅틱 피드백: 실제 긁는 느낌 구현
- API 연동 고려: 결과 미리 결정 구조
실제 서비스에 적용할 때는 다음 사항들을 추가로 고려해야 합니다
- 서버 API 연동 및 에러 처리
- 보안 (클라이언트에서 당첨 결과 조작 방지)
- 애니메이션 및 이펙트 추가
- 접근성 개선 (시각 장애인 지원 등)
- 다양한 화면 크기 대응
이 구현을 기반으로 코드가 참고가 될지는 모르지만 .. 읽어주셔서 감사하고 오늘도 즐코 빡코 !!
'Flutter' 카테고리의 다른 글
Riverpod 3.0 정리 – 주요 변경사항과 사용법 (0) | 2025.07.20 |
---|---|
Dart 3.8 출시! 새로운 기능과 주요 변경 사항 정리 (3) | 2025.07.09 |
Flutter: include of non-modular header inside framework module 'firebase_core.FLTFirebasePlugin' (2) | 2025.05.18 |
Flutter에서 API 호출 시 꼭 알아야 할 것들 (2) | 2025.05.13 |
logger - Flutter 에서 Print말고 logger 사용하기 (0) | 2025.05.08 |