前言
我很好奇Jetpack Compose作为一个新的界面工具包,在TV端应用体验会如何,毕竟现有的leanback库并不是很好用,而且自定义难度很大,导致大多集体开源的TV我的项目都长得差不多;
随着正式版的公布,我想在被大浪卷走之前致力一下,学习Jetpack Compose并开发一款简略的TV端利用;
同时听取巨佬的倡议-养成写文章的习惯对技能会有所晋升
,尝试写下这篇文章做些经验总结。
预览
我的项目地址:compose-anime-tv
喜爱的话,欢送点个Star。
1. 副作用(Effect)
放在第一个说次要是我感觉副作用对Jetpack Compose真的很重要,不须要很理解,但肯定要晓得这是啥;
Jetpack Compose两大标签申明式
与函数式
,尤其是函数式
是咱们次要须要适应的;@Composable
函数会依据UI刷新而反复运行,然而外面的一些如初始化、绑定等行为,或者是一些定义的变量,他们是不能够追随UI刷新而从新初始化、反复绑定或从新生成的;
为了能让它们在适合的工夫运行,就须要应用副作用Effect
;
这里举荐下fundroid大佬的这篇文章,写得十分好,连(副作用)的命名都有解释;
Jetpack Compose Side Effect:如何解决副作用 @fundroid
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()
两个扩大、而且根本能满足开发须要。
- 焦点解决(Focus)
官网sample:androidx.compose.ui.samples.FocusableSample
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()
;
kotlin.UninitializedPropertyAccessException: lateinit property relocationRequesterNode has not been initialized
at androidx.compose.ui.layout.RelocationRequesterModifier.getRelocationRequesterNode(RelocationRequesterModifier.kt:32) at androidx.compose.ui.layout.RelocationRequester.bringIntoView(RelocationRequester.kt:61) at androidx.compose.ui.layout.RelocationRequester.bringIntoView$default(RelocationRequester.kt:59) at androidx.compose.foundation.FocusableKt$focusable$2$4$1.invokeSuspend(Focusable.kt:108) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106) ...
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()
用于确定下一个获取焦点的控件:
@Composablefun 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. Jetpack Compose中的按键&焦点传递
进入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: ModifiedKeyInputNodefun 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
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,这个会是我比拟罕用的,我查了下这个办法的援用,目前如同只能通过focusManager.moveFocus(FocusDirection.Out)去触发;
做个小总结:
基于focusOrder()确定下一个指标是最间接、最稳固的,不会走前面那些较为简单的判断,下层不便配置的话尽量配置;
尽管我很喜爱onKeyEvent(),然而onKeyEvent()初步看来只适宜在Focus链的两端应用,不然很可能判断有余,把本来想让focusManager.moveFocus()生产的行为给抢走;
能够通过focusManager.moveFocus(FocusDirection.Out)把以后焦点传给parent。
- 焦点传递实际
我预期的传递计划大抵就是:
每个组件各自解决焦点,焦点从最外层逐渐传入;挪动焦点时,以后组件不生产就传给父组件解决。
以示例来说,先自定义两个组件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写一个组件老本很低。
- 列表滚动
焦点传递形式尽管大抵确定了,然而在焦点挪动时,列表也是须要跟着滚动的;
通过官网文档,很快就找到了相干代码:
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()来滚动;
这方面的实现我根本就抄了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.viewportStartOffsetval viewEnd = state.layoutInfo.viewportEndOffsetval viewSize = viewEnd - viewStartval itemStart = focusItem.offsetval itemEnd = focusItem.offset + focusItem.size// 这里加点间隔次要是为了让下一个指标控件绘制进去,不然在visibleItemsInfo会找不到val offSect = 80val 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)
复制代码
- 播放器
这块我根本都是参照了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()
}
复制代码
8.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 = playerlifecycle.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()}
}
}
复制代码
8.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 )}
}
}
复制代码
8.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()
}
}
复制代码
Jetpack Compose中应用ViewModel
9.1 个别 ViewModel
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01")
val viewModel: FeedViewModel = viewModel()
复制代码
9.2 Hilt Inject ViewModel
目前官网如同只提供了基于navigation的实现版本:Dagger/Hilt ViewModel Injection (with compose and navigation-compose)
// implementation("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")
val viewModel: FeedViewModel = hiltViewModel()
复制代码
9.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
}
}
...
}
复制代码
如何注入可参考:AssistedInject viewModel with Jetpack Compose
参考外面能够做一些上面的调整:
把@IntoMap改成@IntoSet就能够不必配置@AssistedFactoryKey;
AssistedFactoryModule.kt能够应用ksp去生成,我是这么写的AssistedFactoryProcessor,生成的hilt代码大抵如下:
@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
复制代码
也能够应用Tlaster大佬在TwidereProject中的计划收集AssistedFactory。
其余
- 应用Jetpack Compose制作图标
前段时间抄fundroid大佬的俄罗斯方块代码时,发现了一个很乏味的小技巧:
编写一个@Composable fun AppIcon() {...},通过预览性能右击"copy image"保留图片,就能够简略制作一个App图标;对于像我这样不会ps的来说还是挺有用的。
- 查看Icons
在应用Icons图标的时候,因为看不到预览挺麻烦的,在官网上找到了这个网站Google Fonts,目前我是在这里搜寻和预览的,不晓得有没有更好的形式。 屏幕适配
在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_PORTRAITval 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。
勾销点击波纹
Jetpack Compose在点击时默认有波纹的,对TV来说并不需要;
一开始我是参照stackoverflow.com/a/66839858/… 解决的:
@SuppressLint("UnnecessaryComposedModifier")
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
}
复制代码
然而每个点击都这么配置太麻烦了,所以我还是自定义了LocalIndication:
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) { ...}
}
}
复制代码
留神MaterialTheme会配置LocalIndication,所以要放在MaterialTheme外面去CompositionLocalProvider() {};
@Composable
fun MaterialTheme(
...
) {
...CompositionLocalProvider( LocalColors provides rememberedColors, LocalContentAlpha provides ContentAlpha.high, LocalIndication provides rippleIndication, LocalRippleTheme provides MaterialRippleTheme, LocalShapes provides shapes, LocalTextSelectionColors provides selectionColors, LocalTypography provides typography) { ...}
}
复制代码
- 注入小组件
我尝试在界面上加载一些小组件,如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) }
}
复制代码
我原本是不想写CollectComposeOwner接口的,然而@Composable是kcp解决的,而kapt晚于kcp,所以对hilt来说,@Composable (BoxScope) -> Unit曾经通过编译变成Function3<BoxScope, Composer, Int, Unit>,不便于收集了;
ksp收集@CollectCompose 我是这么写的:CollectComposeProcessor,生成的hilt代码大抵如下:
@InstallIn(ActivityComponent::class)
@Module
object FpsScreenComponentModule {
@Provides
@IntoSet
@CollectScreenComponentQualifier
fun provideFpsScreenComponent() = object : CollectComposeOwner<BoxScope> {
@Composableoverride 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) returnchannel.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}
}
}
复制代码
右上角加了一个按钮是想试这个radiography,很不错的一个库,输入以后界面的Tree,反对Compose,成果如下:
参考
文章
Jetpack Compose 博物馆
Jetpack Compose 在Twidere X中的实际总结 @Tlaster
Jetpack Compose 中显示富文本 @Tlaster
Jetpack Compose Side Effect:如何解决副作用 @fundroid
Focus in Jetpack Compose @Jamie Sanson
android-rethinking-package-structure @Joe Birch
Hilt 实战 | 创立利用级别 CoroutineScope
我的项目
TwidereX-Android @Tlaster
Dota-Info @Mitch Tabian
SampleComposeApp @Akila
ComposeVideoPlayer @Halil Ozercan
dpad-compose @Walter Berggren
原文:Seiko