- 投稿日:2020-02-06T23:40:58+09:00
FirebaseFunctionsでPush通知をHTTP関数呼び出しで送信する
はじめに
今回はFirebaseFunctionsのhttp関数を使用して特定のデバイスにPush通知を送る実装を行なっていきます。実装前に下記の準備項目が必要になります。
事前準備
- Firebaseプロジェクト
- Firebase/Messaging導入済のiosプロジェクト
- APNsのFirebaseアップロード
FirebaseCLIインストール
まずは、FirebaseCLIをインストールすることでFunctionsのDeployやプロジェクトの切り替えなどをCLIで操作できるようにします。今回はnpmでインストールを行います。
npmインストール
とりあえず最新のものをnodebrewで取得してきてPathを通すとこまで終わらせます。
$ brew install nodebrew $ nodebrew install-binary 13.7.0 $ nodebrew use v7.0.0 $ echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile $ source ~/.bash_profilefirebase-toolsインストール
1.npmでfirebase-toolsをインストールします。
$ npm install -g firebase-tools2.firebase-toolsコマンドを使用して、操作を行うユーザの認証をします。下記のコマンドを実行するとWebブラウザが立ち上がるので、Firebaseプロジェクトで編集権限のあるアカウントでログインを行います。
$ firebase login3.firebaseのプロジェクトを
use
コマンドを使って指定します。この操作によりfirebase/functionsなどのデプロイ先を変更できたりします。$ firebase use firebase_project_idFunctionsプロジェクト作成
今回はFunctionsのみ使用するので下記のコマンドでプロジェクトを立ち上げます。
$ firebase init functionsすると下記のような構造のプロジェクトが立ち上がるので、主に
index.js
を編集して関数を作成して行きます。
参照: https://firebase.google.com/docs/functions/get-started?hl=ja
FirebaseAdminSDKインストール
1.sdkの情報などを保存する
package.json
を作成します。$ npm init2.firebase-admin npmパッケージをインストールします。
$ npm install firebase-admin --save3.次に
firebase-admin
を初期化をするためにローカルの環境変数にFirebaseサービスアカウントの秘密鍵を生成したファイルへのパスを指定します。これを設定することでSDKの初期化時にキーが参照され、プロジェクトでの認証が完了します。CIなどでブランチごとにDeploy先を変更させたい時はどうやって秘密鍵を参照させるのがベストなんでしょうか?$ export GOOGLE_APPLICATION_CREDENTIALS="/home/user/Downloads/service-account-file.json"参照: https://firebase.google.com/docs/admin/setup?hl=ja
4.
index.js
に移動してsdkの初期化コードを追加します。index.jsconst functions = require('firebase-functions'); const admin = require('firebase-admin'); admin.initializeApp();node.jsの実装
index.jsconst functions = require('firebase-functions'); const admin = require('firebase-admin'); admin.initializeApp(); //onRequestでhttpからの呼び出しを可能にします。 exports.push = functions.https.onRequest((request, response) => { if (request.query.device_token !== undefined && request.body.message !== undefined) { const device_token = request.query.device_token const message = request.body.message const payload = { notification: { body: message, badge: "1", sound:"default", } }; switch (request.method) { case 'POST': push(device_token, payload, response); break default: response.status(400).send({ error: 'Invalid request method' }) break } } else { response.status(400).send({ error: 'Invalid request parameters' }) } }) function push(token, payload, response) { const options = { priority: "high", }; //FCMにAdminSDKを介してPush通知を送信します。 admin.messaging().sendToDevice(token, payload, options) .then(pushResponse => { console.log("Successfully sent message:", pushResponse); response.status(200).send({message: 'Successfully sent message'}) }) .catch(error => { response.status(400).send({ error: 'Error sending message' }) }); }swiftの実装
AppDelegate.swiftimport UIKit import Firebase import UserNotifications import FirebaseMessaging @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private var mainTabViewController: MainTabViewController? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { //環境ごとにプロジェクトを変えてるためplistを変更しています。 let filePath = Bundle.main.path(forResource: Config.Server.instance.firebaseInfoPlistName, ofType:"plist") //Forced Unwrapping? FirebaseApp.configure(options: FirebaseOptions(contentsOfFile:filePath!)!) initFirebaseMessaging() initRemoteNotification(application) window = UIWindow(frame: UIScreen.main.bounds) window!.makeKeyAndVisible() navigate() return true } func navigate(_ isTrial: Bool = false) { guard let window = window else { assert(false) return } let previousVC = window.rootViewController for v in window.subviews { v.removeFromSuperview() } let vc = MainTabViewController() mainTabViewController = vc window.rootViewController = vc if let previousVC = previousVC { previousVC.dismiss(animated: false) { previousVC.view.removeFromSuperview() } } } private func initRemoteNotification(_ application: UIApplication) { UNUserNotificationCenter.current().delegate = self let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] //TODO: Relocate requestAuthorization method. UNUserNotificationCenter.current().requestAuthorization(options: authOptions, completionHandler: {_, _ in }) application.registerForRemoteNotifications() } private func initFirebaseMessaging() { //DelegateでdeviceTokenの変更を監視します。 Messaging.messaging().delegate = self //明示的にdeviceTokenを取得します。 InstanceID.instanceID().instanceID { (result, error) in if let error = error { //TODO: Error handling. print("Error fetching remote instance ID: \(error)") } else if let result = result { //TODO: Send token to parnovi api for update user fcm token. if authorized == true print("Remote instance ID token: \(result.token)") } } } } extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.badge, .sound, .alert]) } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { completionHandler() } } extension AppDelegate: MessagingDelegate { //Observe firebase messaging token. func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String) { } }FunctionsのDeploy
実際に関数をデプロイしてPush通知を送信してみます。
$ firebase deploy --only functionsswiftの
InstanceID.instanceID().instanceID
で取得したDeviceTokenを使ってcurlで実際にPushを送信してみます。$ curl -X POST https://yout-functions-url/push?device_token=your-device-token -d "message=I love fishing?"結果
さいごに
今回はテスト的に実行できるようにするため、httpリクエストに認証は設定していませんでしたが、また実装し直したら編集しようと思います。また、CIなどを使ってfirebase/functionsなどをデプロイするとき、どのようにFirebaseプロジェクトの秘密鍵を参照させるのがベストなのでしょうか。。
- 投稿日:2020-02-06T20:57:42+09:00
Instagram検索画面風レイアウトをCompositionalLayoutで爆速開発する(約150行の実装)
はじめに
本記事ではiOS13から利用可能になった
UICollectionViewCompositionalLayout
を利用して、インスタグラムの検索画面のようなパネルレイアウトを作成する方法を記載します。
UICollectionViewCompositionalLayout
は、複雑なレイアウトを簡単で宣言的な記述で実現できるレイアウトクラスで、今回のデモに関しては約150行足らずですべての実装を完了することができました。実際のAppの挙動は下記にgif動画を添付しています。
サンプルコード
https://github.com/chocoyama/InstaLikeLayoutViewControllerの実装は↓だけです。(約35行)
https://github.com/chocoyama/InstaLikeLayout/blob/master/InstaLikeLayout/ViewController.swiftUICollectionViewCompositionalLayoutとは
まずは簡単にこのクラスの説明をしておきます。
これは、iOS13で提供されたレイアウトのクラスで、以下の特徴を持ちます。
- 宣言型タイプのAPIでレイアウトを組むことができる
- 小さなレイアウトをつなぎ合わせていく形でレイアウトを組んでいく
- サブクラス化は必要ない
- レイアウトが複雑になっても、それに比例してコードの量が増えない
- セクションごとにレイアウトを変更するといったことができる
関連クラス
関連クラスとしては以下のものがあり、それぞれがレイアウトされるViewを抽象化しています。
これらを組み合わせながら具体的なサイズや位置を決めていきます。
- NSCollectionLayoutSection
- セクションひとつ分を表すもので、NSCollectionLayoutGroupを渡して初期化する
- NSCollectionLayoutGroup
- セルをグルーピングして細かくレイアウトを制御するための集合
- NSCollectionLayoutItem
- レイアウトの最小単位でセル1つ分にあたるもの
詳細な実装は後述します。
サイズの指定
サイズ指定は上記の関連クラスに対して
NSCollectionLayoutSize
を受け渡して指定します。
これはGroupやItemのサイズを指定するもので以下のような形で指定します。NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3), heightDimension: .fractionalHeight(1.0)) )さらに、引数に受け渡す
NSCollectionLayoutDimension
は以下のような種類があり、実現したいレイアウトによってそれぞれ使い分けます。open class NSCollectionLayoutDimension : NSObject, NSCopying { // 親のwidthとの相対値 open class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self // 親のheightとの相対値 open class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self // 絶対値指定 open class func absolute(_ absoluteDimension: CGFloat) -> Self // 推定サイズを指定して、自動計算させる open class func estimated(_ estimatedDimension: CGFloat) -> Self // ...これらを指定することで親からの相対値や絶対値指定などでセルのサイズを決定していきます。
ルートのGroupはCollectionViewを基準としてサイズが決定され、子Groupは親Groupを基準に、Itemは属しているGroupを基準にサイズが決定されていきます。実装
ここからは今回作成するレイアウトの実装の説明に入ります。
全体的な方針としては以下のような感じになります。
- セクションごとに柔軟にレイアウトを変えられるCompositionalLayoutの特徴を活用し、3パターンのレイアウトパターンをセクションごとに適用していく
- 左側のセルが大きく、右側には2x1の小さいセルが表示されるレイアウト
- 右側のセルが大きく、左側には2x1の小さいセルが表示されるレイアウト
- 全て同じサイズのセルが2x3で敷き詰められているレイアウト
- セクションごとにレイアウトを決めるので、扱いやすいようにデータもセクションごとにまとめて持っておく
- これをCompositionalLayoutに与えてレイアウトさせる
レイアウトパターンの定義
まず、CompositionalLayoutを作るにあたって、レイアウトを大きく3パターンに分けました。
- 左側が大きいアイテムで、右側2行に小さいアイテムを配置したレイアウト(leadingLarge)
- 右側が大きいアイテムで、左側2行に小さいアイテムを配置したレイアウト (trailingLarge)
- 全て同じサイズのアイテムで2x3のパネルにアイテムを配置したレイアウト(spread)
これらのレイアウトパターンに応じて、それぞれを1セクションとして扱っていくことにします。
これをコードで定義すると以下のようになります。enum Kind: Int, CaseIterable { // ※ spreadが2つに分かれているのは、後述する処理で、この定義順でレイアウトを作っていくためです case leadingLarge, spread1, trailingLarge, spread2 // 各レイアウトパターンが表示するアイテムの件数を返却します var numberOfItemsInSection: Int { switch self { case .leadingLarge, .trailingLarge: return 3 case .spread1, .spread2: return 6 } } }Sectionのモデルを定義
次に、作成するレイアウトがデータを扱いやすくするために、レイアウト用のモデル(Section)を作成します。
各セクションに表示するセル1つ分にあたるモデル(Item)は画面によって変わるため、Genericsで抽象化しておきます。
Sectionモデルでは、このItemの配列とレイアウトパターン(Kind)を保持します。また、合わせてItemの配列をSectionの形式に変換するbuild関数も定義しました。
変換ロジックはprotocolを用いてDI可能にし、要件によって柔軟に表示をコントロールできるようにしています。
(今回のサンプルではKindで定義されているレイアウトの順番で順々に表示していくだけです。)struct Section<Item: Hashable>: Hashable { let id = UUID() let kind: Kind let items: [Item] static func build(_ items: [Item], with strategy: LayoutStrategy) -> [Section<Item>] { strategy.buildSections(for: items) } } protocol LayoutStrategy { func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>] }Itemの配列をSectionの配列に変換
ここで、実際にSectionを作っていく実装を行います。
内容について詳細は書きませんが、Kindのenumで定義されている順番で、それぞれのレイアウト用の件数分Itemを取り出してSectionにしています。/// Layout.Kindで定義されている1セクションに表示するセルの数ごとに分割していく struct RegularOrderLayoutStrategy: LayoutStrategy { func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>] { var sections = [Layout.Section<Item>]() var kind: Layout.Kind = .leadingLarge var tmpItems: [Item] = [] for item in items { if tmpItems.count == kind.numberOfItemsInSection { sections.append(.init(kind: kind, items: tmpItems)) kind = next(from: kind) tmpItems = [] } tmpItems.append(item) } sections.append(.init(kind: kind, items: tmpItems)) return sections } private func next(from kind: Layout.Kind) -> Layout.Kind { // 定義されている次の値を返却する Layout.Kind(rawValue: kind.rawValue + 1 == Layout.Kind.allCases.count ? 0 : kind.rawValue + 1)! } }Layoutの作成
これで準備が整いました。
あとは、UICollectionViewCompositionalLayoutを作成して実際のレイアウトの記述をしていくだけです。
順番に見ていきます。UICollectionViewCompositionalLayoutの初期化
まずは、最初に
UICollectionViewCompositionalLayout
のイニシャライザを呼び出します。
該当のクラスはいくつかイニシャライザが提供されていますが、今回利用しているのは下記のもので、セクションごとに異なるレイアウトを設定できます。
UICollectionViewCompositionalLayoutSectionProvider
からセクションのインデックスを取得できるので、これを用いて分岐を行います。public typealias UICollectionViewCompositionalLayoutSectionProvider = (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? open class UICollectionViewCompositionalLayout : UICollectionViewLayout { // ... public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider) // ... }レイアウトの構造
今回は大きく分けて3パターンのレイアウトがあるので、それぞれを構造化すると以下のようになります。
具体的にどのようにサイズを指定してるかは後述している実装コードに記載があります。
- leadingLarge
- NSCollectionLayoutSection
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(左側の大きなセル、幅は)
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(右側の小さなセル)
- NSCollectionLayoutItem(右側の小さなセル)
- spread
- NSCollectionLayoutSection
- NSCollectionLayoutGroup
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(左側の小さなセル)
- NSCollectionLayoutItem(左側の小さなセル)
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(中央の小さなセル)
- NSCollectionLayoutItem(中央の小さなセル)
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(右側の小さなセル)
- NSCollectionLayoutItem(右側の小さなセル)
- trailingLarge
- NSCollectionLayoutSection
- NSCollectionLayoutGroup
- NSCollectionLayoutGroup
- NSCollectionLayoutItem(左側の小さなセル)
- NSCollectionLayoutItem(左側の小さなセル)
- NSCollectionLayoutItem(右側の大きなセル)
(汚いですが)これを図にすると以下のようになります。
実装コード
上記に示した構造は下記のようなコードで宣言されます。
static func build<Item>(for sections: [Section<Item>]) -> UICollectionViewCompositionalLayout { UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in let largeItem = NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3), // 1. 幅:親Groupの幅(CollectionViewの幅)の2/3 heightDimension: .fractionalHeight(1.0)) // 2. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる ) let smallGroup = NSCollectionLayoutGroup.vertical( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), // 3. 幅:CollectionViewの幅の1/3 heightDimension: .fractionalHeight(1.0)), // 4. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる subitem: NSCollectionLayoutItem( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 5. 幅:親Groupの幅(CollectionViewの幅の1/3)に合わせる heightDimension: .fractionalHeight(1.0)) // 6. 高さ: 親Groupの高さ(CollectionViewの高さの4/10)をcountで割った値にする ), count: 2 // 2件分表示されるように自動計算させる ) let nestedGroup = NSCollectionLayoutGroup.horizontal( layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 7. CollectionViewの幅に合わせる heightDimension: .fractionalHeight(4/10)), // 8. CollectionViewの高さの4/10 subitems: { switch sections[sectionIndex].kind { case .leadingLarge: return [largeItem, smallGroup] case .spread1, .spread2: return [smallGroup, smallGroup, smallGroup] case .trailingLarge: return [smallGroup, largeItem] } }() ) return NSCollectionLayoutSection(group: nestedGroup) } }非常にシンプルで宣言的な形で、かつ少ない行数でレイアウトの記述ができていることがわかると思います。
ViewControllerでの適合
以上でレイアウトの作成は完了したので、最後に作成したレイアウトをCollectionViewにセットすれば終了です。
let sections: [Layout.Section<UIColor>] = { let colors = (0..<1000).map { _ in UIColor(red: (CGFloat(arc4random_uniform(255)) + 1) / 255, green: (CGFloat(arc4random_uniform(255)) + 1) / 255, blue: (CGFloat(arc4random_uniform(255)) + 1) / 255, alpha: 1.0) } return Layout.Section.build(colors, with: RegularOrderLayoutStrategy()) }() collectionView.setCollectionViewLayout(Layout.build(for: sections), animated: false)最後に
長々と書いてしまいましたが、全体を見るととてもシンプルな実装で複雑なレイアウトが組めていることがわかると思うので、下記にサンプルコードも載せています。
https://github.com/chocoyama/InstaLikeLayout
CompositionalLayoutを使うと、AppStoreのようなレイアウトも簡単に作れるので、iOS13以降をサポートするアプリは積極的に使っていきましょう〜
- 投稿日:2020-02-06T19:07:33+09:00
【iOS】ビューを凹んだように見せて「押した」感を演出する
iOS アプリで、ボタン以外にタップ可能なビューを置きたいケースがあります。例えば、マンガ向け・ストーリー構成力養成ドリル drill というアプリで以下のようなカードっぽいビューを作る機会がありました。
このカードはタップすると次の画面に遷移しますが、そのままだとフィードバックが何もなく、「ちゃんとタップされた」かどうか不安になりそうでした。
そこで、ビューを視覚的に凹んだように見せることで、「押した」感のあるフィードバックを実現しました。意外と情報がなかったのでメモしておきます。
ガイドラインを調べる
早速実装…といきたいことろですが、もし「押した」感の演出で公式で推されている表現があるならそれに従っておきたかったので、最初に簡単にガイドラインを調べました。
Human Interface Guidelineでは?
Apple 公式の UI デザインのガイドラインである Human Interface Guidelines をざっと眺めてみましたが、タップに対する視覚的なフィードバックの記述が見つかりません… ありそうなものですが。見逃しているだけ?
Material Designでは?
Android アプリの UI ガイドラインである Material Design も調べてみました。Material Design のコンポーネントは、押した部分から波紋が広がるようなエフェクトがついています。このエフェクトには Ripple という名前がついているようです。
ただ、なぜか Material Design のページからは Ripple の記述が削除されてしまっていました。obsolete になった…?
https://material.io/go/design-ripple/ 1実は、Ripple も試してみたのですが、非常に「Android 感」が出てしまったので、iOS 版では違う表現を使うことにしました。
公式アプリを参考にする
ガイドラインにはあまり情報が無さそうだったので、実際のアプリを見て参考にしてみます。
Apple 公式の「App Store」アプリなどでは、カードっぽいものを押すとわずかにサイズが小さくなって、「凹んだ」ような感じになって押したことが視覚的にわかりやすくなっています。
確かにこんな感じにすれば、押した感を出せそうです。今回はこの表現を真似します。
実装
まさにな記事があったのでほぼこちらを参考にしました
SwiftでUIButtonのタップ時に押された感のアニメーション - Qiita
ただ、書き方が古くてそのままではコンパイルできなかったので、最新の UIKit のメソッドやプロパティに合わせて書き換えました。Xcode 10.2.1、Swift 5 で試しています。
以下のようなクラスを作って、これを継承すると、押した感のあるフィードバックがあるビューが作れます。
import UIKit class TouchFeedbackView: UIView { // タップ開始時の処理 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesBegan(touches, with: event) self.touchStartAnimation() } // タップキャンセル時の処理 override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesCancelled(touches, with: event) self.touchEndAnimation() } // タップキャンセル時の処理 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesEnded(touches, with: event) self.touchEndAnimation() } // ビューを凹んだように見せるアニメーション private func touchStartAnimation(){ UIView.animate(withDuration: 0.1, delay: 0.0, options: UIView.AnimationOptions.curveEaseIn, animations: { // 少しだけビューを小さく縮めて、奥に行ったような「凹み」を演出する self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) }, completion: nil ) } // 凹みを元に戻すアニメーション private func touchEndAnimation(){ UIView.animate(withDuration: 0.1, delay: 0.0, options: UIView.AnimationOptions.curveEaseIn, animations: { // 元の倍率に戻す self.transform = CGAffineTransform(scaleX: 1.0, y: 1.0) }, completion: nil ) } } // 使用例 class LessonCardView: TouchFeedbackView { // ... }課題:
UIButton
などUIView
以外の親クラスを持ちたいビューではこれを使えないこんな感じの表示になります2
まとめ
ビューを視覚的に凹んだように見せることで、「押した」感を演出する表現を紹介しました。実際に触ってみるとわかるのですが、これがあるのとないのとでは大きく体験が違います。ちょっとした工夫でユーザー体験が向上するので、ぜひ参考にしてみてください
- 投稿日:2020-02-06T17:54:35+09:00
プログラミング未経験者が独学SwiftUIでお天気検索アプリをリリースしました?
はじめまして!
Swiftを独学で勉強中のHibimaruです。
プログラミングは未経験で3Dイラストレーター/デザイナーとして活動しています。自分のイラストでアプリを作ってみたかったので、2019年12月頃からSwiftの勉強を開始し、
最初はStoryboardでアプリを作る練習をしていました。
しかし、SNSなどでSwiftUIなるものの存在を知って、新しい物好きの僕は「最新の技術を使ってリリースしたい!」という思いからSwiftUIの勉強に切り替えました。目次
- 作ったアプリについて
- Swiftの勉強方法
- 作ってみて感じたこと
作ったアプリ
Weather Pop
主な機能
- 現在時刻に加え3時間ごとの天気、気温情報を確認できます。
- ボタンを押すことで風速、湿度、気圧情報の表示切り替えができます。
独学でわからないことだらけでしたので、機能は少ないですが、なんとか形にするという目的は達成できました。
Swiftの勉強方法
- 初めはUdemyの動画を見て学習しました。 【iOS13対応】未経験者がiPhoneアプリ開発者になるための全て iOS Boot Camp
- 次に書籍で学習しました。 絶対に挫折しない iPhoneアプリ開発「超」入門 第7版 【Xcode 10 & iOS 12】 完全対応
- YouTubeで学習
SwiftUIは主にYouTubeで学習しました。
最近では結構動画も増えてきているので、SwiftUI公式チュートリアルを終えた後に見るとより理解が深まったように感じました!作ってみて感じたこと
デザインからイラスト、実装まで一人で作業するのはかなり大変でした
僕はHTMLやCSS、WordPressでサイトを作ることくらいしか知識がなく、プログラミングは全くの未経験です。
いつになったら理解できるんだろうという不安しかありませんでしたが、こんな僕でも学習を続けていると少しづつ理解できるようになってきました。
「誰が使うねん!」みたいなアプリですが、無事リリースまで持っていけたのは嬉しかったですSwiftUIは非常に書きやすく、すぐに慣れることができました。
ただ、細かい制御をもう少しできるようになればいいのになと感じました。
今後のアップデートに期待しております!以上、最後まで読んでくださり、ありがとうございました!
こうしたらもっと良くなるなどのアドバイスをいただけると嬉しいです。
今後も勉強を続けていきたいです
- 投稿日:2020-02-06T15:31:37+09:00
[iOS]今からはじめるドキュメントベースApp
こんにちは。
@gachalatteです。今回はドキュメントベースAppのお話です。
iOS 11のトピックですので目新しさはありませんが、Firevaultの開発で得られた知見を共有したいと思います。
ドキュメントベースApp
ユーザーが対象を選択し、内容を編集、名前を付けて保存する。iOSでこのようなAppを開発するなら、ドキュメントベースApp(Document-Based App)が最適です。
UIDocument、Open in Place、Document Provider Extension、iCloud Driveなど、Appleの主要なテクノロジーをドキュメントベースAppに組み込むことで、ユーザーの生産性を高め、素晴らしいユーザー体験を提供することができます。
ドキュメントベースAppを採用すれば、Appは様々な能力を手に入れることができます。
共通のユーザーインターフェースによるファイル操作
- ユーザーは、
ファイル.app
と同様のインターフェースでファイルを操作することができます。フォルダ、タグによるファイル管理
- ユーザーが自身にとって最適な方法でファイルを整理することができます。
iCloud Drive
によるファイル共有
- ファイルをチームで共同編集したり、読み取り専用での一般公開したりすることができます。
Dropbox
やGoogle Drive
などの外部プロバイダのサポート
- ローカルディレクトリと同じ要領で外部プロバイダのコンテナにアクセスすることができます。フレームワークやAPIの呼び出しは不要です。
シームレスなデバイス間同期
- ファイルの変更は、即座にユーザーインターフェースに反映されます。これは、ユーザー体験を大幅に向上させます。
この記事では、ファイルパッケージを使ったドキュメントベースAppの開発手順を紹介します。
ファイルパッケージ
ドキュメントベースAppを開発する前に、どのような形式でファイルを書き出すかを設計する必要があります。もし、ドキュメントに複数のコンテンツが含まれる場合は、ファイルパッケージ形式がおすすめです。
ファイルパッケージの実体は単なるディレクトリですが、iOSやmacOSは、ファイルパッケージを単一のファイルとして扱います。これによって、内包するファイルの整合性が確保されます。クラウド上のファイルは、Appが実行中に変更される可能性があります。一部のファイルだけが更新された瞬間を読み込んでしまうと誤作動を起こすのは避けられません。整合性が確保されているということは、とても重要な要素です。
ファイルパッケージは、非常に扱いやすいという特徴もあります。プログラムでは、通常のディレクトリと同じように操作することができ、MacのFinderでは、
右クリック
>パッケージの内容を表示
で、中を開くこともできます。また、更新されたファイルだけが送受信の対象となるため、クラウド上のファイルを効率良く転送することができるのもファイルパッケージの特徴です。
FileWrapper
ファイルパッケージを読み書きするには
FileWrapper
を使用します。FileWrapper
を使わず、直接ファイルを読み書きすることもできますが、FileWrapper
を使用することで以下のメリットを享受することができます。
一括操作
- パッケージの読み書きは一括で行います。個別にファイルを読み書きする必要はありません。
UIDocument
を使用する場合は、読み書きの手続きすら不要です。差分書き出し
- 変更されたファイルだけを書き出すことができます。これによって書き込み時のパフォーマンス向上が期待できます。
遅延読み込み
- パッケージ内のファイルが必要になった時に読み込むことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。
ファイルマッピング
- パッケージ内のファイルをメモリマップトファイルとして開くことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。
UIDocument
UIDocument
はドキュメントを表すモデルであると同時に、ファイルの読み書きを行うコントローラの役割を持つクラスです。UIDocument
には以下の機能があり、ドキュメントベースAppを最小限のコードで開発することができます。
協調読み書き
- ファイルは外部のプロセスによって常に更新される可能性があります。そのため、ファイルの読み書きには
NSFilePresenter
やNSFileCoordinator
を使用した協調読み書きの手続きが必要になります。UIDocument
は、これらを適切に使用し、ファイルの協調読み書き行います。非同期の読み書き
- ファイルを同期的に読み書きすると、その間Appが応答しなくなる可能性があります。
UIDocument
は、バックグラウンドのキューを使い、ファイルの読み書きを非同期で行います。更新の監視
UIDocument
は、ファイルの更新を監視し、自動的に再読み込みを行います。また、ファイルが別の場所に移動された場合でも安全に動作します。安全な書き込み
UIDocument
は、ファイルを一時ディレクトリに書き出し、元のファイルを置き換えます。保存中にクラッシュしてもファイルの整合性が失われることはありません。自動保存
UIDocument
は、ファイルを自動的に保存します。Appを閉じてもそれまでの変更は失われません。エラーやコンフリクトの通知
UIDocument
は、エラーやコンフリクトなどの状態を保ち、変化があった時に通知します。Appはこれを監視して適切な処理を行なうことができます。サンドボックス外のファイルアクセス
- ドキュメントベースAppでは、サンドボックス外のファイルを開くことができます。サンドボックス外のファイルのURLは
Security-scoped URL
と呼ばれ、読み書きする前にアクセス権を取得する必要があります。UIDocument
は、Security-scoped URL
に対するアクセス権の取得/解放を自動的に行います。UIDocumentBrowserViewController
UIDocumentBrowserViewController
はコンテナに含まれるファイルの一覧を表示し、それぞれのファイルを操作するインターフェースを提供するクラスです。これはファイル.app
とほぼ同等の機能を持ちます。サンプルプロジェクト
Xcode 11
では、ドキュメントベースAppのテンプレートが提供されています。これを使ってプロジェクトを作成します。なお、サンプルプロジェクトではSwiftUIを使用します。https://github.com/gachalatte/DocumentBasedApp
プロジェクト設定
テンプレートの実装は、サポートするドキュメント形式に画像(public.image)ファイルが定義されています。これをカスタムドキュメントに変更します。
プロジェクト設定
>Info
を開き、定義を変更します。Document Types
Key Value Name My Document Types net.gacha.mydoc Nameには、ファイルの種類として画面上に表示されるテキストを指定します。
Typesには、カスタムドキュメントのUTI(Uniform Type Identifier)を定義します。サンプルプロジェクトではnet.gacha.mydoc
としましたが、ユニークな文字列であれば何でも構いません。Additional document type properties
Key Type Value CFBundleTypeRole String Editor LSHandlerRank String Owner LSTypeIsPackage Boolean YES この部分はAPIドキュメントにも詳細が記されておらず、きちんと説明できるだけの理解が得られませんでした。ただ、今回のケースではこの設定で動作することを確認しています。詳しく知りたい場合は、
CFBundleDocumentTypes
を調べてみてください。Exported UTIs
テンプレートの初期状態は空なので、行を追加します。
Key Value Description My Document Identifier net.gacha.mydoc Conforms To com.apple.package, public.composite-content Identifierには、Document Typesで定義したUTIを設定します。
Conforms Toは、カスタムドキュメントが適合するUTIを表します。com.apple.package
はファイルパッケージを表し、public.composite-content
は複数の内容物で構成されていることを表します。Additional exported UTI properties
Key Type Value UTTypeTagSpecification Dictionary public.filename-extension Array Item 0 String mydoc UTTypeTagSpecificationのpublic.filename-extensionにはファイルの拡張子を定義します。
実装
プロジェクトの設定が終わったら、テンプレートで用意された3つのクラスを実装していきます。
Document
まずは、
UIDocument
のサブクラスであるDocument
を実装します。Document.swiftimport UIKit class Document: UIDocument, ObservableObject { @Published var image: UIImage? @Published var text: String? override func contents(forType typeName: String) throws -> Any { return FileWrapper(directoryWithFileWrappers: Dictionary(uniqueKeysWithValues: FileID.allCases.compactMap(fileWrapper(for:)).map({ ($0.preferredFilename!, $0) }))) } override func load(fromContents contents: Any, ofType typeName: String?) throws { guard let fileWrapper = contents as? FileWrapper, fileWrapper.isDirectory else { return } FileID.allCases.compactMap({ (fileID) -> (FileID, Data?)? in guard let child = fileWrapper.fileWrappers?[filename(for: fileID)] else { return nil } return (fileID, child.regularFileContents) }).forEach({ (fileID, data) in setData(data, for: fileID) }) } } extension Document { private enum FileID: CaseIterable { case image case text } private func filename(for fileID: FileID) -> String { switch fileID { case .image: return "image.png" case .text: return "text.txt" } } private func data(for fileID: FileID) -> Data? { switch fileID { case .image: return image?.pngData() case .text: return text?.data(using: .utf8) } } private func setData(_ data: Data?, for fileID: FileID) { switch fileID { case .image: image = { guard let data = data else { return nil } return UIImage(data: data) }() case .text: text = { guard let data = data else { return nil } return String(data: data, encoding: .utf8)! }() } } private func fileWrapper(for fileID: FileID) -> FileWrapper? { guard let data = data(for: fileID) else { return nil } let fileWrapper = FileWrapper(regularFileWithContents: data) fileWrapper.preferredFilename = filename(for: fileID) return fileWrapper } }SwiftUIで使用するため、Documentを
ObservableObject
に適合し、各プロパティには@Published
属性をつけて変更を通知するようにしています。これは、プロパティが変更されたら画面を更新するということを実現するためのもので、SwiftUIを使わない場合は、@objc
属性をつけてKVOで監視する方法でも構いません。また、オプショナルにしているのは、ファイルが存在しないケースを考慮しています。プロパティの
image
とtext
は、それぞれimage.png
とtext.txt
の内容を保持します。プロパティとファイル、どちらか一方が変更されれば、他方に反映されるようにします。注目すべきは、
func contents(forType: String) -> Any
とfunc load(fromContents: Any, ofType: String?)
です。UIDocument
は、適切なタイミングでこれらのメソッドを呼び出し、ファイルの読み書きを行います。contents
はData
とFileWrapper
に対応しているため、これらの形式の値をやり取りするだけで、ファイルの読み書きが実現できます。サンプルコードの後半、extensionの部分は、上記の実装を効率よく行うためのヘルパーです。
DocumentBrowserViewController
DocumentBrowserViewController
はUIDocumentBrowserViewController
のサブクラスで、アプリ起動時の初期画面となります。テンプレートの初期状態では、ドキュメントを開くことはできますが、新規作成ができません。ドキュメントの新規作成に対応するために、
func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void)
を実装します。DocumentBrowserViewController.swiftfunc documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void) { let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent("Document.mydoc") let document = Document(fileURL: temporaryURL) document.save(to: temporaryURL, for: .forCreating) { (success) in if success { importHandler(temporaryURL, .move) } else { importHandler(nil, .none) } } }まず、Documentオブジェクトを生成し、一時ファイルとして保存します。保存処理が完了したら、
importHandler
を呼び出します。ImportModeとして.move
を指定しているので、一時ファイルを削除する必要はありません。一時ファイルのファイル名は、作成するドキュメントのファイル名になります。拡張子は、プロジェクト設定で定義したものにします。今回は、ファイル名を
Document.mydoc
と固定にしていますが、同じ名前のファイルが存在する場合は、Document 2.mydoc
のように自動的にサフィックスが付与されますので心配はいりません。なお、このメソッドは非同期でデザインされているため、ファイル名を入力するダイアログボックスや、テンプレートを選択する画面を表示することもできます。最後に
importHandler
を呼び出すのを忘れないように気をつけてください。DocumentView
最後に、DocumentViewを実装します。
DocumentView
は、Document
の表示、更新を行うユーザーインターフェースを提供します。DocumentView.swiftimport SwiftUI import Combine struct DocumentView: View { @ObservedObject var document: Document @State private var showImagePicker: Bool = false var dismiss: () -> Void var body: some View { return VStack(spacing: 30) { Text(document.localizedName) .font(.title) Group { if document.image == nil { Button(action: { self.showImagePicker = true }) { Image(systemName: "camera.on.rectangle").imageScale(.large).background(RoundedRectangle(cornerRadius: 6).foregroundColor(Color.secondary.opacity(0.1))) } } else { Image(uiImage: document.image!).resizable().aspectRatio(contentMode: .fit).frame(width: 240, height: 240) .onTapGesture { self.showImagePicker = true } } } TextView(text: bind(\.text)) .frame(width: 240, height: 80) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary, lineWidth: 1)) Button("Done", action: dismiss) }.sheet(isPresented: $showImagePicker) { ImagePicker(image: self.bind(\.image)) } } }
DocumentView
は、以下の4つのコンポーネントで構成されています。
Text(document.localizedName)
- ドキュメント名を表示します。
Group
- document.imageの内容を表示、編集します。タップで、ImagePickerを表示します。
TextView(text: text)
- document.textの内容を表示、編集します。
Button("Done")
- ドキュメントを閉じます。
基本的にはこれだけですが、
UIDocument
を更新するUIの実装で考慮しなければならないことがあります。それは変更の通知です。UIDocument
は適切なタイミングでドキュメントを保存しますが、それにはUIDocument
自身が変更されたことを知っている必要があります。document.hasUnsavedChanges
がそれに当たり、hasUnsavedChanges
がtrue
の時、ドキュメントは自動保存されます。ただし、このプロパティはreadonlyのため、直接設定することはできません。func updateChangeCount(_ change: UIDocument.ChangeKind)
はhasUnsavedChanges
を更新する手段のひとつですが、よりよい実装として、UndoManager
を使う方法があります。
UndoManager
は、操作の取り消し、やり直しを実現するクラスです。UIDocument
はUndoManager
のインスタンスを保持しており、これを利用することができます。プロパティを変更した時にdocument.undoManager
に変更前の値に戻す処理を登録することで、AppがUndo/Redoの能力を手に入れるのと同時に、UIDocument
に変更を通知することができます。以下はSwiftUIの話になりますが、サンプルコードでは、値の変更をハンドリングするために、下記のメソッドを定義して動的に
Binding
オブジェクトをViewコンポーネントに渡すようにしています。ただし、この部分に関しては、あまり自信がありません。もっと良い方法があれば教えてください。DocumentView.swiftextension DocumentView { private func bind<Value>(_ keyPath: ReferenceWritableKeyPath<Document, Value>) -> Binding<Value> { let document = self.document return Binding<Value>(get: { () -> Value in return document[keyPath: keyPath] }, set: { (value) in let oldValue = document[keyPath: keyPath] document.undoManager.registerUndo(withTarget: document) { $0[keyPath: keyPath] = oldValue } document[keyPath: keyPath] = value }) } }ここまでできたら、プロジェクトを実行してみましょう。1
iCloud Drive
にファイルを作成して、デバイス間で相互に変更が反映されることを確認してみてください。ホーム画面でAppアイコンを長押ししてファイル選択をショートカットしたり、ファイル.app
からAppが起動することも確認してみてください。課題
世の中そんなに甘くはありません。うまい話には裏があります。
実際のプロダクトでドキュメントベースAppを開発するに当たっての課題を紹介します。
ファイル数の限界
UIDocument
やFileWrapper
は、ファイルパッケージを一括で操作するため、含まれるファイル数が増えるにしたがってパフォーマンスが低下します。やむを得ずこれらの使用を断念する場合は、NSFilePresenter
による変更監視、NSFileCoordinator
による協調読み書き、バックグランドキューによる非同期のファイルアクセス、サンドボックス外のファイルに対するアクセス権の取得など、すべてを自前で行う必要があります。また、一時ディレクトリを使わずにファイルを直接書き出す場合は、整合性が保証されないことを考慮した設計が必要になり、開発の難易度は一気に上昇します。ドキュメントベースAppの開発を始める前に、想定する最大数のダミーファイルを用意してパフォーマンスを計測するのをおすすめします。
ファイルパッケージの越えられない壁
ファイルパッケージは、iOSおよびmacOSでのみ有効です。外の世界に出た瞬間、それは普通のディレクトリとして扱われます。そのため、
Dropbox
やGoogle Drive
などの外部プロバイダにファイルパッケージを保存することができません。また、メールなどで送信した場合も期待通りの結果にはなりません。実際のプロダクトでファイルパッケージを採用する場合は、この点が課題となるでしょう。AppleのGarageBandでは、サポートページに次のような案内があります。
iOS 用 GarageBand 2.3 では、iOS 用 GarageBand の曲を iPhone、iPad、iPod touch にローカルに、または iCloud Drive にだけ保存できます。
iOS 11 では、iOS 用 GarageBand 2.3 とファイル App を連係させて、GarageBand プロジェクトを管理できます。ファイル App は他社のクラウドストレージサービスに対応していますが、GarageBand のプロジェクトを以下のクラウドストレージサービスに保存することはできません。
- DropBox
- Google Drive
- Box
- Microsoft OneDrive
iOS 11 における iOS 用 GarageBand 2.3 と他社のクラウドストレージ App について - Apple サポート
ファイルの競合(コンフリクト)
ファイルが同時に更新された場合、ファイルが競合状態になることがあります。競合状態は解決しなければなりませんが、その方法は様々です。最新の変更ですべてを上書きすることもできますし、プログラムで判断して自動的にマージすることもできます。ユーザーに選択肢を提示することもできます。
URLの保持
次回の起動に備えて、最後に開いたファイルのURLを記憶しておきたいと思うことがあるかも知れません。このような場合、ファイルのURLを直接記録してはいけません。なぜなら、ユーザーは次にAppを開く前にファイル名を変更したり、場所を移動したりする可能性があるからです。かわりにURLからブックマークを生成して、それを記録するようにします。
読み取り専用での共有
iOSではファイルを読み取り専用にすることはできませんが、
iCloud Drive
上のファイルは、読み取り専用で他のユーザーと共有することができます。ユーザーが読み取り専用のファイルを変更しても、自動的に元の状態に戻されるので大きな問題にはなりませんが、編集ボタンをロックするなどして、ユーザーがドキュメントを変更できないようにする方がよいでしょう。読み取り専用かどうかはURLResourceKey.ubiquitousSharedItemCurrentUserPermissionsKey
で判定することができます。さいごに
ドキュメントベースAppを採用することで、とても簡単にリッチで安全なAppが開発できることがおわかりいただけたでしょうか?
実際にプロダクトとして完成させるまでには様々な困難を乗り越えなければなりませんが、土台としてこれほど有用なものはありません。
クラウドサービス全盛の時代、このようなスタンドアロンAppを開発する機会はあまりないかも知れませんが、ドキュメントベースAppというワードだけでも覚えておいて損はないと思います。
最後までお読みいただきありがとうございました。
Xcode 11.3付属のiOS 13シミュレータは、macOS Catalinaより前の環境ではUIDocumentBrowserViewControllerが正常に動作しないという問題があります。 ↩
- 投稿日:2020-02-06T14:16:33+09:00
[初心者向け]UserDefaultsを使って永続的に配列を保存する
Userdefaultsの復習
UserDefaultsのインスタンスの生成
まずはUserDefaulsのインスタンスをクラスの上部で作成します
ViewController.swiftvar userDefaults = UserDefaults.standardデータを保存する
次にデータを保存します.
UserDefaultsはInt
,String型
,配列
,Date型
,Float型
,Double型
の保存に対応していて、保存の方法は全て同じです.今回は
names
という配列を保存します.ViewController.swiftuserDefaults.set(names, forKey: "キー")データを取り出す
次にデータを読み出してみます.
取り出す場合は取り出す変数の型により取り出し方が異なります.Int型
ViewController.swiftlet number = userDefaults.integer(forKey: "キー")String型
ViewController.swiftlet name = userDefaults.string(forKey: "キー")配列
ViewController.swiftlet names = userDefaults.array(forKey: "キー")Date型
ViewController.swiftlet date = userDefaults.object(forKey: "キー") as? Date配列を保存する場合の注意
配列をUserDefaultsを使って保存する場合を考えてみます.
このような場合、そのまま保存して良いのでしょうか?違いますね
配列に保存する場合は、事前に保存されているデータを読み込んでからそこに追加をして、保存する必要があります.
そうしないと、毎回データを上書きしてしまい、一向に追加して保存されません.保存する
names
というString型の配列に変数name
を保存する場合を考えてみます.まず保存してあるかどうかを判別し、保存してあれば中身を取り出します.
ViewController.swiftvar names = userDefaults.array("キー") as? [String] ?? []上で使用している
??
は、保存されたデータがない場合にnames
に空の配列を代入させるために使用しています.次に実際に
names
に新たなデータを追加して保存していきます.ViewController.swiftnames.append(name) userDefaults.set(names, forKey: "キー")配列に変数を新たに追加する場合は
append
というメソッドを使用します.上記をまとめるとこんな感じです.
ViewController.swiftvar names = userDefaults.array("キー") as? [String] ?? [] names.append(name) userDefaults.set(names, forKey: "キー")取り出す場合
取り出す場合は特に留意する点はありませんが、
userDefaults
で取り出す値はどれもオプショナル型になっているため、同じように??
演算子を使用しますViewController.swiftlet names = userDefauls.array("キー") as [String] ?? []最後に
初めてUserDefaultsで配列の保存や取り出しを行う際に、うまく保存できずにつまづいた経験があったので共有しました.
- 投稿日:2020-02-06T11:24:24+09:00
UIViewのExtensionでフェードイン・アウトアニメーションを実装する
UIViewでフェードイン・アウトをExtensionとして実装しました。
環境
- Swift 5
- Xcode 11.2.1
フェードイン・アウトを行うExtension
UIViewを継承しているクラスなら利用できるので案外便利
extension UIView { // MARK: Animation enum Fade { case `in` case out } /// フェードインアウトを行う /// - Parameters: /// - type: fadeタイプ /// - animation: アニメーションを実行するか func fade(type: Fade, animation: Bool = true) { switch type { case .in: isHidden = false UIView.animate(withDuration: 0.3) { self.alpha = 1.0 } case .out: if animation { isHidden = true UIView.animate(withDuration: 0.3) { self.alpha = 0.0 } } else { alpha = 0.0 isHidden = true } } } }あとがき
簡潔に分岐処理を書けてない気がするのでもっとこうしたらスマートになるよ!とかあったら教えてください
- 投稿日:2020-02-06T10:23:09+09:00
ボタンタップ時の領域内タップ位置判定の愚考
ボタンタップ時にどこをタップしたか知りたかった
iOSアプリ開発の駆け出しエンジニアが、
ボタンタップイベント発生時に、ボタン内のどこをタップしたかを取得する実装を愚考した。
今回は、ボタン内を三分割した場合の位置をenum
で持つことにした。実際に作成したenum
縦横方向それぞれのenumを持つenumを定義し、
initで位置を判定、失敗時はnone
。/// ボタン内のタップ位置取得 public enum ButtonPointer { /// 縦方向 enum Vertical { case none case top case middle case bottom init(of button: UIButton, for event: UIEvent) { if let location = event.touches(for: button)?.first?.location(in: button){ if location.y < button.frame.size.height / 3 { self = .top } else if location.y < button.frame.size.height / 3 * 2 { self = .middle } else { self = .bottom } } else { self = .none } } } /// 横方向 enum Horizontal { case none case left case center case right init(of button: UIButton, for event: UIEvent) { if let location = event.touches(for: button)?.first?.location(in: button){ if location.x < button.frame.size.width / 3 { self = .left } else if location.x < button.frame.size.width / 3 * 2 { self = .center } else { self = .right } } else { self = .none } } } }usage
// addTargetでボタンにイベントを追加した場合 @objc func didTapButton(_ sender: UIButton, event: UIEvent) { print(ButtonPointer.Vertical(of: sender, for: event)) // → top } // ボタンのイベントをIBActionで紐づけた場合 @IBAction func didTapButton(_ sender: Any, forEvent event: UIEvent) { guard let sender = sender as? UIButton else { return } print(ButtonPointer.Vertical(of: sender, for: event)) // → left }おわりに
駆け出しすぎて実装に不安が残るが、
とりあえずアウトプットすることが大事と信じることにする。