見出し画像

iOSアプリを作ろう・スプリットビュー入門

今回は Xcode の Master-Detail App テンプレートを使ってスプリットビューについて解説します。
【🔶2020年5月12日Xcode 11.4.1テンプレートの変更点を加筆しました】

毎月札幌でiOSアプリ作りをアシストするセミナーをやっています。1時間にわたるセミナーの全内容を物理的に参加できない方のためにnote上で公開します。ぜひアプリ作りにチャレンジしてください。

おしらせ
iOSアプリを作ろうシリーズのセミナーは今後も月一回のペースで続けます。
詳細は connpass.com の 札幌Swift でご確認ください。そして機会があればぜひ参加してください。
アプリ作りプログラミング教育に関連する話題は 札幌Swift のfacebookページ で発信しています。

快技庵(かいぎあん)の高橋です。
1980年代後半からのMacのアプリを作ってきた現役開発者で現在は電子書籍などのiOSアプリを作っています。
note や Twitter のアイコンは、最初に作った電子書籍アプリ豊平文庫のアイコンにしています。

Master-Detail App テンプレートはシンプルですがコード量は少し多くなっています。今回はコードを細かく解説します。

Master-Detail App テンプレートについてこのnoteではスプリットビューについて解説し、テーブルビューの表示のしくみや編集については「テーブルビュー入門」で解説します。

まずテンプレートを保存してください。

Master-Detail App テンプレートを保存

画像1

いつものように「Welcome to Xcode」画面の「Create a Xcode Project」ボタンをクリックしてください。

「Welcome to Xcode」画面が出ていない場合は Xcode の Window メニューから表示できます。

iOS のテンプレートから「Master-Detail App」を選び「Next」ボタンをクリックしてください。次に Master-DetailTest などの名前をつけて保存してください。
名前がプロジェクトの名前になります。

画像2

シミュレータで実行

シミュレータで実行してみましょう。
まずシミュレータの機種に iPad Air を選びプロジェクト画面左上の Run ボタンをクリックしてください。(再生ボタンと同じアイコンのボタンです)

縦に分割された Master と Detail 画面が見えるはずです。
Master にある + ボタンをタップすると1行追加されます。
行にはタップした日時を表示します。

画像3

行は選択が可能です。タップして選択すると Detail 画面にタップした行に表示されている日時を表示します。
「Master-Detail App」テンプレートはこれだけのシンプルなアプリです。

iPad Air の実行をStopボタンで終了してください。

次にシミュレータの機種を iPhone XR を選んでから実行してください。

画像4

今度は Master だけを表示します。
+ボタンで行を追加し、その行を選ぶと Detail 画面が右からスライドしてきます。
一つのアプリで iPad の大画面も生かした機能を実現できるところが「Master-Detail App」テンプレートの特徴です。

シミュレータでデバイスの回転を試してください。

画像5

横長にすると二つの画面を同時に表示(Detailが全面で Master が上に重なっている)します。
この機能は Split View の機能です。

Split Viewの機能をHIGで確認

画像6

ヒューマンインターフェースガイドライン(HIG)のスプリットビューのURLは https://developer.apple.com/design/human-interface-guidelines/ios/views/split-views/ です。

要約すると
スプリットビューは二つの隣り合ったペインの表示を管理する。
それぞれプライマリペイン(primary pane)とセカンダリペイン(secondary pane)と呼ばれる
プライマリペインに永続的なコンテンツを表示しセカンダリペインにはその関連情報を表示する
二つのペインが同時に表示されている場合はプライマリペインのアクティブな要素は常に強調する
セカンダリペインを表示中にプライマリペインを表示する方法は複数用意する
などが書かれています。

スプリットビューの特徴

画像7

