일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 안드로이드 레트로핏 crud
- 큐 자바 코드
- 안드로이드 유닛 테스트
- rxjava hot observable
- 스택 자바 코드
- 안드로이드 라이선스
- android ar 개발
- ar vr 차이
- jvm이란
- 플러터 설치 2022
- 클래스
- 안드로이드 레트로핏 사용법
- 안드로이드 라이선스 종류
- 멤버변수
- 스택 큐 차이
- jvm 작동 원리
- android retrofit login
- 2022 플러터 안드로이드 스튜디오
- 안드로이드 os 구조
- 안드로이드 유닛테스트란
- rxjava cold observable
- rxjava disposable
- 서비스 vs 쓰레드
- 객체
- 2022 플러터 설치
- 서비스 쓰레드 차이
- Rxjava Observable
- 안드로이드 유닛 테스트 예시
- ANR이란
- 자바 다형성
- Today
- Total
나만을 위한 블로그
[Android] Coroutine + Retrofit + Hilt + LiveData를 써서 네트워크 상태 별 처리하기 & 리사이클러뷰 페이징 본문
[Android] Coroutine + Retrofit + Hilt + LiveData를 써서 네트워크 상태 별 처리하기 & 리사이클러뷰 페이징
참깨빵위에참깨빵 2023. 3. 21. 22:56※ 이 포스팅에서 사용하는 페이징은 페이징 라이브러리를 사용한 구현이 아니다.
이 포스팅은 아래 링크를 바탕으로 작성됐다.
https://medium.com/@rafiz19/handling-network-call-states-in-kotlin-coroutines-91aff82781a9
사용하는 API는 NewsAPI라는 무료 API다. API 키를 받아서 사용할 수 있게 준비한다.
레트로핏 등 라이브러리를 프로젝트에 설정하는 부분은 생략한다. 패키지는 아래와 같은 구조로 미리 만들어둔다.
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에서 처리할 수도 있다. 찾아보면 관련 내용들이 많이 나오니 다른 미흡한 부분들과 함께 수정해 보면 좋을 것이다.
'Android' 카테고리의 다른 글
[Android] CameraX 코드랩 뜯어보기 - 2 - (0) | 2023.03.26 |
---|---|
[Android] dataUrl이란? 웹뷰로 dataUrl 전송하는 법 (0) | 2023.03.25 |
[Android] hilt 적용 후 단위 테스트 작성 시 @HiltAndroidTest Not found 에러 해결 (0) | 2023.03.15 |
[Android] 단위 테스트 실행 시 Method getMainLooper in android.os.Looper not mocked 에러 해결 (0) | 2023.03.15 |
[Android] 단위 테스트 실행 시 No tests found for given includes 에러 해결 (0) | 2023.03.15 |