#4 期末 App :『LoL Assistant』

➊ 作品 Demo

Simulator: iPhone 12

➌ 功能需求

串接後台的 API 抓取 JSON 資料後以 List 顯示,點選 row 可到下一頁顯示 detail,至少使用到兩個 API。

Champions 資料

https://raw.githubusercontent.com/kevinwforney/LoLChampions/main/champions.json

Riot API

let KEY = "RGAPI-f5114ea7-4a52-44db-8221-4655eb690371"
//API Key 過期日期 :
//2023/01/10 - 07:53 PM

Get summoner by name :

//使用 summoner (玩家)名字找出 summoner id
guard let urlString = "https://na1.api.riotgames.com/lol/summoner/v4/summoners/by-name/\(name)?api_key=\(KEY)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: urlString) else {
error = FetchError.invalidURL
return
}

Get champion masteries by summoner:

//使用從summoner by name 抓的 summoner id 抓 champion mastery
guard let urlString = "https://na1.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-summoner/\(summId)?api_key=\(KEY)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: urlString) else {
error = FetchError.invalidURL
return
}

以 TabView & NavigationView 製作多頁面 App。

TabView & NavigationView

定義遵從 ObservableObject 的 class 串接網路 API 抓資料,利用 Published property 觸發畫面更新,使用到 EnvironmentObject。

class ChampionFetcher: ObservableObject {
@Published var champions = [Champion]()
@Published var showError = false
@Published var isLoading = false
//...
}

class ChampionSaver: ObservableObject {
@AppStorage("champions") var championsData: Data?
@AppStorage("UserData") var UserDataStorage:Data?

@Published var champions = [Champion]()
//...
}

class SummonerFetcher: ObservableObject {
@Published var summoners = [Summoner]()
@Published var masteries = [Mastery]()
@Published var showError = false
@Published var isLoading = false
//...
}
@main
struct final_00857051App: App {
@StateObject private var fetcher = ChampionFetcher()
@StateObject private var sFetcher = SummonerFetcher()
@StateObject private var saver = ChampionSaver()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(fetcher)
.environmentObject(sFetcher)
.environmentObject(saver)
}
}
}

使用 ProgressView 顯示資料下載中。

fetcher.isLoading = true
if !fetcher.isLoading {
//...
} else {
ProgressView()
.scaleEffect(x: 3, y: 3)
.progressViewStyle(CircularProgressViewStyle(tint: Color("LeagueGold")))
}

資料抓取失敗,比方沒有網路時,顯示 alert。

‣ 想抓的資料本來不存在

‣ 無效或過期的 API Key

‣ 內部服務器錯誤

‣ 錄 demo 影片時發生一個問題,search 某一個 summoner 時找不到資料,一開始以為 api key 又過期 (每 24 個小時要 regenerate 一次),後來發現 Riot 的官方內部伺服器有錯誤。這種問題我根本沒辦法解決,只能等他們修改伺服器錯誤。

//func fetchData 裡面
if String(data: data, encoding: .utf8)! == "{\"status\":{\"message\":\"Forbidden\",\"status_code\":403}}" {
print("API key unauthorized!")
self.errorMessage = "Invalid API key!"
} //error code 403
if String(data: data, encoding: .utf8)! == "{\"status\":{\"message\":\"Data not found - summoner not found\",\"status_code\":404}}" {
print("No summoner of that name!")
self.errorMessage = "No summoner of that name!"
} //error code 404
if String(data: data, encoding: .utf8)! == "{\"status\":{\"message\":\"Internal server error\",\"status_code\":500}}" {
print("Internal server error!")
self.errorMessage = "Internal server error!"
} //error code 500

//SummonerTab 裡面
.alert(isPresented: $fetcher.showError, content: {
Alert(title: Text(fetcher.errorMessage))
})

使用 SPM 加入第三方套件。(不包含上課範例提到的 Kingfisher)

‣ 使用直的 TabView 來顯示簡約的 detail (原本只用 ScrollView)

VTabView

Swift Package 連結

import VTabView
VTabView {
// INTRO PAGE
}
.tabItem {
Image(systemName: "square.fill")
}
// ABILITIES PAGE
}
.tabItem {
Image(systemName: "circle.fill")
}
// SKINS PAGE
.tabItem {
Image(systemName: "triangle.fill")
}
}
.tabViewStyle(PageTabViewStyle())

下拉更新功能 (舊版使用重新整理的 button)

Button {
fetcher.champions.removeAll()
fetcher.isLoading = true //顯示 ProgressView()
fetcher.fetchData()
} label: {
ButtonView(color: "ButtonColor", image: "arrow.counterclockwise")
}

使用 FavQs API 開發註冊登入功能。

class DataSaver: ObservableObject {
@AppStorage("UserData") var UserDataStorage: Data?

@Published var UserData = [UserTokenForLogin]() {
didSet{
let encoder = JSONEncoder()
do {
UserDataStorage = try encoder.encode(UserData)
} catch {
print(error)
}
}
}

init() {
if let UserDataStorage = UserDataStorage{
let decoder = JSONDecoder()
do {
UserData = try decoder.decode([UserTokenForLogin].self, from: UserDataStorage)
} catch {
print(error)
}
}
}
}
//Sign Up
func FavQsSignUp(userName: String = "", email: String = "", password: String = "") {
var isValid: Bool = true
if userName.trimmingCharacters(in: CharacterSet.whitespaces) == "" || userName.count < 2{
signUpUserNameMessage = "2-20 character length"
isValid = false
} else if userName.contains("_") {
signUpUserNameMessage = "Invalid character(s)"
isValid = false
}
if email.trimmingCharacters(in: CharacterSet.whitespaces) == "" {
signUpEmailMessage = "Email must not be empty"
isValid = false
}
if password.trimmingCharacters(in: CharacterSet.whitespaces) == "" || password.count < 5 || password.count > 20 {
signUpPasswordMessage = "5-20 character length"
isValid = false
}
if !isValid {
isLoading = false
return
}

let url = URL(string: "https://favqs.com/api/users")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Token token=dd1e779881e3541da7af1ed7ec2b18d3", forHTTPHeaderField: "Authorization")
let data = "{\"user\": {\"login\": \"\(userName)\",\"email\": \"\(email)\",\"password\": \"\(password)\"}}".data(using: .utf8)
request.httpBody = data
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
let statusCode = (response as? HTTPURLResponse)?.statusCode
if statusCode == 200{
let dataToString = String(data: data, encoding: .utf8)!
print(dataToString)
if !dataToString.contains("error_code") {
let decoder = JSONDecoder()
do {
let dataItem = try decoder.decode(UserTokenForLogin.self, from: data)
DispatchQueue.main.async {
saver.UserData.removeAll()
saver.UserData.append(dataItem)
signUpUserNameMessage = ""
signUpEmailMessage = ""
signUpPasswordMessage = ""
isLoading = false
viewMode = 1
}
return
} catch {
print(error)
}
} else {
if dataToString.contains("Username") {
if dataToString.contains("Username has already been taken"){
signUpUserNameMessage = "Username has already been taken"
} else if dataToString.contains("Username is too") {
signUpUserNameMessage = "2-20 character length"
} else {
signUpUserNameMessage = "Invalid character(s)"
}
}
if dataToString.contains("Email") {
if dataToString.contains("Email has already been taken"){
signUpEmailMessage = "Email has already been taken"
} else {
signUpEmailMessage = "Invalid email format"
}
}
if dataToString.contains("Password") {
signUpPasswordMessage = "5-20 character length"
}
isLoading = false
return
}
}
} else if let error = error {
isLoading = false
print(error)
}
}.resume()
}
//Login
func FavQsLogIn(userNameOrEmail: String = "", password: String = "") {
if userNameOrEmail.trimmingCharacters(in: CharacterSet.whitespaces) == "" {
logInPasswordMessage = "Invalid login or password"
isLoading = false
return
}
if password.trimmingCharacters(in: CharacterSet.whitespaces) == "" {
logInPasswordMessage = "Invalid login or password"
isLoading = false
return
}
let url = URL(string: "https://favqs.com/api/session")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Token token=dd1e779881e3541da7af1ed7ec2b18d3", forHTTPHeaderField: "Authorization")
let data = "{\"user\": {\"login\": \"\(userNameOrEmail)\",\"password\": \"\(password)\"}}".data(using: .utf8)
request.httpBody = data
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
let statusCode = (response as? HTTPURLResponse)?.statusCode
if statusCode == 200 {
let dataToString = String(data: data, encoding: .utf8)!
print(dataToString)
if !dataToString.contains("error_code") {
let decoder = JSONDecoder()
do {
let dataItem = try decoder.decode(UserTokenForLogin.self, from: data)
DispatchQueue.main.async {
saver.UserData.removeAll()
saver.UserData.append(dataItem)
logInPasswordMessage = ""
isLoading = false
viewMode = 1
}
return
} catch {
isLoading = false
print(error)
}
} else {
if dataToString.contains("Invalid login or password") {
logInPasswordMessage = "Invalid login or password"
} else {
logInPasswordMessage = "Account lost!"
}
isLoading = false
return
}
}

} else if let error = error {
isLoading = false
print(error)
}
}.resume()
}

加入 search 功能 (舊版使用 TextField)

‣ 輸入文字時過濾已有資料

@State private var searchText = ""
var searchResult: [Champion] {
var champions = [Champion]()
var index: [Champion]
if searchText.isEmpty {
index = fetcher.champions
} else {
index = fetcher.champions.filter {
$0.name.contains(searchText)
}
}
}

List { //body 裡面
ForEach(searchResult) {
champion in NavigationLink (
destination: ChampionView(champion: champion),
label: {
ChampionRow(champion: champion)
})
}
}

‣ 點選 enter 鍵時搜尋資料

@State private var searchText = ""
TextField("Search summoner name", text: $searchText, onCommit: {
fetcher.isLoading = true //顯示 ProgressView()
isShowing = false //顯示初始 app 畫面
fetcher.fetchData(name: searchText)
})

實現分享功能 (舊版使用 UIViewControllerRepresentable 加入 UIActivityViewController)

struct ShareSheet: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems:
[
"League of Legends",
URL(string: "https://developer.riotgames.com/")!
],
applicationActivities: nil
)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
print("Update UI View Controller")
}
typealias UIViewControllerType = UIActivityViewController
}

資料可儲存跟刪除,比方加入收藏功能儲存網路抓取的資料。

class ChampionSaver: ObservableObject {
@AppStorage("champions") var championsData: Data?
@Published var champions = [Champion]() {
didSet {
do {
championsData = try JSONEncoder().encode(champions)
} catch {
print("error")
}
}
}

init() {
if let championsData = championsData {
let decoder = JSONDecoder()
do {
champions = try decoder.decode([Champion].self, from: championsData)
} catch {
print("error")
}
}
}

var searchResult: [Champion] {
var champions = [Champion]()
var index: [Champion]
if searchText.isEmpty {
index = fetcher.champions
} else {
index = fetcher.champions.filter {
$0.name.contains(searchText)
}
}
for var champion in index {
let isContain = saver.champions.contains {
$0.id == champion.id
}
if isContain {
champion.isSave = true
} else {
champion.isSave = false
}
champions.append(champion)
}
return champions
}

➍ 心得

我越多看 API 選項想到越多 App idea,這讓了我猶豫到底要作出哪一種App。因為我自己知道不能變得過於雄心勃勃,要不然作一個不適合我對SwiftUI能力的 App 會很痛苦。我就開始想我如果沒辦法寫出每個需求功能,至少要做好看一點的 App UI。還有夠時間的話我其實想把 champion detail 加個 VideoPlayer 播每個英雄技能的影片 (看:https://d28xe8vt774jo5.cloudfront.net/champion-abilities/0131/ability_0131_Q1.webm)

--

--