지난 포스트에서 BLoC 패턴을 활용한 서버 연동 부분을 구현해 보았다. 이제 리스트 뷰 UI 를 연동해 보자.
Flutter 앱 개발 10 : 우주 사진/비디오 데이터 가져오기 (BLoC)
이번 포스트에서는 BLoC 패턴을 이용해서 우주 사진/비디오 데이터를 서버로부터 가져오는 코드를 구현해보겠다 1. BLoC BLoC 은 Business Logic Component 의 약자로서 로직과 UI 코드를 분리 (decoup
drogrammer.tistory.com
1. 필요 패키지 의존성 추가 (loading_animation, extended_image)
pubspec.yml 을 아래처럼 수정해보자.
...
dependencies:
...
loading_animations: 2.1.0 # 로딩 애니메이션
extended_image: ^1.5.0 # 네트워크를 통한 이미지 로딩 및 확대 / 축소 등 제스쳐 기능 제공
...
2. ApodTile 위젯 추가
리스트 아이템 타일 위젯을 먼저 만들어 보자. lib/widget 폴더 생성 후, lib/widget/apod_tile.dart 파일을 추가한다.
코드 중 핵심적인 부분을 설명하자면,
- 생성자에서 사진/비디오 정보 (Apod Class) 와 현재 타일의 index, 그리고 사진/비디오의 세로 크기 (photoHeight) 를 인자로 받는다. (세로 크기 기본값은: 80.0)
- InkWell 을 사용해서 클릭 시 처리가 가능하도록 한다. 일단 클릭시 아무 처리도 하지 않게 비워둔다.
- Column 내에 1) 사진/비디오 썸네일, 2) 날짜 + 제목 을 순서대로 보여준다.
아래 레이아웃 구조 및 코드를 참고하기 바란다.
import 'package:apod/data/apod.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:loading_animations/loading_animations.dart';
class ApodTile extends StatelessWidget {
final Apod apod; // Apod 데이터 (사진/비디오 정보)
final int index; // 현재 타일의 index
final double photoHeight; // 사진 / 비디오 썸네일 세로 크기
// 생성자, 기본 사진 / 비디오 썸네일 높이는 80
ApodTile(
{Key key,
@required this.apod,
@required this.index,
this.photoHeight = 80.0})
: super(key: key);
@override
Widget build(BuildContext context) {
// 인덱스와 컨텐츠 날짜로 아이디를 만든다. (Hero 애니메이션 용)
var id = "$index/${apod.dateStr}";
// 이벤트 처리를 위한 InkWell 추가
return InkWell(
onTap: () => {}, // 클릭시 이벤트 처리는 일단 비워두었다.
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 8),
// 썸네일 이미지의 Hero 애니메이션 적용
child: Hero(
tag: id,
// 비디오의 경우 Play 버튼 이미지를 썸네일 위에 표현하기 위해 Stack 사용
child: Stack(
alignment: Alignment.center,
children: [
// 네트워크로부터 이미지 로딩 (extended_image 패키지 사용)
ExtendedImage.network(
// 썹네일 이미지 URL
apod.url,
// 사진의 세로 (기본: 80.0)
height: photoHeight,
// 사진의 가로 길이를 충분히 큰 값으로 설정하여 가로로 꽉 차게 처리한다.
width: 10000,
// 사진을 캐싱하여 이후 로딩이 빠르게 처리하기 위한 옵션 활성
cache: true,
// 이미지가 비율을 유지하면서 할당된 영역에 꽉 차도록 cover 옵션 설정
fit: BoxFit.cover,
loadStateChanged: (state) {
switch (state.extendedImageLoadState) {
// loading 중에는 로딩 애니메이션을 표시한다. (loading_animation 패키지 사용)
case LoadState.loading:
return LoadingFadingLine.circle(
backgroundColor: Colors.white,
);
break;
case LoadState.completed:
break;
case LoadState.failed:
break;
}
},
),
// 컨텐츠가 비디오 타입일 경우, play 아이콘을 썸네일 위에 표시한다.
apod.isVideo
? Icon(
Icons.play_circle_fill,
size: 40,
color: Colors.white,
)
: Container(),
],
),
),
),
Padding(
padding: EdgeInsets.all(10),
// 썸네일 아래에 이미지 날짜와 제목을 가로로 표시한다.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat('yyyy MM dd').format(apod.date),
style: TextStyle(
color: Colors.white,
fontFamily: 'NexonLv2GothicMedium',
fontSize: 16,
),
),
SizedBox(width: 15),
Expanded(
child: Text(
apod.title,
textAlign: TextAlign.start,
// 제목이 너무 길면 (overflow) '...' 으로 축약해서 표시한다.
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
)
],
),
),
],
),
);
}
}
3. BottomLoader 위젯 추가
리스트 아이템을 서버로부터 추가로 받아올 떄 표시할 로딩 애니메이션 위젯을 lib/widget/bottom_loader.dart 에 추가한다. 코드가 워낙 간단하니 추가 설명은 생략한다.
import 'package:flutter/material.dart';
import 'package:loading_animations/loading_animations.dart';
class BottomLoader extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 높이가 50인 로딩 애니메이션 표시
return Container(
alignment: Alignment.center,
child: Center(
child: SizedBox(
height: 50,
child: LoadingFadingLine.circle(
backgroundColor: Colors.white,
),
),
),
);
}
}
4. Apods 위젯 추가 (리스트)
마지막으로 BLoC 와 연동하여 리스트를 보여줄 리스트 위젯을 추가하자.
코드 중 핵심적인 부분을 설명하자면,
- initState() 에서 이전 포스트 에서 만들어둔 ApodBloc 를 생성한다.
- initState() 에서 리스트 스크롤 컨트롤러 리스너를 등록한다.
- 스크롤 컨트롤러 리스너에서 최하단 200크기 이내에 스크롤 되었을 때 BLoC 의 ApodFetch 이벤트를 발생시켜 추가 데이터를 로딩한다.
- RefreshIndicator 를 이용해서 리스트 최상단에서 아래로 스크롤 할 경우, BLoC의 ApodRefresh 이벤트를 발생 시켜 리스트를 새로고침 한다.
- 서버에서 로딩할 데이터가 남아있을 경우, 리스트를 현재까지 로딩한 데이터 개수 + 1만큼 만들고 마지막 아이템에 로딩 애니메이션을 추가한다.
- 서버에서 로딩할 데이터가 남아있지 않을 경우, 리스트를 현재까지 로딩한 데이터 개수만큼만 만든다.
아래 레이아웃 구조 및 코드를 참고하기 바란다.
import 'package:apod/data/apod_bloc.dart';
import 'package:apod/data/apod_event.dart';
import 'package:apod/data/apod_state.dart';
import 'package:apod/widget/apod_tile.dart';
import 'package:apod/widget/bottom_loader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:loading_animations/loading_animations.dart';
import 'package:easy_localization/easy_localization.dart';
class Apods extends StatefulWidget {
@override
_ApodsState createState() => _ApodsState();
}
class _ApodsState extends State<Apods> {
// 리스트 위젯에서 사용할 스크롤러
final _scrollController = ScrollController();
// 추가 아이템 로딩을 판단할 영역의 크기.
// 리스트 전체 스크롤 영역중 하단 200 위치 이내로 스크롤 될경우 추가 데이터 로딩하기 위해 사용한다.
final _scrollThreshold = 200.0;
// BLoC
ApodBloc _apodBloc;
// 위젯 State 초기화
@override
void initState() {
super.initState();
// 리스트 스크롤러 리스너 등록
_scrollController.addListener(_onScroll);
// Bloc 생성.
_apodBloc = ApodBloc();
}
@override
void dispose() {
// 리스트 스크롤러 리스너 제거
_scrollController.removeListener(_onScroll);
super.dispose();
}
// 리스트 스크롤 리스너
void _onScroll() {
// 최대 스크롤 가능 크기
final maxScroll = _scrollController.position.maxScrollExtent;
// 현재 스크롤 위치
final currentScroll = _scrollController.position.pixels;
// 최대 스크롤 가능 크기 - 현재 스크롤 위치가 200 이하일 경우
// 즉, 하단 200 이내에 스크롤 되었을 경우 ApodFetched 이벤트를 BLoC 에 전달하여,
// 추가 데이터를 서버로 부터 로딩한다.
if (maxScroll - currentScroll <= _scrollThreshold) {
_apodBloc.add(ApodFetched());
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
// 리스트 최상단에서 아래로 당겼을 때 리스트를 재로딩한다.
// 즉, ApodRefresh 이벤트를 BLoC 에 전달한다.
onRefresh: () async {
_apodBloc.add(ApodRefresh());
},
backgroundColor: Colors.black,
color: Colors.white,
child: Container(
color: Colors.black,
// initState에서 초기화한 BLoC 를 활용하여 UI 를 그리기 위한 BlocBuilder
child: BlocBuilder<ApodBloc, ApodState>(
// BLoC 등록 및 ApodFetched() 이벤트 발생
bloc: _apodBloc..add(ApodFetched()),
builder: (context, state) {
if (state is ApodInitial) {
// State가 ApodInitial(초기 상태) 이면 로딩 에니메이션을 보여준다.
return Center(
child: LoadingFadingLine.circle(
backgroundColor: Colors.white,
),
);
} else if (state is ApodFailure) {
// State가 ApodFailure(실패 상태) 면 로딩 실패 텍스트를 보여준다.
return Center(child: Text('importfail'.tr()));
} else if (state is ApodSuccess) {
// State가 ApodSuccess(서버로 부터 로딩 성공) 면,
if (state.apods.isEmpty) {
// 리스트가 비어 있으면 아무 데이터가 없다는 텍스트를 보여준다.
return Center(
child: Text('nodata'.tr()),
);
}
// BLoC로 부터 받은 사진 리스트를 이용하여 ListView를 보여준다.
return ListView.builder(
// 더이상 받을 데이터가 없을 경우, (hasReachedMax)
// 사진 리스트 수만큼 리스트뷰를 만들고,
// 더 받을 수 있는 데이터가 남아 있을 경우 (!hasReachedMax)
// 사진 리스트 수 + 1 만큼 리스트뷰를 만든다.
itemCount: state.hasReachedMax
? state.apods.length
: state.apods.length + 1,
itemBuilder: (context, index) {
// 더 받을 수 있는 데이터가 남아 있는 경우 (!hasReachedMax)
// 추가로 만든 마지막 아이템에 로딩 애니메이션을 표시한다.
if (index >= state.apods.length) {
return BottomLoader();
}
// BLoC의 ApodSuccess 를 통해 받은 사진/비디오 리스트를
// ApodTile 위젯으로 리스트에 추가한다.
return ApodTile(
apod: state.apods[index],
index: index,
// 첫 아이템만 사진 높이를 300.0 으로 하고
// 나머지는 80.0 으로 처리한다.
photoHeight: index == 0 ? 300.0 : 80.0);
},
controller: _scrollController,
);
}
return Center();
},
),
),
);
}
}
5. Contents 뷰 에 리스트 추가 (드디어!)
아래 이전 포스트들을 통해 만들었던 Contents 뷰 (lib/view/contents.dart) 에 지금까지 만든 리스트를 추가하자.
Flutter 앱 개발 6 : Contents 뷰 - Scaffold, AppBar 추가
자 이제부터 우주 사진 리스트를 보여주기 위한 Contents 뷰 클래스를 차근 차근 만들어 보자. 1. Contents 클래스 추가 일단, lib/view 폴더를 생성 하고 contents.dart 파일을 추가하자. 일단, Contents 클래스
drogrammer.tistory.com
Flutter 앱 개발 7 : Contents 뷰 - Drawer 추가
Flutter 앱 개발 6 : Contents 뷰 - Scaffold, AppBar 추가 포스팅에 이어 Contents 뷰에 Drawer를 추가해 보자. 1. Drawer 추가 Drawer는 좌에서 우로 슬라이딩 되며 나오는 메뉴인데, '앱 평가하기' 메뉴를 dra..
drogrammer.tistory.com
기존에 만들어둔 Scaffold의 body에 Apods() 를 추가하면 끝이다!!!
import 'package:apod/widget/apods.dart';
...
class Contents extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
...
body: Apods(), // body 에 리스트 위젯 추가
...
}
}
6. 결과 화면
아래와 같이 스크롤을 통한 추가 데이터 로딩 및 새로고침이 동작하는 것을 확인할 수 있다.
(GIF 레코더로 녹화해서 느려보이지만.. 실제로는 빠르다. ㅜㅜ)
댓글