중간고사 끝내고 동아리와 학회, 센터 일들을 마무리 하고 한숨 돌리면서 다시 플러터 스터디를 이어가려고 함!! 그리고 실습은 하드웨어 이슈로,, 내려놓고 이론 개념에만 집중하면서 따라가려고 함 ! 그래도 이번 스터디가 끝났을 때 적어도 플러터가 어떤 원리로 작동하고 어떤 기능들을 구현할 수 있으며 어느 단계까지 내가 활용할 수 있을지 명확하게 감을 잡을 수 있게 하는 게 목표인 만큼!! 빠이팅 해보자
일단 지난 내용을 recap하고 따라가기 위해서 제대로 못 들은 2강 마지막 부분 강의들을 보자
Icon들에 대해서, 그리고 코드상에서 Icon들에 대해서 VSCODE가 제공하는 유용한 기능들에 대해서 배웠었다. 크기를 조정하고 위치를 조정하는 방법들에 대해서도 배웠다.
일단 position을 만지기 위해서 단순히 margin, padding그리고 SizedBox만 사용하면 안됐다.
왜냐하면 내가 만지고 싶은건 이 카드 속에 있는 유로 아이콘이었지만 카드 전체의 포지션이 바뀌기 때문이다. 필요한 건, 만지고 싶은 아이콘만 Transform시키는 거다.
사용할 수 있는 방법으로 Transform.scale() 을 이용했다.
이놈은 child를 갖는다는 특징이 있는데 scale로 2.2를 받았으니 2.2배의 크기로 크기를 조정할 수도 있다. 부모에게 어떠한 영향도 끼치지 않은 채 자신만의 크기를 바꿀 수 있어서 유용하다. 그리고 child에 Transform.translate()을 넣어준 뒤 offset을 지정했다. 여기서 오프셋은 아이콘을 어디 위치시킬 건지에 대한 부분이다. 예시 코드대로라면 x축으로 -5픽셀, y축으로 12픽셀을 이동시키는 거다.
사진을 다시 보면 마음에 안 드는 부분이 있다!! 유로 아이콘이 card 바깥쪽으로 튀어나왔다. 즉 Comtainer의 바깥부분으로 튀어나간 부분을 컨트롤 해야하는건데 이건 Container의 clipBehavior이라는 속성을 이용하면 된다.
디폴트 값으로는 Clip.none으로 설정되어 있다. 즉 넘쳐흘러도 괜찮다는 뜻이다. 근데 우리는 이걸 바꿀거다.
Container(
clipBehavior: Clip.hardEdge
.
.
.
이렇게 설정해주면 카드 부위를 넘어가는 모든 것을 잘라내준다.
자 완성된 하나의 유로 카드가 만들어졌다. 근데 우리는 이 카드를 여러개 만들어서 써야한다. 지금까지 만든 것처럼 하나하나 만드는 미친 비효율은 용납할 수 없기에,, 재사용 할 거다. 카드에서 앞으로 우리가 바꿔야할 요소들은 Euro라는 화폐 이름, 양, 아이콘 이렇게 3가지이다.
final String name, code, amount;
final IconData icon;
이렇게 name, code, amount를 모두 finla 프로퍼티가 되게 하고 타입은 모두 String으로 해둔다. 유로 아이콘의 속성이 IconData였기 때문에 IconData타입의 icon도 같이 final 프러퍼티로 만들어 둔다.
code action을 집어 넣어서 final필드에 대한 생성자를 이렇게 만들어주면 끝이다 !
기존에 하드코딩해놨던 Euro같은 변수명들을 name으로 바꿔주고 숫자 값도 amount로 바꿔주고 이름도 code로 바꿔주면 끝 !
이번 단원에서는 이전에 보지 못했던 새로운 종류의 위젯들에 대해 알아본다. Stateful Widget이라는 걸 배울거다. 이전까지 만들어봤던 Widget들은 Stateless Widget으로 빌드 메서드를 통해서 단지 UI를 출력해주는 역할만 한다. 근데 Stateful Widget은 상태에 따라 변하게 될 데이터를 구현할 수 있다. 실시간으로 변하는 UI! 즉 계속해서 변하는 데이터들을 볼 수 있게 해주는 Widget이라는 거다. 만드는 과정부터 하나씩 해보자!
이 State가 우리가 UI를 구축하는 곳인데 이 상태는 매우 특별하다. 왜냐하면 우리가 데이터를 바꿀때 UI는 새로고침되면서 최신 데이터를 보여주기 때문이다. Stateful Widget의 데이터는 단지 클래스의 프로퍼티일 뿐이다. 단순한 Dart클래스 프로퍼티!!!
이해를 돕기 위한 Click Counter를 코드로 보자
import 'package:flutter/material.dart';
void main() {
runApp(App());
}
class App extends StatefulWidget {
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
int counter = 0;
void onClicked() {
counter = counter + 1;
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: const Color(0xFFF4EDDB),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Click Count',
style: TextStyle(fontSize: 30),
),
Text(
'$counter',
style: const TextStyle(fontSize: 30),
),
IconButton(
iconSize: 40,
onPressed: onClicked,
icon: const Icon(
Icons.add_box_rounded,
),
),
],
),
),
),
);
}
}
onClicked함수를 만들어서 onPressed에 할당하고!
Icon은 Icon.add_box_rounded, iconSize 사용해서 버튼을 만들어 넣은 다음
onClicked함수 내부에 counter += 1; 코드만 넣어주면
구현한 버튼이 클릭 될 때마다 Counter숫자가 증가하는 걸 볼 수 있다.
는 구라고 이렇게만 구현하면 버튼을 눌러도 카운터가 증가하지 않는다.
왜 그럴까 !?!?!?
Stateful Widget을 만들었고 데이터를 분명 가지고 있다. 값이 변경될 때 위젯도 같이 업데이트 된다. 근데 왜 카운터가 올라가지 않을까? 중요한 한 가지를 놓쳤기 때문인데 바로 setState 함수를 작성하지 않았기 때문이다. Stateful Widget을 구현할 때 절대 빼먹지 않고 꼭 같이 만들어줘야하는 이 setState는 State클래스에게 데이터가 변경되었다고 알려주는 역할을 한다.
즉 State클래스에게 바뀐 데이터가 있다고 전달을 해줘야 업데이트를 하든 말든 할 수 있는 것이기 때문에 꼭 구현해줘야하는 거다. 호출을 안 하면 build메소드 실행이 안 되기 때문 !
이렇게 !!!
이 setState()를 꼭 넣어줘야한다는 점 기억하자!
열정의 니꼬쌤 한 가지 더 예시를 보여주신다..
이번에는 플러스 버튼을 앱에서 눌렀을 때 카운트 되는 게 아니라 새로운 숫자배열을 만들어서 한 줄 씩 숫자가 추가되게끔 구현해본다.
1
2
3
4
5
...
이런식으로 !! 코드부터 따라 가보면,
import 'package:flutter/material.dart';
void main() {
runApp(App());
}
class App extends StatefulWidget {
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
List<int> numbers = [];
void onClicked() {
setState(() {
numbers.add(numbers.length);
});
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: const Color(0xFFF4EDDB),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Click Count',
style: TextStyle(fontSize: 30),
),
for (var n in numbers) Text('$n'),
IconButton(
iconSize: 40,
onPressed: onClicked,
icon: const Icon(
Icons.add_box_rounded,
),
),
],
),
),
),
);
}
}
이렇게 구현이 된다.
setState()함수도 잘 호출해준 걸 볼 수 있다. 결국 이번 recap에서 가장 중요한 건,
이전까지 배웠던 상태가 따로 없는 위젯들이 아닌 상태를 가지고 실시간으로 데이터와 UI가 변화할 수 있는 상태가 있는 위젯들에 대해서다. 그리고 그 상태가 있는 위젯들을 다루기 위해서는 반드시 클래스에게 실시간으로 변하고 있는 값이나 데이터 즉 state가 변한다는 걸 알려주는 setState()함수를 호출해주는 걸 까먹지 말아야 한다는 것 !
이번 강의부터는 BuildContext에 대해서 배워본다.
일단 Flutter에서 가능한 유용한 속성 하나를 배워보자. 플러터로 앱을 개발할 때는 앱의 모든 스타일을 한 곳에서 지정할 수 있는 기능을 사용할 수 있다. 색상, 크기, 굵기 등등 모든 것을 한 곳에서 할 수 있다! 즉 새로운 영역이나 카드들을 만들 때 모든 곳에 하나하나 속성들을 복사하고 붙여넣어서 코드를 길게 만들어버릴 필요가 없다는 거다. CSS에서 각각의 태그에 똑같은 스타일 속성들을 하나씩 다 넣지 않고 상황에 맞게 id나 class 셀렉터로 스타일을 먹이는 것과 같은 원리인 것 같다. Theme를 이용하면 된다.
Theme안에 데이터를 넣고, 색깔을 설정하는데 위젯 트리를 보면 우리가 MyLargeTitle이랑 매우 멀리 떨어져 있다. 플러터도 결국 부모와 자식을 상속시키고 자식에 자식을 생성하면서 코딩하는 언어인데 자식의 dept가 너무 깊다면 접근하기 어렵고 복잡해지는 거 역시 마찬가지이다. 이 때 BuildContext를 사용할 수 있다는 걸 알 수 있는 예제다. 아주 먼 곳에 있는 요소에서 접근할 수 있게 해주니깐! BuildContext는 위젯 트리에서 위젯의 위치를 제공하고 이를 통해서 상위 요소 데이터에 접근할 수 있게 해준다는게 기본적인 빌드컨텍스트의 아이디어이다. 실제로는 더 복잡하다고 한다..
넘어가기 전에 생존시간이라고도 불리는 위젯의 Lifecycle에 대해서 간단히 이해하고 넘어가자.
강의에서 예시로 들어준 걸 봐도 ... 뭔가 정확히 이해가 잘 안돼서 GPT에게 나를 이해시키라고 명령해봤다.
라이프사이클을 순서대로 보니까 이해가 된다. 앞으로 StatefulWidget을 사용해서 앱에서 필요한 부분을 구현해나갈 때 라이프사이클을 이해해야 UI업데이트나 데이터관리와 같은 작업들을 효율적으로 처리할 수 있다고 한다. 결국 위젯이 처음 생성되고, 갱신되고, 포그라운드 단계를 거쳐 비활성화가 되고 dipose메서드로 해체하는 단계까지의 과정을 거쳐서 불필요한 낭비나 비효율을 최소화 할 수 있다는 것 !!
이번 챕터에서 부터는 Pomodoro라는 앱을 만들어본다. Pomodoro는 나도 강의를 통해서 처음 들어봤다. 25분동안 일한 뒤에 5분씩 쉬게 해주는 타이머 앱서비스 같다.
이런식으로 만들거다! 또 이렇게 보니까 이론만 공부하지 말고 실습까지 따라 하고 싶은 마음이......
import 'package:flutter/material.dart';
import 'package:toonflix/screens/home_screen.dart';
void main() {
runApp(App());
}
class App extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
backgroundColor: const Color(0xFFE7626C),
textTheme: const TextTheme(
headline1: TextStyle(
color: Color(0xFF232B55),
),
),
cardColor: const Color(0xFFF4EDDB),
),
home: const HomeScreen(),
);
}
}
Flexible들을 여러 개 만들어서 각각 다르게 color을 지정해줬다.
Flexible 세 개를 만들고 각각 1, 2, 1로 지정한 다음 색을 다르게 하면
Flixible(
flex:2,
...
이런식으로 !
)
이렇게 화면이 나타난다. 이건 절대적인 크기를 지정하는 px의 개념이 아니라 화면의 column들 중에서 얼마나의 상대적인 크기를 갖게 할 것인가에 대한 설정이다. 즉 비례분할?의 개념으로 보면 되는데 1,2,1로 세 개의 Flex를 넣어줬기에 각각 4분의1, 4분의2, 4분의1의 크기가 할당된다. 상대적으로 비율을 통해 크기를 조정할 수 있다는 것 ! 유저의 휴대폰마다 스크린 크기가 모두 다를 수 있기 때문에 px로 절대값을 넣어주기보다는 이렇게 나름 반응형으로 지정해주는 것이 좋다.
각각의 Flexible들에 child로 필요한 숫자나 텍스트 들을 입력한 뒤에 위치를 조정하여준다.
니콜라스가 원하는 화면을 만들어 나가면서 보니 VSCODE가 제공하는 기능들을 적극적으로 활용하니 편하게 코딩하는 것 같다. Code Action은 언제봐도 위대하다,,,
예시 코드대로 따라하면 이렇게 UI를 완성할 수 있다.
하단에 pomodors는 오늘 pomodoro를 완료한 횟수를 카운트 해주는 숫자다!
자 먼저 25분을 카운트 다운 해주는 화면 먼저 구현을 해 보자.
int totalSeconds=1500;으로 넣어줄건데 헷갈리지 말아야하는건 25분을 second(초)단위로 입력해야하기 때문에 25분을 초로 환산한 1500이 들어가야한다 !!
그리고 실제로 카운트를 해 줄 함수 onStartPressed()를 구현해준다.
void onStartPressed() {
timer = Timer.periodic(
const Duration(seconds: 1),
onTick,
);
이렇게 !!
물론 설정에 따라서 자동으로 카운트 되게 할 수도, n초 뒤 카운트 되게 할 수도, 사용자가 클릭 시에 카운트 되게 할 수도 있다. 일단 이렇게 구현하면 직관적으로 알 수 있듯 onTick 1초마다 실행시키겠다는 뜻이다. 1초마다 HomeScreenWidget의 setState를 실행하겠다는 뜻! 혹시 이 포스팅을 정독중인 사람이 있다면 HomeScreenWidget은 아래 전체 코드에서 보여줄테니 넘어가삼. 그리고 onStartPressed()안에 만들어놓은 저 onTick()은 이렇게 만들어두면 된다.
그리고!!! onTick()을 Timer.periodic으로 호출할 때는 괄호 쓰지 않는다는 거 기억!!! Timer가 우리를 위해 매 초마다 괄호를 붙여서 실행해줄 거니까.
void onTick(Timer timer) {
setState(() {
totalSeconds = totalSeconds - 1;
});
}
짠!
이번 강의에서 전체 실습코드는 아래와 같다.
import 'dart:async';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int totalSeconds = 1500;
late Timer timer;
void onTick(Timer timer) {
setState(() {
totalSeconds = totalSeconds - 1;
});
}
void onStartPressed() {
timer = Timer.periodic(
const Duration(seconds: 1),
onTick,
);
}
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).backgroundColor,
body: Column(
children: [
Flexible(
flex: 1,
child: Container(
alignment: Alignment.bottomCenter,
child: Text(
'$totalSeconds',
style: TextStyle(
color: Theme.of(context).cardColor,
fontSize: 89,
fontWeight: FontWeight.w600,
),
),
),
),
Flexible(
flex: 3,
child: Center(
child: IconButton(
iconSize: 120,
color: Theme.of(context).cardColor,
onPressed: onStartPressed,
icon: const Icon(Icons.play_circle_outline),
),
),
),
Flexible(
flex: 1,
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(50),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Pomodoros',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
Text(
'0',
style: TextStyle(
fontSize: 58,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
],
),
),
),
],
),
)
],
),
);
}
}
자 이렇게 하면
start했을 때 1500의 시간 값이 카운트 되는 것을 알 수 있다.
자 정상적으로 카운트 되는 기능까지 구현했으니 이번엔 일시정지 버튼을 만들어보자.
bool isRunning = false;
isRunning이라는 불리언 변수를 만들어놓는다. 기본값은 false로 둔다. 타이머가 작동 중인 경우와 아닌 경우에 따라서 플레이와 퍼즈를 다르게 보여줘야한다.
setState(() {
isRunning = true;
});
}
void onPausePressed() {
timer.cancel();
setState(() {
isRunning = false;
});
onPausePressed()는 타이머를 취소하고, isRunning을 false로 했다.
그리고
child: IconButton(
iconSize: 120,
color: Theme.of(context).cardColor,
onPressed: isRunning ? onPausePressed : onStartPressed,
icon: Icon(isRunning
? Icons.pause_circle_outline
: Icons.play_circle_outline),
),
),
),
이렇게 onPressed도 수정해준다. 작동 중인지에 따라 다른 함수를 실행해야하기 때문에!
그리고 타이머가 종료 되었을 때 다시 1500으로 초기화 되게끔 하는 부분들까지 생각해주고, 시간 카운트가 1500초가 아니라 몇분 몇 초인지 알 수 있게 25:00으로 나타내는 부분까지 고려하여 코드를 수정하면 최종적으로 실습 코드가 완성된다.
.
.
import 'dart:async';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
static const twentyFiveMinutes = 1500;
int totalSeconds = twentyFiveMinutes;
bool isRunning = false;
int totalPomodoros = 0;
late Timer timer;
void onTick(Timer timer) {
if (totalSeconds == 0) {
setState(() {
totalPomodoros = totalPomodoros + 1;
isRunning = false;
totalSeconds = twentyFiveMinutes;
});
timer.cancel();
} else {
setState(() {
totalSeconds = totalSeconds - 1;
});
}
}
void onStartPressed() {
timer = Timer.periodic(
const Duration(seconds: 1),
onTick,
);
setState(() {
isRunning = true;
});
}
void onPausePressed() {
timer.cancel();
setState(() {
isRunning = false;
});
}
String format(int seconds) {
var duration = Duration(seconds: seconds);
return duration.toString().split(".").first.substring(2, 7);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).backgroundColor,
body: Column(
children: [
Flexible(
flex: 1,
child: Container(
alignment: Alignment.bottomCenter,
child: Text(
format(totalSeconds),
style: TextStyle(
color: Theme.of(context).cardColor,
fontSize: 89,
fontWeight: FontWeight.w600,
),
),
),
),
Flexible(
flex: 3,
child: Center(
child: IconButton(
iconSize: 120,
color: Theme.of(context).cardColor,
onPressed: isRunning ? onPausePressed : onStartPressed,
icon: Icon(isRunning
? Icons.pause_circle_outline
: Icons.play_circle_outline),
),
),
),
Flexible(
flex: 1,
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(50),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Pomodoros',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
Text(
'$totalPomodoros',
style: TextStyle(
fontSize: 58,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
],
),
),
),
],
),
)
],
),
);
}
}
이렇게 두 번째 예제까지 완성된다 !
자 근데 현재까지 구현한대로라면 '재시작'기능이 없다. 리셋하고 다시 25:00으로 되돌아가게 하는 기능이 없다. 근데 타이머에 재시작 기능은 필수지 !!
자 그러면 일단 버튼을 화면에 보여줄 수 있는 버튼 부분을 만들고
TextButton(
style: ButtonStyle(
shadowColor: MaterialStateProperty.all(Colors.red),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).cardColor,
)),
onPressed: resetTimer,
child: Text(
'RESET',
style: TextStyle(
color: Theme.of(context)
.textTheme
.headline1!
.color!
.withOpacity(0.8),
fontSize: 15,
),
),
),
이렇게 ! 그리고나서 resetTimer()함수를 만들어준다.
void resetTimer() {
setState(() {
totalSeconds = twentyFiveMinutes;
});
}
그러면 이렇게 재생 중 초기화해도 초기화되고 시간도 같이 줄어드는 리셋 기능이 정상적으로 구현됐음을 알 수 있다. 실습 사진은 (출처 : https://ninetynine-2026.tistory.com/847)에서 받아왔고, 티스토리 주인장은 25분이 아닌 5분을 기준으로 만들어서 5:00으로 리셋되는 것을 알 수 있다.
다음 5챕터부터는 마지막 챕터로, 본 강좌의 본 목적인 웹툰앱을 실제로 만드는 세션만 남았다. 다음편에 계속!! 아자아자 ~~