UI 开发

从这里开始学习怎样使用 Android 提供的 UI 开发工具来编写程序界面。

如何编写程序界面

Android 中有多种编写程序界面的方式可供选择。

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

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

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

常用控件的使用方法

Android 提供了大量的 UI 控件, 合理地使用这些控件就可以手非常轻松地编写出相当不错的界面。

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

TextView

TextView 可以说是 Android 中最简单的一个控件了, 它主要用于在界面上显示一段文本信息。

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This is TextView" />
</LinearLayout>

外面的 LinearLayout 先忽略不看。

在 TextView 中我们使用 androoid:id 给当前控件定义了一个唯一标识符。

然后使用 android:layout_widthandroid:layout_height 指定了控件的宽度和高度。

Android 中所有的控件都具有这两个属性, 可选值有 3 种: match_parentfill_parentwrap_content

其中 match_parentfill_parent 的意义相同, 现在官方更加推荐使用 match_parentmatch_parent 表示让当前控件的大小和父布局的大小一样, 也就是由父布局来决定当前控件的大小。

wrap_content 表示让当前控件的大小能够刚好包含住里面的内容, 也就是由控件内容决定当前控件的大小。

所以上面的代码就表示让 TextView 的宽度和父布局一样宽, 也就是手机屏幕的宽度, 让 TextView 的高度足够包含住里面的内容就行。

当然除了使用上述值, 你也可以对控件的宽和高指定一个固定的大小, 但是这样做有时会在不同手机屏幕的适配方面出现问题。

接下来我们通过 android:text 指定 TextView 中显示的文本内容。

运行程序的效果:

虽然指定的文本内容正常显示了, 不过我们好像没看出来 TextView 的宽度是和屏幕一样宽的。其实这是由于 TextView 中的文字默认是居左上角对齐的,虽然 TextView 的宽度充满了整个屏幕,可是由于文字内容不够长, 所以从效果上完全看不出来。现在我们修改 TextView 的文字对齐方式, 如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<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、rightcenter 等, 可以用 | 来同时指定多个值, 这里我们指定的 center, 效果等同于 center_vertical|center_horizontal, 表示文字在垂直和水平方向都居中对齐。现在重新运行程序, 效果:

这也说明了 TextView 的宽度确实是和屏幕宽度一样的。

另外我们还可以对 TextView 中文字的大小和颜色进行修改, 如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
</LinearLayout>

通过 android:textSize 属性可以指定文字的大小, 通过 android:textColor 属性可以指定文字的颜色, 在 Android 中字体大小使用 sp 作为单位。重新运行程序, 效果:

Button

可以在 AndroidManifest.xml 文件中这样加入 Button 组件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!" />
</LinearLayout>

运行程序,效果:

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
</LinearLayout>

重新运行程序,效果:

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

package com.example.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 button1 = findViewById(R.id.button);
button1.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 此处写点击事件的逻辑
}
});
}
}

这样每当点击按钮时, 就会执行监听器中的 onClick() 方法, 我们只需要在这个方法中加入待处理的逻辑就行了。

如果你不喜欢使用匿名类的方式来注册监听器, 也可以使用实现接口的方式来进行注册, 代码如下所示:

package com.example.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 button1 = findViewById(R.id.button);
button1.setOnClickListener(this);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button:
// 此处添加逻辑
break;
default:
break;
}
}
}

这两种写法都可以实现对按钮点击事件的监听, 至于使用哪一种就全凭你的喜好了。

EditText

EditText 是程序用于和用户进行交互的另一个重要控件, 它允许用户在控件里输入和编辑内容, 并可以在程序中对这些内容进行处理。EditText 的应用地场景非常普遍, 在进行发短信、发微博、聊 QQ 等操作时, 你不得不使用 EditText。

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"></EditText>
</LinearLayout>

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

重新运行一下程序, EditText 就已经在界面上显示出来了, 并且我们是可以在里面输入内容的:

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="在这里输入一些东西"></EditText>
</LinearLayout>

这里使用 android:hint 属性指定了一段提示性的文本。

效果:

可以看到, EditText 中显示了一段提示性文本, 然后当我们输人任何内容时, 这段文本就会自动消失。

