Flutter 대표 카카오톡 개발자 톡을 운영중입니다.

https://open.kakao.com/o/gsshoXJ

 

Flutter 개발자 모임

#flutter #android #ios #안드로이드 #아이폰 #모바일 #선물요정소환

open.kakao.com


자바스크립트에서 많이 사용되는 Promise.all 을 다트에선 어떻게 구하는지 알아볼 예정입니다.

Promise.all 은 여러개의 Promise 를 모아서 한꺼번에 처리해주는 역할을 해준다. 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise/all

 

Promise.all()

Promise.all() 메서드는 순회 가능한 객체에 주어진 모든 프로미스가 이행한 후, 혹은 프로미스가 주어지지 않았을 때 이행하는 Promise를 반환합니다. 주어진 프로미스 중 하나가 거부하는 경우, 첫 번째로 거절한 프로미스의 이유를 사용해 자신도 거부합니다.

developer.mozilla.org

그럼 다트에서는 어떻게 사용되는 지 살펴보겠다. 

void main() {
  final f1 = getValue(delay : 1, value: 1);
  final f2 = getValue(delay : 5, value: 2);
  final f3 = getValue(delay : 2, value: 3);
  final f4 = getValue(delay : 3, value: 4);
  
  Future.wait([f1,f2,f3,f4])
     .then((value) => print(value));
  
}

Future<int> getValue({int delay, int value}) => Future.delayed(Duration(seconds: delay), () => Future.value(value));

 문제는 저건 순서대로 진행될텐데 병렬로 할려면 어떻게 하는지 궁금 하신 분은(Isolate사용) 아래 링크에서 보시길 바랍니다.

https://buildflutter.com/flutter-threading-isolates-future-async-and-await/

 

Flutter Threading: Isolates, Future, Async And Await - Build Flutter

Flutter applications start with a single execution process to manage executing code. Inside this process you will find different ways that the process handles multiple pieces of code executing at the same time. Isolates When Dart starts, there will be one

buildflutter.com

저장소 정리하다 보니 1월에 진행된 간단한 백엔드 연동된 스터디 자료가 있어서 공유합니다. 

내용 : 간단한 투두 리스트

구현내용
1. Flutter
2. Bloc
3. RxDart
4. Docker
5. Node, Express
6. nginx proxy

https://github.com/bear2u/flutter-ecommerce-study

Flutter 화면 제작 공부

https://www.youtube.com/watch?v=dMLreUXpSQ0&t=1s

를 보고 하나씩 공부 하고자 한다.

우선 사이즈 부분을 알아보자.

1553055691923

  • Row를 사용해서 정렬
  • Container Boxdecoration을 이용해서 박스형태 제작
  • 그림자 효과 적용

소스

높이 자동 계산 로직

double baseHeight = 640.0;

double screenAwareSize(double size, BuildContext context) {
  return size * MediaQuery.of(context).size.height / baseHeight;
}
import 'package:flutter/material.dart';
import 'package:flutter_adidas_shoes_exam/utils.dart';
import 'data.dart';

void main() => runApp(MaterialApp(
      home: MyApp(),
      debugShowCheckedModeBanner: false,
    ));

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

List<String> sizeNumlist = [
  "7",
  "8",
  "9",
  "10",
];

class _MyAppState extends State<MyApp> {
  int _currentSizeIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Padding(
        padding: const EdgeInsets.all(30.0),
        child: Column(
          children: <Widget>[
            Row(
              children: sizeNumlist.map((item) {
                var index = sizeNumlist.indexOf(item);
                return GestureDetector(
                    child: sizeItem(item, _currentSizeIndex == index, context),
                    onTap: (){
                      setState(() {
                        _currentSizeIndex = index;
                      });
                    },
                );
              }).toList(),
            )
          ],
        ),
      ),
    );
  }
}

Widget sizeItem(String item, bool isSelected, BuildContext context) {
  return Padding(
    padding: EdgeInsets.only(left: 8.0),
    child: Container(
      width: screenAwareSize(30.0, context),
      height: screenAwareSize(30.0, context),
      decoration: BoxDecoration(
        color: isSelected ? Color(0xFFFC3930) : Color(0xFF525663),
        borderRadius: BorderRadius.circular(5.0),
        boxShadow: [
          BoxShadow(
            color: isSelected ? Colors.black.withOpacity(.5) : Colors.black12,
            offset: Offset(0.0, 10.0),
            blurRadius: 10.0
          )
        ]
      ),
      child: Center(
        child: Text(
          item,
          style: TextStyle(color: Colors.white, fontFamily: "Montserrat-Bold", fontSize: 11.0),
        ),
      ),
    ),
  );
}

Clipper를 이용한 뷰

1553060095143

위와 같이 구성할려면 알아야 할 내용

  • Clip
  • BoxShadow
  • BoxDecoration
class MClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    var path = Path();
      //1  
    path.lineTo(0.0, size.height);
      //2  
    path.lineTo(size.width * 0.5, size.height);
      //3
    path.lineTo(size.width, size.height * 0.2);
      //4
    path.lineTo(size.width, 0.0);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return true;
  }
}

Path는 펜슬로 위치를 잡아가면서 그려주는 역할을 해준다.

1553060539050

그림이 상당히 이상하지만 순서대로 따라가면서 이렇게 그려주면서 다각형(?)을 그려준다.

전체 소스

import 'package:flutter/material.dart';
import 'package:flutter_adidas_shoes_exam/utils.dart';
import 'data.dart';

void main() => runApp(MaterialApp(
      home: MyApp(),
      debugShowCheckedModeBanner: false,
    ));

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {

  int _currentColorIndex = 0;

  List<Widget> colorSelector() {
    List<Widget> colorItemList = new List();
    for (var i = 0; i < colors.length; i++) {
      colorItemList
          .add(colorItem(
          colors[i],
          i == _currentColorIndex,
          context,
              () {
            setState(() {
              _currentColorIndex = i;
            });
          }
      ));
    }

    return colorItemList;
  }

  Widget colorItem(Color color, bool isSelected, BuildContext context,
      VoidCallback _ontab) {
    return Padding(
      padding: EdgeInsets.all(16.0),
      child: Container(
        width: screenAwareSize(30.0, context),
        height: screenAwareSize(30.0, context),
        decoration: BoxDecoration(
            color: Colors.black,
            borderRadius: BorderRadius.circular(5.0),
            boxShadow: [
              BoxShadow(
                  color: Colors.black.withOpacity(.8),
                  blurRadius: 10.0,
                  offset: Offset(0.0, 10.0)
              )
            ]
        ),
        child: ClipPath(
          clipper: MClipper(),
          child: Container(
            width: double.infinity,
            height: double.infinity,
            color: color,
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Container(
        width: double.infinity,
        margin: EdgeInsets.only(left: screenAwareSize(20.0, context)),
        child: Row(
          children: colorSelector(),
        ),
      ),
    );
  }

}

class MClipper extends CustomClipper<Path> {
  @override
  Path getClip(Size size) {
    var path = Path();
    path.lineTo(0.0, size.height);
    path.lineTo(size.width * 0.5, size.height);
    path.lineTo(size.width, size.height * 0.2);
    path.lineTo(size.width, 0.0);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return true;
  }
}

다음에는 프로그래스바를 이용해서 커스텀 뷰를 공부해볼 예정이다.

Flutter + Steho 사용하기

Flutter 로컬 디비 사용시 대중적인 방법은 sqflite 일것이다.

일반 sqlite 와 문법이 똑같으며 사용하기에도 편하다.

sqlite 를 모바일에서 연동시 페이스북에서 나온 steho 라는 라이버러리가 있다.

안드로이드만 현재 가능하지만 꽤 유용하게 사용가능하다.

그럼 flutter 에서 어떻게 적용하는지 살펴보자.

우선 sqflite 를 import 로 가져오고 생성시 getDatabasePath() 로 가져올 수 있다.

  Future<Database> init() async {
    String documentsDirectory = await getDatabasesPath(); //주의하자.
    final path = join(documentsDirectory, "test.db");
    final db = await openDatabase(
      path,
      version: _version,
      onCreate: _onCreate,
      onUpgrade: (Database db, int oldVersion, int newVersion) async {
        if(newVersion > oldVersion) {
          await File(path).delete();
          _onCreate(db, newVersion);
          print('onUpgrade done');
        }
      }
    );
    return db;
  }

그리고 stetho 라이버러리를 설치를 하자.

마지막으로 main.dart 에서 적용하면 된다.

  if(isInDebugMode) {
    Stetho.initialize();
  }

중요한 점은 디버깅 모드일 경우에만 실행을 해야 한다.

그래서 isInDebugMode 변수를 지정했다.

bool get isInDebugMode {
  bool inDebugMode = false;
  assert(inDebugMode = true);
  return inDebugMode;
}

크롬에서 chrome://inspect/#devices 주소를 열어서 왼쪽 Devices 항목을 클릭시 inspect 항목이 나오는데 그걸 클릭시 새 창이 뜨면서 확인이 가능하다.

Android 만 가능한걸로 안다. 그 점을 유의하자.

 

이상으로 Flutter + Sqflite + Stetho 적용법을 알아보았다.

 

Flutter Codelab을 돌려볼때 오류가 발생될 경우가 있다. 

코드랩 위치 : https://codelabs.developers.google.com/codelabs/mdc-101-flutter


오류 내용은 No toolchains found in the NDK toolchains folder for ABI with prefix: mips64el-linux-android

해결방법은 2가지를 체크 해야 한다. 

1. android 폴더내에 project 레벨에서 build.gradle 에서 dependencies 가 3.1 이상으로 되어있는 지 체크하자. 

dependencies {
classpath 'com.android.tools.build:gradle:3.2.1'
}

2. android/gradle/wrapper 폴더내 gradle-wrapper.properties 에 4.6으로 설정

distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip


이 두가지를 설정시 별 문제 없이 빌드 되는 걸 볼수 있다. 

이상으로 해결방법에 대해 공유해본다. 

No toolchains found in the NDK toolchains folder for ABI with prefix: mips64el-linux-android


원인 : 오래된 flutter 플젝을 열때 나오는 버그이다. 

Open android/gradle/gradle-wrapper.properties and change this line:

distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip

to this line:

distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip

Open android/build.gradle and change this line:

classpath 'com.android.tools.build:gradle:3.0.1'

to this:

classpath 'com.android.tools.build:gradle:3.1.2'


Flutter WhatsApp 클론 (부산 4주차 스터디)

부산에서 매주 진행되는 Flutter 스터디 4주차 내용입니다.

더 많은 부산에서 스터디 정보는 네이버 카페 에서 확인 가능합니다.

소스는 Github 에서 확인 가능합니다.



1주차

2주차

3주차

스터디 내용

이번주차는 저번 스터디에 이어서 로직부분을 진행하도록 하겠다.

  • Firestore
  • FireBase Auth
    • Google Sigin
  • FireBase Storage - 이미지 업로드

FireStore

Firebase 에서 제공하는 Collection - Document 형태의 nosql realdb 이다.

자세한 설치 및 내용은 공식문서를 참조하자.

시나리오 #1

채팅방에서 텍스트박스에 내용을 입력하면 Firestore에 내용이 입력되고 그 입력된 내용을 StreamBuilder 로 통해서 구독을 해서 리스트뷰에 자동 업데이트를 하는 게 목표

Firestore 저장

  /// firestore연결해서 저장
  saveChat(ChatData chatData) {
    Firestore.instance.collection(fireStoreCollectionName)
        .document()
        .setData(chatData.toMap());
  }

Firestore 구독 및 리스트 조회

  • Firestore snapshot 스트림이용해서 구독처리
  Widget _buildListItems() {
    return StreamBuilder(
      stream: Firestore.instance.collection("room").snapshots(),
        builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
          if(!snapshot.hasData) return Text("Loading...");
          List<ChatData> list = snapshot.data.documents.map((DocumentSnapshot document) {
            return ChatData.fromMap(document);
          }).toList();
          print(list);
          return ListView.builder(
              controller: _scrollController,
              itemCount: list.length,
              itemBuilder: (BuildContext context, int index) => _generateItems(list[index])
          );
        }
    );
  }

시나리오 #2

구글 로그인을 하는데 오른쪽 프로필 이미지를 로그인 후 변하도록 하고 다시 클릭시 로그아웃 다이얼로그 창이 나오도록 하자.

image-20181114124552756

  • Firebase Auth 라이버러리 추가

    • firebase_auth: "0.6.2+1"

화면 구성

  • 상단 바 구성
    • title, profile image 등으로 구성되어 있다.
Widget _buildMatchAppbar(context) {
  return AppBar(
    title: Row(
      mainAxisAlignment: MainAxisAlignment.start,
      children: <Widget>[
        Flexible(
          child: Container(
            width: double.maxFinite,
            child: Text(
              "Flutter Study App",
              style: TextStyle(color: AppBarColor),
            ),
          ),
        ),
        _getProfileCircleImage()
      ],
    ),
    backgroundColor: AppBackgroundColor,
  );
}
  • Profile Image
    • 로그인 상태에는 로그아웃 다이얼로그가 나오게 하고
    • 로그아웃 상태에서는 로그인 다이얼로그가 나오게 한다.
_getProfileCircleImage() {
  return InkWell(
    onTap: () {
      userData == null
      ? _handleSignIn()
      : _showSignOutDialog();
    },
    child: new Container(
      width: 30.0,
      height: 30.0,
      decoration: new BoxDecoration(
        color: const Color(0xff7c94b6),
        image: new DecorationImage(
          image: userData == null
            ? new AssetImage("assets/images/profile.png")
            : new NetworkImage(userData.photoUrl),
          fit: BoxFit.cover,
        ),
        borderRadius: new BorderRadius.all(new Radius.circular(30.0)),
      ),
    ),
  );
}

유의할 점

  • Firebase Console 설정 페이지에서 Sha 값을 등록해줘야 한다.
  • image-20181114125547305

시나리오 #3

Image Picker (카메라 또는 갤러리) 를 통해서 가져온 이미지를 Firestorage로 업로드 한다.

  • Firebase Stroage
  • Image Picker

image-20181114154240806

포토 아이콘을 클릭시 Image Picker 가 실행된다.

Source를 어떤걸 주냐에 따라 차이가 날 수 있다. (GalleryCamera)

void _uploadImage() async {
    File imageFile = await ImagePicker.pickImage(source: ImageSource.camera);
    int timeStamp = DateTime.now().millisecondsSinceEpoch;
    StorageReference storageReference = FirebaseStorage
        .instance
        .ref()
        .child("img_" + timeStamp.toString() + ".jpg");
    StorageUploadTask uploadTask = storageReference.putFile(imageFile);
    uploadTask.onComplete
        .then((StorageTaskSnapshot snapShot) async {
          String imgUrl = await snapShot.ref.getDownloadURL();

          final chatData = ChatData(
              message: null,
              time: _getCurrentTime(),
              delivered: true,
              sender: userData.displayName,
              senderEmail: userData.email,
              senderPhotoUrl: userData.photoUrl,
              imgUrl: imgUrl
          );

          fbApiProvider.saveChat(chatData);

        });
  }

마지막으로 Bubble (풍선) 쪽을 살펴보도록 하자.

import 'package:flutter/material.dart';

class Bubble extends StatelessWidget {
  Bubble({this.message, this.time, this.delivered, this.isOthers, this.profilePhotoUrl, this.imageUrl});

  final String message, time, profilePhotoUrl, imageUrl;
  final delivered, isOthers;

  @override
  Widget build(BuildContext context) {
    final bg = isOthers ? Colors.white : Colors.greenAccent.shade100;
    final align = isOthers ? MainAxisAlignment.start : MainAxisAlignment.end;
    final icon = delivered ? Icons.done_all : Icons.done;
    final radius = isOthers
        ? BorderRadius.only(
      topRight: Radius.circular(5.0),
      bottomLeft: Radius.circular(10.0),
      bottomRight: Radius.circular(5.0),
    )
        : BorderRadius.only(
      topLeft: Radius.circular(5.0),
      bottomLeft: Radius.circular(5.0),
      bottomRight: Radius.circular(10.0),
    );
    return Row(
      mainAxisAlignment: align,
      children: <Widget>[
        _getCircleProfileIcon(),
        Container(
          //margin: const EdgeInsets.all(3.0),
          margin: isOthers
            ? EdgeInsets.only(top: 50.0, left: 3.0, bottom: 3.0, right: 3.0)
            : EdgeInsets.all(3.0),
          padding: const EdgeInsets.all(8.0),
          decoration: BoxDecoration(
            boxShadow: [
              BoxShadow(
                  blurRadius: .5,
                  spreadRadius: 1.0,
                  color: Colors.black.withOpacity(.12))
            ],
            color: bg,
            borderRadius: radius,
          ),
          child: Stack(
            children: <Widget>[
              Padding(
                padding: EdgeInsets.only(right: 48.0),
                child: message != null
                  ? Text(message)
                  : Image.network(imageUrl, width: 100.0,)
              ),
              Positioned(
                bottom: 0.0,
                right: 0.0,
                child: Row(
                  children: <Widget>[
                    Text(time,
                        style: TextStyle(
                          color: Colors.black38,
                          fontSize: 10.0,
                        )),
                    SizedBox(width: 3.0),
                    Icon(
                      icon,
                      size: 12.0,
                      color: Colors.black38,
                    )
                  ],
                ),
              )
            ],
          ),
        )
      ],
    );
  }

  _getCircleProfileIcon() {
    if(!isOthers) {
      return Container();
    }
    return Container(
      width: 30.0,
      height: 30.0,
      decoration: new BoxDecoration(
        color: const Color(0xff7c94b6),
        image: new DecorationImage(
          image: new NetworkImage(profilePhotoUrl),
          fit: BoxFit.cover,
        ),
        borderRadius: new BorderRadius.all(new Radius.circular(30.0)),
      ),
    );
  }
}

이상으로 부산에서 진행된 Flutter 입문 스터디 내용이었습니다.

다음 주는 Flutter 프로젝트 스터디를 진행할 예정입니다. 관심있으신분은 신청 부탁드립니다.

Flutter 왓츠앱 클론

부산에서 매주 진행되는 Flutter 스터디 3주차 내용입니다.

더 많은 부산에서 스터디 정보는 네이버 카페 에서 확인 가능합니다.

소스는 Github 에서 확인 가능합니다.

왓츠앱 클론

오늘은 먼저 UI 개발 시간을 가져볼 예정이다.

우선 완성된 화면은 다음과 같다.


ChatScreen

화면은 크게 2개로 나뉜다. 리스트뷰와 하단에 텍스트입력창이다.

그리고 하단에 텍스트가 입력이 될 때마다 리스트가 업데이트 되는 구조이기 때문에 StatefuleWidget 으로 간다.

class ChatScreen extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => ChatState();
}

//채팅 화면
class ChatState extends State<ChatScreen>{
    @override
  Widget build(BuildContext context) {
      ....
  }
}

앞서 설명했다 싶이 크게 두 부분으로 나뉘어 진다.

리스트뷰와 텍스트 입력창

  • Column 은 세로로 정렬 해준다.
  • _buildListItems은 추후 리스트뷰를 만들어 준다.
  • Stack 으로 뒤에 배경을 깔아주고 위에 리스트뷰로 올린다.
  • _buildBottomBar은 하단 텍스트 위젯을 만들는 함수이다.
@override
  Widget build(BuildContext context) {
    return Container(
      child: Column(
        children: <Widget>[
          Flexible(
            child:Stack(
              children: <Widget>[
                Positioned(
                  top: 0.0,
                  child: Image.asset(
                    "assets/images/bg_whatsapp.png",
                    fit: BoxFit.fill,
                  ),
                ),
                _buildListItems()
              ],
            ),
          ),
          Divider(height: 1.0,),
          Container(
            decoration:
              BoxDecoration(color: Theme.of(context).cardColor),
            child: _buildBottomBar(),
          )
        ],
      ),
    );
  }

리스트 뷰는 ListView.builder 로 구성해서 만들어준다.

  Widget _buildListItems() {
    return ListView.builder(
        itemCount: items.length,
        itemBuilder: (BuildContext context, int index) => _generateItems(index)
    );
  }
...
    _generateItems(int index) {
    	return items[index];
	}

하단 바텀 뷰는 세가지 위젯으로 이루어져있다.

  • 사진 아이콘 (현재는 채팅아이콘)
  • 텍스트 입력창
  • 전송버튼

Flexible 은 남은 공간을 꽉 차게 만들어 준다.

그래서 아이콘 2개를 각각 왼쪽, 오른쪽에 위치해준다. 그리고 중간을 Flexible 로 꽉 채워준다.

  • 텍스트폼 내용은 TextEditingController 를 통해서 가져오고 설정할 수 있다.
Widget _buildBottomBar() {
    return IconTheme(
      data: IconThemeData(
        color: Theme.of(context).accentColor
      ),
      child: Container(
        color: Colors.black87,
        child: Row(
          children: <Widget>[
            Container(
              margin: EdgeInsets.symmetric(horizontal: 4.0),
              child: IconButton(
                  icon: Icon(
                      Theme.of(context).accentColor
                  ),
                  onPressed: () {
                  ...
                  }
              ),
            ),
            Flexible(
              child: TextField(
                controller: _tec,
                style: TextStyle(color: Colors.white),
                decoration: InputDecoration(
                  border: InputBorder.none,
                ),
              ),
            ),
            Container(
              margin: EdgeInsets.symmetric(horizontal: 4.0),
              child: _getDefaultSendButton(),
            )
          ],
        ),
      ),
    );
  }

이제 전송 버튼을 클릭시 대화 내용을 Item 이라는 리스트에 추가해준다. setState 를 통해야 하는 건 기본!!

List<Bubble> items = [];
...
setState(() {
    items.add(
        Bubble(
            message: _tec.text,
            time: _getCurrentTime(),
            delivered: true,
            isYours: !_isMe,
        ),
    );
    _tec.text = "";
});

전체 소스

import 'package:flutter/material.dart';
import 'package:youtube_clone_app/src/widgets/Bubble.dart';
import 'package:intl/intl.dart';

class ChatScreen extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => ChatState();
}

