[Flutter]인스타그램 클론 - 12. 마이페이지 UI 제작

한상욱·2023년 8월 18일
1
post-thumbnail

들어가며

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

마이페이지 UI 개요

마이페이지의 전반적인 UI는 이제 쉽게 구축할 수 있습니다. 하지만, 마이페이지에는 특별한 UI가 존재합니다. 바로 Sticky TabBar입니다. 해당 TabBar는 스크롤이 되면, 앱바 하단에 붙고 중간의 내용은 모두 화면에서 보이지 않습니다. 그러면 차근차근 만들어볼까요?

AppBar 영역

앱바의 title은 계정의 이름입니다. 해당 내용을 제외하고 다른 앱바와 다를것이 없습니다.

import ...

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

  
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  
  Widget build(BuildContext context) {
	return Scaffold(
        appBar: _appBar(),
	...
  // 가독성을 위해서 앱바 분리
  PreferredSizeWidget _appBar() {
    return AppBar(
      title: Row(
        children: [
        // 계정을 클릭하면 바텀시트가 올라옴.
          InkWell(
            onTap: () {
              showModalBottomSheet(
                  showDragHandle: true,
                  shape: const RoundedRectangleBorder(
                      borderRadius: BorderRadius.only(
                          topLeft: Radius.circular(10.0),
                          topRight: Radius.circular(10.0))),
                  context: context,
                  builder: (context) => Container(
                        height: 400,
                      ));
            },
            // 계정은 임의로 하드코딩
            child: const Text(
              '_ugsxng99',
              style: TextStyle(fontSize: 25, fontWeight: FontWeight.bold),
            ),
          ),
          ImageData(
            path: ImagePath.arrowDownIcon,
            width: 60,
          )
        ],
      ),
      // actions 영역
      actions: [
        Padding(
          padding: const EdgeInsets.all(14.0),
          child: ImageData(path: ImagePath.upload),
        ),
        Padding(
          padding: const EdgeInsets.all(14.0),
          child: ImageData(path: ImagePath.menuIcon),
        ),
      ],
    );
  }

다만, 계정을 클릭하면 bottom sheet가 열립니다.

앱바 영역은 굉장히 쉽게 제작할 수 있었습니다.

계정정보 영역

계정정보 영역에는 현재 계정의 프로필사진과 더불어 스토리를 확인할 수 있습니다. 그리고 게시물, 팔로워, 팔로잉 정보와 각종 여러가지 기능이 있지만, 프로필 편집, 공유, 계정 검색 버튼까지만 만들겠습니다.

이러면 계정정보 영역은 크게 정보영역과 버튼영역으로 분리할 수 있습니다. 정보영역을 위해 메소드를 만들겠습니다.

import ...

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

  
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  
  Widget build(BuildContext context) {
	return Scaffold(
        appBar: _appBar(),
        body: Column(
        	children: [
            	_info(),
            ]
        )
	...
  // 앱바영역
  PreferredSizeWidget _appBar() {
	...
  }
  // 정보영역
  Widget _info() {
  	return Container();
  }

Scaffold body 영역에 Column을 이용해서 화면을 확인하며 제작하겠습니다. 우선, 프로필은 이미 기존에 제작한 스토리 아바타 위젯을 이용해서 만들 수 있습니다. Row 위젯을 이용해서 가로로 위젯들을 정렬하겠습니다.

...
  Widget _info() {
    return const Row(
      children: [
        Padding(
          padding: EdgeInsets.all(15.0),
          child: ImageAvatar(
              width: 100,
              url:
                  'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTnnnObTCNg1QJoEd9Krwl3kSUnPYTZrxb5Ig&usqp=CAU',
              type: AvatarType.MYSTORY),
        ),
        ...

이 위젯은 이미 제작한것을 그대로 사용하면됩니다. 프로필 우측에는 계정의 게시물, 팔로워, 팔로잉 정보가 나타납니다. 해당 위젯은 중복을 줄이기 위해서 위젯으로 만들겠습니다.

import 'package:flutter/material.dart';

class MyPageInfo extends StatelessWidget {
  final int count;
  final String label;
  const MyPageInfo({super.key, required this.count, required this.label});

  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          count.toString(),
          style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
        ),
        Text(
          label,
          style: const TextStyle(fontSize: 18),
        ),
      ],
    );
  }
}

이 위젯은 수와 문자열을 입력받아서 계정정보를 표시할 수 있는 커스텀 위젯입니다. 위젯으로 만들지 않으면 외부에서 코드의 중복이 굉장히 많아질겁니다. 이제 정보영역을 완성하겠습니다.

  Widget _info() {
    return const Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Padding(
          padding: EdgeInsets.all(15.0),
          child: ImageAvatar(
              width: 100,
              url:
                  'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTnnnObTCNg1QJoEd9Krwl3kSUnPYTZrxb5Ig&usqp=CAU',
              type: AvatarType.MYSTORY),
        ),
        Expanded(
          flex: 3,
          child: Padding(
            padding: EdgeInsets.all(30.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                Padding(
                  padding: EdgeInsets.all(10.0),
                  child: MyPageInfo(
                    count: 35,
                    label: '게시물',
                  ),
                ),
                Padding(
                  padding: EdgeInsets.all(10.0),
                  child: MyPageInfo(count: 167, label: '팔로워'),
                ),
                Padding(
                  padding: EdgeInsets.all(10.0),
                  child: MyPageInfo(count: 144, label: '팔로잉'),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }

각 위젯들은 padding이 필요하지만, 무엇보다도 정보는 특정영역의 너비를 차지하고 있습니다. 임의로 사이즈를 조절하면 아무래도 반응성이 떨어지겠죠. Expanded위젯의 flex를 사용해서 위젯의 차지 너비를 쉽게 반응형으로 만들 수 있어요. 이제 하단의 버튼 영역도 만들겠습니다. 버튼도 마찬가지입니다. 커스텀 위젯으로 코드의 중복을 줄일게요.

import 'package:flutter/material.dart';

class MyPageButton extends StatelessWidget {
  final void Function()? onTap;
  final String label;
  const MyPageButton({super.key, this.onTap, required this.label});

  
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: onTap,
        child: Container(
          height: 40,
          decoration: BoxDecoration(
              color: const Color(0xfff3f3f3),
              borderRadius: BorderRadius.circular(8.0)),
          child: Center(
              child: Text(
            label,
            style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
          )),
        ));
  }
}

해당 버튼은 Container를 이용해서 쉽게 만들 수 있어요. 물론 버튼의 역할을 해야하기 때문에 GestureDetector 위젯으로 감싸야합니다. 외부에서 void타입의 Function과 문자열을 받아 버튼을 만드는 위젯입니다.

  Widget _buttons() {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Expanded(flex: 4, child: MyPageButton(onTap: () {}, label: '프로필 편집')),
          const SizedBox(
            width: 10.0,
          ),
          Expanded(flex: 4, child: MyPageButton(onTap: () {}, label: '프로필 공유')),
          const SizedBox(
            width: 10.0,
          ),
          Container(
              width: 40,
              height: 40,
              padding: const EdgeInsets.all(8.0),
              decoration: BoxDecoration(
                  color: const Color(0xfff3f3f3),
                  borderRadius: BorderRadius.circular(4.0)),
              child: ImageData(path: ImagePath.addFriend))
        ],
      ),
    );
  }

이제 버튼영역까지 완성했으니, body에 전달하겠습니다.

import ...

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

  
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> {
  
  Widget build(BuildContext context) {
	return Scaffold(
        appBar: _appBar(),
        body: Column(
        	children: [
            	_info(),
                _buttons(),
            ]
        )
	...
  // 앱바영역
  PreferredSizeWidget _appBar() {
	...
  }
  // 정보영역
  Widget _info() {
  	...
  }
  // 버튼영역
  Widget _buttons() {
  	...
  }

여기까지는 단순히 하드코딩형식의 UI이기 때문에 큰 어려움이 없습니다.

TabBar 영역

탭바 영역은 Sticky한 스타일을 가집니다. 스크롤업 되면 탭바는 앱바영역 하단에 붙고, 탭바뷰영역을 계속 스크롤해야합니다. 이를 위해서 NestedScrollView를 사용할 수 있습니다. NestedScrollView는 스크롤영역을 중첩하게 사용할 수 있도록하는 위젯이에요. 물론 중첩으로 스크롤이 되게하진 않을것이지만, 해당 위젯을 이용해서 만들고자 하는 Sticky 효과를 만들 수 있습니다.

NestedScrollView를 이용해서 해당 탭바를 만들기위해서는 일단 Scaffold 전체를 DefaultTabController 위젯으로 감싸야합니다.

class _MyPageState extends State<MyPage> {
  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      initialIndex: 0,
      child: Scaffold(
        appBar: _appBar(),

여기서 길이는 탭바의 길이이고, initialIndex는 초기에 보여줄 탭바뷰의 인덱스입니다. 인스타그램은 2개의 탭바를 가지고 있고, 초기에는 무조건 0번 인덱스의 내용을 보여줍니다.

이제, body영역을 지우고, 다시 새롭게 NestedScrollView를 전달하겠습니다.

          NestedScrollView(
            headerSliverBuilder: (context, innerBoxIsScrolled) {
              return [
                SliverList(
                    delegate: SliverChildListDelegate([_info(), _buttons()])),
              ];
            },
            body: Column(
              children: [
                _tabs(),
                _tabBarView(),
              ],
            ),
          ),

정보영역과 버튼영역은 탭바 위에서 고정되어서 스크롤시 사라져야합니다. 따라서, headerSliverBuilder를 통해서 헤더로 생성하겠습니다. _tabs와 _tabBarView는 각각 탭바와 탭바뷰입니다.

  Widget _tabBarView() {
    return Expanded(
      child: TabBarView(children: [
        _myFeeds(),
        _tagImages(),
      ]),
    );
  }

  Widget _myFeeds() {
    return GridView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3, crossAxisSpacing: 1.0, mainAxisSpacing: 1.0),
        itemCount: 50,
        itemBuilder: (context, index) => Container(
              color: Colors.blue,
            ));
  }

  Widget _tagImages() {
    return GridView.builder(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 3, crossAxisSpacing: 1.0, mainAxisSpacing: 1.0),
        itemCount: 50,
        itemBuilder: (context, index) => Container(
              color: Colors.red,
            ));
  }

  Widget _tabs() {
    return TabBar(indicatorColor: Colors.black, tabs: [
      Tab(
        child: ImageData(path: ImagePath.gridViewOff),
      ),
      Tab(
        child: ImageData(path: ImagePath.myTagImageOff),
      ),
    ]);
  }

해당영역은 위의 코드를 참조해주세요. 이제, 우리가 만들고자한 Sticky TabBarView가 완성되었습니다 !

refresh 구현

마이페이지는 상단으로 스크롤하면 CircularProgressIndicator와 같은 refresh기능이 있습니다. 해당효과는 refreshIndicator 위젯을 통해 쉽게 구현할 수 있습니다. 이 위젯은 상단에서 스크롤시, 콜백함수들을 통해 실제로 새로고침 함수를 호출할 수 있습니다. NestedScrollView를 RefreshIndicator로 감싸줍니다. 여기서 주의점이 있습니다.

    return DefaultTabController(
      length: 2,
      initialIndex: 0,
      child: Scaffold(
        appBar: _appBar(),
        body: RefreshIndicator.adaptive(
        	// 해당 부분을 작성해야
            // NestedScrollView에서 
            // Refresh효과를 적용할 수 있음.
          notificationPredicate: (notification) {
            if (notification is OverscrollNotification || Platform.isIOS) {
              return notification.depth == 2;
            }
            return notification.depth == 0;
          },
          onRefresh: () async {
          	//임의의 새로고침 지연시간
            await Future.delayed(const Duration(seconds: 1));
          },
          child: NestedScrollView(
            headerSliverBuilder: (context, innerBoxIsScrolled) {
              return [
                SliverList(
                    delegate: SliverChildListDelegate([_info(), _buttons()])),
              ];
            },
            body: Column(
              children: [
                _tabs(),
                _tabBarView(),
              ],
            ),

NestedScrollView에 RefreshIndicator위젯을 적용하기 위해서는 상단의 코드를 꼭 작성해주세요. 작성하지 않으면 refresh 효과를 볼 수 없습니다.

이렇게 마이페이지 UI 제작을 완료했습니다 !

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

1개의 댓글

comment-user-thumbnail
2023년 8월 18일

좋은 정보 감사합니다

답글 달기

관련 채용 정보