EDOC/Flutter 스터디 (2025-1)

[3.1 ~ 3.3] 블로그 웹 앱 콜백 함수, 웹뷰, 네이티브 설정 ~ 만난 지 며칠 U&I 상태 관리, CupertinoDatePicker, Dialog, DateTime

SpruceMoon 2025. 5. 20. 11:37

 

8. 플러터로 블로그 웹 앱 만들기: 웹뷰와 콜백 함수 활용하기

8.1. 사전 지식

8.1.1 콜백 함수

 - 일정 작업이 완료되면 실행되는 함수

 - 함수를 미리 정의해두고 특정 조건이 성립될 때 실행됨.

 - ex) 웹뷰에서 페이지 로딩이 완료된 후 실행되는 onPageFinished() 콜백 함수

 

8.1.2 웹뷰 위젯

 - 웹뷰 : 프레임워크에 내장된 브라우저를 앱의 네이티브 컴포넌트에 임베딩하는 기능(앱 내에서 웹 브라우저 기능을 구현)

 - 단점 : 네이티브 컴포넌트에 비해 속도 느림 / 애니메이션이 부자연스러움

 - 장점 : 기존 웹사이트를 손쉽게 앱으로 활용할 수 있음.

 - 웹뷰 위젯 :: controller 파라미터에 WebViewController 객체를 입력해 제어 (주요 함수⤵️)

  • setJavaScriptMode: 자바스크립트 실행 허용 여부 설정
  • setBackgroundColor: 배경색 지정
  • loadRequest: 새로운 URL로 이동
  • setNavigationDelegate: 다양한 콜백 함수를 포함한 NavigationDelegate 객체 설정

8.1.3 안드로이드와 iOS 네이티브 설정

 - 특정 기능을 사용하기 위해서는 각 플랫폼별 네이티브 설정이 필요할 수 있음.

 - (ex. 웹뷰 사용 위해서 인터넷 권한과 HTTP/HTTPS 프로토콜 관련 설정 필요, 카메라, 사진첩, 푸시 알림 등 보안에 민감한 기능이나 하드웨어 접근 등에도 필요)

8.2 사전 준비

8.2.1 pubspec.yaml 설정하기

- 플러그인 설치하기

 
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  webview_flutter: 4.10.0

 - 플러그인 설치 후 flutter pub get 명령어를 실행 -> 패키지 다운로드

 - 그 외 다양한 pub 명령어:

  • flutter pub add [플러그인 이름]: pubspec.yaml에 플러그인 추가
  • flutter pub upgrade: 등록된 플러그인을 최신 버전으로 업데이트
  • flutter pub run: 현재 프로젝트 실행

8.2.2 권한 및 네이티브 설정하기 (안드로이드)

-  추가할 코드들

 - AndroidManifest.xml 파일 수정

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="blog_web_app"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        android:usesCleartextTraffic="true">
        <!-- 생략 -->
    </application>
</manifest>
 

8.2.3 프로젝트 초기화하기

 - lib 폴더 아래에 screen 폴더를 만들고, 그 안에 home_screen.dart 파일을 생성

import 'package:flutter/material.dart' ;
class HomeScreen extends StatelessWidget {
	// @ const 생성자
	const HomeScreen({Key? key}) : super(key: key);
	@override
	Widget build(BuildContext context) {
		return Scaffold(
		body: Text('Home Screen’),
		);
	}
}
 
 
 - main.dart 파일 수정:
import 'package:blog_web_app/screen/home_screen.dart';
import 'package: flutter/material.dart';
void main() {
	runApp(
		Materia lApp(
			home: HomeScreen(),
		),
	);
}

8.3 레이아웃 구상하기

  1. 앱바(AppBar): 제목과 홈 버튼을 표시
  2. 웹뷰(WebView): 지정한 URL의 웹페이지를 보여줌

8.4 구현하기

8.4.1 앱바 구현하기

 - HomeScreen 위젯 => 앱바 추가하고, 제목 넣어주고, 배경색 설정:

import 'package: flutter/material.dart';
class HomeScreen extends StatelessWidget {
	const HomeScreen({Key? key}) : super(key: key);
	@override
	Widget build(BuildContext context) {
		return Scaffold(
		// 1. 앱바 위젯 추가 
    		appBar: AppBar(
				// 2. 배경색 지정 
				backgroundColor: Colors.orange,
				// 3. 앱 타이틀 설정
				title: Text('Code Factory'),
				// 4. 가운데 정렬
				centerTitle: true,
	        ),
			body: Text('Home Screen’),
		);
	}
}
 

8.4.2 웹뷰 구현하기

 - 필요한 패키지를 임포트, 위젯 추가

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({Key? key}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.orange,
        title: Text('Code Factory'),
        centerTitle: true,
      ),
      body: WebViewWidget(
        controller: webViewController, // 아직 선언되지 않음
      ),
    );
  }
}
 

8.4.3. 웹뷰 컨트롤러 설정하기

 - 웹뷰 컨트롤러 선언/설정:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class HomeScreen extends StatelessWidget {
  // WebViewController 선언
  WebViewController webViewController = WebViewController()
    // loadRequest() 함수로 웹 페이지 로드
    ..loadRequest(Uri.parse('https://blog.codefactory.ai'))
    // JavaScript 실행 허용
    ..setJavaScriptMode(JavaScriptMode.unrestricted);
    
  const HomeScreen({Key? key}) : super(key: key);
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.orange,
        title: Text('Code Factory'),
        centerTitle: true,
      ),
      body: WebViewWidget(
        controller: webViewController,
      ),
    );
  }
}
 
 

8.4.4 main.dart 파일 수정하기

StatelessWidget에서 WebViewController를 프로퍼티로 직접 인스턴스화하기 위해 플러터 프레임워크가 앱을 실행할 준비가 됐는지 확인하는 ensureInitialized() 함수를 추가합니다:

import 'package:blog_web_app/screen/home_screen.dart';
import 'package:flutter/material.dart';

void main() {
  // 플러터 프레임워크가 앱을 실행할 준비가 될 때까지 기다림
  WidgetsFlutterBinding.ensureInitialized();
  
  runApp(
    MaterialApp(
      home: HomeScreen(),
    ),
  );
}
 

8.4.5 홈 버튼 구현하기

 - 어느 페이지에서든 코드팩토리 홈페이지로 돌아올 수 있는 홈 버튼을 앱바에 추가:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      backgroundColor: Colors.orange,
      title: Text('Code Factory'),
      centerTitle: true,
      // AppBar에 액션 버튼 추가
      actions: [
        IconButton(
          // 아이콘을 눌렀을 때 실행할 콜백 함수
          onPressed: () {
            webViewController.loadRequest(Uri.parse('https://blog.codefactory.ai'));
          },
          // 홈 버튼 아이콘 설정
          icon: Icon(Icons.home),
        ),
      ],
    ),
    body: WebViewWidget(
      controller: webViewController,
    ),
  );
}
 

9. 전자액자 : 위젯 생명주기, PageView, Timer, SystemChrome, StatefulWidget

9.1. 사전 지식

9.1.1 위젯 생명주기

 - 위젯이 화면에 그려지는 순간부터 삭제되는 순간까지의 주기

 - StatelessWidget & StatefulWidget 두 가지 유형 존재

StatelessWidget:

  • 상태가 없는 위젯으로 한번 생성되면 화면 변경이 불가능
  • 불변이기 때문에 생성된 인스턴스의 build() 함수는 재실행되지 않음
  • 변경사항을 반영하려면 인스턴스를 완전히 새로 생성해야 함

StatefulWidget:

  • 상태를 가지고 있어 위젯의 상태 변경 가능
  • setState() 함수를 통해 build() 메소드를 다시 실행 가능
  • 주요 생명주기 메소드:
    • createState(): StatefulWidget과 연동되는 State를 생성
    • initState(): State가 생성되는 순간 딱 한 번만 실행
    • build(): UI를 구성하는 함수
    • dispose(): 위젯이 제거될 때 호출

9.1.2 Timer

 - 전자액자에서 이미지 자동 전환을 위해 사용

 - Timer 클래스는 dart:async 패키지에서 제공함.

 - Timer.periodic() 메소드 -> 정해진 시간마다 특정 함수를 반복 실행

Timer.periodic(
	Duration(seconds: 3), // 주기
	(Timer timer) {}, // 콜백 함수
);

9.2 사전 준비

9.2.1 이미지 추가하기

    1. 플러터 프로젝트 생성
    2. 이미지 준비 :: asset/img/ 폴더 생성 -> 이미지 5개를 복사 -> image_1 ~ image_5.jpeg 이미지 준비.

9.2.2 pubspec.yaml 설정하기

 - pubspec.yaml :: assets 섹션에 이미지 경로 등록하는 파일

9.2.3 프로젝트 초기화하기 

  1. home_screen.dart 파일, HomeScreen 생성.

9.3 레이아웃 구상하기

전자액자 앱의 레이아웃은 매우 단순합니다. 전체 화면을 채우는 PageView 하나로 구성되며, 이미지들을 좌우로 스와이프하여 넘길 수 있습니다8.

  • 화면 전체를 차지하는 PageView 위젯
  • 각 페이지는 하나의 이미지를 표시
  • 이미지는 화면 크기에 맞게 조절 (BoxFit.cover 사용)

9.4 구현하기

9.4.1. 페이지뷰 구현하기

 - PageView 추가하기

import 'package: flutter/material.dart';

class HomeScreen extends StatelessWidget {
	const HomeScreen({Key? key}) : super(key: key);
	
	@override
	Widget build(BuildContext context) {
		return Scaffold(
			body: PageView( // ① PageView 추가
				children: [1, 2, 3, 4, 5]  // ② 샘플 리스트 생성
					.map(// ③ 위젯으로 매핑
				(number) => Image.asset('asset/img/image_$number.jpeg'),
					)
					.toList(),
			),
		);
	}
}

- 이미지 핏 조절하기

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: PageView(
      children: [1, 2, 3, 4, 5] 
	      .map(
		      (number) => Image.asset(
	          'asset/img/image_$number.jpeg',
            fit: BoxFit.cover,
          ),
        )
        .toList(),
      ),
    );
  }
)

 - BoxFit.cover 사용 -> 이미지가 화면 전체를 채우도록 설정

9.4.2 상태바 색상 변경하기

 - HomeScreen 위젯의 build() 메서드에 상태바 아이콘 색상 변경 코드를 추가

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

	@override
  Widget build(BuildContext context) {
	// 상태바가 이미 흰색이면 light 대신 dark를 주어 검정으로 바꾸기
  SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);

	return Scaffold(
		... 생략 ...
	);
 }
}

9.4.2 타이머 구현

 - initState() 메소드에서 타이머 설정

 - 3초마다 다음 이미지로 자동 전환되도록 구현

 - 3초마다 실행되며, 현재 페이지 번호 확인한 후 다음 페이지로 이동 / 마지막 페이지에 도달하면 첫 번째 페이지로 이동

 
 
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:async';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

// ❷ State 정의
class _HomeScreenState extends State<HomeScreen> {
  final PageController pageController = PageController();

  @override
  void initState() {
    super.initState(); // ➌ 부모 initState() 실행

    Timer.periodic(
      // ➍ Timer.periodic() 등록
      Duration(seconds: 3),
      (timer) {
        print('실행!');
        int? nextPage = pageController.page?.toInt();

        // ➋
        if (nextPage == null) {
          return;
        }
        // ➌
        if (nextPage == 4) {
          nextPage = 0;
        } else {
          nextPage++;
        }
        pageController.animateToPage(
          // ➍ 페이지 변경
          nextPage,
          duration: Duration(milliseconds: 500),
          curve: Curves.ease,
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);

    return Scaffold(
      body: PageView(
        controller: pageController,
        // ➊ PageView 추가
        children: [1, 2, 3, 4, 5] // ➋ 샘플 리스트 생성
            .map(
              // ➌ 위젯으로 매핑
              (number) => Image.asset(
                'asset/img/image_$number.jpeg',
                fit: BoxFit.cover,
              ),
            )
            .toList(),
      ),
    );
  }
}

 

9.5 테스트하기

  1. 이미지가 3초마다 자동으로 전환되는지 확인
  2. 마지막 이미지에서 자동으로 첫 번째 이미지로 돌아가는지 확인
  3. 사용자가 직접 이미지를 좌우로 스와이프하여 전환할 수 있는지 확인
  4. 이미지가 화면 크기에 맞게 잘 표시되는지 확인

10. 만난 지 며칠 U&I :: 상태 관리, CupertinoDatePicker, Dialog, Datetime

10.1 사전 지식

10.1.1 setState() 함수

10.1.2 showCupertinoDialog() 함수

import 'package:flutter/cupertino.dart';

showCupertinoDialog(
	context: context, 
	barrierDismissible: true,
	builder: (BuildContext context) { 
		return Text('Dialog');
	},
);

10.2 사전 준비

10.2.3 프로젝트 초기화하기

import 'package: flutter/material.dart' ;
class HomeScreen extends StatelessWidget {
	const HomeScreen({Key? key}) : super(key: key);
	
    @override
	Widget build(BuildContext context) {
		return Scaffold(
			body: Text('Home Screen'),
		);
	}
}
import 'package:u_and_i/screen/home_screen.dart';
import 'package: flutter/material.dart' ;
void main() {
	runApp(
		MaterialApp(
			home: HomeScreen(),
		),
	);
}

10.4 구현하기

10.4.1 홈 스크린 UI 구현하기

import 'package: flutter/material.dart';
class HomeScreen extends StatelessWidget {
	const HomeScreen({Key? key}) : super(key: key);
	@override
	Widget build(BuildContext context) {
		return Scaffold(
			body: Text('Home Screen'),
		);
	}
}
class _CoupleImage extends StatelessWidget {
	@override
	Widget build(BuildContext context) {
		return Text('Couple Image Widget');
	}
}
class _DDay extends StatelessWidget {
	@override
	Widget build(BuildContext context) {
		return Text('DDay Widget');
	}
}
class HomeScreen extends StatelessWidget {
	const HomeScreen({Key? key}) : super(key: key);
	@override
	Widget build(BuildContext context) {
		return Scaffold(
			backgroundColor: Colors.pink[100], 
			... 생략 ...
        );
    }
}

 

class _CoupleImage extends StatelessWidget {
	@override
	Widget build(BuildContext context) {
		return Center(
			child: Image.asset(
				'asset/img/middle_image.png',
				height: MediaQuery.of(context).size.height / 2,
    		),
		);
	}
}

 

...생략...
class _DDay extends StatelessWidget {
	@override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SizedBox(height: 16.0),
        Text( // 최상단 U&I 글자
          'U&I',
        ),
        const SizedBox(height: 16.0),
        Text( // 두번째 글자
          '우리 처음 만난 날',
        ),
        Text( // 임시로 지정한 만난 날짜
					'2021..11.23',
        ),
        const SizedBox(height: 16.0),
        IconButton( // 하트 아이콘 버튼
          iconSize: 60.0,
          onPressed: onHeartPressed,
          icon: Icon(
            Icons.favorite,
          ),
        ),
        const SizedBox(height: 16.0),
        Text( // 만난 후 DDay
					'D+365',
        ),
      ],
    );
  }
}
...생략...

 - 위젯 만들기

 - main.dart 파일에 텍스트 / IconButton 테마 정의

void main() {
  runApp(
    MaterialApp(
      theme: ThemeData(  // ➊ 테마를 지정할 수 있는 클래스
          fontFamily: 'sunflower',  // 기본 글씨체
          textTheme: TextTheme(     // ➋ 글짜 테마를 적용할 수 있는 클래스
            displayLarge: TextStyle(   //  headline1 스타일 정의
              color: Colors.white,  //  글 색상
              fontSize: 80.0,       //  글 크기
              fontWeight: FontWeight.w700, //  글 두께
              fontFamily: 'parisienne',    //  글씨체
            ),
            displayMedium: TextStyle(
              color: Colors.white,
              fontSize: 50.0,
              fontWeight: FontWeight.w700,
            ),
            bodyLarge: TextStyle(
              color: Colors.white,
              fontSize: 30.0,
            ),
            bodyMedium: TextStyle(
              color: Colors.white,
              fontSize: 20.0,
            ),
          )
      ),
      home: HomeScreen(),
    ),
  );
}

 

  - Text 위젯에 스타일 적용

...생략...
class _DDay extends StatelessWidget {
	@override
  Widget build(BuildContext context) {
		// ➊ 테마 불러오기
    final textTheme = Theme.of(context).textTheme;

    return Column(
      children: [
        const SizedBox(height: 16.0),
        Text(
          'U&I',
          style: textTheme.displayLarge, // headline1 스타일 적용
        ),
        const SizedBox(height: 16.0),
        Text(
          '우리 처음 만난 날',
          style: textTheme.bodyLarge, // bodyText1 스타일 적용
        ),
        Text(
          '2021.11.23',
          style: textTheme.bodyMedium, // bodyText2 스타일 적용
        ),
        const SizedBox(height: 16.0),
        IconButton(
          iconSize: 60.0,
          onPressed: () {},
          icon: Icon(
            Icons.favorite,
            color: Colors.red, // ➋ 색상 빨강으로 변경
          ),
        ),
        const SizedBox(height: 16.0),
        Text(
          'D+365',
					// headline2 스타일 적용
          style: textTheme.displayMedium,
        ),
      ],
    );
  }
}

10.1 전체 코드

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  DateTime firstDay = DateTime.now();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.pink[100],
      body: SafeArea(
        // ➊ 시스템 UI 피해서 UI 그리기
        top: true,
        bottom: false,
        child: Column(
          // ➋ 위, 아래 끝에 위젯 배치
          mainAxisAlignment: MainAxisAlignment.spaceBetween,

          // 반대 축 최대 크기로 늘리기
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _DDay(

              // ➎ 하트 눌렀을때 실행할 함수 전달하기
              onHeartPressed: onHeartPressed,
              firstDay: firstDay,
            ),
            _CoupleImage(),
          ],
        ),
      ),
    );
  }

  void onHeartPressed(){  // ➍ 하트 눌렀을때 실행할 함수
    showCupertinoDialog(  // ➋ 쿠퍼티노 다이얼로그 실행
      context: context,
      builder: (BuildContext context) {
        return Align(  // ➊ 정렬을 지정하는 위젯
          alignment: Alignment.bottomCenter,  // ➋ 아래 중간으로 정렬
          child: Container(
            color: Colors.white,  // 배경색 흰색 지정
            height: 300,  // 높이 300 지정
            child: CupertinoDatePicker(
              mode: CupertinoDatePickerMode.date,
              onDateTimeChanged: (DateTime date) {
                setState(() {
                  firstDay = date;
                });
              },
            ),
          ),
        );
      },
      barrierDismissible: true,
    );
  }
}

class _DDay extends StatelessWidget {
  final GestureTapCallback onHeartPressed;
  final DateTime firstDay;

  _DDay({
    required this.onHeartPressed,  // ➋ 상위에서 함수 입력받기
    required this.firstDay,
  });

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    final now = DateTime.now();

    return Column(
      children: [
        const SizedBox(height: 16.0),
        Text(
          // 최상단 U&I 글자
          'U&I',
          style: textTheme.displayLarge,
        ),
        const SizedBox(height: 16.0),
        Text(
          // 두번째 글자
          '우리 처음 만난 날',
          style: textTheme.bodyLarge,
        ),
        Text(
          // 임시로 지정한 만난 날짜
          '${firstDay.year}.${firstDay.month}.${firstDay.day}',
          style: textTheme.bodyMedium,
        ),
        const SizedBox(height: 16.0),
        IconButton(
          // 하트 아이콘 버튼
          iconSize: 60.0,
          onPressed: onHeartPressed,
          icon: Icon(
            Icons.favorite,
            color: Colors.red,
          ),
        ),
        const SizedBox(height: 16.0),
        Text(
          // 만난 후 DDay
          'D+${DateTime(now.year, now.month, now.day).difference(firstDay).inDays + 1}',
          style: textTheme.displayMedium,
        ),
      ],
    );
  }
}

class _CoupleImage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Expanded(
      // Expanded 추가
      child: Center(
        // ➊ 이미지 중앙정렬
        child: Image.asset(
          'asset/img/middle_image.png',

          // ➋ 화면의 반만큼 높이 구현
          height: MediaQuery.of(context).size.height / 2,
        ),
      ),
    );
  }
}