[Flutter]인스타그램 클론 - 4. 피드 제작(1)

한상욱·2023년 7월 26일
0
post-thumbnail

들어가며

이 프로젝트는 개발하는남자님의 유튜브 영상을 참고하여 제작하였습니다. 허나, 원본 영상에서 제작하는 방법과는 다를 수 있습니다.

피드 개요

이제 피드를 만들어볼 차례입니다. 피드는 친구들 업로드한 게시글들 의미해요. 사용자가 사진과 해당하는 설명을 올리면 게시되는 템플릿이죠. 사진이 여러장이면 좌우로 스크롤도 가능하고, 현재 스크롤 페이지를 나타내는 인디케이터도 존재합니다. 아무래도 피드 안에 존재하는 위젯들이 많다보니 제작을 하려면 구역을 나누어야 될 것 같습니다.

피드는 크게 구역을 나누면 가장 위쪽에 헤더영역, 이미지 영역, 여러가지 옵션 영역, 해당 글에 대한 설명인 묘사영역, 마지막으로 댓글을 달 수 있는 댓글 영역으로 나눌 수 있습니다. 이런 순서대로 한번 구현해보겠습니다.

피드 위젯 만들기

제작을 위해서 feed.dart 파일을 새롭게 만들어주겠습니다.

class Feed extends StatefulWidget {
  const Feed({super.key});

  
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> {
  int _current = 0;

  
  Widget build(BuildContext context) {
    return Container();
  }

Stateful Widget으로 제작합니다. 이미지를 좌우로 스크롤하기 위해서는 내부에서 직접 인덱스를 관리해야하기 때문이죠. 이제 Column위젯을 전달해서 내부에 전달할 영역들을 위젯 메소드로 전달하도록 하겠습니다.

class Feed extends StatefulWidget {
  const Feed({super.key});

  
  State<Feed> createState() => _FeedState();
}

class _FeedState extends State<Feed> {
  int _current = 0;

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        _header(),
        _images(),
        _options(),
        _comment(),
      ],
    );
  }

이제 전부 주석처리하고 하나씩 만들어가보겠습니다. 그리고 홈에서 볼 수 있어야 하니, 홈에서는 피드를 임의로 50개정도 만들어주겠습니다. 홈은 CustomScrollView를 사용하고 있으니, SliverList.builder로 만들도록 하겠습니다. home.dart파일에서 body 메소드로 피드영역을 만들어줍니다.

  Widget _body() {
    return SliverList.builder(
      itemCount: 50,
      itemBuilder: (context, index) => const Feed(
        userUrl:
            'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTnnnObTCNg1QJoEd9Krwl3kSUnPYTZrxb5Ig&usqp=CAU',
        userName: '_ugsxng99',
        images: [
          'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTnnnObTCNg1QJoEd9Krwl3kSUnPYTZrxb5Ig&usqp=CAU',
          'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRisv-yQgXGrto6OxQxX62JyvyQGvRsQQ760g&usqp=CAU',
          'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQifBWUhSiSfL0t8M3XCOe8aIyS6de2xWrt5A&usqp=CAU',
        ],
        countLikes: 12,
        countComment: 6,
      ),
    );
  }

헤더 영역

헤더 영역에는 피드를 작성한 작성자의 프로필 사진, 계정 이름이 왼쪽에 붙어 있고, 오른쪽에는 옵션 버튼이 있습니다.

프로필 사진은 사용자의 프로필사진이기 때문에 외부에서 url경로를 받아서 처리해야 될 것 같군요. 그 외에 계정 이름도 외부에서 전달받아야 됩니다. 지금은 우선 제작하기 때문에 각각 전달받을 것이지만, 원래는 모델을 생성해서 전달하는 것이 좋습니다. 우선, 필요한 사항들을 외부에서 전달받겠습니다.

class Feed extends StatefulWidget {
  final String userUrl;
  final String userName;
  const Feed(
      {super.key,
      required this.authorUrl,
      required this.authorName});
      ...

각각 userUrl, userName으로 명명했습니다. 자, 이제 사용자 프로필 사진을 담을 위젯을 만들어야겠습니다. 근데, 해당 아바타는 그냥 기본 아바타를 가져와서 쓰면 됩니다. ImageAvatar 위젯 파일로 이동해서 BASIC타입을 추가하고, 기본 아바타를 반환하도록 하겠습니다.

//BASIC타입 추가됨.
enum AvatarType { ON, OFF, STORY, MYSTORY, BASIC }

class ImageAvatar extends StatelessWidget {
  final double width;
  final String url;
  final AvatarType type;
  final void Function()? onTap;
  const ImageAvatar(
      {super.key,
      this.width = 30,
      required this.url,
      required this.type,
      this.onTap});

  
  Widget build(BuildContext context) {
    return switch (type) {
      AvatarType.BASIC => _basicImage(), // 기본 아바타 이미지
      AvatarType.STORY => _storyAvatar(),
      AvatarType.ON => _onImage(),
      AvatarType.OFF => _offImage(),
      AvatarType.MYSTORY => _myStoryAvatar(),
    };
  }

이제, 다시 피드위젯으로 돌아와서 헤더영역에 대한 위젯을 만들겠습니다. 가로로 나열되어 있으니, Row를 사용해서 만들겠습니다.

  Widget _header() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        //두개의 위젯을 서로 붙이려면 Row 내부에서 Row로 묶으면 됨.
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: ImageAvatar( //프로필 사진
                url: widget.userUrl,
                type: AvatarType.BASIC,
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: Text( // 계정 이름
                widget.userName,
                style: const TextStyle(
                    color: Colors.black,
                    fontSize: 15,
                    fontWeight: FontWeight.w500),
              ),
            )
          ],
        ),
        const Padding( //더보기 옵션 버튼
          padding: EdgeInsets.all(8.0),
          child: Icon(Icons.more_horiz),
        )
      ],
    );
  }

사용자 프로필사진과 이름은 서로 붙어있어야됩니다. 그리고 옵션버튼은 멀리 떨어져있네요. 이런 경우 붙이고자 하는 위젯들은 Row내부에서 다시 Row위젯으로 묶어주면 붙여줄 수 있습니다. 이제 mainAxisAlignment를 MainAxisAlignment.spaceBetween을 전달하면 헤더영역을 만들 수 있습니다. 결과한번 보겠습니다.

50개의 헤더영역이 보이시나요? 완성입니다.

이미지 영역 만들기

이미지 영역에는 이미지가 한장인 경우는 상관없지만, 여러장인 경우 좌우로 스크롤리 가능해야합니다. 따라서, 아주 좋은 패키지를 소개합니다.

carousel_slider패키지는 간편하게 좌우로 스크롤되는 이미지 스크롤영역을 제공합니다. 패키지를 우선 install해줍니다.

$ flutter pub add carousel_slider

이제 carousel_slider를 사용할 수 있는데, 원래 패키지를 새롭게 받으면 flutter clean 후 재 빌드해주셔야 오류가 발생하지 않습니다. 이미지들도 역시 url을 받아야 하는데, 여러장 받을 수 있으니, List형태로 전달받겠습니다.

class Feed extends StatefulWidget {
  final String userUrl;
  final String userName;
  final List<String> images;
  const Feed(
      {super.key,
      required this.userUrl,
      required this.userName,
      required this.images});

url은 문자열이기 때문에 제너릭을 이용해서 String으로 선언했습니다. 이제 이를 이용해서 image영역을 만들겠습니다.

  Widget _images() {
    return CarouselSlider.builder(
    	//이미지 갯수
        itemCount: widget.images.length,
        //이미지 빌더
        itemBuilder: (context, index, realIndex) {
          return Container(
            color: Colors.black,
            width: Get.size.width,
            height: Get.size.width,
            child: CachedNetworkImage(
              //인덱스에 해당하는 이미지 로드
              imageUrl: widget.images[index],
              fit: BoxFit.cover,
            ),
          );
        },
        // carousel_slider위젯의 여러가지 옵션 정의
        options: CarouselOptions(
          //무한대로 스크롤 되는 지
          enableInfiniteScroll: false,
          //가로세로 비율 정의
          aspectRatio: 1,
          //Fraction 비율 정의
          viewportFraction: 1,
        ));
  }

CarouselSlider.builder는 이미지들을 빌드할 수 있는 빌더위젯입니다. ListView.builder랑 사용법이 거의 같아요. 근데, 세부사항에 대해서는 options으로 전달되는 CarouselOptions에 정의해야 됩니다.

옵션에 대해서 enableInfiniteScroll은 무한대로 스크롤되는지를 정의할 수 있습니다. 하지만 마지막 이미지에서는 무한대로 스크롤되지 않죠.

aspectRatio는 가로세로 비율을 의미합니다. 홈 영역에서는 이미지가 가로세로가 동일한 정사각형이에요. 그래서 1을 전달했습니다.

viewportFration은 이미지의 보여지는 부분을 정의합니다. 원래는 좌우의 사진이 살짝 삐져나와서 해당 이미지가 정사각형이 되지 않습니다만, 인스타그램은 옆에 이미지가 보이지 않아요. 그래서 1로 전달했습니다.

이제 결과좀 볼까요?

이미지 영역도 완성했습니다.

옵션 영역

옵션 영역에는 여러가지 버튼들이 존재하죠? 가운데에는 현재 이미지의 인덱스를 알려주는 인디케이터도 있습니다. 우선은 버튼들부터 만들게요. 헤더 영역을 만든 것 처럼 만들면 됩니다.

  Widget _options() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        //오른쪽 버튼들 묶어주기
        Row(
          children: [
            //좋아요 버튼
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(
                  child: ImageData(path: ImagePath.likeOffIcon)),
            ),
            //dm 버튼
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(child: ImageData(path: ImagePath.dm)),
            ),
            //댓글 버튼
            Padding(
              padding: const EdgeInsets.all(8.0),
              child:
                  GestureDetector(child: ImageData(path: ImagePath.replyIcon)),
            ),
          ],
        ),
        //북마크 버튼
        Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(
                  child: ImageData(path: ImagePath.bookMarkOffIcon)),
        ),
      ],
    );
  }

