개발/Flutter

Flutter - 코딩마스터하기|카메라 페이지 만들기

ffuny 2021. 11. 3. 20:59
반응형

카메라 스크린 만들기

카메라 페이지 만들고, 새창으로 띄우기

우선 screens 폴더에 camera_screen.dart 파일을 만들고 stf로 생성해준다. bottom navigation에서 가운데 버튼을 누르면 카메라 스크린이 윗부분에 뜨는게 아닌 새로 카메라 페이지를 생성되게 해줄것이기 때문에, _onBtnItemClick에서 카메라 index인 2일때 새창을 띄워 주도록 하자. 전에 배웠던 Navigator.of(context).push(MaterialPageRoute(builder: (context) => CameraScreen(),),) 을 이용해서 새창으로 CameraScreen을 불러오자. 여기서는 push를 이용하자. 그리고 이 Navigator 부분을 method 로 refactor해주자.


  void _onBtnItemClick(int index) {
    switch (index) {
      case 2:
        _openCamera();
        break;
      default:
        setState(() {
          // 이 안에서만 상태가 바뀜 이 클래스 안에서만 사용가능
          _selectedIndex = index;
        });
    }
  }

  void _openCamera() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => CameraScreen(),
      ),
    );
  }

BottomNavigationBar without Icon

우선 BottomNavigationBar를 쓰기 위해서 camera_screen에 Container 을 Scaffold로 바꿔주고  bottomNavigationBar 옵션에 BottomNavigationBar를 넣어준다. 그리고 items의 []에 BottomNavigationBarItem을 만들어 주면된다. 우선 icon에는 아무 icon이나 넣어준다. 아이콘 크기를 0으로 해서 안보이게 할 것이기 때문에 아무 아이콘이나 한다. 그리고 title이 GALLERY, PHOTO, VIDEO 를 각각 해준다. (현재는 title말고 label로 추천한다. title은 비권장으로 바뀜) 그리고 BottomNavigationBar에  iconSize를 0으로 해주고 selectedLabelStyle를 bold로 해준다. selectedItemColor은 black로 해주고 unselectedItemColor을 black54로 해주면 된다. 그리고 각 버튼이 눌릴 때마다 바뀌어야 하므로 state class 내의 상단에 int _currentIndex = 0 으로 처음 버튼을 실행 상태로 해준다. 그리고 BottomNavigationBar에 onTap에서 (index)를 해주고 {}안에 setState로 _currentIndex = index를 해주면 해당 바 아이템이 눌릴때마다 바뀌는 것을 알 수 있다. 그리고 버튼이 눌릴때마다 보일 페이지를 만들어 주기 위해 Scaffold에 body에 PageView를 만들어 주고 children에 Container을 3가지 다른 색으로 만들어 준다.


class _CameraScreenState extends State<CameraScreen> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(
        children: [
          Container(
            color: Colors.cyanAccent,
          ),
          Container(
            color: Colors.amberAccent,
          ),
          Container(
            color: Colors.greenAccent,
          ),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        iconSize: 0,
        selectedLabelStyle: TextStyle(
          fontWeight: FontWeight.bold,
        ),
        selectedItemColor: Colors.black,
        unselectedItemColor: Colors.black54,
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'GALLERY',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'PHOTH',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'VIDEO',
          ),
        ],
        currentIndex: _currentIndex,
        onTap: _onItemTabbed,
      ),
    );
  }

  void _onItemTabbed(index) {
    setState(() {
      _currentIndex = index;
    });
  }
}

Link Pageview and BottomNavBar

PageView를 컨트롤 하기 위해서 PageController을 사용해야 한다. 그래서 state class의 상단에 PageController _pageController = PageController();을 생성해주고 PageView 내의 controller 옵션에 넣어준다. 그리고 Controller을 생성했으면 memory leak을 해결해주기 위한 dispose도 생성해주면 된다. 이를 위해서 _onItemTabbed method안에 setState 안에 _pageController.animateToPage를 사용해준다.옵션없이 _currentIndex를 넣어주고 duration옵션에 duration을 넣어주고 curve에는 fastOutSlowIn을 사용해주면된다. 클릭을 하면 화면이 옆으로 잘 넘어가는 것을 알 수 있다. 그리고 PageView는 스크롤해서 페이지를 변경할 수 있는데 이렇게 하면 BottomNavigationBar가 안변하는것을 볼 수있다. 이를 위해서는 PageView 옵션에 onPageChanged에 onTab처럼 (index)를 받아와서 setState에 _currentIndex = index를 해주면 BottomNavigationBar도 잘 작동하는 것을 볼 수 있다.


class _CameraScreenState extends State<CameraScreen> {
  int _currentIndex = 0;
  PageController _pageController = PageController();

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(
        controller: _pageController,
        children: [
          Container(
            color: Colors.cyanAccent,
          ),
          Container(
            color: Colors.amberAccent,
          ),
          Container(
            color: Colors.greenAccent,
          ),
        ],
        onPageChanged: (index){
          setState(() {
            _currentIndex = index;
          });
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        iconSize: 0,
        selectedLabelStyle: TextStyle(
          fontWeight: FontWeight.bold,
        ),
        selectedItemColor: Colors.black,
        unselectedItemColor: Colors.black54,
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'GALLERY',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'PHOTH',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'VIDEO',
          ),
        ],
        currentIndex: _currentIndex,
        onTap: _onItemTabbed,
      ),
    );
  }

  void _onItemTabbed(index) {
    setState(() {
      _currentIndex = index;
      _pageController.animateToPage(_currentIndex, duration: duration, curve: Curves.fastOutSlowIn,);
    });
  }
}

 


camera screen appbar with back button

앱바를 넣어줄 것이다. appbar에 AppBar를 만들어 주고 title에 Text('Photo')로 해준다. 실행해보면 앱바가 만들어 졌는데 만들지 않은 백 버튼이 있다. 그 이유는 home_page에서 push로 만들어줘서 새로 생성하며 기존을 뒤로 보내고 새로운것을 위로 올려서 돌아가기 버튼이 자동으로 생성된 것이다. pushReplacement를 하면 생기지 않는다. 그리고 앱바의 title를 버튼에 따라 바꾸고 싶은데 이를 위해서 상단에 Sting _title로 만들고 가운데부터 시작해주기 위해서 'PHOTO'로 해준다. 그리고 onPageChanged의 swith안에 0일때는 GALLERY 1일때는 PHOTO 2일때는 VIDEO로 해준다. 그리고 상단의 _currentIndex를 1로 해주고 실행하면 앱바나 버튼은 PHOTO에서 시작하는데 body부분이 0부터 시작하는것을 볼 수 있다. 이부분은 PageController에서 initialPage를 1로 해주면 된다. 그러면 정상 작동 하는 것을 알 수 있다.


class _CameraScreenState extends State<CameraScreen> {
  int _currentIndex = 1;
  PageController _pageController = PageController(initialPage: 1,);
  String _title = 'PHOTO';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_title),
      ),
      body: PageView(
        controller: _pageController,
        children: [
          Container(
            color: Colors.cyanAccent,
          ),
          Container(
            color: Colors.amberAccent,
          ),
          Container(
            color: Colors.greenAccent,
          ),
        ],
        onPageChanged: (index) {
          setState(() {
            _currentIndex = index;
            switch (_currentIndex) {
              case 0:
                _title = 'GALLERY';
                break;
              case 1:
                _title = 'PHOTO';
                break;
              case 2:
                _title = 'VIDEO';
                break;
            }
          });
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        iconSize: 0,
        selectedLabelStyle: TextStyle(
          fontWeight: FontWeight.bold,
        ),
        selectedItemColor: Colors.black,
        unselectedItemColor: Colors.black54,
        items: [
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'GALLERY',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'PHOTH',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.forward),
            label: 'VIDEO',
          ),
        ],
        currentIndex: _currentIndex,
        onTap: _onItemTabbed,
      ),
    );
  }

  void _onItemTabbed(index) {
    setState(() {
      _currentIndex = index;
      _pageController.animateToPage(
        _currentIndex,
        duration: duration,
        curve: Curves.fastOutSlowIn,
      );
    });
  }
}

take photo widget layout

사진 찍는 부분을 만들어보자. body에서 Container 중 photo쪽인 가운데의 Container을 flutter widget로 refactor 해준뒤 widgets 폴더에 take_photo.dart를 만들어주고 widget를 이동시켜 준다. 그리고 Container 부분을 Column으로 바꿔주고 사진찍는 부분은 1:1 비율이기 때문에 Container로 만들어준 후 색을 black width와 height는 size.width로 1:1 비율로 만들어 준다. 그리고 그 아래에 사진 찍는 버튼은 우선 Expanded로 해준 뒤 그 안에 Container을 넣어준다. 그리고 decoration에 ShapeDecoration으로 넣어서 shape에 CircleBorder를 넣어주면 동그랗게 만들 수 있다. side에 BorderSide를 넣어주고 color옵션에 black12를 넣어주면 동그라미가 나온다. 선이 너무 얇으므로 width를 20정도 준다. 그리고 동그라미가 너무 붙어있으므로 Container에 Padding를 common_s_gap만큼 준다. 또한 버튼으로 사용하기 위해서 Padding를 InkWell로 감싸고 onTap을 주면 된다.(그런데 여기서 전체가 다 버튼이 된다. 내생각엔 Container을 InkWell로 하면 더 좋을꺼 같다.) 다른 방법은 똑같이 Expanded로 한후 OutlineButton으로 만드는 것이다. shape를 CircleBorder()을 주고 borderSide는 전과 똑같이 BorderSide에 color은 black12, width는 20으로 주면 버튼만 눌리는게 만들어 진다.


class TakePhoto extends StatelessWidget {
  const TakePhoto({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: size.width,
          height: size.width,
          color: Colors.black,
        ),
        // Expanded(
        //   child: InkWell(
        //     onTap: (){},
        //     child: Padding(
        //       padding: const EdgeInsets.all(common_s_gap),
        //       child: Container(
        //         decoration: ShapeDecoration(
        //           shape: CircleBorder(side: BorderSide(color: Colors.black12,width: 20,),),
        //         ),
        //       ),
        //     ),
        //   ),
        // ),
        Expanded(
            child: OutlineButton(
          onPressed: () {},
          shape: CircleBorder(),
          borderSide: BorderSide(color: Colors.black12, width: 20),
        )),
      ],
    );
  }
}

camera plugin setup

pub.dev에서 camera plugin을 상요할 것인데 nullsafety가 없는 버전인, camera: ^0.7.0+4 를 사용할 것이다. minSdkVersion 21로 바꿔줘야 한다. 우선 pubspec.yaml에 dependencies에 camera: ^0.7.0+4 를 추가해주고, ios는 info.plist 파일을 열어서</dist> 위에 <key>NSCameraUsageDescription</key> <string>Can I use the camera please?</string> <key>NSMicrophoneUsageDescription</key> <string>Can I use the mic please?</string>를 넣어주면 된다. android는 android / app / build.gradle가 있는데 여기에서 minSdkVersion을 21로 해주면 된다.

 

permission handler 사용법

카메라나 마이크 사용할때 필요한 권한을 받기위한 라이브러리를 사용할꺼임 permission_handler 라는 라이브러리를 사용하면됨. null safety전 최신 버전인 permission_handler: ^5.1.0+2를 pubspec.yaml에 넣어주면됨. 그리고 이를 위한 method를 만들어야 함. home_page.dart 에서 state class 내에 Futuer<bool>로 만들어 줄것임. 그리고 bool permitted로 해서 기본을 true로 하고 하나라도 권한이 허락이 안되면 false로 바꿔줌. 이것을 _onpnCamera에서 if 문에 넣어주면 됨.


갑자기 이렇게 한 후부터 에러가남....흠....

해결법! 현재 새 프로젝트를 생성해서 맞지 않은 버전들이 있음.

1. andriod / app / buil.gradle 에서 compileSdkVersion 28, targetSdkVersion 28 버전으로 바꿈

2. android / build.gradle 에서 classpath 'com.android.tools.build:gradle:3.5.0' 으로 변경

이렇게 해결 함.


그리고 권한을 허용하지 않을 시 SnackBar를 보여줄 것임. if문에 else를 만들어 주고 SnackBar snackBar = SnackBar();를 해주고 content에 Text로 권한허용시에만 카메라 사용가능하다는 문구를 준 후, action에 SnackBarAction()를 넣어준다. 그리고 그 안에 label에는 OK, onPressed도 (){} 빈걸로 생성해주면 된다. 그리고 SnackBar는 Scaffold에서 context를 받아서 써야 한다. 우선 Scaffold.of(context).showSnackBar(snackBar)로 생성해준다. (현재는 showSnackBar는 권장사항이 아님. 이부분 학습 필요) 실행해보면 거부를 누르면 에러가 나는 것을 알 수 있다.  해결하기 위해서는 state class의 상단에 GlobalKey<ScaffoldState> _key = GlobalKey<ScaffoldState>(); 를 생성 해주고 Scaffold 안에 key에 _key를 넣어준 후, SnackBar를 생성하는 부분의 Scaffold.of(context)를 _key.currentState로 변경해주면 된다. onPressed안에는 버튼을 누르면 꺼지게 hideCurrentSnackBar()로 해주면 된다.


class HomePage extends StatefulWidget {
//   밑의 btmNavItems때문에 빨간줄 생김 const 변화하지 않을것이라 생각되고 생성했는데 밑에 btmNavItems가 변화해서 그럼
//   const HomePage({
//     Key key,
// }) : super(key: key);

  HomePage({Key key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<BottomNavigationBarItem> btmNavItems = [
    const BottomNavigationBarItem(
      icon: Icon(Icons.home),
      label: "",
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.search),
      label: "",
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.add),
      label: "",
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.healing),
      label: "",
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.account_circle),
      label: "",
    ),
  ];

  int _selectedIndex = 0;
  GlobalKey<ScaffoldState> _key = GlobalKey<ScaffoldState>();

  static List<Widget> _screens = [
    //노란줄일때 final을 추천함. 강의에서는 static인데 그냥 final쓰면 될듯
    FeedScreen(),
    Container(
      color: Colors.redAccent,
    ),
    Container(
      color: Colors.greenAccent,
    ),
    Container(
      color: Colors.deepPurpleAccent,
    ),
    ProfileScreen(),
  ];

  @override
  Widget build(BuildContext context) {
    if (size == null) {
      size = MediaQuery.of(context).size;
    }
    return Scaffold(
      key: _key,
      //body: _screens[_selectedIndex], //이렇게 할대 문제점은 위에 각각의 스크린에다가 복잡하게 위젯을 하면 힘들어짐 그래서 다른것으로 써야함
      body: IndexedStack(
        //이렇게 하면 화면이 스택으로 쌓여있고 선택된 index가 맨위로 올라오는 형태임 각각의 창들도 관리하기 쉬워짐
        index: _selectedIndex,
        children: _screens,
      ),
      bottomNavigationBar: BottomNavigationBar(
        showSelectedLabels: false,
        showUnselectedLabels: false,
        items: btmNavItems,
        unselectedItemColor: Colors.grey,
        selectedItemColor: Colors.black87,
        currentIndex: _selectedIndex,
        onTap: _onBtnItemClick,
      ),
    );
  }

  void _onBtnItemClick(int index) {
    switch (index) {
      case 2:
        _openCamera();
        break;
      default:
        setState(() {
          // 이 안에서만 상태가 바뀜 이 클래스 안에서만 사용가능
          _selectedIndex = index;
        });
    }
  }

  void _openCamera() async {
    if (await checkIfPermissionGranted(context)) {
      Navigator.of(context).push(
        MaterialPageRoute(
          builder: (context) => CameraScreen(),
        ),
      );
    } else {
      SnackBar snackBar = SnackBar(
        content: Text('사진, 파일, 마이크 접근 허용 해주셔야 카메라 사용 가능합니다!'),
        action: SnackBarAction(
          label: 'OK',
          onPressed: () {
            _key.currentState.hideCurrentSnackBar();
          },
        ),
      );
      _key.currentState.showSnackBar(snackBar);
    }
  }

  Future<bool> checkIfPermissionGranted(BuildContext context) async {
    Map<Permission, PermissionStatus> statuses =
        await [Permission.camera, Permission.microphone].request();
    bool permitted = true;
    statuses.forEach((permission, permissionStatus) {
      if (!permissionStatus.isGranted) {
        permitted = false;
      }
    });
    return permitted;
  }
}

퍼미션 거부 후 설정창 띄우기

앱 세팅을 위해서 app settings 라이브러리를 사용해서 설정창으로 보내줘보자. 우선 세팅창의 권한을 위해서 android / app src / main의 AndroidManifest.xml에 <application>위에 <uses-permission android:name="android.permission.CAMERA" /> 를 추가해서 권한을 받을 수 있게 해주자. 그리고 스낵바를 끄는 부분 아래에 AppSettings.openAppSettings();를 해주면 된다. 간단해서 코드는 생략.


camera preview

take_photh는 카메라가 작동하면 frame으로 움직이므로 statefulWidget로 변경해줘야한다. 그리고 state class 상단에 CameraController _cameraController;을 만들어준다. 그리고 Column을 FutureBuilder<List<CameraDescription>>으로 감싸주고 future 는 availableCameras()를 넣어준다 기존 Column은 builder에 (context, snapshot){}를 해주고 {}안에 return으로 넣어주면 된다. 그리고 그 안의 Container에 사진을 보여줄 _getPreview를 메소드로 만든다._getPreview는(snapshot.data)를 받는다. _getPreview는 (List<CameraDescription> cameras)를 받고 그 안에서 _cameraController = CameraController(camera[0], ResoutionPreset.medium) 을 줘서 카메라가 보이게 해준다. 그리고 return을 FutureBuilder을 주고 future에 _cameraController.initialize()로 시작해준다. builder에는(context, snapshot)를 주고{}에 if(snapshot.connectionState == ConnectionState.done)로 연결이 되었을때 보이게 해준다. return으로 CameraView(_cameraController);을 주면 연결이정상으로 되면 해당 창에 보이게 해준다. 그리고 else에 return MyProgressIndicator로 연결이 안되있을땐 로딩창이 나오게 해준다. 하지만 여기서 로딩창을 2번 쓰지 않기 위해서 state class에 Widget _progress = MyProgressIndicator();를 해주고 Container에 child에 (snapshot.hasData)로 정보가 있을때는 _getPreview(shapshot.data)로 화면을 보여주고 아니면 _progress로 로딩창을 보여주게 한다. 상당히 이해가 어려운 부분이므로 그냥 하도록 하면 된다.


class _TakePhotoState extends State<TakePhoto> {
  CameraController _cameraController;
  Widget _progress = MyProgressIndicator();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<CameraDescription>>(
        future: availableCameras(),
        builder: (context, snapshot) {

          return Column(
            children: [
              Container(
                width: size.width,
                height: size.width,
                color: Colors.black,
                child: (snapshot.hasData)?_getPreview(snapshot.data):_progress,
              ),
              // Expanded(
              //   child: InkWell(
              //     onTap: (){},
              //     child: Padding(
              //       padding: const EdgeInsets.all(common_s_gap),
              //       child: Container(
              //         decoration: ShapeDecoration(
              //           shape: CircleBorder(side: BorderSide(color: Colors.black12,width: 20,),),
              //         ),
              //       ),
              //     ),
              //   ),
              // ),
              Expanded(
                  child: OutlineButton(
                onPressed: () {},
                shape: CircleBorder(),
                borderSide: BorderSide(color: Colors.black12, width: 20),
              )),
            ],
          );
        });
  }

  Widget _getPreview(List<CameraDescription> cameras) {
    _cameraController = CameraController(cameras[0], ResolutionPreset.medium);

    return FutureBuilder(
        future: _cameraController.initialize(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.done) {
            return CameraPreview(_cameraController);
          }else{
            return _progress;
          }
        });
  }
}

crop camera preview

지금까지 그대로 사용하면 화면이 눌려보인다. 이를 해결해보자. CameraPreview 를 Container로 감싸주고 width를 size.width로 height를 size.width/_cameraContriller.value.aspectratio(아마 현재 폰의 카메라 비율인듯) 를 해주면 된다. 거기에 FittedBox로 감싸주고 fit을 Boxfit.fitWidth로 해준다.(내폰에서는 크기가 더 눌리고 맞지않아서 fill로 바꿔줌). 이거를 다시한번 OverflowBox로 감싸주고 alignment를 center로 해준다. OverflowBox는 크기가 넘어서 화면밖으로 나가게 해주는 것이다. 그리고 ClipRect로 감싸줘서 나간 부분을 잘라주는 형식으로 만들어 주면 카메라가 눌린 느낌이 없어진다.


  Widget _getPreview(List<CameraDescription> cameras) {
    _cameraController = CameraController(cameras[0], ResolutionPreset.medium);

    return FutureBuilder(
        future: _cameraController.initialize(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.done) {
            return ClipRect(// Overflow 된 부분을 잘라줌
              child: OverflowBox( // 밖에는 사이즈가 size.width인데 이거밖으로 나가게 해주는 것임.
                alignment: Alignment.center,
                child: FittedBox(
                  fit: BoxFit.fill,
                  child: Container(
                    width: size.width,
                    height: size.width / _cameraController.value.aspectRatio,
                    child: CameraPreview(_cameraController),
                  ),
                ),
              ),
            );
          } else {
            return _progress;
          }
        });
  }

 

반응형