[Flutter] 스나이퍼팩토리 8일차

KWANWOO·2023년 2월 3일
2
post-thumbnail

스나이퍼팩토리 플러터 8일차

8일차에는 Button 위젯들을 학습했고, 상태와 관련된 Stateless Widget, Stateful Widget에 대해 알아보았다. 이와 관련된 라이프 사이클도 찾아보았다.

학습한 내용

  • Flutter의 Button 위젯들
  • Stateless Widget과 Stateful Widget
  • Flutter의 위젯 라이프 사이클

추가 내용 정리

Flutter의 Button 위젯들

이전의 [Flutter] 스나이퍼팩토리 1주차 도전하기 에서 OutlinedButton을 사용해서 버튼들의 종류에 대해서 정리를 했었는데 이번에 자세히 학습하게 되어서 다시 한번 정리하고자 한다.

플러터에서 주로 사용하는 버튼은 FlatButton, RaisedButton, OutlinButton이 있는데 이 세가지 버튼은 아래와 같이 변경되었다.

  • FlatButton -> TextButton
  • RaisedButton -> ElevatedButton
  • OutlineButton -> OutlinedButton

이에 마찬가지로 theme 또한 TextButtonTheme, ElevatedButtonTheme, OutlinedButtonTheme로 변경되었다.

플러터의 버튼들은 아래와 같은 기능으로 사용된다.

  • TextButton : 텍스트를 포함한 기본 버튼
  • OutlinedButton : 테두리 선을 가지고 있는 버튼
  • ElevatedButton : 버튼을 강조할 때 사용
  • ToggleButtons : 그룹 중 하나만 선택할 때 사용
  • IconButton : 아이콘으로 이루어진 버튼, 단일 항목을 선택하거나 해제할 때 주로 사용

BuildContext란?

플러터를 사용하다보면 BuildContext 또는 context를 자주 보게 된다.

BuildContext란 공식문서에 위젯 트리에서 현재 위젯의 위치를 알 수 있는 정보라고 쓰여있다. 간단히 말하면 위젯 트리에 있는 위젯의 위치라고 할 수 있는데 아래와 같이 두 가지 의미로 사용될 수 있다.


  • build method는 Scaffold Widget을 리턴하는데 이 때 위젯 트리에서 어디에 위치하는지에 대한 정보를 갖고 있는 context를 넘겨준다.

  • MyPage에서 빌드된 Scaffold Widget의 context를 그대로 물려받게 된다. 따라서 context가 필요하다면 Scafflod Widget의 build를 통해 나온 결과물을 통해 알 수 있다.

8일차 과제

  1. 프로젝트 생성시 main.dart의 기본 소스코드
  2. Stateful Widget의 라이프 사이클
  3. 주어진 화면과 같은 UI 코드 작성(사칙연산 앱)

1. 프로젝트 생성시 main.dart의 기본 소스코드

Flutter의 프로젝트를 생성하면 아래와 같은 기본 소스코드를 가진 main.dart가 생성된다.

  • main.dart 기본 소스코드
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or simply save your changes to "hot reload" in a Flutter IDE).
        // Notice that the counter didn't reset back to zero; the application
        // is not restarted.
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  // This widget is the home page of your application. It is stateful, meaning
  // that it has a State object (defined below) that contains fields that affect
  // how it looks.

  // This class is the configuration for the state. It holds the values (in this
  // case the title) provided by the parent (in this case the App widget) and
  // used by the build method of the State. Fields in a Widget subclass are
  // always marked "final".

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      // This call to setState tells the Flutter framework that something has
      // changed in this State, which causes it to rerun the build method below
      // so that the display can reflect the updated values. If we changed
      // _counter without calling setState(), then the build method would not be
      // called again, and so nothing would appear to happen.
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text(widget.title),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also a layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

기본 소스코드를 실행하면 아래와 같은 결과를 얻을 수 있다.

동작 원리

코드를 살펴보면 우선 MaterialApptitle로 앱의 이름이 정해져있고, theme에서 기본 색상이 파란색으로 설정되어 있다. home으로 MyHomePage 위젯을 불러 띄워주는데 MyHomePage 위젯은 아래에 StatefulWidget 으로 작성되어 있다.

MyHomePage 위젯을 보면 _counter 변수가 0으로 선언되어 있고, _incrementCounter() 함수가 setState를 사용해 카운트가 1씩 증가되도록 선언되어 있다.

build 함수 내에서 Scaffold를 반환하고 있는데 우선 앱바를 생성했다. 앱바는 MyHomePage를 호출할 때 전달된 title 값을 제목으로 출력한다.

body에서는 가운데에 Column을 생성하고 2개의 텍스트를 생성하는데 아래의 텍스트는 변경이 되므로 const로 선언되지 않았으며 버튼이 눌린 횟수를 보여주도록 _counter를 출력한다.

