【iOS】#4 計算機App|連續計算、計算紀錄、正負數、百分比、小數、倒退刪除

UI 部分:

Auto Layout / StackView

UIColor+ext

臨摹 Figma Community 設計圖 App 畫面

使用功能:

可連續計算

可看見計算過程

先乘除後加減

正負數處理

浮點數小數點處理

倒退刪除

使用 NSExpression 讓字串進行運算

View

附上臨摹的 Figma 作品連結:

畫面架構

  1. 最外層為垂直排列的 mainStackView,依序放入 operatorLabel、displayLabel、buttonDatas(水平排列的 subStackView)
  2. 定義資料結構 struct ButtonDatas,將按鈕文字 Array 依照畫面順序排列
class ViewController: UIViewController {
let mainStackView = UIStackView()
let operatorLabel = UILabel()
let displayLabel = UILabel()
let buttonDatas = ButtonDatas()

override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .background
configMainStackView()
configOperatorLabel()
configDisplayLabel()
addButton()
}
}

struct ButtonDatas {
let titles = [
["C", "±", "%", "÷"],
["7", "8" ,"9", "×"],
["4", "5", "6", "-"],
["1", "2", "3", "+"],
[".", "0", "⌫", "="]
]
}

AutoLayout & Stack View

1. mainStackView

  • 在 Figma 中查看 mainStackView 與 safeArea 距離及垂直 spacing 數值
與 safeArea 距離 | 垂直 spacing 數值
  • 依照數值設置
func configMainStackView() {
mainStackView.axis = .vertical
mainStackView.spacing = 16
view.addSubview(mainStackView)
mainStackView.snp.makeConstraints { make in
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).inset(32)
make.leading.equalTo(view.safeAreaLayoutGuide.snp.leading).inset(20)
make.trailing.equalTo(view.safeAreaLayoutGuide.snp.trailing).inset(20)
}
}

2. displayLabel

  • 在 Figma 中查看 displayLabel 文字大小、重量、顏色
  • 數字顯示過多時自動縮小字體,並設定縮放時字體最小值
displayLabel.adjustsFontSizeToFitWidth = true // 開啟自動縮小字體大小
displayLabel.minimumScaleFactor = 0.6 // 設置最小比例為0.6
  • 高度設置為 96 pt,displayLabel 為空時預留空間
func configDisplayLabel() {
displayLabel.text = "0"
displayLabel.font = .systemFont(ofSize: 96, weight: .light)
displayLabel.textColor = .textPrimary
displayLabel.textAlignment = .right
displayLabel.adjustsFontSizeToFitWidth = true // 開啟自動縮小字體大小
displayLabel.minimumScaleFactor = 0.6 // 設置最小比例為0.6
mainStackView.addArrangedSubview(displayLabel)
displayLabel.snp.makeConstraints { make in
make.height.equalTo(96)
}
}

3. operatorLabel

  • 在 Figma 中查看 operatorLabel 文字大小、重量、顏色,依數值設置
func configOperatorLabel() {
operatorLabel.text = ""
operatorLabel.font = .systemFont(ofSize: 40, weight: .light)
operatorLabel.textColor = .textSecondary
operatorLabel.textAlignment = .right
mainStackView.addArrangedSubview(operatorLabel)
}

4. addButton

這邊先複習一下 ButtonDatas 架構(按鈕文字 Array 依照畫面順序排列)

struct ButtonDatas {
let titles = [
["C", "±", "%", "÷"],
["7", "8" ,"9", "×"],
["4", "5", "6", "-"],
["1", "2", "3", "+"],
[".", "0", "⌫", "="]
]
}
  • 使用雙層 for 迴圈排列出計算機按鈕
  • 外層 for 迴圈:titles in buttonDatas.titles 代表每一列按鈕的字串 Array,共有 5 列,為每一列建立出一個水平排列的 subStackView
for titles in buttonDatas.titles {
let subStackView = UIStackView()
subStackView.axis = .horizontal
subStackView.spacing = 16
mainStackView.addArrangedSubview(subStackView)
subStackView.distribution = .fillEqually
}
  • 內層 for 迴圈:title in titles 為每列 Array 中的字串,每列有 4 個字串,為每個字串建立出一個 button 後,依序放入 title 文字
for title in titles {
let button = UIButton()
button.titleLabel?.font = .systemFont(ofSize: 32)
button.layer.cornerRadius = 24
button.snp.makeConstraints { make in
make.height.equalTo(72)
}
subStackView.addArrangedSubview(button)
button.setTitle("\(title)", for: .normal)
}
  • 在 Figma 中查看 button 圓角數值、顏色、文字大小,依數值設置
button 水平 spacing 數值|圓角數值、顏色|文字大小
func addButton() {
for titles in buttonDatas.titles {
let subStackView = UIStackView()
subStackView.axis = .horizontal
subStackView.spacing = 16
mainStackView.addArrangedSubview(subStackView)
subStackView.distribution = .fillEqually

for title in titles {
let button = UIButton()
button.titleLabel?.font = .systemFont(ofSize: 32)
button.layer.cornerRadius = 24
button.snp.makeConstraints { make in
make.height.equalTo(72)
}
subStackView.addArrangedSubview(button)
button.setTitle("\(title)", for: .normal)
//設定button顏色
switch title {
case "C", "±", "%":
button.backgroundColor = .buttonSecondary
button.setTitleColor(.textPrimary, for: .normal)
case "÷", "×", "-", "+", "=":
button.backgroundColor = .buttonPrimary
default:
button.backgroundColor = .buttonDefault
button.setTitleColor(.textPrimary, for: .normal)
}
//連結功能
switch title {
case "C", "±", "%", "=", ".", "⌫":
button.addTarget(self, action: #selector(tabCalculatorControlButton), for: .touchUpInside)
case "÷", "×", "-", "+":
button.addTarget(self, action: #selector(tabOperatorButton), for: .touchUpInside)
default:
button.addTarget(self, action: #selector(tabNumberButton), for: .touchUpInside)
}
}
}
}

UIColor+ext

不知道大家有沒有發現這篇的顏色設定名稱比較特別 😆,像是 .textPrimary .buttonSecondary .buttonDefault,這是擴充了 UIColor,使用自定義的顏色系統

  • 設定檔案如下:
extension UIColor {
// 根據 hex init
public convenience init?(hex: String) {
let r, g, b: CGFloat
if hex.hasPrefix("#") {
let start = hex.index(hex.startIndex, offsetBy: 1)
let hexColor = String(hex[start...])
if hexColor.count == 6 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff0000) >> 16) / 255
g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255
b = CGFloat((hexNumber & 0x0000ff)) / 255
self.init(red: r, green: g, blue: b, alpha: 1)
return
}
}
}
return nil
}
}
  • 考慮 Dark Mode,使用 if traitCollection.userInterfaceStyle == .dark 判斷,區分 Light / Dark Mode 顏色
  • 建立背景顏色、三種按鈕顏色、兩種文字顏色(透明度),程式碼如下:
extension UIColor {
...
// 根據背景模式 給不同顏色
class var background: UIColor {
return UIColor { (traitCollection) -> UIColor in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(hex: "#17171C")!
} else {
return UIColor(hex: "#F1F2F3")!
}
}
}
class var buttonDefault: UIColor {
return UIColor { (traitCollection) -> UIColor in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(hex: "#2E2F38")!
} else {
return UIColor(hex: "#FFFFFF")!
}
}
}
class var buttonSecondary: UIColor {
return UIColor { (traitCollection) -> UIColor in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(hex: "#4E505F")!
} else {
return UIColor(hex: "#D2D3DA")!
}
}
}
class var buttonPrimary: UIColor {
return UIColor(hex: "#4B5EFC")!
}
class var textPrimary: UIColor {
return UIColor { (traitCollection) -> UIColor in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(hex: "#FFFFFF")!
} else {
return UIColor(hex: "#000000")!
}
}
}
class var textSecondary: UIColor {
return UIColor { (traitCollection) -> UIColor in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(red: 255/255, green: 255/255, blue: 255/255, alpha: 0.4)
} else {
return UIColor(red: 0/255, green: 0/255, blue: 0/255, alpha: 0.4)
}
}
}
}
Simulator|Figma 設計圖

以上,成功複製 Figma 設計圖的排版和顏色啦👏

運算功能

目前作法將按鈕分為三大類別:

1. 數字 NumberButton

2. 加減乘除 OperatorButton

3. 其他運算 CalculatorControlButton

1. 點擊數字 tabNumberButton

  • 建立一個變數,用來記錄目前計算機顯示屏的數字
var currentDisplayLabel = ""
  • 在 function 中建立常數 number,儲存被點擊的數字
@objc func tabNumberButton(_ sender: UIButton) {
let number = sender.currentTitle
currentDisplayLabel = displayLabel.text!
}
  • 判斷數字顯示屏中沒有小數點且字首為 0 時,將字首的 0 移除
@objc func tabNumberButton(_ sender: UIButton) {
var number = sender.currentTitle
currentDisplayLabel = displayLabel.text!
//數字中沒有小數點且字首為0時
if !displayLabel.text!.contains(".") && currentDisplayLabel.hasPrefix("0") {
currentDisplayLabel.removeFirst() //移除字首0
}
}
  • 點擊的數字加進顯示屏中
@objc func tabNumberButton(_ sender: UIButton) {
let number = sender.currentTitle
currentDisplayLabel = displayLabel.text!
//數字中沒有小數點且字首為0時
if !displayLabel.text!.contains(".") && currentDisplayLabel.hasPrefix("0") {
currentDisplayLabel.removeFirst() //移除字首0
}
displayLabel.text = "\(currentDisplayLabel)\(number!)" //把字串往後加
}

2. 點擊加減乘除 tabOperatorButton

+ − × ÷

顯示屏有數字時,將數字及運算子加進上方算式列
顯示屏沒有數字時,更改算式列最後一個運算子
  • 建立變數,用來記錄算式列及最後一個顯示屏的數字
var expressionString = ""
var lastCalculatedNumber = ""
  • 在 function 中建立變數 operatorCharacter,儲存被點擊的符號
@objc func tabOperatorButton(_ sender: UIButton) {
var operatorCharacter = sender.currentTitle
}
  • 為了方便後續使用 NSExpression 進行運算,按鈕的乘除符號 × ÷ 需換成程式可運算的乘除符號 * /
@objc func tabOperatorButton(_ sender: UIButton) {
var operatorCharacter = sender.currentTitle
//先把乘除符號換成程式可運算的符號
if sender.currentTitle == "×" {
operatorCharacter = "*"
} else if sender.currentTitle == "÷" {
operatorCharacter = "/"
}
}
  • 點擊時若顯示屏有數字,將數字及運算子加進上方算式列
  • 點擊時若顯示屏沒有數字,更改算式列最後一個運算子
@objc func tabOperatorButton(_ sender: UIButton) {
var operatorCharacter = sender.currentTitle
//先把乘除符號換成程式可運算的符號
if sender.currentTitle == "×" {
operatorCharacter = "*"
} else if sender.currentTitle == "÷" {
operatorCharacter = "/"
}
//如果有下一個要加入運算的數字
if displayLabel.text != "" {
//把上方運算式存起來
expressionString = operatorLabel.text!
//把最後一個被運算的數字存起來
lastCalculatedNumber = displayLabel.text!
//當前結果顯示在上方
operatorLabel.text = expressionString + lastCalculatedNumber + operatorCharacter!
displayLabel.text = ""
} else {
//更換運算子
operatorLabel.text = expressionString + lastCalculatedNumber + operatorCharacter!
}
}

3. 點擊其他運算符號 tabCalculatorControlButton

C ± % = . ⌫

  • 在 function 中建立常數 character,儲存被點擊的符號
  • 使用 switch語法,根據 character 符號匹配 case ,例如歸零、正負號、百分比等
@objc func tabCalculatorControlButton(_ sender: UIButton) {
let character = sender.currentTitle
currentDisplayLabel = displayLabel.text!
switch character {
case "C":
case "±":
case "%":
case ".":
case "⌫":
case "=":
default:
//什麼都不做
break
}
}

1. 歸零 “C”

  • 建立一個 reset function,將 儲存顯示屏數字的變數 及 算式列 清空
func reset() {
currentDisplayLabel = ""
operatorLabel.text = ""
}
  • 將顯示屏數字更換為 0 並執行 reset
case "C":
displayLabel.text = "0"
reset()

2. 正負號 ”±”

  • 使用 guard 語法檢查顯示屏數字不為 0
case "±":
guard currentDisplayLabel != "0" else {
break
}
  • 點擊時若數字前有負號則移除,若沒有則加上負號
case "±":
guard currentDisplayLabel != "0" else {
break
}
if currentDisplayLabel.hasPrefix("-") {
displayLabel.text = currentDisplayLabel.replacingOccurrences(of: "-", with: "")
} else {
displayLabel.text = "-" + currentDisplayLabel
}

3. 百分比 ”%”

  • 使用 guard 語法檢查顯示屏數字不為空
case "%":
guard currentDisplayLabel != "" else {
break
}
  • 由於計算出的答案有可能是小數,因此將顯示屏數字轉型為 Double 後再除以100
case "%":
guard currentDisplayLabel != "" else {
break
}
let calculateResult = Double(currentDisplayLabel)! / 100
  • 擴充四捨五入與無條件捨去的 function 在 Double 中
extension Double {
//四捨五入
func rounding(toDecimal decimal: Int) -> Double {
let numOfDigits = pow(10.0, Double(decimal))
return (self * numOfDigits).rounded(.toNearestOrAwayFromZero) / numOfDigits
}
//無條件捨去
func floor(toInteger integer: Int) -> Int {
let integer = integer - 1
let numberOfDigits = pow(10.0, Double(integer))
return Int((self / numberOfDigits).rounded(.towardZero) * numberOfDigits)
}
}
  • 將計算結果 calculateResult 使用 truncatingRemainder 判斷是否為整数
  • 結果為整數時,將整數第一位後無條件捨去
case "%":
guard currentDisplayLabel != "" else {
break
}
let calculateResult = Double(currentDisplayLabel)! / 100
if calculateResult.truncatingRemainder(dividingBy: 1) == 0 {
displayLabel.text = String(calculateResult.floor(toInteger: 1))
} else {
displayLabel.text = String(calculateResult)
}

extension Double 可參考連結:

4. 小數點 ” . ”

  • 點擊時若顯示屏為空,自動將顯示屏數字變更為:零加上小數點
  • 避免有多個小數點,判斷點擊時數字中不包含 “ . ”,則可以加上小數點
case ".":
if displayLabel.text == "" {
displayLabel.text = "0."
}
if !displayLabel.text!.contains(".") {
displayLabel.text = "\(currentDisplayLabel)\(character!)"
}

5. 倒退刪除 ”⌫ ”

  • 點擊時若顯示屏數字小於或等於個位數,則將數字變更為 0
  • 一般情況點擊時,移除最後一位數
if currentDisplayLabel.count <= 1 {
displayLabel.text = "0"
}
else {
currentDisplayLabel.removeLast()
displayLabel.text = currentDisplayLabel
}

6. 等於 ”= ”

小數計算|四捨五入到小數點後第6位|先乘除後加減
  • 使用 guard 語法檢查顯示屏數字不為空
  • 將算式列及顯示屏數字(最後一個要運算的數字)存進要進行運算的字串中
case "=":
guard displayLabel.text != "" else {
break
}
var mathExpressionString = "\(operatorLabel.text!)\(currentDisplayLabel)"
  • 由於都是整數的運算結果得不出小數,所以算式中有除法 且 算式中不含小數點數字時,手動處理將被除數加上小數點,這樣就能計算出有小數的答案(這是我自己想的暴力方法(?,如果大神們有什麼其他建議,歡迎留言告訴我 🥹)
case "=":
guard displayLabel.text != "" else {
break
}
var mathExpressionString = "\(operatorLabel.text!)\(currentDisplayLabel)"
//算式中有除法 且 算式中不含小數點數字時,手動將被除數加上小數點
if mathExpressionString.contains("/") && !mathExpressionString.contains(".") {
mathExpressionString = mathExpressionString.replacingOccurrences(of: "/", with: ".0/")
}
  • 使用 NSExpression 進行運算,將字串轉換為數學運算式
  • 由於計算出的答案有可能是小數,因此將計算結果轉型為 Double
case "=":
guard displayLabel.text != "" else {
break
}
var mathExpressionString = "\(operatorLabel.text!)\(currentDisplayLabel)"
//算式中有除法 且 算式中不含小數點數字時,由於計算出的答案有可能是小數,手動將被除數加上小數點
if mathExpressionString.contains("/") && !mathExpressionString.contains(".") {
mathExpressionString = mathExpressionString.replacingOccurrences(of: "/", with: ".0/")
}
let expression = NSExpression(format: mathExpressionString)
if let calculateResult = expression.expressionValue(with: nil, context: nil) as? Double {

}
reset()
  • 如前面提到,將計算結果 calculateResult 使用 truncatingRemainder 判斷是否為整數,接著使用 extension Double 的 function
  • .floor 將整數第一位後無條件捨去
  • .rounding 將小數四捨五入取到小數點後第六位
case "=":
guard displayLabel.text != "" else {
break
}
var mathExpressionString = "\(operatorLabel.text!)\(currentDisplayLabel)"
//算式中有除法 且 算式中不含小數點數字時,由於計算出的答案有可能是小數,手動將被除數加上小數點
if mathExpressionString.contains("/") && !mathExpressionString.contains(".") {
mathExpressionString = mathExpressionString.replacingOccurrences(of: "/", with: ".0/")
}
let expression = NSExpression(format: mathExpressionString)
if let calculateResult = expression.expressionValue(with: nil, context: nil) as? Double {
if calculateResult.truncatingRemainder(dividingBy: 1) == 0 {
displayLabel.text = String(calculateResult.floor(toInteger: 1))
} else {
displayLabel.text = String(calculateResult.rounding(toDecimal: 6))
}
}
reset()

使用 NSExpression 計算,在按下等於之前,可以不斷加入數字與運算子(可連續計算),而且好處是會自動進行 “ 先乘除後加減 ”,不用多煩惱要怎麼處理這個問題,給它一個讚 👍

NSExpression 可參考連結:

以上,計算機 APP 大功告成!👏

結語

計算機 APP 一邊寫一邊操作,才發現有些狀況一開始沒有想到,按一按就閃退 🤣,後來又默默加上很多條件判斷

例如:數字顯示區為空時,許多按鈕(倒退刪除、百分比等等)都是陷阱

例如:點擊會進行計算的按鈕時,要檢查算式必須合理

雖然這個設計圖還要多處理算式列的部分,不過算式列也讓後續使用 NSExpression 計算很方便,一開始不知道有 NSExpression 這個好東西,自己寫加減乘除的運算,在碰到連續計算、先乘除後加減的部分,卡關好久🥲,後來參考網路上的寫法,加入 NSExpression 變得好簡單 ❤️

參考的 Figma 設計圖功能鍵不少 🥹,經過一番努力總算把畫面上有的功能都成功實作出來了 🎉 在這邊分享我的心血給大家~

APP 操作展示:

--

--