이러면 버튼들은 쉽게 만들 수 있습니다.

자, 근데 가운데에 인디케이터를 만들어야 해요. 이를 위한 패키지가 있습니다.

smooth_page_indicator 패키지는 이러한 인디케이터들을 제공해요. 이제 만들어볼까요?

일단은 이미지에 대한 인덱스를 인디케이터에 인덱스에 등록해줘야 합니다. 그래야 이미지가 슬라이딩되면 인덱스의 변화를 알 수 있겠죠? 그렇기 때문에 가장 상단에 피드의 이미지의 인덱스를 관리하는 변수를 선언하겠습니다.

...
class _FeedState extends State<Feed> {
  int _current = 0; //이미지 인덱스

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        _header(),
        _images(),
        _options(),
      ],
    );
  }
  ...

이미지가 슬라이딩되면 계속 인덱스의 값을 _current 에 갱신해서 값을 바꾸어줘야 됩니다. 그래서 CarouselOptions에는 onPageChanged가 존재합니다.

        options: CarouselOptions(
          enableInfiniteScroll: false,
          aspectRatio: 1,
          viewportFraction: 1,
          onPageChanged: (index, reason) {
            setState(() {
			  //인덱스 갱신
              _current = index;
            });
          },
        ));

setState메소드를 사용해서 이미지의 인덱스를 _current에 전달해주면 완료입니다. 이제 저 변수는 이미지의 인덱스값을 계속 전달받습니다.

이제 인디케이터를 만들겠습니다.

  Widget _options() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Row(
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(
                  child: ImageData(path: ImagePath.likeOffIcon)),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(child: ImageData(path: ImagePath.dm)),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child:
                  GestureDetector(child: ImageData(path: ImagePath.replyIcon)),
            ),
          ],
        ),
        //이미지가 한장이면 인디케이터는 없음
        (widget.images.length == 1)
            ? Container()
            : AnimatedSmoothIndicator(
                //인덱스 전달
                activeIndex: _current,
                //이미지의 갯수가 곧 인디케이터의 점의 갯수
                count: widget.images.length,
                //가장 인스타그램 인디케이터와 비슷한 효과
                effect: const ScrollingDotsEffect(
                    dotColor: Colors.black26,
                    activeDotColor: Colors.blue,
                    activeDotScale: 1,
                    spacing: 4.0,
                    dotWidth: 6.0,
                    dotHeight: 6.0),
              ),
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: GestureDetector(
              child: ImageData(path: ImagePath.bookMarkOffIcon)),
        ),
      ],
    );
  }

AnimatedSmoothIndicator 위젯은 쉽게 인디케이터를 만들 수 있는 위젯입니다. 여러가지 효과가 있는데 그 중 ScrollingDotsEffect가 가장 비슷하더라고요. activeIndex에 _current를 전달해주면 인디케이터효과를 볼 수 있습니다.

인디케이터는 이미지가 한장이면 아예 없습니다. 그래서 삼항연산자를 이용해서 이미지가 한장인 경우 Container를 전달했습니다.

근데, 인디케이터가 가운데가 아니라 왼쪽으로 치우쳐져 있습니다. 오른쪽의 버튼 갯수와 왼쪽의 버튼 갯수가 달라서 치우쳐지는겁니다. 이를 간단하게 해결해보겠습니다.

  Widget _options() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Row(
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(
                  child: ImageData(path: ImagePath.likeOffIcon)),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(child: ImageData(path: ImagePath.dm)),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child:
                  GestureDetector(child: ImageData(path: ImagePath.replyIcon)),
            ),
          ],
        ),
        (widget.images.length == 1)
            ? Container()
            : AnimatedSmoothIndicator(
                activeIndex: _current,
                count: widget.images.length,
                effect: const ScrollingDotsEffect(
                    dotColor: Colors.black26,
                    activeDotColor: Colors.blue,
                    activeDotScale: 1,
                    spacing: 4.0,
                    dotWidth: 6.0,
                    dotHeight: 6.0),
              ),
        Row(
          children: [
            //안보이는 아이템1
            Opacity(
              opacity: 0,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: GestureDetector(
                    child: ImageData(path: ImagePath.bookMarkOffIcon)),
              ),
            ),
            //안보이는 아이템2
            Opacity(
              opacity: 0,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: GestureDetector(
                    child: ImageData(path: ImagePath.bookMarkOffIcon)),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: GestureDetector(
                  child: ImageData(path: ImagePath.bookMarkOffIcon)),
            ),
          ],
        ),
      ],
    );
  }

왼쪽과 오른쪽을 좌우대칭이 맞게 북마크를 두개 더 넣어줍니다. 그리고 해당 위젯을 Opacity위젯으로 투명하게 만들면 간단하게 인디케이터를 가운데로 옮길 수 있는 효과를 보여줍니다.

이미지 영역까지 완성했습니다 ! 너무 내용이 길어져서 나머지 영역들은 그 다음에 계속 하도록 하겠습니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자가 되기 위해

0개의 댓글

관련 채용 정보