画面の横幅が十分であれば、縦に分割された二つのビューを同時に表示します。
縦分割なのは文字の表示が横書きだからですね。
二つのビュー(ペイン)のうち左側がプライマリペイン(ビュー)でセカンダリペインに表示する内容を決めます。
iPad と iPhone では挙動が変わりますが、コード内に分岐は基本的には不要で(「Master-Detail App」テンプレートのコードの場合)すべて自動で処理されます。
iPad で二つの画面を同時に使う場合などアプリが使える幅が狭い場合も適切に動作します。

UISplitViewController クラス

画像8

スプリットビューのしくみを実現しているのは UIKit フレームワークの「UISplitViewController」クラスです。
クラス定義は次のようになっています。

class UISplitViewController : UIViewController

「UISplitViewController」クラスはビューコントローラーを管理するビューコントローラーで「コンテナビューコントローラー」と種類わけする場合もあります。(UINavigationController もコンテナビューコントローラーです)

スプリットビューをカスタマイズするには UISplitViewControllerDelegate プロトコルを採用したオブジェクトをデリゲートとして対応します。

preferredDisplayMode プロパティ

画像9

スプリットビューの挙動を指定するプロパティに preferredDisplayMode があります。
実行時の画面幅により指定したモードが利用できない場合もあります。

var preferredDisplayMode: UISplitViewController.DisplayMode { get set }

UISplitViewController.DisplayMode 型は .automatic、 .allVisible、 .primaryHidden、.primaryOverlay のどれかです(iOS 12の場合)。
デフォルトは automatic で実行時の画面幅に最適などれかのモードになります。
allVisible は両方のペインを並べて表示します。
primaryHidden はプライマリを隠します(セカンダリペインを優先して表示)。
primaryOverlay は画面幅いっぱいにセカンダリを表示しその上にプライマリを重ねて表示します。

Master-Detail App テンプレートの構造

画像10

Master-Detail App テンプレートはスプリットビューを最初に表示します。

主なファイル構成は
AppDelegate.swift
🔶Xcode 11.4.1では SceneDelegate.swift が追加】
MasterViewController.swift
DetailViewController.swift
Main.storyboard
の3つのクラスとひとつのストーリーボードファイルです。

ストーリーボードの設定内容

最初にストーリーボードのシーンの構成を確認しましょう。
Main.storyboard ファイルを選びインターフェースビルダー画面を開いてください。
(ここでは View as: iPhone 8 に設定した画面の一部です)

画像11

イニシャルビューコントローラー(最初に表示する画面)は Split View Controller Scene です。
そこから二つの矢印が出ています。Master Scene と Detail Scene です。それぞれのシーンは Navigation Controller を持っています。

ドキュメントアウトラインで確認するとSplit View Controller Scene と二つのビューコントローラーは Relationship Segue で接続されていることがわかります。

画像12

接続はプライマリペインを Master シーン、セカンダリペインを Navigation Controller シーンとなっています。
Master-Detail App テンプレートでは Master シーンが二つありますが、プライマリとして接続しているのはマスター用のナビゲーションビューコントローラーで、さらに実際のマスターであるテーブルビューコントローラーのシーンにroot view controller として接続しています。

Main.storyboard ファイルにはもうひとつ segue の接続がありますが、マスターの説明時にまとめます。

AppDelegate.swift ファイルの内容

画像13

Single View App のテンプレートとは違いMaster-Detail App テンプレートの AppDelegate.swift の中身は必ず確認してください。
クラス定義で UIApplicationDelegateプロトコルだけなく  UISplitViewControllerDelegate プロトコルにも準拠しています。
【🔶Xcode 11.4.1ではSceneDelegate.swiftがUISplitViewControllerDelegateに準拠します】

