- 投稿日:2020-09-29T22:07:01+09:00
UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる
はじめに
iOS14時点でのSwiftUIでは現在、WKWebViewに相当するViewのサポートを行っておりません。
そのため、UIKitのWKWebViewをSwiftUI用にラップして使用することが必要となります。
今回は基本的な機能(ツールバー、プログレスバー)を搭載したWebViewを表示させることをゴールとし、その中で出てくるUIViewRepresentable
の使い方についてまとめていきたいと思います。※ 本記事では汎用性を考え、iOS13まででサポートされている技術を使用してWebViewの表示を行います。
UIViewRepresentableについて
UIKitのViewをSwiftUIで使用するには
UIViewRepresentable
を使用する必要があります。
UIViewRepresentable
とはSwiftUIにてUIKitのViewを使用するためのラッパーです。
UIViewRepresentable
のプロトコルで定義されている各関数について説明します。func makeUIView(context: Self.Context) -> Self.UIViewType実装必須。
表示するViewのインスタンスを生成します。
SwiftUIにて使用したUIKitのViewを戻り値として返却します。func updateUIView(Self.UIViewType, context: Self.Context)実装必須。
アプリの状態が更新される場合に呼ばれます。
Viewの更新がある場合は、本関数の中に記述します。static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)指定したUIKitのViewが削除される際に呼び出されます。
必要に応じて登録した通知の削除など、クリーンアップ処理を本関数内に記述します。func makeCoordinator() -> Self.CoordinatorView側から通知すべきイベントがある場合に実装します。
Coordinator
を定義することで、Delegateのようなユーザの操作によるイベントハンドリングを行うことができるようになります。WKWebViewを使ってWebViewを作る
では本題のWKWebViewの表示に入っていきます。
WKWebViewを表示する
まずは単純にWebViewをSwiftUIで表示する方法です。
UIViewType
はassociatedtype
になりますので、ここをラップしたいUIKitのViewの型に変更します。
今回はWKWebViewにします。WebView.swiftstruct WebView: UIViewRepresentable { /// 表示するView private let webView = WKWebView() /// 表示するURL let url: URL func makeUIView(context: Context) -> WKWebView { // 戻り値をWKWebViewとし、返却する webView.load(URLRequest(url: url)) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { } }ツールバーを作成する
次に
戻る
進む
リロード
の3つの要素を持ったツールバーを作成していきます。ボタンタップ時のアクションの制御
まずはツールバーを作成し、各ボタンを配置します。
次にProperty Wrappers
を使用して、ツールバーにて各ボタンがタップされた際にWebView側で検知できるようにします。
この状態でツールバーのボタンをタップすると、アプリの状態を更新され、UIViewRepresentable
のupdateUIView(_ uiView:, context:)
が発火します。
上記から、WebView側ではupdateUIView(_ uiView:, context:)
の中にボタンに応じたアクションを記述します。WebView.swiftstruct WebView: UIViewRepresentable { // 省略... /// WebViewのアクション enum Action { case none case goBack case goForward case reload } /// アクション @Binding var action: Action func updateUIView(_ uiView: WKWebView, context: Context) { /// バインドしている値が更新されるたびに呼ばれる /// actionが更新されたら、更新された値に応じて処理を行う switch action { case .goBack: uiView.goBack() case .goForward: uiView.goForward() case .reload: uiView.reload() case .none: break } action = .none } }WebToolBarView.swiftstruct WebToolBarView: View { /// アクション @Binding var action: WebView.Action var body: some View { VStack() { HStack() { // タップしたボタンに応じてアクションを更新 Button("Back") { action = .goBack } Button("Forward") { action = .goForward } Button("Reload") { action = .reload } } } } }RichWebView.swiftstruct RichWebView: View { /// URL let url: URL /// アクション @State private var action: WebView.Action = .none var body: some View { VStack() { WebView(url: url, action: $action) WebToolBarView(action: $action) } } }※
@State
や@Binding
などのProperty Wrappers
については、本題から逸れるためここでは解説しません。
詳しく解説されている記事が多く存在しますので、必要に応じて別途ご参照下さい。
State and Data Flow | Apple Developer Documentationボタンの非活性化
これで、WebViewでボタンタップ時に処理をさせることが可能になりました。
しかし、どんな状態でもボタンがタップできてしまいますので、前後のページに移動できない場合は各ボタンを非活性化させるようにします。Coordinatorを定義する
前後のページへ移動できるかどうかをWebViewのページ読み込みが完了したタイミングで判断するようにします。
そのためにはWKNavigationDelegate
を実装する必要がありますが、直接WebViewに実装することはできません。
そこでUIViewRepresentable
では、Coordinator
というUIKitのViewから受け取った変更をSwiftUIに伝えるためのカスタムインスタンスを作成する必要があります。
WKNavigationDelegate
はCoordinator
に実装し、それを通してSwiftUI側のイベントハンドリングを行います。WebView.swiftstruct WebView: UIViewRepresentable { // 省略... /// 戻れるか @Binding var canGoBack: Bool /// 進めるか @Binding var canGoForward: Bool func makeCoordinator() -> WebView.Coordinator { return Coordinator(parent: self) } } extension WebView { final class Coordinator: NSObject, WKNavigationDelegate { /// 親View let parent: WebView init(parent: WebView) { self.parent = parent } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { parent.canGoBack = webView.canGoBack parent.canGoForward = webView.canGoForward } } }あとは
RichWebView
を介してWebToolBarView
にて各値を受け取り、各ボタンの非活性、活性の処理を記述すれば完了です。WebToolBarView.swiftButton("Back") { action = .goBack }.disabled(!canGoBack) Button("Forward") { action = .goForward }.disabled(!canGoForward)プログレスバーを作成する
最後にプログレスバーを作成しましょう。
KVOで値を取ってくる
プログレスバーを作成するために
WKWebView
のestimatedProgress
とisLoading
を取得する必要があります。
今回はそれぞれKVOを使用して取ってきます。
Viewからの変更通知を受け取ることになりますので、先ほども使用したCoordinator
を使用します。WebView.swiftstruct WebView: UIViewRepresentable { // 省略... /// 読み込みの進捗状況 @Binding var estimatedProgress: Double /// ローディング中かどうか @Binding var isLoading: Bool static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { // WKWebView削除時に呼ばれます // インスタンスが削除されるタイミングで通知を無効化、削除しておきます coordinator.observations.forEach({ $0.invalidate() }) coordinator.observations.removeAll() } } extension WebView { final class Coordinator: NSObject, WKNavigationDelegate { /// 親View let parent: WebView /// NSKeyValueObservations var observations: [NSKeyValueObservation] = [] init(parent: WebView) { self.parent = parent // 通知を登録する let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in parent.estimatedProgress = value.newValue ?? 0 }) let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in parent.isLoading = value.newValue ?? false }) observations = [ progressObservation, isLoadingObservation ] } // 省略... } }プログレスバーを作成する
これでプログレスバーに必要なパーツは揃いました。
あとはProgressBarView.swift
を作成し、RichWebView
を介して値を受け取って表示すれば完了です。ProgressBarView.swiftstruct ProgressBarView: View { /// 読み込みの進捗状況 var estimatedProgress: Double var body: some View { VStack { GeometryReader { geometry in Rectangle() .foregroundColor(Color.gray) .opacity(0.3) .frame(width: geometry.size.width) Rectangle() .foregroundColor(Color.blue) .frame(width: geometry.size.width * CGFloat(estimatedProgress)) } }.frame(height: 3.0) } }RichWebView.swiftstruct RichWebView: View { // 省略... /// 読み込みの進捗状況 @State private var estimatedProgress: Double = 0.0 /// ローディング中かどうか @State private var isLoading: Bool = false var body: some View { VStack() { if isLoading { ProgressBarView(estimatedProgress: estimatedProgress) } // 省略... } } }これで一通りの機能を実装することができました。
エラーハンドリングなど別途考慮する点はありますが、一旦ざっくりとした機能を持ったWebViewを作成することができたのではないでしょうか。おわりに
今回、リッチなWebViewの表示を目指して実装を行いましたが、SwiftUIでWebViewを実装するためには
UIViewRepresentable
の基本的な機能を使用して作成する必要があるため、勉強にちょうど良いと思います。
興味ある方はぜひやってみてください。
ソースに関しては下記のgithubにまとめておきますので興味がある方がいらっしゃいましたら見ていただけると幸いです。
(github側はナビバーのタイトル表示や、レイアウトのための制約などここでは省いたコードがいくつか入っています。)
RichWebViewSampleまた、今回の実装について改善案やご意見等ありましたらコメントいただけますと幸いです。
どうぞよろしくお願いいたします。参考文献
- 投稿日:2020-09-29T19:12:58+09:00
UIPasteboardの落とし穴
UIPasteboardの落とし穴
はじめに
UIPasteboardを使いたいと思って、UIPasteboardの使い方と落とし穴を紹介できればなと思います!
使い方
使い方はとっても簡単!!
Button(action: { UIPasteboard.general.string = "おはよう" }, label: { Text("コピー") .font(.headline) } )これで「おはよう」がボタンを押した際、クリップボードにコピーされます!!
落とし穴
一言で言えば、Binding型を入れてしまったことです。
self.$recognizedTextだとBindingなので
Binding<String>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.StoredLocation<Swift.String>, _value: "ullau8:4191%aaims2.g")こんな感じで取得されちゃいます
この躓きを他の方に繰り返してほしくなくて、記事を書きました!
最後まで読んでいただき、ありがとうございます!
- 投稿日:2020-09-29T14:38:59+09:00
iOS 14からUNNotificationPresentationOptionsの「.alert」が「.banner」と「.list」に分かれた
はじめに
プッシュ通知の表示オプション(
UNNotificationPresentationOptions
)に.alert
がありますが、iOS 14までで非推奨になりました。
代わりに.banner
と.list
の2つに分かれ、これらの挙動を調べたので紹介します。環境
- Xcode:12.0 (12A7209)
- Swift:5.3
- iOS:14.0
プッシュ通知の挙動
以下の表の通りです。
.alert
ではバナーと通知センターの両方に通知が来ましたが、iOS 14からは.banner
と.list
を使うことでどちらか片方のみに通知が来るようにできます。
UNNotificationPresentationOptions バナー 通知センター .alert
.banner
.list
実装
iOS 13以前も引き続き対応する場合、OSのバージョンで分岐させるのがベターです。
AppDelegate.swiftextension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { if #available(iOS 14.0, *) { completionHandler([[.banner, .list, .sound]]) } else { completionHandler([[.alert, .sound]]) } } }おまけ:シミュレータでプッシュ通知を試す
iOSのシミュレータにプッシュ通知を送るのは、以下の記事が役立ちました。
https://qiita.com/koogawa/items/85c0dd0abd2f1970c5fcこちらの記事のおかげで、プッシュ通知をかんたんに送って試すことができました。
おわりに
iOS 14からは通知の表示を細かく制御できるようになったことがわかりました。
.list
のみ指定することでAndroidのサイレント通知のように使えたりと、使いこなせると便利そうです。もし便利な使いみちがありましたら、コメントなどで教えていただけると嬉しいです
参考リンク
- UNNotificationPresentationOptions | Apple Developer Documentation
.banner
と.list
が「No overview available.」でなければ本記事を書かなくて済んだのはナイショ- How to use `UNNotificationPresenta… | Apple Developer Forums
- UhooiPicBook/AppDelegate.swift at master · uhooi/UhooiPicBook
- 投稿日:2020-09-29T14:36:19+09:00
【Swift】WidgetKit使用時に、入力された文字をUserDefaults経由でWidgetに表示する方法
前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。環境
- Xcode: 12.0
- Swift5
下準備
※前提として既にTargetからWidgetKitが追加されていることとします
1. CapabilityからAppGroupを追加
TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
以下のようにAppGroupsの欄が追加されていればOK
2.AppGroupsに共通のIdentifierを登録する
a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する
group.{プロジェクト作成時のBundle Identifier}.xxxxx※注意
xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
3.WidgetExtension側にもAppGroupsを追加する
ほぼ手順は先ほど行った手順と同じです。
a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
以上でUserDefaultsを使用する下準備が終了です。
実装
今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する1.画面入力用のテキストフィールド、ボタンを配置する
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 }){ Text("文字を保存する") } } } }2.UserDefaultsに保存処理を追加
今回はボタン押下時にテキストフィールドの値を保存します
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } }){ Text("文字を保存する") } } } }このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。3.UserDefaultsから値取得処理
WidgetAppExtension.swift// struct Provider: TimelineProvider内 func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] /* 追記ここから */ var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } /* ここまで */ let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! let entry = SimpleEntry(date: entryDate) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) }値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。
4.WidgetにUserDefaultsから取得した値を表示する
前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。WidgetAppExtension.swiftimport WidgetKit import SwiftUI struct Provider: TimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), text: "") } func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), text: "") completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! // UserDefaultsから取得した文字列をセット let entry = SimpleEntry(date: entryDate, text: text) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } struct SimpleEntry: TimelineEntry { let date: Date let text: String } struct WidgetAppExtensionEntryView : View { var entry: Provider.Entry var body: some View { Text(entry.date, style: .time) Text(entry.text) } } @main struct WidgetAppExtension: Widget { let kind: String = "WidgetAppExtension" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in WidgetAppExtensionEntryView(entry: entry) } .configurationDisplayName("My Widget") .description("This is an example widget.") } } struct WidgetAppExtension_Previews: PreviewProvider { static var previews: some View { WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: "")) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。
ボタン押下後すぐにWidgetの表示内容を更新したい場合
ボタン押下時に以下処理を呼び出すことで更新が可能となります。
WidgetCenter.shared.reloadAllTimelines()ContentView.swiftimport SwiftUI import WidgetKit struct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 // AppGroups追加時に設定したIdentifier let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } // Widgetを更新 WidgetCenter.shared.reloadAllTimelines() }){ Text("文字を保存する") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }動作
このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。備考
アプリ公開しました!よろしければインストールお願いします。
とらんぽTwitter始めました!よろしければフォローお願いします。
@yajima_tohshu
- 投稿日:2020-09-29T14:36:19+09:00
【Swift】WidgetKit(SwiftUI)使用時にAppGroupsでUserDefaultsの値を共有する方法
前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。環境
- Xcode: 12.0
- Swift5
下準備
※前提として既にTargetからWidgetKitが追加されていることとします
1. CapabilityからAppGroupを追加
TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
以下のようにAppGroupsの欄が追加されていればOK
2.AppGroupsに共通のIdentifierを登録する
a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する
group.{プロジェクト作成時のBundle Identifier}.xxxxx※注意
xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
3.WidgetExtension側にもAppGroupsを追加する
ほぼ手順は先ほど行った手順と同じです。
a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
以上でUserDefaultsを使用する下準備が終了です。
実装
今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する1.画面入力用のテキストフィールド、ボタンを配置する
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 }){ Text("文字を保存する") } } } }2.UserDefaultsに保存処理を追加
今回はボタン押下時にテキストフィールドの値を保存します
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } }){ Text("文字を保存する") } } } }このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。3.UserDefaultsから値取得処理
WidgetAppExtension.swift// struct Provider: TimelineProvider内 func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] /* 追記ここから */ var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } /* ここまで */ let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! let entry = SimpleEntry(date: entryDate) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) }値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。
4.WidgetにUserDefaultsから取得した値を表示する
前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。WidgetAppExtension.swiftimport WidgetKit import SwiftUI struct Provider: TimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), text: "") } func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), text: "") completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! // UserDefaultsから取得した文字列をセット let entry = SimpleEntry(date: entryDate, text: text) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } struct SimpleEntry: TimelineEntry { let date: Date let text: String } struct WidgetAppExtensionEntryView : View { var entry: Provider.Entry var body: some View { Text(entry.date, style: .time) Text(entry.text) } } @main struct WidgetAppExtension: Widget { let kind: String = "WidgetAppExtension" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in WidgetAppExtensionEntryView(entry: entry) } .configurationDisplayName("My Widget") .description("This is an example widget.") } } struct WidgetAppExtension_Previews: PreviewProvider { static var previews: some View { WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: "")) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。
ボタン押下後すぐにWidgetの表示内容を更新したい場合
ボタン押下時に以下処理を呼び出すことで更新が可能となります。
WidgetCenter.shared.reloadAllTimelines()ContentView.swiftimport SwiftUI import WidgetKit struct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 // AppGroups追加時に設定したIdentifier let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } // Widgetを更新 WidgetCenter.shared.reloadAllTimelines() }){ Text("文字を保存する") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }動作
このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。備考
アプリ公開しました!よろしければインストールお願いします。
とらんぽTwitter始めました!よろしければフォローお願いします。
@yajima_tohshu
- 投稿日:2020-09-29T14:36:19+09:00
【Swift】iOS14で追加されたWidgetKit(SwiftUI)使用時に、AppGroupsでUserDefaultsの値を共有する方法
前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。環境
- Xcode: 12.0
- Swift5
下準備
※前提として既にTargetからWidgetKitが追加されていることとします
1. CapabilityからAppGroupを追加
TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
以下のようにAppGroupsの欄が追加されていればOK
2.AppGroupsに共通のIdentifierを登録する
a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する
group.{プロジェクト作成時のBundle Identifier}.xxxxx※注意
xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
3.WidgetExtension側にもAppGroupsを追加する
ほぼ手順は先ほど行った手順と同じです。
a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
以上でUserDefaultsを使用する下準備が終了です。
実装
今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する1.画面入力用のテキストフィールド、ボタンを配置する
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 }){ Text("文字を保存する") } } } }2.UserDefaultsに保存処理を追加
今回はボタン押下時にテキストフィールドの値を保存します
ContentView.swiftstruct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } }){ Text("文字を保存する") } } } }このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。3.UserDefaultsから値取得処理
WidgetAppExtension.swift// struct Provider: TimelineProvider内 func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] /* 追記ここから */ var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } /* ここまで */ let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! let entry = SimpleEntry(date: entryDate) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) }値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。
4.WidgetにUserDefaultsから取得した値を表示する
前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。WidgetAppExtension.swiftimport WidgetKit import SwiftUI struct Provider: TimelineProvider { func placeholder(in context: Context) -> SimpleEntry { SimpleEntry(date: Date(), text: "") } func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { let entry = SimpleEntry(date: Date(), text: "") completion(entry) } func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] var text = "" let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { text = userDefaults.string(forKey: "inputText") ?? "" } let currentDate = Date() for hourOffset in 0 ..< 5 { let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! // UserDefaultsから取得した文字列をセット let entry = SimpleEntry(date: entryDate, text: text) entries.append(entry) } let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } struct SimpleEntry: TimelineEntry { let date: Date let text: String } struct WidgetAppExtensionEntryView : View { var entry: Provider.Entry var body: some View { Text(entry.date, style: .time) Text(entry.text) } } @main struct WidgetAppExtension: Widget { let kind: String = "WidgetAppExtension" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in WidgetAppExtensionEntryView(entry: entry) } .configurationDisplayName("My Widget") .description("This is an example widget.") } } struct WidgetAppExtension_Previews: PreviewProvider { static var previews: some View { WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: "")) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。
ボタン押下後すぐにWidgetの表示内容を更新したい場合
ボタン押下時に以下処理を呼び出すことで更新が可能となります。
WidgetCenter.shared.reloadAllTimelines()ContentView.swiftimport SwiftUI import WidgetKit struct ContentView: View { @State private var text = "" var body: some View { VStack { TextField("文字入力", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(.horizontal) Button(action: { // ボタン押下時 // AppGroups追加時に設定したIdentifier let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension") if let userDefaults = userDefaults { userDefaults.synchronize() userDefaults.setValue(text, forKeyPath: "inputText") } // Widgetを更新 WidgetCenter.shared.reloadAllTimelines() }){ Text("文字を保存する") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }動作
このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。備考
アプリ公開しました!よろしければインストールお願いします。
とらんぽTwitter始めました!よろしければフォローお願いします。
@yajima_tohshu
- 投稿日:2020-09-29T06:07:08+09:00
iOS14でのCore Location変更点
iOS14でのCore Location
こんにちは、今回は業務でiOS14で変更になったCore Location周りの改修をしたので、備忘録的に書かせていただきます。
あんまり記事がなかったので、誰かの役に立てば幸いです!環境
macOS Catalina 10.15.6
Xcode Version 12.0.1Deprecated
まずDeprecatedになったものを調査しました。
対象は今回iOS14だと使えない物についてまとめます。CLLocationManager
Requesting Authorization for Location Services
- authorizationStatus()
class func authorizationStatus() -> CLAuthorizationStatus // 位置情報サービスを利用するためのアプリの認証状況を返します。Initiating Beacon Ranging Requests
func startRangingBeacons(in: CLBeaconRegion) // 指定されたビーコン領域の通知の配信を開始します。 func stopRangingBeacons(in: CLBeaconRegion) // 指定されたビーコン領域の通知の配信を停止します。 var rangedRegions: Set<CLRegion> // 指定されたビーコン領域の通知の配信を停止します。Deferring Location Updates
class func deferredLocationUpdatesAvailable() -> Bool // デバイスが遅延ロケーション更新をサポートするかどうかを示すブール値を返します。 func allowDeferredLocationUpdates(untilTraveled: CLLocationDistance, timeout: TimeInterval) // 指定された基準が満たされるまで、ロケーション更新の配信を延期するようにロケーションマネージャーに依頼します。 func disallowDeferredLocationUpdates() // このアプリの位置情報の更新の延期をキャンセルします。CLLocationManagerDelegate
Responding to Authorization Changes
- Deprecated
func locationManager(CLLocationManager, didChangeAuthorization: CLAuthorizationStatus) // アプリがロケーションマネージャーを作成したとき、および承認ステータスが変更されたときに、代理人に承認ステータスを通知します
- New
func locationManagerDidChangeAuthorization(CLLocationManager) // アプリがロケーション マネージャを作成したとき、および承認ステータスが変更されたときに、委任者に通知します。Responding to Ranging Events
- Deprecated
func locationManager(CLLocationManager, didRangeBeacons: [CLBeacon], in: CLBeaconRegion) // 1つ以上のビーコンが範囲内にあることをデリゲートに通知します。func locationManager(CLLocationManager, rangingBeaconsDidFailFor: CLBeaconRegion, withError: Error) // 一連のビーコンのレンジング情報の収集中にエラーが発生したことをデリゲートに通知します。ビーコン周りが結構変変更されてましたね。
余談ですが、
CLBeacon使ったことないし、何ができるかもわからんので、今度CLBeacon周り深く探ってみようかと思います。
登山とかするので、何かの役に立つかもしれないw本題
さてさて、ここからは本題ですが、
今回のアップデートで特に注目されていたところとしては、
位置情報の許可をするときに、
- 正確な位置情報
項目が追加さてたのが大きな変更かなと個人的には思っています。
なぜこうなったのかは、Appleさんがいろいろ考えてくれた感じなので、それは調べていただければ良いので、
今回はこの
- 正確な位置情報
に関して、Codeを踏まえて説明できたらと思います。実装
これまでよくあった実装としては
let status = CLLocationManager.authorizationStatus() switch status { case .authorizedWhenInUse: // ... case .authorizedAlways: // ... case .denied: // ... case .notDetermined: // ... case .restricted: // ... case .authorized: // ...こんな感じで statusごとの処置を書いていたと思いますが
'authorizationStatus()' was deprecated in iOS 14.0
@available(iOS, introduced: 4.2, deprecated: 14.0) open class func authorizationStatus() -> CLAuthorizationStatusとなっていたので、authorizationStatus()は使えません。
ただしpublic enum CLAuthorizationStatus : Int32 {}こちらは使えますので、間違えないようにしたいですね。
こちらの変更としては
open class CLLocationManager : NSObject { // ... @available(iOS 14.0, *) open var authorizationStatus: CLAuthorizationStatus { get } }CLLocationManager に authorizationStatus が新たに追加された形となりました。
ここ、ややこしいので、気をつけておいた方が良さそうです。
下記Codeは CLLocationManagerの主な変更部分です。
open class CLLocationManager : NSObject { // ... @available(iOS 14.0, *) open var authorizationStatus: CLAuthorizationStatus { get } @available(iOS, introduced: 4.2, deprecated: 14.0) open class func authorizationStatus() -> CLAuthorizationStatus @available(iOS 14.0, *) open var accuracyAuthorization: CLAccuracyAuthorization { get } // アプリが使用を許可されている位置精度のレベルを示す値。 @available(iOS 14.0, *) open var isAuthorizedForWidgetUpdates: Bool { get } // ウィジェットが位置の更新を受信する資格があるかどうかを示すブール値。 @available(iOS 14.0, *) open func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil) // 位置情報サービスを完全に正確に一時的に使用するためのユーザーの許可を要求します。 @available(iOS 14.0, *) open func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String) // 位置情報サービスを完全に正確に一時的に使用するためのユーザーの許可を要求します。 }ステータスの処理を分ける場合
func locationManager(CLLocationManager, didChangeAuthorization: CLAuthorizationStatus) {}
こちらがdeprecatedなので
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {}
を使って実装していきます。
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus switch status { case .authorizedAlways, .authorizedWhenInUse: // ... case .notDetermined, .denied, .restricted: // ... default: // ... } }このような形でstatusに対する処理を分岐する形となります。
次に正確な位置情報についてです
public enum CLAccuracyAuthorization : Int { case fullAccuracy = 0 // 正確な位置情報 ON case reducedAccuracy = 1 // 正確位置情報 OFF }こちらが新規追加されました。
switch manager.accuracyAuthorization { case .fullAccuracy: print("正確な位置情報") case .reducedAccuracy: print("曖昧な位置情報") default: }実装としてはこのような形で分岐することが可能です。
非エンジニアの方に今回の変更を説明する場合
reducedAccuracyを[曖昧な位置情報]という言い方で説明したところ、理解してくれました。
公式によると
(DeepL翻訳)// The user has chosen to grant this application access to location information with reduced accuracy.
// Region monitoring and beacon ranging are not available to the application. Other CoreLocation APIs
// are available with reduced accuracy.
// Location estimates will have a horizontalAccuracy on the order of about 5km. To achieve the
// reduction in accuracy, CoreLocation will snap location estimates to a nearby point which represents
// the region the device is in. Furthermore, CoreLocation will reduce the rate at which location
// estimates are produced. Applications should be prepared to receive locations that are up to 20
// minutes old.ユーザーはこのアプリケーションに、精度を落とした位置情報へのアクセスを許可することを選択しました。
地域モニタリングやビーコン測距はアプリケーションでは利用できません。 その他の CoreLocation API
は精度を落として利用できます。
位置推定の水平精度は約5kmのオーダーになります。 を達成するために
精度の低下を防ぐために、 // CoreLocation は位置推定値を近くの点にスナップします。
デバイスが存在する地域を指定します。 さらに、CoreLocation は、デバイスが存在する地域の
見積もりを作成しています。 アプリケーションは、20までの場所を受信するために準備してください。
分経過しています。こんな感じで書いてあります。
業務で修正した主なところとしては正確な位置情報を取得して、いろいろと使っていたので、今回はここの修正を主に行いました。
まとめ
今回はCoreLocationの変更された部分にフォーカスして書かせていただきました。
アプリによっていろんなパターンがあるので、一概に上記の対応だけでは望める実装ができない場合があるかと思いますので、私が参考にした記事を貼り付けておきます。iOS 14 でさらに強化された位置情報まわりのプライバシー
[iOS14]WWDC 2020 Core Location 新要素 Preciseについて上記の記事を参考にいたしました。
この記事では書いていない
- requestTemporaryFullAccuracyAuthorizationの使い方
- info.plistについて
- AppleのMapアプリに関すること
- 標準アプリの挙動など
- 他諸々
などが記載されていたので、まとめて読んでおくと良さそうに思いました。Apple公式
What's new in location
Core Location読んでいただきありがとうございました。
- 投稿日:2020-09-29T01:53:06+09:00
Swift:配列に初期要素を複数セットする時の罠
配列に初期要素を複数セットする方法はいくつかある。
class Hoge { var num: Int init(num: Int) { self.num = num } } // 方法1 var array1: [Hoge] = [ Hoge(num: 0), Hoge(num: 0), Hoge(num: 0) ] // 方法2 var array2 = [Hoge]() for _ in (0 ..< 3) { array2.append(Hoge(num: 0)) } // 方法3 var array3: [Hoge] = (0 ..< 3).map({ _ -> Hoge in return Hoge(num: 0) }) // 方法4 var array4 = [Hoge](repeating: Hoge(num: 0), count: 3) // 他にもあるかもここで方法4に注目すると、repeatingに初期要素、countに要素数を渡すことでいい感じに初期化ができそうだが、他の3つの方法と違う点がある。
// 方法1で要素同士を比較してみる print(array1[0] === array1[1]) // -> false print(array1[1] === array1[2]) // -> false // 結果は方法2,3も同様 // 方法4で要素同士を比較してみる print(array4[0] === array4[1]) // -> true print(array4[1] === array4[2]) // -> true方法4では全て同じオブジェクトを指すことになっている。classのインスタンスをrepeatingに渡すと全く同じものを繰り返し配列に詰めてしまう。