> Frontend/Flutter

Flutter_15. Building Responsive and adaptive User Interfaces

Janku 2023. 5. 15. 21:25

 

 

0) 학습 목표

  1. 화면 잠금
  2. MediaQuery를 사용하여, responsive 앱 생성
  3. 화면 회전에 따른 showModalBottomSheet 수정

 

 

 

 

 

 

 

1) 화면 잠금 (적용 X,확인용)

 

import 'package:flutter/services.dart';

 

  • main.dart 화면에 import.
void main() {
  // make sure that locking orientation and run the app.
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp, // locked orientation
  ]).then((fn) {
    runApp(

 

  • main.dart 의 main 함수에, 아래와 같이 넣어주면, 화면이 회전되어도 화면이 고정/잠금된다.
.. 생략
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([
  DeviceOrientation.portraitUp, // locked orientation
 ]).then((fn) {
 // 안에 runApp(..생략)을 넣어준다.

 

 

2) MediaQuery를 사용하여, responsive 앱 생성

 

  • expenses.dart 내의, build 안, 
@override
Widget build(BuildContext context) {
  // if rotate, build will re-executed.
  // print(MediaQuery.of(context).size);
  final Size screenSize = MediaQuery.of(context).size;
  final double width = screenSize.width;
  final double height = screenSize.height;

 

 

  • 아래와 같이 선언하면, 화면이 회전될때 마다  build 가 다시 되는 것을 알 수 있다.
  • 이를 활용해서, 회전을 원하는 곳에,  아래와 같이 선언 
body: width < height
    ? Column(
        children: [
          // Tool Bar
          Chart(expenses: _registeredExpenses),
          Expanded(child: mainContent)
          // column inside column will cause error => use expanded to avoid issue
        ],
      )
    : Row(children: [
      // since row takes as much space as infinity, and container in chart takes as many as infinite thus error => use Expanded, and wrap
        Expanded(child: Chart(expenses: _registeredExpenses)),
        Expanded(child: mainContent)
      ]),

 

  • 기본적으로, 핸드폰 화면은 직사각형이기 때문에, 화면이 회전되면 기존 화면의 width 가 새로운 화면의 height 가 되고, 기존의 height가 새로운 화면은 width 가 된다. 
  • 기존 화면에서 회전되면, Column 대신 Row를 사용해서, 화면을 가로로 보여준다. 
  • 이때, Row 는 가로로 최대 (무한)의 공간을 갖는데, Chart 역시 컴포넌트 내부에서 무한의  가로를 갖으므로 에러 발생.
  • 이를 Expanded로 감싸서, 에러를 방지한다. 

 

3) 회면 회전에 따른 showModalBottomSheet 수정 (newexpense.dart 수정)

 

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

import '../../models/expense.dart';

final formatter = DateFormat.yMd();

class NewExpense extends StatefulWidget {
  const NewExpense({super.key, required this.onAddExpense});

  final void Function(Expense expense) onAddExpense;

  @override
  State<NewExpense> createState() => _NewExpenseState();
}

class _NewExpenseState extends State<NewExpense> {
  // => currently no need
  var _enteredTitle = '';

  void _saveTitleInput(String inputValue) {
    // since there is no change in UI, no need to use setState. => currently no need
    _enteredTitle = inputValue;
  }

  // need to tell Flutter to delete TextEditingController when this modal is closed => otherwise, way of wasting memory.
  final _titleController = TextEditingController();
  final _amountController = TextEditingController();
  DateTime? _selectedDate;
  Category _selectedCategory = Category.leisure;

  Future<void> _presentDatePicker() async {
    final now = DateTime.now();
    final firstDate = DateTime(now.year - 1, now.month, now.day);
    // showDatePicker => Flutter's Date picker Method
    final pickedDate = await showDatePicker(
        context: context,
        initialDate: now,
        firstDate: firstDate,
        lastDate: now);
    // => showDatePicker return value of type 'future'
    // => need to async await or then method

    setState(() {
      _selectedDate = pickedDate;
    });
  }

  void _submitExpenseData() {
    // able => double & else => null;
    final enteredAmount = double.tryParse(_amountController.text);
    final amountIsInvalid = enteredAmount == null || enteredAmount <= 0;
    if (_titleController.text.trim().isEmpty ||
        amountIsInvalid ||
        _selectedDate == null) {
      showDialog(
        context: context,
        builder: (ctx) {
          return AlertDialog(
            title: const Text('Invalid Input'),
            content: const Text(
                'Please make sure a valid title, amount, date and category was entered.'),
            actions: [
              TextButton(
                  onPressed: () {
                    Navigator.pop(ctx); //close modal
                  },
                  child: const Text('Okay'))
            ],
          );
        },
      );
      return; //no code executed
    }
    widget.onAddExpense(Expense(
        title: _titleController.text,
        amount: enteredAmount,
        date: _selectedDate!,
        category: _selectedCategory));
    Navigator.pop(context);
  }

  @override
  void dispose() {
    // dispose is a part of StatefulWidget lifecycle. called when widget is destroyed.
    _titleController.dispose();
    _amountController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // extra info abut UI elements that might be overlapping
    final keyboardSpace = MediaQuery.of(context).viewInsets.bottom;
    return LayoutBuilder(builder: (ctx, constraints) {
      final width = constraints.maxWidth;
      final height = constraints.maxHeight;

      return SizedBox(
        height: double.infinity,
        child: SingleChildScrollView(
          child: Padding(
            padding: EdgeInsets.fromLTRB(16, 48, 16, keyboardSpace + 16),
            child: Column(
              children: [
                if (width >= 600)
                  Row(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Expanded(
                        child: TextField(
                          controller: _titleController,
                          maxLength: 50,
                          keyboardType: TextInputType.text,
                          decoration: const InputDecoration(
                              label:
                                  Text('Title')), // title of input (Text Field)
                        ),
                      ),
                      const SizedBox(
                        width: 24,
                      ),
                      Expanded(
                        child: TextField(
                          // TextField wants to take as much space horizontally as possible, and Row do not restrict the amount of space  which will cause error => Expanded
                          controller: _amountController,
                          keyboardType: TextInputType.number,
                          decoration: const InputDecoration(
                              // 앞에 붙는 $ 표시
                              prefixText: '\$ ',
                              // title of input (Text Field)
                              label: Text('Amount')),
                        ),
                      ),
                    ],
                  )
                else
                  TextField(
                    // onChanged: _saveTitleInput, // first way of storing text value => onChanged
                    controller: _titleController,
                    // second way of storing text value => controller
                    maxLength: 50,
                    keyboardType: TextInputType.text,
                    // input tag type in JS
                    decoration: const InputDecoration(
                        label: Text('Title')), // title of input (Text Field)
                  ),
                if (width >= 600)
                  Row(
                    children: [
                      DropdownButton(
                        //initial data:
                        value: _selectedCategory,
                        // DropdownMenuItem: need to set the child parameter to another widget, which simply defines what will be shown on the screen.
                        items: Category.values
                            .map((category) => DropdownMenuItem(
                                  // save internally: value of selected category
                                  value: category,
                                  child: Text(
                                    category.name.toUpperCase(),
                                  ),
                                ))
                            .toList(),
                        onChanged: (value) {
                          // the value here is value from map of DropDownMenuItem
                          if (value == null) return;
                          setState(() {
                            _selectedCategory = value;
                          });
                        },
                      ),
                      const SizedBox(width: 16),
                      Expanded(
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.end,
                          // row controls the horizontal alignment to push content to the end
                          crossAxisAlignment: CrossAxisAlignment.center,
                          // center the content vertically.
                          children: [
                            Text(
                              _selectedDate == null
                                  ? 'No Dated Date'
                                  : formatter.format(
                                      _selectedDate!), // ! assume this wont be null
                            ),
                            IconButton(
                                onPressed: _presentDatePicker,
                                icon: const Icon(Icons.calendar_month))
                          ],
                        ),
                      )
                    ],
                  )
                else
                  Row(
                    children: [
                      Expanded(
                        child: TextField(
                          // TextField wants to take as much space horizontally as possible, and Row do not restrict the amount of space  which will cause error => Expanded
                          controller: _amountController,
                          keyboardType: TextInputType.number,
                          decoration: const InputDecoration(
                              // 앞에 붙는 $ 표시
                              prefixText: '\$ ',
                              // title of input (Text Field)
                              label: Text('Amount')),
                        ),
                      ),
                      const SizedBox(width: 16),
                      Expanded(
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.end,
                          // row controls the horizontal alignment to push content to the end
                          crossAxisAlignment: CrossAxisAlignment.center,
                          // center the content vertically.
                          children: [
                            Text(
                              _selectedDate == null
                                  ? 'No Dated Date'
                                  : formatter.format(
                                      _selectedDate!), // ! assume this wont be null
                            ),
                            IconButton(
                                onPressed: _presentDatePicker,
                                icon: const Icon(Icons.calendar_month))
                          ],
                        ),
                      )
                    ],
                  ),
                const SizedBox(height: 16),
                if (width >= 600)
                  Row(
                    children: [
                      const Spacer(),
                      TextButton(
                          onPressed: () {
                            // Navigator class's pop makes modal close
                            Navigator.pop(context);
                          },
                          child: const Text('Cancel')),
                      ElevatedButton(
                          onPressed: _submitExpenseData,
                          child: const Text('Save Expense')),
                    ],
                  )
                else
                  Row(
                    children: [
                      DropdownButton(
                        //initial data:
                        value: _selectedCategory,
                        // DropdownMenuItem: need to set the child parameter to another widget, which simply defines what will be shown on the screen.
                        items: Category.values
                            .map((category) => DropdownMenuItem(
                                  // save internally: value of selected category
                                  value: category,
                                  child: Text(
                                    category.name.toUpperCase(),
                                  ),
                                ))
                            .toList(),
                        onChanged: (value) {
                          // the value here is value from map of DropDownMenuItem
                          if (value == null) return;
                          setState(() {
                            _selectedCategory = value;
                          });
                        },
                      ),
                      const Spacer(),
                      TextButton(
                          onPressed: () {
                            // Navigator class's pop makes modal close
                            Navigator.pop(context);
                          },
                          child: const Text('Cancel')),
                      ElevatedButton(
                          onPressed: _submitExpenseData,
                          child: const Text('Save Expense')),
                    ],
                  )
              ],
            ),
          ),
        ),
      );
    });
  }
}

 

 

  • new_expense.dart 에서 LayoutBuilder를 사용해서, buils a widget tree that can depend on the parent widget
  • LayoutBuilder는 context, constraints 를 params 값으로 받는데, 이 중 constraints를 사용해 변경을 감지하자

 

Widget build(BuildContext context) {
  // extra info abut UI elements that might be overlapping
  final keyboardSpace = MediaQuery.of(context).viewInsets.bottom;
  return LayoutBuilder(builder: (ctx, constraints) {
    final width = constraints.maxWidth;

 

<showModalBottomSheet 스크롤되게 설정>

return SizedBox(
  height: double.infinity,
  child: SingleChildScrollView(

 

  • SingleChildScrollView를 사용해서, 스크롤 되게 설정
  • App의 방향이 바뀔 때마다, build 함수가 새로 실행됨으로, MediaQuery의 viewInsets를 사용해서, 넘치는 width 사용
padding: EdgeInsets.fromLTRB(16, 48, 16, keyboardSpace + 16),

 

<화면 회전 시, showModalBottomSheet layout 변경>

  • 해당 부위만큼, padding값을 추가한다.
  • 각 row 마다, width 가 600보다 크면, 가로로 회전된거라고 가정한다.
if (width >= 600)
  Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Expanded(
        child: TextField(
          controller: _titleController,
          maxLength: 50,
          keyboardType: TextInputType.text,
          decoration: const InputDecoration(
              label:
                  Text('Title')), // title of input (Text Field)
        ),
      ),
      const SizedBox(
        width: 24,
      ),
      Expanded(
        child: TextField(
          // TextField wants to take as much space horizontally as possible, and Row do not restrict the amount of space  which will cause error => Expanded
          controller: _amountController,
          keyboardType: TextInputType.number,
          decoration: const InputDecoration(
              // 앞에 붙는 $ 표시
              prefixText: '\$ ',
              // title of input (Text Field)
              label: Text('Amount')),
        ),
      ),
    ],
  )
else
  TextField(
    // onChanged: _saveTitleInput, // first way of storing text value => onChanged
    controller: _titleController,
    // second way of storing text value => controller
    maxLength: 50,
    keyboardType: TextInputType.text,
    // input tag type in JS
    decoration: const InputDecoration(
        label: Text('Title')), // title of input (Text Field)
  ),

 

 

  • 세로모드인 경우, 하나의 row에 title이 한개 들어간대비해, 가로모드인 경우, 하나의 Row 에 Title 과 Amount  사용.
  • TextField의 제약(constrained) 가 없이 무한정으로 늘어나기 떄문이다. (Row 의 자식이기 때문.) => expanded 사용
  • 나머지도 이런식으로 구성 (생략)
  • 제약에 대해서는 아래 문서 참조
  • https://velog.io/@knh4300/Flutter-Layout-%EC%A0%9C%EC%95%BD%EC%A1%B0%EA%B1%B4
 

Layout : 제약조건

layout 제약조건과 회피기동

velog.io