[Flutter]인스타그램 클론 - 1. setup & Bottom Navigation 구현

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

들어가며

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

프로젝트 생성

터미널을 이용해서 새로운 프로젝트를 생성하겠습니다. 프로젝트 생성이 완료되었다면, 프로젝트 루트에서 assets 디렉터리를 생성한 후, 프로젝트에서 사용할 이미지들을 추가하겠습니다.

이제 이 미디어파일들을 사용하기 위해서는 pubspec.yaml파일에 등록해야 합니다. 해당 파일에서 assets에 대한 주석을 해제하고, 다음과 같이 작성합니다.

해당 작업을 통해 모든 assets에 대한 접근을 할 수 있습니다. 이제 프로젝트 생성을 완료하였습니다.

Bottom Navigation 개요

실제 인스타그램 앱을 실행시키면, 5개의 UI로 구성된 바텀네비게이션을 확인할 수 있습니다. 따라서, 가장 먼저 바텀 네비게이션 바를 만들어보도록 하겠습니다.

Bottom Navigation Bar UI 제작

가장 먼저 GetX를 사용하기 위해서는 MaterialApp을 GetMaterialApp으로 바꾸어야 합니다. 더불어서, 앱의 foreground와 background는 각각 검정색과 흰색으로 고정될 것 같으므로, 앱 테마에서 함께 지정하도록 하겠습니다.

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,//디버그 배너 해제
      title: 'Flutter Demo',
      theme: ThemeData(
          primaryColor: Colors.white,
          scaffoldBackgroundColor: Colors.white,
          appBarTheme: AppBarTheme(
              foregroundColor: Colors.black, backgroundColor: Colors.white)),
      home: const App(),
      initialBinding: InitBinding(), //초기 바인딩
    );
  }
}

이제, 본격적인 바텀 네비게이션을 구현하겠습니다. 제일 먼저, App클래스를 생성하여 초기앱을 구성할겁니다. app.dart파일을 생성한 후, App클래스를 만들어줍니다. Stateful을 사용할 필요가 없기 때문에, Stateless로 생성합니다.

import ...

class App extends StatelessWidget {
  const App({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
    	body: Container(),
    );
  }
}

일단, 바텀 네비게이션을 먼저 만들겠습니다. 바텀 네비게이션은 가장 기본으로 제공되는 BottomNavigionBar를 이용하겠습니다. 그리고 가독성을 위해서 위젯을 분리하겠습니다.

import ...

class App extends StatelessWidget {
  const App({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
    	body: Container(),
        bottomNavigationBar: _bottomNavigationBar()),
    );
  }
  
  Widget _bottomNavigationBar() {
  	return BottomNavigationBar();
  }
}

지금부터 만들어야되는 바텀 네비게이션 아이템은 5개입니다. 각 아이템은 assets파일들을 이용해서 만들겁니다. 이를 위해서 ImageData 클래스를 생성하겠습니다. 해당 파일에서는 Image.asset을 이용하여 아이콘 이미지를 사용할겁니다. path와 너비값은 외부에서 받아야 사이즈를 조절할 수 있습니다.

class ImageData extends StatelessWidget {
  final String path;
  final double? width;

  const ImageData({
    super.key,
    required this.path,
    this.width = 70, //기본값은 항상 70
  });

  
  Widget build(BuildContext context) {
    return Image.asset(
      path,
      width: width! / Get.mediaQuery.devicePixelRatio,
    );
  }
}

여기서, 외부에서 입력받은 너비를 Get.mediaQuery.devicePixelRatio값으로 나누어 비율을 조절할 수 있습니다. 이제, path를 입력받아야 하는데, 직접 String 데이터로 path를 입력받는 것은 하나하나 경로를 알아야 하고, 경로가 너무 길어서 불편합니다. 따라서, ImagePath 클래스를 생성하여 static getter를 만들어주겠습니다.

