Flutter - Camera - 카메라 사진 찍기, 카메라 커스텀 UI 만들기 (CameraPreview), 사진 저장하기

반응형

 

Overview

이번 포스팅에서는...

  1. 카메라를 사용하여 카메라에 보이는 화면을 앱에 표시하고
  2. 카메라의 화면 그대로를 사진 이미지로 저장하여
  3. 디바이스의 특정 경로에 이미지를 저장하는 것을

목표로합니다.

 

 

  • 포스팅에서 설명하는 프로젝트는 아래의 깃허브 링크에서 다운받으실 수 있습니다.

https://github.com/luvris2/flutter-example/tree/main/flutter_camera_preview_test


필요한 라이브러리 추가

camera

설치

  • 제공되있는 takePicture 함수를 이용해 카메라를 통해 사진을 촬영 기능을 사용하기 위함
  • CameraPreview를 이용해서 카메라 촬영 UI를 커스텀하기 위해 사용
  • 터미널에서 아래의 명령어 실행
flutter pub add camera

 

사용 설정 - iOS

  • (프로젝트 디렉토리에서) ios - Runner - Info.plist 파일 열기
  • 아래의 내용 추가
<key>NSCameraUsageDescription</key>
<string>your usage description here</string>
<key>NSMicrophoneUsageDescription</key>
<string>your usage description here</string>

 

사용 설정 - Android

  • (프로젝트 디렉토리에서) android - app - build.gradle 파일 열기
  • Sdk 최소 버전을 21 이상으로 설정
minSdkVersion 21


사진 저장을 위한 권한 설정

iOS

확인해보지 않았습니다.

추 후 iOS 환경으로 설정 시 추가 수정하도록하겠습니다.

 

Android

  • (프로젝트 디렉토리 내에서) android - app - src - main - AndroidManifest.xml
  • <application> 코드 위에 외부 저장소 사용 권한을 허용해주는 코드 추가
<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"/>

 

  • <applicaiton> 코드 내에 외부 저장소 규칙 설정 코드 추가
<!-- requestLegacyExternalStorage : 외부 저장소 액세스 방식 -> 기존의 파일 액세스 방식 사용 -->
android:requestLegacyExternalStorage="true"
<!-- preserveLegacyExternalStorage : 외부 저장소 액세스 권한 유지 -> 권한을 유지하여 앱 정상 작동-->
android:preserveLegacyExternalStorage="true"


코딩

특정 영역에 카메라를 찍을 수 있도록 카메라 촬영 뷰를 커스터마이징해봅시다.

커스텀 UI 카메라 뷰를 구현하기 위해 바텀 네비게이션 바를 사용하여 명확하게 카메라 뷰 영역을 구분지어줄 예정이며,

바텀 네비게이션 바는 커스텀 UI 카메라 뷰를 보여주기 위한 예시이므로 따로 기능 구현은 하지 않습니다.

 

카메라 사용을 위한 기본 뼈대 정의하기

camera 가이드 문서에 나와 있는 샘플 예제 소스로 뼈대를 구성하였습니다.

기본 뼈대는 1) 카메라 사용가능한 장치가 있는지 확인하고,

2) 사용 가능한 카메라 장치로 카메라 컨트롤러를 구성합니다.

구성된 3) 카메라 컨트롤러를 이용하여 CameraPreview 위젯을 통해 카메라의 촬영 화면을 앱으로 보여줍니다.

 

  1. 앱 실행 시 main 함수에서 사용가능한 카메라가 있는지 확인합니다.
    • await availableCameras()
  2. 사용가능한 카메라 장치의 목록을 리스트 타입의 변수에 저장합니다.
    • List<CameraDescription> _cameras : _cameras 는 사용자가 지정한 임의의 변수 이름입니다.
  3. initState 메서드에서 카메라 컨트롤러(CameraController)를 정의해줍니다.
    • _cameras[0] : 사용가능한 첫 번째 카메라 장치를 의미합니다. 혹은 _cameras.first 로도 사용 가능합니다.
    • ResolutionPreset.max : 촬영할 사진의 품질을 의미합니다. low, medium, high, max 등 옵션으로 조정 가능합니다.
    • enabledAudio : 비디오 녹화 시 카메라에서 오디오를 녹음할지 여부를 정의합니다. true(사용), false(사용안함)
  4. 이어서 카메라 컨트롤러 초기화 설정을 진행해줍니다.
    • 이 포스팅은 카메라프리뷰(커스텀 UI) 구현이 목적이므로 해당 부분은 따로 코딩하지 않습니다.
  5. 빌드 영역에서 사용 가능한 카메라가 있는지 체크합니다. 카메라 사용이 가능할 경우, 카메라 화면을 화면에 보여줍니다.
