본문 바로가기
프로그래밍/Flutter 앱 개발

Flutter 앱 개발 11 : 우주 사진/비디오 리스트뷰

by drogrammer 2021. 1. 9.
반응형

지난 포스트에서 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만큼 만들고 마지막 아이템에 로딩 애니메이션을 추가한다.
  • 서버에서 로딩할 데이터가 남아있지 않을 경우, 리스트를 현재까지 로딩한 데이터 개수만큼만 만든다.

아래 레이아웃 구조 및 코드를 참고하기 바란다.

BLoC 기반 리스트 뷰 레이아웃 구조

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 레코더로 녹화해서 느려보이지만.. 실제로는 빠르다. ㅜㅜ)

결과 화면

 

반응형

댓글