
다음주에 있을 현직자 멘토링에 대한 안내를 받았다. 보니까 옆동네더라. 지난 번에 @보리스와 @다람이 강의를 들으러 간다고 하길래 @이안과 함께 갔던 바로 그곳. 수업 마친 이후 시간대에 진행되는데 어차피 집 가는 방향이랑 비슷해서 한 번 들려보자 하고 신청했는데, 같이 수업을 듣는 분들 중 대부분이 신청을 하신 모양이다. 다음주에 수업 마치고 같이 이동하면 좋을 것 같다.
난 아직 구체적인 방향성을 잡지 못했는데 그래도 아무튼 개발자의 범위 내에서 고민하게 될 테니 그룹 멘토링은 개발자 분과의 만남으로 신청했다. 토스 프론트엔드/웹 개발자와 카뱅 서비스/서버 개발자가 계시더라. 선착순 안에 늦지 않게 신청한 거길 바라며... 사실 디자인도 좀 배웠고 콘텐츠 디자인이나 제품 디자인은 어려웠지만 UI/UX 디자인은 그럭저럭 괜찮았어서 UIUX 분야도 살펴볼 만할 것 같긴 하지만 당분간은 개발 쪽에 좀 더 집중해 봐야지.
앱 개발 시 폰트를 지정하지 않으면 시스템 기본 폰트가 사용된다. 플랫폼마다 폰트가 달라지기도 하고, 폰트 크기 비율이 달라져 화면에 보여지는 배치가 의도치 않게 틀어질 수도 있다. 이를 방지하기 위해서 텍스트에 폰트를 지정해 주는 게 좋다.
폰트를 다운로드 받을 수 있는 곳은 다양하고, 대표적인 곳으로 Google Fonts가 있다. 폰트 라이선스를 포함한 다양한 정보를 살펴볼 수 있고, 원하는 조건으로 필터링하여 검색해 볼 수도 있다. 다운로드 받아서 사용할 수도 있고 webfont로 사용할 수도 있는데 각각 장단점이 있다.
다운로드한 폰트는 assets/fonts 등의 적절한 디렉토리에 모아놓고 pubspec.yaml 파일에 등록하여 사용한다. 그런데 사용하는 폰트가 많아질수록 앱 자체의 크기가 늘어난다. webfont로 사용할 경우에는 앱 크기를 작게 유지할 수 있지만 약간의 통신 딜레이가 있을 수 있다. 한 가지 방식을 고수하기 보다는 로딩 화면 및 주요 폰트 한두 개는 asset으로 추가하고 그 외에 더 필요하다면 webfont 사용하는 게 좋다.
pubspec.yaml# 앞 부분 생략 flutter: uses-material-design: true assets: - assets/ fonts: - family: Pretendard fonts: - asset: assets/fonts/Pretendard/Pretendard-Thin.otf weight: 100 - asset: assets/fonts/Pretendard/Pretendard-ExtraLight.otf weight: 200 - asset: assets/fonts/Pretendard/Pretendard-Light.otf weight: 300 - asset: assets/fonts/Pretendard/Pretendard-Regular.otf weight: 400 - asset: assets/fonts/Pretendard/Pretendard-Medium.otf weight: 500 - asset: assets/fonts/Pretendard/Pretendard-SemiBold.otf weight: 600 - asset: assets/fonts/Pretendard/Pretendard-Bold.otf weight: 700 - asset: assets/fonts/Pretendard/Pretendard-ExtraBold.otf weight: 800 - asset: assets/fonts/Pretendard/Pretendard-Black.otf weight: 900
특정 텍스트에 폰트를 적용하고자 한다면 pubspec.yaml 파일에 지정한 family 값을 fontFamily 에 대입한다.
Text( 'My Perfect Workout Mate', style: TextStyle( fontSize: 16, color: Colors.white, fontFamily: 'Pretendard', // THIS! ), ),
앱 전체의 기본 폰트를 적용하고자 한다면 theme 에 ThemeData 를 전달하여 적용할 수 있다.
class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( home: const LandingPage(), theme: ThemeData( fontFamily: 'Pretendard', ), ); } }
우리 프로젝트의 개발 규칙을 설정하고 가면 일관성을 유지하며 작업을 해 나갈 수 있다.
- 페이지 widget의 이름은
Page로 끝나도록 한다.- 모든 페이지 widget의
build()method는Scaffoldwidget을 반환한다.- 모든 페이지 widget은 반드시 파일 분리를 한다.
- 페이지 widget을 담는 파일의 이름은 해당 widget의 이름을 사용한다.
- import 시 상대경로인지 절대경로인지는 개인 취향이지만 하나로 정하면 일관성 있게 작성한다.
이에 따라 프로젝트 구조를 잡아 보자. 이 구조가 최종 구조는 아니고, 프로젝트를 진행하며 구조를 개선해 나갈 것이다. 처음부터 구조를 명확하게 잡고 가지 않고 나중에 수정하는 건 내 성향에는 안 맞지만, 일단은 내 프로젝트 구성 방식을 고집하기보다는 이 방식도 경험해 봐야지.
lib디렉토리 구조lib ├── main.dart ├── landing_page.dart └── workout_list_page.dart
lib/landing_page.dart 에는 지난 시간에 작성한 LandingPage widget을 옮겨 넣는다. 그리고 lib/workout_list_page.dart 을 새로 생성하여 구성해 보도록 하겠다.
이제 lib/main.dart 는 다음과 같이 단순해졌다.
lib/main.dartimport 'package:flutter/material.dart'; import 'landing_page.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( home: const LandingPage(), theme: ThemeData( fontFamily: 'Pretendard', ), ); } }
앱 실행 흐름 차원에서는 LandingPage widget이 먼저 뜨고 버튼을 누르면 다음 화면으로 넘어가야 하지만, 우리는 아직 화면 넘기는 것을 배우지 않았으므로 임시로 처음부터 LandingPage widget이 아닌 WorkoutListPage widget을 실행시키도록 수정한 뒤 WorkoutListPage widget을 작성해 보도록 하겠다.
lib/main.dartimport 'package:flutter/material.dart'; import 'landing_page.dart'; import 'workout_list_page.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); Widget build(BuildContext context) { return MaterialApp( //home: const LandingPage(), home: const WorkoutListPage(), theme: ThemeData( fontFamily: 'Pretendard', ), ); } }
lib/workout_list_page.dartimport 'package:flutter/material.dart'; class WorkoutListPage extends StatelessWidget { WorkoutListPage({super.key}); Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Center(child: Text('Workout List'))), body: Placeholder(), ); } }
목록은 ListView widget으로 작성하는 게 효과적이겠지만 일단 우리가 배운 Column widget으로 목록 페이지를 구성해 보도록 하겠다.
운동 목록의 역할을 하는 Column widget의 children으로는 각 운동 항목을 담는 widget을 넣는다. 운동 항목은 [사진][이름][시간]으로 구성되므로 Row widget을 사용하여 작성할 수 있다.
Row( children: [ Image(image: AssetImage('assets/squat.png'), Text('1. 스쿼트'), Text('05:30'), ], )
적절한 모양과 크기로 조정하면,
Row( spacing: 20, children: [ Container( width: 100, height: 100, decoration: BoxDecoration( image: DecorationImage( image: AssetImage('assets/squat.png'), ), shape: BoxShape.circle, ), ), Text( '1. 스쿼트', style: TextStyle(fontSize: 20), ), Text( '05:30', style: TextStyle(fontSize: 20, color: Colors.blueAccent), ), ], ),
그리고 적절한 여백을 지정하면,
Row( spacing: 20, children: [ Container( width: 100, height: 100, decoration: BoxDecoration( image: DecorationImage( image: AssetImage('assets/squat.png'), ), shape: BoxShape.circle, ), ), Expanded( child: Text( '1. 스쿼트', style: TextStyle(fontSize: 20), ), ), Text( '05:30', style: TextStyle(fontSize: 20, color: Colors.blueAccent), ), ], ),
다음과 같이 UI가 그려진 것을 확인할 수 있다.
지금은 데이터가 하드코딩 되어 있지만 양이 늘어나면 일일이 작성하는 게 번거롭다. 따라서 적절한 collection object를 생성하여 반복 생성하는 편이 좋다.
List 를 사용할지 Map 을 사용할지 등등 적절한 방식으로 데이터를 작성한다. 수업에서는 이름, 사진, 시간을 담는 List 를 각각 작성하여 사용했는데 나는 List<Map<dynamic>> 하나에 다 집어넣었다.
Map 을 사용하는 편이 관련된 정보끼리 모여 있으니 응집도가 높고, 그래서 내용을 추가하거나 수정할 때 실수할 확률이 낮아져 무결성 차원에서도 이득이다. 그런데 운동 목록은 순서가 있는 목록이니 그것을 List로 묶었다. {'name': '스쿼트', 'image': 'squat.png', 'minutes': 30}, 와 같은 데이터를 담는 List 인 것이다.
기존에 작성했던 Row 를 우클릭하고 [Refactor > Extract Method] 를 선택하여 그것을 기반으로 helper function을 작성한다. collection object의 내용대로 Row widget을 반복적으로 생성하여 List 형태로 return 하는 거다.
List<Row> getWorkoutList() { List<Row> workoutRows = []; for (var i = 0; i < workoutList.length; i++) { String name = workoutList[i]['name']; String image = workoutList[i]['image']; int minutes = workoutList[i]['minutes']; workoutRows.add( Row( spacing: 20, children: [ Container( width: 100, height: 100, decoration: BoxDecoration( image: DecorationImage(image: AssetImage('assets/$image')), shape: BoxShape.circle, ), ), Expanded( child: Text('${i + 1}. $name', style: TextStyle(fontSize: 20)), ), Text( '$minutes 분', style: TextStyle(fontSize: 20, color: Colors.blueAccent), ) ], ), ); } return workoutRows;
Row 를 refactor 한 것이므로 처음에 Row widget이 정의되어 있던 부분이 getWorkoutList() 로 대치되었다. 그런데 작성한 helper function는 Row 가 아닌 List<Row> 를 return하므로 children: [ getWorkoutList() ] 가 아니라 children: getWorkoutList() 로 수정한다.
Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Center(child: Text('Workout List'))), body: Padding( padding: EdgeInsetsGeometry.all(20), child: Column( spacing: 20, children: getWorkoutList(), ), ), ); }
아직 화면을 넘어가는 크기를 가지고 있어 overflow 되는 widget에 대한 처리를 하지 않아 오류가 뜨지만 다음과 같이 목록은 잘 생성되는 것을 확인할 수 있다.
Flutter 공식문서의 architecture에 대한 설명을 읽어 보자. 서비스 개발은 배우지 않고 그냥 예제 따라해 보는 수준으로 해 본 게 전부라 이런 걸 익힐 필요가 있다. 제대로 공부하려면 양도 좀 되는 것 같고 강의실 분위기에서는 집중해서 읽긴 어려울 테니 주말에 읽어 봐야지. 얼마나 걸릴진 잘 모르겠다. 어떤 문서는 몇 페이지고 금방 읽고 어떤 문서는 한 페이지를 하루 종일 붙잡고 있기도 하는데 이건 공부해 본 적 없는 영역이라.
그리고 앱 기획에 대한 것도 좀 더 고민해 볼 필요가 있고. 바쁘다 바빠.