תחילת העבודה עם ממשק משתמש שמבוסס על Compose

הוספת יחסי התלות

ספריית Media3 כוללת מודול ממשק משתמש שמבוסס על Jetpack Compose. כדי להשתמש בו, מוסיפים את יחסי התלות הבאים:

Kotlin

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

מגניב

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

מומלץ מאוד לפתח את האפליקציה בגישה של Compose-first או לעבור משימוש ב-Views.

אפליקציית הדגמה מלאה של Compose

ספריית media3-ui-compose לא כוללת רכיבים שאפשר להשתמש בהם ישר (Composables) כמו לחצנים, אינדיקטורים, תמונות או תיבות דו-שיח, אבל אפשר למצוא אפליקציית הדגמה שנכתבה במלואה ב-Compose בלי להשתמש בפתרונות של יכולת פעולה הדדית כמו עטיפת PlayerView ב-AndroidView. אפליקציית ההדגמה משתמשת במחזיקי מצב של ממשק המשתמש ממודול media3-ui-compose, ומתבססת על ספריית Compose Material3.

מאחסני מצבים לממשקי משתמש

כדי להבין טוב יותר איך אפשר להשתמש בגמישות של מחזיקי מצב בממשק המשתמש לעומת פונקציות Composable, כדאי לקרוא על האופן שבו Compose מנהל את המצב.

מאחסני מצבים של לחצנים

במצבי ממשק משתמש מסוימים, אנחנו מניחים שהם ישמשו ככל הנראה כרכיבי Composables דמויי לחצנים.

מדינה remember*State סוג
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle
PreviousButtonState rememberPreviousButtonState קבוע
NextButtonState rememberNextButtonState קבוע
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState תפריט או N-Toggle

דוגמה לשימוש ב-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),
    )
  }
}

שימו לב שלרכיב state אין מידע על עיצוב, כמו הסמל שמשמש להפעלה או להשהיה. האחריות היחידה שלו היא להפוך את Player למצב ממשק משתמש.

אחר כך תוכלו לשלב בין הלחצנים בפריסה לפי ההעדפה שלכם:

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

מאחסני מצבים של פלט חזותי

PresentationState מכיל מידע לגבי המקרים שבהם אפשר להציג את פלט הווידאו ב-PlayerSurface או שצריך להסתיר אותו באמצעות רכיב placeholder בממשק המשתמש.

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

כאן אפשר להשתמש גם ב-presentationState.videoSizeDp כדי לשנות את גודל הרכיב בהתאם ליחס הגובה-רוחב הרצוי (מידע נוסף על סוגים נוספים זמין במסמכי ContentScale) וגם ב-presentationState.coverSurface כדי לדעת מתי התזמון לא מתאים להצגת הרכיב. במקרה כזה, אפשר למקם תריס אטום מעל המשטח, והוא ייעלם כשהמשטח יהיה מוכן.

איפה נמצאים ה-Flows?

מפתחי Android רבים מכירים את השימוש באובייקטים של Kotlin Flow כדי לאסוף נתונים משתנים של ממשק המשתמש. לדוגמה, יכול להיות שאתם מחפשים Player.isPlaying flow שאפשר collect באופן שמודע למחזור החיים. או משהו כמו Player.eventsFlow כדי לספק לך Flow<Player.Events> שתוכל filter איך שתרצה.

עם זאת, יש כמה חסרונות לשימוש בתהליכים להצגת מצב ממשק המשתמש Player. אחת הבעיות העיקריות היא האופי האסינכרוני של העברת הנתונים. אנחנו רוצים לוודא שיהיה כמה שפחות זמן אחזור בין Player.Event לבין הצריכה שלו בצד ממשק המשתמש, כדי שלא יוצגו רכיבי ממשק משתמש שלא מסונכרנים עם Player.

נקודות נוספות:

  • זרימה עם כל Player.Events לא תעמוד בעקרון האחריות היחידה, וכל צרכן יצטרך לסנן את האירועים הרלוונטיים.
  • כדי ליצור רצף לכל Player.Event, צריך לשלב אותם (עם combine) לכל רכיב בממשק המשתמש. יש מיפוי של הרבה לא הרבה בין Player.Event לבין שינוי ברכיב ממשק המשתמש. השימוש ב-combine עלול להוביל למצבים לא חוקיים בממשק המשתמש.

יצירת מצבי ממשק משתמש בהתאמה אישית

אם המצבים הקיימים של ממשק המשתמש לא מתאימים לצרכים שלכם, אתם יכולים להוסיף מצבים מותאמים אישית. כדאי לבדוק את קוד המקור של המצב הקיים כדי להעתיק את התבנית. בדרך כלל, מחזיק מצב של ממשק משתמש:

  1. הפונקציה מקבלת Player.
  2. הרשמה למינוי אל Player באמצעות קורוטינות. פרטים נוספים זמינים במאמר Player.listen.
  3. מגיבה לPlayer.Events מסוים על ידי עדכון המצב הפנימי שלה.
  4. מקבלים פקודות של לוגיקה עסקית שיומרו לעדכון Player מתאים.
  5. אפשר ליצור אותו בכמה מקומות בעץ ממשק המשתמש, ותמיד תהיה לו תצוגה עקבית של מצב הנגן.
  6. חשיפת שדות של Compose State שאפשר להשתמש בהם ב-Composable כדי להגיב באופן דינמי לשינויים.
  7. כולל פונקציה remember*State לזכירת המופע בין קומפוזיציות.

מה קורה מאחורי הקלעים:

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

כדי להגיב ל-Player.Events שלכם, אתם יכולים להשתמש ב-Player.listen, שהוא suspend fun שמאפשר לכם להיכנס לעולם של קורוטינות ולהאזין ל-Player.Events ללא הגבלה. הטמעה של Media3 במצבי ממשק משתמש שונים עוזרת למפתחים לא לדאוג לגבי לימוד של Player.Events.