SwiftUIでiOSのAppStoreアプリのUIを表現してみる


Ateam Lifestyle Advent Calendar 2019の22日目は
株式会社エイチームライフスタイルでWebとiOSをメインに開発している @hytkgamiが担当します。

SwiftUIでアプリを作ってみようと思い、その制作過程を記事にまとめました。
設計やSwiftUIの実装等、至らない点が多々あるかと思いますが、編集リクエストやコメントをいただけますと幸いです！

# 想定読者

- SwiftUIに興味がある方
- iOSの開発に興味がある方

SwiftUIの`View`について、細かく説明はしていきません。
詳しく知りたい場合は[ドキュメント](https://developer.apple.com/documentation/swiftui)や[チュートリアル](https://developer.apple.com/tutorials/swiftui/)をご覧ください。

# SwiftUIとは

WWDC 2019で発表された、新しいUI構築のフレームワークです。
iOS、macOS、watchOSなどすべてのAppleプラットフォーム上で動作します。

> より優れたAppを、より少ないコードで。
> (中略)
> SwiftUIは宣言型シンタックスを使用しているため、ユーザーインターフェイスの動作をシンプルに記述することができます。たとえば、テキストフィールドからなるアイテムのリストを作成すると書いてから、各フィールドの配置、フォント、色を記述するといった具合です。これにより、コードがかつてないほどシンプルで読みやすくなり、時間の節約と保守作業の負担軽減につながります。

SwiftUIは宣言的な構文が特徴的で、[Xcode - SwiftUI - Apple Developer](https://developer.apple.com/jp/xcode/swiftui/)でも上記のように紹介されています。
より詳しく知りたい方には以下の記事もおすすめです。

- [SwiftUIアプリ開発実践ポイント](https://qiita.com/tamadeveloper/items/3ea2696333725735fee8)
- [SwiftUIの考察](https://qiita.com/tattn/items/920f17868ee3f97ea17d#state%E8%A8%AD%E8%A8%88)

# 主なツール・環境など

## 開発環境

- macOS Catalina 10.15.1
- Swift version 5.1
- Version 11.3 (11C29)

## デザインツール

- Figma

# 開発開始

今回はiOSのAppStoreアプリを開いてすぐに目に入る**Todayタブ**のコンテンツを実装していきます。
イメージはこんな感じです。便宜的に、各コンポーネントに名前をつけました。

![MockImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/eaf9c5ea-f9d3-4e4b-8d5f-2203203ea267.png)

## ヘッダーの作成

まずは簡単にできそうな、ヘッダー部分から作っていきます。
ヘッダーの要素は、テキストラベルが2つと画像が1つです。
以下のように作ります。

```swift
struct HeaderView: View {
    let title: String
    init(title: String) {
        self.title = title
    }
    
    var body: some View {
        VStack {
            HStack {
                Text("12月22日 日曜日")
                    .foregroundColor(.gray)
                    .font(.system(size: 14))
                    .padding(16)
                Spacer()
            }.frame(height: 16, alignment: .topLeading)
            HStack {
                Text(title)
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .padding(16)
                Spacer()
                Image("avator")
                    .resizable() // 画像のサイズを変更可能にする
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 36, height: 36, alignment: .center)
                    .clipShape(Circle()) // 正円形に切り抜く
                    .padding(.trailing, 16)
            }
        }
    }
}
```

プレビューは次のとおりです。

<img width="402" alt="HeaderView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/5e5e9f0a-b4fc-9faf-58a8-84d07731ce97.png">

タイトルとアイコンが水平方向に並び、日付がその上に積まれるようなUIになっていたため、`HStack`を2つ`VStack`に積んでいます。
また、タイトルとアイコンはそれぞれ左端と右端に表示したいため`Spacer()`を噛ませてスペースを作っています。
なお、アイコンの画像は http://flat-icon-design.com/ から拝借しました。

日付の部分はフォーマットを作ってオブジェクトを渡して…が手間だったので固定値を入れています。

## カードの作成

次にカードを作成します。
iOSのAppStoreアプリでは、カードをタップするとフルスクリーンで中身が表示される仕様になっています。
イメージはこちらです↓

![Group 8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/58020452-a4db-25d4-8049-b7f26c66fe6a.png)

### メインイメージの作成

```swift
struct ItemMainView: View {
    var body: some View {
        ZStack(alignment: .top) {
            Image("item_main_image")
                .resizable()
                .frame(height: 420)
            HStack {
                VStack(alignment: .leading, spacing: 0) {
                    Text("title")
                        .font(.headline)
                        .foregroundColor(.white)
                        .shadow(radius: 4.0)
                    Text("APP OF THE DAY")
                        .font(.largeTitle)
                        .foregroundColor(.white)
                        .shadow(radius: 4.0)
                }
                .padding()
                Spacer()
            }
        }
    }
}
```

プレビューは次のとおりです。

<img width="414" alt="CardMainImage.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/db49400c-c3c9-d665-eff5-ebbacbf5d4b0.png">

画像にテキストが重なる構造のため、`ZStack`を利用しています。
引数にある`alignment`によって、上下左右中央のどこを基準として配置するか指定することができます。
画像は https://picsum.photos/ からダウンロードしたものをImageAssetsに追加して利用しています。

### インストールバナーの作成

```swift
struct AppInstallBanner: View {
    var body: some View {
        HStack {
            Image("icon")
                .resizable()
                .frame(width: 48, height: 48)
                .padding()
            VStack(alignment: .leading) {
                Text("WeatherApp")
                    .font(.headline)
                    .lineLimit(1)
                Text("Deliver the weather forecast")
                    .font(.footnote)
                    .lineLimit(1)
                
            }
            Spacer()
            VStack(alignment: .center, spacing: 0) {
                Button(action: {
                    //
                }) {
                    Text("GET")
                        .bold()
                        .foregroundColor(Color.blue)
                }
                .padding(.vertical, 4)
                .padding(.horizontal, 16)
                .background(Color.white)
                .clipShape(Capsule())
                Text("In-app purchase")
                    .lineLimit(1)
                    .font(.caption)
            }
            .fixedSize()
            .padding()
        }
        .foregroundColor(Color.white)
        .background(Color("gray3"))
    }
}
```

プレビューは次のとおりです。

<img width="408" alt="AppInstallBanner.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/53bc4c37-cb53-ac8f-3f08-3add479d6fb9.png">

`HStack`や`VStack`を使った構造はだいぶ見慣れてきたかと思います。
ここでのポイントは`Button`です。ボタン押下時のアクションと見た目を一度に定義します。
今回インストール機能までは実装しないため、コメントアウトするだけにしています。
アプリアイコンはFigmaで適当に作りました。

### 紹介文の作成

```swift
struct ItemIntroduceTextView: View {
    let description: String
    init(with description: String) {
        self.description = description
    }

    var body: some View {
        Text(description)
            .lineLimit(nil)
            .fixedSize(horizontal: false, vertical: true)
            .padding()
    }
}
```

<img width="412" alt="スクリーンショット 2019-12-18 4.12.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/1c35221f-9676-acd4-dfc1-2747f214d0e7.png">

長文が入る箇所なので、`.lineLimit(nil)`としています。
また、`.fixedSize(horizontal: false, vertical: true)`で、要素に応じて垂直方向にViewのサイズが変わるようにしています。

### ここまでのコンポーネントをまとめてカードを作成する

```swift
struct ItemDetailView: View {
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(alignment: .center, spacing: 0) {
                ItemMainView()
                AppInstallBanner()
                Divider()
                    .background(Color.gray)
                ItemIntroduceTextView()
            }
            .background(Color("gray3"))
        }.edgesIgnoringSafeArea(.all)
    }
}
```

<img width="392" alt="ItemDetailView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/04f63acc-b58a-b49d-7175-d87fa26babfc.png">

フルスクリーンを表現するために、`.edgesIgnoringSafeArea(.all)`を利用しています。
本来Viewの端はセーフエリアとの境界までとして描画されるのですが、`.edgesIgnoringSafeArea(.all)`によってその制約を無視します。
全画面に拡張したカードはスクロール可能なため、`ScrollView`で囲います。
クローズボタンはフルスクリーン時にのみ出現するため、後ほど実装します。


## コレクションの作成

まずはカードを一つ置いてみます。

```swift
struct RecommendCollectionView: View {
    var body: some View {
        ScrollView(.vertical, showsIndicators: true) {
            HeaderView(title: "Today")
            ItemDetailView()
                .frame(
                    width: 380,
                    height: 400,
                    alignment: .top)
                .cornerRadius(20)
                .disabled(true)
        }
    }
}
```

<img width="428" alt="RecommendCollectionView.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/91266516-4fdd-e746-32cf-cab04475afa3.png">

ポイントは`.disabled(true)`です。`ScrollView`の中に`ScrollView`がある構造なので、
親のスクロールビューを操作したいのに、子のスクロールビューがスクロールされてしまう…といった状況を起こさないようにします。
それっぽい見た目にはなってきましたが、タップしてフルスクリーン表示される挙動がまだ実装できていません。
ここで`@State`を使います。

### `@State`を使ってフルスクリーン表示を実装する

```swift

struct RecommendCollectionView: View {
    @ObservedObject var store = RecommendItemStore()
    @State private var presentationMode = false
    let item = RecommendItem(id: 1, appName: "appName", title: "title", caption: "caption", recommendReason: "reason", imageUrl: "https://picsum.photos/id/1000/474/520", description: "description")
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                ScrollView(.vertical, showsIndicators: true) {
                    if !self.presentationMode {
                        HeaderView(title: "Today")
                    }
                    ItemDetailView(item: item, presentationMode: self.$presentationMode)
                        .frame(width: 340, height: 380, alignment: .top)
                        .cornerRadius(20)
                        .disabled(true)
                        .onTapGesture {
                            self.presentationMode = true
                    }.padding()
                }
                if self.presentationMode {
                    ItemDetailView(item: item, presentationMode: self.$presentationMode)
                        .background(Color("gray3"))
                        .edgesIgnoringSafeArea(.all)
                }
            }
        }
    }
}

struct ItemDetailView: View {
    let item: RecommendItem
    @Binding var presentationMode: Bool
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(alignment: .center, spacing: 0) {
                ZStack(alignment: .topTrailing) {
                    ItemMainView(with: item)
                    if presentationMode {
                        Button(action: {
                            self.presentationMode = false
                        }) {
                            Image("ic_close")
                        }.padding()
                    }
                }
                AppInstallBanner(with: item)
                Divider()
                    .background(Color.gray)
                ItemIntroduceTextView(with: item.description)
            }
            .background(Color("gray3"))
        }.edgesIgnoringSafeArea(.all)
    }
}

```

いくつか新しい要素が出てきます。

- `GeometryReader`
    - 親レイアウトのサイズと、親に対して相対的な自身の座標を持つコンテナ
- `@State`
    - データバインディングのために用いる修飾子
- `$`
    - `@State`プロパティを`Binding`に変換するためにつけるプレフィクス


`presentationMode`という変数をステートとして管理し、それを`ItemDetailView`にバインディング変換して渡します。
`RecommendCollectionView`では`presentationMode`が`true`の場合にフルスクリーンでアイテムの詳細画面を表示します。

本来フルスクリーンのモーダルで表示するべきかと思いますが、SwiftUIのリファレンスを見てもそのようなメソッドは見当たらず、`ZStack`で実現しました。

[こちらの記事](https://qiita.com/tattn/items/920f17868ee3f97ea17d)にも記載されていますが、他の手段としてはUIKitとの組み合わせでも実現できるようです。

`ItemDetailView`では新たにボタンを追加し、アクションの中でバインドされた`presentationMode`に対して変更を加えています。
こうすることで、`presentationMode`への変更が`RecommendCollectionView`にも伝わりフルスクリーンを解除することが可能です。

## 仕上げ APIを通してカードを生成する

カードが1つでは寂しいので、複数表示したいところですが
すべて固定値では手間がかかるので、APIからデータを取得し、モデルに変換して表示するようにします。

### モデルの準備

```swift
struct RecommendItem: Identifiable, Codable {
    let id: Int
    let appName: String
    let title: String
    let caption: String
    let recommendReason: String
    let imageUrl: String
    let description: String
}
```

単純な`Codable`オブジェクトです。`ForEach`でループさせるために`Identifiable`にも準拠させます。

### APIの用意

[ちょうどいいダミーデータをjson形式で秒で生成してくれる「faker」を紹介](https://qiita.com/shimo_513/items/87f535a8a4afa722d138) を参考にして、ローカルにAPIサーバを用意します。
ほとんどコピペですが、画像URLの部分やテキストの長さを調整したかったので、ダミーデータ生成用のプログラムを以下のようにしています。

```js
let faker = require("faker")

let db = {
 products: []
}

for(let i = 0;i < 20; ++i) {
 db.products.push({
   id: i+1,
   app_name: faker.lorem.word(),
   title: faker.lorem.words(),
   caption: faker.lorem.lines(),
   recommend_reason: faker.lorem.word(),
   image_url: `https://picsum.photos/id/${1000 + i}/474/520`,
   description: faker.lorem.sentences(),
 })
}

console.log(JSON.stringify(db))
```

### URLから`Image`を生成できるようにする

UIKit向けのライブラリはたくさんありますが、SwiftUIに対応したものはほとんどありませんでした。
自前で書くか、UIKit向けのライブラリを使って`UIImage`を取得したあとに`Image(uiImage: UIImage())`のように生成するか迷っていましたが、[Kingfisher](https://github.com/onevcat/Kingfisher)のREADMEに**SwiftUI**の文字が…！

```swift
import KingfisherSwiftUI

var body: some View {
    KFImage(URL(string: "https://example.com/image.png")!)
}
```

ということで、SwiftPackageManagerを使ってインストールします。

### API呼び出し処理の実装

先ほど作成したローカルAPIサーバに向けてリクエストを送る処理を作ります。
`RecommendItemStore`クラスを作成します。

```swift
class RecommendItemStore: ObservableObject {
    @Published var items: [RecommendItem] = []
    
    init() {
        self.fetch()
    }
    
    private func fetch() {
        guard let url = URL(string: "http://localhost:3001/products") else { return }
        URLSession.shared.dataTask(with: url) { (data, _, err) in
            if err != nil {
                print(err.debugDescription)
            }
            let jsonDecoder = JSONDecoder()
            jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
            guard let data = data,
                let items = try? jsonDecoder.decode([RecommendItem].self, from: data) else { return }
            DispatchQueue.main.async {
                self.items = items
            }
        }.resume()
    }
}
```

`@Published`をプロパティに付与することで、値の更新時に通知が行われるようになります。
つまり、上記のクラスでは`fetch()`によってAPIから値がセットされたときに通知が発行されます。

### APIのデータを受け取って描画する

通知を受け取る側では以下のように実装します。

```swift
struct RecommendCollectionView: View {
    @ObservedObject var store = RecommendItemStore()
    @State private var presentationMode = false
    @State private var selection: RecommendItem?
    // 省略
    var body: some View {
        // 省略
            ForEach(self.store.items, id: \.id) { item in
                ItemDetailView(item: item, presentationMode: self.$presentationMode)
                    .frame(width: 340, height: 380, alignment: .top)
                    .cornerRadius(20)
                    .disabled(true)
                    .onTapGesture {
                        self.selection = item
                        self.presentationMode = true
                    }.padding()
                }
            }
            if self.presentationMode {
                ItemDetailView(item: item, presentationMode: self.$presentationMode)
                    .background(Color("gray3"))
                    .edgesIgnoringSafeArea(.all)
                }
            }
        }
    }
}
```

![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68092/71bcbe1f-063f-e930-17f8-0dfc583e91f8.gif)


`ForEach`を使って`RecommendItemStore`に保持しているアイテムのリストを描画していきます。
フルスクリーン表示に対応するため、現在選択されているアイテムを`selection`変数に保持し、それをフルスクリーンに表示する要素として渡します。

# SwiftUIで実装してみての所感

本当は`TabView`を使って実装していたのですが、フルスクリーンモーダルを実装しようとすると
どうしても最前面にタブバーが表示されてしまいうまく実現できませんでした。
今回は複雑な要件とは言い難いかもしれませんが、そうした要件の実現のため自由にカスタマイズしながら使う場合は、まだUIKitのほうが効率が良いように思えます。(もちろんSwiftUIに対する理解不足も大きいです。)

とはいえ、普段のStoryBoardでの開発に比べると圧倒的にスムーズに実装&検証が進みますし、Viewの組み立てもやりやすくなったと思います。
また、イベント処理がしやすくなったことによる恩恵は大きいのではないでしょうか。
今回はまだSwiftUIのほんの一部にしか触れられていませんが、これを機にもっと色んなものを作ってみたいと思っています。

# 終わりに

Ateam Lifestyle Advent Calendar 2019 の23日目は、@masatomasato1224がお送りします。お楽しみに！
“挑戦”を大事にするエイチームグループでは、一緒に働けるチャレンジ精神旺盛な仲間を募集しています。興味を持たれた方はぜひエイチームグループ採用サイトを御覧ください。
https://www.a-tm.co.jp/recruit/