20200929のSwiftに関する記事は8件です。

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.Coordinator

View側から通知すべきイベントがある場合に実装します。
Coordinatorを定義することで、Delegateのようなユーザの操作によるイベントハンドリングを行うことができるようになります。

WKWebViewを使ってWebViewを作る

では本題のWKWebViewの表示に入っていきます。

WKWebViewを表示する

まずは単純にWebViewをSwiftUIで表示する方法です。
UIViewTypeassociatedtypeになりますので、ここをラップしたいUIKitのViewの型に変更します。
今回はWKWebViewにします。

WebView.swift
struct 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側で検知できるようにします。
この状態でツールバーのボタンをタップすると、アプリの状態を更新され、UIViewRepresentableupdateUIView(_ uiView:, context:)が発火します。
上記から、WebView側ではupdateUIView(_ uiView:, context:)の中にボタンに応じたアクションを記述します。

WebView.swift
struct 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.swift
struct 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.swift
struct 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に伝えるためのカスタムインスタンスを作成する必要があります。
WKNavigationDelegateCoordinatorに実装し、それを通してSwiftUI側のイベントハンドリングを行います。

WebView.swift
struct 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.swift
    Button("Back") { action = .goBack }.disabled(!canGoBack)
    Button("Forward") { action = .goForward }.disabled(!canGoForward)

プログレスバーを作成する

最後にプログレスバーを作成しましょう。

KVOで値を取ってくる

プログレスバーを作成するためにWKWebViewestimatedProgressisLoadingを取得する必要があります。
今回はそれぞれKVOを使用して取ってきます。
Viewからの変更通知を受け取ることになりますので、先ほども使用したCoordinatorを使用します。

WebView.swift
struct 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.swift
struct 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.swift
struct 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

また、今回の実装について改善案やご意見等ありましたらコメントいただけますと幸いです。
どうぞよろしくお願いいたします。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UIPasteboardの落とし穴

UIPasteboardの落とし穴

はじめに

UIPasteboardを使いたいと思って、UIPasteboardの使い方と落とし穴を紹介できればなと思います!

使い方

使い方はとっても簡単!!

Button(action: {
    UIPasteboard.general.string = "おはよう"
    }, label: {
        Text("コピー")
         .font(.headline)
    }
)

これで「おはよう」がボタンを押した際、クリップボードにコピーされます!!

落とし穴

一言で言えば、Binding型を入れてしまったことです。

スクリーンショット 2020-09-29 19.04.38.png

self.$recognizedTextだとBindingなので

Binding<String>(transaction: SwiftUI.Transaction(plist: []), location: SwiftUI.StoredLocation<Swift.String>, _value: "ullau8:4191%aaims2.g")

こんな感じで取得されちゃいます

この躓きを他の方に繰り返してほしくなくて、記事を書きました!

最後まで読んでいただき、ありがとうございます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.50.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.34.png
.banner Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.50.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.57.48.png
.list Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.58.49.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.34.png

実装

iOS 13以前も引き続き対応する場合、OSのバージョンで分岐させるのがベターです。

AppDelegate.swift
extension 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のサイレント通知のように使えたりと、使いこなせると便利そうです。

もし便利な使いみちがありましたら、コメントなどで教えていただけると嬉しいです :relaxed:

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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を追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct 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.swift
import 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.swift
import 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()
    }
}

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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を追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct 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.swift
import 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.swift
import 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()
    }
}

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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を追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct 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.swift
import 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.swift
import 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()
    }
}

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS14でのCore Location変更点

iOS14でのCore Location

こんにちは、今回は業務でiOS14で変更になったCore Location周りの改修をしたので、備忘録的に書かせていただきます。
あんまり記事がなかったので、誰かの役に立てば幸いです!

環境

macOS Catalina 10.15.6
Xcode Version 12.0.1

Deprecated

まず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

読んでいただきありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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に渡すと全く同じものを繰り返し配列に詰めてしまう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む