관리 메뉴

나만을 위한 블로그

[Android] Coroutine + Retrofit + Hilt + LiveData를 써서 네트워크 상태 별 처리하기 & 리사이클러뷰 페이징 본문

Android

[Android] Coroutine + Retrofit + Hilt + LiveData를 써서 네트워크 상태 별 처리하기 & 리사이클러뷰 페이징

참깨빵위에참깨빵 2023. 3. 21. 22:56
728x90
반응형

※ 이 포스팅에서 사용하는 페이징은 페이징 라이브러리를 사용한 구현이 아니다.

 

이 포스팅은 아래 링크를 바탕으로 작성됐다.

 

https://medium.com/@rafiz19/handling-network-call-states-in-kotlin-coroutines-91aff82781a9

 

Handling Network Call States in Kotlin Coroutines

As an experienced Android developer, I have seen the evolution of how we handle background tasks and threading in Android continue to…

medium.com

 

사용하는 API는 NewsAPI라는 무료 API다. API 키를 받아서 사용할 수 있게 준비한다.

 

https://newsapi.org/

 

News API – Search News and Blog Articles on the Web

“Ascender AI has a mission to apply AI to the media, and NewsAPI is one of our most valuable resources. Ascender is redefining how users interact with complex information, and the NewsAPI feed is an essential showcase for our technologies.” Braddock Ga

newsapi.org

 

레트로핏 등 라이브러리를 프로젝트에 설정하는 부분은 생략한다. 패키지는 아래와 같은 구조로 미리 만들어둔다.

 

 

utils 패키지에 들어 있는 파일들의 코드는 아래와 같다. utils의 Constants 안에는 const val 프로퍼티 하나만 있고 뷰모델에서만 사용하기 때문에 삭제했다. 미리 파일들을 만들어 두고 코드를 복붙할 거라면 Constants 파일은 무시하고 진행한다.

또한 개인적으로 단일 표현식 함수 형태를 좋아해서 가급적이면 함수 본문을 중괄호로 시작하지 않고 "="로 시작하는 함수가 많다.

 

base 패키지 안에는 BaseApplication만 들어 있다.

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class BaseApplication : Application() {
    override fun onCreate() {
        super.onCreate()
    }
}

Hilt를 사용했기 때문에 필수적으로 만들어줘야 하는 Application을 상속하는 클래스다. 이후 매니페스트에 name 속성의 값으로 설정해준다. 이 처리가 없다면 애써 다 복붙하고 실행하면 에러가 발생한다.

 

import java.text.SimpleDateFormat
import java.util.*

open class CommonUtils {
    private val serverFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
    private val localFormat = SimpleDateFormat("yyyy-MM-dd, a KK:mm", Locale.getDefault())

    open fun formatDate(stringDate: String?): String {
        try {
            serverFormat.timeZone = TimeZone.getTimeZone("UTC")
            if (stringDate.isNullOrEmpty()) return ""
            val date: Date? = serverFormat.parse(stringDate)
            localFormat.timeZone = TimeZone.getDefault()
            return if (date == null) ""
            else localFormat.format(date).orEmpty()
        } catch (ex: Exception) {
            ex.printStackTrace()
            return ""
        }
    }
}

 

UTC 시간을 원하는 시간 표시 형태로 바꿔주는 open class다. 조금 더 가공하고 취향껏 개조하면 쓸만한 유틸 함수로 만들 수 있어서 유용해 보인다.

formatDate()의 결과값은 localFormat에 적힌 대로 "2023.01.01, 오후 01.01" 형태다.

 

import android.app.AlertDialog
import android.content.Context

class DialogUtils(val context: Context) {
    fun showDialog(title: String, message: String): AlertDialog =
        AlertDialog.Builder(context).apply {
            setTitle(title)
            setMessage(message)
            setPositiveButton("OK") { dialog, _ ->
                dialog.dismiss()
            }
        }.show()
}

 

import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager

abstract class EndlessRecyclerViewScrollListener(private var mLayoutManager: RecyclerView.LayoutManager) :
    RecyclerView.OnScrollListener() {
    private var currentPage = 0
    private var previousTotalItemCount = 0
    private var loading = true
    private val startingPageIndex = 0

    private fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
        var maxSize = 0
        for (i in lastVisibleItemPositions.indices) {
            if (i == 0) {
                maxSize = lastVisibleItemPositions[i]
            } else if (lastVisibleItemPositions[i] > maxSize) {
                maxSize = lastVisibleItemPositions[i]
            }
        }
        return maxSize
    }

    override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
        var lastVisibleItemPosition = 0
        val totalItemCount = mLayoutManager.itemCount

        when (mLayoutManager) {
            is StaggeredGridLayoutManager -> {
                val lastVisibleItemPositions =
                    (mLayoutManager as StaggeredGridLayoutManager).findLastVisibleItemPositions(null)
                lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions)
            }
            is GridLayoutManager -> {
                lastVisibleItemPosition =
                    (mLayoutManager as GridLayoutManager).findLastVisibleItemPosition()
            }
            is LinearLayoutManager -> {
                lastVisibleItemPosition =
                    (mLayoutManager as LinearLayoutManager).findLastVisibleItemPosition()
            }
        }

        if (totalItemCount < previousTotalItemCount) {
            this.currentPage = this.startingPageIndex
            this.previousTotalItemCount = totalItemCount
            if (totalItemCount == 0) {
                this.loading = true
            }
        }

        if (loading && totalItemCount > previousTotalItemCount) {
            loading = false
            previousTotalItemCount = totalItemCount
        }

        if (!loading && (lastVisibleItemPosition + 1) >= totalItemCount) {
            currentPage++
            onLoadMore(currentPage, totalItemCount, view)
            loading = true
        }
    }

    abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView)

}

 

메인 액티비티에서 리사이클러뷰를 통해 뉴스 API로 받아온 데이터들을 표시하는데 이 때 리사이클러뷰의 마지막 아이템까지 스크롤했을 경우 다음 페이지의 데이터를 불러오기 위해 사용하는 추상 클래스다. 페이징 라이브러리를 사용하지 않고 페이징을 구현할 경우 이렇게도 구현할 수 있다는 걸 알아두기 위해 보면 좋을 것 같다.

 

import android.content.Context

class ErrorHandler(private val context: Context) {
    fun handleError(throwable: Throwable?) {
        try {
            var errorMessage: String? = throwable?.message

            if (errorMessage.isNullOrEmpty()) {
                errorMessage = "Error occurred, please try again later"
            }
            DialogUtils(context).showDialog("Attention", errorMessage)
        } catch (ex: Exception) {
            ex.printStackTrace()
        }
    }
}

 

이 클래스는 메인 액티비티에서 뷰모델의 LiveData를 observe하는 경우 API 응답 상태가 ERROR인 경우에 사용한다. 에러 뜨면 다이얼로그로 에러 보여주는 간단한 함수를 가진 클래스다.

다음은 di  패키지에 들어있는 파일들이다.

 

import com.example.kotlinprac.BuildConfig
import com.example.kotlinprac.networkstate.two.data.network.NewsService
import com.example.kotlinprac.networkstate.two.di.util.AuthenticationInterceptor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
class AppModule {
    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
        .baseUrl(BuildConfig.NEWS_API_BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(okHttpClient)
        .build()

    @Provides
    fun provideGson(): Gson = GsonBuilder().create()

    @Singleton
    @Provides
    fun provideAuthenticationInterceptor(): AuthenticationInterceptor = AuthenticationInterceptor()

    @Singleton
    @Provides
    fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
        val httpLoggingInterceptor = HttpLoggingInterceptor()
        httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
        return httpLoggingInterceptor
    }

    @Singleton
    @Provides
    fun provideHttpClient(
        logging: HttpLoggingInterceptor,
        auth: AuthenticationInterceptor
    ): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(logging)
        .addInterceptor(auth)
        .connectTimeout(1, TimeUnit.MINUTES)
        .readTimeout(1, TimeUnit.MINUTES)
        .writeTimeout(1, TimeUnit.MINUTES)
        .build()

    @Singleton
    @Provides
    fun provideCommonService(retrofit: Retrofit): NewsService =
        retrofit.create(NewsService::class.java)
}

 

hilt를 적용한다면 구현해야 하는 provide 함수들을 모아놓은 클래스다. object로 만들어서 싱글톤하게 관리할 수도 있다.

 