class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate {

application(_:didFinishLaunchingWithOptions:)

最初にある関数は UIApplicationDelegate のメソッド application(_:didFinishLaunchingWithOptions:) です。
アプリの起動時のもろもろの処理がほぼ完了したタイミングで呼ばれるので、アプリ独自の初期化を行うことが多いメソッドです。
Master-Detail App テンプレートでも初期化をおこなっています。
【🔶Xcode 11.4.1ではSceneDelegate.swiftのscene(_:willConnectTo:options:)で設定しています】

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

二つの引数はここでは利用していません。
【🔶ここから下はSceneDelegate.swiftのscene(_:willConnectTo:options:)にあるコードです】
最初のコード let splitViewController = から順に説明します。

let splitViewController = window!.rootViewController as! UISplitViewController

window! はこのアプリケーションデリゲートのインスタンス var window: UIWindow? で初期値は値なしですが、ストーリーボードファイルを読み込み済みで、このメソッドでは利用可能になっています。
window プロパティはオプショナルなのでアンラップし rootViewController を得ています。

rootViewController は UIWindow クラスのプロパティで表示中のビューコントローラーインスタンスを得ることができます。
このテンプレートの場合、ストリーボードファイルは UISplitViewController クラスのインスタンスをイニシャルビューコントローラーとしているので、確実に UISplitViewController クラスのインスタンスです。
そのため as! で型変換(ダウンキャスト)しています。

ディテールのナビゲーションコントローラーを得る

【🔶Xcode 11.4.1のテンプレートではこのコードが変更になっています】
【🔶guard let navigationController = splitViewController.viewControllers.last as? UINavigationController else { return }】
【🔶splitViewController.viewControllers.lastを使い.viewControllers配列のインデックス指定は使っていません】

次の let navigationController = で始まる行は長いですね。
スプリットビューの二つ目のペイン、Master-Detail App テンプレートではディテールを表示する UINavigationController クラスのインスタンスを得るためのコードです。

Main.storyboard ファイルではセカンダリペインの接続先はナビゲーションコントローラーでした。

let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController

splitViewController.viewControllers[splitViewController.viewControllers.count-1]の部分は、前の行で得た splitViewController を使っています。
viewControllers は UISplitViewController クラスのプロパティで、管理しているビューコントローラーの配列です。
配列の要素数は2で、インデックス 0 がプライマリ(ここではマスター)ビューコントローラー、インデックス 1 がセカンダリ(ここではディテール)ビューコントローラーです。

[splitViewController.viewControllers.count-1] は配列の添字です。
splitViewController.viewControllers.count は配列 viewControllers の要素数で、その値は2です。
つまり [2 - 1] で [1] すなわちインデックス1の要素を使っています。
整理すると splitViewController.viewControllers[1] と同じで、これはセカンダリ(ここではディテール)ビューコントローラーです。

最後の as! UINavigationController でナビゲーションコントローラークラスに型変換しています。

ディスプレイモードボタンの設定

次の行も長いです。アプリ起動時に開くディテールビューコントローラーにディスプレイモードボタンを設定しています。
同じく一つずつ確認しましょう。

navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem

左辺の navigationController.topViewController!.navigationItem.leftBarButtonItem は navigationController の topViewController プロパティを使っています。
スプリットビューコントローラーと同じく、別のビューコントローラーを管理するコンテナビューコントローラーである UINavigationController のインスタンスは現在表示しているビューコントローラーを topViewController プロパティで得ることができます。
Main.storyboard ファイル で確認したようにディテーの navigationController はディテールビューコントローラーを表示します。このため topViewController はディテールビューコントローラーのインスタンスです。

ここでは UIViewController クラスのプロパティ navigationItem を使うので型変換は不要です。
navigationItem は UINavigationItem 型で leftBarButtonItem プロパティを持っています。
このプロパティはビューコントローラーをナビゲーションコントローラーで表示した時、ナビゲーションバーの左に表示するボタンを指定するためのものです。

次に右辺の splitViewController.displayModeButtonItem は UISplitViewController クラスの displayModeButtonItem プロパティです。
スプリットビューコントローラー専用のボタンアイテムでセカンダリビューを広げる機能があります。
この行はナビゲーションバーの左にディスプレイモードボタンを表示します。

下図はモードを変更して displayModeButtonItem を表示した状態です。

画像14

ディスプレイモードボタンは必要な場合に自動で表示されます。(Master-Detail App テンプレートのデフォルト状態では表示されないようです)

UISplitViewControllerDelegate に対応

次の行

splitViewController.delegate = self

スプリットビューコントローラーのデリゲートに AppDelegate クラスのインスタンスを指定しています。
【🔶Xcode 11.4.1のテンプレートではSceneDelegateクラスのインスタンスを指定しています】
【🔶以下AppDelegate をSceneDelegateに読み替えてください 】
AppDelegate クラスは UISplitViewControllerDelegate プロトコルに準拠しているので指定できるのです。
AppDelegate クラスでは UISplitViewControllerDelegate プロトコルのsplitViewController(_:collapseSecondary:onto:) メソッドのみ実装しています。

applicationWillResignActive(_:)
applicationDidEnterBackground(_:)
applicationWillEnterForeground(_:)
applicationDidBecomeActive(_:)
applicationWillTerminate(_:)
の各メソッドは UIApplicationDelegate のメソッドです。すべて実際のコードは空(コメントのみ)なので説明は省略します。

splitViewController(_:collapseSecondary:onto:) メソッド

UISplitViewControllerDelegate のメソッドです。
【🔶Xcode 11.4.1のテンプレートではSceneDelegate.swiftに実装しています】
実装するかしないかは任意の optional 設定のメソッドです。

このメソッドをコメントアウトし実装しない状態にすると iPhone のポートレートモード(縦長表示)で正しく表示しない現象が出ます。

スプリットビューを「collapse」すなわち折りたたむ場合に true を返します。

このデリゲートメソッドが true を返すとセカンダリビューコントローラーを削除しプライマリペインだけを表示します。

このメソッド全体はこのようになっています:

func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
   guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false }
   guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false }
   if topAsDetailController.detailItem == nil {
      // Return true to indicate that we have handled the collapse by doing nothing; the secondary controller will be discarded.
      return true
   }
   return false
}

