Android打造丝滑的Activity recreate重建(主题切换)过渡动画

3,376 阅读6分钟

前言

当应用程序支持多种语言或主题时,切换语言或主题通常需要重新启动 Activity 以重新加载配置。虽然 recreate 是一种常用的重建 Activity 方法,但它不支持像在 Activity 之间切换时那样使用过渡动画。特别是在切换 浅色/深色 主题时,由于缺乏过渡动画而显得很生硬。为了提升改善这一点,只能自己实现过渡动画以提供更流畅的用户体验。

一开始,我考虑在保存状态时使用 onSaveInstanceState 将 activity.window.decorView 绘制成位图并保存到 outState 中。然后在 onCreate 中读取该位图,并通过 WindowManager 在整个屏幕上显示一个铺满的 ImageView,将位图显示在 ImageView 上并执行动画。然而,我尝试后发现 WindowManager 的显示会比 Activity 晚一些,导致出现了闪屏的情况。

在我继续思考的过程中,偶然发现了一篇博客:Change Theme Dynamically with Circular Reveal Animation on Android。原来我与大佬的想法只有一步之差。该博客中的方法是在 Activity 的布局中添加一个铺满全屏的 ImageView,并将其 visibility 设置为 gone。这样,我们就可以在需要时将位图显示在 ImageView 上,而不需要使用 WindowManager。恍然大悟,我怎么没想到呢!🌟

效果

废话不多说,以下是 Demo 实现的效果

Activity recreate transition.gif

Demo源码放在了最下面

步骤

大致分为以下几步:

  1. 设置Activity为全屏显示 确保Activity占据整个屏幕空间,去除状态栏和导航栏的影响。
  2. 添加隐藏的ImageView 在Activity原有的布局顶部添加一个占满全屏的ImageView,默认隐藏。 用于在主题切换后显示Activity重建前保存的Bitmap
  3. 修改主题后保存状态并重建activity 当用户切换主题时,先将当前Activity的decorView绘制为Bitmap保存到状态 recreate重新创建Activity以更新主题
  4. activity重启后通过保存的状态执行动画 在Activity重建后,通过之前保存的状态恢复界面内容并执行揭露动画

将Activity设置为全屏

我这里使用一个BaseActivity来作为基础activity,实现了主题配置的加载和activity全屏的设置

/**
 * 基础 Activity
 * 实现了加载本地配置的主题和语言
 * @author Thousand-Dust
 */
abstract class BaseActivity : AppCompatActivity() {

    override fun attachBaseContext(newBase: Context) {
        // 加载本地配置的主题
        val theme = AppGlobals.getTheme()
        delegate.localNightMode = theme.mode

//        val config = newBase.resources.configuration
        // 加载本地配置的语言
//        val language = AppGlobals.getLanguage()
//        config.setLocale(language.locale)
//        val context = newBase.createConfigurationContext(config)
//        super.attachBaseContext(context)
        return super.attachBaseContext(newBase)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Activity全屏显示,隐藏状态栏和导航栏
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.setDecorFitsSystemWindows(false)
        } else {
            window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or
                    View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
                    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        }
        window.statusBarColor = Color.TRANSPARENT
        window.navigationBarColor = Color.TRANSPARENT
    }

}

在Activity原有的布局顶部添加一个隐藏的ImageView

随便写的布局,只需要关注ClipImageView就好了

<?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"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:title="@string/app_name"
        android:background="?attr/colorPrimary"
        android:paddingTop="10dp"
        app:menu="@menu/main_menu" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.355" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="48dp"
        android:text="Hello World!"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

    <com.td.demoactivityrecreatetransition.ClipImageView
        android:id="@+id/iv_transition"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="gone"/>

</androidx.constraintlayout.widget.ConstraintLayout>

布局的显示效果

image.png

ClipImageView是我为了方便使用动画实现的一个继承自ImageView的自定义View,在后面执行动画时用到

/**
 * 可以裁切的ImageView
 * @author Thousand-Dust
 */
class ClipImageView : androidx.appcompat.widget.AppCompatImageView {

    /**
     * 裁切类型
     */
    enum class ClipType {
        /**
         * 圆形
         */
        CIRCLE,
        /**
         * 圆形(反向裁切)
         */
        CIRCLE_REVERSE,
    }

    /**
     * 裁切类型
     */
    private var clipType = ClipType.CIRCLE

    /**
     * 裁切区域
     */
    private var clipPath = Path()

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    /**
     * 清空裁切
     */
    fun clearClip() {
        clipPath.reset()
        invalidate()
    }

    /**
     * 裁切圆形
     * @param centerX 圆心X
     * @param centerY 圆心Y
     * @param radius 半径
     * @param clipType 裁切类型
     */
    fun clipCircle(centerX: Float, centerY: Float, radius: Float, clipType: ClipType) {
        clipPath.reset()
        clipPath.addCircle(centerX, centerY, radius, Path.Direction.CW)
        this.clipType = clipType
        invalidate()
    }

    override fun onDraw(canvas: Canvas) {
        if (!clipPath.isEmpty) {
            canvas.save()
            when (clipType) {
                ClipType.CIRCLE -> {
                    // 裁切圆形
                    canvas.clipPath(clipPath)
                }

                ClipType.CIRCLE_REVERSE -> {
                    // 反向裁切圆形
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                        canvas.clipOutPath(clipPath)
                    } else {
                        canvas.clipPath(clipPath, Region.Op.DIFFERENCE)
                    }
                }
            }
        }
        // 绘制图片
        super.onDraw(canvas)

        if (!clipPath.isEmpty) {
            canvas.restore()
        }
    }

}

修改主题后保存状态并重建activity

这个Activity继承自上面实现的BaseActivity,因此无需关心设置主题和activity全屏显示的问题。 MainActivity 的 transitionRecreate 方法实现了以下步骤:

  1. 获取切换主题的 Toolbar 中的 menu 按钮中心点(后面用作圆形揭露动画的中心点)
  2. 将当前 Activity 绘制到 Bitmap
  3. 将这些数据赋值给 recreateTransitionData 属性
  4. 调用 recreate 方法开始重建 Activity

在recreate调用后,onSaveInstanceState 会被调用以保存状态,在这里将 recreateTransitionData 属性值保存到状态中

class MainActivity : BaseActivity() {

    private lateinit var toolbar: Toolbar
    private var recreateTransitionData: TransitionData? = null
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initView()
        ...
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)

        if (recreateTransitionData != null) {
            // 保存重建过渡动画 data 到状态
            outState.putParcelable(TRANSITION_DATA_KEY, recreateTransitionData)
        }
    }

    /**
     * 使用过渡动画重建(recreate)Activity
     */
    private fun transitionRecreate(type: TransitionType) {
        // 获取切换主题menu的坐标(以menu的中心点为圆形揭露动画的中心点)
        val menuItemView = toolbar.menu.findItem(R.id.menu_theme_toggle).let {
            toolbar.findViewById<View>(it.itemId)
        }
        val location = IntArray(2)
        menuItemView.getLocationOnScreen(location)
        val centerX = location[0] + menuItemView.width / 2f
        val centerY = location[1] + menuItemView.height / 2f
        // Activity截图
        val screenBitmap = window.decorView.drawToBitmap()
        recreateTransitionData = TransitionData(centerX, centerY, screenBitmap, type)
        // 重建Activity
        recreate()
    }

    private fun initView() {
        toolbar = findViewById(R.id.toolbar)
        ...
    }

}

还有以上代码用到的类代码贴在下边

// -------- AppGlobals.kt --------
object AppGlobals {

    const val THEME_KEY = "theme"

    lateinit var appContext: Context
        private set

    private lateinit var appConfigSP: SharedPreferences

    /**
     * Application创建时调用初始化
     */
    fun init(appContext: Context) {
        this.appContext = appContext
        appConfigSP = this.appContext.getSharedPreferences("AppConfig", Context.MODE_PRIVATE)
    }

    /**
     * 获取主题配置
     */
    fun getTheme(): AppTheme {
        val name = appConfigSP.getString(THEME_KEY, AppTheme.AUTO.name)!!
        return AppTheme.valueOf(name)
    }

    /**
     * 写入主题配置
     */
    fun setTheme(theme: AppTheme) {
        if (theme == AppTheme.AUTO) {
            // delete theme
            appConfigSP.edit().remove(THEME_KEY).apply()
            return
        }
        appConfigSP.edit().putString(THEME_KEY, theme.name).apply()
    }

}

/**
 * 支持的主题
 */
enum class AppTheme(val mode: Int) {
    AUTO(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
    LIGHT(AppCompatDelegate.MODE_NIGHT_NO),
    DARK(AppCompatDelegate.MODE_NIGHT_YES);

    companion object {
        fun byMode(mode: Int): AppTheme {
            return values().firstOrNull { it.mode == mode } ?: AUTO
        }
    }
}

// -------- RecreateTransition.kt --------
enum class TransitionType {
    /**
     * 进入
     */
    ENTER,

    /**
     * 退出
     */
    EXIT
}

/**
 * 重建过渡动画 data
 * 实现Parcelable接口,用于Activity重建时保存和恢复数据
 */
class TransitionData(
    val centerX: Float,
    val centerY: Float,
    val screenBitmap: Bitmap,
    val type: TransitionType,
) : Parcelable {
    constructor(parcel: android.os.Parcel) : this(
        parcel.readFloat(),
        parcel.readFloat(),
        parcel.readParcelable(Bitmap::class.java.classLoader)!!,
        TransitionType.valueOf(parcel.readString()!!)
    )

    override fun writeToParcel(parcel: android.os.Parcel, flags: Int) {
        parcel.writeFloat(centerX)
        parcel.writeFloat(centerY)
        parcel.writeParcelable(screenBitmap, flags)
        parcel.writeString(type.name)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<TransitionData> {
        override fun createFromParcel(parcel: android.os.Parcel): TransitionData {
            return TransitionData(parcel)
        }

        override fun newArray(size: Int): Array<TransitionData?> {
            return arrayOfNulls(size)
        }
    }
}

activity重启后通过保存的状态执行动画

在onCreate被调用时,通过保存的状态判断是否需要执行过渡动画

transitionAnimation 方法负责为Activity创建过渡动画。该方法接受一个TransitionData类型的参数,这个参数包含了动画所需的信息。 在方法的开始,ImageView ivTransition被设置为可见,并且其位图被设置为transitionData对象中的screenBitmap(Activity重建前绘制保存的显示内容)。 此时用户看到的 Activity 将呈现出 Activity 重建前的效果,从而营造出 Activity 尚未发生变化的假象。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    ...
    // 重建过渡动画
    if (savedInstanceState != null)
        savedInstanceState.getParcelable<TransitionData>(TRANSITION_DATA_KEY)?.let {
            transitionAnimation(it)
        }
}

/**
 * 过渡动画
 */
private fun transitionAnimation(transitionData: TransitionData) {
	// 使用隐藏的 ImageView 显示bitmap
    ivTransition.visibility = View.VISIBLE
    ivTransition.setImageBitmap(transitionData.screenBitmap)

    ivTransition.post {
        val animator = ValueAnimator.ofFloat()
        var clipType = ClipImageView.ClipType.CIRCLE
        when (transitionData.type) {
            TransitionType.ENTER -> {
            	// 进入动画,裁切掉圆内的区域 圆由小变大
                animator.setFloatValues(
                    0f,
                    hypot(ivTransition.width.toFloat(), ivTransition.height.toFloat())
                )
                clipType = ClipImageView.ClipType.CIRCLE_REVERSE
            }

            TransitionType.EXIT -> {
            	// 退出动画,裁切掉圆外的区域 圆由大变小
                animator.setFloatValues(
                    hypot(
                        ivTransition.width.toFloat(),
                        ivTransition.height.toFloat()
                    ),
                    0f
                )
                clipType = ClipImageView.ClipType.CIRCLE
            }
        }
        animator.duration =
            resources.getInteger(android.R.integer.config_longAnimTime).toLong()
        animator.addListener(
            onEnd = {
            	// 动画结束后隐藏 ImageView
                ivTransition.visibility = View.GONE
            }
        )
        animator.addUpdateListener {
            val radius = it.animatedValue as Float
            // 更新裁切区域
            ivTransition.clipCircle(
                transitionData.centerX,
                transitionData.centerY,
                radius,
                clipType
            )
        }
        animator.start()
    }
}

OK,大功告成,只需要在切换主题时调用transitionRecreate方法即可实现使用过渡动画重建activity

Demo源码

github.com/Thousand-Du…