Flutter 1.17.0 @ 2020.05.07

Flutter 1.17.0 버전이 2020년 5월 7일 공식 릴리즈 되었습니다. 이번 릴리즈에 반영된 내용 전체는 Flutter 공식 사이트의 “Change log for Flutter 1.17.0 (출처: https://flutter.dev/docs/development/tools/sdk/release-notes/changelogs/changelog-1.17.0)”에서 확인 이 가능합니다. 이번 릴리즈에서는 231명의 contributor들의 총3164개의 PR(Pull Request)가 있는 것으로 발표 되어습니다.

공식 사이트에는 3164개의 항목이 줄줄이 나열될 뿐 category가 나뉘어지거나 중요도가 명시되지 않았기에, Chris Sells (참조: https://medium.com/@csells)가 작성한 “Announcing Flutter 1.17 (출처: https://medium.com/flutter/announcing-flutter-1-17-4182d8af7f8e)”를 중심으로 중요한 부분에 대해서 요점 정리를 해볼까 합니다. Chris Sells는 Product Manager, Flutter developer experience 로써, Flutter와 관련한 좋은 글들을 종종 발표해 주고 있습니다.

Enhanced App Size and Memory Utilization

1.17.0 릴리즈에서는 빠른 애니메이션, 작아진 어플리케이션 크기, 적은 메모리 사용율을 제공합니다. 이는 Navigation 시에 20~30%의 성능 개선, iOS 애니메이션 경우에 40% 수준의 CPU/GPU 사용 절감 등을 포함하고 있습니다.

어플리케이션 크기의 축소에 대해서 확인하면, Flutter에서 예제로 제공하는 Flutter Gallery 프로그램의 경우, 2019년 말 출시될 때에는 9.6MB 였지만, 1.17.0 버전에서는 18.5%가 축소되어 8.1MB가 되었다고 합니다. 메모리 효율 면에서는, 큰 이미지의 스크롤시에 70% 수준의 메모리 감소를 이루었다고 합니다. 이 향상은 기기의 메모리 용량에 따라 다른 차이를 보일 수 있습니다.

실제로 아래의 그림을 보면 성능 개선 이전과 이후의 메모리 사용 효율이 확연하게 차이가 날 만큼 개선된 것을 확인 할 수 있습니다.

[출처] Announcing Flutter 1.17, Chris Sells
(https://medium.com/flutter/announcing-flutter-1-17-4182d8af7f8e)

Metal Framework Performance Improvement

Apple의 Metal 프레임워크는 GPU에 거의 직접적으로 접근할 수 있는 기능을 제공하여 iOS, macOS 및 tvOS 앱의 그래픽과 컴퓨팅 잠재력을 극대화할 수 있습니다. 쉽게 접근할 수 있는 낮은 오버헤드의 아키텍처에 사전 컴파일된 GPU 셰이더, 세분화된 리소스 제어 및 멀티 스레딩 지원을 기반으로 하는 Metal은 GPU 기반 명령어를 생성하고, Metal 지원 GPU 배열 작업을 간소화하며 Mac Pro 및 Pro Display XDR의 Pro급 성능을 경험할 수 있도록 진화했습니다 (출처: https://developer.apple.com/kr/metal/).

Flutter 1.17.0은 iOS에서 Metal을 기본으로 사용하도록 되었습니다. 이를 통해서 평균 50% 수준의 렌더링 성능 개선 효과를 이루었다고 합니다. Metal을 제대로 지원하지 못하는 A7 프로세서 이전 기기 혹은 iOS 10 이전 운영체제에서는 Flutter의 이전 버전과 마찬가지로 OpenGL을 지원 합니다.

New Material Widgets & Text Scale

Material design에 포함되는 새로운 widget들이 추가 되었습니다. NavigationRail (참조: https://master-api.flutter.dev/flutter/material/NavigationRail-class.html), DatePicker (참조: https://api.flutter.dev/flutter/material/showDatePicker.html), 그리고 새로운 애니메이션 패키지 (참조: https://pub.dev/packages/animations)를 제공합니다.

1.17.0에서는 2018 Material Design specification의 Type Scale 부분에 대한 구현이 완료 되었습니다. 몇년간에 걸쳐서 이루어진 개발이지만, 과거 구현된 이름들등의 인터페이스가 변경되지 않고 유지 되었습니다. 따라서, 해당 기술은 1.17.0으로 소프트웨어를 업데이트 하면 기존 소스 프로그램의 수정 없이 반영이 됩니다.

Google Fonts Support

Google은 Flutter를 위한 Google Font를 2019년 12월에 열린 Flutter Interact에서 공개하였습니다 (참조: https://medium.com/flutter/introducing-google-fonts-for-flutter-v-1-0-0-c0e993617118). 1.17.0은 이를 반영한 최초의 릴리즈로서, “Google Fonts for Flutter v1.0 release (출처: https://pub.dev/packages/google_fonts 혹은 https://github.com/material-foundation/google-fonts-flutter)”를 지원하고 있습니다.

Flutter DevTools (Porting of Dart DevTools)

1.17.0에서는 기존 Dart DevTools의 Flutter 버전이 포함됩니다. Dart DevTools에 생긴 “비이커(beaker)” 아이콘을 통해서 활성화 가능합니다.

[출처] Announcing Flutter 1.17, Chris Sells
(https://medium.com/flutter/announcing-flutter-1-17-4182d8af7f8e)

Flutter 버전으로 바뀌면서 소소한 개선과 변화들이 있지만, 가장 큰 차이점은 Network 탭의 추가 입니다. 이 기능을 통해서 Flutter 어플리케이션이 사용한 네트워크 트래픽에 대한 리코딩이 가능합니다.

이외에 Android 앱 개발시 “fast start” 기능을 제공해서, 개발 단계에서 프로그램의 부분적인 수정시 APK를 re-build 하는 부담을 줄입니다. Android 앱 개발시 기존 Android Studio Library를 사용하던 방식에서 AndroidX/Android-Jetpack를 사용하는 방식으로 바뀌기도 했습니다. Android Studio나 IntelliJ 사용시 코드의 analysis error가 Hot Reload 기능을 비활성화 하던 부분도 개선이 이루어 졌습니다.

마무리

2020년 들어서 처음 출시된 Flutter 릴리즈인 1.17.0에 대해서 Chris Sells의 “Announcing Flutter 1.17″에서 주요 부분을 요약하는 입장에서 정리해 보았습니다. Tutorial을 마쳤어도, 소프트웨어 기술을 살아 움직이는 생물처럼 계속 진화와 변화가 있으므로, 정기적인 릴리즈에 대한 지속적인 관심과 이해는 필수라고 볼 수 있습니다.

Dart Programmer 되기 [35]

< Flutter 활용하기 – Skeleton Program for Future Usage >

Flutter가 강력하다고 느낀다면 다행이지만, 처음 모바일 프로그래밍을 cross-platform으로 시도한다면, 뭐가 좋은건가 싶을 것 입니다. 아마도 “왜 이렇게 복잡한거야?”하고 의문을 가질수도 있습니다. 개인적으로 JavaScript 기반의 corss-platform인 Cordova과 PhoneGap을 다뤄본 입장에서, JavsScript 기반의 접근은 진입 장벽이 의외로 높았다고 볼 수 있습니다. JavaScript, HTML, CSS 등의 언어에 대한 이해도 필요하지만, 사용자 인터페이스를 이 기술들로 만드는 작업은 매우 고되며, 디바이스에 맞춰서 사용자 인터페이스를 조정한다는 것도 만족스러운 품질을 얻기 어렵습니다.

이번 글이 Flutter의 마지막에 해당하는 글이기에, 향후를 대비하여 나름 두고 두고 재활용 할 수 있는 형태의 프로그램을 만들고자 합니다. 이를 토대로 본인이 원하는 기능을 채우고, 기능에 적합한 형태의 사용자 인터페이스로 개선하는 작업을 할 수 있을 겁니다.

Step.1 Select Widgets

“Widget is Everything” 이라는 표현이 여러번 등장 한 것처럼, Widget들의 존재를 알고, 이해한 후, 이들을 엮는 기술은 Flutter를 사용함에 매우 중요한 기술 입니다. 이를 위해서, 다음의 단계를 권합니다.

첫번째로 Flutter에서 제공하는 Widget 들이 너무 많기에, 어떤 Widget이 있는지 알고, 쓸만한 Widget을 선택하는 방법이 필요 합니다. 아래는 그림과 글을 통해서 Flutter의 Widget들을 찾아볼 수 있는 사이트 입니다.

  • https://flutter.dev/docs/reference/widgets
  • https://flutter.dev/docs/development/ui/widgets

시간이 있다면, 실제로 동작하는 화면과 간단한 설명을 볼 수 있는 YouTube를 추천합니다. Dart/Flutter는 아래와 같이 공식 YouTube를 통해서 각종 행사와 기술 발표에 대한 사항을 동영상으로 배포하고 있습니다.

  • https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw

특히, 이 YouTube에서는 “Flutter Widget of the Week” 모토 아래 매주 Flutter Widget 중 하나를 짧은 동영상으로 소개하여, 동작 화면과 핵심 코드를 이해할 수 있도록 하고 있으니, 필요할때 살펴보다가 마음에 드는 Widget을 점 찍어 두면 요긴하게 쓸 수 있습니다.

  • https://www.youtube.com/watch?v=b_sQ9bMltGU&list=PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG

두번째로 선택한 Widget에 대해서 자세히 알아야 합니다. 이를 위하여, Flutter의 공식 홈페이지에는 다음과 같이 Widget들을 리스트로 나열하고, 각각의 Widget에 대해서 필요시 상세하게 이해할 수 있는 다음의 사이트를 제공합니다.

  • https://api.flutter.dev/flutter/widgets/widgets-library.html

본 글에서 설명할 darttutorial-35-01.dart 프로그램에서도 다양한 Widget 들을 사용합니다. 이들에 대해서 자세하게 이해하고 싶은 경우는 아래에서 해당 Widget에 대한 상세한 정보를 읽고, 실제 프로그램에서 사용하였습니다.

  • https://api.flutter.dev/flutter/material/Scaffold-class.html
  • https://api.flutter.dev/flutter/material/OutlineButton-class.html
  • https://api.flutter.dev/flutter/material/Icons-class.html
  • https://api.flutter.dev/flutter/material/SliverAppBar-class.html
  • https://api.flutter.dev/flutter/material/ListTile-class.html
  • https://api.flutter.dev/flutter/material/Drawer-class.html
  • https://api.flutter.dev/flutter/widgets/Text-class.html
  • https://api.flutter.dev/flutter/widgets/PageController-class.html
  • https://api.flutter.dev/flutter/painting/TextStyle-class.

세번째로 위의 Widget 설명들을 보다보면, 본인이 만들고자 하는 프로그램에 영감을 주는 유용한 sample들을 발견하는 경우들이 있습니다. darttutorial-35-01.dart 경우는 애시당초 통신 관련 프로그램을 작성하기 위한 일환으로 Widget들을 살펴보던 중, 나름 잘 맞아 보이는 다음의 예제 프로그램을 토대로 만들어 보았습니다.

  • https://flutter.dev/docs/catalog/samples/basic-app-bar
  • https://androidmonks.com/sliverappbar-flutter/

Step.2 GUI Design

이 프로그램은 총4개의 페이지로 구성되며, 첫번째 페이지는 [그림 1]이며, 프로그램이 최초로 실행하면 나타나는 페이지 입니다. 별도의 기능은 없으며, 단순하게 프로그램의 이름을 보여주는 용도일 뿐입니다. 이 페이지를 오른쪽에서 왼쪽으로 밀면 다음 페이지로 이동합니다.

[그림 1] 메인 페이지

주 작업을 진행하는 페이지가 [그림 2] 입니다. 세 부분으로 나누어져 있으며, 윗쪽의 두부분은 같은 색인 파란색으로 채워져 있습니다. 하나로 보이지만, [그림 3]처럼 파랑색 밑의 부분을 스크롤 업 하면, 첫번째 부분은 그대로 고정되어 있지만, 두번째 해당하는 부분은 스크롤이 되어 화면에서 사라지는 것을 볼 수 있습니다.

첫번째 영역은 왼쪽에 타이틀로 필요한 이름을 나타내고 있고, 오른쪽에 세개의 버튼이 있는 것을 볼 수 있습니다. 세개의 버튼에 대해서는 추후 설명합니다. 두번째 영역에는 사용자에게 알려주고 싶은 정보를 문자열로 나타내고 있습니다. 그리고 세번때 영역에는 터치하여 선택이 가능한 메뉴들이 리스트로 나타나 있습니다.

[그림 2] 주 작업 페이지

앞서 언급 한 것처럼, 맨 윗 부분은 그대로 유지되고 있습니다. 하지만, 두번째 영역은 리스트들과 함께 위로 올라가서 사라진 것을 볼 수 있습니다.

[그림 3] 주 작업 페이지의 스크롤 화면

맨 윗 부분의 세개 버튼 중 왼쪽에서 첫번째 버튼을 터치하면, [그림 4]와 같이 AlertDialog 박스가 나타나는 것을 볼 수 있습니다. 일반적인 다이얼로그의 형태로서, “OK” 혹은 “Cancel” 중 하나를 선택하도록 합니다. 두 버튼 중 하나를 누르면, 화면 아래에 몇초간 파란 화면이 생긴 후, OK 혹은 Calcel 이라는 글자가 나타나는데, 이는 SnackBar라고 부르는 Widget으로 구현한 것 입니다.

[그림 4] 다이얼로그 박스 활성화 화면
(주 작업 페이지)

가운데 버튼에는 아무런 동작을 연결하지 않았기에 터치를 해도 반응을 하지 않을 겁니다. 맨 오르쪽의 버튼은 팝업 메뉴를 나타냅니다. 그리고 메뉴는 총4개로 나타나 있는 것을 볼 수 있습니다. 사진에는 나타나지 않지만, 맨위의 세개 버튼 중 왼쪽/가운데 버튼과 팝업 메뉴 중 네개 메뉴는, 메뉴가 눌려졌을때 하단의 리스트안의 문자열이 바뀌며([그림 5]의 경우, “Rotate Left”로 명시한 부분), 문장의 “0 times”의 숫자가 하나씩 증가하도록 되어 있습니다. 따라서, 단추를 터치하는 일이 리스트 내부에 대한 변화를 만들도록 되어 있습니다. 팝업 메뉴는 메뉴 이외의 화면 영역을 터치하면 사라집니다. [그림 10]에 이를 나타내었습니다.

[그림 5] 팝업 메뉴 버튼 활성화 화면
(주 작업 페이지)

본 페이지에서 화면을 오른쪽에서 왼쪽으로 스크롤하면 [그림 6]으로 이동하고, 왼쪽에서 오른쪽으로 스크롤하면 [그림 1]로 이동할 수 있습니다. 그리고 리스트의 항목 중 [Item#0]라고 쓰여진 첫번째 항목을 선택하면, [그림 6]의 페이지로 이동하고, 다른 항목을 선택하면 [그림 9]의 페이지로 이동합니다. [그림 6]은 아래에 3개의 터치 버튼이 있습니다. 첫번째 home 버튼을 누르면, 화면은 주 작업 페이지인 [그림 2]로 이동합니다.

[그림 6] 서브 작업 페이지 #2의 Home 영역

[그림 6]에서 가운데 Cloud 버튼을 누르면, Cloud 버튼의 색이 하얀색으로 바뀌면서, 가운데 글자가 [그림 7]과 같이 바뀝니다.

[그림 7] 서브 작업 페이지 #2의 Cloud 영역

[그림 6]에서 세번째 Star 버튼을 누르면, Star 버튼의 색이 하얀색으로 바뀌면서, 가운데 글자가 [그림 8]과 같이 바뀝니다.

[그림 8] 서브 작업 페이지 #2의 Star 영역

[그림 9]는 화면을 스크롤해서 가거나, 주 작업 페이지에서 첫번째 리스트를 터치하여 이동할 수 있습니다. 가운데에 클릭하면 주 작업 페이지로 이동할 수 있는 RaisedButton이 있는 것을 제외하면, 다른 역할은 없습니다.

[그림 9] 서브 작업 페이지 #3

[그림 10]의 SnackBar는 앞서 설명한 것처럼, 사용자의 입력에 반응해서, 임시적으로 결과를 보여준 후 다시 사라지는 용도로 활용합니다.

[그림 10] SnackBar 화면

이 정도의 기능이라면 왠만한 기능의 앱을 만들기에는 충분히 효과적인 기본 바탕이 될 것 입니다. 그리고, 사용자 인터페이스가 다소 복잡해 보이지만, 기본이 되는 간단한 Widget들을 엮어서 고도화된 사용자 인터페이스를 구현하는 것이 가능하다는 것을 알 수 있습니다.

Step.3 Completes the Functions

Step.2와 같은 GUI를 구현하는 Dart/Flutter 소스코드는 아래와 같습니다. 가장 복잡하지만, 사용자 인터페이스에 직결되는 부분은 State<> class를 확장한 MyStatefulWidgetState의 build() 입니다. 앞서 설명한 4개의 페이지에 대한 코드를 각각 PageView #0 ~ #3으로 명시 하였습니다. 4개의 페이지가 각각 [그림 1], [그림 2], [그림 6] 그리고 [그림 9]에 대응하는 코드들 입니다. 예상 하겠지만, 두번째 페이지가 가장 많은 분량으로 여러 Widget들로 만들어져 있는 것을 볼 수 있습니다.

사실 이 프로그램에서 저장하고 관리하는 정보는 3가지 뿐입니다. 하나는 주 작업 페이지에서 리스트를 선택할 때 마다 증가하는 count 값이고, 두번째는 리스트에 나타나는 문자열 정보로서, 이는 주 작업 메뉴에서 상단부의 버튼들에 의해서 정해 집니다. 세번째는 [그림 6]의 서브 페이지에서 하단 부의 세가지 버튼 중 어느 것이 선택되어 있는지에 대한 정보입니다. State<> class를 확장한 MyStatefulWidgetState class에 저장되어 있습니다.

프로그램 소스 코드에 대한 구체적인 이해는 독자의 몫으로 남겨 놓도록 하겠습니다. 344 라인 정도의 프로그램이므로, 페이지별로 끊어서 앞서의 라이브러리 등을 찾아가며 이해한다면, 추후 필요한 형태로 변형하고 기능을 채우는데 문제가 없을 겁니다.

// darttutorial-35-01.dart

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  static const String _title = 'Flutter Code Sample';

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: MyStatefulWidget(),
    );
  }
}

class MyStatefulWidget extends StatefulWidget {
  MyStatefulWidget({Key key}) : super(key: key);

  @override
  _MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  int _count = 0;
  Choice _selectedChoice = choices[0]; // The app's "state".
  int _selectedIndex = 0;

  final scaffoldKey = GlobalKey<ScaffoldState>();

  static const TextStyle optionStyle =
      TextStyle(fontSize: 30, fontWeight: FontWeight.bold);

  static const List<Widget> _widgetOptions = <Widget>[
    Text(
      'Index 0: Home',
      style: optionStyle,
    ),
    Text(
      'Index 1: Cloud',
      style: optionStyle,
    ),
    Text(
      'Index 2: Star',
      style: optionStyle,
    ),
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;

      print("_onItemTapped : $index");

      if ((_pageController.hasClients) && (index == 0)) {
        _pageController.animateToPage(
          1,
          duration: const Duration(milliseconds: 10),
          curve: Curves.easeInOut,
        );
      }
    });
  }

  void _select(Choice choice) {
    // Causes the app to rebuild with the new _selectedChoice.
    setState(() {
      _selectedChoice = choice;
    });
  }

  void showAlertDialog(BuildContext context) async {
    String result = await showDialog(
      context: context,
      barrierDismissible: false, // user must tap button!
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('AlertDialog Demo'),
          content: Text("Select button you want"),
          actions: <Widget>[
            FlatButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.pop(context, "OK");
              },
            ),
            FlatButton(
              child: Text('Cancel'),
              onPressed: () {
                Navigator.pop(context, "Cancel");
              },
            ),
          ],
        );
      }, // builder
    ); // showDialog

    scaffoldKey.currentState
      ..hideCurrentSnackBar()
      ..showSnackBar(
        SnackBar(
          content: Text("Result: $result"),
          backgroundColor: Colors.blueAccent,
          action: SnackBarAction(
            label: "Done",
            textColor: Colors.white,
            onPressed: () {},
          ),
        ),
      );
  } // showAlertDialog

  PageController _pageController;

  @override
  void initState() {
    super.initState();
    _pageController = PageController();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        key: scaffoldKey,
        // PageViews
        body: PageView(
          controller: _pageController,
          children: [
            // PageView #0 : Initial Title
            Container(
              color: Colors.white,
              child: RaisedButton(
                elevation: 0,
                padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
                color: Colors.blueAccent,
                textColor: Colors.white,
                child: Text(
                  'PageView #0\n\nMain Title',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                      fontSize: 32.0,
                      fontWeight: FontWeight.bold,
                      fontStyle: FontStyle.italic,
                      textBaseline: TextBaseline.alphabetic),
                ),
                onPressed: () {
                  if (_pageController.hasClients) {
                    _pageController.animateToPage(
                      1,
                      duration: const Duration(milliseconds: 10),
                      curve: Curves.easeInOut,
                    );
                  }
                },
              ),
            ),
            // PageView #1 : Main
            Container(
              color: Colors.white,
              child: CustomScrollView(
                slivers: <Widget>[
                  SliverAppBar(
                    title: Text("PageView #1 - Main"),
                    backgroundColor: Colors.blueAccent,
                    pinned: true,
                    actions: <Widget>[
                      // action button
                      IconButton(
                        icon: Icon(choices[0].icon),
                        onPressed: () {
                          showAlertDialog(context);
                          _select(choices[0]);
                        },
                      ),
                      // action button
                      IconButton(
                        icon: Icon(choices[1].icon),
                        onPressed: () {
                          _select(choices[1]);
                        },
                      ),
                      // overflow menu
                      PopupMenuButton<Choice>(
                        onSelected: _select,
                        itemBuilder: (BuildContext context) {
                          return choices.skip(2).map((Choice choice) {
                            return PopupMenuItem<Choice>(
                              value: choice,
                              child: Text(choice.title),
                            );
                          }).toList();
                        },
                      ),
                    ],
                  ),
                  SliverAppBar(
                    backgroundColor: Colors.blueAccent,
                    floating: true,
                    expandedHeight: 70.0,
                    flexibleSpace: ListView(
                      children: <Widget>[
                        Text(
                          '  Sub-title 0',
                          textAlign: TextAlign.left,
                          overflow: TextOverflow.ellipsis,
                          style: TextStyle(
                              fontWeight: FontWeight.bold, color: Colors.white),
                        ),
                        Text(
                          '  Sub-title 1',
                          textAlign: TextAlign.left,
                          overflow: TextOverflow.ellipsis,
                          style: TextStyle(
                              fontWeight: FontWeight.bold, color: Colors.white),
                        ),
                        Text.rich(
                          TextSpan(
                            text: '  ', // default text style
                            children: <TextSpan>[
                              TextSpan(
                                  text: 'Sub-title ',
                                  style: TextStyle(
                                      color: Colors.white,
                                      fontStyle: FontStyle.italic)),
                              TextSpan(
                                  text: 'with Span-mode',
                                  style: TextStyle(
                                      color: Colors.white,
                                      fontWeight: FontWeight.bold)),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                  SliverList(
                    delegate: SliverChildBuilderDelegate(
                        (context, index) => Card(
                            child: ListTile(
                                leading: FlutterLogo(),
                                title: Text(
                                    '[Item #$index] Button pressed $_count times.'),
                                trailing: Icon(Icons.more_vert),
                                subtitle: Text('${_selectedChoice.title}'),
                                onTap: () => setState(() {
                                      if (_pageController.hasClients) {
                                        _pageController.animateToPage(
                                          (index + 2),
                                          duration:
                                              const Duration(milliseconds: 10),
                                          curve: Curves.easeInOut,
                                        );
                                      }
                                      _count++;
                                    }))),
                        childCount: 10),
                  ),
                ],
              ),
            ),
            // PageView #2 : Sub
            Scaffold(
              appBar: AppBar(
                title: const Text('PageView #2 - Sub-Menu 1'),
                backgroundColor: Colors.blueAccent,
              ),
              body: Center(
                child: _widgetOptions.elementAt(_selectedIndex),
              ),
              bottomNavigationBar: BottomNavigationBar(
                items: const <BottomNavigationBarItem>[
                  BottomNavigationBarItem(
                    icon: Icon(Icons.home),
                    title: Text('Home'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.wb_cloudy),
                    title: Text('Cloud'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.star),
                    title: Text('Star'),
                  ),
                ],
                currentIndex: _selectedIndex,
                selectedItemColor: Colors.white,
                backgroundColor: Colors.blueAccent,
                onTap: _onItemTapped,
              ),
            ),
            // PageView #3 : Sub
            Container(
              color: Colors.blueAccent,
              child: Center(
                child: RaisedButton(
                  color: Colors.blueAccent,
                  onPressed: () {
                    if (_pageController.hasClients) {
                      _pageController.animateToPage(
                        1,
                        duration: const Duration(milliseconds: 10),
                        curve: Curves.easeInOut,
                      );
                    }
                  },
                  child: Text(
                    'PageView #3 - Sub-Menu 2',
                    style: TextStyle(
                        fontWeight: FontWeight.bold, color: Colors.white),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class Choice {
  const Choice({this.title, this.icon});

  final String title;
  final IconData icon;
}

const List<Choice> choices = const <Choice>[
  const Choice(title: 'Rotate Left', icon: Icons.rotate_left),
  const Choice(title: 'Rotate Right', icon: Icons.rotate_right),
  const Choice(title: 'Dissatisfied', icon: Icons.sentiment_dissatisfied),
  const Choice(title: 'Neutral', icon: Icons.sentiment_neutral),
  const Choice(title: 'Satisfied', icon: Icons.sentiment_satisfied),
  const Choice(title: 'Very Satisfied', icon: Icons.sentiment_very_satisfied),
];

마무리

비인기 언어의 선봉이였던 Dart를 단시일에 최고 인기 언어 중 하나로 끌어 올린 Flutter는 숙명적으로 corss-platform을 구현해야 하는 기업용 소프트웨어 및 모바일 소프트웨어 개발자들에게 많은 인기를 끌고 있습니다. 최근 Google의 오픈소스 사이트인 https://cs.opensource.google/ 에도 Dart와 Flutter가 등재되어, 더 많은 인기를 구가할 것으로 보입니다.

Creative Commons License (CC BY-NC-ND)

Dart Programmer 되기 [34]

< Flutter 활용하기 – StatefulWidgets Example >

앞의 글에서 작성한 StatelessWidget class 기반 앱을 수정하여, 사용자와 동적인 반응을 하면서 사용자 인터페이스가 변경되는 형태로 바꾸고자 합니다. 즉, StatefulWidget class 개념을 추가하여 앞서에서의 darttutorial-33-01.dart를 수정 합니다.

Goals of Modification

프로그램의 수정 목표는 단순 합니다. 아래의 [그림1]과 같이 작성한 앞서 글의 프로그램에서, 별표를 클릭하면, [그림2]와 같이 되는 것입니다. 즉, 1) 붉은색인 별모양을 검은색으로 바꿉니다. 2) 별표 옆의 숫자를 1만큼 감소 시킵니다.

[그림1] “좋아요” 활성화 상태 (별 모양 아이콘이 붉은색)

만약 별이 검은색인 상태에서 다시 클릭을 하면, 1) 검은색의 별모양을 붉은색으로 바꿉니다. 2) 별표 옆의 숫자를 1만큼 증가 시킵니다. 즉, 누를때 마다 반전(reverse) 작업을 수행하는 단순한 작업을 하도록 개선 합니다.

[그림2] “좋아요” 비활성화 상태 (별 모양 아이콘이 검정색)

Code Modification using StatefulWidget

아래의 darttutorial-34-01.dart 프로그램이 수정한 main.dart 프로그램 입니다. 프로그램의 수정은 크게 2 부분에 대해서 이루어 집니다. 첫째는 소스 코드에서 “OLD”로 표기하여 주석 처리한 부분 대신 “New #1″으로 바뀐 부분입니다. 둘째는 “New #2″로 표시한, 프로그램의 후반부에 추가한 사항입니다.

// darttutorial-34-01.dart
// Based on : https://flutter.dev/docs/development/ui/layout

import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';

void main() {
  var parameter = {
    'appBarTitle': 'Flutter Layout Demo Program',
    'titleImage': 'images/lake.jpg',
    'titleSectionHeader': '경희대학교 국제캠퍼스',
    'titleSectionBody': '경기도 용인시 기흥구 덕영대로 1732번지',
    'titleSectionScore': 22,
    'textSection': '국제캠퍼스는 공학, 응용과학, 생명과학, 전자정보, 소프트웨어, 실용예술, 국제학, '
        '외국어, 체육 중심의 캠퍼스로, 특성 있는 학문분야가 많습니다. '
        '주요 시설로는 수용 인원 8천여명 규모의 노천극장 및 종합체육관, 천문대, 원자로실 등이 있습니다. '
        '경희대는 국제캠퍼스를 포함하여 서울캠퍼스, 광름캠퍼스를 보유하고 있습니다. '
        '교명인 경희대(慶熙大)는 영정조 시대의 치세가 펼쳐진 조선시대의 정궁 경희궁(慶熙宮)에서 따온 것으로, '
        '임진왜란과 병자호란의 폐허를 딛고 문예를 부흥시킨 조선 후기 영정조 시대처럼, '
        '한국 전쟁으로 피폐해진 이 땅에 다시 문화적인 르네상스가 오기를 바라는 마음으로 '
        '경희학원(고황재단)의 설립자인 조영식에 의해 명명되었습니다.',
  };

  runApp(MyApp(parameter));
}

class MyApp extends StatelessWidget {
  // Constructor with a Map type input parameter.
  MyApp(this.internalStorage);

  // Internal storage to save a page content which initialized by constructor.
  final Map internalStorage;

  // build method.
  @override
  Widget build(BuildContext context) {
    Widget titleImage = _buildTitleImage(internalStorage['titleImage']);

    Widget titleSection = _buildTitleSection(
        internalStorage['titleSectionHeader'],
        internalStorage['titleSectionBody'],
        internalStorage['titleSectionScore']);

    Widget buttonSection = _buildButtonSection(Theme.of(context).primaryColor);

    String textSectionMessage = internalStorage['textSection'];

    Widget textSection = _buildTextSection(textSectionMessage);

    return MaterialApp(
      title: internalStorage['appBarTitle'],
      home: Scaffold(
        appBar: AppBar(
          title: Text(internalStorage['appBarTitle']),
        ),
        body: ListView(
          children: [
            titleImage,
            titleSection,
            buttonSection,
            textSection,
          ],
        ),
      ),
    );
  }

  // Build function to create title image Widget
  dynamic _buildTitleImage(String imageName) {
    return Image.asset(
      imageName,
      width: 600,
      height: 240,
      fit: BoxFit.cover,
    );
  }

  // Build function to create title section Widget
  Container _buildTitleSection(String name, String addr, int count) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Container(
                  padding: const EdgeInsets.only(bottom: 8),
                  child: Text(
                    name,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  addr,
                  style: TextStyle(
                    color: Colors.grey[500],
                  ),
                ),
              ],
            ),
          ),
/* OLD    
          Container(
            child: Row(
              children: [
                Icon(
                  Icons.star,
                  color: Colors.red[500],
                ),
                Text('$count'),
              ],
            ),
          ),
 */
// New #1 : Start
          Counter(count),
// New #1 : End
        ],
      ),
    );
  }

  // Build function to create button section Widget
  Container _buildButtonSection(Color color) {
    return Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildButtonColumn(color, Icons.call, '전화'),
          _buildButtonColumn(color, Icons.near_me, '경로'),
          _buildButtonColumn(color, Icons.share, '공유'),
        ],
      ),
    );
  }

  // Build function to create button with icon and sub-title for _buildButtonSection()
  Column _buildButtonColumn(Color color, IconData icon, String label) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }

  // Build function to create text description section Widget
  Container _buildTextSection(String section) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Text(
        section,
        softWrap: true,
      ),
    );
  }
}

