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;
|
|
|
|
|
}
|
|
|
|
|
}
|