順に解説します。
最初に二つの guard文があります。

guard文は if文に似ていて guard の直後に条件を書きます。しかしif文とは違い  else 節しか持ちません。

guard文は、guard文以降(通常は関数の本体)を実行するにはguard文の条件を満たさなければならないことを要求するための文です。

ここでは let キーワードがあるのでオプショナルバインディングです。
正常にバインディングできた場合は次の文に進め、バインディングした定数もコード内で使うことができます。

最初の guard 文で、オプショナルバインディングで引数 secondaryViewController を UINavigationController に型変換し定数 secondaryAsNavController に代入しています。
変換できない場合は処理の続行ができないので else のあとreturn 文でメソッドから抜けます。(ストーリーボードを変更していなければ正常に進みます)

二つ目の guard 文では、正常に得られた secondaryAsNavController を使って、ナビゲーションコントローラーに表示中のディテールビューコントローラーを得ています。
起動時に読み込むシーンでセカンダリペインはナビゲーションコントローラー内に表示するディテールビューコントローラーです。
secondaryAsNavController.topViewController はディテールビューコントローラーを参照していますが型は通常の UIViewController なので as? DetailViewController で DetailViewController 型に変換しします。成功した場合オプショナルバインディングで topAsDetailController に代入し、失敗した場合は else のあとreturn 文でメソッドから抜けます。(ここもストーリーボードを変更していなければ必ず正常に進みます)

次の if topAsDetailController.detailItem == nil { はディテールビューコントローラーの detailItem が値なしかを確認しています。
DetailViewController クラスは後ほど解説しますが detailItem プロパティはアプリ起動時にこのデリゲートが呼ばれる時点ではまだ何のデータも代入されていないので値なしのままです。このため return true へ進みます。

最後の return false は detailItem にデータがある場合です。
例えば iPhone X などを横の状態でディテールビューを表示しデバイスを回転し縦に戻す場合が相当します。
このデリゲートメソッドで false を返すとディテールを表示した状態になります。

ここから先は

10,719字 / 10画像
この記事のみ ¥ 500

今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。