高级控件:ScrollView / ViewPager2 / TabLayout / BottomNavigation
学会滚动、分页、标签联动、底部导航,概览 CoordinatorLayout 与 AppBarLayout 的协调机制。
学习目标
- 理解 ScrollView 与 NestedScrollView 的差异和适用场景
- 能用 ViewPager2 + FragmentStateAdapter 做多页滑动
- 掌握 TabLayout 与 ViewPager2 的联动配置
- 会用 BottomNavigationView 配合菜单切换页面
- 了解 CoordinatorLayout + AppBarLayout 的协调滚动原理
学习目标
- 能区分
ScrollView和NestedScrollView,知道什么时候必须用后者 - 能用
ViewPager2+FragmentStateAdapter搭出左右滑切页结构 - 能让
TabLayout和ViewPager2双向联动 - 能用
BottomNavigationView做底部三 Tab 应用骨架 - 理解
CoordinatorLayout+AppBarLayout实现的「向上滚动隐藏标题栏」效果原理
高级控件:ScrollView / ViewPager2 / TabLayout / BottomNavigation
1. ScrollView 与 NestedScrollView
1.1 ScrollView 的基本用法
ScrollView 让内容超出屏幕时能上下滚动。它只能有一个直接子 View——这是硬性要求,因为它的 onMeasure 直接对第一个孩子测量,多个会被忽略。
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 一堆 TextView 模拟长内容 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="段落 1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="段落 2" />
<!-- ... 省略若干 ... -->
</LinearLayout>
</ScrollView>
fillViewport="true" 是个常被忽略的关键属性:当内容不足一屏时,让子 View 撑满整个 ScrollView 高度。不开的话,内容少时 ScrollView 会按内容高度收缩,下方的背景色露出来很丑。
1.2 NestedScrollView 解决什么问题
ScrollView 有个老问题:它不参与嵌套滚动。意思是当 ScrollView 内嵌一个 RecyclerView 时,RecyclerView 滚到底之后 ScrollView 不会接力继续滚——除非用 NestedScrollView。
NestedScrollView 实现了 NestedScrollingChild3 接口,能和 CoordinatorLayout、AppBarLayout 配合做「滚 AppBar 隐藏标题栏」效果。新项目直接无脑用 NestedScrollView,API 和 ScrollView 一致,但兼容性更好。
依赖:
implementation 'androidx.core:core:1.13.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<!-- 内容 -->
</androidx.core.widget.NestedScrollView>
1.3 横向滚动用 HorizontalScrollView
需求是横滑看图集就用 HorizontalScrollView,用法和 ScrollView 一致,方向变横向:
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/img1" />
<!-- ... -->
</LinearLayout>
</HorizontalScrollView>
2. ViewPager2 + FragmentStateAdapter
ViewPager2 是 AndroidX 推出的现代分页控件,替代老的 ViewPager。基于 RecyclerView 实现,支持 RTL、垂直滑动、DiffUtil 高效更新。新项目别再用老的 ViewPager。
依赖:
implementation 'androidx.viewpager2:viewpager2:1.1.0'
implementation 'androidx.fragment:fragment:1.8.5'
2.1 一个最简 ViewPager2
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
ViewPager2 用 RecyclerView.Adapter 而非老的 PagerAdapter。展示 Fragment 时用 FragmentStateAdapter:
package com.example.viewpagerdemo;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import java.util.ArrayList;
import java.util.List;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ViewPager2 viewPager = findViewById(R.id.viewPager);
List<String> pages = new ArrayList<>();
pages.add("第一页");
pages.add("第二页");
pages.add("第三页");
viewPager.setAdapter(new PagerAdapter(this, pages));
}
private static class PagerAdapter extends FragmentStateAdapter {
private final List<String> titles;
PagerAdapter(FragmentActivity fa, List<String> titles) {
super(fa);
this.titles = titles;
}
@NonNull
@Override
public Fragment createFragment(int position) {
return PageFragment.newInstance(titles.get(position));
}
@Override
public int getItemCount() {
return titles.size();
}
}
}
对应的 PageFragment:
package com.example.viewpagerdemo;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
public class PageFragment extends Fragment {
private static final String ARG_TITLE = "title";
static PageFragment newInstance(String title) {
PageFragment f = new PageFragment();
Bundle args = new Bundle();
args.putString(ARG_TITLE, title);
f.setArguments(args);
return f;
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
TextView tv = new TextView(requireContext());
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
tv.setLayoutParams(lp);
tv.setGravity(View.TEXT_ALIGNMENT_CENTER);
tv.setGravity(android.view.Gravity.CENTER);
String title = getArguments() != null ? getArguments().getString(ARG_TITLE) : "";
tv.setText(title);
tv.setTextSize(24f);
return tv;
}
}
2.2 翻页监听与预加载
viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
// 切页埋点、刷新 AppBar 标题等
}
});
// 限制预加载页数,降低内存
viewPager.setOffscreenPageLimit(1);
默认 offscreenPageLimit=1 意思是当前页左右各预加载 1 页(一共 3 页在内存中)。设大能避免来回切页时重建,但内存占用增加。
2.3 垂直滑动
viewPager.setOrientation(ViewPager2.ORIENTATION_VERTICAL);
老 ViewPager 想做垂直滑动得自己写自定义 View,ViewPager2 一行搞定。
3. TabLayout + ViewPager2 联动
TabLayout 是 Material Design 的标签栏,和 ViewPager2 配合能做出「点 Tab 切页、滑页换 Tab」双向联动。依赖:
implementation 'com.google.android.material:material:1.12.0'
3.1 布局
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabIndicatorColor="@color/brand_primary"
app:tabSelectedTextColor="@color/brand_primary"
app:tabTextColor="@color/text_secondary" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
tabMode="fixed":等分宽度,适合 Tab 数少(≤4)tabMode="scrollable":可横向滚动,适合 Tab 数多tabGravity="fill":让 Tab 撑满宽度
3.2 联动只需一行
TabLayout tabLayout = findViewById(R.id.tabLayout);
ViewPager2 viewPager = findViewById(R.id.viewPager);
// ... 设置 Adapter ...
// TabLayoutMediator 负责双向联动
new TabLayoutMediator(tabLayout, viewPager,
(tab, position) -> tab.setText("Tab " + (position + 1))
).attach();
TabLayoutMediator.attach() 之后,点 Tab 切页、滑页换高亮都自动好了,不用手写监听。tab.setText 也可以换成 tab.setIcon(...) 加图标。
4. BottomNavigationView 底部导航
底部三 Tab 是大多数 App 的骨架,BottomNavigationView + 菜单文件是 Material Design 的标准实现。
4.1 菜单文件 res/menu/bottom_nav.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_home"
android:icon="@drawable/ic_home"
android:title="首页" />
<item
android:id="@+id/nav_discovery"
android:icon="@drawable/ic_discovery"
android:title="发现" />
<item
android:id="@+id/nav_mine"
android:icon="@drawable/ic_mine"
android:title="我的" />
</menu>
4.2 布局
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/navHost"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:name="com.example.bottomnav.HomeFragment" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/bottom_nav"
app:labelVisibilityMode="labeled"
app:itemTextColor="@color/bottom_nav_color"
app:itemIconTint="@color/bottom_nav_color" />
</LinearLayout>
labelVisibilityMode:
labeled:始终显示文字unlabeled:只显示图标selected:仅选中时显示文字auto:≤3 个 Tab 时 labeled,>3 时 selected
itemTextColor / itemIconTint 都接收 selector,方便区分选中/未选中色:
<!-- res/color/bottom_nav_color.xml -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="@color/brand_primary" />
<item android:color="@color/text_secondary" />
</selector>
4.3 切换 Fragment 的代码
最朴素的写法是用 FragmentTransaction.replace:
package com.example.bottomnav;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import com.google.android.material.bottomnavigation.BottomNavigationView;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
BottomNavigationView nav = findViewById(R.id.bottomNav);
nav.setOnItemSelectedListener(item -> {
Fragment fragment;
int id = item.getItemId();
if (id == R.id.nav_home) {
fragment = new HomeFragment();
} else if (id == R.id.nav_discovery) {
fragment = new DiscoveryFragment();
} else if (id == R.id.nav_mine) {
fragment = new MineFragment();
} else {
return false;
}
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.navHost, fragment)
.commit();
return true;
});
// 默认选中首页
if (savedInstanceState == null) {
nav.setSelectedItemId(R.id.nav_home);
}
}
}
更现代的做法是用 Navigation Component(Jetpack Navigation),通过 nav_graph.xml 配置目的地,一行 setupWithNavController(navController) 联动。这会在架构章节详讲。
5. CoordinatorLayout 与 AppBarLayout 概览
CoordinatorLayout 是 Material Design 的「协调容器」——它的子 View 可以监听彼此的滚动事件并做出反应。最常见的组合是 AppBarLayout(顶部栏)+ 滚动内容(NestedScrollView / RecyclerView):向上滚动时,AppBar 一起向上滑出屏幕,让内容获得最大可视区域。
5.1 布局结构
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="标题"
app:layout_scrollFlags="scroll|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="一段很长很长的内容……" />
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
两个关键点:
- AppBarLayout 内的子 View 用
app:layout_scrollFlags决定滚动行为。scroll|enterAlways是「向上滚就消失、向下滚就回来」。 - 滚动内容用
app:layout_behavior="@string/appbar_scrolling_view_behavior"把自己声明为「依赖 AppBarLayout 滚动」的内容,这是 CoordinatorLayout 协调机制的核心。
5.2 layout_scrollFlags 取值
| flag | 含义 |
|---|---|
scroll |
AppBar 跟着滚动一起出/入屏(必填基础 flag) |
enterAlways |
向下滚动时立即回来,不等内容滚到顶 |
enterAlwaysCollapsed |
向下滚动先回最小高度,再继续展开(需配 minHeight) |
exitUntilCollapsed |
向上滚动时折叠到最小高度就停下,不全部移出屏 |
最小化的「顶部图片渐隐 + 工具栏吸附」效果就是 scroll|exitUntilCollapsed + app:layout_scrollFlags 子控件设 app:layout_collapseMode="parallax" 实现,这属于 CollapsingToolbarLayout 范畴,本节只给印象,后续架构章节会展开。
5.3 为什么 NestedScrollView 才能用
ScrollView 不实现 NestedScrollingChild,CoordinatorLayout 收不到它的滚动事件,AppBar 就不会反应。所以用 CoordinatorLayout 时滚动容器必须是 NestedScrollView 或 RecyclerView——这就是本章开头说 NestedScrollView 价值的原因。
常见坑与最佳实践
- ScrollView 内套 RecyclerView:会出现只显示一个 item 高度、滚动卡顿。两个滚动容器不能直接嵌套。要么用
NestedScrollView+ 关闭 RecyclerView 滚动(isNestedScrollingEnabled=false),要么直接用单一 RecyclerView 多类型 Item 把所有内容塞进去。 - ScrollView 子节点不止一个:直接运行只显示第一个,其余被吞。包一层 LinearLayout,或者改为单根布局。
- TabLayout 的 tabMode 选错:5 个 Tab 还写
fixed,每个 Tab 文字会被截断。多 Tab 一律scrollable。 - ViewPager2 切页时 Fragment 状态丢失:Fragment 内部如果有用户输入,切页回来变空。检查
FragmentPagerAdapter(已废弃,不保存状态)是否被误用,新代码必须用FragmentStateAdapter。 - BottomNavigationView 选中色不变:忘了同时改
itemTextColor和itemIconTint,文字变色了图标还是灰的。两者都要绑 selector。 - CoordinatorLayout 行为失效:90% 是滚动容器写成了
ScrollView而不是NestedScrollView;剩下 10% 是漏了app:layout_behavior那行字符串。
章节小结
ScrollView只能有一个直接子 View;NestedScrollView支持嵌套滚动,新项目默认用它ViewPager2基于 RecyclerView,用FragmentStateAdapter加载 Fragment,支持垂直、RTL、DiffUtilTabLayoutMediator.attach()一行实现 TabLayout 与 ViewPager2 双向联动BottomNavigationView+ 菜单文件 +setOnItemSelectedListener是底部导航的标准搭法CoordinatorLayout+AppBarLayout+layout_scrollFlags实现「滚内容、隐藏 AppBar」的协调效果,前提是滚动容器实现 NestedScrollingChild- 复杂嵌套场景优先考虑「单一 RecyclerView 多类型 Item」而不是多个滚动容器互相嵌套
下一章预告
有了多页结构还不够——任何 App 都缺不了「列表」。下一章深入 Android 列表的标配 RecyclerView:Adapter + ViewHolder 模式、LinearLayoutManager/GridLayoutManager、ItemDecoration、DiffUtil 高效更新、ItemTouchHelper 拖拽与侧滑,外加多类型 Item 和四级缓存机制解析,让你写出真正流畅的列表。