Dart Programmer 되기 [41]

< Web 개발 – Flutter for Web : Login UI Example >

지금까지 Flutter for Web를 개발하기 위하여, 기술의 연혁과 도구 설치, 그리고 간단한 예제 프로그램을 실행하고 이해하여 보았습니다. 앞서 dart2js 등에서 본 것처럼, Flutter for Web은 HTML/CSS/JavaScript와 같은 표준 웹 기술을 사용하여 Web 콘텐츠를 개발하도록 지원 함으로써, Dart로 만든 프로그램이 어떠한 Web 브라우저 상에서도 동작하도록 합니다.

Flutter for Web은 Dart 언어로 만들어진 프로그램이 JavaScript로 변환되는 단계에서 더 나아가서, Flutter 프레임워크가 제공하는 다양한 core drawing layer가 기존 Web 브라우저 위에서 동작 할 수 있도록, [그림 1]과 같이, Dart/Flutter Framework를 표준 Web 브라우저 API 상에서 구현하는 작업을 수행하였습니다.

[그림 1] Flutter for Web 기본 개념 (출처: https://flutter.dev/web )

앞서의 글에서, 현재 Flutter for Web이 기존 Web 기술로 만들어진 Web 사이트들을 모두 바꾸려 하기 보다는 인터액티브하고 동적인 콘텐츠를 표현하는 Web 어플리케이션을 목표로 한다고 하였습니다. Flutter for Web 공식 사이트( https://flutter.dev/web )에는 이에 대해서 다음의 세가지 시나리오를 예를 들어서 설명하고 있습니다.

A connected Progressive Web Application built with Flutter : Flutter로 만들어진 기존의 모바일 어플리케이션을 보다 다양한 device들에 적용하거나, 아니면 Web 상에서도 같은 경험을 제공하려는 경우

Embedded Interactive content : 기존 Web 페이지 안에서도 쉽게 운용이 가능한 rich & data-centric 기능을 적용하는 경우로서, 예를 들어 데이터 시각화, 자동차 구성 요소 설정과 같은 온라인 도구, embedded된 형태의 차트 등의 embedded web content를 개발하는 경우

Embedding dynamic content in a Flutter mobile app : 기존 모바일 애플리케이션 내에서 동적 컨텐츠를 업데이트 하기 위해서 사용하는 일반적인 방법은 동적으로 컨텐츠를 로드 할 수 있는 Web view control 이였지만, Flutter는 웹 및 모바일 컨텐츠를 위한 통합 환경을 제공하므로, 컨텐츠를 플랫폼에 따라서 다시 작성하지 않고도 앱에 내장 할 수 있음

따라서, Flutter for Web의 사용에는, 기존의 모든 Web 기술을 대체하겠다는 접근 보다는, 목적에 맞는 사용이 필요합니다. 예를 들어 블로그 기사와 같은, 텍스트 중심의 정적인 컨텐츠를 제공하는 경우는 기존 Web 기반 기술을 활용하고, app-centric한 서비스에서 interactive하고 data-centric한 UI 프레임워크가 동적으로 동작하는 것이 필요한 경우가 Flutter for Web을 도입하기 좋은 환경인 것으로 권장하고 있습니다.

이제, Web 환경을 고려한 Flutter 기반 첫번째 어플리케이션을 만들어 보겠습니다. 주 내용은 Flutter 공식 사이트( https://flutter.dev/docs/get-started/codelab-web )의 “Write your first Flutter app on the web”을 기반으로 진행하고자 합니다. 동작은 단순 합니다. [그림 2]와 같은 로그인 화면을 통해서 사용자로부터 이름과 사용자 아이디를 입력 받은 후, 환영 인사 문구를 화면에 보여주는 정도입니다.

[그림 2] First Flutter app in the web
(출처: https://flutter.dev/docs/get-started/codelab-web )

좀 더 세부적으로 기능을 살펴보면, 화면에는 이름, 성 및 사용자 이름의 세 가지 텍스트 필드가 있습니다. 사용자가 필드를 채우면 진행률 표시 줄이 로그인 영역의 상단을 따라 움직입니다. 세 개의 필드가 모두 채워지면 진행률 표시 줄이 로그인 영역의 전체 너비를 따라 녹색으로 표시되고 가입 버튼이 활성화됩니다. 가입 버튼을 클릭하면 시작 화면이 화면 하단에서 애니메이션으로 표시됩니다.

실행에 앞서서 flutter doctor를 실행해서, 개발 환경에 문제가 없는지 다시 한번 확인하고, flutter devices를 통해서 Chrome과 Web Server가 Flutter for Web의 실행을 위해서 연결되어 있는지 확인 합니다.

Step.0 New Flutter Project with Text Input Fields

Flutter for Web을 경험할 새로운 Flutter 프로젝트를 생성합니다. 이를 위해서 본인이 작업할 디렉토리로 이동하여 flutter create 명령을 실행합니다. 아래는 myapp 이라는 이름으로 Flutter 프로젝트를 생성한 경우 입니다.

flutter create myapp

다음은 Flutter 공식 홈페이지에서 가져온 초기 단계 코드인 darttutorial-41-main-step-0.dart 프로그램으로 myapp 프로젝트의 main.dart( myapp/lib/main.dart ) 프로그램을 대체 합니다.

// Source: https://flutter.dev/docs/get-started/codelab-web
// darttutorial-41-main-step-0.dart

 import 'package:flutter/material.dart';
 void main() => runApp(LoginApp());
 class LoginApp extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return MaterialApp(
       routes: {
         '/': (context) => LoginScreen(),
       },
     );
   }
 }
 class LoginScreen extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       backgroundColor: Colors.grey[200],
       body: Center(
         child: SizedBox(
           width: 400,
           child: Card(
             child: LoginForm(),
           ),
         ),
       ),
     );
   }
 }
 class LoginForm extends StatefulWidget {
   @override
   _LoginFormState createState() => _LoginFormState();
 }
 class _LoginFormState extends State {
   final _firstNameTextController = TextEditingController();
   final _lastNameTextController = TextEditingController();
   final _usernameTextController = TextEditingController();
 double _formProgress = 0;
 @override
   Widget build(BuildContext context) {
     return Form(
       child: Column(
         mainAxisSize: MainAxisSize.min,
         children: [
           LinearProgressIndicator(value: _formProgress),
           Text('Sign Up', style: Theme
               .of(context)
               .textTheme
               .display1), // display1 changes to headline4 in 1.16
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _firstNameTextController,
               decoration: InputDecoration(hintText: 'First name'),
             ),
           ),
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _lastNameTextController,
               decoration: InputDecoration(hintText: 'Last name'),
             ),
           ),
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _usernameTextController,
               decoration: InputDecoration(hintText: 'Username'),
             ),
           ),
           FlatButton(
             color: Colors.blue,
             textColor: Colors.white,
             onPressed: null,
             child: Text('Sign up'),
           ),
         ],
       ),
     );
   }
 }

