티스토리 뷰

Flutter

Flutter로 포켓몬 도감 만들어보기 (1)

알렌보이스 2023. 2. 5. 20:24
728x90

이전에 Compose를 활용하셔 포켓몬 도감을 만든 적이 있습니다.
거기서 만든 Fast-Api 사용하여 일부 화면만 구현을 해보았습니다.

추후 다른 페이지는 새롭게 디자인 작업을 진행하려고 합니다.

아직은 Flutter를 많이 배우지 않았고 사용해 본 경험이 적어서 생각하는 페이지가 완성되는 것에 초점을 맞춰서 개발을 진행하였습니다.
비효율 적인 코드와 성능이 안 좋게 하는 코드가 있을 수 있습니다.

pubspec.yaml


dev_dependencies:
  flutter_test:
    sdk: flutter

  http: ^0.13.5
  flutter_svg: ^1.1.6
  percent_indicator: ^4.2.2

우선 이번에 사용하게 된 Libraray는 총 3개입니다.

네트워크 통신을 위한 http,

svg 확장자로 된 파일을 이미지에 표시하기 위한 flutter_svg,

프로그래스 바에 텍스트, 애니메이션이 적용시키기 위한 percent_indicator

project
 - assets
   - font
   - images

프로젝트에 사용할 폰트와 이미지들을 위와 같은 위치에 저장해 두었습니다.

flutter
  uses-material-design: true

  assets:
    - assets/images/

  fonts:
    - family: MaruBrui
      fonts:
        - asset : assets/font/maru_buri_light.ttf
          weight: 400
        - asset : assets/font/maru_buri_bold.ttf
          weight: 700

font의 경우 여러 개가 있었지만 normal과 bold만 사용하려고 합니다.
적용 후 너무 진지해 보이는 폰트라 안 어울리는 것 같지만 일단 넘어가겠습니다.

main.dart


void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PokeDex',
      theme: ThemeData(
        primaryColor: const Color(0xFFED6035),
        primarySwatch: Colors.blue,

        fontFamily: 'MaruBrui',

        textTheme: const TextTheme(
          headline1: TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.black),
          headline2: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
          headline3: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
          bodyText1: TextStyle(fontSize: 16, fontWeight: FontWeight.normal, color: Colors.black),
          bodyText2: TextStyle(fontSize: 14, fontWeight: FontWeight.normal, color: Colors.black),
          caption: TextStyle(fontSize: 12, fontWeight: FontWeight.normal, color: Colors.black),
        )
      ),
      home: const SplashPage(),
    );
  }
}

Theme에 위에서 저장한 폰트 및 textTheme와 primaryColor를 설정하였습니다.

Text(
  "포켓몬 도감",
  style: Theme.of(context).textTheme.headline2,
)

Scaffold(
  backgroundColor: Theme.of(context).primaryColor
...)

적용한 Theme는 Theme.of(context)를 통해 사용할 수 있습니다.

Screenshot_20230203_165850 1.png

첫 화면은 Splash 화면입니다.

class SplashPage extends StatefulWidget {
  const SplashPage({Key? key}) : super(key: key);

  @override
  State<SplashPage> createState() => _SplashPageState();
}

class _SplashPageState extends State<SplashPage> {
  @override
  void initState() {
    super.initState();

    Future.delayed(const Duration(milliseconds: 1000), () {
      Navigator.pushAndRemoveUntil(
          context,
          MaterialPageRoute(
              builder: (BuildContext context) => const HomeScreen()),
          (route) => false);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Theme.of(context).primaryColor,
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Image.asset(
              "$imagesAddress/logo.png",
              scale: 1.5,
            ),
            const SizedBox(
              height: 20,
            ),
            Text(
              "포켓몬 도감",
              style: Theme.of(context).textTheme.headline2,
            )
          ],
        ),
      ),
    );
  }
}

Splash를 구현하기 위한 Library가 있는 거 같긴 한데 일단 가볍게 만드는 앱이므로 단순하게 만들어 보았습니다.

Future.delayed(const Duration(milliseconds: 1000), () {
  Navigator.pushAndRemoveUntil(
      context,
      MaterialPageRoute(
          builder: (BuildContext context) => const HomeScreen()),
      (route) => false);
});

Futere의 delayed 함수를 이용해서 1초 후에 홈 화면으로 이동하도록 설정하였습니다.

Navigator.pushAndRemoveUntil를 이용하면 페이지를 이동하면서 자신을 포함한 이전 페이지를 제거할 수 있습니다.

const String imagesAddress = 'assets/images';

이미지 등록할 때 $imageAddress를 사용하였는데 이미지를 자주 사용할 거라서

오타를 방지하기 위해 별도의 파일에 위와 같이 지정해 주었습니다.

home.dart


Screenshot_20230203_172635 1.png

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        body: Column(
          children: [
            Card(
              margin: const EdgeInsets.only(left: 24, right: 24, top: 30),
              elevation: 6,
              child: InkWell(
                onTap: () {
                  Navigator.push(
                      context,
                      MaterialPageRoute(
                          builder: (BuildContext context) =>
                              const ListScreen()));
                },
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    Container(
                      padding: const EdgeInsets.only(left: 13),
                      child: Text(
                        '전국 도감',
                        style: Theme.of(context).textTheme.headline2
                            ?.apply(color: Theme.of(context).primaryColor),)
                    ),
                    const Spacer(),
                    Align(
                        alignment: Alignment.bottomRight,
                        child: Image.asset('$imagesAddress/img_dex.png'))
                  ],
                ),
              ),
            )
          ],
        ),
      ),
    );
  }
}

홈 화면은 추후에 다른 화면으로 이동하기 위해서 거치는 페이지입니다.

그래서 지금은 전국도감 하나만 존재하고 있습니다.

network.dart


다음 화면을 넘어가기 전에 api 연결을 먼저 진행하겠습니다.

 

 

포켓몬 도감 만들기(1) : Fast Api

Fast Api 설치 pip install fastapi 터미널에 위의 명령어를 입력하면 설치가 됩니다. 추가로 Python이 없을 경우 따로 설치해야 합니다. pip install "uvicorn[standrad]" 서버 작동을 위해서 uvicorn도 설치를 합니

alanboyce.tistory.com

Api는 위의 링크에서 Fast Api로 만든 Api를 사용하였습니다.

class NetworkUtil {
  final _baseUrl = 'https://3adf-121-164-144-250.ngrok.io';

  Future<List<PokemonListItem>> fetchPokemonList(String searchText, List<int> generations) async {
    var url = '$_baseUrl/pokemons/search';
    http.Response response = await http.post(
        Uri.parse(url),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
        },
      body: jsonEncode(<String, dynamic>{
        'generations': generations,
        'searchText': searchText
      })
    );
    if (response.statusCode == 200) {
      List<dynamic> resultList = jsonDecode(utf8.decode(response.bodyBytes));
      List<PokemonListItem> list = List.empty(growable: true);

      for(var result in resultList) {
        list.add(PokemonListItem.fromJson(result));
      }
      return list;
    } else {
      return throw ('리스트 조회 실패');
    }
  }

  Future<Pokemon> fetchPokemonDetailInfo(String number) async {
    var url = '$_baseUrl/pokemon/number/$number';
    http.Response response = await http.get(Uri.parse(url));
    if(response.statusCode == 200) {
      return Pokemon.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
    } else {
      return throw ('조회 실패');
    }
  }
}

UI랑 서버 호출을 분리하고 싶어서 NetworkUtil이라는 클래스를 만들어서 Api를 관리하도록 하였습니다.

http.get(), 또는 http.post() 등으로 Api를 호출하여 결과를 받아올 수 있습니다.

비동기로 동작할 수 있도록 함수 async와 호출 시 await 키워드를 사용합니다.

jsonDecode(utf8.decode(response.bodyBytes))

jsonDecode를 이용해서 받은 결과 값을 json 형태로 만들어줍니다.

한글 깨짐 현상이 있을 경우 utf8.decode(response.bodyBytes)를 사용하면 됩니다.

class PokemonListItem {
  final String number;
  final String name;
  final String dotImage;
  final String dotShinyImage;
  final String attribute;

  const PokemonListItem(
      {this.number = "",
      this.name = "",
      this.dotImage = "",
      this.dotShinyImage = "",
      this.attribute = ""});

  factory PokemonListItem.fromJson(Map<String, dynamic> json) {
    return PokemonListItem(
      number: json['number'],
      name: json['name'],
      dotImage: json['dotImage'],
      dotShinyImage: json['dotShinyImage'],
      attribute: json['attribute'],
    );
  }
}

결과를 담을 클래스를 만든 후 factory를 이용하여 json에 담긴 정보를 클래스의 정보에 맞게 받아서 객체를 생성하게 합니다.

최종 정리를 하자면

Future<Pokemon> fetchPokemonDetailInfo(String number) async {
    var url = '$_baseUrl/pokemon/number/$number';
    http.Response response = await http.get(Uri.parse(url));

        return Pokemon.fromJson(jsonDecode(utf8.decode(response.bodyBytes)))
}
  1. http Library의 함수 get, post 등을 이용하여 서버 호출
  2. async - awiat 키워드를 이용하여 비동기로 서버 호출
  3. utf8.decode를 통해 한글 깨짐 문제 해결
  4. jsonDecode를 통해 서버에서 받은 정보를 Json 객체로 변환
  5. factory를 통해 위의 Json 객체의 담긴 정보를 바탕으로 결과를 담을 클래스의 객체 생성
  6. 그 후 원하는 곳에서 해당 객체를 이용하면 됩니다.

list.dart


Screenshot_20230203_191456 1.pngScreenshot_20230203_191608 1.png

포켓몬의 리스트화면입니다.

검색과, 조건 선택을 통해 리스트 내용을 변경할 수 있습니다.

// Scaffold 글로벌 키 : Drawer(menu) 오픈 시 사용
final GlobalKey<ScaffoldState> globalKey = GlobalKey<ScaffoldState>();
// 검색어
String searchText = "";
// 서버에서 조회한 포켓몬 리스트
List<PokemonListItem> originalList = List.empty(growable: true);
// 화면에 출력을 위한 리스트
List<PokemonListItem> list = List.empty(growable: true);
// 선택된 타입 리스트
List<TypeCondition> typeList = TypeInfo.values
    .where((element) => element.name != 'unknown')
    .map((e) => TypeCondition(image: e.image, name: e.name))
    .toList();
// 선택된 세대 리스트
List<int> generationList = [1];

@override
void initState() {
  super.initState();
  fetchPokemonList(true);
}

/// 포켓몬 리스트 조회
void fetchPokemonList(bool isInit) async {
  originalList = await NetworkUtil().fetchPokemonList(searchText, generationList);
    isInit ? typeConditionChange() : list = originalList;
  setState(() {});
}

/// 포켓몬 리스트 타입 변경
void typeConditionChange() {
  setState(() {
    list = originalList.where((element) {
      bool result = false;
      for (var type in element.attribute.split(',')) {
        if (!result) {
          result = typeList
              .where((element) => element.isSelect)
              .map((e) => e.name)
              .contains(type);
        }
      }
      return result;
    }).toList();
  });
}

/// 조회 세대 변경
void generationChange(int generation) {
  setState(() {
    if (generationList.contains(generation)) {
      generationList.remove(generation);
    } else {
      generationList.add(generation);
    }
  });
  fetchPokemonList();
}

/// 리스트 조회 조건 초기화
void reset() {
  setState((){
    typeList = TypeInfo.values
        .where((element) => element.name != 'unknown')
        .map((e) => TypeCondition(image: e.image, name: e.name))
        .toList();
    generationList = [1];
  });
  fetchPokemonList(false);
}

우선 데이터 영역입니다.

최초로 initState()에서 서버 호출을 진행하여 데이터를 조회합니다.

조회한 정보는 originalList에 저장하고 list에 담아서 UI에 표시합니다.

검색, 조건 변경이 이루어질 시 typeConditionChange()와 generationChange()를 통해 조건에 맞게 list를 변경합니다.

typeConditionChange()는 조건에서 타입이 변경됐을 때로 원본 리스트에서 사용자가 선택한 타입의 포켓몬만 골라서 list에 담아주는 역할을 합니다.

generationChange()는 변경된 세대를 반영해서 다시 서버 호출을 진행하게 됩니다.

reset()은 조건을 초기 상태로 되돌립니다.

@override
  Widget build(BuildContext context) {
    return SafeArea(
        child: Scaffold(
          key: globalKey,
          /// 조건 선택 메뉴
          endDrawer: listDrawer(
              context: context,
              typeList: typeList,
              generationList: generationList,
              typeChangeListener: (index, isSelect) {
                typeList[index].isSelect = isSelect;
                typeConditionChange();},
              generationChangeListener: (generation) {
                generationChange(generation);},
              resetListener: () {
                reset();
              }),
          body: SingleChildScrollView(
            child: Center(
              child: Column(
                children: [
                  /// 뒤로가기 & 메뉴
                  Container(
                    padding: const EdgeInsets.only(top: 16, left: 24, right: 24),
                    child: Row(
                      children: [
                        GestureDetector(
                          onTap: () {
                            Navigator.pop(context);
                            },
                          child: SvgPicture.asset(
                            '$imagesAddress/ic_prev.svg',
                            height: 24,
                            width: 24,
                          ),
                        ),
                        const Spacer(),
                        GestureDetector(
                          onTap: () {
                            globalKey.currentState?.openEndDrawer();},
                          child: SvgPicture.asset(
                            '$imagesAddress/ic_menu.svg',
                            height: 24,
                            width: 24,
                          ),
                        )
                      ],
                    ),
                  ),
                  /// 검색창
                  Container(
                    padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
                    child: TextField(
                      decoration: const InputDecoration(
                          contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 20),
                          border: OutlineInputBorder(
                            borderRadius: BorderRadius.all(Radius.circular(50.0)),
                            borderSide: BorderSide(
                                color: Color(0xFF299AE6)
                            ),
                          ),
                          filled: true,
                          fillColor: Color(0xFFE3F4FF),
                          hintText: '포켓몬 검색'
                      ),
                      onSubmitted: (value){
                        setState((){
                          searchText = value;
                        });
                        fetchPokemonList(false);},
                    ),
                  ),
                  /// 포켓몬 리스트
                  Container(
                    padding:
                    const EdgeInsets.symmetric(vertical: 10, horizontal: 23),
                    child: Wrap(
                      spacing: 10.0,
                      runSpacing: 10.0,
                      children: [
                        for (var item in list)
                          InkWell(
                            child: pokemonItem(item, context),
                            onTap: () {
                              Navigator.push(
                                  context,
                                  MaterialPageRoute(
                                      builder: (BuildContext context) =>
                                          DetailScreen(number: item.number)));
                              },)
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        )
    );
  }
}

다음은 UI 부분입니다.

뎁스가 좀 깊긴 한데 이 프로젝트는 규모가 작고 다른 화면에서 공통으로 쓸게 없기 때문에 Drawer와 List Item만 따로 구현하고 나머지는 위와 같이 구현하였습니다.

Compose를 먼저 해서 그런지 Widget 안에서 child 구현할 때 자유롭게 문법을 섞어서 구현하기에는 불편함이 느껴졌습니다.

예로 if나 for를 사용할 때 그 범위 안에서 특정 작업을 진행 후 UI를 그리고 싶을 때 중괄호를 쓰면 컴파일 에러가 발생해서 처음에는 당황스러웠습니다.

이 경우에 방법이 있는지는 추가로 공부를 하면서 알아나가야겠습니다.

/// 포켓몬 리스트 아이템
Widget pokemonItem(PokemonListItem item, BuildContext context) {
  return Card(
    shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.all(Radius.circular(7)),
        side: BorderSide(color: Color(0xFF299AE6))),
    color: const Color(0xFFE3F4FF),
    child: SizedBox(
      width: 80,
      height: 80,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Image.network(
            item.dotImage,
            width: 56,
            height: 56,
          ),
          Text(
            '# ${item.number}',
            style: Theme.of(context).textTheme.headline2
                ?.copyWith(fontSize: 12),
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
          )
        ],
      ),
    ),
  );
}
Widget listDrawer(
    {required BuildContext context,
      required List<TypeCondition> typeList,
      required List<int> generationList,
      required Function(int, bool) typeChangeListener,
      required Function(int) generationChangeListener,
      required VoidCallback resetListener}) {
  return Drawer(
    child: ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Container(
          padding: const EdgeInsets.only(bottom: 20),
          child: Row(
            children: [
              Text(
                "조건",
                style: setTextStyle(
                    color: Theme.of(context).primaryColor,
                    fontWeight: FontWeight.bold,
                    fontSize: 36
                ),
              ),
              const Spacer(),
              ElevatedButton(
                  onPressed: () {
                    resetListener();
                  },
                  style: ButtonStyle(
                      backgroundColor: MaterialStateColor.resolveWith(
                              (states) => Theme.of(context).primaryColor)),
                  child: Text(
                    '초기화',
                    style: setTextStyle(
                        color: Colors.white,
                    ),
                  ))
            ],
          ),
        ),
        /// 타입 선택
        Wrap(
          spacing: 5,
          runSpacing: 5,
          children: [
            for (var index = 0; index <= typeList.length - 1; index++)
              GestureDetector(
                onTap: () {
                  typeChangeListener(index, !typeList[index].isSelect);
                },
                child: Stack(
                  alignment: AlignmentDirectional.center,
                  children: [
                    Image.asset(
                      typeList[index].image,
                      width: 55,
                      height: 55,
                    ),
                    if (!typeList[index].isSelect)
                      Container(
                        decoration: const BoxDecoration(
                            color: Color(0x80000000), shape: BoxShape.circle),
                        width: 52,
                        height: 52,
                      )
                  ],
                ),
              )
          ],
        ),
        const SizedBox(height: 40,),
        /// 세대 선택
        Row(
          children: [
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 1,
                  onPressed: () {
                    generationChangeListener(1);
                  },
                  isSelect: generationList.contains(1)),
            ),
            const SizedBox(
              width: 10,
            ),
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 2,
                  onPressed: () {
                    generationChangeListener(2);
                  },
                  isSelect: generationList.contains(2)),
            ),
          ],
        ),
        Row(
          children: [
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 3,
                  onPressed: () {
                    generationChangeListener(3);
                  },
                  isSelect: generationList.contains(3)),
            ),
            const SizedBox(
              width: 10,
            ),
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 4,
                  onPressed: () {
                    generationChangeListener(4);
                  },
                  isSelect: generationList.contains(4)),
            ),
          ],
        ),
        Row(
          children: [
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 5,
                  onPressed: () {
                    generationChangeListener(5);
                  },
                  isSelect: generationList.contains(5)),
            ),
            const SizedBox(
              width: 10,
            ),
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 6,
                  onPressed: () {
                    generationChangeListener(6);
                  },
                  isSelect: generationList.contains(6)),
            ),
          ],
        ),
        Row(
          children: [
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 7,
                  onPressed: () {
                    generationChangeListener(7);
                  },
                  isSelect: generationList.contains(7)),
            ),
            const SizedBox(
              width: 10,
            ),
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 8,
                  onPressed: () {
                    generationChangeListener(8);
                  },
                  isSelect: generationList.contains(8)),
            ),
          ],
        ),
        Row(
          children: [
            Expanded(
              flex: 1,
              child: generationButton(
                  context: context,
                  generation: 9,
                  onPressed: () {
                    generationChangeListener(9);
                  },
                  isSelect: generationList.contains(9)),
            ),
          ],
        ),
      ],
    ),
  );
}

/// 세대 선택 버튼
Widget generationButton(
    {required BuildContext context,
      required int generation, 
      required VoidCallback onPressed, 
      required isSelect}) {
  return OutlinedButton(
      onPressed: onPressed,
      style: OutlinedButton.styleFrom(
        primary: Colors.black,
          backgroundColor:
              isSelect ? const Color(0xFFFFF6A8) : const Color(0x80000000),
          side: BorderSide(color: Theme.of(context).primaryColor, width: 1)),
      child: Text(
        "$generation 세대",
        style: setTextStyle(
            fontSize: 16,
            fontWeight: FontWeight.bold),
      ));

따로 분리해서 구현한 List item과 Drawer입니다.

다른 곳 찾아보면 class로 구현한 경우도 찾아볼 수 있던데

역시 Compose를 먼저 학습 후 작업을 해서 그런지 함수 리턴형식으로 구현을 해보았습니다.

extends를 통해 특정 기능을 필요하여 불러오는 게 아닌 단순 UI라면 함수로 하는 게 편하지 않나 싶어서 이렇게 구현을 해보았는데 통상적으로 어떤 식으로 진행하고 차이점이 어떤 것들이 있는지에 관해서는 추가로 학습을 진행할 예정입니다.

deatil.dart


Screenshot_20230204_210044.pngGroup 18.png

포켓몬의 상세 정보를 보여주는 화면입니다.

class _DetailScreenState extends State<DetailScreen>
    with TickerProviderStateMixin {

    late TabController controller;

    @override
    void initState() {
      super.initState();
      controller = TabController(length: 4, vsync: this);
      fetchPokemonDetailInfo();
    }

  ...

  Widget detailFooter(TabController controller) {
    return Container(
      padding: const EdgeInsets.only(top: 290),
      width: double.infinity,
      child: Card(
        margin: EdgeInsets.zero,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
              topLeft: Radius.circular(40), topRight: Radius.circular(40)),
        ),
        color: Colors.white,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const SizedBox(
              height: 40,
            ),
            Container(
              padding: const EdgeInsets.only(left: 13),
              child: TabBar(
                tabs: const [
                  Text('설명'),
                  Text('스테이터스'),
                  Text('상성'),
                  Text('진화'),
                ],
                controller: controller,
                indicatorColor: Theme.of(context).primaryColor,
                unselectedLabelColor: Colors.black,
                labelColor: Theme.of(context).primaryColor,
                labelStyle: Theme.of(context).textTheme.headline3,
                labelPadding:
                    const EdgeInsets.only(bottom: 5.5, left: 9, right: 9),
                isScrollable: true,
              ),
            ),
            const Divider(
              height: 1,
              color: Color(0xFFC8C8C8),
            ),
            Expanded(
              child: TabBarView(
                controller: controller,
                children: [
                  pokemon?.info != null
                      ? descriptionContainer(context, pokemon!.info)
                      : const SizedBox(),
                  pokemon?.info != null
                      ? statusContainer(pokemon!.info)
                      : const SizedBox(
                          child: Text('sizedBox'),
                        ),
                  pokemon?.info != null
                      ? compatibilityContainer(context, pokemon!.info.attribute)
                      : const SizedBox(),
                  pokemon?.evolution != null
                      ? evolutionContainer(context, pokemon!.evolution, isShiny)
                      : const SizedBox(),
                ],
              ),
            )
          ],
        ),
      ),
    );
}

