58
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Ateam LifestyleAdvent Calendar 2019

Day 22

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

Last updated at Posted at 2019-12-21

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

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

想定読者

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

SwiftUIのViewについて、細かく説明はしていきません。
詳しく知りたい場合はドキュメントチュートリアルをご覧ください。

SwiftUIとは

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

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

SwiftUIは宣言的な構文が特徴的で、Xcode - SwiftUI - Apple Developerでも上記のように紹介されています。
より詳しく知りたい方には以下の記事もおすすめです。

主なツール・環境など

開発環境

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

デザインツール

  • Figma

開発開始

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

MockImage.png

ヘッダーの作成

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

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)
            }
        }
    }
}

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

HeaderView.png

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

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

カードの作成

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

Group 8.png

メインイメージの作成

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()
            }
        }
    }
}

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

CardMainImage.png

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

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

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"))
    }
}

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

AppInstallBanner.png

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

紹介文の作成

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()
    }
}
スクリーンショット 2019-12-18 4.12.17.png

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

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

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)
    }
}
ItemDetailView.png

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

コレクションの作成

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

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)
        }
    }
}
RecommendCollectionView.png

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

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


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ではpresentationModetrueの場合にフルスクリーンでアイテムの詳細画面を表示します。

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

こちらの記事にも記載されていますが、他の手段としてはUIKitとの組み合わせでも実現できるようです。

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

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

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

モデルの準備

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」を紹介 を参考にして、ローカルにAPIサーバを用意します。
ほとんどコピペですが、画像URLの部分やテキストの長さを調整したかったので、ダミーデータ生成用のプログラムを以下のようにしています。

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のREADMEにSwiftUIの文字が…!

import KingfisherSwiftUI

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

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

API呼び出し処理の実装

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

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のデータを受け取って描画する

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

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

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

SwiftUIで実装してみての所感

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

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

終わりに

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

58
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
58
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?