1.引入
1.0 引入
你已经知道利用活动中的片段可以重用代码,让应用更灵活。
这一章中,我们会介绍如何把一个片段嵌套在另一个片段中。你会看到如何使用子片段管理器驯服不守规矩的片段事务。在这个过程中,你将了解为什么活动与片段之间的差别这么重要。
1.1 需求引入--嵌套片段
并不只是活动可以包含片段,片段还可以嵌套在其他片段中。为了了解这种嵌套片段的实际工作,下面为我们的训练项目详细信息片段增加一个秒表片段。
增加一个新的秒表片段:
我们要增加一个新的秒表片段,名为StopwatchFragment.java,它使用布局fragment_stopwatch.xml。这个片段建立在第4章创建的秒表活动基础上。
活动和片段在很多方面都很类似,不过我们也知道片段是完全不同的一种对象,片段不是活动的子类。有没有办法重写活动代码,让它看上去像是一个片段呢?
1.2 片段和活动有类似的生命周期
要了解如何将活动重写为片段,我们要先考虑这二者之间的相似和不同之处。如果查看片段和活动的声明周期,会发现他们非常相似:
不过方法稍有不同:
?片段生命周期方法与活动生命周期方法几乎一样,不过有一个重要的区别:活动生命周期方法是保护的,而片段生命周期方法是公共的。我们已经知道,片段从布局资源文件创建布局的方式有所不同。
另外,在片段中我们不能直接调用类似findviewById()的方法。实际上,需要先找到一个View对象的引用,然后调用view.findviewById()。
1.3 从活动到片段的转换
要将之前写的StopwatchActivity活动代码,转换成一个StopwatchFragment片段,需要注意以下几点:
- 不使用布局文件activity_stopwatch.xml,而要使用布局文件fragment_Stopwatch.xml
- 确保方法的访问限制是真确的
- 如何指定布局?
- runTimer()方法不能调用findViewById(),所以可能要为runTimer()传入一个视图对象。
完整的代码:
package com.hfad.workout;
import android.os.Bundle;
import androidx.fragment.app.Fragment;
import android.os.Handler;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
public class StopwatchFragment extends Fragment {
//经过的秒数
private int seconds = 0;
//running指示秒表现在是否在运行
private boolean running;
//wasRunning指示秒表暂停前是否在运行
private boolean wasRunning;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//屏幕翻转,片段撤销,需要加载撤销前的局部变量
//从savedInstanceState Bundle 恢复变量的状态
if (savedInstanceState != null) {
seconds = savedInstanceState.getInt("seconds");
running = savedInstanceState.getBoolean("running");
wasRunning = savedInstanceState.getBoolean("wasRunning");
if (wasRunning) {
running = true;
}
}
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
//在onCreateView()中设置片段的布局
View layout = inflater.inflate(R.layout.fragment_stopwatch, container, false);
//启动runTimer()方法并传入布局
runTimer(layout);
//获取按钮视图,并设置单击监视器
Button startButton = (Button) layout.findViewById(R.id.start_button);
startButton.setOnClickListener((View.OnClickListener) this);
Button stopButton = (Button) layout.findViewById(R.id.stop_button);
stopButton.setOnClickListener((View.OnClickListener) this);
Button resetButton = (Button) layout.findViewById(R.id.reset_button);
resetButton.setOnClickListener((View.OnClickListener) this);
return layout;
}
@Override
public void onPause() {
super.onPause();
//如果片段暂停,记录秒表原来是否在运行
wasRunning = running;
//然后将它停止
running = false;
}
@Override
public void onResume() {
super.onResume();
if (wasRunning) {
//如果暂停前秒表在运行,再设置为运行
running = true;
}
}
//活动撤销前将变量放在Bundle中,用户旋转设备时会使用这些变量
@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
savedInstanceState.putInt("seconds", seconds);
savedInstanceState.putBoolean("running", running);
savedInstanceState.putBoolean("wasRunning", wasRunning);
}
public void onClick(View v) {
switch (v.getId()) {
case R.id.start_button:
onClickStart(v);
break;
case R.id.stop_button:
onClickStop(v);
break;
case R.id.reset_button:
onClickReset(v);
break;
}
}
public void onClickStart(View view) {
running = true;
}
public void onClickStop(View view) {
running = false;
}
public void onClickReset(View view) {
running = false;
seconds = 0;
}
private void runTimer(View view) {
//把代码放在Handler中意味着它可以在后台现场中运行
final TextView timeView = (TextView) view.findViewById(R.id.time_view);
final Handler handler = new Handler();
handler.post(new Runnable() {
@Override
public void run() {
int hours = seconds / 3600;
int minutes = (seconds % 3600) / 60;
int secs = seconds % 60;
String time = String.format("%d:%02d:%02d",
hours, minutes, secs);
timeView.setText(time);
if (running) {
seconds++;
}
handler.postDelayed(this, 1000);
}
});
}
}
然后是布局的代码:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/time_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"
android:layout_marginTop="0dp"
android:text=""
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="92sp" />
<Button
android:id="@+id/start_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/time_view"
android:layout_centerHorizontal="true"
android:layout_marginTop="20dp"
android:text="@string/start" />
<Button
android:id="@+id/stop_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/start_button"
android:layout_centerHorizontal="true"
android:layout_marginTop="10dp"
android:text="@string/stop" />
<Button
android:id="@+id/reset_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/stop_button"
android:layout_centerHorizontal="true"
android:layout_marginTop="10dp"
android:text="@string/reset" />
</RelativeLayout>
1.4 为WorkoutDetailFragment增加秒表片段
要在WorkoutDetailFragment中增加StopwatchFragment.平板电脑上MainActivity的用户界面如下所示:
如果在一个片段中嵌套另一个片段,需要通过编程来增加嵌套片段。
通过编程增加这个片段。
你已经看到了,增加片段有两种方法:可以使用布局文件,也可以编写Java代码。
如果为另一个片段的布局增加片段,结果可能很怪异,所以我们使用Java代码将stopwatchFragment增加到workoutDetailFragment。
这说明,就像为活动增加workoutDetailFragment一样,我们会用同样的方式为workoutDetailFragment增加片段stopwatchFragment。这里只有一点不同,后面还会谈到。
1.5 增加帧布局-在片段所在的位置增加FrameLayout
通过Java代码来增加片段,需要在布局中片段出现的位置上增加一个帧布局。
我们想把StopwatchFragment放在WorkoutDetailFragment中训练项目名和描述的西面,因此在名和描述文本视图下面增加一个帧布局,用来包含StopwatchFragment:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceLarge"
android:text=""
android:id="@+id/textTitle" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:id="@+id/textDescription" />
<FrameLayout
android:id="@+id/stopwatch_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
为布局增加了帧布局,下面要在Java代码中为它增加片段。
创建WorkoutDetailFragment的视图时,希望在帧布局中增加StopwatchFragment.
要使用一个片段事务替换帧布局中显示的片段。下面来回顾第7章中使用的代码:
我们使用以上代码替换活动中显示的片段,不过这一次有个很大的区别。
这里不是替换活动中显示的片段,而是要替换片段中显示的片段。这说明对于如何创建片段事务要做个小小的修改。
在活动中显示一个片段时,我们使用了活动的片段管理器来创建片段事务,如下所示:
?getFragmentManager()方法得到与这个片段父活动关联的片段管理器。这说明,片段事务会关联到活动。
要想在一个片段中显示另一个片段,需要使用稍有不同的一个片段管理器,这个片段管理器应当与父片段关联。这说明,所有片段事务会关联到父片段,而不是活动。
要得到与父片段关联的片段管理器,可以使用getChildFragmentManager()方法。这说明启动事务的代码如下所示:
FragmentTransaction ft=getChildFragmentManager().beginTransaction();
这样就会得到片段的片段管理器的引用。
1.6 片段事务的嵌套-getChildFragmentManager()
1.6.1 使用getFragmentManager()的后果
下面先来看如果workoutDetailFragment使用getFragmentManager()创建片段事务来显示StopwatchFragment会发生什么。
用户单击一个训练项目时,我们希望应用能显示这个训练项目的详细信息以及秒表。MainActivity会创建一个事务显示workoutDetailFragment。如果使用getFragmentManager()同时显示StopwatchFragment,后退堆栈上就会有两个事务。
会将两个片段分别以事务的形式,加入到后退堆栈。
1.6.2 当心后退按钮--会产生问题:
使用两个事务显示训练项目存在一个问题:用户按下后退按钮时,会有奇怪的事情发生。
用户单击一个训练项目时,然后单击后退按钮,他们希望屏幕回到之前看到的那个屏幕。不过,后退按钮只是撤销了后退堆栈上的最后一个事务。这意味着,如果我们创建两个事务来显示训练项目的详细信息和秒表,用户单击后退按钮时,只会删除秒表。用户必须再次点击后退按钮才能删除训练项目的详细信息部分。
使用getFragmentMangager()创建片段事务显示StopwatchFragment
1.6.3??嵌套片段需要嵌套事务--getChildFragmentManager()
由于对嵌套片段使用多个事务会带来这种问题,所以需要创建子片段管理器。
子片段管理器创建的事务会放在主事务中,所以使用getChildFragmentManager().beginTransaction()创建的事务为workoutDetail-Fragment增加StopwatchFragment时,会如下嵌套事务:
后退堆栈有一个事务,这个事务中包含了两个事务。用户按下后退按钮时,会撤销显示详细信息片段的事务,这说明显示秒表片段的事务也会同时被撤销。现在用户按下后退按钮时,应用的表现就正常了:
?
?1.6.4 替换片段
在父片段的onCreateView()方法中显示片段。
我们希望创建workoutDetailFragment的视图时为帧布局增加StopwatchFragment。创建workoutDetail-Fragment的视图时,会调用它的onCreateview()方法,所以这里要为onCreateview()方法增加一个片段事务来显示StopwatchFragment。
代码如下:
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
if (savedInstanceState!=null){
workoutId=savedInstanceState.getLong("workoutId");//设置workoutId的值
}
//启动事务
FragmentTransaction ft = getChildFragmentManager().beginTransaction();
StopwatchFragment stopwatchFragment = new StopwatchFragment();
//替换帧布局中的片段
ft.replace(R.id.stopwatch_container,stopwatchFragment);
//向 后退栈 增加事务
ft.addToBackStack(null);
//设置过渡动画方式
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
//提交事务
ft.commit();
return inflater.inflate(R.layout.fragment_workout_detail,container,false);
}
这个代码看上去与在活动中显示片段所有的代码几乎完全相同。
主要区别在于,我们要在一个片段中显示另一个片段,因此要使用getChildFragmentManager().
问:如果我在一个片段中增加了另一个片段,子片段管理器可以很好地处理这种情况。不过,如果我把一个片段放在另一个片段中,再把它们放在下一个片段中,然后再放在下一个片段中……会怎么样呢?
答:事务会相互嵌套,最后在活动级只留下一个事务。因此,只需要单击一次后退按钮,这组嵌套的子事务都会撤销。
完整的WorkoutDetailFragment代码:
package com.hfad.workout;
import android.app.FragmentTransaction;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
public class WorkoutDetailFragment extends Fragment {
private long workoutId;//这是用户选择的训练项目的ID。
//接下来,要利用这个ID用训练项目详细信息设置片段视图的值
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
if (savedInstanceState!=null){
workoutId=savedInstanceState.getLong("workoutId");//设置workoutId的值
}
//使用一个片段事务向帧布局增加秒表片段
//启动事务
FragmentTransaction ft = getChildFragmentManager().beginTransaction();
StopwatchFragment stopwatchFragment = new StopwatchFragment();
//替换帧布局中的片段
ft.replace(R.id.stopwatch_container,stopwatchFragment);
//向 后退栈 增加事务
ft.addToBackStack(null);
//设置过渡动画方式
ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE);
//提交事务
ft.commit();
return inflater.inflate(R.layout.fragment_workout_detail,container,false);
}
@Override
public void onStart() {
super.onStart();
View view = getView();//getView()方法得到判断的根视图。然后使用这个根视图得到两个文本视图(训练项目名和描述)的引用
if (view!=null){
TextView title = (TextView) view.findViewById(R.id.textTitle);
Workout workout = Workout.workouts[(int) workoutId];
title.setText(workout.getName());
TextView description = (TextView) view.findViewById(R.id.textDescription);
description.setText(workout.getDescription());
}
}
//在片段撤销之前将workoutId的值保存到outState Bundle中,将在onCreateView()中获取这个值
@Override
public void onSaveInstanceState(Bundle outState) {
outState.putLong("workoutId",workoutId);
}
//训练项目ID的设置方法。活动将使用这个方法设置训练项目ID的值
public void setWorkoutId(long id){
this.workoutId=id;
}
}
1.7 片段中的按钮点击事件修正
1.7.1 在片段中单击按钮,应用崩溃的原因
如果在布局XML文件中,对Button视图以android:onClick属性来指定点击各个按钮时要调用哪个方法,则在应用在运行的时候会发生崩溃。
?原因:onClick属性调用活动中的方法,而不是片段中的方法。
因为,使用android:onClick属性指定点击视图时要调用哪个方法,这里指定的是调用 当前活动 中的那个方法。如果视图处于一个活动的布局中,就没有问题,但是如果视图在一个片段中,就会带来问题。Android不会调用片段中的方法,而只会调用父活动中的方法。如果它无法在这个活动中找到相应的方法,应用就会崩溃。
1.7.2 单击按钮时调用片段中的方法
要让片段中的按钮调用片段中的方法而不是活动中的方法,需要做到两件事:
1.从片段布局中删除android:onCliek的引用。
使用android:onclick属性时,按钮就会试图调用活动中的方法,所以要从片段布局中将它们删除。
2.实现一个onCIickListener将按钮与片段中的方法绑定。
这样才能确保单击按钮时调用正确的方法。
1.7.3 将onClickListener关联到按钮
为片段类StopwatchFragment实现View.onClickListener接口:
这样会把片段类StopwatchFragment转换为View.OnClickListener类型,这样一来,单击视图时它就能做出响应。
通过实现View.onClickListener onClick()方法,可以告诉片段如何响应点击。只要单击了片段中的一个视图,就会调用这个方法:
onClick()方法有一个View参数,这是用户单击的视图。可以使用View getId()方法得到用户单击哪个视图,然后确定如何做出反应。
将onClickListener关联到按钮:
要让视图响应点击事件,需要调用各个视图的setonclickListener()方法。setonClickListener()方法需要一个onclickListener对象作为参数。由于stopwatchFragment实现了onclickListener接口,我们可以传递这个片段作为onclickListener。
?要在创建片段的视图之后调用各个视图的setonclickListener()方法。这说明要把这些代码放在StopwatchFragmentonCreateview()方法中,如下所示:
完整的代码在之前已经给出。
1.8 旋转设备会重新创建活动
运行应用然后旋转设备时,会撤销并重新创建正在运行的活动。活动代码中的所有变量都会重置回它们的默认值;如果希望在活动撤销之前保存这些值,需要使用活动的onSaveInstancestate()方法。
如果活动包含一个片段,活动和片段生命周期是紧密相关的,旋转设备时,片段会发生什么?
1.活动包含一个片段
2.用户旋转设备,片段会随活动一同被撤销
3.重新创建活动,并调用它的onCreate()方法
onCreate()方法包含一个setContentView()调用。
4.运行活动的setContentView()方法时,它会读取活动的布局,重放它的片段事务。
片段随着最后一个事务重新创建。
旋转设备时,片段应当回到设备旋转之前的状态。那么这里为什么会重置秒表?为了找出线索,下面来看WorkoutDetailFragment onCreateView()方法。
重放事务之后运行onCreateView()
活动重放所有片段事务之后运行onCreateView()方法。
onCreateView()方法包含一个片段事务,会把秒表片段替换成为一个全新的片段。这说明会发生两件事:
- ?活动重放它的片段事务,使秒表片段恢复到设备旋转之前的状态。
- onCreateview()方法删除活动恢复的秒表片段,把它替换为一个全新的片段。由于这是一个全新版本的片段,秒表重置为0。
为了防止这种情况发生,我们要确保只有当savedInstances-tate Bundle为null才替换片段。这意味着只是在第一次创建活动时才会显示一个全新的StopwatchFragment。
因为本来秒表片段也是带保存局部变量的功能,因此在运行之后,旋转设备,秒表时间也不会被重置。
问∶如果在片段布局代码中使用android:onclick属性,Android真的会调用活动中的方法吗?
答:没错,确实是这样。所以不要使用android:onClick属性让视图响应单击事件,应当实现一个onclickListener。 ?
?问:这适用嵌套片段,还是一般意义上的片段?
答:所有片段都是这样,不论它们是否嵌套在另一个片段中。
问:我要在我自己的应用中使用片段吗?
答:这取决于你的应用,另外要看你想达到什么目的。 使用片段的一个主要好处是可以用片段支持多种不同的屏幕大小。比如,可以选择在平板电脑上并排显示片段,而在较小的设备上用单独的屏幕显示片段。
2.总结
- 片段可以包含其他片段。
- 如果在一个片段中嵌套另一个片段,需要通过编程在Java代码中增加这个嵌套片段。
- 在一个嵌套片段上执行事务时,要使用getChildFragmentManager()创建事务。
- 如果在片段中使用android:onclick属性,Android会在这个片段的父活动中查找同名的方法。
- 不要在片段中使用android:onclick属性,而应当让片段实现view.onclickListener接口,并实现它的onclick()方法。
- 设备配置改变时,活动和它的片段会被撤销。活动重新创建时,它会在onCreate()方法的setContentview()调用中重放它的片段事务。
- 活动重放片段事务之后会运行片段的oncreateview ()方法。
?
?
|