前言
Fragment 能让我们充分利用平板的屏幕空间。但如果一个应用要为手机、平板都开发一个版本,就有些得不偿失了,开发和维护的成本很高,也增加了代码管理的复杂度。
所以,现在我们来编写一个能够兼容手机和平板的新闻应用,用一份代码应对不同屏幕尺寸的设备。
准备工作
首先,新建一个名为 FragmentBestPractice
的 Empty Views Activity 项目。
然后,在 app/build.gradle.kts
配置文件中:添加 RecyclerView 的依赖,因为我们需要使用 RecyclerView
列表控件来展示可滚动的新闻列表;启用视图绑定,它可以让我们方便、安全地获取布局中的控件实例。
如下所示:
kotlin
android {
...
buildFeatures {
viewBinding = true
}
...
}
dependencies {
implementation("androidx.recyclerview:recyclerview:1.4.0")
...
}
新建承载新闻数据的数据类 News
,代码如下:
kotlin
data class News(val title: String, val content: String)
其中 title
字段表示新闻标题,content
字段表示新闻内容。
创建新闻内容页
首先我们来创建展示新闻内容的界面,这个界面在平板上会在右侧显示,在手机上会使用单独的页面来显示。
内容页布局
创建布局文件 news_content_frag.xml
,代码如下:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/contentLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="invisible">
<TextView
android:id="@+id/newsTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="10dp"
android:textSize="20sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#000" />
<TextView
android:id="@+id/newsContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:padding="15dp"
android:textSize="18sp" />
</LinearLayout>
该布局中有着新闻标题和新闻内容,它们之间使用一条水平黑色细线进行分隔。细线的实现也很简单,只是使用了一个 View
组件,将其粗细(宽度或高度)设为 1dp
,颜色再设为黑色即可。
另外,新闻内容页默认是不可见的 ,我们将根布局 LinearLayout
的 android:visibility
属性设置为 invisible
。这是因为在双页模式下,如果用户还没有点击任何新闻,右侧区域是不应该显示新闻内容布局的,应该是空白。
内容页 Fragment
然后创建新闻内容页对应的 Fragment 类:NewsContentFragment
,继承自 Fragment,代码如下:
kotlin
class NewsContentFragment : Fragment() {
private var _binding: NewsContentFragBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = NewsContentFragBinding.inflate(inflater, container, false)
return binding.root
}
/**
* 将隐藏的新闻内容布局设为可见,并且刷新界面中的新闻标题和新闻内容
*/
fun refresh(title: String, content: String) {
// 显示新闻内容页布局
binding.contentLayout.visibility = View.VISIBLE
binding.newsTitle.text = title // 刷新新闻的标题
binding.newsContent.text = content // 刷新新闻的内容
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
我们在这个 Fragment 中,创建了一个 refresh
方法,用来显示布局并且更新数据。
适配单页模式
在手机的这种单页模式下,当点击了新闻标题后,应该要创建一个新的 Activity 来承载 NewsContentFragment
。
创建容器 Activity
创建一个 Activity,命名为 NewsContentActivity
,对应的布局文件为 activity_news_content
。在该布局文件中,我们静态加载之前完成的 NewsContentFragment
,代码如下:
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"
android:orientation="vertical">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/newsContentFrag"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
这样,单页模式就有了新闻内容页。
传递数据
然后在 NewsContentActivity
中,添加启动当前 Activity 的方法,并且将传入的数据设置到新闻内容页 Fragment 中。代码如下:
kotlin
class NewsContentActivity : AppCompatActivity() {
companion object {
/**
* 启动当前 Activity 的快捷方法
*/
fun actionStart(context: Context, title: String, content: String) {
val intent = Intent(context, NewsContentActivity::class.java).apply {
putExtra("news_title", title)
putExtra("news_content", content)
}
context.startActivity(intent)
}
}
private lateinit var binding: ActivityNewsContentBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityNewsContentBinding.inflate(layoutInflater)
setContentView(binding.root)
// 获取通过 Intent 传递的新闻数据
val title = intent.getStringExtra("news_title")
val content = intent.getStringExtra("news_content")
// 只有在 Activity 第一次创建时才需要设置 arguments
if (savedInstanceState == null) {
val fragment = binding.newsContentFrag.getFragment<NewsContentFragment>()
fragment.arguments = Bundle().apply {
putString("news_title", title)
putString("news_content", content)
}
}
}
}
我们在 onCreate()
方法中通过 Intent 对象获取了传入的新闻数据,然后获取到界面中的 FragmentContainerView
实例,并且调用其 getFragment()
方法获取到了 NewsContentFragment
对象,并且通过其 setArguments()
方法向 NewsContentFragment
传递数据。
为什么我们这里没有直接调用
NewsContentFragment.refresh
方法,而是使用了arguments
来传递数据?这是因为 Activity 与其内部的 Fragment 存在着生命周期的差异:在 Activity 的
onCreate
方法执行时,Fragment 的视图可能还没被创建好。如果此时直接调用refresh
方法去操作视图组件,会抛出java.lang.NullPointerException
异常,其实也就是 Fragment 的视图绑定对象binding
为空。使用
arguments
是官方推荐的做法,而且还安全。它能保证在 Fragment 的任何生命周期阶段都能被安全地访问。最后
if (savedInstanceState == null)
条件判断,是防止 Activity 重新创建时,可以不用重复设置arguments
。
最后,修改 NewsContentFragment
,需要在 onViewCreated
方法中,从 arguments
中拿到数据并更新界面。
kotlin
class NewsContentFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 此时 binding 不为空
// 从 arguments 中获取数据并更新界面
val title = arguments?.getString("news_title")
val content = arguments?.getString("news_content")
if (title != null && content != null) {
refresh(title, content)
}
}
...
}
创建新闻标题列表
现在,我们来创建左侧的新闻标题列表。
列表与列表项布局
首先创建列表布局,新建 news_title_frag.xml
布局文件,代码如下:
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"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/newsTitleRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
创建列表项布局,新建 news_item.xml
布局文件,代码如下:
xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/newsTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:paddingLeft="10dp"
android:paddingTop="15dp"
android:paddingRight="10dp"
android:paddingBottom="15dp"
android:textSize="18sp" />
列表项的布局很简单,只有一个 TextView。
解释一下 ellipsize
属性,它可指定当文本内容超出控件的宽度后,文本的缩略方式。我们设置为 end
,表示尾部进行缩略,比如 "这是一条很长的新闻标..."。
标题列表 Fragment 与 Adapter
创建一个 NewsTitleFragment
,代码如下:
kotlin
class NewsTitleFragment : Fragment() {
private var _binding: NewsTitleFragBinding? = null
private val binding get() = _binding!!
// 是否为双页模式
private var isTwoPane = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = NewsTitleFragBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isTwoPane = requireActivity().findViewById<View>(R.id.newsContentLayout) != null
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
在 onViewCreated
方法中,我们会通过是否能在 Activity 中找到一个 id 为 newsContentLayout
的 View,来判断当前是双页还是单页模式。
然后就是创建 RecyclerView 的适配器,直接在 NewsTitleFragment
中新建一个内部类 NewsAdapter
作为适配器,代码如下:
kotlin
class NewsTitleFragment : Fragment() {
...
// 是否为双页模式
private var isTwoPane = false
...
inner class NewsAdapter(private val newsList: List<News>) :
RecyclerView.Adapter<NewsAdapter.ViewHolder>() {
inner class ViewHolder(private val newsItemBinding: NewsItemBinding) :
RecyclerView.ViewHolder(newsItemBinding.root) {
fun bind(news: News) {
newsItemBinding.newsTitle.text = news.title
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val newsItemBinding = NewsItemBinding.inflate(layoutInflater, parent, false)
val holder = ViewHolder(newsItemBinding)
newsItemBinding.newsTitle.setOnClickListener {
val position = holder.bindingAdapterPosition // 获取 ViewHolder 在适配器中的位置
if (position != RecyclerView.NO_POSITION) {
val news = newsList[position] // 获取当前新闻标题对应的新闻数据
if (isTwoPane) {
// 如果是双页模式,则刷新 NewsContentFragment 中的内容
val fragment =
parentFragmentManager.findFragmentById(R.id.newsContentFrag) as? NewsContentFragment
fragment?.refresh(news.title, news.content)
} else {
// 如果是单页模式,则直接启动 NewsContentActivity
NewsContentActivity.actionStart(
parent.context,
news.title,
news.content
)
}
}
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val news = newsList[position]
holder.bind(news)
}
override fun getItemCount() = newsList.size
}
}
我们给新闻标题注册了点击事件,在点击回调中,首先获取了新闻标题对应的新闻数据,也就是 News 实例,然后通过 isTwoPane
属性判断当前是单页还是双页模式。如果是单页模式,就启动一个 NewsContentActivity
显示新闻内容;如果是双页模式,就更新当前 MainActivity
对应布局中 NewsContentFragment
里的数据。
使用限定符实现动态布局
那怎么让标识符为 newsContentLayout
的控件只有在双页模式中才存在呢?
很简单,使用最小宽度限定符。
默认布局
单页模式下,我们只加载一个新闻标题列表。在 res/layout/activity_main.xml
布局文件中:
xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/newsTitleFrag"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
平板布局
而在双页模式下,我们同时加载新闻标题列表和新闻内容页。新建 res/layout-sw600dp
文件夹,在该文件夹下创建 activity_main.xml
布局文件。这样当设备的最小宽度大于等于 600dp 时,系统会自动加载这个布局。代码如下:
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"
android:orientation="horizontal">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/newsTitleFrag"
android:name="com.example.fragmentbestpractice.NewsTitleFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:layout_alignParentStart="true"
android:background="#000" />
<FrameLayout
android:id="@+id/newsContentLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/newsContentFrag"
android:name="com.example.fragmentbestpractice.NewsContentFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</LinearLayout>
在平板布局中,我们才有标识符为 newsContentLayout
的 FrameLayout
。所以只要能在 Activity 的布局中找到这个标识符,说明当前是双页模式。
删除多余代码
在 MainActivity
中,因为对应的布局中已经不存在 id 为 main
的视图了,所以我们需要删除多余的代码,修改后:
kotlin
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
填充数据
最后就是往新闻列表中填充数据了,回到 NewsTitleFragment
中:
kotlin
class NewsTitleFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isTwoPane = requireActivity().findViewById<View>(R.id.newsContentLayout) != null
// 设置布局管理器
val layoutManager = LinearLayoutManager(activity)
binding.newsTitleRecyclerView.layoutManager = layoutManager
val adapter = NewsAdapter(getNews())
// 设置适配器
binding.newsTitleRecyclerView.adapter = adapter
}
// 获取新闻数据
private fun getNews(): List<News> {
val newsList = ArrayList<News>()
for (i in 1..50) {
val news =
News("This is news title $i", getRandomLengthString("This is news content $i."))
newsList.add(news)
}
return newsList
}
// 获取随机重复次数的字符串
private fun getRandomLengthString(str: String): String {
val n = (1..20).random()
val builder = StringBuilder()
repeat(n) {
builder.append(str)
}
return builder.toString()
}
...
}
结果
这样全部就完成了,开始运行!
手机上的效果:
你会看到一个新闻标题列表,点击任意一条新闻,会启动新的 NewsContentActivity
来显示新闻内容。
在平板上的效果:
你会看见左右分栏的布局,左侧是新闻标题列表,右侧默认是空白。随意点击一条新闻,右侧会显示新闻内容,效果如图:
同一份代码,在手机和平板上运行却有不同的效果,这说明当前程序的兼容性还是不错的。