2차 팀 프로젝트 : BookBox (Front)

도서 대여/예약 앱 플랫폼 : Flutter
SHIN's avatar
Oct 22, 2024
2차 팀 프로젝트 : BookBox (Front)
 
패키지 구조 만들고 로딩 알고리즘 만듬
notion image
일단 바텀 네비게이션바의 홈은 탭바 를 갖고 있고, 각 탭바는 탭바 뷰를 갖고 있다.
이때 알아야 되는건, 탭바 뷰 는 Page 가 아니고 컴포넌트라고 생각해야 한다. 탭바가 움직일 때 모든 페이지가 랜더링 되는 것이 아니라 오렌지색인 내서재 에 해당하는 곳이 랜더링 되는 것이기 때문이다.
 

1. 기본 코드

class MainPage extends StatefulWidget { const MainPage({super.key}); @override State<MainPage> createState() => _MainPageState(); } class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin { late TabController controller; //탭의 컨트롤러 int _currentTab = 0; // 바텀네비바의 인덱스 // 각 탭의 제목 리스트 final List<String> titles = ['홈', '내서재', '검색', '설정']; @override void initState() { super.initState(); controller = TabController(length: 4, vsync: this); // 탭바를 정의한다. } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( // AppBar의 title을 현재 선택된 탭의 제목으로 설정 appBar: CustomAppBar( titleText: titles[_currentTab], ), body: IndexedStack( index: _currentTab, children: [ // 하위 항목은 모두 bottomNavigationBar 의 인덱스 페이지 HomePage(), // 탭바를 갖고 있다. LibraryPage(), SearchPage(), SettingPage(), ], ), bottomNavigationBar: _bottomNavigation()); } }
_bottomNavigation 위젯 코드
BottomNavigationBar _bottomNavigation() { return BottomNavigationBar( selectedItemColor: Colors.blue, unselectedItemColor: Colors.black38, selectedFontSize: 10, unselectedFontSize: 10, type: BottomNavigationBarType.fixed, onTap: (index) { setState(() { _currentTab = index; }); }, //현재 선택된 index 지정 currentIndex: _currentTab, items: [ const BottomNavigationBarItem( label: '홈', icon: Icon(CupertinoIcons.house), ), const BottomNavigationBarItem( label: '내서재', icon: Icon(CupertinoIcons.book), ), const BottomNavigationBarItem( label: '검색', icon: Icon(CupertinoIcons.search), ), const BottomNavigationBarItem( label: '설정', icon: Icon(CupertinoIcons.settings), ), ], ); }
 
이때 큰 문제가 발생한다.
제일 처음 호출되는 MainPage 는 HomePage(), LibraryPage(), SearchPage(), SettingPage(), 를 갖고 있기 때문에 한번에 모든 페이지를 가져오려고 한다. (로딩이 100년 걸리게 된다.) 그렇기 때문에 필요한 페이지만 가져오는 알고리즘을 추가해야 한다.
 

2. 로딩 최적화

var loadPages = [0]; 를 추가한다. loadPages 는 현재 로딩 되어 진 페이지를 저장하는 곳이다. 기본적으로 home 을 갖고있게 되는데 (0번) 얘는 로딩시 처음 뜨는 곳이라 바로 넣어두었다.
 
children: [ loadPages.contains(0) ? const HomePage() : Container(), loadPages.contains(1) ? const LibraryPage() : Container(), loadPages.contains(2) ? const SearchPage() : Container(), loadPages.contains(3) ? const SettingPage() : Container(), ],
배열에 해당 인덱스가 들어있다면 페이지를 랜더링 하고, 아닐 시 빈 Container 를 만든다.
이후 탭을 눌렀을 때 배열에 인덱스를 추가하여 랜더링 할 수 있도록 하고, const 를 사용하여 이미 랜더링 되었던 화면은 다시 그리지 않도록 하였다.
 
notion image
 

전체코드

import 'package:bookbox/ui/components/custom_app_bar.dart'; import 'package:bookbox/ui/main/home/home_page.dart'; import 'package:bookbox/ui/main/library/library_page.dart'; import 'package:bookbox/ui/main/search/search_page.dart'; import 'package:bookbox/ui/main/setting/setting_page.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class MainPage extends StatefulWidget { const MainPage({super.key}); @override State<MainPage> createState() => _MainPageState(); } class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin { late TabController controller; int _currentTab = 0; var loadPages = [0]; //저장되는 곳 // 각 탭의 제목 리스트 final List<String> titles = ['홈', '내서재', '검색', '설정']; @override void initState() { super.initState(); controller = TabController(length: 4, vsync: this); } @override void dispose() { super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( // AppBar의 title을 현재 선택된 탭의 제목으로 설정 appBar: CustomAppBar( titleText: titles[_currentTab], ), //titleText: titles[index], body: IndexedStack( index: _currentTab, children: [ loadPages.contains(0) ? const HomePage() : Container(), loadPages.contains(1) ? const LibraryPage() : Container(), loadPages.contains(2) ? const SearchPage() : Container(), loadPages.contains(3) ? const SettingPage() : Container(), ], ), bottomNavigationBar: _bottomNavigation()); } BottomNavigationBar _bottomNavigation() { return BottomNavigationBar( selectedItemColor: Colors.blue, unselectedItemColor: Colors.black38, selectedFontSize: 10, unselectedFontSize: 10, type: BottomNavigationBarType.fixed, currentIndex: _currentTab, onTap: (index) { var pages = loadPages; if (!pages.contains(index)) { pages.add(index); print(pages); } _currentTab = index; loadPages = pages; setState(() {}); }, //현재 선택된 index 지정 items: [ const BottomNavigationBarItem( label: '홈', icon: Icon(CupertinoIcons.house), ), const BottomNavigationBarItem( label: '내서재', icon: Icon(CupertinoIcons.book), ), const BottomNavigationBarItem( label: '검색', icon: Icon(CupertinoIcons.search), ), const BottomNavigationBarItem( label: '설정', icon: Icon(CupertinoIcons.settings), ), ], ); } }
 

패키지

notion image
notion image
패키지는 각 페이지이고, 패키지 내부에 탭을 위한 패키지를 생성했다. 이제 내부 요소를 만들면 된다.
 
추천 페이지. ListView와 GridView 사용
notion image
 

1. 텍스트 위젯 추가

일단 해당 탭을 정의하는 Text 를 넣었다. 처음에는 Grid 이미지와 별개로 존재한다 생각하여 컬럼 내부에 넣었더니, 스크롤이 올라가도 "00님\nBookBox 에서 추천하는 책을 만나보세요.", 라는 텍스트가 계속 존재했음. 따라서 ListView 내부에 넣었다.
Text( "00님\nBookBox 에서 추천하는 책을 만나보세요.", style: theme.bodyLarge, ),
 

2. GridView 설정

ListView 는 스크롤이 영원히 늘어나는 속성을 갖고 있기 때문에 GridView 를 사용하기 적절하지 않다. 그러나 사용하고 싶기 때문에 그리드뷰 빌더에 두가지 속성을 추가 했다.
physics: NeverScrollableScrollPhysics(), // 그리드 스크롤 비활성화 shrinkWrap: true, // 그리드의 높이를 내용에 맞게 조절
 
기본적으로 ListView , GridView 둘 다 스크롤을 중복해서 갖게 되기 때문에, 그리드 뷰의 스크롤을 비활성화 하고, shrinkWrap 를 사용하여 높이를 임의로 할당했다.
 

2-1. 그리드 이미지 크기 구하기

그리드뷰의 넓이는 미디어 쿼리를 사용하여 계산했다.
var size = MediaQuery.of(context).size; final double itemHeight = (size.height - kToolbarHeight - 24) / 2; final double itemWidth = size.width / 2.3;
GridView.builder( physics: NeverScrollableScrollPhysics(), // 그리드 스크롤 비활성화 shrinkWrap: true, // 그리드의 높이를 내용에 맞게 조절 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisSpacing: 10, crossAxisCount: 3, mainAxisSpacing: 10, childAspectRatio: (itemWidth / itemHeight), ),
childAspectRatio 를 사용하여 컨테이너의 크기를 지정했다.
 
그리고 리턴값에 RecommendItem 위젯을 호출한다.
 

3. 위젯 만들기

notion image
위젯은 적절하게 스타일을 주고, 글자수가 길이를 초과하는 것을 방지하기 위해 maxLines , overflow 를 사용했다. maxLines : 줄 길이 설정
overflow : 넓이 초과하는 글자 처리 법
maxLines: 2, overflow: TextOverflow.ellipsis,
 
 

전체 코드

import 'package:bookbox/core/constants/size.dart'; import 'package:bookbox/core/constants/styles.dart'; import 'package:bookbox/ui/main/home/recommend_tab/recommend_item.dart'; import 'package:flutter/material.dart'; class RecommendTab extends StatelessWidget { @override Widget build(BuildContext context) { TextTheme theme = textTheme(); var size = MediaQuery.of(context).size; final double itemHeight = (size.height - kToolbarHeight - 24) / 2; final double itemWidth = size.width / 2.3; return Padding( padding: const EdgeInsets.all(gap_s), child: ListView( children: [ Padding( padding: const EdgeInsets.only(bottom: gap_s), // 아래 간격 조정 child: Text( "00님\nBookBox 에서 추천하는 책을 만나보세요.", style: theme.bodyLarge, ), ), GridView.builder( physics: NeverScrollableScrollPhysics(), // 그리드 스크롤 비활성화 shrinkWrap: true, // 그리드의 높이를 내용에 맞게 조절 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisSpacing: 10, crossAxisCount: 3, mainAxisSpacing: 10, childAspectRatio: (itemWidth / itemHeight), ), itemCount: 12, itemBuilder: (context, index) { return RecommendItem( imageUrl: "https://picsum.photos/id/${index + 10}/200/280", // 이미지 URL title: "책 제목\n제목길면청길면 $index", // 책 제목 author: "저자이름 $index 엄청길면", // 저자 이름 ); }, ), ], ), ); } }
import 'package:bookbox/core/constants/size.dart'; import 'package:flutter/material.dart'; class RecommendItem extends StatelessWidget { final String imageUrl; // 이미지 URL final String title; // 책 제목 final String author; // 저자 RecommendItem({ required this.imageUrl, required this.title, required this.author, }); @override Widget build(BuildContext context) { return Container( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 이미지 표시 ClipRRect( child: Container( decoration: BoxDecoration( border: Border.all( color: Colors.grey, width: 2, ), ), child: Image.network( imageUrl, // 너비를 부모에 맞추기 fit: BoxFit.cover, // 이미지가 잘리도록 설정 ), ), ), Padding( padding: const EdgeInsets.all(8.0), // 패딩 추가 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, overflow: TextOverflow.ellipsis, // 제목 스타일 ), Text( author, style: TextStyle(color: Colors.grey), maxLines: 1, overflow: TextOverflow.ellipsis, // 저자 스타일 ), ], ), ), ], ), ); } }
 
주제별(ExpansionPanel) 토글 같은 기능
일단 더미 데이터를 넣어줍니다.
class Cate { int categoryId; String categoryName; Cate({ required this.categoryId, required this.categoryName, }); } List<Cate> cateList = [ Cate(categoryId: 112011, categoryName: '소설'), Cate(categoryId: 170, categoryName: '경제경영'), //생략 Cate(categoryId: 8257, categoryName: '대학교재/전문서적'), ];
cateList 를 확인하여 for 문을 돌릴 수 있게 되었습니다.
 
notion image
오늘 만들 것. 제일 처음 넣은 더미 데이터들이 버튼이 되어 나오고 있습니다.
 
이런 기능을 지원하는 ExpansionPanel 클래스를 사용하여 구현해 봅니다.
bool _isOpen = false; // 상태 변수 초기화
해당 칸이 열려 있는지 여부를 저장하는 isOpen 이 필요하고, 따라서 이 클래스는 StatefulWidget 을 상속하도록 변경 되어야 합니다.
ExpansionPanelList 내부에 간단한 스타일 지정을 하고, children 내부에 판넬 코드를 작성합니다.
Widget _buildExpansionPanelList(TextTheme theme) { return ExpansionPanelList( dividerColor: Colors.transparent, elevation: 0, // 그림자 효과 expandedHeaderPadding: EdgeInsets.all(0), children: [ _buildExpansionPanel(theme), // 메서드로 분리된 개별 패널 ], expansionCallback: (panelIndex, isOpen) { setState(() { _isOpen = !_isOpen; }); // 패널 상태 변경 }, ); }
보통은 노션의 토글 기능처럼 제목-컨텐츠로 이루어 지지만, 이번 경우에는 카테고리[0-3], 카테고리[4-끝] 이 안에 나와야 하는 문제가 있었습니다.
notion image
child: Wrap( spacing: 8.0, runSpacing: 8.0, children: cateList.take(4).map((cate) { return _buildCategoryButton(cate.categoryName, cate.categoryId); }).toList(), ),
Wrap 을 사용하여 해당 넓이를 초과하지 않도록 하고, cateList.take(4).map 을 통해 초반 표시될 카테고리의 갯수를 지정했습니다.
 
child: Wrap( spacing: 8.0, runSpacing: 8.0, children: cateList.sublist(4).map((cate) { return _buildCategoryButton(cate.categoryName, cate.categoryId); }).toList(), ),
cateList.sublist(4).map 을 통해 인문학 이후의 카테고리 부터 표시 되도록 설정했습니다.

전체코드(_buildExpansionPanel)

// 개별 ExpansionPanel 빌드 메서드 ExpansionPanel _buildExpansionPanel(TextTheme theme) { final double panelHeight = 20.0; return ExpansionPanel( backgroundColor: Colors.white, headerBuilder: (context, isOpen) { return Container( height: panelHeight, // Set the height alignment: Alignment.centerLeft, // Optional: Align content child: Wrap( spacing: 8.0, runSpacing: 8.0, children: cateList.take(4).map((cate) { return _buildCategoryButton(cate.categoryName, cate.categoryId); }).toList(), ), ); }, body: Container( alignment: Alignment.centerLeft, child: Wrap( spacing: 8.0, runSpacing: 8.0, children: cateList.sublist(4).map((cate) { return _buildCategoryButton(cate.categoryName, cate.categoryId); }).toList(), ), ), isExpanded: _isOpen, ); } }
 

_buildCategoryButton

버튼 스타일 입니다. 버튼이 눌릴 때 마다 ajax 요청을 보내야 하기 때문에 onTap 사용이 필요합니다. 따라서 container 였던 위젯을 InkWell 로 변경 했습니다.
Widget _buildCategoryButton(String categoryName, int categoryId) { return InkWell( onTap: () { print('카테고리 ID: $categoryId'); }, borderRadius: BorderRadius.circular(20), // 클릭 영역 둥글게 설정 child: Container( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: Colors.grey[200], // 배경 색상 설정 borderRadius: BorderRadius.circular(20), // 모서리 둥글게 border: Border.all(color: Colors.grey[400]!), ), child: Text( categoryName, // 카테고리 이름 표시 style: TextStyle( color: Colors.black, // 텍스트 색상 ), ), ), ); }
 
신고 탭
notion image
notion image
내 댓글인 경우 → 수정, 삭제가 보이게
내 댓글이 아닌 경우 → 신고할 수 있게
분기를 아직 못나눠서 이렇게 여러개가 나열 되었다.
 
일단 신고 탭의 코드는 다음과 같다. 운영자가 정해놓은 신고 사유들을 List 에 저장해 놓고 RadioListTile 을 사용하여 하나만 선택할 수 있도록 하였다. 사유가 onChange 될때 선택된 사유의 상태를 바꿈으로써 마지막에 선택된 사유를 저장한다.
class ReportDialog extends StatefulWidget { @override _ReportDialogState createState() => _ReportDialogState(); } class _ReportDialogState extends State<ReportDialog> { String? _selectedReason; final List<String> _reasons = [ '욕설', '스팸', '부적절한 콘텐츠', '혐오 발언', '위협', '기타', ]; @override Widget build(BuildContext context) { return AlertDialog( title: Text('댓글 신고'), content: Column( mainAxisSize: MainAxisSize.min, children: _reasons.map((reason) { return RadioListTile<String>( title: Text(reason), value: reason, groupValue: _selectedReason, onChanged: (value) { setState(() { _selectedReason = value; }); }, ); }).toList(), ), actions: [ TextButton( onPressed: () { Navigator.pop(context); }, child: Text('취소'), ), TextButton( onPressed: () { if (_selectedReason != null) { print('신고 사유: $_selectedReason'); // 여기서 신고 처리를 수행자리. } Navigator.pop(context); }, child: Text('신고'), ), ], ); } }
 
이 신고 탭을 호출하는 부분이다.
void _showReportDialog(BuildContext context) { showDialog( context: context, builder: (BuildContext context) { return ReportDialog(); }, ); }
 
 
따라서 JWT 받아와서 분기 구분이 가능해 지면 _reviewActions 내부에서 분기를 나누면 되겠다.
notion image
리뷰 버튼 부분인데 나중에 JWT 체크해서 주석있는 if 부분에 추가하여 분기를 나누어야 한다!
그렇게 하면 올바른 버튼이 나타나겠지.
Share article

SHIN