마지막으로 FloatingActionButton이 생성되었는데 onPressed에 핸들러로 앞에서 생성되어 있는 _incrementConter가 연결되어 있다. 따라서 이 플로팅 액션 버튼이 클릭되면 _counter의 값이 1씩 증가하며 setState 함수 안에서 기능이 수행되기 때문에 본문에 생성된 Text위젯의 값도 같이 변경된다.

Stateful Widget이 사용되어야 하는 이유

Stateless Widget은 화면이 로드될 때 한 번만 그려지는 State가 없는 위젯으로 변경이 필요한 데이터가 없는 것을 의미하며 이벤트 또한 사용자 상호 작용에 의해 동작하지 않는다.

Stateful Widget은 위젯이 동작하는 동안 데이터 변경이 필요한 경우 화면을 다시 그려 변경된 부분을 위젯에 반영하는 동적인 위젯으로 이벤트 또는 사용자 상호 작용에 동작한다.

즉, 기본 소스코드의 앱은 플로팅 액션 버튼을 사용자가 누른다는 이벤트가 존재하고 이러한 이벤트를 핸들링하여 텍스트 위젯에 반영해야 된다는 동적인 상황이 필요하다. 만약 이 앱이 Stateless Widget으로 만들어졌다면 버튼을 눌러 _conter의 값을 변경하더라도 이를 텍스트 위젯에 적용해 다시 그릴 수 없다. 따라서 반드시 Stateful Widget이 필요하다.

그러면 Stateful Widget만 사용하면 되는거 아니야??

앱의 모든 위젯을 Stateful Widget으로만 구성하면 편할것 같지만 그렇지 않다. 이유는 Stateful WidgetStateless Widget에 비해 성능이 떨어진다는 단점이 있기 때문이다.

따라서 Stateful WidgetStateless Widget을 자신이 만드려고 하는 화면의 상태에 맞게 사용해야 한다.

결론적으로 두 위젯을 빌드 과정을 기준으로 비교하면 아래와 같이 정리할 수 있다.

  • Stateless Widget
    Stateless Widget은 한 번만 Build 과정이 발생하여 한 번 그려진 화면은 계속 유지되며, 성능이 좋다.
  • Stateful Widget
    Stateful Widget은 setState가 발생 시 다시 Build하는 과정이 일어나며 동적 화면을 구현할 수 있다.

2. Stateful Widget의 라이프 사이클

플러터의 위젯에는 라이프사이클이 존재한다. 위젯의 생성부터 파기까지 위젯의 생명주기가 관리되어지고, 특정 시점에 특정 메소드가 호출된다.

Stateful Widget의 라이프사이클은 화면 구축, 재 드로잉, 화면 파기의 순서대로 이루어진다. 각 사이클에서 호출되어지는 메소드를 살펴보자

위젯 구축

  • createState()
  1. StatefulWidget을 구축하자마자 호출된다.
  2. 위젯 트리 상태를 만들기 위해 호출된다.
  3. state 객체를 생성한다. 이 객체는 해당 위젯에 대한 모든 변경 가능한 state가 유지되는 곳이다.
class MyHomePage extends StatefulWidget {
  
  _MyHomePageState createState() => _MyHomePageState();
}
  • initState()
  1. 위젯 트리 초기화를 한다.
  2. 단 한번만 호출된다.

void initState() {
  super.initState();
  // TODO: implement initState
}
  • didChangeDependencies()
  1. state 객체의 종속성이 변경될 때 호출된다.
  2. initState 뒤에 호출되지만 그 이외에도 호출된다.

재 드로잉

  • build()
  1. 위젯으로 만든 UI를 구축한다.
  2. 다양한 곳에서 반복적으로 호출된다.
  3. 변경된 부분 트리를 감지하고 대체한다.
  • didUpdateWidget()
  1. 위젯의 구성이 변경될 때마다 호출된다.
  2. 부모 위젯이 변경되고 다시 그려져야 할 때 호출된다.
  3. 이전 위젯인 oldWidget을 새 위젯과 비교한다.
  4. didUpdateWidget() 이후에 build() 메소드를 호출한다.

void didUpdateWidget(covariant MyHomePage oldWidget) {
  super.didUpdateWidget(oldWidget);
  // TODO: implement didUpdateWidget
}
  • setState()
  1. 상태가 변경되었을 때 프레임워크에 상태가 변경됨을 알린다.
  2. setState() 이후에 build() 메소드를 호출한다.
setState(() {
  // implement setState
});

화면 파기

  • deactivate()
  1. state 객체가 트리로부터 삭제될 때마다 호출된다.
  2. 위젯 트리에서 위젯이 제거될 때 호출된다.
  3. state가 위젯 트리의 한 지점에서 다른 지점으로 이동할 때, 현재 프레임 변경이 완료되기 전에 다시 주입될 수 있다.
  4. deactive() 메소드는 거의 사용되지 않는다.

void deactivate() {
  super.deactivate();
  // TODO: implement deactivate
}
  • dispose()
  1. state 객체가 트리에서 영구적으로 삭제되고 두 번 다시 빌드되지 않으면 호출된다.

void dispose() {
  super.dispose();
  // TODO: implement dispose
}

initState와 setState의 실행 테스트

initStatesetState 메소드의 실행 시점을 알아보기 위해 아래와 같은 테스트 코드를 작성했다.

  • 테스트 코드
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  
  State<MyApp> createState() {
    print('createState 실행');
    return _MyAppState();
  }
}

class _MyAppState extends State<MyApp> {
  
  void initState() {
    super.initState();
    print('initState 실행');
  }

  
  void didChangeDependencies() {
    super.didChangeDependencies();
    print('didChangeDependencies 실행');
  }

  // root Widget
  
  Widget build(BuildContext context) {
    print('build 실행');
    return MaterialApp(
      home: Scaffold(
          body: TextButton(
        onPressed: () {
          setState(() {
            print('setState 실행');
          });
        },
        child: Text('init 실행테스트'),
      )),
    );
  }
}

위의 코드는 createState initState didChangeDependencies build setState 5가지의 메소드가 실행될 때 각각의 메소드가 실행되었다는 텍스트가 터미널에 출력된다. setStateTextButton을 생성하여 버튼이 눌렸을 때 실행되도록 했다.

우선 앱을 실행시키면 아래와 같은 텍스트가 터미널에 출력된다.

앱이 실행되면 createState가 실행되고 그 다음으로 initState가 실행된다. 다음으로 didChangeDependenciesbuild가 순서대로 실행된다. setState는 아직 실행되지 않는다. 여기서 버튼을 클릭하게 되면 아래와 같이 터미널에 내용이 추가된다.

버튼이 눌리면 setState가 실행되고buuld가 다시 한번 호출된다. 여기서 버튼을 다시 클릭해도 setStatebuild가 실행된다.

이처럼 setState를 실행 했을 때 initState는 다시 실행되지 않는데 그 이유는 우선 initState가 위젯 트리 초기화 시 단 한 번만 호출 되기 때문이다. 그리고, setState는 상태가 변했음을 알리고 build가 다시 그려주면 되기 때문에 initState가 다시 호출될 필요도 없는 것이다. 이러한 과정은 앞에서 살펴본 라이프 사이클의 그림을 통해서도 확인이 가능하다.

추가 라이프사이클 내용

앱을 개발하면서 createState initState build setState dispose는 자주 사용하게 되는 메소드이다.

추가로 mounted 속성이 존재하는데 state 객체를 생성하면 프레임워크가 이 속성을 true로 설정해 state 객체와 BuildContext를 연결한다. 즉, 이 속성은 state 객체가 현재 위젯 트리에 있는지에 대한 정보를 제공하는데 createState가 실행된 후에 true로 설정되고, dispose 메소드 다음에 false로 설정된다.

  • ressemble()
    ressemble() 메소드는 hot reload를 실행할 때마다 호출된다. ressemble()이 호출된 후에는 아래 그림과 같이 라이프 사이클이 진행된다.

Stateless Widget 라이프 사이클

Stateless Widget은 state를 가지고 한 번 생성되면 절대 다시 바뀌지 않는다. 즉, Stateless Widget을 바꾸려면 완전히 파괴하고 다시 리빌드 해야한다. 따라서 Stateless Widget의 라이프 사이클은 매우 간단하며 build() 메소드만 신경써주면 된다.

Stateless Widget이 빌드 될때 마다 build()메소드가 호출되고, 이 메소드 내에서 사용자가 원하는 내용의 위젯을 구성하면 된다.

3. 주어진 화면과 같은 UI 코드 작성(사칙연산 앱)

아래 화면과 같은 사칙연산 앱을 만들고자 한다.

  • 결과 예시

