> Frontend/Flutter
Flutter_15. Building Responsive and adaptive User Interfaces
Janku
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