Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

What Can I Learn From Checklists - Part 2 #5

Open
jasonliao opened this issue Mar 20, 2017 · 0 comments
Open

What Can I Learn From Checklists - Part 2 #5

jasonliao opened this issue Mar 20, 2017 · 0 comments
Labels

Comments

@jasonliao
Copy link
Owner

What Can I Learn From Checklists - Part 2

Checklist 这个教程的第二部分终于完结,距离 第一部分 已经一个月了。本以为第二部分可以简短一点,但篇幅还是很长。这部分主要讲了如何用编程的方式来实现 Table View 的一些功能、存储数据别的一些方式与方法、还有 Date Picker 组件和本地推送等等。

Table of Contents

Programmed Table View

这个 table view 我们打算使用编程的方式来完成大部分的东西,使用 storyboard 的方式的确很方便,但了解一下别的方式还是好的。

如何创建一个新的 table view controller 已经很熟悉了,如何让 table view 显示数据上一部分也搞得很清楚,这里再来复习一次。要想 table view 展示数据,有三个函数很重要,找到 MARK: - Table view data source 这行注释后面的三个函数:

  • numberOfSections(in) 返回 1
  • tableView(numberOfRowsInSection:) 返回 3,因为现在还没有数据,所以先返回一个固定值,先看看假数据
  • tableView(cellForRowAt:) 是指明使用哪个 tabel view cell 来渲染数据,但好像还没有在 IB 里设计 prototype cell 呢?

Tip: 有时 table view 中显示多个 section,每个 section 中又会显示不同的 row,如果我们在函数里控制,就可能需要做条件判断。最方便的方法就是直接去掉 numberOfSectionstableView(numberOfRowsInSection) 这两个函数,那么渲染的时候,就可以直接根据 storyboard 来渲染。

现在来更新一下 storyboard,拖拽一个 table view 出来,按住 ctrl 把第一个 navigation controller 拖到它身上,在弹出来的菜单里选择 Relationship Segue - root view controller。这就意味着应用进来的第一个视图已经改变为我们刚刚创建的 table view 了,当然啦,这个 table view 想运用我们刚刚的代码,还需要在 Identity inspector 把类设置为刚刚创建的 table view controller。

把默认存在的 prototype cell 删掉,我们说好了用代码实现的,还记得吗?

create prototype cell in code

现在来实现 tableView(cellForRowAt:),我们调用一个自己创建的 makeCell(for:) 方法,返回一个 cell,然后简单地设置一个 taxtLabel 在里面:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = makeCell(for: tableView)
    cell.textLabel!.text = "Line \(indexPath.row)"
    return cell
}

这个 makeCell(for:) 方法做了什么呢?先看看代码:

private func makeCell(for tableView: UITableView) {
    let cellIdentifier = "cell"
    if let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) {
        return cell
    } else {
        return UITableViewCell(style: .default, reuseIdentifier: cellIdentifier)
    }
}

对比一下之前 ChecklistItemViewController 上的 tableView(cellForRowAt),只有简单的一行:

let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)

makeCell(for:) 方法里的 else 部分是用来做什么呢?

当 table view 里没有可以重用的 cell(只有有足够的 cells 可以布满整个可视区域,且在滚动时才可能会产生可重用的 cell),dequeueReusableCell 这个方法就会返回 nil,在有 prototype cell 的时候,table view 会调用它来创建新的 table view cell,但当没有 prototype cell 的时候,就需要我们手动的创建并返回,这就是 else 部分做的东西。

细心的同学还可以发现,dequeueReusableCell 还有一点点不同,就是参数 for,有 for 参数的只能和 prototype cell 一起合作使用。

navigate cell to another view

之前点击了 prototype cell 之后跳到另一个视图,我们只需要在 storyboard 里按住 ctrl 把 cell 拖到对应的视图就可以了,但是现在没有了,怎么办呢?

我们可以先把两个视图通过 segue 连接起来,当有需要的时候,再调用 performSegue 方法触发对应的 segue 就可以了。按住 ctrl 把 dock scene 上的黄色小按钮(它代表整个 table view)拖拽到我们点击 cell 想要到达的视图,然后在弹窗里选择一种 segue 类型就可以了。接着点击刚刚创建的 segue,在 Attributes inspector 里设置 identifier。

然后在 tableView(didSelectRowAt:) 里调用刚刚的方法就可以,传入对应的 identifier:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    performSegue(withIdentifier: "ShowChecklist", sender: tableView.cellForRow(at: indexPath))
}

这里的 sender 可以在后面的 prepare(for:sender:) 中使用,这样等下就可以获取到点击的是哪一行。

use segue by hand

刚刚学会了创建一个 segue 之后,通过 performSegue(withIdentifier:sender:) 的方法在特定的操作下来跳转到对应的视图,现在我们就来看看,在不创建 segue 的情况下,如何使用代码的方式来跳转到其他的页面上,先来看代码:

override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
    let navigationController = storyboard!.instantiateViewController(withIdentifier: "ListDetailNavigationController") as! UINavigationController
    // ... 
    present(navigationController, animated: true, completion: nil)
}

storyboard 这个变量在每个 view controller 里都会有,你可以用它来完成所有有关于 storyboard 的事情,例如创建一个其他 view controller 的实例。这里采用 storyboard! 的原因是因为 storyboard 是可选的,因为当前的视图不一定是从 storyboard 里加载进来的,但我们知道这里是,所以直接采用 !

然后调用 instantiateViewController(withIdentifier) 方法去实例化一个 view controller,这时需要传入一个 identifier,我们需要去到 storyboard 里,选中你要跳转到的 view controller,在 Identity inspector 里找到 Storyboard ID,填入与之对应的字符串,这样 storyboard 就可以通过这个标识找到对应的 view controller。

因为这里是要跳转到一个 navigation controller 里,所以后面使用 as! UINavigationController 强制转成 UINavigationController,然后用 present 方法就可以把这个 navigation controller 展示出来。

Doing Saves Differently

现在来对数据的加载和存储做一些升级,对数据如何加载存储、在哪里加载存储、怎么加载存储都做了一些改变。

when

以前每当我们都数据进行增删改的时候,都会去调用 saveChecklist 方法来保存数据到文件里,但实际上这并不需要,因为当应用在运行的时候,数据的变化都会保存在内存里。也就说,只有在应用被终止的时候,这些没有被保存的数据才会消失,所以想要保持下次加载数据时和我们预期的一样,只需要在应用终止之前,保存到文件里就可以了。

那应用什么时候会终止呢?这里有三种情况:

  • 当正在运行这个应用的时。这种情况在 iOS4 加入多任务之后就很少见了,但仍有一些情况是应用没反应了或者把内存用完的时候。
  • 当这个应用在后台休眠时。大多数情况下 iOS 会把应用保存很长的一段时间,这时应用的数据会被冻结在内存里,不会进行任何的运算,当再次启动时,就会从你上次离开的地方继续。但有时系统需要更多的内存空间来支撑当前运行的应用,例如一些游戏,那么这时系统就会关掉一些休眠的应用和把他们的内存清理掉。
  • 当应用崩溃时。有很多种方式会导致应用崩溃,而且处理起来也很麻烦,最好的办法就是在写程序的时候减少 bug,这样就可以避免应用崩溃。

从上面的三种情况可以看出,只要我们在应用休眠之前和崩溃之前存储数据,这样就可以确保数据不会丢失了。

how

之前 saveChecklistsloadChecklists 方法都是写在应用的第一个视图里,然后在 init() 方法或者 viewDidLoad() 方法里加载数据。而存储的路径信息写在 Checklist 这个数据类里。但当我们想要修改一些关于数据的东西时,就要在多个文件之间跳转。如果我们把这些数据的信息都放在一起,那就更容易管理了。因此先创建一个 DataModel 的类,把文件信息和保存加载方法也放在里面。

class DataModel {
    var checklists = [Checklist]()
    
    static let documentDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
    static let archiveURL = documentDirectory.appendingPathComponent("checklists.plist")
    
    init() { loadChecklists() }
    
