Data Binding with view model Part 4

Suriya Wongkasum
5 min readAug 13, 2020

--

หลังจากที่เราทำส่วนแสดงผลข้อมูลโน็ตเสร็จแล้ว ใน Part 4 นี้ก็จะเป็นส่วนการแสดงผลรายละเอียดข้อมูลโน็ต จากที่เราใส่ action เมื่อกด item view ให้เปิดไปหน้า NoteDetailFragment พร้อมทั้งส่ง noteId ผ่าน bundle ไปแล้ว

val adapter = NoteAdapter(OnClickItemListener {
val bundle = bundleOf("noteId" to it)
this.findNavController()
.navigate(R.id.action_homeFragment_to_detailFragment, bundle)
})

ภาพรวมหลักของหน้า NoteDetailFragment หลักประกอบไปด้วย

  • แสดง tittle: หัวข้อโน็ต
  • รายละเอียดโน็ต
  • ปุ่มลบรายการโน็ต
  • ปุ่มแก้ไขข้อมูลโน็ต
  • เริ่มจากพัฒนาสร้าง

เริ่มจากสร้าง NoteDetailViewModel class ที่ใส่ Business logic ที่เกี่ยวข้องกับการแสดงรายละเอียดโน็ตทั้งหมด และ view model factory แล้วก็ทำการผูก data เข้ากับ view model ที่ NoteDetailFragment

class NoteDetailFragment : Fragment() {

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {

val binding: DetailFragmentBinding =
DataBindingUtil.inflate(inflater, R.layout.detail_fragment, container, false)

val dataSource = NoteDatabase.getInstance(requireContext()).noteDao
arguments?.getLong("noteId")?.let { noteId ->
val factory = NoteDetailViewModelFactory(dataSource, requireContext(), noteId)
val noteDetailViewModel =
ViewModelProvider(this, factory).get(NoteDetailViewModel::class.java)
binding.noteViewModel = noteDetailViewModel

}
binding.lifecycleOwner = this

return binding.root
}
}
class NoteDetailViewModel(
private val dataSource: NoteDao,
private val context: Context,
private val noteId: Long
) :
ViewModel() {

private val viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)

private val dao = dataSource
private var _note: LiveData<NoteItem>

fun getNote() = _note

private var _isBackToHome = MutableLiveData<Boolean>()
val isBackToHome: LiveData<Boolean>
get() = _isBackToHome

private var _isNavigateToEdit = MutableLiveData<Boolean?>()

val isNavigateToEditNote: LiveData<Boolean?>
get() = _isNavigateToEdit

init {
_note = dao.getNoteById(noteId)
}

fun getNoteTitle(): String {
val title = context.getString(R.string.title)
return String.format("%s: %s", title, _note.value?.title)
}

fun onDeleteNote() {
uiScope.launch {
deleteNoteById(noteId)
}
backToHome()
}

private suspend fun deleteNoteById(id: Long) {
withContext(Dispatchers.IO) {
dataSource.deleteNoteById(id)
}
Toast.makeText(context, "Delete Complete", Toast.LENGTH_SHORT).show()
}


fun gotoEditNote() {
_isNavigateToEdit.postValue(true)
}

fun doneNavigateToEditNote() {
_isNavigateToEdit.value = null
}

private fun backToHome() {
_isBackToHome.postValue(true)
}
}

เริ่มจาก UI ด้านบนจะเห็นว่าในส่วนของ title จะเป็นการต่อ string ถ้าเกิดจะต้องต่อ string จาก string resource กับ data จาก view model ละจะต้องทำยังงัย ถ้าทำแบบสร้าง getNoteTitle() ที่ NoteDetailViewModel แล้วเรียกใช้ที่ tv_title ที่ใช้แสดง title

fun getNoteTitle(): String {
val title = context.getString(R.string.title)
return String.format("%s: %s", title, _note.value?.title)
}

detail_fragment.xml

<TextView
android:id="@+id/tv_title"
...
android:text="@{noteViewModel.noteTitle}"
...
/>

ลอง build app ทดสอบดูพบว่า ค่าของ title เป็น null

สาเหตุที่ค่าของ title มีค่าเป็น null ถ้าดูจาก getNoteTitle()

fun getNoteTitle(): String {
val title = context.getString(R.string.title)
return String.format("%s: %s", title, _note.value?.title)
}

จะเห็นว่าค่าของ title ที่เป็น null ถูก set ค่าด้วย _note.value?.title ซึ่งตัวแปร _note มี type เป็น live data ซึ่งจะต้องอัพเดททุกครั้งที่ข้อมูลมีการเปลี่ยนแปลง แต่แล้วทำไมเมื่อ load ได้ข้อมูลมาแล้วยังมีค่าเป็น null ต้องไปดูสาเหตุจริงๆ ที่ NoteItem class

@Entity(tableName = "note_table")
data class NoteItem(

@PrimaryKey(autoGenerate = true)
val id: Long = 0L,

@ColumnInfo(name = "note")
var note: String,

@ColumnInfo(name = "title")
var title: String
)

เป็นเพราะว่าค่าของ title ไม่ได้มี type เป็น LiveData<String> เมื่อตัวแปร _note get ค่า room database เสร็จแล้วจึงทำให้ค่าของตัว title ไม่ถูกอัพเดทไปแสดงผลใน text view

แล้วเราจะแก้ปัญหานี้ได้อย่างไร?

ถ้าแก้ type ของ title เป็น LiveData<String> ละ มันก็ไม่ใช่วิธีที่ควรทำอยู่ดีเพราะว่าสมมติเราจะ set ค่าแบบเดียวกับ title ในเคสนี้เยอะละเราก็ต้องไป get ค่าตัวแปร value เยอะเหมือนกันมันจะทำให้เราต้องเหนื่อยเปล่าๆ

สิ่งที่จะมาช่วยให้เรา set ค่า title ง่ายๆ โดยไม่ต้องไปตามเปลี่ยน type ก็คือ

data binding adapter ซึ่งเป็น annotation สำหรับทำ extension ที่ใช้ set เป็น attribute ใน xml layout ได้เลย ซึ่งสามารถรับค่าเป็น parameter จากแท็ค data ได้เลย

วิธีทำ data binding adapter

สามารถทำง่ายๆ เลย โดยการสร้าง data binding adapter class เพื่อเก็บ function ต่างๆ แยกออกตามหน้านั้นๆ

เริ่มจากใส่ annotation @BindingAdapter(value = [“attribute name”]) ให้ ที่ข้างบน function นั้นเลย โดยจำนวน attribute name ต้องใส่ตามจำนวน parameter ของ function นั้นโดยจะไม่นับ parameter ตัวแรก เพราะ parameter ตัวแรกจะรับเฉพาะ parameter ที่เป็น view เท่านั้น

ในกรณีนี้เราต้องการ set ค่า title แบบต่อ string ให้ text view ก็จะได้

@BindingAdapter(value = ["app:setNoteTitle"])
fun setNoteTitle(textView: TextView, note:LiveData<NoteItem>) {
val title = textView.context.getString(R.string.title)
textView.text = String.format("%s: %s", title, note.value?.title)
}

เราสามารถ set ค่าที่ text view ได้เลยผ่าน attribute app:setNoteTitle

<TextView
android:id="@+id/tv_title"
...
app:setNoteTitle="@{noteViewModel.note}" />

เมื่อทำ data binding adapter สำหรับ set ค่า แสดงผลเสร็จแล้ว ทดสอบ run ดูผลลัพธ์ก็จะได้ดังนี้

เพิ่ม action ให้กับปุ่ม Delete

<Button
android:id="@+id/btn_delete"
...
android:onClick="@{()-> noteViewModel.onDeleteNote()}"
/>

ใน onDeleteNote function ที่ NoteDetailViewModel เมื่อทำการลบข้อมูลเสร็จแล้วก็จะ post value ให้กับ _isBackToHome เพื่อค่าให้ subscriber ทำ action เพื่อกลับไปหน้า home fragment