不过, 随着输入的内容不断增多, EditText 会被不断地拉长。这时由于 EditText 的高度指定的是 wrap_content, 因此它总能包含住里面的内容, 但是当输入的内容过多时, 界面就会变得非常难看。效果:

我们可以使用 android:maxLines 属性来解决这个问题, 修改 activity_main.xml, 如下所示:

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

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

package com.example.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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
button1.setOnClickListener(this);
}
@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_LONG).show();
break;
default:
break;
}
}
}

首先通过 findViewById() 方法得到 EditText 的实例, 然后在按钮的点击事件里调用 EditText 的 getText() 方法获取到输入的内容, 再调用 toString() 方法转换成字符串, 最后还是老方法, 使用 Toast 将输入的内容显示出来。

重新运行程序, 在 EditText 中输入一段内容, 然后点击按钮, 效果:

ImageView

ImageView 可以让我们的程序界面变得更加丰富多彩。学习这个控件需要提前准备好一些图片, 图片通常都是放敬在以 "drawable" 开头的目录下的。目前我们的项目中有一个空的 drawable 目录, 不过由于这个目录没有指定具体的分辨率, 所以一般不使用它来放置图片。这里我们在 res 目录下新建一个 drawable-xhdpi 目录, 然后将事先准备好的两张图片 img 1.pngimg 2.png 复制到该目录当中。

接下来修改 activity main.xml, 如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="在这里输入一些东西"
android:maxLines="3"></EditText>
<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/image_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/img_1" />
</LinearLayout>

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

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

package com.example.uiwidgettest;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
imageView = findViewById(R.id.image_view);
button1.setOnClickListener(this);
}
@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 中显示的图片改变了.

ProgressBar

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="在这里输入一些东西"
android:maxLines="3"></EditText>
<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/image_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/img_1" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

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

这时你可能会问, 旋转的进度条表明我们的程序正在加载数据, 那数据总会有加载完的时候吧? 如何才能让进度条在数据加载完成时消失呢?

这里我们就需要用到一个新的知识点: Android 控件的可见属性。

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

  • visible 表示控件是可见的, 这个值是默认值, 不指定 android:visibility 时, 控件都是可见的。

  • invisible 表示控件不可见, 但是它仍然占据着原来的位置和大小, 可以理解成控件变成透明状态了。

  • gone 则表示控件不仅不可见, 而且不再占用任何屏幕空间。

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

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

package com.example.uiwidgettest;
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;
import androidx.appcompat.app.AppCompatActivity;
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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
imageView = findViewById(R.id.image_view);
progressBar = findViewById(R.id.progress_bar);
button1.setOnClickListener(this);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button:
// 此处添加逻辑
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 中的代码, 如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="This is TextView"
android:textColor="#00ff00"
android:textSize="24sp" />
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Button!"
android:textAllCaps="false" />
<EditText
android:id="@+id/edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="在这里输入一些东西"
android:maxLines="3"></EditText>
<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/image_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/img_1" />
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100" />
</LinearLayout>

效果:

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

package com.example.uiwidgettest;
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;
import androidx.appcompat.app.AppCompatActivity;
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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
imageView = findViewById(R.id.image_view);
progressBar = findViewById(R.id.progress_bar);
button1.setOnClickListener(this);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button:
// 此处添加逻辑
int progress = progressBar.getProgress();
progress += 10;
progressBar.setProgress(progress);
break;
default:
break;
}
}
}

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

AlertDialog

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

修改 MainActivity 中的代码, 如下所示:

package com.example.uiwidgettest;
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;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
imageView = findViewById(R.id.image_view);
progressBar = findViewById(R.id.progress_bar);
button1.setOnClickListener(this);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button:
// 此处添加逻辑
// 通过 AlertDialog.Builder 创建一个 AlertDialog 的实例
AlertDialog.Builder dialog = new AlertDialog.Builder(MainActivity.this);
dialog.setTitle("This is Dialog."); // 为这个对话框设置标题
dialog.setMessage("Something important."); // 为这个对话框设置内容
dialog.setCancelable(false); // 为这个对话框设置可否取消
// 调用 setPositiveButton() 方法为对话框设置确定按钮的点击事件
dialog.setPositiveButton("OK", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
}
});
// 调用 setNegativeButton() 方法为对话框设置确定按钮的点击事件
dialog.setNegativeButton("Cancel", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
}
});
// 调用 show() 方法将对话框显示出来
dialog.show();
break;
default:
break;
}
}
}

