A
第 8 章JAVA38 分钟

高级控件: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>

两个关键点:

  1. AppBarLayout 内的子 View 用 app:layout_scrollFlags 决定滚动行为。scroll|enterAlways 是「向上滚就消失、向下滚就回来」。
  2. 滚动内容用 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 价值的原因。

常见坑与最佳实践

  1. ScrollView 内套 RecyclerView:会出现只显示一个 item 高度、滚动卡顿。两个滚动容器不能直接嵌套。要么用 NestedScrollView + 关闭 RecyclerView 滚动(isNestedScrollingEnabled=false),要么直接用单一 RecyclerView 多类型 Item 把所有内容塞进去。
  2. ScrollView 子节点不止一个:直接运行只显示第一个,其余被吞。包一层 LinearLayout,或者改为单根布局。
  3. TabLayout 的 tabMode 选错:5 个 Tab 还写 fixed,每个 Tab 文字会被截断。多 Tab 一律 scrollable。
  4. ViewPager2 切页时 Fragment 状态丢失:Fragment 内部如果有用户输入,切页回来变空。检查 FragmentPagerAdapter(已废弃,不保存状态)是否被误用,新代码必须用 FragmentStateAdapter。
  5. BottomNavigationView 选中色不变:忘了同时改 itemTextColor 和 itemIconTint,文字变色了图标还是灰的。两者都要绑 selector。
  6. CoordinatorLayout 行为失效:90% 是滚动容器写成了 ScrollView 而不是 NestedScrollView;剩下 10% 是漏了 app:layout_behavior 那行字符串。

章节小结

  • ScrollView 只能有一个直接子 View;NestedScrollView 支持嵌套滚动,新项目默认用它
  • ViewPager2 基于 RecyclerView,用 FragmentStateAdapter 加载 Fragment,支持垂直、RTL、DiffUtil
  • TabLayoutMediator.attach() 一行实现 TabLayout 与 ViewPager2 双向联动
  • BottomNavigationView + 菜单文件 + setOnItemSelectedListener 是底部导航的标准搭法
  • CoordinatorLayout + AppBarLayout + layout_scrollFlags 实现「滚内容、隐藏 AppBar」的协调效果,前提是滚动容器实现 NestedScrollingChild
  • 复杂嵌套场景优先考虑「单一 RecyclerView 多类型 Item」而不是多个滚动容器互相嵌套

下一章预告

有了多页结构还不够——任何 App 都缺不了「列表」。下一章深入 Android 列表的标配 RecyclerView:Adapter + ViewHolder 模式、LinearLayoutManager/GridLayoutManager、ItemDecoration、DiffUtil 高效更新、ItemTouchHelper 拖拽与侧滑,外加多类型 Item 和四级缓存机制解析,让你写出真正流畅的列表。