29. 플러터 : 앨범 + 카메라

수업도 하고 창업 준비도 하고..
그리고 놀기도 해야 하고..
하루가 길지 않음을 새삼 느끼게 된다.

플러터를 사용하는 기본적인 코드조차 머리 속에 남아있지 않음을 느끼며
이건 빨리 우재 시켜야겠다는 생각이 든다.
딥러닝 모델을 플러터에 얹으려니
카메라 사용을 먼저 처리하지 않으면 아무 것도 할 수 없어서
어쩔 수 없이 시간을 내야 했다.
머리가 너무 아프다.

앱을 구동하면 나타나는 화면.
사진도 카메라도 없기 때문에 'EMPTY' 문자열을 표시했다.
갤러리에서 사진을 선택해서 가져오면 오른쪽과 같은 모양이 된다.
이때 안드로이드 에뮬레이터 또는 아이폰 시뮬레이터에서 가져와야 하기 때문에
갤러리 또는 앨범에 사진이 들어있어야 한다.
에뮬레이터에 사진을 추가하는 방법은 다른 사이트를 참고하도록 한다.

카메라 버튼을 선택하면 촬영 모드로 들어가고
촬영한 이후에는 갤러리에서 가져오는 것과 동일하다.
실물이 훨씬 낫다.

잘 봤겠지?
이제부터 앞에서 본 것처럼 훌륭한 앱을 직접 만들어 보자.
약간 업된 것 같은 이유는
앱을 코딩했을 때는 도서관이었지만
이 글을 쓰는 곳은 바닷가이기 때문이다.
서핑하러 인구해변에 왔는데
역스웰이래나 뭐래나.. 그냥 파도가 없을 뿐이다.

프로젝트를 생성하고
pubspec.yaml 파일을 열어서 image_picker 모듈을 가져온다.
dependencies 영역 마지막에 'image_picker:'라고 쓴다.
버전을 생략하면 최신 버전을 가져온다는 뜻이다.

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2

  # 최신 버전 사용하도록 버전 명시하지 않음
  image_picker:

 

main.dart 파일을 작성한다.
앞서 봤던 것처럼 처음에 문자열을 표시했다가
사진을 선택하면 해당 사진으로 바꾼다.

우리가 할건 없고 ImagePicker 라이브러리에
갤러리(ImageSource.gallery) 또는 카메라(ImageSource.camera)를 전달하기만 하면 된다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';    // 앨범과 카메라 모두에 대해 작동


class ImageAndCamera extends StatefulWidget {
    @override
    ImageAndCameraState createState() => ImageAndCameraState();
}


class ImageAndCameraState extends State<ImageAndCamera> {
    // 파일 경로 문자열은 카메라에서는 에러 발생했다. image_picker 모듈에서 File 객체 반환.
    File mPhoto;
    
    @override
    Widget build(BuildContext context) {
        Widget photo = (mPhoto != null) ? Image.file(mPhoto) : Text('EMPTY');

        return Container(
            child: Column(
                children: <Widget>[
                    // 버튼을 제외한 영역의 가운데 출력
                    Expanded(
                        child: Center(child: photo),
                    ),
                    Row(
                        children: <Widget>[
                            RaisedButton(
                                child: Text('앨범'),
                                onPressed: () => onPhoto(ImageSource.gallery),  // 앨범에서 선택
                            ),
                            RaisedButton(
                                child: Text('카메라'),
                                onPressed: () => onPhoto(ImageSource.camera),   // 사진 찍기
                            ),
                        ],
                        mainAxisAlignment: MainAxisAlignment.center,
                    ),
                ],
                // 화면 하단에 배치
                mainAxisAlignment: MainAxisAlignment.end,
            ),
        );
    }

    // 앨범과 카메라 양쪽에서 호출. ImageSource.gallery와 ImageSource.camera 두 가지밖에 없다.
    void onPhoto(ImageSource source) async {
        // await 키워드 때문에 setState 안에서 호출할 수 없다.
        // pickImage 함수 외에 pickVideo 함수가 더 있다.
        File f = await ImagePicker.pickImage(source: source);
        setState(() => mPhoto = f);
    }
}

void main() {
    runApp(MaterialApp(
        home: MaterialApp(
            title: '앨범 + 카메라',
            home: Scaffold(
                appBar: AppBar(title: Text('앨범 + 카메라'),),
                body: ImageAndCamera(),
            )
        )
    ));
}

28. 플러터 : 서버/클라이언트 연동 (3)

이전 예제에서는 클라이언트에 해당하는 앱은 플러터로 구현하고
서버는 만들어져 있는 것을 가져다 사용했다.
드디어 서버를 만들 차례가 됐다는 것이다.

이번 예제는 직접 만든 서버로부터 다양한 형태의 데이터를 수신한다.
데이터의 종류에는 문자열, 사진, 배열이 있고
스마트폰 앱에서 해당 버튼을 누르면 연결된 데이터만 보여주는 방식이다.

서버는 파이썬의 플라스크(flask)로 구현했다.
아쉽지만 여기는 파이썬하고는 상관 없기 때문에 이 부분은 간단하게 넘어가기로 한다.
그래도 우재는 할 수 있잖아?
아빠한테 다 배웠으니까..

로컬에서 서버를 구동했더니 로컬 호스트(127.0.0.1)를 안드로이드 에뮬레이터에서 인식을 못한다.
할 수 없이 파이썬애니웨어(pythonanywhere.com)의 도움을 받는다.
가입하면 무료로 웹 서버를 하나 구동할 수 있다. 지금처럼.

from flask import Flask
import json

app = Flask(__name__)


# 루트 인터페이스. 서버 주소(127.0.0.1:5000)의 루트 폴더.
@app.route('/')
def index():
return 'home. nothing.'


# 루트 아래의 string 폴더에 접근할 때 보여줄 내용
@app.route('/string')
def send_string():
return '취미 : 서핑, 스노보드, 배드민턴'