noteDetailViewModel.isBackToHome.observe(viewLifecycleOwner, Observer { isBack ->
if (isBack) {
requireActivity().onBackPressed()
}
})

ลอง build app ทดสอบ เมื่อกดปุ่มลบข้อมูลโน็ตเมื่อลบเสร็จจะแสดงข้อความ Delete Complete แล้วกลับไปหน้า home fragment ข้อมูลที่ถูกลบจะไม่แสดง

และส่วนสุดท้ายปุ่มแก้ไขข้อมูลโน็ต เมื่อกดปุ่ม edit ก็จะส่งทำการ post value ให้กับ _isNavigateToEdit เพื่อเป็น trigger ให้ NoteDetailFragment เปิดไปหน้า AddNoteFragment พร้อมทั้งส่ง noteId ไปด้วย

noteDetailViewModel.isNavigateToEditNote.observe(
viewLifecycleOwner,
Observer { isEdit ->
isEdit?.let {
val id = bundleOf("noteId" to noteId)
this.findNavController()
.navigate(R.id.action_detailFragment_to_addFragment, id)
noteDetailViewModel.doneNavigateToEditNote()
}
}
)

ไปที่หน้า AddFragment ทำการเพิ่ม parameter noteId ที่ AddNoteFactory classและ AddNoteViewModel class สำหรับดึงค่า note จาก id ที่ส่งมาจากหน้า detail

class AddNoteFragment : Fragment() {

override fun onCreateView(...){
//case edit note
val noteId = arguments?.getLong("noteId")
val factory =
AddNoteFactory(dataSource = dataSource, context = requireContext(), noteId = noteId)

เพิ่มตัวแปร _note ที่ AddNoteFactory class สำหรับเก็บค่า note ข้อมูลโน็ตจาก noteId และตัวแปร note สำหรับให้ class อื่น get ค่า

class AddNoteViewModel(..){
...
private val _note = dataSource.getNoteById(noteId) val note: LiveData<NoteItem>
get() = _note

เพิ่มการ subscribe ตัวแปร note ที่หน้า HomeFragment เพื่อกำหนดค่า title และ noteDetail ให้แสดงผลในค่าของข้อมูลโน็ตที่จะแก้ไข

addNoteViewModel.note.observe(viewLifecycleOwner, Observer {
it
?.let {
addNoteViewModel.title.postValue(it.title)
addNoteViewModel.noteDetail.postValue(it.note)
}
}
)

เพิ่มตัวแปร isEditNote type เป็น boolean เพื่อ check edit mode และเพิ่ม fun อัพเดทข้อมูลที่ AddNoteViewModel class และกำหนดค่าเป็น edit mode เมื่อ noteId มีค่าไม่เป็น null

class AddNoteViewModel(...){private val _note = dataSource.getNoteById(noteId)var isEditNote = falseinit {
noteId?.let {
isEditNote = true
}
}
private suspend fun updateData() {
val detail = this.noteDetail.value ?: ""
val title = this.title.value ?: ""

val isValidateNote = detail.isNotEmpty() && title.isNotEmpty()

if (isValidateNote) {
withContext(Dispatchers.IO) {
_note.value?.title = title
_note.value?.note = detail
dataSource.updateNote(noteItem = _note.value)
}
_isSaveNote.postValue(true)
Toast.makeText(context, "Edit is complete", Toast.LENGTH_SHORT).show()
} else {
showInvalidData()
}
}

แก้ไข onSaveFunction ให้ check เพื่อเช็ค edit mode ตอนกดปุ่ม save

fun onSave() {
uiScope.launch {
if (isEditNote) {
updateData()
} else {
insertData()
}
}
}

build app ทดสอบการแก้ไขดู เมื่อแก้ไขข้อมูลโน็ตแล้วกลับมาหน้า home และเปิดดูหน้ารายละเอียดโน็ต ข้อมูลจะต้องแสดงตามที่ถูกแก้ไข

หากมีข้อสงสัยหรือมีจุดไหนผิดพลาดสามารถคอมเม้นได้นะครับ

source code เพิ่มเติม click

จบแล้วววครับ…

--

--