// 사용가능한 카메라 장치의 목록을 저장하는 변수
late List<CameraDescription> _cameras;

Future<void> main() async {
  // 앱이 실행되기 전에 필요한 초기화 작업을 수행하는 메서드
  // main 함수에서만 호출 가능
  // 사용가능한 카메라를 확인하기 위함
  WidgetsFlutterBinding.ensureInitialized();

  // 사용 가능한 카메라 확인
  _cameras = await availableCameras();
  
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: SafeArea(child: CameraApp()),
      ),
    ),
  );
}

class CameraApp extends StatefulWidget {
  const CameraApp({super.key});

  @override
  State<CameraApp> createState() => CameraAppState();
}

class CameraAppState extends State<CameraApp> {
  // 카메라 컨트롤러 인스턴스 생성
  late CameraController controller;

  @override
  void initState() {
    super.initState();
    // 카메라 컨트롤러 초기화
    // _cameras[0] : 사용 가능한 카메라
    controller =
        CameraController(_cameras[0], ResolutionPreset.max, enableAudio: false);

    controller.initialize().then((_) {
      // 카메라가 작동되지 않을 경우
      if (!mounted) {
        return;
      }
      // 카메라가 작동될 경우
      setState(() {
        // 코드 작성
      });
    })
        // 카메라 오류 시
        .catchError((Object e) {
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            print("CameraController Error : CameraAccessDenied");
            // Handle access errors here.
            break;
          default:
            print("CameraController Error");
            // Handle other errors here.
            break;
        }
      }
    });
  }

  @override
  void dispose() {
    // 카메라 컨트롤러 해제
    // dispose에서 카메라 컨트롤러를 해제하지 않으면 에러 가능성 존재
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 카메라 컨트롤러가 초기화 되어 있지 않을 경우, 카메라 뷰 띄우지 않음
    if (!controller.value.isInitialized) {
      return Container();
    }
    // 카메라 촬영 화면
    return CameraPreview(controller);
  }
}

카메라 촬영 기능 구현하기 (+ 사진 저장)

camera 라이브러리에는 자체적으로 사진을 찍어주는 메서드가 내장되어 있습니다.

이 포스팅에서는 'takePicture()' 메서드를 이용하여 사진 찍는 것을 구현합니다.

또한, 촬영한 사진을 저장하기 위하여 path_provider 라이브러리를 사용하여 임시 파일을 생성할 수 있습니다.

해당 라이브러리는 직접적인 저장소에 저장하는 것이 아닌 캐싱 파일로 생성하기 때문에

앱이 종료되면 이미지 파일도 같이 사라지므로 이 포스팅에서는 사용하지 않습니다.

 

  1. 버튼을 누르면 사진을 찍을 액션을 취할 함수를 정의해줍니다. (Future<void> _takePicture() async)
    • 사진을 찍고 파일을 저장하기 위해 await를 사용하여 비동기식 프로그래밍으로 코드가 작성되어야 합니다.
    • 함수 정의시 async 키워드를 사용하여 비동기 함수임을 정의해줍니다.
  2. CameraController의 takePicture() 메서드를 이용하여 카메라로 보여지는 화면을 촬영합니다.
    • final XFile file = await controller.takePicture() : 촬영한 사진을 XFile 타입의 변수에 저장합니다.
  3. 촬영한 사진을 저장할 특정 경로를 지정합니다.
    • dart:io 라이브러리의 Directory 를 사용하여 경로를 생성해줍니다.
    • Directory('경로') : 최상위 루트의 경로는 'storage/emulated/0/'
    • 카메라 앨범이 존재하는 DCIM 폴더 안에 이미지를 저장할 경우에는 'storage/emulated/0/DCIM/~경로' 로 지정하면 됩니다.
  4. 지정한 경로에 사진을 저장합니다.
    • XFile 로 생성된 이미지 파일은 임시 파일이기 때문에 앱을 종료하면 사라지므로 특정 경로로 복사를 해줍니다.
    • await File(file.path).copy('${directory.path}/${file.name}') : 지정한 경로와 파일이름으로 사진 파일을 복사합니다.
    • .copy(경로) 메소드 : 경로와 파일 이름을 원하는 형태로 변경하여 복사할 수 있습니다.
  // 사진을 찍는 함수
  Future<void> _takePicture() async {
    if (!controller.value.isInitialized) {
      return;
    }

    try {
      // 사진 촬영
      final XFile file = await controller.takePicture();

      // import 'dart:io';
      // 사진을 저장할 경로 : 기본경로(storage/emulated/0/)
      Directory directory = Directory('storage/emulated/0/DCIM/MyImages');

      // 지정한 경로에 디렉토리를 생성하는 코드
      // .create : 디렉토리 생성    recursive : true - 존재하지 않는 디렉토리일 경우 자동 생성
      await Directory(directory.path).create(recursive: true);

      // 지정한 경로에 사진 저장
      await File(file.path).copy('${directory.path}/${file.name}');
    } catch (e) {
      print('Error taking picture: $e');
    }
  }

