[advanced Android] write a simple Android TV application with Jetpack Compose

Posted by OopyBoo on Sun, 20 Feb 2022 17:34:57 +0100

preface

I'm curious about how Jetpack Compose, as a new interface toolkit, will be used on the TV side. After all, the existing leanback library is not very easy to use, and it's very difficult to customize. As a result, most individual open-source TV projects look the same;
With the release of the official version, I want to study Jetpack Compose and develop a simple TV application before being swept away by the big wave;
At the same time, listen to the suggestions of the giant - forming the habit of writing articles will improve your skills. Try to write this article and summarize your experience.

preview

Project address: compose-anime-tv
If you like, welcome to order a Star.


1. Side effects

In the first place, I think the side effects are really important to Jetpack Compose. I don't need to know very well, but I must know what it is;
Jetpack Compose has two tag declarative and functional expressions, especially functional expressions, which we mainly need to adapt to@ The Composable function will run repeatedly according to the UI refresh, but some behaviors, such as initialization and binding, or some defined variables, cannot be reinitialized, repeatedly bound or regenerated with the UI refresh;
In order to make them run at the right time, we need to use the side Effect effect;
Here I recommend this article by the fundroid boss, which is very well written and explains the name of (side effects);
Jetpack Compose Side Effect: how to deal with side effects @ fundroid

2. Keyevent

In order to use the existing Modifier extension as much as possible, I first Official documents After checking the KeyEvent, you can see the following code:

Box(
    Modifier
        .onPreviewKeyEvent { keyEvent1 -> false }
        // .onKeyEvent { keyEvent5 -> false }
        .onKeyEvent { keyEvent4 -> false }
) {
    Box(
        Modifier
            .onPreviewKeyEvent { keyEvent2 -> false }
            .onKeyEvent { keyEvent3 -> false }
            .focusable()
    )
}

I like the above code very much. There are only two extensions, onKeyEvent() and onPreviewKeyEvent(), which can basically meet the development needs.

  1. Focus processing
    Official sample: androidx.compose.ui.samples.FocusableSample

3.1 Modifier extension

Mainly the following:

- Modifier.focusTarget(),Modifier.focusable()
- Modifier.onFocusEvent(),Modifier.onFocusChange()
- Modifier.focusRequester(),Modifier.focusOrder()

3.1.1 focusable() and focusTarget()

focusable() is a further encapsulation of focusTarget(). focusTarget() must be configured to obtain focus. onFocusChange() and onKeyEvent() are normally used;
The official suggestion is to use focusable() instead of focusTarget(), but I have encountered the following error in use. In addition, I don't need the encapsulated function very much, so I mainly use focusTarget() in the project;
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() was once called focusModifier(). I think the old name can better reflect why relevant methods can only be used after configuration, so I mention it here.

3.1.2 onFocusChange() and onFocusEvent()

onFocusEvent() is used to call back the focus state FocusState;

interface FocusState {
    val isFocused: Boolean
    val hasFocus: Boolean
    val isCaptured: Boolean
}

onFocusChange() encapsulates onFocusEvent(), and only the change callback FocusState, similar to flow distinctUntilChanged;
In general, onFocusChange() is used more often;

3.1.3 focusOrder() and focusRequester()

focusRequester() is used to configure the FocusRequester class for the control:

class FocusRequester {
    fun requestFocus()
    fun captureFocus(): Boolean
    fun freeFocus(): Boolean
}

FocusRequester.requestFocus() is the only way to get focus for the control;
captureFocus() and freeFocus() are locking and releasing focus respectively;
focusOrder() is used to determine the next control to get focus:

@Composable
fun FocusOrderSample() {
  val (item1, item2, item3, item4) = remember { FocusRequester.createRefs() }
  Box(
    Modifier
      .focusOrder(item1) {
        next = item2
        right = item2
        down = item3
        previous = item4
      }
      .focusable()
  )
  ...
}

In order to facilitate the use of focusOrder(), the official has added the following extension. Therefore, I have been lazy in the project and used focusOrder() to configure FocusRequester;

fun Modifier.focusOrder(focusRequester: FocusRequester): Modifier = focusRequester(focusRequester)

To simplify, the Modifier extensions that are often used are reduced to three major pieces:
focusTarget(),focusOrder(),onFocusChange()

3.2 FocusManager

interface FocusManager {
    fun clearFocus(force: Boolean)
    fun moveFocus(focusDirection: FocusDirection): Boolean
}

