距离上一次开篇10多天时间,到目前为止,我只加了一个特性——保存历史记录。
考虑到下载链接的历史记录没有保存的必要性,所以只做了一个保存路径的历史记录。
同时对界面做了更改,让其看起来更像一个可用软件了。
使用资源
正式开始之前先讲一下如何在 CMP 中获取资源,例如图片、文字或者尺寸。
图片资源和文字资源的管理方式在 Android 中是差不多的,使用 drawable 文件夹和 strings.xml 进行管理。
但是颜色和尺寸资源,不能使用这样的方式,需要写 Kotlin 代码。
我单独建立了一个 Dimens.kt 文件,内容如下:
import androidx.compose.ui.unit.dp
val IconButtonSize = 40.dp
val IconButtonPadding = 8.dp
val SmallIconButtonSize = 32.dp
val SmallIconButtonPadding = 8.dp
val PagePadding = 12.dp
val PaddingMedium = 8.dp
val SingleLineListItemHeight = 56.dp
val SingleLineListItemPaddingHorizontal = 16.dp
颜色和尺寸的管理方式一致,也需要代码实现。
在之前导入的主题文件中,Color.kt 就是了。
针对文字和图片资源,我写了一点扩展,这样可以在代码中使用类似 inPainter() 和inString() 方便获取资源。
@Composable
fun StringArrayResource.inStringArray():List<String> {
return stringArrayResource(this)
}
@Composable
fun DrawableResource.inPainter(): Painter {
return painterResource(this)
}
@Composable
fun StringResource.inString():String {
return stringResource(this)
}
调整首页
首页添加了部分控件:
- 下载链接的输入框和下载按钮。
- 保存路径的输入框、历史记录的按钮和选择保存路径的按钮。
输入框使用 OutlinedTextField,该控件可以显示带有边线的输入框,同时设置一下在没有输入时的提示和标签。
OutlinedTextField(
modifier = Modifier.weight(1f),
value = input,
singleLine = true,
onValueChange = {
input = it
},
label = {
Text(
text = Res.string.download_link_label.inString(),
)
},
placeholder = {
Text(
text = Res.string.download_link_hint.inString(),
)
}
)
大概是这个效果:
同理对保存路径的输入框做同样的处理。
路径选择
桌面软件的文件选择框是一个常用功能,通常用于选择文件进行上传或者选择文件夹用于下载。
上图是一个简单的 Windows 文件夹选择器。
那么在 CMP 中,应该如何实现呢?
当然没必要我们自己实现了(如果你自己可以的话,鼓励自己做),使用第三方的开源库filekit即可。
引入 filekit 之后,通过 val saveDir = FileKit.openDirectoryPicker() 就可以打开一个文件夹选择框了。当我们选择完成文件夹之后,openDirectoryPicker 会返回我们选择的路径。
好的,我们在本地路径的按钮上绑定一个点击事件,用于打开文件夹选择器。
// other code...
var dir by remember { mutableStateOf("") }
fun chooseFileSaveDir() {
scope.launch(Dispatchers.IO) {
val saveDir = FileKit.openDirectoryPicker()
dir = saveDir?.file?.absolutePath.orEmpty()
}
}
AppRoundFilledIconButton(icon = Res.drawable.save) {
chooseFileSaveDir()
}
此时,我们就有文件夹选择功能了。
本地存储
我们依然使用第三方库KStore来实现本地存储,详细用法请参考该库的 Github 地址。
简单来讲,它使用 kotlinx.coroutines、kotlinx.serialization 和 kotlinx.io 协助实现对象的磁盘存储与恢复功能。
参照文档示例代码,我们引入 kstore 与 kstore-file,然后定义对象:
@Serializable
data class DestinationHistory(val items: List<Destination>)
@Serializable
data class Destination(val path: String, val addTime: Long)
为了方便使用,我编写了一个 Store 类用于管理历史记录:
object Store {
init {
File("./cache/").mkdirs()
}
private val store: KStore<DestinationHistory> =
storeOf(file = Path("./cache/destinations.json"), version = 0)
// 历史记录的流
val destinationHistory: Flow<DestinationHistory> = store.updates.filterNotNull()
// 添加历史记录
suspend fun addDestination(destination: String) {
if (destination.isBlank()) {
return
}
store.update { history ->
val newItem = Destination(destination, System.currentTimeMillis())
val olds = history?.items?.toMutableList() ?: mutableListOf()
olds.removeIf { it.path == destination }
olds.add(0, newItem)
history?.copy(items = olds) ?: DestinationHistory(olds)
}
}
}
HomeViewModel
我们创建一个新的 HomeViewModel 用于处理首页 HomePage 的业务逻辑:
class HomeViewModel : ViewModel() {
private val _destinationHistory = MutableStateFlow<ImmutableList<String>>(persistentListOf())
val destinationHistory: StateFlow<ImmutableList<String>> = _destinationHistory.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
Store.destinationHistory.collectLatest { history ->
_destinationHistory.update {
history.items.map { it.path }.toImmutableList()
}
}
}
}
}
在 HomePage 中监听 destinationHistory 来获取当前的本地保存路径的历史记录。
历史记录的显示,我使用了 ModalBottomSheet + LazyColumn 的组合,即在屏幕下方打开一个下拉框进行显示:
fun showDestinationHistory() {
showDestinationHistory = true
}
fun hideDestinationHistory() {
showDestinationHistory = false
}
if (showDestinationHistory) {
ModalBottomSheet(
onDismissRequest = {
hideDestinationHistory()
},
sheetState = sheetState,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(destinationHistory, key = { a, b -> b }) { index, value ->
// other code
}
}
}
}
ModalBottomSheet 需要提供一个 SheetState,同时在响应 onDismissRequest 的时候,不显示 ModalBottomSheet。
内部使用 LazyColumn 加载历史记录的每一项,因为每一项都是文字内容,所以简单的使用 Text 即可。我们给 Text 添加一个点击事件——当触发点击的时候,将点击的内容输入到保存地址框中,同时隐藏该 ModalBottomSheet。
fun chooseHistory(history: String) {
dir = history
scope.launch {
sheetState.hide()
}.invokeOnCompletion {
if (!sheetState.isVisible) {
hideDestinationHistory()
}
}
}
// other code...
Box(
modifier = Modifier.height(SingleLineListItemHeight).fillMaxWidth()
.clickable {
chooseHistory(value)
}
) {
Text(
value,
modifier = Modifier.align(Alignment.CenterStart)
.padding(horizontal = SingleLineListItemPaddingHorizontal)
)
}
注意这里隐藏的用法,在 sheetState.hide() 完成之后,需要让 showDestinationHistory 为 false 才能完全隐藏 ModalBottomSheet。
读者可以自己试试这里不隐藏 ModalBottomSheet 会发生什么结果。(提示:千万不要以为App卡死了)
最后一步逻辑,在我们点击下载的时候,保存历史记录即可。
此时点击历史记录输入框右边的按钮的时候,会显示我们的历史记录。
我们来看下效果:
Snackbar
在开始下载之前,我们还需要做一点额外工作,就是显示提示信息。
目前有三种情况需要处理:
- 没有输入下载链接;
- 没有输入保存路径;
- 下载链接不是 http(s) 格式的。
在 Android 中,一般使用 Toast 进行提示的显示,但是 Toast 是 Android 平台的系统支持。在桌面端是没有这个的。所以我选用了 Snackbar 作为提示信息显示的控件。
val snackHostState = remember {
SnackbarHostState()
}
SnackbarHost(
hostState = snackHostState,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = PagePadding),
) {
Snackbar(snackbarData = it)
}
使用 Snackbar 需要一个 SnackbarHost,而 SnackbarHost 需要一个 SnackbarHostState 用来控制显示。
现在,我们针对上面的三种情况,编写相关的逻辑:
fun startDownload() {
scope.launch {
if (input.isEmpty()) {
snackHostState.showSnackbar(Res.string.download_link_empty.getString())
} else if (dir.isEmpty()) {
snackHostState.showSnackbar(Res.string.download_destination_empty.getString())
} else if(!input.isValidHttpUrl()) {
snackHostState.showSnackbar(Res.string.download_link_not_url.getString())
} else {
// 下载
}
}
}
使用 snackHostState.showSnackbar 显示 Snackbar,参数填入想要显示的文字即可。
我们看下效果:
总结
- 使用 CMP 的资源
- filekit 选择保存路径
- KStore 保存历史记录
ModalBottomSheet显示历史记录Snackbar显示提示信息
源码地址:
github.com/kapaseker/Y…