ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter_15. Building Responsive and adaptive User Interfaces
    > Frontend/Flutter 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

     

     

    댓글

Designed by Tistory.