便利贴中的手势操作

在 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] 这边 pointerInputdetectDragGestures 让我们可以对手势操作有更完整的掌控权,在pointerInput 这样的设计中,也不难猜测出在这个 lambda 中可以加入不只一个手势操作的处理,除了 Drag 之外,还有 Transform、Tap 等等不一样的选项,跟原生的 GestureDetector 比较起来方便了不少。

detectDragGestures 给我们的资讯是 x 跟 y 的位移量,看上面的程序码,这些位移量会被加总起来放在 offsetX, offsetY 这些变数里面,然後在 [2] 这边再用这个最新的资讯来显示最新的位置。在接下来的这个章节,我们将会依样画葫芦,把上面的程序码应用在便利贴程序中。

资料流

在开始实作之前,让我们先模拟一下资料会从哪边产生,在哪边计算处理,还有最後是怎麽使用的,请看下图:

Screen Shot 2021-08-26 at 3.58.53 PM.png

假设现在有一笔便利贴的资料,Id 是 “A”,其所在的位置是 (60, 60),因为资料绑定,这资料就会从最右边的 Reactive Model (也就是前一篇的 NoteRepository),经由 ViewModel ,最後到达 View ,画在 (60, 60)的这个位置上,这边应该没什麽问题。

Screen Shot 2021-08-26 at 4.30.19 PM.png

接下来,有一个拖曳的事件发生了,“A“这一个便利贴往下以及往右各移动 10 个单位,这个事件会送到 ViewModel 去处理。

Screen Shot 2021-08-26 at 4.31.04 PM.png

ViewModel 接收到这个事件後,由於 ViewModel 在便利贴 App 中的职责包含了所有的逻辑运算,所以 ViewModel 有责任计算出便利贴“A”接下来的位置,该位置将应该会是 (70, 70),然後丢给 ReactiveModel 去做更新。

Screen Shot 2021-08-26 at 4.35.13 PM.png

更新完之後,由於资料是在有绑定的状态下,所以 View 会自动的更新到最新位置,也就是 (70, 70)。

实作

View

@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 的部分,多了几个不一样的地方,分别是:

  • [1] 第一个参数 Modifier : 为什麽这边会把 Modifier 当作第一个参数输入进来呢?而且还直接用在 Surface 身上呢?其实这样的模式是官方建议的模式,这样一来, View Parent 会有一定程度的掌控权,包含对齐位置,大小设定等等,等等将会看到它的作用。
  • [2] onPositionChanged 跟 [3] pointerInput:为了让外界有办法获得位置改变的事件,这边使用了函式来当作参数,一但有位置改变的事件发生时, 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)。

  • [1] onNotePositionChanged:这边传进去到 StickyNote 的型别是 (Position)→ Unit ,但是 ViewModel 要能够更新便利贴的位置的话,就必须还要有 Id 这个资讯,否则 ViewModel 无法知道是哪一个便利贴的位置改变了,所以就在这边将这个额外的资讯放进去。然後 moveNote(id, delta) 这个函式在使用起来也很直觉,完全知道会发生什麽事。
  • [2] Modifier:使用了 Alignment.Center 之後,每一个 StickyNote 的起点就是整个画布的中心点了,所以在这样的范围底下,整个萤幕大约有一半是正的座标位置,另一半是负的座标位置,这样的座标系比较容易跟不同的平台共用(像是 Web 或是 iOS)。

moveNote() 这个命名出来之前也有考虑过其它不同的名字,像是 updateNote() 或是 dragNote() 。updateNote() 是一个很容易想到的 function 名称,但是这个函式的名称并没有表示使用者的意图,只是说明了要“更新资料”这件事,所以我放弃了这个选项。至於 dragNote() 就考虑得比较久了,因为他有表示到使用者的意图,但是这个名字有隐含着一个意义,如果 drag 了,那是不是还要呼叫另一个函式 dropNote() 呢?因此, moveNote()是一个最好的选项。

ViewModel

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 的实作。

Repository

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,YAML Engineer 初登板

>>:  [Day12] 让 Linux 的 systemd 帮我们管理 API 程序

Day16. Blue Prism牌乐高积木-BP Collection and Loop Stages 的模拟测试

还记得我们之前提到Blue Prism有如积木般, 将不同的Object堆积、重整、并列後, 可以产...

30天学会C语言: Day 5-来比大小啊!

比较/关系 运算 用来比较两个数值大小关系的运算,运算的结果是一个布林值,当两者的大小关系成立时结果...

Day11 Lab 1 - 简单的Object storage系统

我们的第一个Lab就从Simple object system开始,程序码我放在这 https://...

[Day 06] 从简单的Todolist 了解 iOS开发的大致流程

  在这边以之前【Day3】看YT学写程序中从Github上找到的例子作为实际的例子进而深挖学习,延...

.NET CLI 打包成单一免安装 Runtime/SDK Exe 执行档

Youtube 影片 : 影片介绍如何使用 dotnet cli 打包 .net 开发程序,建立单一...