//채팅 화면
class ChatState extends State<ChatScreen>{

  List<Bubble> items = [];
  TextEditingController _tec = TextEditingController();

  bool _isMe = true;

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Column(
        children: <Widget>[
          Flexible(
            child:Stack(
              children: <Widget>[
                Positioned(
                  top: 0.0,
                  child: Image.asset(
                    "assets/images/bg_whatsapp.png",
                    fit: BoxFit.fill,
                  ),
                ),
                _buildListItems()
              ],
            ),
          ),
          Divider(height: 1.0,),
          Container(
            decoration:
              BoxDecoration(color: Theme.of(context).cardColor),
            child: _buildBottomBar(),
          )
        ],
      ),
    );
  }

  Widget _buildListItems() {
    return ListView.builder(
        itemCount: items.length,
        itemBuilder: (BuildContext context, int index) => _generateItems(index)
    );
  }

  Widget _buildBottomBar() {
    return IconTheme(
      data: IconThemeData(
        color: Theme.of(context).accentColor
      ),
      child: Container(
        color: Colors.black87,
        child: Row(
          children: <Widget>[
            Container(
              margin: EdgeInsets.symmetric(horizontal: 4.0),
              child: IconButton(
                  icon: Icon(
                      Icons.chat,
                      color: _isMe
                        ? Theme.of(context).accentColor
                        : Colors.white
                  ),
                  onPressed: () {
                    setState(() {
                      _isMe = !_isMe;
                    });
                  }
              ),
            ),
            Flexible(
              child: TextField(
                controller: _tec,
                style: TextStyle(color: Colors.white),
                decoration: InputDecoration(
                  border: InputBorder.none,
                ),
              ),
            ),
            Container(
              margin: EdgeInsets.symmetric(horizontal: 4.0),
              child: _getDefaultSendButton(),
            )
          ],
        ),
      ),
    );
  }

  _getDefaultSendButton() {
    return IconButton(
      icon: Icon(Icons.send),
      onPressed: () {
        setState(() {
          items.add(
            Bubble(
              message: _tec.text,
              time: _getCurrentTime(),
              delivered: true,
              isYours: !_isMe,
            ),
          );
          _tec.text = "";
        });
      },
    );
  }

  _generateItems(int index) {
    return items[index];
  }

  _getCurrentTime() {
    final f = new DateFormat('hh:mm');

    return f.format(new DateTime.now());
  }
}

Bubble

풍선 대화말을 보여주는 위젯이다.

기본적으로 내부적으로 변경되는 경우가 없으므로 StatelessWidget 으로 구현한다.

  • message : 대화내용
  • time : 전송시간
  • delivered : 전송여부 (현재로서는 무조건 all done 상태)
  • isYours 은 상대방 메세지인지 체크
class Bubble extends StatelessWidget {
  Bubble({this.message, this.time, this.delivered, this.isYours});
}

풍선 사방에 약간 라운드 처리를 위해 Radius 설정한다.

    final radius = isYours
        ? BorderRadius.only(
      topRight: Radius.circular(5.0),
      bottomLeft: Radius.circular(10.0),
      bottomRight: Radius.circular(5.0),
    )
        : BorderRadius.only(
      topLeft: Radius.circular(5.0),
      bottomLeft: Radius.circular(5.0),
      bottomRight: Radius.circular(10.0),

그림자 효과와 라운딩 효과를 위해 decoration 적용

decoration: BoxDecoration(
    boxShadow: [
    BoxShadow(
    blurRadius: .5,
    spreadRadius: 1.0,
    color: Colors.black.withOpacity(.12))
    ],
    color: bg,
    borderRadius: radius,
),

내부에 텍스트 넣는 부분이다.

  • stack 으로 구성하는데 EdgeInsets.only(right: 48.0)으로 오른쪽에 여백을 둔다.

  • 그리고 그 여백에 날짜와 all done아이콘을 위치 한다.

    1541500802760

		child: Stack(
            children: <Widget>[
              Padding(
                padding: EdgeInsets.only(right: 48.0),
                child: Text(message),
              ),
              Positioned(
                bottom: 0.0,
                right: 0.0,
                child: Row(
                  children: <Widget>[
                    Text(time,
                        style: TextStyle(
                          color: Colors.black38,
                          fontSize: 10.0,
                        )),
                    SizedBox(width: 3.0),
                    Icon(
                      icon,
                      size: 12.0,
                      color: Colors.black38,
                    )
                  ],
                ),
              )
            ],
          ),

이상으로 간단하게 왓츠앱 대화 UI 를 만들어보았다.

다음시간에는 파이어베이스 및 인증 를 연동해서 그룹 대화를 만들도록 해보겠다.

참석해주셔서 감사합니다.

소스는 Github 에서 받으실수 있습니다.


Flutter Youtube 화면 개발

2주차 스터디는 Youtube Api를 활용해서 화면 구성을 공부해보는 시간을 가져봅니다.

이번시간에 배우는 부분은 아래와 같습니다.

  • Youtube Api 조회

  • Json decoding -> dart class 로 변환하는 방법

  • FutureBuilder 로 비동기로 위젯 구성하기

우선 프로젝트를 기본적으로 구성을 해봅니다.

main.dart

  • material 테마를 가져옵니다.

  • src/app.dart 를 import 해서 패키징 합니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/app.dart';

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

app.dart

  • Theme 설정합니다.

  • home.dart 를 import 해서 런칭 위젯 설정해줍니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/home.dart';

class App extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     theme: ThemeData(
       primarySwatch: Colors.red
    ),
     home: Home(),
  );
}
}

Home Widget

Home 위젯은 탭이 포함된 메인 을 말합니다.

탭은 총 3개로 구성될 예정입니다. 매주 공부할 내용을 기반으로 하나씩 채워나가면서 만들어 볼 예정입니다.

  • Youtube ( 2주차 )

  • Chat ( 3주차 )

  • Nearby ( 4주차 )

Home 위젯은 탭이 바뀌면 setState를 호출해서 변해야 해서 statefulwidget 으로 만듭니다.

class Home extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => HomeState();
}

class HomeState extends State<Home>{
  ...
}

내용은 크게 appbar, body, bottomNavigationBar 가 포함되어 있습니다.

@override
Widget build(BuildContext context) {
 return Scaffold(
   appBar: _buildMatchAppbar(context), //1
   body: _tabs[_tabIndex], //2
   bottomNavigationBar: _buildBottomNavigationBar(context), //3
);
}
  1. AppBar를 커스텀 해서 만들어줍니다.

      Widget _buildMatchAppbar(context) {
       return AppBar(
         title: Text(
             "Flutter Study App",
             style: TextStyle(color: AppBarColor),
        ),
         backgroundColor: AppBackgroundColor,
      );
    }
  2. 3개의 화면 위젯을 미리 만들어서 리스트로 호출하고 있습니다.

   var _tabIndex = 0;
  final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];
  1. BottomNavigationBar 라고 flutter 에서 미리 만들어서 제공해주는 탭바 비슷한게 있습니다. Scaffold 기본 위젯에 포함되어 있어서 생성 후 바로 사용이 가능합니다.

    _buildBottomNavigationBar(context) => Theme(
       data: Theme.of(context).copyWith(
         canvasColor: AppBackgroundColor,
         primaryColor: BottomNaviItemSelectedColor,
         iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
         textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
           color: BottomNaviItemColor
        )),
      ),
       child: BottomNavigationBar(
         items: <BottomNavigationBarItem>[
           _bottomNavigationBarItem(Icons.videocam, "youtube"),
           _bottomNavigationBarItem(Icons.chat, "chat"),
           _bottomNavigationBarItem(Icons.map, "nearby")
        ],
         type: BottomNavigationBarType.fixed,
         currentIndex: _tabIndex,
         onTap: (int index) {        
           setState(() {
             _tabIndex = index;
          });
        },
      ));


    BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
     return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
    }

    탭 이벤트가 발생시 onTap 함수를 통해서 setState 호출해서 body 를 변경하고 있습니다.

    onTap: (int index) {        
       setState(() {
       _tabIndex = index;
    });

Home.dart 전체 소스는 다음과 같습니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/tabs/youtubeScreen.dart';
import 'package:youtube_clone_app/src/commons/colors.dart';


import 'package:youtube_clone_app/src/tabs/NearByScreen.dart';
import 'package:youtube_clone_app/src/tabs/ChatScreen.dart';

class Home extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => HomeState();
}

class HomeState extends State<Home>{

 var _tabIndex = 0;
 final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];

 @override
 void initState() {
   // TODO: implement initState
   super.initState();

}
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: _buildMatchAppbar(context),
     body: _tabs[_tabIndex],
     bottomNavigationBar: _buildBottomNavigationBar(context),
  );
}

 Widget _buildMatchAppbar(context) {
   return AppBar(
     title: Text(
         "Flutter Study App",
         style: TextStyle(color: AppBarColor),
    ),
     backgroundColor: AppBackgroundColor,
  );
}

 _buildBottomNavigationBar(context) => Theme(
     data: Theme.of(context).copyWith(
       canvasColor: AppBackgroundColor,
       primaryColor: BottomNaviItemSelectedColor,
       iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
       textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
         color: BottomNaviItemColor
      )),
    ),
     child: BottomNavigationBar(
       items: <BottomNavigationBarItem>[
         _bottomNavigationBarItem(Icons.videocam, "youtube"),
         _bottomNavigationBarItem(Icons.chat, "chat"),
         _bottomNavigationBarItem(Icons.map, "nearby")
      ],
       type: BottomNavigationBarType.fixed,
       currentIndex: _tabIndex,
       onTap: (int index) {          
         setState(() {
           _tabIndex = index;
        });
      },
    ));


 BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
   return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
}
}

Youtube Widget

youtube 위젯도 동적으로 리스트를 가져와야 해서 Stateful 위젯으로 구성합니다.

class YoutubeScreen extends StatefulWidget {
 @override
 _YoutubeState createState() => _YoutubeState();
}

class _YoutubeState extends State<YoutubeScreen> with AutomaticKeepAliveClientMixin<YoutubeScreen>{
 @override
 bool get wantKeepAlive => true;
}
  • AutomaticKeepAliveClientMixin 은 탭이 변경시 유지를 해준다고 하는데 지금 동작이 잘 안되고 있어서 다시 살펴봐야 할것으로 보입니다.

    • bool get wantKeepAlive => true; 을 설정해줘야 합니다.

    • build 에서 부모를 super.build 해줘야 합니다.

      @override
      Widget build(BuildContext context) {
       super.build(context);
       return _createListBuilder();
      }

리스트 구성

Build 함수내에 ListBuilder() 를 통해서 리스트를 구성해봅니다.

...
return _createListBuilder();
...
//1
_createListBuilder()  => FutureBuilder(
 //2
 future: _getVideos(),
 //3
 builder: (BuildContext context, AsyncSnapshot snapshot){
   //4  
   switch(snapshot.connectionState) {
     case ConnectionState.none:
     case ConnectionState.waiting:
       //5    
       return Center(
         child: CircularProgressIndicator(),
      );
     //6      
     default :
       var list = snapshot.data;
       return ListView.builder(
           itemCount: list.length,
           itemBuilder: (BuildContext context, int index) => _buildListItem(context, list[index])
      );
  }
},
);
  1. FutureBuilder를 통해서 비동기로 youtube api를 조회해서 결과값이 리턴되는 경우 snapshot 으로 콜백이 다시 들어옵니다.

  2. _getVideos() 함수를 통해서 api 를 호출합니다.

  3. 빌더 함수를 통해서 상태제어를 할수 있습니다.

  4. 기본적으로 처음 호출시 connectionStatewaiting 상태가 됩니다. 그리고 결과가 리턴되면 done 으로 바뀌어서 다시 들어오게 됩니다.

  5. 데이터 가져오는 동안 중간에 Progress 를 올려서 ProgressBar 올려서 볼수 있습니다.

  6. snapshot.data는 추후에 나올 함수 list 로 리턴받게 됩니다.

  7. ListView.builder 를 통해서 리스트를 만들고 있습니다.

  8. _buildListItem 를 통해서 리스트내 각각의 리스트 아이템을 만들어주고 있습니다.

리스트 아이템 구성

_buildListItem을 통해서 구성이 됩니다. 전체적인 트리구조는 다음과 같습니다.


_buildListItem(context, video) {
 return InkWell(
   onTap: () {
     Scaffold.of(context).showSnackBar(SnackBar(content: Text("clicked : $video")));
  },
   child: Container(
     decoration: BoxDecoration(
       border: Border(bottom: BorderSide(color: BorderColor)),
    ),
     child: Padding(
         padding: const EdgeInsets.all(10.0),
         child: Flex(
           direction: Axis.vertical,
           children: <Widget>[
             _buildItemVideoThumbnail(video),
             _myTubeVideoContent(video)
          ],
        )
    ),
  ),
);
  • Thumbnail 이미지와 그 밑에 내용으로 크게 구성되어 있습니다.

Thumbnail 이미지 구성

썸네일은 FadeIn 되는 구조를 가집니다.

  _buildItemVideoThumbnail(VideoData video) => AspectRatio(
   aspectRatio: 1.8,
   child: FadeInImage.memoryNetwork(
       placeholder: kTransparentImage,
       image: video.getThumbnailUrl,
       fit: BoxFit.cover
  )
);
  • VideoData 라는 데이터 클래스를 통해서 썸네일 이미지를 설정해주고 있습니다.

  • AspectRatio 을 통해서 종횡비 비율을 조절하고 있습니다.

  • kTransparentImage placeHolder 를 통해서 fadeIn 되는 효과를 만들수 있습니다.

    import 'dart:typed_data';

    final Uint8List kTransparentImage = new Uint8List.fromList(<int>[
     0x89,
     0x50,
     0x4E,
     0x47,
     0x0D,
     0x0A,
     0x1A,
     0x0A,
     0x00,
     0x00,
     0x00,
     0x0D,
     0x49,
     0x48,
     0x44,
     0x52,
     0x00,
     0x00,
     0x00,
     0x01,
     0x00,
     0x00,
     0x00,
     0x01,
     0x08,
     0x06,
     0x00,
     0x00,
     0x00,
     0x1F,
     0x15,
     0xC4,
     0x89,
     0x00,
     0x00,
     0x00,
     0x0A,
     0x49,
     0x44,
     0x41,
     0x54,
     0x78,
     0x9C,
     0x63,
     0x00,
     0x01,
     0x00,
     0x00,
     0x05,
     0x00,
     0x01,
     0x0D,
     0x0A,
     0x2D,
     0xB4,
     0x00,
     0x00,
     0x00,
     0x00,
     0x49,
     0x45,
     0x4E,
     0x44,
     0xAE,
    ]);

하단 화면 구성하기

각각의 리스트 아이템에서 하단 화면을 구성해봅니다.

/// Video Content View builder at ListView Item Widget
 _myTubeVideoContent(VideoData video) => Container(
   alignment: Alignment.topCenter,
   margin: EdgeInsets.only(top: 10.0),
     //1
   child: Row(
     children: <Widget>[
       Container(
         margin: EdgeInsets.only(right: 10.0),
           //2
         decoration: BoxDecoration(
             shape: BoxShape.circle,
             color: BorderColor,
             image: DecorationImage(
               image: NetworkImage(video.getChannelData.getThumbnailUrl),
               fit: BoxFit.contain,
            )
        ),
         width: 32.0,
         height: 32.0,
      ),
         //3
       Flexible(
         child: Column(
           children: <Widget>[
             Container(
               alignment: Alignment.centerLeft,
               child: Text(
                   video.getTitle,
                   maxLines: 2,
                   style: TextStyle(
                       fontWeight: FontWeight.w400,
                       fontSize: 16.0,
                       color: TextColor
                  ),
                   overflow: TextOverflow.ellipsis,
                   textAlign: TextAlign.left),
            ),
               //4
             Container(
               alignment: Alignment.centerLeft,
               child: Text(
                 "${video.getChannelData.getName}",
                 maxLines: 2,
                 textAlign: TextAlign.left,
                 style: TextStyle(color: TextColor,),
              ),
            ),
          ],
           mainAxisAlignment: MainAxisAlignment.start,
        ),
         flex: 1,
      ),
         //5
       InkWell(
           child: Container(child: Icon(Icons.more_vert, size: 20.0, color: BorderColor),),
           onTap: _modalBottomSheet,
           borderRadius: BorderRadius.circular(20.0)
      )
    ],
  ),
);

  1. 하단은 프로필 써클 이미지와 기타 설명으로 구성되어 있습니다. 세로로 구성되어 지기 때문에 Row 로 설정합니다.

  2. 써클 이미지 구성은 BoxDecoration 위젯 안에 NetworkImage 를 가져옵니다.

    image: DecorationImage(
       image: NetworkImage(video.getChannelData.getThumbnailUrl),
       fit: BoxFit.contain,
    )
  3. 3,4번은 설명글을 구성합니다. Column 을 통해서 가로로 설정합니다.

    1. 마지막으로 InkWell을 통해 Ripple 효과를 줘서 더보기 버튼을 구성해봅니다.

더보기 클릭

_modalBottomSheet

바텀시트를 올려서 메뉴를 볼수 있습니다.

_modalBottomSheet() => showModalBottomSheet(
     context: context,
     builder: (builder) => Container(
       color: AppBackgroundColor,
       child: new Column(
         children: <Widget>[
           _bottomSheetListTile(Icons.not_interested, "관심 없음", () => debugPrint("관심 없음")),
           _bottomSheetListTile(Icons.access_time, "나중에 볼 동영상에 추가", () => debugPrint("나중에 볼 동영상에 추가")),
           _bottomSheetListTile(Icons.playlist_add, "재생목록에 추가", () => debugPrint("재생목록에 추가")),
           _bottomSheetListTile(Icons.share, "공유", () => debugPrint("공유")),
           _bottomSheetListTile(Icons.flag, "신고", () => debugPrint("신고")),
           Container(
             decoration: new BoxDecoration(
                 border: new Border(top: new BorderSide(color: BorderColor))
            ),
             child: _bottomSheetListTile(Icons.close, "취소", () => Navigator.pop(context)),
          )
        ],

      ),
    )
);

 /// list tile builder for BottomSheet
 _bottomSheetListTile(IconData icon, String text, Function onTap) =>
     ListTile(
         leading: Icon(icon, color: TextColor),
         title: Text(text, style: TextStyle(color: TextColor),),
         onTap: onTap
    );

마지막으로 비디오를 가져오는 부분을 추가해서 데이터를 갱신할 수 있도록 해줍니다.

Future<List<VideoData>> _getVideos() async {
   List<VideoData> videoDataList = new List<VideoData>();
   String dataURL = "https://www.googleapis.com/youtube/v3/videos?chart=mostpopular&regionCode=KR"
       "&maxResults=20&key=$youtubeApiKey&part=snippet,contentDetails,statistics,status";

   http.Response response = await http.get(dataURL);
   dynamic resBody = json.decode(response.body);
   List videosResData = resBody["items"];

   videosResData.forEach((item) => videoDataList.add(new VideoData(item)));

   for (var videoData in videoDataList) {
     String channelDataURL = "https://www.googleapis.com/youtube/v3/channels?key=$youtubeApiKey&part=snippet&id=${videoData.getOwnerChannelId}";

     http.Response channelResponse = await http.get(channelDataURL);
     dynamic channelResBody = json.decode(channelResponse.body);

     videoData.channelDataFromJson = channelResBody["items"][0];
  }

   return videoDataList;
}

전체 리스트를 가져온다음 각각 채널 정보를 다시 호출하기 때문에 비효율적으로 보이지만 스터디 목적이기 때문에 따로 리팩토링은 하지 않겠습니다.

JSON Decoding

미리 VideoData 와 ChannelData 클래스를 만들어서 Json decoding 해줄수 있습니다.

다른 방법도 있지만 여기선 기본적인 방법으로 설명합니다.

VideoDart.dart

import './ChannelData.dart';

class VideoData {
 String ownerChannelId;
 String thumbnailUrl;
 String title;
 String viewCount;
 String publishDate;
 ChannelData channelData;

 /// Video Data Constructor
 VideoData(Map videoJsonData) {
   this.ownerChannelId = videoJsonData["snippet"]["channelId"];
   this.thumbnailUrl = videoJsonData["snippet"]["thumbnails"]["high"]["url"];
   this.title = videoJsonData["snippet"]["title"];
   this.viewCount = videoJsonData["statistics"]["viewCount"];
   this.publishDate = videoJsonData["snippet"]["publishedAt"];
}

 /// Video Data unnamed Constructor
 VideoData.includeChannelData(Map videoJsonData, Map channelJsonData) {
   VideoData(videoJsonData);
   this.channelData = ChannelData(channelJsonData);
}

 set channelDataFromJson(Map channelJsonData) {
   this.channelData = new ChannelData(channelJsonData);
}

 get getOwnerChannelId => this.ownerChannelId;

 get getThumbnailUrl => this.thumbnailUrl;

 get getTitle => this.title;

 get getViewCount => this.viewCount;

 get getPublishedDate => this.publishDate;

 get getChannelData => this.channelData;
}

ChannelData.dart

class ChannelData {
 String name;
 String thumbnailUrl;

 ChannelData(Map channelJsonData) {
   this.name = channelJsonData["snippet"]["title"];
   this.thumbnailUrl = channelJsonData["snippet"]["thumbnails"]["default"]["url"];
}

 get getName => this.name;

 get getThumbnailUrl => this.thumbnailUrl;
}

전체적인 화면 트리는 다음과 같습니다.


여기까지 2주차 유튜브 리스트 개발이었습니다.

개선해야 될 내용

  • 탭이 변경시 계속 갱신되는 문제

  • 좀 더 효율적으로 API 를 호출하는 부분

그럼 다음 주차에는 카카오톡 같은 메신징 화면을 Firebase 연동해서 개발해보도록 하겠습니다.

참석해주셔서 감사합니다.

Flutter Simple TODO(할일관리) List

첫번째 수업에서는 DartFlutter를 살펴보고 ToDoList App 을 만들어보도록 할 것이다.

이 내용은 부산에서 진행된 Flutter 스터디 1주차를 정리한 내용입니다.

소스는 여기에서 받을수 있습니다.

Stateless 와 Stateful Widget

  • Stateless 는 동적으로 변하지 않는 위젯

  • Stateful 은 동적으로 변할수 있는 위젯

Stateful 구현은

  • createState

  • State 상속된 클래스 구현

모든 Widget은 build 함수를 필수

import 'package:flutter/material.dart';

void main() => runApp(new TodoApp());

class TodoApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
     title: 'Todo List',
     home: new TodoList()
  );
}
}

class TodoList extends StatefulWidget {
 @override
 createState() => new TodoListState();
}

class TodoListState extends State<TodoList> {
 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text('Todo List')
    )
  );
}
}

이제 Stateful widget이 된 상태이다. 그럼 TODO 아이템을 추가를 해볼까 한다.

Body 부분 추가

우선 floating action button 을 추가를 해보자.

floatingActionButton: new FloatingActionButton(
       onPressed: _addTodoItem,
       tooltip: '추가',
       child: new Icon(Icons.add)
    ),
  • _addTodoItem 은 클릭시 발생되는 함수를 뜻한다.

  • child는 해당 위젯에 자식으로 들어가서 다시 아이콘을 추가를 한다.

body 에 리스트를 추가를 해보자.

body: _buildTodoList(),
  • _buildTodoList

Widget _buildToDoList() {
   return new ListView.builder(
       itemBuilder: (context, index) {
           if(index < _todoItems.length) {
               return _buildToDoItem(_todoItems[index]);
          }
      }
  );
}

자식 아이템도 추가해준다.

  Widget _buildToDoItem(String todoText) {
   return new ListTile(
     title: Text(todoText),
  );
}

이벤트 등록

그리고 floating button 을 클릭시 아이템을 추가 하기 위해선 이벤트도 등록해줘야 하는데 그건 pressed 에서 해주면 된다.

  • 버튼 클릭시 아이템이 하나씩 추가된다.

onPressed: _addTodoItem,
  _addTodoItem() {
  setState(() {
    int index = _todoItems.length;
    _todoItems.add('Item_$index');
  });
}

전체 소스

import 'package:flutter/material.dart';

void main() => runApp(new TodoApp());

class TodoApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
       title: 'Todo List',
       home: new TodoList()
  );
}
}

class TodoList extends StatefulWidget {
 @override
 createState() => new TodoListState();
}

class TodoListState extends State<TodoList> {

 List<String> _todoItems = [];

 Widget _buildToDoList() {
   return new ListView.builder(
       itemBuilder: (context, index) {
         if(index < _todoItems.length) {
           return _buildToDoItem(_todoItems[index]);
        }
      }
  );
}

 Widget _buildToDoItem(String todoText) {
   return new ListTile(
     title: Text(todoText),
  );
}

 _addTodoItem() {
   setState(() {
     int index = _todoItems.length;
     _todoItems.add('Item_$index');
  });
}

 @override
 Widget build(BuildContext context) {
   return new Scaffold(
       appBar: new AppBar(
           title: new Text('Todo List')
      ),
       body: _buildToDoList(),
       floatingActionButton: new FloatingActionButton(
           onPressed: _addTodoItem,
           tooltip: '추가',
           child: new Icon(Icons.add)
      ),
  );
}
}

아이템 추가하는 화면 등록

이전까지는 한 화면에서 모든걸 진행했다. 이제 새로운 화면을 추가 해보자.

  1. 새로운 Screen 추가

    • 입력되는 문자열을 리턴하는 화면 생성

    import 'package:flutter/material.dart';

    class AddItemScreen extends StatelessWidget {
     @override
     Widget build(BuildContext context) {
       return Scaffold(
         appBar: AppBar(
           title: Text("할일 추가")
        ),
         body: TextField(
           autofocus: true,
           onSubmitted: (val) {
             Navigator.of(context).pop({'item': val});
          },
        )
      );
    }
    }

  2. 버튼 이벤트 함수에 라우팅 추가

    • 라우팅을 통해 이동된 후 값을 리턴받기를 원함

      onPressed: _navigatorAddItemScreen,
      _navigatorAddItemScreen() async {
         Map results = await Navigator.of(context).push(new MaterialPageRoute(
             builder: (BuildContext context) {
                 return AddItemScreen();
            },
        ));

         if(results != null && results.containsKey("item")) {
             _addTodoItem(results["item"]);
        }
      }

  1. 최종적으로 아이템을 리스트에 추가해서 화면 갱신

    _addTodoItem(String item) {
       setState(() {
      _todoItems.add(item);
    });
    }

할일 완료 처리

추가는 이전 시간까지 되는 걸 확인했다.

하지만 삭제는 어떻게 처리해야 할까?

방법은 여러가지가 있겠지만

  • 해당 리스트 항목에 버튼을 추가해서 클릭시 완료 처리한다.

리스트 아이템 마다 클릭시 이벤트를 먼저 등록

Widget _buildToDoItem(String todoText, int index) {
   return new ListTile(
       title: Text(todoText),
       onTap: () => _promptRemoveTodoItem(index),
  );
}

그런 다음 다이얼로그 띄워서 완료 할껀지 물어보자.

_promptRemoveTodoItem(int index) {
   showDialog(
       context: context,
       builder: (BuildContext context) {
         return new AlertDialog(
             title: new Text(' "${_todoItems[index]}" 완료 처리 하시겠습니까?'),
             actions: <Widget>[
               new FlatButton(
                   child: new Text('CANCEL'),
                   onPressed: () => Navigator.of(context).pop()
              ),
               new FlatButton(
                   child: new Text('완료'),
                   onPressed: () {
                     _removeTodoItem(index);
                     Navigator.of(context).pop();
                  }
              )
            ]
        );
      }
  );
}

