本篇延續Day15 MVVM專案-2 Simple Fragment -3 DI
今天會用Data Binding, ViewModel, LiveData 構築一個RecyclerView
內含新增,移除,編輯等基本功能
成品圖
資料部分
S03DataRepository.kt
interface S03DataRepository<T> {
val list: List<T>
fun add(item: T)
fun removeAt(idx: Int)
}
S03Memo.kt
data class S03Memo(var content: String)
S03MemoRepository.kt
class S03MemoRepository : S03DataRepository<S03Memo> {
private val memos by lazy {
"The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically."
.split(' ')
.map(::S03Memo)
.toMutableList()
}
override val list: List<S03Memo> by lazy { memos }
override fun add(item: S03Memo) {
memos.add(0, item)
}
override fun removeAt(idx: Int) {
memos.removeAt(idx)
}
}
viewmodel
S03ViewModel.kt
class S03ViewModel(private val repository: S03DataRepository<S03Memo> = S03MemoRepository()) :
ViewModel() {
sealed class ListAction {
class Added : ListAction()
class Removed(val memo: S03Memo, val idx: Int) : ListAction()
}
val memos: LiveData<List<S03Memo>> = MutableLiveData<List<S03Memo>>().apply {
value = repository.list
}
val listAction: MutableLiveData<ListAction> = MutableLiveData()
val newContent: MutableLiveData<String> = MutableLiveData()
val sizeOfMemos: Int = memos.value!!.size
fun remove(memo: List<S03Memo>, idx: Int) {
if (idx < 0) return
repository.removeAt(idx)
listAction.value = ListAction.Removed(memo[idx], idx)
}
fun add() {
if (newContent.value == null || newContent.value.equals("")) return
repository.add(S03Memo(newContent.value ?: ""))
newContent.value = ""
listAction.value = ListAction.Added()
}
}
S03MemoAdapter.kt
typealias OnListItemEvent = (List<S03Memo>, Int) -> Unit
class S03MemoAdapter(private val items: List<S03Memo>, val onClickRemove: OnListItemEvent) : androidx.recyclerview.widget.RecyclerView.Adapter<S03MemoAdapter.VH>() {
inner class VH(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view) {
val content = MutableLiveData<String>()
fun onClickRemove(): Unit = this@S03MemoAdapter.onClickRemove(items, layoutPosition)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val lifecycleOwner = parent.context as LifecycleOwner
val binding = DataBindingUtil.inflate<ListitemS03Binding>(LayoutInflater.from(parent.context), R.layout.listitem_s03, parent, false)
val vh = VH(binding.root)
binding.let {
it.setLifecycleOwner(lifecycleOwner)
it.vh = vh
}
vh.content.observe(lifecycleOwner, Observer {
val memo = items[vh.layoutPosition]
memo.content = it ?: ""
})
return vh
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: VH, position: Int): Unit = with(holder) {
val memo = items[position]
content.value = memo.content
}
}
Stage03Activity.kt
class Stage03Activity : AppCompatActivity() {
private val viewModel: S03ViewModel by lazy { ViewModelProviders.of(this).get(S03ViewModel::class.java) }
private val adapter: androidx.recyclerview.widget.RecyclerView.Adapter<S03MemoAdapter.VH> by lazy { S03MemoAdapter(viewModel.memos.value!!, viewModel::remove) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DataBindingUtil.setContentView<ActivityStage03Binding>(this, R.layout.activity_stage03).let {
it.setLifecycleOwner(this)
it.viewModel = viewModel
}
viewModel.listAction.observe(this, Observer {
when(it) {
is S03ViewModel.ListAction.Added -> onAdded()
is S03ViewModel.ListAction.Removed -> onRemoved(it.memo, it.idx)
}
})
rcv_contents.let {
it.adapter = adapter
it.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this)
}
}
private fun onAdded() {
adapter.notifyItemInserted(0)
adapter.notifyItemRangeChanged(1, viewModel.sizeOfMemos -1)
rcv_contents.post {
rcv_contents.scrollToPosition(0)
}
}
private fun onRemoved(memo: S03Memo, idx: Int) {
adapter.notifyItemRemoved(idx)
adapter.notifyItemRangeChanged(idx, viewModel.sizeOfMemos - idx)
Toast.makeText(this, "\"${memo.content}\" is removed", Toast.LENGTH_SHORT).show()
}
}
xml
activity_stage03.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.ithome11.jetpackmvvmdemo.main.s03.S03ViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".e03.E03Activity">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/bt_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginTop="8dp"
android:background="@null"
android:src="@android:drawable/ic_menu_add"
android:onClick="@{_ -> viewModel.add()}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_content_new"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:imeOptions="actionNext"
android:lines="1"
android:singleLine="true"
android:text="@={viewModel.newContent}"
app:layout_constraintEnd_toStartOf="@+id/bt_add"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rcv_contents"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
tools:listitem="@layout/listitem_s03"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/et_content_new" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
listitem_s03.xml
<?xml version="1.0" encoding="utf-8"?>
<layout 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">
<data>
<variable
name="vh"
type="com.ithome11.jetpackmvvmdemo.main.s03.S03MemoAdapter.VH" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/bt_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginTop="8dp"
android:onClick="@{_ -> vh.onClickRemove()}"
android:background="@null"
android:src="@android:drawable/ic_menu_delete"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/et_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:lines="1"
android:singleLine="true"
android:imeOptions="actionGo"
android:text="@={vh.content}"
app:layout_constraintEnd_toStartOf="@id/bt_remove"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
至此專案便可正常運行
接著新增測試案例
build.gradle(module:app)
dependencies {
androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.2.0'
}
ViewActionsEx.kt
object ViewActionsEx {
@JvmStatic
fun setSpeed(speed: Float) = object : ViewAction {
override fun getDescription(): String = "set animation speed to : $speed"
override fun getConstraints(): Matcher<View> = ViewMatchers.isAssignableFrom(LottieAnimationView::class.java)
override fun perform(uiController: UiController, view: View) {
(view as LottieAnimationView).speed = 10.0f
}
}
@JvmStatic
fun waiting(milliSec: Long) = object : ViewAction {
override fun getDescription(): String = "waiting $milliSec milli seconds"
override fun getConstraints(): Matcher<View> = ViewMatchers.isDisplayed()
override fun perform(uiController: UiController, view: View) =
uiController.loopMainThreadForAtLeast(milliSec)
}
@JvmStatic
fun setProgress(value: Int) = object : ViewAction {
override fun getDescription(): String = "set progress to $value"
override fun getConstraints(): Matcher<View> = ViewMatchers.isAssignableFrom(ProgressBar::class.java)
override fun perform(uiController: UiController, view: View) {
(view as ProgressBar).progress = value
}
}
}
Stage03ActivityTest.kt
@RunWith(AndroidJUnit4::class)
class Stage03ActivityTest {
@get:Rule
val rule = object : ActivityTestRule<Stage03Activity>(Stage03Activity::class.java) {}
@Test
fun add() {
// given
val givenNewContent = "new content"
// when
Espresso.onView(withId(R.id.et_content_new)).perform(click(), replaceText(givenNewContent))
Espresso.onView(withId(R.id.bt_add)).perform(click())
// then
Espresso.onView(withId(R.id.rcv_contents))
.perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(0
, object : ViewAction {
override fun getDescription(): String = "check added memo content is \"$givenNewContent\""
override fun getConstraints(): Matcher<View> = hasDescendant(withText(givenNewContent))
override fun perform(uiController: UiController?, view: View?) {
@Suppress("UNCHECKED_CAST")
val matcher = constraints as Matcher<in View?>
Assert.assertThat(view, matcher)
}
}))
}
@Test
fun remove() {
// given
val givenIdxWillBeRemoved = 3
var beforeContent = ""
Espresso.onView(withId(R.id.rcv_contents))
.perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
, object : ViewAction {
override fun getDescription(): String = "get before content"
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun perform(uiController: UiController, view: View) {
beforeContent = view.findViewById<TextView>(R.id.et_content).text.toString()
}
}))
// when
Espresso.onView(withId(R.id.rcv_contents))
.perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
, object : ViewAction {
override fun getDescription(): String = "click remove"
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun perform(uiController: UiController, view: View) {
view.findViewById<View>(R.id.bt_remove).performClick()
uiController.loopMainThreadForAtLeast(ViewConfiguration.getTapTimeout().toLong())
}
}))
// then
Espresso.onView(withId(R.id.rcv_contents))
.perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeRemoved
, object : ViewAction {
override fun getDescription(): String = "check remove"
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun perform(uiController: UiController, view: View) {
val content = view.findViewById<TextView>(R.id.et_content).text.toString()
Assert.assertFalse(content == beforeContent)
}
}))
}
@Test
fun edit() {
// given
val givenIdxWillBeEdit = 3
val givenReplaceText = "replaced!!"
// when
Espresso.onView(withId(R.id.rcv_contents))
.perform(
RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeEdit
, object : ViewAction {
override fun getDescription(): String = "get before content"
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun perform(uiController: UiController, view: View) {
view.findViewById<EditText>(R.id.et_content).setText(givenReplaceText)
}
}))
Espresso.onView(withId(R.id.rcv_contents)) // scrolling
.perform(
ViewActionsEx.waiting(500)
, RecyclerViewActions.scrollToPosition<S03MemoAdapter.VH>(15)
, ViewActionsEx.waiting(500)
, RecyclerViewActions.scrollToPosition<S03MemoAdapter.VH>(0)
, ViewActionsEx.waiting(500)
)
// then
Espresso.onView(withId(R.id.rcv_contents))
.perform(RecyclerViewActions.actionOnItemAtPosition<S03MemoAdapter.VH>(givenIdxWillBeEdit
, object : ViewAction {
override fun getDescription(): String = "check replaced text"
override fun getConstraints(): Matcher<View> = isDisplayed()
override fun perform(uiController: UiController, view: View) {
val content = view.findViewById<TextView>(R.id.et_content).text.toString()
Assert.assertEquals(givenReplaceText, content)
}
}))
}
}
soluction
https://github.com/mars1120/jetpackMvvmDemo/tree/mvvm-03-RecyclerView