從這一章節開始進入實作的部分,我們要達到的目標是:
針對這個目標,我們當然無法一次就能做到位,而且在這當中還有一些不確定性,其中最大的不確定性就是手勢操作,Jetpack Compose 的技術限制可能會影響多張便利貼的架構。其中的問題是:我們有辦法對單一個 View 做手勢操作嗎?如果答案是肯定的,那我會拿到的座標位置是絕對座標嗎?或是,我們要從 View Parent 來控制全部的手勢操作嗎?那重疊的便利貼我該如何知道?要如何得知誰在上誰在下呢?
還有另一個問題是,要怎麼設計座標系統?Android 原生的座標系統中左上角是原點(0, 0),往右以及往下都是正值,往上以及往右都是負值。在我們的 App 中使用一樣的定義方式嗎?我們要接受負值嗎?這個 app 是有限邊界還是無限邊界呢?
無論如何,我們都需要先做出一個便利貼的 View 才能進行下一步,Jetpack Compose 可以簡單的做到這件事:
@Composable
fun StickyNote(content: String) {
Surface(
Modifier.size(108.dp, 108.dp),
color = Color.Green,
elevation = 8.dp
) {
Column(modifier = Modifier
.padding(16.dp)
) {
Text(text = content, style = MaterialTheme.typography.h5)
}
}
}
@Preview
@Composable
fun StickyNotePreview() {
StickyNote("Hello")
}
這個 StickyNote 的 UI 由三個部分組合而成: Surface, Column, Text,以下分別解釋:
依據官方文件的說明,Surface 可以做到以下這幾件事:
依照以上的說明, Surface
非常適合用來當作便利貼的基底,所以便利貼的大小、陰影、背景色都定義在這一層。便利貼的大小這邊直接定一個寫死的值:108 ,這數值其實沒什麼特別意義,就只是這樣的大小在手機上看來是大小剛好的,那這大小是不是可以調整的呢?目前沒有定義,但這是一個相對好解決的問題,可以放到之後再決定。
下一層我使用了 Column
,我在這裡做了一個假設,便利貼很有可能是垂直排版,通常大家也都是這樣用便利貼的。如果到時候 Column 沒辦法符合需求也沒關係,因為在這邊我也沒有過度設計,可以隨時被取代,現在如果替換成 Box 或是 Row 也會是同樣的結果。這一層最主要的功能只有 Padding 的效果。
最後是 Text
,用來顯示內容,以下是效果圖:
在能夠移動之前,得要有辦法將便利貼放在對的位置上,依照我們對 Jetpack Compose 的了解,位置這部分的職責,應該是在 Modifier 身上,於是我們就找到了這個 API : Modifier.offset
。
用法非常直覺,那就來試試看吧!在 StickyNote 的 function 中再加入另外兩個參數:x 跟 y。
@Composable
fun StickyNote(content: String, **x: Int, y: Int**) {
Surface(
Modifier
**.offset(x = x.dp, y = y.dp)**
.size(108.dp, 108.dp),
color = Color.Green,
elevation = 8.dp
) {
Column(modifier = Modifier
.padding(16.dp)
) {
Text(text = content, style = MaterialTheme.typography.h5)
}
}
}
@Preview(showBackground = true)
@Composable
fun StickyNotePreview() {
Box(Modifier.fillMaxSize()) {
StickyNote("Hello", x = 10, y = 10)
StickyNote("World", x = 80, y = 60)
}
}
效果看起來不錯!但是為了讓他可以顯示在對的位置上我們多了兩個參數,如果要有更多控制的選項,那參數是不是會越來越多呢?看樣子我們在這邊嗅到了一個壞味道(Bad smell),在程式碼越來越難維護之前,來做的簡單的重構吧!
在 Refactoring 這本書中,這個壞味道叫做 Long Parameter List,我們要採取的做法是 Introduce Parameter Object,也就是說使用一個物件來代表這些 parameter ,那這個物件是什麼呢?我們要幫它想一個最適合的名字,別忘了,命名永遠是工程師所要面對的最困難的問題之一。現在的參數是 x, y, content,這些資訊組合起來是一個什麼樣的概念呢?恩...這個方向好像不太好想,那換個方向想,對於這個 View 來說,我提供給他的資訊應該是什麼?
答案就是便利貼,對吧?所以這個資訊就是便利貼,為了跟 StickyNote
的這個名字作區別,我們將這物件命名為 Note
。根據需求,我們還需要更改顏色,所以 Note
這物件理所當然的應該要包含顏色的資訊,不只如此,我們還有許許多多不同的考量。而這個考量以及定義 Note
的過程,也叫做 Modeling。
不管是怎樣的應用程式中,Modeling 都是非常重要的一件事,在開發前期定義 Model 的完整度以及方向會大大的影響之後的整體架構以及維護成本。通常我會有以下的考量:
Point
以及Rect
。但是在做單元測試這會是一個負擔,必須要做額外的環境設定才能執行該單元測試,也會花更多時間執行測試。全部使用自己定義的物件還有另一個好處,就是可以根據需求去定義不變性(Invariant[註1]),這樣就不用擔心會有意外的狀態出現在應用程式執行的過程。[註1]Invariant: 以這個 app 為例,如果在座標位置上不允許負值的存在,那麼我們就可以在這個 Model 上進行限制,如果建立了一個有負座標的物件,就丟出一個例外,將錯誤狀態留在觸發的時間點,發現就即時處理,而不是保留這個錯誤狀態,可能在很後面的時間點才知道這邊有發現這個問題,讓 debug 變得相對困難。關於 invariant 有興趣想更深入了解的,可以去看看 Domain Driven Design 相關的書籍。
[註2] 在應用程式的架構設計中,最常使用使用的架構就屬於 Layered-Architecture 了,在不同層級之間,應該要有嚴格的依賴限制還有可見度限制,在商業邏輯層的物件不應該能夠使用顯示層的物件,但是反過來是可以接受的。然而,在一個複雜的應用程式中,顯示層所關注的內容可能小於商業邏輯層所關注的內容,或是要經過特定的資料轉換,這時候,就可以在顯示層有獨立的 Model 來達到關注點分離的效果。
綜合以上的考量,我的決定是不需要有兩個以上定義類似的 Model ,只使用一個 Model 在初期的開發上負擔是最小的,而且他是 Pure Kotlin object,沒有其他 Library 的情況下是能夠獨立存在的,最後,他應該要有個唯一的身份(ID),不然無法跟其他便利貼做出區隔,對於位置上的定義,由於我希望最後的成品是可以拓展到無限邊界的,所以允許有負的位置,以下是我所定義的 Model:
data class Note(
val id: String,
val text: String,
val position: Position,
val color: YBColor) {
companion object {
fun createRandomNote(): Note {
val randomColorIndex = Random.nextInt(YBColor.defaultColors.size)
val randomPosition = Position(Random.nextInt(-50, 50).toFloat(), Random.nextInt(-50, 50).toFloat())
val randomId = UUID.randomUUID().toString()
return Note(randomId, "Hello", randomPosition, YBColor.defaultColors[randomColorIndex])
}
}
}
data class Position(val x: Float, val y: Float) {
operator fun plus(other: Position): Position {
return Position(x + other.x, y + other.y)
}
}
data class YBColor(
val color: Long
) {
companion object {
val HotPink = YBColor(0xFFFF7EB9)
val Aquamarine = YBColor(0xFF7AFCFF)
val PaleCanary = YBColor(0xFFFEFF9C)
val Gorse = YBColor(0xFFFFF740)
val defaultColors = listOf(HotPink, Aquamarine, PaleCanary, Gorse)
}
}
以下是 StickyNote 使用 Introduce Parameter Object 這技巧, refactor 後的樣子:
@Composable
fun StickyNote(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
.padding(16.dp)
) {
Text(text = note.text, style = MaterialTheme.typography.h5)
}
}
}
@Preview(showBackground = true)
@Composable
fun StickyNotePreview() {
Box(Modifier.fillMaxSize()) {
StickyNote(Note.createRandomNote())
StickyNote(Note.createRandomNote())
}
}