=== 전자 액자 ===
기능 소개
안드로이드 기기에 있는 사진 파일들을 보여줍니다.
3초마다 자동으로 사진이 슬라이드 됩니다.
핵심이 되는 주제
프로바이더를 이용해 사진 데이터를 조회
외부 메모리 데이터로의 접근 권한 요청
프래그먼트 생성
Glide 라이브러리 사용
뷰 페이저, 페이저 어댑터의 사용
1) 프로젝트 생성
프로젝트 명 : MyGallery
Anko 라이브러리 설정할 것
2) 프로바이더 사용
컨텐츠 프로바이더는 데이터베이스, 파일, 네트워크등의 데이터를 앱들에게 공유해 주는 컴포넌트다.
사진을 촬영하면 사진 정보가 안드로이드 미디어 데이터베이스에 저장되는데,
이 사진 정보를 '콘텐츠 프로바이더(Contents Provider)'를 통해 얻어올 수 있다.
(단, 사진 정보는 외부저장소에 저장되므로 반드시 외부저장소 읽기 권한 요청 필수!)
콘텐츠 프로바이더를 포함해 안드로이드 기기에는 중요한 4가지 컴포넌트들이 있다.
※ 안드로이드 기기의 4대 컴포넌트
액티비티 (화면 구성)
콘텐츠 프로바이더 (데이터베이스, 파일, 네트워크 데이터 제공)
브로드캐스트 리시버 (앱이나 기기에서 발송하는 데이터를 수신)
서비스 (화면에 표시되지는 않고 백그라운드에서 동작)
※ 안드로이드 기기의 2가지 저장소
내부 저장소 (OS가 설치되어 있으며 유저가 접근할 수 없는 시스템 영역)
외부 저장소 (유저 영역)
※ 안드로이드 기기의 권한의 종류 2가지
보통(normal) 권한 - 매니페스트에 권한 추가 (예: 인터넷 사용 권한)
위험(dangerous) 권한 - 매니페스트에 권한 추가 + 앱 실행시 사용자의 승인 필수 (예: 외부 저장소 읽기 권한)
주의) 위험 권한을 사용하는 앱의 경우, 이미 사용자의 승인을 얻었다고 해도 권한과 관련된 기능 사용시
매번 사용자 승인완료 상태를 체크해야 한다. 왜냐하면 사용자가 언제든 권한을 취소할 수 있기 때문.
자주 사용되는 위험 권한들의 예)
STORAGE |
READ_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE |
LOCATION |
ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION |
SMS |
SEND_SMS RECEIVE_SMS |
CAMERA |
CAMERA |
※ 사진 정보 가져오기 구현 (콘텐츠 프로바이더 사용법임)
① 아래 getAllPhotos()의 *1처럼 contentResolver 객체로 Cursor라는 객체를 얻어오면 그 안에
사진 데이터가 담겨져 옴
class MainActivity : AppCompatActivity() {
private val REQUEST_READ_EXTERNAL_STORAGE = 1000
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
// 권한 승인이 안되어 있는 경우
if (ActivityCompat.shouldShowRequestPermissionRationale(this,
Manifest.permission.READ_EXTERNAL_STORAGE)) { // true: 거부한 적이 있음
// 이전에 이미 권한 거부가 있었을 경우 설명 (Anko 라이브러리를 쓰면 편하다)
alert("사진을 표시하려면 외부 저장소 권한이 필요합니다!", "권한이 필요한 이유") {
yesButton {
ActivityCompat.requestPermissions( // 권한 요청
this@MainActivity,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
REQUEST_READ_EXTERNAL_STORAGE) // 권한 요청에 대한 분기 처리를 위해
// 만든 적당한 정수 값임
}
noButton { }
}.show()
} else {
// 이전에 권한 거부가 없었을 경우 권한 요청
ActivityCompat.requestPermissions(this,
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
REQUEST_READ_EXTERNAL_STORAGE)
}
} else {
// 권한이 이미 승인되어 있는 상태
getAllPhotos()
}
}
private fun getAllPhotos() {
val photosCursor = contentResolver.query( // *1
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, // 가져올 데이터의 URI
// EXTERNAL_CONTENT_URI 는 외부 저장소를 의미함
null, // 가져올 항목들을 문자열 배열로 지정 (null : 모든 항목을 가져옴)
null, // 조건1 (null : 전체 데이터)
null, // 조건2 (조건1과 조합하여 조건 지정)
MediaStore.Images.ImageColumns.DATE_TAKEN + " DESC") // 정렬방법 (촬영날짜 내림차순)
// 이 코드는 사진이 제대로 읽어지는지 로그로 확인해 보려고 작성
if (photosCursor != null) { // photosCursor == null 이면 사진이 없는 것임
while(photosCursor.moveToNext()) { // PhotosCursor 객체 내부에 데이터 이동용 포인터가 있음
val uri = photosCursor.getString(
photosCursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
// 사진의 경로가 저장된 데이터베이스의 컬럼명은 DATA 상수에 정의되어 있음
Log.d("MainActivity", uri) // 안드로이드 스튜디오의 Logcat에서
// MainActivity로 필터링 했을 때 사진의 URI가 표시됨
}
photosCursor.close() // 이 객체를 더 이상 사용하지 않으므로 닫아줘야 함 (메모리 누수 방지)
}
}
}
② 매니페스트에 외부 저장소 읽기 권한 설정
-- AndroidManifest.xml --
<manifest ...>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
일단 이 단계에서 실행해보자. 아직 사진이 표시되지는 않지만 Logcat에 사진 경로가 표시될 것이다.
(사진이 많으면 시간이 좀 걸릴 수도 있음)
3) 전자 액자 구현
사진들을 좌우로 돌려볼 수 있도록 '프래그먼트UI' 요소와 'ViewPage'로 구현.
메모리 관리와 성능 향상을 위해서 'Glide' 라이브러리를 활용.
※ 프래그먼트 (Fragment; 파편)
웹 페이지 프레임처럼 화면에 구획한 프레임을 말하며 한 번 프레임을 짜놓으면 재사용도 가능하다.
액티비티처럼 생명주기를 가지고 있는데 액티비티보다 더욱 세분화되어 있다.
※ 본 예제에서 사용할 세 가지 메서드
onCreate()
프래그먼트 생성시 호출 (레이아웃 완성 전)
onCreateView()
프래그먼트에 표시할 뷰를 레이아웃 파일에서 읽어 옴
(레이아웃 완성 전)
onViewCreated()
생명주기에는 포함되지 않는 특별 메서드
완성된 레이아웃 뷰를 받아 이벤트 처리
※ 참고
onAttach() : 액티비티에 붙을 때 호출
onActivityCreate() : 액티비티의 onCreate() 실행 직후 호출
onStart() : 프래그먼트가 사용자에게 보여질 때 호출
onResume() : 사용자와 상호작용 시작
onPause() : 프래그먼트 일시중지 (사용자와 상호작용 안함)
onStop() : 프래그먼트 중지
onDestroyView() : 프래그먼트 자원 해제
onDestroy() : 프래그먼트 제거
onDetach() : 프래그먼트가 액티비티에서 완전히 제거
① 프래그먼트 생성
File > New > Fragment > Fragment(Blank)
(이름: PhotoFragment --> 레이아웃 이름은 자동으로 fragment_photo가 될 것임)
(체크박스해제: 'Include interface callbacks?' ∵ 본 예제에서 사용하지 않음)
아래의 두 개 파일이 생성될 것임 (액티비티 처럼 두 개의 파일로 구성됨)
app/java/팩키지명/PhotoFragment.kt
app/res/layout/fragment_photo.xml ( <-- 디자인에는 텍스트 뷰가 하나 포함되어 생성됨)
※ 옵션 설명
Create layout XML? (본 예제에서는 XML을 사용하므로 체크)
Include fragment factory methods? (팩토리 메서드를 이용해 생성. 생성시 인자도 넘겨줌. 체크)
Include interface callbacks? (액티비티와 상호 작용하는 콜백 인터페이스를 가져옴.
프래그먼트에서 발생한 이벤트를 액티비티로 전달할 경우 체크)
② 트리창에서 루트 레이아웃인 FrameLayout에서 컨텍스트 메뉴를 이용해 ConstraintLayout으로 변경(옵션:디폴트)
(변환이 되면 레이아웃 아이콘 모양이 바뀐다)
(트리창의 루트 레이아웃 이름은 id가 표시된 것임 --> 본 예제에서는 ID를 사용하지 않으므로 id를 삭제할 것)
③ 트리창에서 자동으로 생성된 텍스트뷰를 삭제하고,
Autoconnect 모드를 켜고 여백은 0dp로 설정한 후 팔레트창에서 ImageView를 정 중앙에 배치
이미지 리소스 선택창이 나오면 샘플 이미지를 아무거나 선택 (디자인 하는 동안에만 임시로 표시됨)
이미지 뷰 속성 >> id: imageView1, layout_width와 layout_height: match_constraint, scaleType: centerCrop
④ Glide 라이브러리 사용 준비
다음과 같이 setImageURI()를 이용해서 이미지 뷰에 사진을 표시할 수도 있지만,
imageView.setImageURI(Uri.parse("/storage/emulated/0/DCIM/Camera/aaaaa.jpg"))
효율적인 리소스 관리를 자동으로 해주고 이미지 비동기 로딩으로 UI의 끊김이 없는
Glide 라이브러리를 이용하자.
-- build.gradle 에 의존성 추가후 Sync Now.
(버전은 https://github.com/bumptech/glide 에서 최신 버전을 확인해서 적을 것)
dependencies {
...
implementation 'com.github.bumptech.glide:glide:4.11.0' // 2020.05.13 버전
}
※ build.gradle 파일을 수정하지 않고 직접 안드로이드 스튜디오의 메뉴를 활용하는 방법도 있다.
File > Project Structure 클릭 > Dependencies > '+'눌러 추가 > 검색창에 glide 입력후 검색
자동으로 검색된 라이브러리들 중에 필요한 항목을 선택하고 (자동으로 최신 버전도 표시됨) OK 버튼 클릭.
⑤ PhotoFragment.kt 파일 코딩
자동으로 작성되어 있는 코드를 아래와 같이 수정하면 된다.
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
private const val ARG_URI = "uri" // 클래스 밖에서 상수를 정의하면 컴파일 시 상수가 초기화됨
// 컴파일 시 상수 초기화는 프리미티브형(Int, Long, Double등 기본형)만 가능
class PhotoFragment : Fragment() {
private var uri: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
uri = it.getString(ARG_URI) // 프래그먼트가 생성되고 onCreate() 호출되면
// ARG_URI키에 저장된 uri값을 얻어서 변수에 저장
}
}
// 프래그먼트에 표시될 뷰를 생성함
// 액티비티가 아닌 곳에서 레이아웃 리소스를 가져오려면 LayoutInflater객체의 inflate()를 사용함
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_photo, container, false)
}
// newInstance()를 이용해서 프래그먼트를 생성할 수 있고 인자로 uri 값을 전달
// 이 값은 Bundle 객체에 ARG_URI 키로 저장되고 arguments 프로퍼티에 저장됨
companion object {
@JvmStatic
fun newInstance(uri: String) =
PhotoFragment().apply {
arguments = Bundle().apply {
putString(ARG_URI, uri)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Glide.with(this).load(uri).into(imageView1) // 사진을 이미지뷰에 표시
// with(): 사용준비, load(): 이미지 로드, into(): 이미지 표시
}
}
⑥ 액티비티에 ViewPager 추가
activity_main.xml 디자인 창 > Containers > ViewPager를 화면 정중앙에 배치.
(id: viewPager1, layout_width, layout_height: match_constraint)
(디자인창을 이용하는 경우에는 안내와 함께 필요한 라이브러리가 자동으로 의존성으로 추가됨)
※ 뷰페이저란?
여러 프래그먼트들을 좌우로 슬라이드하는 뷰. (별도의 라이브러리에 있는 뷰이므로 의존성 추가 필요)
프래그먼트 목록용 컨테이너('페이저 어댑터'라고 부름)는 다음의 두 가지가 있는데, 이를 뷰페이저에
연결하면 사진이 표시된다. (본 예제에서는 FragmentStatePagerAdapter를 사용함)
FragmentPagerAdapter (페이지들 로딩후 계속 메모리 상주. 속도는 빠르나 메모리 많이 사용)
FragmentStatePagerAdapter (보이지 않는 페이지는 메모리에서 제거. 페이지가 많을 때 적합)
⑦ 페이저 어댑터 작성
File > New > 'Kotlin File/Class' (이름: MyPagerAdapter1, 종류: Class)
(자동으로 생성된 코드를 아래와 같이 조금 수정하면 됨)
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
class MyPagerAdapter1(fm: FragmentManager?) : FragmentStatePagerAdapter(fm) {
private val items = ArrayList() // 뷰페이저가 표시할 프래그먼트 목록
override fun getItem(position: Int): Fragment {
return items[position]
}
override fun getCount(): Int {
return items.size // 아이템의 개수
}
fun updateFragments(items: List) {
this.items.addAll(items) // 외부에서 추가
}
}
⑧ MainActivity.kt의 getAllPhotos() 코드 추가
private fun getAllPhotos() {
val photosCursor = ...
val fragmentArray = ArrayList()
if (photosCursor != null) {
while (photosCursor.moveToNext()) {
val uri = photosCursor.getString(photosCursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
Log.d("MainActivity", uri)
fragmentArray.add(PhotoFragment.newInstance(uri)) // 사진을 photosCursor 객체에서 가져올 때마다
// 새 프래그먼트를 생성하여 추가
}
photosCursor.close()
}
val adapter1 = MyPagerAdapter1(supportFragmentManager)
// 프래그먼트 관리자는 getSupportFragmentManager()로 가져올 수 있는데,
// 코틀린에서는 supportFragmentManager 프로퍼티로 접근할 수 있음
adapter1.updateFragments(fragmentArray)
viewPager1.adapter = adapter1
...
}
이 단계에서 실행해 보고 사진이 좌우로 잘 슬라이드되며 표시되는 지 확인해 보자.
(이전 단계에서 사진이 제대로 읽어지는지 로그로 확인해 보려고 작성했던 코드는 리마크할 것
- 그냥 놔두면 photosCursor.close()에서 오류가 날 수 있다)
4) 슬라이드 쇼 구현
getAllPhotos()에 간단한 코드를 추가하여 3초마다 자동으로 사진이 슬라이드 되도록 만들어 보자.
private fun getAllPhotos() {
...
timer(period = 3000) {
runOnUiThread {
if (viewPager1.currentItem < adapter1.count - 1) {
viewPager1.currentItem = viewPager1.currentItem + 1
} else {
viewPager1.currentItem = 0
}
}
}
}