# 사진은 앱에서 파일에 직접 접근하기 때문에 이름만 알려주면 된다.
@app.route('/image')
def send_image():
return 'book.jpg'


# 배열이나 사전 등의 프로그래밍 언어 객체는 문자열로 변환해서 전달해야 하고
# json 또는 xml 등을 사용하게 된다. 여기서는 json 사용.
@app.route('/array')
def send_array():
items = [
{'id': 12, 'content': '세상은 호락호락하지 않다. 괜찮다. 나도 호락호락하지 않다.'},
{'id': 45, 'content': '공부를 많이 하면 공부가 늘고 걱정을 많이 하면 걱정이 는다.'},
{'id': 78, 'content': '참아야 한다고 배워 힘든 걸 참고 행복도 참게 되었다.'},
]
return json.dumps(items)


# 파이썬애니웨어(www.pythonanywhere.com)에서 구동할 때는
# 아래 코드를 사용해선 안 된다. 로컬에서 구동할 때만 사용한다.
# if __name__ == '__main__':
# app.run(debug=True)

파이썬애니웨어의 사용법은 해당 사이트에 가서 확인하자.

어려울 것이 전혀 없다.
프로젝트를 구성한 후에 static 폴더를 만들어서 'book.jpg' 파일을 하나 넣어둬야 아래 코드를 구동할 수 있다.

파이썬애니웨어 구성은 아래와 같은데.. 볼 건 없다.
왼쪽에 static과 templates 폴더가 있는데..
이번 예제에서는 templates 폴더는 사용하지 않는다.


이번에 만들 앱의 스크린샷이다.
화면이 4가지라서 캡쳐해서 깨끗하게 잘라내는 것도 힘들다.
첫 번째 사진은 서버로부터 아무 것도 가져오지 않았을 때를 가리킨다.


코드가 너무 길다.
150줄 정도 나왔는데.. 너무 많은 걸 보여주려고 한 것일까?

상수를 정의하기 위해 enum 클래스를 사용헀다. 
enum을 사용하지 않으면 당장은 편하지만 향후 불편할 수밖에 없다.
그리고 다트 문법도 익혀야 하는 관계로, 공부 차원에서 사용했다.

역시나 우재는 아래 코드 없이 화면만 보고 만들어 볼 수 있겠지?
파이썬애니웨어는 우재도 사용하고 있고.. 아빠보다 더 잘 하니까.
시간은 걸리겠지만..
고생하고 안 될 때만 살짝 보고. 아빠도 이번 코드는 진짜 오래 걸렸어.
아빠보다 빨리 하는 건 아니겠지?

참, 스냅샷의 connectionState 속성 사용하는 거 중요하니까.. 자세히 보고.
서버 구성은 클라이언트와 상관 없기 때문에 클라이언트 부분의 코드는 똑같을 수밖에 없다.
다만 서버쪽에서 어떤 방식으로 데이터를 전달하는지만 명확하게 하면 끝.
여기서는 사진을 제외하고는 모두 문자열로 처리.
수신 이후에 원하는 형태로 변환해서 사용하는 방식을 사용했다.


import 'dart:async';
import 'dart:convert'; // json 관련

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

// 수신 데이터의 종류를 상수로 설정
enum DataKind { NONE, STRING, IMAGE, ARRAY }

// 3가지 형태의 문자열 수신 : 단순 문자열, json 문자열, 이미지 주소 문자열.
Future<String> fetchString(DataKind kind) async {
if(kind == DataKind.NONE)
return '';

print(kind.toString());
print(kind.toString().split('.')[1]);
// 첫 번째 항목은 NONE. 비워두긴 했지만 루트(/)를 가리킨다는 뜻은 아니고, 사용 안함을 의미한다.
final details = ['', 'string', 'image', 'array'];
final urlPath = 'http://applekoong.pythonanywhere.com/' + details[kind.index];

// 아래처럼 구하면 details 배열과 동기화시키지 않아도 된다.
// 코드는 좀 복잡해지지만 업그레이드에서 유리할 수 있다.
// DataKind.IMAGE 상수를 toString 함수에 전달하면 'DataKind.IMAGE'가 나온다.
// final detail = kind.toString().split('.')[1].toLowerCase()
// final urlPath = 'http://applekoong.pythonanywhere.com/' + detail;

final response = await http.get(urlPath);

if (response.statusCode == 200)
return response.body;

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatefulWidget {
@override
State createState() {
return MyAppState();
}
}

class MyAppState extends State<MyApp> {
DataKind mKind = DataKind.NONE;

Widget makeChild(String str) {
switch (mKind) {
case DataKind.NONE:
return Placeholder(); // 아무 것도 수신하지 않았을 때 사용
case DataKind.STRING:
return Text(str, style: TextStyle(fontSize: 25),);
case DataKind.IMAGE:
return Image.network('http://applekoong.pythonanywhere.com/static/' + str);
default:
// str : 사전을 담고있는 배열 형태의 json 문자열.
final items = json.decode(str);
final textStyle = TextStyle(fontSize: 21, fontWeight: FontWeight.bold);

List<Container> widgets = [];
for(var item in items) {
widgets.add(
// 여백을 통해 위젯을 보기좋게 배치하기 위해 Container 사용
Container(
child: Column(
children: <Widget>[
Text(item['id'].toString(), style: textStyle),
Text(item['content'], style: textStyle, softWrap: true),
],
),
padding: EdgeInsets.all(20.0),
),
);
}
return Column(children: widgets);
}
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
FutureBuilder(
future: fetchString(mKind),
builder: (context, snapshot) {
// 이전 예제에서는 hasData를 사용했는데, 이전 호출의 결과가 남아있을 수 있다.
// builder에 전달되는 함수는 무조건 2회 호출된다. 처음에는 waiting으로, 두 번째에는 done으로.
// done이라면 완료되었다는 뜻이고 에러가 발생했을 수도 있다.
if (snapshot.connectionState == ConnectionState.done) {
// 나머지 공간 전체를 사용하는데, 전체 공간을 사용하지 않는 위젯이라면 가운데에 배치.
return Expanded(
child: Center(
child: makeChild(snapshot.data),
),
);
}
else if (snapshot.hasError) {
// 에러 메시지는 굳이 화면 가운데에 놓을 필요가 없다.
return Text('${snapshot.error}');
}

// 인디케이터가 다른 위젯들처럼 화면 가운데에 위치시킨다.
return Expanded(
child: Center(
child: CircularProgressIndicator(),
),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
RaisedButton(
child: Text('수신 안함'),
onPressed: () {
setState(() {
mKind = DataKind.NONE;
});
},
),
RaisedButton(
child: Text('문자열'),
// 정직하게 함수를 두 번 호출하는 코드
onPressed: () {
setState(() {
mKind = DataKind.STRING;
});
},
),
RaisedButton(
child: Text('사진'),
// 한 번은 정직하게, 한 번은 => 문법 사용
onPressed: () {
setState(() => mKind = DataKind.IMAGE);
},
),
RaisedButton(
child: Text('배열'),
// 두 번 모두 => 문법 사용. 간단하지만 가독성에서는 어렵다.
onPressed: () => setState(() => mKind = DataKind.ARRAY),
),
],
),
],
);
}
}


