在 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
的。我在这边做的取舍,不知道对大家来说是合理还是不合理,如果是你,你又会采取怎样的作法呢?欢迎留言跟我讨论!
>>: [Day12] 让 Linux 的 systemd 帮我们管理 API 程序
还记得我们之前提到Blue Prism有如积木般, 将不同的Object堆积、重整、并列後, 可以产...
比较/关系 运算 用来比较两个数值大小关系的运算,运算的结果是一个布林值,当两者的大小关系成立时结果...
我们的第一个Lab就从Simple object system开始,程序码我放在这 https://...
在这边以之前【Day3】看YT学写程序中从Github上找到的例子作为实际的例子进而深挖学习,延...
Youtube 影片 : 影片介绍如何使用 dotnet cli 打包 .net 开发程序,建立单一...