카메라 UI 커스텀하기

기본 뼈대와 사진 촬영, 저장 기능을 구현하였으므로 이제는 간단히 카메라 촬영 인터페이스를 구성하고 커스텀해봅시다.

커스텀 인터페이스는 개발자가 원하는 형태에 따라 각자 다르게 구성되므로 이 포스팅에서는 간단한 구현을 위한 기본 형태만 제공합니다.

 

  1. 기본 뼈대에서 정의한 CameraView 위젯의 카메라 영역과 다른 UI를 같이 배치할 수 있도록 빌드 영역에 위젯을 추가합니다.
  2. 하단의 메뉴 이동을 위해 간단한 Bottom Navigation Bar 를 사용합니다.
  3. 사진을 찍는 함수를 통해 사진을 찍고 저장하기 위해 버튼을 추가하여 _takePicture 함수와 연결해줍니다.
    • 이 포스팅에서는 GestureDetector 위젯을 사용하여 아이콘에 버튼 클릭 이벤트를 넣었습니다.

 

  • 바텀 네비게이션 바 정의
    • 간단한 바텀 네비게이션 바 버튼 2개 추가
  runApp(
    MaterialApp(
      home: Scaffold(
        body: const SafeArea(child: CameraApp()),
        // 바텀 네비게이션 바 정의
        bottomNavigationBar: BottomNavigationBar(items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: "테스트1"),
          BottomNavigationBarItem(icon: Icon(Icons.home), label: "테스트2"),
        ]),
      ),
    ),
  );

 

  • 카메라 UI 커스터마이징
    • CameraView와 다른 위젯을 사용하여 커스텀 UI를 구성합니다.
    • Stack 위젯 안에 정의할 위젯들을 정의하여 위치를 지정해줍니다.
      (Stack 외 다른 위젯을 설명하는 포스팅이 아니므로 자세한 부분은 생략하도록 합니다.
      이 부분은 알아서 센스껏!)
  @override
  Widget build(BuildContext context) {
    // 카메라가 준비되지 않으면 아무것도 띄우지 않음
    if (!controller.value.isInitialized) {
      return Container();
    }
    // 카메라 인터페이스와 위젯을 겹쳐 구성할 예정이므로 Stack 위젯 사용
    return Stack(
      children: [
        // 화면 전체를 차지하도록 Positioned.fill 위젯 사용
        Positioned.fill(
          // 카메라 촬영 화면이 보일 CameraPrivew
          child: CameraPreview(controller),
        ),
        // 하단 중앙에 위치도록 Align 위젯 설정
        Align(
          alignment: Alignment.bottomCenter,
          child: Padding(
              padding: const EdgeInsets.all(15.0),
              // 버튼 클릭 이벤트 정의를 위한 GestureDetector
              child: GestureDetector(
                onTap: () {
                  // 사진 찍기 함수 호출
                  _takePicture();
                },
                // 버튼으로 표시될 Icon
                child: const Icon(
                  Icons.camera_enhance,
                  size: 70,
                  color: Colors.white,
                ),
              )),
        ),
      ],
    );
  }

