프로필 앱 구조 살펴보기

Stupefyee's avatar
Dec 24, 2024
프로필 앱 구조 살펴보기

1. 시작하기에 앞서

1. 샘플코드 확인하기

  • 처음 다루는 위젯은 샘플 코드를 통해 구조를 파악하는 것이 먼저
    • import 'package:flutter/material.dart'; /// Flutter code sample for [TabBar]. void main() => runApp(const TabBarApp()); class TabBarApp extends StatelessWidget { const TabBarApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: const TabBarExample(), ); } } class TabBarExample extends StatelessWidget { const TabBarExample({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( initialIndex: 1, // 탭의 시작점 >> 시작 값 0 length: 3, // 탭의 갯수 child: Scaffold( appBar: AppBar( title: const Text('TabBar Sample'), bottom: const TabBar( // 탭의 갯수 만큼 생성 tabs: <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), ), // 각 탭의 내용 body: const TabBarView( children: <Widget>[ Center( child: Text("It's cloudy here"), ), Center( child: Text("It's rainy here"), ), Center( child: Text("It's sunny here"), ), ], ), ), ); } }
      실행 시 나오는 화면
      실행 시 나오는 화면

2. 변형시켜 확인하기

  • 탭의 개수와 탭 아이콘, 내용을 변형시켜 확인
    • import 'package:flutter/material.dart'; /// Flutter code sample for [TabBar]. void main() => runApp(const TabBarApp()); class TabBarApp extends StatelessWidget { const TabBarApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(useMaterial3: true), home: const TabBarExample(), ); } } class TabBarExample extends StatelessWidget { const TabBarExample({super.key}); @override Widget build(BuildContext context) { return DefaultTabController( initialIndex: 1, length: 2, child: Scaffold( appBar: AppBar( title: const Text('TabBar Sample'), bottom: const TabBar( tabs: <Widget>[ Tab( icon: Icon(Icons.directions_subway), ), Tab( icon: Icon(Icons.directions_car), ), ], ), ), body: TabBarView( children: <Widget>[ Column( children: [ Container(height: 100, color: Colors.red), Container(height: 100, color: Colors.yellow), Container(height: 100, color: Colors.blue), ], ), Center( child: Text("It's sunny here"), ), ], ), ), ); } }
      실행 화면
      실행 화면

2. 적용시켜 만들기

  1. 탭 안에 사진들 넣기
      • 색깔 컨테이너 대신 사진들을 그리드로 넣어야 함
