Iniziare a utilizzare l'interfaccia utente basata su Compose

Aggiungi la dipendenza

La libreria Media3 include un modulo UI basato su Jetpack Compose. Per utilizzarlo, aggiungi la seguente dipendenza:

Kotlin

implementation("androidx.media3:media3-ui-compose:1.6.0")

Groovy

implementation "androidx.media3:media3-ui-compose:1.6.0"

Ti consigliamo vivamente di sviluppare la tua app in un modo che dia la priorità a Compose o di eseguire la migrazione dall'utilizzo di View.

App demo completamente composta

Sebbene la libreria media3-ui-compose non includa Composable pronti all'uso (come pulsanti, indicatori, immagini o finestre di dialogo), puoi trovare un'app di demo scritta interamente in Compose che evita qualsiasi soluzione di interoperabilità come il wrapping di PlayerView in AndroidView. L'app di demo utilizza le classi di contenitore dello stato dell'interfaccia utente del modulo media3-ui-compose e la libreria Compose Material3.

Contenitori di stato dell'interfaccia utente

Per comprendere meglio come utilizzare la flessibilità dei detentori dello stato dell'interfaccia utente rispetto ai composabili, scopri come Compose gestisce lo stato.

Contenitori di stato dei pulsanti

Per alcuni stati dell'interfaccia utente, assumiamo che molto probabilmente verranno utilizzati da composabili simili a pulsanti.

Stato remember*State Digitazione
PlayPauseButtonState rememberPlayPauseButtonState 2 pulsanti di attivazione/disattivazione
PreviousButtonState rememberPreviousButtonState Costante
NextButtonState rememberNextButtonState Costante
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2 pulsanti di attivazione/disattivazione
PlaybackSpeedState rememberPlaybackSpeedState Menu o N-Toggle

Esempio di utilizzo di PlayPauseButtonState:

@Composable
fun PlayPauseButton(player: Player, modifier: Modifier = Modifier) {
  val state = rememberPlayPauseButtonState(player)

  IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
    Icon(
      imageVector = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
      contentDescription =
        if (state.showPlay) stringResource(R.string.playpause_button_play)
        else stringResource(R.string.playpause_button_pause),
    )
  }
}

Tieni presente che state non contiene informazioni sul tema, ad esempio l'icona da utilizzare per riprodurre o mettere in pausa. La sua unica responsabilità è trasformare Player nello stato dell'interfaccia utente.

Puoi quindi combinare i pulsanti nel layout che preferisci:

Row(
  modifier = modifier.fillMaxWidth(),
  horizontalArrangement = Arrangement.SpaceEvenly,
  verticalAlignment = Alignment.CenterVertically,
) {
  PreviousButton(player)
  PlayPauseButton(player)
  NextButton(player)
}

Contenitori di stato dell'output visivo

PresentationState contiene le informazioni su quando l'output video in un PlayerSurface può essere mostrato o deve essere coperto da un elemento dell'interfaccia utente segnaposto.

val presentationState = rememberPresentationState(player)
val scaledModifier = Modifier.resize(ContentScale.Fit, presentationState.videoSizeDp)

Box(modifier) {
  // Always leave PlayerSurface to be part of the Compose tree because it will be initialised in
  // the process. If this composable is guarded by some condition, it might never become visible
  // because the Player won't emit the relevant event, e.g. the first frame being ready.
  PlayerSurface(
    player = player,
    surfaceType = SURFACE_TYPE_SURFACE_VIEW,
    modifier = scaledModifier,
  )

  if (presentationState.coverSurface) {
    // Cover the surface that is being prepared with a shutter
    Box(Modifier.background(Color.Black))
  }

Qui possiamo utilizzare sia presentationState.videoSizeDp per ridimensionare la superficie in base alle proporzioni desiderate (consulta la documentazione di ContentScale per altri tipi) sia presentationState.coverSurface per sapere quando non è il momento giusto per mostrare la superficie. In questo caso, puoi posizionare un'otturatrice opaca sulla superficie, che scompare quando la superficie è pronta.

Dove si trovano i flussi?

Molti sviluppatori Android sono familiarizzati con l'utilizzo degli oggetti Kotlin Flow per raccogliere dati UI in continua evoluzione. Ad esempio, potresti essere alla ricerca di un flusso Player.isPlaying che puoi collect in modo consapevole del ciclo di vita. Oppure qualcosa come Player.eventsFlow per fornirti un Flow<Player.Events> che puoi filter come preferisci.

Tuttavia, l'utilizzo di flussi per lo stato dell'interfaccia utente Player presenta alcuni svantaggi. Uno dei principali preoccupazioni è la natura asincrona del trasferimento dei dati. Vogliamo garantire la minima latenza possibile tra un Player.Event e il suo consumo lato UI, evitando di mostrare elementi dell'interfaccia utente non sincronizzati con il Player.Event.Player

Altri aspetti:

  • Un flusso con tutti i Player.Events non rispetterebbe il principio di responsabilità singola, poiché ogni consumatore dovrebbe filtrare gli eventi pertinenti.
  • Per creare un flusso per ogni Player.Event, dovrai combinarli (con combine) per ogni elemento dell'interfaccia utente. Esiste una mappatura many-to-many tra un Player.Event e una modifica dell'elemento dell'interfaccia utente. L'utilizzo di combine potrebbe portare la UI a stati potenzialmente illegali.

Creare stati dell'interfaccia utente personalizzati

Puoi aggiungere stati dell'interfaccia utente personalizzati se quelli esistenti non soddisfano le tue esigenze. Controlla il codice sorgente dello stato esistente per copiare il pattern. Una tipica classe di detentore dello stato dell'interfaccia utente esegue le seguenti operazioni:

  1. Riceve un Player.
  2. Si iscrive a Player utilizzando le coroutine. Per maggiori dettagli, consulta Player.listen.
  3. Risponde a determinati Player.Events aggiornando il proprio stato interno.
  4. Accetta i comandi di logica aziendale che verranno trasformati in un aggiornamento Player appropriato.
  5. Può essere creato in più punti dell'albero dell'interfaccia utente e manterrà sempre una visione coerente dello stato del player.
  6. Espone i campi Compose State che possono essere utilizzati da un composable per rispondere dinamicamente alle modifiche.
  7. È dotato di una funzione remember*State per ricordare l'istanza tra le composizioni.

Cosa succede dietro le quinte:

class SomeButtonState(private val player: Player) {
  var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_ACTION_A))
    private set

  var someField by mutableStateOf(someFieldDefault)
    private set

  fun onClick() {
    player.actionA()
  }

  suspend fun observe() =
    player.listen { events ->
      if (
        events.containsAny(
          Player.EVENT_B_CHANGED,
          Player.EVENT_C_CHANGED,
          Player.EVENT_AVAILABLE_COMMANDS_CHANGED,
        )
      ) {
        someField = this.someField
        isEnabled = this.isCommandAvailable(Player.COMMAND_ACTION_A)
      }
    }
}

Per reagire al tuo Player.Events, puoi catturarlo utilizzando Player.listen, che è un suspend fun che ti consente di entrare nel mondo delle coroutine e di ascoltare Player.Events indefinitamente. L'implementazione di Media3 di vari stati della UI aiuta lo sviluppatore finale a non preoccuparsi di dover conoscerePlayer.Events.