Sencha-Touch2-移动应用创建指南-全-

Sencha Touch2 移动应用创建指南(全)

原文:zh.annas-archive.org/md5/c9b38e9c49873f6873c4415414ec6fee

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎使用 使用 Sencha Touch 2 创建移动应用。本书的目标是通过引导你通过一系列完整的应用程序来学习 Sencha Touch 移动开发平台。每个应用程序都将侧重于语言的不同方面,并展示 Sencha Touch 的许多功能。

Sencha Touch 语言是一个使用 JavaScript 和 CSS 创建强大且灵活的移动应用的 HTML5 框架。这些应用可以像普通网站一样托管,或者编译成可以在苹果或安卓应用商店销售的应用程序(应用)。

本书涵盖的内容

第一章,简单的任务列表,引导你了解 Sencha Architect 的使用,Sencha Architect 是 Sencha Touch 框架的图形应用开发工具。

第二章,订阅阅读器,继续探索 Sencha Architect,并探讨了在应用中使用外部数据,以及使用 xTemplates 创建复杂布局。

第三章,命令行模式,我们暂时离开 Sencha Architect,探索 Sencha Cmd 工具创建应用的强大功能。我们还介绍了编译基本应用,以便我们可以使用移动设备的附加功能。

第四章,重量重量,是一个锻炼和体重跟踪应用,它使用强大的 Sencha Charts 包来创建数据可视化显示。

第五章,准备就绪:使用 Sencha.io,探讨了使用 Sencha.io 框架在远程服务器上存储数据的方法。

第六章,目录应用和 API,基于上一章中我们对 API 的使用,展示了我们如何设计和构建自己的自定义 API。

第七章,决策者:外部 API,涵盖了使用多个外部 API(谷歌地图和 FourSquare)来创建单个应用。

第八章,进化者:使用配置文件,使用 Sencha Touch 配置文件根据你使用的移动设备创建独特的界面。它还涵盖了从 WordPress 拉取数据以创建传统网站的移动版本。

第九章,工作簿:使用相机,展示了如何在 Sencha Touch 框架内部使用你的移动设备上的相机。

第十章,游戏开始,展示了如何创建一个简单的回合制游戏。这可以作为创建你自己的回合制游戏的基础。

本书所需条件

需要的工具如下:

  • Sencha Touch 2.1(商业版或 GPL)。

  • Sencha Cmd 3。

  • Sencha Architect 2.1(用于第一章,一个简单的任务列表和第二章,一个订阅阅读器)。

  • 触摸图表(用于第四章,重量重量。这包含在 Sencha Touch 2.1 GPL 中,或者可以作为单独的商业购买获得)。

本书面向对象

这本书是为那些对 Sencha Touch 有基本了解,并想了解 Sencha Touch 的功能如何作为完整应用程序一部分的人。

术语约定

在这本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:

“与我们的上一个 iOS 配置文件一样,我们创建了一个名为 packager_android.json 的 JSON 文件。”

代码块如下设置:

listeners: [
 {
  fn: 'onStartButtonTap',
  event: 'tap',
  delegate: '#startButton'
 }
]

任何命令行输入或输出都如下所示:

sencha generate model Contact --fields=id:int,firstName, lastName,email,phone

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“Sencha Architect 的 工具箱 部分是您将找到所有 Sencha Touch 组件的地方”。

注意

警告或重要提示以如下框的形式出现。

技巧

技巧和窍门如下所示。

读者反馈

我们读者的反馈总是受欢迎的。告诉我们你对这本书的看法——你喜欢什么或者可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送给我们一般性的反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件的主题中提及书名。

如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你购买 Packt 书籍的账户中下载所有示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

错误表

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

海盗行为

在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果您在书的任何方面遇到问题,请通过 <copyright@packtpub.com> 联系我们,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。

询问

如果您在书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章。简单的任务列表

在其核心,大多数编程任务可以分为三类:数据展示、数据输入和数据存储。我们的第一个项目将以涵盖 Sencha Touch 如何处理这三个基本类别为目标。为此,我们将创建一个常见的编程应用,待办事项列表或任务列表。

在这个应用程序中,我们将使用 HTML5 中可用的本地存储来存储任务,包括名称、描述、创建日期、完成日期和优先级。然后我们将创建一个任务列表来显示当前任务以及我们的已完成任务。我们将讨论测试显示和管理错误的方法。然后我们将创建输入新任务、编辑现有任务和标记任务完成的表单。

最后,在我们的加分部分,我们将探讨一些此类应用可能的其他附加功能。

Sencha Architect 的简要概述

Sencha Architect 是一个跨平台的视觉开发工具,用于 Sencha Touch 和 Ext JS。Sencha Architect 可用于 Mac、Windows 和 Linux,可以通过以下链接下载:

www.sencha.com/products/architect

在本书的大部分章节中,我们将结合使用 Sencha Architect 和标准编码来创建我们的项目。这将让你了解设计师的一些强大优势,同时不会隐藏任何实际代码。

这实际上是 Sencha Architect 的关键优势之一;虽然它允许你快速创建界面并测试它们,但在幕后,设计师正在生成标准的 JavaScript 文件,你可以使用任何文本编辑器来编辑这些文件。这种优势允许你快速组装应用程序的基本元素,同时保持根据需要手动调整代码的能力。我们将在稍后对此进行更多介绍,但现在让我们看看 Sencha Architect 的设置。

当你首次启动 Sencha Architect 时,你会看到一个对话框,你可以选择开始一个新的 Ext JS 项目或一个新的 Sencha Touch 项目,或者你可以从现有项目列表中选择:

Sencha Architect 的简要概述

由于我们在这本书中关注 Sencha Touch,你应该选择一个新的 Sencha Touch 2.1 项目。

小贴士

Ext JS 和 Sencha Touch 之间的区别

ExtJS 和 Sencha Touch 都是 Sencha Inc. 公司的产品。Sencha Touch 用于为各种设备开发移动应用程序,而 Ext JS 用于创建桌面浏览器(如 Firefox、Chrome 或 Internet Explorer)的 Web 应用程序。对于这本书,我们将坚持使用 Sencha Touch。

一旦你选择了你的新项目类型,Sencha Architect 窗口将打开。这个窗口包含应用程序的视觉展示,并允许我们通过拖放以及直接输入代码来修改应用程序。

Sencha Architect 的简要概述

工具箱

Sencha Architect 的工具箱部分是您将找到所有由 Sencha Touch 提供的组件的地方。这些组件按字母顺序列在工具箱部分的右侧,而基本组件类型则列在左侧。点击这些组件类型之一将限制列表只显示该特定类型的组件。

工具箱

默认提供的类型如下:

  • 行为: 它为函数和控制器提供空容器

  • 图表: 它是一组可以直接从存储库中提取数据的图表和图形

  • 容器: 它包含面板、选项卡面板、轮播图和字段集等元素

  • 数据: 它包含数据驱动的组件,如存储库、代理、读取器、写入器和过滤器

  • 表单: 它包含基本表单元素,如文本字段、单选按钮、选择字段和按钮

  • 模型: 它包括基本数据模型、验证、字段、代理、读取器和写入器

  • 资源: 它允许您添加 JavaScript 和 CSS 的外部文件,以及编译应用的打包文件

  • : 树是嵌套树组件所需的存储类型

  • 视图: 它包含 Sencha Touch 中所有基本可查看组件,如容器、表单字段、媒体、选择器、工具栏、列表和按钮

此外,还有所有内容选项以显示列表中的所有类型。

您还可以在自定义部分使用+按钮添加您自己的自定义类型以限制列表。这对于常用组件或简单地根据您自己的个人习惯定制列表非常有帮助。

创建自定义类型后,您只需从右侧的列表中拖动组件,并将它们拖放到自定义类型中。

您还可以使用工具箱区域顶部的过滤...字段直接按名称搜索组件。

帮助部分

当从工具箱中选择任何组件时,直接位于其下方的帮助部分将显示有关该组件的信息。

在帮助区域的底部还有一个蓝色链接,上面写着查看类文档。点击此链接将带您到 Sencha 网站上所选特定组件的文档。这些文档是信息宝贵的来源,您应尽快熟悉它们。

设计区域

设计区域是我们开始创建第一个应用程序的地方。默认情况下,Sencha Touch 应用程序以 iPhone 320 x 480 布局开始。此布局可以更改以显示 iPad、Nexus S 或 Kindle Fire 显示尺寸。这允许您在多个设备下查看您的设计。您还可以设置设备的方向并放大或缩小设计视图区域。

设计区域

设计区域还提供了一个查看和操作设计背后代码的选项。如果你刚开始接触移动编程,这是一个非常好的学习工具。通过在设计代码视图之间切换,你可以检查复杂的布局,并确切地看到 JavaScript 代码是如何用来创建它们的。

项目检查器区域

项目检查器区域为你提供了项目代码的另一种视图。当你将组件拖动到设计区域时,它们也会出现在项目检查器中。项目检查器区域将以分层列表的形式显示这些组件。这通常非常有帮助,可以查看哪些组件嵌套在其他组件内部。

项目检查器区域

组件也可以从工具箱列表拖动到项目检查器中。通过将组件拖放到项目检查器中,而不是设计区域,通常更容易管理某些组件。这可以确保你正确地将组件放置在所需的容器中。

注意

资源部分是 2.1 版本中的新增功能,它允许你将外部文件添加到你的项目中。如果你有一个较旧的 2.0 版本 Sencha Touch 项目,你可以右键点击并选择升级,将项目更改为较新的 Sencha Touch 2.1 项目。

配置区域

配置区域将显示设计区域或项目检查器中选定的任何组件的所有配置选项。所有典型的配置选项,如高度、宽度、布局、ID、填充、边距、事件和函数,都可以从配置区域访问。

配置区域

配置名称列在左侧,值在右侧。点击值将允许你编辑它。你还可以点击某些部分旁边的+,例如函数事件,向配置中添加新项目。

开始使用任务列表

要了解所有这些组件如何协同工作以创建一个应用程序,让我们首先为任务管理器应用程序创建数据存储。将你打开的新文件保存为TaskList,然后开始添加一些组件。

创建数据存储

要将组件添加到项目中,我们需要从工具箱中拖动组件,并将其放置在项目或项目检查器的适当部分。对于我们的第一个组件,让我们选择一个普通的数据存储。

工具箱中选择数据,然后点击存储并将其拖动到设计区域中的我们的 iPhone 上。你现在应该能在属性****检查器下的存储列表中看到一个名为MyStore的存储:

创建数据存储

你也会注意到,在我们的商店旁边有一个红色的警告图标。这告诉我们我们的商店缺少一些必需的组件。在这种情况下,商店需要一个代理来控制数据的发送和接收,并且需要一个模型来告诉它期望哪些数据。

工具箱列表中选择LocalStorage Proxy对象,并将其拖动到我们的商店上。

你可能会注意到,某些组件只能放置在其他组件内部。例如,当你拖动代理时,你不能像之前那样直接将其拖放到 iPhone 图表中。代理组件将仅作为数据存储的一部分存在。这意味着你必须将代理组件拖放到属性检查器中的数据存储上,以便它能够正确地添加到商店中。

一旦将代理拖放到商店中,我们需要添加一个模型,并将其链接到我们的商店。

添加模型、字段和字段类型

在我们的工具箱数据部分中,向上滚动以找到模型的列表。将模型对象拖动到我们的项目检查器模型部分。这将创建一个名为MyModel的新模型。

项目检查器中,选择MyModel,查看配置部分。我们可能首先想要更改的是名称。点击列出的userClassName的值,将MyModel更改为Task,然后按键盘上的Enter键。模型现在应在配置项目检查器中均列为准Task

接下来,我们需要向我们的模型添加一些字段。选择Task模型后,你应该在配置区域中看到字段的列表。点击字段旁边的+按钮,在出现的文本区域中输入id。在屏幕上点击完成或按键盘上的Enter键。

你的配置区域现在应该看起来像这样:

添加模型、字段和字段类型

重复之前的步骤,将以下字段添加到任务模型中:

  • 名称

  • 描述

  • 创建

  • 完成

  • 优先级

  • 是否完成

现在你已经拥有了所有字段,我们需要为每个字段定义数据类型。

项目检查器中,点击名为id的字段。这将打开该字段的配置,你应该看到目前没有为类型列出任何值。点击类型旁边的值。将出现一个下拉菜单,您可以从列表中选择int

现在我们需要为我们的其他字段做同样的事情。依次选择每个字段,并按以下方式设置它们:

  • 名称:将其设置为字符串

  • 描述:将其设置为字符串

  • 创建:将其设置为日期

  • 完成:将其设置为日期

  • 优先级:将其设置为整数

  • 是否完成:将其设置为布尔值

现在你已经定义了所有模型字段和类型,我们需要将模型添加到商店中。

将模型添加到商店

点击Project Inspector中的MyStore。就像我们对模型所做的那样,我们可能想要更改存储的名称,以便在代码中更容易引用并跟踪。点击userClassNamestoreID旁边的值,并将两者都更改为taskStore

接下来,您需要点击并编辑存储中的模型Config以选择我们的Task模型。完成后,您的存储配置应该看起来像这样:

将模型添加到存储

制作副本

现在我们已经有了我们的存储和模型,我们需要复制一份以保存我们的已完成任务。这两个存储将使用相同的模型和大部分相同的设置信息。我们只需要复制存储并更改iduserClassName的值。完成这些后,我们将为存储创建过滤器,以便它只获取我们需要的资料。

要复制TaskStore,在Project Inspector中右键单击它并选择Duplicate。将出现一个新的存储,称为MyStore2,具有相同的代理和模型信息。在Project Inspector中选择MyStore2,并在Config部分将iduserClassName的值都更改为CompletedStore

添加过滤器

现在我们有了两个存储,我们需要设置一些过滤器以确保TaskStore只加载当前任务,而CompletedStore只加载已完成任务。

您可以在每个存储的Config部分添加过滤器。首先,选择TaskStore,然后点击Config部分旁边Filters旁边的+按钮。这将添加一个名为MyFilter的新过滤器。点击MyFilter旁边的箭头以显示其Config选项。

我们需要向这个过滤器添加一个函数,以便告诉它要获取哪些记录。点击filterFn旁边的+按钮以添加一个新的过滤器函数。在Project Inspector区域的上方,filterFn应该位于MyFilter下方。在Property Inspector中点击filterFn以打开该函数的代码编辑器。

编辑器应该出现以下代码:

filterFn: function(item) {

}

这设置了过滤器的基本功能,并将存储中的每个记录作为item传递给我们。如果我们的函数返回true,则记录包含在存储中,如果返回false,则记录被忽略。

我们的模式有一个名为isComplete的布尔值。我们可以在函数中这样检查这个值:

return !item.data.isComplete;

这将接受我们传递的记录作为item,并检查记录的数据中的isComplete。如果任务记录已完成,这将变为true,因此我们在前面放置!字符以仅获取isCompletefalse的记录。过滤器现在已完成。

按照相同的步骤为CompletedStore添加一个过滤器:

  1. CompletedStore添加一个过滤器。

  2. 使用filterFn向过滤器添加一个函数。

  3. 添加过滤器函数的代码。

在这种情况下,我们的函数只需要查找isCompletetrue的任务(这次不要使用!字符):

return item.data.isComplete;

两个存储库现在都将正确地根据完成情况过滤任务。

当我们在屏幕上移动这些组件时,Sencha Architect 在后端为我们做了一些繁重的工作。让我们揭开幕布,看看实际代码中发生了什么。

注意幕后的那个人

首先要查看的是你在硬盘上保存的任务管理器项目文件。你会注意到设计师在这里创建了许多文件:app.htmlapp.jsTaskManager.xds。我们还有 app 和元数据文件夹。

Sencha Architect 使用 TaskManager.xdsmetadata 文件夹。TaskManager.xds 文件是你目前正在工作的主要项目文件,而 metadata 文件夹包含该项目文件的资源。我们现在可以忽略这些文件,因为有趣的内容在其他文件中。

让我们从 app.html 文件开始。如果你用你最喜欢的代码编辑器打开这个文件,你应该会看到类似这样的内容:

<!DOCTYPE html>

<!-- Auto Generated with Sencha Architect -->
<!-- Modifications to this file will be overwritten. -->
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Chapter1</title>
    <link rel="stylesheet" type="text/css" href="http://extjs.cachefly.net/touch/sencha-touch-2.1.0/resources/css/sencha-touch.css"/>
    <script type="text/javascript" src="img/sencha-touch-all-debug.js"></script>
    <script type="text/javascript" src="img/app.js"></script>
    <script type="text/javascript">
        if (!Ext.browser.is.WebKit) {
            alert("The current browser is unsupported.\n\nSupported browsers:\n" +
                "Google Chrome\n" +
                "Apple Safari\n" +
                "Mobile Safari (iOS)\n" +
                "Android Browser\n" +
                "BlackBerry Browser"
            );
        }
    </script>
</head>
<body></body>
</html>

这应该对熟悉 HTML 和 JavaScript 的人来说很熟悉。该文件创建了一个基本的 HTML 页面,包含了 Sencha Touch 的 JavaScript 和 CSS 文件,并且还包含了我们的 app.js 文件(我们稍后会讨论)。

文件随后设置了一些浏览器检测,以便如果用户尝试使用非 WebKit 浏览器访问应用程序,他们将会被告知他们的浏览器不兼容,并会得到一个兼容浏览器的列表。

注意

Chrome 和 Safari 是 WebKit 浏览器,可在 Windows 和 Mac 上使用。Chrome 也可在 Linux 上使用。在这本书中,我们将使用 Safari 进行测试,但示例同样适用于 Chrome。

还有一点需要注意,是 app.html 文件顶部注释中的信息。这个特定的文件每次你保存 TaskManager 项目时都会自动生成。如果你在代码编辑器中对其进行了修改,它们将在下一次保存时被覆盖。

小贴士

关于 CacheFly 的一些建议

CacheFly 是一个 CDN内容分发网络)。他们拥有全球各地的计算机,可以从离用户较近的服务器发送文件,这样文件在互联网上的传输时间更短,因此加载时间也更短。这也意味着,通过不自己提供这些文件,你可以节省自己的服务器带宽。

接下来,让我们看一下我们的 app.js 文件:

/*
 * File: app.js
 *
 * This file was generated by Sencha Architect version 2.0.0.
 * http://www.sencha.com/products/designer/
 *
 * This file requires use of the Sencha Touch 2.0.x library, under independent license.
 * License of Sencha Architect does not include license for Sencha Touch 2.1.x. For more
 * details see http://www.sencha.com/license or contact license@sencha.com.
 *
 * This file will be auto-generated each and every time you save your project.
 *
 * Do NOT hand edit this file.
 */

Ext.Loader.setConfig({
    enabled: true
});

Ext.application({
    models: [
        'Task'
    ],
    stores: [
        'TaskStore', 'CompletedStore'
    ],
    name: 'MyApp'
});

就像我们的 HTML 文件一样,我们在顶部有一个关于手动编辑文件的严厉警告。

小贴士

关于手动编辑的一些建议

Sencha Architect 会为你做很多工作,但这意味着它也可能意外覆盖你手动编写的代码。最好的办法是在使用 Architect 首先完全布局和配置好应用程序的所有组件后再添加任何代码。

如果我们跳过那个部分,我们会看到一个 Ext.Loader 的设置函数,然后是我们的应用程序定义。这包括所有模型和存储的链接,以及我们应用程序的名称(我们完成代码审查后可能需要更改一次)。

注意

Ext.Loader 是 Sencha Touch 的一个特殊部分,它会在需要时加载 JavaScript 文件。而不是在 HTML 文件中包含所有 JavaScript 文件,Ext.Loader 只会在需要时加载它们。这大大减少了应用程序的启动时间。你可以在www.sencha.com/blog/using-ext-loader-for-your-application了解更多关于 Ext.Loader 的信息。

关闭app.js文件,打开app文件夹。正如你所见,我们有两个名为modelstore的文件夹。当然,这些文件夹中包含了我们模型和存储的代码。

首先打开store/TaskStore.js文件:

Ext.define('MyApp.store.TaskStore', {
    extend: 'Ext.data.Store',
    requires: [
        'MyApp.model.Task'
    ],

    config: {
        autoLoad: true,
        model: 'MyApp.model.Task',
        storeId: 'TaskStore',
        proxy: {
            type: 'localstorage',
            id: 'Tasks'
        }
    },
        filters: {
            filterFn: function(item) {
                return !item.data.isComplete;
            }
        }
});

除了始终存在的关于手动编辑的警告之外,你还会看到我们的存储定义以纯 JavaScript 的形式编写。请注意,存储定义不仅包含我们的代理代码,还包括过滤函数,并列出我们的任务模型作为存储的模型。

小贴士

为什么是"MyApp.model.Task"

Ext.Loader 通过将点号转换为斜杠,将你的组件名称转换为文件名。这意味着如果你的组件是MyApp.model.Task,那么 Ext.Loader 将在你的应用程序文件夹中查找一个名为MyApp的文件夹。它将在该MyApp文件夹中查找一个包含Task.js文件的model文件夹。

这也是保持应用程序文件夹组织良好的好方法。如果你将所有模型放在model文件夹中,所有视图放在view文件夹中,那么当你需要查找它们时就会知道它们在哪里。

关闭TaskStore.js文件,让我们看看最后一个文件,model/Task.js。这是我们的模型文件:

Ext.define('MyApp.model.Task', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'name',
                type: 'string'
            },
            {
                name: 'description',
                type: 'string'
            },
            {
                name: 'created',
                type: 'date'
            },
            {
                name: 'completed',
                type: 'date'
            },
            {
                name: 'isCompleted',
                type: 'boolean'
            }

        ]
    }
});

注意,存储和模型中的内容有相当大的重叠。这种重复允许模型和存储独立于彼此工作,同时仍然保持数据本身的一致性。当我们处理应用程序的表单时,我们将更详细地探讨这一点。

架构与手动编码

正如你所见,Sencha Architect 为我们生成代码,但我们也可以选择手动创建完全相同的代码。Sencha Architect 通过允许以可视化的方式构建应用程序和允许开发者按需探索代码,为新手程序员提供了便利。设计师还会根据 Sencha Touch 最佳实践生成代码。这有助于新手用户避免学习坏习惯,并在程序员需要手动编程时鼓励编写更干净的代码。

对于经验丰富的程序员来说,Sencha Touch 提供了快速原型化和为客户创建模拟界面的方法。这些模拟界面背后的代码可以在设计师之外使用,以创建可能对 Sencha Architect 来说既困难又不可能完成的复杂应用程序。

通过结合使用 Sencha Architect 和传统的基于文本的编码,我们希望这本书能为读者在速度和一致性方面提供额外的益处。

创建视图

到目前为止,我们的代码实际上还没有在屏幕上创建任何内容。现在我们需要创建一些用户可以与之交互的视觉组件,从包含我们应用程序的主要面板开始。

从左侧的 工具箱 列表中拖动一个 标签面板 对象并将其拖放到设计师中的 iPhone 屏幕上。现在在 项目检查器 中将出现一个 面板 选项。选择 标签面板 并将以下信息添加到 配置 区域:

  • 确保勾选 initialView 复选框

  • userClassNamemyTabPanel 更改为 mainView

  • 通过右键单击并选择 删除 来删除第三个标签

保存项目,然后让我们再次查看您项目的代码。在 app.js 文件中,您现在将看到以下代码:

Ext.Loader.setConfig({
    enabled: true
});

Ext.application({
    models: [
        'Task'
    ],
    stores: [
        'TaskStore'
    ],
    views: [
        'MainView'
    ],
    name: 'MyApp',
    launch: function() {
        Ext.create('MyApp.view.MainView', {fullscreen: true});
    }
});

设计师现在添加了一个 launch 函数,它创建了一个 MainView 面板的实例并将其设置为全屏。

如果我们查看 app 文件夹,现在我们看到一个名为 view 的文件夹。这个文件夹包含我们的 MainView.js 文件:

Ext.define('MyApp.view.MainView', {
    extend: 'Ext.tab.Panel',

   config: {
        items: [
            {
                xtype: 'container',
                title: 'Tab 1'
            },
            {
                xtype: 'container',
                title: 'Tab 2'
            }
        ]
    }
});

目前这个文件只是将 MainView 定义为标准 Ext.tab.Panel 函数的扩展,并设置了包含我们两个标签的配置。当我们向面板添加更多组件时,它们将在这里的代码中显示。让我们回到设计师,并做同样的事情。

配置标签面板

我们可能首先应该做的是重命名标签。在 项目检查器 中选择 Tab 1,然后点击 配置 部分的 标题 值。将标题从 Tab 1 更改为 Current 并按键盘上的 Enter。对 Tab 2 也做同样的事情,将其标题更改为 Completed

配置标签面板

我们应该做的一件额外的事情是将标签移至底部。这将使我们的应用程序看起来更像经典的 iPhone。要做出这个更改,在 项目检查器 中选择 mainView,然后在 配置 部分找到 标签栏配置 列表。点击 标签栏配置 旁边的 + 按钮,新的 标签栏 选项将出现在下方。点击新 标签栏 选项旁边的箭头,将出现其配置选项。

定位到停靠的配置区域,并将其从顶部改为底部。选项卡应下拉到底部,给我们带来大多数 iPhone 用户熟悉的图标。您可以通过在项目检查器中点击当前完成选项卡并更改配置中的iconCls值来更改这些图标。选择您喜欢的图标并保存项目(我选择了organize作为当前选项卡的图标,delete作为完成选项卡的图标)。

完成后,在属性检查器中选择主视图,然后在设计器的右上角点击代码。这将使设计器切换到代码视图,显示我们的MainView.js文件内容。

Ext.define('MyApp.view.MainView', {
  extend: 'Ext.tab.Panel',
  config: {
  items: [
    {
        xtype: 'container',
        title: 'Current',
        iconCls: 'organize'
    },
    {
        xtype: 'container',
        title: 'Completed',
        iconCls: 'delete'
    }
  ],
  tabBar: {
    docked: 'bottom'
  }
  }
}); 

您现在可以看到,选项卡面板及其两个选项卡已添加到我们的MainView.js文件中。我们还看到了我们的tabBar配置和选择的iconCls值。

添加列表和详情

接下来,我们想要在每个选项卡上添加一个列表和一个面板。列表将显示当前和已完成任务的名称。面板将在我们点击列表中的任务时显示任务的详细信息。

让我们从选择每个选项卡并设置配置中的布局属性为卡片开始。这将使我们能够轻松地在每个选项卡的列表详情部分之间切换。

接下来,从工具箱列表中取一个列表组件,并将其拖放到属性检查器中的每个选项卡上。

小贴士

在属性检查器中放置项目

虽然大多数组件可以直接拖放到设计区域,但通常将组件拖放到项目检查器中会更好。使用属性检查器确保您将组件放入正确的容器中要容易得多。

接下来,您需要从工具箱中取一个面板,并将其拖放到每个选项卡上,就像我们处理列表时做的那样。这个面板将是我们的详情容器,并且它不会在设计视图中出现,因为列表在前。我们将在稍后创建一些代码来在用户点击列表中的任务时交换列表和容器。

您的属性检查器区域现在应该看起来像这样:

添加列表和详情

注意,选项卡(当前完成)都缩进在mainView选项卡面板下。每个选项卡下也有一个列表和一个面板。选项卡是mainView选项卡面板的子项,每个选项卡有两个子项;一个列表和一个面板。

由于我们将在代码中处理列表和面板,我们可能需要将它们命名为比MyListMyPanel更具有描述性的名称。为了做到这一点,您需要选择这些项目中的每一个,并在配置中更改id属性。让我们按以下方式重命名它们:

当前选项卡中,我们将它们命名为CurrentListCurrentDetails,而在完成选项卡中,我们将它们命名为CompletedListCompletedDetails

设置模板

接下来,我们需要设置列表和详情的模板(在配置区域中称为itemTpl)。这些模板控制我们的数据如何在屏幕上布局。记住,我们有以下数据项:

  • id

  • name

  • description

  • 已创建

  • 已完成

  • priority

我们可以通过将它们放在大括号中(如下所示)将它们包含在我们的模板中,从而使用这些值:

<div>{name}</div>

我们还可以将任何 HTML 样式或标签作为模板的一部分使用。这为我们控制应用程序布局提供了很大的灵活性。

要编辑我们的CurrentList组件的模板,在项目检查器中选择它,然后在我们的设计视图中列表旁边会出现一个齿轮图标。点击齿轮,你将看到一个弹出窗口,其中包含一些配置选项,包括弹出窗口底部的编辑模板

当你点击编辑模板时,将出现一个文本区域,其中包含以下文本:

<div>List Item {string}</div>

将此文本更改为:

<div class="priority_{priority}">{name}</div> 

当你完成编辑时,点击完成编辑。目前列表项将显示为空,但我们稍后会解决这个问题。

接下来,在项目检查器中点击CurrentDetails面板,并以与CurrentList相同的方式编辑模板。将CurrentDetails的模板设置为:

<div class="taskName">{name}</div> 
<div class="taskDescription">{description}</div> 
<div class="taskCreated">Created: {created}</div>

当你完成编辑时,点击完成编辑

当你完成CurrentDetails的编辑后,我们希望对CompletedListCompletedDetails执行相同的步骤。你可以保持列表模板不变,但我们应该在我们的详情页上包含完成日期,如下所示:

<div class="taskName">{name}</div> 
<div class="taskDescription">{description}</div> 
<div class="taskCreated">Created: {created}</div>
<div class="taskCompleted">Completed: {completed}</div>

使用启动数据测试

你会注意到,由于我们没有记录,我们没有什么可以展示的。这可能会使测试我们的应用程序变得困难,因此我们打算使用launch方法在我们的应用程序中添加一些测试记录。

项目检查器中选择应用程序,然后在配置部分中找到launch。点击launch旁边的+按钮以添加新的启动函数,然后点击新启动函数旁边的箭头以打开它。这将打开代码编辑器,显示:

launch: function() {

}

launch函数内部添加以下代码:

var TaskStore = Ext.data.StoreManager.lookup('TaskStore');
var CompletedStore = Ext.data.StoreManager.lookup('CompletedStore');
if(CompletedStore.getCount()+TaskStore.getCount() === 0) {
    console.log('we have no records');
    TaskStore.add({name: 'Here Is A Task', description: 'You can mark the task complete by clicking the Completed button below.', priority: 1, created: Date.now(), completed: '', isComplete: false});
    TaskStore.add({name: 'How To Edit A Task', description: 'You can edit the task by clicking the Edit button below.', priority: 2, created: Date.now(), completed: '', isCompleted: false});
    TaskStore.add({name: 'How To Add A Task', description: 'Add a task by clicking the Add button in the upper right corner.', priority: 3, created: Date.now(), completed: '', isComplete: false});
    TaskStore.sync();
} else {
    console.log('we have records');
}

此代码将获取我们的两个存储库,任务存储库和完成存储库,并检查是否有任何记录。如果没有记录,该函数将添加三条新记录,然后同步存储库以保存记录。这些任务记录也可以作为一组三条指令,供首次打开应用程序的新用户使用。

小贴士

控制台日志

console.log 命令是编程时的最佳朋友。它将在 Safari 或 Chrome 的错误控制台中打印文本和对象。这对于调试任何问题至关重要。在前面的代码中,控制台日志将根据我们是否获取回记录而打印。我们也可以使用 console.log(TaskStore) 来获取该存储对象每个属性的显示。这实际上非常方便,以确保你确实拥有你认为自己拥有的对象。

现在当你在浏览器中打开 app.html 文件时,你应该在 TaskMaster 应用程序中看到以下内容:

使用起始数据测试

我们现在有任务可以查看,但我们仍然无法获取详细信息。我们需要添加一个函数来在列表和详细信息之间切换视图。

在 Sencha Architect 中,点击 项目检查器 中的 CurrentList,然后在 配置 部分的顶部查找 事件。点击 事件 旁边的 + 按钮以添加新的事件监听器。使用出现的菜单选择 select 事件。点击新 select 事件旁边的箭头以编辑代码。代码编辑器将出现以下代码:

onCurrentListSelect: function(dataview, record, options) {

}

将以下代码添加到 select 事件:

  var currentTab = this.getActiveItem();

  var currentDetails = currentTab.down('panel');

  currentDetails.setRecord(record);
  currentTab.setActiveItem(currentDetails);

第一行获取我们的 Current 选项卡,第二行获取我们的 CurrentDetails 面板。然后我们将详细信息面板上的记录设置为作为 select 函数一部分传递给我们的记录(列表中的记录)。最后,我们将当前选项卡的卡片布局切换到 CurrentDetails 面板,隐藏 CurrentList

我们还需要对 CompletedList 组件做同样的事情。添加新的 select 事件,并将代码设置为:

var completedTab = this.getActiveItem();

var completedDetails = completedTab.down('panel');

completedDetails.setRecord(record);
completedTab.setActiveItem(completedDetails);

如果我们在浏览器中测试这个,我们应该能够在点击列表中的项目时看到我们的详细信息面板。这也带我们到了下一个挑战;我们需要一种方法回到我们的列表。

在本章中,我们将手动创建返回按钮,在下一章中,我们将介绍解决这个问题的另一种方法。现在,让我们在我们的工具栏中添加一个新按钮。

添加返回按钮

工具箱 中获取一个 toolbar 对象并将其拖动到 mainView 选项卡面板上。从 工具箱 中获取一个 button 对象并将其拖动到新的 toolbar 面板上。

接下来,我们想要给我们的工具栏添加一个标题。让我们选择工具栏,并在 配置 部分中将标题更改为 TaskMaster

接下来,我们需要对我们的按钮做一些更改。选择 Button 对象,并在其 配置 部分进行以下更改:

  • text 改为 Back

  • id 改为 backButton

  • ui 改为 back(这将使其看起来像典型的返回按钮)

  • 检查 hidden 属性旁边的框(我们希望按钮默认隐藏)

    小贴士

    我找不到一个配置选项

    如果您找不到一些这些属性,您可能需要使用Config部分右上角的两个按钮在Show Common ConfigsShow All Configs之间切换。

现在我们有了返回按钮,我们需要让它执行一些操作。通过点击按钮的Config部分旁边的事件旁边的+按钮,添加一个点击监听器。选择tap,然后点击出现的点击事件旁边的箭头。

将返回按钮代码编辑如下:

onBackButtonTap: function(button, e, options) {
  var currentTab = this.getActiveItem();
  currentTab.setActiveItem(0);
  button.hide();
}

通过获取this.getActiveItem(),我们获取了MainView标签面板中的活动标签,这确保了按钮可以正确地为我们的两个列表工作。我们将活动项设置为活动标签中的第一个项。最后,我们隐藏按钮,以确保它不会出现在我们的列表视图中。

我们需要最后处理的部分是在点击列表中的项时显示按钮。点击当前面板的select事件,并将以下内容添加到我们的select函数中:

var backButton = Ext.getCmp('backButton');
backButton.show();

您希望将完全相同的按钮代码添加到CompletedList组件的select事件中。只需复制,打开CompletedList的 select 事件,然后粘贴。

我们现在已经完成了列表和详情。现在我们需要能够添加、编辑和标记任务为完成。

创建表单

在我们开始创建表单之前,我们需要向MainView工具栏添加一个按钮,用于显示添加新任务的表单。

Toolbox列表中拖出一个Button对象,并将其拖放到TaskMaster工具栏上。新按钮应出现在backButton旁边。我们可能希望将其移到标题的另一侧,因此我们需要从Toolbox中拖出一个Spacer并将其放置在新按钮和backButton之间。Spacer将新按钮推到屏幕的右侧。

接下来,我们需要更改新按钮的以下Config属性:

  • text设置为Add

  • itemId设置为addButton

我们将在完成表单后回来添加一个tap函数。

添加任务表单

要创建我们的添加任务表单,我们将向Current选项卡添加一个新的表单面板。从工具箱中拖出一个Form Panel并将其拖放到我们的Current选项卡上。面板应出现在我们的CurrentListCurrentDetails面板下方。

接下来,我们需要将一些字段拖入我们的表单中,所以让我们先在MyFormPanel面板上放置一个Text Field对象,并更改以下Config属性:

  • name设置为name

  • label设置为Name

  • id设置为name

  • margin设置为3

接下来,将一个Text Area对象添加到MyFormPanel面板中,并设置以下Config属性:

  • name设置为description

  • label设置为Description

  • id设置为description

  • margin设置为3

现在,我们需要向MyFormPanel添加一个Select Field对象,并设置以下Config属性:

  • name设置为priority

  • label设置为Priority

  • id设置为priority

  • 边距设置为 3

我们还需要为选择字段添加一些选项。在Config属性中找到Options并点击编辑。Options属性期望一个对象作为其值,在这种情况下是一个类似这样的名称-值对的数组:

[{text: 'High',  value: 1},
{text: 'Medium', value: 2},
{text: 'Low',  value: 3}]

默认情况下,选择字段使用文本进行显示,使用值作为提交的值。您可以通过编辑Config属性中的displayFieldvalueFields来更改此设置,但我们可以将它们保留为我们的应用程序的默认值。

如果我们使用此表单来添加新任务和编辑现有任务,我们可以节省很多工作。为此,我们还需要添加一个隐藏字段来保存我们编辑的任何现有任务的 ID 值。

将一个隐藏字段对象添加到MyFormPanel中,并在Config部分将idname的属性设置为id。我们将在保存表单时使用它。

在我们的表单中,我们还需要两个按钮;一个用于保存,一个用于取消。添加按钮并做出以下更改:

  • 按钮 1文本设置为保存

  • 按钮 1itemID设置为SaveButton

  • 按钮 1边距设置为10

  • 按钮 2文本设置为取消

  • 按钮 2itemID设置为CancelButton

  • 按钮 2边距设置为10

结构和表单应该看起来像这样:

添加任务表单

接下来,我们将像之前一样使用ConfigEvent部分为每个按钮添加一个点击事件处理程序。

对于我们的取消按钮,将event函数设置为:

var currentTab = this.getActiveItem(); 
currentTab.setActiveItem(0);

此代码获取我们的当前标签页并将活动面板重置为CurrentList

保存按钮稍微复杂一些。如我们之前提到的,我们希望使用此表单来添加新任务和编辑现有任务。这意味着我们需要检查表单的隐藏字段值是否已设置,并正确保存任务。

将以下代码添加到SaveButton的点击事件函数中:

var currentTab = this.getActiveItem();
var formPanel = currentTab.getActiveItem();

var values = formPanel.getValues();

var store = Ext.data.StoreManager.lookup('TaskStore');

if(values.id === null) {
    var record = Ext.ModelMgr.create(values, 'MyApp.model.Task');
    record.set('created', new Date());
    store.add(record);
} else {
    var record = store.getById(values.id);
    record.set('name', values.name);
    record.set('description', values.description);
    record.set('priority', values.priority);
}

store.sync();
formPanel.reset();
currentTab.setActiveItem(0);

我们的前两行获取currentTabformPanel。然后,我们获取formPanelstore中的值,以便将数据保存到我们的数据中。

我们检查隐藏字段的值以查看它是否已设置。如果我们正在编辑,这将返回true,但如果我们正在添加新任务则不会。

如果我们正在添加新任务,我们使用表单字段中的values表单创建一个新的record选项,设置创建日期,并将record添加到store

如果我们正在编辑现有记录,我们使用store中的id值从store获取record。然后,我们从表单values设置recordnamedescriptionpriority值。

最后,我们将store同步以保存record,清除表单values,并通过将活动项重置为我们的CurrentList视图(0)来关闭表单。

编辑和完成任务

对于编辑任务,我们将使用我们刚刚创建的表单,但我们需要用当前选定的记录加载它。对于完成任务,我们只需要一个按钮。

要做到这一点,我们将在我们的当前详情面板中添加一个带有两个按钮的工具栏。我们可能需要在两个按钮之间添加一个间隔对象,就像我们在之前的工具栏中所做的那样。

每个按钮也需要在配置部分下的事件中添加一个点击事件。

对于编辑按钮,设置点击函数为:

var currentTab = this.getActiveItem();
var DetailsPanel = currentTab.getActiveItem();

currentTab.setActiveItem(2);
var formPanel = currentTab.getActiveItem();
formPanel.setRecord(DetailsPanel.getRecord());

this.setActiveItem(currentTab);

var backButton = Ext.getCmp('backButton');
backButton.hide();

此代码从DetailsPanel面板中获取record并将其加载到formPanel中。将此record设置在表单上也会设置我们隐藏字段的正确id值。然后我们像之前一样显示表单并隐藏返回按钮。

对于我们的完成任务按钮,我们需要从DetailsPanel获取record并设置completed(一个日期)和isCompleted(一个布尔值)的值。我们通过将点击事件函数设置为以下内容来实现:

var currentTab = this.getActiveItem();
var detailsPanel = currentTab.getActiveItem();

var record = detailsPanel.getRecord();

record.set('completed', new Date());
record.set('isComplete', true);

var store = Ext.data.StoreManager.lookup('TaskStore');
store.sync();

this.setActiveItem(1);
var completedList = this.getActiveItem();
var completedStore = completedList.getActiveItem().getStore();
completedStore.add(record);
completedList.getActiveItem().refresh();

currentTab.setActiveItem(0);

var backButton = Ext.getCmp('backButton');
backButton.hide();

这就像之前一样获取我们的record,设置我们的两个值,并同步TaskStore。此同步还将导致TaskStore上的过滤器阻止记录在当前列表中显示。

接下来,我们将记录添加到CompletedStore中,并刷新我们的completed列表视图。最后,我们将用户返回到当前列表并隐藏返回按钮。

测试应用程序

如果我们一切都做得正确,你应该能够在 Safari(或 Chrome)中打开app.html文件并测试应用程序。尝试将编辑和标记任务标记为已完成。务必使用浏览器中的 JavaScript 控制台来追踪问题并查看错误。

测试应用程序

额外加分

任务管理应用程序的设计多种多样,功能丰富。似乎每个人都对跟踪任务有自己的偏好。您可以使用此应用程序作为您自己的个人任务管理应用程序的基础。以下是将应用程序提升到下一个级别的几个想法:

  • 根据列表和详情中的任务优先级在 CSS 文件中添加样式

  • 添加一种按日期和优先级对任务进行排序的方法

  • 自定义CurrentDetailsCompletedDetails模板以添加优先级的图标

摘要

在本章中,我们讨论了使用本地存储的应用程序的基本设置,包括:

  • Sencha Architect 应用程序的基本知识

  • 创建用于本地存储和任务模型的数据存储

  • 为我们的数据存储创建列表和详情

  • 创建事件以在列表详情视图之间切换

  • 创建按钮以控制导航并启动我们的表单

  • 创建用于编辑和添加新任务的表单

在下一章中,我们将探讨使用布局和模板创建更复杂和视觉上吸引人的应用程序。

第二章。源阅读器

在我们的第一个项目,任务管理器中,我们探讨了 Sencha Architect 的一些基础知识。我们还介绍了在 Sencha Touch 中存储、编辑和显示数据的一些方法。

本章将探讨三个新的领域:

  • 导航视图

  • 加载远程数据

  • 使用 Sencha XTemplate 创建复杂布局

在本章中,我们将构建一个 RSS 阅读器,从一系列网站抓取新闻源,并以复杂的行列模式显示这些源的内容:

源阅读器

新闻阅读器也将基于 Sencha Touch 的 NavigationView 组件,为触摸设备自动化许多有用的导航元素。

基本应用程序

我们的基本应用程序将与我们之前的应用程序以几乎相同的方式开始:

  • 一个用于包含所有视图组件的 NavigationView 组件

  • 一个用于显示源列表

  • 一个用于存储列表数据的存储

  • 一个用于描述数据的模型

  • 一个用于向列表添加项目的表单

我们将花费大部分时间设置 NavigationView,以及更简短的时间介绍其他组件,因为这些组件与我们在 第一章,简单任务列表 中所做的是非常相似的。

导航视图概述

在 第一章,简单任务列表 中,我们手动将返回按钮编码到我们的详细信息视图中。默认情况下,返回按钮是隐藏的,并且仅在点击列表中的项目并出现详细信息视图时显示。返回按钮将用户返回到主要任务列表,然后再次隐藏,直到再次需要。

在我们的新源阅读器应用程序中,我们将使用 NavigationView,它自动处理所有这些功能。NavigationView 的功能类似于卡片布局,其中可以根据视图的活动项目隐藏或显示多个面板。

然而,与卡片布局不同,其中项目通常在布局创建时声明,NavigationView 通常使用 push() 函数动态添加项目,用于显示面板和其他项目。例如,假设我们有一个名为 MainViewNavigationView 组件,它包含一个带有项目列表的列表视图。我们还有一个名为 Details 的详细信息面板。当用户点击列表中的项目时,我们可以调用:

var main = Ext.getCmp('MainView');
var details = Ext.create('MyApp.view.Details', {
    title: 'Something Cool'
});
main.push(details);

此代码将我们的详细信息视图的副本推送到 NavigationView (MainView)。NavigationView 将使用动画过渡将新的详细信息面板滑入位置,为我们自动创建返回按钮,并将我们的导航栏标题设置为 Something Cool。它还处理所有幕后代码,在点击返回按钮时带我们返回到主要列表。

这使得 NavigationView 成为一个非常适合作为我们应用程序基础的组件。

让我们从在 Sencha Architect 中创建一个新的 Sencha Touch 应用程序开始。对于这个应用程序,我们将针对 iPad 平板电脑大小的屏幕。这将给我们更多的空间来创建有趣的显示。我们还将向您展示如何动态调整 iPhone 和手机用户的屏幕。

使用设计区域底部的大小菜单选择 iPad 作为我们的屏幕大小,并将一个NavigationView对象从工具箱拖动到显示区域中的 iPad 图像上。在配置部分,将userAlias设置为MainView,以便我们稍后可以引用它。

接下来,我们需要向MainView添加一个列表视图,并给它一个存储(带有LocalStorage代理)和模型,就像我们在第一章中做的那样,一个简单的任务列表

  1. 首先,添加一个模型并按以下方式配置它:

    • userClassName设置为Feed

    • 添加三个字段:

      • id作为int

      • name作为string

      • url作为string

  2. 接下来,添加存储并按以下方式配置它:

    • 一个LocalStorage代理

    • userClassName设置为FeedStore

    • storeId设置为FeedStore

    • model设置为Feed

  3. 最后配置列表:

    • title设置为Feed Bag

    • itemTpl设置为<div>{name}</div>

    • id设置为FeedList

    • store设置为FeedStore

    NavigationView 概述

添加表单

由于我们的表单这次相当简单(并且为了增加一些多样性),我们将使用一个工作表来显示我们的表单。工作表可以设置为从屏幕的顶部、底部或侧面滑入。它也可以设置为从中心弹出。

在此情况下,我们不希望工作表成为我们的MainView容器的子项;我们只想在我们需要时创建它,而不是在应用程序启动时。为此,我们将Sheet对象拖动到项目检查器上,并将其放置在视图图标上。这将创建一个与我们的MainView容器分开的Sheet视图。

按以下方式配置工作表:

  • userClassName设置为AddSheet

  • enter设置为top

  • exit设置为bottom

  • stretch设置为true

  • stretchY设置为true

接下来,我们需要在我们的工作表中添加一个表单面板对象,包含以下项目:一个容器,两个文本字段和两个按钮对象。

容器只是一个为用户提供一些说明的地方。将html属性设置为:

'Add an RSS feed URL and a Name. Feed URLs should be in the format: http://feedURL.com'

按以下方式配置两个文本字段:

  • 对于字段 1

    • id设置为name

    • name设置为name

    • label设置为名称

    • margin设置为3 0 3 0

  • 对于字段 2

    • id设置为url

    • name设置为url

    • label设置为URL

    • margin设置为3 0 3 0

按照以下方式配置两个按钮:

  • 对于按钮 1

    • id设置为SaveButton

    • text设置为保存

    • margin设置为10

  • 对于按钮 2

    • id设置为CancelButton

    • text设置为取消

    • margin设置为10

接下来,我们需要为我们的两个按钮添加触摸监听器。在每个按钮的配置中的事件部分,点击+按钮并选择基本事件绑定。当菜单出现时,选择触摸

添加表单

对于取消按钮,我们的点击功能相当简单:

this.down('formpanel').reset(); 
this.hide();

在函数内部,this 指的是我们的表单视图。代码向下传递到表单中,找到表单并清除所有字段值。然后表单会隐藏自己。

保存按钮的工作方式与上一章中的按钮类似:

var formPanel = this.down('formpanel');

var values = formPanel.getValues();

var store = Ext.data.StoreManager.lookup('FeedStore');

var record = Ext.ModelMgr.create(values, 'MyApp.model.Feed');
store.add(record);
store.sync();

this.hide();

我们从表单视图(this)向下移动以获取表单面板。然后我们从表单中获取值并找到我们的存储库。接下来,使用模型管理器创建一个新的内容记录,并用表单中的值填充它。最后,我们将记录添加到存储库中,同步存储库以保存它,然后隐藏表单。

接下来,我们需要一种方法来显示添加内容项的表单。

返回导航视图

在我们的主视图组件中,我们需要使用导航栏旁边的+按钮,将一个导航栏对象添加到配置部分。这个导航栏将显示我们的后退按钮和标题。它还将提供一个放置添加按钮的位置,该按钮将显示用于添加内容项的表单。

我们不需要更改导航栏的任何配置选项,所以只需将一个新的按钮对象拖放到它上面作为添加按钮。设置按钮的配置如下:

  • align 设置为 right

  • text 设置为 Add

  • id 设置为 addButton

然后我们需要添加一个点击事件监听器,就像我们为其他按钮所做的那样。我们的点击事件代码需要创建一个新的 AddSheet 实例并显示它。它还必须在创建表单之前进行一些思考,以确保没有已经存在的表单。

var sheet = Ext.getCmp('AddSheet');
if(!Ext.isDefined(sheet)) {
    sheet = Ext.create('MyApp.view.AddSheet');
    Ext.Viewport.add(sheet);
}
sheet.show();

我们首先调用 Ext.getCmp 来查看是否已经存在一个表单。这是因为我们设置了保存和取消按钮来隐藏表单,但不会销毁它。它仍然是应用程序的一部分(仍在内存中),但不会被显示。

如果我们之前已经使用了添加按钮,那么 Ext.getCmp 将返回一个有效的组件。这就是我们在第二行检查 !Ext.isDefined(sheet) 的内容。如果表单尚未定义(尚未创建),我们使用 Ext.create('MyApp.view.AddSheet'); 来创建我们的表单,然后将其添加到视图中。

到目前为止,我们应该有一个有效的表单组件,然后我们可以直接调用 sheet.show();

我们的应用程序现在应该能够添加并显示新的内容项到列表中。通过使用 Safari 打开保存项目文件夹中的 app.html 文件来测试应用程序,以确保一切正常工作:

返回导航视图

接下来,我们需要为主视图导航视图添加逻辑,以便我们可以为每个内容源显示一个漂亮的布局页面。

添加控制器

通过将其拖放到项目检查器控制器部分来向项目中添加一个控制器。您可以在工具箱下的行为中找到它。将控制器的 userClassName 属性设置为 FeedController

我们还希望在控制器中为mainView添加一个Reference属性。点击Reference旁边的Add按钮,将ref属性设置为mainView,并将Selector设置为MainView(选择器需要与主要导航视图的userAlias实例匹配)。

添加这个引用将允许我们通过调用this.getMainView()在控制器内部任何地方轻松获取 MainView 导航。

小贴士

等等,它不应该写成 getmainView 而不是 getMainView 吗?

在这个例子中应该指出的一点是,当创建引用时,Sencha 会自动为引用组件创建一个“getter”函数。尽管我们的引用有一个小写的m,但getMainView函数将其改为大写,M。鉴于 JavaScript 的大小写敏感性,这种自动的大小写转换可能会导致大量的烦恼和彩色语言。

现在我们有了参考,我们需要添加一个在用户点击订阅列表时执行的操作。点击Actions旁边的+按钮,并设置以下信息:

  • controlQuery设置为#FeedList

  • targetType设置为Ext.dataview.List

  • fn设置为onListItemTap

  • name设置为itemtap

接下来,我们需要双击itemtap操作来打开我们的代码编辑器。这是当列表被点击时将触发的代码。注意,函数已经设置好,可以传递给我们一些有用的项目,包括数据视图本身和用户点击的项目记录。我们将设置此操作调用另一个函数,如下所示:

onListItemTap: function(dataview, index, target, record, e, options) {
    this.createFeedDetailsView(record.get('name'), record.get('url'));
}

我们将通过使用record.get()将记录nameurl传递给新的createFeedDetailsView函数。

如果我们查看控制器的Code视图,它应该看起来像这样:

Ext.define('MyApp.controller.FeedController', {
    extend: 'Ext.app.Controller',
    config: {
        refs: {
            mainView: 'MainView'
        },

        control: {
            "#FeedList": {
                itemtap: 'onListItemTap'
            }
        }
    },

    onListItemTap: function(dataview, index, target, record, e, options) {
        this.createFeedDetailsView(record.get('name'), record.get('url'));
    }
});

在这里,我们看到 Sencha 已经设置了我们的FeedController函数,使其扩展主要的Ext.app.Controller组件。这意味着它继承了Ext.app.Controller组件的所有基本功能。

config部分,我们在refs部分看到了我们的引用设置。controls部分告诉控制器监听哪个组件(#FeedList),监听哪个事件(itemtap),以及当事件发生时调用哪个函数(onListItemTap)。

在这里,我们需要做的最后一件事是为我们的createFeedDetailsView函数创建代码。这段代码需要使用 URL 获取 RSS 源,创建一个新的视图,并将其推送到主要导航视图。

在我们进行之前,有一些事情需要考虑:我们如何从远程源获取数据,以及我们如何将其格式化为易于使用的结构(JSON)?

为了回答这些问题,我们需要更好地理解 Sencha Touch 如何与外部服务器通信,以及这些交易类型中涉及的一些限制。

从远程源获取数据

由于安全原因,JavaScript(以及因此 Sencha Touch)不允许向其他域发起 AJAX 请求。这意味着如果您的应用程序位于myCoolApp.com,并且您向boingboing.net的 RSS 源发起 AJAX 请求,它将被拒绝。

这是因为同源策略,它指出某些浏览器功能(如 cookies 和 AJAX 请求)不能在不同服务器之间共享。理由是 JavaScript 在用户计算机上的浏览器中执行。这赋予了 JavaScript 一些独特的与用户交互的能力,而无需始终与 Web 服务器保持联系。一旦 Web 浏览器加载了初始 JavaScript 文件,它们就会存储在用户的机器上,直到缓存被清除。这意味着应用程序可以在离线状态下继续运行。

然而,正如我们所知,权力越大,责任越大。在用户的计算机上运行远程代码的能力可能导致人们做非常糟糕的事情。特别是 AJAX 请求,因为它们可以在没有任何直接用户请求的情况下发生。

因此,JavaScript 中的跨域 AJAX 请求是一个非常糟糕的想法。虽然可能很容易确定自己的代码有正当意图,但来自另一个域的未经检查的代码可能具有潜在的恶意。

注意

如果您想了解更多关于同源策略的信息,这篇维基百科文章是一个很好的起点:

en.wikipedia.org/wiki/Same_origin_policy

进入 JSONP 代理

我们可以通过使用 Sencha 的 JSONP 代理组件来绕过同源策略发送请求。该组件直接将包含代理 URL 的<script>标签注入 DOM 中,以绕过跨域限制。脚本标签看起来像常规的 JavaScript 嵌入标签,类似于这样:

<script src="img/articles?callback=someFunction"></script>

响应被包含,就像任何其他 JavaScript 包含一样。JSONP 代理使用自动生成的callback函数来处理这些数据并将其发送回代理。这里的一个注意事项是,响应必须是 JSON 格式,以便callback函数能够正确处理它。

注意

关于跨站脚本问题的完整解释可以在这里找到:en.wikipedia.org/wiki/Cross-site_scripting,以及 JSONP 代理组件的概述可以在这里找到:docs.sencha.com/touch/2-0/#!/api/Ext.data.proxy.JsonP

这引出了我们将在 RSS 源中遇到的其他问题:它们是以 XML 格式而不是 JSON 格式编写的。由于我们需要 JSONP 存储进行跨站请求,以及 JSON 编码的响应供callback函数处理,因此没有更多的调整,XML 对我们来说将不起作用。

这基本上给我们提供了两个选项。第一个选项是使用另一种编程语言编写一些代码,以便为我们发出代理请求。这包括 PHP、Ruby、ASP 和 Perl 等语言,这些语言在本地服务器上与我们的应用程序一起运行,并且不受 JavaScript 相同的安全限制。

本地代理将从我们的存储中接收请求以及变量,然后使用我们的变量向远程服务器发出请求。然后远程服务器将请求发送回我们的本地代理,本地代理再将它以我们需要的任何格式传递回存储。这是一种足够好的完成任务的方式,但对于我们的简单应用程序来说可能有些过度。不用担心,我们稍后会讨论这个问题。

我们还有一个名为 YQL 的第二个选项,即 Yahoo 查询语言,我们将在应用程序中使用它。

Yahoo 查询语言 (YQL)

Yahoo 查询语言YQL)是为了以类似于 结构化查询语言SQL)的语言搜索公开数据源而开发的,SQL 是处理数据库信息的一种标准语言。

一个典型的 SQL 请求可能看起来像这样:

select * from users where lastname = 'Scalzi';

这将获取 users 表中姓氏为 Scalzi 的所有记录。* 字符告诉我们的查询获取该记录的所有数据。

一个 YQL 请求的结构类似,可能看起来像这样:

select * from rss where url= "http://feeds.boingboing.net/boingboing/iBag" 

这个请求将返回来自 boingboing.net 的 RSS 源数据。

注意

YQL 可以访问大量的公开数据源。要了解这些数据源并更好地了解 YQL 的可能性,请访问:developer.yahoo.com/yql/

现在我们已经可以从数据源获取信息,我们需要弄清楚如何以我们的 Sencha Touch 应用程序能够理解的方式发送它。

YQL 带来的一个额外好处是,我们的结果可以设置为以多种不同的格式返回,包括我们应用程序需要的 JSON 格式。在我们为应用程序设置 YQL 查询之前,我们应该查看查询返回的实际 JSON。我们可以使用 YQL 控制台来完成这个操作。

YQL 控制台

YQL 控制台(位于:[developer.yahoo.com/yql/console/](http://developer.yahoo.com/yql/console/))提供了一个简单的方式来测试各种 YQL 命令,并立即看到结果:

YQL 控制台

将之前截图中的查询输入到控制台,选择 JSON,然后点击 TEST 按钮。

你将得到一个以以下内容开始的 JSON 大包作为回应:

cbfunc({
 "query": {
  "count": 30,
  "created": "2012-05-10T15:25:40Z",
  "lang": "en-US"…

cbfunc 头是 JSONP 存储将要使用的回调函数来处理响应。我们不需要担心这个问题,因为存储会自动处理它。我们需要的第一条信息是 query 数组中列出的 count 参数。我们可以在代码中引用它为 query.count

在结果下方,您将看到我们需要用于 dataview 的实际项目:

"results": {
   "item": [
    {
     "title": "Minecraft heads to consoles",
     "link": "http://feeds.boingboing.net/~r/boingboing/iBag/~3/iyy5tLpUCpU/minecraft-heads-to-consoles.html",
     "category": [
      "Short",
      "Games",
      "minecraft"
     ],
     "creator": {
      "dc": "http://purl.org/dc/elements/1.1/",
      "content": "Rob Beschizza"
     }…

由于这些结果嵌套在query数组中,我们需要告诉存储将我们的rootProperty属性设置为query.results.item。这个item数组包含了我们的查询返回的 30 个列表。

当这些项目成为存储的一部分时,我们将可以通过名称访问每个单独的项目。例如,从之前的代码中的title将给我们Minecraft heads to consoles,而creator.content将给我们Rob Beschizza。所有这些数据都将可在我们的 dataview 项目模板中访问。

您需要设置一个数据模型来获取您感兴趣的信息片段。对于我们的目的,我们将使用以下数据模型:

Ext.define('MyApp.model.FeedItem', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'title'
            },
            {
                name: 'link'
            },
            {
                name: 'pubDate',
                type: 'date'
            },
            {
                mapping: 'encoded.content',
                name: 'content'
            },
            {
                mapping: 'creator.content',
                name: 'creator'
            },
            {
                name: 'description'
            },
            {
                name: 'thumbnail'
            },
            {
                name: 'author'
            }
        ]
    }
});

注意,我们需要映射一些更深层次的项,例如encoded.contentcreator.content。这个模型将为我们提供我们视图所需的所有数据项。

让我们看看这一切是如何结合在一起的。

同时,回到控制器

如果您还记得,在我们的FeedController函数中,我们有一个看起来像这样的触摸处理程序:

onListItemTap: function(dataview, index, target, record, e, options) {
        this.createFeedDetailsView(record.get('name'), record.get('url'));
    }

我们现在需要设置createFeedDetailsView函数,并使其创建我们的数据存储以连接到 YQL 服务器。

通过点击functions旁边的+按钮,并设置fnConfig值为createFeedDetailsView来添加新函数。然后添加两个paramsnameurl

在代码编辑器中,我们希望我们的函数能够抓取从我们的触摸函数传递过来的名称和 URL,并创建一个新的数据存储。然后我们将加载存储,并使用它向新的 dataview 提供数据。最后,我们将这个新的 dataview 推送到我们的主导航视图中进行显示。

代码看起来像这样:

createFeedDetailsView: function(name, url) {
    var newURL = 'http://query.yahooapis.com/v1/public/yql?',
        yql = {
            q: 'select * from rss where url="' + url + '"',
            format: 'json'
        };

    newURL += Ext.Object.toQueryString(yql);
    var details = Ext.create(
    'MyApp.view.FeedDetails', {
        title: name,
        store: Ext.create('MyApp.store.FeedItemStore', {
            proxy: {
                type: 'jsonp',
                url: newURL,
                reader: {
                    type: 'json',
                    rootProperty: 'query.results.item',
                    totalProperty: 'query.count'
                }
            }
        })
    });
    details.getStore().load();
    this.getMainView().push(details);
}

我们的第一行设置我们的 URL 为主连接点:

var newURL = 'http://query.yahooapis.com/v1/public/yql?'

我们然后使用 YQL 所需的格式设置我们的变量。这包括一个查询字符串(q)和返回值的格式类型,它将是 JSON:

yql = {
q: 'select * from rss where url="' + url + '"',
            format: 'json'
};

接下来,我们将我们的变量转换为查询字符串,并将其添加到我们的 URL 中,如下所示:

newURL += Ext.Object.toQueryString(yql);

接下来,我们需要在我们的主导航视图中添加一个新的 dataview。我们将在下一节创建实际视图,但在此期间,我们可以像这样输入代码来显示它:

var details = Ext.create(
    'MyApp.view.FeedDetails', {
        title: name,
        store: Ext.create('MyApp.store.FeedItemStore', {
            proxy: {
                type: 'jsonp',
                url: newURL,
                reader: {
                    type: 'json',
                    rootProperty: 'query.results.item',
                    totalProperty: 'query.count'
                }
            }
        })
    });

这将使用MyApp.view.FeedDetails(我们稍后会添加的视图)创建details视图,并使用我们在函数开头创建的newURL字符串设置其存储。

我们的reader配置设置为json,而rootPropertytotalProperty配置设置为 YQL 服务器为每个属性返回的默认值。rootProperty告诉读取器在哪里开始查找结果,而totalProperty告诉读取器我们获得了多少结果。

最后,我们加载我们的存储并将新的 dataview 添加到我们的主导航面板中:

details.getStore().load();
this.getMainView().push(details);

现在我们已经设置了存储代码,是时候为 MyApp.view.FeedDetails 创建数据视图了,这是显示源数据所需的。

详情数据视图

当使用 Sencha Touch 时,很容易将数据视图视为一个花哨的列表。然而,这往往会让人们陷入数据视图的列表方面,而忽略了更复杂布局的可能性。

例如,报纸风格的布局非常适合使用数据视图。布局是一系列包含标题、日期、作者和内容的文章集合。然而,它避开了列表的标准概念,并使用更直观的布局来代替:

详情数据视图

报纸风格的布局在大尺寸平板屏幕上效果很好,但在小尺寸手机屏幕上阅读起来会很困难。我们需要一种根据用户设备更改应用程序布局的方法。

幸运的是,Sencha Touch 不仅理解设备之间的差异,还利用了 HTML5 标准的强大功能,该标准也理解不同的平台和设备。

我们将在后面的章节中介绍 Sencha Touch 如何管理不同的设备。现在,我们将使用 CSS 媒体查询根据设备加载不同的样式表。在我们深入探讨这个话题之前,我们需要创建数据视图和 XTemplate。

XTemplate 实际上是一个带有一些额外功能的 HTML 模板。XTemplate 接收你提供的数据,并使用它通过替换花括号中的任何值来填充模板,以匹配数据中的相应值。

例如,假设你有一个看起来像这样的 XTemplate:

<div class="name">{name}</div>

在这个例子中,XTemplate 将在数据中搜索一个名为 name 的变量,并将其插入到 {name} 的模板位置。你可以使用这些花括号来引用数据记录中的任何字段。

XTemplate 还为我们提供了在 HTML 模板中添加逻辑(如 "if...then" 语句、简单数学等)的机会。

让我们为我们的数据视图创建一个,看看它是如何组合在一起的。

在 Sencha Architect 中创建一个新的数据视图,并将 userAlias 设置为 feeddetails,将 userClassName 设置为 FeedDetails。将存储配置设置为使用我们之前创建的 FeedItemStore

接下来,我们需要创建一个 itemTpl XTemplate。你应该在 Project Inspector 下的新 FeedDetails 数据视图下看到 itemTpl 的列表,如果你选择它,Code 编辑器将显示默认的 itemTpl 以供编辑。

我们的 itemTpl 需要考虑 RSS 源中可能缺失的数据(RSS 源通常缺少描述、图标和其他元素)。我们还需要将日期转换为 Sencha Touch 能够理解的形式,并且需要遍历 YQL 请求返回的一些嵌套数组元素。

让我们先看看完整的模板,然后我们将逐一介绍不同的部分,以了解我们在做什么:

itemTpl: [
    '<tpl if="thumbnail">',
    '  <article class="hasThumbnail">',
    '    <tpl else>',
    '      <article>',
    '    </tpl>',
    '    <header>',
    '      <div class="headline">',
    '        <tpl if="thumbnail">',
    '          <tpl for="thumbnail">',
    '            <img class="thumbnail" src="img/{url}" height="{height}" width="{width}" alt="Thumbnail" />',
    '          </tpl>',
    '        </tpl>',
    '        <h2>{title}</h2>',
    '      </div>',
    '      <tpl if="creator"><p class="creator">by {creator}</p>',
    '        <tpl elseif="author"><p class="creator">by {author}</p></tpl>',
    '        <tpl if="pubDate"><time datetime="{pubDate:date("c")}">{pubDate:date("M j, Y, g:i a")}</time></tpl>',
    '    </header>',
    '    <div class="description">{description}</div>',
    '    <div class="content">',
    '      <tpl if="content.length &gt; 0">',
    '        <tpl for="content">',
    '          <tpl if="xindex == 2">{.}</tpl>',
    '        </tpl>',
    '        <tpl else>',
    '          {description}',
    '        </tpl>',
    '    </div>',
    '    <footer>',
    '      <a href="{link}">Read Original Article</a>',
    '    </footer>',
    '    </article>'
] 

我们的第一部分是检查是否有文章的缩略图,使用<tpl if="thumbnail">。如果有,我们就用hasThumbnail类来样式化文章容器,如果没有,我们就只用基本的<article>标签。这样我们就可以根据是否有缩略图使用 CSS 做不同的处理。

接下来,我们开始构建我们的页眉部分,创建一个包含文章的thumbnailtitle变量的<div>标签,并具有headline类。我们还检查是否有thumbnail,然后通过<tpl for="thumbnail">遍历缩略图数据,以访问thumbnail数组中的单个元素(heightwidthurl)。

接下来,我们关闭标题div标签,并将作者/创建者和日期添加到模板中。RSS 源可以使用作者或创建者来指代撰写文章的人。我们使用<tpl if="creator"><tpl elseif="author">来确保如果它们可用,我们得到其中一个。

然后,我们检查是否有日期,并将其转换为我们喜欢的格式:

<tpl if="pubDate"><time datetime="{pubDate:date("c")}">{pubDate:date("M j, Y, g:i a")}</time></tpl>

注意,我们还在 HTML5 <time> 元素内部包装了日期,这为浏览器提供了上下文数据,允许使用本地化、视觉时间线以及将事件添加到日历等特性。我们将datetime设置为浏览器能理解的格式,然后我们将显示的日期和时间设置为更符合用户习惯的形式。

接下来我们格式化描述和内容。描述相当直接,但内容则稍微复杂一些。

根据设备的尺寸,我们可能不想显示完整的内容 div,而只想显示较短的描述 div。我们可以设置 CSS 只显示<div class="description"><div class="content">块,具体取决于设备。然而,内容元素是可选的,所以如果没有内容,我们需要在<div class="content">块中显示描述。

为了使事情更复杂,我们 YQL 查询返回的内容是一个包含两个元素的嵌套数组:一个内容定义链接,我们可以忽略它,以及我们想要的实际内容。为此,我们遍历内容并使用内置的xindex变量来计数循环,将第二个数据元素添加到我们的 XTemplate 中({.}是循环中的当前数据元素)。

    '        <tpl for="content">',
    '          <tpl if="xindex == 2">{.}</tpl>',
    '        </tpl>'

最后,我们用页脚和指向原始文章的链接来关闭我们的 XTemplate。

我们将在后面的章节中查看一些其他的 XTemplate 选项,或者您可以在在线文档中查看完整的选项列表:

docs.sencha.com/touch/2-0/#!/api/Ext.XTemplate

现在,是 CSS 的时间了

由于我们的容器目前存在,显示不会很有趣。它只是简单的数据块一个接一个。虽然这对小设备如手机来说是可以的,但对于更大的平板显示器来说则有点单调。

我们将通过创建这三个单独的 CSS 文件来解决这个问题:

  • feedbag.css

  • feedbag-tablet-portrait.css

  • feedback-tablet-landscape.css

我们将在app.html文件中这样链接这些文件:

    <link rel="stylesheet" type="text/css" href="feedbag.css"/>
    <link rel="stylesheet" type="text/css" href="feedbag-tablet-portrait.css" media="only screen and (min-device-width : 700px)"/>
    <link rel="stylesheet" type="text/css" href="feedbag-tablet-landscape.css" media="only screen and (min-device-width : 700px) and (orientation : landscape)"/>

第一个文件,feedbag.css,包含我们所有的默认样式,如颜色、字体大小等。它还包含小手机屏幕样式,在列表视图中只显示我们的图片和标题。

这组样式被下一个文件覆盖,该文件包含平板电脑的肖像样式(最小设备宽度:700 像素)。此文件使用与上一个文件相同的样式,但将我们的内容组织成两列的块。它还在列表视图中显示描述块。

最后一个文件用于横向排列的平板设备。与之前一样,它将覆盖前几个文件中的某些样式以进一步增强布局。在这种情况下,我们将所有块浮动并设置不同的宽度,以给布局带来更自然的感受。

我们不想翻遍整个 CSS 文件,而是想专注于最后一个文件,因为它是文件中最复杂的——feedback-tablet-landscape.css

在这个 CSS 文件中,我们将采取一系列步骤来实现这个布局,从 12 项的网格开始,修改宽度和高度以给我们一个更流畅的布局。看看下面的图,以了解我们的意思:

现在,这是 CSS

feedback-tablet-landscape.css中,我们的第一个 CSS 块看起来像这样:

div.x-dataview-item {
    width: 33%;
    float: left;
}

在横向模式下,我们可以在屏幕上放置三个内容块,因此我们将每个项目设置为默认宽度 33%。

下一个 CSS 块使用 nth-of-type 选择器来改变一些项目块的大小:

div.x-dataview-item:nth-of-type(12n+2), div.x-dataview-item:nth-of-type(12n+8) {
    width: 66%;
}

这个看起来相当复杂的 CSS 实际上是这样说的:

  1. 我们只查看带有div.x-dataview-item标签的项(这些是我们的项目块)。

  2. 我们想要nth-of-type(12n+2),在这种情况下意味着我们在查看 12 项的块(12n)。在这 12 项中,我们想要将此样式应用于第二项(+2)。

  3. 我们还想要对十二个成员中的第八个成员(nth-of-type(12n+8))做同样的事情。

  4. 对于我们的两个项目,我们想要将宽度设置为 66%(是我们正常文章宽度的两倍)。

通过以 12 项的块处理这些项目,我们使我们的布局看起来更加随机和自然,而不是如果我们简单地将每个第三个项目设置为更宽的尺寸。

下两个样式确保我们不会在布局的末尾留下一个或两个项目。首先,我们给列表中的最后一个项目一个 99%的宽度:

div.x-dataview-item:last-child {
    width: 99%;
}

然而,如果我们的最后一个项目是三行中的最后一个,我们不希望它有 99%的宽度,因为这会将它推到下一行,使事物看起来很奇怪。它应该只是常规大小(33%):

div.x-dataview-item:nth-of-type(12n+7), div.x-dataview-item:nth-of-type(12n) {
    width: 33% !important;
}

12n+7(第七项和第十二项)是我们 12 项集中可能成为三行最后一项的唯一两个项目。

小贴士

我们在 CSS 中使用 !important 来确保我们的 width 定义覆盖了其他(父)类可能应用的任何其他 width 值。

我们也不希望它是 99%,如果它是只有两个项目的行中的最后一个项目。在这种情况下,最后一个项目只需要 66%的宽度:

div.x-dataview-item:nth-of-type(12n+6):last-child {
    width: 66% !important;
}

最后,为了在布局的垂直节奏中给我们一个休息,我们将 12 个项目集中的第一个项目的高度设置为其他项目的两倍:

div.x-dataview-item:nth-of-type(12n+1), div.x-dataview-item:nth-of-type(12n+1) article {
    min-height: 400px;
    max-height: 400px;
}

虽然这些类型的 CSS 选择器一开始可能看起来有些令人畏惧,但它们提供了比标准 Sencha Touch 选项更广泛的布局选项。

如果你查看其他两个样式表,你会发现样式变得更加简单。feedbag-tablet-portrait.css 文件是一个简单的两列布局,而普通的 feedbag.css 文件是一个单列布局,描述内容块被隐藏,适用于像 iPhone 这样的小设备。根据设备或屏幕像素密度进一步自定义布局是可能的,但我们将其留作你的练习。

作业

在本章的支持文件中,我们还添加了一个名为 FeedItem.js 的详情视图,这是一个简单的面板,当我们的 dataview 中的项目被点击时由控制器调用。面板以与我们的 feed 详情视图相同的方式推送到导航视图中。面板包含一个从传递给 tap 函数的记录加载的简单 XTemplate。如果你探索这段代码,你也会看到我们添加了一些动画来使过渡更加流畅。

这种类型的应用程序可以很容易地修改为任何具有 RSS 源网站的移动版本。你还可以使用 RSS 源中的附加数据来添加更多显示信息,或通过 CSS 修改外观和感觉。

摘要

在本章中,我们讨论了:

  • Feed Reader 应用程序的基本应用设置

  • 创建数据以使用远程数据源

  • 向远程服务器发送 Ajax 请求可能存在的问题

  • 使用 YQL 系统从网络查询数据并将其作为 JSON 返回

  • 为我们的 dataview 设置一个复杂的 XTemplate

  • 使用 CSS 样式和选择器创建一个视觉上有趣且可适应的 dataview 显示

在下一章中,我们将探讨使用编译后的 Sencha Touch 应用程序来利用超出标准基于 Web 应用程序的功能。

第三章. 命令行操作

对于我们的第三个项目,我们将从对 Sencha Designer 的依赖中做出一些改变。在本章中,我们将探讨 Sencha 的免费命令行工具 Sencha Cmd 的用法。使用这个新的工具集,我们可以快速生成新应用程序的基本框架,创建控制器和模型,并将我们的应用程序编译成适用于 iOS 和 Android 的原生应用程序。

本章的应用程序是一个简单的用于安排休息时间的计时器应用程序。我们称之为 TimeCop

TimeCop 应用程序允许用户为未来的某个时间设置提醒(例如,为休息时间设置 15 分钟)。一旦设置时间,按下开始按钮,在适当的延迟后会出现提醒。这个应用程序的简单性将使我们能够关注使用 Sencha Cmd 和创建编译应用程序涉及的某些细节和问题。

在本章中,我们将涵盖:

  • 使用 Sencha Cmd 创建基本应用程序

  • Sencha Cmd 的功能

  • 为 iOS 创建开发者账户

  • 为 iOS 配置应用程序

  • 创建正确的文件配置

  • 添加原生通知

  • 编译应用程序

基本应用程序

TimeCop 应用程序被设计为一个简单的跟踪你的休息或午餐时间结束的方式。该应用程序由四个主要按钮组成,分别标记为5103060。点击这些按钮中的任何一个都会在中心出现一个第五个按钮。此按钮将显示用户选择的时间量,并作为开始按钮。用户可以通过多次点击四个外围按钮来添加不同的分配时间。

基本应用程序

例如,点击5按钮两次将在中心按钮的分配时间内放置 10 分钟。点击5按钮一次然后点击10按钮一次将在分配时间内放置15分钟。一旦达到所需的时间量,用户点击中心按钮,倒计时开始。当倒计时结束时,用户将通过设备通知被提醒。

安装 Sencha Cmd

Sencha Cmd 是从我们的 Sencha Touch 代码中单独下载的,可以在www.sencha.com/products/sencha-cmd/download找到。下载适用于 Windows、OSX 和 Linux(32 位和 64 位)。当你解压下载的文件时,你可以双击它来安装 Sencha Cmd。

注意

对于这本书,我们使用 Sencha Cmd 版本 3(至少需要 3.0.0.250)。详细的安装说明可以在docs.sencha.com/ext-js/4-1/#!/guide/command找到。

一旦安装了 Sencha Cmd,你可以在计算机上按照以下方式打开命令行:

  • 在 Mac OSX 上,转到应用程序并启动终端

  • 在 Windows 上,转到开始 | 运行并输入cmd

从这里开始,你需要切换到你的 Sencha Touch 文件安装的目录(不是我们刚刚下载的 Sencha Cmd 文件,而是你的原始 Sencha Touch 2.1 文件):

cd /path/to/Sencha-touch-directory/

一旦你切换到目录,请输入:

sencha

你应该在终端看到以下截图类似的内容:

安装 Sencha Cmd

这段文本为你提供了可用的命令的快速概述以及你当前运行的 Sencha Cmd 版本(这应该是版本 3.0.0.250 或更高)。

Sencha Cmd 的优势

Sencha Cmd 的一个主要优势是,与 Designer 不同,它是免费提供的。Sencha Cmd 还可以为我们处理很多重复的编码工作。例如,你可以通过从你的 Sencha Touch 2.1 目录执行以下命令来生成一个完整的应用程序骨架:

sencha generate app MyApp /path/to/www/myapp

这将在指定的目录中创建一个名为MyApp的新应用程序。

你也可以在新的应用程序目录中使用类似以下命令从命令行创建模型:

sencha generate model Contact --fields=id:int,firstName, lastName,email,phone

这将为具有五个指定字段的用户创建一个完整的模型,并确保id字段是一个整数。

小贴士

关于 Sencha Cmd 和目录的注意事项

当生成应用程序骨架时,你需要位于 Sencha Touch 2.1 目录中。一旦应用程序生成,你需要切换到新的应用程序目录以执行生成模型和控制器、构建和编译应用程序的命令。

你还可以使用build命令来自动优化你的应用程序以用于生产。这包括解决依赖关系,以便你的应用程序只包含它实际需要的代码。此外,build命令设置了 HTML5 应用程序缓存,最小化了所有的 JavaScript 和 CSS,以及其他速度和缓存增强。

让我们通过生成我们的 TimeCop 应用程序来看看它是如何工作的。

生成应用程序骨架

在我们之前的例子中,我们从create命令开始:

sencha generate app TimeCop /Library/Documents/Webserver/timecop

你还需要确保调整输出路径以适应你的开发环境。

注意

如我们之前所述,此命令应从你的 Sencha Touch 2.1 目录中执行以正确工作。你还可以通过使用-sdk选项来指定 Sencha Touch 目录,如下所示:

sencha -sdk /Path/to /sencha-touch-2.1 generate app AppName /path/to/your/app/directory

这将在timecop文件夹中创建以下文件和目录结构:

生成应用程序骨架

如果你查看index.html文件,你会看到它已经设置了基本的应用程序,并包含了以下 JavaScript:

<script id="microloader" type="text/javascript" src="img/development.js"></script>

这是自动加载器,它将自动包含我们需要的其余 JavaScript 代码。它还在 CSS 中包含一个加载指示。当应用程序加载时,这将触发,以提醒用户后台正在发生事情。你不应该需要触摸index.html文件。

app.js文件包含了一些更有趣的部分:

Ext.Loader.setPath({
    'Ext': 'touch/src',
    'TimeCop': 'app'
});
Ext.application({
    name: 'TimeCop',

    requires: [
        'Ext.MessageBox'
    ],

    views: ['Main'],

    icon: {
        57: 'resources/icons/Icon.png',
        72: 'resources/icons/Icon~ipad.png',
        114: 'resources/icons/Icon@2x.png',
        144: 'resources/icons/Icon~ipad@2x.png'
    },

    phoneStartupScreen: 'resources/loading/Homescreen.jpg',
    tabletStartupScreen: 'resources/loading/Homescreen~ipad.jpg',

    launch: function() {
        // Destroy the #appLoadingIndicator element
        Ext.fly('appLoadingIndicator').destroy();

        // Initialize the main view
        Ext.Viewport.add(Ext.create('TimeCop.view.Main'));
    },

});

顶部的 Ext.Loader.setPath 函数将指向我们的 touch 目录,其中包含所有我们的基础 Sencha Touch 2 库文件。

下一个部分设置应用程序的名称、所需组件、视图、图标和启动屏幕。

launch 部分将移除我们的加载指示器,并将 TimeCop.view.Main 添加到视图中。

如果你使用 Safari 导航到文件夹并查看应用程序,你会看到如下内容:

生成应用程序骨架

此应用程序的实际显示代码的大部分都包含在 TimeCop.view.Main 文件中。这是我们稍后将要修改以创建实际应用程序的文件。

创建 TimeCop 布局

此应用程序的布局由主容器上的 vbox 布局组成。在主容器内部是一组三个容器,每个容器都有一个 hbox 布局,并包含三个额外的容器。这为我们提供了一个灵活的 3 x 3 网格,我们可以在此网格中放置我们的组件:

创建 TimeCop 布局

在容器 1378 内,我们需要带有四个时间增加按钮。在容器 5 中我们将放置启动按钮。通过使用本节稍后描述的 vboxhbox 布局,我们可以使组件在屏幕大小变化时保持居中。

我们的按钮容器可以设置一个固定宽度(在这种情况下我们选择 120)。该行中的空容器将被赋予一个 flex 值为 1。这将导致它们占据剩余的可用空间,并在屏幕大小变化时保持按钮之间的均匀间距。

例如,我们的第一行布局如下:

{
    xtype: 'container',
    layout: {
        type: 'hbox'
    },
    flex: 1,
    items: [
        {
            xtype: 'container',
            width: 120,
            layout: {
                type: 'fit'
            },
            items: [
                {
                    xtype: 'incrementButton',
                    text: 5

                }
            ]
        },
        {
            xtype: 'container',
            flex: 1
        },
        {
            xtype: 'container',
            layout: {
                type: 'fit'
            },
            width: 120,
            items: [
                {
                    xtype: 'incrementButton',
                    text: 10
                }
            ]
        }
    ]
}

你会注意到我们的每个按钮都有一个 xtype 类型为 incrementButton,但文本值(510)不同。我们稍后会回到这一点,但首先我们需要看看我们的第二行。

第二个 hbox 容器行是第一行的变体;中间有一个固定宽度的容器,两侧是可变宽度的(flex:1)容器:

{
    xtype: 'container',
    layout: {
        type: 'hbox'
    },
    flex: 1,
    items: [
        {
            xtype: 'container',
            flex: 1
        },
        {
            xtype: 'container',
            width: '',
            layout: {
                type: 'fit'
            },
            width: 120,
            items: [
                {
                    xtype: 'button',
                    hidden: true,
                    id: 'startButton',
                    ui: 'roundStart',
                    text: 0
                }
            ]
        },
        {
            xtype: 'container',
            flex: 1
        }
    ]
}

居中的 startButton 默认情况下是隐藏的,并且只有在点击 incrementButtons 时才会出现。我们将 ui 设置为 roundStart,稍后我们将使用它来设置按钮样式,并将按钮的 text 值设置为 0(我们将在 incrementButton 函数中稍后使用此值)。我们还为 startButton 实例添加了一个点击事件监听器。

我们的第三行只是第一行的复制,按钮值分别设置为 3060。这两个按钮都将具有 xtype:incrementButton,就像第一行一样。

创建主题

我们目前的基础布局将给我们一大堆大而丑陋的正方形按钮。我们想要比这更酷的东西,因此我们将为应用程序设置一个新的主题。Sencha Touch 主题使用 SASS 和 Compass 以多种有趣的方式自定义用户界面。

注意

关于创建 Sencha Touch 主题的更多信息,请参阅文档和教程视频:docs.sencha.com/touch/2-0/#!/guide/theming

我们主题的第一步是为我们的按钮添加一个ui配置。四个时间增量按钮将在其配置选项中添加ui: 'round'。这将给我们一个更令人愉悦的圆形按钮。

中心按钮将在其配置选项中添加ui: 'roundStart'。我们将使ui继承我们原始圆形 ui 的所有特性,并添加一些颜色变化,以给我们一个绿色的开始按钮。

然后,我们可以将以下代码添加到app.scss中:

.x-button-round, .x-button-roundStart {
  background-color: transparent;
  background-image: none;
  width: 120px;
  padding: 10px;
  height: 120px;
  overflow:hidden;
  border: none;

  span  {
    color:#333;
    font-size:24px;
    line-height:68px;
  }
  span.x-button-label {
    display: block;
    background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(230,230,230,1)), color-stop(50%,rgba(168,168,168,1)), color-stop(50%,rgba(168,168,168,1)), color-stop(100%,rgba(230,230,230,1))); /* Chrome,Safari4+ */
    position:relative;
    height:100px;
    width:100px;
    text-align:center;
    cursor:pointer;
    border:16px solid #e8e8e8;
    -webkit-border-radius: 60px;
    font-weight: 900;
    -webkit-box-shadow: inset 0 0 10px#C7C7C7, 0 0 1px 2px #bababa;
  }
}

这其中的关键部分是-webkit-border-radius: 60px(按钮宽度/高度的一半),这使得按钮呈圆形,以及background: -webkit-gradient,它创建了按钮的渐变背景。

我们对开始按钮做类似的事情,但我们将文本设置为白色,背景设置为绿色:

.x-button-roundStart {
  span {
  color: white;
  }
  span.x-button-label {
  background-color: #0C0;
  background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #1AFF1A), color-stop(50%, #00E600), color-stop(51%, #0C0), color-stop(100%, #00B300));
  background-image: -webkit-linear-gradient(#1AFF1A, #00E600 50%, #0C0 51%, #00B300);
  background-image: linear-gradient(#1AFF1A, #00E600 50%, #0C0 51%, #00B300);
  }
}

运行compass compile将重新生成包含我们新样式的app.css文件。现在我们已经有了应用程序的基本外观和感觉,我们需要讨论在 Sencha Touch 应用程序中使用原生 API。

创建增量按钮

由于我们的每个增量按钮都将执行类似的功能,这成为创建按钮类的一个绝佳机会。

要做到这一点,我们将在视图文件夹中创建一个名为incrementButton.js的单独文件:

Ext.define('TimeCop.view.incrementButton', {
    extend: 'Ext.Button',
    alias: 'widget.incrementButton', 
    config: {
        itemId: 'mybutton',
        ui: 'round',
        text: 5,
        listeners: [
            {
                fn: 'onMybuttonTap',
                event: 'tap'
            }
        ]
    }
});

此代码扩展了标准的Ext.Button类,并为itemIDtextui(我们将在后面使用ui来设置按钮样式)设置了默认值。我们还添加了一个监听器,用于用户按下按钮时。

当我们的时间增量按钮被点击时,我们需要将适当的时间添加到中心开始按钮中,并显示它(如果它是隐藏的)。我们将通过在之前代码的Config部分之后添加以下内容来实现这一点:

onMybuttonTap: function(button, e, options) {
 var increment = button.getText();
 var start = Ext.getCmp('startButton');

 var startInt = start.getText();

 var total = parseInt(startInt, 10) + parseInt(increment, 10);

 start.setText(total);

 if(start.isHidden()) {
  start.show();
 }
}

此代码从我们的当前按钮中获取文本,该按钮是5,然后获取开始按钮。然后我们将两个按钮的值相加,并将此值设置为开始按钮上的文本。由于此函数现在是基本incrementButton的一部分,我们具有xtypeincrementButton的四个按钮都将能够使用此相同的功能。唯一会改变的是按钮的文本值。这允许你轻松选择其他时间增量,如果你需要的话。

使用按钮的方式如下:当用户首次启动应用程序时,开始按钮是隐藏的,其文本值为0。用户点击5按钮,开始按钮出现,5 加到 0 上,开始按钮的文本设置为5。然后用户点击10按钮,导致开始按钮文本增加到15,依此类推。

创建开始按钮

我们的开始按钮使用一个单独的函数来开始计时器的倒计时。在这种情况下,我们将监听器添加到我们的主视图中:

listeners: [
 {
  fn: 'onStartButtonTap',
  event: 'tap',
  delegate: '#startButton'
 }
]

这将触发一个名为onStartButtonTap的函数。我们在Main.js文件的Config部分之后添加这个新函数。这是启动计时器倒计时的函数:

onStartButtonTap: function(button, e, options) {
 var delay = button.getText();
 setTimeout(function() {
  Ext.Msg.alert('Back to work minion!', 
  'The boss needs a new villa!', 
  Ext.emptyFn);
 },parseInt(delay)*1000);
}

此函数获取按钮的文本,现在设置为我们要在计时器上设置的总时间。然后我们创建一个setTimeout函数,在计时器完成后显示消息框。为了测试目的,我们已将延迟设置为*1000,这将实际上给我们提供秒而不是分钟的延迟时间。当我们想要将延迟设置为分钟时,最后一行可以更改为:

setTimeout(function() {
 Ext.Msg.alert('Back to work minion!', 
 'The boss needs a new villa!', 
 Ext.emptyFn);
},parseInt(delay)*60000);

为了测试目的,我们现在暂时保留代码不变,并测试这些函数。

创建开始按钮

如我们从示例中看到的那样,警报在延迟时间到期时出现。然而,目前这并没有使用设备上可用的任何原生警报。为了做到这一点,我们需要查看Ext.device

使用 Ext.device 与原生 API

默认情况下,Sencha Touch 应用程序是基于 Web 的。这意味着 Android 或 iOS 上的用户将使用网络浏览器来访问你的应用程序。你可以将网页添加到桌面,它将非常类似于编译应用程序的外观和行为。然而,移动设备上存在许多通过基于 Web 的应用程序无法访问的功能;这些包括相机、设备方向、连接监控、原生警报以及一些原生地理位置功能。

Sencha Touch 通过使用Ext.device提供了一种绕过此问题的方法。该组件接受 JavaScript 命令,当应用程序编译时,这些命令将被转换为原生函数。

注意

应该注意的是,在使用Ext.device时,必须每次编译应用程序才能实际测试原生应用程序功能。如果你进行更改或需要调试,你必须重新编译应用程序并在你的移动设备上重新安装它。

Ext.device提供了以下选项:

  • 连接:这允许你使用Ext.device.Connection.isOnline()检查用户是否在线。你也可以使用Ext.device.Connection.getType()检查连接类型。

  • 通知:这允许你访问原生通知窗口和振动设备选项。

  • 方向:这提供了在三维空间(alpha、beta 和 gamma)中跟踪的设备的当前方向。这些维度返回的值在 0 到 360 之间,可以用来计算各种设备运动。

  • 相机:这允许你的应用程序拍照或从相机库中选择现有图像(需要用户许可)。

对于 TimeCop,我们将使用简单的通知/振动警报。在后面的章节中,我们将介绍其他Ext.Device组件。不过,首先,我们需要稍微偏离一下,探索一些额外的步骤,以便在 iOS 下测试和运行编译的应用程序。

测试和运行原生应用程序

为了在 iOS 上运行原生(编译)应用程序,你需要采取以下步骤:

  1. 注册为苹果 iOS 开发者(截至本文写作时,费用为每年 99 美元)。

  2. 启用你的设备进行开发。

  3. 使用苹果为应用程序提供配置并创建 P12 证书。

  4. 在你的设备上安装应用程序。

这个过程并不总是直观的,有时甚至感觉比编写实际应用程序还要繁琐。如果你更愿意创建 Android 应用程序,我们将在本章后面讨论如何构建原生 Android 应用程序。

注册为开发者

为了将你的应用程序发布到苹果商店,或者仅仅为了测试编译的 iOS 应用程序,你必须注册一个开发者账户。成为开发者需要付费(截至本文写作时,费用为每年 99 美元),苹果将需要你提供大量的个人信息。他们需要这些信息有几个原因。首先,他们需要知道你是谁,这样你才能从他们商店销售的应用程序中获得报酬。其次,如果您的应用程序出现问题,他们需要知道如何联系你。最后,如果他们发现你试图用你的应用程序做坏事,他们需要能够找到你。当然,你不会这么做。

即使你还没有准备好分发你的应用程序,你仍然需要注册为开发者,以便在你的 iOS 设备上安装编译应用程序。

你的 iOS 设备也需要在苹果处注册以进行开发。这将允许你直接从你的开发计算机上安装和测试你自己的个人编译应用程序,而不是通过苹果商店。

成为苹果开发者

要成为苹果开发者,首先你必须去:developer.apple.com/programs/register/

你需要提供现有的苹果 ID 或注册一个新的 ID,填写一些冗长的个人资料信息,同意一些法律文件,然后进行电子邮件验证。从那里你将能够访问苹果开发者中心。对我们这些移动开发者来说,最感兴趣的两个点是 iOS Dev Center 和 iOS Provisioning Portal。

iOS Dev Center 是你可以下载 iOS SDK(称为 Xcode),阅读文档,查看示例代码和教程,以及观看一些 iOS 开发视频的地方。

iOS Provisioning Portal 是你可以将你的应用程序添加到苹果商店或发布应用程序测试版本的地方。

小贴士

注意,为了使用 Xcode、安装开发证书或将您的应用程序发布到苹果商店,您必须在运行 OSX 的计算机上。Windows 和 Linux 计算机无法运行 Xcode 或发布到苹果商店。

配置文件门户是我们关注的重点区域。

配置应用程序

为了运行您为 iPhone、iPad 或 iPod touch 开发的编译后的 Sencha Touch 应用程序,您必须在您的设备和 Mac 上安装配置文件和开发证书。(这仅适用于编译应用程序,不适用于标准的 Sencha Web 应用程序。)

虽然配置过程可能看起来有些复杂,但苹果在配置文件门户的右侧列出了非常不错的“如何操作”视频,以及一个方便的配置助手设置向导。配置助手向导将引导您完成创建和安装开发配置文件和 iOS 开发证书的步骤。

处理流程的第一步是获取一个开发证书。开发证书是一个电子文档,它将您作为苹果开发者与您的编译应用程序联系起来。出于测试目的,证书会被加载到您的 iOS 设备上,并让设备知道运行您的应用程序是安全的。

当您的应用程序编译时,会使用配置文件。它包含一组单独的开发证书、设备 ID 和应用程序 ID。这些信息会与原始开发证书进行核对,以授权应用程序在您的设备上运行。

同时,回到代码中

现在我们已经正确设置了证书,我们可以回到编写代码的业务中。

使用原生通知

要使用原生通知,我们需要将原来的onStartButtonTap函数替换为一个使用Ext.device的新函数。除此之外,原生通知的代码几乎与我们的之前代码相同:

    onStartButtonTap: function(button, e, options) {
        var delay = button.getText();
        setTimeout(function() {
            Ext.device.Notification.vibrate();
            Ext.device.Notification.show({
                title: ' Back to work minion! ',
                message: 'The boss needs a new villa!'
            });

        },parseInt(delay)*1000);
    }

我们仍然将函数包裹在setTimeout语句中。然后调用Ext.device.Notification.vibrateExt.device.Notification.show。这将导致设备振动(如果设备支持的话),然后显示我们原来的消息,就像之前一样。

此外,Ext.device默认不会加载,因此我们需要将其添加到app.js文件中的requires配置:

Ext.application({
    name: 'TimeCop',

    requires: [
        'Ext.MessageBox',
        'Ext.device.Notification'
    ],

注意

调试Ext.device的问题是一个棘手的问题。Ext.device功能在桌面浏览器中不可用,或者当您的应用程序未编译时。然而,如果您需要调试在您的移动设备上运行的应用程序,有一些第三方解决方案,其中之一是weinre,代表WEb INspector REmote。您可以在people.apache.org/~pmuellr/weinre/docs/latest/了解更多关于 weinre 的信息。

然而,目前我们需要使用 Sencha Cmd 为原生 iPhone 编译应用程序。

编译应用程序

为了编译应用程序,您首先需要从苹果公司获取一些东西:

您还需要知道您应用程序的应用程序名称、应用程序 ID 和包种子 ID。您可以通过点击门户中App ID部分旁边应用程序名称旁边的配置来找到这些信息。格式看起来像这样:

编译应用程序

一旦我们有了这些信息和文件,我们需要设置我们的packager.json文件。

设置 packager.json

packager.json文件位于我们的应用程序文件夹的根目录中,它是由 Sencha Cmd 为我们最初生成的模板。我们需要更改一些默认信息以便编译应用程序。

packager.json文件有大量的注释,所以我们只需查看文件中的一些更关键的设置:

  • "applicationName":"TimeCop"

  • "applicationId":"com.example.TimeCop"

  • "bundleSeedId":"D3THNXJT69"

这是我们使用之前示例中的值的地方。您需要更改这些值以反映您自己的应用程序信息。

注意

请注意,我们正在使用示例信息作为我们的applicationIDbundleSeedId值。您需要将这些值更改为从苹果公司获得的值。

下一个重要部分是:

"configuration":"Debug",
"platform":"iOSSimulator",
"deviceType":"Universal",

我们现在可以保持这些设置不变,但它们控制着应用程序的输出方式和它能在哪些设备上运行。在您准备好通过应用商店分发应用程序之前,配置类型应始终为Debug。这将帮助您追踪可能存在的任何代码错误。

平台选项包括:

  • iOSSimulator

  • iOS

  • Android

  • AndroidEmulator

使用iOSSimmulatorAndroidEmulator选项允许您在没有 iOS 或 Android 设备的情况下在您的机器上本地测试。您需要将 Xcode 和/或 Android SDK 套件安装到您的机器上才能使用此选项。

deviceType是一个仅适用于 iOS 的选项,它将应用程序声明为iPhoneiPadUniversal(意味着两者都适用)。

最后几个关键信息是:

"certificatePath":"/Users/12ftguru/Downloads/New_Cer/Certificates.p12",
"provisionProfile":"/Users/12ftguru/Downloads/New_Cer/TimeCop.mobileprovision",

certificatePathprovisionProfile都应该对应于之前提到的转换后的 P12 证书的正确路径以及您从苹果公司下载的配置文件。

一旦您有了所需的信息,我们就可以编译应用程序了。

在命令行中,切换到您的应用程序目录:

cd /path/to/your/application

然后输入:

sencha app build -run native

这应该将您的应用程序编译成可执行文件并启动 iOS 模拟器。

注意

-run 选项是 Sencha Cmd 版本 3 中的新功能。之前的版本会默认启动模拟器。如果你只想构建应用程序,可以省略 -run 选项。

设置 packager.json

这看起来更像是原生 iPhone 应用程序应有的样子,如果应用程序在 iPhone 上运行,当通知发生时,手机也会振动。

编译后的应用程序现在应该位于你的应用程序目录中的 build/native 文件夹中。你可以将其拖放到 iTunes 中以在设备上安装。

小贴士

安装原生应用程序

如果你的应用程序安装失败,尝试在 Xcode 下安装通常很有帮助。连接你的设备,并将应用程序文件拖放到 Xcode 应用程序顶部。这将启动 Xcode 并尝试在你的移动设备上安装应用程序。Xcode 通常提供的错误信息比 iTunes 更好。

构建 Android 原生应用程序

在 Android 中构建编译的应用程序遵循与我们在 iOS 中使用的类似模式:

  1. 我们创建一个 Android 签名证书。

  2. 我们为 Sencha Cmd 创建一个包配置文件。

  3. 我们运行 Sencha Cmd 打包器来创建一个 application.apk 文件,该文件将在 Android 设备或 Android 模拟器上运行以进行测试。

好的一点是,我们仍然可以使用完全相同的代码,我们需要的只是一个新的证书和一些配置更改。

创建 Android 签名证书

生成 Android 签名证书的过程比其 iOS 对应版本简单得多。我们可以在本地机器上生成所有密钥,并且没有为 Android 应用程序设置预配过程。

我们需要做的第一件事是从 developer.android.com/sdk/index.html 下载 Android SDK。一旦 ZIP 文件下载完成,我们需要将其提取并保存在适当的位置。在这个例子中,我们选择将 SDK 保存在主目录下的一个名为 development 的文件夹中。当我们创建配置文件时,你的文件路径信息可能会根据你放置 SDK 的位置而有所不同。

Android 证书是通过以下命令在命令行中生成的(所有内容都在一行中):

keytool -genkey -v -keystore time-cop.keystore -alias timecop -keyalg RSA -keysize 2048 -validity 10000

这个命令的重要部分是 keystore 名称,time-cop.keystore,以及 alias,即 timecop。我们需要这些值来正确设置我们的配置文件。

当你执行这个命令时,系统会提示你为 keystore 创建一个密码。然后,系统会引导你回答一系列关于你的组织位置的问题(这些是可选的,但可能是个好主意)。

一旦你回答了所有问题,将会生成一个名为 time-cop.keystore 的文件(或者你给 keystore 命名的任何名称)。

创建 Android 配置文件

与我们之前的 iOS 配置文件一样,我们创建一个名为 packager_android.json 的 JSON 文件。此文件的格式将与之前的 iOS 文件格式相同:

{
 "applicationName": "TimeCop",
 "applicationId": "com.12ftguru.TimeCop",
 "outputPath": "/Users/12ftguru/Development/compiled/",
 "iconName": "timecop.png",
"versionCode": "1.0", 
"versionString": "1.0 Release 1",
 "inputPath": "/path/to/your/application",
 "configuration": "Debug",
 "platform": "AndroidEmulator",
 "certificatePath": "/Users/12ftguru/Development/time-cop.keystore",
 "certificateAlias": "timecop",
 "sdkPath": "/Users/12ftguru/Development/sdk",
 "orientations": [
  "portrait",
  "landscapeLeft",
  "landscapeRight",
  "portraitUpsideDown"
 ],
 "deviceType": "<Not applicable for Android>"
}

applicationName 将是我们编译时创建的 .apk 文件名,在这个例子中,是 TimeCop.apk

applicationId 是您应用程序的唯一标识符,我们建议使用类似 com.your_name.your_application_name 的格式。

outputPath.apk 文件将被保存的位置,而 iconName 是用作您应用程序图标的文件。versionCodeversionString 由您决定,并且应该用于区分正在使用的软件版本。

inputPath 是您的 TimeCop 文件(或相对于此配置文件的路径)的完整路径。

configuration 可以设置为 ReleaseDebug,而 platform 可以设置为 AndroidAndroidEmulator。这些设置通常为测试时为 Debug + AndroidEmulator,而对于完成的应用程序则为 Release + Android

certificatePath 是我们之前章节中生成的 keystore 文件的位置,而 certificateAlias 是我们在创建 keystore 时作为命令行参数提供的别名。

方向是应用程序可用的查看位置。它们通常会保持为之前列出的默认值。Android 会忽略设备类型,但如果配置或值被省略,配置管理器将返回错误。您可以保持此值设置为 <Not applicable for Android>,并且它将被安全地忽略。

编译和启动 Android 应用程序

就像之前的 iOS 应用程序一样,我们将使用 Sencha 包命令来编译应用程序。但是,如果您在 Android 模拟器中进行测试,您需要在发出命令之前启动模拟器。

一旦模拟器正在运行,请在命令行中输入以下命令:

sencha package run packager_android.json

这将执行我们在上一节中创建的 packagerAndroid.json 文件。

如果您正在创建应用程序的发布版本,请在您的 packager_android.json 配置文件中将 configuration 设置为 Release,将 platform 设置为 Android。然后,您可以执行 package 命令,但省略 run 命令,如下所示:

sencha package packager_android.json

这将编译应用程序,但不会在模拟器中运行。

注意

Android 模拟器能够模拟各种硬件。有关 Android 模拟器的更多信息,请访问developer.android.com/tools/devices/emulator.html

关于设置不同的硬件配置文件(有时称为 Android 模拟器的 Android 虚拟设备(AVDs)的信息,请参阅developer.android.com/tools/devices/managing-avds.html提供的文档。

一旦应用程序开始运行,你就可以开始测试不同的功能并修复任何问题。

摘要

在本章中,我们学习了以下内容:

  • 使用 Sencha SDK 命令行工具生成应用程序骨架

  • 使用 Sencha 的原生 Ext.device API

  • 通过苹果开发者门户配置 iOS 应用程序

  • 将 Sencha Touch 网络应用程序编译成原生 iOS 应用

  • 将 Sencha Touch 网络应用程序编译成原生 Android 应用

在下一章中,我们将探讨 Sencha Touch Charts 包。Charts 包是 Sencha Touch 的一个附加组件,它将允许我们在应用程序中使用图表和图形。我们将向您展示如何使用标准的 datastore 实例并将其用于向您的图表和图形提供数据。

第四章。体重 体重

在本章中,我们将探讨 Sencha Touch 框架的可选附加包。该包称为 Sencha Charts,它使我们能够使用数据存储来创建图表。

在本章中,我们将涵盖以下内容:

  • 构建基本应用程序

  • 定义数据存储

  • 设置 Sencha Charts 包

  • 将商店连接到 Sencha Charts

  • 配置和显示图表

Sencha Charts 概览

基本的 Sencha Touch 框架提供了一些用于显示数据的组件。然而,商业和其他密集型软件产品通常需要更健壮的解决方案。通过使用 Sencha Touch Charts,我们还可以将复杂图形数据作为我们应用程序的一部分来显示。

以下截图展示了用于显示数据的图表和图形类型的概览:

Sencha Charts 概览

这些新组件使用数据存储来显示各种图表和图形类型,包括:

  • 饼图

  • 柱状图

  • 折线图

  • 散点图

  • 面积图

  • K 线图

  • 雷达图

  • 仪表盘

我们将使用这些图表中的几个来为我们的应用程序提供更友好的显示。

备注

截至编写时,Sencha Charts 包仅作为 Sencha Complete 的一部分提供,或者 Sencha Touch 2.1 的开源版本(GPL)下载。对于本章,我们将使用开源版本,可以从网页 www.sencha.com/products/touch/download/ 免费下载。

在本章的后面部分,我们将介绍使用 Sencha Charts 的基本设置,但首先,我们将查看设置基本应用程序的过程。

基本应用程序

我们将使用 Sencha Charts 包来创建一个跟踪体重、锻炼、卡路里和水分摄入的程序。我们还将允许用户为图表添加额外的信息。

应用程序由以下四个基本部分组成:

  • 用于输入数据的表单

  • 一个概览,将在单页面上提供一组图表

  • 一个详细部分,用于查看特定图表的详细信息

  • 一个配置部分,允许用户为我们的四个类别设置目标,并定义体重和水分摄入量的计量单位

我们将首先设置基本应用程序并构建我们的表单。

设置应用程序和构建表单

我们将使用上一章中描述的 Sencha Command SDK 来创建应用程序。您需要从 Sencha Touch 目录执行此命令。基本命令如下:

sencha app create weightweight /Path/To/Your/New/Application

如果您愿意,您可以自己创建初始目录和文件。您的文件和目录结构应该看起来像这样:

设置应用程序和构建表单

上一张截图显示了使用 sencha app create 命令自动生成的结构。

  • touch 目录包含 Sencha Touch 框架的副本,包括我们的图表功能。

  • resources目录将包含我们的图片和 CSS 文件。

  • app目录将包含我们大部分的代码。

首先,我们需要定义我们的主视图。这个文件将被称为main.js,它属于views文件夹。main.js文件是一个简单的标签面板,包含四个项目:

Ext.define("WeightWeight.view.Main", {
    extend: 'Ext.tab.Panel',
    requires: ['Ext.TitleBar'],

    config: {
        tabBar: {
            docked: 'bottom'
        },
        items: [
            { xtype: 'dataentry'},
            { xtype: 'overview'},
            { xtype: 'details'},
            { xtype: 'configform' }
        ]
    }
});

我们还需要确保将此组件添加到我们的app.js文件中,在Ext.application函数的views部分:

Ext.application({
    name: 'WeightWeight',
    views: ['Main'],
…

记住,我们在视图下列出的名称不是文件名(Main.js),而是我们代码顶部定义语句的最后一部分:WeightWeight.view.Main。一旦我们设置好这个,让我们为我们的标签视图中的每个面板创建四个占位符文件。

我们需要为dataentryoverviewdetailsconfigform面板创建占位符。这些文件将包含我们应用程序中每个面板或表单的起始代码。这将使我们能够在不出现缺失文件错误的情况下测试我们的应用程序。

让我们看看如何通过每个面板的起始代码来测试我们的应用程序:

  1. views目录下创建一个dataentry.js文件。这将是一个表单面板,因此起始代码应设置如下:

    Ext.define("WeightWeight.view.DataEntry", {
        extend:'Ext.form.Panel',
        alias:'widget.dataentry',
        config:{
            title:'Enter Data',
            iconCls:'info',
            html: 'Data Entry'
        }
    });
    
  2. 接下来,我们需要在views目录中创建一个overview.js文件,并设置一个简单的面板,代码如下:

    Ext.define("WeightWeight.view.OverviewChart", {
        extend:'Ext.Panel',
        alias:'widget.overview',
        config:{
            title:'Overview',
            iconCls:'star',
            html: 'Overview'
    
        }
    });
    
  3. view/details.js文件与之前的overview.js文件类似,也是一个面板。代码如下:

    Ext.define("WeightWeight.view.DetailChart", {
        extend:'Ext.Panel',
        alias:'widget.details',
        config:{
            title:'Details',
            iconCls:'locate',
            html: 'Details'
    
        }
    });
    
  4. 最后,是views/config.js文件,它也是一个与dataentry.js文件类似的表单面板。代码如下:

    Ext.define("WeightWeight.view.Config", {
        extend:'Ext.form.Panel',
        alias:'widget.configform',
        config:{
            title:'Config',
            iconCls:'settings',
            html: 'Config'
    
        }
    });
    
  5. 一旦所有视图都创建完成,我们需要记住将它们添加到app.js文件中的views部分(我们之前添加了Main)。在app js文件中,将views部分设置如下:

    views: ['Main', 'Config', 'AddTag', "OverviewChart", "DetailChart"]
    

我们现在应该能够加载代码并测试我们的面板。

小贴士

小步骤

编写代码可能是一个非常复杂的过程。通常,先进行小幅度修改并测试,而不是修改几百行代码后再测试,这样做往往更有帮助。通过修改少量代码,当问题发生时,你应该能够更快地追踪到问题。在这种情况下,通过创建这些起始文件,我们可以测试以确保 Sencha 正确地定位文件,并且应用程序可以无错误地启动。然后我们可以一次处理一个文件,并限制在出现问题时需要查找的地方。

设置应用程序和构建表单

到目前为止,我们的应用程序应该简单地启动并允许我们在视图之间切换。这确认了应用程序正在工作,然后我们可以开始创建我们的表单。

创建数据输入表单

我们的数据输入表单包括:

  • 三个字段:datepickerfield用于设置日期,numberfield用于我们四个类别(体重、水分、卡路里和锻炼)中的每一个,以及hiddenfield用于存储我们的条目标签值。

  • 三个按钮:一个用于添加标签,一个用于保存,一个用于取消并清除表单。

  • 我们还将把 取消保存 按钮放在一个 Hbox 布局容器内。这将使我们能够并排显示按钮。

我们将替换 view/DataEntry.js 中的那一行,该行说 html: 'Data Entry',以便代码看起来像这样:

Ext.define("WeightWeight.view.DataEntry", {
    extend:'Ext.form.Panel',
    alias:'widget.dataentry',
    config:{

        title:'Enter Data',
        iconCls:'info',
        items:[
            {
                xtype:'datepickerfield',
                label:'Date',
                placeHolder:'mm/dd/yyyy'
            },
            {
                xtype:'numberfield',
                id:'weightField',
                margin:'10 0',
                label:'Weight'
            },
            {
                xtype:'numberfield',
                id:'waterField',
                margin:'10 0',
                label:'Water'
            },
            {
                xtype:'numberfield',
                id:'calorieField',
                margin:'10 0',
                label:'Calories'
            },
            {
                xtype:'numberfield',
                id:'exerciseField',
                label:'Exercise'
            },
            {
                xtype:'hiddenfield',
                id:'hiddenTagField'
            },
            {
                xtype:'button',
                margin:'25 0 25',
                text:'Add Tag',
                id: 'addTagButton'
            },
            {
                xtype:'container',
                layout:{
                    type:'hbox'
                },
                items:[
                    {
                        xtype:'button',
                        margin:'0 10 0 0',
                        text:'Cancel',
                        flex:1
                    },
                    {
                        xtype:'button',
                        margin:'0 0 0 10',
                        text:'Save',
                        flex:1
                    }
                ]
            }
        ]
    }
});

我们还为我们的每个项目提供了边距,以在表单中添加间距,使其更易于阅读。最终结果应该看起来像这样:

创建数据录入表单

我们需要创建的下一个视图是添加我们的标签的视图。我们将使用一个表格来完成这个任务。

创建 AddTag 视图

AddTag 视图嵌入在一个 ActionSheet 组件中。这个视图将允许我们添加新的标签或从之前的标签中选择,ActionSheet 组件将作为从屏幕底部滑上的覆盖层显示视图。表单包含一个名为 textfield 的单行文本字段、一个 list 视图和两个按钮。在 views 目录中创建文件,并命名为 AddTag.js

Ext.define('WeightWeight.view.AddTag', {
    extend: 'Ext.ActionSheet',
    alias: 'widget.addtag',
    config: {
        id: 'addTagSheet',
        items: [
            {
                xtype: 'textfield',
                label: 'Enter a New Tag',
                placeHolder: 'or choose a tag from the list below.'
            },
            {
                xtype: 'list',
                height: 300,
                itemTpl: [
                    '<div>List Item {string}</div>'
                ]
            },
            {
                xtype: 'container',
                margin: 10,
                layout: {
                    type: 'hbox'
                },
                items: [
                    {
                        xtype: 'button',
                        margin: '0 10 0 0',
                        text: 'Cancel',
                        flex: 1
                    },
                    {
                        xtype: 'button',
                        margin: '0 0 0 10',
                        text: 'Save',
                        flex: 1
                    }
                ]
            }
        ]
    }
});

我们已经使用 alias 配置为这个组件提供了一个 xtype 属性。这将使我们能够在程序中快速创建和删除它。我们还为组件提供了一个 id 属性,以便我们可以在控制器中引用它。

最终结果应该看起来像这样:

创建 AddTag 视图

list 组件目前是一个占位符。一旦我们设置了数据存储,我们将在之后完成它。

我们需要设置的下一个视图是配置表单。这将类似于我们的数据录入表单,但有一些不同的字段类型。

创建配置表单

我们将首先编辑本章中较早设置的 Config.js 占位符文件。它的代码如下:

Ext.define("WeightWeight.view.Config", {
    extend:'Ext.form.Panel',
    alias: 'widget.configform',
    config:{
        title:'Config',
        iconCls:'settings',
        items:[]
    }
});

alias 属性允许我们通过自定义的 xtype 或配置表单来调用面板。这是我们 Main.js 文件中第四个面板使用的 xtype 属性。titleiconCls 属性控制了此面板在主视图中的导航显示方式。

接下来,我们需要向我们的面板添加一些项目。我们将从添加 Starting Weight(起始重量)和 Target Weight(目标重量)的数字字段开始。通过使用 numberfield 组件,我们确保在大多数移动设备上会出现数字键盘。为了保持字段组织有序,我们将它们放在一个 fieldset 组件中。这将放入空的 items 配置中:

{
    xtype:'fieldset',
    title:'Weight Loss Goal',
    items:[
        {
            xtype:'numberfield',
            id:'startingWeight', 
            name:'startingWeight',
            label:'Starting Weight'
        },
        {
            xtype:'numberfield',
            id:'targetWeight', 
            name:'targetWeight',
            label:'Target Weight'
        }
    ]
}

接下来,我们将添加一组旋转字段。spinnerfield 组件允许用户使用 +- 按钮增加字段值。这些也将像之前的那样放在一个 fieldset 组件中:

{
xtype:'fieldset',
title:'Daily Goals',
items:[
    {
        xtype:'spinnerfield',
        id:'exercisePerDay',
        label:'Exercise (minutes)',
        defaultValue:30,
        stepValue: 1
    },
    {
        xtype:'spinnerfield',
        id:'caloriesPerDay',
        label:'Caloric Intake',
        defaultValue:0,
        stepValue: 100
    },
    {
        xtype:'spinnerfield',
        id:'waterPerDay',
        label:'Water Consumption',
        defaultValue:8,
        stepValue: 1
    }
]
}

注意,spinnerfield 组件还允许我们设置 stepValue 配置,这控制了当按钮被按下时字段增加或减少的量。

最后,我们将添加我们的单位测量部分,包含不同选择的单选按钮,如下所示:

{
    xtype:'fieldset',
    title:'Units of Measure',
    padding:25,
    items:[
        {
            xtype:'fieldset',
            title:'Weight',
            items:[
                {
                    xtype:'radiofield',
                    label:'Pounds',
                    name:'weightUnits',
                    value:'lbs',
                    checked:true
                },
                {
                    xtype:'radiofield',
                    label:'Kilograms',
                    name:'weightUnits',
                    value:'kg'
                }
            ]
        },
        {
            xtype:'fieldset',
            title:'Water',
            items:[
                {
                    xtype:'radiofield',
                    label:'Glasses',
                    name:'waterUnits',
                    value:'glass',
                    checked:true
                },
                {
                    xtype:'radiofield',
                    label:'Ounces',
                    name:'waterUnits',
                    value:'oz'
                }
            ]
        }
    ]
}

结束表单应该看起来像这样:

创建配置表单

现在我们有了两个表单,让我们开始为它们编写控制器。我们将从数据输入控制器开始。

创建 DataEntry 控制器

让我们从这样一个裸控制器开始:

Ext.define('WeightWeight.controller.DataEntry', {
    extend: 'Ext.app.Controller',
    config: {
        refs: {

        },
        control: {

        }
    }
});

我们首先扩展基本控制器,然后添加一个 config 部分,该部分将包含我们其余的初始设置代码。refs 部分将包含我们需要的其他组件的引用,而 control 部分将为我们的按钮和其他组件分配函数。

refs 部分是我们将添加 AddTag 表格引用的地方:

refs: {
    tagSheet: '#addTagSheet',
}

这有时会以更长的形式写出,如下所示:

refs: {
    tagSheet: {
        selector: '#addTagSheet'
    }
}

两种方式都可以正常工作。参考寻找一个组件选择器,在这种情况下是一个具有 id 值为 addTagSheet 的组件。

通过使用 AddTag 表格的 id 配置创建这个参考,我们可以在控制器中的任何地方通过输入以下代码来访问它:

var sheet = this.getTagSheet();

备注

注意,尽管我们使用了 tagSheet 作为参考,但 get 函数将我们的参考的第一个字母大写为 getTagSheet。由于 JavaScript 区分大小写,如果你尝试使用 gettagSheet,JavaScript 将返回一个错误。

现在我们有了参考,我们需要在我们的 DataEntry 表格中的 添加标签 按钮和 AddTag 表格上的两个按钮中添加控件。代码如下:

control: {
    'button#addTagButton': {
        tap: 'showAddTag'
    },
    '#addTagSheet button[text="Cancel"]': {
        tap: 'cancelAddTag'
    },
    '#addTagSheet button[text="Save"]': {
        tap: 'saveAddTag'
    }
}

我们的每个控件都有三个部分:

  • 一个 DOM 选择器,告诉程序我们想要绑定到哪个组件

  • 我们希望它监听的事件

  • 当事件发生时触发的功能

我们将在创建数据存储时添加额外的控件。现在,让我们添加在点击这三个按钮时需要触发的函数。

第一部分是 showAddTag 函数。它调用我们的 AddTag 表格并显示它。该函数添加在 config 部分的末尾,看起来类似于以下代码:

showAddTag: function() {
    var sheet = this.getTagSheet();
    if (typeof sheet == 'undefined') {
        sheet = Ext.widget('addtag');
        Ext.Viewport.add(sheet);
    }
    sheet.show();
}

首先,我们检查内存中是否已经有一个表格(使用 this.getTagSheet() 函数,该函数由 refs 部分中的参考自动创建),如果没有,则使用 Ext.Widget() 函数创建一个新的具有 xtype 属性为 addtag 的组件来创建一个新的表格。然后我们将此表格添加到视图中并显示它。

我们 AddTag 表格中的 取消 按钮具有一个非常简单的功能:

cancelAddTag: function() {
    this.getTagSheet().hide();
}

这也用作我们自动生成的参考函数,用于获取打开的表格并关闭它。

现在,我们将为最后的 saveAddTag 函数复制此函数:

saveAddTag: function() {
    this.getTagSheet().hide();
}

这将简单地隐藏该表格。一旦我们创建了存储,我们将添加保存标签数据的代码。现在,保存并测试代码以确保表格按预期出现和隐藏。

最终结果应该看起来像这样:

创建 DataEntry 控制器

现在我们有了基本表单,我们需要创建我们的存储和模型。这将为我们提供存储来自各种表单的数据的地方。

定义模型和存储

对于这个项目,我们将使用 HTML5 提供的本地存储来存储我们的数据。我们将首先定义我们的数据条目模型的模型。我们将称这个为Entry.js,它放在models文件夹中。代码如下:

Ext.define('WeightWeight.model.Entry', {
    extend: 'Ext.data.Model',

    config: {
        idProperty: 'id',
        fields: [
            {name: 'id', type: 'auto'},
            {name: 'entryDate', type: 'date', dateFormat: 'm-d-Y'},
            {name: 'weight', type:'float'},
            {name: 'water', type:'int'},
            {name: 'calories', type: 'int'},
            {name: 'exercise', type: 'int'},
            {name: 'tag', type: 'string'}
        ],
        proxy: {
            type: 'localstorage',
            id: 'weightweight-entry'
        }
    }
});

模型相当简单,定义了各种数据类型和名称。需要注意的一点是entryDate字段,它有一个type字段为date

提示

当你在模型中使用日期类型时,你应该始终声明一个dateFormat组件。这告诉模型如何存储和检索数据。它还提供了从模型获取数据的组件的通用翻译。未能设置dateFormat组件通常会导致粗俗的语言和极度沮丧。

下一个我们需要的是标签的模型。Tag.js文件放在models文件夹中,它相当简单。它只有一个id字段和一个text字段:

Ext.define('WeightWeight.model.Tag', {
    extend: 'Ext.data.Model',

    config: {
        idProperty: 'id',
        fields: [
            {name: 'id', type: 'auto'},
            {name: 'text', type: 'string'}
        ],
        proxy: {
            type: 'localstorage',
            id: 'weightweight-tag'
        }
    }
});

如前所述,我们只使用一个localstorage代理并给它一个唯一的 ID。这个 ID 确保数据存储在其自己的单独表中。

我们需要的最后一个模型是我们的Config.js模型。这个模型遵循与本地存储代理和我们的config表单字段相同的格式。代码如下:

Ext.define('WeightWeight.model.Config', {
 extend: 'Ext.data.Model',
 config: {
  fields: [
   {name: 'id', type: 'int'},
   {name: 'startingWeight', type: 'float'},
   {name: 'targetWeight', type: 'float'},
   {name: 'exercisePerDay', type: 'int', defaultValue: 30},
   {name: 'caloriesPerDay', type: 'int'},
   {name: 'waterPerDay', type: 'int', defaultValue: 8},
   {name: 'weightUnits', type: 'string', defaultValue: 'lbs'},
   {name: 'waterUnits', type: 'string', defaultValue: 'glass'}
  ],
  proxy: {
   type: 'localstorage',
   id  : 'weightweight-config'
  }
 }
});

我们还在模型中包含了一些默认值。这些值将在我们创建新的配置记录时被拉入表单中。

一旦我们有了模型,我们需要创建我们的数据存储。EntryStore.js文件被创建,并放入stores文件夹中。代码如下:

Ext.define('WeightWeight.store.EntryStore', {
   extend: 'Ext.data.Store',
    config: {
   model: 'WeightWeight.model.Entry',
   autoLoad: true,
    storeId: 'EntryStore'
    }
});

这是一个非常基本的存储,我们将在以后扩展。现在,我们将使用模型来做大部分繁重的工作。我们给存储一个storeId值为EntryStore,这样我们就可以用我们的DataEntry控制器轻松地引用它。

接下来,我们需要一个用于我们的标签的存储。由于我们只需要对标签存储有非常有限的控制(它只向我们的AddTag表单中的列表提供数据),我们将把存储作为组件本身的一部分添加。打开AddTag.js文件并修改list条目,使其看起来类似于以下代码:

{
    xtype: 'list',
    height: 300,
    store: {
        model: 'WeightWeight.model.Tag',
        autoLoad: true
    },
    itemTpl: [
        '<div>{text}</div>'
    ]
}

这种简单的存储格式将存储作为list条目的部分创建,不需要添加到我们的app.js文件中。

说到app.js文件,我们应该在Ext.Application函数的顶部附近添加其他模型和存储,如下所示:

models: ["Tag", "Entry", "Config"]
stores: ['EntryStore']

注意

如果一个模型或存储在其自己的文件中,那么它需要添加到app.js文件中。但是,由于我们list的简单存储格式是组件本身的一部分,所以我们不需要将其添加到app.js文件中。

在我们的Config模型的情况下,将只有一个配置记录用于应用程序。这意味着我们实际上不需要存储来使用它。我们将在控制器中处理这个问题。

同时,回到控制器中

在我们的控制器中,现在是时候让那些存储为我们工作了,保存并显示我们的数据。

让我们从DataEntry控制器开始。首先,我们将添加一些额外的引用,这样我们就可以更容易地访问我们的组件。按照以下方式更新DataEntry.js的引用:

refs: {
    tagSheet: '#addTagSheet',
    tagList: '#addTagSheet list',
    tagInput: '#addTagSheet textfield',
    tagButton: 'button#addTagButton',
    tagField: '#hiddenTagField',
    entrySaveButton: 'dataentry button[text="Save"]',
    entryCancelButton: 'dataentry button[text="Cancel"]',
    entryForm: 'dataentry'
}

这为我们提供了轻松访问我们的标签添加表单、标签列表、输入和隐藏字段以及打开表单的按钮。我们还添加了对我们数据输入表单及其两个按钮的引用。

在这里,在control部分,我们需要为这些项目分配事件和函数。我们也可以在这里使用我们的引用名称来引用控件,如下所示:

control: {
    tagButton: {
        tap: 'showAddTag'
    },
    tagInput: {
      clearicontap: 'deselectTag'
    },
    tagList: {
      select: 'selectTag'
    },
    '#addTagSheet button[text="Cancel"]': {
        tap: 'cancelAddTag'
    },
    '#addTagSheet button[text="Save"]': {
        tap: 'saveAddTag'
    },
    entrySaveButton: {
        tap: 'saveEntry'
    },
    entryCancelButton: {
        tap: 'clearEntry'
    }
}

注意,我们大多数情况下使用了引用名称。然而,对于我们的tagSheet上的SaveCancel按钮,我们使用了组件查询引用。这是因为我们并不真的需要对这些两个组件有任何额外的控制。它们基本上是单用途组件。

例如,我们的showAddTagcancelAddTag函数都需要能够获取表单本身以便显示和隐藏它。由于我们有一个TagSheet的引用分配给它,我们可以使用以下代码调用它:

var sheet = this.getTagSheet();

由于我们一旦创建了SaveCancel按钮就不会对其进行修改,因此没有必要为它们创建引用。然而,当我们在保存标签时,将对我们的AddTagButton进行一些修改,因此我们为它创建了一个引用。

让我们更新我们的saveAddTag函数,看看它是如何完成的。按照以下方式更改函数:

saveAddTag: function() {
        var tag = this.getTagInput().getValue(),
            store = this.getTagList().getStore();
        if (tag != "") {
            this.getTagButton().setText('Tag: '+tag);
            this.getTagField().setValue(tag);
            if (store.findExact('text', tag) == -1) {
                store.add({text: tag});
                store.sync();
            }
        } else {
            this.getTagButton().setText('Add Tag');
            this.getTagField().setValue('');
        }

        this.getTagSheet().hide();
    }

从一开始,我们就开始自动使用由我们的引用创建的get函数。我们使用this.getTagInput().getValue()获取我们表单中textfield的值,然后通过调用this.getTagList().getStore()函数获取我们用于标签列表的存储。

注意

记住,列表存储是我们作为组件的一部分创建的,而不是单独的store.js文件。然而,由于我们可以访问列表,并且列表知道它使用的是哪个存储,我们可以轻松访问我们所需的一切。对父级的引用也为我们提供了快速访问其子项的途径。

接下来,我们检查用户是否在字段中输入了任何内容(如果tag的值不为空),如果是,我们将按钮上的文本设置为Tag:**和用户输入的内容。这为用户提供了一个关于当前条目上标签的简单反馈,因此如果我们把条目标记为Tired,那么按钮将看起来像这样:

与此同时,在控制器中

接下来,我们将隐藏字段的值设置为相同的值。我们这样做是因为我们需要将我们的表单加载到记录中以保存它。我们可以从表单字段中加载值,但不能从按钮名称中加载值。我们使用隐藏字段来在表单中保存值以供以后使用。

接下来,我们需要找出标签是否是我们之前输入的,或者它是否是新的。为了做到这一点,我们需要使用store.findExact('text', tag)在存储中搜索。如果tag的值在任何存储数据的text字段中找不到,它将返回-1。如果我们找不到标签,我们使用以下代码将其添加到我们的存储中:

store.add({text: tag});
store.sync();

最后,如果用户清除了textfield并使其为空,我们将从按钮中移除之前的标签文本并清除隐藏字段的值。

我们下一个函数控制当用户从表格中的标签列表中选择一个现有标签时(而不是输入一个新的):

selectTag: function(list, record) {
this.getTagInput().setValue(record.get('text'));
}

当用户在列表中选择一个项目时,我们将项目的文本放入文本字段以保存。saveAddTag函数将处理其余部分。

我们有一个类似的功能,用于取消选择列表中的项目:

deselectTag: function() {
 this.getTagList().deselectAll();
}

我们文本字段有一个清除图标,可以移除字段的值。我们将其与我们在controllers部分中设置的clearicontap事件关联起来,以触发这个deselectTag函数。

现在我们已经处理好了标签,我们将能够保存完整的条目。我们通过添加以下函数来完成这项工作:

saveEntry: function() {
    var values = this.getEntryForm().getValues(),
    store = Ext.getStore('EntryStore'),
    entry = Ext.create('WeightWeight.model.Entry', values);

    store.add(entry);

    store.sync();
    Ext.Msg.alert('Saved!', 'Your data has been saved.', this.clearEntry, this);
}

这个函数从我们的表单中获取值并为我们的存储创建一个新的条目。由于表单的名称与我们的模型名称匹配,我们可以使用Ext.Create来创建一个新的条目记录并直接分配值。然后我们将新的记录添加到存储中并同步。最后,我们向用户提醒新数据已被保存。

我们最后的函数通过以下函数清除我们表单中的字段:

clearEntry: function() {
    this.getEntryForm().reset();
    this.getTagButton().setText('Add Tag');
}

这个函数重置我们的表单和按钮的文本。这个函数将由我们的数据输入表单中的取消按钮触发。

这完成了DataEntry.js控制器的编写。我们现在可以继续到Config.js控制器。

Config.js

controllers文件夹中创建一个名为Config.js的新文件(确保也将它添加到app.js文件中的控制器列表中)。我们将从基本的控制器开始:

Ext.define('WeightWeight.controller.Config', {
    extend: 'Ext.app.Controller',

    config: {
        views:['Config'],
        models:['Config'],
        refs: {
            form: 'configform'
        },
        control: {
            form: {
                initialize: 'getSavedConfig'
            }
        }
    }
});

这设置了我们的控制器,包括我们的视图、模型和引用。它还为一个表单分配了一个函数,以便在初始化时调用getSavedConfig。这个函数也是我们需要创建的第一个。

在我们开始之前,我们应该记住一些关于config的事情。这将会像是一组应用的首选项。对于config将只有一个记录,这就是为什么我们不需要创建一个存储。我们可以使用Config.js模型直接创建、加载和保存记录。让我们看看这是如何完成的。

config部分下方,我们需要添加以下代码:

getSavedConfig: function() {
    var config = Ext.ModelManager.getModel('WeightWeight.model.Config');
    config.load(1, {
        scope: this,
        failure: this.createSavedConfig, 
        success: this.bindRecordToForm 
    });
}

在这里,我们创建了一个Config模型的实例,并尝试从 HTML5 本地存储中加载第一条记录(记住这应该也是唯一的记录)。这里有两种可能的结果:

  • 如果加载失败,这意味着这是用户第一次访问 Config 部分,我们没有记录。在这种情况下,我们将调用另一个名为 createSavedConfig 的函数。

  • 如果加载成功,那么我们需要将数据加载到我们的表单中以进行显示。这将在 bindRecordToForm 函数中发生。

通过将函数的作用域设置为 this(即控制器本身),我们可以使这两个函数成为控制器的一部分,并分别使用 this.createSavedConfigthis.bindRecordToForm 调用它们。

我们将从在之前的 getSavedConfig 函数下方添加我们的新函数开始:

createSavedConfig: function() {
    var config = Ext.create('WeightWeight.model.Config', {id: 1});
    config.save({
        success: this.bindRecordToForm
    }, this);
}

这个函数创建了一个具有我们在 config 对象中定义的默认值的新空记录,然后保存该记录。如果这成功,我们将调用我们的下一个函数,该函数将数据记录绑定到我们的表单:

bindRecordToForm: function(record) {
    this.savedConfig = record;

    var form = this.getForm();
    form.setRecord(this.savedConfig);

    form.on({
       delegate: 'field',
       change: this.updateValue,
        spin: this.updateValue,
        check: function(field) {
            this.updateValue(field, field.getGroupValue());
        },
        scope: this
    });
}

这个函数由 getSavedConfigcreateSavedConfig 调用,它们会自动传递数据记录。我们将这个记录设置为我们的 savedConfig,这样我们就可以在任何控制器位置获取配置数据。

接下来我们获取表单并使用 setRecord 用我们的数据填充表单。一旦表单被填充,我们还需要一种保存数据的方法。为此,我们将使用一种称为 代理 的有趣技术。

代理允许我们在表单中的特定子元素上设置监听器和函数。在这种情况下,我们执行 form.on({ delegate: 'field',这让我们可以在我们表单的每个字段上设置一组监听器:

  • numberfield 组件理解 change 事件

  • spinnerfield 组件理解 spin 事件

  • checkboxfield 组件理解 check 事件

这些事件中的每一个都会调用 this.updateValue 来保存数据。虽然其他字段会自动传递字段和值,但复选框实际上只有在 check 事件触发时才传递字段。这意味着我们需要做一点额外的工作,以便它们将字段和值传递给我们的下一个函数。

我们的 updateValue 函数接受在前一个函数中传递的字段和值,并为我们保存数据:

updateValue: function(field, newValue) {
    this.savedConfig.set(field.getName(), newValue);
    this.savedConfig.save();
}

这将我们的数据保存到本地存储。现在我们有了保存数据和目标的方法,我们可以开始查看用于显示数据的图表功能。

开始使用 Sencha Touch Charts

如我们在本章开头所提到的,Sencha Touch Charts 目前仅作为 Sencha Complete 或 Sencha Touch 2.1 的开源版本的一部分提供。以前,Sencha Touch Charts 是一个单独的下载,必须作为您应用程序的一部分安装和配置才能使用。现在这不再需要了。

注意

还应注意的是,如果你使用的是独立的商业版 Sencha Touch 2.1(它不属于 Sencha Complete 套件),你将无法使用新的 Sencha Charts 功能。虽然这个独立的商业版 Sencha Touch 2.1 包含一个空的src/charts目录,但它没有任何实际的图表功能。

创建概览图

概览图是一个单线图,追踪体重和锻炼。我们的图表将有三条轴,体重范围显示在左侧,日期范围显示在底部,锻炼时间范围在右侧。

以下截图更详细地描述了前面的解释:

创建概览图

我们将从对OverviewChart.js视图的占位符进行一些更改开始:

Ext.define("WeightWeight.view.OverviewChart", {
    extend:'Ext.Panel',
    alias:'widget.overview',
    config:{
        title:'Overview',
        iconCls:'star',
        layout: 'fit',
        items:[{
          xtype:'chart',
          store:'EntryStore', 
          legend:{
            position:'bottom'
          }]
       }
    }
});

在这里,我们已经替换了html配置,并将单个chart项作为我们面板的一部分包含进来。

注意

Sencha Touch Chart 软件的早期版本使用了一个chartPanel对象,该对象自动将chart项作为面板的一部分。当前的 2.1 版本将chart项视为一个单独的对象,这允许chart项嵌入到面板或容器中。

我们已经为chart项指定了一个store值以获取数据,并将legend部分定位在图表的底部。

添加轴

我们接下来需要添加的是我们的轴。正如我们之前提到的,这个图有三个轴。它们的代码位于config定义中的chart部分(在我们的legend定义下方):

axes:[
 {
  type:'numeric',
  position:'left',
  fields:['weight'],
  title:{
   text:'Weight',
   fontSize:14
  }
 },
 {
  type:'numeric',
  position:'right',
  fields:['exercise'],
  title:{
   text:'Exercise',
   fontSize:14
  }
 },
 {
  type:'time',
  dateFormat:'m-d-Y',
  position:'bottom',
  fields:'entryDate',
  title:{
   text:'Date',
   fontSize:20
  }
 }
]

第一个轴有一个title部分为weight,它是一个numeric轴。我们将它定位在左侧,然后告诉轴我们正在跟踪哪些字段(在这种情况下,weight)。

如您从名称fields中猜测的那样,这意味着我们可以沿着同一轴跟踪多个项目。如果您有多个具有相同数值数据范围的项,这将工作得很好。在这种情况下,exerciseweight的范围变化太大,所以我们把它们放在不同的轴上。

exercise轴以类似的方式设置,但位于右侧。

date轴略有不同。它有一个date类型和用于显示的dateFormat

接下来,我们需要设置系列。

创建系列

series部分位于图表配置内部,并在我们的轴部分之下。series部分描述了数据点应在图上如何对齐以及它们应该如何格式化。

我们的概览图是一个折线图显示,追踪随着时间的推移体重和锻炼情况。我们需要为体重创建一个条目,并为锻炼创建第二个条目:

series:[
 {
  type:'line',
  xField:'entryDate',
  yField:'weight',
  title:'Weight',
  axis:'left',
  style:{
   smooth:false,
   stroke:'#76AD86',
   miterLimit:3,
   lineCap:'miter',
   lineWidth:3
  },
  marker:{
   type:'circle',
   r:6,
   fillStyle:'#76AD86'
  },
  highlightCfg:{
   scale:1.25
  }
 },
 {
  type:'line',
  xField:'entryDate',
  yField:'exercise',
  title:'Exercise',
  axis:'right',
  style:{
   smooth:false,
   stroke:'#7681AD',
   lineWidth:3
  },
  marker:{
   type:'circle',
   r:6,
   fillStyle:'#7681AD'
  },
  highlightCfg:{
   scale:1.25
  }
 }
]

这定义了我们的两个系列(WeightExercise)。type配置定义了我们使用的是哪种类型的系列。xField配置确定沿着水平轴跟踪哪个数据字段(两个都是entryDate)和yField配置确定沿着垂直轴跟踪哪个字段(第一个系列是weight,第二个系列是exercise)。axis配置告诉系列将值映射到图形的哪个部分。

style部分确定我们的系列线条将如何显示。marker部分给出了线条上每个数据点的外观。highlightCfg部分使用scale来增加选中标记的大小,因此当用户点击数据点时,标记将增加到正常大小的 1.25 倍。

marker部分本身实际上是一个sprite引用,这意味着我们可以为我们的marker使用任何可用的 Sencha Touch sprite对象。这些包括如下内容:

  • 圆形

  • 椭圆

  • 图片

  • 矩形

  • 文本

可用的sprite及其配置选项的完整列表可以在 API 的draw | sprite部分找到,网址为docs.sencha.com/touch/2-1/。要使用这些sprite,只需将类型配置设置为sprite名称。每个sprite的名称可以在文档的顶部找到,如下面的截图所示:

创建系列

一旦为marker部分设置了type配置,就可以使用任何sprite的配置选项来自定义标记的外观。

系列配置完成后,我们还可以向图形添加一些交互,使其更有趣。

interactions部分

interactions部分允许我们响应用户的点击和手势来扩展我们提供的信息量。当前交互类型包括以下几种:

  • ItemCompare:这允许用户选择两个项目并查看数据比较

  • ItemHighlight:这允许用户点击并突出显示图表中的数据系列

  • ItemInfo:这允许用户点击并获取数据记录的详细视图

  • PanZoom:这允许用户通过捏合图表来放大和缩小,或者通过点击和拖动来平移

  • PieGrouping:这允许用户选择并合并连续的饼图切片

  • Rotate:这允许用户点击并拖动饼图或雷达图的中心来旋转图表

  • ToggleStacked:这允许用户在柱状图或柱形图系列之间切换堆叠和分组方向

对于这个应用程序,我们将允许用户轻触数据点,并获取该特定日期的所有详细信息。我们设置了一个type值为iteminfo的交互,并定义了一个tpl标签,该标签用于在面板中显示数据。交互接收被轻触数据点的整个数据记录,以便tpl标签可以使用我们的任何值,如重量、锻炼、水分、卡路里或标签:

interactions:[
 {
  type:'iteminfo',
  panel:{
   tpl:[ '<table>',
    '<tpl if="weight"><tr><th>Weight</th><td>{weight} ({weightUnits})</td></tr></tpl>',
    '<tpl if="water"><tr><th>Water</th><td>{water} ({waterUnits})</td></tr></tpl>',
    '<tpl if="calories"><tr><th>Calories</th><td>{calories}</td></tr></tpl>',
    '<tpl if="exercise"><tr><th>Exercise</th><td>{exercise} minutes</td></tr></tpl>',
    '<tpl if="tag"><tr><th>Tag</th><td>{tag}</td></tr></tpl>',
    '</table>'
   ]
  }

这个模板将显示我们的详细项目信息。接下来,我们需要添加一个监听器,当我们在OverviewChart中点击一个数据点时,它会显示窗口:

  listeners:{
   show:function (interaction, item, panel) {
    var record = item.record;
    var dt = new Date(record.get('entryDate'));
    var config = Ext.ModelManager.getModel('WeightWeight.model.Config');
    config.load(1, {
     scope:this,
     success:function (configRecord) {
      panel.setData(Ext.apply(record.getData(), configRecord.getData()));
     }
    });

panel.getDockedComponent(0).setTitle(Ext.Date.format(dt, 'm-d-Y'));
   }
  }
 }
]

监听器首先设置var record = item.record;然后从记录中获取日期,以便我们可以在监听器的末尾正确地格式化它用于setTitle函数。

接下来,我们获取单个配置记录,以便我们可以获取重量和水分消耗的单位。然后我们将面板的数据设置为组合的recordconfigRecord对象(使用Ext.apply())。这会将两组数据都放入我们的tpl中以便显示。

最后,由于这是一个 Sencha Touch 中的特殊浮动面板,它没有title属性,但我们可以使用面板中第一个停靠的组件来创建一个。我们将这个title设置为函数顶部获取的格式化日期。

交互部分

您现在应该能够保存您的作品并点击任何数据点以查看我们新的详细项目信息。

我们最后要介绍的是创建详细视图。

创建详细视图

对于详细图表,我们决定制作一个稍微更可重用的东西。我们的整体详细视图将包含三个类似的图表和一个雷达图。由于我们不希望反复创建相同的图表,我们需要一个可以调用不同配置的视图。这将是一个简单的条形图,有两个轴;一个用于日期,一个用于金额。

创建详细视图

这个可重用的图表将是我们的goalChart视图。我们将创建一个具有自己xtypegoalChart视图,这将允许我们使用不同的配置重用它。

创建目标图表视图

我们首先创建一个goalChart视图,并在初始化时设置它来加载我们的config文件:

Ext.define('WeightWeight.view.goalChart', {
   extend:'Ext.Panel',
    alias:'widget.goalchart',
    config: {
      layout: 'fit'
    },
    constructor: function (config) {
        this.store = Ext.getStore('EntryStore');

        Ext.apply(this, config);

        this.callParent([config]);
        var configRecord = Ext.ModelManager.getModel('WeightWeight.model.Config');
        configRecord.load(1, {
            scope:this,
            success: this.createChart
        });

    }
});

在这里,我们将面板的 store 设置为包含所有数据的EntryStore(这使我们能够访问每条记录)。接下来,我们的constructor函数将接受传递给它的任何配置选项,并使用Ext.apply(this, config);将其应用于面板。这就是我们将为每个图表设置一个单独的titledataFieldgoalFieldcolorSet的地方。

一旦设置了这些选项,面板随后将以与我们之前图表面板相同的方式加载来自单个configRecord的目标和测量值。这次当Config成功加载时,我们调用一个新的函数createChart

createChart函数紧随我们的constructor函数之后:

createChart: function(config) {
 this.configRecord = config;
 var goalStore = Ext.create('Ext.data.Store',{ fields: [
    'entryDate', 
    {name: Ext.String.capitalize(this.dataField), type:'int'}, 
    {name: 'goal', type: 'int'}
   ]
  }
 );
 this.store.each(function(record) {
 if (record.get(this.dataField)) {
  var values = {
  entryDate: Ext.Date.format(dt,'m-d-Y'),
  goal: this.configRecord.get(this.goalField)
  };
  values[Ext.String.capitalize(this.dataField)] = record.get(this.dataField);
  goalStore.add(values);
  }
 }, this);
}

createChart函数首先创建一个名为goalStore的第二个store,并给它以下三个字段:

  • entryDate:这是我们的存储中的日期字段

  • goal:这是从我们的configRecord传递过来的目标

  • this.dataField:当我们使用goalChart视图时,这将被作为我们的配置选项之一传递给我们

然后,我们遍历主存储(EntryData)中的数据,并查找字段中与this.dataField收到的值匹配的任何值。当我们找到匹配项时,我们将它们添加到我们的goalStore中。goalStore是实际将数据提供给图表的存储。

例如,我们可以使用以下代码创建一个goalChart视图:

{
  xtype: 'goalchart', 
  chartTitle: 'Exercise', 
  dataField: 'exercise', 
  goalField: 'exercisePerDay', 
  colorSet:['#a61120', '#ff0000'] 
}

goalChart视图将使用dataField值来查找我们拥有的任何关于exercise的数据,并创建图表。它还会使用goalFieldexercisePerDay从我们的配置记录中获取该数字并将其添加到显示中。

我们goalChart的最后一部分设置系列和轴,与之前的类似:

this.chart = Ext.factory({
 xtype: 'chart',
 store: goalStore,
 animate: true,
 legend: {
  position: 'right'
 },
 axes: [{
  type:'Numeric',
  position:'left',
  fields:[ Ext.String.capitalize(this.dataField), 'goal'],
  title: Ext.String.capitalize(this.dataField),
  decimals:0,
  minimum:0
 },
 {
  type:'category',
  position:'bottom',
  fields:['entryDate'],
  title:'Date'
 }],
 series: [
  {
   type: 'bar',
   xField: 'entryDate',
   yField: Ext.String.capitalize(this.dataField),
   style: {
    fill: this.colorSet[0],
    shadowColor: 'rgba(0,0,0,0.3)',
    maxBarWidth: 50,
    minGapWidth: 3,
    shadowOffsetX: 3,
    shadowOffsetY: 3
   }
  },
  {
   type:'line',
   style: {
    smooth: false,
    stroke: this.colorSet[1],
    lineWidth: 3
   },
   axis:'left',
   xField:'entryDate',
   yField:'goal',
   showMarkers: false,
   title:'Goal'
   }
  ]
 }, 'Ext.char t.Chart');

与之前的图表相比,主要的不同之处在于我们有一些值将由我们的config提供,我们使用Ext.factory函数来创建图表对象。

注意

在这里,我们使用Ext.factory的方式等同于Ext.create,但Ext.factory也可以用来更新现有对象的配置。我们选择在这里使用Ext.factory而不是Ext.create,仅仅是因为大多数 Sencha Charts 示例在创建图表时都引用了Ext.factory,我们希望保持一致性。

现在,我们只需通过为以下内容设置不同的config值,就可以重用图表来创建我们的锻炼、水和体重图表:

  • dataField

  • goalField

  • chartTitle

  • colorSet

请查看我们示例代码中的DetailChart.js文件,以了解这是如何工作的。

我们需要最后提到的图表是单词图表。

创建单词图表

wordChart视图的设置与我们的goalChart类似,有自己的constructorcreateChart函数。然而,目标图表使用我们的标签创建一个不同类型的图表,称为雷达图。我们的wordChart.js文件检查特定单词的出现次数,并使用这些信息绘制我们的雷达图。

创建单词图表

wordChart.js文件的开头几乎与我们的goalChart相同:

Ext.define('WeightWeight.view.wordChart', {
    extend:'Ext.Panel',
    alias:'widget.wordchart',
    config: {
        layout: 'fit'
    },
    constructor: function (config) {
        this.store = Ext.getStore('EntryStore');
        Ext.apply(this, config);
        this.callParent([config]);
        var configRecord = Ext.ModelManager.getModel('WeightWeight.model.Config');
        configRecord.load(1, {
            scope:this,
            success: this.createChart
        });

    }

});

constructor结束之后,我们设置我们的createChart函数:

createChart: function(config) {
        this.configRecord = config;
        this.store.filterBy(function(record) {
            if (record.get('tag')) {
                return true;
            } else {
                return false;
            }
        });
        this.store.setGroupField('tag');
        this.store.setGroupDir('ASC');
        var groups = this.store.getGroups();
        this.store.setGroupField('');
        this.store.clearFilter();
        var wordStore = Ext.create('Ext.data.Store',
            { fields: ['name', {name: 'count', type: 'int'}]}
        );
        Ext.each(groups, function(group) {
           wordStore.add({name: group.name, count: group.children.length});
        });

这就像我们之前做的那样抓取我们的configRecord,然后过滤我们的store以找到只有具有tag数据的记录。然后我们按tag字段group字段,以便我们可以为每个tag生成一个count

接下来,我们创建第二个store,就像在我们的goalCharts中一样,并将我们的标签名称和计数转移到第二个store中。这个store被称为我们的wordStore

现在我们有一个只包含标签名称和出现次数的wordStore,我们可以用它来为我们的新图表提供数据。同样,我们使用Ext.Factory来创建我们的存储:

this.chart = Ext.factory({
 xtype: 'polar',
 store: wordStore,
 animate: {
  easing: "backInOut",
  duration: 500
 },
 series: [{
  type: 'radar',
  xField: 'name',
  yField: 'count',
  labelField: 'name',
  marker:{
   type:'circle',
   r:3,
   fillStyle:'#76AD86'
  },
  style: {
   fillStyle: 'rgba(0,255,0,0.2)',
   strokeStyle: 'rgba(0,0,0,0.8)',
   lineWidth: 1
  }
 }]

雷达风格图表在其图表配置中使用了xtype值为polar

小贴士

极坐标图表包括饼图和雷达风格图表等圆形图表系统,而笛卡尔图表是基于线条的图表,如面积图和柱状图。

series部分,我们为我们的图表设置type值为radar,这为我们提供了特定的图表外观。

与我们之前的图表一样,我们也设置了markerstyle配置。最后,我们通过设置轴、关闭图表对象并将其添加到面板来完成wordChart

axes: [{
  type: 'numeric',
   position: 'radial',
   fields: 'count',
   grid: true,
   label: {
    fill: 'black'
   }
  },{
   type: 'category',
   position: 'angular',
   fields: 'name',
   grid: true,
   label: {
    fill: 'black'
   },
  style: {
   estStepSize: 1
  }
 }]
}, 'Ext.chart.Chart');

this.add(this.chart); 

这里我们有两个轴:一个用于标签计数的numeric轴和一个用于标签名称的category轴。我们将这些轴映射到正确的field,并将grid设置为true。这将为我们雷达图提供一个底部的网格。

style设置中的estStepSize: 1确保所有单词都将显示在雷达图的边缘,不会跳过任何单词。

现在我们的wordChart已经完成,我们需要将所有图表组装成一个页面,以供我们的完整详细信息视图使用:

创建词云图

回到我们的details.js占位符文件,我们需要设置一个新的布局并添加我们的四个图表。正如您在屏幕截图中所见,我们在页面上以正方形排列了四个图表,每个角落一个图表。完成这个任务最简单的方法是使用一组嵌套的hboxvbox布局:

创建词云图

正如您在上一张图片中所见,我们的详细信息面板将有一个layout部分为hbox,其中包含两个容器,一个在另一个上面。在我们的config部分,添加布局如下:

  layout: {
   type: 'hbox',
   align: 'stretch',
   pack: 'center',
   flex: 1
  }

stretchcenter值确保我们的容器将扩展以填充可用空间并占据详细信息面板的中心。flex值使内部容器大小相等。这两个容器将具有vbox布局。

我们在config部分的items部分添加了这两个容器:

items: [
    {
        xtype: 'container',
        layout: {
            type: 'vbox',
            align: 'stretch',
            pack: 'center',
            flex: 1
        },
        items: [
            {height: 300, width: 400, xtype: 'goalchart', chartTitle: 'Exercise', dataField: 'exercise', goalField: 'exercisePerDay', colorSet:['#a61120', '#ff0000'] },
            {height: 300, width: 400, xtype: 'goalchart', chartTitle: 'Caloric Intake', dataField: 'calories', goalField: 'caloriesPerDay', colorSet:['#ffd13e', '#ff0000']}
        ]
    },
    {
        xtype: 'container',
        layout: {
            type: 'vbox',
            align: 'stretch',
            pack: 'center',
            flex: 1
        },
        items: [
            {height: 300, width: 400, xtype: 'goalchart', chartTitle: 'Water', dataField: 'water', goalField: 'waterPerDay', colorSet:['#115fa6', '#ff0000']},
            {height: 300, width: 400, xtype: 'wordchart', chartTitle: 'Tags', dataField: 'tag'}
        ]
    }
]

这两个容器形成了顶部和底部布局,每个容器中各有两个图表。目标图表具有略微不同的配置,以便显示锻炼、卡路里和水的消耗。我们还用不同的颜色来增强视觉效果。wordchart使用类似的配置,仅包含我们标签的数据。

完成最后一个面板后,您应该能够将数据输入到应用程序中并测试所有图表。

作业

花些时间尝试不同的图表类型,看看有哪些可用选项。Sencha 网站提供了一个优秀的指南,用于使用图表和交互功能,请参阅docs.sencha.com/touch/2-1/#!/guide/drawing_and_charting

总结

在本章中,我们讨论了:

  • 设置基本应用程序以创建应用程序的不同视图

  • 创建将存储数据并为我们提供图表的存储库

  • 设置应用程序的控制器

  • 创建概述图表

  • 创建详细图表

在下一章中,我们将探讨如何创建一个简单的应用程序来处理外部 API。

第五章。在牌组中:使用 Sencha.io

在我们之前的章节中,我们通常使用本地存储来维护我们的数据。这以其易用性和简单性提供了许多优势。存储和模型为我们做了所有繁重的工作。

然而,本地存储也有一些缺点。首先,它非常本地化。这意味着如果你的用户有多个设备(一部手机、桌面和一台平板电脑),那么他们将为每个设备有一组独立的数据。

这可能会使用户感到困惑,并抵消了拥有一个可以从多个设备访问的单个应用程序的优势。此外,用户在清除本地浏览器数据时可以删除数据。这可能会使本地存储对健壮的应用程序有些问题。

在本章中,我们将探讨使用名为 Sench.io 的外部 API 来解决此问题。以下是我们将要涵盖的内容:

  • 设置基本应用程序

  • 开始使用 Sencha.io

  • 将基本应用程序更新为与 Sencha.io 一起工作

基本应用程序

我们的基本应用程序旨在以随机顺序向用户展示一组闪卡。每一组闪卡包含一副牌。用户可以为每副牌添加新的牌。这些牌和牌组将存储在一个名为Sencha.io的远程存储服务中。使用此服务,用户还可以从任何数量的设备登录并访问他们的牌和牌组。

基本应用程序

我们将开始我们的应用程序,从模型和存储开始。

创建模型和存储

我们的牌组模型非常简单,只需要两块信息。我们将使用一个 ID 将牌链接到特定的牌组,并使用名称进行显示:

Ext.define('MyApp.model.Deck', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id'
            },
            {
                name: 'name'
            }
        ]
    }
}); 

牌模型需要它自己的 ID,以便我们可以唯一地识别它,以及一个deckID值,以便我们知道它是哪个牌组的一部分。我们还需要为每张牌提供问题和答案:

Ext.define('MyApp.model.Card', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id'
            },
            {
                name: 'deckID'
            },
            {
                name: 'question'
            },
            {
                name: 'answer'
            }
        ]
    }
});

对于这两个存储,我们最初将使用与之前章节中相同的本地存储proxy。这将让我们在开始使用 Sencha.io 服务之前测试我们的应用程序。

我们的牌组存储看起来是这样的:

Ext.define('MyApp.store.DeckStore', {
    extend: 'Ext.data.Store',
    requires: [
        'MyApp.model.Deck'
    ],
    config: {
        autoLoad: true,
        model: 'MyApp.model.Deck',
        storeId: 'DeckStore',
        proxy: {
            type: 'localstorage',
            id: 'Decks'
        },
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'name',
                type: 'string'
            }
        ]
    }
});

如果你已经完成了第一章,这个基本设置应该对你来说相当熟悉。我们扩展了基本存储,需要我们的model文件,然后设置我们的配置。配置设置在创建时加载存储,告诉它使用哪个模型,设置我们的本地存储proxy,并告诉它期望哪些fields

我们的牌存储几乎是一个完全的副本:

Ext.define('MyApp.store.CardStore', {
    extend: 'Ext.data.Store',
    requires: [
        'MyApp.model.Card'
    ],
    config: {
        autoLoad: true,
        model: 'MyApp.model.Card',
        storeId: 'CardStore',
        proxy: {
            type: 'localstorage',
            id: 'Cards'
        },
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'deckID',
                type: 'int'
            },
            {
                name: 'question',
                type: 'string'
            },
            {
                name: 'question',
                type: 'string'
            }
        ]
    }
});

在这里,我们只是将名称从Deck更改为Card,并用我们的牌字段替换了牌组字段。

注意

如果你用模型配置存储,实际上你不必指定字段。我们在这里这样做只是为了完整性。

如前所述,一旦我们在 Sencha.io 上设置好一切,我们就会重新访问这些商店,但首先我们需要为我们的列表、卡片和编辑准备显示。

创建视图

对于我们的主视图,我们将使用一个包含两个容器的选项卡面板,一个用于我们的牌组,另一个用于我们的卡片。我们将使用表单进行编辑和添加新的牌组和卡片。我们的初始 main.js 文件如下所示:

Ext.define('MyApp.view.Main', {
    extend: 'Ext.tab.Panel',

    config: {
        id: 'mainView',
        items: [],
        tabBar: {
            docked: 'bottom'
        }
});

记得将此文件添加到你的 app.js 文件中,并将启动函数设置为在应用程序启动时创建组件的副本(如果你使用 Sencha Architect,则此操作应自动完成)。你的 app.js 文件应如下所示:

Ext.Loader.setConfig({
    enabled: true
});

Ext.application({
    models: [
        'Deck',
        'Card'
    ],
    stores: [
        'DeckStore',
        'CardStore'
    ],
    views: [
        'Main'
    ],
    name: 'MyApp',

    launch: function() {

        Ext.create('MyApp.view.Main', {fullscreen: true});
    }

});

接下来,我们需要将两个容器添加到我们的 main.js 视图中。在空项部分,添加以下容器:

{
    xtype: 'container',
    layout: {
        type: 'fit'
    },
    title: 'Decks',
    iconCls: 'info',
    items: [
        {
            xtype: 'list',
            itemTpl: [
                '<div>{name}</div>'
            ],
            store: 'DeckStore'
        },
        {
            xtype: 'titlebar',
            docked: 'top',
            title: 'Decks',
            items: [
                {
                    xtype: 'button',
                    itemId: 'mybutton',
                    text: 'Add',
                    align: 'right'
                }
            ]
        }
    ]
}

这将是我们的牌组列表。整体容器具有 fit 布局,因此项目将填充容器的整个宽度和高度。我们给容器添加了一个标题和 iconCls 值,这些值将用于在 Main 选项卡面板中标记标签。

该容器有一个 list 视图,使用我们的 DeckStore 存储和简单的 itemTpl 模板,在单独的 div 标签中显示每个牌组的名称。

我们还添加了一个标题栏,其中可以显示用于添加新牌组的按钮,以及一个标题,让用户知道他们正在查看的内容。

我们的第二个容器遵循与第一个相同的模式,但与列表不同,我们有一个单独的容器,其中包含 carousel 布局,如下面的代码所示:

{
    xtype: 'container',
    title: 'Cards',
    iconCls: 'info',
    items: [
        {
            xtype: 'titlebar',
            docked: 'top',
            items: [
                {
                    xtype: 'button',
                    itemId: 'mybutton1',
                    text: 'Add',
                    align: 'right'
                },
                {
                    xtype: 'button',
                    text: 'Shuffle'
                }
            ]
        },
        {
            xtype: 'carousel'
        }
    ]
}

该容器有一个 titlebar 控件,将设置为在顶部显示当前牌组的名称,并将卡片拉入 carousel 布局。我们还有一个第二个按钮,可以随机排列当前的牌组卡片。

接下来,我们需要设置用于添加卡片和牌组的两个表单。牌组表单是一个简单的表单,包含用于命名牌组的 textfield 元素,用于保存的 button 元素,以及用于取消的另一个 button 元素:

Ext.define('MyApp.view.addDeckSheet', {
    extend: 'Ext.Sheet',
    alias: 'widget.addDeckSheet',

    config: {
        id: 'addDeckSheet',
        items: [
            {
                xtype: 'textfield',
                margin: '0 0 10 0',
                label: 'Name'
            },
            {
                xtype: 'button',
                ui: 'confirm',
                text: 'Save',
                itemID: 'saveDeckButton'
            },
            {
                xtype: 'button',
                itemId: 'cancelDeckButton',
                ui: 'decline',
                text: 'Cancel'
            }
        ],
        listeners: [
            {
                fn: 'hideDeckSheet',
                event: 'tap',
                delegate: '#cancelDeckButton'
            }
        ]
    },

    hideDeckSheet: function(button, e, options) {
        button.up('sheet').hide();
    }

});

我们还添加了一个监听器来监听 Cancel 按钮,该按钮会在不保存值的情况下隐藏表单。监听器将 tap 事件委托给我们的 cancelDeckButton 委托,并在 tap 事件发生时调用 hideDeckSheet 函数。

hideDeckSheet 函数接收 button 元素作为其参数的一部分。然后我们可以从按钮开始,沿着 DOM 结构向上移动,找到该表单,并将其隐藏。

提示

关于上下使用的注意事项

当你有一个组件并且需要到达子组件或父组件时,Sencha Touch 中的 updown 函数非常有用。然而,需要注意的是,updown 只返回第一个匹配的组件。例如,如果 button 元素位于 container 元素内部,而 container 元素本身又位于另一个 container 元素内部,那么 button.up('container') 将返回第一个容器而不是第二个,外部的容器。

我们的卡片表单是牌组表单的副本,但包含 问题答案 的文本字段:

Ext.define('MyApp.view.addCardSheet', {
    extend: 'Ext.Sheet', 
    config: {
        id: 'addCardSheet',
        items: [
            {
                xtype: 'container',
                html: 'Deck Name Here',
                style: 'color: #FFFFFF; text-align:center;'
            },
            {
                xtype: 'textareafield',
                id: 'cardQuestion',
                margin: '0 0 10 0',
                label: 'Question'
            },
            {
                xtype: 'textareafield',
                id: 'cardAnswer',
                margin: '0 0 10 0',
                label: 'Answer'
            },
            {
                xtype: 'button',
                ui: 'confirm',
                itemId: 'saveCardButton',
                text: 'Save'
            },
            {
                xtype: 'button',
                itemId: 'cancelCardButton',
                ui: 'decline',
                text: 'Cancel'
            }

        ],
        listeners: [
            {
                fn: 'hideCardSheet',
                event: 'tap',
                delegate: '#cancelCardButton'
            }
        ]
    },
    hideCardSheet: function(button, e, options) {
        button.up('sheet').hide();
    }
});

如前所述,我们有 保存取消 按钮,当点击 取消 按钮时将隐藏表单。

现在,你应该能够启动应用程序并测试不同的视图,如所示:

创建视图

在我们能够在应用程序中进一步工作之前,我们需要设置 Sencha.io。

开始使用 Sencha.io

Sencha.io 服务将允许我们使用 Sencha 的云服务来存储我们的数据。我们需要在 Sencha.io 控制台中注册一个新账户,添加我们的应用程序和用户组,然后配置我们的应用程序以使用该服务。

注册流程

要注册一个新账户,请访问 manage.sencha.io 并点击页面底部的注册链接。填写表格并提交。一旦你的账户创建成功,使用注册时相同的地址登录 Sencha.io 控制台,你将看到以下截图类似的内容:

注册流程

下载和安装 Sencha.io SDK

现在你已经有了账户,你可以下载并安装 Sencha.io SDK。在入门页面的第一部分有一个下载链接(当你登录时应该首先开始的地方)。

将 SDK 下载到您的计算机上并解压文件。将其移动到您的网页目录(您可以从应用程序中轻松引用的地方)。接下来,我们需要将这些文件添加到我们的应用程序中。您可以从打开主 app.html 文件并在文件的 head 部分添加以下行开始(与您的其他脚本包含一起):

<script type="text/javascript" src="img/socket.io.js"></script>
<script type="text/javascript" src="img/sencha-io-debug.js"></script>

注意

在这个例子中,我们将所有 Sencha.io 文件复制到了应用程序 lib 目录下的一个名为 io 的文件夹中。如果你的路径不同,你需要调整上面的行以适应你的设置。

现在我们已经包含了 Sencha.io 所需的两个主要文件,我们还需要在 app.js 中设置一些选项,以便自动加载器也能收集这些文件。在 app.js 文件的顶部,添加以下代码:

Ext.Loader.setPath({
    'Ext.io': 'lib/io/src/io',
    'Ext.cf': 'lib/io/src/cf'
});

一旦我们设置了这些信息,我们的应用程序应该能够收集到它需要与 Sencha.io 一起工作的所有文件,但在我们继续构建之前,我们仍然需要注册我们的应用程序。

注册你的应用程序和 Auth 组

在 Sencha.io 上注册你的应用程序和 Auth 组将为应用程序提供其自己的数据存储和授权用户基础。与大多数 API 服务一样,你的应用程序需要一种方式来唯一标识自己以远程系统,这样它就会知道在哪里存储你的数据。

使用 Sencha.io,当注册我们的应用程序时,我们将获得两块信息:一个 appID 值和一个 appSecret 值。这两块信息将被添加到 app.js 中,以标识我们的应用程序给 Sencha.io 系统。

让我们从添加一个认证组开始。认证组设置了一个用户可以注册以使用您的应用程序的组。如果您有多个应用程序,可以为每个应用程序设置一个认证组,或者设置一个共享多个应用程序的单个认证组。

从您的 Sencha.io 账户的 仪表板 部分 (manage.sencha.io),点击页面顶部的 认证组 链接,然后选择 创建认证组

注册应用程序和认证组

输入您的认证组名称,然后点击 保存。名称是任意的,但如果您为单个应用程序使用该组,最好将其命名为类似 myAppNameUsers 的名称,这样您可以跟踪这是为哪个应用程序。

认证组还控制用户在您的应用程序中的认证方式。一旦保存了认证组,您就可以编辑它并更改用户登录应用程序的方式。

注册应用程序和认证组

您可以选择在应用程序完全下载之前或之后让用户进行认证。您还可以选择让用户使用有效的 Facebook、Twitter 或 Sencha.io 账户登录。

如果您选择 Sencha.io 登录选项,那么 SDK 将自动处理应用程序内的用户注册和认证,无需额外编码。

如果您选择 FacebookTwitter 登录选项,Sencha.io 将自动处理认证。用户在能够访问应用程序之前必须先在任一服务中注册。

注册应用程序和认证组

现在我们已经为应用程序设置了一组用户,我们需要注册应用程序本身。从 Sencha.io 仪表板,点击 应用,然后点击 创建应用。和之前一样,我们只需要输入应用程序的名称。对于这个应用程序,我们选择了 OnDeck 这个名称,如下截图所示:

注册应用程序和认证组

接下来,我们需要从下拉菜单中选择我们的 认证组 名称。这会告诉 Sencha.io 对此组进行认证并将用户分配给我们的应用程序。有一个可选的 CNAME 字段用于设置应用程序所在的域名,以及一个用于 index.html 文件路径的字段。如果您使用的是 app.html 文件而不是 index.html,或者如果您的应用程序托管在服务器的子目录中,则需要设置此字段。

我们还可以在这里设置应用程序图标。这是当用户将我们的网络应用程序保存到主屏幕时将使用的图标。完成设置后,点击 保存

注册应用程序和认证组

在页面应用程序页面上,您现在应该看到您的应用程序 ID(在 ID 字段中提及)和密钥(在 Secret 字段中提及)的列表。请注意这些信息,因为我们完成注册后需要将它们添加到 app.js 中。

备注

请注意,前面示例截图中的 IDSecret 值仅是示例数据。您需要在 Sencha.io 网站上生成自己的信息以使您的应用程序工作。您还需要生成自己的 ID 和密钥以使章节中的示例代码工作。

完成注册的这一部分后,您需要将 ID 和密钥值添加到您的 app.js 文件中。打开文件,并在 Ext.application({ 部分的顶部添加以下代码:

config: {
    io: {
    appId: 'YouAppIDHere',
    appSecret: 'YouAppSecretHere'
  }
}

appIdappSecret 占位符所示的信息替换您的信息。一旦您将信息添加到 app.js 中,我们就可以回到更新我们的存储和创建我们的控制器。

更新应用程序以使用 Sencha.io

现在我们已经注册了应用程序,我们需要设置存储以使用 Sencha.io。我们还将设置应用程序的控制器,并展示如何覆盖 Carousel 组件以使其与数据存储一起工作。

更新存储

我们原始的存储是简单的本地存储,用于测试。我们现在将编辑这些存储以使用 Sencha.io 存储我们的数据。这些更改的大部分将在 proxy 部分进行。例如,DeckStore 代码应更新为如下所示:

Ext.define('MyApp.store.DeckStore', {
    extend: 'Ext.data.Store',
    requires: [
        'MyApp.model.Deck'
    ],

    config: {
        autoLoad: true,
        model: 'MyApp.model.Deck',
        storeId: 'DeckStore',
 proxy: {
 type: 'syncstorage',
 id: 'decks',
 owner: 'user',
 access: 'private'
 },
 autoLoad: true, 
 autoSync: false
    }
});

注意,我们现在使用一种新的代理类型,称为 syncstorage。这是一个特殊的 Sencha.io 代理,它的工作方式与本地存储非常相似,但它将数据远程存储在 Sencha.io 服务器上。

我们还有新的 owneraccess 配置。owneraccess 字段都必须设置,syncstorage 才能正确工作。截至本文写作时,唯一的 owner 选项是 user。这是当前认证的用户。

access 配置确定存储是私有的,仅对当前认证用户可用,还是公共的,对所有用户组的成员可用。这是我们设置在 Sencha.io 仪表板中的用户组。

接下来,我们添加了 autoLoad: true 的配置。当用户仍然登录时,如果连接丢失,这将加载任何本地数据。然后我们设置 autosync: false 以防止存储在应用程序启动时自动同步。我们应该在加载存储之前等待用户登录到应用程序。我们将手动作为应用程序控制器的一部分来完成这项工作。

您可以对 CardStore.js 文件进行完全相同的更改,使其与 Sencha.io 一起工作,但需要为 remoteFilter: false 设置一个额外的设置。当用户登录时,我们将加载所有用户的卡片,并在选择一副牌后进行过滤。

备注

remoteFilter 配置是存储的一部分,而不是代理。

现在我们已经配置了存储,我们可以继续到控制器部分。

创建控制器

我们的控制器需要为我们处理一些事情:

  • 为 Sencha.io 设置

  • 处理登录和登出时需要发生的任何事情

  • 添加卡片和牌组

  • 选择用于显示的牌组

  • 在本地应用程序和 Sencha.io 之间同步

我们将首先设置基本控制器,包括模型、视图、存储和引用:

Ext.define('MyApp.controller.MyController', {
    extend: 'Ext.app.Controller',
    config: {
        selectedDeck: false,
        models: [
          'Deck',
          'Card'
        ],
        stores: [
          'DeckStore',
          'CardStore'
        ],
        views: [
          'Main',
          'addCardSheet',
          'addDeckSheet',
          'CardView'
        ],
   refs: {
       addCardSheet: '#addCardSheet',
       addCardSaveButton: '#addCardSheet button[text="Save"]',
       addDeckSheet: '#addDeckSheet',
       addDeckSaveButton: '#addDeckSheet button[text="Save"]',
       deckList: '#deckList',
       mainView: '#mainView',
       shuffle: 'button[text="Shuffle"]'
   }
  }
});

第一部分定义了我们的控制器,并列出了之前创建的modelsstoresviews值。我们还为selectedDeck添加了一个空配置。我们将使用这个作为存储当前所选牌组记录的占位符。这将允许我们在控制器的任何地方使用getSelectedDecksetSelectedDeck轻松地获取和设置值。

小贴士

获取器和设置器

Sencha Touch 会自动为配置设置以及引用创建getset函数(见下一节)。这些函数的形式为getWhateverYouCalledItsetWhateverYouCalledIt。重要的是要记住,即使你将config选项或引用的第一个字母小写,getset函数也会将第一个字母大写。

refs部分允许我们通过id(例如#addCardSheet)或组件查询(例如#addCardSheet button[text="Save"])创建对组件的引用。

注意

前一个组件查询将寻找一个id值为addCardSheet的组件,然后在该组件中找到配置为"Save"的按钮。

我们现在可以使用this.getReferenceName引用我们的refs列表中的任何内容。

refs部分之后,我们需要添加一个control部分。此部分使用我们的引用,并为一些组件定义了一组监听器和函数:

control: {
    addCardSaveButton: {
  tap: "addCard"
    },
    addDeckSaveButton: {
  tap: "addDeck"
    },
    deckList: {
  select: "onDeckSelected"
    },
    addCardSheet: {
  show: "updateCardSheetDeckInfo"
    },
    shuffle: {
  tap: 'shuffleDeck'
    }
}

我们control部分的每个成员都有一个引用,一个要监听的事件,以及当事件发生时要触发的函数。我们将在控制器中稍后创建这些函数,但首先我们需要添加我们的init函数来设置 Sencha.io 和其他认证函数。

当我们的应用程序首次启动时,它需要监听 Sencha.io 控制器进行认证和消息处理。我们在init函数中处理这个问题(紧接在config部分之后):

init: function() {
  this.getApplication().sio.on({
    authorized: this.onAuth,
    logout: this.onLogout,
    usermessage: this.onUserMessage,
    scope: this
  });
}

这段代码告诉我们的控制器来自 Sencha.io 控制器的三个事件:

  • authorized:用户已成功登录

  • logout:用户已从系统中登出

  • usermessage:用户已收到消息

我们为每个这些事件分配了一个函数,现在我们需要在init函数下方添加它们。

第一件事是我们的onAuth函数,它在用户登录后同步所有用户的存储:

onAuth: function(user) {
 Ext.getStore('DeckStore').sync();
 Ext.getStore('CardStore').sync();
 return true;
}

我们的onLogout函数执行相反的操作,并清除任何本地存储的数据:

onLogout: function() {
  var deckStore = Ext.getStore('DeckStore');
  deckStore.getProxy().clear();
  deckStore.load();
  var cardStore = Ext.getStore('CardStore');
  cardStore.getProxy().clear();
  cardStore.load();
  return true;
}

我们的消息功能有点更有趣。

Sencha.io 允许应用程序向用户发送消息。这些可以是系统消息(例如,存储中的数据已更新)或甚至用户之间的直接消息。

我们已经设置了一个函数,当收到消息时简单地同步我们的存储(我们将在稍后设置函数以发送消息)。这意味着如果用户在一个设备上打开了程序并更改了数据,任何其他登录了该用户的设备都将更新并接收更改:

    onUserMessage: function(sender, message) {
        var userId = sender.getUserId();
        console.log("user got a message!", arguments, userId);
        Ext.getStore('DeckStore').sync(function() {
            console.log("DeckStore sync callback", arguments);
        });
        Ext.getStore('CardStore').sync(function() {
            console.log("CardStore sync callback", arguments);
        });
        return true;
    }

我们还添加了一些控制台日志,你可以使用它们来查看发送的消息和可能可用于应用程序的数据。

注意

在测试应用程序时,请确保打开控制台并检查可用的不同消息元素。我们将在最后一章深入探讨消息传递,但你也可以查看docs.sencha.io上提供的概述指南以获取有关消息传递的更多信息。

接下来,我们需要添加将保存我们新卡片和牌组的函数。这两个函数都与它们各自的保存按钮相关联。它们需要从表格中获取数据,将其添加到存储中,然后同步存储:

addCard: function() {
 var cards = Ext.getStore('CardStore'),
 sheet = this.getAddCardSheet();
 cards.add({
  deckID: this.getSelectedDeck().get('id'),
  question: sheet.down('#cardQuestion').getValue(),
  answer: sheet.down('#cardAnswer').getValue()
 });
 cards.sync(Ext.bind(this.syncCallback, this));
 sheet.down('#cardQuestion').setValue("");
 sheet.down('#cardAnswer').setValue("");
 sheet.hide();
}

我们在这里使用我们的引用来通过this.getAddCardSheet()获取addCardSheet值,然后将数据作为一条新记录添加到我们的卡片存储中。

然后,我们同步存储并将一个名为syncCallback的函数绑定。这是将发送消息告知应用程序数据已更新的函数。syncCallback函数可以直接放在addCards函数下面:

syncCallback: function() {
    console.log("broadcast update", arguments);
    this.getApplication().sio.getUser(function(user, error) {
        if (user) {
            console.log("user", user);
            user.send({
                message: "updated"
            },
            function() {
                console.log("send callback");
            }
            );

        }
    });
}

控制台日志被保留以提供函数内部传递数据的内部视角。代码的第一部分this.getApplication().sio.getUser获取当前认证的用户并运行一个函数。该函数检查是否收到了用户,如果是,我们向用户发送一条简单的消息,内容为updated

这条消息由我们的onUserMessage函数处理,这会导致我们的存储更新其数据。你可以更改这条消息并使用控制台日志来查看两个函数之间数据是如何传递的。

我们的addDeck函数是addCard函数的虚拟副本:

addDeck: function() {
 var decks = Ext.getStore('DeckStore'),
 sheet = this.getAddDeckSheet();
 decks.add({
  name: sheet.down('textfield').getValue()
 });
 decks.sync(Ext.bind(this.syncCallback, this));
 sheet.down('textfield').setValue("");
 sheet.hide();
}

在同步存储并执行syncCallback函数之前,我们只需要获取一个textfield值。与之前一样,在隐藏表格之前,我们也会清除字段值。

接下来,我们需要暂时离开控制器,看看我们的卡片是如何显示的。

对于一个闪卡应用来说,Carousel组件似乎是一个理想的选择,因为它允许用户快速翻转到下一张卡片。这将使我们能够展示一个问题,并让用户滑动以获取答案。用户可以再次滑动以获取下一个问题,依此类推。

这个问题在于Carousel实际上是一个面板集合,而我们真正需要的是能够像列表视图那样从我们的数据存储中提取记录的东西。为了做到这一点,我们需要覆盖Carousel组件并添加一些额外的行为。

我们将从扩展Carousel的基本组件开始:

Ext.define('MyApp.view.CardView', {
 extend: 'Ext.carousel.Carousel',
 alias: 'widget.flashcards',
 config: {
  store: null,
  indicator: false
 }
});

我们最初将store配置设置为默认的null。我们将在Main.js文件中声明组件时设置它。在我们的Main.js文件中,找到以下部分:

xtype: 'carousel'

将该行替换为以下内容:

xtype: 'flashcards',
store: 'CardStore'

这将容器设置为我们的新flashcards轮播图,并将store配置设置为CardStore

如果我们在CardView.js文件中硬编码了组件的存储,那么它将更难重用。当你覆盖一个组件以扩展功能时,始终是一个好主意,编写时考虑到将来在其他地方重用的可能性。

回到CardView.js,我们需要添加一对字符串,用作问题和答案卡片的 xTemplates。这些放在我们的组件的config部分:

questionTpl: '<div class="question qa"><span class="count">{number} of {total}</span><span class="question">{question}</span></div>',
answerTpl: '<div class="question qa"><span class="count">{number} of {total}</span><span class="question">{question}</span></div><div class="answer qa"><span class="answer">{answer}</span></div>'

这些将控制如何显示问题和答案卡片。由于我们将多次使用这些相同的模板,所以在constructor函数中编译它们是个好主意。否则,xTemplate 将在每次创建新的闪存卡时被编译和重新编译:

constructor: function(config) {
   this.callParent(arguments);
   this.getQuestionTpl().compile();
   this.getAnswerTpl().compile();
   this.setStore(Ext.getStore(this.getStore()));
   this.getStore().on({
       load: this.createCards,
       refresh: this.createCards,
       addrecords: this.createCards,
       scope: this
   });

}

我们的constructor函数还设置了在Main.js中传递的存储。这个函数有点复杂,需要一些解释。让我们从内部开始,逐步向外解释:

  • this.getStore(): 这获取我们在Main.js中传递的字符串值(store: 'CardStore'

  • Ext.getStore(): 这通过storeId值为'CardStore'获取存储

  • this.setStore(): 这将存储应用于我们的CardView组件,替换原始字符串值

在下一行调用this.getStore()来设置listeners时,现在它返回一个实际的存储,而不是之前的字符串值。

然后我们为存储的loadrefreshaddrecords事件分配一个单一的功能。我们需要在下一个步骤中添加该函数。

我们将这个函数分为两部分。第一部分是createCards函数,它获取我们的存储并从我们的自定义轮播图中删除任何现有的面板。然后它检查存储中是否有任何卡片:

createCards: function() {
   var store = this.getStore();
   this.removeAll(); // removes all the old panels
   if (store.getCount() > 0) {
     store.each(this.createFlashCard, this); 
   } else {
       this.add({xtype: 'panel', html: 'No Cards Available for this Deck.<br />Please click Add to add a card to this deck.'});
   }
   this.setActiveItem(0);
}

如果没有,我们给用户显示一条消息,说明牌组中没有卡片,并且他们可以点击添加来创建新的卡片。

这就是我们的函数的第二部分发挥作用的地方。如果我们有记录,我们将每个记录传递给一个名为createFlashCard的第二个函数:

createFlashCard: function(record, index, total) {
  var data = Ext.apply({ total: total, number: (index + 1) }, record.data);
  this.add({ xtype: 'panel', html: this.getQuestionTpl().apply(data), scrollable: 'vertical' });
  this.add({ xtype: 'panel', html: this.getAnswerTpl().apply(data), scrollable: 'vertical' });
}

这个函数在CardStore中的每一条记录上运行。第一行创建我们的初始数据数组,并设置total(牌组中的卡片数量)、number(当前卡片的顺序号)和从存储中获取的data记录(其中包含我们的问题和答案)的值。

然后我们创建一个新的panel组件,并将html设置为应用了数据的编译后问题模板。

我们对答案模板也做同样的事情,因此对于存储中的每条记录,我们最终会得到两个新的面板;一个问题面板,后面跟着一个答案面板。

由于CardStore包含每个牌组的所有记录,我们在CardsView轮播加载之前需要按牌组过滤这些记录。我们将在控制器中处理这个问题。

回到控制器

一旦用户从我们的列表中选择了一个牌组,我们需要过滤CardStore,以便只有该牌组的卡片可用。我们在onDeckSelected函数中这样做:

onDeckSelected: function(list, record) {
 var cards = Ext.getStore('CardStore');
 this.setSelectedDeck(record);
 cards.clearFilter();
 cards.sort('id', 'ASC');
 cards.filter('deckID', record.get('id'));
 this.getMainView().down('#cardsPanel').enable();
 this.getMainView().setActiveItem(1);
}

此函数由我们的deckList组件中的选择事件触发,并将列表和所选记录传递给我们。一旦我们获取到CardStore,我们将selectedDeck函数设置为在列表中的牌组被选中时传递给我们的记录。

接下来,我们清除CardStore上的任何现有过滤器,并按其id值对其进行排序。然后我们过滤卡片,只显示当前牌组的卡片。最后,我们启用cardsPanel并将其设置为活动项。

我们还有一个control函数,当cardPanel显示时触发。此函数将卡片面板的标题栏设置为牌组的名称:

updateCardSheetDeckInfo: function(sheet) {
sheet.down('#deckName').setHtml(this.getSelectedDeck().get('name'));
}

由于我们最初加载的卡片存储按id顺序显示它们,可能让用户洗牌是个好主意。我们通过最终的控制器函数来实现这一点:

shuffleDeck: function() {
 Ext.getStore('CardStore').sort({
  sorterFn: function() {
   return (Math.round(Math.random())-0.5);
  }
 });
}

此函数获取CardStore并使用 JavaScript 的Math.random函数对其进行排序,为每张卡片分配一个随机的排序顺序。

注意

要更全面地了解 JavaScript(以及 Sencha Touch)中的排序工作原理,请参阅 Mozilla 开发者网络提供的出色的 JavaScript 排序参考,网址为developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Array/sort

现在,你应该能够向应用程序添加牌组和卡片。当你选择一个牌组时,卡片堆将出现。你可以通过从右向左滑动来从一张卡片切换到下一张,如下所示:

回到控制器

你可以使用 CSS 文件来设置答案和问题的样式,以适应你自己的个人品味。

记住,在开始创建牌组和卡片之前,你需要注册应用程序。一旦你注册了,你就可以从任何 Sencha Touch 兼容的浏览器登录并跨多个设备访问相同的信息。幸运的是,由于我们使用 Sencha.io,所有这些都会自动处理。

回到控制器

Sencha.io 自动为我们应用程序创建这些登录和注册表单。如果用户点击注册按钮,他们将被提供注册和使用您应用程序的机会。Sencha.io 处理所有表单、数据存储和交互,包括密码恢复,无需任何额外的代码。

应用程序的部署

Sencha.io 还提供了将您的应用程序部署到 Sencha.io 云服务的选项。

从 Sencha.io 仪表板中,您可以在页面右侧的列表中点击您的应用程序。当应用程序的主页出现时,点击新版本,您将能够上传一个包含所有代码的压缩文件到 Sencha.io 网站。

上传完成后,您将看到一个可以用来访问应用程序的公共 URL。您还可以指定发布是针对开发还是生产。

您也可以选择将应用程序部署到您自己的 Web 服务器。

作业

可以添加一些内容到应用程序中使其更加完整:

  • 添加编辑/删除卡片和牌组的功能

  • 更新牌组列表模板以显示牌组中的卡片数量

  • 为问题和答案提供更详细的布局和 CSS 样式

  • 将 Sencha 仪表板中的登录方法切换为允许使用 Facebook 登录

  • 使用消息系统在用户有新卡片或牌组可用时向用户显示警报

Sencha.io 提供的机会是巨大的。凭借其集成的消息系统,您有可能与单个用户或应用程序的每个用户进行通信。这为用户之间发布和共享牌组等可能性打开了大门。

更多信息,请查看docs.sencha.io/0.3.3/index.html#!/guide/overview_introduction提供的文档。

摘要

在本章中,我们使用了一个简单的闪卡应用程序来探索 Sencha.io 的一些用途和可能性。我们涵盖了以下内容:

  • 构建基本应用程序,包括存储、模型和视图

  • 开始使用 Sencha.io,注册、下载、安装和配置基本应用程序以与 Sencha.io 服务通信

  • 为应用程序创建控制器并更新存储以连接到 Sencha.io 服务

  • 覆盖轮播图,使其能够从数据存储中读取

  • 将应用程序部署到 Sencha.io

在下一章中,我们将探讨创建用于 Sencha Touch 的自定义 API。

第六章。目录应用程序和 API

在本章中,我们将创建一个简单的目录应用程序,允许您输入待售项目并将它们分配到类别。从界面和功能角度来看,这将与之前我们创建的应用程序相似。这里的区别在于,我们将探索创建自己的 API 来从应用程序中创建、读取、更新和删除数据。

本章我们将涵盖:

  • 什么是 API?

  • 创建基本应用程序

  • 开始使用 API

  • 使用 PHP 创建 API

  • 连接 API 和应用

什么是 API?

API(应用程序编程接口)是一种将存储在一个地方的数据提供给远程应用程序的简单方式。

记得我们关于本地存储的第一个问题吗?数据存储在设备上,这限制了它在许多方面的有用性。如果我们将数据与设备分开存储(在服务器上),我们可以允许多个设备访问数据,我们可以通过身份验证限制和确保数据的安全,并且我们可以为所有用户备份整个数据集。

当我们将数据与应用程序分开存储时,我们需要一种方法来访问它并对其进行更改。这就是 API 发挥作用的地方。在上一个章节中,我们使用了 Sencha.io API 来存储和检索数据。在本章中,我们将探讨如何创建自己的 API。

API 是一组用服务器端语言(如 PHP、Ruby、ASP 或 Perl)编写的代码文件。JavaScript 代码将这些数据作为 HTTP 请求(GETPOSTPUTDELETE)的一部分发送到这些文件。在 Sencha Touch 的情况下,这些数据通常以 JSON 或 XML 格式发送。然后服务器端语言将解析数据,将其作为 SQL 语句发送到数据库。数据库返回一个结果,该结果由服务器端语言转换回 JSON,并返回到浏览器。

整个过程看起来像这样:

什么是 API?

这一段落中有许多缩写,让我们将其分解成更小的部分。假设您想添加一些新的用户数据:姓名、电子邮件地址和电话号码。在其最基本的形式中,我们可以使用浏览器 URL 发送这些信息,如下所示:

mydomain.com/myapifile.php?action=add&&name=john&&email=john@mydomain.com&&phone=555-1212

这将允许我们的服务器端语言(我们将使用 PHP 作为示例)获取我们发送的数据并正确处理它。这可能看起来像这样:

<?php
 $action = $_GET["action"];
 $name = $_GET["name"];
 $email = $_GET["email"];
 $phone = $_GET["phone"];
?>

注意

请注意,这只是一个示例;您几乎肯定想要过滤和检查任何传入的数据。由于我们不会在这里涵盖所有 PHP,我们将仅以非常通用的方式讨论服务器端代码。这不是实际使用,而是一个关于事物如何工作的通用概念。

现在 PHP 已经有了我们的变量,它可以将其转换为服务器端数据库可以理解的内容:

if($action == "add") {
 $sql = "INSERT INTO users VALUES($name, $email, $phone)";
 $results = $db->query($sql);
}

这会将数据从 PHP 传递到数据库(如 MySQL)。然后数据库返回一个结果,我们可以遍历它以创建一个新的数组,该数组符合我们需要的格式。然后我们可以将结果格式化为 JSON 并将其回显:

foreach($results as $result) {
  $newArray[$result['key']] = $result['value'];
}
json_encode($newArray);

print $result;

如前所述,这是一个非常原始的通用示例,API 可能会非常复杂,而且会很快变得复杂。幸运的是,Sencha Touch 自动处理了一些这项工作。

在 Sencha Touch 中使用远程 API

当使用 Sencha Touch 时,API 的 JavaScript 方面变得更容易管理。我们可以使用存储和模型完成我们需要的几乎所有事情。按照我们之前的例子,我们会创建一个看起来像这样的模型:

Ext.define('MyApp.model.User', {
    extend: 'Ext.data.Model',
    config: {
        idProperty: 'id',
        fields: [
            {name: 'id', type: 'int'},
            {name: 'name', type: 'string'},
            {name: 'email', type: 'string'},
            {name: 'phone', type: 'string'}
        ]
    }
});

这设置了我们的简单模型,具有唯一的 idnameemailphone 字段。然而,我们也可以向这个(在我们完成字段声明后)添加 proxy 设置和 api 定义:

proxy: {
  type: 'scripttag',
  url: 'api/Users.php',
  reader: {
    type: 'json',
    root: 'children'
  },
  api: {
    create: 'api/Users.php?action=create',
    read: 'api/ Users.php?action=read',
    update: 'api/ Users.php?action=update',
    destroy: 'api/ Users.php?action=destroy'
  }
}

在这个例子中,我们使用一个 scripttag 代理。在章节的后面,我们将使用一个不同的代理,称为 rest 代理,用于实际的应用程序。你应该熟悉 Sencha Touch 提供的各种代理类型,并使用最适合你应用程序和需求的一个。

scripttag 代理允许我们与另一个域进行通信(如果 PHP 和 Sencha Touch 代码在同一个域上运行,则应使用 ajax 代理)。

注意

如我们在 第二章 中提到的,阅读器,这是由于同源策略,它防止 JavaScript 中的跨域攻击。如果你想了解更多关于这个同源策略的信息,这个维基百科文章是一个很好的起点:

en.wikipedia.org/wiki/Same_origin_policy

url 属性告诉模型将信息发送到创建、读取、更新和删除用户的位置(通常称为 CRUD 函数)。

reader 属性告诉模型在接收到一个 JSON 编码的用户列表时在哪里查找数据。

最后,api 部分告诉模型为我们的每个 CRUD 函数使用哪个 URL。这种 API 设置允许我们完成诸如:

var user = Ext.create('User', {
    name : 'Stacy McClendon',
    email  : 'stacy@superhappyfuntimego.com',
    phone: '555-555-5555'
});

user.save();

我们使用所有信息创建一个新的用户,然后调用 save。通过调用 save,信息被提交到我们在模型中之前设置的 create URL。

注意我们没有发送一个 ID。当我们创建一个新用户时,数据库实际上会设置唯一的 ID。一个正确编写的 API 应该在交易成功时返回这个值以及用户信息的其余部分。返回给我们的 JSON 应该看起来像这样:

{
"totalCount":1,
"children":[
  {
      id:1,
      name : 'Stacy McClendon',
      email  : 'stacy@superhappyfuntimego.com',
      phone: '555-555-5555'
  }
 ]
}

我们可以使用这些信息进行任何所需的后期处理。

我们也可以通过调用以下方式保存现有用户的更改,或删除用户:

user.destroy();

我们在设置用户存储时也使用相同的代理:

Ext.define('MyApp.store.UserStore', {
  extend: 'Ext.data.Store',
  model: ' MyApp.model.User',
  requires: [' MyApp.model.User'],
  storeID: 'UserStore',
  emptyText: 'No Users To List',
  proxy: {
    type: 'ajax',
    url: 'api/users.php',
    reader: {
      type: 'json',
      root: 'children'
    },
    api: {
      create: 'api/users.php?action=create',
      read: 'api/users.php?action=read',
      update: 'api/users.php?action=update',
      destroy: 'api/users.php?action=destroy'
    }
  }
});

这将使我们能够一次性创建、读取、更新和删除多个用户。

当我们加载或同步商店时,将会调用 api 部分,如下所示:

  • 在商店上调用 load() 将会联系 users.php,它应该返回用户列表

  • 在商店中添加新的用户记录并调用 sync() 将会联系 users.php 并保存新用户

  • 在商店中更新现有用户记录并调用 sync() 将会联系 users.php,并更新每个用户记录

  • 从商店中删除用户并调用 sync() 将会联系 users.php,并在数据库中销毁指定的用户记录

    小贴士

    在模型和商店上定义 proxy 设置并不是严格必要的。如果你的模型有定义 proxy 设置而你的商店没有,那么商店将自动使用模型的 proxy

如果你使用的是你没有编写的 API,你需要确保你发送的数据和请求是以正确的格式进行的。查看你所使用的 API 文档,以了解是否有任何额外的要求。

如你所注意到的,我们在 API 端的事情上有点含糊。这是因为每个 API 在期望的数据和返回的数据方面都有所不同。

大多数公开可访问的 API 都有相当好的文档,但了解制作 API 的基本概念也是有帮助的。

创建自己的 API

在最基本的情况下,API 执行三个核心功能:

  • 从远程源接收数据

  • 连接到并修改数据库(或其他数据源)

  • 向远程应用程序发送数据

当你创建自己的 API 时,你需要考虑这些函数中的每一个。你可以使用任何你想要的服务器端语言来创建你的 API,只要它能处理这三个基本的事情。让我们从高层次的角度来看每个这些函数。

接收数据

当你从应用程序接收数据时,你需要弄清楚用户在询问什么,以及他们期望如何返回答案。

当你使用标准的 Sencha Touch API 对模型和商店进行请求时,会触发 createreadupdatedestroy 函数,导致请求被发送到你在代理配置中指定的 URL,以及交易相关的任何数据。你也可以使用 params 配置发送额外的数据,如下所示:

store.load({
 params:{paramName: paramValue}
});

你的 API 需要设置好以接收和转换数据和任何额外的参数,以便它可以决定采取什么行动以及返回什么数据。

你首先需要做的是确定变量是否作为 GETPOST 请求的一部分发送。你选择的服务器端语言应该有处理这两种传输类型的方法。

如前所述,PHP 可以使用$_GET["variableName"]$_POST["variableName"]来收集这些数据。你选择的语言将有类似的功能。这些数据也将被编码为 JSON,因此在你能够获取到单个数据项之前,你需要对其进行解码。

大多数服务器端语言都有处理此问题的函数。例如,PHP 使用json_decode($myJSONData)将 JSON 数据转换为 PHP 数组。

数据收集完毕后,应该检查以确保它是我们预期的。例如,如果我们的 API 接收到的action值为read,而id值为DELETE * FROM users,我们可能不希望将此发送到我们的数据库。大多数语言也有验证数据类型和清理任何潜在有害值的方法。

此外,存储可以经常将多个请求组合成数据数组。你的代码需要检查传递的数据是数组还是单个项目,并相应地处理。

与数据库通信

一旦你有了变量,并且理解了用户需要完成什么,你可能会需要连接到你的数据库以获取或修改数据。例如,如果你得到一个action值为read,而id值为45,你可能会想要查询数据库以获取任何id值为45的用户。

执行此操作需要了解你使用的服务器端语言如何连接到数据库,以及你的数据库如何接受信息查询。你需要查阅你语言和数据库的参考指南,以确定如何完成此操作。

将数据发送回应用程序

一旦你与数据库进行了通信,并且准备将信息发送回应用程序,你需要正确地编码它,以便 Sencha Touch 能够处理它。

需要注意的是,Sencha Touch 期望接收编码为 JSON 的数据。大多数服务器端语言都有处理这种数据的函数。例如,PHP 使用json_encode($myArrayOfData)将信息捆绑成 JSON 格式。

一旦你有了 JSON 编码的数据,你可以直接使用类似 PHP 的printecho函数将其输出到浏览器。

关于 API 的更多信息

现在你已经对 API 的工作方式有了些了解,你可以收集你想要使用的服务器端语言的特定数据。获取更具体的信息,请参阅:

www.webresourcesdepot.com/how-to-create-an-api-10-tutorials/

构建基本应用程序

对于这一章,我们不会详细介绍整个应用程序的构建过程。相反,我们将主要关注模型、存储和基本 API。应用程序本身由一个带有两个标签的标签布局组成。我们两个标签中的每一个都是一个带有卡片布局的面板,一个用于项目,一个用于类别。反过来,这两个面板都有自己的子面板,用于列出、显示详细信息和管理(分别是一个列表、一个面板和一个表单面板)。

构建基本应用程序

您可以查看应用程序代码以了解这些组件是如何布局的。主选项卡面板和用于项目和类别的两个卡片布局面板都是Main.js文件的一部分。列表、详细信息和管理面板都是app/views文件夹内的独立视图。我们稍后会回到视图,但对我们这一章真正感兴趣的是存储和模型。

创建项目模型

模型是大多数有趣事情发生的地方。在模型中,我们将使用两个新的选项:一个rest代理和模型关系。

Ext.define('CatHerder.model.Item', {
    extend: 'Ext.data.Model',
    uses: [
        'CatHerder.model.Category'
    ],
    config: {
        idProperty: 'itemID',
        fields: [
            {
                name: 'itemID',
                type: 'int'
            },
            {
                name: 'name'
            },
            {
                name: 'description'
            },
            {
                name: 'price',
                type: 'float'
            },
            {
                name: 'photoURL'
            },
            {
                name: 'categoryID',
                type: 'int'
            }
        ],
        hasOne: {
         model: 'CatHerder.model.Category',
         name: 'category',
         primaryKey: 'categoryID'
           },
            proxy: {
             type: 'rest',
        url: '/api/item'
       }
    }
});

这与我们的先前模型以相同的方式开始,但我们有一个新的配置选项称为uses。此选项设置为我们的另一个模型CatHerder.model.Category,并告诉我们我们将有一个Item模型和Category模型之间的关系。

在我们的fields下方,我们还有一个新的选项,称为hasOne。此选项告诉我们每个Item都与一个Category相关联。

注意

您还可以使用hasMany选项。这将允许您为项目设置多个类别。为了简单起见,我们将在这个例子中使用hasOne。要查看hasMany的一些示例,请参阅docs.sencha.com/touch/2-0/#!/api/Ext.data.association.HasMany

hasOne选项包括:

  • 我们要关联的模型:CatHerder.model.Category

  • 我们将引用以获取相关信息的name属性:category

  • primaryKey属性表示是Item模型的一部分的字段,我们将使用它来匹配为相关模型categoryID设置的id属性。

此配置将使我们能够以使用Item模型的其他属性相同的方式使用Category模型中的字段。

我们还有一个新的代理类型,称为rest代理。与之前我们讨论的scripttag代理不同,rest代理使用不同类型的 HTTP 请求来表示我们是在创建、读取、更新还是删除数据。

不同的请求类型包括:

  • 创建新记录将以POST请求发送

  • 读取数据将以GET请求发送

  • 更新现有记录将以PUT请求发送

  • 删除记录将以DELETE请求发送

每种请求类型都将发送到 api/item 进行处理。我们在 URL 上做了一些技巧,使它们对用户看起来更干净。

RewriteRule 和 .htaccess

通常我们会在早期 scripttag 代理示例中指向特定的文件,就像这样:

proxy: {
  type: 'rest',
  url: '/api/item.php'
}

当我们想要查看特定项目时,URL 看起来像这样:

myapp.com/api/item.php?item=143

然而,通过一点配置,我们可以将其更改,使 URL 看起来像这样:

myapp.com/api/item/143

这更简洁,也更易于阅读。只需一个 RewriteRule 指令和一个 .htaccess 文件即可。

.htaccess 文件由 Apache 网络服务器用于确定您所使用的服务器的一组选项和配置。这些选项可以通过在应用程序的 api 目录中创建或编辑 .htaccess 文件来设置。在这种情况下,我们将创建一个包含以下信息的文件:

RewriteEngine on
# Send item requests to item.php
RewriteRule ^item(/.*)?$ /api/item.php [L]

# Send category requests to category.php
RewriteRule ^category(/.*)?$ /api/category.php [L]

第一行启用了重写 URL 的功能。这意味着虽然用户的地址栏显示的是一件事,但我们将请求重定向到不同的文件。正如前面的注释所述,下一个块将任何对 item 的请求发送到 item.php,下一个块将请求发送到 category.php[L] 指令表示如果规则与当前请求的 URL 匹配,则不再检查更多规则,因为这将是最后一个匹配的规则。这是一个非常简单的更改,使应用程序看起来更加专业和友好。

注意

如果您想了解更多关于 .htaccess 和 Apache 的信息,一个好的起点是 www.addedbytes.com/for-beginners/url-rewriting-for-beginners/

保存文件并关闭它。现在,我们所有的不同 HTTP 请求都将发送到 api/itemapi/category

为了理解我们的 API 如何区分不同的 HTTP 请求类型以便理解如何处理数据,我们将在下一部分进行说明,但首先让我们快速看一下存储。

项目存储

对于这个项目,项目存储非常简单:

Ext.define('CatHerder.store.itemStore', {
    extend: 'Ext.data.Store',
    requires: [
        'CatHerder.model.Item'
    ],

    config: {
        model: 'CatHerder.model.Item',
        storeId: 'itemStore',
        autoLoad: true
    }
});

由于我们没有声明代理,存储将自动使用我们在模型上设置的存储。除此之外,我们只需要求我们的模型给存储提供一个 storeId 值,并将 autoLoad 设置为 true

创建分类模型和存储

我们的分类模型和存储只是我们项目模型和存储的变体。该模型使用 hasMany 而不是 hasOne

Ext.define('CatHerder.model.Category', {
    extend:'Ext.data.Model',
    config:{
        idProperty:'categoryID',
        fields:[
            {
                name:'categoryID',
                type:'int'
            },
            {
                name:'name'
            }
        ],
        hasMany:{
            model:'CatHerder.model.Item',
            name:'items',
            autoLoad:false
        },
        proxy:{
            type:'rest',
            url:'/api/category'
        }
    }
});

我们使用 hasMany 是因为我们将在单个分类中拥有多个项目。除此之外,模型的基本结构与我们的项目模型相同,使用 rest 代理并联系我们的 PHP API 在 /api/category

我们的 categoryStore 也是 itemStore 的虚拟副本:

Ext.define('CatHerder.store.categoryStore', {
    extend: 'Ext.data.Store',
    requires: [
        'CatHerder.model.Category'
    ],
    config: {
        model: 'CatHerder.model.Category',
        storeId: 'categoryStore',
        autoLoad: true
    }
});

如前所述,我们只是扩展了基本存储组件,声明我们正在使用的模型('CatHerder.model.Category'),并给存储一个我们可以稍后引用的 ID。

一旦我们有了这两部分,就到了进行一些测试的时候了。

测试存储和模型

当为您的应用程序创建 API 时,通常从只是一个平面文本文件开始是个好主意。这将让您测试存储的读取能力,并帮助您更好地理解数据将如何格式化以适应您的 API。

对于这个测试文件,我们将创建几个非常基础的 PHP 文件。这些文件将简单地创建一个静态数据数组,将其编码为 JSON,并将其回显到应用程序中。

注意

您可以使用任何您喜欢的服务器端语言;Sencha Touch 并不关心,只要它能接受和返回 JSON 即可。如果您想使用 PHP 作为您的服务器代码,但需要一个起点,请尝试phpmaster.com/。PHP Master 为所有级别的 PHP 程序员提供了大量的教程。

我们将从item.php文件开始:

<?PHP

$test = array(
  array(
    'itemID' => 1,
    'name' => 'Test Item 1',
    'description' => 'Lorem Ipsum',
    'price' => 1.00,
    'photoURL' => 'http://placekitten.com/200/300',
    'categoryID' => 1,
    'category' => array(
      'categoryID' => 1, 'name' => 'Category 1', 'itemID' => 1
    )
  ),
  array(
    'itemID' => 2,
    'name' => 'Test Item 2',
    'description' => 'Lorem Ipsum',
    'price' => 2.00,
    'photoURL' => 'http://placekitten.com/400/300',
    'categoryID' => 2,
    'category' => array(
      'categoryID' => 2, 'name' => 'Category 2', 'itemID' => 2
    )
  ),
  array(
    'itemID' => 3,
    'name' => 'Test Item 3',
    'description' => 'Lorem Ipsum',
    'price' => 3.50,
    'photoURL' => 'http://placekitten.com/200/200',
    'categoryID' => 1,
    'category' => array(
      'categoryID' => 1, 'name' => 'Category 1', 'itemID' => 3
    )
  )
);

echo json_encode($test);
?>

此文件创建了一个名为$test的数组。$test数组在其内部嵌套了三个数组,每个数组对应我们的一个项目。这些数组包含各种字段,如itemIDnamedescriptionpricephotoURLcategoryID。它们还包含作为附加嵌套数组一部分的类别数据。我们将类别数据包含在项目数据中,以便我们的存储可以通过hasOne关系访问它,而无需对每个项目进行额外的 AJAX 调用以从服务器加载类别数据。

当您为应用程序编写最终的 PHP 代码时,它将查询数据库以获取关于项目和类别的信息。然后 PHP 需要以这种格式格式化它。

注意

PHP 以类似于 Sencha Touch 的方式格式化数组,其中 Sencha Touch 使用key: value,而 PHP 使用'key' => value

PHP 文件的最后一行将我们的数组编码为 JSON。然后我们使用echo将数据发送回我们的应用程序。

我们在category.php中对类别数组做完全相同的事情:

<?PHP

$test = array(
  array(
    'categoryID' => 1,
    'name' => 'Category 1',
    'items' => array(
      array(
        'itemID' => 1,
        'name' => 'Test Item 1',
        'description' => 'Lorem Ipsum',
        'price' => 1.00,
        'photoURL' => 'http://placekitten.com/200/300',
        'categoryID' => 1
      ),
      array(
        'itemID' => 3,
        'name' => 'Test Item 3',
        'description' => 'Lorem Ipsum',
        'price' => 3.50,
        'photoURL' => 'http://placekitten.com/200/200',
        'categoryID' => 1
      )
    )
  ),
  array(
    'categoryID' => 2,
    'name' => 'Category 2',
    'items' => array(
      array(
        'itemID' => 2,
        'name' => 'Test Item 2',
        'description' => 'Lorem Ipsum',
        'price' => 2.00,
        'photoURL' => 'http://placekitten.com/400/300',
        'categoryID' => 2	
      )
    )
  ) 
);

echo json_encode($test);
?>

注意,在这种情况下,我们只有两个类别,但我们还包含了与每个类别关联的项目。这使得我们可以在稍后对数据进行更多操作(例如显示类别中的项目数量)。

虽然这些可能看起来像大而杂乱的数组,但最终的 PHP 代码将为您做大部分工作。这只是为了测试并确保您已经正确地格式化了初始数据。

测试存储和模型

当您加载应用程序时,现在您可以看到包含平面数据的两个列表。让我们快速看一下我们用于这些数据的 XTemplates。

创建 XTemplates

现在我们已经看到了数据是如何发送到应用程序的,我们需要了解我们如何在 XTemplates 中使用它。在我们的列表视图中,itemTpl看起来像这样:

itemTpl: [
  '{category.name}: {name}',
  '<p class="delete hidden" style="position: absolute; right: 10px; top: 12px;">',
  '<img src="img/delete.png" alt="delete" />',
  '</p>'
]

我们的类别和项目都包含名称字段值。由于项目是我们这里的主要数据,我们可以将项目的名称值称为 {name}。由于类别与项目相关(并且我们的 PHP 将其作为每个项目的嵌套数组返回),我们将类别名称值称为 category.name

我们使用的类别列表 XTemplate 采用类似的格式来提供类别名称,但它使用了一种稍微不同的方法来获取每个类别中的项目数量:

itemTpl:[
    '<div>{name} -- {[values.items.length]} item(s)</div>'
]

由于类别是我们这里的主要数据,名称指的是类别名称。我们可以通过执行 item.propertyName 来获取任何项目的属性,但我们真正想知道的是类别中的项目数量。

记住,在我们的 PHP 平面文件中,我们将单个项目作为嵌套数组的一部分包含在我们的类别中。这个嵌套数组被称为 items。我们可以通过使用 items[0].name 来打印第一个项目的名称,或者通过使用 items[1].name 来打印第二个项目的名称,以此类推,为每个不同的项目和属性。我们还可以使用 JavaScript 的 length 属性来找出 items 数组中有多少个单独的项目。

为了做到这一点,我们需要在我们的模板中使用内联 JavaScript 代码。这就是为什么我们同时使用花括号和方括号的原因:{[function goes here]}。当你在模板中使用内联代码时,你必须通过 values 数组而不是通过名称来访问模板变量。

在这种情况下,values.items.length 返回 items 数组中的元素数量。

现在我们已经可以看到 PHP 如何格式化值并在 XTemplate 中显示,让我们看看我们如何在数据库中工作以存储和检索这些数据。

API 和数据库

首先,我们需要用一些新的代码替换我们的平面 PHP 文件。这段代码将包含三个基本任务:

  • 决定传入的是哪种类型的请求

  • 联系数据库并发出适当的请求

  • 格式化要返回给 Sencha Touch 应用程序的数据

    注意

    请注意,从现在开始,我们将查看 PHP 代码。虽然其中一些看起来与 JavaScript 类似,但此代码完全独立于 JavaScript 和 Sencha Touch。

这三个基本函数无论你的 API 用什么语言编写都成立,尽管实现方式会有所不同。在我们的 PHP 中,第一个任务可能看起来像这样:

include_once 'dbSetup.inc';

switch ($_SERVER['REQUEST_METHOD']) {
case "GET":
    doGet();
    break;
case "POST":
    doPost();
    break;
case "PUT":
    doPut();
    break;
case "DELETE":
    doDelete();
    break;
default:
    doGet();
}

在这里,我们包含我们的数据库设置文件,它处理数据库和我们的代码之间的基本连接。接下来,我们使用特殊变量 $_SERVER['REQUEST_METHOD'],它告诉我们请求是通过 GETPOSTPUT 还是 DELETE 发送的。

注意

GETPOSTPUTDELETE 被称为 HTTP 方法动词,用于表示用户与 Web 服务器之间的交互。要了解更多关于 HTTP 动词的信息,请查看 RFC www.w3.org/Protocols/rfc2616/rfc2616-sec9.html

我们使用switch语句(就像 JavaScript 中的那样)将不同的请求发送到不同的函数。每个函数将联系数据库并执行请求。

我们不会深入到执行此操作的 PHP 代码的细节,但我们将查看不同请求的基本操作。

GET 请求

GET请求用于从数据库中读取,并在 API 中有两种基本用途:获取单个项目或获取项目列表。首先我们需要确定请求是针对单个项目还是所有项目。

在 REST API 中,对单个项目的请求看起来是这样的:

mydomain.com/api/items/123

在这种情况下,我们想要返回一个 ID 为 123 的单个项目。然而,如果我们得到一个看起来像这样的请求:

mydomain.com/api/items/

我们只是将数据库中的所有项目发送回去。

因此,我们的doGet()函数首先需要检查 URL,看看我们的请求 URL 的末尾是否有额外的数字:

function doGet() {
    $db = dbSetup();

    if (preg_match('/item\/(\d+)[\/]*$/', $_SERVER['REQUEST_URI'], $matches)) {
        /* We've got a single item to grab. */
        $itemID = array($matches[1]); // execute() expects an array.
        $stmt = $db->prepare("select * from `items` where itemID = ?");
        if (is_object($stmt) && $stmt->execute($itemID)) {
            /* We only asked for one. */
            $row = $stmt->fetch();
            $row['category'] = getCategory($row['categoryID'], $db);
            doJson($row);
        } else {
            doJson(array(), false, $stmt->errorInfo());
        }
    }

第一行设置我们的数据库,以便我们可以进行请求。

注意

我们已经将数据库的设置文件包含在本章的示例代码中。对于这个应用程序,我们假设使用 MySQL 数据库,它是免费可用的。有一个dbSetup.inc文件用于处理数据库的基本设置和配置,还有一个setup.sql文件,可以导入 MySQL 以设置应用程序的初始表。更多信息请访问dev.mysql.com/doc/.

PHP 代码的下一部分使用正则表达式匹配来检查我们作为请求的一部分接收到的 URL ($_SERVER['REQUEST_URI'])。

注意

正则表达式是匹配字符串、数字和字符的极其强大的工具。您可以使用它来查找不仅仅是单词或字母,还可以查找任何字符串中的模式。更多信息请访问:

www.regular-expressions.info/.

如果我们得到匹配,这意味着我们正在寻找单个项目。然后我们取我们的单个项目并使用它向 MySQL 发送请求以获取相关数据。我们获取 MySQL 返回的row数据。我们使用row数据中的categoryID值,通过另一个名为getCategory()的函数将分类数据附加到行数据上。然后整个数据传递给一个名为doJson()的函数进行最终格式化。

这个doJson()函数将在我们的代码的多个地方使用。它简单地将一个数组、一个成功值和一个可选的消息转换成 Sencha Touch 可以处理的 JSON 编码格式:

function doJson($data, $success = true, $message = '') {
    $output = array('success' => $success, 'data' => $data);
    if ($message != '') {
        $output['message'] = $message;
    }
    echo json_encode($output);
}

在我们的doGet()函数中,我们已经处理了单个项目的请求,现在我们需要处理所有项目的请求。这意味着除了我们原来的if语句(处理我们的单个项目请求)之外,我们还需要一个else语句:

} else {
        $data = array();
        $categories = array();
        $filters = json_decode($_GET['filter'], TRUE);
        $start = intval($_GET['start']);
        $limit = intval($_GET['limit']);
        /* For simplicity, just use one filter */
        $filterColumn = $filters[0]['property'];
        $filterValue = $filters[0]['value'];
        $sql = "select * from `items`";
        if (!is_null($filterValue) && $filterValue != 'null' && $filterValue != "") {
            $sql .= " where `$filterColumn` = '$filterValue'";
        }
        if ($limit > 0) {
            $sql .= " limit $start,$limit";
        }
        foreach ($db->query($sql) as $row) {
            /* Only fetch categories once. */
            if (!isset($categories[$row['categoryID']])) {
                $categories[$row['categoryID']] = getCategory($row['categoryID'], $db);
            }
            $row['category'] = $categories[$row['categoryID']];

            $data[] = $row;
        }

        echo json_encode($data);
        exit;
    }

这个操作的工作方式略有不同,因为我们正在获取多个结果。这意味着我们需要通过循环遍历我们的结果(使用 foreach),并将它们放入一个我们可以使用 json_encode() 处理的单个数组中。我们还需要在循环的每一轮中使用我们的 getCategory() 函数来获取 category 数据并将其添加到我们的每个条目中。

一旦我们有了所有这些,我们就回显编码后的数组并退出。这将发送 JSON 回到我们的应用程序,以便在 XTemplate 中显示。

我们还包含了一些可选的组件,我们可以在以后使用:过滤器以及起始/限制。这将允许我们传递过滤器的值(作为一个数组),以及起始和限制作为单独的值。然后我们可以将这些传递给 MySQL,控制我们获取的结果数量或通过特定的列过滤结果。在 API 中构建这种灵活性总是一个好主意。当您开始向应用程序添加新功能时,这可以真正为您节省时间。

POST 请求

POST 请求是我们需要处理新项目创建的地方。这次变量作为 POST 请求传递给我们,而不是作为 URL 的一部分(如我们之前的 GET 方法)。

小贴士

发送 POST 请求时使用零为新项目

在我们的 item 模型中,我们将 itemID 设置为我们的 id 属性。这是我们的数据记录的唯一 id。有一种旧的编程习惯,当你想要创建一个新的数据记录时,将 id 设置为 0。然而,如果你在你的模型中将 id 设置为 0,Sencha Touch 会将其视为一个有效的 ID,并确定你正在更新一个记录,这将使你的请求作为一个 PUT 请求,而不是 POST 请求。

我们的 doPost() 函数需要获取请求发送的数据并解码 Sencha Touch 发送的 JSON。由于我们还需要在我们的 doPut()doDelete() 函数中这样做,我们将创建一个单独的函数来执行解码:

function getJsonPayload() {
    return json_decode(file_get_contents('php://input'), true);
}

在 PHP 中,我们可以使用 file_get_contents('php://input') 获取请求的原始数据流。然后我们可以使用 json_decode() 将此流从 JSON 解码为一个关联数组 ('key' => 'val')。每当函数被调用时,我们简单地返回这个关联数组。

这个新函数在 doPost() 函数的顶部被调用:

function doPost() {
    $data = getJsonPayload();
    $sql = "insert into `items` (`itemID`, `name`, `description`, `price`, `photoURL`, `categoryID`) values (NULL, :name, :description, :price, :photoURL, :categoryID)";

    $db = dbSetup();
    /* Prepare our data. Here is where you should add filtering, etc. */
    $insert = array();
    foreach ($data as $key => $val) {
        if ($key != "category_id") {
            $insert[':'.$key] = $val;
        }
    }

    $stmt = $db->prepare($sql);
    $stmt->execute($insert);
    $data['itemID'] = $db->lastInsertId();
    $data['category'] = getCategory($data['categoryID'],$db);
    doJson($data);
}

接下来,我们创建将用于将数据放入我们数据库的 SQL 语句。然后我们需要格式化我们的数据,以确保它能够正确地与我们的 MySQL 语句匹配。

我们这样做是通过遍历我们的数据并创建一个修改后的数组,该数组将适用于 execute() 命令,将数据与 sql 语句结合并插入我们的新数据记录。

我们然后使用 $db->lastInsertId() 获取新数据行的唯一 ID,并将其添加到我们的数据数组中,这样我们就可以通过 doJson() 函数将其传递回 Sencha Touch 应用程序。我们就是这样做的。

我们的 PUT 请求遵循一个非常相似的格式。

PUT 请求

当我们在 Sencha Touch 中保存一个具有有效 id 属性的对象时,会执行 PUT 请求,在这种情况下,是一个 itemID 值。当 Sencha Touch 发送此类预存在对象的 save 请求时,它使用 PUT 请求。

我们的 API 函数 doPut() 与我们的 doPost() 函数执行相同的基本操作:

function doPut() {
    $data = getJsonPayload();
    $sql = "update `items` set `itemID` = :itemID, `name` = :name, `description` = :description, `price` = :price, `photoURL` = :photoURL, `categoryID` = :categoryID where `itemID` = :itemID";

    $db = dbSetup();

    /* Prepare our data. Here is where you should add filtering, etc. */
    $insert = array();
    foreach ($data as $key => $val) {
        if ($key != "category_id") {
            $insert[':'.$key] = $val;
        }
    }

    $stmt = $db->prepare($sql);
    $stmt->execute($insert);
   ata['category'] = getCategory($data['categoryID'],$db);
    doJson($data);
}

由于我们已经有唯一的 itemID,我们不需要在更新行后从数据库中获取它。除此之外,基本结构是相同的。我们只是在 MySQL 中输入 update 命令而不是 insert

DELETE 请求

当我们在 Sencha Touch 中的模型记录上调用 erase() 时,会发送一个 DELETE 请求。在这种情况下,我们的 API 只关心唯一的 itemID,因此我们不需要创建一个新的数组来与我们的 execute() 语句一起使用。我们仍然使用 getJsonPayload() 来获取数据,并在执行后,回显 JSON 编码的数据。

function doDelete() {
    $db = dbSetup();
    $data = getJsonPayload();
    $itemID = array($data['itemID']);
    $sql = 'delete from items where itemID = ?';
    $stmt = $db->prepare($sql);
    $stmt->execute($itemID);
    echo json_encode($data);
} 

在 Sencha Touch 的另一侧,您需要重新加载存储以查看从列表中删除的记录。

API 的其余部分

API 的分类部分与刚刚我们讨论的项目部分工作方式完全相同。您可以在本章的示例代码中查看,但逻辑都是一样的。顶部有一个 switch 语句,用于确定请求是如何接收的。然后我们有相同的基本函数集来响应每种类型的请求。

这种简单的可重复结构可以用来生成适用于任何应用程序的基本 API。

如果您需要在您的 API 中使用除基本 CRUD 函数之外的功能,您还可以使用 AJAX 存储,将请求发送到 API 文件,在那里它可以被适当处理。API 需要执行相同的基本功能:

  • 根据发送的变量或请求的类型确定请求的类型

  • 通过数据库处理请求

  • 将必要的请求编码成 JSON 并将其发送回 Sencha Touch 应用程序

  • 在失败的情况下,也应该发送一条消息以帮助调试或向用户提供一些帮助解决问题的信息

摘要

在本章中,我们使用了一个简单的目录应用程序来构建我们自己的 API。此外,我们还涵盖了:

  • 如何设置 Sencha 模型关联

  • 使用 .htaccessmod_rewrite 的技巧来为我们的 API 创建更漂亮的 URL

  • 使用 Ext.data.proxy.Rest 通过基本的 CRUD 交互与 API 进行通信

在下一章中,我们将使用来自几个第三方 API 的数据来增强我们的应用程序。

第七章. 决策者:外部 API

移动技术的一个关键方面是将不同的系统结合起来形成一个有意义的应用程序。越来越多的公司允许通过应用程序编程接口API访问他们的程序和数据。这些 API 包括如下内容:

  • 通过 Google、Yahoo 和其他提供商的地图

  • 如 Rdio 和 Spotify 之类的音乐应用程序

  • 如 Foursquare 之类的位置感知数据提供商

  • 如 Facebook 和 Google Plus 之类的社交网络

  • 如 Flickr 和 Picasa 之类的照片服务

    注意

    你可以在www.programmableweb.com/apis上大致了解可用的内容。

这只是可用数据的小样本,使你的应用程序更有用。关键是获取数据和使用数据的方法。在本章中,我们将使用 Foursquare API 来探索这些类型 API 的使用以及如何开始。我们将讨论:

  • 外部 API 概述

  • 开始使用 Foursquare API

  • 构建基本应用程序

  • 使用 Foursquare 的信息加载数据存储

  • 向用户展示数据

我们将从外部 API 通常是如何工作以及你需要开始使用它的概述开始。

使用外部 API

许多不同的公司提供 API 作为一项服务。这不是公司完全无私的行为。预期是通过提供信息和访问公司数据,公司可以使他们的服务得到更多使用,并吸引更多客户。

考虑到这一点,大多数(如果不是所有)公司都会要求你在他们的系统中拥有一个账户,以便访问他们的 API。这允许你从你的应用程序内部访问他们的系统和信息,但更重要的是,从公司的角度来看,它允许他们保持对其数据如何使用的控制。如果你违反了公司的使用政策,他们可以切断你的应用程序对数据的访问,所以请保持友好。

API 密钥

大多数 API 在使用时都需要一个密钥。API 密钥是一长串文本,它作为任何你发送到 API 的请求的额外参数发送。密钥通常由两个独立的部分组成,它像用户名和密码对普通用户账户那样唯一地识别你的应用程序。因此,最好也将此密钥隐藏在你的应用程序中,这样你的用户就不能轻易地获取它。

虽然每个公司都不同,但 API 密钥通常只是填写一个网页表单并获取密钥的事情。大多数公司不为此服务收费。然而,一些公司确实限制了外部应用程序可用的使用量,因此查看公司对其服务设定的任何限制是个好主意。

一旦你有了 API 密钥,你应该查看 API 提供的可用功能。

API 函数

API 函数通常有两种类型——公开的和受保护的:

  • 公共功能可以通过 API 密钥简单请求

  • 受保护的函数还要求用户登录到系统中才能发出请求

如果 API 功能受保护,您的应用程序还需要知道如何正确地与远程系统登录。登录功能通常会是 API 的一部分或是一个网络标准,例如 Facebook 和 Google 的 OAuth。

注意

应该注意的是,虽然 OAuth 是一个标准,但其实现将根据服务而有所不同。您需要查阅您所使用服务的文档,以确保您需要的特性和功能得到支持。

一定要阅读服务的 API 文档,以了解您需要哪些功能以及它们是否需要登录。

关于 API 的另一件事是要理解,它们并不总是完全按照您需要的去做。您可能会发现,您需要做比预期更多的工作才能获取所需的数据。在这种情况下,进行一些测试总是好的。

许多 API 提供控制台接口,您可以直接在系统中输入命令并检查结果:

API 函数

这对于深入挖掘数据非常有帮助,但控制台并不总是适用于每个 API 服务。另一种选择是将命令发送到您的应用程序中(包括您的 API 凭据)并在 Safari 控制台中检查返回的数据。

这种方法的缺点是数据通常以单行字符串的形式返回,如截图所示,非常难以阅读:

API 函数

这时,一个像 JSONLint 这样的工具就派上用场了。您可以将从 Safari 控制台复制的单行字符串粘贴到jsonlint.com页面中,这样字符串就会被格式化,使其更容易阅读,并且同时验证字符串作为 JSON 的有效性:

API 函数

一旦您掌握了发送和接收的数据,您就需要在 Sencha Touch 中设置所有这些。

外部 API 和 Sencha Touch

正如我们在本书中之前讨论过的,您不能使用标准的 AJAX 请求从另一个域获取数据。您需要使用 JSONP 代理和存储来请求外部 API 的数据。

使用 API 或 Safari 控制台,您可以很好地了解返回给您的数据,并使用它来设置您的模型。对于这个例子,让我们使用一个简单的模型叫做Category

Ext.define('MyApp.model.Category', {
    extend: 'Ext.data.Model',
    config: {
        fields: ['id', 'name', 'icon']
    }
});

然后,我们可以设置一个存储来从 API 加载数据:

var store = Ext.create('Ext.data.Store', {
    model: 'Category',
    proxy: {
        type: 'jsonp',
        url : 'http://foursquare.com/vendors/categories' ,
        extraParams: {
          apiKey: 'XXXXXXXXXXXXXXXXXXXXXXXXX',
          appSecret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
        },
        reader: {
            type: 'json',
            rootProperty: 'categories'
        }
    }
});

这将设置一个使用我们的Category模型的数据存储,并调用我们外部 API 的url属性。请记住,我们必须在请求中发送我们的凭据,因此我们在proxy部分将这些设置为extraParams

注意

这里显示的apiKeyappSecret属性是示例。您需要自己的 API 密钥信息才能使用 API。

我们还需要在reader部分设置一个名为rootProperty的属性。大多数 API 都会在请求中发送大量的详细信息,并且存储需要一些关于从何处开始加载类别记录的想法。

我们还可以通过在存储代理上调用setExtraParam()函数来稍后添加额外的参数。这将使我们能够添加要发送到外部 API URL 的额外参数。

注意

请注意,setExtraParam()将添加一个额外的参数,但setExtraParams()将用新值替换我们所有的extraParams

让我们看看本章的应用程序,看看这一切是如何结合在一起的。

基本应用

Decider 应用程序旨在结合使用本地存储、Google 的 Map API 和 Foursquare API。应用程序将获取人员列表及其食品偏好,然后使用 Foursquare 和 Google Maps 找到符合每个人食品偏好的附近餐饮场所。

此截图提供了先前解释的图示表示:

基本应用

我们将使用本地存储来存储我们的联系人和类别。来自 Google 和 Foursquare 的外部 API 将分别生成我们的地图和餐厅列表。我们将在深入研究存储设置和 API 集成之前,快速概述基本应用程序结构和表单。

我们的主要容器是一个简单的卡片布局:

Ext.define('MyApp.view.ViewPortContainer', {
    extend: 'Ext.Container',

    config: {
        id: 'viewport',
        layout: {
            type: 'card'
        },
        items: [ ]
    }

});

在此视图中,我们将添加两个卡片:一个导航视图和一个表单面板。我们的navigationvew将作为我们的主显示窗口。我们将通过我们的控制器向其中添加额外的容器:

{
    xtype: 'navigationview',
    id: 'mainView',
    navigationBar: {
        items: [
            {
                xtype: 'button',
                handler: function(button, event) {
                    Ext.getCmp('viewport').setActiveItem(1);
                },
                id: 'addContactButton',
                ui: 'action',
                iconCls: 'add',
                iconMask: true,
                align: 'right'
            }
        ]
    },
    items: [
        {
            xtype: 'container',
            id: 'homeScreen',
            layout: {
                type: 'hbox'
            },
            items: [
                {
                    xtype: 'button',
                    action: 'go',
                    margin: 75,
                    text: 'Get Started!',
                    flex: 1
                }
            ]
        }
    ]
}

mainView包含我们的navigationBar和包含大号Get Started按钮的homeScreen容器。此按钮将向导航视图添加新容器(我们将在控制器中稍后查看)。

注意

记住,Sencha Touch 会自动为添加到导航视图中的每个容器创建一个返回按钮。这意味着我们不需要为它编写额外的代码。

添加到我们的视口中的第二个项目是我们的表单面板。它将包含用于姓氏和名字的文本字段,以及用于我们不同食品类别的可选择列表:

{
    xtype: 'formpanel',
    id: 'editContact',
    layout: {
        type: 'vbox'
    },
    items: [
        {
            xtype: 'textfield',
            label: 'First Name',
            labelWidth: '40%',
            name: 'firstname'
        },
        {
            xtype: 'textfield',
            label: 'Last Name',
            labelWidth: '40%',
            name: 'lastname'
        },
        {
            xtype: 'label',
            html: 'Choose what kind of food they like:'
        },
        {
            xtype: 'list',
            id: 'categoryList',
            itemTpl: [
                '<div><span class="icon"><img src="img/{imgURL}" /></span> {shortName}</div>'
            ],
            store: 'Categories',
            mode: 'MULTI',
            flex: 1
        },
        {
            xtype: 'segmentedbutton',
            margin: '0 0 10 0',
            layout: {
                pack: 'center',
                type: 'hbox'
            },
            items: [
                {
                    xtype: 'button',
                    text: 'Cancel'
                },
                {
                    xtype: 'button',
                    text: 'Save'
                }
            ]
        },
        {
            xtype: 'titlebar',
            docked: 'top',
            title: 'Add Contact'
        }
    ]
}

我们通过一个segmentedbutton属性关闭表单,该属性具有SaveCancel选项。我们将在控制器中稍后添加这些按钮的处理函数。

我们还在表单的顶部包括一个标题栏,以使用户了解他们正在做什么。

此表单的关键部分之一是类别列表,因此让我们更详细地看看它是如何工作的。

创建类别列表

由于我们将从 Foursquare API 获取潜在餐厅的列表,因此我们需要使用他们的类别,以便我们可以以某种程度的准确性匹配事物。

注意

Foursquare API 可以在 developer.foursquare.com/ 找到。如前所述,你需要一个 Foursquare 账户来访问 API。你还需要一个 API 密钥,以便将 Foursquare 集成到你的应用程序中。

我们可以使用 Foursquare 的 API 获取类别列表,然而 API 返回的列表包括机场、火车、出租车、博物馆和餐厅等几百个类别。此外,每个类别都有自己的子类别。我们真正想要的只是餐厅的子类别。

要使事情更加复杂,Foursquare 的 API 还会以这种方式返回数据:

categories: [
  {category 1},
  {category 2},
  {category 3},
  {category 4}…

]

这意味着我们只能通过类别数组中的顺序来获取特定的类别。例如,如果餐厅是数组中的第二十三类别,我们可以通过 categories[23] 来访问它,但不能通过调用 categories['Restaurants'] 来访问。不幸的是,如果我们使用 categories[23],而 Foursquare 添加了一个新类别或改变了顺序,我们的应用程序将会崩溃。

这是一个需要灵活应对的情况。Foursquare 的 API 包含一个控制台,我们可以在这里尝试我们的 API 请求。我们可以使用这个控制台来请求我们所有类别的数据,然后将所需的数据拉入我们应用程序的平面文件中。查看此 URL 以查看输出:

developer.foursquare.com/docs/explore#req=venues/categories

我们可以从类别中复制所需的餐厅信息,并将其保存为名为 categories.json 的文件,然后从我们的存储中调用它。

注意

解决这个难题的更好方法可能是编写一些服务器代码,从 Foursquare 请求完整的类别列表,然后提取我们感兴趣的信息。但为了简洁起见,我们只会使用平面 json 文件。

我们的每个类别都像这样排列:

{
    id: "4bf58dd8d48988d107941735",
    name: "Argentinian Restaurant",
    pluralName: "Argentinian Restaurants",
    shortName: "Argentinian",
    icon: {
        prefix: "https://foursquare.com/img/categories_v2/food/argentinian_",
        mapPrefix: "https://foursquare.com/img/categories_map/food/argentinian",
        suffix: ".png",
    },
    categories: [ ]
}

我们关注的要点是 idnameshortnameicon 值。这给我们一个看起来像这样的数据模型:

Ext.define('MyApp.model.Category', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id'
            },
            {
                name: 'name'
            },
            {
                name: 'shortName'
            },
            {
                name: 'icon'
            },
            {
                convert: function(v, rec) {
                    return rec.data.icon.prefix+ '32' + rec.data.icon.suffix;
                },
                name: 'imgURL'
            }
        ],
        proxy: {
            type: 'ajax',
            url: '/categories.json',
            reader: {
                type: 'json',
                rootProperty: 'categories'
            }
        }
    }
});

注意我们还在其中添加了一个函数来创建所需图标的图像 URL。我们通过 convert 配置来完成此操作,它允许我们根据记录中的其他数据组装图像 URL 的数据:

{
 convert: function(v, rec) {
  return rec.data.icon.prefix+ '32' + rec.data.icon.suffix;
 },
 name: 'imgURL'
}

convert 函数会自动传入数据值(v),在这个例子中我们忽略了它,以及记录(rec),这允许我们通过将记录中的 icon.prefix 值、一个数字和 icon.suffix 值组合起来,创建一个有效的 Foursquare URL。如果你查看我们之前的类别数据示例,这将产生以下 URL:

foursquare.com/img/categories_v2/food/argentinian_32.png

通过更改数字,我们可以控制图标的大小(这也是 Foursquare API 的一部分)。

我们将这个与我们的 XTemplate 结合起来:

'<div><span class="icon"><img src="img/{imgURL}" /></span> {shortName}</div>'

这为我们提供了一个非常吸引人的类别选择列表:

创建类别列表

接下来我们需要查看联系表单的控制器。

创建联系人控制器

联系控制器处理保存联系人和取消操作。我们通过声明我们的引用和控制开始控制器:

Ext.define('MyApp.controller.Contact', {
    extend: 'Ext.app.Controller',
    config: {
        refs: {
            contactEditor: '#editContact',
            categoryList: '#editContact list',
            cancelButton: '#editContact button[text="Cancel"]',
            saveButton: '#editContact button[text="Save"]',
            viewContainer: '#viewport'
        },
        control: {
            cancelButton: {
                tap: 'doCancel'
            },
            saveButton: {
                tap: 'doSave'
            }
        }
    }
});

记住我们的refs(引用)为我们提供了一个方便的快捷方式,我们可以在控制器中的任何地方使用它来获取我们需要的部分。我们的control部分将tap监听器附加到取消和保存按钮上。

接下来我们需要在控制部分之后添加我们的两个函数。doCancel函数非常简单:

doCancel: function() {
    this.getContactEditor().reset();
    this.getCategoryList().deselectAll();
    this.getViewContainer().setActiveItem(0);
}

我们只需使用我们的引用来清除联系人编辑器,取消选择我们类别列表中的所有项目,并切换回我们的主视图。

save函数比我们在这本书的其他地方覆盖的函数复杂一些,但类似:

doSave: function() {

    var contact = Ext.create('MyApp.model.Contact', this.getContactEditor().getValues());
    var categories = this.getCategoryList().getSelection();
    var categoryIDs = [];
    Ext.each(categories, function(category) {
        categoryIDs.push(category.get('id'));
    });
    contact.set('categories', categoryIDs.join(','));

    contact.save(function() {
        console.log('Contact: ',contact);
    });

    this.doCancel();
}

与我们之前的保存函数一样,我们创建一个新的MyApp.model.Contact并添加表单中的值。然而,由于我们的列表并不是一个标准的表单组件,我们需要单独获取其选择并将其作为以逗号分隔的列表添加到联系人数据中。

我们通过创建一个空数组并使用Ext.each()来遍历并运行一个函数在我们所有的类别上。然后我们使用join将数组压缩成以逗号分隔的列表。

最后,我们保存联系人并运行我们的doCancel函数来清理并返回到我们的主视图。

现在我们能够添加联系人,我们需要创建一个控制器来处理我们对 Foursquare 和 Google API 的请求,并将数据返回给我们的用户。

与 Google Maps 和 Foursquare 集成

我们的应用程序仍有一些任务要完成。它需要:

  • 处理开始按钮的点击

  • 添加我们的地图面板,并通过 Google Maps API 提供调整当前位置的选项

  • 显示要包含在搜索中的朋友列表

  • 在列表中显示搜索结果

  • 显示所选结果的详细信息

我们将从控制器的基本骨架开始,创建视图和存储,然后完成控制器以完成应用程序。

启动 mainView.js 控制器

我们将以一些存储的占位符开始mainView.js控制器文件。我们将在稍后添加视图和这些组件的一些引用。

注意

请记住,以这种方式使用占位符时,应用程序将无法测试,直到所有文件实际上都已就位。

我们在controllers文件夹中创建mainView.js文件:

Ext.define('MyApp.controller.mainView', {
    extend: 'Ext.app.Controller',
    requires: 'Ext.DateExtras',
    config: {
        views: [ 'confirmLocation', 'restaurantList', 'ViewPortContainer', 'friendChooser', 'restaurantDetails'],
        stores: [ 'ContactStore', 'RestaurantStore'],
        refs: {
            viewContainer: '#viewport',
            mainView: '#mainView',
            startButton: '#homeScreen button[action="go"]',
            cancelButton: 'button[action="cancel"]',
            finishButton: 'button[action="finish"]',
            locationButton: 'button[action="newlocation"]',
            nextButton: 'button[action="choosefriends"]',
            map: 'confirmlocation map',
            restaurantList: 'restaurantlist',
            friendList: 'friendchooser list'
        }
    }
});

在此配置的顶部,我们引入了Ext.DateExtras。此文件为我们提供了日期对象的格式化选项。如果此文件未包含,则日期对象中只有now()方法将可用在你的应用程序中。

在我们的views部分,我们为confirmLocationrestaurantListfriendChooserrestaurantDetails添加了占位符。我们将在稍后添加这些文件,以及stores部分中列出的RestaurantStore文件。

我们也为这些视图、存储以及它们的一些子组件提供了一些参考。在我们继续控制器其他部分之前,我们需要创建这些视图。我们将按照用户看到的顺序来处理这些视图,首先是confirmLocation视图。

创建 confirmLocation 视图

当用户点击开始按钮时,confirmLocation视图首先出现。这个视图将向用户展示一个显示他们当前位置的地图,并提供一个选项,如果用户希望,可以切换到不同的位置。

以下截图给出了前面代码的图形表示:

创建 confirmLocation 视图

为了给我们自己更多的灵活性,我们将在这个视图中使用 Google Maps Tracker 插件。你可以在你的 Sencha Touch 2 文件夹中的examples/map/lib/plugin/google/Tracker.js找到这个插件。将文件复制到你的主应用程序文件夹中的lib/google文件夹,并确保将其添加到app.js文件的requires部分:

requires: [
 'Ext.plugin.google.Tracker'
]

你还应该在app.js文件中启用Ext.Loader的上方设置与Ext.plugin命名空间对应的路径:

Ext.Loader.setPath({
    'Ext.plugin': 'lib/plugin'
});

这个插件将使我们能够轻松地在地图上放置标记。

一旦将 Google Tracker 插件文件包含到应用程序中,我们就可以像这样设置我们的confirmLocation.js视图:

Ext.define('MyApp.view.confirmLocation', {
 extend: 'Ext.Container',
 alias: 'widget.confirmlocation',
 config: {
  layout: {
   type: 'vbox'
  },
  items: [
   {
    xtype: 'container',
    height: 25,
    html: 'Please confirm your location:'
   },
   {
    xtype: 'map',
    useCurrentLocation: true,
    flex: 1,
    plugins: [
     new Ext.plugin.google.Tracker({
      trackSuspended: false,   //suspend tracking initially
      allowHighAccuracy: false,
      marker: new google.maps.Marker({
      position: new google.maps.LatLng(37.44885, -122.158592), 
      title: 'My Current Location',
      animation: google.maps.Animation.DROP
      })
     })
    ]
   }
  ]
 }
});

视图本身是一个简单的容器,顶部有一些 HTML 代码,提示用户确认他们的位置。接下来是一个地图容器,它使用我们的 Google Tracker 插件来配置地图并使位置标记从屏幕顶部动画到用户的当前位置。position配置是一个默认位置,当用户拒绝应用程序访问其当前位置时使用。这个位置设置为 Sencha 总部。

接下来,我们需要为用户提供一些选项来选择:取消新位置下一步。我们将把这些选项作为一个分段按钮添加到地图容器下。我们在items容器的末尾添加代码(在map容器之后):

{
 xtype: 'segmentedbutton',
 height: 40,
 margin: '10 0 10 0',
 layout: {
  pack: 'center',
  type: 'hbox'
 },
 items: [
  {
   xtype: 'button',
   text: 'Cancel',
   action: 'cancel'
  },
  {
   xtype: 'button',
   text: 'New Location',
   action: 'newlocation'
  },
  {
   xtype: 'button',
   text: 'Next',
   action: 'choosefriends'
  }
 ]
}

我们每个按钮都关联了一个动作。这允许我们在mainView.js控制器中为每个按钮分配函数。通过以这种方式创建按钮,我们保持了应用程序显示和功能之间的分离。当你想要重用视图组件时,这非常有帮助。

用户遇到的下一个视图是“朋友选择器”。

创建 Friends Chooser 视图

friendsChooser.js文件使用了一个与之前类别选择器相似的列表。这允许我们的用户选择多个要包含在餐厅搜索中的人:

创建 Friends Chooser 视图

我们的friendChooser扩展了Ext.Container组件,并允许用户从朋友列表中选择:

Ext.define('MyApp.view.friendChooser', {
 extend: 'Ext.Container',
 alias: 'widget.friendchooser',
 config: {
  id: 'friendChooser',
  layout: {
   type: 'vbox'
  },
  items: [
   {
    xtype: 'container',
    height: 20,
    html: 'Please Choose Friends from the list...',
    styleHtmlContent: true
   },
   {
    xtype: 'list',
    margin: 25,
    store: 'Contacts',
    itemTpl: [
     '<div>{firstname} {lastname}</div>'
    ],
    mode: 'MULTI',
    flex: 1,
    grouped: true,
    emptyText: 'No Contacts to display.<br />Please add some by clicking the plus icon.'
   }
  ]
 }
});

就像我们之前的面板一样,我们有一个包含 HTML 的容器在顶部,为用户提供一些说明。下面是我们的 list 容器,它,就像我们的类别列表一样,允许通过 mode: 'MULTI' 配置选择多个项目。我们还设置了 groupedtrue。这允许我们的商店通过姓氏将联系人分组在一起。

如果您查看 ContactStore.js 文件,您可以看到我们做了什么:

grouper: {
 groupFn: function(record) {
  return record.get('lastname')[0];
 }
}

此配置返回姓氏的首字母进行分组。

我们需要用 friendChooser.js 文件做的最后一件事是在底部添加按钮来 取消完成 搜索。按钮位于列表下方,items 部分中:

{
  xtype: 'segmentedbutton',
  height: 40,
  margin: '10 0 10 0',
  layout: {
   pack: 'center',
   type: 'hbox'
  },
  items: [
   {
    xtype: 'button',
    text: 'Cancel',
    action: 'cancel'
   },
   {
    xtype: 'button',
    text: 'Finish',
    action: 'finish'
   }
  ]
 }

就像我们之前的视图一样,我们使用一个 segmentedbutton 属性,并为每个单独的按钮分配了动作。

一旦用户点击 完成,我们需要返回一个他们可以从中选择的餐厅列表。

创建餐厅列表、商店和详细信息

我们的餐厅列表将使用商店和 Foursquare API 根据用户选择的每个人的共享偏好返回餐厅列表。

以下截图展示了前面的解释:

创建餐厅列表、商店和详细信息

此组件相当基础:

Ext.define('MyApp.view.restaurantList', {
    extend: 'Ext.dataview.List',
    alias: 'widget.restaurantlist',
    config: {
        store: 'Restaurants',
        itemTpl: [
            '<div>{name}</div>'
        ],
        onItemDisclosure: true,
        grouped: true
    }
});

此组件使用一个简单的列表,并具有 onItemDisclosure: true 的配置选项。这将在列表中的餐厅名称旁边放置一个箭头。用户将能够点击箭头并查看该餐厅的详细信息(我们将在创建商店后创建)。

我们还设置了 groupedtrue,但这次我们的商店将使用一个函数来计算和按距离排序。

创建餐厅商店和模型

餐厅商店是我们设置向 Foursquare API 发送请求的地方:

Ext.define('MyApp.store.RestaurantStore', {
 extend: 'Ext.data.Store',
 requires: [
  'MyApp.model.Restaurant'
 ],
 config: {
  model: 'MyApp.model.Restaurant',
  storeId: 'Restaurants',
  proxy: {
   type: 'jsonp',
   url: 'https://api.foursquare.com/v2/venues/search',
   reader: {
    type: 'json',
    rootProperty: 'response.venues'
   }
  },
  grouper: {
   groupFn: function(record) {
    var distM = record.raw.location.distance;
    var distMiles = Math.round(distM * 0.000621371); //give or take.
    return (distMiles == 1)?"1 Mile":distMiles+' Miles';
   }
  },
  sorters: [
   { property: 'name', direction: 'ASC' }
  ]
 }
});

RestaurantStore.js 文件为我们的商店设置了一个 modelstoreId 字段,然后定义了我们的代理。proxy 部分是我们设置向 Foursquare 发送请求的地方。

正如我们在本章开头提到的,这需要是一个 jsonp 请求,因为它将访问另一个域名。我们向 api.foursquare.com/v2/venues/search 发送请求,并寻找返回的 JSON 数组中的 responses.venues 部分。

您会注意到,这个商店目前没有其他参数要发送给 Foursquare。我们将在控制器中加载商店之前稍后添加这些参数。

对于模型,我们可以查阅 Foursquare API 文档,以查看返回的餐厅信息(在 Foursquare 术语中称为 venue)developer.foursquare.com/docs/responses/venue

您可以包括页面上的任何字段。对于这个应用程序,我们选择在我们的模型中包含以下代码:

Ext.define('MyApp.model.Restaurant', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id'
            },
            {
                name: 'name'
            },
            {
                name: 'categories'
            },
            {
                name: 'location'
            },
            {
                name: 'contact'
            },
            {
                name: 'menu'
            },
            {
                name: 'specials'
            }
        ]
    }
});

如果您想在详细视图中显示更多信息,可以添加更多字段。

创建详细视图

详细信息视图是一个简单的面板和 XTemplate 组合。使用我们的控制器,当用户在列表中点击餐厅时,面板将接收到数据记录:

Ext.define('MyApp.view.restaurantDetails', {
 extend: 'Ext.Panel',
 alias: 'widget.restaurantdetails',
 title: 'Details',
 config: {
  tpl: [
   '<div class="restaurant"><span class="name">{name}</span>',
   '<tpl for="contact">',
     '<span class="phone">- {formattedPhone}</span>',
   '</tpl>',
   '<div class="icons"><tpl for="categories">',
     '<span><img src="img/{icon.prefix}32{icon.suffix}" /></span>',
   '</tpl></div>',
   '<div class="address">Address:<br />',
   '<tpl for="location">',
     '{address}<br />',
     '{city}, {state} {postalCode}',
   '</tpl></div>',
   '<tpl for="menu">',
     '<a class="menu" href="{mobileUrl}">Menu</a>',
   '</tpl>',
   '<tpl for="specials">',
     '<tpl if="count &gt; 0">',
       '<div class="specials">Specials:<dl><tpl for="items">',
         '<dt>{title}</dt>',
         '<dd>{description}<br>{message}</dd>',
       '</tpl></dl></div>',
     '</tpl>',
   '</tpl>',
   '</div>'
  ]
 }
});

由于 tpl 标签基本上是 HTML,你可以在这里使用任何你喜欢的 CSS 样式。请注意,某些字段如 contactlocationcategories 可以有多个条目。你需要使用 <tpl for="fieldname"> 来遍历这些值。

现在视图已经完成,我们需要回到我们的控制器并添加将一切组合起来的函数。

完成主视图控制器

当我们开始使用主控制器时,我们添加了所有的视图、存储和引用。现在是我们添加应用程序功能的时候了。我们首先在 config 的末尾添加一个 control 部分:

control: {
 startButton: {
   tap: 'doStart'
 },
 cancelButton: {
   tap: 'doCancel'
 },
 locationButton: {
   tap: 'doNewLocation'
 },
 nextButton: {
   tap: 'doChooseFriends'
 },
 finishButton: {
   tap: 'doShowRestaurants'
 },
 restaurantList: {
   disclose: 'doShowRestaurantDetails'
 }
}

控件基于控制器中的引用,并为组件的特定监听器添加函数。这些函数的格式如下:

reference: {
 eventName: 'functionName'
}

一旦这些控件就位,我们可以在控制器的 config 部分之后添加我们的函数。

我们的第一个函数是 doStart。这个函数加载我们的 Contacts 存储并检查我们是否有任何现有联系人。如果没有,我们提醒用户并建议他们添加一些。如果他们有联系人,我们创建一个新的 confirmLocation 容器实例并将其 push 到主导航视图中:

doStart: function() {
 var contactStore = Ext.getStore('Contacts');
 contactStore.load();
 if(contactStore.getCount() > 0) {
     this.getMainView().push({ xtype: 'confirmlocation' });
 } else {
     Ext.Msg.confirm('No Contacts', 'You will need to add some contacts before we can search for restaurants. Would you like to add contacts now?', function(btn){
    if(btn == 'yes') {
      Ext.getCmp('viewport').setActiveItem(1);
    }
  }, this);
 }
}

记住,由于 mainView 是一个导航视图,后退 按钮将自动在顶部工具栏中创建。这个函数将显示用户当前的初始地图面板。

这个面板需要四个函数:一个用于取消请求,一个用于弹出新的位置窗口,一个用于设置新位置,以及一个用于进入下一步:

doCancel: function() {
 var count = this.getMainView().items.length - 1;
 this.getMainView().pop(count);
}

我们实际上希望能够在处理过程中的任何地方使用 doCancel 函数。当我们向 mainView 导航添加新的面板时,这些面板只是简单地堆叠起来。这意味着我们需要获取当前在 mainView 堆栈上的面板数量。我们使用 length-1 以确保初始面板(带有我们的大 Get Started 按钮的那个)仍然在堆栈上。我们使用 pop 来从堆栈中移除除了第一个面板之外的所有面板。这样,取消 按钮将带我们回到堆栈的起点,而 后退 按钮将带我们回到上一步。

下一个函数是 doNewLocation(),它使用 Ext.Msg.prompt 来提示用户输入新位置:

doNewLocation: function() {
 Ext.Msg.prompt(
     '',
     'Please enter the address you want to search from:',
     this.setNewLocation,
     this,
     100
 );
} 

如果用户输入了新位置,我们调用 setNewLocation 来处理用户在提示文本框中输入的文本:

setNewLocation: function(buttonID, address) {
 var geocoder = new google.maps.Geocoder();
 var map = this.getMap();
 geocoder.geocode({'address': address}, function(results, status) {
  if (status == google.maps.GeocoderStatus.OK) {
   map.getGeo().suspendUpdates();
   map.getMap().setCenter(results[0].geometry.location);
   var marker = new google.maps.Marker({
    map: map.getMap(),
    position: results[0].geometry.location,
    title: 'My Current Location',
    animation: google.maps.Animation.DROP
   });
   map.getGeo().setLatitude(results[0].geometry.location.lat());
   map.getGeo().setLongitude(results[0].geometry.location.lng());
  } else {
   Ext.Msg.alert('Error', 'Unable to find address.');
  }
 }); 
}

这段代码获取我们的地图并将用户传递给我们的文本编码为地理编码位置。如果谷歌返回一个有效的地址,我们将地图中心定位在该位置并放置一个标记以显示确切位置。我们还设置了纬度和经度,以便我们以后可以引用它们。

如果我们无法获取有效的地址,我们将提醒用户以便他们可以修复并再次尝试。

一旦用户对位置满意,他们可以点击 下一步 按钮,这将触发我们的 doChooseFriends 函数:

doChooseFriends: function() {
 this.getMainView().push({ xtype: 'friendchooser' });
}

此函数将我们的 friendchooser 视图推入堆栈以进行显示。friendchooser 视图允许用户选择多个朋友并点击 取消完成

由于我们已经用 doCancel 函数处理了我们的 取消 按钮,我们只需要编写 doShowRestaurants 函数。

此函数首先遍历选定的朋友。对于列表中的第一个,我们获取朋友存储的餐厅类别,并将其从逗号分隔列表(这是我们存储的方式)转换为数组。

这使我们能够获取每个后续选择,并运行 Ext.Array.intersect() 来找到所有选中朋友之间的共同类别:

doShowRestaurants: function() {
 var location = this.getMap().getGeo();
 var friends = this.getFriendList().getSelection();
 var store = Ext.getStore('Restaurants');
 var categories = [];
 var dt = new Date();
 var first = true;
 Ext.each(friends, function(friend) {
  if (first) {
   categories = friend.get('categories').split(',');
   first = false;
  } else {
   categories = Ext.Array.intersect(categories, friend.get('categories').split(','));
  }
 });
 store.load({
  params: {
   ll: location.getLatitude()+','+location.getLongitude(),
   client_id: FourSquare.clientID,
   client_secret: FourSquare.clientSecret,
   radius: 2000,
   categoryId: categories.join(','),
   v: Ext.Date.format(dt, 'Ymd')
 }
 });
 this.getMainView().push({xtype: 'restaurantlist', store: store});
}

接下来,我们根据 categoryID 加载存储,这是我们在地图中存储的位置数据,client_idclient_secret,它们构成了我们的 Foursquare API 密钥以及一个 radius 值(以米为单位)。

我们还发送一个名为 v 的必填字段,其设置为当前日期。

最后,我们将我们的餐厅列表组件推入容器堆栈。这将显示我们的结果列表,并允许用户点击查看详情。

这将带我们到 doShowRestaurantDetails 函数:

doShowRestaurantDetails: function(list, record) {
 this.getMainView().push({xtype: 'restaurantdetails', data: record.data});
}

当用户在我们的餐厅列表中点击其中一个展开图标时,我们将 restaurantdetails 视图推入容器堆栈,并将其数据设置为被点击的记录。这将在我们的详情 XTemplate 中显示餐厅的详细信息。

作业

可以向此类应用程序添加许多其他功能,包括:

  • 编辑联系人(或从 Facebook 自动拉取朋友)

  • 设置类别菜单的实时流

  • 添加除餐厅以外的其他场所

  • 将应用程序与额外的 API(如 Yelp)结合以获取评论

只需记住使用额外 API 的关键要求:API 密钥、研究 API 文档以及使用 JSONP 存储来获取数据。

摘要

在本章中,我们讨论了使用外部 API 来增强您的 Sencha Touch 应用程序。这包括:

  • API 基础概述

  • 组装基本应用程序

  • 与 Google Maps 和 Foursquare 的交互

  • 构建视图、模型和存储

  • 构建应用程序控制器

在下一章中,我们将讨论使用渐进增强来针对特定设备或屏幕尺寸的网站。

第八章。Evolver:使用配置文件

随着移动设备的日益普及,网页设计师不得不处理各种屏幕尺寸。这对需要在不同设备之间允许不同功能的网页应用开发者来说更加困难。Sencha Touch 提供了一个简单的方法来处理多个设备,称为配置文件。

由于 Sencha Touch 框架知道它在哪个设备上运行,我们可以为每个我们想要支持的设备设置单独的配置文件。然后 Sencha Touch 将根据设备交换组件和功能。

在本章中,我们将探讨:

  • 配置文件的概述

  • 设置配置文件

  • 测试配置文件

  • 根据设备加载自定义 CSS

我们将创建一个应用程序,从 WordPress 网站读取页面和帖子,并将它们转换为适用于移动设备的自定义应用程序。我们称这个应用程序为 Evolver。

Evolver 将使用不同的配置文件来为 iPhone 创建一个视图,并为 iPad 创建一个不同的视图。这些视图将具有自己的功能,并且可以根据用户和设备的需求进行定制。

然而,在我们过于深入之前,我们可能应该稍微谈谈配置文件的作用以及何时使用它们。

配置文件的概述

在 Sencha Touch 中,配置文件的作用类似于导演或交通警察。当应用程序加载时,配置文件会确定它们正在运行的设备类型,并根据设备(通常是存储和模型不会改变)加载不同的控制器和视图。

如你所猜,这意味着每个配置文件(设备)都需要自己的控制器和视图集。虽然它们可以,并且确实共享诸如存储和模型之类的元素,但大多数显示逻辑都是针对特定设备的。这可能会感觉像是一项额外的工作,这也引出了一个问题:何时以及为什么应该使用配置文件?

配置文件基础

作为一般规则,为特定设备或屏幕尺寸设计应用程序通常是一个好主意。较小的屏幕需要更大的字体以便阅读,但同时也为信息提供了更少的空间。这意味着它通常依赖于多个屏幕将信息传达给用户。在平板电脑设备上,类似的界面在大多数情况下都会显得笨拙且令人沮丧。

然而,通过一些简单的应用,CSS 样式的更改就能实现界面所需的更改。在这种情况下不需要配置文件,可以直接根据使用的设备加载样式表。这种方法允许我们使用我们个别元素的类和 ID 来控制应用程序的整体外观。

对于大多数专业应用,配置文件和样式表更改的组合将更好地服务于最终用户。我们可以通过一些 Evolver 应用程序的绘图来展示这一点。让我们从我们的平板电脑版本开始:

配置文件基础

在这里,我们有大量的屏幕空间可以利用。我们可以轻松显示我们的页面和帖子列表,同时还可以显示所选项目的内。我们有足够的空间放置多个标签,甚至是一个网站标志。

如果我们在手机大小的屏幕上查看这种布局,它将太小,无法有效使用,甚至难以阅读。正如我们之前提到的,手机大小的屏幕需要更大的字体和多个屏幕来显示用户所需的数据:

个人资料基础

在这种情况下,我们有两个屏幕。第一个是我们页面或帖子的列表。底部的标签将决定用户看到哪个列表。当用户从列表中选择一个项目时,我们将使用第二个屏幕来显示页面或帖子的内容。我们还将提供一个返回按钮,允许用户返回到邮件列表。

小贴士

草图或线框图

这样的草图通常被称为 线框图。在开始编码应用程序之前,将这些想法绘制出来是一个非常好的主意。它们不需要很复杂,只需要让你思考应用程序的组织方式以及用户如何从一个屏幕切换到下一个屏幕。线框图可以快速突出你尚未考虑的问题,并在你开始编码时节省大量时间。向潜在用户展示这些线框图也是一个好主意。非技术人士经常可以揭示用户的困惑区域,他们的疑问和反馈会使你的应用程序更有用。

通过查看这两个草图,我们可以确定我们需要哪些视图以及控制器中需要的不同功能。例如,在手机版本中点击一个项目需要向导航视图中添加一个面板,而在平板电脑版本中,点击一个项目只会替换我们主面板中的内容。

现在我们对想要的不同视图和功能有了些了解,我们可以看看个人资料实际上是如何工作的。

使用个人资料

我们个人资料设置的第一个部分发生在 app.js 文件中。这个文件通常是加载我们的初始存储和模型的地方,以及设置一个启动函数来启动应用程序。

使用个人资料时,事情的工作方式略有不同。我们首先声明我们希望使用的个人资料,如下所示:

Ext.application({
 name: 'Evolver', 
 profiles: ['Phone', 'Tablet'] …

我们两个个人资料的名字是任意的,我们可以有我们想要的任意多个。它们可以针对特定的操作系统以及设备类型。当我们使用这样的个人资料时,我们通常不在 app.js 文件中使用启动函数。由于个人资料将具有不同的起始屏幕,我们将启动函数放在单独的个人资料中。

个人资料应该放在一个名为 profile 的目录中,位于应用程序的 app 目录内。它们的命名方式应该与你在 app.js 文件中命名的方式相同(在我们的例子中这将分别是 Phone.jsTablet.js)。

注意

如果有帮助,您可以想象配置文件文件就像是一种拥有多个 app.js 文件的方式,每个设备一个。

这些单独的配置文件将加载我们的视图和控制器,并将启动我们的初始屏幕。然而,我们首先需要做的是确定哪个配置文件是激活的。我们通过在 Phone.js 文件中创建一个 isActive 函数来实现这一点,如下所示:

Ext.define('Evolver.profile.Phone', {
    extend: 'Ext.app.Profile',
    config: {
        name: 'Phone'
    },
    isActive: function() {
        return Ext.os.is.Phone;
    }
});

如果该函数返回 true,则表示应用程序正在手机上运行。我们在 Tablet.js 配置文件中也使用了一个类似的功能:

Ext.define('Evolver.profile.Tablet, {
    extend: 'Ext.app.Profile',
    config: {
        name: 'Tablet'
    },
    isActive: function() {
        return Ext.os.is.Tablet;
    }
});

如果 isActive 函数返回 true,则 app.js 文件将加载这些配置文件中的一个。只有一个配置文件应该始终返回 true。

注意

您可以在位于 docs.sencha.com/touch/2-1/#!/api/Ext.env.OS-method-is 的 Sencha Touch 开发者文档中找到有关 Ext.os.is 函数的更多信息。

每个配置文件也将包含其自己的视图和控制器,以及其自己的启动函数。然而,重要的是要注意,Ext.loader 函数将自动在以配置文件命名的子文件夹中查找这些项目。

例如,我们的平板电脑配置文件有一个名为 Main.js 的控制器。我们以通常在 app.js 中包含的方式将其包含在我们的配置文件中:

controllers: ['Main']

然而,由于此控制器位于配置文件中,Ext.loader 函数将按 app/controllers/tablet/Main.js 查找文件。相反,在我们的手机配置文件中,我们仍然将控制器包含为 controllers: ['Main'],但加载器将自动按 app/controllers/phone/Main.js 查找文件。

您可以通过使用控制器的全称来覆盖此行为,例如:

controllers: ['MyApp.controller.Main']

这将在 app/controller 文件夹中查找 Main.js 文件。这对于视图、模型和存储也是一样的。

您还会发现一些文件在两个配置文件中都是通用的。我们不是将它们包含在我们的单独配置文件中,而是可以将这些通用文件包含在 app.js 中。例如,在我们的 Evolver 应用程序中,我们将有页面和帖子模型和存储。这些将适用于两个配置文件,因此我们可以在 app.js 中正常添加它们:

 models: ['Page', 'Post'],
 stores: ['pageStore', 'postStore']

由于这些是从 app.js(而不是从我们的配置文件之一)加载的,因此加载器将在 app/modelapp/store 文件夹中分别查找它们。

正如我们之前提到的,配置文件除了 app.js 中的可选 launch 函数外,还有各自的启动函数。在基于配置文件的应用程序中,app.js 中的 launch 函数通常会被忽略,因为配置文件可能会启动不同的组件来创建主屏幕。但是,如果应用程序需要,您也可以使用 app.js 中的 launch 函数,以执行清理或加载存储(如果需要的话)。

顺序大致如下(当应用程序启动时):

  1. 激活配置文件已确定。

  2. 配置文件或 app.js 中的任何控制器都将被实例化(这意味着将触发 init 函数)。

  3. 配置文件中的启动函数被触发。

  4. app.js中的启动函数被触发。

应该注意的是,个人资料和app.js launch功能都是可选的,并且只有在它们被定义的情况下才会被调用。

现在我们已经处理了个人资料的基本内容,让我们对我们的应用程序进行更具体的了解。

创建 Evolver 应用程序

Evolver 应用程序从 WordPress 网站获取 RSS 源,并将源转换为 Sencha Touch 存储中的数据。我们在之前的 Feedback 应用程序中也使用过 RSS 源。然而,我们需要一些额外的帮助来从 WordPress 中获取所有所需的信息。

关于 WordPress

对于那些不了解 WordPress 的人来说,它是一个最初为博客设计的内容管理系统。WordPress 允许用户通过简单的基于 Web 的表单轻松创建文章和页面。文章通常是较短的、时间敏感的项目,而页面则包含更长、更通用的信息。

当 WordPress 开始流行时,用户迅速开始将其用于各种网站,从个人到商业和购物。目前的估计是,WordPress 网站数量超过 5500 万,占互联网上活跃网站的 15%到 20%。

由于安装简单和大量定制,WordPress 已经成为小型商业社区的宠儿。唯一的缺点是,典型 WordPress 网站的设计复杂性并不总是适合移动屏幕和平台。以下截图是一个标准 WordPress 风格网站的示例:

关于 WordPress

虽然这种布局在桌面或笔记本电脑屏幕上看起来不错,但对于平板电脑来说并不是很好的空间利用,对于手机大小的屏幕来说信息量过大。我们需要一个对移动设备友好的网站版本,能够有效利用平板和手机平台的优势和局限性。以下截图是我们想要采取的方法的示例:

关于 WordPress

由于 WordPress 网站背后的数据已经存在于 MySQL 数据库中,我们只需要一种方法将其放入数据存储中,以便我们可以在 Sencha Touch 中使用它。

WordPress 发布的文章以 RSS 格式提供,但页面不是。我们将需要使用 WordPress 插件来获取我们想要的页面。您需要使用自己的 WordPress 网站来实现这一点。

使用插件

WordPress 插件允许您扩展 WordPress 的基本功能和特性。在大多数情况下,这个过程就像搜索并点击安装按钮一样简单。在这种情况下,我们将安装 Dan Phiffer 的 JSON API 插件。此插件将允许我们向我们的 WordPress 网站发出标准 API 调用。

让我们安装插件,然后我们可以通过一些测试来看看它是如何工作的:

  1. 从您的 WordPress 管理页面,从菜单中选择插件

  2. 插件 页面上,点击 添加新插件 按钮。

  3. 搜索 字段中输入 JSON API 并点击 搜索插件

  4. 点击 安装 按钮旁边的 JSON API 插件(它应该是列表中的第一个)。

你可以通过检查以下 URL 来测试插件是否已正确安装(将 yourwordpressdomain.com 替换为你的 WordPress 安装地址):

yourwordpressdomain.com/api/get_page_index/

你应该会收到一个包含你网站页面的 JSON 响应字符串。注意,这个字符串返回如下所示:

{"status":"ok",
 "pages":[
  {page 1 data},
  {page 2 data},
  {page 3 data},
  {etc…}
 ]
}

我们需要记住,pages 数组实际上包含我们正在寻找的数据。当我们创建存储时,这将设置为存储的读取组件的 rootProperty

设置配置文件和 app.js

我们将从基本的 Sencha Touch 应用程序开始,使用我们的命令行 SDK 工具设置(就像我们在一些早期的章节中所做的那样)。这会创建我们的应用程序外壳以及视图、模型、存储和控制器文件夹。

在我们的 app.js 文件中,我们将添加我们的配置文件行。我们还在两个配置文件中都有一些公共元素,特别是页面和配置文件的模型和存储。由于这些将需要为两个配置文件加载,我们可以在 Ext.application 声明中添加它们,而不是在每个配置文件中各添加一次:

 profiles: ['Phone', 'Tablet']
 models: ['Page', 'Post'],
 stores: ['pageStore', 'postStore'] 

现在我们已经设置了 app.js 文件,我们需要在 app 目录中创建一个 profile 文件夹。这是我们的两个配置文件将存放的地方。

我们的 Phone.js 配置文件如下所示:

Ext.define('Evolver.profile.Phone', {
    extend: 'Ext.app.Profile',
    config: {
        name: 'Phone',
        controllers: ['Main'],
        views: ['Main', 'Evolver.view.PostList', 'Evolver.view.PageList', 'PageDetails', 'PostDetails']
    },
    isActive: function() {
       return Ext.os.is.Phone;
    },
    launch: function() {
       Ext.fly('appLoadingIndicator').destroy();
       Ext.create('Evolver.view.phone.Main', {fullscreen: true});
    }
});

我们这里有三个部分:config 部分、isActive 函数和 launch 函数。

我们的 config 部分包含配置文件 namecontrollersviews 的值。记住,由于我们处于配置文件中,加载器将在 app/controller/phoneapp/view/phone 分别查找这些文件。

注意,我们还有两个带有完整名称的视图列在我们的列表中:Evolver.view.PostListEvolver.view.PageList。这些视图实际上将由两个配置文件共享,因此我们将它们放在 app/views 文件夹中。我们也可以只是将它们放在 app.js 中,这也会达到相同的效果。我们在这里包括它们是为了演示如果需要,可以覆盖文件位置。

我们的 isActive 函数将返回 true,如果我们正在手机上运行。

小贴士

isActive、设备类型和 Safari

如果你使用桌面或笔记本电脑上的 Safari 进行测试,使用此功能会遇到一些问题。尽管可以在 Safari 中设置用户代理为 iPad 或 iPod,但浏览器并没有正确地向 Sencha Touch 报告设备类型。

在测试时,您需要在isActive函数中注释掉return Ext.os.is.Phonereturn Ext.os.is.Tablet行。然后,您可以将您想要测试的配置文件中的isActive函数设置为return true;,另一个设置为return false;。只需记住在转移到生产环境之前取消注释正确的函数。

如果您正在使用 iOS 或 Android 模拟器进行测试,这不是问题。

launch函数移除我们的加载指示器,并在全屏大小创建我们的Evolver.view.phone.Main视图实例。

我们的Tablet.js配置文件遵循与手机配置文件相同的模式:

Ext.define('Evolver.profile.Tablet', {
    extend: 'Ext.app.Profile',
    config: {
        name: 'Tablet',
        controllers: ['Main'],
        views: ['Main', 'Evolver.view.PostList', 'Evolver.view.PageList', 'PageDetails', 'PostDetails']
    },
    isActive: function() {
      return Ext.os.is.Tablet;
    },
    launch: function() {
        Ext.fly('appLoadingIndicator').destroy();
        Ext.create('Evolver.view.tablet.Main', {fullscreen: true});
    }
});

与手机配置文件类似,控制器和视图将从我们的app/controllersapp/views文件夹中的tablet文件夹加载。

由于这里没有太多新内容,我们将简要地查看应用的存储、模型和视图。然后,我们将完成控制器部分,因为真正的动作就发生在那里。

设置模型和存储

我们的模式非常基础,它们来自 RSS 订阅源返回的帖子数据和页面 JSON API 插件。我们只使用其中的一些数据,但您可以轻松地查看从yoursite/feeds/rss/(帖子)和yoursite/api/get_page_index/(页面)返回的值,以查看是否有其他您可能想要使用的数据。

我们的文章模型看起来像这样:

Ext.define('Evolver.model.Post', {
    extend: 'Ext.data.Model',
    config: {
    idProperty: 'guid',
        fields: [
            {
                name: 'guid',
                type: 'string'
            },
            {
                dateFormat: 'D, d M Y H:i:s Z',
                name: 'pubDate',
                type: 'date'
            },
            {
                name: 'title',
                type: 'string'
            },
            {
                name: 'author',
                mapping: 'creator',
                type: 'string'
            },
            {
                name: 'content',
                mapping: 'encoded',
                type: 'string'
            },
            {
                name: 'category',
                type: 'string'
            },
            {
                name: 'link',
                type: 'string'
            }
        ]
    }
});

这里唯一的新变化是使用映射来指示,虽然我们将字符串称为authorcontent,但数据实际上以creatorencoded分别接收。这通常有助于在整个应用中保持值的一致性,避免命名冲突,或者简单地保持代码员的理智。

此模型的postStore属性配置为从您使用的 WordPress 网站相同的服务器运行。这意味着我们可以使用ajax存储而不是jsonp存储。如果您想从不同的服务器使用它,您需要将代理更改为jsonp,并使用 JSON API 来获取帖子,而不是标准的 WordPress RSS 订阅源(它位于 XML 中):

Ext.define('Evolver.store.postStore', {
    extend: 'Ext.data.Store',
    requires: [
        'Evolver.model.Post'
    ],
    config: {
        storeId: 'postStore',
        autoLoad: true,
        model: 'Evolver.model.Post',
        proxy: {
            type: 'ajax',
            url: '/feed/rss/',
            reader: {
                type: 'xml',
                record: 'item'
            }
        }
    }
});

record: 'item' 配置指示读者在 XML 中查找用于其记录数据的一组项目。

小贴士

WordPress RSS 订阅源

默认情况下,WordPress 中的 RSS 订阅源仅显示文章的部分文本。您可以在 WordPress 网站的管理控制面板中更改此设置。在管理菜单中选择设置 | 阅读,并将订阅设置从摘要更改为全文

由于这个存储库不是 JSONP 存储库,它必须运行在与它从 WordPress 网站拉取的同一服务器上。没有等效的 XMLP 存储库,因此在本地机器上进行测试需要一些变通方法。在测试期间,您可以从 WordPress 网站下载 RSS 源到您的本地机器,并从本地 XML 文件中读取。稍后,当您迁移到生产环境时,您可以更改 URL 到实时链接。

我们的pageStore是为使用 JSON API 插件而设计的,因此我们将接收 JSON 而不是 XML:

Ext.define('Evolver.store.pageStore', {
    extend: 'Ext.data.Store',
    requires: [
        'Evolver.model.Page'
    ],
    config: {
        model: 'Evolver.model.Page',
        autoLoad: true,
        storeId: 'pageStore',
        proxy: {
            type: 'jsonp',
            url: 'http://yourWordPressSite.com/api/get_page_index/',
            reader: {
                type: 'json',
                rootProperty: 'pages'
            }
        }
    }
});

使用这个存储库,我们从 API 读取 JSON。get_page_index函数将返回网站页面的分层列表。

注意

WordPress 的 JSON API 插件提供了一些有用的函数,您可以使用这些函数读取和写入 WordPress 网站的数据。完整的函数列表可以在以下链接中找到:wordpress.org/extend/plugins/json-api/other_notes/

我们为页面使用的数据模型使用了 JSON API 插件提供的有限数据集:

Ext.define('Evolver.model.Page', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'title',
                type: 'string'
            },
            {
                name: 'content',
                type: 'string'
            },
            {
                dateFormat: 'Y-m-d H:i:s',
                name: 'modified',
                type: 'date'
            }
        ]
    }
});

您可以通过访问 WordPress 网站的 API URL 来查看所有可用的数据列表(yourWordPressSite.com/api/get_page_index/?dev=1)。dev1参数将格式化 JSON 响应并使其更易于阅读。

现在我们有了存储和模型,我们可以着手创建用于显示数据的视图。

创建视图

由于我们将在两个配置文件之间共享我们的帖子列表和页面列表,让我们从这里开始。我们将使用数据视图而不是列表视图,因为这与简单的列表相比提供了更多的显示选项。

我们的PostList.js文件如下所示:

Ext.define('Evolver.view.PostList', {
    extend: 'Ext.dataview.DataView',
    alias: 'widget.postlist',
    title: 'Posts',
    id: 'postList',
    config: {
        store: 'postStore',
        itemTpl: [
            '<div class="postItem">',
            '    <div class="postTitle">{title}</div>',
            '    <div class="postMeta"><span class="postAuthor">{author}</span> - <span class="postDate">{[Ext.util.Format.date(values.pubDate, "m/d/Y")]}</span></div>',
            '</div>'
        ]
    }
});

与标准列表类似,DataView接受数据存储并按顺序从存储中显示项目。然而,DataView在样式方面更为灵活,允许创建拼贴列表和其他更有趣的布局。

这个视图从postStore读取并使用itemTpl显示每篇帖子的标题、作者和日期。我们每个数据项都使用特定的类进行样式化,这意味着我们可以根据加载的样式表以不同的方式显示它们,甚至可以完全关闭它们。我们将在本章的末尾讨论这些类型的条件样式表。

我们的PageList视图遵循类似的架构:

Ext.define('Evolver.view.PageList', {
    extend: 'Ext.dataview.DataView',
    alias: 'widget.pagelist',
    title: 'Pages',
    config: {
        store: 'pageStore',
        itemTpl: [
            '<div class="pageItem">',
            '    <div class="pageTitle">{title}</div>',
            '    <div class="pageMeta">Updated <span class="pageDate">{[Ext.util.Format.date(values.modified, "m/d/Y")]}</span></div>',
            '</div>'
        ]
    }

});

这个共享视图从我们的pageStore读取并仅显示标题和日期。正如所注,您可以添加由 WordPress JSON API 生成的任何其他数据,然后使用条件样式表根据用户查看的设备显示或隐藏它。

我们的其他视图将针对手机或平板电脑配置文件而独特。在本章的目的上,每个视图都类似,每个都是一个简单的容器,包含一个模板来格式化记录以供显示。让我们从我们的手机详情容器开始。

创建手机详情视图

由于这个详情容器是我们电话配置文件的一部分,因此它被命名为Evolver.view.phone.PageDetails,并且它将位于app/view/phone/文件夹中。

Ext.define('Evolver.view.phone.PageDetails', {
    extend: 'Ext.Container',
    alias: 'widget.pagedetails', 
    config: {
    layout: 'fit',
    scrollable: {direction: 'vertical', directionLock: true},
    tpl: [
     '<div class="pageDetails">',
     '    <div class="pageTitle">{title}</div>',
     '    <div class="pageMeta"><span class="pageAuthor">{author}</span> <span class="pageDate">{updated}</span></div>',
     '    <div class="pageContent">{content}</div>',
     '</div>'
     ]
    }
});

我们也为此视图设置了scrollable配置。由于我们处于一个可以通过滑动触发的导航视图中,我们设置了方向和方向锁定,以防止滑动触发页面更改。

创建平板电脑详情视图

我们PageDetails.js视图的平板版本看起来是这样的:

Ext.define('Evolver.view.tablet.PageDetails', {
 extend: 'Ext.Container',
 alias: 'widget.pagedetails',
 config: {
  tpl: [
   '<div class="pageDetails">',
   '<div class="pageTitle">{title}</div>',
   '<div class="pageMeta"><span class="pageAuthor">{author}</span> <span class="pageDate">{updated}</span></div>',
   '<div class="pageContent">{content}</div>',
   '<div class="pageContent"><a href="{url}">View Original Page</a></div>',
   '</div>'
  ]
 }
});

两个版本之间的唯一区别是名称(Evolver.view.tablet.PageDetails)和页面的 URL,我们在tpl底部包含这个 URL。虽然大多数基于平板的应用程序中都有“查看原始页面”的链接,但对于电话应用程序来说,这并不是必需的,因为我们首先试图避免原始网站。

我们将在平板版本中处理滚动的方式不同,所以我们在这里不包括scrollable配置。

如前所述,你可以使这两个容器尽可能不同。如果你愿意,你可以将其中一个做成面板,另一个做成容器。由于实际上只有一个会被包含,我们甚至可以保持widget.pageDetailsalias属性值相同。不会出现xtype冲突,因为电话版本或平板版本在任何时候都只会激活一个。

我们为电话和平板电脑的postDetails对象遵循相同的格式,所以我们在这里不详细说明。你可以在章节的示例代码中看到它们。只需记住,电话版本放在app/view/phone中,平板版本放在app/view/tablet中。此外,视图命名约定将遵循Evolver.view.phone.viewNameEvolver.view.tablet.viewName的格式。

现在,我们需要将各个部分组合成一个主容器,用于我们的每个配置文件。

主视图

从我们的原始草图来看,我们有两个不同的界面。我们将首先查看的是电话界面:

主视图

由于电话屏幕尺寸有限,我们需要创建一个更紧凑和分层的界面。这将允许我们显示所有数据,同时仍然为用户提供可读的文本。

创建电话主视图

电话Main.js界面将包含一个带有两个 DataView(一个用于页面,一个用于帖子)的标签视图。每个 DataView 都在一个导航视图中。当我们点击 DataView 中的项目时,控制器将弹出我们的详情容器到导航视图中,自动创建一个返回按钮:

Ext.define('Evolver.view.phone.Main', {
    extend: 'Ext.tab.Panel',
    alias: 'widget.phonemain',
    id: 'mainView',
    config: {
        tabBar: {
            docked: 'bottom'
        },
        items: [
            {
                xtype: 'navigationview',
                iconCls: 'quote_black2',
                iconMask: true,
                title: 'Posts',
                items: [
                  {xtype: 'postlist', title: 'Posts List'}
                ]
            },
            {
                xtype: 'navigationview',
                title: 'Pages',
                iconCls: 'info',
                iconMask: true,
                items: [
                  {xtype: 'pagelist', title: 'Pages List'}
                ]
            }
        ]
    }

});

我们给这个视图一个id值为mainView,以便在控制器中轻松引用。你会注意到导航视图有一个title配置,并且每个导航视图内的 DataView 也有自己的title配置。

两个导航视图是整体标签面板的直接子视图。这意味着我们主标签面板底部的标签上的标题将使用这个标题。导航视图内部的数据视图每个都有一个标题栏组件,它将在顶部显示每个数据视图的标题。

通过使用带有导航视图的弹出详情的双列表策略,我们最大限度地利用了我们有限的手机屏幕区域。在我们的平板配置中,我们有更多的工作空间,因此我们可以采取不同的方法。

创建平板主视图

由于我们在平板尺寸的屏幕上有额外的空间,我们可以使用不同的视图来利用额外的空间:

创建平板主视图

我们平板的Main.js视图在标签面板中有两个数据视图,这与我们的手机配置版本类似。然而,平板版本保持了一个可见的主内容区域,我们可以在这里显示当前选定的项目。我们还在内容上方包含了一个区域,我们可以使用以下代码放置网站标志:

Ext.define('Evolver.view.tablet.Main', {
 extend: 'Ext.Panel',
 id: 'mainView',
 config: {
  layout: 'fit',
  items: [
   {
    xtype: 'tabpanel',
    width: 200,
    docked: 'left',
    items: [
     {
      xtype: 'pagelist',
      title: 'Pages'
     },
     {
      xtype: 'postlist',
      title: 'Posts'
     }
    ]
   },
   {
    xtype: 'container',
    layout: 'vbox',
    scrollable: true,
    items: [
     {
      xtype: 'container',
      layout: 'fit',
      id: 'banner',
      height: 140,
      html: '<img src="img/sb_logo.png">'
     },
     {
      xtype: 'pagedetails'
     },
     {
      xtype: 'postdetails',
      hidden: true
     }
    ]
   }
  ]
 }
});

我们首先扩展基本的Ext.Panel组件,并给它一个fit布局。在这个面板内部有一个标签面板和两个容器。

tabpanel组件包含我们的两个数据视图(页面和帖子),就像我们在应用程序的手机版本中一样。我们将其设置为width值为200

内容容器设置为具有三个子容器的vbox布局。用于我们标志的顶部容器将具有height值为140,以及从我们的 WordPress 网站到标志的 HTML 链接。vbox布局意味着我们的其他容器,包括我们的帖子内容和页面内容,将自动调整大小以适应剩余的空间。

注意,我们的postdetails容器默认是隐藏的,而页面详情是可见的。我们将根据控制器中选定的数据视图来交换这两个容器。

创建控制器

Evolver 应用程序使用一对数据视图(一个用于帖子,一个用于页面),这些视图在手机和平板配置之间共享。在手机配置的情况下,数据视图需要监听触摸事件并向导航容器添加一个新的详情组件。在平板配置的情况下,数据视图仍然监听触摸事件,但需要根据选定的数据视图交换两个详情容器。

通过将功能与显示逻辑分离,我们可以使单个数据视图执行两种不同的操作(一个在手机版本中,另一个在平板版本中)。我们从phone.jsmain.js控制器开始,以及tablet.jsmain.js控制器如下:

Ext.define('Evolver.controller.phone.Main', {
 extend: 'Ext.app.Controller',
 config: {
  refs: {
   postList: '#postList',
   pageList: '#pageList',
   mainView: '#mainView'
  },

  control: {
   postList: {
    itemtap: 'onListItemTap'
   },
   pageList: {
    itemtap: 'onListItemTap'
   }
  }
 }
});

这里显示的是手机配置版本,但在这个阶段,平板版本实际上是相同的,只是命名为Evolver.controller.tablet.Main

我们的引用设置了指向我们组件的快捷指针。由于postListpageList在两个应用程序中都是相同的,并且我们一致地为两个配置文件的主要容器命名,因此引用在两个控制器中都是相同的。

我们还使用相同的代码为我们的control配置,其中两个列表都需要监听itemTap事件以触发我们的函数。为了使我们的工作更简单,我们还让 DataViews 触发名为onListItemTap的相同函数。我们可以根据哪个列表被点击来决定需要发生什么。

这里是我们两个控制器开始分叉的地方。让我们从onListItemTap函数的手机版本开始:

onListItemTap: function(dataview, index, target, record) {
 var original = record.get('content');
 var converted = original.replace(/src=\"/g, 'src=\"http://src.sencha.io/120/');
 var final = converted.replace(/((width|height)\s*=\s*"*\d+"*)/g, '');
 record.set('content', final);
 if(dataview.id == 'postList') {
  var details = Ext.create(
   'Evolver.view.phone.PostDetails', {
    title: record.get('title'),
    data: record.data
  });
 } else {
  var details = Ext.create(
   'Evolver.view.phone.PageDetails', {
    title: record.get('title'),
    data: record.data
  });
 }
 this.getMainView().getActiveItem().push(details);
}

我们使用此函数的第一部分进行一些有趣的操作,以修改我们的内容,使其在手机大小的屏幕上显示得更好。由于返回给我们的内容包含全尺寸图像,它通常会在手机大小的屏幕上占用大量空间,使得布局看起来有点糟糕。

为了解决这个问题,我们首先使用以下方法从记录中获取内容:

var original = record.get('content');

接下来,我们对内容进行两次遍历,以查找和替换一些图像信息,这样我们就可以按我们的意愿调整大小。第一次遍历看起来像这样:

var converted = original.replace(/src=\"/g, 'src=\"http://src.sencha.io/120/');

这将把一个带有src链接的图像标签mydomain.com/images/image15.png转换成http://src.sencha.io/120/http://mydomain.com/images/image15.png

此格式将抓取图像并将其通过src.sencha.io进行处理,然后再显示。120 的值表示图像将自动调整大小,最大宽度为 120 像素,大约是典型手机屏幕大小的一半。

小贴士

src.sencha.io

src.sencha.io 可以用来即时调整任何图像的大小。此服务有许多有用的功能,更多信息可以在docs.sencha.io/current/index.html#!/guide/src找到。

第三次也是最后一次转换,使用正则表达式匹配并移除值,从<img>标签中移除原始的高度和宽度配置。默认情况下,WordPress 会在将图片作为帖子或页面的一部分包含时插入heightwidth标签:

var final = converted.replace(/((width|height)\s*=\s*"*\d+"*)/g, '');

如果我们只使用我们第一段代码来调整实际图像的大小而不移除高度和宽度配置,图像实际上将以原始大小显示,只是像素化且看起来很丑陋。

小贴士

正则表达式

正则表达式,或Regexes,是匹配字符串模式的一个极其有价值的工具。您可以在www.rexv.org/了解更多有关正则表达式的信息。

一旦我们完成了所有的转换,我们将记录的内容值设置为我们的新改进的值:

record.set('content', final);

转换完成后,我们需要确定需要哪个详情容器。幸运的是,我们通过itemTap处理程序传递了被点击的视图。我们可以使用这个来检查哪个数据视图是活动的:

if(dataview.id == 'postList') {
  var details = Ext.create(
   'Evolver.view.phone.PostDetails', {
    title: record.get('title'),
    data: record.data
  });
 } else {
  var details = Ext.create(
   'Evolver.view.phone.PageDetails', {
    title: record.get('title'),
    data: record.data
  });
 }

如果我们有postList,我们需要创建一个新的帖子详情容器,如果没有,我们想要创建一个新的页面详情容器。一旦我们有了新的容器,我们就用以下代码将其推送到活动项上:

this.getMainView().getActiveItem().push(details);

我们使用this.getMainView()通过我们之前创建的引用来获取我们的主视图。通过使用getActiveItem(),我们确保获取用户正在查看的数据视图,并将容器推送到正确的导航视图中。

这就是手机配置文件Main.js控制器文件的全部内容。Main.js控制器的平板电脑版本与手机版本完全相同,除了onListItemTap()函数,它看起来是这样的:

onListItemTap: function(dataview, index, target, record, e, options) {
 var original = record.get('content');
 var converted = original.replace(/src=\"/g, 'src=\"http://src.sencha.io/240/');
 var final = converted.replace(/((width|height)\s*=\s*"*\d+"*)/g, '');
 record.set('content', final);
 var pageDetails = this.getMainView().down('pagedetails');
 var postDetails = this.getMainView().down('postdetails');
 if(dataview.id == 'pageList') {
  postDetails.hide();
  pageDetails.setRecord(record)
  pageDetails.show();
 } else {
  pageDetails.hide();
  postDetails.setRecord(record)
  postDetails.show();
 }
}

在这个函数中,我们以与手机版本中相同的方式进行转换。这次我们将最大宽度增加到 240 像素。

接下来,我们使用以下代码获取我们的两个详情容器:

 var pageDetails = this.getMainView().down('pagedetails');
 var postDetails = this.getMainView().down('postdetails');

一旦我们有了这些,我们就使用与之前相同的方式使用dataview id来获取当前活动的数据视图。然后我们在添加记录以供显示后隐藏一个并显示另一个。

如从两个控制器中可以看到,我们可以完全指定应用程序中的函数,无论视图本身是否在配置文件之间共享。结合根据特定配置文件包含不同视图的能力,我们可以轻松地将应用程序针对特定平台的优势,并克服任何潜在弱点。

然而,尽管这很强大,我们还可以使用另一个技巧来进一步根据平台或设备自定义我们的应用程序,那就是条件样式。

条件样式

你应该熟悉使用 CSS 样式表根据idclass来控制网页元素的外观。由于这些可以应用于 Sencha 组件和 xTemplates 中,我们可以使用这些类和 ID 来控制应用程序的外观。我们通过使用媒体查询检查设备的宽度来实现这一点。

媒体查询

媒体查询实际上是 CSS 标准的一部分,而不是 Sencha Touch 的直接部分。然而,由于 Sencha Touch 使用 CSS,我们可以继承这个工具,并使用它作为一个根据网页显示的环境做出决策的简单方法。虽然这个功能已经存在了一段时间,但直到最近它才在所有网络浏览器中成为标准。幸运的是,对于我们的 Sencha Touch 来说,它只支持现代网络浏览器,所以标准的较晚采用对我们没有影响。

如果你过去几年使用了大量的 CSS,你可能已经注意到了在样式表链接中使用媒体的方式,如下所示:

<link rel="stylesheet" type="text/css" href="main.css" media="screen" />
<link rel="stylesheet" type="text/css" href="print.css" media="print" />

此设置将使用 main.css 在浏览器中显示,并在打印页面到打印机时使用 print.css。这通常用于在打印时移除导航和多余的页面元素。

然而,这些相同的媒体查询也可以根据屏幕大小包含样式表,如下所示:

<link rel="stylesheet" type="text/css" media="screen and (max-device-width: 480px)" href="iPhone.css" />

如果设备使用的是最大屏幕宽度为 480px 的网络浏览器(屏幕)(如 iPhone 3G),则此媒体查询将加载样式表。

我们甚至可以更进一步,根据方向更改样式表:

<link rel="stylesheet" type="text/css" media="screen and (max-device-width: 480px) and (orientation:portrait" href="iPhonePortrait.css" />
<link rel="stylesheet" type="text/css" media="screen and (max-device-width: 480px) and (orientation:landscape" href="iPhoneLandscape.css" />

当 iPhone 以纵向模式持有时,这两个链接将包含 iPhonePortrait.css,当以横向模式持有时,将包含 iPhoneLandscape.css

将这些 CSS 媒体查询与 Sencha Touch 的配置文件结合使用,使我们能够非常细致地针对特定设备。

概述

当为移动环境设计应用程序时,充分利用设备的任何特殊功能并避免设备可能存在的任何潜在缺陷至关重要。通过直接针对设备定制应用程序,您提供更好的整体用户体验。在本章中,我们通过以下要点展示了如何充分利用这一能力:

  • 配置文件的基本用法

  • 设置 WordPress 以与 Sencha Touch 一起工作

  • 创建基本的 Evolver 应用程序

  • 设置控制器以管理手机和平板设备上的功能

  • 使用媒体查询进一步针对特定设备样式化您的应用程序

在下一章中,我们将探讨如何访问您设备的一些硬件功能,特别是摄像头。我们还向您展示如何编译您的应用程序以利用更多设备的功能。

第九章。工作簿:使用摄像头

到目前为止,移动设备最常见的特点是摄像头。很难想象一个没有摄像头的移动设备。一个高质量的应用程序需要能够利用这个功能,在本章中,我们将向您展示如何实现。

在本章中,我们将构建一个基本的工作簿应用程序,您可以在其中:

  • 创建笔记本

  • 为每个笔记本添加笔记

  • 为每个笔记添加图片

我们还将使用拼贴布局数据视图来增强应用程序的外观,并讨论将图片发送到其他应用程序(如 WordPress 或 Sencha.io 存储系统)的方法。

设计基本应用程序

对于这个应用程序,我们将有“书籍”包含“笔记”,这意味着我们只有在用户点击特定书籍时才会看到笔记。在这种情况下,标签页界面可能没有太多意义,所以我们将使用导航视图在书籍列表和特定书籍的笔记列表之间切换。

我们还希望超越传统的列表,使用带有一些图标的拼贴视图。这将给我们带来类似这样的效果:

设计基本应用程序

当用户点击其中一本书时,他们将看到一个类似的屏幕,显示该特定书籍的笔记。我们将有一个添加按钮用于新书籍和一个添加按钮用于新笔记。由于导航视图使用相同的标题栏来显示两个视图,我们需要根据我们正在查看的视图在两个添加按钮之间切换。

我们还需要为我们的书籍和笔记创建表单。笔记表单还需要一个按钮,允许我们使用设备摄像头拍照,或者从设备的照片库中选择照片。

在数据方面,我们需要为我们的书籍设置一个标题和一个 ID。我们的笔记也将有一个标题和一个 ID,我们还需要为笔记设置字段,一个图片字段,以及一个书籍 ID 来告诉我们笔记属于哪本书。

由于我们已经清楚我们需要哪些数据,让我们先设置我们的模型和存储。

创建模型和存储

我们需要处理的两个组件中,书籍要简单得多,所以让我们从这里开始。我们的书籍模型看起来像这样:

Ext.define('Workbook.model.Book', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'title',
                type: 'string'
            }
        ]
    }
});

我们的bookstore.js文件同样简单,并使用本地存储代理来存储我们的数据。由于书籍列表是用户首先看到的东西,我们也希望这个存储自动加载。因此,在这种情况下,我们将使用以下代码:

Ext.define('Workbook.store.BookStore', {
    extend: 'Ext.data.Store',
    requires: [
        'Workbook.model.Book'
    ],
    config: {
        model: 'Workbook.model.Book',
        autoLoad: true,
        storeId: 'BookStore',
        proxy: {
            type: 'localstorage',
            id  : 'books'
        }
    }
});

我们的笔记需要与我们的书籍相关联,所以它们将有一个关联的bookID以及它们自己的唯一 ID 属性:

Ext.define('Workbook.model.Note', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {
                name: 'id',
                type: 'int'
            },
            {
                name: 'bookID',
                type: 'int'
            },
            {
                name: 'title',
                type: 'string'
            },
            {
                name: 'dateModified',
                type: 'date'
            },
            {
                name: 'notes',
                type: 'string'
            },
            {
                name: 'image',
                type: 'string'
            }
        ]
    }
});

我们还有标题、dateModifiednotesimage字段。当创建新笔记或更新并保存现有笔记时,我们将dateModified设置为当前日期。

我们的noteStore.js文件看起来和我们的书店文件很相似,但我们不希望这个文件自动加载,所以我们设置autoLoadfalse

Ext.define('Workbook.store.NoteStore', {
    extend: 'Ext.data.Store',
    requires: [
        'Workbook.model.Note'
    ],
    config: {
        model: 'Workbook.model.Note',
        storeId: 'NoteStore',
        autoLoad: false,
        proxy: {
            type: 'localstorage',
            id  : 'notes'
        }
    }
});

现在我们知道了我们正在处理什么类型的数据,我们需要考虑如何显示这些数据。

视图

对于我们的应用程序视图,我们需要为书籍创建一个列表视图和编辑视图。当用户点击一本书时,他们会看到笔记列表。我们还需要一个用于编辑笔记的表单和一个用于查看笔记的详细视图。

让我们从书籍视图开始。

创建书籍视图

第一本书的视图是我们的书籍列表。我们不会简单地使用列表,而是会使用一个 DataView 和图标来给我们的列表增添一些视觉吸引力:

创建书籍视图

这个布局将使用 xTemplate(tpl)和 CSS 样式来创建:

Ext.define('Workbook.view.bookList', {
    extend: 'Ext.dataview.DataView',
    alias: 'widget.booklist',
    config: {
        title: 'Workbooks',
        styleHtmlContent: true,
        scrollable: {
         direction: 'vertical',
         directionLock: true
        },
        emptyText: 'You don\'t have any Workbooks. Click the Add button at the top of your screen to add a new Workbook',
        store: 'BookStore',
        id: 'bookList',
        itemTpl: '<img src="img/book.png" /><h4>{title}</h4>',
        itemCls: 'bookItem'
    }
});

我们从booklist.js文件开始,扩展标准的Ext.dataview.DataView并添加我们的配置选项。我们设置了一个标题,并允许在面板中使用样式化的 HTML 内容。我们还设置了滚动,并提供了directionLock,使其只能在一个方向上滚动。

接下来,我们添加一些指示用户如何添加书籍的空文本,并完成我们的itemTplitemClsitemTplitemCls将用于在视图中定位我们的每本书。

默认情况下,当 DataView 以 HTML 渲染时,我们 DataView 中的每个项目都被一个带有.x-dataview-item CSS 类的div标签包裹。它看起来像这样:

<div class="x-dataview-item">Our Book Item</div>

可能的话,我们只需在.x-dataview-item上设置样式,但这将改变我们使用的每个 DataView 的样式。通过设置itemClsdiv标签现在看起来是这样的:

<div class="x-dataview-item bookItem">Our Book Item</div>

这意味着我们现在可以样式化bookItem类,而不会影响我们的其他数据视图。我们通过在我们的 CSS 文件中放置以下内容来样式化bookItem类:

.bookItem {
  width: 140px;
  display: inline-block;
  clear: none;
  margin: 10px;
  text-align: center;
  vertical-align: top;
}
.bookItem img {
    margin-left: auto;
    margin-right: auto;
  }
 .bookItem h4 {
    margin-bottom: 0px;
  }

这种样式数据决定了每个项目的宽度,并将它们从左到右平铺在屏幕上。它还设置了我们的边距,并使文本和图标居中。

接下来,我们需要创建一个用于添加书籍的视图,使用一个form组件:

Ext.define('Workbook.view.bookEdit', {
    extend: 'Ext.form.Panel',
    alias: 'widget.bookedit',
    config: {
        items: [
            {
                xtype: 'container',
                html: 'Please enter a book name below:',
                id: 'bookEditText',
                margin: 8,
                style: 'text-align:center;'
            },
            {
                xtype: 'textfield',
                id: 'bookName',
                name: 'title',
                label: 'Title'
            },
            {
                xtype: 'hiddenfield',
                id: 'bookID',
                name: 'id'
            }
        ]
    }
});

这个表单有一个用于说明的容器,一个用于用户输入书籍名称的文本字段,以及一个hiddenfield组件,当我们编辑现有的书籍时,我们将添加书籍的id值。

我们还将在表单中添加两个按钮;一个保存按钮和一个取消按钮。在这个例子中,我们将在视图内部为每个按钮设置处理程序。如果你更喜欢将此代码移动到控制器中,我们稍后会展示如何这样做。现在,让我们首先添加保存按钮:

{
 xtype: 'button',
 margin: 8,
 id: 'saveBookButton',
 ui: 'confirm',
 text: 'Save Book',
 handler: function() {
  var form = this.up('formpanel');
  var store = Ext.getStore('BookStore');
  var values = form.getValues();
  if(values.id > 0) {
   var index = store.find('id', values.id);
   var record = store.getAt(index);
   record.set(values);
  } else {
   var record = Ext.ModelMgr.create(values, 'Workbook.model.Book'); 
  store.add(record);
  }
  store.sync();
  var main = this.up('navigationview');
  main.pop(form);
 }
}

对于button元素的基本设置现在应该对你来说很熟悉了。当按钮被点击时,handler组件会自动触发。

这个函数获取我们的storeform,值,以及values变量的值。然后我们检查id值是否是一个大于零的数字。这种情况只会发生在我们将现有的书籍记录加载到表单中进行编辑时。

如果 id 值大于零(我们有一个现有的书籍),我们从商店中获取当前的 record 值,并用我们表单的新值替换它。

如果 id 值为 null(它是一本书),我们使用我们的书籍模型创建一个新的记录,插入表单的 values 变量,并将记录添加到商店中。

我们随后同步商店以保存我们的更改,并从主 navigationview 中移除书籍表单。

最后,我们在“保存”按钮之后添加了一个 取消 按钮。这个按钮只需要从我们的 navigationview 中移除表单:

{
 xtype: 'button',
 margin: 8,
 ui: 'decline',
 text: 'Cancel',
 handler: function() {
  var form = this.up('formpanel');
  var main = this.up('navigationview');
  main.pop(form);
 }
}

现在我们已经在我们的应用程序中创建了添加和显示书籍的视图,我们需要创建我们的 main.js 视图,该视图将在应用程序启动时启动。

将书籍列表添加到主视图

我们的主视图需要显示书籍列表,并且需要一个按钮来显示我们的添加书籍表单。正如我们之前所提到的,我们将使用 navigationview 来实现这个主要组件:

Ext.define("Workbook.view.Main", {
 extend: 'Ext.NavigationView',
 requires: ['Ext.TitleBar','Ext.dataview.DataView'],
 config: {
  id: 'mainView',
  fullscreen: true,
  navigationBar : {
   docked : 'top',
   items : [
    {
     text : 'Add Book',
     align : 'right',
     id: 'addBookButton'
    }
   ]
  },
  items: [
   { xtype: 'booklist'}
  ]
 }
});

此视图在 app.js 中定义,作为应用程序启动时将被添加到视口中的视图。您会注意到,我们在 Main 组件中已引入了 TitleBarDataView 组件,这是为了防止在构建我们的原生应用程序时出现编译错误。主要的 navigationview 组件还包括我们的书籍列表组件和添加按钮,以显示我们的表单。

小贴士

控制器中的代码与视图中的代码

与我们之前书籍的 form 视图不同,我们将“添加书籍”按钮的功能代码放在控制器中。将视图的功能放在控制器中通常被认为是“最佳实践”,但重要的是要理解这可以通过多种不同的方式来完成。

现在我们已经创建了主视图,让我们跳转到 Book.js 控制器,并设置好以测试到目前为止我们所做的工作。

启动书籍控制器

书籍控制器将从我们创建的视图、模型和商店开始。它还将设置我们的引用、初始控件和两个函数:

Ext.define('Workbook.controller.Book', {
    extend: 'Ext.app.Controller',
    config: {
        stores: ['BookStore'],
        models: ['Book'],
        views: ['bookEdit', 'bookList'],
        refs: {
            bookList: '#bookList',
            addBookButton: '#addBookButton',
            main: '#mainView'
        },

        control: {
            addBookButton: {
                tap: 'onAddBookButtonTap'
            },
            bookList: {
                select: 'onBookSelect'
            }
        }
    },
    onAddBookButtonTap: function(button, event, options) {
        var bookForm = Ext.create('Workbook.view.bookEdit');
        this.getMain().push(bookForm);
    },
    onBookSelect: function(dataview, record, options) {
        console.log(dataview, record, options);
    }
});

我们的 book.js 控制器文件需要将我们的 bookEdit 表单推送到 Main 导航视图中。我们通过创建一个引用 (refs) 为 addBookButton 组件使用其 id 属性。然后我们在 controls 部分为按钮的 tap 事件分配一个函数。

onAddBookButtonTap 函数创建我们 bookEdit 表单的新实例,并将其推送到我们的 Main 导航视图中。这将使表单出现,并在页面顶部添加一个返回按钮。

我们还为 bookList 表单添加了 refscontrols,包括一个 onBookSelect 函数。然而,我们没有其他视图,因此无法显示书籍的笔记列表。相反,我们添加了一个控制台日志,它将显示当列表中的书籍被点击时传递的 DataView、记录和选项。一旦我们添加了一本书,我们应该能够点击它,并在 Safari 错误控制台中看到显示的信息。

备注

console.log()函数是测试开发早期阶段应用程序的绝佳方式。它可以帮助你及早发现问题并处理它们,在你构建整个项目之前。

如果你现在测试项目,你应该会得到初始的空书屏幕,并且能够添加新的书籍:

启动书控制器

如果你添加了一本书并在书列表中轻触它,你应该在 Safari 错误控制台中看到以下截图类似的内容:

启动书控制器

从左到右,这些对象是DataView(我们的书列表)、记录(被轻触的书)和选项(传递给监听器的对象——真正的技术宅内容)。

我们将在稍后使用记录对象中的信息来告诉我们显示哪些笔记。现在我们需要为我们的笔记创建视图。

创建笔记视图

我们需要为我们的笔记创建三个不同的视图:列表视图、编辑视图和详情视图。我们将从列表视图开始,因为它与我们的书列表类似:

Ext.define('Workbook.view.noteList', {
    extend: 'Ext.dataview.DataView',
    config: {
        id: 'noteList',
        itemId: 'noteList',
        styleHtmlContent: true,
        scrollable: {
            direction: 'vertical',
            directionLock: true
        },
        itemTpl: '<img src="img/note.png" /><h4>{title}</h4><h5>{dateModified:date("m/d/Y, g:i a")}</h5>',
        store: 'NoteStore',
        emptyText: 'You don't have any Notes in this Workbook. Click the Add button at the top of your screen to add a new Note to the Workbook',
        title: 'Notes For'
    }
});

我们覆盖了DataView,设置了我们的 ID 和滚动,就像我们在书列表中做的那样。在itemTpl配置中,我们添加了笔记修改的日期,使用date()函数将其转换为比默认格式更短的格式。我们还设置了我们的空文本和默认标题。我们的书控制器将更新标题并显示当前笔记的书名。

我们将使用与我们的书容器相同的基本样式,使笔记列表像书列表一样横跨屏幕。

创建笔记视图

当用户在列表中轻触笔记时,我们需要显示笔记的详细信息,包括文本和笔记的图片。我们的noteDetails.js视图是一个简单的面板,使用 xTemplate:

Ext.define('Workbook.view.noteDetails', {
    extend: 'Ext.Container',
    alias: 'widget.notedetails',
    config: {
        layout: 'fit',
        scrollable: {direction: 'vertical', directionLock: true},
        tpl: '<h1>{title}</h1><img src="img/png;base64,{image}" /><h5>{date}</h5><div class="notes">{notes}</div>'
    }
});

不要过分担心此代码中的data:image/png;base64部分。我们将在本章的图像入门部分介绍 base64 图像格式。

我们笔记的编辑视图将包含标题和笔记文本字段。同时,还将有隐藏字段,如bookIDimage和笔记的id字段(这些字段的值将由我们的控制器设置)。

Ext.define('Workbook.view.noteEdit', {
 extend: 'Ext.form.Panel',
 alias: 'widget.noteedit',
 config: {
  items: [
   {
    xtype: 'container',
    html: 'Please enter a note title, notes and select an image below:',
    id: 'noteEditText',
    margin: 8,
    style: 'text-align:center;'
   }, {
    xtype: 'button',
    text: 'Select Image',
    id: 'imageSelectButton',
    width: 220,
    style: 'margin-top: 10px; margin-right:auto; margin-left:auto; margin-bottom: 15px;'
   }, {
    xtype: 'container',
    id: 'imageView',
    width: 200,
    height: 200
   },
   {
    xtype: 'hiddenfield',
    id: 'imageField',
    name: 'image',
    value: ''
   },
   {
    xtype: 'textfield',
    id: 'noteTitle',
    name: 'title',
    label: 'Title'
   },
   {
    xtype: 'hiddenfield',
    id: 'bookID',
    name: 'bookID',
    value: 0
   },
   {
    xtype: 'hiddenfield',
    id: 'noteID',
    name: 'id',
    value: 0
   },
   {
    xtype: 'textareafield',
    id: 'notesArea',
    name: 'notes',
    label: 'Notes',
    value: ''
   }
  ]
 }
});

我们还将有一个用于选择图片的按钮和一个用于显示所选图片的容器。当按钮被轻触时,我们将从设备中选择一个图片,该图片将作为 base64 字符串返回给我们。这个字符串将被设置为我们的隐藏image字段的值。

我们还将有两个按钮,就像我们的书编辑表单一样,一个用于保存,一个用于取消。取消按钮与之前的书编辑按钮完全相同,它只是从导航视图中弹出表单。

保存按钮有一点不同,因为它需要在每次保存笔记时设置日期修改的值:

{
 xtype: 'button',
 margin: 8,
 ui: 'confirm',
 text: 'Save',
 id: 'saveNoteButton',
 handler: function() {
  var form = this.up('formpanel');
  var store = Ext.getStore('NoteStore');
  var values = form.getValues();
  if(values.id > 0) {
   var index = store.find('id', values.id);
   var record = store.getAt(index);
   record.set(values);
 var date = new Date();
 record.set('dateModified', date);
  } else {
   var record = Ext.ModelMgr.create(values, 'Workbook.model.Note');
 var date = new Date();
 record.set('dateModified', date);
  }
  store.add(record);
  store.sync();
  var main = this.up('navigationview');
  main.pop(form);
 }
}

除了这些,按钮基本上与我们的书籍保存按钮相同。我们获取存储和表单值,检查我们是否正在处理新的笔记或现有的笔记,并相应地保存笔记。

在设置好这些基本视图后,是时候回到我们的控制器,将所有东西连接在一起了。

创建控制器

在我们的控制器中,我们首先需要更新 config 部分,为我们的应用程序添加新的视图、存储和模型。我们还需要为这些新组件添加一些新的引用和控制。

config: {
  stores: ['BookStore', 'NoteStore'],
  models: ['Book', 'Note'],
  views: ['bookEdit', 'noteEdit', 'noteList', 'bookList'],
  refs: {
   bookList: '#bookList',
   noteList: '#noteList',
   addBookButton: '#addBookButton',
   addNoteButton: '#addNoteButton',
   imageSelectButton: '#imageSelectButton',
   main: '#mainView'
  },
  control: {
   addBookButton: {
    tap: 'onAddBookButtonTap'
   },
   addNoteButton: {
    tap: 'onAddNoteButtonTap'
   },
   imageSelectButton: {
    tap: 'onImageSelectButtonTap'
   },
   bookList: {
    select: 'onBookSelect'
   },
   noteList: {
    select: 'onNoteSelect'
   },
   main: {
    back: 'onBackClicked'
   }
  }
 }

refs 部分将设置我们为创建的新笔记组件设置快捷键,而控制部分将为以下操作添加新功能:

  • 点击列表中的笔记

  • 点击按钮添加图片

  • 在列表中选择书籍

  • 在列表中选择笔记

  • 在应用程序的任何地方点击返回按钮

现在,我们已经有了 refscontrol,让我们开始创建应用程序所需的函数。

onBookSelect 函数与书单的 select 事件(在 controls 部分中)相关联。select 事件将自动将其参数中选中的记录传递过来。我们将使用该记录中的书名来设置我们新的笔记列表视图的 title 属性。我们还将使用此记录的 id 属性来限制笔记的 store 只包含该书的笔记:

onBookSelect: function(dataview, record, options) {
  console.log(dataview, record, options);
  var noteList = Ext.create('Workbook.view.noteList', {title: record.get('title')});
  var bookID = record.get('id');
  this.getMain().push(noteList);
  this.getAddNoteButton().show();
  this.getAddBookButton().hide();
  var noteStore = noteList.getStore();
  noteStore.filter("bookID", bookID);
  noteStore.load();
  noteList.bookID = bookID;
}

注意,我们在创建笔记列表的新实例时,将 title 值作为配置选项传递。这通常是在创建新对象时设置额外参数的一种便捷方式。

接下来,我们将新的 noteList 对象推送到 Main 导航视图,并使用 show/hide 函数将 AddBook 按钮与 AddNote 按钮进行交换。

然后,我们使用 bookID 值来过滤存储,仅限制当前书籍的笔记,并加载存储。

我们还在 noteList 上添加了 bookID 作为配置选项。这将使我们能够轻松地在我们添加新笔记时获取 bookID

接下来我们需要处理的是 返回 按钮。此按钮由导航视图自动创建,并且它将自动从导航视图堆栈中弹出当前视图,返回到上一页。

然而,这里有三个问题:

  • 当我们切换回上一视图时,我们需要隐藏 添加笔记 按钮并显示我们的 添加书籍 按钮。

  • 当我们切换回书单时,我们仍然在笔记存储上有过滤器。如果我们选择不同的书籍,这将会搞乱事情。

  • 当我们切换回书单时,最初选择的书籍仍然被选中。这意味着如果你再次点击同一本书,select 事件将不会触发。这同样适用于我们的笔记列表。

这意味着我们需要连接到我们的导航视图上的后退事件并修复这些问题:

onBackClicked: function(button, options) {
 var store = Ext.getStore('NoteStore');
 var activeItem = this.getMain().getActiveItem();
 if(activeItem.id == 'bookList') {
  this.getAddNoteButton().hide();
  this.getAddBookButton().show();
  this.getBookList().deselectAll();
  store.clearFilter();
 } else if(activeItem.id == 'noteList') {
  this.getAddNoteButton().show();
  this.getAddBookButton().hide();
  this.getNoteList().deselectAll();
 }
}

我们必须确定在 back 事件触发后哪个项目是激活的。

如果是书单,我们将隐藏AddNote按钮,显示AddBook按钮,取消选择bookList中的所有项,并清除存储上的过滤器。

如果noteList是活动的,则用户是从笔记详情返回的。我们仍然需要取消选择noteList中的所有项并显示正确的按钮,但我们需要保持存储上的过滤器。

下一个函数将用于创建一个noteEdit表单并向其中添加一些初始值:

onAddNoteButtonTap: function(button, event, options) {
 var noteForm = Ext.create('Workbook.view.noteEdit');
 this.getMain().push(noteForm);
 var record = Ext.create(
 'Workbook.model.Note', {
  title: '',
  note: '',
  bookID: this.getNoteList().bookID
 });
 noteForm.setRecord(record);
 }

onAddNoteButtonTap函数中,我们创建一个noteEdit表单的新实例并将其推送到Main导航视图。我们还基于我们的Note模型创建一个新记录并设置bookID值。最后,我们使用setRecord()将记录加载到表单中。

接下来,我们需要一个简短的函数来将笔记的详细信息面板推送到我们的主导航。这是通过我们的onNoteSelect函数实现的:

onNoteSelect: function(dataview, record, options) {
 var noteDetails = Ext.create('Workbook.view.noteDetails');
 this.getMain().push(noteDetails);
 noteDetails.setRecord(record);
}

现在我们已经完成了基本函数,我们最终可以开始将图像添加到我们的应用程序中。

开始使用图像

在应用程序中使用图像有几个重要事项需要注意。首先,应用程序必须是一个编译后的应用程序,以便此功能能够工作。你还需要在app.js中的requires部分添加Ext.device.Camera。由于此功能仅在编译后的应用程序中使用,因此默认情况下不包括该文件。

注意

由于我们在第三章中已经介绍了编译应用程序,即“命令行操作”,对于我们的 TimeCop 应用程序,我们在此不再重复介绍。

由于安全原因,JavaScript 无法访问移动设备上的文件系统。只有通过编译应用程序,我们才能绕过这一限制并使用相机或访问设备上的现有照片。这意味着在实际编译应用程序之前,对应用程序的实际测试可能有些受限。

当你在网页浏览器中进行测试时,Sencha Touch 会返回一个占位符图像链接以供测试。然而,图像可以以两种不同的格式返回。

第一种格式是file或 URI。这基本上是现有文件的链接,其实施可以在不同设备上有所不同。第二种格式是data,它是一个 base64 编码的字符串。

file格式通常可以用作图像链接中的src。例如:

<img src="img/file_URI_here" />

然而,base64 data需要稍有不同的格式,你可能还记得我们的noteDetails.js文件:

<img src="img/png;base64,{image}" />

这种 base64 data格式允许我们动态控制图像格式并将值作为字符串存储在数据库中。

注意

Base64 数据是一种允许你将文件作为文本字符串传输的格式。这使得我们可以将图像等东西作为我们 JSON 数据的一部分。它还允许我们将字符串作为数据库中的数据部分存储。

如果我们使用文件 URI,并且用户从设备中删除了图像,URI 也将从我们的应用程序中消失。虽然在某些情况下这可能是一种期望的行为,但更常见的情况并非如此。

我们还需要考虑源图片是从哪里来的。它是从相机本身来的,还是从存储的照片,或者是从特定的相册来的?

所有这些选项以及更多都是在capture函数中处理的。

捕获图片

让我们看看图片capture()函数是如何在我们的Book.js控制器中的onImageSelectButtonTap函数中工作的:

onImageSelectButtonTap: function(button, event, options) {
 var imageView = Ext.getCmp('imageView');
 var imageField = Ext.getCmp('imageField');
 Ext.device.Camera.capture({
  success: function(image) {
   imageView.setHtml('<img src="img/png;base64,'+image+'" width=200px height=200px />');
   imageField.setValue(image);
  },
  quality: 100,
  destination: 'data'
 });
}

我们首先获取我们的imageView变量(在图片被选中后我们将放置图片副本的地方)和我们的imageField变量(将存储我们图片数据的隐藏字段)。您可以在下面的屏幕截图中看到我们的完整表单。这显示了我们的按钮、选中的图片和两个表单字段:

捕获图片

Ext.device.Camera.capture函数有一个内部的success函数,将图片传递给它。图片的格式由destination配置设置,可以是data(base64)或file(URI)格式。

success函数是我们处理接收到的图片信息的地方。在这种情况下,我们将我们的imageView容器(在图片被选中后我们将放置图片副本的地方)的 HTML 设置为 200x200 像素的图片。这为用户提供了一个在保存之前的预览。

我们还将我们的隐藏imageField组件的值设置为 base64 编码的字符串,以便它将与我们的其他表单值一起保存。

然而,在我们保存之前,我们可以在图片上设置一系列选项:

  • quality: 这指定了图片质量,范围从 1 到 100。

  • source: 图片应该从哪里来?选项有cameralibraryalbum

  • encoding: 可用的编码格式有pngjpg

  • height: 这指定了图片的高度(以像素为单位)。

  • width: 这指定了图片的宽度(以像素为单位)。

这些选项中的任何一个都可以在捕获函数中设置,但最好记住,qualityheightwidth将在图片可能被存储之前应用于图片。限制这些值可能会随后限制图片在之后的可用性。将大图片变小总是比将小图片变大容易。

存储图片

在我们的onImageSelectButtonTap函数中,我们将隐藏字段的值设置为 base64 编码的data字符串。如果我们选择了destination: file而不是destination: data,我们仍然可以做很多相同的事情。图片作为我们之前在章节中构建的noteEdit.js中的保存按钮处理程序的一部分被保存。

然而,file选项只会存储图片文件的引用。正如我们之前提到的,如果用户使用他们的照片管理器从设备中删除图片,它也会从我们的应用程序中消失。

data选项为我们提供了图片本身的实际数据。这意味着如果用户使用他们的照片管理器从设备中删除图片,它不会影响我们应用程序中存储的图片。

显示图片

一旦您有了存储的图像,您就可以像我们之前描述的那样在您的 xTemplates 中使用它。

文件格式通常可以用作图像链接中的src,如下所示:

<img src="img/file_URI_here" />

base64 数据格式的使用方式如下:

<img src="img/png;base64,{image}" />

正如我们也提到的,当您在网页浏览器中测试时,Sencha Touch 将返回一个测试图像链接(www.sencha.com/img/sencha-large.png)。

当在浏览器中测试时返回的测试图像将正常工作,但如果您使用的是文件目标格式,并且使用的是数据目标格式,则会显示一个缺失的图像。在编译的应用程序中,图像将正确显示。

在这些图像中还需要注意的另一件事是,您可以使用标准的img heightwidth标签将图像缩小到特定的屏幕大小。例如:

var imageWidth = Ext.Viewport.getWindowWidth();
var imageString = '<img src="img/png;base64,{image}" width='+imageWidth' />';

如果您存储全尺寸图像并以不同的方式使用它,这将为您带来极大的灵活性。

发送图像

如果您的应用程序需要传输图像,无论是发送给其他用户还是外部 API,您将需要使用数据目标格式。如前所述,文件目标格式只是一个参考,它与应用程序运行的设备相关。

由于数据格式是 base64,它可以像任何其他字符串数据一样传输。不幸的是,目前还没有办法在不构建自己的自定义 API 来接受 base64 数据字符串并将其转换为图像文件的情况下,在本地上传文件到远程服务器。

然而,如果您使用 PhoneGap 编译器而不是本地的 Sencha Touch 编译器来编译您的应用程序,您可以使用他们的fileTransfer对象将文件作为标准的 HTTP POST 发送。您可以在docs.phonegap.com/en/1.0.0/phonegap_file_file.md.html#FileTransfer找到更多关于 PhoneGap 和fileTransfer对象的信息。

PhoneGap 提供了一个在线编译服务,可以从 Sencha Touch 代码创建原生应用程序,就像我们在前一章中介绍的 Sencha Touch 命令工具一样。

更多关于 PhoneGap 的信息可以在www.phonegap.com/找到。

摘要

在本章中,我们讨论了如何设置您的应用程序以利用您的移动设备上的相机。通过这种方式,我们涵盖了以下要点:

  • 如何使用 DataViews 创建不同外观的 UI

  • 数据文件图像格式之间的区别

  • 使用 Sencha Touch 与设备的相机和本地照片存储进行交互

在下一章中,我们将更广泛地使用 DataViews 来创建多人游戏的游戏板。

第十章。游戏开始

快速浏览任何在线应用商店都会迅速显示,移动应用程序市场最大的部分属于游戏。虽然大多数程序员在开发游戏时不会想到 JavaScript,但实际上它非常适合各种游戏,包括回合制策略游戏。

这些游戏只需要有限的动画,并且可以很容易地使用 Sencha Touch 框架和 Sencha.io 平台进行通信。对于回合制策略游戏,我们只需要做几件基本的事情,例如:

  • 构建游戏棋盘

  • 构建单个棋子

  • 处理移动

  • 处理一个棋子攻击另一个棋子的结果

  • 处理玩家在回合结束时移动的通信

  • 定义游戏的结束

虽然这种游戏风格可能看起来很微不足道,但它涵盖了从井字棋到国际象棋、扑克、围棋、风险以及互联网时代之前的复杂桌面策略游戏(如轴心国与同盟国)的一切。

备注

如果你真的想了解这些游戏有多复杂,请查看 boardgamegeek.com/ 并查看策略部分。

由于我们没有一本整本书来专门讨论这个单一主题,我们将从一个相对简单的跳棋游戏开始。我们还将探讨一些将这个简单游戏扩展并创建更复杂游戏的可能方法。

构建基本棋盘

在任何类型的回合制策略游戏中,一切始于棋盘。棋盘决定了棋子放置的位置以及它们可以移动的位置。

跳棋或国际象棋的棋盘由一个 8x8 的方格网格组成。方格在浅色和深色(通常为跳棋棋盘的红色和黑色)之间交替。

此外,只有深色方格可以被跳棋的棋子使用。

你可以使用许多不同的 Sencha Touch 组件来创建这样的棋盘,但出于这些目的,一个 DataView 可能是最合适的选择。DataView 将允许我们点击并选择我们想要移动的棋子以及我们想要将其移动到的位置。这些选择方法已经内置在 DataView 中。我们还可以根据这些选择应用样式,让用户知道哪些移动是有效的。

创建方格模型

我们的 DataView 将由一个我们称为 Square 的模型提供数据。它看起来像这样:

Ext.define('Checkers.model.Square', {
    extend: 'Ext.data.Model',
    config: {
        fields: [
            {name: 'squareID', type: 'string'},
            {name: 'occupiedBy', type: 'string'},
            {name: 'pieceType', type: 'string'},
            {name: 'decoration', type: 'string'},
            {name: 'background', type: 'string'}
        ],
        idProperty: 'squareID'
    }
});

此模型包含五个关键信息:

  • squareID 告诉我们方格在我们棋盘上的确切位置。

  • occupiedBy 的值告诉我们方格目前是否被红色棋子、黑色棋子占据,或者是否为空(none)。

  • pieceType 将告诉我们我们是在处理普通棋子还是国王。

  • decoration 设置将允许我们指示棋子的当前移动路径以及是否有特定的棋子被跳过。

  • background 设置控制棋子的背景颜色。我们将使用它来为 DataView 设置样式。

我们初始加载数据看起来可能像这样:

{squareID: 'A1', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'B1', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'C1', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'D1', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'E1', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'F1', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'G1', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'H1', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'A2', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'B2', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'C2', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'D2', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'E2', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'F2', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'},
{squareID: 'G2', occupiedBy: 'black', pieceType: 'Piece', decoration: '', background: 'dark'},
{squareID: 'H2', occupiedBy: 'none', pieceType: 'none', decoration: '', background: 'light'}…

这将继续,给我们每行八个方格的八行。方格用 A 到 H 表示,行用 1 到 8 编号。这些数据还将按照国际跳棋游戏开始的标准布局排列初始棋子。

你也会注意到,当我们交替背景时,我们保持行的最后一个项目和下一行的第一个项目颜色相同(H1A2都是dark)。这给了我们我们的棋盘图案。

创建方格模型

实际的棋盘图像本身是一个单独的背景图像。我们已经安排了我们的 DataView 覆盖棋盘并与单个方格对齐。这将使我们能够使用 CSS 将元素放置在我们选择的任何方格上。dataview代码作为我们view/Main.js文件中的一个项目包含在内:

{
    xtype: 'dataview',
    itemTpl: ['<div class="gameSquare {background} {decoration}">{squareID}',
              "<tpl if='occupiedBy != \"none\" && pieceType != \"none\"'><img src=\"resources/images/{occupiedBy}{pieceType}.png\" height=\"72\" width=\"72\" /></tpl>",
              '</div>'],
    store: 'BoardStore',
    height: 619,
    width: 619,
    scrollable: false,
    cls: 'board',
    margin: 5,
    padding: 5,
    mode: 'MULTI'
}

这个 DataView 有一个cls值为board,因此我们可以将背景图像设置为我们的resources/css/app.css文件中的大棋盘图像。

探索itemTpl

我们还在itemTpl配置中广泛使用了类。让我们逐行查看模板:

'<div class="gameSquare {background} {decoration}">{squareID}'

第一行设置了一个div元素,其class值为gameSquare。每个gameSquareapp.css文件中设置为:

.gameSquare {
  height: 72px;
  width: 72px;
  margin: 2px;
  float: left;
  position: relative;
}

这将我们的 DataView 中的单个项目设置为与游戏板对齐。通过设置position: relative,我们还可以在gameSquare内绝对定位项目。

我们还添加了一个{background}类。这个值将从我们的数据存储中提取,它将是浅色或深色。我们添加这个类,以便我们可以将深色方格的字体颜色更改为白色。在 CSS 中,这看起来像:

.gameSquare.dark {
    color: white;
}

我们设置的下一个类是decorationdecoration类将用于显示移动时的箭头和当棋子作为移动的一部分将被跳过时的否定符号,如下面的截图所示:

探索 itemTpl

这些图像可以使用before CSS 选择器插入到样式表中。这个选择器将在我们的div元素之前插入内容。在这种情况下,我们将插入一个绿色箭头以指示棋子移动的方向。

例如,一个向上向左移动的棋子将它的decoration值设置为up_left,并在我们的app.css文件中应用以下样式:

.up_left:before {
    content: '';
    background: url("../images/up_left.png");
    height: 32px;
    width: 32px;
    margin: 0;
    padding: 0;
    position: absolute;
    top: -16px;
    left: -16px;
    z-index: 1000;
}

通过使用position: absolute,我们可以将图像的topleft位置设置为任何我们喜欢的值,包括负数。负数将其放置在实际方格的上方和左侧(覆盖左上角的方格)。高z-index值确保图像出现在其他图像和文本之上。

我们为.up_right.down_left.down_right创建了类似的 CSS 样式。这为我们提供了四个对角方向移动的指示器。

注意

我们所有的图片都保存在resources/images目录下。如果你更改图片的位置,你需要调整 CSS 文件以匹配你的设置。

我们还有一个名为removed的装饰类。这个类使用 CSS 选择器afterdiv元素之后插入内容,并在当前移动过程中将被跳过的棋子上的否定符号显示出来。CSS 看起来像这样:

.removed:after {
    content: '';
    background: url("../images/removed.png");
    height: 72px;
    width: 72px;
    position:absolute;
    top: 0;
    left: 0;
    z-index: 1001;
}

它类似于我们箭头的样式,但我们需要这个符号显示在棋子上方。我们将它的topleft属性设置为0,因为图像与我们的方格大小相同,它浮在棋子图像的上方。更高的 z-index 确保它是顶层元素。

itemTpl的下一行控制占据方格的棋子:

<tpl if='occupiedBy != \"none\" && pieceType != \"none\"'><img src=\"resources/images/{occupiedBy}{pieceType}.png\" height=\"72\" width=\"72\" /></tpl>

我们在这里使用tpl if语句来检查方格是否被占据,以及如果被占据,是由哪种类型的棋子占据。我们使用我们两个数据值来确定这个值。

第一个是occupiedBy,可以是redblacknone

第二个是pieceType,可以是regularkingnone

如果两个数据值都设置为none,则我们不在方格中放置棋子。如果我们已经在方格中放置了棋子,我们使用occupiedBypieceType的组合来确定我们的图像。

探索 itemTpl

这些 CSS 值以及我们的 DataView 中的单个方格允许我们通过使用数据存储中存储的值来设置棋盘上每个方格的外观。

我们可以使用 DataView 中的select事件创建单个移动。通过将mode属性设置为MULTI,用户可以点击他们想要移动的棋子,然后点击他们想要移动到的方格。如果他们处于可以跳过多个棋子的位置,他们可以继续点击方格。我们还可以使用 DataView 内置的x-item-selected类来突出显示用户点击的方格。

我们只需将高亮样式添加到我们的样式表中:

.x-item-selected .gameSquare {
    outline: 3px solid rgba(0,175,0,0.75);
    color: rgb(0,175,0);
}
.x-item-selected .gameSquare.dark {
    color: rgb(0,255,0);
}

这给我们一个与箭头匹配的绿色边框,并更改了文本颜色。我们还在我们的深色方格中设置了一个稍微不同的文本颜色以提高可读性。

现在我们已经将所有不同的显示可能性绘制出来,我们需要设置游戏的逻辑。

创建游戏控制器

游戏控制器是我们游戏逻辑所在的地方。在这里,我们将遵循跳棋的基本规则:

  • 首先在棋盘的相对两侧的三行中排列棋子,只位于黑色方格上

  • 棋子可以斜着移动

  • 棋子只能移动到空方格

  • 普通棋子只能向前移动

  • 棋子只能移动一个方格,除非跳过一个相邻的棋子

  • 棋子一次只能跳过一个棋子

  • 被跳过的棋子从棋盘上移除

  • 皇后棋子可以向前和向后移动

  • 一枚到达棋盘另一侧的普通棋子被改变为皇后棋子,结束它们的当前回合

  • 当一方的所有棋子都被跳过并移除后,游戏结束

我们的控制器将检查每一步移动是否符合这些规则,并移除被跳过的棋子。为了本章的目的,我们只创建一个本地游戏。这是一种两个玩家通过互相传递一个单一设备来进行的游戏。然而,这个游戏可以很容易地修改为允许使用 Sencha.io 或外部 API 进行网络游戏。

在我们深入研究控制器代码之前,我们需要在我们的主视图中添加一些东西。我们需要一种方式让每个用户开始和结束一轮。我们还需要一种方式来显示上一轮,以便用户可以看到在上一个移动中发生了什么。

为了做到这一点,我们将在主视图的底部添加一个带有两个按钮的工具栏:

{
    xtype : 'toolbar',
    docked: 'bottom',
    items: [
        {
            xtype: 'spacer'
        },
        { text: 'Start Turn', action: 'mainButton' },
        { text: 'Show Previous', action: 'altButton' },
        {
        xtype: 'spacer'
        }
    ]
}

我们将在控制器中扩展这两个按钮的功能,以便它们还可以允许我们在完成之前改变主意时完成一轮或清除当前选定的移动。

现在我们来看看所有这些在控制器中是如何组合在一起的。

和往常一样,我们首先通过设置 controlrefs 部分来设置我们的控制器:

Ext.define('Checkers.controller.Game', {
 extend: 'Ext.app.Controller',
 config: {
  control: {
   board: {
    select: 'doSelect',
    deselect: 'doDeselect'
   },
   mainBtn: {
    tap: 'doMainBtn'
   },
   altBtn: {
    tap: 'doAltBtn'
   }
  },
  refs: {
   board: 'main dataview',
   mainBtn: 'button[action="mainButton"]',
   altBtn: 'button[action="altButton"]'
  }
 }
});

我们为我们的棋盘和两个按钮创建引用。在 control 部分,我们为棋盘添加 selectdeselect 函数,并为两个按钮添加 tap 函数。mainBtn 函数将开始一轮并执行完成的移动。altBtn 函数将显示上一轮或清除当前选定的移动。我们将适当交换 tap 函数中的文本和功能。

为了跟踪回合,我们将在控制器中添加两个自定义变量。这些变量位于我们的 refs 部分下方(在 config 部分内部):

previousTurn: {
 player: null,
 piece: null,
 moves: [],
 removedPieces: []
},
currentTurn: {
 player: 'black',
 piece: null,
 moves: [],
 removedPieces: [],
 endOfTurn: false,
 hasJumped: false,
 started: false,
 kingable: false
}

previousTurn 变量将存储 player(玩家)、piece(移动的棋子)、moves(移动的步骤——作为一个数组)和 removedPieces(移除的棋子——作为一个数组)。这将允许我们在用户点击 显示上一轮 按钮时突出显示上一轮的方格。

currentTurn 变量存储与 previousTurn 相同的信息,但它还添加了 Boolean 数据用于:

  • endOfTurn: 用户是否确认并完成了当前移动?

  • hasJumped: 用户是否在他们的回合中跳过一个棋子?

  • started: 用户是否按下了 开始回合 按钮?

  • kingable: 用户是否在当前移动中到达了棋盘的对角端?

我们还将玩家的默认值设置为 black,因为在跳棋中黑色传统上先移动。

通过将这些 previousTurncurrentTurn 变量作为 config 的一部分声明,我们自动为两者创建了获取器和设置器。这意味着我们可以在任何控制器函数中执行类似 this.getPreviousTurn()this.setCurrentTurn() 的操作。我们将在控制器中广泛使用这些函数。

理解基本控制器功能

在这个控制器中,我们还将大量使用从属函数的概念。这些是由其他函数调用的函数。虽然一开始可能感觉有些反直觉,但将较大的函数拆分成较小的子函数可以使逻辑更容易理解。

这在游戏逻辑的情况下尤其如此,因为游戏规则可以迅速演变成一系列难以理解的“如果…那么”语句。通过将逻辑拆分成更小的函数,逻辑检查变得更加容易。你可以简单地使用console.log()来输出每个较小函数开始和结束时的值。这使得判断哪些棋子按预期工作变得容易得多。

对于这个应用,我们有一些较小的函数帮助我们处理游戏逻辑。我们不会详细介绍这些较小的函数,但它们可以在controller/Game.js文件中找到。这些函数包括:

  • nextLetterpreviousLetter:给定一个字母和距离,这两个函数会返回序列中的下一个或前一个字母。例如,调用nextLetter('c', 2)会返回e。这些将帮助我们确定棋盘上的位置。

  • getIntermediateSquare:给定一个起始位置和一个目标位置,这个函数将返回位于两个方格之间的方格。当棋子跳过时使用此功能,这样我们可以确定移动是否有效,并正确地应用装饰到方格上(箭头和跳过的棋子的否定符号)。

  • isKingable: 给定棋子移动到的位置,它是否有资格成为国王?

  • clearTurn:清除当前术语中的任何旧数据,并在 DataView 中选择任何选中方格。

  • clearDecorations:清除数据存储中的任何装饰(清除这些数据也会将其从显示中移除)。

除了这些较小的函数之外,棋盘的主要逻辑由select事件处理。这个事件需要检查是否有有效的移动,然后向棋盘添加适当的装饰。这些装饰将显示选中的棋子、移动的方向以及任何受影响的方格或棋子。

游戏棋盘逻辑

游戏逻辑将按以下方式工作:

  1. 玩家点击开始回合按钮。

  2. 游戏会通过一个警告框告诉玩家现在是黑方或红方的回合。

  3. 玩家点击一个棋子。

  4. 游戏会检查是否点击了有效的棋子,并将信息存储在currentMove变量中。

  5. 玩家点击目标方格。

  6. 游戏会检查目标位置是否有效,并将信息存储在currentMove变量中。

  7. 玩家可以点击完成回合以完成回合,或者点击更多方格以跳过多个棋子(最后点击完成回合以完成所有回合的移动)。

  8. 一旦玩家点击Finish Turn!,系统将移除任何跳过的棋子,移除所有关于这次走法的装饰,并将走法存储在previousMove变量中。

我们将从游戏逻辑中最合理的地方开始我们的旅程,即当用户点击Start Turn按钮时触发的函数。

开始回合

我们的Start Turn按钮实际上处理了两个功能,即开始和结束回合。这意味着我们将根据按钮的当前状态(文本)切换功能。在控制器中,这个按钮被称为mainBtn,点击函数看起来像这样:

doMainBtn: function(btn) {
 var turn = this.getCurrentTurn();
 if (btn.getText() == 'Start Turn') {
  btn.setText('Finish Turn!');
  this.getAltBtn().setText('Clear Moves');
  turn.started = true;
  this.setCurrentTurn(turn);
  this.clearTurn();
  Ext.Msg.alert("Ready to play!", "It is "+turn.player[0].toUpperCase() + turn.player.slice(1)+"'s turn!");
 } else {
  if (turn.moves.length > 1) {
  this.commitTurn(turn);
  turn.player = (turn.player == 'red')?'black':'red';
  this.setCurrentTurn(turn);
  this.clearTurn();
  btn.setText('Start Turn');
  this.getAltBtn().setText('Show Previous');
   }
 }
}

我们首先通过调用this.getCurrentTurn()来获取我们的currentTurn变量。如果游戏刚刚开始,回合属于黑方。此时,我们其他的所有值都将为空或为假。

我们然后检查按钮的文本值,以便我们可以确定下一步要做什么。如果文本是Start Turn,我们需要将按钮的文本更改为Finish Turn!

我们的其他按钮(altBtn)也会根据当前回合的状态改变其文本。如果我们正在开始一个新的回合,altBtn将被设置为Clear Move。这将允许玩家在完成之前改变主意时清除走法。

接下来,我们将turnstarted值更新为true。这让我们知道当前走法已经开始。我们使用名为clearTurn()的函数清除任何旧的回合数据并从棋盘上移除任何先前的选择。然后我们通知当前玩家现在是他们的回合。

如果按钮的文本设置为Finish Turn!,按钮将使用另一个名为commitTurn()的子函数提交当前回合所选的走法。然后我们更改当前玩家,清除回合数据,并重置两个按钮的文本。我们稍后会回到结束回合的话题,但首先我们需要看看回合开始后会发生什么。

检查回合

一旦回合开始,用户会点击 DataView 来移动棋子。然后我们需要确保他们所做的每个选择都是有效的。我们通过使用名为doSelect()的函数监听 DataView 的select事件来实现这一点。

我们首先要确保用户已经选择了一个有效的棋子,所以我们首先获取当前回合:

doSelect: function (view, record) {
 var turn = this.getCurrentTurn();

接下来,我们有一些已知的非法走法,在这种情况下,我们可以返回 false 来防止玩家选择这些走法:

if (turn.endOfTurn || !turn.started) {
  return false;
 }

这防止了用户在回合开始之前或结束之后移动。我们也不希望用户选择棋盘上的任何或亮色的方格:

if (record.get('background') == 'light') {
  return false;
}

在将明显的非法走法排除之后,我们开始检查允许的走法,从这一步开始:

if (turn.moves.length == 0 && record.get('occupiedBy') != turn.player) {
 return false;
}

这检查我们是否处于回合的开始(turn.moves.length == 0)以及玩家是否没有点击他或她的对手的棋子。如果是这样,我们返回 false 来防止选择。

} else if (turn.moves.length == 0) {
  turn.moves.unshift(record);
  turn.piece = record;
  this.setCurrentTurn(turn);
  return true;
}

如果我们在移动的开始处,并且用户点击了正确的棋子,我们将记录添加到我们的moves数组的开始处。

注意

我们以相反的顺序存储moves,这样列表中的第一个移动就是最后一步移动。这是因为从数组中获取第一个元素(始终是turns.moves[0])比计算数组元素以获取数组末尾的元素要容易得多。

然后我们使用新的信息设置当前回合并返回 true,这样选择事件就会触发,并且棋子所在的方格会高亮显示。

如果这不是第一次移动(turn.moves.length大于零),这意味着用户之前已经选择了一个棋子,现在正在选择一个方格来移动棋子。如果是这种情况,我们继续到下一个else语句,该语句检查在游戏规则下移动是否合法:

} else if (this.isLegalMove(turn.moves[0], record)) {
  turn.moves.unshift(record);

  if (this.isKingable(record)) {
   turn.kingable = true;
   this.setEndOfTurn();
   Ext.Msg.alert("King me!", "Landing here would cause you to be kinged and end your turn.");
  }

  this.setCurrentTurn(turn);
  this.decorateCurrentTurn();
  return true;
 } else {
  return false;
}

如果移动是合法的,我们还会检查isKingable()以查看玩家是否到达了棋盘的另一侧。如果移动是合法的,我们将适当地设置回合并使用decorateCurrentTurn()函数添加移动的箭头。我们稍后会更详细地了解decorateCurrentTurn()函数的工作原理,但首先我们想要介绍isLegalMove()函数背后的逻辑。

检查一个移动是否合法

当用户选择了一个有效的棋子并尝试将其移动到新的方格时,会调用isLegalMove()函数。在这个函数中,移动实际上并没有被提交,我们只是检查玩家点击的方格是否是一个有效的移动。如果是,我们通过返回 true 允许 DataView 的select事件触发。

注意

此应用程序的源代码在此函数中包含大量的控制台日志。这些日志将在 Safari 或 Chrome 中打印到控制台,并有助于跟踪此函数中的逻辑。尝试在查看控制台的同时点击有效和无效的移动,以查看哪些函数部分正在响应,以及如何验证移动。

为了做出这个判断,我们遵循跳棋的基本规则,并使用以下标准检查移动:

  • 目标方格不能被占据

  • 移动必须在正确的方向上(普通棋子只能向前移动)

  • 移动可以距离当前位置一个方格

  • 移动可以距离当前位置两个方格,*前提是两个方格之间有一个对手的棋子

由于这是一个相当大的函数,我们将分几个部分来介绍,从整体框架开始,然后逐步填充细节。

我们开始我们的函数,通过传递fromto位置的值来移动。然后我们获取当前回合并设置一些后续使用的变量:

isLegalMove: function (from, to) {
 var turn = this.getCurrentTurn(),
 fromID, toID, distance, intermediate;

 if (to.get('occupiedBy') != 'none') {
  return false;
 }

 fromID = from.get('squareID').split('');
 toID = to.get('squareID').split(''); 
 // This makes the letter element 0, and the number element 1.

 distance = Math.abs(toID[1] - fromID[1]);

 if (distance == 1 && !turn.hasJumped) {
  // here we will check for our different piece types: king, black or red
 }
 if (distance == 2) {
  // here we will check for our different piece types: king, black or red
 }

 return false;
}

我们首先检查目标方格是否被占用,使用to.get('occupiedBy')。如果方格是空的,这个变量应该是none,如果它被占用,它将是redblack。如果我们得到redblack,我们立即返回 false,这将退出我们的isLegalMove()函数。

接下来,我们使用split函数来获取tofrom的值,并将它们拆分成一个数组。由于我们在''上拆分,它将字母(A-H)分配给数组的第一个元素(fromID[0]toID[0]),并将数字(1-8)分配给数组的第二个元素(fromID[1]toID[1])。

然后我们使用Math.abs来给出两个数值之间的距离。abs是绝对值,这确保了我们即使fromID[1]大于toID[1]也能得到一个正数。

注意

重要的是要注意,这个距离是行距离,而不是移动开始和结束之间的实际方格数。

接下来,我们需要确保我们的距离是一行(不跳跃)或两行(跳跃)。如果不是这两者之一,我们返回 false,不允许移动。这两个部分目前是空的,所以让我们用一些代码来填充它们。

事实上,这两个选项也有一些我们需要考虑的可能性。我们将从一个可能的单行移动开始:

if (distance == 1 && !turn.hasJumped) {

这检查了我们的一行距离,并确保用户不会跳跃一个棋子,然后作为同一移动的一部分尝试移动一行。

在这个if语句内部,我们需要检查三种可能性:

  • 这件棋子是王吗?

  • 这件棋子是红色吗?

  • 这件棋子是黑色吗?

这些标准决定了棋子可以移动的方向,并允许我们检查移动是否有效。对于王,我们检查以下条件:

if (turn.piece.get('pieceType') == 'King') {
    if (toID[0] == this.nextLetter(fromID[0])) {
        this.setEndOfTurn();
        return true;
    } else if (toID[0] == this.previousLetter(fromID[0])) {
        this.setEndOfTurn();
        return true;
    }
} 

在这里,我们使用nextLetter()previousLetter()函数作为检查移动是否在斜线上的部分:

检查移动是否合法

在上面的例子中,位于棋盘2行的王可以移动到1行或3行,在DF列。由于我们之前的doSelect()函数已经检查确保我们没有选择浅色背景,我们知道这些都是有效的移动。这对任何颜色的王都适用。

然后我们调用setEndOfTurn()并返回 true 以触发选择函数并选择方格。

对于常规的红色和黑色棋子,我们需要确保移动的方向是正确的。对于红色棋子,这看起来是这样的:

} else if (turn.piece.get('occupiedBy') == 'red') {
 if (toID[1] < fromID[1]) {
  if (toID[0] == this.nextLetter(fromID[0])) {
   this.setEndOfTurn();
   return true;
  } else if (toID[0] == this.previousLetter(fromID[0])) {
   this.setEndOfTurn();
   return true;
  }
 }
}

由于我们的行是从上到下编号为 1-8,红色从下往上移动,我们需要确保我们来的行号小于我们要去的行号(toID[1] < fromID[1])。

检查移动是否合法

我们还需要确保在调用setEndOfTurn()并返回 true 以选择 DataView 中的方格之前,我们将前往下一个或上一个字母。

对于黑色棋子,我们将从上到下移动,所以我们需要确保目标行大于起始行。这将关闭我们关于单行距离(无跳跃)的if语句:

} else {
 if (toID[1] > fromID[1]) {
  if (toID[0] == this.nextLetter(fromID[0])) 
   { 
    this.setEndOfTurn();
    return true;
   } else if (toID[0] == this.previousLetter(fromID[0])) {
    this.setEndOfTurn();
    return true;
   }
 }
}

和之前一样,这也检查我们的行字母,以确保我们只移动到相邻的行,调用setEndOfTurn()并返回 true 来选择方格:

检查移动是否合法

现在我们已经记录了单行移动,我们需要看看当我们尝试移动两行距离时会发生什么。

从用户的视角来看,他们将通过点击选择一个棋子,然后选择一个空方格,其中有一个对手的棋子位于两个方格之间。如果还有其他跳跃可用,玩家在点击完成移动按钮之前也会点击这些方格:

检查移动是否合法

在这个例子中,玩家会点击红色的国王棋子,然后点击三个方格(此处用勾号表示),最后点击完成移动按钮。让我们看看代码是如何检查这个移动的。

这是我们填充isValidMove()函数内部的第二个if语句的地方:

if (distance == 2) {
 intermediate = this.getIntermediateSquare(from, to);

这将检查我们的行距离,并使用getIntermediateSquare()函数获取位于fromto位置之间的正方形。在先前的例子中,从D5移动到B3会抓取C4作为中间正方形。

就像之前一样,我们还需要检查棋子是黑色、红色还是国王,以确保跳跃方向正确。然而,还有一些新的细节需要考虑。

首先,我们必须确保在fromto位置之间有一个对手的棋子。其次,我们需要允许多次跳跃。

如果你记得在控制器的顶部,我们有两个变量currentTurnpreviousTurn。在这些变量内部,我们有空数组movesremovedPieces。我们将使用这些数组来存储多次跳跃。

对于国王棋子,我们在抓取中间方格的下方打开一个新的if语句:

if (turn.piece.get('pieceType') == 'King') {
  if (intermediate.get('occupiedBy') == 'red' && turn.piece.get('occupiedBy') == 'black') {
   turn.moves.unshift(intermediate);
   turn.removedPieces.push(intermediate);
   turn.hasJumped = true;
   this.setCurrentTurn(turn);
   return true;
  } else if (intermediate.get('occupiedBy') == 'black' && turn.piece.get('occupiedBy') == 'red') {
   turn.moves.unshift(intermediate);
   turn.removedPieces.push(intermediate);
   turn.hasJumped = true;
   this.setCurrentTurn(turn);
   return true;
  }
 }

一旦我们知道我们有一个King棋子,我们不需要检查跳跃方向,我们只需要确保intermediate棋子与移动的棋子(我们的occupiedBy棋子)颜色相反。一旦我们确定这是一个有效的国王跳跃,我们使用unshift将移动添加到我们的移动数组开头。

注意

记得我们需要将东西添加到moves数组的开头,这样我们就可以通过在其他函数中使用moves[0]来快速访问这些移动中的最新移动。这对于放置箭头装饰(我们稍后将看到)是必要的。对于放置否定符号直接在棋子上的removedPieces数组来说,这不太重要。因此,对于removedPieces,我们使用push()函数。

我们还将 intermediate 位置添加到我们的 removedPieces 数组中,并将 hasJumped 设置为 true。这让我们知道可能还有更多需要执行的移动。最后,我们使用 setCurrentTurn() 记录用户选择的位置,并返回 true 以在 DataView 中选择该方格。

对于移动红色棋子,我们运行一个检查,以确保棋子是向前跳跃的,这需要确保 toID[1] < fromID[1]

} else if (turn.piece.get('occupiedBy') == 'red') {
 if (toID[1] < fromID[1]) {
  if (intermediate.get('occupiedBy') == 'black') {
   turn.moves.unshift(intermediate);
   turn.removedPieces.push(intermediate);
   turn.hasJumped = true;
   this.setCurrentTurn(turn);
   return true;
  }
 }
}

我们还检查被跳过的棋子是否是黑色的。其余的代码遵循国王跳跃代码相同的模式。

我们添加相同的代码块来检查黑色棋子的跳跃:

} else {
  if (toID[1] > fromID[1]) {
   if (intermediate.get('occupiedBy') == 'red') {
    turn.moves.unshift(intermediate);
    turn.removedPieces.push(intermediate);
    turn.hasJumped = true;
    this.setCurrentTurn(turn);
    return true;
   }
  }
 }

再次,我们使用 toID[1] > fromID[1] 来检查我们的方向,并检查中间方格是否有红色棋子。其余的代码遵循红色和国王跳跃代码相同的模式。

isValidMove() 函数的底部,在所有 if 语句之后,我们使用 return false 结束函数。这涵盖了当用户做了完全超出我们 if…then 规则范围的事情时的情况。

一旦我们确定移动是否有效,我们需要向我们的游戏棋盘添加正确的类,以便用户知道他们选择了一个有效的移动,以及移动完成后会发生什么。

装饰移动

一旦用户开始一个回合并点击一个方格,就需要有一些指示来表明已选择了一个有效的移动。这作为 doSelect() 函数的一部分发生,并且以两种不同的方式发生。

第一种方式是,当我们验证所选移动时,我们返回 truefalse。当我们返回 true 时,DataView 触发 select 事件,所选方格被高亮显示(这是 DataView 的默认行为)。

当我们返回 false 时,实际上阻止了 select 事件的发生,该方格不会被高亮显示。

如我们本章前面提到的,高亮颜色由 CSS 样式和 x-item-selected 类控制。这个类会自动应用到 DataView 中的任何所选项。我们可以使用类似的方法为我们的方格添加额外的 CSS 装饰,这将使用户更好地了解游戏中的情况。

这发生在 decorateCurrentTurn() 函数中。

本章前面我们讨论了我们的装饰类:

  • up_left: 这表示一个指向左上角的箭头

  • up_right: 这表示一个指向右上角的箭头

  • down_left: 这表示一个指向左下角的箭头

  • down_right: 这表示一个指向右下角的箭头

  • removed: 这表示方格中间的否定中心

在游戏棋盘上,它们看起来是这样的:

装饰移动

decorateTurn() 函数将遍历一个回合的移动数组并应用正确的样式。

我们首先获取移动的 fromIDtoID 值,并将它们拆分为一个包含两个元素的数组:一个数字和一个字母。然后我们比较它们以创建一个与我们的四个箭头之一对应的类名:

decorateTurn: function(turn) {
        var i, from, to, fromID, toID, cls;

        for (i = turn.moves.length - 1; i > 0; i--) {
            from = turn.moves[i];
            to = turn.moves[i - 1];
            fromID = from.get('squareID').split('');
            toID = to.get('squareID').split(''); 
            if (fromID[1] < toID[1]) {
                cls = 'down';
            } else {
                cls = 'up';
            }
            if (fromID[0] < toID[0]) {
                cls += '_right';
            } else {
                cls += '_left';
            }
            from.set('decoration', cls);
        }

        for (i = 0; i < turn.removedPieces.length; i++) {
            cls = turn.removedPieces[i].get('decoration');
            cls += ' removed';
            turn.removedPieces[i].set('decoration', cls);
        }

        this.getBoard().refresh();
    }

例如,假设我们有一个棋子从 E4 移动到 D3。如果我们拆分这些值并在前面的代码中检查它们,我们会看到:

  • 4 < 3 是错误的,我们会将 cls 的值设置为 up

  • D < C 也是错误的,我们会将文本 _left 添加到我们的 cls 值中

这使得我们的方格应用了 up_left 类,并在左上角有一个箭头。

注意

在 JavaScript 中,比较文本值的大于/小于比较字母的 ASCII 值。如果你比较的是相同大小写的单个字母,这是可以的,但在许多情况下可能会出现问题。例如,"Z" < "a" 在 JavaScript 中实际上是正确的,因为所有大写字母的 ASCII 值都低于小写字母。在这种情况下,我们正在比较单个大写字母和另一个单个大写字母,这可以正常工作。

一旦我们使用 from.set('decoration', cls); 设置了箭头来指示方向,我们需要考虑任何要移除的棋子。我们通过遍历我们的 turn 变量的一部分 removedPieces 数组来处理这个问题。我们为这个数组中的所有棋子添加 'removed' 到类中。字符串前面的空格意味着它将作为额外的类添加到方格上。

这意味着 CSS 类将类似于 "up_left removed",如果回合跳过一个位于当前位置上方和左方的棋子。这两种样式都会应用于该方格,给它一个上左箭头和一个否定符号。

一旦我们为每个移动应用了我们的样式,我们就调用 this.getBoard().refresh(); 来刷新棋盘并显示所有内容。

decorateTurn() 创建一个单独函数的美丽之处在于,我们可以用它来装饰上一个回合以及当前的回合:

decoratePreviousTurn: function() {
 var turn = this.getPreviousTurn();
 if (turn.player == null && turn.piece == null) {
  Ext.Msg.alert('Game not started', 'There is no previous turn to show');
 return false;
}
 this.getBoard().select(turn.moves, false, true);
 return this.decorateTurn(turn);
}

这个函数会检查我们是否有上一个回合。如果有,我们就将它传递给我们的装饰回合函数,让它处理显示适当的装饰。如果你特别雄心勃勃,你可以存储所有的回合并通过循环将每个回合传递给 decorateTurn() 函数来重新播放每一个。

然而,在我们过于雄心勃勃之前,让我们看看我们是如何清除移动及其装饰的。

清除移动

当我们清除一个移动时,我们需要完成两个主要任务:清除 currentTurn 变量中的数据,并清除存储中的装饰值。我们将此分为两个单独的函数,以便更容易更新和维护。第一个函数处理重置 currentTurn 变量的值,然后在棋盘上取消选择所有内容:

clearTurn: function() {
 var turn = this.getCurrentTurn();
 turn.piece = null;
 turn.moves = [];
 turn.removedPieces = [];
 turn.endOfTurn = false;
 turn.hasJumped = false;
 turn.kingable = false;
 this.setCurrentTurn(turn);
 this.getBoard().deselectAll(true);
 this.clearDecorations();
},
clearDecorations: function() {
 var store = this.getBoard().getStore();
 store.each(function(square) {
  square.set('decoration', '');
 });
}

第二个函数清除存储中的所有装饰。您可能还记得在本章之前,装饰是指示移动的箭头和用来指定跳过的棋子的否定符号。这些都是作为 CSS 样式应用到我们的 DataView 中的方块上。当我们清除存储中每个方块上的装饰值时,DataView 将自动从显示中移除箭头和符号。

超越完成的游戏

玩过完成的游戏可以带来一些修改和改进的有趣想法。

超越完成的游戏

这些方块可以很容易地适应大多数桌面角色扮演游戏中使用的传统六边形网格。

超越完成的游戏

即使是六边形布局,游戏的基本逻辑流程仍然保持不变:

  • 用户选择一个棋子,我们检查这个选择是否有效

  • 用户选择一个目的地,我们检查这个目的地是否有效

  • 如果这是一个有效的移动,我们通过 CSS 提供视觉反馈来告诉用户

  • 我们确定移动的结果,并适当地移除或修改棋子

  • 我们检查游戏是否结束,如果没有,我们为下一位玩家重复这个过程

验证仍然是数学和一些基本的if…then逻辑的问题。诚然,这种逻辑可以变得更加复杂,但基本的规则和游戏流程将保持不变。

此外,可以使用 CSS 过渡来为游戏玩法添加更多的视觉吸引力。在docs.sencha.com/touch/2-0/#!/api/Ext.Anim的文档中可以找到许多选项。

这些变化允许您将一个简单的游戏模型真正变成自己的。

摘要

在本章中,我们介绍了国际象棋基本游戏的创建:

  • 我们构建了基本的游戏板

  • 探索了 CSS 和 HTML 结构以创建我们的布局

  • 我们构建了基本的游戏控制器并覆盖了游戏板逻辑

  • 我们向您展示了如何开始、验证、装饰和清除板上的移动

  • 我们还讨论了一些扩展游戏并使其成为您自己原创想法的选项

posted @ 2025-10-24 09:59  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报