오늘까지 기본적인 사용법을 익히고 이후 Flutter에서 많이 사용되는 아키텍처 패턴 및 주요 라이브러리, 상태관리 흐름에 대해서 익히고 프로젝트를 생성해서 진행하려고 한다.
강제, 제약이라는 뜻을 가진 Constraint는 Widget의 위치, 사이즈에 대한 제약을 4개의 제약(Max Height, Min Height, Max Width, Min Width)을 가지고 있다.
Single Pass: Flutter에서 레이아웃을 계산하는 과정은 단 한 번의 pass로 이루어진다. 이 패스를 통해 모든 위젯의 크기와 위치가 결정된다. 각각의 위젯은 이 과정에서 한 번만 레이아웃 계산을 하게 된다. 이를 통해 위젯 트리 전체의 레이아웃이 효율적으로 결정된다.
Constraints Go Down: 부모 위젯은 자식 위젯에게 레이아웃 제약 조건(Constraints)을 전달한다. 이 제약 조건은 자식 위젯이 가질 수 있는 최소 및 최대 크기를 의미한다. 자식 위젯은 부모로부터 받은 이 제약 조건을 바탕으로 자신의 크기를 결정한다.
Sizes Go Up: 자식 위젯은 주어진 제약 조건에 따라 자신의 크기를 계산하고, 그 크기를 부모 위젯에게 다시 전달한다. 이 과정에서 자식 위젯이 결정한 크기가 부모 위젯에게 올라간다. 부모는 자식 위젯들의 크기를 바탕으로 자신의 크기나 레이아웃을 조정한다.
Parent Sets Position: 자식 위젯이 자신의 크기를 결정한 후, 부모 위젯이 자식 위젯의 최종 위치를 설정한다. 자식 위젯은 자신이 어느 위치에 배치될지 결정할 수 없으며, 부모 위젯이 이를 결정하여 전체 레이아웃을 완성한다.
이를 통해 알 수 있는 점은
3번의 경우 아래와 같이 코드를 작성하게 되면 자식 위젯의 크기를 아무리 조절해도 부모 위젯의 크기만큼 자식위젯이 다 먹어버린다.
return Scaffold(
body: Center(
child: Container(
height: 300,
width: 300,
color: Colors.blue,
child: Container(
height: 50,
width: 50,
color: Colors.yellow,
),
),
),
);
따라서 자식 위젯의 크기를 지정해 주고 싶다면 부모 위젯에서 자식 위젯의 위치를 확실하게 지정해주어야 한다. Center같은 위젯 말고도 Row, Column, Align, Positioned, Padding 등 다양한 위젯을 사용하여 지정할 수 있다.
return Scaffold(
body: Center(
child: Container(
height: 300,
width: 300,
color: Colors.blue,
child: Center( // 위치 지정 위젯 추가
child: Container(
height: 100,
width: 100,
color: Colors.yellow,
),
),
),
),
);
🍕 pass? Flutter는 위젯 트리에서 각 위젯의 크기와 위치를 결정하기 위해 Layout Pass를 진행하는데 두 가지로 나뉜다.
1. Measure Pass(측정 단계): 부모 위젯이 자식 위젯에게 제약조건(Constraint)을 내려보내고 자식 위젯은 그 제약조건을 기반으로 자신의 크기를 결정한다. 이 결정된 크기는 다시 부모 위젯에게 전달된다.
2. Layout Pass(위치 설정 단계): 부모 위젯에 전달된 크기 정보를 바탕으로 부모 위젯이 자식위젯의 위치를 설정한다. - 크기가 달라지면 위치또한 달라져야하니 계산후에 재설정 하는 것.
BuildContext는 Flutter에서 위젯 트리 내에서의 위치를 나타내는 객체다. 각 위젯은 BuildContext를 통해 다른 위젯들과 상호작용하고, 트리 구조 내에서의 위치에 따라 특정 작업을 수행할 수 있다.
BuildContext의 역할:
1. 위젯 트리 내 위치 정보 제공: BuildContext는 위젯이 트리 내에서 위치하는 곳을 나타낸다. 이를 통해 위젯은 부모, 형제, 자식 위젯과의 관계를 인식할 수 있다.
2. 위젯 트리 탐색: BuildContext는 ancestorWidgetOfExactType, findAncestorStateOfType, findRootAncestorStateOfType 같은 메서드를 통해 위젯 트리를 탐색할 수 있는 방법을 제공한다. 이를 통해 특정 부모 위젯이나 상태(State)를 쉽게 찾을 수 있다.
3. 상위 위젯의 데이터 접근: BuildContext는 InheritedWidget과 같은 상위 위젯의 데이터를 자식 위젯에 전달하는 데 사용된다. 이를 통해 위젯은 부모 위젯에서 공유된 데이터를 받아올 수 있다.
4. 테마 및 로컬 데이터 접근: BuildContext는 Theme.of(context), MediaQuery.of(context)와 같은 메서드를 통해 현재 테마나 로컬 데이터에 접근하는 데 사용된다.
- BuildContext 사용 예:
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).primaryColor, // BuildContext를 통해 테마에 접근
child: Text('Hello, World!'),
);
}
}
이 예제에서 Theme.of(context)는 BuildContext를 통해 현재 테마의 primaryColor를 가져온다. 이처럼 BuildContext는 위젯이 트리 내에서 필요한 정보를 얻고, 상위 레벨의 위젯과 상호작용하는 데 필수적인 요소다.
👉 요약 BuildContext는 위젯이 트리 내에서 자신의 위치를 인식하고, 필요한 정보를 상위 위젯으로부터 가져오거나 트리를 탐색하는 데 사용되는 객체다. 모든 위젯은 createElement() 메서드를 통해 Element를 생성하며, 이 Element가 BuildContext를 구현하여 이러한 기능을 수행한다. Element는 위젯의 위치, 렌더링 정보 등을 관리한다.
🍕 위젯 트리는 Flutter가 애플리케이션 실행 시 자동으로 생성하며, 각 위젯이 Element를 통해 이 트리에 자동으로 추가된다.
MaterialStateProperty는 Flutter에서 위젯의 다양한 상태에 따라 속성을 동적으로 정의할 수 있도록 도와주는 클래스다. 이를 사용하면 위젯의 상태(예: 눌림, 선택됨, 비활성화됨 등)에 따라 속성을 다르게 설정할 수 있다. 주로 버튼의 스타일링에서 많이 사용된다.
MaterialStateProperty는 위젯이 어떤 상태인지에 따라 반환될 값을 정의할 수 있도록 한다. 예를 들어, 버튼이 활성화되었을 때는 특정 색상을 사용하고, 비활성화되었을 때는 다른 색상을 사용할 수 있다.
사용 예
ElevatedButton(
onPressed: () {},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.pressed)) {
return Colors.green; // 버튼이 눌렸을 때 색상
} else if (states.contains(MaterialState.disabled)) {
return Colors.grey; // 버튼이 비활성화되었을 때 색상
}
return Colors.blue; // 기본 색상
},
),
),
child: Text('Elevated Button'),
);
- MaterialStateProperty의 주요 특징
1. resolveWith: MaterialStateProperty의 가장 흔한 사용 방법이다. 이 메서드는 상태 집합(Set<MaterialState>
)을 입력받아 특정 상태에서 반환할 값을 결정한다.
상태는 MaterialState enum으로 정의되며, pressed, hovered, focused, disabled 등 다양한 상태를 나타낸다.
2. MaterialState: MaterialState는 위젯이 가질 수 있는 다양한 상태를 나타내는 enum이다. 여러 상태가 동시에 적용될 수 있으며, 이 상태에 따라 다른 스타일을 적용할 수 있다.
3. all: 모든 상태에서 동일한 설정을 하고싶을 때 사용한다.
- 주요 상태:
pressed: 사용자가 위젯을 누르고 있는 상태.
hovered: 사용자가 마우스로 위젯 위에 커서를 올린 상태.
focused: 위젯이 포커스를 받은 상태.
disabled: 위젯이 비활성화된 상태.
selected: 선택된 상태 (주로 체크박스, 라디오 버튼 등에서 사용).
scrollUnder: 위젯이 스크롤될 때 다른 컴포넌트 밑으로 지나가는 상태.
dragged: 사용자가 위젯을 드래그하고 있는 상태.
error: 에러 상태일 때, 주로 폼 필드나 입력 필드에서 오류가 발생했을 때 사용.
👉 요약
MaterialStateProperty는 Flutter에서 위젯의 상태에 따라 스타일 속성을 동적으로 설정할 수 있는 강력한 도구다. 주로 버튼 스타일링에 사용되며, resolveWith 메서드를 통해 각 상태에 맞는 값을 지정할 수 있다. 이를 통해 위젯의 다양한 상태에서 일관된 스타일링을 유지할 수 있다.
Flutter의 Navigator는 애플리케이션 내에서 화면 간의 전환과 탐색을 관리하는 역할을 한다. Navigator는 화면 스택(stack)을 관리하며, 이 스택에 화면을 추가하거나 제거하면서 화면 간 전환을 수행한다.
Navigator의 두 가지 주요 접근 방식:
Imperative Routing (명령형 라우팅): 명령형 라우팅은 개발자가 명령을 통해 화면 전환을 직접 제어하는 방식이다. 이 접근 방식에서는 명시적으로 Navigator의 메서드를 호출하여 화면을 푸시하거나 팝(pop)한다.
특징 -
화면 전환이 코드에서 명시적으로 호출된다.
상태 관리는 개발자가 수동으로 관리한다.
복잡한 네비게이션 로직을 쉽게 구현할 수 있다.
// 새로운 화면을 스택에 푸시하는 예
Navigator.push(
context,
MaterialPageRoute(builder: (context) => NewScreen()),
);
// 현재 화면을 팝(popping)하여 이전 화면으로 돌아가는 예
Navigator.pop(context);
Declarative Routing (선언형 라우팅): Flutter의 선언적 UI 패턴을 따르며, UI 상태에 따라 화면 전환을 정의하는 방식이다. 이 접근 방식에서는 애플리케이션의 상태에 따라 자동으로 화면 전환이 이루어진다. Navigator 2.0에서 도입된 이 방식은 더 복잡한 네비게이션 구조를 간결하게 관리할 수 있도록 설계되었다.
특징:
UI의 상태에 따라 화면 전환이 선언적으로 정의된다.
상태 변화에 따라 네비게이션이 자동으로 업데이트된다.
대규모 애플리케이션이나 복잡한 네비게이션 구조에서 유용하다.
예시:
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: MyHomePage(),
// onGenerateRoute 형식 - 동적 라우팅
onGenerateRoute: (RouteSettings settings) {
// 상태에 따라 화면을 결정
if (settings.name == '/second') {
return MaterialPageRoute(builder: (context) => SecondPage());
}
return MaterialPageRoute(builder: (context) => MyHomePage());
},
// routes 형식 - 정적 라우팅
initialRoute: '/',
routes: {
'/': (BuildContext context) => HomeScreen(),
'/routeOne': (BuildContext context) => RouteOneScreen(), // 경로 이름 명확히 정의
},
);
}
}
// Routes 사용 시
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pushNamed('/routeOne', arguments: 123); // 정의된 경로로 이동
},
child: const Text('Go to Route One Screen'),
),
),
);
}
}
👉 요약
두 방식은 서로 배타적인 것이 아니라, 프로젝트의 필요에 따라 혼용할 수 있다. 일반적으로 간단한 애플리케이션에서는 명령형 라우팅이 더 직관적일 수 있고, 복잡한 상태 관리와 화면 전환이 필요한 대규모 애플리케이션에서는 선언형 라우팅이 더 적합할 수 있다.
- 추가내용:
pushReplacement: 제일 위(top) navigation stack을 지우고 pushReplacement를 한 화면을 스택(top)에 넣는다. 선언형 라우팅을 사용한다면 pushReplacementNmaed를 사용하면 된다. 이름만 다를 뿐 용법은 둘다 push와 pushNamed와 같다.
maybePop: 현재 화면이 스택에서 제거될 수 있는지 여부를 확인 후 가능한 경우에만 화면을 삭제하고 불가능하면 아무 동작을 하지 않는다. bool type으로 성공여부를 반환한다. 따라서 특정 조건 만족시에만 POP이 가능하도록 설정할 수 있다.
canPop: Pop가능 여부를 반환하는 bool type이다.
🍕 pushNmaedAndRemoveUntil: 스택에서 특정 경로를 제외한 나머지 스택을 삭제한다. true로 설정하면 기본적인 pop이 되고 false로 설정하면 모든 스택을 삭제할 수 있다.
Navigator.of(context).pushNamedAndRemoveUntil(
'/targetRoute', // 이동할 경로
ModalRoute.withName('/keepRoute'), // 이 조건에 부합하지 않는 경로들을 스택에서 제거
// 혹은 route를 받아서 지정가능
(route) {
return route.settings.name == 'home'
}
);
위치를 자유 자재로 설정할 수 있는 위젯으로 x, y (실수) 위치를 조절해서 적용할 수도 있고 기본적으로 제공하는 center, bottom등의 프로퍼티를 통해서 설정할 수도 있다.
child: Align(
alignment: Alignment.bottomRight, // Alignment(x, y)로 설정가능
child: Container(
height: 100,
width: 100,
color: Colors.yellow,
),
),
Flutter에서 사용되는 버튼 종류에 대해서 알아보고자 한다. 사실 버튼 스타일링은 전부 똑같이 가능해서 특색을 살리지 않는다면 어떤 버튼을 사용해도 무방하다. 버튼 클릭이나 포커스 등에서 효과를 줄 수도 있는데 MaterialStateProperty로 가능하기 때문이다.
1. ElevatedButton: 사용자가 눌렀을 때 약간 올라오는 느낌을 주는, 배경색이 있는 버튼이다. 주로 사용자가 인터페이스에서 주요 작업을 수행할 때 사용된다. 배경색이 기본적으로 적용되고 그림자(shadow)가 있어 버튼이 떠 있는 느낌을 준다.
다양한 스타일링 옵션을 제공한다.
ElevatedButton(
onPressed: () {
// 버튼이 눌렸을 때의 동작
},
child: Text('Elevated Button'),
);
2. OutlinedButton: 테두리만 있고 내부는 투명한 버튼이다. 주로 보조적인 액션을 표현할 때 사용된다. 버튼의 테두리에만 선이 있고, 배경색이 없고, 눌렀을 때 배경색이 추가되는 효과가 있다.
중요한 액션보다는 덜 중요한 액션을 나타낼 때 적합하다.
OutlinedButton(
onPressed: () {
// 버튼이 눌렸을 때의 동작
},
child: Text('Outlined Button'),
);
3. TextButton: 텍스트만 표시되는 버튼으로, 화면에 최소한의 영향만 주는 버튼이다. 주로 간단한 액션이나 링크에 사용된다. 배경색이나 테두리가 없는 텍스트 형태의 버튼이며 눌렀을 때 텍스트 색상이 바뀌는 효과가 있다.
사용자가 여러 개의 옵션 중 하나를 선택하거나, 링크를 클릭하는 동작을 나타낼 때 적합하다.
TextButton(
onPressed: () {
// 버튼이 눌렸을 때의 동작
},
child: Text('Text Button'),
);
- 추가내용
🍔 버튼 비활성화는 onPressed에 null을 넣어주면된다. 비활성화 버튼 색상을 따로 지정하고 싶다면 disabledBackGroundColor나 disabledForegroundColor를 넣어주면된다.
🍳 버튼의 기본 외형이 일반적으로 둥근 형태로 되어 있는데 이 형태를 바꾸고 싶다면 style을 적용하고 styleForm에서 shape를 변경하면된다.
🍕 Button에 Icon을 넣어주고 싶다면 Button.icon으로 생성해주면 기존의 버튼에 icon과 label paremeter가 붙은 생성자로 설정할 수 있다.
PopScope는 페이지가 팝될 때 호출되는 콜백을 정의하여, 페이지 팝을 방지하거나 특정 작업을 수행할 수 있도록 한다. 이는 기존의 WillPopScope와 유사한 역할을 하지만, 더 다양한 상황에 맞게 사용할 수 있다.
PopScope는 canPop과 onPopInvoked 두 가지 주요 콜백을 사용하여 동작을 제어한다.
canPop: 이 콜백은 페이지가 팝될 수 있는지를 결정한다. 이 콜백에서 true를 반환하면 페이지가 팝될 수 있고, false를 반환하면 팝이 방지된다.
onPopInvoked: 페이지가 팝될 때 실행되는 콜백으로 이 콜백에서 특정 작업을 수행할 수 있다.
PopScope(
canPop: () async {
// 여기서 팝 가능 여부를 결정
return true; // true를 반환하면 팝이 허용됨
},
onPopInvoked: () {
// 팝이 수행될 때 실행되는 로직
print("Page was popped!");
},
child: Scaffold(
appBar: AppBar(
title: Text('PopScope Example'),
),
body: Center(
child: Text('Try to pop this page'),
),
),
);
🤚 pop 가능여부를 false로 하게 되면 Navigator를 설정한 경우에는 Navigator.of(context).pop()은 잘 수행이 되지만 이외의 자동으로 생성된 뒤로가기 버튼이나 화면 스와이프로 뒤로가기, 디바이스에서 기본적으로 제공하는 뒤로가기 등의 action은 무시된다.
FutureBuilder는 Flutter에서 비동기 작업의 상태를 기반으로 UI를 동적으로 구성하는 데 사용되는 위젯이다. 이 위젯은 Future 객체와 연계되어, 해당 작업이 완료될 때까지의 상태를 관리하며, 결과에 따라 UI를 자동으로 업데이트한다.
주요 구성 요소 -
주요 속성:
connectionState: Future의 현재 상태를 나타냄 (none, waiting, active, done).
data: 비동기 작업이 성공적으로 완료되었을 때 반환되는 결과.
error: 작업 중 발생한 오류.
hasData: 데이터의 존재 여부.
hasError: 오류 발생 여부.
import 'dart:math';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<int>(
future: getNumber(),
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
// 상태 출력
print('-------State---------');
print('Connection State: ${snapshot.connectionState}');
print('Data: ${snapshot.data}');
print('Error: ${snapshot.error}');
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else if (snapshot.hasData) {
return Center(child: Text('Number: ${snapshot.data}'));
} else {
return const Center(child: Text('데이터가 없습니다.'));
}
},
),
);
}
Future<int> getNumber() async {
await Future.delayed(const Duration(seconds: 3));
return Random().nextInt(100);
}
}
import 'dart:math';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<int>(
future: getNumber(),
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
// 상태 출력
print('Connection State: ${snapshot.connectionState}');
if (snapshot.connectionState == ConnectionState.waiting) {
// wating 상태일때
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// error 상태일때
return Center(child: Text('Error: ${snapshot.error}'));
} else if (snapshot.hasData) {
// data를 받아왔을때
return Center(child: Text('Number: ${snapshot.data}'));
} else {
// data가 없을때
return const Center(child: Text('데이터가 없습니다.'));
}
},
),
);
}
Future<int> getNumber() async {
await Future.delayed(const Duration(seconds: 3));
return Random().nextInt(100);
}
}
Future<int>
를 생성한다.FutureBuilder<int>
는 이 Future의 상태를 기반으로 UI를 동적으로 구성한다.AsyncSnapshot<int>
의 상태에 따라 로딩 중일 때, 오류 발생 시, 데이터가 성공적으로 로드되었을 때 각각 다른 UI를 반환한다.👉 요약
FutureBuilder는 비동기 작업의 상태에 따라 UI를 자동으로 업데이트하는 데 유용한 도구다. 비동기 작업을 관리하고, 작업 완료 후 결과를 반영할 때 이를 활용하면 복잡한 상태 관리 로직을 간단하게 처리할 수 있다.
🍕 FutureBuilder는 앱이 꺼지지 않는 이상 Future를 실행하고서 데이터를 받았던 기록이 있다면 이전의 값을 캐싱해서 가지고 있다.
Future Builder와 사용법은 같지만 Future가 Stream으로 바뀐것 뿐이다.
stream: StreamBuilder에 전달되는 Stream 객체다. Stream은 여러 개의 비동기 이벤트나 데이터의 흐름을 나타낸다. Stream이 새로운 데이터를 발행할 때마다 StreamBuilder는 이를 수신하고 UI를 다시 빌드한다.
import 'dart:async';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final StreamController<int> _streamController = StreamController<int>();
void initState() {
super.initState();
_startStream();
}
void _startStream() {
// 1초마다 새로운 숫자를 스트림에 추가
Timer.periodic(const Duration(seconds: 1), (timer) {
_streamController.sink.add(timer.tick);
});
}
void dispose() {
_streamController.close(); // StreamController를 해제
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
body: StreamBuilder<int>(
stream: _streamController.stream,
builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
} else if (snapshot.hasData) {
return Center(child: Text('Stream Value: ${snapshot.data}'));
} else {
return const Center(child: Text('데이터가 없습니다.'));
}
},
),
);
}
}