重新运行程序, 点击按钮后, 效果:

出现弹窗后,点击弹窗外面部分,弹窗不会消失。但是如果将 dialog.setCancelable(false); // 为这个对话框设置可否取消 改为 dialog.setCancelable(true); // 为这个对话框设置可否取消,则点击弹窗外部范围,弹窗也会关闭。改为 dialog.setCancelable(true); // 为这个对话框设置可否取消 之后的效果:

ProgressDialog

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

package com.example.uiwidgettest;
import android.app.ProgressDialog;
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;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
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 button1 = findViewById(R.id.button);
editText = (EditText) findViewById(R.id.edit_text);
imageView = findViewById(R.id.image_view);
progressBar = findViewById(R.id.progress_bar);
button1.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 显示出来。重新运行程序, 点击按钮后, 效果:

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

详解 4 种基本布局

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

布局是一种可用于放置很多控件的容器, 它可以按照一定的规律调整内部控件的位置, 从而编写出精美的界面。

布局的内部除了放置控件外, 也可以放置布局, 通过多层布局的嵌套, 我们就能够完成一些比较复杂的界面实现, 下图很好地展示了它们之间的关系:

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

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

线性布局

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"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="button 1" />
<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="button 2" />
<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="button 3" />
</LinearLayout>

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

activity_main.xml 中的 android:orientation="vertical" 改为 android:orientation="horizontal",现在运行一下程序, 效果:

如果不指定 android:orientation 属性的值, 默认的排列方向就是 horizontal。

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

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"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<Button
android:id="@+id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:text="button 1" />
<Button
android:id="@+id/button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="button 2" />
<Button
android:id="@+id/button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:text="button 3" />
</LinearLayout>

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

接下来我们学习下 LinearLayout 中的另一个重要属性 -- androidd:layout_weight

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

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

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

另外, dp 是 Android 中用于指定控件大小、间距等属性的单位。

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

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

重新运行程序, 你会看到下图效果。

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<EditText
android:id="@+id/input_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Type something here"
tools:ignore="Suspicious0dp" />
<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 则会占满屏幕所有的剩余空间。使用这种方式编写的界面, 不仅在各种用屏幕的适配方面会非常好, 而且看起来也更加舒服。重新运行程序, 效果:

相对布局

RelativeLayout 又称作相对布局。和 LinearLayout 的排列规则不同, RelativeLayout 显得更加随意一些, 它可以通过相对定位的方式让控件出现在布局的任何位置。也正因为如此, RelativeLayout 中的属性非常多, 不过这些属性都是有规律可循的。

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

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<Button
android:id="@+id/Button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentTop="true"
android:text="Button 1" />
<Button
android:id="@+id/Button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:text="Button 2" />
<Button
android:id="@+id/Button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Button 3" />
<Button
android:id="@+id/Button4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentBottom="true"
android:text="Button 4" />
<Button
android:id="@+id/Button5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_alignParentBottom="true"
android:text="Button 5" />
</RelativeLayout>

我们让 Button1 和父布局的左上角对齐, Button2 和父布局的右上角对齐, Button3 居中显示, Button4 和父布局的左下角对齐, Button5 和父布局的右下角对齐。虽然 android:layout_alignParentLeftandroid:layout_alignParentTopandroid:layout_alignParentRightandroid:layoutalignParentBottomandroid:layout_centerInParent 这几个属性我们之前都没接触过, 可是它们的名字已经完全说明了它们的作用。重新运行程序, 效果:

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

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<Button
android:id="@+id/Button3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Button 3" />
<Button
android:id="@+id/Button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/Button3"
android:layout_toLeftOf="@+id/Button3"
android:text="Button 1" />
<Button
android:id="@+id/Button2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/Button3"
android:layout_toRightOf="@+id/Button3"
android:text="Button 2" />
<Button
android:id="@+id/Button4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/Button3"
android:layout_toLeftOf="@+id/Button3"
android:text="Button 4" />
<Button
android:id="@+id/Button5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/Button3"
android:layout_toRightOf="@+id/Button3"
android:text="Button 5" />
</RelativeLayout>

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

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

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

帧布局

FrameLayout 又称作帧布局。

这种布局没有方便的定位方式, 所有的控件都会默认摆放在布局的左上角。

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

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

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

可以看到, 文字和图片都是位于布局的左上角。由于 ImageView 是在 TextView 之后添加的, 因此图片压在了文字的上面。

当然除了这种默认效果之外, 我们还可以使用 layout_gravity 属性来指定控件在布局中的对齐方式, 这和 LinearLayout 中的用法是相似的。修改 activitymain.xml 中的代码, 如下所示:

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

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

百分比布局

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

不过只有 LinearLayout 支持使用 layout_weight 属性来实现按比例指定控件大小的功能, 其他两种布局都不支持。比如说, 如果想用 RelativeLayout 来实现让两个按钮平分布局宽度的效果, 则是比较困难的。

为此, Android 引人了一种全新的布局方式来解决此问题 -- 百分比布局。在这种布局中, 我们可以不再使用 wrap_contentmatch_parent 等方式来指定控件的大小, 而是允许直接指定控件在布局中所占的百分比, 这样的话就可以轻松实现平分布局甚至是任意比例分割布局的效果了。

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

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

plugins {
id 'com.android.application'
}
android {
compileSdk 30
defaultConfig {
applicationId "com.example.uilayouttest"
minSdk 28
targetSdk 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'androidx.percentlayout:percentlayout:1.0.0'
}

注意这里最后一行是新加的,是 implementation 'androidx.percentlayout:percentlayout:1.0.0' 而不是书上的 compile 'com.android.support.percent:24.2.1',按照书上去写会导致 sync 报错,修改后则可以 sync 成功且执行程序成功。

参考链接:https://blog.csdn.net/qq_21331159/article/details/114024546

每当修改了任何 gradle 文件时, Android Studio 都会弹出一个 sync 的提示,这个提示告诉我们, 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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".MainActivity">
<Button
android:id="@+id/button1"
android:layout_gravity="left|top"
android:text="Button 1"
app:layout_heightPercent="50%"
app:layout_widthPercent="50%" />
<Button
android:id="@+id/button2"
android:layout_gravity="right|top"
android:text="Button 2"
app:layout_heightPercent="50%"
app:layout_widthPercent="50%" />
<Button
android:id="@+id/button3"
android:layout_gravity="left|bottom"
android:text="Button 3"
app:layout_heightPercent="50%"
app:layout_widthPercent="50%" />
<Button
android:id="@+id/button4"
android:layout_gravity="right|bottom"
android:text="Button 4"
app:layout_heightPercent="50%"
app:layout_widthPercent="50%" />
</androidx.percentlayout.widget.PercentFrameLayout>

这里代码会报错:

这是因为老版本的 Android Studio 中内置了布局的检查机制, 认为每一个控件都应该通过 android:layout_widthandroid_height 属性指定宽高才是合法的。而其实我们是通过 app:layout_widthPercentapp:layout_heightPercent 属性来指定宽高的, 所以 Android Studio 没检测到。

不过这个错误提示并不影响程序运行, 我们直接忽视就可以了。当然最新的 Android Studio 2.2 版本中已经修复了这个问题, 因此你可能并不会看到上述的错误提示。

这里选择忽略报错提示,按照提示去改反而无法执行程序。

执行程序后的效果:

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

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

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

可以看到, 每一个按钮的宽和高都占据了布局的 50%, 这样我们门就轻松实现了 4 个按钮平分屏幕的效果。

PercentFrameLayout 的用法就介绍到这里, 另外一个 PercentRelativeLayout 的用法也是非常相似的, 它继承了 RelativeLayout 中的所有属性, 并且可以使用 app:layout_widthPercentapp:layout_heightPercent 来按百分比指定控件的宽高。

创建自定义控件

控件和布局的继承结构:

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

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

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

先将准备工作做好, 创建一个 UICustomViews 项目。

引入布局

新建一个布局 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:id="@+id/title_back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="5dp"
android:background="@drawable/back_bg"
android:text="Back"
android:textColor="#fff" />
<TextView
android:id="@+id/title_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:text="Title Text"
android:textColor="#fff"
android:textSize="24sp" />
<Button
android:id="@+id/title_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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 用于为布局或控件指定一个背景, 可以使用颜色或图片来进行填充。

这里我们提前准备好了三张图片 title_bg.pngback_bg.pngedit_bg.png 用于标题栏、返回按钮和编辑按钮的背景。

在两个 Button 中我们都使用了 android:layout_margin 这个属性, 它可以指定控件在上下左右方向上偏移的距离, 当然也可以使用 android:layout_marginLeftandroid:layout_marginTop 等属性来单独指定控件在某个方向上偏移的距离。

现在标题栏布局已经编写完成了, 剩下的就是如何在不程序中使用这个标题栏了, 修改 activity_main.xml 中的代码, 如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<include layout="@layout/title" />
</LinearLayout>

我们只需要通过一行 include 语句将标题栏布局引入进来就可以了。

最后别忘了在 MainActivity 中将系统自带的标题栏隐藏掉, 代码如下所示:

package com.example.uicustomviews;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
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() 方法来获得 ActtionBar 的实例, 然后再调用 ActionBar 的 hide() 方法将标题栏隐藏起来。运行程序,效果:

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

执行程序,效果:

创建自定义控件

引人布局的技巧确实解决了重复编写布局代码的问题, 但是如果布局中有一些控件要求能够响应事件, 我们还是需要在每个活动中为这些控件单独编写一次事件注册的代码。比如说标题栏中的返回按钮, 其实不管是在哪一个活动中, 这个按钮的功能都是相同的, 即销毁当前活动。而如果在每一个活动中都需要重新注册一遍返回按钮的点击事件, 无疑会增加很多重复代码, 这种情况最好是使用自定义控件的方式来解决。

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

package com.example.uicustomviews;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.LinearLayout;
public class TitleLayout extends LinearLayout {
public TitleLayout(Context context, 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 中的代码, 如下所示:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.uicustomviews.TitleLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

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

重新运行程序, 你会发现此时效果和使用引入布局方式的效果是一样的。

下面我们尝试为标题栏中的按钮注册点击事件, 修改 TitleLayout 中的代码, 如下所示:

package com.example.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;
public class TitleLayout extends LinearLayout {
public TitleLayout(Context context, AttributeSet attrs) {
super(context, attrs);
LayoutInflater.from(context).inflate(R.layout.title, this);
Button titleBack = findViewById(R.id.title_back);
Button titleEdit = findViewById(R.id.title_edit);
titleBack.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
((Activity) getContext()).finish();
}
});
titleEdit.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(getContext(), "You click edit button!", Toast.LENGTH_LONG).show();
}
});
}
}

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

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

ListView

ListView 绝对可以称得上是 Android 中最常用的控件之一, 几乎所有的应用程序都会用到它。由于手机屏幕空间都比较有限, 能够一次性在屏幕上显示的内容并不多, 当我们的程序中有大量的数据需要展示的时候, 就可以借助 ListView 来实现。

ListView 允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内, 同时屏幕上原有的数据则到会滚动出屏幕。相信你其实每天都在使用这个控件, 比如查看 QQ 聊天记录, 翻阅微博最新消息, 等等。

ListView 的简单用法

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

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/list_view"
android:layout_height="match_parent"
android:layout_width="match_parent" />
</LinearLayout>

在布局中加入 ListView 控件还算非常简单, 先为 ListView 指定一个 id, 然后将宽度和高度都设置为 match_parent, 这样 ListView 也就占满了整个布局的空间。

接下来修改 MainActivity 中的代码, 如下所示:

package com.example.listviewtest;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.util.AndroidException;
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<>(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 和数据之间的关联就建立完成了。

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

定制 ListView 的界面

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

首先需要准备好一组图片, 分别对应上面提供的每一种水果, 待会我们要让这些水果名称的旁边都有一个图样。

接着定义一个实体类, 作为 ListView 适配器的适配类型。新建建类 Fruit, 代码如下所示:

package com.example.listviewtest;
public class Fruit {
private String name;
private int imageId;
public Fruit(String name, int imageId) {
this.name = name;
this.imageId = imageId;
}
public String getName() {
return name;
}
public int getImageId() {
return imageId;
}
}

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

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

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

package com.example.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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
public class FruitAdapter extends ArrayAdapter<Fruit> {
private int resourceId;
public FruitAdapter(@Nullable Context context, int textViewResourceId, List<Fruit> objects) {
super(context, textViewResourceId, objects);
resourceId = textViewResourceId;
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull 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 中了。

接下来调用 View 的 findViewById() 方法去分别获取到 ImageView 和 TextView 的实例, 并分别调用它们的 setImageResource() 和 setText() 方法来设置显示的图片和文字, 最后将布局返回, 这样我们自定义的适配器就完成了。下面修改 MainActivity 中的代码, 如下所示:

package com.example.listviewtest;
import android.os.Bundle;
import android.widget.ListView;
import androidx.appcompat.app.AppCompatActivity;
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 fruitAdapter = new FruitAdapter(MainActivity.this, R.layout.fruit_item, fruitList);
ListView listView = (ListView) findViewById(R.id.list_view);
listView.setAdapter(fruitAdapter);
}
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 orange = new Fruit("Orange", R.drawable.orange_pic);
fruitList.add(orange);
Fruit watermelon = new Fruit("Watermelon", R.drawable.watermelon_pic);
fruitList.add(watermelon);
Fruit pear = new Fruit("Pear", R.drawable.pear_pic);
fruitList.add(pear);
Fruit grape = new Fruit("Grape", R.drawable.grape_pic);
fruitList.add(grape);
Fruit pineapple = new Fruit("Pineapple", R.drawable.pineapple_pic);
fruitList.add(pineapple);
Fruit strawberry = new Fruit("Strawberry", R.drawable.strawberry_pic);
fruitList.add(strawberry);
Fruit cherry = new Fruit("Cherry", R.drawable.cherry_pic);
fruitList.add(cherry);
Fruit mango = new Fruit("Mango", R.drawable.mango_pic);
fruitList.add(mango);
}
}
}

可以看到, 这里添加了一个 initFruits() 方法, 用于初始化所有的水果数据。在 Fruit 类的构造函数中将水果的名字和对应的图片 id 传入, 然后把创建好的对象添加到水果列表中。另外我们使用了一个 for 循环将所有的水果数据添加了两遍, 这是因为如果只添加一遍的话, 数据量还不足以充满整个屏幕。接着在 onCreate() 方法中创建了 FruitAdapter 对象, 并将 FruitAdapter 作为适配器传递给 ListView, 这样定制 ListView 界面的任务就完成了。

现在重新运行程序, 效果如图所示:

提升 ListView 的运行效率

之所以说 ListView 这个控件很难用, 就是因为它有很多细节可以优化, 其中运行效率就是很重要的一点。目前我们 ListView 的运行效率是很低的, 因为在 FiruitAdapter 的 getView() 方法中, 每次都将布局重新加载了一遍, 当 ListView 快速滚动的时候, 这就会成为性能的瓶颈。

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

package com.example.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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
public class FruitAdapter extends ArrayAdapter<Fruit> {
private int resourceId;
public FruitAdapter(@Nullable Context context, int textViewResourceId, List<Fruit> objects) {
super(context, textViewResourceId, objects);
resourceId = textViewResourceId;
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
Fruit fruit = getItem(position); // 获取当前项的 Fruit 实例
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 中的代码, 如下所示:

我们新增了一个内部类 ViewHolder, 用于对控件的实例进行缓存。当 convertView 为 null 的时候, 创建一个 ViewHolder 对象, 并将控件的实例都存放在 ViewHolder 里, 然后调用 View 的 setTag() 方法, 将 ViewHolder 对象存储在 View 中。当 ConvertView 不为 null 的时候, 则调用 View 的 getTag() 方法, 把 ViewHolder 重新取出。这样所有控件的实例都缓存在了 ViewHolder 里, 就没有必要每次都通过 findViewById() 方法来获取控件实例了。

通过这两步优化之后, 我们 ListView 的运行效率就已经非常不错了。

ListView 的点击事件

ListView 的滚动毕竟只是满足了我们视觉上的效果, 可是如果 ListView 中的子项不能点击的话, 这个控件就没有什么实际的用途了。因此, 本小节我们就来学习一下 ListView 如何才能响应用户的点击事件。

修改 MainActivity 中的代码, 如下所示:

package com.example.listviewtest;
import android.os.Bundle;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
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 fruitAdapter = new FruitAdapter(MainActivity.this, R.layout.fruit_item, fruitList);
ListView listView = (ListView) findViewById(R.id.list_view);
listView.setAdapter(fruitAdapter);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Fruit fruit = fruitList.get(position);
Toast.makeText(MainActivity.this, fruit.getName(), Toast.LENGTH_LONG).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 orange = new Fruit("Orange", R.drawable.orange_pic);
fruitList.add(orange);
Fruit watermelon = new Fruit("Watermelon", R.drawable.watermelon_pic);
fruitList.add(watermelon);
Fruit pear = new Fruit("Pear", R.drawable.pear_pic);
fruitList.add(pear);
Fruit grape = new Fruit("Grape", R.drawable.grape_pic);
fruitList.add(grape);
Fruit pineapple = new Fruit("Pineapple", R.drawable.pineapple_pic);
fruitList.add(pineapple);
Fruit strawberry = new Fruit("Strawberry", R.drawable.strawberry_pic);
fruitList.add(strawberry);
Fruit cherry = new Fruit("Cherry", R.drawable.cherry_pic);
fruitList.add(cherry);
Fruit mango = new Fruit("Mango", R.drawable.mango_pic);
fruitList.add(mango);
}
}
}

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

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

更强大的滚动控件 RecyclerView

ListView 由于其强大的功能, 在过去的 Android 开发当中可以说是贡献卓越, 直到今天仍然还有不计其数的程序在继续使用着 ListView。不过 ListView 并不是完全没有缺点的, 比如说如果我们不使用一些技巧来提升它的运行效率, 那么 ListView 的性能就会非常差。还有, ListView 的扩展性也不够好, 它只能实现数据纵向滚动的效果, 如果我们想实现横向滚动的话, ListView 是做不到的。

为此, Android 提供了一个更强大的滚动控件 RecyclerView。它可以说是一个增强版的 ListView, 不仅可以轻松实现和 ListView 同样的效果, 还优化了 ListView 中存在的各种不足之处。目前 Android 官方更加推荐使用 RecyclerView, 未来也会有更多的程序逐渐从 ListView 转向 RecyclerView, 那么本节我们就来详细讲解一下 RecyclerView 的用法。

首先新建一个 RecyclerViewTest 项目, 并让 Android Studio 自动帮我们创建好活动。

RecyclerView 的基本用法

和百分比布局类似, RecyclerView 也属于新增的控件, 为了让 RecyclerView 在所有 Android 版本上都能使用, Android 团队采取了同样的方式, 将 RecyclerView 定义在了 support 库当中。因此, 想要使用 RecyclerView 这个控件, 首先需要在模块的 build.gradle 中添加相应的依赖库才行。

修改之前:

修改之后:

模块的 build.gradle 的内容为:

plugins {
id 'com.android.application'
}
android {
compileSdk 30
defaultConfig {
applicationId "com.example.recyclerviewtest"
minSdk 28
targetSdk 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'com.android.support:recyclerview-v7:28.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

但是改完之后,代码还是过时的,最后的代码为:

plugins {
id 'com.android.application'
}
android {
compileSdk 30
defaultConfig {
applicationId "com.example.recyclerviewtest"
minSdk 28
targetSdk 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

添加完之后记得要点击一下 Sync Now 来进行同步。然后修改 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 也复制过来, 省得将同样的代码再写一遍。但是实践证明,fruit_item.xml 中的一个位置要改:

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

Fruit 类的代码:

package com.example.recyclerviewtest;
public class Fruit {
private String name;
private int imageId;
public Fruit(String name, int imageId) {
this.name = name;
this.imageId = imageId;
}
public String getName() {
return name;
}
public int getImageId() {
return imageId;
}
}

接下来需要为 RecyclerView 准备一个适配器, 新建 FruitAdapter 类, 让这个适配器继承自 RecyclerView.Adapter, 并将泛型指定为 FruitAdapter.ViewHolder。其中, ViewHolder 是我们在 FruitAdapter 中定义的一个内部类, FruitAdapter 类的代码如下所示:

package com.example.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();
}
}

这里我们首先定义了一个内部类 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() 方法是用于对 RedcyclerView 子项的数据进行赋值的, 会在每个子项被滚动到屏幕内的时候执行, 这里我们通过 position 参数得到当前项的 Fruit 实例, 然后再将数据设置到 ViewHolder 的 ImageView 和 TextView 当中即可。getItemCount() 方法就非常简单了, 它用于告诉 RecyclerView 一共有多少子项, 直接返回数据源的长度就可以了。

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

package com.example.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 的实例, 然后创建了一个 LinearLayoutManager 对象, 并将它设置到 RecyclerView 当中。LayoutManager 用于指定 RecyclerView 的布局方式, 这里使用的 LinearLayoutManager 是线性布局的意思, 可以实现和 ListView 类似的效果。接下来我们创建了 FruitAdapter 的实例, 并将水果数据传人到 FruitAdapter 的构造函数中, 最后调用 RecyclerView 的 setAdapter() 方法来完成适配器设置, 这样 RecyclerView 和数据之间的关联就建立完成了。

现在可以运行一下程序了, 效果如图所示:

可以看到, 我们使用 RecyclerView 实现了和 ListView 几乎一模一一样的效果, 虽说在代码量方面并没有明显地减少, 但是逻辑变得更加清晰了。当然这只是 RecyclerView 的基本用法而已, 接下来我们就看一看 RecyclerView 还能实现哪些 ListView 实现不了的效果。

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

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

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

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

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

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

package com.example.recyclerviewtest;
import androidx.appcompat.app.AppCompatActivity;
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);
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() 方法来设置布局的排列方向, 默认是纵向排列的, 我们传人 LinearLayouItManager.HORIZONTAL 表示让布局横行排列, 这样 RecyclerView 就可以横向滚动了。重新运行一下程序, 效果如图所示:

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

为什么 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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:orientation="vertical">
<ImageView
android:id="@+id/fruit_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" />
<TextView
android:id="@+id/fruit_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:layout_marginLeft="10dp" />
</LinearLayout>

这里做了几处小的调整, 首先将 LinearLayout 的宽度由 100dp 改成了 match_parent, 因为瀑布流布局的宽度应该是根据布局的列数来自动适配的, 而不是一个固定值。另外我们使用了 layout_margin 属性来让子项之间互留一点间距, 这样就不至于所有子项都紧贴在一些。还有就是将 TextView 的对齐属性改成了居左对齐, 因为待会我们会将文字的长度变长, 如果还是居中显示就会感觉怪怪的。

接着修改 MainActivity 中的代码, 如下所示:

package com.example.recyclerviewtest;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
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 列;第二个参数用于指定布局的排列方向,传入 StaggeredGridLayoutManager.VERTICAL 表示会让布局纵向排列,最后再把创建好的实例设置到 RecyclerView 当中就可以了,就是这么简单!仅仅修改了一行代码,我们就已经成功实现瀑布流布局的效果了。

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

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

RecyclerView 的点击事件

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

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

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

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

package com.example.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 的点击事件。

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

编写界面的最佳实践

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

制作 Nine-Patch 图片

在实战正式开始之前, 我们还需要先学习一下如何制作 Nine-Pattch 图片。你可能之前还没有听说过这个名词, 它是一种被特殊处理过的 png 图片, 能够指定哪哪些区域可以被拉伸、哪些区域不可以。

那么 Nine-Patch 图片到底有什么实际作用呢? 我们还是通过一个例子来看一下吧。比如说项目中有一张气泡样式的图片 message_left.png, 如图所示:

将这张图片设置为 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_original"></LinearLayout>

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

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

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

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

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

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

此时再次运行程序,出现报错 Error:Duplicate resources,解决是,直接删除了原来的用来制作这个 9-patch 的图片 message_left.png,并且 activity_main.xml 文件不做任何改动。重新运行程序,效果如图:

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

编写精美的聊天界面

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

由于待会我们会用到 RecyclerView, 因此首先需要在 app/build.gradle 当中添加依赖库, 如下所示:

plugins {
id 'com.android.application'
}
android {
compileSdk 30
defaultConfig {
applicationId "com.example.uibestpractice"
minSdk 28
targetSdk 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

其中,implementation 'androidx.recyclerview:recyclerview:1.0.0' 是新添加的。

接下来开始编写主界面,修改 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,代码如下所示:

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:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
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_original">
<TextView
android:id="@+id/left_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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:id="@+id/right_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="10dp" />
</LinearLayout>
</LinearLayout>

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

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

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

package com.example.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.example.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() 方法将输入的内容清空。

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

能够同时兼容手机和平板也是自 Android 4.0 系统开始就支持的特性。

posted @   有空  阅读(54)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
点击右上角即可分享
微信分享提示

目录导航