notion image
TabBarView( // 탭 전환에 따라 다른 화면을 보여주는 위젯 children: <Widget>[ // 각 탭에 해당하는 콘텐츠를 정의하는 리스트 GridView.builder( // 유동적인 그리드 레이아웃을 생성하는 위젯 physics: NeverScrollableScrollPhysics(), // 그리드 자체 스크롤을 비활성화 itemCount: count, // 그리드 아이템의 개수 gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, // 열의 개수를 3으로 고정 ), itemBuilder: (context, index) { // 각 아이템을 빌드하는 함수 return Image.network( // 네트워크에서 이미지를 가져오는 위젯 "https://picsum.photos/id/${200 + index}/100/100", // 이미지 URL, 각 index에 따라 변경, 더미 사진가져오는 사이트 fit: BoxFit.cover, // 이미지가 위젯 크기에 맞게 채워짐 ); }, ), Center( // 중앙에 정렬된 위젯을 배치 child: Text("It's sunny here"), // 텍스트를 화면에 표시 ), ], );
탭바 내부 코드만
실행화면
실행화면

3. 상단 부와 합치기

1. 상단 부 코드

  • 제작과정은 생략(전과 비슷함)
import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); // 앱의 진입점, MyApp 위젯 실행 } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, // 디버그 배너 숨김 theme: ThemeData(useMaterial3: true), // Material 3 디자인 적용 home: TabBarExample(), // 메인 화면으로 TabBarExample 설정 ); } } class TabBarExample extends StatelessWidget { const TabBarExample({super.key}); @override Widget build(BuildContext context) { double width = MediaQuery.of(context).size.width; // 화면의 너비 가져오기 double gridBoxHeight = width / 3; // 3등분한 그리드 박스 높이 계산 int count = 30; // 그리드 아이템 개수 double tabbarHeight = 50; // 탭바 높이 double tabControllerHeight = tabbarHeight + // 탭 컨트롤러 총 높이 계산 (gridBoxHeight * ((count ~/ 3) + (count % 3 > 0 ? 1 : 0))); // 행의 개수에 따라 추가 높이 설정 return Scaffold( endDrawer: Container( // 오른쪽에 슬라이드로 열리는 드로어 width: 200, // 드로어의 너비 color: Colors.yellow, // 드로어 배경색 설정 ), appBar: AppBar( // 상단 앱바 설정 leading: Icon(Icons.arrow_back_ios), // 뒤로가기 아이콘 title: Text("Profile"), // 앱바 제목 설정 centerTitle: true, // 제목을 가운데 정렬 ), body: ListView( // 스크롤 가능한 리스트뷰 children: [ ProfileImageAndName(), // 프로필 이미지와 이름 위젯 ProfileCountInfo(), // 프로필 관련 정보 위젯 SizedBox( height: 10, // 리스트뷰 내 간격 추가 ), ButtonBox(), // 버튼 관련 위젯 ], ), ); } } class ProfileImageAndName extends StatelessWidget { const ProfileImageAndName({ super.key, }); @override Widget build(BuildContext context) { return Row( // 가로 방향으로 정렬되는 위젯 mainAxisAlignment: MainAxisAlignment.start, // 가로 방향으로 왼쪽 정렬 children: [ SizedBox( width: 25, // 프로필 이미지 왼쪽에 여백 추가 ), SizedBox( width: 100, // 프로필 이미지의 너비 설정 height: 100, // 프로필 이미지의 높이 설정 child: CircleAvatar( // 원형 아바타 위젯 backgroundImage: AssetImage("avatar.png"), // 로컬 이미지 파일 설정 ), ), SizedBox( width: 25, // 프로필 이미지와 텍스트 간의 여백 추가 ), Column( // 세로 방향으로 정렬되는 위젯 crossAxisAlignment: CrossAxisAlignment.start, // 텍스트를 왼쪽 정렬 children: [ Text( "GetinThere", style: TextStyle( fontSize: 25, // 글자 크기 fontWeight: FontWeight.w700, // 글자 굵기 ), ), Text( "프로그래머/작가/강사", style: TextStyle( fontSize: 20, // 글자 크기 ), ), Text("데어 프로그래밍"), ], ), ], ); } } class ProfileCountInfo extends StatelessWidget { const ProfileCountInfo({ super.key, }); @override Widget build(BuildContext context) { return Row( // 가로로 정렬되는 위젯 mainAxisAlignment: MainAxisAlignment.spaceAround, // 자식 위젯들 사이의 간격을 고르게 분배 children: [ ProfileInfo( // 첫 번째 정보 (포스트 개수) myCount: "50", myText: "Posts", ), GetLine(), ProfileInfo( // 두 번째 정보 (좋아요 개수) myCount: "10", // 숫자 정보 myText: "Likes", // 텍스트 정보 ), GetLine(), ProfileInfo( // 세 번째 정보 (공유 개수) myCount: "3", // 숫자 정보 myText: "Share", // 텍스트 정보 ), ], ); } } class GetLine extends StatelessWidget { const GetLine({ super.key, }); @override Widget build(BuildContext context) { return Container( width: 2, height: 60, color: Colors.blue, ); } } class ProfileInfo extends StatelessWidget { final myCount; final myText; const ProfileInfo({required this.myCount, required this.myText}); @override Widget build(BuildContext context) { return Column( children: [ Text("${myCount}"), Text("${myText}"), ], ); } } class ButtonBox extends StatelessWidget { const ButtonBox({ super.key, }); @override Widget build(BuildContext context) { return Row( // 가로로 정렬되는 위젯 mainAxisAlignment: MainAxisAlignment.spaceAround, // 자식 위젯들 간의 간격을 고르게 분배 children: [ TextButton( // 첫 번째 버튼 onPressed: () {}, // 버튼 클릭 시 실행될 콜백 (현재 비어 있음) child: Text("Follow"), // 버튼 텍스트 style: TextButton.styleFrom( // TextButton의 스타일 설정 backgroundColor: Colors.blueAccent, // 버튼 배경색 foregroundColor: Colors.white, // 버튼 텍스트 색상 shape: RoundedRectangleBorder( // 버튼 테두리 모양 설정 borderRadius: BorderRadius.circular(10), // 모서리를 둥글게 처리 ), minimumSize: Size(170, 40), // 버튼의 최소 크기 설정 (너비, 높이) ), ), OutlinedButton( // 두 번째 버튼 onPressed: () {}, // 버튼 클릭 시 실행될 콜백 (현재 비어 있음) child: Text("Message"), // 버튼 텍스트 style: OutlinedButton.styleFrom( // OutlinedButton의 스타일 설정 minimumSize: Size(170, 40), // 버튼의 최소 크기 설정 (너비, 높이) foregroundColor: Colors.black, // 버튼 텍스트 및 테두리 색상 shape: RoundedRectangleBorder( // 버튼 테두리 모양 설정 borderRadius: BorderRadius.circular(10), // 모서리를 둥글게 처리 ), ), ), ], ); } }
실행 화면
실행 화면

