Animasi di Android: transisi fragmen yang halus di dalam Lembar Bawah

Sejumlah besar dokumentasi dan artikel telah ditulis tentang komponen visual penting dari aplikasi - animasi. Meskipun demikian, kami dapat membawa diri kami ke masalah dan menghadapi hambatan dalam implementasinya.



Artikel ini adalah tentang masalah dan analisis opsi untuk solusinya. Aku tidak akan memberimu peluru perak melawan semua monster, tapi aku akan menunjukkan kepadamu bagaimana kamu bisa mempelajari yang spesifik untuk membuat peluru yang khusus untuknya. Saya akan menganalisis ini dengan menggunakan contoh bagaimana kami membuat animasi perubahan fragmen menjadi teman dengan Lembar Bawah.







Diamond Checkout: Latar Belakang



Diamond Checkout adalah nama kode untuk proyek kami. Artinya sangat sederhana - untuk mengurangi waktu yang dihabiskan oleh klien pada tahap terakhir pemesanan. Jika versi lama memerlukan setidaknya empat klik pada dua layar untuk melakukan pemesanan (dan setiap layar baru berpotensi kehilangan konteks oleh pengguna), "pembayaran berlian" idealnya hanya membutuhkan satu klik pada satu layar.





Perbandingan checkout lama dan baru.



Kami menyebut layar baru "tirai" antara kami. Dalam gambar Anda dapat melihat bagaimana kami menerima tugas dari para desainer. Solusi desain ini standar, dikenal dengan nama Bottom Sheet, dijelaskan dalam Desain Material (termasuk untuk Android) dan digunakan dalam berbagai variasi di banyak aplikasi. Google menawarkan dua opsi implementasi siap pakai: Modal dan Persisten. Perbedaan antara pendekatan ini telah dijelaskan dalam banyak , banyak artikel.





Kami memutuskan bahwa tirai kami akan menjadi modal dan dekat dengan akhir yang bahagia, tetapi tim desain siap siaga dan tidak membiarkan ini terjadi begitu mudah.



Lihat animasi yang luar biasa di iOS . Mari kita lakukan hal yang sama?



Kami tidak bisa menolak tantangan seperti itu! Oke, hanya bercanda tentang "para desainer tiba-tiba muncul dengan tawaran untuk membuat animasi", tetapi bagian tentang iOS itu benar.



Transisi standar antara layar (yaitu, tidak adanya transisi) terlihat, meskipun tidak terlalu canggung, tetapi tidak mencapai judul "checkout berlian". Meskipun, siapa aku bercanda, itu benar-benar mengerikan:





Apa yang kita miliki "di luar kotak"



Sebelum melanjutkan ke deskripsi implementasi animasi, saya akan memberi tahu Anda bagaimana transisi terlihat sebelumnya.



  1. Klien mengklik bidang alamat restoran pizza -> sebagai tanggapan, fragmen "Penjemputan" dibuka. Itu dibuka di layar penuh (seperti yang dimaksudkan) dengan lompatan yang tajam, sementara daftar pizza muncul dengan sedikit penundaan.
  2. Ketika klien menekan "Kembali" -> kembali ke layar sebelumnya terjadi dengan lompatan yang tajam.
  3. Ketika saya mengklik bidang metode pembayaran -> dari bawah, fragmen "Metode Pembayaran" dibuka dengan lompatan yang tajam. Daftar metode pembayaran muncul dengan penundaan, ketika muncul, layar meningkat dengan lompatan.
  4. Ketika Anda menekan "Kembali" -> kembali dengan lompatan yang tajam.


Penundaan dalam menampilkan data disebabkan oleh fakta bahwa data dimuat ke layar secara tidak sinkron. Ini akan perlu untuk memperhitungkan ini di masa depan.



Sebenarnya, apa masalahnya: di mana klien merasa baik, di sana kami memiliki keterbatasan



Pengguna tidak suka ketika ada terlalu banyak gerakan tiba-tiba di layar. Ini mengganggu dan membingungkan. Selain itu, Anda selalu ingin melihat respons yang halus terhadap tindakan Anda, dan bukan kejang-kejang.



Ini membawa kami ke batasan teknis: kami memutuskan bahwa kami tidak boleh menutup lembar bawah saat ini dan menunjukkan yang baru untuk setiap perubahan layar, dan juga buruk untuk menunjukkan beberapa lembar bawah satu di atas yang lain. Jadi, dalam kerangka implementasi kami (setiap layar adalah fragmen baru), Anda hanya dapat membuat satu lembar terbawah, yang seharusnya bergerak selancar mungkin dalam menanggapi tindakan pengguna.



Ini berarti bahwa kita akan memiliki wadah fragmen yang tingginya dinamis (karena semua fragmen memiliki ketinggian yang berbeda), dan kita harus menghidupkan perubahan ketinggiannya.



Markup awal



Elemen akar dari "tirai" sangat sederhana - itu hanya latar belakang persegi panjang dengan sudut bulat di bagian atas dan wadah tempat fragmen ditempatkan.



<?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>


Dan file dialog_gray200_background.xml terlihat seperti ini:



<?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>


Setiap layar baru adalah fragmen yang terpisah, fragmen diganti menggunakan metode ganti, semuanya standar di sini.



Upaya pertama untuk menerapkan animasi



animateLayoutChanges



Mari kita ingat sihir elf kuno animateLayoutChanges , yang sebenarnya adalah LayoutTransition default. Meskipun animateLayoutChanges sama sekali tidak dirancang untuk mengubah fragmen, diharapkan ini akan membantu dengan animasi ketinggian. Juga FragmentContainerView tidak mendukung animateLayoutChanges, jadi kami mengubahnya ke FrameLayout lama yang baik.



<?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>


Lari:



animateLayoutChanges



Seperti yang Anda lihat, mengubah ketinggian wadah benar-benar dianimasikan saat mengganti fragmen. Pergi ke layar Pickup terlihat baik-baik saja, tetapi sisanya meninggalkan banyak yang diinginkan.



Intuition menunjukkan bahwa jalan ini akan mengarah ke mata perancang, jadi kami memutar balik perubahan kami dan mencoba sesuatu yang lain.



setCustomAnimations



FragmentTransaction memungkinkan Anda untuk mengatur animasi yang dijelaskan dalam format xml menggunakan metode setCustomAnimation . Untuk melakukan ini, di sumber daya, buat folder bernama "anim" dan tambahkan empat file animasi di sana:



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>


Dan kemudian kita mengatur animasi ini menjadi transaksi:



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


Kami mendapatkan hasil sebagai berikut:





setCustomAnimation



Apa yang kita miliki dengan implementasi ini:



  • Ini sudah menjadi lebih baik - Anda dapat melihat bagaimana layar saling menggantikan dalam menanggapi tindakan pengguna.
  • Tetapi masih ada lompatan karena ketinggian fragmen yang berbeda. Ini disebabkan oleh fakta bahwa ketika Anda memindahkan fragmen dalam hierarki, hanya ada satu fragmen. Dialah yang mengatur ketinggian wadah untuk dirinya sendiri, dan yang kedua menampilkan "bagaimana hal itu terjadi".
  • Masih ada masalah dengan pemuatan data yang tidak sinkron pada metode pembayaran - layar muncul pertama kali kosong, dan kemudian diisi dengan konten.


Ini tidak baik. Kesimpulan: Anda membutuhkan sesuatu yang lain.



Atau mungkin mencoba sesuatu yang tiba-tiba: Transisi Elemen Bersama



Sebagian besar pengembang Android tahu tentang Shared Element Transition. Namun, meskipun alat ini sangat fleksibel, banyak orang menghadapi masalah dalam menggunakannya dan karenanya tidak terlalu suka menggunakannya.





Esensinya cukup sederhana - kita dapat menghidupkan transisi elemen-elemen dari satu fragmen ke fragmen yang lain. Misalnya, kita dapat memindahkan elemen pada fragmen pertama (sebut saja "elemen awal") dengan animasi ke tempat elemen pada fragmen kedua (kita akan menyebut elemen ini "elemen terakhir"), sementara memudarkan sisa elemen dari fragmen pertama dan menampilkan fragmen kedua dengan fade. Elemen yang perlu dianimasikan dari satu fragmen ke fragmen lain disebut Elemen Bersama.



Untuk mengatur Elemen Bersama, kita perlu:



  • tandai elemen awal dan elemen akhir dengan atribut transisiName dengan nilai yang sama;
  • tentukan sharedElementEnterTransition untuk potongan kedua.


Bagaimana jika Anda menggunakan tampilan root fragmen sebagai Elemen Bersama? Mungkin Transisi Elemen Bersama tidak ditemukan untuk ini. Padahal, jika Anda memikirkannya, sulit untuk menemukan argumen mengapa solusi ini tidak akan berhasil. Kami ingin menghidupkan elemen awal ke elemen akhir di antara dua fragmen. Saya tidak melihat kontradiksi ideologis. Ayo coba ini!



Untuk setiap fragmen yang ada di dalam "tirai", untuk tampilan root, tentukan atribut transitionName dengan nilai yang sama:



<?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"
    >


Penting: Ini akan berfungsi karena kami menggunakan REPLACE dalam transaksi chunk. Jika Anda menggunakan ADD (atau menggunakan ADD dan menyembunyikan cuplikan sebelumnya dengan beforeFragment.hide () [jangan lakukan ini]), maka transisiName harus diatur secara dinamis dan dihapus setelah animasi selesai. Ini harus dilakukan, karena pada satu titik waktu dalam hierarki Tampilan saat ini tidak boleh ada dua Tampilan dengan nama transisi yang sama. Ini bisa dilakukan, tetapi akan lebih baik jika Anda bisa melakukannya tanpa peretasan seperti itu. Jika Anda benar-benar perlu menggunakan ADD, Anda dapat menemukan inspirasi untuk implementasi dalam artikel ini.


Selanjutnya, Anda perlu menentukan kelas Transisi, yang akan bertanggung jawab atas bagaimana transisi kami akan dilanjutkan. Pertama, mari kita periksa apa yang ada di luar kotak - gunakan AutoTransition .



newFragment.sharedElementEnterTransition = AutoTransition()


Dan kita harus mengatur Elemen Bersama yang ingin kita hidupkan dalam transaksi fragmen. Dalam kasus kami, ini akan menjadi Tampilan root dari fragmen:



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


Penting: Harap perhatikan bahwa nama transisi (seperti API Transisi keseluruhan) tersedia mulai dari Android Lollipop.


Mari kita lihat apa yang terjadi:









Transisi AutoTransisi berhasil, tetapi terlihat begitu-begitu. Ini karena selama transaksi chunk, hanya chunk baru dalam hierarki tampilan. Fragmen ini meregangkan atau mengecilkan ukuran wadah dan hanya setelah itu mulai hidup menggunakan transisi. Karena alasan inilah kita melihat animasi hanya ketika fragmen baru lebih tinggi daripada yang sebelumnya.



Karena penerapan standar tidak sesuai dengan kita, apa yang harus kita lakukan? Tentu saja, Anda perlu menulis ulang semua yang ada di Flutter dan menulis Transisi Anda sendiri!



Menulis Transisi Anda



Transition adalah kelas dari Transition API yang bertanggung jawab untuk membuat animasi antara dua adegan (Scene). Elemen utama API ini:



  • Scene adalah susunan elemen pada layar pada titik waktu tertentu (tata letak) dan ViewGroup tempat animasi berlangsung (sceneRoot).
  • Adegan Mulai adalah Adegan pada waktu mulai.
  • Adegan Akhir adalah Adegan pada titik akhir waktu.
  • Transition adalah kelas yang mengumpulkan properti adegan awal dan akhir dan membuat animator untuk menghidupkannya.


Kami akan menggunakan empat metode di kelas Transisi:



  • fun getTransitionProperties (): Array. Metode ini harus mengembalikan seperangkat properti yang akan dianimasikan. Dari metode ini, Anda perlu mengembalikan array string (kunci) dalam bentuk bebas, hal utama adalah bahwa metode captureStartValues ​​dan captureEndValues ​​(dijelaskan di bawah) menulis properti dengan kunci ini. Contoh akan mengikuti.
  • 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()




Agar animasi dimulai tepat waktu dan terlihat bagus, Anda hanya perlu menunda transisi antar fragmen (dan, sesuai dengan itu, animasi) hingga data dimuat. Untuk melakukan ini, panggil metode postponeEnterTransition di dalam fragmen . Ingatlah untuk memanggil startPostponedEnterTransition ketika Anda selesai dengan tugas pemuatan data yang panjang . Saya yakin Anda tahu tentang trik ini, tetapi tidak ada salahnya mengingatkan Anda sekali lagi.



Semuanya: apa yang terjadi pada akhirnya



Dengan BottomSheetSharedTransition baru dan menggunakan postponeEnterTransition saat memuat data secara tidak sinkron, kami mendapatkan animasi berikut:



Transisi siap



Di bawah spoiler ada kelas BottomSheetSharedTransition yang sudah jadi
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
}




Ketika kita memiliki kelas Transisi yang sudah jadi, aplikasinya turun ke langkah-langkah sederhana:



Langkah 1. Dalam transaksi fragmen, tambahkan Elemen Bersama dan setel Transisi:



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


Langkah 2. Di markup fragmen (fragmen saat ini dan yang berikutnya), yang harus dianimasikan di dalam BottomSheetDialogFragment, tetapkan nama transisi:



<?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"
    >


Itu saja, akhirnya.



Mungkinkah itu dilakukan secara berbeda?



Selalu ada beberapa opsi untuk memecahkan masalah. Saya ingin menyebutkan kemungkinan pendekatan lain yang belum kami coba:



  • Parit parit, gunakan satu fragmen dengan banyak Tampilan, dan animate Tampilan spesifik. Ini memberi Anda lebih banyak kontrol atas animasi, tetapi Anda kehilangan manfaat dari fragmen: dukungan navigasi asli dan penanganan siklus hidup yang out-of-the-box (Anda harus mengimplementasikannya sendiri).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles