Fragment

Fragment

当今是移动调动非常词的时代,不仅手机已经尤为了生活必需品,而且平板也变得越来越普及,平板和手机最大的区别就在于屏幕的大小,一般手机屏幕的大小在3英寸到7英寸之间,平板屏幕的大小在7英寸到10英寸之间。屏幕大小差距过大有可以会让同样的界面在视觉效果上有较大的差异,比如一些界面在手机上看起来非常美观,但在平板上看起来可以会有控件被过分拉长,元素之间的空隙过大等情况。

对于一名专业的Android开发人员而言,能够兼顾手机和平板的开发是我们尽可能要做到的事情。Android9.0版本开引入了Fragment的概念,它可以让界面在平板上更好地展示,下面我们就来学习一下。

5.1 Fragment是什么?

Fragment是一种可以嵌入在Activity当中的UI片段,它能让程序更加合理和充分地利用大屏幕的空间,因而在平板上应用得非常广泛。虽然Fragment对你来说是一个全新的概念,但我相信你学习起来应该毫不费力,因为它和Activity实在是太像了,同样都能包含布局,同样都有自己的生命周期。你甚至可以将Fragment理解成一个迷你型的Activity,虽然这个迷你型 的Activity有可能和普通的Activity是一样大的。

那么空间要如何使用Fragment才能充分地利用平板屏幕的空间呢?想象我们正在开发一个新闻应用,其中一个界面使用RecyclerView展示了一组新闻的标题,当点击其中一个标题时,就打开另一个界面显示新闻的详细内容。如果是在手机中设计,我们可以将新闻标题列表放在一个Activity中,将新闻的详细内容放在另一个Activity中。如下图所示:

image-20210703103536907

可是如果在平板上这么设计,那么新闻标题列表将会被拉长至填充满整个平板的屏幕,而新闻的标题一般不会矿长,这样将会导致界面上有大量的空白区域,如下图所示:

image-20210703104032442

因此,更好的设计方案是将新闻标题列表界面和新闻详细内容界面分别放在两个Fragment中,然后在同一个Activity里引入这两个Fragment,这样就可以将屏幕空间充分了利用起来了,如下图所示:

image-20210703104421736

5.2 Fragment的使用方式

介绍了这么多抽象的东西,是时候学习一下Fragment的具体用法了,首先我们要创建一个平板模拟器,这次我们创建一个Pixel C平板模拟器,并启动,具体步骤这里就不说了。

好了,准备工作都完成了,接亲一个FragmentTest项目,然后开始我们的Fragment探索之旅吧。

5.2.1 Fragment的简单用法

这里我们准备先写一个最简单的Fragment示例练练手。在一个Activity当中添加两个Fragment,并让这两个Fragment平分Activity的空间。

新建一个左侧的Fragment的布局left_fragment.xml代码如下所示:

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

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

这个布局非常简单,只放置了一个按钮,并让它水平居中显示。

然后新建右侧Fragment的布局,right_fragment.xml代码如下所示:

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

   <TextView
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_gravity="center_horizontal"
       android:textSize="25sp"
       android:text="This is right fragment"/>
</LinearLayout>

显而易见,我们将这个布局的背景设置成了绿色,并放置了一个TextView用于显示一段文本。

接着新建一个LeftFragment类,并让它继承自Fragment。注意,这里可能会有两个不同的包下的Fragment供你选择,一个是系统内置的android.app.Fragment,一个是AndroidX库中的androidx.fragment.app.Fragment。这里请一定要使用AndroidX库中的Fragment,因为它可以让Fragment的特性在所有AndroidX系统版本中操持一致,而系统内置的Fragment在Android9.0版本中已经被废弃,使用AndroidX库中的Fragment并不需要在build.gradle文件中添加额外的依赖,只要你在创建新项目时勾选了 Use androidx.* artifacts选项,Android Studio会自动导入必要的AndroidX库。

现在编写一下LeftFragment中的代码,如下所示:

public class LeftFragment extends Fragment {
   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @org.jetbrains.annotations.NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.left_fragment, container, false);
  }
}

这里仅仅是重写了Fragment的onCreateView()方法,然后在这个方法中通过LayoutInflater的inflate()方法将刚才定义的left_fragment.xml布局动态加载进来,整个方法简单明了,接着我们用同样的方法再新建一个RightFragment,代码如下所示:

public class RightFragment extends Fragment {
   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.right_fragment, container, false);
  }
}

代码基本上是相同的,相信已经没有必要再做什么解释了。接来来修改activity_main.xml中的代码,如下所示:

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

   <fragment
       android:id="@+id/leftFrag"
       android:name="com.ziyia.fragmenttest.LeftFragment"
       android:layout_weight="1"
       android:layout_width="0dp"
       android:layout_height="match_parent"/>
   
   <fragment
       android:id="@+id/RightFrag"
       android:name="com.ziyia.fragmenttest.RightFragment"
       android:layout_weight="1"
       android:layout_width="0dp"
       android:layout_height="match_parent"/>

</LinearLayout>

显而易见,我们使用了<fragment>标签在布局中添加 Fragment,其中指定的大多数属性你已经非常熟悉了,只不过这里还需要通过android:name属性来显式声明要添加的Fragment类名,注意一定要将类的包名也加上。

这样最简单的Fragment示例就已经写好了,现在运行一下程序,效果如下图所示:

image-20210703112204580

正如我们预期的一样,两个 Fragment平分了整个Activity布局。不过这个例子实在是太简单了,在真正的项目中很难有什么实际的作用,因此下面我们马上来看一看,关于Fragment更加高级的使用技巧。

5.2.2 动态添加 Fragment

经过上面的学习,你已经学会了在布局文件中添加 Fragmnet方法,不过 Fragment真正的强大之处在于,它可以在程序运行时动态地添加到Activity中。根据具体的情况下动态地添加Fragment,你就可以将程序界面定制得更加多样化。

我们在上方的例子中继续完善,新建another_right_fragment.xml,代码如下所示:

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

   <TextView
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_gravity="center_horizontal"
       android:textSize="25sp"
       android:text="This is another right fragment"/>
</LinearLayout>

这个布局文件的代码和right_fragment.xml中的代码基本相同,只是将背景色改成了黄色,并将显示的文字改了改。然后新建AnotherRightFragment作为另一个右侧Fragment,代码如下所示:

ackage com.ziyia.fragmenttest;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

import org.jetbrains.annotations.NotNull;

public class AnotherRightFragment extends Fragment {
   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.another_right_fragment, container, false);
  }
}

代码同样非常简单,在onCreateView()方法中加载了刚刚创建的another_right_fragment布局。这样我们就准备好了另一个Fragment。接下来看一下如何将它动态地添加到Activity当中。

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

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

   <fragment
       android:id="@+id/leftFrag"
       android:name="com.ziyia.fragmenttest.LeftFragment"
       android:layout_weight="1"
       android:layout_width="0dp"
       android:layout_height="match_parent"/>

   <FrameLayout
       android:id="@+id/rightLayout"
       android:layout_width="0dp"
       android:layout_height="match_parent"
       android:layout_weight="1"/>
   
</LinearLayout>

显而易见,现在将右侧Fragment替换成了一个FrameLayout中,还记得这个布局吗?这是Android中最简单的一种布局,所有的控件默认都会摆放在布局的左上角。由于这里仅需要在布局里放入一个Fragment,不需要任何定位,因此非常适合使用FrameLayout。

下面我们将代码中向向FrameLayout里添加内容,从而实现动态添加Fragment的功能,修改MainActivity中的代码,如下所示:

public class MainActivity extends AppCompatActivity {


   @BindView(R.id.button)
   Button button;
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);
       ButterKnife.bind(this);


       button.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               replaceFragment(new AnotherRightFragment());
          }
      });
       replaceFragment(new RightFragment());

  }

   private void replaceFragment(Fragment fragment) {
       FragmentManager fragmentManager = getSupportFragmentManager();
       FragmentTransaction transaction = fragmentManager.beginTransaction();
       transaction.replace(R.id.rightLayout, fragment);
       transaction.commit();
  }
}

显而易见,首先我们给左侧Fragment中的按钮注册了一个点击事件,然后调用replaceFragment()方法动态添加了RightFragment。当点击左侧Fragment中的按钮时,又会调用replaceFragment()方法,将右侧的Fragment替换成AnoherRightFragment。结合replaceFragment()方法中的代码可以看出,动态添加Fragment主要分为3步。

  1. 创建待添加的Fragment实例
  2. 获取 FragmentManager,在Activity中可以直接调用 getSupportFragmentManager()方法获取。
  3. 开启一个事务,通过调用beginTransaction()方法开启
  4. 向容器内添加或替换Fragment,一般使用replace()方法实现,需要传入容器的id和待添加的Fragment的实例。
  5. 提交事务,调用 commit()方法来完成。

这样就完成在Activity中动态添加Fragment的功能,重新运行程序,可以看到和之前相同的界面,然后点击一下左侧按钮,效果如下图所示:

image-20210703115015903

5.2.3 在Fragment中实现返回栈

在上一小节中,我们成功实现了向Activity中动态添加Fragment的功能,不过你尝试一下就会发现,通过点击按钮添加了一个Fragment之后,这时按下Back键程序就会直接退出。如果我们想实现类似于返回栈的效果,按下Back键可以回到上一个Fragment,该如何实现呢?

其实很简单,FragmentTransaction中提供了一个addToBackStack()方法,可以用于将一个事务添加到返回栈中。修改MainActivity中的代码,如下所示:

public class MainActivity extends AppCompatActivity {
  ........
   private void replaceFragment(Fragment fragment) {
       FragmentManager fragmentManager = getSupportFragmentManager();
       FragmentTransaction transaction = fragmentManager.beginTransaction();
       transaction.replace(R.id.rightLayout, fragment);
       transaction.addToBackStack(null);
       transaction.commit();
  }
}

这里我们在事务提交之前调用了FragmentTransaction的addToBackStack()方法, 它可以接收一个名字用于描述返回栈的状态,一般传入null即可。现在重新运行程序,并点击按钮将AnoherRightFragment添加到Activity中,然后按下Back键,你会发现程序并没有退出,而是回到了RightFragment界面,继续按下Back键,RightFragment界面也会消失,再次按下Back程序才会退出。

5.2.4 Fragment和Activity之间的交互

虽然Fragment是嵌入在Activity中显示的,可是它们的关系并没有那么亲密。实际上,Fragment和Activity是各自在于一个独立的类当中的,它们之间并没有那么明显的方式来直接进行交互。如果想要在Activity中调用Fragment里的方法,或者在Fragment中调用 Activity里的方法,应该如何实现呢?

为了方便Fragmnet和Activity之间进行交互,FragmentManager提供了一个类似于findViewById()的方法,专门用于从布局文件中获取Fragment的实例,代码如下所示:

FragmentManager supportFragmentManager = getSupportFragmentManager();
supportFragmentManager.findFragmentById(R.id.leftFrag);

调用FragmentManager的findFragmentById()方法,可以在Activity中得到相应的Fragment的实例,然后就可以轻松地调用Fragment里的方法了。

掌握了如何在Activity中调用Fragment里的方法,那么在Fragment中又该怎样调用Activity里的方法呢?这就更简单了,在每个Fragment中都可以通过调用getActivity()方法来得到 和当前Fragment相关联的Activity实例,代码如下所示:

public class LeftFragment extends Fragment {
   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @org.jetbrains.annotations.NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.left_fragment, container, false);
  }

   @Override
   public void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       FragmentActivity activity = getActivity();
       if (activity != null) {
           MainActivity mainActivity = (MainActivity) activity;
      }
  }
}

这里由于getActivity()方法有可能返回null,因此我们需要先进行一个判空处理。有了Activity的实例,在Fragment中调用Activity里的方法就变得轻而易举了。另外当Fragment中需要使用Context对象时,也可以使用getActivity()方法,因为获取到的Activity本身就是一个Context。

这时不知道你心中会不会产生一个疑问,既然Fragment和Activity之间的通信问题已经解决了,那么不同的Fragment之间可不可以进行通信呢?

说实在的,这个问题并没有看上去那么复杂,它的基本思路非常简单,首先在一个Fragment中可以得到与它相关联的Activity,然后再通过这个Activity去获取一个Fragment的实例,这样就实现了不同Fragment之间的通信功能。

5.3 Fragment的生命周期

和Activity一样,Fragment也有自己的生命周期,并且它和Activity的生命周期实在是太像了,我相信你很快就可以学会。

5.3.1 Fragment的状态和回调

还记得每个Activity在其生命周期内可能会有哪几种吗?没错,一共有运行状态、暂停状态、停止状态和销毁状态这4种,类似地,每个Fragment在其生命周期内也可能会经历这几种状态,只不过在一些细小的地方会有部分区别。

  1. 运行状态当一个Fragment所关联的Activity正处于运行状态时,该Fragment也处于运行状态。
  2. 暂停状态当一个Activity进入暂停状态时(由于另一个未占满屏幕的Activity被添加到了栈顶)与它关联的Fragment就会进入暂停状态。
  3. 停止状态当一个Activity进入停止状态时,与它相关联的Fragment就会进入停止状态,或者通过调用FragmentTransaction的remove()、replace()方法将Fragment从Activity中移除,但在事务提交之前调用了addToBackStack()方法,这时的Fragment也会进入停止状态,总的来说,进入停止状态的Fragment对用户来说是完全不可见的,有可能会被系统回收。
  4. 销毁状态Fragment总是依附于Activity的存在,因此当Activity被销毁时,与它相关联的Fragment就会进入销毁状态。或者通过调用FragmentTransaction的remove()、replace()方法将fragment从Activity中移除,但在事务提交之前并没有调用addToBackStack()方法,这里的Fragment也会进入销毁状态。

结合之前的Activity状态,相信你理解起来应该毫不费力吧。同样地,Fragment类中也提供了一系列的回调方法,以覆盖它生命周期 的每个环节。其中,Activity中有的回调方法,Fragment中基本上也有,不过Fragment还提供了一些附加的回调方法,下面我们就重点来看一下这几个回调。

  • onAttach()当Fragment和Activity建立关联时调用
  • onCreateView()为Fragment创建视图(加载布局)时调用
  • onActivityCreated()确保与Fragment相关联的Activity已经创建完毕时调用。
  • onDestroyView()当与Fragment关联的视图被移除时调用
  • onDetach()当Fragment和Activity解除关联时调用

Fragment完整的生命周期可参考如下图片:

img

5.3.2 体验Fragment的生命周期

为了让你能够更加直观地体验Fragment的生命周期,我们还是通过一个盒子来实践一下。

盒子很简单,仍然是在Fragmenttest项目的基础上改动

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

package com.ziyia.fragmenttest;

import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;

import org.jetbrains.annotations.NotNull;

public class RightFragment extends Fragment {

   private static final String TAG = RightFragment.class.getSimpleName();


   @Override
   public void onAttach(@NonNull @NotNull Context context) {
       super.onAttach(context);
       Log.d(TAG, "onAttach: ");
  }

   @Override
   public void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       Log.d(TAG, "onCreate: ");
  }
   
   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       Log.d(TAG, "onCreateView: ");
       return inflater.inflate(R.layout.right_fragment, container, false);
  }

   @Override
   public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onActivityCreated(savedInstanceState);
       Log.d(TAG, "onActivityCreated: ");
  }

   @Override
   public void onStart() {
       super.onStart();
       Log.d(TAG, "onStart: ");
  }

   @Override
   public void onResume() {
       super.onResume();
       Log.d(TAG, "onResume: ");
  }

   @Override
   public void onPause() {
       super.onPause();
       Log.d(TAG, "onPause: ");
  }

   @Override
   public void onStop() {
       super.onStop();
       Log.d(TAG, "onStop: ");
  }

   @Override
   public void onDestroyView() {
       super.onDestroyView();
       Log.d(TAG, "onDestroyView: ");
  }

   @Override
   public void onDestroy() {
       super.onDestroy();
       Log.d(TAG, "onDestroy: ");
  }

   @Override
   public void onDetach() {
       super.onDetach();
       Log.d(TAG, "onDetach: ");
  }
}

注意,这里为了方便日志打印,我们先定义了一个TAG变量。

接下来,我们在RightFragment中的每个回调方法里都加入了打印日志的代码,然后重新运行程序。这时观察日志中的打印信息,如下图所示:

image-20210703161526107

显而易见,当RightFragment第一次被加载到屏幕上时,会依次执onAttach()、onCreate()、onCreateView()、onActivityCreated()、onStart()、onResume()方法,然后点击LeftFragment中的按钮,此时日志打印信息如下图所示:

image-20210703162016004

由于AnotherRightFragment替换了RightFragment,此时的RightFragment进入了停止状态,因此onPause()、onStop()、onDestroyView()方法会得到执行。当然,如果在替换的时候没有addToBackStack()方法,此时的RightFragment就会进入销毁状态,onDestroy()方法和onDetach()方法就会得到执行。

接下按下Back键,RightFragment会重新回到屏幕,打印信息如下图所示:

image-20210703162446803

由于RightFragmen重新回到了运行状态,因此onCreateView()、onActivityCreated()、onStart()和onResume()方法会得到执行。注意,此时的onCreate()方法并不会执行,因此我们借助了addToBackStack()方法使得RgithFragment并没有销毁。

现在再次按下Back键,打印信息如下图所示:

image-20210703162725319

依次执行onPause()、onStop()、onDestroyView()、onDestroy()和onDetach()方法,最终将Fragment销毁。现在,你体验了一遍Fragment完整的生命周期。

另外值得一提的是,在Fragment中你可以通过onSaveInstanceState()方法来保存数据,因此进入停止状态的Fragment有可能在系统内在不足的时候被回收,保存下来的数据在onCreate()、onCreateView()和onActivityCreated()这3个方法中你都可以重新得到,它们都含有一个Bundle类型的saveInstanceState参数,具体的代码不不在这里展示了。

5.4 动态加载布局的技巧

虽然动态添加Fragment的功能很强大,可以解决很多实际开发中的问题,但是它毕竟只是一个布局文件中进行一些添加和替换操作。如果程序能够根据设备的分辨率或屏幕大小,在运行时决定加载哪个布局,那我们可发挥的空间就更多了。

5.4.1 使用限定符

如果你经常使用平板,应该会发现很多平板应用采用的是双页模式(程序会在左侧的面板上显示一个包含子项的列表 ,在右侧的面板上显示内容),因为平板的屏幕足够大,完全可以同时网页的内容,但手机的屏幕就只能显示一页的内容,因此两个页面需要分开显示。

那么怎样才能在运行时判断程序应该是使用双页模式还是单页模式呢?这就需要借助限定符(qualifier)来实现了。下面我们通过一例子来学习一下它的用法,修改FragmentTest项目中的activity_main.xml文件,代码如下所示:

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

   <fragment
       android:id="@+id/leftFrag"
       android:name="com.ziyia.fragmenttest.LeftFragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent"/>
   
</LinearLayout>

这里将多余的代码删掉,只留下一个左侧Fragment,并让它充满整个父布局。接着在res目录下新建layout_large文件夹,在这个文件夹下新建一个布局,也叫作activity_main.xml,代码如下所示:

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

   <fragment
       android:id="@+id/leftFrag"
       android:name="com.ziyia.fragmenttest.LeftFragment"
       android:layout_weight="1"
       android:layout_width="0dp"
       android:layout_height="match_parent"/>
   
   <fragment
       android:id="@+id/rightFrag"
       android:name="com.ziyia.fragmenttest.RightFragment"
       android:layout_weight="3"
       android:layout_width="0dp"
       android:layout_height="match_parent"/>

</LinearLayout>

显而易见,layout/activity_main布局只包含了一个Fragment,即单页模式,而layout-large/activity_main布局包含了两个 Fragment,即双页模式。其中large就是一个限定符,那些屏幕被认为是large的设备就会自动加载layout-large文件夹下的布局,小屏幕的设备则还是会加载layout文件夹的布局。

然后将MainActivity中replaceFragment()方法里的代码注释掉,并在平板模拟器上重新运行程序,效果如图所示:

5.4.2 使用最小宽度限定符image-20210703201107026

再启动一个手机模拟器,并重新运行程序,效果如图所示:

image-20210703201155713

这样我们就实现了在程序运行时动态加载布局的功能:

Android中一些觉的限定符可以如表所示:

屏幕特征限定符描述
大小small提供给小屏幕设备的资源
normal提供给中等屏幕设备的资源
large提供给大屏幕设备的资源
xlarge提供给超大屏设备的资源
分辨率ldpi提供给低分辨率设备的资源(120dpi以下)
mdpi提供给中等分辨率设备的资源(120 – 160dpi)
hdpi提供给高分辨率设备的资源(160 – 240dpi)
xhdpi提供给超高分辨率设备的资源(240 – 320dpi)
xxhdpi提供给超超高分辨率设备的资源(320 – 480dpi)
方向land提供给横屏设备的资源
port提供给坚屏设备的资源

5.4.2 使用最小宽度限定符

在上一小节中我们使用了large限定符成功解决了单页的判断问题,不过很快又有一个新的问题出现了,large到底是指多大呢?有时候我们希望可以更加灵活地为不同设备加载布局,不管它们是不是被系统认定为large,这时候就可以使用最小宽度限定符(smallest-width-quelifier)

最小宽度限定符允许我们对屏幕的宽度指定一个最小值(以dp为单位),然后以这个最小宽度为临界点,屏幕宽度大于这个值 的设备就加载一个布局,屏幕宽度小于这个值的高度就加载另一个布局。

在res目录下新建layout-sw600dp文件夹,然后在这个文件夹下新建activity_main.xml布局,代码如下所示:

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

   <fragment
       android:id="@+id/leftFrag"
       android:name="com.ziyia.fragmenttest.LeftFragment"
       android:layout_width="0dp"
       android:layout_weight="1"
       android:layout_height="match_parent"/>

   <fragment
       android:layout_marginLeft="10dp"
       android:id="@+id/rightFrag"
       android:name="com.ziyia.fragmenttest.RightFragment"
       android:layout_width="0dp"
       android:layout_weight="1"
       android:layout_height="match_parent"/>
</LinearLayout>

这就意味着,当程序运行在屏幕宽度大于等于600dp的设备上时,会加载layout-sw600dp/activity_main.xml布局,当程序运行在屏幕宽度小于600dp的设备上时,则仍然加载默认的layout/activity_main布局。

5.5 实践:一个简易版的新闻应用

现在你已经将关于Fragment的重要知识点掌握了得差不多了,不过在灵活运用方面可能还有欠缺。

前面提到过,Fragment很多时候是平板开发当中使用的,因此它可以解决屏幕空间不能充分利用的问题。那是还是表明,我们开发的程序都需要提供一个手机版和平板版呢?确实有不少公司是这么做的,但是这样会耗费很多的人力财力,因此维护两个版本的代码成本很高,每当增加新功能时,需要在两代码里各写一遍;每当发现bug时,需要在两代码里各修改一次。因此,今天我们实践的内容就是教你如何编写兼容手机和平板的应用程序。

由于在编写的时候会用到 ButterKnife,因此首先需要在app/build.gradle当中添加依赖库,如下所示:

implementation 'com.jakewharton:butterknife:10.1.0'
annotationProcessor 'com.jakewharton:butterknife-compiler:10.1.0'

接下来我们要准备好一个实体类,新建类News,代码如下所示:

public class News {
   private String title;
   private String content;


   public News() {
  }

   public News(String title, String content) {
       this.title = title;
       this.content = content;
  }

   public String getTitle() {
       return title;
  }

   public void setTitle(String title) {
       this.title = title;
  }

   public String getContent() {
       return content;
  }

   public void setContent(String content) {
       this.content = content;
  }

   @Override
   public String toString() {
       return "News{" +
               "title='" + title + '\'' +
               ", content='" + content + '\'' +
               '}';
  }
}

News类的代码非常简单,title字段表示新闻标题,content字段表示新闻内容。接着新建布局文件news_content_frag.xml。作为新闻的内容的布局。

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

   <LinearLayout
       android:visibility="invisible"
       android:id="@+id/contentLayout"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:orientation="vertical">

       <TextView
           android:id="@+id/newsTitle"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:gravity="center"
           android:text="标题"
           android:textSize="20sp"/>

       <View
           android:layout_width="match_parent"
           android:layout_height="1dp"
           android:background="#000"/>

       <TextView
           android:text="newsContent"
           android:id="@+id/newsContent"
           android:layout_width="match_parent"
           android:layout_height="0dp"
           android:layout_weight="1"
           android:padding="15dp"
           android:textSize="18sp"/>
   </LinearLayout>

   <View
       android:layout_width="1dp"
       android:layout_height="match_parent"
       android:background="#000"
       android:layout_alignParentLeft="true"/>
</RelativeLayout>

新闻内容的布局主要可以分为两个部分,头部部分显示新闻标题,正文部分显示新闻内容,中间使用一条水平方向的细线分开。除此之外,这里还使用了一条垂直方向的细线,它的作用是在双页模式时将左侧的新闻列表和的新闻内容分隔开,细线是使用View来实现的,将View的宽度或高度设置为1dp,再通过backgroun属性给细线设置一下颜色就可以了。这里我们就把细线设置成黑色。

另外,我们还要将新闻内容的布局设置成不可见,因为在双页模式下,如果还没有选中新闻列表中的任何一条新闻,是不应该显示新闻内容布局的。

接下来新建一个NewsContentFragment类,继承自Fragment,代码如下所示:

public class NewsContentFragment extends Fragment {

   @BindView(R.id.contentLayout)
   LinearLayout linearLayout;

   @BindView(R.id.newsTitle)
   TextView newsTitle;

   @BindView(R.id.newsContent)
   TextView newsContent;


   @Override
   public void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
  }

   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @org.jetbrains.annotations.NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       View view = inflater.inflate(R.layout.news_content_frag, container, false);
       ButterKnife.bind(this, view);
       return view;
  }

   public void refresh(String title, String content) {
       linearLayout.setVisibility(View.VISIBLE);
       newsTitle.setText(title);
       newsContent.setText(content);
  }
}

这里首先在onCreateView()方法中加载了我们刚刚创建的news_content_fag布局,这个没什么好解释的。接下来又提供了一个refresh()方法,用于将新闻的标题和内容在我们刚刚定义的界面上。注意,当调用了refresh()方法时,需要将我们刚才隐藏的新闻内容布局设置成可见。

这样我们就把新闻内容的Fragment和布局都创建好了但是它们都是在双页模式中使用的,如果想在单页模式中使用的话,我们还需要再创建一个Activity,右击包名新建一个NewsContentActivity,布局名就使用默认的activity_news_content即可。然后修改activity_news_content.xml中的代码,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   android:orientation="vertical"
   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=".NewsContentActivity">

   <fragment
       android:id="@+id/newsContentFrag"
       android:name="com.ziyia.fragmentbasepracitce.NewsContentFragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent"/>

</LinearLayout>

这里我们充分发挥了代码的复用性,直接在引入了NewsContentFragment。这样相当于把news_content_grag布局的内容自动加了进来。

然后修改NewsContentActivity中的代码,如下所示:

public class NewsContentActivity extends AppCompatActivity {


   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_news_content);
       Intent intent = getIntent();
       String title = intent.getStringExtra("title");
       String content = intent.getStringExtra("content");
       if (title != null && content != null) {
           FragmentManager manager = getSupportFragmentManager();
           NewsContentFragment fragment = (NewsContentFragment) manager.findFragmentById(R.id.newsContentFrag);
           if (fragment != null) {
               fragment.refresh(title, content);
          }
      }
  }


   public static void actionStart(Context context, String title, String content) {
       Intent intent = new Intent(context, NewsContentActivity.class);
       intent.putExtra("title", title);
       intent.putExtra("content", content);
       context.startActivity(intent);
  }
}

显而易见,在onCreate()方法中我们通过Intent获取到了传入的新闻标题和新闻内容,然后我们获取到了NewsContentFragment的实例,接着调用它的refresh()方法,将新闻的标题和内容传入,就可以把这些数据显示出来了。注意,这里我们还提供了一个actionStart()方法。

接下来还需要再创建一个用于显示新闻列表的布局,新建news_title_frag.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">

   <androidx.recyclerview.widget.RecyclerView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:id="@+id/newsTitleRecyclerView"/>

</LinearLayout>

这个布局的代码就非常简单了,里面只有一个用于显示新闻列表的RecyclerView,既然要用到RecyclerView,那么就必定少不了子项的布局。新建news_item.xml作为RecyclerView子项的布局,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<TextView android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:maxLines="1"
   android:ellipsize="end"
   android:textSize="18sp"
   android:paddingLeft="10dp"
   android:paddingRight="10dp"
   android:paddingTop="15dp"
   android:paddingBottom="15dp"
   android:text="Title"
   android:id="@+id/newsTitle"
   xmlns:android="http://schemas.android.com/apk/res/android" />

子项的布局也非常简单,只有一个TextView。仔细观察 TextView 你会发现其中有几个属性是我们之前没有尝过的:android:padding表示给控件的周围加上内边距,这样不至于让文本内容紧靠在边缘上;android:maxLines设置为1表示让这个TextView只能单行显示,android:ellipsize用于设定当文本内容超出控件宽度时文本的缩略方式,这里指定成end表示在尾部进行缩略。

既然新闻列表和子项的布局都已经创建好了,那么接下来我们就需要一个用于展示新闻列表的地方。这里新建NewsTitleFragment作为展示新闻列表Fragment,代码如下所示:

public class NewsTitleFragment extends Fragment {
   private boolean isTwoPane = false;

   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.news_title_frag, container, false);
  }

   @Override
   public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onActivityCreated(savedInstanceState);
       FragmentActivity activity = getActivity();
       if (activity != null) {
           isTwoPane = activity.findViewById(R.id.newsContentLayout) != null;
      }
       List<News> newsList = new ArrayList<>();
       newsList.add(new News("年少有为,年少有为", "第一条数据"));
       newsList.add(new News("测试,测试,测试,测试", "第二条数据"));
       newsList.add(new News("嘿嘿,嘿嘿,哈哈哈", "第三条数据"));
       newsList.add(new News("测试就是测试,知道吗?", "第四条数据"));

       LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getActivity());
       RecyclerView recyclerView = activity.findViewById(R.id.newsTitleRecyclerView);
       recyclerView.setLayoutManager(linearLayoutManager);
       recyclerView.setAdapter(new NewsAdapter(newsList));

  }
}

显而易见,NewsTieltFragment中并没有多少代码,在onCreateView()方法中加载了news_title_frag布局,这个没什么好说的。我们注意看一下onActivityCreated()方法,这个方法通过在Activity中能否找到一个id为newsContentLayout的View,来判断当前是双页模式还是单页模式,因此我们需要让这个id为newsContentLayout的View只在双页模式中才会出现。注意,由于在Fragment中调用getActivity()方法有可能返回null,所以在上述代码中我们使用了if判空来保证代码的安全性。

那么怎样才能实现让id为newsContentLayout的View只在双页模式中才会出现呢?其实并不复杂,只需要借助我们刚刚尝过的限定符就可以了。首先修改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:id="@+id/newsTitleLayout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <fragment
       android:name="com.ziyia.fragmentbasepracitce.NewsTitleFragment"
       android:id="@+id/newsTitleFrag"
       android:layout_width="match_parent"
       android:layout_height="match_parent"/>

</FrameLayout>

上述代码表示在单页模式下只会加载一个新闻标题的Fragment。

然后新建layout-sw600dp文件夹,在这个文件夹下再创建一个activity_main.xml文件,代码如下所示:

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

   <fragment
       android:id="@+id/newsTitleFrag"
       android:name="com.ziyia.fragmentbasepracitce.NewsTitleFragment"
       android:layout_width="0dp"
       android:layout_weight="1"
       android:layout_height="match_parent"/>

   <FrameLayout
       android:id="@+id/newsContentLayout"
       android:layout_width="0dp"
       android:layout_height="match_parent"
       android:layout_weight="3">

       <fragment
           android:id="@+id/newsContentFrag"
           android:name="com.ziyia.fragmentbasepracitce.NewsContentFragment"
           android:layout_width="match_parent"
           android:layout_height="match_parent"/>
   </FrameLayout>


</LinearLayout>

显而易见,在双页模式下,我们同时引入了两个Fragment,并将新闻内容的Fragment放在了一个FrameLayout布局下,而这个布局的id正是newsContentLayout。因此,能够找到这个id的时候就是双页模式,否则就是单页模式。

现在我们已经将绝大部分的工作完成了,但还剩下至关重要的一点,就是在NewsTitleFragment中通过RecyclerView将新闻列表展示出来。我们在NewsTitleFragment中新建一个内部类NewsAdapter来作为RecyclerView的适配器,如下所示:

public class NewsTitleFragment extends Fragment {
   private boolean isTwoPane = false;

   @Nullable
   @org.jetbrains.annotations.Nullable
   @Override
   public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable @org.jetbrains.annotations.Nullable ViewGroup container, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       return inflater.inflate(R.layout.news_title_frag, container, false);
  }

   @Override
   public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onActivityCreated(savedInstanceState);
       FragmentActivity activity = getActivity();
       if (activity != null) {
           isTwoPane = activity.findViewById(R.id.newsContentLayout) != null;
      }
       List<News> newsList = new ArrayList<>();
       newsList.add(new News("年少有为,年少有为", "第一条数据"));
       newsList.add(new News("测试,测试,测试,测试", "第二条数据"));
       newsList.add(new News("嘿嘿,嘿嘿,哈哈哈", "第三条数据"));
       newsList.add(new News("测试就是测试,知道吗?", "第四条数据"));

       LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getActivity());
       RecyclerView recyclerView = activity.findViewById(R.id.newsTitleRecyclerView);
       recyclerView.setLayoutManager(linearLayoutManager);
       recyclerView.setAdapter(new NewsAdapter(newsList));

  }


   class NewsAdapter extends RecyclerView.Adapter<NewsAdapter.ViewHolder> {
       private List<News> list;

       public NewsAdapter(List<News> list) {
           this.list = list;
      }

       @NonNull
       @NotNull
       @Override
       public ViewHolder onCreateViewHolder(@NonNull @NotNull ViewGroup parent, int viewType) {
           View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.news_item, parent, false);
           ViewHolder viewHolder = new ViewHolder(view);
           viewHolder.itemView.setOnClickListener(new View.OnClickListener() {
               @Override
               public void onClick(View v) {
                   int position = viewHolder.getAdapterPosition();
                   News news = list.get(position);
                   if (isTwoPane) {
                       FragmentManager supportFragmentManager = getActivity().getSupportFragmentManager();
                       NewsContentFragment fragment = (NewsContentFragment) supportFragmentManager.findFragmentById(R.id.newsContentFrag);
                       fragment.refresh(news.getTitle(), news.getContent());
                  } else {
                       NewsContentActivity.actionStart(parent.getContext(), news.getTitle(), news.getContent());
                  }
              }
          });
           return viewHolder;
      }

       @Override
       public void onBindViewHolder(@NonNull @NotNull NewsTitleFragment.NewsAdapter.ViewHolder holder, int position) {
           News news = list.get(position);
           holder.title.setText(news.getTitle());
      }

       @Override
       public int getItemCount() {
           return list.size();
      }

       public class ViewHolder extends RecyclerView.ViewHolder {
           private TextView title;
           public ViewHolder(@NonNull @NotNull View itemView) {
               super(itemView);
               title = itemView.findViewById(R.id.newsTitle);
          }
      }
  }
}

RecyclerView的用法你已经相当熟悉了,因此这个适配器的代码对你来说应该没有什么难度吧?需要注意的是,之前我们都是将适配器写成一个独立的类,其实也可以写成内部类。这里写成内部类的好处就是可以直接访问NewsTitleFragment的变量,比如 isTwoPane。

观察一下onCreateViewHolder()方法中注册的点击事件,首先获取了点击项的News实例,然后通过isTwoPane变量判断当前是单页模式还是双页模式。如果是单页模式,就启动一个新的Activity去显示新闻内容;如果是又页模式,就更新NewsContentFragment里的数据。

现在还剩最后一步收尾工作,就是向RecyclerView中填充数据了。修改NewsTitleFragment中的代码,如下所示:

@Override
public void onActivityCreated(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
   super.onActivityCreated(savedInstanceState);
   FragmentActivity activity = getActivity();
   if (activity != null) {
       isTwoPane = activity.findViewById(R.id.newsContentLayout) != null;
  }
   List<News> newsList = new ArrayList<>();
   newsList.add(new News("年少有为,年少有为", "第一条数据"));
   newsList.add(new News("测试,测试,测试,测试", "第二条数据"));
   newsList.add(new News("嘿嘿,嘿嘿,哈哈哈", "第三条数据"));
   newsList.add(new News("测试就是测试,知道吗?", "第四条数据"));

   LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getActivity());
   RecyclerView recyclerView = activity.findViewById(R.id.newsTitleRecyclerView);
   recyclerView.setLayoutManager(linearLayoutManager);
   recyclerView.setAdapter(new NewsAdapter(newsList));

}

显而易见,onActivityCreated()方法中添加了RecyclerView标准的使用方法,在Fragment中使用RecyclerView和在Activity中使用几乎是一模一样的,相信没有什么需要解释的。

这样我们所有的编码工作就已经完成了,赶快来运行一下吧。首先在手机模拟器上运行,效果如下图片所示:

image-20210704114115319

可以看到许多新闻的标题,然后点击第一条新闻,会启动一个新的Activity来显示新闻的内容,如下图所示:

image-20210704114224374

接下来将程序在平板模拟器上运行,同样是点击第一条新闻,效果如下图所示:

image-20210704114321965

发表回复

相关

浙ICP备2021031744号-3