美文网首页iOS Developer
iOS Apprentice中文版-从0开始学iOS开发-第二十

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

作者: Billionfan | 来源:发表于2017-07-27 02:45 被阅读404次

    上节课,我们已经实现了一个本地通知。为什么我要求你们要先按Home键退回到主界面呢?那是因为iOS的消息通知,仅仅在app未使用时才会生效,如果你正在使用app,你当然不需要关于这个app的提醒。

    点击Stop按钮中断app,然后再次运行app,这次不要按Home键退回主界面,再进行一次漫长的等待,看吧,什么都不会发生,我只希望你不要等了太久。

    消息通知的功能已经实现了,但是它和用户的待办事项是相互独立的,两者之间还不存在关系,为了解决这个问题,我们要以某种方式让相关的事件注意到本地通知。怎么办呢?当然是通过使用委托了。

    在AppDelegate的class声明的那一行上改动一下:

    class AppDelegate: UIResponder, UIApplicationDelegate,UNUserNotificationCenterDelegate {
    

    这样就让AppDelegate成为了UNUserNotificationCenter的委托。

    同时在AppDelegate.swift中添加以下方法:

    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
            print("Received local notification \(notification)")
        }
    

    这个方法当app仍在运行时有本地通知发布时被调用。你不用在这里做任何操作,除了在调试区域打印一条消息。

    当app在前台运行时,它假定任何通知都会自己照顾自己。根据app的类型不同,通知也许会被展现给用户,也许会自动刷新界面。

    最后,告诉UNUserNotificationCenter现在AppDelegate是它的委托了。在application(didFinishLaunchingWithOptions)方法中添加一行代码来完成这件事:

    center.delegate = self
    

    再次重新运行app,不要按Home键,等待10秒,10秒之后你会看到调试区域打印出了一条消息:

    Received local notification <UNNotification: 0x60800003b160; date: 2017-07-23 13:38:50 +0000, request: <UNNotificationRequest: 0x6000004250c0; identifier: MyNotification, content: <UNNotificationContent: 0x600000107b30; title: Hello, subtitle: (null), body: I am a local notifcation, ...
    

    好了,你已经确定它在工作了,你需要从AppDelegate.swift中移除掉所有代码,因为你并不需要每次用户启动app时都安排新的通知。

    从didFinishLaunchingWithOptions中把所有通知相关的代码移除掉,仅保留以下两行:

    let center = UNUserNotificationCenter.current()
            center.delegate = self
    

    你可以userNotificationCenter(willPresent...)方法也留下,让它继续在调试区域打印消息。

    扩展数据模型

    让我们来考虑一下,app应该如何处理这些消息。每条待办事项都应该有一个处理时间的字段(一个Date型对象,可以指定具体的日期和时间)并且需要有一个Bool型对象来判断用户是否想要对这一条信息进行提示。

    用户也许不需要对每一条待办事项都进行提醒,所以你不能对所有的待办事项都安排一条通知。所以我们需要一个Bool型对象来进行判断,名字就叫做shouldRemind好了。

    你要在Add/Edit Item界面上增加关于它们的设置,完成后的样子看起来会是这个样子:

    处理时间字段需要靠某种可以选择时间的控制器实现。iOS自带一个非常酷的日期选择视图,你可以直接把它添加到table view中。

    首先,让我们指出应该在什么时间以什么方式来安排通知。我考虑的情况如下:

    1、当用户添加一条新的待办事项时,如果同时设置shouldRemind标示为true,则需要安排一条通知。

    2、当用户对已存在的待办事项的处理时间进行编辑的时候,旧的通知安排需要被取消(如果之前有安排通知的话),并且安排一条新的通知(如果用户没有取消shouldRemind设置的话)

    3、当用户对shouldRemind状态进行true到false切换的时候,存在的通知需要被取消,从false到true到时候需要安排一条通知。

    4、当用户删除一条待办事项的时候,需要取消通知(如果之前有的话)

    5、当用户删除一个待办分类的时候,其中所有已存在的通知都要被取消掉。

    通过上面的分析,你要做的事情就一目了然了。

    你同时需要注意一下,不能为哪些处理时间已经小于当前时间的待办事项安排通知。虽然iOS会自动忽略这些通知,但是我们还是最好做到自己处理,养成考虑周全的习惯。

    要把ChecklistItem对象和它们的通知联系起来,就必须修改一下数据模型。

    当你安排一条本地通知的时候,你就创建了一个UNNotificationRequest对象。你可能会觉得既然如此,那么将UNNotificationRequest对象作为一个实例变量放入ChecklistItem中就好了,但是,这并不是正确的方法。

    取而代之的是,你要使用一个标识符,每当你创建一条本地通知时,你都给他一个标识符,这个标识符可以用一个字符串。字符串中的内容并不重要,只要它不发生重复就行。

    当取消同时的时候,你并不需要对UNNotificationRequest进行操作,而是操作作为标识符的字符串就可以了。所以正确的做法是将这个标识符存放在ChecklistItem中。

    即使用做通知标识符的是一个字符串,但是实际上我们我们给它的值将是数字。你还需要将这些数字保存至Checklist.plist文件中。每次你安排或者取消一条通知时,你就将数组转换为字符串。这样当有一个ChecklistItem对象时,你就可以简单的找到对应的通知,或者当你有一个通知时就能简单的找到ChecklistItem对象。

    创建一个数字序列ID,是非常普遍的一种行为,就和关系型数据库中的主键一样。

    首先在ChecklistItem.swift中添加以下代码:

    var dueDate = Date()
    var shouldRemind = false
    var itemID: Int
    

    我们将它取名为itemID,而不是简单的取名为id,那是因为id是OC中的一个特殊关键字,用id做变量名会使编译器困惑。

    其中的dueDate和shouldRemind都有初始值,而itemID则没有。这就是为什么你要指定itemID的类型的原因,而其他两个不需要指定类型。因为swfit有类型推断,记得吗?

    你还需要拓展一下init?(coder) 和 encode(with),这样就可以在保存和读取ChecklistItem对象时,把它们也包含进去了。

    在init?(coder)中添加以下代码:

    dueDate = aDecoder.decodeObject(forKey: "DueDate") as! Date
            shouldRemind = aDecoder.decodeBool(forKey: "ShouldRemind")
            itemID = aDecoder.decodeInteger(forKey: "ItemID")
    

    在encode(with)中添加以下代码:

    aCoder.encode(itemID, forKey: "ItemID")
            aCoder.encode(shouldRemind, forKey: "ShouldRemind")
            aCoder.encode(dueDate, forKey: "DueDate")
    

    我们对dueDate使用了decodeObject(forKey),shouldRemain使用了decodeBool(forKey),而对itemID使用了decodeInteger(forKey),这是非常必要的,因为NSCoder系统使用OC写的,这种语言对类型的要求非常严谨。

    对OC而言Int、Float、和Bool属于原始类型。其他的东西比如String和Date属于对象。这点和Swift不同,Swift对待所有东西都是按照对象处理。但是因为这里你要使用的是OC的框架,所以你必须遵守OC的规则。

    非常棒,现在这些属性也可以被存储和读取了。

    现在Xcode中还存在一处报错:init()需要itemID有一个值,因为每新建一个对象都需要一个值。所以你需要在init()中给itemID分配一个值。

    在init()中添加以下代码:

    override init() {
            itemID = DataModel.nextChecklistItemID()
            super.init()
        }
    

    这个代码的作用是无论app是否新创建一个ChecklistItem对象,你都向DataModel请求一个新的ID。

    我们现在就来添加这个新的方法,这个方法和它的名字一样每次都返回一个不同的ID。

    打开DataModel.swift,添加这个新的方法:

    class func nextChecklistItemID() -> Int {
            let userDefaults = UserDefaults.standard
            let itemID = userDefaults.integer(forKey: "ChecklistItemID")
            userDefaults.set(itemID + 1, forKey: "ChecklistItemID")
            userDefaults.synchronize()
            return itemID
        }
    

    我们又见到了老朋友UserDefaults。

    这个方法从UserDefaults中得到目前的“ ChecklistItemID”的值,然后将它加1,然后将之前没有加1的值返回给调用者。

    同时它用userDefaults.synchronize()强制UserDefaults实时的将变化写入磁盘,这样就算app突然中断了,也不会丢失数据,从而保证不会出现重复的值。

    在registerDefaults方法中为“ ChecklistItemID”的值添加初始值(注意一下,一定是要在FirstTime的后面添加):

     func registerDefaults() {
            let dictionary: [String: Any] = ["ChecklistIndex": -1,"FirstTime": true,"ChecklistItemID: 0"]
           ...
    

    nextChecklistItemID第一次被调用后返回0,然后每次加1。就算你调用上亿次都不会重复。

    类方法和实例方法(Class methods & instance methods)

    如果你对下面的语句感到好奇,为什么是:

    class func nextChecklistItemID()
    

    而不是:

    func nextChecklistItemID()
    

    那么我很高兴你如此细心。

    class关键字意味着你可以在不引用DataModel的前提下,调用这个方法。

    记住,你使用:

    itemID = DataModel.nextChecklistItemID()
    

    来调用类方法,而不是:

    itemID = dataModel.nextChecklistItemID()
    

    这是因为ChecklistItem对象没有一个用于引用DataModel的dataModel属性。当然,你可以给它一个这样的引用,但是我决定使用类方法,这样简单些。

    声明类方法使用关键字class func,这种类型的方法适用于整个类。

    到目前为止你使用的方法都是实例方法,使用关键字func定义,只能用于类中一个特定的实例。

    以前我们没有讨论过类方法和实例方法的区别,在以后的课程中我们会逐渐深化这个话题。就现在而言,仅仅记住用class func声明的方法可以允许你在任何对象上调用它,甚至在不引用这个对象的前提下。

    我不得不做出一个权衡:给每个ChecklistItem对象一个到DataModel的引用是否值得,或者简单的使用一个类方法就好了?为了保持简单,我选择后者。如果你未来还在开发app的话,那么你很可能遇到这种需要做出权衡的情况。

    为了快速的测试分配的ID是否正常工作,你可以把它放到ChecklistItem的标签中展示出来看,下面的代码仅仅是用做测试,因为这些内部的ID号没有必要展示给用户看。

    打开ChecklistViewController.swift,改动一下configureText(for:with:)方法:

    func configureText(for cell: UITableViewCell,with item: ChecklistItem) {
            let lable = cell.viewWithTag(1000) as! UILabel
            //lable.text = item.text
            lable.text = "\(item.itemID):\(item.text)"
        }
    

    把原来的那一行注释掉,不要删掉,因为你一会还要改回来。

    在重新运行app之前,一定要重置模拟器,并且把Checklist.plist文件删掉,因为我们的数据模型已经变了,旧的文件结构会导致app崩溃掉。

    运行app,并且添加几条待办事项,每一个都会得到一个唯一的ID,使用Home键回到iOS的主界面,然后中断掉app,然后再次运行app。然后在新增几条待办事项,你会看到它们的编号和之前的是连续的。

    效果图

    OK,ID们工作的很好。现在我们来添加“due date”和“should remind”到Add/Edit Item界面。

    先不要把configureText(for:with:)改回去,我们还要继续用它做测试。

    打开ItemDetailViewController.swift,添加两个outlet:

    @IBOutlet weak var shouldRemindSwitch: UISwitch!
    @IBOutlet weak var dueDateLable: UILabel!
    

    打开故事模版,选择Item Detail View Controller中的table view(名字为Add Item的那个)

    为这个table新增一个分节,这非常简单,打开属性检查器然后将Section字段设置为2就可以了。这样会复制一个已存在的cell过去。

    删除这个新的cell中的Text Field。拖拽一个新的Table View Cell到这个新的cell的下面,这个这个新增的分节就有两个cell了。

    最终我们完成设计时,界面会是这个样子:

    拖拽一个Lable到第一个cell的左边,输入文本Remind Me,设置字体为System 17。

    在拖拽一个Switch到这个cell的右边。将这个Switch和shouldRemindSwitch连接起来,然后在它的属性检查器中将Value设置为off,这样它的初始状态就是关闭的了,开关会由绿色变为灰色。

    将这个Switch的顶部及右侧固定起来(使用Pin菜单),这样就保证了这个控件可以匹配所有的设备大小。

    下面的一个cell应该具备两个标签:左边的标签负责获取并且显示用户选择的时间,右边的负责选择时间。实际上你不需要去拖拽两个标签上去,仅仅是将这个cell的风格修改为Right Detail,然后将标签重命名为Due Date就可以了。

    右边的那个标签应该和dueDateLabel outlet连接起来。(这个标签比较难以选中,你需要多点几次试试)

    你还需要将Remind Me标签以及Switch的位置移动一下,让它俩和下面的两个标签保持左对齐,你选择下面的标签,打开尺寸检查器,看看它们的x值是多少,然后把Remind Me标签和Switch的x值设置为一致就可以了,用不着拖来拖去的微操。

    下面进入代码部分:

    打开ItemDetailViewController.swift,添加一个新的实例变量dueDate:

    var dueDate = Date()
    

    对于每一个新的ChecklistItem,due date都应该默认当前时间。但是假如用户选择了时间,那么就要立刻把当前时间替换掉。

    这里还有一些其他选择,比如默认时间设置为明天或者10分钟以后,但是实际上,用户基本上都会立即选择时间,所以对于默认时间不需要做太多考虑。

    改动一下viewDidLoad():

    override func viewDidLoad() {
            super.viewDidLoad()
            
            if let item = itemToEdit {
                title = "Edit Item"
                textField.text =  item.text
                doneBarButton.isEnabled = true
                shouldRemindSwitch.isOn = item.shouldRemind  //新增这一行
                dueDate = item.dueDate   //新增这一行
            }
            updateDueDateLabel()   //新增这一行
        }
    

    对于已经存在的ChecklistItem对象,你设置switch的状态需要使用这个对象的shouldRemind属性,如果是新增的,那么初始状态默认为off,我们在故事模版中做了设置。

    你同时还从ChecklistItem中获取了due date。

    这个updateDueDateLabel()是个新的方法,我们现在把它添加上:

    func updateDueDateLabel() {
            let formtter = DateFormatter()
            formtter.dateStyle = .medium
            formtter.timeStyle = .short
            dueDateLable.text = formtter.string(from: dueDate)
        }
    

    你使用DateFormatter来将日期转换为文本。

    它的工作原理非常明显:你给它的date部分设置了一个风格,time部分设置了另外一个风格,并且从中获得格式化好的Date对象。

    你可以试试其他类型的风格,但是由于label的尺寸有点小,所以也看不出什么效果。

    DateFormatter最酷的地方是它返回的是当地时间,不管你在地球上的那个地方,DateFormatter都是返回你所在地的当地时间。

    最后一件事情就是修改done方法:

    @IBAction func done() {
            if let item = itemToEdit {
                item.text = textField.text!
                
                item.shouldRemind = shouldRemindSwitch.isOn //新增这一行
                item.dueDate = dueDate  //新增这一行
                
                delegate?.itemDetailViewController(self, didFinishEditing: item)
            } else {
            let item = ChecklistItem()
            item.text = textField.text!
            item.checked = false
            
                item.shouldRemind = shouldRemindSwitch.isOn //新增这一行
                item.dueDate = dueDate  //新增这一行
                
            delegate?.itemDetailViewController(self, didFinishAdding: item)
            }
        }
    

    当用户点击done按钮的时候你将switch和due实例变量的值返回给ChecklistItem对象。

    运行app,改变开关的状态。app在中断后也会记得开关的最终状态(记得先退回主界面再中断app)

    due date还没有生效,想要让它工作,你必须先创建一个时间选择器。

    ⚠️:你也许想知道为什么你对dueDate使用了一个实例变量,而shouldRemind没有。
    因为并不需要这样做,你可以轻易的从switch控件中得到它的状态值,通过isON属性,这个属性返回值也是true和false。
    然而,从dueDateLabel中将时间读取出来就没那么容易了,因为这个label存储的文本是String型的,不是Date。所以我们用了一个实例变量来跟踪日期的值。

    时间选择器

    时间选择器(date picker)对我们而言并不是什么新的视图控制器。我们要实现的效果是,点击Due Date这一行自动在table view中插入一个UIDatePicker组件,日历型的app通常就具备这一功能。

    时间选择器

    打开ItemDetailViewController.swift,添加一个新的实例变量来跟踪时间选择器是否可见:

    var datePickerVisible = false
    

    并且添加showDatePicker()方法:

    func showDatePicker() {
            datePickerVisible = true
            let indexPathDatePicker = IndexPath(row: 2, section: 1)
            tableView.insertRows(at: [indexPathDatePicker], with: .fade)
        }
    

    这里将刚添加的实例变量设置为true,并且告诉table view插入一个新行到Due Date这一行下面。这个新插入到行将用来容纳UIDatePicker。

    问题是:用于date picker这一行的cell从哪来?你不能像静态cell那样直接把它放入table view。因为这样就会使它总是可见。而你仅仅想要用户点击Due Date这一行后它才显示。

    Xcode有一个非常酷的功能可以使你添加附加视图到场景中,而并不立即显示它们。这是我们解决这个问题的不二之选。

    打开故事模版找到Add Item界面。拖拽一个table view cell,不要把它拖拽到视图控制器里面,而是拖拽到顶部的dock里,见下图:

    拖拽完毕后,故事模版看起来会是这个样子:

    这个新的table view cell对象属于这个场景,但是它还不是这个场景的table view的一部分。

    这个cell也有点小,不足以容纳一个date picker,所以首先我们来把它弄大点。

    选择这个table view cell打开尺寸检查器,设置Height为217,date picker的高是216,所以我们要设置高一个点位,在顶部留一点空隙,否则会非常难看。

    然后打开属性检查器,设置Selection为None,这样就使cell在你点击它的时候不会变灰。

    然后拖拽一个date picker到这个cell中,它应该刚好可以容纳进去。

    使用Pin菜单将date picker的四条边都固定好。注意不要勾选Constrain to margins复选框。

    当你完成后,新的cell看起来应该是这个样子的:

    那么你如何将这个cell放入table view中呢?首先,做两个个新的outlets并且把它们分别和date picker与cell连接起来,这样你就可以在代码中引用这两个视图了。

    打开ItemDetailViewController.swift,添加以下代码:

    @IBOutlet weak var datePickerCell: UITableViewCell!
    @IBOutlet weak var datePicker: UIDatePicker!
    

    回到故事模版,注意一下顶部的dock栏,上面有一个黄色圆圈的图标,它就代表这个视图控制器。

    按住ctrl从这个黄色圆圈图标拉线到灰色的那个代表table view cell的图标,然后选择datePickerCell outlet:

    然后还是按住ctrl从这个黄色圆圈图标到Date Picker上,之后选择datePicker就完成了date picker的连接。

    非常棒,现在你完成了cell和date picker的连接,你可以通过写点代码,把它们添加到table view上了。

    通常你会执行tableView(cellForRowAt)方法,但是记住,这是用于静态cell的情况。像我们这种情况下,不存在数据源,所以也就不存在cellForRowAt。

    如果你观察下ItemDetailViewController.swift,你不会看到有这个方法存在。通过一些列手段,你可以为静态的table view重写数据源,并且提供你自己写的方法。

    我们这样在ItemDetailViewController.swift中添加cellForRowAt:

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            if indexPath.section == 1 && indexPath.row == 2 {
                return datePickerCell
            } else {
                return super.tableView(tableView, cellForRowAt: indexPath)
            }
        }
    

    注意:你不能对它进行太多的操作,当它由一个静态table view使用时,因为它也许会影响这些静态cell的内部工作方式。但是如果你足够小心的话,你可以避免它。

    这个if语句检查cellForRowAt是否被date picker的indexPath调用。如果是,它返回你刚设计的datePickerCell。这样操作是安全的,因为这个table view对row 2,section 1毫不知情,所以你不会影响到已存在的静态cell。

    对于其他任何不是date picker cell的行,这个方法会调用super.tableView(tableView, cellForRowAt: indexPath),通过这种手段来保证其他的静态cell正常工作。

    你还需要重写tableView(numberOfRowsInSection):

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            if section == 1 && datePickerVisible {
                return 3
            } else {
                return super.tableView(tableView, numberOfRowsInSection: section)
            }
        }
    

    如果date picker可见,那么section 1就有三行,如果不可见,则仅返回原始的数据源。

    同样的,我们来重写tableView(heightForRowAt)方法:

    override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            if indexPath.section == 1 && indexPath.row == 2 {
                return 217
            } else {
                return super.tableView(tableView, heightForRowAt: indexPath)
            }
        }
    

    到目前为止你的table view中的cell都是同样的高度,都是44,但是改变它并不难,你可以通过“heightForRowAt”来控制每个cell的高度。

    如果是date picker所属的cell的话,我们设置它的高为217。

    date picker仅在用户点击due date这一行的cell时才显示,我们来添加相关的代码:

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            tableView.deselectRow(at: indexPath, animated: true)
            textField.resignFirstResponder()
            
            if indexPath.section == 1 && indexPath.row == 1 {
                showDatePicker()
            }
        }
    

    当due date这一行被点击后调用showDatePicker(),如果此时界面上有虚拟小键盘的话,也会被自动隐藏掉。

    此时,你已经完成了大部分工作,但是due date这一行现在实际上并不能被点击,这是因为ItemDetailViewController.swift中已经存在了一个“willSelectRowAt”方法,它总是返回nil,所以点击会被忽视掉。

    我们来改动一下tableView(willSelectRowAt):

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

    现在due date这一行会被选中了,而其他行不会。

    运行app,试试效果。添加一个新的待办事项,并且点击due date这一行。

    不出意外的话你会发现app挂了,如果没有挂的话,那就真的很意外了,通过一些调查我发现,当你为静态table view重写了数据源后,你还需要提供委托方法:tableView(indentationLevelForRowAt)

    这不是你经常使用的一个方法,但是因为你动了用于静态table view的数据源,所以你必须重写它。我早就告诉过你(其实并没有)。

    添加tableView(indentationLevelForRowAt)方法:

    override func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int {
            var newIndexPath = indexPath
            if indexPath.section == 1 && indexPath.row == 2 {
                newIndexPath = IndexPath(row: 0,section: indexPath.section)
            }
            return super.tableView(tableView, indentationLevelForRowAt: newIndexPath)
        }
    

    app会因为这个方法挂掉的原因是标准的数据源对section 1,row2的cell(就是date picker所属的cell)毫不知情,甚至不知道它的存在,因为这个cell在设计时并不属于这个table view。

    所以在插入date picker所属的cell后,数据源表示我没见过它,所以app就躺枪了。为了克服这个问题,你需要在date picker显示时欺骗数据源,使它确信这一行真的存在。这就是indentationLevelForRowAt这个方法的作用。

    运行app,这一次点击due date后,可以正常显示出date picker了。

    当你选择date picker中的时间的时候,选择的结果应该反馈在Due Date这一行中,但是现在并没有起到这个效果。

    我们需要监听date picker的值的改变事件。无论何时,当date picker上的滚轮被转动时都必须触发这一事件。为了实现这个需求,你需要添加一个新的方法。

    打开ItemDetailViewController.swift,添加这个方法:

    @IBAction func dateChanged(_ datePicker: UIDatePicker) {
            dueDate = datePicker.date
            updateDueDateLabel()
        }
    

    这非常简单。它使用date picker的时间来更新dueDate,然后更新Due Date这一行的标签。

    打开故事模版,按住ctrl拖拽Date Picker到视图控制器,并且选择dateChanged动作方法。现在所有的连接都完成了。

    你一定要确认这个动作方法连接的是date picker的Value Changed事件。可以通过查看链接检查器来确认。

    运行app,试试效果。当你转动date picker上的滚轮时,Due Date中的标签也会随着变化。

    然而,当你编辑一条已存在的待办事项的时候,data picker总是显示当前时间。

    在showDatePicker()方法的底部添加一行:

    datePicker.setDate(dueDate, animated: false)
    

    这样就给了UIDatePicker组件一个合适的时间。

    确认一下它是否按照我们的意图工作,编辑一条已存在的待办事项,最好用已经设置过due date的,确认一下date picker上的时间和due date标签上的时间一致。

    当date picker可见的时候如果Due Date上的标签能够高亮显示,那么久太棒了。你可以使用tint color来实现这一目的(这也是日历型app常见的功能)

    再改一次showDatePicker:

    func showDatePicker() {
            datePickerVisible = true
            let indexPathDateRow = IndexPath(row: 1, section: 1)
            let indexPathDatePicker = IndexPath(row: 2, section: 1)
            
            if let dateCell = tableView.cellForRow(at: indexPathDateRow) {
                dateCell.detailTextLabel!.textColor = dateCell.detailTextLabel!.tintColor
            }
            
            tableView.beginUpdates()
            tableView.insertRows(at: [indexPathDatePicker], with: .fade)
            tableView.reloadRows(at: [indexPathDateRow], with: .none)
            tableView.endUpdates()
            
            datePicker.setDate(dueDate, animated: false)
        }
    

    这样就将detailTextLabel的颜色设置为了tint color。它同时也告诉table view需要重新加载Due Date这一行。但是cell之间的间隔线没有被更新。

    因为你在同一时间对这个table view进行了两种操作,插入一个新行并且重新加载另一个,你需要把它们放到叫做beginUpdates()和 endUpdates()的东西之间,这样就可以同时更新所有东西了。

    运行app,现在日期是浅蓝色了。

    当用户再次点击Due Date这一行时,date picker应该自动消失掉。如果你现在这样做的话app就会挂掉,这样肯定不会为你在app store中带来太多好评。

    添加一个新的方法:

    func hideDatePicker() {
            if datePickerVisible {
                datePickerVisible = false
                
                let indexPathDateRow = IndexPath(row: 1, section: 1)
                let indexPathDatePicker = IndexPath(row: 2, section: 1)
                
                if let cell = tableView.cellForRow(at: indexPathDateRow) {
                    cell.detailTextLabel!.textColor = UIColor(white: 0, alpha: 0.5)
                }
                
                tableView.beginUpdates()
                tableView.reloadRows(at: [indexPathDateRow], with: .none)
                tableView.deleteRows(at: [indexPathDatePicker], with: .fade)
                tableView.endUpdates()
            }
        }
    

    这个方法的作用和showDatePicker()。它从table view中删除了date picker cell并且将date label的颜色恢复为灰色。

    改变一下tableView(didSelectRowAt)来触发显示和隐藏状态:

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            tableView.deselectRow(at: indexPath, animated: true)
            textField.resignFirstResponder()
            
            if indexPath.section == 1 && indexPath.row == 1 {
                if !datePickerVisible {
                    showDatePicker()
                } else {
                    hideDatePicker()
                }
            }
        }
    

    还存在一种情况,需要我们把date picker隐藏起来:当用户点击text field的时候。

    如果虚拟键盘和时间选择器重叠在一起的话,会非常难看,所以你最好还是把时间选择器隐藏起来。这个视图控制器已经是text field的委托了,我们处理起来会非常简单。

    添加textFieldDidBeginEditing()方法:

    func textFieldDidEndEditing(_ textField: UITextField) {
            hideDatePicker()
        }
    

    这样就非常完美了。

    运行app并且确认是否一切工作正常。

    安排本地通知

    经过这么漫长的插曲,希望大家不要忘了,我们最终的目的是安排本地通知。

    面向对象编程的一个原则是,对象可以尽可能的利用自己。因此,让ChecklistItem对象来安排它自己的通知。

    打开ChecklistItem.swift:

    func scheduleNotification() {
            if shouldRemind && dueDate > Date() {
                print("We should schedule a notification")
            }
        }
    

    这里我们对比了due date和当前时间。你可以通过使用Date对象来获得当前时间。

    语句dueDate > Date() 比较两个时间后返回true和false。

    如果返回false的话,则print不会执行。

    注意一下这个“&&”符号,表示“与”,只有当Remind Me被设置为on,且due date大于Date()时,print才被执行。

    当用户新增或者编辑完一条待办事项后,点击Done按钮时,你调用这个方法。

    打开ItemDetailViewController.swift,在didFinishEditing和didFinishaAdding前面添加一行:

    item.scheduleNotification()
    

    运行app,试试效果。添加一条新的待办事项,将开关状态设置为on,不要改变due date。然后点击Done。

    这时在调试区域应该没有打印出消息,因为due date小于当前时间(当你点击Done按钮的时候已经有几秒过去了)

    再添加一条待办事项,将switch设置为on,并且选一个几分钟后的due date。

    然后点击Done按钮,这时调试区域应该打印出一条消息“We should schedule a notification”

    现在你可以确认这个方法确实被调用了,我们来实际的把本地消息添加进去。首先考虑新增待办事项的情况。

    打开ChecklistItem.swift,将scheduleNotification()修改为:

        func scheduleNotification() {
            if shouldRemind && dueDate > Date() {
                //1
                let content = UNMutableNotificationContent()
                content.title = "Reminder"
                content.body = text
                content.sound = UNNotificationSound.default()
                //2
                let calender = Calendar(identifier: .gregorian)
                let components = calender.dateComponents([.month,.day,.hour,.minute], from: dueDate)
                //3
                let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
                //4
                let request = UNNotificationRequest(identifier: "\(itemID)", content: content, trigger: trigger)
                //5
                let center = UNUserNotificationCenter.current()
                center.add(request)
                
                print("We should schedule a notification")
            }
        }
    

    你在第一次调试本地通知的时候应该见过这些代码,但是这里有些不同。

    1、将item的文本放入通知中

    2、从dueDate中提取月、日、小时和分钟。我们不关心年和秒。

    3、之前你用UNTimeIntervalNotificationTrigger来测试本地消息,但是现在这里,你使用它来展示详细的时间。

    4、创建UNNotificationRequest对象。这里比较重要的是,我们把待办事项的ID转换为String型,并且使用它来确定通知。假如你之后需要取消这条消息的话,就可以用这个标示找到它。

    5、添加新的通知到UNUserNotificationCenter。

    唯一的问题就是,Xcode给出了一大堆报错。

    出什么事了呢?ChecklistItem还没有导入本地消息的框架,现在它只有NSObject、NSCoder和Foundation框架。

    导入框架非常简单:

    import UserNotifications
    

    这样就可以了。

    这里还有另外一个小问题。如果你重置过模拟器,那么此时app就不再被允许发送本地通知。

    你不能假定app总是被允许发送通知消息的。最初你测试的时候,是将请求许可的代码放入了AppDelegate中,但是现在不行了,也不推荐这样做。

    因为你本人肯定讨厌那些强制的消息,这种app一点都不受欢迎,我们让自己的app变得美好一些。

    打开ItemDetailViewController.swift,添加以下方法进去:

    @IBAction func shouldRemindToggled(_ switchControl: UISwitch) {
            textField.resignFirstResponder()
            
            if switchControl.isOn {
                let center = UNUserNotificationCenter.current()
                center.requestAuthorization(options: [.alert,.sound], completionHandler: {
                    granted ,error in /*do nothing*/
                })
            }
        }
    

    当switch设置为on时,会自动提示用户允许通知消息,一旦用户给予了许可 ,app不会再次请求许可了就。

    同时记得添加import UserNotifications。导入UserNotifications。

    运行app,新增一个待办事项,设置due date到几分钟后,点击Done按钮,并且会到iOS主界面。

    你就可以看到本地通知已经生效了:

    现在新增部分已经实现了,还剩下几个情况,1、用户编辑待办事项时,2、用户删除待办事项时。

    我们先来做编辑部分,当用户编辑待办事项时,会发生以下情况:

    1、Remind Me曾经是off,现在被设置为on。你需要安排一条通知

    2、Remind Me曾经时on,现在被设置为off,你要取消掉已存在的通知

    3、Remind Me保持为on,但是due date改变了,你需要取消旧的通知,安排新的通知。

    4、没有任何改变,你不需要做任何事。

    5、Remind Me保持为off,也不用做任何事。

    当然,上面所有情况中,都必须due date大于当前时间才安排通知消息。

    好长的一个列表啊。在编程前,把所有的可能性列出来,是一个非常好的习惯。

    看起来你要写非常多的代码了,但是实际上非常简单。

    首先,你观察这里是否已经存在一条消息。如果有,你简单的把它取消掉。然后判断是否需要安排一条新的。

    这样就可以处理上面的所有情况了,甚至有时候仅仅把已经存在的通知保留下来就可以了。算法有点粗糙,但是很有效。

    打开ChecklistItem.swift,添加以下方法:

    func removeNotification() {
            let center = UNUserNotificationCenter.current()
            center.removePendingNotificationRequests(withIdentifiers: ["\(itemID)"])
        }
    

    这个方法的作用是移除已存在的某条待办事项的通知安排,注意一下removePendingNotificationRequests()要求一个数组作为标示,所以你把(itemID)放入一对方括号中。

    在scheduleNotification()的顶部调用这个方法:

    func scheduleNotification() {
            
            removeNotification()
    
    ...
    

    运行app,添加一个待办事项,并且将due date设置到两分钟后。一条新的通知就被安排上了。会到主界面等待它的出现。

    编辑待办事项并且改变due date,到三分钟或者4分钟后,这样旧的消息就被取消了,然后根据新的时间安排了一条新的消息。

    添加一条新的待办事项,然后把switch设置为off,旧的消息会被取消,并且不会安排新的消息。

    再次编辑上面哪条待办事项,改变一下时间,不要动其他的,还是不会被安排消息。

    我们还有最后一种情况要处理,就是删除待办事项,有两种情况需要考虑:

    1、用户通过滑动的方式删除某一条待办事项

    2、用户删除了整个待办事项分类的目录

    当删除发生时,有一个方法会被告知这件事。你可以简单的执行这个方法,然后看看有没有安排消息通知,有的话就取消掉。

    打开ChecklistItem.swift,添加以下方法:

    deinit {
            removeNotification()
        }
    

    所有的工作都做完了。这个特殊的deinit方法会在删除某一条待办事项以及删除整个目录的时候被调用。

    运行app,测试一下各种情况。如果一切正常的话,就把代码里的print语句都删掉。虽然不删也没什么关系,用户是看不到它们的,但是我们所做的一切都是为了代码的简洁。

    同时也把item ID从ChecklistViewController的label中移除,这仅仅是为了测试使用的。

    好累啊

    我们从设计草图开始,一直到完整的完成了一个app。我们接触了许多高级的课题,希望你能跟的上思路,明白我们是在做什么。你坚持到了现在,我非常为你感到骄傲。

    如果你对其中的一些细节迷惑不解,那是正常的,没有关系。睡一觉,然后重新在看一遍。编程需要你去思考,但是并不需要你通宵达旦的和它在一起。不要害怕重头再来一遍。要记住,温故而知新。

    本课程聚焦于UIKit,以及其中的重要控件和模式。在下一节课,我们会先花点时间将将swift语言。当然,你还会再和我一起做一个更酷的app。

    最终我们的故事模版是这个样子的:

    看起来很壮观吧。

    你得到了应得的回报,当你准备好开始下一课前,好好休息一下吧,我也休息一下,有两段结束语不翻译了。

    相关文章

      网友评论

        本文标题:iOS Apprentice中文版-从0开始学iOS开发-第二十

        本文链接:https://www.haomeiwen.com/subject/tfnmkxtx.html