SwiftでAPIを実装してデータを反映してみる

## はじめに
こんにちは、ゆきおです。
今日はConnpassの題材にする簡単なiOSアプリの実装をしていきます。

## アプリの概要
XCode,Swiftを使って、公開されているAPIにGETリクエストを送ってデータを反映したいと思います。

今回使用するサイトは海外のITニュースサイトの「Hacker News」を使います。
https://news.ycombinator.com/
このサイトの記事一覧を取得するAPIが公開されているので、記事一覧を表示するアプリを作ってみます。
https://hn.algolia.com/api

## 見た目から作る
とりあえずレイアウトから作っていきましょう。
今回は宣言的にUIを実装できる「SwiftUI」を使って実装していきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/8f764aad-01b8-e433-ddf9-7b49ac48d790.png" alt="スクリーンショット 2024-06-26 14.22.01.png" width="300" height="200">


こんな感じで一覧画面を作ります。
・タイトル
・作成者
・ポイント
・コメント
これらをまずダミーでレイアウトだけ組んでみます。

## 
![スクリーンショット 2024-06-26 14.10.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/f5fc0d63-a061-6d36-7f04-434f1de728bb.png)

そんなわけでこちらがSwiftUIで立ち上げたプロジェクトです。
"VStack"という縦並びレイアウトの宣言をして、その中に"Image()"で画像を呼び出したり
"Text()"で「Hello,world!」を出力しているContentView（親ビュー）がデフォルトで実装されています。
また.paddingや.imageScaleのようにドット記法でレイアウトを直感的に調整できます。

何よりビューファイルを書いている横で画面イメージが更新されていくのも非常に分かりやすくていいですね。


記事一覧画面を作るには、"List{ }"を使うことで簡単に実現できます。
Listの中に"VStack","HStack"を使って上下左右にレイアウトを組み、Textで文字を入れていきます。

```swift
import SwiftUI

// ContentView: 主なビューを定義する構造体
struct ContentView: View {
    var body: some View {
        // NavigationView: ナビゲーションバーを提供するコンテナ
        NavigationView {
            // List: 縦方向にアイテムを並べるリストビュー
            List {
                // 各ニュース記事の表示を行うVStack
                VStack(alignment: .leading, spacing: 4) {
                    // Text: テキスト表示
                    Text("Show HN: Glasskube – Open Source Kubernetes Package Manager, alternative to Helm")
                        .font(.headline) // テキストのフォントスタイルを設定
                    // HStack: 横方向にアイテムを並べるビュー
                    HStack {
                        Text("By pmig")
                        Spacer() // フレックススペース、左右の要素を分ける
                        Text("178 points")
                    }
                    .font(.subheadline) // HStack内のフォントスタイルを設定
                    .foregroundColor(.secondary) // テキストの色をセカンダリーに設定
                    Text("76 comments")
                        .font(.subheadline) // テキストのフォントスタイルを設定
                        .foregroundColor(.secondary) // テキストの色をセカンダリーに設定
                }
                .padding(.vertical, 8) // VStackに垂直方向のパディングを設定
                
                // 次のニュース記事
                VStack(alignment: .leading, spacing: 4) {
                    Text("Does a cave beneath Pembroke Castle hold key to fate of early Britons?")
                        .font(.headline)
                    HStack {
                        Text("By Brajeshwar")
                        Spacer()
                        Text("49 points")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    Text("16 comments")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
                
                // 次のニュース記事
                VStack(alignment: .leading, spacing: 4) {
                    Text("The album art of Phil Hartman(n) (2022)")
                        .font(.headline)
                    HStack {
                        Text("By JojoFatsani")
                        Spacer()
                        Text("67 points")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    Text("8 comments")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
                
                // 次のニュース記事
                VStack(alignment: .leading, spacing: 4) {
                    Text("Fixing QuickLook (2023)")
                        .font(.headline)
                    HStack {
                        Text("By rogual")
                        Spacer()
                        Text("135 points")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    Text("19 comments")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
                
                // 次のニュース記事
                VStack(alignment: .leading, spacing: 4) {
                    Text("Do not confuse a random variable with its distribution")
                        .font(.headline)
                    HStack {
                        Text("By reqo")
                        Spacer()
                        Text("41 points")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    Text("26 comments")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
            }
            .navigationTitle("Hacker News") // ナビゲーションバーのタイトルを設定
        }
    }
}

// プレビュー用の構造体
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

```
あっという間に見た目の完成です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/8f764aad-01b8-e433-ddf9-7b49ac48d790.png" alt="スクリーンショット 2024-06-26 14.22.01.png" width="300" height="200">

次にAPIリクエストを作成し、各パラメータを取得した値に置き換えていきます。

## APIリクエストを作成する
まず各データをモデルにしてデータを受け取る準備をします。
ここで要注意なのは、受け取るためにAPI側のパラメータ名と合わせるということに気をつけてください。

```swift
import Foundation

struct Results: Decodable {
    let hits: [Post]
}

struct Post: Decodable, Identifiable {
    var id: String {
        return objectID
    }
    let objectID: String
    let points: Int
    let title: String
    let url: String
    let author: String
}
```

APIの値

![スクリーンショット 2024-06-26 14.56.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/74312932-16c8-a6f5-a43d-7cee20c63013.png)

これを受け取る準備が出来たならリクエストを送ります。

