기능
활용 기술
https://unsplash.com/developers 에서 API 사용 신청
의존성 추가
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.8.0'
access key와 baseUrl 정의
Authentication, Schema 참고
gradle.properties
UNSPLASH_ACCESS_KEY="access_key"
앱수준 build.gradle
android {
defaultConfig {
buildConfigField "String", "UNSPLASH_ACCESS_KEY", project.properties["UNSPLASH_ACCESS_KEY"]
}
data/Url
object Url {
const val UNSPLASH_BASE_URL = "https://api.unsplash.com/"
}
사진과 관련된 정보 가져오기 위해 네트워크 모델 추가
models 패키지에 kotlin data class file from JSON 통해서 네트워크 모델을 추가해주면 아래의 이미지처럼 파일들이 생성된다.
서비스 정의
data/UnsplashApiService
interface UnsplashApiService {
@GET(
"photos/random?" +
"client_id=${BuildConfig.UNSPLASH_ACCESS_KEY}" +
"&count=30"
)
suspend fun getRandomPhotos(
@Query("query") query: String?
): Response<List<PhotoResponse>>
}
Repository 정의
data/Repository
object Repository {
// 레트로핏으로 생성
private val unsplashApiService: UnsplashApiService by lazy {
Retrofit.Builder()
.baseUrl(Url.UNSPLASH_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(buildOkHttpClient())
.build()
.create()
}
// 실제로 호출할 수 있는 호출부 구현
suspend fun getRandomPhotos(query: String?): List<PhotoResponse>? =
unsplashApiService.getRandomPhotos(query).body()
// 로깅 찍기 위해 OkHttpClient 추가
private fun buildOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
)
.build()
}
MainActivity에서 API 호출
API를 호출하기 위해서는 INTERNET 퍼미션이 필요
<uses-permission android:name="android.permission.INTERNET"/>
MainActivity.kt
class MainActivity : AppCompatActivity() {
private val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fetchRandomPhotos()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
private fun fetchRandomPhotos(query: String? = null) = scope.launch {
Repository.getRandomPhotos(query)?.let { photos ->
photos
}
}
}
의존성 추가
// Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
// SwipeRefreshLayout
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
레이아웃 구성
layout/item_photo.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/contentsContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
tools:layout_height="500dp">
<ImageView
android:id="@+id/photoImageView"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:background="@color/teal_200"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/profileImageView"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:background="@color/teal_700"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/authorTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:maxLines="1"
android:shadowColor="#60000000"
android:shadowDx="1"
android:shadowDy="1"
android:shadowRadius="5"
android:textColor="@color/white"
app:layout_constraintBottom_toTopOf="@id/descriptionTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/profileImageView"
app:layout_constraintTop_toTopOf="@id/profileImageView"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Author" />
<TextView
android:id="@+id/descriptionTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:alpha="0.8"
android:ellipsize="end"
android:maxLines="1"
android:shadowColor="#60000000"
android:shadowDx="1"
android:shadowDy="1"
android:shadowRadius="5"
android:textColor="@color/white"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/profileImageView"
app:layout_constraintEnd_toEndOf="@id/authorTextView"
app:layout_constraintStart_toStartOf="@id/authorTextView"
app:layout_constraintTop_toBottomOf="@id/authorTextView"
tools:text="Description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</FrameLayout>
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<EditText
android:id="@+id/searchEditText"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/white"
android:drawableStart="@drawable/ic_baseline_search_24"
android:drawablePadding="6dp"
android:elevation="8dp"
android:hint="@string/search_image"
android:imeOptions="actionSearch"
android:importantForAutofill="no"
android:inputType="text"
android:paddingHorizontal="12dp"
android:textSize="14sp" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingVertical="6dp"
tools:listitem="@layout/item_photo" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
drawable/shape_profile_placeholder.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFDDDDDD" />
</shape>
PhotoAdapter
class PhotoAdapter : RecyclerView.Adapter<PhotoAdapter.ViewHolder>() {
var photos: List<PhotoResponse> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
ViewHolder(
ItemPhotoBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(photos[position])
}
override fun getItemCount(): Int = photos.size
inner class ViewHolder(
private val binding: ItemPhotoBinding
): RecyclerView.ViewHolder(binding.root) {
fun bind(photo: PhotoResponse) {
val dimensionRatio = photo.height / photo.width.toFloat()
val targetWidth = binding.root.resources.displayMetrics.widthPixels -
(binding.root.paddingStart + binding.root.paddingEnd)
val targetHeight = (targetWidth * dimensionRatio).toInt()
binding.contentsContainer.layoutParams =
binding.contentsContainer.layoutParams.apply {
height = targetHeight
}
Glide.with(binding.root)
.load(photo.urls?.regular)
.thumbnail(
Glide.with(binding.root)
.load(photo.urls?.thumb)
.transition(DrawableTransitionOptions.withCrossFade())
)
.override(targetWidth, targetHeight)
.into(binding.photoImageView)
Glide.with(binding.root)
.load(photo.user?.profileImageUrls?.small)
.placeholder(R.drawable.shape_profile_placeholder)
.transition(DrawableTransitionOptions.withCrossFade())
.circleCrop()
.into(binding.profileImageView)
if (photo.user?.name.isNullOrBlank()) {
binding.authorTextView.visibility = View.GONE
} else {
binding.authorTextView.visibility = View.VISIBLE
binding.authorTextView.text = photo.user?.name
}
if (photo.description.isNullOrBlank()) {
binding.descriptionTextView.visibility = View.GONE
} else {
binding.descriptionTextView.visibility = View.VISIBLE
binding.descriptionTextView.text = photo.description
}
}
}
}
Thumbnail requests
: 인터넷 속도에 따라 고화질의 이미지를 불러올 때 오래 걸리는 경우가 있기 때문에 해상도가 조금 떨어지는 이미지를 먼저 불러와 보여준 후, 고화질의 이미지가 완전히 로딩되면 교체해주는 식의 api를 말함.
MainActivity에 연동
MainActivity
private fun initViews() {
binding.recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
binding.recyclerView.adapter = PhotoAdapter()
}
@SuppressLint("NotifyDataSetChanged")
private fun fetchRandomPhotos(query: String? = null) = scope.launch {
Repository.getRandomPhotos(query)?.let { photos ->
(binding.recyclerView.adapter as? PhotoAdapter)?.apply {
this.photos = photos
notifyDataSetChanged()
}
}
}
이미지 갱신 및 새로운 이미지 검색해서 갱신
MainActivity
private fun bindView() {
binding.searchEditText.setOnEditorActionListener { editText, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) { // SEARCH 버튼 눌렀을때만 처리
currentFocus?.let { view ->
// search 가 끝나면 키보드 사라지게 함
val inputMethodManager =
getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
inputMethodManager?.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus() // search 가 끝나면 커셔 깜빡거림 사라지게 함
}
// editText 의 값을 전달, fetch 하고 데이터가 변경되면서 갱신됨
fetchRandomPhotos(editText.text.toString())
}
true
}
binding.refreshLayout.setOnRefreshListener {
// 검색한 데이터에 대해서 refresh 하면, 검색한 데이터에 대한 것만 갱신하게끔
fetchRandomPhotos(binding.searchEditText.text.toString())
}
}
@SuppressLint("NotifyDataSetChanged")
private fun fetchRandomPhotos(query: String? = null) = scope.launch {
Repository.getRandomPhotos(query)?.let { photos ->
(binding.recyclerView.adapter as? PhotoAdapter)?.apply {
this.photos = photos
notifyDataSetChanged()
}
// refresh 의 경우, fetch 가 끝나면 숨겨줘야 함
binding.refreshLayout.isRefreshing = false
}
}
facebook/shimmer-android 라이브러리 추가
dependencies {
implementation 'com.facebook.shimmer:shimmer:0.5.0'
}
activity_main.xml
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView/>
<TextView/>
<com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/shimmerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<include layout="@layout/view_shimmer_item_photo" />
<include layout="@layout/view_shimmer_item_photo" />
</LinearLayout>
</com.facebook.shimmer.ShimmerFrameLayout>
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
FrameLayout으로 감싸주고 ShimmerLayout과 LinearLayout을 추가해주고 include로 레이아웃을 추가해준다.
layout/view_shimmer_item_photo.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="6dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/contentsContainer"
android:layout_width="match_parent"
android:layout_height="400dp">
<View
android:id="@+id/photoImageView"
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="#FFEEEEEE"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0" />
<View
android:id="@+id/profileImageView"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="12dp"
android:background="@drawable/shape_profile_placeholder"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<View
android:id="@+id/authorTextView"
android:layout_width="0dp"
android:layout_height="10dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="12dp"
android:background="#FFDDDDDD"
app:layout_constraintBottom_toTopOf="@id/descriptionTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/profileImageView"
app:layout_constraintTop_toTopOf="@id/profileImageView"
app:layout_constraintVertical_chainStyle="packed" />
<View
android:id="@+id/descriptionTextView"
android:layout_width="0dp"
android:layout_height="10dp"
android:layout_marginTop="5dp"
android:background="#FFDDDDDD"
app:layout_constraintBottom_toBottomOf="@id/profileImageView"
app:layout_constraintEnd_toEndOf="@id/authorTextView"
app:layout_constraintStart_toStartOf="@id/authorTextView"
app:layout_constraintTop_toBottomOf="@id/authorTextView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
MainActivity
private fun fetchRandomPhotos(query: String? = null) = scope.launch {
Repository.getRandomPhotos(query)?.let { photos ->
(binding.recyclerView.adapter as? PhotoAdapter)?.apply {
this.photos = photos
notifyDataSetChanged()
}
binding.recyclerView.visibility = View.VISIBLE
binding.shimmerLayout.visibility = View.GONE
binding.refreshLayout.isRefreshing = false
}
}
fetch가 끝나면, GONE처리 되어있던 recyclerView를 VISIBLE로 바꿔주고, shimmerLayout은 GONE처리해준다.
에러 처리
MainActivity
private fun fetchRandomPhotos(query: String? = null) = scope.launch {
try {
Repository.getRandomPhotos(query)?.let { photos ->
binding.errorDescriptionTextView.visibility = View.GONE
(binding.recyclerView.adapter as? PhotoAdapter)?.apply {
this.photos = photos
notifyDataSetChanged()
}
}
binding.recyclerView.visibility = View.VISIBLE
} catch (exception: Exception) {
binding.recyclerView.visibility = View.INVISIBLE
binding.errorDescriptionTextView.visibility = View.VISIBLE
} finally {
binding.shimmerLayout.visibility = View.GONE
binding.refreshLayout.isRefreshing = false
}
}
PhotoAdapter
class PhotoAdapter : RecyclerView.Adapter<PhotoAdapter.ViewHolder>() {
var onClickPhoto: (PhotoResponse) -> Unit = {}
// 내부클래스에 inner 붙여주면 상위 클래스에서 정의한 프로퍼티 접근 가능
inner class ViewHolder(
private val binding: ItemPhotoBinding
): RecyclerView.ViewHolder(binding.root) {
init {
binding.root.setOnClickListener {
// 아이템 클릭 시, 클릭 당시 adapterPosition 에 있는 photo 를 이벤트로 전달
onClickPhoto(photos[adapterPosition])
}
}
MainActivity
private fun bindView() {
(binding.recyclerView.adapter as? PhotoAdapter)?.onClickPhoto = { photo ->
showDownloadPhotoConfirmationDialog(photo)
}
}
이미지 저장 전 한번 더 확인해주는 Dialog 구현
private fun showDownloadPhotoConfirmationDialog(photo: PhotoResponse) {
AlertDialog.Builder(this)
.setMessage("이 사진을 저장하시겠습니까?")
.setPositiveButton("저장") { dialog, _ ->
downloadPhoto(photo.urls?.full)
dialog.dismiss()
}
.setNegativeButton("취소") { dialog, _ ->
dialog.dismiss()
}
.create()
.show()
}
이미지 다운로드 관련
private fun downloadPhoto(photoUrl: String?) {
photoUrl ?: return
Glide.with(this)
.asBitmap()
.load(photoUrl)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.into(
object : CustomTarget<Bitmap>(SIZE_ORIGINAL, SIZE_ORIGINAL) {
@RequiresApi(Build.VERSION_CODES.M)
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) { // 다운로드가 다 끝난 상태
saveBitmapToMediaStore(resource)
val wallpaperManager = WallpaperManager.getInstance(this@MainActivity)
val snackbar = Snackbar.make(
binding.root,
"다운로드 완료",
Snackbar.LENGTH_SHORT
)
if (wallpaperManager.isWallpaperSupported
&& (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& wallpaperManager.isSetWallpaperAllowed)
) {
snackbar.setAction("배경화면으로 저장") {
try {
wallpaperManager.setBitmap(resource)
} catch (exception: Exception) {
Snackbar.make(binding.root, "배경화면 저장 실패", Snackbar.LENGTH_SHORT)
}
}
snackbar.duration = Snackbar.LENGTH_INDEFINITE
}
snackbar.show()
}
override fun onLoadStarted(placeholder: Drawable?) {
super.onLoadStarted(placeholder)
Snackbar.make(
binding.root,
"다운로드 중...",
Snackbar.LENGTH_INDEFINITE
).show()
}
// 불러오는게 취소되어 리소스가 해제되었을 때
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onLoadFailed(errorDrawable: Drawable?) {
super.onLoadFailed(errorDrawable)
Snackbar.make(
binding.root,
"다운로드 실패",
Snackbar.LENGTH_SHORT
).show()
}
}
)
}
이미지 저장
private fun saveBitmapToMediaStore(bitmap: Bitmap) {
val fileName = "${System.currentTimeMillis()}.jpg"
val resolver = applicationContext.contentResolver
val imageCollectionUri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY
)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val imageDetails = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 이미지 저장하는 동안은(0으로 바뀌기 전까진) 이미지 파일에 접근하지 못하도록
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
// 실제로 이미지파일을 저장할 uri 를 가져와야 함
val imageUri = resolver.insert(imageCollectionUri, imageDetails)
imageUri ?: return // imageUri 가 null 일 경우 저장하지 않고 리턴
resolver.openOutputStream(imageUri).use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
imageDetails.clear()
// 이미지가 저장되었으면 1이었던 팬딩을 다시 0으로 바꿔줌
imageDetails.put(MediaStore.Images.Media.IS_PENDING, 0)
// imageUri 를 imageDetails 로 업데이터
resolver.update(imageUri, imageDetails, null, null)
}
}
이렇게 하면 안드로이드 10버전 이상에서는 이미지가 잘 저장되지만, 그 아래 28이하부터는 권한 요청을 하지 않았기 때문에 이미지 저장에 있어 에러가 발생한다. 이부분을 추가로 권한 요청 해줘야 한다.
AndroidManifest.xml
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
fetchRandomPhotos()
} else {
requestWriteStoragePermission()
}
}
private fun requestWriteStoragePermission() {
ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
val writeExternalStoragePermissionGranted =
requestCode == REQUEST_WRITE_EXTERNAL_STORAGE_PERMISSION &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
if (writeExternalStoragePermissionGranted) {
fetchRandomPhotos()
}
}
이미지를 배경화면을 지정하기 위해 WallpaperManager
를 사용했다.
"다운로드 완료" snackBar를 OnResourceReady() {} 로 옮기고, wallpaperManager를 정의해준다.
MainActivity
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) { // 다운로드가 다 끝난 상태
saveBitmapToMediaStore(resource)
val wallpaperManager = WallpaperManager.getInstance(this@MainActivity)
val snackbar = Snackbar.make(
binding.root,
"다운로드 완료",
Snackbar.LENGTH_SHORT
)
if (wallpaperManager.isWallpaperSupported
&& (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& wallpaperManager.isSetWallpaperAllowed)
) {
snackbar.setAction("배경화면으로 저장") {
try {
wallpaperManager.setBitmap(resource)
} catch (exception: Exception) {
Snackbar.make(binding.root, "배경화면 저장 실패", Snackbar.LENGTH_SHORT)
}
}
snackbar.duration = Snackbar.LENGTH_INDEFINITE
}
snackbar.show()
}
wallpaper를 set하려고 시도하다 에러 발생시 IOException이 뜨기 때문에 try-catch로 감싸주었다.