본문 바로가기
Flutter 맛만보기

Flutter 퀴즈앱 만들기 - 입문 (for MAC) (2) 구현하기

by onejunu 2022. 3. 13.

원활한 진행을 위해서 pubspec.yaml을 파일을 동일하게 맞춰주겠습니다.

아래의 코드를 모두 복사하여 pubspec.yaml 파일에 복사하여주세요.

 

pubspec.yaml

name: flutter_complete_guide
description: A new Flutter project.

# The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1

environment:
  sdk: ">=2.11.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware.

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

그 다음 lib/main.dart 파일속 내용을 아래와 같이 바꿔봅시다.

 

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(
          appBar: AppBar(
            title: Text('Welcome to Flutter'),
          ),
          body: Center(
            child: Text('Hello World'),
          ),
        ));
  }
}

 

그런 다음에 시뮬레이터를 켜고 "실행 -> 디버그없이 실행" 을 클릭해주면 다음과 같이 나옵니다.

이제 한줄 한줄 간단하게 설명 해보겠습니다. 아래 패키지 선언 부분은 저희가 사용한 MaterialApp 을 불러오기 위해 사용하였고 그외에도 다양한 기능을 제공합니다.

import 'package:flutter/material.dart';

 

다음 StatelessWidget 이 있는데 이는 간단히 말해 상태정보를 업데이트하지 않는 정적 위젯이라고 보면됩니다. StatelessWidget 클래스를 상속받아서 build 메서드를 오버라이드 하면 위젯이 하나 만들어집니다. appbar와 body 부분은 직관적으로 나타난 화면을 보면 이해하리라고 생각합니다.

 

question.dart 

먼저 lib 폴더에 question.dart 파일을 하나 만듭니다. 질문하나에 해당하는 위젯입니다.

import 'package:flutter/material.dart';

class Question extends StatelessWidget {
  final String questionText;

  Question(this.questionText);

  @override
  Widget build(BuildContext context) {
    return Container(
        width: double.infinity,
        margin: EdgeInsets.all(10),
        child: Text(questionText,
            style: TextStyle(fontSize: 23), textAlign: TextAlign.center));
  }
}

1. Question 생성자는 하나의 인자를 받아서 questionText에 저장합니다.

2. 따로 상태정보를 가질 필요가 없기 때문에 StatelessWidget으로 상속받아서 구현합니다.

3. Container 는 내부 요소를 한번 묶어주는 html 의 div 같은 존재입니다.

4. width : double.infinity 는 100% 와 같은 의미로 Container의 크기를 가로를 full로 채우겠다는 의미입니다.

5. margin: 은 css의 margin 과 의미가 같습니다. all(10) 은 상하좌우 10px 씩 마진을 줍니다.

6. child : Text() 는 컨테이너 구성요소에 Text 객체 하나가 있고 그 Text는 questionText를 보여줍니다.

 

Answer.dart

lib 폴더에 answer.dart 파일을 하나 만듭니다. answer는 퀴즈에서 정답보기 위젯입니다. 예를들어, 질문이 "가장 좋아하는 색은 무엇입니까?" 라고 할때 보기가 "1번 : 빨간색, 2번 : 검정색, .. " 있으면 보기에 해당하는 것이 Answer.dart 입니다.

import 'package:flutter/material.dart';

class Answer extends StatelessWidget {

  final Function selectHandler;
  final String answerText;

  Answer(this.selectHandler,this.answerText);

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      child : RaisedButton(
          color: Colors.blue,
          textColor: Colors.white,
          child: Text(answerText),
          onPressed: selectHandler,
              ),
    );
  }
}

 

1. 런타임시 한번 초기화 되면 변경될 일이 없기 때문에 selectHandler(함수)를 final로 선언합니다.

2. 생성자는 정답보기에 표시될 텍스트와 정답을 선택했을 경우 실행할 함수를 인자로 받아둡니다. 왜냐하면 정답보기는 버튼인데 버튼은 onPressed 라는 메서드를 가지고 있고 해당 메서드는 버튼을 눌렀을 시 작동하는 메서드로서 구현을 따로 해야합니다.

 

Quiz.dart

lib 폴더 안에 quiz.dart를 생성합니다. answer.dart 와 question.dart 을 합쳐서 새로운 quiz라는 위젯으로 구성할 겁니다.

import 'package:flutter/material.dart';
import 'answer.dart';
import 'question.dart';

class Quiz extends StatelessWidget {
  final List<Map<String,Object>> questions; // 문제 목록들 저장
  final int questionIndex; // 현재 문제 번호
  final Function answerQuestion; // 정답을 선택했을 때 실행할 함수

  Quiz({@required this.questions,@required this.answerQuestion,@required this.questionIndex});

  @override
  Widget build(BuildContext context) {
    return Column(
            children: <Widget>[
              Question(questions[questionIndex]['questionText'] as String),
              ...(questions[questionIndex]['answers' ] as List<Map<String,Object>>).map((answer){
                  return Answer(() => answerQuestion(answer['score']),answer['text'] as String);
              }).toList()
            ],
        );
  }
}

1. questions 는 문제들의 맵들의 리스트입니다. 예를 들면 아래와 같습니다.

[
    {
      'questionText': '가장 좋아하는 색은 무엇인가요?',
      'answers': [{'text':'빨강','score':10}, {'text':'검정','score':7}, {'text':'노랑','score':5}, {'text':'하양','score':3}]
    },
    {
      'questionText': '가장 좋아하는 동물은 무엇인가요?',
      'answers': [{'text':'돼지','score':10},{'text':'말','score':4},{'text':'고양이','score':15}]
    },
    {
      'questionText': '가장 좋아하는 사람은 누구인가요?',
      'answers': [{'text':'엄마','score':13},{'text':'아빠','score':15}]
    },
  ];

2. questionIndex 는 현재 풀고 있는 문제의 번호를 저장해둡니다.

3. answerQuestion 은 정답보기를 선택했을 때 실행할 함수 입니다.

4. Quiz의 생성자안에 {} 마크가 있습니다. 이는 생성자에 인자를 넘길때 이름을 명시하기 위함입니다. 예를 들어, 아래처럼 이름에 해당하는 곳에 인자를 넘길 수 있습니다. 생성자에 파라미터만 들어가면 어떤 인자를 넘기는지 헷갈리지만 아래처럼 이름을 명시하면 헷갈리지 않습니다. 

Quiz(
      answerQuestion: _answerQuestion,
      questionIndex: _questionIndex,
      questions: _questions
)

5. Column 은 세로 열을 지칭합니다. Column 의 child 는 세로로 배열이 됩니다.

6. children : <Widget>[ .. ] 은 자식들의 타입이 Widget이라는 것입니다. 자바의 제네릭처럼 타입을 명시하는 것과 같다고 보시면 됩니다.

7.

Question(questions[questionIndex]['questionText'] as String)

Question 생성자를 호출하고 questions의 현재문제 번호에 해당하는 문제를 String 으로 보냅니다. 예를들어 questionIndex 인경우 1번 문제의 목록을 참고 하였을 때 questions[questionIndex]['questionText'] 의 값은 '가장 좋아하는 색은 무엇인가요?' 입니다.

 

8.

...(questions[questionIndex]['answers' ] as List<Map<String,Object>>).map((answer){
                  return Answer(() => answerQuestion(answer['score']),answer['text'] as String);
              }).toList()

위와 같이 복잡한데 하나씩 뜯어보겠습니다.

questions[questionIndex]['answers' ] as List<Map<String,Object>>

questionIndex 가 0번이라면 questions[questionIndex]['answers' ] 의 값은 아래처럼 나옵니다.

'answers': [{'text':'빨강','score':10}, {'text':'검정','score':7}, {'text':'노랑','score':5}, {'text':'하양','score':3}]

 

즉, List<Map<String,Object>> 입니다. 이런 리스트를 돌면서 각각의 answer 에 대해서 Answer() 생성자를 호출하여 각각의 리스트를 Answer 위젯으로 매핑을 합니다(map 함수).

위의 내용을 잠깐 언급하면 Answer의 생성자는 2개의 인자를 받습니다.

Answer(this.selectHandler,this.answerText);

this.selectHandler 에는 아래의 람다 함수를 파라미터로 넘깁니다. 아래 함수는 정답보기를 클릭했을 경우 answerQuestion 메서드를 실행하는 역할입니다. main.dart에 answerQuestion 의 구현부가 나옵니다. 지금은 인자로만 받았기때문에 어떤 함수인지는 알 수 없습니다.

() => answerQuestion(answer['score'])

두번째 파라미터로 answer['text'] 를 보냅니다.

answer['text'] as String

이렇게 한개의 함수 파라미터와 한개의 String 파라미터를 가지고 Answer 위젯을 만들면서 toList() 를 통해 Answer 위젯의 리스트를 반환합니다.

 

9. ... 의 의미는 toList() 만 반환하면 말그대로 리스트만 될 뿐이기 때문에 일종의 리스트를 풀어헤치는(?) 의미입니다.  예를들어, [Quiz , [a,b,c,d,..]] 에서 [a,b,c,d,..] 리스트 앞에 ... 을 붙이면 [Quiz,a,b,c,d,...] 이런 느낌입니다.

 

Result.dart

lib 폴더 안에 result.dart 파일을 만듭니다. result.dart는 퀴즈를 다푼 경우 결과화면 입니다.

import 'package:flutter/material.dart';

class Result extends StatelessWidget {
  final int resultScore;

  String get resultPhrase {
    var resultText = 'You did it !';
    if (resultScore <= 25) {
      resultText = '잘했어요!';
    } else {
      resultText = '정말 잘했어요!';
    }
    return resultText;
  }

  Result(this.resultScore);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(
        resultPhrase,
        style: TextStyle(
          fontSize: 36,
          fontWeight: FontWeight.bold,
        ),
        textAlign: TextAlign.center,
      ),
    );
  }
}

1. resultPhrase 는 일종의 getter 입니다.

나머지는 question , answer 에서 했던것과 똑같으므로 넘어가겠습니다.

 

Main.dart

main.dart 파일을 아래처럼 수정해줍시다.

import 'package:flutter/material.dart';
import 'quiz.dart';
import 'result.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new _MyAppState();
  }
}

class _MyAppState extends State<MyApp> {
  final _questions = const [
    {
      'questionText': '가장 좋아하는 색은 무엇인가요?',
      'answers': [{'text':'빨강','score':10}, {'text':'검정','score':7}, {'text':'노랑','score':5}, {'text':'하양','score':3}]
    },
    {
      'questionText': '가장 좋아하는 동물은 무엇인가요?',
      'answers': [{'text':'돼지','score':10},{'text':'말','score':4},{'text':'고양이','score':15}]
    },
    {
      'questionText': '가장 좋아하는 사람은 누구인가요?',
      'answers': [{'text':'엄마','score':13},{'text':'아빠','score':15}]
    },
  ];

  var _questionIndex = 0;
  var _totalScore = 0;

  void _answerQuestion(int score) {

    _totalScore += score;

    setState(() {
      _questionIndex = _questionIndex + 1;
    });
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return MaterialApp(
      home: Scaffold(
          appBar: AppBar(
            title: Text('퀴즈 어플'),
          ),
          body: _questionIndex < _questions.length
              ? Quiz(
                  answerQuestion: _answerQuestion,
                  questionIndex: _questionIndex,
                  questions: _questions)
              : Result(_totalScore)
              ),
    );
  }
}

1.  아래는 quiz 와 result 다트파일을 사용하기 위해 임포트 합니다.

import 'package:flutter/material.dart';
import 'quiz.dart';
import 'result.dart';

2. runApp 을 통해서 MyApp을 생성해야 실행이 됩니다.

void main() => runApp(MyApp());

3. 이때까지 StatelessWidget만 만들어 왔는데 main의 앱은 StatefulWidget 입니다. 그이유는 사용자의 클릭에 따라 문제번호도 증가해야하면 점수까지 계산해서 각각 다른 화면을 동적으로 만들어 줘야하기 때문입니다. StatelessWidget은 그러한 기능을 제공하지 않기 때문에 상태정보를 간직할 수 있는 StatefulWidget으로 상속받아 줍니다. 그러면 createState() 라는 함수를 구현해야합니다. 반환 값은 State<StatefulWidget> 이므로 이에 해당하는 클래스를 만들어 주면 됩니다.

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new _MyAppState();
  }
}

4. 클래스명 앞에 _ 의 의미는 java로 치면 private 클래스에 해당합니다. 

State<MyApp>을 상속받고 있습니다. 위에 createState()의 반환값은 State<StatefulWidget> 인데 StatefulWidget을 구현한게 MyApp 이므로 _MyAppState() extends State<MyApp>를 반환 할 수 있습니다. 이부분이 이해가 안된다면 자바의 다형성에 대해 알아보면 됩니다. 개념이 비슷합니다.

class _MyAppState extends State<MyApp> {

 

5. 각종 변수들을 선언 합니다. _ 는 private한 변수를 의미합니다. final과 const가 있는 데 둘의 공통점은 일단 변경이 불가능하다는 것입니다. 차이점은 final은 초기화 시점이 런타임이고 const는 컴파일 타임입니다. 따라서 질문과 답변 목록들은 const로 선언하고 이를 런타임시 final로 _questions에 할당해줍니다.

final _questions = const [
    {
      'questionText': '가장 좋아하는 색은 무엇인가요?',
      'answers': [{'text':'빨강','score':10}, {'text':'검정','score':7}, {'text':'노랑','score':5}, {'text':'하양','score':3}]
    },
    {
      'questionText': '가장 좋아하는 동물은 무엇인가요?',
      'answers': [{'text':'돼지','score':10},{'text':'말','score':4},{'text':'고양이','score':15}]
    },
    {
      'questionText': '가장 좋아하는 사람은 누구인가요?',
      'answers': [{'text':'엄마','score':13},{'text':'아빠','score':15}]
    },
  ];

  var _questionIndex = 0;
  var _totalScore = 0;

6.  정답보기에 해당하는 점수를 받아서 전체 점수에 더해주고 다음 문제로 넘어가는 로직입니다. setState를 통해서 문제번호를 1증가 시키는 이유는 그냥 문제번호를 1증가 시키면 변수만 증가할 뿐 문제 번호에 따른 문제를 리로드 하지 못합니다. 그래서 상태가 변했다는 것을 알려주고 다시 리로드 시키는 작업이 필요하기 때문에 questionIndex는 setstate를 통해서 변경합니다. 반면 score 를 저장하는 totalScore는 점수가 증가한다고 하여 영향을 받는 위젯들이 아직 없기 때문에 변수만 증가 시킵니다.

void _answerQuestion(int score) {

    _totalScore += score;

    setState(() {
      _questionIndex = _questionIndex + 1;
    });
  }

7. 만약 문제가 남았다면 Quiz를 보여주고 아니면 Result 결과화면에 전체점수를 넘깁니다.

 body: _questionIndex < _questions.length
              ? Quiz(
                  answerQuestion: _answerQuestion,
                  questionIndex: _questionIndex,
                  questions: _questions)
              : Result(_totalScore)
              ),

 

 

🚀 실행

 

 

https://github.com/hangeulisbest/flutter_quiz_app_ex

 

GitHub - hangeulisbest/flutter_quiz_app_ex: flutter tutorial

flutter tutorial. Contribute to hangeulisbest/flutter_quiz_app_ex development by creating an account on GitHub.

github.com

 

댓글