27. 플러터 : 서버/클라이언트 연동 (2)

어찌 된게 뭐하나 할 때마다 이렇게 힘이 드는 건지 모르겠다.
얼핏 보면 그냥 되야 하는데
막상 해보면 절대 그냥 되지 않는다.
이 간단한 걸 하는데 서너 시간 이상 들었다.
예전 같지 않은걸까..?

이전 글에서는 사용자 정보를 1개만 가져왔는데
이번에는 전체에 해당하는 10개를 가져와서 리스트뷰에 출력한다.
모든 데이터를 출력하면 지저분해지는 관계로 이름과 이메일만 보여준다.



우재야!
지금까지 했던 것처럼..
이번에도 아래 코드 안보고 위의 그림처럼 만들어 보자.
해보고 잘 안되면 살짝 보는 걸로.
우리 아들 화이팅!!


import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
// http가 있기 때문에 http.get()이라고 쓸 수 있다. 아니면 그냥 get(). 헷갈릴 수 있다.
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

class User {
int userId;
String name;
String email;
String phone;
Map<String, dynamic> company;

User({this.userId, this.name, this.email, this.phone, this.company});
}

Future<List<User>> fetchUsers() async {
final response = await http.get('https://jsonplaceholder.typicode.com/users');

if (response.statusCode == 200) {
// 수신 데이터는 사전(Map)의 배열이지만, 정확한 형식은 Iterable 클래스.
// Map의 형식은 이전 예제에 나온 것처럼 Map<String, dynamic>이 된다.
final users = json.decode(response.body);

// Map을 User 객체로 변환.
// Iterable 객체로부터 전체 데이터에 대해 반복
List<User> usersMap = [];
for(var user in users) { // user는 Map<String, dynamic>
usersMap.add(User(
userId: user['id'],
name: user['name'],
email: user['email'],
phone: user['phone'],
company: user['company'],
));
}
return usersMap;
}

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Center 위젯이 없으면 데이터를 가져오는 동안 인디케이터가 좌상단에 표시된다.
return Center(
child: FutureBuilder(
future: fetchUsers(), // User 배열 반환
builder: (context, snapshot) {
if (snapshot.hasData) {
List<User> userArray = snapshot.data; // 정확한 형식으로 변환
return ListView.builder(
itemCount: userArray.length, // 필요한 개수만큼 아이템 생성
itemExtent: 100.0,
itemBuilder: (context, index) => makeRowItem(userArray[index], index),
);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}

// 데이터를 로딩하는 동안 표시되는 인디케이터
return CircularProgressIndicator();
},
),
);
}

// 리스트뷰의 항목 생성. idx는 항목의 색상을 달리 주기 위해.
Widget makeRowItem(User user, int idx) {
return Container(
child: Column(
children: <Widget>[
Text(user.name, style: TextStyle(fontSize: 21, color: Colors.white)),
Text(user.email, style: TextStyle(fontSize: 21, fontWeight: FontWeight.bold)),
],
),
padding: EdgeInsets.only(top: 20.0),
color: idx % 2 == 1 ? Colors.blueGrey : Colors.orange[300],
);
}
}

26. 플러터 : 서버/클라이언트 연동 (1)

플러터를 사용하려고 했던 최초의 목적은 서버와의 연동이었다.
딥러닝 서버를 구축하고
스마트폰 앱을 사용해서 결과를 보여주기 위해서.

그래서 이번에는 첫 번째 시간으로 서버로부터 데이터를 가져오는 코드를 구성해 봤다.
좋은 코드가 있어 참고했음을 밝힌다.
이곳에서 참고한 코드를 확인할 수 있다.

최종적으로는 여러 개의 데이터를 리스트 형태로 보여준다.
원본 코드에서는 포스트(post)를 가져다 사용했는데
http에서 get과 post라는 단어가 핵심 용어로 사용되기 때문에 일부러 사용자(user)를 선택했다.

총 10개의 데이터 중에서 첫 번째 사용자의 정보는 아래와 같다.
참조했던 코드보다 훨씬 복잡하다. 사전(map) 안에 사전이 존재하는 형태다.

// 첫 번째 사용자 데이터
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
},


플러터와 관련된 json 내용은 이곳에서 확인할 수 있다.
fromJson과 toJson 함수를 만드는 방법을 비롯해서 유용한 코드가 많으니 꼭 살펴보기 바란다.
이번 코드에서는 fromJson 생성자만 만들어서 코드를 보기좋게 꾸민다.



주석을 많이 붙이고
동일하게 동작하는 코드도 여럿 붙였더니.. 많이 길어졌다.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

class User {
final int userId;
final String name;
final String email;
final String phone;

// 사전 안에 포함된 사전. 디코딩을 했기 때문에 문자열이 아니라 사전(map)이 되어야 한다.
// 이때 사전의 값으로는 여러 가지가 올 수 있기 때문에 dynamic 키워드가 온다.
// 엣갈리면 앞에 나온 사용자 데이터에서 company 항목을 찾아서 확인해 볼 것.
final Map<String, dynamic> company;

User({this.userId, this.name, this.email, this.phone, this.company});

// fromJson 생성자. 이 함수를 호출하면 User 객체를 만들 수 있기 때문에 생성자라고 부른다.
// factory는 클래스 함수로 생성자를 만들 때 사용하는 키워드.
// 전역 함수처럼 동작하기 때문에 this 키워드를 사용할 수 없다.
factory User.fromJson(Map<String, dynamic> userMap) {
return User(
userId: userMap['id'],
name: userMap['name'],
email: userMap['email'],
phone: userMap['phone'],
company: userMap['company'],
);
}

// 위와 동일한 방법으로 factory 키워드를 생략할 수 있다.
// User.fromJson(Map<String, dynamic> userMap)
// : userId = userMap['id']
// , name = userMap['name']
// , email = userMap['email']
// , phone = userMap['phone']
// , company = userMap['company']
}

// json 서버로부터 사용자 데이터 중에서 첫 번째 데이터 1개만 가져옴
Future<User> fetchUser() async {
// 첫 번째를 가져오기 때문에 주소 마지막에 '1'이 붙어있다.
// http 프로토콜의 get 방식으로 데이터를 가져온다.
// get은 가져온다는 뜻이 아나리 어떤 방식으로 데이터를 가져올지를 알려주는 방식(method)을 의미한다.
final response = await http.get('https://jsonplaceholder.typicode.com/users/1');

// 웹 서버로부터 정상(200) 데이터 수신
if (response.statusCode == 200) {
// json 데이터를 수신해서 User 객체로 변환
final userMap = json.decode(response.body);
return User.fromJson(userMap);

// fromJson 생성자를 만들지 않고 직접 User 객체를 생성할 수도 있다.
// return User(
// userId: userMap['id'],
// name: userMap['name'],
// email: userMap['email'],
// phone: userMap['phone'],
// company: userMap['company']
//);
}

// ok가 아니라면 예외 발생.
// 실제 상황에서는 데이터 수신에 실패했을 때의 처리를 제공해야 한다.
// 다시 읽어야 한다던가 빈 데이터 또는 에러를 표시한다던가.
throw Exception('데이터 수신 실패!');
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final style = TextStyle(fontSize: 21, height: 2.0);
return Column(
children: <Widget>[
FutureBuilder(
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.hasData) {
// 변수에 저장할 필요없이 Text 위젯에 바로 전달해도 된다.
// userId는 int 자료형을 갖기 때문에 문자열 변환이 필요하다.
final userId = snapshot.data.userId.toString();
final name = snapshot.data.name;
final email = snapshot.data.email;
final phone = snapshot.data.phone;
final company = snapshot.data.company;

return Column(
children: <Widget>[
Center(child: Text(userId, style: style)),
Center(child: Text(name, style: style)),
Center(child: Text(email, style: style)),
Center(child: Text(phone, style: style)),
Center(child: Text(company['name'], style: style)),
Center(child: Text(company['catchPhrase'], style: style)),
Center(child: Text(company['bs'], style: style)),
],
);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}

return CircularProgressIndicator();
},
),
],
);
}
}

CircularProgressIndicator 객체의 모양은 이곳에서 확인할 수 있다.
잘 동작하고 있다는 것을 보여줄 때 사용하는 인디케이터의 한 가지이다.

참.. json 서버의 역할을 너무 잘 수행해 주는 JSONPlaceHolder 사이트는 꼭 가봐야 한다.
홈 화면 아래에 내려가면 common으로 제공하는 api 목록을 확인할 수 있다.
앱에서 서버에 접근하기 전에
크롬 등의 브라우저를 사용해서 해당 주소가 잘 동작하는지 먼저 확인해야 한다. 꼭!


25. 플러터 : 텍스트 파일 읽기

플러터는 그만하려고 했는데..
경험 없는 사람들에게는 이런저런 것들도 필요하구나.. 하는 생각이 들었다.
우재, 너 말이야!

간단하게 텍스트 파일을 읽어서 출력할건데..
우재가 프로젝트에서 했던 것처럼 테이블로 표시해 볼께.

플러터 문서에 보면 순서가 정의되어 있는데..
실제로는 누락된 부분이 있어서 한번에 성공하지 못했다. (참고 사이트)


먼저 path_provider 플러그인을 설치하라고 되어 있다.
클릭하면 아래 페이지로 이동한다.


라이브러리를 추가하기 위해서는 pubspec.yaml 파일을 수정해야 한다.
dependencies: 항목을 찾아 다른 라이브러리하고 똑같은 형식으로 입력한다.

  path_provider: 0.5.0+1

pubspec.yaml 파일 상단에는 패키지 명령 몇 가지가 항상 표시된다.
그 중에서 get이나 upgrade 명령을 선택해서
path_provider 라이브러리를 프로젝트에 반영하면 준비 완료.


헐.. 지금까지 설명한 부분은 앱 내부에서 파일을 만들고 접근하는 방법.
미리 만들어 놓은 파일을 읽기 위해서는 사용할 수 없다.
모든 리소스는 패키지로 묶이기 때문에 앞의 코드로는 파일에 접근할 수 없고
플러터에서 제공하는 애셋 관리자를 사용해서 접근해야 한다.

지금 하려고 하는 것처럼 assets 폴더 아래에 파일을 만들었다면
앞의 코드를 사용할 수 없다는 말.
여기서는 '2016_GDP.txt' 파일을 사용한다.

2016_GDP.txt


pubspec.yaml 파일에 아래와 같이 접근할 수 있도록 명시한다.

dev_dependencies:
flutter_test:
sdk: flutter

flutter:
uses-material-design: true
assets:
- assets/2016_GDP.txt


이번 코드에서 가장 어려운 부분은 Future<String>과 FutureBuilder 클래스 사용법인데..
코드에 주석으로 달아놨으니까.. 읽어보고.

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

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('파일 읽기')),
body: SingleChildScrollView( // 수직 스크롤 지원
child: FutureBuilder(
future: loadAsset('assets/2016_GDP.txt'),
builder: (context, snapshot) {
// snapshot은 Future 클래스가 포장하고 있는 객체를 data 속성으로 전달
// Future<String>이기 때문에 data는 String이 된다.
final contents = snapshot.data.toString();

// 개행 단위로 분리
final rows = contents.split('\n');

var tableRows = <TableRow>[];
for(var row in rows) {
// 이번 파일에서 구분 문자는 콜론(:)
var cols = row.split(':');

// 마지막 줄은 빈 줄이라서 컬럼 개수가 3개가 아니다.
if(cols.length != 3)
continue;

// map 함수를 이용해서 문자열 각각에 대해 Text 위젯 생성
var widgets = cols.map((s) => Text(s));
tableRows.add(TableRow(children: widgets.toList()));
}
return Table(children: tableRows);
},
),
),
);
}

// assets 폴더 아래에 2016_GDP.txt 파일 있어야 함.
// AssetBundle 객체를 통해 리소스에 접근.
// DefaultAssetBundle 클래스 또는 미리 만들어 놓은 rootBundle 객체 사용.
// async는 비동기 함수, await는 비동기 작업이 종료될 때까지 기다린다는 뜻.
// 그러나, 함수 자체가 블록되지는 않고 예약 전달의 형태로 함수 반환됨.
// 따라서 Future 클래스를 사용하기 위해서는 FutureBuilder 등의 특별한 클래스가 필요함.
Future<String> loadAsset(String path) async {
return await rootBundle.loadString(path);
// return await DefaultAssetBundle.of(ctx).loadString('assets/2016_GDP.txt');
}
}


24. 플러터 : 목록 보기 (화면 전환)

여기까지만 할까?

왼쪽 그림에서 목록을 보여주고, 하나를 선택하면 오른쪽 그림으로 넘어가는거야.
상단 네비게이션바의 뒤로(<- 화살표) 버튼을 누르면 다시 왼쪽 그림이 나타나지.

왼쪽 그림에서 가운데 흐린 파랑은
출력 결과가 정확하게 가운데 표시된다는 것을 알려줘.
직접 해보면 가운데 오게 하는 것이 잘 안되거든.
왼쪽 그림에서는 표시가 잘 나진 않지만,
조금 스크롤되니까 위아래로 꼭 움직여 봐야 해.
안드로이드 디바이스를 어떤 걸 선택했느냐에 따라 화면 안에 전부 출력될 수도 있는데..
그러면 스크롤은 당연히 안 되겠지?



이번 코드에서 중요한 점은
첫 번째로 데이터 클래스를 만들어서 리스트를 생성하고 상세보기 클래스에 전달하는 방법.
두 번째로 위젯이 많이 중첩돼서 헷갈리지만, 출력물이 화면 가운데 오게 하는 방법.
세 번째로 커스텀 셀(custom cell)이라고 부르는 리스트뷰 항목을 원하는 대로 구성하는 방법.

import 'package:flutter/material.dart';

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

// 구조체 스타일의 클래스. 상세보기로 데이터를 넘길 목적으로 생성.
class PageInfo {
PageInfo(this.image, this.title, this.group);

String image; // 사진 경로
String title; // 본문 설명
String group; // 지역 이름
}

class MyApp extends StatelessWidget {
final infos = [
PageInfo('images/family_1.jpg', '서핑 후의 달콤한 휴식. 아이서프 샵', '설악'),
PageInfo('images/family_2.jpg', '보드가 서퍼보다 크다!', '설악'),
PageInfo('images/family_3.jpg', '우재와 서진, 잘 생겼다!', '설악'),
PageInfo('images/family_4.jpg', '따뜻한 적이 없던 스키장. 장갑이 필요해!', '지산'),
];

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('새로운 기억들')),
// itemExtent 옵션으로 항목 높이 설정
// map 함수로 infos에 들어있는 개수만큼 ListTile 객체 생성 (makeRowItem)
body: ListView(
itemExtent: 120,
children: infos.map((info) => makeRowItem(context, info)).toList(),
),
);
}

Widget makeRowItem(BuildContext ctx, PageInfo info) {
return Center(child: Container(
color: Colors.lightBlue[100], // 출력물이 항목 가운데 오는지 확인하기 위한 용도
child: ListTile(
// 셀의 왼쪽 영역. 오른쪽을 가리키는 trailing도 있지만, 여기서는 사용 안함
leading: Image.asset(
info.image, width: 100, height: 100, fit: BoxFit.cover),
// 본문 영역에 Row나 Column 위젯을 통한 여러 개의 위젯 전달 가능
title: Row(
children: <Widget>[
Expanded( // 자식 위젯이 나머지 전체를 차지하도록 확장
child: Text(
info.title,
style: TextStyle(fontSize: 19, color: Colors.blueGrey)),
),
Container( // 답답해 보이지 않도록 패딩을 주기 위해 사용
child: Text(
info.group,
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black54)),
padding: EdgeInsets.only(left: 12.0, right: 12.0),
),
],
),
// 특정 셀을 선택하면 다음 화면으로 현재 셀의 데이터 전달하면서 이동
onTap: () {
Navigator.push(ctx,
MaterialPageRoute<void>(builder: (BuildContext context) => Detail(info: info))
);
},
),
),
);
}
}

// 상세보기 화면 클래스
// 실제로는 더 많은 데이터가 전달되어야 하지만, 여기서는 3가지만 사용.
class Detail extends StatelessWidget {
// key는 부모 클래스에서 사용하는 기본 옵션
Detail({Key key, this.info}) : super(key: key);
final PageInfo info;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(info.group)),
body: SingleChildScrollView( // 없으면, 화면을 벗어났을 때 볼 수 없음 (스크롤 지원)
child: Column(
children: <Widget>[
Container(
child: Center(
child: Text(
info.title,
style: TextStyle(fontSize: 21.0, color: Colors.black87),
),
),
padding: EdgeInsets.all(20.0),
),
Container(
child: Image.asset(info.image, fit: BoxFit.contain),
padding: EdgeInsets.all(10.0),
),
],
),
),
);
}
}


다 했어?
그러면 간단한 프로젝트를 시작해야 할테고
잘 안되는 것들은 꼭 질문하기.
명심할 것은 바로 질문해서는 안 되고 조금이라도 고민한 후에 질문하기.

우재가 시행착오를 잘 이겨내고
그럴듯한 스마트폰 앱으로 친구들한테 자랑할 날을 기다리면서.
아빠가!!

23. 플러터 : 목록 보기 (무제한)

무제한 목록보기를 구현했다.
제목으로는 끝이 없다는 뜻으로 '무한리필'!
쩔지 않니, 우재야?

왼쪽은 앱 실행 후 첫 번째 화면, 오른쪽은 여러 페이지를 이동한 후의 화면.
출력 문자열은 양쪽에 3글자 영어 단어가 있고, 가운데는 항목 순서를 표시했지.
단어 10개 중에서 난수로 뽑았기 때문에 중복될 수 있고, 양쪽 그림 모두에서 중복된 패턴이 보인다. ㅎ



이번 코드에서 중요한 것은
여러 페이지를 넘어설 만큼 개수가 많을 때 ListTile 객체를 생성하는 방법이야.
3개만 생성한다면 한번에 생성할 수도 있지만
너무 많아지면 계속해서 나열할 수는 없기 때문에
반복적으로 필요할 때마다 생성할 수 있는 구성이 필요해.

ListView 클래스의 builder 생성자를 사용해서 리스트뷰 위젯을 만들면서
itemBuilder 옵션으로 ListTile 객체를 생성하는 방법을 알려주는 게 핵심 중의 핵심!


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

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

class MyApp extends StatelessWidget {
// 단어 목록으로부터 난수가 가리키는 위치의 단어를 보여주기 위한 변수 및 함수. 42는 seed.
final rand_gen = Random(42);
final words = ['ten', 'day', 'sky', 'fat', 'gym', 'run', 'ace', 'red', 'zen', 'sun'];

String randomWord() => words[rand_gen.nextInt(words.length)];
String capitalize(String s) => s[0].toUpperCase() + s.substring(1);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('무한리필')),
body: ListView.builder(
itemBuilder: (context, index) {
final first = capitalize(randomWord()); // 첫 글자가 대문자인 단어 생성
final second = capitalize(randomWord());
final disp_text = '$first $index $second';

return ListTile(
title: Center( // 항목 가운데 배치
child: Text(
'$first $index $second', // 원하는 형태로 조합
style: TextStyle( // 모든 단어를 같은 스타일로 처리
fontSize: 21,
fontWeight: FontWeight.bold,
),
),
),
onTap: () {
showDialog( // 초간단 경고창
context: context,
builder: (BuildContext ctx) => AlertDialog(title: Text(disp_text))
);
},
);
}
),
);
}
}


문자열에 포함된 단어 각각에 대해 다른 스타일을 적용하고 싶어졌어.
갑자기 그런 건 아니고
프로젝트를 하다 보면 그럴 경우가 많거든.
예전에는 문자열 3개를 Text 위젯으로 따로 만들어서 화면에서만 하나인 것처럼 했는데..
지금은 어렵지 않게 개별적인 스타일을 적용할 수 있게 라이브러리에서 지원을 하고 있어.

양쪽 영어 단어는 짙은 파랑, 가운데 순서는 빨강.
그리고 모든 단어는 폰트 크기를 키웠고 굵은 글씨로 수정했어.



앞쪽 코드에서 ListTile에 들어갈 title 옵션만 수정하면 돼.
TextSpan 위젯은 반드시 RichText 위젯의 자식으로 들어가야 하고
공통 스타일과 개별 스타일을 어떻게 적용하는지만 보면 돼.

참.. TextSpan 위젯 안에 TextSpan 위젯이 들어가는 거 중요하다!

title: Center(                          // 항목 가운데 배치
child: RichText(
text: TextSpan( // Text 확장
style: TextStyle( // 모든 자식에 대한 공통 스타일
fontSize: 21,
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(text: first + ' '), // 1번 자식
TextSpan( // 2번 자식
text: index.toString(),
style: TextStyle( // 개별 스타일
color: Colors.red
),
),
TextSpan(text: ' ' + second), // 3번 자식
],
),
),
),

22. 플러터 : 목록 보기 (리스트뷰 기본)

우재야.. 이제 플러터의 끝이 다가오고 있다.
아빠가 기본적인 내용을 열심히 만들기는 했는데
여기에 살을 붙여서 응용하는 것은 많이 힘들거야.
근데.. 그건 아빠가 해줄 수가 없어.
인생이란 늘 한결 같아서 고생하지 않으면 가질 수가 없었어.
마지막까지.. 파이팅!!

항목이 3개 있는 리스트뷰 위젯을 만들었다.
왼쪽은 처음 화면, 오른쪽은 '사진' 항목을 눌러서 경고창이 떴을 때의 화면.
내용이 별거 없는 만큼 리스트뷰가 동작하는 방식을 잘 보여줄 수 있게 만들었어.



ListView 클래스를 만드는 방법에는 여러 가지가 있지만
가장 쉬운 방법은 그냥 기본 생성자를 통해 만드는 거야.
리스트뷰 위젯 만들고 그 안에 필요한 만큼 리스트 항목, 여기서는 ListTile 클래스가 되지.

이번 코드에서는 개수가 많지 않기 때문에 코드가 선명하게 보이는 거고
다음 예제에서 항목의 개수를 늘려서 여러 페이지에 나타나도록 해 볼거야.

ListTile 클래스에서 가장 중요한 것은 onTap 매개변수.
나를 눌렀으니까 그에 따른 뭔가를 해야겠지.
여기서는 간단하게 항목별로 다른 문자열을 출력하고 있어.

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('목록 보기')),
body: ListView( // 1. 리스트뷰 생성하고
children: <Widget>[
ListTile( // 2. 리스트 항목 추가하면 끝!
leading: Icon(Icons.map),
title: Text('지도'),
onTap: () => _showDialog(context, '지도'),
),
ListTile(
leading: Icon(Icons.photo),
title: Text('사진'),
onTap: () => _showDialog(context, '사진'),
),
ListTile(
leading: Icon(Icons.phone),
title: Text('전화'),
enabled: false, // 비활성
onTap: () => _showDialog(context, '전화'),
),
],
),
);
}

// API에 있는 showDialog 함수와 이름이 같아서 밑줄(_) 접두사(private 함수)
void _showDialog(BuildContext context, String text) {
// 경고창을 보여주는 가장 흔한 방법.
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text('선택 완료!'),
content: Text('$text 항목을 선택했습니다.'),
// 주석으로 막아놓은 actions 매개변수도 확인해 볼 것.
// actions: <Widget>[
// FlatButton(child: Text('확인'), onPressed: () => Navigator.pop(context)),
// ],
);
}
);
}
}


showDialog 함수는 경고창을 표시하는 AlertDialog 위젯을 비롯해서
여러 가지 형태의 대화상자를 보여주는 가장 쉬운 방법이야.
showDialog 함수를 통하지 않으면 화면에 아무 것도 표시되지 않아.

21. 플러터 : 탭바

이번에는 가장 일반적인 형태로 사용하는 탭바 인터페이스를 살펴보자.

탭바 인터페이스의 구현은 TabController 클래스가 담당한다.
탭바는 보통 화면 하단에 위치한다.
사실 위쪽에 탭바가 있으면 '바(bar)'를 붙이지 않고 그냥 탭(tab)이라고 부른다.

탭바 인터페이스로 구현하는 이유는
탭에 연결된 화면이 완전히 달라지기 때문이다.
각각의 화면은 일부 연관되어 있을 수도 있지만, 전혀 상관없는 경우가 많다.
그래서 이번 예제에서도 단순하긴 하지만
완벽하게 다른 화면으로 각각의 화면을 구성했다.

탭바의 아이콘은 형식적으로 붙인 것이고
제목에 있는 색상에 맞게 화면 배경색을 처리했다.


각각의 화면은 보통 클래스로 구현을 한다.
색상에 맞게 Red, Green, Blue 클래스를 간단하게 만들었다.

배경색을 처리하기 위해 Container 클래스로 감쌌고
Card 위젯의 여백을 흉내내기 위해 maring 옵션도 일부 줬다.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: MyTabs()));

// TabController 객체를 멤버로 만들어서 상태를 유지하기 때문에 StatefulWidget 클래스 사용
class MyTabs extends StatefulWidget{
@override
MyTabsState createState() => MyTabsState();
}

// SingleTickerProviderStateMixin 클래스는 애니메이션을 처리하기 위한 헬퍼 클래스
// 상속에 포함시키지 않으면 탭바 컨트롤러를 생성할 수 없다.
// mixin은 다중 상속에서 코드를 재사용하기 위한 한 가지 방법으로 with 키워드와 함께 사용
class MyTabsState extends State<MyTabs> with SingleTickerProviderStateMixin {
// 컨트롤러는 TabBar와 TabBarView 객체를 생성할 때 직접 전달
TabController controller;

// 객체가 위젯 트리에 추가될 때 호출되는 함수. 즉, 그려지기 전에 탭바 컨트롤러 샛성.
@override
void initState(){
super.initState();

// SingleTickerProviderStateMixin를 상속 받아서
// vsync에 this 형태로 전달해야 애니메이션이 정상 처리된다.
controller = TabController(vsync: this, length: 3);
}

// initState 함수의 반대.
// 위젯 트리에서 제거되기 전에 호출. 멤버로 갖고 있는 컨트롤러부터 제거.
@override
void dispose(){
controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context){
return Scaffold(
appBar: AppBar(title: Text('교통 수단')),
body: TabBarView(
controller: controller, // 컨트롤러 연결
children: [Red(), Green(), Blue()]
),
bottomNavigationBar: Container(
child: TabBar(
controller: controller, // 컨트롤러 연결
tabs: [
// 아이콘은 글자 수 같은 걸로 선택. 의미 없음. 제목에 들어간 색상은 중요.
Tab(icon: Icon(Icons.card_travel), text: '빨강'),
Tab(icon: Icon(Icons.donut_small), text: '초록',),
Tab(icon: Icon(Icons.table_chart), text: '파랑'),
]
),
color: Colors.blueGrey,
),
);
}
}

// Card 위젯 구현
class Red extends StatelessWidget {
@override
Widget build(BuildContext context){
return Card(color: Colors.red);
}
}

// Text 위젯 구현
class Green extends StatelessWidget {
@override
Widget build(BuildContext context){
return Container(
child: Center(
child:Text('GREEN', style: TextStyle(fontSize: 31, color: Colors.white))
),
color: Colors.green,
margin: EdgeInsets.all(6.0),
);
}
}

// Icon 위젯 구현
class Blue extends StatelessWidget {
@override
Widget build(BuildContext context){
return Container(
child: Center(
child: Icon(Icons.table_chart, size: 150, color: Colors.white),
),
color: Colors.blue,
margin: EdgeInsets.all(6.0),
);
}
}


탭바 인터페이스는 각각의 화면을 개별적인 클래스로 처리하기 때문에
당연하게 개별 파일에 저장하게 된다.
앞의 예제를 다중 파일 형태로 수정해 보자.

1.
Red, Green, Blue 클래스를 잘라내서 my_screen.dart 파일을 만들어서 붙여넣자.
my_screen.dart 파일의 꼭대기에는 당연히 material.dart 파일 import문이 있어야 한다.

2.
main.dart 파일에 아래 코드를 추가한다.
다른 파일과 연동할 때는 아래처럼 사용한다. 점(dot)은 현재 폴더를 가리키는 문법이다.

import './my_screen.dart' as my_screen;


3.
TabBarView 객체를 생성하는 children 옵션을 아래처럼 수정한다.
참조하려는 클래스가 현재 파일이 아니라 my_screen 파일에 있다고 알려준다.

children: [
my_screen.Red(),
my_screen.Green(),
my_screen.Blue(),
]

20. 플러터 : 탭바 기본 (아이콘 + 제목)

탭바와 화면 중앙에 아이콘을 추가했더니
앱의 품질이 매우 좋아진 것처럼 느껴지는 것은 나만의 착각일까?



역시 주석을 꼼꼼하게 달았다.
이번 코드에서 주의 깊게 볼 것은 아이콘 사용은 아니다.
그건 너무 쉬우니까.

구조체 같은 Choice 클래스를 만들었고
왜 만들었는지에 대한 이해를 할 수 있다면 얼마나 좋을까?
주석에 있는 것처럼 단순하게 두 군데에서 사용하기 위해서라고 생각할까?

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: TabBarSample()));

// 아이콘과 제목을 함께 보여주기 위해 데이터만 포함하는 구조체 스타일의 클래스 생성
class Choice {
Choice(this.text, this.icon);
final String text;
final IconData icon;

// 매개변수를 전달할 때 {}가 있다면 매개변수 이름을 생략할 수 없다.
// Choice({this.title, this.icon});
}

class TabBarSample extends StatelessWidget {
// 탭바와 탭바뷰 양쪽에서 사용하기 위한 공통 데이터 리스트.
// Choice 생성자에 {}를 사용하지 않았기 때문에 매개변수 이름이 없다.
final choices = [
Choice('PLANE', Icons.flight),
Choice('CAR', Icons.directions_car),
Choice('BIKE', Icons.directions_bike),
Choice('BOAT', Icons.directions_boat),
Choice('BUS', Icons.directions_bus),
Choice('TRAIN', Icons.directions_railway),
Choice('WALK', Icons.directions_walk),
];

@override
Widget build(BuildContext context) {
return DefaultTabController(
length: choices.length,
child: Scaffold(
appBar: AppBar(
title: Text('교통 수단'),
bottom: TabBar(
tabs: choices.map((Choice choice) {
return Tab(
text: choice.text,
icon: Icon(choice.icon), // 이전 코드와 다른 부분
);
}).toList(),
isScrollable: true,
),
),
body: TabBarView(
// map과 toList 함수를 연결해서 화면 리스트 전달
children: choices.map((Choice choice) {
// 문자열과 아이콘을 모두 포함하는 위젯 객체 생성
// 이전 코드에서는 Text 위젯 하나만 사용했었다. 코드가 많아 클래스로 분리.
return ChoiceCard(
// 생성자에서 {}를 사용했기 때문에 text와 icon 매개변수 이름 사용 필수
text: choice.text,
icon: choice.icon, // 이전 코드와 다른 부분
);
}).toList(),
),
),
);
}
}

class ChoiceCard extends StatelessWidget {
// 매개변수 주변에 {}가 있기 때문에 text와 icon이라는 매개변수 이름을 함께 사용해야 한다.
const ChoiceCard({Key key, this.text, this.icon}) : super(key: key);

final String text;
final IconData icon;

@override
Widget build(BuildContext context) {
// 아이콘과 텍스트 양쪽에서 사용하기 위해 별도 변수로 처리
final TextStyle textStyle = Theme.of(context).textTheme.display3;
return Card(
child: Column(
children: <Widget>[
// 아이콘이 위쪽, 문자열이 아래쪽.
Icon(icon, size: 128.0, color: textStyle.color),
Text(text, style: textStyle),
],
mainAxisAlignment: MainAxisAlignment.center,
),
color: Colors.green,
margin: EdgeInsets.all(12),
);
}
}


클래스 없이 만들 수 있는 방법이 있지 않을까?
일단 클래스와 같은, 크기가 작아도 클래스니까 코드가 복잡해 진다.
클래스를 없애보자!

고생 엄청 했다.
몇 시간 코딩하고 나서 알았다.
생각 좀 하고 코딩했어야 하는데.. 일단 코딩하고 보는 성격이라..
우재야! 아빠가 급한 성격이니?

문제는 Card 클래스를 사용해서 화면 중앙에 탭의 내용을 다시 한번 출력하려는 것이다.
그러니 두 군데에 나올 수밖에 없다.
보통은 전혀 그럴 일이 없기 때문에 실제 상황에서는 Choice 같은 클래스는 사용하지 않을 것이다.
항상 그랬기 때문에 미처 감지하지 못했다. 늘 하던 방식으로 생각하는 바람에.

Card 클래스는 아래 그림과 같은
Material 클래스에서 제공하는 많지 않은 데이터를 보여주는 위젯이다.


이번 예제는 플러터에서 가져왔다.
좋은 예제들이 많으니까 꼭 둘러보기 바란다.