    func saveChecklists() { //... }
    
    func loadChecklists() { //... }
}

DataModel 这个类里创建一个 checklists 数组变量,在 init() 方法加载文件里的数据。这样就可以为第一个视图提供数据了。去掉第一个视图里本身的 checklists 变量,添加 var dataModel: DataModel!,然后把下面所有的 checklists 都改成 dataModel.checklists 就可以了。

可以看到第一个视图里的 dataModel 并没有实例化,哪在哪里将 dataModel 传递给它呢?

where

AppDelegate.swift 这个文件存放了很多模板函数,就是整个应用的一些生命周期函数。我们可以在应用完成加载将要打开第一个视图的时候,把 dataModel 实例化后传给它,因为 dataModel 实例化时会调用自身的 init() 方法,也就是说,已经从文件里加载了数据了。

let dataModel = DataModel()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    let navigationController = window!.rootViewController as! UINavigationController
    let controller = navigationController.viewControllers[0] as! AllListsTableViewController
    controller.dataModel = dataModel
    return true
}

这里对语句的部分作一些解释,window! 和刚刚上面 storyboard! 的意思是一样的,凡是使用了 storyboard 的应用 window 这个变量都不会为空,当我们知道是一个变量是可选的但可以确保它肯定存在的时候,我们就可以使用 !

然后调用 UIWindowrootViewController 属性就可以获取整个应用的第一个 view controller,也就是在 storyboard 里左边有一个向右箭头的 view controller。在这个例子中,是一个 navigation controller。而获取它所包着的 view controller 则可以通过 viewController 这个数组,第一个就是了,然后把它转成相应的 view controller 类,就可以获取里面的 dataModel 变量并赋值了。

很多可能注意到,之前在 prepare 里不是有一个 topViewController 的属性也可以从 navigation controller 里获取 view controller 吗?原来这个属性是获取当前展示的 view controller,想像一下如果在应用休眠之前,正在展示的是其他的 view controller,而它刚好又被包裹在 root 的 navigation controller 里,那么这时就会因为获取不到对应类里的变量而应用崩溃。

加载数据完成了,而保存数据刚刚也说了,可以在应用休眠之前和崩溃之前保存,正好在 AppDelegate.swift 的生命周期函数里,也有这两个方法:

func applicationDidEnterBackground(_ application: UIApplication) { }

func applicationWillTerminate(_ application: UIApplication) { }

在 AppDelegate.swift 里加入一个 saveData() 的方法:

func saveData() {
    dataModel.saveChecklists()
}

最后,在 applicationDidEnterBackground()applicationWillTerminate() 里调用一下 saveData() 就可以了。

Still with Me?

来到这里应用已经较 part1 的时候有了很大的不同:

  • 新建了两个视图,一个是 AllListsTableViewController,另一个则是 ListDetailTabelViewController,并把 AllListsTableViewController 作为应用的第一个视图。这两个视图和我们之前创建的 ChecklistTableViewController 和 ItemDetailTableViewController 很像,所以内部的实现也大致相同,例如增删改的逻辑、delegate 的定义和实现、static cell 的设置等等。在这里也就没有必要累赘了,如果忘记了,可以查看 part1 的相应部分。

    这里有一些 Tips 可以帮助我们快速开发,以前我们都是在 storyboard 里把组件拖拽好后,然后 ctrl 拖到代码里生成对应的 outlets 或者 actions。但如果我们是先写好了 @IBOutlet 或者 @IBAction,也可以直接按住 ctrl 拖拽组件到视图空白处,在弹出的窗口里选择 actions,从视图拖拽到组件弹窗来选择 outlets。

    当然也有不一样的地方,例如 AllListsTableViewController 这个视图没有使用 prototype cell,所以一些与之相关的数据展示、segue 手动实现和跳转这些都在上文的 Programmed Table View 这个部分做了记录。

  • 数据的存储结构也做了改变,之前是只对 checklist item 进行存储,现在是存储多个 checklist,而每一个 checklist 也对应多个 checklist item。Checklist 里同样也要做类似 ChecklistItem 的事情,包括继承 NSCoding 类,实现 encodeinit 两个编码和解码的方法。这些在 part1 都已经解释得很清楚。

