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タブのコンテンツを実装していきます。
イメージはこんな感じです。便宜的に、各コンポーネントに名前をつけました。
ヘッダーの作成
まずは簡単にできそうな、ヘッダー部分から作っていきます。
ヘッダーの要素は、テキストラベルが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)
}
}
}
}
プレビューは次のとおりです。
タイトルとアイコンが水平方向に並び、日付がその上に積まれるようなUIになっていたため、HStack
を2つVStack
に積んでいます。
また、タイトルとアイコンはそれぞれ左端と右端に表示したいためSpacer()
を噛ませてスペースを作っています。
なお、アイコンの画像は http://flat-icon-design.com/ から拝借しました。
日付の部分はフォーマットを作ってオブジェクトを渡して…が手間だったので固定値を入れています。
カードの作成
次にカードを作成します。
iOSのAppStoreアプリでは、カードをタップするとフルスクリーンで中身が表示される仕様になっています。
イメージはこちらです↓
メインイメージの作成
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()
}
}
}
}
プレビューは次のとおりです。
画像にテキストが重なる構造のため、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"))
}
}
プレビューは次のとおりです。
HStack
やVStack
を使った構造はだいぶ見慣れてきたかと思います。
ここでのポイントは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()
}
}
長文が入る箇所なので、.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)
}
}
フルスクリーンを表現するために、.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)
}
}
}
ポイントは.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
ではpresentationMode
がtrue
の場合にフルスクリーンでアイテムの詳細画面を表示します。
本来フルスクリーンのモーダルで表示するべきかと思いますが、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)
}
}
}
}
}
ForEach
を使ってRecommendItemStore
に保持しているアイテムのリストを描画していきます。
フルスクリーン表示に対応するため、現在選択されているアイテムをselection
変数に保持し、それをフルスクリーンに表示する要素として渡します。
SwiftUIで実装してみての所感
本当はTabView
を使って実装していたのですが、フルスクリーンモーダルを実装しようとすると
どうしても最前面にタブバーが表示されてしまいうまく実現できませんでした。
今回は複雑な要件とは言い難いかもしれませんが、そうした要件の実現のため自由にカスタマイズしながら使う場合は、まだUIKitのほうが効率が良いように思えます。(もちろんSwiftUIに対する理解不足も大きいです。)
とはいえ、普段のStoryBoardでの開発に比べると圧倒的にスムーズに実装&検証が進みますし、Viewの組み立てもやりやすくなったと思います。
また、イベント処理がしやすくなったことによる恩恵は大きいのではないでしょうか。
今回はまだSwiftUIのほんの一部にしか触れられていませんが、これを機にもっと色んなものを作ってみたいと思っています。
終わりに
Ateam Lifestyle Advent Calendar 2019 の23日目は、@masatomasato1224がお送りします。お楽しみに!
“挑戦”を大事にするエイチームグループでは、一緒に働けるチャレンジ精神旺盛な仲間を募集しています。興味を持たれた方はぜひエイチームグループ採用サイトを御覧ください。
https://www.a-tm.co.jp/recruit/