《第一行代码:Android篇》学习笔记(三)

本文和接下来的几篇文章为阅读郭霖先生所著《第一行代码:Android(篇第2版)》的学习笔记,按照书中的内容顺序进行记录,书中的Demo本人全部都做过了。
每一章节本人都做了详细的记录,以下是我学习记录(包含大量书中内容的整理和自己在学习中遇到的各种bug及解决方案),方便以后阅读和查阅。最后,感激感激郭霖先生提供这么好的书籍。

第3章 软件也要拼脸蛋——UI开发的点点滴滴

Android也给我们提供了大量的UI开发工具,只要合理地使用它们,就可以编写出各种各样漂亮的界面。在这里,我无法教会你如何提升自己的审美观,但我可以教会你怎样使用Android提供的UI开发工具来编写程序界面。

3.1 如何编写程序界面

Android中有多种编写程序界面的方式可供选择。Android Studio和Eclipse中都提供了相应的可视化编辑器,允许使用拖放控件的方式来编写布局,并能在视图上直接修改控件的属性。

不过我并不推荐你使用这种方式来编写界面,因为可视化编辑工具并不利于你去真正了解界面背后的实现原理。通过这种方式制作出的界面通常不具有很好的屏幕适配性,而且当需要编写较为复杂的界面时,可视化编辑工具将很难胜任。

因此本书中所有的界面都将通过最基本的方式去实现,即编写XML代码。等你完全掌握了使用XML来编写界面的方法之后,不管是进行高复杂度的界面实现,还是分析和修改当前现有界面,对你来说都将是手到擒来。

下面我们就从Android中几种常见的控件开始吧。

3.2 常用控件的使用方法

Android提供了大量的UI控件,合理地使用这些控件就可以非常轻松地编写出相当不错的界面,下面就挑选几种常用的控件,详细介绍一下它们的使用方法。

首先新建一个UIWidgetTest项目,我们还是允许Android Studio自动创建活动,活动名和布局名都使用默认值。

3.2.1 TextView

修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="This is TextView"/>
</LinearLayout>
  • 在TextView中我们使用android:id给当前控件定义了一个唯一标识符

  • 用android:layout_width和android:layout_height指定了控件的宽度和高度

    Android中所有的控件都具有这两个属性,可选值有3种:match_parent、fill_parent和wrap_content。

    • match_parent和fill_parent的意义相同,现在官方更加推荐使用match_parent。
    • match_parent表示让当前控件的大小和父布局的大小一样,也就是由父布局来决定当前控件的大小。
    • wrap_content表示让当前控件的大小能够刚好包含住里面的内容,由控件内容决定当前控件的大小。

    所以,上面的代码就表示让TextView的宽度和父布局一样宽,也就是手机屏幕的宽度,让TextView的高度足够包含住里面的内容就行。当然除了使用上述值,你也可以对控件的宽和高指定一个固定的大小,但是这样做有时会在不同手机屏幕的适配方面出现问题。

    接下来我们通过android:text指定TextView中显示的文本内容,现在运行程序,效果如图:

image

由于TextView中的文字默认是居左上角对齐的,虽然TextView的宽度充满了整个屏幕,可是由于文字内容不够长,所以从效果上完全看不出来。修改TextView的文字对齐方式,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="This is TextView"/>
</LinearLayout>

使用android:gravity来指定文字的对齐方式,可选值有top、bottom、left、right、center等,可以用“|”来同时指定多个值,这里我们指定的center,效果等同于center_vertical|center_horizontal,表示文字在垂直和水平方向都居中对齐。现在重新运行程序,效果如图:

image

这也说明了TextView的宽度确实是和屏幕宽度一样的。另外还可以对TextView中文字的大小和颜色进行修改,如下所示:

 <TextView
        android:id="@+id/text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:textSize="24sp"
        android:textColor="#00ff00"
        android:text="This is TextView"/>

当然TextView中还有很多其他的属性,这里就不再一一介绍了,用到的时候去查阅文档就可以了。

3.2.2 Button

Button是程序用于和用户进行交互的一个重要控件,可以在activity_main.xml中这样加入Button:

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/button"
        android:text="Button"/>

image

你可能会留意到,我们在布局文件里面设置的文字是“Button”,但最终的显示结果却是“BUTTON”。这是由于系统会对Button中的所有英文字母自动进行大写转换,如果这不是你想要的效果,可以使用如下配置来禁用这一默认特性:

<Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/button"
        android:text="Button"
        android:textAllCaps="false"/>

接下来可以在MainActivity中为Button的点击事件注册一个监听器,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                //此处添加逻辑
            }
        });
    }
}

每当点击按钮时,就会执行监听器中的onClick()方法,我们只需要在这个方法中加入待处理的逻辑就行了。如果你不喜欢使用匿名类的方式来注册监听器,也可以使用实现接口的方式来进行注册,代码如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
    }
    //实现View.OnClickListener接口
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                break;
            default:
                break;
        }
    }
}

3.2.3 EditText

EditText是程序用于和用户进行交互的另一个重要控件,它允许用户在控件里输入和编辑内容,并可以在程序中对这些内容进行处理。EditText的应用场景非常普遍,在进行发短信、发微博、聊QQ等操作时,使用EditText。修改activity_main.xml中的代码,如下所示:

    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/edit_text"/>

Android控件的使用规律了,用法基本上都很相似:给控件定义一个id,再指定控件的宽度和高度,然后再适当加入一些控件特有的属性就差不多了。

所以使用XML来编写界面其实一点都不难,完全可以不用借助任何可视化工具来实现。现在重新运行一下程序,EditText就已经在界面上显示出来了,并且我们是可以在里面输入内容的,如图:

image

做得比较人性化的软件会在输入框里显示一些提示性的文字,然后一旦用户输入了任何内容,这些提示性的文字就会消失。这种提示功能在Android里是非常容易实现的,我们甚至不需要做任何的逻辑控制,因为系统已经帮我们都处理好了。修改activity_main.xml,如下所示:

 <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/edit_text"
        android:hint="新鲜事儿"/>

image

EditText中显示了一段提示性文本——“新鲜事儿”,当输入任何内容时,这段文本就会自动消失。

不过,随着输入的内容不断增多,EditText会被不断地拉长。这时由于EditText的高度指定的是wrap_content,因此它总能包含住里面的内容,但是当输入的内容过多时,界面就会变得非常难看。我们可以使用android:maxLines属性来解决这个问题,修改activity_main.xml,如下所示:

    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/edit_text"
        android:hint="新鲜事儿"
        android:maxLines="2"/>

过android:maxLines指定了EditText的最大行数为两行,这样当输入的内容超过两行时,文本就会向上滚动,而EditText则不会再继续拉伸。

还可以结合使用EditText与Button来完成一些功能,比如通过点击按钮来获取EditText中输入的内容。修改MainActivity中的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private EditText editText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button) findViewById(R.id.button);
        editText = (EditText) findViewById(R.id.edit_text);

        button.setOnClickListener(this);
    }
    //实现View.OnClickListener接口
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                String inputText = editText.getText().toString();
                Toast.makeText(MainActivity.this,inputText,Toast.LENGTH_SHORT).show();
                break;
            default:
                break;
        }
    }
}

image

3.2.4 ImageView

ImageView是用于在界面上展示图片的一个控件,它可以让我们的程序界面变得更加丰富多彩。

学习这个控件需要提前准备好一些图片,图片通常都是放在以“drawable”开头的目录下的。目前我们的项目中有一个空的drawable目录,不过由于这个目录没有指定具体的分辨率,所以一般不使用它来放置图片。

在res目录下新建一个drawable-xhdpi目录,然后将事先准备好的两张图片img_1.png和img_2.png复制到该目录当中。接下来修改activity_main.xml,如下所示:

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/image_view"
        android:src="@drawable/img_1"/>

这里使用android:src属性给ImageView指定了一张图片。由于图片的宽和高都是未知的,所以将ImageView的宽和高都设定为wrap_content,这样就保证了不管图片的尺寸是多少,图片都可以完整地展示出来。重新运行程序,效果如图:

image

可以在程序中通过代码动态地更改ImageView中的图片,然后修改MainActivity的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private EditText editText;
    private ImageView imageView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button) findViewById(R.id.button);
        editText = (EditText) findViewById(R.id.edit_text);
        imageView = (ImageView) findViewById(R.id.image_view);

        button.setOnClickListener(this);
    }
    //实现View.OnClickListener接口
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                imageView.setImageResource(R.drawable.img_2);
                break;
            default:
                break;
        }
    }
}

在按钮的点击事件里,通过调用ImageView的setImageResource()方法将显示的图片改成img_2,现在重新运行程序,然后点击一下按钮,就可以看到ImageView中显示的图片改变了,如图:

image

3.2.5 ProgressBar

ProgressBar用于在界面上显示一个进度条,表示我们的程序正在加载一些数据。它的用法也非常简单,修改activity_main.xml中的代码,如下所示:

    <ProgressBar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/progress_bar"/>

重新运行程序,会看到屏幕中有一个圆形进度条正在旋转:

image

一个新的知识点:Android控件的可见属性

所有的Android控件都具有这个属性,可以通过android:visibility进行指定,可选值有3种:visible、invisible和gone。

  • visible表示控件是可见的,这个值是默认值,不指定android:visibility时,控件都是可见的。
  • invisible表示控件不可见,但是它仍然占据着原来的位置和大小,可以理解成控件变成透明状态了。
  • gone则表示控件不仅不可见,而且不再占用任何屏幕空间。

我们还可以通过代码来设置控件的可见性,使用的是setVisibility()方法,可以传入View.VISIBLE、View.INVISIBLE和View.GONE这3种值。

接下来我们就来尝试实现,点击一下按钮让进度条消失,再点击一下按钮让进度条出现的这种效果。修改MainActivity中的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private EditText editText;
    private ImageView imageView;
    private ProgressBar progressBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button) findViewById(R.id.button);
        editText = (EditText) findViewById(R.id.edit_text);
        imageView = (ImageView) findViewById(R.id.image_view);
        progressBar = (ProgressBar) findViewById(R.id.progress_bar);

        button.setOnClickListener(this);
    }
    //实现View.OnClickListener接口
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                //imageView.setImageResource(R.drawable.img_2);
                if (progressBar.getVisibility() == View.GONE){
                    progressBar.setVisibility(View.VISIBLE);
                }else{
                    progressBar.setVisibility(View.GONE);
                }
                break;
            default:
                break;
        }
    }
}

在按钮的点击事件中,我们通过getVisibility()方法来判断ProgressBar是否可见,如果可见就将ProgressBar隐藏掉,如果不可见就将ProgressBar显示出来。

重新运行程序,然后不断地点击按钮,你就会看到进度条会在显示与隐藏之间来回切换。另外,我们还可以给ProgressBar指定不同的样式,刚刚是圆形进度条,通过style属性可以将它指定成水平进度条,修改activity_main.xml中的代码,如下所示:

    <ProgressBar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyleHorizontal"
        android:max="100"/>

指定成水平进度条后,我们还可以通过android:max属性给进度条设置一个最大值,然后在代码中动态地更改进度条的进度。修改MainActivity中的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private ProgressBar progressBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button) findViewById(R.id.button);
        progressBar = (ProgressBar) findViewById(R.id.progress_bar);

        button.setOnClickListener(this);
    }
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                int progress = progressBar.getProgress();
                progress = progress + 10;
                progressBar.setProgress(progress);
                break;
            default:
                break;
        }
    }
}

每点击一次按钮,我们就获取进度条的当前进度,然后在现有的进度上加10作为更新后的进度。重新运行程序,点击数次按钮后,效果如图:

image

ProgressBar还有几种其他的样式,你可以自己去尝试一下。

3.2.6 AlertDialog

AlertDialog可以在当前的界面弹出一个对话框,这个对话框是置顶于所有界面元素之上的,能够屏蔽掉其他控件的交互能力,因此AlertDialog一般都是用于提示一些非常重要的内容或者警告信息。

比如为了防止用户误删重要内容,在删除前弹出一个确认对话框。修改MainActivity中的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;

import android.content.DialogInterface;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{

    private EditText editText;
    private ImageView imageView;
    private ProgressBar progressBar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button button = (Button) findViewById(R.id.button);
        editText = (EditText) findViewById(R.id.edit_text);
        imageView = (ImageView) findViewById(R.id.image_view);
        progressBar = (ProgressBar) findViewById(R.id.progress_bar);

        button.setOnClickListener(this);
    }
    //实现View.OnClickListener接口
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                AlertDialog.Builder dialog= new AlertDialog.Builder(MainActivity.this);
                dialog.setTitle("This is Dialog");
                dialog.setMessage("Something important.");
                dialog.setCancelable(false);//可否用Back键关闭对话框
                dialog.setPositiveButton("OK", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialogInterface, int i) {
                    }
                });
                dialog.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialogInterface, int i) {
                    }
                });
                dialog.show();
                break;
            default:
                break;
        }
    }
}

首先通过AlertDialog.Builder创建一个AlertDialog的实例,然后可以为这个对话框设置标题、内容、可否用Back键关闭对话框等属性,接下来调用setPositiveButton()方法为对话框设置确定按钮的点击事件,调用setNegativeButton()方法设置取消按钮的点击事件,最后调用show()方法将对话框显示出来。

重新运行程序,点击按钮后,效果如图所示:

image

3.2.7 ProgressDialog

ProgressDialog和AlertDialog有点类似,都可以在界面上弹出一个对话框,都能够屏蔽掉其他控件的交互能力。不同的是,ProgressDialog会在对话框中显示一个进度条,一般用于表示当前操作比较耗时,让用户耐心地等待。它的用法和AlertDialog也比较相似,修改MainActivity中的代码,如下所示:

package com.zhouzhou.uiwidgettest;

import androidx.appcompat.app.AppCompatActivity;

import android.app.ProgressDialog;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;

public class MainActivity extends AppCompatActivity implements View.OnClickListener{
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Button button = (Button) findViewById(R.id.button);
        button.setOnClickListener(this);
    }
    @Override
    public void onClick(View v){
        switch (v.getId()){
            case R.id.button:
                //此处添加逻辑
                ProgressDialog progressDialog = new ProgressDialog(MainActivity.this);
                progressDialog.setTitle("This is ProgressDialog");
                progressDialog.setMessage("Loading...");
                progressDialog.setCancelable(true);
                progressDialog.show();

                break;
            default:
                break;
        }
    }
}

可以看到,这里也是先构建出一个ProgressDialog对象,然后同样可以设置标题、内容、可否取消等属性,最后也是通过调用show()方法将ProgressDialog显示出来。重新运行程序,点击按钮后,效果如图:

image

注意:如果在setCancelable()中传入了false,表示ProgressDialog是不能通过Back键取消掉的,这时你就一定要在代码中做好控制,当数据加载完成后必须要调用ProgressDialog的dismiss()方法来关闭对话框,否则ProgressDialog将会一直存在。控件先学习这么多,阅读文档了解更多控件用法。

3.3 详解4种基本布局

一个丰富的界面总是要由很多个控件组成的,那我们如何才能让各个控件都有条不紊地摆放在界面上,而不是乱糟糟的呢?这就需要借助布局来实现了。

布局是一种可用于放置很多控件的容器,它可以按照一定的规律调整内部控件的位置,从而编写出精美的界面。当然,布局的内部除了放置控件外,也可以放置布局,通过多层布局的嵌套,我们就能够完成一些比较复杂的界面实现,图:

image

下面详细讲解下Android中4种最基本的布局。

先做好准备工作,新建一个UILayoutTest项目,并让Android Studio自动帮我们创建好活动,活动名和布局名都使用默认值。

3.3.1 线性布局

LinearLayout又称作线性布局,是一种非常常用的布局。

正如它的名字所描述的一样,这个布局会将它所包含的控件在线性方向上依次排列。在上一节中学习控件用法时,所有的控件就都是放在LinearLayout布局里的,因此上一节中的控件也确实是在垂直方向上线性排列的。

既然是线性排列,肯定就不仅只有一个方向,那为什么上一节中的控件都是在垂直方向排列的呢?这是由于我们通过android:orientation属性指定了排列方向是vertical,如果指定的是horizontal,控件就会在水平方向上排列了。下面我们通过实战来体会一下,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button1"
        android:text="button1"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button2"
        android:text="button2"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button3"
        android:text="button3"/>

</LinearLayout>

在LinearLayout中添加了3个Button,每个Button的长和宽都是wrap_content,并指定了排列方向是vertical。现在运行一下程序,效果如图:

image

然后我们修改一下LinearLayout的排列方向,如下所示:

android:orientation="horizontal"

将android:orientation属性的值改成了horizontal,这就意味着要让LinearLayout中的控件在水平方向上依次排列。当然如果不指定android:orientation属性的值,默认的排列方向就是horizontal。重新运行一下程序,效果如图:

image

这里需要注意,如果LinearLayout的排列方向是horizontal,内部的控件就绝对不能将宽度指定为match_parent,因为这样的话,单独一个控件就会将整个水平方向占满,其他的控件就没有可放置的位置了。同样的道理,如果LinearLayout的排列方向是vertical,内部的控件就不能将高度指定为match_parent。

首先来看android:layout_gravity属性,它和上一节中学到的android:gravity属性看起来有些相似,这两个属性有什么区别呢?

android:gravity用于指定文字在控件中的对齐方式,而android:layout_gravity用于指定控件在布局中的对齐方式。android:layout_gravity的可选值和android:gravity差不多。

但是需要注意,当LinearLayout的排列方向是horizontal时,只有垂直方向上的对齐方式才会生效,因为此时水平方向上的长度是不固定的,每添加一个控件,水平方向上的长度都会改变,因而无法指定该方向上的对齐方式。同样的道理,当LinearLayout的排列方向是vertical时,只有水平方向上的对齐方式才会生效。修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button1"
        android:text="button1"
        android:layout_gravity="top"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button2"
        android:text="button2"
        android:layout_gravity="center_vertical"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button3"
        android:text="button3"
        android:layout_gravity="bottom"/>

</LinearLayout>

由于目前LinearLayout的排列方向是horizontal,因此我们只能指定垂直方向上的排列方向,将第一个Button的对齐方式指定为top,第二个Button的对齐方式指定为center_vertical,第三个Button的对齐方式指定为bottom。重新运行程序,效果如图:

image

LinearLayout中的另一个重要属性——android:layout_weight。

这个属性允许我们使用比例的方式来指定控件的大小,它在手机屏幕的适配性方面可以起到非常重要的作用。比如我们正在编写一个消息发送界面,需要一个文本编辑框和一个发送按钮,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <EditText
        android:id="@+id/input_message"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:hint="Type something"
        />
    <Button
        android:id="@+id/send"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:text="Send"/>
</LinearLayout>

将EditText和Button的宽度都指定成了0dp,由于我们使用了android:layout_weight属性,此时控件的宽度就不应该再由android:layout_width来决定,这里指定成0dp是一种比较规范的写法。

另外,dp是Android中用于指定控件大小、间距等属性的单位,后面还会经常用到它。然后在EditText和Button里都将android:layout_weight属性的值指定为1,这表示EditText和Button将在水平方向平分宽度。

然后在EditText和Button里都将android:layout_weight属性的值指定为1,这表示EditText和Button将在水平方向平分宽度。

image

为什么将android:layout_weight属性的值同时指定为1就会平分屏幕宽度呢?

系统会先把LinearLayout下所有控件指定的layout_weight值相加,得到一个总值,然后每个控件所占大小的比例就是用该控件的layout_weight值除以刚才算出的总值。因此如果想让EditText占据屏幕宽度的3/5, Button占据屏幕宽度的2/5,只需要将EditText的layout_weight改成3, Button的layout_weight改成2就可以了。

重新运行程序,你会看到如图的效果:

image

还可以通过指定部分控件的layout_weight值来实现更好的效果。修改activity_main. xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <EditText
        android:id="@+id/input_message"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:hint="Type something"
        />
    <Button
        android:id="@+id/send"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Send"/>

</LinearLayout>

这里仅指定了EditText的android:layout_weight属性,并将Button的宽度改回wrap_content。这表示Button的宽度仍然按照wrap_content来计算,而EditText则会占满屏幕所有的剩余空间。(

使用layout_weight实现宽度自适配效果,这种方式编写的界面,不仅在各种屏幕的适配方面会非常好,而且看起来也更加舒服。重新运行程序,效果如图:

image

3.3.2 相对布局

RelativeLayout又称作相对布局,也是一种非常常用的布局。和LinearLayout的排列规则不同,RelativeLayout显得更加随意一些,它可以通过相对定位的方式让控件出现在布局的任何位置。

也正因为如此,RelativeLayout中的属性非常多,不过这些属性都是有规律可循的,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button1"
        android:layout_alignParentLeft="true"
        android:layout_alignParentTop="true"
        android:text="Button1"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button2"
        android:layout_alignParentTop="true"
        android:layout_alignParentRight="true"
        android:text="Button2"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button3"
        android:layout_centerInParent="true"
        android:text="Button3"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button4"
        android:layout_alignParentBottom="true"
        android:layout_alignParentLeft="true"
        android:text="Button4"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button5"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"
        android:text="Button5"/>
</RelativeLayout>

让Button 1和父布局的左上角对齐,Button 2和父布局的右上角对齐,Button3居中显示,Button 4和父布局的左下角对齐,Button 5和父布局的右下角对齐。虽然android:layout_alignParentLeft、android:layout_alignParentTop、android:layout_alignParentRight、android:layout_alignParentBottom、android:layout_centerInParent这几个属性它们的名字已经完全说明了它们的作用。

重新运行程序,效果如图:

image

上面例子中的每个控件都是相对于父布局进行定位的,那控件可不可以相对于控件进行定位呢?当然是可以的,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button3"
        android:layout_centerInParent="true"
        android:text="Button3"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button1"
        android:layout_above="@+id/button3"
        android:layout_toLeftOf="@+id/button3"
        android:text="Button1"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button2"
        android:layout_above="@+id/button3"
        android:layout_toRightOf="@+id/button3"
        android:text="Button2"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button4"
        android:layout_below="@+id/button3"
        android:layout_toLeftOf="@+id/button3"
        android:text="Button4"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/button5"
        android:layout_below="@id/button3"
        android:layout_toRightOf="@id/button3"
        android:text="Button5"/>
</RelativeLayout>

android:layout_above属性可以让一个控件位于另一个控件的上方,需要为这个属性指定相对控件id的引用,这里我们填入了@id/button3,表示让该控件位于Button 3的上方。其他的属性也都是相似的,android:layout_below表示让一个控件位于另一个控件的下方,android:layout_toLeftOf表示让一个控件位于另一个控件的左侧,android:layout_toRightOf表示让一个控件位于另一个控件的右侧。

注意,当一个控件去引用另一个控件的id时,该控件一定要定义在引用控件的后面,不然会出现找不到id的情况。重新运行程序,效果如图:

image

RelativeLayout中还有另外一组相对于控件进行定位的属性,android:layout_alignLeft表示让一个控件的左边缘和另一个控件的左边缘对齐,android:layout_alignRight表示让一个控件的右边缘和另一个控件的右边缘对齐。此外,还有android:layout_alignTop和android:layout_alignBottom,道理都是一样的。

3.3.3 帧布局

FrameLayout又称作帧布局,它相比于前面两种布局就简单太多了,因此它的应用场景也少了很多。这种布局没有方便的定位方式,所有的控件都会默认摆放在布局的左上角。让我们通过例子来看一看吧,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/text_view"
        android:text="This is TextView"/>
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/image_view"
        android:src="@mipmap/ic_launcher"/>
</FrameLayout>

FrameLayout中只是放置了一个TextView和一个ImageView。需要注意的是,当前项目我们没有准备任何图片,所以这里ImageView直接使用了@mipmap来访问ic_launcher这张图,虽说这种用法的场景可能非常少,但我还是要告诉你,这是完全可行的。重新运行程序,效果如图所示:

image

可以看到,文字和图片都是位于布局的左上角。由于ImageView是在TextView之后添加的,因此图片压在了文字的上面。除了这种默认效果之外,还可以使用layout_gravity属性来指定控件在布局中的对齐方式,这和LinearLayout中的用法是相似的。修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/text_view"
        android:layout_gravity="left"
        android:text="This is TextView"/>
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/image_view"
        android:layout_gravity="right"
        android:src="@mipmap/ic_launcher"/>
</FrameLayout>

指定TextView在FrameLayout中居左对齐,指定ImageView在FrameLayout中居右对齐,然后重新运行程序,效果如图:

image

总体来讲,FrameLayout由于定位方式的欠缺,导致它的应用场景也比较少,不过在下一章中介绍碎片的时候我们还是可以用到它的。

3.3.4 百分比布局

前面介绍的3种布局都是从Android 1.0版本中就开始支持了,一直沿用到现在,可以说是满足了绝大多数场景的界面设计需求。

不过你会发现,只有LinearLayout支持使用layout_weight属性来实现按比例指定控件大小的功能,其他两种布局都不支持。

比如说,如果想用RelativeLayout来实现让两个按钮平分布局宽度的效果,则是比较困难的。为此,Android引入了一种全新的布局方式来解决此问题——百分比布局。在这种布局中,我们可以不再使用wrap_content、match_parent等方式来指定控件的大小,而是允许直接指定控件在布局中所占的百分比,这样的话就可以轻松实现平分布局甚至是任意比例分割布局的效果了。

由于LinearLayout本身已经支持按比例指定控件的大小了,因此百分比布局只为FrameLayout和RelativeLayout进行了功能扩展,提供了PercentFrameLayout和PercentRelativeLayout这两个全新的布局,下面我们就来具体学习一下。

不同于前3种布局,百分比布局属于新增布局,那么怎么才能做到让新增布局在所有Android版本上都能使用呢?为此,Android团队将百分比布局定义在了support库当中,我们只需要在项目的build.gradle中添加百分比布局库的依赖,就能保证百分比布局在Android所有系统版本上的兼容性了。打开app/build.gradle文件,在dependencies闭包中添加如下内容:

dependencies {

    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'androidx.appcompat:appcompat:1.0.0'
    implementation 'androidx.percentlayout:percentlayout:1.0.0'
    testImplementation 'junit:junit:4.13.2'
}

需要注意的是,每当修改了任何gradle文件时,Android Studio都会弹出一个如图:

image

这个提示告诉我们,gradle文件自上次同步之后又发生了变化,需要再次同步才能使项目正常工作。这里只需要点击Sync Now就可以了,然后gradle会开始进行同步,把我们新添加的百分比布局库引入到项目当中。接下来修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<androidx.percentlayout.widget.PercentFrameLayout
    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">
    <Button
        android:id="@+id/button1"
        android:text="Button1"
        android:layout_gravity="right|top"
        app:layout_widthPercent="50%"
        app:layout_heightPercent="50%"/>
    <Button
        android:id="@+id/button2"
        android:text="Button2"
        android:layout_gravity="left|bottom"
        app:layout_widthPercent="50%"
        app:layout_heightPercent="50%"/>
    <Button
        android:id="@+id/button3"
        android:text="Button3"
        android:layout_gravity="right|bottom"
        app:layout_widthPercent="50%"
        app:layout_heightPercent="50%"/>
    <Button
        android:id="@+id/button4"
        android:text="Button4"
        android:layout_gravity="left|top"
        app:layout_widthPercent="50%"
        app:layout_heightPercent="50%" />
</androidx.percentlayout.widget.PercentFrameLayout>

最外层我们使用了PercentFrameLayout,由于百分比布局并不是内置在系统SDK当中的,所以需要把完整的包路径写出来。然后还必须定义一个app的命名空间,这样才能使用百分比布局的自定义属性。

在PercentFrameLayout中我们定义了4个按钮,使用app:layout_widthPercent属性将各按钮的宽度指定为布局的50%,使用app:layout_heightPercent属性将各按钮的高度指定为布局的50%。这里之所以能使用app前缀的属性就是因为刚才定义了app的命名空间,当然我们一直能使用android前缀的属性也是同样的道理。

不过PercentFrameLayout还是会继承FrameLayout的特性,即所有的控件默认都是摆放在布局的左上角。那么为了让这4个按钮不会重叠,这里还是借助了layout_gravity来分别将这4个按钮放置在布局的左上、右上、左下、右下4个位置。

现在我们已经可以重新运行程序了,不过如果你使用的是老版本的AndroidStudio,可能会在activity_main.xml中看到一些错误提示:

image

这是因为老版本的Android Studio中内置了布局的检查机制,认为每一个控件都应该通过android:layout_width和android:layout_height属性指定宽高才是合法的。而其实我们是通过app:layout_widthPercent和app:layout_heightPercent属性来指定宽高的,所以Android Studio没检测到。不过这个错误提示并不影响程序运行,直接忽视就可以了。

当然最新的Android Studio 2.2版本中已经修复了这个问题,因此你可能并不会看到上述的错误提示。现在重新运行程序,效果如图所示:

image

可以看到,每一个按钮的宽和高都占据了布局的50%,这样我们就轻松实现了4个按钮平分屏幕的效果。另外一个PercentRelativeLayout的用法也是非常相似的,它继承了RelativeLayout中的所有属性,并且可以使用app:layout_widthPercent和app:layout_heightPercent来按百分比指定控件的宽高,最常用的几种布局都讲解完了,其实Android中还有AbsoluteLayout、TableLayout等布局,不过使用得实在是太少了。

3.4 系统控件不够用?创建自定义控件

在前面两节已经学习了Android中的一些常用控件以及基本布局的用法,不过当时我们并没有关注这些控件和布局的继承结构:

image

可以看到,所用的所有控件都是直接或间接继承自View的,所用的所有布局都是直接或间接继承自ViewGroup的。

View是Android中最基本的一种UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件,因此,我们使用的各种控件其实就是在View的基础之上又添加了各自特有的功能。而ViewGroup则是一种特殊的View,它可以包含很多子View和子ViewGroup,是一个用于放置控件和布局的容器。

思考:当系统自带的控件并不能满足我们的需求时,可不可以利用上面的继承结构来创建自定义控件呢?

答案是肯定的,下面我们就来学习一下创建自定义控件的两种简单方法。先将准备工作做好,创建一个UICustomViews项目。

3.4.1 引入布局

如果你用过iPhone应该会知道,几乎每一个iPhone应用的界面顶部都会有一个标题栏,标题栏上会有一到两个按钮可用于返回或其他操作(iPhone没有实体返回键)。

现在很多Android程序也都喜欢模仿iPhone的风格,在界面的顶部放置一个标题栏。虽然Android系统已经给每个活动提供了标题栏功能,但这里我们决定先不使用它,而是创建一个自定义的标题栏。

一般我们的程序中可能有很多个活动都需要标题栏,如果在每个活动的布局中都编写一遍同样的标题栏代码,明显就会导致代码的大量重复。这个时候我们就可以使用引入布局的方式来解决这个问题,新建一个布局title.xml,代码如下所示:

<?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:background="@drawable/title_bg">
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/title_back"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:background="@drawable/back_bg"
        android:text="Back"
        android:textColor="#fff"/>
    <TextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:id="@+id/title_text"
        android:layout_gravity="center"
        android:layout_weight="1"
        android:text="Title Text"
        android:textColor="#fff"
        android:textSize="24sp"/>
    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/title_edit"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        android:background="@drawable/edit_bg"
        android:text="Edit"
        android:textColor="#fff"/>
</LinearLayout>

可以看到,在LinearLayout中分别加入了两个Button和一个TextView,左边的Button可用于返回,右边的Button可用于编辑,中间的TextView则可以显示一段标题文本。

android:background用于为布局或控件指定一个背景,可以使用颜色或图片来进行填充,这里我提前准备好了3张图片——title_bg.png、back_bg.png和edit_bg.png,分别用于作为标题栏、返回按钮和编辑按钮的背景。

另外,在两个Button中我们都使用了android:layout_margin这个属性,它可以指定控件在上下左右方向上偏移的距离,当然也可以使用android:layout_marginLeft或android:layout_marginTop等属性来单独指定控件在某个方向上偏移的距离。现在标题栏布局已经编写完成了,剩下的就是如何在程序中使用这个标题栏了,修改activity_main.xml中的代码,如下所示:

<?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="match_parent">
    <include layout="@layout/title"/>
</LinearLayout>

只需要通过一行include语句将标题栏布局引入进来就可以了。最后别忘了在MainActivity中将系统自带的标题栏隐藏掉,代码如下所示:

package com.zhouzhou.uicustomviews;

import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            actionBar.hide();
        }
    }
}

这里调用了getSupportActionBar()方法来获得ActionBar的实例,然后再调用ActionBar的hide()方法将标题栏隐藏起来。效果如图:

image

使用这种方式,不管有多少布局需要添加标题栏,只需一行include语句就可以了。

3.4.2 创建自定义控件

引入布局的技巧确实解决了重复编写布局代码的问题,但是如果布局中有一些控件要求能够响应事件,我们还是需要在每个活动中为这些控件单独编写一次事件注册的代码。

比如说标题栏中的返回按钮,其实不管是在哪一个活动中,这个按钮的功能都是相同的,即销毁当前活动。而如果在每一个活动中都需要重新注册一遍返回按钮的点击事件,无疑会增加很多重复代码,这种情况最好是使用自定义控件的方式来解决。

新建TitleLayout继承自LinearLayout,让它成为我们自定义的标题栏控件,代码如下所示:

package com.zhouzhou.uicustomviews;

import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.LinearLayout;

import androidx.annotation.Nullable;

public class TitleLayout extends LinearLayout{

    public TitleLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        LayoutInflater.from(context).inflate(R.layout.title,this);
    }
}

首先重写了LinearLayout中带有两个参数的构造函数,在布局中引入TitleLayout控件就会调用这个构造函数。然后在构造函数中需要对标题栏布局进行动态加载,这就要借助LayoutInflater来实现了。通过LayoutInflater的from()方法可以构建出一个LayoutInflater对象,然后调用inflate()方法就可以动态加载一个布局文件,inflate()方法接收两个参数,第一个参数是要加载的布局文件的id,这里我们传入R.layout.title,第二个参数是给加载好的布局再添加一个父布局,这里我们想要指定为TitleLayout,于是直接传入this。

现在自定义控件已经创建好了,然后我们需要在布局文件中添加这个自定义控件,修改activity_main.xml中的代码,如下所示:

<?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="match_parent">
    <com.zhouzhou.uicustomviews.TitleLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</LinearLayout>

添加自定义控件和添加普通控件的方式基本是一样的,只不过在添加自定义控件的时候,我们需要指明控件的完整类名,包名在这里是不可以省略的。

重新运行程序,发现此时效果和使用引入布局方式的效果是一样的。尝试为标题栏中的按钮注册点击事件,修改TitleLayout中的代码,如下所示:

package com.zhouzhou.uicustomviews;

import android.app.Activity;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.Toast;
import androidx.annotation.Nullable;

public class TitleLayout extends LinearLayout{

    public TitleLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        LayoutInflater.from(context).inflate(R.layout.title,this);
        Button titleBack = (Button) findViewById(R.id.title_back);
        Button titleEdit = (Button) findViewById(R.id.title_edit);
        titleBack.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                ((Activity)getContext()).finish();
            }
        });
        titleEdit.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                Toast.makeText(getContext(),"You clicked Edit button", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

首先还是通过findViewById()方法得到按钮的实例,然后分别调用setOnClickListener()方法给两个按钮注册了点击事件,当点击返回按钮时销毁掉当前的活动,当点击编辑按钮时弹出一段文本。重新运行程序,点击一下编辑按钮,效果如图:

image

这样,每当我们在一个布局中引入TitleLayout时,返回按钮和编辑按钮的点击事件就已经自动实现好了,这就省去了很多编写重复代码的工作。

3.5 最常用和最难用的控件——ListView

ListView绝对可以称得上是Android中最常用的控件之一,几乎所有的应用程序都会用到它。

由于手机屏幕空间都比较有限,能够一次性在屏幕上显示的内容并不多,当我们的程序中有大量的数据需要展示的时候,就可以借助ListView来实现。

ListView允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内,同时屏幕上原有的数据则会滚动出屏幕。比如查看QQ聊天记录,翻阅微博最新消息,等等。不过比起前面介绍的几种控件,ListView的用法也相对复杂了很多。

3.5.1 ListView的简单用法

首先新建一个ListViewTest项目,并让Android Studio自动帮我们创建好活动。然后修改activity_main.xml中的代码,如下所示:

<?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="match_parent">
    <ListView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/list_view"/>
</LinearLayout>

在布局中加入ListView控件还算非常简单,先为ListView指定一个id,然后将宽度和高度都设置为match_parent,这样ListView也就占满了整个布局的空间。接下来修改MainActivity中的代码,如下所示:

package com.zhouzhou.listviewtest;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.ListView;

public class MainActivity extends AppCompatActivity {
    private String[] data = {"Apple","Banana","Orange","Watermelon",
            "Pear","Grape","Pineapple","Strawberry", "Cherry","Mango",
        "Apple","Banana","Orange","Watermelon","Pear","Grape","Pineapple","Strawberry",
            "Cherry","Mango"};
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ArrayAdapter<String> adapter = new ArrayAdapter<String>(
                MainActivity.this,android.R.layout.simple_list_item_1,data);
        ListView listView = (ListView)findViewById(R.id.list_view);
        listView.setAdapter(adapter);
    }
}

既然ListView是用于展示大量数据的,那我们就应该先将数据提供好。这里简单使用了一个data数组来测试,里面包含了很多水果的名称。

不过,数组中的数据是无法直接传递给ListView的,我们还需要借助适配器来完成

Android中提供了很多适配器的实现类,其中我认为最好用的就是ArrayAdapter。它可以通过泛型来指定要适配的数据类型,然后在构造函数中把要适配的数据传入。

ArrayAdapter有多个构造函数的重载,你应该根据实际情况选择最合适的一种。这里由于我们提供的数据都是字符串,因此将ArrayAdapter的泛型指定为String,然后在ArrayAdapter的构造函数中依次传入当前上下文、ListView子项布局的id,以及要适配的数据。

注意,使用了android.R.layout.simple_list_item_1作为ListView子项布局的id,这是一个Android内置的布局文件,里面只有一个TextView,可用于简单地显示一段文本。这样适配器对象就构建好了。

最后,还需要调用ListView的setAdapter()方法,将构建好的适配器对象传递进去,这样ListView和数据之间的关联就建立完成了。

现在运行一下程序,效果如图。可以通过滚动的方式来查看屏幕外的数据:

image

3.5.2 定制ListView的界面

只能显示一段文本的ListView实在是太单调了,现在就来对ListView的界面进行定制,让它可以显示更加丰富的内容。

首先需要准备好一组图片,分别对应上面提供的每一种水果,待会我们要让这些水果名称的旁边都有一个图样。接着定义一个实体类,作为ListView适配器的适配类型。新建类Fruit,代码如下所示:

package com.zhouzhou.listviewtest;

public class Fruit {
    private String name;
    private int imageId;

    //快捷键:Alt+Insert 或者 工具栏:Code->Generate
    public Fruit(String name, int imageId) {
        this.name = name;
        this.imageId = imageId;
    }
    public String getName() {
        return name;
    }
    public int getImageId() {
        return imageId;
    }
}

Fruit类中只有两个字段,name表示水果的名字,imageId表示水果对应图片的资源id。然后需要为ListView的子项指定一个我们自定义的布局,在layout目录下新建fruit_item.xml,代码如下所示:

<?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">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_image"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_name"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="10dp"/>
</LinearLayout>

在这个布局中,定义了一个ImageView用于显示水果的图片,又定义了一个TextView用于显示水果的名称,并让TextView在垂直方向上居中显示。

接下来需要创建一个自定义的适配器,这个适配器继承自ArrayAdapter,并将泛型指定为Fruit类。新建类FruitAdapter,代码如下所示:

package com.zhouzhou.listviewtest;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.List;

public class FruitAdapter extends ArrayAdapter<Fruit> {
    private int resourceId;
    public FruitAdapter(Context context, int textViewResourceId, List<Fruit> objects) {
        super(context,textViewResourceId,objects);
        resourceId = textViewResourceId;
    }
    @Override
    public View getView(int position,View convertView,ViewGroup parent) {
        Fruit fruit = getItem(position);//获取当前项的Fruit实例
        View view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
        ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
        TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
        fruitImage.setImageResource(fruit.getImageId());
        fruitName.setText(fruit.getName());
        return view;
    }
}
  • FruitAdapter重写了父类的一组构造函数,用于将上下文、ListView子项布局的id和数据都传递进来。
  • 重写了getView()方法,这个方法在每个子项被滚动到屏幕内的时候会被调用。
  • 在getView()方法中,首先通过getItem()方法得到当前项的Fruit实例,然后使用LayoutInflater来为这个子项加载我们传入的布局。
  • LayoutInflater的inflate()方法接收3个参数,第三个参数指定成false,表示只让在父布局中声明的layout属性生效,但不会为这个View添加父布局,因为一旦View有了父布局之后,它就不能再添加到ListView中了。这是ListView中的标准写法,当你以后对View理解得更加深刻的时候,再来读这段话就没有问题了。
  • 调用View的findViewById()方法分别获取到ImageView和TextView的实例,并分别调用它们的setImageResource()和setText()方法来设置显示的图片和文字.
  • 最后将布局返回,这样我们自定义的适配器就完成了。
  • 下面修改MainActivity中的代码,如下所示:
package com.zhouzhou.listviewtest;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.widget.ListView;

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

public class MainActivity extends AppCompatActivity {
    private List<Fruit> fruitList = new ArrayList<>();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();//初始化水果数据
        FruitAdapter adapter = new FruitAdapter(MainActivity.this,R.layout.fruit_item,fruitList);
        ListView listView = (ListView) findViewById(R.id.list_view);
        listView.setAdapter(adapter);
    }
    private void initFruits() {
        for (int i = 0; i < 2; i++) {
            Fruit apple = new Fruit("Apple",R.drawable.apple_pic);
            fruitList.add(apple);
            Fruit banana = new Fruit("Banana",R.drawable.banana_pic);
            fruitList.add(banana);
            Fruit cherry = new Fruit("Cherry",R.drawable.cherry_pic);
            fruitList.add(cherry);
            Fruit grape = new Fruit("Grape",R.drawable.grape_pic);
            fruitList.add(grape);
            Fruit mango = new Fruit("Mango",R.drawable.mango_pic);
            fruitList.add(mango);
            Fruit orange = new Fruit("Orange",R.drawable.orange_pic);
            fruitList.add(orange);
            Fruit pear = new Fruit("Pear",R.drawable.pear_pic);
            fruitList.add(pear);
            Fruit watermelon = new Fruit("Watermelon",R.drawable.watermelon_pic);
            fruitList.add(watermelon);
            Fruit pineapple = new Fruit("Pineapple",R.drawable.pineapple_pic);
            fruitList.add(pineapple);
            Fruit strawberry = new Fruit("Strawberry",R.drawable.strawberry_pic);
            fruitList.add(strawberry);
        }
    }
}
  • 添加了一个initFruits()方法,用于初始化所有的水果数据。
  • 在Fruit类的构造函数中将水果的名字和对应的图片id传入,把创建好的对象添加到水果列表中。
  • 使用for循环将所有的水果数据添加了两遍,只添加一遍,数据量还不足以充满整个屏幕。
  • 接着在onCreate()方法中创建了FruitAdapter对象,并将FruitAdapter作为适配器传递给ListView。

这样定制ListView界面的任务就完成了。现在重新运行程序,效果如图所示:

image

3.5.3 提升ListView的运行效率

ListView这个控件很难用,因为它有很多细节可以优化,其中运行效率就是很重要的一点。

目前我们ListView的运行效率是很低的,因为在FruitAdapter的getView()方法中,每次都将布局重新加载了一遍,当ListView快速滚动的时候,这就会成为性能的瓶颈。

仔细观察会发现,getView()方法中还有一个convertView参数,这个参数用于将之前加载好的布局进行缓存,以便之后可以进行重用。修改FruitAdapter中的代码,如下所示:

package com.zhouzhou.listviewtest;

import android.content.Context;
import android.media.Image;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.List;

public class FruitAdapter extends ArrayAdapter<Fruit> {
    private int resourceId;
    public FruitAdapter(Context context, int textViewResourceId, List<Fruit> objects) {
        super(context,textViewResourceId,objects);
        resourceId = textViewResourceId;
    }
    @Override
    public View getView(int position,View convertView,ViewGroup parent) {
        Fruit fruit = getItem(position);//获取当前项的Fruit实例
        /**
         * View view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
         * ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
         * TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
         * fruitImage.setImageResource(fruit.getImageId());
         * fruitName.setText(fruit.getName());
         **/
        View view;
        if(convertView == null){
            view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
        }else{
            view = convertView;
        }
        ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
        TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
        fruitImage.setImageResource(fruit.getImageId());
        fruitName.setText(fruit.getName());
        return view;
    }
}

可以看到,在getView()方法中进行了判断,如果convertView为null,则使用LayoutInflater去加载布局,如果不为null则直接对convertView进行重用。这样就大大提高了ListView的运行效率,在快速滚动的时候也可以表现出更好的性能。

不过,目前这份代码还是可以继续优化的,虽然现在已经不会再重复去加载布局,但是每次在getView()方法中还是会调用View的findViewById()方法来获取一次控件的实例。可以借助一个ViewHolder来对这部分性能进行优化,修改FruitAdapter中的代码,如下所示:

package com.zhouzhou.listviewtest;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;

import java.util.List;

public class FruitAdapter extends ArrayAdapter<Fruit> {
    private int resourceId;
    public FruitAdapter(Context context, int textViewResourceId, List<Fruit> objects) {
        super(context,textViewResourceId,objects);
        resourceId = textViewResourceId;
    }
    @Override
    public View getView(int position,View convertView,ViewGroup parent) {

        /**
         * View view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
         * ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
         * TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
         * fruitImage.setImageResource(fruit.getImageId());
         * fruitName.setText(fruit.getName());
         **/
        Fruit fruit = getItem(position);//获取当前项的Fruit实例
        View view;
        ViewHolder viewHolder;
        if(convertView == null){
            view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
            viewHolder = new ViewHolder();
            viewHolder.fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
            viewHolder.fruitName = (TextView) view.findViewById(R.id.fruit_name);
            view.setTag(viewHolder);//将ViewHolder存储在View中
        }else{
            view = convertView;
            viewHolder = (ViewHolder) view.getTag();//重新获取viewHolder
        }
        /**
         * ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
         * TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
         * fruitImage.setImageResource(fruit.getImageId());
         * fruitName.setText(fruit.getName());
         **/
        viewHolder.fruitImage.setImageResource(fruit.getImageId());
        viewHolder.fruitName.setText(fruit.getName());
        return view;
    }
        class ViewHolder{
        ImageView fruitImage;
        TextView fruitName;
    }
}

新增了一个内部类ViewHolder,用于对控件的实例进行缓存。

  • 当convertView为null的时候,创建一个ViewHolder对象,并将控件的实例都存放在ViewHolder里,然后调用View的setTag()方法,将ViewHolder对象存储在View中。
  • 当convertView不为null的时候,则调用View的getTag()方法,把ViewHolder重新取出。

这样所有控件的实例都缓存在了ViewHolder里,就没有必要每次都通过findViewById()方法来获取控件实例了。通过这两步优化之后,我们ListView的运行效率就已经非常不错了。

3.5.4 ListView的点击事件

ListView的滚动毕竟只是满足了视觉上的效果,可是如果ListView中的子项不能点击的话,这个控件就没有什么实际的用途了。ListView如何才能响应用户的点击事件。修改MainActivity中的代码,如下所示:

package com.zhouzhou.listviewtest;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.Toast;

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

public class MainActivity extends AppCompatActivity {
    private List<Fruit> fruitList = new ArrayList<>();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();//初始化水果数据
        FruitAdapter adapter = new FruitAdapter(MainActivity.this,R.layout.fruit_item,fruitList);
        ListView listView = (ListView) findViewById(R.id.list_view);
        listView.setAdapter(adapter);
        listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
                Fruit fruit = fruitList.get(position);
                Toast.makeText(MainActivity.this,fruit.getName(),Toast.LENGTH_SHORT).show();
            }
        });
    }
    private void initFruits() {
        for (int i = 0; i < 2; i++) {
            Fruit apple = new Fruit("Apple",R.drawable.apple_pic);
            fruitList.add(apple);
            Fruit banana = new Fruit("Banana",R.drawable.banana_pic);
            fruitList.add(banana);
            Fruit cherry = new Fruit("Cherry",R.drawable.cherry_pic);
            fruitList.add(cherry);
            Fruit grape = new Fruit("Grape",R.drawable.grape_pic);
            fruitList.add(grape);
            Fruit mango = new Fruit("Mango",R.drawable.mango_pic);
            fruitList.add(mango);
            Fruit orange = new Fruit("Orange",R.drawable.orange_pic);
            fruitList.add(orange);
            Fruit pear = new Fruit("Pear",R.drawable.pear_pic);
            fruitList.add(pear);
            Fruit watermelon = new Fruit("Watermelon",R.drawable.watermelon_pic);
            fruitList.add(watermelon);
            Fruit pineapple = new Fruit("Pineapple",R.drawable.pineapple_pic);
            fruitList.add(pineapple);
            Fruit strawberry = new Fruit("Strawberry",R.drawable.strawberry_pic);
            fruitList.add(strawberry);
        }
    }
}

使用setOnItemClickListener()方法为ListView注册了一个监听器,当用户点击了ListView中的任何一个子项时,就会回调onItemClick()方法。在这个方法中可以通过position参数判断出用户点击的是哪一个子项,然后获取到相应的水果,并通过Toast将水果的名字显示出来。重新运行程序,并点击一下橘子,效果如图:

image

3.6 更强大的滚动控件——RecyclerView

ListView由于其强大的功能,在过去的Android开发当中可以说是贡献卓越,直到今天仍然还有不计其数的程序在继续使用着ListView。

不过ListView并不是完全没有缺点的,比如说如果我们不使用一些技巧来提升它的运行效率,那么ListView的性能就会非常差。还有,ListView的扩展性也不够好,它只能实现数据纵向滚动的效果,如果我们想实现横向滚动的话,ListView是做不到的。

为此,Android提供了一个更强大的滚动控件——RecyclerView。它可以说是一个增强版的ListView,不仅可以轻松实现和ListView同样的效果,还优化了ListView中存在的各种不足之处。

目前Android官方更加推荐使用RecyclerView,未来也会有更多的程序逐渐从ListView转向RecyclerView。首先新建一个RecyclerViewTest项目,并让Android Studio自动帮我们创建好活动。

3.6.1 RecyclerView的基本用法

和百分比布局类似,RecyclerView也属于新增的控件,为了让RecyclerView在所有Android版本上都能使用,Android团队采取了同样的方式,将RecyclerView定义在了support库当中。

想要使用RecyclerView这个控件:1.首先需要在项目的build.gradle中添加相应的依赖库才行(打开app/build.gradle文件,在dependencies闭包中添加依赖库,书中是旧的版本,不能用了。在build.gradle中添加依赖库的方式比较麻烦)。

  1. 首先,在activity_main.xml的Design模式下,选择RecyclerView ——>右击Add to Design

image

  1. 编辑activity_main.xml代码:
<?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="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>
  • 布局中加入了RecyclerView控件,先为RecyclerView指定一个id,然后将宽度和高度都设置为match_parent,这样RecyclerView也就占满了整个布局的空间。(需要注意的是,由于RecyclerView并不是内置在系统SDK当中的,所以需要把完整的包路径写出来。)
  • 这里想要使用RecyclerView来实现和ListView相同的效果,因此就需要准备一份同样的水果图片。
  • 简单起见,直接从ListViewTest项目中把图片复制过来,另外将Fruit类和fruit_item.xml也复制过来。
  • 需要为RecyclerView准备一个适配器,新建FruitAdapter类,让这个适配器继承自RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder。其中,ViewHolder是我们在FruitAdapter中定义的一个内部类,代码如下所示:
package com.zhouzhou.recyclerviewtest;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;

public class FruitAdapter extends RecyclerView.Adapter<FruitAdapter.ViewHolder> {
    private List<Fruit> mFruitList;
    static class ViewHolder extends RecyclerView.ViewHolder{
        ImageView fruitImage;
        TextView fruitName;
        public ViewHolder(View view){
            super(view);
            fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
            fruitName = (TextView) view.findViewById(R.id.fruit_name);
        }
    }
    public FruitAdapter(List<Fruit> fruitList){
        mFruitList = fruitList;
    }
    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.fruit_item,parent,false);
        ViewHolder holder = new ViewHolder(view);
        return holder;
    }
    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Fruit fruit = mFruitList.get(position);
        holder.fruitImage.setImageResource(fruit.getImageId());
        holder.fruitName.setText(fruit.getName());
    }
    @Override
    public int getItemCount() {
        return mFruitList.size();
    }
}

这段代码其实比ListView的适配器要更容易理解。

  • 首先定义了一个内部类ViewHolder, ViewHolder要继承自RecyclerView.ViewHolder。
  • 然后ViewHolder的构造函数中要传入一个View参数,这个参数通常就是RecyclerView子项的最外层布局,那么我们就可以通过findViewById()方法来获取到布局中的ImageView和TextView的实例了。
  • 接着,FruitAdapter中也有一个构造函数,这个方法用于把要展示的数据源传进来,并赋值给一个全局变量mFruitList,后续的操作都将在这个数据源的基础上进行。

由于FruitAdapter是继承自RecyclerView.Adapter的,那么就必须重写onCreateViewHolder()、onBindViewHolder()和getItemCount()这3个方法。

  • onCreateViewHolder()方法是用于创建ViewHolder实例的,在这个方法中将fruit_item布局加载进来,然后创建一个ViewHolder实例,并把加载出来的布局传入到构造函数当中,最后将ViewHolder的实例返回。
  • onBindViewHolder()方法是用于对RecyclerView子项的数据进行赋值的,会在每个子项被滚动到屏幕内的时候执行,这里我们通过position参数得到当前项的Fruit实例,然后再将数据设置到ViewHolder的ImageView和TextView当中即可。
  • getItemCount()方法就非常简单了,它用于告诉RecyclerView一共有多少子项,直接返回数据源的长度就可以了。

适配器准备好了之后,我们就可以开始使用RecyclerView了,修改MainActivity中的代码,如下所示:

package com.zhouzhou.recyclerviewtest;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentTransitionImpl;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.os.Bundle;

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

public class MainActivity extends AppCompatActivity {
    private List<Fruit> fruitList = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();//初始化水果数据
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
        LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        recyclerView.setLayoutManager(layoutManager);
        FruitAdapter adapter = new FruitAdapter(fruitList);
        recyclerView.setAdapter(adapter);
    }

    private void initFruits() {
        for (int i = 0; i < 2; i++) {
            Fruit apple = new Fruit("Apple", R.drawable.apple_pic);
            fruitList.add(apple);
            Fruit banana = new Fruit("Banana", R.drawable.banana_pic);
            fruitList.add(banana);
            Fruit cherry = new Fruit("Cherry", R.drawable.cherry_pic);
            fruitList.add(cherry);
            Fruit grape = new Fruit("Grape", R.drawable.grape_pic);
            fruitList.add(grape);
            Fruit mango = new Fruit("Mango", R.drawable.mango_pic);
            fruitList.add(mango);
            Fruit orange = new Fruit("Orange", R.drawable.orange_pic);
            fruitList.add(orange);
            Fruit pear = new Fruit("Pear", R.drawable.pear_pic);
            fruitList.add(pear);
            Fruit watermelon = new Fruit("Watermelon", R.drawable.watermelon_pic);
            fruitList.add(watermelon);
            Fruit pineapple = new Fruit("Pineapple", R.drawable.pineapple_pic);
            fruitList.add(pineapple);
            Fruit strawberry = new Fruit("Strawberry", R.drawable.strawberry_pic);
            fruitList.add(strawberry);
        }
    }
}

可以看到,这里使用了一个同样的initFruits()方法,用于初始化所有的水果数据。接着在onCreate()方法中先获取到RecyclerView的实例,然后创建了一个LinearLayout-Manager对象,并将它设置到RecyclerView当中。LayoutManager用于指定RecyclerView的布局方式,这里使用的LinearLayoutManager是线性布局的意思,可以实现和ListView类似的效果。

接下来我们创建了FruitAdapter的实例,并将水果数据传入到FruitAdapter的构造函数中,最后调用RecyclerView的setAdapter()方法来完成适配器设置,这样RecyclerView和数据之间的关联就建立完成了。现在可以运行一下程序了,效果如图:

image

3.6.2 实现横向滚动和瀑布流布局

ListView的扩展性并不好,它只能实现纵向滚动的效果,如果想进行横向滚动的话,ListView就做不到了。那么RecyclerView就能做得到吗?当然可以,不仅能做得到,还非常简单,那么接下来我们就尝试实现一下横向滚动的效果。

首先要对fruit_item布局进行修改,因为目前这个布局里面的元素是水平排列的,适用于纵向滚动的场景,而如果我们要实现横向滚动的话,应该把fruit_item里的元素改成垂直排列才比较合理。修改fruit_item.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="100dp"
    android:layout_height="wrap_content">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_image"
        android:layout_gravity="center_horizontal"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_name"
        android:layout_gravity="center_horizontal"
        android:layout_marginLeft="10dp"/>
</LinearLayout>

将LinearLayout改成垂直方向排列,并把宽度设为100dp。这里将宽度指定为固定值是因为每种水果的文字长度不一致,如果用wrap_content的话,RecyclerView的子项就会有长有短,非常不美观;而如果用match_parent的话,就会导致宽度过长,一个子项占满整个屏幕。

然后将ImageView和TextView都设置成了在布局中水平居中,并且使用layout_marginTop属性让文字和图片之间保持一些距离。接下来修改MainActivity中的代码,如下所示:

package com.zhouzhou.recyclerviewtest;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentTransitionImpl;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.os.Bundle;

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

public class MainActivity extends AppCompatActivity {
    private List<Fruit> fruitList = new ArrayList<>();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();//初始化水果数据
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
        LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        //调用LinearLayoutManager的setOrientation()方法来设置布局的排列方向,默认是纵向排列的
        layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
        recyclerView.setLayoutManager(layoutManager);
        FruitAdapter adapter = new FruitAdapter(fruitList);
        recyclerView.setAdapter(adapter);
    }

    private void initFruits() {
        for (int i = 0; i < 2; i++) {
            Fruit apple = new Fruit("Apple", R.drawable.apple_pic);
            fruitList.add(apple);
            Fruit banana = new Fruit("Banana", R.drawable.banana_pic);
            fruitList.add(banana);
            Fruit cherry = new Fruit("Cherry", R.drawable.cherry_pic);
            fruitList.add(cherry);
            Fruit grape = new Fruit("Grape", R.drawable.grape_pic);
            fruitList.add(grape);
            Fruit mango = new Fruit("Mango", R.drawable.mango_pic);
            fruitList.add(mango);
            Fruit orange = new Fruit("Orange", R.drawable.orange_pic);
            fruitList.add(orange);
            Fruit pear = new Fruit("Pear", R.drawable.pear_pic);
            fruitList.add(pear);
            Fruit watermelon = new Fruit("Watermelon", R.drawable.watermelon_pic);
            fruitList.add(watermelon);
            Fruit pineapple = new Fruit("Pineapple", R.drawable.pineapple_pic);
            fruitList.add(pineapple);
            Fruit strawberry = new Fruit("Strawberry", R.drawable.strawberry_pic);
            fruitList.add(strawberry);
        }
    }
}

MainActivity中只加入了一行代码,调用LinearLayoutManager的setOrientation()方法来设置布局的排列方向,默认是纵向排列的,传入LinearLayoutManager.HORIZONTAL表示让布局横行排列,这样RecyclerView就可以横向滚动了。重新运行一下程序,效果如图:

image

可以用手指在水平方向上滑动来查看屏幕外的数据。

为什么ListView很难或者根本无法实现的效果在RecyclerView上这么轻松就能实现了呢?

这主要得益于RecyclerView出色的设计。ListView的布局排列是由自身去管理的,而RecyclerView则将这个工作交给了LayoutManager,LayoutManager中制定了一套可扩展的布局排列接口,子类只要按照接口的规范来实现,就能定制出各种不同排列方式的布局了。

除了LinearLayoutManager之外,RecyclerView还给我们提供了GridLayoutManager和StaggeredGridLayoutManager这两种内置的布局排列方式。GridLayoutManager可以用于实现网格布局,StaggeredGridLayoutManager可以用于实现瀑布流布局。

这里我们来实现一下效果更加炫酷的瀑布流布局。首先还是来修改一下fruit_item.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="5dp">
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_image"
        android:layout_gravity="center_horizontal"/>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/fruit_name"
        android:layout_gravity="left"
        android:layout_marginLeft="10dp"/>
</LinearLayout>

这里做了几处小的调整,首先将LinearLayout的宽度由100dp改成了match_parent,因为瀑布流布局的宽度应该是根据布局的列数来自动适配的,而不是一个固定值。

另外使用了layout_margin属性来让子项之间互留一点间距,这样就不至于所有子项都紧贴在一些。还有就是将TextView的对齐属性改成了居左对齐,因为待会我们会将文字的长度变长,如果还是居中显示就会感觉怪怪的。接着修改MainActivity中的代码,如下所示:

package com.zhouzhou.recyclerviewtest;

import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentTransitionImpl;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;

import android.os.Bundle;

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

public class MainActivity extends AppCompatActivity {
    private List<Fruit> fruitList = new ArrayList<>();
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initFruits();//初始化水果数据
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
        StaggeredGridLayoutManager layoutManager = new StaggeredGridLayoutManager(3,StaggeredGridLayoutManager.VERTICAL);
        //LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        //调用LinearLayoutManager的setOrientation()方法来设置布局的排列方向,默认是纵向排列的
        //layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
        recyclerView.setLayoutManager(layoutManager);
        FruitAdapter adapter = new FruitAdapter(fruitList);
        recyclerView.setAdapter(adapter);
    }

    private void initFruits() {
        for (int i = 0; i < 2; i++) {
            Fruit apple = new Fruit(getRandomLengthName("Apple"), R.drawable.apple_pic);
            fruitList.add(apple);
            Fruit banana = new Fruit(getRandomLengthName("Banana"), R.drawable.banana_pic);
            fruitList.add(banana);
            Fruit cherry = new Fruit(getRandomLengthName("Cherry"), R.drawable.cherry_pic);
            fruitList.add(cherry);
            Fruit grape = new Fruit(getRandomLengthName("Grape"), R.drawable.grape_pic);
            fruitList.add(grape);
            Fruit mango = new Fruit(getRandomLengthName("Mango"), R.drawable.mango_pic);
            fruitList.add(mango);
            Fruit orange = new Fruit(getRandomLengthName("Orange"), R.drawable.orange_pic);
            fruitList.add(orange);
            Fruit pear = new Fruit(getRandomLengthName("Pear"), R.drawable.pear_pic);
            fruitList.add(pear);
            Fruit watermelon = new Fruit(getRandomLengthName("Watermelon"), R.drawable.watermelon_pic);
            fruitList.add(watermelon);
            Fruit pineapple = new Fruit(getRandomLengthName("Pineapple"), R.drawable.pineapple_pic);
            fruitList.add(pineapple);
            Fruit strawberry = new Fruit(getRandomLengthName("Strawberry"), R.drawable.strawberry_pic);
            fruitList.add(strawberry);
        }
    }
    private String getRandomLengthName(String name){
        Random random = new Random();
        int length = random.nextInt(20) + 1;
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < length; i++) {
            builder.append(name);
        }
        return builder.toString();
    }
}

首先,在onCreate()方法中,我们创建了一个StaggeredGridLayoutManager的实例。StaggeredGridLayoutManager的构造函数接收两个参数,第一个参数用于指定布局的列数,传入3表示会把布局分为3列;第二个参数用于指定布局的排列方向,传入StaggeredGrid-LayoutManager.VERTICAL表示会让布局纵向排列,最后再把创建好的实例设置到RecyclerView当中就可以了,就是这么简单!仅仅修改了一行代码,我们就已经成功实现瀑布流布局的效果了。

不过由于瀑布流布局需要各个子项的高度不一致才能看出明显的效果,为此我又使用了一个小技巧。这里我们把眼光聚焦在getRandomLengthName()这个方法上,这个方法使用了Random对象来创造一个1到20之间的随机数,然后将参数中传入的字符串随机重复几遍。在initFruits()方法中,每个水果的名字都改成调用getRandomLengthName()这个方法来生成,这样就能保证各水果名字的长短差距都比较大,子项的高度也就各不相同了。现在重新运行一下程序,效果如图:

image

当然由于水果名字的长度每次都是随机生成的,你运行时的效果肯定和图中还是不一样的。

3.6.3 RecyclerView的点击事件

和ListView一样,RecyclerView也必须要能响应点击事件才可以,不然的话就没什么实际用途了。不过不同于ListView的是,RecyclerView并没有提供类似于setOnItemClickListener()这样的注册监听器方法,而是需要我们自己给子项具体的View去注册点击事件,相比于ListView来说,实现起来要复杂一些。

为什么RecyclerView在各方面的设计都要优于ListView,偏偏在点击事件上却没有处理得非常好呢?

其实不是这样的,ListView在点击事件上的处理并不人性化,setOnItemClickListener()方法注册的是子项的点击事件,但如果我想点击的是子项里具体的某一个按钮呢?虽然ListView也是能做到的,但是实现起来就相对比较麻烦了。为此,RecyclerView干脆直接摒弃了子项点击事件的监听器,所有的点击事件都由具体的View去注册,就再没有这个困扰了。

下面我们来具体学习一下如何在RecyclerView中注册点击事件,修改FruitAdapter中的代码,如下所示:

package com.zhouzhou.recyclerviewtest;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;

public class FruitAdapter extends RecyclerView.Adapter<FruitAdapter.ViewHolder> {
    private List<Fruit> mFruitList;
    static class ViewHolder extends RecyclerView.ViewHolder{
        View fruitView;
        ImageView fruitImage;
        TextView fruitName;
        public ViewHolder(View view){
            super(view);
            fruitView = view;
            fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
            fruitName = (TextView) view.findViewById(R.id.fruit_name);
        }
    }
    public FruitAdapter(List<Fruit> fruitList){
        mFruitList = fruitList;
    }
    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.fruit_item,parent,false);
        final  ViewHolder holder = new ViewHolder(view);
        holder.fruitView.setOnClickListener(new View.OnClickListener() {
                                                @Override
                                                public void onClick(View view) {
                                                    int position = holder.getAdapterPosition();
                                                    Fruit fruit = mFruitList.get(position);
                                                    Toast.makeText(view.getContext(),"You clicked view " + fruit.getName(),Toast.LENGTH_SHORT).show();
                                                }
                                            });
        holder.fruitImage.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                int position = holder.getAdapterPosition();
                Fruit fruit = mFruitList.get(position);
                Toast.makeText(view.getContext(),"You clicked image " + fruit.getName(),Toast.LENGTH_SHORT).show();
            }
        });
        return holder;
    }
    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Fruit fruit = mFruitList.get(position);
        holder.fruitImage.setImageResource(fruit.getImageId());
        holder.fruitName.setText(fruit.getName());
    }
    @Override
    public int getItemCount() {
        return mFruitList.size();
    }
}

先是修改了ViewHolder,在ViewHolder中添加了fruitView变量来保存子项最外层布局的实例,然后在onCreateViewHolder()方法中注册点击事件就可以了。这里分别为最外层布局和ImageView都注册了点击事件,RecyclerView的强大之处也在这里,它可以轻松实现子项中任意控件或布局的点击事件

我们在两个点击事件中先获取了用户点击的position,然后通过position拿到相应的Fruit实例,再使用Toast分别弹出两种不同的内容以示区别。现在重新运行代码,并点击香蕉的图片部分,效果如图。可以看到,这时触发了ImageView的点击事件。

image

再点击菠萝的文字部分,由于TextView并没有注册点击事件,因此点击文字这个事件会被子项的最外层布局捕获到,效果如图:

image

3.7 编写界面的最佳实践

这次要综合运用前面所学的大量内容来编写出一个较为复杂且相当美观的聊天界面。要先创建一个UIBestPractice项目。

3.7.1 制作Nine-Patch图片

在实战正式开始之前,还需要先学习一下如何制作Nine-Patch图片。它是一种被特殊处理过的png图片,能够指定哪些区域可以被拉伸、哪些区域不可以。Nine-Patch图片到底有什么实际作用呢?通过一个例子来看一下吧。比如说项目中有一张气泡样式的图片message_left.png,如图所示:

image

将这张图片设置为LinearLayout的背景图片,修改activity_main.xml中的代码,如下所示:

<?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:background="@drawable/message_left">
</LinearLayout>

将LinearLayout的宽度指定为match_parent,将它的背景图设置为message_left,现在运行程序,效果如图:

image

可以看到,由于message_left的宽度不足以填满整个屏幕的宽度,整张图片被均匀地拉伸了!

这种效果非常差,用户肯定是不能容忍的,这时我们就可以使用Nine-Patch图片来进行改善,9-patch是一种缩放不失真或变形的图片格式,常用于聊天框的实现。

(书中所写:“在Android sdk目录下有一个tools文件夹,在这个文件夹中找到draw9patch.bat文件,我们就是使用它来制作Nine-Patch图片的。不过,要打开这个文件,必须先将JDK的bin目录配置到环境变量当中才行,比如你使用的是Android Studio内置的jdk,那么要配置的路径就是<Android Studio安装目录>/jre/bin。如果你还不知道该如何配置环境变量,可以先去参考6.4.1小节的内容。双击打开draw9patch.bat文件,在导航栏点击File→Open 9-patch将message_left.png加载进来。”)

找不到draw9patch.bat?已经不用找了。Google 已经因为 draw9patch 热门的原因,把它集成在 Android Studio 里面了,你现在可以直接在 Android Studio 里直接打开编辑了。如图所示:

image

点击 Create9-Patch file...后,message_left.9.png被加载进来了:

image

可以在图片的四个边框绘制一个个的小黑点,在上边框和左边框绘制的部分表示当图片需要拉伸时就拉伸黑点标记的区域,在下边框和右边框绘制的部分表示内容会被放置的区域。使用鼠标在图片的边缘拖动就可以进行绘制了,按住Shift键拖动可以进行擦除。绘制完成后效果如图:

image

(书中所写:“最后点击导航栏File→Save 9-patch把绘制好的图片进行保存,此时的文件名就是message_left.9.png。使用这张图片替换掉之前的message_left.png图片”,我运行之后,出现报错Error:Duplicate resources 解决是,直接删除了图片message_left.png,并且在activity_main.xml文件中,ndroid:background="@drawable/message_left"没有更改)重新运行程序,效果如图:

image

这样当图片需要拉伸的时候,就可以只拉伸指定的区域,程序在外观上也有了很大的改进。

3.7.2 编写精美的聊天界面

编写一个聊天界面,那就肯定要有收到的消息和发出的消息。上一节中制作的message_left.9.png可以作为收到消息的背景图,还需要再制作一张message_right.9.png作为发出消息的背景图。图片都提供好了之后就可以开始编码了。(会用到RecyclerView)如下所示:

image

接下来开始编写主界面,修改activity_main.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#d8e0e8">
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/msg_recycler_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"/>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <EditText
            android:id="@+id/input_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="Type something here"
            android:maxLines="2" />
        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/send"
            android:text="Send"/>
    </LinearLayout>   
</LinearLayout>

在主界面中放置了一个RecyclerView用于显示聊天的消息内容,又放置了一个EditText用于输入消息,还放置了一个Button用于发送消息。然后定义消息的实体类,新建Msg,代码如下所示:

package com.zhouzhou.uibestpractice;

public class Msg {
    public static final int TYPE_RECEIVED = 0;
    public static final int TYPE_SENT = 1;
    private String content;
    private int type;

    public Msg(String content, int type) {
        this.content = content;
        this.type = type;
    }

    public String getContent() {
        return content;
    }

    public int getType() {
        return type;
    }
}

Msg类中只有两个字段,content表示消息的内容,type表示消息的类型。其中消息类型有两个值可选,TYPE_RECEIVED表示这是一条收到的消息,TYPE_SENT表示这是一条发出的消息。

接着来编写RecyclerView子项的布局,新建msg_item.xml,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">
    <LinearLayout
        android:id="@+id/left_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="left"
        android:background="@drawable/message_left">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/left_msg"
            android:layout_gravity="center"
            android:layout_margin="10dp"
            android:textColor="#fff"/>
    </LinearLayout>
    <LinearLayout
        android:id="@+id/right_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right"
        android:background="@drawable/message_right">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/right_msg"
            android:layout_gravity="center"
            android:layout_margin="10dp"/>
    </LinearLayout>
</LinearLayout>

这里让收到的消息居左对齐,发出的消息居右对齐,并且分别使用message_left.9.png和message_right.9.png作为背景图。

你可能会有些疑虑,怎么能让收到的消息和发出的消息都放在同一个布局里呢?不用担心,还记得我们前面学过的可见属性吗?只要稍后在代码中根据消息的类型来决定隐藏和显示哪种消息就可以了。

接下来需要创建RecyclerView的适配器类,新建类MsgAdapter,代码如下所示:

package com.zhouzhou.uibestpractice;

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.recyclerview.widget.RecyclerView;

import java.util.List;

public class MsgAdapter extends RecyclerView.Adapter<MsgAdapter.ViewHolder> {
    public MsgAdapter(List<Msg> mMsgList) {
        this.mMsgList = mMsgList;
    }
    private List<Msg> mMsgList;
    static class ViewHolder extends RecyclerView.ViewHolder {
        LinearLayout leftLayout;
        LinearLayout rightLayout;
        TextView leftMsg;
        TextView rightMsg;
        public ViewHolder(@NonNull View itemView) {
            super(itemView);
            leftLayout = (LinearLayout) itemView.findViewById(R.id.left_layout);
            rightLayout = (LinearLayout) itemView.findViewById(R.id.right_layout);
            leftMsg = (TextView) itemView.findViewById(R.id.left_msg);
            rightMsg = (TextView) itemView.findViewById(R.id.right_msg);
        }
    }
    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.msg_item,parent,false);
        return new ViewHolder(view);
    }
    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Msg msg = mMsgList.get(position);
        if (msg.getType() == Msg.TYPE_RECEIVED){
            //如果是收到的消息,则显示左边的消息布局,将右边的消息布局隐藏
            holder.leftLayout.setVisibility(View.VISIBLE);
            holder.rightLayout.setVisibility(View.GONE);
            holder.leftMsg.setText(msg.getContent());
        }else if (msg.getType() == Msg.TYPE_SENT){
            //如果是发出的消息,则显示右边的消息布局,将左边的消息布局隐藏
            holder.rightLayout.setVisibility(View.VISIBLE);
            holder.leftLayout.setVisibility(View.GONE);
            holder.rightMsg.setText(msg.getContent());
        }
    }
    @Override
    public int getItemCount() {
        return mMsgList.size();
    }
}

以上代码在onBindViewHolder()方法中增加了对消息类型的判断。如果这条消息是收到的,则显示左边的消息布局,如果这条消息是发出的,则显示右边的消息布局。

最后修改MainActivity中的代码,来为RecyclerView初始化一些数据,并给发送按钮加入事件响应,代码如下所示:

package com.zhouzhou.uibestpractice;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {
    private List<Msg> msgList = new ArrayList<>();
    private EditText inputText;
    private Button send;
    private RecyclerView msgRecyclerView;
    private MsgAdapter adapter;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initMsgs();//初始化消息数据
        inputText = (EditText) findViewById(R.id.input_text);
        send = (Button) findViewById(R.id.send);
        msgRecyclerView = (RecyclerView) findViewById(R.id.msg_recycler_view);
        LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        msgRecyclerView.setLayoutManager(layoutManager);
        adapter = new MsgAdapter(msgList);
        msgRecyclerView.setAdapter(adapter);
        send.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String content = inputText.getText().toString();
                if(!"".equals(content)){
                    Msg msg =new Msg(content,Msg.TYPE_SENT);
                    msgList.add(msg);
                    adapter.notifyItemInserted(msgList.size() - 1);//当有新消息时,刷新RecyclerView中的显示
                    msgRecyclerView.scrollToPosition(msgList.size() - 1);//将RecyclerView定位到最后一行
                    inputText.setText("");//清空输入框中的内容
                }
            }
        });
        
    }

    private void initMsgs() {
        Msg msg1 = new Msg("Hello guy.",Msg.TYPE_RECEIVED);
        msgList.add(msg1);
        Msg msg2 = new Msg("Hello.Who is that?",Msg.TYPE_SENT);
        msgList.add(msg2);
        Msg msg3 = new Msg("This is zhouzhou.Nice talking to you",Msg.TYPE_RECEIVED);
        msgList.add(msg3);
        Msg msg4 = new Msg("Hi zhouzhou.My name is xiaoming.",Msg.TYPE_RECEIVED);
        msgList.add(msg4);
    }
}

在initMsgs()方法中先初始化了几条数据用于在RecyclerView中显示。

在发送按钮的点击事件里获取了EditText中的内容,如果内容不为空字符串则创建出一个新的Msg对象,并把它添加到msgList列表中去。

又调用了适配器的notifyItemInserted()方法,用于通知列表有新的数据插入,这样新增的一条消息才能够在RecyclerView中显示。

调用RecyclerView的scrollToPosition()方法将显示的数据定位到最后一行,以保证一定可以看得到最后发出的一条消息。

调用EditText的setText()方法将输入的内容清空。

这样所有的工作就都完成了,终于可以检验一下我们的成果了,运行程序之后你将会看到非常美观的聊天界面,并且可以输入和发送消息,如图:

image
image
image

posted @ 2022-05-11 03:56  江河湖泊  阅读(984)  评论(0编辑  收藏  举报