第8章 高级控件

Mr.Tong...
  • Android
  • Android
大约 59 分钟

第 8 章 高级控件

本章介绍了App开发常用的一些高级控件用法,主要包括:如何使用下拉框及其适配器、如何使用列表类视图及其适配器、如何使用翻页类视图及其适配器、如何使用碎片及其适配器等。然后结合本章所学的知识,演示了一个实战项目“记账本”的设计与实现。

📖8.1 下拉列表

本节介绍下拉框的用法以及适配器的基本概念,结合对下拉框Spinner的使用说明分别阐述数组适配器ArrayAdapter、简单适配器SimpleAdapter的具体用法与展示效果。

✅下拉框Spinner

Spinner是下拉框控件,它用于从一串列表中选择某项,其功能类似于单选按钮的组合。

image-20231113142106987

下拉列表的展示方式有两种,

  • 一种是在当前下拉框的正下方弹出列表框,此时要把spinnerMode属性设置为dropdown。
  • 另一种是在页面中部弹出列表对话框,此时要把spinnerMode属性设置为dialog
//dropdown
<Spinner
android:id="@+id/sp_dropdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:spinnerMode="dropdown" />
//dialog
<Spinner
android:id="@+id/sp_dialog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:spinnerMode="dialog" />

此外,在Java代码中,Spinner还可以调用下列 4 个方法。

  • setPrompt:设置标题文字。注意对话框模式才显示标题,下拉模式不显示标题。
  • setAdapter:设置列表项的数据适配器。
  • setSelection:设置当前选中哪项。注意该方法要在setAdapter方法后调用。
  • setOnItemSelectedListener:设置下拉列表的选择监听器,该监听器要实现接口OnItemSelectedListener。

🚩适配器Adapter

适配器负责从数据集合中取出对应的数据显示到条目布局上

image-20231113141554764

Adapter继承图:

image-20231113141641970

🚩下拉框案例

//初始化数据
    private final static String[] startArray = {"水星","金星","地球","火星","木星","土星"};
    /**
     * 下拉模式列表
     */
    private void spinnerDropdown(){
        sp_dropdown = findViewById(R.id.sp_dropdown);
        //声明一个适配器,有三个参数
        //1.上下文
        //2.layout资源文件
        //3.源数据
        ArrayAdapter<String> adapter = new ArrayAdapter<>(this,R.layout.spinner_item_list,startArray);
        sp_dropdown.setAdapter(adapter);//设置adapter
        sp_dropdown.setSelection(0);//设置默认选中
        sp_dropdown.setOnItemSelectedListener(this);//设置监听item
    }

    /**
     * 弹窗dialog模式列表
     */
    private void spinnerDialog(){
        sp_dialog = findViewById(R.id.sp_dialog);
        //声明一个适配器
        ArrayAdapter<String> adapter2 = new ArrayAdapter<>(this,R.layout.spinner_item_list,startArray);
        sp_dialog.setAdapter(adapter2);//设置adapter
        sp_dialog.setSelection(0);//设置默认选中
        sp_dialog.setPrompt("请选择行星");//设置标题文字
        sp_dialog.setOnItemSelectedListener(this);//设置监听item
    }

    /**
     *监听方法
     * @param parent 发生选择的AdapterView
     * @param view AdapterView中单击的视图
     * @param position 视图在适配器中的位置
     * @param id 所选项目的行id
     */
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        ToastUtils.show(this,"你选择的是:"+startArray[position]);
    }

✅数组适配器ArrayAdapter

ArrayAdapter是Android提供的一个适配器类,用于将数据与ListView等UI组件进行绑定,实现数据的展示。它是BaseAdapter的子类,简化了数据与视图之间的绑定过程。最简单的适配器,只展示一行文字

ArrayAdapter实现过程分成下列 3 个步骤:

  • 编写列表项的XML文件,内部布局只有一个TextView标签。

  • 调用ArrayAdapter的构造方法,填入待展现的字符串数组,以及列表项的包装盒,即第一步创建的xml文件。

  • 调用下拉框控件的setAdapter方法,传入第二步得到的适配器实例。

//第一步
<TextView xmlns:android="http://schemas.android.com/apk/res/android" 
   android:id="@+id/tv_name"
   android:layout_width="match_parent" 
   android:layout_height="50dp"
   android:gravity="center"/>
       
// 第二步:声明一个下拉列表的数组适配器
ArrayAdapter<String> starAdapter = new ArrayAdapter<String> 
(this,R.layout.item_select, starArray);

// 第三步:设置下拉框的数组适配器 
sp_dropdown.setAdapter(starAdapter);  

✅简单适配器SimpleAdapter

ArrayAdapter只能显示文本列表,显然不够美观,有时还想给列表加上图标,比如希望显示六大行星的天文影像。这时简单适配器SimpleAdapter就派上用场了,它允许在列表项中同时展示文本与图片。

SimpleAdapter的实现过程略微复杂,因为它的原料需要更多信息。例如,原料不但有糖果,还有贺卡,这样就得把一大袋糖果和一大袋贺卡送进流水线,适配器每次拿一颗糖果和一张贺卡,把糖果与贺卡按规定塞进包装盒。对于SimpleAdapter的构造方法来说,第 2 个参数Map容器放的是原料糖果与贺卡,第 3 个参数放的是包装盒,第 4 个参数放的是糖果袋与贺卡袋的名称,第 5 个参数放的是包装盒里塞糖果的位置与塞贺卡的位置。

下面是下拉框控件使用简单适配器的示例代码:

 //初始化数据
    private static final int[] iconArray = {
            R.drawable.sun, R.drawable.sun2, R.drawable.sun,
            R.drawable.sun, R.drawable.sun2, R.drawable.sun,
    };
    private static final String[] startArray2 = {"水星","金星","地球","火星","木星","土星"};
    /**
     * simpleSpinner列表
     */
    private void simpleSpinner(){
        List<Map<String,Object>> list = new ArrayList<>();//声明一个映射对象
        for (int i = 0; i < iconArray.length; i++) {
            Map<String,Object> item = new HashMap<>();
            item.put("icon", iconArray[i]);
            item.put("name", startArray2[i]);
            list.add(item);//把行星图标与名称的配对映射添加到列表 
        }
        SimpleAdapter simpleAdapter = new SimpleAdapter(this,list,R.layout.item_simple,
                new String[]{"icon","name"},
                new int[]{R.id.iv_icon,R.id.tv_name});

        Spinner sp_icon = findViewById(R.id.sp_icon);
        sp_icon.setAdapter(simpleAdapter);//设置adapter
        sp_icon.setOnItemSelectedListener(this);//设置监听item

    }
    /**
     *监听方法
     * @param parent 发生选择的AdapterView
     * @param view AdapterView中单击的视图
     * @param position 视图在适配器中的位置
     * @param id 所选项目的行id
     */
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        ToastUtils.show(this,"你选择的是:"+startArray[position]);
    }

以上代码中,简单适配器使用的包装盒名为R.layout.item_simple,它的布局内容如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">
    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="0dp"
        android:layout_height="50dp"
        android:layout_weight="1"
        android:src="@drawable/sun"/>
    <TextView
        android:id="@+id/tv_name"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"
        android:gravity="center"
        android:textColor="#ff0000"
        android:text="太阳"
        android:textSize="17sp"/>
</LinearLayout>

image-20220711161628396

📖8.2 列表类视图

本节介绍列表类视图怎样结合基本适配器展示视图阵列,包括:基本适配器BaseAdapter的用法、列表视图ListView的用法及其常见问题的解决、网格视图GridView的用法及其拉伸模式说明。

✅基本适配器BaseAdapter

BaseAdapter是一个抽象类,需要开发者自己实现一些方法才能使用。在使用BaseAdapter时,通常需要自定义一个适配器类继承自BaseAdapter。它可以使列表项存在 3 个以上的控件,该适配器允许开发者在别的代码文件中编写操作代码,大大提高了代码的可读性和可维护性。

BaseAdapter派生的数据适配器主要实现下面 5 种方法。

  • 构造方法:指定适配器需要处理的数据集合。
  • getCount:获取列表项的个数。
  • getItem:获取列表项的数据。
  • getItemId:获取列表项的编号。
  • getView:获取每项的展示视图,并对每项的内部控件进行业务处理。

下面以下拉框控件为载体,演示如何操作BaseAdapter,具体的编码过程分为 3 步:

  • 编写列表项的布局文件
  • 写个新的适配器继承BaseAdapter,实现对列表项的管理操作
  • 在页面代码中创建该适配器实例,并交给下拉框设置

步骤一,编写列表项的布局文件item_list.xml,示例代码如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">
    <!-- 这是显示行星图片的图像视图 -->
    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="0dp"
        android:layout_height="80dp"
        android:layout_weight="1"
        android:scaleType="fitCenter"
        android:src="@drawable/sun"/>
    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"
        android:layout_marginLeft="5dp"
        android:orientation="vertical">
        <!-- 这是显示行星名称的文本视图 -->
        <TextView
            android:id="@+id/tv_name"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:gravity="left|center"
            android:textColor="@color/black"
            android:text="太阳"
            android:textSize="20sp" />
        <!-- 这是显示行星述的文本视图 -->
        <TextView
            android:id="@+id/tv_desc"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="2"
            android:gravity="left|center"
            android:textColor="@color/black"
            android:text="描述段落"
            android:textSize="13sp" />
    </LinearLayout>
</LinearLayout>

步骤二,写个新的适配器PlanetBaseAdapter.java继承BaseAdapter,实现对列表项的管理操作,示例代码如下:

package com.example.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import com.example.bean.Planet;
import com.example.myapplication.R;
import java.util.List;

public class PlanetBaseAdapter extends BaseAdapter {

    private Context mContext;//上下文
    private List<Planet> mPlanetList;//数据集合
    