    数据的存储方式也有所改变,我们把数据存储加载的方法和文件信息存在了一个新建的 DataModel 类里。也没有在每次操作的时候都调用一次存储数据的方法,而是选择在应用休眠之前和崩溃之前存储数据。这部分在刚刚的 Doing Saves Differently 这部分里有详细的讲解。

About UserDefaults

UserDefaults 可以用来存储一些很小的东西,例如一些用户的行为、偏好设置等等。它的工作原理和之前用于存储数据的 Dictionary 很像,也是基于键值对来存储内容的。

现在我们使用 UserDefaults 来做一些有助于提升用户体验的东西。假设用户正在查看一个 checklist 里的 checklist item,这时用户把应用休眠,去玩游戏,而游戏太吃内存了,把这个应用杀掉了。这时用户再次打开应用的时候,就会回到第一个视图。

如果当用户点击了某一个 checklist 的时候进入 checklist item 视图时,我们把用户点击的 indexPath 存储到 UserDefaults 里,如果用户回到查看 checklist 视图的时候,就把这个值设为 -1,那么当应用重新打开的时候,判断这个值就知道应该展示哪个视图。

tableView(didSelectRowAt:) 方法里,在跳转到其他 segue 之前,添加下面这一句:

UserDefaults.standard.set(indexPath.row, forKey: "checklistIndex")

那如何感知到用户返回 checklist 视图的动作呢?UINavigationControllerDelegate 可以每当用户对 navigation controller 做出操作的时候发出通知。所以第一个视图需要继承这个类,并实现他的接口方法。

class AllListsTableViewController: UINavigationControllerDelegate, ... {
    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        if viewController == self {
            UserDefaults.standard.set(-1, forKey: "checklistIndex")
        }
    }
}

这里很简单,如果将要展示的视图是第一视图本身,就把 checklistIndex 这个键设为 -1。

最后,在 viewDidAppear() 的时候把让视图成为 navigation controller 的委托,这样才可以收到 UINavigationControllerDelegate 的通知。再判断一下,看看是否要跳转:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    navigationController?.delegate = self
    let index = UserDefaults.standard.integer(forKey: "checklistIndex")
    if index != -1 {
        let checklist = dataModel.lists[index]
        performSegue(withIdentifier: "ShowChecklist", sender: checklist)
    }
}

但事实上,这时是会出 bug 的,当应用第一次安装打开时,就会去获取 UserDefaults 的 checklistIndex 这个键的值,因为根本都没有这个键,所以就会返回 0,而 0 却是一个有效的 index,是指第一个 checklist,而新应用这时并没有任何 checklist,那么应用就会崩溃。

所以要为 UserDefaults 的 checklistIndex 设置默认值,在 DataModel.swift 里添加下列 :

func registerDefaults() {
    let dictionary: [String: Any] = [ "ChecklistIndex": -1 ]
    UserDefaults.standard.register(defaults: dictionary)
} 

然后在 init() 方法里调用就可以了。然后我们再把所有对 UserDefaults 的设置都统一在 DataModel.swift 里处理。创建 indexOfSelectedChecklist 变量,这里有点新的语法,就是为变量增加 getset 方法

var indexOfSelectedChecklist: Int {
    get {
        return UserDefaults.standard.integer(forKey: "checklistIndex")
    }
    set {
        UserDefaults.standard.set(newValue, forKey: "checklistIndex")
    }
}

每当我们使用 dataModel.indexOfSelectedChecklist 获取值或者赋值的时候,都会调用 getset 方法。这样就可以在第一个视图里直接赋值获取。

Improving User Experience

showing the number of to-do items remaining

在查看全部 checklist 的视图上,想要看到对应 checklist 里的 checklist item 情况,需要点击进去查看,如果在外层就有相关的信息提示,那么用户体验就会好很多。对于一个 checklists 应用来说,里面还有多少个 items 没有完成是比较重要的信息。那现在我们就来把类似 “All Done!”、“1 Remaining”、“(No Item)” 这样的信息在 checklist 名字下方显示出来。

首先我们需要一个函数来算一下,一个 checklist 里还有几个 checklist item 没有完成:

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

for in where 相当于 for in 循环里再做一个 if 语句的判断。然后在 makeCell(for:) 方法里改变 UITableViewCell 的类型,把 .default 改成 .subtitlesubtitle 这个类型的 table view cell 可以在原先标题的下方再显示一行小标题。这时就可以到 tableView(cellForRowAt) 里判断剩余 checklist item 的数量而显示出不同的东西。

let count = checklist.countUncheckedItem()
if checklist.items.count == 0 {
    cell.detailTextLabel!.text = "(No Items)"
} else if count == 0 {
    cell.detailTextLabel!.text = "All Done!"
} else {
    cell.detailTextLabel!.text = "\(count) Remaining"
}

最后还要在 viewWillAppear 重新加载一下数据,因为 checklist item 做了增加、删除、勾选操作之后,这个 detailTextLabel 都要作出相应的改变,所以每当 checklist 视图展示之前,都重新加载一遍数据。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    tableView.reloadData()
}

sorting the checklists

想要为 checklists 排序,要需要在添加、编辑和加载 checklist 的时候 sortChecklists() 就可以了。看看这个函数怎么实现:

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