요구사항은 다음과 같다.

  • TextField를 두 개 사용하여 변수에 저장합니다.
  • 사칙연산이 가능하도록 버튼을 4개 만듭니다. 각각의 버튼(+,-,*,/)를 누르면 해당 연산자에 맞는 결과값을 출력합니다.
  • 이 때, 결과값은 다이얼로그(Dialog)로 출력합니다.
  • 코드
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // root Widget
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(title: '사칙연산'), // 홈 페이지 호출(title 매개변수로 전달)
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({super.key, required this.title});

  final String title; // 앱 제목
  int x = 1; // x값 초기화
  int y = 1; // y값 초기화

  //사칙연산 결과 Dialog
  showResultDialog(BuildContext context, var result) {
    showDialog(
      context: context,
      builder: (context) {
        return Dialog(
          shape:
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
          child: SizedBox(
            width: MediaQuery.of(context).size.width / 2,
            height: 150,
            child: Center(
                child: Text(
              "$result",
              style: const TextStyle(fontWeight: FontWeight.bold),
            )),
          ),
        );
      },
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      //앱바
      appBar: AppBar(
        title: Text(title),
        centerTitle: true, //타이틀 글씨 가운데 정렬
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          //x값 입력 필드
          Padding(
            padding: const EdgeInsets.only(left: 16.0),
            child: Row(
              children: [
                Text('X의 값은?'),
                SizedBox(width: 50),
                SizedBox(
                  width: 200,
                  child: TextField(
                    onChanged: (value) {
                      x = int.parse(value);
                    },
                    keyboardType: TextInputType.number,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      hintText: 'x값을 입력하세요.',
                    ),
                  ),
                ),
              ],
            ),
          ),
          //y 값 입력 필드
          Padding(
            padding: const EdgeInsets.only(left: 16.0),
            child: Row(
              children: [
                Text('Y의 값은?'),
                SizedBox(width: 50),
                SizedBox(
                  width: 200,
                  child: TextField(
                    onChanged: (value) {
                      y = int.parse(value);
                    },
                    keyboardType: TextInputType.number,
                    decoration: InputDecoration(
                      border: OutlineInputBorder(),
                      hintText: 'y값을 입력하세요.',
                    ),
                  ),
                ),
              ],
            ),
          ),
          //더하기 버튼
          ElevatedButton(
            onPressed: () {
              showResultDialog(context, x + y);
            },
            child: Text('더하기의 결과값은?!'),
          ),
          //곱하기 버튼
          ElevatedButton(
            onPressed: () {
              showResultDialog(context, x * y);
            },
            child: Text('곱하기의 결과값은?!'),
          ),
          //빼기 버튼
          ElevatedButton(
            onPressed: () {
              showResultDialog(context, x - y);
            },
            child: Text('빼기의 결과값은?!'),
          ),
          //나누기 버튼
          ElevatedButton(
            onPressed: () {
              showResultDialog(context, x / y);
            },
            child: Text('나누기의 결과값은?!'),
          ),
        ],
      ),
    );
  }
}
  • 결과

우선 다이얼로그를 루트 위젯에서 사용하게 되면 에러가 발생한다. 따라서 루트 위젯을 따로 MyApp 위젯으로 MaterialApp 위젯을 반환하는 형태로 만들었다. 이 위젯에서는 MyHomePagetitle의 값을 '사칙연산'으로 전달하며 호출한다.

MyHomePage 위젯에서는 우선 title x y 변수를 생성하였다. 그리고 사칙연산의 결과를 보여주는 다이얼로그를 생성하는 함수를 showResultDialog로 작성했다.

반환하는 위젯은 우선 Scaffold로 만들고 앱바의 타이틀을 매개변수로 전달받은 값으로 설정했다.

본문은 우선 Column을 생성하고 아래에 x값을 받는 필드와 y값을 받는 필드는 각각 Row로 만들었다. 값을 입력받는 TextField에서는 onChanged를 통해 입력받은 값이 x 또는 y에 저장되도록 구현했다. 그리고 숫자를 입력받기 위해 keyboardType: TextInputType.number를 통해 키보드를 숫자 패드로 제한했다. 마지막으로 decoration 속성에서 InputDecoration으로 텍스트 필드의 외곽선과 힌트 텍스트를 입력해 주었다.

값을 입력받는 필드 아래에는 사칙연산별로 4개의 ElevatedButton을 생성하고 onPressed 를 통해 이벤트와 앞에서 작성한 showResultDialog() 함수를 연결해 주었다.


이번주도 끝!!

2주차의 마지막인 8일차가 끝났다. ㅎㅎ (물론 주말에 도전하기랑 주간평가도 하긴 해야되지만...ㅠㅠ) 시작할 때는 기본적인 내용이었지만 점점 내용이 추가되고 있다. 그래도 아직까지는 어려운 내용은 없는것 같다. 가끔 코드로 구현할 때 막힌다거나 오류가 생기긴 하지만 구글링 열심히 해서 해결하고 있다. 오늘은 크게 막히는 부분은 없었지만 처음에 다이얼로그가 안뜨고 오류가 나서 찾아보니 루트 위젯과 분리하면 해결된다고 해서 해결했다. 이유는 루트에서 컨텍스트를 만드는데 같이 포함되면 임의로 만들기 때문에 오류가 난다는 것 같은데 잘 이해가 되지는 않는다.ㅠㅠ 나중에 더 자세히 찾아봐야겠다.

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보