Jetpack Compose 编写 AndroidTV 应用
作者:Seiko
https://juejin.cn/post/7018880391515734047
前言
我很好奇 Jetpack Compose 作为一个新的界面工具包,在TV端使用体验会如何,毕竟现有的 leanback 库并不是很好用,而且自定义难度很大,导致大多个人开源的TV项目都长得差不多;
随着正式版的发布,我想在被大浪卷走之前努力一下,学习 Jetpack Compose 并开发一款简单的TV端应用。
项目地址:https://github.com/qdsfdhvh/compose-anime-tv 欢迎点个Star。
预览
1. 副作用(Effect)
Jetpack Compose 两大标签声明式与函数式,尤其是函数式是我们主要需要适应的;@Composable
函数会根据UI刷新而重复运行,但是里面的一些如初始化、绑定等行为,或者是一些定义的变量,他们是不可以跟随UI刷新而重新初始化、重复绑定或重新生成的;
为了能让它们在合适的时间运行,就需要使用副作用 Effect
2. 按键传递(KeyEvent)
为了尽量使用现有的 Modifier 扩展,我首先在官方文档查阅了下 KeyEvent:
Box(
Modifier
.onPreviewKeyEvent { keyEvent1 -> false }
// .onKeyEvent { keyEvent5 -> false }
.onKeyEvent { keyEvent4 -> false }
) {
Box(
Modifier
.onPreviewKeyEvent { keyEvent2 -> false }
.onKeyEvent { keyEvent3 -> false }
.focusable()
)
}
非常简洁,只有 onKeyEvent()
、onPreviewKeyEvent()
两个扩展、而且基本能满足开发需要。
3. 焦点处理(Focus)
3.1 Modifier扩展
主要有下面这几个:
Modifier.focusTarget()、Modifier.focusable() Modifier.onFocusEvent()、Modifier.onFocusChange() Modifier.focusRequester()、Modifier.focusOrder()
3.1.1 focusable() 与 focusTarget()
focusable()
是对 focusTarget()
的进一步封装,必须配置 focusTarget()
才能获取焦点,正常使用 onFocusChange()
、onKeyEvent()
等;
官方建议使用 focusable() 而不是直接使用 focusTarget(),但是我这里主要使用的是 focusTarget();
PS:focusTarget() 曾经叫 focusModifier()
,我感觉旧名字更能体现为啥一定要配置了才能使用相关方法,所以这里提一下。
3.1.2 onFocusChange() 与 onFocusEvent()
onFocusEvent()
作用是回调焦点状态 FocusState
;
interface FocusState {
val isFocused: Boolean
val hasFocus: Boolean
val isCaptured: Boolean
}
onFocusChange()
则是对 onFocusEvent()
的封装,只有变化回调 FocusState,类似于 Flow.distinctUntilChanged
一般 onFocusChange() 用的比较多;
3.1.3 focusOrder() 与 focusRequester()
focusRequester()
用于给控件配置 FocusRequester
类:
class FocusRequester {
fun requestFocus()
fun captureFocus(): Boolean
fun freeFocus(): Boolean
}
FocusRequester.requestFocus()
是给控件获取焦点的唯一手段;
captureFocus()
和 freeFocus()
分别是锁定与释放焦点;
focusOrder()
用于确定下一个获取焦点的控件:
@Composable
fun FocusOrderSample() {
val (item1, item2, item3, item4) = remember { FocusRequester.createRefs() }
Box(
Modifier
.focusOrder(item1) {
next = item2
right = item2
down = item3
previous = item4
}
.focusable()
)
...
}
官方为了便于 focusOrder() 使用,加了下面这个扩展,为此在项目里我偷懒了下,都使用了 focusOrder() 配置 FocusRequester;
fun Modifier.focusOrder(focusRequester: FocusRequester): Modifier = focusRequester(focusRequester)
简化一下,平时使用较多的 Modifier 扩展就减成了三大件:
focusTarget()
focusOrder()
onFocusChange()
3.2 FocusManager
interface FocusManager {
fun clearFocus(force: Boolean)
fun moveFocus(focusDirection: FocusDirection): Boolean
}
通过 LocalFocusManager.current
获取,实现类 FocusManagerImpl
是私有的,同时内部很多变量也是私有的,不便于自定义 FocusManager,能做的事情就比较有限了。
4. 按键 & 焦点传递
进入 AndroidComposeView,从 dispatchKeyEvent()
开始大致预览下实现:
//androidx.compose.ui.platform.AndroidComposeView.android.kt
override fun dispatchKeyEvent(event: AndroidKeyEvent) =
if (isFocused) {
sendKeyEvent(KeyEvent(event))
} else {
super.dispatchKeyEvent(event)
}
override fun sendKeyEvent(keyEvent: KeyEvent): Boolean {
return keyInputModifier.processKeyInput(keyEvent)
}
private val keyInputModifier: KeyInputModifier = KeyInputModifier(
onKeyEvent = {
val focusDirection = getFocusDirection(it)
if (focusDirection == null || it.type != KeyDown) return@KeyInputModifier false
// Consume the key event if we moved focus.
focusManager.moveFocus(focusDirection)
},
onPreviewKeyEvent = null
)
//androidx.compose.ui.input.key.KeyInputModifier.kt
internal class KeyInputModifier(
val onKeyEvent: ((KeyEvent) -> Boolean)?,
val onPreviewKeyEvent: ((KeyEvent) -> Boolean)?
) : Modifier.Element {
lateinit var keyInputNode: ModifiedKeyInputNode
fun processKeyInput(keyEvent: KeyEvent): Boolean {
val activeKeyInputNode = keyInputNode.findPreviousFocusWrapper()
?.findActiveFocusNode()
?.findLastKeyInputWrapper()
?: error("KeyEvent can't be processed because this key input node is not active.")
return with(activeKeyInputNode) {
val consumed = propagatePreviewKeyEvent(keyEvent)
if (consumed) true else propagateKeyEvent(keyEvent)
}
}
}
fun Modifier.onPreviewKeyEvent(onPreviewKeyEvent: (KeyEvent) -> Boolean): Modifier = composed {
KeyInputModifier(onKeyEvent = null, onPreviewKeyEvent = onPreviewKeyEvent)
}
fun Modifier.onKeyEvent(onKeyEvent: (KeyEvent) -> Boolean): Modifier = composed {
KeyInputModifier(onKeyEvent = onKeyEvent, onPreviewKeyEvent = null)
}
上面的代码结合官方 KeyEvent 的使用示例,可以判断出:
Jetpack Compose 会先把 KeyEvent 交给 Focus 链上配置了 onKeyEvent()
的控件们消费,没有控件消费就会走默认的 onKeyEvent(),约等于 focusManager.moveFocus(focusDirection)
再看下 focusManager 是大致是怎么处理的:
//androidx.compose.ui.focus.FocusManager.kt
class FocusManagerImpl(
private val focusModifier: FocusModifier = FocusModifier(Inactive)
) : FocusManager {
...
override fun moveFocus(focusDirection: FocusDirection): Boolean {
val source = focusModifier.focusNode.findActiveFocusNode() ?: return false
val nextFocusRequester = source.customFocusSearch(focusDirection, layoutDirection)
if (nextFocusRequester != FocusRequester.Default) {
nextFocusRequester.requestFocus()
return true
}
val destination = focusModifier.focusNode.focusSearch(focusDirection, layoutDirection)
if (destination == null || destination == source) {
return false
}
// We don't want moveFocus to set focus to the root, as this would essentially clear focus.
if (destination.findParentFocusNode() == null) {
return when (focusDirection) {
// Skip the root and proceed to the next/previous item from the root's perspective.
Next, Previous -> {
destination.requestFocus(propagateFocus = false)
moveFocus(focusDirection)
}
// Instead of moving out to the root, we return false.
// When we return false the key event will not be consumed, but it will bubble
// up to the owner. (In the case of Android, the back key will be sent to the
// activity, where it can be handled appropriately).
@OptIn(ExperimentalComposeUiApi::class)
Out -> false
else -> error("Move focus landed at the root through an unknown path.")
}
}
// If we found a potential next item, call requestFocus() to move focus to it.
destination.requestFocus(propagateFocus = false)
return true
}
}
nextFocusRequester
就是通过 focusOrder 配置的下一个目标,如果返回的不是 FocusRequester.Default
,就直接 requestFocus()
;
否则就通过 focusModifier.focusNode.focusSearch()
寻找焦点:
internal fun ModifiedFocusNode.focusSearch(
focusDirection: FocusDirection,
layoutDirection: LayoutDirection
): ModifiedFocusNode? {
return when (focusDirection) {
Next, Previous -> oneDimensionalFocusSearch(focusDirection)
Left, Right, Up, Down -> twoDimensionalFocusSearch(focusDirection)
@OptIn(ExperimentalComposeUiApi::class)
In -> {
// we search among the children of the active item.
val direction = when (layoutDirection) { Rtl -> Left; Ltr -> Right }
findActiveFocusNode()?.twoDimensionalFocusSearch(direction)
}
@OptIn(ExperimentalComposeUiApi::class)
Out -> findActiveFocusNode()?.findParentFocusNode()
else -> error(invalidFocusDirection)
}
}
internal fun ModifiedFocusNode.findActiveFocusNode(): ModifiedFocusNode? {
return when (focusState) {
Active, Captured -> this
ActiveParent -> focusedChild?.findActiveFocusNode()
Inactive, Disabled -> null
}
}
findActiveFocusNode()
方法主要还是确定当前的焦点,基于当前焦点去寻找下一个目标;oneDimensionalFocusSearch()
与 twoDimensionalFocusSearch()
都是往 child 寻找下一个目标, findParentFocusNode()
则是把焦点传给 parent
做个小总结:
基于 focusOrder() 确定下一个目标是最直接、最稳定的,不会走后面那些较为复杂的判断,上层方便配置的话尽量配置; onKeyEvent() 初步看来只适合在 Focus 链的两端使用,不然很可能判断不足,把原本想让 focusManager.moveFocus() 消费的行为给抢走; 可以通过 focusManager.moveFocus(FocusDirection.Out) 把当前焦点传给 parent。
5. 焦点传递实践
我预期的传递方案大致就是:
每个组件各自处理焦点,焦点从最外层逐步传入; 移动焦点时,当前组件不消费就传给父组件处理。
以示例来说,先自定义两个组件 Box1
与 Box2
:
@Composable
fun AppScreen() {
val (focus1, focus2) = remember { FocusRequester.createRefs() }
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
Box1(Modifier.focusOrder(focus1) {
right = focus2
// left = focus2
})
Box2(Modifier.focusOrder(focus2) {
left = focus1
// right = focus1
})
}
SideEffect {
focus1.requestFocus()
}
}
@Composable
fun Box1(modifier: Modifier = Modifier) {
var isParentFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
// .background(Color.Green)
// .size(200.dp)
.onFocusChanged { isParentFocused = it.isFocused }
.focusTarget(),
// contentAlignment = Alignment.Center
) {
Text(
if (isParentFocused) "Focused" else "",
// color = Color.White,
// style = MaterialTheme.typography.h3
)
}
}
@Composable
fun Box2(modifier: Modifier = Modifier) {
...
}
其他不变的情况下,把 Box1 改成一个List:
@Composable
fun Box1(modifier: Modifier = Modifier) {
var isParentFocused by remember { mutableStateOf(false) }
var focusIndex by remember { mutableStateOf(0) }
LazyColumn(
modifier = modifier
.onFocusChanged { isParentFocused = it.isFocused }
.focusTarget(),
) {
items(10) { index ->
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
Text(
if (isFocused) "Focused" else "",
// color = Color.Black,
// style = MaterialTheme.typography.h5,
// textAlign = TextAlign.Center,
modifier = Modifier
// .padding(10.dp)
// .background(Color.Green)
// .width(120.dp)
// .padding(vertical = 10.dp)
.onFocusChanged {
isFocused = it.isFocused
if (isFocused) focusIndex = index
}
.focusOrder(focusRequester)
.focusTarget(),
)
if (isParentFocused && focusIndex == index) {
SideEffect {
focusRequester.requestFocus()
}
}
}
}
}
看似没什么问题,但其实向右的跳转并不是根据 AppScreen 中的配置而跳转的,给 Box1 配置 focusOrder(focus1) { left = focus2 }
,按左键并不能找到 focus2;
这里就需要手动去把焦点传给 parent,借助 onKeyEvent() 在按键传递过程中触发 focusManager.moveFocus(FocusDirection.Out)
把焦点返给 parent,并返回 false 让这个按键继续传递下去;
...
val focusManager = LocalFocusManager.current
LazyColumn(
modifier = modifier
// .onFocusChanged { isParentFocused = it.isFocused }
.onKeyEvent {
when (it) {
Key.DirectionRight,
Key.DirectionLeft -> {
focusManager.moveFocus(FocusDirection.Out)
}
}
false
}
// .focusTarget(),
) {
...
}
我在项目中使用的焦点传递方案大致就是这样,目前只能应付一些较为简单的场景,由于有返回焦点给 parent 的行为,单个组件不适合有两层 Focus 的传递,需要把多的一层再拆成组件,不过好在 Jetpack Compose 写一个组件成本很低。
6. 列表滚动
焦点传递方式虽然大致确定了,但是在焦点移动时,列表也是需要跟着滚动的;
通过官方文档很快就找到了相关代码:
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(state = listState) {
// ...
}
ScrollToTopButton(
onClick = {
coroutineScope.launch {
// Animate scroll to the first item
listState.animateScrollToItem(index = 0)
}
}
)
借助 LazyListState 就能实现列表的滚动,相关方法大概有:
listState.scrollBy(value) listState.scrollToItem(index, offset) listState.animateScrollBy(value, animationSpec) listState.animateScrollToItem(index, offset)
单从使用上看 animateScrollToItem()
比较符合需要,给上面的 Box1 添加相关配置,并在focusIndex
变化时触发滚动:
val listState = rememberLazyListState()
...
LazyColumn(
state = listState
...
) {
...
}
LaunchedEffect(focusIndex) {
listState.animateScrollToItem(focusIndex)
}
可以看到 animateScrollToItem()
滚动效果不尽人意,所以我们需要自己去算滚动距离并使用 animateScrollBy()
来滚动;
这方面的实现我参考了 akilarajeshks/SampleComposeApp:
interface ScrollBehaviour {
suspend fun onScroll(state: LazyListState, focusIndex: Int)
}
object VerticalScrollBehaviour : ScrollBehaviour {
override suspend fun onScroll(state: LazyListState, focusIndex: Int) {
val focusItem = state.layoutInfo.visibleItemsInfo.find { focusIndex == it.index } ?: return
val viewStart = state.layoutInfo.viewportStartOffset
val viewEnd = state.layoutInfo.viewportEndOffset
val viewSize = viewEnd - viewStart
val itemStart = focusItem.offset
val itemEnd = focusItem.offset + focusItem.size
// 这里加点距离主要是为了让下一个目标控件绘制出来,不然在visibleItemsInfo会找不到
val offSect = 80
val value = when {
itemStart < viewStart -> itemStart.toFloat() - offSect
itemEnd > viewStart + viewSize -> (itemEnd - viewSize - viewStart).toFloat() + offSect
else -> return
}
state.animateScrollBy(value, tween(150, 0, LinearEasing))
}
}
suspend fun LazyListState.animateScrollToItem(focusIndex: Int, scrollBehaviour: ScrollBehaviour) {
scrollBehaviour.onScroll(this, focusIndex)
}
再把 Box1 里的滚动代码修改下就完成了:
listState.animateScrollToItem(focusIndex, VerticalScrollBehaviour)
7.播放器
这块我基本都是参照了 halilozercan/ComposeVideoPlayer,它的结构设计的非常好,我只把它里面触摸部分换成了按键的;
大致如下,界面方面最外层一个Box,里面个三个控件分别是:
第一层 画面 MediaPlayerLayout()
第二层 按钮、进度条等小组件 MediaControlLayout()
第三层 监听 KeyEventMediaControlKeyEvent()
@Composable
fun TvVideoPlayer(
player: Player,
controller: VideoPlayerController,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(
LocalVideoPlayerController provides controller
) {
Box(modifier = modifier.background(Color.Black)) {
MediaPlayerLayout(player, modifier = Modifier.matchParentSize())
MediaControlLayout(modifier = Modifier.matchParentSize())
MediaControlKeyEvent(modifier = Modifier.matchParentSize())
}
}
}
internal val LocalVideoPlayerController =
compositionLocalOf<VideoPlayerController> { error("VideoPlayerController is not initialized") }
使用 VideoPlayerController
去控制播放和获取当前播放状态:
interface VideoPlayerController {
val state: StateFlow<VideoPlayerState>
val isPlaying: Boolean
fun play()
fun pause()
fun playToggle()
fun reset()
fun seekTo(positionMs: Long)
fun seekForward()
fun seekRewind()
fun seekFinish()
fun showControl()
fun hideControl()
}
7.1 MediaPlayerLayout
播放器使用常规的 Exoplayer
,通过 AndroidView 去加载它;
@Composable
fun PlayerSurface(
modifier: Modifier = Modifier,
onPlayerViewAvailable: (PlayerView) -> Unit = {}
) {
AndroidView(
modifier = modifier,
factory = { context ->
PlayerView(context).apply {
useController = false // 关闭默认的控制界面
onPlayerViewAvailable(this)
}
}
)
}
基于 VideoPlayerController
类,再对 PlayerSurface
做个封装,在 onStart
、onStop
、onDestory
做些常规处理:
@Composable
fun MediaPlayerLayout(player: Player, modifier: Modifier = Modifier) {
val controller = LocalVideoPlayerController.current
val state by controller.state.collectAsState()
val lifecycle = LocalLifecycleOwner.current.lifecycle
PlayerSurface(modifier) { playerView ->
playerView.player = player
lifecycle.addObserver(object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onStart() {
playerView.keepScreenOn = true
playerView.onResume()
if (state.isPlaying) {
controller.play()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onStop() {
playerView.keepScreenOn = false
playerView.onPause()
controller.pause()
}
})
}
DisposableEffect(Unit) {
onDispose {
player.release()
}
}
}
7.2 MediaControlLayout
根据当前播放状态,显示 播放/暂停按钮、快进/快退按钮、进度条 等;
@Composable
fun MediaControlLayout(modifier: Modifier = Modifier) {
val controller = LocalVideoPlayerController.current
val state by controller.state.collectAsState()
val isSeeking by remember(state.seekDirection) {
mutableStateOf(state.seekDirection.isSeeking)
}
if (!state.controlsVisible && !isSeeking) {
return
}
val position = remember(state.currentPosition) { getDurationString(state.currentPosition) }
val duration = remember(state.duration) { getDurationString(state.duration) }
Box(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(4.dp)
) {
TimeTextBar(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
position = position,
duration = duration
)
SmallSeekBar(
modifier = Modifier
.fillMaxWidth(),
secondaryProgress = state.bufferedPosition,
progress = state.currentPosition,
max = state.duration,
)
}
if (!isSeeking) {
PlayToggleButton(
modifier = Modifier.align(Alignment.Center),
isPlaying = state.isPlaying,
playbackState = state.playbackState
)
}
}
}
7.3 MediaControlKeyEvent
定义个空白的 Box 并监听 onKeyEvent
,这里就不用考虑传给 FocusManager
了,直接消费掉按键;
@Composable
fun MediaControlKeyEvent(modifier: Modifier = Modifier) {
val controller = LocalVideoPlayerController.current
val state by controller.state.collectAsState()
val focusRequester = remember { FocusRequester() }
Box(
modifier = modifier
.onFocusDirection {
when (it) {
FocusDirection.In -> {
if (state.isPlaying) {
controller.pause()
controller.showControl()
} else {
controller.play()
controller.hideControl()
}
true
}
FocusDirection.Down -> {
if (state.controlsVisible) {
controller.hideControl()
} else {
controller.showControl()
}
true
}
FocusDirection.Left -> {
controller.seekRewind()
true
}
FocusDirection.Right -> {
controller.seekForward()
true
}
FocusDirection.Out -> {
if (state.controlsVisible) {
controller.hideControl()
true
} else false
}
else -> false
}
}
.focusRequester(focusRequester)
.focusTarget(),
) {
VideoSeekAnimation(
modifier = Modifier.matchParentSize(),
seekDirection = state.seekDirection,
)
}
SideEffect {
focusRequester.requestFocus()
}
}
8. Jetpack Compose 中使用 ViewModel
8.1 一般 ViewModel
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01")
val viewModel: FeedViewModel = viewModel()
8.2 Hilt Inject ViewModel
目前官方好像只提供了基于 navigation 的实现版本:
https://github.com/google/dagger/issues/2166
// implementation("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")
val viewModel: FeedViewModel = hiltViewModel()
8.3 Hilt AssistedInject ViewModel
逛 Github 的时候看到有大佬在 Jetpack Compose中 使用了这种方式,我觉得还是很不错的,对于函数式的 Jetpack Compose 来说,在创建 ViewModel 的时候传入参数是比较合适的;
class DetailViewModel @AssistedInject constructor(
@Assisted id: Long,
...
) : ViewModel() {
...
@dagger.assisted.AssistedFactory
interface AssistedFactory {
fun create(id: Long): DetailViewModel
}
}
缺点是用 AssistedInject
注入要写的代码会多一些,有时候使用像 produceState
这种方式会更简单,具体就看情况使用;
@Composable
fun DetailScreen(id: Long) {
val viewState by produceState(initialValue = DetailViewState.Empty) {
viewModel.loadState(id).collect {
value = it
}
}
...
}
如何注入可参考 https://gist.github.com/qdsfdhvh/0e5b1d04851354af1e6cda5b96582bb8
参考里面可以做一些下面的调整:
把
@IntoMap
改成@IntoSet
就可以不用配置@AssistedFactoryKey
;AssistedFactoryModule.kt
可以使用 KSP 去生成,项目中通过 FAssistedFactoryProcessor 生成代码,大致如下:
@InstallIn(SingletonComponent::class)
@Module
public interface DetailViewModelFactoryModule {
@Binds
@IntoMap
@AssistedFactoryQualifier
@AssistedFactoryKey(DetailViewModel.AssistedFactory::class)
public fun bindDetailViewModelFactory(factory: DetailViewModel.AssistedFactory): Any
}
我跑了下 --dry-run
发现好像 kapt task 有依赖 ksp task,这样用 ksp 生成 hilt module 在 task 执行顺序上应该没问题,目前试下来也没遇到什么问题。
./gradlew app:kaptDebugKotlin --dry-run
// ....
// :app:kspDebugKotlin SKIPPED
// :app:kaptGenerateStubsDebugKotlin SKIPPED
// :app:kaptDebugKotlin SKIPPED
9. 其他
9.1 Jetpack Compose 制作图标
前段时间抄fundroid大佬的俄罗斯方块代码时,发现了一个很有趣的小技巧:编写一个 @Composable fun AppIcon() {...}
,通过预览功能右击 "copy image"保存图片,就可以简单制作一个App图标;对于像我这样不会ps的来说还是挺有用的。
9.2 查看 Icons
在使用Icons图标的时候,因为看不到预览挺麻烦的,在官方上找到了这个网站 https://fonts.google.com/icons,目前我是在这里搜索和预览的,不知道有没有更好的方式。
9.3 屏幕适配
在 Jetpack Compose 中提供了 .dp
、.sp
扩展,换算则是借助了 Density
这个类,在 Android 中这个类是这样创建的:
fun Density(context: Context): Density =
Density(
context.resources.displayMetrics.density,
context.resources.configuration.fontScale
)
可以使使用 AndroidAutoSize 这类库达到效果,但是为了项目能更 Compose 些,我自定义了下 Density:
fun autoSizeDensity(context: Context, designWidthInDp: Int): Density =
with(context.resources) {
val isVertical = configuration.orientation == Configuration.ORIENTATION_PORTRAIT
val scale = displayMetrics.run {
val sizeInDp = if (isVertical) widthPixels else heightPixels
sizeInDp.toFloat() / density / designWidthInDp
}
Density(
density = displayMetrics.density * scale,
fontScale = configuration.fontScale * scale
)
}
// 使用
setContent {
...
CompositionLocalProvider(
LocalDensity provides autoSizeDensity(this@AnimeTvActivity, 480)
) {
...
}
}
PS: 上面的方法只能适配 Compose,不支持 AndroidView。
9.4 取消点击波纹
Jetpack Compose 在点击时默认有波纹的,对 TV 来说并不需要, 所以项目自定义了 LocalIndication
, 并在 MaterialTheme
进行配置:
object NoRippleIndication : Indication {
private object NoIndicationInstance : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
}
}
@Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
return NoIndicationInstance
}
}
// 使用
setContent {
...
MaterialTheme {
CompositionLocalProvider(
LocalIndication provides NoRippleIndication
) {
...
}
}
}
9.5 @CollectCompose 注入小组件
我尝试在界面上加载一些小组件,如 fps 等;一开始我是放在app里的,后面就想着把它放入其他 module 里面通过注入的方式去加载它,主要想研究下这方面的可行性;
一开始我想着使用 ASM 去收集这些小组件的 @Composable 函数,好在巨佬给了建议,ASM入局太晚,彼时的 Compose 代码是比较复杂的,要实现并不容易, 对于还没写过ASM的我来说这条路≈不可能,及时止损没有误入歧途(怂了);
之后我还是用了老方法:使用 ksp 生成hilt代码来注入 Composable组件;
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class CollectCompose(
val qualifier: KClass<out Any>
)
interface CollectComposeOwner<in T> {
@Composable
fun Show(scope: T)
}
@Composable
fun <T> T.Show(owners: Collection<CollectComposeOwner<T>>) {
owners.forEach { owner -> owner.Show(this) }
}
@Composable 是 kcp 处理的,而 kapt 晚于 kcp,所以对 hilt 来说,@Composable (BoxScope) -> Unit
已经通过编译变成 Function3<BoxScope, Composer, Int, Unit>
,不便于收集了,因此需要借助 CollectComposeOwner
接口:
在 CollectComposeProcessor.kt
ksp 对 @CollectCompose
进行处理,生成的 hilt 代码大致如下:
@InstallIn(ActivityComponent::class)
@Module
object FpsScreenComponentModule {
@Provides
@IntoSet
@CollectScreenComponentQualifier
fun provideFpsScreenComponent() = object : CollectComposeOwner<BoxScope> {
@Composable
override fun Show(scope: BoxScope) {
scope.FpsScreenComponent()
}
}
}
使用处:
@CollectCompose(CollectScreenComponentQualifier::class)
@Composable
fun BoxScope.FpsScreenComponent() {
...
}
@AndroidEntryPoint
class AnimeTvActivity : ComponentActivity() {
@Inject
@CollectScreenComponentQualifier
lateinit var collectScreenComponents: Set<@JvmSuppressWildcards CollectComposeOwner<BoxScope>>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Box() {
AppScreen()
Show(collectScreenComponents)
}
}
}
}
除了在界面显示 fps,我也尝试以此实现 Compose Toast(只是尝试,不建议这么用):
object ToastUtils {
fun showToast(msg: String?) {
if (msg == null) return
channel.trySend(msg)
}
}
private val channel = Channel<String>(1)
@CollectCompose(CollectScreenComponentQualifier::class)
@Composable
fun BoxScope.ToastScreenComponent() {
var isShown by remember { mutableStateOf(false) }
var showMsg by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
channel.receiveAsFlow().collect {
showMsg = it
isShown = true
}
}
AnimatedVisibility(
visible = isShown,
modifier = Modifier
.padding(10.dp)
.padding(bottom = 50.dp)
.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
Text(
text = showMsg,
modifier = Modifier
.shadow(1.dp, CircleShape)
.background(MaterialTheme.colors.surface, CircleShape)
.padding(horizontal = 20.dp, vertical = 10.dp)
)
}
if (isShown) {
LaunchedEffect(isShown) {
delay(1500)
isShown = false
}
}
}
右上角加了一个按钮是想试这个square/radiography,很不错的一个库,输出当前界面的Tree,支持Compose,效果如下:
-- End --
欢迎关注 Jetpack Compose 中文手册项目
https://github.com/compose-museum/compose-library
推荐阅读
加好友进交流群,技术干货聊不停