1. 목표
삼성 기본 앱에 있는 날씨 앱처럼 구현하기 위해 연습하고 있다.
이번 프로젝트는 삼성 앱처럼 드로우 네비게이션 바를 구현하고, 그곳에 즐겨찾기 등록한 날씨 위치와 이 목록을 관리할 수 있는 페이지를 제작하는 것으로 계획하였다.
2. 구현 방식
2-1. 초기 설계
연습적인 목적이 있어서 이번 기회에 안드로이드 스튜디오에서 기본적으로 제공되는 드로우 네비게이션 레이아웃의 원리에 대해서도 같이 공부하면서 진행하기로 하였다. 공부 시간을 배로 늘린 주 원인
안드로이드 스튜디오에서 기본 제공되는 네비게이션 바를 그대로 사용하지는 않고, 그냥 필요한 부분만 가져다 썼다. 뭔가 nav_graph로 여러 Fragment를 관리하는 것이 좀 좋아 보여서 사용하고 하는 방식으로 말이다.
단순히 따라하는 것인데 중간에 멋대로 id를 바꾸거나 하는 점이 있어서 좀 많이 헤맸다.
특히, nav_graph에서 지정한 각 Fragment id랑 드로어 메뉴를 지정하는 곳에서 각 메뉴의 id가 같아야 id가 같은 것을 매칭하여 이동한다는 것을 깨닫기까지 좀 많은 시간이 걸렸다.
2-2. 작동 영상
2-3. 핵심 구현 코드 설명
Main Fragment는 그냥 초기 화면을 세팅해둔 것으로 없는 것이나 마찬가지이다. 핵심 부분은 바로 Add Fragment 부분. Add Fragment의 구조는 아래 그림과 같다.
[CityData]
data class CityData(
val name: String,
val date: String,
val icon: Int,
val temp: Double
)
리사이클러 뷰에 표시할 각 도시의 데이터를 담아둘 수 있는 데이터 클래스이다.
데이터 클래스를 만들었으면 이제 리사이클러 뷰와 Fragment를 연결해줄 AddAdapter을 만들어야 한다.
우선 리사이클러 뷰에 들어갈 아이템은 아래와 같이 생겼다.
[AddAdapter]
class AddAdapter(private val context: Context): RecyclerView.Adapter<AddAdapter.AddViewHolder>(){
var datas = mutableListOf<CityData>()
var cityNames = mutableListOf<String>()
val checkedItems = mutableListOf<Int>()
inner class AddViewHolder(private val _binding: ItemRecyclerBinding): RecyclerView.ViewHolder(_binding.root) {
private val checkBox = _binding.checkSelected
fun bind(item: CityData){
cityNames.add(item.name)
_binding.tvCity.text = item.name
_binding.tvTime.text = item.date
_binding.tvTemp.text = item.temp.toString()
Glide.with(itemView).load(item.icon).into(_binding.ivIconWeather)
checkBox.isChecked = checkedItems.contains(adapterPosition)
checkBox.setOnCheckedChangeListener{_, isChecked ->
if(isChecked){
checkedItems.add(adapterPosition)
Log.d("AddAdapter", "check ${adapterPosition}")
} else {
checkedItems.remove(adapterPosition)
Log.d("AddAdapter", "remove ${adapterPosition}")
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddViewHolder {
val binding = ItemRecyclerBinding.inflate(LayoutInflater.from(context), parent, false)
return AddViewHolder(binding)
}
override fun getItemCount(): Int {
return datas.size
}
override fun onBindViewHolder(holder: AddViewHolder, position: Int) {
val city = datas[position]
holder.bind(city)
}
}
뷰 바인딩을 이용하여 Adapter을 구현했습니다.
- onCreateViewHolder : 아이템 목록 xml을 뷰 바인딩으로 정의하여 초기화하고, 이것을 뷰 홀더에 전달한다.
- AddViewHolder : 리사이클러 뷰의 아이템을 보유하고, 해당 아이템의 데이터를 설정하는 역할을 가졌다. CityData 객체를 매개변수로 받아 해당 아이템의 뷰들을 재설정한다. 또한, 삭제하는 목록을 업데이트 하기 위해 체크 박스를 구현하였고, 체크가 된 아이템 리스트의 index를 저장하는 checkedItems 변수를 구현하였다.
- getItemCount : 리사이클러 뷰에 표현할 아이템의 개수를 반환한다. 여기서는 datas의 개수이다.
- onBindViewHolder : AddViewHolder에서 정의한 bind 함수를 이용하여 각 아이템의 postion에 맞게 데이터를 연결합니다.
[AddViewModel]
class AddViewModel: ViewModel() {
val datas: MutableLiveData<MutableList<CityData>> = MutableLiveData(mutableListOf())
init{
datas.value?.apply {
add(CityData(name = "Seoul", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 3.0))
add(CityData(name = "Busan", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 3.2))
add(CityData(name = "London", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 16.5))
add(CityData(name = "New York", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 4.0))
add(CityData(name = "Los Angeles", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 11.0))
add(CityData(name = "Cidny", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 23.0))
add(CityData(name = "ToKyo", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 42.2))
add(CityData(name = "InChon", date = "2023년 2월 25일", icon = R.drawable.va_sun, temp = 444.5))
}
}
fun addCityData(cityData: CityData) {
datas.value?.add(cityData)
datas.postValue(datas.value)
}
fun removeCityData(cityData: CityData) {
datas.value?.remove(cityData)
datas.postValue(datas.value)
}
}
Fragment랑 data랑 연결해주는 ViewModel입니다.
LiveData 방식으로 만약 구성 내용물이 변하면 바로 알 수 있도록 datas 변수를 지정하였고, datas 변수에 초기값을 대충 세팅해주었습니다.
- addCitiyData : LiveData datas에 CityData를 추가하는 구분입니다. datas 변수에 매개변수로 받은 cityData를 추가하고, 이를 postValue 메소드르 datas를 업데이트 합니다.
- removeCityData : 반대로 기존에 존재하던 cityData를 삭제하기 위해 만든 함수입니다.
[HorizontalItemDecorator, VerticalItemDecorator]
class HorizontalItemDecorator(private val divHeight: Int): RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = divHeight
outRect.right = divHeight
}
}
class VerticalItemDecorator(private val divHeight: Int):RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.top = divHeight
outRect.bottom = divHeight
}
}
리사이클러 뷰를 카드 뷰를 통해 보기 좋게 제작하였습니다. 하지만 이럴 경우 리사이클러 뷰 아이템끼리 간격이 없어 다닥다닥 붙어 출력되는 보기 좋지 않은 상황이 발생합니다.
위 코드는 그런 경우를 해결 하기 위한 코드입니다. 클래스 이름대로 각각 수직과 수평 방향의 margin을 추가해주는 역할을 맡습니다.
[AddFragment]
class AddFragment : Fragment() {
private var _binding: FragmentAddBinding? = null
private lateinit var addAdapter: AddAdapter
private lateinit var addViewModel: AddViewModel
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAddBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
initRecycler()
addNavigationItems()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.dynamic_menu, menu)
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when(item.itemId) {
R.id.menu_add -> {
// handle add menu item click
val builder = AlertDialog.Builder(requireContext())
val builderItem = AlertdialogEdittextBinding.inflate(layoutInflater)
val editText = builderItem.editText
with(builder) {
setTitle("Input City")
setMessage("도시를 입력하시오")
setView(builderItem.root)
setPositiveButton("Ok") { dialogInterface: DialogInterface, i: Int ->
if (editText.text != null && (editText.text.toString() !in addAdapter.cityNames)) {
val cityData = CityData(
name = editText.text.toString(),
date = "2023년 2월 25일",
icon = R.drawable.va_sun,
temp = 3.0
)
addViewModel.addCityData(cityData)
Toast.makeText(
requireContext(),
"입력된 이름 : ${editText.text}",
Toast.LENGTH_SHORT
).show()
}
}
show()
}
true
}
R.id.menu_delete -> {
val sorted = addAdapter.checkedItems.sorted().reversed()
Log.d("AddFragment", "$sorted")
Log.d("AddFragment", "${addViewModel.datas.value}")
for (i in sorted) {
addAdapter.cityNames.removeAt(i)
addViewModel.removeCityData(addViewModel.datas.value?.get(i)!!)
Log.d("AddFragment", "delete")
}
addAdapter.checkedItems.clear()
Log.d("AddFragment", "${addViewModel.datas.value}")
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun initRecycler(){
addViewModel = ViewModelProvider(requireActivity())[AddViewModel::class.java]
addAdapter = AddAdapter(requireContext())
binding.recyclerView.adapter = addAdapter
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.addItemDecoration(VerticalItemDecorator(20))
binding.recyclerView.addItemDecoration(HorizontalItemDecorator(10))
addViewModel.datas.observe(viewLifecycleOwner){
addAdapter.datas = it
addAdapter.notifyDataSetChanged()
}
}
private fun addNavigationItems() {
val activity = requireActivity() as MainActivity
val navView = activity.findViewById<NavigationView>(R.id.nav_view)
val menu = navView.menu
addViewModel.datas.observe(viewLifecycleOwner) { cities ->
// Remove all existing menu items
menu.removeGroup(R.id.add_fragment)
// Add new menu items for cities that are not in the menu
cities.forEachIndexed { index, cityData ->
if (menu.findItem(index) == null) {
menu.add(R.id.add_fragment, index, index, cityData.name).setIcon(cityData.icon)
}
}
}
navView.setNavigationItemSelectedListener { menuItem ->
val cityData = addViewModel.datas.value?.get(menuItem.itemId)
if (cityData != null) {
Toast.makeText(requireContext(), "${cityData.name}을 선택했습니다.", Toast.LENGTH_SHORT).show()
}
true
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
이제 메인인 AddFragment 클래스입니다. Fragment를 구성하는 클래스이며, 상위 액티비티로는 MainActivity가 존재합니다. MainActivity의 하나의 화면을 보여주는 작은 구성품 중 하나라고 생각하시면 편합니다.
- onCreateView: 뷰에서 인플레이트를 반환합니다. 뷰를 제작한다고 생각하시면 편합니다.
- onViewCreated: 뷰가 생성되면 이제 리사이클러뷰와 어댑터를 초기화하고, 동적 메뉴를 부르기 위해 setHasOptionsMenu를 true로 설정합니다.
- onCreateOptionsMenu: 메소드에서 액티비티의 옵션 메뉴를 인플레이트합니다.
- onOptionsItemSelected: 옵션 메뉴에 있는 아이템이 선택되었을 때의 이벤트를 설정합니다. Add 버튼을 누르면 입력 다이얼로그를 출력하여 입력할 수 있도록 하고, 입력을 하면 addAdapter.cityNames로 기존에 있었던 요소와 곂치는 것이 있는 지 검사한 후에 업데이트합니다. 삭제 버튼도 마찬가지로 체크 박스로 체크한 아이템들의 position을 담고 있는 checkedItems의 요소를 역순으로 정렬한 후에 하나씩 삭제하는 과정을 실행합니다. 여기서 역순으로 정리하는 이유는 앞의 것을 삭제해서 position이 변경되어 엉뚱한 객체를 삭제하는 것을 방지하기 위함입니다. 그리고 삭제를 마치면 check 리스트를 전부 비워줍니다.
- initRecycler: AddViewModel 객체를 생성하고 리사이클러뷰와 어댑터를 초기화합니다.
- addNavigationItems: 이제 LiveData인 datas에서 변화가 감지된다면(observe로 확인) 네비게이션 바에 있는 요소들도 업데이트를 시작합니다. 이 경우에는 position이 변경이 일어나서 하나하나 바꾸기에는 IndexOutOfBoundsException가 발생할 수 있으니. 조금 낭비가 있더라도 안전하게 처음에 그냥 목록을 다 지우고, 새로 변경된 datas를 기반으로 메뉴 목록을 생성하도록 만들었습니다. 아직 초보라 정확하게 사용하기 힘들어서 그냥 이렇게 만들었습니다.
- 마지막으로 Fragment에서 벗어나면 onDestroyView를 통해 뷰 바인딩한 것을 null로 되돌립니다.
3. 최종 정리
생각보다 복잡한 것도 많았고, 실제 날씨 앱에서는 RoomDatabase 방식을 이용할거라 요소를 추가하거나 삭제하는 면에서는 조금 더 코드가 깔끔해질 것이라고 생각하였습니다. 라이브러리가 괜히 필요한게...
'Kotlin > 개인 프로젝트' 카테고리의 다른 글
날씨 앱 제작: 전체적인 작동 구조 고안 (0) | 2023.03.08 |
---|---|
개인 프로젝트 : 코틀린으로 To-do List - MVVM 사용하기 (0) | 2023.02.19 |
개인 프로젝트 : 코틀린으로 To-do List - Room Database 이용하기 (0) | 2023.02.19 |