Pierwsze kroki z interfejsem użytkownika opartym na Compose

Dodawanie zależności

Biblioteka Media3 zawiera moduł interfejsu użytkownika oparty na Jetpack Compose. Aby go użyć, dodaj tę zależność:

Kotlin

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

Groovy

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

Zdecydowanie zalecamy, aby najpierw tworzyć aplikację w Compose lub przejść z użycia widoków.

Pełna aplikacja demo Compose

Biblioteka media3-ui-compose nie zawiera gotowych komponentów kompozytowych (takich jak przyciski, wskaźniki, obrazy czy dialogi), ale możesz znaleźć aplikację demonstracyjną napisaną w pełni w Compose, która nie korzysta z rozwiązań zapewniających interoperacyjność, takich jak owijanie PlayerViewAndroidView. Aplikacja demonstracyjna korzysta z klas uchwytujących stan interfejsu z modułu media3-ui-compose i z biblioteki Compose Material3.

Zmienne stanów interfejsu

Aby lepiej zrozumieć, jak możesz korzystać z elastyczności komponentów stanu interfejsu użytkownika w porównaniu z komponentami składanymi, przeczytaj, jak Compose zarządza stanem.

Zmienne stanów przycisku

W przypadku niektórych stanów interfejsu zakładamy, że będą one najprawdopodobniej używane przez komponenty kompozytowe podobne do przycisków.

Region remember*State Typ
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle
PreviousButtonState rememberPreviousButtonState Stała
NextButtonState rememberNextButtonState Stała
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState Menu lub przełącznik N-Toggle

Przykład użycia 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),
    )
  }
}

Zwróć uwagę, że state nie zawiera informacji o motywie, np. ikony do odtwarzania lub wstrzymywania. Jego jedynym zadaniem jest przekształcenie Player w stan interfejsu użytkownika.

Następnie możesz dowolnie łączyć przyciski w układ:

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

Zmienne stanów wizualnych

PresentationState zawiera informacje o tym, kiedy wyjście wideo w PlayerSurface może być wyświetlane lub powinno być zastąpione przez element zastępczy interfejsu.

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))
  }

Tutaj możemy użyć zarówno presentationState.videoSizeDp, aby dopasować powierzchnię do żądanego formatu obrazu (więcej typów znajdziesz w dokumentacji ContentScale), jak i presentationState.coverSurface, aby określić, kiedy nie jest odpowiedni czas na wyświetlanie powierzchni. W takim przypadku możesz umieścić na powierzchni nieprzezroczystą przesłonę, która zniknie, gdy powierzchnia będzie gotowa.

Gdzie znajdują się przepływy danych?

Wielu deweloperów aplikacji na Androida zna obiekty Kotlina Flow, które służą do zbierania stale zmieniających się danych interfejsu. Możesz na przykład szukać Player.isPlaying przepływu, który możesz collect w sposób uwzględniający cykl życia. Możesz też użyć Player.eventsFlow, aby uzyskać Flow<Player.Events>, który możesz filter w sposób, który Ci odpowiada.

Korzystanie z przepływów w przypadku stanu interfejsu Player ma jednak pewne wady. Jednym z głównych problemów jest asynchroniczny charakter przesyłania danych. Chcemy zapewnić jak najmniejsze opóźnienie między Player.Event a jego wykorzystaniem po stronie interfejsu użytkownika, unikając wyświetlania elementów interfejsu, które są niezsynchronizowane z Player.

Inne kwestie:

  • Przebieg z wszystkimi Player.Events nie byłby zgodny z zasadami odpowiedzialności, ponieważ każdy konsument musiałby odfiltrowywać odpowiednie zdarzenia.
  • Aby utworzyć przepływ dla każdego elementu Player.Event, musisz połączyć je (za pomocą combine) dla każdego elementu interfejsu. Zdarzenie Player.Event jest mapowane jeden-do-wielu na zmianę elementu interfejsu. Wymaganie korzystania z funkcji combinemoże spowodować, że interfejs będzie działać w sposób niezgodny z prawem.

Tworzenie niestandardowych stanów interfejsu

Jeśli istniejące stany interfejsu nie spełniają Twoich potrzeb, możesz dodać niestandardowe stany interfejsu. Sprawdź kod źródłowy dotychczasowego stanu, aby skopiować wzór. Typowa klasa uchwytu stanu interfejsu użytkownika:

  1. Trwa Player.
  2. Subskrybuje Player za pomocą coroutines. Więcej informacji znajdziesz w artykule Player.listen.
  3. Reaguje na określone Player.Events, aktualizując swój stan wewnętrzny.
  4. Akceptuj polecenia biznesowe, które zostaną przekształcone w odpowiednie aktualizacje Player.
  5. Można je tworzyć w różnych miejscach w drzewie interfejsu użytkownika i zawsze będą one zachowywać spójny widok stanu odtwarzacza.
  6. Wyświetla pola Compose State, które mogą być używane przez kompozyt, aby dynamicznie reagować na zmiany.
  7. Zawiera funkcję remember*State, która zapamiętuje instancję między składaniami.

Co dzieje się za kulisami:

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)
      }
    }
}

Aby zareagować na własne Player.Events, możesz je złapać za pomocą Player.listen, czyli suspend fun, który pozwala wejść do świata współbieżnego i bez końca słuchać Player.Events. Implementacja Media3 różnych stanów interfejsu użytkownika pomaga programiście końcowemu uniknąć konieczności zapoznania się z informacjami na temat Player.Events.