Chapter 1
IN THIS CHAPTER
Adjusting for screen size and screen orientation
Managing multipanel activities
Writing apps that run on both phones and tablets
Don’t think about an elephant.
Okay, now that you’re thinking about an elephant, think about an elephant’s legs. The diameter of an elephant’s leg is typically about 40 centimeters (more than four-tenths of a yard).
And think about spiders of the Pholcidae family (the “daddy longlegs”) with their hair-like legs. And think about Gulliver with his Brobdingnagian friends. Each Brobdingnagian was about 72 feet tall, but a Brobdingnagian adult had the same physical proportions as Gulliver.
Gulliver’s Travels is a work of fiction. An animal whose height is 12 times a human’s height can’t have bone sizes in human proportions. In other words, if you increase an object’s size, you have to widen the object’s supports. If you don’t, the object will collapse.
This unintuitive truth about heights and widths comes from some geometric facts. An object’s bulk increases as the cube of the object’s height. But the ability to support that bulk increases only as the square of the object’s height. That’s because weight support depends on the cross-sectional area of the supporting legs, and a cross-sectional area is a square measurement, not a cubic measurement.
Anyway, the sizes of things make important qualitative differences. Take an activity designed for a touchscreen phone. Zoom that activity to a larger size without making any other changes. Then display the enlarged version on a ten-inch tablet screen. What you get on the tablet looks really bad. A tiny, crisp-looking icon turns into a big, blurry blob. An e-book page adapts in order to display longer line lengths. But, with lines that are 40 words long, the human eye suffers from terrible fatigue.
The same issue arises with Android activities. An activity contains enough information to fill a small phone screen. When the user needs more information, your app displays a different activity. The new activity replaces the old activity, resulting in a complete refresh of the screen.
If you slap this activity behavior onto a larger tablet screen, the user feels cheated. You’ve replaced everything on the screen even though there’s room for both the old and new information. The transition from one activity to the next is jarring, and both the old and new activities look barren.
No doubt about it, tablet devices require a design that’s different from phone designs. And to implement this design, Android has fragments. You first start discovering fragments in Book 3, Chapters 3 and 4, and this chapter adds to that knowledge by emphasizing the differences between smartphone and tablet presentation. This chapter specifically uses the navigational graph technique explored in Example 03_03_02 of Book 3, Chapter 3.
The examples in previous minibooks rely on the perspective of either a Nexus S or a Pixel 3a smartphone used in the portrait orientation (unless you chose some other smartphone setup during the initial configuration). Of course, you could just assume that no one will ever have any other sort of smartphone and will never use it in landscape orientation. You could go further and just assume that tablet users will be happy with the space-wasting view of a smartphone app, but the users of those devices will soon provide you with a rude awakening. Different devices have different perspectives of your app. The following sections help you understand how to configure Android Studio to support testing with multiple device perspectives so that you can create better layouts for your apps. These sections do rely on Example 03_03_02 from Book 3, Chapter 3, but you can easily understand the material without having to create the example if desired.
Creating great apps means choosing a set of test devices that match what you expect users will have (or at least provide a broad enough spectrum that the app should work on all current devices, even those you haven’t tested). As the chapter progresses, you build a flexible app designed around the ideas presented in Book 3, Chapters 3 and 4. In this section, you look again at one of those apps, Example 03_03_02.
You can use a number of techniques to see problems with Example 03_03_02, which is designed to work on a smartphone in portrait mode. One of those techniques is to work with the emulator controls, shown in Figure 1-1. Notice that two of the controls let you turn the emulated device — which you can do either to the left or to the right. Turning the device shows that the example doesn’t rotate when the screen rotates, making it quite impossible to use the app in landscape mode, as shown in Figure 1-2.
The “Creating an Android virtual device” section of Book 1, Chapter 2 gives you the basics of creating a virtual device. When creating a virtual device, remember that you can choose a startup orientation, as shown in Figure 1-3, and it often helps to have one of each running as you test (see the next section of this chapter for more details).
As shown in Figure 1-4, the maximum resolution of a tablet is nearly twice that of the maximum of a smartphone. The emulator offerings supplied with Android Studio represent the most popular resolutions. However, some devices do provide slightly higher resolutions, and you may need to test against them using a physical device and the USB technique described in the “Testing Apps on a Real Device” section of Book 1, Chapter 3.
Running Example 03_03_02 on a high-resolution tablet demonstrates that it doesn’t scale very well, as shown in Figure 1-5. The text is so tiny that your user will easily go blind, and the controls are all jammed into the upper-left corner. Between the inability to rotate the display and a lack of scaling, the Example 03_03_02 is a failure, despite looking good in Book 3, Chapter 3.
When creating a testing setup for apps used with both smartphones and tablets, you want to be sure to have a range of device resolutions and form factors. Also helpful is to be able to quickly display both landscape and portrait versions of your app, as shown in Figure 1-6.
You may have noticed that even with great machine resources, starting an emulation to test your app can take a while, depending on what you expect the app to do. Sometimes, the more efficient approach is to start the testing process on multiple emulators when your goal is to see how the layout works. To use multiple devices to run the app, follow these steps:
Choose Run⇒ Select Device.
You see the context menu, shown in Figure 1-7.
Click Run on Multiple Devices.
You see the Select Deployment Targets dialog box, shown in Figure 1-8.
Select the devices you want to use and click Run.
Android Studio compiles the app, starts the selected emulators or attached devices, and installs the app on the devices. This process requires longer than the usual time. However, you can then work with each device independently to see various app changes.
Clearly, the example app has some design deficiencies, but it makes a good starting point for this chapter, which means making a copy so that you get to keep the original. Use these steps to create a copy of Example 03_03_02.
Create a new folder named 05_01_01 in your source code folder.
You see the new empty folder.
Paste all the folders and files into the 05_01_01 folder on your system.
You see a copy of the files and folders in the new folder.
Choose the Open an Existing Android Studio Project option from the main Android Studio window.
Android Studio displays an Open File or Project dialog box.
Select the 05_01_01 folder and click OK.
The app won’t run correctly at this point, so don’t even try. In fact, it may not compile, either.
package="com.allmycode.p03_03_02"
to read package="com.allmycode.p05_01_01"
.Right-click the javacom.allmycode.p03_03_02
folder and choose Refactor⇒ Rename from the context menu.
You see the Warning dialog box shown in Figure 1-9, telling you that Android Studio will rename the package instances for you.
Click Rename Package.
You see a Rename dialog box like the one shown in Figure 1-10.
Type p05_01_01 in the field supplied, check both options, and click Refactor.
Android Studio generates a Refactoring Preview like the one shown in Figure 1-11.
Click Do Refactor.
After a few moments, the Refactoring Preview dialog box goes away and you can see that the directory names and other elements have been renamed.
Open settings.gradle
, change the rootProject.name
entry to 05_01_01
, and then click Sync Now.
Android Studio syncs the change with the rest of the app.
Click Build⇒ Make Project.
The copied project should compile without error.
Because you're changing things, open resvaluesstrings.xml
and change the <string name="app_name">03_03_02</string>
entry to read <string name="app_name">Layout Validation</string>
so that the example has a better name than the example number. This technique comes in handy any time you want to retain an existing project but also use it as a starting point for a new project.
The new example you've created, 05_01_01, has two problems that you’ve already noted. The first is that it doesn’t change orientation when the user changes the device from portrait to landscape presentation. The second is that the text isn’t the right size for each device; what looks nice on a smartphone causes the user to squint when working with a tablet. The following sections address both of these issues.
It seems as if your app should be able to automatically reorient itself without additional code, but some apps truly are directional, and you see them all the time when you try various apps in Google Play Store (meaning that they have been vetted by Google). For example, a utility to control a furnace might actually work when placed in landscape mode, except, you couldn’t conveniently see things like the temperature schedule because adding this information requires a longer display. Consequently, you must decide on an appropriate orientation for your app and then provide code to manage it. The example in this section allows for changes in orientation and reorients itself as needed (a little trick is involved when you’re using an emulator).
To begin, you must tell Android that you’re interested in knowing about certain changes in configuration, like orientation. Open the AndroidManifest.xml
file and add the following code, in bold, to the activity.
<activity android:name=".MainActivity"
android:configChanges="orientation|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
You can add other configuration changes to the android:configChanges
attribute. However, for now, all you need is to know about orientation and screen size.
The next change appears in activity_main.xml
file, where you need to add a new TextView
to display the orientation information received by the code you add in a moment. Name the new control LastChange
and give it a default text
value of N/A
.
The last change is in MainActivity.kt
. You need to add an override
for onConfigurationChanged()
, which is where all the change information appears (so it can get rather complex). Here's the code you use for this part of the example:
override fun onConfigurationChanged(newConfig: Configuration) {
if (newConfig.orientation ==
Configuration.ORIENTATION_LANDSCAPE) {
LastChange.setText("Landscape")
} else if (newConfig.orientation ==
Configuration.ORIENTATION_PORTRAIT) {
LastChange.setText("Portrait")
} else {
LastChange.setText("Unrecognized")
}
super.onConfigurationChanged(newConfig)
}
When running the app on an emulator, you see a new little symbol on the bottom of the display with the rest of the controls, as shown in Figure 1-12. This little symbol looks like a box with an arrow in each corner. You must click that symbol to change the orientation of the app.
After you click the orientation symbol, you see the app reorient itself as shown in Figure 1-13. Of course, the text size isn’t right, and now the various controls appear shoved over to the left instead of being centered.
You could slowly drive yourself nuts trying to obtain the right Android layout using programmatic means. A much easier method involves using layouts effectively. For this example, you need to modify how you approach the layout so that it can work well in different orientations and on different-sized devices. To start, consider that activity_main.xml
currently uses a ConstraintLayout
, which works well when you know you need to work with a specific-sized device at a specific orientation. Using a several LinearLayout
setups works better in this case, so activity_main.xml
now uses a setup that looks like the one in Figure 1-14.
<TextView
android:id="@+id/Caller"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:text="@string/CallerText"
android:textSize="24sp"/>
Notice that the control now specifies a textSize
attribute value of 24sp
, for scalable pixels (sp). Use sp
whenever possible to account for differences in device dots-per-inch (dpi) values. Android supports a range of size qualifiers, and it's important to know which one to use. The article at https://blog.mindorks.com/understanding-density-independent-pixel-sp-dp-dip-in-android
describes the differences between dp, sp, dip, and so on. Trying to keep them straight could make you crazy! The reason the example now uses 24sp
for the text is that it provides a good presentation across smartphone and tablet devices. The text in the pushbuttons is set to 18sp
because it shows up better onscreen.
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<fragment
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="3"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph"/>
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<Button
android:id="@+id/Previous"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="@string/PreviousClick"
android:text="@string/PreviousText"
android:textSize="18sp"/>
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
None of the controls uses any sort of positioning any longer because the combination of the LinearLayout
controls makes it unnecessary. Of course, you give up the ability to precisely position controls in the process. A ConstraintLayout
comes in handy when positioning is critical or you want special effects.
The last odd sort of change is that all the fragments must now have two Button controls and a Space, even fragment_see_results.xml
. The solution is to include a blank button. Here's the code used to create this new setup:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:orientation="horizontal"
tools:context=".SeeResults">
<Button
android:id="@+id/GoSayGoodbye"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="2"
android:onClick="@string/GoSayGoodbyeClick"
android:textSize="18sp"
android:text="@string/GoSayGoodbyeText"/>
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<Button
android:id="@+id/InvisibleButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="2"
android:clickable="false"
android:textSize="18sp"
android:visibility="invisible"/>
</LinearLayout>
Because the visibility
attribute is set to "invisible"
, you don't even see the Button onscreen, but the effect of the button is felt through the layout, with Goodbye appearing on one side of the display and Previous on the other. Of course, the main concern is whether these changes have actually fixed the problems noted earlier. Figure 1-15 shows the smartphone landscape view, and Figure 1-16 shows the tablet landscape view. The new design works for simple apps. However, as you see in later sections of the chapter, apps with complex layouts require a bit more planning.
The navigational graph is a lot more flexible than previous examples would have you believe. You can create complex navigation that makes high-end apps possible with a lot less work than you needed to perform in the past. This section conveys the first part of that process, creating a nested navigational graph that groups fragments in such a manner as to make supporting multiple device types considerably easier. When using a nested navigational graph, what you create is a setup in which you use a fragment alone on a smartphone or placed side by side with another fragment on a smartphone in landscape mode (with the requisite reduction in font size). When working with a tablet, you might see four fragments in a group placed in various ways depending on whether the tablet is in portrait or landscape mode. The purpose of all these configurations is to use screen real estate more effectively when working with complex apps.
The example in this section begins by copying the example in the previous section using the technique discussed in the “Copying the project” section, earlier in this chapter. Call this example 05_01_02. As you follow the steps, substitute 05_01_02
for 05_01_01
as needed. You don't need to do anything other than copy the project to start the sections that follow.
The essential idea behind a navigational graph (as described in Book 3, Chapter 3) is to outline a process consisting of a series of workflows. In that chapter, you see three workflows:
mainEntry->doStuff->seeResults->sayGoodbye
mainEntry->seeResults->sayGoodbye
mainEntry->doStuff->sayGoodbye
The problem with this setup is that doStuff
might actually require a number of steps that won’t fit on a single smartphone screen. To make the fragments work, you have to fit them on a single screen of the smallest device you want to support. However, in order to use screen real estate efficiently, a larger screen might show multiple fragments. You have a problem here that you can solve using a nested navigational graph by maintaining the individual fragments but grouping them together into a single nested graph within the main graph.
Consider a series of forms used to make a sale from an online shopping cart. The app you create makes the shopping process easier because a user can safely store personal information locally. So, you might break this process into four parts, as shown here:
The process must follow all four of these steps, so grouping them makes sense. However, a buyer need not necessarily want to make another purchase. The seeResults
part of the overall process might simply display current orders. So, a buyer might want to make a new purchase and see it added to the list of pending orders, see just the pending orders, or just make a purchase. The three original workflows still work, but doStuff
is now more complex. Examples of other kinds of nested graph include:
A complex app doesn't just sprout out of your mind like a patch of petunias. You don’t get up in the morning, proclaim you’ll be brilliant, and create the design from scratch without reworking it a little. Most people start with an overview, which is what you can consider the original design from Book 3, Chapter 3 to be. You augmented that design in this chapter as example 05_01_01, and you see this augmented design in Figure 1-17.
Follow these steps to implement the design changes considered in the previous section of the chapter:
Add three new fragments below doStuff
and name them:
personalInfo
creditCard
finalize
You use the same process as you do in the “Adding destinations” section of Book 3, Chapter 3.
doStuff
, seeResults
, and sayGoodbye
by clicking each link and pressing Delete.Create new links as shown in Figure 1-18 to create the new workflow.
You use the same process as you do in the “Creating links between destinations” section of Book 3, Chapter 3.
Rename doStuff
to verifySale
by highlighting the doStuff
block in the design and typing verifySale in the ID field of the Attributes pane.
You see a dialog box asking whether to perform the update locally or globally.
Click Yes to perform the update globally.
The navigational graph designer applies the change globally.
seeResults
to seeOrders
, with a Label of fragment_see_orders
.Shift-click verifySale
, personalInfo
, creditCard
, and finalize
.
You see all four blocks highlighted.
Click Group Into Nested Graph on the toolbar (the icon that looks like three blocks grouped).
You now see the grouped items as a single block with an ID value of navigation
, which clearly isn't helpful. If you wanted to maintain the original design, you could always use doStuff
for an ID because this single grouped block encapsulates what you used to call doStuff
, but a better name might be purchaseGoods
.
Highlight the grouped block and type purchaseGoods in the ID field of the Attributes pane.
The final graph looks like the one in Figure 1-19.
You should note a few things aspects of this new design. For one thing, all the nested graph links are dashed so that you can easily see that clicking one of them won't show the actual actions. To see the actions contained within a nested graph, you must double-click the block, which reveals the content.
The second thing to note is that the Destinations pane takes on a new meaning, as shown in Figure 1-20. Notice that when you go into a nested graph to see its individual elements, you can’t see any of the elements outside the nested graph. You click Root (with the left-pointing arrow) to leave the nested graph and return to the overview.
At this point, you should try to compile the app by choosing Build⇒ Make Project. You see two unresolved reference errors as a result. The errors come from the fact that the actions have changed. Double-click the entries to modify the actions: action_doStuff_to_seeResults
becomes action_finalize_to_seeResults
, and action_doStuff_to_sayGoodbye
becomes action_finalize_to_sayGoodbye
. If you like, you can also change the Caller.setText()
call to "Caller: Finalize"
.
Of course, these actions don't completely fix the app linkages, but they’re a start. The problem is that you now have a different flow, and using the Button controls as the app has done makes the app brittle and not very adaptive to the content. What you really need is a quick method of navigating the graph that isn’t so brittle. In addition, you want the fragments to contain only content. So, begin by removing the buttons from all the fragments and adding two buttons to activity_main.xml
so that it looks like Figure 1-21.
To keep things easy, change the fragments so that they all have a simple TextView
that describes the current page, like this:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:orientation="horizontal"
tools:context=".DoStuff">
<!-- TODO: Update blank fragment layout -->
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:text="This is the Main Entry page."
android:textSize="24sp"/>
</LinearLayout>
The nav_graph.xml
file requires changes as well. Set the Label
attribute for each of the destinations to match the ID
attribute. You also need to add information about the buttons for each of the destinations by adding arguments on the Attribute pane. Click the + icon to see a dialog box like the one shown in Figure 1-22.
For this example, type Button1 or Button2 (when the destination has two buttons) in the Name field, select String as the Type, and type the text that should appear on the button in Default Value, such as Buy Goods for Button1
of mainEntry
. Figure 1-23 shows an example of how your entries should look for mainEntry
.
Making these changes allows you handle the two destination buttons using a single function for each. Both functions work the same, but onDestination1Click()
is a little longer because it contains more destinations. Here is the onDestination1Click()
code:
fun onDestination1Click(view: View){
val navController = Navigation.findNavController(
this, R.id.nav_host)
val Fragments: NavHostFragment =
supportFragmentManager.findFragmentById(
R.id.nav_host) as NavHostFragment
val ChildFragment =
Fragments.childFragmentManager.fragments[0]
val thisCaller = navController.currentDestination
Caller.setText("Caller: " + thisCaller!!.label)
when (ChildFragment) {
is MainEntry -> navController.navigate(
R.id.action_mainEntry_to_doStuff)
is DoStuff -> navController.navigate(
R.id.action_doStuff_to_personalInfo)
is personalInfo -> navController.navigate(
R.id.action_personalInfo_to_creditCard)
is creditCard -> navController.navigate(
R.id.action_creditCard_to_finalize)
is finalize -> navController.navigate(
R.id.action_finalize_to_seeResults)
is SeeResults -> navController.navigate(
R.id.action_seeResults_to_sayGoodbye)
}
ChangeButtons(navController)
}
This function works similarly to those you have worked with in the past, but in reality it provides a simplification because you no longer micromanage everything. The navController
provides access to the various locations that you want to manage, while ChildFragment
tells you about the current destination. For example, you use ChildFragment
to determine the name of the caller based on its label
property. ChildFragment
also lets you know about the destination type so that you know where the app should go next.
Part of the magic in this version of the app is that navController
changes to match the current destination. So, after you call navController.navigate()
, navController
no longer points to the previous destination; it points to the new destination instead. The call to ChangeButtons(navController)
configures the buttons to match the new destination using the following code:
fun ChangeButtons(navController: NavController) {
val newCaller =
navController.currentDestination!!.arguments
if (newCaller!!["Button1"] != null) {
Destination1.visibility = View.VISIBLE
Destination1.setText(
newCaller!!["Button1"]!!.defaultValue.toString()
)
} else
Destination1.visibility = View.INVISIBLE
if (newCaller!!["Button2"] != null) {
Destination2.visibility = View.VISIBLE
Destination2.setText(
newCaller!!["Button2"]!!.defaultValue.toString()
)
} else
Destination2.visibility = View.INVISIBLE
}
This is where the default argument values you configured in Figure 1-23 come into play. Each of the destinations has between zero and two buttons. When there is button text to display, the code uses the argument text to update the button. Otherwise, the button is invisible. Figure 1-24 shows the result of making the app useful with a nested navigational graph.
You can do more than you've seen in this chapter to create an app that works equally well on a smartphone or a tablet (or any other device, for that matter). Here are some other methods of adding flexibility and responsiveness to consider:
https://developer.android.com/guide/practices/screens-distribution.html
.https://developer.android.com/training/multiscreen/screensizes
.35.170.81.33