일일 회고
우선 개인과제 기능 구현 및 수정 단계는 마무리가 되었다. 그래서 오늘은 TIL을 정리 글로 써보았다!
아마 추가적인 기능구현은 안 할 것 같고, 버그가 발견되면 수정하는 정도로만 진행할 것 같다.
내일부터는 선발대 과제 및 아키텍쳐 관련해서 공부를 해봐야겠다.
공부하면 할 수록 할게 더 많이 나오는 느낌이다... 어렵지만 해보자!
오늘의 키워드
- 개인 과제 정리
개인 과제 정리
첫 번째 fragment : 검색 결과
- 검색어를 입력할 수 있습니다.
- EditText를 이용하여 텍스트 필드를 받을 수 있도록 함
- EditText에 검색 옵션을 주기 위하여 imeOption 속성을 'actionSearch'로 지정
- 검색된 이미지 리스트가 나타납니다. 각 아이템에는 이미지와 함께 날짜와 시간을 표시합니다. + 두 검색 결과를 datetime 필드를 이용해 정렬하여 출력합니다. (최신부터 나타나도록)
- 검색 버튼을 누르거나, 엔터키를 치거나, 가상키보드의 검색 버튼을 누르면 검색을 시작함 (단, 검색어가 비어있으면 검색되지 않도록 막아놓음)
- 엔터키, 가상키보드는 EditText의 setOnEditorActionListener를 활용
- 검색 액션 시, 구현해놓은 Retrofit 연결을 통해 KaKao search service를 실행하여 데이터를 가져옴
- 최신순 데이터를 가져오기 위해 쿼리 파리미터 중 'sort'를 'recency'로 고정
- 가져온 데이터는 속성이 굉장히 많은데, 그중에서 필요한 thumnail, title, time을 가져옴 (기타로 이미지 크기가 너무 제각각이길래 크 조정을 위해 width, height값도 가져와봤음)
- time 속성의 경우 DateTime 형식으로 온다고 했는데, JSON으로 받는 순간, DateTime 형식이 유지된 String타입으로 들어와 확장함수를 통해 출력형식 변환 및 정렬을 위해 Date로 형변환 해주는 함수를 구현함 (최신 순으로 가져왔어도, 다시 한번 정렬을 해주기 위해 Date로 형변환)
searchEditText.setOnEditorActionListener { editText, actionId, keyEvent ->
if (actionId == EditorInfo.IME_ACTION_SEARCH || keyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
// 검색 로직...
}
true
}
- 스크롤을 통해 다음 페이지를 불러옵니다.
- 문서를 보면 is_end라는 속성이 있는데, 다음과 같다.
- 현재 페이지가 마지막 페이지인지 여부, 값이 false면 page를 증가시켜 다음 페이지를 요청할 수 있음
- 이 속성을 활용해서 다음 페이지 불러오는 기능을 구현하였다.
- 데이터를 가져올 떄, Response값에서 is_end값을 따로 뽑아내 저장한다.
- 그리고 RecyclerView의 OnScrollListener를 통해 최하단이 감지되면 페이지 수를 1 증가시키고, 다시 데이터를 가져와 RecylclerView에 바인딩시킨다. 이로써 스크롤을 통해 다음페이지를 불러와볼 수 있다.
private val endScrollListener by lazy {
object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
// 스크롤이 움직이고 있는 상태이고
if (newState == RecyclerView.SCROLL_STATE_IDLE
// 최하단이고
&& !binding.searchRecyclerView.canScrollVertically(1)
) {
// is_end 값이 false라면
if (searchViewModel.isEndClip == false && searchViewModel.isEndImage == false) {
// 페이지를 증가시키고, 다음 페이지 가져옴
page++
fetchItems(binding.searchEditText.text.toString(), page, SCROLL_BOTTOM)
}
}
}
}
}
- 리스트에서 특정 이미지를 선택하여 '내 보관함'으로 저장할 수 있습니다. + 이미 보관된 이미지는 특별한 표시를 보여줍니다. (좋아요/별표/하트 등)
- RecyclerView Item에 먼저 표시를 할 별표를 추가
- CheckedChangeListener를 통해 체크가 되면, SharedPreference를 통해 현재 Recycler Item의 모델을 저장 (보관함에서 보여주려면 모델 정보가 필요하므로 모델 통째로 저장함)
- 모델을 쉽게 SharedPreference에 저장하기 위해서 Gson 라이브러리를 사용
- 저장이 완료되면 노란 별 표시로 바뀜
fun setModel(key: String, value: IntegratedModel?) {
val editor = prefs.edit()
// Gson을 통해 모델 객체를 Json 형태의 문자열로 변환
val model = Gson().toJson(value, IntegratedModel::class.java)
if(value != null){
editor.putString(key, model)
} else {
editor.putString(key, null)
}
editor.apply()
}
fun getModel(key: String): IntegratedModel? {
// json 값을 받아와 리스트로 바꿀 준비
val jsonList = prefs.getString(key, null)
val gson = GsonBuilder()
return gson.gsonToIntegrateModel(jsonList)
}
fun GsonBuilder.gsonToIntegrateModel(value: String?): IntegratedModel? {
// Gson을 통해 Json 형식의 문자열을 Model 타입으로 변환
return this.create().fromJson(value, IntegratedModel::class.java)
}
- 보관된 이미지를 다시 선택하여 보관함에서 제거 가능합니다.
- 별표시가 된 이미지를 다시 한번 선택하면, 해제되며, SharedPreference에서 삭제
- 보관함을 가보면 사라진 것을 볼 수 있음
- 마지막 검색어는 저장되며, 앱 재시작 시 마지막 검색어가 입력 필드에 자동으로 입력됩니다. + 검색어 삭제 기능
- 검색 액션(엔터, 버튼, 가상키보드)을 할 때마다, 현재의 검색어를 SharedPreference에 저장
- 검색할 때마다, 모든 검색어를 저장하는 것이 아닌 1개의 preference에다가 업데이트를 시킴
- 앱 재시작, 다른 화면으로 갔다 올 시 등 검색화면으로 돌아오면 검색어를 EditText에 넣고 자동 검색 실행
- EditText의 우측에 X 버튼이 있는데, 누르면 SharedPreference에서 검색어를 삭제
두 번째 fragment: 내 보관함
- 검색 결과에서 보관했던 이미지들이 보관한 순서대로 보입니다. + 보관한 이미지 리스트는 앱 재시작 후 다시 보여야 합니다. (DB 관련 라이브러리 사용 금지. SharedPreferences 사용 권장)
-
- 위의 보관함 기능과 연계됨
- 이미지를 보관을 할 때, 시간 순이 아닌 보관한 순서대로 보이려면, 클릭을 한 순서를 기억하고 있어야 함
- 그래서 별표를 누를 때, 모델에 ordering이라는 속성을 추가해서 클릭한 순서를 같이 저장했음
- 문제는 모델에 넣는 값이기 때문에, 앱을 껐다 켜면, ordering값을 넣는 부분에서 값이 초기화가 되는데, 이러한 현상을 막기 위해, ordering 값도 sharedPreference에 저장하여 순서를 기억하도록 하였음
- 이렇게 해서, 앱을 껐다 켜서, 별표를 다시 해도 순서가 유지된 채로 보관함 목록을 볼 수 있음
- ordering 값은 별표를 누를 때마다 1씩 증가시켜 주었음 (Long 타입)
likedCheckBox.setOnCheckedChangeListener { _, isChecked ->
isLikedResources(isChecked, likedCheckBox)
if (model.isLiked != isChecked) {
// 순서를 1개 증가시키고
App.prefs.id = ++prefsId
onStarChecked(
// 증가 시킨 모델을 copy해서, onStarChecked lambda식으로 넘김
// onStarChecked는 fragment에서 상세 구현됨
model.copy(
isLiked = isChecked,
ordering = prefsId
)
)
}
}
- 보관함 전체 삭제 기능 추가
- 보관함 화면에서 Floating Action Button을 추가하였음
- FAB를 누르면 SharedPreference에 있는 모든 항목을 제거하고 (보관함 순서도 함께 제거)
- RecyclerView의 list를 clear 시킴 (RecyclerView.Adapter를 사용했을 때는 clear를 하였고, ListAdapter - DiffUtil로 전환하면서 submitList(emptyList())를 통해 빈 리스트로 바꾸는 동작을 함)
fun clear() {
// 보관함 모델 제거
prefs.edit().clear().apply()
// 보관함 순서 제거
orderingPrefs.edit().clear().apply()
}
// RecyclerAdapter
saveAdapter.clearItems()
fun clearItems() {
_list.clear()
notifyDataSetChanged()
}
// listAdapter - diffutil
saveAdapter.submitList(emptyList())
적혀있지 않은 내용은 자유롭게 작성하시면 됩니다. (요건을 침해하지 않는 범위에서 기능 추가 등)
1. MVVM 패턴 연습 ( + Repository 패턴 적용)
UI Layer - Data Layer를 나누어서 UI Layer에서는 RecyclerView의 Item이나 검색, 스크롤 관련 이벤트만 맡도록 하고,
Data Layer에서 API를 통해 가져온 데이터를 뷰모델 및 Repository에서 가공하였음
UI Layer의 View(fragment)에서는, ViewModel의 LiveData를 Observe 하여 API에서 가져온 값의 변화를 관찰하여, RecyclerView를 업데이트해주도록 함
ViewModel 및 View의 연결은 Sealed Class를 통해 API의 상태 (Success, Error, Loading)에 따라, View의 동작을 처리 해주었음
private fun fetchItems(query: String, page: Int, scrollFlag: Int) = with(binding) {
searchViewModel.getDatas(AUTHORIZATION, query, page, scrollFlag)
searchViewModel.state.observe(viewLifecycleOwner) {
when (it) {
is APIResponse.Error -> {
searchRecyclerView.isVisible = false
progressbar.isVisible = false
updateProgressbar.isVisible = false
requireActivity().toast("오류 발생")
Log.e("error", it.message.toString())
}
is APIResponse.Loading -> {
// 최하단 스크롤일때 -- 하단에서 업데이트 로딩바를 보여줌
if (it.data == null) {
progressbar.isVisible = false
updateProgressbar.isVisible = true
searchRecyclerView.isVisible = true
} else { // 그외 - 검색 액션
updateProgressbar.isVisible = false
progressbar.isVisible = true
searchRecyclerView.isVisible = false
}
}
is APIResponse.Success -> {
searchRecyclerView.isVisible = true
// 마지막 스크롤일떄마다 늘어나는 거 확인
Log.e("!!!!!size!!!!!!!! ", searchAdapter.currentList.size.toString())
it.data?.let { data ->
searchAdapter.submitList(data.toMutableList())
}
progressbar.isVisible = false
updateProgressbar.isVisible = false
}
}
}
}
2. ListAdapter - DiffUtil 적용
기존 RecyclerView.Adapter에서 ListAdapter - DiffUtil로 전환을 하였다.
우선 사용하는 모델에 DiffUtil 객체를 만들어 주었다.
data class IntegratedModel(
val thumbnailUrl: String?,
val title: String?,
val dateTime: Date?,
var height: Int = 500,
var width: Int = 1000,
var isLiked: Boolean = false,
var ordering: Long = 0L,
var isEnd: Boolean?
) {
companion object {
val DIFF_CALLBACK = object: DiffUtil.ItemCallback<IntegratedModel>() {
override fun areItemsTheSame(
oldItem: IntegratedModel,
newItem: IntegratedModel
): Boolean = oldItem.thumbnailUrl == newItem.thumbnailUrl
override fun areContentsTheSame(
oldItem: IntegratedModel,
newItem: IntegratedModel
): Boolean = oldItem == newItem
}
}
}
DiffUtil을 적용하려면 모델의 특유의 고유 값이 필요한데, thumbnail_url이 이미지마다 각각 다른 url이어서 따로 필드를 만들지 않고, Uril을 ID 값으로 삼아 사용하였다.
어댑터 같은 경우는
RecyclerView.Adapter<SearchListAdapter.SearchViewHolder>()
에서
ListAdapter<IntegratedModel, SearchListViewAdapter.SearchViewHolder>(IntegratedModel.DIFF_CALLBACK)
타입으로 변경하였고, 기존 Recylcer View Adapter 내에서 item list를 관리하였는데, List Adapter는 자체적으로 관리하는 리스트가 있고, submitList()라는 메서드로 리스트를 갱신할 수 있기 때문에, 리스트 관리하는 코드를 모두 제거할 수 있었다.
3. 인터넷 감지 기능 적용해 보기
ConnectivityManager의 NetworkCallback과 LiveData 활용해서 간단하게 구현해 보았다.
4. Data Binding 시도해 보기
Data Binding은 완전 처음 해보는 것이라, 미숙해서 Main Branch에는 올리지 않았고 feature/databinding 인 서브 branch에 올려두었다. ( 연습입니다!)
++ 가능하다면 보관함에서 개별 삭제 기능도 구현해 보기! (완료! 2번째 시연영상에서 확인 가능)
- ViewHolder의 LongClickListener를 통해 구현
시연 영상
ref.
'내일배움캠프 7기 > TIL' 카테고리의 다른 글
내일배움캠프 7기 Android TIL 50일차 (2023.09.22) (0) | 2023.09.22 |
---|---|
내일배움캠프 7기 Android TIL 49일차 (2023.09.21) (0) | 2023.09.21 |
내일배움캠프 7기 Android TIL 47일차 (2023.09.19) (1) | 2023.09.19 |
내일배움캠프 7기 Android TIL 46일차 (2023.09.18) (0) | 2023.09.18 |
내일배움캠프 7기 Android TIL 45일차 (2023.09.15) (0) | 2023.09.15 |