Via localfocusmanager Current is obtained. The implementation class FocusManagerImpl is private. At the same time, many internal variables are private. It is not convenient to customize FocusManager, and there are limited things you can do.

4. Key & focus transfer in jetpack compose

Enter AndroidComposeView, start with dispatchKeyEvent(), and preview the implementation:
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

)

Copy code
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)

}
Copy code
The above code, combined with the use example of the official KeyEvent, can judge that:
Jetpack Compose will first give the KeyEvent to the controls configured with onKeyEvent() on the Focus chain for consumption. If there is no control consumption, the default onKeyEvent() will be used, which is about equal to focusmanager moveFocus(focusDirection);
Let's take a look at how focusManager handles it:
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
}

}
Copy code
nextFocusRequester is the next target configured through focusOrder. If the returned is not focusrequester Default, directly requestFocus();
Otherwise, use focusmodifier focusNode. Focussearch() find focus:
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
}

}
Copy code
The findActiveFocusNode() method mainly determines the current focus and finds the next target based on the current focus;
oneDimensionalFocusSearch() and twoDimensionalFocusSearch() are both looking for the next target of child. I think the delivery scheme is the opposite, so I haven't studied these two methods too much;
findParentFocusNode() transfers the focus to the parent, which I often use. I checked the reference of this method. At present, it seems that it can only be through focusmanager Movefocus (focusdirection. Out) to trigger;
Make a summary:

Based on focusOrder(), it is determined that the next goal is the most direct and stable, and will not follow the more complex judgment behind. If it is convenient for the upper layer to configure, try to configure it as much as possible;

Although I like onKeyEvent() very much, onKeyEvent() is only suitable for use at both ends of the Focus chain at first. Otherwise, it is likely that I have insufficient judgment and put the original intention of focusmanager The consumption behavior of movefocus () is robbed;

You can use focusmanager Movefocus (focusdirection. Out) transfers the current focus to the parent.

  1. Focus transmission practice
    My expected delivery plan is roughly as follows:

Each component handles the focus respectively, and the focus is gradually transferred from the outermost layer; When moving the focus, the current component is passed to the parent component for processing without consumption.

For example, first customize two components Box1 and 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) {
...
}
Copy code

In other cases, change Box1 to a 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()
    }
  }
}

}
}
Copy code

There seems to be no problem, but in fact, the jump to the right is not based on the configuration in AppScreen. Configure focusorder (focus1) {left = focus2} for Box1, and press the left button to find focus2;
Here, you need to manually transfer the focus to the parent, and trigger focusmanager during key transfer with onKeyEvent() Movefocus (focusdirection. Out) returns the focus to the parent and returns false to continue the key transmission;
...
val focusManager = LocalFocusManager.current
LazyColumn(
modifier = modifier

// .onFocusChanged { isParentFocused = it.isFocused }
.onKeyEvent {
  when (it) {
    Key.DirectionRight,
    Key.DirectionLeft -> {
      focusManager.moveFocus(FocusDirection.Out)
    }
  }
  false
}
// .focusTarget(),

) {
...
}
Copy code
The Focus transfer scheme I used in the project is basically like this. At present, I can only deal with some relatively simple scenarios. Due to the behavior of returning Focus to parent, a single component is not suitable for the transfer of two layers of Focus, so I need to disassemble one more layer into components. Fortunately, the cost of writing one component in Jetpack Compose is very low.

  1. List scrolling
    Although the focus transfer mode is roughly determined, the list also needs to scroll when the focus moves;
    Through the official documents, the relevant codes are quickly found:
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

LazyColumn(state = listState) {

// ...

}

ScrollToTopButton(

onClick = {
    coroutineScope.launch {
        // Animate scroll to the first item
        listState.animateScrollToItem(index = 0)
    }
}

)
Copy code
The list can be scrolled with LazyListState. The relevant methods are as follows:
listState.scrollBy(value)
listState.scrollToItem(index, offset)
listState.animateScrollBy(value, animationSpec)
listState.animateScrollToItem(index, offset)
Copy code
From the perspective of usage alone, animateScrollToItem() is more suitable. Add relevant configurations to the Box1 above, and trigger scrolling when focusIndex changes:
val listState = rememberLazyListState()
...

LazyColumn(
state = listState
...
) {
...
}

LaunchedEffect(focusIndex) {
listState.animateScrollToItem(focusIndex)
}
Copy code

You can see that the scrolling effect of animateScrollToItem() is not satisfactory, so we need to calculate the scrolling distance ourselves and use animateScrollBy() to scroll;
I basically copied SampleComposeApp for the implementation of this aspect:
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

// The main purpose of adding some distance here is to draw the next target control, otherwise it will not be found in 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)
}
Copy code
Then modify the scroll code in Box1 to complete:
listState.animateScrollToItem(focusIndex, VerticalScrollBehaviour)
Copy code

  1. player
    I basically refer to the ComposeVideoPlayer. Its structure design is very good. I only replace the touch part with the key;
    Roughly as follows, the outermost Box of the interface has three controls:

First layer picture MediaPlayerLayout()
Second layer buttons, progress bars and other small components MediaControlLayout()
Layer 3 monitors 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") }
Copy code
Use VideoPlayerController to control playback and obtain the current playback status:
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()
}
Copy code
8.1 MediaPlayerLayout
The player uses conventional Exoplayer and loads it through AndroidView;
@Composable
fun PlayerSurface(
modifier: Modifier = Modifier,
onPlayerViewAvailable: (PlayerView) -> Unit = {}
) {
AndroidView(

modifier = modifier,
factory = { context ->
  PlayerView(context).apply {
    useController = false // Close the default control interface
    onPlayerViewAvailable(this)
  }
}

)
}
Copy code
Based on the VideoPlayerController class, encapsulate the PlayerSurface and do some routine processing in onStart, onStop and 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()
}

}
}
Copy code
8.2 MediaControlLayout
Display the play / pause button, fast forward / rewind button, progress bar, etc. according to the current playback status;
@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
  )
}

}
}
Copy code
8.3 MediaControlKeyEvent
Define a blank Box and listen to onKeyEvent. Here, you don't need to consider passing it to FocusManager and directly consume the keys;
@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()

}
}
Copy code

  1. Use ViewModel in Jetpack Compose
    9.1 general ViewModel
    // implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01")
    val viewModel: FeedViewModel = viewModel()
    Copy code
    9.2 Hilt Inject ViewModel
    At present, it seems that the official only provides the implementation version based on navigation:

    Dagger/Hilt ViewModel Injection (with compose and navigation-compose)

    // implementation("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")
    val viewModel: FeedViewModel = hiltViewModel()
    Copy code
    9.3 Hilt AssistedInject ViewModel
    When visiting Github, I found that some big guys used this method in jetpack composition. For functional jetpack composition, it is more appropriate to pass in parameters when creating ViewModel;
    class DetailViewModel @AssistedInject constructor(
    @Assisted id: Long,
    ...
    ) : ViewModel() {
    ...

    @dagger.assisted.AssistedFactory
    interface AssistedFactory {
    fun create(id: Long): DetailViewModel
    }
    }
    Copy code
    The disadvantage is that there will be more code to be written by injecting AssistedInject. Sometimes it is easier to use a method like produceState, depending on the situation;
    @Composable
    fun DetailScreen(id: Long) {
    val viewState by produceState(initialValue = DetailViewState.Empty) {
    viewModel.loadState(id).collect {

     value = it

    }
    }
    ...
    }
    Copy code
    How to inject: AssistedInject viewModel with Jetpack Compose
    The following adjustments can be made in the reference:

Change @ IntoMap to @ IntoSet to avoid configuring @ AssistedFactoryKey;

AssistedFactoryModule.kt can be generated using ksp. I write AssistedFactoryProcessor in this way. The generated hit code is roughly as follows:
@InstallIn(SingletonComponent::class)
@Module
public interface DetailViewModelFactoryModule {
@Binds
@IntoMap
@AssistedFactoryQualifier
@AssistedFactoryKey(DetailViewModel.AssistedFactory::class)
public fun bindDetailViewModelFactory(factory: DetailViewModel.AssistedFactory): Any
}
Copy code

In addition, I ran -- dry run seems that kapt task depends on ksp task, so there should be no problem in the task execution sequence of generating hit module with ksp. At present, I haven't encountered any problems in trying.
./gradlew app:kaptDebugKotlin --dry-run

// ....
// :app:kspDebugKotlin SKIPPED
// :app:kaptGenerateStubsDebugKotlin SKIPPED
// :app:kaptDebugKotlin SKIPPED
Copy code
You can also collect AssistedFactory by using the scheme of the elder Tlaster in the TwidereProject.
other

  1. Making icons with Jetpack Compose
    When copying the Tetris code of the fundroid boss some time ago, I found a very interesting trick:

