【SwiftUI】一旦動く物が作りたい私へ

#はじめに
対象としない読者：ちゃんとswiftを勉強したい人、「正しく」swiftを書きたい人、スタイリッシュなUIのものを作りたい人
対象とする読者：「あ、うごいた」を体験したい人、私


##環境

| もの |バージョン |
|:-:|:-:|
| Xcode  | 12.3  |
|  mac |   Catalina |
|  iPhone |   iOS14 |

## 私
「初めましてswiftさん」と言う状態。
これからappチームの人たちと関わることになったのでお勉強してみましたレベル。

かの有名な [挫折しない iPhoneアプリ開発「超」入門 第8版 【Xcode 11 & iOS 13】 完全対応](https://www.amazon.co.jp/%E7%B5%B6%E5%AF%BE%E3%81%AB%E6%8C%AB%E6%8A%98%E3%81%97%E3%81%AA%E3%81%84-iPhone%E3%82%A2%E3%83%97%E3%83%AA%E9%96%8B%E7%99%BA%E3%80%8C%E8%B6%85%E3%80%8D%E5%85%A5%E9%96%80-%E3%80%90Xcode-iOS13%E3%80%91-%E5%AE%8C%E5%85%A8%E5%AF%BE%E5%BF%9C/dp/4815604622)を買ってサンプルコードを打ってみました、レベル。
<img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/b88fc8de-dbef-693f-c090-a1a257eeaadd.png">


こちらはxcode11の本ですが、自身のiPhoneがiOS14でxcode12を使う必要があった（と思う）ので最終的にxcode12にしました。



# 作った物
天気のAPIとニュースのAPIから情報を取得するiOSアプリ
大好きな観光地のベトナム：ダナンの情報を取得する

## イメージ
初期表示

|画面の状態| 初期表示 | 「ダナンの天気」ボタン押下 | 「ニュース」ボタン押下 |
|:-:|:-:|:-:|:-:|
|画面イメージ| <img width="" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/5f5bdd06-36dd-628c-a030-a460186cd98e.png">  | <img width="" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/ba7f0d68-ef7f-5c68-8999-909e9dc05b7a.png">   |  <img width="" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/fa95d98d-0e88-b113-d5fd-ffceeec9df16.png">  |
| 関係のあるモジュール |ContentView.swift |   ContentView.swift　OpenWeatherMap.swift |  ContentView.swift   NewsView.swift   NewsAPI.swift|


# セットアップ
## xcodeを落としてくる。
最新(xcode12)がいいならmacのApp Storeから取得できます。

## ライブラリはCocoaPodsで管理する。
導入方法はこちらを参考にさせていただきました。
[iOSライブラリ管理ツール「CocoaPods」の使用方法](https://qiita.com/satoken0417/items/479bcdf91cff2634ffb1)

インストール先は自身の作業プロジェクト配下です。
私の場合は `/Users/{小松菜}/Practice/arekoreVN`でした。

ベトナムが好きすぎて、これからもっといろいろ足したいので`あれこれベトナム`にしました。
抽象的なのでシステムのネーミングとしてはよくないですね。


※セットアップの途中でエラーになったら適宜対応してください。
私はRuby関連でエラーになったので、メッセージをみながら都度対応しました。
基本的に足りていない物をインストールしたり、古い物をバージョンアップしたり、という作業が発生します。
キーワードでぐぐると結構出てくるので安心してエラーメッセージと向きあえました。


CocoaPodのインストールで`pod install`コマンドを実施したと思います。
するとプロジェクト配下にワークスペース(.xcworkspaceファイル)ができていますね？
それをひらけたらコーディング開始です。
感動ですね。はじめの一歩です。

が、開けたらもう一度閉じてください。
そしてPodfile を以下のように修正して再度`pod install`してください。

```
# Uncomment the next line to define a global platform for your project
 platform :ios, '12.0'
use_frameworks!
install! 'cocoapods',
            :warn_for_unused_master_specs_repo => false

target '自身のプロジェクト名' do
 # Comment the next line if you don't want to use dynamic frameworks

 # Pods for 自身のプロジェクト名
  pod "SwiftyJSON"

end
```
これで準備は完了です。(多分）

# 初期表示画面を実装する
早速画面を作っていきます。
<img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/5f5bdd06-36dd-628c-a030-a460186cd98e.png"> 
この画面ですね。
初期表示画面自体は`ContentView.swift`に実装しました、


以下、ソースにコメントアウトする形で説明を入れていきます。
※ 先に1つ下の ```画像（アイコン）の登録```の作業からしてもらってもOKです。

```ContentView.swift
// 初期表示画面。

import SwiftUI

/**
 初期表示画面。
 ダナン の天気の表示とニュースのページに遷移するリンクがある
 */
struct ContentView: View {
    
    // お天気情報 //　このあと、APIでとってきたデータをセットする。
    @State var result = ""
    @State var temp = ""
    @State var humidity = ""
    @State var weathericon = ""
    
    // 画面表示
    var body: some View {
        NavigationView {
            VStack() {
                ScrollView {  //　ScrollViewでスクロールを可能にする
                    VStack() {
                        Text("今日のベトナム")
                            .font(.largeTitle)
                            .fontWeight(.bold).foregroundColor(Color.white)
                            .lineLimit(nil)
                            .padding(.all, 30)
                            .padding(.bottom,50)
                        
                        HStack{
                            Button(action: {}) { //　actionは今は空。このあとopenweathermapにアクセスする処理を書いて呼び出す。
                                Image("weather").resizable().frame(width: 90, height:90)
                                Text("ダナンの天気")
                                    .fontWeight(.bold).font(.title)
                                    .foregroundColor(Color.white)
                            }
                        }
                        //　天気情報を取得できたときのみ、天気情報のパーツを表示させる。
                        if (self.result.count != 0 ){
                            VStack{
                                Group{
                                    VStack{
                                        Image("WeatherIcons/\(weathericon)").resizable().frame(width: 200, height: 200).padding(.all,-40)
                                        Text("\(result)").padding(.top, 0)
                                    }
                                    HStack{
                                        Text("気温 ")
                                            .padding([.top, .leading])
                                        Text("\(temp)")
                                            .padding([.top, .leading])
                                    }
                                    HStack{
                                        Text("湿度 ")
                                            .padding([.top, .leading])
                                        Text("\(humidity)")
                                            .padding([.top, .leading])
                                    }
                                }.font(.title2)
                            }
                            .padding(50)
                            .background(Color.white.opacity(0.7))
                        }
                        HStack{
                                Image("news")
                                    .resizable()
                                    .frame(width: 90, height: 90)
                                Text("ニュース")
                                    .fontWeight(.bold).font(.title)
                                    .foregroundColor(Color.white)
                        }
                    }
                    //背景画像(bg)を全画面にセットしたかったので以下のようにしてい
                }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top)
                .background(Image("bg"))
            }
        }
    }
}

/**
 初期画面プレビューの表示
 */
// これがないとXcode上でプレビューを表示できないので忘れずに。
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

```

### 画像（アイコン）の登録
プロジェクト配下のAssets.xcassetsに格納します。
ネットでフリー配布されているものの色をいじって使いました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/05dbabc0-a00e-cbd6-dfe1-c760e6af2e07.png)




# OpenWeatherMap　APIから天気情報を取得する
この画面部分の作成に入ります。
 <img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/ba7f0d68-ef7f-5c68-8999-909e9dc05b7a.png"> 

## OpenWeatherMapへの登録
天気情報の取得は[OpenWeatherMap](https://openweathermap.org/api)の`Weather Maps 2.0`を使いました。
会員登録すれば個人利用であれば無料で使えます。(アクセス数の上限はあり)

詳しい使い方は公式のドキュメントを読んでみてください。
シンプルな英語で書かれているので比較的読みやすいかと思います。

appidが発行できたら、`天気を取得したい地域`と`自身のａｐｐｉｄ`をセットしてアクセスしてきましょう。
今回はベトナムのTuranの地域(q=Turan,vn)にしました

`https://api.openweathermap.org/data/2.5/weather?q=Turan,vn&appid={自身のappid}&lang=ja&units=metric`

するとjsonが返却されます。

## json → swift に変換する
返却されたjsonの値を[quicktype](https://app.quicktype.io/) に突っ込んでswift化しました。


## APIの実装
こちらもソースにコメントを追加する形で説明します


```OpenWeatherMap.swift

// APIからお天気情報取得して格納する処理たち

/** json -> swiftに直す処理をする
 https://app.quicktype.io/　で自動生成したもの
 使わないものもあるので適宜削除してよし。
 */
import SwiftUI

// MARK: - Templatures
struct Templatures: Codable {
    let coord: Coord
    let weather: [Weather]
    let base: String
    let main: Main
    let visibility: Int
    let wind: Wind
    let clouds: Clouds
    let dt: Int
    let sys: Sys
    let timezone, id: Int
    let name: String
    let cod: Int
}

// MARK: - Clouds
struct Clouds: Codable {
    let all: Int
}

// MARK: - Coord
struct Coord: Codable {
    let lon, lat: Double
}

// MARK: - Main
struct Main: Codable {
    let temp, feelsLike, tempMin: Double
    let tempMax, pressure, humidity: Int
    
    enum CodingKeys: String, CodingKey {
        case temp
        case feelsLike = "feels_like"
        case tempMin = "temp_min"
        case tempMax = "temp_max"
        case pressure, humidity
    }
}

// MARK: - Sys
struct Sys: Codable {
    let type, id: Int
    let country: String
    let sunrise, sunset: Int
}

// MARK: - Weather
struct Weather: Codable {
    let id: Int
    let main, weatherDescription, icon: String
    
    enum CodingKeys: String, CodingKey {
        case id, main
        case weatherDescription = "description"
        case icon
    }
}

// MARK: - Wind
struct Wind: Codable {
    let speed: Double
    let deg: Int
}


/**
 OpenWeatherMap からお天気情報を取得する。
 */
//結果を返す関数の定義
typealias RecvFunc = ((_ item:Templatures) -> Void)?

//OpenWeatherMapお天気情報を取得するクラス
class OpenWeatherMap {
    
    // 天気を取得する地域の指定
    let locale = "Turan,vn"
    //　本当はソースに直打ちではなく、設定ファイルなどで管理するべきだけど、いったん置いておく。
    let appid = "自身のappid"
    
    var _action: ((_ item:Templatures) -> Void)?
    
    // 結果受け取り
    func SetAction(action :((_ item:Templatures) -> Void)?){
        self._action = action
    }
    
    // 天気情報の取得
    func getWeather(action:RecvFunc) -> Void {
        self.SetAction(action: action)
        
        // APIのコール
        let VNWeatherURL="https://api.openweathermap.org/data/2.5/weather?q=\(locale)&appid=\(appid)&lang=ja&units=metric"
        guard let url = URL(string: VNWeatherURL)
        else
        {
            print("URLがおかしいので処理を続けられませんでした。")
            return
        }
        
        let request = URLRequest(url: url)
        URLSession.shared.dataTask(with: request){
            data, request, error in
            // 取得データをデコード
            if let data = data {
                if let decodedResponse = try? JSONDecoder().decode(Templatures.self, from: data){
                    DispatchQueue.main.async {
                        self._action!(decodedResponse)
                    }
                }
            }
        }
        // タスクの実行
        .resume()
    }
}
```

これでAPIに接続する部分はOKです。

天気のアイコンはOpenWeatherMapから取得出来ます。
私はダウンロードして`./Assets.xcassets/WeatherIcons`に格納しました。
xcodeはファイル名からよしなに画像を引っ張ってきてくれます。
私はなんか嫌だったので以下の設定をしました。
これ以降のソースもこの設定をしていることが前提です
[【Xcode】画像をフォルダ管理したい](https://qiita.com/komatsuuuuuna/items/b014d5682cfed56cc035)




次は画面のボタンを押下してこの処理を呼び出せるようにします。

```ContentView.swift
// 初期表示画面。
import SwiftUI

/**
 初期表示画面。
 ダナン の天気の表示とニュースのページに遷移するリンクがある
 */
struct ContentView: View {
    
    // お天気情報
    @State var result = ""
    @State var temp = ""
    @State var humidity = ""
    @State var weathericon = ""
    
    var weatherObj = OpenWeatherMap()
    
    //取得したお天気情報の編集
    func editData(data : Templatures) {
        self.result = ""
        self.temp = "\(data.main.temp)℃"
        self.humidity =  "\(data.main.humidity)%"
        
        // 天気データを複数取ることもあるため、1列で表示させる処理を追加)
        data.weather.forEach{
            item in
            if (self.result.count == 0 )
            {
                self.result = item.weatherDescription
            }else {
                self.result = self.result + "、" + item.weatherDescription
            }
        }
        // アイコンの設定
        let icon = data.weather[0].icon
        weathericon = icon
    }
    
    
    // 画面表示
    var body: some View {
        NavigationView {
            VStack() {
                ScrollView {
                    VStack() {
                        Text("今日のベトナム")
                            .font(.largeTitle)
                            .fontWeight(.bold).foregroundColor(Color.white)
                            .lineLimit(nil)
                            .padding(.all, 30)
                            .padding(.bottom,50)
                        
                        HStack{
                            Button(action: {weatherObj.getWeather(action: self.editData) }) {
                                Image("weather").resizable().frame(width: 90, height:90)
                                Text("ダナンの天気")
                                    .fontWeight(.bold).font(.title)
                                    .foregroundColor(Color.white)
                            }
                        }
                        //　天気情報を取得できたときのみ、天気情報のパーツを表示させる。
                        if (self.result.count != 0 ){
                            VStack{
                                Group{
                                    VStack{
                                        Image("WeatherIcons/\(weathericon)").resizable().frame(width: 200, height: 200).padding(.all,-40)
                                        Text("\(result)").padding(.top, 0)
                                    }
                                    HStack{
                                        Text("気温 ")
                                            .padding([.top, .leading])
                                        Text("\(temp)")
                                            .padding([.top, .leading])
                                    }
                                    HStack{
                                        Text("湿度 ")
                                            .padding([.top, .leading])
                                        Text("\(humidity)")
                                            .padding([.top, .leading])
                                    }
                                }.font(.title2)
                            }
                            .padding(50)
                            .background(Color.white.opacity(0.7))
                        }
                        HStack{
                                Image("news")
                                    .resizable()
                                    .frame(width: 90, height: 90)
                                Text("ニュース")
                                    .fontWeight(.bold).font(.title)
                                    .foregroundColor(Color.white)
                        }
                    }.padding().frame(width: nil)
                }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top)
                .background(Image("bg"))
            }
        }
    }
}

/**
 初期画面プレビューの表示
 */
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```
これで「ダナンの天気」ボタンを押すとAPIでデータを取得して表示が出来ます。
このAPIの呼び出しはこの部分ですね。
Buttonに呼び出すactionを設定します。以下です。
`Button(action: {weatherObj.getWeather(action: self.editData) }) { ... } `

最後にニュース画面の作成、画面遷移、画面遷移時のAPIの呼び出しを実装します。


# ニュース画面の実装
この部分ですね。行き詰まりすぎて投げ出したくなりました。
<img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513179/fa95d98d-0e88-b113-d5fd-ffceeec9df16.png"> 


## ニュース画面への遷移はNavigationLinkを使いました
`ContentView.swift`のニュースのパーツを`NavigationLink`で囲みます。これは画面遷移などに使われるパーツです。
`NewsView`は遷移先のニュース画面です。

```ContentView.swift
HStack{
    NavigationLink(destination: NewsView()) {
        Image("news")
            .resizable()
            .frame(width: 90, height: 90)
        Text("ニュース")
            .fontWeight(.bold).font(.title)
            .foregroundColor(Color.white)
    }
}
```


## ニュース画面の実装
画面表示時にニュースを取得するように`onAppear`を使用しました。


```NewsView.swift
import SwiftUI

struct NewsView: View {
    
    var newsMap = NewsApiMap()
    @State var errorMsg = ""
    @State var newsList:[(id: Int, title:String, url: String, urlToImage: String )] = []
    
    // ニュースを取得して編集
    func getData(data : News) {
        newsList = []
        errorMsg = ""
        if(data.articles.count != 0){
            newsList = []
            
            for i:Int in (0 ... (data.articles.count - 1)){
                self.newsList.append((i,data.articles[i].title, data.articles[i].url, data.articles[i].urlToImage))
            }
        } else {
            errorMsg = "ベトナムの関連のニュースがありませんでした。\nごめんなさい。"
        }
    }
    
    var body: some View{
        VStack  {
            VStack{
                Text("ニュース").font(.largeTitle).fontWeight(.bold).foregroundColor(.white)
            }
            .padding(.all, -5)
            Spacer()
            VStack{
                if (self.errorMsg == ""){
                    List {
                        ForEach(newsList.indices,id: \.self) { index in
                            HStack{
                                Link("\(self.newsList[index].title)", destination: URL(string: "\(self.newsList[index].url)")!)
                            }
                        }
                    }
                } else {
                    VStack{
                        Text("\(self.errorMsg)").font(.title).fontWeight(.bold).foregroundColor(.black)
                    }.frame(width: 300, height: 400, alignment: .center)
                }
            }.padding()
            .background(Color.white.opacity(0.7))
            Spacer()
        }.onAppear{newsMap.getNews(action: self.getData)}
       .background(Image("bg"))
        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
    }
}

struct NewsView_Previews: PreviewProvider {
    static var previews: some View {
        NewsView()
    }
}

```

##　ニュースAPIから取得する処理を実装する
基本的には天気のAPIと同じです。
ニュースは[NewsAPI](https://newsapi.org/)のものを使いました。こちらも個人利用であれば無料で利用可能です。（アクセス数の制限はあります）
今回は「ベトナム」のワードを含むニュースをとってきます。

ここでは天気のAPIの実装の差分を紹介してから全文の紹介をします。



###まずは天気APIとの実装の差分
######①日本語を含むキーワードをURLに指定したい場合はaddingPercentEncodingでエンコーディングしたあげる必要がある
まとめたのでよかったらどうぞ。
[【Swift】日本語を含むURLを使いたい時はaddingPercentEncodingせよ](【Swift】日本語を含むURLを使いたい時はaddingPercentEncodingせよ)

######② quicktypeの出力結果をそのまんまは使えなかった。
よーくよーく見ればわかる部分でした。ヒントは型。
答えはこちらに書いているのでよかったらどうぞ。
[【Swift】NewsAPIで取得したjsonのデコードで失敗して半日潰した話](https://qiita.com/komatsuuuuuna/items/4c6f5668c12952cc276a)
当時はデバッグしても「デコードのあたりで落ちているや:ghost:」としかわかりませんでした。
　
　
ソースの全量はこちらです。

```NewsView.swift
//  NewsAPIから日本語のベトナムニュースをとってくる

/** json -> swiftに直す処理をする
 https://app.quicktype.io/　で自動生成したものから使うものだけ残した
 */

import SwiftUI

// MARK: - News
struct News: Codable {
    let status: String
    let totalResults: Int
    let articles: [Article]
}

// MARK: - Article
struct Article: Codable {
    let title: String
    let url: String
    let urlToImage: String
}
/**
 NewAPI から"ベトナム"のワードを持つニュースを取得する
 */
//結果を返す関数の定義
typealias getNewsFunc = ((_ item:News) -> Void)?

//NewsApiからデータを取得するクラス
class NewsApiMap : ObservableObject {
    @Published var articles: [Article] = []
    
    let keyword = "ベトナム"
    let apikey = "自身のapikey"
    var _action: ((_ item:News) -> Void)?
    
    // 結果受け取り
    func SetAction(action :((_ item:News) -> Void)?){
        self._action = action
    }
    
    // ニュースの取得
    func getNews(action:getNewsFunc) -> Void {
        
        self.SetAction(action: action)
        // APIのコール
        let NewsApiURL="https://newsapi.org/v2/everything?q=\(keyword)&num=1&sortBy=latest&apiKey=\(apikey)"
        let encodedURL = NewsApiURL.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
        
        guard let url = NSURL(string: encodedURL!) else {
            print("無効なURL")
            return
        }
        
        let request = URLRequest(url: url as URL)
        
        URLSession.shared.dataTask(with: request){
            data, request, error in
            // 取得データをデコード
            if let data = data {
                if let decodedResponse = try? JSONDecoder().decode(News.self, from: data){
                    DispatchQueue.main.async {
                        self._action!(decodedResponse)
                    }
                }
            }
        }
        // タスクの実行
        .resume()
    }
}
```



以上、動いて楽しかったのでここから体系的に勉強していこうと思います。