- 投稿日:2022-02-14T23:21:59+09:00
【Swift】カスタムViewとViewControllerの作成
はじめに xibを使って、レイアウトはViewに、イベントはViewControllerで処理する場合のシンプルな実装方法を紹介します。 ゴール(完成図) xibを使って、ViewControllerにカスタムViewを表示させる。 ※青背景色の部分がUIViewController、白背景色の部分がUIView。 おおまかな開発の流れとしては、ViewControllerにViewを埋め込む感じです。 1.カスタムUIView、UIViewControllerのswiftファイルと、それに紐付くxibファイルの作成 UIViewはSwiftとViewファイル別々に選択して作成する必要があり、UIViewControllerはcocoaTouchClassでxibファイルを一気に作成することができます。 ※Viewのxibファイルを作成する際は、File→New File→でccoa touch classで選択してAlso createXIB fileが選択できないので注意。 xibファイルの作成は、File →NewFile →Viewを選択 Swiftファイルの作成はFile →NewFile→Swiftを選択 ViewControllerについてはまとめてxibファイルを作成 File→NewFile→CocoaTouchClassを選択でSubClass of:をUIViewControllerに指定し、Also Create XibFileにチェック→Next→Createを選択。 今回のファイル名はContentViewにします。 ↓こんな感じでそれぞれxibとswiftファイルを作成できていたらOKです。 2.NibViewクラスの作成 なぜNibViewクラスを作成するのかというと、後々、開発するときに1個NibViewクラスを作成しておけば、継承して使い回せて便利だからです。 NibView.swiftファイルを作成 NibView.swift import UIKit class NibView: UIView { var view: UIView! override init(frame: CGRect) { super.init(frame: frame) xibSetup() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) xibSetup() } } private extension NibView { func xibSetup() { backgroundColor = UIColor.clear view = loadNib() view.frame = bounds addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[childView]|", options: [], metrics: nil, views: ["childView": view as Any])) addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[childView]|", options: [], metrics: nil, views: ["childView": view as Any])) } } extension UIView { func loadNib() -> UIView? { let bundle = Bundle(for: type(of: self)) guard let nibName = type(of: self).description().components(separatedBy: ".").last else { print("NibViewでERROR") return UIView() } let nib = UINib(nibName: nibName, bundle: bundle) return nib.instantiate(withOwner: self, options: nil).first as? UIView } } 3.ViewにNibViewクラスを継承する 先ほど作ったNibViewクラスをContentViewクラスに継承します。 ContentView.swift import Foundation class ContentView: NibView { } 4.xibのViewControllerにobjectsのViewをセット セットしたViewのCustom ClassにContentViewと入力し、ContentViewを紐づける。 5.Xibとswiftファイルの紐付け。 XibのViewControllerのFile'sOwnerでxibのViewControllerを紐づけます。 XibのViewのFiles'OwnerでxibのViewも紐づけます。 私はここで間違ってCustomClassに設定して沼にはまったので、必ずFiles'Ownerの方で設定するよう注意が必要です。 6.ContentViewController.xibでContentViewをIBOutletで接続します。 7.ContentView.xibのカスタムビューにUIlabelを配置してレイアウト 基本的にUIの配置などレイアウトはViewのXibでします。 Autolayoutで適当に制約(省略)。 一部分のみのカスタムビューを作る場合は、右のユーティリティエリアのSimulated MetricsのSizeをFreedom、TopBarとBottomBarをNoneに設定すると、実装しやすいです。 8.Main.storyboardのViewControllerのCustom ClassをContentViewControllerに設定します。 表示させたいViewControllerにCustom classの紐付けをします。 今回は、デフォルトで設定されているViewは消しています。 注意 Files'Ownerの設定とCustomClassで設定がごちゃ混ぜになりがちなので注意が必要です。 参考
- 投稿日:2022-02-14T22:38:29+09:00
「The Ultimate Guide to WKWebView」をSwiftUIで実装する #07 - Monitoring page loads -
「The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、 7つ目になります。 15まであるので、大体半分まできました! ? 今回はKVOだとか、SwiftUIのProgressViewだとか、WKWebViewのメソッドだとか 使ったことがないものが多くて自分には重めだった・・・ 目次 シリーズ化していこうと思うので、全体の目次を置いておきます。 リンクが貼られていないタイトルは、記事作成中または未作成のものになります。 # タイトル 01 Making a web view fill the screen (フルスクリーンのWebViewを作る) 02 Loading remote content (リモートのコンテンツを読み込む) 03 Loading local content (ローカルのコンテンツを読み込む) 04 Loading HTML fragments (HTMLフラグメントの読み込み) 05 Controlling which sites can be visited (訪問可能なサイトの制御) 06 Opening a link in the external browser (外部ブラウザでリンクを開く) 07 Monitoring page loads (ページの読み込みを監視する) 08 Reading a web page’s title as it changes (Webページのタイトルの変化を読み取る) 09 Reading pages the user has visited (ユーザーが閲覧したページを読み取る) 10 Injecting JavaScript into a page (JavaScriptをページに注入する) 11 Reading and deleting cookies (cookieの読み取りと削除) 12 Providing a custom user agent (カスタムUser Agentを提供する) 13 Showing custom UI (カスタムUIを表示する) 14 Snapshot part of the page (ページの一部のスナップショットを撮る) 15 Detecting data (データの探索) 環境 【Xcode】13.1 【Swift】5.5 【macOS】Big Sur バージョン 11.4 実現したいこと 今回やることは ページの読み込み進捗率を取得して、ナビゲーションバーの下に表示すること です。 読み込みが完了すると、進捗率のバー(以降プログレスバー)は非表示になります。 それがメインなのですが、とても参考にさせてもらったWebViewの実装が とても勉強になったので、それを真似て実装した結果、 戻る、進む、リフレッシュ、ついでに閉じるボタンもつけました。 がっつり普通にWebViewの実装ですね。笑 実現方法 今回は本題以外の実装をしたこともあり、コードを全部載せると長いので 一部省略して書きます。 実際に動くコード全体を見たい場合は、一番最後にGithubのリンクを置いていますので、 そちらをご参考ください。 ではまずWebViewです。 WebView.swift struct WebView: UIViewRepresentable { private let webView = WKWebView() let url: URL @Binding var loadingProgress: Double @Binding var isLoading: Bool @Binding var title: String func makeUIView(context: Context) -> WKWebView { webView.navigationDelegate = context.coordinator let request = URLRequest(url: url) webView.load(request) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { // 省略 } func makeCoordinator() -> Coordinator { return Coordinator(self) } static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.observations.removeAll() } } extension WebView { class Coordinator: NSObject, WKNavigationDelegate { var parent: WebView var observations: [NSKeyValueObservation] = [] init(_ parent: WebView) { self.parent = parent let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in parent.loadingProgress = value.newValue ?? 0 }) let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in parent.isLoading = value.newValue ?? false }) observations = [ progressObservation, isLoadingObservation ] } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // 省略 } } } では詳細を見ていきましょう。 今回は、ページロードの監視つまり、読み込んでいるページがどのくらいまで読み込まれたのかを 監視してViewに描画します。 そのために監視したいのは、以下2つの値です。 estimatedProgress isLoading estimatedProgressは、公式ドキュメントによると 「現在のナビゲーションがどのくらい読み込まれたかの推測値」 An estimate of what fraction of the current navigation has been loaded. これがプログレスバーを実装するために最も重要な値になります。 isLoadingは、 「Viewが現在コンテンツを読み込んでいるかどうかの真偽値」 A Boolean value that indicates whether the view is currently loading content. これは、プログレスバーの表示非表示を切り替えるために使います。 WebViewの実装サンプルや、普段使っているアプリをじっくり見てみても、 コンテンツの読み込みが完了したら、プログレスバーを非表示にしていることがほとんどのようですので、 今回この値を使って切り替えします。 次に、これらの値を監視する方法です。 KVO(Key Value Observing)という値監視の方法を使用して実装します。 KVOについては、書き始めるとそれだけで1つの記事にできちゃうくらいなので、 ここでは説明省きます。 代わりに、KVOについて調べたスクラップを貼っておきます。 (ご参考になれば。KVOに触れたのは今回が初めてだったので、気になることあればコメントください!) ちなみにKVOの書き方は、Swift4以降で新しくなっています。 Hacking with Swiftの方は、古い書き方なので、今回は新しい書き方で実装しています。 本題に戻ります。 つまりはKVOを使用して、前述の値を監視し、 @Bindingプロパティに代入して、Viewに逐次反映します。 ちなみにKVOの話にはなりますが、 optionsの値を指定することで、更新後の値だけではなく、更新前の値も取得することができるそうです。 今回は最新の値だけが欲しいので、.newを指定しています。 それによって、例のプログレスバーが出来上がります。 次に今回初めて使ったメソッドの1つとして dismantleUIView()メソッドがあります。 これは、UIViewRepresentableプロトコルに準拠したメソッドで、 「除去されるのを見越して、UIKitのViewやcoordinatorをクリーンアップする」 ようです。 今まで使ってこなくても済んでいたのは、ドキュメントにも記載あるようにデフォルト実装がされているためですね。 Cleans up the presented UIKit view (and coordinator) in anticipation of their removal. Required. Default implementation provided. とこれだけではよくわからなかったのですが、 Discussionを見ると、こうあります。 「このメソッドは、カスタムビューに関連する追加のクリーンアップ作業を実行するために使用します。たとえば、オブザーバーを削除したり、SwiftUI インターフェイスの他の部分を更新するためにこのメソッドを使うことができます。」(DeepLより) Use this method to perform additional clean-up work related to your custom view. For example, you might use this method to remove observers or update other parts of your SwiftUI interface. なるほど・・・? 今回はobservationsから全てのオブザーバーを除去しています。 とここまで書いておいてなんですが、 この処理は本当に必要なのでしょうか・・・正直なところわかりませんでした・・・(この実装なくても動く。。) ちなみに参考にした記事の、dismantleUIView()の実装はこうなっていました。 static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { // WKWebView削除時に呼ばれます // インスタンスが削除されるタイミングで通知を無効化、削除しておきます coordinator.observations.forEach({ $0.invalidate() }) coordinator.observations.removeAll() } ただ、invalidate()はマルチスレッドで呼ばれると例外を引き起こし、 クラッシュの原因になるというのを 「NSKeyValueObservation.invalidate()は極力避ける」の記事で拝見しました。 またinvalaidate()のドキュメントコメントに 「NSKeyValueObservationがdeinitされる時に自動的に呼ばれる」 と記載があったのでそれを信じて、invalidate()メソッドは呼ばず、とりあえず配列からremoveAll()する実装だけ残しました。(それも残す意味あったかなあ。。) invalidate() will be called automatically when an NSKeyValueObservation is deinited 逆にinvalidate()メソッドがないとクラッシュするという記事もあったのですが、 今のところは特にそういった問題は起きてはないです。 どういう場合に問題が起きるのか、解明できたらまた記事更新します。。。! では次に、WebViewを表示するViewです。 以下三部構成になっています。 プログレスバー →今回のメイン Webページを表示するView 戻る、進む、リロード、閉じるの4つのボタンがあるView 上の3つを、VStack()で重ねているサンプルも多かったのですが、 そうすると、プログレスバーが非表示になった時に、非表示になった分だけWebページの表示位置が若干上に上がるという挙動をします。 それは少し違和感があったので、 今回はZStack()を使って、Webページ部分のViewの上に重ねる形でプログレスバーを配置しました。 これが正解なのかはわかりませんが、少なくとも普段よく使うアプリの挙動を見ていて プログレスバーが非表示になった時、上に上がるような挙動はしていなかったので 何かしら位置がずれない工夫はしていると思います。 WebBaseView.swift import SwiftUI struct WebBaseView: View { let url: URL @State private var loadingProgress: Double = 0.0 @State private var isLoading = false @State private var action: WebView.Action = .none @State private var canGoBack = false @State private var canGoForward = false @State private var title = "" @Binding var isShownWebView: Bool var body: some View { NavigationView { ZStack(alignment: .top) { VStack(spacing: 0) { WebView( url: url, loadingProgress: $loadingProgress, isLoading: $isLoading, action: $action, canGoBack: $canGoBack, canGoforward: $canGoForward, title: $title ) WebToolBarView( action: $action, canGoBack: $canGoBack, canGoForward: $canGoForward, isShownWebView: $isShownWebView ) } if isLoading { ProgressView(value: loadingProgress, total: 1.0) .progressViewStyle(LinearProgressViewStyle()) .accentColor(.green) } } .navigationBarTitle(title, displayMode: .inline) } } } 最後にこのWebBaseViewを使用する、初期表示画面のViewです。 ContentView.swift import SwiftUI struct ContentView: View { private let url = "https://www.apple.com/" @State private var isShownWebView = false var body: some View { Button(action: { if URL(string: url) != nil { isShownWebView.toggle() } }) { Text("WebView開く") } .fullScreenCover(isPresented: $isShownWebView) { WebBaseView(url: URL(string: url)!, isShownWebView: $isShownWebView) } } } 以上です! コード全体はこちらにあげています。 今回は、参考の一番上に貼った記事を、真似て実装する形になりました。 私はiOS15以上を想定して実装しているので、SwiftUIとして用意されているProgressViewを使用していますが、 元記事の方はiOS13をサポート対象に入っているため、まだProgressViewが使えなかったようです。 代わりにRectangleが使用されていました。 WKWebViewというか、KVOがわからなすぎて苦戦したし、 まだ消化不良なところもあるのですが、とても勉強になりました。 何か誤ったこと、KVO周りわかるよという人がもしいれば コメントいただけるととっても嬉しいです。 参考 UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる →めちゃくちゃ参考にしました。SwiftUIのProgressViewを使用している以外はほぼほぼおんなじ実装で、解説が大変わかりやすかったです。 estimatedProgressApple公式ドキュメント isLoadingApple公式ドキュメント dismantleUIView(_:coordinator:)Apple公式ドキュメント NSKeyValueObservation.invalidate()は極力避ける
- 投稿日:2022-02-14T12:09:44+09:00
SwiftUI + FlowController パターンの提案
コンセプト モチベーション アプリを SwiftUI ベースで作りたい! ……けど、危険な香りもするので UIKit に逃げられる余地を残しておきたい SwiftUI SwiftUI が発表されたのが WWDC2019 なので、今年 2022 年で登場から約 3 年。 ネットを徘徊すると実際のプロダクトへの導入例もだいぶ見かけるようになってきました。 実際に SwiftUI を触ってみると、宣言的 UI によるコード記述量の削減やコンポーネントとしての再利用性の向上、Canvas を利用したプレビューによる開発体験といったあたりは確かに素晴らしいのですが、実際にプロダクトに導入すると個人的には色々と不安な印象です。 特に気になるのが、 画面遷移周りの制限 プッシュ遷移での NavigationLink の要求 不可視の NavigationLink を仕込むワークアラウンド NavigationLink が強制的にスタイル変更してくる 複数の sheet modifier を設定する場合に、同階層に設定できない 一応 iOS 14.5 で解消 UIKit と比較しての機能不足 View の背景色を変えたいのに、 UIKit の appearance でのグローバル設定や Introspect for SwiftUI のようなライブラリ活用が必要 UIScrollView の各種 delegate メソッド等の UIKit で公開されているものが未公開なので、細かい調整が効かない といったあたりで、通常の UIKit 採用アプリでは実現できていたことが、 「SwiftUI を採用しているのでできません」というのは、ステークホルダによっては説明が厳しいところです。 FlowController パターン 話は変わりますが、 iOS アプリのアーキテクチャとして、 FlowController パターンというものが存在します。 オリジナルの提案はおそらく以下の記事。 日本語では、 @shiz さんの以下の紹介記事が詳しいです。 歴史的には、画面遷移に関する処理を VC から外部の Coordinator へと切り出した Coordinator パターンから派生したアーキテクチャで、 Coordinator 自体を UIViewController で実現しています。 厳密には多少異なる気もするのですが、ここでは FlowController パターン自体を 画面レイアウトに責任を持つ実装 とそれを保持する画面遷移に責任を持つ VC (FlowControllerと呼ぶ) で 1 画面を構成するアーキテクチャ という理解で話を進めます。 SwiftUI View + FlowController SwiftUI の利用 いざというときの UIKit での対応 を両立させるとなると、基本的な作りは UIKit で、レイアウト実装部分を SwiftUI View に任せるのが良さそうです。 SwiftUI View の UIKit での利用においては、 UIHostingController が提供されています。 UIHostingController は UIViewController を継承しているため、都合のいいことに UIHostingController を FlowController として見ると、 画面レイアウトを SwiftUI View で実装した FlowController パターンとして扱えてしまいます。 これを SwiftUI + FlowController パターンという 1 つのアプリアーキテクチャとして見るというのが本記事での提案となります。 大まかなポイントとしては、以下 3 点となります。 ベースは UIKit 画面レイアウト実装は、 SwiftUI View + UIHostingController で実現 画面遷移は FlowController パターンで実施 サンプルアプリ アーキテクチャの概要については上述の通りなのですが、抽象的な議論を避けてもう少し具体的な内容まで掘り下げるために、 SwiftUI + FlowController パターンを採用したサンプルアプリを作成しました。 以降、このサンプルアプリベースで SwiftUI + FlowController パターンについて解説していきます。 リポジトリ 画面構成 アプリ自体は、 Apple 提供の RSS フィードの内容を表示するアプリとなっており、大まかに以下のような画面構成を取ります。 ウォークスルー アプリ初回起動時に表示 フィード アプリ通常表示の左タブ RSS フィードの内容を表示するメイン機能 設定 アプリ通常表示の右タブ 各種設定とテスト機能 対象 SS その 1 SS その 2 SS その 3 ウォークスルー フィード 設定 アーキテクチャ SwitUI + FlowController パターンにおける View 周りの実装、アーキテクチャについて説明していきます。 基本構成 基本的には、 1 画面に対して以下 3 つの実装が必要となります。 FlowController (UIViewController) 画面遷移を担当 その他 UIKit ベースでの細かい設定を実施 SwiftUI View (View) レイアウトを担当 コンテナ VC で実装される親画面の場合は省略 ViewModel (ObservableObject) SwiftUI View に対するモデルデータの提供 SwiftUI View と FlowController の橋渡し モデルレイヤとの接続 これらの依存性を整理するために、以下の図のような MVVM 型のレイヤ構成を採用しています。 サンプルアプリベースでは MVVM を採用しているのですが、 SwiftUI + FlowController パターンのキモとしては、 SwiftUI ベースの画面レイアウト実装と UIKit ベースの 画面遷移実装を責務分割することにあるため、 MVVM の採用については絶対的な条件ではありません。 Presenter が FlowController の弱参照を保持する MVP や Flux 系のアーキテクチャで SwiftUI Viewのモデルデータを State として管理するといった対応も考えられますが、今回は MVVM をベースとして説明を進めます。 SwiftUI View の実装 SwiftUI View については、 SwiftUI が持つレイアウト実装の記述力の高さを生かすためにも、基本的にはそのまま標準的な SwiftUI 実装を行います。 注意点としては、 NavigationLink や sheet modifier といった画面遷移に関連する実装は行わない 後述する View のイベント処理に任せる ViewModel については、 @ObservedObject として保持 View 外部で初期化されるため、 @StateObject は不要 といったあたりです。 また詳しくは後述するのですが、 FlowController から ViewModel へのアクセスできないと不便なことが多いため、 SwiftUI View では get-only で ViewModel を公開してしまっています。 SwiftUI View での ViewModel の公開 public struct FeedListView: View { @ObservedObject private(set) var viewModel: FeedListViewModel } public final class FeedListFlowController: UIHostingController<FeedListView> { private var _viewModel: FeedListViewModel { rootView.viewModel } } FlowController の実装 FlowController の実装については、コンテナ VC として振る舞う親画面の場合と、通常の画面レイアウトを担う子画面の場合において以下のように実装が異なってきます。 対象画面 継承 SwiftUI View ViewModel 親画面 UIViewController 保持しない 直接保持 子画面 UIHostingController 保持する SwiftUI View経由でアクセス 親画面の実装例 public final class MainFlowController: UIViewController { private let _viewModel: MainViewModel private let _embeddedTabBarController = UITabBarController() public func start() { let feed = _feedProvider() let settings = _settingsProvider() feed.tabBarItem = UITabBarItem( title: "Feed", image: UIImage(systemName: "doc.text.image"), tag: 0 ) settings.tabBarItem = UITabBarItem( title: "Settings", image: UIImage(systemName: "wrench.and.screwdriver"), tag: 1 ) _embeddedTabBarController.setViewControllers([feed, settings], animated: false) _embeddedTabBarController.selectedIndex = 0 feed.start() settings.start() } } 子画面の実装例 public final class WalkthroughIntroFlowController: UIHostingController<WalkthroughIntroView> { private var _viewModel: WalkthroughIntroViewModel { rootView.viewModel } override public init(rootView: WalkthroughIntroView) { super.init(rootView: rootView) } public func start() { } FlowController には、画面遷移後に行う処理をまとめた start() メソッドを実装します。 また、 画面レイアウト実装が SwiftUI では機能不足で UIKit での実装が必要な場合においては、子画面の FlowController においても、 UIViewController を継承した実装として、別途 UIKit ベースの画面実装を行うこととなります。 ViewModel の実装 ViewModel 実装についても SwiftUI View から標準的な利用を考慮して、 ObservableObject への適合 @Published な プロパティ定義 といった標準的な実装を行います。 ViewModel 実装 public class WalkthroughSettingsViewModel: ObservableObject { @Published var feedLanguage: FeedLanguage = .english @Published var userName = "" var feedLanguageStream: AnyPublisher<FeedLanguage, Never> { $feedLanguage.eraseToAnyPublisher() } var userNameStream: AnyPublisher<String, Never> { $userName.eraseToAnyPublisher() } public init() { } } この場合、主にテストに備えた protocol 化が難しくなるという弊害はあるのですが、SwiftUI View でのレイアウト実装の DX のほうが優先されると考えています。 ViewModel に関しては、 SwiftUI View のためのモデルデータ提供に加えて、 SwiftUI View と FlowController 間のデータの橋渡しも必要となってくるため、以下の図のような流れでこれを実現しています。 ViewModel を protocol 化しない関係上、 SwiftUI View でのプレビュー時や FlowController のユニットテスト作成時のモック化等で問題が発生します。 前者については、 ViewModel の Subclassing である程度対応できるのですが、後者については @Published の projected value ($hoge) を参照している場合、 Property Wrapper については override もできないため、モック化が非常に難しくなります。 この対策として、多少冗長な実装にはなるのですが、 FlowController 側からアクセスする部分については AnyPublisher なインターフェイスを用意しています。 View のイベント処理 画面遷移処理を SwiftUI View ではなく FlowController 側で実施するため、ボタンタップ等のイベントを SwiftUI View から FlowController へと伝達する必要があります。 この処理については、 ViewModel を利用して PassthroughSubject or CurrentValueSubject を経由したリアクティブなストリームを構築することで対応しています。 図にすると以下の通り。 実装的には以下のようになります。 イベント処理実装例 public class ModalTransitionTestViewModel: ObservableObject { private let _navigationSubject = PassthroughSubject<Navigation, Never>() var navigationSignal: AnyPublisher<Navigation, Never> { _navigationSubject.eraseToAnyPublisher() } func navigate(_ navigation: Navigation) { _navigationSubject.send(navigation) } } public struct ModalTransitionTestView: View { @ObservedObject private(set) var viewModel: ModalTransitionTestViewModel public var body: some View { VStack { Button { viewModel.navigate(.alert) } label: { Text("Alert") } .padding() Button { viewModel.navigate(.fullScreen) } label: { Text("Modal .fullScreen") } .padding() Button { viewModel.navigate(.pageSheet) } label: { Text("Modal .pageSheet") } .padding() } } } public final class ModalTransitionTestFlowController: UIHostingController<ModalTransitionTestView> { private var _cancellable = Set<AnyCancellable>() private var _viewModel: ModalTransitionTestViewModel { rootView.viewModel } public func start() { _cancellable = Set() _viewModel.navigationSignal .receive(on: DispatchQueue.main) .sink { [weak self] navigation in guard let self = self else { return } switch navigation { case .alert: self._showNoticeAlertView() case .fullScreen: self._showFullScreenModalView() case .pageSheet: self._showPageSheetModalView() } } .store(in: &_cancellable) } } 画面遷移 画面遷移時の動きとしては、オリジナルの FlowController と同様に以下の手順を踏みます。 遷移先 FlowController のインスタンスを作成 UIKit ベースでの画面遷移処理を実行 pushViewController(_:animated:) や present(_:animated:completion:) UINavigationController での viewContollers 設定 遷移先 FlowController の start() メソッド呼び出し 画面遷移処理 public final class FeedFlowController: UIViewController, FeedListFlowControllerDelegate { private let _appSalesProvider: () -> AppSalesFlowControllerService private let _embeddedNavigationController = UINavigationController() public func feedListFlowController(_: FeedListFlowControllerService, didSelect appSales: AppSegment) { let sales = _appSalesProvider() _embeddedNavigationController.pushViewController(sales, animated: true) sales.start(segment: appSales) } } Coordinator パターンやオリジナル FlowController では、遷移先画面が末端でそこから先の遷移を持たない画面の場合は、レイアウト実装用の VC をそのまま利用しているのですが、 SwiftUI + FlowController パターンにおいては UIHostingController の都合上、必ず FlowController を経由する形としています。 また遷移前後の画面間でデータのやり取りが必要な場合については、 以下の図のように delegate パターンを利用してやりとりをする実装としています。 典型的には、UINavigationController を利用した親子画面の構成で子画面側から親画面に画面遷移を要求する場合等になります。 delegate パターンを利用した画面遷移 public final class SettingsFlowController: UIViewController, SettingsMenuFlowControllerDelegate { public func start() { let menu = _settingsMenuProvider() menu.delegate = self _embeddedNavigationController.setViewControllers([menu], animated: false) menu.start() } public func settingsMenuFlowController(_: SettingsMenuFlowControllerService, didSelect menuRow: SettingsMenu.Row) { switch menuRow { case .userNameSetting: _showUserNameSettingView() default: break } } private func _showUserNameSettingView() { let userName = _userNameSettingProvider() _embeddedNavigationController.pushViewController(userName, animated: true) userName.start() } } public final class SettingsMenuFlowController: UIHostingController<SettingsMenuView> { public func start() { _cancellable = Set() _viewModel.navigationSignal .receive(on: DispatchQueue.main) .sink { [weak self] navigation in guard let self = self else { return } switch navigation { case let .menu(row): self.delegate?.settingsMenuFlowController(self, didSelect: row) } } .store(in: &_cancellable) } } 補足等 SwiftUI での NavigationView 関連の modifier は効くのか SwiftUI + FlowController パターンにおいては、 NavigationView (UINavigationController) 配下の階層構造が以下のようになります。 親 FlowController (UIViewController) └── _embeddedNavigationController (UINavigationController) └── 子 FlowController (UIHostingController) └── View この場合、 navigationTitle(_:) や toolbar(content:) 等が効くのかどうかが気になります。 サンプルアプリで確認した結果は以下の通りで、概ねは問題なさそうです。 サンプルアプリでの確認コード public struct ToolbarTestView: View { @ObservedObject private(set) var viewModel: ToolbarTestViewModel public var body: some View { List { Section("Action") { Text(viewModel.actionText) } } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { viewModel.printNavigation1Text() } label: { Image(systemName: "1.circle") } } ToolbarItem(placement: .navigationBarTrailing) { Button { viewModel.printNavigation2Text() } label: { Image(systemName: "2.circle") } } ToolbarItemGroup(placement: .bottomBar) { Button("Left") { viewModel.printBottomLeftText() } Spacer() Button("Center") { viewModel.printBottomCenterText() } Spacer() Button("Right") { viewModel.printBottomRightText() } } } .navigationBarTitleDisplayMode(.inline) .navigationTitle("Toolbar test") } ただ、完全に動作するかと言うと微妙で、サンプルアプリにおいても Large Title 表示の画面からインライン表示の画面へとプッシュ遷移すると、表示がガタつく事象が発生してしまうため、その対策として FlowController 側で navigationItem.largeTitleDisplayMode = .never を設定していたりします。 NavigationLinkのデフォルト表示等 SwiftUI View でのレイアウトにおいて、基本的には NavigationView を利用しない実装となってくるため、通常のSwiftUIでは利用できていた NavigationLink を利用した場合の chevron アイコン付きのセル表示が難しくなっています。 これについては諦めるしかなく、サンプルアプリでも擬似的に同様の表示をするパーツを用意しています。 擬似的な NavigationLink パーツ /// A Button in NavigationView simulating NavigationLink /// /// - seealso: https://ideal-reality.com/programing/swiftui-list-navlink-design/ public struct NavigationLinkButton: View { private let _title: String? private let _action: () -> Void public var body: some View { Button(action: _action) { HStack { if let title = _title { Text(title) .foregroundColor(.primary) } Spacer() Image(systemName: "chevron.right") .font(Font.system(size: 14, weight: .semibold)) .foregroundColor(.secondary) .opacity(0.5) } } } public init(_ title: String?, action: @escaping () -> Void) { self._title = title self._action = action } } こういった副作用についても一部は発生してしまいます。 オリジナルの FlowController パターンとの差異 画面遷移 の項目でも少し触れていますが、オリジナルの FlowController パターンにおいては、画面遷移の末尾となる画面に対しては FlowController を持たずに直接レイアウト実装を扱う VC を参照していたりします。 一方、 SwiftUI + FlowController パターンにおいては基本的に全ての画面において FlowController を作成しています。 このため、本来は画面遷移後の処理を呼び出すための start() メソッドの責務が曖昧になっている部分があります。 オリジナルの FlowController パターンでは存在しない場面でも実装する必要があるため、空の start() メソッドを実装したり、 FlowController のイニシャライザ実行との役割分担等、腹落ちしていない部分があるというのは正直なところです。 最後に ミニマムな機能ではあるのですが、サンプルアプリレベルのものを SwiftUI + FlowController パターンで実装してみた限りは、プロダクション投入にもまあまあ耐えられそうというのが個人的な感想です。 特に、限定的な利用とはいえ SwiftUI によるレイアウト実装は生産性が高く、特にコンポーネントとしての再利用性やインタラクション実装の面では、 UIKit より明らかな優位性があると感じました。 ベースは UIKit なので、何かあった場合の UIKit によるパワープレイが可能だというのもプロダクション投入においては、一つのアピールポイントになるかと思います。 また、画面レイアウトと画面遷移の機能を責務分割するという方針については、サンプルアプリの実装を通じてなのですが、純粋な SwiftUI の場合においても有効そうだという感触を得ました。 現在の SwiftUI では、どうしても画面遷移部分の密結合が要求されてしまう (主に NavigationLink が原因) のですが、将来的に分離可能となった場合、 SwiftUI + FlowController パターンの FlowController 部分を SwiftUI 化するだけで純 SwiftUI アプリ化できるという、ステップアップも期待できるのでは?と思っています。 以上、 SwiftUI + FlowController パターンの解説+感想でした。
- 投稿日:2022-02-14T11:05:57+09:00
[Swift]なぜIdentifableつけるの?
この記事を書いた理由 現在Swift UIの勉強をしていますが、Listを作成する機会がとっても多い。 その中で頻出するIdentifableについて、「頻出だけどあまり理解できていない・・・」と思っていたので、1人でも私と同じような人がいなくなればと思って今回記事にまとめています。 まだ勉強中なので間違えていましたらご指摘いただけますと幸いです。 目次 1.Identifableとは? 2.Identifableが無い時のエラーについて 3.実装してみる 4.まとめ Identifableとは? そもそもIdentifableとは何でしょうか。 結論から言うと、構造体に準拠するコンテンツにidを振ることを可能にするプロトコルです。 まだちょっとわかりにくいですよね。 順を追って説明していきます。 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ プロトコルについての記事はこちら → 現在作成中 構造体についての記事はこちら → 現在作成中 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Identifableが無い時のエラーについて Listを作成する際に以下のエラーが出たことないでしょうか。 Initializer ‘init(_:rowContent:)’ requires that ‘xxxx’ conform to ‘Identifiable’ これは、「イニシャライザー init(_:rowContent:) は、xxxx が identifiable に準拠している必要があります。」という内容になり、以下のような場合に発生します。 コードの説明(折りたたみ) 下記は let animals = ["犬","猫","猿"] でanimalsという配列を作り、中に犬、猫、猿を格納しています。 次に List(animals){ animal in Text(animal) } で 配列animalsからListのような繰り返し処理でコンテンツを作ろうとしています。 struct ContentView: View { let animals = ["犬","猫","猿"] var body: some View { List(animals){ animal in Text(animal) } } } ではなぜエラーが出てしまうのでしょうか? 理由はListの性質にあります。Listのような繰り返し処理でコンテンツを作った場合、そのコンテンツのデータを更新する必要が出た際にどのコンテンツを更新するのか、SwiftUI側がわからないといけません。 そのため繰り返し処理で作成されるコンテンツには、これが更新されるよー!と指定できるように予めidを振ってあげる必要があるのです。 つまり上記のエラーが出たということは、そのidを振ることができないということを伝えているのです。 実装してみる では実際に、エラーが出ない形にすると以下のようになります。 struct ContentView: View { let animals = [Animal(id: 1, name: "犬"), Animal(id: 2, name: "猫"), Animal(id: 3, name: "猿")] var body: some View { List(animals){ animal in Text(animal.name) } } } struct Animal: Identifiable { let id: Int var name: String } 先ほどListで表示しようとしていたnameの配列は、Animalという構造体で定義するようにして、AnimalはIdentifiableに準拠し、idというプロパティを持つようにしました。 こうすることでSwiftUI側はプロパティidを使ってどのコンテンツを更新すればいいのかを判断できるようになります。 まとめ 簡単に言うと、英訳どおりIdentifable自体には識別可能にする効果があります。 Identifiableプロトコルに準拠した構造体は、idを持っている=idを振ることができる ということになります。 つまり、idを振らせることでSwiftUIへ識別可能にするのです。
- 投稿日:2022-02-14T11:05:57+09:00
[Swift]なぜIdentifiableつけるの?
この記事を書いた理由 現在Swift UIの勉強をしていますが、Listを作成する機会がとっても多い。 その中で頻出するIdentifiableについて、「頻出だけどあまり理解できていない・・・」と思っていたので、1人でも私と同じような人がいなくなればと思って今回記事にまとめています。 まだ勉強中なので間違えていましたらご指摘いただけますと幸いです。 目次 1.Identifiableとは? 2.Identifiableが無い時のエラーについて 3.実装してみる 4.まとめ Identifiableとは? そもそもIdentifiableとは何でしょうか。 結論から言うと、構造体に準拠するコンテンツにidを振ることを可能にするプロトコルです。 まだちょっとわかりにくいですよね。 順を追って説明していきます。 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ プロトコルについての記事はこちら → 現在作成中 構造体についての記事はこちら → 現在作成中 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Identifiableが無い時のエラーについて Listを作成する際に以下のエラーが出たことないでしょうか。 Initializer ‘init(_:rowContent:)’ requires that ‘xxxx’ conform to ‘Identifiable’ これは、「イニシャライザー init(_:rowContent:) は、xxxx が identifiable に準拠している必要があります。」という内容になり、以下のような場合に発生します。 コードの説明(折りたたみ) 下記は let animals = ["犬","猫","猿"] でanimalsという配列を作り、中に犬、猫、猿を格納しています。 次に List(animals){ animal in Text(animal) } で 配列animalsからListのような繰り返し処理でコンテンツを作ろうとしています。 struct ContentView: View { let animals = ["犬","猫","猿"] var body: some View { List(animals){ animal in Text(animal) } } } ではなぜエラーが出てしまうのでしょうか? 理由はListの性質にあります。Listのような繰り返し処理でコンテンツを作った場合、そのコンテンツのデータを更新する必要が出た際にどのコンテンツを更新するのか、SwiftUI側がわからないといけません。 そのため繰り返し処理で作成されるコンテンツには、これが更新されるよー!と指定できるように予めidを振ってあげる必要があるのです。 つまり上記のエラーが出たということは、そのidを振ることができないということを伝えているのです。 実装してみる では実際に、エラーが出ない形にすると以下のようになります。 struct ContentView: View { let animals = [Animal(id: 1, name: "犬"), Animal(id: 2, name: "猫"), Animal(id: 3, name: "猿")] var body: some View { List(animals){ animal in Text(animal.name) } } } struct Animal: Identifiable { let id: Int var name: String } 先ほどListで表示しようとしていたnameの配列は、Animalという構造体で定義するようにして、AnimalはIdentifiableに準拠し、idというプロパティを持つようにしました。 こうすることでSwiftUI側はプロパティidを使ってどのコンテンツを更新すればいいのかを判断できるようになります。 まとめ 簡単に言うと、英訳どおりIdentifiable自体には識別可能にする効果があります。 Identifiableプロトコルに準拠した構造体は、idを持っている=idを振ることができる ということになります。 つまり、idを振らせることでSwiftUIへ識別可能にするのです。
- 投稿日:2022-02-14T01:37:48+09:00
UITextField以外からキーボードを表示する
UITextField以外からキーボードを表示する方法を紹介します。 以下の画像ではUIViewをカスタマイズしてUITextFieldっぽく見せています。 必須実装 UIViewに準拠した独自のViewを作成し、以下の実装を行う必要があります。 canBecomeFirstResponder canResignFirstResponder becomeFirstResponder() ViewをタップしたタイミングでbecomeFirstResponder()を呼び出すことで、キーボードを表示することができます。 Tips UIControlを継承することでtapイベントを取得する UIControlではtouchUpInsideなどのイベントを取得することができるので、init時にaddTargetを呼び出しtap時の処理を登録します。 UITapGestureRecognizerなどの追加は不要です。 inputViewの設定で独自のViewをkeyboard部分に設定する 下のコードでは、UIDatePickerをinputViewに設定することで、キーボード部分で時間を設定できるようにしています。 inputAccessoryViewでkeyboardの上部のViewを設定する 下のコードでは、UIToolbarをinputAccessoryViewに設定し、ボタン押下でキーボードを閉じています。 autoresizingMaskに.flexibleHeightを指定しないと正しく表示できなかったので注意が必要です。 return押下でキーボードを閉じる UIKeyInputに準拠することでキーボードの入力イベントを取得することができます。 returnを押した時には insertText 関数に textが "\n" として渡ってくるのでその判定の上で resignFirstResponder() を呼び出します。 コード全体 import UIKit final class CustomInputView: UIControl { override var canBecomeFirstResponder: Bool { !isFirstResponder } override var canResignFirstResponder: Bool { isFirstResponder } override var inputAccessoryView: UIView? { toolbar } override var inputView: UIView? { datePicker } private lazy var toolbar: UIToolbar = { let toolbar = UIToolbar() toolbar.autoresizingMask = .flexibleHeight toolbar.setItems([ UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)), UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped)) ], animated: false) return toolbar }() let datePicker: UIDatePicker = { let picker = UIDatePicker() picker.datePickerMode = .countDownTimer picker.preferredDatePickerStyle = .wheels return picker }() override init(frame: CGRect) { super.init(frame: frame) configure() } required init?(coder: NSCoder) { super.init(coder: coder) configure() } private func configure() { addTarget(self, action: #selector(itemTapped), for: .touchUpInside) } @objc private func itemTapped() { if isFirstResponder { resignFirstResponder() } else { becomeFirstResponder() } } @objc private func cancelButtonTapped() { resignFirstResponder() } @objc private func doneButtonTapped() { resignFirstResponder() } } extension CustomInputView: UIKeyInput { var hasText: Bool { false } func insertText(_ text: String) { if text == "\n" { resignFirstResponder() } } func deleteBackward() {} }