이번 화면에서 포켓몬 설명을 위해 텝과 뷰페이저를 사용합니다.

텝을 이용하기 위해서는 TabController가 필요합니다.

우선 클래스 설정에서 extends 뒤에 with 키워드를 입력 후 TickerProviderStateMixin를 추가해 줍니다.

late로 TabController로 생성 후 initState()에서 TabController(length: 4, vsync: this)를 넣어줍니다.

length는 원하는 텝의 크기만큼 입력해 주세요.

TabBar(
  tabs: const [
    Text('설명'),
    Text('스테이터스'),
    Text('상성'),
    Text('진화'),
  ],
  controller: controller,
  indicatorColor: Theme.of(context).primaryColor,
  unselectedLabelColor: Colors.black,
  labelColor: Theme.of(context).primaryColor,
  labelStyle: Theme.of(context).textTheme.headline3,
  labelPadding:
      const EdgeInsets.only(bottom: 5.5, left: 9, right: 9),
  isScrollable: true,
),

tabs에 List 형식으로 텝에 들어가는 내용을 넣어주면 됩니다.

controller는 위에서 만든 것을 넣어주면 됩니다.

indicatorColor ~ labelPadding은 파라미터 명만 봐도 알 것이라 생각됩니다.

isScrollable을 true로 하면 각 텝 영역이 tabs에서 넣은 widget 크기만큼 설정이 되고 화면보다 tabs의 영역이 크면 스크롤이 가능해집니다.

false로 설정하면 화면의 크기가 tabs의 개수만큼 n분의 1로 분할하여 텝의 크기를 지정합니다.

TabBarView(
  controller: controller,
  children: [
    pokemon?.info != null
        ? descriptionContainer(context, pokemon!.info)
        : const SizedBox(),
    pokemon?.info != null
        ? statusContainer(pokemon!.info)
        : const SizedBox(
            child: Text('sizedBox'),
          ),
    pokemon?.info != null
        ? compatibilityContainer(context, pokemon!.info.attribute)
        : const SizedBox(),
    pokemon?.evolution != null
        ? evolutionContainer(context, pokemon!.evolution, isShiny)
        : const SizedBox(),
  ],
),

TabBarView에도 동일하게 controller를 연결해 준 뒤

children에서 텝의 순서에 맞게 Widget을 넣어주면 TabBar와 연동이 됩니다.

void _pageMove(String number) {
  Navigator.pushReplacement(
      context,
      MaterialPageRoute(
          builder: (BuildContext context) => DetailScreen(number: number)));
}

상단에 아이콘으로 좌우에 이전, 다음 포켓몬이 있는데 클릭 시 해당 페이지로 이동하게 됩니다.
이동 시에 현재 떠있는 화면은 지우고 새로운 화면을 띄우고 싶어서 Navigator.pushReplacement를 사용하였습니다.

상세화면에서는 이 외에는 디자인 요소라서 다른 코드는 생략합니다.

해결해야 하는 것들


지금 api에서 포켓몬 리스트를 불러오는 방법이 한 번에 모든 아이템을 다 전달을 해줍니다.
그로 인해서 최대 1000개의 정보가 한 번에 넘어오게 되는데 이걸 패이징해서 전달하는 방법을 조사한 뒤 적용 시켜봐야겠습니다.
Compose로 작업했을 때에는 크게 무리가 안 갔는데 Flutter에서는 버벅거리는 느낌이 있었습니다.

  1. 예외처리 작업 추가. 결과가 없을 경우, 로딩 시 ProgressBar나 PlaceHolder 등을 추가시킬 예정입니다.
  2. gif 라이브러리 조사? gif 이미지가 정상적으로 작동되긴 하는데 이전 프레임이 겹쳐서 보이는 문제가 있어서 이걸 해결할 수 있는지에 대해서 조사를 해볼 예정입니다.
728x90

'Flutter' 카테고리의 다른 글

Flutter SQLite 사용하기  (0) 2022.10.03
댓글