Firebaseを利用したプッシュ通知のカスタムデータの解釈(その1)
沖縄は梅雨に入ってしまったそうですが、関東では過ごしやすい日が続いて欲しいと思う今日この頃です。ランニング🏃♀️もはじめました(健康になるぞ...!!)
さて今回は、Firebaseを利用したプッシュ通知の実装について(特に、任意のデータを受け取りたい場合にどのような実装が必要かについて)書いてみました。
プッシュ通知を受け取るところまでは、ドキュメント通りに実装すれば割と簡単にできます。
しかし、任意のデータを含めてプッシュ通知を送った場合、アプリ側でどのようにデータを受け取って解釈するかという点については、あまりまとまった情報がありませんでした。
今回やりたかったことは、受信したプッシュ通知をタップした時に任意の画面へ遷移させること。
そのためには、送信時に付与した遷移先を示す任意のデータを受信時に解釈し、その後の処理を分ける必要があります。
実現するにあたって、以下が分からなくて調べたり考えたりしました。
- どういう形式でデータが送られてくるの?
- 通知の中身はどこで受け取れるの?
- そのデータをどういう構造体で保持すべき?(これは次の記事)
それぞれについて、備忘録としてまとめておきます📝
どういう形式でデータが送られてくるの?
ドキュメント通知メッセージ ペイロードの解釈では以下の形式で送られてくると書かれていました。
"customKey" : "customValue"
というのが任意の値ですが、任意の値も、標準で送られてくる値と同じ階層に配置されるようです。
{ "aps" : { "alert" : { "body" : "great match!", "title" : "Portugal vs. Denmark", }, "badge" : 1, }, "customKey" : "customValue" }
例えば"test" : "aaa"
というカスタムデータを追加してfirebase consoleから送信し、実際に受け取ったデータをprintすると以下のように出力されました。
[AnyHashable("aps"): { alert = { body = "{bodyのテキスト}"; title = "{titleのテキスト}"; }; }, AnyHashable("gcm.message_id"): {message_id}, AnyHashable("test"): aaa, ...(省略)]
プッシュ通知を受信した際に、受け取ったデータ(dictionary)にtest
というキーを指定することで値aaa
を取得できます。
通知の中身はどこで受け取れるの?
プッシュ通知受信時はUIApplicationDelegate
やUNUserNotificationCenterDelegate
の関数が呼ばれ、その中で、一緒に送られてくる任意のデータを参照することができます。
(※送られてくるデータのことを、ここからはペイロードと記述します)
呼び出される関数は、アプリの起動状態によって異なります。
具体的には、プッシュ通知を受け取る可能性がある状況は以下の3パターンで、
それぞれ呼び出される関数が異なります。
FYI: https://firebase.google.com/docs/cloud-messaging/ios/receive?hl=ja
- A .バックグラウンドで起動している時
- B .未起動時(プロセスが起動していない時)
- C .フォアグラウンドで起動している時
それぞれ詳しくみていきます。
A. バックグラウンドで起動している時
UIApplicationDelegate
のapplication(_:didReceiveRemoteNotification:fetchCompletionHandler:)
が呼び出されます。
この場合、userInfoにペイロードが含まれます。
class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { print("userInfo: \(userInfo)") //userInfoにペイロードが含まれる completionHandler(UIBackgroundFetchResult.newData) } }
B. 未起動時(プロセスが起動していない時)
アプリが未起動の状態でプッシュ通知をタップして開くと、通常の起動時と同じでUIApplicationDelegate
のapplication(_:didFinishLaunchingWithOptions:)
が呼び出されます。
この場合、launchOptionsに.remoteNotification
のキー名でペイロードが含まれていました。
class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if let data = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { print("data: \(data)") //.remoteNotification を指定するとペイロードが取得可能 } } }
C. フォアグラウンドで起動している時
アプリを開いている場合は、UNUserNotificationCenterDelegate
のuserNotificationCenter(_:willPresent:withCompletionHandler:)
が呼び出されます。
ただ、このままでは通知は表示されません。
通知のビューを表示したい場合はcompletionHandlerを実行します。
extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .sound]) // 通知を表示し、通知音も鳴らす } }
completionHandlerを呼び出して表示された通知をタップした場合は、UIApplicationDelegate
のapplication(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any])
が呼び出されます。
ここでも、通知のペイロードはuserInfoに含まれています。
class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) { print("userInfo: \(userInfo)") //userInfoにペイロードが含まれる } }
そのデータをどういう構造体で保持すべき?
受信できたデータを、どのような構造体で保持しておくのが良いでしょうか。
冒頭で書いた通り、画面遷移に必要な情報ペイロードに含めますが、遷移先の画面によって必要な値が異なり、ほぼ全ての値がnullableになります。
こういった場合は、どのようにデコードし、値を保持しておくべきでしょうか。
少し頭を捻ったので、こちらについても残しておきたいと思います。
少し長くなってしまいそうなので、
これについては次の記事で書いてみようと思います。
ではでは、おやすみなさい〜👋👋
Cell内のテキスト等を上寄せにしたい時のTips
Cell内の説明文が中央寄せになってしまう...コマッタ!! よくあります。
そんな時には、テキスト関連をStackViewで構成すると良さそうです。
👇は、画像とテキストで構成されたCellですが、右側のテキスト関連はひとつのStackViewの中に納めて、 StackViewのTopとBottomを画像のTopとBottomに合わせるように制約を追加しています。
ただこうすると、StackViewが画像の高さ分広がってしまい不恰好だし制約エラーも出てしまっています。
そんな時におすすめなのが、Bottomの制約を「Less Than or Equal」にすること。
こうすれば、画像のBottomを超えないようにしつつ、StackViewの高さがUILableの内容に合わせて伸縮してくれます。
↓変更後
いい感じに上寄せできました😊
以上、UITableViewCellのテキスト等を上寄せにしたい時のTipsでした!
後からFirebaseのプッシュ通知を設定する場合に確認しておきたいこと(認証キーを使う場合)
既存のプロジェクトに後からプッシュ通知の設定を入れる際に、全く通知が届かず、手こずったのでので備忘録。
状況
- FirebaseのCloudMessagingの機能を使って、iPhoneにプッシュ通知を送りたい
- そのため、公式ドキュメントに沿って、設定やコードを追加した
- 今回はAPNs証明書ではなく、APNs認証キーを利用
- そのため、APNsの設定はこちらのドキュメントに沿って追加した
- ...プッシュ通知全然届かん!!
原因
その1)プロビジョニングプロファイルの指定を間違っていた...😇
- AppleStore未公開のアプリで通知のテストを行うためには、送りたい端末のUDIDが紐づいたプロビジョニングプロファイルを設定する必要がある(無かったら作成すること)
- 受信したい端末のUDIDを紐づけたプロビジョニングプロファイルを指定できていなかった...うぅ...
- ちゃんと、Debugの設定を変更すること
その2)Capabilitiesの設定が足りなかった
- Background Modesと Push Notificationsの設定を追加したら無事受信できるように...!!
- firebaseが出しているサンプルを落としてきて見比べたら、設定の違いに気づきました
ふぅ、いつもPUSH通知の設定に手こずっている気がする....
ちなみに、記事と全然関係無いですが、iOS13.3に上げた後くらいから殆どのアプリからプッシュ通知が全く届かなくなりました(再起動、SIM抜き差しなど試したがダメ)。
Appleサポートに問い合わせたら、「設定 >一般>リセット>すべての設定をリセット」を実行してくれ(端末のデータに影響は無いから安心せよ)と言われ、そのようにしたら改善しました👏
壁紙、Wifi設定、VPN、アラーム設定など、設定系は初期状態に戻ってしまいますが、どうしようもない場合などは試してみる価値ありですね。
WalletアプリからSuicaのチャージもできなくなっていたのですが、それも直りました✌️
Swiftでmultipart/form-dataのリクエストを実装してみる
最近、埼玉県飯能市にあるムーミンのテーマパーク、ムーミンバレーパークに行って来ました。ムーミンの原作の絵が怖すぎて、作者であるトーベ・ヤンソンの人生が気になります🙄💭
さて、最近、複数の画像をmultipart/form-dataの形式でアップロードしたいと思う事があり、知見もあまりなかったので、学習も兼ねてライブラリを使わずに自前で実装してみました。
実装コードはこちらに載せているので、興味のある方は覗いてみてください💁♀️
multipart/form-data のリクエストの中身
一言で説明すると、HTTPヘッダには、Content-Type(multipart/form-data)とバウンダリ文字列を指定し、HTTPボディにはバウンダリ文字列で区切りながら複数のデータを指定する事でデータ送信ができました。
バウンダリ文字列とは複数データの境界を示す文字列で、リクエスト毎に変わります。
今回実装した仕組みでは、以下のような情報が生成されました。
HTTPヘッダ
Content-Type: multipart/form-data; boundary=-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0
boundary=-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0
がバウンダリ文字列ですね。今回は、リクエスト毎にUUIDから生成するようにしました。
HTTPボディ
-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0 Content-Disposition: form-data; name="id"; 12345 -----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0 Content-Disposition: form-data; name="file"; filename="image.jpg" Content-Type: image/jpeg {画像データ} -----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0--
HTTPヘッダで指定したものと同じバウンダリ文字列で情報を区切ります。
また、文字列を送ると画像データを送る場合とで、Content-Disposition
Content-Type
に指定する内容が変わっていますが、これはデータの形式によって適宜変える必要があります。
実装
実装コードはこちらにも載せています。
import Foundation import UIKit enum HTTPMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } enum RequestError: Error { case invalidRequest } struct APIRequest { var method: HTTPMethod var path: String var parameters: [String: Any] } final class APIClient { let session: URLSession = { let configuration = URLSessionConfiguration.default let session = URLSession(configuration: configuration) return session }() private let baseURL = URL(string: "https://XXXX.com")! func buildMultipartFormURLRequest(from request: APIRequest) throws -> URLRequest { guard case .post = request.method else { throw RequestError.invalidRequest } //今回はUUIDを使っていますが、他の方法でも良さそうです。arc4random()使っているパターンも見かけた。 let uniqueId = UUID().uuidString let boundary = "---------------------------\(uniqueId)" let url = baseURL.appendingPathComponent(request.path) var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method.rawValue urlRequest.url = url urlRequest.httpBody = try multiData(withJSONObject: request.parameters, boundary: boundary, uniqueId: uniqueId) urlRequest.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") return urlRequest } private func multiData(withJSONObject objects: [String: Any], boundary: String, uniqueId: String) throws -> Data { let boundaryText = "--\(boundary)\r\n" var data = Data() for (key, value) in objects { switch value { case let image as UIImage: guard let imageData = image.jpegData(compressionQuality: 1.0) else { throw RequestError.invalidRequest } data.append(boundaryText.data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(uniqueId).jpg\"\r\n".data(using: .utf8)!) data.append("Content-Type: image/jpeg\r\n".data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) data.append(imageData) data.append("\r\n".data(using: .utf8)!) case let string as String: data.append(boundaryText.data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n".data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) data.append(string.data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) case let int as Int: data.append(boundaryText.data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n".data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) data.append(String(int).data(using: .utf8)!) data.append("\r\n".data(using: .utf8)!) default: //今回はUIImage, String, Intしか受け付けないようにしたけど、必要に応じて追加すると良さそう throw RequestError.invalidRequest } } //これを書き忘れるうっかりミスをしてしまった..💦 data.append("--\(boundary)--\r\n".data(using: .utf8)!) return data } }
実際に使う時には
こんな感じで呼び出す想定です。
func sendMultipartFormRequest() { guard let image = UIImage(named: "image") else { fatalError("Invalid imageName") } let apiClient = APIClient() let request = APIRequest( method: .post, path: "/files", parameters: [ "file": image, "id": 1234, ]) do { let urlRequest = try apiClient.buildMultipartFormURLRequest(from: request) let task = apiClient.session.dataTask(with: urlRequest) { data, response, error in //成功した時の処理 } task.resume() } catch { //エラーハンドリング } }
勝手に難しそうなイメージを持っていたけれど、意外とシンプルでした!良かった良かった。
参考文献
Compositional Layoutsで複雑なUIのCollectionViewを実装する
WWDC2019で発表されたCompositional Layoutsは、どうやら複雑なレイアウトのUICollectionViewを簡単に実現することができるらしいという事で、調査と実装をしてみました。
今回は、Compositional Layoutsを利用して、インスタグラムやWearで使われているようなUIを実現。割とサクッと実現できました。
サンプルコードはこちらに載せています。
Compositional Layoutsざっくり説明
そもそもCompositional Layoutsとはどういうものなのかを簡単にまとめます。(※WWDC2019の動画やデモコードを調べた上での、私なりの理解なので間違っていたらご指摘いただきたいです🙏)
まず、Compositional LayoutsはItem、Group、Section の3つのComponentで構成されているようです。
そして、それらは階層構造になっており、図にすると以下のような感じ
- Item
- レイアウトを構成する一番小さな部品で、Cellやヘッダー、フッターになるもの
- Group
- Itemを一つにまとめて構成するレイアウトの基本単位
- Section
- Groupをまとめて、それらに対してInsetsなどを追加できる
- Layout
- 最終的にレンダリングされる単位
実際にこのLayoutをCollectionViewに適用するコードは簡単で、UICollectionView
の初期化時に生成したレイアウトを渡すだけです。
こんな感じ👇
var collectionView: UICollectionView! = nil override func viewDidLoad() { super.viewDidLoad() collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout()) ... } func createLayout() -> UICollectionViewLayout { //ここでUICollectionViewCompositionalLayoutを作成し返却 let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in //各レイアウトの定義 } return layout }
複雑なUIを実現する
上記を踏まえて、サンプルUIの実現方法について説明します。 今回の構成ですが、3つのItemを持つGroupを、4つ組み合わせて1つのSectionにしています。
図で説明すると以下のような構成です。4つのGroupでSectionを構成しています。
各Groupの構成は以下の通りです。
TypeA・TypeB
サイズの違うItemを扱うため、Groupの中も2つのGroupで構成するようにしています。 2つの小さなItemで構成されるGroupと、1つのItemで構成されるGroupを1つのGroupにまとめています(言葉にすると分かりにくい...)。
TypeC
こちらは、同じサイズの3つのItemを一つのGroupにまとめています。
実装
コードにするとちょっと長いですが、以下の通りです。
CollectionViewも含む全体のコードは、こちらで公開しています。
func createLayout() -> UICollectionViewLayout { let sideInset: CGFloat = 18 let insideInset: CGFloat = 8 let topInset: CGFloat = 8 let viewWidth: CGFloat = view.bounds.width let smallSquareWidth: CGFloat = (viewWidth - (sideInset * 2 + insideInset * 2)) / 3 let mediumSquareWidth: CGFloat = smallSquareWidth * 2 + insideInset let nestedGroupHeight: CGFloat = mediumSquareWidth + topInset let smallSquareGroupHeight: CGFloat = smallSquareWidth + topInset let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in let nestedGroupTypeA: NSCollectionLayoutGroup = { let smallSquareItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(smallSquareWidth + insideInset))) smallSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: insideInset) let smallSquareGroup = NSCollectionLayoutGroup.vertical( layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth + insideInset), heightDimension: .fractionalHeight(1.0)), subitem: smallSquareItem, count: 2) let mediumSquareItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(mediumSquareWidth), heightDimension: .fractionalHeight(1.0))) mediumSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0) let nestedGroup = NSCollectionLayoutGroup.horizontal( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(nestedGroupHeight)), subitems: [smallSquareGroup, mediumSquareItem]) return nestedGroup }() let nestedGroupTypeB: NSCollectionLayoutGroup = { let mediumSquareItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(mediumSquareWidth + insideInset), heightDimension: .fractionalHeight(1.0))) mediumSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: insideInset) let smallSquareItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(smallSquareWidth + insideInset))) smallSquareItem.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0) let smallSquareGroup = NSCollectionLayoutGroup.vertical( layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth), heightDimension: .fractionalHeight(1.0)), subitem: smallSquareItem, count: 2) let nestedGroup = NSCollectionLayoutGroup.horizontal( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(nestedGroupHeight)), subitems: [mediumSquareItem, smallSquareGroup]) return nestedGroup }() let nestedGroupTypeC: NSCollectionLayoutGroup = { let smallSquareItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .absolute(smallSquareWidth), heightDimension: .fractionalHeight(1.0))) let smallSquareGroup = NSCollectionLayoutGroup.horizontal( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(smallSquareGroupHeight)), subitem: smallSquareItem, count: 3) smallSquareGroup.interItemSpacing = .fixed(insideInset) smallSquareGroup.contentInsets = NSDirectionalEdgeInsets(top: topInset, leading: 0, bottom: 0, trailing: 0) return smallSquareGroup }() let group = NSCollectionLayoutGroup.vertical( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(nestedGroupHeight * 2 + smallSquareGroupHeight * 2)), subitems: [nestedGroupTypeA, nestedGroupTypeC, nestedGroupTypeB, nestedGroupTypeC]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: sideInset, bottom: 0, trailing: sideInset) return section } return layout }
実装は複雑に見えますが、考え方はシンプルです。 複数のItemをGroupにまとめて小さなパーツを作り、そのGroupを更に複数まとめてひとつのSectionを作る事で、複雑なUIを実現しています。
各ItemやGroupのサイズ指定方法については、ひとつ上のComponentの高さや幅に対する割合を指定する方法や、絶対値を指定する方法などがあります。 詳しくはドキュメントに書かれています。
今回は、左右のマージンやitem間のスペースを固定したため、絶対値で指定する箇所が多いですが、UIに合わせて指定方法を決めると良いかと思います。
ちなみに、UICollectionViewの初期化時に createLayout()
を渡していますが、collectionView の invalidatelayout()
が呼ばれると createLayout()
も呼ばれるので、画面回転時には今まで通りinvalidatelayout()
を呼んであげれば再計算してくれます。便利!👏
もっと知りたいという方
WWDC2019の動画で、デモコードの解説をしています。動画と見比べながらデモコードをいじってみると、結構分かりやすくておすすめです!
WWDC2019動画(Advances in Collection View Layout)
https://developer.apple.com/videos/play/wwdc2019/215
便利なCompositional Layouts、積極的に活用していきたい!🎉
Xcode11.1でDistributeしようとしたら、Code signing "XXXX.framework" failed エラーが出る
TL;DR Carthage で入れたframework設定を Do not Embed
にして、Run Script の Input Files にパスを追加する。
XcodeでArchive後、AdHocでDistributeしようとしたら Code signing "XXXX.framework" failed
が出て進まなくなってしまった...
背景としては、Carthage 経由でいくつかライブラリを入れていました。 各ドキュメントに沿って導入していたけれど、ライブラリによっては、Installation手順に↓Carthage手順の6番(ファイルのドラッグ)の実行までを行うように書かれていて、7以降の手順を実行していないライブラリに対してエラーが出ているようでした。 github.com
対処方法としては TL;DR
に書いてある方法で、細かく説明すると以下の通り。
1. Target > General > Frameworks, Libraries, and Embedded Content の設定を Do Not Embed
にする
2. Target > Build Phases > Carthageの設定 > Input Files にパスを追加
はー、結構時間使ってしまった😢とりあえずの備忘録でした📝
全画面のナビゲーションバーの設定を一括で変更するには
UINavigationControllerのサブクラスを作って共通の設定を追加すると簡単です。
ナビゲーションバーの戻るボタンタイトルを全画面で消したい、背景色やタイトルなどの設定も一括で行いたいのであれば、
以下のように、そのアプリ専用のナビゲーションを実装して、それをUINavigationControllerの代わりに利用するだけ。
// MyNavigationController.swift import Foundation import UIKit final class MyNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() navigationBar.tintColor = .green // 戻るボタンなど、ナビゲーションバーアイテムの色 navigationBar.barTintColor = .white // ナビゲーションバー の背景色 let attributes: [NSAttributedString.Key: AnyObject] = [ NSAttributedString.Key.foregroundColor: UIColor.black, ] navigationBar.titleTextAttributes = attributes // ナビゲーションバー タイトルの指定 delegate = self } } extension MyNavigationController: UINavigationControllerDelegate { // NavigationControllerがViewControllerやNavigationItemを呼び出す直前に呼ばれるDelegateメソッド内に // 戻るボタンのタイトルを""にする指定を追加 func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { viewController.navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) } }
呼び出す時は、
let viewController = UIViewController() let navigationController = MyNavigationController(rootViewController: viewController)
のように、UINavigationControllerからMyNavigationControllerに置き換えるだけ🤗