前言
当应用程序支持多种语言或主题时,切换语言或主题通常需要重新启动 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 实现的效果
Demo源码放在了最下面
步骤
大致分为以下几步:
- 设置Activity为全屏显示 确保Activity占据整个屏幕空间,去除状态栏和导航栏的影响。
- 添加隐藏的ImageView 在Activity原有的布局顶部添加一个占满全屏的ImageView,默认隐藏。 用于在主题切换后显示Activity重建前保存的Bitmap
- 修改主题后保存状态并重建activity 当用户切换主题时,先将当前Activity的decorView绘制为Bitmap保存到状态 recreate重新创建Activity以更新主题
- 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>
布局的显示效果
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 方法实现了以下步骤:
- 获取切换主题的 Toolbar 中的 menu 按钮中心点(后面用作圆形揭露动画的中心点)
- 将当前 Activity 绘制到 Bitmap
- 将这些数据赋值给 recreateTransitionData 属性
- 调用 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