✨ Today I Learned 3회차 - Flutter 위젯 심화 🎯

8 분 소요

🎯 Flutter 위젯 심화

🔄 3-1. StatefulWidget

정의: UI 상태 변경이 가능한 위젯 ✨

다만 실제로 UI 업데이트를 진행할 때 UI를 새로 그려달라는 요청을 해야한다. 곧 build 메서드를 다시 한번 호출해 달라는 요청이다.

➡️ 이것을 setState()로 가능하다. ➡️ setState를 호출하면 build 함수가 다시 한번 호출되는 형태이다.

💡 이것을 통해 알 수 있는 사실은 Widget의 build 함수가 결국 UI를 그리는 작업을 담당하고 있다.

⚡ 실제 StatefulWidget을 구현할 때는 StatelessWidget을 먼저 생성하고 quick fix 기능으로 변경을 해보자.

🔄 StatefulWidget LifeCycle

메서드 이름 호출 시점 및 조건 주요 역할
createState() StatefulWidget이 처음 생성될 때 State 객체를 생성하여 연결 🔗
initState() State가 처음 초기화될 때 1회 호출 초기 상태 설정, 리스너 등록 ⚙️
didChangeDependencies() initState() 이후 또는 InheritedWidget이 변경되었을 때 의존성 관련 값 접근 가능 🔄
build() 위젯이 처음 그려질 때, 또는 setState()/didUpdateWidget() 호출 후 UI 구성 🎨
didUpdateWidget() 부모가 위젯을 새 인스턴스로 다시 그릴 때 (State는 유지) 새 파라미터 반영. 이후 자동으로 build() 🔄
setState() 상태 변경 발생 시 호출 상태 변경 → build() 트리거 ⚡
deactivate() 위젯이 트리에서 제거되기 직전 위젯이 제거될 준비 단계 ⏳
dispose() 위젯이 완전히 트리에서 제거될 때 리소스 해제, 컨트롤러/리스너 정리 🗑️

⌨️ 3-2. TextField

사용자의 글자 입력을 받을 수 있는 위젯 📝

📝 주요 속성

  • textInputAction ⌨️
    • 키보드의 액션 버튼(오른쪽 아래 버튼)의 형태와 동작을 결정 🎯 | 타입명 | 키보드 버튼에 표시되는 텍스트 | 용도/설명 | | — | — | — | | TextInputAction.done | Done ✅ | 입력 완료 시 사용 (폼 제출, 입력 종료) | | TextInputAction.next | Next ➡️ | 다음 입력 필드로 이동 | | TextInputAction.previous | Previous ⬅️ | 이전 입력 필드로 이동 (드물게 사용됨) | | TextInputAction.search | Search 🔍 | 검색 창 등에서 검색 트리거 | | TextInputAction.send | Send 📤 | 채팅/메시지 등에서 전송 기능 | | TextInputAction.go | Go 🚀 | URL 이동, 진행 버튼 등에 사용 | | TextInputAction.continueAction | Continue ⏩ | 다음 단계로 계속 진행할 때 (iOS에서 주로 사용) | | TextInputAction.join | Join 🤝 | 가입이나 참여 관련 입력창 | | TextInputAction.route | Route 🗺️ | 경로 탐색, 내비게이션 목적의 입력 | | TextInputAction.emergencyCall | Emergency 🚨 | 긴급 전화 관련 UI | | TextInputAction.newline | Enter / 줄 바꿈 📝 | 멀티라인 입력 시 줄 바꿈 기능 (채팅/메모장 등에서 자주 사용) | | TextInputAction.unspecified | 플랫폼 기본값 사용 ⚙️ | 특별히 지정하지 않음 (기본 동작 사용) |
  • expands 📏
    • TextField가 부모 위젯의 남은 세로 공간을 전부 차지
    • maxLines: null과 함께 반드시 사용해야 동작함 ⚠️
  • maxLines 📊
    • 줄 수 제한
    • null 일 땐 줄 수 제한 없음 ♾️
  • onChanged 📝
    • 사용자가 입력을 변경할 때마다 호출
    • 실시간 입력 감지, 자동 저장 등에 활용 가능 ⚡
  • onSubmitted
    • 사용자가 Enter 키를 눌러 입력을 제출했을 때 호출
    • 작성 완료 처리, 입력값 저장 등에 사용 💾

🎮 TextEditingController

TextField의 입력 값을 직접 제어하거나 읽을 수 있도록 하는 객체 🎯