如果 table view 里的数据很少的时候,可以考虑直接在添加和编辑之后直接采用 tableView.reloadData() 的方式来刷新列表。但如果数据有很多,更好的方式还是采用原来 tableView.insertRow(at:with:) 或者 tableView.reloadRow(at:with:) 的方式。

adding icons to the checklists

为 checklists 添加 icons 这里只是为了给我们巩固 table view 和 delegate 这两个知识点。我们完全可以根据效果图来自己完成,但这里还是有点是我们之前没有涉及过的。

prototype cell 如果 style 为 .subtitle 或者 .basic 的时,那就会默认有一个 UIImageView 在 cell 的左侧。所以可以直接使用:

cell.imageView!.image = UIImage(named: iconName)

我们之前一直都在使用 Present Modally 这个 segue,但这次显示 icon list 的时候,选择的是 Push,Push 这种 segue 实际上是把目标视图推进到原视图的 navigation controller 里。所以在弹出这个目标视图的时候,不再使用 dismiss(),而是使用:

let _ = navigationController?.popViewController(animated: true)

我们可以使用 _ 来代替变量,使用这个代替变量的意思就是告诉 XCode,我不在乎这个函数的返回值,但是不使用变量去接又会报警告。

making the app look good

应用所有按钮的字或者符号的颜色都是统一的,当然这也是可以修改的,有时是为了和主题搭配,有时是为了和图标等等。去到 storyboard 里,去到 file inspector,把 Tint Color 修改就可以了,而在代码里,则可以使用 view.tintColor 来获取。

Local Notifications

local notifications 和 push notifications 的区别在于,push notifications 可以让你的应用接收非应用内的事件进行响应,例如哪个球队赢了比赛你也可以收到通知。而 local notifications 就相当于闹钟,到了时间就会响。

local notifications 只会在这个应用第一次打开的时候寻问用户的权限,如果用户取消了,那么所有的消息都不会推送了,而且这个寻问只会出现一次,如果用户要重新设置,就要到 settings 里面设置了。

先来看一下 local notifications 的用法:

import UserNotifications

let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound], completionHandler: { granted, error in
    if (granted) {
        print("We have permission")
    } else {
        print("Permission denied")
    }
})

application(didFinishLaunchingWithOptions) 这个函数里面调用,这时在第一次打开这个应用的时候,就会弹窗,寻问用户允不允许推送消息。

这时先来创建一个假的推送消息,在打开应用后5秒推送出来:

let content = UNMutableNotificationContent()
content.title = "Hello!"
content.body = "I am a local notification"
content.sound = UNNotificationSound.default()
    
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(identifier: "MyNoftification", content: content, trigger: trigger)
    
center.add(request)

但只有当你不在当前应用的时候,才会有推送过来。

class func and static func

在类里定义一个可以直接通过类来访问的函数有两种方式,一种是通过 class func,还有一个是 static func,他们的区别在于,class func 可以被子类重写,而 static func 则不可以

Table View Data Source

我们很早之前说过,实现一个 table view 需要三个 data source 的函数,但是有时 table view 中显示多个 section,每个 section 中又会显示不同的 row,如果我们在函数里控制,就可能需要做条件判断。最方便的方法就是直接去掉 numberOfSectionstableView(numberOfRowsInSection) 这两个函数,那么渲染的时候,就可以直接根据 storyboard 来渲染。

The Date Picker

Date Picker 是我第一次接触,先按照这个例子使用它的方式来了解一下。

Date Picker 一般会在用户点击了某些地方之后才弹出来。但我们不可以一开始就创建一个 static cell,因为这样会一直显示在那里。所以我们创建一个新的 table view cell 的时候,不要直接拖拽到 table view 里,相反,我们把它拖到 sence dock 上。这时我们的确在这个视图里创建一个 table view cell,但这个 cell 并不会直接在我们的视图中出现。

然后再拖拽一个 date picker 进入刚刚创建的 table view cell 里面,然后再创建两个 outlet,datePickerCelldatePicker。分别绑定刚刚创建的 table view cell 和 date picker。

tableView(cellForRowAt) 需要在显示 date picker 的地方返回刚刚创建的 datePickerCell,其他时候,只要根据 storyboard 里渲染就可以了。可以使用 super 来调用自身方法。

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)
    }      
}

刚刚说过,没有 numberOfSection 的时候,就会按照 storyboard 来渲染,但有时候,我们还是需要特殊的条件来控制哪个 section 里显示多少 row。除了特殊条件,就使用原来的渲染方式,同样可以使用 super

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

我们还可以为特定的行设置高度,使用 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)
    }
}

当点击 due date 这个 table view cell 的时候,就要展示 date picker cell。而其他 cells 则不会。我们在 willSelectRowAt 里作判断,非特殊的行就 return nil,这样该行就不会被选中,也不会进入 didSelectRowAt 方法里。

override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
    if indexPath.section == 1 && indexPath.row == 1 {
        return indexPath
    } else {
        return nil
        // won't get in `didSelectRowAt`
    }
}

然后在 tableView(didSelectRowAt) 里取消点击的样式,然后把有可能出现在屏幕上的键盘隐藏掉,最后判断 datePickerVisible 调用 showDatePicker()hideDatePicker() 方法。

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

showDatePicker()hideDatePicker() 又做了什么呢?

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)
}

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() 
    }    
}

这里需要同时做两个操作,一个是插入或者删除 date picker cell,另一个则是刷新 due date cell,因为修改了颜色。所以需要把这两个操作包在 tableView.beginUpdates()tableView.endUpdates() 里,这样才能同时动进来。

但如果这时候跑起来,点击 due date cell 的时候,还是会报错。原因是我们还缺少了一个 data source 函数。就是 tableView(indentationLevelForRowAt)。当我们插入 section 1 row 2(date picker cell) 的时候,因为这个 cell 并没有在 storyboard 里定义。所以我们需要告诉程序的确有这一行存在。

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)
}

date picker 总算出来了,滑动时间的时候,需要修改 due date cell 的时间文字,绑定 date picker 的滑动 action 就可以了

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

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

把一个 date 对象转成字符串,我们需要 DateFormatter() 对象,设置好格式,然后传入想要格式化的 Date,就可以转成字符串了。

Scheduling the Local Notifications

每一条 checklistitem 对应一个 notification,所以我们把 notification 的设备放在 ChecklistItem.swift

func scheduleNotification() {
    if shouldRemind && dueDate > Date() {
        let content = UNMutableNotificationContent()
        content.title = "Reminder:"
        content.body = text
        content.sound = UNNotificationSound.default()
    
        let calendar = Calendar(identifier: .gregorian)
        let components = calendar.dateComponents([.month, .day, .hour, .minute], from: dueDate)
    
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
    
        let request = UNNotificationRequest(identifier: "\(itemID)", content: content, trigger: trigger)
    
        let center = UNUserNotificationCenter.current()
        center.add(request)
    }
}

先来解释一下这个 scheduleNotification 函数,首先要判断,用户有没有为这条 checklistitem 开启提醒,还有用户设置的到期时间是不是比当前的时间大。

接着设置一个 notification content,显示的内容就是我们 checklistitem 的 text。然后根据 dueDate 来创建一个 dateComponent,这个 components 用于 trigger 中,意味着可以在指定的时间触发 notification。再接着则需要一个 request,把 itemID 当成这个 notification 唯一的标识,并把 content 和 trigger 一起组成一个通知,最后添加到 center 了就可以了。

如果你还记得的话,向用户推送消息是需要用户的允许的,那在什么时候弹出来询问用户呢?在应用的第一次打开并不是很好的体验,应该在用户真正使用这个功能的时候,才弹出。所以可以为 UISwitch 绑定一个事件。

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

这个询问只会出现一次,当用户给了权限之后,就不会再出现了。

接着要处理的就是修改 checklistitem 的时候,用户有可能取消通知,或者更改通知时间。这里似乎要处理很多种情况,但实际上,只要每次都 remove 掉通知,再根据判断条件重新创建一个就满足全部的情况了。

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

然后在 scheduleNotification() 方法最开头调用一个 removeNotification 就可以了。

最后要处理的就是删除 checklistitem。删除 item 有两种方式,一个是单独删除 item,而另一种则是删除外层整个 checklist。但是这两种方法,都会调用 item 的 deinit 方法,所以可以在这里面取消掉通知

deinit() {
    removeNotification()
}

Summary

我的第三个学习 App 完成了,跨度有点长,前面的知识都有点忘了,但还好有详细的笔记。这个 App 完成之后,iOS 的学习就暂时搁置一下,我要回去学习我的大前端啦。但我还是会回来的 😎

@jasonliao jasonliao added the blog label Mar 20, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant