안드로이드/앱개발(Android)

(코틀린 kotlin) 전자액자 앱

김염인 2022. 1. 21. 23:09

목차

  1. 인트로 (완성앱 & 구현 기능 소개)
  2. Android 기기 권한 받아오기
  3. 권한을 통해 사진 가져오기
  4. 사진을 가져와서 목록 구성하기
  5. 액자 화면 구성하기
  6. Activity Lifecycle을 알아보고 완성도 높이기
  7. 아웃트로

결과화면

이 챕터를 통해 배우는 것

전자액자

저장소 접근 권한을 이용하여 로컬 사진을 로드 할 수 있음.

추가한 사진들을 일정한 간격으로 전환하여 보여줄 수 있음.

 


  • Android Permission 사용하기

1. 첫번째

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

Manifast에 READ_EXTERNAL_STORAGE의 Permission을 추가한다.

private fun initAddPhotoButton(){
    addPhotoButton.setOnClickListener{ //사진 추가 버튼을 클릭 할 시 !
        when{
            ContextCompat.checkSelfPermission( // Android에서 권한 보유 여부를 확인하기 위해서는 ContextCompat.checkSelfPermission() 메서드를 사용하면 된다
                this,
                android.Manifest.permission.READ_EXTERNAL_STORAGE
            ) == PackageManager.PERMISSION_GRANTED -> {
                // todo 권한이 잘 부여 됐을 때 갤러리에서 사진을 선택하는 기능
                navigatePhotos()
            }
            shouldShowRequestPermissionRationale(android.Manifest.permission.READ_EXTERNAL_STORAGE) -> {
                // todo 교육용 팝업 확인 후 권환 확인을 띄우는 문
                showPermissionContextPopup()
            }
            else -> {
                requestPermissions(arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE), 1000)
            // 권한 허가
            }
        }
    }
}

Android에서 권한 보유 여부를 확인하기 위해서는 ContextCompat.checkSelfPermission() 메서드를 사용하면 된다. 위의 코드는 사진 추가 버튼을 클릭 할시 발생 할 이벤트를 설정해준 MainActivity.kt 의 코드이다. 권한이 잘 부여 됐을때 when을 이용하여 판단 후 추가 실행을 시켜준다.

 

2. 사용하려는 권한을 이미 부여 받았다면 권한 요청을 다시 하지는 않습니다.

ContextCompat.checkSelfPermission() 메서드를 사용하여 앱에 이미 권한을 부여 받았는지 확인을 할 수 있습니다 호출 결과로는 PERMISSION_GRANTED 또는 PERMISSION_DENIED를 반환 받게 됩니다.

public static int checkSelfPermission (Context context, String permission)

 

3. shouldShowRequestPermissionRationale() 메서드는 사용자가 이전에 권한 요청을 거부한 경우 true 값을 넘겨주게 되어 있습니다 그 결과를 이용하여 앱을 사용하려면 권한이 필요함을 사용자에게 알려 주는 안내를 추가 해야 합니다.

여기서는 shouldShowRequestPermissionRationale() true값을 넘겨주게 되면 밑에 있는 코드가 실행 된다.

private  fun showPermissionContextPopup(){
    AlertDialog.Builder(this)
        .setTitle("권한이 필요 합니다.")
        .setMessage("전자액자에서 사진을 불러오기 위해 권한이 필요 합니다.")
        .setPositiveButton("권한 허가하기",{_, _ ->
            requestPermissions(arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE), 1000)
        })
        .setNegativeButton("취소하기",{_, _ ->})
        .create()
        .show()
}

 

4. 권한 요청은 메소드를 호출 하면서 필요한 권한을 적어주면 됩니다 요청 하려는 권한이 한개 이상이면 String 배열에 죽 기입해 주면 되고 너무 많으면 배열을 별도로 작성해서 추가해도 됩니다.

static void requestPermissions(Activity activity, String[] permissions, int requestCode)

 

Parameters
Activity Activity
permissions String[p] : 필요한 권한 명칭들
requestCode 실행 후 전달 받을 코드

requestCode는 개발자가 임의로 만들어 놓은 코드를 말하고 메소드가 void를 리턴하는데 전달받으려는 값을 설정 한다는 건 메소드 실행 후 onRequestPermissionsResult() 메소드를 이용하여 결과 값을 넘겨 주게 됩니다.

 - 요청 권한이 한개인 경우

 

static final int PERMISSIONS_REQUEST_READ_LOCATION = 0x00000001;
 
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
                        PERMISSIONS_REQUEST_READ_LOCATION);

 - 요청 권한이 2개 이상인 경우

 

static final int PERMISSIONS_REQUEST_READ_LOCATION = 0x00000001;
private String[] PERMISSIONS = {
    Manifest.permission.ACCESS_COARSE_LOCATION,
    Manifest.permission.ACCESS_FINE_LOCATION
};
ActivityCompat.requestPermissions(this,PERMISSIONS,PERMISSIONS_REQUEST_READ_LOCATION);
 

 

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    when(requestCode){
        1000 -> {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED){
                navigatePhotos()
            }
            else{
                Toast.makeText(this,"권한을 거부하였습니다", Toast.LENGTH_SHORT).show()
            }
        }
        else -> {
            //
        }
    }
}

private fun navigatePhotos(){
    val intent = Intent(Intent.ACTION_GET_CONTENT) // 앨범을 호출할 때, 인텐트에 넣는 액션은 크게 2가지가 있다.
    intent.type = "image/*"
    startActivityForResult(intent,2000)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if(resultCode != Activity.RESULT_OK){
        return
    }
    when(requestCode){
        2000 -> {
            val selectedImageUri : Uri? = data?.data

            if (selectedImageUri != null){
                if(uriImageList.size == 6){
                    Toast.makeText(this,"이미 사진이 꽉 찼습니다.", Toast.LENGTH_SHORT).show()
                    return
                }
                uriImageList.add(selectedImageUri)
                imageViewList[uriImageList.size-1].setImageURI(selectedImageUri)
            }
            else{
                Toast.makeText(this,"사진을 가져오기 못하였습니다.", Toast.LENGTH_SHORT).show()
            }
        }
        else -> {
            Toast.makeText(this,"사진을 가져오기 못하였습니다.", Toast.LENGTH_SHORT).show()
        }
    }
}

 

onRequestPermissionResult()와 onActivityResult()를 override 해준다.

 

onRequestPermissionResult

requestCode는 requestPermissions으로 부터 받은 requestCode이며 어떤 요청인지 분기처리하게끔 할 수 있다.

또한 두번째 param permissions는 requestPermissions로 부터 요청받은 권한의 종류를 그대로 배열의 형태로 돌려 받는 것이며, grantResult는 0또는 -1의 값을 해당 권한을 승인했을 때의 0, 거절했을 때 -1을 반환하며 두번째 param의 배열과 1:1 대응을 갖는다.

onActivityResult 란 ?

예를들어 액티비티 main이 있고 sub가 있다.

main액티비티에서 sub액티비티를 호출하여 넘어갔다가, 다시 main 액티비티로 돌아올때 사용되는 기본 메소드 이다.

sub액티비티에서 뒤로가기버튼을 만들던 핸드폰 내에있는 뒤로가기 버튼을 누르던 onActivityResult() 메소드는 실행이 된다.

 

- Layout 을 그리는 법

LinearLayout을 화면에 그릴 때 높이를 설정을 원하는 비율 만큼 해주기 Ratio를 사용해준다. 

Ratio

위젯의 치수(dimension) 대한 비율을 이용하여 다른 부분의 치수를 지정할 수도 있습니다. 비율을 사용하기 위해서는 최소한 하나의 치수(dimension)를 0dp(MATCH_CONSTRAINT)로 지정해야만 합니다. 그리고 layout_constraintDimensionRatio를 사용하여 비율을 지정하면 됩니다. 비율을 입력하는 방식에는 아래와 같이 두 가지의 방식이 있습니다.

- app:layout_constraintDimensionRatio="1:1" (width:height로 표현하는 방법)
- app:layout_constraintDimensionRatio="1.0" (width와 height의 비율을 float값으로 표현하는 방법)
- app:layout_constraintDimensionRatio = "H, x:y" (width를 constraint에 맞춰 설정한 뒤 비율에 따라 높이를 결정합니다)
- app:layout_constraintDimensionRatio = "W, x:y" (height를 constraint에 맞춰 설정한 뒤 비율에 따라 높이를 결정합니다)
    • 가로 화면으로 그리기
<activity
    android:name=".photoFrameActivity"
    android:screenOrientation="landscape"/>

Mainifast에 Activity를 추가 할때 screenOrientaition 을 landscape로 설정해준다.

 

  • View Animation 사용하기
private fun startTimer(){
    timer = timer(period = 5 * 1000){
        runOnUiThread{
            val current = currentPosition

            val next = if(photoList.size <= currentPosition + 1) {
                0
            } else currentPosition + 1

            backgroundPhotoImageView.setImageURI(photoList[current])

            photoImageView.alpha = 0f // 투명도를 0으로 준다..! 그럼 보이지 않음
            photoImageView.setImageURI(photoList[next])
            photoImageView.animate()
                .alpha(1.0f)
                .setDuration(1000)
                .start()

            currentPosition = next
        } // 메인 쓰레드로 변경 해주어야 한다.
    }
}

위의 코드는 View Animation을 표현하기 위한 코드이다. 일단 timer를 5초 동안 실행시키고 runonUiThread를 이용해 Thread를 이용한 실시간 반응형 앱을 만들어 준다. 야기서 current 에 현재 이미지뷰의 인덱스 값을 넣어준다. 그리고 다음 인덱스를 사이즈 초과시 0으로 바뀌게끔 넣어준다. 그리고 backgroundImageView에 photoList[current]를통해 intent를 받아온 이미지를 넣어준다. 이때. 

photoImageView.alpha 즉 투명도를 0으로 설정 해주면 보이지가 않게 되고, setImageUri를 통해 pthoList[next]값을 photoImageview에 할당해준다. 그러면 백그라운드에는 기존 current imageView가 있고 다음 이미지는 투명도 0인상태로 존재하게 된다. 그리고 animate()를 통해 .alpha(1.0f) 투명도를 1.0f까지 올려주고, setDuration(1000) 1초동안 .start() 실행 시킨다. 이렇게 되면 메인쓰레드에서 실행이 되어 실시간으로 변경되게끔 보인다.

 

Activity Lifecycle 알아보기

 활동 수명 주기 개념

활동 수명 주기 단계 간에 전환하기 위해 활동 클래스는 6가지 콜백으로 구성된 핵심 집합의 onCreate(), onStart(), onResume(), onPause(), onStop(), onDestroy()를 제공합니다. 활동이 새로운 상태에 들어가면 시스템은 각 콜백을 호출합니다.

그림 1은 이 패러다임을 시각적으로 나타낸 것입니다.

그림 1. 활동 수명 주기를 간략하게 표현한 그림

사용자가 활동을 벗어나기 시작하면 시스템은 활동을 해체할 메서드를 호출합니다. 어떤 경우에는 부분적으로만 해체하기도 합니다. 이때 활동은 여전히 메모리 안에 남아 있으며(예: 사용자가 다른 앱으로 전환할 경우) 포그라운드로 다시 돌아올 수 있습니다. 사용자가 해당 활동으로 돌아오는 경우 사용자가 종료한 지점에서 활동이 다시 시작됩니다. 몇 가지 예외를 제외하고 앱은 백그라운드에서 실행될 때 활동을 실행할 수 없습니다.

시스템은 그 시점의 활동 상태에 따라 특정 프로세스와 그 안의 활동을 함께 종료할지 여부를 결정합니다. 활동 상태 및 메모리에서 제거는 활동 상태와 제거 취약성과의 관계에 관한 자세한 정보를 제공합니다.

활동의 복잡도에 따라, 모든 수명 주기 메서드를 구현할 필요가 없는 경우도 있습니다. 하지만 각각의 수명 주기 메서드를 이해하고, 사용자가 예상한 대로 앱이 동작하도록 필요한 수명 주기 메서드를 구현하는 것이 중요합니다.

이 문서의 다음 섹션에서는 상태 간 전환을 처리할 때 사용하는 콜백에 관한 자세한 내용을 다룹니다.

 

override fun onStop(){
    super.onStop()

    timer?.cancel()
}

override fun onStart() {
    super.onStart()

    startTimer()
}

override fun onDestroy() {
    super.onDestroy()

    timer?.cancel()
}

여기서는 onStop onStart onDestroy를 overide하여 timer의 시작 과 종료를 설정해준다.

 

파일 읽기

먼저 Selector 화면을 띄워야 합니다.

다음은 Selector Activity를 실행하는 코드입니다.

val READ_REQUEST_CODE: Int = 42

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {    // 1
    addCategory(Intent.CATEGORY_OPENABLE)   // 2
    type = "image/*"    // 3
}

startActivityForResult(intent, READ_REQUEST_CODE)   // 4

위의 "// 1"와 같이 주석으로 표시한 코드는 아래에 자세히 설명하였습니다.

  1. 파일을 열 때 Intent.ACTION_OPEN_DOCUMENT 액션을 사용합니다.
  2. 열 수 있는 파일들만 보고 싶을 때 Intent.CATEGORY_OPENABLE 를 카테고리로 넣어줍니다.
  3. 타입과 일치하는 파일들만 필터링해서 보여줍니다. "image/*" 타입으로 설정하면 이미지 파일만 보여줍니다.
  4. 인텐트로 실행된 화면에서 파일을 선택하면 그 파일의 Uri가 내 앱의 액티비티로 전달됩니다.

위의 코드를 실행하면 다음과 같은 화면이 나옵니다.

선택된 파일의 Uri 리턴 받기

이미지를 하나 선택하면 그 파일의 Uri가 앱으로 전달됩니다. 앱은 다음과 같은 코드로 Uri를 받을 수 있습니다.

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    super.onActivityResult(requestCode, resultCode, resultData)

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")   // 1
            dumpImageMetaData(uri)    // 2
            showImage(uri)    // 3
        }
    }
}
  1. Uri 정보를 스트링으로 출력합니다.
  2. Image의 MetaData를 출력합니다.
  3. 이미지 파일을 읽어서 액티비티에 출력합니다.

MetaData

Uri로 MetaData는 다음과 같이 가져올 수 있습니다.

fun dumpImageMetaData(uri: Uri) {
    val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null)
    cursor?.use {
        if (it.moveToFirst()) {
            val displayName: String =
                it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))  // 1
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)  // 2
            val size: String = if (!it.isNull(sizeIndex)) {
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}
  1. ContentResolver에 쿼리하여 가져온 데이터에서 OpenableColumns.DISPLAY_NAME 컬럼으로 Display Name을 가져올 수 있습니다. Display Name은 파일명입니다.
  2. OpenableColumns.SIZE 는 파일의 사이즈(Byte)를 가져오는데 사용하는 칼럼입니다.

Uri와 MetaData를 로그로 출력하면 다음과 같이 보입니다.

OpenExampleActivity: Uri: content://com.android.providers.media.documents/document/image%3A45
OpenExampleActivity: Display Name: matterhorn-4535693_1920.jpg
OpenExampleActivity: Size: 862778

이미지를 화면에 출력

Uri에서 이미지 파일을 읽고 액티비티의 ImageView에 출력하는 코드는 다음과 같습니다.

private fun showImage(uri: Uri) {
    GlobalScope.launch {    // 1
        val bitmap = getBitmapFromUri(uri)    // 2
        withContext(Dispatchers.Main) {
            mainImageView.setImageBitmap(bitmap)    // 3
        }
    }
}

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
    val parcelFileDescriptor: ParcelFileDescriptor? = contentResolver.openFileDescriptor(uri, "r")
    val fileDescriptor: FileDescriptor = parcelFileDescriptor!!.fileDescriptor
    val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor.close()
    return image
}
  1. 이미지를 읽는 작업은 오래걸리기 때문에 coroutine을 이용하여 다른 쓰레드에서 작업하도록 하였습니다.
  2. ContentResolver를 통해 Uri의 File descriptior를 가져와서 이미지 파일을 읽었습니다. 이미지는 Bitmap으로 변환하였습니다.
  3. bitmap을 ImageView에 set하였습니다. UI작업은 main thread에서 해야 하기 때문에 Dispatchers.Main에서 작업하도록 하였습니다.

결과를 보면 다음처럼 이미지가 화면에 출력됩니다.

이런식으로 SAF로 파일을 열고, 파일의 데이터를 읽을 수 있습니다.