[TOC]
关于 NSTableView的使用
接下来我们需要就是做出这个列表数据,我们可以使用 NSTableView来做出这个效果。
我们拖拽一个 NSTableView放在 BaseListView.xib的试图上面。

设置布局如下。

解决 NSTableView的 Header在 Xib无法正常显示
有的时候我们发现 NSTableView在 Xib被隐藏了,但是我们显示 Header的选项是开启的。
我们只要重新勾选
Hader选项即可显示出来。

我们可以看出来我们的列表分为三部分 标题 时间 操作,我们就设置 NSTableView有 3个 Column。

因为名字的长度是不固定的,我们就设置 NSTableView的第一个 Column的宽度随着 NSTableView的宽度变化。

我们设置其余的 Column的宽度固定为 100。


我们的基本结构已经出现了,现在我们要设置 Header的背景颜色为黑色。
我们关联一下 Xib上面的 NSTableView控件。
设置 NSTableView的 Header背景颜色。
参考资料:
⛔️这里遇到了一个棘手的问题,如果使用
NSTableHeaderView的子类,在Draw绘制虽然颜色是设置了,但是标题已经被覆盖掉了。如果我们使用下面的方法进行设置的话
public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { if let headerCell = tableColumn?.headerCell { headerCell.drawsBackground = true headerCell.backgroundColor = NSColor.black } return nil }如果数据源为0就无法设置,并且还有下面的问题。
如果就算有数据也是这样的状态。
中间有间隙并没有完全的黑掉。
我们暂时没有找到合适设置背景颜色的方案,我们暂时使用系统自带的。

展示列表分为三种样式。
- 第一种是图标加上文字并且是可以点击的
- 第二种是文字只做展示
- 第三种是两个按钮
我们设置 NSTableView的 Cell的高度为 83。
我们新建一个类 IconTitleTableCellView继承与 NSTableCellView。我们在 IconTitleTableCellView.xib上面拖拽一个 NSView继承于 SideMenuItemView。
布局如下。



我们先暂时设置宽度为 100,因为标题不知道长度,所以我们需要动态改变长度。
为了设置默认的字体颜色,我们设置normalColor为 var的变量。

NSView如何 sizeThatFits:
为了让标题显示完全,我们绑定一下设定宽度的约束。
@IBOutlet weak var itemViewWidthConstraint: NSLayoutConstraint!
我们发现 sizeThatFits并不是 NSView只有 NSControl或者子类才可以使用。但是对于我们的需求已经够了。
我们给 SideMenuItemView写一个 sizeThatFits方法。
func sizeThatFits(_ size: NSSize) -> NSSize {
let labelSize = self.itemTitle.sizeThatFits(size)
let sizeWidth = size.height + 10 + labelSize.width + 10
return NSSize(width: sizeWidth, height: size.height)
}
我们通过计算出 SideMenuItemView的宽度。
func configurationView() {
let configuration = SideMenuItemConfiguration(title: "这是测试标题", iconHex: "F0F6", hidden: true, selected: false, normalColor: NSColor(red:0.267, green:0.267, blue:0.267, alpha:1.000))
self.itemView.menuItemConfiguration = configuration
let size = self.itemView.sizeThatFits(NSSize(width: Int.max, height: 20))
self.itemViewWidthConstraint.constant = CGFloat(size.width)
}
此时我们已经正常可以显示标题了。

再次激活 App
我们现在的 App运行,假设一个应用遮挡着我们的应用,我们点击 App图标是无法再次显示出来 App面板的。
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
for window in sender.windows {
window.makeKeyAndOrderFront(self)
}
return true
}
}

此时我们已经可以再次点击 App图标让界面显示最前面了。
我们再创建一个 DateTableCellView继承与 NSTableCellView。
我们拖拽一个 Label到 DateTableCellView.xib布局设置如下。

我们让 cloumn第二个使用 DateTableCellView。
我们新建一个类 ActionTableCellView继承于 NSTableCellView。
我们在 ActionTableCellView.xib上面拖拽一个 NSView继承与 SideMenuItemView。布局设置如下:



我们再在右边放置一个按钮,布局如下。



我们 Column第三个为 ActionTableCellView。

我们设置按钮的 Cloumn的宽度为 200。

显示效果似乎还是不足,原因是 80的宽度不足以正常的显示出来。
设置 ActionTableCellView中按钮的宽度都为 100。
我们给 DateTableCellView连接 label的属性用于设置时间。
@IBOutlet weak var dateLabel: NSTextField!
我们分别给 ActionTableCellView两个自定义控件设置圆角和背景颜色。
@IBOutlet weak var deleteItemView: SideMenuItemView!
@IBOutlet weak var lookItemView: SideMenuItemView!

我们的列表的样式已经基本上搭建完毕了。
请求 Jekyll的 Post文章的列表。
我们新建一个 GetPostListApi类用于获取文章页列表。
我们新建一个类 PostDetail用于显示文章的信息详情。
class PostDetail: Mappable {
var path:String?
var url:String?
var id:String?
var collection:String?
var relativePath:String?
var draft:Bool = false
var categories:[String] = []
var title:String?
var date:String?
var slug:String?
var ext:String?
var tags:[String] = []
var layout:String?
var httpURL:String?
var apiURL:String?
var name:String?
required init?(map: Map) {
}
func mapping(map: Map) {
path <- map["path"]
url <- map["url"]
id <- map["id"]
collection <- map["collection"]
relativePath <- map["relative_path"]
draft <- map["draft"]
categories <- map["categories"]
title <- map["title"]
date <- map["date"]
slug <- map["slug"]
ext <- map["ext"]
tags <- map["tags"]
layout <- map["layout"]
httpURL <- map["http_url"]
apiURL <- map["api_url"]
name <- map["name"]
}
}
我没有找到 ObjectMapper直接转成 模型数组的,应该需要自己单独封装添加数组里面,但是却无意发现了这个。

官方建议我们使用 AlamofireObjectMapper这个库,看了文档确实比较简单,我们就用这个库替换掉 Alamofire和 ObjectMapper。
class GetPostListApi {
func loadRequest(success:GetPostListApiSuccessCompletionHandle?, failure:GetPostListApiFailureCompletionHandle?) {
let URL = "http://localhost:4000/_api/collections/posts/entries"
Alamofire.request(URL).responseArray { (response:DataResponse<[PostDetail]>) in
if let list = response.value {
self.completionHandle(success: success, failure: nil, postList: list, error: nil)
} else {
self.completionHandle(success: nil, failure: failure, postList: nil, error: response.error)
}
}
}
func completionHandle(success:GetPostListApiSuccessCompletionHandle?, failure:GetPostListApiFailureCompletionHandle?, postList:[PostDetail]?, error:Error?) {
if let success = success , let postList = postList {
success(postList)
} else if let failure = failure {
failure(error)
}
}
}
写到这里,我们会发现 GetPostListApi这个类和 GetConfigurationApi有太多的相似代码。我们不妨创建一个 BaseRequestApi的请求子类去掉一些多余的代码。
我们现在请求的地址是基于 http://localhost:4000/_api/这个地址,大部分的 Jekyll本地都是 4000端口也可能是其他的。
我们就在 BaseRequestApi定义一个 URL的变量默认为 http://localhost:4000/_api/。
为了能够请求到数据,我们创建一个发起请求的方法。
我们发起请求需要完整的请求地址我们新建一个方法传递 http://localhost:4000/_api/的后缀。
func URLPath() -> String {
return ""
}
我们新建一个方法用于拼接完整的请求地址。
func URLFullPath() -> String {
guard self.URLPath().characters.count > 0 else {
return self.URL
}
return "\(self.URL)/\(self.URLPath())"
}
当后缀是空字符串的时候我们不拼接。
关于泛型参数
对于 泛型参数在 OC和 Swift一直没有明白过来,也一直掌握精髓,到现在都不会用。
现在要封装请求,对于代理回调应该需要用上 泛型参数,研究一下。
参考资料:
我们获取数据主要分为两种,一种是对象类型,一种是数组对象类型。
我们新建一个请求协议。
protocol BaseRequestProtocol {
associatedtype R:BaseMappable
func loadObjectRequest(success:BaseRequestResponseObjectCompletionHandle<R>, failure:BaseRequestFailureCompletionHandle)
func loadArrayRequest(success:BaseRequestResponseArrayCompletionHandle<R>, failure:BaseRequestFailureCompletionHandle)
}
typealias BaseRequestResponseObjectCompletionHandle<T:BaseMappable> = (_ model:T) -> Void
typealias BaseRequestResponseArrayCompletionHandle<T:BaseMappable> = (_ models:[T]) -> Void
typealias BaseRequestFailureCompletionHandle = (_ error:Error) -> Void
我们让请求的基类 BaseRequestApi实现 BaseRequestProtocol的协议。
class BaseRequestApi<T:BaseMappable>: BaseRequestProtocol
我们实现一下 BaseRequestProtocol的方法。
func loadObjectRequest(success: @escaping (T) -> Void, failure: @escaping (Error?) -> Void) {
Alamofire.request(self.URLFullPath()).responseObject { (response:DataResponse<R>) in
guard let value = response.value else {
failure(response.error)
}
success(value)
}
}
我们返回确保返回的对象存在,当不存在就返回错误信息。
public var error: Error? { return result.error }
因为 error可能不存在,我们就回调 BaseRequestFailureCompletionHandle设置可选型。
关于 @escaping
我们在网络请求完成之后进行回调编译器会提示我们加上 @escaping。关于 @escaping我们可以参考下面资料。
参考资料: swift3.0中@escaping 和 @noescape 的含义。
看过资料我们可以知道,系统默认是 @noescape。只要被 @noescape标记的 闭包我们都是不需要关心内存管理的。
但是如果在方法执行完毕才执行 闭包我们就需要用 @escaping标识,这样系统自动在调用时候提示用户对于直接使用 self进行内存管理。
func loadArrayRequest(success: @escaping ([T]) -> Void, failure: @escaping BaseRequestFailureCompletionHandle) {
Alamofire.request(self.URLFullPath()).responseArray { (response:DataResponse<[R]>) in
guard let value = response.value else {
failure(response.error)
return
}
success(value)
}
}
func loadObjectRequest(success: @escaping (T) -> Void, failure: @escaping (Error?) -> Void) {
Alamofire.request(self.URLFullPath()).responseObject { (response:DataResponse<R>) in
guard let value = response.value else {
failure(response.error)
return
}
success(value)
}
}
我们现在的请求基类基本上已经可以正常的运行了,我们已经迫不及待的准备尝试一下。
精简请求子类
我们设置 GetConfigurationApi父类为 BaseRequestApi。
class GetConfigurationApi: BaseRequestApi<JekyllConfiguration> {
override func URLPath() -> String {
return "configuration"
}
}
我们此时子类的代码就变成这么的简单。但是现在有一个问题就是我们配置的数据在子数据里面。
我们需要使用 Path进行获取,我们就为 BaseRequestApi设置一个属性可以让外接设置 Path。
var responseKeyPath:String?
class GetConfigurationApi: BaseRequestApi<JekyllConfiguration> {
override func URLPath() -> String {
return "configuration"
}
var responseKeyPath: String? = "content"
}
此时我们会受到编译器通知我们的错误。

cannot override with a stored property
参考资料:
override var responseKeyPath: String? {
get {
return "content"
}
set {
self.responseKeyPath = newValue
}
}
我们此时在 ViewController的请求代码可以设置如下。
let getConfigurationApi = GetConfigurationApi()
getConfigurationApi.loadObjectRequest(success: { [weak self] (configuration) in
guard let title = configuration.title else {
return
}
self?.navigationBar.blogMenuItem.itemTitle.stringValue = title
}, failure: { (error) in })
我们就可以请求到数据了,是不是代码更加的简洁了呢?
请求文章列表
我们配置 GetPostListApi类的代码如下。
class GetPostListApi: BaseRequestApi<PostDetail> {
override func URLPath() -> String {
return "collections/posts/entries"
}
}
我们在 PostsView新写一个方法用于获取文章列表。
func loadData() {
let api = GetPostListApi()
api.loadArrayRequest(success: { (list:[PostDetail]) in
}) { (error) in }
}
有了数据我们需要在列表里面展示出来。
BaseListView作为列表的基类,我们的数据源的结构可能不太一样,我们不可能让我们自定义的数据源传入 BaseListView。
这个时候我们的 泛型参数又可以登场了。
我们给 BaseListView新建一个泛型参数,必须是 BaseMappable的子类。
class BaseListView<M:BaseMappable>
我们新建一个属性存储 M数组,当用户重新设置就刷新表格。
var models:[M] = [] {
didSet {
self.tableView.reloadData()
}
}
@IBOutlet Property cannot have non-‘@objc’ class type
此时我们已经收到了一个错误信息。
参考资料:
查了很多的资料,这个技术难点倒是没有找到合适的方法解决。是因为 @IBOutlet在 OC里面使用的运行时,但是运行时不允许 @IBOutlet绑定一个泛型的对象。
我还尝试过在 BaseListView使用其他的泛型类间接代理,但是依然无法解决我们的问题。
我现在唯一能够想到的方案就是所谓的协议,用协议声明泛型参数。
我们希望别人继承我们的协议可以把数据转换成我们想要的数据。
protocol BaseListViewDataSource {
associatedtype M:BaseMappable ///< 泛型类型
static func converModels(models:[M]) -> [BaseListViewDataModel] ///< 将其他类型对象数组转换成BaseListViewDataModel对象数组
static func converModel(model:M) -> BaseListViewDataModel ///< 将其他类型转换成BaseListViewDataModel对象
}
extension BaseListViewDataSource {
static func converModels(models:[M]) -> [BaseListViewDataModel] {
var datas:[BaseListViewDataModel] = []
for model in models {
let data = self.converModel(model: model)
datas.append(data)
}
return datas
}
}
class BaseListViewDataModel {
var title:String? ///< 显示标题
var date:String? ///< 显示时间
}
我们 PostDetail实现我们刚才的协议 BaseListViewDataSource。
static func converModel(model: PostDetail) -> BaseListViewDataModel {
let data = BaseListViewDataModel()
data.title = model.title
data.date = model.date
return data
}
typealias M = PostDetail
我们在 loadData方法实现我们刚才的方法。
func loadData() {
let api = GetPostListApi()
api.loadArrayRequest(success: { (list:[PostDetail]) in
self.listView.models = PostDetail.converModels(models: list)
}) { (error) in }
}

我们已经可以发现我们的界面已经可以正常的显示我们数据条数,现在剩下做的就是给我们界面正确的赋值了。
public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let identifier = tableColumn?.identifier else {
return nil
}
let model = self.models[row]
let view = tableView.make(withIdentifier: identifier, owner: self)
if let iconTitle = view as? IconTitleTableCellView, let title = model.title {
}
return view
}
我们将 IconTitleTableCellView中 configurationView方法修改如下。
func configurationView(title:String) {
let configuration = SideMenuItemConfiguration(title: title, iconHex: "F0F6", hidden: true, selected: false, normalColor: NSColor(red:0.267, green:0.267, blue:0.267, alpha:1.000))
self.itemView.menuItemConfiguration = configuration
let size = self.itemView.sizeThatFits(NSSize(width: Int.max, height: 20))
self.itemViewWidthConstraint.constant = CGFloat(size.width)
}
public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let identifier = tableColumn?.identifier else {
return nil
}
let model = self.models[row]
let view = tableView.make(withIdentifier: identifier, owner: self)
if let iconTitle = view as? IconTitleTableCellView, let title = model.title {
iconTitle.configurationView(title: title)
}
return view
}

我们的界面就可以正常的显示标题了。同样我们我们赋值一下时间。
if let dateView = view as? DateTableCellView, let date = model.date {
dateView.dateLabel.stringValue = date
}

我们发现时间显示的格式不正确。我们给 DateTableCellView写一个转换时间格式的方法。
func configuration(dateString:String) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd hh:mm:ss zzzz"
guard let date = formatter.date(from: dateString) else {
return
}
formatter.dateFormat = "MMM dd,yyyy"
self.dateLabel.stringValue = formatter.string(from: date)
}

我们看到显示竟然是中文六月,不是我们希望看到的 Jun。
中文系统格式化时间显示英文字符
formatter.locale = Locale(identifier: "en_US")
我们还是按照默认的比较好,我们中文用起来比较方便。
现在要做的就是 删除 查看两个方法了。我们封装的 SideMenuItemView控件是无法响应我们的事件的。
给 NSView添加 NSGestureRecognizer事件
参考资料:

一共有五个 NSGestureRecognizer的子类可以使用。我们使用 NSClickGestureRecognizer来处理点击。
func addClick() {
let click = NSClickGestureRecognizer(target: self, action:#selector(self.clickAction))
self.addGestureRecognizer(click)
}
func clickAction() {
}
我们的方法无法告诉外接什么时候点击了,如果有一个回调就好了。
typealias SideMenuItemViewClickCompletionHandle = (_ view:SideMenuItemView) -> Void
func addClick(completionHandle:@escaping SideMenuItemViewClickCompletionHandle) {
self.clickCompletionHandle = completionHandle
let click = NSClickGestureRecognizer(target: self, action:#selector(self.clickAction))
self.addGestureRecognizer(click)
}
func clickAction() {
guard let completionHandle = self.clickCompletionHandle else {
return
}
completionHandle(self)
}
var clickCompletionHandle:SideMenuItemViewClickCompletionHandle?
删除文章
参考资料:
我们新建一个类 DeletePostDetail继承与我们 BaseRequestApi。
class DeletePostDetail: BaseRequestApi<DeletePostDetailResponse> {
override func URLPath() -> String {
return "collections/posts/{name}"
}
}
class DeletePostDetailResponse: BaseMappable {
func mapping(map: Map) {
}
}
这样是不符合我们请求的标准的,我们的地址需要一个真实的 name。
我们就给 DeletePostDetail初始化带一个 name的参数。
override func URLPath() -> String {
return "collections/posts/\(self.name)"
}
let name:String
init(name:String) {
self.name = name
}
我们删除的请求是 delete请求,我们底层封装的默认为 Get请求,我们还需要稍微的修改一下。
func requestMethod() -> HTTPMethod {
return HTTPMethod.get
}
Alamofire.request(self.URLFullPath(), method:self.requestMethod())
这样我们父类默认是 Get请求,子类如果需要 delete请求,我们只需要重写这个方法即可。
我们需要点击删除的按钮提示用户是否要删除这个文章,所以我们需要传入一个文章的文件名称。
///BaseListViewDataModel类
var fileName:String? ///< Markdown 的文件名称
///PostDetail类
static func converModel(model: PostDetail) -> BaseListViewDataModel {
let data = BaseListViewDataModel()
data.title = model.title
data.date = model.date
data.fileName = model.name
return data
}
///ActionTableCellView
var fileName:String? ///< 用来知道要删除那个文件
关于 NSAlert
对于弹出框我们可以使用 NSAlert控件
参考资料:
在
ActionTableCellView类增加代码如下
self.deleteItemView.addClick { (view) in
guard let fileName = self.fileName, let window = NSApplication.shared().keyWindow else {
return
}
let alert = NSAlert()
alert.messageText = "确定要删除\(fileName)"
alert.beginSheetModal(for: window, completionHandler: { (response) in
})
}
在
BaseListView的public func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?方法 增加代码如下if let actionView = view as? ActionTableCellView { actionView.fileName = model.fileName }

此时只有一个确定,没有取消按钮,到时候误删就 GG 了。
self.deleteItemView.addClick { (view) in
guard let fileName = self.fileName, let window = NSApplication.shared().keyWindow else {
return
}
let alert = NSAlert()
alert.messageText = "确定要删除\(fileName)"
alert.addButton(withTitle: "删除")
alert.addButton(withTitle: "取消")
alert.beginSheetModal(for: window, completionHandler: { (response) in
})
}
当我们点击删除按钮我们需要执行删除的请求。
if response == NSAlertFirstButtonReturn {
self.deletePost(fileName: fileName)
}
func deletePost(fileName:String) {
let api = DeletePostDetail(name: fileName)
api.loadObjectRequest(success: { (response) in
}) { (error) in
}
}
当我们删除完毕我们需要刷新我们的表格,我就给 ActionTableCellView新写一个回调用于删除完毕更新表格的内容。
typealias ActionTableCellViewDeleteSuccessCompletionHandle = (_ view:ActionTableCellView) -> Void
var deleteSuccessCompletionHandle:ActionTableCellViewDeleteSuccessCompletionHandle?
func deletePost(fileName:String) {
let api = DeletePostDetail(name: fileName)
api.loadObjectRequest(success: { (response) in
guard let completionHandle = self.deleteSuccessCompletionHandle else {
return
}
completionHandle(self)
}) { (error) in
}
}
我们发现我们的表格并没有刷新,因为对于 Delete请求是没有任何信息回调的。我们只用知道状态吗是200就可以知道成功了。
func loadObjectRequest(success: @escaping (T?) -> Void, failure: @escaping (Error?) -> Void) {
Alamofire.request(self.URLFullPath(),method:self.requestMethod()).responseObject(keyPath:self.responseKeyPath) { (response:DataResponse<R>) in
guard let code = response.response?.statusCode, code == 200 else {
failure(response.error)
return
}
success(response.value)
}
}
当我们当识别状态吗为 200果然成功了。
OSX平台代码打开一个地址
我们做完 删除功能,还剩下一个 查看功能,当用户点击 查看按钮。
我们给 ActionTableCellView新增一个方法用于配置 查看按钮的点击方法。
func addLookView() {
self.lookItemView.addClick { (view) in
guard let urlString = self.httpURL, let url = URL(string: urlString) else {
return
}
NSWorkspace.shared().open(url)
}
}
界面上面的搜索功能,说简单不简单,说复杂不复杂。那要你需要实现的搜索到什么程度。
参考资料:
我们做先做一个简单版本的,就直接匹配就好了。
我们给 BaseListView增加一个搜索过滤之后的数组。
private var filterModels:[BaseListViewDataModel] = []
我们用 filterModels来作为我们暂时数据的数据源。
我们给 ContentHeaderValue1关联一下搜索输入框。
@IBOutlet weak var searchFiled: NSTextField!
我们设置一下 searchFiled代理对象为 BaseListView。
@IBOutlet weak var header: ContentHeader! {
didSet {
guard let headerValue1 = self.header.headerContent as? ContentHeaderValue1 else {
return
}
headerValue1.searchFiled.delegate = self
}
}
经过研究如果要监听输入框文字变化需要用通知。我们声明一个方法监听输入框通知变化。
func searchFiledTextChanged(notification:Notification) {
guard let filed = notification.object as? NSTextField else {
return
}
guard let headerValue1 = self.header.headerContent as? ContentHeaderValue1 else {
return
}
guard filed == headerValue1.searchFiled else {
return
}
}
我们新建一个方法处理字符串改变过滤数据源。
func filterDataModels(filter:String) {
self.filterModels.removeAll()
if filter.characters.count == 0 {
self.filterModels.append(contentsOf: self.models)
} else {
for model in self.models {
if let _ = model.title?.range(of: filter) {
self.filterModels.append(model)
}
}
}
self.tableView.reloadData()
}
我们在 searchFiledTextChanged方法里面调用我们刚才的过滤的方法。
func searchFiledTextChanged(notification:Notification) {
guard let filed = notification.object as? NSTextField else {
return
}
guard let headerValue1 = self.header.headerContent as? ContentHeaderValue1 else {
return
}
guard filed == headerValue1.searchFiled else {
return
}
self.filterDataModels(filter: filed.stringValue)
}
因为我们初始化的时候,我们还没有输入任何的搜索字符串,设置 models我们要初始化我们的 filterDataModels数组。
我们新建一个方法用于初始化 filterDataModels。
func settingFilterModels() {
guard let headerValue1 = self.header.headerContent as? ContentHeaderValue1 else {
return
}
let filterText = headerValue1.searchFiled.stringValue
self.filterDataModels(filter: filterText)
}
我们在设置 models时候进行重新设置 filterModels。
我们在 header的方法 didSet进行注册通知。

我们的搜索功能已经可以用了。
deinit方法
我们在 Objective-C开发里面经常在 dealloc注销通知,减少资源消耗。我们在 Swift里面可以使用 deinit函数。
参考资料:
deinit {
NotificationCenter.default.removeObserver(self)
}
刚才无意间发现下面系统自带的方法
extension NSObject {
open func controlTextDidBeginEditing(_ obj: Notification)
open func controlTextDidEndEditing(_ obj: Notification)
open func controlTextDidChange(_ obj: Notification)
}
这是 NSObject的扩展,我们去掉我们注册的通知,用 controlTextDidChange方法试一下。
参考资料:

