[Flutter] 스나이퍼팩토리 3주차 주간평가 : 퀴즈 앱 작성

KWANWOO·2023년 2월 11일
1
post-thumbnail

스나이퍼팩토리 플러터 3주차 주간평가

퀴즈 앱 작성

1. 퀴즈 앱 기반 요구사항

  • 주어진 데이터를 활용하여 퀴즈앱을 만듭니다.

  • 앱 배경색은 다음의 색상들을 Gradient로 표시합니다.

    • Colors.pinkAccent
    • Colors.blue
  • 퀴즈 위젯은 퀴즈 데이터 수 만큼 생성되며, 데이터에 퀴즈 데이터를 추가할 때 추가된 퀴즈도 위젯으로 되어 보여집니다.

  • 각 퀴즈의 보기들 중 하나를 클릭하였을 경우, 상단에 스코어가 위젯형태로 표시되며, 다음 페이지로 넘어갑니다.

    • 정답시 Icon(Icons.circle_outlined) 가 추가됩니다.
    • 오답시 Icon(Icons.close) 가 추가됩니다.
  • 모든 문제를 풀었을 경우, 하단에 FAB가 표시되며 이 때 표시되는 위젯은 Icon(Icons.refresh)가 됩니다.

    • 해당 버튼을 클릭하면, 모든 스코어가 초기화되며 첫 퀴즈로 이동됩니다.
  • 제공되는 일부 코드가 있습니다.

    • 코드를 분석하고 어떠한 코드인지, 어떠한 부분이 생략되었는지 고민하여 과제 해결에 사용할 수 있습니다.
class QuizCard extends StatelessWidget {
  const QuizCard({super.key, required this.quiz, required this.onCorrect, required this.onIncorrect});
  final Map<String, dynamic> quiz;
  final Function onCorrect;
  final Function onIncorrect;
    
  
  Widget build(BuildContext context) {
    ...
  • 그 외 UI 디자인은 자유입니다.

    • 폰트, 이미지의 사용, 글씨 크기 및 자간 등, 퀴즈 색상 자유

2. 데이터

List<Map<String, dynamic>> quizs = [
  {
    "question": "의학적으로 얼굴과 머리를 구분하는 기준은 어디일까요?",
    "answer": 2,
    "options": ["코", "눈썹", "귀", "머리카락"]
  },
  {
    "question": "다음 중 바다가 아닌 곳은?",
    "answer": 3,
    "options": ["카리브해", "오호츠크해", "사해", "지중해"]
  },
  {
    "question": "심청이의 아버지 심봉사의 이름은?",
    "answer": 2,
    "options": ["심전도", "심학규", "심한길", "심은하"]
  },
  {
    "question": "심청전에서 심청이가 빠진 곳은 어디일까요?",
    "answer": 4,
    "options": ["정단수", "육각수", "해모수", "인당수"]
  },
  {
    "question": "택시 번호판의 바탕색은?",
    "answer": 3,
    "options": ["녹색", "흰색", "노란색", "파란색"]
  }
];

3. 결과물 예시

4. 코드 작성

  • main.dart
import 'package:first_app/page/home_page.dart';
import 'package:flutter/material.dart';

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

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

  // root Widget
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomePage(), // 홈 페이지 호출
    );
  }
}

main.dart에서는 MaterialApp으로 HomePage()를 호출한다.

  • home_page.dart
// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables

import 'package:first_app/widget/QuizCard.dart';
import 'package:flutter/material.dart';

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

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  //퀴즈 데이터
  List<Map<String, dynamic>> quizs = [
    {
      "question": "의학적으로 얼굴과 머리를 구분하는 기준은 어디일까요?",
      "answer": 2,
      "options": ["코", "눈썹", "귀", "머리카락"]
    },
    {
      "question": "다음 중 바다가 아닌 곳은?",
      "answer": 3,
      "options": ["카리브해", "오호츠크해", "사해", "지중해"]
    },
    {
      "question": "심청이의 아버지 심봉사의 이름은?",
      "answer": 2,
      "options": ["심전도", "심학규", "심한길", "심은하"]
    },
    {
      "question": "심청전에서 심청이가 빠진 곳은 어디일까요?",
      "answer": 4,
      "options": ["정단수", "육각수", "해모수", "인당수"]
    },
    {
      "question": "택시 번호판의 바탕색은?",
      "answer": 3,
      "options": ["녹색", "흰색", "노란색", "파란색"]
    }
  ];

  var score = []; // 정답 확인
  var pageController = PageController(viewportFraction: 0.8); //페이지 뷰 컨트롤러

//페이지뷰에서 다음 페이지로 이동
  goNextPage(controller) {
    controller.nextPage(duration: Duration(seconds: 1), curve: Curves.easeIn);
  }

//페이지뷰에서 이전 페이지로 이동
  goPreviousPage(controller) {
    controller.previousPage(
        duration: Duration(seconds: 1), curve: Curves.easeIn);
  }

  
  void initState() {
    score = List.filled(quizs.length, ''); //score 리스트 초기화
    super.initState();
  }

  
  void dispose() {
    pageController.dispose(); //컨트롤러 해제
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true, //앱바 영역까지 body 확장
      body: Container(
        padding: EdgeInsets.only(top: 36),
        //배경 그라데이션
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: [Colors.pinkAccent, Colors.blue],
          ),
        ),
        child: Column(
          children: [
            // 상단 페이지 이동 아이콘과 점수 Row
            SizedBox(
              height: 40,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  //이전 페이지 이동 아이콘버튼
                  IconButton(
                    color: Colors.white,
                    onPressed: () => goPreviousPage(pageController),
                    icon: Icon(Icons.navigate_before),
                  ),
                  //점수 현황 리스트뷰
                  ListView.builder(
                    scrollDirection: Axis.horizontal,
                    shrinkWrap: true,
                    itemCount: score.length,
                    itemBuilder: (context, index) {
                      if (score[index] == 'correct') {
                        return Icon(
                          color: Colors.white,
                          Icons.circle_outlined,
                        );
                      } else if (score[index] == 'incorrect') {
                        return Icon(
                          color: Colors.white,
                          Icons.close,
                        );
                      } else {
                        return SizedBox(width: 16);
                      }
                    },
                  ),
                  // 다음 페이지 이동 아이콘버튼
                  IconButton(
                    color: Colors.white,
                    onPressed: () => goNextPage(pageController),
                    icon: Icon(Icons.navigate_next),
                  ),
                ],
              ),
            ),
            //퀴즈 내용 페이지 뷰
            Expanded(
              child: PageView.builder(
                physics: NeverScrollableScrollPhysics(
                  parent: BouncingScrollPhysics(),
                ), // 스와이프 금지와 바운스 효과 적용
                controller: pageController, //컨트롤러 연결
                itemCount: quizs.length,
                itemBuilder: (context, index) {
                  return QuizCard(
                    quiz: quizs[index],
                    //정답을 맞춘 이벤트
                    onCorrect: () {
                      setState(() {
                        if (score[index] == '') {
                          score[index] = 'correct'; //정답 여부 변경
                          goNextPage(pageController); //다음 페이지로 이동
                        }
                      });
                    },
                    //정답을 틀린 이벤트
                    onIncorrect: () {
                      setState(() {
                        if (score[index] == '') {
                          score[index] = 'incorrect'; //정답 여부 변경
                          goNextPage(pageController); //다음 페이지로 이동
                        }
                      });
                    },
                  );
                },
              ),
            ),
          ],
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.miniEndDocked,
      floatingActionButton: !score.contains('') //모든 퀴즈의 답을 작성한 경우에 FAB 보이기
          ? FloatingActionButton(
              backgroundColor: Colors.white,
              foregroundColor: Colors.black,
              onPressed: () {
                setState(() {
                  // 점수 초기화
                  for (int i = 0; i < score.length; i++) {
                    score[i] = '';
                  }
                  pageController.jumpTo(0); //첫 번째 페이지로 이동
                });
              },
              child: Icon(Icons.refresh),
            )
          : null,
    );
  }
}

HomePage는 정답 여부에 따라 아이콘이 추가된다. 따라서 화면이 상호작용에 의해 변경되므로 StatefulWidget으로 생성했다.

홈페이지에서는 우선 주어진 quizs 리스트를 생성하고, 정답 여부를 판별할 score 리스트를 선언했다. 그리고 페이지의 동작을 제어할 pageController를 생성하고, 페이지를 이전과 다음으로 이동시키는 함수를 각각 goNextPage()goPreviousPage()로 작성했다.

score는 한 번 입력한 정답을 다시 입력할 수 없도록 하고, 건너 뛴 문제의 정답 아이콘 부분은 빈 공간을 만들 수 있도록 initState()에서 quizs의 길이만큼 빈 스트링으로 초기화 했다.

본문에서는 Container로 배경 그라데이션을 적용하고 안의 내용들은 Column으로 구성했다. 가장 위의 페이지 제어 아이콘은 IconButton으로 생성했고, 각각 onPressed이벤트에서 미리 작성한 함수를 호출했다.

가운데 점수 현황 아이콘들은 ListView.builder로 만들었는데 score 리스트의 값에 따라 다르게 결과를 출력해 주었다.

아래의 페이지 뷰는 PageView.builder로 만들었으며 우선 스와이프를 금지했고, 바운스 효과를 넣었다. 그리고 컨트롤러를 연결한 뒤 QizeCard를 반환했는데 QuizCard는 따로 작성한 커스텀 위젯이다.

QuizCard에서는 현재 퀴즈 내용을 매개변수로 전달하고 정답이 맞았을 경우와 틀렸을 경우를 각각 다른 함수를 작성했는데 각 상황에 맞게 score의 값을 변경한 뒤 다음 페이지로 이동 시켰다.

마지막으로 FAB은 score 리스트에 정답을 입력하지 않은 것이 하나도 없을 경우에만 출력했으며 눌렸을 때 모든 스코어를 초기화 하고, 첫 번째 페이지로 이동하도록 핸들러를 작성했다.

  • QuizCard.dart
import 'package:flutter/material.dart';

class QuizCard extends StatelessWidget {
  const QuizCard(
      {super.key,
      required this.quiz,
      required this.onCorrect,
      required this.onIncorrect});

  final Map<String, dynamic> quiz; // 현재 페이지의 퀴즈 리스트
  final Function onCorrect; // 정답을 맞춘 경우 이벤트 핸들러
  final Function onIncorrect; // 정답을 틀린 경우 이벤트 핸들러

  
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.symmetric(horizontal: 16, vertical: 48),
      elevation: 16,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16.0),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            //문제
            Text(
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
              quiz['question'].toString(),
            ),
            //보기 버튼들
            ListView.builder(
              padding: EdgeInsets.only(top: 24),
              shrinkWrap: true,
              itemCount: quiz['options'].length,
              itemBuilder: (context, index) {
                return ElevatedButton(
                  onPressed: () {
                    // 정답 확인 후 각 상황에 맞는 함수 호출
                    (quiz['answer'] == index + 1) ? onCorrect() : onIncorrect();
                  },
                  child: Text(
                    quiz['options'][index],
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

QuizCard는 각 퀴즈를 담은 위젯이며 매개변수로 현재 페이지의 퀴즈와 정답을 맞춘 경우의 핸들러, 틀린 경우의 핸들러를 받는다.

전체 위젯은 Card 위젯으로 만들었으며 안의 내용은 Column으로 구성했다. 문제는 Text 위젯으로 출력했다.

아래의 보기 버튼을은 ListView.builder로 그려주었다. 각 보기 버튼은 ElevatedButton을 사용했고 onPressed이벤트에서 정답을 확인하여 맞은 경우 onCorrect를 호출하고 틀린 경우 onIncorrect를 호출하도록 했다.

이와 같은 방식으로 각 상황에 맞는 이벤트를 직접 만들어 함수를 적용해 핸들링 할 수 있다.

5. 결과

6. 추가 내용 정리

controller dispose()

과제를 진행하다 보니 컨트롤러를 선언해서 사용하고 dispose()를 해주는 글들이 많았다. 컨트롤러를 사용하면 꼭 dispose()를 해야 되는 걸까?

결론은 해주는 것이 좋다. 이유는 컨트롤러가 다른 페이지로 이동하거나 앱이 종료되어도 계속 살아있을 수 있기 때문이다.

메모리를 효율적으로 관리할 수 있도록 하는 가비지 컬렉팅기능이 있는데 사용하지 않는 부분에 대해서는 자동으로 정리해 준다. 하지만 TextEditingController의 경우 컨트롤러를 스스로 정리하는데 더 큰 리소스가 들 수 있어서 직접 정리해 준다.

다른 예시로 어떠한 패키지를 사용하여 동영상을 플레이하는 컨트롤러를 붙이게 되었을 때, dispose()를 안하게 되면 계속 백그라운드에서 실행되어 다른 페이지에 가도 재생되는 일이 발생할 수 있다.

(검색해도 컨트롤러를 dispose()해야 하는 이유는 잘 나오지 않아서 테디님이 친절하게 설명해주신 내용이다 ㅎㅎ)

Flutter에서 Dart를 사용하는 이유

어떤 내용을 추가로 정리해볼지 고민하던 중에 컨트롤러와 dispose()를 찾아보다가 가비지 컬렉터를 추가로 찾아보게 되었는데 Flutter에서 Dart를 사용하는 이유라는 글들이 재밌어 보여서 정리해 보고자 한다.

Flutter 팀에서는 초기 언어를 선택할 때 12개 이상의 언어를 평가했다고 한다. 그 중에서 구축 방식이 Dart와 가장 일치하여 이를 선택했다고 한다.

Dart는 2011년 Javascript를 대체하기 위해 발표되었지만 아래와 같은 이유로 많이 사용되지 않았다.

Dart가 많이 사용되지 않았던 이유

  • Javascript를 대체하기 위해서라면 Typescript 등의 다른 언어를 사용할 수 있었다.
  • Dart만으로 이루어진 구현체가 마땅히 없었다.
  • 웹 브라우저에서 바로 Dart가 동작하는 것이 아니었다.
  • 언어적 특성이 세련되지 않았고 러닝 커브가 높았다.

하지만 Flutter가 Dart를 선택하게 된 주요 기능들은 아래와 같다.

Dart의 주요 기능

  • 두 가지 컴파일 지원 (JIT + AOT)
  • 핫 리로드 (빠른 개발 가능)
  • 초당 60 프레임의 훌륭한 애니메이션
  • 선제적 스케줄링, 타임 슬라이싱 및 공유 리소스
  • Lock 없이 객체 할당 가비지 수집 가능
  • 선언적인 방식의 레이아웃

위의 Dart의 기능들을 해당 포스팅에 정리하려 했으나 너무 길어져서 따로 글을 정리하여 링크를 첨부하였다.
Flutter에서 Dart를 사용하는 이유


일단은 과제 완료

이번에 스나이퍼 팩토리를 시작하면서 다짐? 한 것중 하나가 있는데 포스팅을 하면서 과제 내용 이외에 최소 하나의 추가 내용을 작성하자는 것이다. 추가 내용은 과제를 진행하면서 관련된 내용 중에 정리하고 싶은 내용이 생기면 이를 정리했었다. 지금까지는 한번 도 빼놓지 않았는데 이번에는 크게 어떤걸 정리하면 좋을지 생각이 나질 않는다 ㅋㅋㅋㅋ 일단 마무리하고 내일 생각나면 추가해야지! 아 그리고 이번주는 도전과제가 없다 ㅎㅎ

(추가 내용 정리로 컨트롤러와 dispose 내용과 Flutter에서 다트를 사용하는 이유를 추가했습니다!!)

📄 Reference

profile
관우로그

0개의 댓글

관련 채용 정보