-
Flutter_15. Building Responsive and adaptive User Interfaces> Frontend/Flutter 2023. 5. 15. 21:25
0) 학습 목표
- 화면 잠금
- MediaQuery를 사용하여, responsive 앱 생성
- 화면 회전에 따른 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
'> Frontend > Flutter' 카테고리의 다른 글
Flutter_17. GridView & Inkwell & FadeImage & Stack (0) 2023.05.17 Flutter_16. Flutter Internals, Way of Render with Tree, Key, Mutating Values (0) 2023.05.16 Flutter_14. Theme in Widget & Dark Mode & Named Constructor & For in (0) 2023.05.12 Flutter_14. Dropdown and Validation (0) 2023.05.09 Flutter_13. Dropdown and Validation (0) 2023.05.08