그리고 다음의 명령을 통해서 실행해 봅니다. 지금까지의 작업을 잘 따라왔다면, 문제없이 Chrome 브라우저를 통해서 이름과 아이디를 입력하도록 하는 화면이 열렸을 겁니다.

flutter run -d Chrome

Flutter 공식 홈페이지에서는 바로 수정 작업을 진행하지만, 본 글에서는 앞서 main.dart의 내용을 좀 더 살펴 보도록 하겠습니다. 편의상 main.dart의 설명을 용이하게 하기 위하여 줄 번호가 포함된 형태인 [그림 3]과 [그림 4]로 나누어서 이해 하도록 합니다.

[그림 3] main.dart 이해 (1/2)

3번 줄에서 이 프로그램이 Material 디자인을 기반으로 만들어 지는 것을 알 수 있습니다.

5번 줄에서는 main() 실행 시, LoginApp() 객체를 만들어서, 프로그램이 처음 구동하도록 합니다.

7번 줄에서는 LoginApp 클래스의 정의가 StatelessWidget를 확장하여 만들어 지는 것을 볼 수 있습니다. 내부 구성은 앞서 Flutter에서 다뤘던 내용과 동일하게 build()를 override하는 것을 볼 수 있습니다. MaterialApp() 객체를 리턴 하는 것 까지는 이전에 다룬 전형적인 형태인데, 내부를 보면 지금까지 다루지 않았던 routes Property를 설정하는 내용이 나옵니다. 여기서 MaterialApp 클래스의 routes Property에 대해서 이해가 필요합니다.

