从当前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

上面优化后面有空在出新篇

posted on 2026-01-09 15:44  翻滚的咸鱼  阅读(6)  评论(0)    收藏  举报