사전캠프 9일차
스레드 앱 실습을 마무리하며, 드디어 3주차 강의가 끝났다.
초심자 강의가 아닌 느낌으로, 다른 주차 강의와 비교하면 무척 어려웠다.
이론까지는 이해하고 넘어갔지만, 실습부분은 따라가기 급급했다.
📌 thread_feed_write_controller 상태관리 변수 선언
import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; // class ThreadFeedWriteController extends GetxController { String contents = ''; List<XFile>? selectedImages; }
thread_feed_write_controller.dart파일을 만들어 상태 관리 변수를 선언한다.
📌 thread_feed_write_controller 이벤트 등록
class ThreadFeedWriteController extends GetxController { String contents = ''; List<XFile>? selectedImages; // void setContent(String value) { contents = value; update(); } // void setSelectedImages(List<XFile>? value) { selectedImages = value; update(); } }
- 상태 변수가 변경될 수 있도록 함수 이벤트를 각각 만든다.
📌 thread_write_page 이미지 선택 이벤트 연결
Future<void> getImagePickerData() async { final ImagePicker picker = ImagePicker(); final List<XFile> images = await picker.pickMultiImage(); Get.find<ThreadFeedWriteController>().setSelectedImages(images); }📌 thread_write_page 글 작성 이벤트 연결
TextField( cursorHeight: 16, decoration: InputDecoration( isDense: true, hintText: '새로운 소식이 있나요?', hintStyle: TextStyle( color: Color(0xff9a9a9a), fontSize: 14, ), contentPadding: EdgeInsets.zero, border: InputBorder.none, ), onChanged: (value) { Get.find<ThreadFeedWriteController>() .setContent(value); }, ),
- 등록 화면에서 각각의 이벤트를 연결될 수 있도록 수정한다.
컨트롤러의 이벤트들을 연결했지만 정작 컨트롤러를 프로젝트에 등록하지 않았다.
해당controller는 앱이 실행되면서부터 앱이 종료되는 시기까지 항상 영구적으로 존재되어야 하는 컨트롤러는 아니다.
피드 등록 페이지가 열릴 때부터 피드 등록 페이지가 종료될 때까지만 유지되면 되는 컨트롤러이다.
📌 home.dart > _quickFeedWriteView 의 onTap 페이지 전환시 컨트롤러 등록
Get.to(ThreadWritePage(), binding: BindingsBuilder(() { Get.put(ThreadFeedWriteController()); }));
- 페이지
route시에 해당controller를 등록되어 사용할 수 있도록 한다.
📌 이미지 선택시 화면에 보이도록 소스코드 수정
if (controller.selectedImages == null || (controller.selectedImages?.isEmpty ?? true)) { return Container(); }
- 이미지 선택 시 화면에 보이도록 소스코드를 수정한다.
controller에 선택된 이미지가 없을 수 있기 때문에, 없을 경우는 빈Container를 반환 하도록 한다.Image.file( File(controller.selectedImages![index].path), ),
Image위젯에는asset으로 보여주는 방식과network로 보여주는 방식 그리고 파일을 통해서 보여주는 방식이 있다.
이번에는 선택된 파일에서path(이미지 경로)를 추출해서File데이터를 통해서 이미지를 보여주는 방식으로 처리했다.
📌 이미지 일정사이즈로 꽉찬 이미지로 표기
ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack(children: [ Positioned( left: 0, right: 0, top: 0, bottom: 0, child: Image.file( File( controller.selectedImages![index].path), fit: BoxFit.cover, ), ), Positioned( right: 5, top: 5, child: Icon( Icons.close, color: Colors.white, ), ) ]), ),
- 이미지 사이즈가 일정하지 않아
fit: BoxFit.cover을 사용해서 동일하게 사이즈를 수정한다.
📌 등록버튼 배치
bottomNavigationBar: Container( height: 70, padding: EdgeInsets.only( left: 15, right: 15, bottom: MediaQuery.of(context).padding.bottom), child: Row( children: [ Expanded( child: Text( '누구에게나 답글 및 인용 허용', style: TextStyle(color: Color(0xff9a9a9a)), ), ), Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15), decoration: BoxDecoration( borderRadius: BorderRadius.circular(50), color: Color(0xff9a9a9a), ), child: Text('개시'), ) ], ), ),
- 스레드는 등록 버튼이 하단에 위치해있는데, ios의 경우 하단에 네비게이션 바가 있다.
때문에padding에 값을 입력할 경우 android에서는 하단에 여백이 생기게 된다.
처리하기 위해Sacffold의bottomNavigation의 옵션을 이용하면 쉽게 처리할 수 있다.
padding.bottom의 경우 하단의 공간을 계산해주고,padding.top의 경우 ios의 카메라와 페이스 id 부분의 여백을 계산하여 설정한다.
📌 게시 버튼 활성화 비활성화 처리
GetBuilder<ThreadFeedWriteController>(builder: (controller) { return GestureDetector( onTap: () { if (controller.contents != '') { // 저장 이벤트 처리 } }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15), decoration: BoxDecoration( borderRadius: BorderRadius.circular(50), color: controller.contents != '' ? Colors.black : Color(0xff9a9a9a), ), child: Text('게시'), ), ); })
- 글을 입력해야만 게시 버튼이 활성화 되도록 한다.
개시 버튼을 눌렀을 때 이벤트 처리를 작업한다.
우선 스레드 피드의 모델을 만들어주고 저장 버튼을 눌렀을 때, 해당 모델로 데이터를 넣어주는 작업을 진행해야 한다.
📌 FeedModel 설계
import 'dart:io'; import 'package:uuid/uuid.dart'; // class FeedModel { String id; String contents; List<File> images; DateTime createdAt; // FeedModel({ required this.contents, required this.images, }) : id = Uuid().v4(), createdAt = DateTime.now(); }
feed_model.dart파일을 만들어FeedModel을 설계한다.
📌 uuid 라이브러리 설치
flutter pub add uuid
- Terminal에
uuid라이브러리를 설치한다.
📌 ThreadFeedWriteController save 이벤트 개발
void save() { Get.back( result: FeedModel( contents: contents, images: selectedImages?.map<File>((e) => File(e.path)).toList() ?? [], ), ); }
- 저장 이벤트에서는
selectedImages의 경우 XFile 모델이기 때문에,map함수를 사용해서File로 변환해줘야 한다.
📌 저장 이벤트 연결
GetBuilder<ThreadFeedWriteController>(builder: (controller) { return GestureDetector( onTap: () { if (controller.contents != '') { Get.find<ThreadFeedWriteController>().save(); } }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 15), decoration: BoxDecoration( borderRadius: BorderRadius.circular(50), color: controller.contents != '' ? Colors.black : Color(0xff9a9a9a), ), child: Text('게시'), ), ); })
- 모델을 만들고
Get.back이벤트 함수에result라는 파라미터로FeedModel을 담아서 넘겨주고 있다.
원래Get.back은 뒤로 가기 이벤트지만, 거기에result를 담을 수 있는데 이것을 통해 피드 등록 페이지를 불렀던 홈 화면에서 저장된FeedModel을 받아볼 수 있게 된다.
📌 홈 화면 result 확인
var result = await Get.to<FeedModel?>(ThreadWritePage(), binding: BindingsBuilder(() { Get.put(ThreadFeedWriteController()); })); if (result != null) { print(result.id); print(result.contents); print(result.images.length); }
- 홈화면으로 돌아가면 원하던 result 값을 얻을 수 있다.
피드 등록 페이지에서 완료 후 등록한 피드를 홈 화면에서 보기 위해서는 등록 화면에서 전달된 result model을 홈 피드 리스트 상태 관리에 추가를 해주는 작업을 해줘야 한다.
💡 실 프로젝트에서는 피드 등록 페이지에서 저장 시 서버로 데이터를 저장시키고, 홈 필드로 돌아왔을 때 피드를 새로고침하여 서버로부터 피드 리스트를 새롭게 받아오도록 처리를 한다.
하지만 아직 api 통신 관련 학습 전이기 때문에 단순히 화면에 상태 관리로 보여주는 방식을 학습하기 위해 선택한 방법이라는 부분 참고한다.
📌 HomeFeedListController 생성
import 'package:get/get.dart'; // class HomeFeedListcontroller extends GetxController {}
home_feed_list_controller.dart파일을 만들어 컨트롤러를 생성한다.
📌 main.dart 파일내 HomeFeedListController 의존성 주입
Get.put(HomeFeedListcontroller());
- 생성된
controller를 홈 화면에서 접근하여 사용하기 위해 의존성을 주입한다.
📌 HomeFeedListController 피드리스트 상태 추가
import 'package:get/get.dart'; import 'package:thread_app_sample/feed_model.dart'; // class HomeFeedListcontroller extends GetxController { List<FeedModel> feedList = []; // void addFeed(FeedModel feed) { feedList.add(feed); update(); } }
- 이제 홈 화면에서 피드 리스트를 보여줄 것을 위해 상태를 만들어준다.
피드 리스트 상태와 피드를 등록했을 때 추가하는 함수를 추가했다.
📌 home.dart 파일에 HomeFeedListController 를 통해 UI동기화
Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: ListView( children: [ _header(), SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: _quickFeedWriteView(), ), Divider(), GetBuilder<HomeFeedListcontroller>( builder: (controller) { if (controller.feedList.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(30.0), child: Text( '피드가 없습니다.', style: TextStyle(color: Colors.black), ), ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), child: _singleFeed(), ); . . .
- 홈 화면에서 피드 리스트 상태를 바탕으로 화면을 그린다.
controller의feedList가 없을 경우를 대비해서 메세지 위젯으로 대응한다.
📌 피드 추가 완료 후 이벤트 연결
Widget _quickFeedWriteView() { return GestureDetector( onTap: () async { var result = await Get.to<FeedModel?>(ThreadWritePage(), binding: BindingsBuilder(() { Get.put(ThreadFeedWriteController()); })); if (result != null) { Get.find<HomeFeedListcontroller>().addFeed(result); } },
- 피드 등록 후 전달받은
result를homeFeedListController로 상태를 추가하는 것을 연결해준다.
이제 등록을 하면 그와 동시에 피드가 생기긴 했는데 직접 입력한 피드가 아닌 고정된 피드가 계속적으로 보이는 것을 확인할 수 있다.
그 이유는feddList의 직접적인 데이터로 화면 위젯을 배치하지 않았기 때문이다.
📌 feedList 데이터 활용 1
return Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: Column( children: List.generate( controller.feedList.length, (index) => _singleFeed(controller.feedList[index]), )), );📌 feedList 데이터 활용 _singleFeed 함수 수정
Widget _singleFeed(FeedModel model) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _leftProfileArea(), Expanded( child: _contentArea(model), ), ], ); }📌 feedList 데이터 활용 _contentArea 함수 수정
Widget _contentArea(FeedModel model) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 30, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text( '개발하는남자', style: TextStyle( color: Colors.black, fontSize: 14, fontWeight: FontWeight.bold, ), ), SizedBox(width: 7), Text( model.createdAt.toString(), style: TextStyle( color: Color(0xff999999), fontSize: 14, ), ) ], ), GestureDetector( onTap: () {}, child: Icon( Icons.more_horiz, color: Color(0xff999999), ), ) ], ), ), Text( model.contents, style: TextStyle(color: Colors.black), ), SizedBox(height: 10), if (model.images.isNotEmpty) SizedBox( height: 200, child: PageView( padEnds: false, controller: PageController(viewportFraction: 0.75), children: [ ...model.images.map<Widget>( (e) => Image.file(e), ) ]), ), SizedBox(height: 10), Row( children: [ GestureDetector( onTap: () {}, child: SizedBox( width: 55, child: Row( children: [ Image.asset( 'assets/images/icon_like.png', width: 30, ), Text('24'), ], ), ), ), GestureDetector( onTap: () {}, child: SizedBox( width: 55, child: Row( children: [ Image.asset( 'assets/images/icon_message.png', width: 30, ), Text('14'), ], ), ), ), GestureDetector( onTap: () {}, child: SizedBox( width: 55, child: Row( children: [ Image.asset( 'assets/images/icon_share.png', width: 30, ), Text('7'), ], ), ), ), GestureDetector( onTap: () {}, child: Image.asset( 'assets/images/icon_send.png', width: 30, ), ), ], ), ], ); }
수정 후 이미지까지 등록해보면 텍스트만 있을 때도 이미지를 넣었을 때와 같이 피드 하나당 영역을 크게 차지하고 있다.
📌 _leftProfileArea 고정된 line 부분 제거
Widget _leftProfileArea() { return Column( children: [ SizedBox( width: 60, height: 60, child: Stack( children: [ Align( alignment: Alignment.centerLeft, child: ClipRRect( borderRadius: BorderRadius.circular(50), child: Image.network( 'https://yt3.googleusercontent.com/XmYJ7m6JFlhA5BNLnQdnlew7g1E6YGSE4p8hl8ow_pOI6-cZkGdjo38oJhBG7NPrj9eawodgqA=s900-c-k-c0x00ffffff-no-rj', width: 50, ), ), ), Positioned( right: 5, bottom: 2, child: SizedBox( width: 20, height: 20, child: ClipRRect( borderRadius: BorderRadius.circular(20), child: Container( decoration: BoxDecoration(color: Colors.black), child: Icon( Icons.add, color: Colors.white, size: 17, ), ), ), )) ], ), ), ], ); }
- 좌측 선 위젯 때문에 발생하는 문제로, 해당
line부분을 제거한다.
콘텐츠 영역만큼 line이 그려지게 하는 방법도 있으나, 복잡해서 넘어간다.
피드 등록 화면에서 사용되는 이미지 정렬 PageView 소스코드를 재사용하면 손쉽게 이부분을 넘길 수 있다.
코드 재사용을 하기 위해서는 컴포넌트로 별도의 위젯으로 만들어줘야한다.
📌 image_view_widget 컨포넌트
import 'dart:io'; import 'package:flutter/material.dart'; // class ImageViewWidget extends StatelessWidget { final List<File> images; const ImageViewWidget({super.key, required this.images}); // Widget build(BuildContext context) { return SizedBox( height: 250, child: PageView( padEnds: false, pageSnapping: false, controller: PageController(viewportFraction: 0.4), children: List.generate( images.length ?? 0, (index) => Padding( padding: const EdgeInsets.all(4.0), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Stack(children: [ Positioned( left: 0, right: 0, top: 0, bottom: 0, child: Image.file( images[index], fit: BoxFit.cover, ), ), Positioned( right: 5, top: 5, child: Icon( Icons.close, color: Colors.white, ), ) ]), ), ), ).toList(), ), ); } }
📌 thread_write_page 리펙토링
Expanded(child: GetBuilder<ThreadFeedWriteController>( builder: (controller) { if (controller.selectedImages == null || (controller.selectedImages?.isEmpty ?? true)) { return Container(); } return ImageViewWidget( images: controller.selectedImages ?.map<File>((e) => File(e.path)) .toList() ?? [], ); }, )),
📌 home 리펙토링
if (model.images.isNotEmpty) ImageViewWidget(images: model.images),
- 정상적으로 처리가 된다.
📌 timeago 라이브러리 설치
flutter pub add timeago
- Terminal에
timeago라이브러리를 설치한다.
📌 main.dart timeage 언어 설정
import 'package:timeago/timeago.dart' as timeago;timeago.setLocaleMessages('ko', timeago.KoMessages());
- main.dart파일 상단에
import를 해주고, 하단에는 timeago의 언어를 설정한다.
📌 시간 format
Text( timeago.format( DateTime.now().subtract( DateTime.now().difference(model.createdAt)), locale: 'ko'), style: TextStyle( color: Color(0xff999999), fontSize: 14, ), )
- 시간 포맷부분을
timeago로 설정한다.
피드별 더보기 아이콘을 누르면 bottomSheet를 열어서 수정, 삭제를 할 수 있도록 메뉴를 활성화한다.
📌 더보기 아이콘 클릭 이벤트 연결
GestureDetector( onTap: () { _showCupertinoActionSheet(); }, child: Icon( Icons.more_horiz, color: Color(0xff999999), ), )
📌 bottomSheet 메뉴 활성화
void _showCupertinoActionSheet() { showCupertinoModalPopup( context: Get.context!, builder: (BuildContext context) => CupertinoActionSheet( actions: <CupertinoActionSheetAction>[ CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); print('Edit Pressed'); }, child: Text('수정'), ), CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); print('Delete Pressed'); }, isDestructiveAction: true, child: Text('삭제'), ), ], cancelButton: CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); }, child: Text('취소'), ), ), ); }
💡
BottomSheet형식의 레이어를 활성화하기 위해서는BuildContext가 있어야 한다.
위젯은 모든 것이 연결되어 있기 때문에BottomSheet가 띄워질 위치를 최상위로 찾아가서 위젯을 띄워야 하기 때문이다.
그런데StatelessWidget의 경우BuildContext가 위젯 내에서 참조할 수 없다.
별도의 함수에서context에 접근하기 위해서는build함수에 파라미터로 전달이 되는BuildContext를 함수로 전달해 줘서 처리할 수 있다.
만일 함수가 깊은 함수로 있다고 한다면BuildContext를 지속적으로 전달을 해줘야 하는 불편한 상황이 발생되는데, 이는GetX를 사용하면 손쉽게 context를 사용할 수 있다.
📌 HomeFeedListController 삭제 함수 개발
void removeFeed(String feedId) { feedList.removeWhere((feed) => feed.id == feedId); update(); }
bottomSheet메뉴 중 삭제 버튼을 누른다면 해당 feed의id로HomeFeedListController에 전달하여feedList에서 해당되는id의 피드를 삭제해준다.
📌 bottomSheet메뉴 삭제 함수 연결
void _showCupertinoActionSheet(String feedId) { showCupertinoModalPopup( context: Get.context!, builder: (BuildContext context) => CupertinoActionSheet( actions: <CupertinoActionSheetAction>[ CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); print('Edit Pressed'); }, child: Text('수정'), ), CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); Get.find<HomeFeedListcontroller>().removeFeed(feedId); }, isDestructiveAction: true, child: Text('삭제'), ), ], cancelButton: CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); }, child: Text('취소'), ), ), ); }
- bottomSheet 메뉴 클릭 시 해당
controller의remove함수를 호출한다.
actions에 수정 및 삭제를 추가하였고,cancelButton으로 제일 밑 취소 버튼을 만들었다.
📌 feedId 전달
GestureDetector( onTap: () { _showCupertinoActionSheet(model.id); }, child: Icon( Icons.more_horiz, color: Color(0xff999999), ), )
- bottomSheet를 열 때 어떤 피드인지를 알고 있어야 하기 때문에
feedId를 전달한다.
이렇게 3주차 강의를 마무리했는데, 실습 부분이 굉장히 빠르고 강의 시간이 길지 않아 금방 넘어간 것 같다.
나중에 처음부터 다시 차근차근 학습을 해봐야 할 것 같다.
내일은 4주차 강의를 학습하는데, 파이어베이스를 다루고 있다.
듣기로는 굉장히 어렵고 복잡하다고 하는데, 강의 시간은 길지 않아서 걱정이 좀 된다.
구글의 무료(부분유로) 임대 서버로 1인 개발자들이 서버개설까지 힘들 때 사용한다고 들어서 일단 이해하고 넘어가면 좋을 것 같다.