MaterialApp 클래스의 routes Property는 Map<String, WidgetBuilder> 타입으로 정의되어 있습니다 (출처: https://api.flutter.dev/flutter/material/MaterialApp/routes.html ). String에 해당하는 문자열과 이 문자열에 매핑 되는 Widget을 생성할 Builder 함수를 입력 파라메타로 받는 다는 것 입니다. 일반적인 Web 사이트 접속 시 사용하는 URL(웹 사이트 주소)을 연상하면 쉽게 이해할 수 있습니다. 예를 들어, test.net 이라는 Web 사이트에 접속할 때, Web 브라우저의 주소 창에 다음처럼 입력을 합니다.

http://test.net

이렇게 하면, 통상 test.net 사이트를 동작하고 있는 Web 서버 컴퓨터에서 Web 서버 프로그램의 root 디렉토리의 index.html 화일을 동작하게 됩니다. Root 디렉토리를 기호로 “/”로 나타낸다는 것을 이해한다면, 12번 줄의 의미가 바로 이해될지 모르겠습니다.

12번 줄은 사용자가 root 디렉토리(“/”)로 접속하면 LoginScreen() 객체를 생성하여, 해당 객체의 생성시 수행하는 작업을 진행하라는 의미 입니다. Routes Property가 Map 타입인 것을 상기 한다면, 여러 개의 입력 값에 반응 가능한 복수의 기능을 정의할 수 있는 것을 예측 할 수 있습니다. 따라서, [그림 3]에서는 root 디렉토리(“/”)에 대한 처리만 정의 했지만, 만약 root 하단의 sub1 디렉토리에 대한 요청과 root 하단의 sub2 디렉토리에 대한 동작을 추가로 정의하고 싶은 경우는 다음과 같이 각각에 functionSub1()과 functionSub2()를 수행하도록 정의할 수 있습니다.

routes: {
‘/’: (context) => LoginScreen(),
‘/sub1’: (context) => functionSub1(),                   
‘/sub2’: (context) => functionSub2(),
}

12번 줄에서 최초 root 로의 접근시 실행하는 LoginScreen()은 18번 줄부터 정의되어 있습니다. 이는 StatelessWidget으로 확장하여 만듭니다. 그리고 전형적인 실행 예제처럼 build()를 override하는 것을 볼 수 있습니다.

21번 줄을 보면 Scaffold()를 return하는 것을 볼 수 있는데, 22번 줄을 보면 배경 색깔을 grey[200]으로 정의하는 것과, body()를 포함하도록 합니다.

23번 줄을 보면, body()에 Center()를 생성하는데, 이 안을 보면, LoginForm()을 child로 포함하는 SizedBox()를 하나 포함하는 것을 볼 수 있습니다.

[그림 4] main.dart 이해 (2/2)

다음은 main.dart의 반에 해당하는 내용인 [그림 4]를 설명하도록 하겠습니다.

35번 줄에서는 LoginForm이 StatefulWidget()을 확장해서 만들어 지는 것을 볼 수 있습니다. 그리고 내부 상태로 LoginFormState를 createState()로 생성하는 것을 볼 수 있습니다.

40번 줄부터 LoginFormState()가 State<>의 확장으로 만들어 지는 것을 볼 수 있습니다.

41번 줄에서 43번 줄을 통해서, 3개의 TextController()가 내부 상태로 만들어 집니다. 이들은 TextEditingController()로 만들어 집니다. 여기서 처음 등장한 TextEditingController 클래스에 대해서 좀 더 알아 보겠습니다.

TextEditingController 클래스는 편집 가능한 텍스트 필드에 대한 컨트롤러 입니다(참조: https://api.flutter.dev/flutter/widgets/TextEditingController-class.html ). 이 클래스에서 주요한 property는 value, text 그리고 selection 입니다. 먼저 value Property는 TextEditingController 안에 저장되어 있는 현재의 값입니다. text Property는 사용자가 편집하고 있는 현재 문자열을 의미합니다. selection Property는 현재 선택된 텍스트를 의미합니다.

사용자가 관련 TextEditingController로 텍스트 필드를 수정할 때마다 텍스트 필드는 value Property를 업데이트하고 컨트롤러는 해당 Listeners에게 알립니다. 그러면 Listeners는 text Property 및 selection Property를 읽고 사용자가 입력 한 내용 또는 selection이 어떻게 업데이트 되었는지 알 수 있습니다. 마찬가지로 text Property 또는 selection Property를 수정하면 텍스트 필드에 알리고 적절하게 업데이트 됩니다. TextEditingController를 사용하여 텍스트 필드의 초기 값을 제공 할 수도 있습니다. 이미 text Property가 있는 컨트롤러로 텍스트 필드를 작성하면 텍스트 필드는 해당 텍스트를 초기 값으로 사용합니다. 이 컨트롤러에 추가 된 Listeners 내에서 text Property 또는 selection Property를 설정할 수 있습니다. 두 속성을 모두 변경해야하는 경우 컨트롤러 value Property를 대신 설정해야 합니다. 더 이상 필요하지 않은 경우 TextEditingController의 dispose Method를 실행 합니다. 이를 통해 객체가 사용하는 모든 리소스를 폐기 할 수 있습니다.

48번 줄을 보면, 이렇게 3가지 편집 가능한 텍스트 컨트롤러를 상태 값으로 관리하는 LoginFormState 클래스는 build 함수에서 Form() 객체를 리턴 합니다.

49번 줄을 보면, Form 클래스는 복수의 form field widget들을 묶어서 그룹으로 만든 container를 만듭니다.

50번 줄을 보면, Form 클래스의 구성 요소는 Column 형태로 묶인, LinearProgressIndicator(), Text(), 그리고 3개의 Padding()와 FlatButton()인 것을 알 수 있습니다. Text()는 입력 창의 맨 위에 표시한 “Sign Up” 글자를 나타내며, FlatButton()은 아직 활성화가 안된 (onpressed Property가 null인) “Sign Up” 글자를 내용으로 가지는 버튼입니다.

58번 줄을 포함한 3개의 Padding()은 각각 TextFormField()를 가지고 있는데, 이 안의 controller Property를 보면, 앞서 정의한 StatefulWidget의 상태 값인 TextEditingController()로 설정되어 있는 것을 볼 수 있습니다. Padding Property와 decoration Property는 각각 사용자 화면의 구성과 힌트 문자열을 보여주는 효과를 나타냅니다.

Step.1 Add welcome screen

이제 사용자가 텍스트의 입력을 마치면, 화면의 맨 아래 위치한 “Sign up” 단추를 눌러서 작업을 마치는 기능을 추가 하도록 합니다. 이를 위해서, main.dart 프로그램을 수정합니다. 먼저, 화면 중간에 간단하게 “Welcome!” 이라는 글자를 출력하는 WelcomeScreen 클래스를 위한 아래 코드를 추가 합니다.

class WelcomeScreen extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       body: Center(
         child: Text('Welcome!', style: Theme.of(context).textTheme.display3),
       ),
     );
   }
 }

