iT邦幫忙

2021 iThome 鐵人賽

DAY 7
1
Mobile Development

Jetpack Compose X Android Architecture X Functional Reactive Programming系列 第 7

便利貼中的手勢操作

在 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 的。我在這邊做的取捨,不知道對大家來說是合理還是不合理,如果是你,你又會採取怎樣的作法呢?歡迎留言跟我討論!


上一篇
你的 MVVM 不是你的 MVVM
下一篇
RxJava operators && Java.Optional as a type class
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
yuyue_139
iT邦新手 5 級 ‧ 2022-08-13 17:52:17

請問dragAmount的部分是不是需要toDp().value
因為原本單位應該是px, 其他地方使用的都是dp

我要留言

立即登入留言