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,根據需求,我們需要一個搜尋方塊用來輸入資料,然後搜尋到資料後需要展示今日天氣資料、9日天氣資料。那麼簡潔一點,我們就把今日資料用一行文字表示出來,9日溫度資料用一個自定義折線圖表示出來。
首先是輸入框(搜尋方塊),我們使用TextField來完成搜尋方塊功能,通過設定colors相關引數來隱藏其預設自帶的下劃線指示器,通過shape和modifier引數來控制其圓角邊框樣式。通過設定keyboardOptions和keyboardActions來獲取點選鍵盤的確認鍵時觸發的事件。 還需要注意一點,這裡我們為了在點選確認鍵後隱藏鍵盤使用了還在實驗階段的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)
}
)
)
}
預覽圖如下,簡簡單單一個輸入框:
接下來是自定義溫度折線圖,首先我們來分析下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
)
}
至此,我們單獨的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 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的架構思想:
還有一個也比較重要: