原文:Responsive UI Tutorial for Android
作者:James Nocentini
译者:kmyhy
2017/5/4 更新说明: 由 James Nocentini 更新到 Android Studio 2.2.3。原文作者也是 James。
Android 运行的设备十分广泛,它们的屏幕尺寸和分辨率都不一样。因此,Android app 能够拥有适应各种屏幕的响应式 UI 就显得非常重要。Android 平台很早以来就提供了非常强大的设计响应式 UI 的抽象层,即所谓的自适应布局。
本文是《Android 自适应 UI 教程》的一次升级,本教程中演示了如何用 fragmentation 构建跨设备的 app。这里,你将学习到:
- 设置限定符
- 可变布局和 drawable
- 使 Android Studio 的布局预览——一个非常有用的工具
一个不爱折腾的教程不是好教程。因此,我们将从头开始编写一个简单天气 app 的用户界面。当你做完这一切,屏幕中会用三种不同的配置来显示一张图片、几个文字标签和一张地图。一个看起来酷酷的拥有响应式 UI 的 app 就做好了。
开始
从此处下载开始项目 Adaptive Weather,用 Android Studio 打开它。然后编译、运行。
app 会显示一个简单的 RecyclerView,列出了几个城市。
要了解 RecyclerViews,建议阅读我们的Android RecyclerView 教程。
打开 build.gradle 声明下列依赖:
dependencies {
...
compile 'com.google.android:flexbox:0.2.5'
}
谷歌的 FlexBox 为 Android 平台提供了一种弹性盒子的实现。在后面你会看到,对于设计响应式布局来说这是非常有用的工具。它和 Android 的资源限定符系统一起使用,更是威力倍增!
注意:Android 平台更新频繁,当这篇教程发布时,版本号很可能又有增加。你可以查看不同版本的细节,比如去 Android 开发者网站查看最新的支持库页面。
在教程中,你经常需要在项目导航器中在 Android 和 Project 模式间来回切换。通俗地讲:
- Android 模式是 Android Studio 中的默认工作模式,它提供了一个干净的、简单的文件结构。
- Project 模式对于编写会变化的布局来说也是必须的。
天气图片
Android 设备的屏幕分辨率各有不同,因此将不同的静态图片导成多种尺寸是一种比较好的做法。Android 系统 API 提供了一种方法创建响应式 UI。根据多屏幕支持指南,屏幕分辨率分成以下几种类别:
- ldpi (low) ~120dpi
- mdpi (medium) ~160dpi
- hdpi (high) ~240dpi
- xhdpi (extra-high) ~320dpi
- xxhdpi (extra-extra-high) ~480dpi
- xxxhdpi (extra-extra-extra-high) ~640dpi
有一些 UI 编辑器轻易就能将图片导出成各种尺寸,在本文中将演示另外一种方法。Android Studio 最近对矢量图进行了支持。也就是说,你所有的图片资源都可以只导出一张,然后在运行时根据设备配置(屏幕尺寸和方向)进行拉伸.
下载天气图片并解压。在 Android Studio 中,在 res/drawable 上右键,点击 New\Vector Asset 菜单:
Asset Type 选择 Local file(SVG、PSD)。从 Path 右边的文件浏览器中找到 weather-icon 文件夹选择第一个图片 cloud.svg。勾上 Size 设置右边的 Override,否则在后面图片会出现一些失真。点击 Next、Finish:
在 Android Studio 的 res/drawable/ic_cloud.xml 中你会看到这张图片。在其它图片上重复同样动作:fog,rain,snow,sun,thunder。
最后,在 app 模块的 build.gradle 中启用 Vector Drawable:
android {
...
defaultConfig {
...
vectorDrawables.useSupportLibrary = true
}
}
现在,项目中的图片已经是可拉伸的了,准备编写布局。
编写布局
声明完依赖后,将工作转移到布局的实现上!
这个 app 只有一个页面,即 MainActivity。在项目导航器中,打开 res/layout/activity_main.xml。点击右下角的 Preview 按钮进行预览。
一个 Activity 是一个 Java 类——这里,也就是 MainActivty.java 了——外加一个布局文件。事实上,一个 activity 也可以有多个布局的,等会你就会看到。就目前来说,终点是记住这个文件 activity_main.xml 是我们的默认布局。
Forecast 网格视图
首先,为主 activity 定义默认布局。打开 res/values/colors.xml 将内容替换成:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_primary">#9B26AF</color>
<color name="color_primary_dark">#89229b</color>
<color name="text_color_primary">#ffffff</color>
<color name="forecast_grid_background">#89bef2</color>
</resources>
这里,我们将 forecast 网格的默认的材料设计的主题色改成了另外一个背景色。然后,在 values 文件夹上右键,选择 New\Value resource file :
文件名取名为 fractions.xml,输入如下内容:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="weather_icon" type="fraction">33%</item>
</resources>
这里,我们指定了每个图标的宽度应当是整个宽度的 1/3。
然后,创建一个新的布局文件,名为 forecast_grid.xml,在其中添加:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.flexbox.FlexboxLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/forecast"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/forecast_grid_background"
app:alignItems="center"
app:flexWrap="wrap"
app:justifyContent="space_around">
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day1"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_thunder"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day2"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_fog"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day3"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_rain"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day4"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_snow"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day5"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_cloud"/>
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day6"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_sun"/>
</com.google.android.flexbox.FlexboxLayout>
这里有几个地方值得注意:
- 我们将使用 com.google.android.flexbox.FlexboxLayout 标签来布局屏幕上的图标。
- 我们将使用 android.support.v7.widget.AppCompatImageView 标签来绘制天气图片。对于一般的图片(.png、.jpg),我们通常会使用 ImageView 标签,但对于矢量图片,我们必须换成前者。
在 Preview 面板中,你会看到完美对齐的天气图标:
已经尝到了响应式的滋味了吧?不需要用 margin 来控制图片的位置,或者用我们常用的相对布局,FlexBox 属性会对称地展开它们。例如,你删除中间的一个图标,剩余的图片会自动向左移动以补上空出来的地方。在布局中使用 FlexBox 的威力非常强大。 forecast 网格已经就绪,可以在主 activity 的默认布局中使用了。
主 Activity
打开 res/layout/activity_main.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">
<include layout="@layout/forecast_grid"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
这个布局主要是:
- 将 LinearLayout 的方向设置为竖向。
- 尺寸:用 layout_weight 属性分别将两个子视图的高度设置为占据屏幕高度的一半。
- 布局的重用:通过 include 标签,引用了 forecast_grid.xml 布局,并将 forecast 网格放在屏幕上部。这是不需要重复代码就能创建不同布局的核心能力。
注意编辑器的预览窗口立即发生了变化。很神奇吧?我们并没有将 app 部署到设备或模拟器。
运行 App,你会发现在城市列表上的天气图标。
刷新天气数据
看一下 assets/data.json 中的静态 JSON 数据。每个城市的天气数据用一个字符串数组来保存。你可以用一个 GridLayout 来创建另外一个 RecyclerView 以动态显示天气数据,但那太麻烦了。相反,你可以写一个方法,将每个可能的天气状态映射成对应的 drawable 图标。
在 MainActivity.java 中新增方法:
private Drawable mapWeatherToDrawable(String forecast) {
int drawableId = 0;
switch (forecast) {
case "sun":
drawableId = R.drawable.ic_sun;
break;
case "rain":
drawableId = R.drawable.ic_rain;
break;
case "fog":
drawableId = R.drawable.ic_fog;
break;
case "thunder":
drawableId = R.drawable.ic_thunder;
break;
case "cloud":
drawableId = R.drawable.ic_cloud;
break;
case "snow":
drawableId = R.drawable.ic_snow;
break;
}
return getResources().getDrawable(drawableId);
}
接下来我们编写响应 RecyclerView 单元格点击事件的代码。在 MainActivity 中新增方法:
private void loadForecast(List<String> forecast) {
FlexboxLayout forecastView = (FlexboxLayout) findViewById(R.id.forecast);
for (int i = 0; i < forecastView.getChildCount(); i++) {
AppCompatImageView dayView = (AppCompatImageView) forecastView.getChildAt(i);
dayView.setImageDrawable(mapWeatherToDrawable(forecast.get(i)));
}
}
然后找到 MainActivity 中的 // TODO 注释,将它替换成:
loadForecast(location.getForecast());
运行 App。点击一个城市,注意天气数据发生了变化:
不错,App 看起来很漂亮!只是旧金山的天气看起来不太好啊:]
创建响应式 UI:横向布局
我们以默认竖屏的模式创建了 App,让我们来看一下当手机转成横屏时会发生些什么。打开 activity_main.xml,在布局编辑器中点击 orientation 图标:
然后在各种 Android 设备和模拟器上运行 app。这种方法测试可变布局不仅费事,而且容易出现问题。我们应该用别的方法。
幸好,Android Studio 拥有扩展式预览功能。打开默认的 activity_main.xml,将鼠标放到屏幕右下角,然后拖动布局的大小。注意,点击手柄的时候,Android Studio 会自动显示各种设备尺寸的导线。
呃——横屏布局对你的设计来说不太友好。我们应该将两个视图并排而列。要告诉系统某个尺寸使用不同的资源,你应当将布局资源放到一个特殊命名的文件夹中。系统会自动为这个尺寸使用对应的 acitivity 布局。这样,你的 app 就变成了响应式 UI了。
布局限定符
回到 Android Studio,在 res/layout and 右键,然后点击 New\Layout resource file:
文件取名为 activity_main ,并添加 landscape 资源限定符:
布局编辑器将显示一个空白窗口,因为这是一个新建的布局文件,位于 layout-land/activity_main.xml。它只有一个空的 LinearLayout 在里面,当然很快就不是了。添加两个重用的布局 weather forecast 和 Recycler View,但这次 orientation 变成了 horizontal:
<?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">
<include layout="@layout/forecast_grid"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
</LinearLayout>
布局编辑器现在将所有元素以横向的方式显示。
不错!我们在这个 app 中第一次试用了布局限定符。还有许多别的布局限定符(比如屏幕宽度、高度、宽高比等)。在下一部分,我们将用一句代码来修改横向布局。
资源限定符
另一个改进是将天气图标从 3 列 2 行修改为 2 列 3 行。我们可以复制 forecast_grid.xml 布局,但重复的代码更难维护。每个天气图标的宽度想相对于 FlexBox view 的宽度的,这个比例是通过 layout_flexBasisPercent 属性指定:
<android.support.v7.widget.AppCompatImageView
android:id="@+id/day1"
android:layout_width="wrap_content"
android:layout_height="60dp"
app:layout_flexBasisPercent="@fraction/weather_icon"
app:srcCompat="@drawable/ic_thunder"/>
这个值是分数类型,当前值为 33%,在资源文件 res/values/fractions.xml 中定义。用创建横向布局的方式,我们可以创建横屏设置的资源文件。在 res/values 文件夹上右键,选择 New\Values resource file。文件取名为 fractions 并添加一个 landscape 限定符:
在这个文件中添加:
<item name="weather_icon" type="fraction">49%</item>
返回 main activity 布局,你会看到天气图标现在排成了 2 列 3 行:
OK! 你可以停下来欣赏一小会儿,你没有必要部署 app。当然,你可以 build & run,确认一切正常。
配置限定符能够使用在任何 XML 布局的属性类型上(比如字体大小、颜色、边距等)。
超大布局
在布局编辑器中,回到竖向布局,将屏幕尺寸拖到 X-Large 尺寸。
因为设备拥有更多的屏幕空间,你可以将所有天气图标放在一行显示。右键点击 res/values,然后选择 New\Values resource file。文件命名为 fractions 并添加 X-Large size 限定符:
在这个文件中,添加 XML 标签:
<item name="weather_icon" type="fraction">16%</item>
回到布局编辑器,你会看到天气图标排成了 1 行。
配置计算
别害怕,这部分内容没有标题看起来那么可怕。当用户和 App 交互时,布局状态会随时改变(行被选中,输入字段改变等等)。当布局发生变化时(例如方向变化),现有布局被抛弃,新的布局创建。但是,系统不知道如何恢复状态,因为这两个布局完全不同,除非你告诉它。
要实际看一下这个例子,可以运行 app。选择一个城市然后改变方向,你会看到这个城市不再是被选中状态了!
如果你对于 London 整整一个星期都是大晴天感到毫不奇怪,那么当你切换到横屏后,被选中的行又变成未选中状态了。
要解决这个问题,我们要用到 activity 的生命周期方法,将所选城市保存到 bundle,然后在屏幕发生旋转后重新获取这个状态。
在 MainActivity.java 添加变量:
private static final String SELECTED_LOCATION_INDEX = "selectedLocationIndex";
然后是这个方法:
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(SELECTED_LOCATION_INDEX, mLocationAdapter.getSelectedLocationIndex());
}
在 onCreate() 方法最后加入:
if (savedInstanceState != null) {
int index = savedInstanceState.getInt(SELECTED_LOCATION_INDEX);
mLocationAdapter.setSelectedLocationIndex(index);
loadForecast(mLocations.get(index).getForecast());
}
运行 app,这次在切换方向之后,选中的城市保持了选中状态!欢呼吧!
结束
太好了!你编写了自己第一个自适应布局 app,学习了如何让 activity 使用多个布局。还学习了如何让 drawable 适配不同的显示尺寸,如何使你的 app 在所有 android 设备上都能适应。
对于编写自适应 UI 来说,没有什么比本教程更好的方法了,当然,Android 除了布局还有别的,如果想学习更多内容,请参阅谷歌的 最佳 UI 体检指南 。
如果你愿意,你可以尝试如下挑战:
- 用其它限定符来使用其它类型的布局。例如,用 locale 限定符显示不同的背景色。
- 或者,在其它资源上添加 size 限定符,比如字符串。你可以用一个 TextView 来显示短字符串,当屏幕转变为横屏后又显示长字符串?
这里下载本教程的示例项目的源代码,也可以访问它的 Github 库。
请留下你的足迹,发现任何问题或提问请在下面留言。期望与你交流!