本章介绍App开发常用的一些组合控件用法,主要包括:如何实现底部标签栏、如何运用顶部导航栏、如何利用循环视图实现3种增强型列表、如何使用二代翻页视图实现更炫的翻页效果。
底部标签栏
本节介绍底部标签栏的两种实现方式:首先说明如何通过Android Studio菜单自动创建基于BottomNavigationView的导航活动,然后描述如何利用状态图形与风格样式实现自定义的标签按钮,最后阐述怎样结合RadioGroup和ViewPager制作自定义的底部标签栏。
利用BottomNavigationView实现底部标签栏
不管是微信还是QQ,淘宝抑或京东,它们的首屏都在底部展开一排标签,每个标签对应着一个频道,从而方便用户迅速切换到对应频道。这种底部标签原本是苹果手机的标配,原生安卓最开始并不提供屏幕底部的快捷方式,倒是众多国产App纷纷山寨苹果的风格,八仙过海各显神通整出了底部标签栏。后来谷歌一看这种风格还颇受用户欢迎,于是顺势在Android Studio种集成了该风格的快捷标签。
如今在Android Studio上创建官方默认的首屏标签页面已经很方便了,具体步骤如下:
-
首先右击需要添加标签栏的模块,在弹出的右键菜单中依次选择New->Activity->Buttom Navigation Views Activity,弹出如下图所示的活动创建对话框。
-
在创建对话框的Activity Name一栏填写新活动的名称,再单击对话框右下角的Finish按钮,Android Studio就会自动创建该活动的Java代码及其XML文件。
-
编译运行App,进入刚创建的活动页面,其界面效果如下图所示。可见测试页面的底部默认提供了3个导航标签,分别是Home、Dashboard和Notifications。
注意到初始页面的Home标签从文字到图片均为高亮显示,说明当前处于Home频道。接着点击Dashboard标签,此时界面即可切换到Dashboard频道,再点击Notifications则可切换到Notifications频道。
不过为了定制页面的详细内容,开发者仍需修改相关代码,譬如将标签文字从英文改成中文,将频道上方的描述说明从英文改成中文,给频道页面添加图像等其他控件,等等,故而还得梳理标签框架的实现方式。
但是由于Android API的更新,在你新建好底部导航栏后编译可能会出现下列报错:
Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.internal.jdk7.JDK7PlatformImplementations found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.internal.jdk7.JDK7PlatformImplementations$ReflectSdkVersion found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.internal.jdk8.JDK8PlatformImplementations found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.internal.jdk8.JDK8PlatformImplementations$ReflectSdkVersion found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.io.path.ExperimentalPathApi found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.io.path.PathRelativizer found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.io.path.PathsKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.io.path.PathsKt__PathReadWriteKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.io.path.PathsKt__PathUtilsKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.jdk7.AutoCloseableKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk7-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21)
Duplicate class kotlin.jvm.jdk8.JvmRepeatableKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.random.jdk8.PlatformThreadLocalRandom found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.streams.jdk8.StreamsKt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$1 found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$2 found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$3 found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$4 found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.text.jdk8.RegexExtensionsJDK8Kt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Duplicate class kotlin.time.jdk8.DurationConversionsJDK8Kt found in modules kotlin-stdlib-1.8.20 (org.jetbrains.kotlin:kotlin-stdlib:1.8.20) and kotlin-stdlib-jdk8-1.6.21 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.21)
Go to the documentation to learn how to Fix dependency resolution errors.
解决方法就是在项目的build.gradle.kts文件,dependencies下面添加这句代码指定使用哪个类就好了:
dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.20"))
}
然后打开导航栏页面,马上又会报了另外一个错如下:
java.lang.IllegalStateException: Activity com.example.xxxxxx.TabNavigationActivity@d19f70f does not have an ActionBar set via setSupportActionBar()
这个报错大概意思就是说页面需要支持ActionBar,所以简单点操作就是修改主题支持ActionBar就可以了。现在打开亮色/暗色主题查看声明如下,可以看到声明为NoActionBar:
<style name="Base.Theme.Chapter09" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Base.Theme.Chapter09" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
然后删掉NoActionBar变为如下即可:
<style name="Base.Theme.Chapter09" parent="Theme.Material3.DayNight">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Base.Theme.Chapter09" parent="Theme.Material3.DayNight">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
接下来打开模块的build.gradle.kts,在dependencies节点内部发现多了下面两行依赖库,表示引用了标签导航的navigation库:
implementation("androidx.navigation:navigation-fragment:2.6.0")
implementation("androidx.navigation:navigation-ui:2.6.0")
再来看标签页面的XML文件,它的关键内容如下:
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
android:background="?android:attr/windowBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/bottom_nav_menu" />
<fragment
android:id="@+id/nav_host_fragment_activity_tab_navigation"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/nav_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/mobile_navigation" />
从上面的布局内容可知,标签页面主要包含两个组成部分:一个位于底部的BottomNavigationView(底部导航视图),另一个是位于其上占据剩余屏幕的碎片fragment。底部导航视图又由一排标签菜单组成,具体菜单在@menu/bottom_nav_menu中定义;而碎片为各频道的主题部分,具体内容在@navigation/mobile_navigation中定义。哟,原来奥妙就在这两个文件中,赶紧打开menu目录之下的bottom_nav_menu.xml看看:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_home"
android:icon="@drawable/ic_home_black_24dp"
android:title="@string/title_home" />
<item
android:id="@+id/navigation_dashboard"
android:icon="@drawable/ic_dashboard_black_24dp"
android:title="@string/title_dashboard" />
<item
android:id="@+id/navigation_notifications"
android:icon="@drawable/ic_notifications_black_24dp"
android:title="@string/title_notifications" />
</menu>
上面的菜单定义文件以menu为根节点,内部容纳3个item节点,分别对应屏幕底部的3个标签。每个item节点都拥有id、icon、title 3个属性,其中id指定该菜单项的编号,icon指定该菜单项的图标,title指定该菜单的文本。顺藤摸瓜查看values目录之下的strings.xml,果然找到了下面的3个标签文本定义:
<string name="title_home">Home</string>
<string name="title_dashboard">Dashboard</string>
<string name="title_notifications">Notifications</string>
搞清楚了底部标签的资源情况,接着打开navigation目录之下的mobile_navigation.xml,究竟里面是怎么定义各个频道的呢?
<?xml version="1.0" encoding="utf-8"?>
<navigation 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:id="@+id/mobile_navigation"
app:startDestination="@+id/navigation_home">
<fragment
android:id="@+id/navigation_home"
android:name="com.example.chapter09.ui.home.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home" />
<fragment
android:id="@+id/navigation_dashboard"
android:name="com.example.chapter09.ui.dashboard.DashboardFragment"
android:label="@string/title_dashboard"
tools:layout="@layout/fragment_dashboard" />
<fragment
android:id="@+id/navigation_notifications"
android:name="com.example.chapter09.ui.notifications.NotificationsFragment"
android:label="@string/title_notifications"
tools:layout="@layout/fragment_notifications" />
</navigation>
上述的导航定义文件以navigation为根节点,内部依旧分布着3个fragment节点,显然正好对应3个频道。每个fragment节点拥有id、name、label、layout 4个属性,各属性的用途说明如下:
- id:指定当前碎片的编号。
- name:指定当前碎片的完整类型名路径。
- label:指定当前碎片的标题文本。
- layout:指定当前碎片的布局文件。
这些默认的碎片代码到底有何不同,打开其中一个HomeFragment.java研究研究,它的关键代码如下:
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
HomeViewModel homeViewModel =
new ViewModelProvider(this).get(HomeViewModel.class);
binding = FragmentHomeBinding.inflate(inflater, container, false);
View root = binding.getRoot();
final TextView textView = binding.textHome;
homeViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
return root;
}
看来频道用到的碎片代码仍然在onCreateView方法中根据传参来的XML布局文件生成页面元素,这样频道界面的修改操作就交给碎片编码了。总算理清了这种底部导航的实现方式,接下来准备修理修理默认的标签及其频道。先打开values目录下的strings.xml,把3各标签的文字从英文改成中文,修改内容如下:
<string name="title_home">首页</string>
<string name="title_dashboard">仪表盘</string>
<string name="title_notifications">消息</string>
再打开3个频道的碎片代码,给文本视图天上中文描述,首页频道HomeFragment.java修改之后的代码示例如下:
public View onCreateView(@NonNull LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
HomeViewModel homeViewModel =
new ViewModelProvider(this).get(HomeViewModel.class);
binding = FragmentHomeBinding.inflate(inflater, container, false);
View root = binding.getRoot();
final TextView textView = binding.textHome;
// homeViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
textView.setText("这是首页");
return root;
}
修改完毕重新编译App,改过的各频道即显示对应的中文效果如下:
自定义标签按钮
按钮控件种类繁多,有文本按钮Button、图像按钮ImageButton、单选按钮RadioButton、复选框CheckBox、开关按钮Switch等,支持展现的形式有文本、图像、文本+图标,如此丰富的展现形式,已经能满足大部分需求。但总有少数场合比较特殊,一般的按钮样式满足不了,比如如下图所示的微信底部标签栏,一排有4个标签按钮,每个按钮的图标和文字都会随着选中而高亮显示。
这样的标签是各大主流App的标配,无论是淘宝、京东,还是微信、手机QQ,首屏底部是清一色的标签栏,而且在选中标签栏按钮时对应的文字、图标、背景一起高亮显示。虽然上一小节使用Android Studio自动生成了底部标签栏,但是整个标签栏都封装进了BottomNavigationView,看不到标签按钮的具体实现,令人不知其所以然,像这种标签,Android似乎都没有对应的专门控件,如果要自定义控件,就得设计一个布局容器,里面放入一个文本控件和图像控件,然后注册选中事件的监听器,一旦监听到选中事件,就高亮显示它的文字、图标与布局背景。
自定义控件固然是一个不错的思路,不过无须如此干戈。我们可以像操作开关按钮一样,通过状态图形自动展示显示背景,可以给background属性设置状态图形;要想高亮显示图标,可以给drawableTop属性设置状态图形;要想高亮显示文本,可以给textColor属性设置状态图形。既然背景、图标文字都能通过状态图形控制是否高亮显示,接下来的事情就好办了,具体实现步骤如下:
- 定义一个状态图形的XML描述文件,当状态为选中时展示高亮图标,其余情况展示普通图标,于是状态图形的XML内容示例如下:
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/tab_bg_selected" />
<item android:drawable="@drawable/tab_bg_normal" />
</selector>
上面定义的tab_bg_selector.xml用于控制标签背景的状态显示,控制文本状态的tab_text_selector.xml和控制图标的tab_text_selector.xml可如法炮制。
2. 在活动页面的XML文件中添加CheckBox节点,并给该节点的background、drawableTop、textColor 3个属性分别设置对应的状态图形,修改后的XML文件内容如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="5dp">
<!-- 复选框的背景、文字颜色和顶部图标都采用了状态图形,看起来像个崭新的标签控件 -->
<CheckBox
android:id="@+id/ck_tab"
android:layout_width="100dp"
android:layout_height="100dp"
android:padding="5dp"
android:gravity="center"
android:button="@null"
android:background="@drawable/tab_bg_selector"
android:text="点我"
android:textSize="12sp"
android:textColor="@drawable/tab_text_selector"
android:drawableTop="@drawable/tab_first_selector" />
<TextView
android:id="@+id/tv_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="这里查看标签选择结果"
android:textColor="@color/black"
android:textSize="17sp" />
</LinearLayout>
- 活动页面的Java代码给复选框ck_tab设置勾选监听器,用力啊监听复选框的选中事件和取消选中事件,活动代码如下:
public class TabButtonActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tab_button);
final TextView tv_select = findViewById(R.id.tv_select);
CheckBox ck_tab = findViewById(R.id.ck_tab);
// 给复选框设置勾选监听器
ck_tab.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (buttonView.getId() == R.id.ck_tab) {
String desc = String.format("标签按钮被%s了", isChecked?"选中":"取消选中");
tv_select.setText(desc);
}
});
}
}
运行App,一开始的标签按钮界面如下图:
点击标签控件,复选框变为选中状态,它的文字、图标、背景同时高亮显示如下图:
是不是很神奇?接下来不妨把该控件的共同属性挑出来,因为底部标签栏通常有4、5个标签按钮,如果每个按钮节点添加重复的属性,就太啰嗦了,所以把它们之间通用的属性挑出来,然后再values/styles.xml中定义名为TabButton的新风格,具体的风格内容如下:
<style name="TabButton">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">match_parent</item>
<item name="android:layout_weight">1</item>
<item name="android:padding">5dp</item>
<item name="android:gravity">center</item>
<item name="android:background">@drawable/tab_bg_selector</item>
<item name="android:textSize">12sp</item>
<item name="android:textColor">@drawable/tab_text_selector</item>
<item name="android:button">@null</item>
</style>
然后,XML文件只要给CheckBox节点添加一行style="@style/TabButton"
,即可将其变为标签按钮。直接在styles.xml中定义风格,无需另外编写自定义控件的代码,这是自定义控件的另一种途径。
回到前述活动页面的XML文件,补充一下的布局内容,表示添加一行3个标签控件,也就是3个CheckBox节点都声明了style="@style/TabButton"
,同时每个CheckBox另外指定自己的标签文字和标签图标。
<LinearLayout
android:layout_width="match_parent"
android:layout_height="100dp"
android:orientation="horizontal">
<CheckBox
style="@style/TabButton"
android:checked="true"
android:drawableTop="@drawable/tab_first_selector"
android:text="首页" />
<CheckBox
style="@style/TabButton"
android:drawableTop="@drawable/tab_second_selector"
android:text="分类" />
<CheckBox
style="@style/TabButton"
android:drawableTop="@drawable/tab_third_selector"
android:text="购物车" />
</LinearLayout>
重新运行App,发现标签界面多了一排标签按钮,分别是“首页” “分类” “购物车”,如下图:
多次点击3个按钮,它们的外观都遵循一种样式状态,可见统一的风格定义果然奏效了。
结合RadioGrop和ViewPager自定义底部标签栏
尽管使用Android Studio很容易生成自带底部标签的活动页面,可是该标签基于BottomNavigationView,标签的样式风格不方便另行调整,况且它也不支持通过左右滑动切换标签。因此,开发者若想实现拥有更多花样的标签栏,就得自己定义专门的底部标签栏了。
话说翻页视图ViewPager搭配翻页标签栏PagerTabStrip,本来已经实现了带标签的翻页功能,不过这个标签位于翻页视上方而非下方,而且只有标签没有标签图标,比起BottomNavigationView更不友好,所以用不了PagerTabStrip。鉴于标签栏每次只能选中一项标签,这种排他性与单选按钮类似,理论上采用一排单选按钮也能实现标签栏的单选功能。只是单选按钮的外观不满足要求,中用却不中看。这点小瑕疵倒也无妨,把它的样式改成上一小节介绍的标签按钮就行,也就是给RadioButton标签添加演示属性style="@style/TabButton"
。然后用单选组RadioGroup容纳这几个单选按钮,再把单选按钮放在页面底部,把翻页视图放在单选组上当,于是整个页面的XML文件变成下面这样:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.viewpager.widget.ViewPager
android:id="@+id/vp_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<RadioGroup
android:id="@+id/rg_tabbar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_home"
style="@style/TabButton"
android:checked="true"
android:text="首页"
android:drawableTop="@drawable/tab_first_selector" />
<RadioButton
android:id="@+id/rb_class"
style="@style/TabButton"
android:text="分类"
android:drawableTop="@drawable/tab_second_selector" />
<RadioButton
android:id="@+id/rb_cart"
style="@style/TabButton"
android:text="购物车"
android:drawableTop="@drawable/tab_third_selector" />
</RadioGroup>
</LinearLayout>
该页面对应的Java代码主要实现以下两个切换逻辑:
- 左右滑动翻页视图的时候,每个页面滚动结束,就自动选择对应位置的单选按钮。
- 点击某个单选按钮的时候,先判断当前选择的时第几个按钮,再将翻页视图翻到第几个页面。
具体到编码实现,则要给翻页视图添加页面变更监听器,并补充翻页完成的处理操作;还要给单选组注册选择监听器,并补充选中之后的处理操作。纤细的活动代码如下:
public class TabPagerActivity extends AppCompatActivity {
private ViewPager vp_content; // 声明一个翻页视图对象
private RadioGroup rg_tabbar; // 声明一个单选组对象
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tab_pager);
vp_content = findViewById(R.id.vp_content); // 从布局文件获取翻页视图
// 构建一个翻页适配器
TabPagerAdapter adapter = new TabPagerAdapter(getSupportFragmentManager());
vp_content.setAdapter(adapter); // 设置翻页视图的适配器
// 给翻页视图添加页面变更监听器
vp_content.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
// 选中指定位置的单选按钮
rg_tabbar.check(rg_tabbar.getChildAt(position).getId());
}
});
rg_tabbar = findViewById(R.id.rg_tabbar); // 从布局文件获取单选组
// 设置单选组的选中监听器
rg_tabbar.setOnCheckedChangeListener((group, checkedId) -> {
for (int pos=0; pos<rg_tabbar.getChildCount(); pos++) {
// 获得指定位置的单选按钮
RadioButton tab = (RadioButton) rg_tabbar.getChildAt(pos);
if (tab.getId() == checkedId) { // 正是当前选中的按钮
vp_content.setCurrentItem(pos); // 设置翻页视图显示第几页
}
}
});
}
}
由于翻页视图需要搭配翻页适配器,因此以上代码给出了一个适配器TabPagerAdapter,该适配器的代码很简单,仅仅返回3个碎片而已,下面是翻页适配器的代码例子:
public class TabPagerAdapter extends FragmentPagerAdapter {
// 碎片页适配器的构造方法,传入碎片管理器
public TabPagerAdapter(FragmentManager fm) {
super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
}
// 获取指定位置的碎片Fragment
@Override
public Fragment getItem(int position) {
if (position == 0) {
return new TabFirstFragment(); // 返回第一个碎片
} else if (position == 1) {
return new TabSecondFragment(); // 返回第二个碎片
} else if (position == 2) {
return new TabThirdFragment(); // 返回第三个碎片
} else {
return null;
}
}
// 获取碎片Fragment的个数
@Override
public int getCount() {
return 3;
}
}
当前的3个碎片TabFirstFragment、TabSecondFragment、TabThirdFragment并没有什么功能,只在其内部都放了一个文本试图而已。
至此从活动页面到适配器再到碎片全部重写了以便,运行App,打开该活动的初始界面,如下图所示。
当我们点击对应界面或者左右滑动时就会切换到对应碎片,如下图所示:
顶部导航栏
本节介绍顶部导航栏的组成控件:首先描述工具栏Toolbar的基本用法,然后叙述溢出菜单OverflowMenu的格式及其用法,最后讲解标签布局TabLayout的相关属性和方法用途。
工具栏ToolBar
主流App除了底部有一排标签栏外,通常顶部还有一排导航栏。在Android 5.0之前,这个顶部导航栏用的是ActionBar控件,但ActionBar存在不灵活、难以扩展等毛病,所以Android 5.0之后推出了Toolbar工具栏,意在取代ActionBar。
不过为了兼容之前的系统版本,ActionBar仍然保留。当然,由于Toolbar与ActionBar都占着顶部导航栏的位置,二者肯定不能共存,因此想要引入Toolbar就得先关闭ActionBar。具体的操作步骤如下:
- 在styles.xml中定义一个不包含ActionBar的风格样式,代码如下:
<style name="AppCompatTheme" parent="Theme.AppCompat.Light.NoActionBar" />
- 修改AndroidManifest.xml,给activity节点添加android:theme属性,并将属性值设为步骤1定义的风格,如
android:theme=@style/AppCompatTheme
。 - 将活动页面的XML文件根节点改成LinearLayout,且为vertical(垂直方向);然后增加一个Toolbar节点,因为Toolbar本质是一个ViewGrop,所以允许在内部添加其他控件。下面是Toolbar节点的XML例子:
<androidx.appcompat.widget.Toolbar
android:id="@+id/tl_head"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
- 打开活动页面的Java代码,在onCreate方法中获取布局文件中的Toolbar对象,并调用setSupportActionBar方法设置当前的Toolbar对象。对应代码如下:
// 从布局文件中获取名叫tl_head的工具栏
Toolbar tl_head = findViewById(R.id.tl_head);
setSupportActionBar(tl_head); // 使用tl_head替换系统自带的ActionBar
Toolbar之所以比ActionBar灵活,原因之一是Toolbar提供了多个属性,方便定制各种控件风格。它的常用属性及其设置方法见下表:
XML中的属性 | Toolbar类的设置方法 | 说明 |
---|---|---|
logo | setLogo | 设置工具栏图标 |
title | setTitle | 设置标题文字 |
titleTextColor | setTitleTextColor | 设置标题的文字颜色 |
subtitle | setSubtitle | 设置副标题文字。副标题在标题下方 |
subtitleTextColor | setSubtitleTextColor | 设置副标题的文字颜色 |
navigationIcon | setNavigationIcon | 设置左侧的箭头导航图标 |
无 | setNavigationOnClickListener | 设置导航图标的点击监听器 |
结合上表提到的设置方法,下面是给Toolbar设置风格的代码例子:
// 从布局文件中获取名叫tl_head的工具栏
Toolbar tl_head = findViewById(R.id.tl_head);
tl_head.setTitle("工具栏页面"); // 设置工具栏的标题文本
setSupportActionBar(tl_head); // 使用tl_head替换系统自带的ActionBar
tl_head.setTitleTextColor(Color.RED); // 设置工具栏的标题文字颜色
tl_head.setLogo(R.drawable.ic_app); // 设置工具栏的标志图片
tl_head.setSubtitle("Toolbar"); // 设置工具栏的副标题文本
tl_head.setSubtitleTextColor(Color.YELLOW); // 设置工具栏的副标题文字颜色
tl_head.setBackgroundResource(R.color.blue_light); // 设置工具栏的背景
tl_head.setNavigationIcon(R.drawable.ic_back); // 设置工具栏左边的导航图标
// 给tl_head设置导航图标的点击监听器
// setNavigationOnClickListener必须放到setSupportActionBar之后,不然不起作用
tl_head.setNavigationOnClickListener(view -> {
finish(); // 结束当前页面
});
运行App,观察到工具栏效果如下图所示,可见工具栏包括导航箭头图标、工具栏图标、标题、副标题。
溢出菜单OverflowMenu
导航栏右边往往有3个小点图标,点击后会在界面右上角弹出菜单。这个菜单名为溢出菜单OverflowMenu,意思是导航栏不够放、溢出来了。溢出菜单的格式同前面小节“利用BottomNavigationView实现底部标签栏”介绍的导航菜单,它们都在res/menu下面的XML文件中定义,不过溢出菜单多了个app:showAsAction属性,该属性用来控制菜单在导航栏上的展示位置,位置类型的取值见下表:
展示位置属性 | 说明 |
---|---|
always | 总是在导航栏上显示菜单图标 |
ifRoom | 如果导航栏右侧有空间,该项就直接显示在导航栏上,不再放入溢出菜单 |
never | 从不在导航栏上直接显示,总是放在溢出菜单列表里面 |
withText | 如果能在导航栏上显示,除了显示图标,还要显示该菜单项的文字说明 |
注意,因为showAsAction是菜单的自定义属性,所以要现在菜单XML的menu根节点增加命名空间声明xmlns:app="http://schemas.android.com/apk/res-auto"
,这样showAsAction指定的位置类型才会生效。下面是一个包含3个菜单的溢出菜单XML例子:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" >
<item
android:id="@+id/menu_refresh"
android:icon="@drawable/ic_refresh"
app:showAsAction="ifRoom"
android:title="刷新"/>
<item
android:id="@+id/menu_about"
android:icon="@drawable/ic_about"
app:showAsAction="never"
android:title="关于"/>
<item
android:id="@+id/menu_quit"
android:icon="@drawable/ic_quit"
app:showAsAction="never"
android:title="退出"/>
</menu>
有了上面的菜单文件menu_overflow.xml,还得在活动代码中增加对菜单的处理逻辑。下面是在活动页面中操作溢出菜单的代码片段:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// 从menu_overflow.xml中构建菜单界面布局
getMenuInflater().inflate(R.menu.menu_overflow, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId(); // 获取菜单项的编号
if (id == android.R.id.home) { // 点击了工具栏左边的返回箭头
finish(); // 结束当前页面
} else if (id == R.id.menu_refresh) { // 点击了刷新图标
tv_desc.setText("当前刷新时间: " + DateUtil.getNowTime());
} else if (id == R.id.menu_about) { // 点击了关于菜单项
Toast.makeText(this, "这个是工具栏的演示demo", Toast.LENGTH_LONG).show();
} else if (id == R.id.menu_quit) { // 点击了退出菜单项
finish(); // 结束当前页面
}
return super.onOptionsItemSelected(item);
}
运行App,打开添加了溢出菜单的导航栏页面,可见初始界面如下图所示,此时导航栏右侧有刷新按钮和3点图标:
点击3点图标,弹出剩余的菜单项列表,如下图所示。点击某个菜单项可触发对应的菜单事件。
标签布局TabLayout
Toolbar作为ActionBar的升级版,它不仅允许设置内部控件的样式,还允许添加其他外部控件。例如京东App的商品页面,既有如下图左侧所示的商品页,又有下图右侧所示的详情页。可见这个导航栏拥有一排文字标签,类似于翻页视图附属的翻页标题栏PagerTabStrip,商品页和详情页之间通过点击标签进行切换。
通过导航栏集成文字切换标签,有效提高了页面空间的利用效率,该功能用到了design库中的标签布局TabLayout。使用该控件前要先修改build.gradle.kts,在dependencies节点下加入以下配置表示导入design库:
implementation("com.google.android.material:material:1.12.0")
TabLayout的展现形式类似于PagerTabStrip,同样是文字标签下划线,不同的是TabLayout允许定制更丰富的样式,它增加的新样式属性主要有下列6种:
- tabBackground:指定标签的背景。
- tabIndicatorColor:指定下划线颜色。
- tabIndicatorHeight:指定下划线的高度。
- tabTextColor:指定标签文字的颜色。
- tabTextAppearance:指定标签文字的风格。样式风格来自styles.xml中的定义。
- tabSelectedTextColor:指定选中文字的颜色。
下面是在XML文件中通过Toolbar集成TabLayout的内容片段:
<androidx.appcompat.widget.Toolbar
android:id="@+id/tl_head"
android:layout_width="match_parent"
android:layout_height="50dp"
app:navigationIcon="@drawable/ic_back">
<!-- 注意TabLayout节点需要使用完整路径 -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerInParent="true"
app:tabIndicatorColor="@color/red"
app:tabIndicatorHeight="2dp"
app:tabSelectedTextColor="@color/red"
app:tabTextColor="@color/grey"
app:tabTextAppearance="@style/TabText" />
</androidx.appcompat.widget.Toolbar>
在Java代码中,TabLayout通过以下4个方法操作文字标签。
- newTab:创建新标签。
- addTab:添加一个标签。
- getTabAt:获取指定位置的标签。
- setOnTabSelectedListener:设置标签的选中监听器。该监听器需要实现OnTabSelectedListener接口的3个方法。
(1)onTabSelected:标签被选中时触发。
(2)onTabUnselected:标签被取消选中时触发。
(3)onTabReselected:标签被重新选中时触发。
把TabLayout与ViewPager结合起来就是一个固定的套路,二者各自通过选中监听器或者翻页监听器控制页面切换,使用时直接套框架就行。下面时两者联合使用的代码例子:
public class TabLayoutActivity extends AppCompatActivity implements OnTabSelectedListener {
private final static String TAG = "TabLayoutActivity";
private ViewPager vp_content; // 声明一个翻页视图对象
private TabLayout tab_title; // 声明一个标签布局对象
private String[] mTitleArray = {"商品", "详情"}; // 标题文字数组
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tab_layout);
Toolbar tl_head = findViewById(R.id.tl_head); // 从布局文件中获取名叫tl_head的工具栏
tl_head.setTitle(""); // 设置工具栏的标题文本
setSupportActionBar(tl_head); // 使用tl_head替换系统自带的ActionBar
tab_title = findViewById(R.id.tab_title); // 从布局文件中获取名叫tab_title的标签布局
vp_content = findViewById(R.id.vp_content); // 从布局文件中获取名叫vp_content的翻页视图
initTabLayout(); // 初始化标签布局
initTabViewPager(); // 初始化标签翻页
}
// 初始化标签布局
private void initTabLayout() {
// 给标签布局添加一个文字标签
tab_title.addTab(tab_title.newTab().setText(mTitleArray[0]));
// 给标签布局添加一个文字标签
tab_title.addTab(tab_title.newTab().setText(mTitleArray[1]));
tab_title.addOnTabSelectedListener(this); // 给标签布局添加标签选中监听器
// 监听器ViewPagerOnTabSelectedListener允许直接关联某个翻页视图
//tab_title.addOnTabSelectedListener(new ViewPagerOnTabSelectedListener(vp_content));
}
// 初始化标签翻页
private void initTabViewPager() {
// 构建一个商品信息的翻页适配器
GoodsPagerAdapter adapter = new GoodsPagerAdapter(
getSupportFragmentManager(), mTitleArray);
vp_content.setAdapter(adapter); // 设置翻页视图的适配器
// 给vp_content添加页面变更监听器
vp_content.addOnPageChangeListener(new SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
tab_title.getTabAt(position).select(); // 选中指定位置的标签
}
});
}
// 在标签被重复选中时触发
public void onTabReselected(Tab tab) {}
// 在标签选中时触发
public void onTabSelected(Tab tab) {
vp_content.setCurrentItem(tab.getPosition()); // 设置翻页视图显示第几页
}
// 在标签取消选中时触发
public void onTabUnselected(Tab tab) {}
}
运行App测试,准备观察标签布局的界面效果。先点击“商品”标签,此时页面显示商品的图片概览,如下图左侧所示;再点击“详情”标签,切换到商品的详情页面,如下图右侧所示。
增强型列表
本节介绍如何利用循环试图Recyclerview实现3种增强型列表,包括线性列表布局、普通网格布局、瀑布流网格布局等,以及如何动态更新循环视图内部的列表项数据。
循环视图Recyclerview
尽管ListView和GridView分别实现了多行单列和多行多列的列表,使用也很简单,可是它们缺少变化,风格也比较呆板。为此Android推出了更灵活多变的循环视图Recyclerview,它的功能非常强大,不但足以囊括列表视图和网格视图,还能实现高度错开的瀑布网格效果。总之,只要学会RecyclerView,就相当于同时掌握了ListView、GridView,再加上瀑布流一共3种列表界面。
由于RecyclerView来自recyclerview库,因此在使用RecyclerView前要修改build.gradle.kts,在dependencies节点中加入以下配置表示导入recyclerview库:
implementation("androidx.recyclerview:recyclerview:1.3.2")
下面是RecyclerView的常用方法说明:
- setAdapter:设置列表项的循环适配器。适配器采用RecyclerView.Adapter。
- setLayoutManager:设置列表项的布局管理器。管理器一共3种,包括线性布局管理器LinearLayoutManager、网格布局管理器GridLayoutManager、瀑布流网格布局管理器StaggeredGridLayoutManager。
- addItemDecoration:添加列表项的变更动画。默认动画为DefaultItemAnimator。
- scrollToPosition:滚动到指定位置。
循环视图有专门的循环适配器RecyclerView.Adapter,在setAdapter方法之前,得先实现一个从RecyclerView.Adapter派生而来的适配器,用来定义列表项的界面布局及其控件操作。下面是实现循环适配器时有待重写的方法说明。
- getItemCount:获得列表项的数目。
- onCreateViewHolder:创建整个布局的视图特有持有者,可在该方法中指定列表项的布局文件。第二个参数为视图类型viewType,根据视图类型加载不同的布局,从而实现带头部的列表布局。
- getItemViewType:返回每一项的视图类型。这里的类型与onCreateViewHolder方法的viewType参数保持一致。
- getItemId:获得每个列表项的编号。
以上两个方法不是必须的,可以重写也可以不重写。
举个公众号消息列表的例子,若要通过循环适配器实现的话,需要让自定义的适配器完成下列步骤:
- 在构造方法中传入消息列表。
- 重写getItemCount方法,返回列表项的个数。
- 定义一个由RecyclerView.ViewHolder派生而来的内部类,用作列表项的视图持有者。
- 重写onCreateViewHolder方法,根据指定的布局文件生成视图对象,并返回该视图对象对应的视图持有者。
- 重写onBindViewHolder方法,从参数中的视图持有着获取各个控件实例,再操纵这些控件(设置文字、设置图片、设置点击监听器等)。
根据上述步骤编写而成的循环适配器代码示例如下:
public class RecyclerLinearAdapter extends RecyclerView.Adapter<ViewHolder> {
private Context mContext; // 声明一个上下文对象
private List<NewsInfo> mPublicList; // 公众号列表
public RecyclerLinearAdapter(Context context, List<NewsInfo> publicList) {
mContext = context;
mPublicList = publicList;
}
// 获取列表项的个数
public int getItemCount() {
return mPublicList.size();
}
// 创建列表项的视图持有者
public ViewHolder onCreateViewHolder(ViewGroup vg, int viewType) {
// 根据布局文件item_linear.xml生成视图对象
View v = LayoutInflater.from(mContext).inflate(R.layout.item_linear, vg, false);
return new ItemHolder(v);
}
// 绑定列表项的视图持有者
public void onBindViewHolder(ViewHolder vh, final int position) {
ItemHolder holder = (ItemHolder) vh;
holder.iv_pic.setImageResource(mPublicList.get(position).pic_id);
holder.tv_title.setText(mPublicList.get(position).title);
holder.tv_desc.setText(mPublicList.get(position).desc);
}
// 定义列表项的视图持有者
public class ItemHolder extends ViewHolder {
public ImageView iv_pic; // 声明列表项图标的图像视图
public TextView tv_title; // 声明列表项标题的文本视图
public TextView tv_desc; // 声明列表项描述的文本视图
public ItemHolder(View v) {
super(v);
iv_pic = v.findViewById(R.id.iv_pic);
tv_title = v.findViewById(R.id.tv_title);
tv_desc = v.findViewById(R.id.tv_desc);
}
}
}
回到活动页面,由循环视图对象调用setAdapter方法设置适配器,具体的调用代码如下:
// 初始化线性布局的循环视图
private void initRecyclerLinear() {
// 从布局文件中获取名叫rv_linear的循环视图
RecyclerView rv_linear = findViewById(R.id.rv_linear);
// 创建一个垂直方向的线性布局管理器
LinearLayoutManager manager = new LinearLayoutManager(this, RecyclerView.VERTICAL, false);
rv_linear.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个公众号列表的线性适配器
RecyclerLinearAdapter adapter = new RecyclerLinearAdapter(this, NewsInfo.getDefaultList());
rv_linear.setAdapter(adapter); // 设置循环视图的线性适配器
}
运行App,观察到公众号消息界面如下图所示,可见该效果仿照微信公众号的消息列表,看起来像是用ListView实现的。
注意:循环视图并未提供点击监听器和长按监听器,若想让列表项能够响应点击事件,则需再适配器的onBindViewHolder方法中给列表的根布局注册点击监听器,代码示例如下:
// 列表项的点击事件需要自己实现。ll_item为列表项的根布局
holder.ll_item.setOnClickListener(v -> {
// 这里补充点击事件的处理代码
});
布局管理器LayoutManager
循环视图之所以能够变身为3种列表,是因为它允许指定不同的列表布局,这正是布局管理器LayoutManager的拿手好戏。LayoutManager不但提供了3类布局管理,分别实现类似列表视图、网格视图、瀑布流网格的效果,而且可由循环视图对象随时调用setLayoutManager方法设置新布局。一旦调用了setLayoutManager方法,界面就会根据新布局刷新列表项。此特性特别适用于手机在竖屏与横屏之间的显示切换(如竖屏时展示列表,横屏时展示网格),也适用于在不同屏幕尺寸(手机与平板)之间的显示切换(如在手机上展示列表,在平板上展示网格)。接下来分别介绍循环视图的3类布局管理器。
线性布局管理器LinearLayoutManager
LinearLayoutManager可看作是线性布局LinearLayout,它在垂直方向布局时,展示效果类似于列表视图ListView;在水平方向布局时,展示效果类似于水平方向的列表视图。
下面是LinearLayoutManager的常用方法。
- 构造方法:第二个参数指定了布局方向,RecyclerView.HORIZONTAL表示水平,RecyclerView.VERTICAL表示垂直;第三个参数指定了是否从相反方向开始布局。
- setOrientation:设置布局方向,RecyclerView.HORIZONTAL表示水平方向,RecyclerView.VERTICAL表示垂直方向。
- setReverseLayout:设置是否从相反方向开始布局,默认为false。如果设置为true,那么垂直方向将从下往上开始布局,水平方向将从右往左开始布局。
前面在介绍循环视图时,采用了最简单的线性布局管理器,虽然调用addItemDecoration方法能够添加列表项的分割线,但是RecyclerView并未提供默认的分割线,需要先由开发者自定义分割线的样式,再调用addItemDecoration方法设置分割线样式。下面是个允许设置线宽的分割线实现代码:
public class SpacesDecoration extends RecyclerView.ItemDecoration {
private int space; // 空白间隔
public SpacesDecoration(int space) {
this.space = space;
}
@Override
public void getItemOffsets(Rect outRect, View v, RecyclerView parent, RecyclerView.State state) {
outRect.left = space; // 左边空白间隔
outRect.right = space; // 右边空白间隔
outRect.bottom = space; // 上方空白间隔
outRect.top = space; // 下方空白间隔
}
}
网格布局管理器GridLayoutManager
GridLayoutManager可看作是网格布局GridLayout,从展示效果来看,GridLayoutManager类似于网格视图GridView。不管是GridLayout还是GridView,抑或GridLayoutManager,都呈现多行多列的网格布局。
下面是GridLayoutManager的常用方法。
- 构造方法:第二个参数指定了网格列数。
- setSpanCount:设置了网格的列数。
- setSpanSizeLookup:设置网格项的占位规则。默认一个网格项占一列,若想某个网格项占多列,就可在此设置占位规则。
下面是在活动页面种给循环视图设置网格布局管理器的代码例子:
// 初始化网格布局的循环视图
private void initRecyclerGrid() {
// 从布局文件中获取名叫rv_grid的循环视图
RecyclerView rv_grid = findViewById(R.id.rv_grid);
// 创建一个网格布局管理器(每行5列)
GridLayoutManager manager = new GridLayoutManager(this, 5);
rv_grid.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个市场列表的网格适配器
RecyclerGridAdapter adapter = new RecyclerGridAdapter(this, NewsInfo.getDefaultGrid());
adapter.setOnItemClickListener(adapter); // 设置网格列表的点击监听器
adapter.setOnItemLongClickListener(adapter); // 设置网格列表的长按监听器
rv_grid.setAdapter(adapter); // 设置循环视图的网格适配器
rv_grid.setItemAnimator(new DefaultItemAnimator()); // 设置循环视图的动画效果
rv_grid.addItemDecoration(new SpacesDecoration(1)); // 设置循环视图的空白装饰
}
运行App,观察到网格布局管理器的循环视图界面如下图所示,看起来跟GridView的展示效果没什么区别。
但GridLayoutManager绝非GridView可比,因为它还提供了setSpanSizeLookup方法,该方法允许一个网格占据多列,展示更加灵活。下面是使用占位规则的网格管理器代码例子:
// 初始化合并网格布局的循环视图
private void initRecyclerCombine() {
// 从布局文件中获取名叫rv_combine的循环视图
RecyclerView rv_combine = findViewById(R.id.rv_combine);
// 创建一个四列的网格布局管理器
GridLayoutManager manager = new GridLayoutManager(this, 4);
// 设置网格布局管理器的占位规则。以下规则为:第一项和第二项占两列,其他项占一列;
// 如果网格的列数为四,那么第一项和第二项平分第一行,第二行开始每行有四项。
manager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
if (position == 0 || position == 1) { // 为第一项或者第二项
return 2; // 占据两列
} else { // 为其它项
return 1; // 占据一列
}
}
});
rv_combine.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个猜你喜欢的网格适配器
RecyclerCombineAdapter adapter = new RecyclerCombineAdapter(
this, NewsInfo.getDefaultCombine());
adapter.setOnItemClickListener(adapter); // 设置网格列表的点击监听器
adapter.setOnItemLongClickListener(adapter); // 设置网格列表的长按监听器
rv_combine.setAdapter(adapter); // 设置循环视图的网格适配器
rv_combine.setItemAnimator(new DefaultItemAnimator()); // 设置循环视图的动画效果
rv_combine.addItemDecoration(new SpacesDecoration(1)); // 设置循环视图的空白装饰
}
运行App,观察到占位规则的界面效果如下图所示。可见第一行只有两个网格,而第二行右4个网格,这意味着第一行的每个网格都占据两列位置。
瀑布流网格布局管理器StaggeredGridLayoutManager
电商App在展示众多商品信息时,往往通过高矮不一的各自展示。因为不同商品的外观尺寸不一样,比如冰箱在纵向比较长,空调在横向比较长,所以若用一样规格的网格展示,必然导致有的商品图片会被压缩得很小。像这种根据不同得商品形状展示不同高度的图片,就是瀑布流网格的应用场合。自从有了瀑布流网格布局管理器StaggeredGridLayoutManager,瀑布流效果的开发过程便大大简化了,只要在循环适配器中动态设置每个网格的高度,系统就会在界面上自动排列瀑布流网格。
下面是StaggeredGridLayoutManager的常用方法。
- 构造函数:第一个参数指定了瀑布流网格每行的列数;第二个参数指定了瀑布流布局的方向,取值说明同LinearLayoutManager。
- setSpanCount:设置瀑布流网格每行的列数。
- setOrientation:设置瀑布流布局的方向。取值说明同LinearLayoutManager。
- setReverseLayout:设置是否从相反方向开始布局,默认为false。如果设置为true,那么垂直方向将从下往上开始布局,水平方向将从右往左开始布局。
下面是在活动页面中操作瀑布流网格布局管理器的代码例子:
// 初始化瀑布流布局的循环视图
private void initRecyclerStaggered() {
// 从布局文件中获取名叫rv_staggered的循环视图
RecyclerView rv_staggered = findViewById(R.id.rv_staggered);
// 创建一个垂直方向的瀑布流布局管理器(每行3列)
StaggeredGridLayoutManager manager = new StaggeredGridLayoutManager(
3, RecyclerView.VERTICAL);
rv_staggered.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个服装列表的瀑布流适配器
RecyclerStagAdapter adapter = new RecyclerStagAdapter(this, NewsInfo.getDefaultStag());
adapter.setOnItemClickListener(adapter); // 设置瀑布流列表的点击监听器
adapter.setOnItemLongClickListener(adapter); // 设置瀑布流列表的长按监听器
rv_staggered.setAdapter(adapter); // 设置循环视图的瀑布流适配器
rv_staggered.setItemAnimator(new DefaultItemAnimator()); // 设置循环视图的动画效果
rv_staggered.addItemDecoration(new SpacesDecoration(3)); // 设置循环视图的空白装饰
}
运行App,观察到瀑布流网格布局的效果如下图,每个网格的高度依照具体图片的高度变化而变化,使得整个页面更加生动活泼。
动态更新循环视图
循环视图不但支持多种布局,而且更新内部数据也很方便。原先列表视图或者网格视图若想更新列表项,只能调用setAdapter方法重新设置适配器,或者由适配器对象调用notifyDataSetChanged刷新整个列表界面,可是这两种更新方式都得重新加载全部列表项,非常低效。相比之下,循环视图允许动态更新局部记录,既能对一条列表项单独添加/修改/删除,也能更新全部列表项。这种动态更新功能用到了循环适配器对象的下列方法:
- notifyItemInserted:通知适配器在指定位置插入了新项。
- notifyItemRemoved:通知适配器在指定位置删除了原有项。
- notifyItemChanged:通知适配器在指定位置发生了数据变化。此时循环试图会刷新指定位置的列表项。
- notifyDataSetChanged:通知适配器整个列表的数据发生了变化。
动态更新列表项不仅只是功能上的增强,在更新之时还能展示变更动画,这是循环视图在用户体验上的优化,自从有了变更动画,列表项的增删动作看起来更加柔和,不再像列表视图或者网格视图那么呆板了,总之,只要用上了循环试图,你一定会对它爱不释手。
以公众号消息列表的更新操作为例,往循环视图顶部添加一条消息的步骤如下:
public class RecyclerDynamicActivity extends AppCompatActivity implements View.OnClickListener {
private RecyclerView rv_dynamic; // 声明一个循环视图对象
private LinearDynamicAdapter mAdapter; // 声明一个线性适配器对象
private List<NewsInfo> mPublicList = NewsInfo.getDefaultList(); // 当前的公众号信息列表
private List<NewsInfo> mOriginList = NewsInfo.getDefaultList(); // 原始的公众号信息列表
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recycler_dynamic);
findViewById(R.id.btn_recycler_add).setOnClickListener(this);
initRecyclerDynamic(); // 初始化动态线性布局的循环视图
}
// 初始化动态线性布局的循环视图
private void initRecyclerDynamic() {
// 从布局文件中获取名叫rv_dynamic的循环视图
rv_dynamic = findViewById(R.id.rv_dynamic);
// 创建一个垂直方向的线性布局管理器
LinearLayoutManager manager = new LinearLayoutManager(
this, RecyclerView.VERTICAL, false);
rv_dynamic.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个公众号列表的线性适配器
mAdapter = new LinearDynamicAdapter(this, mPublicList);
mAdapter.setOnItemClickListener(this); // 设置线性列表的点击监听器
mAdapter.setOnItemLongClickListener(this); // 设置线性列表的长按监听器
mAdapter.setOnItemDeleteClickListener(this); // 设置线性列表的删除按钮监听器
rv_dynamic.setAdapter(mAdapter); // 设置循环视图的线性适配器
rv_dynamic.setItemAnimator(new DefaultItemAnimator()); // 设置循环视图的动画效果
rv_dynamic.addItemDecoration(new SpacesDecoration(1)); // 设置循环视图的空白装饰
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_recycler_add) {
int position = new Random().nextInt(mOriginList.size()-1); // 获取一个随机位置
NewsInfo old_item = mOriginList.get(position);
NewsInfo new_item = new NewsInfo(old_item.pic_id, old_item.title, old_item.desc);
mPublicList.add(0, new_item); // 在顶部添加一条公众号消息
mAdapter.notifyItemInserted(0); // 通知适配器列表在第一项插入数据
rv_dynamic.scrollToPosition(0); // 让循环视图滚动到第一项所在的位置
}
}
}
运行App,观察到动态添加消息的界面效果如下图。其中,下图左侧为公众号消息列表的初始界面;点击“增加新聊天”按钮,在列表顶部新增一条公众号消息,注意在添加消息的时候会显示变更动画,下图右侧为动画结束后的公众号界面。
升级版翻页
本章介绍如何使用循环视图的扩展功能:首先引入下拉刷新布局SwipeRefreshLayout,并说明如何通过下拉刷新动态更新循环视图;然后描述第二代翻页视图ViewPager2的基本用法,以及如何给ViewPager2搭档循环适配器;最后阐述如何给ViewPager2搭档专门的翻页适配器,以及如何集成标签布局。
下拉刷新布局SwipeRefreshLayout
电商App在商品列表页面提供了下拉功能,在屏幕顶端向下滑动即可触发页面的刷新操作,该功能用到了下拉刷新布局SwipeRefreshLayout。在使用SwipeRefreshLayout前要修改build.gradle.kts,在dependencies节点中加入以下配置表示导入swiperefreshlayout库:
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
下面是SwipeRefreshLayout的常用方法说明。
- setOnRefreshListener:设置刷新监听器。需要重写监听器OnRefreshListener的onRefresh方法,该方法在下拉松开时触发。
- setRefreshing:设置刷新的状态。true表示正在刷新,false表示结束刷新。
- isRefreshing:判断是否正在刷新。
- setColorSchemeColors:设置进度圆圈的圆环颜色。
在XML文件中,SwipeRefreshLayout节点内部有且仅有一个直接子视图。如果存在多个直接子视图,那么只会展示第一个子视图,后面的子视图将不予展示。并且直接子视图必须允许滚动,包括滚动视图ScrollView、列表视图ListView、网格视图GridView、循环视图RecyclerView等。如果不是这些视图,当前界面就不支持滚动,更不支持下拉刷新。以循环视图为例,通过下拉刷新动态添加列表记录,从而省去一个控制按钮,避免按钮太多显得界面凌乱。
下面是结合SwipeRefreshLayout与RecyclerView的XML文件例子:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="5dp">
<!-- 注意SwipeRefreshLayout要使用全路径 -->
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/srl_dynamic"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 注意RecyclerView要使用全路径 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_dynamic"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#aaaaff" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
与上面的XML文件对应的活动页面代码示例如下:
public class SwipeRecyclerActivity extends AppCompatActivity implements OnRefreshListener {
private SwipeRefreshLayout srl_dynamic; // 声明一个下拉刷新布局对象
private RecyclerView rv_dynamic; // 声明一个循环视图对象
private LinearDynamicAdapter mAdapter; // 声明一个线性适配器对象
private List<NewsInfo> mPublicList = NewsInfo.getDefaultList(); // 当前的公众号信息列表
private List<NewsInfo> mOriginList = NewsInfo.getDefaultList(); // 原始的公众号信息列表
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_swipe_recycler);
// 从布局文件中获取名叫srl_dynamic的下拉刷新布局
srl_dynamic = findViewById(R.id.srl_dynamic);
srl_dynamic.setOnRefreshListener(this); // 设置下拉布局的下拉刷新监听器
// 设置下拉刷新布局的进度圆圈颜色
srl_dynamic.setColorSchemeResources(
R.color.red, R.color.orange, R.color.green, R.color.blue);
initRecyclerDynamic(); // 初始化动态线性布局的循环视图
}
// 初始化动态线性布局的循环视图
private void initRecyclerDynamic() {
// 从布局文件中获取名叫rv_dynamic的循环视图
rv_dynamic = findViewById(R.id.rv_dynamic);
// 创建一个垂直方向的线性布局管理器
LinearLayoutManager manager = new LinearLayoutManager(
this, RecyclerView.VERTICAL, false);
rv_dynamic.setLayoutManager(manager); // 设置循环视图的布局管理器
// 构建一个公众号列表的线性适配器
mAdapter = new LinearDynamicAdapter(this, mPublicList);
rv_dynamic.setAdapter(mAdapter); // 设置循环视图的线性适配器
}
// 一旦在下拉刷新布局内部往下拉动页面,就触发下拉监听器的onRefresh方法
public void onRefresh() {
mHandler.postDelayed(mRefresh, 2000); // 模拟网络耗时,延迟若干秒后启动刷新任务
}
private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
// 定义一个刷新任务
private Runnable mRefresh = new Runnable() {
@Override
public void run() {
srl_dynamic.setRefreshing(false); // 结束下拉刷新布局的刷新动作
int position = new Random().nextInt(mOriginList.size()-1); // 获取一个随机位置
NewsInfo old_item = mOriginList.get(position);
NewsInfo new_item = new NewsInfo(old_item.pic_id, old_item.title, old_item.desc);
mPublicList.add(0, new_item); // 在顶部添加一条公众号消息
mAdapter.notifyItemInserted(0); // 通知适配器列表在第一项插入数据
rv_dynamic.scrollToPosition(0); // 让循环视图滚动到第一项所在的位置
}
};
}
运行App,打开公众号列表界面,在屏幕顶端下拉再松手,此时页面上方弹出转圈提示正在刷新,如下图左侧;稍等片刻结束刷新,此时列表顶端增加了一条新消息,如下图右侧所示。
第二代翻页视图ViewPager2
正如RecyclerView横空出世取代ListView个GridView那样,Android也推出了二代翻页视图ViewPager2,打算替换原来的翻页视图ViewPager2。与ViewPager相比,ViewPager2支持更丰富的界面特效,包括但不限于下列几点:
- 不但支持水平方向翻页,还支持垂直方向翻页。
- 支持RecyclerView.Adapter,也允许调用适配器对象的notifyItem***方法,从而动态刷新某个页面项。
- 除了展示当前页,也支持展示左、右两页的部分区域。
- 支持在翻页过程中展示自定义的切换动画。
虽然ViewPager2增加了这么棒的功能,但它用起来很简单,掌握下面几个方法就够了:
- setAdapter:设置二代翻页视图的页面适配器。
- setOrientation:设置二代翻页视图的翻页方向。其中ORIENTATION_HORIZONTAL表示水平方向,ORIENTATION_VERTICAL表示垂直方向。
- setPageTransformer:设置二代翻页视图的页面转换器,以便展示切换动画。
接下来利用循环适配器搭档二代翻页视图,演示看看ViewPager2的界面效果。注意,因为RecyclerView与ViewPager2拥有各自的依赖库,所以需要修改模块build.gradle.kts,在dependencies节点内部补充以下两行依赖配置:
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.viewpager2:viewpager2:1.1.0")
接着建一个活动页面,往该页面XML文件中添加如下所示的ViewPager2标签:
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp2_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
由于ViewPager2仍然需要适配器,因此首先编写每个页面项的布局文件,下面便是一个页面项的XML例子,页面上方时图像视图,下方是文本视图。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_pic"
android:layout_width="match_parent"
android:layout_height="360dp"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/tv_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="17sp" />
</LinearLayout>
然后给上面的页面项补充对应的循环适配器代码,在适配器的构造方法中传入一个商品列表,再展示每个商品的图片与文字描述。循环适配器的代码示例如下:
public class MobileRecyclerAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private Context mContext; // 声明一个上下文对象
private List<GoodsInfo> mGoodsList = new ArrayList<GoodsInfo>(); // 声明一个商品列表
public MobileRecyclerAdapter(Context context, List<GoodsInfo> goodsList) {
mContext = context;
mGoodsList = goodsList;
}
// 创建列表项的视图持有者
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup vg, int viewType) {
// 根据布局文件item_mobile.xml生成视图对象
View v = LayoutInflater.from(mContext).inflate(R.layout.item_mobile, vg, false);
return new ItemHolder(v);
}
// 绑定列表项的视图持有者
public void onBindViewHolder(RecyclerView.ViewHolder vh, final int position) {
ItemHolder holder = (ItemHolder) vh;
holder.iv_pic.setImageResource(mGoodsList.get(position).pic);
holder.tv_desc.setText(mGoodsList.get(position).desc);
}
// 定义列表项的视图持有者
public class ItemHolder extends RecyclerView.ViewHolder {
public ImageView iv_pic; // 声明列表项图标的图像视图
public TextView tv_desc; // 声明列表项描述的文本视图
public ItemHolder(View v) {
super(v);
iv_pic = v.findViewById(R.id.iv_pic);
tv_desc = v.findViewById(R.id.tv_desc);
}
}
}
回到测试页面的Java代码,把二代翻页视图的排列方向设为水平方向,并将它的适配器设置为上述的循环适配器。只要以下几行代码就搞定了:
// 从布局文件中获取名叫vp2_content的二代翻页视图
ViewPager2 vp2_content = findViewById(R.id.vp2_content);
// 设置二代翻页视图的排列方向为水平方向
vp2_content.setOrientation(ViewPager2.ORIENTATION_HORIZONTAL);
// 构建一个商品信息列表的循环适配器
MobileRecyclerAdapter adapter = new MobileRecyclerAdapter(this, GoodsInfo.getDefaultList());
vp2_content.setAdapter(adapter); // 设置二代翻页视图的适配器
运行App,观察二代翻页视图的展示效果,其中水平方向的翻页过程如下图所示:
现在我们再在页面代码中添加以下代码:
// ViewPager2支持展示左右两页的部分区域
RecyclerView cv_content = (RecyclerView) vp2_content.getChildAt(0);
cv_content.setPadding(Utils.dip2px(this, 60), 0, Utils.dip2px(this, 60), 0);
cv_content.setClipToPadding(false); // false表示不裁剪下级视图
重新编译运行App,此时页面效果如下图,可见除了商品之外,左右两边呈现了边缘区域。
撤销刚刚添加的边缘特效代码,再给测试页面的活动代码补充下面几行代码:
// ViewPager2支持在翻页时展示切换动画,通过页面转换器计算切换动画的各项参数
ViewPager2.PageTransformer animator = new ViewPager2.PageTransformer() {
@Override
public void transformPage(@NonNull View page, float position) {
page.setRotation(position * 360); // 设置页面的旋转角度
}
};
vp2_content.setPageTransformer(animator); // 设置二代翻页视图的页面转换器
重新运行App,此时翻页过程如下图所示,从中可见翻页时展示了旋转动画。
给ViewPager2集成标签布局
ViewPager2不仅支持循环适配器,同样支持翻页适配器,还是新的哦!原先ViewPager采用的翻页适配器叫做FragmentPagerAdapter,而ViewPager2采用了FragmentStateAdapter,开起来仅仅差了个“Pager”,实际上差得远了,因为新适配器FragmentStateAdapter继承了循环适配器RecyclerView.Adapter。尽管它们都支持碎片Fragment,但具体的方法就不一样了。新、旧适配器的实现方法对比表如下:
旧翻页适配器的方法来自FragmentPagerAdapter | 新翻页适配器的方法来自FragmentStateAdapter | 说明 |
---|---|---|
FragmentPagerAdapter(fm: FragmentManager) | FragmentStateAdapter(@NonNull FragmentActivity fragmentActivity) | 构造方法 |
getCount | getItemCount | 获取碎片个数 |
getItem | createFragment | 创建指定位置的碎片 |
getPageTitle | 无 | 获得指定位置的标题 |
比如下面是采用FragmentStateAdapter的翻页适配器代码例子:
public class MobilePagerAdapter extends FragmentStateAdapter {
private List<GoodsInfo> mGoodsList = new ArrayList<GoodsInfo>(); // 声明一个商品列表
// 碎片页适配器的构造方法,传入碎片管理器与商品信息列表
public MobilePagerAdapter(FragmentActivity fa, List<GoodsInfo> goodsList) {
super(fa);
mGoodsList = goodsList;
}
// 创建指定位置的碎片Fragmen
@Override
public Fragment createFragment(int position) {
return MobileFragment.newInstance(position,
mGoodsList.get(position).pic, mGoodsList.get(position).desc);
}
// 获取碎片Fragment的个数
@Override
public int getItemCount() {
return mGoodsList.size();
}
}
新适配器集成的碎片MobileFragment代码参见fragment\MobileFragment.java,该碎片的功能同上一节,依旧传入商品列表,然后展示每个商品的图片与文字描述。运行测试App观察到的界面效果跟循环适配器差不多,因为展示商品信息的场景比较简单,所以循环适配器和翻页适配器看不出区别。就实际开发而言,简单的业务场景适合采用循环适配器,复杂的业务场景适合采用翻页适配器。
ViewPager有个标签栏搭档PagerTabStrip,然而ViewPager2抛弃了PagerTabStrip,直接跟TabLayout搭配了。可是在“标签布局TabLayout”小节中,为了让ViewPager联动TabLayout,着实费了不少功夫。先给ViewPager添加页面变更监听器,一旦听到翻页事件就切换对应标签;再给TabLayout注册标签选中监听器,一旦监听到标签事件就翻到对应的页面。现在有了ViewPager2,搭配TabLayout便轻松多了,只要一行代码即可绑定ViewPager2与TabLayout。下面是将二者联结起来的操作步骤。
- 创建测试页面,并往页面的XML文件先后加入TabLayout标签和ViewPager2标签,具体代码如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 标签布局TabLayout节点需要使用完整路径 -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_title"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<!-- 二代翻页视图ViewPager2节点也需要使用完整路径 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp2_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
- 打开该页面的Java代码,分别获取TabLayout和ViewPager2的视图对象,再利用TabLayoutMediator把标签布局跟翻页视图连为一体,完整代码示例如下:
public class ViewPager2FragmentActivity extends AppCompatActivity {
private List<GoodsInfo> mGoodsList = GoodsInfo.getDefaultList(); // 商品信息列表
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_pager2_fragment);
// 从布局文件中获取名叫tab_title的标签布局
TabLayout tab_title = findViewById(R.id.tab_title);
// 从布局文件中获取名叫vp2_content的二代翻页视图
ViewPager2 vp2_content = findViewById(R.id.vp2_content);
// 构建一个商品信息的翻页适配器
MobilePagerAdapter adapter = new MobilePagerAdapter(this, mGoodsList);
vp2_content.setAdapter(adapter); // 设置二代翻页视图的适配器
// 把标签布局跟翻页视图通过指定策略连为一体,二者在页面切换时一起联动
new TabLayoutMediator(tab_title, vp2_content, new TabLayoutMediator.TabConfigurationStrategy() {
@Override
public void onConfigureTab(TabLayout.Tab tab, int position) {
tab.setText(mGoodsList.get(position).name); // 设置每页的标签文字
}
}).attach();
}
}
重新运行App,初始的演示页面如下图左侧所示;接着点击上方标签,此时页面下方翻到了对应商品。
工程源码
文章设计所有代码可点击工程源码下载。