前言
我之前在用 Telegram 的时候,无意中点击到了设置里的切换夜间按钮,然后被动画惊艳到了。当时我的 Android 技术实在有限,根本无法想象开发者是怎么做到日/夜间切换如此流畅且拥有动画的。我之前确实写过切换主题,但都是立即切换,毫无含金量,调个 recreate()
和 setTheme()
就解决了。当时在我的认知里,切换主题是需要重建(recreate) Activity 的。既然重建,重建之前的 activity 的很多东西几乎都不能保留,所以我想了好久都没想到处理的思路,再加上懒,就索性放弃了。
探寻 TG 源码
就在前些时候,我心血来潮,想彻底研究研究这是怎么实现的,就往 GitHub 上查了查 Telegram,看看有没有相关源码参考。不看不知道,一看吓一跳,Telegram 的源码竟然是屎山!平常使用的定位源码方式在 Telegram 源码里根本用不了。
然后我只好用 LibChecker 来看一眼那个界面是属于哪个 Activity,然后定点分析。Telegram 的 Activity 并不多,很好找,看样子应该在 LaunchActivity 里,因为其他 Activity 实在和主题、设置不搭边。随即我打开了 ui 文件夹,寻找 LaunchActivity,但令我奇怪的是,文件夹里怎么还有其他几十个 Activity?我随便打开了一个,震惊我一整天。
先别管这么多了,打开 LaunchActivity 找找吧。结果映入我眼帘的前 200 行全是 import,麻了。查关键字,果然找到了关键的字段!
java
private ImageView themeSwitchImageView;
private View themeSwitchSunView;
private RLottieDrawable themeSwitchSunDrawable;
定位到这些字段所处的位置,就可以开始研究了。代码很长,我就不贴过来占空间了,给个地址,大家可以去观摩下 [源代码],在第 6468 行开始(一共 8000 多行,实在太恐怖)。
具体思路就是:
- 代码开始通过从数组中提取参数,设置变量,并为动画准备UI。它获取
drawerLayoutContainer
的宽度和高度,根据toDark
布尔值设置darkThemeView
的可见性,并对drawerLayoutContainer
进行快照(也就是类似截图),以便将其设置给themeSwitchImageView
。 - 然后代码根据主题是否切换为暗色来设置
themeSwitchImageView
和themeSwitchSunView
。然后将themeSwitchImageView
设置为之前的位图快照,并使其可见。代码根据用户点击的位置和drawerLayoutContainer
的尺寸计算出环形揭示动画的最终半径(计算当前点击的控件距离与应用窗口的欧氏距离,看谁大要哪个)。 - 使用
ViewAnimationUtils#createCircularReveal
创建一个 Animator 对象,该对象将在drawerLayoutContainer
或themeSwitchImageView
上执行环形揭示动画,具体取决于主题是否切换为暗色。动画的持续时间设置为 400 毫秒,使用 缓动插值器 实现平滑的动画效果。 - 最后任务发布到 UI 线程,以在延迟后设置导航栏颜色并检查系统栏颜色。延迟时间是根据主题是否切换为暗色来计算的。最后开始动画。
总结就是:
java
if (toDark) {
frameLayout.addView(themeSwitchImageView, 0, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
themeSwitchSunView.setVisibility(View.GONE);
} else {
frameLayout.addView(themeSwitchImageView, 1, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
themeSwitchSunView.setTranslationX(pos[0] - AndroidUtilities.dp(14));
themeSwitchSunView.setTranslationY(pos[1] - AndroidUtilities.dp(14));
themeSwitchSunView.setVisibility(View.VISIBLE);
themeSwitchSunView.invalidate();
}
java
Animator anim = ViewAnimationUtils.createCircularReveal(toDark ? drawerLayoutContainer : themeSwitchImageView, pos[0], pos[1], toDark ? 0 : finalRadius, toDark ? finalRadius : 0);
- 切换到夜间前,ImageView 放在根 View (
frameLayout
)内层(也就是 index = 0, FrameLayout 中 View 的 index 越小越在内层),设置上位图快照(相当于对当前窗口进行截图),并且设置为 VISIBLE,然后对 次根 View (drawerLayoutContainer
)施加动画效果(由0
到finalRadius
逐渐扩散)并切换到夜间模式。 - 切换到日间前,ImageView 放在根 View 顶层,设置上位图快照,并且设置为 VISIBLE,然后直接对它施加动画效果(由
finalRadius
到0
逐渐缩小)并切换到日间模式。
其实不只是我,许多人看到了这种精妙的解决方式都觉得妙不可言。竟然还有这种实现方式的?
那我们应该怎么样才能把这种炫酷的环形揭示切换动画提取出来,给我们自己的 App 使用,或者做成一个库呢?我们可以看到,确实有一些厂商也使用了这种效果,比如酷安。
其他人的实现方式
我从 GitHub 上找到两款比较优质的库/实现(似乎做这种库的人比较少,stars 数都不多):
MaskAnim
[MaskAnim] 的作者也在掘金上写过类似的文章,指路 [安卓开发实现电报的主题切换动画效果]。
它的思路是:自定义 MaskView,在 onDraw()
方法里手动实现此 View 的扩散和收缩流程。将 MaskView 动态添加于 DecorView,根据传入的 MaskAnimModel 是 EXPAND
还是 SHRINK
来判断 MaskView 扩散还是收缩,动画结束之后,将 MaskView 移除于 DecorView。
所以可以看到 MaskView 是永远处于顶层的,而且没有使用 ImageView,跟 Telegram 的实现思路还有点不同。这种方法其实不错,拓展性也高(如果你想要其他动画),而且支持 Compose!但是如果对自定义 View 不熟悉的话,实现 MaskView 会有些难度。
CircularRevealThemesChanger
[CircularRevealThemesChanger] 是根据 这篇文章 写的。
它的思路是:将屏幕截图传给另一个 Activity 处理,另一个 Activity 用和 Telegram 一样的 ViewAnimationUtils#createCircularReveal
方法处理完动画之后再 finish()
。当然,在启动 Activity 和 finish Activity 的过程中添加了取消启动动画的 Flag。
这种方式确实有点拉了,为实现这么一个效果引入一个新 Activity 不太可取。
其他
还有的人是往自己 xml 里的根 ViewGroup 里定义一个 ImageView 然后实现动画,还有的人是直接通过 LayoutInflater.Factory2 直接将 xml 的 View 映射为自己定义的"傀儡" View。
实现方式属实有点多,但到底有没有稍微简单点的方式,比如:
kotlin
view.setDayNightModeSwitcher()
一句代码就能实现几乎所有功能呢?
其实 MaskAnim 也基本上实现了一句代码实现功能,但我从 Telegram 实现角度上重新组织一下。
TG 方向实现思路
我们先捋一下我们需要哪些函数才能实现这个功能:
-
判断当前是夜间还是日间模式:
javaAppCompatDelegate.getDefaultNightMode()
-
切换夜间或日间模式:
javaAppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_YES // or AppCompatDelegate.MODE_NIGHT_NO )
-
对当前整个屏幕进行截图:
kotlinprotected open fun Window.screenshot() = decorView.rootView.drawToBitmap()
-
...
第二个和第三个是比较重要的,因为我们在做切换动画的时候就指望这两个函数起作用。
⚠ 注意:Telegram 的实现方式并不是通过 recreate,具体可以参考源码实现。recreate 实现更直观。
收缩
假设收缩是切换至日间模式。按照普通思路,我们可以模拟出这样的伪代码:
kotlin
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束
这种思路是完全没有错的,但是有一个地方很容易被忽略,设置成夜间模式有一个 recreate 的过程,让我们观察 AppCompatDelegate#setDefaultNightMode
方法调用栈:
bash
AppCompatDelegate#setDefaultNightMode
↓
AppCompatDelegate#applyDayNightToActiveDelegates
↓
AppCompatDelegateImpl#applyDayNight
↓
AppCompatDelegateImpl#applyApplicationSpecificConfig
↓
AppCompatDelegateImpl#updateAppConfiguration
↓
ActivityCompat#recreate
在稍微有点深的嵌套中,最后调用了熟悉的 ActivityCompat#recreate
方法。那这个方法又干了什么呢,看看调用栈:
bash
ActivityCompat#recreate
↓
Activity#recreate
↓
ActivityThread#scheduleRelaunchActivity
↓
ActivityThread#scheduleRelaunchActivityIfPossible
↓
ActivityThread#sendMessage
↓
ActivityThread.H#sendMessage
↓
Handler#sendMessage
可以看到,recreate 就是给 ActivityThread 的 内部类 H 发送了一个异步消息,要求执行 recreate 作业。通过观察得知,H 类其实就是特殊的 Handler(Looper.getMainLooper())
,因为 ActivityThread 所处的线程就是主线程。
Handler 的工作原理如下:
- 使用 Handler 的
sendMessage()
或post()
方法,会将消息添加到 MessageQueue 中。 - Looper 不断从 MessageQueue 中检索消息。
- 当 Looper 检索到消息时,它会将其传递给相应的 Handler。
- Handler 的
handleMessage()
方法会处理消息。
众所周知,MessageQueue 是一个先进先出(FIFO)的队列,这确保如果向同一个 Handler 发送多个消息,则它们将按发送顺序处理。
这给了我们很好的思路,既然 recreate 发送了消息,假设该消息占用了消息队列的第一位,那我们占第一位的后面不就好了?而且,recreate 发送的是异步(Asynchronous)消息,不受同步屏障影响,所以基本上 recreate 是立即完成的,几乎不用考虑时间问题,我们可以在 recreate 后的新的 Activity 里执行动画。
让我们写一下新的伪代码:
kotlin
// 此时 Activity 为 旧 Activity(夜间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
MainHandler.post { // 将下面的消息传递在 recreate 后面
// 此时 Activity 为 新 Activity(日间模式)
RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束
}
这样就又完善了一步。但现在的问题是:RootView 到底选择哪个?我们需要在动画结束之后将 ImageView 移除,但此时 Activity 已经是新的了,里面的字段基本都被重建了,应该怎么办?
让我们再看看发送的 recreate 事件会在 handleMessage()
方法中执行什么样的调用栈:
less
ActivityThread#handleRelaunchActivityLocally
↓
ClientTransactionHandler#executeTransaction
↓
...
↓
TransactionExecutor#performLifecycleSequence
↓
ActivityThread#handleRelaunchActivity
↓
ActivityThread#handleRelaunchActivityInner
↓
ActivityThread#performPauseActivity #1
↓
ActivityThread#callActivityOnStop #2
↓
ActivityThread#handleDestroyActivity #3
↓
ActivityThread#performDestroyActivity
↓
ActivityThread#handleLaunchActivity
↓
ActivityThread#performLaunchActivity
↓ ↓
Activity#attach Activity#setTheme #5
↓
Instrumentation#callActivityOnCreate
↓
Activity#performCreate #4
↓
Activity#onCreate
很标准的 Pause → Stop → Destroy → Create → (Start → Resume)
这就是 handler#post
之后走向新 Activity 的原因,而且会走完 onResume()
方法。
看向 #5,这也是为什么 setTheme
必须要在 super.onCreate
前执行。
我们发现,Activity 重建后,不论是 context 还是 window,全部都被重建了(参考 Activity#attach
),唯一坚挺的就是 DecorView。
为什么这么说呢?在相关源码中明明 mWindow
都重新赋值了,DecorView 有什么理由不跟着变化呢?让我们看看 PhoneWindow 的构造器:
java
public PhoneWindow(Context context) {
super(context);
mLayoutInflater = LayoutInflater.from(context);
}
/**
* Constructor for main window of an activity.
*/
public PhoneWindow(Context context, Window preservedWindow,
ActivityConfigCallback activityConfigCallback) {
this(context);
// Only main activity windows use decor context, all the other windows depend on whatever
// context that was given to them.
mUseDecorContext = true;
if (preservedWindow != null) {
mDecor = (DecorView) preservedWindow.getDecorView();
mElevation = preservedWindow.getElevation();
mLoadElevation = false;
mForceDecorInstall = true;
// If we're preserving window, carry over the app token from the preserved
// window, as we'll be skipping the addView in handleResumeActivity(), and
// the token will not be updated as for a new window.
getAttributes().token = preservedWindow.getAttributes().token;
}
// Even though the device doesn't support picture-in-picture mode,
// an user can force using it through developer options.
boolean forceResizable = Settings.Global.getInt(context.getContentResolver(),
DEVELOPMENT_FORCE_RESIZABLE_ACTIVITIES, 0) != 0;
mSupportsPictureInPicture = forceResizable || context.getPackageManager().hasSystemFeature(
PackageManager.FEATURE_PICTURE_IN_PICTURE);
mActivityConfigCallback = activityConfigCallback;
}
Activity 中,走的是第二个构造器,可以看到 DecorView 是重复利用的。
⚠ 但请注意:这是 Android 7.0 及以上的源码,在 API 23 及之前,DecorView 是不重复利用的,PhoneWindow 只有第一个构造器!所以你在 API 23 及之前切换主题或者切换夜间模式的时候,会黑一下屏!所以我暂定把最低支持 API 设置为 24。
所以,我们就可以名正言顺的在 DecorView 中添加 ImageView 和删除 ImageView 了!而且 DecorView 位于当前 Activity 的最顶层,非常方便地就能实现全屏动画!
所以,我们又可以更新我们的伪代码:
kotlin
// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
// 此时 Activity 为 新 Activity(夜间模式)
// 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
// 非要调用最后也没效果
DecorView.addView(ImageView) // 给 DecorView 添加最前端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
ImageView.animate(onEnd = {
DecorView.removeView(ImageView)
}) // ImageView 进行动画,动画半径由最大 radius 到 0,结束后删除 ImageView
}
一个收缩动画就结束了。
扩张
假设扩张是切换至夜间模式。
扩张与收缩其实是差不多的,要注意我们需要扩张的是 DecorView 中的 content 界面,可以通过 findViewById()
寻找这个 次根 View。
kotlin
// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
// 此时 Activity 为 新 Activity(夜间模式)
// 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
// 非要调用最后也没效果
DecorView.addView(ImageView, 0) // 给 DecorView 添加最末端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
content = DecorView.findViewById(android.R.id.content) // 次根 View
content.animate(onEnd = {
DecorView.removeView(ImageView)
}) // content 进行动画,动画半径由 0 到最大 radius,结束后删除 ImageView
}
效果和 Telegram 是一模一样的,如果感兴趣,你可以调慢动画速度,然后在 Telegram 切换主题时滑动一下窗口试试,但内部实现方法还是有点差异。
通过这思路,你不仅可以实现日间夜间的切换,也可以实现主题的切换,只需要把这些相关代码包裹在 view 的 setOnClickListener()
和 setOnTouchListener()
里就可以了!
定位
我们更希望在点击的位置进行环形的扩张与收缩,这时候就需要当前点击位置的 XY 值。
这个就比较常见了:
kotlin
override fun onTouch(v: View, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
// Do not use event.rawX and event.rawY
// It is not accurate in floating window mode
// x = event.rawX
// y = event.rawY
v.getLocationInWindow(locationInWindow)
x = event.x + locationInWindow[0]
y = event.y + locationInWindow[1]
if (DEBUG) {
Log.d(TAG, "onTouch: x = $x, y = $y")
}
}
}
return false
}
正如注释所说,不要去直接使用 rawX
和 rawY
。这两个字段在全屏模式确实是准确的,但悬浮窗模式就不准了。
Logcat 观察
-
Activity#recreate
后执行Handler#post
直接到达onResume()
: -
DecorView 在 Activity 重建过后依然保持内存地址不变,而 Activity 和 Window 发生了变化:
整理成库
我已经将这些整理成了一个 CircularRevealSwitch 库,欢迎指点!
使用也很简单,实现最基础的功能,只需要:
关键代码在这里:[CircularRevealSwitch.kt]
目前存在的问题
- 使用 HarmonyOS 和 HyperOS(MIUI) 动画一切正常,经过观察,DecorView 是一直无变化的,符合预期。但是使用 Android Emulator 测试的时候,不知道为什么 DecorView 一直在变化,导致动画失效。
- 部分手机 DecorView 显示的速度会比后添加的 ImageView 快,导致闪屏问题(比如 HarmonyOS),我现在也没什么好办法解决,若有知道的可以评论区指点一下,感激不尽!