- 投稿日:2020-12-12T23:23:21+09:00
[Swift] 関数 外部引数の省略
- 投稿日:2020-12-12T23:22:13+09:00
WebサイトからSet-Cookieを返したとき、WKWebViewはそれを永続化するのか(する)
お世話になります、 @sussan0416 です。
Classi Advent Calendar 2020 は13日目です。昨日は、 @kitaharamikiya さんによる エンジニアが顧客へ提供したい価値を見つけるまでにやっていること でした。さて今日は、iOSのWebViewとCookieの話題です。
サーバサイドのエンジニアに説明する気持ちで書きたい
iOS(iPadOS)のアプリでは、Webコンテンツを画面に表示するとき、
WKWebViewというViewコンポーネントを使用します。しかしこのWKWebView、iOSアプリエンジニアにとっては曲者というか、サーバサイドのエンジニアにとっても、挙動がわかりにくい・デバッグが難しい対象だなと感じています。そこで今回は、WKWebViewにおけるCookieの永続化に関する挙動について、この1年で理解したことをまとめておきたいと思います。iOSの具体的な実装の話題は控えめにしつつ、iOSのWKWebViewの挙動をまとめたいと思います。
WKWebViewとは...
WKWebViewとは、iOS(iPadOS)アプリで、Webコンテンツを表示する際に使用するViewオブジェクトです。ブラウザでイメージするとわかりやすいでしょうか、下にイメージ画像をおいておきます。まぁとにかく、シンプルに、Webコンテンツを表示するだけの、ペラペラとしたスクリーンみたいなものです。アプリケーションにはいろんな画面要素(UI)がありますが、WebViewは、もう本当にここだけ。
念のため、地味に重要なことなので書いておきますが、このように画面上にWebViewが存在すれば、それはWebViewのインスタンスがあるということです。画面から消えると、当然ながらインスタンスは消えます。
(あー……画面に見えていなくても、裏に隠れているとかであれば、インスタンスは残りますが……)ちなみに、
WKWebViewのWKというプレフィックスは、iOS SDKのWebKitフレームワークのクラスであることを示しています。では、
WKWebViewの挙動を以下にまとめていきます。Webサイトから返すSet-Cookieは、永続化される(期限のあるものは)
Webサイトのレスポンスに、
Set-Cookieヘッダをつけることがあると思います。このCookieが永続化されているかというと……ちゃんと永続化されています!
このあたりは、普通のブラウザと一緒ですね。
有効期限の有無 永続化 WKWebViewのインスタンスが新たに生成されたとき あり 永続化される 永続化してあるCookieが、リクエストにセットされる なし 永続化されない(WKWebViewインスタンスが削除されると消える) リクエストにセットされない1 このように、有効期限が設定されているCookieは、新たにWebViewの画面を開いたときにセットされた状態になります。
有効期限のないセッションオンリーのCookieは、WebViewが画面から消えることで(インスタンスが削除されることで)Cookieは消えてしまいます2。
期限切れのCookieは、リクエストにセットされない
アプリ内に永続化される期限付きのCookieですが、当然ながら、期限が切れたCookieはリクエストにセットされません。なお、Cookieデータが削除されるタイミングは不明です。プログラムから明示的に削除することも可能です。
Cookieは、アプリ内の
Library/Cookiesに永続化されている(らしい)このあたりは…さほど詳しくないのですが、永続化されたCookieは、アプリのサンドボックス内にある
Library/Cookiesに永続化されるそうです3。サンドボックスというのは、アプリ固有のディレクトリで、他のアプリからは見ることのできない専用の領域のことです4。ここで、iOSのファイル構成を、ちょっと覗いてみたいと思います5。
参考にしたサイトの情報をまとめると、こんな感じのファイル構成になっているようです。
Library/Cookiesは安全だが完全ではない
Library/CookiesはCookieの保存場所として、あらゆるアプリが使用しています。この領域は、他のアプリからアクセスできない安全な領域です。ですが、完全なものではありません。……色々調べていくと、アプリのサンドボックス内のデータを抽出する方法もあるにはあるようですので。また、Cookieの保存場所として、OS領域でありますKeychainを使用しても、これは同じことです。暗号化されるという意味ではより安全とは思いますが。
ちなみに、Libraryディレクトリは、MacやiCloudにバックアップされるようです(ユーザーの指定によるはず)。
アプリを削除すると、
Library/Cookiesも削除されるCookieは、アプリケーションのサンドボックス内に永続化されます。そのため、アプリを削除すると、一緒にCookieも端末から削除されます。
ただし、CookieをOSのKeychain領域に保存するなどしていた場合は、アプリを削除してもKeychain領域にはCookieが残ることになります。ちなみに、Cookieの保存場所としてKeychainを指定することは、デフォルトではできません(おそらく普通はしない)。Keychainに保存する処理を別途実装することになります。アプリの全てのリクエストに、Set-CookieしたCookieが適用されるわけではない
前提として、WebViewとしての通信(JavaScriptの通信を含む)と、ネイティブアプリケーションとしての通信(例: JSONでやり取りするAPIリクエスト)は、互いに独立しています。要するに、クライアントが違うということです。それを示すように、WebViewの通信と、アプリとしてのAPI通信は、User-Agentが異なります。
クライアント User-Agent(例) WKWebView Mozilla/5.0 (iPhone; CPU iPhone OS 12_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CFNetwork(iOS SDKのフレームワーク) MyApplication/1 CFNetwork/1128.0.1 Darwin/19.6. たとえばWebViewからのリクエストに対して
Set-Cookieした場合、WebViewを使っている限りはCookieが適用されますが、ネイティブ側からのリクエストに対しては、Cookieが適用されないということになります(逆もしかり)。もしこれを読んでいる人で、いや〜うちのアプリはWebViewでログインさせてCookie使うんだけど、それ以降はネイティブのリクエストもあるのよ……なんていうプロジェクトの人がいたら、やっぱりCookieをWebViewとネイティブ間で同期させたくなりますよね。
基本的には、同期されません。クライアントを超えてCookieやらセッションやらを同期することはできません。じゃぁどうするか。どちらか一方のクライアントからCookieを抽出して、もう一方のクライアントにセットすることで対応しています。Set-CookieしたCookieがリクエストに適用されているか、見ることはできる?
Library/Cookieが見えにくくされているように、保存されたCookieそのものを見るのは難しく手間がかかります。- プロキシアプリを使い通信内容をキャプチャすることで、Cookieがセットされているかを確認することができる場合があります(だいたい見える)。
- もし開発用ビルドのアプリを使って挙動確認をしているのであれば、端末を接続したMacの方で、Safariのインスペクタを開くとCookieをチェックすることができます(方法)
ちなみに、WKWebViewにはプライベートブラウズモードもある
あるよ程度に知っておくと良さそうです。DataStoreの挙動が変わったりするので注意です。
プライベートブラウズの実装方法(iOSエンジニア向け)まとめ
- Webサイト側から見たときに気になりそうな、iOSのWebViewのCookieについてまとめました
- 基本的なCookie挙動はブラウザと変わらず、永続化もされます
- アプリがWebViewとネイティブ両方で通信する場合、双方でCookieが同期されるわけではないということを、ちょっとだけ意識しておくと良さそうです
以上、この1年でわかったWKWebViewのCookieに関する挙動まとめでした。
それでは失礼します。みなさん良きクリスマスをお迎えください
明日は、 @youichiro さんです!
付録(iOSアプリエンジニア向け)
テスト用にアプリを組んでいろいろ試しました。挙動をまとめます。
登場人物
- WKWebsiteDataStore.httpCookieStore(WebViewのCookie保存場所)
- HTTPCookieStorage(ネイティブ側のCookie保存場所)
処理 結果 HTTPCookieStorageにCookieをセットする WKWebViewには反映されない websiteDataStore.httpCookieStoreに、Cookieをセットする 条件付きで、HTTPCookieStorageに同期(後述) WKWebView使用中に、WebサイトからSet-CookieされたCookie HTTPCookieStorageには同期されない WKUserScriptを使用して、JavaScript(document.cookie)でCookieをセット HTTPCookieStoreには同期されないが、有効期限のあるものはwebsiteDataStore.httpCookieStoreに永続化されている WKWebView→HTTPCookieStoreにCookieが同期される条件
WKWebViewをインスタンス化するときに渡すWKWebViewConfigurationに、あらかじめCookieをセットしておくと、WKWebViewがインスタンス化されるときに、HTTPCookieStorageにも同期されているようだった。
インスタンス化されたあとは、同期されない(Set-Cookie無念)。lazy var webView: WKWebView! = { let persistentCookie = HTTPCookie(properties: [.name : "cookie_1_min", .domain : "example.com", .path : "/", .value : "1", .expires: Date(timeIntervalSinceNow: 60), .secure: true])! // setCookieする let config = WKWebViewConfiguration() config.websiteDataStore .httpCookieStore .setCookie(persistentCookie, completionHandler: nil) // ここでconfigを渡すことで、Cookieが同期されるっぽい let view = WKWebView(frame: view.bounds, configuration: config) // レイアウトの処理は端折る // 順序が入れ替わると、同期されない!!! view.configuration .websiteDataStore .httpCookieStore .setCookie(HTTPCookie(......), completionHandler: nil) return view }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(webView) let url = URL(string: "https://〜〜")! let request = URLRequest(url: url) webView.load(request) }WKUserScriptでセットしたCookieは、WebViewとしては永続化されている
そもそもドキュメントへのインジェクションなので、最初のリクエストにはセットされないのが残念ではある。
インジェクトしたスクリプトが実行されたあとは、有効期限のあるものについては永続化されている。
HTTPCookieStorageには同期されない。lazy var webView: WKWebView! = { let script = "document.cookie = 'cookie_from_script=1; domain=example.com; path=/; expires=Sun, 13-December-2020 00:00:00 GMT'" let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false) let userContent = WKUserContentController() userContent.addUserScript(userScript) let config = WKWebViewConfiguration() config.userContentController = userContent // ここでconfigを渡すけれど、HTTPCookieStorageにはCookie同期されない let view = WKWebView(frame: view.bounds, configuration: config) // レイアウトの処理は端折る return view }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(webView) let url = URL(string: "https://〜〜")! let request = URLRequest(url: url) webView.load(request) }そもそもUserScriptって、拡張機能とかの用途だよねきっと。
以上!
WKProcessPoolインスタンスをWKWebViewのインスタンス同士で共有している場合、WKProcessPoolのインスタンスが消えるまではセッションCookieが維持される。WKProcessPoolは、アプリが複数のWebViewを生成し、WebView同士でセッションを引き継ぎたい場合に使用する。 ↩ちなみに、アプリを一旦閉じる(ホームボタンを押してアプリをバックグラウンドに移動する)だけでは、このCookieは消えません。この場合はアプリが一時停止しているだけで、WebViewのインスタンスは残っているためです。アプリをKill(バックグラウンドのアプリ一覧からアプリを削除)した場合は、当然ながらWebViewインスタンスも削除されるので、有効期限が設定されていないCookieは消えます。 ↩
(参照) https://qiita.com/ShingoFukuyama/items/eede79a284c3669846e9#cookiecachecredential%E3%81%9D%E3%81%AE%E4%BB%96web%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AA%E3%81%A9%E3%82%92%E6%B6%88%E3%81%97%E3%81%9F%E3%81%84 ↩
(参照)https://www.alphansotech.com/ios-application-files-and-folder-structure ↩
- 投稿日:2020-12-12T23:09:48+09:00
[Swift] 関数の外部引数と内部引数
定義
外部引数とは関数呼び出し時に使用する引数。
内部引数とは関数内で使用される引数。書き方
以下のコードは、外部引数をheightとwidthで内部引数をhとwにしています。
外部引数と内部引数の間は半角スペースを1つ開けます。
外部引数は関数を利用する側から見てわかりやすい名前を書き、
内部引数はプログラムが冗長にならない無駄のない名前を書くそうです。func menseki(height h: Double,width w: Double) -> Double { return h * w / 2 } let kekka = menseki(height: 6, width: 3) //9メリット
関数を使う側には分かりやすく書けて、関数内では短かく書く事ができるので手間が省けるのかなと思います。
参考文献
石川洋資・西川勇世、『[増補改訂第3版] Swift実践入門 直感的な文法と安全性を兼ね備えた言語』、技術評論社、2020年4月28日、453ページ
- 投稿日:2020-12-12T22:12:53+09:00
[Swift] 関数について初歩的なことをまとめる
関数とは
「関数は、入力として引数、出力として戻り値を持つ、名前を持ったひとまとまりの処理」
(石川洋資・西川勇世、『[増補改訂第3版] Swift実践入門 直感的な文法と安全性を兼ね備えた言語』、技術評論社、2020年4月28日 128p)と定義されています。
同じ処理をいろんな箇所で書く時に関数としてまとめて使用します。書き方
実際の関数の例が以下になります。
doubleが関数名、_が外部引数の省略、xが内部引数、:の後が引数の型、->の後が戻り値の型、{}内が実行される処理になります。
戻り値を返すにはreturn文を{}内で使用します。func double(_ x:Int) -> Int { return x * 2 }実行してみた
double(2)を実行すると、
内部引数のxに2が入る→{}内の式のxに2が渡される→結果がInt型の戻り値に渡される→4という結果が得られる→定数kekkaに代入
という流れです!func double(_ x:Int) -> Int { return x * 2 } let kekka = double(2) //4引数について
関数の定義時に宣言するもの(上記の例でいうとx)を仮引数
関数の呼び出し時に指定するもの(2)を実引数参考文献
石川洋資・西川勇世、『[増補改訂第3版] Swift実践入門 直感的な文法と安全性を兼ね備えた言語』、技術評論社、2020年4月28日、453ページ
- 投稿日:2020-12-12T22:06:35+09:00
Udemyの海外コースおすすめ(iOSアプリ開発)
コースについて
勉強するコースはこれです
https://www.udemy.com/course/ios-13-app-development-bootcamp/
英語ですが、なんとかなるかなという軽い気持ちで選びました。
あと、セールで安かったのでついつい買ってしまった(お得という言葉の響きに弱い)
そして、先生の笑顔がとってもキュート
日本語のコースより英語のコースのほうが内容が充実しているという話もあるし、アプリ開発するついでに英語も学べてラッキーですよ。<Udemyのコースについて>
- 本当に手取り足取り教えてくれるので、知識ゼロからでも学べそうなレベル
- 英語のレベルは、アメリカの小学校高学年~中学校くらいのレベルな気がする(小難しい単語は出ない)
- 先生(Dr. Angela Yu)の英語は、少しイギリス英語アクセントっぽいが非常に聞き取りやすい(アジア人にとって分かりやすい英語だと思う)
- やや説明するスピードが速いが、英語字幕があるので大丈夫そう
- 先生の英語はとても勉強になるし、ちゃんとした授業形式になっているので学びにつながる
- これやっとけばセブ島留学とか行く必要ないんじゃないかとすら思う。。提出期限とかないしコース視聴いつでもできるし、Angela(先生)の優しさに甘えてしまうので注意です(オンラインコースあるある)
やはり絶対にこの曜日のこの時間にやるとか決まり事をつくって習慣化していかないとオンラインコースは続かない(戒め)コース内の小ネタが盛り込まれていて飽きにくくなっています。
たとえば、スマホアプリ黎明期に「I AM rich.という画面を表示させるだけのアプリで高額課金させて儲けるという詐欺同然の方法で稼いだ人がいた」という話が実に興味深かった。
そのアプリ持っているだけで金持ちの象徴のようなことだしアプリの内容は間違っていない(?)
開発について説明中にちょくちょく小ボケがあったり興味深い話をしたりします。英語のUdemyコースの受講について
今まで自分がしてきた学習は日本語での本や授業の内容だったので、英語で学ぶとまた新鮮な部分がありますね。
「英語だとこう言うのか!」とか「英語だったらこういう風に解説すればいいのか!」とか目から鱗な内容もありました。
すでにswiftに詳しい人でも受講すると英語の勉強になっていいかも。
また、英単語や英語での表現に詳しければ英語で内容を調べやすいので、開発中に分からないところでつまづいたときにこの学習経験が役に立ちそうな気がする。
- 投稿日:2020-12-12T19:29:37+09:00
AltSwiftUIさわってみた
iOSDC2020のセッションでAltSwiftUIというライブラリが紹介されました。
SwiftUIは導入するのにビルドターゲットをiOS13以降にする必要がありますが、このライブラリを使えばiOS11からSwiftUIライクにViewを組むことができます。
紹介されたときはこのライブラリが今後どう普及していくのか楽しみだったのですが、iOSDCが終わり数ヶ月経った現在もあまり導入例の記事やリポジトリがなかったため、少し触ってみることにしました。
この記事ではSwiftUIとの全ての違いを網羅することはできませんので、さらに気になられた方はAltSwiftUIのリポジトリをクローンしExampleのプロジェクトを動かしてみることをお勧めします。
この記事を読むよりそっちの方がよっぽど勉強になる導入方法
CocoaPodsで導入することができます。
pod 'AltSwiftUI'検証環境
iOS 12.0
Xcode12.2
AltSwiftUI 1.3.2実際にViewを組んでみる
ボタンをタップするとテキストが表示されるだけの単純なviewを組んでみて、SwiftUIと実装にどのように差があるかみていきます。
まずこちらがSwiftUIのコードです。あまりSwiftUI自体もキャッチアップできていないため、お作法がなっていないところもあるかもしれませんがご容赦ください?ContentView.swift// SwiftUI import SwiftUI struct ContentView: View { @State private var isPushed = false var body: some View { VStack(alignment: .center) { Image("Image") .resizable() .scaledToFit() .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width) if isPushed { Text("Merry Christmas?") .font(.system(size: 20, weight: .bold)) .padding(.bottom, 20) } Button(action: { isPushed.toggle() }, label: { Text("Button") }) .accentColor(.orange) .frame(width: UIScreen.main.bounds.width - 24, height: 44) .font(.system(size: 14, weight: .bold)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.orange, lineWidth: 2)) } .offset(x: 0, y: -150) } }そしてこちらがAltSwiftUIのソースです。
ほぼ同じように実装できているように見えますが多少差があります。ContentView.swift// AltSwiftUI import AltSwiftUI struct ContentView: View { var viewStore = ViewValues() @AltSwiftUI.State private var isPushed = false var body: View { VStack(alignment: .center) { Image("Image") .resizable() .scaledToFit() .frame(width: UIScreen.main.bounds.width , height: UIScreen.main.bounds.width) if isPushed { Text("Merry Christmas?") .font(.system(size: 20, weight: .bold)) .padding(.bottom, 20) } Button(action: { isPushed.toggle() }, label: { Text("Button") }) .accentColor(.orange) .border(.orange, width: 2) .font(.system(size: 14, weight: .bold)) .frame(width: UIScreen.main.bounds.width - 24, height: 44) .cornerRadius(10) } .offset(x: 0, y: -150) } }ではここからはいくつかの違いについて触れていきます。
viewStore
AltSwiftUIは
viewStoreの実装を求められます。
求められるものは書いておきましょう!笑someの有無
SwiftUIではViewの前に
someをつけたものを準拠させていますが、AltSwiftUIではつける必要がありません。つける必要がないというのは正確ではなく、つけることができないという方が正しいです。
someはSwift5.1から使えるようになったものなので、iOS11をサポートしようとすると使えません。このsomeについては詳しくはこちらの記事に書かれています(あまり理解していないのでどなたか教えてください・・・)Property Wrapper
AltSwiftUIではSwiftUIのProperty Wrapperも使用できます。使う際は
@AltSwiftUI.Stateのように指定する必要があります。最新のリリースをみるとこの指定がなくても直接@Stateで使えるようだったのですが、なぜかエラーで使えませんでした。わかり次第、追記します。
RoundedRectangleが使えなかった
ボタンの角丸を表現するのにSwiftUIでは
RoundedRectangleを使用したのですが、AltSwiftUIでは使えませんでした。しかしborderとcornerRadiusを使えば表現できるので、さほど問題でもないかなと思います。他をあまり調べていませんが、こういった差はちょこちょこありそうなので使う時に注意が必要かもしれません。
Preview機能は?
SwiftUIというとPreview機能が目玉のひとつでしょう。コードの変更がリアルタイムでViewに反映されるため、ずいぶんと実装が楽になりました。
AltSwiftUIでPreviewを実現するためには、SwiftUIとAltSwiftUIそれぞれの
PreviewProviderを準拠させる手法が公式から紹介されています。 実際この方法でPreviewすることができました。Preview.swift#if DEBUG && canImport(SwiftUI) import SwiftUI import protocol SwiftUI.PreviewProvider import protocol AltSwiftUI.View struct MyPreview: AltPreviewProvider, PreviewProvider { static var previewView: View { ContentView() } } #endifまとめと所感
さわってみた感想としては
- SwiftUIとほぼ同様なシンタックスで宣言的にコードがかけるのが良い
- SwiftUIに移行する時ラクかも?
- 予期せぬバグなどは心配なので最小のコンポーネントから試すのがよさそう
と思いました。一方これからSwiftUIが普及していく中で、わざわざAltSwiftUIを使う機会は限定的かな?とも感じました。しかし携わるプロジェクトによってはiOS12以前をサポートしなければならない場面も存在すると思います。そういった方がSwiftUIを実務で取り入れつつ学ぶにはよいかもしれません。
- 投稿日:2020-12-12T16:57:51+09:00
【Flutter】遭遇したエラー&&解決策まとめ
参考文献
- Xcode12にアップデートしたらPodライブラリで大量に警告が発生したので対処した件
- FlutterでGoogleDataTransportにエラーが出た時の対処
- 【Flutter】エラー「firebase_core_web not found.」の対策
- 【Flutter】No Firebase App '[DEFAULT]' has been created - call Firebase.initializeApp()の対処法
Podライブラリの警告
◆ エラー内容
The iOS Simulator deployment target ‘IPHONEOS_DEPLOYMENT_TARGET’ is set to 8.0, but the range of supported deployment target versions is 9.0 to 14.0.99.◆ 解決策
Podfilepost_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' end end end$ pod updateGoogleDataTransportエラー
◆ エラー内容
error: umbrella header for module 'GoogleDataTransport' does not include header 'GDTCORDataFuture.h'◆ 解決策
$ rm -rf Pods $ flutter clean $ flutter run //pod installが自動実行される「firebase_core_web not found」エラー
◆ エラー内容
Plugin project :firebase_core_web not found. Please update settings.gradle.◆ 解決策
setting.gradledef flutterProjectRoot = rootProject.projectDir.parentFile.toPath() def plugins = new Properties() def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') if (pluginsFile.exists()) { pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } } plugins.each { name, path -> def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() include ":$name" project(":$name").projectDir = pluginDirectory }「Firebase.initializeApp」エラー
◆ エラー内容
No Firebase App '[DEFAULT]' has been created - call Firebase.initializeApp()◆ 解決策
Widget _buildBody(BuildContext context) { Firebase.initializeApp(); // new
- 投稿日:2020-12-12T11:02:08+09:00
[Swift5]秒数を指定して数秒遅れて処理を実行する方法。(遅延処理)
- 投稿日:2020-12-12T10:43:56+09:00
【Flutter】 iphoneのようにボトムナビゲーションを作る
Flutterでの下タブでのナビゲーションについてです。
マテリアルデザインであればそのままBottomNavigationBarを使用すればいいのですがその場合
画面遷移で下タブが消えてしまうので遷移後に別タブの画面切り替えができません。今回の記事はiosのように下タブを残したまま画面遷移をさせる方法についてです。
以下を記述しています。
- CupertinoTabScaffoldを使用しながら下タブを表示させずに画面遷移する方法
- androidのバックキーを押下した際に現在のタブから前画面に戻る方法
CupertinoTabScaffoldを使用しながら下タブを表示させずに画面遷移する方法
画面遷移する際にrootNavigatorをtrueにしてpushします。
Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (context) => nextPage(), ), );androidのバックキー押下した際に現在のタブから前画面に戻る方法
上記のpushのように下タブを表示せずに画面遷移した場合は問題ありませんが
CupertinoTabScaffoldを使用して下タブを表示しながら画面遷移をした場合androidでバックキーを押すとアプリが閉じます。
これはバックキーでpopの処理が行われているのですが
表示されている画面のcontextではなく、CupertinoTabScaffoldを使用した親画面のcontextを用いて
popしているため親画面は画面遷移していないのでアプリが閉じています。
※iOSの場合は特に何もしなくても画面を左スワイプで前画面に戻ることが可能です。方法としてはstaticで各タブのcontextを保持しておき、バックキーを押されたタイミングで親画面から各タブのcontextを呼び出しpopします。
/// androidのバックキー制御するための各タブ分のcontext保持クラス class ConstantsChildContext { static int selectedIndex = 0; static BuildContext childContext0; static BuildContext childContext1; static BuildContext childContext2; }各タブのbuild時にcontextを初回だけセットします。
/// 初回に各タブのChildContextをセットする void setChildContext({@required BuildContext childContext}) { switch (ConstantsChildContext.selectedIndex) { case 0: if (ConstantsChildContext.childContext0 == null) { ConstantsChildContext.childContext0 = childContext; } break; case 1: if (ConstantsChildContext.childContext1 == null) { ConstantsChildContext.childContext1 = childContext; } break; case 2: if (ConstantsChildContext.childContext2 == null) { ConstantsChildContext.childContext2 = childContext; } break; } }最後に親画面でCupertinoTabScaffoldの親WidgetとしてWillPopScopeを書き、
ここでバックキーを押した際のイベントを取得します。@override Widget build(BuildContext context) { /// WillPopScopeで親画面のbackKeyイベントを取得し、現在のタブのcontextをpopして前画面に戻る return WillPopScope( onWillPop: () async { /// バックキー押下時のイベント取得 return _onBackKeyAndroid(); }, child: CupertinoTabScaffold(), ); }各タブで前画面に戻れるかどうかを判定しbool値をかえします。
/// バックキーをタップすると各画面のpopを実行(Androidのみ) bool _onBackKeyAndroid() { if (Platform.isAndroid) { switch (ConstantsChildContext.selectedIndex) { case 0: if (Navigator.canPop(ConstantsChildContext.childContext0)) { Navigator.pop(ConstantsChildContext.childContext0); } else { /// 前画面に戻れない場合にアプリを閉じたくなければここはfalse return true; } break; case 1: if (Navigator.canPop(ConstantsChildContext.childContext1)) { Navigator.pop(ConstantsChildContext.childContext1); } else { return true; } break; case 2: if (Navigator.canPop(ConstantsChildContext.childContext2)) { Navigator.pop(ConstantsChildContext.childContext2); } else { return true; } break; } } return false; }これでandroidのバックキー制御ができるようになりました。
こちらで勉強しました。
サンプルコードのデザインはそのままです。サンプルコード
main.dartimport 'package:flutter/material.dart'; import 'home_page.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), home: HomePage(), ); } }home_page.dartimport 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'constants_child_context.dart'; import 'custom_page.dart'; class HomePage extends StatefulWidget { @override _HomePageState createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { static List<Widget> _pageList = [ CustomPage( pannelColor: Colors.cyan, title: 'Home', pageCount: 1, ), CustomPage( pannelColor: Colors.green, title: 'Settings', pageCount: 1, ), CustomPage( pannelColor: Colors.pink, title: 'Search', pageCount: 1, ) ]; void _onItemTapped(int index) { setState(() { ConstantsChildContext.selectedIndex = index; }); } /// バックキーをタップすると各画面のpopを実行(Androidのみ) bool _onBackKeyAndroid() { if (Platform.isAndroid) { switch (ConstantsChildContext.selectedIndex) { case 0: if (Navigator.canPop(ConstantsChildContext.childContext0)) { Navigator.pop(ConstantsChildContext.childContext0); } else { /// 前画面に戻れない場合にアプリを閉じたくなければここはfalse return true; } break; case 1: if (Navigator.canPop(ConstantsChildContext.childContext1)) { Navigator.pop(ConstantsChildContext.childContext1); } else { return true; } break; case 2: if (Navigator.canPop(ConstantsChildContext.childContext2)) { Navigator.pop(ConstantsChildContext.childContext2); } else { return true; } break; } } return false; } @override Widget build(BuildContext context) { /// WillPopScopeで親画面のbackKeyイベントを取得し、現在のタブのcontextをpopして前画面に戻る return WillPopScope( onWillPop: () async { return _onBackKeyAndroid(); }, child: CupertinoTabScaffold( tabBar: CupertinoTabBar( items: [ BottomNavigationBarItem( icon: Icon(Icons.home), ), BottomNavigationBarItem( icon: Icon(Icons.settings), ), BottomNavigationBarItem( icon: Icon(Icons.search), ), ], currentIndex: ConstantsChildContext.selectedIndex, onTap: _onItemTapped, backgroundColor: Colors.white, ), tabBuilder: (context, index) { return CupertinoTabView( builder: (context) { return _pageList[index]; }, ); }, ), ); } }constants_child_context.dartimport 'package:flutter/cupertino.dart'; /// androidのバックキー制御するための各タブ分のcontext保持クラス class ConstantsChildContext { static int selectedIndex = 0; static BuildContext childContext0; static BuildContext childContext1; static BuildContext childContext2; } /// 初回に各タブのChildContextをセットする void setChildContext({@required BuildContext childContext}) { switch (ConstantsChildContext.selectedIndex) { case 0: if (ConstantsChildContext.childContext0 == null) { ConstantsChildContext.childContext0 = childContext; } break; case 1: if (ConstantsChildContext.childContext1 == null) { ConstantsChildContext.childContext1 = childContext; } break; case 2: if (ConstantsChildContext.childContext2 == null) { ConstantsChildContext.childContext2 = childContext; } break; } }custom_page.dartimport 'package:flutter/material.dart'; import 'constants_child_context.dart'; import 'full_screen_custom_page.dart'; class CustomPage extends StatelessWidget { final Color pannelColor; final String title; final int pageCount; CustomPage( {@required this.pannelColor, @required this.title, @required this.pageCount}); @override Widget build(BuildContext context) { setChildContext(childContext: context); final titleTextStyle = Theme.of(context).textTheme.title; return Scaffold( appBar: AppBar(), body: Container( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 200, height: 200, decoration: BoxDecoration( color: pannelColor, borderRadius: BorderRadius.all(Radius.circular(20.0))), child: Center( child: Text( title + pageCount.toString(), style: TextStyle( fontSize: titleTextStyle.fontSize, color: titleTextStyle.color, ), ), ), ), TextButton( onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => CustomPage( pannelColor: pannelColor, title: title, pageCount: pageCount + 1, ), ), ); }, child: Text('下タブあり次画面')), TextButton( onPressed: () { Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (context) => FullScreenCustomPage( pannelColor: pannelColor, title: title, pageCount: pageCount + 1), ), ); }, child: Text('下タブなし次画面')), ], ), ), ), ); } }full_screen_custom_page.dartimport 'package:flutter/material.dart'; class FullScreenCustomPage extends StatelessWidget { final Color pannelColor; final String title; final int pageCount; FullScreenCustomPage( {@required this.pannelColor, @required this.title, @required this.pageCount}); @override Widget build(BuildContext context) { final titleTextStyle = Theme.of(context).textTheme.title; return Scaffold( appBar: AppBar(), body: Container( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 200, height: 200, decoration: BoxDecoration( color: pannelColor, borderRadius: BorderRadius.all(Radius.circular(20.0))), child: Center( child: Text( title + pageCount.toString(), style: TextStyle( fontSize: titleTextStyle.fontSize, color: titleTextStyle.color, ), ), ), ), TextButton( onPressed: () { Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (context) => FullScreenCustomPage( pannelColor: pannelColor, title: title, pageCount: pageCount + 1, ), ), ); }, child: Text('次画面へ')), ], ), ), ), ); } }
- 投稿日:2020-12-12T00:45:48+09:00
Realmの「逆方向の関連」を利用してTodoリストを作る
TableViewの勉強を進めていると、Sectionが複数になった途端コードが散らかってしまいませんか?
2次元配列にしたり、Sectionクラスを作ってプロパティとしてrow要素を組み込んだり方法は色々ありますが、Realm の逆方向の関連を使うと比較的綺麗にまとまったので紹介します。Sectionとrowだけでなく、一対多のネスト構造のもの全般に活躍できそうです。例えば、メインオブジェクトはゲームソフトだけど、新規作成はゲームの記録をメインとしたUIを想定している場合でも、Realmの逆方向の関連を活用すればUIに沿った形でコードが作成できるかと思います。
今回は、Realmの逆方向の関連を用いて、簡単なTodoアプリの新規作成機能を作りたいと思います。
環境
Swift 5.2
Xcode 11.4
Realm 10.4.0Realmのインストールが済んだ状態から進めます。
todo.swiftimport FonDation import RealmSwift class Section: Object { @objc dynamic var name: String = "" //Taskオブジェクトとの逆方向の関連を明示 let tasks = LinkingObjects(fromType: Task.self, property: "section") } class Task: Object { @objc dynamic var section: Section? @objc dynamic var name: String = "" }sectionに格納されるSectionクラスと、rowに格納されるTaskクラスは、一対多の関係なので、SectionクラスのプロパティにTaskをネストさせるのが自然です。が、どちらかというとSectionよりもTaskをメインオブジェクトとして扱いたいのと、後々検索やソートをかける時などTaskにSectionの情報が直接入っている方が便利な場合も多いため、TaskのプロパティにSectionを入れています。
新しく作ったTaskのsectionプロパティが既存のSectionオブジェクトと一致すると、自動でその配下に入る仕組みにより、SectionクラスとTaskクラスの一対多のネスト関係が成り立っています。これが逆方向の関連の強みの一つだと思います。新しく作ったTaskオブジェクトのsectionプロパティが既存のSectionオブジェクトと一致した場合に、自動でその配下へ追加される仕組みです。ViewControllerは例えばこのような感じになります。
viewController.swiftimport UIKit import RealmSwift class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! //既存のSectionを間違いなく選択できるようにする var pickerView: UIPickerView = UIPickerView() //テーブルビューの元の配列 var list: Results<Section>? //Realmオブジェクト作成 let realm = try!Realm() //タスク名を入れるテキストフィールド @IBOutlet weak var taskTextField: UITextField! //セクション名を入れるテキストフィールド @IBOutlet weak var SectionTextField: UITextField! override func viewDidLoad() { super.viewDidLoad() //Sectionオブジェクトを全て呼び出しlist配列に格納 list = realm.objects(Section.self) setUpTable() callPickerView() } //追加ボタン @IBAction func addButton(_ sender: Any) { do { try realm.write{ //セクションのテキストフィールドの文字を元にRealmから呼び出す let section = realm.objects(Section.self).filter("name = '\(SectionTextField.text ?? "")'").first //タスクの作成、呼び出したsectionを格納、なければ新しくインスタンス作成 let task = Task(value: ["name": taskTextField.text ?? "不明", "section": section ?? Section(value: ["name": SectionTextField.text ?? "不明"])]) //新規タスクを追加 realm.add(task) } }catch { } tableView.reloadData() } } extension ViewController { //tableViewのセットアップ func setUpTable(){ tableView.delegate = self tableView.dataSource = self tableView.register(UITableViewCell.self, forCellReuseIdentifier: "taskCell") tableView.reloadData() } //SectionのテキストフィールドをタップするとpickerViewが呼び出される func callPickerView() { pickerView.delegate = self pickerView.dataSource = self let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: 50)) let item1 = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) let flexibleItem = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) toolbar.setItems([item1, flexibleItem], animated: true) SectionTextField.inputView = pickerView SectionTextField.inputAccessoryView = toolbar } //PickerViewの完了ボタンをタップした時の処理 @objc func done() { if let list = list { SectionTextField.text = "\(list[pickerView.selectedRow(inComponent: 0)].name)"} else { return } SectionTextField.endEditing(true) } } extension ViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return list?[section].tasks.count ?? 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // guard let count = timeCardData?.count, indexPath.row < count else { return cell } let cell = tableView.dequeueReusableCell(withIdentifier: "taskCell", for: indexPath) cell.textLabel?.text = list?[indexPath.section].tasks[indexPath.row].name return cell } func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { guard let list = list else { return "no Section" } return list[section].name } func numberOfSections(in tableView: UITableView) -> Int { return list?.count ?? 0 } } extension ViewController: UITableViewDelegate { } extension ViewController: UIPickerViewDelegate { func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return list?.count ?? 0 } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return list?[row].name ?? "" } } extension ViewController: UIPickerViewDataSource { func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1 } }




