Compose Desktop 使用中的几个问题(分平台加载资源、编写Gradle 任务下载平台资源、桌面特有组件、鼠标键盘事件)

前言

在我之前的文章 Compose For Desktop 实践:使用 Compose-jb 做一个时间水印助手 中,我们使用 Compose For Desktop 写了一个用于读取照片 EXIF 中的拍摄日期参数并以文字水印的方式添加到照片上的桌面程序。

但是事实上,这个程序的名字叫做 TimelapseHelper 也就是延时助手,是我写来辅助做延时视频的。

即然取名取的这么大,功能自然也要符合它的名字啊,只是添加时间水印可不行啊。

恰好,最近又有了点时间,所以抽空逐渐完善了它的功能,最最主要的当然是给它加上了使用图片直接生成视频的功能了。

本文会讲解一些这次在完善功能时遇到的问题。

如何在 Compose Desktop 中使用 FFmpeg (C/C++) ?

其实说到音视频的处理,大多数人第一时间想到的都是大名鼎鼎的 ffmpeg,而我自然也不例外。

我跟 ffmpeg 可算是老熟人了,之前写的某个原生安卓 app,核心功能全是使用 ffmpeg 实现的,所以这次需要实现图片合成视频的功能,我第一时间想到的自然也是使用 ffmpeg 实现。

而想要在程序中使用 ffmpeg ,一般有两种方式,一种是自己深度定制修改 ffmpeg 源码后,提供接口给程序原生调用,一种是使用命令行调用已编译好的 ffmpeg 二进制文件。

对于第一种方式,在原生安卓中通常使用 JNI 实现,对于 compose 或者说对于 kotlin,它同样提供了 cinterop 用于与 C/C++ 互操作,同时,由于我这个项目并不是跨平台项目,而只是一个 dektop 项目,还是基于 Jvm 实现的,所以 JNI 也还是可以使用的。

但是,第一种方式的难点显而易见,需要我们非常懂 C/C++ 或者 JNI 等相关技术,而且还必须对 ffmpeg 源码相当熟悉才行,否则一切都是白搭。

显然,我并不具备这样的能力,哈哈哈。不过感兴趣的可以看看 Interoperability with C 。

那么对于我们来说,最简单的方式莫过于直接使用已经编译好的 FFmpeg 二进制文件了。

这样我们就可以直接使用 ffmpeg 执行相应的命令,例如获取当前 FFmpeg 版本号:

1.jpg

但是,这种方法显而易见的就有一个明显的问题,那就是我们并不能保证所有使用者都已经在自己电脑上安装了 FFmpeg,并且加入了系统变量(PATH)。也就是说,我们无从得知 FFmpeg 二进制可执行文件的位置。

对于这个问题,我一开始想的就是由用户自己指定 FFmpeg 可执行文件的路径,但是显然这样使用起来非常不方便,别说是用户了,我自己用着都觉得很烦,每次都要去设置 FFmpeg 位置。

所以,最简单的方式就是将可执行文件打包到程序或者安装包中,但是我们将所有平台的可执行文件都打包进安装包的话,显然又是不合理的。因此我们应该分平台打包不同的可执行文件。

关于如何分平台打包资源,我们下节再讲,这节先讲如何在我们的程序中调用 FFmpeg 命令。

其实如果是安卓开发的话,对于这个问题也很好解决,同样是获取当前 FFmpeg 版本号,我们只需要:

 Runtime.getRuntime().exec("ffmpeg -version")

这个方法来自于 jvm,好在我们这个程序目前只需要运行在桌面端,而所有桌面端都是基于 jvm 的,所以这个方法可以放心大胆的用。

不过,这种方法是没有返回值的直接执行,如果想要获取返回值,我们可以这样:

val p = Runtime.getRuntime().exec("ffmpeg -version")
val returnCode = p.waitFor()
println("exec finish with exit code $returnCode")

注意,这里的 returnCode 只是命令执行完毕后返回的退出代码(一般返回 0 表示成功执行无异常,返回其他数值表示执行失败),如果想要获取实时输出的话需要从 p.inputStream 中实时去读取输入流的数据。(没写错,是输入流)。这里还有一个坑,如果执行出错的话,输出内容就不会再走 p.inputStream ,而是会从 p.errorStream 中输出。

对于我这个程序的需求,我既需要获取程序执行返回结果,也需要获取程序执行实时输出内容,同时如果执行出错,我还需要能拿到错误输出信息。

也就是说,我们可能得造个轮子来优雅的完成这项工作。

但是作为新时代的程序员,当然是要秉承着能不造轮子就不造轮子的风范,所以我直接找来个大佬写的库来使用了: zt-exec 。

举个例子,如果我们想要验证当前 FFmeg 是否可用,我们可以通过执行 ffmpeg -version 来验证,由于我们只是获取版本号,所以并不需要实时获取输出结果,只需要执行完毕后能拿到最终输出结果和返回值即可:

try {val cmd = state.getFfmpegBinaryPath()val output = ProcessExecutor().command(cmd, "-version").readOutput(true).exitValues(0).execute().outputUTF8()applicationState.changeDialogText("FFmpeg 正常!\n\n $output")
} catch (e: InvalidExitValueException) {println("Process exited with ${e.exitValue}")val output = e.result.outputUTF8()applicationState.changeDialogText("FFmpeg 不可用!\n\n $output")
} catch (tr: Throwable) {println("Process exited with ${tr.stackTraceToString()}")applicationState.changeDialogText("FFmpeg 不可用!\n\n ${tr.stackTraceToString()}")
}

其中的 .exitValues(0) 表示我们需要返回值为 0 ,如果不为 0 ,则会抛出异常 InvalidExitValueException

而在实际使用时,我们既需要实时输出信息,也需要错误信息和最终返回值,那么我们可以这样写:

val cmd = "some cmd".split(" ")ProcessExecutor().command(cmd).redirectOutput(object : LogOutputStream() {override fun processLine(line: String?) {println("$line")}}).redirectError(object : LogOutputStream() {override fun processLine(line: String?) {println("$line")}}).exitValues(0).execute()

通过 redirectOutput 可以注册实时获取输入流(输出信息)和 redirectError 注册实时获取错误信息流。

现在我们已经具备了执行 FFmpeg 的知识,下一步,自然是思考如何才能获取到 FFmpeg 执行文件。

Compose Desktop 分平台获取资源文件

根据 compose-multiplatform 教程 。

我们可以通过将资源文件放到 /src/main/resources 目录下,然后通过调用 Class::getResource Class::getResourceAsStream 来获取到文件或文件流。

只是这样更适合于用来存放和读取一些一般的资源文件,例如文本、媒体文件等,对于我们的可执行文件似乎不太适合。

而且存放在这个目录中的文件是不区分平台的,会打包到所有平台的 jar 包中。

如果想要分平台打包资源,那么我们应该使用 Compose 插件提供的特定资源配置。

首先在 build.gradle.kts 文件中指定资源文件根目录:

compose.desktop {// ...application {// ...nativeDistributions {// ...appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))}}
}

在这里我们将资源文件根目录指定为了 <PROJECT_DIR>/resources ,即项目根目录下的 resources 文件夹。

注意这里是项目根目录,不要和上面的 /src/main/resources 混淆了,我就是在这里搞混淆了,害得我 debug 了好久。

然后我们可以在这个目录下用不同的文件夹名指定不同平台需要的资源,这些资源将只会为指定的平台打包,例如:

  1. resources/common 表示公用资源,即所有平台都会打包。
  2. resources/windows 表示其中只会打包给 windows 平台。
  3. resources/macos 表示其中只会打包给macOS 平台。
  4. resources/linux 表示其中只会打包给 Linux 平台。

我们还可以在不同平台加上架构后缀(x64 arm64 ),表示只供特定架构使用,例如,指定某资源只在 arm 架构的 macOS 平台可用,则可以命名为: resources/macos-arm64

在我们的项目中,以 macOS 为例,我们的 FFmpeg 可执行文件的放置位置为:

2.png

那么,如何获取到这个文件的路径呢?同样很简单:

val ffmpegFile = File(System.getProperty("compose.application.resources.dir")).resolve("ffmpeg")

其中,使用 System.getProperty("compose.application.resources.dir") 可以拿到当前的资源文件目录,对于不同的运行方式,这个目录是不同的.

如果我们不打包,直接在 IDE 中运行程序的话,这个目录是:<PROJECT_DIR>/build/compose/tmp/prepareAppResources/

如果是打包后运行的话,不同的平台具有不同的目录,例如在 macOS 平台为: xxx.app/Contents/app/resources/ 。其中 xxx.app 就是生成的 macOS 上的 app 文件(夹)。而在 Windows 上则为程序安装目录下的 resources 目录。

另外有一点必须要说明的是,如果直接在 IDE 运行程序的话,不能直接 run MainKt 而是要运行 run 或有 run 前缀的这些 task :

3.jpg

因为如果直接 run MainKt

4.png

的话,并不会触发相应的 task,会导致 System.getProperty("compose.application.resources.dir") 返回 null 。

自此,我们已经可以为不同的平台打包不同的可执行文件了,我们在实际使用时只需要把原先的 ffmpeg 命令改成 ffmpeg 实际的路径即可,例如在 macOS 未打包直接运行时执行查看版本号命令可以写成: <PROJECT_DIR>/build/compose/tmp/prepareAppResources/ffmpeg -version

需要注意的是,不同的平台的可执行文件名称是不一样的,例如在 macOS 和 Linux 上可执行文件是没有后缀名的,但是在 windows 上却有后缀名 .exe

同时,在 macOS 和 Linux 上,我们打包的可执行文件一般默认是没有执行权限的,所以需要我们额外处理一下。定义一个函数用于获取指定平台的可执行文件路径并且检查是否有可执行权限:

fun getFfmpegBinaryPath(): String {val fileName = if (System.getProperty("os.name").lowercase(Locale.getDefault()).contains("win")) "ffmpeg.exe" else "ffmpeg"val executableFile = File(System.getProperty("compose.application.resources.dir")).resolve(fileName)if (!executableFile.canExecute()) {println("没有执行权限,正在尝试授予")executableFile.setExecutable(true)}return executableFile.absolutePath}
}

自此,我们已经可以在程序中正常的使用 FFmpeg 了。

唯独还有一个不太合理的地方,那就是显然我们不应该把所有的可执行文件都上传到 GitHub 啊,且不说 git 对二进制文件支持较差,而且这些二进制文件的大小也超出了 GIthub 的大小限制了啊。

所以,我们采用的解决方案是通过编写自定义 Gradle 脚本实现在编译之前自动检查并下载对应平台的 FFmpeg 二进制文件并放到对应的目录下。

编写 Gradle 脚本自动下载并复制资源文件

得益于我们的 Gradle 脚本现在使用的也是 kotlin (.kts)了,所以编写起来毫无压力。

只是我们需要先大致了解一下 Gradle 的工作流程。

Gradle 基础知识

在 Gradle 构建运行时分为了三个阶段:

  1. 初始化阶段
  2. 配置阶段
  3. 执行阶段

在初始化阶段会:

  • 检查设置文件(settings.gradle.kts)
  • 解析设置文件以确定哪些项目以及包含(include)的项目将参与构建
  • 为每个项目创建 Project 实例

在配置阶段会:

  • 解析参与构建的所有项目的构建脚本 (build.gradle)
  • 为需要执行的任务(task)创建任务运行图

在执行阶段:

  • 按照依赖关系顺序依次执行每个需要执行的任务

我们可以通过以下方式创建一个任务:

tasks.register("hello") {doLast {println("Hello world!")}
}

这样,我们就创建了一个名为 hello 的 task, 执行它将会输出 Hello world! ,通过以下命令执行:

gradlew hello

运行结果:

5.png

需要注意的是,如果我们不把代码包在 doLastdoFirst 中,那么每次配置阶段时这些代码都会被执行:

tasks.register("hello") {println("Hello world!")
}

上述这个代码,即使我们不运行这个任务,只是同步了一下 Gradle,依旧会输出 “Hello world!"” 。

我们也可以通过 dependsOn 为任务添加依赖,例如:

tasks.register("equationl") {doLast {println("Hello equationl!")}
}tasks.register("hello") {dependsOn("equationl")doLast {println("Hello world!")}
}

此时执行 ./gradlew hello ,输出结果:

> Task :equationl
Hello equationl!> Task :hello
Hello world!

可以看到,虽然我们运行的是 hello 任务,但是由于 hello 依赖于 equationl ,所以最终结果是先运行了 equationl 再接着运行 hello

然后,我们还可以通过 tasks.named 动态的去为已有的任务添加新内容,例如为其他任务添加新的运行依赖:

tasks.register("equationl") {doLast {println("Hello equationl!")}
}tasks.register("hello") {doLast {println("Hello world!")}
}tasks.named("hello") {dependsOn("equationl")
}

执行 ./gradlew hello ,输出结果:

> Task :equationl
Hello equationl!> Task :hello
Hello world!

可以看到,在上述的脚本中的 hello 任务中我们并没有添加依赖,但是输出依旧是带上了 equationl 依赖,这是因为我们之后又通过 tasks.namedhello 添加了 equationl 依赖。

自此,我们已经大致了解了编写下载不同平台资源所需的基础知识,下一步就是开始正式编写下载脚本。

应该什么时候下载资源?

在正式开始编写脚本之前,我们还需要明确一点,就是我们这个下载资源的代码,应该在什么时候执行?

容易想到,即然我们是为了将资源按照平台放到对应的目录,使其打包时能够读取到就行,那么我只需要在配置资源文件的任务前运行这个任务就可以了。

那么,怎么知道资源文件的任务是哪个呢?其实也很简单,run 一下在看构建输出不就知道了嘛:

Executing 'run'...> Task :compileKotlin UP-TO-DATE
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :prepareAppResources UP-TO-DATE

根据这些任务名称,显然 prepareAppResources 就是我们要找的任务了。

因此,我们这样写:

tasks.register("downloadFFmpeg") {// 在这里下载文件doLast {println("假装我在下载文件")}
}tasks.named("prepareAppResources") {dependsOn("downloadFFmpeg")
}

如果你这样写的话,大概率会收到报错:

* Exception is:
org.gradle.api.UnknownTaskException: Task with name 'prepareAppResources' not found in root project 'TimelapseHelper'.at org.gradle.api.internal.tasks.DefaultTaskCollection.createNotFoundException(DefaultTaskCollection.java:102)// ...

什么?prepareAppResources 不存在?怎么可能,通过 ./gradlew tasks --all 查看所有任务也是有这个任务的啊!

哈哈,其实这里并不是这个任务不存在,只是创建这个任务的脚本推迟了这个任务的创建,换句话说就是,在我们调用 tasks.named("prepareAppResources") 时,prepareAppResources 这个任务还没有被创建出来呢。

要解决这个问题也很简单,把这行代码放到 gradle.projectsEvaluatedproject.afterEvaluate 之后即可:

tasks.register("downloadFFmpeg") {// 在这里下载文件doLast {println("假装我在下载文件")}
}gradle.projectsEvaluated {tasks.named("prepareAppResources") {dependsOn("downloadFFmpeg")}
}

gradle.projectsEvaluatedproject.afterEvaluate 的意思比较像,都可以简单理解为在配置阶段结束后再运行其中的代码。这样就可以保证我们在 tasks.named("prepareAppResources") 时这个任务已经被创建了。

此时再执行 run 输出如下:

Executing 'run'...> Task :compileKotlin UP-TO-DATE
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE> Task :downloadFFmpeg
假装我在下载文件> Task :prepareAppResources UP-TO-DATE

可以看到,我们添加的 downloadFFmpeg 任务已经成功在 prepareAppResources 之前运行了。

接下来只要完成 downloadFFmpeg 中的具体下载资源的实现就可以了。

注意:虽然我在文章中写了应该下载资源的最佳时机,但是我自己实际编写程序时却偷懒了并没有在按照我介绍的编写任务,而是直接在 gradle.afterProject { } 中添加了一个下载函数。另外,我的代码中也没有实现缓存和跳过机制,这可能会拖慢构建速度。

开始下载!

该节几乎就不属于 Gradle 的范畴了,完全可以看作是一个普通的 Kotlin 代码了。

只是还有几个点需要了解一下。

判断当前系统

我们可以通过 Os.isFamily(Os.FAMILY_WINDOWS) 判断当前平台是否是 windows,同理 Os.isFamily(Os.FAMILY_MAC) 用于判断是否是 macOS。

从网络下载文件

从网络下载文件使用的是 ant 来实现的:

ant.invokeMethod("get", mapOf("src" to link, "dest" to cachePath))

其中的 get 表示 GET 请求;

src 表示请求网址;

dest 表示储存至哪个文件。

解压文件

kotlin 的 Gradle DSL 提供了一个叫 unzipTo 的函数可以直接用来解压文件:

unzipTo(cacheFile.parentFile, cacheFile)

其中,第一个参数表示解压到哪个文件夹,第二个参数表示需要解压的 zip 文件。

除了以上这几个地方外,其他的语法和普通 kotlin 无异,我就不一一赘述了,直接上代码:

fun downloadFFmpeg() {println("downloadFFmpeg: Downloading and extracting ffmpeg binary file ...")if (!System.getProperty("os.arch").contains("64")) {println("downloadFFmpeg: Not support Current System arch(${System.getProperty("os.arch")}), You may need download for your system by yourself at: https://ffmpeg.org/download.html, then copy to '<RESOURCES_ROOT_DIR>/<OS_NAME>', such as `resources/macos/`")return}val windowsDownloadLink = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"val macDownloadLink = "https://evermeet.cx/ffmpeg/ffmpeg-6.0.zip"val cachePath = file("cache")val windowsFile = "resources/windows/ffmpeg.exe"val macFile = "resources/macos/ffmpeg"val ffmpegFile = if (Os.isFamily(Os.FAMILY_WINDOWS)) file(windowsFile) else file(macFile)println("downloadFFmpeg: Check $ffmpegFile ...")if (!ffmpegFile.exists()) {println("downloadFFmpeg: '$ffmpegFile' not exist, start downloading...")if (!cachePath.exists()) {mkdir(cachePath)}if (Os.isFamily(Os.FAMILY_WINDOWS)) {executeDownload(windowsDownloadLink, cachePath.resolve("ffmpeg.zip"))unzipWindowsFFmpeg(cachePath.resolve("ffmpeg.zip"), file(windowsFile))}else if (Os.isFamily(Os.FAMILY_MAC)) {executeDownload(macDownloadLink, cachePath.resolve("ffmpeg.zip"))unzipMacFFmpeg(cachePath.resolve("ffmpeg.zip"), file(macFile))}else {println("downloadFFmpeg: Not support Current System, You may need download for your system by yourself at: https://ffmpeg.org/download.html, then copy to '<RESOURCES_ROOT_DIR>/<OS_NAME>', such as `resources/macos/ffmpeg`")}}else {println("downloadFFmpeg: '$ffmpegFile' already exist, all done!\nTip: if you want download again, just remove '$ffmpegFile' then build again.")}
}fun executeDownload(link: String, cachePath: File) {println("downloadFFmpeg: Download from `$link` to `$cachePath` ...")ant.invokeMethod("get", mapOf("src" to link, "dest" to cachePath))println("downloadFFmpeg: `$cachePath` downloaded, start unzip...")
}fun unzipWindowsFFmpeg(cacheFile: File, saveFile: File) {unzipTo(cacheFile.parentFile, cacheFile)println("downloadFFmpeg: unzip finish, start copy...")cacheFile.parentFile.resolve("ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe").copyTo(saveFile)println("downloadFFmpeg: copy finish! start remove cache...")cacheFile.parentFile.deleteRecursively()println("downloadFFmpeg: All done!")
}fun unzipMacFFmpeg(cacheFile: File, saveFile: File) {unzipTo(cacheFile.parentFile, cacheFile)println("downloadFFmpeg: unzip finish, start copy...")cacheFile.parentFile.resolve("ffmpeg").copyTo(saveFile)println("downloadFFmpeg: copy finish! start remove cache...")cacheFile.parentFile.deleteRecursively()println("downloadFFmpeg: All done!")
}

最后,只需要在我们创建的任务中调用 downloadFFmpeg() 函数即可:

tasks.register("downloadFFmpeg") {// 在这里下载文件doLast {downloadFFmpeg()}
}

Desktop 桌面特有组件

虽然本文的标题叫做 Compose Desktop ,但是前面几节讲的内容似乎都和 Compose 没有太大关系,更多的是业务逻辑上的问题,但是不要急,你看,Compose 这不就来了嘛。

即然我们这个项目是纯桌面项目,那么当然可以放心大胆的用上一些桌面特有组件了。

Tooltips 鼠标悬浮提示框

Tooltips 用于在我们的鼠标移动并停留在某个组件时悬浮显示提示内容:

6.png

它的使用方式也很简单,只要将需要添加提示的组件置于 TooltipArea 之下即可:

TooltipArea(tooltip = {// 提示内容Surface(modifier = Modifier.shadow(4.dp),color = Color(255, 255, 210),shape = RoundedCornerShape(4.dp)) {Text(text = "自定义 FFmpeg 可执行文件路径",modifier = Modifier.padding(10.dp))}},modifier = Modifier.padding(start = 40.dp),delayMillis = 600,  // 停留多久后才显示
) {// 需要添加提示的组件Text("自定义")
}

其中的 tooltip 即提示文本内容,一般直接使用一个 Text 即可;content (最后一个 lambda)即需要添加提示的原内容。

Scrollbars 滚动进度条

使用 Scrollbars 可以为可滚动的 Compose 组件添加滚动进度条,并且拖动这个进度条可以快速滚动内容:

7.gif

可以为 Modifier.verticalScrollLazyColumn Modifier.horizontalScrollLazyRow 等可滚动的组件添加滚动条。

使用方式也很简单,将原本的可滚动组件放到 Box 中,然后在 Box 中添加 VerticalScrollbar HorizontalScrollbar 即可,例如为具有 Modifier.verticalScroll 的组件添加滚动条:

val scrollState  = rememberScrollState()
Box {Column(modifier = Modifier.fillMaxSize().padding(8.dp).verticalScroll(scrollState),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {Text("text\ntext\ntext\ntext\ntext\ntext\ntext")}VerticalScrollbar(modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),adapter = rememberScrollbarAdapter(scrollState))
}

可以看到,使用 VerticalScrollbar 的重点在于需要提供一个 adapter 即适配器,这个适配器用于滚动条计算当前大小、滚动位置以及拖动滚动条后同步更新可滚动内容的位置。

而官方已经给几乎所有的可滚动组件内置了可用的适配器:

8.png

我们只需要将对应的可滚动组件的滚动状态传入即可,例如上面例子中,我们传入的就是一个 ScrollState

虽然说官方提供了几乎所有可滚动组件的适配器,但是好巧不巧的,就是没有提供我所需要的这个可滚动组件 LazyVerticalStaggeredGrid 的适配器。

但是通过查看类似的 LazyGridState 的适配器实现,我发现其实自己写一个挺简单的,不知道是因为 LazyVerticalStaggeredGrid 是刚添加的所以没有适配器还是因为 LazyVerticalStaggeredGrid 的每个 item 高度都不是固定的,导致计算位置时会出现错误,总之现在需要我们自己去实现了。

显然,我们自己实现的也会出现高度计算错误,但是又不是不能用。哈哈哈。

首先,如果要实现自己的适配器,我们需要继承 ScrollbarAdapter 这个接口,然后实现:

  1. scrollOffset: Double 当前可滚动组件已经滚动了多少像素
  2. contentSize: Double 可滚动内容总的大小
  3. viewportSize: Double 当前可见区域的大小
  4. scrollTo(scrollOffset: Double) 如果滚动条被拖动,会调用这个方法,我们需要在其中实现滚动内容的方法。

下面我简要介绍一下实现思路,然后直接上代码,这个适配器是修改自 LazyGridState 的适配器。

首先,scrollOffset 我们可以通过将首个可见 item 的序号(index) x 平均每个 item 的高度 - 首个可见 item 的相对于可见区域的偏移量即可得到。

contentSize 可以通过所有项目的数量 x 平均每个项目的高度 得到(当然,还需要将项目之间的间距也加进去)

viewportSize scrollTo 就是可滚动组件的固有属性和方法,我们只需要判断一下方向直接返回就可以了。

完整代码如下:

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.v2.ScrollbarAdapter
import androidx.compose.foundation.v2.maxScrollOffset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import kotlin.math.abs@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberLazyFGridScrollbarAdapter(scrollState: LazyStaggeredGridState,
): ScrollbarAdapter = remember(scrollState) {LazyStaggeredGridAdapter(scrollState)
}@OptIn(ExperimentalFoundationApi::class)
class LazyStaggeredGridAdapter(private val scrollState: LazyStaggeredGridState
) : LazyLineContentAdapter() {override val lineSpacing: Intget() = scrollState.layoutInfo.mainAxisItemSpacingoverride val viewportSize: Doubleget() = with(scrollState.layoutInfo) {if (orientation == Orientation.Vertical)viewportSize.heightelseviewportSize.width}.toDouble()override fun firstVisibleLine(): VisibleLine? {return scrollState.layoutInfo.visibleItemsInfo.firstOrNull { it.index != -1 } // Skip exiting items?.let { firstVisibleItem ->VisibleLine(index = firstVisibleItem.index,offset = firstVisibleItem.mainAxisOffset())}}override fun totalLineCount() = scrollState.layoutInfo.totalItemsCountoverride fun contentPadding() = with(scrollState.layoutInfo){beforeContentPadding + afterContentPadding}override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {scrollState.scrollToItem(lineIndex, scrollOffset)}override suspend fun scrollBy(value: Float) {scrollState.scrollBy(value)}override fun averageVisibleLineSize()= with(scrollState.layoutInfo.visibleItemsInfo){val firstFloatingIndex = 0val first = this[firstFloatingIndex]val last = last()val count = size - firstFloatingIndex(last.mainAxisOffset() + last.mainAxisSize() - first.mainAxisOffset() - (count-1)*lineSpacing).toDouble() / count}private val isVertical = scrollState.layoutInfo.orientation == Orientation.Verticalprivate fun LazyStaggeredGridItemInfo.mainAxisOffset() = with(offset) {if (isVertical) y else x}private fun LazyStaggeredGridItemInfo.mainAxisSize() = with(size) {if (isVertical) height else width}}abstract class LazyLineContentAdapter: ScrollbarAdapter{// Implement the adapter in terms of "lines", which means either rows,// (for a vertically scrollable widget) or columns (for a horizontally// scrollable one).// For LazyList this translates directly to items; for LazyGrid, it// translates to rows/columns of items.data class VisibleLine(val index: Int,val offset: Int)/*** Return the first visible line, if any.*/protected abstract fun firstVisibleLine(): VisibleLine?/*** Return the total number of lines.*/protected abstract fun totalLineCount(): Int/*** The sum of content padding (before+after) on the scrollable axis.*/protected abstract fun contentPadding(): Int/*** Scroll immediately to the given line, and offset it by [scrollOffset] pixels.*/protected abstract suspend fun snapToLine(lineIndex: Int, scrollOffset: Int)/*** Scroll from the current position by the given amount of pixels.*/protected abstract suspend fun scrollBy(value: Float)/*** Return the average size (on the scrollable axis) of the visible lines.*/protected abstract fun averageVisibleLineSize(): Double/*** The spacing between lines.*/protected abstract val lineSpacing: Intprivate val averageVisibleLineSize by derivedStateOf {if (totalLineCount() == 0)0.0elseaverageVisibleLineSize()}private val averageVisibleLineSizeWithSpacing get() = averageVisibleLineSize + lineSpacingoverride val scrollOffset: Doubleget() {val firstVisibleLine = firstVisibleLine()return if (firstVisibleLine == null)0.0else {firstVisibleLine.index * averageVisibleLineSizeWithSpacing - firstVisibleLine.offset}}override val contentSize: Doubleget() {val totalLineCount = totalLineCount()return averageVisibleLineSize * totalLineCount +lineSpacing * (totalLineCount - 1).coerceAtLeast(0) +contentPadding()}override suspend fun scrollTo(scrollOffset: Double) {val distance = scrollOffset - this@LazyLineContentAdapter.scrollOffset// if we scroll less than viewport we need to use scrollBy function to avoid// undesirable scroll jumps (when an item size is different)//// if we scroll more than viewport we should immediately jump to this position// without recreating all items between the current and the new positionif (abs(distance) <= viewportSize) {scrollBy(distance.toFloat())} else {snapTo(scrollOffset)}}private suspend fun snapTo(scrollOffset: Double) {val scrollOffsetCoerced = scrollOffset.coerceIn(0.0, maxScrollOffset)val index = (scrollOffsetCoerced / averageVisibleLineSizeWithSpacing).toInt().coerceAtLeast(0).coerceAtMost(totalLineCount() - 1)val offset = (scrollOffsetCoerced - index * averageVisibleLineSizeWithSpacing).toInt().coerceAtLeast(0)snapToLine(lineIndex = index, scrollOffset = offset)}}

监听按键和鼠标事件

在我这个项目中,有一个文件列表,我希望能在这个地方监听键盘的上下方向键,然后以此来快速移动选中项。

为此我们需要为 程序 加上对于键盘事件的接收,对于键盘事件的接收只能写在 WindowonKeyEvent 参数中:

Window(// ……onKeyEvent = {applicationState.onKeyEvent(it)}) {// ……}

为了让我的 Compose 组件能够接受到按键事件,我将它的状态提升到了最顶级,也就是和 window 平级的位置。然后在接收到按键事件后就可以使用了:

    fun onKeyEvent(keyEvent: KeyEvent): Boolean {if (keyEvent.type == KeyEventType.KeyDown) {when (keyEvent.key.nativeKeyCode) {37 -> { // 向左箭头}38 -> { // 向上箭头}39 -> { // 向右箭头}40 -> { // 向下箭头}}}return false}

之后,在我的程序中,有一个查看大图的界面,我希望能够在这个界面通过监听鼠标滚轮来实时缩放图片,那么我就需要监听鼠标的滚动事件:

Modifier.onPointerEvent(PointerEventType.Scroll) {scaleNumber = (scaleNumber - it.changes.first().scrollDelta.y).coerceIn(1f, 20f)}

可以看到,获取鼠标滚轮滚动事件也十分简单,只需要在需要接收事件的组件中添加 Modifier..onPointerEvent() 并将类型指定为 PointerEventType.Scroll 即可。

另外,如果缩放过图片,我们一般还会添加鼠标拖动图片移动:

Modifier.onPointerEvent(PointerEventType.Move) {if (it.changes.first().pressed) {offset -= (it.changes.first().previousPosition - it.changes.first().position)}}

和接收滚动类似,只是将类型改为 PointerEventType.Move, 并且,这个事件会在鼠标移动始终触发,显然不符合我们的需要,我们需要的是按下并移动,所以这里还要额外加一个判断 it.changes.first().pressed ,如果该值为 true 则表示此时鼠标被按下了。

其他问题

在修改这个程序时,遇到的另外一个大问题就是关于加载图片的问题,在我这个程序中有这么一个界面:

9.jpg

它用于展示当前添加的所有图片,虽然我已经用了 Lazy 组件并且实现了图片的异步加载,但是滑动时还是非常的卡顿。

显然,这是因为这些图片的分辨率都太高了,而加载时又都是以原分辨率加载的,这就会导致滑动时频繁的解码图片,使其非常卡顿。

在安卓端,我们一般会使用 Glide,它实现了按需加载缩略图,加了各种缓存等,但是 Glide 并不支持 Compose。

对于 Compose 来说,有一个类似的框架 Coil ,但是目前 Coil 只支持安卓。

好在,目前 Coil 的作者已经开始添加多平台支持了: Compose Multiplatform support 。

让我们一起期待吧,期望更换为使用 Coil 加载图片后能够改善这个界面的性能。

总结

以上就是我最近在修改使用 Compose 实现的纯桌面端程序时发现的一些问题以及解决方法。

完整的项目源码在此: TimelapseHelper。

其实在修改这个程序时还发现了很多有趣的问题,但是限于这篇文章已经不短了,所以我也就不再继续写了,感兴趣的可以看看源码。

参考资料

  1. Packaging resources
  2. Gradle User Manual

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/114160.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

从入门到进阶 之 ElasticSearch SpringData 继承篇

&#x1f339; 以上分享 从入门到进阶 之 ElasticSearch SpringData 继承篇&#xff0c;如有问题请指教写。&#x1f339;&#x1f339; 如你对技术也感兴趣&#xff0c;欢迎交流。&#x1f339;&#x1f339;&#x1f339; 如有需要&#xff0c;请&#x1f44d;点赞&#x1f…

PHP数据加密传输和存储问题

PHP数据加密的类型 md5()&#xff0c;sha1()&#xff0c;crypt() 双md5加密加盐

AAPCS:最新的ARM子程序调用规则

AAPCS是arm公司发布的ARM架构应用程序二进制&#xff08;ABI&#xff09;程序调用接口&#xff0c;该文档由多个版本&#xff0c;博主第一次ARM程序调用规则是在《ARM体系与结构编程》&#xff0c;但书中描述的是ATPCS&#xff0c;AAPCS是ATPCS的升级版。后面去ARM官网看到了AA…

自然语言处理基础——词表示

词表示 把自然语言中最基本的语言单元——词转换为机器能够理解的 词表示能完成以下两个能力 词相似度计算 词与词之间语义的关系 近义词&上位词 使用近义词或上位词表示的问题 遗漏差异 遗漏新的释义 带有主观性 数据吸收 需要大量人工构建 One-Hot Representation …

Kafka学习(最新版3.6.0)

文章目录 一、初识MQ1.1 什么是MQ1.2 同步和异步通讯1.1.1 同步通讯1.1.2 异步通讯 1.3 技术对比1.4 MQ的两种模式 二、初识Kafka2.1 Kafka的使用场景2.2 Kafka基本概念2.3 Topic与Partition 三、Kafka基本使用3.1 部署前的准备3.2 启动kafka服务器3.3 Kafka核心概念之Topic3.4…

059:mapboxGL监听键盘事件,通过eastTo控制左右旋转

第059个 点击查看专栏目录 本示例是介绍演示如何在vue+mapbox中监听键盘事件,通过eastTo控制左右旋转。 本例通过easeTo方法来加减一定数值的bearing角度,通过.addEventListener的方法来监听键盘的按键动作。这里一定要设置interactive: false, 否则展现不出来旋转效果。 直…

机械设备经营小程序商城的作用是什么

由于机械设备厂商品牌需要各地招商代理&#xff0c;因此在管理方面也需要工具进行高效管理。如今各个行业都在开展数字化转型解决行业所遇难题或通过线上销售解决传统三公里难题及品牌扩张难题、用户消费渠道少等难题&#xff0c;构建会员体系精细化管理&#xff0c;同时还需要…

MySQL 主从复制原理

文章目录 1.主从复制方式1.1 异步复制1.2 半同步复制1.3 全同步复制 2.主从复制原理3.主从复制时推还是拉&#xff1f;参考文献 主从复制是 MySQL 高可用&#xff08;备份&#xff09;和高性能&#xff08;读写分离&#xff09;的基础&#xff0c;有了这个基础&#xff0c;MySQ…

【Ascend C算子开发(入门)】——Ascend C编程模式与范式

Ascend C编程模型与范式 1.并行计算架构抽象 Ascend C编程开发的算子是运行在AI Core上的&#xff0c;所以我们需要了解一下AI Core的结构。AI Core主要包括计算单元、存储单元、搬运单元。 计算单元包括了三种计算资源&#xff1a;Scalar计算单元&#xff08;执行标量计算&…

外骨骼机器人和人形机器人概览

前言&#xff1a;一点思考 外骨骼机器人和人形机器人都曾随着一些爆品的出现火热过一段时间&#xff0c;但总感觉当前技术条件还不成熟&#xff0c;真正能落地的应用场景不多。马斯克在擎天柱发布会上被问到人形机器人的落地与前景问题时并没有给出明确答案&#xff0c;只是用…

c++ pcl点云变换骨架枝干添加树叶源码实例

程序示例精选 c pcl点云变换骨架枝干添加树叶源码实例 如需安装运行环境或远程调试&#xff0c;见文章底部个人QQ名片&#xff0c;由专业技术人员远程协助&#xff01; 前言 这篇博客针对《c pcl点云变换骨架枝干添加树叶源码实例》编写代码&#xff0c;代码整洁&#xff0c;…

自然语言处理---Tr ansformer机制详解之Transformer结构

1 Encoder模块 1.1 Encoder模块的结构和作用 经典的Transformer结构中的Encoder模块包含6个Encoder Block.每个Encoder Block包含一个多头自注意力层&#xff0c;和一个前馈全连接层. 1.2 Encoder Block 在Transformer架构中&#xff0c;6个一模一样的Encoder …

【JavaScript】深入浅出理解事件循环

1. 浏览器的进程模型 1.1 进程 程序运行需要有它自己专属的内存空间&#xff0c;可以把这块内存空间简单的理解为进程。 每个应用至少有一个进程&#xff0c;进程之间相互独立&#xff0c;即使要通信&#xff0c;也需要双方同意。 1.2 线程 有了进程后&#xff0c;就可以运…

【广州华锐互动】VR营销心理学情景模拟培训系统介绍

在高度竞争的汽车市场中&#xff0c;销售人员需要具备强大的专业知识、引人入胜的销售技巧&#xff0c;以及敏锐的市场洞察力。然而&#xff0c;传统的培训方式往往无法满足这些需求&#xff0c;因为它们往往忽略了实践的重要性。 为了解决这个问题&#xff0c;许多公司开始采用…

TCP/IP(十九)TCP 实战抓包分析(三)TCP 第一次握手 SYN 丢包

一 TCP 三次握手异常情况实战分析 说明&#xff1a; 本文是TCP 三次握手异常系列之一 ① 异常场景 接下里我用三个实验案例,带大家一起探究探究这三种异常关注&#xff1a; 如何刻意练习模拟上述场景 以及 wireshark现象 ② 实验环境 ③ 实验一&#xff1a;TCP 第一次握…

Python —— UI自动化之使用JavaScript进行元素点亮、修改、点击元素

1、JavaScript点亮元素 在控制台通过JavaScript语言中对元素点亮效果如下&#xff1a; 将这个语句和UI自动化结合&#xff0c;代码如下&#xff1a; locator (By.ID,"kw") # 是元组类型 web_element WebDriverWait(driver,5,0.5).until(EC.visibility_of_eleme…

Arduino驱动BMA220三轴加速度传感器(惯性测量传感器篇)

目录 1、传感器特性 2、硬件原理图 3、驱动程序 BMA220的三轴加速度计是一款具有I2C接口的超小型三轴低g加速度传感器断路器,面向低功耗消费市场应用。它可以测量3个垂直轴的加速度,从而在手机、手持设备、计算机外围设备、人机界面、虚拟现实功能和游戏控制器中感知倾斜、…

王道计算机考研 操作系统学习笔记 + 完整思维导图篇章五: IO管理

目录 IO设备的基本概念和分类 IO设备的分类 按使用特性分类 按传输速率分类 按信息交换单位分类 IO控制器 l/O设备的电子部件&#xff08;I/O控制器&#xff09; l/O控制器的组成 内存映像I/o vs.寄存器独立编址 IO控制方式 程序直接控制方式 中断驱动方式 DMA方式 ​编辑通…

java1.8流的新特性使用

案例描述 今天跟着黑马程序员的视频&#xff0c;完成“瑞吉外卖”项目的菜品信息管理模块的时候&#xff0c;遇到了一个比较陌生的写法 用到了Java8的新特性 stream().map((item) -> {}).collect() List<DishDto> collect records.stream().map((item) -> {DishDt…

10.17七段数码管单个多个(部分)

单个数码管的实现 第一种方式 一端并接称为位码&#xff1b;一端分别接收电平信号以控制灯的亮灭&#xff0c;称为段码 8421BCD码转七段数码管段码是将BCD码表示的十进制数转换成七段LED数码管的7个驱动段码&#xff0c; 段码就是LED灯的信号 a为1表示没用到a&#xff0c;a为…