Pierwsze kroki z interfejsem użytkownika opartym na Compose

Dodawanie zależności

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

Kotlin

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

Groovy

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

Zdecydowanie zalecamy tworzenie aplikacji w pierwszej kolejności w Compose lub migrację z używania widoków.

Pełna aplikacja demonstracyjna Compose

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

Zmienne stanów interfejsu

Aby lepiej zrozumieć, jak wykorzystać elastyczność elementów przechowujących stan interfejsu w porównaniu z funkcjami kompozycyjnymi, przeczytaj artykuł o tym, jak Compose zarządza stanem.

Zmienne stanu przycisku

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

Województwo 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 N-Toggle

Przykładowe użycie elementu 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.

Możesz następnie dopasować przyciski do wybranego układu:

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

Zmienne stanu danych wyjściowych wizualnych

PresentationState zawiera informacje o tym, kiedy można wyświetlić dane wyjściowe wideo w PlayerSurface lub kiedy powinny być one zastąpione elementem interfejsu użytkownika.

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

Możemy tu użyć zarówno presentationState.videoSizeDp, aby dostosować powierzchnię do żądanego współczynnika proporcji (więcej typów znajdziesz w dokumentacji ContentScale), jak i presentationState.coverSurface, aby wiedzieć, kiedy nie jest odpowiedni moment na wyświetlenie powierzchni. W takim przypadku możesz umieścić nieprzezroczystą zasłonę na powierzchni, która zniknie, gdy będzie gotowa.

Gdzie są przepływy?

Wielu deweloperów Androida wie, jak używać obiektów Kotlin Flow do zbierania stale zmieniających się danych interfejsu. Możesz na przykład szukać Player.isPlaying, które możesz collect w sposób uwzględniający cykl życia. lub coś w rodzaju Player.eventsFlow, aby zapewnić Ci Flow<Player.Events> które możesz filter w dowolny sposób.

Używanie przepływów do zarządzania stanem 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, unikając wyświetlania elementów interfejsu, które nie są zsynchronizowane z Player.

Inne kwestie:

  • Przepływ ze wszystkimi Player.Events nie byłby zgodny z zasadą pojedynczej odpowiedzialności, ponieważ każdy odbiorca musiałby odfiltrowywać odpowiednie zdarzenia.
  • Utworzenie przepływu dla każdego elementu Player.Event będzie wymagać połączenia ich (za pomocą elementu combine) w przypadku każdego elementu interfejsu. Między obiektem Player.Event a zmianą elementu interfejsu istnieje mapowanie wiele do wielu. Używanie combine może spowodować, że interfejs użytkownika znajdzie się w stanie potencjalnie niezgodnym z prawem.

Tworzenie niestandardowych stanów interfejsu

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

  1. Przyjmuje wartość Player.
  2. Subskrybuje Player za pomocą korutyn. Więcej informacji znajdziesz w Player.listen.
  3. Odpowiada na określone Player.Events, aktualizując swój stan wewnętrzny.
  4. akceptować polecenia logiki biznesowej, które zostaną przekształcone w odpowiedniąPlayer aktualizację;
  5. Można go utworzyć w wielu miejscach w drzewie interfejsu i zawsze będzie on wyświetlać spójny widok stanu odtwarzacza.
  6. Udostępnia pola State Compose, które mogą być używane przez komponent kompozycyjny, aby dynamicznie reagować na zmiany.
  7. Zawiera funkcję remember*State, która zapamiętuje instancję między kompozycjami.

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 reagować na własne Player.Events, możesz je przechwytywać za pomocą Player.listen, czyli suspend fun, które pozwala wejść do świata korutyn i bezterminowo nasłuchiwać Player.Events. Implementacja różnych stanów interfejsu w Media3 pomaga programistom nie przejmować się Player.Events.