해당 함수는 앞서의 MaterialApp의 routes Property를 통해서 접근 가능하도록 수정할 계획입니다. 따라서, 아래의 문장을 “/”에 대한 구문 아래 추가 합니다.

'/welcome': (context) => WelcomeScreen(),

이제 “Sign up” 단추를 활성화 하고, 이 단추가 눌렸을 때, 프로그램이 “/welcome” route로 이동하도록 해야 합니다. 이 기능을 위해서, LoginFormState 클래스 내부 method로, 아래의 showWelcomeScreen() 함수를 추가 합니다.

void _showWelcomeScreen() {
   Navigator.of(context).pushNamed('/welcome');
 }

Flutter는 단 하나의 Navigator 객체를 갖습니다. 이 위젯은 스택 내에서 Flutter의 화면 (routes 또는 pages 라고도 함)을 관리합니다. 스택 맨 위에 있는 화면은 현재 표시된 view 입니다. 이 스택에 새로운 스크린을 push 하면, 디스플레이가 새 화면으로 전환됩니다. 이것이 showWelcomeScreen() 함수가 WelcomeScreen을 Navigator의 스택으로 push 하는 이유입니다. 이제, 사용자가 “Sign up” 버튼을 클릭하면, Welcome 화면이 나타납니다. 마찬가지로, Navigator에서 pop()을 호출하면 이전 화면으로 돌아갑니다. Flutter의 navigation 기능은 브라우저의 navigation 기능에 통합되어 있습니다. 이로 인하여, 브라우저의 ‘뒤로가기’ 화살표 버튼을 클릭하면 암시적으로 이전 화면으로 돌아가는 pop()이 실행 됩니다.

