Pubblicato da Nick Butcher
Recentemente al Google I/O ho presentato alcune tecniche per scrivere animazioni più intelligenti nelle applicazioni Android, in particolare per la riproduzione di animazioni con le architetture reattive:
tl;dw?
So che non tutti possono guardare un video di 32 minuti, quindi ecco un articolo sull'argomento. ☕️
#AnimationsMatter
Penso che le animazioni siano importanti per l'usabilità delle app; spiegano i cambiamenti di stato o le transizioni, stabiliscono un modello spaziale o possono indirizzare l'attenzione. Aiutano gli utenti a comprendere e navigare nelle app.
con animazioni e senza
Questo esempio mostra lo stesso flusso in un'app con le animazioni abilitate a sinistra e disabilitate a destra. Senza animazioni, l'esperienza risulta "brusca": si salta tra uno stato e l'altro senza spiegare cosa è cambiato.
Quindi, sebbene io ritenga che le animazioni siano importanti, penso anche che la loro implementazione sia sempre più difficile a causa delle variazioni nella progettazione delle app moderne. Stiamo spostando la maggior parte della gestione degli stati fuori dal livello di visualizzazione sui controller (come ViewModel), che pubblicano un tipo di oggetto stato, ad esempio un UiModel che incapsula lo stato corrente dell'app necessario per eseguire il rendering della visualizzazione. Ogni volta che qualcosa cambia nel nostro modello di dati, ad esempio viene restituita una richiesta di rete o si completa un'azione avviata dall'utente, pubblichiamo un nuovo modello di UI, incapsulando l'intero stato aggiornato.
Un ViewModel che pubblica un flusso di oggetti stato
Oggi non intendo concentrarmi su questo modello o sui suoi benefici. Ci sono molte ottime risorse in questo campo: cerca ad esempio Uni-directional Data Flow o MVI o librerie come MvRx o Mobius . Tuttavia, voglio concentrarmi sull'altra estremità di questo flusso, ovvero dove la visualizzazione osserva questo flusso di modelli e li associa alla UI. È come una funzione pura dove, dato un nuovo stato, vogliamo associarlo completamente alla nostra UI. Non vogliamo pensare allo stato attuale della UI. Ovvero, l'associazione dei dati alla UI deve essere stateless . Tuttavia, le animazioni sono stateful . Si tratta di passare da un valore all'altro nel tempo. Questa è la tensione essenziale su cui voglio concentrarmi in questo post. Perché in questo momento temo che molte app stiano rimuovendo le animazioni a causa di questa tensione, determinando una reale perdita di usabilità.
... i dati di associazione alla tua UI devono essere stateless. Tuttavia, le animazioni sono stateful.
Qual è il problema?
Per vedere concretamente come potremmo conservare le animazioni in questo mondo reattivo e le sfide che dobbiamo affrontare, ecco un esempio di base: una schermata di accesso.
Una schermata di accesso in cui il pulsante di accesso e l'indicatore di avanzamento si dissolvono quando diventano visibili/invisibili
Quando l'utente seleziona Login, bisogna nascondere il pulsante di accesso e mostrare un indicatore di avanzamento e il pulsante di accesso deve dissolversi in uscita, mentre l'indicatore di avanzamento deve dissolversi in entrata.
Un oggetto stato per questa schermata e la logica di associazione (statica) potrebbero essere simili ai seguenti:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
data class LoginUiModel(
val submitEnabled: Boolean,
val loginInProgress: Boolean
)
…
setVisibility(login, !uiModel.loginInProgress)
setVisibility(progress, uiModel.loginInProgress)
…
fun setVisibility(view: View, visible: Boolean) {
view.visibility = if (visible) View.VISIBLE else View.GONE
}
Quindi, se vogliamo animare questo cambiamento, un tentativo iniziale potrebbe assomigliare a questo, in cui animiamo la proprietà alfa (e infine impostiamo il valore di visibilità in caso di dissolvenza in uscita):
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun animateVisibility(view: View, visible: Boolean) {
view.visibility = View.VISIBLE
if (visible) {
ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).start()
} else {
ObjectAnimator.ofFloat(view, View.ALPHA, 1f, 0f).apply {
doOnEnd { view.visibility = View.GONE }
}.start()
}
}
Ciò può tuttavia portare a risultati imprevisti:
Il tipo di problemi che potresti riscontrare durante l'aggiunta di animazioni a un'app reattiva
Qui un nuovo modello di UI viene pubblicato ad ogni pressione dei tasti, ma puoi vedere che l'indicatore di progresso continua a comparire inaspettatamente! Oppure, se premi il pulsante di invio (con durate di animazione esagerate per la demo), puoi ritrovarti in uno stato in cui sia il pulsante che l'indicatore di avanzamento sono spariti. Questo perché le nostre animazioni hanno effetti collaterali, come l'end listener , che non vengono gestiti correttamente.
Quando scrivi animazioni in questo mondo reattivo, ci sono alcune qualità di cui il tuo codice di animazione ha bisogno. Le classificherei nel modo seguente:
Rientrante
Continuo
Uniforme
Rientrante
La rientranza indica che l'animazione deve essere preparata in modo da essere interrotta e richiamata in qualsiasi momento. Se è possibile pubblicare nuovi oggetti stato in qualsiasi momento, tutte le animazioni che eseguiamo devono essere preparate per un nuovo stato da associare mentre un'animazione è in esecuzione. Per fare ciò dobbiamo essere in grado di annullare o reindirizzare le animazioni in esecuzione ed eliminare eventuali effetti collaterali (come i listener).
Continuo
La continuità consiste nell'evitare bruschi cambiamenti nel valore da animare. Per dimostrare questa proprietà, considera una visualizzazione che anima dimensioni e colore quando viene premuta/rilasciata:
Animazione di dimensioni e colore alla pressione
Tutto sembra OK quando eseguiamo l'animazione fino al completamento, ma se invece la tocchiamo rapidamente, dimensioni e colore dell'animazione cambiano. Questa è una conseguenza del fare ipotesi nel nostro codice di associazione, ad esempio supponendo che un'animazione di dissolvenza inizi sempre da 0 alfa.
Uniformità
Per comprendere questa proprietà, considera questo esempio in cui una visualizzazione si anima in alto a sinistra o in alto a destra in risposta a un evento:
Stuttering nelle animazioni
Se la inviamo due volte in alto a destra in rapida successione, la visualizzazione si interrompe a metà strada prima di continuare lentamente verso la sua destinazione. Se cambiamo destinazione a metà strada, si ferma di nuovo e cambia bruscamente direzione. Questo tipo di arresti o cambi di direzione improvvisi sembrano innaturali: nulla nel mondo reale si comporta in questo modo. Dovremmo cercare di evitare questo tipo di comportamento, per mantenere fluide le nostre animazioni.
Fixme
Quindi torniamo alla nostra funzione di associazione della visibilità e risolviamo questi problemi. Innanzitutto, osserviamo la continuità . Possiamo vedere che le nostre animazioni alfa vanno sempre da un valore iniziale a un valore finale, ad esempio da 0 a 1 per entrare con una dissolvenza in ingresso. Possiamo omettere il valore iniziale e fornire solo un valore finale.
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).start()
+ ObjectAnimator.ofFloat(view, View.ALPHA, 1f).start()
Se ometti il valore iniziale, l'animatore leggerà il valore corrente e inizierà da lì. Questo è esattamente ciò che vogliamo e ci consentirà di evitare salti improvvisi nella proprietà da animare.
Ora rendiamo la nostra funzione rientrante , in modo da poterla richiamare in qualsiasi momento. In primo luogo, possiamo evitare di lavorare più del dovuto. Se la visualizzazione è già al valore target, possiamo richiamarla prima.
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun animateVisibility(view: View, visible: Boolean) {
+ val targetAlpha = if (visible) 1f else 0f
+ if (view.alpha == targetAlpha) return
Quindi dobbiamo archiviare gli animatori e i listener in esecuzione in modo da poterli annullare prima di iniziare una nuova animazione. Il luogo logico in cui archiviarli è nella visualizzazione stessa... ma View offre già un pratico meccanismo per farlo: ViewPropertyAnimator . Questo è l'oggetto restituito dalle chiamate a View.animate() e annulla automaticamente qualsiasi animazione attualmente in esecuzione su una proprietà se ne avvii una nuova... fantastico! ViewPropertyAnimator offre anche un metodo withEndAction che viene eseguito solo se l'animazione viene eseguita normalmente fino al completamento, non se viene cancellato. Ancora una volta, questo è esattamente il comportamento che desideriamo, il che significa che eventuali effetti collaterali (come la nostra modifica della visibilità) non verranno eseguiti se l'animazione viene annullata da un nuovo valore target in ingresso. Il passaggio a un ViewPropertyAnimator rende la nostra funzione rientrante.
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- val anim = ObjectAnimator.ofFloat(view, View.ALPHA, targetAlpha)
- if (!visible) {
- anim.doOnEnd { view.visibility = View.GONE }
- }
+ val anim = view.animate().alpha(targetAlpha)
+ if (!visible) {
+ anim.withEndAction { view.visibility = View.GONE }
+ }
Abbiamo detto che ViewPropertyAnimator avrebbe annullato qualsiasi animazione in esecuzione sulla stessa proprietà e ne avrebbe iniziata una nuova. Questo viola la nostra proprietà di uniformità e può portare al problema dello stuttering che abbiamo visto prima, in cui un'animazione si interrompe bruscamente e un'altra inizia (con la stessa durata, nonostante sia a una distanza più breve). Per ovviare a questo possiamo sfruttare una libreria di animazioni che non credo sia nota a molti sviluppatori.
Springterlude
Le animazioni "spring" fanno parte della libreria Jetpack "dynamic-animation" . Penso che molti possano aver deciso di ignorare questa libreria vedendo esempi di animazioni "spring"... sebbene questo effetto possa essere utile, non è sempre necessario o desiderabile. Questo effetto, tuttavia, può essere disabilitato , lasciandoci con un sistema di animazione supportato da un modello fisico che ha una serie di proprietà che sono utili per l'animazione generale, specificamente interrompibilità e reindirizzamento.
Quindi, tornando al nostro esempio precedente, se reimplementiamo con animazioni spring, possiamo vedere che non sono più presenti i problemi di uniformità. Invece, la modifica della destinazione e gli avvii ripetuti vengono gestiti correttamente, rispettando la velocità corrente per produrre animazioni fluide:
Le animazioni "spring" mantengono la velocità quando vengono reindirizzate
Scrivere una SpringAnimation assomiglia molto a un normale animatore; gran parte del vantaggio deriva dall'utilizzo del metodo animateToFinalPosition anziché dall'effettuare una chiamata start(). Questo avvierà un'animazione se non è ancora iniziata, ma, soprattutto, se è in corso un'animazione, la reindirizzerà verso una nuova destinazione, mantenendo il movimento invece di modificarlo bruscamente.
Sfortunatamente non esiste un'API View comoda come View.animate per usare le animazioni spring (è solo Jetpack)... ma possiamo costruirne una come funzione di estensione:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
fun View.spring(
property: ViewProperty
): SpringAnimation {
val key = getKey(property)
var springAnim = getTag(key) as? SpringAnimation?
if (springAnim == null) {
springAnim = SpringAnimation(this, property)
setTag(key, springAnim)
}
return springAnim
}
Questo crea o recupera un'animazione spring per una data ViewProperty (traslazione, rotazione ecc. ), archiviandola nel tag della visualizzazione. Possiamo quindi facilmente utilizzare il metodo animateToFinalPosition per aggiornare un'animazione in esecuzione. Usandola nella nostra funzione di associazione della visibilità:
/* Copyright 2019 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
- val anim = view.animate().alpha(targetAlpha)
+ val spring = view.spring(SpringAnimation.ALPHA)
+ spring.animateToFinalPosition(targetAlpha)
Dobbiamo anche cambiare l'azione di fine per usare un listener di fine animazione "spring". Il codice completo per questo è disponibile in questo gist . Probabilmente vorresti anche poter configurare l'animazione in qualche modo; a differenza delle normali animazioni, che specificano durate e interpolatori, le animazioni spring possono essere configurate impostando rigidità e rapporto di smorzamento. Possiamo migliorare la nostra funzione di estensione in modo che accetti tali parametri, semplificando la configurazione del call-site, ma offrendo impostazioni predefinite ragionevoli. Vedi qui per un'implementazione più completa.
Quindi abbiamo reso la nostra associazione di visibilità rientrante, continua e fluida. Sebbene possa sembrare complesso raggiungere questo obiettivo, in realtà avrai bisogno solo di alcune di queste funzioni di associazione che possono essere utilizzate in tutta la tua applicazione. Ecco una libreria in cui è contenuta questa tecnica di associazione, che potrai facilmente utilizzare.
Animatore di oggetti
Diamo un'occhiata a un altro esempio di utilizzo di questo tipo di animazione: come applicare questi principi a un RecyclerView.ItemAnimator .
DefaultItemAnimator a confronto con un ItemAnimator basato su animazioni spring
Questo esempio simula gli aggiornamenti che vengono eseguiti sul set di dati mentre le animazioni sono in esecuzione utilizzando il pulsante di riproduzione casuale. Nota che, quando si preme il pulsante due volte in rapida successione, l'uniformità dell'animatore basato su animazioni spring fa una grande differenza. A sinistra, la casella si blocca e quindi cambia direzione. A destra cambia direzione senza intoppi. Scommetto che la maggior parte delle app carica le informazioni dalla rete e le visualizza in un RecyclerView, probabilmente da più fonti. Questo tipo di animazione flessibile aggiunge un livello di perfezionamento alla tua applicazione che rende l'esperienza molto più fluida. Ecco un PR che aggiunge questo tipo di animatore all'esempio Plaid .
Animare in modo più intelligente
Spero che i principi che ho presentato in questo post ti aiuteranno a scrivere animazioni nelle tue app reattive, migliorandone l'usabilità. In realtà sono un elenco ordinato:
Gerarchia delle esigenze di animazione di @crafty
Essere rientranti riguarda la correttezza: se non hai questa proprietà le animazioni potrebbero essere interrotte. Usa ViewPropertyAnimator o fai attenzione nel codice dell'animazione a gestire la situazione che potrebbe essere interrotta e richiamata.
La continuità aiuta a migliorare la user experience, evitando cambiamenti o salti improvvisi. Nel codice dell'animazione devi evitare di procedere per supposizioni e garantire uniformità tra un'animazione e l'altra.
L'uniformità è la ciliegina sulla torta. Rende le tue animazioni più naturali e consente cambiamenti, interruzioni e reindirizzamenti dinamici.
Credo davvero che le animazioni non solo rendano le nostre app più piacevoli e divertenti da usare, ma anche più facili da capire. Ti incoraggio a imparare queste tecniche in modo da poterle impiegare con successo nelle tue app.