    public PlanetBaseAdapter(Context mContext, List<Planet> mPlanetList) {//构造方法
        this.mContext = mContext;
        this.mPlanetList = mPlanetList;
    }
    @Override
    public int getCount() {
        return mPlanetList.size();
    }
    @Override
    public Object getItem(int position) {
        return mPlanetList.get(position);
    }
    @Override
    public long getItemId(int position) {
        return position;
    }
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {

        View view = LayoutInflater.from(mContext).inflate(R.layout.item_list, null);
        //获取组件
        ImageView iv_icon = view.findViewById(R.id.iv_icon);
        TextView tv_name = view.findViewById(R.id.tv_name);
        TextView tv_desc = view.findViewById(R.id.tv_desc);
        Planet planet = mPlanetList.get(position);
        //给控件设置数据
        iv_icon.setImageResource(planet.getImage());
        tv_name.setText(planet.getName());
        tv_desc.setText(planet.getDesc());
        return view;
    }
}

实体类Planet

package com.example.bean;

import com.example.myapplication.R;

import java.util.ArrayList;
import java.util.List;

public class Planet {
    private int image;//行星图片
    private String name;//行星名字
    private String desc;//行星描述

    public Planet(int image, String name, String desc) {
        this.image = image;
        this.name = name;
        this.desc = desc;
    }
    //初始化数据
    public static int[] iconArray = {
            R.drawable.sun, R.drawable.sun2, R.drawable.sun,
            R.drawable.sun, R.drawable.sun2, R.drawable.sun,
    };
    public static String[] nameArray = {"水星","金星","地球","火星","木星","土星"};
    public static String[] descArray ={
        "水星是太阳系八大行星最内侧也是最小的一颗行星,也是离太阳最近的行星",
        "金星是太阳系八大行星之一,排行第二,距离太阳0.725天文单位",
        "地球是太阳系八大行星之一,排行第三,也是太阳系中直径、质量和密度最大的类",
        "火星是太阳系八大行星之一,排行第四,属于类地行星,直径约为地球的53%",
        "木星是太阳系八大行星中体积最大、自转最快的行星,排行第五。它的质量为太阳",
        "土星为太阳系八大行星之一,排行第六,体积仅次于木星",
    };
    public static List<Planet> getDefaultList() {
        List<Planet> planetList = new ArrayList<>();
        for (int i = 0; i < iconArray.length; i++) {
            planetList.add(new Planet(iconArray[i],nameArray[i],descArray[i] ));
        }
        return planetList;
    }
    public int getImage() {
        return image;
    }
    public String getName() {
        return name;
    }
    public String getDesc() {
        return desc;
    }
    
}
package com.example.chapter08.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import com.example.chapter08.R;
import com.example.chapter08.bean.Planet;
import java.util.List;
public class PlanetBaseAdapter extends BaseAdapter {
   private Context mContext; // 声明一个上下文对象
   private List<Planet> mPlanetList; // 声明一个行星信息列表
   // 行星适配器的构造方法,传入上下文与行星列表
   public PlanetBaseAdapter(Context context, List<Planet> planet_list) {
       mContext = context;
       mPlanetList = planet_list;
 }
   // 获取列表项的个数
   public int getCount() {
       return mPlanetList.size();
 }
   // 获取列表项的数据
   public Object getItem(int arg0) {
       return mPlanetList.get(arg0);
 }
   // 获取列表项的编号
   public long getItemId(int arg0) {
       return arg0;
 }
   // 获取指定位置的列表项视图
   public View getView(final int position, View convertView, ViewGroup parent) 
{
       ViewHolder holder;
       if (convertView == null) { // 转换视图为空
           holder = new ViewHolder(); // 创建一个新的视图持有者 
           // 根据布局文件item_list.xml生成转换视图对象
           convertView =
LayoutInflater.from(mContext).inflate(R.layout.item_list, null);
           holder.iv_icon = convertView.findViewById(R.id.iv_icon);
           holder.tv_name = convertView.findViewById(R.id.tv_name);
           holder.tv_desc = convertView.findViewById(R.id.tv_desc);
           convertView.setTag(holder); // 将视图持有者保存到转换视图当中
      } else { // 转换视图非空
           // 从转换视图中获取之前保存的视图持有者
           holder = (ViewHolder) convertView.getTag();
    }
       Planet planet = mPlanetList.get(position);
       holder.iv_icon.setImageResource(planet.image); // 显示行星的图片
       holder.tv_name.setText(planet.name); // 显示行星的名称
       holder.tv_desc.setText(planet.desc); // 显示行星的述
       holder.iv_icon.requestFocus();
       return convertView;
 }
   // 定义一个视图持有者,以便重用列表项的视图资源
   public final class ViewHolder {
       public ImageView iv_icon; // 声明行星图片的图像视图对象
       public TextView tv_name; // 声明行星名称的文本视图对象
       public TextView tv_desc; // 声明行星述的文本视图对象
 } 
}

步骤三,在页面BaseAdapterActivity.java代码中创建该适配器实例,并交给下拉框设置,示例代码如下:

    private List<Planet> planetList;
    private void baseAdapterSpinnner(){
        Spinner sp_base = findViewById(R.id.sp_base);// 获取数据容器

        planetList = Planet.getDefaultList();// 获取默认的行星列表

        PlanetBaseAdapter adapter = new PlanetBaseAdapter(this, planetList);//构建一个baseAdapter

        sp_base.setAdapter(adapter);// 设置下拉框的列表适配器
        sp_base.setSelection(0);// 设置下拉框默认显示第一项
        sp_base.setOnItemSelectedListener(this);// 给下拉框设置选择监听器
    }
    /**
     *监听方法
     * @param parent 发生选择的AdapterView
     * @param view AdapterView中单击的视图
     * @param position 视图在适配器中的位置
     * @param id 所选项目的行id
     */
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        ToastUtils.show(this,"你选择的是:"+planetList.get(position).getName());
    }
public class ToastUtils {  
    public static void show(Context context, String message) {  
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show();  
    }  
}

image-20220711162939383

🚩复用convertView

当列表的ltem从上方滚出屏幕视角之外时,复用原来的item,不需要反复的创建新的item对象。

image-20231114101520535

使用convertView的步骤:

  • 创建原型数据holder
  • 判断convertView为null,进行第一次创建,并setTag(holder)
  • convertView不为null时,则直接convertView.getTag()
  • 最后正常赋值

这里的setTag和getTag类似缓存,首次创建的item,然后超出去的item直接getTag来使用。

   @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        ViewHolder holder;
        if (convertView == null){
            convertView = LayoutInflater.from(mContext).inflate(R.layout.item_list, null);
            //获取组件
            holder = new ViewHolder();
            holder.iv_icon = convertView.findViewById(R.id.iv_icon);
            holder.tv_name = convertView.findViewById(R.id.tv_name);
            holder.tv_desc = convertView.findViewById(R.id.tv_desc);
            convertView.setTag(holder);//将视图持有者保存到转换视图当中
        }else {
             holder = (ViewHolder) convertView.getTag();
        }

        Planet planet = mPlanetList.get(position);
        //给控件设置数据
        holder.iv_icon.setImageResource(planet.getImage());
        holder.tv_name.setText(planet.getName());
        holder.tv_desc.setText(planet.getDesc());
        return convertView;
    }
    public final class ViewHolder {
        ImageView iv_icon ;
        TextView tv_name ;
        TextView tv_desc ;
    }

✅列表视图ListView

ListView是一种常用的Android控件,它可以显示一个垂直滚动的列表,每个列表项可以是文本、图片或其他视图组合,允许在页面上分行展示相似的数据列表,例如新闻列表、商品列表、图书列表等,方便用户浏览与操作。

ListView同样通过setAdapter方法设置列表项的数据适配器,但操作列表项的时候是调用setOnItemClickListener方法设置列表项的点击监听器OnItemClickListener,有时也调用setOnItemLongClickListener方法设置列表项的长按监听器OnItemLongClickListener

使用ListView的步骤:

  • 在布局文件中添加ListView控件,并设置相关的属性,如divider、choiceMode等。
  • 定义列表项的布局文件,可以是简单的TextView,也可以是复杂的视图组合。
  • 创建一个适配器(Adapter)对象,用于提供数据和视图的中间件,可以使用系统提供的ArrayAdapterSimpleAdapter等,也可以自定义BaseAdapter的子类。
  • 在代码中获取ListView对象,并调用setAdapter方法,将适配器对象传递给ListView,实现数据和视图的绑定。
  • 可以为ListView添加表头或表尾,以及设置监听器,实现列表项的点击、长按等交互功能。

ListView的常用属性

属性解释
android:divider使用一个Drawable或者color设置数据项之间的分隔线样式,@null取消
android:dividerHeight设置分隔线高度
android:entries设置一个资源Id用于填充ListView的数据项
android:footerDividersEnabled设定列表表尾是否显示分割线,如果有表尾的话
android:headerDividerEnabled设定列表表头是否显示分割线,如果有表头的话
listSelector指定列表项的按压背景(状态图像格式)

ListView的常用方法

方法解释
void addFooterView(View v)添加表尾View视图
boolean removeFooterView(View v)移除一个表尾View视图
void addHeaderView(View v)添加一个表头View视图
boolean removeHeaderView(View v)移除一个表头View视图
ListAdapter getAdapter()获取当前绑定的ListAdapter适配器
void setAdapter(ListAdapter adapter)设置一个ListAdapter适配器到当前ListView中
void setSelection(int posotion)设定当前选中项
long[] getCheckItemIds()获取当前选中项

ListView的常用触发事件

事件解释
AdapterView.OnItemCLickListener列表项被点击时触发
AdapterView.OnItemLongClickListener列表项被长按时触发
AdapterView.OnItemSelectedListener列表项被选择时触发

🚩案例:

第一步:添加ListView控件

<ListView
       android:id="@+id/lv_planet"
       android:layout_width="match_parent" 
       android:layout_height="wrap_content" /> 

第二步:定义列表项的布局文件item_list.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">
    <!-- 这是显示行星图片的图像视图 -->
    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="0dp"
        android:layout_height="80dp"
        android:layout_weight="1"
        android:scaleType="fitCenter"
        android:src="@drawable/sun"/>
    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="3"
        android:layout_marginLeft="5dp"
        android:orientation="vertical">
        <!-- 这是显示行星名称的文本视图 -->
        <TextView
            android:id="@+id/tv_name"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:gravity="left|center"
            android:textColor="@color/black"
            android:text="太阳"
            android:textSize="20sp" />
        <!-- 这是显示行星述的文本视图 -->
        <TextView
            android:id="@+id/tv_desc"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="2"
            android:gravity="left|center"
            android:textColor="@color/black"
            android:text="描述段落"
            android:textSize="13sp" />
    </LinearLayout>
</LinearLayout>

第三步:自定义BaseAdapter,这里复用上节baseAdapter的案例代码:PlanetBaseAdapter.java 、 Planet.java

第四步:在ListViewActivity代码中获取ListView对象,并调用setAdapter方法

 private List<Planet> planetList;   
private void ListViewPlanet(){
        ListView lv_planet = findViewById(R.id.lv_planet);
        planetList = Planet.getDefaultList();// 获取默认的行星列表
        PlanetBaseAdapter adapter = new PlanetBaseAdapter(this, planetList);//构建一个baseAdapter
        lv_planet.setAdapter(adapter);// 设置列表视图的适配器
        lv_planet.setOnItemClickListener(this);// 设置列表视图的点击监听器
    }

第五步:处理列表项的点击事件

    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        ToastUtils.show(this,"你选择的是:"+planetList.get(position).getName());
    }

image-20231114115549566

🚩修改列表视图的分隔线样式

修改分隔线样式要在XML文件中同时设置divider(分隔图片)与dividerHeight(分隔高度)两个属性,并且遵循下列两条规则:

  • divider属性设置为@null时,不能再将dividerHeight属性设置为大于 0 的数值,因为这会导致最后一项没法完全显示,底部有一部分被掩盖了。原因是列表高度为wrap_content时,系统已按照没有分隔线的情况计算列表高度,此时dividerHeight占用了n-1块空白分隔区域,使得最后一项被挤到背影里面去了。
  • 通过代码设置的话,务必先调用setDivider方法再调用setDividerHeight方法。如果先调用setDividerHeight后调用setDivider,分隔线高度就会变成分隔图片的高度,而不是setDividerHeight设置的高度。XML布局文件则不存在divider属性和dividerHeight属性的先后顺序问题。

下面的代码示范了如何在代码中正确设置分隔线,以及如何正确去掉分隔线:

if (ck_divider.isChecked()) { // 显示分隔线 
   // 从资源文件获得图形对象
   Drawable drawable = getResources().getDrawable(R.color.red); 
   lv_planet.setDivider(drawable); // 设置列表视图的分隔线
   lv_planet.setDividerHeight(Utils.dip2px(this, 5)); // 设置列表视图的分隔线高度 
} else { // 不显示分隔线
   lv_planet.setDivider(null); // 设置列表视图的分隔线
   lv_planet.setDividerHeight(0); // 设置列表视图的分隔线高度 
}

🚩修改列表项的按压背景

若想取消按压列表项之时默认的水波背景,可在布局文件中设置也可在代码中设置,两种方式的注意点说明如下:

( 1 )在布局文件中取消按压背景,直接将listSelector属性设置为@null并不合适,因为尽管设为@null,按压列表项时仍出现橙色背景。只有把listSelector属性设置为透明色才算真正取消背景,此时listSelector的属性值如下所示(事先在colors.xml中定义好透明色#00000000):

android:listSelector="@color/transparent"//颜色#00000000

( 2 )在代码中取消按压背景,调用setSelector方法不能设置null值,因为null值会在运行时报空指针异常。正确的做法是先从资源文件获得透明色的图形对象,再调用setSelector方法设置列表项的按压状态图形,设置按压背景的代码如下所示:

// 从资源文件获得图形对象
Drawable drawable = getResources().getDrawable(R.color.transparent); 
lv_planet.setSelector(drawable);  // 设置列表项的按压状态图形

列表视图除了以上两处属性修改,实际开发还有两种用法要特别小心,一种是列表视图的高度问题,另一种是列表项的点击问题,分别叙述如下。

🚩列表视图的高度问题

在XML文件中,如果ListView后面还有其他平级的控件,就要将ListView的高度设为0dp,同时权重设为

确保列表视图扩展到剩余的页面区域;如果ListView的高度设置为wrap_content,系统就只给列表视图预留一行高度,如此一来只有列表的第一项会显示,其他项不显示,这显然不是我们所期望的。因此建议列表视图的尺寸参数按照如下方式设置:

<ListView
       android:id="@+id/lv_planet"
       android:layout_width="match_parent" 
       android:layout_height="0dp"
       android:layout_weight="1"/>

🚩列表项的点击问题

通常只要调用setOnItemClickListener方法设置点击监听器,点击列表项即可触发列表项的点击事件,但是如果列表项中存在编辑框或按钮(含Button、ImageButton、Checkbox等),点击列表项就无法触发点击事件了。缘由在于编辑框和按钮这类控件会抢占焦点,因为它们要么等待用户输入、要么等待用户点击,按道理用户点击按钮确实应该触发按钮的点击事件,而非触发列表项的点击事件,可问题是用户点击列表项的其余区域,也由于焦点被抢占的缘故导致触发不了列表项的点击事件。

为了规避焦点抢占的问题,列表视图允许开发者自行设置内部视图的焦点抢占方式,该方式在XML文件中由descendantFocusability属性指定,在代码中由setDescendantFocusability方法设置,详细的焦点抢占方式说明见表8-2。

image-20220711164044321

注意焦点抢占方式不是由ListView设置,而是由列表项的根布局设置,也就是item_***.xml的根节点。完整的演示代码见本章源码中的ListFocusActivity.java、PlanetListWithButtonAdapter.java,以及列表项的布局文件item_list_with_button.xml。自行指定焦点抢占方式的界面效果如图8-9所示。

image-20220711164119599

在图8-9所示的界面上选择方式“不让子控件处理”(FOCUS_BLOCK_DESCENDANTS),之后点击列表项除按钮之外的区域,才会弹出列表项点击事件的提示。接下来我们不妨改写第 6 章实战项目的购物车页面,将商品列表改为列表视图实现,从而把列表项的相关操作剥离到单独的适配器代码,有利于界面代码的合理解耦。改造完毕的购物车效果如图8-10所示

(完整代码见chapter08\src\main\java\com\example\chapter08\ShoppingCartActivity.java)。

image-20220711164246653

图8-10 利用列表视图改造购物车界面

✅网格视图GridView

除了列表视图,网格视图GridView也是常见的列表类视图,它用于分行分列显示表格信息,比列表视图更适合展示物品清单。除了沿用列表视图的 3 个方法setAdapter、setOnItemClickListener、setOnItemLongClickListener,网格视图还新增了部分属性与方法,新属性与新方法的说明见表8-3。

表8-3 网格视图新增的属性与方法说明

image-20220711164343330

表8-4 网格视图拉伸模式的取值说明

image-20220711164356417

在XML文件中添加GridView需要指定列的数目,以及空隙的拉伸模式,示例如下:

<GridView
       android:id="@+id/gv_planet"
       android:layout_width="match_parent" 
       android:layout_height="wrap_content" 
       android:numColumns="2"
       android:stretchMode="columnWidth" />

网格视图的按压背景与焦点抢占问题类似于列表视图,此外还需注意网格项的拉伸模式,因为同一行的网格项可能占不满该行空间,多出来的空间就由拉伸模式决定怎么分配。接下来做个实验,看看各种拉伸模式分别呈现什么样的界面效果。实验之前先给网格视图设置青色背景,通过观察背景的覆盖区域,即可知晓网格项之间的空隙分布。下面是演示网格视图拉伸模式的代码片段:

(完整代码见chapter08\src\main\java\com\example\chapter08\GridViewActivity.java)

int dividerPad = Utils.dip2px(GridViewActivity.this, 2); // 定义间隔宽度为2dp 
gv_planet.setBackgroundColor(Color.CYAN);  // 设置背景颜色
gv_planet.setHorizontalSpacing(dividerPad);  // 设置列表项在水平方向的间距
gv_planet.setVerticalSpacing(dividerPad);  // 设置列表项在垂直方向的间距
gv_planet.setStretchMode(GridView.STRETCH_COLUMN_WIDTH);  // 设置拉伸模式
gv_planet.setColumnWidth(Utils.dip2px(GridViewActivity.this, 120));  // 设置每列宽度为120dp
gv_planet.setPadding(0, 0, 0, 0);  // 设置网格视图的四周间距 
if (arg2 == 0) {  // 不显示分隔线
   gv_planet.setBackgroundColor(Color.WHITE); 
   gv_planet.setHorizontalSpacing(0);
   gv_planet.setVerticalSpacing(0);
} else if (arg2 == 1) {  // 不拉伸(NO_STRETCH)
   gv_planet.setStretchMode(GridView.NO_STRETCH); 
} else if (arg2 == 2) {  // 拉伸列宽(COLUMN_WIDTH)
   gv_planet.setStretchMode(GridView.STRETCH_COLUMN_WIDTH); 
} else if (arg2 == 3) {  // 列间空隙(STRETCH_SPACING)
   gv_planet.setStretchMode(GridView.STRETCH_SPACING); 
} else if (arg2 == 4) {  // 左右空隙(SPACING_UNIFORM)
   gv_planet.setStretchMode(GridView.STRETCH_SPACING_UNIFORM); 
} else if (arg2 == 5) {  // 使用padding显示全部分隔线
   gv_planet.setPadding(dividerPad, dividerPad, dividerPad, dividerPad); 
}

运行测试App,一开始的行星网格界面如图8-11所示,此时网格视图没有分隔线。点击界面顶部的下拉框,并选择“不拉伸NO_STRETCH”,此时每行的网格项紧挨着,多出来的空隙排在当前行的右边,如图8-12所示。

拉伸模式选择“拉伸列宽(COLUMN_WIDTH)”,此时行星网格界面如图8-13所示,可见每个网格的宽度都变宽了。拉伸模式选择“列间空隙(STRETCH_SPACING)”,此时行星网格界面如图8-14所示,可见多出来的空隙位于网格项中间。

image-20220711164744215

图8-11 没有分隔线效果

image-20220711164830042

图8-12 拉伸模式为NO_STRETCH

image-20220711164846275

图8-13 拉伸模式为COLUMN_WIDTH

image-20220711164903256

图8-14 拉伸模式为STRETCH_SPACING

拉伸模式选择“左右空隙(SPACING_UNIFORM)”,此时行星网格界面如图8-15所示,可见空隙同时出现在网格项的左右两边。拉伸模式选择“使用padding显示全部分隔线”,此时行星网格界面如图8-16所示,可见网格视图的内外边界都显示了分隔线。

image-20220711164934488

图8-15 拉伸模式为SPACING_UNIFORM

image-20220711164954562

图8-16 使用padding显示全部分隔线

接下来继续在实战中运用网格视图,上一节的列表视图已经成功改造了购物车的商品列表,现在使用网格视图改造商品频道页面,六部手机正好做成三行两列的GridView。采用网格视图改造的商品频道页面效果如图8-17所示(完整代码见

chapter08\src\main\java\com\example\chapter08\ShoppingChannelActivity.java)。

image-20220711165044681

📖8.3 翻页类视图

本节介绍翻页类视图的相关用法,包括:翻页视图ViewPager如何搭配翻页适配器PagerAdapter、如何搭配翻页标签栏PagerTabStrip,最后结合实战演示了如何使用翻页视图实现简单的启动引导页。

✅翻页视图ViewPager

上一节介绍的列表视图与网格视图,一个分行展示,另一个分行又分列,其实都是在垂直方向上下滑动。有没有一种控件允许页面在水平方向左右滑动,就像翻书、翻报纸一样呢?为了实现左右滑动的翻页功能,Android提供了相应的控件—翻页视图ViewPager。对于ViewPager来说,一个页面就是一个项(相当于ListView的一个列表项),许多个页面组成了ViewPager的页面项。

既然明确了翻页视图的原理类似列表视图和网格视图,它们的用法也很类似。例如,列表视图和网格视图使用基本适配器BaseAdapter,翻页视图则使用翻页适配器PagerAdapter;列表视图和网格视图使用列表项的点击监听器OnItemClickListener,翻页视图则使用页面变更监听器OnPageChangeListener监听页面切换事件。

下面是翻页视图 3 个常用方法的说明。

  • setAdapter:设置页面项的适配器。适配器用的是PagerAdapter及其子类。
  • setCurrentItem:设置当前页码,也就是要显示哪个页面。
  • addOnPageChangeListener:添加翻页视图的页面变更监听器。该监听器需实现接口OnPageChangeListener下的 3 个方法,具体说明如下。
    • onPageScrollStateChanged:在页面滑动状态变化时触发。
    • onPageScrolled:在页面滑动过程中触发。
    • onPageSelected:在选中页面时,即滑动结束后触发。

在XML文件中添加ViewPager时注意指定完整路径的节点名称,示例如下:

<!-- 注意翻页视图ViewPager的节点名称要填全路径 -->
<androidx.viewpager.widget.ViewPager
android:id="@+id/vp_content"
android:layout_width="match_parent"
android:layout_height="370dp" />

由于翻页视图包含了多个页面项,因此要借助翻页适配器展示每个页面。翻页适配器的实现原理与基本适配器类似,从PagerAdapter派生的翻页适配器主要实现下面 6 个方法。

  • 构造方法:指定适配器需要处理的数据集合。
  • getCount:获取页面项的个数。
  • isViewFromObject:判断当前视图是否来自指定对象,返回view == object即可。
  • instantiateItem:实例化指定位置的页面,并将其添加到容器中。
  • destroyItem:从容器中销毁指定位置的页面。
  • getPageTitle:获得指定页面的标题文本,有搭配翻页标签栏时才要实现该方法。

以商品信息为例,翻页适配器需要通过构造方法传入商品列表,再由instantiateItem方法实例化视图对象并添加至容器,详细的翻页适配器代码示例如下::

(完整代码见

chapter08\src\main\java\com\example\chapter08\adapter\ImagePagerAdapater.java)

public class ImagePagerAdapater extends PagerAdapter {
   // 声明一个图像视图列表
   private List<ImageView> mViewList = new ArrayList<ImageView>();
   // 声明一个商品信息列表
   private List<GoodsInfo> mGoodsList = new ArrayList<GoodsInfo>();
   // 图像翻页适配器的构造方法,传入上下文与商品信息列表
   public ImagePagerAdapater(Context context, List<GoodsInfo> goodsList) {
       mGoodsList = goodsList;
       // 给每个商品分配一个专用的图像视图
       for (int i = 0; i < mGoodsList.size(); i++) {
           ImageView view = new ImageView(context); // 创建一个图像视图对象
           view.setLayoutParams(new LayoutParams(
                   LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
           view.setImageResource(mGoodsList.get(i).pic);
           mViewList.add(view); // 把该商品的图像视图添加到图像视图列表
    }
 }
   // 获取页面项的个数
   public int getCount() {
       return mViewList.size();
 }
   // 判断当前视图是否来自指定对象
   public boolean isViewFromObject(View view, Object object) {
       return view == object;
 }
   // 从容器中销毁指定位置的页面
   public void destroyItem(ViewGroup container, int position, Object object) {
       container.removeView(mViewList.get(position));
 }
   // 实例化指定位置的页面,并将其添加到容器中
   public Object instantiateItem(ViewGroup container, int position) {
       container.addView(mViewList.get(position));
       return mViewList.get(position);
 }
   // 获得指定页面的标题文本
   public CharSequence getPageTitle(int position) {
       return mGoodsList.get(position).name;
 }
}

接着回到活动页面代码,给翻页视图设置上述的翻页适配器,代码如下所示:

(完整代码见chapter08\src\main\java\com\example\chapter08\ViewPagerActivity.java)

public class ViewPagerActivity extends AppCompatActivity implements 
OnPageChangeListener {
   private List<GoodsInfo> mGoodsList; // 手机商品列表
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_view_pager);
       mGoodsList = GoodsInfo.getDefaultList();
       // 构建一个商品图片的翻页适配器
       ImagePagerAdapater adapter = new ImagePagerAdapater(this, mGoodsList);
       // 从布局视图中获取名叫vp_content的翻页视图
       ViewPager vp_content = findViewById(R.id.vp_content);
       vp_content.setAdapter(adapter); // 设置翻页视图的适配器
       vp_content.setCurrentItem(0); // 设置翻页视图显示第一页
       vp_content.addOnPageChangeListener(this); // 给翻页视图添加页面变更监听器
 }
   // 翻页状态改变时触发。state取值说明为:0表示静止,1表示正在滑动,2表示滑动完毕
   // 在翻页过程中,状态值变化依次为:正在滑动→滑动完毕→静止
   public void onPageScrollStateChanged(int state) {}
   // 在翻页过程中触发。该方法的三个参数取值说明为 :第一个参数表示当前页面的序号
   // 第二个参数表示页面偏移的百分比,取值为0到1;第三个参数表示页面的偏移距离
   public void onPageScrolled(int position, float ratio, int offset) {}
   // 在翻页结束后触发。position表示当前滑到了哪一个页面
   public void onPageSelected(int position) {
       Toast.makeText(this, "您翻到的手机品牌是:" + mGoodsList.get(position).name, 
Toast.LENGTH_SHORT).show();
 } 
}

由于监听器OnPageChangeListener多数情况只用到onPageSelected方法,很少用到onPageScrollStateChanged和onPageScrolled两个方法,因此Android又提供了简化版的页面变更监听器名为SimpleOnPageChangeListener,新的监听器仅需实现onPageSelected方法。给翻页视图添加简化版监听器的代码示例如下:

// 给翻页视图添加简化版的页面变更监听器
vp_content.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { 
   @Override
   public void onPageSelected(int position) {
       Toast.makeText(ViewPagerActivity.this, "您翻到的手机品牌是:" 
                      + mGoodsList.get(position).name,
Toast.LENGTH_SHORT).show(); 
 }
});

然后运行测试App,初始的翻页界面如图8-18所示,此时整个页面只显示第一部手机。用手指从右向左活动页面,滑到一半的界面如图8-19所示,可见第一部手机逐渐向左隐去,而第二部手机逐渐从右边拉出。继续向左活动一段距离再松开手指,此时滑动结束的界面如图8-20所示,可见整个页面完全显示第二部手机了。

image-20220711172250297image-20220711172310916

✅翻页标签栏PagerTabStrip

尽管翻页视图实现了左右滑动,可是没滑动的时候看不出这是个翻页视图,而且也不晓得当前滑到了哪个页面。为此Android提供了翻页标签栏PagerTabStrip,它能够在翻页视图上方显示页面标题,从而方便用户的浏览操作。PagerTabStrip类似选项卡效果,文本下面有横线,点击左右选项卡即可切换到对应页面。给翻页视图引入翻页标签栏只需下列两个步骤:

步骤一,在XML文件的ViewPager节点内部添加PagerTabStrip节点,示例如下:

(完整代码见chapter08\src\main\res\layout\activity_pager_tab.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">
   <!-- 注意翻页视图ViewPager的节点名称要填全路径 -->
   <androidx.viewpager.widget.ViewPager
       android:id="@+id/vp_content"
       android:layout_width="match_parent"
       android:layout_height="400dp">
       <!-- 注意翻页标签栏PagerTabStrip的节点名称要填全路径 -->
       <androidx.viewpager.widget.PagerTabStrip
           android:id="@+id/pts_tab"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content" />
   </androidx.viewpager.widget.ViewPager> 
</LinearLayout>

步骤二,在翻页适配器的代码中重写getPageTitle方法,在不同位置返回对应的标题文本,示例代码如下:

(完整代码见

chapter08\src\main\java\com\example\chapter08\adapter\ImagePagerAdapater.java)

// 获得指定页面的标题文本
public CharSequence getPageTitle(int position) { 
   return mGoodsList.get(position).name;
}

完成上述两步骤之后,重新运行测试App,即可观察翻页标签栏的界面效果。如图8-21和图8-22所示,这是翻到不同页面的翻页视图,可见界面正上方是当前页面的标题,左上方文字是左边页面的标题,右上方文字是右边页面的标题。

image-20220711172716823image-20220711172731623

图8-22 翻页标签栏的界面效果 2

另外,若想修改翻页标签栏的文本样式,必须在Java代码中调用setTextSize和setTextColor方法才行,因为PagerTabStrip不支持在XML文件中设置文本大小和文本颜色,只能在代码中设置文本样式,具体的设置代码如下所示:

(完整代码见chapter08\src\main\java\com\example\chapter08\PagerTabActivity.java)

// 初始化翻页标签栏
private void initPagerStrip() {
   // 从布局视图中获取名叫pts_tab的翻页标签栏
   PagerTabStrip pts_tab = findViewById(R.id.pts_tab); 
   // 设置翻页标签栏的文本大小
   pts_tab.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
   pts_tab.setTextColor(Color.BLACK); // 设置翻页标签栏的文本颜色 
}

✅简单的启动引导页

翻页视图的使用范围很广,当用户安装一个新应用时,首次启动大多出现欢迎页面,这个引导页要往右翻好几页,才会进入应用主页。这种启动引导页就是通过翻页视图实现的。下面就来动手打造你的第一个App启动欢迎页吧!翻页技术的核心在于页面项的XML布局及其适配器,因此首先要设计页面项的布局。一般来说,引导页由两部分组成,一部分是背景图;另一部分是页面下方的一排圆点,其中高亮的圆点表示当前位于第几页。启动引导页的界面效果如图8-23与图8-24所示。

其中,图8-23为欢迎页面的第一页,此时第一个圆点高亮显示;图8-24为右翻到了第二页,此时第二个圆点高亮显示。

image-20220711172941623

图8-23 欢迎页的第一页

image-20220711173007727

图8-24 欢迎页的第二页

除了背景图与一排圆点之外,最后一页往往有个按钮,它便是进入应用主页的入口。于是页面项的XML文件至少包含 3 个控件:引导页的背景图(采用ImageView)、底部的一排圆点(采用RadioGroup)、 最后一页的入口按钮(采用Button),XML内容示例如下:

(完整代码见chapter08\src\main\res\layout\item_launch.xml)

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent">
   <!-- 这是引导图片的图像视图 -->
   <ImageView
       android:id="@+id/iv_launch"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:scaleType="fitXY" />
   <!-- 这里容纳引导页底部的一排圆点 -->
   <RadioGroup
       android:id="@+id/rg_indicate"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_alignParentBottom="true"
       android:layout_centerHorizontal="true"
       android:orientation="horizontal"
       android:paddingBottom="20dp" />
   <!-- 这是最后一页的入口按钮 -->
   <Button
       android:id="@+id/btn_start"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_centerInParent="true"
       android:text="立即开始美好生活"
       android:textColor="#ff3300"
       android:textSize="22sp"
       android:visibility="gone" />" 
</RelativeLayout>

根据上面的XML文件,引导页的最后两页如图8-25与图8-26所示。其中,图8-25是第三页,此时第三个圆点高亮显示;图8-26是最后一页,只有该页才会显示入口按钮。

image-20220711173215436

图8-26 欢迎页的最后一页

写好了页面项的XML布局,还得编写启动引导页的适配器代码,主要完成 3 项工作:

( 1 )根据页面项的XML文件构造每页的视图。

( 2 )让当前页码的圆点高亮显示。

( 3 )如果翻到了最后一页,就显示中间的入口按钮。

下面是启动引导页对应的翻页适配器代码例子:

(完整代码见chapter08\src\main\java\com\example\chapter08\adapter\LaunchSimpleAdapter.java)

public class LaunchSimpleAdapter extends PagerAdapter {
   private List<View> mViewList = new ArrayList<View>(); // 声明一个引导页的视图列表 
   // 引导页适配器的构造方法,传入上下文与图片数组
   public LaunchSimpleAdapter(final Context context, int[] imageArray) { 
       for (int i = 0; i < imageArray.length; i++) {
           // 根据布局文件item_launch.xml生成视图对象 
           View view =
LayoutInflater.from(context).inflate(R.layout.item_launch, null);
           ImageView iv_launch = view.findViewById(R.id.iv_launch);
           RadioGroup rg_indicate = view.findViewById(R.id.rg_indicate); 
           Button btn_start = view.findViewById(R.id.btn_start);
           iv_launch.setImageResource(imageArray[i]); // 设置引导页的全屏图片 
           // 每个页面都分配一个对应的单选按钮
           for (int j = 0; j < imageArray.length; j++) {
               RadioButton radio = new RadioButton(context); // 创建一个单选按钮 
               radio.setLayoutParams(new LayoutParams(
                       LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); 
               radio.setButtonDrawable(R.drawable.launch_guide); // 设置单选按钮的图标
               radio.setPadding(10, 10, 10, 10); // 设置单选按钮的四周间距
               rg_indicate.addView(radio); // 把单选按钮添加到页面底部的单选组
      }
           // 当前位置的单选按钮要高亮显示,比如第二个引导页就高亮第二个单选按钮
          ((RadioButton) rg_indicate.getChildAt(i)).setChecked(true);
           // 如果是最后一个引导页,则显示入口按钮,以便用户点击按钮进入主页
           if (i == imageArray.length - 1) {
               btn_start.setVisibility(View.VISIBLE);
               btn_start.setOnClickListener(new OnClickListener() {
                   @Override
                   public void onClick(View v) {
                       // 这里要跳到应用主页
                       Toast.makeText(context, "欢迎您开启美好生活",
                               Toast.LENGTH_SHORT).show();
          }
              });
      }
           mViewList.add(view); // 把该图片对应的页面添加到引导页的视图列表
    }
 }
   // 获取页面项的个数
   public int getCount() {
       return mViewList.size();
 }
   // 判断当前视图是否来自指定对象
   public boolean isViewFromObject(View view, Object object) {
       return view == object;
 }
   // 从容器中销毁指定位置的页面
   public void destroyItem(ViewGroup container, int position, Object object) {
       container.removeView(mViewList.get(position));
 }
   // 实例化指定位置的页面,并将其添加到容器中
   public Object instantiateItem(ViewGroup container, int position) {
       container.addView(mViewList.get(position));
       return mViewList.get(position);
 } 
}

📖8.4 碎片Fragment

本节介绍碎片的概念及其用法,包括:通过静态注册方式使用碎片、通过动态注册方式使用碎片(需要配合碎片适配器FragmentPagerAdapter),并分析两种注册方式的碎片生命周期,最后结合实战演示了如何使用碎片改进启动引导页。

✅碎片的静态注册

碎片Fragment是个特别的存在,它有点像报纸上的专栏,看起来只占据页面的一小块区域,但是这一区域有自己的生命周期,可以自行其是,仿佛独立王国;并且该区域只占据空间不扰乱业务,添加之后不影响宿主页面的其他区域,去除之后也不影响宿主页面的其他区域。每个碎片都有对应的XML布局文件,依据其使用方式可分为静态注册与动态注册两类。静态注册指的是在XML文件中直接放置fragment节点,类似于一个普通控件,可被多个布局文件同时引用。静态注册一般用于某个通用的页面部件(如Logo条、广告条等),每个活动页面均可直接引用该部件。

下面是碎片页对应的XML文件内容,看起来跟列表项与网格项的布局文件差不多。

(完整代码见chapter08\src\main\res\layout\fragment_static.xml)

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:orientation="horizontal"
   android:background="#bbffbb">
   <TextView
       android:id="@+id/tv_adv"
       android:layout_width="0dp"
       android:layout_height="match_parent"
       android:layout_weight="1"
       android:gravity="center"
       android:text="广告图片"
       android:textColor="#000000"
       android:textSize="17sp" />
   <ImageView
       android:id="@+id/iv_adv"
       android:layout_width="0dp"
       android:layout_height="match_parent"
       android:layout_weight="4"
       android:src="@drawable/adv"
       android:scaleType="fitCenter" />
</LinearLayout>

下面是与上述XML布局对应的碎片代码,除了继承自Fragment与入口方法onCreateView两点,其他地方类似活动页面代码。

(完整代码见chapter08\src\main\java\com\example\chapter08\fragment\StaticFragment.java)

public class StaticFragment extends Fragment implements View.OnClickListener { 
   private static final String TAG = "StaticFragment";
   protected View mView; // 声明一个视图对象
   protected Context mContext; // 声明一个上下文对象 
   // 创建碎片视图
   @Override
   public View onCreateView(LayoutInflater inflater, ViewGroup container, 
Bundle savedInstanceState) {
       mContext = getActivity(); // 获取活动页面的上下文 
       // 根据布局文件fragment_static.xml生成视图对象
       mView = inflater.inflate(R.layout.fragment_static, container, false); 
       TextView tv_adv = mView.findViewById(R.id.tv_adv);
       ImageView iv_adv = mView.findViewById(R.id.iv_adv);
       tv_adv.setOnClickListener(this); // 设置点击监听器
       iv_adv.setOnClickListener(this); // 设置点击监听器
       Log.d(TAG, "onCreateView");
       return mView; // 返回该碎片的视图对象
 }
   @Override
   public void onClick(View v) {
       if (v.getId() == R.id.tv_adv) {
           Toast.makeText(mContext, "您点击了广告文本", Toast.LENGTH_LONG).show();
      } else if (v.getId() == R.id.iv_adv) {
           Toast.makeText(mContext, "您点击了广告图片", Toast.LENGTH_LONG).show();
    }
 } 
}

若想在活动页面的XML文件中引用上面定义的StaticFragment,可以直接添加一个fragment节点,但需注意下列两点:

( 1 )fragment节点必须指定id属性,否则App运行会报错。

( 2 )fragment节点必须通过name属性指定碎片类的完整路径。

下面是在布局文件中引用碎片的XML例子。

(完整代码见chapter08\src\main\res\layout\activity_fragment_static.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:name指明了碎片来源 -->
   <fragment
       android:id="@+id/fragment_static"
       android:name="com.example.chapter08.fragment.StaticFragment"
       android:layout_width="match_parent"
       android:layout_height="60dp" />
   <TextView
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:gravity="center"
       android:text="这里是每个页面的具体内容"
       android:textColor="#000000"
       android:textSize="17sp" /> 
</LinearLayout>

运行测试App,可见碎片所在界面如图8-27所示。此时碎片区域仿佛一个视图,其内部控件同样可以接收点击事件。

image-20220711185040597

图8-27 静态注册的碎片效果

另外,介绍一下碎片在静态注册时的生命周期,像活动的基本生命周期方法onCreate、onStart、onResume、onPause、onStop、onDestroy,碎片同样也有,而且还多出了下面 5 个生命周期方法。

  • onAttach:与活动页面结合。
  • onCreateView:创建碎片视图。
  • onActivityCreated:在活动页面创建完毕后调用。
  • onDestroyView:回收碎片视图。
  • onDetach:与活动页面分离。

至于这些周期方法的先后调用顺序,观察日志最简单明了。下面是打开活动页面时的日志信息,此时碎片的onCreate方法先于活动的onCreate方法,而碎片的onStart与onResume均在活动的同名方法之后。

12:26:11.506:D/StaticFragment:onAttach
12:26:11.506:D/StaticFragment:onCreate
12:26:11.530:D/StaticFragment:onCreateView
12:26:11.530:D/FragmentStaticActivity:onCreate
12:26:11.530:D/StaticFragment:onActivityCreated
12:26:11.530:D/FragmentStaticActivity:onStart
12:26:11.530:D/StaticFragment:onStart
12:26:11.530:D/FragmentStaticActivity:onResume
12:26:11.530:D/StaticFragment:onResume

下面是退出活动页面时的日志信息,此时碎片的onPause、onStop、onDestroy都在活动的同名方法之前。

12:26:36.586:D/StaticFragment:onPause
12:26:36.586:D/FragmentStaticActivity:onPause
12:26:36.990:D/StaticFragment:onStop
12:26:36.990:D/FragmentStaticActivity:onStop
12:26:36.990:D/StaticFragment:onDestroyView
12:26:36.990:D/StaticFragment:onDestroy
12:26:36.990:D/StaticFragment:onDetach
12:26:36.990:D/FragmentStaticActivity:onDestroy

总结一下,在静态注册时,除了碎片的创建操作在页面创建之前,其他操作没有僭越页面范围。就像老实本分的下级,上级开腔后才能说话,上级要做总结性发言前赶紧闭嘴。

✅碎片的动态注册

碎片拥有两种使用方式,也就是静态注册和动态注册。相比静态注册,实际开发中动态注册用得更多。静态注册是在XML文件中直接添加fragment节点,而动态注册迟至代码执行时才动态添加碎片。动态生成的碎片基本给翻页视图使用,要知道ViewPager和Fragment可是一对好搭档。要想在翻页视图中使用动态碎片,关键在于适配器。在“8.3.1 翻页视图ViewPager”小节演示翻页功能时,用到了翻页适配器PagerAdapter。如果结合使用碎片,翻页视图的适配器就要改用碎片适配器FragmentPagerAdapter。与翻页适配器相比,碎片适配器增加了getItem方法用于获取指定位置的碎片,同时去掉了isViewFromObject、instantiateItem、destroyItem三个方法,用起来更加容易。下面是一个碎片适配器的实现代码例子。

(完整代码见chapter08\src\main\java\com\example\chapter08\adapter\MobilePagerAdapter.java)

public class MobilePagerAdapter extends FragmentPagerAdapter {
   private List<GoodsInfo> mGoodsList = new ArrayList<GoodsInfo>(); // 声明一个商品列表
   // 碎片页适配器的构造方法,传入碎片管理器与商品信息列表
   public MobilePagerAdapter(FragmentManager fm, List<GoodsInfo> goodsList) {
       super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
       mGoodsList = goodsList;
 }
   // 获取碎片Fragment的个数
   public int getCount() {
       return mGoodsList.size();
 }
   // 获取指定位置的碎片Fragment
   public Fragment getItem(int position) {
       return DynamicFragment.newInstance(position,
               mGoodsList.get(position).pic, mGoodsList.get(position).desc);
 }
   // 获得指定碎片页的标题文本
   public CharSequence getPageTitle(int position) {
       return mGoodsList.get(position).name;
 } 
}

上面的适配器代码在getItem方法中不调用碎片的构造方法,却调用了newInstance方法,目的是给碎片对象传递参数信息。由newInstance方法内部先调用构造方法创建碎片对象,再调用setArguments方法塞进请求参数,然后在onCreateView中调用getArguments方法才能取出请求参数。下面是在动态注册时传递请求参数的碎片代码例子:

(完整代码见chapter08\src\main\java\com\example\chapter08\fragment\DynamicFragment.java)

public class DynamicFragment extends Fragment {
   private static final String TAG = "DynamicFragment"; 
   protected View mView; // 声明一个视图对象
   protected Context mContext; // 声明一个上下文对象 
   private int mPosition; // 位置序号
   private int mImageId; // 图片的资源编号 
   private String mDesc; // 商品的文字述
   // 获取该碎片的一个实例
   public static DynamicFragment newInstance(int position, int image_id, String 
desc) {
       DynamicFragment fragment = new DynamicFragment(); // 创建该碎片的一个实例
       Bundle bundle = new Bundle(); // 创建一个新包裹
       bundle.putInt("position", position); // 往包裹存入位置序号
       bundle.putInt("image_id", image_id); // 往包裹存入图片的资源编号
       bundle.putString("desc", desc); // 往包裹存入商品的文字述
       fragment.setArguments(bundle); // 把包裹塞给碎片
       return fragment; // 返回碎片实例
 }
   // 创建碎片视图
   public View onCreateView(LayoutInflater inflater, ViewGroup container, 
Bundle savedInstanceState) {
       mContext = getActivity(); // 获取活动页面的上下文
       if (getArguments() != null) { // 如果碎片携带有包裹,就打开包裹获取参数信息
           mPosition = getArguments().getInt("position", 0); // 从包裹取出位置序号 
           mImageId = getArguments().getInt("image_id", 0); // 从包裹取出图片的资源编号
           mDesc = getArguments().getString("desc"); // 从包裹取出商品的文字述
    }
       // 根据布局文件fragment_dynamic.xml生成视图对象
       mView = inflater.inflate(R.layout.fragment_dynamic, container, false);
       ImageView iv_pic = mView.findViewById(R.id.iv_pic);
       TextView tv_desc = mView.findViewById(R.id.tv_desc);
       iv_pic.setImageResource(mImageId);
       tv_desc.setText(mDesc);
       Log.d(TAG, "onCreateView position=" + mPosition);
       return mView; // 返回该碎片的视图对象
 } 
}

现在有了适用于动态注册的适配器与碎片对象,还需要一个活动页面展示翻页视图及其搭配的碎片适配器。下面便是动态注册用到的活动页面代码。

(完整代码见chapter08\src\main\java\com\example\chapter08\FragmentDynamicActivity.java)

public class FragmentDynamicActivity extends AppCompatActivity {
   private static final String TAG = "FragmentDynamicActivity";
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_fragment_dynamic);
       List<GoodsInfo> goodsList = GoodsInfo.getDefaultList();
       // 构建一个手机商品的碎片翻页适配器
       MobilePagerAdapter adapter = new MobilePagerAdapter(
               getSupportFragmentManager(), goodsList);
       // 从布局视图中获取名叫vp_content的翻页视图
       ViewPager vp_content = findViewById(R.id.vp_content);
       vp_content.setAdapter(adapter); // 设置翻页视图的适配器
       vp_content.setCurrentItem(0); // 设置翻页视图显示第一页
 } 
}

运行测试App,初始的碎片界面如图8-28所示,此时默认展示第一个碎片,包含商品图片和商品描述。接着一路滑到最后一页如图8-29所示,此时展示了最后一个碎片,可见总体界面效果类似于“8.3.2 翻页标签栏PagerTabStrip”那样。

image-20220711192649053

图8-28 翻到第一个碎片界面

image-20220711192723969

图8-29 翻到最后一个碎片界面

接下来观察动态注册时候的碎片生命周期。按惯例分别在活动代码与碎片代码内部补充生命周期的日志,然后观察App运行日志。下面是打开活动页面时的日志信息:

12:28:28.074:D/FragmentDynamicActivity:onCreate
12:28:28.074:D/FragmentDynamicActivity:onStart
12:28:28.074:D/FragmentDynamicActivity:onResume
12:28:28.086:D/DynamicFragment:onAttach position=0
12:28:28.086:D/DynamicFragment:onCreate position=0
12:28:28.114:D/DynamicFragment:onCreateView position=0
12:28:28.114:D/DynamicFragment:onActivityCreated position=0
12:28:28.114:D/DynamicFragment:onStart position=0
12:28:28.114:D/DynamicFragment:onResume position=0
12:28:28.114:D/DynamicFragment:onAttach position=0
12:28:28.114:D/DynamicFragment:onCreate position=0
12:28:28.146:D/DynamicFragment:onCreateView position=1
12:28:28.146:D/DynamicFragment:onStart position=1
12:28:28.146:D/DynamicFragment:onResume position=1

下面是退出活动页面时的日志信息:

12:28:57.994:D/DynamicFragment:onPause position=0
12:28:57.994:D/DynamicFragment:onPause position=1
12:28:57.994:D/FragmentDynamicActivity:onPause
12:28:58.402:D/DynamicFragment:onStop position=0
12:28:58.402:D/DynamicFragment:onStop position=1
12:28:58.402:D/FragmentDynamicActivity:onStop
12:28:58.402:D/DynamicFragment:onDestroyView position=0
12:28:58.402:D/DynamicFragment:onDestroy position=0
12:28:58.402:D/DynamicFragment:onDetach position=0
12:28:58.402:D/DynamicFragment:onDestroyView position=1
12:28:58.402:D/DynamicFragment:onDestroy position=1
12:28:58.402:D/DynamicFragment:onDetach position=1
12:28:58.402:D/FragmentDynamicActivity:onDestroy

日志搜集完毕,分析其中的奥妙,总结一下主要有以下 3 点:

( 1 )动态注册时,碎片的onCreate方法在活动的onCreate方法之后,其余方法的先后顺序与静态注册时保持一致。

( 2 )注意onActivityCreated方法,无论是静态注册还是动态注册,该方法都在活动的onCreate方法之后,可见该方法的确在页面创建之后才调用。

( 3 )最重要的一点,进入第一个碎片之际,实际只加载了第一页和第二页,并没有加载所有碎片页,这正是碎片动态注册的优点。无论当前位于哪一页,系统都只会加载当前页及相邻的左右两页,总共加载不超过 3 页。一旦发生页面切换,相邻页面就被加载,非相邻页面就被回收。这么做的好处是节省了宝贵的系统资源,只有用户正在浏览与将要浏览的碎片页才会加载,避免所有碎片页一起加载造成资源浪费,后者正是普通翻页视图的缺点。

✅改进的启动引导页

接下来将碎片用于实战,对“8.3.3 简单的启动引导页”加以改进。与之前相比,XML文件不变,改动的都是Java代码。下面是用于启动引导页的碎片适配器代码:

(完整代码见chapter08\src\main\java\com\example\chapter08\adapter\LaunchImproveAdapter.java)

public class LaunchImproveAdapter extends FragmentPagerAdapter {
   private int[] mImageArray; // 声明一个图片数组
   // 碎片页适配器的构造方法,传入碎片管理器与图片数组
   public LaunchImproveAdapter(FragmentManager fm, int[] imageArray) {
       super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
       mImageArray = imageArray;
 }
   // 获取碎片Fragment的个数
   public int getCount() {
       return mImageArray.length;
 }
   // 获取指定位置的碎片Fragment
   public Fragment getItem(int position) {
       return LaunchFragment.newInstance(position, mImageArray[position]);
 } 
}

以上的碎片适配器代码倒是简单,原来与视图控件有关的操作都挪到碎片代码当中了,下面是每个启动页的碎片代码例子:

(完整代码见chapter08\src\main\java\com\example\chapter08\fragment\LaunchFragment.java)

public class LaunchFragment extends Fragment {
   protected View mView; // 声明一个视图对象
   protected Context mContext; // 声明一个上下文对象
   private int mPosition; // 位置序号
   private int mImageId; // 图片的资源编号
   private int mCount = 4; // 引导页的数量
   // 获取该碎片的一个实例
   public static LaunchFragment newInstance(int position, int image_id) {
       LaunchFragment fragment = new LaunchFragment(); // 创建该碎片的一个实例
       Bundle bundle = new Bundle(); // 创建一个新包裹
       bundle.putInt("position", position); // 往包裹存入位置序号
       bundle.putInt("image_id", image_id); // 往包裹存入图片的资源编号
       fragment.setArguments(bundle); // 把包裹塞给碎片
       return fragment; // 返回碎片实例
 }
   // 创建碎片视图
   public View onCreateView(LayoutInflater inflater, ViewGroup container, 
Bundle savedInstanceState) {
       mContext = getActivity(); // 获取活动页面的上下文
       if (getArguments() != null) { // 如果碎片携带有包裹,就打开包裹获取参数信息
           mPosition = getArguments().getInt("position", 0); // 从包裹获取位置序号 
           mImageId = getArguments().getInt("image_id", 0); // 从包裹获取图片的资源编号
    }
       // 根据布局文件item_launch.xml生成视图对象
       mView = inflater.inflate(R.layout.item_launch, container, false);
       ImageView iv_launch = mView.findViewById(R.id.iv_launch);
       RadioGroup rg_indicate = mView.findViewById(R.id.rg_indicate);
       Button btn_start = mView.findViewById(R.id.btn_start);
       iv_launch.setImageResource(mImageId); // 设置引导页的全屏图片
       // 每个页面都分配一个对应的单选按钮
       for (int j = 0; j < mCount; j++) {
           RadioButton radio = new RadioButton(mContext); // 创建一个单选按钮 
           radio.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, 
LayoutParams.WRAP_CONTENT));
           radio.setButtonDrawable(R.drawable.launch_guide); // 设置单选按钮的图标
           radio.setPadding(10, 10, 10, 10); // 设置单选按钮的四周间距
           rg_indicate.addView(radio); // 把单选按钮添加到页面底部的单选组
    }
       // 当前位置的单选按钮要高亮显示,比如第二个引导页就高亮第二个单选按钮
      ((RadioButton) rg_indicate.getChildAt(mPosition)).setChecked(true);
       // 如果是最后一个引导页,则显示入口按钮,以便用户点击按钮进入首页
       if (mPosition == mCount - 1) {
           btn_start.setVisibility(View.VISIBLE);
           btn_start.setOnClickListener(new View.OnClickListener() {
               @Override
               public void onClick(View v) {
                   // 这里要跳到应用主页
                   Toast.makeText(mContext, "欢迎您开启美好生活", 
Toast.LENGTH_SHORT).show();
        } 
          }); 
    }
       return mView; // 返回该碎片的视图对象 
 }
}

