Flutter Camera 패키지를 이용하여 사진 촬영 및 갤러리 저장 기능 구현하기
Flutter Camera Plugin 을 사용하여 사진 촬영 및 저장을 해봅시다.
camera plugin은 flutter 공식 카메라 플러그인으로
iOS, Android, Web에서의 카메라 접근을 지원하는 플러그인입니다.
이를 이용하여 사진을 찍고 갤러리에 저장하는 간단한 카메라 안드로이드 앱을 만들어 보겠습니다.
camera | Flutter package
A Flutter plugin for controlling the camera. Supports previewing the camera feed, capturing images and video, and streaming image buffers to Dart.
pub.dev
패키지 설치
1. Dependency 설정
카메라 플러그인을 설치합니다.
flutter pub add camera
버전은 다음과 같이 설정했습니다.
environment:
sdk: ^3.5.3
dependencies:
flutter:
sdk: flutter
camera: ^0.11.0+2
코드 설명
1. Main
메인 위젯의 전체 코드를 먼저 보겠습니다.
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:practice_flutter_camera/views/camera_screen/camera_screen.dart';
void main() async {
// 플러터 엔진 초기화를 기다림
WidgetsFlutterBinding.ensureInitialized();
// 기기의 카메라 목록을 가져옴
final cameras = await availableCameras();
runApp(MainScreen(cameras: cameras));
}
class MainScreen extends StatelessWidget {
final List cameras;
const MainScreen({
super.key,
required this.cameras,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CameraScreen(
cameras: cameras,
),
);
}
}
availableCameras() 함수를 사용하여 기기의 모든 카메라 목록에 접근이 가능합니다.
비동기 함수이므로 WidgetFlutterBinding.ensureInitialized() 함수를 사용하여
플러터 엔진이 완전히 초기화된 후에 실행하여야 합니다.
해당 함수가 실행되면 앱 시작 시 하단 이미지처럼 마이크 및 카메라 권한을 요구합니다.
원래라면 안드로이드 앱의 경우 마이크 및 카메라를 사용하기 위해서
app/src/profile/AndroidManifest.xml 에서 아래와 같이 권한 관련 설정을 해주어야 합니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 카메라 관련 권한 설정 -->
<uses-permission android:name="android.permission.CAMERA" />
</manifest>
하지만 권한 설정을 하지 않았는데 불구하고 카메라 기능을 정상적으로 사용할 수 있는 것처럼 보입니다.
그 이유는 플러터 카메라 플러그인을 사용하면
빌드 시 자동으로 필요한 권한 관련 처리를 해주기 때문에 권한 관련 설정을 할 필요가 없습니다.
아래 경로에서 빌드된 앱의 Manifest를 확인할 수 있습니다.
build/app/intermediates/merged_manifest/debug/AndroidManifest.xml
파일을 확인해 보면 자동으로 마이크 및 카메라 권한 관련 설정이 되어있는 것을 확인할 수 있습니다.
또한 스토리지 관련 권한이 포함되어 있는 것을 확인할 수 있습니다.
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
2. CameraPreview
카메라 플러그인은 CameraPreview 위젯을 제공하여 다양한 기능을 제공합니다.
내부 코드를 살펴보겠습니다. 다양한 기능이 있는데 몇 가지 추려보겠습니다.
- 카메라 위젯을 생성합니다.
- 기기의 회전 방향을 검사하여 화면을 회전하고 비율을 조정할 수 있습니다.
- 카메라 위에 오버레이를 추가할 수 있습니다.
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../camera.dart';
/// A widget showing a live camera preview.
class CameraPreview extends StatelessWidget {
/// Creates a preview widget for the given camera controller.
const CameraPreview(this.controller, {super.key, this.child});
/// The controller for the camera that the preview is shown for.
final CameraController controller;
/// A widget to overlay on top of the camera preview
final Widget? child;
@override
Widget build(BuildContext context) {
return controller.value.isInitialized
? ValueListenableBuilder(
valueListenable: controller,
builder: (BuildContext context, Object? value, Widget? child) {
return AspectRatio(
aspectRatio: _isLandscape()
? controller.value.aspectRatio
: (1 / controller.value.aspectRatio),
child: Stack(
fit: StackFit.expand,
children: [
_wrapInRotatedBox(child: controller.buildPreview()),
child ?? Container(),
],
),
);
},
child: child,
)
: Container();
}
Widget _wrapInRotatedBox({required Widget child}) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return child;
}
return RotatedBox(
quarterTurns: _getQuarterTurns(),
child: child,
);
}
bool _isLandscape() {
return [
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
].contains(_getApplicableOrientation());
}
int _getQuarterTurns() {
final Map<DeviceOrientation, int> turns = <DeviceOrientation, int>{
DeviceOrientation.portraitUp: 0,
DeviceOrientation.landscapeRight: 1,
DeviceOrientation.portraitDown: 2,
DeviceOrientation.landscapeLeft: 3,
};
return turns[_getApplicableOrientation()]!;
}
DeviceOrientation _getApplicableOrientation() {
return controller.value.isRecordingVideo
? controller.value.recordingOrientation!
: (controller.value.previewPauseOrientation ??
controller.value.lockedCaptureOrientation ??
controller.value.deviceOrientation);
}
}
3. CameraController
CameraPreview 위젯을 사용할 경우 플러그인에서 제공하는 CameraController를 선언해야 합니다.
컨트롤러가 initialized 상태가 아닐 경우 빈 컨테이너를 반환합니다.
(new) CameraController CameraController(
CameraDescription description,
ResolutionPreset resolutionPreset, {
bool enableAudio = true,
int? fps,
int? videoBitrate,
int? audioBitrate,
ImageFormatGroup? imageFormatGroup,
})
CameraController를 생성할 때 대상 카메라와 해상도, fps, 비트레이트, 오디오 활성화 여부를 지정할 수 있습니다.
Main 스크립트에서 불러온 카메라의 리스트 중 하나를 인자로 받아 컨트롤러를 생성할 수 있습니다.
해상도의 경우 ResolutionPreset Enum 타입으로 설정할 수 있으며 프리셋은 다음과 같습니다.
- ResolutionPreset.low: 낮은 해상도 (240p)
- ResolutionPreset.medium: 중간 해상도 (480p)
- ResolutionPreset.high: 높은 해상도 (720p)
- ResolutionPreset.veryHigh: 매우 높은 해상도 (1080p)
- ResolutionPreset.ultraHigh: 초고해상도 (2160p)
- ResolutionPreset.max: 기기의 최대 해상도
해상도에 따라 CameraPreview 위젯 크기가 바뀔 수 있습니다.
CameraController를 선언하는 Controller를 생성해 보겠습니다.
class CameraScreenController {
late CameraController _controller;
late Future _initializeControllerFuture;
late List _cameras;
late final void Function(Function()) setState;
// 카메라 선택 기능을 추가하기 위해 카메라 인덱스를 전달합니다.
int _currentCameraIndex = 0;
CameraController get controller => _controller;
Future get initializeControllerFuture => _initializeControllerFuture;
// 카메라 컨트롤러를 선언합니다.
void initState(
List cameras,
void Function(Function()) setState,
) {
this.setState = setState;
_cameras = cameras;
_controller = CameraController(
// 컨트롤러에서는 사용 가능한 카메라 목록 중 하나를 인자로 받습니다.
_cameras[_currentCameraIndex],
ResolutionPreset.medium,
);
// 컨트롤러를 `CameraPreview` 위젯에 할당하기 위해서
// `initialize()` 함수를 사용하여 `initialized` 상태로 변환해야합니다.
_initializeControllerFuture = _controller.initialize();
}
// ...
}
해당 Controller를 CameraPreview 위젯에 할당할 수 있습니다.
이때, 상태 값 변경을 감지하기 위해 CameraPreview 위젯을 FutureBuilder 위젯으로 감싸줍니다.
컨트롤러가 initialized 될 때까지 로딩창을 대신 출력합니다.
FutureBuilder(
future: _controller.initializeControllerFuture,
builder: (_, snapshot) {
// 카메라 초기화가 완료되면 CameraPreview 위젯 출력
if (snapshot.connectionState == ConnectionState.done) {
return CameraPreview(_controller.controller);
}
// 로딩 출력
else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
기능 구현
1. 사진 촬영하기
CameraController의 takePicture 함수를 사용하면 쉽게 사진을 촬영할 수 있습니다.
사진 촬영을 위한 플로팅 버튼을 생성하여 눌렀을 때 해당 함수가 실행되면 기능이 동작합니다.
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () async {
await _controller.takePicture();
},
child: const Text("사진 찍기"),
),
],
);
takePicture() 함수 구현은 아래와 같습니다.
// 사진 찍기 함수
Future takePicture() async {
try {
await _initializeControllerFuture;
final image = await _controller.takePicture();
log(image.path);
} catch (e) {
log(e.toString());
}
}
버튼을 누를 경우 takePicture() 함수가 실행되어 XFile 타입의 이미지 객체를 반환합니다.
로그를 통해 해당 이미지 경로를 확인할 수 있습니다.
아래와 같이 /data/user/0/ 로 시작하는 디렉터리에 이미지 파일이 생성된 것을 확인할 수 있습니다.
[log] /data/user/0/com.example.practice_flutter_camera/cache/CAP5678386206346795107.jpg
[log] /data/user/0/com.example.practice_flutter_camera/cache/CAP3212924364063168023.jpg
그러나 갤러리에서 이미지를 확인해 보면 해당 이미지가 존재하지 않는 것처럼 보입니다.
Flutter path_provider 패키지 정리
Flutter path_provider 패키지 정리Flutter path_provider 패키지를 알아봅시다.path_provider 는 현재 앱의 경로를 반환하는 패키지입니다. path_provider | Flutter packageFlutter plugin for getting commonly used locations on host p
karina-winter.tistory.com
해당 디렉터리가 앱 전용 캐시 디렉터리이기 때문에 다른 앱에서는 접근이 불가능하기 때문입니다.
따라서 공유 가능한 외부 저장소[갤러리]로 이미지를 저장해야 합니다.
2. 이미지를 갤러리에 저장하기
이전에는 이미지를 외부 저장소에 저장하는 패키지
image_gallery_saver 또는 gallery_saver를 사용하여 이미지를 외부 저장소에 저장할 수 있었습니다.
다만 현재[2025. 02] 안드로이드 정책이 변경되면서
해당 패키지를 사용할 경우 아래처럼 빌드 에러가 발생합니다.
Incorrect package="com.example.imagegallerysaver" found in source AndroidManifest.xml: /Users/amuz/.pub-cache/hosted/pub.dev/image_gallery_saver-2.0.3/android/src/main/AndroidManifest.xml.
Setting the namespace via the package attribute in the source AndroidManifest.xml is no longer supported.
Recommendation: remove package="com.example.imagegallerysaver" from the source AndroidManifest.xml: /Users/amuz/.pub-cache/hosted/pub.dev/image_gallery_saver-2.0.3/android/src/main/AndroidManifest.xml.
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':image_gallery_saver:processDebugManifest'.
> A failure occurred while executing cohttp://m.android.build.gradle.tasks.ProcessLibraryManifest$ProcessLibWorkAction
Incorrect package="com.example.imagegallerysaver" found in source AndroidManifest.xml: /Users/amuz/.pub-cache/hosted/pub.dev/image_gallery_saver-2.0.3/android/src/main/AndroidManifest.xml.
Setting the namespace via the package attribute in the source AndroidManifest.xml is no longer supported.
Recommendation: remove package="com.example.imagegallerysaver" from the source AndroidManifest.xml: /Users/amuz/.pub-cache/hosted/pub.dev/image_gallery_saver-2.0.3/android/src/main/AndroidManifest.xml.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 724ms
Running Gradle task 'assembleDebug'... 1,207ms
Error: Gradle task assembleDebug failed with exit code 1
Android Gradle Plugin(AGP) 7.0.0 이상에서는
AndroidManifest.xml 파일에서 package 속성을 설정하는 것이 더 이상 지원되지 않습니다.
대신, build.gradle 파일에서 namespace 속성을 사용해야 합니다.
해당 패키지들이 최신 gradle 규칙을 반영하지 못하여 발생하는 에러입니다.
패키지의 AndroidManifest를 변경하는 것도 하나의 해결 방법이지만
Flutter image_gallery_saver 패키지 빌드 에러 해결방법
Flutter image_gallery_saver 패키지 빌드 에러 해결방법image_gallery_saver 패키지 빌드 에러 시 발생하는 에러를 해결해 봅시다.image_gallery_saver는 flutter에서 미디어 파일을 갤러리에 저장하는 기능을 제공
karina-winter.tistory.com
MethodChannel로 안드로이드 네이티브 기능을 사용하여 직접 기능을 구현할 수 있습니다.
플러터 안드로이드 네이티브 기능을 호출하기 위한 클래스를 생성합니다.
import 'dart:developer';
import 'package:flutter/services.dart';
class ImageSaver {
static const platform = MethodChannel('com.example.gallery/saveImage');
static Future saveImageToGallery(String imagePath) async {
try {
final result = await platform.invokeMethod(
'saveImage',
{
'imagePath': imagePath,
},
);
log(result);
} on PlatformException catch (e) {
log("Failed to save image: '${e.message}'.");
}
}
}
android/app/src/main/kotlin/.../MainActivity.kt 파일에서
네이티브 기능 관련 코드를 작성합니다.
package com.example.practice_flutter_camera
import android.content.ContentValues
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.provider.MediaStore
import android.widget.Toast
import android.media.ExifInterface
import androidx.activity.ComponentActivity
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.gallery/saveImage"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 권한 요청
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION\_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
// MethodChannel 설정
MethodChannel(flutterEngine?.dartExecutor!!, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "saveImage") {
val imagePath = call.argument<String>("imagePath")
if (imagePath != null) {
saveImageToGallery(imagePath)
result.success("Image saved to gallery")
} else {
result.error("UNAVAILABLE", "Image path not provided", null)
}
} else {
result.notImplemented()
}
}
}
private fun saveImageToGallery(imagePath: String) {
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "image_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/YourAppFolder")
}
val contentResolver = contentResolver
val uri: Uri? = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
uri?.let {
contentResolver.openOutputStream(it)?.use { outputStream ->
// 스마트폰 이미지 파일에는 EXIF 라는 메타데이터가 존재합니다.
// 사진 파일에 대한 정보를 포함하는데 해당 데이터를 이용하여 이미지의 회전 방향을 알 수 있습니다.
val bitmap = BitmapFactory.decodeFile(imagePath)
val exif = ExifInterface(imagePath)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val rotatedBitmap = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f)
ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f)
ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f)
else -> bitmap
}
rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
Toast.makeText(this, "이미지가 갤러리에 저장되었습니다.", Toast.LENGTH_SHORT).show()
}
} ?: run {
Toast.makeText(this, "이미지 저장 실패", Toast.LENGTH_SHORT).show()
}
}
// 이미지를 회전시키는 함수
private fun rotateBitmap(bitmap: Bitmap, degrees: Float): Bitmap {
val matrix = android.graphics.Matrix()
matrix.postRotate(degrees)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
작성한 네이티브 코드를 반영합니다.
// CameraScreenController
// 사진 찍기 함수
Future takePicture() async {
try {
await _initializeControllerFuture;
final image = await _controller.takePicture();
// 컨트롤러에서 생성한 이미지 객체의 path를 전달하여 외부 저장소로 저장합니다.
ImageSaver.saveImageToGallery(image.path);
} catch (e) {
log(e.toString());
}
}
전체 코드
https://github.com/JisooOvO/Practice-Flutter-Camera
GitHub - JisooOvO/Practice-Flutter-Camera: 플러터 카메라 플러그인 연습
플러터 카메라 플러그인 연습. Contribute to JisooOvO/Practice-Flutter-Camera development by creating an account on GitHub.
github.com
'Flutter > Package' 카테고리의 다른 글
[Flutter] path_provider 패키지 정리 (1) | 2025.02.16 |
---|---|
[Flutter] image_gallery_saver 패키지 빌드 에러 해결방법 (0) | 2025.02.16 |