이렇게 완성된 프로그램을 darttutorial-41-main-step-1.dart에서 확인할 수 있습니다.

// Source: https://flutter.dev/docs/get-started/codelab-web
// darttutorial-41-main-step-1.dart

 import 'package:flutter/material.dart';
 void main() => runApp(LoginApp());
 class LoginApp extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return MaterialApp(
       routes: {
         '/': (context) => LoginScreen(),
         '/welcome': (context) => WelcomeScreen(),
       },
     );
   }
 }
 class LoginScreen extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       backgroundColor: Colors.grey[200],
       body: Center(
         child: SizedBox(
           width: 400,
           child: Card(
             child: LoginForm(),
           ),
         ),
       ),
     );
   }
 }
 class LoginForm extends StatefulWidget {
   @override
   _LoginFormState createState() => _LoginFormState();
 }
 class _LoginFormState extends State {
   final _firstNameTextController = TextEditingController();
   final _lastNameTextController = TextEditingController();
   final _usernameTextController = TextEditingController();
 double _formProgress = 0;
 void _showWelcomeScreen() {
     Navigator.of(context).pushNamed('/welcome');
   }
 @override
   Widget build(BuildContext context) {
     return Form(
       child: Column(
         mainAxisSize: MainAxisSize.min,
         children: [
           LinearProgressIndicator(value: _formProgress),
           Text('Sign Up',
               style: Theme.of(context)
                   .textTheme
                   .display1), // display1 changes to headline4 in 1.16
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _firstNameTextController,
               decoration: InputDecoration(hintText: 'First name'),
             ),
           ),
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _lastNameTextController,
               decoration: InputDecoration(hintText: 'Last name'),
             ),
           ),
           Padding(
             padding: EdgeInsets.all(8.0),
             child: TextFormField(
               controller: _usernameTextController,
               decoration: InputDecoration(hintText: 'Username'),
             ),
           ),
           FlatButton(
             color: Colors.blue,
             textColor: Colors.white,
             onPressed: _showWelcomeScreen,
             child: Text('Sign up'),
           ),
         ],
       ),
     );
   }
 }
 class WelcomeScreen extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       body: Center(
         child: Text('Welcome!', style: Theme.of(context).textTheme.display3),
       ),
     );
   }
 }