经过碎片改造后的启动引导页,其界面效果跟“8.3.3 简单的启动引导页”是一样的。尽管看不出界面上的差异,但引入碎片之后至少有以下两个好处。

( 1 )加快启动速度。因为动态注册的碎片,一开始只会加载前两个启动页,对比原来加载所有启动页(至少 4 页),无疑大幅减少了加载页的数量,从而提升了启动速度。

( 2 )降低代码耦合。把视图操作剥离到单独的碎片代码,不与适配器代码混合在一起,方便后继的代码维护工作。

📖8.5 实战项目:记账本

人云:你不理财,财不理你。从工作开始,年轻人就要好好管理自己的个人收支。每年的收入减去支出,剩下的结余才是进一步发展的积累资金。记账本便是管理日常收支的好帮手,一个易用的记账本App有助于合理安排个人资金。

✅需求描述

好用的记账本必须具备两项基本功能,一项是记录新账单,另一项是查看账单列表。其中账单的记录操作要求用户输入账单的明细要素,包括账单的发生时间、账单的收支类型(收入还是支出)、账单的交易金额、账单的事由描述等,据此勾勒简易的账单添加界面如图8-30所示。账单列表页通常分月展示,每页显示单个月份的账单数据,还要支持在不同月份之间切换。每月的账单数据按照时间从上往下排列,每行的账单明细则需依次展示账单日期、事由描述、交易金额等信息,然后列表末尾展示当月的账单合计情况(总共收入多少、总共支出多少)。根据这些要求描绘的账单列表界面原型如图8-31所示。账单的填写功能对应数据库记录的添加操作,账单的展示功能对应数据库记录的查询操作,数据库记录还有修改和删除操作,分别对应账单的编辑功能和删除功能。账单的编辑页面原型如图8-32所示,至于删除操作则由如图8-33所示的提示窗控制,点击“是”按钮表示确定删除,点击“否”按钮表示取消删除。

