|
从当前View过渡到另一个View,常规做法是针对View的坐标跟大小一起做平移,如果针对视频过渡,还更麻烦。
常规动效实现(这里根据上面效果为例子),因为需要根据当前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布局如下

<?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布局)
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需要在最上层显示,逻辑很简单,如果是常规动效处理,要麻烦很多,并且无法复用
重点在 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
首先小窗口图片是一个矩形图片
需要让UI准备一张合成图片,也就是不规则的圆形区域图片,用于合成绘制

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
上面优化后面有空在出新篇
来源:https://www.cnblogs.com/LiuZhen/p/19461453 |