Step.2 Enable sign in progress tracking

앞서 [그림 4]의 53번 줄을 보면, LinearProgressIndicator가 있는 것을 볼 수 있습니다. 이는 progress bar라고도 하는데, Material 디자인에 기반해서 작업이 진행 중이라는 것을 사용자에게 보여주는 역할 입니다. 두가지 타입이 존재하는 Determinate와 Indeterminate 입니다. 전자는 0.0 ~ 1.0 사이의 값으로 현재의 진행 상황을 표시합니다. 후자는 값을 갖지 않으며(null value), 단지 진행 중이라는 의미를 전달하기 위해서 사용합니다. (참조: https://api.flutter.dev/flutter/material/LinearProgressIndicator-class.html )

이제 비활성화 되어 있는 LinearProgressIndicator를 활성화 하도록 하겠습니다. 즉, 사용자가 [그림 2]의 각각의 칸을 채워 나가면, LinearProgressIndicator의 색과 채워지는 모양이 바뀌며, 모든 칸에 대해서 사용자가 입력을 하면, 꽉찬 줄의 모양을 보여주도록 수정합니다. 이를 위해서 사용자가 각 칸 별로 글을 썼는지 확인한 후, 칸이 채워지는 만큼의 값을 LinearProgressIndicator의 파라메타로 설정한 formProgress 변수의 값을 0.0 ~ 1.0으로 변경하는 방식으로 줄의 모양이 바뀌도록 합니다. 이를 위해서 가장 먼저 할 일은 3개의 칸에 대해서 빈칸이 아니면, 각 칸별로 1/3의 값을 formProgress에 더하는 함수 입니다. 이 함수는 updateFormProgress() 이며, 다음과 같은 내용을 갑습니다. 이 함수를 LoginFormState 클래스 안에 정의합니다. 함수의 내용은 명확 합니다. 세개의 편집 가능한 칸을 담당하는 컨트롤러의 isNotEmpty Property를 통해서, 빈 칸인지 확인하고, 빈 칸이 아니라면 임시 값인 progress에 1/3의 값을 추가 합니다. 그리고 이 값을 LinearProgressIndicator가 관리하는 formProgress에 전달하여 LinearProgressIndicator의 모양이 바뀌도록 합니다. 이때, setState() Method안에서 formProgress를 설정하는데, setState()는 앞서 Flutter의 내용을 설명할 때, 화면을 업데이트 하도록 유도 한다는 부분을 다시 한번 리마인드 합니다.

void _updateFormProgress() {
   var progress = 0.0;
   var controllers = [
     _firstNameTextController,
     _lastNameTextController,
     _usernameTextController
   ];
 for (var controller in controllers) {
     if (controller.value.text.isNotEmpty) {
       progress += 1 / controllers.length;
     }
   }
 setState(() {
     _formProgress = progress;
   });
 }

다음에 할 일은 updateFormProgress() Method가 칸에 글을 입력하거나 삭제하는 활동이 발생 했을때, 자동으로 호출되도록 하는 것 입니다. 이는 LoginFormState 클래스의 build() 메소드에서 리턴되는 Form() 내의 onChanged Property 값에 updateFormProgress()를 전달하는 것으로 가능합니다. 즉, Form 안의 칸에 값이 입력/삭제 되어 변경이 발생하면, updateFormProgress() 함수가 자동 호출 되도록 하는 것 입니다. 따라서 Form()의 첫번째 줄을 다음과 같이 추가 합니다.

onChanged: () => _updateFormProgress(), 

마지막으로 칸이 모두 채워졌는지 아닌지에 대한 판단을 할 수 있게 되었으므로, 칸이 채워지면 Welcome 화면으로 넘어가지만, 빈칸이 있다면 Welcome 화면으로 넘어가지 않도록 하는 기능을 추가로 작성하도록 하겠습니다. 이는 매우 단순하게, 작업을 마치고 누르는 FlatButton의 onPressed Property를 다음과 같이 변경합니다.

onPressed: _formProgress == 1 ? _showWelcomeScreen : null, 

이는 formProgress가 1이면 showWelcomeScreen Method를 실행하고, 1이 아니면 아무런 동작을 하지 말라는 의미입니다.

이제 Step.2의 모든 작업을 마쳤으므로, “flutter run -d Chrome”으로 수정된 프로그램을 실행하고, 수정된 내용을 확인해 봅니다.

이렇게 수정한 main.dart는 darttutorial-41-main-step-2.dart와 같습니다.

// Source: https://flutter.dev/docs/get-started/codelab-web

import 'package:flutter/material.dart';

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

class LoginApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      routes: {
        '/': (context) => LoginScreen(),
        '/welcome': (context) => WelcomeScreen(),
      },
    );
  }
}

