Project5: 单词争夺战
概述
摘要:制作一款字母游戏同时学习闭包和布尔量。
概念:NSString,闭包,方法返回值,布尔量,NSRange
1.设置
2.从硬盘读取:contentsOfFile
3.挑个词,任何一个:UIAlertController
4.准备提交:lowercaseString和NSIndexPath
5.返回值:contains
6.或者其他什么?
7.总结
设置
项目1~4都特别简单,因为我的目标是尽可能的多让你了解Swift而不是吓跑你,同时试着做点有用的东西。但现在你很有希望开始熟悉iOS开发的核心工具,是时候更换齿轮,来点更难的了。
这个项目中你将要学习怎么制作一个处理字母的单词游戏,但是如同以往,我肯定会借机教你更多关于iOS开发的知识。虽然这次我们将要回到表视图,但同时你也会学到如何从文件载入文本,如何在UIAlertController中获得用户的输入,更深入地了解闭包的工作原理。
在Xcode中创建一个新的Master-Detail Application,命名为project5。选择iPhone作为目标设备然后保存。现在打开IB中的Main.storyboard,删除整个详情视图控制器——在最右边。在文件导航器中右击DetailViewController.swift,选择删除(Delete),然后选择“Move to Trash”。
完成之后会出现很多错误,但都很好修复——我们只需删除更多一点的Apple的模板即可!首先打开APPDelegate.swift,在开始的位置找到以下代码:
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
let splitViewController = self.window!.rootViewController as! UISplitViewController
let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController
navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem()
splitViewController.delegate = self
return true
}
删除return true以外的所有内容,像这样:
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
return true
}
在同个文件中,拉倒页面底部找到一个特别长的方法:
func splitViewController(splitViewController: UISplitViewController, collapseSecondaryViewController secondaryViewController:UIViewController, ontoPrimaryViewController primaryViewController:UIViewController) -> Bool {
然后删除整个方法。
接下来,打开MasterViewController.swift,viewDidLoad()中删除super.viewDidLoad()之外的所有内容,再删除方法viewWillAppear()、insertNewObject()和prepareForSegue()的全部。最后,找到tableView(_:canEditRowAtIndexPath:)和tableView(_:commitEditingStyle:forRowAtIndexPath:)方法同样也删除掉。
最后的改动是删除类顶部的一个属性:
var detailViewController: DetailViewController? = nil
清理完成,但我们需要在IB上做点小改动来完成准备工作。跟项目1里面一样,Apple的Master-Detail Application模板添加了一个分屏视图控制器和连个导航控制器,同时还有表视图控制器和带详情标签(label)的普通视图控制器——这对于我们要做的app来说,过于复杂了,让我们削减一部分内容。
所以,在IB中打开Main.storyboard,选中并删除分屏视图控制器(最左边的那个带深灰色背景的),然后删除底部的导航控制器和右边的视图控制器。现在,就剩下俩了:导航控制器和表视图控制器。
Apple已经设置过了,所以分屏视图控制器是这个项目的第一视图控制器,即在应用运行时出现的第一页面。我们刚删了它,所以需要换个新的顶上去。选中留下的导航控制器,进入属性观察器(Alt+Cmd+4),在选项列表的大概中间位置勾选“Is Initial View Controller”。修改好了你就会在导航控制器的左边看到一个指向它的箭头。
这个项目被删的只剩这么一点,你可能会想为什么我们不直接从零开始!但,剩下的量其实还是不少,而且删代码会有宣泄作用。更重要的是:现在你的项目已经变成了一个干净的表视图项目,可以开始自定义了——让我们开始吧!
PS:从Single View application开始更快吗?不好说,至少这让你积累了一些清理Apple的模板的经验。还有,删东西很有趣!
从硬盘读取:contentsOfFile
我们将要制作一款字母游戏,用户要用字母组合拼出完整的单词。我们会把一些可能的字母组合制成一个清单,然后放进一个独立文件中。但我们怎么才能从文件中获取文本呢?这对Swift的String数据类型来说就是小菜一碟。
先准备好app要用的文件,就是从hackingwithswift.com上下载。在Content文件夹中你会找到start.txt。把它拖到你的项目中,确保勾选“Copy items if needed”。
start.txt文件包含了超过12,000的我们可以用的8字母单词,都是以一个单词一行的形式保存的。我们需要把它转换成一个我们可以玩的单词数组。场景背后,这些换行被标记为“\n”这一特别的换行符号。所以,我们得把单词清单载入到一个字符串,然后用“\n”来把它打断成字符串数组。
首先,在类顶部定义一个新数组。顶部已经有一个在那儿了,所以把下面的代码放到它下面的位置:
var allWords = [String] ()
同时你最好把Apple的数组类型也从[AnyObject]改成[String,因为我们只会把它用来存储字符串。你还需要把表视图的cellForRowAtIndexPath方法从:
let object = objects[indexPath.row] as NSDate
cell.textLabel!.text = object.description
改成:
let object = objects[indexPath.row]
cell.textLabel!.text = object
在项目1里面我们也做过这事儿,所以不是很难。这样改是因为数组objects里面存放的肯定是字符串。
第二步,读取我们的数组。分三个步骤:找到start.txt的存放路径,载入文件内容,把内容分割装进数组。
找文件路径将会是以后常干的一件事,因为即便你知道文件名为“start.txt”,你也不确定它在文件系统的什么位置。所以我们用NSBundle的一个内建方法pathForResource()来找到它。它需要的参数是文件名和路径扩展,然后返回一个String?——要么返回路径要么返回nil。
把文件内容载入到字符串中也是需要你熟悉的内容,而且方法很简单:当你创建一个String实例时,你可以让它根据一个指定路径的文件内容来创建自己。你也可以用参数告诉他文本使用的编码,虽然这里我们不关心。
最后,我们需要根据“\n”把单个字符串分解成一个字符串数组。用字符串类的componentsSeparatedByString()方法就可以实现。告诉它分割符是什么,它就能返回一个数组。
开始码代码之前,有两件事需要我们知道:方法pathForResource()和根据文件内容创建字符串返回的都是String?,也就是说我们需要用if/let语句来判断和解包。
现在让我们把下面的代码放到viewDidLoad()里面super调用的后面:
if let startWordsPath = NSBundle.mainBundle().pathForResource("start", ofType: "txt") {
if let startWords = try? String(contentsOfFile: startWordsPath, usedEncoding: nil) {
allWords = startWords.componentsSeparatedByString("\n")
}
} else {
allWords = ["silkworm"]
}
如果你看的比较仔细,会发现这里有个新关键字:try?。之前你已经看过try!了,其实这里也可以用,因为我们是从自己app的目录里载入文件,所以如果载入失败则表明问题相当严重。但这样写我可以教你点新东西:try?表示“调用这些代码,如果它出错了就返回一个nil。”这表示你调用的代码会一直起作用,但你需要小心翼翼地解包。
你会看到,代码小心地检查然后解包start文件的内容,然后把它转换成数组。当转换完成后,allWords会包含12,000+的字符串。
为了保证继续之前一切正常,让我们创建一个名叫startGame()的新方法。它会在我们每次为玩家生成一个新单词之前被调用:
func startGame() {
allWords = GKRandomSource.shareRandom().arrayByShufflingObjectsInArray(allWords) as! [String]
title = allWords[0]
objects.removeAll(keepCapacity: true)
tableView.reloadData()
}
第一行打乱了数组中单词的顺序。我们在项目2里面用过,所以你得记住你需要引入GameplayKit架构来让方法可以工作。
第二行,一旦随机化完成,就把视图控制器的标题设置成数组中的第一个单词。这也就是需要用户去找到的目标单词。
第三行,把数组objects里面的所有值都删除。这个数组是Xcode模板帮我创建的,我们要用它来存储玩家的答案。我们现在不会把任何东西放进去,所以removeAll()不会做任何事。
第四行是最有趣的部分:它调用了tableView的reloadData()方法。这是定义在……等等!表视图从哪儿来的?我们肯定没有创建它。相反,它是为我们准备的因为——戏剧化的笑声——MasterViewController并不是从UIViewController继承的子类。
是的,我说过UIViewController用于app的所有屏,只是有时候并不是直接的使用。这里,MasterViewController继承自UITableViewController,而UITableViewController继承自UIViewController。这是个继承链,每个部分都会增加它们自己的功能。
别害怕:大多数视图控制器都是直接继承自UIViewController或者先经过UITableViewController。只是iOS中表视图无处不在,所以Apple就为开发者预烧了些额外行为。
那么什么是UITableViewController做而UIViewController不做的呢?对于初学者来说,就是张全屏表格。当视图被呈现时,UITableViewController会自动刷新滚动条这样用户就会知道他们可以滚动屏幕;如果这时键盘也在屏幕上的话UITableViewController也会自动它的位置使内容不会被键盘遮挡。
无论如何,UITableViewController是MasterViewController的基础,而这就是tableView的来源。调用reloadData()会促使表视图检查它有多少行同时全部重新载入。
我们的表视图还没任何行,所以reloadData()不会做任何事。但方法已经准备好,可以让我们检查有没有正确载入了数据,所以,把它放在viewDidLoad()结束之前就可以了:
startGame()
挑个词,任何一个:UIAlertController
这个游戏会鼓励用户输入一个单词,其构成的字母都来自于给定的8字母单词。比如,如果这个单词是“agencies”,用户就可以输入“cease”。我们会用UIAlertController来完成这件事,因为很完美,而且也给我个机会来介绍些新东西。我总是有其他的目的!
把这些代码加入到viewDidLoad()里super调用后面:
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Add, target: self, action: "promptForAnswer")
代码使用系统提供的“add”来创建了一个新的UIBarButtonItem,点击会调用方法promptForAnswer()。运行时方法会显示一个带有输入框的UIAlertController,用户点击Submit后用户提交的答案会被确认是否有效。
在我给你代码之前,让我解释下你需要知道些什么。
我们将会使用一个比好,而且还会有点复杂。提醒一下,闭包是可以被当做变量来处理的代码块——我们可以把闭包传送到某个地方,然后它被保存起来稍后执行。为此Swift复制了一份代码并获取了它涉及的对象,方便闭包晚点使用。
问题就在这里:如果闭包涉及的是视图控制器会怎么样呢?这样将会出现一个超级循环:视图控制器拥有一个对象,对象拥有闭包,闭包拥有视图控制器,而什么都不会变。
我打算给你个形象点的例子,所以稍微忍耐下。想象你有两个清洁机器人,一红一蓝。你告诉红:“在蓝停下来之前不要停止清理。”同时你告诉蓝:“在红停下来之前不要停止清理。”那么它们什么时候会停下来?永远不会,因为谁都不会是第一个停下来的。
这就是我们要面对的一个很强的引用循环:对象A拥有对象B,对象B拥有一个引用对象A的闭包。而当闭包被创造出来时,它们会捕获所需的一切,这样对象B也就拥有了对象A。
强引用循环过去很难被发现,但Swift已经让它们变得很简单。实际上,即使你不确定循环的存在你也可以去使用它。
所以,振作起来:我们将要第一次见到真正的闭包。学句法会很痛苦。而且当你最后理解了之后,你就能应对网上那些烧脑案例。
准备好了嘛?下面就是promptForAnswer()方法:
func promptForAnswer() {
let ac = UIAlertController(title: "Enter answer", message: nil, preferredStyle: .Alert)
ac.addTextFieldWithConfigurationHandler(nil)
let submitAction = UIAlertAction(title: "Submit", style: .Default) { [unowned self, ac] (action: UIAlertAction!) in
let answer = ac.textFields![0]
self.submitAnswer(answer.text!)
}
ac.addAction(submitAction)
presentViewController(ac, animated: true, completion: nil)
}
这一个方法着实介绍了不少新东西,但我们先略过一些简单的部分。
创建一个UIAlertController(项目2里做过了)
addTextFieldWithConfigurationHandler()只是在UIAlertController中加入了一个可编辑文本框。
addAction()用于向UIAlertController中添加UIAlertAction。项目2里也用过了。
presentViewController也是项目2里面用过的!
剩下的内容很狡猾:创建submitAction。短短几行代码有至少五个新内容要学,而且都很重要。先从最简单的UITextField开始。
UILabel你是见过的:UIView的一个子类,在屏幕上用来显示一段不可编辑的文本内容。UITextField也类似,只是它可以被编辑。我们用UIAlertController的addTextFieldWithConfigurationHandler()方法来添加单行文本输入框,现在我们要读取被输入的值。
下一步是尾随闭包句法。我懂,我懂:你还没学过闭包呢,可现在却要学尾随闭包句法!好吧,它们是互相关联的,而且尾随闭包不是特别难,给个机会。
这里是项目2的部分代码:
UIAlertAction(title: "Continue", style: .Default, handler: askQuestion)
这里的情况差不多:我们用UIAlertController和UIAlertAction来添加用户可以按的按钮。那个时候,我们用一个单独的方法(askQuestion())来避免太早解释闭包,但你你可以看到我把askQuestion()作为操作员参数传入UIAlertAction。
闭包有点儿像无名的方法。就是指,我们传递进去执行的是一块代码,而不是方法名。所以从定义上来说,我们可以把这行代码写成如下形式:
UIAlertAction(title: "Continue", style: .Default, handler: { CLOSURE CODE HERE })
但有个很严重的问题:太丑了!如果你在闭包里面执行很多代码,就会产生只有一行的方法使用了一个10行的参数。
所以,Swift给了个解决办法,就是尾随闭包句法。任何你要调用最后一个参数是闭包的方法时——这样的方法有很多——你都可以直接去掉最后一个参数,然后用一组大括号把它传进来。这是非强制而且自动的,会让我们概念上的代码变成下面的样子:
UIAlertAction(title: "Continue", style: .Default) {
CLOSURE CODE HERE
}
括号内的全都是闭包部分,就作为UIAlertAction的最后一个参数传入。很方便!
接下来是“(action: UIAlertAction!) in”。如果你还记得,项目2中我们得调整askQuestion()方法这样它才接受参数UIAlertAction告诉它哪个按钮被触碰了,像介个样子:
func askQuestion(action: UIAlertAction!) {
我们没的选。因为UIAlertAction的handler参数需要的是一个方法把它自己作为参数。这里发生的是:当它被触碰时,我们给UIAlertAction一些代码去执行,而它想知道这些代码接受一个UIAlertAction类型的参数。
关键字in很关键:在它前面的都是描述这个闭包的;在它之后的全都是闭包的内容。所以(action: UIAlertAction!) in 表示它接受一个参数传入,类型为UIAlertAction。
我用这种方式写闭包是因为它跟项目2里面用过的很想。然而,Swift知道闭包得是什么样,所以我们可以进行简化:从这样……
(action: UIAlertAction!) in
变成这样:
action in
在当前项目中,我们可以更进一步的简化:我们不会再闭包中引用action参数,也就是说,我们甚至不需要给它命名。Swift中,如果你没有给一个参数命名,你可以直接使用下划线,像这样:
_ in
第四、第五个一起讲:unowned和self.。
Swift会获取闭包需要的任何常量和变量,基于闭包的环境内容。即,如果你在闭包外创建了一个整型,一个字符串,一个数组和另外一个类,然后在闭包内使用它们时,Swift就会获取它们。
这很重要,因为闭包引用了变量,很有可能会改变它们的值。但我还没说“获取”的真正含义,而这时因为它会因为你使用的数据类型的不同而变化。幸好,Swift把它彻底隐藏了,这样你就不需要担心了……
除了一些强引用循环以外。这些是你需要考虑的。在这种情况中,对象甚至都无法毁灭。
Swift的办法是让你定义一些没有被紧紧抓住的变量。两步搞定,它太简单了所以你会发现只要有机会你就会把它用到任何地方。
第一步,你必须告诉Swift哪些变量你不想被强引用。两种方法:unowned或者weak。unowned近似于隐式解析可选,而weak类似于普通可选项:一个弱(weak)拥有(owned)引用的可能是nil,所以你需要解包;一个没拥有(unowned)引用的是你确认过不会是nil所以不需要解包,然而如果你错了你就会碰到问题。
代码中我们使用的是:[unowned self, ac]。它声明了self(指当前的视图控制器)和ac(我们的UIAlertController)是被闭包作为没拥有的引用获取的,表示闭包可以使用它们,但不会创建一个强引用因为很清楚闭包并不拥有它们中的任何一个。
但这对于Swift来说还不够。方法中我们还调用了视图控制器的submitAnswer()方法。我们还没有创建它呢,但你应该能知道它将会得到用户输入的答案并在游戏中判断对错。
submitAnswer()方法在闭包当前内容的外面,所以当你写的时候,你可能没注意到调用时隐含地需要闭包获取self。意思是,如果没有获取视图控制器,闭包无法调用submitAnswer()。
我们已经说过self不被闭包拥有,但Swift希望我们完全肯定我们知道自己在做什么:对当前视图控制器的属性或方法的每次调用都必须加上前缀“self.”,就像self.submitAnswer()。
项目1里我跟你说过两种使用self的思路,还有“第一类人永远不喜欢self.除非非要不可,因为当它被需要时它非常重要而且意义非凡,所以把它用在不必要的地方会让人困惑。”
闭包对self的隐式获取正是这样的一个使用场所:在这里,Swift不会让你省略掉它的。通过限制你在闭包中的self的用法,你可以简单地确认你的代码并没有任何引用循环——不需要花多大的力气就可以完成了。
准备提交:lowercaseString和NSIndexPath
你可以松一口气了:我们已经搞定闭包部分了。我知道这不容易,但一旦你理解了基本闭包你就已经在你的Swift道路上迈出了一大步。
我们会做些比较简单的编程工作,因为游戏就快完成了!
首先,再次编辑你的代码,因为现在它要调用self.submitAnswer(),而我们还没完成这个方法。所以,把下面的方法加入到类中:
func submitAnswer(answer: String) {
}
是的,它是空的——这足够让代码变得清楚起来这样我们就可以继续了。
我们已经了解了闭包的结构了:尾随闭包句法,不拥有的自己,传入的一个参数,然后需要用self.来让获取变得明朗。我们还没有聊过闭包的实际内容,因为不是很多。代码如下:
let answer = ac.textFields![0]
self.submitAnswer(answer.text!)
第一行解包了文本框的数组,然后告诉Swift把它作为UITextField来处理。第二行将内容从文本框中取出然后传递到submitAnswer()方法中。【看下Xcode里这儿的代码】
方法需要检查玩家的单词是不是由给出的字母组成的,单词是否已经被用过(因为我们不想要重复的答案),还有单词是不是实际存在的(否则用户就可以输入一些无意义的内容)。
如果三个检查都通过,submitAnswer()需要把这个单词添加进objects数组中,然后在表视图中插入新的一行。我们可以用表视图的reloadData()方法强行全部重新载入,但当我们只需要改变一行时这样非常低效。
这里是我们第一次写的submitAnswer()方法:
func submitAnswer(answer: String) {
let lowerAnswer = answer.lowercaseString
if wordIsPossible(lowerAnswer) {
if wordIsOriginal(lowerAnswer) {
if wordIsReal(lowerAnswer) {
objects.insert(answer, atIndex: 0)
let indexPath = NSIndexPath(forRow: 0, inSection: 0)
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
}
}
}
}
先暂时忽略wordIsPossible(),wordIsOriginal() 和 wordIsReal()三个方法——我们先看剩下的代码。
如果用户输入“cease”作为初始词“agencies”的答案,很明显是正确的,因为有一个“c”,两个“e”,一个“a”和一个“s”。但是如果输入的是“Cease”呢?现在它有了一个“C”,而“agencies”没有。没错:字符串对大小写敏感,即答案区分大小写。
解决办法相当简单:所有的初始词都是小写的,所以我们只要在检查玩家的答案时用lowercaseString属性来小写字符串就好啦。它被存放在常量lowerAnswer中因为我们还要多次使用。
接下来就是三个if语句,一个包含另一个,这叫嵌套语句。只有当三个语句都为真时代码的主要部分才会被执行。
一旦我们知道单词是对的,我们要做三件事:在objects[0]的位置上插入新单词,这表示“把它加到数组的开始位置”,即最新的单词要放在表视图的顶部。
其他两件事是相关的:我们向表视图中插入新的一行。因为表视图的数据都是来自于数组objects,所以这样看上去有点奇怪。毕竟,我们刚把单词放入objects数组中,所以为什么我们还得把其他东西插入到表视图中呢?
答案是因为动画。就像我说的,我们可以调用reloadData()方法让表格重新载入全部的内容,但它意味着小改动的巨多额外任务,而且还引起了一次跳转——单词原来不在这儿,而现在它在了。
用户可能难以发现这次跳转,所以用insertRowsAtIndexPaths()让我们告诉表视图,新的一行已经被放在数组的指定位置,这样它就可以带着动画效果更新每一小格的内容。加一行明显比重新载入每一行要简单太多!
还有两个奇怪的地方需要解释。首先,NSIndexPath是我们在项目1里草草看过的,因为他为表格里的每一项都准备了一个方框和一行空白。跟项目1一样,这里我们也没有用到方框,但行号等于我们在数组中加入元素的位置——这里是位置0。
第二,参数withRowAnimation让你指定行载入时的动画效果。不论何时你从表格中添加或删除内容,值.Automatic表示“用标准系统准备的动画效果来展示改变”,这里表示“从顶部滑入新的一行”。
你的代码还不会编译,因为我们还有三个未完成的方法。把这三个加到submitAnswer()方法下面让一切重新启动:
func wordIsPossible(word: String) -> Bool {
return true
}
func wordIsOriginal(word: String) -> Bool {
return true
}
func wordIsReal(word: String) -> Bool {
return true
}
我们很快就会看看他到底做了些什么,然后用很多具体代码补全。但现在,Cmd+R来试试你做出来的app,你应该可以点击“+”来输入单词了。
返回值:contains
到目前为止,我们自定义的方法还没返回过任何值。我们在项目4里用关键字return只是为了早点跳出decidePolicyForNavigationAction方法,但并没有返回任何数据。所以,让我们更进一步。
就像我说的,return用于跳出方法,无论何时。如果你就写了个return,它就只是跳出方法而已。但如果你用return和一个值一起,它就会返回一个值给任何调用它的东西。
你得先告诉Swift你希望返回的是什么值,你才能发送一个值回来。Swift会自动检查返回的是不是你指定的数据类型,所以这很重要。目前我们只是给三个方法各留了个壳。让我们仔细看看其中的一个:
func wordIsOriginal(word: String) -> Bool {
return true
}
方法名为wordIsOriginal(),它只有一个字符串参数。在大括号之前有个新东西:-> Bool。这告诉Swift方法会返回一个布尔值,就是只能返回真或假的一个值。
方法只有一行代码:return true。这就是return如何返回值的方法:我们从这个方法中返回真值,所以调用者可以在if语句中调用它来检验真假。
该方法可以拥有足够多的代码,包括其它需要的方法,来充分判断单词是否已经被用过。我们要让它调用另外一个方法,就是用来检查我们的objects数组是否已经包含这个被提供的单词了。用下面的代码替代现有的代码:
return !objects.contains(word)
有两个新内容。首先,contain()是一个检查指定数组(参数1--objects)是否包含指定值(参数2--word)。包含则返回真。第二,“!”表示非运算,不是隐式解析可选值的解包。
用在变量常量之前为取非,所以,如果contains()返回真,!翻转结果让它为假;用在常量变量后面则为“解包隐式解析可选值”。
这样做是因为我们的方法名为wordIsOriginal(),如果单词之前未被用过,它应该返回真。如果我们使用的是return objects.contains(word),那么结果就会相反:如果单词被用过则会返回真。所以我们使用了!来翻转结果这样当单词是新的时就会返回真。
一个方法完成了,接下来是wordIsPossible(),它只有一个字符串参数,返回的是一个布尔量——真或假。这个方法较之前的更加复杂,但我已经让算法尽可能简单了。
我们怎么确定“cease”是从“agencies”中来,而且每个字母只用了一次?我用的方法是循环玩家的答案中的每一个字母,来看它是否在我们给出的初始词中出现。如果有,我们就从初始词中删除这个字母,然后继续循环。所以,如果我们想一个字母用两次,那么第一次循环可以通过,但之后的循环就会终止。
你已经在项目4中碰到过rangeOfString()了,所以这应该会很简单:
func wordIsPossible(word: String) -> Bool {
var tempWord = title!.lowercaseString
for letter in word.characters {
if let pos = tempWord.rangeOfString(String(letter)) {
tempWord.removeAtIndex(pos.startIndex)
} else {
return false
}
}
return true
}
这里rangeOfString()方法的使用方法跟项目4里面稍有不同。记住,rangeOfString()返回的是一个关于对象发现的位置的可选项——也有可能为nil,所以我们使用了if/let。
用法还有些不同是因为我们用的是String(letter)而不是letter。因为我们的for循环用在字符串上,而它把字符串里的每个字母都取出来,并保存为一个新的字符串。rangeOfString()寻求的是字符串,而不是字符,所以我们需要用String(letter)来把字符变成字符串。
如果字母在字符串里被找到,我们就用removeAtIndex()来移除tempWord变量中的字母。这就是我们需要tempWord的全部理由:因为我们会一直从中移除字母,这样我们就可以在下次循环时再检查一次。
方法的最后是return true,因为只有当用户单词的每个字母都被在初始词中只发现了一次时才会执行到。如果有字母没找到,或者用了超过一次,方法就会执行到return false然后结束调用,这样我们就可以确保单词没问题。
重要提示:我们已经告诉Swift方法返回的是一个布尔量,它会检查代码执行完之后的任何可能结果来确保返回值必须会布尔量。
轮到最后的方法了。用下面的代码替换当前的wordIsReal()方法:
func wordIsReal(word: String) -> Bool {
let checker = UITextChecker()
let range = NSMakeRange(0, word.characters.count)
let misspelledRange = checker.rangeOfMisspelledWordInString(word, range:range, startingAt: 0, wrap: false, language: "en")
return misspelledRange.location == NSNotFound
}
这里有个新类,叫UITexChecker。这是iOS中用来检查拼写错误的类,是用来检查单词是否实际存在的绝佳方法。我们创建了一个该类的新实例,然后把它放进常量checker中。
这里还调用了个新的方法,叫NSMakeRange()。它用来创建字符排列,一个保存起始位置和长度的变量。我们想要检查整个字符串,所以要从0开始,直到过完整个长度。
下一步,我们调用了UITextChecker实例的rangeOfMisspelledWordInString()方法。它有5个参数,但我们只关心第一、二个和第五个:第一个是目标单词,第二个是扫描长度,最后一个是我们使用的语言。
参数三和四在这里没啥用,但是为了完整我们说明下:参数3选择了扫描的起始点,参数4让我们设置如果从参数三的位置开始扫描没有发现错词,是否要让UITextChecker从排列的最开始开始。显然这里没啥用。
调用rangeOfMisspelledWordInString()返回的是结构体NSRange,告诉我们在哪儿发现了误拼。但我们关心的是是否发现误拼,如果没有发现我们的NSRange就会得到一个特殊的位置:NSNotFound。普通的NSRange返回值告诉你误拼的位置,但NSNotFound表示单词拼写正确——比如,这个单词可以用。
这里的return语句的用法很新鲜:返回的是“==”运算的结果。这是很常用的编程方法,“==”返回的真或假取决于misspelledRange.location跟NSNotFound是否相等。这个结果的真假会作为方法的结果通过return返回。
我们可以用另一种方法实现,但不太常用:
if misspelledRange.location == NSNotFound {
return true
} else {
return false
}
项目差不多要完成了。运行测试下看看!
或者其他什么?
还有问题需要修复,但不是什么大问题。如果单词是不曾出现过的,第一次输入的真实单词,我们就把它加进发现的单词列表中。但如果单词不真实呢?已经输入过了、或者不存在的呢?这时我们拒绝了单词但是没有给用户任何反馈。
所以最后一步是要给用户一个失败的反馈。这很无聊,因为就是要给submitAnswer()里的if语句加上else语句,每次都反馈一个信息给用户。
方法调整后如下所示:
func submitAnswer(answer: String) {
let lowerAnswer = answer.lowercaseString
let errorTitle: String
let errorMessage: String
if wordIsPossible(lowerAnswer) {
if wordIsOriginal(lowerAnswer) {
if wordIsReal(lowerAnswer) {
objects.insert(answer, atIndex: 0)
let indexPath = NSIndexPath(forRow: 0, inSection: 0)
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: .Automatic)
return
} else {
errorTitle = "Word not recognised"
errorMessage = "You can't just make them up, you know!"
}
} else {
errorTitle = "Word used already"
errorMessage = "Be more original!"
}
} else {
errorTitle = "Word not possible"
errorMessage = "You can't spell that word from '\(title!.lowercaseString)'!"
}
let ac = UIAlertController(title: errorTitle, message: errorMessage, preferredStyle: .Alert)
ac.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil))
presentViewController(ac, animated: true, completion: nil)
}
如你所见,每个if都跟一个else配对,这样用户就能得到合适的反馈。所有的else都差不多一样:给errorTitle和errorMessage设对用户有用的值。最后一个是个有意思的例外,我们用字符串插值来显示视图标题的小写格式。
如果用户输入了一个有效答案,return的调用促使Swift直接跳出该方法。这很有用,因为在方法的底部有个用errorTitle和errorMessage创建的UIAlertController,还添加了个nil操作员的OK按钮,然后显示警告。这样只有当什么出错时错误才会出现。
这个例子展示了关于Swift中常量很重要的一点:errorTitle和errorMessage都被定义为常量,也就是说它们的值一旦被设置就无法更改。我没有给它们初始值,这没关系——Swift允许你在它们被读取之前完成就行,只要之后不去修改就行。
项目完成了!
总结
到这里为止你做了这么多,所以你的Swift学习也进展到了这里。还有我希望这个项目能告诉你,你可以用你的知识来做更进一步的东西。
在这个项目中,你学习到了更多关于UITableView的内容:如何重新读取它们的数据和如何插入行。你还学会了如何向UIAlertController中添加文本输入框,这样你可以得到用户输入的内容。但你还学会了一些非常核心的内容:更多关于Swift的字符串,闭包,方法返回值,布尔量,NSRange等等。这些都是你在接下来的Swift生涯中将会反复用到的,也是在这个系列中不断重复的。
你可能已经有怎么改进这个游戏的计划了,如果还没有下面有4个值得入手的想法:
1.不允许答案短于3个字母。最简单的实现方法是使用wordIsReal()在单词长度短于3时返回假。
2.把else语句全部改写成一个方法showErrorMessage()。这个方法有两个参数error message和title,并完成UIAlertController的任务。
3.不允许答案就是初始词。现在的游戏还不能阻止用户输入初始词。
4.修复start.txt的载入代码。如果pathForResource()调用返回nil,我们载入一个只有一个单词的数组:silkworm。但如果pathForResource()成功了,而用contentsOfFile创建NSString失败了呢?这样的话答案数组就是空的了!写一个新的loadDefaultWords()方法,可以应对所有的错误。