在 Jetpack Compose 的官方文件中,拖曳手勢操作是這樣子使用的:
Box(modifier = Modifier.fillMaxSize()) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } // [2]
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) { // [1]
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
在 [1] 這邊 pointerInput
跟 detectDragGestures
讓我們可以對手勢操作有更完整的掌控權,在pointerInput
這樣的設計中,也不難猜測出在這個 lambda 中可以加入不只一個手勢操作的處理,除了 Drag 之外,還有 Transform、Tap 等等不一樣的選項,跟原生的 GestureDetector 比較起來方便了不少。
detectDragGestures
給我們的資訊是 x 跟 y 的位移量,看上面的程式碼,這些位移量會被加總起來放在 offsetX
, offsetY
這些變數裡面,然後在 [2] 這邊再用這個最新的資訊來顯示最新的位置。在接下來的這個章節,我們將會依樣畫葫蘆,把上面的程式碼應用在便利貼程式中。
在開始實作之前,讓我們先模擬一下資料會從哪邊產生,在哪邊計算處理,還有最後是怎麼使用的,請看下圖:
假設現在有一筆便利貼的資料,Id 是 “A”,其所在的位置是 (60, 60),因為資料綁定,這資料就會從最右邊的 Reactive Model (也就是前一篇的 NoteRepository),經由 ViewModel ,最後到達 View ,畫在 (60, 60)的這個位置上,這邊應該沒什麼問題。
接下來,有一個拖曳的事件發生了,“A“這一個便利貼往下以及往右各移動 10 個單位,這個事件會送到 ViewModel 去處理。
ViewModel 接收到這個事件後,由於 ViewModel 在便利貼 App 中的職責包含了所有的邏輯運算,所以 ViewModel 有責任計算出便利貼“A”接下來的位置,該位置將應該會是 (70, 70),然後丟給 ReactiveModel 去做更新。
更新完之後,由於資料是在有綁定的狀態下,所以 View 會自動的更新到最新位置,也就是 (70, 70)。
@Composable
fun StickyNote(
**modifier: Modifier = Modifier, // [1]
onPositionChanged: (Position) -> Unit = {}, // [2]**
note: Note,
) {
Surface(
**modifier**
.offset(x = note.position.x.dp, y = note.position.y.dp)
.size(108.dp, 108.dp),
color = Color(note.color.color),
elevation = 8.dp
) {
Column(
modifier = Modifier
***.pointerInput(note.id) { // [3]
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
onPositionChanged(Position(dragAmount.x, dragAmount.y))
}
}***
.padding(16.dp)
) {
Text(text = note.text, style = MaterialTheme.typography.h5)
}
}
}
在 StickyNote 的部分,多了幾個不一樣的地方,分別是:
onPositionChanged
就會被呼叫,那這個函式的內容就會被執行再去做下面的動作,以目前來說,就是再去執行 ViewModel 的公開函式。至於pointerInput
, note.id 被使用在這邊當作是第一個參數,依據官方文件說明,這個參數如果改變的話,就會發生 recomposition,所以使用 note.id 可以防止 recomposition 發生一再發生,因為 note.id 是一個固定值的,不會任意改變。Recomposition 是 Jetpack Compose 中的一個重要的機制,一但觸發,就會重新執行整個函式,其定位有點像是原生的 invalidate() 還有 requestLayout(),想暸解更多細節的話,可以參考我之前寫的文章:https://tech.pic-collage.com/8915f95c41f3
@Composable
fun BoardView(boardViewModel: BoardViewModel) {
val notes by boardViewModel.allNotes.subscribeAsState(initial = emptyList())
Box(Modifier.fillMaxSize()) {
notes.forEach { note ->
**val onNotePositionChanged: (Position) -> Unit = { delta -> // [1]
boardViewModel.moveNote(note.id, delta)
}**
StickyNote(
**modifier = Modifier.align(Alignment.Center)**, // [2]
**onPositionChanged = onNotePositionChanged,**
note = note)
}
}
}
換到 BoardView 這邊,多了 [1] onNotePositionChanged 的 lambda 以及在 [2] StickyNote 中的第一個參數使用 Modifier.align(Alignment.Center)。
StickyNote
的型別是 (Position)→ Unit ,但是 ViewModel 要能夠更新便利貼的位置的話,就必須還要有 Id 這個資訊,否則 ViewModel 無法知道是哪一個便利貼的位置改變了,所以就在這邊將這個額外的資訊放進去。然後 moveNote(id, delta) 這個函式在使用起來也很直覺,完全知道會發生什麼事。在
moveNote()
這個命名出來之前也有考慮過其它不同的名字,像是updateNote()
或是dragNote()
。updateNote() 是一個很容易想到的 function 名稱,但是這個函式的名稱並沒有表示使用者的意圖,只是說明了要“更新資料”這件事,所以我放棄了這個選項。至於dragNote()
就考慮得比較久了,因為他有表示到使用者的意圖,但是這個名字有隱含著一個意義,如果 drag 了,那是不是還要呼叫另一個函式dropNote()
呢?因此,moveNote()
是一個最好的選項。
class BoardViewModel(
private val noteRepository: NoteRepository
): ViewModel() {
val allNotes = noteRepository.getAll()
private val disposableBag = CompositeDisposable()
fun moveNote(noteId: String, delta: Position) {
Observable.just(Pair(noteId, delta))
.withLatestFrom(allNotes) { (noteId, delta), notes ->
val currentNote = notes.find { it.id == noteId }
Optional.ofNullable(currentNote?.copy(position = currentNote.position + delta))
}
.mapOptional { it }
.subscribe { newNote ->
noteRepository.putNote(newNote)
}
.addTo(disposableBag)
}
}
接著來看 ViewModel,接續上面的 View 的實作,我們知道我們在 ViewModel 需要一個 moveNote
的函式,這裡面的實作內容對於 RxJava 的新手來說可能有點恐怖,但沒關係,今天可以先不用理解它,我會在下一篇完整的解釋這邊的內容,請注意到 subscribe
這邊就好,在完成計算之後,產生了一個新的 note
,接著會藉由呼叫 noteRepository.putNote
去做更新。最後再來看看 NoteRepository 的實作。
class InMemoryNoteRepository(): NoteRepository {
private val notesSubject = BehaviorSubject.create<List<Note>>()
private val noteMap = ConcurrentHashMap<String, Note>()
init {
val initNote = Note.createRandomNote()
noteMap[initNote.id] = initNote
notesSubject.onNext(noteMap.elements().toList())
}
override fun getAll(): Observable<List<Note>> {
return notesSubject.hide()
}
override fun putNote(newNote: Note) {
noteMap[newNote.id] = newNote
notesSubject.onNext(noteMap.elements().toList())
}
}
這邊又看到了一個新的型別: BehaviorSubject
,這個新型別我一樣會在後面的章節再做解釋,目前只要知道他也是一個 Observable
就可以了,在 putNote
被呼叫的時候,藉由執行 notesSubject.onNext
,我們的 Observable 就可以因此發送出最新的資料內容,而這個 Observable 也是 getAll() 中的 Observable,所以任何有跟 getAll() 這個 Observable
做綁定的 Observer
就會收到通知再去做更新。
這邊再重新整理一下整個流程,NoteRepository 的 Observable ,中間經過 ViewModel , 最後藉由Subscribe
,也就是資料綁定,而跟 View 產生連結,因此而確保 View 能夠隨時繪製出最新的內容。另一方面,View 的手勢操作,藉由執行 lambda 來去呼叫 ViewModel 的 moveNote
,最後再去更新 NoteRepository 的內容,內容更新了之後,因為之前產生的連結,所以 View 所繪製的內容也隨之更新。所以這種作法的資料流包含了從 Repository 出發的 **Observable**
與從 View 出發的 函式呼叫。
但其實可以有另一種作法!就是整條資料流都是 Observable
!從手勢操作開始就是一個 Observable
,然後這個 Observable 最終會跟 NoteRepository 的 Observable 有連結關係,產生一條從 View 出發,最後還是 View 去 Subscribe 的 Observable chain
。但是這種作法會讓我覺得程式碼比較難閱讀,為什麼呢?首先,這個手勢的 Observable 要怎麼傳給 ViewModel 呢?要嘛就是用建構子傳入,要嘛就是用 setter,建構子傳入的話代表 ViewModel 將沒有一個公開的介面去說明他的職責,而這樣一來的話 moveNote
就不存在了,所以我個人不太喜歡這種 implicit 的作法。另一方面,如果是用 setter 的話,你就得要處理 null 的案例,或是要有預設的實作,為了要處理這些事,會讓 ViewModel 的程式碼更加複雜,在每次閱讀程式碼時都需要花額外的時間來消化這些“雜訊”,然而這“雜訊”對於我們的商業邏輯來說一點都不重要,只是因為一些技術決策而產生出來不得不做的事情。
想要看看整條資料流都是
Observable
的範例嗎?可以去參考看看 RxJava for Android Developers 這本書,書中有非常詳細的範例程式碼解說,github 連結在此:https://github.com/tehmou/RxJava-for-Android-Developers。這其中的一個 ViewModel 範例:https://github.com/tehmou/android-tic-tac-toe-example/blob/master/app/src/main/java/com/tehmou/book/androidtictactoe/GameViewModel.java
在這案例中,同時使用函式呼叫跟 **Observable**
是比較乾淨的作法。但是,也是會有某種案例,是非常適合從頭到尾都使用 Observable
的。我在這邊做的取捨,不知道對大家來說是合理還是不合理,如果是你,你又會採取怎樣的作法呢?歡迎留言跟我討論!
請問dragAmount的部分是不是需要toDp().value
因為原本單位應該是px, 其他地方使用的都是dp