こんにちは、たなたつです
SwiftUIが発表されて半年ほど経ちましたね。あっという間に時間は過ぎていき、iOS 13以降じゃないと使えないし、まだ気にしなくていいでしょなんて言ってられなくなるのもあっという間な気がします。
iOS Advent Calendarの5日目ということで今回は、いくつかSwiftUIでサンプルアプリを作ったり、実際にアプリをリリースしたりした中でたまってきた知見を書こうと思います。
SwiftUIは様々なプラットフォームで動きますがiOSアプリに注目し、開発する前に知っておきたい実践的なポイントなどを共有します。
※ Xcode 11.2.1、iOS 13.2.1 での動作を元に記事を書いています。
SwiftUIの特徴
- 少ないコードでUIを作れる (コードレイアウト)
- 宣言的に記述できる
- Appleのすべてのプラットフォームで動く
- ただし、iOS 13、macOS 10.15、tvOS 13.0、watchOS 6.0以上
このような特徴がよく言われていますが、実際にアプリを作るうえで現状のSwiftUIはどうなのでしょうか。
実際のところSwiftUIってどう?
SwiftUIでiOSアプリをいくつか作ってみてこのように感じました。
- 細かなUIの動きまでアプリの仕様に沿って実装する必要がある場合はかなり難しい
- アプリの仕様をSwiftUIが得意としている仕様に柔軟に変更できる場合は採用しても良い
アプリの仕様通りに細かいUI/UXを実現するのは大変
SwiftUIのAPIはまだUIKitほど柔軟ではないため、UIKitでは実現できるUI/UXを再現できない場合があります。
SwiftUIはUIKitと組み合わせて利用することができるため、SwiftUIではできない部分をUIKitで代わりに実装するというアプローチもできます。
しかし、実際に試してみると組み合わせるために必要なボイラープレートコードが多く、負担になります。
また、SwiftUIの特徴的な機能の一つにStateをバインディングしてUIを自動的に更新するというものがありますが、UIKitと組み合わせたときにそれらの機能の活用が難しくなるケースがあります。
そしてそれを回避するためのワークアラウンド的なコードが必要となり、本質的な実装に集中できなくなりがちです。
SwiftUIの挙動に合わせてUIを変更可能ならあり
SwiftUIが不得意としている仕様を実装するのは、開発の大きなボトルネックになってしまうため、実験的なアプリや個人アプリのように、アプリの仕様を柔軟に変更できる場合は採用してみてもよいと思いました。 (もちろん対応OSバージョンが狭まることを許容できる場合です)
ユーザーファーストの視点とは全く逆になってしまいますが、開発者視点でUI変更できれば、SwiftUIの強みを活かして爆速でアプリを開発することができるかもしれません。
開発の進め方
ここからは実際にSwiftUIでアプリを作るときにおすすめな開発の進め方を紹介します。
現時点ではSwiftUIの情報が少なく、開発者の知識もUIKitほどはないと思いますので、その状況を想定しています。
前述したようにSwiftUIには不得意なUIがあるため、想定しているUIが実現しやすいものなのかどうかを作りながら判断し、難しい場合はアプリの仕様を調整するというサイクルを回していきます。
また、SwiftUIの優れた機能の一つにプレビュー機能があります。
プレビュー機能を使うことで早いサイクルでUIの実現性の確認とレイアウトの調整ができるため、積極的に活用したほうが良いです。
画面の漸進的な開発
まずは作りたいレイアウトになるように、Viewのbody
にべた書きしていくとレイアウトしやすいです。
struct ListCell: View {
var body: some View {
HStack {
Button(action: {
#warning("TODO")
}, label: {
Image("usericon")
.resizable()
.scaledToFit()
.clipShape(Circle())
.frame(width: 60, height: 60)
.padding(8)
})
VStack(alignment: .leading) {
HStack {
Text("たなたつ")
Text("@tanakasan2525・10m")
.foregroundColor(.gray)
}
Text("ここは本文が表示されるテキスト領域です。改行することもできます。")
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true) // workaround
HStack {
Button(action: {
#warning("TODO")
}, label: {
Image(systemName: "bubble.left")
})
Spacer()
Button(action: {
#warning("TODO")
}, label: {
Image(systemName: "arrow.2.squarepath")
})
Spacer()
Button(action: {
#warning("TODO")
}, label: {
Image(systemName: "heart")
})
Spacer()
Button(action: {
#warning("TODO")
}, label: {
Image(systemName: "square.and.arrow.up")
})
}
.foregroundColor(.gray)
.padding(8)
}
}
.padding(8)
}
}
struct ListCell_Previews: PreviewProvider {
static var previews: some View {
ListCell()
.previewLayout(.sizeThatFits)
}
}
レイアウトがある程度出来たら、bodyの可読性を上げるためにメソッドに切り出します。
この時にXcodeのリファクタリング機能を使うこともできます。
var body: some View {
HStack {
userIconView()
VStack(alignment: .leading) {
userNameView()
messageView()
bottomButtonView()
.padding(8)
}
}
.padding(8)
}
どこまでメソッド化するか悩ましいですが、bodyを見ればざっくりのレイアウトがわかるくらいまで切り出すのが良いと思います。
また、他の画面でも使いそうなViewのレイアウトはカスタムViewとして切り出していきましょう。
上記の場合、画像のボタンはImageButton
というカスタムクラスを作っても良さそうです。
画面のレイアウトのポイント
SwiftUIのエラーは読みにくいので細かく分ける
現状ではSwiftUIのエラーは非常に読みにくく、Xcodeの気持ちを読み取るエスパー力が必要な状態です。
例えばこの実装、どこが悪いでしょうか?
正解は
var body: some View {
VStack {
Text("エラーわかりにくい")
.frame(width: 300, height: 60)
TextField("名前", text: self.$viewModel.name)
}
}
TextFieldの第二引数で渡しているtextが期待している型はBinding<String>
です。エラーの実装は間違えてPublishedのnameに$
をつけてしまっています。
ですが、Xcodeが提示しているエラーはなぜかframe
のところになっています。
上記は短い実装なのでパッと見てわかるかもしれませんがbodyがかなり長い行数になっていた場合、見つけるのは非常に困難です。
そのためにもできるだけメソッドやカスタムViewに切り出してbody
部分を短いコードに留めるようにしておきたいです。
プレビューしやすいView
Viewを作っていく際に、SwiftUIのプレビュー機能を使うとリアルタイムで表示を確認できるだけでなく、自然と依存関係の少ない (正しい) Viewができていくように思いました。
プレビューするためにはダミーの値を用意してViewに渡す必要があるため、例えば、後述する EnvironmentObject
の良くない使い方をしていると「あれ、プレビューするためにいろいろデータを用意しないといけないぞ、面倒だ」ということに気づき、Viewの粒度やStateの設計などを早いサイクルで改めることができます。
なので、積極的にプレビューを利用しながら、プレビューしやすいViewになっているかを常に意識して開発をすると綺麗なViewを保っていけると思いました。
画面遷移の実装
次に画面遷移の実装をします。
画面遷移のよくあるパターンとしては3通りです。
- present (モーダル遷移)
- push (プッシュ遷移)
- 今の画面を新しい画面に置き換える
個人的に感じたSwiftUIでの実装の難易度は簡単順に 3 > 1 >>> 2 です。UIKitでは 1 > 2 > 3 だと思っています。
モーダル遷移
モーダル遷移は sheet
を使います。
@State private var isPresented = false
var body: some View {
Button("Present") {
self.isPresented = true
}
.sheet(isPresented: $isPresented) {
NextView()
}
}
シンプルで柔軟性があり、実装が容易です。
ただし、iOS 13から pageSheet
スタイルがデフォルトになったため、SwiftUIでもそのスタイルになります。
画面を全部覆うfullScreen
スタイルをSwiftUIで実現するにはUIKitのpresent
を使うか、モーダル遷移アニメーションを自作し、ZStack
などを使って似た表示を再現する必要があります。
https://stackoverflow.com/questions/56756318/swiftui-presentationbutton-with-modal-that-is-full-screen
https://stackoverflow.com/questions/56709479/how-to-modally-push-next-screen-to-be-full-in-swiftui
プッシュ遷移
プッシュ遷移は NavigationLink
とNavigationView
を使います。
var body: some View {
NavigationView {
NavigationLink("Push", destination: NextView())
.navigationBarTitle("Title")
}
}
リンクボタンをタップしてプッシュ遷移する場合は、このようにシンプルになりますが、例えば、通信成功後にプッシュ遷移する場合はこのようになります。
@State private var isPushed = false
var body: some View {
NavigationView {
VStack {
Button("Fetch data") {
// 通信の代わりに遅延させる
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// データの取得後Push
self.isPushed = true
}
}
// 見えないリンクを置いて遷移先を設定する
NavigationLink(destination: NextView(), isActive: $isPushed, label: EmptyView.init)
}
.navigationBarTitle("Title")
}
}
ちょっと違和感のある実装になってしまいますね。調べた限り、今のAPIではこのようになります。
そして、非同期で取得したデータを次の画面に渡す方法はどのようになるでしょうか。
何通りもやり方はありますが、素直に実装する場合はこのようになると思います。
@State private var isPushed = false
@State private var fetchedData: String?
var body: some View {
NavigationView {
VStack {
Button("Fetch data") {
// 通信の代わりに遅延させる
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// データの取得後Push
self.isPushed = true
// 取得したデータの設定(new dataというデータを取得できたとする)
self.fetchedData = "new data"
}
}
if fetchedData != nil { // ViewBuilder内ではif letは使えない
NavigationLink(destination: NextView(data: fetchedData!), isActive: $isPushed, label: EmptyView.init)
}
// if letの代わりにmapを使うこともできる(こっちの方が綺麗)
// fetchedData.map {
// NavigationLink(destination: NextView(data: $0), isActive: $isPushed, label: EmptyView.init)
// }
}
.navigationBarTitle("Title")
}
}
ちょっとずつらみが出てきましたね。画面遷移とデータ渡しがセットになっているケースを単純に実装するとプロパティの数がどんどん増えてしまいます。
そのため、ObservableObject
やカスタムView、structでデータをきれいにまとめるなどの工夫によって、Viewを清潔に保つように頑張る必要があります。
この辺りは次のState設計の部分でいくつかパターンを紹介します。
プッシュ遷移周りはUIKitよりも明らかに面倒です。ナビゲーションバーやエッジスワイプ周りでも厄介な部分があるので、後で軽く紹介します。
今の画面を新しい画面に置き換える
今の画面をまるっと新しい画面に置き換える実装はかなり簡単です。
@State private var isBlueView = false
var body: some View {
VStack {
if isBlueView {
BlueView()
} else {
RedView()
}
Button(isBlueView ? "Red" : "Blue") {
self.isBlueView.toggle()
}
}
}
bodyの中でif文を使うことができるので、そこで表示するViewを出し分けるだけで簡単に画面切替が可能です。
画面遷移実装のポイント
基本的にはUX優先で遷移方法を選択して良いと思います。ですが現在のSwiftUIのバグなどによっては問題を回避するワークアラウンドを考えるよりも遷移方法を見直すほうが良い場合も多いため、柔軟に仕様を変えられるようにしておきたいところです。
執筆時現在に起きているいくつかの問題/複雑なポイントを紹介します。
NavigationBarItemに置いたNavigationLinkでPushした後、Popするとクラッシュする
ナビゲーションバーにボタンを置いてPush遷移する動作はよくあるものですが、iOS 13.2 ではNavigationBarItem
に置いたNavigationLink
でPushした後、Popするとクラッシュしてしまいます。
回避方法としてはNavigationLink
ではなく、普通のButton
を置くようにし、前述したようなEmptyView
を持つNavigationLink
を用いて画面遷移するようにするとうまくいきます。
navigationBarHiddenは親の設定が優先される
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink("Push", destination: NextView())
.navigationBarHidden(true)
.navigationBarTitle("")
}
}
}
private struct NextView: View {
var body: some View {
Color.blue
.navigationBarHidden(false)
.navigationBarTitle("Next View")
}
}
このようなコードなら、プッシュ後にナビゲーションバーが表示されるようになりそうですが、実際はこのようになります。
動作を観察すると、ViewGraphの親の設定が優先されるようでした。
この現象を回避するためにはこのように親側の状態を更新する必要があります。
struct ContentView: View {
@State private var isPushed = false
var body: some View {
NavigationView {
NavigationLink(destination: NextView(), isActive: $isPushed, label: { Text("Push") })
.navigationBarHidden(!isPushed)
.navigationBarTitle("")
}
}
}
private struct NextView: View {
var body: some View {
Color.blue
.navigationBarTitle("Next View")
}
}
sheetをメソッドチェインor入れ子にすると最後(親)のsheetしか動かなくなる
複数のモーダルを表示したいときに以下のように書きたくなりますが、Modal 1が動かなくなります。
struct SheetChain: View {
@State private var isModal1Presented = false
@State private var isModal2Presented = false
var body: some View {
VStack {
Button("Modal 1") {
self.isModal1Presented = true
}
Button("Modal 2") {
self.isModal2Presented = true
}
}
.sheet(isPresented: $isModal1Presented, content: { NextView(color: .red) })
.sheet(isPresented: $isModal2Presented, content: { NextView(color: .blue) })
}
}
private struct NextView: View {
let color: Color
var body: some View {
color
}
}
sheet
がチェインしたり入れ子になっていると、最後(親)のsheet
しか動かなくなるようです。
これを回避するためにはsheet
がチェインしないように各ボタンにsheet
を付けるようにします。
struct SheetChain: View {
@State private var isModal1Presented = false
@State private var isModal2Presented = false
var body: some View {
VStack {
Button("Modal 1") {
self.isModal1Presented = true
}
.sheet(isPresented: $isModal1Presented, content: { NextView(color: .red) })
Button("Modal 2") {
self.isModal2Presented = true
}
.sheet(isPresented: $isModal2Presented, content: { NextView(color: .blue) })
}
}
}
シンプルな画面であればあまり問題になりませんが、複雑な画面でViewを細かくコンポーネント化し、入れ子にしていくと発生しやすいのでsheet
はできるだけ子のViewにつけるようにしたほうが良いです。
ちなみに入れ子で動かなくなるというのはこのような例です。
var body: some View {
VStack {
Button("Modal 1") {
self.isModal1Presented = true
}
Button("Modal 2") {
self.isModal2Presented = true
}
.sheet(isPresented: $isModal2Presented, content: { NextView(color: .blue) })
}.sheet(isPresented: $isModal1Presented, content: { NextView(color: .red) })
}
この場合は、Modal 2が動かなくなります。最後(親)のsheetしか動かなくなるためです。
State設計
SwiftUIの便利な機能の一つであるバインディングに必要なStateはアプリを作り進めていくとだんだんと増えていき、Viewの可読性が落ちていきがちです。
そこで、Stateをできるだけきれいに保つためにいくつかの便利なテクニックを紹介します。
関連性のあるStateはstructにまとめる
いろいろなサンプルコードで @State
や@Published
をプリミティブ型に対して利用していることが多いですが、structでも利用可能です。
前述したAPIからデータを取得した後にPush遷移する時のStateはこのように書くこともできます。
struct NavigationStateWithData<T> {
var isActive: Bool = false {
didSet {
if !isActive, data != nil {
data = nil
}
}
}
var data: T? {
didSet {
// 無限ループしないように代入前のチェックが必要
if (data == nil) == isActive {
isActive.toggle()
}
}
}
}
struct ContentView: View {
@State private var fetchedData = NavigationStateWithData<String>()
var body: some View {
NavigationView {
VStack {
Button("Fetch data") {
// 通信の代わりに遅延させる
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.fetchedData.data = "new data"
}
}
fetchedData.data.map {
NavigationLink(destination: NextView(data: $0), isActive: $fetchedData.isActive, label: EmptyView.init)
}
}
.navigationBarTitle("Title")
}
}
}
まとまるとStateの関連性がわかりやすくなってよいですね。
上記の場合はデータがあるときに自動でPush遷移をするカスタムNavigationLinkを作るのもありかもしれません。
ObeservableObject
@State
とstructの組み合わせでは表現しにくいViewの全体の状態を管理する時はObservableObject
を使います。
ObservableObject
はクラスにしか適合できないプロトコルのため、structでは値がコピーされてしまうようなViewを跨ぐ状態管理や、値更新時にViewを更新する必要がないプロパティなどを保持したりするのに便利です。
また、ObservableObject
で使用する@Published
は$
でアクセスすることで値を監視できるPublisher
として扱えるので、値の変化に伴って処理を挟むことができます。
struct SettingView: View {
@ObservedObject private var viewModel = SettingViewModel()
var body: some View {
Picker("テーマ", selection: $viewModel.theme) {
Text("ライトモード").tag(UIUserInterfaceStyle.light)
Text("端末の設定に従う").tag(UIUserInterfaceStyle.unspecified)
Text("ダークモード").tag(UIUserInterfaceStyle.dark)
}.pickerStyle(SegmentedPickerStyle())
}
}
class SettingViewModel: ObservableObject {
@Published var theme = UIUserInterfaceStyle.unspecified
private var cancellables: Set<AnyCancellable> = []
init() {
$theme.sink { [weak self] theme in
// 外観モードを切り替える
let keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }
keyWindow?.overrideUserInterfaceStyle = theme
}.store(in: &cancellables)
}
}
ロジックをObservableObject
に詰め込むと状態の更新タイミングが複雑になりがちです。できるだけUIに関係のある処理だけをここに記述するようにして、複雑なロジックは別の型に記述したほうが良いと思いました。
EnvironmentObject
EnvironmentObject
は子View全てにオブジェクトを伝搬させることができる機能で、これを使うとViewを細かくコンポーネント化していったときに毎回initでオブジェクトを渡す必要がなくなるためとても便利です。
ただ、なんでもEnvironmentObject
で渡してしまうと、データがどこで更新されたのか分かりにくくなったり、Viewの使いまわしにくくなったりします。
実際に使ってみて思ったGood/Badパターンはこちらです。
EnvrionmentObjectのGoodパターン
- アプリ全体で使う表示に関係する状態を管理する
- ログイン状態など
- 他の画面で使いまわさない子Viewにオブジェクトを渡す
- Fluxで実装した場合のStoreを子Viewに渡すときなど
EnvrionmentObjectのBadパターン
- 表示に全く関係しない状態を管理する
- それはシングルトンなオブジェクトで管理するほうが適しているかもしれません
- 子Viewに必要のないデータを含むオブジェクトを
EnvironmentObject
で渡す- そのデータの監視方法は
@State
や@ObservedObject
に置き換えられるかもしれません
- そのデータの監視方法は
- 他の画面でも使い回されるViewが特定のViewに依存した
EnvironmentObject
を参照している- そのデータは
init
で渡すようにしたほうが良いかもしれません
- そのデータは
State実装時のTips
Single Source of Truth
Appleは「Single Source of Truth」を推奨しています。これはデータソースは1つにしましょうという意味です。
SwiftUIの実装的には同じ意味を持つデータを別々の@State
や@Published
で保持しないようにするということになります。値を保持せず参照だけしたい時は@Binding
を使いましょう。
Bindingを使ったチュートリアル
https://developer.apple.com/tutorials/swiftui/handling-user-input
また、あるStateに変更があった時に別のStateを変更したいというケースでは、Combineフレームワークを使って値を監視すると、同一ソースを複数のStateで保持する (Single Source of Truthに反する) 必要があるときにも多少安全です。
// ユーザーの入力によってリストの表示をフィルターする処理
class SearchViewModel: ObservableObject {
@Published var keyword = ""
@Published private(set) var items: [Item] = []
@Published private(set) var filteredItems: [Item] = []
// ↑computed propertyにすることも可能ですが、結果をキャッシュしたいという意図です
// ...
private var cancellables: Set<AnyCancellable> = []
init() {
// 入力キーワードがnameに含まれているものをfilteredItemsにセットする
$keyword.combineLatest($items).map { keyword, items in
items.filter { item in
item.name.localizedCaseInsensitiveContains(keyword)
}
}
.assign(to: \.filteredItems, on: self)
.store(in: &cancellables)
}
}
@State
プロパティはprivate
に
@State
のプロパティをViewのbody
外から操作すると実行時エラーになります。
想定外の用途を防ぐために、@State
のプロパティにはprivate
を付けるようにしたほうが良いです。
こちらはSwiftUIのドキュメントにも明記されています。
you should declare your state properties as private, to prevent clients of your view from accessing it.
https://developer.apple.com/documentation/swiftui/state
また、@Published
のプロパティもprivate(set)
にできるケースは結構多いので、できるだけViewを更新可能な人物を減らすように意識していくと良いと思います。
よく使うサービス/ツールとの相性
fastlane
今のところ何も問題なく利用できています。App Store Connectへのアップロードも全く問題ありませんでした。
Firebase Crashlytics
SwiftUIのViewはView BuilderとOpaque Result TypeによってView構造が型になっているため、クラッシュログのスタックトレースがこのようになります。
※自作の型名などはマスク処理しています。
一見複雑ですが、よく見るとViewのどの部分から起きている問題なのかが以外と分かるため、特に大きな問題は感じていません。
Admob
Admobを利用する場合は、ネイティブ広告で自前のSwiftUI Viewを組み立てるか、またはAdmobが提供しているUIKitのインターフェースをラップして利用することになります。
ラップして利用する場合はこのような実装になります。
バナー
struct AdBanner: UIViewControllerRepresentable {
let adUnitId: String
private var adSize: GADAdSize {
UIDevice.current.userInterfaceIdiom == .pad ? kGADAdSizeFullBanner : kGADAdSizeBanner
}
func expectedFrame() -> some View {
let size = adSize.size
return frame(width: size.width, height: size.height, alignment: .center)
}
func makeUIViewController(context: Context) -> UIViewController {
let view = GADBannerView(adSize: adSize)
let viewController = UIViewController()
view.adUnitID = adUnitId
view.rootViewController = viewController
viewController.view.addSubview(view)
viewController.view.frame.size = adSize.size
view.load(GADRequest())
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
// body内 ---
AdBanner(adUnitId: "***").expectedFrame()
インタースティシャル
final class Interstitial: NSObject {
private let adUnitId: String
private var interstitial: GADInterstitial!
private var completion: (() -> Void)?
required init(adUnitId: String) {
self.adUnitId = adUnitId
super.init()
load()
}
private func load() {
interstitial = GADInterstitial(adUnitID: adUnitId)
interstitial.load(GADRequest())
interstitial.delegate = self
}
func show(completion: @escaping () -> Void) {
guard
canShow(),
interstitial.isReady,
// ViewControllerの取得処理を簡略化していますが、場合により適切なWindowを選択して取得するように変える必要があります
let root = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
else {
completion()
return
}
self.completion = completion
interstitial.present(fromRootViewController: root)
// 何度も表示されないように調整する場合はこの辺りに処理を書く
}
private func canShow() -> Bool {
// 条件を満たしたら表示する
return true
}
}
extension Interstitial: GADInterstitialDelegate {
func interstitialDidDismissScreen(_ ad: GADInterstitial) {
completion?()
load() // 再表示に備えて再読み込み
}
}
// View内 ---
private let interstitial = Interstital(adUnitId: "***")
var body: some View {
YourCustomView()
.onAppear {
self.interstital.show {
// 広告を閉じた後の処理
}
}
}
まとめ
現段階でSwiftUIを使う時の進め方や注意点、Tipsなどを紹介しました。
まだまだAPIが足りず、複雑な画面仕様を実現するには難しいケースもありますが、ある程度SwiftUIにアプリの仕様を寄せることができれば使っても良さそうです。
UIKitでレイアウトを作るよりも圧倒的に早く見た目が作れ、作った後のレイアウト変更も簡単なので、SwiftUIのバグさえ回避できればかなり楽にアプリが作れました。
SwiftUIの破壊的変更や不具合と戦いながらその進化を見ていくのはエンジニアとしては面白い経験かと思うので、SwiftUIアプリ作りに挑戦してみてはどうでしょうか。