Android实现底部导航栏效果
目前网上主流的文章都是用底部的 RadioGroup + 页面部分的 Fragment 实现导航栏切换页面效果的。
然而底部的 RadioGroup 是如此麻烦,每个按钮的图片和文字部分都要做一个 selector 用于表示选中和非选中两种状态时的样式。
另外 Fragment 也有很多坑,先不管大家是否已熟练掌握,反正我是看着看着就学不下去了,所以我另辟蹊径用 Activity 的方式实现了伪 Fragment 的效果。
这里我们就来做一个三个按钮的底部导航栏。
因为我们这里是用三个 Activity 实现三个页面,而并非一个 Activity 中的三个 Fragment,所以在此之前,我们需要建立一个管理活动堆栈的类,以便在程序退出时能直接结束堆栈中的所有活动,不至于要依次退出三个 Activity。
在 java 代码目录里新建一个 base 包,在包内新建文件 AppManager.java:
/** * AppManager: 用于对活动进行管理。 * 该模块仅限 base 包内使用。 * 该模块为单一实例,您需要调用 AppManager.get() 获取实例后再调用方法。 * <p> * 为确保应用管理器正常工作,请新建一个继承 Activity 的抽象类 BaseActivity, * 然后重写 BaseActivity 类的 onCreate() 和 onDestroy() 方法。 * 请给 BaseActivity 类的 onCreate() 方法添加如下代码: * AppManager.get().addActivity(this); * 请给 BaseActivity 类的 onDestroy() 方法添加如下代码: * AppManager.get().removeActivity(this); * 最后,确保本 APP 内的所有活动类均继承于 BaseActivity 类。 */ class AppManager { private static AppManager sManager = new AppManager(); private Stack<BaseActivity> mActivities; private AppManager() { // 将作用域关键字设置为 private 以隐藏该类的构造器。 // 该类的单例由 get() 方法引用。 // 创建单例的同时创建活动堆栈。 mActivities = new Stack<>(); } // AppManager() (Class Constructor) /** * get(): 获得 AppManager 类的单例。 * * @return 该类的单例 sManager。 */ static AppManager get() { return sManager; } // get() /** * addActivity(): 向堆栈中添加一个活动对象。 * * @param activity 要添加的活动对象。 */ void addActivity(BaseActivity activity) { mActivities.add(activity); } // addActivity() /** * removeActivity(): 从堆栈中移除一个活动对象。 * * @param activity 要移除的活动对象。 */ void removeActivity(BaseActivity activity) { mActivities.remove(activity); } // removeActivity() /** * finishAllExcept(): 除一个特定活动外,结束堆栈中其余所有活动。 * 结束活动时会触发 BaseActivity 类的 onDestroy()方法, * 堆栈中的活动对象会同步移除。 * * @param activityClass 要保留的活动的类名(xxxActivity.class) */ void finishAllExcept(Class activityClass) { int i, len; BaseActivity[] activities; // 结束活动时会调用活动的 onDestroy() 方法,堆栈的内容会实时改变 // 为避免因此引起的引用错误,先将堆栈的内容复制到一个临时数组里 activities = mActivities.toArray(new BaseActivity[0]); len = activities.length; for (i = 0; i < len; ++i) { if (!activities[i].getClass().equals(activityClass)) { // 从数组里引用活动对象并结束,堆栈内容的改变不影响数组 activities[i].finish(); } // if (!activities[i].getClass().equals(activityClass)) } // for (i = 0; i < len; ++i) } // finishAllExcept() /** * finishAllActivities(): 结束堆栈中的所有活动。 * 结束活动时会触发 BaseActivity 类的 onDestroy()方法, * 堆栈中的活动对象会同步移除。 */ void finishAllActivities() { int i, len; BaseActivity[] activities; // 结束活动时会调用活动的 onDestroy() 方法,堆栈的内容会实时改变 // 为避免因此引起的引用错误,先将堆栈的内容复制到一个临时数组里 activities = mActivities.toArray(new BaseActivity[0]); len = activities.length; for (i = 0; i < len; ++i) { // 从数组里引用活动对象并结束,堆栈内容的改变不影响数组 activities[i].finish(); } // for (i = 0; i < len; ++i) } // finishAllActivities() } // AppManager Class // E.O.F
上述代码粘贴完后会报错,别着急,那是因为我们还没有建立和管理器相关联的 BaseActivity 类。现在我们在 base 包内再新建一个 BaseActivity.java,封装活动间的跳转方法。其中 jumpTo() 方法表示跳转后活动堆栈中只保留跳转后的那一个活动,压在堆栈中的其他活动全部销毁;而 open() 方法则保留活动堆栈。另外还有一个 showExitDialog() 的方法,用于询问用户是否退出程序,当用户选择“是”时,将堆栈中的所有活动一次性全部销毁。
/** * BaseActivity: 该抽象类定义所有活动均拥有的共同属性。 * 本 APP 中所有活动对象均继承此类。 */ public abstract class BaseActivity extends Activity { /** * onCreate(): 重写父类的 onCreate() 方法,向应用管理器中添加本活动。 */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppManager.get().addActivity(this); } // onCreate() /** * onDestroy(): 重写父类的 onDestroy() 方法,从应用管理器中移除本活动。 */ @Override protected void onDestroy() { super.onDestroy(); AppManager.get().removeActivity(this); } // onDestroy() /** * jumpTo(): 实现不传参的活动间跳转。 * * @param dst 要跳转到的活动的类名(xxxActivity.class)。 */ protected void jumpTo(Class dst) { Intent intent = new Intent(this, dst); startActivity(intent); AppManager.get().finishAllExcept(dst); } // jumpTo() /** * open(): 将当前活动压入堆栈,打开一个新活动。 * * @param dst 要打开的活动的类名(xxxActivity.class)。 */ protected void open(Class dst) { Intent intent = new Intent(this, dst); startActivity(intent); } // open() /** * showExitDialog(): 显示退出程序对话框,询问用户是否退出程序。 */ protected void showExitDialog() { AlertDialog dialog; DialogInterface.OnClickListener onClick; onClick = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { switch (which) { case DialogInterface.BUTTON_POSITIVE: // 确定按钮 dialog.dismiss(); AppManager.get().finishAllActivities(); break; // case DialogInterface.BUTTON_POSITIVE case DialogInterface.BUTTON_NEGATIVE: // 取消按钮 dialog.dismiss(); break; // case DialogInterface.BUTTON_NEGATIVE default: break; // default } // switch (which) } // onClick() }; // onClick = new DialogInterface.OnClickListener() // 显示对话框 dialog = new AlertDialog.Builder(this) .setMessage(R.string.dlgExitMsg) // "确定要退出吗?" .setPositiveButton(android.R.string.ok, onClick) .setNegativeButton(android.R.string.cancel, onClick) .create(); // dialog = new AlertDialog.Builder(this)... dialog.show(); } // showExitDialog() } // BaseActivity Abstract Class // E.O.F
上述代码粘贴完后还是有一处报错。这是因为这段代码中用到了一处尚未定义的字符串资源。我们打开 res/values 目录下的 strings.xml 文件,添加字符串资源:
<?xml version="1.0" encoding="utf-8"?> <resources> <!-- 这个字符串是你的工程名称,可以自己取,不一定要是 My Application --> <string name="app_name">My Application</string> <!-- 这个是我们新添加的字符串 --> <string name="dlgExitMsg">确定要退出吗?</string> <!-- 顺便把之后要用到的字符串也一并准备了 --> <string name="btnNavHome">主页</string> <string name="btnNavMessage">消息</string> <string name="btnNavSettings">设置</string> </resources>
至此,报错全部消除。
现在我们建立底部导航栏的三个按钮对应的三个 Activity 页面。
准备导航栏的图标资源,放入 res/drawable 文件夹内:
打开 res/values/colors.xml 文件,定义导航栏相关颜色(背景、选中颜色、非选中颜色)
<?xml version="1.0" encoding="utf-8"?> <resources> <!-- 定义导航栏的相关颜色 --> <color name="navBack">#e0e0e0</color> <!-- 导航栏背景色 Grey 300--> <color name="navNormal">#000000</color> <!-- 未激活项目的文字颜色 Black --> <color name="navActivated">#039be5</color> <!-- 已激活项目的文字颜色 Light Blue 600 --> </resources>
新建三个活动 HomeActivity、MessageActivity 和 SettingsActivity。
MainActivity 的布局文件 activity_main.xml 如下:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.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" android:background="@android:color/holo_blue_dark" tools:context=".HomeActivity"> <LinearLayout android:id="@+id/llHomePage" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp" android:layout_marginBottom="8dp" android:orientation="vertical" app:layout_constraintBottom_toTopOf="@+id/llHomeNav" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <!-- 该 LinearLayout 为主体页面布局的容器,你也可以根据需要换成其他形式的 Layout 以上代码将页面布局容器和底部的导航栏进行了约束 即该容器的底端和导航栏的顶端彼此约束 确保该容器的占用空间不会覆盖导航栏 页面的主体布局请在该容器内部(即此处)创建 --> </LinearLayout> <!-- 以下为导航栏的布局 --> <LinearLayout android:id="@+id/llHomeNav" style="?android:attr/buttonBarStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:background="@color/navBack" android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/llHomePage"> <!-- 【主页】活动中,【主页】按钮设为已激活样式,注意 drawableTop 和 textColor 属性的值 --> <Button android:id="@+id/btnNavHome" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/home1" android:onClick="onNavButtonsTapped" android:text="@string/btnNavHome" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navActivated" /> <!-- 其他按钮设为未激活样式,注意 drawableTop 和 textColor 属性的值 --> <Button android:id="@+id/btnNavMessage" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/message0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavMessage" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> <Button android:id="@+id/btnNavSettings" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/settings0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavSettings" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> </LinearLayout> </android.support.constraint.ConstraintLayout>
切换到预览页面看一下,已经有了底部导航栏的雏形:
现在打开代码文件 HomeActivity.java 编写点击导航栏按钮时的活动跳转代码:
public class HomeActivity extends BaseActivity { // 请注意此处继承的是 BaseActivity 而不是 Activity /** * onCreate(): 活动创建时触发。 */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_home); } // onCreate() /** * onNavButtonsTapped(): 点击导航栏上的标签时触发。 * * @param v 点击的按钮对象,用 v.getId() 获取其资源 ID。 */ public void onNavButtonsTapped(View v) { switch (v.getId()) { case R.id.btnNavMessage: open(MessageActivity.class); break; // case R.id.btnNavMessage case R.id.btnNavSettings: open(SettingsActivity.class); break; // case R.id.btnNavSettings } // switch (v.getId()) } // onNavButtonsTapped() /** * onKeyDown(): 按下回退键时触发。 * 弹出对话框询问是否退出程序。 */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { showExitDialog(); return true; } // if (keyCode == KeyEvent.KEYCODE_BACK) else { return super.onKeyDown(keyCode, event); } // else } // onKeyDown() } // HomeActivity Class // E.O.F
另外两个活动直接照葫芦画瓢就 OK 了。不过这里千万要注意后两个活动不能只复制 MainActivity 的布局就完事了,一定要更改导航栏各个按钮的样式!另外就是活动中的 onNavButtonsTapped() 方法的内容也不一样!
activity_message.xml:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.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" android:background="@android:color/holo_green_dark" tools:context=".MessageActivity"> <!-- 本活动的背景色为 holo green dark --> <!-- 注意上方的 Context --> <LinearLayout android:id="@+id/llMessagePage" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp" android:layout_marginBottom="8dp" android:orientation="vertical" app:layout_constraintBottom_toTopOf="@+id/llMessageNav" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <!-- 在此定义页面主体布局 --> </LinearLayout> <LinearLayout android:id="@+id/llMessageNav" style="?android:attr/buttonBarStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:background="@color/navBack" android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/llMessagePage"> <!-- 请务必注意以下各按钮的 drawableTop 和 textColor 属性 --> <Button android:id="@+id/btnNavHome" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/home0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavHome" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> <Button android:id="@+id/btnNavMessage" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/message1" android:onClick="onNavButtonsTapped" android:text="@string/btnNavMessage" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navActivated" /> <Button android:id="@+id/btnNavSettings" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/settings0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavSettings" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> </LinearLayout> </android.support.constraint.ConstraintLayout>
MessageActivity.java
public class MessageActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_message); } // onCreate() // 请注意本方法内容的变化 public void onNavButtonsTapped(View v) { switch (v.getId()) { case R.id.btnNavHome: open(HomeActivity.class); break; // case R.id.btnNavHome case R.id.btnNavSettings: open(SettingsActivity.class); break; // case R.id.btnNavSettings } // switch (v.getId()) } // onNavButtonsTapped() @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { showExitDialog(); return true; } // if (keyCode == KeyEvent.KEYCODE_BACK) else { return super.onKeyDown(keyCode, event); } // else } // onKeyDown() } // MessageActivity Class // E.O.F
最后是 SettingsActivity。
activity_settings.xml:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.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" android:background="@android:color/holo_orange_dark" tools:context=".SettingsActivity"> <!-- 本活动的背景色为 holo orange dark --> <!-- 注意上方的 Context --> <LinearLayout android:id="@+id/llHomePage" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp" android:layout_marginBottom="8dp" android:orientation="vertical" app:layout_constraintBottom_toTopOf="@+id/llHomeNav" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> <!-- 在此定义页面主体布局 --> </LinearLayout> <!-- 以下为导航栏的布局 --> <LinearLayout android:id="@+id/llHomeNav" style="?android:attr/buttonBarStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:background="@color/navBack" android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/llHomePage"> <!-- 请务必注意以下各按钮的 drawableTop 和 textColor 属性 --> <Button android:id="@+id/btnNavHome" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/home0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavHome" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> <Button android:id="@+id/btnNavMessage" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/message0" android:onClick="onNavButtonsTapped" android:text="@string/btnNavMessage" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navNormal" /> <Button android:id="@+id/btnNavSettings" style="?android:attr/buttonBarButtonStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:drawableTop="@drawable/settings1" android:onClick="onNavButtonsTapped" android:text="@string/btnNavSettings" android:textAppearance="?android:attr/textAppearanceSmall" android:textColor="@color/navActivated" /> </LinearLayout> </android.support.constraint.ConstraintLayout>
SettingsActivity.java:
package com.example.myapplication; import android.os.Bundle; import android.view.KeyEvent; import android.view.View; import com.example.myapplication.base.BaseActivity; public class SettingsActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); } // onCreate() // 请注意本方法内容的变化 public void onNavButtonsTapped(View v) { switch (v.getId()) { case R.id.btnNavHome: open(HomeActivity.class); break; // case R.id.btnNavHome case R.id.btnNavMessage: open(MessageActivity.class); break; // case R.id.btnNavMessage } // switch (v.getId()) } // onNavButtonsTapped() @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (keyCode == KeyEvent.KEYCODE_BACK) { showExitDialog(); return true; } // if (keyCode == KeyEvent.KEYCODE_BACK) else { return super.onKeyDown(keyCode, event); } // else } // onKeyDown() } // SettingsActivity Class // E.O.F
做到这一步,先中场休息,打开模拟器调试一下。
我们之后还有两个问题需要解决:
①活动间的跳转是有动画的,而我们并不需要这画蛇添足的动画;
②这一点也是更重要的。每次一点击导航栏上的按钮,就会打开一个新活动。当我们从主页活动跳至设置活动,然后再由设置活动跳回主页活动时,系统的堆栈里其实是有两个主页活动的实例的,如果反复跳转,系统也会一直继续创建活动实例,原先的活动实例名存实亡,造成实质上的内存泄漏,直到最后内存不够用而崩溃。我们需要的是这样的效果:跳回曾经已经创建过的活动时,不要新建实例,而是直接重新引用原先活动的实例。这样,不论在导航栏上跳转多少次,内存中最多只会有 3 个活动,永远不会有内存泄漏的问题。
我们先来解决第一个问题。这个问题其实很好办,在 res/values 目录下有个 styles.xml 的文件,用于定义活动的主题样式。我们在其中增加几行代码用于关闭活动间的跳转动画:
<resources> <!-- Base application theme. --> <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar"> <!-- Customize your theme here. --> <!-- 关闭动画 --> <item name="android:windowIsTranslucent">true</item> <item name="android:windowAnimationStyle">@style/NoAnimation</item> </style> <!-- 定义无动画样式 --> <style name="NoAnimation"> <item name="android:activityCloseEnterAnimation">@null</item> <item name="android:activityCloseExitAnimation">@null</item> <item name="android:activityOpenEnterAnimation">@null</item> <item name="android:activityOpenExitAnimation">@null</item> <item name="android:taskCloseEnterAnimation">@null</item> <item name="android:taskCloseExitAnimation">@null</item> <item name="android:taskOpenEnterAnimation">@null</item> <item name="android:taskOpenExitAnimation">@null</item> <item name="android:taskToBackEnterAnimation">@null</item> <item name="android:taskToBackExitAnimation">@null</item> <item name="android:taskToFrontEnterAnimation">@null</item> <item name="android:taskToFrontExitAnimation">@null</item> </style> </resources>
接下来是第二个问题,保证每个活动只有唯一的实例,避免跳转过程中活动实例反复创建造成的内存泄漏。
Android 中有一种活动启动方式叫 singleInstance,它表示整个 Application 周期里对应的活动实例不能超过一个,当该活动已创建但之后又从其他的 intent 跳转而来时,不新建实例,而是引用已有的实例。另外,singleInstance 启动模式的活动各自拥有各自的活动堆栈,互不影响。我们打开 AndroidManifest,添加如下代码:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.myapplication"> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".HomeActivity" android:launchMode="singleInstance"> <!-- 设置活动的启动方式为 singleInstance --> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".MessageActivity" android:launchMode="singleInstance" /> <!-- 如法炮制 --> <activity android:name=".SettingsActivity" android:launchMode="singleInstance" /> <!-- 如法炮制 --> </application> </manifest>
再次打开模拟器进行调试:
效果 PERFECT。
至此,我们就实现了无 Fragment 的底部导航栏。