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