安卓 Processing 教程(二)
五、触摸屏交互
本章将详细介绍 Android 处理中的触摸屏支持。我们将学习如何在草图中捕捉单点和多点触摸事件,如何处理这些事件以实现基于触摸的交互,如选择、滚动、滑动和挤压,以及如何使用虚拟键盘。
Android 中的触摸事件
在这一章中,我们谈到了一个专门针对移动设备的话题。自 2007 年推出 iPhone 以来,触摸屏已经成为与智能手机、平板电脑和可穿戴设备交互的主要机制。旧手机通常包括一个物理键盘,但今天这些已经很少了,键盘输入是通过虚拟或软件键盘实现的。
触摸屏交互非常直接和直观,在将手势作为体验核心部分的应用中非常有用(例如,笔记和绘图应用)。触摸的物理特性使其成为移动设备上创意应用的理想交互方式。
Android 系统提供了对触摸屏交互的全面支持,从单点触摸事件、用手指触发的多点触摸手势到手写笔输入。由于其通用性,Android 中的触摸 API 可能很难使用,因此 Android 的处理用一个更简单的 API 包装了这种复杂性,尽管它可能不会覆盖所有的触摸屏功能,但它使创建广泛的基于触摸的交互成为可能。
基本触摸事件
从最早的版本开始,Java 处理模式就包含了变量和函数来处理与鼠标的交互。所有这些变量和函数在 Android 模式下也是可用的,它们的工作方式与最初的 Java 模式非常相似,至少对于单触事件是如此。当然,不同之处在于事件是由我们的手指按下触摸屏而不是鼠标的移动触发的。我们在前面的章节中使用了一些鼠标 API 特别是,mouseX和mouseY变量——来跟踪触摸点的位置。清单 5-1 展示了这个 API 的一个基本例子,在这里我们控制一些形状的位置(图 5-1 )。
图 5-1。
Simple use of mouseX and mouseY variables to track touch position
void setup() {
fullScreen();
strokeWeight(20);
fill(#3B91FF);
}
void draw() {
background(#FFD53B);
stroke(#3B91FF);
line(0, 0, mouseX, mouseY);
line(width, 0, mouseX, mouseY);
line(width, height, mouseX, mouseY);
line(0, height, mouseX, mouseY);
noStroke();
ellipse(mouseX, mouseY, 200, 200);
}
Listing 5-1.Simple Touch Event Using the Mouse Variables
与实际鼠标的一个重要区别是存在“按下”状态:我们可以在不按下任何按钮的情况下移动鼠标,当我们按下鼠标时,会触发一个“拖动”事件,直到我们松开按钮。对于触摸屏,“鼠标”在移动时总是处于“按下”状态。这种差异使得典型的基于鼠标的交互——悬停——无效,当我们在屏幕的预定义区域内移动鼠标但不按任何按钮时,就会发生悬停。
当触摸开始/结束时,或者当触摸点改变位置时,我们可以精确地执行特定的任务。每当这些事件发生时,处理都会自动调用函数mousePressed()、mousedDragged()和mouseReleased(),因此我们可以在其中实现我们的事件处理功能。例如,在清单 5-2 中,只要我们在屏幕上拖动手指,我们就会在鼠标位置绘制一个不断增长的椭圆。此外,请注意使用displayDensity来缩放椭圆的初始半径及其在拖动时的常规增量,这样无论设备的 DPI 如何,它都显示相同的大小。
boolean drawing = false;
float radius;
void setup() {
fullScreen();
noStroke();
fill(100, 100);
}
void draw() {
background(255);
if (drawing) {
ellipse(mouseX, mouseY, radius, radius);
}
}
void mousePressed() {
drawing = true;
radius = 70 * displayDensity;
}
void mouseReleased() {
drawing = false;
}
void mouseDragged() {
radius += 0.5 * displayDensity;
}
Listing 5-2.Detecting Press, Drag, and Release “Mouse” Events
我们可以在这个简单的例子的基础上建立一个到目前为止创建的所有椭圆的列表,并给它们分配随机的 RGB 颜色。为此,我们创建一个类来存储椭圆的位置、大小和颜色,如清单 5-3 所示。
ArrayList<Circle> circles;
Circle newCircle;
void setup() {
fullScreen();
circles = new ArrayList<Circle>();
noStroke();
}
void draw() {
background(255);
for (Circle c: circles) {
c.draw();
}
if (newCircle != null) newCircle.draw();
}
void mousePressed() {
newCircle = new Circle(mouseX, mouseY);
}
void mouseReleased() {
circles.add(newCircle);
newCircle = null;
}
void mouseDragged() {
newCircle.setPosition(mouseX, mouseY);
newCircle.grow();
}
class Circle {
color c;
float x, y, r;
Circle(float x, float y) {
this.x = x;
this.y = y;
r = 70 * displayDensity;
c = color(random(255), random(255), random(255), 100);
}
void grow() {
r += 0.5 * displayDensity;
}
void setPosition(float x, float y) {
this.x = x;
this.y = y;
}
void draw() {
fill(c);
ellipse(x, y, r, r);
}
}
Listing 5-3.Drawing Multiple Growing Ellipses with Mouse Events
除了mouseX/Y变量之外,处理还将触摸指针的先前位置存储在pmouseX/Y变量中。使用这些变量,我们可以编写一个简单的绘图草图,其中我们用一条线段连接以前和当前的鼠标位置,只要用户一直按着屏幕,就可以绘制一条连续的路径。我们可以用内置的布尔变量mousePressed来判断用户是否在按屏幕。该草图如清单 5-4 所示,用其绘制的图纸如图 5-2 所示。
图 5-2。
Generating a line drawing with our sketch
void setup() {
fullScreen();
strokeWeight(10);
stroke(100, 100);
}
void draw() {
if (mousePressed) line(pmouseX, pmouseY, mouseX, mouseY);
}
Listing 5-4.Simple Drawing Sketch Using Current and Previous Mouse Positions
先前和当前触摸位置之间的差异告诉我们手指在屏幕上滑动的速度。我们滑动得越快,这种差异就越大,所以我们可以用它来驱动物体的运动,就像我们在清单 5-3 中看到的圆圈一样。例如,圆圈的速度可以与滑动速度成比例。让我们通过添加一对速度变量(vx代表 x 方向,vy代表 y 方向)和一个setVelocity()方法到Circle类来实现这个想法,如清单 5-5 所示。
ArrayList<Circle> circles;
Circle newCircle;
void setup() {
fullScreen();
circles = new ArrayList<Circle>();
noStroke();
}
void draw() {
background(255);
for (Circle c: circles) {
c.draw();
}
if (newCircle != null) newCircle.draw();
}
void mousePressed() {
newCircle = new Circle(mouseX, mouseY);
}
void mouseReleased() {
newCircle.setVelocity(mouseX - pmouseX, mouseY - pmouseY);
circles.add(newCircle);
newCircle = null;
}
void mouseDragged() {
newCircle.setPosition(mouseX, mouseY);
newCircle.grow();
}
class Circle {
color c;
float x, y, r, vx, vy;
Circle(float x, float y) {
this.x = x;
this.y = y;
r = 70 * displayDensity;
c = color(random(255), random(255), random(255), 100);
}
void grow() {
r += 0.5 * displayDensity;
}
void setPosition(float x, float y) {
this.x = x;
this.y = y;
}
void setVelocity(float vx, float vy) {
this.vx = vx;
this.vy = vy;
}
void draw() {
x += vx;
y += vy;
if (x < 0 || x > width) vx = -vx;
if (y < 0 || y > height) vy = -vy;
fill(c);
ellipse(x, y, r, r);
}
}
Listing 5-5.Using the Difference Between Current and Previous Mouse Positions to Calculate the Velocity of Graphical Elements in Our Sketch
尽管在这个草图的原始版本中,圆圈在我们释放触摸后立即停止移动,但它们现在继续以与滑动速度成比例的速度沿着滑动方向移动,因为我们一直将vx和vy添加到它们的当前位置。此外,通过if (x < 0 || x > width) vx = -vx;和if (y < 0 || y > height) vy = -vy;线,我们实现了一个非常简单的碰撞检测算法,其中如果一个圆移动经过屏幕的边缘,它的速度就会反转,这样它的移动就会反向朝向屏幕的内部。换句话说,圆圈在屏幕边缘反弹。
作为对这个例子的最后补充,我们将实现一个 Clear 按钮。由于我们每次触摸屏幕时都不断添加圆圈,最终屏幕会变得杂乱无章。按钮只是屏幕上的一个矩形区域,当按下时会触发一些动作,在这种情况下,会删除我们从开始添加的所有圆圈。事实上,我们不需要太多额外的代码来实现这个按钮。清单 5-6 显示了我们需要在draw()和mouseReleased()中加入什么来绘制和触发按钮(图 5-3 )。
图 5-3。
Outcome of the circle-drawing sketch, complete with a Clear button
ArrayList<Circle> circles;
Circle newCircle;
float buttonHeight = 200 * displayDensity;
...
void draw() {
background(255);
for (Circle c: circles) {
c.draw();
}
if (newCircle != null) newCircle.draw();
fill(100, 180);
rect(0, height - buttonHeight, width, buttonHeight);
fill(80);
text("Touch this area to clear", 0, height - buttonHeight, width, buttonHeight);
}
...
void mouseReleased() {
newCircle.setVelocity(mouseX - pmouseX, mouseY - pmouseY);
circles.add(newCircle);
newCircle = null;
if (height - buttonHeight < mouseY) circles.clear();
}
...
Listing 5-6.Implementation of a Simple Clear Button
这个例子向我们展示了单触式事件可以走多远,以及如何在我们的应用中使用它们来控制移动和交互。我们可以将这些技术扩展到具有更多界面动作和对象行为的更复杂的情况。
多点触摸事件
我们已经学习了如何使用继承自处理 Java 的鼠标 API 来处理单触事件。然而,Android 设备上的触摸屏可以同时跟踪几个触摸点,最大值由屏幕的功能决定。一些设备可以同时跟踪多达十个触摸点。
处理包括touches数组来提供关于触摸点的信息。该数组中的每个元素都包含一个唯一的数字标识符,允许我们跨连续帧跟踪指针,并检索其当前的 x 和 y 坐标,以及指针的压力和面积。手机和平板电脑上的电容式触摸屏不仅能够测量触摸点的位置,还能测量我们施加在屏幕上的压力。面积是指针大小的近似度量,它与压力有关,因为我们越用力将手指按在屏幕上,接触面积就应该越大。
每次检测到新的触摸点时,处理将触发startTouch()功能。反之,当一个触摸点被释放时,endTouch()将被调用。与针对单次触摸事件的mouseDragged()功能类似,每次当前触摸点改变位置时,都会调用touchMoved()功能。同样,类似于mousePressed,有一个touchPressed逻辑变量,根据是否检测到至少一个触摸点来存储真或假。清单 5-7 展示了所有这些功能,其输出在图 5-4 中显示了多个触摸点。
图 5-4。
Output of simple multi-touch example Note
压力和面积以 0 到 1 之间的标准值给出,需要根据屏幕分辨率(压力)和触摸屏校准(面积)进行缩放。
void setup() {
fullScreen();
noStroke();
colorMode(HSB, 350, 100, 100);
textFont(createFont("SansSerif", displayDensity * 24));
}
void draw() {
background(30, 0, 100);
fill(30, 0, 20);
text("Number of touch points: " + touches.length, 20, displayDensity * 50);
for (int i = 0; i < touches.length; i++) {
float s = displayDensity * map(touches[i].area, 0, 1, 30, 300);
fill(30, map(touches[i].pressure, 0.6, 1.6, 0, 100), 70, 200);
ellipse(touches[i].x, touches[i].y, s, s);
}
}
void touchStarted() {
println("Touch started");
}
void touchEnded() {
println("Touch ended");
}
void touchMoved() {
println("Touch moved");
}
Listing 5-7.Accessing Properties of Multiple Touch Points
用于转换标准化面积和压力值的映射是特定于设备的。在这种情况下,大小范围从 0 到 1,这是在 Nexus 5X 中观察到的范围;但是,其他设备可能有不同的范围。压力的情况类似,在同一 Nexus 设备上从 0.6 到 1.6 不等。
touches阵列中的每个触摸点都有一个唯一的 ID,我们可以用它来跟踪它的运动。触摸阵列中触摸点的索引不得用作其标识符,因为它可能从一帧到下一帧不相同(例如,一个触摸点可能是一帧中的元素 0,而下一帧中的元素 3)。另一方面,触摸 ID 对于每个触摸点都是唯一的,因为它被按下直到最终释放。
在下一个例子中,列表 5-8 ,我们将使用 touch ID 创建一个多点触摸绘画草图。每个手指将控制一个画笔,该画笔使用由触摸点的索引确定的 HSB 颜色绘制一个圆。这个想法是将这些画笔对象存储在一个哈希映射中,哈希映射是一种数据结构,也称为字典( https://developer.android.com/reference/java/util/HashMap.html ),我们可以使用它将值(在本例中为画笔)与唯一的键(触摸 id)相关联。
在这段代码中,当在touchStarted()函数中检测到触摸时,我们向哈希表添加一个新的画笔,当调用touchEnded()时,在touches数组中找不到现有画笔的键(ID)时,我们删除现有画笔。每当一个动作触发了touchMoved()功能,我们就更新所有的笔刷。该草图的典型输出如图 5-5 所示。
图 5-5。
Multi-touch painting
import java.util.*;
HashMap<Integer, Brush> brushes;
void setup() {
fullScreen();
brushes = new HashMap<Integer, Brush>();
noStroke();
colorMode(HSB, 360, 100, 100);
background(0, 0, 100);
}
void draw() {
for (Brush b: brushes.values()) b.draw();
}
void touchStarted() {
for (int i = 0; i < touches.length; i++) {
if (!brushes.containsKey(touches[i].id)) {
brushes.put(touches[i].id, new Brush(i));
}
}
}
void touchEnded() {
Set<Integer> ids = new HashSet<Integer>(brushes.keySet());
for (int id: ids) {
boolean found = false;
for (int i = 0; i < touches.length; i++) {
if (touches[i].id == id) found = true;
}
if (!found) brushes.remove(id);
}
}
void touchMoved() {
for (int i = 0; i < touches.length; i++) {
Brush b = brushes.get(touches[i].id);
b.update(touches[i].x, touches[i].y, touches[i].area);
}
}
class Brush {
color c;
float x, y, s;
Brush(int index) {
c = color(map(index, 0, 10, 0, 360), 60, 75, 100);
}
void update(float x, float y, float s) {
this.x = x;
this.y = y;
this.s = map(s, 0, 1, 50, 500);
}
void draw() {
fill(c);
ellipse(x, y, s, s);
}
}
Listing 5-8.Painting with Multiple Brushes
有几件重要的事情需要注意。首先,我们可以确定touchStarted()和touchEnded()只有在新的触摸点分别向下或向上时才会被调用。所以,我们在这些函数中需要做的就是识别哪个是传入指针,哪个是传出指针。在触摸释放的情况下,我们迭代哈希表中的所有当前键,直到我们在touches数组中找到一个不对应于有效 id 的键。因为我们在遍历哈希表的键时修改了哈希表,所以我们需要用Set<Integer> ids = new HashSet<Integer>(brushes.keySet());创建一个原始键集的副本,然后执行搜索和删除操作。
基于触摸的交互
为移动应用创建一个直观且吸引人的界面并不容易;它需要理解用户界面(UI)原理、实践和大量的迭代。除了低级的单点和多点触摸处理功能,Android 的处理不提供任何内置的 UI 功能,因此我们有很大的自由来定义我们的应用将如何管理与用户的交互。在这一节中,我们将回顾一些基本技术,这些技术可以应用于许多不同的情况。
形状选择
回到第四章,我们回顾了使用PShape对象存储复杂的 SVG 形状,并通过 P2D 或 P3D 渲染器增加帧速率。由于 SVG 形状由子形状组成,我们可能希望通过触摸单独选择这些子形状,因此了解如何执行测试以确定触摸点是否落在PShape对象内是很有用的。如果我们正在处理一个基本的形状,比如一个矩形或者一个圆形,我们可以编写一个简单的针对这个形状的测试;然而,对于不规则的形状,比如地图上的国家,我们需要一个更通用的方法。PShape类有一个名为getTessellation()的函数,它返回一个与源形状完全相同的新形状,但仅由三角形组成(这个三角形集合决定了更复杂形状的“镶嵌”)。由于很容易确定一个点是否落在一个三角形内( http://blackpawn.com/texts/pointinpoly/default.html ),我们可以检查鼠标或触摸位置是否落在镶嵌的任何三角形内,如果是,我们可以断定已经选择了较大的形状。这就是我们在清单 5-9 中所做的,其结果如图 5-6 所示。
图 5-6。
Selecting a country inside an SVG shape with touch events
PShape world, country;
void setup() {
fullScreen(P2D);
orientation(LANDSCAPE);
world = loadShape("World-map.svg");
world.scale(width / world.width);
}
void draw() {
background(255);
if (mousePressed) {
if (country != null) country.setFill(color(0));
for (PShape child: world.getChildren()) {
if (child.getVertexCount() == 0) continue;
PShape tess = child.getTessellation();
boolean inside = false;
for (int i = 0; i < tess.getVertexCount(); i += 3) {
PVector v0 = tess.getVertex(i);
PVector v1 = tess.getVertex(i + 1);
PVector v2 = tess.getVertex(i + 2);
if (insideTriangle(new PVector(mouseX, mouseY), v0, v1, v2)) {
inside = true;
country = child;
break;
}
}
if (inside) {
country.setFill(color(255, 0, 0));
break;
}
}
}
shape(world);
}
boolean insideTriangle(PVector pt, PVector v1, PVector v2, PVector v3) {
boolean b1, b2, b3;
b1 = sign(pt, v1, v2) < 0.0f;
b2 = sign(pt, v2, v3) < 0.0f;
b3 = sign(pt, v3, v1) < 0.0f;
return ((b1 == b2) && (b2 == b3));
}
float sign (PVector p1, PVector p2, PVector p3) {
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
}
Listing 5-9.Selecting a Child Shape Inside a Group Shape with Touch Events
卷动
滚动是与移动设备交互的另一种基本模式。由于相对于笔记本电脑和其他计算机来说,它们的屏幕尺寸较小,所以信息通常不能一次显示在一个页面上。由沿屏幕边缘的触摸位移控制的(水平或垂直)滚动条是最常见的滚动功能。
清单 5-10 中的代码示例包括一个非常简单的垂直scrollbar类,它跟踪沿 y 轴的位移,以便平移图形元素,从而显示应该可见的元素。这个滚动条实现的关键部分是计算所有元素的总高度,并使用它来确定我们可以向下滚动滚动条多远,直到到达最后一个元素。该类中的update()方法获取鼠标/触摸拖动的量,并更新变量translateY,该变量包含垂直平移。
ScrollBar scrollbar;
int numItems = 20;
void setup() {
fullScreen(P2D);
orientation(PORTRAIT);
scrollbar = new ScrollBar(0.2 * height * numItems, 0.1 * width);
noStroke();
}
void draw() {
background(255);
pushMatrix();
translate(0, scrollbar.translateY);
for (int i = 0; i < numItems; i++) {
fill(map(i, 0, numItems - 1, 220, 0));
rect(20, i * 0.2 * height + 20, width - 40, 0.2 * height - 20);
}
popMatrix();
scrollbar.draw();
}
public void mousePressed() {
scrollbar.open();
}
public void mouseDragged() {
scrollbar.update(mouseY - pmouseY);
}
void mouseReleased() {
scrollbar.close();
}
class ScrollBar {
float totalHeight;
float translateY;
float opacity;
float barWidth;
ScrollBar(float h, float w) {
totalHeight = h;
barWidth = w;
translateY = 0;
opacity = 0;
}
void open() {
opacity = 150;
}
void close() {
opacity = 0;
}
void update(float dy) {
if (totalHeight + translateY + dy > height) {
translateY += dy;
if (translateY > 0) translateY = 0;
}
}
void draw() {
if (0 < opacity) {
float frac = (height / totalHeight);
float x = width - 1.5 * barWidth;
float y = PApplet.map(translateY / totalHeight, -1, 0, height, 0);
float w = barWidth;
float h = frac * height;
pushStyle();
fill(150, opacity);
rect(x, y, w, h, 0.2 * w);
popStyle();
}
}
}
Listing 5-10.Implementing a Scrolling Bar
条件totalHeight + translateY + dy > height确保我们不会滚动到列表中的最后一个元素,而translateY > 0帮助我们避免向上滚动到屏幕顶部。我们可以在任何草图中使用这个类,只要我们能够提供想要显示的元素的总高度。图 5-7 显示了我们的滚动条的运行。
图 5-7。
Scrolling through a pre-defined list of elements
滑动和挤压
滑动和挤压手势是智能手机和平板电脑上最具特色的两种触摸屏交互方式。我们通常使用滑动或拖动在连续的元素之间翻转,如页面或图像,收缩或缩放是放大和缩小图像或部分屏幕的默认手势。
虽然 Android 的处理不会在滑动或挤压发生时触发类似于mousePressed()或touchMoved()的调用,但是我们可以在我们的处理草图中使用 Android API 来添加对这些事件的支持。谷歌的官方 Android 开发者网站有一个非常详细的部分,介绍了如何通过几个手势检测类来使用触摸手势( https://developer.android.com/training/gestures/index.html )。
Android 提供了一个GestureDetector,需要与一个包含特殊“事件处理”方法的 listener 类结合使用,当检测到滑动或缩放事件时会调用该方法。为了使用这个功能,我们需要从 Android SDK 添加一些导入,然后为手势监听器编写实现。将事件处理与 Android 的处理相集成的另一个重要元素是将事件对象从处理传递给surfaceTouchEvent()函数中的手势处理程序。每次有新的触摸事件时都会调用这个函数,但是它也需要调用父实现,以便处理可以执行默认的事件处理(更新鼠标和触摸变量等等)。所有这些都显示在清单 5-11 中,我们在其中进行刷卡检测,其输出如图 5-8 所示。
图 5-8。
Detecting swipe direction
import android.os.Looper;
import android.view.MotionEvent;
import android.view.GestureDetector;
import android.view.GestureDetector.OnGestureListener;
GestureDetector detector;
PVector swipe = new PVector();
void setup() {
fullScreen();
Looper.prepare();
detector = new GestureDetector(surface.getActivity(),
new SwipeListener());
strokeWeight(20);
}
boolean surfaceTouchEvent(MotionEvent event) {
detector.onTouchEvent(event);
return super.surfaceTouchEvent(event);
}
void draw() {
background(210);
translate(width/2, height/2);
drawArrow();
}
void drawArrow() {
float x = swipe.x;
float y = swipe.y;
line(0, 0, x, y);
swipe.rotate(QUARTER_PI/2);
swipe.mult(0.85);
line(x, y, swipe.x, swipe.y);
swipe.rotate(-QUARTER_PI);
line(x, y, swipe.x, swipe.y);
swipe.rotate(QUARTER_PI/2);
swipe.mult(1/0.85);
}
class SwipeListener extends GestureDetector.SimpleOnGestureListener {
boolean onFling(MotionEvent event1, MotionEvent event2,
float velocityX, float velocityY) {
swipe.set(velocityX, velocityY);
swipe.normalize();
swipe.mult(min(width/2, height/2));
return true;
}
}
Listing 5-11.Swipe Detection Using the Android API in Processing
请注意 setup()中对 Looper.prepare()的调用。Android 的 Looper 是一个类,允许一个 app 中的主线程接收其他线程的消息( https://developer.android.com/reference/android/os/Looper.html )。在这个特殊的例子中,我们需要 Looper 从草图中读取手势事件。
我们可以用类似的方式实现缩放检测器,在清单 5-12 中,我们用它来放大和缩小图像。
import android.os.Looper;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener;
ScaleGestureDetector detector;
PImage img;
float scaleFactor = 1;
void setup() {
fullScreen();
img = loadImage("jelly.jpg");
Looper.prepare();
detector = new ScaleGestureDetector(surface.getActivity(),
new ScaleListener());
imageMode(CENTER);
}
boolean surfaceTouchEvent(MotionEvent event) {
detector.onTouchEvent(event);
return super.surfaceTouchEvent(event);
}
void draw() {
background(180);
translate(width/2, height/2);
scale(scaleFactor);
image(img, 0, 0);
}
class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
public boolean onScale(ScaleGestureDetector detector) {
scaleFactor *= detector.getScaleFactor();
scaleFactor = constrain(scaleFactor, 0.1, 5);
return true;
}
}
Listing 5-12.Zooming In and Out with a Scale Detector
使用键盘
在本章的最后,我们将描述一些在按键输入处理中可用的函数。尽管在笔记本电脑和台式电脑上,键盘和鼠标通常是不同的输入设备,但触摸屏通过“软”或虚拟键盘在很大程度上吸收了键盘的功能。
处理 Java 有几个函数来处理键盘事件,变量来检查最后按下的键,如在线语言参考( https://processing.org/reference/ )和教程( https://processing.org/tutorials/interactivity/ )中所述。所有这些功能(除了keyTyped)在安卓模式下也是可用的。
如果设备有物理键盘,为了使用键盘 API,没有什么特别的事情要做,但是在虚拟键盘的情况下,我们需要首先打开它,并在用户完成输入后关闭它。Android 模式增加了两个功能来做到这一点,openKeyboard()和closeKeyboard()。本章的最后一个例子,列出了 5-13 ,举例说明了它们的用法,以及键盘 API 中的一些其他函数。
String text = "touch the screen to type something";
boolean keyboard = false;
void setup() {
fullScreen();
textFont(createFont("Monospaced", 25 * displayDensity));
textAlign(CENTER);
fill(100);
}
void draw() {
background(200);
text(text, 0, 20, width, height - 40);
}
void keyReleased() {
if (key == DELETE || key == BACKSPACE) {
text = text.substring(text.length() - 1);
} else {
text += key;
}
}
void mouseReleased() {
if (!keyboard) {;
text = "";
openKeyboard();
keyboard = true;
} else {
closeKeyboard();
keyboard = false;
}
}
Listing 5-13.Typing Text with the Virtual Keyboard
摘要
我们已经学习了如何在处理过程中处理单点和多点触摸事件,然后我们继续研究移动设备上常用的不同交互技术(选择、滚动、滑动、挤压和缩放)。有了这些工具,我们就可以实现最适合用户需求的交互类型的用户界面。
六、实时壁纸
在经历了 Android 的绘图和交互处理的细节之后,我们将在这一章结束本书的第二部分。我们将学习如何从相机加载图像来创建画廊壁纸,然后如何使用粒子系统来实现我们的手机或平板电脑的动画背景。
动态壁纸
动态壁纸是一种特殊类型的 Android 应用,作为主屏幕和锁屏的背景运行。它们是在 Android 2.1 版本中引入的,所以今天的大多数设备都支持它们。我们可以将目前为止看到的任何绘图和交互技术应用到动态壁纸上。这使我们能够为设备创建动态背景,同时访问传感器和网络数据并对用户输入做出反应。
我们可以从前面的章节中提取任何草图,并将其作为动态壁纸运行,但要设计和实现一个成功的壁纸,我们必须考虑壁纸应用的具体特征和局限性,这将在接下来的章节中讨论。
编写和安装动态壁纸
Android 处理让我们可以非常容易地将草图作为动态壁纸运行:我们所需要做的就是在 PDE 的 Android 菜单下选择“壁纸”选项。一旦草图作为壁纸安装,它不会立即开始运行。需要通过安卓的壁纸选择器来选择。为此,长按主屏幕中的任何空闲区域,然后在接下来出现的弹出菜单中选择“设置壁纸”选项,这可能与下面的截图不同,具体取决于所使用的 Android 或 UI 皮肤。壁纸将首先在预览模式下运行,在预览模式下,我们可以确认选择或继续浏览可用的壁纸。一旦选中,壁纸将在主屏幕的背景中重启,在启动图标的后面。图 6-1 显示了这些阶段。
图 6-1。
Selecting the wallpaper option in the PDE (left), opening the wallpaper selector on the device (center), live wallpaper running in the home screen (right)
让我们考虑一下动态壁纸的几个重要方面。首先,它们覆盖了整个屏幕区域,所以我们应该用fullScreen()函数初始化它们。第二,动态壁纸在后台持续运行,因此会很快耗尽电池。因此,在打算作为壁纸运行的草图中不要使用非常繁重的计算是一个好主意。一般来说,这个建议对所有移动应用都有效,但对壁纸更是如此。减少动态壁纸电池使用的一个简单“技巧”是通过frameRate()功能设置一个较低的帧速率。使用 30 或 25 而不是默认的 60 将保持动画相当流畅,而不会以非常高的速度重绘屏幕和消耗更多的电池电量。
清单 6-1 中的代码生成了一个大小可变的椭圆网格,我们使用了fullScreen()——以确保壁纸覆盖整个屏幕——和frameRate(25),,因为屏幕不需要更新得更快。
void setup() {
fullScreen();
frameRate(25);
noStroke();
fill(#FF03F3);
}
void draw() {
background(#6268FF);
float maxRad = 50 * displayDensity;
for (int i = 0; i < width/maxRad; i++) {
float x = map(i, 0, int(width/maxRad) - 1, 0, width);
for (int j = 0; j < height/maxRad; j++) {
float y = map(j, 0, int(height/maxRad) - 1, 0, height);
float t = millis() / 1000.0;
float r = maxRad * cos(t + 0.1 * PI * i * j);
ellipse(x, y, r, r);
}
}
}
Listing 6-1.Simple Live Wallpaper
要运行动态壁纸,我们首先需要在选择器中打开它。事实上,选择器向我们展示了一个壁纸的预览实例,我们可以将它设置为背景或者取消它来预览另一个。就在我们设置壁纸之后,预览实例被系统关闭,一个非预览实例被立即启动。我们可以通过调用previewWallpaper()函数来验证壁纸是否在预览模式下运行,该函数会相应地返回 true 或 false。这种检查使我们有机会在预览模式期间执行特殊的定制,例如加载更少的资源,因为壁纸不会运行很长时间,或者显示壁纸的代表性输出。
使用多个主屏幕
在介绍更高级的壁纸示例之前,我们将通过一个显示背景图像的简单应用来了解动态壁纸的一些重要特性。我们已经看到了如何在处理中加载和显示图像。同样的方法也适用于壁纸:只需将一个图像文件复制到草图的数据文件夹,用loadImage()加载,用image()显示,这样它就覆盖了设备的整个屏幕,如清单 6-2 所示。
PImage pic;
fullScreen();
pic = loadImage("paine.jpg");
image(pic, 0, 0, width, height);
Listing 6-2.Loading and Displaying an Image in Full-screen Mode
此代码的一个问题是,如果图像与屏幕的比例不同,图像会看起来失真。此外,壁纸总是以纵向模式显示,因此横向拍摄的照片会沿垂直方向拉伸。我们可以在image()函数中设置高度,使显示图像的宽高比与其原始比例相同,如清单 6-3 所示。
PImage pic;
fullScreen();
pic = loadImage("paine.jpg");
imageMode(CENTER);
float r = float(pic.width) / float(pic.height);
float h = width/r;
image(pic, width/2, height/2, width, h);
Listing 6-3.Keeping the Image Ratio
这里我们将图像模式设置为CENTER,因此image()函数的x和y参数被取为图像的中心,这使得它在屏幕上居中变得容易。因为我们把它画成屏幕的宽度,所以我们需要使用高度width/r,其中r是图像的原始宽高比。
Note
一个PImage对象中的宽度和高度变量是整数,所以我们需要把它们转换成浮点数,用float(x)得到一个带小数点的正确比值,比如 1.6(float(1280)/float(800)的结果)或者 0.561(也就是 float(2576) / float(4592))。
然而,使用这种方法,我们可能会浪费大量的屏幕空间,尤其是当图像非常宽的时候。Android 提供了一个功能可以在这种情况下有所帮助:多个主屏幕。用户可以通过左右滑动来移动这些屏幕,壁纸将相应地向两侧移动适当的量,这是根据主屏幕的数量和这些屏幕的宽度来确定的。处理通过两个函数公开这些信息:wallpaperHomeCount()和wallpaperOffset()。这首先返回主屏幕的当前数量(随着用户添加或删除主屏幕,它可能会在壁纸的生命周期内发生变化),而第二个返回 0 和 1 之间的浮点数,对应于主屏幕的水平位移:当我们在第一个屏幕时为 0,最后一个屏幕时为 1。清单 6-4 展示了我们如何使用这些函数来创建图像滚动交互。
PImage pic;
float ratio;
void setup() {
fullScreen();
pic = loadImage("paine.jpg");
ratio = float(pic.width)/float(pic.height);
}
void draw() {
background(0);
float w = wallpaperHomeCount() * width;
float h = w/ratio;
float x = map(wallpaperOffset(), 0, 1, 0, -(wallpaperHomeCount()-1) * width);
image(pic, x, 0, w, h);
}
Listing 6-4.Image Scrolling Across Home Screens
我们使用wallpaperHomeCount() * width作为图像的显示宽度,跨越所有屏幕,当用户向右滑动时,我们将图像向左平移位移x。这样,图像的正确部分显示在当前屏幕上,平滑过渡,因为wallpaperOffset()在 0 和 1 之间连续变化(图 6-2 )。
图 6-2。
Interpretation of the offset function in live wallpapers Note
当在屏幕上滑动时,我们可能会注意到断断续续的动画,特别是在高分辨率屏幕的设备上。这可能是默认渲染器无法以平滑帧速率在全屏模式下绘制的结果。一个解决方案是切换到 P2D 渲染器,它使用 GPU 进行硬件加速渲染。
处理权限
我们已经学会了在多个主屏幕上加载图像并显示为壁纸。我们可以基于这种技术创建一个照片库壁纸,浏览手机或平板电脑上的相机拍摄的照片。但是为了加载这些照片,我们的草图需要访问设备的外部存储,并且读取外部存储的权限必须由用户明确授予。尽管我们现在将在一个墙纸示例中查看权限请求,但是这些函数可以在任何类型的草图中使用。
事实上,权限是 Android 开发的一个非常重要的方面,因为移动设备处理几种不同类型的个人数据(联系人、位置、消息),未经授权访问这些数据可能会导致隐私泄露。Android 操作系统确保每个应用都被授权访问设备中的特定数据和功能。常规权限(例如,Wi-Fi 和蓝牙访问)在用户首次安装应用时授予,而关键或“危险”权限(例如,访问摄像头、位置、麦克风、存储和身体传感器)需要在用户打开应用( https://developer.android.com/guide/topics/permissions/index.html )时授予(在使用 Android 6.0 或更新版本的设备上)。
我们的草图所需的任何权限,无论是常规权限还是危险权限,都必须通过使用 Android 权限选择器从 PDE 添加到草图中,该选择器可从 Android 菜单下的“草图权限”选项获得(参见图 6-3 )。
图 6-3。
“Sketch Permissions” option (left) and Android Permission Selector dialog (right)
对于普通权限,我们需要做的就是用权限选择器选择它们。然而,对于危险的权限,我们也必须在草图代码中用requestPermission()函数显式地请求它们。这个函数有两个参数——请求权限的名称(例如,android.permission. READ_EXTERNAL_STORAGE)和一个回调函数的名称,在我们的草图中,一旦用户授予(或拒绝)了权限,这个回调函数就会被调用。回调函数必须有一个布尔参数,这是它接收权限请求结果的地方。清单 6-5 展示了这种机制,当许可被授予时,背景色变成绿色。回调函数不一定从setup()调用,因为当草图已经在draw()函数中时,Android 系统会显示权限对话框。所以,我们应该准备好处理 draw()中缺少预期权限的情况。为此,我们可以使用 hasPermission()函数来检查作为参数传递的权限,即:has permission(" Android . permission . read _ EXTERNAL _ STORAGE "),是否已经被授予,并在每种情况下运行适当的代码。
color bckColor = #EA6411;
void setup() {
fullScreen();
requestPermission("android.permission.READ_EXTERNAL_STORAGE",
"handlePermission");
}
void draw() {
background(bckColor);
}
void handlePermission(boolean granted) {
if (granted) bckColor = #58EA11;
}
Listing 6-5.Requesting a Dangerous Permission
Note
在 https://developer.android.com/reference/android/Manifest.permission.html 可以获得每个版本 Android 的所有权限列表。
回到我们的 image-gallery 壁纸草图,除了请求读取外部存储器的许可之外,它还需要列出存储在外部存储器中的所有照片。这个功能不是处理 API 的一部分,但是我们可以从草图中访问 Android API,并导入允许我们执行这些更高级任务的 Android 包。在从保存相机拍摄的图片和视频的DCIM文件夹中列出文件的情况下,我们可以使用android.os.Environment包中的getExternalStoragePublicDirectory()方法( https://developer.android.com/reference/android/os/Environment.html )。我们需要做的就是在草图的开始导入这个包。
我们现在有了图库壁纸所需的所有部分,如清单 6-6 所示。我们现在将讨论本例中引入的新代码。
import android.os.Environment;
PImage defImage, currImage;
ArrayList<String> imageNames = new ArrayList<String>();
int lastChange;
int swapInterval = 10;
void setup() {
fullScreen();
defImage = loadImage("default.jpg");
if (!wallpaperPreview()) {
requestPermission("android.permission.READ_EXTERNAL_STORAGE",
"scanForImages");
}
loadRandomImage();
}
void draw() {
background(0);
float ratio = float(currImage.width)/float(currImage.height);
float w = wallpaperHomeCount() * width;
float h = w/ratio;
if (h < height) {
h = height;
w = ratio * h;
}
float x = map(wallpaperOffset(), 0, 1, 0, -(wallpaperHomeCount()-1) * width);
float y = (height - h)/2;
image(currImage, x, y, w, h);
int t = millis();
if (swapInterval * 1000 < t - lastChange) {
loadRandomImage();
lastChange = t;
}
}
void loadRandomImage() {
if (imageNames.size() == 0) {
currImage = defImage;
} else {
int i = int(random(1) * imageNames.size());
String fn = imageNames.get(i);
currImage = loadImage(fn);
}
}
void scanForImages(boolean grantedPermission) {
if (grantedPermission) {
File dcimDir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DCIM);
String[] subDirs = dcimDir.list();
if (subDirs == null) return;
for (String d: subDirs) {
if (d.charAt(0) == '.') continue;
File fullPath = new File (dcimDir, d);
File[] listFiles = fullPath.listFiles();
for (File f: listFiles) {
String filename = f.getAbsolutePath().toLowerCase();
if (filename.endsWith(".jpg")) imageNames.add(filename);
}
}
}
}
Listing 6-6.Image-Gallery Wallpaper
这段代码的逻辑很简单:我们有当前和默认的PImage变量(currImage和defImage)以及一个图像文件名列表(imageNames)。当授予READ_EXTERNAL_STORAGE权限时,该列表在scanForImages()函数中初始化。在这个函数中,我们获得了用getExternalStoragePublicDirectory()表示 DCIM 文件夹的File对象,然后我们用它来遍历所有子文件夹,最后我们列出了每个子文件夹的内容。文件与。jpg 扩展名添加到列表中。每十秒钟调用一次loadRandomImage(),从列表中选择一个随机文件名,将新的PImage加载到currImage中。如果列表是空的,如果许可没有被授予,草图就使用默认的图像,我们应该在运行草图之前将它添加到数据文件夹中。
正如我们在本章开始时讨论的,我们不希望在仅仅预览壁纸时向用户呈现权限对话框,这就是为什么我们只在不处于预览模式时调用requestPermission()。预览将显示默认图像,这样用户在预览壁纸时仍然能够看到图像。
粒子系统
应用开发人员经常使用动态壁纸来创建动画图形,为静态背景图像提供替代方案。然而,这些图形不能过于强烈,因为它们可能会分散用户对设备屏幕上显示的相关信息的注意力(电话、消息、来自其他应用的提醒)。
怎样才能打造出视觉上有趣又不分散注意力的壁纸?一个可能的概念是流体模拟,其中一群粒子在背景中“有机地”移动,并留下某种痕迹。有机运动将有助于保持壁纸柔和,但仍然有吸引力,增加了随机变化的成分。我们也可以结合触摸交互来驱动某种形式的粒子运动,因为我们在前一章看到的触摸 API 可用于壁纸。考虑到这些想法,这可能是在现有作品中寻找视觉灵感的好时机,从绘画到基于代码的项目(图 6-4 )。
图 6-4。
From top to bottom: “Starry Night,” by Vincent van Gogh (1889), Plate from “Processing Images and Video for An Impressionist Effect” by Peter Litwinowicz (1997), Drawing Machine #10 by Ale Gonzalez ( https://www.openprocessing.org/sketch/34320 ).
我们现在可以开始用笔和纸勾画出一些想法(图 6-5 )。一个想法是:单个粒子沿着弯曲的路径在接触点之间移动。问题是,我们如何模拟这些平滑的路径?在下一节中,我们将回顾一些技术,这些技术将允许我们实现这样的系统。
图 6-5。
Some pen and paper sketches for the particle-system wallpaper
自主代理
创建自然感觉的粒子系统的问题已经被研究了很多年。丹尼尔·希夫曼的书《代码的本质》(可在网上的 http://natureofcode.com/ 获得)很好地涵盖了这个主题(特别是第 4 和 6 章)。粒子系统允许我们模拟大量个体实体的涌现行为,每个个体都遵循简单(或更复杂)的运动规则。
粒子群具有某种程度的自主行为,由作用于它们的力决定,这在我们的项目中可能是有用的,因为我们不需要指定每个粒子的确切运动,只需要指定整体的力。克雷格·雷诺兹( http://www.red3d.com/cwr/steer/ )提出了生成转向行为的算法。在一种称为流场跟随的算法中,目标速度场(“流场”)可以将粒子的运动导向特定的位置,而不会看起来是强制的或人为的。在这个算法中,每个粒子都有一个由其加速度、速度和位置决定的动态,一个力基于当前速度和屏幕空间每个点定义的目标速度之差作用在粒子上,如图 6-6 所示。
图 6-6。
Diagram of the steering force (red) that is applied on a particle moving on a flow field of velocities (blue)
为了让这种方法正常工作,我们需要提供一个驱动粒子运动的速度流场。我们认为触摸互动是这种运动的潜在来源。例如,每当指针改变位置时,我们可以计算测量指针位置变化的向量,即(mouseX - pmouseX, mouseY – pmouseY),作为位置(mouseX, mouseY)处的“速度”。让我们在清单 6-7 中实现这种方法。在这个例子中,我们将使用两个类来组织代码——一个存储每个粒子,另一个保存整个场。我们可以在 Java 模式下运行它,也可以在我们的设备或模拟器上作为常规应用运行。输出应该看起来或多或少如图 6-7 所示。
图 6-7。
Particle movement steered by a flow field derived from changes in touch position
ArrayList<Particle> particles;
Field field;
void setup() {
size(1200, 600);
field = new Field();
particles = new ArrayList<Particle>();
}
void draw() {
background(255);
field.display();
for (int i = particles.size() - 1; i >= 0; i--) {
Particle p = particles.get(i);
p.update(field);
p.display();
if (p.dead()) particles.remove(i);
}
}
void mouseDragged() {
field.update(mouseX, mouseY, mouseX - pmouseX, mouseY - pmouseY);
particles.add(new Particle(mouseX, mouseY));
}
class Particle {
PVector position;
PVector velocity;
PVector acceleration;
float size;
int life;
float maxAccel;
float maxSpeed;
int maxLife;
Particle(float x, float y) {
position = new PVector(x, y);
size = random(15, 25);
velocity = new PVector(0, 0);
acceleration = new PVector(0, 0);
maxSpeed = random(2, 5);
maxAccel = random(0.1, 0.5);
maxLife = int(random(100, 200));
}
boolean dead() {
return maxLife < life;
}
public void setPosition(float x, float y) {
position.set(x, y);
}
void update(Field flow) {
PVector desired = flow.lookup(int(position.x), int(position.y));
acceleration.x = maxSpeed * desired.x - velocity.x;
acceleration.y = maxSpeed * desired.y - velocity.y;
acceleration.limit(maxAccel);
velocity.add(acceleration);
velocity.limit(maxSpeed);
position.add(velocity);
life++;
}
void display() {
noStroke();
fill(180, 150);
ellipse(position.x, position.y, size, size);
}
}
class Field {
PVector[][] field;
Field() {
field = new PVector[width][height];
for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
field[i][j] = new PVector(0, 0);
}
}
}
void update(int x, int y, float vx, float vy) {
for (int i = max(0, x - 20); i < min(x + 20, width); i++) {
for (int j = max(0, y - 20); j < min(y + 20, height); j++) {
PVector v = field[i][j];
v.set(vx, vy);
v.normalize();
}
}
}
PVector lookup(int x, int y) {
return field[x][y];
}
void display() {
int resolution = 20;
int cols = width / resolution;
int rows = height / resolution;
for (int i = 1; i < cols; i++) {
for (int j = 1; j < rows; j++) {
int x = i * resolution;
int y = j * resolution;
PVector v = lookup(x, y);
pushMatrix();
translate(x, y);
stroke(28, 117, 188);
strokeWeight(2);
rotate(v.heading());
float len = v.mag() * (resolution - 2);
if (0 < len) {
float arrowsize = 8;
line(0, 0, len, 0);
line(len, 0, len-arrowsize, +arrowsize/2);
line(len, 0, len-arrowsize, -arrowsize/2);
}
popMatrix();
}
}
}
}
Listing 6-7.Particle System with Flow Field Calculated from Touch or Mouse Events
这段代码中发生了几件事,所以让我们一步一步地回顾一下。在draw()函数中,我们首先显示流场,然后迭代粒子列表,根据当前场更新它们的位置。我们展示它们,并最终移除那些被标记为“死亡”的请注意,我们以相反的顺序迭代列表,因为我们在迭代的同时删除了列表中的元素。如果我们执行一个普通的正向循环,我们可能会越过列表的末尾(因为开始循环时它的大小小于末尾的大小),从而导致运行时错误。在mouseDragged()中,我们使用当前和上一个位置之间的差异来更新当前指针位置处的场,并且还添加了一个新粒子。
Particle类跟踪粒子的动态状态,包括它的位置、速度和加速度,以及它的“寿命”每次我们调用update()方法时,我们从粒子当前位置的流场获得所需的速度,然后计算转向力产生的加速度,根据雷诺公式,该加速度是所需速度和当前速度之差。maxSpeed因子允许我们改变粒子的运动,因为它是在Particle构造器中随机确定的,导致一些粒子运动得更快,另一些运动得更慢。
Field类有一个PVector对象的数组,带有width × height元素,包含屏幕每个像素的流场值。该字段最初到处都是零,但是我们使用update()方法将其设置为从触摸或鼠标事件中获得的值。请注意,在这种方法中,我们不仅仅在(x,y)位置设置场向量,而是在以(x,y)为中心的矩形中设置场向量,因为否则向量场的变化只会影响非常小的区域——不足以控制粒子穿过屏幕。最后,display()方法也需要注意,因为我们不能在屏幕上画出所有像素的场矢量。我们使用一个更大的网格,这样我们就可以为每个尺寸为resolution的矩形绘制一个矢量。PVector类中的方法,比如heading(),在这里可以方便地将向量绘制成小箭头,这有助于可视化流向。
虽然这个例子是一个简单的操纵行为的应用,我们可以用许多不同的方式来改进,但一个问题是它需要持续的触摸输入来保持系统的发展。虽然这对于常规应用来说没问题,但如此依赖壁纸中的触摸交互可能会有问题,因为主屏幕上的触摸主要用于驱动与 UI 的交互。在粒子中创建运动的拖动可能会被误认为是改变主屏幕的滑动,反之亦然。我们将在下一节中看到如何生成不需要触摸交互的流场。
图像流场
有几种方法我们可以生成一个平滑的流场,给我们的粒子系统足够的视觉可变性。一种可能是使用由柏林噪声(mrl.nyu.edu/∼perlin/doc/oscar.html)生成的场,柏林噪声是一种合成生成的随机噪声,它平滑地变化并产生看起来更有机的图案。另一种可能是使用图像。事实证明,我们可以转换图像中每个像素位置的颜色信息来计算流场的速度矢量。
更具体地说,如果我们用brightness()函数计算一种颜色的亮度,我们得到一个介于 0(黑色)和 1(白色)之间的数,我们可以用它作为速度矢量相对于 x 轴的角度。这会产生一个平滑的速度流场,遵循图像的特征(边缘、颜色漩涡等)。).我们可以使用 Processing 的PVector类中的fromAngle(theta)函数来轻松计算向量。从图像中生成速度场时,另一个非常重要的功能是访问图像中各个像素的能力。一旦我们调用了loadPixels()函数,我们就可以用任何PImage对象都可用的pixels数组来做到这一点。我们在清单 6-8 中组合了所有这些,它加载一个图像并生成相关的流场,如图 6-8 所示。
图 6-8。
Flow field generated from an image
PImage img;
void setup() {
fullScreen();
img = loadImage("jupiter.jpg");
img.loadPixels();
}
void draw() {
image(img, 0, 0, width, height);
int resolution = 30;
int cols = width / resolution;
int rows = height / resolution;
for (int i = 1; i < cols; i++) {
for (int j = 1; j < rows; j++) {
int x = i * resolution;
int y = j * resolution;
int ix = int(map(x, 0, width, 0, img.width - 1));
int iy = int(map(y, 0, height, 0, img.height - 1));
int idx = ix + iy * img.width;
int c = img.pixels[idx];
float theta = map(brightness(c), 0, 255, 0, TWO_PI);
PVector v = PVector.fromAngle(theta);
drawArrow(x, y, v, resolution-2);
}
}
}
void drawArrow(float x, float y, PVector v, float l) {
pushMatrix();
float arrowsize = 8;
translate(x, y);
strokeWeight(2);
stroke(28, 117, 188);
rotate(v.heading());
float len = v.mag() * l;
line(0, 0, len, 0);
line(len, 0, len-arrowsize, +arrowsize/2);
line(len, 0, len-arrowsize, -arrowsize/2);
popMatrix();
}
Listing 6-8.Code That Generates a Flow Field from an Image
在这个例子中,我们需要访问图像中的单个像素。我们通过首先调用setup()中的方法loadPixels()来实现,该方法初始化我们稍后在draw()中使用的pixels数组。这是一个一维数组,图像中的每一行像素都是一个接一个存储的,所以像素(ix,iy)对应于数组中的元素 idx,如图 6-9 所示。然而,在代码中还有另一个转换,因为我们拉伸图像以覆盖整个屏幕。因此,我们首先需要使用map()函数将屏幕坐标(x,y)映射到像素索引(ix,iy)。然后,我们可以应用图像中像素的索引和阵列中的索引之间的对应关系。
图 6-9。
Correspondence between pixels in an image and elements in the pixels array
图像流动壁纸
我们现在知道如何从任何图像生成一个平滑的矢量场。我们可以将固定的图像列表添加到我们的草图中,并在它们之间循环,以产生可变的运动模式。但更好的是,我们可以应用我们在照片图库示例中学到的技术来加载用设备的摄像头拍摄的照片,并将它们用作我们的流场的源。这将为我们的壁纸增添独特和无尽的变化。然而,为了实现这个壁纸,仍然有一些重要的细节需要解决,我们将在下面的部分中考虑。
加载、调整大小和裁剪图像
我们可能面临的一个问题是,设备的摄像头,尤其是最近的 Android 手机,可以以非常高的分辨率拍照。当我们将这些图片加载到一个PImage对象中,然后用loadPixels()加载pixels数组时,我们可能会耗尽内存(壁纸分配给它们的资源较少,以免影响系统中的其他应用)。然而,我们不需要全高分辨率的图像来计算流场。事实上,我们可以将一张图片加载到一个临时位图对象中,然后在将它加载到草图中的PImage对象之前,调整它的大小并将其裁剪到所需的大小。在清单 6-9 中,我们将croppedBitmap()函数添加到清单 6-6 中的图库示例中。
import android.os.Environment;
import java.io.FileOutputStream;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
...
void loadRandomImage() {
if (imageNames.size() == 0) {
currImage = defImage;
} else {
int i = int(random(1) * imageNames.size());
String sourceFn = imageNames.get(i);
try {
File destFile = sketchFile("cropped.jpg");
Bitmap bitmap = croppedBitmap(sourceFn);
OutputStream fout = new FileOutputStream(destFile);
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fout);
currImage = loadImage("cropped.jpg");
} catch (Exception ex) {
println("An error while cropping has occurred");
currImage = defImage;
}
}
}
...
Bitmap croppedBitmap(String sourceFile) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = false;
options.inSampleSize = 4;
Bitmap src = BitmapFactory.decodeFile(sourceFile, options);
int srcW = options.outWidth;
int srcH = options.outHeight;
float ratio = float(width)/float(height);
float srcRatio = float(srcW)/float(srcH);
int cropX, cropY, cropW, cropH;
if (ratio < srcRatio) {
cropH = srcH;
cropW = int(ratio * cropH);
} else {
cropW = srcW;
cropH = int(cropW / ratio);
}
cropX = (srcW - cropW)/2;
cropY = (srcH - cropH)/2;
return Bitmap.createBitmap(src, cropX, cropY, cropW, cropH);
}
Listing 6-9.Cropping and Loading Images
这里,在选择图像文件之后,我们应用调整大小/裁剪,其中我们利用Bitmap类来解码图像,同时对其进行下采样(在本例中,将其原始宽度和高度减少四分之一),然后我们确定裁剪区域,以便在全屏模式下显示图像的正确比例区域。我们将裁剪后的图像保存到草图文件夹中的一个新文件中,名为cropped.jpg,然后像以前一样用loadImage()加载它。我们应该能够看到背景中显示的图像的像素化。
把所有东西放在一起
在经历了前面几节从图像创建流场(清单 6-8 )和从外部存储器裁剪并加载图像(清单 6-9 )之后,我们准备好将我们的图像流壁纸放在一起。我们可以按照基于触摸的粒子系统的结构,用单独的Particle和Field类,来得到清单 6-10 。
import android.os.Environment;
import java.io.FileOutputStream;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
int maxParticles = 200;
int swapInterval = 10;
Field field;
ArrayList<Particle> particles;
ArrayList<String> imageNames = new ArrayList<String>();
int lastChange;
void setup() {
fullScreen(P2D);
frameRate(25);
field = new Field();
if (!wallpaperPreview()) {
requestPermission("android.permission.READ_EXTERNAL_STORAGE",
"scanForImages");
}
particles = new ArrayList<Particle>();
for (int i = 0; i < maxParticles; i++) {
particles.add(new Particle(random(width), random(height)));
}
loadRandomImage();
background(255);
}
void draw() {
for (Particle b: particles) {
b.update(field);
b.display();
}
int t = millis();
if (swapInterval * 1000 < t - lastChange) {
loadRandomImage();
lastChange = t;
}
}
void loadRandomImage() {
if (imageNames.size() == 0) {
field.update("default.jpg");
} else {
int i = int(random(1) * imageNames.size());
String sourceFn = imageNames.get(i);
try {
File destFile = sketchFile("cropped.jpg");
Bitmap bitmap = croppedBitmap(sourceFn);
OutputStream fout = new FileOutputStream(destFile);
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fout);
field.update("cropped.jpg");
} catch (Exception ex) {
println("An error while cropping has occurred");
field.update("default.jpg");
}
}
}
void scanForImages(boolean grantedPermission) {
if (grantedPermission) {
File dcimDir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DCIM);
String[] subDirs = dcimDir.list();
if (subDirs == null) return;
for (String d: subDirs) {
if (d.charAt(0) == '.') continue;
File fullPath = new File (dcimDir, d);
File[] listFiles = fullPath.listFiles();
for (File f: listFiles) {
String filename = f.getAbsolutePath().toLowerCase();
if (filename.endsWith(".jpg")) imageNames.add(filename);
}
}
}
}
Bitmap croppedBitmap(String sourceFile) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = false;
options.inSampleSize = 4;
Bitmap src = BitmapFactory.decodeFile(sourceFile, options);
int srcW = options.outWidth;
int srcH = options.outHeight;
float ratio = float(width)/float(height);
float srcRatio = float(srcW)/float(srcH);
int cropX, cropY, cropW, cropH;
if (ratio < srcRatio) {
cropH = srcH;
cropW = int(ratio * cropH);
} else {
cropW = srcW;
cropH = int(cropW / ratio);
}
cropX = (srcW - cropW)/2;
cropY = (srcH - cropH)/2;
return Bitmap.createBitmap(src, cropX, cropY, cropW, cropH);
}
class Field {
PImage flowImage;
void update(String fn) {
flowImage = loadImage(fn);
flowImage.loadPixels();
}
PVector lookupVector(PVector v) {
if (flowImage == null) return PVector.random2D();
color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
float theta = map(brightness(c), 0, 255, 0, TWO_PI);
return PVector.fromAngle(theta);
}
color lookupColor(PVector v) {
if (flowImage == null) return color(0, 0, 100, 0);
return flowImage.pixels[getPixelIndex(v.x, v.y)];
}
int getPixelIndex(float x, float y) {
int ix = int(map(x, 0, width, 0, flowImage.width - 1));
int iy = int(map(y, 0, height, 0, flowImage.height - 1));
return constrain(ix + iy * flowImage.width, 0, flowImage.pixels.length - 1);
}
}
class Particle {
PVector position;
PVector velocity;
PVector acceleration;
float size;
color color;
int life;
float maxAccel;
float maxSpeed;
int maxLife;
Particle(float x, float y) {
position = new PVector(x, y);
size = random(2, 4) * displayDensity;
velocity = new PVector(0, 0);
acceleration = new PVector(0, 0);
maxSpeed = random(2, 5);
maxAccel = random(0.1, 0.5);
maxLife = int(random(5, 20));
}
void update(Field flow) {
if (life == 0) {
color = flow.lookupColor(position);
} else if (life > frameRate * maxLife) {
position.x = random(width);
position.y = random(height);
life = 0;
color = flow.lookupColor(position);
}
PVector desired = flow.lookupVector(position);
acceleration.x = maxSpeed * desired.x - velocity.x;
acceleration.y = maxSpeed * desired.y - velocity.y;
acceleration.limit(maxAccel);
velocity.add(acceleration);
velocity.limit(maxSpeed);
position.add(velocity);
if (position.x < -size) position.x = width + size;
if (position.y < -size) position.y = height + size;
if (position.x > width + size) position.x = -size;
if (position.y > height + size) position.y = -size;
life++;
}
void display() {
float theta = velocity.heading();
noStroke();
pushMatrix();
translate(position.x,position.y);
rotate(theta);
fill(color);
beginShape(QUADS);
vertex(-2*size, -size);
vertex(+2*size, -size);
vertex(+2*size, +size);
vertex(-2*size, +size);
endShape();
popMatrix();
}
}
Listing 6-10.Image-flow Wallpaper
Field类包含保存要从中导出流场的图像的PImage对象,通过传入文件名来设置新图像的update()方法,以及两个查找方法lookupVector()和lookupColor(),它们分别返回给定位置的场向量和图像颜色。注意getPixelIndex()方法的使用是很重要的,该类在内部使用该方法将屏幕位置(x,y)转换为有效的像素索引,而不考虑当前流图像的分辨率。
Particle类与清单 6-7 中看到的非常相似,增加了一些关键的东西:在每次更新的开始,我们计算粒子“寿命”开始时的颜色,并在life变量达到最大值时重新开始它的位置;在更新结束时,我们检查粒子是否在屏幕之外,如果是,我们将它包裹起来,使它从屏幕的另一侧出现,以确保我们的粒子不会跑偏。
使用线程
正如在线参考中所解释的,加工草图遵循特定的步骤顺序:首先是setup(),然后是draw(),一遍又一遍地循环。一个线程也是一系列具有开始、中间和结束的步骤。一个加工草图是一个单独的线程,通常称为“动画线程”( https://processing.org/reference/thread_.html )。虽然在大多数情况下单线程就足够了,但有时我们需要一个单独的线程来执行额外的计算。例如,我们的图像流壁纸的一个问题是,当一个新的图像被调整大小和加载时,它会暂停一会儿。原因是调整大小/加载过程当前发生在动画线程中,阻止了后续帧的渲染,因此造成了延迟。解决方案是在一个单独的线程中运行loadRandomImage()函数,这样就可以在不妨碍渲染的情况下并行调整图像大小和加载图像。这可以通过用thread("loadRandomImage")替换draw()中对loadRandomImage()的调用来实现。
因为线程彼此独立运行,我们可能会面临多个线程同时调用 Field 类中的update()方法的问题,或者粒子在另一个线程更新字段数据时从动画线程中查找字段数据。如果处理不当,这种并发访问会导致意外错误。一个解决方案是将访问flowImage的Field类中的所有方法标记为“同步”当一个线程正在调用同步方法时,不能从另一个线程调用它们。清单 6-11 显示了使用线程和处理并发所需的壁纸代码的变化。
void draw() {...
if (swapInterval * 1000 < t - lastChange) {
thread("loadRandomImage");
lastChange = t;
}
}
...
class Field {
PImage flowImage;
synchronized void update(String fn) {
flowImage = loadImage(fn);
flowImage.loadPixels();
}
synchronized PVector lookupVector(PVector v) {
if (flowImage == null) return PVector.random2D();
color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
float theta = map(brightness(c), 0, 255, 0, TWO_PI);
return PVector.fromAngle(theta);
}
synchronized color lookupColor(PVector v) {
if (flowImage == null) return color(0, 0, 100, 0);
return flowImage.pixels[getPixelIndex(v.x, v.y)];
}
...
}
Listing 6-11.Running the Image-loading Function in a Separate Thread
控制色调
正如我们之前所讨论的,动态壁纸应该在视觉上吸引人,但又不至于压倒用户界面。在我们的例子中,粒子的颜色可能太亮或太饱和,使得很难看到前景中的图标。这个问题的一个解决方案是在我们的草图中使用 HSB 颜色空间,然后在从pixels数组中获得颜色时调整颜色的亮度。清单 6-12 包括一个修改过的lookupColor()方法,该方法应用处理的hue()和saturation()函数来提取这些属性,然后用color()来重建最终的颜色,因此粒子保持其原始色调和饱和度,但降低其亮度和不透明度。
void setup() {
...
colorMode(HSB, 360, 100, 100, 100);
background(0, 0, 100);
}
...
class Field {
...
synchronized color lookupColor(PVector v) {
if (flowImage == null) return color(0, 0, 100, 0);
color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
float h = hue(c);
float s = saturation(c);
float b = 50;
return color(h, s, b, 70);
}
...
}
Listing 6-12.Using the HSB Color Space
为了使用 HSB 色彩空间,我们需要使用colorMode()函数在setup()中设置色彩模式,我们已经在第四章中看到了。修改后我们的图像流壁纸应该显示更多柔和的颜色,如图 6-10 所示。
图 6-10。
Final version of the image-flow wallpaper running in the background
结束项目
作为这个项目的最后一个阶段,我们应该为所有需要的分辨率(36 × 36、48 × 48、72 × 72、96 × 96、144 × 144 和 192 × 192 像素)创建图标,并在 manifest 文件中为我们的壁纸设置一个唯一的包名和版本号。见图 6-11 。
图 6-11。
Icon set for the image flow wallpaper.
如果草图在设备或仿真器上至少运行过一次,则清单文件中已经填充了大部分必需的值。我们应该在应用和服务标签中设置一个唯一的包名和android:label属性,以便在壁纸选择器和应用列表中用一个更易读的标题来标识壁纸。包含所有这些值的完整清单文件如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="1"android:versionName="1.0"
package="com.example.image_flow">
<uses-sdk android:minSdkVersion="17" android:targetSdkVersion="25"/>
<uses-feature android:name="android.software.live_wallpaper"/>
<application android:icon="@drawable/icon"
android:label="Image Flow">
<service android:label="Image Flow"
android:name=".MainService"
android:permission="android.permission.BIND_WALLPAPER">
<intent-filter>
<action
android:name="android.service.wallpaper.WallpaperService"/>
</intent-filter>
<meta-data android:name="android.service.wallpaper"
android:resource="@xml/wallpaper"/>
</service>
<activity android:name="processing.android.PermissionRequestor"/>
</application>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest>
编辑完清单后,我们准备将草图导出为一个签名包,以便上传到谷歌 Play 商店,正如我们在第三章中所讨论的。
摘要
动态壁纸为我们提供了一种独特的媒介来创建动画图形,用户可以在他们的设备上体验动态背景。我们可以在处理中应用整个绘图 API 来创建原始壁纸,正如我们在本章中了解到的,也可以导入 Android API 来执行更高级的任务,例如从外部存储中读取文件或调整图像大小。本章还介绍了正常和危险权限的概念,以及如何从我们的草图中请求它们,我们将在下一章中多次重温。
七、读取传感器数据
Android 设备使用众多传感器从我们周围的物理世界读取数据,如加速度计、陀螺仪和磁力计。在本章中,我们将学习如何使用 Android SDK 和 Ketai 库从我们的处理草图中访问这些数据。
Android 设备中的传感器
如今,从智能手机到活动追踪器,几乎所有移动设备都配备了各种硬件传感器来捕捉设备的移动、环境和位置数据(以及我们自己的数据)。Android 设备通常包含加速度计、陀螺仪、磁力计和位置传感器。这些传感器让我们能够以多种方式访问大量信息,用于处理草图,从控制屏幕上图形元素的行为和创建不限于触摸手势的用户界面,到推断和可视化我们自己的运动模式。
让我们从安卓手机、平板电脑或手表上最典型的传感器开始(查看谷歌官方开发者指南了解更多细节: https://developer.android.com/guide/topics/sensors/sensors_overview.html )。
加速计
加速度传感器能够测量沿三个坐标轴的加速度,如图 7-1 所示。加速度是以米/秒 2 来衡量的,而 Android 将重力产生的加速度也包括在内。加速度数据有许多用途;例如,它可以是确定空间方向的基础,也可以帮助检测突然的运动,如冲击或振动。
图 7-1。
Physical axes that Android uses to describe acceleration and other sensor data
陀螺仪
陀螺仪测量设备的角速度;即围绕图 7-1 中加速度计传感器定义的相同三个轴的旋转速率(弧度/秒)。虽然它与加速度计相似,都可以用来确定器件的方位,但陀螺仪可以检测旋转,而加速度计则不能。因此,当我们需要测量旋转运动时,陀螺仪非常有用,例如旋转、转动等。
磁力计
磁力计是一种测量地磁场强度和方向的传感器——也使用图 7-1 中描述的坐标系——通过以μT(微特斯拉)为单位提供沿每个轴的磁场的原始分量。这种传感器的一个典型应用(我们将在下一章详细讨论)是使用指南针来显示设备相对于磁北的角度。
位置
这不是一个实际的传感器,而是从不同来源(全球定位系统,或 GPS,cell-ID 和 Wi-Fi)收集的数据的组合,使我们能够以不同的分辨率水平(粗略或精细)确定设备的地理位置(纬度/经度)。GPS 数据是从环绕地球运行的卫星网络中获得的,在开阔的天空下( http://www.gps.gov/systems/gps/performance/accuracy/ )精度约为 4.9 米(16 英尺)。从蜂窝塔或 Wi-Fi 接入点 id 获得的位置信息准确度低得多(在 5,300 英尺和 1 英里之间),但由于它是被动工作的,与 GPS 进行的主动定位相比,它消耗的能量非常少。
从处理中访问传感器
Processing 语言没有专门的函数来读取传感器数据,但是有两种简单的方法可以从我们的代码中访问这些数据。第一种是依赖 Android API,我们可以通过导入所有相关的 Android 包从处理中调用它,就像我们在第四章中从外部存储器读取文件一样。第二种方法是使用贡献的库,它扩展了 Android 模式的功能。我们将学习访问传感器数据的两种方式,从使用草图中的 Android SDK 开始。
创建传感器管理器
使用 Android sensor API 的第一步是获取包含草图的应用的“上下文”。我们可以把这个上下文看作是一个接口,允许我们访问关于我们的应用的有用信息。例如,一旦我们获得了上下文,我们就可以从中创建一个传感器管理器。该经理将在草图中创建我们需要的任何传感器。
让我们看一个读取加速度计数据的具体例子。我们将传感器管理器的初始化放在setup()函数中,如清单 7-1 所示,其中我们还为加速度计创建了传感器对象。
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
Context context;
SensorManager manager;
Sensor sensor;
void setup() {
fullScreen();
context = getContext();
manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
}
void draw() {
}
Listing 7-1.Accessing the Accelerometer
在这段代码中,我们必须从 Android SDK 导入几个包来访问Context、SensorManager和Sensor类。
添加传感器监听器
在下一步中(清单 7-2 ,我们添加一个监听器对象,通知草图传感器有新数据可用。我们通过实现两个方法onSensorChanged()和onAccuracyChanged(),从 Android API 的基类SensorEventListener中派生出特定于我们的草图的监听器类。前者在新数据可用时调用,后者在传感器的精度改变时调用。一旦我们获得了 listener 类的实例,我们必须向管理器注册它,这样它就可以生成数据了。
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
Context context;
SensorManager manager;
Sensor sensor;
AccelerometerListener listener;
void setup() {
fullScreen();
context = getActivity();
manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
listener = new AccelerometerListener();
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
}
void draw() {
}
class AccelerometerListener implements SensorEventListener {
public void onSensorChanged(SensorEvent event) {
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
Listing 7-2.Creating a Listener
您可能已经注意到监听器注册中的SensorManager.SENSOR_DELAY_NORMAL参数。此参数设置传感器用新数据更新的速率。更快的速率意味着更高的响应速度,但也意味着更多的电池消耗。默认的SENSOR_DELAY_NORMAL为屏幕方向变化设置足够快的速率,而SENSOR_DELAY_GAME和SENSOR_DELAY_UI分别适用于游戏和用户界面。最后,SENSOR_DELAY_FASTEST让我们尽可能快地获得传感器数据。
从传感器读取数据
正如我们刚才提到的,事件监听器有两个方法,onSensorChanged()和onAccuracyChanged()。我们只需要用onSensorChanged()从传感器上获取数据。对于加速度计,数据由三个浮点数组成,代表器件沿 x、y 和 z 轴的加速度。
在清单 7-3 中,我们简单地将这些值打印到屏幕上。我们可以验证,如果我们将手机平放在桌子上,屏幕朝上,我们应该会看到 9.81 m/s 的 Z 加速度 2 (实际加速度会在该值附近波动,因为加速度计数据有噪声),这对应于重力加速度。
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
Context context;
SensorManager manager;
Sensor sensor;
AccelerometerListener listener;
float ax, ay, az;
void setup() {
fullScreen();
context = getContext();
manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
listener = new AccelerometerListener();
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
textFont(createFont("SansSerif", displayDensity * 24));
textAlign(CENTER, CENTER);
}
void draw() {
background(157);
text("X: " + ax + "\n" + "Y: " + ay + "\n" + "Z: " + az, width/2, height/2);
}
public void resume() {
if (manager != null) {
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
}
}
public void pause() {
if (manager != null) {
manager.unregisterListener(listener);
}
}
class AccelerometerListener implements SensorEventListener {
public void onSensorChanged(SensorEvent event) {
ax = event.values[0];
ay = event.values[1];
az = event.values[2];
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
Listing 7-3.Reading the Accelerometer
此外,我们在草图暂停时注销监听器,在草图恢复时重新注册。图 7-2 显示了我们第一个传感器草图的输出,这只是 X、Y 和 Z 加速度值的打印输出。
图 7-2。
Output of the accelerometer sensor example Note
作为使用传感器的最佳实践,我们应该在草图的活动暂停时取消注册关联的侦听器,以减少电池使用,然后在活动恢复时再次注册它。
来自其他传感器的读数
我们在前面的例子中放在一起的结构可以重复使用,对于其他类型的传感器几乎没有变化。例如,如果我们想使用陀螺仪读取每个轴的旋转角度,我们需要做的就是请求一个TYPE_GYROSCOPE传感器,如清单 7-4 所示。
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
Context context;
SensorManager manager;
Sensor sensor;
GyroscopeListener listener;
float rx, ry, rz;
void setup() {
fullScreen();
context = getContext();
manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
sensor = manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
listener = new GyroscopeListener();
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
textFont(createFont("SansSerif", displayDensity * 24));
textAlign(CENTER, CENTER);
}
void draw() {
background(157);
text("X: " + rx + "\n" + "Y: " + ry + "\n" + "Z: " + rz, width/2, height/2);
}
public void resume() {
if (manager != null) {
manager.registerListener(listener, sensor,
SensorManager.SENSOR_DELAY_NORMAL);
}
}
public void pause() {
if (manager != null) {
manager.unregisterListener(listener);
}
}
class GyroscopeListener implements SensorEventListener {
public void onSensorChanged(SensorEvent event) {
rx = event.values[0];
ry = event.values[1];
rz = event.values[2];
}
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
Listing 7-4.Reading the Gyroscope
科泰图书馆
我们已经学会了从草图中直接调用 Android API 来访问传感器数据。这种方法的优点是我们可以随时访问 Android API,但缺点是我们需要额外的代码来创建传感器管理器和监听器。这些额外的代码没有遵循处理 API 的约定,因此新用户可能很难理解。
我们不得不使用 Android 传感器 API,因为 Android 的处理不包括一个。然而,我们可以通过使用贡献的库为处理添加新的功能。正如我们在第一章中看到的,贡献库是封装了不属于处理核心的额外函数和类的模块。我们可以将这些库导入到草图中,以使用它们的特性和功能。事实证明,有一个旨在以简化、类似处理的方式使用 Android 传感器的库。它被称为科泰( http://ketai.org/ ),由丹尼尔·索特和耶稣·杜兰创造。
安装科泰
贡献库,比如 Ketai,可以使用贡献管理器(Contributions Manager,或 CM)轻松地安装在进程中。要打开 CM,我们进入草图菜单,然后选择“导入库|添加库…”我们已经使用 CM 来安装 Android 模式,但它也是安装库、工具和示例的主界面。
打开 CM 后,我们选择 Libraries 选项卡;在那里,我们可以通过向下滚动列表或按名称搜索来搜索感兴趣的库。一旦我们找到了 Ketai 的条目,我们需要做的就是点击 Install 按钮(图 7-3 )。
图 7-3。
Installing the Ketai library through the Contributions Manager
使用 Ketai
Ketai 库为我们的 Android 设备中的传感器提供了一个简单的接口,遵循核心处理 API 的风格。Ketai 需要一些初始化代码来导入库并创建 KetaiSensor 对象,但是读取传感器值非常容易。我们只需要在我们的草图中添加一个“事件处理程序”函数,每当有来自传感器的新值时就会调用这个函数(类似于内置的mousePressed()或touchMoved()函数的工作方式)。
清单 7-5 中展示了一个简单的草图,它读取加速度计并将数值显示为文本,就像我们在清单 7-3 中所做的那样。其输出如图 7-4 所示。
图 7-4。
Output of Ketai accelerometer example
import ketai.sensors.*;
KetaiSensor sensor;
float accelerometerX, accelerometerY, accelerometerZ;
void setup() {
fullScreen();
sensor = new KetaiSensor(this);
sensor.start();
textAlign(CENTER, CENTER);
textSize(displayDensity * 36);
}
void draw() {
background(78, 93, 75);
text("Accelerometer: \n" +
"x: " + nfp(accelerometerX, 1, 3) + "\n" +
"y: " + nfp(accelerometerY, 1, 3) + "\n" +
"z: " + nfp(accelerometerZ, 1, 3), 0, 0, width, height);
}
void onAccelerometerEvent(float x, float y, float z) {
accelerometerX = x;
accelerometerY = y;
accelerometerZ = z;
}
Listing 7-5.Reading the Accelerometer
with Ketai
来自其他传感器的数据以同样的方式被访问;我们只需要添加一个不同的事件处理程序。例如,为了读取陀螺仪的值,我们使用onGyroscopeEvent(列表 7-6 )。
import ketai.sensors.*;
KetaiSensor sensor;
float rotationX, rotationY, rotationZ;
void setup() {
fullScreen();
sensor = new KetaiSensor(this);
sensor.start();
textAlign(CENTER, CENTER);
textSize(displayDensity * 24);
}
void draw() {
background(78, 93, 75);
text("Gyroscope: \n" +
"x: " + nfp(rotationX, 1, 3) + "\n" +
"y: " + nfp(rotationY, 1, 3) + "\n" +
"z: " + nfp(rotationZ, 1, 3), 0, 0, width, height);
}
void onGyroscopeEvent(float x, float y, float z) {
rotationX = x;
rotationY = y;
rotationZ = z;
}
Listing 7-6.Reading the Gyroscope
with Ketai
Ketai 中的事件处理程序
对于 Ketai,我们通过添加相应的事件处理函数来指示要读取哪些传感器数据。这里显示了一些 Ketai 支持的处理程序,完整的列表可以在库的参考( http://ketai.org/reference/sensors )中找到。Daniel Sauter 的《Android 快速开发》,可以在 https://www.mobileprocessing.org 在线获得,也是了解科泰所有细节的好资源。
void onSensorEvent(SensorEvent e):这个处理程序返回一个“原始的”Android sensor 事件对象,它包含描述事件的所有相关信息,包括类型、值等等。这个SensorEvent类在官方 Android 参考中有完整的文档,在这里可以找到:https://developer.android.com/reference/android/hardware/SensorEvent.htmlvoid onAccelerometerEvent(float x, float y, float z, long a, int b):我们接收加速度计数据,x, y, z为沿三个轴的加速度,单位为米/秒 2 ,a为事件的时间戳(单位为纳秒),b为当前的精度水平。void onAccelerometerEvent(float x, float y, float z):与之前相同,但没有给出时间戳或精确度void onGyroscopeEvent(float x, float y, float z, long a, int b):提供x, y, z弧度/秒的角速度,事件时间戳a,精度等级bvoid onGyroscopeEvent(float x, float y, float z):仅角速度void onPressureEvent(float p):当前环境压力p,单位为百帕(hPa)void onTemperatureEvent(float t):当前温度t,单位为摄氏度
我们可以通过添加所有需要的事件处理程序来同时使用几个传感器。然而,设备可能不包括特定的传感器。为了正确处理这种情况,我们可以使用KetaiSensor中的isXXXAvailable()函数来检查任何支持的传感器的可用性。让我们将前面的例子合并到一个草图中,该草图读取并显示加速度计和陀螺仪数据,但前提是器件具有这些传感器。一般来说,几乎所有的 Android 手机都配有加速度计,但更便宜的入门级设备往往没有陀螺仪。
清单 7-7 包括setup()函数中的传感器可用性代码,并使用长度代表传感器值大小的条图形显示加速度计和陀螺仪值(图 7-5 )。它的灵感来自 Tiago Martins 创造的一个更全面的例子,他还写了几个其他草图来说明传感器在处理中的使用( https://github.com/tms-martins/processing-androidExamples )。
图 7-5。
Showing values from the accelerometer and the gyroscope
import ketai.sensors.*;
KetaiSensor sensor;
boolean hasAccel = false;
boolean hasGyro = false;
PVector dataAccel = new PVector();
PVector dataGyro = new PVector();
void setup() {
fullScreen();
sensor = new KetaiSensor(this);
sensor.start();
if (sensor.isAccelerometerAvailable()) {
hasAccel = true;
println("Device has accelerometer");
}
if (sensor.isGyroscopeAvailable()) {
hasGyro = true;
println("Device has gyroscope");
}
noStroke();
}
void draw() {
background(255);
float h = height/6;
float y = 0;
translate(width/2, 0);
if (hasAccel) {
fill(#C63030);
rect(0, y, map(dataAccel.x, -10, +10, -width/2, +width/2), h);
y += h;
rect(0, y, map(dataAccel.y, -10, +10, -width/2, +width/2), h);
y += h;
rect(0, y, map(dataAccel.z, -10, +10, -width/2, +width/2), h);
y += h;
}
if (hasGyro) {
fill(#30C652);
rect(0, y, map(dataGyro.x, -10, +10, -width/2, +width/2), h);
y += h;
rect(0, y, map(dataGyro.y, -10, +10, -width/2, +width/2), h);
y += h;
rect(0, y, map(dataGyro.z, -10, +10, -width/2, +width/2), h);
}
}
void onAccelerometerEvent(float x, float y, float z) {
dataAccel.set(x, y, z);
}
void onGyroscopeEvent(float x, float y, float z) {
dataGyro.set(x, y, z);
}
Listing 7-7.Checking Sensor Availability with Ketai
摘要
本章为我们在加工草图中使用传感器数据奠定了基础,无论是通过 Android API 还是通过 Ketai 库。在接下来的章节中,我们将基于这些技术来创建由设备的移动或位置驱动的图形和交互。