SENG 440: Lecture 10 – Master + Detail via Fragments
Dear students,
Today we extend our Lately app by applying a master+detail UI to it. It will show both a list of headlines and a selected article. One phones, these two views will appear as separate screens, but on a tablet, they will both appear on the same screen. We will use Android’s Fragment
class to compose our UI out of independent pieces. We’ll only handle the phone portion today and save tablets for next time.
Before we forget, here’s your TODO for next time:
- Read App Resources Overview. The page goes into a lot of detail; read enough to gain a broad overview of the configuration options and resource selection algorithm.
- On a quarter sheet of paper to be turned in at the beginning of the next class, randomly generate three resource directory names with at least two configuration qualifications (e.g.,
layout-es-land
). For each, describe in your own words the configuration conditions under which its resources will be used.
Let’s continue making our app.
Master+detail
The master+detail UI pattern is one we see in email clients, in slide design software, and in multipage document viewers. Some portion of the screen shows a master list of titles or thumbnails. When the user clicks on an entry, the item is expanded in detail on the rest of the screen.
We want our Lately app to employ this master+detail pattern. The headlines will be listed in the master view, and an individual article will appear in the detail view. On the phone, the two views will be in separate activities, but on a tablet, both views will be on the screen at the same time.
Fragment-ation
In the early days of Android, screens were small. But when tablets appeared, we suddenly had a lot more space to fill. One approach to provide a larger UI was to specify both small and large versions of an activity’s layout, and rely on the Android resource manager to load up the appropriate version in onCreate
. This is a workable solution, but it leads to some awkwardness. In particular, the activity must include a lot of conditional logic to arbitrate between its behavior on small screens and its behavior on large screens.
The Android folks provided a solution. Instead of the Activity
being a manager for the whole screen, they allowed us to create a mini-manager for some portion of the screen. A mini-manager is called a Fragment
.
I was hoping I could avoid discussing fragments in lecture. Their complexity is daunting and doesn’t make for an intellectually stimulating presentation. However, they are hard to avoid in modern Android development. Indeed, it seems that a lot of Android development is easier if your app is a single activity that shows its many screens using fragments. So, we will examine them today. Not in detail, but in master+detail. We will use fragments to implement a master+detail UI.
These are the rough steps we’ll follow:
- Encapsulate our two chunks of interaction inside fragments.
- Develop an abstraction so fragments can talk with their activities—without knowing exactly in which activity they are embedded.
- Create a two-screen and two-activity interaction for phones.
- Create a one-screen and one-activity interaction for tablets.
Let’s get fragmenting.
BrowserFragment
Previously we used a implicit intent to show our news articles. But we don’t really want a full blown web browser, just an article reader. So, we’ll use a WebView
to a render a page within our app. Here’s the XML:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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=".BrowserFragment">
<WebView
android:id="@+id/webView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
</WebView>
</android.support.constraint.ConstraintLayout>
Who will manage this UI? Not a full-blown activity, but a Fragment
, a self-contained and reusable sub-activity. We create one like this:
class BrowserFragment : Fragment() {
private lateinit var webView: WebView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_browser, container, false)
webView = view.findViewById(R.id.webView)
webView.loadUrl("https://www.google.com")
return view
}
}
The fragment feels much like an activity with some types and names different. But we will be able to embed this fragment in several contexts.
However, a stock WebView
is a very disappointing thing. If the page you are loading incurs any redirects, those redirects will open in an external browser. We fix this by giving the view a WebViewClient
:
webView.webViewClient = WebViewClient()
If you search for this issue on StackOverflow, you will see code like this suggested in many answers:
private class HelloWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
view.loadUrl(url);
return true;
}
}
This is a bad suggestion. A method named shouldOverrideUrlLoading
should not be triggering a side effect like opening a web browser. Either the method should only return false
, or we should just let the default implementation of WebViewClient
do the job.
We may also want to enable Javascript for our pages to load properly:
webView.settings.javaScriptEnabled = true
Finally, we’ll find that some pages result in an error about cleartext traffic. In Marshmallow, the Android devs added the ability to block unencrypted requests sent over the network—like HTTP requests. The setting defaulted to allow cleartext traffic. In Pie, the default was changed to block cleartext traffic. We override this default via the usesCleartextTraffic
attribute in the application
element in the manifest:
<application
android:usesCleartextTraffic="true">
This is a good reminder that you can’t really make an app once and expect it to work for very long. We must keep up with the developments of the systems atop which we build.
BrowserActivity
In the phone context, we still need a dedicated activity to show this fragment. It gets its UI entirely from the fragment:
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/browserFragment"
android:name="org.twodee.lately.BrowserFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
Referencing the fragment in a layout is one way to load it. Let’s call this approach static loading. Later on we’ll look at a way to dynamically load fragments.
The activity class itself does nothing interesting beyond extend FragmentActivity
:
class BrowserActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_browser)
}
}
Now, we trigger our new activity-with-a-fragment back in MainActivity
instead of the external browser:
val intent = Intent(this, BrowserActivity::class.java)
intent.putExtra("url", it.url)
startActivity(intent)
Fragment Calls Activity?
We have a situation. Our fragment calls a loadUrl
method on its WebView
. But that URL is known in the activity, not in the fragment. Fragments are meant to be reusable by many activities, so we want to avoid making too many assumptions about the context. Communicating the other direction is generally safer; an activity knows about its fragments. We could ask the activity to push the URL down to the fragment. However, today we will have the fragment pull the information down from the activity. How? Through an abstraction.
The only behavior that our fragment needs from its context is a URL, so let’s create an interface that imposes a url
property on its implementers:
interface UrlKeeper {
var url: String
}
We’ll have the browser activity also implement this interface:
class BrowserActivity : FragmentActivity(), UrlKeeper {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = intent.getStringExtra("url")
setContentView(R.layout.activity_browser)
}
override lateinit var url: String
}
The browser fragment can now grab the URL from its hosting activity. We tweak the loading in onCreateView
:
val keeper = activity as UrlKeeper
webView.loadUrl(keeper.url)
The browser fragment is not tightly coupled to BrowserActivity
. Whew.
HeadlinesFragment
The primary use case of fragments is to decouple sections of the interface from an activity. So, just as we created a mini-manager for the browser, we’ll recreate a mini-manager for the headlines picker. We’ll first move all the UI into layout/fragment_headlines.xml
:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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">
<Spinner
android:id="@+id/sourcesPicker"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<android.support.v7.widget.RecyclerView
android:id="@+id/headlinesPicker"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sourcesPicker" />
</android.support.constraint.ConstraintLayout>
Similarly, we move all of our code from our MainActivity
into HeadlinesFragment
. A fragment is not a Context
, but it has a context
property. We have to make some minor adjustments:
class HeadlinesFragment : Fragment() {
private lateinit var headlinesPicker: RecyclerView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_headlines, container, false)
headlinesPicker = view.findViewById(R.id.headlinesPicker)
val layoutManager = LinearLayoutManager(context)
headlinesPicker.layoutManager = layoutManager
val decoration = DividerItemDecoration(context, layoutManager.orientation)
headlinesPicker.addItemDecoration(decoration)
val parameters = mapOf("source" to "bbc-news", "apiKey" to KEY)
val url = parameterizeUrl("https://newsapi.org/v2/top-headlines", parameters)
HeadlinesDownloader(this).execute(url)
return view
}
var headlines: List<Headline> = listOf()
set(value) {
field = value
headlinesPicker.adapter = HeadlineAdapter(context!!, field) {
val intent = Intent(context, BrowserActivity::class.java)
intent.putExtra("url", it.url)
startActivity(intent)
}
}
}
Our MainActivity
gets a new UI that statically loads the fragment:
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
android:name="org.twodee.lately.HeadlinesFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/headlinesFragment"/>
We switch its superclass to FragmentActivity
.
Now we have a decoupled list that works well enough on our phone. But ugh, that was a lot of work to decouple the independent sections of our UI. We’ll justify this headache by reusing these subactivities into one activity on the tablet.
That’s enough for today. See you next time!
P.S. It’s time for a haiku!
One king or many?
My father-in-law says one
One per watershed