Memo

メモ > 技術 > IDE: Xcode

■アカウント
■Apple ID https://appleid.apple.com/ AppStoreでアプリをダウンロードしたり、iCloudを利用したりするためのアカウント Apple製品を使っているなら、すでに持っているはず IDの作成手順は以下が参考になりそう Apple ID を新規作成する方法を初心者の方向けに徹底解説 | アドミンウェブ https://www.adminweb.jp/blog/2021061201/ ■Apple Developer Program https://developer.apple.com/jp/programs/ 証明書やプッシュなど、アプリの設定を管理するためのもの すべての機能を使用するためには、年会費を払う必要がある 【Xcode】無料の実機ビルドでどこまでできるのか - Qiita https://qiita.com/koogawa/items/15b231e2728ff64e08f3 ■App Store Connect https://appstoreconnect.apple.com/ アプリを公開するためのもの アプリを公開するためには、Apple Developer Programで年会費を払う必要がある Apple Developer Program内にある「App Store Connect」メニューからも遷移できるようになっているようなので、 原則ここからログインする必要は無いかもしれない(ただし、権限によっては必要になるのかもしれない) もとはiTunes Connect(https://itunesconnect.apple.com/)という名前だったが、リブランディングが行われた iTunes Connectが「App Store Connect」にリブランディング - iPhone Mania https://iphone-mania.jp/news-214615/
■Xcode環境の作成
基本的にはApp Storeからインストールするだけ ■Xcodeインストール Mac App Store で「Xcode」を検索してインストールする インストールが完了したらアプリを開く 利用規約が表示されるので同意する 開発プラットフォームと、それに関連するツールのインストール画面が表示される。今回は「macOS」と「iOS」にチェックされたまま(デフォルト)でインストール Xcodeの新機能が表示されるので「Continue」をクリック インストール完了 Xcode をインストールする、 iOSアプリ作成準備 https://i-app-tec.com/ios/xcode-install.html ■Xcode初期設定 メニューから「Xcode > Settings > Text Editing」で「Line numbers」と「Code folding ribbon」にチェックを入れる 「Indentation」画面に切り替え、「Line Wrapping」の「Wrap lines to editor width」のチェックを外す(としていたが、Xcod14ではこの設定項目が無かった) ■簡単なアプリを作成 以下で作成 Product Name: HelloWorld Team: (「Add Acount」をクリックし、自身のApple IDを入力。認証後、改めて自身のApple IDを選択) Organization Identifier: org.refirio Bundle Identifier: org.refirio.HelloWorld Interface: SwiftUI Language: Swift Use Core Data: (デフォルトのままチェックせず) Include Test: (デフォルトのままチェックしておく) 「Next」をクリック 保存場所を確認される。今回は「Documents」内に「Xcode」フォルダを作成した 「Create Git repository on my Mac」はチェックを入れたままにした 「Create」をクリック ※「Git Repository Creation Failed」というエラーが表示された 詳細に「Ensure the author information supplied in Xcode > Settings is correct then create the git repository using Source Control > New Git Repository...」とある 「Fix...」をクリックすると、「Source Control」の画面が表示された 「Author Name」と「Author Email」を入力して閉じる 今回のプロジェクトではリポジトリが作られないままだったが、次回作成した際はリポジトリが作成された ソースコードの画面とプレビューの画面が表示されることを確認 シミュレータ(iPhone 14)で実行できることを確認 ■シミュレータで実行 はじめてアプリを実行するとき、「Enable Developer Mode on this Mac?」と聞いてきたので「Enable」を選択 Simulatorメニューから「Window > Scale > 33%」と設定 Simulator画面内で「Settings > General > Language & Region > iPhone Language > 日本語」と設定 Safariを起動し、インターネットに接続できることを確認しておく ■iPhone実機で実行 はじめて実機を繋ぐとiTunesが起動したので、利用規約に同意 Xcodeで繋いだ実機を選択すると「Processing symbol files」状態になった。結構時間がかかるので待つ 実機で実行しようとすると「Signing for requires a development team」でエラー 作成しようとしているアプリのプロジェクトを選択して「General > Signing > Add Account」を選択。Apple ID でログイン Tearmで自身のアカウントを選択してビルド キーチェーンへのアクセスを要求されるので「常に許可」 それでも「Could not launch」のエラーになる。ダイアログに詳細が書かれているが、実機側で許可が必要 実機の「設定 > 一般 > デバイス管理」から、使用しているアカウントを選択して承認する これで実機で実行できた ※昔は実機実行のために開発者登録(要年会費)が必要だったり、 実機実行を許可するデバイスをあらかじめ登録したり …が必要だったが、今は不要 ■iOS16のデベロッパモード iOS16からは、実機で実行するには「デベロッパモード」を有効にする必要がある iOS16端末でDeveloper Modeを有効にする方法 - Qiita https://qiita.com/YokohamaHori/items/d00e1786c34b4ab30638 iOS16から導入された「デベロッパモード」について - モナカプレス https://press.monaca.io/takuya/12662 以下、2023年3月に試したメモ 「設定 → プライバシーとセキュリティ → デベロッパーモード」 から有効にすると再起動を求められる 再起動後に再度「デベロッパーモードを有効にするか」の確認ダイアログが表示されるので、有効にする ここまではiOS16から必要になった操作なので、iOS15などでは必要ない ここからはiOS16もiOS15も共通 デバイスをMacに接続してビルドを実行すると 「Device "iPhone" isn't registered in your developer account.」 のように表示される 「Register Device」 ボタンをクリックすることで、実機で実行できるようになる ■フォントの変更 Xcodeで日本語を入力すると、英語と日本語で文字の高さが異なるので違和感がある 以下で解消できる…かと思ったが解消できず Xcodeの日本語の行高を英語と(ほぼ)同じにする設定 - Swift・iOSコラム - Medium https://medium.com/swift-column/xcode-xccolortheme-8980f205b116 Xcodeの「Preferences → Themes → Source Editor」の画面下でフォントを変更できる 以下ページの「バージョン」から最新のものをダウンロード プログラミング用フォント Ricty Diminished https://rictyfonts.github.io/diminished 圧縮ファイルを展開する RictyDiminished-Regular.ttf と RictyDiminishedDiscord-Regular.ttf をダブルクリックし、「フォントをインストール」ボタンを押してインストール としたが、そのフォントでも高さがおかしい さらに幅もおかしくなるので悪化している
■Xcode環境の更新
基本的にはApp Storeから更新されるが、Macの容量不足で更新できないことがある 対応するために色々作業したときのメモ ■不要ファイルの削除1 Macが空き容量不足でXcodeをupdateできない時の対処 - Qiita https://qiita.com/hisw/items/250aaf3ffaab7b0822df 「結論」に書かれている以下のコマンドで容量を確保してXcodeを更新できた
sudo rm -rf '/Users/xxxxx/Library/Developer/Xcode/iOS DeviceSupport'
【容量そのままでOK!】Macの容量不足でXcodeがダウンロードできない、を解決する方法 | ドルフィンのIT日記 https://it.fuwafuwasky.com/entry/2021/swift/xcode-yoryo/ 未検証だが、Xcodeを直接ダウンロードしてインストールする、という方法もあるみたい ただし今後AppStore経由でのアップデートに支障が出ないか、などは不明 ■不要ファイルの削除2 Macのアップルアイコン → このMacについて → ストレージ → 管理 → おすすめ から、不要なファイルを削除する 「デベロッパ」に表示されるファイルをすべて削除すると、多くの容量を確保できた (恐らく削除したファイルは、Xcodeアップデート後に再度インストールする必要がある) ■初期化 Mac.txt の「初期化」も参照 アプリを作成するためにXcodeを最新版にする必要があり、 Xcodeを最新版にするにはMacを最新版にする必要があり、 Macを最新版にしようとするとHDDにある謎の「その他」領域が圧迫してアップデートできないことがある この場合、Mac自体を初期化して対応しているが、あまり好ましい対応とは思っていない
■アプリデザインのガイドライン
アプリのデザインを行う場合、以下に目をとおしておくといい ユーザーインターフェイスのデザインのヒント - Apple Developer https://developer.apple.com/jp/design/tips/ Human Interface Guidelines - Design - Apple Developer https://developer.apple.com/design/human-interface-guidelines/ Themes - iOS - Human Interface Guidelines - Apple Developer https://developer.apple.com/design/human-interface-guidelines/ios/overview/themes/ 以下に日本語訳されたものがある Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Overview編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-overview/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS App Architecture編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-app-architecture/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS User Interaction編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-user-interaction/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS System Capabilities編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-system-capabilities/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Visual Design編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-visual-design/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Icons and Images編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-icons-and-images/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Bars編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-bars/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Views編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-views/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Controls編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-controls/ Apple ヒューマンインターフェースガイドライン日本語訳 - iOS Extensions編 - knmts.com https://knmts.com/apple-human-interface-guidelines-ios-extensions/ 以下なども参考になりそう モバイルアプリに最適なボタンサイズと間隔とは | UX MILK https://uxmilk.jp/81679 守ってはいけない、iOSのデザインルール4つ - U-Site https://u-site.jp/alertbox/4-ios-rules-break iPhone Xアプリをデザインするための基礎知識 | アドビUX道場 #UXDojo https://blog.adobe.com/jp/publish/2018/01/22/web-designing-apps-iphone-x-every-ux-designer-needs-kno... iOS とAndroid の違い クロスプラットフォームのアプリデザインで特に気をつけるべき点|marin|note https://note.com/ku_marin/n/n60ebdb19ebd0
■UIKit
■初期コード 「Swift + Storyboard」での初期コードは以下のとおり
import UIKit class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } }
■ボタンを配置&タップでアラート表示 Storyboardで画面にボタンを配置し、そのボタンをタップしたときにアラートを表示するコード例は以下のとおり
import UIKit class ViewController: UIViewController { @IBOutlet weak var button: UIButton! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } @IBAction func tapButton(_ sender: Any) { let alert = UIAlertController(title: "タイトル", message: "メッセージ", preferredStyle: .alert) let ok = UIAlertAction(title: "OK", style: .default) { (action) in self.dismiss(animated: true, completion: nil) } let cancel = UIAlertAction(title: "キャンセル", style: .cancel) { (acrion) in self.dismiss(animated: true, completion: nil) } alert.addAction(cancel) alert.addAction(ok) self.present(alert, animated: true, completion: nil) } }
■UIViewとUIViewController UIView 画面部品を作る 矩形領域を表示する機能を持ったクラス UIViewおよび、そのサブクラスを用いて画面を構築する UIViewController 画面を管理する Viewのライフサイクルの管理と画面遷移の機能を持つクラス viewDidLoadやviewWillAppearなどのメソッドが用意されており、必要に応じてそれらの中に処理を記述する iOS開発の知識 https://zenn.dev/tishii2479/scraps/54e81c0e347cc9 【Swift/UIKit】UIViewControllerの役割とは?ビュー階層とviewDidLoadメソッド https://tech.amefure.com/swift-uikit-uiviewcontroller ■画面の向き ViewControllerごとに画面の向きを固定する - Qiita http://qiita.com/masapp/items/38ed20b27dcc09c24cba ■パスの確認 ※パスは下のように取る? [0]ではなくlastとか使う方がいい?
let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] /* // パスの確認 let documentDirPath = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true) print(documentDirPath) */
■WebViewでWebページを表示 ※これからはWebViewではなくWKWebViewが推奨される ストーリーボードにWebViewを配置する webViewという名前でOutlet接続する(名前は任意) あらかじめWebKitを読み込む
import WebKit
以下のコードでAppleのサイトを表示できる(「@IBOutlet」はOutlet接続によって追加されたコード)
@IBOutlet weak var webView: UIWebView! override func viewDidLoad() { super.viewDidLoad() let myURL = URL(string: "https://www.apple.com/jp/") let myRequest = URLRequest(url: myURL!) webView.loadRequest(myRequest) }
■WKWebViewでWebページを表示 ※これからはWebViewではなくWKWebViewが推奨されるが、現時点では問題も多いので注意 WKWebViewをストーリーボードで配置すると問題が多いので、コードで扱う必要があるかもしれない WKWebViewと向き合ってみた - Qiita https://qiita.com/UJIPOID/items/fd4b33cac48ad37733f5 「iOS11未満もサポートする場合はコードでWKWebViewを実装する必要がある」 以下はストーリーボードで実装する例 ストーリーボードにWKWebViewを配置する webKitViewという名前でOutlet接続する(名前は任意) あらかじめWebKitを読み込む
import WebKit
以下のコードでAppleのサイトを表示できる(「@IBOutlet」はOutlet接続によって追加されたコード)
@IBOutlet weak var webKitView: WKWebView! override func viewDidLoad() { super.viewDidLoad() let myURL = URL(string: "https://www.apple.com/jp/") let myRequest = URLRequest(url: myURL!) webKitView.load(myRequest) }
■WebViewのキャッシュをクリア WebViewのキャッシュは強力で、アプリを再起動しても古い情報を読み続けることが多い プログラムで対応することもできるようだが、なかなか厄介そう 原則としてページをPHPで作成し、CSSファイルなどは「?20180816」のような文字列を付けて読み込む…とするのが安全そう UIWebViewを使うときに気をつけていること - Qiita https://qiita.com/urouro_n/items/d4e5fb66f2039090000f ■WebViewの長押しメニューを制御 UITextfieldやUIWebViewの長押しメニューが英語になる場合の解決法 | イリテク https://iritec.jp/web_service/7326/ WKWebView でテキスト選択禁止や長押しによるメニュー表示禁止(TouchCallout)など | MUSHIKAGO APPS MEMO https://mushikago.com/i/?p=8385 ■Web上の画像を表示
@IBOutlet weak var myImageView: UIImageView! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. let url = URL(string: "https://pbs.twimg.com/media/DGynUZqV0AAK9aq.jpg") let task = URLSession.shared.dataTask(with: url!) { data, response, error in if error == nil { if let dlImage = UIImage(data: data!) { self.myImageView.image = dlImage } } else { print("error") } } task.resume() /* // 以前の書き方 var myURL = NSURL(string: "https://pbs.twimg.com/media/DGynUZqV0AAK9aq.jpg") var myData = NSData(contentsOfURL: myURL) var myImage = UIImage(data: myData) myImageView.image = myImage */ }
■ローカルファイルを扱う
override func viewDidLoad() { super.viewDidLoad() /* // ディレクトリを作成 let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] if (FileManager.default.fileExists(atPath: documentPath + "/test")) { print("ディレクトリはすでに作成されています") } else { do { try FileManager.default.createDirectory( atPath: documentPath + "/test", withIntermediateDirectories: false, attributes: nil ) print("ディレクトリ作成成功") } catch let error as NSError { print("ディレクトリ作成エラー") } } */ /* */ // ファイル・ディレクトリを一覧表示 let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] var file_names: [String] { do { return try FileManager.default.contentsOfDirectory(atPath: documentPath) //return try FileManager.default.contentsOfDirectory(atPath: documentPath + "/test") } catch { return [] } } let fm = FileManager() for file_name in file_names { var isDir = ObjCBool(false) let isExist = fm.fileExists(atPath: documentPath + "/" + file_name, isDirectory: &isDir) if isDir.boolValue == true { print("dir=" + file_name) } else if isExist { print("file=" + file_name) } else { print("nodata=" + file_name) } } /* // ファイルのテキストを表示 let file_name = "data2.txt" if let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let path_file_name = dir.appendingPathComponent(file_name) do { let text = try String(contentsOf: path_file_name, encoding: String.Encoding.utf8) print(text) } catch { print("NG") } } */ /* // ファイルにテキストを保存 let file_name = "data1.txt" //let file_name = "test/data3.txt" let text = "abcd1234" if let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let path_file_name = dir.appendingPathComponent(file_name) do { try text.write(to: path_file_name, atomically: false, encoding: String.Encoding.utf8) print("OK") } catch { print("NG") } } */ }
■設定画面を作る UITableView + Static Cellsでアプリ内設定画面を作成するサンプル(XCode9, Swift4, StoryBoard使用) - Androidはワンツーパンチ 三歩進んで二歩下がる https://sakura-bird1.hatenablog.com/entry/2018/03/09/014455 UITableViewControllerのStatic Cellsをカスタマイズしてアプリの設定画面を作る - Qiita https://qiita.com/KikurageChan/items/08844e4eee774da992db ■ハンバーガーメニューを作る [Tips]ハンバーガーメニューを作成するには? - Swift Life http://swift.hiros-dot.net/?p=377 【iOS】ハンバーガーメニューの作り方 - Qiita https://qiita.com/takehiro224/items/dc5903ae42f288ccd5f7 ■Delegateとは何か プロトコルとデリゲートのとても簡単なサンプルについて - Qiita https://qiita.com/mochizukikotaro/items/a5bc60d92aa2d6fe52ca SwiftにおけるDelegateとは何か、なぜ使うのか - Qiita https://qiita.com/st43/items/9f9990d76cefa1909ef4 ■オプショナル 【Swift入門】オプショナル(Optional)型の基本を徹底解説! | 侍エンジニアブログ https://www.sejuku.net/blog/35070 SwiftのOptional型を極める - Qiita https://qiita.com/koher/items/c6f446bad54442a28bf4 【Optional型】アンラップの仕方や非Optional型との違い https://tech-maga.com/swift-optional 以下、unwrapの例
var year = 0 if let yearData = yearString as? Int { year = yearData }
以下のように書くこともできる
var year = Int(yearString) ?? 0
■UIKit+Playground
■Playgroundでボタンやラベルを確認
import UIKit var myLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 30)) myLabel.backgroundColor = UIColor.gray myLabel.text = "テスト"
■PlaygroundでJSONを取得 ※あらかじめ https://refirio.org/memos/ios/json_book.php に以下のプログラムを用意している
<?php $data = array( 'books' => array( array( 'title' => 'C言語入門', 'price' => '1500' ), array( 'title' => 'JAVA言語入門', 'price' => '1600' ), array( 'title' => 'Ruby言語入門', 'price' => '2000' ) ) ); echo json_encode($data); exit;
import Foundation import PlaygroundSupport PlaygroundPage.current.needsIndefiniteExecution = true class Client { func someTask() { let target = URL(string: "https://refirio.org/memos/ios/json.php")! let task = URLSession.shared.dataTask(with: target) { data, response, error in if let jsonData = data { self.printJSON(jsonData) } } task.resume() } func printJSON(_ data: Data) { do { let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) print(json) if let items = (json as AnyObject).object(forKey: "books") { for item in items as! NSArray { guard let title = (item as AnyObject).object(forKey: "title") else { continue } guard let price = (item as AnyObject).object(forKey: "price") else { continue } print(title) print(price) } } } catch { print("parse error!") } } } let client = Client() client.someTask()
■PlaygroundでViewControllerを使う [iOS 10] PlaygroundでUIKitの描画を行う | Developers.IO http://dev.classmethod.jp/smartphone/ios-10-playground-uikit-draw/
■UIKit+ARKit
■デフォルトのプロジェクト 「Augmented Reality App」で以下の設定で新規作成する Product Name: arkit Content Technology: SceneKit Interface: Storyboad 拡張現実ではカメラを利用するので、info.plist にはカメラのプライバシー設定(Privacy - Camera Usage Description)が追加されている プロジェクトをビルドすると、カメラを通して宇宙船が浮いているのを確認できる 作成された初期プログラムは以下のとおり(コメントは調整してある) ViewController.swift
import UIKit import SceneKit // SceneKitのインポート import ARKit // ARKitのインポート class ViewController: UIViewController, ARSCNViewDelegate { // ARSCNViewDelegateの利用 // ストーリーボードのARSCNViewとOutlet接続 @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // FPSやタイミングの情報を表示する sceneView.showsStatistics = true // シーンを新しく作る let scene = SCNScene(named: "art.scnassets/ship.scn")! // シーンビューにシーンを設定する sceneView.scene = scene } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
■アニメーションするオブジェクト Assets.xcassets に earth_1024.jpg を読み込ませておく ViewController.swift
import UIKit import SceneKit // SceneKitのインポート import ARKit // ARKitのインポート class ViewController: UIViewController, ARSCNViewDelegate { // ARSCNViewDelegateの利用 // ストーリーボードのARSCNViewとOutlet接続 @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // FPSやタイミングの情報を表示する sceneView.showsStatistics = true // カラのシーンを新しく作る //let scene = SCNScene(named: "art.scnassets/ship.scn")! let scene = SCNScene() // シーンビューにシーンを設定する sceneView.scene = scene // ジオメトリ(半径20cmの球体を作る) let earch = SCNSphere(radius: 0.2) // テクスチャ(地球のテクスチャを貼り付ける) earch.firstMaterial?.diffuse.contents = UIImage(named: "earth_1024") // ノード(地球のノードを作る) let earchNode = SCNNode(geometry: earch) // アニメーション(100秒でY軸回転を1回行う) let action = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 100) earchNode.runAction(SCNAction.repeatForever(action)) // 位置決め(右へ0.2m、上へ0.3m、奥へ0.2 に配置) earchNode.position = SCNVector3(0.2, 0.3, -0.2) // シーンに追加 sceneView.scene.rootNode.addChildNode(earchNode) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
■環境マッピング ARKit で球体に環境マッピング (Storyboard などを使わずソースコードで実現) - Qiita https://qiita.com/niwasawa/items/04714f1603b98a713ca1 ARKit Hello World (宙に浮く Hello World テキスト in 拡張現実) - Qiita https://qiita.com/niwasawa/items/fbc2e6231a1b7d0da672 ViewController.swift
import UIKit import SceneKit // SceneKitのインポート import ARKit // ARKitのインポート class ViewController: UIViewController, ARSCNViewDelegate { // ARSCNViewDelegateの利用 // ストーリーボードのARSCNViewとOutlet接続 @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // ワイヤーフレームを表示する //sceneView.debugOptions = .showWireframe // FPSやタイミングの情報を表示する sceneView.showsStatistics = true // カラのシーンを新しく作る let scene = SCNScene() // シーンビューにシーンを設定する sceneView.scene = scene // 箱を作る let box1 = SCNBox(width: 0.3, height: 0.1, length: 0.3, chamferRadius: 0.01) // 塗り box1.firstMaterial?.diffuse.contents = UIColor.blue // 物理ベースのレンダリング box1.firstMaterial?.lightingModel = .physicallyBased // 反射 box1.firstMaterial?.metalness.contents = 0.5 box1.firstMaterial?.metalness.intensity = 0.5 // 粗さ box1.firstMaterial?.roughness.intensity = 0.5 // 箱のノードを作る let box1Node = SCNNode(geometry: box1) // 右へ0.5m、下へ0.5m、奥へ0.8m に配置 box1Node.position = SCNVector3(0.5, -0.5, -0.8) // シーンに追加 sceneView.scene.rootNode.addChildNode(box1Node) // 反射する箱を作る let box2 = SCNBox(width: 0.2, height: 0.2, length: 0.2, chamferRadius: 0.01) // 塗り //box2.firstMaterial?.diffuse.contents = UIColor.gray // 物理ベースのレンダリング box2.firstMaterial?.lightingModel = .physicallyBased // 反射 box2.firstMaterial?.metalness.contents = 1.0 box2.firstMaterial?.metalness.intensity = 1.0 // 粗さ box2.firstMaterial?.roughness.intensity = 0.0 // 箱のノードを作る let box2Node = SCNNode(geometry: box2) // 左へ0.5m、下へ0.5m、奥へ0.8m に配置 box2Node.position = SCNVector3(-0.5, -0.5, -0.8) // シーンに追加 sceneView.scene.rootNode.addChildNode(box2Node) // 球体を作る let sphere = SCNSphere(radius: 0.1) // 塗り sphere.firstMaterial?.diffuse.contents = UIColor.black // 物理ベースのレンダリング sphere.firstMaterial?.lightingModel = .physicallyBased // 反射 sphere.firstMaterial?.metalness.contents = 0.2 sphere.firstMaterial?.metalness.intensity = 0.2 // 粗さ sphere.firstMaterial?.roughness.intensity = 0.8 // 球体のノードを作る let sphereNode = SCNNode(geometry: sphere) // 左へ1.0m、下へ0.5m、奥へ0.5m に配置 sphereNode.position = SCNVector3(-1.0, -0.5, -0.5) // シーンに追加 sceneView.scene.rootNode.addChildNode(sphereNode) // 地球を作る let earthNode = EarthNode() // 100秒かけてY軸回転を1回行う let action = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 100) earthNode.runAction(SCNAction.repeatForever(action)) // 右へ0.2m、上へ0.3m、奥へ1.2m に配置 earthNode.position = SCNVector3(0.2, 0.3, -1.2) // シーンに追加 sceneView.scene.rootNode.addChildNode(earthNode) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // 環境マッピングを有効にする configuration.environmentTexturing = .automatic // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
EarthNode.swift
import SceneKit import ARKit class EarthNode: SCNNode { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override init() { super.init() // 球体を作る let earch = SCNSphere(radius: 0.2) // 地球のテクスチャを貼り付ける earch.firstMaterial?.diffuse.contents = UIImage(named: "earth_1024") // ノードのgeometryプロパティに設定する geometry = earch } }
■タップした場所にオブジェクトを配置 ストーリーボードで Tap Gesture Recognizer ドラッグ&ドロップで配置 ViewController.swift に以下の設定でAction接続 Connection: Action Object: View Controller Name: tapSceneView Type: UITapGestureRecognizer ViewController.swift
import UIKit import SceneKit import ARKit class ViewController: UIViewController, ARSCNViewDelegate { @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // ワイヤーフレームを表示する //sceneView.debugOptions = .showWireframe // FPSやタイミングの情報を表示する sceneView.showsStatistics = true } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // 環境マッピングを有効にする configuration.environmentTexturing = .automatic // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // シーンビューsceneViewをタップした @IBAction func tapSceneView(_ sender: UITapGestureRecognizer) { // 現在のフレーム let frame = sceneView.session.currentFrame! // トランスフォームを作る var transform = matrix_identity_float4x4 transform.columns.3.z = -0.2 // カメラ正面の位置を作る let tf = simd_mul(frame.camera.transform, transform) let pos = SCNVector3(tf.columns.3.x, tf.columns.3.y, tf.columns.3.z) // 箱を作って追加する let boxNode = BoxNode() boxNode.position = pos sceneView.scene.rootNode.addChildNode(boxNode) } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
PlaneNode.swift
import SceneKit import ARKit class BoxNode: SCNNode { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override init() { super.init() // 箱を作る let box = SCNBox(width: 0.1, height: 0.05, length: 0.1, chamferRadius: 0.01) // 塗り box.firstMaterial?.diffuse.contents = UIColor.gray // 物理ベースのレンダリング box.firstMaterial?.lightingModel = .physicallyBased // 反射 box.firstMaterial?.metalness.contents = 0.5 box.firstMaterial?.metalness.intensity = 0.5 // 粗さ box.firstMaterial?.roughness.intensity = 0.5 // ノードのgeometryプロパティに設定する geometry = box } }
■タップした場所にオブジェクトを配置&オブジェクトをタップすると飛ばす ストーリーボードで Tap Gesture Recognizer ドラッグ&ドロップで配置 ViewController.swift に以下の設定でAction接続 Connection: Action Object: View Controller Name: tapSceneView Type: UITapGestureRecognizer ViewController.swift
import UIKit import SceneKit import ARKit class ViewController: UIViewController, ARSCNViewDelegate { @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // ワイヤーフレームを表示する //sceneView.debugOptions = .showWireframe // FPSやタイミングの情報を表示する sceneView.showsStatistics = true // 箱1を作って追加する(右へ0.5m、下へ0.1m、奥へ0.8m に配置) let boxNode1 = BoxNode() boxNode1.position = SCNVector3(0.5, -0.1, -0.8) sceneView.scene.rootNode.addChildNode(boxNode1) // 箱2を作って追加する(右へ0.8m、下へ0.5m、奥へ0.2m に配置) let boxNode2 = BoxNode() boxNode2.position = SCNVector3(0.8, -0.5, -0.2) sceneView.scene.rootNode.addChildNode(boxNode2) // 箱3を作って追加する(左へ1.0m、上へ0.3m、奥へ0.5m に配置) let boxNode3 = BoxNode() boxNode3.position = SCNVector3(0.8, -0.5, -0.8) sceneView.scene.rootNode.addChildNode(boxNode3) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // 環境マッピングを有効にする configuration.environmentTexturing = .automatic // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // シーンビューsceneViewをタップした @IBAction func tapSceneView(_ sender: UITapGestureRecognizer) { // タップした2D座標 let tapLoc = sender.location(in: sceneView) // 2D座標のヒットテスト let hitTestOptions = [SCNHitTestOption:Any]() let results = sceneView.hitTest(tapLoc, options: hitTestOptions) if let result = results.first { if let node = result.node as? BoxNode { // 現在のフレーム let frame = sceneView.session.currentFrame! // トランスフォームを作る let transform = matrix_identity_float4x4 // カメラ正面の位置を作る let tf = simd_mul(frame.camera.transform, transform) let pos = SCNVector3(tf.columns.3.x, tf.columns.3.y, tf.columns.3.z) // カメラとノードの距離 let x = node.position.x - pos.x let y = node.position.y - pos.y let z = node.position.z - pos.z let len = sqrt(x*x + y*y + z*z) // 距離を確認 if (len < 0.3) { // カメラからノード方向への単位ベクトル let unitVec = SCNVector3(x/len, y/len, z/len) // 力 let force:Float = 2.0 // 力のベクトル let forceVec = SCNVector3(force*unitVec.x, force*unitVec.y, force*unitVec.z) // ノードを弾くように力を加える node.physicsBody?.applyForce(forceVec, asImpulse: true) // 重力の影響を受けるように変更する node.physicsBody?.isAffectedByGravity = true } } } else { // 現在のフレーム let frame = sceneView.session.currentFrame! // トランスフォームを作る var transform = matrix_identity_float4x4 transform.columns.3.z = -0.2 // カメラ正面の位置を作る let tf = simd_mul(frame.camera.transform, transform) let pos = SCNVector3(tf.columns.3.x, tf.columns.3.y, tf.columns.3.z) // 箱を作って追加する let boxNode = BoxNode() boxNode.position = pos sceneView.scene.rootNode.addChildNode(boxNode) } } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
PlaneNode.swift
import SceneKit import ARKit class BoxNode: SCNNode { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override init() { super.init() // 箱を作る let box = SCNBox(width: 0.1, height: 0.05, length: 0.1, chamferRadius: 0.01) // 塗り box.firstMaterial?.diffuse.contents = UIColor.gray // 物理ベースのレンダリング box.firstMaterial?.lightingModel = .physicallyBased // 反射 box.firstMaterial?.metalness.contents = 0.5 box.firstMaterial?.metalness.intensity = 0.5 // 粗さ box.firstMaterial?.roughness.intensity = 0.5 // ノードのgeometryプロパティに設定する geometry = box // 物理ボディを設定する let bodyShape = SCNPhysicsShape(geometry: geometry!, options: [:]) physicsBody = SCNPhysicsBody(type: .dynamic, shape: bodyShape) // 重力の影響を受けない状態でスタートする physicsBody?.isAffectedByGravity = false // 摩擦 physicsBody?.friction = 2.0 // 反発力 physicsBody?.restitution = 0.2 } }
■平面検出 ViewController.swift
import UIKit import SceneKit import ARKit class ViewController: UIViewController, ARSCNViewDelegate { @IBOutlet var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() // シーンビューのデリゲートになる sceneView.delegate = self // ワイヤーフレームを表示する //sceneView.debugOptions = .showWireframe // FPSやタイミングの情報を表示する sceneView.showsStatistics = true } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // セッションのコンフィグを作成する let configuration = ARWorldTrackingConfiguration() // 平面の検出を有効にする(水平面と垂直面) configuration.planeDetection = [.horizontal, .vertical] // ビューのセッションを開始する sceneView.session.run(configuration) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // ビューのセッションを停止する sceneView.session.pause() } // ノードが追加された func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { // 平面アンカーではないときは中断する guard let planeAnchor = anchor as? ARPlaneAnchor else { return } // アンカーが示す位置に水平ノードを追加する node.addChildNode(PlaneNode(anchor: planeAnchor)) } // ノードが更新された func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { // 平面アンカーではないときは中断する guard let planeAnchor = anchor as? ARPlaneAnchor else { return } // PlaneNodeではないときは中断する guard let planeNode = node.childNodes.first as? PlaneNode else { return } // ノードの位置とサイズを調整する planeNode.update(anchor: planeAnchor) } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } }
PlaneNode.swift
import SceneKit import ARKit class PlaneNode: SCNNode { required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } init(anchor: ARPlaneAnchor) { super.init() // 平面のジオメトリを作る let plane = SCNBox(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z), length: CGFloat(anchor.extent.z), chamferRadius: 0.0) // 塗り(緑で半透明) plane.firstMaterial?.diffuse.contents = UIColor.green.withAlphaComponent(0.5) // ワイヤーフレーム表示の分割数(ワイヤーフレームにするかどうかはsceneViewで指定) plane.widthSegmentCount = 10 plane.heightSegmentCount = 1 plane.lengthSegmentCount = 10 // ノードのgeometryプロパティに設定する geometry = plane // X軸回りで90度回転 transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0) // 位置決め position = SCNVector3Make(anchor.center.x, 0, anchor.center.z) } // 位置とサイズを更新する func update(anchor: ARPlaneAnchor) { // ジオメトリを取り出す let plane = geometry as! SCNBox // アンカーから平面のサイズを更新する plane.width = CGFloat(anchor.extent.x) plane.length = CGFloat(anchor.extent.z) // 位置を更新する position = SCNVector3Make(anchor.center.x, 0, anchor.center.z) } }
■ARKit+Blender ※未検証 Hello AR !!: Blenderを使って3Dデータを作成&表示させてみよう https://www.nsunrise.work/2019/09/blender3d.html 【ARKit入門】Xcodeに3Dファイルを入れてみる - Qiita https://qiita.com/yyokii/items/667998a28d46b93fb1e3
■UIKit+Game
■デフォルトのプロジェクト 「Game」で以下の設定で新規作成する Product Name: game2d Language: Swift Game Technology: SpritKit 実行すると画面に「Hello, World!」と表示され、 画面をタップすると画面に反応がある 以下を参考に、引き続き調整していく iOS GameplayKitの「Agents, Goals, and Behaviors」で作る、鬼ごっごの鬼AI https://atmarkit.itmedia.co.jp/ait/articles/1701/30/news021.html ■不要な処理を削除してプレイヤーを表示 GameScene.sks を開き、Sceneの下にある helloLabel を削除する GameScene.swift のコードを、以下のように最低限のものに変更する GameScene.swift
import SpriteKit import GameplayKit class GameScene: SKScene { // プレイヤー let player = SKShapeNode(circleOfRadius: 10) // GameSceneがviewに設置されたとき(画面が表示されたとき)に呼ばれる override func didMove(to view: SKView) { // プレイヤー(黄色い丸)を画面に表示 player.fillColor = UIColor(red: 0.90, green: 0.90, blue: 0.10, alpha: 1.0) addChild(player) } }
■プレイヤーを動かす処理を追加 GameSceneクラスに以下のメソッドを追加する GameScene.swift
// タッチされた位置に向かってプレイヤーを移動 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { // すべてのタッチ位置を処理 touches.forEach { // タッチされた位置を取得 let point = $0.location(in: self) // 移動方向を決定 let path = CGMutablePath() path.move(to: CGPoint()) path.addLine(to: CGPoint(x: point.x - player.position.x, y: point.y - player.position.y)) // 設定済みのアニメーションを削除 player.removeAllActions() // SKActionでSKNodeをアニメーション(移動) player.run(SKAction.follow(path, speed: 50.0)) } }
■敵を追加 GameScene.swift
// 敵 var enemies = [SKShapeNode]() var timer: Timer? var prevTime: TimeInterval = 0 // GameSceneがviewに設置されたとき(画面が表示されたとき)に呼ばれる override func didMove(to view: SKView) { 〜略〜 // 敵を作成し続ける setCreateEnemyTimer() // 敵が落下しないようにする physicsWorld.gravity = CGVector() } 〜略〜 // 1フレームごとに呼ばれる override func update(_ currentTime: TimeInterval) { if prevTime == 0 { prevTime = currentTime } // プレイヤーの位置が変わるので、1秒ごとに移動方向を調整 if Int(currentTime) != Int(prevTime) { // すべての敵を処理 enemies.forEach { // 移動方向を決定 let path = CGMutablePath() path.move(to: CGPoint()) path.addLine(to: CGPoint(x: player.position.x - $0.position.x, y: player.position.y - $0.position.y)) // 設定済みのアニメーションを削除 $0.removeAllActions() // SKActionでSKNodeをアニメーション(移動) $0.run(SKAction.follow(path, speed: 50.0)) } } prevTime = currentTime } // 敵を作成し続ける func setCreateEnemyTimer() { timer?.invalidate() // 5秒ごとにcreateEnemyを呼び出す timer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(GameScene.createEnemy), userInfo: nil, repeats: true) timer?.fire() } // 敵を作成する @objc func createEnemy() { // 敵(赤い丸)を画面に表示する let enemy = SKShapeNode(circleOfRadius: 10) enemy.position.x = size.width / 2 enemy.fillColor = UIColor(red: 0.90, green: 0.10, blue: 0.10, alpha: 1.0) enemy.physicsBody = SKPhysicsBody(circleOfRadius: enemy.frame.width / 2) addChild(enemy) // 敵を配列で管理する enemies.append(enemy) }
■ゲームオーバーを追加 GameScene.swift
// 終了判定 var startTime: TimeInterval = 0 var isGameFinished = false 〜略〜 // 1フレームごとに呼ばれる override func update(_ currentTime: TimeInterval) { if prevTime == 0 { prevTime = currentTime startTime = currentTime } 〜略〜 // ゲームオーバーを判定 if !isGameFinished { // すべての敵を処理 for enemy in enemies { // 敵の位置を取得 let dx = enemy.position.x - player.position.x let dy = enemy.position.y - player.position.y // プレイヤーと敵の接触を確認 if sqrt(dx*dx + dy*dy) < player.frame.width / 2 + enemy.frame.width / 2 { // ゲームを終了 isGameFinished = true timer?.invalidate() // 結果を表示 let label = SKLabelNode(text: "記録:\(Int(currentTime - startTime))秒") label.fontSize = 80 label.position = CGPoint(x: 0, y: -100) addChild(label) break } } } prevTime = currentTime }
■SwiftUI
■考察 「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 #Swift - Qiita https://qiita.com/karamage/items/8a9c76caff187d3eb838 ■初期コード SwiftUIでの初期コードは以下のとおり ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("Hello, world!") .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
Xcode15の時点では、以下のように変わっていた ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world.") } .padding() } } #Preview { ContentView() }
■プレビューのサイズを変更 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("Hello, world!") .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() // プレビューのキャンバスサイズを変更 .previewLayout(.fixed(width: 200, height: 80)) } }
■横に並べて表示 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { HStack { Text("AAA") Text("BBB") Text("CCC") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
以下のように spacing を指定すると、要素間に余白を設けることができる
HStack (spacing: 20) { Text("AAA") Text("BBB") Text("CCC") }
■縦に並べて表示 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack { Text("AAA") Text("BBB") Text("CCC") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
以下のように spacing を指定すると、要素間に余白を設けることができる
VStack (spacing: 20) { Text("AAA") Text("BBB") Text("CCC") }
■スペーサー ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack { Text("AAA") Spacer() Text("BBB") Spacer() Text("CCC") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■余白 padding() で余白を設定できる padding(20) で余白のサイズを指定できる .padding(.vertical, 100) で特定の方向の余白を指定できる EdgeInsets() を組み合わせることで、上下左右を個別に指定することもできる
Text("Memo") .padding(EdgeInsets( top: 8, leading: 0, bottom: 0, trailing: 0 ))
【SwiftUI】Viewに余白を付加する(padding) | カピ通信 https://capibara1969.com/1954/ 【SwiftUI】余白を追加するModifier「padding」の使い方まとめ | Swift Note https://naoya-ono.com/swift/swiftui-modifier-padding/ ■フォントの指定 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack (spacing: 10) { Text("largeTitle").font(.largeTitle) Text("title").font(.title) Text("headline").font(.headline) Text("subheadline").font(.subheadline) Text("body").font(.body) Text("callout").font(.callout) Text("footnote").font(.footnote) Text("caption").font(.caption) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■テキストの装飾 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack (spacing: 10) { // 文字数を制御 Text("これはサンプルですこれはサンプルです").font(.title).frame(width: 300, height: 50).truncationMode(.middle) // 行数を制御 Text("これはサンプルですこれはサンプルですこれはサンプルですこれはサンプルですこれはサンプルです").lineLimit(2) // 文字の色を制御 Text("これはサンプルです").foregroundColor(.red) // 文字の太さを制御 Text("これはサンプルです").fontWeight(.bold) // 文字の下線を制御 Text("これはサンプルです").underline() // 文字の打ち消し線を制御 Text("これはサンプルです").strikethrough() // 文字の間隔を制御 Text("これはサンプルです").kerning(3) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■ボタンの表示 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Button(action: { print("ボタンが押されました") }) { Text("ボタン") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■ボタンの装飾 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Button(action: { print("ボタンが押されました") }) { VStack { // 画像を配置 Image(systemName: "camera") .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) // 色を指定してテキストを配置 Text("ボタン") .foregroundColor(.black) } // タップ領域を広く .frame(width: 150, height: 100) // 角丸の枠線を付ける .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.black, lineWidth: 2) ) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■リスト表示 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { List { Text("コンテンツ1") Text("コンテンツ2") Text("コンテンツ3") Text("コンテンツ4") Text("コンテンツ5") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
以下はアイコンとともにリスト表示する例 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { List { HStack { Image(systemName: "moon") Text("moon") } HStack { Image(systemName: "sun.max") Text("sun") } HStack { Image(systemName: "cloud") Text("cloud") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■画像と独自ビューをリスト表示 ContentView.swift
import SwiftUI struct ContentView: View { struct PhotoSample: View { var body: some View { HStack { Image("caramel") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100) .clipShape(Circle()) .overlay( Text("ハロー!") //.font(.title) .fontWeight(.bold) .foregroundColor(.white) //.offset(x: 0, y: -50) .shadow(radius: 2) ) } } } var body: some View { List { Text("コンテンツ1") Text("コンテンツ2") Image("brownie") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100) Text("コンテンツ3") PhotoSample() Text("コンテンツ4") Text("コンテンツ5") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■配列をリスト表示 ContentView.swift
import SwiftUI let metro = [ "銀座線", "丸ノ内線", "日比谷線", "東西線", "千代田線", "半蔵門線", "南北線", "副都心線", ] struct ContentView: View { var body: some View { List(0 ..< metro.count) { item in HStack { Text(String(item)) Text(metro[item]) } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■配列を複数のセクションでリスト表示 ContentView.swift
import SwiftUI let shikoku = [ "徳島県", "香川県", "愛媛県", "高知県", ] let kyushu = [ "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", ] struct ContentView: View { var body: some View { NavigationView { List { Section(header: Text("四国"), footer: Text("四国の都道府県一覧")) { ForEach(0 ..< shikoku.count) { index in Text(shikoku[index]) } } Section(header: Text("九州"), footer: Text("九州の都道府県一覧")) { ForEach(0 ..< kyushu.count) { index in Text(kyushu[index]) } } } .navigationTitle("タイトル") .navigationBarTitleDisplayMode(.inline) .listStyle(GroupedListStyle()) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■簡単なナビゲーションリンク ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { NavigationView { NavigationLink(destination: SubView()) { Text("サブビューへ移動") .padding() } .navigationTitle("ホーム") } } } struct SubView: View { var body: some View { Text("サブビュー") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■写真の一覧と詳細を表示 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { NavigationView { List(photoArray) { item in NavigationLink(destination: PhotoDetailView(photo: item)) { RowView(photo: item) } } .navigationTitle(Text("写真リスト")) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
PhotoDetailView.swift
import SwiftUI struct PhotoDetailView: View { var photo: PhotoData var body: some View { VStack { Image(photo.imageName) .resizable() .aspectRatio(contentMode: .fit) Text(photo.title) Spacer() } .padding() // タイトル .navigationTitle(Text(verbatim: photo.title)) .navigationBarTitleDisplayMode(.inline) } } struct PhotoDetailView_Previews: PreviewProvider { static var previews: some View { PhotoDetailView(photo:photoArray[0]) } }
RowView.swift
import SwiftUI struct RowView: View { var photo: PhotoData var body: some View { HStack { Image(photo.imageName) .resizable() .frame(width: 110, height: 80) Text(photo.title) Spacer() } } } struct RowView_Previews: PreviewProvider { static var previews: some View { RowView(photo:photoArray[0]) .previewLayout(.fixed(width: 300, height: 80)) } }
PhotoData.swift
import Foundation // 写真データを配列に入れる var photoArray: [PhotoData] = makeData() // 写真データを構造体で定義する struct PhotoData: Identifiable { var id: Int var imageName: String var title: String } // 構造体PhotoData型の写真データが入った配列を作る func makeData()->[PhotoData] { var dataArray: [PhotoData] = [] dataArray.append(PhotoData(id: 1, imageName: "photo01", title: "写真1")) dataArray.append(PhotoData(id: 2, imageName: "photo02", title: "写真2")) dataArray.append(PhotoData(id: 3, imageName: "photo03", title: "写真3")) dataArray.append(PhotoData(id: 4, imageName: "photo04", title: "写真4")) dataArray.append(PhotoData(id: 5, imageName: "photo05", title: "写真5")) dataArray.append(PhotoData(id: 6, imageName: "photo06", title: "写真6")) dataArray.append(PhotoData(id: 7, imageName: "photo07", title: "写真7")) dataArray.append(PhotoData(id: 8, imageName: "photo08", title: "写真8")) dataArray.append(PhotoData(id: 9, imageName: "photo09", title: "写真9")) dataArray.append(PhotoData(id: 10, imageName: "photo10", title: "写真10")) dataArray.append(PhotoData(id: 11, imageName: "photo11", title: "写真11")) dataArray.append(PhotoData(id: 12, imageName: "photo12", title: "写真12")) dataArray.append(PhotoData(id: 13, imageName: "photo13", title: "写真13")) dataArray.append(PhotoData(id: 14, imageName: "photo14", title: "写真14")) dataArray.append(PhotoData(id: 15, imageName: "photo15", title: "写真15")) dataArray.append(PhotoData(id: 16, imageName: "photo16", title: "写真16")) return dataArray }
■タブビュー ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { TabView { Text("テスト") .fontWeight(.bold) .tabItem { Image(systemName: "message") Text("Message") } TextPage() .tabItem { Image(systemName: "iphone") Text("iPhone") } ListPage() .tabItem { Image(systemName: "list.dash") Text("List") } } } } struct TextPage: View { var body: some View { Text("コンテンツ") } } struct ListPage: View { var body: some View { List { Text("コンテンツ1") Text("コンテンツ2") Text("コンテンツ3") Text("コンテンツ4") Text("コンテンツ5") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■スクロールビュー ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { ScrollView(.vertical, showsIndicators: true, content: { VStack { Text("コンテンツ1").frame(height: 100) Text("コンテンツ2").frame(height: 100) Text("コンテンツ3").frame(height: 100) Text("コンテンツ4").frame(height: 100) Text("コンテンツ5").frame(height: 100) Text("コンテンツ6").frame(height: 100) Text("コンテンツ7").frame(height: 100) Text("コンテンツ8").frame(height: 100) Text("コンテンツ9").frame(height: 100) Text("コンテンツ10").frame(height: 100) } .frame(maxWidth: .infinity) }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■アラート ContentView.swift
import SwiftUI struct ContentView: View { @State var show: Bool = false var body: some View { Button(action: { show = true }) { Text("Alertテスト") }.alert(isPresented: $show, content: { Alert( title: Text("タイトル"), message: Text("メッセージ"), dismissButton: .default(Text("OK"), action: { print("OKがタップされました") }) ) }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■アクションシート ContentView.swift
import SwiftUI struct ContentView: View { @State var show: Bool = false var body: some View { Button(action: { show = true }) { Text("ActionSheetテスト") }.actionSheet(isPresented: $show, content: { ActionSheet( title: Text("タイトル"), message: Text("メッセージ"), buttons: [ .default(Text("選択肢1"), action: { print("選択肢1がタップされました") }), .default(Text("選択肢2"), action: { print("選択肢2がタップされました") }), .default(Text("選択肢3"), action: { print("選択肢3がタップされました") }) ] ) }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■シート iOS14から対応したもの シート型のモーダル表示を行う ContentView.swift
import SwiftUI struct ContentView: View { @State var show: Bool = false var body: some View { Button(action: { show = true }) { Text("Sheetテスト") }.sheet(isPresented: $show, content: { Text("これはシートの内容です。").padding(10) Button(action: { show = false }) { Text("閉じる") }.padding(10) }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■リンク ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { List { Link(destination: URL(string: "https://www.apple.com/jp/")!, label: { HStack { Image(systemName: "link") Text("Apple") } }) Link(destination: URL(string: "https://www.google.com/?hl=ja")!, label: { HStack { Image(systemName: "link") Text("Google") } }) Link(destination: URL(string: "https://www.microsoft.com/ja-jp")!, label: { HStack { Image(systemName: "link") Text("Microsoft") } }) Link(destination: URL(string: "https://www.amazon.co.jp/")!, label: { HStack { Image(systemName: "link") Text("Amazon") } }) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■リードオンリーの変数 ContentView.swift
import SwiftUI struct ContentView: View { var test2: Int { get { 5 * 3 } } var body: some View { VStack { let test1 = 10 Text("test1 = " + String(test1)) Text("test2 = " + String(test2)) Spacer() } .padding(.top, 50) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■プロパティ データが値型で、Viewがデータを読み込みだけする場合、そのデータはプロパティで管理できる 読み込み用なので let で定義するといい ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { HStack { SampleView(color: .red) SampleView(color: .green) SampleView(color: .blue) } } } struct SampleView: View { let color: Color var body: some View { Circle().foregroundColor(color) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■some Opaque Result Typesのプロトコルに準拠した型であることを表す構文 Opaque Result Typesに準拠することで、内部実装を隠蔽しながらパフォーマンスにも影響させずに返り値の型を抽象化することができる 【SwiftUI】someってなに?詳しく説明 | S.T.Blog https://shuhey-hashimoto.com/swiftui/swiftuisome%E3%81%A3%E3%81%A6%E3%81%AA%E3%81%AB%E7%B0%A1%E5%8D%... SwiftUIに出てくるsomeとは何なのか | レコチョクのエンジニアブログ https://techblog.recochoku.jp/7754 SwiftUIで何気なく使っている some を調べてみる - Qiita https://qiita.com/masa7351/items/1e6b235c1c0d3f54b3de ■Combine ※勉強中メモ イベントを発行する側と受け取る側に分かれて、あるイベントが発行されたら、それを受け取った側の処理が走ることを容易にしたフレームワーク 【Swift】Combine入門レベルのまとめ - Qiita https://qiita.com/kamomeKUN/items/394e668ee8c2a0b3327a 【Swift】 Combineを使用するメリットについて考えてみる | レコチョクのエンジニアブログ https://techblog.recochoku.jp/8537 Combine初心者講座 -SwiftUIの相棒を使いこなそう- - bravesoft blog https://www.bravesoft.co.jp/blog/archives/15610 SwiftUIとCombineの基礎 - アドグローブブログ | 渋谷のIT会社 https://blog.adglobe.co.jp/entry/2022/05/13/100000 【Combine】Timerの処理をCombineを使って置き換える - Swift・iOS https://www.hfoasi8fje3.work/entry/2021/08/22/%E3%80%90Combine%E3%80%91Timer%E3%81%AE%E5%87%A6%E7%90... SwiftUIとCombineを使ったMVVMの実装 - トレタ開発者ブログ https://tech.toreta.in/entry/2019/12/24/104612 Combine入門 | CombineでTimer処理を行う方法 | アールケー開発 https://www.rk-k.com/archives/3943 Combine入門 | CombineでNotificationを受け取る方法 | アールケー開発 https://www.rk-k.com/archives/3937 RxSwiftとは?導入方法と使い方まとめ!ストリームを理解する https://tech.amefure.com/swift-rxswift ■@State @State を付与したプロパティが更新されると、SwiftUIが自動的に関連するデータを更新する 外から渡されるデータでは無いので、private を付けておくといい ContentView.swift
import SwiftUI struct ContentView: View { @State private var counter = 0 var body: some View { Button(action: { counter += 1 }, label: { Text("counter is \(counter)") }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
以下のように変数名に $ を付けると、TextFieldの値が更新されると自動でTextの表示も更新される ContentView.swift
import SwiftUI struct ContentView: View { @State private var inputText: String = "" var body: some View { VStack { TextField("", text: $inputText) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() Text(inputText) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■@Published @State はViewごとに変数を宣言して使用する @Published は ObservableObject クラスに準拠させたクラス内で定義することにより、複数のViewから使用することができる。その際、@ObservedObject で宣言して使用する これにより、クラスの値が変更されると、ビューが自動的に再描画される つまり @ObservedObject とは、@State を複数同時に宣言できるクラスのこと 【SwiftUI】ObservableObjectについて(クラス、入れ子、配列など) | thwork https://thwork.net/2021/08/31/swiftui_observableobject/ 【SwiftUI】@ObservedObjectの使い方を徹底解説 | iOS-Docs https://ios-docs.dev/swiftui-observerdobjects/ SwiftUIにおけるObservableObjectの管理 https://zenn.dev/yorifuji/articles/swiftui-manage-observableobject ■@Binding @State を付与したデータを子Viewに渡す場合、@Binding のデータとして渡す @Binding は外からの値を受け取ることになるので、private にはしない ContentView.swift
import SwiftUI struct ContentView: View { @State private var counter = 0 var body: some View { HStack { SampleView(counter: $counter) .frame(width: .infinity) } } } struct SampleView: View { @Binding var counter: Int var body: some View { Button(action: { counter += 1 }, label: { Text("counter is \(counter)") }) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■@Environment @Environment を付与すると、View の環境値を読み取ることができる スクリーンの解像度や言語と地域情報などをが集められている 以下は端末がライトモードかダークモードかを読み取る方法 ContentView.swift
import SwiftUI struct ContentView: View { @Environment(\.colorScheme) var colorScheme: ColorScheme // 「\」はバックスラッシュ var body: some View { if colorScheme == .dark { Text("ダークモード") } else if colorScheme == .light { Text("ライトモード") } else { Text("不明") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■SwiftUI+Timer
【Combine】Timerの処理をCombineを使って置き換える - Swift・iOS https://www.hfoasi8fje3.work/entry/2021/08/22/%E3%80%90Combine%E3%80%91Timer%E3%81%AE%E5%87%A6%E7%90... ContentView.swift
import SwiftUI struct ContentView: View { @ObservedObject private var viewModel = TimerModel() var body: some View { VStack { Text("\(viewModel.count)") .font(.title) .fontWeight(.bold) .padding() if viewModel.count <= 0 { Text("終了").padding() } else if viewModel.isTimerRunning { if (viewModel.count <= 3) { Text("もうすぐ終了").padding() } else { Text("カウントダウン中").padding() } } else { Text("ストップ中").padding() } Button("Start") { viewModel.startCounting() } .disabled(viewModel.isTimerRunning) Button("Stop") { viewModel.stopCounting() } .disabled(!viewModel.isTimerRunning) .padding() Button("Reset") { viewModel.resetCount() } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() // プレビュー表示のために必要 //.environmentObject(TimerModel()) } }
TimerModel.swift
import Foundation import Combine class TimerModel: ObservableObject { @Published var count = 10 @Published var isTimerRunning = false private var cancellable: AnyCancellable? func startCounting() { isTimerRunning = true cancellable = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .sink { _ in if self.count <= 0 { self.stopCounting() } else { self.count -= 1 } } } func stopCounting() { isTimerRunning = false cancellable?.cancel() } func resetCount() { count = 10 } }
■SwiftUI+マップ
ContentView.swift
import SwiftUI import MapKit struct ContentView: View { // 座標と領域を指定する @State var region = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: 35.6805702, // 緯度 longitude: 139.7676359 // 経度 ), latitudinalMeters: 1000.0, // 南北距離 longitudinalMeters: 1000.0 // 東西距離 ) var body: some View { // 地図を表示する Map(coordinateRegion: $region) .edgesIgnoringSafeArea(.bottom) //Text("Hello, world!").padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■SwiftUI+ネットワーク
■JSONを取得して表示する 【Swift】URLSessionまとめ - Qiita https://qiita.com/shiz/items/09523baf7d1cd37f6dee ContentView.swift
import SwiftUI struct ContentView: View { @State private var result = "Now Loading..." var body: some View { Text(result) .padding() .onAppear { let target = URL(string: "https://refirio.org/memos/ios/json_test.php")! let task = URLSession.shared.dataTask(with: target) { data, response, error in if let data = data { do { let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) guard let status = (json as AnyObject).object(forKey: "status") else { throw NSError(domain: "ステータスを取得できません", code: -1, userInfo: nil) } guard let message = (json as AnyObject).object(forKey: "message") else { throw NSError(domain: "メッセージを取得できません", code: -1, userInfo: nil) } print(status) print(message) if let status = status as? String { if (status != "OK") { result = "不正なステータスです" throw NSError(domain: result, code: -1, userInfo: nil) } } if let message = message as? String { result = message } } catch { print(error.localizedDescription) } } } task.resume() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
json_test.php の内容は以下のとおり
<?php $data = array( 'status' => 'OK', 'message' => '完了しました。', ); echo json_encode($data); exit;
■JSONを取得して一覧に表示する 【SwiftUI】ForEachの使い方(2/2) | カピ通信 https://capibara1969.com/1650/ ContentView.swift
import SwiftUI struct Book: Identifiable { var id = UUID() var title: String var price: Int } struct ContentView: View { @State private var books: [Book] = [] var body: some View { List { ForEach(books) { book in HStack { Text(book.title) Spacer() Text(String(book.price) + "円") } } }.onAppear { let target = URL(string: "https://refirio.org/memos/ios/json_book.php")! let task = URLSession.shared.dataTask(with: target) { data, response, error in if let data = data { do { let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) if let items = (json as AnyObject).object(forKey: "books") { for item in items as! NSArray { guard let titleObject = (item as AnyObject).object(forKey: "title") else { continue } guard let priceObject = (item as AnyObject).object(forKey: "price") else { continue } guard let title = titleObject as? String else { continue } guard let price = priceObject as? String else { continue } print(title) print(price) books.append(Book(title: title, price: Int(price)!)) } } } catch { print(error.localizedDescription) } } } task.resume() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
json_book.php の内容は以下のとおり
<?php $data = array( 'books' => array( array( 'title' => 'C言語入門', 'price' => '1500' ), array( 'title' => 'JAVA言語入門', 'price' => '1600' ), array( 'title' => 'Ruby言語入門', 'price' => '2000' ) ) ); echo json_encode($data); exit;
■インターネット上の画像を表示 SwiftUIで非同期で画像を表示する方法 - Qiita https://qiita.com/From_F/items/e3eb8bd279f75b864865 ImageDownloader.swift
import Foundation class ImageDownloader : ObservableObject { @Published var downloadData: Data? = nil func downloadImage(url: String) { guard let imageURL = URL(string: url) else { return } DispatchQueue.global().async { let data = try? Data(contentsOf: imageURL) DispatchQueue.main.async { self.downloadData = data } } } }
URLImage.swift
import SwiftUI struct URLImage: View { let url: String @ObservedObject private var imageDownloader = ImageDownloader() init(url: String) { self.url = url self.imageDownloader.downloadImage(url: self.url) } var body: some View { if let imageData = self.imageDownloader.downloadData { return Image(uiImage: UIImage(data: imageData)!).resizable() } else { return Image(uiImage: UIImage(systemName: "icloud.and.arrow.down")!).resizable() } } }
ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { VStack { URLImage(url: "https://1.bp.blogspot.com/-_CVATibRMZQ/XQjt4fzUmjI/AAAAAAABTNY/nprVPKTfsHcihF4py1KrLfIqioNc_c41gCLcBGAs/s400/animal_chara_smartphone_penguin.png") .aspectRatio(contentMode: .fit) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■RSSリーダー ImageDownloader.swift と URLImage.swift が必要 内容は「インターネット上の画像を表示」を参照 ContentView.swift
import SwiftUI struct Article: Identifiable { var id = UUID() var title: String var url: String var image: String var datetime: String var name: String } struct ContentView: View { @State private var articles: [Article] = [] var body: some View { List { ForEach(articles) { article in Link(destination: URL(string: article.url)!, label: { VStack { Text(article.title).font(.title).frame(maxWidth: .infinity, alignment: .leading).lineLimit(1).truncationMode(.tail) Text("by " + article.name + " at " + article.datetime).frame(maxWidth: .infinity, alignment: .leading).lineLimit(1).truncationMode(.middle) if article.image != "" { URLImage(url: article.image).aspectRatio(contentMode: .fit) } } }) } }.onAppear { getData() } } /* * JSONデータを取得 */ func getData() { let target = URL(string: "https://refirio.org/reader/?type=json")! let task = URLSession.shared.dataTask(with: target) { data, response, error in if let data = data { do { let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) if let items = (json as AnyObject).object(forKey: "articles") { for item in items as! NSArray { guard let titleObject = (item as AnyObject).object(forKey: "title") else { continue } guard let urlObject = (item as AnyObject).object(forKey: "url") else { continue } guard let imageObject = (item as AnyObject).object(forKey: "image") else { continue } guard let datetimeObject = (item as AnyObject).object(forKey: "datetime") else { continue } guard let nameObject = (item as AnyObject).object(forKey: "name") else { continue } guard let title = titleObject as? String else { continue } guard let url = urlObject as? String else { continue } /* guard let image = imageObject as? String else { continue } */ guard let datetime = datetimeObject as? String else { continue } guard let name = nameObject as? String else { continue } var image:String if imageObject is NSNull { image = "" } else { image = imageObject as! String } print(title) print(url) print(image) print(datetime) print(name) articles.append( Article( title: title, url: url, image: image, datetime: convertDateFormat(datetime: datetime), name: name ) ) } } } catch { print(error.localizedDescription) } } } task.resume() } /* * 日時をフォーマットして返す */ func convertDateFormat(datetime:String) -> String { // 引数で渡ってきた文字列をDateFormatterでDateにする let inFormatter = DateFormatter() inFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss" let date:Date = inFormatter.date(from: datetime)! as Date // Dateから指定のフォーマットの文字列に変換する let outFormatter = DateFormatter() outFormatter.dateFormat = "MM/dd HH:mm" return outFormatter.string(from: date as Date) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■データをPOSTする URLsessionを用いたHTTPリクエストの方法(Swift, Xcode) - Qiita https://qiita.com/shungo_m/items/64564fd822a7558ac7b1 HTTP GETとPOST(Swift) [URLRequest, URLSession] iOS Objective-C, Swift Tips-モバイル開発系(K) http://www.office-matsunaga.biz/ios/description.php?id=54 Swift で日本語を含む URL を扱う - Qiita https://qiita.com/yum_fishing/items/db029c097197e6b27fba ContentView.swift
import SwiftUI struct ContentView: View { @State private var title = "" @State private var text = "" var body: some View { VStack { Text("データを編集します。") .padding(10) TextField("タイトル", text: $title) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) TextEditor(text: $text) .frame(width: UIScreen.main.bounds.width * 0.95, height: 200) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color(red: 0.9, green: 0.9, blue: 0.9), lineWidth: 1) ) Button(action: { let titleValue = String(title).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" let textValue = String(text).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" let url = URL(string: "https://refirio.org/memos/ios/request/post.php")! var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = String("title=" + titleValue + "&text=" + textValue).data(using: .utf8) let task = URLSession.shared.dataTask(with: request) { (data, response, error) in guard let data = data else { return } do { let object = try JSONSerialization.jsonObject(with: data, options: []) print(object) } catch let error { print(error) } } task.resume() }) { Text("保存") }.padding(10) } .onAppear { let target = URL(string: "https://refirio.org/memos/ios/request/get.php")! let task = URLSession.shared.dataTask(with: target) { data, response, error in if let data = data { do { let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) guard let statusData = (json as AnyObject).object(forKey: "status") else { throw NSError(domain: "ステータスを取得できません", code: -1, userInfo: nil) } guard let titleData = (json as AnyObject).object(forKey: "title") else { throw NSError(domain: "タイトルを取得できません", code: -1, userInfo: nil) } guard let textData = (json as AnyObject).object(forKey: "text") else { throw NSError(domain: "テキストを取得できません", code: -1, userInfo: nil) } print(statusData) print(titleData) print(textData) let result: String if let status = statusData as? String { if (status != "OK") { result = "不正なステータスです" throw NSError(domain: result, code: -1, userInfo: nil) } } if let titleString = titleData as? String { title = titleString } if let textString = textData as? String { text = textString } } catch { print(error.localizedDescription) } } } task.resume() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
各サーバサイドプログラムの内容は以下のとおり index.php は動作確認用 get.php
<?php // データを取得 $result = file_get_contents('./data.txt'); if ($result === false) { $result = json_encode(array( 'status' => 'NG', )); } list($title, $text) = explode("\n", $result); $title = str_replace('\n', "\n", $title); $text = str_replace('\n', "\n", $text); $result = array( 'status' => 'OK', 'title' => $title, 'text' => $text, ); // 結果を返す echo json_encode($result);
post.php
<?php // データを取得 $title = str_replace(array("\r\n","\n","\r"), '\n', $_POST['title']); $text = str_replace(array("\r\n","\n","\r"), '\n', $_POST['text']); // データを保存 if (file_put_contents('./data.txt', $title . "\n" . $text) === false) { $result = array( 'status' => 'NG', ); } else { $result = array( 'status' => 'OK', ); } // 結果を返す echo json_encode($result);
index.php
<?php if ($_SERVER['REQUEST_METHOD'] === 'POST') { $result = file_get_contents( 'http://localhost/~refirio_org/memos/ios/request/post.php', false, stream_context_create( array( 'http' => array( 'method' => 'POST', 'header' => 'Content-Type: application/x-www-form-urlencoded', 'content' => http_build_query( array( 'title' => $_POST['title'], 'text' => $_POST['text'], ) ) ) ) ) ); if ($result === false) { exit('NG'); } else { exit('OK'); } } else { $result = file_get_contents('http://localhost/~refirio_org/memos/ios/request/get.php'); if ($result === false) { $result = ''; } $json = json_decode($result, true); if (empty($json) || $json['status'] !== 'OK') { $json['title'] = 'タイトル'; $json['text'] = 'テキスト'; } } ?> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Request</title> </head> <body> <h1>Request</h1> <form action="index.php" method="post"> <fieldset> <legend>送信フォーム</legend> <dl> <dt>タイトル</dt> <dd><input type="text" name="title" size="30" value="<?php echo htmlspecialchars($json['title'], ENT_QUOTES) ?>"></dd> <dt>テキスト</dt> <dd><textarea name="text" rows="10" cols="50"><?php echo htmlspecialchars($json['text'], ENT_QUOTES) ?></textarea></dd> </dl> <p><input type="submit" value="送信する"></p> </fieldset> </form> </html>
■SwiftUI+リストの編集
■リストの編集(配列版) SwiftUIでリストを編集する - すいすいSwift https://swiswiswift.com/2019-12-17/ 【SwiftUI】Listの行削除 | カピ通信 https://capibara1969.com/1443/ [SwiftUI] List の要素削除 の実装方法 | SmallDeskSoftware https://software.small-desk.com/development/2020/10/08/swiftui-list-ondelete/ 【SwiftUI】Viewの編集モード(editMode)について | カピ通信 https://capibara1969.com/2625/ 行単位の直接編集モードは作れるかも? 【SwiftUI】TextField付きAlertを表示する - .NET ゆる〜りワーク https://www.yururiwork.net/%E3%80%90swiftui%E3%80%91textfield%E4%BB%98%E3%81%8Dalert%E3%82%92%E8%A1%... 入力欄付きのアラートは現状SwiftUIの標準では作れないみたい? もしくは可能なら、カラの状態で一覧に追加して、直接編集モードを有効にしておく…ができるならそれもいいかもしれない ContentView.swift
import SwiftUI struct ContentView: View { @State private var users = ["Paul", "Taylor", "Adele"] @State private var showDialog = false @State private var inputName = "" var userDefaults = UserDefaults.standard var body: some View { NavigationView { List { ForEach(users, id: \.self) { user in Text(user) } .onMove(perform: move) .onDelete(perform: delete) } .navigationBarTitle("ユーザ", displayMode: .inline) .navigationBarItems(trailing: HStack { Button(action: { inputName = "" showDialog = true }) { Text("追加") }.sheet(isPresented: $showDialog, onDismiss: { // 要素を追加すると、エミュレータではリストの編集が正しく動作しなくなる? users.insert(inputName, at: 0) // userDefaultsに値を保存 userDefaults.set(users, forKey: "users") }, content: { Text("これはシートの内容です。") .padding(10) TextField("ユーザ名", text: $inputName) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) Button(action: { // 要素を追加すると、リストの編集が正しく動作しなくなる? //users.insert(inputName, at: 0) //users.append("Test") showDialog = false }) { Text("閉じる") }.padding(10) }) //EditButton() MyEditButton() } ) .onAppear { // userDefaultsから値を取得 users = UserDefaults.standard.stringArray(forKey: "users") ?? [String]() } } } func move(from source: IndexSet, to destination: Int) { users.move(fromOffsets: source, toOffset: destination) // userDefaultsに値を保存 userDefaults.set(users, forKey: "users") } func delete(at offsets: IndexSet) { users.remove(atOffsets: offsets) // userDefaultsに値を保存 userDefaults.set(users, forKey: "users") } } struct MyEditButton: View { @Environment(\.editMode) var editMode var body: some View { Button(action: { withAnimation() { if editMode?.wrappedValue.isEditing == true { editMode?.wrappedValue = .inactive } else { editMode?.wrappedValue = .active } } }) { if editMode?.wrappedValue.isEditing == true { Text("完了") } else { Text("編集") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■リストの編集(構造体版) ContentView.swift
import SwiftUI struct Article: Identifiable { var id = UUID() var title: String var text: String } struct ContentView: View { @State private var articles: [Article] = [] @State private var showDialog = false @State private var inputTitle = "" @State private var inputText = "" var body: some View { NavigationView { List { ForEach(articles) { article in Text(article.title) } .onMove(perform: move) .onDelete(perform: delete) } .navigationBarTitle("記事", displayMode: .inline) .navigationBarItems(trailing: HStack { Button(action: { showDialog = true inputTitle = "" inputText = "" }) { Text("追加") }.sheet(isPresented: $showDialog, onDismiss: { articles.insert( Article( title: inputTitle, text: inputText ) , at: 0) }, content: { Text("記事を追加します。") .padding(10) TextField("タイトル", text: $inputTitle) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) TextField("テキスト", text: $inputText) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) Button(action: { showDialog = false }) { Text("閉じる") }.padding(10) }) MyEditButton() } ) } } func move(from source: IndexSet, to destination: Int) { articles.move(fromOffsets: source, toOffset: destination) } func delete(at offsets: IndexSet) { articles.remove(atOffsets: offsets) } } struct MyEditButton: View { @Environment(\.editMode) var editMode var body: some View { Button(action: { withAnimation() { if editMode?.wrappedValue.isEditing == true { editMode?.wrappedValue = .inactive } else { editMode?.wrappedValue = .active } } }) { if editMode?.wrappedValue.isEditing == true { Text("完了") } else { Text("編集") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■リストの編集(構造体版 / 高機能版) ContentView.swift
import SwiftUI struct ContentView: View { @State private var articles: [Article] = [] var body: some View { NavigationView { List { ForEach(articles) { article in NavigationLink(destination: EditView(id: article.id).onDisappear(perform: { articles = loadArticles() })) { Text(article.title) } } .onMove(perform: move) .onDelete(perform: delete) } .navigationBarTitle("記事", displayMode: .inline) .navigationBarItems(trailing: HStack { NavigationLink(destination: AddView().onDisappear(perform: { articles = loadArticles() })) { Text("追加") } MyEditButton() } ) } .onAppear { articles = loadArticles() } } func move(from source: IndexSet, to destination: Int) { articles.move(fromOffsets: source, toOffset: destination) saveArticles(data: articles) } func delete(at offsets: IndexSet) { articles.remove(atOffsets: offsets) saveArticles(data: articles) } } struct MyEditButton: View { @Environment(\.editMode) var editMode var body: some View { Button(action: { withAnimation() { if editMode?.wrappedValue.isEditing == true { editMode?.wrappedValue = .inactive } else { editMode?.wrappedValue = .active } } }) { if editMode?.wrappedValue.isEditing == true { Text("完了") } else { Text("編集") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
AddView.swift
import SwiftUI struct AddView: View { @Environment(\.presentationMode) var presentation @State private var title = "" @State private var text = "" var body: some View { VStack { Text("記事を追加します。") .padding(10) TextField("タイトル", text: $title) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) TextField("テキスト", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) Button(action: { let temps = loadArticles() var articles: [Article] = [] articles.append( Article( title: title, text: text ) ) for temp in temps { articles.append(temp) } saveArticles(data: articles) self.presentation.wrappedValue.dismiss() }) { Text("追加") }.padding(10) Spacer() } } } struct AddView_Previews: PreviewProvider { static var previews: some View { AddView() } }
EditView.swift
import SwiftUI struct EditView: View { @Environment(\.presentationMode) var presentation @State var id: UUID @State private var title = "" @State private var text = "" var userDefaults = UserDefaults.standard var body: some View { VStack { Text("記事を編集します。") .padding(10) TextField("タイトル", text: $title) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) TextField("テキスト", text: $text) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding(10) Button(action: { let temps = loadArticles() var articles: [Article] = [] for temp in temps { if temp.id == id { articles.append( Article( id: id, title: title, text: text ) ) } else { articles.append(temp) } } saveArticles(data: articles) self.presentation.wrappedValue.dismiss() }) { Text("編集") }.padding(10) Spacer() } .onAppear { let articles = loadArticles() for article in articles { if article.id == id { title = article.title text = article.text break } } } } } struct EditView_Previews: PreviewProvider { static var previews: some View { EditView(id: UUID()) } }
common.swift
import Foundation struct Article: Identifiable, Codable { var id = UUID() var title: String var text: String } func loadArticles() -> [Article] { var articles: [Article] = [] if let data = UserDefaults.standard.value(forKey: "articles") as? Data { articles = try! PropertyListDecoder().decode(Array<Article>.self, from: data) } else { articles = [] } return articles } func saveArticles(data articles: [Article]) -> Void { UserDefaults.standard.set(try? PropertyListEncoder().encode(articles), forKey: "articles") }
■SwiftUI+UIViewRepresentable
■SwiftUIには無いWebKitの機能を呼び出す UIKitで設計されたものをSwiftUIで使用するときは、「〇〇Representable」に準拠させる 具体的には、UIView は UIViewRepresentable に、UIViewController は UIViewControllerRepresentable に準拠させる 【SwiftUI】UIViewRepresentableの使い方!Coordinatorクラスとは? https://tech.amefure.com/swift-uiviewrepresentable 【SwiftUI】UIKitで作成したUIViewControllerやUIViewをSwiftUI側で表示する方法 - NRIネットコムBlog https://tech.nri-net.com/entry/display_uiview_created_with_uikit_on_swiftui UIKitのUIViewController/UIViewをSwiftUIで利用する場合の利用方法とその詳細 - Qiita https://qiita.com/yimajo/items/791dc1c1693d9821c5a8 SwiftUIで対応しきれずUIKitを使ったコンポーネントのまとめ - スタディサプリ Product Team Blog https://blog.studysapuri.jp/entry/2022/03/28/using-uikit-in-swiftui SwiftUI と UIKit 混合環境で開発を行うときの tips 集 - Qiita https://qiita.com/AkkeyLab/items/732887517da9abab6634 SwiftUIでAVFundationを導入する【Video Capture偏】 https://blog.personal-factory.com/2020/06/14/introduce-avfundation-by-swiftui/ 例えばSwiftUIにはWebViewが無い この場合、UIViewRepresentableを継承してWebKitを呼び出すことでSwiftUIから利用できる このファイルを「UIViewRepresentable」で検索すると、この項目の他にもいくつかの例がある UIViewRepresentableを使ってUIKitのviewをSwiftUI上で扱う|Tamappe Life Log https://tamappe.com/2020/08/08/uiviewrepresentable/ ■サンプル1(ボタンから制御されるラベルを自作) UIKitのUIViewをSwiftUIから利用する場合、UIViewRepresentableプロトコルに準拠して実装する 具体的には、以下のようにmakeUIView(表示するViewの初期状態のインスタンスを生成)とupdateUIView(表示するビューの状態が更新されるたびに呼び出され更新を反映)を実装する LabelView.swift
import SwiftUI struct LabelView: UIViewRepresentable { @Binding var isClick: Bool func makeUIView(context: Context) -> UILabel { let labelView: UILabel = UILabel() labelView.text = "UIKitから作成したView" labelView.textAlignment = NSTextAlignment.center return labelView } func updateUIView(_ uiView: UILabel, context: Context) { if isClick { uiView.text = "変更しました。" }else{ uiView.text = "UIKitから作成したView" } } }
ContentView.swift
import SwiftUI struct ContentView: View { @State var isClick: Bool = false var body: some View { VStack { LabelView(isClick: $isClick).padding() Button("ボタン"){ isClick.toggle() } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■サンプル2(ボタン側も自作&ラベルは上のものを流用) UIKitのイベントをSwiftUIで管理する場合、Coordinatorクラスを定義する (このクラス名に決まりは無いが、「Coordinator」という名前にしておくのが無難) ButtonView.swift
import SwiftUI struct ButtonView: UIViewRepresentable { @Binding var isClick: Bool func makeUIView(context: Context) -> UIButton { let control = UIButton(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) control.setTitle("ボタン", for: .normal) control.setTitleColor(.red, for: .normal) control.addTarget(context.coordinator,action: #selector(Coordinator.clickButton(sender:)),for: .touchUpInside) return control } func updateUIView(_ uiView: UIButton, context: Context) { } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator { var control: ButtonView init(_ control: ButtonView){ self.control = control } @objc func clickButton(sender : Any){ control.isClick.toggle() } } }
ContentView.swift
import SwiftUI struct ContentView: View { @State var isClick: Bool = false var body: some View { VStack { LabelView(isClick: $isClick).padding() ButtonView(isClick: $isClick) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■WebView(UIViewRepresentableの例) SwiftUIにはWebViewが無い UIViewRepresentableで機能を作成する SwiftUIでWebViewを使う - Qiita https://qiita.com/wiii_na/items/36123cf901839a8038e2 SwiftUIでUIViewを表示する https://zenn.dev/yorifuji/articles/swiftui-uiviewrepresentable UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる - Qiita https://qiita.com/k_awoki/items/448fd0bd6f51500d13b1 WebView.swift
import SwiftUI import WebKit struct WebView: UIViewRepresentable { var loadUrl: String func makeUIView(context: Context) -> WKWebView { return WKWebView() } func updateUIView(_ uiView: WKWebView, context: Context) { uiView.load(URLRequest(url: URL(string: loadUrl)!)) } }
ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { WebView(loadUrl: "https://www.apple.com/jp/") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■WebView(UIViewRepresentableの例&高機能版) UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる - Qiita https://qiita.com/k_awoki/items/448fd0bd6f51500d13b1 ContentView.swift
import SwiftUI struct ContentView: View { let url = URL(string: "https://www.apple.com/jp/")! var body: some View { WebView(url: url) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
WebView.swift
import SwiftUI struct WebView: View { // 表示するURL let url: URL // アクション @State private var action: WebContentView.Action = .none // 戻れるか @State private var canGoBack: Bool = false // 進めるか @State private var canGoForward: Bool = false // ローディング中か @State private var isLoading: Bool = false // 読み込みの進捗状況 @State private var loadingProgress: Double = 0.0 // ページタイトル @State private var pageTitle: String = "Now Loading..." var body: some View { NavigationView { VStack(spacing: 0) { if isLoading { WebProgressBarView(loadingProgress: loadingProgress) } WebContentView( url: url, action: $action, canGoBack: $canGoBack, canGoForward: $canGoForward, isLoading: $isLoading, loadingProgress: $loadingProgress, pageTitle: $pageTitle ).navigationBarTitle(Text(pageTitle), displayMode: .inline) WebToolBarView( action: $action, canGoBack: canGoBack, canGoForward: canGoForward ) } } // iPadでも画面全体に表示する .navigationViewStyle(StackNavigationViewStyle()) } }
WebContentView.swift
import SwiftUI import WebKit struct WebContentView: UIViewRepresentable { // 表示するURL let url: URL // アクション @Binding var action: Action // 戻れるか @Binding var canGoBack: Bool // 進めるか @Binding var canGoForward: Bool // ローディング中か @Binding var isLoading: Bool // 読み込みの進捗状況 @Binding var loadingProgress: Double // ページタイトル @Binding var pageTitle: String // WebViewのアクション enum Action { case none case goBack case goForward case reload } // 表示するView private let webView = WKWebView() func makeUIView(context: Context) -> WKWebView { webView.navigationDelegate = context.coordinator webView.load(URLRequest(url: url)) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { switch action { case .goBack: uiView.goBack() case .goForward: uiView.goForward() case .reload: uiView.reload() case .none: break } action = .none } func makeCoordinator() -> WebContentView.Coordinator { return Coordinator(parent: self) } static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.observations.forEach({ $0.invalidate() }) coordinator.observations.removeAll() } } extension WebContentView { final class Coordinator: NSObject, WKNavigationDelegate { let parent: WebContentView var observations: [NSKeyValueObservation] = [] init(parent: WebContentView) { self.parent = parent let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in parent.loadingProgress = value.newValue ?? 0 }) let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in parent.isLoading = value.newValue ?? false }) observations = [ progressObservation, isLoadingObservation ] } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { parent.canGoBack = webView.canGoBack parent.canGoForward = webView.canGoForward parent.pageTitle = webView.title ?? "" } } }
WebToolBarView.swift
import SwiftUI struct WebToolBarView: View { // アクション @Binding var action: WebContentView.Action // 戻れるか var canGoBack: Bool // 進めるか var canGoForward: Bool var body: some View { VStack() { Divider() HStack(spacing: 16) { Button(action: { action = .goBack }) { Image(systemName: "arrow.backward") }.disabled(!canGoBack) Button(action: { action = .goForward }) { Image(systemName: "arrow.forward") }.disabled(!canGoForward) Spacer() Button(action: { action = .reload }) { Image(systemName: "arrow.clockwise") } } .padding(.top, 8) .padding(.horizontal, 16) Spacer() }.frame(height: 60) } }
WebProgressBarView.swift
import SwiftUI struct WebProgressBarView: View { // 読み込みの進捗状況 var loadingProgress: 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(loadingProgress)) } }.frame(height: 2.0) } }
■TextField付きAlertを表示する(UIViewControllerRepresentableの例) SwiftUIのアラートにはテキスト入力の機能が無い UIViewControllerRepresentableで機能を作成する 【SwiftUI】TextField付きAlertを表示する - .NET ゆる〜りワーク https://www.yururiwork.net/%E3%80%90swiftui%E3%80%91textfield%E4%BB%98%E3%81%8Dalert%E3%82%92%E8%A1%... TextFieldAlertView.swift
import SwiftUI struct TextFieldAlertView: UIViewControllerRepresentable { @Binding var text: String @Binding var isShowingAlert: Bool let placeholder: String let isSecureTextEntry: Bool let title: String let message: String let leftButtonTitle: String? let rightButtonTitle: String? var leftButtonAction: (() -> Void)? var rightButtonAction: (() -> Void)? func makeUIViewController(context: UIViewControllerRepresentableContext<TextFieldAlertView>) -> some UIViewController { return UIViewController() } func updateUIViewController(_ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext<TextFieldAlertView>) { guard context.coordinator.alert == nil else { return } if !isShowingAlert { return } let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) context.coordinator.alert = alert alert.addTextField { textField in textField.placeholder = placeholder textField.text = text textField.delegate = context.coordinator textField.isSecureTextEntry = isSecureTextEntry } if leftButtonTitle != nil { alert.addAction(UIAlertAction(title: leftButtonTitle, style: .default) { _ in alert.dismiss(animated: true) { isShowingAlert = false leftButtonAction?() } }) } if rightButtonTitle != nil { alert.addAction(UIAlertAction(title: rightButtonTitle, style: .default) { _ in if let textField = alert.textFields?.first, let text = textField.text { self.text = text } alert.dismiss(animated: true) { isShowingAlert = false rightButtonAction?() } }) } DispatchQueue.main.async { uiViewController.present(alert, animated: true, completion: { isShowingAlert = false context.coordinator.alert = nil }) } } func makeCoordinator() -> TextFieldAlertView.Coordinator { Coordinator(self) } class Coordinator: NSObject, UITextFieldDelegate { var alert: UIAlertController? var view: TextFieldAlertView init(_ view: TextFieldAlertView) { self.view = view } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if let text = textField.text as NSString? { self.view.text = text.replacingCharacters(in: range, with: string) } else { self.view.text = "" } return true } } }
ContentView.swift
import SwiftUI struct ContentView: View { @State private var isShowingAlert = false @State var text: String = "" var body: some View { VStack { Text("SwiftUIからUIKitの命令を呼び出す") .padding() Button("TextField付きAlertを表示する") { isShowingAlert = true } TextFieldAlertView( text: $text, isShowingAlert: $isShowingAlert, placeholder: "", isSecureTextEntry: true, title: "ログイン", message: "パスワードを入力してください", leftButtonTitle: "キャンセル", rightButtonTitle: "認証", leftButtonAction: nil, rightButtonAction: { print("パスワード認証リクエスト [" + text + "]") } ) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■引っ張って更新 【SwiftUI】Pull to refresh(UIRefreshControl)を実装する - .NET ゆる〜りワーク https://www.yururiwork.net/archives/1534 SwiftUI3.0より前は、「引っ張って更新」には対応していなかったが、現在は対応している 実装方法は「SwiftUIその他」を参照
■SwiftUI+AR
■基本的なプログラムの作成 ※もともと「SceneKit」が使えたが、新しく「RealityKit」が使えるようになった 今後はこちらを採用する方がいいかもしれないが、現時点では解説が少なく、またSwiftUI同様機能不足の可能性はある 今回、「Augmented Reality App」で以下を登録してアプリを作成するものとする Content Technology: RealityKit Interface: SwiftUI 以下のコードが生成された 実機で実行するとカメラへのアクセスを求められるので許可する しばらくすると、画面上に四角の箱が現れる ContentView.swift
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // Load the "Box" scene from the "Experience" Reality File let boxAnchor = try! Experience.loadBox() // Add the box anchor to the scene arView.scene.anchors.append(boxAnchor) return arView } func updateUIView(_ uiView: ARView, context: Context) {} } #if DEBUG struct ContentView_Previews : PreviewProvider { static var previews: some View { ContentView() } } #endif
以下はコメントを追加したコード
import SwiftUI import RealityKit struct ContentView: View { var body: some View { // 全画面表示にする ARViewContainer().edgesIgnoringSafeArea(.all) } } // UIKitのビューや機能をSwiftUIでも使えるようにする。今回はUIViewContainerの機能を使用するためにUIViewRepresentableに準拠する(詳細は「SwiftUI+UIViewRepresentable」を参照) struct ARViewContainer: UIViewRepresentable { // 表示するViewの初期状態のインスタンスを生成する func makeUIView(context: Context) -> ARView { // ゼロで初期化することで、画面いっぱいの表示になる let arView = ARView(frame: .zero) // 3Dモデルの「Experience」ファイルから「Box」シーンを読み込む let boxAnchor = try! Experience.loadBox() // 読み込んだ「Box」シーンを「arView」シーンに表示する arView.scene.anchors.append(boxAnchor) return arView } // 表示するビューの状態が更新されるたびに呼び出される func updateUIView(_ uiView: ARView, context: Context) {} } #if DEBUG struct ContentView_Previews : PreviewProvider { static var previews: some View { ContentView() } } #endif
Xcode15の時点では、以下のように変わっていた
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // Create a cube model let mesh = MeshResource.generateBox(size: 0.1, cornerRadius: 0.005) let material = SimpleMaterial(color: .gray, roughness: 0.15, isMetallic: true) let model = ModelEntity(mesh: mesh, materials: [material]) // Create horizontal plane anchor for the content let anchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: SIMD2<Float>(0.2, 0.2))) anchor.children.append(model) // Add the horizontal plane anchor to the scene arView.scene.anchors.append(anchor) return arView } func updateUIView(_ uiView: ARView, context: Context) {} } #Preview { ContentView() }
SwiftUIでARKitを触って考えたこと - Qiita https://qiita.com/y_taka/items/431a69d7f7acbf3387df 【やってみた】iOS13/Xcode11で登場の新機能「Reality Composer」を紹介するよ〜〜|ノースサンド|note https://note.com/northsand/n/nb245a2d4ab1f Reality Composerに任意の3Dオブジェクトをインポートする方法 - Qiita https://qiita.com/TokyoYoshida/items/678587f41ade3d04d14a 以下のようにプログラムを変更し、立方体・球体・テキストが表示されることを確認する ContentView.swift
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // 3Dモデルの「Experience」ファイルから「Box」シーンを読み込む let boxAnchor = try! Experience.loadBox() // 読み込んだ「Box」シーンを「arView」シーンに表示する arView.scene.anchors.append(boxAnchor) // 水平面のアンカーを作成する。検出最小面積は「0.15×0.15」とする let anchor = AnchorEntity(plane: .horizontal, minimumBounds: [0.15, 0.15]) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor) // メッシュリソースで立方体を作成する。サイズは「0.1m」とする let boxMesh = MeshResource.generateBox(size: 0.1) // 光源を無視するマテリアルを作成する。色は単色の赤とする let boxMaterial = UnlitMaterial(color: UIColor.red) // モデルを作成する。メッシュとマテリアルは、上で作成したものを指定する let boxModel = ModelEntity(mesh: boxMesh, materials: [boxMaterial]) // 3つの値からなるベクトルにより、モデルの位置を「左0.2m」に指定する boxModel.position = SIMD3<Float>(-0.2, 0.0, 0.0) // アンカーにモデルを追加する anchor.addChild(boxModel) // メッシュリソースで球体を作成する。サイズは「0.1m」とする let sphereMesh = MeshResource.generateSphere(radius: 0.1) // マテリアルを作成する。環境マッピングは基本色「白」、表面の粗さは「0.0」、光の反射は「あり」とする let sphereMaterial = SimpleMaterial(color: UIColor.white, roughness: 0.0, isMetallic: true) // モデルを作成する。メッシュとマテリアルは、上で作成したものを指定する let sphereModel = ModelEntity(mesh: sphereMesh, materials: [sphereMaterial]) // 3つの値からなるベクトルにより、モデルの位置を「右0.4m」に指定する sphereModel.position = SIMD3<Float>(0.4, 0.0, 0.0) // アンカーにモデルを追加する anchor.addChild(sphereModel) // メッシュリソースでテキストを作成する let textMesh = MeshResource.generateText( "Hello, world!", // テキストは「Hello, world!」 extrusionDepth: 0.1, // 奥行きは「0.1m」 font: .systemFont(ofSize: 1.0), // フォントサイズは「1.0」 containerFrame: CGRect.zero, // コンテナフレームは「ゼロ」(その文字列の表示に必要な大きさに調整される) alignment: .left, // 左揃えで表示 lineBreakMode: .byTruncatingTail // テキストの折り返しは「末尾を切り捨て」 ) // マテリアルを作成する。環境マッピングは基本色「青」、表面の粗さは「0.0」、光の反射は「あり」とする let textMaterial = SimpleMaterial(color: UIColor.blue, roughness: 0.0, isMetallic: true) // モデルを作成する。メッシュとマテリアルは、上で作成したものを指定する let textModel = ModelEntity(mesh: textMesh, materials: [textMaterial]) // 3つの値からなるベクトルにより、モデルのサイズを「10分の1に縮小」に指定する textModel.scale = SIMD3<Float>(0.1, 0.1, 0.1) // 3つの値からなるベクトルにより、モデルの位置を「手前0.2m」に指定する textModel.position = SIMD3<Float>(0.0, 0.0, 0.2) // アンカーにモデルを追加する anchor.addChild(textModel) return arView } func updateUIView(_ uiView: ARView, context: Context) {} } #if DEBUG struct ContentView_Previews : PreviewProvider { static var previews: some View { ContentView() } } #endif
RealityKit + ARKit 3 + SwiftUI で宙に浮く Hello World テキスト in 拡張現実 - Qiita https://qiita.com/niwasawa/items/3d1bd6af3ebbcadfb366 ■機能の検証 ※2023年3月に引き続き検証した内容 ※SwiftUIのプレビュー、「Updating took more than 5 seconds」というエラーで表示されない また、シミュレータの候補に「iPhone12」が無い(iPhone14からになっている) 動作確認は実機でしかできないようなので、それぞれいったん気にせずとする RealityKit の参考書 - Qiita https://qiita.com/john-rocky/items/77dd077a5778c7ca9369 以降は以下のうち、「ここに処理を書く」部分に書くコードのみを記載する
struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) /* ここに処理を書く */ return arView } func updateUIView(_ uiView: ARView, context: Context) {} }
ボックスの表示
// アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // ボックスのモデルを作成する。幅「0.3m」、高さ「0.1m」、奥行き「0.2m」、角の丸みの半径「0.03m」とする let boxModel = ModelEntity(mesh: .generateBox(size: simd_make_float3(0.3, 0.1, 0.2), cornerRadius: 0.03)) // モデルをY軸で1ラジアン回転させる boxModel.transform = Transform(pitch: 0, yaw: 1, roll: 0) // アンカーにモデルを追加する anchor.addChild(boxModel) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
板の表示
// アンカーを作成する。アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする let anchor = AnchorEntity(world: [0, -0.5, -1]) // 板のモデルを作成する。幅「0.2m」、高さ「0.3m」とする let plane = ModelEntity(mesh: .generatePlane(width: 0.2, height: 0.3)) // モデルをY軸で1ラジアン回転させる plane.transform = Transform(pitch: 0, yaw: 1, roll: 0) // アンカーにモデルを追加する anchor.addChild(plane) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
球体の表示
// アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // 球体のモデルを作成する。半径「0.1m」とする let sphere = ModelEntity(mesh: .generateSphere(radius: 0.1)) // アンカーにモデルを追加する anchor.addChild(sphere) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
テキストの表示
// アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // テキストのモデルを作成する let text = ModelEntity(mesh: .generateText( "Ciao!", // テキストは「Ciao!」 extrusionDepth: 0.03, // 奥行きは「3cm」 font: .systemFont(ofSize: 0.1, weight: .bold), // フォントサイズは「0.1」で太字 containerFrame: CGRect.zero, // コンテナフレームは「ゼロ」(その文字列の表示に必要な大きさに調整される) alignment: .center, // 中央揃えで表示 lineBreakMode: .byCharWrapping // テキストの折り返しは「末尾を折り返し」 )) // フォントサイズを30cmにする text.transform = Transform(pitch: 0, yaw: 0.3, roll: 0) // アンカーにモデルを追加する anchor.addChild(text) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
反射など現実の光に影響されるマテリアル
// アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // ボックスのモデルを作成する。幅「0.3m」、高さ「0.1m」、奥行き「0.2m」とする let box = ModelEntity(mesh: .generateBox(size: simd_make_float3(0.3, 0.1, 0.2))) // マテリアルを作成する。環境マッピングは基本色「青」、表面の粗さは「0」、光の反射は「あり」とする var material = SimpleMaterial(color: .blue, roughness: 0, isMetallic: true) 光の反射量(最大値1に近づくと金属的な表面になる) material.metallic = 1 // モデルの表面に適用する box.model?.materials = [material] // アンカーにモデルを追加する anchor.addChild(box) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
物理レンダリングの影響を受けないマテリアル
// アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // ボックスのモデルを作成する。幅「0.3m」、高さ「0.1m」、奥行き「0.2m」とする let box = ModelEntity(mesh: .generateBox(size: simd_make_float3(0.3, 0.1, 0.2))) // 光源を無視するマテリアルを作成する。色は単色の青とする let unlitMaterial = UnlitMaterial(color: .blue) // モデルの表面に適用する box.model?.materials = [unlitMaterial] // アンカーにモデルを追加する anchor.addChild(box) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor)
■サンプルの検証 以下のプログラムをもとに検証中 GitHub - john-rocky/RealityKit-Sampler: a sample collection of basic functions of Apple's AR framework for iOS. https://github.com/john-rocky/RealityKit-Sampler RealityKit の参考書 - Qiita https://qiita.com/john-rocky/items/77dd077a5778c7ca9369 SwiftUIにボックスを配置 ContentView.swift
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // 水平面のアンカーを作成する let anchorEntity = AnchorEntity(plane: .horizontal) // ボックスのモデルを作成する。幅「0.1m」、高さ「0.1m」、奥行き「0.1m」、角の丸みの半径「0.02m」とする let boxEntity = ModelEntity(mesh: .generateBox(size: [0.1, 0.1, 0.1], cornerRadius: 0.02)) // マテリアルを作成する。環境マッピングは基本色「青」、光の反射は「あり」とする let material = SimpleMaterial(color: .blue, isMetallic: true) // モデルの表面に適用する boxEntity.model?.materials = [material] // アンカーにモデルを追加する anchorEntity.addChild(boxEntity) // 作成したアンカーを「arView」シーンに表示する arView.scene.addAnchor(anchorEntity) return arView } func updateUIView(_ uiView: ARView, context: Context) {} } #if DEBUG struct ContentView_Previews : PreviewProvider { static var previews: some View { ContentView() } } #endif
SwiftUIではなくUIKitに配置する ContentView.swift
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewControllerContainer() .edgesIgnoringSafeArea(.all) } } // UIKitのUIViewControllerを扱うため、UIViewControllerRepresentableに準拠させる struct ARViewControllerContainer: UIViewControllerRepresentable { func makeUIViewController(context: UIViewControllerRepresentableContext<ARViewControllerContainer>) -> ARViewController { let viewController = ARViewController() return viewController } func updateUIViewController(_ uiViewController: ARViewController, context: UIViewControllerRepresentableContext<ARViewControllerContainer>) { } func makeCoordinator() -> ARViewControllerContainer.Coordinator { return Coordinator() } class Coordinator { } } #if DEBUG struct ContentView_Previews : PreviewProvider { static var previews: some View { ContentView() } } #endif
ARViewController.swift
import UIKit import RealityKit class ARViewController: UIViewController { private var arView: ARView! override func viewDidLoad() { super.viewDidLoad() arView = ARView(frame: view.bounds) // アンカーを作成する let anchor = AnchorEntity() // アンカーの位置はデバイス初期位置から下に「0.5m」、向こうに「1m」とする anchor.position = simd_make_float3(0, -0.5, -1) // ボックスのモデルを作成する。幅「0.3m」、高さ「0.1m」、奥行き「0.2m」、角の丸みの半径「0.03m」とする let boxModel = ModelEntity(mesh: .generateBox(size: simd_make_float3(0.3, 0.1, 0.2), cornerRadius: 0.03)) // モデルをY軸で1ラジアン回転させる boxModel.transform = Transform(pitch: 0, yaw: 1, roll: 0) // アンカーにモデルを追加する anchor.addChild(boxModel) // 作成したアンカーを「arView」シーンに表示する arView.scene.anchors.append(anchor) view.addSubview(arView) } }
画面内で物体に触れる(ContentView.swift は上と同じ) RealityKitで Tracking Configuration や ARSessionDelegate をつかう場合、明示的に ARKit をインポートしてセッションを構成・実行する必要がある ARをさわる【コンピュータ・ビジョン*AR】 - Qiita https://qiita.com/john-rocky/items/285b729a9ae86a0aea10 ARViewController.swift
import UIKit import Vision import RealityKit import ARKit // トラッキング情報やセッション状況の変化に対応するためARSessionDelegateプロトコルを使用する(Swiftでは多重継承ができないのでプロトコルを使用する) class ARViewController: UIViewController, ARSessionDelegate { private var arView:ARView! lazy var request:VNRequest = { // 手のポイントを取得するリクエスト。completionHandlerで指定したメソッド(handDetectionCompletionHandler)内で結果を処理する var handPoseRequest = VNDetectHumanHandPoseRequest(completionHandler: handDetectionCompletionHandler) // 検出する手は1つ handPoseRequest.maximumHandCount = 1 return handPoseRequest }() var viewWidth:Int = 0 var viewHeight:Int = 0 var box:ModelEntity! override func viewDidLoad() { super.viewDidLoad() arView = ARView(frame: view.bounds) arView.session.delegate = self view.addSubview(arView) // 平面アンカーを使用するため、ARWorldTrackingConfiguration でARセッションを実行する let config = ARWorldTrackingConfiguration() config.environmentTexturing = .automatic config.frameSemantics = [.personSegmentation] config.planeDetection = [.horizontal] // ビューの幅と高さを取得する arView.session.run(config, options: []) viewWidth = Int(arView.bounds.width) viewHeight = Int(arView.bounds.height) setupObject() } private func setupObject(){ // 水平面のアンカーを作成する let anchor = AnchorEntity(plane: .horizontal) // 板のモデルを作成する let plane = ModelEntity(mesh: .generatePlane(width: 2, depth: 2), materials: [OcclusionMaterial()]) // 衝突形状を付ける★もし動かなければ「アンカーにモデルを追加する」の後ろに戻す plane.generateCollisionShapes(recursive: false) plane.physicsBody = PhysicsBodyComponent(massProperties: .default, material: .default, mode: .static) // アンカーにモデルを追加する anchor.addChild(plane) // ボックスのモデルを作成する box = ModelEntity(mesh: .generateBox(size: 0.05), materials: [SimpleMaterial(color: .white, isMetallic: true)]) // 衝突形状を付ける box.generateCollisionShapes(recursive: false) box.physicsBody = PhysicsBodyComponent(massProperties: .default, material: .default, mode: .dynamic) // 位置を指定する box.position = [0,0.025,0] // アンカーにモデルを追加する anchor.addChild(box) arView.scene.addAnchor(anchor) } var recentIndexFingerPoint:CGPoint = .zero // 手のポイントを取得するリクエストを処理する func handDetectionCompletionHandler(request: VNRequest?, error: Error?) { // リクエストの結果から、人差し指の先の位置を取得する guard let observation = request?.results?.first as? VNHumanHandPoseObservation else { return } guard let indexFingerTip = try? observation.recognizedPoints(.all)[.indexTip], indexFingerTip.confidence > 0.3 else {return} // Visionの結果は0〜1に正規化されているので、ARViewの座標に変換する let normalizedIndexPoint = VNImagePointForNormalizedPoint(CGPoint(x: indexFingerTip.location.y, y: indexFingerTip.location.x), viewWidth, viewHeight) // 取得した指先の座標でヒットテストを実施(ヒットテストで検出するエンティティにはgenerateCollisionShapesが必要) if let entity = arView.entity(at: normalizedIndexPoint) as? ModelEntity, entity == box { // 見つけたボックス・オブジェクトに物理的な力を加える entity.addForce([0,40,0], relativeTo: nil) } recentIndexFingerPoint = normalizedIndexPoint } func session(_ session: ARSession, didUpdate frame: ARFrame) { let pixelBuffer = frame.capturedImage // ARSessionで取得したフレームでVisionリクエスト(VNImageRequestHandler)を実行 DispatchQueue.global(qos: .userInitiated).async { [weak self] in let handler = VNImageRequestHandler(cvPixelBuffer:pixelBuffer, orientation: .up, options: [:]) do { try handler.perform([(self?.request)!]) } catch let error { print(error) } } } }
■Blenderで作成したファイルを配置 RealityKit の参考書 #Swift - Qiita https://qiita.com/john-rocky/items/77dd077a5778c7ca9369 あらかじめ、BlenderからエクスポートしたUSDZファイルをツリーにドラッグ&ドロップしておく 必要に応じて、「Models」などのフォルダ(New Group)を作成するといい(フォルダ内に移動させても、参照は切れなかった) 以下のようなプログラムで、USDZファイルを画面に表示できた
import SwiftUI import RealityKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) /* 初期のコード */ /* // Create a cube model let mesh = MeshResource.generateBox(size: 0.1, cornerRadius: 0.005) let material = SimpleMaterial(color: .gray, roughness: 0.15, isMetallic: true) let model = ModelEntity(mesh: mesh, materials: [material]) // Create horizontal plane anchor for the content let anchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: SIMD2<Float>(0.2, 0.2))) anchor.children.append(model) // Add the horizontal plane anchor to the scene arView.scene.anchors.append(anchor) */ /* ボックスメッシュを配置する場合 */ /* let anchor = AnchorEntity() // アンカー(ARモデルを固定する錨) anchor.position = simd_make_float3(0, -0.5, -1) // アンカーの位置は、デバイス初期位置から、0.5m下、1m向こう let box = ModelEntity(mesh: .generateBox(size: simd_make_float3(0.3, 0.1, 0.2), cornerRadius: 0.03)) // 幅0.3m、高さ0.1m、奥行き0.2m、角の丸みの半径が0.03mのボックスメッシュからモデルをつくる。 box.transform = Transform(pitch: 0, yaw: 1, roll: 0) // ボックスモデルをY軸で1ラジアン回転させる anchor.addChild(box) // アンカーの子階層にボックスを加える arView.scene.anchors.append(anchor) // arViewにアンカーを加える */ /* USDZモデルを配置する場合 */ let anchor = AnchorEntity() anchor.position = simd_make_float3(0, -0.5, -1) if let usdzModel = try? Entity.load(named: "rocking-chair") { anchor.addChild(usdzModel) } arView.scene.anchors.append(anchor) return arView } func updateUIView(_ uiView: ARView, context: Context) {} }
以下も参考になるか 【iOS】USDZ形式の3DモデルをAR空間でアニメーションさせる方法 #Swift - Qiita https://qiita.com/Shota-Abe/items/88b8de761187b46fb1cb Webからのお手軽ARの手段(model-viewer)、glTF/usdによる表現力の違いについて #AR - Qiita https://qiita.com/ft-lab/items/a3ddc635c8d034137efa SwiftUIとARKitでUSDZ表示アプリ https://zenn.dev/soh92/articles/a2ad0dcfeafe80 [ARKit] 画像を認識してみる #Swift - Qiita https://qiita.com/katopan/items/1854f7cac3523f22a6a7 ■Blenderで作成したファイルを配置(平面を検出して配置)
import SwiftUI import RealityKit import ARKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) let config = ARWorldTrackingConfiguration() // 検出する面 //config.planeDetection = [.horizontal, .vertical] config.planeDetection = [.horizontal] // デバッグオプション //arView.debugOptions = [.showFeaturePoints, .showAnchorOrigins, .showAnchorGeometry] arView.session.run(config) // CoordinatorにARViewの参照を設定し、セッションデリゲートとして指定 let coordinator = context.coordinator coordinator.setARView(arView) arView.session.delegate = coordinator return arView } func updateUIView(_ uiView: ARView, context: Context) {} func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject, ARSessionDelegate { weak var arView: ARView? func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { guard let arView = self.arView else { return } for anchor in anchors { if let planeAnchor = anchor as? ARPlaneAnchor { var modelEntity: ModelEntity? do { modelEntity = try Entity.loadModel(named: "rocking-chair") } catch { print("モデルのロードに失敗: \(error)") return } modelEntity?.generateCollisionShapes(recursive: true) let anchorEntity = AnchorEntity(anchor: planeAnchor) // モデルエンティティがnilでないことを確認 if let modelEntity = modelEntity { anchorEntity.addChild(modelEntity) arView.scene.addAnchor(anchorEntity) } } } } func setARView(_ view: ARView) { self.arView = view } } }
■Blenderで作成したファイルを配置(平面を検出してからタップで配置)
import SwiftUI import RealityKit import ARKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) let config = ARWorldTrackingConfiguration() // 検出する面 //config.planeDetection = [.horizontal, .vertical] config.planeDetection = [.horizontal] // デバッグオプション //arView.debugOptions = [.showFeaturePoints, .showAnchorOrigins, .showAnchorGeometry] arView.session.run(config) // CoordinatorにARViewの参照を設定し、セッションデリゲートとして指定 let coordinator = context.coordinator coordinator.setARView(arView) arView.session.delegate = coordinator // タップジェスチャの追加 let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) arView.addGestureRecognizer(tapGesture) return arView } func updateUIView(_ uiView: ARView, context: Context) {} func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject, ARSessionDelegate { weak var arView: ARView? var lastPlaneAnchor: ARPlaneAnchor? func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { for anchor in anchors { if let planeAnchor = anchor as? ARPlaneAnchor { // 最後に検出された平面アンカーを保存 lastPlaneAnchor = planeAnchor } } } @objc func handleTap(_ sender: UITapGestureRecognizer) { guard let arView = arView, let planeAnchor = lastPlaneAnchor, let tapLocation = sender.view as? ARView else { return } let hitTestResults = tapLocation.hitTest(tapLocation.center, types: .existingPlaneUsingExtent) if let hitTestResult = hitTestResults.first { // モデルをロードし、タップされた位置に配置 var modelEntity: ModelEntity? do { modelEntity = try Entity.loadModel(named: "rocking-chair") } catch { print("モデルのロードに失敗: \(error)") return } modelEntity?.generateCollisionShapes(recursive: true) let anchorEntity = AnchorEntity(world: hitTestResult.worldTransform) if let modelEntity = modelEntity { anchorEntity.addChild(modelEntity) arView.scene.addAnchor(anchorEntity) } } } func setARView(_ view: ARView) { self.arView = view } } }
■Blenderで作成したファイルを配置(平面にマーカーを表示)
import SwiftUI import RealityKit import ARKit struct ContentView: View { var body: some View { ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) let config = ARWorldTrackingConfiguration() // 検出する面 //config.planeDetection = [.horizontal, .vertical] config.planeDetection = [.horizontal] // デバッグオプション //arView.debugOptions = [.showFeaturePoints, .showAnchorOrigins, .showAnchorGeometry] arView.session.run(config) // CoordinatorにARViewの参照を設定し、セッションデリゲートとして指定 let coordinator = context.coordinator coordinator.setARView(arView) arView.session.delegate = coordinator // タップジェスチャの追加 let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) arView.addGestureRecognizer(tapGesture) return arView } func updateUIView(_ uiView: ARView, context: Context) {} func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject, ARSessionDelegate { weak var arView: ARView? var planeAnchors = [ARPlaneAnchor]() func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { guard let arView = self.arView else { return } for anchor in anchors { if let planeAnchor = anchor as? ARPlaneAnchor { planeAnchors.append(planeAnchor) // マーカーの追加 let marker = createMarker() let anchorEntity = AnchorEntity(anchor: planeAnchor) anchorEntity.addChild(marker) arView.scene.addAnchor(anchorEntity) } } } @objc func handleTap(_ sender: UITapGestureRecognizer) { guard let arView = arView else { return } let location = sender.location(in: arView) let hitTestResults = arView.hitTest(location, types: .existingPlaneUsingExtent) if let hitTestResult = hitTestResults.first { // モデルをロードし、タップされた位置に配置 var modelEntity: ModelEntity? do { modelEntity = try Entity.loadModel(named: "rocking-chair") } catch { print("モデルのロードに失敗: \(error)") return } modelEntity?.generateCollisionShapes(recursive: true) let anchorEntity = AnchorEntity(world: hitTestResult.worldTransform) if let modelEntity = modelEntity { anchorEntity.addChild(modelEntity) arView.scene.addAnchor(anchorEntity) } } } func setARView(_ view: ARView) { self.arView = view } private func createMarker() -> Entity { let mesh = MeshResource.generatePlane(width: 0.05, depth: 0.05) let material = SimpleMaterial(color: .yellow, isMetallic: false) return ModelEntity(mesh: mesh, materials: [material]) } } }
■Blenderで作成したファイルを配置(配置済みのモデルをタップすると削除)
@objc func handleTap(_ sender: UITapGestureRecognizer) { guard let arView = arView else { return } let location = sender.location(in: arView) let hitTestResults = arView.hitTest(location) // タップされた場所に既にモデルが存在するかチェック if let firstHit = hitTestResults.first(where: { $0.entity is ModelEntity }) { // モデルがあれば削除 firstHit.entity.removeFromParent() } else { // 新しいモデルを配置 let planeHitTestResults = arView.hitTest(location, types: .existingPlaneUsingExtent) if let hitTestResult = planeHitTestResults.first { // モデルをロードし、タップされた位置に配置 var modelEntity: ModelEntity? do { modelEntity = try Entity.loadModel(named: "rocking-chair") } catch { print("モデルのロードに失敗: \(error)") return } modelEntity?.generateCollisionShapes(recursive: true) let anchorEntity = AnchorEntity(world: hitTestResult.worldTransform) if let modelEntity = modelEntity { anchorEntity.addChild(modelEntity) arView.scene.addAnchor(anchorEntity) } } } }
■Blenderで作成したファイルを配置(配置するモデルを選択)
import SwiftUI import RealityKit import ARKit struct ContentView: View { @State private var selectedModel: String = "rocking-chair" var body: some View { VStack { Picker("Select Model", selection: $selectedModel) { Text("Rocking Chair").tag("rocking-chair") Text("Chair").tag("chair") Text("Table").tag("table") } .pickerStyle(SegmentedPickerStyle()) .padding() ARViewContainer(selectedModel: selectedModel).edgesIgnoringSafeArea(.all) } } } struct ARViewContainer: UIViewRepresentable { var selectedModel: String func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) let config = ARWorldTrackingConfiguration() // 検出する面 //config.planeDetection = [.horizontal, .vertical] config.planeDetection = [.horizontal] // デバッグオプション //arView.debugOptions = [.showFeaturePoints, .showAnchorOrigins, .showAnchorGeometry] arView.session.run(config) // CoordinatorにARViewの参照を設定し、セッションデリゲートとして指定 let coordinator = context.coordinator coordinator.setARView(arView) arView.session.delegate = coordinator // タップジェスチャの追加 let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) arView.addGestureRecognizer(tapGesture) return arView } func updateUIView(_ uiView: ARView, context: Context) { // CoordinatorのselectedModelを更新 context.coordinator.selectedModel = selectedModel } func makeCoordinator() -> Coordinator { return Coordinator(selectedModel: selectedModel) } class Coordinator: NSObject, ARSessionDelegate { weak var arView: ARView? var planeAnchors = [ARPlaneAnchor]() var selectedModel: String init(selectedModel: String) { self.selectedModel = selectedModel } func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { guard let arView = self.arView else { return } for anchor in anchors { if let planeAnchor = anchor as? ARPlaneAnchor { planeAnchors.append(planeAnchor) // マーカーの追加 let marker = createMarker() let anchorEntity = AnchorEntity(anchor: planeAnchor) anchorEntity.addChild(marker) arView.scene.addAnchor(anchorEntity) } } } @objc func handleTap(_ sender: UITapGestureRecognizer) { guard let arView = arView else { return } let location = sender.location(in: arView) let hitTestResults = arView.hitTest(location) // タップされた場所に既にモデルが存在するかチェック if let firstHit = hitTestResults.first(where: { $0.entity is ModelEntity }) { // モデルがあれば削除 firstHit.entity.removeFromParent() } else { // 新しいモデルを配置 let planeHitTestResults = arView.hitTest(location, types: .existingPlaneUsingExtent) if let hitTestResult = planeHitTestResults.first { // モデルをロードし、タップされた位置に配置 var modelEntity: ModelEntity? do { modelEntity = try Entity.loadModel(named: selectedModel) //modelEntity = try Entity.loadModel(named: "rocking-chair") //modelEntity = try Entity.loadModel(named: "chair") //modelEntity = try Entity.loadModel(named: "table") } catch { print("モデルのロードに失敗: \(error)") return } modelEntity?.generateCollisionShapes(recursive: true) let anchorEntity = AnchorEntity(world: hitTestResult.worldTransform) if let modelEntity = modelEntity { anchorEntity.addChild(modelEntity) arView.scene.addAnchor(anchorEntity) } } } } func setARView(_ view: ARView) { self.arView = view } private func createMarker() -> Entity { let mesh = MeshResource.generatePlane(width: 0.05, depth: 0.05) let material = SimpleMaterial(color: .yellow, isMetallic: false) return ModelEntity(mesh: mesh, materials: [material]) } } }
■SwiftUI+Camera
■画像取得(カメラ&ライブラリ) SwiftUIでUIImagePickerControllerを使う https://zenn.dev/yorifuji/articles/swiftui-imagepicker SwiftUIでAVFoundationを使ってみた - Qiita https://qiita.com/From_F/items/759544896fe89e828898 【SwiftUI】カメラ機能の実装方法【撮影画像とライブラリー画像の利用】 https://tomato-develop.com/swiftui-how-to-use-camera-and-select-photos-from-library/ iOSアプリCamera撮影, UIImagePickerController https://i-app-tec.com/ios/camera.html 「App」で以下の設定で新規作成する Product Name: imagepicker Interface: SwiftUI Language: Swift Info.plist にKey「Privacy - Camera Usage Description」を追加し、Valueに「写真を撮影します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSCameraUsageDescription</key> <string>写真を撮影します。</string>
ContentView.swift
import SwiftUI struct ContentView: View { @State var showingPicker = false @State var image: UIImage? var body: some View { VStack { if let image = image { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) } Text("Image") .onTapGesture { showingPicker.toggle() } } .sheet(isPresented: $showingPicker) { ImagePickerView(image: $image, sourceType: .camera) //ImagePickerView(image: $image, sourceType: .camera, allowsEditing: true) //ImagePickerView(image: $image, sourceType: .library) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
ImagePickerView.swift
import SwiftUI struct ImagePickerView: UIViewControllerRepresentable { typealias UIViewControllerType = UIImagePickerController @Environment(\.presentationMode) var presentationMode @Binding var image: UIImage? enum SourceType { case camera case library } var sourceType: SourceType var allowsEditing: Bool = false class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { let parent: ImagePickerView init(_ parent: ImagePickerView) { self.parent = parent } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let image = info[.editedImage] as? UIImage { parent.image = image } else if let image = info[.originalImage] as? UIImage { parent.image = image } parent.presentationMode.wrappedValue.dismiss() } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIImagePickerController { let viewController = UIImagePickerController() viewController.delegate = context.coordinator switch sourceType { case .camera: viewController.sourceType = UIImagePickerController.SourceType.camera case .library: viewController.sourceType = UIImagePickerController.SourceType.photoLibrary } viewController.allowsEditing = allowsEditing return viewController } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } }
「ImagePickerView」で撮影する際に「allowsEditing: true」を指定すると、撮影後にトリミング位置を指定できるみたい また、カメラUIを日本語化するには、 Info.plist のKey「Localization native development region」のValueを「Japan」に変更する [iOS]カメラ機能作成のメモ - ワークレ https://reftec.work/posts/2019/10/126/ ■画像取得(カメラ&ライブラリ+画像を保存) Info.plist にKey「Privacy - Photo Library Additions Usage Description」を追加し、Valueに「画像を保存します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSPhotoLibraryAddUsageDescription</key> <string>画像を保存します。</string>
ContentView.swift
@State var saved: Bool = false var body: some View { VStack { if let image = image { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) Button(action: { // 画像を保存 UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) // 保存完了アラートを表示 saved = true }) { Text("保存") }.alert(isPresented: $saved, content: { Alert( title: Text("保存"), message: Text("画像が保存されました。") ) }) }
保存された画像は、標準カメラ並みの画質みたい ■カメラプレビュー(最低限のプレビューを独自に作成) 【SwiftUI】最低限のコードでカメラのプレビューを表示する - Qiita https://qiita.com/eb4gh/items/3918f1d28c9e68fc1705 SwiftUIでAVFundationを導入する【Video Capture偏】 https://blog.personal-factory.com/2020/06/14/introduce-avfundation-by-swiftui/ SwiftUIでAVFoundationを使ってみた - Qiita https://qiita.com/From_F/items/759544896fe89e828898 UIViewにおけるレイアウトのライフサイクル - Qiita https://qiita.com/shoheiyokoyama/items/2f76938dffa845130acc 「App」で以下の設定で新規作成する Product Name: camera Interface: SwiftUI Language: Swift Info.plist にKey「Privacy - Camera Usage Description」を追加し、Valueに「写真を撮影します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSCameraUsageDescription</key> <string>写真を撮影します。</string>
ContentView.swift
import SwiftUI import AVFoundation struct ContentView: View { var body: some View { CameraView() } } // SwiftUIでUIKitのViewを使いたい場合、UIViewRepresentableを継承する struct CameraView: UIViewRepresentable { // 画面が作成されたときに呼ばれる(実装必須) func makeUIView(context: Context) -> UIView { BaseCameraView() } // 画面が更新されたときに呼ばれる(実装必須) func updateUIView(_ uiView: UIViewType, context: Context) {} } class BaseCameraView: UIView { // 利用されるまで初期化されない変数として定義 lazy var initCaptureSession: Void = { var device: AVCaptureDevice? if let availableDevice = AVCaptureDevice.DiscoverySession( deviceTypes: [.builtInWideAngleCamera], mediaType: .video, //position: .front position: .back ).devices.first { device = availableDevice } do { let input = try AVCaptureDeviceInput(device: device!) let session = AVCaptureSession() session.addInput(input) session.startRunning() layer.insertSublayer(AVCaptureVideoPreviewLayer(session: session), at: 0) } catch let error { print(error.localizedDescription) } }() // 画面の制約が更新されると呼ばれる override func layoutSubviews() { super.layoutSubviews() _ = initCaptureSession (layer.sublayers?.first as? AVCaptureVideoPreviewLayer)?.frame = frame } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■カメラプレビュー(カメラ撮影を独自に作成) SwiftUIでAVFoundationを使ってフレームバッファを取得する https://zenn.dev/yorifuji/articles/swiftui-avfoundation 「App」で以下の設定で新規作成する Product Name: camera Interface: SwiftUI Language: Swift Info.plist にKey「Privacy - Camera Usage Description」を追加し、Valueに「写真を撮影します。」と記載しておく さらにKey「Privacy - Photo Library Additions Usage Description」を追加し、Valueに「写真を保存します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSCameraUsageDescription</key> <string>写真を撮影します。</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>写真を保存します。</string>
VideoCapture.swift
import Foundation import AVFoundation class VideoCapture: NSObject { let captureSession = AVCaptureSession() var handler: ((CMSampleBuffer) -> Void)? override init() { super.init() setup() } // キャプチャ設定 func setup() { captureSession.beginConfiguration() let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) guard let deviceInput = try? AVCaptureDeviceInput(device: device!), captureSession.canAddInput(deviceInput) else { return } captureSession.addInput(deviceInput) let videoDataOutput = AVCaptureVideoDataOutput() videoDataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "mydispatchqueue")) videoDataOutput.alwaysDiscardsLateVideoFrames = true guard captureSession.canAddOutput(videoDataOutput) else { return } captureSession.addOutput(videoDataOutput) // アウトプットの画像を縦向きに変更(標準は横) for connection in videoDataOutput.connections { if connection.isVideoOrientationSupported { connection.videoOrientation = .portrait } } captureSession.commitConfiguration() } // キャプチャ開始 func run(_ handler: @escaping (CMSampleBuffer) -> Void) { if !captureSession.isRunning { self.handler = handler captureSession.startRunning() } } // キャプチャ停止 func stop() { if captureSession.isRunning { captureSession.stopRunning() } } } extension VideoCapture: AVCaptureVideoDataOutputSampleBufferDelegate { func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { if let handler = handler { handler(sampleBuffer) } } }
ContentView.swift
import SwiftUI import AVFoundation struct ContentView: View { let videoCapture = VideoCapture() @State var image: UIImage? = nil @State var saved: Bool = false var body: some View { VStack { if let image = image { Image(uiImage: image) .resizable() .scaledToFit() } HStack { Button("撮影") { // カメラプレビューを停止 stopCameraPreview() // シャッター音を鳴らす AudioServicesPlaySystemSound(1108) // 画像を保存 UIImageWriteToSavedPhotosAlbum(self.image!, nil, nil, nil) // 保存完了アラートを表示 saved = true }.alert(isPresented: $saved, content: { Alert( title: Text("保存"), message: Text("画像が保存されました。"), dismissButton: .default(Text("OK"), action: { // カメラプレビューを開始 startCameraPreview() }) ) }) } } .onAppear { // カメラプレビューを開始 startCameraPreview() } } // カメラプレビューを開始 func startCameraPreview() { // キャプチャ開始 videoCapture.run { sampleBuffer in if let convertImage = UIImageFromSampleBuffer(sampleBuffer) { DispatchQueue.main.async { self.image = convertImage } } } } // カメラプレビューを停止 func stopCameraPreview() { // キャプチャ停止 videoCapture.stop() } // カメラプレビューからイメージを取得 func UIImageFromSampleBuffer(_ sampleBuffer: CMSampleBuffer) -> UIImage? { if let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { let ciImage = CIImage(cvPixelBuffer: pixelBuffer) let imageRect = CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) let context = CIContext() if let image = context.createCGImage(ciImage, from: imageRect) { return UIImage(cgImage: image) } } return nil } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
ビューに表示したサイズの画像を保存するためか、 標準カメラに比べると画質は低い 「AudioServicesPlaySystemSound(1108)」でシャッター音を鳴らすことはできたが、マナーモードだと鳴らなかった 強制的に鳴らすことはできるか、もしくはこのままでいいのか 引き続き調べたい iOS - iPhoneのデフォルトのシャッター音を鳴らすには|teratail https://teratail.com/questions/89195 ios - ボリューム設定が0(ミュート)であっても音を鳴らす方法 - スタック・オーバーフロー https://ja.stackoverflow.com/questions/6068/%E3%83%9C%E3%83%AA%E3%83%A5%E3%83%BC%E3%83%A0%E8%A8%AD%E... iOS - 実機で音声が再生されない|teratail https://teratail.com/questions/40086 未検証だが、以下も参考になりそう Swiftでカメラアプリを作成する(1) - Qiita https://qiita.com/t_okkan/items/f2ba9b7009b49fc2e30a Swiftでカメラアプリを作成する(2) - Qiita https://qiita.com/t_okkan/items/b2dd11426eab107c5d15 ■カメラプレビュー(プレビューの映像を反転) 未検証 iOSでの動画処理における「回転」「向き」の取り扱いでもう混乱したくない - Qiita https://qiita.com/shu223/items/057351d41229861251af ■Live Photos Live Photosを表示する解説はあるが、作成する解説はすぐに見つからなかった 独自に実装するなら、画像と動画の作成&保存処理をゴリゴリと書いていくくらいか もしくは、専用のファイル形式やそれを扱うための命令があるか 要調査 【SwiftUI】Live Photoの表示 https://zenn.dev/harumaru/articles/6f7ec2659261f6 Live Photos(ライブフォト)を表示するクラス PHLivePhotoView を試す - Qiita https://qiita.com/shu223/items/e87ea139512ba732997d
■SwiftUI+QRコード
※今ならVisionフレームワークを使う方がいいのかもしれない プレビューでのリアルタイムな検出ができるかは要検証 少なくとも 「QRコードが見つかった → 画像データとして一時保存 → その画像内から改めてQRコードを探す」 のような手順を踏めば対応できないことはなさそう ※1次元バーコード読み取りの精度に難がある場合、以下ライブラリで精度向上が期待できるかもしれない(未検証) [iOS] ZXingObjCを使ってQRコードを読み取る | DevelopersIO https://dev.classmethod.jp/articles/ios-zxing-322-qrcode-decode/ ※以下は参考までにメモしておく 中日新聞:自動車工場のガロア体 QRコードはどう動くか https://static.chunichi.co.jp/chunichi/pages/feature/QR/galois_field_in_auto_factory.html ■QRコード作成 未検証 【SwiftUI】SwiftUIでQRコードを表示する - It’s now or never https://inon29.hateblo.jp/entry/2020/04/25/105335 SwiftUIでQRコードを表示してみる - Qiita https://qiita.com/From_F/items/6c97205fc20cd10a0ddf ■QRコード読み取り SwiftUIでQRコードを読み取る。 - Qiita https://qiita.com/ikaasamay/items/58d1a401e98673a96fd2 Camera preview and a QR-code Scanner in SwiftUI | by Konstantin | Dev Genius https://blog.devgenius.io/camera-preview-and-a-qr-code-scanner-in-swiftui-48b111155c66 【Swift】QRコードを読み取って文字列を取得する - Qiita https://qiita.com/_asa08_/items/8562fe79ec6528a61b06 QRコード(二次元バーコード)作成【無料】 https://www.cman.jp/QRcode/ 【SwiftUI】QRコードを読み込みに使える便利なフレームワーク(MercariQRScanner/CodeScanner) #iOS - Qiita https://qiita.com/tigercat1124/items/6a4c861c58783c273706 「App」で以下の設定で新規作成する Product Name: qrcodereader Interface: SwiftUI Language: Swift Info.plist にKey「Privacy - Camera Usage Description」を追加し、Valueに「QRコードを読み取ります。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSCameraUsageDescription</key> <string>QRコードを読み取ります。</string>
ScannerViewModel.swift
import Foundation class ScannerViewModel: ObservableObject { // QRコードを読み取る間隔 let scanInterval: Double = 1.0 @Published var lastQrCode: String = "" @Published var isShowing: Bool = false // QRコード読み取り時に実行される処理 func onFoundQrCode(_ code: String) { self.lastQrCode = code isShowing = false } }
QrCodeCameraDelegate.swift
import AVFoundation class QrCodeCameraDelegate: NSObject, AVCaptureMetadataOutputObjectsDelegate { var scanInterval: Double = 1.0 var lastTime = Date(timeIntervalSince1970: 0) var onResult: (String) -> Void = { _ in } var mockData: String? func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { if let metadataObject = metadataObjects.first { guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return } guard let stringValue = readableObject.stringValue else { return } foundBarcode(stringValue) } } @objc func onSimulateScanning(){ foundBarcode(mockData ?? "Simulated QR-code result.") } func foundBarcode(_ stringValue: String) { let now = Date() if now.timeIntervalSince(lastTime) >= scanInterval { lastTime = now self.onResult(stringValue) } } }
CameraPreview.swift
import UIKit import AVFoundation class CameraPreview: UIView { private var label: UILabel? var previewLayer: AVCaptureVideoPreviewLayer? var session = AVCaptureSession() weak var delegate: QrCodeCameraDelegate? init(session: AVCaptureSession) { super.init(frame: .zero) self.session = session } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc func onClick(){ delegate?.onSimulateScanning() } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = self.bounds } }
QrCodeScannerView.swift
import SwiftUI import AVFoundation struct QrCodeScannerView: UIViewRepresentable { var supportedBarcodeTypes: [AVMetadataObject.ObjectType] = [.qr] typealias UIViewType = CameraPreview private let session = AVCaptureSession() private let delegate = QrCodeCameraDelegate() private let metadataOutput = AVCaptureMetadataOutput() func interval(delay: Double) -> QrCodeScannerView { delegate.scanInterval = delay return self } func found(result: @escaping (String) -> Void) -> QrCodeScannerView { delegate.onResult = result return self } func setupCamera(_ uiView: CameraPreview) { if let backCamera = AVCaptureDevice.default(for: AVMediaType.video) { if let input = try? AVCaptureDeviceInput(device: backCamera) { session.sessionPreset = .photo if session.canAddInput(input) { session.addInput(input) } if session.canAddOutput(metadataOutput) { session.addOutput(metadataOutput) metadataOutput.metadataObjectTypes = supportedBarcodeTypes metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main) } let previewLayer = AVCaptureVideoPreviewLayer(session: session) uiView.backgroundColor = UIColor.gray previewLayer.videoGravity = .resizeAspectFill uiView.layer.addSublayer(previewLayer) uiView.previewLayer = previewLayer session.startRunning() } } } func makeUIView(context: UIViewRepresentableContext<QrCodeScannerView>) -> QrCodeScannerView.UIViewType { let cameraView = CameraPreview(session: session) checkCameraAuthorizationStatus(cameraView) return cameraView } static func dismantleUIView(_ uiView: CameraPreview, coordinator: ()) { uiView.session.stopRunning() } private func checkCameraAuthorizationStatus(_ uiView: CameraPreview) { let cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) if cameraAuthorizationStatus == .authorized { setupCamera(uiView) } else { AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.sync { if granted { self.setupCamera(uiView) } } } } } func updateUIView(_ uiView: CameraPreview, context: UIViewRepresentableContext<QrCodeScannerView>) { uiView.setContentHuggingPriority(.defaultHigh, for: .vertical) uiView.setContentHuggingPriority(.defaultLow, for: .horizontal) } }
ContentView.swift
import SwiftUI struct ContentView: View { @ObservedObject var viewModel = ScannerViewModel() var body: some View { VStack { Text("QRコードリーダー") // カメラ起動ボタン Button(action: { viewModel.isShowing = true }) { Text("カメラ起動") } .fullScreenCover(isPresented: $viewModel.isShowing) { QrCodeReaderView(viewModel: viewModel) } .padding() // 読み取ったQRコード if (viewModel.lastQrCode != "") { Text("QRコード = [ " + viewModel.lastQrCode + " ]") } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
QrCodeReaderView.swift
import SwiftUI struct QrCodeReaderView: View { @ObservedObject var viewModel: ScannerViewModel var body: some View { Text("QRコード読み取り") ZStack { // QRコード読み取り QrCodeScannerView() .interval(delay: self.viewModel.scanInterval) .found(result: self.viewModel.onFoundQrCode) // 閉じるボタン VStack { Spacer() Button("閉じる") { self.viewModel.isShowing = false } .padding() } } } }
■SwiftUI+顔認識
リアルタイム顔検出と画像を重ねての表示ができるなら、独自に差分検出などもできるかもしれない 以下の参考サイトはSwiftであってSwiftUIでは無いようなので注意 【iOS】Vision Frameworkを使ってリアルタイム顔検出アプリを作ってみた - 株式会社ライトコード https://rightcode.co.jp/blog/information-technology/ios-vision-framework-real-time-face-detection-ap... iOSでリアルタイム顔検出を行う - Qiita https://qiita.com/renchild8/items/b2e04fe48cb2cf60bcbc [コピペで使える]swift3/swift4/swift5でリアルタイム顔認識をする方法 - Qiita https://qiita.com/TakahiroYamamoto/items/e970658a98a4e659cf9e ■検証中 SwiftUI-Vision/Detected-in-Still-Image/Detected-in-Still-Image at main - SatoTakeshiX/SwiftUI-Vision - GitHub https://github.com/SatoTakeshiX/SwiftUI-Vision/tree/main/Detected-in-Still-Image/Detected-in-Still-I... をもとに検証中 SwiftUI-Visionで作られている また、リアルタイムな顔認識は「SwiftUI+リアルタイム顔認識」に記載している https://raw.githubusercontent.com/SatoTakeshiX/SwiftUI-Vision/main/Detected-in-Still-Image/Detected-... 画像をダウンロードし、Assets.xcassets に配置 (ドラッグ&ドロップすると「people」という名前で配置された) VisionClient.swift DetectorViewModel.swift
import Foundation import SwiftUI import UIKit import Vision import Combine final class DetectorViewModel: ObservableObject { @Published var image: UIImage = UIImage() @Published var detectedFrame: [CGRect] = [] @Published var detectedPoints: [(closed: Bool, points: [CGPoint])] = [] @Published var detectedInfo: [[String: String]] = [] private var cancellables: Set<AnyCancellable> = [] private var errorCancellables: Set<AnyCancellable> = [] private let visionClient = VisionClient() private var imageViewFramePublisher = PassthroughSubject<CGRect, Never>() private var originImagePublisher = PassthroughSubject<(CGImage, VisionRequestTypes.Set), Never>() // 初期処理 init() { visionClient.$result .receive(on: RunLoop.main) .sink { type in switch type { case .faceLandmarks(let drawPoints, let info): self.detectedPoints = drawPoints self.detectedInfo = info case .faceRect(let rectBox, let info): self.detectedFrame = rectBox self.detectedInfo = info case .word(let rectBoxes, let info): self.detectedFrame += rectBoxes self.detectedInfo = info case .character(let rectBox, let info): self.detectedFrame += rectBox self.detectedInfo = info case .textRecognize(let info): self.detectedInfo = info case .barcode(let rectBoxes, let info): self.detectedFrame = rectBoxes self.detectedInfo = info case .rect(let drawPoints, let info): self.detectedPoints = drawPoints self.detectedInfo = info case .rectBoundingBoxes(let rectBoxes): self.detectedFrame = rectBoxes default: break } } .store(in: &cancellables) visionClient.$error .receive(on: RunLoop.main) .sink { error in print(error?.localizedDescription ?? "") } .store(in: &errorCancellables) imageViewFramePublisher .removeDuplicates() // イベント送信を2つに絞る、最後のイベントを受け取る(GeometryReaderのハンドラが2回呼ばれており、2回目のみ正しい座標を取得できたため。overlayを利用しているためか) .prefix(2).last() // originImagePublisherのイベントと組み合わせて最後の値を取る .combineLatest(originImagePublisher) // Publisherのイベントの値を受け取る .sink { (imageRect, originImageArg) in // 画像サイズをimage viewのサイズに合わせてリサイズ let (cgImage, detectType) = originImageArg let fullImageWidth = CGFloat(cgImage.width) let fullImageHeight = CGFloat(cgImage.height) let targetWidh = imageRect.width let ratio = fullImageWidth / targetWidh let imageFrame = CGRect(x: 0, y: 0, width: imageRect.width, height: fullImageHeight / ratio) self.visionClient.configure(type: detectType, imageViewFrame: imageFrame) print(cgImage) // 画像向きを作成 let cgOrientation = CGImagePropertyOrientation(self.image.imageOrientation) // 情報をクリア self.clearAllInfo() // 画像と向きを返す self.visionClient.performVisionRequest(image: cgImage, orientation: cgOrientation) } .store(in: &cancellables) } // 画像情報と検出タイプを受け取る func onAppear(image: UIImage, detectType: VisionRequestTypes.Set) { self.image = image guard let resizedImage = resize(image: image) else { return } print(resizedImage.description) // Transform image to fit screen. guard let cgImage = resizedImage.cgImage else { print("Trying to show an image not backed by CGImage!") return } // 画像情報をイベントとして送信 originImagePublisher.send((cgImage, detectType)) } // 情報を入力 func input(imageFrame: CGRect) { // ImageViewの矩形情報をイベントとして送信 // 複数回呼ばれる可能性がある imageViewFramePublisher.send(imageFrame) } // 画像をリサイズ func resize(image: UIImage) -> UIImage? { let width: Double = 640 let aspectScale = image.size.height / image.size.width let resizedSize = CGSize(width: width, height: width * Double(aspectScale)) UIGraphicsBeginImageContextWithOptions(resizedSize, false, 0.0) image.draw(in: CGRect(x: 0, y: 0, width: resizedSize.width, height: resizedSize.height)) let resizedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return resizedImage } // 情報をクリア private func clearAllInfo() { detectedFrame.removeAll() detectedPoints.removeAll() detectedInfo.removeAll() } } // UIImageOrientationをCGImageOrientationに変換 extension CGImagePropertyOrientation { init(_ uiImageOrientation: UIImage.Orientation) { switch uiImageOrientation { case .up: self = .up case .down: self = .down case .left: self = .left case .right: self = .right case .upMirrored: self = .upMirrored case .downMirrored: self = .downMirrored case .leftMirrored: self = .leftMirrored case .rightMirrored: self = .rightMirrored @unknown default: fatalError() } } }
ContentView.swift
import SwiftUI struct ContentView: View { @StateObject var viewModel = DetectorViewModel() var body: some View { VStack { Image(uiImage: viewModel.image) .resizable() .aspectRatio(contentMode: .fit) .opacity(0.6) .overlay( // 画像Viewの座標情報を取得 GeometryReader { proxy -> AnyView in viewModel.input(imageFrame: proxy.frame(in: .local)) return AnyView(EmptyView()) } ) .overlay( // 開いたパスを描画 Path { path in for frame in viewModel.detectedFrame { // 矩形を描画 path.addRect(frame) } } .stroke(Color.green, lineWidth: 2.0) // Visionの座標系からSwiftUIの座標系に変換 .scaleEffect(x: 1.0, y: -1.0, anchor: .center) ) .overlay( // 閉じたパスを描画 Path { path in for (closed, points) in viewModel.detectedPoints { // 線を描画 path.addLines(points) if closed { // パスを閉じる path.closeSubpath() } } } .stroke(Color.blue, lineWidth: 2.0) // Visionの座標系からSwiftUIの座標系に変換 .scaleEffect(x: 1.0, y: -1.0, anchor: .center) ) Text("Vision Framework") .padding() } .onAppear { // 画像情報と検出タイプをViewModelに渡す viewModel.onAppear(image: UIImage(named: "people")!, detectType: [.faceRect]) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■SwiftUI+リアルタイム顔認識
■検証中 SwiftUI-Vision/Realtime-Face-Tracking/Realtime-Face-Tracking at main - SatoTakeshiX/SwiftUI-Vision https://github.com/SatoTakeshiX/SwiftUI-Vision/tree/main/Realtime-Face-Tracking/Realtime-Face-Tracki... をもとに検証中 Info.plist にKey「Privacy - Camera Usage Description」を追加し、Valueに「顔を検出します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSCameraUsageDescription</key> <string>顔を検出します。</string>
ContentView.swift
import SwiftUI struct ContentView: View { @StateObject var viewModel = TrackingViewModel() var body: some View { ZStack { PreviewLayerView(previewLayer: viewModel.previewLayer, detectedRect: viewModel.detectedRects, pixelSize: viewModel.pixelSize) } .edgesIgnoringSafeArea(.all) .onAppear { viewModel.startSession() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
PreviewLayerView.swift
import SwiftUI import AVFoundation /// UIViewRepresentableを使うとview.frameがzeroになりlayerが描画されない。 /// UIViewControllerRepresentableを利用するとviewController.viewは端末サイズが与えられる struct PreviewLayerView: UIViewControllerRepresentable { typealias UIViewControllerType = UIViewController let previewLayer: AVCaptureVideoPreviewLayer let detectedRect: [CGRect] let pixelSize: CGSize func makeUIViewController(context: Context) -> UIViewController { let viewController = UIViewController() viewController.view.layer.addSublayer(previewLayer) previewLayer.frame = viewController.view.layer.frame return viewController } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { previewLayer.frame = uiViewController.view.layer.frame drawFaceObservations(detectedRect) } func drawFaceObservations(_ detectedRects: [CGRect]) { // sublayerを削除 previewLayer.sublayers?.removeSubrange(1...) // pixelSizeで矩形作成 let captureDeviceBounds = CGRect( x: 0, y: 0, width: pixelSize.width, height: pixelSize.height ) let overlayLayer = CALayer() overlayLayer.name = "DetectionOverlay" overlayLayer.bounds = captureDeviceBounds overlayLayer.position = CGPoint( x: captureDeviceBounds.midX, y: captureDeviceBounds.midY ) print("overlay: befor: \(overlayLayer.frame)") // let videoPreviewRect = previewLayer.layerRectConverted(fromMetadataOutputRect: CGRect(x: 0, y: 0, width: 1, height: 1)) let (rotation, scaleX, scaleY) = makerotationAndScale(videoPreviewRect: videoPreviewRect, pixelSize: pixelSize) // Scale and mirror the image to ensure upright presentation. let affineTransform = CGAffineTransform(rotationAngle: radiansForDegrees(rotation)).scaledBy(x: scaleX, y: -scaleY) overlayLayer.setAffineTransform(affineTransform) overlayLayer.position = CGPoint(x: previewLayer.bounds.midX, y: previewLayer.bounds.midY) previewLayer.addSublayer(overlayLayer) print("overlay: after: \(overlayLayer.frame)") let layers = detectedRects.compactMap { detectedRect -> CALayer in let xMin = detectedRect.minX let yMax = detectedRect.maxY let detectedX = xMin * overlayLayer.frame.size.width + overlayLayer.frame.minX let detectedY = (1 - yMax) * overlayLayer.frame.size.height let detectedWidth = detectedRect.width * overlayLayer.frame.size.width let detectedHeight = detectedRect.height * overlayLayer.frame.size.height let layer = CALayer() layer.frame = CGRect(x: detectedX, y: detectedY, width: detectedWidth, height: detectedHeight) layer.borderWidth = 2.0 layer.borderColor = UIColor.green.cgColor return layer } layers.forEach { self.previewLayer.addSublayer($0) } } private func radiansForDegrees(_ degrees: CGFloat) -> CGFloat { return CGFloat(Double(degrees) * Double.pi / 180.0) } private func makerotationAndScale(videoPreviewRect: CGRect, pixelSize: CGSize) -> (rotation: CGFloat, scaleX: CGFloat, scaleY: CGFloat) { var rotation: CGFloat var scaleX: CGFloat var scaleY: CGFloat // Rotate the layer into screen orientation. switch UIDevice.current.orientation { case .portraitUpsideDown: rotation = 180 scaleX = videoPreviewRect.width / pixelSize.width scaleY = videoPreviewRect.height / pixelSize.height case .landscapeLeft: rotation = 90 scaleX = videoPreviewRect.height / pixelSize.width scaleY = scaleX case .landscapeRight: rotation = -90 scaleX = videoPreviewRect.height / pixelSize.width scaleY = scaleX default: rotation = 0 scaleX = videoPreviewRect.width / pixelSize.width scaleY = videoPreviewRect.height / pixelSize.height } return (rotation, scaleX, scaleY) } }
TrackingViewModel.swift
import Combine import UIKit import Vision import AVKit final class TrackingViewModel: ObservableObject { let captureSession = CaptureSession() let visionClient = VisionClient() var previewLayer: AVCaptureVideoPreviewLayer { return captureSession.previewLayer } @Published var detectedRects: [CGRect] = [] private var cancellables: Set<AnyCancellable> = [] init() { bind() } @Published var pixelSize: CGSize = .zero func bind() { captureSession.outputs .receive(on: RunLoop.main) .sink { [weak self] output in guard let self = self else { return } var requestHandlerOptions: [VNImageOption: AnyObject] = [:] // 内部データをVisionリクエストにオプションとして設定 requestHandlerOptions[VNImageOption.cameraIntrinsics] = output.cameraIntrinsicData // 画像サイズは保持する self.pixelSize = output.pixelBufferSize self.visionClient.request(cvPixelBuffer: output.pixelBuffer, orientation: self.makeOrientation(with: UIDevice.current.orientation), options: requestHandlerOptions) } .store(in: &cancellables) visionClient.$visionObjectObservations .receive(on: RunLoop.main) .map { observations -> [CGRect] in return observations.map { $0.boundingBox } } .assign(to: &$detectedRects) } func startSession() { captureSession.startSettion() } func makeOrientation(with deviceOrientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch deviceOrientation { case .portraitUpsideDown: return .rightMirrored case .landscapeLeft: return .downMirrored case .landscapeRight: return .upMirrored default: return .leftMirrored } } }
CaptureSession.swift
import Foundation import AVKit import Combine import SwiftUI final class CaptureSession: NSObject, ObservableObject { struct Outputs { let cameraIntrinsicData: CFTypeRef let pixelBuffer: CVImageBuffer let pixelBufferSize: CGSize } private let captureSession = AVCaptureSession() private var captureDevice: AVCaptureDevice? private var videoDataOutput: AVCaptureVideoDataOutput? private var videoDataOutputQueue: DispatchQueue? private(set) var previewLayer = AVCaptureVideoPreviewLayer() var outputs = PassthroughSubject<Outputs, Never>() private var cancellable: AnyCancellable? override init() { super.init() setupCaptureSession() } // MARK: - Create capture session private func setupCaptureSession() { captureSession.sessionPreset = .photo // use front camera if let availableDevice = AVCaptureDevice.DiscoverySession( deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .front ).devices.first { captureDevice = availableDevice do { let captureDeviceInput = try AVCaptureDeviceInput(device: availableDevice) captureSession.addInput(captureDeviceInput) } catch { print(error.localizedDescription) } } makePreviewLayser(session: captureSession) // ここだけcombine。TODO: fix later cancellable = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) .map { _ in () } .prepend(()) // initial run .sink { [previewLayer] in let interfaceOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation if let interfaceOrientation = interfaceOrientation, let orientation = AVCaptureVideoOrientation(interfaceOrientation: interfaceOrientation) { previewLayer.connection?.videoOrientation = orientation } } makeDataOutput() } func startSettion() { if captureSession.isRunning { return } captureSession.startRunning() } func stopSettion() { if !captureSession.isRunning { return } captureSession.stopRunning() } private func makePreviewLayser(session: AVCaptureSession) { let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.name = "CameraPreview" previewLayer.videoGravity = .resizeAspectFill previewLayer.backgroundColor = UIColor.green.cgColor //previewLayer.borderWidth = 2 //previewLayer.borderColor = UIColor.black.cgColor self.previewLayer = previewLayer } private func makeDataOutput() { let videoDataOutput = AVCaptureVideoDataOutput() videoDataOutput.videoSettings = [ (kCVPixelBufferPixelFormatTypeKey as String): kCVPixelFormatType_32BGRA ] // frame落ちたら捨てる処理 videoDataOutput.alwaysDiscardsLateVideoFrames = true let videoDataOutputQueue = DispatchQueue(label: "com.Personal-Factory.Realtime-Face-Tracking") videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue) captureSession.beginConfiguration() if captureSession.canAddOutput(videoDataOutput) { captureSession.addOutput(videoDataOutput) } // to use CMGetAttachment in sampleBuffer if let captureConnection = videoDataOutput.connection(with: .video) { if captureConnection.isCameraIntrinsicMatrixDeliverySupported { captureConnection.isCameraIntrinsicMatrixDeliveryEnabled = true } } self.videoDataOutput = videoDataOutput self.videoDataOutputQueue = videoDataOutputQueue captureSession.commitConfiguration() } } extension CaptureSession: AVCaptureVideoDataOutputSampleBufferDelegate { func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { guard let cameraIntrinsicData = CMGetAttachment(sampleBuffer, key: kCMSampleBufferAttachmentKey_CameraIntrinsicMatrix, attachmentModeOut: nil) else { return } guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { print("Failed to obtain a CVPixelBuffer for the current output frame.") return } let width = CVPixelBufferGetWidth(pixelBuffer) let hight = CVPixelBufferGetHeight(pixelBuffer) self.outputs.send(.init( cameraIntrinsicData: cameraIntrinsicData, pixelBuffer: pixelBuffer, pixelBufferSize: CGSize(width: width, height: hight) )) } } // MARK: - AVCaptureVideoOrientation extension AVCaptureVideoOrientation: CustomDebugStringConvertible { public var debugDescription: String { switch self { case .portrait: return "portrait" case .portraitUpsideDown: return "portraitUpsideDown" case .landscapeRight: return "landscapeRight" case .landscapeLeft: return "landscapeLeft" @unknown default: return "unknown" } } public init?(deviceOrientation: UIDeviceOrientation) { switch deviceOrientation { case .portrait: self = .portrait case .portraitUpsideDown: self = .portraitUpsideDown case .landscapeLeft: self = .landscapeRight case .landscapeRight: self = .landscapeLeft case .faceUp, .faceDown, .unknown: return nil @unknown default: return nil } } public init?(interfaceOrientation: UIInterfaceOrientation) { switch interfaceOrientation { case .portrait: self = .portrait case .portraitUpsideDown: self = .portraitUpsideDown case .landscapeLeft: self = .landscapeLeft case .landscapeRight: self = .landscapeRight case .unknown: return nil @unknown default: return nil } } }
VisionClient.swift
import Foundation import Vision import Combine // tracking face via CVPixelBuffer final class VisionClient: NSObject, ObservableObject { enum State { case stop case tracking(trackingRequests: [VNTrackObjectRequest]) } @Published var visionObjectObservations: [VNDetectedObjectObservation] = [] @Published var state: State = .stop private var subscriber: Set<AnyCancellable> = [] private lazy var sequenceRequestHandler = VNSequenceRequestHandler() func request(cvPixelBuffer pixelBuffer: CVPixelBuffer, orientation: CGImagePropertyOrientation, options: [VNImageOption : Any] = [:]) { switch state { case .stop: initialRequest(cvPixelBuffer: pixelBuffer, orientation: orientation, options: options) case .tracking(let trackingRequests): guard !trackingRequests.isEmpty else { initialRequest(cvPixelBuffer: pixelBuffer, orientation: orientation, options: options) break } do { try sequenceRequestHandler.perform(trackingRequests, on: pixelBuffer, orientation: orientation) } catch { print(error.localizedDescription) } // 次のトラッキングを設定 // perform実行後はresultsプロパティが更新されている let newTrackingRequests = trackingRequests.compactMap { request -> VNTrackObjectRequest? in guard let results = request.results else { return nil } guard let observation = results[0] as? VNDetectedObjectObservation else { return nil } if !request.isLastFrame { if observation.confidence > 0.3 { request.inputObservation = observation } else { request.isLastFrame = true } return request } else { return nil } } state = .tracking(trackingRequests: newTrackingRequests) if newTrackingRequests.isEmpty { // トラックするものがない self.visionObjectObservations = [] return } newTrackingRequests.forEach { request in guard let result = request.results as? [VNDetectedObjectObservation] else { return } self.visionObjectObservations = result } } } // MARK: Performing Vision Requests private func prepareRequest(completion: @escaping (Result<[VNTrackObjectRequest], Error>) -> Void) -> VNDetectFaceRectanglesRequest { var requests = [VNTrackObjectRequest]() let faceRequest = VNDetectFaceRectanglesRequest(completionHandler: { (request, error) in if let error = error { completion(.failure(error)) } guard let faceDetectionRequest = request as? VNDetectFaceRectanglesRequest, let results = faceDetectionRequest.results as? [VNFaceObservation] else { return } // Add the observations to the tracking list for obs in results { let faceTrackingRequest = VNTrackObjectRequest(detectedObjectObservation: obs) requests.append(faceTrackingRequest) } completion(.success(requests)) }) return faceRequest } private func initialRequest(cvPixelBuffer pixelBuffer: CVPixelBuffer, orientation: CGImagePropertyOrientation, options: [VNImageOption : Any] = [:]) { // No tracking object detected, so perform initial detection let imageRequestHandler = VNImageRequestHandler( cvPixelBuffer: pixelBuffer, orientation: orientation, options: options ) do { let faceDetectionRequest = prepareRequest() { [weak self] result in switch result { case .success(let trackingRequests): self?.state = .tracking(trackingRequests: trackingRequests) case .failure(let error): print("error: \(String(describing: error)).") } } try imageRequestHandler.perform([faceDetectionRequest]) } catch let error as NSError { NSLog("Failed to perform FaceRectangleRequest: %@", error) } } }
プレビューを UIViewRepresentable ではなく UIViewControllerRepresentable で作成している UIViewRepresentable を使うとサイズ調整が厄介そうなためらしいが、使えないわけでは無いみたい 以下でも最低限のサンプルとともに解説されているので、挙動を比較しつつ試したい SwiftUIでAVFundationを導入する【Video Capture偏】 https://blog.personal-factory.com/2020/06/14/introduce-avfundation-by-swiftui/
■SwiftUI+音声
【SwiftUI】AVFoundationでText to Speech - Qiita https://qiita.com/mushroominger/items/5f4199d4eff8d2b4bc30 [Swift] AVSpeechSynthesizerで読み上げ機能を使ってみる | DevelopersIO https://dev.classmethod.jp/articles/swfit-avspeechsynthesizer/ ■音声読み上げ ContentView.swift
import SwiftUI import AVFoundation struct ContentView: View { @State private var language = "ja" @State private var text: String = "" var body: some View { VStack { Picker(selection: $language, label: Text("フルーツを選択")) { Text("日本語").tag("ja") Text("英語").tag("en") } .frame(width: 200, height: 100) //Text("選択値:\(language)") TextEditor(text: $text) .frame(width: UIScreen.main.bounds.width * 0.9, height: 200) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray, lineWidth: 1) ) .padding() Button("読み上げる") { // 読み上げる内容 let utterance = AVSpeechUtterance(string: text) // 言語 if (language == "ja") { utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP") } else { utterance.voice = AVSpeechSynthesisVoice(language: "en-US") } // 速度 utterance.rate = 0.5 //utterance.rate = 0.6 // 高さ utterance.pitchMultiplier = 1.0 //utterance.pitchMultiplier = 1.2 // 読み上げ実行 let synthesizer = AVSpeechSynthesizer() synthesizer.speak(utterance) } .padding() Spacer() } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■音声認識 ※検証中 Swiftでリアルタイム音声認識するための最小コード - Qiita https://qiita.com/mishimay/items/71304f0aa2a313ad93ac Swift でリアルタイム音声認識をするサンプルコード - iOS アプリケーション開発の基本 - Swift による iOS 開発入門 https://swift-ios.keicode.com/ios/speechrecognition-live.php SpeechFrameworkで音声認識されなくなる問題 - 野生のプログラマZ http://harumi.sakura.ne.jp/wordpress/2020/04/20/speechframework%E3%81%A7%E9%9F%B3%E5%A3%B0%E8%AA%8D%... SwiftUIとSpeech Frameworkで動画の文字起こしアプリを作ってみる - Qiita https://qiita.com/rymshm/items/5ea968acb686c53133c7 Info.plist にKey「Privacy - Microphone Usage Description」を追加し、Valueに「マイクを使用します。」と記載しておく 同様に「Privacy - Speech Recognition Usage Description」を追加し、Valueに「音声認識を使用します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSMicrophoneUsageDescription</key> <string>マイクを使用します。</string> <key>NSSpeechRecognitionUsageDescription</key> <string>音声認識を使用します。</string>
ContentView.swift
import SwiftUI import Speech struct ContentView: View { private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))! @State private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? @State private var recognitionTask: SFSpeechRecognitionTask? private let audioEngine = AVAudioEngine() @State private var text: String = "" var body: some View { VStack { TextEditor(text: $text) .frame(width: UIScreen.main.bounds.width * 0.9, height: 200) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray, lineWidth: 1) ) .padding() Button("音声認識開始") { try? start() } Button("音声認識終了") { stop() } } .onAppear() { SFSpeechRecognizer.requestAuthorization { (authStatus) in } } } private func start() throws { if let recognitionTask = recognitionTask { recognitionTask.cancel() self.recognitionTask = nil } let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .measurement, options: []) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) let recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest = recognitionRequest recognitionRequest.shouldReportPartialResults = true recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest, resultHandler: { (result, error) in //recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] (result, error) in //guard let `self` = self else { return } var isFinal = false if let result = result { print(result.bestTranscription.formattedString) text = result.bestTranscription.formattedString isFinal = result.isFinal } if error != nil || isFinal { self.audioEngine.stop() self.audioEngine.inputNode.removeTap(onBus: 0) self.recognitionRequest = nil self.recognitionTask = nil } }) let recordingFormat = audioEngine.inputNode.outputFormat(forBus: 0) audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in self.recognitionRequest?.append(buffer) } audioEngine.prepare() try? audioEngine.start() } private func stop() { audioEngine.stop() recognitionRequest?.endAudio() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
「音声認識開始」ボタンを連続してタップするとアプリが止まる 認識中ならタップできないようにする仕組みが必要か
■SwiftUI+Bluetooth
※未検証 【Swift5】Bluetoothクラス実装の備忘録 - Qiita https://qiita.com/haru15komekome/items/f5791d322995a3fd7452 【SwiftUI × CoreBluetooth】 SwiftUI でデバイスと BLE 通信を行う 【前編】|ソフトウェア|技術開発|TechBLOG|Braveridge TechBLOG https://blog.braveridge.com/blog/archives/148 iOS Core Bluetooth (Swift) を使用してみた - Grow up https://knkomko.hatenablog.com/entry/2019/07/16/013443
■SwiftUI+通知
■通知 ※未検証 SwiftUIのディープリンク対応:プッシュ通知から画面遷移する方法 - Quipper Product Team Blog https://quipper.hatenablog.com/entry/2020/12/24/swiftui-deeplinking iOSシミュレータにプッシュ通知を送ってみる - Qiita https://qiita.com/koogawa/items/85c0dd0abd2f1970c5fc 【SwiftUI】Firebase Cloud Messagingで受信したプッシュ通知の内容をSwiftUIのViewで利用する - Swift・iOS https://www.hfoasi8fje3.work/entry/2021/01/20/%E3%80%90SwiftUI%E3%80%91Firebase_Cloud_Messaging%E3%8... 【SwiftUI】プッシュ通知を選択した時に特定の画面に遷移する - Swift・iOS https://www.hfoasi8fje3.work/entry/2021/01/25/%E3%80%90SwiftUI%E3%80%91%E3%83%97%E3%83%83%E3%82%B7%E... ※以下2022年に改めて調べたときのもの 未検証 【SwiftUI】通知機能の実装方法!ローカル通知とリモート通知の違い https://tech.amefure.com/swift-notification 【Swift】Firebaseからプッシュ通知を受け取るために最低限の実装をする(iOS15対応) https://zenn.dev/tomsan96/articles/0cdfde2a49bfb2 ■ローカル通知 【SwiftUI】ローカル通知と通知からのアプリ起動(DeepLink) | thwork https://thwork.net/2021/08/29/swiftui_notification_deeplink/ 【SwiftUI】ローカル通知を実装する方法【バックグラウンド】 - おもちblog https://omochiblog.com/2021/02/28/swiftui-localnotification-background/ 最低限のローカル通知
import SwiftUI import UserNotifications struct ContentView: View { @State var buttonText = "5秒後にローカル通知を発行する" var body: some View { // ローカル通知発行ボタン Button(action: { UNUserNotificationCenter.current().requestAuthorization(options: [.alert,.sound,.badge]) { (granted, error) in if granted { // 通知が許可されている場合の処理 // 通知を作成 makeNotification() } else { // 通知が拒否されている場合の処理 // ボタンの表示を変える buttonText = "通知が拒否されているので発動できません" DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // 1秒後に表示を戻す buttonText = "5秒後にローカル通知を発行する" } } } }) { //ボタンのテキストを表示 Text(buttonText) } } // 通知を作成 func makeNotification() { // 通知コンテンツを指定 let content = UNMutableNotificationContent() content.title = "ローカル通知" content.body = "これはローカル通知のテストです。" content.sound = UNNotificationSound.default // 通知タイミングを指定 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) // 通知リクエストを作成 let request = UNNotificationRequest(identifier: "notification001", content: content, trigger: trigger) UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
フォアグラウンドでも通知を受け取れるように
import SwiftUI import UserNotifications struct ContentView: View { var notificationDelegate = ForegroundNotificationDelegate() @State var buttonText = "5秒後にローカル通知を発行する" var body: some View { // ローカル通知発行ボタン Button(action: { UNUserNotificationCenter.current().requestAuthorization(options: [.alert,.sound,.badge]) { (granted, error) in if granted { // 通知が許可されている場合の処理 // 通知を作成 makeNotification() } else { // 通知が拒否されている場合の処理 // ボタンの表示を変える buttonText = "通知が拒否されているので発動できません" DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // 1秒後に表示を戻す buttonText = "5秒後にローカル通知を発行する" } } } }) { //ボタンのテキストを表示 Text(buttonText) } } // 通知を作成 func makeNotification() { // 通知コンテンツを指定 let content = UNMutableNotificationContent() content.title = "ローカル通知" content.body = "これはローカル通知のテストです。" content.sound = UNNotificationSound.default // 通知タイミングを指定 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) // 通知リクエストを作成 let request = UNNotificationRequest(identifier: "notification001", content: content, trigger: trigger) UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) // フォアグラウンドでの通知に対応 UNUserNotificationCenter.current().delegate = notificationDelegate } } class ForegroundNotificationDelegate:NSObject, UNUserNotificationCenterDelegate{ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { //completionHandler([.alert, .list, .badge, .sound]) // iOS13まで completionHandler([.banner, .list, .badge, .sound]) // iOS14から } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let queryString = response.notification.request.identifier let url = URL(string:"deeplinktest://deeplink?\(queryString)") if let openUrl = url { UIApplication.shared.open(openUrl) } completionHandler() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
通知のタイミングを指定
// 5秒後に通知 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) // 5秒後に通知(別の指定方法) let notificationDate = Date().addingTimeInterval(5) let dateComp = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: notificationDate) let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: false) // 日時を指定して通知 let calendar = Calendar(identifier: .gregorian) let notificationDate = calendar.date(from: DateComponents(year: 2021, month: 12, day: 5, hour: 17, minute: 51, second: 45)) let dateComp = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: notificationDate!) let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: false)
■SwiftUI+アナリティクス
【Swift UI/Firebase】iOSアプリにGoogle Analyticsを導入する方法! https://tech.amefure.com/swift-firebase-analytics
■SwiftUI+Apple Watch
※未検証 そろそろSwiftUIで「手軽に」Apple Watch単体のアプリを作ろうじゃないか - ギャップロ https://gaprot.jp/2020/07/06/swiftui-apple-watch/ 簡単な Apple Watch アプリを初めて作ってみる https://ez-net.jp/article/8C/ylktGR5J/5ZZKjSNrNQac/ 【初心者向け】はじめてのApple Watchアプリ | HAFILOG https://hafilog.com/introduction-watch-app はじめての Swift UI × watchOS 〜タイマーアプリを作る〜 https://zenn.dev/ryo_kawamata/articles/timer-app-with-swift-ui
■SwiftUIの作例
■お絵かきツール SwiftUICatalog/DrawingApp at master - SatoTakeshiX/SwiftUICatalog - GitHub https://github.com/SatoTakeshiX/SwiftUICatalog/tree/master/DrawingApp ContentView.swift
import SwiftUI struct ContentView: View { @State private var endedDrawPoints: [DrawPoints] = [] @State private var selectedColor: DrawColor = .black var body: some View { VStack { // キャンバス Canvas( endedDrawPoints: $endedDrawPoints, selectedColor: $selectedColor ) // 操作用UI HStack(spacing: 10) { Spacer() Button(action: { selectedColor = .black }) { Text("描く") } Button(action: { selectedColor = .clear }) { Text("消す") } Spacer() } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
DrawPoints.swift
import SwiftUI struct DrawPoints: Identifiable { var id = UUID() var points: [CGPoint] var color: Color } enum DrawColor { case black case clear var color: Color { switch self { case .black: return Color.black case .clear: return Color.white } } }
Canvas.swift
import SwiftUI struct Canvas: View { @State private var tmpDrawPoints: DrawPoints = DrawPoints(points: [], color: .black) @Binding var endedDrawPoints: [DrawPoints] @Binding var selectedColor: DrawColor var body: some View { ZStack { // 外枠を描画 Rectangle() .foregroundColor(Color.white) .border(Color.black, width: 2) // endedDrawPointsに保存された線を描画 ForEach(endedDrawPoints) { data in Path { path in path.addLines(data.points) } .stroke(data.color, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round)) } // 最後にtmpDrawPointsに保存された線を描画 Path { path in path.addLines(tmpDrawPoints.points) } .stroke(tmpDrawPoints.color, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round)) } .gesture( // ドラッグで線を描画 DragGesture(minimumDistance: 0) .onChanged({ (value) in tmpDrawPoints.color = selectedColor.color tmpDrawPoints.points.append(value.location) }) .onEnded({ (value) in endedDrawPoints.append(tmpDrawPoints) tmpDrawPoints = DrawPoints(points: [], color: selectedColor.color) }) ) } }
■お絵かきツール(画像を保存する機能を追加) Info.plist にKey「Privacy - Photo Library Additions Usage Description」を追加し、Valueに「画像を保存します。」と記載しておく ファイルの内容を直接確認すると、dictタグ内に以下が追加されている Info.plist
<key>NSPhotoLibraryAddUsageDescription</key> <string>画像を保存します。</string>
UIView+Extension.swift
import UIKit extension UIView { var renderedImage: UIImage { // 自身の矩形情報を取得する let rect = self.bounds // ビットマップの内容を作成する UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0) let context: CGContext = UIGraphicsGetCurrentContext()! self.layer.render(in: context) // ビットマップから画像を取得する let capturedImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() return capturedImage } }
ContentView に capture メソッドを追加する ContentView.swift
func capture(rect: CGRect) -> UIImage { // 矩形情報からUIWindowを作成 let window = UIWindow(frame: CGRect(origin: rect.origin, size: rect.size)) // 自身をUIViewControllerに変換 let hosting = UIHostingController(rootView: self.body) // windowのview階層にUIViewControllerのViewを組み込んで表示させる hosting.view.frame = window.frame window.addSubview(hosting.view) window.makeKeyAndVisible() // viewをキャプチャ return hosting.view.renderedImage }
ContentView に saved を追加し、 さらに「var body: some View」の直下に GeometryReader を追加し、 さらに保存ボタンとその処理も追加する SwiftUIの肝となるGeometryReaderについて理解を深める - Qiita https://qiita.com/masa7351/items/0567969f93cc88d714ac ContentView.swift
@State var saved: Bool = false var body: some View { GeometryReader { geometry in VStack { // キャンバス Canvas( endedDrawPoints: $endedDrawPoints, selectedColor: $selectedColor ) // 操作用UI HStack(spacing: 10) { Spacer() 〜略〜 Button(action: { // 画像を取得 let captureImage = capture(rect: geometry.frame(in: .global)) // 画像を保存 UIImageWriteToSavedPhotosAlbum(captureImage, nil, nil, nil) // 保存完了アラートを表示 saved = true }) { Text("保存") }.alert(isPresented: $saved, content: { Alert( title: Text("保存"), message: Text("画像が保存されました。") ) }) Spacer() } } } }
これで「写真」アプリに画像を保存できる ただし現状では、お絵かきのためのUIも含めて保存されているので要改良 ■お絵かきツール(UIを除外して画像を保存する機能を追加) ContentView に canvasRect を追加し、引数として Canvas に渡す ContentView.swift
@State private var canvasRect: CGRect = .zero @State var saved: Bool = false var body: some View { GeometryReader { geometry in VStack { // キャンバス Canvas( endedDrawPoints: $endedDrawPoints, selectedColor: $selectedColor, canvasRect: $canvasRect )
Canvas 側で値を受け取り、GeometryReader を使って、表示されたタイミングで値を更新する (ContentView にも更新が検知される) Canvas.swift
@Binding var canvasRect: CGRect var body: some View { GeometryReader { geometry in ZStack { Rectangle() .foregroundColor(Color.white) .border(Color.black, width: 2) .onAppear { canvasRect = geometry.frame(in: .local) }
ContentView.swift
private func cropImage(with image: UIImage, rect: CGRect) -> UIImage? { // 意図した箇所を切り抜きたいので、画像のスケール情報に合わせて切り取りたい矩形情報を拡大 let ajustRect = CGRect(x: rect.origin.x * image.scale, y: rect.origin.y * image.scale, width: rect.width * image.scale, height: rect.height * image.scale) // 画像を切り抜く guard let img = image.cgImage?.cropping(to: ajustRect) else { return nil } //UIImageに変換 return UIImage(cgImage: img, scale: image.scale, orientation: image.imageOrientation) }
ContentView.swift
Button(action: { // 画像を取得 let captureImage = capture(rect: geometry.frame(in: .global)) // 画像を切り抜き let croppedImage = cropImage(with: captureImage, rect: canvasRect) // 画像を保存 UIImageWriteToSavedPhotosAlbum(croppedImage!, nil, nil, nil) // 保存完了アラートを表示 saved = true }) {
■SwiftUIその他
■引っ張って更新 ContentView.swift
import SwiftUI struct ContentView: View { @State var articles: [String] = [ "Article 1", "Article 2", "Article 3", "Article 4", "Article 5", ] var body: some View { NavigationView { List($articles, id: \.self) { $article in Text(article) } .refreshable { articles.append("New Article") } .navigationTitle("Article List") } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
【SwiftUI 3.0】refreshable で Pull To Refresh を実装 - .NET ゆる〜りワーク https://www.yururiwork.net/archives/1864 SwiftUI3.0より前は、「引っ張って更新」には対応していなかった 【SwiftUI】Pull to refresh(UIRefreshControl)を実装する - .NET ゆる〜りワーク https://www.yururiwork.net/archives/1534 ■リストを検索 ContentView.swift
import SwiftUI struct ContentView: View { @State private var query = "" private let characters = [ "Alice", "The White Rabbit", "The Queen of Heart", "The King of Heart", "The Cheshire Cat", "アリス", "ホワイトラビット", "ハートのクイーン", "ハートのキング", "チェシャー猫", ] var body: some View { NavigationView { VStack { List(filtered, id: \.self) { Text($0) } .listStyle(.insetGrouped) } .searchable(text: $query) .navigationTitle("Characters") } } private var filtered: [String] { guard !query.isEmpty else { return characters } return characters.filter { $0.localizedCaseInsensitiveContains(query) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
How to add a search bar to filter your data - a free SwiftUI by Example tutorial https://www.hackingwithswift.com/quick-start/swiftui/how-to-add-a-search-bar-to-filter-your-data ■複数行入力欄にプレースホルダーを設定 ZStackを使って「入力が無ければ代わりのテキストを表示する」のように調整する コードが長くなるので、使いまわせる部品にするといいかもしれない
Form { TextField("Name", text: $name) ZStack { if memo.isEmpty { VStack { HStack { Text("Memo") .padding(EdgeInsets( top: 8, leading: 0, bottom: 0, trailing: 0 )) .opacity(0.25) Spacer() } Spacer() } } TextEditor(text: $memo) .padding(EdgeInsets( top: 0, leading: -4, bottom: 0, trailing: 0 )) .frame(height: 100) } }
【SwiftUI】TextEditorにプレースホルダーを表示する - Qiita https://qiita.com/cappie5551/items/dfbfcb69b99bb3400b89 ■NavigationView NavigationViewを使用した際、iPadではデフォルトで左側に一覧が表示されるようになる 以下のように「.navigationViewStyle(StackNavigationViewStyle())」を指定することで、iPadでも全面表示にできる
struct ContentView: View { var body: some View { NavigationView { } .navigationViewStyle(StackNavigationViewStyle()) } }
SwiftUIのNavigationViewについて - Qiita https://qiita.com/k-motoyan/items/6b5077d06efccb426344 SwiftUIのNavigationViewでiPhoneとiPadの動作を合わせる https://zenn.dev/yorifuji/articles/swiftui-navigationviewstyle-stack SwiftUI iPadタブレットで全面表示にする。左側に小さくならないようにする https://ao-system.net/note/137 ■NavigationLinkのページからNavigationLinkのページへ遷移した際の余計な空白対策 ページ遷移先ではNavigationViewを削除すると解消される ただし単にその対応だと、Form -> TextField に反映されるはずの入力初期値が反映されない現象が発生した (タップすると、そのタイミングで何故か反映される) 正しい対応かどうかは不明だが、Form -> HStack -> TextField とHStackを挟むと正常に表示された 【SwiftUI】NavigationLinkの画面遷移時に発生する謎の空白問題について | プログラマーになった 「中卒」 男のブログ https://chusotsu-program.com/swiftui-navigationlink-blank/ ■プロパティの変更を検知 onChangeメソッドを使うと、@State や @Published の変化を検知して処理を行うことができる 【SwiftUI】プロパティの変更を検知する方法【onChange】 - .NET ゆる〜りワーク https://www.yururiwork.net/archives/1481 ■画面の向きを固定 Infoの画面で設定できる Supported interface orientations(iPhone) 内の項目を Portrait(bottom home button) だけにすると、画面が縦向きだけで固定される 【SwiftUI】 アプリの画面を縦向きまたは横向きに固定して、画面の回転を防ぐ方法 https://www.motokis-brain.com/article/65 ■設定画面 【SwiftUI】入力フォームを簡単に作れるFormビュー | プログラマーになった 「中卒」 男のブログ https://chusotsu-program.com/swiftui-form/ 【SwiftUI】Form を使って設定アプリもどきの画面を作成する - .NET ゆる〜りワーク https://www.yururiwork.net/archives/511 ■ハンバーガーメニューを作る SwiftUIでサイドメニューを実装してみた | DevelopersIO https://dev.classmethod.jp/articles/swiftui_overlay_sidemenu/ ■参考メモ [Swift] SwiftUIのチートシート - Qiita https://qiita.com/hcrane/items/eb847ca7fb7a0b9e8073 【SwiftUI】List の使い方【総まとめ】 - .NET ゆる〜りワーク https://www.yururiwork.net/%E3%80%90swiftui%E3%80%91list-%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9%E3%80%... 【SwiftUI】Form を使って設定アプリもどきの画面を作成する - .NET ゆる〜りワーク https://www.yururiwork.net/%e3%80%90swiftui%e3%80%91form-%e3%82%92%e4%bd%bf%e3%81%a3%e3%81%a6%e8%a8%... 普通にURLSessionとCombineでURLSession - Qiita https://qiita.com/Sho-heikun/items/4da148b4e1618c3eec82 APIのデータを利用する - SwiftUIへの道 https://d1v1b.com/swiftui/use_data_from_api API - SwiftUIでWebAPIから結果を表示したい|teratail https://teratail.com/questions/222072 【SwiftUI】外部APIを叩いて取得した結果をListに表示する - Qiita https://qiita.com/MilanistaDev/items/64dca8c9d5099a19529e 【Swift】SwiftUIのListでスクロール末尾で次のデータを読み込み表示する方法 - Qiita https://qiita.com/shiz/items/f0f663f8fb926d914990 Swift 4.0 エラー処理入門 - Qiita https://qiita.com/koishi/items/67cf4d0f51c4d79f1d22 [Swift] Swiftのエラー処理についてざっくりとまとめてみた | DevelopersIO https://dev.classmethod.jp/articles/about-error-handling/
■Objective-C
Xcode | くずのは探偵事務所 http://www.kyoji-kuzunoha.com/category/xcode 2018/02/18、Xcode9.2で1〜6まで試してみたが、若干表記が違う程度ですんなり動いた
■Firebase Analytics
※未検証 【Swift】iOSアプリにGoogleアナリティクスを導入する(Firebase SDK)| blog(スワブロ) | スワローインキュベート https://swallow-incubate.com/archives/blog/20200916 【Swift】FirebaseAnalyticsでイベントを測定する方法 #Swift - Qiita https://qiita.com/shi_ei/items/9289b57407016e772cfa 【GA4】XcodeでiOSアプリにFirebase Analyticsを入れる - Yosshi Labo. https://yosshiblog.jp/%E3%80%90ga4%E3%80%91xcode%E3%81%A7ios%E3%82%A2%E3%83%97%E3%83%AA%E3%81%ABfire... 【GA4】SwiftUIで作ったiOSアプリでFirebaseとGA4プロパティ連携検証 - Yosshi Labo. https://yosshiblog.jp/swiftui%E3%81%A7%E4%BD%9C%E3%81%A3%E3%81%9Fios%E3%82%A2%E3%83%97%E3%83%AA%E3%8... [iOS] Firebase Analyticsを追加する方法 #iOS - Qiita https://qiita.com/yoshitaka/items/eda5846581b9ffe187fc SwiftUIではFirebaseのスクリーンイベントが自動収集されない話 | DevelopersIO https://dev.classmethod.jp/articles/swiftui-firebase-screen-event-not-auto-log/
■Xcodeが挿入するデフォルトヘッダーコメントの変更
Xcodeでファイルを新規作成したとき、「Created by 名前 on 2021/12/10」のように名前が表示される ソースコードを編集すれば変更できるが、Gitに初期コミット済みになっているので対応したい 「~/Library/Developer/Xcode/UserData/」にIDETemplateMacros.plist というファイルを作成し、以下の内容を記述する 「Created by refirio.」の部分を任意の名前に変更する(その他、内容は一例なので適当に変更する)
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>FILEHEADER</key> <string> // ___FILENAME___ // ___TARGETNAME___ // // Created by refirio. //</string> </dict> </plist>
XcodeでCreated byの名前を変えるためにやったこと - mstのらぼ https://mst335.hatenablog.com/entry/2020/08/15/134220 [iOS] Xcodeのデフォルトのヘッダーコメントのテンプレートを作成したい | DevelopersIO https://dev.classmethod.jp/articles/ios-xcode%E3%81%AE%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%...
■XcodeとGitHubの連携
■GitHubへの接続 「Preference → Accounts → +」 「GitHub」を選択し、対象アカウントのパスワードで認証してGitHubと接続する Cloneの設定画面が表示されるので、以下のように設定する Clone Using: SSH SSH Key: Createをクリックし、パスワードを指定して鍵を作成し、id_rsa として保存 …としたが、何故か「Resource not found.」と表示される 改めてid_rsaを選択すると「SSH key does not exists on "GitHub"」というメッセージに変わった 隣にある「Upload」をクリックしてアップロードしようとしても変化なし ブラウザでGitHubにアクセスし、鍵を登録する 「Settings → SSH and GPG keys → SSH keys → New SSH keys」 作成した公開鍵の内容を登録する Xcodeで再度「SSH Key」で「id_rsa」を選択するとエラーが消えた ■リポジトリの作成 GitHubにリポジトリを作成する Xcodeの左パネルで「Source Control navigator」をクリック(左から2番目) 「Repositories」内にある「xxx main → Remotes」を右クリックし「New xxx remote…」を選択 「Repository Name」に任意のリポジトリ名を入力する(今回は「ios-shoppinglist」とした) 「Create」ボタンを押すとGitHub上にリポジトリが作成され、「Initial Commit」というコミットが存在する状態になった 【XcodeでGithub】XcodeでGithubを使用する方法 - Qiita https://qiita.com/y-aimi/items/9a4f55d00fc6b59fc374 XcodeとGithubの連携をしたのでまとめる。 https://zenn.dev/kueharx/articles/ebd14c46f02211 ■.gitignore 無くても問題ないようだが、以下のように設定されているプロジェクトがあった。要確認
UserInterfaceState.xcuserstate Breakpoints_v2.xcbkptlist
以下を参考に作成すると良さそう XcodeでiOSアプリ開発をする時の.gitignore - Qiita https://qiita.com/ikuwow/items/4fae81a099bf82f44749 ■XcodeでGitを操作する 必要に応じて確認する 別途Sourcetreeをインストールして操作するのも有効そう Xcodeでgit操作(ブランチを作ってみる) - Qiita https://qiita.com/sakamotoyuya/items/ffbd229010eec67e49ea ■XcodeのGitから確認すると、編集していないファイルがコミット対象になる 過去使っていた場所と同じ場所にプロジェクトを作成した場合、すでに無いファイルがリストに上がることがある プロジェクトの場所が例えば Prj1 の場合、以下のようにするとリセットできる
$ cd Prj1 $ /Applications/Xcode.app/Contents/Developer/usr/bin/git reset
iOSアプリ開発:リポジトリにコミット出来ない - Qiita https://qiita.com/pgcmg00/items/0b94986290e8ae3a3b7e
■プライバシーの設定
※2024年4月時点で調査中の内容 ■概要 以下に概要が記載されている 【Xcode/iOS】Privacy Manifestsに対応する方法!PrivacyInfo.xcprivacyとは https://appdev-room.com/swift-manifest 3月13日から始まったプライバシーマニフェスト未対応の警告メールを受け取った | DevelopersIO https://dev.classmethod.jp/articles/received-email-warning-privacy-manifest/ 2024年春以降、Privacy Manifests未対応のiOSアプリはリジェクトされてしまう | DevelopersIO https://dev.classmethod.jp/articles/support-privacy-manifests-for-ios-app/ 2024年5月1日から、Privacy Manifests未対応のiOSアプリはリジェクトされるとのこと 例えば「UserDefaultsを使った設定の永続化」や「Firebase Analyticsなどによるアプリ統計情報の取得」を行なっているアプリが対象となる 恐らく、ゆくゆくはこの宣言を使用して「アプリのプライバシーポリシーを自動で表示する」などが行なわれるのだと思われる アップロード時の審査なので、個人アプリなどリリース時期の調整がきくものなら、5月1日以降に情報が出てきてから対応するのが良さそう 以下なども参考になりそう 「ITMS-91053: Missing API declaration」アプリのプライバシーレポートでのAPI使用宣言(iOSアプリを審査に提出したら) https://mszpro.com/blog/itms-91053-ja/ 2024年5月以降、iOSアプリで利用するプライバシー情報の定義が必須に https://zenn.dev/omatsu/articles/6f2e63976141987108d6 iOSアプリを審査に提出したら「ITMS-91053: Missing API declaration」というメールが来た。Privacy Manifest対応についてのメモ。 https://zenn.dev/matsuchiyo/scraps/8c742b06f626bb ■想定手順 ※手順中「Privacy Accessed API Reasons」の選択肢に「User Defaults - CA92.1」などが表示されない場合、Xcodeを最新版にアップデートしてから試す アップデート前でも、「35F9.1」など他の選択肢は表示されていた 具体的には、以下のような対応を行う 1. Xcodeで PrivacyInfo.xcprivacy ファイルを作成する 「File → New → File...」でファイル作成ダイアログを開き、「iOS → Resource → App Provacy」を選択して「Next」をクリック ファイル名はそのままでいい。「Targets」はチェックが外れているのでチェックして「Create」をクリック プロジェクト直下に「PrivacyInfo.xcprivacy」が作成される 2. Xcodeで PrivacyInfo.xcprivacy ファイルを編集する 「PrivacyInfo.xcprivacy」を開く。ルートに「App Privacy Configuration」のみが作られていることを確認できる 「+」をクリックして「Privacy Accessed API Types」を追加する またその中で、「Privacy Accessed API Type」に「User Defaults」を、「Privacy Accessed API Reasons」に「User Defaults - CA92.1」を、それぞれ選択する ただし実際には、「Type」が「Array」の配下には「Item 0」を配置するなどの操作があるので注意。以下ページの「対応方法」のスクリーンショットを参考に設定するのが解りやすいか https://zenn.dev/omatsu/articles/6f2e63976141987108d6 3. Xcodeでアーカイブを行う アーカイブの際、以下ページの「確認方法」を参考に、プライバシー設定を確認できる 【Xcode/iOS】Privacy Manifestsに対応する方法!PrivacyInfo.xcprivacyとは https://appdev-room.com/swift-manifest 以降は通常どおり、TestFlightやAppStoreへの掲載を進める 設定内容に問題が無ければ、警告メールが送られてこなくなる ■メモ ・「Privacy Accessed API Types」だけでなく「Privacy Nutrition Label Types」も追加が必要か ・サードパーティ製ライブラリを使用している場合、ライブラリ自体のアップデートも必要らしい ・本当に対応できているかどうかは、App Store Connectにアップロードしてみないと判らないらしい
■製品用、開発用などの切り分け
BundleIDが同じアプリは上書きインストールされる つまり、AppStoreからインストールした本番アプリがあると、その端末には開発版をインストールできない が、スキーマを使用することによりこの問題を解消できる iOS開発で環境ごとにアイコンやアプリ名、コード等を切り分けるオレオレプラクティス - Qiita https://qiita.com/KazaKago/items/2835d76ced43f913c31d ■前提 Scheme buildtest1 ... 製品用 buildtest1_develop ... 開発用 Build Release ... 製品用 Debug ... 開発用 と設定するものとする なお、Buildを一つにしてスキーマで以下のように3段階で分ける手も考えられる (スキーマの切り替えは手軽だが、ビルドの切り替えはそれよりは少し手間) buildtest1 ... 製品用 buildtest1_staging ... 検収用 buildtest1_develop ... 開発用 が、 ・Xcodeがはじめから「Release」と「Debug」を用意している ・「製品版だがデバッグ情報は表示したい」にも対応できる という理由から、 「リリース時は buildtest1+Release、開発時は buildtest1_develop+Debug だが、必要なら buildtest1+Debug などに切り替えることもできる」 で良さそうなので、最初に上げた切り分けで良さそう ■iOSアプリの作成 Xcodeでプロジェクトを作成する Create a new Xcode project ↓ iOS Application Single View App 「Next」 ↓ Product Name: buildtest1 Team: REFIRIO CO.,LTD. (Enterprise) Organization Name: Refirio Organization Identifier: net.refirio Language: Swift 「Next」 ↓ プロジェクトの作成場所を選択 「Create」 Bundle Identifier はプロジェクトのGeneralを確認すると「net.refirio.buildtest1」になっていた エミュレータと実機で、アプリを起動できるかテストする ■ビルドの設定 画面左のツリーでプロジェクトをクリック 「General」「Capabilities」などが並んだ画面になる その画面内の左側から PROJECT → buildtest1 → info と選択(「info」は画面の上部にある) Configurationsの項目があり、初期状態では以下のようになっている Name | Based on Configuration File Debug | No Configurations Set Release | No Configurations Set 今回は以下のように設定する Name | Based on Configuration File Develop_Debug | No Configurations Set ... 既存のDebugの名前を変更 Develop_Release | No Configurations Set ... 既存のReleaseから複製(下にある「+」をクリックして「Duplicate "Release" Configuration」を選択)して名前を変更 Production_Debug | No Configurations Set ... 既存のReleaseから複製(下にある「+」をクリックして「Duplicate "Release" Configuration」を選択)して名前を変更 Production_Release | No Configurations Set ... 既存のReleaseの名前を変更 ■スキーマの設定 メニューから Product → Scheme → Manage Schemes... を選択 スキーマが一覧表示され、初期状態では以下のようになっている Scheme | Container buildtest1 | buildtest1 project 今回は以下のように設定する Scheme | Container buildtest1 | buildtest1 project buildtest1_develop | buildtest1 project ... 下にある歯車をクリックしてDuplicateで複製。ダイアログ左上で名前だけ変更 それぞれのスキームに対して、ビルドの設定を行う (スキームを選択して「Edit...」ボタンを押す。設定が完了したら「Manage Schemes...」で前の画面に戻ることができる。「Close」で完了する) なお、「Shared」にチェックを入れておくと、この設定をリポジトリに含めることができるらしいので、付けておくと良さそう(未検証) buildtest1 「Run」の「Build Configration」を「Production_Debug」に設定する 「Profile」と「Archive」の「Build Configration」を「Production_Release」に設定する buildtest1_develop 「Run」の「Build Configration」を「Develop_Debug」に設定する 「Profile」と「Archive」の「Build Configration」を「Develop_Release」に設定する ※「Run」の「Debug executable」にあるチェックを外すと、Distribution証明書でも直接実機にインストールできるみたい? でもチェックを外すことにより、デバッグ情報が表示されなくなるみたい? 他の設定も関連するかもしれないので要調査 ■BundleIDの設定(1つの端末に、本番アプリや開発アプリを同時にインストールできるようにする) 「PROJECT」の下にある「TARGETS」からアプリを選択 「Build Settings」内のページ中程「Packaging」内にある「Product Bundle Identifier」にカーソルを合わせると表示される、三角をクリックする つまり TARGETS → buildtest1 → Build Settings → Product Bundle Identifier を選択 BundleIDが一覧表示され、初期状態では以下のようになっている Develop_Debug | net.refirio.buildtest1 Develop_Release | net.refirio.buildtest1 Production_Debug | net.refirio.buildtest1 Production_Release | net.refirio.buildtest1 今回は以下のように設定する(右側の値をクリックすると、編集状態になる) Develop_Debug と Production_Debug は「net.refirio.buildtest1.dev.debug」「net.refirio.buildtest1.debug」にするのもアリか。検証したい Develop_Debug | net.refirio.buildtest1.dev Develop_Release | net.refirio.buildtest1.dev Production_Debug | net.refirio.buildtest1 Production_Release | net.refirio.buildtest1 ※「General → Identity → Bundle Identifier」でも設定できる。ただしその場所で設定すると、 上で設定した Bundle Identifier がすべて上書きされてしまうので注意(上書きされたものを戻したければ、再度手動で設定が必要) ■アプリケーション名の設定(インストール後に本番アプリか開発アプリかを判断できるようにする) TARGETS → buildtest1 → info → Custom iOS Target Properties Bundle name が「$(PRODUCT_NAME)」になっていることを確認する TARGETS → buildtest1 → Build Settings → Product Name Develop_Debug | buildtest1 Develop_Release | buildtest1 Production_Debug | buildtest1 Production_Release | buildtest1 今回は以下のように設定する(編集時、「buildtest1」は「$(TARGET_NAME)」と表示されるが、気にせず後ろに文字列を追加する) デバッグ版は後ろに「Debug」を付けるのもアリか。検証したい Develop_Debug | buildtest1 Dev Develop_Release | buildtest1 Dev Production_Debug | buildtest1 Production_Release | buildtest1 ■署名/証明書の設定 必要に応じて、それぞれのBuild設定ごとに設定する ■スキーマの切り替え Xcode左上の「実行」「停止」ボタンの右にある「buildtest1」部分で切り替えられる ■ビルドの切り替え Xcode左上の「実行」「停止」ボタンの右にある「buildtest1」をクリックして表示される「Edit Scheme...」をクリックし、 「Build Configration」で切り替えることができる ■ビルド設定によるプログラムの分岐 TARGETS → buildtest1 → Build Settings → Swift Compiler - Custom Flags → Other Swift Flags ※フィルタで「Basic」になっていると、「Swift Compiler - Custom Flags」が表示されないので注意 「All」にすると、ページの下の方に表示される。見つからなければ、ページ上部の検索ボックスで「Swift」を検索してみる Develop_Debug | (空欄) Develop_Release | (空欄) Production_Debug | (空欄) Production_Release | (空欄) 今回は以下のように設定する Develop_Debug | -D DEVELOP_DEBUG Develop_Release | -D DEVELOP_RELEASE Production_Debug | -D PRODUCTION_DEBUG Production_Release | (空欄) Swiftプログラム内では、以下のようにすると処理の分岐ができる
print("TEST START") #if DEVELOP_DEBUG print("DEVELOP_DEBUG") #elseif DEVELOP_RELEASE print("DEVELOP_RELEASE") #elseif PRODUCTION_DEBUG print("PRODUCTION_DEBUG") #else print("PRODUCTION_RELEASE") #endif print("TEST END")
ただし環境が増えると分岐の対象が多くなるので、以下のように分けて設定するのも有効かもしれない(要検証) Develop_Debug | -D DEVELOP -D DEBUG Develop_Release | -D DEVELOP -D RELEASE Production_Debug | -D PRODUCTION -D DEBUG Production_Release | -D PRODUCTION -D RELEASE
print("TEST START") #if DEVELOP && DEBUG print("DEVELOP && DEBUG") #elseif PRODUCTION && DEBUG print("PRODUCTION && DEBUG") #endif print("TEST END")
■その他参考になりそうなページ iPhoneアプリ開発でデバッグ版とリリース版をきれいに同居させる - しめ鯖日記 http://www.cl9.info/entry/2015/07/29/010020 XcodeでDevelop/Staging/Release環境を上手に切り分ける方法 - Qiita https://qiita.com/Todate/items/a2e6a26731c79bd23e02 [Xcode] ビルド環境を切り替えるためにSchemeを追加する | DevelopersIO https://dev.classmethod.jp/smartphone/iphone/xcode-build-environment-adding-scheme/ テスト用iOSアプリの配布方法 - Qiita https://qiita.com/mishimay/items/47f7680014fc6141f5c4
■リリース
iOSアプリリリース手順1 - Certificate、App IDの準備 http://www.swift-study.com/ios-app-release-1-certificate-and-app-id/
■リジェクト
※リジェクト対応の一例として記載 申請に提出後、Appleから以下の返信が来た 指摘としては「サインインやアカウント登録の際、デフォルトのウェブブラウザに移動するため、ユーザーエクスペリエンスが低下している」とのことで、 対応としては「アプリ内でユーザーがサインインまたはアカウント登録できるようにアプリを修正」する必要があるらしい ↓
Thank you for submitting your items for review. We noticed an issue with your submission that requires your attention. Submission ID: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX App Name: 〇〇〇〇〇〇〇〇〇〇 We look forward to working with you to resolve the issues with the following items: App Version 1.3.2 for iOS - - - - - - - - - - Bug Fix Submissions The issues we've identified below are eligible to be resolved on your next update. If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed. Alternatively, if you'd like to resolve these issues now, please review the details, make the appropriate changes, and resubmit. Guideline 4.0 - Design We noticed that the user is taken to the default web browser to sign in or register for an account, which provides a poor user experience. Next Steps To resolve this issue, please revise your app to enable users to sign in or register for an account in the app. You may also choose to implement the Safari View Controller API to display web content within your app. The Safari View Controller allows the display of a URL and inspection of the certificate from an embedded browser in an app so that customers can verify the webpage URL and SSL certificate to confirm they are entering their sign in credentials into a legitimate page. Resources For additional information on the Safari View Controller API, please review the What's New in Safari webpage. Note that apps that support account creation must also offer account deletion, per App Review Guideline 5.1.1(v). Learn more about offering account deletion in your app. Please see attached screenshot for details. - - - - - - - - - - For details, next steps, and to ask questions about these issues, please visit the App Review page in App Store Connect. Best regards, App Review
App Store Connect でアプリ一覧を確認すると、対象のアプリアイコンに「!」が付いていた。また「iOS 1.3.2 却下済み」と記載されていた アプリの詳細画面に遷移すると、画面上部に以下が表示されている
この項目は、次の理由により却下されました。 ・4.0.0 Design: Preamble [提出物を表示]
「提出物を表示」をクリックすると、レビュー内容の詳細が表示される(左メニューの「App Review」にも「!」が表示されており、ここからもレビューの一覧に遷移できる) 審査ステータスが「却下済み」となっており、スクリーンショットがダウンロードできるようになっていた 画面下にレビュー内容とともに、「App Reviewに返信」リンクが表示されている クリックするとレビューへの返信を入力するためのダイアログが表示される。ファイルも添付できるようになっている 入力欄の上には、以下の内容が記載されている
以下のフィールドを次の目的のために使用してください。 ・アプリが却下された理由の説明を求めます。 ・App Reviewが求める情報を提供します。 ・App Reviewがアプリやビジネスモデルをより理解できるよう、追加情報を提供します。 アプリの再検討を希望する場合は、App Review Boardに申し立てを行うことができます。 アカウントに利用可能なTSIがある場合、コードレベル技術サポートリクエストをデベロッパテクニカルサポート(DTS)のエンジニアに送信することができます。
Appleからのメールに 「If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed.」 とあったので、今回はこのまま通してもらうように依頼する 以下の内容で返信した
The review email contained the following information. 'If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed.' As this is a high priority bug fix, please submit it as is. We will consider responding to the points you raised again. Thanks.
6時間ほど末と、「The status of your (iOS) app, 〇〇〇〇〇〇〇〇〇〇, is now "Pending Developer Release"」というメールが届いた メールの内容には、アプリの情報とともに以下が記載されている
To make this version available for distribution, go to your app's platform version page in My Apps in App Store Connect and click Release This Version. If you have any questions, contact us.
このバージョンを配布するには、App Store Connectでリリースボタンを押す必要があるらしい App Store Connectで確認すると、画面上部に「このバージョンをリリース」ボタンが表示されていた またレビューページの最後に、以下のコメントが投稿されていた
Thank you for confirming that you will address the remaining issues in your next submission. We’re happy to help you deliver bug fixes to your users and will approve this submission at this time. We look forward to reviewing your future submissions.
案内どおり「このバージョンをリリース」ボタンをクリックすると、以下のメッセージが記載されたダイアログが表示された
175個の国または地域のApp Storeで、iOSアプリをリリースしますか?
デフォルトですべての国に配信されるようなので、ダイアログ内の「このバージョンをリリース」ボタンをクリック ステータスが「1.3.2配信の準備中」となった またしばらくすると(10〜20分後くらい)、 「The status of your (iOS) app, 〇〇〇〇〇〇〇〇〇〇, is now "Processing for Distribution"」 と 「The status of your (iOS) app, 〇〇〇〇〇〇〇〇〇〇, is now "Ready for Distribution"」 というメールが届いた ストアが更新されるにはタイムラグがあるので、時間をおいて確認する
■Enterpriseで社内向けに配布
※詳細な検証内容は以下のファイルも参照。以下は以前に調べたときのメモ Dropbox\iOS\In-House で書き出すメモ.txt iOS Developer Enterpriseで社内向けiOSアプリを作って配布する方法 [完全版] | イリテク https://iritec.jp/selfhack/3355/ 2021年時点で、Enterpriseの取得は非常に難しくなっているみたい 実質利用不可と考えておく 代わりにカスタムAppで非公開アプリとして作ることになるみたい 非公開アプリについては、「アプリの限定公開」の項目を参照 ADEP(Apple Developer Enterprise Program)はもう取得することができないと諦めたほうが良い理由 | エンタープライズiOS研究所 https://www.micss.biz/2020/06/19/1774/
■アプリの限定公開
※要検証 ■前提 ADP = Apple Developer Program ... AppStoreでアプリをリリースするための機能を提供するサービス MDM = Mobile Device Management ... アプリケーションの一括配布やデバイスの機能制限など、多数のモバイル端末を集中管理する ABM = Apple Business Manager ... 会社から配布する業務用アプリ、会社から支給する業務用デバイス、会社から提供する業務用AppleID、ABMと連携するMDM、を管理する SDE, ADP, DEP, ABM, ADE…名称がコロコロ変わる端末登録の歴史 | エンタープライズiOS研究所 https://www.micss.biz/2021/08/16/4250/ MDMとは何か 〜今さら聞けないMDMの基礎〜 | エンタープライズiOS研究所 https://www.micss.biz/2020/01/27/1164/ ABM(Apple Business Manager)とは何か | エンタープライズiOS研究所 https://www.micss.biz/2020/08/14/1927/ これらを前提知識として、公開アプリ、カスタムAppで非公開アプリ、非表示Appの違いを以下に記載する なお、Enterpriseを利用することは難しくなっているので、選択肢には入れない方が無難 ■公開アプリ 必要な契約: ADP 申請・審査: 必要(公開を選択) 配布: AppStore 補足: 通常の配布方法 ■カスタムAppで非公開アプリ 必要な契約: ADPとABM+MDM 申請・審査: 必要(非公開を選択) 配布: カスタムApp 補足: 業務用アプリは原則この方法で配布する ■非表示App 必要な契約: ADP 申請・審査: 必要 配布: AppStoreのURL直指定のみ 補足: イベント参加者専用アプリや組織外人物に使わせる業務用アプリなど、カスタムAppで対応が難しい場合にのみ利用できる 非表示Appの利用にはAppleの審査があり、その際にも「非公開アプリはカスタムAppとABMの利用を検討してください」と案内される ■補足 ・非公開アプリはADPに加えてABMとMDMが必要 配布の際は引き換えコードなどを使って、その企業専用のAppStore領域からインストールしてもらう リクエスト承認後に生成されるURLから直接アクセスできるようになるが、そのURLが漏れると誰でもインストールできてしまう よってアプリ自体に認証機能が必要 引き換えコードでアプリを配布する場合はMDMは無くてもいいが、 引き換えコードはアプリをインストールした端末と紐づくので、一度インストールすると他の端末では利用できなくなる(URLを発行する場合も同様) ・非表示アプリは、AppStoreの検索に表示されなくなるだけ URLを知っていれば誰でもインストールできてしまう よってアプリ自体に認証機能が必要 ・まずは非公開アプリで運用できるかを検討し、どうしても難しければ非表示Appを検討する 非表示App(Unlisted App)とは何か | エンタープライズiOS研究所 https://www.micss.biz/2022/02/07/5041/ カスタムApp(CustomApp)とは何か(1) 〜非公開アプリをリリースする唯一の方法〜 | エンタープライズiOS研究所 https://www.micss.biz/2021/03/08/3427/
■非表示Appの配信
※要検証 非表示Appと非公開アプリは同じもの?違うもの? 非表示Appとしてリリースする場合、Apple Business Managerの登録が必要? 非表示Appの配信 - サポート - Apple Developer https://developer.apple.com/jp/support/unlisted-app-distribution/ 以下によると、非表示Appはごく最近追加された手段みたい 非公開アプリとは異なるものみたい 非表示AppはAppleに申請して認めてもらう必要があるみたい まずは非公開アプリで実現できないかを検討するといいみたい 非表示App(Unlisted App)とは何か | エンタープライズiOS研究所 https://www.micss.biz/2022/02/07/5041/ 「非表示Appの審査が適切なケース」として、以下が記載されている ・イベントやカンファレンスの参加者専用アプリ ・雇用関係にはないが一時的に組織外の人物に使わせる業務用アプリ ・あるメーカ製品を営業するための販売代理店使用を前提とするアプリ ・海外を含む全グループ子会社に提供する従業員専用アプリ
■TestFlight
TestFlightで内部テスターへiOSアプリを配信してみた | DevelopersIO https://dev.classmethod.jp/articles/testfligh/ 【iOS】 TestFlightにアプリをリリースするやり方 Flutter https://zenn.dev/lisras/articles/14b90852fe5525 ■アプリをアップロード 通常どおりアプリの改修を行う 通常どおりアプリをアップロードする 対象アプリの「TestFlight」画面に、アップロードしたビルドが表示されていることを確認する 「コンプライアンスがありません」と表示されていたら、隣にある「管理」をクリック 今回は通信を行うアプリではないので、「上記のアルゴリズムのどれでもない」を選択して「保存」をクリックした これで「コンプライアンスがありません」の表示が「提出準備完了 期限切れまで91日」に変わった ■内部テスト(ユーザの追加) App Store Connectの上部メニューから「ユーザとアクセス」を開く 「+」からユーザを追加する 姓: 山田 名: 太郎 メールアドレス: taro@example.com 役割: Developer 「その他のリソース」「App」には何も入力しないでおく 「招待」をクリック 招待したメールアドレスに「You've been invited to App Store Connect.」というメールが届く 本文の案内に従って招待を受け入れる これでユーザを追加できた。引き続き、内部テストに追加する ■内部テスト 内部テスターを追加する サイドバーの「内部テスト」の隣にある「+」をクリック 今回はグループ名を「開発チーム」としておく。「自動配信を有効にする」にはチェックを入れたままにしておく 「作成」をクリック 「内部テスト」に「開発チーム」が追加されるのでクリック 「テスター」の隣にある「+」をクリック テスターに追加したいメールアドレスにチェックを入れて「追加」をクリック 5分ほどすると、「Hirokazu Yamano has invited you to test My Shopping List.」というメールが届いた メール内の「View in TestFlight」をクリック TestFlightが立ち上がり、対象アプリに対する「同意する」ボタンが表示された 以降は、TestFlight内からインストールなどを行える (複数のアカウントから招待を受けている場合、それらが同じ一覧に表示された) ■内部テストからの削除 内部テストのテスターから削除しても、インストール済みのアプリは起動できる (必要に応じて、App Store Connectの上部メニューの「ユーザとアクセス」からも削除しておく) ただしTestFlightを確認すると、アプリは「以前にテスト済み」として表示された タップすると「デベロッパがテストプログラムからあなたを削除しました。」と表示されていて、インストールはできない ■外部テスト ※内部テストはAppleのアカウントの招待が必要だが、外部テストはAppleのメールアドレスだけで招待ができる サイドバーの「外部テスト」の隣にある「+」をクリック 今回はグループ名を「テスター」としておく 「作成」をクリック 「外部テスト」に「テスター」が追加されるのでクリック 「テスター」の隣にある「+」をクリックして「新規テスターを追加」をクリック テスターに追加したいメールアドレスと名前を入力して「追加」をクリック 「ビルド」の隣にある「+」をクリックしてテストしたいビルドを追加する フィードバックメールアドレス: (自分のメールアドレス) 姓: (自分の名前) 名: (自分の名前) 電話番号: +81 9012345678 (自分の電話番号) メールアドレス: (自分のメールアドレス) サインイン: (チェックを外す。SNSなどでログインする必要がある場合、そのログイン情報を記載する) 「次へ」をクリック テスト内容を入力して「審査へ提出」をクリック ステータスが「審査待ち 期限切れまで91日」となった ※電話番号は、「+81 9012345678」のような形式で入力しないとエラーになった ※外部テストは、そのバージョンの最初のビルドのみ審査が発生する 審査が通っても通知は無いらしい TestFlightには90日の期限があるので、例えば「検収環境の確認はTestFlightを使う」のような場合、定期的な更新が必要となる この場合、原則バージョン番号はそのままに、ビルド番号だけを上げることで審査を回避できる TestFlightのテスト情報入力で電話番号が弾かれた https://zenn.dev/kabewall/articles/b8eed0438546d9 TestFlightで外部テストするときに審査が発生する場合がある https://zenn.dev/st43/articles/df974ee0e5ffd5 以降は、TestFlight内からインストールなどを行える (複数のアカウントから招待を受けている場合、それらが同じ一覧に表示された) ■外部テストからの削除 外部テストの一覧から削除すると、対象ユーザのTestFlightでは「以前にテスト済み」の部分に表示されるようになった インストール済みのアプリは引き続き使えるが、アンインストールすると再度インストールはできなくなった ■パブリックリンク [TestFlight][新機能] リンクをシェアするだけでアプリのβ配信・テストが出来ちゃう TestFlight Public Link を試してみた | DevelopersIO https://dev.classmethod.jp/articles/how-to-distribute-app-with-testflight-public-link/ 外部テストのページ内にある「パブリックリンクを有効にする」ボタンをクリックする
このパブリックリンクを有効にしてもよろしいですか? このリンクを保持するユーザはAppをデバイスにインストールしたり、リンクを他のユーザと共有できるようになります。
の確認が表示されるので「有効にする」ボタンをクリック パブリックリンクとして https://testflight.apple.com/join/nh3GtuLW が表示された このメールで共有してみる iPhoneでURLをクリックすると、TestFlightの案内とともに「テストを開始」のボタンが表示された TestFlightがインストール済みの場合、TestFlightが起動してアプリの概要と「同意する」ボタンが表示された 同意すると、アプリのインストールができるようになった ※URLをクリックするとTestFlightが起動するが、テストは開始できなかった …という場合、対象のアプリを選択して、いったん「テストの停止」とする その後、再度URLをクリックすると、アプリの概要と「同意する」ボタンが表示される ■本番アプリと検収アプリの切り分け 【Xcode】Scheme(スキーム)とは?作成方法とビルドオプションの設定 https://tech.amefure.com/swift-xcode-basic-scheme Xcodeで本番・ステージング・開発などの環境を分ける方法 | モグモグ https://mo-gu-mo-gu.com/ios-scheme-build-settings/ iOS開発で環境ごとにアイコンやアプリ名、コード等を切り分けるオレオレプラクティス - Qiita https://qiita.com/kazakago/items/2835d76ced43f913c31d つい忘れて調べるXcodeの設定〜スキーム追加編〜 - Qiita https://qiita.com/kazy_dev/items/feb68f162ec3d91005d3 TestFlight使用時に配布トラブルを減らすテクニックAlternate Icon使用 - SwiftUI100行チャレンジ? | Irimasu Densan Planning - いります電算企画 https://irimasu.com/testflight-using-alternate-icon ExpoでstagingビルドをTestFlightする時にはアプリアイコンを変えるとわかりやすい - Qiita https://qiita.com/0ba/items/1085df5deb69cc57a8cc ■トラブル iOS: TestFlightが使えなくなる呪いとその解呪法 https://zenn.dev/kyome/articles/64386947a599cf
■In-Houseで書き出す
組織内配布(In-House)やAdHoc(評価用配布)のために書き出す方法 実際に書き出したときのメモ Dropbox\iOS\In-House用に書き出すメモ.txt
■更新
■証明書更新 以下の「リリース: 証明書の更新」を参照 Dropbox\iOS\アプリリリース.txt ■プロビジョニングプロファイル更新 以下の「リリース: 証明書の更新」を参照 Dropbox\iOS\アプリリリース.txt ■Apple Developer Program 更新 Apple Developer Programにログインすると 「Apple Developer Programのメンバーシップは、あと5日で有効期限が切れます。」 のように表示されている その下にある「メンバーシップを更新」ボタンをクリック 以降は、画面の指示に従って作業する ■「Your iOS Distribution Certificate will expire in 30 days.」メールが来た 「アプリを書き出して公開する」ときに必要な証明書の期限が切れる…というものらしい 公開中のアプリには影響しないようなので、もし「アプリを改良して更新する」ということがあれば、そのタイミングで更新することになるみたい Your iOS Distribution Certificate will expire in 30 days.が来た時の対処法 | Macfancy http://macfancy.com/2015/11/07/your-ios-distribution-certificate-will-expire-in-30-days-%E3%81%8C%E6... iOS - ios一年おきの証明書の更新について(85469)|teratail https://teratail.com/questions/85469 ■その他 Apple Developer で規約への同意が必要になっていないか確認する Xcodeのセッションが切れているときがある。ログインしなおす プロジェクトの General > Signing > Term で「none」を選択し、その後自分の名前を選択すると大丈夫のときがある
■Swift Package Manager(SPM)
当初iOSにはパッケージ管理ツールが無く、ライブラリを手動でプロジェクトに追加していた 2011年にCocoaPodsが登場して管理しやすくなった ただしRubyで作られた外部システムのため、Rubyのバージョンによっては動作しないなどの問題があった 結果として、CocoaPodsを利用しているプロジェクトは、環境構築が困難なものになった 2017年にApple公式のSwift Package Manager(SPM)がリリースされた 2019年にはXcodeにも統合され、対応するライブラリも増えた 複雑な依存関係だと配布用エクスポートに失敗することがあったが、2022年4月(Xcode 13.3.1)でその問題も解消された Swift.org - Package Manager https://www.swift.org/package-manager/ XcodeでSwift Package Manager実用段階 - クックパッド開発者ブログ https://techlife.cookpad.com/entry/2022/06/01/090000 CocoaPodsからSPMに移行した事例もある CocoaPods から Swift Package Manager に移行した話 - Cybozu Inside Out | サイボウズエンジニアのブログ https://blog.cybozu.io/entry/welcome-spm CocoaPodsからSwift Package Managerに移行するのに少しつまずいた - EY-Office https://www.ey-office.com/blog_archive/2022/02/10/stumbled-on-migrating-from-cocoapods-to-swift-pack... FirebaseもSPMで提供されている Firebase を Apple プロジェクトに追加する | Firebase for Apple platforms https://firebase.google.com/docs/ios/setup?hl=ja まずは、公式の example-package-playingcard リポジトリで導入を試すと良さそう 具体的なコードは、公式の解説やリポジトリ内のテストを参考にするくらいか 【Xcode】Swift Package Managerの使い方!パッケージ導入管理ツール https://tech.amefure.com/swift-package-manager GitHub - apple/example-package-playingcard: Example package for use with the Swift Package Manager https://github.com/apple/example-package-playingcard
■CocoaPods
ライブラリの管理ツール 今新規に作るなら、SPMを使う方がいいかもしれない SPMの詳細は、このファイル内の「Swift Package Manager(SPM)」を参照 Swiftで外部ライブラリを追加する(CocoaPods) - Qiita http://qiita.com/YuukiWatanabe/items/98e5f6cb19787b9e95ca Swift で外部ライブラリを追加する - みかづきメモ http://mikazuki.hatenablog.jp/entry/2016/02/28/030000 iOSライブラリ管理ツール「CocoaPods」の使用方法 - Qiita http://qiita.com/satoken0417/items/479bcdf91cff2634ffb1 なんとなく使ってしまっているCocoaPodsを今更ながら公式の使い方を読んで理解を改めてみた https://zenn.dev/toaster/articles/1cf4c22b9da33f ■CocoaPodsを導入済みの既存プロジェクトをビルドしたときのメモ SourceTreeでPULL エミュレータで実行しようとすると「No such module'SwiftGitOrigin'」と言われる Swiftでgifアニメを再生できるアプリを作る(SwiftGifOriginの使い方) - JoyPlotドキュメント https://joyplot.com/documents/2016/09/15/swift-gif-image/ CocoaPods.org https://cocoapods.org/ ターミナルでプロジェクトの場所へ移動 $ cd /path/to/project $ sudo gem install cocoapods $ pod --version $ pod init Xcodeで「Product → Clean Build Folder」としてから「Product → Build」としてみる …が、それでも実行すると 「No such module'SwiftGitOrigin'」 と言われる Xcodeで開くプロジェクトを「xxx.xcodeproj」ではなく「xxx.xcworkspace」にするとビルドできた 開くべきプロジェクトが変わるようなので注意 ■SwiftUIのプロジェクトに導入 ※未検証 【Xcode/Swift】CocoaPodsの使い方を徹底解説 | iOS-Docs https://ios-docs.dev/cocoapods/ SwiftUI開発メモ https://zenn.dev/taquu/scraps/ba151e0bc7c717 【SwiftUI】CocoaPods導入手順とFirebaseの設定 - Qiita https://qiita.com/m37335/items/6c507495f840d4bdaa43 【Swift UI】CocoaPodsのインストール方法と使い方! https://tech.amefure.com/swift-cocoapods
■Push通知
※詳細な検証内容は AmazonSNS.txt を参照 以下は以前に調べたときのメモ ■参考ページ amazon SNSでiOSアプリにプッシュ通知を送る!画像つきで詳しく使い方もまとめました! | イリテク https://iritec.jp/app_dev/16197/ Amazon SNSを使ってiOSアプリにプッシュ通知を送信する方法 | レコチョクのエンジニアブログ https://techblog.recochoku.jp/2537 Lambda(node.js) + Amazon SNSでiPhoneにプッシュ通知を送るサンプルコード - Qiita https://qiita.com/Fujimon_fn/items/740ecfdd9328375c1616 おじさんのための2018年スマホPUSH通知事情 (+GCM終了のお知らせ) - Qiita https://qiita.com/keidroid/items/290af7b99952e889f4a7 ■未検証だが参考になりそう APNsとは?設定と実装方法の完全版! | Growth Hack Journal https://growthhackjournal.com/ios-remote-push-notifications-in-a-nutshell/ AWS SNSを使ってiOSへpush通知 - Qiita https://qiita.com/ijun/items/2cbff7664e49fb93bf39 プッシュ通知に必要な証明書の作り方2018 - Qiita https://qiita.com/natsumo/items/d5cc1d0be427ca3af1cb Swiftでプッシュ通知を送ろう! - Qiita https://qiita.com/natsumo/items/8ffafee05cb7eb69d815 Releases - noodlewerk/NWPusher https://github.com/noodlewerk/NWPusher/releases Push通知を送信できるアプリ Pusher [iOS8以降]Push通知の実装とテスト(swift) - Qiita https://qiita.com/k-yamada-github/items/258aec32a0d5b514f1cf ■Amazon SNS [基本操作]Amazon SNSでメールを送信する | Developers.IO https://dev.classmethod.jp/cloud/aws/amazon-sns-2017/ まずは上の方法でメールとHTTPでの通知を試す 問題なければ、アプリへのプッシュ通知を試す amazon SNSでiOSアプリにプッシュ通知を送る!画像つきで詳しく使い方もまとめました! https://iritec.jp/app_dev/16197/ Rails + Swiftのプッシュ通知をAmazonSNSで実現する https://qiita.com/3kuni/items/62c4739cf1316b2c2ef4 トピック型のモバイルPush通知をRails + Amazon SNSで実装する https://tech.medpeer.co.jp/entry/2018/03/15/080000 プッシュ通知に必要な証明書の作り方2018 https://qiita.com/natsumo/items/d5cc1d0be427ca3af1cb 【Swift】いまさらですがiOS10でプッシュ通知を実装したサンプルアプリを作ってみた https://qiita.com/natsumo/items/ebba9664494ce64ca1b8 [Swift] Amazon SNS で iOSアプリにPush通知を送信する #アドカレ2015 https://dev.classmethod.jp/cloud/aws/aws-amazon-sns-mobile-push-swift/ Amazon Simple Notification Service https://docs.aws.amazon.com/ja_jp/sns/latest/dg/mobile-push-apns.html Amazon SNS で、iOS・Androidにpush通知する方法 - Qiita https://qiita.com/papettoTV/items/f45f75ce00157f87e41a phpでAWSのSNSを使ってpush通知を送るときのパターン的なお話 ~ 適当な感じでプログラミングとか! http://watanabeyu.blogspot.com/2017/01/phpawssnspush.html 大規模ネイティブアプリへのプッシュ通知機能導入にあたって考えたこと - Qiita https://qiita.com/gomi_ningen/items/ab31aa2b3d46bb6ffa5e ■旧サンプルメモ http://refirio.org/twitter/?word=push
■複数人で開発する
※未検証 親となるMacを決め、そこで証明書の作成などを行い、 その証明書を他のMacに配布する…という手順で対応できるみたい 複数台のMacでiOSアプリを開発する方法 ~ 開発チームのブログ http://stpsysdev.blogspot.com/2015/09/macios.html iOSアプリ開発で実機による開発を複数台(メイン機ではない2台目以降)のMacで行いたい場合 | makotton.com https://makotton.com/2014/10/24/625 iOSアプリの開発環境の2台目を設定する - はつねの日記 https://hatsune.hatenablog.jp/entry/2021/04/03/212607 所有している複数のMacでiOSアプリを開発するための証明書周りのやり方 - Qiita https://qiita.com/_mogaming/items/b7e24e59073130429f41
■作業アカウントの追加
apple@example.com というメールアドレスがあり、Apple Developer Program や iTunes Connect には登録済みとする 必要に応じてDUNSナンバーの手続きなども完了しているものとする test-app@example.com というメールアドレスを作成したものとする メールアドレスのみで、AppleやGoogleのアカウントは無い状態 ■Apple Developer Program https://developer.apple.com/jp/programs/ に、既存アカウントの apple@example.com でログイン 左メニューの「People」をクリックし、「Invite People」から招待できる 「Invite as Members」に招待したいメールアドレスを入力して「Invite」ボタンを押す 「The email addresses indicated above are not valid.」と表示されたが、Apple Developer からログアウトして再度ログインすると招待できた すぐに test-app@example.com に、「You have been invited to join an Apple Developer Program.」というメールが届いた https://developer.apple.com/account/?inviteId=A7QP6UW45Y のようなURLが記載されている。クリックすると「Apple Developer へサインイン」という画面になった Apple ID は必要みたいなので、「Apple IDをお持ちでないですか? 作成はこちら」から新規作成画面へ メールアドレス: test-app@example.com パスワード: Rg9Qb_QNam3B 質問1: 十代の頃の親友の名前は? → 山田一郎 質問2: 子供の頃のニックネームは? → 山田二郎 質問3: 初めての職場での上司の名前は? → 山田三郎 アカウントの作成が完了するとログイン済になった (なお、この時点ではこのアカウントでiTunes Connectにはログインできない。ログインしようとすると「Apple IDがiTunes Connect用に設定されていません。」と言われる) 同時に以下のダイアログが表示されたので「Accept」をクリック
Join Team You have been invited to join a development team in the Apple Developer Program. You are accepting this invitation with the Apple ID test-app@example.com. To accept with a different Apple ID, cancel, sign out, and click the link in your invitation email. Cancel Accept
これで Apple Developer Program でアプリIDの一覧などにアクセスできるようになった ただしMembersだと、プロビジョニングプロファイルの作成やApp IDの作成などができない。プッシュ証明書の作成もできなかった Adminsに変更すると、それぞれ作業ができるようになった(即座に反映された / が、アプリの新規作成など一部の機能は、ログアウトしてからログインし直さないと反映されないかも) ひととおりアプリの作成を行うなら、Adminsの権限が必要そう iTunesConnect及びAppleDeveloperのメンバーを追加してみた - Qiita https://qiita.com/toshihirock/items/dc78fc5e254c1886ad0d プログラムにおける役割とApp Store Connectにおける役割 - サポート - Apple Developer https://developer.apple.com/jp/support/roles/ Apple DeveloperとiTunes Connectに追加するユーザーとその権限|Wano Group Developers Blog https://developers.wano.co.jp/1251/ ■iTunes Connect ※Apple Developer Program とは別に招待が必要 https://itunesconnect.apple.com/ に、既存アカウントの apple@example.com でログイン メニューの「ユーザとアクセス」を選択 画面内の「+」をクリックするとユーザの登録画面になる 姓 名: アップル テスト メールアドレス: test-app@example.com (Apple ID と同じアドレス) 役割: Developer (場合によっては App Manager の方が適切かも) すぐに test-app@example.com に、「You've been invited to App Store Connect.」というメールが届いた (ただしロリポップメーラーで確認すると内容が白紙なので、Thunderbirdなどで確認。サーバ上にメールを残すためIMAPで受信) アクティベートのリンクをクリックすると、以下の画面が表示される
サインインして招待を承諾してください REFIRIO CO.,LTD.が、あなたをApple Developer Programのチームへの参加に招待しました。 メンバーとして、Appleプラットフォーム向けのAppの作成や配信をするために、 ベータ版ソフトウェアやApp Store Connectなどのリソースにアクセスできるようになります。
承諾時に「リクエストを処理できません。」のエラー画面に遷移したが、登録は正常にできた(別件で試した場合も同じようになった) ただしDeveloperだと、アプリの新規追加作成などができない App Managerに変更すると、それぞれ作業ができるようになった(即座に反映された / が、反映されない機能があればログアウトしてからログインを試す) ひととおりアプリの作成を行うなら、App Managerの権限が必要そう もしくはDeveloper権限を与えてもらい、管理者にアプリを新規に作成してもらい、そこに対して権限を与えてもらうか 1から始めるiOSチーム開発:iTunes Connectにメンバーを追加する - Qiita https://qiita.com/kurumaya/items/95dc2a6fc3c080f73706
■Xcodeベータ版でのビルド
※Xcode15ベータ版を試そうとした …が、すでにAppStoreにXcode15があったので、そちらを利用することにした というときの手順。よってベータ版の確認は途中で終わっている ダウンロードとリソース - Xcode - Apple Developer https://developer.apple.com/jp/xcode/resources/ Apple Developerにログインした状態で上記ページにアクセスすると、ベータ版をダウンロードできるみたい Additional ToolsやFront Toolsなどが必要かは、また確認しておきたい Xcodeへのアカウント登録が必要かなどは、また実際に試してメモしておきたい 複数バージョンのXcodeを共存させる方法 - Support https://docwiki.embarcadero.com/Support/ja/%E8%A4%87%E6%95%B0%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3... iOSの最終ベータ版が"GM"から"RC"に変わった件 - Qiita https://qiita.com/y-some/items/b4e4944de4b289e07fd6 拡張子「.xip」のファイルとは?開く方法を紹介! | Aprico https://aprico-media.com/posts/7377 ファイル拡張機能XIP-開くにはどうすればよいですか? https://whatext.com/ja/xip 以下、実際にベータ版をダウンロードしたときのメモ(Xcode14がインストール済み) 準備として、macOSを最新版にアップデートしておき、もともとインストールされているXcodeでアプリを実機転送できることを確認しておく また、HDDの要領が十分に空いていることを確認しておく 1. 上記「ダウンロードとリソース」にアクセスする 「Xcode 15ベータ版」にある「ベータ版をダウンロードする(英語)」にアクセスする 2.「More Downloads」のページが表示される 「Xcode 15」の「View Details」をクリックする 「Xcode 15.xip」が表示されるのでクリックする(サイズは2.96GBとなっていたが、ダウンロードしたファイルは3.18GBとなっている) 3. ダウンロードしたファイルを、ダブルクリックで展開する 展開すると、同じフォルダ内に「Xcode」というファイルが作成された(サイズは11.46GBとなっている) 「Xcode」の名前を「Xcode_15beta」に変更する 「Xcode_15beta」を「アプリケーション」フォルダに移動する なお、この時点ではアイコンに停止マークが表示されている 4. 「Xcode_15beta」をダブルクリックで起動する 「このバージョンのアプリケーション"Xcode_15beta"は、このバージョンのmacOSでは使用できません」と表示された Macのバージョンを確認すると、「Ventura 13.4」となっていた いったんOSを「Sonoma 14.0」にバージョンアップする OSをアップデートすると、そもそもXcode14が利用できなくなった(アップデートを促される) そのままアップデートする Xcodeを起動させようとすると、「Installing additional components...」の表示になるので少し待つ Xcodeは起動したが、裏側で「iOS 17.0 Simulator」のダウンロードが進んでいる(サイズは7.56GBとなっている) それぞれ完了すると、Xcodeが使えるようになった
■Xcode旧版でのビルド
古いバージョンのXcodeをインストールする方法 | S.T.Blog https://shuhey-hashimoto.com/xcode/%E5%8F%A4%E3%81%84%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E... https://developer.apple.com/download/all/ この時点で使用しているXcodeは15.3なので、15.2をダウンロードする 1. Apple開発者ページにログインしてから https://developer.apple.com/download/all/ にアクセスする 2. 「Xcode 15.2」の「View Details」をクリックする 「Xcode 15.2.xip」が表示されるのでクリックする 3. ダウンロードしたファイルを、ダブルクリックで展開する 「Xcode」の名前を「Xcode_15.2」に変更する 「Xcode_15.2」を「アプリケーション」フォルダに移動する 4. 「Xcode_15.2」をダブルクリックで起動する 少し待つとプラットフォームに対応する開発ツールをダウンロードするダイアログが表示された(Xcode初回起動時と同じもののはず) 必要なものにチェックを入れて「Download & Install」ボタンをクリックする 開発ツールがダウンロードされ、Xcodeが起動した。並行してシミュレータがダウンロードされるようなので、しばらく待つ 5. 完了したら、通常どおり作業を行う
■TIPS
■画面サイズを確認する iPhone/iPad/Apple Watch解像度(画面サイズ)早見表 - Qiita https://qiita.com/tomohisaota/items/f8857d01f328e34fb551 ■アプリ内でアイコン画像(シンボル)を使用する SF Symbols - SF Symbols - Human Interface Guidelines - Apple Developer https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ SwiftUI Image(systemName:)で使用するアイコン名の一覧 - Qiita https://qiita.com/kazy_dev/items/4983faa45630afa75b06 アイコン自体は、以下のようにすると利用できる 「moon」「sun.max」部分で表示対象を指定できる
Image(systemName: "moon") Image(systemName: "sun.max")
以下の公式アプリをMacにインストールすれば、ひととおりのアイコンを一覧することができる SF Symbols - Apple Developer https://developer.apple.com/sf-symbols/ 必要なアイコンが無ければ、もとのシンボルをカスタマイズして、再利用可能なベクターベースのファイル形式でエクスポートできる (一部のシンボルは対応していないらしい) ライセンスについては、以下のように記載されている SwiftUI Image(systemName:)で使用するアイコン名の一覧 - Qiita https://qiita.com/kazy_dev/items/4983faa45630afa75b06
すべてのSFシンボルは、XcodeおよびApple SDKのライセンス契約で定義されているシステム提供のイメージであると見なされ、 そこに記載されている契約条件が適用されます。アプリのアイコン、ロゴ、またはその他の商標関連の用途では、 SFシンボル(または実質的または混乱を招くような類似のグリフ)を使用できません。 Appleは、前述の制限に違反して使用されたシンボルのレビューおよび独自の裁量での使用の変更または中止を要求する権利を留保し、 お客様はかかる要求に速やかに準拠することに同意します。
■アプリのアイコンを設定する (初心者向け)Swift3.0で初アプリ - アイコンを登録してみる - Qiita https://qiita.com/egplnt/items/5987773844c35a735dea PNG形式で、120pxと180pxの2パターンが必要 ■アプリの起動画面を表示する 【Swift4】アプリ起動時のスプラッシュ(ローディング)画面作成方法|ぴっぴproject http://pippi-pro.com/swift-launchscreen 専用のストーリーボードが、はじめから用意されている iOSのスプラッシュ画面実装における注意点と実装方法 - Qiita https://qiita.com/k-boy/items/7de88a834bf01a6e858f 画像を登録するだけでも実装できる が、たくさんの画像を準備するのが面倒かも?柔軟性も低いかも? ■アプリ起動画面の表示時間を長くする 【xcode】【iOS】アプリ起動画面の表示時間 | 【xcode】【iOS】【iphoneアプリ開発】すぐ使えるiOSプログラミングTips http://funkit.blog.fc2.com/blog-entry-1.html Swiftでも「sleep(3)」のようにすれば大丈夫だった ■画像の縦横比を保って表示する 縦横比を保ったまま目一杯表示したいならAspectFit - 極上の人生 https://kawairi.jp/weblog/vita/201311229639 ■プロジェクト名を変更する Xcodeのプロジェクト名変更 - Xcode9.2 http://somen.site/2018/02/10/xcode%E3%81%AE%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E5... Xcodeでプロジェクト名を変更する方法 (Xcode8.0) | Libra Studio エンジニアブログ https://tech.librastudio.co.jp/index.php/2016/10/05/post-1038/ 不可能ではないようだが、かなり大変そう 原則として変更しない方が良さそうなので、適当な名前でプロジェクトを作らないようにする ■プロジェクトを古いXcodeで開く ※未検証 【Xcode/Swift】前バージョンのXcodeで開くとエラー「The project at ‘/Users/xxx/XXX.xcodeproj’ cannot be opened because it is in a future Xcode project file format.」の解決方法について | iOS-Docs https://ios-docs.dev/previous-xcode-open/ Xcodeでプロジェクトの画面を開くと、画面右側に 「Project Document → Project Format」 がある ここを古いバージョンにすることで、前バージョンのXcodeで開けるようになるみたい 無理に新しいものに切り替えなくても大丈夫のようだが、また調べておきたい 以下2023年9月時点の確認メモ ・作ったばかりのアプリだと、最新の「Xcode 14.0-compatible」になっている ・比較的最近に作られたアプリだと「Xcode 13.0-compatible」になっている ・昔に作られたアプリだと「Xcode 9.3-compatible」になっている ・一番古い選択肢は「Xcode 3.1-compatible」になっている
■トラブル
■SwiftUIで開発時、プレビューが表示されない Xcodeの「General -> Display Name」で「shoppinglist」を「Shopping List」に変更すると、 プレビューを表示させる際に 「Failed to build the scheme “shopping list”」 のエラーが出るようになった(プレビューの表示には失敗する) また、エラーの詳細を確認すると 「Cannot load module ‘ShoppingList’ as ‘shopping list’」 となっている 以下の方法でアプリ名を変更すればエラーにならなかった 具体的にはInfo.plistに「Bundle display name」の項目を追加し、アプリ名を指定した Display Name の変更は良くない方法みたい? iOS開発でホーム画面に表示するアプリ名を変更する方法 - Reasonable Code https://reasonable-code.com/ios-app-home-name-change/ Xcode13でInfo.Plistがない。どこ? - Qiita https://qiita.com/john-rocky/items/0d7bf4428f013feba64c iOSでホーム画面に表示されるアプリ名はどこで決まるのか?変更するには? - Qiita https://qiita.com/temoki/items/fc3b62bc088f96184f8f ■ビルド時、「private key is not installed in your keychain」のようなエラーが表示される 他端末で作成された鍵ファイルが無いので、持ってくる必要がある 鍵のある環境でキーチェーンアクセスを起動し、証明書とキーを選択して「ファイル → 書き出す」とする p12ファイルが作成できるので、これを問題のある環境に渡す p12ファイルをダブルクリックすると、キーチェーンアクセスに登録される キーチェーンアクセスの「証明書」内の一覧を確認すると証明書が追加されていた。先頭の三角をクリックすると鍵のアイコンを確認できる 古いMacから新しいMacへ鍵付き証明書を持ってくる https://zenn.dev/welchi/articles/xcode-revoke-certificate ■実機書き出し時、「An error was encountered while attempting to communicate with this device.」のようなエラーが表示される 以下で解消することがある ・クリーンとビルドを試す ・Xcodeを再起動する ・iPhoneを再起動する 【Xcode9】An error was encountered while attempting to communicate with this device.のエラーが出た場合の対処方法【iOS11】 | ニートに憧れるプログラム日記 http://program-life.com/227 ■実機書き出し時、「iPhone is busy: Preparing debugger support」のようなエラーが表示される 上と同じ内容を試す 【Xcode】”〜のiPhone is busy: Preparing debugger support for 〜のiPhone”というエラーを解消出来るかもしれない方法 - ぱふの自由帳 https://pafu-of-duck.hatenablog.com/entry/2018/03/08/232033 ■実機書き出し時、「iPhone is busy: Xcode will continue when iPhone is Finished」のようなエラーが表示される 「10〜15分ほど待つ」と紹介されている 追加のデータをAppleのサーバからダウンロードしている…などかもしれない 実際、クリーンとビルド、XCodeの再起動、iPhoneの再起動を試しても駄目だったが、5分ほど待つと実機書き出しが完了した Xcode will continue when iPhone is Finished | ITechBrand.com https://itechbrand.com/xcode-will-continue-when-iphone-is-finished/ iPhoneをiOS12.0.1にアップデートしたらXcodeで「iPhone is busy: Preparing debugger support for iPhone」が表示される場合の対処法 - AppSeedのアプリ開発ブログ https://develop.hateblo.jp/entry/xcode-iphone-os-update-busy ■実機書き出し時、「iPhone is busy: Making the device ready for development」のようなエラーが表示される 何時間待っても進まなかった Xcodeをアップデートし、改めて書き出しを試すと解消できたみたい ■実機書き出し時、「Errors were encountered while preparing your device for development. Please check the Devices and Simulators Window.」のようなエラーが表示される アプリの削除とiPhoneの再起動で解消できたが、後者だけで十分だったかも 【Xcode】iPhoneへのビルドエラー対処法 - アプリ開発で老後の副業を目指すブログ https://rougo-fukugyo.com/archives/3413 ■実機書き出し時、「failed with a nonzero exit code」のようなエラーが表示される アプリの表示名を変更してアイコンを設定したときに発生した 「ライブラリがおかしい」のような表示もあったが、Xcodeで 「Product → Crean Build Folder」 としてから 「Product → Build」 とすればインストールできた よく判らないエラーが表示されたら、基本的にまずは「クリーンとビルドを試す」とすれば良さそう Command 〜 failed with a nonzero exit code - Qiita https://qiita.com/fuwamaki/items/1638ab79c32467a9f94b ■実機書き出し時、「maximum number of apps」のようなエラーが表示される 【swift】実機テストで「The maximum number of apps for free development profiles has been reached.」というエラーが発生 http://pg.kdtk.net/1369 ■実機書き出し時、キーチェーン「access」のパスワードを何度も求められる 5つほど同じダイアログが開いているみたい すべてのダイアログでMacのログインパスワードを入力し、すべて「常に認証」にすれば書き出せた(表記はうろ覚え) 書き出しのために裏側で5つのダイアログが開き、それぞれに対して認証が必要だった…のかも ■実機書き出し時、どうしても書き出せなくて原因不明なら ・端末側でアプリを信頼しているか、「設定 → 一般 → プロファイルとデバイス管理」を確認する ・Appleのアカウントが有効期限切れになっていないか確認する ・Appleの規約が更新された場合、改めて規約に同意する必要がある ・MacOSの再インストールからはじめると、すんなり書き出せることがある Appleの規約が更新された場合、Apple Developer Program にログインすると以下のようなメッセージが表示される The Apple Developer Program License Agreement has been updated. In order to access certain membership resources, you must accept the latest license agreement by November 3, 2018. [Review Agreement] リンクをクリックすると規約が表示されるので、内容を確認して同意する なおEnterpriseなど別アカウントを紐付けている場合は、そのアカウント所持者に連絡を取るようにメッセージが表示される The Apple Developer Enterprise Program License Agreement has been updated. In order to access certain membership resources, Refirio must accept the latest license agreement by November 3, 2018. [Contact Refirio] 該当アカウントでログインし、リンクをクリックすると規約が表示されるので、内容を確認して同意する
■macOSアプリ
【SwiftUI】Mac向けアプリを作ろう | チグサウェブ https://chigusa-web.com/blog/swiftui-sample-app/ SwiftUIを使ってmacOSステータスバーアプリをつくる方法 | 株式会社ヌーラボ(Nulab inc.) https://nulab.com/ja/blog/nulab/how-to-make-statusbar-app-with-swiftui/ macOS SwiftUIプログラミング / 初めの一歩 https://vivacocoa.jp/swiftui/swiftui_firststep.php 以下、実際にアプリの作成を試したときのメモ ■アプリの作成 Xcodeを起動してプロジェクトを作成する 「macOS」から「App」を選択し、以下の内容で作成する Product Name: MacSample Interface: SwiftUI Language: Swift Xcodeによって作成された、ContentViewの初期コードは以下のとおり
import SwiftUI struct ContentView: View { var body: some View { VStack { Image(systemName: "globe") .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") } .padding() } } #Preview { ContentView() }
iOS用アプリを作るときは重すぎて(?)表示されなくなったプレビューは今回は表示された そのまま実行すると、アプリケーションが表示された ■アプリのアーカイブ Xcodeで「Product → Archive」を選択し、「Distribute App」をクリック 「Custom」から進み、「Copy App (Export a copy of the archived app.)」を選択することでアプリを書き出すことができる プロジェクトと同じ階層に「MacSample 2023-12-28 23-10-15」フォルダが作られ、その中に「MacSample」ファイルが保存されている これを実行すると、アプリを起動できる ■アプリのアーカイブ(駄目だった手順) Xcodeで「Product → Archive」を選択し、「Validate App」をクリック しかし以下のエラーになった あらかじめバンドルIDを作っておく必要があるみたい
Error Downloading App Information App record with bundle identifier "org.refirio.MacSample" not found on App Store Connect. Create an app record on App Store Connect, or distribute the app from Xcode, and then try again.
なお、Xcodeの「Signing & Capabilities」の「Signing」で「Automatically manage signing」にはチェックが入っていた Xcode : Mac用アプリをMac App Storeを使用せずに個人配布する|アプリケーションの出力方法 | HikaruApp https://hikaruapp.jpn.com/xcode/osx/post-619 Developerサイトにログイン Identifiersから「App IDs」で以下を作成 Description: MacSample Bundle ID: org.refirio.MacSample ただし、Xcodeから改めて操作しても、同じエラーになったような Validateの途中で「Custom」を選択すると進めたと思ったが、やはり途中でエラーになった
■その他メモ
iOSアプリ開発の全体像 - Qiita http://qiita.com/gomi_ningen/items/b8c9c5c11aee91be820e iOSライセンス&配布方法まとめ - Qiita https://qiita.com/isaac-otao/items/126bced83d9af86c7ce5 【完全保存版】「iOS 11」新機能・変更点の完全ガイド 押さえておきたい15のポイントを解説 | CoRRiENTE.top https://corriente.top/post-49396/ [Swift 3.0] Playground で URLSession を使う http://dev.classmethod.jp/smartphone/iphone/swift-3-playground-urlsession/ iPad mini2のSwift PlaygroundsでUIKitを使ったHello worldを書いてみた http://kako.com/blog/?p=20856 [iOS8] Swiftでデバッグ出力する方法 http://qiita.com/hiroo0529/items/b84d4e85b5104cb008e8 なんとかストライクとは http://xavier.hateblo.jp/entry/2014/09/22/100201 クエスチョンマークとビックリマーク http://swift-salaryman.com/optional.php 日本語ドキュメント - Apple Developer https://developer.apple.com/jp/documentation/ SBクリエイティブ:絶対に挫折しない iPhoneアプリ開発「超」入門 増補改訂第5版 【Swift 3 & iOS 10.1以降】 完全対応 http://www.sbcr.jp/products/4797389814.html?sku=4797389814 ↑アプリ公開手順のPDFをダウンロードできる ■配列 Swiftで多次元配列を使う場合 - Qiita https://qiita.com/art_526/items/9282b63f51d85f58c3e5 [Swift]空の配列を用意してタプルを追加する https://code-schools.com/swift-array-append-tuple/ Xcode - Swift4でタプル配列をUserDefaultに保存して取り出したいです。|teratail https://teratail.com/questions/135321 Swift - 多次元の辞書型配列をUserDefaultsで保存する方法|teratail https://teratail.com/questions/127808 ■日時 Swiftで日付の形式を変換する - Qiita https://qiita.com/kwst/items/949402c635d1e2113f95 ■TableView tableViewのロード方法色々 - Qiita https://qiita.com/tatetate55/items/858fc644b9b8d878cfd1 Swift3でテーブルのセルを横にずらせる(スワイプできる)ようにする - Qiita https://qiita.com/suzuki_y/items/f04f4e9578060d0e6306 Xcode - UITableViewで画面遷移後のセルの選択状態解除|teratail https://teratail.com/questions/57949 ■PDF iOSでPDFを表示してみる メモ http://nonchalanttan.hatenablog.com/entry/2016/11/11/200000 【初心者向け】Swift3で爆速コーディングその1(画面作成とSnippetsの使い方) http://qiita.com/teradonburi/items/d0ffb6367e34966d761b 【iOS開発】Swiftで簡易PDFビューワを作成(PDFを読み込み、表示) http://kerubito.net/technology/1615 ■デザイン 【Flutter】アプリ開発_初心者のアプリをプロっぽくする最強のpackegeを紹介 - Qiita https://qiita.com/kazumaz/items/876e162cf429014661d8 ■その他 iOS 12以降のAPIで "NSKeyedArchiver" と "NSKeyedUnarchiver" を使う - 文字っぽいの。 https://fromatom.hatenablog.com/entry/2019/02/01/174830 swiftでexpected declarationとエラーが出る - Qiita https://qiita.com/hyoutann/items/76513fc40ab5881f84a1 XcodeでMGIsDeviceOneOfType is not supported on this platform. - Programmer's Note http://hifistar.hatenablog.com/entry/2018/12/01/155046 更新できなければ淘汰されるiOSアプリ - いつもあさって https://akuraru.hatenadiary.jp/entry/2020/01/05/154749

Advertisement