Swift CollectionView-CsutomerCollectionCell、Decoder API ; CollectionCell陰影、CollectionCell DragDelegate&DropDelegate(拖拽)-(匯率APP製作 Part 1)

Ahri
彼得潘的 Swift iOS / Flutter App 開發教室
25 min readMay 21, 2024

這次開發匯率APP真的花了超多時間在看文章,解BUG…

我自己寫APP的習慣是先大概看一下有什麼功能,不念書就直接開始寫,不會的再查資料,讓自己習慣上班後求助無門的感覺(咦?) ಥ口ಥ

所以這次沒有先規劃UI,邊作邊改,下面也會介紹很多奇怪的BUG給大家避坑。由於很多都備註在程式碼裡,所以文章不會多解釋。

1.先拉好畫面:

2.自製CollectionCell:

Storyboard要選在Cell裡的東西上面才有辦法選cell assistant!!

拉線

切換到UICollectionViewController設定Cell的規格、顯示資料:

    //cell的規格
func configureCellSize() {
let itemSpace: Double = 8
let colimnCount: Double = 1
let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout
let width = floor((collectionView.bounds.width - itemSpace * 2)/colimnCount)
flowLayout?.itemSize = CGSize(width: width, height: 100)
flowLayout?.estimatedItemSize = .zero//如果每個cell一樣大就.zero
flowLayout?.minimumInteritemSpacing = itemSpace//最小間距
flowLayout?.minimumLineSpacing = itemSpace//最小行距
}
 //cell的顯示

// MARK: UICollectionViewDataSource

override func numberOfSections(in collectionView: UICollectionView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1 //每行只有一個cell
}


override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of items
return showRates.count //根據想要的國家顯示cell數量

}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(CustomerCollectionViewCell.self)", for: indexPath) as! CustomerCollectionViewCell//需要轉型加as!,這樣下面再打的時候,才會show客製化拉Cell裡outlet上面的所有東西

let item = showRates[indexPath.item]
//currency字串拆分成前三後三
let sourceCurrency = String(item.currency.prefix(3))//USD
let targetCurrency = String(item.currency.suffix(3))//幣別
cell.usdLabel.text! = "\(targetCurrency)換\(sourceCurrency)"

//只顯示小數點三位數
let formattedRate = String(format: "%.3f", item.rate)
cell.exchangeLabel.text! = formattedRate

//國旗圖案配對顯示
if flag.contains(targetCurrency){
cell.photo.image = UIImage(named: targetCurrency)
}else{
cell.photo.image = UIImage(named: "ONKNOW")
}
//cell圓角&陰影設定
cell.layer.cornerRadius = 10
cell.layer.backgroundColor = UIColor.white.cgColor
cell.layer.shadowColor = UIColor.gray.cgColor
cell.layer.shadowOffset = CGSize(width: 0.0, height: 0.0)
cell.layer.shadowRadius = 3.0
cell.layer.shadowOpacity = 0.5
cell.layer.masksToBounds = false

return cell
}

*為什麼你的cell沒辦法顯示外框陰影?
『!!因為你沒有設定backgroundColor!!』(花了我半天找原因-`д´-*

3. Decoder 匯率API (https://tw.rter.info/capi.php)

網址打開整理好長這樣:

所以我的Struct長這樣:

import Foundation


// 定義單一貨幣的匯率信息結構
struct ExchangeRate: Codable {
let currency: String // 貨幣代碼
let rate: Double // 匯率

// 定義編碼和解碼時使用的鍵
enum CodingKeys: String, CodingKey {
case currency = "currency" // 鍵值為 "currency"
case rate = "Exrate" // 鍵值為 "Exrate"
}
}

// 定義所有貨幣的匯率信息結構
struct ExchangeRates: Codable {
let ratesDict: [String: ExchangeRate] // 使用字典存儲貨幣代碼與匯率信息的映射

// 定義編碼和解碼時使用的鍵
enum CodingKeys: String, CodingKey {
case ratesDict = "" // 因為字典的鍵是動態的,所以這裡使用空字串
}
}

// 使用擴展來定義自定義的初始化方法
extension ExchangeRates {
// 自定義的初始化方法,從Decoder對象中解碼數據
init(from decoder: Decoder) throws {
// 獲取一個容器,使用動態的鍵來進行解碼
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

// 暫時存儲解碼後的匯率信息
var tempRates = [String: ExchangeRate]()

// 遍歷所有的鍵(即貨幣代碼)
for key in container.allKeys {
// 獲取對應鍵的嵌套容器
let exchangeRateContainer = try container.nestedContainer(keyedBy: ExchangeRate.CodingKeys.self, forKey: key)
// 解碼匯率
let rate = try exchangeRateContainer.decode(Double.self, forKey: .rate)

// 創建ExchangeRate對象
let exchangeRate = ExchangeRate(currency: key.stringValue, rate: rate)
// 將解碼後的匯率信息添加到暫存字典中
tempRates[key.stringValue] = exchangeRate
}

// 將解碼後的匯率信息賦值給rates屬性
self.ratesDict = tempRates
}

// 定義動態的鍵
struct DynamicCodingKeys: CodingKey {
var stringValue: String

// 初始化方法,根據字串值創建鍵
init?(stringValue: String) {
self.stringValue = stringValue
}

var intValue: Int? { return nil } // 因為不需要整數鍵,這裡返回nil

// 初始化方法,根據整數值創建鍵,這裡直接返回nil
init?(intValue: Int) { return nil }
}
}

覺得以上太複雜的同學可以簡單的寫一下,我是為了後面功能,所以才寫很長。

UICollectionViewController Decoder :

import UIKit

private let reuseIdentifier = "Cell"

class CollectionViewController: UICollectionViewController, UICollectionViewDragDelegate, UICollectionViewDropDelegate {

var allRates : [ExchangeRate] = [ExchangeRate]()
//目前打開APP後顯示的國家匯率
var keywords = ["USDJPY", "USDTWD", "USD"]
var showRates : [ExchangeRate] = [ExchangeRate]()
//國旗圖片名稱
var flag = ["JPY","TWD","KRW","CHN","USD","MYR","THB","SGD","PHP","CAD","ONKONW"]


override func viewDidLoad() {
super.viewDidLoad()

self.collectionView!.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)

configureCellSize()
decoder()
}

func decoder(){

if let url = URL(string: "https://tw.rter.info/capi.php"){
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {

let decodedRates = try JSONDecoder().decode(ExchangeRates.self, from: data)

//拿所有匯率出來,for(key,value) in 解碼後的.ratesDict;key用不到所以"_"
for (_, rate) in decodedRates.ratesDict {
self.allRates.append(rate)//資料都加到array裡
}
//查要拿出來的資料
for searchRate in self.keywords {
if let rate = self.allRates.first(where: { rate in rate.currency == searchRate}){
var formattedRate = rate
self.showRates.append(rate)
}
}

DispatchQueue.main.async {
self.collectionView.reloadData()

//可以計算出同屬性的“數量”
//let mirror = Mirror(reflecting: rates)
//self.itemsCell = Int(mirror.children.count)
//print(self.itemsCell)
// 類似地,其他貨幣的匯率也可以這樣顯示或使用
}
} catch {
print(error)
}
}
}.resume()
}
}

**因為使用時可能網路會不穩,不建議輸入一次國家,就上網拿一次資料,所以先拿取全部匯率資料放進allRates,再根據需求(keywords)顯示需要的國家**

4.CollectionCell DragDelegate

cell拖曳換位功能

  // MARK: - UICollectionViewDragDelegate
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

// 獲取當前索引的項目
let item = showRates[indexPath.item]
// 創建一個 NSItemProvider 對象,用於提供拖拽數據
let itemProvider = NSItemProvider(object: NSString(string: item.currency))
// 創建一個 UIDragItem 對象,並將 NSItemProvider 賦值給它
let dragItem = UIDragItem(itemProvider: itemProvider)
// 將項目對象賦值給 dragItem
dragItem.localObject = item
// 返回拖拽的數組
return [dragItem]
}

// MARK: - UICollectionViewDropDelegate

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
// 獲取拖曳路徑
guard let destinationIndexPath = coordinator.destinationIndexPath else { return }

coordinator.items.forEach { dropItem in
// 檢查是否有路徑
if let sourceIndexPath = dropItem.sourceIndexPath {
// 處理更新
collectionView.performBatchUpdates({
// 移除原本項目
let item = showRates.remove(at: sourceIndexPath.item)
// 將項目插入到目的地
showRates.insert(item, at: destinationIndexPath.item)
// 更新集合視圖中的項目位置
collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath)
}, completion: nil)
// 完成後丟進去
coordinator.drop(dropItem.dragItem, toItemAt: destinationIndexPath)
}
}
}
// 當拖拽更新時使用
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}

5.以下是整個CollectionViewController程式碼:

import UIKit

private let reuseIdentifier = "Cell"

class CollectionViewController: UICollectionViewController, UICollectionViewDragDelegate, UICollectionViewDropDelegate {

var allRates : [ExchangeRate] = [ExchangeRate]()
var keywords = ["USDJPY", "USDTWD", "USD"]
var showRates : [ExchangeRate] = [ExchangeRate]()
var flag = ["JPY","TWD","KRW","CHN","USD","MYR","THB","SGD","PHP","CAD","ONKONW"]
var srcIndex: IndexPath?
var destIndex: IndexPath?

override func viewDidLoad() {
super.viewDidLoad()
// Uncomment the following line to preserve selection between presentations
// self.clearsSelectionOnViewWillAppear = false

// Register cell classes
self.collectionView!.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)

configureCellSize()
decode()

//拖曳功能開啟
collectionView.dragDelegate = self
collectionView.dropDelegate = self
collectionView.dataSource = self
collectionView.dragInteractionEnabled = true

}


func decode(){

if let url = URL(string: "https://tw.rter.info/capi.php"){
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {

let decodedRates = try JSONDecoder().decode(ExchangeRates.self, from: data)

//拿所有匯率出來,for(key,value) in 解碼後的.ratesDict;key用不到所以"_"
for (_, rate) in decodedRates.ratesDict {
self.allRates.append(rate)//資料都加到array裡
}
//查要拿出來的資料
for searchRate in self.keywords {
if let rate = self.allRates.first(where: { rate in rate.currency == searchRate}){
var formattedRate = rate
self.showRates.append(rate)
}
}


DispatchQueue.main.async {
self.collectionView.reloadData()

//可以計算出同屬性的“數量”
//let mirror = Mirror(reflecting: rates)
//self.itemsCell = Int(mirror.children.count)
//print(self.itemsCell)
// 類似地,其他貨幣的匯率也可以這樣顯示或使用
}
} catch {
print(error)
}
}

}.resume()
}


}
//設定cell樣式
func configureCellSize() {
let itemSpace: Double = 8
let colimnCount: Double = 1
let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout
let width = floor((collectionView.bounds.width - itemSpace * 2)/colimnCount)
flowLayout?.itemSize = CGSize(width: width, height: 100)
flowLayout?.estimatedItemSize = .zero//如果每個cell一樣大就.zero
flowLayout?.minimumInteritemSpacing = itemSpace//最小間距
flowLayout?.minimumLineSpacing = itemSpace//最小行距
}
/*
// MARK: - Navigation

// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using [segue destinationViewController].
// Pass the selected object to the new view controller.
}
*/

// MARK: UICollectionViewDataSource

override func numberOfSections(in collectionView: UICollectionView) -> Int {
// #warning Incomplete implementation, return the number of sections
return 1
}


override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// #warning Incomplete implementation, return the number of items
return showRates.count

}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(CustomerCollectionViewCell.self)", for: indexPath) as! CustomerCollectionViewCell//需要轉型加as!,這樣下面再打的時候,才會show客製化拉Cell裡outlet上面的所有東西

let item = showRates[indexPath.item]
//currency字串拆分成前三後三
let sourceCurrency = String(item.currency.prefix(3))//USD
let targetCurrency = String(item.currency.suffix(3))//
cell.usdLabel.text! = "\(targetCurrency)換\(sourceCurrency)"

//只顯示小數點三位數
let formattedRate = String(format: "%.3f", item.rate)
cell.exchangeLabel.text! = formattedRate

//國旗圖案配對顯示
if flag.contains(targetCurrency){
cell.photo.image = UIImage(named: targetCurrency)
}else{
cell.photo.image = UIImage(named: "ONKNOW")
}
//cell圓角&陰影設定
cell.layer.cornerRadius = 10
cell.layer.backgroundColor = UIColor.white.cgColor
cell.layer.shadowColor = UIColor.gray.cgColor
cell.layer.shadowOffset = CGSize(width: 0.0, height: 0.0)
cell.layer.shadowRadius = 3.0
cell.layer.shadowOpacity = 0.5
cell.layer.masksToBounds = false

return cell
}

// MARK: - UICollectionViewDragDelegate
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {

// 獲取當前索引的項目
let item = showRates[indexPath.item]
// 創建一個 NSItemProvider 對象,用於提供拖拽數據
let itemProvider = NSItemProvider(object: NSString(string: item.currency))
// 創建一個 UIDragItem 對象,並將 NSItemProvider 賦值給它
let dragItem = UIDragItem(itemProvider: itemProvider)
// 將項目對象賦值給 dragItem
dragItem.localObject = item
// 返回拖拽的數組
return [dragItem]
}

// MARK: - UICollectionViewDropDelegate

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
// 獲取拖曳路徑
guard let destinationIndexPath = coordinator.destinationIndexPath else { return }

coordinator.items.forEach { dropItem in
// 檢查是否有路徑
if let sourceIndexPath = dropItem.sourceIndexPath {
// 處理更新
collectionView.performBatchUpdates({
// 移除原本項目
let item = showRates.remove(at: sourceIndexPath.item)
// 將項目插入到目的地
showRates.insert(item, at: destinationIndexPath.item)
// 更新集合視圖中的項目位置
collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath)
}, completion: nil)
// 完成後丟進去
coordinator.drop(dropItem.dragItem, toItemAt: destinationIndexPath)
}
}
}
// 當拖拽更新時使用
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}

未完待續….

--

--