최종적으로 state 함수를 통해서 완료를 진행

_removeTodoItem(int index) {
   setState(() => _todoItems.removeAt(index));
}

이상으로 오늘 Flutter 1주차 수업 내용 정리했습니다.

보통 이런 오류는 lazy 하게 네트워크 상태가 늦어지거나 할 경우 이미 위젯은 dispose가 된 상태인데 setState 를 호출 하는 경우로 보인다. 


해결법은 간단하게 onDispose 함수에서 변수 하나 선언해서 true / false 로 해주면 된다. 


라이프사이클에 대해서 궁금하면 여기 로 가서 한번 보고 오자.


우선 이렇게 변수를 지정 후에

@override
void dispose() {
super.dispose();
isDisposed = true;
}


변경하는 부분에서 

if(!isDisposed) {
setState(() {

....
});
}

이런식으로 하면 된다. 


팁으로 시작시 setState 를 하는 경우에는 this.mounted 로 체크가 가능하다.


이상으로 늦은 setState 오류에 대해서 살펴보았다. 



Android 사용시 자주 사용되는 게 같은 Task 내에서 onActivityForResult 를 사용하는 편이죠. 

물론 안드로이드내에서는 호출하는 쪽에서는 startActivityForResult 를 하고 다음 엑티비티에서 할일 하고 값을 다시 보낼때 onActivityResult 를 통해서 값을 받습니다.


관련 글은 http://liveonthekeyboard.tistory.com/150 보시면 됩니다. 


Flutter 내에서는 라우터를 이용을 할텐데요 그럴경우 주고 받는 걸 어떻게 하는 지 볼게요. 


우선 A -> B -> A 라고 가정하면

A 에서 라우팅을 

Map results =  await Navigator.of(context).push(new MaterialPageRoute<dynamic>(
    builder: (BuildContext context) {
      return new SelectionPage();
    },
  ));

라고 Map 형태로 Future 함수로 받습니다. await 도 넣어야 합니다. 


그리고 B 함수에서 pop 을 할때 값을 보내줍니다. 

void _selectItem(String value, BuildContext context) {
    Navigator.of(context).pop({'selection':value});
  }

이렇게 pop 할때 value 값을 보내줍니다.

그러면 받는 A 쪽에서는 마지막으로

Future _buttonTapped() async {
  Map results =  await Navigator.of(context).push(new MaterialPageRoute<dynamic>(
    builder: (BuildContext context) {
      return new SelectionPage();
    },
  ));
 
  if (results != null && results.containsKey('selection')) {
    setState(() {
      _selection = results['selection'];
    });
  }
}

이렇게 result 로 값을 체크해서 setState 할수가 있습니다. 


이상으로 flutter에서 onActivityForResult 기능 구현에 대해서 살펴보았습니다.





Flutter로 작업시 Android Native 모듈과 통신할 일이 생길 수 있다. 

그럴경우 android 폴더를 오른쪽 클릭 후 open Android module in Android Studio 로 열면 된다. 


기존에 Flutter에 라이버러리들을 안 붙인 상태이면 정상적으로 작동이 될것으로 보인다. 

하지만 다른 라이버러리들을 사용을 하고 있는 경우 하단의 오류 같은게 나올 수 있다. 

이 부분을 해결하기 위해선 플러터 오픈 채팅방에서 고수 두부랩님이 알려주신 팁으로

gradle 폴더안에 gradle-wrapper.properties 파일을 열어서 gradle 버전을 수정해주면 된다. 

distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip

그러면 정상적으로 빌드가 되는걸 볼수 있다. 


Flutter 화이팅!!

Flutter BuildContext 알아보기

https://flutterbyexample.com/build-context-class 보고 공부한 내용입니다.

Flutter 에서 BuildContext는 위젯 트리에 있는 위젯 위치라고 보면 된다.

각각의 위젯마다 고유의 BuildContext 가 존재한다.

class _MyHomePageState extends State<MyHomePage> {
  _MyHomePageState() {
    print(context.hashCode);
    // prints 2011
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Container(),
      floatingActionButton:
          new FloatingActionButton(onPressed: () => print(context.hashCode)),
          // prints 63
    );
  }
}

위에서 보듯이 두개의 context 의 hashcode가 다르게 보인다.

그래서 of 함수를 사용시 조심해서 사용해야 한다.

of 함수는 위젯트리에서 위,아래로 옮겨다니면서 해당 위젯을 참조한다.

대표적인 예로 Theme 를 들수 있는데

@override
Widget build(context) {
  return new Text('Hello, World',
    style: new TextStyle(color: Theme.of(context).primaryColor),
  );
}

이경우 Theme 라는 위젯을 참조해서 해당 컬러를 가져오는 경우이다.

다른 예로 snackbar 위젯을 들수 있다. snackbar 표시하는 데 있어서 Scaffold 위젯 build context가 필요하다. 그럼 이렇게 하는건 가능할까?

@override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text(widget.title),
        ),
        body: new Container(),
        /// Scaffold doesn't exist in this context here
        /// because the context thats passed into 'build'
        /// refers to the Widget 'above' this one in the tree,
        /// and the Scaffold doesn't exist above this exact build method
        ///
        /// This will throw an error:
        /// 'Scaffold.of() called with a context that does not contain a Scaffold.'
        floatingActionButton: new FloatingActionButton(onPressed: () {
          Scaffold.of(context).showSnackBar(
                new SnackBar(
                  content: new Text('SnackBar'),
                ),
              );
        }));
  }

이건 동작이 안될 것이다. 이유는 Scaffold.of(context).showSnackBar 에서 context가 Scaffold 이지 않기 때문이다. 그래서 이걸 해결 하기 위해서는 Builder 함수를 통해서 context 를 전달을 할 수 있다.

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Container(),
      /// Builders let you pass context
      /// from your *current* build method
      /// Directly to children returned in this build method
      ///
      /// The 'builder' property accepts a callback
      /// which can be treated exactly as a 'build' method on any
      /// widget
      floatingActionButton: new Builder(builder: (context) {
        return new FloatingActionButton(onPressed: () {
          Scaffold.of(context).showSnackBar(
                new SnackBar(
                  backgroundColor: Colors.blue,
                  content: new Text('SnackBar'),
                ),
              );
        });
      }),
    );
  }

이상으로 BuildContext를 공부해보았다.

Flutter 에서 내위치 가져오기

모바일에서 내 위치를 가져오는 건 필수 기능 중 하나!!

관련 플러그인은 https://pub.dartlang.org/packages/geolocator#-readme-tab- 에서 확인 가능하다.

그럼 설정은?

pubspec.yaml

geolocator: '^2.0.1'

Geolocator 라는 메인 클래스를 통해서 위치를 가져 올 수 있다.

Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.high)

리턴 값은 Future 비동기로 받는다. 그래서 then이나 await 로 사용이 가능하다.

Future<Position> getCurrentUserLocation() async {
    return Geolocator()
        .getCurrentPosition(desiredAccuracy: LocationAccuracy.high)
        .then((location) {      
      return location;
    });
  }

추후 이 함수(getCurrentUserLocation) 을 FutureBuilder 와 연계해서 UI 부분에서 핸들링 가능해 보인다.

이상으로 내 위치를 가져오는 방법에 대해서 알아보았다.

Flutter 개발자 오픈 채팅방 
https://open.kakao.com/o/gsshoXJ


Stateful Widget Lifecycle

https://flutterbyexample.com/stateful-widget-lifecycle/#6-didupdatewidget 을 공부해서 요약한 글입니다.
자세한 내용은 원문확인 부탁드립니다.

StatefulWidget을 만들때 State 라는 오브젝트를 만든다. 이 오브젝트는 위젯이 동작하는 동안 mutable state를 뜻한다.

state의 컨셉은 두가지로 정의되어 진다.

  1. 위젯에 의해서 사용되어지는 데이터는 변할수 있다.
  2. 위젯이 빌드 될때 데이터들을 동기적으로 읽을수 없다. 모든 State 들은 build 함수가 호출될 때까지 설정되어야 한다.

StatefulWidget 와 State 는 클래스를 분리를 한 이유는?

성능때문이다.

1. createState()

Framework가 StatefulWidget을 만들경우 createState() 가 즉시 호출된다.

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

2. mounted is true

createState 함수는 buildContext 가 state 에 할당되게 된다. BuildContext 는 위젯트리를 단순하기 위해 필요한 것이다. (좀 더 공부 필요)

모든 위젯은 this.mounted : bool 속성을 가지고 있다. 즉 buildContext 가 할달될 때 this.mounted 가 true 로 리턴된다.

위젯이 unmounted 일 경우에는 setState 를 부를 경우 에러가 발생될 수 있습니다.

이 속성은 중요하다고 볼 수 있습니다. setState() 함수를 호출시 만약 비동기로 시간이 지연되는 경우 widget이 마운트가 해제될 수 있기 때문에 if(mounted) {...} 을 사용해서 setState() 를 하는 걸 추천하다.

3. initState()

widget이 만들어지고 생성자 후에 처음 메소드 실행할때 이 함수가 실행된다. super.initState() 를 필수적으로 호출해야 한다.

이 함수를 호출할 때 최고의 선택은

  • 위젯 인스턴스를 만들기 위해 BuildContext 를 이용해서 데이터들을 초기화를 할 경우
  • 위젯 트리에서 부모 위젯의 속성을 초기화할때
  • 스트림을 구독(리스닝) 할 때
@override
initState() {
  super.initState();
  // Add listeners to this class
  cartItemStream.listen((data) {
    _updateWidget(data);
  });
}

4. didChangeDependencies()

이 함수는 initState를 호출한 뒤에 실행된다. 또한 이 위젯은 데이터에 의존하는 객체가 호출될 때마다 호출됩니다. InheritedWidget 에 의존하는 경우 업데이틀 합니다.

build 는 항상 didChangeDependencies 호출 후에 실행되는 점을 명심하자. 잘 사용하지 않지만 BuildContext.inheritFromWidgetOfExactType 을 호출하기 위해서 첫단계 시작점이다. 이건 데이터를 상속받는 위젯의 변경사항을 리스닝 하게 만든다.

이 함수는 상속된 위젯이 업데이트를 하는 경우 당신이 네트워크 호출이라던가 그런 코스트가 많이 드는 액션을 할 때 유용하다.

5. build()

필수적으로 오버라이딩해서 구현해야되는 함수이다. 위젯을 리턴한다.

6. didUpdateWidget(Widget oldWidget)

만약 부모 위젯이 업데이트가 되거나 이 위젯이 다시 만들 경우 이 함수가 호출되고 같은 runtimeType (이건 또 뭐징?) 을 함께 다시 만들어진다.

Flutter는 state를 재사용한다. 이 경우 initState 에서 값을 초기화를 해야 할수 있다.

build 함수가 변경 할 수 있는 스트림이나 다른 개체를 사용하는 경우 이전 개체에서 구독을 취소하고 didUpdateWidget 에서 새 인스턴스에 다시 구독합니다.

이 메소드는 기본적으로 상태와 관련된 위젯이 rebuild 될 것으로 예상 되는 경우 initState 를 대체합니다.

@override
void didUpdateWidget(Widget oldWidget) {
  if (oldWidget.importantProperty != widget.importantProperty) {
    _init();
  }
}

8. deactivate()

9. dispose()

영구적인 State Object가 삭제될때 호출된다. 이 함수는 주로 Stream 이나 애니메이션 을 해제시 사용된다.

10. mounted is false

state object가 다시 마운팅 되지 않을 경우 setState 호출시 에러를 리턴한다.

Transform 활용

Stream 사용시 주어진 값들을 이용해서 가공할 일이 생길수 있다. 
그럴경우 Transform 을 활용해서 가능하다.

우선 예제를 보면서 살펴보자.

  • 이메일을 넣는 경우 유효성 체크 하는 validate stream 을 만든다고 가정하자.
  • '@' 포함되는 경우 정상, 없는 경우 오류로 리턴하는 아주 간단한 예제

우선 bloc 패턴을 이용할 예정이다.

  • bloc 생성하자.
  • StreamController 생성
  • sink 정의
  • transform 정의
...

final bloc = new Bloc();

...

class Bloc {
    final emailController = StreamController<String>();
    
    Function(String) get changeEmail => emailController.sink.add;
    
    Stream<String> get email => emailController.stream.transform(validateEmail);
    
    final validateEmail = 
        StreamTransformer<String, String>.fromHandlers(handleData: (email, sink) {
        if(email.contains('@')){
            sink.add(email); //정상
        } else {
            sink.addError('Enter a valid email'); //실패
        }        
        });
}
  • Main 함수 정의
  bloc.email.listen((value) {
    print(value);
  });
  
  bloc.changeEmail('test'); // => Error
  bloc.changeEmail('test@test.net'); // => 정상

전체 소스는 다음과 같다.

import 'dart:async';

void main() {
  final bloc = new Bloc();
    
  bloc.email.listen((value) {
    print(value);
  });
  
  bloc.changeEmail('test@test.net');
}

class Bloc {
  final emailController = StreamController<String>();
    
  Function(String) get changeEmail => emailController.sink.add;
  
  Stream<String> get email => emailController.stream.transform(validateEmail);
  
  final validateEmail = 
    StreamTransformer<String, String>.fromHandlers(handleData: (email, sink) {
      if(email.contains('@')){
       sink.add(email); 
      } else {
        sink.addError('Enter a valid email');
      }        
    });
  
}


Relative Programming - BLoC 패턴

아래 내용은 https://www.didierboelens.com/2018/08/reactive-programming---streams---bloc/ 을 공부하고 요약해놓은 글입니다.

BLoC Pattern 은 구글 개발자 Paolo Soares 와 Cong Hui 에 의해서 디자인 되었다. 그리고 처음 발표된 건 2018년 DartConf 이다.

관련 영상

BLoC = Business Logic Component.

  • 하나 또는 여러개의 BLoC가 존재할수 있다.
  • Presentation Layer(UI 부분)에서 가능하한 제거되며 로직부분만 분리해서 테스트가 용이함
  • 플랫폼 종속적이지 않다.
  • 환경에 종속적이지 않다.

BLoC 패턴은 스트림을 이용해서 만들어진다.

  • 위젯은 Sinks 를 통해서 Bloc에 이벤트를 보낸다.
  • 위젯은 Bloc에 있는 스트림을 통해서 결과를 통지 받는다.
  • BLoC에 의해서 비즈니스 로직들은 재사용이 가능하다.
  • UI 쪽에서는 BLoC 에 대해서 알 필요가 없다.

그럼 적용은 어떻게 하는 걸까?

기본적으로 앱 플젝 새로 설치시 나오는 카운트 올리는 걸 수정해보자.

Widget에 BlocProvider를 만들어준다.

home: BlocProvider<IncrementBloc>(
  bloc: IncrementBloc(),
  child: CounterPage(),
),

Bloc 인스턴스를 생성한다.

final IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

리스닝하고 싶은 위젯에 등록한다.

 body: Center(
    child: StreamBuilder<int>(
      stream: bloc.outCounter,
      initialData: 0,
      builder: (BuildContext context, AsyncSnapshot<int> snapshot){
        return Text('You hit me: ${snapshot.data} times');
      }
    ),
  ),

그리고 BLoC 파일을 작성해준다.

class IncrementBloc implements BlocBase {
  int _counter;

  //
  // Stream to handle the counter
  //
  StreamController<int> _counterController = StreamController<int>();
  StreamSink<int> get _inAdd => _counterController.sink;
  Stream<int> get outCounter => _counterController.stream;

  //
  // Stream to handle the action on the counter
  //
  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  //
  // Constructor
  //
  IncrementBloc(){
    _counter = 0;
    _actionController.stream
                     .listen(_handleLogic);
  }

  void dispose(){
    _actionController.close();
    _counterController.close();
  }

  void _handleLogic(data){
    _counter = _counter + 1;
    _inAdd.add(_counter);
  }
}

카운트를 올리는 이벤트를 스트림에 보낸다. (인풋)

bloc.incrementCounter.add(null);

Provider도 작성해준다.

// Generic Interface for all BLoCs
abstract class BlocBase {
  void dispose();
}

// Generic BLoC provider
class BlocProvider<T extends BlocBase> extends StatefulWidget {
  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

전체 소스는 다음과 같다.

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
        title: 'Streams Demo',
        theme: new ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: BlocProvider<IncrementBloc>(
          bloc: IncrementBloc(),
          child: CounterPage(),
        ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(
          stream: bloc.outCounter,
          initialData: 0,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          bloc.incrementCounter.add(null);
        },
      ),
    );
  }
}

class IncrementBloc implements BlocBase {
  int _counter;

  //
  // Stream to handle the counter
  //
  StreamController<int> _counterController = StreamController<int>();
  StreamSink<int> get _inAdd => _counterController.sink;
  Stream<int> get outCounter => _counterController.stream;

  //
  // Stream to handle the action on the counter
  //
  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  //
  // Constructor
  //
  IncrementBloc(){
    _counter = 0;
    _actionController.stream
                     .listen(_handleLogic);
  }

  void dispose(){
    _actionController.close();
    _counterController.close();
  }

  void _handleLogic(data){
    _counter = _counter + 1;
    _inAdd.add(_counter);
  }
}

///
// Generic Interface for all BLoCs
abstract class BlocBase {
  void dispose();
}

// Generic BLoC provider
class BlocProvider<T extends BlocBase> extends StatefulWidget {
  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

만약 여러개의 위젯에 BLoC을 붙일수 있나?

child 형태로 붙일수 있다.

void main() => runApp(
  BlocProvider<ApplicationBloc>(
    bloc: ApplicationBloc(),
    child: MyApp(),
  )
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    return MaterialApp(
      title: 'Streams Demo',
      home: BlocProvider<IncrementBloc>(
        bloc: IncrementBloc(),
        child: CounterPage(),
      ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    final IncrementBloc counterBloc = BlocProvider.of<IncrementBloc>(context);
    final ApplicationBloc appBloc = BlocProvider.of<ApplicationBloc>(context);

    ...
  }
}



Reactive Programming part 1 - Stream

스트림이란 무엇인가?

스트림을 이해하기 위해선 파이프를 상상해야 한다.

뭔가를 입력을 했다면 그것이 파이프 안에서 흘러서 다른 출구쪽으로 배출 되는 걸 뜻한다.

그럼 Flutter 을 대입해보자면

  1. 파이프는 Stream 이라고 부른다.
  2. Stream을 제어하기위해 우리는 자주 StreamController 를 이용한다.
  3. Stream 입력하기 하기 위해 StreamSink 를 사용한다.

입력 할수 있는 것들은 어떤 것이든 가능하다.

데이터, 오브젝트, map, 에러코드, 이벤트, 심지어 다른 스트림도 가능하다.

배출은 어떻게 확인하나?

일단 입력이 되면 Rx 처럼 subscribe 를 통해서 받을 수 있다. 이 과정을 flutter에서는 listen 한다고 말한다.

**즉 Stream을 listen 하면 StreamSubscription 오브젝트를 받을수 있는 것이다. **

StreamSubscription이 할수 있는 것들은

  • Stop listening
  • pause
  • resume

그럼 스트림은 단순히 파이프 역할만 하는 건가?

스트림은 또한 흐르는 데이터들을 **process 를 허용하고 있다. **

즉 **StreamTransformer **를 통해서 데이터를 조작하고 그것을 다시 배출 하는 과정을 거친다.

**StreamTransformer 은 프로세싱하는 데 어떠한 타입 형태로도 사용 가능하다. **

예를 들어 Filtering, regrouping, modification 등등

Streams 의 종류

Single-subscription Streams

  • 하나의 리스너를 통해서만 값을 배출 받을수 있다. 그 후 리스너들은 취소될 것이다.
import 'dart:async';

void main() {
  //step 1 StreamController 등록
  final StreamController ctrl = StreamController();

  //step 2 listen 등록
  final StreamSubscription subscription = ctrl.stream.listen((data) => print('$data'));

  //step 3 streamcontroller 에 sink를 통해 입력
  ctrl.sink.add('Hello Stream');
  ctrl.sink.add(1004);
  ctrl.sink.add({'1':100,'2':'200'});

  //사용 종류 후 close()
  ctrl.close();
}

Broadcast Streams

  • 다수의 리스너에 동시에 배출을 할 수 있다.
import 'dart:async';

void main() {
  final StreamController ctrl = StreamController<int>.broadcast();

  ctrl.stream
    .where((value) => value % 2 == 0) //StreamTransformer 적용함
    .listen((data) => print('$data'));

  for(int i=0; i< 10; i++) {
    ctrl.sink.add(i);
  }

  ctrl.close();
}

그러면 Flutter에서는 연동을 어떻게 할까?

Flutter Widget에서는 StreamBuilder라고 지원해준다.

StreamBuilder<T>(
    key: ...optional, the unique ID of this Widget...
    stream: ...the stream to listen to...
    initialData: ...any initial data, in case the stream would initially be empty...
    builder: (BuildContext context, AsyncSnapshot<T> snapshot){
        if (snapshot.hasData){
            return ...the Widget to be built based on snapshot.data
        }
        return ...the Widget to be built if no data is available
    },
)

StreamBuilder를 이용해서 Counter 를 1씩 올리는 예제

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

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;
  //Step1 StreamController 선언
  final StreamController<int> _streamController = StreamController<int>();

  @override
  void dispose(){
    _streamController.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(
          //Step2 Stream Listener 등록
          stream: _streamController.stream,
          initialData: _counter,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          //StreamController에 데이터 입력
          _streamController.sink.add(++_counter);
        },
      ),
    );
  }
}

