【Swift UI】Qiita API v2で記事取得アプリ(iOS)を開発する流れと実装手順

自分のスキルを上げるために**QiitaのAPI(v2)を使用して記事情報を取得するiOSアプリを開発**してみたいと思います。

# 完成図
![スクリーンショット 2023-06-26 9.10.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/00f04b5b-3d16-1efa-89ec-7a0b24f31d8e.png)
上記画像のような**検索機能を備えたアプリを開発**していきます。実際には公開はしませんがソースコードはGitHubに公開しています。


# 環境
+ Xcode 14.2
+ Swift 5.7.1
+ Qiita API(v2)
+ MVVMアーキテクチャ

## フレームワーク/ライブラリ
+ Swift UI
+ CocoaPods(ライブラリ管理ツール)
+ Alamofire(HTTP通信)

# Qiita API v2とは
Qiitaでは記事情報などをJSON形式で取得できるように「**Qiita API v2**」というAPI(Application Programming Interface)が用意されています。

:::note info
エンドポイント
https://qiita.com/api/v2
:::

詳しくは以下の記事を参考にしてください。

[【Swift】QiitaのAPI(v2)をiOSアプリで操作する方法！パラメータやクエリの実装](https://appdev-room.com/swift-api-qiita-article-list)


# 開発の流れ
1. 新規Xcodeプロジェクトの作成
1. CocoaPodsの導入
1. ライブラリ(Alamofire)の導入
1. API用のModelデータ構造体の作成
1. APIの取得Modelの作成
1. パラメータとクエリ用のModelデータの作成
1. ViewModelの作成
1. リスト表示のUIを構築
1. 検索機能の追加

## 新規Xcodeプロジェクトの作成
まずはXcodeから新規プロジェクトを立ち上げます。
![77FDFBF0-005C-4293-8AF8-FD7FAAEFE479.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/648af42c-5c56-b087-a079-4cf339ac4c6c.png)

今回のプロジェクト名は「**QiitaArticleList**」にしておきました。

## CocoaPodsの導入&ライブラリ(Alamofire)の導入
続いてライブラリ管理ツールである「Cocoa Pods」をプロジェクトに組み込みます。
ターミナルで以下のコマンドを打ち込み「PodFile」を生成します。

```ruby:ターミナル
$ cd プロジェクトまでのパス
$ pod init
```
生成できたらPodFile内に各ライブラリをインストールするコードを追加します。
```ruby:PodFile
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target '(プロジェクト名)' do

  use_frameworks!

  pod 'Alamofire'

end
```
最後に実行``pod install``すればプロジェクトにAlamofireが組み込まれます。

```ruby:ターミナル
$ pod install
```

[【Swift UI】CocoaPodsのインストール方法と使い方！](https://tech.amefure.com/swift-cocoapods)

## API用のModelデータ構造体の作成
続いて**Qiitaから取得する情報をSwift内で操作するためのModelを作成**します。これはAPIで取得できる**JSONデータのフォーマットに倣ったプロパティ名で定義**しておきます。必要になる情報だけを保持させたいので以下のように定義しておきました。他にもAPIで**いいね数や参照URLなどさまざまな情報をJSON形式で受け取れる**ので必要であれば構造体にプロパティを追加してください。

```swift:QiitaApiDataModel.swift
struct User : Decodable,Identifiable{
    let id:String
    let name:String
    let profile_image_url:String
}

struct Article : Decodable,Identifiable{
    let id:String
    let title:String
    let body:String
    let created_at:String
    let updated_at:String
    let user:User
}
```
APIから取得できる情報は実際に記事情報を取得するJSONをみることで確認できます。

[URL：https://qiita.com/api/v2/items/7da45a163e6f06eda6cf](https://qiita.com/api/v2/items/7da45a163e6f06eda6cf)

以下はURLにアクセスした際に返されるJSON。
![スクリーンショット 2023-06-26 11.38.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/0cf96d4d-a449-e9a9-7115-920f342a69cd.png)


## APIの取得Modelの作成
SwiftからAPIにアクセスするために今回は「**Alamofire**」を使用します。対象のURLにアクセスし、レスポンスで受け取ることができるデータを``JSONDecoder``クラスを使用して``[構造体]``に変換します。

[【Swift】Alamofireの導入と使い方！HTTP通信とAPI](https://tech.amefure.com/swift-alamofire)
```swift:QiitaApiModel.swift
import UIKit
import Alamofire

class QiitaApiModel {
    
    private let apiUrl:String = "https://qiita.com/api/v2/items"
    
    public func fetchArticles(params:QiitaParameterModel?,completion:@escaping ([Article]) -> Void) {
        
        AF.request(apiUrl,method: .get,parameters: params)
            .responseData { response in
                do {
                    let decoder = JSONDecoder()
                    let articles = try! decoder.decode([Article].self, from: response.data!)
                    completion(articles)
                } catch {
                    print(error.localizedDescription)
                }
            }   
    }
}
```
変換に成功した配列形式の構造体は``completionHandler``を利用して非同期で取得できるようにしておきます。

[【Swift】completionHandlerとは？使い方と@escapingの意味](https://tech.amefure.com/swift-completionhandler)

## パラメータとクエリ用のModelデータの作成
QiitaのAPIではURLに任意の**パラメータやクエリを渡すことで取得できる記事情報をフィルタリング**することが可能です。

:::note info
全記事を2個でページングした際の1ページ目
https://qiita.com/api/v2/items?page=1&per_page=2
:::

:::note info
タイトルに「swift」が含まれる記事
https://qiita.com/api/v2/items?query=title:swift
:::

なのでパラメータやクエリを指定する用のModelも定義しておきます。

```swift:QiitaParameterModel.swift
struct QiitaParameterModel: Encodable {
    let page: Int
    let per_page: Int
    let query: String?
    
    enum CodingKeys: String, CodingKey {
        case page
        case per_page
        case query
    }
}

```


```swift:QiitaQueryModel.swift
enum QiitaQueryModel:String, Encodable,CaseIterable,Identifiable{
    var id:String { self.rawValue }
    
    case title             // タイトルに指定の文字列が含まれる
    case body              // 本文に指定の文字列が含まれる
    case tag               // 指定のタグを持つ
    case notag = "-tag"    // 指定のタグを持たない
    case user              // 指定のユーザー
//    case created           // 作成日
//    case updated           // 更新日
    case stocks            // ストック数
    
    static func getString(type:QiitaQueryModel,text:String) -> String{
        return "\(type.rawValue):\(text)"
    }
}

```

## ViewModelの作成
ここまで作成したModelを操作するためのViewModelを定義します。

1. ObservableObjectに準拠
1. シングルトン
1. @Publishedなarticlesプロパティを保持

```swift:QiitaArticleViewModel.swift
class QiitaArticleViewModel:ObservableObject {
    
    static let shared = QiitaArticleViewModel()
    
    private let model = QiitaApiModel()
 
    @Published var articles: [Article] = []
    
    public func setArticle(params:QiitaParameterModel?){
        model.fetchArticles(params: params) { array in
            self.articles = array
        }
    }
    
    public func getString(type:QiitaQueryModel,text:String) -> String{
        QiitaQueryModel.getString(type: type, text: text)
    }
}
```

## リスト表示のUIを構築
続いて取得した記事情報をリスト形式で表示させたいため以下のようなUIをSwift UIを使用して実装していきます。

![スクリーンショット 2023-06-26 19.27.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/06d0b4ed-866b-135a-562c-db1ddb58e6d7.png)


```swift:ListArticleView.swift
struct ListArticleView: View {
    
    @ObservedObject var qiitaArticleVM = QiitaArticleViewModel.shared
    @State var text:String = ""
    @State var queryType:QiitaQueryModel = .title
    
    var body: some View {
        
        VStack{
            
            QiitaHeaderView()
            
            QiitaSearchBoxView(text: $text,queryType: $queryType)
            
            QiitaQueryTypeButtonsView(queryType: $queryType)
            
            if qiitaArticleVM.articles.count != 0{
                List(qiitaArticleVM.articles){ article in
                    RowArticleView(article: article)
                }.listStyle(.grouped)
            }else{
                Spacer()
                Text("該当する記事が見つかりませんでした。")
                Spacer()
            }
            
        }.onAppear{
            qiitaArticleVM.setArticle(params: QiitaParameterModel(page: 1, per_page: 5,query:nil))
        }
    }
}

```
1行単位のUIは以下のように定義しています。

```swift:RowArticleView.swift
struct RowArticleView: View {
    
    private let displayDateVM = DisplayDateViewModel()
    
    let article:Article
    
    var body: some View {
        HStack{
            AsyncImage(url: URL(string: article.user.profile_image_url)) { image in
                image.resizable()
                    .cornerRadius(30)
            } placeholder: {
                ProgressView()
            }.frame(width: 30, height: 30)
            VStack{
                Text(article.title)
                HStack{
                    Spacer()
                    Text(displayDateVM.getQiitaFormatToDate(str: article.created_at))
                }
            }
        }
    }
}
```
日付情報を適切に表示させるために``DisplayDateViewModel``も定義しています。
```swift:DisplayDateViewModel.swift
class DisplayDateViewModel {
    
    private let df = DateFormatter()
    
    public func getQiitaFormatToDate(str:String) -> String{
        df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
        let date = df.date(from: str)!
        df.dateFormat = "yyyy/MM/dd HH:mm"
        return df.string(from: date)
    }
}
```

## 検索機能の追加
最後に検索機能を追加します。検索機能には「タイトル」、「内容」、「タグ(含む)」、「タグ(含まない)」、「ユーザー名」、「ストック数」の6つを用意しました。これらをボタンで切り替えられるようにしておきます。また「ストック数」を選択されたときは**キーボードを数字のみに変更**しています。

![スクリーンショット 2023-06-27 13.00.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2264957/085e6a4b-b3a9-697c-d43a-f84d3a8ad678.png)

```swift:QiitaSearchBoxView.swift
struct QiitaSearchBoxView: View {
    
    @ObservedObject var qiitaArticleVM = QiitaArticleViewModel.shared
    @Binding var text:String
    @Binding var queryType:QiitaQueryModel
    @FocusState var isActive:Bool
    
    var body: some View {
        
        HStack{
            TextField("検索文字", text: $text)
                .textFieldStyle(.roundedBorder)
                .padding(5)
                .keyboardType(queryType == .stocks ? .numberPad : .default)
                .focused($isActive)
                .toolbar {
                    ToolbarItem(placement: .keyboard) {
                        Button {
                            isActive = false
                        } label: {
                            Text("閉じる")
                        }.foregroundColor(Color(red: 121/255, green: 196/255, blue: 83/255))
                    }
                }
                .onChange(of: queryType) { newValue in
                    if newValue == .stocks && isActive == true{
                        isActive = false
                        isActive = true
                    }else if newValue != .stocks && isActive == true{
                        isActive = false
                        isActive = true
                    }
                }
            
            Button {
                qiitaArticleVM.setArticle(params: QiitaParameterModel(page: 1, per_page: 5,query: qiitaArticleVM.getString(type: queryType ,text: text)))
            } label: {
                Image(systemName: "magnifyingglass")
            }.padding(5)
                .foregroundColor(.white)
                .background(Color(red: 121/255, green: 196/255, blue: 83/255))
                .cornerRadius(40)
            
        }.padding(5)
    }
}

```

```swift:QiitaQueryTypeButtonsView.swift
struct QiitaQueryTypeButtonsView: View {
    @Binding var queryType:QiitaQueryModel
    var body: some View {
        HStack{
            ForEach(QiitaQueryModel.allCases) { type in
                Button {
                    queryType = type
                } label: {
                    Text("\(type.rawValue)")
                }.padding(5)
                    .foregroundColor(.white)
                    .background(queryType == type ? Color(red: 121/255, green: 196/255, blue: 83/255):.gray)
                    .font(.caption)
                    .cornerRadius(5)
            }
        }
    }
}
```
## 最後に

今回のソースコードはGitHubに公開しています。至らぬ部分やもっとこうした方が良いよみたいなアドバイスをいただけると嬉しいです。

https://github.com/amefure/QiitaArticleList/tree/main