2. 탭바도 합친 전체 코드

import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, theme: ThemeData(useMaterial3: true), home: TabBarExample(), ); } } class TabBarExample extends StatelessWidget { const TabBarExample({super.key}); @override Widget build(BuildContext context) { double width = MediaQuery.of(context).size.width; double gridBoxHeight = width / 3; int count = 30; double tabbarHeight = 50; double tabControllerHeight = tabbarHeight + (gridBoxHeight * ((count ~/ 3) + (count % 3 > 0 ? 1 : 0))); return Scaffold( endDrawer: Container( width: 200, color: Colors.yellow, ), appBar: AppBar( leading: Icon(Icons.arrow_back_ios), title: Text("Profile"), centerTitle: true, ), body: ListView( children: [ ProfileImageAndName(), ProfileCountInfo(), SizedBox( height: 10, ), ButtonBox(), ProfileTabbar(tabControllerHeight: tabControllerHeight, count: count), ], ), ); } } class ProfileTabbar extends StatelessWidget { const ProfileTabbar({ super.key, required this.tabControllerHeight, required this.count, }); final double tabControllerHeight; final int count; @override Widget build(BuildContext context) { return SizedBox( height: tabControllerHeight, child: DefaultTabController( initialIndex: 0, // 시작 인덱스 0 length: 2, child: Column( children: [ const TabBar( tabs: <Widget>[ Tab( icon: Icon(Icons.directions_subway), ), Tab( icon: Icon(Icons.directions_car), ), ], ), Expanded( child: TabBarView( children: <Widget>[ GridView.builder( physics: NeverScrollableScrollPhysics(), itemCount: count, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3), itemBuilder: (context, index) { return Image.network( "https://picsum.photos/id/${200 + index}/100/100", fit: BoxFit.cover, ); }, ), Center( child: Text("여긴 또 다른 탭의 영역"), ), ], ), ), ], ), ), ); } } class ButtonBox extends StatelessWidget { const ButtonBox({ super.key, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ TextButton( onPressed: () {}, child: Text("Follow"), style: TextButton.styleFrom( backgroundColor: Colors.blueAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), minimumSize: Size(170, 40), ), ), OutlinedButton( onPressed: () {}, // 버튼 클릭 시 동작 child: Text("Message"), // 버튼의 텍스트 style: OutlinedButton.styleFrom( minimumSize: Size(170, 40), foregroundColor: Colors.black, // 텍스트 색상 shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), // 모서리 둥근 정도 ), ), ), ], ); } } class ProfileCountInfo extends StatelessWidget { const ProfileCountInfo({ super.key, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ProfileInfo( myCount: "50", myText: "Posts", ), GetLine(), ProfileInfo( myCount: "10", myText: "Likes", ), GetLine(), ProfileInfo( myCount: "3", myText: "Share", ), ], ); } } class GetLine extends StatelessWidget { const GetLine({ super.key, }); @override Widget build(BuildContext context) { return Container( width: 2, height: 60, color: Colors.blue, ); } } class ProfileImageAndName extends StatelessWidget { const ProfileImageAndName({ super.key, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ SizedBox( width: 25, ), SizedBox( width: 100, height: 100, child: CircleAvatar( backgroundImage: AssetImage("avatar.png"), ), ), SizedBox( width: 25, ), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "GetinThere", style: TextStyle( fontSize: 25, fontWeight: FontWeight.w700, ), ), Text( "프로그래머/작가/강사", style: TextStyle( fontSize: 20, ), ), Text("데어 프로그래밍"), ], ), ], ); } } class ProfileInfo extends StatelessWidget { final myCount; final myText; const ProfileInfo({required this.myCount, required this.myText}); @override Widget build(BuildContext context) { return Column( children: [ Text("${myCount}"), Text("${myText}"), ], ); } }
최종화면
최종화면

4. 주의사항

  • GridListView를 혼용하면 앱에서 스크롤이 2개가 되고 Grid 가 화면을 모두 차지하면 ListView의 스크롤이 사라져 프로필 상단부를 다시 못보게 됨 + Grid 의 영역을 미리 구해두지 않으면 앱에서 해당 영역의 사이즈를 몰라 무너지게 됨
  • 이를 방지하기 위해 physics: NeverScrollableScrollPhysics() 을 통해 Grid 의 스크롤을 비활성화 시키고 아래 코드의 산수식을 통해 미리 Grid의 영역을 계산하여 넣어둠
class TabBarExample extends StatelessWidget { const TabBarExample({super.key}); @override Widget build(BuildContext context) { double width = MediaQuery.of(context).size.width; double gridBoxHeight = width / 3; // 정사각형이 가로로 3개 들어감 int count = 30; // Grid에 들어갈 아이템 수 double tabbarHeight = 50; double tabControllerHeight = tabbarHeight + (gridBoxHeight * ((count ~/ 3) + (count % 3 > 0 ? 1 : 0))); ... class ProfileTabbar extends StatelessWidget { const ProfileTabbar({ super.key, required this.tabControllerHeight, required this.count, }); final double tabControllerHeight; final int count; @override Widget build(BuildContext context) { return SizedBox( height: tabControllerHeight, child: DefaultTabController( initialIndex: 0, // 시작 인덱스 0 length: 2, child: Column( children: [ const TabBar( tabs: <Widget>[ Tab( icon: Icon(Icons.directions_subway), ), Tab( icon: Icon(Icons.directions_car), ), ], ), Expanded( child: TabBarView( children: <Widget>[ GridView.builder( physics: NeverScrollableScrollPhysics(), itemCount: count, ...

마지막. 추가 정보 정리

1. 단축어(숏컷) 생성하기

notion image
Change는 Define일 경우도 있음
Change는 Define일 경우도 있음
notion image

2. 선언형 UI

  1. 정의
      • 선언형 UI(Declarative UI)는 UI의 상태를 선언적으로 정의하는 방식.
      • UI가 어떻게 보여야 할지에 대한 선언만 이루어지고, 실제 렌더링은 프레임워크나 시스템이 관리.
  1. 특징
      • UI는 상태를 기반으로 렌더링.
      • UI와 로직이 분리되어 있음.
      • 시스템이 렌더링을 최적화하여 효율적.
  1. 예시
      • Flutter에서 setState(), StreamBuilder를 통해 상태 변경을 선언적으로 처리.
        • setState(() { _counter++; });
  1. 장점
      • UI와 로직이 분리되어 가독성과 유지보수가 용이.
      • 상태에 따라 자동으로 UI가 업데이트되어 직관적이고 오류 가능성 감소.

3. 뷰홀더 패턴

  1. 정의
      • 반복되는 아이템을 다룰 때 성능을 최적화하는 디자인 패턴.
      • 뷰를 재사용하여 메모리 사용을 최소화.
2. 특징
  • 화면에 표시되지 않는 뷰는 재사용.
  • 성능 최적화가 가능.
  • 리스트나 그리드에서 대량의 데이터를 처리할 때 유용.
  1. 예시
      • Android에서 RecyclerView와 ViewHolder를 사용하여 뷰를 재사용.
      • ViewHolder는 뷰를 재사용하는 객체.
        • public class MyViewHolder extends RecyclerView.ViewHolder { TextView textView; public MyViewHolder(View itemView) { super(itemView); textView = itemView.findViewById(R.id.textView); } }
4. 장점
  • 메모리 효율성 증가.
  • 성능 향상.
  • 빠르고 효율적인 렌더링 제공.

Share article

stupefyee