Animation in Android: Glatte Übergänge von Fragmenten im unteren Bereich

Es wurde eine Vielzahl von Dokumentationen und Artikeln über eine wichtige visuelle Komponente von Anwendungen geschrieben - die Animation. Trotzdem konnten wir uns in Probleme stürzen und stießen bei der Implementierung auf Mängel .



Dieser Artikel befasst sich mit dem Problem und der Analyse von Optionen für seine Lösung. Ich werde dir nicht gegen alle Monster eine Silberkugel geben, aber ich werde dir zeigen, wie du eine bestimmte studieren kannst, um eine Kugel speziell für ihn zu erstellen. Ich werde dies anhand eines Beispiels analysieren, wie wir die Animation wechselnder Fragmente mit dem unteren Blatt befreundet haben.







Diamond Checkout: Hintergrund



Diamond Checkout ist der Codename für unser Projekt. Die Bedeutung ist sehr einfach - um die Zeit zu reduzieren, die der Kunde in der letzten Phase der Bestellung verbringt. Wenn die alte Version mindestens vier Klicks auf zwei Bildschirme erfordert, um eine Bestellung aufzugeben (und jeder neue Bildschirm einen potenziellen Kontextverlust für den Benutzer darstellt), erfordert die "Diamant-Kaufabwicklung" idealerweise nur einen Klick auf einen Bildschirm.





Vergleich der alten und neuen Kasse



Wir nennen den neuen Bildschirm "Vorhang" zwischen uns. Auf dem Bild sehen Sie, wie wir die Aufgabe von den Designern erhalten haben. Diese Designlösung ist Standard und unter dem Namen Bottom Sheet bekannt, der in Material Design (einschließlich für Android) beschrieben wird) und wird in vielen Variationen in vielen Anwendungen eingesetzt. Google bietet uns zwei vorgefertigte Implementierungsoptionen: Modal und Persistent. Der Unterschied zwischen diesen Ansätzen wurde in vielen , vielen Artikeln beschrieben.





Wir beschlossen, dass unser Vorhang modal sein würde und kurz vor dem Happy End stand, aber das Designteam war auf der Hut und ließ dies nicht so einfach zu.



Sehen Sie, was für großartige Animationen unter iOS . Lass uns das Gleiche tun?



Wir konnten eine solche Herausforderung nicht ablehnen! Okay, nur ein Scherz über "Die Designer haben plötzlich ein Angebot für Animationen gemacht", aber der Teil über iOS ist wahr.



Die Standardübergänge zwischen Bildschirmen (dh das Fehlen von Übergängen) sahen zwar nicht zu ungeschickt aus, erreichten jedoch nicht den Titel "Diamond Checkout". Obwohl, wen ich veräpple, war es wirklich schrecklich:





Was wir "out of the box" haben



Bevor wir mit der Beschreibung der Implementierung der Animation fortfahren, werde ich Ihnen sagen, wie die Übergänge zuvor ausgesehen haben.



  1. Der Client klickte auf das Adressfeld der Pizzeria -> als Antwort wurde das Fragment "Pickup" geöffnet. Es öffnete sich im Vollbildmodus (wie beabsichtigt) mit einem scharfen Sprung, während die Liste der Pizzerien mit einer leichten Verzögerung erschien.
  2. Wenn der Client "Zurück" -> drückte, erfolgte die Rückkehr zum vorherigen Bildschirm mit einem scharfen Sprung.
  3. Als ich unten auf das Feld Zahlungsmethode -> klickte, öffnete sich das Fragment "Zahlungsmethode" mit einem scharfen Sprung. Die Liste der Zahlungsmethoden wurde mit Verzögerung angezeigt, und als sie angezeigt wurden, vergrößerte sich der Bildschirm mit einem Sprung.
  4. Wenn Sie "Zurück" drücken -> kehren Sie mit einem scharfen Sprung zurück.


Die Verzögerung bei der Anzeige von Daten wird dadurch verursacht, dass sie asynchron auf den Bildschirm geladen werden. Dies muss in Zukunft berücksichtigt werden.



Was in der Tat das Problem ist: Wo sich der Kunde gut fühlt, haben wir Einschränkungen



Benutzer mögen es nicht, wenn zu viele abrupte Bewegungen auf dem Bildschirm angezeigt werden. Es ist ablenkend und verwirrend. Darüber hinaus möchten Sie immer eine reibungslose Reaktion auf Ihre Aktion und keine Krämpfe sehen.



Dies führte uns zu einer technischen Einschränkung: Wir haben entschieden, dass wir das aktuelle untere Blatt nicht schließen und für jeden Bildschirmwechsel ein neues anzeigen können, und es wäre auch schlecht, mehrere untere Blätter übereinander anzuzeigen. Im Rahmen unserer Implementierung (jeder Bildschirm ist ein neues Fragment) können Sie also nur ein unteres Blatt erstellen, das sich als Reaktion auf Benutzeraktionen so reibungslos wie möglich bewegen sollte.



Dies bedeutet, dass wir einen Fragmentcontainer haben, dessen Höhe dynamisch ist (da alle Fragmente unterschiedliche Höhen haben), und wir müssen seine Höhenänderung animieren.



Vorläufiges Markup



Das Wurzelelement des "Vorhangs" ist sehr einfach - es ist nur ein rechteckiger Hintergrund mit abgerundeten Ecken oben und einem Behälter, in den die Fragmente gelegt werden.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <androidx.fragment.app.FragmentContainerView
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      />
 
</FrameLayout>


Und die Datei dialog_gray200_background.xml sieht folgendermaßen aus:



<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
  <item>
    <shape android:shape="rectangle">
      <solid android:color="@color/gray200" />
      <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
    </shape>
  </item>
</selector>


Jeder neue Bildschirm ist ein separates Fragment, die Fragmente werden mit der Ersetzungsmethode ersetzt, hier ist alles Standard.



Erste Versuche, Animation zu implementieren



animateLayoutChanges



Erinnern wir uns an die alte Elfenmagie animateLayoutChanges , die eigentlich die Standard-LayoutTransition ist. Obwohl animateLayoutChanges überhaupt nicht zum Ändern von Fragmenten entwickelt wurde, ist zu hoffen, dass dies bei der Höhenanimation hilfreich ist. Auch FragmentContainerView unterstützt animateLayoutChanges nicht, daher ändern wir es in gutes altes FrameLayout.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <FrameLayout
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:animateLayoutChanges="true"
      />
 
</FrameLayout>


Lauf:



animateLayoutChanges



Wie Sie sehen können, wird das Ändern der Höhe des Containers beim Ändern von Fragmenten wirklich animiert. Das Aufrufen des Aufnahmebildschirms sieht gut aus, aber der Rest lässt zu wünschen übrig.



Die Intuition legt nahe, dass dieser Weg zu einem zuckenden Auge des Designers führen wird, also setzen wir unsere Änderungen zurück und versuchen etwas anderes.



setCustomAnimations



Mit FragmentTransaction können Sie die im XML-Format beschriebene Animation mithilfe der setCustomAnimation- Methode festlegen . Erstellen Sie dazu in den Ressourcen einen Ordner mit dem Namen "anim" und fügen Sie dort vier Animationsdateien hinzu:



to_right_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="100%" />
</set>


to_right_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="-100%" />
</set>


to_left_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="-100%" />
</set>


to_left_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="100%" />
</set>


Und dann setzen wir diese Animationen in eine Transaktion:



fragmentManager
    .beginTransaction()
    .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out)
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


Wir erhalten folgendes Ergebnis:





setCustomAnimation



Was wir mit dieser Implementierung haben:



  • Es ist bereits besser geworden - Sie können sehen, wie sich die Bildschirme als Reaktion auf Benutzeraktionen gegenseitig ersetzen.
  • Aufgrund der unterschiedlichen Höhen der Fragmente gibt es jedoch immer noch einen Sprung. Dies liegt daran, dass beim Verschieben von Fragmenten in der Hierarchie nur ein Fragment vorhanden ist. Er ist es, der die Höhe des Containers für sich selbst einstellt, und der zweite zeigt "wie es passiert ist".
  • Es gibt immer noch ein Problem beim asynchronen Laden von Daten zu Zahlungsmethoden - der Bildschirm wird zunächst leer angezeigt und dann mit Inhalten gefüllt.


Das ist nicht gut. Fazit: Sie brauchen noch etwas.



Oder versuchen Sie etwas Plötzliches: Shared Element Transition



Die meisten Android-Entwickler kennen Shared Element Transition. Obwohl dieses Tool sehr flexibel ist, haben viele Menschen Probleme damit und verwenden es daher nicht sehr gern.





