■目次
アカウントXcode環境の作成Xcode環境の更新.gitignoreSwiftSwift+PlaygroundSwift+ARKitSwift+GameSwiftUISwiftUI+ネットワークSwiftUI+リストの編集SwiftUI+UIViewRepresentableSwiftUI+ARSwiftUI+CameraSwiftUI+QRコードSwiftUI+顔認識SwiftUI+リアルタイム顔認識SwiftUI+音声SwiftUI+BluetoothSwiftUI+Apple WatchSwiftUIの作例SwiftUIその他Objective-C製品用、開発用などの切り分けアプリ公開Enterpriseで社内向けに配布TestFlightIn-Houseで書き出す更新CocoaPodsPush通知作業アカウントの追加TIPSトラブルその他メモ
■アカウント
■Apple ID https://appleid.apple.com/ AppStoreでアプリをダウンロードしたり、iCloudを利用したりするためのアカウント Apple製品を使っているなら、すでに持っているはず ■Apple Developer Program https://developer.apple.com/jp/programs/ 証明書やプッシュなど、アプリの設定を管理するためのもの すべての機能を使用するためには、年会費を払う必要がある 【Xcode】無料の実機ビルドでどこまでできるのか - Qiita https://qiita.com/koogawa/items/15b231e2728ff64e08f3 ■iTunes Connect https://itunesconnect.apple.com/ アプリを公開するためのもの アプリを公開するためには、Apple Developer Programで年会費を払う必要がある
■Xcode環境の作成
■Xcodeインストール Mac App Store で「Xcode」を検索してインストールする Xcode をインストールする、 iOSアプリ作成準備 https://i-app-tec.com/ios/xcode-install.html ■Xcode初期設定 メニューから「Xcode > Preferences > Text Editing」で「Line numbers」と「Code folding ribbon」にチェックを入れる 「Indentation」画面に切り替え、「Line Wrapping」の「Wrap lines to editor width」のチェックを外す ■Simulatorで実行 はじめてアプリを実行するとき、「Enable Developer Mode on this Mac?」と聞いてきたので「Enable」を選択 Simulatorメニューから「Window > Scale > 33%」と設定 Simulator画面内で「Settings > General > Language & Region > iPhone Language > 日本語」と設定 ■iPhone実機で実行 はじめて実機を繋ぐとiTunesが起動したので、利用規約に同意 Xcodeで繋いだ実機を選択すると「Processing symbol files」状態になった。結構時間がかかるので待つ 実機で実行しようとすると「Signing for requires a development team」でエラー 作成しようとしているアプリのプロジェクトを選択して「General > Signing > Add Account」を選択。Apple ID でログイン Tearmで自身のアカウントを選択してビルド キーチェーンへのアクセスを要求されるので「常に許可」 それでも「Could not launch」のエラーになる。ダイアログに詳細が書かれているが、実機側で許可が必要 実機の「設定 > 一般 > デバイス管理」から、使用しているアカウントを選択して承認する これで実機で実行できた ※昔は実機実行のために開発者登録(要年会費)が必要だったり、 実機実行を許可するデバイスをあらかじめ登録したり …が必要だったが、今は不要
■Xcode環境の更新
Macが空き容量不足でXcodeをupdateできない時の対処 - Qiita https://qiita.com/hisw/items/250aaf3ffaab7b0822df
■.gitignore
無くても問題ないようだが、以下のように設定されているプロジェクトがあった。要確認
UserInterfaceState.xcuserstate Breakpoints_v2.xcbkptlist
以下を参考に作成すると良さそう XcodeでiOSアプリ開発をする時の.gitignore - Qiita https://qiita.com/ikuwow/items/4fae81a099bf82f44749
■Swift
■画面の向き 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+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/
■Swift+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) } }
■Swift+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
■ハローワールド ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("ハローワールド!") .padding() } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
■プレビューのサイズを変更 ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("ハローワールド!") .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() } }
■フォントの指定 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() } }
■@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() } }
■@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+ネットワーク
■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 { 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) } } 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 と 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/ ■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() } }
■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
■SwiftUI+AR
■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 { return 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
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 { return ARViewContainer().edgesIgnoringSafeArea(.all) } } struct ARViewContainer: UIViewRepresentable { func makeUIView(context: Context) -> ARView { let arView = ARView(frame: .zero) // 「Experience.rcproject」ファイル(Reality Composer で扱える3Dモデル)から「Box」を読み込む let boxAnchor = try! Experience.loadBox() // 「Box」をシーンに追加する arView.scene.anchors.append(boxAnchor) // シーンにアンカーを追加する let anchor = AnchorEntity(plane: .horizontal, minimumBounds: [0.15, 0.15]) arView.scene.anchors.append(anchor) // 立方体を作成 let boxMesh = MeshResource.generateBox(size: 0.1) // 光源を無視する単色を設定 let boxMaterial = UnlitMaterial(color: UIColor.red) let boxModel = ModelEntity(mesh: boxMesh, materials: [boxMaterial]) boxModel.position = SIMD3<Float>(-0.2, 0.0, 0.0) // 左0.2m anchor.addChild(boxModel) // 球体を作成 let sphereMesh = MeshResource.generateSphere(radius: 0.1) // 環境マッピングするマテリアルを設定 let sphereMaterial = SimpleMaterial(color: UIColor.white, roughness: 0.0, isMetallic: true) let sphereModel = ModelEntity(mesh: sphereMesh, materials: [sphereMaterial]) sphereModel.position = SIMD3<Float>(0.4, 0.0, 0.0) // 右0.4m anchor.addChild(sphereModel) // テキストを作成 let textMesh = MeshResource.generateText( "Hello, world!", extrusionDepth: 0.1, font: .systemFont(ofSize: 1.0), // 小さいとフォントがつぶれてしまうのでこれぐらいに設定 containerFrame: CGRect.zero, alignment: .left, lineBreakMode: .byTruncatingTail) // 環境マッピングするマテリアルを設定 let textMaterial = SimpleMaterial(color: UIColor.blue, roughness: 0.0, isMetallic: true) let textModel = ModelEntity(mesh: textMesh, materials: [textMaterial]) textModel.scale = SIMD3<Float>(0.1, 0.1, 0.1) // 10分の1に縮小 textModel.position = SIMD3<Float>(0.0, 0.0, 0.2) // 手前0.2m 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
■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コードを探す」 のような手順を踏めば対応できないことはなさそう ■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/ 「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
https://github.com/SatoTakeshiX/SwiftUI-Vision/blob/main/Detected-in-Still-Image/Detected-in-Still-I... の内容をそのまま貼り付けた
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+リアルタイム顔認識
■検証中 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() } }
■音声認識 ※未検証 SwiftUIとSpeech Frameworkで動画の文字起こしアプリを作ってみる - Qiita https://qiita.com/rymshm/items/5ea968acb686c53133c7
■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+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
■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その他
■ハンバーガーメニューを作る SwiftUIでサイドメニューを実装してみた | DevelopersIO https://dev.classmethod.jp/articles/swiftui_overlay_sidemenu/ ■プッシュ通知 SwiftUIのディープリンク対応:プッシュ通知から画面遷移する方法 - Quipper Product Team Blog https://quipper.hatenablog.com/entry/2020/12/24/swiftui-deeplinking 【SwiftUI】ローカル通知を実装する方法【バックグラウンド】 - おもちblog https://omochiblog.com/2021/02/28/swiftui-localnotification-background/ 【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... ■参考メモ [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まで試してみたが、若干表記が違う程度ですんなり動いた
■製品用、開発用などの切り分け
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+Debug だが、必要なら buildtest1+Release に切り替えることもある」 で良さそうなので、最初に上げた切り分けで良さそう ■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/
■Enterpriseで社内向けに配布
※詳細な検証内容は以下のファイルも参照。以下は以前に調べたときのメモ Dropbox\iOS\In-House で書き出すメモ.txt iOS Developer Enterpriseで社内向けiOSアプリを作って配布する方法 [完全版] | イリテク https://iritec.jp/selfhack/3355/
■TestFlight
招待されたときのスクリーンショットとメモ Dropbox\iOS\TestFlight TestFlightの使い方!iOSアプリをテスト配信、導入方法まで! | イリテク https://iritec.jp/web_service/11438/ TestFlightの使い方(テスター向け) - Qiita https://qiita.com/itokjp/items/e76f404647c2849aba3c 新TestFlightの使用方法 - Qiita https://qiita.com/Raugh/items/9e803fbb03391cf1b388 ■テスターの追加 App Store Connect で 「マイ App → (アプリ名) → TestFlight → 公開ベータテスト → + → 新規テスターを追加」 からユーザを追加 ■パブリックリンク パブリックリンクを使用すれば、URLを知らせるだけでTestFlightに参加してもらうことができる 以下は iTunes Connect に表示される、TestFlightパブリックリンクの説明
TestFlight の新機能 パブリックリンクを使用してTestFlightのテスターを招待 リンクを公開するだけで、Appのベータテストに不特定多数の人を招待できるようになりました。 任意の場所に公開できるこの招待リンクをクリックすると、TestFlight App内の招待ページが表示されます。 メールアドレスのリストを管理する必要はなく、テスターは匿名で参加することもできます。
■In-Houseで書き出す
組織内配布(In-House)やAdHoc(評価用配布)のために書き出す方法 実際に書き出したときのメモ Dropbox\iOS\In-House用に書き出すメモ.txt
■更新
■証明書更新 Apple Developer:3分で完了!期限切れ間近の開発者証明書の更新手順 | siro:chro http://www.sirochro.com/note/apple-developer-certificate-update/ ■プロビジョニングプロファイル更新 http://refirio.org/twitter/?date=20160918 iOS Developer Program の契約は続けているのに、全然iPhoneアプリを作っていない…。 posted at 2016/09/18 00:04 ので、久々にMacを起動したけど、Xcodeはバージョンアップされているし、プロビジョニングプロファイルの有効期限は切れているし、再発行してみてもよく解らないエラーが出て実機に書き出せないし。で2時間ほど悩んでいた。 posted at 2016/09/18 00:05 ブラウザで Apple Developer にログインして更新された規約に同意して、Xcode > Preferences > Accounts からプロビジョニングプロファイルを作り直して、プロジェクトの General > Signing > Term で自分の名前を選択。 posted at 2016/09/18 00:11 で再度実機で動くようになった…はず。いろいろ試していたので、何か書き忘れているかもだけど、一応の備忘録として。 posted at 2016/09/18 00:12 Android Studio も、自宅PCではベータ版の時のままだったので最新版に更新。正式版になってから日本語化できなくなったと聞いていたけど、https://t.co/KPFIVflBQO の方法で日本語化できた。 posted at 2016/09/18 00:43 ■Apple Developer Program 更新 Appleにログイン https://developer.apple.com/account/#/overview/HEZ8494358 にアクセス Renew Membership ボタンを押して、利用規約に同意 Appleにログインして支払内容確定 ■「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」を選択し、その後自分の名前を選択すると大丈夫のときがある
■CocoaPods
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を導入済みの既存プロジェクトをビルドしたときのメモ 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/terasapo $ sudo gem install cocoapods $ pod init Xcodeで「Product → Clean Build Folder」としてから「Product → Build」としてみる …が、それでも実行すると 「No such module'SwiftGitOrigin'」 と言われる Xcodeで開くプロジェクトを「terasapo.xcodeproj」ではなく「terasapo.xcworkspace」にするとビルドできた 開くべきプロジェクトが変わるようなので注意
■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
■作業アカウントの追加
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
■TIPS
■アプリのアイコンを設定する (初心者向け)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/ 不可能ではないようだが、かなり大変そう 原則として変更しない方が良さそうなので、適当な名前でプロジェクトを作らないようにする
■トラブル
■実機書き出し時、「An error was encountered while attempting to communicate with this device.」のようなエラーが表示される 【Xcode9】An error was encountered while attempting to communicate with this device.のエラーが出た場合の対処方法【iOS11】 | ニートに憧れるプログラム日記 http://program-life.com/227 ■実機書き出し時、「Errors were encountered while preparing your device for development. Please check the Devices and Simulators Window.」のようなエラーが表示される 【Xcode】iPhoneへのビルドエラー対処法 - アプリ開発で老後の副業を目指すブログ https://rougo-fukugyo.com/archives/3413 アプリの削除とiPhoneの再起動で解消できたが、後者だけで十分だったかも ■実機書き出し時、「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] 該当アカウントでログインし、リンクをクリックすると規約が表示されるので、内容を確認して同意する ■XcodeのGitから確認すると、編集していないファイルがコミット対象になる 過去使っていた場所と同じ場所にプロジェクトを作成した場合、すでに無いファイルがリストに上がることがある プロジェクトの場所が例えば Prj1 の場合、以下のようにするとリセットできる $ cd Prj1 $ /Applications/Xcode.app/Contents/Developer/usr/bin/git reset iOSアプリ開発:リポジトリにコミット出来ない - Qiita https://qiita.com/pgcmg00/items/0b94986290e8ae3a3b7e
■その他メモ
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