// New #2 : Start

class Counter extends StatefulWidget {
  final _counter;

  Counter(this._counter) {
  }

  @override
  _CounterState createState() => _CounterState(_counter);
}

class _CounterState extends State<Counter> {
  int _counter;
  bool _boolStatus = true;
  Color _statusColor = Colors.black;

  _CounterState(var counterValue) {
    _counter = counterValue;
  }

  void _buttnPressed() {
    setState(() {
      if(_boolStatus == true) {
        _boolStatus = false;
        _counter--;
        _statusColor = Colors.black;
      } else {
        _boolStatus = true;
        _counter++;
        _statusColor = Colors.red;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Row(
        children: [
          IconButton(
            icon: Icon(Icons.star),
            color: _statusColor,
            onPressed: _buttnPressed,
          ),
          Text('$_counter'),
        ],
      ),
    );
  }
}

// New #2 : End

수정사항 #1은 앞서 darttutorial-33-01.dart에서 고정적인 형태로 만든 붉은색 별표 아이콘과 고정된 count 값을, count 값을 constructor의 입력 파라메타로 대체한 Counter class의 객체로 교체한 것 입니다.

수정사항 #2는 이제 정적인 별모양과 count 값이, 동적으로 변경될 것이니, 이를 저장하고 관리할 StatefulWidget과 이 widget이 다룰 State<> class를 만드는 작업입니다. 먼저, 앞서 #1에서 제거한 모양을 대체하는 모양이 _CounterState class 안의 build() 속에 포함된 것을 볼 수 있습니다. build() 함수의 기능은 이미 여러번 언급이 되었듯이, 화면에 사용자 인터페이스를 표시하는 역할을 합니다. 이 함수가 리턴하는 Container() 객체의 구성 요소를 살펴보면, 앞서 제거한 고정 형태의 Container()와 동일하게 별모양 아이콘과 Text로 이루어진 것을 알수 있습니다. 단, 고정 방식에서는 color가 붉은 색으로 고정되었으나, 수정한 코드에서는 stateColor 변수의 값에 따라 달라지도록 하였습니다. 고정 방식에서는 없었던 onPressed method를 채우는 부분이 생겼으며, 이는 별모양을 클릭하면 buttonPressed() 함수가 호출되는 것을 의미합니다. 별 모양 옆에 같은 Row로 표시되는 글자는 마찬가지로 counter라는 변수 값을 표시하도록 되어 있습니다.

새로 등장한 사항을 확인해 보면, 먼저 statusColor는 CounterState class의 property로 만들어져 있는 것을 볼 수 있습니다. 다음으로 buttnPressed() method가 CounterState class 안에 정의되어 있는 것을 볼수 있습니다. 여기서, buttnPressed() 안을 보면, build() 함수를 trigger하여 화면을 업데이트 하는 setState()가 실행되는데, 여기에 전달하는 함수의 기능을 살펴보면, boolStatus 값이 true/false의 이분법적인 상태에 맞춰서, 별모양의 색을 붉은색과 검정색 사이에서 변경하고, counter의 값을 1만큼 늘리거나 줄이는 작업을 하는 것을 알 수 있습니다. 따라서, 단추를 눌렀을때 모양과 숫자가 바뀌는 이유를 이제 이해할 수 있을 겁니다. 별모양 옆의 숫자는 다음의 순서를 거쳐서 CounterState class의 counter 값으로 전달됩니다. 먼저 MyApp class의 buildTitleSection()이 실행하면서, Counter 객체를 만들게 되고, 여기서 count 값을 초기화 합니다. 이를 받은 Counter 클래스의 객체는 자신의 property를 받은 count 값으로 초기화 한 후, 관리할 State인 CounterState class 객체의 초기화 값으로 전달하게 되고, CounterState class는 이를 내부 property인 counter에 저장함으로, 프로그램 시작시의 초기화 값을 전달 받게 됩니다.

마무리

지금까지 Flutter의 StatelessWidget과 StatefulWidget을 이용하여, 정적인 사용자 인터페이스와 동적인 사용자 인터페이스를 구현해 보았습니다. 지금까지 실행해 온 프로그램들의 소스 코드를 살펴보면 알겠지만, Flutter는 간단하게 동작하는 Widget들을 활용하여 단순한 화면을 구성하거나, Widget들을 복합적으로 사용하여, 복잡한 기능과 표현을 구현할 수 있습니다. 이러한 이유로 Flutter를 시작할 때, 다양한 Reference들을 먼저 소개하였습니다. 어떤 Widget들을 Flutter가 지원하는지와 3rd party를 통해서 제공되는 Widget들은 어떤 것들이 있는지를 이해한다면 프로그램을 개발하는데 큰 도움이 될 겁니다.

Creative Commons License (CC BY-NC-ND)

Dart Programmer 되기 [33]

< Flutter 활용하기 – StatelessWidget Example >

지금까지 글들을 이해하고 실행해 왔다면, 이제 직접 스마트폰과 태블릿에서 컨텐츠가 바뀌지 않는 프로그램의 개발은 가능하게 되었습니다. 이번 글에서는 앞서의 내용을 제대로 이해하고 있는지를 재확인하는 차원에서 Flutter 공식 사이트의 실습 과정을 직접 해보는 것으로 대체하고자 합니다.

Building Layouts – Layout in Flutter

Flutter 공식 사이트의 https://flutter.dev/docs/development/ui/layout 에 위치한 Layouts in Flutter는 앞서의 글에서도 인용하여 설명한 내용들이 들어가 있습니다. 이 글의 내용을 하나 하나 읽고 직접 따라해 보기 바랍니다. 이를 통해서, 사용자 인터페이스를 만드는데 사용되는 Widget class들을 다시 재확인하게 됩니다. 그리고 사용자 인터페이스를 채우는 요소들에 해당하는 widget들과 layout의 개념에 대해서 확인하게 됩니다. 아울러 단순한 widget들을 사용하여 복잡한 widget을 만들어 내는 과정을 경험합니다.

Building Layouts – Tutorial

Flutter 공식 사이트의 https://flutter.dev/docs/development/ui/layout/tutorial 에 위치한 Building Layouts는 StatelessWidget을 사용하여 간단한 앱을 만드는 과정입니다. 이 글의 내용을 하나 하나 읽고 직접 따라해 보기 바랍니다. 이를 통해서, Flutter의 layout에 대한 이해가 좀 더 고도화 되며, row/column 기반으로 widget들을 배치하는 부분을 이해하게 됩니다.

Write your own StatelessWidget application

앞에서 실습한 내용을 토대로 본인만의 StatelessWidget application을 만들어 봅니다. 저의 경우는 경희대학교를 소개하는 고정 컨텐츠 페이지를 아래의 그림과 같이 만들어 보았습니다. 앞서의 예제에서 일부 수정하는 방식으로 작성하였습니다.

[그림] darttutorial-33-01.dart 실행 화면

이미지를 바꾸고, 내용을 일부 바꾸는 minor한 수정을 하였으며, 이에 대한 소스 코드인 darttutorial-33-01.dart는 아래와 같습니다.

// darttutorial-33-01.dart
// Based on : https://flutter.dev/docs/development/ui/layout

import 'package:flutter/material.dart';

void main() {
  var parameter = {
    'appBarTitle': 'Flutter Layout Demo Program',
    'titleImage': 'images/lake.jpg',
    'titleSectionHeader': '경희대학교 국제캠퍼스',
    'titleSectionBody': '경기도 용인시 기흥구 덕영대로 1732번지',
    'titleSectionScore': 41,
    'textSection':
        '국제캠퍼스는 공학, 응용과학, 생명과학, 전자정보, 소프트웨어, 실용예술, 국제학, '
        '외국어, 체육 중심의 캠퍼스로, 특성 있는 학문분야가 많습니다. '
        '주요 시설로는 수용 인원 8천여명 규모의 노천극장 및 종합체육관, 천문대, 원자로실 등이 있습니다. '
        '경희대는 국제캠퍼스를 포함하여 서울캠퍼스, 광름캠퍼스를 보유하고 있습니다. '
        '교명인 경희대(慶熙大)는 영정조 시대의 치세가 펼쳐진 조선시대의 정궁 경희궁(慶熙宮)에서 따온 것으로, '
        '임진왜란과 병자호란의 폐허를 딛고 문예를 부흥시킨 조선 후기 영정조 시대처럼, '
        '한국 전쟁으로 피폐해진 이 땅에 다시 문화적인 르네상스가 오기를 바라는 마음으로 '
        '경희학원(고황재단)의 설립자인 조영식에 의해 명명되었습니다.',
  };

  runApp(MyApp(parameter));
}

class MyApp extends StatelessWidget {
  // Constructor with a Map type input parameter.
  MyApp(this.internalStorage);

  // Internal storage to save a page content which initialized by constructor.
  final Map internalStorage;

  // build method.
  @override
  Widget build(BuildContext context) {
    Widget titleImage = _buildTitleImage(internalStorage['titleImage']);

    Widget titleSection = _buildTitleSection(
        internalStorage['titleSectionHeader'],
        internalStorage['titleSectionBody'],
        internalStorage['titleSectionScore']);

    Widget buttonSection = _buildButtonSection(Theme.of(context).primaryColor);

    String textSectionMessage = internalStorage['textSection'];

    Widget textSection = _buildTextSection(textSectionMessage);

    return MaterialApp(
      title: internalStorage['appBarTitle'],
      home: Scaffold(
        appBar: AppBar(
          title: Text(internalStorage['appBarTitle']),
        ),
        body: ListView(
          children: [
            titleImage,
            titleSection,
            buttonSection,
            textSection,
          ],
        ),
      ),
    );
  }

  // Build function to create title image Widget
  dynamic _buildTitleImage(String imageName) {
    return Image.asset(
      imageName,
      width: 600,
      height: 240,
      fit: BoxFit.cover,
    );
  }

  // Build function to create title section Widget
  Container _buildTitleSection(String name, String addr, int count) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Container(
                  padding: const EdgeInsets.only(bottom: 8),
                  child: Text(
                    name,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  addr,
                  style: TextStyle(
                    color: Colors.grey[500],
                  ),
                ),
              ],
            ),
          ),
          Icon(
            Icons.star,
            color: Colors.red[500],
          ),
          Text('$count'),
        ],
      ),
    );
  }

  // Build function to create button section Widget
  Container _buildButtonSection(Color color) {
    return Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildButtonColumn(color, Icons.call, '전화'),
          _buildButtonColumn(color, Icons.near_me, '경로'),
          _buildButtonColumn(color, Icons.share, '공유'),
        ],
      ),
    );
  }

  // Build function to create button with icon and sub-title for _buildButtonSection()
  Column _buildButtonColumn(Color color, IconData icon, String label) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }

  // Build function to create text description section Widget
  Container _buildTextSection(String section) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Text(
        section,
        softWrap: true,
      ),
    );
  }
}

사용한 이미지는 아래와 같으며, 경희대학교에서 이미지에 대한 라이센스를 가지고 있으니 참조하기 바랍니다.

[그림] darttutorial-33-01.dart에서 사용하는 lake.jpg 화일

앞서의 실습을 토대로 직접 위와 같이 만들고자 한다면, 문제가 없겠지만, darttutorial-33-01.dart를 copy & paste하여 실행하고자 한다면, Flutter 공식 사이트에 대한 실습에서 설명하는 부분중 다음을 이해해야 합니다. 즉, 프로젝트 폴더의 root 디렉토리에 images 서브 디렉토리를 만든후 (위와 같이 본인이 사용할) lake.jpg 이미지 화일을 이 디렉토리에 복사합니다. 그리고 pubspec.yaml 화일의 flutter: 하단에 assets: 를 활성화 한후, 그 밑에 – images/lake.jpg를 추가합니다. Flutter가 자동 생성한, Start App의 pubspec.yaml 화일 안에 보면, 이렇게 이미지를 추가하는 경우에 대한 설명이 주석으로 되어 있으니 한번 읽어 보기 바랍니다. 만약 프로그램을 실행하는 중에, main.dart의 수정과 이미지 화일의 추가를 하였다면, MS Visual Code의 디버그 단추 중 “다시 고침” 단추를 클릭해야 합니다.

마무리

StatelessWidget은 고정적인 컨텐츠를 표현하기에 사용하므로 정적인 동작을 합니다. 따라서, 동적인 동작을 하는 StatefulWidget을 이해하기 위한 필수적인 단계로 볼 수 있습니다. 특히 다음 글에서는 darttutorial-33-01.dart 프로그램이 저이상 정적이지 않고, 사용자와의 인터액션을 통해서 동적으로 반응하게 만드는 과정에 대해서 설명합니다. 따라서, 최소한 darttutorial-33-01.dart 프로그램을 이해하고 실행해 보도록 합니다.

Creative Commons License (CC BY-NC-ND)

Dart Programmer 되기 [31]

< Flutter 활용하기 – Hello World! >

앞서에서 Dart 언어를 설치한 것처럼, 개발용 컴퓨터에 Flutter를 설치해야 합니다. Flutter 공식 홈페이지로 이동하여, 가이드라인에 따라서 Flutter를 설치하고 환경을 설정하도록 합니다. 본 연재 글을 따라가기 위해서는 공식 홈페이지의 “Get started” 절차에서 다음의 단계는 반드시 읽고 이해한 후, 완료해 주어야 합니다.

  1. Install : Flutter 구동을 위한 개발 환경 설정, Flutter SDK 설치 및 환경 설정, iOS 혹은 Android 개발 환경 설정 (본 글에서는 개발자 환경은 MacOS이며, iOS를 위한 Xcode와 Android를 위한 Android Studio를 모두 설치하였습니다)
  2. Set up an editor : 개발용 Editor 설정 (본 글에서는 Dart에서 사용한 MS Visual Code를 그대로 사용합니다)
  3. Test drive : 개발용 Editor를 사용하여, Flutter가 제공하는 기본 프로그램인 Start App을 실행하고, hot reload를 경험합니다 (본 글에서는 Dart에서 사용한 MS Visual Code를 그대로 사용합니다)

본 글에서는 이제 독자가 MS Visual Code를 사용하여, Flutter를 통한 신규 프로젝트를 생성하고(3. Test drive의 Create the app), 이를 실제 기기(휴대폰, 태블릿 등) 혹은 Emulator를 통해서 실행할 수 있는 것으로 가정하여, 다음의 글을 이어 갑니다. 따라서, 반드시 앞서의 3단계를 수행하기 바랍니다.

이미 앞서의 Get started의 3번에서 Flutter가 제공하는 기본 Start App을 실행해 보았지만, 본 글에서는 보다 단순한 프로그램으로 시작을 하려 합니다. 이를 위해서 Get started의 3번에서 한 것과 같이, 새로운 프로젝트를 만듭니다. Visual Code의 명령 팔레트를 열고, “Flutter: New Project”를 통해서, hello_world 프로젝트를 원하는 장소에 생성합니다. 프로젝트를 생성하면, Visual Code에 HELLO_WORLD 이름의 폴더가 생성되어 열려 있는 것을 확인 할 수 있습니다. Flutter 개발의 시작에 앞서서, 개발 환경에 문제가 없는지를 마지막으로 확인하고자, 앞서 Get started에서도 소개된, “flutter doctor”를 수행하여, 이슈 없음을 확인해 봅니다.

Simple Hello World! Program

새로 만든 hello_world 프로젝트에서 우리가 수정하고자 하는 화일은, 프로그램 수행의 가장 기본이 되는 main.dart 화일 입니다. 이 화일안에 main() 함수가 있습니다. 이 화일은 프로젝트 폴더안의 lib 디렉토리에 있습니다. 이 화일을 열고, 이 안의 내용을 다음의 dart_tutorial-31-01.dart 화일의 내용으로 변경합니다. 참고로 main.dart 화일 내용을 아래 화일의 내용으로 변경하면, test 디렉토리의 widget_test.dart 화일에 오류가 있는 것으로 나타나지만, 일단 무시하도록 합니다.

// darttutorial-31-01.dart

import 'package:flutter/material.dart'; // #1

void main() { // #2
  runApp( // #3
    Center( // #4
      child: Text( // #5
        'Hello, World!', 
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

main.dart 화일의 내용을 수정한 후, 디버그 모드에서 실행합니다. MacOS에서 ios simulator를 사용하여 iPhone 11 Pro Max에서 실행한 결과 화면이 다음과 같습니다.

[그림] Hello World! 프로젝트 실행 화면

프로그램을 한줄 한줄 설명 하면 다음과 같습니다.

#1: Flutter가 제공하는 material 타입의 GUI 패키지를 포함합니다.

#2: Flutter 프로젝트에서 가장 먼저 실행이 되는 main() 구문의 시작입니다.

#3: runApp() 함수는 Flutter의 main() 함수와 같은 존재 입니다. 앞서의 글에서 Flutter는 모든 것이 Widget으로 이루어 진다고 이야기 했는데, 이 함수는 주어진 widget을 GUI 화면에 연결하는 작업을 합니다. 주어진 widget은 전체 화면을 채우는 GUI의 레이아웃에 대한 조건을 제공 합니다. 예를 들어, 만약 widget을 중앙에 위치하고 싶다면, Center widget을 사용하고, 만약 화면의 한쪽에 정렬하고 싶다면 Align widget을 사용하는 식으로 프로그램을 작성합니다. (참조: https://api.flutter.dev/flutter/widgets/runApp.html )

#4: runApp()의 입력 파라메타로 Center widget을 주었으므로, 앞서 #3의 예시에서 설명한 것처럼, GUI 화면의 중앙에 뭔가를 표현 하겠다는 의도입니다. Center class는 본인의 자식(child)들을 화면의 중앙에 의치시켜서 나타냅니다. Flutter에서 제공하는 다양한 Class와 Widget들은 내부에 유용한 method 및 property를 제공합니다. 이들에 대한 세부적인 정보는 https://api.flutter.dev/ 사이트에서 확인 가능합니다. (참조: https://api.flutter.dev/flutter/widgets/Center-class.html )

#5: 앞서 #4에서 중앙에 위치시킨 정보(Center class의 child)가 문자열을 나타내는 Text class 임을 알수 있습니다. Text class도 유용한 method와 property를 제공하는데, 예제의 경우는 문자열 “Hello, World!”를 나타낸다는 것을 알수 있으며, 이의 방향(textDirection)이 “left to right”로 정렬(TextDirection.ltr) 되도록 설정한 것을 알수 있습니다. (참조: https://api.flutter.dev/flutter/widgets/Text-class.html )

Advanced, But Still Simple Hello World! Program

Flutter에서 제공하는 Start App 보다 더 단순한 형태의 Hello World 프로그램을 살펴보았습니다. Flutter를 기반으로하는 프로그램은 이렇게 글자만 나타내고 끝내지는 않으며, 화면을 업데이트하고, 내부에 정보를 저장/관리해야 합니다. 그리고, 앞서 에러가 발생했으나 무시했던 화일인 test/widget_test.dart 안으로 들어가서, 에러가 발생한 부분을 보면, main.dart에 MyApp()이라는 함수가 없기에 에러가 발생한 것으로 나타납니다. 따라서, 이 에러를 제거함과 동시에 darttutorial-31-01.dart 보다 더 많은 기능을 투입해야 제대로 된 가장 간단한 형태의 Hello World 프로그램을 만들수 있는데, 이를 반영하여 darttutorial-31-01.dart를 일부 확장한 프로그램이 darttutorial-31-02.dart 입니다.

// darttutorial-30-02.dart

import 'package:flutter/material.dart';

void main() => runApp(MyApp()); // #1

class MyApp extends StatelessWidget { // #2
  @override
  Widget build(BuildContext context) { // #3
    return Center(
      child: Text(
        'Hello, World!',
        textDirection: TextDirection.ltr,
      ),
    );
  }
}

앞서 main.dart의 내용을 darttutorial-30-02.dart의 내용으로 변경합니다. 이와 함께 widget_test.dart에서 나타났던 에러도 사라지는 것을 알수 있습니다. darttutorial-30-02.dart 프로그램에 대한 설명을 darttutorial-30-01.dart 대비 달라진 부분 중심으로 설명 합니다.

darttutorial-30-02.dart 프로그램의 #2와 같이 MyApp class를 정의하고, #1에서 이를 객체로 만들어서 실행했기 때문입니다. Flutter 프로그램을 만들때, #1과 #2는 매우 전형적인 구문입니다. MyApp class는 StatelessWidget를 기반으로 확장하였습니다. StatelessWidget는 이후의 글에서 다루겠지만, 내부적으로 지속적인 관리를 수행하는 데이타가 없는 경우(does not require mutable state)에 사용하는 class 입니다. Flutter는 지속적인 관리가 필요 없는 경우에, StatelessWidget 안에 필요한 widget들을 포함시켜서 GUI를 만듭니다. (참조: https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html )

darttutorial-30-02.dart 프로그램의 #3의 build method는 StatelessWidget의 내부에 포함된 widget들 중에 변화가 발생하는 wudget(들)이 있을때, 다시 GUI를 업데이트 하는 용도로 호출이 되는 method 입니다. 이에 대한 상세한 설명은 추후 다룰 예정입니다. (참조: https://api.flutter.dev/flutter/widgets/StatelessWidget/build.html ) build method 안을 보면, 앞서의 darttutorial-30-01.dart에서 화면을 채우는데 사용한 Center class가 동일하게 Text class를 포함하되, return 구문에 의하여 만들어지고 전달되는 것을 볼 수 있습니다.

일반적인 Flutter 프로그램은 darttutorial-30-02.dart과 같이 main() 함수에서 runApp 함수를 통하여, MyApp과 같인 StatelessWidget 혹은 추후 설명할 StatefulWidget 클래스를 만들고, 이 안에 GUI로 표시하고 싶은 widget들과 내용을 채우는 형태로 만들어 집니다.

이제 darttutorial-30-02.dart 프로그램을 실행해 봅니다. 결과는 앞서의 darttutorial-30-01.dart 기반의 프로그램과 동일한 것을 확인할 수 있습니다.

Hot-Reload based Debug

앞서 Flutter 공식 홈페이지의 Get-started를 실행하다보면 Hot Reload 라는 기능을 설명하는 부분이 짧게 나타나는 것을 볼 수 있었습니다. 이를 Hello World 프로그램에서 다시 경험하기 위하여, 프로그램이 디버그 모드로 실행중인 상태에서, Visual Code 창의, darttutorial-30-02.dart 기반 main.dart에서 “Hello, World!” 문자열을 “Good-Bye, World!”로 바꾼후 저장하기를 실행하면, 실행중인 프로그램의 문자열이 바뀌는 것을 볼 수 있습니다. 이는 Hot Reload라는 기능으로서, Flutter로 만들어진 프로그램은 내부적으로 직접 화면에 그림을 그리는 형태로 동작하기에 가능합니다.

이와 반대로 다른 모바일 개발 방법 도구들은 모바일 기기의 기능에 화면 업데이트를 의뢰하는 방식이기에, 지금처럼 화면에 나타나는 문자열을 바꾸고자 하면, 디버그 중인 프로그램의 실행을 멈추고, 프로그램 코드 수정후, 다시 빌드한 후, 이를 디버그 모드로 다시 동작해야 합니다. 당연히 이를 위한 시간이 더 필요하게 되는데, Flutter는 디버그 모드로 실행중인 프로그램의 소스 코드를 수정하며, 바로 디버그 중인 프로그램에 반영이 되므로, 디버그를 위한 시간과 노력이 매우 줄어드는 것을 알 수 있습니다. 이는 Flutter의 매우 큰 장점입니다.

Flutter의 Hot Reload가 어떻게 기존의 Native 방식, Web View 방식, Reactive 방식과 다른지에 대해서는 hackernoon의 “What’s Revolutionary about Flutter”에 잘 설명이 되어 있으니, 관심있는 경우는 한번 읽어 보기를 권장합니다. (참조: https://hackernoon.com/whats-revolutionary-about-flutter-946915b09514?ref=morioh.com )

마무리

본 글에서는 Flutter의 Start App 보다 더 단순한 형태의 Hello World! 프로그램의 두가지 버전을 통해서, 가장 기본적으로 Flutter 기반 어플리케이션이 가져야 하는 요소들에 대해서 설명하였습니다. 또한, Flutter의 매우 큰 장점 중 하나인 Hot Reload 방식에 대해서 설명하여, 개발자가 얼마나 개발에 필요한 시간을 줄 일수 있는지를 알아보았습니다.

Creative Commons License (CC BY-NC-ND)

Dart Programmer 되기 [32]

< Flutter 활용하기 – Understanding Widgets >

이제 Flutter에서 새롭게 프로젝트를 만드는 경우에 자동으로 만들어지는 Start App에 대해서 알아보도록 하겠습니다.

Flutter Start App의 이해

아래의 darttutorial-32-01.dart 프로그램이 Start App 프로그램에서 주석문(comment)만 제거한 프로그램 입니다.

[프로그램] darttutorial-32-01.dart (Flutter Start App)

3~9번 라인은 앞서 글의 내용과 동일합니다.

10번 라인에서 MaterialApp class의 객체를 만들어서 return 하는 것을 볼수 있습니다. Material App은 Android 운영체제를 위한 Material 디자인을 지원하는 App을 의미합니다. (참조: https://api.flutter.dev/flutter/material/MaterialApp-class.html )

MaterialApp class에서 지원하는 property중 title은 사용자에게 App을 식별하기 위한 한줄의 표현 입니다. 이 경우 “Flutter Demo”라는 문자열 입니다. 이 프로그램이 디바이스에 설치되면 이 이름이 앱의 이름으로 사용됩니다. theme는 해당 App의 material widget(들)에 대한 시각적 속성을 표현합니다. 이때 primarySwatch는 MaterialColor라고 하며, material app가 사용한 색의 different shade를 정의합니다. 이 경우 파랑색(Colors.blue)임을 알수 있습니다.

이를 확인하기 위해서 앞서에서 처럼 새로운 프로젝트를 만들고, Start App을 바로 실행하면, 기본 설정인 파랑색으로 배경이 채워지는 것을 아래의 실행화면 [그림 0]와 같이 볼 수 있습니다. (이를 확인하기 위하여, 이 프로그램을 실행합니다. 그리고 Colors.blue를 Colors.red로 바꿔서 App의 상단부 색깔이 바뀌는 것을 확인해 봅니다) home은 App의 default route(기본 페이지 화면)로 사용할 widget을 나타내며, 20번 라인을 보면, StatefulWidget을 확장한 MyHomePage의 객체임을 알 수 있습니다. 이의 이름은 ‘Flutter Demo Home Page’으로 정의하는 것을 볼 수 있습니다. 결국 이 프로그램의 주요 동작과 모양은 MyHomePage class에서 정의되는 것을 알수 있습니다.

[그림 0] Start App (darttutorial-32-01.dart) 실행 화면

MyHomePage class는 20~26번 라인에서 정의합니다. MyHomePage는 StatefulWidget을 확장하는데, 이는 mutable한 값을 저장하고 관리하는 경우에 사용하는 class 입니다. 22번 라인에서 title 변수가 있고, 15번 라인에서 title을 ‘Flutter Demo Home Page’의 문자열로 전달 받은후, 21번 라인에서 처럼, title 변수에 이 값을 저장하는 것을 볼 수 있습니다. MyHomePage class는 createState() method가 있으며, 이를 통해서 MyHomePageState class의 객체가 만들어 지는 것을 알 수 있습니다. 그리고 이 class 안에서는, StatefulWidget이 다룰 State class를 생성하는 createState()가 호출됩니다. 따라서 실질적인 프로그램의 작업은 mutable한 정보를 저장/관리하는 MyHomePageState class안에서 이루어 지게 됩니다.

여기까지의 내용을 단계적으로 표현하면 1) Dart의 main()이 실행 됩니다. 2) Flutter의 main()에 해당하는 runApp()을 실행합니다. 3) 프로그램을 디바이스에 등록하고, 기본적인 GUI 환경을 설정하는 (StatelessWidget를 확장한) MyApp class를 만들고, build 함수를 override 하여, 화면이 나타나도록 합니다. 이 경우 MaterialApp 기반의 GUI 디자인을 사용합니다. 4) mutable한 정보를 다룰 필요가 있는 경우 StatefulWidget인 MyHomePage class를 만들어서 3)의 widget에 포함하여 생성하도록 합니다. 그리고 createState()를 override해서 mutable한 정보를 저장하고 관리하는 State<> class를 생성하도록 합니다. 5) StatefulWidget이 생성/관리할 정보를 다룰 class를 State<> class를 확장하여 MyHomePageState class로 생성합니다.