class IconsPath {
  static String get homeOff => 'assets/images/bottom_nav_home_off_icon.jpg';
  static String get homeOn => 'assets/images/bottom_nav_home_on_icon.jpg';
	...
}

이제, 모든 경로를 별칭으로 지정하여 사용할 수 있습니다. 이를 이용하여 바텀네비게이션의 아이템들을 완성시킵니다. items는 기본적으로 제너릭타입으로 BottomNavigationBarItem위젯만 넣을 수 있습니다. BottomNavigationBarItem은 기본 아이콘인 icon, 활성화된 아이콘인 activeIcon, label을 이용할 수 있습니다.

Widget _bottomNavigationBar() {
    return BottomNavigationBar(
      showSelectedLabels: false,
      showUnselectedLabels: false,
      items: [
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.homeOff),
            activeIcon: ImageData(path: IconsPath.homeOn),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.searchOff),
            activeIcon: ImageData(path: IconsPath.searchOn),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.uploadIcon),
            activeIcon: ImageData(path: IconsPath.uploadIcon),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.reelsOff),
            activeIcon: ImageData(path: IconsPath.reelsOn),
            label: 'home'),
      ],
    );
  }

label은 required 프로퍼티입니다. 그렇기 때문에 일단, home이라고 대충 적어놓고, showSelectedLabels, showUnselectedLabels 항목에 false로 체크합니다. 이렇게하면 선택하더라도 label은 보이지 않습니다. 추가적으로 elevation은 0.0으로, backgroundColor는 white를, type에 BottomNavigationBarType.fixed를 전달해주면 매우 흡사하게 완성됩니다.

마지막 아이템을 만들어보겠습니다. ImageAvatar클래스를 만들어줍니다. 해당 위젯은 이미지의 url, 사이즈, 이미지 타입을 받아서 구현하도록 하겠습니다. 타입은 active, off를 구분하기 위해 사용할겁니다.

class ImageAvatar extends StatelessWidget {
  final String imgUrl;
  final double size;
  final Shape type;
  const ImageAvatar(
      {super.key,
      required this.imgUrl,
      required this.type,
      required this.size});

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

가장 기본이 되는 이미지 위젯을 제작하고, 이를 타입에 따라 적절하게 만들어주는 것이 좋을 것 같습니다. 이를 위해 가장 기본이 되는 이미지 위젯을 만들어주겠습니다. url을 통해서 이미지를 띄우려면, cached_network_image 패키지를 사용하는 것이 좋습니다.

# flutter pub add cached_network_image

패키지를 설치하면 꼭 flutter clean을 해주시기 바랍니다. 이제 가장 기본이 되는 이미를 만들어주겠습니다.

class ImageAvatar extends StatelessWidget {
  final String imgUrl;
  final double size;
  final Shape type;
  const ImageAvatar(
      {super.key,
      required this.imgUrl,
      required this.type,
      required this.size});

  
  Widget build(BuildContext context) {
   	return Container();
  }
  
  Widget _basicAvatar() {
    return Container(
      padding: EdgeInsets.all(1.0),
      decoration: BoxDecoration(color: Colors.white, shape: BoxShape.circle),
      child: CircleAvatar(
        backgroundColor: Colors.white,
        child: SizedBox(
          width: size,
          height: size,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(65),
            child: CachedNetworkImage(
              imageUrl: imgUrl,
              fit: BoxFit.cover,
            ),
          ),
        ),
      ),
    );
  }
}

이제 enum을 이용해서 타입을 만들어줍니다. 해당 타입을 통해 타입에 알맞은 이미지를 불러올 수 있습니다.

enum Shape {ACTIVE, OFF}

class ImageAvatar extends StatelessWidget {
  final String imgUrl;
  final double size;
  final Shape type;
  const ImageAvatar(
      {super.key,
      required this.imgUrl,
      required this.type,
      required this.size});

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

이제 타입에 알맞은 위젯들도 만들어줍니다. 핵심은 이미지 주위에 색깔 띠가 있어야 합니다. 선택된 이미지는 주위에 검정색 띠가 생깁니다. 물론, 선택되지 않은 이미지도 흰색 띠를 만들어줄겁니다. 그렇게 해야 이미지의 사이즈를 동일하게 할 수 있습니다.

