You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何实现切换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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.06 12:04:07