이제 마지막으로 남은, MyHomePageState class안의 설명을 합니다. 이에 대해서는 화면에 나타난 GUI와 기능을 설명하는 방식으로 하겠습니다. 먼저, 앞서의 그림은 3단으로 나뉩니다. 맨위의 파랑 바탕에 타이틀인 ‘Flutter Demo Home Page’이 있는 부분, 중간에 “You have pushed the button this many times:”과 숫자”0″이 있는 부분, 그리로 하단의 “+” 기호가 있는 둥근 원 모양의 단추 입니다. 이런 스타일의 GUI Layout은 39번 라인에서 만들어지는 Scaffold widget을 통해서 만들어 집니다. Scaffold widget은 Material 디자인의 시각적 레이아웃 구조를 나타내며, drawers, snack bars 그리고 bottom sheets 등의 다양한 요소를 포함할 수 있습니다. (참조: https://api.flutter.dev/flutter/material/Scaffold-class.html )

MyHomePageState class의 Scarffold class는 화면의 상단 부분을 표현하기 위해서 AppBar 클래스 타입으로 Scarffold의 appBar property를 채웁니다. AppBar는 MyApp class의 생성자에 부여한 title로 초기화 됩니다. 중간 부분은 앞서의 글에서도 등장했었던 Center class로 채워집니다. mainAxisAlignment를MainAxisAlignment.center로 하여 화면의 중간에 위치하도록 하였으며, Text class 두개를 Widget list에 담아서 Center class의 children property를 채웁니다. 이를 통해서 Center class에 두개의 문자열이 있도록 하는 것 입니다. 마지막으로 하단의 버튼은 FloatingActionButton class를 만들어서 Center class의 floatingActionButton을 채워서 만듭니다. 이 class는 child property를 통해서 더하기 기호에 해당하는 Icons.add 아이콘을 가지며, 클릭이 되면(onPressed) incrementCounter method가 호출되도록 합니다. 그렇다면 이 method는 어디 있나요? MyHomePageState class의 처음 부분으로 올라가면, 이 method가 만들어져 있습니다. 실행이 되면, setState()를 실행하는데, 입력 파라메타로 이름이 없는 함수를 주며, 이 함수가 counter 값을 증가시키고, 이 변수는 MyHomePageState class 안의 property로 29번 라인에서 만들어져 있음을 알 수 있습니다. 따라서 화면의 하단 단추를 누르면, counter 변수 값을 증가시키게 되는 것을 알 수 있습니다. 그렇다면, 이렇게 증가된 값이 어떻게 Center class안의 children 안의 두번째 Text를 통해서 화면에 갱신되어 출력이 되는 걸까요? 이에 대한 해답을 찾기 위해서 다소 복잡하지만 반드시 알아야 하는 “build()와 setState()의 상관 관계를 이해”라는 여정을 떠나야 합니다.

Deep Dive into Flutter Widget Operation

Stateless Widget은 immutable한 정보를 갖는 Widget 이지만, Stateful Widget은 State 정보를 가지고 mutable하게 관리 합니다. 이에 대해서, 간단하게 [그림 1]에 도식화 되어 있습니다. 만약 그래픽 인터페이스와 연계한다면, Stateless Widget은 절대로 바뀌지 않는 그래픽 인터페이스이며, Stateful Widget은 내부 정보에 따라서 그래픽이 달라지는 형태라도 볼 수 있습니다.

Stateless Widget은 여러 widget 들을 모아서 사용자 인터페이스를 구성할 때 주로 사용합니다. 이 경우 build()의 진행은 회귀적으로(recursively) 모든 사항이 concrete한 수준이 될 때까지 진행되는데, 즉 concrete하게 모든 widget들이 RederObjects/RenderObjectWidgets가 될 때까지 진행합니다. 따라서 Stateless Widget은 사용자 인터페이스를 구성하는 경우, 오로지 고정된 configuration 정보 만으로 구성하는 경우들에 대해서 유용합니다.

Stateful Widget은 반대의 경우로 사용자 인터페이스를 구성하는 widget 들의 집합/위치/형태 등이 동적이고, 상시 변할 수 있는 경우에 유용합니다. 시간이나 이벤트에 따라서 상태가 변하는 경우들에서 유용합니다. 그리고 동적으로 변하는 속성상, Widget이 build 되고 있을 때에서는 동기적으로 데이터를 읽을 수 없습니다.

[그림 1] Stateless Widget과 Stateful Widger의 개념
(출처) https://medium.com/@rkishan516/flutter-book-stateless-and-stateful-widgets-8db946f45df5

StatelessWidget을 extends한 Stateless Widget Class 객체는, constructor를 통해서 전달 받은 정보를 final 멤버 변수로 저장합니다. 이 정보들은 build()를 실행하면서 사용합니다. Stateless Widget의 멤버 변수를 바꿀 필요가 있다면, Stateless Widget은 이를 직접 바꾸지 않고, (즉, Widget 내부의 멤버 변수의 값을 바꾸는 것이 아니고, 대부분의 경우) parent widget으로 부터 전달 받은 함수를 호출하게 됩니다. 예를 들어, Stateless Widget이 flag 값을 가지고 있고, true/false 이라면, parent 함수를 호출해서, flag 값이 바뀐 새로운 Stateless Widget을 만들어서 parent에 등록 하는 형태입니다. 이렇게 하여, 프로그램에서 관리해야 하는 정보가 Stateless Widget를 떠나서 parent widget 레벨에서 관리가 됩니다. 이런 방식으로 (Stateless Widget의 수명이 아닌) 프로그램에서 필요로 하는 기간 동안 정보를 관리하는 것이 가능합니다. 극단적으로 프로그램의 수행부터 종료까지 정보를 관리하는 것도 가능합니다.

앞서의 예처럼, Stateless Widget이 parent에서 전달한 call back 함수를 호출하면, parent는 자신의 내부 상태를 업데이트하고, 새로운 Stateless Widget을 (변경한 정보를 갖는 상태로) rebuild/create 합니다. 새로운 Stateless Widget들을 (상태 변경이 필요할 때마다) parent가 새롭게 생성하는 부분에서 성능 저하를 우려할 수 있습니다. 이와 관련해서, Flutter는 새롭게 만드는 Widget과 기존의 Widget을 비교하여, 대부분은 재사용을 하고, 단지 차이가 있는 부분을 RenderObject에서 처리하여, 성능 상의 부하를 줄이고 있습니다.

Flutter에서 widget 들은 tree 형태로 관리 됩니다. 예를 들어, Flutter 공식 사이트의 예제를 기반으로 설명할때, 다음과 같은 그래픽 인터페이스를 만든다고 가정해 봅니다. (출처: https://flutter.dev/docs/development/ui/layout)

Flutter에서는 위의 구조를 프로그래밍 할 때 다음의 그림처럼 여러 구성 요소의 집합으로 구현할 수 있습니다.

즉, 3개의 icon과 이들 각각에 대한 글자(text)가 보입니다. 따라서 icon과 text를 묶는 논리적인 그룹을 생각할 수 있는데, 이들은 위/아래의 수직적인 그룹(column)입니다. 이 그룹이 3개 수평적으로 그룹(row)을 만들고 있습니다. 그리고 이 그룹은 하나의 큰 박스(container) 안에 담아서 처리합니다. 이를 tree 형태로 도식화 하면 다음과 같습니다. Row, Column, Icon, Text는 직관적으로 이해가 가능하지만, Container는 일부 설명이 필요해 보입니다. Container는 내부에 포함하는 child widget들을 customize하는 용도로 사용합니다. 즉, padding, margin, border, background color, icon color, text style 등을 조정하기 위한 목적으로 사용합니다. 따라서 아래의 tree 구조를 살펴보면, Row와 Text에 대한 customization을 위해서 Container를 사용하여 감싸는 것을 유추할 수 있습니다.

[출처] https://flutter.dev/docs/development/ui/layout

StatefulWidget을 extends한 Stateful Widget Class 객체는 mutable state를 저장합니다. 이런 Stateful Widget이 tree안에 삽입되면, Flutter framework는 createState() 함수를 호출하여 Stateful Widget 객체가 관리할 State Class 객체들을 새롭게 만들고, tree에 이들의 위치 정보도 함께 저장합니다. State Class를 extends한 서브 클래스 들은 private 속성을 가지기에, 이름을 “_”로 시작합니다. 만약 Stateful Widget의 parent가 rebuild를 실시하면, parent는 하위 체계의 Stateful Widget의 객체를 새롭게 만듭니다. 하지만 Flutter framework는 기존의 Stateful Widget 객체가 이미 저장하고 있던 State Class 객체를 재사용하여, Stateful Widget Class의 createState() 함수가 다시 호출되는 것을 지양합니다.

Stateful Widget이 관리하는 State Class 객체에서, Stateful Widget의 property에 접근하고자 한다면, State Class 객체는 자신의 widget Property를 사용할 수 있습니다. 만약 Stateful Widget의 parent가 Stateful Widget 객체를 rebuild하고 새롭게 만들고자 한다면, Stateful Widget이 관리하는 State Class 객체는 자신의 widget Property를 새롭게 만들어진 widget Property로 rebuild 됩니다. 이런 경우, 개발자가 widget Property의 변경 여부를 직접 받아보고 싶다면, didUpdateWidget() 멤버 메소드를 override 합니다. 이 메소드를 사용하면, 현재의 widget과 과거의 widget을 비교할 수 있습니다.

State Class 객체의 상태를 변경하는 함수 혹은 call back 함수가 실행되면, Flutter framework에 내부 상태가 변경 되었음을 알리기 위해서, setStaste() 함수에 필요한 작업을 작성하여 실행합니다. setStaste() 함수를 호출한다는 의미는, Flutter framework에게 상태 변화가 있음을 알려줘서, 예정된 다음 차례의 프로그램 화면 업데이트 시점에서, 반영해 달라(build() 함수를 호출해 달라)는 의미입니다. setStaste() 함수 호출을 개발자가 누락하면, Flutter framework는 상태의 변화를 알지 못하게 되며, build() 함수 호출을 하지 않게 됩니다. 이렇게 state를 관리 함으로써, Widget의 복잡한 tree 체계를 개발자가 모두 이해해서, child widget들의 생성과 업데이트를 일일이 처리하지 않아도 됩니다. 단지 개발자는 build() 함수를 만들면, 이 모든 사항을 Flutter framework가 처리합니다.

StatefulWidget에서 createState ()를 호출 한 후, Flutter 프레임 워크는 새로 만든 State 객체를 트리에 삽입 한 다음 상태 객체에서 initState()를 호출합니다. State의 서브 클래스는 initState()를 override하여 한 번만 수행해야 하는 작업을 수행 할 수 있습니다. 예를 들어 애니메이션을 구성하거나 플랫폼 서비스를 구독하려면 initState()를 override 합니다. 이 경우에는, override하는 initState() 함수의 시작 부분에서 super.initState()를 호출하여야 하여야 합니다.

State 객체가 더 이상 필요하지 않으면 Flutter 프레임 워크가 해당 State 객체의 dispose()를 호출합니다. 필요한 정리 작업이 있다면, dispose()를 override 하도록 합니다. 예를 들어 타이머를 취소하거나 플랫폼 서비스의 구독을 취소하려면 dispose()를 override 합니다. dispose()의 구현은 일반적으로 super.dispose()를 함수의 마지막 부분에서 호출하여 종료합니다.

StatefulWidget의 life cycle과 함수들의 관계를 그림으로 설명하면 다음의 [그림 2]와 같습니다.

[그림 2] Stateful Widget과 State의 상관 관계
(출처: https://livebook.manning.com/book/flutter-in-action/chapter-8/v-9/37)

StatefulWidget의 life cycle에 연관된 함수들을 간단히 설명하면 다음과 같습니다. (참조: https://stackoverflow.com/questions/47501710/what-is-the-relation-between-stateful-and-stateless-widgets-in-flutter/49377090#49377090)

createState()는 Flutter가 StatefulWidget을 만들 때 호출됩니다. 이 함수는 tree안의 주어진 위치에 StatefuleWidget을 위한 mutable한 state를 생성합니다. 서브 클래스는 반드시, 연관된 State 서브 클래스를 기반으로 해서, 새롭게 만들어진 객체를 리턴 하도록 이 함수를 override 해야 합니다.

@override
_MyState createState() => _MyState();

initState()는 widget이 생성될 때, constructor 다음으로, 가장 먼저 호출되는 메소드 입니다. 이 함수는 반드시 한번만 실행되며, super.initState() 함수를 실행해야 합니다. 생성하는 widget 객체에 대한 특정 BuildContext에 연관된 데이터를 초기화 합니다. 그리고 tree 구조에서 (생성하는 widget 객체의) parent widget들에 연관된 property들도 초기화 합니다. 마지막으로 ChangeNotifiers 스트림에 가입하거나 혹은 생성하는 widget의 데이터를 변경할 수 있는 다른 객체에 가입합니다.

@override
 initState() {
   super.initState();
   // Add listeners to this class
   cartItemStream.listen((data) {
     _updateWidget(data);
   });
 }

build()는 widget에 의하여 표현되는 사용자 인터페이스 부분을 표현합니다. Flutter framework는 다음의 같은 상황에서 이 함수를 호출합니다. 1) initState() 함수를 호출한 다음, 2) didUpdateWidget() 함수를 호출한 다음, 3) setState() 함수의 호출을 요청 받은 경우, 4) 이 State 객체의 dependency가 변경되는 경우 (즉, 이전의 build에 의해서 참조하는 InheritedWidget이 변경되는 경우), 5) Deactivate를 호출한 후, State 객체를 tree 구조의 다른 위치로 재 삽입한 경우 입니다. Flutter framework는 이 widget 하단의 subtree를 build() 메소드의 리턴 값으로 교체하거나, 기존 subtree를 업데이트 하거나, 혹은 subtree를 삭제하고 새로운 subtree로 대체합니다. 이때 build()가 리턴하는 widget이 기존 subtree의 root를 업데이트 할지 여부를 결정하는 인자가 되며, 이에 대한 정보는 Widget.canUpdate를 호출하여 알 수 있습니다. 일반적으로 build()의 implementation들은, a) widget의 constructor에서 받은 정보, b) 주어진 BuildContext 그리고 c) State 객체의 내부 상태 정보를 기반으로 구성된 ‘widget들의 집합으로 만들어진 객체’를 리턴 합니다.

@override
Widget build(BuildContext context, MyButtonState state) {
  … () { print("color: $color"); } …
}

setState()에는 State 객체의 내부 상태가 변경될 때 마다, 반영해야 하는 사항을 구현 합니다. setState()를 호출한다는 것은 Flutter framework에게 이 객체의 내부 상태가 바뀌었다는 것을 알려주는 것으로써, 이 subtree안의 사용자 인터페이스에 영향이 있을 수 있다는 의미입니다. 따라서, Flutter framework는 다음 업데이트 시점에서 이 State 객체에 대한 build를 수행하게 됩니다.

setState(() { _myState = newValue });

didUpdateWidget()는 widget의 구성(configuration)이 바뀔 때 마다 호출됩니다. 만약 parent widget이 rebuild를 수행하고 tree 업데이트를 요청해서, 이 위치에 있는 (동일 runtime type과 Widget.key를 갖는) 새로운 widget을 디스플레이 하도록 요청하면, Flutter framework는 State 객체의 widget property가 새로운 widget을 참조하도록 업데이트 한 후, 기존의 widget을 argument로 해서 didUpdateWidget() 메소드를 호출합니다. 따라서, widget의 변경에 대해서 반응하고 싶다면 이 메소드를 override 합니다. Flutter framework는 didUpdateWidget() 메소드를 호출한 후에는 항상 build를 실행 합니다. 따라서, didUpdateWidget() 메소드에서의 setState()는 필요하지 않습니다.

@mustCallSuper
@protected
void didUpdateWidget(covariant T oldWidget) { }

dispose()는 tree에서 객체가 제거될 때 호출됩니다. Flutter framework는 State 객체가 다시는 build 되지 않는 다고 판단되면 이 메소드를 호출합니다. 이 메소드를 실행하면, State 객체는 unmout되고 mounted Property는 flase 값을 가집니다. 이후로 이 객체에 대한 setState()는 에러를 유발합니다. 한번 dispose된 객체는 다시 사용(mount)할 수 없습니다. 서브 클래스에서는 점유했던 자원을 해제하거나, 활성화한 애니메이션을 중단하는 등의 작업을 수행합니다.

@protected
@mustCallSuper
void dispose() {
  assert(_debugLifecycleState == _StateLifecycle.ready);
  assert(() { _debugLifecycleState = _StateLifecycle.defunct; return true; }());
}

Flutter 사이트에서 제시하는 Stateless Widget의 예제가 아래의 Frog Class로 입니다. Frog 클래스는 내부적으로 color와 child 멤버 변수를 final 타입으로 가집니다. 그리고, contructor를 통해서 이들의 값과, Widget의 key 값을 받습니다. 그리고 build 함수를 통해서 Container 형태에 멤버 변수를 표현하도록 하고 있습니다.

// Reference: https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
 class Frog extends StatelessWidget {
   const Frog({
     Key key,
     this.color = const Color(0xFF2DBD3A),
     this.child,
   }) : super(key: key);
 final Color color;
   final Widget child;
 @override
   Widget build(BuildContext context) {
     return Container(color: color, child: child);
   }
 }

Flutter 사이트에서 제시하는 Stateful Widget의 예제가 아래의 Bird Class 입니다. Stateful Widget도 immutable 하므로 final 타입의 color와 child 멤버 변수를 가집니다. 그리고 State를 보관하기 위하여 createState() 멤소드를 가집니다. 그리고 이 메소드는 State의 서브 클래스인 BirdState 객체를 리턴하며, private 타입이기에 “_”로 이름이 시작합니다. _BirdState 클래스는 state 정보로 _size를 가집니다. 이를 증가하는 grow() 메서드를 가지고 있으며, build() 메소드를 통해서, Container 타입으로 color와 child, 그리고 size를 기반으로 하는 값을 표현합니다.

// Reference: https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html

 class Bird extends StatefulWidget {
   const Bird({
     Key key,
     this.color = const Color(0xFFFFE306),
     this.child,
   }) : super(key: key);
 final Color color;
   final Widget child;
 _BirdState createState() => _BirdState();
 }

 class _BirdState extends State {
   double _size = 1.0;
 void grow() {
     setState(() { _size += 0.1; });
   }
 @override
   Widget build(BuildContext context) {
     return Container(
       color: widget.color,
       transform: Matrix4.diagonal3Values(_size, _size, 1.0),
       child: widget.child,
     );
   }
 }

Flutter 공식 사이트에서 보다 자세한 설명을 얻을 수 있습니다. 다음은 앞서 설명한 클래스들과 주요 메소드에 대한 공식 사이트 링크들이니, 필요한 경우 참조하기 바랍니다.

  • StatelessWidget Class : https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html
  • StatefulWidget Class : https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html
  • State Class : https://api.flutter.dev/flutter/widgets/State-class.html
  • build method in State Class : https://api.flutter.dev/flutter/widgets/State/build.html
  • Comments for Android : https://flutter.dev/docs/get-started/flutter-for/android-devs
  • Comments for iOS : https://flutter.dev/docs/get-started/flutter-for/ios-devs

Sample Programs

앞서 설명한 내용의 이해를 토대로 Flutter 사이트에서 제공하는 몇개의 sample 프로그램을 이해하는 과정을 거쳤으면 합니다. 본인이 무언가를 만들기 전, 다른 이가 만든 예제를 읽어서 모방하는 것은 매우 중요한 과정이니, 앞서의 내용에 대한 이래를 토대로 다음의 Flutter 예제들을 하나 하나 읽어보고, 직접 실행해서 이래가 맞는지 확인해 보기 바랍니다.

Sample Program 1 – Code & Screen Shot

// darttutorial-32-02.dart : Flutter HelloWorld using own widgets 
// Reference:https://flutter.dev/docs/development/ui/widgets-intro#basic-widgets
 import 'package:flutter/material.dart';
 void main() => runApp(MyApp());
 class MyAppBar extends StatelessWidget {
   MyAppBar({this.title});
 final Widget title;
 @override
   Widget build(BuildContext context) {
     return Container(
       height: 56.0, 
       padding: const EdgeInsets.symmetric(horizontal: 8.0),
       decoration: BoxDecoration(color: Colors.blue[500]),
       child: Row(
         children: [
           IconButton(
             icon: Icon(Icons.menu),
             tooltip: 'Navigation menu',
             onPressed: null, 
           ),
           Expanded(
             child: title,
           ),
           IconButton(
             icon: Icon(Icons.search),
             tooltip: 'Search',
             onPressed: null,
           ),
         ],
       ),
     );
   }
 }
 class MyScaffold extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Material(
       child: Column(
         children: [
           MyAppBar(
             title: Text(
               'Example title',
               style: Theme.of(context).primaryTextTheme.title,
             ),
           ),
           Expanded(
             child: Center(
               child: Text('Hello, world!'),
             ),
           ),
         ],
       ),
     );
   }
 }
 class MyApp extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return MaterialApp(
       title: 'My app',
       home: MyScaffold(),
     );
   }
 }
[그림] darttutorial-32-02.dart 실행 화면

Sample Program 2 – Code & Screen Shot

// darttutorial-32-03.dart : Flutter HelloWorld using baseline widgets 
// Reference: https://flutter.dev/docs/development/ui/widgets-intro#using-material-components
 import 'package:flutter/material.dart';
 void main() => runApp(MyApp());
 class MyApp extends StatelessWidget {
   @override 
   Widget build(BuildContext context) {
     return MaterialApp(
       title: 'darttutorial-32-03.dart',
       home: MobileAppHome(),
     );
   }
 }
 class MobileAppHome extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     // Scaffold is a layout for the major Material Components.
     return Scaffold(
       appBar: AppBar(
         leading: IconButton(
           icon: Icon(Icons.menu),
           tooltip: 'Navigation menu',
           onPressed: null,
         ),
         title: Text('Example title'),
         actions: [
           IconButton(
             icon: Icon(Icons.search),
             tooltip: 'Search',
             onPressed: null,
           ),
         ],
       ),
       // body is the majority of the screen.
       body: Center(
         child: Text('Hello, world!'),
       ),
       floatingActionButton: FloatingActionButton(
         tooltip: 'Add', // used by assistive technologies
         child: Icon(Icons.add),
         onPressed: null,
       ),
     );
   }
 }
[그림] darttutorial-32-03.dart 실행 화면

Sample Program 3 – Code & Screen Shot

// darttutorial-32-04.dart : Product order program
// Reference: https://flutter.dev/docs/development/ui/widgets-intro#bringing-it-all-together

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shopping App',
      home: ShoppingList(
        products: <Product>[
          Product(name: 'Eggs'),
          Product(name: 'Flour'),
          Product(name: 'Chocolate chips'),
        ],
      ),
    );
  }
}

class Product {
  const Product({this.name});
  final String name;
}

typedef void CartChangedCallback(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({this.product, this.inCart, this.onCartChanged})
      : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    return inCart ? Colors.black54 : Theme.of(context).primaryColor;
  }

  TextStyle _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(product.name, style: _getTextStyle(context)),
    );
  }
}

class ShoppingList extends StatefulWidget {
  ShoppingList({Key key, this.products}) : super(key: key);

  final List<Product> products;

  @override
  _ShoppingListState createState() => _ShoppingListState();
}

class _ShoppingListState extends State<ShoppingList> {
  Set<Product> _shoppingCart = Set<Product>();

  void _handleCartChanged(Product product, bool inCart) {
    setState(() {
      if (!inCart)
        _shoppingCart.add(product);
      else
        _shoppingCart.remove(product);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Shopping List'),
      ),
      body: ListView(
        padding: EdgeInsets.symmetric(vertical: 8.0),
        children: widget.products.map((Product product) {
          return ShoppingListItem(
            product: product,
            inCart: _shoppingCart.contains(product),
            onCartChanged: _handleCartChanged,
          );
        }).toList(),
      ),
    );
  }
}

[그림] darttutorial-32-04.dart 실행 화면

마무리

다소 복잡한 내용에 대해서 배웠습니다. 하지만 이 글에서 다룬 내용은 Flutter의 GUI가 동작하는 부분에서 주요한 사항이니 당장은 이해가 안가더라도 틈틈히 읽어서 이해를 넓혀갔으면 좋겠습니다. 아울러 흔히 Flutter를 설명할 때, “Widget is Everything”이라고 하는데, 다양한 Widger을 보고 경험하여 응용한다면, 많은 공을 들이지 않더라도 제법 모양을 갖춘 프로그램의 개발이 가능할 것 입니다.

Creative Commons License (CC BY-NC-ND)

Dart Programmer 되기 [30]

< Flutter 활용하기 – Ice Break & References >

이제 Flutter에 대해서 알아보고자 합니다. Flutter는 홈페이지 메인 페이지에 다음과 같이 소개하고 있습니다. “Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobileweb, and desktop from a single codebase.” Flutter는 문장 그대로 구글이 만든 사용자 인터페이스 프레임 워크입니다. JavaScript의 경우 모바일/웹/데스크탑에 대해서 서로 다른 프레임워크를 사용해야 하지만, Flutter와 Dart를 사용하면, 동일한 기술로 서로 다른 플랫폼을 지원할 수 있습니다. 또한, 모바일은 Android와 iOS를 함께 지원합니다. JavaScript의 경우는 Cordova/PhoneGap 등으로 모바일 앱을 만들때, HTML/CSS 등의 웹기술에 대한 이해가 GUI를 구성하기 위해서 필수적인 지식이지만, Flutter는 그렇지 않습니다. 또한 구글이 새롭게 만드는 운영체제인 Google Fuchsia의 개발도구에 Dart/Flutter가 전성되어 활용 가능성이 큰 것으로 기대되고 있습니다. 사실 Dart가 배우지 말아야 하는 언어의 상위권이였다가, 프로그래머가 배우고 싶은 언어로 등극하게된 계기가 Flutter라고 해도 과언은 아닙니다.

Flutter 기술을 설명하기 전에, Flutter에 대한 자료들을 찾아 볼 곳을 먼저 리스트업하고자 합니다. Flutter의 경우는 지지자들의 확산에 힘입어 매우 많은 온라인 정보들을 찾아볼 수 있습니다. Flutter에 대한 본격적인 설명을 하기 전에, 참조 할 만한 사이트들을 먼저 언급하겠습니다. 특히 Flutter는 “Widget is Everything!”이라는 말이 있을 정도로 Widget 종류를 알고 활용하는 것이 매우 중요한데, 이들에 대해 정리한 문서들, 예제를 설명한 동영상, 실제 예제를 구현하여 Play Store에 등록한 무료 프로그램들이 많이 있습니다.

Official Flutter Site

Official Video for Flutter Release

Flutter Getting Started

Flutter Widgets

Flutter References

AngularDart

HTTP Server Programming for Dart

Why Flutter?

Flutter Supporting Tools

Google and Android developers

마무리

Javascript를 사용하여 front-end를 개발해 보았다면, HTML/CSS와 같은 웹기술에 대한 설명이 언제 즈음 나올지에 대해서 궁금해 할 겁니다. Flutter는 기본적으로 정해진 template를 Logo 블럭 끼우듯이 만드는 구조 입니다. 특히 Flutter에서 사용할 수 있는 Widget들에 대해서 설명하는 이미지와 소스코드 외에도, 실제 동작과 소스코드를 설명하는 동영상, 예제들을 묶어서 만든 프로그램을 앱 스토어에서 무료로 다운받아 실제 동작을 활용할 수 있다는 점, 그리고 이들의 소스코드를 오픈소스로 누구나 볼 수 있는 생태계(ecosystem)은 다른 언어들과 비교해서 매우 독보적이라고 볼 수 있습니다. 이제 Flutter에 대한 구체적인 설명을 시작해 보려 합니다.

Creative Commons License (CC BY-NC-ND)