20220131のSwiftに関する記事は13件です。

【Swift】 String部分参照の処理性能に注意

まずは、以下のコードと処理時間を見てもらいたい 10万文字の文字列を1文字づつアクセスすると、 コード1 let string = String(repeating: "0123456789", count: 10000) let length = string.count print("length: \(length)") extension String { subscript(at: Int) -> Character { self[self.index(self.startIndex, offsetBy: at)] } } let t1 = measureTimeSeconds { for n in 0 ..< length { let ch = string[n] // nop } } print("subscript:", t1 * 1000, "ms") な なんと47秒超え。あまりの遅さに絶句。これに気付かず何度もTLE(Time Limit Exceeded)を喰らった. extensionやめて直接書いてもほとんど変わらない。 結果1 length: 100000 subscript: 47260.40498 ms 下記の通りmeasureTimeSecondsはblockの処理時間を秒で返す関数。 共通コード func measureTimeSeconds(block: (() -> Void)) -> Double { let startTime = Date() block() let duration = -startTime.timeIntervalSinceNow return duration } measureTimeSeconds自身のオーバーヘッドは約30ナノ秒と無視できる性能。 100万回の平均値 var total = 0.0 for _ in 0 ..< 1000000 { let t = measureTimeSeconds { // nop } total += t } print("measureTimeSeconds:", total * 1000 * 1000 * 1000 / 1000000, "ns") //-> measureTimeSeconds: 30.085206031799316 ns では、一旦、配列に変換したらどの程度か? コード2 let t2 = measureTimeSeconds { let array = string.map { $0 } for n in 0 ..< length { let ch = array[n] // nop } } print("array:", t2 * 1000, "ms") 配列への変換を含めて、たったの3.8ミリ秒。この差(上との差)は何だ! 結果2 array: 3.83301576 ms for-inはどの程度か? これまでの例のように、文字列を先頭から末尾に向かって(もしくは、末尾から先頭に向かって)順に走査するだけなら、for-in(for-in loop to iterate over a sequence)でよい。この場合の性能はどうか? コード3 let t3 = measureTimeSeconds { for ch in string { // nop } } print("for-in:", t3 * 1000, "ms") let t4 = measureTimeSeconds { for ch in string.reversed() { // nop } } print("for-in (reversed):", t4 * 1000, "ms") 1.7ミリ秒、さすがに速い。 リバースでも2.1ミリ秒と高速。 結果3 for-in: 1.657684644 ms for-in (reversed): 2.124031385 ms 今回の教訓 StringをCやC++の様に文字の配列としてアクセスすると、とんでもなく時間を要す Stringを先頭から末尾に向かって(もしくは、末尾から先頭に向かって)順に走査するだけならfor-in(for-in loop to iterate over a sequence)を使う 何度もアクセスしたり、一様なアクセスでは無い場合は、Arrayに変換してからアクセスする 部分文字列(Substring)も同様に遅いと考えられる 計測条件 PC : MacBook Pro 2019(Intel Core i5 2.4GHz) OS : macOS 12.1 (21C52) Swift : Version 5.5.2 (swiftlang-1300.0.47.5 clang-1300.0.29.30) コンパイル : swiftc -Ounchecked test.swift -o a.out 実行 : ./a.out 3回実行した平均値 追記(遅い理由を探ってみた) String[String.index(String.startIndex, offsetBy: at)]は、もしかして、offsetByの位置まで毎回先頭から走査しているのでは? アクセス位置を10倍づつずらしてアクセスを繰り返してみると、 let string = String(repeating: "0123456789", count: 10001) let length = string.count print("length: \(length)") extension String { subscript(at: Int) -> Character { self[self.index(self.startIndex, offsetBy: at)] } } for at in [1, 10, 100, 1000, 10000, 100000] { let t = measureTimeSeconds { for n in 0 ..< 10000 { let ch = string[at] // nop } } print("at:", at, t * 1000, "ms / 10000") } 予想通り、ほぼ1桁づつ処理時間が伸びていく。 結果 length: 100010 at: 1 0.30791759490966797 ms / 10000 at: 10 1.425027847290039 ms / 10000 at: 100 11.00003719329834 ms / 10000 at: 1000 101.6700267791748 ms / 10000 at: 10000 1001.6210079193115 ms / 10000 at: 100000 9921.151041984558 ms / 10000 内部はUnicodeでマルチバイト文字のため、アクセス位置を直接計算では求められないと想定した。まあ確かにそうだ。 競プロではArrayに変換してから使うのが正解だな。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

50行で目線トラッキング(iPhone)

はじめに こちらの記事を見て目線トラッキングが面白そうだなと思いました。 目線でブラウザをスクロールするアプリを作ってみた 調べてみると 50 行くらいで下記ができました(動作確認は iOS 15.1 の iPhone 12 mini)。 目線トラッキングができてるようなできてないような?コード量50行! pic.twitter.com/6uiApUJnPn— am10 (@am103141592) January 28, 2022 実装 とりあえずソース。 import UIKit import ARKit final class ViewController: UIViewController { private let session = ARSession() private var lookAtPointView: UIImageView = { let image = UIImageView(image: .init(systemName: "eye")) image.frame = .init(origin: .zero, size: CGSize(width: 30, height: 30)) image.contentMode = .scaleAspectFit return image }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(lookAtPointView) session.delegate = self } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let configuration = ARFaceTrackingConfiguration() configuration.isLightEstimationEnabled = true session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) } } extension ViewController: ARSessionDelegate { func session(_ session: ARSession, didUpdate frame: ARFrame) { guard let faceAnchor = frame.anchors.first(where: { $0 is ARFaceAnchor }) as? ARFaceAnchor else { return } let lookingPoint = frame.camera.projectPoint(faceAnchor.lookAtPoint, orientation: .portrait, viewportSize: view.bounds.size) DispatchQueue.main.async { self.lookAtPointView.center = lookingPoint } } } これだけで目線トラッキングぽいことができます 必要条件 ARKit の ARFaceTrackingConfiguration を利用するので TrueDepth カメラを搭載した端末(iPhone X 以降?)が必要です。 ドキュメント:ARFaceTrackingConfiguration やりたかったこと ピアノ演奏中の楽譜への目線とか本読むのが速い人の目線の動きとかを iPhone で見れたらおもしろいと思った。 が、厳密に目線を取得はできなかったのであきらめました。。。(どちらかというと顔の向きの取得?) ドキュメント:ARFaceAnchor つくったもの 上記のものは作れなさそうでしたが目線トラッキングはおもしろいので何か他のものを作れないかと思い色々考えてみました。 調べてみると ARFaceAnchor.BlendShapeLocation で左右の瞬きなどが取れそうです。 ドキュメント:ARFaceAnchor.BlendShapeLocation 1つ目 瞬きで撃つシューティングゲーム(音声あり)。 やりたいことできなそうだったから路線変更して瞬きで撃つシューティングつくった? pic.twitter.com/nDSIIEMBz3— am10 (@am103141592) January 29, 2022 2つ目 目線をマウスカーソル瞬きをクリックに対応させた PC のような操作ができるなにか。 目線をマウスカーソルまばたきをクリックに対応させることでiPhoneでPCのような操作を実現? pic.twitter.com/9KFblAJ5bp— am10 (@am103141592) January 30, 2022 おわりに 目線トラッキングおもしろそうなのでなにか使えそうなのですが私の発想ではなかなかむずかしかったです。 なにかに使えそう 参考 目線でブラウザをスクロールするアプリを作ってみた ドキュメント:ARFaceTrackingConfiguration ドキュメント:ARFaceAnchor
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

macOSアプリでLaunchAtLoginの実装

LaunchAtLoginの実装 概要 アプリでよくあるLaunch at Loginの実装を行う。 LaunchAtLoginの仕組みとしてはざっくり以下の通り。 起動したいアプリ(以下メインアプリ)にヘルパーアプリを内包させる。 ヘルパーアプリをユーザログイン時に起動するよう登録。 (ユーザログイン時)ヘルパーアプリからメインアプリを起動させ、ヘルパーアプリを終了させる。 GitHub qiita-launch-at-login-demo 参考 How to launch a macOS app at login? 主に参考にした。ただ記事のが古いのかいくつか手直しが必要。 例えばlaunchApplication(_:)はDeprecatedなのでNSWorkspace.shared.urlForApplication()で置き換える。 上記の記事である@NSApplicationMainを記述せずに、main.swiftを作るとうまくいく。 Is there a way to delete Main.storyboard in SwiftUI macOS project? Swift: macOSでLaunch at loginを実装する 同じ方向性。アプリの起動のコードを参考にしました。 https://github.com/Kyome22/ShiftWindow NSWorkspace.shared.urlForApplication()で本体アプリのファイルパスを取得する。 ライブラリもあるけど現在はCarthageでしか使えない?SPMで入れてみるもうまく動かず。 sindresorhus/LaunchAtLogin 1024jp/Preferences-Demo UserDefaultsを使ったCocoaBindingを参考にしました。 プロジェクト設定 メインアプリ メインのアプリの作成 以下からヘルパーアプリを作成 メインアプリにヘルパーアプリを内包させる設定 ServiceManagement.frameworkを追加 ヘルパーアプリ Build SettingsのSkip InstallをYES Info.plistにApplication is background onlyを追加しValueをYESにする ヘルパーアプリ側のUIがいらないので下記を削除 Main storyboard file base nameのMainを空にする 実装 メインアプリ LauncherConst.swift ヘルパーアプリのIdentifierを複数箇所で使用するので下記の通り宣言しておく。 import Foundation struct LauncherConst { static let launcherAppId = "com.gmail.ikeh1024.MainApplicationLauncher" } AppDelegate.swift 起動時にヘルパーアプリが起動している場合に、ヘルパーアプリを終了させるようにする。 DistributedNotificationCenterで異なるアプリ間でやりとりできる。 import Cocoa import ServiceManagement extension Notification.Name { static let killLauncher = Notification.Name("killLauncher") } @main class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ aNotification: Notification) { // ヘルパーが起動していたら終了させる let runningApps = NSWorkspace.shared.runningApplications let isRunning = !runningApps.filter { $0.bundleIdentifier == LauncherConst.launcherAppId }.isEmpty if isRunning { DistributedNotificationCenter.default().post(name: .killLauncher, object: Bundle.main.bundleIdentifier!) } } ... } ViewContorller.swift 下記コードはチェックボックスを選択したときのアクション。 SMLoginItemSetEnabled(_:_:)でヘルパーアプリのIdentifierを登録することで、ログイン時にヘルパーアプリが起動される。 @IBAction func launchAtLoginCheckboxClicked(_ sender: NSButton) { SMLoginItemSetEnabled(LauncherConst.launcherAppId as CFString, GeneralPreferences.shared.launchAtLogin.isOn) } Launch at Loginのチェックボックスは、CocoaBindingを使い、UserDefaultsのlaunchAtLoginをキーとして同期させている。 ヘルパーアプリ main.swift エントリーポイントとしての実装 import AppKit let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.run() AppDelegate.swift メインアプリの起動に関する設定を諸々行う import Cocoa extension Notification.Name { static let killLauncher = Notification.Name("killLauncher") } class AppDelegate: NSObject { @objc func terminate() { NSApp.terminate(nil) } } extension AppDelegate: NSApplicationDelegate { func applicationDidFinishLaunching(_ aNotification: Notification) { // メインアプリの起動確認 let mainAppIdentifier = "com.gmail.ikeh1024.MainApplication" let runningApps = NSWorkspace.shared.runningApplications let isRunning = !runningApps.filter { $0.bundleIdentifier == mainAppIdentifier }.isEmpty if !isRunning { // メインアプリ起動時に終了させる通知を受け取るための設定 DistributedNotificationCenter .default() .addObserver(self, selector: #selector(self.terminate), name: .killLauncher, object: mainAppIdentifier) // メインアプリを起動する if !isRunning, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: mainAppIdentifier) { let config = NSWorkspace.OpenConfiguration() NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in } } } else { // メインアプリが起動していれば何もせずに終了 self.terminate() } } } ヘルパーアプリが登録されているかの確認方法 SMCopyAllJobDictionaries(_:)でヘルパーアプリがログイン時に起動するよう登録されているか確認できる。 Login Item - cocoaを参考にした。 @IBAction func checkSMLoginButtonClicked(_ sender: Any) { let jobDicts = SMCopyAllJobDictionaries(kSMDomainUserLaunchd).takeRetainedValue() as NSArray as! [[String:AnyObject]] let jobEnabled = jobDicts.filter { $0["Label"] as! String == LauncherConst.launcherAppId }.isEmpty == false print("SMLogin: \(jobEnabled)") } SMCopyAllJobDictionaries(_:)はDeprecatedだが、代替ができるまではこれしか無い?みたい。 SMLoginItemSetEnabled(...) GET counterpart On SMCopyAllJobDictionaries inside For the specific use of testing the state of a login item that may have been enabled with SMLoginItemSetEnabled() in order to show that state to the user, this function remains the recommended API. A replacement API for this specific use will be provided before this function is removed. We can safely use the method until further notice. Just be sure to check the new SDK releases/updates for the new API and this method's obsolescence. – Nogurenn Feb 7 '19 at 8:15 実際にXcode上で覗いてみるとlibxpcで置き換わる予定と記載があった。 @available(macOS, introduced: 10.6, deprecated: 10.10) public func SMCopyAllJobDictionaries(_ domain: CFString!) -> Unmanaged<CFArray>! /** (省略) * @discussion * This routine is deprecated and will be removed in a future release. A * replacement will be provided by libxpc. */
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリの検索ビューにおけるタグの追加

iOSの検索ビュー内でタグを追加し、検索対象のカテゴリーを指定することができます。 このタグは検索トークンと呼ばれるものです。 ここでは、検索トークンを作成し、それを検索バーに追加する方法を説明します。 さらに、SwiftUIアプリに検索トークンを使った検索バーを加えることについても説明します。 サーチトークンの作成 UISearchTokenプロパティを使用し、サーチトークンを作成することができます。このオブジェクトは、UIImageアイコンやタイトルで初期化することができます。 Apple Developer Documentation また、トークンの識別子を指定する必要があります(representedObjectプロパティを使用)。この変数はどのような型でもよく、コードで自分が参照するために使います。一般的に、この変数には文字列の識別子を設定します。 例えば、ユーザーがSwiftUIについての記事を検索している場合、次のようにUISearchTokenオブジェクトを初期化することができます: var token = UISearchToken(icon: UIImage(systemName: "swift"), text: "SwiftUI") token.representedObject = "#swiftui" 識別子は検索トークンから後で読み取ることができます。 if let identifier = token.representedObject as? String { print(identifier) } 検索トークンを検索バーに追加する UISearchControllerでは、検索トークンを設定できます。 searchController.searchBar.searchTextField.tokens = searchTokens また、検索トークンを追加または削除したり、テキスト内にあるトークンの位置を取得したり、指定されたテキスト位置の間にある全トークンを取得したりするなどの追加機能もあります。 open var tokens: [UISearchToken] open func insertToken(_ token: UISearchToken, at tokenIndex: Int) open func removeToken(at tokenIndex: Int) open func positionOfToken(at tokenIndex: Int) -> UITextPosition open func tokens(in textRange: UITextRange) -> [UISearchToken] UIKitアプリに検索トークンを追加する UIKitアプリに検索トークンを追加するのは簡単です。UISearchControllerを作成し、searchController.searchBar.searchTextField.tokens変数を使用して検索トークンをそのビューコントローラに割り当てるだけです。 SwiftUIアプリに検索トークンを追加する SwiftUIには、新たな.searchableビューモディファイアがあります。しかし、このモディファイアは検索トークンの追加をサポートしていません。そのため、UIViewControllerRepresentable構造を実装する必要があります。 UIKitコンポーネントをSwiftUIのナビゲーションバーに追加する 検索バー(UIKitコンポーネント)を単に表示するだけではなく、SwiftUIのナビゲーションバーに直接追加したいと思います。 class NavBarEmbeddedSearch: UIViewController { let searchController = UISearchController() override func viewDidLoad() { searchController.hidesNavigationBarDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = false } override func viewWillAppear(_ animated: Bool) { self.parent?.navigationItem.searchController = searchController } } viewDidLoad関数内では、検索バーのUIに関する基本的な設定を行います。 このビューコントローラが画面に表示されるとき(viewWillAppearが呼び出されるとき)、検索コントローラが親ナビゲーションバー(SwiftUIが管理するもの)に割り当てられます。 SearchBar(サーチバー)構造体の作成 次に、SearchBarの基本構造を作成します。これはタイプUIViewControllerRepresentableに準拠するもので、UIKitをSwiftUIに移植する(利用できるようにする)ことを意味します。 fileprivate struct SearchBar: UIViewControllerRepresentable { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { // TODO 1 } func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(searchText: $searchText, searchTokens: $searchTokens) } class Coordinator: NSObject, UISearchResultsUpdating { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func updateSearchResults(for searchController: UISearchController) { // TODO 2 } } } 上記のコードでご覧のように、SearchBar構造体には検索テキストのバインディング変数と検索トークンが格納されることになります。 また、デリゲートとして動作するCoordinatorクラスもあり、検索バーから更新情報を受け取ります(ユーザーが検索テキストを入力したときやユーザーが検索トークンを削除したときなど)。 検索バーの初期化 上記のコードの// TODO 1で、検索コントローラーの初期化を行います。 func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { let controller = NavBarEmbeddedSearch() controller.searchController.searchBar.searchTextField.tokens = searchTokens controller.searchController.searchResultsUpdater = context.coordinator return controller } ここでは、サーチトークンに提供された値を設定します。 また、検索結果アップデータsearchResultsUpdater変数には、作成したCoordinatorを設定します。これにより、検索文字が変更されたことがわかるようになります。 検索文字の変更でSwiftUIのバインディング変数を更新する 検索文字に変更があった場合、Coordinatorオブジェクト内のfunc updateSearchResults(for searchController: UISearchController)関数が呼び出されます。 上記のコードの// TODO 2コードブロックの中で、更新された検索文字でSwiftUIのバインディング変数を更新します。 guard let text = searchController.searchBar.text else { return } self.searchText = text self.searchTokens = searchController.searchBar.searchTextField.tokens 完成したコード こちらが、完成したSearchBarのコードです class NavBarEmbeddedSearch: UIViewController { let searchController = UISearchController() override func viewDidLoad() { searchController.hidesNavigationBarDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = false } override func viewWillAppear(_ animated: Bool) { self.parent?.navigationItem.searchController = searchController } } fileprivate struct SearchBar: UIViewControllerRepresentable { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { let controller = NavBarEmbeddedSearch() controller.searchController.searchResultsUpdater = context.coordinator controller.searchController.searchBar.searchTextField.tokens = searchTokens return controller } func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(searchText: $searchText, searchTokens: $searchTokens) } class Coordinator: NSObject, UISearchResultsUpdating { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func updateSearchResults(for searchController: UISearchController) { guard let text = searchController.searchBar.text else { return } self.searchText = text self.searchTokens = searchController.searchBar.searchTextField.tokens } } } SwiftUIビュー内に検索バーを設置する ここでは、SwiftUIビューのナビゲーションバーに検索バーを追加します。 まずは、SwiftUIビューがナビゲーションビュー内に埋め込まれていることを確認します。 検索コントローラーは、ナビゲーションバーにプログラムで直接割り当てるため、検索バーをビューコンポーネントにする必要はありません。従って、overlayビューモディファイアを利用することで、そのフレームを0に設定します。 struct ContentView: View { /// Variables used by `SearchView` @State private var searchText: String = "" @State private var searchTokens: [UISearchToken] init(searchTokens: [UISearchToken]) { self._searchTokens = .init(initialValue: searchTokens) } var body: some View { Form { Section { Text("Search term: \(searchText)") Text("Search tokens: \(searchTokens.getTokenNames().description)") } }.overlay( SearchBar(searchText: $searchText, searchTokens: $searchTokens).frame(width: 0, height: 0) ) } } 上の例では、入力された検索テキストとトークンを表示しているだけです。ご自分のアプリケーションでは、検索ワードが変わるたびに検索を実行する必要があります。 上記のSwiftUIビューを使う これで、上記のSwiftUIビューを使って、タグと共に検索バーを表示できるようになりました。 struct MenuView: View { var body: some View { List { NavigationLink("Movies") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "play.rectangle.on.rectangle.fill"), text: "Movies").getTokenWithIdentifier("movies")]) } NavigationLink("Music") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "music.note"), text: "Music").getTokenWithIdentifier("music")]) } NavigationLink("Documents") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "doc"), text: "Documents").getTokenWithIdentifier("documents")]) } } .navigationTitle("Search demo") .navigationBarTitleDisplayMode(.inline) } } 上の例では、ユーザーは、映画、歌、文書のいずれかの検索を選択できます。ユーザーが検索しているカテゴリーは、タグ(検索トークン)として表示されます。 完成したプロジェクト 完成したプロジェクトのコードをこちらからご覧いただけます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSアプリの検索ビューにおけるタグ(UISearchToken)の追加

iOSの検索ビュー内でタグを追加し、検索対象のカテゴリーを指定することができます。 このタグは検索トークンと呼ばれるものです。 ここでは、検索トークンを作成し、それを検索バーに追加する方法を説明します。 さらに、SwiftUIアプリに検索トークンを使った検索バーを加えることについても説明します。 サーチトークンの作成 UISearchTokenプロパティを使用し、サーチトークンを作成することができます。このオブジェクトは、UIImageアイコンやタイトルで初期化することができます。 Apple Developer Documentation また、トークンの識別子を指定する必要があります(representedObjectプロパティを使用)。この変数はどのような型でもよく、コードで自分が参照するために使います。一般的に、この変数には文字列の識別子を設定します。 例えば、ユーザーがSwiftUIについての記事を検索している場合、次のようにUISearchTokenオブジェクトを初期化することができます: var token = UISearchToken(icon: UIImage(systemName: "swift"), text: "SwiftUI") token.representedObject = "#swiftui" 識別子は検索トークンから後で読み取ることができます。 if let identifier = token.representedObject as? String { print(identifier) } 検索トークンを検索バーに追加する UISearchControllerでは、検索トークンを設定できます。 searchController.searchBar.searchTextField.tokens = searchTokens また、検索トークンを追加または削除したり、テキスト内にあるトークンの位置を取得したり、指定されたテキスト位置の間にある全トークンを取得したりするなどの追加機能もあります。 open var tokens: [UISearchToken] open func insertToken(_ token: UISearchToken, at tokenIndex: Int) open func removeToken(at tokenIndex: Int) open func positionOfToken(at tokenIndex: Int) -> UITextPosition open func tokens(in textRange: UITextRange) -> [UISearchToken] UIKitアプリに検索トークンを追加する UIKitアプリに検索トークンを追加するのは簡単です。UISearchControllerを作成し、searchController.searchBar.searchTextField.tokens変数を使用して検索トークンをそのビューコントローラに割り当てるだけです。 SwiftUIアプリに検索トークンを追加する SwiftUIには、新たな.searchableビューモディファイアがあります。しかし、このモディファイアは検索トークンの追加をサポートしていません。そのため、UIViewControllerRepresentable構造を実装する必要があります。 UIKitコンポーネントをSwiftUIのナビゲーションバーに追加する 検索バー(UIKitコンポーネント)を単に表示するだけではなく、SwiftUIのナビゲーションバーに直接追加したいと思います。 class NavBarEmbeddedSearch: UIViewController { let searchController = UISearchController() override func viewDidLoad() { searchController.hidesNavigationBarDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = false } override func viewWillAppear(_ animated: Bool) { self.parent?.navigationItem.searchController = searchController } } viewDidLoad関数内では、検索バーのUIに関する基本的な設定を行います。 このビューコントローラが画面に表示されるとき(viewWillAppearが呼び出されるとき)、検索コントローラが親ナビゲーションバー(SwiftUIが管理するもの)に割り当てられます。 SearchBar(サーチバー)構造体の作成 次に、SearchBarの基本構造を作成します。これはタイプUIViewControllerRepresentableに準拠するもので、UIKitをSwiftUIに移植する(利用できるようにする)ことを意味します。 fileprivate struct SearchBar: UIViewControllerRepresentable { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { // TODO 1 } func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(searchText: $searchText, searchTokens: $searchTokens) } class Coordinator: NSObject, UISearchResultsUpdating { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func updateSearchResults(for searchController: UISearchController) { // TODO 2 } } } 上記のコードでご覧のように、SearchBar構造体には検索テキストのバインディング変数と検索トークンが格納されることになります。 また、デリゲートとして動作するCoordinatorクラスもあり、検索バーから更新情報を受け取ります(ユーザーが検索テキストを入力したときやユーザーが検索トークンを削除したときなど)。 検索バーの初期化 上記のコードの// TODO 1で、検索コントローラーの初期化を行います。 func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { let controller = NavBarEmbeddedSearch() controller.searchController.searchBar.searchTextField.tokens = searchTokens controller.searchController.searchResultsUpdater = context.coordinator return controller } ここでは、サーチトークンに提供された値を設定します。 また、検索結果アップデータsearchResultsUpdater変数には、作成したCoordinatorを設定します。これにより、検索文字が変更されたことがわかるようになります。 検索文字の変更でSwiftUIのバインディング変数を更新する 検索文字に変更があった場合、Coordinatorオブジェクト内のfunc updateSearchResults(for searchController: UISearchController)関数が呼び出されます。 上記のコードの// TODO 2コードブロックの中で、更新された検索文字でSwiftUIのバインディング変数を更新します。 guard let text = searchController.searchBar.text else { return } self.searchText = text self.searchTokens = searchController.searchBar.searchTextField.tokens 完成したコード こちらが、完成したSearchBarのコードです class NavBarEmbeddedSearch: UIViewController { let searchController = UISearchController() override func viewDidLoad() { searchController.hidesNavigationBarDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = false } override func viewWillAppear(_ animated: Bool) { self.parent?.navigationItem.searchController = searchController } } fileprivate struct SearchBar: UIViewControllerRepresentable { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func makeUIViewController(context: Context) -> NavBarEmbeddedSearch { let controller = NavBarEmbeddedSearch() controller.searchController.searchResultsUpdater = context.coordinator controller.searchController.searchBar.searchTextField.tokens = searchTokens return controller } func updateUIViewController(_ controller: NavBarEmbeddedSearch, context: Context) { } func makeCoordinator() -> Coordinator { return Coordinator(searchText: $searchText, searchTokens: $searchTokens) } class Coordinator: NSObject, UISearchResultsUpdating { @Binding var searchText: String @Binding var searchTokens: [UISearchToken] init(searchText: Binding<String>, searchTokens: Binding<[UISearchToken]>) { self._searchText = searchText self._searchTokens = searchTokens } func updateSearchResults(for searchController: UISearchController) { guard let text = searchController.searchBar.text else { return } self.searchText = text self.searchTokens = searchController.searchBar.searchTextField.tokens } } } SwiftUIビュー内に検索バーを設置する ここでは、SwiftUIビューのナビゲーションバーに検索バーを追加します。 まずは、SwiftUIビューがナビゲーションビュー内に埋め込まれていることを確認します。 検索コントローラーは、ナビゲーションバーにプログラムで直接割り当てるため、検索バーをビューコンポーネントにする必要はありません。従って、overlayビューモディファイアを利用することで、そのフレームを0に設定します。 struct ContentView: View { /// Variables used by `SearchView` @State private var searchText: String = "" @State private var searchTokens: [UISearchToken] init(searchTokens: [UISearchToken]) { self._searchTokens = .init(initialValue: searchTokens) } var body: some View { Form { Section { Text("Search term: \(searchText)") Text("Search tokens: \(searchTokens.getTokenNames().description)") } }.overlay( SearchBar(searchText: $searchText, searchTokens: $searchTokens).frame(width: 0, height: 0) ) } } 上の例では、入力された検索テキストとトークンを表示しているだけです。ご自分のアプリケーションでは、検索ワードが変わるたびに検索を実行する必要があります。 上記のSwiftUIビューを使う これで、上記のSwiftUIビューを使って、タグと共に検索バーを表示できるようになりました。 struct MenuView: View { var body: some View { List { NavigationLink("Movies") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "play.rectangle.on.rectangle.fill"), text: "Movies").getTokenWithIdentifier("movies")]) } NavigationLink("Music") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "music.note"), text: "Music").getTokenWithIdentifier("music")]) } NavigationLink("Documents") { ContentView(searchTokens: [.init(icon: UIImage(systemName: "doc"), text: "Documents").getTokenWithIdentifier("documents")]) } } .navigationTitle("Search demo") .navigationBarTitleDisplayMode(.inline) } } 上の例では、ユーザーは、映画、歌、文書のいずれかの検索を選択できます。ユーザーが検索しているカテゴリーは、タグ(検索トークン)として表示されます。 完成したプロジェクト 完成したプロジェクトのコードをこちらからご覧いただけます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UITextViewに入力されている文字数をカウントする

TextViewに入力されている文字数のリアルタイムカウントを実装します。 文字列中の全ての空白や改行はカウントしません。 環境 Xcode 13.2.1 Swift 5.5.2 手順 StringProtocolを拡張します。 Extension.swift extension StringProtocol where Self: RangeReplaceableCollection { var removeWhitespacesAndNewlines: Self { filter { !$0.isNewline && !$0.isWhitespace} } } 使用例 ViewController.swift extension ViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { let textLength = textView.text.removeWhitespacesAndNewlines.count navigationTitle.text = "total: \(textLength)" } } 終わりに   終わりです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WidgetKitで天気予報アプリ作ってみた〜天気情報取得編〜

投稿の経緯 最近仕事でウィジェットをよく触っており、復習とアウトプットを兼ねて天気予報アプリを開発しているので記事にしました。 今回使う天気情報はOpenWeatherMapのOneCallAPIから取得します。 開発環境 Swift 5.5 Xcode 13.2.1 サンプルプロジェクト GitHubにPushしています。気になる方はご覧ください。 https://github.com/ken-sasaki-222/WeatherWidget APIを叩いてみる { "lat": 33.44, "lon": -94.04, "timezone": "America/Chicago", "timezone_offset": -21600, "current": { "dt": 1618317040, "sunrise": 1618282134, "sunset": 1618333901, "temp": 284.07, "feels_like": 282.84, "pressure": 1019, "humidity": 62, "dew_point": 277.08, "uvi": 0.89, "clouds": 0, "visibility": 10000, "wind_speed": 6, "wind_deg": 300, "weather": [ { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "rain": { "1h": 0.21 } }, "minutely": [ { "dt": 1618317060, "precipitation": 0.205 }, ... }, "hourly": [ { "dt": 1618315200, "temp": 282.58, "feels_like": 280.4, "pressure": 1019, "humidity": 68, "dew_point": 276.98, "uvi": 1.4, "clouds": 19, "visibility": 306, "wind_speed": 4.12, "wind_deg": 296, "wind_gust": 7.33, "weather": [ { "id": 801, "main": "Clouds", "description": "few clouds", "icon": "02d" } ], "pop": 0 }, ... } "daily": [ { "dt": 1618308000, "sunrise": 1618282134, "sunset": 1618333901, "moonrise": 1618284960, "moonset": 1618339740, "moon_phase": 0.04, "temp": { "day": 279.79, "min": 275.09, "max": 284.07, "night": 275.09, "eve": 279.21, "morn": 278.49 }, "feels_like": { "day": 277.59, "night": 276.27, "eve": 276.49, "morn": 276.27 }, "pressure": 1020, "humidity": 81, "dew_point": 276.77, "wind_speed": 3.06, "wind_deg": 294, "weather": [ { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "clouds": 56, "pop": 0.2, "rain": 0.62, "uvi": 1.93 }, ... }, "alerts": [ { "sender_name": "NWS Tulsa", "event": "Heat Advisory", "start": 1597341600, "end": 1597366800, "description": "...HEAT ADVISORY REMAINS IN EFFECT FROM 1 PM THIS AFTERNOON TO\n8 PM CDT THIS EVENING...\n* WHAT...Heat index values of 105 to 109 degrees expected.\n* WHERE...Creek, Okfuskee, Okmulgee, McIntosh, Pittsburg,\nLatimer, Pushmataha, and Choctaw Counties.\n* WHEN...From 1 PM to 8 PM CDT Thursday.\n* IMPACTS...The combination of hot temperatures and high\nhumidity will combine to create a dangerous situation in which\nheat illnesses are possible.", "tags": [ "Extreme temperature value" ] }, ... ] 公式のレスポンス例を参考にしていますが、このままでは取得する情報が多く、使わないものもあるので条件を絞ってリクエストURLを作成したいと思います。条件は以下の通り。 hourly以外は取得しない 温度は摂氏で取得 言語は日本対応で取得 hourly以外は取得しない パラメータのexcludeにcurrent minutely daily alertsを指定しhourly以外は取得しないように設定します。 カンマ区切りのスペースなしである必要があります。 温度は摂氏で取得 温度を摂氏で取得するにはパラメータunitsにmetricを指定します。 言語は日本対応で取得 パラメータlangにjaを指定します。 リクエストURLの確認 https://api.openweathermap.org/data/2.5/onecall?lat=35.65146&lon=139.63678&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid={キー} 作成したリクエストURLです。緯度経度は東京都のとある地点を使っています。(将来的に現在地から緯度経度を取得します) レスポンスをもとにModelを作成 { "lat": 35.6515, "lon": 139.6368, "timezone": "Asia/Tokyo", "timezone_offset": 32400, "hourly": [ { "dt": 1643446800, "temp": 7.16, "feels_like": 4.87, "pressure": 1009, "humidity": 50, "dew_point": -2.26, "uvi": 0, "clouds": 75, "visibility": 8073, "wind_speed": 3.37, "wind_deg": 102, "wind_gust": 5.11, "weather": [ { "id": 500, "main": "Rain", "description": "小雨", "icon": "10n" } ], "pop": 0.21, "rain": { "1h": 0.12 } }, .... } 作成したリクエストを送るとこのようにレスポンスが返ってきます。 今回はhourlyから現在時刻〜48時間分のdt、temp、pressureと、weatherのmainのみを取得します。 WeatherResponseModel.swift import Foundation struct WeatherResponseModel: Decodable { var hourly: [Hourly] } struct Hourly: Decodable { var dt: Double var temp: Double var pressure: Double var weather: [Weather] } struct Weather: Decodable { var main: String } リクエストModelを作成 WeatherRequestModel.swift import Foundation struct WeatherRequestModel { var lat: Double var lng: Double } 緯度経度を格納するリクエストモデルです。 実装 アーキテクチャ MVVM + リポジトリパターンを採用して今回は開発しています。 DataStore WeatherDataStore.swift import Foundation final class WeatherDataStore { private let baseUrl = "https://api.openweathermap.org/data/2.5/onecall" private let shared = URLSession.shared private let decoder = JSONDecoder() func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel { let params = "lat=\(requestModel.lat)&lon=\(requestModel.lng)&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid=\(API_KEY)" let urlString = baseUrl + "?" + params guard let url = URL(string: urlString) else { throw NSError(domain: "Error fetch weathers.", code: -1) } let request = URLRequest(url: url) let (data, _) = try await shared.data(for: request) let response = try decoder.decode(WeatherResponseModel.self, from: data) return response } } APIと通信するDataStoreです。今回async/awaitを使って非同期処理を書いています。 WeatherWidgetTests.swift import XCTest class WeatherWidgetTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testWeatherDataStore() async throws { let dataStore = WeatherDataStore() let request = WeatherRequestModel(lat: 35.65146, lng: 139.63678) let response = try await dataStore.fetchWeathers(requestModel: request) print("Success fetch weathers.") print("Response hourly:", response.hourly) XCTAssert(response.hourly.count > 0) } } データに依存するので完全なテストではないですが、動作確認レベルでDataStoreのテストを書きました。このテストでAPI通信の挙動を確認できるので、テストが成功して正常に値が取得できているのであればData層の開発は完了です。 Repository WeatherRepositoryInterface.swift import Foundation protocol WeatherRepositoryInterface { func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel } WeatherRepository.swift import Foundation class WeatherRepository: WeatherRepositoryInterface { private let weatherDataStore = WeatherDataStore() func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel { do { let response = try await weatherDataStore.fetchWeathers(requestModel: requestModel) return response } catch { throw error } } } WeatherDataStoreへrequestModelを渡してdo catchで値を受け取ります。受け取った結果はasync throwsでViewModelに返します。後々開発するWidget ExtensionからもこのRepositoryを経由して天気情報を取得します。 ViewModel RepositoryRocator.swift import Foundation class RepositoryRocator { static func getWeatherRepository() -> WeatherRepositoryInterface { WeatherRepository() } } WeatherViewModel.swift import Foundation class WeatherViewModel: NSObject { private let weatherRepository: WeatherRepositoryInterface init(weatherRepository: WeatherRepositoryInterface) { self.weatherRepository = weatherRepository super.init() } override convenience init() { self.init(weatherRepository: RepositoryRocator.getWeatherRepository()) } func createRequestModel() -> WeatherRequestModel { let requestModel = WeatherRequestModel( lat: 35.65146, lng: 139.63678 ) return requestModel } func fetchWeathers() async { do { let response = try await weatherRepository.fetchWeathers(requestModel: createRequestModel()) print("Success fetch weathers:", response.hourly) } catch { print("Error fetch weathers:", error) } } } WeatherRepositoryへアクセスするViewModelです。RepositoryはRepositoryRocatorを経由してRepositoryInterface(抽象)から取得しています。 View ContentView.swift import SwiftUI struct ContentView: View { private let weatherVM = WeatherViewModel() var body: some View { Button { Task { await weatherVM.fetchWeathers() } } label: { Text("天気情報取得") .font(.system(size: 18, weight: .medium, design: .default)) .padding(.horizontal, 80) .padding(.vertical, 12) .foregroundColor(.white) .background(.orange) .cornerRadius(100) } } } 今回はウィジェットの開発がメインなのでアプリのViewはボタンタップで天気情報の取得だけにしておきます。 レスポンスのログ確認 Success fetch weathers: [ WeatherWidget.Hourly(dt: 1643612400.0, temp: 8.05, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]), WeatherWidget.Hourly(dt: 1643616000.0, temp: 8.21, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]), WeatherWidget.Hourly(dt: 1643619600.0, temp: 7.85, pressure: 1014.0, weather: [WeatherWidget.Weather(main: "Clouds")]), ... ] 48時間分の時間、気温、気圧、weatherが取得できているので取得成功です。 おわりに 今回はWidgetKitで天気予報アプリ作ってみた〜天気情報取得編〜について書きました。 現状、緯度経度を直接指定しているので、WidgetKitで天気予報アプリ作ってみた〜位置情報取得&保存編〜で書き換えていきます。 続きが気になる方は↓こちら↓から ご覧いただきありがとうございました。 こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。 お知らせ 現在副業でiOSアプリ開発案件を募集しています。 Twitter DMでご依頼お待ちしております! ↓活動リンクはこちら↓ https://linktr.ee/sasaki.ken
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WidgetKitで天気予報アプリ作ってみた①〜天気情報取得編〜

投稿の経緯 最近仕事でウィジェットをよく触っており、復習とアウトプットを兼ねて天気予報アプリを開発しているので記事にしました。 今回使う天気情報はOpenWeatherMapのOneCallAPIから取得します。 開発環境 Swift 5.5 Xcode 13.2.1 サンプルプロジェクト GitHubにPushしています。気になる方はご覧ください。 https://github.com/ken-sasaki-222/WeatherWidget APIを叩いてみる { "lat": 33.44, "lon": -94.04, "timezone": "America/Chicago", "timezone_offset": -21600, "current": { "dt": 1618317040, "sunrise": 1618282134, "sunset": 1618333901, "temp": 284.07, "feels_like": 282.84, "pressure": 1019, "humidity": 62, "dew_point": 277.08, "uvi": 0.89, "clouds": 0, "visibility": 10000, "wind_speed": 6, "wind_deg": 300, "weather": [ { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "rain": { "1h": 0.21 } }, "minutely": [ { "dt": 1618317060, "precipitation": 0.205 }, ... }, "hourly": [ { "dt": 1618315200, "temp": 282.58, "feels_like": 280.4, "pressure": 1019, "humidity": 68, "dew_point": 276.98, "uvi": 1.4, "clouds": 19, "visibility": 306, "wind_speed": 4.12, "wind_deg": 296, "wind_gust": 7.33, "weather": [ { "id": 801, "main": "Clouds", "description": "few clouds", "icon": "02d" } ], "pop": 0 }, ... } "daily": [ { "dt": 1618308000, "sunrise": 1618282134, "sunset": 1618333901, "moonrise": 1618284960, "moonset": 1618339740, "moon_phase": 0.04, "temp": { "day": 279.79, "min": 275.09, "max": 284.07, "night": 275.09, "eve": 279.21, "morn": 278.49 }, "feels_like": { "day": 277.59, "night": 276.27, "eve": 276.49, "morn": 276.27 }, "pressure": 1020, "humidity": 81, "dew_point": 276.77, "wind_speed": 3.06, "wind_deg": 294, "weather": [ { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "clouds": 56, "pop": 0.2, "rain": 0.62, "uvi": 1.93 }, ... }, "alerts": [ { "sender_name": "NWS Tulsa", "event": "Heat Advisory", "start": 1597341600, "end": 1597366800, "description": "...HEAT ADVISORY REMAINS IN EFFECT FROM 1 PM THIS AFTERNOON TO\n8 PM CDT THIS EVENING...\n* WHAT...Heat index values of 105 to 109 degrees expected.\n* WHERE...Creek, Okfuskee, Okmulgee, McIntosh, Pittsburg,\nLatimer, Pushmataha, and Choctaw Counties.\n* WHEN...From 1 PM to 8 PM CDT Thursday.\n* IMPACTS...The combination of hot temperatures and high\nhumidity will combine to create a dangerous situation in which\nheat illnesses are possible.", "tags": [ "Extreme temperature value" ] }, ... ] 公式のレスポンス例を参考にしていますが、このままでは取得する情報が多く、使わないものもあるので条件を絞ってリクエストURLを作成したいと思います。条件は以下の通り。 hourly以外は取得しない 温度は摂氏で取得 言語は日本対応で取得 hourly以外は取得しない パラメータのexcludeにcurrent minutely daily alertsを指定しhourly以外は取得しないように設定します。 カンマ区切りのスペースなしである必要があります。 温度は摂氏で取得 温度を摂氏で取得するにはパラメータunitsにmetricを指定します。 言語は日本対応で取得 パラメータlangにjaを指定します。 リクエストURLの確認 https://api.openweathermap.org/data/2.5/onecall?lat=35.65146&lon=139.63678&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid={キー} 作成したリクエストURLです。緯度経度は東京都のとある地点を使っています。(将来的に現在地から緯度経度を取得します) レスポンスをもとにModelを作成 { "lat": 35.6515, "lon": 139.6368, "timezone": "Asia/Tokyo", "timezone_offset": 32400, "hourly": [ { "dt": 1643446800, "temp": 7.16, "feels_like": 4.87, "pressure": 1009, "humidity": 50, "dew_point": -2.26, "uvi": 0, "clouds": 75, "visibility": 8073, "wind_speed": 3.37, "wind_deg": 102, "wind_gust": 5.11, "weather": [ { "id": 500, "main": "Rain", "description": "小雨", "icon": "10n" } ], "pop": 0.21, "rain": { "1h": 0.12 } }, .... } 作成したリクエストを送るとこのようにレスポンスが返ってきます。 今回はhourlyから現在時刻〜48時間分のdt、temp、pressureと、weatherのmainのみを取得します。 WeatherResponseModel.swift import Foundation struct WeatherResponseModel: Decodable { var hourly: [Hourly] } struct Hourly: Decodable { var dt: Double var temp: Double var pressure: Double var weather: [Weather] } struct Weather: Decodable { var main: String } リクエストModelを作成 WeatherRequestModel.swift import Foundation struct WeatherRequestModel { var lat: Double var lng: Double } 緯度経度を格納するリクエストモデルです。 実装 アーキテクチャ MVVM + リポジトリパターンを採用して今回は開発しています。 DataStore WeatherDataStore.swift import Foundation final class WeatherDataStore { private let baseUrl = "https://api.openweathermap.org/data/2.5/onecall" private let shared = URLSession.shared private let decoder = JSONDecoder() func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel { let params = "lat=\(requestModel.lat)&lon=\(requestModel.lng)&units=metric&exclude=current,minutely,daily,alerts&lang=ja&appid=\(API_KEY)" let urlString = baseUrl + "?" + params guard let url = URL(string: urlString) else { throw NSError(domain: "Error fetch weathers.", code: -1) } let request = URLRequest(url: url) let (data, _) = try await shared.data(for: request) let response = try decoder.decode(WeatherResponseModel.self, from: data) return response } } APIと通信するDataStoreです。今回async/awaitを使って非同期処理を書いています。 WeatherWidgetTests.swift import XCTest class WeatherWidgetTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testWeatherDataStore() async throws { let dataStore = WeatherDataStore() let request = WeatherRequestModel(lat: 35.65146, lng: 139.63678) let response = try await dataStore.fetchWeathers(requestModel: request) print("Success fetch weathers.") print("Response hourly:", response.hourly) XCTAssert(response.hourly.count > 0) } } データに依存するので完全なテストではないですが、動作確認レベルでDataStoreのテストを書きました。このテストでAPI通信の挙動を確認できるので、テストが成功して正常に値が取得できているのであればData層の開発は完了です。 Repository WeatherRepositoryInterface.swift import Foundation protocol WeatherRepositoryInterface { func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel } WeatherRepository.swift import Foundation class WeatherRepository: WeatherRepositoryInterface { private let weatherDataStore = WeatherDataStore() func fetchWeathers(requestModel: WeatherRequestModel) async throws -> WeatherResponseModel { do { let response = try await weatherDataStore.fetchWeathers(requestModel: requestModel) return response } catch { throw error } } } WeatherDataStoreへrequestModelを渡してdo catchで値を受け取ります。受け取った結果はasync throwsでViewModelに返します。後々開発するWidget ExtensionからもこのRepositoryを経由して天気情報を取得します。 ViewModel RepositoryRocator.swift import Foundation class RepositoryRocator { static func getWeatherRepository() -> WeatherRepositoryInterface { WeatherRepository() } } WeatherViewModel.swift import Foundation class WeatherViewModel: NSObject { private let weatherRepository: WeatherRepositoryInterface init(weatherRepository: WeatherRepositoryInterface) { self.weatherRepository = weatherRepository super.init() } override convenience init() { self.init(weatherRepository: RepositoryRocator.getWeatherRepository()) } func createRequestModel() -> WeatherRequestModel { let requestModel = WeatherRequestModel( lat: 35.65146, lng: 139.63678 ) return requestModel } func fetchWeathers() async { do { let response = try await weatherRepository.fetchWeathers(requestModel: createRequestModel()) print("Success fetch weathers:", response.hourly) } catch { print("Error fetch weathers:", error) } } } WeatherRepositoryへアクセスするViewModelです。RepositoryはRepositoryRocatorを経由してRepositoryInterface(抽象)から取得しています。 View ContentView.swift import SwiftUI struct ContentView: View { private let weatherVM = WeatherViewModel() var body: some View { Button { Task { await weatherVM.fetchWeathers() } } label: { Text("天気情報取得") .font(.system(size: 18, weight: .medium, design: .default)) .padding(.horizontal, 80) .padding(.vertical, 12) .foregroundColor(.white) .background(.orange) .cornerRadius(100) } } } 今回はウィジェットの開発がメインなのでアプリのViewはボタンタップで天気情報の取得だけにしておきます。 レスポンスのログ確認 Success fetch weathers: [ WeatherWidget.Hourly(dt: 1643612400.0, temp: 8.05, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]), WeatherWidget.Hourly(dt: 1643616000.0, temp: 8.21, pressure: 1013.0, weather: [WeatherWidget.Weather(main: "Clouds")]), WeatherWidget.Hourly(dt: 1643619600.0, temp: 7.85, pressure: 1014.0, weather: [WeatherWidget.Weather(main: "Clouds")]), ... ] 48時間分の時間、気温、気圧、weatherが取得できているので取得成功です。 おわりに 今回はWidgetKitで天気予報アプリ作ってみた①〜天気情報取得編〜について書きました。 現状、緯度経度を直接指定しているので、WidgetKitで天気予報アプリ作ってみた〜位置情報取得&保存編〜で書き換えていきます。 続きが気になる方は↓こちら↓から ご覧いただきありがとうございました。 こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。 お知らせ 現在副業でiOSアプリ開発案件を募集しています。 Twitter DMでご依頼お待ちしております! ↓活動リンクはこちら↓ https://linktr.ee/sasaki.ken
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift×AWS】iOSアプリにAPI Gatewayを導入してCognitoから指定のユーザーデータを消去する

前提 ユーザーの退会機能を作るべく、今までAmplifyを使用していたアプリにAPI Gatewayを追加したサーバレスアプリです。 Amplifyからユーザーの削除APIがあればよかったのですが見つからずで、Lambdaから実現することに。(もしAmplifyからのやり方を知っている人がいたら教えてください笑) 元々はLambdaが使えれば良く、どうやらLambdaを直叩きする方法もあるっぽいんですが、API Gatewayを触ったことがなかったのでそちらを使って実装してみることにしました。 また、今回初めてAPI Gatewayを触るので、とりあえずリクエストだけ実装してレスポンスはデフォルトです。 実装が甘いところ等もあると思いますがご容赦ください。 構成 構成はこんな感じ。これをAlamofireを使って叩いていきます。 実際の設計にはユーザーの他のデータも消すため、Lambdaの先にDynamo DBなりS3なりがくっついているのですが、今回はCognitoのユーザーを消すことが目的なので割愛。 気が向いたらそちらも記事にします。 以下、各所説明。 ◆API Gateway 今回の主役です。APIがユーザー側から叩かれたらIDトークンを検証して、トークンの中にあるsub値をLambdaに渡します。そしてそれに応じてレスポンスをユーザー側に返していきます。 ◆AWS Lambda API Gatewayから呼ばれたら、送られてきたsub値を元に該当するcognitoユーザーを消去します。 実装方法 ◆Lambda API Gateway実装時に、Lambda関数を指定するため、先に該当するLambda関数を作成しておく。 ①Lambda関数を作成する。今回はNode.jsを使用して実装しますが、お好きなのを選択してください。 関数名は今回は"delete_user"とします。 ②関数を作成したら、Lambda関数がcognitoにアクセスできるようにロールにポリシーを振る。 設定タブにあるロールをクリック。 ③「ポリシーをアタッチします」をクリックし、「AmazonCognitoPowerUser」をアタッチ。 ④コードを実装する。私は以下のコードで実装しました。 let aws = require("aws-sdk"); exports.handler = async (event) => { // eventから削除するCognitoユーザーのユーザー名を格納 let userName = event.context.sub; // Cognitoユーザーの無効化メソッドをコール await disalbeCognito(userName); const response = { statusCode: 200, }; return response; }; // Cognito情報を無効化し、ログインできないようにする async function disalbeCognito(username){ // Cognitoを使う準備 aws.config.update({ region: 'ap-northeast-1', }); const cognito = new aws.CognitoIdentityServiceProvider({ apiVersion: '2016-04-18' }); // 対象のCognitoユーザープールID const user_pool_id = "ap-northeast-1_xxxxxxxx"; // ユーザー削除 try { await cognito.adminDeleteUser({ UserPoolId: user_pool_id, Username: username }).promise(); console.log("Success! userName : " + username); } catch (err) { console.log("Failed! userName : " + username); if (err.code == 'UserNotFoundException') { // ユーザープールにユーザーが存在していない場合 console.log('UserNotFoundException'); } else { // その他のエラー console.log(err, err.stack); } throw err; } } 使用するならコピペで大丈夫ですが、一箇所注意点があるのでお伝えしておきます。 let userName = event.context.sub; 4行目のこちらですが、これはAPI Gatewayの中にあるJSONの影響によってこうなっています。JSONの形式はAPI Gatewayの方で載せてある、そちらを確認してみてください。 こちらでデプロイしてLambdaは完了です。 ◆API Gateway ①AWSコンソール画面からAPI Gatewayを開き、REST APIの構築を選択。 ②プロトコル-REST、新しいAPIの作成を選択、API名(わかりやすい名前)を入力し、APIの作成をクリック。 ③アクションをクリックしメソッドの作成を選択。 ④作成されたメソッドを選択し、該当するものを選択。今回はユーザーを削除したいため「DELETE」を選択。その後、チェックマークをクリック。 ⑤作成されたメソッドのセットアップ。ここで先ほど作成したLambdaを指定し、保存をクリック。 ⑥"オーソライザー"タブを選択し、「新しいオーソライザーの作成」を選択し、作成。 ※ここで、消去したいユーザーが存在するユーザープールを選択。 ※また、トークンのソースは"Authorization"を入力したが、値はお好きなもので。 ⑦リソース→作成したメソッド→メソッドリクエストをクリックし、"認可"の右側の鉛筆マークから⑥で作成したおーそライザーを選択し、チェック。 ⑧統合リクエスト内下部のマッピングテンプレートから、「マッピングテンプレートの追加」を選択、Content-typeに"application/json"と入力しチェック。 ⑨テンプレートに以下のコードを入力し保存。 { "context" : { "sub" : "$context.authorizer.claims.sub" } } 上記により、IDトークン内のsubの値を取り出してLambdaに渡すことができます。 ここのJSONの形がcontext : { sub : {} } なので、Lambdaのeventの後にcontext.subと記述しています。 ⑩アクション→APIのデプロイを選択し、新しくステージを作成してデプロイすれば、完了です。 ※この手順で作るURLの形は決まっていて、以下のようになっています。 https://{restapi_id}.execute-api.{region}.amazonaws.com/{stage_name}/ そのため、stage名もある程度わかりやすいor命名規則を設けた方が良いでしょう。 ◆Swift Swift側の実装はメインではないので、さっくりコードだけ載せようと思います。 public func deleteAPI( completion: (()->())?=nil) { let urlString = "https://{restapi_id}.execute-api.{region}.amazonaws.com/{stage_name}/" let defaultHeader = ["Authorization" : "Bearer \(cognito.idToken)"] as HTTPHeaders Alamofire.request(urlString, method: .delete, headers: defaultHeader) .responseJSON() { (response) in switch response.result { case .success: print("success") break case .failure: print("failure") break } } } 以上で、実装終了です。 感想 駆け足でしたが、いかがだったでしょうか。 分かりくいところもあったと思いますが、筆者もそこまで深く理解できているわけではないのでご容赦ください。 何かありましたら、コメントで教えてくださると助かります。 API Gatewayは初めて触ってみたのですが、使いやすくて良いですね。何より便利です。 サーバレスってことでAPI Gatewayが使えてくると元々使えているAmplifyも使ってバックエンド側もガンガン構築できそうです。 それでは。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

画像をコラージュする

画像を組み合わせて一枚の画像を作る方法です 複数の画像から新しい画像を作りたい 複数の画像を組み合わせて一枚の画像にするとなると、はて、どうすればいいのか、、、と改めて思いました。 Contextに描画すればいい UIGraphicsBeginImageContextWithOptions(CGSize(width: outputWidth, height: outputHeight), false, 0.0) imageA.draw(in: drawRectA) imageB.draw(in: drawRectB) imageC.draw(in: drawRectC) let assembledImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スマホアプリ クロスプラットフォーム開発 in Flutter

Index 1.Flutterフレームワーク 2.AndroidStudioで開発できる 3.UIの記載はネストを多用する 4.UIとビジネスロジックを分けて書く 5.KotlinとSwiftによる書き分けが必要な場合がある 6.Dartプログラムのエントリーポイント 7.使用パッケージの宣言 8.ホットリロードで一回一回ビルドしなくてもいい 9.動作確認 10.アプリ公開 1.Flutterフレームワーク 2018年12月にGoogleからリリースされた、モバイルアプリ開発用のフレームワーク。 Dart言語 従来ならば、AndroidならKotlin iOSであればSwift という、それぞれの言語を覚えなければいけないが Flutterの場合は、DartというJavaライクな言語を覚えるだけで 両方の開発ができ、学習コストを抑えることができる。 2.AndroidStudioで開発できる AndroidStudioのインストール Flutterのインストール 3.UIの記載はネストを多用する 画面を構成するコンポーネントはWidgetというクラスが基底クラスにあり 各コンポーネントはWidgetを継承する。 Widgetを並べることで画面を作っていく。 コード const Text.rich( TextSpan( text: 'Hello', // default text style children: <TextSpan>[ TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), ], ), ) 4.UIとビジネスロジックを分けて書く BLoC(BusinessLogicComponent)というアーキテクチャを用いることで、UIとビジネスロジックを分けて書くことができるようになる BLoCパターンを用いることにより、次のことができるようになる ・ソースファイルごとの責務を明確化し、ソースの見通しがよくなる ・イベントハンドラ(StreamBuilder)を使って、状態管理をできる 参考URL)長めだけどたぶんわかりやすいBLoCパターンの解説 https://qiita.com/kabochapo/items/8738223894fb74f952d3 5.KotlinとSwiftによる書き分けが必要な場合がある UI部分、HTTP通信、内部ストレージへの保存などは一つのソースで書くことができるが 担当したプロダクトにおいてFirebaseとAzureNotificationHubsを利用したプッシュ通知をすることがあり、 Firebaseに端末を登録をするところまではDart記載で共通だが、 プッシュ通知の送信先を指示する部分であるAzureNotificationHubsへの端末識別情報の登録部分は、 Dartが対応していない部分であり、KotlinとSwiftで書き分ける必要がある。 6.Dartプログラムのエントリーポイント 以下のように、C言語などと同じで、main()から始まる // Copyright 2018 The Flutter team. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( title: 'Welcome to Flutter', home: Scaffold( appBar: AppBar( title: const Text('Welcome to Flutter'), ), body: const Center( child: Text('Hello World'), ), ), ); } } 参考URL) https://docs.flutter.dev/get-started/codelab 7.依存パッケージの宣言 pubspec.yamlという設定ファイルで、HTTP通信や内部ストレージアクセスなど、使用するパッケージを宣言する。 また、「version」でアプリバージョンを設定する name: <project name> description: A new Flutter project. publish_to: 'none' version: 1.0.0+1 environment: sdk: ">=2.7.0 <3.0.0" dependencies: flutter: # Required for every Flutter project sdk: flutter # Required for every Flutter project http: '>=0.12.0 <0.13.0' cupertino_icons: ^1.0.2 # Only required if you use Cupertino (iOS style) icons dev_dependencies: flutter_test: sdk: flutter # Required for a Flutter project that includes tests flutter: uses-material-design: true # Required if you use the Material icon font assets: # Lists assets, such as image files - images/a_dot_burr.jpeg - images/a_dot_ham.jpeg fonts: # Required if your app uses custom fonts - family: Schyler fonts: - asset: fonts/Schyler-Regular.ttf - asset: fonts/Schyler-Italic.ttf style: italic - family: Trajan Pro fonts: - asset: fonts/TrajanPro.ttf - asset: fonts/TrajanPro_Bold.ttf weight: 700 参考URL) https://docs.flutter.dev/development/tools/pubspec 宣言したパッケージはビルド時にダウンロードされる Flutterルートに.pubspecという隠しフォルダができ そこにパッケージがダウンロードされてビルドに使用される 8.ホットリロードで一回一回ビルドしなくてもいい Flutterにはホットリロードという機能があり UIを修正した際に修正が即反映される main関数を再度実行することなく、イベントハンドラの部分のみが処理される ①メインカラーをblueに指定 ②タイトルやボタンの色がblue ③メインカラーをyellowに指定してソースを保存 ④タイトルやボタンの色がyellow 9.動作確認 Androidのエミュレータ、実機 iPhoneのシミュレータ、実機 で確認を行うことができます。 Androidエミュレータ起動 iPhoneシミュレータ起動 10.アプリ公開 テスト完了後、アプリ審査に提出 AndroidであればGooglePlayConsole、 iOSであればAppleDeveloperから 作成したアプリを審査に提出する アプリの作成ルールに違反していないか審査され、問題がなければ公開の流れとなる
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift Rxで音声の出力先を取得する方法

コード AVAudioSession.sharedInstance() NotificationCenter.default.rx .notification(AVAudioSession.routeChangeNotification) .map { $0.userInfo?[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription } .bind(to: Binder(self) { me, desc in if let audio = desc?.outputs.first { // 出力先の取得 print("audio portType:", audio.portType.rawValue) } }) .disposed(by: disposeBag) ??ポートタイプの一覧はこちらにまとめてます!?? https://qiita.com/shin1007m/items/6318e7648ad7232551da
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】AVAudioSessionPortDescription PortType 一覧

portTypeとは? The value of this property can be any of the constants declared in Input Ports, Output Port Types, or I/O Port Types. このプロパティの値は、入力ポート、出力ポートタイプ、またはI / Oポートタイプで宣言された定数のいずれかです。 一覧 type name description lineIn ドックコネクタのラインレベル入力 builtInMic iOSデバイスの内蔵マイク headsetMic 有線ヘッドセットのマイク。 ヘッドセットとは、ヘッドフォン出力がマイクとペアになっているアクセサリのことです。 lineOut ドックコネクタのラインレベル出力 headphones ヘッドホンまたはヘッドセット出力 bluetoothA2DP BluetoothA2DPデバイスでの出力 builtInReceiver 電話をしているときに耳に当てるスピーカー builtInSpeaker iOSデバイスの内蔵スピーカー HDMI 高品位マルチメディアインターフェースを介した出力 airPlay リモートAirPlayデバイスでの出力 bluetoothLE Bluetooth LowEnergyデバイスでの出力 bluetoothHFP Bluetoothハンズフリープロファイルデバイスでの入力または出力 usbAudio ユニバーサルシリアルバスデバイスでの入力または出力 carAudio カーオーディオによる入力または出力 virtual 実際のオーディオハードウェアに対応していない入力または出力 PCI PCI(Peripheral Component Interconnect)バスを介して接続された入力または出力 fireWire FireWire経由で接続された入力または出力 displayPort DisplayPortを介して接続された入力または出力 AVB AVB(オーディオビデオブリッジング)を介して接続された入力または出力 thunderbolt Thunderboltを介して接続された入力または出力
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む