[iOS] #4 旅行計算機 (固定匯率)

之前出國旅遊都是在計算機APP+匯率APP兩個工具中切換使用,打折或加稅也都還要花費五秒鐘想一下:是乘以一點多少?還要除以一百嗎?這次嘗試整合計算機+換匯+折扣+加稅+小費,做出到各國旅行時好用的花錢小助理。

兩種貨幣

以兩個View繪製換匯的兩種貨幣,開始計算之前,要先選擇以哪一個貨幣為主來輸入算式。選擇貨幣之後,下方計算機輸入的數字和符號,將會顯示在該貨幣顯示框內。

此處使用Tap輕點View的手勢(UITapGestureRecognizer),判斷使用者選擇哪一種貨幣,使用者輕點之後,立即改變View的背景色。首先在繪製主畫面時,將兩個貨幣View分別綁定UITapGestureRecognizer手勢,並定義當使用者輕點View時觸發的動作:


override func viewDidLoad() {
super.viewDidLoad(
initUI()
}

//畫面初始化
@objc func initUI(){
...

//第一個貨幣View綁定手勢
let tapGestureRecognizerOne = UITapGestureRecognizer(target: self, action: #selector(focusCurrencyOne(_:)))
CurrencyOneView.isUserInteractionEnabled = true
CurrencyOneView.addGestureRecognizer(tapGestureRecognizerOne)

//第二個貨幣View綁定手勢
let tapGestureRecognizerTwo = UITapGestureRecognizer(target: self, action: #selector(focusCurrencyTwo(_:)))
CurrencyTwoView.isUserInteractionEnabled = true
CurrencyTwoView.addGestureRecognizer(tapGestureRecognizerTwo)

...
}

//選擇輸入框 Tap
@objc func focusCurrencyOne(_ sender: UITapGestureRecognizer) {
focusCurrencyBox(1)
}
@objc func focusCurrencyTwo(_ sender: UITapGestureRecognizer) {
focusCurrencyBox(2)
}

//選擇輸入框,變更背景色 func
func focusCurrencyBox(_ boxIndex:Int){
CurrencyOneView.backgroundColor = boxIndex==1 ? HColor : DColor
CurrencyTwoView.backgroundColor = boxIndex==2 ? HColor : DColor
currentCurrencyBox = boxIndex
clearData()
}

變更貨幣 (IBSegueAction)

若需要變更貨幣,可點擊貨幣View左方的國旗圓形按鈕,進入貨幣選單。於貨幣選單點選任一個國旗按鈕,即可返回主畫面,並更新按鈕的國旗圖示。

傳遞參數到另一個ViewController

從MainViewController切換到CurrencyViewController時,將原本選擇的貨幣參數,傳遞到CurrencyViewController並儲存在selectedCurrency。接著繪製貨幣選單時,將原本貨幣的國旗按鈕,加上藍色的外框凸顯。

MainViewController 傳遞參數

CurrencyViewController 接收參數

import UIKit
class CurrencyViewController: UIViewController {

//接收前一頁的資料
let selectedCurrency:String //原本選擇的貨幣:TWD、USD、JPY...
let currentCurrencyBox:Int. //使用者點選第一個or第二個
init?(coder:NSCoder, currency:String, box:Int){
self.selectedCurrency = currency
self.currentCurrencyBox = box
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

...

}

Highlight原本選擇的貨幣

  1. 設定每一個國旗按鈕專屬的Identifier
  2. for迴圈比對按鈕Identifier及傳入的參數selectedCurrency
  3. 比對成功的按鈕繪製藍色邊框
  override func viewDidLoad() {
super.viewDidLoad()

//highlight
for flag in FlagButtons {
if flag.accessibilityIdentifier == selectedCurrency {
flag.configuration?.background.strokeColor = HColor
flag.configuration?.background.strokeWidth = 10
}else{
flag.configuration?.background.strokeColor = UIColor.lightGray
flag.configuration?.background.strokeWidth = 1
}
}
}

返回並重新繪製主畫面

使用者點擊任一國旗按鈕,關閉CurrencyViewController返回MainViewController,並回傳新選擇的貨幣參數。(目前先以全域變數回傳參數,待課程之後再改用Protocol with delegate)

    //點選國旗按鈕
//所有的按鈕都採用同一個action
@IBAction func selectCurrency(_ sender: UIButton) {
if currentCurrencyBox==1 {
CurrencyOne = sender.accessibilityIdentifier ?? "TWD"
}else{
CurrencyTwo = sender.accessibilityIdentifier ?? "TWD"
}
dismiss(animated: true, completion: nil)
}

返回主畫面之後,需要更新按鈕的國旗圖示,利用viewWillAppear重繪畫面。

    //畫面重繪 - 子畫面dismiss返回
override func viewWillAppear(_ animated: Bool){
initUI()
}

計算機 (Outlet Collection、didSet)

計算機是以Button組成,因為外觀和功能相近,以群組的方式操作比較方便。從button拉線設定outlet時,connection選擇outlet collection,再把每個Button依序拉到同一個IBOutlet。

再利用Button的didSet函式變更背景圖片的透明度,做出按下按鈕的效果。

    //計算機按鈕
@IBOutlet var NumberButtons: [UIButton]!
@IBOutlet var OperatorButtons: [UIButton]!{
didSet {
for button in OperatorButtons{
button.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.8 : 1
}
}

}
}

四則運算

計算機的首要功能希望可以解析運算式,符合先乘除後加減的運算原則,使用者不需要精通M+、M-、MR這幾個按鈕。當使用者點擊數字和符號按鈕時,逐步將輸入的算式記錄在貨幣View上方的Label,直到按下等於,才開始解析算式並計算結果。

四則運算的程式請教了ChatGPT大師,計算函式也可以解析加上括號的運算式:

    let operators = ["+","-","*","/"]

// 計算給定數學運算式的結果
func calculateExpression(_ expression: String) -> Double? {
let cleanedExpression = expression.replacingOccurrences(of: " ", with: "") // 移除所有空格

// 透過正則表達式來將運算符號與數字分開
let pattern = #"(\d+(\.\d+)?)|([+\-*/])"#
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let matches = regex.matches(in: cleanedExpression, options: [], range: NSRange(location: 0, length: cleanedExpression.utf16.count))

// 將匹配到的token依序加入到tokens陣列中
var tokens: [String] = []
for match in matches {
if let range = Range(match.range, in: cleanedExpression) {
tokens.append(String(cleanedExpression[range]))
}
}

// 建立兩個Stack,一個用來存放數字,一個用來存放運算符號
var numbersStack: [Double] = []
var operatorsStack: [String] = []

// 定義優先順序字典,指定運算符號的優先順序
let precedence: [String: Int] = ["+": 1, "-": 1, "*": 2, "/": 2]

// 處理token陣列,計算運算式的結果
for token in tokens {
if let number = Double(token) {
// 如果是數字,直接加入數字Stack
numbersStack.append(number)
} else if token == "(" {
// 如果是左括號,將其加入運算符號Stack
operatorsStack.append(token)
} else if token == ")" {
// 如果是右括號,處理括號內的運算,直到左括號
while !operatorsStack.isEmpty && operatorsStack.last != "(" { //當符號stack有值 & 前一個符號不是 ( 時
if let operatorToken = operatorsStack.popLast(), //取出上一個符號
let b = numbersStack.popLast(), //取出前一個數值
let a = numbersStack.popLast() { //取出前前一個數值
let result = calculate(a, b, operatorToken) //將括號內從右邊開始的一組運算式,計算出結果
numbersStack.append(result) //將結果存入數值stack
}
}
// 移除左括號
operatorsStack.popLast()

} else if let tokenPrecedence = precedence[token] { //如果在優先順序字典可以取得1或2,代表此token是符號
// 如果是運算符號,處理運算符號的優先順序
while !operatorsStack.isEmpty, //當符號stack有值
let lastOperator = operatorsStack.last, //前一個符號
let lastOperatorPrecedence = precedence[lastOperator], //前一個符號的優先順序 1或2
tokenPrecedence <= lastOperatorPrecedence { //當目前符號的優先順序 小於 前一個符號
//就先處理上一個符號的運算式
if let b = numbersStack.popLast(), //取出前一個數值
let a = numbersStack.popLast() { //取出前前一個數值
let operatorToken = operatorsStack.popLast() //取出上一個符號
if let operatorToken = operatorToken { //上一個符號取出無誤
let result = calculate(a, b, operatorToken) //將該組運算式,計算出結果
numbersStack.append(result) //將結果存入數值stack
}
}
}
// 將目前運算符號加入運算符號Stack
operatorsStack.append(token)
}
}

// 處理剩餘的運算符號(優先的乘和除應該都處理完畢,剩下的是加或減)
while !operatorsStack.isEmpty {
if let b = numbersStack.popLast(), //取出前一個數值
let a = numbersStack.popLast() { //取出前前一個數值
let operatorToken = operatorsStack.popLast() //取出上一個符號
if let operatorToken = operatorToken { //上一個符號取出無誤
let result = calculate(a, b, operatorToken) //將該組運算式,計算出結果
numbersStack.append(result) //將結果存入數值stack
}
}
}

return numbersStack.last // 回傳最終結果
}

// 計算兩數之間的運算結果
func calculate(_ a: Double, _ b: Double, _ operatorToken: String) -> Double {
switch operatorToken {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
default:
fatalError("Unknown operator: \(operatorToken)")
}
}

輸入運算式的過程需要做一些防呆判斷,例如防止使用者連續輸入符號,沒有數字時按下符號要被忽略等等,希望各種狀況都有被考慮到了XD

換匯

當使用者按下等於時,除了解析運算式計算結果之外,也同時將計算結果的數字,乘上對應的貨幣匯率,完成換匯的運算。

目前是固定匯率的版本,只預先儲存六種貨幣相互之間的匯率。以[String:Double] 的陣列形式儲存,再透過 array[String] 的方式快速取得對應的資料:

    var ExchangeRates: [String: [String:Double]] = [
"TWD": ["TWD":1.0, "USD":0.03224, "JPY":4.71921, "KRW":43.64906, "GBP":0.02549, "EUR":0.02954],
"USD": ["TWD":31.02, "USD":1.0, "JPY":146.38981, "KRW":1353.99389, "GBP":0.79072, "EUR":0.9164],
"JPY": ["TWD":0.2119, "USD":0.00683, "JPY":1.0, "KRW":9.24924, "GBP":0.0054, "EUR":0.00626],
"KRW": ["TWD":0.02291, "USD":0.00074, "JPY":0.10812, "KRW":1.0, "GBP":0.00058, "EUR":0.00068],
"GBP": ["TWD":39.23, "USD":1.26467, "JPY":185.1345, "KRW":1712.35268, "GBP":1.0, "EUR":1.15894],
"EUR": ["TWD":33.85, "USD":1.09123, "JPY":159.74516, "KRW":1477.52073, "GBP":0.86286, "EUR":1.0]
]

//1.假設要計算台幣1000元等於多少美金? originalCurrency=TWD, exchangeCurrency=USD
//2.從上方的陣列找到台幣對其他貨幣的匯率array -> "TWD": ["TWD":1.0, "USD":0.03224, "JPY":4.71921, "KRW":43.64906, "GBP":0.02549, "EUR":0.02954]
if let exchangeRates = ExchangeRates[originalCurrency],

//3.再從台幣對外幣的陣列中找到美金的匯率 -> "USD":0.03224
let exchangeRate = exchangeRates[exchangeCurrency],

//4.將台幣金額字串轉為Double型態
let doubleNumber:Double = Double(originalNumber) {

//5.計算並顯示換匯後的美金金額
exchangeLabel?.text = String(format: "%.2f", doubleNumber*exchangeRate)
}

折扣、加稅、小費

以三個TextField讓使用者自行輸入折扣、稅率及小費趴數:

TextField 的 Keyboard Type = Number Pad

點擊任一空白處收健盤

    //按空白處收鍵盤
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
view.endEditing(true)
}

折扣

以目前計算結果的金額乘上折扣,例如20% off,就會將金額再減去20%的數值。使用者要先在TextField設定折扣數字,再按OFF按鈕,就可以在算式中看到減掉的折扣金額。

加稅、小費

都是以計算結果的金額,或是折扣之後的金額來計算稅額和小費額度,再追加上去。使用者要先於 +TAX 或 +TIP 的 TextField 輸入稅率或費率,再按下方的 +TAX按鈕、+TIP按鈕計算,就可在上方的算式中看到追加的稅或小費金額。

即時匯率版本

目前先完成簡單的固定匯率版本,之後會再改版連接API取得即時資料,也會將選擇貨幣的選單改為Table View,以容納大量的貨幣資料,並採用Protocol with delegate 逆向回傳參數。

GitHub

--

--