지난번에는 title 로 구현한 인디케이터를 숨기기 위해서 Scaffold 를 추가, LinearGradient 를 구현했었습니다.
이번에는 게시글의 상세 정보를 화면에 표시 및 Custom bottomNavigationBar 구현해 보겠습니다.
개발환경 : 윈도우11, 안드로이드 스튜디오, flutter 3.0.1
구현 전/후 하면은 아래와 같습니다.
./src/screens/home/item_detail_page.dart - 전체 코드는 아래 "더보기" 를 클릭하세요
더보기
import 'package:apple_market3/src/states/category_controller.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import '../../constants/common_size.dart';
import '../../models/item_model.dart';
import '../../repo/item_service.dart';
import '../../states/category_controller.dart';
import '../../utils/logger.dart';
class ItemDetailPage extends StatefulWidget {
const ItemDetailPage({Key? key}) : super(key: key);
@override
_ItemDetailPageState createState() => _ItemDetailPageState();
}
class _ItemDetailPageState extends State<ItemDetailPage> {
final PageController _pageController = PageController();
// 스크롤이 얼마나 되었는지 알기 위해서 컨트롤러 등록,
final ScrollController _scrollController = ScrollController();
// isAppbarCollapsed 이미지가 화면에서 사라졌는지 확인,
bool isAppbarCollapsed = false;
Size? _size;
num? _statusBarHeight;
late String newItemKey;
final Widget _textGap = const SizedBox(height: padding_16);
Widget _divider(double _height) {
return Divider(
height: _height, //padding_16 * 2 + 1,
thickness: 2,
color: Colors.grey[300]!,
);
}
@override
void initState() {
newItemKey = Get.arguments['itemKey'];
logger.d('$_size!.width, $kToolbarHeight, $_statusBarHeight, ${isAppbarCollapsed.toString()}');
// 스크롤이 발생할때 마다 addListener 가 실행됨,
_scrollController.addListener(() {
if (_size == null && _statusBarHeight == null) return;
if (isAppbarCollapsed) {
// 여기는 이미지가 앱바 아래로 보여지기 시작하는 시점,
// 앱바 사이즈(kToolbarHeight), 상태바 사이즈(_statusBarHeight)
if (_scrollController.offset < _size!.width - kToolbarHeight - _statusBarHeight!) {
isAppbarCollapsed = false;
setState(() {});
}
} else {
// 여기는 이미지가 앱바에 위로 올라가서 안보이기 시작하는 시점,
// 앱바 사이즈(kToolbarHeight), 상태바 사이즈(_statusBarHeight)
if (_scrollController.offset > _size!.width - kToolbarHeight - _statusBarHeight!) {
isAppbarCollapsed = true;
setState(() {});
}
}
});
super.initState();
}
@override
void dispose() {
_pageController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
logger.d('item detail screen >> build >>> [$newItemKey]');
return FutureBuilder<ItemModel2>(
future: ItemService().getItem(newItemKey),
builder: (context, snapshot) {
if (snapshot.hasData) {
// FutureBuilder 의 snapshot 에서 게시글 데이터 가져오기
ItemModel2 itemModel = snapshot.data!;
return LayoutBuilder(
builder: (context, constraints) {
_size = MediaQuery.of(context).size;
// 상태바 길이 가져오는 공식,
_statusBarHeight = MediaQuery.of(context).padding.top;
return Stack(
// fit 은 Stack 에 있는 모든 아이콘들이 화면에 가득차게 하는 옵션,
fit: StackFit.expand,
children: [
// 메인 정보를 표시하는 영역
Scaffold(
// 화면 하단을 네비로 표시함
bottomNavigationBar: SafeArea(
// 전화기의 하단 메뉴파와 앱 영역이 중복되는거 피하기 위해서, SafeArea 처리함,
top: false,
bottom: true, // 아래 영역만 피하기 위해서,
child: Container(
// Row/VerticalDivider 의 사이즈를 제한하기 위해서 Container&height 처리함,
height: 80,
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey[300]!),
bottom: BorderSide(color: Colors.grey[300]!),
),
),
child: Padding(
padding: const EdgeInsets.all(padding_08),
child: Row(
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.favorite_border),
),
const VerticalDivider(
thickness: 1,
width: padding_08 * 2 + 1,
indent: padding_08,
endIndent: padding_08,
),
Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// crossAxisAlignment: CrossAxisAlignment.auth,
children: [
Text(
itemModel.price.toString(),
style: Theme.of(context).textTheme.bodyText1,
),
Text(
itemModel.negotiable ? '가격제안가능' : '가격제안불가',
style: itemModel.negotiable
? Theme.of(context)
.textTheme
.bodyText2!
.copyWith(color: Colors.blue)
: Theme.of(context).textTheme.bodyText2,
),
],
),
Expanded(child: Container()),
TextButton(
onPressed: () {
// _goToChatroom(itemModel, userModel);
},
child: const Text('채팅으로 거래하기'),
),
],
),
),
),
),
// 메인정보를 표시, CustomScrollView 는 listView 유사함
// listView 대신에 CustomScrollView 사용하는 이유는
// slivers 를 이용해서 화면을 구역으로 나눠서 각 구역마다 슬라이스를 구현할 수 있다,
body: CustomScrollView(
controller: _scrollController,
// children 을 대신하는 slivers 있고, slivers 안에는 sliver 형식의 위젯을 넣어줘야 한다
slivers: [
// 업로드한 사진 정보를 표시하는 영역
_imageAppBar(itemModel),
// 상세 텍스트 정보를 표시하는 영역
SliverPadding(
padding: const EdgeInsets.all(padding_16),
sliver: SliverList(
// SliverToBoxAdapter 로 각각 처리할수도 있고,
// SliverList 를 이용하여 리스트 형식으로도 처리 가능,
delegate: SliverChildListDelegate([
_userSection(itemModel),
_divider(padding_16 * 2 + 1),
Text(
itemModel.title,
style: Theme.of(context).textTheme.headline6,
),
_textGap,
Row(
children: [
Text(
categoriesMapEngToKor[itemModel.category] ?? '선택',
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(decoration: TextDecoration.underline),
),
Text(
// ' · ${TimeCalculation.getTimeDiff(itemModel.createdDate)}',
' · 2분전',
style: Theme.of(context).textTheme.bodyText2,
),
],
),
_textGap,
Text(
itemModel.detail,
style: Theme.of(context).textTheme.bodyText1,
),
_textGap,
Text(
'조회 33',
style: Theme.of(context).textTheme.caption,
),
_textGap,
_divider(2),
MaterialButton(
padding: EdgeInsets.zero,
onPressed: () {
// 이메일로 처리하고 싶으면 flutter_email_sender 참고할 것
// https://pub.dev/packages/flutter_email_sender
logger.d('게시글을 신고합니다');
},
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'이 게시글 신고하기',
style: Theme.of(context)
.textTheme
.button!
.copyWith(color: Colors.black87),
),
),
),
_divider(2),
]),
),
),
],
),
),
// 앱바 영역에 그라데이션 표현 추가
Positioned(
left: 0,
right: 0,
top: 0,
height: kToolbarHeight + _statusBarHeight!,
child: Container(
height: kToolbarHeight + _statusBarHeight!,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black45,
Colors.black38,
Colors.black26,
Colors.black12,
Colors.transparent,
],
),
),
),
),
// 화면 스크롤업 하면 앱바를 힌색으로 변경.
// 이전에 구현한 인디케이터가 appBar 타이틀위치에서 보여주던걸 숨김,
Positioned(
left: 0,
right: 0,
top: 0,
height: kToolbarHeight + _statusBarHeight!,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
shadowColor: Colors.transparent,
backgroundColor: isAppbarCollapsed ? Colors.white : Colors.transparent,
foregroundColor: isAppbarCollapsed ? Colors.black87 : Colors.white,
),
),
)
],
);
},
);
}
return Container(
color: Colors.white,
child: const Center(child: CircularProgressIndicator()),
);
},
);
}
SliverAppBar _imageAppBar(ItemModel2 itemModel) {
return SliverAppBar(
// expandedHeight 에서는 세로 길이를 정해줄 수 있음,
expandedHeight: _size!.width,
// pinned: true 면 앱바 역역을 남기는 역할, false 면 스크롤시 같이 사라짐,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
centerTitle: true,
// title: const Text('testing', style: TextStyle(color: Colors.black)),
// 타이틀 부분에 인디케이터 표시하고 아래에 위치함, 패키지 추가 필요함,
title: SizedBox(
child: SmoothPageIndicator(
controller: _pageController,
// PageController
count: itemModel.imageDownloadUrls.length,
effect: const WormEffect(
activeDotColor: Colors.white,
//Theme.of(context).primaryColor,
dotColor: Colors.black45,
//Theme.of(context).colorScheme.background,
radius: 3,
dotHeight: 6,
dotWidth: 6,
),
// your preferred effect
onDotClicked: (index) {}),
),
// background 로 이미지를 넣으면 됨, 이미지 표시
background: PageView.builder(
// 좌/우로 스크롤 가능하게 처리,
controller: _pageController,
// 옆페이지로 이동시 포커스를 옆페이지로 이동시켜 로딩을 미리하게 설정함,
allowImplicitScrolling: true,
itemBuilder: (BuildContext context, int index) {
return ExtendedImage.network(
itemModel.imageDownloadUrls[index],
fit: BoxFit.cover,
// 캐싱을 했지만 다시 로딩하는 경우가 있어서 이미지 사이즈를 줄여줌,
scale: 0.1,
);
},
itemCount: itemModel.imageDownloadUrls.length,
),
),
);
}
Widget _userSection(ItemModel2 _itemModel) {
int phoneCnt = _itemModel.userPhone.length;
List _address = _itemModel.address.split(' ');
String _detail = _address[_address.length - 1];
String _location = '';
if (_detail.contains('(') && _detail.contains(')')) {
_location = _detail.replaceAll('(', '').replaceAll(')', '');
} else {
_location = _address[2];
}
return Row(
children: [
ExtendedImage.network(
'https://picsum.photos/50',
fit: BoxFit.cover,
width: _size!.width / 10,
height: _size!.width / 10,
shape: BoxShape.circle,
),
const SizedBox(width: padding_16),
SizedBox(
height: _size!.width / 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.auth,
children: [
Text(
_itemModel.userPhone.substring(phoneCnt - 4).toString(),
style: Theme.of(context).textTheme.bodyText1,
),
Text(
_location,
style: Theme.of(context).textTheme.bodyText2,
),
],
),
),
// 매너온도를 오른쪽으로 보내기 위해서 중간에 빈박스를 Expanded 로 처리,
Expanded(child: Container()),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
children: [
SizedBox(
// 온도와 LinearProgressIndicator 를 가로 길이를 같게 하기위해서,
width: 42,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const FittedBox(
child: Text(
'37.3 °C',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueAccent),
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(1),
child: LinearProgressIndicator(
color: Colors.blueAccent,
value: 0.365,
minHeight: 3,
backgroundColor: Colors.grey[200],
),
)
],
),
),
const SizedBox(width: 6),
const ImageIcon(
ExtendedAssetImageProvider('assets/imgs/happiness.png'),
color: Colors.blue,
),
],
),
const SizedBox(height: padding_08),
Text(
'매너온도',
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(decoration: TextDecoration.underline),
),
],
),
// Text('aaa'),
],
);
}
}
주요 부분은 아래와 같습니다.
1. 사용자 정보 표시( 서클이미지, 이름, 간략주소, 매너온도 등)
Widget _userSection(ItemModel2 _itemModel) {
int phoneCnt = _itemModel.userPhone.length;
//[서울특별시, 용산구, xxx길, 11, (xxx2가)]
//[서울특별시, 중구, 태평로x가, xx]
List _address = _itemModel.address.split(' ');
String _detail = _address[_address.length - 1];
String _location = '';
if (_detail.contains('(') && _detail.contains(')')) {
_location = _detail.replaceAll('(', '').replaceAll(')', '');
} else {
_location = _address[2];
}
return Row(
children: [
ExtendedImage.network(
'https://picsum.photos/50',
fit: BoxFit.cover,
width: _size!.width / 10,
height: _size!.width / 10,
shape: BoxShape.circle,
),
const SizedBox(width: padding_16),
SizedBox(
height: _size!.width / 10,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.auth,
children: [
Text(
_itemModel.userPhone.substring(phoneCnt - 4).toString(),
style: Theme.of(context).textTheme.bodyText1,
),
Text(
_location,
style: Theme.of(context).textTheme.bodyText2,
),
],
),
),
// 매너온도를 오른쪽으로 보내기 위해서 중간에 빈박스를 Expanded 로 처리,
Expanded(child: Container()),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
children: [
SizedBox(
// 온도와 LinearProgressIndicator 를 가로 길이를 같게 하기위해서,
width: 42,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const FittedBox(
child: Text(
'37.3 °C',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueAccent),
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(1),
child: LinearProgressIndicator(
color: Colors.blueAccent,
value: 0.365,
minHeight: 3,
backgroundColor: Colors.grey[200],
),
)
],
),
),
const SizedBox(width: 6),
const ImageIcon(
ExtendedAssetImageProvider('assets/imgs/happiness.png'),
color: Colors.blue,
),
],
),
const SizedBox(height: padding_08),
Text(
'매너온도',
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(decoration: TextDecoration.underline),
),
],
),
// Text('aaa'),
],
);
}
2. 아이템 상세 정보(글제목, 카테고리, 상세내용, 신고버튼 등)
SliverPadding(
padding: const EdgeInsets.all(padding_16),
sliver: SliverList(
// SliverToBoxAdapter 로 각각 처리할수도 있고,
// SliverList 를 이용하여 리스트 형식으로도 처리 가능,
delegate: SliverChildListDelegate([
_userSection(itemModel),
_divider(padding_16 * 2 + 1),
Text(
itemModel.title,
style: Theme.of(context).textTheme.headline6,
),
_textGap,
Row(
children: [
Text(
categoriesMapEngToKor[itemModel.category] ?? '선택',
style: Theme.of(context)
.textTheme
.bodyText2!
.copyWith(decoration: TextDecoration.underline),
),
Text(
' · 2분전',
style: Theme.of(context).textTheme.bodyText2,
),
],
),
_textGap,
Text(
itemModel.detail,
style: Theme.of(context).textTheme.bodyText1,
),
_textGap,
Text(
'조회 33',
style: Theme.of(context).textTheme.caption,
),
_textGap,
_divider(2),
MaterialButton(
padding: EdgeInsets.zero,
onPressed: () {
// 이메일로 처리하고 싶으면 flutter_email_sender 참고할 것
// https://pub.dev/packages/flutter_email_sender
logger.d('게시글을 신고합니다');
},
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'이 게시글 신고하기',
style: Theme.of(context)
.textTheme
.button!
.copyWith(color: Colors.black87),
),
),
),
_divider(2),
]),
),
),
3. Custom bottomNavigationBar
bottomNavigationBar: SafeArea(
// SafeArea - 전화기의 하단 메뉴바 영역과 앱 영역이 중복되는거 피하기 위해서,
top: false,
bottom: true, // 아래 영역만 피하기 위해서,
child: Container(
// Row/VerticalDivider 의 사이즈를 제한하기 위해서 Container&height 처리함,
height: 80,
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.grey[300]!),
bottom: BorderSide(color: Colors.grey[300]!),
),
),
child: Padding(
padding: const EdgeInsets.all(padding_08),
child: Row(
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.favorite_border),
),
const VerticalDivider(
thickness: 1,
width: padding_08 * 2 + 1,
indent: padding_08,
endIndent: padding_08,
),
Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
itemModel.price.toString(),
style: Theme.of(context).textTheme.bodyText1,
),
Text(
itemModel.negotiable ? '가격제안가능' : '가격제안불가',
style: itemModel.negotiable
? Theme.of(context)
.textTheme
.bodyText2!
.copyWith(color: Colors.blue)
: Theme.of(context).textTheme.bodyText2,
),
],
),
Expanded(child: Container()),
TextButton(
onPressed: () {
},
child: const Text('채팅으로 거래하기'),
),
],
),
),
),
),
'Flutter > 12 Clone 'Used Goods app'' 카테고리의 다른 글
[Flutter] Clone - 당근마켓44(Map - 1) (0) | 2022.08.25 |
---|---|
[Flutter] Clone - 당근마켓43(Item detail & PageView) - 5 (0) | 2022.08.24 |
[Flutter] Clone - 당근마켓41(Item detail & PageView) - 3 (2) | 2022.08.22 |
[Flutter] Clone - 당근마켓40(Item detail & PageView) - 2 (0) | 2022.08.21 |
[Flutter] Clone - 당근마켓39(Item detail & PageView) (0) | 2022.08.21 |