[Flutter] : TDD - 12. User Interface


드디어 마지막 단계다. 잘 진행해보자.

1. 페이지 생성


number_trivia_page를 만들어보자

…/presentation/pages/number_trivia_page.dart

import 'package:flutter/material.dart';

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
    );
  }
}

자 stateless widget으로 생성하였다. 내용들을 하나씩 채워보자.

기본 생성되면 있던 main.dart 내용물을 다 지우고 아래 처럼 채워보자.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Number Trivia',
      theme: ThemeData(
        primaryColor: Colors.green.shade800,
        accentColor: Colors.green.shade600,
      ),
      home: NumberTriviaPage(),
    );
  }
}

2. Presentation Logic Holder (Bloc)


Clean Architecture에 따르면 UI와 통신 하는 것은 오직 Presentation Logic Holder 뿐이다.

이 부분이 Mobx나 Provider로 수정되어도 문제 없다.

이제 우리는 UI에 bloc을 전달해주기 위해 BlocProvider를 사용할 것이다.

우리는 NumberTriviaBloc을 Get_it을 통해 ServiceLocator에 생성해 놓았다. 이제 불러와서 연결해보자.

number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: BlocProvider(
        create: (_) => sl<NumberTriviaBloc>(),
        child: Container(),
      ),
    );
  }
}

데이터 연결은 끝이 났다.

이제 화면을 그려보자.

3. 화면 그리기


이미지

위 화면을 만들어야 한다.

  • 상단에는 결과물인 숫자와 텍스트가 나올 것이고, 에러나 “Start Searching!”같은 문구가 나올 것이다. 그리고 검색 중에는 CircularLoadingIndicator를 보여주자.
  • 하단에는 숫자를 입력받는 텍스트 필드와 버튼들이 있다.

코드를 작성해보자.

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: buildBody(context),
    );
  }

  BlocProvider<NumberTriviaBloc> buildBody(BuildContext context) {
    return BlocProvider(
      create: (_) => sl<NumberTriviaBloc>(),
      child: Container(
        child: Padding(
          padding: const EdgeInsets.all(10),
          child: Column(
            children: [
              SizedBox(
                height: 10,
              ),
              // Top half
              Container(
                height: MediaQuery.of(context).size.height / 3,
                // message Text Widget / CircularLoadingIndicator
                child: Placeholder(),
              ),
              SizedBox(
                height: 20,
              ),
              // Bottom half
              Column(
                children: [
                  //TextField
                  Placeholder(
                    fallbackHeight: 40,
                  ),
                  SizedBox(
                    height: 10,
                  ),
                  Row(
                    children: [
                      Expanded(
                        child: Placeholder(
                          fallbackHeight: 30,
                        ),
                      ),
                      SizedBox(
                        width: 10,
                      ),
                      Expanded(
                        child: Placeholder(
                          fallbackHeight: 30,
                        ),
                      ),
                    ],
                  )
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

이미지

PlaceHolder로 화면을 잡아 봤다.

4. 상단 데이터 연결


이제 bloc 에서 데이터를 불러와 보여줘 보자.

먼저 상단 데이터 부분을 봐보자

Top half


// Top half
              BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
                builder: (context, state) {
                  if (state is Empty) {
                    return Container(
                      height: MediaQuery.of(context).size.height / 3,
                      child: Center(
                        child: Text('Start searching!'),
                      ),
                    );
                  }
                },
              ),

현재 State가 비어있음, 즉 초기 상태이면 Start searching 메시지를 띄우게 했다.

이 메시지 띄우는 부분은 글씨를 키워야 하고 또 앞으로 숫자가 뜰 공간이니 Widget으로 따로 만들어주자.

// Top half
              BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
                builder: (context, state) {
                  if (state is Empty) {
                    return MessageDisplay(
                      message: "Start searching!",
                    );
                  }
                },
              ),

....

class MessageDisplay extends StatelessWidget {
  final String message;

  const MessageDisplay({Key key, this.message})
      : assert(message != null),
        super(key: key);
  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: SingleChildScrollView(
          child: Text(
            message,
            style: TextStyle(fontSize: 25),
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

에러 메시지도 띄우도록 분기 처리를 해주자.

BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
                builder: (context, state) {
                  if (state is Empty) {
                    return MessageDisplay(
                      message: "Start searching!",
                    );
                  } else if (state is Error) {
                    return MessageDisplay(
                      message: state.message,
                    );
                  }
                },
              ),

Loading


이제 로딩 하는 부분을 처리해주자. API를 Call 할 때 Loading이 필요하다.

state에 따라 분기 처리 해주자.

BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
                builder: (context, state) {
                  if (state is Empty) {
                    return MessageDisplay(
                      message: "Start searching!",
                    );
                  } else if (state is Error) {
                    return MessageDisplay(
                      message: state.message,
                    );
                  } else if (state is Loading) {
                    return LoadingWidget();
                  }
                },
              ),

....

class LoadingWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

Loaded


이제 드디어 숫자 데이터를 표시 해주는 부분이다.

Trivia Display를 처리해주자.

BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
                builder: (context, state) {
                  if (state is Empty) {
                    return MessageDisplay(
                      message: "Start searching!",
                    );
                  } else if (state is Loading) {
                    return LoadingWidget();
                  } else if (state is Loaded) {
                    return TriviaDisplay(
                      numberTrivia: state.trivia,
                    );
                  } else if (state is Error) {
                    return MessageDisplay(
                      message: state.message,
                    );
                  }
                },
              ),

...

class TriviaDisplay extends StatelessWidget {
  final NumberTrivia numberTrivia;

  const TriviaDisplay({Key key, this.numberTrivia})
      : assert(numberTrivia != null),
        super(key: key);
  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Column(
        children: [
          Text(
            numberTrivia.number.toString(),
            style: TextStyle(fontSize: 50, fontWeight: FontWeight.bold),
          ),
          Expanded(
            child: Center(
              child: SingleChildScrollView(
                child: Text(
                  numberTrivia.text,
                  style: TextStyle(fontSize: 25),
                  textAlign: TextAlign.center,
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

자 이제 데이터를 화면에 처리하는 부분이 끝이 났다.

  • 초기값
  • 로딩
  • 에러
  • 데이터 로드

이제 Widget별로 파일을 만들어 넣어주자.

이미지

widgets.dart

export 'loading_widget.dart';
export 'message_display.dart';
export 'trivia_display.dart';

이렇게 설정 해주고

page파일에서 widget.dart 만 import 해주자.

5. 하단 데이터 연결


이제 아랫 부분 버튼과 데이터를 연결 해주자.

여기서는 bloc 에서 dispatching event가 발생한다.

control 할 수 있도록 stateful widget을 만들어보자.

입력 받는 텍스트 필드가 있기 때문에 state가 필요하다.

tirivia_controls.dart

class TriviaControls extends StatefulWidget {
  @override
  _TriviaControlsState createState() => _TriviaControlsState();
}

class _TriviaControlsState extends State<TriviaControls> {
  final controller = TextEditingController();
  String inputStr;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: controller,
          keyboardType: TextInputType.number,
          decoration: InputDecoration(
              border: OutlineInputBorder(), hintText: 'Input a number'),
          onChanged: (value) {
            inputStr = value;
          },
          onSubmitted: (_) {
            dispatchConcrete();
          },
        ),
        SizedBox(
          height: 10,
        ),
        Row(
          children: [
            Expanded(
              child: RaisedButton(
                child: Text('Search'),
                color: Theme.of(context).accentColor,
                textTheme: ButtonTextTheme.primary,
                onPressed: dispatchConcrete,
              ),
            ),
            SizedBox(
              width: 10,
            ),
            Expanded(
              child: RaisedButton(
                child: Text('Get random trivia'),
                onPressed: dispatchRandom,
              ),
            )
          ],
        )
      ],
    );
  }

  void dispatchConcrete() {
    //텍스트 필드 지우기, 다음 입력을 위해
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context)
        .add(GetTriviaForConcreteNumber(inputStr));
  }

  void dispatchRandom() {
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context).add(GetTriviaForRandomNumber());
  }
}

이 파일도 widgets.dart에 export 해주자.

이미지

이미지

아주 정상적으로 잘 작동한다!!

6. 마지막 수정

시뮬레이터에서 아래 키보드가 올라오면 화면이 깨지는 경우가 있다.

이걸 수정하도록 하자.

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: SingleChildScrollView(child: buildBody(context)),
    );
  }
...
}

SingleChildScrollView로 Scroll로 처리하게 하였다.

마무리


드디어 모든 강의가 끝이 났다. 총 14개의 강의를 정리하였는데

bloc 패턴에 대해서 조금 알게 되었다. (별로 쓰고 싶지 않다….)

이제 provider를 mvvm패턴으로 사용하면서 TDD처리하도록 한번 코드를 수정해보자!!