Reveal Animation Transition between Fragments (from and to specific coordinate position)

Dikarenakan UX di aplikasi akhir-akhir ini dirasa cukup penting maka saya rasa perlu saya tulis di KB juga sekaligus sebagai pengingat pribadi.

Pertama untuk memudahkan kita buat model untuk konfigurasi animasi transisinya :

RevealAnimationConfig class :
data class RevealAnimationConfig(

        @NonNull
        var centerX: Int,

        @NonNull
        var centerY: Int,

        @NonNull
        var width: Int,

        @NonNull
        var height: Int
)

Pada data class diatas terdapat 4 variabel untuk menentukan posisi / koordinat dimana transisi harus “dimulai (saat membuka fragment)” dan “diakhiri (saat menutup fragment)”

  • var centerX: Int => koordinat X titik animasi
  • var centerY: Int => koordinat Y titik animasi
  • var width: Int => lebar (keseluruhan) dari view yang akan digunakan untuk fragment baru
  • var height: Int => tinggi(keseluruhan) dari view yang akan digunakan untuk fragment baru
RevealAnimationUtility object :

Pertama adalah beberapa baris optional code yang saya buat hanya untuk mempersingkat waktu apabila sedang bekerja dengan debugging agar tak banyak animasi yang dapat membuang banyak waktu :

fun getMediumDuration(context: Context): Int {
        return if (isAnimationEnabled()) {
            context.resources.getInteger(R.integer.config_mediumAnimTime)
        } else {
            0
        }
    }

//For the tests we don't want to waste any time on animations!
    fun isAnimationEnabled(): Boolean {
        return !BuildConfig.DEBUG
    }

Selanjutnya fungsi untuk menampilkan transisi warna awal dan warna akhir dengan durasi yang sudah kita tentukan pada fungsi getMediumDuration diatas :

private fun startBackgroundColorAnimation(view: View, startColor: Int, endColor: Int, duration: Int) {
        ValueAnimator().apply {
            setIntValues(startColor, endColor)
            setEvaluator(ArgbEvaluator())
            this.duration = duration.toLong()
            addUpdateListener { valueAnimator -> view.setBackgroundColor((valueAnimator.animatedValue as Int)) }
            start()
        }

Fungsi dibuat hanya agar reusable saja karena akan digunakan saat transisi membuka fragment dan menutupnya.

Lalu buat satu interface yang akan digunakan sebagai listener apabila transisi animasi telah selesai dengan durasi yang telah ditentukan :

interface AnimationFinishedListener {
        fun onAnimationFinished()
    }

Ini penting karena ini akan mencegah adanya kedipan pada layar seperti halnya kita membuka atau menutup activity / fragment secara normal karena view atau fragment terkait dapat di hide / remove (begitu juga sebaliknya) pada waktu yang tepat yaitu saat animasi transisi telah selesai.

Kemudian lanjut ke kedua fungsi untuk animasi transisi membuka dan menutup.

fun registerCircularRevealAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, startColor: Int, endColor: Int, listener: AnimationFinishedListener) {
        if (isAnimationEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            view.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
                
                override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
                    v.removeOnLayoutChangeListener(this)
                    val cx: Int = revealSettings.centerX
                    val cy: Int = revealSettings.centerY
                    val width: Int = revealSettings.width
                    val height: Int = revealSettings.height

                   
                    val finalRadius = sqrt(width * width + height * height.toDouble()).toFloat()
                    val anim = ViewAnimationUtils.createCircularReveal(v, cx, cy, 0f, finalRadius)
                    anim.duration = getMediumDuration(context).toLong()
                    anim.interpolator = FastOutSlowInInterpolator()
                    anim.addListener(object : AnimatorListenerAdapter() {
                        override fun onAnimationEnd(animation: Animator) {
                            listener.onAnimationFinished()
                        }
                    })
                    anim.start()
                    startBackgroundColorAnimation(view, startColor, endColor, getMediumDuration(context))
                }
            })
        } else {
            listener.onAnimationFinished()
        }
    }

Kondisi diatas sangat boleh dibuang karena saya gunakan hanya untuk case pada aplikasi terkait saja yang minimum SDK nya 21 / Lollipop dan isAnimationEnabled() diatas hanya pengecekan apakah BuildConfig-nya tidak sama dengan DEBUG saja.

