import 'dart:async'; import 'package:flutter/material.dart'; // ๐Ÿ‘ˆ [ํ•ต์‹ฌ] ์ด import ๋ฌธ์ด ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. /// [์ˆ˜์ •] "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; } }