실행

  • 앱 실행 화면
    • 하얀색 카메라 아이콘을 누르면 사진 촬영 후 기기 내의 사진 저장

 

  • 사진 촬영 후 사진이 저장된 화면
    • 지정한 DCIM - MyImages 에 촬영한 사진들이 저장되어 있는 것을 확인

 

  • 실제 휴대폰에서도 저장이 잘 되는 것을 확인하였다.


전체 소스 코드

import 'dart:io';

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

// 사용가능한 카메라 장치의 목록을 저장하는 변수
late List<CameraDescription> _cameras;

Future<void> main() async {
  // 앱이 실행되기 전에 필요한 초기화 작업을 수행하는 메서드
  // main 함수에서만 호출 가능
  // 사용가능한 카메라를 확인하기 위함
  WidgetsFlutterBinding.ensureInitialized();

  // 사용 가능한 카메라 확인
  _cameras = await availableCameras();

  runApp(
    MaterialApp(
      home: Scaffold(
        body: const SafeArea(child: CameraApp()),
        // 바텀 네비게이션 바 정의
        bottomNavigationBar: BottomNavigationBar(items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: "테스트1"),
          BottomNavigationBarItem(icon: Icon(Icons.home), label: "테스트2"),
        ]),
      ),
    ),
  );
}

/// CameraApp is the Main Application.
class CameraApp extends StatefulWidget {
  /// Default Constructor
  const CameraApp({super.key});

  @override
  State<CameraApp> createState() => CameraAppState();
}

class CameraAppState extends State<CameraApp> {
  // 카메라 컨트롤러 인스턴스 생성
  late CameraController controller;

  @override
  void initState() {
    super.initState();
    // 카메라 컨트롤러 초기화
    // _cameras[0] : 사용 가능한 카메라
    controller =
        CameraController(_cameras[0], ResolutionPreset.max, enableAudio: false);

    controller.initialize().then((_) {
      // 카메라가 작동되지 않을 경우
      if (!mounted) {
        return;
      }
      // 카메라가 작동될 경우
      setState(() {
        // 코드 작성
      });
    })
        // 카메라 오류 시
        .catchError((Object e) {
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            print("CameraController Error : CameraAccessDenied");
            // Handle access errors here.
            break;
          default:
            print("CameraController Error");
            // Handle other errors here.
            break;
        }
      }
    });
  }

  // 사진을 찍는 함수
  Future<void> _takePicture() async {
    if (!controller.value.isInitialized) {
      return;
    }

    try {
      // 사진 촬영
      final XFile file = await controller.takePicture();

      // import 'dart:io';
      // 사진을 저장할 경로 : 기본경로(storage/emulated/0/)
      Directory directory = Directory('storage/emulated/0/DCIM/MyImages');

      // 지정한 경로에 디렉토리를 생성하는 코드
      // .create : 디렉토리 생성    recursive : true - 존재하지 않는 디렉토리일 경우 자동 생성
      await Directory(directory.path).create(recursive: true);

      // 지정한 경로에 사진 저장
      await File(file.path).copy('${directory.path}/${file.name}');
    } catch (e) {
      print('Error taking picture: $e');
    }
  }

  @override
  void dispose() {
    // 카메라 컨트롤러 해제
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 카메라가 준비되지 않으면 아무것도 띄우지 않음
    if (!controller.value.isInitialized) {
      return Container();
    }
    // 카메라 인터페이스와 위젯을 겹쳐 구성할 예정이므로 Stack 위젯 사용
    return Stack(
      children: [
        // 화면 전체를 차지하도록 Positioned.fill 위젯 사용
        Positioned.fill(
          // 카메라 촬영 화면이 보일 CameraPrivew
          child: CameraPreview(controller),
        ),
        // 하단 중앙에 위치도록 Align 위젯 설정
        Align(
          alignment: Alignment.bottomCenter,
          child: Padding(
              padding: const EdgeInsets.all(15.0),
              // 버튼 클릭 이벤트 정의를 위한 GestureDetector
              child: GestureDetector(
                onTap: () {
                  // 사진 찍기 함수 호출
                  _takePicture();
                },
                // 버튼으로 표시될 Icon
                child: const Icon(
                  Icons.camera_enhance,
                  size: 70,
                  color: Colors.white,
                ),
              )),
        ),
      ],
    );
  }
}

참고

 

반응형