feat(ui): 승리 오버레이 스크롤 UX 개선

- 터치 시 스크롤 속도업 기능 추가 (5배속)
- 스크롤 완료 후 수동 스크롤 모드 지원
- 속도업 안내 텍스트 표시
This commit is contained in:
JiWoong Sul
2026-01-14 23:23:48 +09:00
parent 8efd3e875c
commit 6c92a323c0

View File

@@ -34,107 +34,184 @@ class VictoryOverlay extends StatefulWidget {
class _VictoryOverlayState extends State<VictoryOverlay> class _VictoryOverlayState extends State<VictoryOverlay>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _scrollController; late AnimationController _animationController;
late Animation<double> _scrollAnimation; late Animation<double> _scrollAnimation;
// 스크롤이 완료(최하단 도달) 되었는지 여부 // 스크롤이 완료(최하단 도달) 되었는지 여부
bool _isScrollComplete = false; bool _isScrollComplete = false;
// 터치 중인지 여부 (터치 시 속도업)
bool _isTouching = false;
// 스크롤 완료 후 수동 스크롤용 컨트롤러
late ScrollController _manualScrollController;
// 스크롤 지속 시간 (밀리초) // 스크롤 지속 시간 (밀리초)
static const _scrollDurationMs = 25000; // 25초 static const _scrollDurationMs = 25000; // 25초 (기본 속도)
static const _fastScrollDurationMs = 5000; // 5초 (빠른 속도, 5배속)
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = AnimationController( _manualScrollController = ScrollController();
_animationController = AnimationController(
duration: const Duration(milliseconds: _scrollDurationMs), duration: const Duration(milliseconds: _scrollDurationMs),
vsync: this, vsync: this,
); );
_scrollAnimation = Tween<double>( _scrollAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
begin: 0.0, CurvedAnimation(parent: _animationController, curve: Curves.linear),
end: 1.0, );
).animate(CurvedAnimation(parent: _scrollController, curve: Curves.linear));
// 스크롤 완료 시 버튼 표시 (자동 종료하지 않음) // 스크롤 완료 시 수동 스크롤 모드로 전환
_scrollController.addStatusListener((status) { _animationController.addStatusListener((status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
setState(() { _onScrollComplete();
_isScrollComplete = true;
});
} }
}); });
_scrollController.forward(); _animationController.forward();
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _animationController.dispose();
_manualScrollController.dispose();
super.dispose(); super.dispose();
} }
/// 탭 시 스크롤 최하단으로 즉시 이동 /// 스크롤 완료 시 호출
void _skipToEnd() { void _onScrollComplete() {
_scrollController.stop();
_scrollController.value = 1.0;
setState(() { setState(() {
_isScrollComplete = true; _isScrollComplete = true;
}); });
// 수동 스크롤 시 맨 아래에서 시작
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_manualScrollController.hasClients) {
_manualScrollController.jumpTo(
_manualScrollController.position.maxScrollExtent,
);
}
});
}
/// 터치 시작 - 스크롤 속도업
void _onTouchStart() {
if (_isScrollComplete) return;
setState(() {
_isTouching = true;
});
// 현재 진행도 저장
final currentProgress = _animationController.value;
final remainingProgress = 1.0 - currentProgress;
// 남은 시간 계산 (빠른 속도 기준)
final remainingDuration = Duration(
milliseconds: (remainingProgress * _fastScrollDurationMs).toInt(),
);
_animationController.duration = remainingDuration;
_animationController.forward(from: currentProgress);
}
/// 터치 종료 - 스크롤 속도 복원
void _onTouchEnd() {
if (_isScrollComplete) return;
setState(() {
_isTouching = false;
});
// 현재 진행도 저장
final currentProgress = _animationController.value;
final remainingProgress = 1.0 - currentProgress;
// 남은 시간 계산 (기본 속도 기준)
final remainingDuration = Duration(
milliseconds: (remainingProgress * _scrollDurationMs).toInt(),
);
_animationController.duration = remainingDuration;
_animationController.forward(from: currentProgress);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context); final gold = RetroColors.goldOf(context);
final l10n = L10n.of(context);
return GestureDetector( return GestureDetector(
onTap: _isScrollComplete ? null : _skipToEnd, // 스크롤 중에만 탭으로 스킵 // 터치 시 스크롤 속도업 (스크롤 중에만)
onTapDown: _isScrollComplete ? null : (_) => _onTouchStart(),
onTapUp: _isScrollComplete ? null : (_) => _onTouchEnd(),
onTapCancel: _isScrollComplete ? null : _onTouchEnd,
onLongPressStart: _isScrollComplete ? null : (_) => _onTouchStart(),
onLongPressEnd: _isScrollComplete ? null : (_) => _onTouchEnd(),
onLongPressCancel: _isScrollComplete ? null : _onTouchEnd,
child: Material( child: Material(
color: Colors.black, color: Colors.black,
child: SafeArea( child: SafeArea(
child: Stack( child: Stack(
children: [ children: [
// 스크롤되는 크레딧 // 스크롤 중: 애니메이션 기반 스크롤
AnimatedBuilder( // 스크롤 완료: 수동 스크롤 가능
animation: _scrollAnimation,
builder: (context, child) {
return _buildScrollingCredits(context);
},
),
// 스킵 버튼 (스크롤 중에만 표시)
if (!_isScrollComplete) if (!_isScrollComplete)
Positioned( AnimatedBuilder(
top: 16, animation: _scrollAnimation,
right: 16, builder: (context, child) {
child: TextButton( return _buildScrollingCredits(context);
onPressed: _skipToEnd, },
child: Text( )
L10n.of(context).endingSkip, else
style: TextStyle( _buildManualScrollableCredits(context),
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold.withValues(alpha: 0.5),
),
),
),
),
// 하단 힌트 (스크롤 중에만 표시) // 하단 터치 힌트 (스크롤 중에만 표시)
if (!_isScrollComplete) if (!_isScrollComplete)
Positioned( Positioned(
bottom: 16, bottom: 16,
left: 0, left: 0,
right: 0, right: 0,
child: Text( child: AnimatedOpacity(
L10n.of(context).endingTapToSkip, opacity: _isTouching ? 0.0 : 1.0,
style: TextStyle( duration: const Duration(milliseconds: 200),
fontFamily: 'PressStart2P', child: Text(
fontSize: 8, l10n.endingHoldToSpeedUp,
color: Colors.white.withValues(alpha: 0.2), style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: Colors.white.withValues(alpha: 0.3),
),
textAlign: TextAlign.center,
),
),
),
// 속도업 표시 (터치 중에만 표시)
if (!_isScrollComplete && _isTouching)
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: gold.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'▶▶ x5',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
), ),
textAlign: TextAlign.center,
), ),
), ),
], ],
@@ -162,6 +239,15 @@ class _VictoryOverlayState extends State<VictoryOverlay>
); );
} }
/// 수동 스크롤 가능한 크레딧 (스크롤 완료 후)
Widget _buildManualScrollableCredits(BuildContext context) {
return SingleChildScrollView(
controller: _manualScrollController,
physics: const BouncingScrollPhysics(),
child: _buildCreditContent(context),
);
}
double _estimateContentHeight() { double _estimateContentHeight() {
// 대략적인 콘텐츠 높이 추정 (스크롤 계산용) // 대략적인 콘텐츠 높이 추정 (스크롤 계산용)
// 명예의 전당 버튼 추가로 인해 높이 증가 // 명예의 전당 버튼 추가로 인해 높이 증가