class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[200],
      body: Center(
        child: SizedBox(
          width: 400,
          child: Card(
            child: LoginForm(),
          ),
        ),
      ),
    );
  }
}

class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _firstNameTextController = TextEditingController();
  final _lastNameTextController = TextEditingController();
  final _usernameTextController = TextEditingController();

  double _formProgress = 0;

  void _showWelcomeScreen() {
    Navigator.of(context).pushNamed('/welcome');
  }

  void _updateFormProgress() {
    var progress = 0.0;
    var controllers = [
      _firstNameTextController,
      _lastNameTextController,
      _usernameTextController
    ];

    for (var controller in controllers) {
      if (controller.value.text.isNotEmpty) {
        progress += 1 / controllers.length;
      }
    }

    setState(() {
      _formProgress = progress;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      onChanged: () => _updateFormProgress(),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          LinearProgressIndicator(value: _formProgress),
          Text('Sign Up',
              style: Theme.of(context)
                  .textTheme
                  .display1), // display1 changes to headline4 in 1.16
          Padding(
            padding: EdgeInsets.all(8.0),
            child: TextFormField(
              controller: _firstNameTextController,
              decoration: InputDecoration(hintText: 'First name'),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8.0),
            child: TextFormField(
              controller: _lastNameTextController,
              decoration: InputDecoration(hintText: 'Last name'),
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8.0),
            child: TextFormField(
              controller: _usernameTextController,
              decoration: InputDecoration(hintText: 'Username'),
            ),
          ),
          FlatButton(
            color: Colors.blue,
            textColor: Colors.white,
            onPressed:
                _formProgress == 1 ? _showWelcomeScreen : null, 
            child: Text('Sign up'),
          ),
        ],
      ),
    );
  }
}

class WelcomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Welcome!', style: Theme.of(context).textTheme.display3),
      ),
    );
  }
}

마무리

공식 사이트에는 추가적으로 progress indicator에 애니메이션 효과를 추가하는 예제까지 되어 있습니다. 하지만 개인적으로 애니메이션에는 큰 흥미가 없기에 이 부분은 본 글에서는 다루지 않도록 하겠습니다.

이번 글에서 어떤 부분을 느낄수 있을까요? Web 브라우저 화면을 채우고 있지만, HTML/CSS/JS에 대한 전문 지식이 그다지 필요하지 않은 것을 볼 수 있습니다. 그리고 모바일 어플리케이션을 개발하는 것과 동일하게 개발이 되는 것을 알 수 있습니다. 이렇게 플랫폼이 다르더라도 같은 기술을 사용하여 Web 어플리케이션을 개발하는 것이 가능한 매력을, Dart/Flutter 개발 환경은 제공하고 있습니다.

Creative Commons License (CC BY-NC-ND)

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다