Sein Wesen ist recht einfach - wir können den Übergang von Elementen von einem Fragment zum anderen animieren. Zum Beispiel können wir das Element auf dem ersten Fragment (nennen wir es das „Anfangselement“) mit Animation an die Stelle des Elements auf dem zweiten Fragment verschieben (wir nennen dieses Element „das letzte Element“), während wir den Rest der Elemente des ersten Fragments ausblenden und das zweite Fragment mit Fade anzeigen. Das Element, das von einem Fragment zum anderen animiert werden muss, wird als gemeinsames Element bezeichnet.



Um das gemeinsame Element festzulegen, benötigen wir:



  • Markieren Sie das Startelement und das Endelement mit dem TransitionName-Attribut mit demselben Wert.
  • angeben sharedElementEnterTransition für den zweiten Chunk.


Was ist, wenn Sie die Stammansicht des Fragments als freigegebenes Element verwenden? Vielleicht wurde der Shared Element Transition dafür nicht erfunden. Wenn Sie darüber nachdenken, ist es jedoch schwierig, ein Argument zu finden, warum diese Lösung nicht funktioniert. Wir wollen das Startelement zwischen zwei Fragmenten zum Endelement animieren. Ich sehe keinen ideologischen Widerspruch. Lass uns das versuchen!



Geben Sie für jedes Fragment, das sich innerhalb des "Vorhangs" befindet, für die Stammansicht das Attribut TransitionName mit demselben Wert an:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


Wichtig: Dies funktioniert, da wir REPLACE in der Chunk-Transaktion verwenden. Wenn Sie ADD verwenden (oder ADD verwenden und das vorherige Snippet mit previousFragment.hide () ausblenden [tun Sie dies nicht]), muss TransitionName dynamisch festgelegt und nach Abschluss der Animation gelöscht werden. Dies muss erfolgen, da zu einem bestimmten Zeitpunkt in der aktuellen Ansichtshierarchie nicht zwei Ansichten mit demselben Übergangsnamen vorhanden sein können. Dies kann getan werden, aber es ist besser, wenn Sie auf einen solchen Hack verzichten können. Wenn Sie ADD wirklich verwenden müssen, finden Sie in diesem Artikel Anregungen für die Implementierung .


Als Nächstes müssen Sie die Übergangsklasse angeben, die für den Ablauf unseres Übergangs verantwortlich ist. Lassen Sie uns zunächst überprüfen, was im Lieferumfang enthalten ist - verwenden Sie AutoTransition .



newFragment.sharedElementEnterTransition = AutoTransition()


Und wir müssen das gemeinsame Element festlegen, das wir in der Fragmenttransaktion animieren möchten. In unserem Fall ist dies die Stammansicht des Fragments:



fragmentManager
    .beginTransaction()
    .apply{
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName)
        setReorderingAllowed(true)
      }
    }
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


Wichtig: Bitte beachten Sie, dass TransitionName (wie die gesamte Transition API) ab Android Lollipop verfügbar ist.


Lass uns nachsehen, was passiert ist:





AutoTransition



Transition hat funktioniert, sieht aber mittelmäßig aus. Dies liegt daran, dass sich während einer Blocktransaktion nur der neue Block in der Ansichtshierarchie befindet. Dieses Fragment streckt oder verkleinert den Container auf seine Größe und beginnt erst danach mit einem Übergang zu animieren. Aus diesem Grund sehen wir Animationen nur, wenn das neue Fragment höher als das vorherige ist.



Was sollen wir tun, da die Standardimplementierung nicht zu uns passte? Natürlich müssen Sie alles in Flutter neu schreiben und Ihren eigenen Übergang schreiben!



Schreiben Sie Ihren Übergang



Transition ist eine Klasse aus der Transition-API , die für die Erstellung von Animationen zwischen zwei Szenen (Szene) verantwortlich ist. Die Hauptelemente dieser API:



  • Szene ist die Anordnung von Elementen auf dem Bildschirm zu einem bestimmten Zeitpunkt (Layout) und der ViewGroup, in der die Animation stattfindet (sceneRoot).
  • Die Startszene ist die Szene zur Startzeit.
  • Die Endszene ist die Szene zum Endzeitpunkt.
  • Transition ist eine Klasse, die die Eigenschaften der Start- und Endszenen sammelt und einen Animator erstellt, um zwischen ihnen zu animieren.


In der Transition-Klasse werden vier Methoden verwendet:



  • Spaß getTransitionProperties (): Array. Diese Methode sollte eine Reihe von Eigenschaften zurückgeben, die animiert werden. Bei dieser Methode müssen Sie ein Array von Zeichenfolgen (Schlüsseln) in freier Form zurückgeben. Die Hauptsache ist, dass die Methoden captureStartValues ​​und captureEndValues ​​(unten beschrieben) Eigenschaften mit diesen Schlüsseln schreiben. Ein Beispiel wird folgen.
  • fun captureStartValues(transitionValues: TransitionValues). layout' . , , , .
  • fun captureEndValues(transitionValues: TransitionValues). , layout' .
  • fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator?. , , . , , .


Transition



  1. , Transition.



    @TargetApi(VERSION_CODES.LOLLIPOP)
    class BottomSheetSharedTransition : Transition {
    	@Suppress("unused")
    	constructor() : super()
     
    	@Suppress("unused")
    	constructor(
        	  context: Context?,
        	   attrs: AttributeSet?
    	) : super(context, attrs)
    }
    , Transition API Android Lollipop.
  2. getTransitionProperties.



    View, PROP_HEIGHT, ( ) :



    companion object {
      private const val PROP_HEIGHT = "heightTransition:height"
     
      private val TransitionProperties = arrayOf(PROP_HEIGHT)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
  3. captureStartValues.



    View, transitionValues. transitionValues.values ( Map) c PROP_HEIGHT:



    override fun captureStartValues(transitionValues: TransitionValues) {
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
    }


    , . , . , - . « » , , . , . :



    override fun captureStartValues(transitionValues: TransitionValues) {
      //    View...
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
     
      // ...      
      transitionValues.view.parent
        .let { it as? View }
        ?.also { view ->
            view.updateLayoutParams<ViewGroup.LayoutParams> {
                height = view.height
            }
        }
     
    }
  4. captureEndValues.



    , View. . . , . . , , , . — view, , . :



    override fun captureEndValues(transitionValues: TransitionValues) {
      //     View
      transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
    }


    getViewHeight:



    private fun getViewHeight(view: View): Int {
      //   
      val deviceWidth = getScreenWidth(view)
     
      //  View      
      val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY)
      val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
     
      return view
          // 
          .apply { measure(widthMeasureSpec, heightMeasureSpec) }
          //   
          .measuredHeight
          //  View       ,     
          .coerceAtMost(getScreenHeight(view))
    }
     
    private fun getScreenHeight(view: View) =
      getDisplaySize(view).y - getStatusBarHeight(view.context)
     
    private fun getScreenWidth(view: View) =
      getDisplaySize(view).x
     
    private fun getDisplaySize(view: View) =
      Point().also {
        (view.context.getSystemService(
            Context.WINDOW_SERVICE
        ) as WindowManager).defaultDisplay.getSize(it)
      }
     
    private fun getStatusBarHeight(context: Context): Int =
      context.resources
          .getIdentifier("status_bar_height", "dimen", "android")
          .takeIf { resourceId -> resourceId > 0 }
          ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
          ?: 0


    , , — .
  5. . Fade in.



    , . . «BottomSheetSharedTransition», :



    private fun prepareFadeInAnimator(view: View): Animator =
       ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
     
  6. . .



    , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
        }


    ValueAnimator . , . , . , , . , WRAP_CONTENT. , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
            
            //      WRAP_CONTENT 
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }


    , .
  7. . createAnimator.



    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
     
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
     
        return AnimatorSet()
            .apply {
                interpolator = FastOutSlowInInterpolator()
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
  8. .



    Transititon'. , . , . «createAnimator» . ?



    • Fade' , .
    • «captureStartValues» , , WRAP_CONTENT.


    , . : , , Transition'. :



    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
     
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
     
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
     
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
     
    }
     
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
    


    , «PROP_VIEW_TYPE», «captureStartValues» «captureEndValues» . , !
  9. Transition.



    newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()




Damit die Animation pünktlich startet und gut aussieht, müssen Sie nur den Übergang zwischen Fragmenten (und entsprechend der Animation) verschieben, bis die Daten geladen sind. Rufen Sie dazu die postponeEnterTransition- Methode im Fragment auf . Denken Sie daran, startPostponedEnterTransition aufzurufen, wenn Sie mit langen Datenladeaufgaben fertig sind . Ich bin sicher, Sie wussten von diesem Trick, aber es tut nicht weh, Sie noch einmal daran zu erinnern.



Alles zusammen: Was ist am Ende passiert?



Mit der neuen BottomSheetSharedTransition und der Verwendung von postponeEnterTransition beim asynchronen Laden von Daten haben wir die folgende Animation erhalten:



Bereit für den Übergang



Unter dem Spoiler befindet sich eine vorgefertigte Klasse BottomSheetSharedTransition
package com.maleev.bottomsheetanimation
 
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Point
import android.os.Build
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.core.view.updateLayoutParams
 
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class BottomSheetSharedTransition : Transition {
 
    @Suppress("unused")
    constructor() : super()
 
    @Suppress("unused")
    constructor(
        context: Context?,
        attrs: AttributeSet?
    ) : super(context, attrs)
 
    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
 
        // the property PROP_VIEW_TYPE is workaround that allows to run transition always
        // even if height was not changed. It's required as we should set container height
        // to WRAP_CONTENT after animation complete
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
        private const val ANIMATION_DURATION = 400L
 
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
 
    override fun getTransitionProperties(): Array<String> = TransitionProperties
 
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
 
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
 
    }
 
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
 
    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
 
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
 
        return AnimatorSet()
            .apply {
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
 
    private fun prepareFadeInAnimator(view: View): Animator =
        ObjectAnimator
            .ofFloat(view, "alpha", 0f, 1f)
            .apply { interpolator = AccelerateInterpolator() }
 
    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
 
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
 
            //      WRAP_CONTENT
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }
 
    private fun getViewHeight(view: View): Int {
        //   
        val deviceWidth = getScreenWidth(view)
 
        //  View      
        val widthMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY)
        val heightMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
 
        return view
            // :
            .apply { measure(widthMeasureSpec, heightMeasureSpec) }
            //   :
            .measuredHeight
            //  View       ,     :
            .coerceAtMost(getScreenHeight(view))
    }
 
    private fun getScreenHeight(view: View) =
        getDisplaySize(view).y - getStatusBarHeight(view.context)
 
    private fun getScreenWidth(view: View) =
        getDisplaySize(view).x
 
    private fun getDisplaySize(view: View) =
        Point().also { point ->
            view.context.getSystemService(Context.WINDOW_SERVICE)
                .let { it as WindowManager }
                .defaultDisplay
                .getSize(point)
        }
 
    private fun getStatusBarHeight(context: Context): Int =
        context.resources
            .getIdentifier("status_bar_height", "dimen", "android")
            .takeIf { resourceId -> resourceId > 0 }
            ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
            ?: 0
}




Wenn wir eine vorgefertigte Übergangsklasse haben, besteht ihre Anwendung aus einfachen Schritten:



Schritt 1. Fügen Sie in einer Fragmenttransaktion ein gemeinsames Element hinzu und legen Sie den Übergang fest:



private fun transitToFragment(newFragment: Fragment) {
    val currentFragmentRoot = childFragmentManager.fragments[0].requireView()
 
    childFragmentManager
        .beginTransaction()
        .apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName)
                setReorderingAllowed(true)
 
                newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
            }
        }
        .replace(R.id.container, newFragment)
        .addToBackStack(newFragment.javaClass.name)
        .commit()
}


Schritt 2. Legen Sie im Markup der Fragmente (des aktuellen und des nächsten Fragments), die im BottomSheetDialogFragment animiert werden sollen, den Übergangsnamen fest:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


Das ist alles, das Ende.



Könnte es anders gemacht worden sein?



Es gibt immer mehrere Möglichkeiten, ein Problem zu lösen. Ich möchte andere mögliche Ansätze erwähnen, die wir nicht ausprobiert haben:



  • Graben Sie Fragmente aus, verwenden Sie ein Fragment mit vielen Ansichten und animieren Sie bestimmte Ansichten. Dies gibt Ihnen mehr Kontrolle über die Animation, aber Sie verlieren die Vorteile von Fragmenten: native Navigationsunterstützung und sofort einsatzbereite Lebenszyklusbehandlung (Sie müssen dies selbst implementieren).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles