패키지 구조 만들고 로딩 알고리즘 만듬

일단 바텀 네비게이션바의 홈은
탭바
를 갖고 있고, 각 탭바는 탭바 뷰를 갖고 있다.이때 알아야 되는건,
탭바 뷰
는 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
를 사용하여 이미 랜더링 되었던 화면은 다시 그리지 않도록 하였다.
전체코드
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),
),
],
);
}
}
패키지


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

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. 위젯 만들기

위젯은 적절하게 스타일을 주고, 글자수가 길이를 초과하는 것을 방지하기 위해
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 문을 돌릴 수 있게 되었습니다.

오늘 만들 것. 제일 처음 넣은 더미 데이터들이 버튼이 되어 나오고 있습니다.
이런 기능을 지원하는
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-끝] 이 안에 나와야 하는 문제가 있었습니다.

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, // 텍스트 색상
),
),
),
);
}
신고 탭


내 댓글인 경우 → 수정, 삭제가 보이게
내 댓글이 아닌 경우 → 신고할 수 있게
분기를 아직 못나눠서 이렇게 여러개가 나열 되었다.
일단 신고 탭의 코드는 다음과 같다.
운영자가 정해놓은 신고 사유들을 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
내부에서 분기를 나누면 되겠다.
리뷰 버튼 부분인데 나중에 JWT 체크해서 주석있는 if 부분에 추가하여 분기를 나누어야 한다!
그렇게 하면 올바른 버튼이 나타나겠지.
Share article