image-20220711193408551

图8-30 账单填写页面

image-20220711193426496

图8-31 账单列表页面

image-20220711193445963

图8-32 账单编辑页面

image-20220711193500744

图8-33 删除账单的提示窗

✅界面设计

除了文本视图、按钮、编辑框、单选按钮等简单控件之外,记账本还用到了下列控件以及相关的适配器:

  • 翻页视图ViewPager:每页一个月份,一年 12 个月,支持左右滑动,用到了ViewPager。
  • 翻页标签栏PagerTabStrip:每个账单页上方的月份标题来自PagerTabStrip。
  • 碎片适配器FragmentPagerAdapter:把 12 个月份的Fragment组装到ViewPager中,用到了碎片适配器。
  • 碎片Fragment: 12 个月份对应 12 个账单页,每页都是一个碎片Fragment。
  • 列表视图ListView:每月的账单明细从上往下排列,采用了ListView。
  • 基本适配器BaseAdapter:每行的账单项依次展示账单日期、事由描述、交易金额等信息,需要列表视图搭档基本适配器。
  • 提醒对话框AlertDialog:删除账单项的提示窗用到了AlertDialog。
  • 日期选择对话框DatePickerDialog:填写账单信息时,要通过DatePickerDialog选择账单日期。

记账本的几个页面当中,账单列表页面使用了好几种高级控件,又有翻页视图又有列表视图,以及它们各自的数据适配器,看起来颇为复杂。为方便读者理清该页面的控件联系,图8-34列出了从活动页面开始直到账单行的依赖嵌套关系(账单总体页面→每个月份的账单页→每月账单的明细列表→每行的账单信息)。

