文章目錄
- Compose如虎添翼 -- 搭配ViewModel、LiveData!!!
-
- 一、需求一覽
- 二、架構、流程
- 三、Compose UI開發
-
- 3.1、搜尋框
- 3.2、折線圖
- 四、ViewModel 業務開發
- 五、Compose和ViewModel建立關系
- 六、總結
Compose如虎添翼 – 搭配ViewModel、LiveData!!!
Compose系列文章,請點原文閱讀。原文:是時候學習Compose了!
單純的使用Compose來進行UI的展示,相信我們已經運用自如了,接下來的文章我們一起搭配其他Jetpack元件,例如LiveData,ViewModel、Room等來了解下Compose在現代化的開發上是多麼的簡單、舒适!
一、需求一覽
我們一起來完成一個需求:首先我們需要一個搜尋框,在搜尋框中輸入城市名,點選鍵盤回車按鈕後請求網絡接口擷取到該城市的天氣資訊 – 今日天氣,9日天氣,并展示在頁面上。
大緻顯示的UI效果及功能如下所示:
二、架構、流程
假如使用之前 View + MVP架構 的模式,整體的流程圖應該是如下所示:
那麼在 Compose + MVVM架構 中的話,流程圖會有什麼變化呢?(其實想使用MVI架構,但是又需要加入一定的解釋成本,是以後續文章再專門結合MVI做示例吧)
如上所示,很明顯的Activity和Compose在這裡隻要一個 setContent{} 的關系,後續都是Compose直接和ViewModel之間的互動,Presenter和Model、ViewModel和Model這兩層類似,不做贅述。
三、Compose UI開發
接下來我們先使用Compose編寫UI,根據需求,我們需要一個搜尋框用來輸入資料,然後搜尋到資料後需要展示今日天氣資料、9日天氣資料。那麼簡潔一點,我們就把今日資料用一行文字表示出來,9日溫度資料用一個自定義折線圖表示出來。
3.1、搜尋框
首先是輸入框(搜尋框),我們使用TextField來完成搜尋框功能,通過設定colors相關參數來隐藏其預設自帶的下劃線訓示器,通過shape和modifier參數來控制其圓角邊框樣式。通過配置keyboardOptions和keyboardActions來擷取點選鍵盤的Enter鍵時觸發的事件。 還需要注意一點,這裡我們為了在點選Enter鍵後隐藏鍵盤使用了還在實驗階段的API – LocalSoftwareKeyboardController。整體搜尋框代碼如下所示:
@ExperimentalComposeUiApi
@Composable
fun SearchView(
onClick: (city: String) -> Unit
) {
val input = remember {
mutableStateOf("")
}
//鍵盤控制器,可控制鍵盤的展示和隐藏
val keyboardController = LocalSoftwareKeyboardController.current
//輸入框圓角設定
val corner = 20.dp
TextField(
value = input.value,
onValueChange = {
input.value = it
},
colors = TextFieldDefaults.textFieldColors(
//輸入框下部的訓示線
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
//外觀配置
modifier = Modifier
.fillMaxWidth()
.border(
width = 2.dp,
color = Color.Black,
shape = RoundedCornerShape(corner)
),
shape = RoundedCornerShape(corner),
//鍵盤配置,輸入完畢後隐藏鍵盤
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
onClick(input.value)
}
)
)
}
預覽圖如下,簡簡單單一個輸入框:
3.2、折線圖
接下來是自定義溫度折線圖,首先我們來分析下9天的資料,那麼需要9個點,也就是螢幕需要8等分,然後分别繪制線段和端點就可以了。整體關于Canvas繪制的請檢視之前的文章,這裡我們需要注意一點就是:繪制的端點是有半徑的,我們繪制區域的時候,x軸前後需要留出來這個半徑能把首尾的端點全部展示出來,否則首尾的端點隻能顯示半個。代碼如下:
@Composable
fun TempLineChart(
modifier: Modifier,
weatherDaily: List<WeatherDaily>
) {
if (weatherDaily.isEmpty()) {
return
}
val days = weatherDaily.size
Canvas(
modifier = modifier
) {
//圓點的集合
val points: ArrayList<Offset> = ArrayList()
//溫度的內插補點(最大溫度的內插補點)
val tempMax = weatherDaily.maxOf {
it.tempMax.toInt()
}
val tempMin = weatherDaily.minOf {
it.tempMax.toInt()
}
val diff = tempMax - tempMin
//繪制的直線的寬度
val lineStrokeWidth = 8f
//繪制的最大圓點的直徑,注意是半徑,繪制時候需要乘以2
val pointStrokeWidth = 16f
val path = Path()
//起點位置
val startX = pointStrokeWidth
val startY = size.height
//平均每天的步長,需剔除圓點的寬度
val xOffset = (size.width - pointStrokeWidth * 2) / (days - 1)
val endX = size.width - pointStrokeWidth
path.moveTo(startX, startY)
var lastOffset: Offset? = null
for ((index, weatherDailyBean) in weatherDaily.withIndex()) {
val x = startX + xOffset * index
val y =
startY - (size.height / (diff + 2) * ((weatherDailyBean.tempMax.toInt() - tempMin) + 1))
val offset = Offset(x, y)
points.add(offset)
//路徑
path.lineTo(x, y)
//繪制直線
if (lastOffset != null) {
drawLine(
color = Color(0xFF357AFF),
start = lastOffset,
end = offset,
strokeWidth = lineStrokeWidth,
)
}
lastOffset = offset
}
path.lineTo(endX, startY)
path.close()
//繪制路徑
drawPath(
path = path,
brush = Brush.verticalGradient(
colors = arrayListOf(Color(0x80357AFF), Color(0x00000000))
),
)
//繪制藍色圓點
drawPoints(
pointMode = PointMode.Points,
color = Color(0xFF357AFF),
strokeWidth = pointStrokeWidth * 2,
points = points,
cap = StrokeCap.Round,
)
//繪制白色圓點
drawPoints(
pointMode = PointMode.Points,
color = Color.White,
strokeWidth = pointStrokeWidth,
points = points,
cap = StrokeCap.Round,
)
}
}
OK,然後造幾條僞資料,我們使用@Preview來預覽下顯示效果:
@Preview
@Composable
fun TempLineChartPreview() {
val weatherDailyList = ArrayList<WeatherDaily>()
for (i in 1..9) {
weatherDailyList.add(WeatherDaily(tempMax = i.toString()))
}
TempLineChart(
modifier = Modifier
.height(200.dp)
.fillMaxWidth(),
weatherDailyList
)
}
四、ViewModel 業務開發
至此,我們單獨的UI已經編寫完畢了,接下來是ViewModel的部分,網絡請求這塊無疑是Retrofit套餐,但是Retrofit和Compose沒有任何關系,是以這裡我們暫時不花篇幅講解其使用方式,直接使用僞資料來代替網絡請求結果,後續文章我們會結合Hilt來示例Retrofit、Room等相關知識。ViewModel相關代碼如下:
class MainViewModel : ViewModel() {
/**
* 城市名
*/
private val _cityName = MutableLiveData<String>()
/**
* 對外單獨暴漏修改城市名方法
*/
fun updateCityName(name: String) {
_cityName.value = name
}
/**
* 當日天氣【當_cityName值變更的時候,這裡會響應】
*/
val weatherNow: LiveData<String> = Transformations.switchMap(_cityName) {
MutableLiveData(" ${_cityName.value} 地區,今日天氣好的不能再好了!")
}
/**
* n天天氣【當_cityName值變更的時候,這裡會響應】
*/
val weatherDays: LiveData<List<WeatherDaily>> = Transformations.switchMap(_cityName) {
val weatherDailyList = ArrayList<WeatherDaily>()
for (i in 1..9) {
val temp = (15..20).random()
weatherDailyList.add(WeatherDaily(tempMax = temp.toString()))
}
MutableLiveData(weatherDailyList)
}
}
注意:我們使用了Transformations類,當_cityName的值變化的時候, switchMap( _cityName ) 會響應,我們處理過後傳回一個新的LiveData的值,weatherNow和weatherDays這兩個變量就會被指派。
【其實這裡的代碼設計方式再深入想一下,好像又能感受到一絲 MVI Intent的思想。】
五、Compose和ViewModel建立關系
Compose UI和ViewModel都搞定了,那麼他們之間如何像上文流程圖中表示的那樣可以建立聯系呢?其實官方給我們提供了一個庫:androidx.lifecycle:lifecycle-viewmodel-compose:$latestVersion,該庫提供了一個**viewModel()**函數,可以直接在@Composable 函數中通路到相關ViewModel的執行個體,例如:
@ExperimentalComposeUiApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val weatherNow = viewModel.weatherNow.observeAsState()
val weatherDays = viewModel.weatherDays.observeAsState(arrayListOf())
}
如上,我們在參數中直接使用viewModel()來擷取MainViewModel執行個體,而在MainScreen()函數中我們還使用到了一個 observeAsState() 函數,使用該函數也需要引用一個擴充庫:androidx.compose.runtime:runtime-livedata:$latestVersion,該函數的作用就是将ViewModel提供的LiveData資料轉換為Compose需要的State資料。
當LiveData資料更新後,LiveData轉換為State,而Compose會根據State資料來自行重新整理,是以将之前的UI控件組合起來,再将State資料設定進去,相關代碼如下所示:
@ExperimentalComposeUiApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val weatherNow = viewModel.weatherNow.observeAsState()
val weatherDays = viewModel.weatherDays.observeAsState(arrayListOf())
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Spacer(modifier = Modifier.height(20.dp))
SearchView(
onClick = {
viewModel.updateCityName(it)
})
Spacer(modifier = Modifier.height(20.dp))
Text(
text = weatherNow.value ?: ""
)
TempLineChart(
modifier = Modifier
.height(200.dp)
.fillMaxWidth(),
weatherDaily = weatherDays.value
)
}
}
OK,至此整體就大功告成了,運作下代碼試試吧,能不能達到如下效果呢?
六、總結
整體的話,重點在于Compose和ViewModel的結合、以及LiveData和State的使用。這其中我們還要注意Compose的架構思想:
- 事件向上傳遞,例如搜尋框的回車事件,暴漏出來給上層處理;
- 狀态向下傳遞,例如網絡請求結果資料等向UI層傳遞顯示,可以封裝成網絡請求中、請求成功、請求失敗等狀态向UI傳遞;
還有一個也比較重要:
- 單一信任源,上文中沒有明顯的示例,但是你可以觀察到TextField中輸入的資料是根據 input 的值來進行變化的。舉個View中的例子,比如CheckBox,當你點選的時候,狀态會立即進行改變,此時如果網絡請求失敗了,我們還需要把CheckBox的顯示狀态重置。但是在Compose中,CheckBox的顯示狀态改變和TextField一樣,隻有input的值變化了,它才變化。也就是說CheckBox需要訂閱一個變量,你隻有請求網絡成功後或者失敗後更改此變量,CheckBox才會根據此變量更改顯示狀态。