课程 4: 音频和库

这节课是 Android 开发(入门)课程 的第二部分《多屏幕应用》的第四节课,导师依然是 Jessica Lin 和 Katherine Kuan,这节课完成了以下两点内容:

  • In Miwok App, allow the user to play an audio file to hear the pronunciation of each word when they touch it.
  • Learn about Android’s activity lifecycle and learn how to properly clean up resources in the right callbacks.

这节课虽然只添加了音频播放的功能,但实际上知识点非常多,扩展内容也非常丰富,完全学习下来需要花些时间。因此,这篇笔记仅关注这节课的内容,主要思路仍按 Miwok App 进行,扩展学习的分享会在以后发布。

关键词:合理安排实现新需求的步骤、MediaPlayer、static method、AdapterView.OnItemClickListener、Anonymous Classes、toString、Async Callback、Activity Lifecycle、Audio Focus、AudioManager、FrameLayout、触摸反馈、RippleDrawable

合理安排新需求的实现步骤

首先来看如何给 Miwok App 添加音频,正如课程 3 介绍的,面对一个新需求时,开发者要能够合理安排实现的步骤:

  1. Modify list item layout to include a play button
  2. Handle clicking on a list item to play an audio file
  3. Add in all audio files
  4. Modify Word class to store audio resource ID
  5. Play correct audio file per word
  6. Visual polish

当然上述步骤不是一成不变的,在实际 coding 的过程中总会发现有更多的任务要去处理。

在列出所有实现功能的步骤后,就要安排各个任务的优先级。由于之前没有做过添加音频的功能,所以第一步可以新建一个工程来验证添加音频功能的可行性,验证成功后再回到 Miwok App 中来。

Google 搜索 "Android play audio" 可以找到 MediaPlayer API 的相关文档,还有 MediaPlayer 的入门教程,略读后发现它可以实现播放音频的功能,所以我们的示例应用就可以用 MediaPlayer 来测试。

  1. 右键 app→res 选择 New→Android resource directory 打开对话框,选择 Resource type 为 raw,点击 OK 新建目录并将音频文件放到 raw 目录下。
    延续 Android App 代码与资源分离的风格,并且不同资源之间也分类存放,包括图片、字符串等不同类型的资源,针对不同分辨率设备的图片或不同语言的字符串等替代资源 (Alternative Resources) 都会在不同目录下存放。更多信息可参考相应文档 (Providing Resources)。

  2. 实际上手发现 MediaPlayer 的入门操作非常简单,调用 create() 新建一个 MediaPlayer 对象实例,接着调用 start()pause() 就能控制音频的播放或暂停,其他功能也可以通过简单调用 method 实现。
    但是,这只是根据教程复制粘贴了几行代码而已,距离正确地在 Android 使用媒体播放 (Media Playback) 还很遥远,不过这已经达到在示例应用验证 MediaPlayer 添加音频功能可行性的目的了。在回到 Miwok App 之前,先简单介绍一下 MediaPlayer API。

MediaPlayer Class
  1. MediaPlayer 是一个比较复杂的 class,它同时支持播放音频和视频,媒体文件可以与 App 绑定,也可以是网络的流媒体,它支持的所有媒体格式可以在 Android 文档 (Supported Media Formats) 中查看。

  2. MediaPlayer 的操作是由状态机 (State Machine) 管理的,状态机控制 MediaPlayer 在不同状态之间过渡,开发者可以根据不同状态对 MediaPlayer 进行不同操作,也可以执行 method 使 MediaPlayer 在状态之间切换。

  1. Idle: 空闲状态,等待指令,不会发出声音;
  2. Prepared: 准备状态,准备播放媒体文件,文件已加载,但尚未开始播放;
  3. Started: 开始状态,开始播放媒体文件;
  4. Paused: 暂停状态,暂停播放媒体文件,此状态可返回开始状态;
  5. Stopped: 停止状态,停止播放媒体文件,可从 Started 或 Paused 状态切换过来,但 Stopped 状态只能切换到 Prepared 状态,重新加载媒体文件。

上面是 MediaPlayer 状态机的简化版本,只覆盖了常规的状态,让脑海里有个概念。完整状态机可以在 MediaPlayer 文档 中看到,包括结束 (End) 状态,是释放 MediaPlayer 资源后的状态;还有媒体播放完毕后进入的 PlaybackCompleted 状态。这两个状态的操作会在后面提到。

  1. 使 MediaPlayer 在不同状态之间切换,需要调用 MediaPlayer 对象的 method,那么就先要将 MediaPlayer 实例化,即新建一个 MediaPlayer 对象。

     MediaPlayer mediaPlayer = MediaPlayer.create(context, R.raw.song);
    

与常见的新建 Toast 对象的方法类似,这里使用的是工厂方法,而不是构造函数。因此 create() 是一个静态 (static) method,它属于 class 而不是单个实例,所以在调用 create() 时,. 前面的数据类型是 class,在这里是 MediaPlayer,而不是对象实例 mediaPlayer。这样做的好处是可直接调用 method 而无需先新建对象实例。
非静态 (regular) method 则可以通过对象实例来调用。

MediaPlayer API 提供了多个 create() method,这里选择的新建方法有两个输入参数:当前 Context 和媒体文件的资源 ID。返回值是一个 MediaPlayer 对象。另外,调用 create() method 会自动调用 prepare() method,所以在新建 MediaPlayer 对象之后可以直接调用 start() method 播放媒体文件,如

// 由于 start() 不是 static method,所以可以用对象实例 mediaPlayer 调用。
mediaPlayer.start();

从示例应用回到 Miwok App,要播放列表中的每一项 Miwok 发音,目前仅使用上述两条 MediaPlayer 指令就可以实现了,那么接下来就要将 ListView 上的点击事件操作设置为使用 MediaPlayer 播放音频。

AdapterView.OnItemClickListener

与之前为 TextView 和 Button 设置的监听器 (OnClickListener) 不同,ListView 的列表项 (item) 数量不是固定的,所以无法给每一项单独设置监听器,比如不可能为一个有 1000 项的 ListView 设置 1000 个监听器。

  1. Google 搜索 "handle listview item click android",结合 ListView 文档 可以找到
setOnItemClickListener(AdapterView.OnItemClickListener listener)

这个 method 负责注册一个回调函数 (callback),当 AdapterView 发生点击事件时会调用这个回调函数。

AdapterView 是一个超级类,包括 ListView、GridView、Spinner 都是它的子类 (Subclass)。因为 OnItemClickListener 是在 AdapterView 中定义的,ListView 将它从父类继承,所以这里出现 AdapterView。同时也说明 GridView 和 Spinner 的点击事件监听器也是由这个 method 注册的。

  1. 与 TextView 和 Button 的 OnClickListener 类似,AdapterView 的 OnItemClickListener 也是一个接口,它有一个抽象方法 onItemClick,需要开发者定义代码,也就是点击事件发生时执行的代码。
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
    // Create and setup the {@link MediaPlayer} for the audio resource associated with the current word
    mMediaPlayer = MediaPlayer.create(NumbersActivity.this, words.get(position).getAudioResourceId());
    // Start the audio file
    mMediaPlayer.start();
    }
});

(1)为 listView 设置监听器,传入 AdapterView.OnItemClickListener 接口函数,并在行内定义一个抽象 method (Override onItemClick),它被称作匿名类 (Anonymous Class)匿名类只能访问 Activity 的全局变量或声明为 final 的本地变量,所以这里用到 words 就要把 ArrayList 的声明前添加 final 关键词:

// Create a list of words
final ArrayList<Word> words = new ArrayList<>();

如果一个接口需要定义多个抽象 method,那么一般采用在单独的文件新建 class,再调用构造函数的方式。

(2)onItemClick callback 有四个输入参数:

  • AdapterView: 点击事件发生的 AdapterView,在这里是 listView;
  • View: AdapterView 内被点击的项,在这里可以是 listView 内的 TextView 或 ImageView;
  • position: AdapterView 内被点击项的位置,在这里是 ListView 的列表顺序;
  • id: AdapterView 内被点击项的数字 ID,它可以由开发者任意指定,在大多数情况下不用。

(3)在 AdapterView.OnItemClickListener 类内调用 MediaPlayer.create(),第一个输入参数 Context 如果直接用 this 会指向当前类,即 OnItemClickListener,所以需要用 NumbersActivity.this 指定 Activity 才能正确传入 Context。

(4)使 ListView 的每一项都对应正确的音频文件,首先需要修改 Word 自定义类,为 Word 的构造函数添加一个 int 输入参数,传入音频资源 ID,并设置相应的 getter method。然后在 words.add() 输入 raw 资源 ID 后,就将它作为 AdapterView.OnItemClickListener 的第二个输入参数传入:

words.get(position).getAudioResourceId()

这里通过 ArrayList words 的 get(position) 获取发生点击事件的列表项,position 在上面提到是 listView 中被点击项的列表顺序;再调用 Word 类的 getAudioResourceId() 获取音频资源 ID。

Tips:
1. 添加代码后注意添加注释。
2. 字符过多时注意换行(在 Android Studio→Preferences 搜索 "right margin" 可找到:在 Editor→General→Appearance→Show right margin 勾选开启字符数指示竖线;在 Editor→Code Style→Default Options→Right margin (columns) 设置单行的字符数,默认为 100)。
3. 实现 toString() method,将整个对象当作字符串输出,通常用于调试,了解 Java 对象的状态。(将光标放在已有 method 之外,class 之内,使用快捷键 "cmd+N" 自动生成一个 method,支持 getter、setter、toString、构造函数等)
Word 类自动生成的 toString() method 如下。

/**
* Returns the string representation of the {@link Word} object.
*/
@Override
public String toString() {
    return "Word{" +
           "mDefaultTranslation='" + mDefaultTranslation + '\'' +
           ", mMiwokTranslation='" + mMiwokTranslation + '\'' +
           ", mAudioResourceId=" + mAudioResourceId +
           ", mImageResourceId=" + mImageResourceId +
           '}';
}

使用 Log 语句调试时,可以如下应用:

Log.v("NumbersActivity", "Current word: " + word);

这里字符串连接的是 Word 对象本身,Java 会在后台调用其 toString() method,所以在这里 wordword.toString() 的结果是相同的。

至此,Miwok App 的音频功能已经实现了,但是正如前面提到的,这离正确使用媒体播放 (Media Playback) 还很远,下面将朝着这个方向逐步优化 Miwok App。

MediaPlayer.OnCompletionListener

通过 MediaPlayer 状态机可知,当音频播放完毕,未设置循环播放,且 OnCompletionListener 调用 onCompletion() 时,MediaPlayer 会切换到 PlaybackCompleted 状态。其中,MediaPlayer 的 OnCompletionListener 也是一个接口,它有一个抽象方法 onCompletion,需要开发者定义代码,也就是音频播放完毕时执行的代码。

/**
* This listener gets triggered when the {@link MediaPlayer} has completed playing the audio file.
*/
mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
    @Override
    public void onCompletion(MediaPlayer mediaPlayer) {
        // Now that the sound file has finished playing, release the media player resources.
        releaseMediaPlayer();
    }
};
  1. 这种设置监听器并定义回调函数的模式叫做异步回调 (Asynchronous Callback, abbr. Async Callback),在 Android 中是一种常见的模式。与逐行执行的同步 (Synchronous) 代码不同,异步回调的代码仅在指定事件发生时执行,在其他情况下不影响系统,应用可执行其它代码。

  2. MediaPlayer.OnCompletionListener 同样用匿名类定义了 onCompletion method,执行一条指令 releaseMediaPlayer();。正如上面提到的,音频播放完毕后释放 MediaPlayer 资源,使其切换到 End 状态。这是基于管理 Android 内存的考虑,尤其是对于移动设备而言,内存是非常珍贵的资源。当 App 占用大量资源时,设备不仅会响应变慢,还会大量消耗电池电量。与视图回收类似,节约内存的做法是在使用完资源后,释放这些资源。
    对于 MediaPlayer 而言,在新建对象时需要占用内存,播放媒体文件也需要消耗内存,所以在媒体文件播放完毕后,调用 release() 释放这些内存,使系统回收这些资源以用于其它地方。这里定义了一个 method,在调用 release() 前判断 MediaPlayer 对象是否为空 (null),使代码更健壮 (robust),供需要释放资源的地方调用:

/**
* Clean up the media player by releasing its resources.
*/
private void releaseMediaPlayer() {
    // If the media player is not null, then it may be currently playing a sound.
    if (mediaPlayer != null) {
    // Regardless of the current state of the media player, release its resources because we no longer need it.
    mMediaPlayer.release();

    // Set the media player back to null. For our code, we've decided that
    // setting the media player to null is an easy way to tell that the media player
    // is not configured to play an audio file at the moment.
    mediaPlayer = null;
    }
}