class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  TextEditingController controller = TextEditingController();

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Test")),
      body: Column(
        children: [
          TextField(controller: controller),
          ElevatedButton(
            onPressed: () {
              controller.clear();
            },
            child: Text("초기화"),
          ),
          ElevatedButton(
            onPressed: () {
              print(controller.text);
            },
            child: Text("현재값"),
          ),
        ],
      ),
    );
  }
}

TextField를 사용하고 싶을 때 사용 📱

  • 입력 초기값 설정, clear 등 다양한 작업 가능 ⚙️
  • 더이상 사용하지 않을 때 dispose해줘야 한다(메모리 누수 방지) ⚠️

🎨 TextField 꾸미기

✨ Style 관련 속성

  • decoration: 입력창 꾸미기 🎨 | 속성명 | 설명 | | — | — | | labelText | 입력창 위에 항상 표시되는 라벨 텍스트 🏷️ | | hintText | 입력 전, 입력창 안에 흐릿하게 보이는 힌트 💭 | | prefixIcon / suffixIcon | 입력창 안쪽에 표시할 아이콘 🔍 | | prefix / suffix | 아이콘 외에 텍스트/위젯도 가능 📝 | | filled | 배경을 채울지 여부 (true 설정 시 fillColor 필요) ✨ | | fillColor | 배경색 설정 🎨 | | border | 기본 상태 테두리 📦 | | enabledBorder | 포커스되지 않은 상태의 테두리 ⚪ | | focusedBorder | 포커스된 상태의 테두리 🔵 | | contentPadding | 내부 여백 (입력값과 테두리 사이 간격) 📏 |
  • style: 입력 텍스트 스타일 ✍️
  • cursorColor, cursorWidth: 커서 색상/굵기
  • textAlign: 정렬 위치 📍
  • obscureText: 비밀번호 처리 🔒

📋 사용 예제

TextField(
  decoration: InputDecoration(
    labelText: '이메일',
    hintText: 'example@domain.com',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    filled: true,
    fillColor: Colors.grey[100],
  ),
  style: TextStyle(fontSize: 16, color: Colors.black87),
  cursorColor: Colors.blueAccent,
)

🧪 실습 코드

  @override
  Widget build(BuildContext context) {
    print("build 메서드 호출됨");
    return GestureDetector(
      onTap: () {
        FocusScope.of(
          context,
        ).unfocus(); // TextField를 다룰 때는 Scaffold를 GestureDetector로 감싸서 포커스를 해제해주는 동작을 반드시 수행
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text("카운터"),
          backgroundColor: Colors.purple.shade100,
          centerTitle: true,
        ),
        body: SizedBox(
          width: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              SizedBox(
                height: 200,
                child: TextField(
                  controller: controller,
                  decoration: InputDecoration(
                    labelText: "라벨",
                    hintText: "힌트",
                    prefixIcon: Icon(Icons.search),
                    suffixIcon: Icon(Icons.add_call),
                    // fillColor: Colors.amber,
                    // filled: true, // 이 값을 true로 줘야 fillColor가 적용
                    border: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(20),
                      borderSide: BorderSide(width: 20),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderSide: BorderSide(color: Colors.red),
                    ),
                    contentPadding: EdgeInsets.all(0),
                  ),
                  textInputAction: TextInputAction.go,
                  maxLines: 1,
                  onChanged: (value) {
                    print(value);
                  },
                  onSubmitted: (value) => {print("onSubmit: $value")},
                  cursorColor: Colors.green,
                  cursorHeight: 10,
                ),
              ),
              ElevatedButton(
                onPressed: () {
                  controller.clear();
                  print(controller.text);
                },
                child: Text("Increment"),
              ),
            ],
          ),
        ),
      ),
    );
  }

📜 3-3. ListView

스크롤 가능한 위젯 리스트를 만들 때 사용 📱

💡 리스트뷰는 화면에 보이는 위젯만 그리기 때문에 성능상 좀더 효율이 좋다. (SingleChildScrollView는 모두 그리고 있기 때문에 ListView를 사용하는 게 더 좋다) ⚡

🔨 ListView.builder

for문처럼 인덱스를 기반으로 위젯을 하나씩 만들어줌 🔁

body: ListView.builder(
  itemCount: 5,
  itemBuilder: (context, index) {
    return Container(
      width: double.infinity,
      height: 400,
      color: Colors.amber,
      margin: EdgeInsets.all(20),
      child: Center(child: Text("1", style: TextStyle(fontSize: 50))),
    );
  },
),

💡 builder도 화면이 보이는 위젯만 그때 그때 그려줌 ⚡

📐 ListView.separated

List를 그릴 때 중간 중간 여백을 둘 때 사용 🔲

body: ListView.separated(
  itemCount: 5,
  separatorBuilder: (context, index) {
    return SizedBox(height: 100);
  },
  itemBuilder: (context, index) {
    return Container(
      width: double.infinity,
      height: 400,
      color: Colors.amber,
      margin: EdgeInsets.all(20),
      child: Center(child: Text("1", style: TextStyle(fontSize: 50))),
    );
  },
)

📱 3-4. ButtomNavigationBar + IndexedStack

📱 ButtomNavigationBar

  • 화면 하단에 고정된 탭 메뉴를 만드는데 사용 📍
  • 화면 하단에 고정된 탭 메뉴를 만드는 데 사용 🎯
  • 앱에서 여러 페이지(예: 홈, 설정, 프로필 등)를 전환할 수 있게 함 🔄
  • 일반적으로 StatefulWidget과 함께 사용하여 선택된 탭 인덱스를 상태로 관리함 📊
  • ScaffoldbottomNavigationBar 속성에 사용 🏗️

📝 주요 속성

  • items: 탭으로 표시할 아이콘과 라벨 리스트 📋
  • currentIndex: 현재 선택된 탭 인덱스 📍
  • onTap: 탭 터치 시 호출되는 함수(터치된 탭의 인덱스가 인자로 넘어옴) 👆
  • selectedItemColor: 선택된 탭의 아이콘과 텍스트 색상 🔵
  • iconSize: 아이콘 크기 설정 (기본값은 24.0) 📏
  • selectedLabelStyle: 선택된 탭의 텍스트 스타일 (ex. 볼드체) ✨
  • unselectedItemColor: 선택되지 않은 탭의 색상 (생략 가능, 기본은 회색) ⚪
  • unselectedLabelStyle: 선택되지 않은 텍스트 스타일도 따로 설정 가능 📝

📋 사용 예제

@override
Widget build(BuildContext context) {
  // TODO: implement build
  return Scaffold(
    appBar: AppBar(title: Text("바텀내비")),
    bottomNavigationBar: BottomNavigationBar(
      currentIndex: index,
      onTap: (value) {
        setState(() {
          index = value;
        });
      },
      selectedItemColor: Colors.red,
      iconSize: 30, // 기본 사이즈 24
      selectedLabelStyle: TextStyle(fontWeight: FontWeight.bold),
      items: [
        BottomNavigationBarItem(icon: Icon(Icons.home), label: "홈"),
        BottomNavigationBarItem(icon: Icon(Icons.settings), label: "설정"),
        BottomNavigationBarItem(icon: Icon(Icons.person), label: "마이페이지"),
      ],
    ),
  );
}

📚 IndexedStack

여러 위젯을 겹쳐서(stack) 배치한 뒤, 그 중 지정한 index에 해당하는 위젯만 보여주는 위젯 🎯

📝 주요 속성

  • index 📍
    • 현재 표시할 자식 위젯의 인덱스
    • 해당 인덱스에 해당하는 위젯만 화면에 보임 👁️
  • children 👶
    • 겹쳐놓을 위젯 리스트
    • 모든 위젯이 메모리에는 유지됨 (build됨) 🧠

🧭 3-5. 페이지 라우팅

페이지 이동 🚀

  • 새로운 페이지로 이동 ➡️
    • Navigator.push()
  • 뒤로 가기 ⬅️
    • Navigator.pop()

🧭 Navigator란? 그리고 왜 필요할까?

  • Navigator는 Flutter 앱에서 화면(Page, Route) 간 전환을 관리하는 핵심 위젯 🎯
    • 내부적으로 스택(Stack) 자료구조를 사용하여 📚 스택 자료구조 : 데이터를 담을 때 마지막에 넣은 데이터가 가장 먼저 나오는 구조 🔄
    • 새로운 페이지는 push()로 쌓고 이전 페이지는 pop()으로 꺼내며 이동을 처리 ⚡
    • PageA → PageB 이동 시 라우트 스택 상황
[PageA]  // 첫 페이지
[PageB]  // 첫 페이지
[PageA]  // 첫 페이지

┌──────────────────────────────┐ │ MaterialApp │ │ (Navigator 🧭 내장되어 있음) │ └──────────────────────────────┘ ⬆️ ┌────────────────────┐ │ PageA(context) │ └────────────────────┘ ⬆️ ┌────────────────────┐ │ Scaffold │ └────────────────────┘ ⬆️ ┌────────────────────┐ │ ElevatedButton │ └────────────────────┘