그럼 다음 시간에는 Bloc 패턴을 배워보는 시간을 가져보자.

참고 내용 : https://www.didierboelens.com/2018/08/reactive-programming---streams---bloc/

Container 레이아웃 치트시트

https://medium.com/jlouage/container-de5b0d3ad184


Flutter Layouts Walkthrough: Row, Column, Stack, Expanded, Padding

https://medium.com/coding-with-flutter/flutter-layouts-walkthrough-row-column-stack-expanded-padding-5ed64e8f9584


Flutter Layout Cheat Sheet

https://proandroiddev.com/flutter-layout-cheat-sheet-5363348d037e


레이아웃 Flutter 영상 #1

https://codingwithflutter.com/

Flutter App Tutorial #1

이 글의 모든 내용은 udemy 강좌를 공부하고 따라해본 내용입니다.

https://www.udemy.com/dart-and-flutter-the-complete-developers-guide

완성 된 형태이다.

첫 화면에서 오른쪽 아래 플로팅 버튼 클릭시 오른쪽 화면처럼 이미지와 글자를 가진 뷰가 하나씩 추가되는 형태이다.

소스는 http json 을 통해서 가져왔다.

소스는 https://github.com/bear2u/flutter-sample-pic.git 에서 확인가능하다. 


main.dart 시작

flutter는 첫진입은 일반적으로 lib/main.dart 로 시작된다.

  • 매트리얼 테마를 사용하기 위해서 import를 한다.
  • runApp 은 메테리얼 라이버러리를 통해 실행된다.
  • App 은 따로 빼서 진행한다.
# lib/main.dart

import 'package:flutter/material.dart';
import 'src/app.dart';

void main() {  
  runApp(App());
}

lib에 src 폴더를 만들고 app.dart 를 생성한다.

  • StatefulWidget을 통해서 서버로 들어오는 아이템들을 리스트로 보내주고 있다.
  • Scaffold 는 Floating Action Button 과 타이틀바 및 바텀바등을 기본적으로 가지는 위젯이다.
  • await 는 비동기방식을 동기형태로 바꿔주고 네트워크 결과값을 가져온다.
  • 그리고 미리 정의된 ImageModel.FromJson 을 통해 디코딩을 거친다.
  • 마지막으로 setState()을 통해 images 라는 리스트에 아이템을 추가를 해서 state 변경을 한다.
# lib/src/app.dart

import 'package:flutter/material.dart';
import 'package:http/http.dart' show get;
import 'models/image_model.dart';
import 'dart:convert';
import 'widgets/image_list.dart';

class App extends StatefulWidget {
  @override
    State<StatefulWidget> createState() {
      // TODO: implement createState
      return AppState();
    }
}

class AppState extends State<App> {
  int counter = 0;
  List<ImageModel> images = [];

  void fetchImage() async {
    counter++;
    var response = await get('https://jsonplaceholder.typicode.com/photos/$counter');
    var imageModel = ImageModel.fromJson(json.decode(response.body));

    setState(() {
      images.add(imageModel);
    });

  }

  Widget build(context) {
    return MaterialApp(
      home: Scaffold(
        body: ImageList(images),
        appBar: AppBar(
          title: Text("Lets see some images!"),
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), //Widget 추가
          onPressed: fetchImage,
        ),
      ),
    );
  }
}

// Must define a 'build' method that returns
// the widgets that *this* widget will show

모델 정의

서버로 부터 가져온 JSON 결과값을 dart 내 오브젝트 형태로 변경할수 있도록 할수 있다.

# lib/src/models/image_model.dart

/**
 * JSON Decoding Model
 */
class ImageModel {
  int id;
  String url;
  String title;

  ImageModel(this.id, this.url, this.title);

  ImageModel.fromJson(Map<String, dynamic> parsedJson) {
    id = parsedJson['id'];
    url = parsedJson['url'];
    title = parsedJson['title'];
  }

  // 이렇게도 정의할 수 있다.
  // ImageModel.fromJson(Map<String, dynamic> parsedJson)
  // : id = parsedJson['id'],
  //   url = parsedJson['url'],
  //   title = parsedJson['title'];

  @override
    String toString() {
      // TODO: implement toString
      return '$id,$url,$title';
    }
}

리스트 뷰 정의

  • ListView 의 경우 React상에 바보뷰라고 하는 상태가 전혀 없고 값만 가지고 핸들링을 한다.
  • final 사용을 통해 값이 immutable 한지 체크를 할 수 있다. (바뀌면 안된다)
  • 그리고 위젯을 함수로 빼서 사용가능하다. (buildImage)
  • border의 경우 decoration 으로 가능하다.
  • 생성자로 값을 받아서 그 값을 뷰에 세팅해주고 있다.
import 'package:flutter/material.dart';
import '../models/image_model.dart';

class ImageList extends StatelessWidget {
  final List<ImageModel> images;

  ImageList(this.images);

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
        itemCount: images.length,
        itemBuilder: (context, int index) {
          return buildImage(images[index]);
        });
  }

  Widget buildImage(ImageModel image) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
      ),
      padding: EdgeInsets.all(20.0),
      margin: EdgeInsets.all(20.0),
      child: Column(
        children: <Widget>[
          Padding(
            child: Image.network(image.url),
            padding: EdgeInsets.only(bottom: 10.0),
          ),        
          Text(image.title)
        ]),
    );
  }
}

이로써 아주 간단한 앱을 만들어보았다.

큰 흐름을 이해 할수 있는 소스였다.


Await / Future 비동기 함수 호출

Http 로 통신할때 일반적으로 비동기로 호출을 하는 편이다. 비동기란 함수 호출시 블럭이 되지 않고 한번 다 훝고 콜백식으로 완료가 됐을 경우 다시 결과를 받는 경우를 말한다.

만약 아래와 같은 코드에서 실행시 결과값은 마지막에 'I got the data' 로 떨어지게 된다.

그리고 자바스크립트에서 Promise 형태와 비슷한 걸 볼수 있다.

import 'dart:async';

void main () {
  print('1. start to fetch data');
  
  get('http://weasds.com')
    .then((data) {
      print(data);
    });
  
  print('3. finish call to fetch the data');
}

Future<String> get(String url) {
  return new Future.delayed(new Duration(seconds: 3), () {
    return '2. I got the data!!';
  });
}
...........
1. start to fetch data
3. finish call to fetch the data
2. I got the data!!
// 순서가 1-3-2 로 되는 걸 볼수 있다.

그리고 Promise말고도 await로 비동기를 기다렸다가 가져오는 방법도 지원한다.

import 'dart:async';

void main () async {
  print('1. start to fetch data');
  
  print(await get('http://weasds.com'));
  
  print('3. finish call to fetch the data');
}

Future<String> get(String url) {
  return new Future.delayed(new Duration(seconds: 3), () {
    return '2. I got the data!!';
  });
}

.........

1. start to fetch data
2. I got the data!! //약 3초뒤 나온다.
3. finish call to fetch the data

참고



JSON 핸들러

서버와 통신시 제일 자주 사용되는 형태가 JSON 이다. 그럼 다트에서는 어떻게 다룰수 있는 지 살펴보겠다.

테스트 환경은 https://dartpad.dartlang.org/ 에서 진행한다.

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  print(parsedJson);
  print(parsedJson['url']);

}

.............
{url: http://blah.jpg, id: 1}
http://blah.jpg

그리고 모델 클래스를 만들어서 매핑하고자 할때에는 값을 키값을 줘서 값을 가져온다음 set 을 통해서 모델을 만들수 있다.

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  var imageModel = new ImageModel(
            parsedJson['id'], 
            parsedJson['url']
  );

  print(imageModel);

}

class ImageModel {
  int id;
  String url;  

  ImageModel(this.id, this.url);

  String toString() {
    return '$id,$url';
  }
}

하지만 만약 여러개 속성을 파싱할때 이 방식은 좀 불편하다.

그래서 fromJson 함수를 제공해준다. 이 함수는 JSON 을 미리 정의된 값을 매핑해주는 역할을 한다.

그럼 코드를 보면 이해가 빠를거라 본다.

관련 링크

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  var imageModel = new ImageModel.fromJson(parsedJson);

  print(imageModel);

}

class ImageModel {
  int id;
  String url;  

  ImageModel(this.id, this.url);

  ImageModel.fromJson(Map<String, dynamic> parsedJson) 
    : id = parsedJson['id'],
      url = parsedJson['url'];


  String toString() {
    return '$id,$