플러터에서 카메라를 사용해야 할 때 어떻게 하시나요? image_picker 같이 쉽게 사용할 수 있는 패키지를 많이들 사용하리라 생각합니다. 저도 과거엔 image_picker를 많이 사용했었는데요, 최근에 프로젝트를 진행하다가 카메라 UI를 수정해야 할 일이 생겨서 알아보던 중 camera 패키지가 있다는 것을 알게 되었습니다. 이 패키지를 사용하면 UI를 마음대로 커스텀해서 그릴 수도 있는 것과 동시에 카메라에 있는 기능들도 정상적으로 사용이 가능합니다.
커스텀 카메라를 만들기 위해서는 일단 3개의 패키지를 설치해야 합니다. 카메라 모듈인 camera, 촬영한 사진을 임시로 저장해 줄 path_provider, 그리고 데이터 관리를 위한 provider가 필요합니다. 터미널에 다음 코드를 입력해서 프로젝트에 필요한 패키지들을 설치해 줍시다.
flutter pub add camera
flutter pub add path_provider
flutter pub add provider
카메라 권한 설정하기
요즘에는 앱을 만들 때 신경 써야 할 보안 가이드라인이 많습니다. 카메라 역시 사용자에게 권한을 요청해야 하죠. 아이폰은 info.plist 파일을, 안드로이드는 AndroidManifest.xml 파일을 수정하면 됩니다. 우선은 아이폰을 먼저 설정해 보겠습니다. 프로젝트 폴더에서 ios/Runner/Info.plist 파일 가장 하단에 다음 코드를 입력해 주세요. </dict>
직전에 추가하시면 됩니다.
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access for video recording</string>
<key>NSCameraUsageDescription</key>
<string>Camera access for camera app</string>
안드로이드는 설정할게 많습니다. 우선 android/app/build.gradle 파일에서 minSdk
값을 수정해야 합니다. flutter.minSdkVersion으로 되어있던 것을 21로 수정해 주세요.
minSdk = 21
다음은 android/app/src/main/AndroidManifest.xml 파일에 다음과 같이 6줄을 추가하시면 됩니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-feature android:name="android.hardware.camera" android:required="true"/>
<application
...
android:requestLegacyExternalStorage="true"
android:preserveLegacyExternalStorage="true">
<activity
...
이제 에뮬레이터 또는 실제 기기에서 앱을 실행하면 카메라 권한을 묻는 창이 뜹니다. 다만 iOS 에뮬레이터의 경우에는 카메라 사용 자체가 막혀있기 때문에 실제 기기에서 테스트해야 합니다.
MainApp 구성
MainApp은 크게 수정할 내용은 없지만, ChangeNotifierProvider
를 통해서 HomeViewModel
과 HomeView
를 생성해 주면 됩니다.
import 'package:custom_camera/view_models/home_view_model.dart';
import 'package:custom_camera/views/home_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ChangeNotifierProvider<HomeViewModel>(
create: (context) => HomeViewModel(),
child: const HomeView(),
),
),
);
}
}
Home View 구성
이제 카메라를 만들기 위해 필요한 페이지들을 하나씩 만들어 가보죠. 우선 홈 화면부터 만들어 보겠습니다. 홈 화면은 두 가지 위젯으로 구성했습니다. 촬영한 사진을 보여줄 컨테이너 아래에 카메라를 실행시키는 버튼을 배치했습니다. 사진 컨테이너는 homeViewModel
에 저장된 imgPath
가 있는지 확인한 후 빈 컨테이너 또는 사진을 보여주게 됩니다. 사진 촬영 버튼을 클릭하면 openCamera
함수를 통해서 카메라를 실행시킵니다.
import 'dart:io';
import 'package:custom_camera/view_models/home_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HomeView extends StatefulWidget {
const HomeView({super.key});
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('홈'), centerTitle: true),
body: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// 촬영한 이미지가 있는지 확인
SizedBox(
width: 300,
height: 400,
child: context.watch<HomeViewModel>().imgPath != null
? Image.file(
File(context.read<HomeViewModel>().imgPath!),
fit: BoxFit.cover,
)
: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius:
const BorderRadius.all(Radius.circular(16)),
),
child: const Icon(Icons.camera_alt,
size: 48, color: Colors.grey),
),
),
const SizedBox(height: 16),
// 사진 촬영 버튼
SizedBox(
width: 300,
height: 48,
child: ElevatedButton(
onPressed: () {
context.read<HomeViewModel>().openCamera(context);
},
child: const Text(
'사진 촬영',
style: TextStyle(color: Colors.black),
),
),
),
],
),
),
);
}
}
HomeViewModel 생성
뷰모델을 만들어 보겠습니다. 촬영한 사진의 저장 경로를 저장할 imgPath
변수와 openCamera
함수로 구성되어 있습니다. openCamera
함수를 살펴보면 Navigator.push
를 통해 카메라 화면을 실행시키고, 화면이 닫혔을 때 반환된 값을 받아오는 것을 보실 수 있습니다. 반환된 값을 imgPath
에 저장하고 NotifyListeners
를 통해 뷰를 재구성합니다.
import 'package:custom_camera/view_models/camera_view_model.dart';
import 'package:custom_camera/views/camera_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class HomeViewModel with ChangeNotifier {
String? imgPath;
void openCamera(BuildContext context) async {
// CameraView가 닫힐 때 반환된 값을 저장
String? tempImg = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
create: (context) => CameraViewModel(),
child: const CameraView(),
),
),
);
if (tempImg != null) {
imgPath = tempImg;
} else {
debugPrint('Image path error');
}
notifyListeners();
}
}
CameraView 구성
카메라 화면을 만들어 보겠습니다. 카메라 화면은 가장 아래에 카메라로 촬영 중인 화면을 띄우고, 그 위에 스택으로 촬영 버튼이나 플래시, 전환 등의 버튼을 배치하는 형식으로 구성되어 있습니다. camera 패키지에서 제공하는 CameraPreview
를 사용해서 가장 하단에 촬영 화면을 띄울 수 있습니다. 삼항 연산자를 사용해서 카메라가 로딩 중일 때는 로딩 창을 그리고, 로딩이 완료됐을 때 CameraPreview
가 뜨도록 하면 됩니다. 나머지 버튼들은 IconButton
과 GestureDetector
을 사용해서 처리했습니다.
import 'package:camera/camera.dart';
import 'package:custom_camera/view_models/camera_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CameraView extends StatefulWidget {
const CameraView({super.key});
@override
State<CameraView> createState() => _CameraViewState();
}
class _CameraViewState extends State<CameraView> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
context.watch<CameraViewModel>().isLoaded
? Positioned.fill(
child: CameraPreview(
context.watch<CameraViewModel>().controller),
)
: const Center(child: CircularProgressIndicator()),
// 상단 메뉴 버튼
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 0),
child: Align(
alignment: Alignment.topCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 플래시 토글 버튼
IconButton(
onPressed: () {
context.read<CameraViewModel>().toggleFlash();
},
icon: Icon(
context.read<CameraViewModel>().isFlashOn
? Icons.flash_on_outlined
: Icons.flash_off_outlined,
size: 24,
color: Colors.black,
),
),
// 카메라 전환 버튼
IconButton(
onPressed: () {
context.read<CameraViewModel>().changeCameraDirection();
},
icon: const Icon(
Icons.cameraswitch_outlined,
size: 24,
color: Colors.black,
),
),
// 닫기 버튼
IconButton(
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(
Icons.close,
size: 24,
color: Colors.black,
),
),
],
),
),
),
),
// 하단 촬영 버튼
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.only(bottom: 64),
child: GestureDetector(
onTap: () {
// 사진 촬영 함수 호출
context.read<CameraViewModel>().takePicture(context);
},
child: Icon(
Icons.circle,
size: 64,
color: context.read<CameraViewModel>().canTakePicture
? Colors.white
: Colors.grey,
),
),
),
),
),
],
),
);
}
}
CameraViewModel 생성
카메라를 사용하기 위해서는 여러 준비 과정을 거쳐야 합니다. 우선 _initialize()
함수를 정의하고 초기 설정을 해보겠습니다. availableCameras()
함수는 기기에서 사용 가능한 카메라들의 리스트를 반환합니다. 그중 첫 번째 카메라를 사용해서 CameraController
를 정의할 겁니다. ResolutionPreset
은 사진의 화질을 설정하고 imageFormatGroup
는 이미지 저장 형식, enableAudio
는 오디오 녹음 여부를 결정합니다. 카메라 컨트롤러를 정의했다면 controller.initialize
를 통해 컨트롤러를 초기화해 줍니다.
/// 초기 설정
Future<void> _initialize() async {
// 사용 가능한 카메라 불러오기
_cameras = await availableCameras();
// 카메라 기본 설정
controller = CameraController(_cameras[0], ResolutionPreset.max,
imageFormatGroup: ImageFormatGroup.jpeg, enableAudio: false);
await controller.initialize().catchError((e) {
if (e is CameraException && e.code == 'CameraAccessDenied') {
// 카메라 권한 설정
debugPrint('Camera access denied');
} else {
// 카메라 오류
debugPrint('Camera error: $e');
}
});
// 카메라 설정 완료
isLoaded = controller.value.isInitialized;
canTakePicture = isLoaded;
notifyListeners();
}
dispose
함수를 사용해서 카메라 사용이 끝나면 컨트롤러를 자동으로 삭제하도록 설정해 주도록 하겠습니다.
@override
void dispose() {
// 카메라 controller 해제
controller.dispose();
super.dispose();
}
사진을 찍는 함수를 만들어 보겠습니다. 컨트롤러가 초기화되지 않았거나 사진을 이미 찍고 있는 중이라면 더 이상 진행이 되면 안 되겠죠. 오류 상황이 아니라면 제일 먼저 사진이 임시로 저장될 경로를 생성해야 합니다. path_provider 패키지의 getTemporaryDirectory()
함수를 사용해서 임시 경로를 받아와 주세요. 그다음 사진의 이름도 지정해서 최종 경로를 만들면 됩니다. 저는 사진이 찍히는 시간을 가지고 이름을 만들었습니다. 사진을 찍는 방법은 간단합니다. controller.takePicture()
함수를 사용하면 사진이 찍히게 되고요, 이렇게 찍힌 사진을 경로에 저장해 주면 됩니다. 사진 촬영이 끝났다면 Navigator.pop()
을 통해 카메라 화면을 닫아주는데요, 이때 사진 경로를 반환해 주어야 합니다.
/// 사진 촬영
/// 촬영한 사진을 임시 저장 디렉토리에 저장 후 주소 반환
Future<void> takePicture(BuildContext context) async {
// 카메라 init 오류 또는 사진 찍는 중
if (!controller.value.isInitialized || controller.value.isTakingPicture) {
return;
}
try {
// 임시 저장 장소와 이름 생성
Directory directory = await getTemporaryDirectory();
final imgPath = '${directory.path}/${DateTime.now()}.jpeg';
// 사진 촬영
final XFile file = await controller.takePicture();
await file.saveTo(imgPath);
if (context.mounted) Navigator.pop(context, imgPath);
} catch (e) {
// 사진 촬영 오류
debugPrint('Error taking picture');
}
}
플래시는 FlashMode
를 통해서 설정할 수 있습니다. off, always, auto 이렇게 세 가지 모드가 있으니 잘 활용해 보시면 좋겠네요. 카메라 전환은 controller.setDescription
을 통해서 사용할 카메라를 변경하면 됩니다. 초기화할 때 불러온 카메라 리스트에서 선택하면 되겠죠.
/// 플래시 토글
void toggleFlash() {
if (isFlashOn) {
controller.setFlashMode(FlashMode.off);
isFlashOn = false;
} else {
controller.setFlashMode(FlashMode.always);
isFlashOn = true;
}
notifyListeners();
}
/// 카메라 전환
void changeCameraDirection() {
if (controller.description.lensDirection == CameraLensDirection.back) {
controller.setDescription(_cameras[0]);
} else {
controller.setDescription(_cameras[1]);
}
}
아래 전체 코드를 보시면 조금 더 이해가 쉽게 되실 겁니다.
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
class CameraViewModel with ChangeNotifier {
CameraViewModel() {
_initialize();
}
late List<CameraDescription> _cameras;
late CameraController controller;
bool isLoaded = false; // 카메라 준비 완료 여부
bool canTakePicture = false; // 사진 촬영 가능 여부
bool isFlashOn = false; // 플래시 on/off 여부
/// 초기 설정
Future<void> _initialize() async {
// 사용 가능한 카메라 불러오기
_cameras = await availableCameras();
// 카메라 기본 설정
controller = CameraController(_cameras[0], ResolutionPreset.max,
imageFormatGroup: ImageFormatGroup.jpeg, enableAudio: false);
await controller.initialize().catchError((e) {
if (e is CameraException && e.code == 'CameraAccessDenied') {
// 카메라 권한 설정
debugPrint('Camera access denied');
} else {
// 카메라 오류
debugPrint('Camera error: $e');
}
});
// 카메라 설정 완료
isLoaded = controller.value.isInitialized;
canTakePicture = isLoaded;
notifyListeners();
}
@override
void dispose() {
// 카메라 controller 해제
controller.dispose();
super.dispose();
}
/// 사진 촬영
/// 촬영한 사진을 임시 저장 디렉토리에 저장 후 주소 반환
Future<void> takePicture(BuildContext context) async {
// 카메라 init 오류 또는 사진 찍는 중
if (!controller.value.isInitialized || controller.value.isTakingPicture) {
return;
}
try {
// 임시 저장 장소와 이름 생성
Directory directory = await getTemporaryDirectory();
final imgPath = '${directory.path}/${DateTime.now()}.jpeg';
// 사진 촬영
final XFile file = await controller.takePicture();
await file.saveTo(imgPath);
if (context.mounted) Navigator.pop(context, imgPath);
} catch (e) {
// 사진 촬영 오류
debugPrint('Error taking picture');
}
}
/// 플래시 토글
void toggleFlash() {
if (isFlashOn) {
controller.setFlashMode(FlashMode.off);
isFlashOn = false;
} else {
controller.setFlashMode(FlashMode.always);
isFlashOn = true;
}
notifyListeners();
}
/// 카메라 전환
void changeCameraDirection() {
if (controller.description.lensDirection == CameraLensDirection.back) {
controller.setDescription(_cameras[0]);
} else {
controller.setDescription(_cameras[1]);
}
}
}
이제 앱을 실행하고 카메라를 실행해 보도록 하겠습니다. 사진을 찍어 보면 홈 화면에 사진이 업데이트되는 것을 볼 수 있을 겁니다.
camera 패키지를 잘 활용하면 다양한 기능들을 추가할 수 있습니다. 줌 기능뿐 아니라 인스타처럼 필터 기능을 추가하는 것도 가능합니다. 다음에 기회가 된다면 필터와 관련된 포스트도 작성해 보겠습니다.
'프로그래밍 > 플러터' 카테고리의 다른 글
플러터 GoRouter를 사용한 MVVM 아키텍처 적용 (0) | 2024.09.15 |
---|---|
Homebrew를 사용한 맥북 플러터 설치하기 (0) | 2024.08.09 |
플러터 다크모드 설정 페이지 만들기 (0) | 2024.07.10 |
플러터 시작하기 05. 버튼 종류를 알아보자! (0) | 2024.05.16 |
플러터 시작하기 02. Appbar 꾸미기 (0) | 2024.05.14 |