博客 / 詳情

返回

View共享動效

從當前View過渡到另一個View,常規做法是針對View的座標跟大小一起做平移,如果針對視頻過渡,還更麻煩。

screen_preview

常規動效實現(這裏根據上面效果為例子),因為需要根據當前View的位置跟大小開始縮放過渡,並且過渡後的View樣式跟過渡前的有差異,參數都無法動態獲取

常規動效缺點:

1、動效參數難獲取,每次變更ui都要調整,很費時(ui上面透明區域變更,參數不是動態獲取的就要跟着調整,動效複雜的話調整很費勁)

2、視頻過渡麻煩,需要根據播放進度截圖等處理

3、不好在界面之間解耦,很多邏輯會冗餘

4、動效處理麻煩,需要針對座標跟大小動效

5、過渡View差異大的,動效時還需要專門繪製動效的view視圖,而不是直接過渡

而共享動效更絲滑,可以直接從ViewA過渡到ViewB,並且支持Activity到Activity或者Fragment到Fragment

下面提到的所有ViewA都是指過渡前的View,ViewB指過渡後的View

如果對動效要求不高,那麼實現很簡單,只需要在跳轉的時候標識為共享動效就行

原生的共享動效流程是在ViewA到ViewB時,自動對ViewA做過渡,動效可以自己定義,縮放平移漸變都行

而退出動效時,是對ViewB做動效,從B過渡到ViewA,所以這裏是被限制的

上面效果是自定義的動效,因為無論入場跟退場,都是針對ViewB去做的動效

上面動效還有一個問題,就是層級問題,這裏是有一個背景圖,背景圖中間有幾個透明區域,而ViewA則剛好填補了透明區域,所以看着他們就好像一個整體

共享動效的優勢:

1、動效參數動態獲取,只要調整好佈局,可以複用動效

2、過渡簡單,無論圖片還是視頻,都統一處理

3、界面解耦,可以在不同的Fragment或者Activity處理對應的邏輯

4、動效簡單,一個屬性動畫解決所有問題

5、動效直接過渡,不需要中間動效層

難點:

1、共享動效需要在同一共享層級下處理,否則無效

2、針對不規則區域,需要藉助UI來繪製區域(比如左邊的圓形區域)

3、背景蓋在View上面時,需要對層級進行處理,因為ViewB跟ViewA需要在同一共享層級,如果有背景圖的View蓋在A上面,B也會在背景圖下面,就會被圖片遮擋

這裏使用Fragment來實現動效,方便處理多個跳轉

首先在Activity中需要提供一個容器,用來加載Fragment,這是必須的

但是在此,需要先考慮上面的一個難點,就是背景圖怎麼處理,背景使用一個ImageView來單獨顯示,需要放在Activity中,否則跟共享動效會有衝突

但是如果放在Activity中,又會存在共享層級問題,背景View需要在ViewA的上層才行(如果都是矩形區域,就沒這個問題,我這邊有個不規則圓形,但是我們的viewA是矩形,所以得靠背景將邊上都蓋住)

這裏在我做之前想了挺久的,嘗試了好幾種方案,都因為共享層級衝突導致動效無法進行,最後得出了一個結論,那就是背景View必須在ViewA上面才行

嘗試的方案:

1、將背景View放底層,對不規則區域裁剪(直接放棄,這形狀不好處理,特別是我這邊還有視頻,裁不了一點)

2、將背景View放在Fragment中,每個Fragment都有一個背景(直接放棄,需求是過渡縮放,如果每個Fragment都有背景,那背景View也會跟着縮放)

3、將背景View放在FragmentA中,由A來顯示背景跟ViewA(直接放棄,跟Activity中沒什麼兩樣,背景View依然需要在上層,共享動效是ok了,但是過渡的ViewB被遮擋了)

4、動態調整視圖層級,背景View在上層,點擊動效時調整到下層(直接放棄,調整層級容易出問題,而且還有不規則的圓形ViewA,在層級變化時顯示有問題)

5、讓UI出土,將邊上的區域一起摳出來,這樣所有透明區域都是矩形,問題解決(繁瑣,並且對拼接的細節處需要剛剛好,但是方案可行)

6、終極方案,在Activity的容器中,將背景View設置在ViewA下面,動效時針對ViewB層級調整,完美解決,並且簡單

最終方案很簡單,主要就是需要一個思路,在所有View都處於同一共享層級下時,View層級就可以隨意調整了,Activity佈局如下

test_bg

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

    <FrameLayout
        android:id="@+id/root_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:id="@+id/iv_img"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:elevation="1dp"
            android:scaleType="fitXY"
            android:src="@drawable/test_bg"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

    </FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
ActivityMainBinding

這樣所有的View包括背景View都處於一個共享層級,然後將ViewA的Fragment先添加進去

import android.os.Bundle
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.example.myapplication.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private val mBinding by lazy {
        ActivityMainBinding::bind.invoke(findViewById<ViewGroup>(android.R.id.content).getChildAt(0))
    }
    private val TAG = "MainActivity"
    private val fragment = Test1Fragment()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
//        StrictMode.setThreadPolicy(
//            ThreadPolicy.Builder()
//                .detectCustomSlowCalls()
//                .detectDiskReads()
//                .detectNetwork()
//                .penaltyLog()
//                .build()
//        )
        supportFragmentManager.beginTransaction().replace(R.id.root_container, fragment)
            .commitAllowingStateLoss()
        val windowInsetsControllerCompat = WindowInsetsControllerCompat(window, window.decorView)
        windowInsetsControllerCompat.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
        window.setDecorFitsSystemWindows(false)
    }

}
MainActivity

基礎佈局很簡單,就是ViewA,調整好位置,剛好處於透明區域

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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:id="@+id/test1"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_dim"
        android:layout_width="336dp"
        android:layout_height="72dp"
        android:layout_marginStart="696dp"
        android:layout_marginTop="840dp"
        android:transitionName="shared_dim"
        tools:background="@color/black" />

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_ceiling"
        android:layout_width="663dp"
        android:layout_height="366dp"
        android:layout_marginStart="939dp"
        android:layout_marginTop="296dp"
        android:transitionName="shared_ceiling"
        tools:background="@color/black" />

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_csd"
        android:layout_width="385dp"
        android:layout_height="240dp"
        android:layout_marginStart="1081dp"
        android:layout_marginTop="775dp"
        android:transitionName="shared_csd"
        tools:background="@color/black" />

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_psd"
        android:layout_width="385dp"
        android:layout_height="240dp"
        android:layout_marginStart="1477dp"
        android:layout_marginTop="775dp"
        android:transitionName="shared_psd"
        tools:background="@color/black" />

</FrameLayout>
FragmentTest1Binding

image

dump佈局可以看到,背景View在最上面,test1在上層,所以在Activity中對背景view進行了 elevation 處理,讓背景始終在ViewA的上面,這樣就蓋住了所有的A佈局(對應下面test1佈局)

image

CustomVideo用來顯示過渡View,這裏ViewA跟ViewB都用的CustomVideo,只是ViewB加了黑色背景邊框用於區分

import android.content.Context
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.PixelCopy
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.graphics.createBitmap
import androidx.core.view.isVisible
import com.example.myapplication.databinding.LayoutVideoBinding
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player

class CustomVideo @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

    private val mBinding = LayoutVideoBinding.inflate(LayoutInflater.from(context), this)
    private val mHandler = Handler(Looper.getMainLooper())

    private val playListener = object : Player.Listener {
        override fun onPlayerError(error: PlaybackException) {
            super.onPlayerError(error)
            Log.e("CustomVideo", "onPlayerError $error")
            PlayerManager.releasePlayer(mBinding.videoData)
        }

        override fun onPlayerErrorChanged(error: PlaybackException?) {
            super.onPlayerErrorChanged(error)
            Log.e("CustomVideo", "onPlayerErrorChanged $error")
        }

        override fun onRenderedFirstFrame() {
            super.onRenderedFirstFrame()
            mBinding.ivImg.isVisible = false
        }
    }

    fun setImage(bitmap: Bitmap) {
        mBinding.ivImg.scaleType = ImageView.ScaleType.CENTER
        mBinding.ivImg.setImageBitmap(bitmap)
        mBinding.videoData.isVisible = false
    }

    fun start(bitmap: Bitmap? = null, position: Long = -1) {
        Log.d("CustomVideo", "start position=$position")
        mBinding.ivImg.setImageBitmap(bitmap)
        mBinding.ivImg.isVisible = bitmap != null
        if (position >= 0) {
            PlayerManager.startPlay(
                "/androidres/app_assets/com.zeekr.screensaver/picture_res/dynamic/8/csd/Metallic_caramel.mp4",
                mBinding.videoData,
                position,
                playListener
            )
        }
    }

    fun getCurrentFrameBitmap(callBack: (Bitmap, Long) -> Unit) {
        //此時視頻surface處於view.GONE狀態.
        if (mBinding.videoData.width <= 0 || mBinding.videoData.height <= 0) {
            Log.d("CustomVideo", "視頻沒準備好,且視頻控件處於可點擊狀態那麼直接返回")
            return
        }
        val currentPosition = PlayerManager.getCurrentPosition(mBinding.videoData)
        val bmp = createBitmap(mBinding.videoData.width, mBinding.videoData.height)
        Log.d("CustomVideo", "getCurrentFrameBitmap $currentPosition $bmp")
        PixelCopy.request(
            mBinding.videoData,
            bmp,
            { copyResult -> callBack.invoke(bmp, currentPosition) },
            mHandler
        )
    }

}
CustomVideo
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <SurfaceView
        android:id="@+id/video_data"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/iv_img"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="fitXY"
        android:src="@drawable/test1" />

</merge>
LayoutVideoBinding

在基礎佈局的Fragment中邏輯很簡單,只有點擊時間,跳轉到ViewB的Fragment

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.myapplication.databinding.FragmentTest1Binding

class Test1Fragment : Fragment() {

    private val TAG = "Test1Fragment"
    private lateinit var mBinding: FragmentTest1Binding
    private val testCsd = TestCsdFragment()
    private val testPsd = TestPsdFragment()
    private val testDim = TestDimFragment()
    private val testCeiling = TestCeilingFragment()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        mBinding = FragmentTest1Binding.inflate(inflater, container, false)
        return mBinding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        mBinding.ivCsd.start(position = 0)
        mBinding.ivCsd.setOnClickListener {
            mBinding.ivCsd.getCurrentFrameBitmap { bitmap, position ->
                testCsd.bitmap = bitmap
                testCsd.position = position
                val transaction = parentFragmentManager.beginTransaction()
                transaction.addSharedElement(mBinding.ivCsd, mBinding.ivCsd.transitionName)
                transaction.hide(this).add(R.id.root_container, testCsd)
                transaction.addToBackStack(null)
                transaction.commit()
            }
        }

        mBinding.ivPsd.setOnClickListener {
            val transaction = parentFragmentManager.beginTransaction()
            transaction.addSharedElement(mBinding.ivPsd, mBinding.ivPsd.transitionName)
            transaction.hide(this).add(R.id.root_container, testPsd)
            transaction.addToBackStack(null)
            transaction.commit()
        }

        mBinding.ivDim.setOnClickListener {
            val transaction = parentFragmentManager.beginTransaction()
            transaction.addSharedElement(mBinding.ivDim, mBinding.ivDim.transitionName)
            transaction.hide(this).add(R.id.root_container, testDim)
            transaction.addToBackStack(null)
            transaction.commit()
        }

        mBinding.ivCeiling.setOnClickListener {
            val transaction = parentFragmentManager.beginTransaction()
            transaction.addSharedElement(mBinding.ivCeiling, mBinding.ivCeiling.transitionName)
            transaction.hide(this).add(R.id.root_container, testCeiling)
            transaction.addToBackStack(null)
            transaction.commit()
        }
    }

}
Test1Fragment

需要注意的是 addSharedElement,添加要過渡的共享View,並且ViewA跟ViewB的 transitionName 需要保持一致

接下來是ViewB了,我這邊為了簡單,就直接為每個窗口都新建了一個測試的Fragment作為ViewB

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/test_psd"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/transparent"
    android:elevation="2dp"
    android:transitionName="shared_psd">

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_psd"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        android:padding="20dp" />

</FrameLayout>
FragmentTestPsdBinding
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.myapplication.databinding.FragmentTestPsdBinding


class TestPsdFragment : Fragment() {

    private lateinit var mBinding: FragmentTestPsdBinding

    init {
        sharedElementEnterTransition = CustomScaleTransition(true)
        sharedElementReturnTransition = CustomScaleTransition(false)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        mBinding = FragmentTestPsdBinding.inflate(inflater, container, false)
        return mBinding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        mBinding.ivPsd.setOnClickListener {
            parentFragmentManager.popBackStack()
        }
    }

}
TestPsdFragment

佈局作為測試demo很簡單,跟ViewA一樣,只是多了一個邊框用於區分

還有個細節就是 elevation 的處理,因為ViewB需要在最上層顯示,邏輯很簡單,如果是常規動效處理,要麻煩很多,並且無法複用

image

重點在 CustomScaleTransition 中,這個類主要用於自定義共享動效

import android.animation.Animator
import android.animation.ValueAnimator
import android.graphics.Rect
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.core.view.isVisible
import androidx.transition.Transition
import androidx.transition.TransitionValues
import kotlin.math.max
import kotlin.math.min

/**
 * 最終執行 DefaultSpecialEffectsController -> executeOperations -> startAnimations
 * startTransitions會返回一個startedTransitions集合,這個集合就是共享資源
 * executeOperations 方法被 SpecialEffectsController 的 executePendingOperations 調用

 * 通過 FragmentStateManage 中的 moveToExpectedState 方法判斷,enter時會通過 enqueueShow 等方法,添加 sharedView,所以需要 hide 操作,否則動效不會生效
 */
class CustomScaleTransition(private val isEnter: Boolean) : Transition() {

    companion object {
        private const val TAG = "CustomScaleTransition"
        private const val VIEW_BOUNDS = "view_bounds"
    }

    override fun captureStartValues(transitionValues: TransitionValues) {
        val view = transitionValues.view
        transitionValues.values.put(VIEW_BOUNDS, Rect(view.left, view.top, view.right, view.bottom))
    }

    override fun captureEndValues(transitionValues: TransitionValues) {
        val view = transitionValues.view
        transitionValues.values.put(VIEW_BOUNDS, Rect(view.left, view.top, view.right, view.bottom))
    }

    override fun createAnimator(
        sceneRoot: ViewGroup,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
        val test1View = if (isEnter) startValues.view else endValues.view
        val test2View = if (isEnter) endValues.view else startValues.view
        val startBounds = startValues.values[VIEW_BOUNDS] as Rect
        val endBounds = endValues.values[VIEW_BOUNDS] as Rect
        // 計算translation
        val tX = if (isEnter) {
            startBounds.centerX().toFloat() - endBounds.centerX()
        } else {
            endBounds.centerX().toFloat() - startBounds.centerX()
        }
        val tY = if (isEnter) {
            startBounds.centerY().toFloat() - endBounds.centerY()
        } else {
            endBounds.centerY().toFloat() - startBounds.centerY()
        }
        // 計算scale
        val minW = min(startBounds.width(), endBounds.width())
        val minH = min(startBounds.height(), endBounds.height())
        val maxW = max(startBounds.width(), endBounds.width())
        val maxH = max(startBounds.height(), endBounds.height())
        val myScaleX = minW.toFloat() / maxW
        val myScaleY = minH.toFloat() / maxH
        if (isEnter) {
            // hide狀態需要提前visible
            (test1View.parent as? ViewGroup)?.isVisible = true
        } else {
            // view層級處理
//            test1View.elevation = 1f
            // back後會被移除屏幕,需要重新add,在動效完成後remove
            val rootView = if (test2View.tag == "dim") {
                test2View.parent as? ViewGroup
            } else {
                test2View as? ViewGroup
            }
            rootView?.isVisible = true
            sceneRoot.addView(rootView)
        }
        Log.w(TAG, "tX=$tX,tY=$tY, sX=$myScaleX,sY=$myScaleY")
        return ValueAnimator.ofFloat(0f, 1f).apply {
            duration = 500
            interpolator = AccelerateInterpolator()
            addUpdateListener { animation ->
                val progress = animation.animatedValue as Float
                test2View?.apply {
                    (parent as? ViewGroup)?.findViewById<View>(R.id.blur_img)?.let {
                        it.alpha = if (isEnter) progress else 1 - progress
                    }
                    translationX = if (isEnter) (1 - progress) * tX else tX - ((1 - progress) * tX)
                    translationY = if (isEnter) (1 - progress) * tY else tY - ((1 - progress) * tY)
//                    Log.d(TAG, "update num=$progress,translationX=${translationX},translationY=$translationY")
                    scaleX = if (isEnter) {
                        myScaleX + (1 - myScaleX) * progress
                    } else {
                        myScaleX + (1 - progress) * (1 - myScaleX)
                    }
                    scaleY = if (isEnter) {
                        myScaleY + (1 - myScaleY) * progress
                    } else {
                        myScaleX + (1 - progress) * (1 - myScaleX)
                    }
                }
            }
            doOnEnd {
//                test1View.elevation = 0f
                (test1View.parent as? ViewGroup)?.findViewById<View>(R.id.blur_img)?.let {
                    it.alpha = 1f
                }
                if (isEnter) {
                    (test1View.parent as? ViewGroup)?.isVisible = false
                } else {
                    val rootView = if (test2View.tag == "dim") {
                        test2View.parent as? ViewGroup
                    } else {
                        test2View as? ViewGroup
                    }
                    rootView?.isVisible = false
                    sceneRoot.removeView(rootView)
                }
                Log.w(TAG, "animationEnd childCount=${sceneRoot.childCount}")
            }
        }
    }
}
CustomScaleTransition

可以看到,Transition 中,所有的參數都是原生計算好了可以直接獲取,如果是常規的動效處理,這些數據就是額外的邏輯,但是在共享動效中,只需要關注你的動效

重點講解

1、使用 isEnter 來區分是入場還是退場,因為這裏違反了原生的規則(上面有講),入場退場都是針對ViewB去做動效

2、如果是原生,只對 endValues.view 做動效,入場時 endValues.view是ViewA,退場時是ViewB,所以這裏專門處理了一下,test1View表示上面提到的ViewA,test2View表示ViewB

3、共享動效是通過Fragment的顯示隱藏狀態控制,如果狀態不對,動效無法執行,比如A跳轉到B,那麼A會被隱藏,但是我需要A顯示,在A的基礎上過渡到B,所以這裏需要特殊處理

4、退場時,ViewB的Fragment被 popBackStack,view被remove(原生是對ViewA動效,不影響),所以這裏想要針對ViewB退場,就需要讓ViewB可見(sceneRoot.addView(rootView))

到這裏,共享動效已經完成,點擊ViewA,直接從自身開始平移並且縮放,過渡到ViewB,退場時從原來的路徑返回到ViewA。

dim效果

開始效果圖中發現,在 iv_dim 中,對不規則圓形處理,還有高斯模糊背景過渡,這裏用的圖片合成

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/test_dim"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:elevation="2dp">

    <com.example.myapplication.BlurImageView
        android:id="@+id/blur_img"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="fitXY"
        android:src="@drawable/test1" />

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_dim"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:tag="dim"
        android:transitionName="shared_dim" />

</FrameLayout>
FragmentTestDimBinding
import android.annotation.SuppressLint
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap
import androidx.fragment.app.Fragment
import com.example.myapplication.databinding.FragmentTestDimBinding


class TestDimFragment : Fragment() {

    private lateinit var mBinding: FragmentTestDimBinding

    init {
        sharedElementEnterTransition = CustomScaleTransition(true)
        sharedElementReturnTransition = CustomScaleTransition(false)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        mBinding = FragmentTestDimBinding.inflate(inflater, container, false)
        return mBinding.root
    }

    @SuppressLint("UseCompatLoadingForDrawables")
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val bitmap = resources.getDrawable(R.drawable.test1_dim, context?.theme).toBitmap()
        val mask = BitmapFactory.decodeResource(resources, R.drawable.edit_dim_mask)
        val imageBmp = MaskImageUtil.applyMask(bitmap, mask)
        mBinding.blurImg.setBlurRadius(110f)
        mBinding.ivDim.setImage(imageBmp)
        mBinding.ivDim.setOnClickListener {
            parentFragmentManager.popBackStack()
        }
    }

}
TestDimFragment

首先小窗口圖片是一個矩形圖片

test1_dim

需要讓UI準備一張合成圖片,也就是不規則的圓形區域圖片,用於合成繪製

edit_dim_mask

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import androidx.core.graphics.createBitmap

object MaskImageUtil {
    
    @JvmStatic
    fun applyMask(src: Bitmap, mask: Bitmap): Bitmap {
        return createBitmap(src.width, src.height, src.config).apply {
            val canvas = Canvas(this)
            val paint = Paint(Paint.ANTI_ALIAS_FLAG)
            canvas.drawBitmap(src, 0f, 0f, paint)
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
            val scaleMask = scaleBitmap(src, mask)
            canvas.drawBitmap(scaleMask, 0f, 0f, paint)
            paint.xfermode = null
        }
    }

    private fun scaleBitmap(src: Bitmap, mask: Bitmap): Bitmap {
        return createBitmap(src.width, src.height, src.config).apply {
            val canvas = Canvas(this)
            val paint = Paint()
            val matrix = Matrix()
            val ratio = src.width.toFloat() / mask.width.toFloat()
            matrix.postScale(ratio, ratio)
            canvas.drawBitmap(mask, matrix, paint)
        }
    }
}
MaskImageUtil

使用 PorterDuff.Mode.DST_IN 模式繪製成圓形區域的形狀

高斯模糊使用上面的矩形圖片處理

open class BlurImageView(
    context: Context,
    attrs: AttributeSet
) : AppCompatImageView(
    context,
    attrs
) {
    private val mBlurNode = RenderNode("blur")

    init {
        mBlurNode.setRenderEffect(RenderEffect.createBlurEffect(400f, 400f, Shader.TileMode.CLAMP))
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mBlurNode.setPosition(0, 0, w, h)
    }

    override fun onDraw(canvas: Canvas) {
        val imageCanvas = mBlurNode.beginRecording()
        super.onDraw(imageCanvas)
        mBlurNode.endRecording()

        canvas.drawRenderNode(mBlurNode)
    }


    fun setBlurRadius(blurRadius: Float) {
        if (blurRadius > 0) {
            mBlurNode.setRenderEffect(
                RenderEffect.createBlurEffect(
                    blurRadius,
                    blurRadius,
                    Shader.TileMode.CLAMP
                )
            )
        } else {
            mBlurNode.setRenderEffect(null)
        }
    }
}
BlurImageView

講繪製好的新圖片 mBinding.ivDim.setImage(imageBmp),這樣就成了指定形狀的樣式了

視頻效果

針對視頻動效,這裏需要一個圖片去過渡,在ViewA進入ViewB之前,截取當前播放的視頻圖片,用於過渡到ViewB,等過渡完成後在ViewB中繼續播放ViewA進度的視頻

這裏視頻使用 ExoPlayer 播放器播放

import android.content.Context
import android.util.Log
import android.view.SurfaceView
import androidx.lifecycle.DefaultLifecycleObserver
import com.blankj.utilcode.util.Utils
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Player.Listener
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
import com.google.android.exoplayer2.mediacodec.MediaCodecUtil
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DefaultDataSource


object PlayerManager : DefaultLifecycleObserver {
    const val TAG = "PlayerManager"

    private val playerMutableMap = mutableMapOf<String, ExoPlayer>()

    private val listener: Listener = object : Listener {
        override fun onIsPlayingChanged(isPlaying: Boolean) {
            Log.d(TAG, "onIsPlayingChanged state -->$isPlaying")
        }

        override fun onPlayerErrorChanged(error: PlaybackException?) {
            Log.d(TAG, "onPlayerErrorChanged error -->$error")
        }

        override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
            Log.d(
                TAG,
                "onPlayWhenReadyChanged state ->$playWhenReady- reason -->$reason"
            )
        }

        override fun onPlayerError(error: PlaybackException) {
            Log.d(TAG, "onPlayerError error -->$error")
        }

        override fun onRenderedFirstFrame() {
            Log.d(TAG, "onRenderedFirstFrame")
        }
    }

    private fun createPlayer(url: String, position: Long = 0L): ExoPlayer {
        Log.d(TAG, "createPlayer")
        val renderersFactory = DefaultRenderersFactory(Utils.getApp())
            .setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunneling ->
                val allCodecs: List<MediaCodecInfo> =
                    MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, false)
                val softwareCodecs: MutableList<MediaCodecInfo> =
                    ArrayList()
                for (info in allCodecs) {
                    val name: String = info.toString().toLowerCase()
                    if (name.contains("sw") || name.contains("omx.google")) {
                        softwareCodecs.add(info)
                    }
                }
                if (softwareCodecs.isEmpty()) allCodecs else softwareCodecs
            }
        val player = ExoPlayer.Builder(Utils.getApp(), renderersFactory).build()
        player.trackSelectionParameters =
            player.trackSelectionParameters.buildUpon().setMaxVideoSize(1280, 720)
                .setMaxVideoFrameRate(30)
                .setMinVideoFrameRate(15)
                .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true)
                .setForceHighestSupportedBitrate(true)
                .build()
        player.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
        player.repeatMode = Player.REPEAT_MODE_ONE
        updateMediaSource(url, Utils.getApp()).let { player.setMediaSource(it) }
        player.prepare()
        player.addListener(listener)
        player.seekTo(position)
        player.playWhenReady = true
        return player
    }

    private fun updateMediaSource(url: String, mContext: Context): MediaSource {
        val dataSourceFactory = DefaultDataSource.Factory(mContext)
        return ProgressiveMediaSource.Factory(dataSourceFactory)
            .createMediaSource(MediaItem.fromUri(url))
    }

    @JvmStatic
    fun startPlay(
        url: String,
        surfaceView: SurfaceView,
        position: Long = 0L,
        listener: Listener
    ) {
        Log.d(
            TAG,
            "startPlay called start url $url , surfaceView $surfaceView , position $position"
        )
        val key = System.identityHashCode(surfaceView).toString()
        var player = playerMutableMap[key]
        if (player == null) {
            player = createPlayer(url, position)
            playerMutableMap[key] = player
        }
        player.addListener(listener)
        player.setVideoSurfaceView(surfaceView)
        if (!player.isPlaying) {
            Log.d(TAG, "startPlay called $surfaceView")
            player.play()
        }
    }

    @JvmStatic
    fun startPlay(url: String, surfaceView: SurfaceView, listener: Listener) {
        Log.d(TAG, "startPlay called start url $url , surfaceView $surfaceView")
        val key = System.identityHashCode(surfaceView).toString()
        var player = playerMutableMap[key]
        if (player == null) {
            player = createPlayer(url)
            playerMutableMap[key] = player
        }
        player.setVideoSurfaceView(surfaceView)
        player.addListener(listener)
        if (!player.isPlaying) {
            Log.d(TAG, "startPlay called $surfaceView")
            player.prepare()
            player.play()
        }
    }

    @JvmStatic
    fun stopPlay(surfaceView: SurfaceView) {
        val key = System.identityHashCode(surfaceView).toString()
        val player = playerMutableMap[key]
        if (player != null && player.isPlaying) {
            player.pause()
        }
    }

    @JvmStatic
    fun resumePlay(surfaceView: SurfaceView) {
        val key = System.identityHashCode(surfaceView).toString()
        val player = playerMutableMap[key]
        if (player != null && !player.isPlaying) {
            player.play()
        }
    }

    @JvmStatic
    fun releasePlayer(surfaceView: SurfaceView) {
        val key = System.identityHashCode(surfaceView).toString()
        val player = playerMutableMap[key]
        Log.d(TAG, "ReleasePlayer called $key player $player")
        if (player != null) {
            player.stop()
            player.clearVideoSurfaceView(surfaceView)
            player.removeListener(listener)
            player.release()
            playerMutableMap.remove(key)
            Log.d(TAG, "release called size = ${playerMutableMap.size}")
        }
    }

    @JvmStatic
    fun getCurrentPosition(surfaceView: SurfaceView): Long {
        val key = System.identityHashCode(surfaceView).toString()
        val player = playerMutableMap[key]
        if (player != null && player.isPlaying) {
            return player.currentPosition
        }
        return 0L
    }

}
PlayerManager

獲取當前播放進度跟截圖,然後在跳轉時傳遞給ViewB,這裏沒有做處理,只是簡單看看

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/test_csd"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/transparent"
    android:clipChildren="false"
    android:clipToPadding="false"
    android:elevation="2dp"
    android:transitionName="shared_csd">

    <com.example.myapplication.CustomVideo
        android:id="@+id/iv_csd"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        android:padding="20dp" />

</FrameLayout>
FragmentTestCsdBinding
import android.graphics.Bitmap
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.myapplication.databinding.FragmentTestCsdBinding

class TestCsdFragment : Fragment() {

    private lateinit var mBinding: FragmentTestCsdBinding

    lateinit var bitmap: Bitmap
    var position: Long = 0L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        sharedElementEnterTransition = CustomScaleTransition(true)
        sharedElementReturnTransition = CustomScaleTransition(false)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        mBinding = FragmentTestCsdBinding.inflate(inflater, container, false)
        return mBinding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        mBinding.ivCsd.start(bitmap, position)
        mBinding.ivCsd.setOnClickListener {
            parentFragmentManager.popBackStack()
        }
    }

}
TestCsdFragment

無論UI如何變更,只要xml調整好位置,其它全部都能複用,省時省力

優化空間:

1、存在內存泄露,需要處理,上面只做簡單展示

2、播放器可以使用 SurfaceControl 複用,這樣可以在多個界面共享一個視圖View

上面優化後面有空在出新篇

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.