目前,很多 Android 應用都有一個啟動界面 (Launch/Splash Screen),即應用在從桌面或應用抽屜啟動的過程中,顯示的一個有內容的界面,而不是一個空白頁;在應用運行時,啟動界面則不會出現。
根據 Material Design 的定義,啟動界面可分為兩種:
- 一種是應用在數據加載過程中顯示的不可交互的占位符界面 (Placeholder UI),它適用于應用啟動以及應用內 Activity 切換的場景,好處是讓用戶感知數據正在加載,而不致于誤認為應用無響應。在此暫不討論這種啟動界面。
- 另一種是專門在應用啟動時顯示的品牌頁 (Branded Launch Screen),它可以在應用啟動的短暫時間內展示應用的品牌信息,這有利于提升應用的品牌辨識度。例如以下三款應用的啟動界面都顯示了自家應用的 logo;另外,也可以顯示應用的品牌口號,不過要避免使用需要用戶仔細閱讀的大段文字。
分析啟動界面的概念,其重點應該在于:啟動界面僅在應用啟動的瞬間顯示。也就是說,啟動界面不能導致應用的啟動速度變慢,更不用說強制顯示一段時間了(下面有更多討論)。畢竟沒人是為了看啟動界面而打開應用的,為用戶提供他們關心的內容才是應用的首要任務。
基于這個原則,Android Development Patterns 項目組的 Ian Lake 在 Google+ 發了一個帖子,詳細敘述了打造啟動界面的“正確”方法。其主體思路是:在應用冷啟動時,也就是用戶點擊應用圖標到應用的 Launcher Activity 的 onCreate() 被調用的這段時間內,設備的窗口管理器 (Window Manager) 會根據應用的主題元素 (Theme) 繪制一個占位符界面,而我們就可以通過設置這個特定的主題元素,使這個占位符界面替換為有品牌內容的啟動界面。下面分步驟詳細描述。
- 設置主題元素
In res/values/styles.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name=”AppTheme.Launcher”>
<item name="android:windowBackground">@drawable/launch_screen</item>
<!-- Optional, on Android 5.0+ you can modify the colorPrimaryDark color to match the windowBackground color for further branding-->
<!-- <item name="colorPrimaryDark">@android:color/white</item> -->
</style>
</resources>
由于啟動界面只在應用啟動瞬間顯示,所以不能直接在 AppTheme 中直接修改主題元素,而是需要新建一個繼承 AppTheme 的 Launcher Theme,命名為 AppTheme.Launcher,并且只修改其中的 android:windowBackground
屬性為想要的 drawable 資源即可。對于 Android 5.0 及以上的設備,還可以修改 colorPrimaryDark
屬性為匹配啟動界面的顏色,提供更好的視覺效果。
- 定義啟動界面
In res/drawable/launch_screen.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<!-- The background color, preferably the same as your normal theme -->
<item android:drawable="@android:color/white"/>
<!-- Your product logo - 144dp color version of your app icon -->
<item>
<bitmap
android:src="@drawable/product_logo_144dp"
android:gravity="center"/>
</item>
</layer-list>
在上面 AppTheme.Launcher 主題中 android:windowBackground
屬性設置的 drawable 資源不能是一個圖片,因為圖片會拉伸至整個屏幕的尺寸;所以這里需要定義一個 XML 文件來設置啟動界面。注意 layer-list 的 android:opacity
透明度屬性要設置為 opaque(不透明),以防主題轉換時發生閃屏的情況。
- 切換應用主題
設置好 Launcher Activity 在應用啟動瞬間應用的主題后,即可在 AndroidManifest.xml 中設置好,這樣 Launcher Activity 就默認使用 AppTheme.Launcher 主題了,使啟動界面得以顯示。
In AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application ...>
<activity
android:name=".MainActivity"
android:theme="@style/AppTheme.Launcher">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
不過,在調用 Launcher Activity 的 onCreate() 時,其主題應該切換回 AppTheme,這里在 super.onCreate()
之前通過 setTheme(R.style.AppTheme)
設置好。
In MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
// Make sure this is before calling super.onCreate
setTheme(R.style.AppTheme);
super.onCreate(savedInstanceState);
// ...
}
}
至此,一個啟動界面就打造好了。上述這種實現方法的優勢是不會延長應用的啟動速度,是理論上最好的方案;但是有一個問題,即當 Launcher Activity 重啟時,啟動界面會重新顯示一遍。這個問題的解決方法是新建一個專門的 Splash Activity,使其作為應用的 Launcher Activity;也就是說,應用啟動時,啟動界面顯示完即跳到 Splash Activity 中,隨后再跳到其它 Activity。雖然這樣增加了 Activity 之間切換的延時,不過應用可以實現根據不同的啟動狀態跳轉到不同 Activity 的功能。在此不過多討論。
以上描述的啟動界面的實現方法是符合 Material Design 設計規范的,但是它有很多局限性,例如無法實現動畫,更不用說國內廠商非常熱衷的定時廣告頁面了。要實現這類啟動界面,就要為應用增加一個界面 (Activity & Layout),相當于在用戶與應用的實際內容之間人為地增加一層關系。這必須權衡利弊,因為即使再酷炫的啟動畫面,用戶也很快會審美疲勞。
下面介紹我在 FilmsPeek 應用中實現的啟動界面,它是一個關于應用 logo 的一秒鐘動畫。此項目托管在我的 GitHub 上,項目介紹已詳細寫在 README 上,歡迎大家 star 和 fork。
首先為專用于啟動界面的 Splash Activity 構建布局,基于 Material Design 只顯示應用 logo 或口號的設計規范,在 FilmPeek App 中只擺放了將應用 logo 拆解為“膠片”和“放大鏡”兩部分的兩個 ImageView,以及由于應用了使用 THE MOVIE DB (TMDb) API 而需要顯示其 logo 的 ImageView。為精簡篇幅,代碼不在此貼出,具體可在 GitHub FilmsPeek Repository 中查看。值得一提的是,對于這種扁平化的簡單布局,Android Support 庫提供的 ConstraintLayout 十分簡單易用,這也是 Google Android 團隊大力推廣的。
然后在 Splash Activity 中僅完成一件事即可,那就是使用 AnimationSet 讓應用 logo 的“放大鏡”部分完成一系列的動畫。例如,“放大鏡”首先需要往左直線移動一段距離,這首先可以通過設置一個 TranslateAnimation 對象,然后將該對象添加到 AnimationSet 來實現。
In SplashActivity.java
// Create an animation that make the lens icon move straight left.
Animation straightLeftAnimation = new TranslateAnimation(
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, -1,
Animation.RELATIVE_TO_SELF, 0,
Animation.RELATIVE_TO_SELF, 0);
straightLeftAnimation.setDuration(300);
// Set the LinearInterpolator to the animation to uniform its play velocity.
// The same as below.
straightLeftAnimation.setInterpolator(new LinearInterpolator());
// Add the animation to the set.
animation.addAnimation(straightLeftAnimation);
由于 Android 未提供做圓周運動的類,所以這里需要新建一個自定義 Animation 類,在 FilmsPeek App 中實現了順時針的、水平方向的半圓周運動。
In SplashActivity.java
private class SemicircleAnimation extends Animation {
private final float mFromXValue, mToXValue;
private float mRadius;
private SemicircleAnimation(float fromX, float toX) {
mFromXValue = fromX;
mToXValue = toX;
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
float fromXDelta = resolveSize(Animation.RELATIVE_TO_SELF, mFromXValue, width, parentWidth);
float toXDelta = resolveSize(Animation.RELATIVE_TO_SELF, mToXValue, width, parentWidth);
// Calculate the radius of the semicircle motion.
// Note: Radius can be negative here.
mRadius = (toXDelta - fromXDelta) / 2;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
float dx = 0;
float dy = 0;
if (interpolatedTime == 0) {
t.getMatrix().setTranslate(dx, dy);
return;
}
float angleDeg = (interpolatedTime * 180f) % 360;
float angleRad = (float) Math.toRadians(angleDeg);
dx = (float) (mRadius * (1 - Math.cos(angleRad)));
dy = (float) (-mRadius * Math.sin(angleRad));
t.getMatrix().setTranslate(dx, dy);
}
}
由于自定義類 SemicircleAnimation 只實現了水平方向的半圓周運動,所以其構造函數的輸入參數只需要水平方向上(X 軸)的起點與終點位置;而且輸入參數都是相對于自身而言的,例如 SemicircleAnimation(0, 2)
表示移動物體從當前位置出發,往右上角做半圓周運動,終點在距離兩倍于自身寬度的水平位置上。
動畫完成后,跳轉至 MainActivity 并調用 finish() 使 Splash Activity 不再出現。在 FilmsPeek App 中,通過設置 AnimationSet 的 AnimationListener 來實現。
In SplashActivity.java
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
// When animation set ended, intent to the MainActivity.
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
startActivity(intent);
// It's IMPORTANT to finish the SplashActivity, so user won't reach it afterwards.
finish();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
從上述代碼可見,自定義 Animation.AnimationListener 需要 override 三個方法,其中在 onAnimationEnd 方法中實現在動畫結束后的操作。
最后在 AndroidManifest 中設置 SplashActivity,使其作為應用的 Launcher Activity;同時也將 Splash Activity 的主題設置為無應用欄的。注意修改 MainActivity 的 intent-filter 屬性。
In AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.android.filmspeek">
<application ...>
<activity
android:name=".SplashActivity"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
本文主要觀點來自 Elvis Chidera 在 Medium 發表的文章,在此特表感謝。