🤔 페이지 이동하는데 context는 왜 필요할까?

  • context는 Flutter 위젯 트리 내에서 현재 위젯의 위치와 관련된 정보를 담고 있는 객체 📍
  • 정확한 타입은 BuildContext이며, 이를 통해 Flutter는 위젯 트리를 상위로 타고 올라가며 필요한 위젯을 찾을 수 있음 🔍
  • Navigator 위젯을 찾아서 push 의 두번째 인자에서 전달해준 위젯을 스택에 push! 🎯

📦 데이터 전달

class PageA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("APage"),
      ), // 뒤로 가야하는 페이지가 있을 때 자동으로 뒤로 가기 버튼이 생성됨
      body: Column(
        children: [
          ElevatedButton(
            onPressed: () async {
              String? result = await Navigator.push(
                // 반환 받은 문자열이 null일 수 있으니 nullable 타입을 사용하자
                context,
                MaterialPageRoute(builder: (context) => PageB("안녕하세요")),
              );
              print("페이지 이동 완료");
              print(result);
            },
            child: Text("B페이지로 이동"),
          ),
        ],
      ),
    );
  }
}

class PageB extends StatelessWidget {
  PageB(this.value);
  String value;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("BPage")),
      body: Column(
        children: [
          Text(value, style: TextStyle(fontSize: 100)),
          ElevatedButton(
            onPressed: () {
              Navigator.pop(context, "돌아갑니다");
            },
            child: Text("뒤로가기"),
          ),
        ],
      ),
    );
  }
}

💡 데이터 전달 방식

  • push를 할 때 MaterialPageRoute를 사용해서 이동할 페이지를 생성할 때 생성자를 통해 데이터를 전달해 줄 수 있고 📤
  • pop을 할 때는 return 타입이 Future인 점을 활용해서 push 시점에 async await을 사용해서 전달 받은 데이터를 활용할 수 있다 📥

📝 3-6. 투두앱 만들기 - UI 구현하기

UI를 구현할 때 가장 먼저 해야하는 것이 있다. 바로 레이아웃 나누기! 🎯

전체 페이지에서의 골격은 Scaffold가 나눠줌 🏗️

  • Scaffold appBar 📱
  • Scaffold body 📄
  • Scaffold bottomSheet 📋

투두 아이템을 길게 눌렀을 때 iOS 스타일의 팝업 노출 📱

📋 TODO 앱을 만드는 과정

  • UI를 보면서 레이아웃을 먼저 잡는다 👀
    • 크게 3가지 영역으로 잡아 본다 📐
      1. appBar(상단) 📱
      2. body(중단) 📄
      3. bottomSheet(하단) 📋
  • MaterialApp에서 home에 들어갈 위젯을 생성 🏠
  • 홈 위젯에서 화면의 구조를 잡을 Scaffold 위젯을 생성 🏗️
  • AppBar 영역 구현 📱
    • Title 텍스트 생성 ✨
  • BottomSheet 영역 구현 📋
    • TextField 생성 ⌨️
      • Container: bottom 배경 색상과 padding을 위해 📦
        • TextField 📝
          • controller 🎮
          • decoration: InputDecoration 🎨
            • hintText 💭
            • border 📦
            • fillColor 🎨
            • filled ✨
            • subfixIcon 🔍
              • GestureDetector 👆
              • child 👶
                • Container 📦
                  • margin 📏
                  • decoration: BoxDecoration 🎨
                  • child 👶
                    • Icon 🔍
  • Body 영역 구현 📄
    • Todo 리스트 박스 레이아웃 나누기 📐
    • Scoller 영역을 위해 ListView로 구현 📜
      • padding: ListView 아이템들의 여백을 위해서 📏
      • children 👶
        • Container 📦
          • width: double.infinity(가로 끝까지) ↔️
          • decoration: BoxDecoration 🎨
            • border 📦
            • borderRadius 🔘
          • padding 📏
          • child 👶
            • Row ↔️
              • children 👶
                • Container 📦
                  • width 📏
                  • height 📏
                  • decoration: BoxDecoration 🎨
                    • border: Box 색상 🎨
                    • shape: Box의 모양 🔘
                • SizedBox 📏
                • Expanded: 자동으로 다음 칸으로 넘어가기 위해서 ↔️
                  • Text ✍️
                • Sized Box(끝 여백) 📏
        • SizedBox 📏

댓글남기기