画布 2D Web 应用程序/响应式按钮
本章扩展了关于静态按钮的章节,涵盖响应式按钮,这些按钮会随着用户点击它们或将鼠标指针悬停在它们上面而改变其外观。
本章的示例(可在在线获取;也可作为可下载版本)扩展了关于静态按钮的章节的示例,使用三种图像之一(“正常”、“聚焦”和“按下”)根据按钮的当前状态呈现按钮。因此,示例还必须包含每个按钮的 cuiButton
对象,用于跟踪其状态。
尽管进行了这些更改,但示例的基本结构仍然相同:myPageProcess
函数重新绘制画布并处理事件,同时调用每个按钮的 process 函数以处理其重新绘制和影响它的事件处理。
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no">
<script>
function init() {
// get images
imageNormalButton.src = "normal.png";
imageNormalButton.onload = cuiRepaint;
imageFocusedButton.src = "selected.png";
imageFocusedButton.onload = cuiRepaint;
imagePressedButton.src = "depressed.png";
imagePressedButton.onload = cuiRepaint;
// set defaults for all pages
cuiBackgroundFillStyle = "#000000";
cuiDefaultFont = "bold 20px Helvetica, sans-serif";
cuiDefaultFillStyle = "#FFFFFF";
// initialize cui2d and start with myPage
cuiInit(myPage);
}
// create images for the buttons
var imageNormalButton = new Image();
var imageFocusedButton = new Image();
var imagePressedButton = new Image();
// create a color
var myColor = "#000000";
// create buttons
var button0 = new cuiButton();
var button1 = new cuiButton();
var button2 = new cuiButton();
// create a new page of size 400x300 and attach myPageProcess
var myPage = new cuiPage(400, 300, myPageProcess);
// a function to repaint the canvas and return false (if null == event)
// or to process user events (if null != event) and return true
// if the event has been processed
function myPageProcess(event) {
// draw and react to buttons
if (button0.process(event, 50, 50, 80, 50, "red",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button0.isClicked()) {
myColor = "#FF0000";
cuiRepaint();
}
return true;
}
if (button1.process(event, 150, 50, 80, 50, "green",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button1.isClicked()) {
myColor = "#00FF00";
cuiRepaint();
}
return true;
}
if (button2.process(event, 250, 50, 80, 50, "blue",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button2.isClicked()) {
myColor = "#0000FF";
cuiRepaint();
}
return true;
}
// repaint this page?
if (null == event) {
// draw color box
cuiContext.fillStyle = myColor;
cuiContext.fillRect(150, 150, 80, 80);
// background
cuiContext.fillStyle = "#404040";
cuiContext.fillRect(0, 0, this.width, this.height);
}
return false; // event has not been processed
}
</script>
</head>
<body bgcolor="#000000" onload="init()"
style="-webkit-user-drag:none; -webkit-user-select:none; ">
<span style="color:white;">A canvas element cannot be displayed.</span>
</body>
</html>
本次讨论假设您已阅读关于静态按钮的章节,并重点介绍与该章节中介绍的示例的差异。
现在有三个图像(imageNormalButton
、imageFocusedButton
和 imagePressedButton
)。我们为它们都设置 onload
为 cuiRepaint()
,以确保它们在加载后立即正确呈现。
代码不再将 myPage.isDraggableWithOneFinger
设置为 false
,因为我们不允许用户通过点击背景将颜色更改为黑色,因此可以允许他们拖动页面。
此示例的主要新功能是三个 cuiButton
对象
// create buttons
var button0 = new cuiButton();
var button1 = new cuiButton();
var button2 = new cuiButton();
请注意,这些对象的构造函数没有参数,因为所有对象都从“正常”状态开始,其外观由对 process 函数的调用定义。出于同样的原因,cui2d 中大多数 GUI 元素的构造函数都没有参数。(cuiPage
是一个例外,主要是因为它的 process 函数仅在 cui2d 的渲染循环中内部调用。)
通过调用 process 函数来呈现和处理三个按钮
// draw and react to buttons
if (button0.process(event, 50, 50, 80, 50, "red",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button0.isClicked()) {
myColor = "#FF0000";
cuiRepaint();
}
return true;
}
if (button1.process(event, 150, 50, 80, 50, "green",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button1.isClicked()) {
myColor = "#00FF00";
cuiRepaint();
}
return true;
}
if (button2.process(event, 250, 50, 80, 50, "blue",
imageNormalButton, imageFocusedButton, imagePressedButton)) {
if (button2.isClicked()) {
myColor = "#0000FF";
cuiRepaint();
}
return true;
}
按钮的 process 函数要么重新绘制按钮(对于 null == event
),要么尝试处理事件(对于 null != event
)。如果事件已被处理,它将返回 true
。在这种情况下,我们检查按钮是否被 isClicked()
方法点击。如果是这种情况,myColor
将相应地设置,并调用 cuiRepaint()
以请求重新绘制画布。
此时,可能值得退一步,看看代码的结构。在 cui2d 中,对小部件的调用的总体结构为
var <widget> = new <widget type>();
...
if (<widget>.process(<event>, <configuration arguments>)) {
<handle widget events>
return true;
}
重要的是,所有相关信息都在一个地方
- 小部件在页面上的布局由小部件 process 函数的配置参数指定
- 小部件的外观也由配置参数指定。
- 程序对任何小部件事件的反应是在调用小部件 process 函数之后立即指定的。
唯一不在同一个地方的部分是对构造函数的调用。但是,该调用通常不会提供任何相关信息,因为 cui2d 中大多数小部件构造函数都没有参数。因此,所有相关部分都集中在代码中的一个地方。
将其与标准 GUI 编程进行比较,在标准 GUI 编程中,将布局定义与外观定义和事件处理分开被认为是良好的编程风格——通常甚至跨不同的文件。虽然可能有充分的理由将这些内容分开,但它会使代码变得更难阅读和更改;因此,原型制作更难,这很可能导致更少的原型制作,因此会导致更糟糕的产品。
cuiButton
类型在 cui2d.js 中实现。它定义了一个没有参数的构造函数,该构造函数在初始状态下创建了一个新对象。此初始状态始终是用户与对象交互之前的状态
/**
* Buttons are clickable rectangular regions.
* @typedef cuiButton
*/
/**
* Creates a new cuiButton.
* @constructor
*/
function cuiButton() {
this.isPointerInside = false; // mouse or touch point inside rectangle?
this.isPointerDown = false; // mouse button down or finger down _on_this_button_?
this.identifier = -1; // the identifier of the touch point
this.hasTriggeredClick = false; // click event has been triggered?
}
然后定义一个函数来检查按钮是否已被点击
/** Returns whether the button has just been clicked. */
cuiButton.prototype.isClicked = function() {
return this.hasTriggeredClick;
}
此外,还定义了一个 process 函数。对于 event == null
,这只是一个用于重新绘制按钮的函数。对于 event != null
,最好将其视为自动机的一步;即,根据按钮的当前状态(如 this
中定义)和 event
,设置一个新状态。请注意,通常最好让顶层 if
语句区分 this
中变量的不同值(例如:if (!this.isPointerInside && !this.isPointerDown)
),因为这表示按钮的状态。只有在这些 if
语句内部,代码才应区分不同类型的事件(例如:if (isIn && ("touchend" == event.type))
)。这种结构允许程序员轻松地检查是否涵盖了所有状态和所有状态的相关事件。将这种结构与适当的缩进结合使用还可以让读者轻松地识别某个状态以及从该状态的所有转换——这正是图形状态转换图也擅长的。(事实上,如果描述转换的代码结构良好、注释良好且格式良好,它可能与状态转换图一样易读,但它具有机器可读的优点。)
/**
* Either process the event (if event != null) and return true if the event has been processed,
* or draw the appropriate image for the button state in the rectangle
* with a text string on top of it (if event == null) and return false.
*/
cuiButton.prototype.process = function(event, x, y, width, height,
text, imageNormal, imageFocused, imagePressed) {
// choose appropriate image
var image = imageNormal;
if (this.isPointerDown && this.isPointerInside) {
image = imagePressed;
}
else if (this.isPointerDown || this.isPointerInside) {
image = imageFocused;
}
// check or repaint button
var isIn = cuiIsInsideRectangle(event, x, y, width, height, text, image);
// note that the event might be inside the rectangle (isIn == true)
// but the state might still be isPointerDown == false (e.g. for touchend or
// touchcancel or if the pointer went down outside of the button)
// react to event
if (null == event) {
return false; // no event to process
}
// clear isReleased (this is set only once after the click and has to be cleared afterwards)
this.hasTriggeredClick = false;
// ignore mouse or touch points that are not the tracked point (apart from mousedown and touchstart)
if (this.isPointerInside || this.isPointerDown) {
if ("touchend" == event.type || "touchmove" == event.type || "touchcancel" == event.type) {
if (event.identifier != this.identifier) {
return false; // ignore all other touch points except "touchstart" events
}
}
else if (("mousemove" == event.type || "mouseup" == event.type) && event.identifier >= 0) {
return false; // ignore mouse (except mousedown) if we are tracking a touch point
}
}
// state changes
if (!this.isPointerInside && !this.isPointerDown) { // passive button state
if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
this.isPointerDown = true;
this.isPointerInside = true;
if ("touchstart" == event.type) {
this.identifier = event.identifier;
}
else {
this.identifier = -1; // mouse
}
cuiRepaint();
return true;
}
else if (isIn && ("mousemove" == event.type || "mouseup" == event.type ||
"touchmove" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = true;
if ("touchmove" == event.type) {
this.identifier = event.identifier;
}
else {
this.identifier = -1; // mouse
}
cuiRepaint();
return true;
}
else {
return false; // none of our business
}
}
else if (this.isPointerInside && !this.isPointerDown) { // focused button state (not pushed yet)
if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
this.isPointerDown = true;
this.isPointerInside = true;
if ("touchstart" == event.type) {
this.identifier = event.identifier;
}
else {
this.identifier = -1; // mouse
}
cuiRepaint();
return true;
}
else if (isIn && ("touchend" == event.type || "touchcancel" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (!isIn && ("touchmove" == event.type || "touchend" == event.type ||
"touchcancel" == event.type || "mousemove" == event.type || "mouseup" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return false; // none of our business
}
else {
return false; // none of our business
}
}
else if (!this.isPointerInside && this.isPointerDown) { // focused button state (pushed previously)
if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
this.isPointerDown = true;
this.isPointerInside = true;
if ("touchstart" == event.type) {
this.identifier = event.identifier;
}
else {
this.identifier = -1; // mouse
}
cuiRepaint();
return true;
}
else if (isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons))) {
this.isPointerDown = false;
this.isPointerInside = true;
this.identifier = -1; // mouse
this.hasTriggeredClick = true;
cuiRepaint();
return true;
}
else if (isIn && ("touchend" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
this.hasTriggeredClick = true;
cuiRepaint();
return true;
}
else if (isIn && ("touchcancel" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (isIn && ("touchmove" == event.type || ("mousemove" == event.type && 0 < event.buttons))) {
this.isPointerInside = true;
cuiRepaint();
return true;
}
else if (!isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons) ||
"touchend" == event.type || "touchcancel" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (!isIn && (("mousedown" == event.type && this.identifier < 0) ||
("touchstart" == event.type && this.identifier == event.identifier))) {
this.isPointerDown = false;
this.isPointerInside = false;
return false; // none of our business
}
else if ("touchmove" == event.type || "mousemove" == event.type) {
return true; // this is our event, we feel responsible for it
}
else {
return false; // none of our business
}
}
else if (this.isPointerInside && this.isPointerDown) { // depressed button state
if (isIn && ("mousedown" == event.type || "touchstart" == event.type)) {
this.isPointerDown = true;
this.isPointerInside = true;
if ("touchstart" == event.type) {
this.identifier = event.identifier;
}
else {
this.identifier = -1; // mouse
}
cuiRepaint();
return true;
}
else if (isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons))) {
this.isPointerDown = false;
this.isPointerInside = true;
this.identifier = -1; // mouse
this.hasTriggeredClick = true;
cuiRepaint();
return true;
}
else if (isIn && ("touchend" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
this.hasTriggeredClick = true;
cuiRepaint();
return true;
}
else if (isIn && ("touchcancel" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (!isIn && ("mouseup" == event.type || ("mousemove" == event.type && 0 == event.buttons) ||
"touchend" == event.type || "touchcancel" == event.type)) {
this.isPointerDown = false;
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (!isIn && ("touchmove" == event.type || ("mousemove" == event.type && 0 < event.buttons))) {
this.isPointerInside = false;
cuiRepaint();
return true;
}
else if (!isIn && (("mousedown" == event.type && this.identifier < 0) ||
("touchstart" == event.type && this.identifier == event.identifier))) {
this.isPointerDown = false;
this.isPointerInside = false;
return false; // none of our business
}
else if ("touchmove" == event.type || "mousemove" == event.type) {
return true; // this is our event, we feel responsible for it
}
else {
return false; // none of our business
}
}
// unreachable code
return false;
}