image-20220711193638775

图8-34 账单列表页面的控件嵌套关系

✅关键代码

为了方便读者顺利完成记账本的编码开发,下面罗列几处关键的代码实现逻辑。

1 .如何实现日期下拉框

填写账单时间的时候,输入界面默认展示当天日期,用户若想修改账单时间,就要点击日期文本,此时界面弹出日期选择对话框,待用户选完具体日期,再回到主界面展示选定日期的文本。这种实现方式类似于下拉框控件Spinner,可是点击Spinner会弹出文本列表对话框,而非日期选择对话框。尽管Android未提供现成的日期下拉框,但是结合文本视图与日期选择对话框,也能实现类似Spinner的日期下拉框效果。具体步骤说明如下:

步骤一,在账单填写页面的XML文件中添加名为tv_date的TextView,并给它指定drawableRight属性,

属性值为一个向下三角形的资源图片,也就是让该控件看起来像个下拉框。包含tv_date在内的账单时间布局片段示例如下:

(完整代码见chapter08\src\main\res\layout\activity_bill_add.xml)

<LinearLayout
             android:layout_width="match_parent"
             android:layout_height="40dp"
             android:layout_margin="5dp"
             android:orientation="horizontal">
   <TextView
             android:layout_width="wrap_content"
             android:layout_height="match_parent"
             android:gravity="center|right"
             android:text="账单日期:"
             android:textColor="@color/black"
             android:textSize="17sp" />
   <TextView
             android:id="@+id/tv_date"
             android:layout_width="0dp"
             android:layout_height="match_parent"
             android:layout_weight="2"
             android:drawableRight="@drawable/arrow_down"
             android:gravity="center"
             android:textColor="@color/black"
             android:textSize="17sp" /> 
</LinearLayout>

步骤二,回到该页面对应的Java代码,给文本视图tv_date注册点击监听器,一旦发现用户点击了该视图,就弹出日期选择对话框DatePickerDialog。下面是控件tv_date的点击响应代码例子:

(完整代码见chapter08\src\main\java\com\example\chapter08\BillAddActivity.java)

@Override
public void onClick(View v) {
   if (v.getId() == R.id.tv_date) {
       // 构建一个日期对话框,该对话框已经集成了日期选择器。
       // DatePickerDialog的第二个构造参数指定了日期监听器
       DatePickerDialog dialog = new DatePickerDialog(this, this,
                       calendar.get(Calendar.YEAR), // 年份
                       calendar.get(Calendar.MONTH), // 月份
                       calendar.get(Calendar.DAY_OF_MONTH)); // 日子
       dialog.show(); // 显示日期选择对话框
 } 
}

步骤三,注意到第二步构建日期对话框时,将日期监听器设在了当前页面,于是令活动代码实现日期变更监听接口DatePickerDialog.OnDateSetListener,同时还要重写该接口的onDateSet方法,一旦发现用户选择了某个日期,就将文本视图tv_date设为该日期文本。重写后的onDateSet方法代码示例如下:

@Override
public void onDateSet(DatePicker view, int year, int month, int dayOfMonth) { 
   calendar.set(Calendar.YEAR, year);
   calendar.set(Calendar.MONTH, month);
   calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth); 
   tv_date.setText(DateUtil.getDate(calendar)); 
}

2 .如何编辑与删除账单项

需求描述提到既要支持账单的编辑功能,又要支持账单的删除功能,因为账单明细位于列表视图当中,且列表视图允许同时设置列表项的点击监听器和长按监听器,所以可考虑将列表项的点击监听器映射到账单的编辑功能,将列表项的长按监听器映射到账单的删除功能,也就是点击账单项时跳到账单的编辑页面,长按账单项时弹出删除账单的提醒对话框。为此需要在账单的列表页实现下列两个步骤:

步骤一,给每月账单的列表视图分别注册列表项的点击监听器和长按监听器,注册代码如下:

(完整代码见chapter08\src\main\java\com\example\chapter08\fragment\BillFragment.java)

// 构建一个当月账单的列表适配器
BillListAdapter listAdapter = new BillListAdapter(mContext, mBillList); 
lv_bill.setAdapter(listAdapter);  // 设置列表视图的适配器
lv_bill.setOnItemClickListener(listAdapter);  // 设置列表视图的点击监听器 
lv_bill.setOnItemLongClickListener(listAdapter);  // 设置列表视图的长按监听器

步骤二,由于第一步将点击监听器和长按监听器设到了列表适配器,因此令BillListAdapter分别实现AdapterView.OnItemClickListener和AdapterView.OnItemLongClickListener,并且重写对应的点击方法onItemClick与长按方法onItemLongClick,其中onItemClick内部补充页面的跳转逻辑,而onItemLongClick内部补充提示窗的处理逻辑。重写之后的点击方法与长按方法代码如下所示:

(完整代码见chapter08\src\main\java\com\example\chapter08\adapter\BillListAdapter.java)

@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) 
{
   if (position >= mBillList.size()-1) { // 合计行不响应点击事件
       return;
 }
   Log.d(TAG, "onItemClick position=" + position);
   BillInfo bill = mBillList.get(position);
   // 以下跳转到账单填写页面
   Intent intent = new Intent(mContext, BillAddActivity.class);
   intent.putExtra("xuhao", bill.xuhao); // 携带账单序号,表示已存在该账单
   mContext.startActivity(intent); // 因为已存在该账单,所以跳过去实际会编辑账单 
}
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, final int 
position, long id) {
   if (position >= mBillList.size()-1) { // 合计行不响应长按事件
       return true;
 }
   Log.d(TAG, "onItemLongClick position=" + position);
   BillInfo bill = mBillList.get(position); // 获得当前位置的账单信息
   AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
   String desc = String.format("是否删除以下账单?\n%s %s%d %s", bill.date,
                               bill.type==0?"收入":"支出", (int) bill.amount, 
bill.desc);
   builder.setMessage(desc); // 设置醒对话框的消息文本
   builder.setPositiveButton("是", new DialogInterface.OnClickListener() {
       @Override
       public void onClick(DialogInterface dialog, int which) {
           deleteBill(position); // 删除该账单
    }
  });
   builder.setNegativeButton("否", null);
   builder.create().show(); // 显示醒对话框
   return true; 
}

3 .合并账单的添加与编辑功能

上述第二点提到账单编辑页面仍然跳到了BillAddActivity,然而该页面原本用作账单填写,若想让它同时支持账单编辑功能,则需从意图包裹取出名为xuhao的字段,得到上个页面传来的序号数值,通过判断该字段是否为-1,再分别对应处理,后续的处理过程分成以下两个步骤:

步骤一,若xuhao字段的值为-1,则表示不存在原账单的序号,此时应进入账单添加逻辑;若值不为-1,则表示已存在该账单序号,此时应进入账单编辑处理,也就是将数据库中查到的原账单信息展示在各输入框,再由用户酌情修改详细的账单信息。相应的代码逻辑如下所示:

(完整代码见chapter08\src\main\java\com\example\chapter08\BillAddActivity.java)

private int xuhao; // 如果序号有值,说明已存在该账单
private Calendar calendar = Calendar.getInstance(); // 获取日历实例,里面包含了当前的年月日
private BillDBHelper mBillHelper; // 声明一个账单数据库的帮助器对象 
@Override
protected void onResume() { 
   super.onResume();
   xuhao = getIntent().getIntExtra("xuhao", -1);
   mBillHelper = BillDBHelper.getInstance(this); // 获取账单数据库的帮助器对象 
   if (xuhao != -1) { // 序号有值,就展示数据库里的账单详情
       List<BillInfo> bill_list = (List<BillInfo>) 
mBillHelper.queryById(xuhao);
       if (bill_list.size() > 0) { // 已存在该账单
           BillInfo bill = bill_list.get(0); // 获取账单信息 
           Date date = DateUtil.formatString(bill.date); 
           Log.d(TAG, "bill.date="+bill.date);
           Log.d(TAG,
"year="+date.getYear()+",month="+date.getMonth()+",day="+date.getDate());
           calendar.set(Calendar.YEAR, date.getYear()+1900);
           calendar.set(Calendar.MONTH, date.getMonth());
           calendar.set(Calendar.DAY_OF_MONTH, date.getDate());
           if (bill.type == 0) { // 收入
               rb_income.setChecked(true);
          } else { // 支出
               rb_expand.setChecked(true);
      }
           et_desc.setText(bill.desc); // 设置账单的述文本
           et_amount.setText(""+bill.amount); // 设置账单的交易金额
    }
 }
   tv_date.setText(DateUtil.getDate(calendar)); // 设置账单的发生时间 
}

步骤二,保存账单记录之时,也要先判断数据库中是否已经存在对应账单,如果有找到对应的账单记录,那么执行记录更新操作,否则执行记录添加操作。对应的数据库的操作代码示例如下:

(完整代码见chapter08\src\main\java\com\example\chapter08\database\BillDBHelper.java)

public void save(BillInfo bill) {
   // 根据序号寻找对应的账单记录
   List<BillInfo> bill_list = (List<BillInfo>) queryById(bill.xuhao);
   BillInfo info = null;
   if (bill_list.size() > 0) { // 有找到账单记录
       info = bill_list.get(0);
 }
   if (info != null) { // 已存在该账单信息,则更新账单
       bill.rowid = info.rowid;
       bill.create_time = info.create_time;
       bill.update_time = DateUtil.getNowDateTime("");
       update(bill); // 更新数据库记录
  } else { // 未存在该账单信息,则添加账单
       bill.create_time = DateUtil.getNowDateTime("");
       insert(bill); // 添加数据库记录
 } 
}

📖8.6 小结

本章主要介绍了App开发的高级控件相关知识,包括:下拉列表的用法(下拉框Spinner、数组适配器ArrayAdapter、简单适配器SimpleAdapter)、列表类视图的用法(基本适配器BaseAdapter、列表视图ListView、网格视图GridView)、翻页类视图的基本用法(翻页视图ViewPager、翻页适配器PagerAdapter、翻页标签栏PagerTabStrip)、碎片的两种用法(静态注册方式、动态注册方式、碎片适配器FragmentPagerAdapter)。中间穿插了实战模块的运用,如改进后的购物车、改进后的启动引导页等。最后设计了一个实战项目“记账本”,在该项目的App编码中用到了前面介绍的大部分控件,从而加深了对所学知识的理解。

通过本章的学习,我们应该能够掌握以下 4 种开发技能:

( 1 )学会使用下拉框控件。

( 2 )学会使用列表视图和网格视图。

( 3 )学会使用翻页视图与翻页标签栏。

( 4 )学会通过两种注册方式分别使用碎片。

📖8.7 课后练习题

一、填空题

1 .Spinner是种多选 _ 的下拉框控件。

2 .若想在页面中部弹出Spinner的列表对话框,要把spinnerMode属性设置为 _ 。

3 .在XML文件中,如果ListView后面还有其他平级的控件,就要将ListView的高度设为 _ ,同时权重设

为 1 ,确保列表视图扩展到剩余的页面区域。

4 .翻页视图ViewPager设置当前页面的方法是_。

5 .Fragment有两种注册方式,分别是 _ 和 _ 。

二、判断题(正确打√,错误打×)

1 .简单适配器只能展示纯文本列表。(  )

2 .列表视图只支持列表项的点击事件,不支持列表项的长按事件。(  )

3 .网格视图可以同时指定行数和列数。(  )

4 .引入翻页标签栏PagerTabStrip,它能够在翻页视图上方显示页面标题。(  )

5 .采取动态注册方式的时候,碎片需要配合翻页视图才能正常使用。(  )

三、选择题

1 .下拉框可使用(  )。

A.数组适配器

B.简单适配器

C.基本适配器

D.翻页适配器

2 .从BaseAdapter派生的数据适配器,要在(  )方法中补充各控件的处理逻辑。

A.getCount

B.getItem

C.getItemId

D.getView

3 .在列表视图当中,若想不让列表中的控件抢占列表项的焦点,应当将内部视图的焦点抢占方式设置为

(  )。

A.beforeDescendants

B.afterDescendants

C.blocksDescendants

D.不设置

4 .在网格视图当中,若想让每行的剩余空间均匀分配给该行的每个网格,应当将拉伸模式设置为(   )。

A.none

B.columnWidth

C.spacingWidth

D.spacingWidthUniform

5 .若想让翻页视图在滚动结束后触发某种动作,应当重写翻页适配器的(  )方法。

A.onPageScrolled

B.onPageSelected

C.onPageScrollStateChanged

D.以上 3 个都不是

四、简答题

请简要描述App的启动引导页主要采用了哪些控件。

五、动手练习

请上机实验下列 3 项练习:

1 .将第 6 章购物车界面的商品列表改造为列表视图,将商城界面的商品列表改造为网格视图。

2 .联合运用翻页视图与碎片,实现App启动之时的欢迎引导页面。

3 .实践本章的记账本项目,要求实现账单的增加、删除、修改、查看功能,并支持账单的列表展示与分月浏览。

你认为这篇文章怎么样?

  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.14.1