235 lines
7.5 KiB
Dart
Raw Normal View History

2025-11-14 18:03:50 +09:00
import 'dart:async';
2025-11-19 11:17:33 +09:00
import 'package:flutter/material.dart';
2025-11-14 18:03:50 +09:00
/// [수정] "SBSPACE"를 한 줄로 그리는 IntroView
///
/// @param mainColor 뷰의 텍스트 색상 (S, B 강조)
/// @param onAnimationFinished 뷰의 애니메이션이 완료될 때 호출될 콜백
class IntroViewFlutter extends StatefulWidget {
final Color mainColor;
final VoidCallback onAnimationFinished;
const IntroViewFlutter({
super.key,
required this.mainColor,
required this.onAnimationFinished,
});
@override
_IntroViewFlutterState createState() => _IntroViewFlutterState();
}
class _IntroViewFlutterState extends State<IntroViewFlutter>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<int> _logoTextAnimation; // 0 -> 7
late final Animation<int> _missionTextAnimation; // 0 -> 15
static const String _logoString = "SBSPACE";
static const String _missionString = "Simple is Best.";
static const String _fontFamily = "Sdmisaeng";
@override
void initState() {
super.initState();
const int logoDuration = _logoString.length * 150; // 7 * 150ms = 1050ms
const int missionDuration = _missionString.length * 100; // 15 * 100ms = 1500ms
final int totalAnimationDuration = logoDuration + missionDuration; // 2550ms
_controller = AnimationController(
duration: Duration(milliseconds: totalAnimationDuration),
vsync: this,
);
// 로고 타이핑 애니메이션 (0 -> 7)
_logoTextAnimation = IntTween(begin: 0, end: _logoString.length).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
0.0,
logoDuration / totalAnimationDuration,
curve: Curves.linear,
),
),
);
// 미션 타이핑 애니메이션 (0 -> 15)
_missionTextAnimation = IntTween(begin: 0, end: _missionString.length).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
logoDuration / totalAnimationDuration,
1.0,
curve: Curves.linear,
),
),
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
widget.onAnimationFinished();
}
});
}
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: _IntroPainter(
mainColor: widget.mainColor,
fontFamily: _fontFamily,
logoTextLength: _logoTextAnimation.value,
missionTextLength: _missionTextAnimation.value,
),
size: Size.infinite,
);
},
);
}
}
/// [수정] RichText(TextSpan)를 사용해 그리는 CustomPainter
class _IntroPainter extends CustomPainter {
final Color mainColor;
final String fontFamily;
final int logoTextLength;
final int missionTextLength;
static const String _logoString = "SBSPACE";
static const String _missionString = "Simple is Best.";
_IntroPainter({
required this.mainColor,
required this.fontFamily,
required this.logoTextLength,
required this.missionTextLength,
});
/// 헬퍼: 현재 길이에 맞는 "SBSPACE" TextSpan을 생성
TextSpan _buildLogoSpan(int length) {
final Color normalColor = mainColor.withOpacity(0.6);
final List<TextSpan> children = [];
if (length >= 1) {
children.add(TextSpan(text: "S", style: TextStyle(color: mainColor)));
}
if (length >= 2) {
children.add(TextSpan(text: "B", style: TextStyle(color: mainColor)));
}
if (length >= 3) {
final String spaceToDraw = _logoString.substring(2, length.clamp(2, _logoString.length));
children.add(TextSpan(text: spaceToDraw, style: TextStyle(color: normalColor)));
}
return TextSpan(
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold),
children: children,
);
}
/// 헬퍼: TextSpan과 폰트 크기로 TextPainter를 생성 (레이아웃 포함)
TextPainter _createTextPainter(TextSpan textSpan, double fontSize) {
final style = textSpan.style!.copyWith(fontSize: fontSize);
final painter = TextPainter(
text: TextSpan(children: textSpan.children, style: style),
textDirection: TextDirection.ltr,
);
painter.layout();
return painter;
}
@override
void paint(Canvas canvas, Size size) {
// 1. 로고 폰트 크기 정의
final double logoFontSize = size.shortestSide / 6.0;
// 2. 미션 폰트 크기를 화면 너비에 꽉 차게 동적 계산
// 2a. 임시 폰트 크기(100)로 TextPainter를 생성하여 원본 너비를 측정
const double tempMissionFontSize = 100.0;
final TextPainter tpMissionTemp = TextPainter(
text: TextSpan(
text: _missionString,
style: TextStyle(fontFamily: fontFamily, fontWeight: FontWeight.bold, fontSize: tempMissionFontSize)
),
textDirection: TextDirection.ltr,
)..layout();
// 2b. (화면 너비 * 0.9) / (임시 텍스트 너비) = 스케일 비율
final double targetWidth = size.width * 0.9;
final double scale = targetWidth / tpMissionTemp.width;
// 2c. 실제 폰트 크기 계산
final double missionFontSize = tempMissionFontSize * scale;
// 3. 레이아웃 계산용 TextPainter (항상 전체 텍스트 기준)
// 3a. 로고
final tpLogoFull = _createTextPainter(_buildLogoSpan(_logoString.length), logoFontSize);
// 3b. 미션 (계산된 missionFontSize 사용)
final TextPainter tpMissionFull = TextPainter(
text: TextSpan(
text: _missionString,
style: TextStyle(
color: mainColor,
fontSize: missionFontSize,
fontFamily: fontFamily,
fontWeight: FontWeight.bold
)
),
textDirection: TextDirection.ltr,
)..layout();
// 4. 전체 텍스트 블록의 세로 중앙 정렬 Y좌표 계산
final double padding = logoFontSize * 0.1;
final double totalHeight = tpLogoFull.height + padding + tpMissionFull.height;
final double startyLogo = (size.height - totalHeight) / 2.0;
final double startyMission = startyLogo + tpLogoFull.height + padding;
// 5. 각 텍스트 라인의 가로 중앙 정렬 X좌표 계산
final double startxLogo = (size.width - tpLogoFull.width) / 2.0;
final double startxMission = (size.width - tpMissionFull.width) / 2.0;
// 6. 그리기용 TextPainter (애니메이션 적용된 길이 기준)
// 6a. 로고 그리기 (현재 길이: logoTextLength)
final tpLogoSub = _createTextPainter(_buildLogoSpan(logoTextLength), logoFontSize);
tpLogoSub.paint(canvas, Offset(startxLogo, startyLogo));
// 6b. 미션 그리기 (현재 길이: missionTextLength)
final String missionToDraw = _missionString.substring(0, missionTextLength);
final tpMissionSub = TextPainter(
text: TextSpan(text: missionToDraw, style: tpMissionFull.text!.style),
textDirection: TextDirection.ltr,
)..layout();
tpMissionSub.paint(canvas, Offset(startxMission, startyMission));
}
@override
bool shouldRepaint(covariant _IntroPainter oldDelegate) {
return oldDelegate.mainColor != mainColor ||
oldDelegate.logoTextLength != logoTextLength ||
oldDelegate.missionTextLength != missionTextLength;
}
}