  Widget _activeAvatar() {
    return Container(
      height: size + 2,
      width: size + 2,
      padding: EdgeInsets.all(1.0),
      decoration: BoxDecoration(color: Colors.black, shape: BoxShape.circle),
      child: _basicAvatar(),
    );
  }

  Widget _offAvatar() {
    return Container(
      height: size + 2,
      width: size + 2,
      padding: EdgeInsets.all(1.0),
      decoration: BoxDecoration(color: Colors.white, shape: BoxShape.circle),
      child: _basicAvatar(),
    );
  }

이제 마지막으로 switch case를 이용해서 타입에 따라 이미지를 생성할 수 있도록 합니다.

  
  Widget build(BuildContext context) {
    switch (type) {
      case Shape.ACTIVE:
        return _activeAvatar();
      case Shape.OFF:
        return _offAvatar();
    }
  }

만약 다른 이미지 아바타를 사용해야 한다면, 추가하는 방향으로 하면 되겠죠? 이제 마찬가지로 바텀네비게이션 아이템은 완성해줍니다.

  Widget _bottomNavigationBar() {
    return BottomNavigationBar(
      backgroundColor: Colors.white,
      elevation: 0.0,
      type: BottomNavigationBarType.fixed,
      showSelectedLabels: false,
      showUnselectedLabels: false,
      items: [
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.homeOff),
            activeIcon: ImageData(path: IconsPath.homeOn),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.searchOff),
            activeIcon: ImageData(path: IconsPath.searchOn),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.uploadIcon),
            activeIcon: ImageData(path: IconsPath.uploadIcon),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageData(path: IconsPath.reelsOff),
            activeIcon: ImageData(path: IconsPath.reelsOn),
            label: 'home'),
        BottomNavigationBarItem(
            icon: ImageAvatar(
                imgUrl: RandomImage.myImage, type: Shape.OFF, size: 25),
            activeIcon: ImageAvatar(
                imgUrl: RandomImage.myImage, type: Shape.ACTIVE, size: 25),
            label: 'home'),
      ],
    );
  }

이제 UI는 모두 완성되었습니다.

Bottom Navigation Bar 기능 제작

Bottom Navigation 기능을 위해서 BottomNavController를 생성하도록 하겠습니다.

class BottomNavController extends GetxController {
}

이 컨트롤러는 앱이 실행되는 동시에 메모리에 생성되어야 합니다. 따라서, InitBinding 클래스에 생성될 수 있도록 Get.put 메소드를 작성하겠습니다.

class InitBinding extends Bindings {
  
  void dependencies() {
    Get.put(BottomNavController(), permanent: true);
  }
}

permanent 속성을 true로 할당하게 되면, 앱이 종료되기 전까지 컨트롤러를 메모리에서 삭제하지 않습니다. 바텀네비게이션 기능은 앱이 실행되면 계속 사용되므로 permanent를 true로 할당합니다. 다시 컨트롤러로 돌아오겠습니다. 컨트롤러에서는 현재 페이지의 인덱스를 RxInt타입으로 생성하겠습니다. 이 Rx변수를 이용해서 네비게이션을 구현할겁니다.

class BottomNavController extends GetxController {
	RxInt _page = 0.obs;
  	int get pageIndex => _page.value;
}

getter를 통해 외부에서 해당값을 가져올 수 있습니다. 이제 바텀네비게이에 해당 인덱스를 등록해야 합니다. 그전에 StatelessWidget을 GetView로 바꾸겠습니다. 이렇게 바꿔주면 해당클래스에서는 controller를 이용해서 제너릭에 할당된 컨트롤러에 접근할 수 있습니다.

class App extends GetView<BottomNavController> {
  const App({super.key});

  
  Widget build(BuildContext context) {
  ...

아이콘을 클릭해서 화면을 이동하면 바텀네비게이션의 아이템도, 화면도 변화합니다. 따라서 Scaffold 전체를 Obx위젯으로 감싸야합니다.

  class App extends GetView<BottomNavController> {
  const App({super.key});

  
  Widget build(BuildContext context) {
    return Obx(
        () => Scaffold(
            backgroundColor: Colors.white,
            body: Container(),
            bottomNavigationBar: _bottomNavigationBar()),
    	);
  }

바텀네비게이션 바에도 현재 인덱스를 전달해주겠습니다.

 Widget _bottomNavigationBar() {
    return BottomNavigationBar(
      backgroundColor: Colors.white,
      elevation: 0.0,
      type: BottomNavigationBarType.fixed,
      showSelectedLabels: false,
      showUnselectedLabels: false,
      currentIndex: controller.pageIndex, //현재 페이지 인덱스
      ...

BottomNavigationBar위젯은 아이콘을 클릭하는 이벤트를 작성할 수 있는 onTap 프로퍼티가 존재합니다. controller.changeIndex를 전달하고 해당 메소드를 생성합니다. 그리고 아래와 같이 작성합니다.

 Widget _bottomNavigationBar() {
    return BottomNavigationBar(
      backgroundColor: Colors.white,
      elevation: 0.0,
      type: BottomNavigationBarType.fixed,
      showSelectedLabels: false,
      showUnselectedLabels: false,
      currentIndex: controller.pageIndex,
      onTap: controller.changeIndex, //페이지 이동 메소드
      ...
class BottomNavController extends GetxController {
  RxInt _page = 0.obs;

  int get pageIndex => _page.value;

  void changeIndex(int value) {
	_page(value);
  }
}

여기서 value는 선택한 아이콘의 인덱스입니다. 왼쪽부터 순서대로 0, 1, 2, 3, 4를 의미하죠. 이 값을 그대로 Rx변수에 전달하면 됩니다. 간단하죠? 이제 아이콘을 클릭하면 페이지 인덱스를 바꿀 수 있습니다. 실제로 바뀌는지 확인하기 위해 간단한 페이지들을 만들어주겠습니다. 해당 페이지들은 body에 전달할거기 때문에 body를 분리해서 작성하겠습니다.

  ...
  
  Widget build(BuildContext context) {
    return Obx(
        () => Scaffold(
            backgroundColor: Colors.white,
            body: _body(),
            bottomNavigationBar: _bottomNavigationBar()),
    	);
  }
  
  Widget _body() {
  	return Container();
  }

간단한 페이지는 Center위젯에 text를 이용해서 제작할건데, 이 페이지들을 바텀네비게이션으로 보기 위해서는 IndexedStack을 이용할 수 있습니다.

  Widget _body() {
    return IndexedStack(
      index: controller.pageIndex,
      children: [
        Center(
          child: Text('home'),
        ),
        Center(
          child: Text('search'),
        ),
        Center(
          child: Text('upload'),
        ),
        Center(
          child: Text('reels'),
        ),
        Center(
          child: Text('mypage'),
        ),
      ],
    );
  }

IndexedStack은 인덱스를 이용해서 해당 페이지를 반환하는 위젯입니다. controller.pageIndex를 index로 전달하면 해당 화면을 나타내겠죠? 이제 바텀 네비게이션 기능이 완성되었습니다.

+ 히스토리 기능

현재는 IOS simulator를 사용하기 때문에 느낄 수 없지만 안드로이드인 경우, 뒤로가기 버튼을 클릭하면 히스토리 기능을 이용해서 이동해왔던 인덱스로 이동해야 합니다. 무슨의미인지 잘 안와닿나요? 안드로이드 버전을 봐주세요.

>뒤로가기 버튼을 누르면, 현재까지 방문했던 히스토리를 거슬러올라가야 합니다. 해당 기능을 위해 히스토리를 저장할 List 타입의 변수를 만들어주겠습니다.
  class BottomNavController extends GetxController {
  RxInt _page = 0.obs;

  int get pageIndex => _page.value;

  List bottomHistory = [0];

  void changeIndex(int value) {
  ...

맨 처음 앱이 실행되면 항상 홈 화면입니다. 따라서, 히스토리는 맨 처음에 0을 가지고 있어야 합니다. 이제 이동할때마다 해당 히스토리에 현재 인덱스를 하나씩 저장하도록 하겠습니다. 그리고 화면마다 인덱스로 이동하면 후에 유지보수가 어려울 수 있을 것 같으니까 마찬가지로 enum을 이용해서 페이지의 이름을 직관적으로 관리하겠습니다.

enum Page { HOME, SEARCH, UPLOAD, REELS, MYPAGE }

class BottomNavController extends GetxController {
  RxInt _page = 0.obs;

  int get pageIndex => _page.value;

  List bottomHistory = [0];

  void changeIndex(int value) {
    var page = Page.values[value];
    switch (page) {
      case Page.UPLOAD:
      case Page.HOME:
      case Page.SEARCH:
      case Page.REELS:
      case Page.MYPAGE:
        moveTo(value);
    }
  }

  void moveTo(int value) {
    _page(value);
    if (bottomHistory.last != value && Platform.isAndroid) {
      bottomHistory.add(value);
    }
  }

Page라는 enum클래스는 각 페이지의 이름을 담고있습니다. Page.values를 이용하면 Page클래스가 갖고있는 아이템들을 가지고 있는 아이템을 List형식으로 가져와서 사용할 수 있죠. 이를 이용해서 현재 페이지의 index를 통해 Page를 가져와서 각 페이지에 맞는 라우팅을 정의할 수 있습니다. 나중에 업로드 화면은 다른화면과 다르게 라우팅하기 때문에 이렇게 해준겁니다. moveTo함수는 히스토리를 저장하면서 _page에 새로운 인덱스를 전달합니다. 단, 현재 OS가 안드로이드이면서 히스토리의 마지막 값이 현재 페이지가 아닌 경우에만 히스토리를 남겨야 합니다. 그냥 히스토리를 계속 남기면, 동일한 페이지 아이콘을 클릭해도 계속 히스토리가 쌓이겠죠? 이제, 뒤로가기 기능을 구현해야 합니다.

다시 App클래스로 이동해서 Scaffold 전체를 WillPopScope위젯으로 감싸줍니다.

  
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: controller.willPopAction,
      child: Obx(
        () => Scaffold(
            backgroundColor: Colors.white,
            body: _body(),
            bottomNavigationBar: _bottomNavigationBar()),
      ),
    );
  }

WillPopScope는 뒤로가기 버튼을 마음대로 커스터마이징할 수 있는 위젯입니다. onWillPop프로퍼티를 통해 뒤로가기 액션을 지정할 수 있어요. controller.willPopAction을 전달하고 해당 메소드를 생성합니다.

 Future<bool> willPopAction() async {
    if (bottomHistory.length == 1) {
      return true;
    } else {
      bottomHistory.removeLast();
      _page(bottomHistory.last);
      print(bottomHistory);
      return false;
    }
  }

이 메소드를 이용해서 히스토리가 현재 1이라면 앱을 종료시키고, 그렇지 않은 경우, 히스토리의 마지막을 삭제합니다. 그러면 마지막에는 이전 페이지의 히스토리가 남아있겠죠? 그래서 그 인덱스로 페이지를 갱신시켜주면 됩니다. 이를 이용해서 히스토리 기능도 모두 완성했습니다 !

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

2개의 댓글

comment-user-thumbnail
2023년 10월 13일

상세히 써주셔서 읽으면서 도움이 많이 됐습니다. 질문이 있는데, BottomNavController 부분은, GetXController를 extend하셔서 단순 상태관리일텐데, RxInt와 obs를 사용하신 이유가 궁금합니다!

1개의 답글

관련 채용 정보