第4章 注册视图中编写与界面相关的代码
现在,如果我们构建并运行项目的话,在用户注册界面单击某个Text Field控件时,iOS系统会自动为我们弹出虚拟键盘,如图4-1所示。但是,出现的虚拟键盘却遮挡住了TextField控件以及按钮,这是一个致命的Bug,因为用户根本无法单击注册按钮来进行数据的提交,或者是单击取消按钮回到之前的界面。
图4-1 虚拟键盘遮挡了TextField控件以及按钮
4.1 获取当前屏幕的尺寸
在注册视图中,我们需要编写一些代码来解决虚拟键盘出现以后的视图滚动问题。但首先要获取屏幕的尺寸,并且将该尺寸作为滚动视图的大小。
步骤1 在项目导航中打开SignUpVC.swift文件,找到viewDidLoad()方法。
技巧
随着类中方法和属性的增加,今后找起方法和属性可能会越来越费劲。可以在编辑区域中通过顶部的指示栏快速定位到类中的方法,如图4-2所示。在弹出的列表中,C代表类,P代表属性, M则代表方法,Pr代表协议。
图4-2 通过编辑区域顶部的指示栏快速定位类中的方法
步骤2 在viewDidLoad()方法中super.viewDidLoad()代码的下面添加如下代码:
// 滚动视图的窗口尺寸 scrollView.frame = CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height)
当应用程序的控制器视图被载入到内存以后会自动调用viewDidLoad()方法,视图的载入通常有两种方式。一种是载入故事板中所设计的用户界面,也就是载入Storyboard文件中相关的视图。另外,对于早期的iOS开发来说,也可能载入的是xib文件,xib文件是故事板被引入到Xcode之前所使用的保存UI的文件方式。第二种是执行完控制器类中的loadView()方法以后。如果想通过手动编写代码的方式加载各种UI元素,则需要重写loadView()方法。
注意
如果你通过Interface Builder创建了控制器视图,并且进行了初始化,那么loadView()方法就不会起作用。
还有一点需要大家了解的是,viewDidLoad()方法在整个控制器的生存周期中只会被调用一次,就是在控制器视图载入完成以后,之后就不会再被调用了。直到控制器对象被销毁,再次创建一个新的控制器对象时,才会再次调用该方法。如果我们需要每次在控制器视图重新出现到屏幕的时候执行一些代码,则需要重写viewWillAppear(_:)方法,它会在视图将要呈现到屏幕时被调用,比如在导航控制器中被压入栈的控制器重新呈现出来的时候。
在上面的代码中,我们将滚动视图的位置和大小设置为控制器视图的左上角并扩展到整个视图的宽高大小。这里因为控制器视图的大小就是手机屏幕的尺寸,所以使用self.view. frame.width语句来确定屏幕尺寸。另外,我们也可以直接使用下面的代码来设置滚动视图的尺寸:
scrollView.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
UIScreen类定义了基于硬件显示的相关属性,iOS设置都有一个主屏幕(main screen)以及可能会有的附加屏幕(attached screen)。如果是tvOS的话,则它的主屏幕尺寸就是与之相连的TV的分辨率。每个屏幕对象都含有一个bounds属性,通过该属性可以得到屏幕的宽度值和高度值。
步骤3 在scrollView.frame代码行的下面再添加两行代码:
// 定义滚动视图的内容视图尺寸与窗口尺寸一样 scrollView.contentSize.height = self.view.frame.height scrollViewHeight = self.view.frame.height
滚动视图的contentSize属性是CGSize类型,这里我们将其高度设置为与屏幕一样的高度。另外,我们还定义了一个scrollViewHeight属性用于存储滚动视图的高度值,这里也将其设置为屏幕的高度值。就目前的情况来看,因为contentSize的高度值与滚动视图的高度值一样,所以现在并不会发生垂直方向的滚动效果。
4.2 添加键盘相关的Notification通知
当我们在注册视图中单击最下面的Text Field,弹出的虚拟键盘完全遮盖住网站Text Field和下面的两个按钮,如图4-3所示。
图4-3 虚拟键盘遮盖住Text Field的情况
提示
如果模拟器在单击了Text Field以后并没有出现虚拟键盘的话,就意味着此时的模拟器已经连接到了真正的物理键盘,需要在模拟器中通过菜单Hardware > Keyboard >Connect Hardware Keyboard将其关闭,或者直接使用Shift+Cmd+K快捷键将其关闭。
根据虚拟键盘来调整滚动视图确实是一件比较棘手的事情,因为我们需要考虑很多现实问题。比如不同类型的键盘有不同的高度,用户可以随时改变设备的方向,或者是连接一个蓝牙键盘或其他输入设备,甚至可以随时显示或隐藏QuickType栏(键盘按钮上方的语句建议栏)。面对如此复杂的情况,我们力争使用最简单的方式解决它。
当键盘状态发生变化的时候,我们可以通过NotificationCenter(本地消息通知中心)得到虚拟键盘的信息。在iOS系统层面,当有一些事情发生的时候,它会不断地发送消息通知,比如键盘的出现与消失,应用程序被移到了后台,以及项目中自定义的事件等。
我们可以添加属于自己的本地消息通知并命名相应的方法,当消息通知发生的时候就会调用这个方法,甚至传递一些有用的信息。
步骤1 在viewDidLoad()方法的底部,添加两个NotificationCenter类型的本地消息通知。
// 检测键盘出现或消失的状态 NotificationCenter.default.addObserver(self, selector: #selector(showKeyboard), name: Notification.Name.UIKeyboardWillShow, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(hideKeyboard), name: Notification.Name.UIKeyboardWillHide, object: nil)
注意
如果你有Swift 2.2项目开发经验的话,可能会使用NSNotificationCenter类进行本地消息通知的设置,并且通过UIKeyboardWillShowNotification常量来监测键盘状态。但是到了Swift 3,直接使用NotificationCenter(取消了NS前缀)即可,并且消息名称必须使用Notification.Name.UIKeyboardWillHide。
NotificationCenter的类属性default用于得到默认的消息中心实例。而addObserver(_:selector: name: object:)方法则用于注册一个消息通知,当发生name参数所指定的事件时,就会调用selector参数中所指定的方法,object参数是在传递消息时可携带的数据。
刚才所添加的两行消息通知代码,当虚拟键盘将要出现(UIKeyboardWillShow)的时候会调用当前控制器类的showKeyboard()方法,当虚拟键盘将要消失(UIKeyboardWillHide)的时候会调用当前控制器类的hideKeyboard()方法。注意,这里的消息类型中含有Will,同时消息类型中还包含另外两个消息:虚拟键盘已经出现(UIKeyboardDidShow)和虚拟键盘已经消失(UIKeyboardDidHide)。Will和Did这两大类消息,前者是在键盘出现和消失前被发送,后者是在键盘出现和消失后被发送。
此时Xcode的NotificationCenter相关代码会报错误,这是因为我们目前还没有定义消息通知所调用的方法,如图4-4所示。
图4-4 本地消息通知报错
步骤2 在SignUpVC类中添加下面的两个方法:
// 当键盘出现或消失时调用的方法 func showKeyboard(notification: Notification) { } func hideKeyboard(notification: Notification) { }
这两个方法均带有一个Notification类型的参数,该类型封装了通过NotificationCenter发送的通知的消息,那它为什么会携带键盘的相关信息呢?因为在设置NotificationCenter的时候,它的消息类型为UIKeyboardWillShow/Hide。
步骤3 在showKeyboard(:)方法中添加下面的代码来获取键盘大小:
// 定义keyboard大小 let rect = notification.userInfo! [UIKeyboardFrameEndUserInfoKey] as! NSValue keyboard = rect.cgRectValue
如果你对Swift语言的可选(option)特性还不太熟悉的话,可能会被代码中出现的问号(? )和惊叹号(! )弄得有些不知所措。Swift语法方面的知识不在本书的讲授范围之内,这里仅简单做下介绍。
4.3 Swift语言中的可选特性
你现在可以暂时关闭当前的iOS项目,然后在Xcode 8的欢迎窗口中新建一个Playground文件,这样方便本部分代码的调试。
Swift是非常安全的语言,这就意味着它努力让程序员在编写代码的时候避免出现任何语法上的错误。
一种导致代码运行错误的最常见方式是试图访问一个不存在的数据。添加下面的代码到Playground中:
func getStatus() -> String { return "Good" }
假设是一个监测个人状态的应用,其中有一个函数getStatus()是返回个人状态情况的。该函数没有参数,但它会返回一个状态字符串:“Good”。如果今天没有进行状态的测试,它应该返回什么呢?“Bad”显然不行,因为它也代表一种状态。空字符串也许是很常用的解决方案,但是如果在其他情况下,需要返回的是数字呢?不管是用0还是-1,它们都代表实数,并不能代表一种无实际值的情况。
Swift为我们提供了一种解决方案:可选。一个可选值说明它可能有值或者可能没有值。
上面getStatus()函数的返回值是String,这意味着:调用getStatus()函数以后,不管内部执行什么样的代码,总会有一个字符串类型的返回值。如果我们想告诉Swift,这个函数可能会返回一个字符串对象,或者返回一个空值呢?那就需要使用下面的代码来替代之前的代码:
func getStatus() -> String? { return "Good" }
请注意这个问号,它表示返回值的类型是可选字符串。现在,我们仍然可以在getStatus()函数中返回字符串对象,但是也可以返回一个nil对象,修改之前的函数为下面这样:
func getStatus(isTest: Bool) -> String? { if isTest == true { if score > 80 { return "Good" } else if score > 60 { return "Normal" } else { return "Bad" } }else { return nil } }
它接受一个参数isTest代表用户是否进行了测试,如果值为true则会根据分数返回相应的字符串,但是如果值为false则会返回nil,是一个没有任何意义的值。也就是说,通过这个函数我们或者得到一个字符串,或者得到一个nil。
一个重要的事情是:Swift想让我们的代码更加安全,如果直接使用这个nil值也是非常危险的,因为它可能会让代码崩溃,出现逻辑问题,或者是让UI显示错误的东西。因此,在声明一个变量为可选的时候,Swift要确保这样处理才够安全。
添加下面的代码到Playground:
var score = 100 func getStatus(isTest: Bool) -> String? { if isTest == true { if score > 80 { return "Good" } else if score > 60 { return "Normal" } else { return "Bad" } }else { return nil } } var status: String status = getStatus(isTest: true)
函数下面的第一行代码,我们声明了一个字符串变量status,然后在第二行将getStatus(:)函数运行的返回值赋值给status。此时的代码是不会运行的,因为status是String类型,只有纯String类型的对象才能赋值给它。因此当前的getStatus是不会返回String对象的,它只能返回可选String类型。Swift不会让这样的情况发生,从而避免Bug的发生。
修复这个问题,我们只需要让status的类型为String?即可:
var status: String? status = getStatus(isTest: true)
如果在代码中直接使用可选变量将是非常危险的,比如下面这段代码:
func printStatus(status: String) { if status == "Good" { print("你的状态相当好!") } }
该函数通过传递进来的String类型的参数,打印相应的信息。参数是String类型而不是String? ,因此不能传递可选类型的变量,这也就意味着该函数不能接受之前定义的status变量作为参数——因为该变量是可选类型。
func printStatus(status: String) { if status == "Good" { print("你的状态相当好!") } } // 下面这句报错! printStatus(status: status)
Swift提供两种解决方案:第一种方案叫作可选拆包,通过特定的语法判断可选变量是否有值。它主要完成两件事情:检查可选变量status是否有值;根据情况执行相应的语句代码。可选拆包语法如下:
if let unwrappedStatus = status { //unwrappedStatus 包含一个String类型的值! } else { // 当status的值为nil的时候,需要处理的一些代码... }
这里的if-let语句检测并拆包一个可选变量到一个新的常量(极少数情况下是变量),再根据实际情况执行相应的代码。
if let unwrappedStatus = status { printStatus(status: unwrappedStatus) } else { print("今天无状态!") }
Swift提供的第二种方案叫强制拆包。如果我们知道一个可选变量已经包含了实际值,就可以直接使用!进行强制拆包。但是需要注意的是,如果你试图在一个值为nil的可选变量上强制拆包,将会发生崩溃。
删除之前的if let unwrappedStatus = status {开始的所有代码,替换成如下语句:
// 因为确定status是有实际值的,所以在这里使用!对其强制拆包 printStatus(status:status! )
注意
使用这句代码之前一定要确保可选变量中是有值的。
可选的概念虽然非常好,但是真正使用起来可能会比较麻烦,比如类中的一个属性A是可选,那我们应该如何访问该属性的子属性A1呢?如果A1还是可选呢?以此类推,如何访问A1的子属性A11呢?如果它又是可选呢?通过A访问A11的话,我们需要经过几层嵌套的if let语句才可以呢?这大大降低了代码的可读性。
好在Swift提供了可选链解决这个问题。还记得我们学习可选的初衷吗?完全是因为那段让人不知所措的两行代码:
let rect = notification.userInfo! [UIKeyboardFrameEndUserInfoKey] as! NSValue keyboard = rect.cgRectValue
notification对象中有一个属性userInfo,如果按住command键单击它的话,会看到它是可选字典类型。
public var userInfo: [AnyHashable : Any]?
因此使用userInfo? [UIKeyboardFrameEndUserInfoKey]获取字典中键名为UIKeyboard-FrameEndUserInfoKey的值,注意,因为字典是可选的,所以要在字典的后面添加一个!。
此时,通过字典所获取到的值是NSRect类型,它是一种值类型(与Int、Float类似),所以并不能直接赋值给keyboard,因为keyboard是CGRect类型,是引用类型。所以,我们使用as!将它强制转换为NSValue类型。NSValue类型有一个属性叫做cgRectValue,它可以返回CGRect类型的对象。
技巧
在调试应用程序的时候,可以通过调试控制台利用po命令查看程序代码中某些对象的信息,帮助我们确定这些对象的类型和值。
步骤1 在代码编辑区域左侧的灰色沟槽中单击鼠标便可以添加一个蓝色的指示条——断点。构建并运行项目,当程序运行到断点时便会暂停运行,这样我们就可以进行单步调试,如图4-5所示。
图4-5 应用程序在遇到断点以后暂停运行
步骤2 在调试控制台上单击单步调试按钮,即图4-6中的第三个按钮,让代码执行到keyboard = rect.cgRectValue这句代码的下边。
图4-6 调试控制台中按钮的作用
步骤3 在调试控制台中的信息输出窗口输入下面这行代码,可以看到相应的信息反馈,如图4-7所示。
图4-7 在调试控制台中打印rect和keyboard值
(lldb) po rect (lldb) po keyboard
po是print object的缩写,通过代码输出发现,rect是NSRect类型,keyboard是CGRect类型。
4.4 以动画的方式改变滚动视图的高度
当键盘出现以后,让滚动视图的高度值从屏幕的高度变为减去虚拟键盘高度后的高度值,这样就相当于滚动视图的窗口高度小于滚动视图的内容高度值,从而允许垂直滚动,且我们还以动画的方式让滚动视图的高度值变化。
步骤1 在showKeyboard(:)方法的底部,添加下面的代码:
func showKeyboard(notification: Notification) { // 定义keyboard大小 let rect = notification.userInfo! [UIKeyboardFrameEndUserInfoKey] as! NSValue keyboard = rect.cgRectValue // 当虚拟键盘出现以后,将滚动视图的实际高度缩小为屏幕高度减去键盘的高度。 UIView.animate(withDuration: 0.4) { self.scrollView.frame.size.height = self.scrollViewHeight - self.keyboard.size. height } }
利用UIView的类方法animate(withDuration:animations:)以动画的方式,在特定的时间改变视图的属性。对于上面的代码,是用0.4秒的时间,改变滚动视图的高度值为当前滚动视图的高度(也就是屏幕的高度)减去呈现出来的虚拟键盘高度。
步骤2 接下来完成hideKeyboard(:)方法:。
func hideKeyboard(notification: Notification) { // 当虚拟键盘消失后,将滚动视图的实际高度改变为屏幕的高度值。 UIView.animate(withDuration: 0.4) { self.scrollView.frame.size.height = self.view.frame.height } }
当虚拟键盘消失的时候,经过0.4秒的时间,将滚动视图的实际高度值改变为屏幕的高度值。
4.5 通过Tap手势让虚拟键盘消失
虽然虚拟键盘出现和消失的处理方法已经在控制器类中编写完成,但是让虚拟键盘消失的事件我们还没有定义。接下来,我们要为控制器的视图添加一个单击手势。
步骤1 在viewDidLoad()方法的底部添加下面的代码:
// 声明隐藏虚拟键盘的操作 let hideTap = UITapGestureRecognizer(target: self, action: #selector(hideKey boardTap)) hideTap.numberOfTapsRequired = 1 self.view.isUserInteractionEnabled = true self.view.addGestureRecognizer(hideTap)
在上面代码的第一行,我们创建了一个单击手势,当手势发生后会调用当前类的hidekeyboardTap(:)方法;第二行设置了该手势的单击次数是1次;第三行设置了当前控制器的视图为可交互,也就是能够响应用户的单击操作,默认控制器的视图是不可交互的;最后一行是将该手势识别添加到控制器的视图上。
步骤2 为SignUpVC类中添加hideKeyboardTap(:)方法:
// 隐藏视图中的虚拟键盘 func hideKeyboardTap(recognizer: UITapGestureRecognizer) { self.view.endEditing(true) }
UIView的endEditing()方法用于设置视图的编辑状态。当视图中的Text Field处于编辑状态(虚拟键盘呈现在屏幕上)时,执行endEditing(true)可以让虚拟键盘消失,也就是让视图中所有Text Field的The first responder处于挂起状态。
构建并运行项目,在登录界面中单击注册按钮,然后单击任意的Text Field后会出现虚拟键盘,并且它盖住了位于底部的Text Field和Button。在视图上拖曳鼠标后,可以看到所有的UI元素。单击视图后虚拟键盘立即消失,如图4-8所示。
图4-8 虚拟键盘完美呈现
本章小结
本章我们利用本地消息通知获取虚拟键盘出现和消失时候的事件,并且指定了在发生键盘事件时的方法。通过传递Notification对象参数,我们了解了如何获取虚拟键盘的高度值。在获取键盘高度值的同时,又简单了解了可选变量的相关知识。