import 'dart:async'; import 'package:flutter/material.dart'; /// [수정] "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 with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _logoTextAnimation; // 0 -> 7 late final Animation _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 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; } }