Sospensione sulle viste - Esempio
24 gennaio 2020
Pubblicato da Chris Banes
Un esempio elaborato dall'app Tivi

Se vuoi consultare il primo post, puoi trovarlo qui:
Sospensione sulle viste
Prendiamo ciò che abbiamo appreso nel post precedente e applichiamolo a un caso di utilizzo di app nel mondo reale.
Il problema
Qui abbiamo la UI dei dettagli dei programmi TV dell'app di esempio Tivi. Oltre alle informazioni sui programmi, elenca le stagioni e gli episodi. Quando l'utente fa clic su uno degli episodi, i dettagli dell'episodio vengono visualizzati utilizzando un'animazione che espande l'elemento selezionato:L'app utilizza la libreria InboxRecyclerView per gestire l'animazione di espansione precedente:
fun onEpisodeItemClicked(view: View, episode: Episode) { // Tell InboxRecyclerView to expand the episode item. // We pass it the ID of the item to expand recyclerView.expandItem(episode.id) }
InboxRecyclerView funziona fornendo l'ID elemento della vista da espandere. Quindi trova la vista corrispondente dagli elementi di RecyclerView ed esegue l'animazione su di essa.
Ora diamo un'occhiata al problema che stiamo cercando di risolvere. Nella parte superiore della stessa UI è presente un elemento diverso, che mostra all'utente il prossimo episodio da guardare. Utilizza lo stesso tipo di vista dell'elemento del singolo episodio mostrato sopra, ma ha un ID elemento diverso.
Vista la mia pigrizia, per facilitare lo sviluppo ho usato lo stesso onEpisodeItemClicked() per questo elemento. Sfortunatamente questo ha portato a un'interruzione dell'animazione al momento del clic.

Invece di espandere l'elemento cliccato, la libreria espande un elemento apparentemente casuale in alto. Questo non è l'effetto desiderato ed è causato da alcuni problemi sottostanti:
- L'ID che utilizziamo nel listener di clic viene preso direttamente dalla classe Episode. Questo ID è associato all'elemento dell'episodio singolo nell'elenco delle stagioni.
- L'elemento dell'episodio potrebbe non essere collegato a RecyclerView. Affinché la vista esista in RecyclerView, l'utente dovrebbe aver allargato la stagione e scorso in modo tale che l'elemento sia nell'area visibile.
Soluzione ideale
Quindi qual è il comportamento previsto? Idealmente avremmo qualcosa del genere (rallentato:In pseudo-codice potrebbe apparire così:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) { // Tell the ViewModel to include the season’s episodes in the // RecyclerView data set. This will trigger a database fetch, and update // the view state viewModel.expandSeason(nextEpisodeToWatch.seasonId) // Scroll the RecyclerView so that the episode is displayed recyclerView.scrollToItemId(nextEpisodeToWatch.id) // Expand the item like before recyclerView.expandItem(nextEpisodeToWatch.id) }
In realtà, però, dovrebbe assomigliare di più a questo:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) { // Tell the ViewModel to include the season’s episodes in the // RecyclerView data set. This will trigger a database fetch viewModel.expandSeason(nextEpisodeToWatch.seasonId) // TODO wait for new state dispatch from the ViewModel // TODO wait for RecyclerView adapter to diff new data set // TODO wait for RecyclerView to layout any new items // Scroll the RecyclerView so that the episode is displayed recyclerView.scrollToItemId(nextEpisodeToWatch.id) // TODO wait for RecyclerView scroller to finish // Expand the item like before recyclerView.expandItem(nextEpisodeToWatch.id) }
Come puoi vedere, bisogna aspettare un bel po' per l'esecuzione di attività asincrone! ⏳
Lo pseudo-codice qui non sembra troppo complesso, ma quando inizi a implementarlo ti ritrovi presto impantanato in un mare di callback. Ecco un tentativo di scrivere una soluzione scheletro usando callback concatenati:
fun expandEpisodeItem(itemId: Long) { recyclerView.expandItem(itemId) } fun scrollToEpisodeItem(position: Int) { recyclerView.smoothScrollToPosition(position) // Add a scroll listener, and wait for the RV to be become idle recyclerView.addOnScrollListener(object : OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { expandEpisodeItem(episode.id) } } }) } fun waitForEpisodeItemInAdapter() { // We need to wait for the adapter to contain the item id val position = adapter.findItemIdPosition(itemId) if (position != RecyclerView.NO_POSITION) { // The item ID is in the adapter, now we can scroll to it scrollToEpisodeItem(itemId)) } else { // Otherwise we wait for new items to be added to the adapter and try again adapter.registerAdapterDataObserver(object : AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { waitForEpisodeItemInAdapter() } }) } } // And tell the ViewModel to give us the expanded season data set viewModel.expandSeason(nextEpisodeToWatch.seasonId) // Now need to wait for the new data waitForEpisodeItemInAdapter()
Questo codice non è particolarmente ben scritto e probabilmente non funziona, ma lo scopo è mostrare come le callback possano rendere la programmazione della UI davvero complessa. In genere, questo codice presenta alcuni problemi:
Strettamente accoppiate
Dal momento che dobbiamo scrivere la nostra transizione usando i callback, ogni "animazione" deve essere consapevole di cosa chiamare successivamente: Il callback n. 1 chiama l'animazione 2, il callback n. 2 chiama l'animazione n. 3 e così via. Queste animazioni non hanno alcuna relazione l'una con l'altra, ma siamo stati costretti ad accoppiarle insieme.Difficile da mantenere/aggiornare
Due mesi dopo aver scritto questo codice, il tuo motion designer ti chiede di aggiungere una transizione di dissolvenza nel mezzo. Dovrai tracciare la transizione, passando attraverso ogni callback per trovare il callback corretto in cui attivare la nuova animazione. Quindi dovrai provarla...Test
Testare le animazioni è sempre difficile, ma barcamenarsi in questo mare di callback lo rende ancora più complesso. Il test deve riguardare tutti i diversi tipi di animazione e tutti i callback per verificare che tutto funzioni correttamente. In questo articolo non analizziamo i test in dettaglio, ma è un'attività che le coroutine rendono molto più facile.Coroutine in soccorso
Nel primo post abbiamo imparato come includere un'API di callback in una funzione di sospensione. Usiamo questa conoscenza per trasformare il nostro terribile codice di callback in questo:viewLifecycleOwner.lifecycleScope.launch { // await until the adapter contains the episode item ID adapter.awaitItemIdExists(episode.id) // Find the position of the season item val seasonItemPosition = adapter.findItemIdPosition(episode.seasonId) // Scroll the RecyclerView so that the season item is at the // top of the viewport recyclerView.smoothScrollToPosition(seasonItemPosition) // ...and await that scroll to finish recyclerView.awaitScrollEnd() // Finally, expand the episode item to show the episode details recyclerView.expandItem(episode.id) }
Non è decisamente più leggibile?
Le nuove funzioni di sospensione dell'attesa nascondono tutta la complessità, determinando un elenco sequenziale di chiamate di funzione. Entriamo più nei dettagli...
MotionLayout.awaitTransitionComplete()
Al momento non ci sono estensioni MotionLayout ktx disponibili e, inoltre, MotionLayout attualmente non ha la possibilità di aggiungere più di un listener alla volta (richiesta di funzionalità). Ciò significa che l'implementazione della funzione awaitTransitionComplete() è un po' più complicata rispetto ad alcune delle altre funzioni.Usiamo una sottoclasse di MotionLayout che aggiunge il supporto per più listener: MultiListenerMotionLayout.
La nostra funzione waititTransitionComplete() viene quindi definita come:
/** * Wait for the transition to complete so that the given [transitionId] is fully displayed. * * @param transitionId The transition set to await the completion of * @param timeout Timeout for the transition to take place. Defaults to 5 seconds. */ suspend fun MultiListenerMotionLayout.awaitTransitionComplete(transitionId: Int, timeout: Long = 5000L) { // If we're already at the specified state, return now if (currentState == transitionId) return var listener: MotionLayout.TransitionListener? = null try { withTimeout(timeout) { suspendCancellableCoroutine<Unit> { continuation -> val l = object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { if (currentId == transitionId) { removeTransitionListener(this) continuation.resume(Unit) } } } // If the coroutine is cancelled, remove the listener continuation.invokeOnCancellation { removeTransitionListener(l) } // And finally add the listener addTransitionListener(l) listener = l } } } catch (tex: TimeoutCancellationException) { // Transition didn't happen in time. Remove our listener and throw a cancellation // exception to let the coroutine know listener?.let(::removeTransitionListener) throw CancellationException("Transition to state with id: $transitionId did not" + " complete in timeout.", tex) } }
Adapter.awaitItemIdExists()
Questa funzione è probabilmente piuttosto di nicchia, ma è molto utile. Nell'esempio dei programmi TV precedenti, in realtà gestisce alcuni stati asincroni diversi:// Make sure that the season is expanded, with the episode attached viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// 1. Wait for new data dispatch // 2. Wait for RecyclerView adapter to diff new data set
// Scroll the RecyclerView so that the episode is displayed recyclerView.scrollToItemId(nextEpisodeToWatch.id)La funzione viene implementata usando AdapterDataObserver di RecyclerView, che viene chiamato ogni volta che cambia il set di dati dell'adattatore:
/** * Await an item in the data set with the given [itemId], and return its adapter position. */ suspend fun <VH : RecyclerView.ViewHolder> RecyclerView.Adapter<VH>.awaitItemIdExists(itemId: Long): Int { val currentPos = findItemIdPosition(itemId) // If the item is already in the data set, return the position now if (currentPos >= 0) return currentPos // Otherwise we register a data set observer and wait for the item ID to be added return suspendCancellableCoroutine { continuation -> val observer = object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { (positionStart until positionStart + itemCount).forEach { position -> // Iterate through the new items and check if any have our itemId if (getItemId(position) == itemId) { // Remove this observer so we don't leak the coroutine unregisterAdapterDataObserver(this) // And resume the coroutine continuation.resume(position) } } } } // If the coroutine is cancelled, remove the observer continuation.invokeOnCancellation { unregisterAdapterDataObserver(observer) } // And finally register the observer registerAdapterDataObserver(observer) } }
RecyclerView.awaitScrollEnd()
L'ultima funzione da evidenziare è RecyclerView.awaitScrollEnd(), che attende che lo scorrimento sia terminato:suspend fun RecyclerView.awaitScrollEnd() { // If a smooth scroll has just been started, it won't actually start until the next // animation frame, so we'll await that first awaitAnimationFrame() // Now we can check if we're actually idle. If so, return now if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return suspendCancellableCoroutine<Unit> { continuation -> continuation.invokeOnCancellation { // If the coroutine is cancelled, remove the scroll listener recyclerView.removeOnScrollListener(this) // We could also stop the scroll here if desired } addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { // Make sure we remove the listener so we don't leak the // coroutine continuation recyclerView.removeOnScrollListener(this) // Finally, resume the coroutine continuation.resume(Unit) } } }) } }
Spero che ora questo codice risulti abbastanza comprensibile. Il problema con questa funzione è la necessità di utilizzare awaitAnimationFrame() prima di eseguire il controllo fail-fast. Come menzionato nei commenti, ciò è dovuto al fatto che uno SmoothScroller inizia al successivo frame di animazione, quindi bisogna attendere che ciò accada prima di controllare lo stato di scorrimento.
awaitAnimationFrame() è un wrapper su postOnAnimation (), che ci consente di attendere il successivo step temporale dell'animazione, che in genere si verifica al rendering della visualizzazione successiva. È implementato come nell'esempio doOnNextLayout() dal primo post:
suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation -> val runnable = Runnable { continuation.resume(Unit) } // If the coroutine is cancelled, remove the callback continuation.invokeOnCancellation { removeCallbacks(runnable) } // And finally post the runnable postOnAnimation(runnable) }
Risultato finale
Alla fine, la sequenza delle operazioni è simile alla seguente:Rompere le catene dei callback ⛓️
Passando alle coroutine, il nostro codice è in grado di eliminare lunghissime catene di callback, che sono difficili da mantenere e testare.La ricetta per racchiudere un'API callback/listener/observer in una funzione di sospensione è sostanzialmente la stessa per tutte le API. Spero che le funzioni mostrate in questo post siano ora di facile comprensione. Quindi non ti resta che liberare il codice della tua UI dalle catene dei callback!