如何实现切换DayNight主题时的圆形揭露动画(仿Telegram/VK)
实现无需重建Activity的主题切换+圆形揭露动画
我之前也踩过这个坑——系统默认的AppCompatDelegate.setDefaultNightMode()确实会强制重建Activity,根本没法和揭露动画流畅结合。要做到像Telegram/VK那样的丝滑效果,核心是绕过系统的Activity重建机制,手动把控主题切换和动画的时序,下面是一步步的落地方案:
1. 先阻止系统自动重建Activity
首先在AndroidManifest.xml里给目标Activity添加configChanges属性,告诉系统我们自己处理UI模式变化,别随便重建Activity:
<activity android:name=".MainActivity" android:configChanges="uiMode"> <!-- 其他原有属性保留 --> </activity>
2. 自定义主题切换逻辑(摆脱系统重建依赖)
我们要手动切换当前Activity的主题资源,而不是依赖系统的自动重建。先准备好日间和夜间主题:
<!-- res/values/styles.xml --> <style name="AppTheme.Day" parent="Theme.MaterialComponents.Light.NoActionBar"> <!-- 自定义日间主题属性,比如背景色、文本色 --> </style> <!-- res/values-night/styles.xml --> <style name="AppTheme.Night" parent="Theme.MaterialComponents.Dark.NoActionBar"> <!-- 自定义夜间主题属性 --> </style>
然后在Activity里维护主题状态,切换时先触发动画,动画结束后再更新主题:
private var isDarkMode = false private fun toggleTheme() { isDarkMode = !isDarkMode // 先启动揭露动画,动画结束后再更新主题 startCircularRevealAnimation() } private fun applyNewTheme() { val targetTheme = if (isDarkMode) R.style.AppTheme_Night else R.style.AppTheme_Day setTheme(targetTheme) // 手动刷新所有View的主题(setTheme不会自动更新已加载的View) refreshAllViewsTheme(findViewById(android.R.id.content)) } // 递归遍历所有子View,更新主题样式 private fun refreshAllViewsTheme(view: View) { if (view is ViewGroup) { for (i in 0 until view.childCount) { refreshAllViewsTheme(view.getChildAt(i)) } } // 重新应用主题到当前View context?.theme?.applyStyle(if (isDarkMode) R.style.AppTheme_Night else R.style.AppTheme_Day, true) // 针对不同控件类型做针对性更新(示例) when (view) { is TextView -> view.setTextColor(ContextCompat.getColor(context, if (isDarkMode) R.color.text_dark else R.color.text_light)) is View -> view.setBackgroundColor(ContextCompat.getColor(context, if (isDarkMode) R.color.bg_dark else R.color.bg_light)) // 比如Toolbar、RecyclerView这类复杂控件,可能需要额外调用invalidate或重新设置适配器 } }
3. 实现圆形揭露动画(核心视觉效果)
动画要从切换按钮的位置开始,覆盖整个屏幕。我们可以创建一个全屏的临时View,添加到窗口最顶层,对它执行揭露动画,动画结束后移除并更新主题:
private fun startCircularRevealAnimation() { val toggleBtn = findViewById<View>(R.id.theme_toggle_btn) // 获取按钮在屏幕中的中心坐标 val centerX = toggleBtn.x + toggleBtn.width / 2 val centerY = toggleBtn.y + toggleBtn.height / 2 // 创建全屏覆盖View,背景设为目标主题的颜色 val revealView = View(this) revealView.setBackgroundColor(ContextCompat.getColor(this, if (isDarkMode) R.color.bg_light else R.color.bg_dark)) val rootView = findViewById<ViewGroup>(android.R.id.content) rootView.addView(revealView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) // 计算动画的最大半径(从按钮到屏幕最远角的距离) val maxRadius = hypot( max(centerX, rootView.width - centerX), max(centerY, rootView.height - centerY) ).toInt() // 创建揭露动画 val revealAnimator = ViewAnimationUtils.createCircularReveal( revealView, centerX.toInt(), centerY.toInt(), 0f, maxRadius.toFloat() ) revealAnimator.duration = 400 // 和Telegram/VK的动画时长保持一致,提升体验 // 动画结束后的回调:移除临时View,更新主题并保存状态 revealAnimator.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator?) {} override fun onAnimationEnd(animation: Animator?) { rootView.removeView(revealView) applyNewTheme() saveThemeState(isDarkMode) } override fun onAnimationCancel(animation: Animator?) {} override fun onAnimationRepeat(animation: Animator?) {} }) revealAnimator.start() }
4. 主题状态持久化
记得把当前主题状态保存到SharedPreferences,App启动时自动恢复:
private fun saveThemeState(isDark: Boolean) { getSharedPreferences("ThemePrefs", MODE_PRIVATE) .edit() .putBoolean("isDarkMode", isDark) .apply() } private fun loadThemeState() { isDarkMode = getSharedPreferences("ThemePrefs", MODE_PRIVATE) .getBoolean("isDarkMode", false) applyNewTheme() } // 在onCreate中加载主题状态 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadThemeState() setContentView(R.layout.activity_main) // 设置切换按钮的点击事件 findViewById<View>(R.id.theme_toggle_btn).setOnClickListener { toggleTheme() } }
关键注意事项
- 复杂控件(如RecyclerView、BottomNavigationView)可能需要额外处理,比如调用
invalidate()或重新设置适配器,确保所有子元素都更新样式。 - 如果使用DataBinding/ViewBinding,可以批量绑定主题属性,避免递归遍历View的繁琐操作。
- 动画时长建议控制在300-400ms,和主流应用的节奏对齐,避免用户觉得卡顿或突兀。
这样实现出来的效果就和Telegram/VK完全一致了:点击切换按钮,从按钮位置展开圆形揭露动画,动画覆盖全屏后无缝切换主题,全程不需要重建Activity。
内容的提问来源于stack exchange,提问作者Darth Vader
相关产品推荐
相关产品推荐