这里要求 Activity 中有一个全局 MediaPlayer 对象。

/**
* Handles playback of all the sound files
*/
private MediaPlayer mediaPlayer;
Activity Lifecycle

一般情况下,一个 Activity 对应一个屏幕,当用户离开当前屏幕或离开 App 时,Android 最终会销毁 Activity 以节省资源,这个策略会对 App 造成很大的影响。例如用户在邮件 App 编写邮件的过程中,屏幕切换到桌面或其它应用时,App 应该在 Activity 被销毁前保存草稿,否则当用户回到 App 时发现之前编写的内容消失了,这会造成很不好的体验。因此,App 应对用户随时离开 Activity 做好准备,这里就需要引入 Activity 生命周期 (Lifecycle) 的概念了。
与 MediaPlayer 的状态机类似,Activity 生命周期也有不同阶段,而且可以在不同阶段之间过渡。不同的是,Activity 的阶段切换由 Android 控制,开发者只能在阶段过渡时通过回调函数进行操作,而无法控制阶段切换

一个 Activity 的完整生命周期如上图所示。

  1. Activity 启动时,它会通过 onCreate() 进入 Created 状态,然后通过 onStart() 进入 Started 状态,这个阶段 Activity 开始在屏幕上显示,对用户可见;
  2. 接下来通过 onResume() 进入 Resumed 状态,此时 Activity 不仅对用户可见,还支持用户交互,如播放媒体文件,使用摄像头或 GPS 传感器等,只要用户一直待在这一界面,Activity 就会一直保持 Resumed 状态;
  3. 当用户切换到其它 Activity 时,它会通过 onPaused() 进入 Paused 状态,接着通过 onStop() 进入 Stopped 状态,此时 Activity 不再用户可见;
  4. 在 Stopped 状态时,如果 Android 判断 Activity 不再需要,它就会通过 onDestroyed() 销毁 Activity 以释放资源,使 Activity 进入 Destroyed 状态;否则,Android 会保留 Activity 在 Stopped 状态,直到用户回到 Activity,它会通过 onRestart() 回到 Started 状态。

可以看到,Activity 的每个阶段都有相应的 callback,Android Activity 文档 列出了 Activity 生命周期的所有回调函数。开发者就是通过 override 这些 callback 来操作 Activity,例如 Miwok App 中大部分代码都写在 onCreated 中,表示 Activity 在创建时进行的操作。
在 Miwok App 中,要求切换 Activity 时,释放 MediaPlayer 资源,所以需要 override onStop() 调用 releaseMediaPlayer();

@Override
protected void onStop() {
    super.onStop();
    // Release MediaPlayer resource
    releaseMediaPlayer();
}
  1. 在 class 内输入快捷键 "ctrl+O" 打开对话框,快速新建 method;
  2. super.onStop();
    这条指令是 Android Studio 自动添加的,每个 callback 都要调用对应的超级类 callback,因为它会执行 AppCompatActivity 的代码,这才是实现 Activity 的源码,如显示窗口等。

能够释放不需要的资源对 Miwok App 来说是一大优化,不过它还能做得更好。目前 Miwok App 还没有引入任何 MediaPlayer 音频播放的管理,这会导致一些问题,下面详细描述这一点。

Audio Focus

在 Android 设备上,如果 App 不对音频播放进行管理,那么可能就会发生两个音乐应用同时播放,或者用户收到来电时音乐仍在播放的问题。为了避免这种情况,做一个 Android 良民,App 需要使用 Audio Focus 来管理音频播放。
引入 Audio Focus 后,只有获取 Audio Focus 的 App 才能播放音频,失去 Audio Focus 时需要暂停或停止播放,具体的操作可以阅读这篇博客,里面提到:
(1)调用 AudioManager 的 requestAudioFocus() 来请求 Audio Focus;
(2)调用 AudioManger 的 abandonAudioFocus() 来释放 Audio Focus;
(3)当 Audio Focus 状态改变时,新建 AudioManger.OnAudioFocusChangeListener 接口对象,定义 onAudioFocusChange 抽象 method 来作出响应。

可以看出,Audio Focus 事实上是由 AudioManager 提供支持的。

  1. 获取 AudioManager
// Create and setup the {@link AudioManager} to request audio focus
AudioManager mAudioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);

从上面的代码可以看到,AudioManager 是一项系统服务 (System Service)。系统服务可以为 App 提供常用功能,如通知、闹钟管理器服务;有些服务可访问设备硬件,如位置信息管理器。AudioManger 就是为 App 提供音频管理服务的。但说到底,系统服务也只是一个 Java class,App 通过对象实例化然后调用其 method 来获取各种功能。类似的,API (Application Programming Interface) 就是 Android 框架提供给开发者的 class 和 method。

  1. 调用 requestAudioFocus() 请求 Audio Focus
// Request audio focus so in order to play the audio file. The app needs to play a
// short audio file, so we will request audio focus with a short amount of time
// with AUDIOFOCUS_GAIN_TRANSIENT.
int result = mAudioManager.requestAudioFocus(mOnAudioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);

requestAudioFocus() 有三个输入参数:

(1)AudioManager.OnAudioFocusChangeListener: 在 Audio Focus 状态改变时响应的监听器,在对象实例化后传入。

(2)streamType: 音频文件的类型,从 API 提供的类型中选择,它们是全部大写的常量,如

  • STREAM_ALARM: 闹钟类型的音频
  • STREAM_MUSIC: 音乐类型的音频
  • STREAM_RING: 铃声类型的音频

(3)durationHint: 音频文件的长度声明,从 API 提供的类型中选择,它们是全部大写的常量,有

  • AUDIOFOCUS_GAIN_TRANSIENT: 声明暂时获取 Audio Focus,持续时间短,例如消息提示音。
  • AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: 声明暂时获取 Audio Focus,持续时间短,同时可接受其它应用降低音量后 (ducking) 继续播放,例如导航提示语音。
  • AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE: 声明暂时获取 Audio Focus,持续时间短,期间不允许其它应用或系统声音,例如语音助手。
  • AUDIOFOCUS_GAIN: 获取未知持续时间的 Audio Focus,即一直占用 Audio Focus 直到失去它。

requestAudioFocus() 的返回值为 AUDIOFOCUS_REQUEST_FAILED (0) 或 AUDIOFOCUS_REQUEST_GRANTED (1),指示是否成功获取 Audio Focus。

Note:
上述请求 Audio Focus 的 method 在 API 26 中已被弃用,应使用以下 method:

int requestAudioFocus (AudioFocusRequest focusRequest)

(1)输入参数:AudioFocusRequest 的对象实例,不能为 null
(2)返回值:与上述 method 类似,但多了 AUDIOFOCUS_REQUEST_DELAYED,表示延迟获取 Audio Focus(需要设置 setAcceptsDelayedFocusGain 为 true)

  1. 当 Audio Focus 状态改变时作出响应
/**
* This listener gets triggered whenever the audio focus changes
* (i.e., we gain or lose audio focus because of another app or device).
*/
private AudioManager.OnAudioFocusChangeListener mOnAudioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
    @Override
    public void onAudioFocusChange(int focusChange) {
        if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || 
            focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
            // The AUDIOFOCUS_LOSS_TRANSIENT case means that we've lost audio focus for a
            // short amount of time. The AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK case means that
            // our app is allowed to continue playing sound but at a lower volume. We'll treat
            // both cases the same way because our app is playing short sound files.

            // Pause playback and reset player to the start of the file. That way, we can
            // play the word from the beginning when we resume playback.
            mMediaPlayer.pause();
            mMediaPlayer.seekTo(0);
        } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
            // The AUDIOFOCUS_GAIN case means we have regained focus and can resume playback.
            mMediaPlayer.start();
        } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
            // The AUDIOFOCUS_LOSS case means we've lost audio focus and
            // Stop playback and clean up resources
            releaseMediaPlayer();
        }
    }
};

与 AdapterView 的 OnItemClickListener 接口和 MediaPlayer 的 OnCompletionListener 接口类似,AudioManger 的 OnAudioFocusChangeListener 接口也有一个抽象 method: onAudioFocusChange,只有一个输入参数:focusChange,表示 Audio Focus 变化的四种状态,它们是全部大写的常量:

  • AUDIOFOCUS_GAIN: 重新获取 Audio Focus,此时应该恢复播放。
  • AUDIOFOCUS_LOSS: 永久失去 Audio Focus,此时应该停止播放,但考虑到用户误触其它应用的播放按钮的情况,可先暂停一段时间,如果用户一直没有回到 App 再停止播放。
  • AUDIOFOCUS_LOSS_TRANSIENT: 暂时失去 Audio Focus,此时应该暂停播放,等待重新获取 Audio Focus。
  • AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: 暂时失去 Audio Focus,但是期间允许降低音量后 (ducking) 继续播放;根据不同应用的需求,也可以暂停播放,如 Miwok App 就选择暂停并重置音频,因为单词的发音需要清晰地传递给用户。

开发者就是根据这个参数(引用时在常量名前添加类名 AudioManager)做出不同的响应的。与前两者不同,这里没有匿名类,而是另外新建 method,保持代码模块化,不会局部臃肿。

  1. 调用 abandonAudioFocus() 释放 Audio Focus
// Regardless of whether or not we were granted audio focus, abandon it. This also
// unregisters the AudioFocusChangeListener so we don't get anymore callbacks.
mAudioManager.abandonAudioFocus(mOnAudioFocusChangeListener);

输入参数传入 AudioManager.OnAudioFocusChangeListener 的对象实例即可。

Note:
上述释放 Audio Focus 的 method 在 API 26 中已被弃用,应使用以下 method:

int abandonAudioFocusRequest (AudioFocusRequest focusRequest)

输入参数为 AudioFocusRequest 的对象实例,与获取 Audio Focus 时的相同,不能为 null。

至此,Miwok App 对 MediaPlayer 做了资源管理,也对音频播放做了管理,这基本上就已经优化完全了,最后完成几点视觉优化。

触摸反馈

触摸反馈是指在用户与 UI 元素交互的接触点为用户提供一种即时外观确认。 App 一定要有触摸反馈,这样可以让 App 看起来响应速度很快。在 Android Lollipop 5.0 采用 Material Design 后,触摸反馈是圆形涟漪的动画效果,在先前的 Android 版本上则是静态的彩色按下反馈。为视图添加触摸反馈的一个简单做法是,利用 Android 特性,设置背景为

android:background="?android:attr/selectableItemBackground"

这样就使视图具有触摸反馈了,但同时视图背景也变成了透明的。简单的解决办法是将每个视图放进 FrameLayout 中,在 FrameLayout 中设置背景颜色。

<FrameLayout
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:background="@color/category_numbers">
   <TextView
       android:id="@+id/numbers"
       style="@style/CategoryStyle"
       android:background="?android:attr/selectableItemBackground"
       android:text="@string/category_numbers" />
</FrameLayout>

FrameLayout 被设计用于分隔出一部分屏幕来显示单个视图,以保证该视图在不同尺寸的屏幕上能够完整显示,而不会与其它视图重叠。所以通常 FrameLayout 只有一个子视图,当然也可以有多个子视图,这种情况下可以通过设置 gravity 来控制它们的位置)。

上面是为视图添加触摸反馈的一个简单方法,可以看到这种方法向视图层级结构中引入了更多视图,因此效率不高。事实上,圆形涟漪的动画触摸反馈是由 RippleDrawable class (API level 21) 实现的,开发者可以自定义反馈效果,如设置涟漪效果的显示范围或持续时间。上面用到的 selectableItemBackground 特性就是由 RippleDrawable 实现的,具体介绍会在 进阶 课程中出现,这里暂时不做解释。

针对 ListView,设置 android:drawSelectorOnTop 为 true 也可以使每个列表项都将显示按下状态。

<?xml version="1.0" encoding="utf-8"?>
<ListView 
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/list"
   android:orientation="vertical"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:drawSelectorOnTop="true"/>

Tips:
Material Design 提供了超过 900 个图标供开发者免费使用,并且提供黑白两色,18dp、24dp、36dp、48dp 四组大小选择,图标格式包括 SVG、PNGS、ICON FONT。图标按照不同分辨率分类放在不同文件夹内。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • 本人初学Android,最近做了一个实现安卓简单音乐播放功能的播放器,收获不少,于是便记录下来自己的思路与知识总结...
    落日柳风阅读 18,816评论 2 41
  • Media Playback Android多媒体框架包涵了对播放多种通用媒体的类型的支持,所以你可以很容易的集成...
    VegetableAD阅读 847评论 0 0
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,209评论 0 17
  • 世界上有两种人,一种向往宁静、悠远、离群索居的生活,另一种喜欢热闹、多样性、在人群中扎堆。前一种人,好比中国的陶渊...
    娃娃爱天下阅读 323评论 0 0