Write a @ Composable fun AppIcon() {...}, Right click "copy image" to save the picture through the preview function, and you can simply make an App icon; It's still very useful for people like me who can't ps.

  1. View Icons
    When using Icons, it's troublesome not to see the preview. I found this website Google Fonts on the official website. At present, I search and preview here. I don't know if there is a better way.
  2. Screen adaptation
    Provided in Jetpack Compose dp,. sp extension, conversion is based on the Density class. In Android, this class is created as follows:
    fun Density(context: Context): Density =
    Density(
    context.resources.displayMetrics.density,
    context.resources.configuration.fontScale
    )
    Copy code
    It can be seen that the effect can be achieved by directly using libraries such as AndroidAutoSize. However, in order to make the project more composable, I have customized the following 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
    )
    }

//Use
setContent {
...
CompositionLocalProvider(

LocalDensity provides autoSizeDensity(this@AnimeTvActivity, 480)

) {

...

}
}
Copy code
PS: the above method can only adapt to Compose and does not support AndroidView.

  1. Cancel click ripple
    Jetpack Compose has ripple by default when clicking, which is not required for TV;
    At first I referred to stackoverflow COM / A / 66839858 /... Processed:
    @SuppressLint("UnnecessaryComposedModifier")
    fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
    clickable(
    indication = null,
    interactionSource = remember { MutableInteractionSource() },
    onClick = onClick
    )
    }
    Copy code
    But it's too troublesome to configure every click, so I still customized the LocalIndication:
    object NoRippleIndication : Indication {
    private object NoIndicationInstance : IndicationInstance {
    override fun ContentDrawScope.drawIndication() {
    drawContent()
    }
    }

    @Composable
    override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
    return NoIndicationInstance
    }
    }

//Use
setContent {
...
MaterialTheme {

CompositionLocalProvider(
  LocalIndication provides NoRippleIndication
) {
  ...
}

}
}
Copy code
Note that MaterialTheme will configure LocalIndication, so put it in MaterialTheme and go to 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
) {
    ...
}

}
Copy code

  1. Injection widget

I try to load some small components on the interface, such as fps; At the beginning, I put it in the app. Later, I wanted to put it into other module s and load it by injection. I mainly wanted to study the feasibility of this aspect;
At the beginning, I thought about using ASM to collect @ Composable functions of these small components. Fortunately, the giant gave advice. ASM entered the game too late. At that time, the Compose code was relatively complex and it was not easy to implement. For me who hadn't written ASM, this path was ≈ impossible. Stop loss in time and didn't go astray (counselled);
After that, I still used the old method: using ksp to generate hit code to inject Composable components;
@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) }
}
Copy code
I didn't want to write the CollectComposeOwner interface, but @ Composable is processed by kcp, and kapt is later than kcp. Therefore, for hilt, @ Composable (boxscope) - > unit has been compiled into function3 < boxscope, composer, int, unit >, which is not convenient for collection;
ksp collection @ CollectCompose I wrote this: CollectComposeProcessor. The generated hit code is roughly as follows:
@InstallIn(ActivityComponent::class)
@Module
object FpsScreenComponentModule {
@Provides
@IntoSet
@CollectScreenComponentQualifier
fun provideFpsScreenComponent() = object : CollectComposeOwner<BoxScope> {

@Composable
override fun Show(scope: BoxScope) {
  scope.FpsScreenComponent()
}

}
}
Copy code
General usage:
@CollectCompose(CollectScreenComponentQualifier::class)
@Composable
fun BoxScope.FpsScreenComponent() {
...
}
Copy code
@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)
  }
}

}
}
Copy code
In addition to displaying fps on the interface, I also try to realize Compose Toast (just trying, not recommended):
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
}

}
}
Copy code
A button is added in the upper right corner to try this radio. It is a very good library. It outputs the Tree of the current interface and supports Compose. The effect is as follows:

reference resources
article

Jetpack Compose Museum
Jetpack Compose practice summary in Twidere X @ Tlaster
Display rich text @ laster in Jetpack Compose
Jetpack Compose Side Effect: how to deal with side effects @ fundroid
Focus in Jetpack Compose @Jamie Sanson
android-rethinking-package-structure @Joe Birch
Hilt practice | create application level CoroutineScope

project

TwidereX-Android @Tlaster
Dota-Info @Mitch Tabian
SampleComposeApp @Akila
ComposeVideoPlayer @Halil Ozercan
dpad-compose @Walter Berggren

Original text: Seiko

Topics: Android jetpack