키워드

  1. Retrofit2, OkHttpLoggingInterceptor
  2. Shimmer
  3. Glide
  4. Content Resolver
  5. Unsplash API

개발 과정

1. Unsplash API 와 연결

Model 구현

PluginJson To Kotlin 을 활용하면 JSON 형식에 맞춰서 자동으로 변환하고 Model 을 생성해준다.

Interface 선언

Retrofit 의 요청에 대한 interface 를 선언한다.

package com.example.imageviewer.data.service

import com.example.imageviewer.BuildConfig
import com.example.imageviewer.data.models.PhotoResponse
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query

interface UnsplashApiService {
    @GET("photos/random?" + "client_id=${BuildConfig.UNSPLASH_ACCESS_KEY}" + "&count=30")
    suspend fun getRandomPhotos(
        @Query("query") query : String?
    ): Response<List<PhotoResponse>>
}

싱글톤으로 구현

object 를 활용하면 Singleton 으로 손쉽게 구현할 수 있다. 우선 interface 를 정의한다. 이 때 HTTP 요청에 대한 로깅을 출력하기 위해 OkHttpHttpLoggingInterceptor 를 구현하여 인자로 전달했다.

package com.example.imageviewer.data.util

import com.example.imageviewer.BuildConfig
import com.example.imageviewer.data.Url
import com.example.imageviewer.data.models.PhotoResponse
import com.example.imageviewer.data.service.UnsplashApiService
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create

object RetrofitUtil {
    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()

    private fun buildOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(
                HttpLoggingInterceptor().apply {
                    level = if (BuildConfig.DEBUG) {
                        HttpLoggingInterceptor.Level.BODY
                    } else {
                        HttpLoggingInterceptor.Level.NONE
                    }
                }
            )
            .build()
}

2. RecyclerView 와 연결

API 를 통해 사진을 불러오는 것을 구현했으니 이제 사진을 보여줄 RecyclerView 를 구현해야 한다. 지금까지 구현했던 방식과 동일하다. ListAdapter 를 상속하는 클래스와 RecyclerView.ViewHolder 를 상속하는 이너 클래스를 정의한다. 또한 ClickListener 를 통해 Photo 객체가 전달될 수 있도록 한다.

package com.example.imageviewer

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.example.imageviewer.data.models.PhotoResponse
import com.example.imageviewer.databinding.ItemPhotoBinding

class PhotoAdapter(val itemClickListener : (PhotoResponse) -> Unit) : ListAdapter<PhotoResponse, PhotoAdapter.ViewHolder>(diffUtil) {
    inner class ViewHolder(private val binding: ItemPhotoBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bindViews(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)
                .circleCrop()
                .into(binding.profileImageView)

            if (photo.user?.name.isNullOrBlank()) {
                binding.authorTextView.isGone = true
            } else {
                binding.authorTextView.isGone = false
                binding.authorTextView.text = photo.user?.name
            }

            if (photo.description.isNullOrBlank()) {
                binding.descriptionTextView.isGone = true
            } else {
                binding.descriptionTextView.isGone = false
                binding.descriptionTextView.text = photo.description
            }

            binding.root.setOnClickListener {
                itemClickListener(photo)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(
            ItemPhotoBinding.inflate(
                LayoutInflater.from(parent.context),
                parent,
                false
            )
        )
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        return holder.bindViews(currentList[position])
    }

    companion object {
        private val diffUtil = object : DiffUtil.ItemCallback<PhotoResponse>() {
            override fun areItemsTheSame(oldItem: PhotoResponse, newItem: PhotoResponse): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(
                oldItem: PhotoResponse,
                newItem: PhotoResponse
            ): Boolean {
                return oldItem == newItem
            }
        }
    }
}