import com.example.kotlinprac.BuildConfig
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import javax.inject.Singleton

@Singleton
class AuthenticationInterceptor : Interceptor {
    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        return try {
            var request: Request = chain.request()
            val builder: Request.Builder = request.newBuilder()
            builder.addHeader("X-Api-Key", BuildConfig.NEWS_API_KEY)
            request = builder.build()
            chain.proceed(request)
        } catch (ex: Exception) {
            ex.printStackTrace()
            chain.proceed(chain.request())
        }
    }

}

 

AppModule에서 사용되는 클래스다. API 공식 문서를 찾아보면 헤더에 "X-Api-Key"로 API 키를 넣으라고 설명하기 때문에 위와 같이 구현한 걸 볼 수 있다. 또는 네트워크 통신이 끊겼을 경우 Interceptor 인터페이스를 구현한 후 재정의하는 intercept()를 통해 처리할 수도 있다.

 

다음은 data 패키지 안의 파일들이다. 먼저 model 패키지의 Source 파일부터 확인한다.

 

import com.example.kotlinprac.networkstate.two.utils.CommonUtils
import com.google.gson.annotations.SerializedName

data class Source (
    @SerializedName("id") var id: String? = null,
    @SerializedName("name") var name: String? = null
)

data class ArticleResponse(
    @SerializedName("articles")
    var articles: MutableList<Article>? = null
)

data class Article(
    @SerializedName("source") var source: Source? = Source(),
    @SerializedName("author") var author: String? = null,
    @SerializedName("title") var title: String? = null,
    @SerializedName("description") var description: String? = null,
    @SerializedName("url") var url: String? = null,
    @SerializedName("urlToImage") var urlToImage: String? = null,
    @SerializedName("publishedAt") var _publishedAt: String? = null,
    @SerializedName("content") var content: String? = null
) {
    val publishedAt
        get(): String? = CommonUtils().formatDate(_publishedAt.orEmpty())
}

 

network 패키지 안의 파일들은 아래와 같다.

 

data class ApiResponse<out T>(val status: Status, val data: T?, val throwable: Throwable?) {
    companion object {
        fun <T> success(data: T): ApiResponse<T> = ApiResponse(Status.SUCCESS, data, null)
        fun <T> error(throwable: Throwable): ApiResponse<T> = ApiResponse(Status.ERROR, null, throwable)
        fun <T> loading(data: T? = null): ApiResponse<T> = ApiResponse(Status.LOADING, data, null)
    }
}

 

첨부한 링크에선 위와 같이 data class로 구현됐지만 sealed class로 구현할 수도 있다.

 

import retrofit2.HttpException
import retrofit2.Response

abstract class BaseDataSource {
    protected suspend fun <T> getResult(call: suspend () -> Response<T>): ApiResponse<T> {
        try {
            val response = call()
            if (response.isSuccessful) {
                response.body()?.let {
                    return ApiResponse.success(it)
                }
            }
            return ApiResponse.error(HttpException(response))
        } catch (e: Exception) {
            return ApiResponse.error(e)
        }
    }
}

 

Repository에서 인터페이스의 함수를 호출하고 그 결과값을 처리하는 보일러 플레이트 코드를 별도의 추상 클래스 안에 함수로 추출한 클래스다. 이렇게 추출하면 Repository 안의 코드가 많이 간결해진다. 밑에 NewsRepository 클래스에서 확인해 본다.

 

enum class Status {
    SUCCESS,
    ERROR,
    LOADING
}
import com.example.kotlinprac.networkstate.two.data.model.ArticleResponse
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query

interface NewsService {
    @GET("top-headlines?country=us")
    suspend fun getTopHeadlines(
        @Query("page") page: Int,
        @Query("pageSize") pageSize: Int
    ): Response<ArticleResponse>

    @GET("everything?domains=techcrunch.com&sortBy=popularity")
    suspend fun getNewsList(
        @Query("page") page: Int,
        @Query("pageSize") pageSize: Int
    ): Response<ArticleResponse>
}
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NewsRepository @Inject constructor(
    private val commonService: NewsService
) : BaseDataSource() {
    suspend fun getTopHeadlines(page: Int, pageSize: Int) = getResult {
        commonService.getTopHeadlines(page, pageSize)
    }

    suspend fun getNewsList(page: Int, pageSize: Int) = getResult {
        commonService.getNewsList(page, pageSize)
    }

}

 

