第17章 实战一:简易计算器 耳闻之不如目见之,目见之不如足践之。 ——刘向 本章将带领读者进入Swift编程语言学习的实战部分。所谓“实践出真知”,因此,要完全掌握一种编程技能,实战是必修课。 本章将以一款简易的计算器为示例,将界面逻辑设计与界面开发结合,为读者讲解一个完整项目的开发思路与过程。在逐渐熟悉与理解项目的开发思路后,再加以练习,秩序渐近,你终会成为一名合格的软件开发者。 通过本章,你将学习到: ? 使用Aotulayout对横竖屏进行适配。 ? 封装计算方法类。 ? 独立视图控件的封装。 ? 视图控件间的交互与传值。 ? 开发第一款属于自己的完整iOS应用。 17.1 计算器按键与操作面板的封装 在开发一款完整的应用程序时,要始终遵循面向对象与封装的思路。对于计算器软件,可以将其拆分为界面与功能逻辑两部分。在界面开发中,又可以将界面分为显示板与操作板两部分,显示板顾名思义是用来显示用户输入和计算结果的,而操作面板排布着各种数字按钮和运算符号,主要用于接收用户的输入操作。本小节首先来做操作面板界面的开发。 观察生活中常用的计算器操作面板,可以看到它由一系列的功能按钮组成,因此在编写操作面板之前,可以先来封装这些功能按钮。 使用Xcode开发工具创建一个项目,并将其命名为Calculator,本项目采用Autolayout技术来进行界面布局,因此需要在项目中引入SnapKit第三方框架。 在工程中新建一个类文件,使其继承自UIButton类,并命名为FuncButton。需要对其提供一个构造方法来设置按钮字体、颜色和风格,代码如下: class FuncButton: UIButton { init() { //要使用自动布局 这里的frame设置为(0,0,0,0) super.init(frame: CGRect.zero) //为按钮添加边框 self.layer.borderWidth = 0.5; self.layer.borderColor = UIColor(red: 219/255.0, green: 219/255.0, blue: 219/255.0, alpha: 1).cgColor //设置字体与字体颜色 self.setTitleColor(UIColor.orange, for: UIControlState.normal) self.titleLabel?.font = UIFont.systemFont(ofSize: 25) self.setTitleColor(UIColor.black, for: UIControlState.highlighted) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 在工程中创建一个继承自UIView的类,将其用作计算器的操作面板,并命名为Board。在其中引入SnapKit框架: import SnapKit 首先在这个类中提供一个数组属性,用于存放操作面板上所有功能按钮的标题,数组如下: var dataArray = ["0",".","%","=" ,"1","2","3","+" ,"4","5","6","-" ,"7","8","9","*" ,"AC","Delete","^","/" ] 重写父类的构造方法,在其中进行界面的加载操作: override init(frame: CGRect) { super.init(frame: frame) installUI() } installUI()方法是开发者自定义的一个方法,用来对界面进行布局,实现如下: func installUI(){ //创建一个变量 用于保存当前布局按钮的上一个按钮 var frontBtn:FuncButton! //进行功能按钮的循环创建 for index in 0..<20{ //创建一个功能按钮 let btn = FuncButton() //在进行自动布局前 必须将其添加到父视图上 self.addSubview(btn) //使用SnapKit进行约束添加 btn.snp.makeConstraints({ (maker) in //当按钮为每一行的第一个时 将其靠父视图左侧摆放 if index%4 == 0 { maker.left.equalTo(0) } //否则将按钮的左边靠其上个按钮的右侧进行摆放 else{ maker.left.equalTo(frontBtn.snp.right) } //当按钮为第一行时,将其靠父视图底部摆放 if index/4 == 0 { maker.bottom.equalTo(0) //当按钮不在第一行且为每行的第一个时,将其底部与上一个按钮的顶部对齐 }else if index%4 == 0 { maker.bottom.equalTo(frontBtn.snp.top) //否则将其底部与上一个按钮底部对齐 }else{ maker.bottom.equalTo(frontBtn.snp.bottom) } //约束宽度为父视图宽度的0.25倍 maker.width.equalTo(btn.superview!.snp.width). multipliedBy(0.25) //约束高度为父视图高度的0.2倍 maker.height.equalTo(btn.superview!.snp.height). multipliedBy(0.2) }) //设置一个标记tag值 btn.tag = index+100 //添加点击事件 btn.addTarget(self, action: #selector(btnClick(button:)), for: .touchUpInside) //设置标题 btn.setTitle(dataArray[index], for: UIControlState.normal) //对上一个按钮进行更新保存 frontBtn = btn } } 上面代码中的布局方法采用了一个比较巧妙的设计思路,将按钮的排版定位为5行4列,布局的顺序为从下向上、从左向右依次布局。最底部的一行和最左侧的一列与父视图边界对齐,其余位置的功能按钮则参照其上一个按钮的位置进行约束布局。btnClick(button:)方法是用户点击按钮后的触发方法,读者可以实现它,在其中打印按钮的tag值以测试布局是否正确,后面会继续处理其交互功能。 func btnClick(button:FuncButton) { print(button.title(for: .normal)) } 运行工程,竖屏横屏下的界面效果如图17-1与图17-2所示。 图17-1 竖屏模式下的计算器面板界面 图17-2 横屏模式下的计算器面板界面 init?(coder:)构造方法是UIView子类必须实现的一个必要构造方法,因此如果开发者对自定义的视图控件覆写了构造方法,那么必须实现这个构造方法,如果不使用此构造方法,开发者可以直接按如下方式编写: required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } 17.2 计算器显示板输入显示的逻辑开发 17.1节读者完成了计算器操作面板的界面开发,本小节继续完成计算器应用界面部分的相关开发。用户在操作面板上进行输入操作,输入的内容我们希望显示在计算器的显示屏上,同时,显示屏还兼有显示计算结果的功能。 首先在Calculator工程中创建一个新的类文件,使其继承于UIView,并其命名为Screen,作为计算器的显示屏控件。显示屏应该分为两部分,一部分用于显示计算结果,另一部分用于显示用户输入的计算过程,这里可以使用两个UILabel控件来处理。 Screen类除了用于显示相关计算信息外,还应该兼具一定的检查功能。举个例子,用户的输入可能是无规律的,某个用户很有可能在“+”运算符后面再次输入“-”运算符,这样会出现不符合规则的算术表达式。因此,开发者需要对用户的输入进行一定控制。比如算术表达式的开头不能是运算符,运算符后面不能再次输入运算符等。 实现Screnn类如下: class Screen: UIView { //用于显示用户输入信息 var inputLabel:UILabel? //用于显示历史记录信息 var historyLabel:UILabel? //用户输入表达式或者计算结果字符串 var inputString = "" //历史表达式字符串 var historyString = "" //所有数字字符 用于进行检测匹配 let figureArray:Array = ["0","1","2","3","4","5","6","7", "8","9","."] //所有运算功能字符 用于进行检测匹配 let funcArray = ["+","-","*","/","%","^"] //默认的构造方法 init() { inputLabel = UILabel() historyLabel = UILabel() super.init(frame: CGRect.zero) installUI() } //进行界面的设计 func installUI() { //设置文字的对齐方式为右对齐 inputLabel?.textAlignment = .right historyLabel?.textAlignment = .right //设置字体 inputLabel?.font = UIFont.systemFont(ofSize: 34) historyLabel?.font = UIFont.systemFont(ofSize: 30) //设置文字颜色 inputLabel?.textColor = UIColor.orange historyLabel?.textColor = UIColor.black //设置文字大小可以根据字数进行适配 inputLabel?.adjustsFontSizeToFitWidth = true inputLabel?.minimumScaleFactor=0.5 historyLabel?.adjustsFontSizeToFitWidth = true historyLabel?.minimumScaleFactor=0.5 //设置文字的截断方式 inputLabel?.lineBreakMode = .byTruncatingHead historyLabel?.lineBreakMode = .byTruncatingHead //设置文字的行数 inputLabel?.numberOfLines = 0 historyLabel?.numberOfLines = 0 self.addSubview(inputLabel!) self.addSubview(historyLabel!) //进行自动布局 inputLabel?.snp.makeConstraints({ (maker) in maker.left.equalTo(10) maker.right.equalTo(-10) maker.bottom.equalTo(-10) maker.height.equalTo(inputLabel!.superview!.snp.height). multipliedBy(0.5).offset(-10) }) historyLabel?.snp.makeConstraints({ (maker) in maker.left.equalTo(10) maker.right.equalTo(-10) maker.top.equalTo(10) maker.height.equalTo(inputLabel!.superview!.snp.height). multipliedBy(0.5).offset(-10) }) } //提供一个输入信息的接口 func inputContent(content:String){ //如果不是数字也不是运算符 则不处理 if !figureArray.contains(content.characters.last!) && !funcArray. contains(content) { return; } //如果不是首次输入字符 if inputString.characters.count>0 { //数字后面可以任意输入 if figureArray.contains(inputString.characters.last!) { inputString.append(content) inputLabel?.text = inputString }else{ //运算符后面只能输入数字 if figureArray.contains(content.characters.last!) { inputString.append(content) inputLabel?.text = inputString } } }else{ //只有数字可以作为首个字符 if figureArray.contains(content.characters.last!){ inputString.append(content) inputLabel?.text = inputString } } } //提供一个刷新历史记录的方法 func refreshHistory() { historyString = inputString historyLabel?.text = historyString } //实现必要的构造方法 required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } Board类可以接收用户的输入,Screen类需要获取用户的输入,它们之间的关联与交互是需要通过ViewController类来完成的。因此需要将Board类略作修改,将其获取到的数据向外传递,使用代理设计模式可以很方便地完成这个功能。 在Board.swift文件中添加如下协议: protocol BoardButtonInputDelegate { func boardButtonClick(content:String) } 在Board类中添加一个代理属性如下: var delegate:BoardButtonInputDelegate? 修改Board类中按钮的点击事件方法如下: func btnClick(button:FuncButton) { if delegate != nil { //通过协议方法将值传递出去 delegate?.boardButtonClick(content: button.currentTitle!) //这样获取title更为方便 } } ViewController类也需要将Board类实例和Screen类实例作为ViewController类的属性,方便ViewController类内部的各个函数之间相互调用: let board = Board() let screen = Screen() 在installUI()方法中添加创建计算器操作板、显示屏对象的代码,如下: func installUI() { self.view.addSubview(board) //设置代理 board.delegate = self board.snp.makeConstraints { (maker) in maker.left.equalTo(0) maker.right.equalTo(0) maker.bottom.equalTo(0) maker.height.equalTo(board.superview!.snp.height).multipliedBy (2/3.0) } self.view.addSubview(screen) screen.snp.makeConstraints { (maker) in maker.left.equalTo(0) maker.right.equalTo(0) maker.top.equalTo(0) maker.bottom.equalTo(board.snp.top) } } 上面的代码也为Board实例设置了代理,因此需要使ViewController类遵守BoardButtonInputDelegate协议,如下所示: class ViewController: UIViewController,BoardButtonInputDelegate 最后,实现协议方法如下所示: func boardButtonClick(content: String) { //如果是这些功能按钮,则进行功能逻辑处理 if content == "AC" || content == "Delete" || content == "=" { //进行功能逻辑处理 screen.refreshHistory() }else{ screen.inputContent(content: content) } } 运行工程,效果如图17-3与图17-4所示。 图17-3 竖屏模式下的计算器界面 图17-4 横屏模式下的计算器界面 到此,简易计算器项目的界面部分已经基本开发完成,后面会与读者一起进行逻辑处理类的封装。将逻辑与界面分离和提供接口的编程方式是面向对象开发的关键,读者在练习时要深入体会。 17.3 计算器计算逻辑的设计 首先,读者需要将Screen类再做一些完善。例如当用户点击清空按钮时,输入的计算表达就应该被清空。当用户点击回退按钮时,上一次输入的字符就应该被清空。在Screen类中添加如下方法: //清空显示屏中当前输入的信息 func clearContent() { inputString = "" } //删除显示屏中上次输入的字符 func deleteInput() { if inputString.characters.count>0 { inputString.remove(at: inputString.index(before: inputString.endIndex)) inputLabel?.text = inputString } } 对于计算功能的实现,可以采取这样的思路:用户输入的本是一串表达式字符串,我们可以通过一个解析方法将字符串中的运算符和操作数分离开来,然后自左向右依次进行计算。需要注意,实际的数学运算会有运算优先级的控制,本项目作为简易计算器的演示,不再做复杂的优先级逻辑控制,计算方式一律采用从左向右依次计算的方式,有兴趣的读者可以自行实现优先级功能。 在项目中新建一个类文件,使其继承于NSObject类,并命名为CalculatorEngine。将其作为计算引擎工具类,实现如下: class CalculatorEngine: NSObject { //运算符集合 let funcArray:CharacterSet = ["+","-","*","/","^","%"] func calculatEquation(equation:String)->Double { //以运算符进行分割获取到所有数字 let elementArray = equation.components(separatedBy: funcArray) //设置一个运算标记游标 var tip = 0 //运算结果 var result:Double = Double(elementArray[0])! //遍历计算表达式 for char in equation.characters { switch char { //进行加法运算 case "+": tip += 1 if elementArray.count>tip { result+=Double(elementArray[tip])! } //进行减法运算 case "-": tip += 1 if elementArray.count>tip { result-=Double(elementArray[tip])! } //进行乘法运算 case "*": tip += 1 if elementArray.count>tip { result*=Double(elementArray[tip])! } //进行除法运算 case "/": tip += 1 if elementArray.count>tip { result/=Double(elementArray[tip])! } //进行取余运算 case "%": tip += 1 if elementArray.count>tip { result = Double(Int(result)%Int(elementArray[tip])!) } //进行指数运算 case "^": tip += 1 if elementArray.count>tip { let tmp = result for _ in 1..