Didalam fungsi tersebut terdapat beberapa parameter yang merupakan model dari data class config yang telah dibuat diatas. Namun ada satu variable yang masih kita butuhkan yaitu finalRadius yaitu luas dari view ( keseluruhan) yang akan digunakan.

Untuk mengukurnya saya tidak sengaja ingat saat salah persepsi tentang ukuran satuan inch pada sebuah televisi dan mendapat pencerahan disini.

Dan karena lupa lagi saya akhirnya menemukan rumusnya.

val finalRadius = sqrt(width * width + height * height.toDouble()).toFloat()

Kurang lebih seperti ini :

Hasil diatas dalam satuan pixel dan nilainya tergantung pada setiap device yang digunakan.

Lalu jalankan CircularReveal Animation-nya :

val anim = ViewAnimationUtils.createCircularReveal(v, cx, cy, 0f, finalRadius)
  • v => view yang akan di hide atau di show
  • cx => koordinat X center (dalam tulisan ini center X dari ExtendedFloatingActionButton)
  • cy => koordinat Y center
  • 0f => nilai animasi transisi dimulai
  • finalRadius => luas view yang sudah dihitung diatas

Kedua adalah fungsi saat ingin menutup fragment, karena akan aneh apabila animasi transisi hanya ada pada saat membuka saja :

fun startCircularRevealExitAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, startColor: Int, endColor: Int, listener: AnimationFinishedListener) {
        if (isAnimationEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            val cx: Int = revealSettings.centerX
            val cy: Int = revealSettings.centerY
            val width: Int = revealSettings.width
            val height: Int = revealSettings.height
            val initRadius = sqrt(width * width + height * height.toDouble()).toFloat()
            ViewAnimationUtils.createCircularReveal(view, cx, cy, initRadius, 0f).apply {
                this.duration = getMediumDuration(context).toLong()
                interpolator = FastOutSlowInInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        
                        view.hide()
                        listener.onAnimationFinished()
                    }
                })
                start()
            }
            startBackgroundColorAnimation(view, startColor, endColor, getMediumDuration(context))
        } else {
            listener.onAnimationFinished()
        }
    }

Penjelasan singkatnya disini ada 2 perbedaan yang sangat terlihat dimana pertama pada registerCircularRevealAnimation kita menaruh listener onAnimationFinished sedangkan pada startCircularRevealExitAnimation kita mengeksekusi Unit / fungsi pada saat animasi telah selesai yaitu menyembunyikan view (fragment) tersebut jadi apabila fragment telah di remove dari activity tidak akan terjadi blink atau kedipan pada layar.

Kedua yaitu pada pembuatan CircularReveal dimana nilai diagonal dan 0f ( nilai awal) yang kita hitung diatas tinggal kita balik saja.

Full Code of RevealAnimationUtility object :

/**
 * Created by rizkyagungramadhan@gmail.com
 * on 6/2/2020.
 */
object RevealAnimationUtility {
    fun getMediumDuration(context: Context): Int {
        return if (isAnimationEnabled()) {
            context.resources.getInteger(R.integer.config_mediumAnimTime)
        } else {
            0
        }
    }

    //For the tests we don't want to waste any time on animations!
    fun isAnimationEnabled(): Boolean {
        return !BuildConfig.DEBUG
    }

    @ColorInt
    private fun getColor(context: Context, @ColorRes colorId: Int): Int {
        return ContextCompat.getColor(context, colorId)
    }

    fun registerCircularRevealAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, startColor: Int, endColor: Int, listener: AnimationFinishedListener) {
        if (isAnimationEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            view.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
                @TargetApi(Build.VERSION_CODES.LOLLIPOP)
                override fun onLayoutChange(v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
                    v.removeOnLayoutChangeListener(this)
                    val cx: Int = revealSettings.centerX
                    val cy: Int = revealSettings.centerY
                    val width: Int = revealSettings.width
                    val height: Int = revealSettings.height

                    val finalRadius = sqrt(width * width + height * height.toDouble()).toFloat()
                    val anim = ViewAnimationUtils.createCircularReveal(v, cx, cy, 0f, finalRadius)
                    anim.duration = getMediumDuration(context).toLong()
                    anim.interpolator = FastOutSlowInInterpolator()
                    anim.addListener(object : AnimatorListenerAdapter() {
                        override fun onAnimationEnd(animation: Animator) {
                            listener.onAnimationFinished()
                        }
                    })
                    anim.start()
                    startBackgroundColorAnimation(view, startColor, endColor, getMediumDuration(context))
                }
            })
        } else {
            listener.onAnimationFinished()
        }
    }

    fun startCircularRevealExitAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, startColor: Int, endColor: Int, listener: AnimationFinishedListener) {
        if (isAnimationEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            val cx: Int = revealSettings.centerX
            val cy: Int = revealSettings.centerY
            val width: Int = revealSettings.width
            val height: Int = revealSettings.height
            val initRadius = sqrt(width * width + height * height.toDouble()).toFloat()
            ViewAnimationUtils.createCircularReveal(view, cx, cy, initRadius, 0f).apply {
                this.duration = getMediumDuration(context).toLong()
                interpolator = FastOutSlowInInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        view.hide()
                        listener.onAnimationFinished()
                    }
                })
                start()
            }
            startBackgroundColorAnimation(view, startColor, endColor, getMediumDuration(context))
        } else {
            listener.onAnimationFinished()
        }
    }

    private fun startBackgroundColorAnimation(view: View, startColor: Int, endColor: Int, duration: Int) {
        ValueAnimator().apply {
            setIntValues(startColor, endColor)
            setEvaluator(ArgbEvaluator())
            this.duration = duration.toLong()
            addUpdateListener { valueAnimator -> view.setBackgroundColor((valueAnimator.animatedValue as Int)) }
            start()
        }
    }

    //Specific cases
//    fun registerCreateShareLinkCircularRevealAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, listener: AnimationFinishedListener) {
//        registerCircularRevealAnimation(context, view, revealSettings, getColor(context, R.color.primary), getColor(context, android.R.color.white), listener)
//    }
//
//    fun startCreateShareLinkCircularRevealExitAnimation(context: Context, view: View, revealSettings: RevealAnimationConfig, listener: AnimationFinishedListener) {
//        startCircularRevealExitAnimation(context, view, revealSettings, getColor(context, android.R.color.white), getColor(context, R.color.primary), listener)
//    }

    interface AnimationFinishedListener {
        fun onAnimationFinished()
    }
}

Penggunaan :

Pertama penggunaan saat akan membuka fragment baru dan memulai transisi animasi. Sertakan variable untuk nantinya agar dapat dihitung oleh object RevealAnimationUtility :

val revealConfig = RevealAnimationConfig(view.width - (exfab_filter.width / 2), view.height - (exfab_filter.height), view.width, view.height)

(activity as MainActivity).supportFragmentManager.beginTransaction().add(R.id.container, FilterFragment()).addToBackStack(FilterFragment().tag).commit()

Kedua pada fragment yang akan ditampilkan (implementasi disini yaitu FilterFragment) :

Kita implementasikan dulu interface onAnimationFinished yang ada pada object RevealAnimationUtility, lalu pada onViewCreated langsung kita panggil fungsi RevealAnimationUtility.registerCircularRevealAnimation untuk memulai transisinya.

 context?.let {
            RevealAnimationUtility.registerCircularRevealAnimation(it, view, revealConfig, getColor(it, R.color.red_medium), getColor(it, android.R.color.white), this)
        }

Kemudian implementasikan listener onAnimationFinished pada salah satu event , kali ini saya implementasikan pada event onClick tombol.

RxView.clicks(bt_close).observeOn(AndroidSchedulers.mainThread())
                .subscribe {
                    RevealAnimationUtility.startCircularRevealExitAnimation(view.context, view, revealConfig, getColor(view.context, android.R.color.white), getColor(view.context, R.color.red_medium), object : RevealAnimationUtility.AnimationFinishedListener {
                        override fun onAnimationFinished() {
                            (activity as MainActivity).supportFragmentManager.beginTransaction().remove(FilterFragment()).commitAllowingStateLoss()
                        }
                    })
                }.addTo(compositeDisposable)

Pada onAnimationFinished baru kita remove FilterFragment ini sehingga akan kembali ke fragment sebelumnya dengan animasi transisi circular reveal.

Untuk hasilnya dapat dilihat pada video berikut :

Seperti biasanya, CMIIW.

Rizky Agung Ramadhan has written 10 articles

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>