Repository 코드가 많이 간결해진 걸 볼 수 있다. BaseDataSource가 없었다면 함수마다 response.isSuccessful을 체크해서 성공이면 어떤 값 넣고 실패면 어떤 값 넣는 코드들 때문에 파일이 좀 더 길어졌을 것이다.

 

이제 XML 파일들을 미리 작성해둔다. 

 

<!-- dialog_progress.xml -->

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:gravity="center_horizontal"
    android:orientation="horizontal">

    <ProgressBar
        android:id="@+id/progressBar"
        style="?android:attr/progressBarStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
<!-- item_news.xml -->

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView 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:layout_marginHorizontal="8dp"
    android:layout_marginVertical="4dp"
    app:cardCornerRadius="8dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/imgView"
            android:layout_width="120dp"
            android:layout_height="120dp"
            android:scaleType="centerCrop"
            android:src="@drawable/ic_placeholder"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/imgView"
            app:layout_constraintTop_toTopOf="parent">

            <TextView
                android:id="@+id/txtTitle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:paddingHorizontal="10dp"
                android:textColor="@color/black"
                android:textSize="18sp" />

            <TextView
                android:id="@+id/txtContent"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:paddingHorizontal="10dp"
                android:textColor="@color/textColor"
                android:textSize="16sp" />

            <TextView
                android:id="@+id/txtDate"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:drawablePadding="6dp"
                android:gravity="center_vertical"
                android:padding="10dp"
                android:textColor="@color/textColor"
                app:drawableStartCompat="@drawable/ic_clock" />
        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.cardview.widget.CardView>

 

아래는 MainActivity, WebViewActivity의 XML이다.

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
    tools:context=".networkstate.two.ui.MainActivity">

    <RelativeLayout
        android:id="@+id/rltHeadlineContainer"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:id="@+id/imgHeadline"
            android:layout_width="match_parent"
            android:layout_height="300dp"
            android:scaleType="centerCrop"
            android:src="@drawable/ic_placeholder" />

        <TextView
            android:id="@+id/txtTitle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_above="@+id/txtDate"
            android:layout_centerVertical="true"
            android:background="#80000000"
            android:paddingHorizontal="10dp"
            android:textColor="@color/white"
            android:textSize="16sp" />

        <TextView
            android:id="@+id/txtDate"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_centerVertical="true"
            android:background="#80000000"
            android:drawablePadding="6dp"
            android:gravity="center_vertical"
            android:padding="10dp"
            android:textColor="@color/textColor"
            app:drawableStartCompat="@drawable/ic_clock" />

    </RelativeLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerNews"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/rltHeadlineContainer" />

</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
    tools:context=".networkstate.two.ui.WebViewActivity">

    <WebView
        android:id="@+id/webView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

이제 ui 패키지 안의 코드들을 확인한다.

 

import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import com.example.kotlinprac.R

class ProgressDialog(context: Context) : Dialog(context) {
    init {
        @SuppressLint("InflateParams")
        val inflate = LayoutInflater.from(context).inflate(R.layout.dialog_progress, null)
        setContentView(inflate)
        setCancelable(false)
        window!!.setBackgroundDrawable(
            ColorDrawable(Color.TRANSPARENT)
        )
    }
}

 

배경이 딤 처리되고 둥근 프로그레스 바만 돌아가는 XML을 사용하는 다이얼로그 클래스다.

 

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.kotlinprac.networkstate.two.data.model.Article
import com.example.kotlinprac.networkstate.two.data.model.ArticleResponse
import com.example.kotlinprac.networkstate.two.data.network.ApiResponse
import com.example.kotlinprac.networkstate.two.data.network.NewsRepository
import com.example.kotlinprac.networkstate.two.data.network.Status
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

const val PAGE_SIZE = 10

@HiltViewModel
class MainViewModel @Inject constructor(private val commonDao: NewsRepository) : ViewModel() {

    private var newsResponse: ArticleResponse? = null
    private val _newsList: MutableLiveData<ApiResponse<List<Article>>> = MutableLiveData()
    val newsList: LiveData<ApiResponse<List<Article>>> = _newsList

    private val _headline: MutableLiveData<ApiResponse<Article>> = MutableLiveData()
    val headline: LiveData<ApiResponse<Article>> = _headline

    fun getNewsList(newsPage: Int) {
        viewModelScope.launch {
            _newsList.value = ApiResponse.loading()
            _newsList.value = handleNewsListResponse(commonDao.getNewsList(newsPage, PAGE_SIZE))
        }
    }

    private fun handleNewsListResponse(response: ApiResponse<ArticleResponse>): ApiResponse<List<Article>> {
        if (response.status == Status.SUCCESS) {
            response.data?.let {
                if (newsResponse == null) {
                    newsResponse = it
                } else {
                    val oldNews = newsResponse?.articles
                    val newNews = it.articles
                    oldNews?.addAll(newNews.orEmpty())
                }
                return ApiResponse.success(it.articles.orEmpty())
            }
        }
        return ApiResponse.error(response.throwable!!)
    }

    fun getHeadlines() {
        viewModelScope.launch {
            _headline.value = ApiResponse.loading()
            _headline.value = handleHeadlineResponse(commonDao.getTopHeadlines(1, PAGE_SIZE))
        }
    }

    private fun handleHeadlineResponse(response: ApiResponse<ArticleResponse>): ApiResponse<Article> {
        if (response.status == Status.SUCCESS) {
            response.data?.let { it ->
                if (!it.articles.isNullOrEmpty()) {
                    val sortedList = it.articles?.sortedByDescending { article -> article._publishedAt }
                    return ApiResponse.success(sortedList?.get(0)!!)
                }
            }
        }
        return ApiResponse.error(response.throwable!!)
    }
}

 

메인 액티비티에서 사용할 뷰모델이다. let {}을 써서 else가 없는 if문의 처리를 수행할 수도 있다. 무조건 써야 되는 건 아니니 본인 상황에 맞춰 알아서 사용하자.

 

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ItemNewsBinding
import com.example.kotlinprac.networkstate.two.data.model.Article

class NewsAdapter(
    private val mList: ArrayList<Article>
) : RecyclerView.Adapter<NewsAdapter.ViewHolder>() {
    var onItemClick: ((Article) -> Unit)? = null
    private lateinit var binding: ItemNewsBinding

    inner class ViewHolder : RecyclerView.ViewHolder(binding.root) {

        init {
            binding.root.setOnClickListener {
                onItemClick?.invoke(mList[bindingAdapterPosition])
            }
        }

        fun setData(item: Article) {
            binding.apply {
                txtTitle.text = item.title
                txtContent.text = item.content
                txtDate.text = item.publishedAt
                Glide.with(this.root.context)
                    .load(item.urlToImage)
                    .placeholder(R.drawable.ic_placeholder)
                    .into(imgView)
            }
        }
    }

    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
        binding = ItemNewsBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)
        return ViewHolder()
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.setData(mList[position])
        holder.setIsRecyclable(false)
    }

    fun addList(newList: List<Article>) {
        val oldCount = mList.size
        mList.addAll(newList)
        notifyItemRangeInserted(oldCount, mList.size)
    }

    override fun getItemCount() = mList.size

}
import android.app.Dialog
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ActivityMainBinding
import com.example.kotlinprac.networkstate.two.data.model.Article
import com.example.kotlinprac.networkstate.two.data.network.ApiResponse
import com.example.kotlinprac.networkstate.two.data.network.Status
import com.example.kotlinprac.networkstate.two.ui.custom.ProgressDialog
import com.example.kotlinprac.networkstate.two.utils.EndlessRecyclerViewScrollListener
import com.example.kotlinprac.networkstate.two.utils.ErrorHandler
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private lateinit var mProgressDialog: Dialog
    private lateinit var binding: ActivityMainBinding
    private val mainViewModel: MainViewModel by viewModels()
    private var newsPage = 1
    private var newsAdapter = NewsAdapter(arrayListOf())
    lateinit var scrollListener: EndlessRecyclerViewScrollListener

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        mProgressDialog = ProgressDialog(this)
        setUpRecyclerView()

        mainViewModel.run {
            newsList.observe(this@MainActivity, ::consumeNewsListResult)
            headline.observe(this@MainActivity, ::consumeHeadlinesResult)
            getNewsList(newsPage)
            getHeadlines()
        }

    }

    private fun setUpRecyclerView() {
        val myRecyclerViewManager = LinearLayoutManager(this)
        scrollListener = object : EndlessRecyclerViewScrollListener(myRecyclerViewManager) {
            override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView) {
                newsPage++
                mainViewModel.getNewsList(newsPage)
            }
        }
        binding.recyclerNews.apply {
            layoutManager = myRecyclerViewManager
            addOnScrollListener(scrollListener)
            adapter = newsAdapter
        }

        newsAdapter.onItemClick = {
            startActivity(
                Intent(this, WebViewActivity::class.java)
                    .putExtra("articleUrl", it.url)
            )
        }
    }

    private fun bindHeadline(headline: Article?) {
        Glide.with(this)
            .load(headline?.urlToImage)
            .placeholder(R.drawable.ic_placeholder)
            .into(binding.imgHeadline)
        binding.txtTitle.text = headline?.title
        binding.txtDate.text = headline?.publishedAt
        binding.rltHeadlineContainer.setOnClickListener {
            startActivity(
                Intent(this, WebViewActivity::class.java)
                    .putExtra("articleUrl", headline?.url)
            )
        }
    }


    private fun consumeNewsListResult(resource: ApiResponse<List<Article>>) {
        when (resource.status) {
            Status.LOADING -> showLoading()
            Status.ERROR -> {
                hideLoading()
                ErrorHandler(this).handleError(resource.throwable)
            }
            Status.SUCCESS -> {
                hideLoading()
                newsAdapter.addList(resource.data.orEmpty())
            }
        }
    }


    private fun consumeHeadlinesResult(resource: ApiResponse<Article>) {
        when (resource.status) {
            Status.LOADING -> showLoading()
            Status.ERROR -> {
                hideLoading()
                ErrorHandler(this).handleError(resource.throwable)
            }
            Status.SUCCESS -> {
                hideLoading()
                bindHeadline(resource.data)
            }
        }
    }

    private fun showLoading() = mProgressDialog.show()
    private fun hideLoading() = mProgressDialog.cancel()

}
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.example.kotlinprac.databinding.ActivityWebViewBinding
import com.example.kotlinprac.networkstate.two.utils.DialogUtils

class WebViewActivity : AppCompatActivity() {

    private lateinit var binding: ActivityWebViewBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityWebViewBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val articleUrl = intent.getStringExtra("articleUrl")
        if (!articleUrl.isNullOrEmpty())
            binding.webView.loadUrl(articleUrl)
        else DialogUtils(this).showDialog("Error", "Article Url Not Found")

        supportActionBar?.setDisplayHomeAsUpEnabled(true)
        supportActionBar?.title = ""
    }

    override fun onSupportNavigateUp(): Boolean {
        onBackPressed()
        return true
    }
}

 

이렇게 하고 앱을 실행하면 아래처럼 작동할 것이다.

 

 

에뮬레이션에서 실행했는데 움짤 마지막에 녹색으로 변색되는 부분이 있다. 실제로 실행하면 저렇게 보이지 않는다.

아이템을 누르면 WebViewActivity로 이동해서 선택한 기사를 보여주고, 스크롤해서 맨 밑까지 스크롤하면 다음 페이지 데이터를 가져올 때까지 다이얼로그가 잠깐 보였다가 데이터를 가져오고 Status.SUCCESS를 받게 되면 다이얼로그를 없애고 새로 가져온 데이터를 마지막 아이템 뒤에 붙여서 추가로 스크롤할 수 있게 해 준다. Status.ERROR라면 다이얼로그를 없애고 에러를 표시하는 다이얼로그가 표시된다.

다이얼로그 표시, 숨김 처리는 액티비티에서 하는 게 아니라 데이터 바인딩을 써서 XML에서 처리할 수도 있다. 찾아보면 관련 내용들이 많이 나오니 다른 미흡한 부분들과 함께 수정해 보면 좋을 것이다.

반응형
Comments