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

SceneKitでサイコロを振る(Swift)

はじめに iOS アプリをつくっているとだれしも一度はサイコロを振りたい!と思ったことがあるはずです今回は SceneKit を使ってサイコロを振ってみます。 完成形はこんな感じです。 SwiftUIでもサイコロ振りたかったけどDelegateまわりがややこしくて断念した? pic.twitter.com/qsHSjEeqRl— am10 (@am103141592) November 30, 2021 ソース ソース全体はこんな感じです。 ソース全体 import UIKit import SceneKit final class ViewController: UIViewController { @IBOutlet private weak var scnView: SCNView! @IBOutlet private weak var resultLabel: UILabel! private var boxNode: SCNNode! private var timer: Timer? private let diceLength: CGFloat = 1.5 private let diceNumbers = ["1", "3", "6", "4", "5", "2"] override func viewDidLoad() { super.viewDidLoad() resultLabel.text = nil scnView.allowsCameraControl = true scnView.scene = SCNScene() // カメラをシーンに追加する let cameraNode: SCNNode = { let cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(x: 0, y: 10, z: 10) cameraNode.rotation = .init(x: 1, y: 0, z: 0, w: -Float.pi/4) return cameraNode }() scnView.scene?.rootNode.addChildNode(cameraNode) // 床をシーンに追加する let floorNode: SCNNode = { let floor = SCNFloor() floor.reflectivity = 0.1 let floorNode = SCNNode(geometry: floor) floorNode.physicsBody = SCNPhysicsBody(type: .static, shape: .init(geometry: floor)) floorNode.physicsBody?.contactTestBitMask = 1 return floorNode }() scnView.scene?.rootNode.addChildNode(floorNode) // サイコロをシーンに追加する boxNode = { let box = SCNBox(width: diceLength, height: diceLength, length: diceLength, chamferRadius: 0) box.materials = diceNumbers.map { let material = SCNMaterial() material.diffuse.contents = UIImage(named: $0) return material } let boxNode = SCNNode(geometry: box) boxNode.name = "dice" boxNode.position = SCNVector3(x: 0, y: 0, z: 0) boxNode.physicsBody = .init(type: .dynamic, shape: .init(geometry: box)) boxNode.physicsBody?.categoryBitMask = 1 return boxNode }() scnView.scene?.rootNode.addChildNode(boxNode) scnView.scene?.physicsWorld.contactDelegate = self } @IBAction private func rollDice() { resultLabel.text = nil timer?.invalidate() timer = nil boxNode.physicsBody?.angularVelocity = .init(x: Float.random(in: 0..<10), y: Float.random(in: 0..<10), z: Float.random(in: 0..<10), w: 1) boxNode.physicsBody?.velocity = .init(-3, 0, 0) boxNode.position = SCNVector3(x: 7, y: 10, z: 0) } } extension ViewController: SCNPhysicsContactDelegate { func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) { if contact.nodeA.name == boxNode.name { timer?.invalidate() timer = nil timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false, block: { [weak self] _ in DispatchQueue.main.async { self?.diceRollDidEnd() } }) } } private func diceRollDidEnd() { var worldPosition = boxNode.presentation.worldPosition worldPosition.y += Float(diceLength)/2 let projectPoint = scnView.projectPoint(worldPosition) let point = CGPoint(x: CGFloat(projectPoint.x), y: CGFloat(projectPoint.y)) if let result = scnView.hitTest(point).first, result.node.name == boxNode.name { resultLabel.text = diceNumbers[result.geometryIndex] } } } サイコロを表示する 各処理の説明です。まずサイコロを表示します。SCNBox を使ってキューブを表示し、materials プロパティに各面の画像を設定します(面の順番はよくわからなかったのでいろいろ試して並べました)。 画像はこちら(1 という名前で Xcassets に追加してます)。色を変えたりご自由にお使いください サイコロ サイコロ サイコロ ソースはこんな感じです。 @IBOutlet private weak var scnView: SCNView! private var boxNode: SCNNode! private let diceLength: CGFloat = 1.5 private let diceNumbers = ["1", "3", "6", "4", "5", "2"] boxNode = { let box = SCNBox(width: diceLength, height: diceLength, length: diceLength, chamferRadius: 0) box.materials = diceNumbers.map { let material = SCNMaterial() material.diffuse.contents = UIImage(named: $0) return material } let boxNode = SCNNode(geometry: box) boxNode.name = "dice" boxNode.position = SCNVector3(x: 0, y: 0, z: 0) // 床とぶつけるために設定 boxNode.physicsBody = .init(type: .dynamic, shape: .init(geometry: box)) boxNode.physicsBody?.categoryBitMask = 1 return boxNode }() scnView.scene?.rootNode.addChildNode(boxNode) サイコロを振る サイコロを振る処理は単純でサイコロにランダムに angularVelocity を設定し velocity と position の設定で画面の右側から落とすようにしています。timer はサイコロが転がり終わったかどうかの判定用です。 @IBOutlet private weak var resultLabel: UILabel! private var boxNode: SCNNode! private var timer: Timer? @IBAction private func rollDice() { // サイコロ振り終わったとき用 resultLabel.text = nil timer?.invalidate() timer = nil // サイコロを画面右側から落とす boxNode.physicsBody?.angularVelocity = .init(x: Float.random(in: 0..<10), y: Float.random(in: 0..<10), z: Float.random(in: 0..<10), w: 1) boxNode.physicsBody?.velocity = .init(-3, 0, 0) boxNode.position = SCNVector3(x: 7, y: 10, z: 0) } サイコロの出た目を取得する ここが一番苦労した部分です(たぶんもっといい方法があると思います)。サイコロが転がり終わったのを判定するために床とサイコロが衝突するたびにタイマーを設定しています。衝突した 1.5 秒後に出た目の計算処理をおこないます(1.5 秒以内に再び衝突するとまたタイマーがはじまる)。 出た目の計算処理ではサイコロの上の面の中心座標を取得して hitTest で materials のどのインデックスなのかを取得しています。 参考:Swiftで始まったタッチでヒットしたキューブの面を特定する-SceneKit extension ViewController: SCNPhysicsContactDelegate { func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) { if contact.nodeA.name == boxNode.name { // 床とサイコロがぶつかるたびにタイマーを設定する timer?.invalidate() timer = nil timer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false, block: { [weak self] _ in DispatchQueue.main.async { self?.diceRollDidEnd() } }) } } private func diceRollDidEnd() { // ワールド座標系での現在のサイコロの上の面の中心座標を取得する var worldPosition = boxNode.presentation.worldPosition worldPosition.y += Float(diceLength)/2 // 2Dの座標に変換する let projectPoint = scnView.projectPoint(worldPosition) let point = CGPoint(x: CGFloat(projectPoint.x), y: CGFloat(projectPoint.y)) // 上の面がどこなのか取得する if let result = scnView.hitTest(point).first, result.node.name == boxNode.name { resultLabel.text = diceNumbers[result.geometryIndex] } } } おわりに サイコロを振るのに 3D 表示する必要があるので今まではむずかしくてなんども挫折していたのですがついにサイコロを振ることができました SceneView というのがあるようなので SwiftUI でもサイコロを振れそうです SceneView:ドキュメント 参考 SceneKit:ドキュメント SCNBox:ドキュメント SCNGeometrys:ドキュメント Swiftで始まったタッチでヒットしたキューブの面を特定する-SceneKit SceneKitで重力などの物理シュミレーションを設定する ARKitのための3D数学
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

NCMBを用いた匿名ログイン機能

はじめに 今回はNCMBを用いた匿名ログインの機能を書いていきたいと思います。 匿名ログイン機能とは、その名のとおり個人情報を入力することなくサービスを使えるようにする仕組みのことです。 匿名ログイン機能を実装することで、会員登録はハードルが高いけど、それぞれのユーザーを識別したいなっていう場合などに役に立ちます! 目次   1. 初期状態   2. コードを書こう! 1.初期状態 import UIKit @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } // MARK: UISceneSession Lifecycle func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { } } 2.コードを書こう! 2.1 NCMBの登録 AppDelegateのdidFinishLaunchingWithOptionsの関数に記述します。 NCMBを利用するのでpodをインストールしてください。(ここはNCMB 導入編で記述してあるので省略します) import UIKit //NCMBをインポートする import NCMB @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { //アプリケーションキーとクライアントキーの設定 let applicationKey = "アプリのアプリケーションキー" let clientKey = "アプリのクライアントキー" NCMB.setApplicationKey(applicationKey, clientKey: clientKey) return true } //その他は略します } 2.3 NCMB側で匿名ログインを許可する NCMBを開き「アプリの設定」→「基本」→「匿名設定」→匿名会員の利用 - 許可する NCMB側の設定は以上で完了です。 2.2 匿名ログインの機能のコード 先ほどと同様にAppDelegateのdidFinishLaunchingWithOptionsの関数に記述します。 アプリ内に情報を保存しておくUserDefaultsを利用します。 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let applicationKey = "アプリのアプリケーションキー" let clientKey = "アプリのクライアントキー" NCMB.setApplicationKey(applicationKey, clientKey: clientKey) //UserDefaultsの`uuid`キーの情報を変数uuidに代入 let uuid = UserDefaults.standard.string(forKey: "uuid") if uuid == nil{ //変数uuidが空だった場合 = ログインユーザーがいない場合 //NCMBのNCMBAnonymousUtils.logIn関数を利用し、匿名ログインを実施 NCMBAnonymousUtils.logIn { (user, error) in if error != nil { //エラーが出た場合出力 print ("Log in failed") } else { //エラーが出ない場合 //ログインユーザーのオブジェクトIDをコンソールに出力(実際にできているのか確認するため) print("Logged in" + NCMBUser.current().objectId) //変数authDataを宣言し、ログインユーザーのauthDataカラムを代入 let authData = user!.object(forKey: "authData") as! [String: Any] //変数uuidを宣言し、変数authDataのanonymousキーの中身を代入 let uuid = (authData["anonymous"] as! [String: String])["id"] //UserDefaulsのuuidキーに変数uuidの中身を保存する UserDefaults.standard.set(uuid, forKey: "uuid") } } }else{ //変数uuidが存在した場合 = ログインユーザーがいる場合 //NCMBUser型のユーザーuserを作成 let user = NCMBUser.init() let anonymousDic = ["anonymous": ["id": uuid]] //userのauthDataキーにanonymousDicを保存 user.setObject(anonymousDic, forKey: "authData") //保存したauthDataなどの情報に当てはまるユーザーでログインをする user.signUpInBackground({(error) in if error != nil { //エラーが出た場合出力 print("Error") } else { //ログインユーザーのオブジェクトIDをコンソールに出力(実際にできているのか確認するため) print("Log in" + NCMBUser.current().objectId) } }) } return true } 完成形 実行結果はコンソールに出力されています。 1回目に実行したときは、ログインユーザーが存在しないため匿名ログインを実施、2回目に実行した時は、ログインユーザーが存在するためUserDefaultsに保存されているauthDataを元にログインを実施されています。 終わりに 以上で匿名ログイン機能は完成になります。 このようなログイン方法を用いることで会員登録をすることなく、ユーザーが気軽に利用できるアプリケーションを開発することができます。 不備/疑問点アンケート 教材の不備を見つけた方や、わからなかったこと、もっと詳しく解説してほしいことに関しては、こちらの学習進捗フォームに答えてください! 著者:髙橋直輝
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

なぜ、Couchbaseなのか?〜モバイル/エッジコンピューティングプラットフォームとしての観点から

はじめに Couchbaseは、NoSQLドキュメント指向データベースです。この表現は間違ってはいませんが、若干正確性を欠いてもいます。つまり、Couchbaseという名前を持つデータベースには、Couchbase ServerとCouchbase Liteというふたつが存在します。 ここではCouchbaseについて、そのモバイル/エッジコンピューティングプラットフォームとしての側面について解説します。この側面においては、Couchbase ServerとCouchbase Liteの両方が重要な役割を持ちます。 以下、上述の本題に進む前にいくつか基本的な部分を整理します。 NoSQLドキュメント指向データベースについて NoSQLドキュメント指向データベースについては、パブリッククラウドが提供する独自のテクノロジーを除くと、代表的なものとして以下の3つをあげることができます。 MongoDB Apache CouchDB Couchbase Server Couchbase Serverについて Couchbase Serverについて、2021年11月に、以下の書籍を上梓しました。 Couchbase Serverに関しては、上記書籍に日本語で読める最新の情報が整理されています。 オンデマンド版のページの試し読みでは、ふたりの登場人物の会話形式によるプロローグを読むことができます。また、Kindle版のサンプルを入手いただくと、「第1章 Couchbase Serverとは何か」を全てお読みいただくことが可能です。 MongoDBとの違いや、Apache CouchDBとの関係などは、こちらをご参照ください。 モバイル/エッジコンピューティングプラットフォームとしてのCouchbase モバイル(組込)データベース モバイルアプリケーションで利用することのできるデータベースとしては、SQLiteが筆頭に上げられるのではないかと思います。あるいは、iOS開発者であれば、Core Dataを思い出すかもしれません。また、オブジェクト指向モバイルデータベースとして、Realmが知られています。 Couchbase Liteについて Couchbase Liteは、NoSQLドキュメント指向モバイル(組込)データベースです。KotlinやSwiftなどの開発言語を利用して、AndroidやiOSアプリケーションで利用することができるだけでなく、JVM環境でも動かすことができます。また、3.0.0ベータでは、C/C++環境でも利用することができるようになりました。これにより、エッジデバイスでの利用の幅が格段に広がっています。 Couchbase Mobileとは何か? ここで、新しい言葉が登場します。「Couchbase Mobile」とは、単体のデータベースないし何らかのテクノロジーを指す言葉ではなく、Couchbase LiteとSync Gatewayを含むカテゴリーとして用いられています。 Sync Gatewayとは、Couchbase LiteとCouchbase Serverとのデータ同期を行う技術です(具体的には、Couchbase Serverともに用いられる、Couchbase LiteとCouchbase Serverとの間でデータ同期を仲介するサーバーソフトウェアです)。 モバイルデータベースの重要性 スマートフォン上で利用されるモバイルアプリケーションの多くは、サーバーとデータを通信しながら稼働します。モバイルデータベースといえども、その中に格納されるデータは、モバイル端末で作成・保存・利用されれば十分なものばかりとは限らず、サーバーとの間で、データのやりとり〜サーバーからのデータの取得、サーバーへのデータの登録・更新〜が行われるデータを含むことがあります。サーバー(データベース)との間で、データ通信が行われる場合に、あえてローカルデータベースを持つ理由・利点としては、以下があります。 ネットワーク依存の分離 通信ロジックの分離 それぞれについて、以下解説します。 ネットワーク依存の分離 オフラインファーストアプリケーション ローカルデータベースを持つことによって、ネットワーク通信が行えない環境でもデータを利用しつつ、ネットワークが回復した場合にデータの同期を行う、という設計を指して「オフラインファーストアプリケーション」という言葉が使われます。 航空会社の従業員用のアプリケーションというユースケースもありますが、「ネットワーク通信が行えない環境」と言われても、非常に特殊なケースという印象を持つ方も多いかもしれません。その意味では、「オフラインファーストアプリケーション」は、「ユーザーエクスペリエンス向上」の中の一形態と考えた方が良いかもしれません。 ユーザーエクスペリエンス向上 モバイルデータベースを持たないアプリケーションが、サーバーからデータを取得して表示する場合、ユーザーがアプリケーションを立ち上げてから、データが画面が表示されるまでの期間は、ユーザー体験として、空白期間(単なる待ち時間)になります。これはウェブアプリケーションに典型的に見られる状況ということがいえます。 モバイルデータベースの存在は、「ネットワーク依存」をアプリケーション操作から分離するために役立たせることができます。当然ながら、モバイルデータベースを導入するだけで自動的に解決するようなものではなく、開発者が適切にユーザー体験をデザインする必要がありますが、ローカルデータベースとアプリケーションの関係をこうした観点から整理することができます。 通信ロジックの分離 ここまで、モバイルデータベースの意義について、ユーザーの利便性の観点から見てきました。「オフラインファーストアプリケーション」にしろ「ユーザーエクスペリエンス向上」にしろ、必要なのは、データの扱いをデザインすることであり、モバイルデータベースの導入は、その手段でしかないといえます。 そもそもデータベースは、アプリケーションアーキテクチャーにおける、データとロジックの分離を可能にするために存在しています。つまり、汎用的な処理(データマネジメント)を信頼性のある既存のテクノロジーに任せることで、開発者は利益を得ています。ローカルデータベースとリモートデータベースの同期を、データベースプロダクト(データプラットフォーム)に任せることにより、開発者はさらに、通信ロジックについても、信頼性のある既存のテクノロジーに任せることが可能になります。 これには、次のような副産物が伴います。 プレビルトデータベース アプリケーションが利用するデータを、アプリケーションインストール時に、データベースとして同梱することにより、初期データへのアクセスと、それ以外のデータへのアクセス手法を区別する必要がなくなります。 マルチプラットフォーム/チャネル 同じサービスをモバイルアプリケーションとウェブアプリケーションの両方の携帯で提供することは珍しくありません。このような場合に、モバイルアプリケーションではローカルデータベースを利用し、ウェブアプリケーションでは、ローカルデータベースと同期されたデータベースを利用することが考えられます。結果として、全体として、統一されたテクノロジーを採用することができます。 最後に 本稿では、データプラットフォームとして、Couchbaseを採用する意義について、モバイル/エッジコンピューティングプラットフォームとしての観点から概念的に整理しました。 テクノロジーを理解し、選定する際には、全体像、そして個々の要素について、具体的に把握することが欠かせません。 Couchbase Mobileについての記事を以下の投稿で整理していますので、ご関心に応じて、参照してみてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUI の中で UIView を使うための UIViewRepresentable を一通り理解する

概要 SwiftUI で開発していると、 UIKit で使っていた機能に対応するコンポーネントがなくて困るということがよく起こります。そんなときに SwiftUI の中で UIKit を使えるようにしてしまえばめちゃくちゃ便利じゃん...という素人の発想を実現してくれるのが UIViewRepresentable です。とくに SwiftUI があまり成熟していなかった iOS 13 をサポートしているアプリでは UIViewRepresentable に頼る場面が多くなるのではないでしょうか。 UIViewRepresentable の API は非常にシンプルなので既存の利用例をざっと見ただけでも使えてしまうのですが、一度ちゃんと理解しておこうと思って調べたのでまとめます。もしこの記事に修正・改善すべき点があればコメントで教えていただけると助かります。 検証環境は以下です。 Xcode 13.2 Beta 2 iOS 15.2 使い方 まずは UIViewRepresentable の基本的な使い方についてです。SwiftUI の中で UIView を使いたいと思ったときにやることは、 UIView をラップする UIViewRepresentable を作り、そのメソッドとして makeUIView と updateUIView を実装する 作った UIViewRepresentable を SwiftUI の View の中で呼び出す の2つだけです。 UIViewRepresentable を作る方から見ていきます。まずは冷静に UIViewRepresentable のシグネチャを眺めてみましょう。 上記のドキュメントから必要な情報のみ抜粋したものを以下に示します。 public protocol UIViewRepresentable : View where Self.Body == Never { // どの型の UIView をラップしたいかを表す associatedtype UIViewType : UIView // ラップしたい UIView を作って返す func makeUIView(context: Self.Context) -> Self.UIViewType // データの更新に対応してラップしている UIView を更新する func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) } どの UIView をラップするかが associatedtype の UIViewType として定義されており、これが protocol のメソッドのシグネチャにも使われています。UILabel をラップしたいときには UILabel を、UIScrollView をラップしたいときには UIScrollView を UIViewType に指定します。 ユーザ側での実装が必須なメソッドは2つです。 まず、makeUIView でラップしたい UIViewType のインスタンスを生成して返します。例えば UILabel や UIScrollView のイニシャライザを呼んで、必要な設定をしてからそれを return することになるでしょう。 updateUIView は UIViewRepresentable が更新されるたびに呼ばれるメソッドです。現在の UIView が引数として渡されてくるので、更新されたデータや Environment を UIView に反映させるということを行います。 実際に使用例を見てみましょう。よくあるカウンター画面でカウントの表示に UILabel を使いたくなったときのことを考えます。念のため補足すると、この例は説明のための人工的なもので、同等のことを SwiftUI の Text で実現できるため UIViewRepresentable を使う必然性はありません。 Int 型の count を受け取って現在のカウントを表示する UILabel をラップする UIViewRepresentable を書いてみます。 struct CounterLabelRepresentable: UIViewRepresentable { // 受け取りたいデータをプロパティとして定義する let count: Int func makeUIView(context: Context) -> UILabel { UILabel() } func updateUIView(_ uiLabel: UILabel, context: Context) { uiLabel.text = "I am a UILabel and count is \(count)" } } UIViewRepresentable のシグネチャと見比べると、今回の CounterLabelRepresentable は associatedtype の UIViewType に UILabel を指定していることがわかると思います。makeUIView で UILabel のインスタンスを生成して返し、updateUIView で現在の count を表示に反映させています。 表示に必要なデータである count はプロパティとして定義します。ここで、プロパティは @Binding や @State にもできますが、この例は受け取った count を表示するだけの受動的な View なのでふつうのプロパティにしています。 続いて、この CounterLabelRepresentable を実際に使ってみましょう。 struct CounterView: View { @State var count: Int = 0 var body: some View { VStack(alignment: .leading) { Button(action: { count += 1 }) { Text("Increment") } // 作った UIViewRepresentable を呼び出すだけ CounterLabelRepresentable(count: count) .frame(height: 44) } .padding() } } 注目したいのは、CounterLabelRepresentable の実体が UIKit の UILabel であるにも関わらず呼び出し側からは SwiftUI の View であるかのように見えていることです。もちろん modifier もつけることができ、ここでは .frame で高さを指定しています。 UIViewRepresentable の利用者側からはその実装が UIView であることを意識する必要がないというのはとてもよい設計だと思います。 この画面は以下のように動作します。 各メソッドとライフサイクル 基本的な使い方がわかったところで、UIViewRepresentable の各メソッドについてもう少し詳しく調べてみます。Coordinator に関してはのちほど説明するので、ここでは Coordinator が関わらないメソッドのみ見ることにします。 ここからは UIViewRepresentable のライフサイクルという概念が出てきますが、これは UIViewRepresentable に対応する View が生成されてから消去されるまでのことを表していて、UIViewRepresentable の protocol に準拠している struct の寿命とは関係がないことに注意してください。基本的に View は生成されると非表示になるまで生き続けますが、その View を記述する struct は再描画のたびに作り直されます。 このあたりの SwiftUI の View のライフサイクルの話は難しくて自分が完全に理解できているか怪しいのですが、以下の WWDC の2つのセッションを見れば概要は掴めると思います。 Data Essentials in SwiftUI (WWDC 2020) Demystify SwiftUI func makeUIView makeUIView は UIViewRepresentable のライフサイクルの中で一度だけ最初に呼ばれ、ラップする対象の UIView を生成します。一度だけ行えばよい、データに関わらず不変な設定処理などを書くのがよいでしょう。例えば、UILabel をラップする場合はフォントサイズやカラーの設定などが考えられます。また、AutoLayout を設定する必要がある場合もここでやることが多くなると思います。 func updateUIView updateUIView は makeUIView が呼ばれた直後に一度、その後は View の状態が更新されるたびに呼ばれます。その性質上、データを View に反映させる責任は makeUIView ではなく updateUIView にあります。もちろん変化することがないデータの反映や、データが変化する場合も初期データの反映だけは makeUIView で行うことが可能ですが、いずれにしても makeUIView の直後に updateUIView が呼ばれるのでデータの反映は updateUIView のみで行うのがわかりやすいと思います。 static func dismantleUIView dismantleUIView は UIViewRepresentable のライフサイクルの終わりに呼ばれ、もしなんらかの後処理が必要であればここで行います。subscription の解除や、この View が消えることをきっかけにアプリの他の画面で何かしたい場合の通知などが考えられます。 余談ですが、dismantle というのは取り外すとか分解するみたいな意味らしいです。 各メソッドの呼び出しタイミング ここまでに上げたメソッドがいつ呼び出されるかを検証してみましょう。検証に使う例として、先ほどのカウンターを以下のように変えたものを用意します。 CounterLabelRepresentable の各メソッドや親 View の body にデバッグ出力を追加 中身の UIView のライフサイクルも知りたいのでラップする UIView を UILabel から自前の MyUILabel に変更。機能は同じだが init / deinit 時にデバッグ出力を追加 UIViewRepresentable の表示 / 非表示を切り替える Toggle を追加 struct CounterView: View { @State var count: Int = 0 @State var showingLabel: Bool = true var body: some View { let _ = print("Parent \(#function)") VStack(alignment: .leading) { Toggle(isOn: $showingLabel) { Text("Showing Label") } Button(action: { count += 1 }) { Text("Increment") } if showingLabel { CounterLabelRepresentable(count: count) .frame(height: 44) } } .padding() } } struct CounterLabelRepresentable: UIViewRepresentable { let count: Int init(count: Int) { print("CounterLabelRepresentable \(#function)") self.count = count } func makeUIView(context: Context) -> MyUILabel { print("Representable \(#function)") return MyUILabel() } func updateUIView(_ uiLabel: MyUILabel, context: Context) { print("CounterLabelRepresentable \(#function)") uiLabel.text = "I am a UILabel and count is \(count)" } static func dismantleUIView(_ uiLabel: MyUILabel, coordinator: ()) { print("CounterLabelRepresentable \(#function)") } } class MyUILabel: UILabel { override init(frame: CGRect) { print("UILabel \(#function)") super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { print("UILabel \(#function)") } } 動作は Toggle が加わったこと以外は同じです。 以下、いくつかの操作に対してどのメソッドが呼ばれるかをデバッグ出力から見ていきます。 表示時 まず、初回の表示時のデバッグ出力を見てみます。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable makeUIView(context:) UILabel init(frame:) CounterLabelRepresentable updateUIView(_:context:) 親 View の body が呼ばれ、body に含まれる CounterLabelRepresentable struct の init が呼ばれています。その後、makeUIView が呼ばれ、実体の UILabel のインスタンスが生成されています。最後に、updateUIView が呼ばれていることがわかります。データ更新時だけでなく初回の表示時にも updateUIView が呼ばれることは UIViewRepresentable を使う上で知っておく必要があります。 更新時 続いて、Increment ボタンをタップして count を変化させます。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable updateUIView(_:context:) count が変化することにより親 View の body が再評価され、updateUIView が呼ばれて表示が変化します。ここで init により CounterLabelRepresentable の struct は初期化されていますが、その裏にある UILabel は作り直されず、既存のインスタンスが使いまわされていることがわかります。UIView のインスタンス生成は View の struct と比べてコストが高いため、パフォーマンスを落とさないようにインスタンスが使い回される設計になっているのだと思います。 非表示時 続いて、Toggle をタップして CounterLabelRepresentable を非表示にしてみます。 Parent body CounterLabelRepresentable updateUIView(_:context:) CounterLabelRepresentable dismantleUIView(_:coordinator:) UILabel deinit 非表示になったことにより dismantleUIView が呼ばれ、その後裏の UILabel も deinit されています。このあと再び Toggle をタップして CounterLabelRepresentable を表示すると、初回表示と同じように CounterLabelRepresentable と UILabel が生成されます。裏の UIView ができるだけ使いまわされると言っても、さすがに非表示 -> 表示するとインスタンスは作り直されるようです。 親 View のみ再描画時 ここまでは CounterLabelViewRepresentable に関わる状態が変化したときの振る舞いを見てきましたが、親 View の body は再描画されるけど CounterLabelViewRepresentable が依存するデータには変化がないときにどのメソッドが呼ばれるのかも気になります。 CounterLabelViewRepresentable に関係ないデータとして、画面の背景色を親 View の状態として持たせて Toggle で更新できるようにしましょう。追加の差分のみを示します。 struct CounterView: View { @State var backgroundColored: Bool = false var body: some View { // ... VStack(alignment: .leading) { Toggle(isOn: $backgroundColored) { Text("Coloring Background") } // ... } // ... .background(backgroundColored ? Color.blue.opacity(0.2) : Color.clear) } } 動作は以下のようになります。 ここで、背景色の有無を切り替えたときのデバッグ出力は以下です。 Parent body CounterLabelRepresentable init(count:) @State が更新されたことによって親 View の body の再評価がされ、それに伴い CounterLabelRepresentable が init で初期化されています。ただし count を変化させたときと違って updateUIView が走らないことがわかります。この例は CounterLabelRepresentable が依存するデータが変化していないので再描画の必要はなく、パフォーマンスの観点からは望ましい振る舞いであると言えます。裏側でどのように実現されているのかは想像するしかないと思いますが、 SwiftUI は各 View がどのデータに依存しているのかを把握し、データの更新が発生するとそのデータを使っている View のみを再描画するのだと思います。 Environment ここまでは UIViewRepresentable の表示に必要なデータを引数として渡す場合について見てきました。実は、通常の SwiftUI の View と同じように UIViewRepresentable は Environment からも値を受け取ることができます。 カウンターの例を少し書き変えて、親 View から CounterLabelRepresentable に disabled modifier をつけて、値を切り替えられるようにします。 CounterLabelRpresentable の側では、引数の updateUIView の引数の context の environment.isEnabled からその値が取得できるので、それに応じてテキストの色を変化させます。 struct CounterView: View { // ... @State var enabled: Bool = true var body: some View { VStack(alignment: .leading) { Toggle(isOn: $enabled) { Text("Enabling Label") } // ... if showingLabel { CounterLabelRepresentable(count: count) .frame(height: 44) .disabled(!enabled) } } // ... } } struct CounterLabelRepresentable: UIViewRepresentable { func updateUIView(_ uiLabel: MyUILabel, context: Context) { // ... uiLabel.textColor = context.environment.isEnabled ? .black : .gray } } 動作させてみると、以下のように Environment の変化が表示に反映させていることがわかります。ここで、現在の Environment を UIViewRepresentable に反映するのも makeUIView ではなく updateUIView で行う必要があることに注意してください。 disabled を切り替えたときのデバッグ出力は以下で、Environment が変化すると引数の値が変わったときと同じようにメソッドが呼ばれていることがわかります。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable updateUIView(_:context:) Coordinator CounterLabelRepresentable は SwiftUI の親 View から情報を渡されるだけの受動的なコンポーネントでしたが、UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator という仕組みが必要になります。 簡単な例として、カウンター画面の Increment ボタンを UIButton で書き直す例を考えてみます。以下のようになるでしょう。 struct IncrementButtonRepresentable: UIViewRepresentable { let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } func makeUIView(context: Context) -> UIButton { let button = UIButton(type: .system) button.setTitle("I am a UIButton and Increment", for: .normal) // TODO: onTap を呼んで count を increment する return button } func updateUIView(_ uiButton: UIButton, context: Context) {} } コード中に TODO としてコメントしたように、現状のコードだと親 View から渡されたボタンタップ時のアクション onTapped を発火させる方法がありません。最初に思いつくのは以下のように UIViewRepresentable にアクションを定義してしまうことでしょう。しかし、このコードはコンパイルエラーになってしまいます。 struct IncrementButtonRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UIButton { // ... button.addTarget(self, action: #selector(didTap), for: .touchUpInside) // ... } // ... // ❌ @objc can only be used with members of classes, @objc protocols, and concrete extensions of classes @objc func didTap() { onTapped() } } コンパイルエラーの原因は struct には @objc メソッドを定義できないことですが、仮に文法上許されていたとしても UIViewRepresentable の struct はいつ親 View の再描画により作り直されるかわからない一時的な存在なのでアクションの定義先としては不適切でしょう。 このこと考えると、ラップする UIView と同じライフサイクルを持ち、かつ @objc メソッドが定義可能な class にアクションを定義したいです。そんな都合のよいものないよな...と思いきやちゃんとあって、それが Coordinator です。 Coordinator は UIViewRepresentable がラップする UIView のデリゲートとして働きます。このデリゲートというのは UIView のアクションに対応して何かするものというくらい意味で、UIScrollViewDelegate や UISearchBarDelegate のような Delegate と名のつく protocol であることもありますし、今回の例のように単に UIButton のアクションの定義先であることもあります。Delegate protocol に Coordinator を使う例としては、例えば以下の記事を参照ください。 Coordinator を使う上でやることは以下です。 Coordinator として使う class を定義する。UIViewRepresentable にネストさせて Coordinator という名前で定義することが多い UIViewRepresentable の makeCoordinator メソッドの中で Coordinator を生成して返す makeUIView / updateUIView メソッドの引数の context.coordinator で Coordinator が取得できるので Coordinator に行わせたい処理を設定する IncrementButtonRepresentable を Coordinator を使うように修正すると、例えば以下のようになるでしょう。 struct CounterView: View { @State var count: Int = 0 // ... var body: some View { // ... VStack(alignment: .leading) { // ... IncrementButtonRepresentable(onTapped: { count += 1 }) .frame(height: 44) // ... } // ... } } struct IncrementButtonRepresentable: UIViewRepresentable { final class Coordinator { let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } @objc func didTap() { onTapped() } } let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } func makeCoordinator() -> Coordinator { return Coordinator(onTapped: onTapped) } func makeUIView(context: Context) -> UIButton { let button = UIButton(type: .system) button.setTitle("I am a UIButton and Increment", for: .normal) button.addTarget(context.coordinator, action: #selector(Coordinator.didTap), for: .touchUpInside) return button } func updateUIView(_ uiButton: UIButton, context: Context) {} } 以下のようにきちんと動作するようになります。ちなみに、デバッグ出力を仕込むと、Coordinator は IncrementButtonRepresentable がラップする UIButton と同じタイミングで生成・消滅することがわかります。 まとめ UIViewRepresentable は SwiftUI の中で一部のみ UIView を使うためのしくみ makeUIView は初期化時に一度だけ、updateUIView は初期化時とその後の状態更新時に呼ばれる。データを表示に反映するのは updateUIView で行う 実体の UIView のインスタンスは状態更新のたびに作り直されるのではなく、非表示になるまで使い回される UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator を使う 参考 https://developer.apple.com/documentation/swiftui/uiviewrepresentable https://developer.apple.com/documentation/swiftui/uiviewrepresentablecontext https://www.hackingwithswift.com/books/ios-swiftui/using-coordinators-to-manage-swiftui-view-controllers https://swiftontap.com/UIViewRepresentable https://www.swiftbysundell.com/articles/swiftui-and-uikit-interoperability-part-1/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UIViewRepresentable を一通り理解する

概要 SwiftUI で開発していると、 UIKit で使っていた機能に対応するコンポーネントがなくて困るということがよく起こります。そんなときに SwiftUI の中で UIKit を使えるようにしてしまえばめちゃくちゃ便利じゃん...という素人の発想を実現してくれるのが UIViewRepresentable です。とくに SwiftUI があまり成熟していなかった iOS 13 をサポートしているアプリでは UIViewRepresentable に頼る場面が多くなるのではないでしょうか。 UIViewRepresentable の API は非常にシンプルなので既存の利用例をざっと見ただけでも使えてしまうのですが、一度ちゃんと理解しておこうと思って調べたのでまとめます。もしこの記事に修正・改善すべき点があればコメントで教えていただけると助かります。 検証環境は以下です。 Xcode 13.2 Beta 2 iOS 15.2 使い方 まずは UIViewRepresentable の基本的な使い方についてです。SwiftUI の中で UIView を使いたいと思ったときにやることは、 UIView をラップする UIViewRepresentable を作り、そのメソッドとして makeUIView と updateUIView を実装する 作った UIViewRepresentable を SwiftUI の View の中で呼び出す の2つだけです。 UIViewRepresentable を作る方から見ていきます。まずは冷静に UIViewRepresentable のシグネチャを眺めてみましょう。 上記のドキュメントから必要な情報のみ抜粋したものを以下に示します。 public protocol UIViewRepresentable : View where Self.Body == Never { // どの型の UIView をラップしたいかを表す associatedtype UIViewType : UIView // ラップしたい UIView を作って返す func makeUIView(context: Self.Context) -> Self.UIViewType // データの更新に対応してラップしている UIView を更新する func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) } どの UIView をラップするかが associatedtype の UIViewType として定義されており、これが protocol のメソッドのシグネチャにも使われています。UILabel をラップしたいときには UILabel を、UIScrollView をラップしたいときには UIScrollView を UIViewType に指定します。 ユーザ側での実装が必須なメソッドは2つです。 まず、makeUIView でラップしたい UIViewType のインスタンスを生成して返します。例えば UILabel や UIScrollView のイニシャライザを呼んで、必要な設定をしてからそれを return することになるでしょう。 updateUIView は UIViewRepresentable が更新されるたびに呼ばれるメソッドです。現在の UIView が引数として渡されてくるので、更新されたデータや Environment を UIView に反映させるということを行います。 実際に使用例を見てみましょう。よくあるカウンター画面でカウントの表示に UILabel を使いたくなったときのことを考えます。念のため補足すると、この例は説明のための人工的なもので、同等のことを SwiftUI の Text で実現できるため UIViewRepresentable を使う必然性はありません。 Int 型の count を受け取って現在のカウントを表示する UILabel をラップする UIViewRepresentable を書いてみます。 struct CounterLabelRepresentable: UIViewRepresentable { // 受け取りたいデータをプロパティとして定義する let count: Int func makeUIView(context: Context) -> UILabel { UILabel() } func updateUIView(_ uiLabel: UILabel, context: Context) { uiLabel.text = "I am a UILabel and count is \(count)" } } UIViewRepresentable のシグネチャと見比べると、今回の CounterLabelRepresentable は associatedtype の UIViewType に UILabel を指定していることがわかると思います。makeUIView で UILabel のインスタンスを生成して返し、updateUIView で現在の count を表示に反映させています。 表示に必要なデータである count はプロパティとして定義します。ここで、プロパティは @Binding や @State にもできますが、この例は受け取った count を表示するだけの受動的な View なのでふつうのプロパティにしています。 続いて、この CounterLabelRepresentable を実際に使ってみましょう。 struct CounterView: View { @State var count: Int = 0 var body: some View { VStack(alignment: .leading) { Button(action: { count += 1 }) { Text("Increment") } // 作った UIViewRepresentable を呼び出すだけ CounterLabelRepresentable(count: count) .frame(height: 44) } .padding() } } 注目したいのは、CounterLabelRepresentable の実体が UIKit の UILabel であるにも関わらず呼び出し側からは SwiftUI の View であるかのように見えていることです。もちろん modifier もつけることができ、ここでは .frame で高さを指定しています。 UIViewRepresentable の利用者側からはその実装が UIView であることを意識する必要がないというのはとてもよい設計だと思います。 この画面は以下のように動作します。 各メソッドとライフサイクル 基本的な使い方がわかったところで、UIViewRepresentable の各メソッドについてもう少し詳しく調べてみます。Coordinator に関してはのちほど説明するので、ここでは Coordinator が関わらないメソッドのみ見ることにします。 ここからは UIViewRepresentable のライフサイクルという概念が出てきますが、これは UIViewRepresentable に対応する View が生成されてから消去されるまでのことを表していて、UIViewRepresentable の protocol に準拠している struct の寿命とは関係がないことに注意してください。基本的に View は生成されると非表示になるまで生き続けますが、その View を記述する struct は再描画のたびに作り直されます。 このあたりの SwiftUI の View のライフサイクルの話は難しくて自分が完全に理解できているか怪しいのですが、以下の WWDC の2つのセッションを見れば概要は掴めると思います。 Data Essentials in SwiftUI (WWDC 2020) Demystify SwiftUI func makeUIView makeUIView は UIViewRepresentable のライフサイクルの中で一度だけ最初に呼ばれ、ラップする対象の UIView を生成します。一度だけ行えばよい、データに関わらず不変な設定処理などを書くのがよいでしょう。例えば、UILabel をラップする場合はフォントサイズやカラーの設定などが考えられます。また、AutoLayout を設定する必要がある場合もここでやることが多くなると思います。 func updateUIView updateUIView は makeUIView が呼ばれた直後に一度、その後は View の状態が更新されるたびに呼ばれます。その性質上、データを View に反映させる責任は makeUIView ではなく updateUIView にあります。もちろん変化することがないデータの反映や、データが変化する場合も初期データの反映だけは makeUIView で行うことが可能ですが、いずれにしても makeUIView の直後に updateUIView が呼ばれるのでデータの反映は updateUIView のみで行うのがわかりやすいと思います。 static func dismantleUIView dismantleUIView は UIViewRepresentable のライフサイクルの終わりに呼ばれ、もしなんらかの後処理が必要であればここで行います。subscription の解除や、この View が消えることをきっかけにアプリの他の画面で何かしたい場合の通知などが考えられます。 余談ですが、dismantle というのは取り外すとか分解するみたいな意味らしいです。 各メソッドの呼び出しタイミング ここまでに上げたメソッドがいつ呼び出されるかを検証してみましょう。検証に使う例として、先ほどのカウンターを以下のように変えたものを用意します。 CounterLabelRepresentable の各メソッドや親 View の body にデバッグ出力を追加 中身の UIView のライフサイクルも知りたいのでラップする UIView を UILabel から自前の MyUILabel に変更。機能は同じだが init / deinit 時にデバッグ出力を追加 UIViewRepresentable の表示 / 非表示を切り替える Toggle を追加 struct CounterView: View { @State var count: Int = 0 @State var showingLabel: Bool = true var body: some View { let _ = print("Parent \(#function)") VStack(alignment: .leading) { Toggle(isOn: $showingLabel) { Text("Showing Label") } Button(action: { count += 1 }) { Text("Increment") } if showingLabel { CounterLabelRepresentable(count: count) .frame(height: 44) } } .padding() } } struct CounterLabelRepresentable: UIViewRepresentable { let count: Int init(count: Int) { print("CounterLabelRepresentable \(#function)") self.count = count } func makeUIView(context: Context) -> MyUILabel { print("Representable \(#function)") return MyUILabel() } func updateUIView(_ uiLabel: MyUILabel, context: Context) { print("CounterLabelRepresentable \(#function)") uiLabel.text = "I am a UILabel and count is \(count)" } static func dismantleUIView(_ uiLabel: MyUILabel, coordinator: ()) { print("CounterLabelRepresentable \(#function)") } } class MyUILabel: UILabel { override init(frame: CGRect) { print("UILabel \(#function)") super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { print("UILabel \(#function)") } } 動作は Toggle が加わったこと以外は同じです。 以下、いくつかの操作に対してどのメソッドが呼ばれるかをデバッグ出力から見ていきます。 表示時 まず、初回の表示時のデバッグ出力を見てみます。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable makeUIView(context:) UILabel init(frame:) CounterLabelRepresentable updateUIView(_:context:) 親 View の body が呼ばれ、body に含まれる CounterLabelRepresentable struct の init が呼ばれています。その後、makeUIView が呼ばれ、実体の UILabel のインスタンスが生成されています。最後に、updateUIView が呼ばれていることがわかります。データ更新時だけでなく初回の表示時にも updateUIView が呼ばれることは UIViewRepresentable を使う上で知っておく必要があります。 更新時 続いて、Increment ボタンをタップして count を変化させます。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable updateUIView(_:context:) count が変化することにより親 View の body が再評価され、updateUIView が呼ばれて表示が変化します。ここで init により CounterLabelRepresentable の struct は初期化されていますが、その裏にある UILabel は作り直されず、既存のインスタンスが使いまわされていることがわかります。UIView のインスタンス生成は View の struct と比べてコストが高いため、パフォーマンスを落とさないようにインスタンスが使い回される設計になっているのだと思います。 非表示時 続いて、Toggle をタップして CounterLabelRepresentable を非表示にしてみます。 Parent body CounterLabelRepresentable updateUIView(_:context:) CounterLabelRepresentable dismantleUIView(_:coordinator:) UILabel deinit 非表示になったことにより dismantleUIView が呼ばれ、その後裏の UILabel も deinit されています。このあと再び Toggle をタップして CounterLabelRepresentable を表示すると、初回表示と同じように CounterLabelRepresentable と UILabel が生成されます。裏の UIView ができるだけ使いまわされると言っても、さすがに非表示 -> 表示するとインスタンスは作り直されるようです。 親 View のみ再描画時 ここまでは CounterLabelViewRepresentable に関わる状態が変化したときの振る舞いを見てきましたが、親 View の body は再描画されるけど CounterLabelViewRepresentable が依存するデータには変化がないときにどのメソッドが呼ばれるのかも気になります。 CounterLabelViewRepresentable に関係ないデータとして、画面の背景色を親 View の状態として持たせて Toggle で更新できるようにしましょう。追加の差分のみを示します。 struct CounterView: View { @State var backgroundColored: Bool = false var body: some View { // ... VStack(alignment: .leading) { Toggle(isOn: $backgroundColored) { Text("Coloring Background") } // ... } // ... .background(backgroundColored ? Color.blue.opacity(0.2) : Color.clear) } } 動作は以下のようになります。 ここで、背景色の有無を切り替えたときのデバッグ出力は以下です。 Parent body CounterLabelRepresentable init(count:) @State が更新されたことによって親 View の body の再評価がされ、それに伴い CounterLabelRepresentable が init で初期化されています。ただし count を変化させたときと違って updateUIView が走らないことがわかります。この例は CounterLabelRepresentable が依存するデータが変化していないので再描画の必要はなく、パフォーマンスの観点からは望ましい振る舞いであると言えます。裏側でどのように実現されているのかは想像するしかないと思いますが、 SwiftUI は各 View がどのデータに依存しているのかを把握し、データの更新が発生するとそのデータを使っている View のみを再描画するのだと思います。 Environment ここまでは UIViewRepresentable の表示に必要なデータを引数として渡す場合について見てきました。実は、通常の SwiftUI の View と同じように UIViewRepresentable は Environment からも値を受け取ることができます。 カウンターの例を少し書き変えて、親 View から CounterLabelRepresentable に disabled modifier をつけて、値を切り替えられるようにします。 CounterLabelRpresentable の側では、引数の updateUIView の引数の context の environment.isEnabled からその値が取得できるので、それに応じてテキストの色を変化させます。 struct CounterView: View { // ... @State var enabled: Bool = true var body: some View { VStack(alignment: .leading) { Toggle(isOn: $enabled) { Text("Enabling Label") } // ... if showingLabel { CounterLabelRepresentable(count: count) .frame(height: 44) .disabled(!enabled) } } // ... } } struct CounterLabelRepresentable: UIViewRepresentable { func updateUIView(_ uiLabel: MyUILabel, context: Context) { // ... uiLabel.textColor = context.environment.isEnabled ? .black : .gray } } 動作させてみると、以下のように Environment の変化が表示に反映させていることがわかります。ここで、現在の Environment を UIViewRepresentable に反映するのも makeUIView ではなく updateUIView で行う必要があることに注意してください。 disabled を切り替えたときのデバッグ出力は以下で、Environment が変化すると引数の値が変わったときと同じようにメソッドが呼ばれていることがわかります。 Parent body CounterLabelRepresentable init(count:) CounterLabelRepresentable updateUIView(_:context:) Coordinator CounterLabelRepresentable は SwiftUI の親 View から情報を渡されるだけの受動的なコンポーネントでしたが、UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator という仕組みが必要になります。 簡単な例として、カウンター画面の Increment ボタンを UIButton で書き直す例を考えてみます。以下のようになるでしょう。 struct IncrementButtonRepresentable: UIViewRepresentable { let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } func makeUIView(context: Context) -> UIButton { let button = UIButton(type: .system) button.setTitle("I am a UIButton and Increment", for: .normal) // TODO: onTap を呼んで count を increment する return button } func updateUIView(_ uiButton: UIButton, context: Context) {} } コード中に TODO としてコメントしたように、現状のコードだと親 View から渡されたボタンタップ時のアクション onTapped を発火させる方法がありません。最初に思いつくのは以下のように UIViewRepresentable にアクションを定義してしまうことでしょう。しかし、このコードはコンパイルエラーになってしまいます。 struct IncrementButtonRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UIButton { // ... button.addTarget(self, action: #selector(didTap), for: .touchUpInside) // ... } // ... // ❌ @objc can only be used with members of classes, @objc protocols, and concrete extensions of classes @objc func didTap() { onTapped() } } コンパイルエラーの原因は struct には @objc メソッドを定義できないことですが、仮に文法上許されていたとしても UIViewRepresentable の struct はいつ親 View の再描画により作り直されるかわからない一時的な存在なのでアクションの定義先としては不適切でしょう。 このこと考えると、ラップする UIView と同じライフサイクルを持ち、かつ @objc メソッドが定義可能な class にアクションを定義したいです。そんな都合のよいものないよな...と思いきやちゃんとあって、それが Coordinator です。 Coordinator は UIViewRepresentable がラップする UIView のデリゲートとして働きます。このデリゲートというのは UIView のアクションに対応して何かするものというくらい意味で、UIScrollViewDelegate や UISearchBarDelegate のような Delegate と名のつく protocol であることもありますし、今回の例のように単に UIButton のアクションの定義先であることもあります。Delegate protocol に Coordinator を使う例としては、例えば以下の記事を参照ください。 Coordinator を使う上でやることは以下です。 Coordinator として使う class を定義する。UIViewRepresentable にネストさせて Coordinator という名前で定義することが多い UIViewRepresentable の makeCoordinator メソッドの中で Coordinator を生成して返す makeUIView / updateUIView メソッドの引数の context.coordinator で Coordinator が取得できるので Coordinator に行わせたい処理を設定する IncrementButtonRepresentable を Coordinator を使うように修正すると、例えば以下のようになるでしょう。 struct CounterView: View { @State var count: Int = 0 // ... var body: some View { // ... VStack(alignment: .leading) { // ... IncrementButtonRepresentable(onTapped: { count += 1 }) .frame(height: 44) // ... } // ... } } struct IncrementButtonRepresentable: UIViewRepresentable { final class Coordinator { let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } @objc func didTap() { onTapped() } } let onTapped: () -> Void init(onTapped: @escaping () -> Void) { self.onTapped = onTapped } func makeCoordinator() -> Coordinator { return Coordinator(onTapped: onTapped) } func makeUIView(context: Context) -> UIButton { let button = UIButton(type: .system) button.setTitle("I am a UIButton and Increment", for: .normal) button.addTarget(context.coordinator, action: #selector(Coordinator.didTap), for: .touchUpInside) return button } func updateUIView(_ uiButton: UIButton, context: Context) {} } 以下のようにきちんと動作するようになります。ちなみに、デバッグ出力を仕込むと、Coordinator は IncrementButtonRepresentable がラップする UIButton と同じタイミングで生成・消滅することがわかります。 まとめ UIViewRepresentable は SwiftUI の中で一部のみ UIView を使うためのしくみ makeUIView は初期化時に一度だけ、updateUIView は初期化時とその後の状態更新時に呼ばれる。データを表示に反映するのは updateUIView で行う 実体の UIView のインスタンスは状態更新のたびに作り直されるのではなく、非表示になるまで使い回される UIViewRepresentable からなんらかのアクションを起こしたい場合は Coordinator を使う 参考 https://developer.apple.com/documentation/swiftui/uiviewrepresentable https://developer.apple.com/documentation/swiftui/uiviewrepresentablecontext https://www.hackingwithswift.com/books/ios-swiftui/using-coordinators-to-manage-swiftui-view-controllers https://swiftontap.com/UIViewRepresentable https://www.swiftbysundell.com/articles/swiftui-and-uikit-interoperability-part-1/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOSで位置情報やモーションを使うのときの権限要求あれこれ

仕事でiOSアプリを作っていて、妙なところでハマってしまったので備忘録までに。 位置情報やモーション フィットネスを使うための事前設定 任意のタイミングで許諾を求める処理 アプリを開いたときの権限不足チェック 位置情報やモーション フィットネスを使うための事前設定 アプリを作成したら、info.plistに位置情報とモーションの項目を追加します Targets > Signing&Capabilitiesの左上の「+Capability」からBackgroundModesを選択すると項目が追加されます バックグラウンドでアクセスする処理を追加しておきます これにてアプリ側の初期設定は終了かなと思います 任意のタイミングで許諾を求める処理 例えばウォークスルーの途中など、ユーザとコミュニケーションを撮っている流れの中で許諾を取りたい場面もあるかと思います SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate, CLLocationManagerDelegate { // CLLocationManagerDelegate private let locationManager = CLLocationManager() private let motionManager = CMMotionActivityManager() var window: UIWindow? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). guard let _ = (scene as? UIWindowScene) else { return } self.locationManager.requestAlwaysAuthorization() self.locationManager.delegate = self self.motionManager.startActivityUpdates( to: OperationQueue.current!) { (data: CMMotionActivity?) in /* do nothing */ } self.motionManager.stopActivityUpdates() } 〜 略 〜 } ハマったポイントとしては、 位置情報のアクセスを求めるときは、delegateをしないといけない モーションには位置情報のように権限リクエストするメソッドが見当たらず、一瞬start/stopして動かしている アプリを起動すると、位置情報アクセスとモーションフィットネスへのアクセスを要求するダイアログが出るようになりました アプリを開いたときの権限チェック アプリを使っている途中やウォークスルーで許可しそびれたときに、アプリ側から権限チェックをする機能です。アプリを起動して最前面にもっていくごとにチェックするため、SceneDelegateからダイアログを表示しています SceneDelegate.swift class SceneDelegate: UIResponder, UIWindowSceneDelegate, CLLocationManagerDelegate { private let locationManager = CLLocationManager() private let motionManager = CMMotionActivityManager() 〜 略 〜 func sceneDidBecomeActive(_ scene: UIScene) { if #available(iOS 14.0, *) { if locationManager.authorizationStatus != .authorizedAlways { showPermitDialog(title: "権限が不足しています", body: "位置情報アクセスを「常に許可」にしてください") } if locationManager.accuracyAuthorization != .fullAccuracy { showPermitDialog(title: "権限が不足しています", body: "正確な位置情報を許可にしてください") } } else { if CLLocationManager.authorizationStatus() != .authorizedAlways { showPermitDialog(title: "権限が不足しています", body: "位置情報アクセスを「常に許可」にしてください") } } if CMMotionActivityManager.authorizationStatus() != .authorized { showPermitDialog(title: "権限が不足しています", body: "モーション&フィットネスを許可にしてください") } if ProcessInfo.processInfo.isLowPowerModeEnabled { showPermitDialog(title: "権限が不足しています", body: "低電力モードを無効にしてください") } } private func showPermitDialog(title: String, body: String) { let dialog = UIAlertController(title: title, message: body, preferredStyle: .alert) dialog.addAction(UIAlertAction(title: "設定を開く", style: .default, handler: { (action: UIAlertAction!) -> Void in if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } })) dialog.addAction(UIAlertAction(title: "キャンセル", style: .cancel, handler: nil)) self.window?.rootViewController?.present(dialog, animated: true, completion: nil) } 〜 略 〜 }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SNSブロック機能

今回はSNSのアプリをリリースする際に必要となるブロック機能を実装していきたいと思います。 まず初めにブロック機能とは、SNSで閲覧したくないものをブロックすることでタイムライン上に表示されないようにするために実装します。 サンプルとしてNCMBを用いて取得したユーザーのオブジェクトIDをブロックする対象とします。 ここでは、NCMBのデータストアにブロッククラスを作成し、タイムライン上に表示させたくない投稿の投稿者のオブジェクトIdをアップロードしブロック機能を実装します。 前提として、Instagram風SNSアプリを作ろう[リリース編❷]報告機能の実装は完了しているものとし、ViewControllerのタイムラインTableViewのカスタムセルのデリゲートメソッドの一つとしてブロック機能を実装しています。 完成サンプル 一番下にコードの全容があるので、コピぺしたかったらそこまで飛んでつけてみてください! 目次   1. ブロック機能の理論の話   2. 作り方の説明! 1. ブロック機能の理論の話 ブロック機能は →1.非表示にしたいユーザーをブロックする作業 →2.ブロックしたユーザーの投稿を非表示にする作業 の2つの手順に分けることができます! 2. 作り方の説明 1.非表示にしたいユーザーをブロックする作業  →1.1タイムラインのViewControlerでアラートコントローラーのアクションの設置→→ViewController  →1.2ブロック機能のコード→→ViewController 2.ブロックしたユーザーの投稿を非表示にする機能の仕組み 初期状態 func didTapMenuButton(targetCell tableViewCell: UITableViewCell, targetButton button: UIButton) { let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let cancelAction = UIAlertAction(title: "キャンセル", style: .cancel) { (action) in alertController.dismiss(animated: true, completion: nil) } //selctedPostUserは選択してる投稿の投稿者を指しています。 let selectedPostUser = posts[tableViewCell.tag].object(forKey: "user") as! NCMBUser //投稿者がログインユーザーか否かを条件に分岐している if selectedPostUser.object(forKey: "objectId") as! String == NCMBUser.current()?.objectId{ // ログインユーザーの投稿を削除する時の一連の流れ let deleteAction = UIAlertAction(title: "削除する", style: .destructive) { (action) in //削除機能のコード } alertController.addAction(deleteAction) alertController.addAction(cancelAction) }else{ //ログインユーザーの投稿でない場合のながれ let reportAction = UIAlertAction(title: "報告する", style: .destructive) { (action) in //報告機能のコード } alertController.addAction(reportAction) alertController.addAction(cancelAction) } self.present(alertController, animated: true, completion: nil) } 1. 非表示にしたいユーザーをブロックする作業 1.1アラートコントローラーにアクションの設置 ここではdidTapMenuButton関数の内容を記述します。 今回はアラートコントローラーを設置し、報告機能や削除機能のようにアクションの一つとしてブロック機能を実装します。 そのため操作するのはViewControllerです。 let blockAction = UIAlertAction(title: "ブロックする", style: .default) { (action) in HUD.show(.progress, onView: self.view) //NCMBのBlockクラスにオブジェクトを作成する let object = NCMBObject(className: "Block") //"blockUserId"をキーとしてブロックされる側(投稿者)のオブジェクトIDを格納 let user = self.posts[tableViewCell.tag].object(forKey: "user") as! NCMBUser object?.setObject(user.objectId, forKey: "blockUserId") //"user"をキーとしてブロックする側(現在ログインしているユーザー)をNCMBUser型として格納 object?.setObject(NCMBUser.current(), forKey: "user") //NCMB上にオブジェクトを保存 object?.saveInBackground({ (error) in if error != nil { HUD.show(.progress, onView: self.view) } else { HUD.hide(animated: true) //ここで③を読み込んでいる //self.getBlockUser() } }) } 上記のブロックアクションのコードをdidTapMenuButton関数のログインユーザーの投稿でない場合の流れの中に記述します。 1.2ブロック機能のコード func didTapMenuButton(targetCell tableViewCell: UITableViewCell, targetButton button: UIButton) { let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let cancelAction = UIAlertAction(title: "キャンセル", style: .cancel) { (action) in alertController.dismiss(animated: true, completion: nil) } //selctedPostUserは選択してる投稿の投稿者を指しています。 let selectedPostUser = posts[tableViewCell.tag].object(forKey: "user") as! NCMBUser //投稿者がログインユーザーか否かを条件に分岐している if selectedPostUser.object(forKey: "objectId") as! String == NCMBUser.current()?.objectId{ // ログインユーザーの投稿を削除する時の一連の流れ(略) }else{ //ログインユーザーの投稿でない場合のながれ let reportAction = UIAlertAction(title: "報告する", style: .destructive) { (action) in //報告機能のコード } let blockAction = UIAlertAction(title: "ブロックする", style: .default) { (action) in HUD.show(.progress, onView: self.view) //NCMBのBlockクラスにオブジェクトを作成する let object = NCMBObject(className: "Block") //"blockUserId"をキーとしてブロックされる側(投稿者)のオブジェクトIDを格納 let user = self.posts[tableViewCell.tag].object(forKey: "user") as! NCMBUser object?.setObject(user.objectId, forKey: "blockUserId") //"user"をキーとしてブロックする側(現在ログインしているユーザー)をNCMBUser型として格納 object?.setObject(NCMBUser.current(), forKey: "user") //NCMB上にオブジェクトを保存 object?.saveInBackground({ (error) in if error != nil { HUD.show(.progress, onView: self.view) } else { HUD.hide(animated: true) //ここの後で記述する関数です。 self.getBlockUser() } }) } alertController.addAction(reportAction) //alertControllerのアクションとして追加 alertController.addAction(blockAction) alertController.addAction(cancelAction) } self.present(alertController, animated: true, completion: nil) } 以上で非表示にしたいユーザーをブロックする作業のコードは完了です。 2.ブロックしたユーザーの投稿を非表示にする作業 ブロックしたユーザーをタイムラインから非表示にする方法は大きく分けて二つの手順があります。 一つ目に、下記画像の①のNCMBからブロックしているユーザーの情報を取得する機能 二つ目に、下記画像の②のNCMBからタイムラインに表示させる投稿情報を取得するときに、ブロックしているユーザーをはじく機能です。 実際にコードを書いていきましょう^-^ ①NCMBからブロックしているユーザーの情報を取得する機能 ここではgetBlockUser()という関数を定義し、viewWillAppearの中で呼び出します。そうすることでこのViewController{{が表示されるたびに、ログインユーザーがブロックしているユーザーのオブジェクトIDをblockUserIdArray``という配列に格納します。 (今回の機能に関係のないコードは省いています。) import UIKit import NCMB import PKHUD import Kingfisher import SwiftDate class ViewController: UIViewController, UITableViewDataSource,UITableViewDelegate,TimeLineTableViewCellDelegate{ var followings = [NCMBUser]() //ブロックしているユーザーのオブジェクトIDを格納する配列を宣言 var blockUserIdArray = [String]() var posts = [NCMBObject]() override func viewDidLoad() { super.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { getBlockUser() loadFollowingUsers() } //ログインユーザーがブロックしているユーザーのオブジェクトIDを格納する関数 func getBlockUser(){ //NCMBのBlockクラスのクエリを宣言 let query = NCMBQuery(className: "Block") //検索結果にuserカラムの情報を含める query?.includeKey("user") //userカラムがログインユーザーと一致するレコードを取得 query?.whereKey("user", equalTo: NCMBUser.current()) query?.findObjectsInBackground({ (result, error) in if error != nil{ //もし保存する際にエラーがあったらその地域の言葉でエラーの内容を表示させます。 HUD.flash(.labeledError(title: error?.localizedDescription, subtitle: nil), delay: 1) }else{ //removeAll()で初期化をし、データの重複を防ぐ self.blockUserIdArray.removeAll() for blockObject in result as! [NCMBObject]{ //blockUserIdArrayに取得したレコードのblockUserIdカラムの要素のみを加える self.blockUserIdArray.append(blockObject.object(forKey: "blockUserId") as! String) } //タイムラインを読み込みする self.loadTimeline() } }) } } ②表示する投稿情報からブロックしているユーザーをはじく機能 ブロックしているユーザーを表示する投稿情報から弾くコードはNCMBから情報を取得するReadの関数(今回は、loadTimeline())に記述します。 クエリによる検索結果を表示する投稿情報の配列(今回はposts)に加える一歩前で、投稿者のオブジェクトIDがブロックしているユーザーIDの配列に含まれていなければ、表示する配列に加えるというコードを記述します。 func loadTimeline(){ //オートログアウト guard let currentUser = NCMBUser.current() else{ //ログアウト成功 let storyboard = UIStoryboard(name: "SignIn",bundle: Bundle.main) let rootViewController = storyboard.instantiateViewController(withIdentifier: "RootNavigationController") UIApplication.shared.keyWindow?.rootViewController = rootViewController //ログイン情報の保持 let ud = UserDefaults.standard ud.set(true, forKey: "isLogin") return } //"Postクラスから取ってくる"というクエリを宣言する.クエリは仕様書のイメージですね!この仕様書をもとにデータを取ってくるって感じです。 let query = NCMBQuery(className: "Post") //日付の降順でとるってことを仕様書に追記してるイメージ query?.order(byDescending: "createDate") // 投稿したユーザーの情報も同時取得することを仕様書に追記してるイメージです。 query?.includeKey("user") //以上の仕様書を元にデータを取ってきます。無事取れたらresultという変数に結果(Any型配列)が入る。失敗したらerrorという変数にエラー内容(NSError型)が入ります。 query?.findObjectsInBackground({(result,error) in if error != nil{ //エラーだったら、エラーの内容を表示する print(error) }else{ //データの取得がうまくいったら、postsっていう別の変数(箱)にデータを移し変えてあげましょう! for postObject in result as! [NCMBObject]{ let user = postObject.object(forKey: "user") as! NCMBUser //念のため取れてるかprintして確認できるようにしておきます。 print(user) print(postObject) if self.blockUserIdArray.firstIndex(of: user.objectId) == nil { //postsっていう別の変数(箱)にデータを写変える文章 self.posts.append(postObject) } } //tableViewを再読み込みする self.timelineTableView.reloadData() } }) } ※if self.blockUserIdArray.firstIndex(of: user.objectId) == nilに関して firstIndexとは配列に対して利用できるもので、先頭から要素を検索し、該当する要素のインデックスを返すメソッドです。 そのため、今回の左辺は、blockUserIdArray(ログインユーザーがブロックしているユーザーのオブジェクトIDの配列)の中で、user.objectId(投稿者のオブジェクトID)は何番目の要素かというものを示しています。右辺はnil(空)を示します。したがって、該当する要素のインデックスがnilである場合self.posts.append(postObject)のコードが実行されるようになり、タイムラインに表示されるようになります。 少し複雑な内容になっているので一度で理解できなかったとしても焦らず、それぞれの変数が何を表しているのかを考えながらなんども読み込んでみてください! うまくいかなかったらメンターに聞いてみてください! ここで止まったらこの後の教材もうまくいかないままになってしまいます! 不備/疑問点アンケート 教材の不備を見つけた方や、わからなかったこと、もっと詳しく解説してほしいことに関しては、こちらの学習進捗フォームに答えてください! 著者:髙橋直輝
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】ファイル操作で保存されたファイルやパスを色んな形式で確認してみる

はじめに ※この記事はLIFULL Advent Calendar 2021の2日目の記事となります。 個人でiOSアプリ開発をしているときに、アプリ内でファイル操作をしたいときがあって、ほしい値を見たい時にどれを参照するのかがごっちゃになるのでまとめました。 前提 今回はDocumentディレクトリに日付のディレクトリを作成し、その中に動画ファイルがあるという状態で考えました。 ※↓の階層はDocumentから始まっていますが、実際にはもっと階層は上にもあります。 Document/ ├── 20211124/ │ ├── hogehoge.mov │ └── fugafuga.mov └── 20211126/ ├── hogehoge.mov └── hugahuga.mov 準備 ディレクトリ作成 今回はDocumentsディレクトリに作成します。 guard let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } // ひとまず日付を直撃ちしてディレクトリ名を作成する // ex.20211124とか let directory = directory.appendingPathComponent("[ディレクトリ名]", isDirectory: true) do { try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) } catch { print("失敗した") } ファイル作成 作成したディレクトリにファイルを追加します。 ハードコーディング気味です。 // ディレクトリ名を直撃ちしています let directoryPath = documentDirectoryURL.absoluteString + "20211124" guard let targetDirectory = URL(string: directoryPath) else { return } // ディレクトリのURLにファイル名を追加する // これで ~~~~~/Document/20211124/hogehogeみたいなURLのファイルを作成できる let fileName = "hogehoge" let fileURL = targetDirectory.appendingPathComponent(fileName) // 省略 fileURLを用いてrecordする パスやファイル名の取得 let fileManager = FileManager.default let documentDirectoryURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! do { // Documentから動画ファイルのURLを取得 // 今回はURLの一つに対しての値をいくつか見る guard let contentUrl = try fileManager.contentsOfDirectory(at: documentDirectoryURL, includingPropertiesForKeys: nil).first else { return } print("contentUrl: \(contentUrl)") print("contentUrlPath: \(contentUrl.path)") print("contentUrlLastPathComponent: \(contentUrl.lastPathComponent)") print("FileName: \(try FileManager.default.contentsOfDirectory(atPath: contentUrl.path))") } catch { print("フォルダが空です。") } これを実行するとコンソールにprint文の結果が以下のように表示されます。 contentUrl: file:///private/var/mobile/Containers/Data/Application/FBAC66B0-F8EF-4689-8379-EE961C62E00B/Documents/20211124/ contentUrlPath: /private/var/mobile/Containers/Data/Application/FBAC66B0-F8EF-4689-8379-EE961C62E00B/Documents/20211124 contentUrlLastPathComponent: 20211124 FileName: ["hogehoge.mov"] 動画の再生をするにはファイルのURLが必要で、最初contentUrl.pathで取れるかと思っていました。 しかし、今回のcontentUrlはDocumentディレクトリの一個下にある階層のコンテンツになるので、20211124ディレクトリまでの情報は取得できますが、そのさらに一個下の階層にあるhogehoge.movの情報はこれだけだと取得できませんでした。 これによって動画の再生ができなくて詰まっていました。 今回は前提としてDocument > [日付のディレクトリ] > [なんらかの動画ファイル]という構成になることは決まっていたので、contentUrlPathとFileNameを取得し、それらを文字列として連結させ、URLに変えてあげる。。をすれば最低限動画の再生には持って行けました。 これは若干力技なので、Documentディレクトリから再帰的にディレクトリじゃなくなるまでファイルを検索しにいくようにするとファイルの構成が異なったりしていても柔軟に動画ファイルのURLを取得できてスマートかなと思ったりしました。 また、地味にcontentUrlとcontentUrl.pathの違いでも詰まりました。 .pathはあくまでファイルパスの文字列を返すだけなので、これを使えば動画再生ができると思いましたが、URLではないので動画をそのまま使いたい場合は間違えずにURL型のcontentUrlの方を使ってあげましょう。 終わりに 今回はSwiftでファイルの作成や取得をする際に、必要な情報をどう取得するべきかを、簡単にコーディングをしながらまとめてみました。 今回得た知見を活かしてファイルの取り扱いをうまくやっていきながらアプリ開発していけるようにしたいですね。 また、ここら辺参考になるよとか、言ってること違うよとかがあればコメントいただけると幸いです。 僕の書いた本文と、コメント欄で一つの作品とさせていただければと思います。 参照 いまさらだけどiOSのファイル操作まとめ(Swift) - Qiita Documentフォルダにファイルを保存する – 野生のプログラマZ Swiftでパスからファイル名や拡張子を取得する方法 ファイルPathとファイルURL - アプリ開発ブログ(仮)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む