iOS Apprentice中文版-从0开始学iOS开发-第二十五课

来个简短的插曲—关于函数编程

Swift首先是一种面向对象编程语言,但是实际上存在一种其他风格的编程方式并且在最近几年也很流行,那就是函数编程。

“函数”这个术语的意思就是,可以纯粹的通过数学函数来传送并且改变数据。

不同于Swift中的函数或者方法,这些数据函数不允许出现所谓的“侧面效应”,对于任何给定的输入,这个函数总是会产生一个同样的输出。方法则没这么严格。

虽然Swift不是一种函数语言,它还是可以让你在app使用一些函数编程的技术。它们会使你的代码变的非常简洁。

作为例子,我们再来看一看countUncheckedItems():

func countUncheckedItems() -> Int {
  var count = 0
  for item in items where !item.checked {
count += 1 }
  return count
}

这是相当简单的一小段代码。但是实际上你可以把它们浓缩为一行:

func countUncheckedItems() -> Int {
  return items.reduce(0) { cnt, item in cnt + (item.checked ? 0 : 1) }
}

reduce()是一个函数,作用为对每一个条目执行花括号内的代码。最初变量cnt包含的值为0,但是每次执行后它都会增加0或者1,这取决于条目是否checked。

当reduce()结束后,它返回为被打勾的数量的总和。

你不用记住或者理解这些,但是了解一下Swift可以允许你执行这种简洁的算法,是非常棒的。

对列表进行排序

对列表常见的另一个操作就是以某种规则排序。

让我们来以名称为顺序来对列表进行排序。目前当你添加一个Checklist的时候,新增的条目总是出现在最后一条,无论它的首写字母在字母表中的顺序如何。

在我们了解如何对数组进行排序前,让我们想想什么时候需要执行排序:

1、新增的时候

2、重命名的时候

没有必要在删除掉时候进行重新排序,因为删除不会引起序列的变化。

目前这两种操作都是由AllListsViewController中的 “didFinishAdding” 和“didFinishEditing”来执行。

将这两个方改动一下:

func listDetailViewController(_ controller: ListDetailViewController, didFinishAdding checklist: Checklist) {
        dataModel.lists.append(checklist)
        dataModel.sortChecklists()
        tableView.reloadData()
        dismiss(animated: true, completion: nil)
    }
    
    func listDetailViewController(_ controller: ListDetailViewController, didFinishEditing checklist: Checklist) {
        dataModel.sortChecklists()
        tableView.reloadData()
        dismiss(animated: true, completion: nil)
    }

在这两个方法中你删掉了不少东西,因为现在你总是在table view上使用reloadData()。

它再也不用手动插入一个新行了,或者更新cell的textLabel。而是简单的通过调用tableView.reloadData()来刷新整个列表的内容。

你再一次使用了全量更新,因为我们这个app中不太可能会出现大量数据。如果我们的列表中会出现上千行,那么用更加科学的方法才会显得比较有必要。(对于大量数据,你可以定位新增或者重命名的是哪一行,然后仅仅对这一行进行更新)

DataModel中的sortChecklists()是一个新的方法,你还没有添加它。在这之前,我们来讨论下排序是如何实现的。

当你对一个列表中的条目进行排序时,app会逐条比较来找出每一条合适的位置,但是比较两个Checklist对象是什么意思呢?

在我们的app中,很显然,我们想要根据名称进行排序,但是我们需要一些方法来告诉app我们的意图。

在DataModel.swift中添加以下方法:

func sortChecklists() {
        lists.sort(by: {
            checklist1, checklist2 in
            return checklist1.name.localizedStandardCompare(checklist2.name) == .orderedAscending
        })
    }

这里你告诉lists数组Checklists的内容要以某种准则排序。

这个准则由闭包提供。花括号内就是排序的代码;它们以闭包的形式存在:

lists.sort(by: { /* 这里就是排序代码 */ })

在Bull's Eye这个课程中,我们简单的介绍过一次闭包。它们包裹一段代码到一个匿名的,内联的方法中。

这个闭包的作用是判断一个Checklist对象是否应该排在另一个之前,基于你提供的排序规则。

这个排序算法会通过闭包中的准则重复的两两比较列表中的Checklist对象,直到数组被排序完毕。

如果你想以其他标准来进行排序,那么你就要修改闭包中的代码。

实际的排序准则是这个:

 checklist1.name.localizedStandardCompare(checklist2.name) ==
                                                       .orderedAscending

比较两个Checklist对象,你只需要知道它们的名字。

localizedStandardCompare() 方法会结合区域中的规则比较两个字符串,比较时会忽略大小写(它会认为A和a是一样的)。

区域是一个对象,它知道一个国家中的语法细节,比如Sorting在德语中的意思可能和英语中不太一样。

这就是对一个数组进行排序的全部工作:调用sort()并且给它一个闭包,闭包中包含着比较两个Checklist对象的逻辑。

为了确保以存在的条目也被同样的规则排序,你需要在plist文件被加载时也调用sortChecklists()方法:

func loadChecklists() {
        ...
            sortChecklists()
        }
    }

运行app并且新增几个待办事项分类。改变它们的名称,观察下是否已经按照名称排序了。

已排序的列表

给待办分类添加图标

因为真正的iOS开发者不能无限制的添加视图控制器和委托,所以我们要通过给Checklist对象添加一个新属性的方法来增加图标。我们要把这些概念固化在你的脑袋里。

当你完成后,编辑和新增待办事项分类的界面会看起来是这个样子:

你可以为分类选择一个图标

当你新增或者编辑待办事项分类的时候,可以通过当前界面打开一个新的界面来选择图标。这个图标选择器是一个新的视图控制器。这一次你不会使用modally转场,而是会将它推入导航器的栈堆。

可以在课程附带的Resources文件里找到相关的图片文件,文件夹的名字是Checklist Icons(小伙伴们可以通过购买正版图书得到这个附件,也可以去网上下载一些自己喜欢的素材图标来练习)

各种不同类型的图标

把图标从这个文件夹添加到Asset Catalog里。在工程导航起上选择Assets.xcassets,点击底部的加号按钮,并且选择Import

加号按钮在这里

在文件选择时打开Checklist Icons目录,全选其中的文件。

全选文件,而不是选择目录

注意:一定要选择文件,而不是目录。

然后点击Open,导入就完成了。

导入成功效果图

每一个图标都有对应Retina设备的2x版本和对应Retina HD的3x版本。

就像我在之前的课程中说过的一样,你不需要1x的低分辨率图标,所有的iPhone,ipad或者iPod,只要能运行iOS 10,就一定是2x或者3x分辨率。

打开Checklist.swift,添加一条属性:

var iconName: String

变量iconName存储图标的文件名。

扩展一下init?(coder)和encode(with),让它们可以分别在Checklist.plist文件中读取和存储这个图标名称。

required init?(coder aDecoder: NSCoder) {
        name = aDecoder.decodeObject(forKey: "Name") as! String
        items = aDecoder.decodeObject(forKey: "Items") as! [ChecklistItem]
        iconName = aDecoder.decodeObject(forKey: "IconName") as! String
        super.init()
    }
    
    func encode(with aCoder: NSCoder) {
        aCoder.encode(name, forKey: "Name")
        aCoder.encode(items, forKey: "Items")
        aCoder.encode(iconName, forKey: "IconName")
    }

假如你以后想要自己给这个app扩展什么功能的话,记住这一点,你要把所有涉及到的新的属性都添加到Checklist中,否则app就不会从plist文件中读取或者存储它们。

Xcode现在仍然有所不满,它在抱怨init(name)方法中没有这个新的属性,报错信息为:Property self.iconName is not initialized at super.init call

这个意思是,如果Checklist对象用init(name)初始化,而不是用init?(coder)初始化时,iconName没有值。

更新一下init(name)方法:

init(name: String) {
        self.name = name
        iconName = "Appointments"
        super.init()
    }

这样会给所有新的checklist一个“ Appointments”图标。

我猜你现在一定很想知道如何在table view上展示这个图标。不过在这之后,你还有更多的问题要操心,比如如何使用户选择图标。

打开AllListsViewController.swift,把图标放入cell中:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        ...
        cell.imageView!.image = UIImage(named: checklist.iconName)
        
        return cell
    }

使用标准cell的.subtitle风格时,会自带一个内建的UIImageView在左边。你可以简单的将图片给它,然后它会自动将图片展现出来,非常简单。

在重新运行app前,删除掉Checklist.plist文件,然后重置模拟器,因为我们的文件结构又发生了变化,如果不这样做,app就随时会死给你看。

运行app,现在每个待办分类都有了一个闹钟样子的图标:

图标展示成功

改造非常成功,你现在可以改变一下Checklist的init(name)方法,给每个Checklist对象一个“No Icon”图标作为默认。

打开Checklist.swift,在init(name)内,稍微改动一下:

iconName = "No Icon"

"No Icon"是个完全透明的PNG图片,它和其他图片的大小一模一样。使用透明图片的必要性在于,这样可以使所有的行格式一样,即使用户有时不想要一个图片。

如果我们仅仅是把iconName设置为空的话,那么图片位置就不会显示出来,标签会位于最左边,看起来非常难看,大家可以比较一下没有图片和透明图片的区别:

左边为没有图片,右边为透明图片

让我们来创建图片选择界面:

添加一个Swift文件到工程中,取名为IconPickerViewController。

将该文件中的预置内容全部删掉,替换为以下代码:

protocol IconPickerViewControllerDelegate: class {
    func iconPicker(_ picker: IconPickerViewController,didPick iconName: String)
}

class IconPickerViewController: UITableViewController {
    weak var delegate: IconPickerViewControllerDelegate?
}

这里定义了IconPickerViewController对象,它是一个table view controller,并且有有一个用来和这个app中其他对象通信的协议。

在class内部,新增一个常量数组来保存各种图片的名称:

let icons = ["No Icon",
                 "Appointments",
                 "Birthdays",
                 "Chores",
                 "Drinks",
                 "Folder",
                 "Groceries",
                 "Inbox",
                 "Photos",
                 "Trips"]

这是一个包含图片名称的常量数组。这些字符串同时也是asset catalog中PNG文件的名称。

这个数组就是这个视图控制器的数据模型。注意一下,这个数组不会发生改变,因为它是用let而不是var定义的,因为用户不能增加或者删除图片。

这个新的视图控制器是一个UITableViewController,所以你需要执行table view的数据源方法。

在文件中添加以下方法:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return icons.count
    }

这里简单的返回了数组中对象的数量

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "IconCell", for: indexPath)
        let iconName = icons[indexPath.row]
        cell.textLabel!.text = iconName
        cell.imageView!.image = UIImage(named: iconName)
        return cell
    }

这里你得到了一个table view cell并且给它了一个文本和图片。稍候你会在故事模版里设计这个cell。

这是一个标准cell,风格为“Default”(在界面建造器中对应的名字是“Basic”)。这个风格的cell自带一个文本标签,一个图片视图,非常的便利。

打开故事模版。拖拽一个Table View Controller到画布上,把它放在List Detail View Controller的旁边(就是名字叫做“Add Checklist”)的那个。

打开这个新视图的身份检查器,将在class中填入IconPickerViewController。

选定cell,在属性检查器中设置Style(风格)为Basic,Identifier为IconCell。

这样,图片选择器的cell就设置好了。现在你需要在某些地方调用它。为了实现这个目的,你需要在Add/Edit Checklist界面添加一个新的行。

选择List Detail View Controller,并且在table view中添加一个新的分节。你可以在table view的属性检查器中将Sections设置为2。这样就有两个分节了。

删除掉新cell上的Text Field,你并不需要它。

新增一个标签到这个cell上,并且命名为Icon。

设置这个cell的Accessory为Disclosure Indicator,这样你会得到一个灰色的大于号的按钮。

添加一个Image View到这个cell的右边,大小为36*36(可以使用尺寸检查器来设置)

⚠️:如果你的Mac OS版本为Sierra,Xcode中的Image View也许会展现为一个白色的矩形,非常难以观察。我认为这是Xcode的一个bug,在EI Capitan版本的Mac OS上,Image View就展现为一个蓝色的矩形。

打开辅助编辑器为这个image view添加一个outlet,命名为iconImageView。(辅助编辑器中的文件应该是在ListDetailViewController.swift)

这时,两个界面的设计都完成了,你现在可以使用转场把它们连接起来了。

按住ctrl拖拽这个新的table view cell到Icon Picker View Controller并且将转场的类型设置为Show。(确保你拖拽的是table view cell,而不是其中的图片或者标签)

将这个新的转场的identifier设置为PickIcon。

托转场的福,这个新的视图控制器有了一个导航栏。双击导航栏,将其标题命名为Choose Icon。

⚠️:如果双击无法修改导航栏标题,你可以先拖拽一个Navigation Item到这个新的视图控制器上。然后就可以修改标题了,Xcode也是存在一些bug的。

现在你的故事模版中的这一部分,看起来应该是这个样子:

完成图

打开ListDetailViewController.swift,将table view的willSelectRowAt委托方法修改一下:

override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        if indexPath.section == 1 {
            return indexPath
        } else {
            return nil
        }
    }

如果不这样做的话,点击“Icon”cell,就不会触发转场。

之前这个方法总是返回nil,就是说不可能对这一行进行点击。现在你必须允许用户点击“Icon”cell所在对行,所以当点击这一行时,需要返回它的indexPath。

因为“Icon”cell是第二个分节中的一行,所以你要查看一下indexPath.section,这里并不需要检查行号,因为第二个分节只有一行。用户仍然不能选择第一个分节中的行。

运行app,确认一下,在添加和编辑Checklist的界面有一行是用于选择图标的,并且点击这一行后会展示图标的列表。

像这样就对了

你可以点击back按钮来回到上一步,但是选择图标功能还不会生效,你点击一个图标,仅仅是可以看到这一行变灰了,表示已经被选中。

为了完成剩下的部分,你需要使用icon picker的委托协议把它和Checklist连接起来。

首先,在ListDetailViewController.swift中添加一个实例变量:

var iconName = "Folder"

你使用这个变量来跟踪被选择的图标名称。

因为即使Checklist对象已经有了一个iconName属性,你也不能保证百分百的追踪到被选择图标的名称,原因很简单,因为有可能此时Checklist对象根本不存在,比如正在新建一个Checklist的时候。

所以你要把图标的名称存储到一个临时变量里,并且在合适的时候把这个临时变量的值拷贝到Checklist的iconName属性中。

你要在合理的对iconName变量进行初始化。仅对新建Checklist对象时需要这样做,我们需要在新建Checklist对象时,给它一个默认图标,就用文件夹图标好了。

改动一下viewDidLoad()方法:

override func viewDidLoad() {
        super.viewDidLoad()
        
        if let checklist = checklistToEdit {
            title = "Edit Checklist"
            textField.text = checklist.name
            doneBarButton.isEnabled = true
            iconName = checklist.iconName    //添加这一行
        }
        iconImageView.image = UIImage(named: iconName)   //添加这一行
    }

这里新增了两行,如果checklistToEdit不为nil,那么你将Checklist对象的iconName拷贝到新建的iconName变量中,你同时还读取图标的图片文件到一个新的UIImage对象中,并且将它设置到iconImageView中,这样图标就可以展示出来了。

之前你创建的转场名称叫做“PickIcon”,你还需要执行prepare(for:sender:)来告诉IconPickerViewController这个界面是它的委托。

打开ListDetailViewController.swift,添加下面的方法:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "PickIcon" {
            let controller = segue.destination as! IconPickerViewController
            controller.delegate = self
        }
    }

这对你应该很轻车熟路了。

当然,Xcode已经发现了有些事情不对,并且给出了一个大大的警告:在“controller.delegate = self”这一行。

Xcode的意思是:“Cannot assign a value of type 'ListDetailViewController' to a value of type
'IconPickerViewControllerDelegate?'”(不能把类型ListDetailViewController的值分配给IconPickerViewControllerDelegate)

练习:我们忘记了什么?

答案:你还没有让这个视图控制器遵循委托协议,所以Swift不会使ListDetailViewController成为icon picker的委托。

在class这一行添加一点东西进去:

class ListDetailViewController: UITableViewController,UITextFieldDelegate,IconPickerViewControllerDelegate

并且执行委托协议中的方法,随便放在什么地方都可以,但是要在ListDetailViewController class的里面:

func iconPicker(_ picker: IconPickerViewController, didPick iconName: String) {
        self.iconName = iconName
        iconImageView.image = UIImage(named: iconName)
        let _ = navigationController?.popViewController(animated: true)
    }

这里将被选择的图标名称存放到iconName变量中,并且用新的图片更新了image view。

这里不能调用dismiss()而是调用popViewController(animated),因为Icon Picker位于导航栈堆中。当创建这个转场的时候你使用了Show而不是present modally,所以这里要使用“pop”,dismiss()仅用于present modally。

回忆一下,navigationController是这个视图控制器的一个可选型的属性,所以你使用问号或者感叹号来解包得到实际的UINavigationController对象。通过let _ =你告诉了Xcode不需要关心popViewController()的返回结果。不这样做的话,Xcode又会给出一个警告。这里的下划线符号叫做通配符,你可以用具体的变量名称来替换它。

⚠️:你已经知道了self用于引用对象自己。这里你写的:
self.iconName = iconName
理由是这里iconName可以指代两个不同的东西:1、委托方法的参数。2、实例变量。
为了消除歧义,你用带有self前缀的来表示实例变量,这样编译器就很清楚你想要用的是这两个iconName中的那一个。

改变一下done()方法,以便于将被选择的icon name放入Checklist对象。

@IBAction func done() {
        if let checklist = checklistToEdit {
            checklist.name = textField.text!
            checklist.iconName = iconName  //添加这一行
            delegate?.listDetailViewController(self, didFinishEditing: checklist)
        } else {
            let checklist = Checklist(name: textField.text!)
            checklist.iconName = iconName  //添加这一行
            delegate?.listDetailViewController(self, didFinishAdding: checklist)
        }
    }

最后,你必须改变IconPickerViewController,使它在某一行被点击时,实际的调用委托方法。

打开IconPickerViewController.swift,在底部添加一个方法:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let delegate = delegate {
            let iconName = icons[indexPath.row]
            delegate.iconPicker(self, didPick: iconName)
        }
    }

现在你就完成了对Checklist对象图标设置的工作。

回顾一下,你做了以下事情:

1、添加一个新的视图控制器

2、在故事模版中设计界面

3、使用转场和委托将这个视图控制和添加及编辑Checklist界面连接起来。

当你每新建一个视图控制的时候,都需要完成以上基本步骤。

运行app,试试效果如何:

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

推荐阅读更多精彩内容