Swiftには標準で"URLSession"というHTTPライブラリが備わっており、特にパッケージをインストールなどせずともAPIが実装できちゃいます。

だいぶ簡略化しますが、ViewModelにこれを実装します。
大抵は情報を取得することを"fetch"という言い回しをするので、fetch〇〇というメソッドにします。
```swift
import SwiftUI
import Combine

// ViewModelクラス：HackerNewsViewModel
// APIからデータを取得し、SwiftUIのビューで表示するためのデータを管理
class HackerNewsViewModel: ObservableObject {
    // SwiftUIのビューがこのプロパティの変更を監視し、自動的に更新
    @Published var posts: [Post] = []
    
    // ネットワークリクエストのキャンセルを管理
    private var cancellable: AnyCancellable?
    
    // APIからデータを取得し、postsプロパティに格納
    func fetchPosts() {
        // APIのURLを定義
        guard let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") else {
            return
        }
        
        // ネットワークリクエストを実行
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data } // データ部分のみを取得
            .decode(type: Results.self, decoder: JSONDecoder()) // JSONデコーダを使用してデコード
            .receive(on: DispatchQueue.main) // メインスレッドで受信したデータを処理
            .sink(receiveCompletion: { completion in
                // リクエストの成功または失敗を処理
                switch completion {
                case .failure(let error):
                    print("Error fetching posts: \(error)")
                case .finished:
                    break
                }
            }, receiveValue: { [weak self] results in
                // 取得したデータをpostsプロパティに格納
                self?.posts = results.hits
            })
    }
}

```
CombineというAPIリクエストをシンプルに記述できるフレームワークを使います。
興味のある方は深掘りしてみてください。

https://developer.apple.com/documentation/combine

https://zenn.dev/naoya_maeda/articles/2ca0d08c6d9ac8

ほんでリクエストが無事成功しモデルにデータを格納できたらビューに反映させます

```swift
import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = HackerNewsViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.posts) { post in
                VStack(alignment: .leading, spacing: 4) {
                    Text(post.title)
                        .font(.headline)
                    HStack {
                        Text("By \(post.author)")
                        Spacer()
                        Text("Points: \(post.points)")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    Text(post.url)
                        .foregroundColor(.blue)
                        .onTapGesture {
                            if let url = URL(string: post.url) {
                                UIApplication.shared.open(url)
                            }
                        }
                }
                .padding(.vertical, 8)
            }
            .navigationTitle("Hacker News")
            .onAppear {
                viewModel.fetchPosts()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

onAppearを使ってAPIを実行しています。
```swift
//ページを開いたときに{ }内のメソッドを実行するというモディファイア
.onAppear {
    viewModel.fetchPosts()
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/72604448-dda8-cace-b197-4c568c0c5009.png" alt="スクリーンショット 2024-06-26 15.28.50.png" width="300">

これでニュースを取得し反映させることができました！
楽ちんですね。

ついでに、ここから少しアレンジを加えていきましょう

## １０件表示する
取得できたデータのうち１０件を表示するようにしましょう。
それにはViewModelをちょこっと変えるだけです。

```swift
import SwiftUI
import Combine

class HackerNewsViewModel: ObservableObject {
    @Published var posts: [Post] = []
    private var cancellable: AnyCancellable?
    
    func fetchPosts() {
        guard let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") else {
            return
        }
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Results.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    print("Error fetching posts: \(error)")
                case .finished:
                    break
                }
            }, receiveValue: { [weak self] results in
                // 取得したデータの最初の10件をpostsプロパティに格納
                self?.posts = Array(results.hits.prefix(10))
            })
    }
}
```
これだけで１０件表示になります。
１０件表示にしたのでここからもアレンジをしていきましょう。

## PulltoRefreshを実装する
まず、データを10件取得したときにランダム取得するよう"shuffled"を追加します。
また、SwiftUIには"Refreshable"というモディファイアがあります。
これを実行するときにこの取得APIを実行するように実装します。

```swift
import SwiftUI
import Combine

class HackerNewsViewModel: ObservableObject {
    @Published var posts: [Post] = []
    private var cancellable: AnyCancellable?
    
    func fetchPosts() {
        guard let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") else {
            return
        }
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Results.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    print("Error fetching posts: \(error)")
                case .finished:
                    break
                }
            }, receiveValue: { [weak self] results in
                // データをシャッフルし、最初の10件をpostsプロパティに格納
                self?.posts = Array(results.hits.shuffled().prefix(10))
            })
    }
}

```

```swift
import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = HackerNewsViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.posts) { post in
                VStack(alignment: .leading, spacing: 4) {
                    Text(post.title)
                        .font(.headline)
                    HStack {
                        Text("By \(post.author)")
                        Spacer()
                        Text("Points: \(post.points)")
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    HStack {
                        Text("Comments: \(post.num_comments)")
                        Spacer()
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                }
                .padding(.vertical, 8)
            }
            .navigationTitle("Hacker News")
            .onAppear {
                viewModel.fetchPosts()
            }
            // プル・トゥ・リフレッシュ機能を追加
            .refreshable {
                viewModel.fetchPosts()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

```
これだけで画面を上に引っ張ると再度ランダムに１０件取得するようになります。

## おわり
かなり簡略化した内容ですが、SwiftでシンプルにAPIを実行してデータを反映させてみました。
今回はプルトゥリフレッシュにしましたが、他にもページネーションなども実装できます。
自分なりにデザインや用件などをアレンジして勉強してみましょう！