iPhoneアプリ『妄想通知』開発録
開発したアプリ
処女作『妄想通知』アプリ開発の記録。
自分がそうだったように、これから初めてアプリ開発をする人の助けや参考になる記事になるように、自分が直面した課題やつまづいた所、解決策等について記します。
- 開発したアプリ
- 開発動機
- 構想
- わからなかったこと
- 学習
- 直面した課題
- Swiftの配列
- データの保存(自作クラスのシリアライズ)
- 背景写真の登録
- 多言語対応
- 通知の登録
- 以下随時更新
- 今後試してみたいこと
開発動機
本アプリが処女作。
アプリの開発から販売までの流れと、App Store市場の動向の把握が目的。
最初のアプリは、上に挙げたように、流れと動向の把握が目的なので簡単に作れて販売まで短期間で到れることが必要。
妄想の通知画面の壁紙を作るアプリはあるものの、通知そのものを発行するものは意外となかったので、すぐできそうだったので作ることに。
構想
OneNoteを使用し以下のような、最低限盛り込みたい機能やUIのデザインの構想を練る
わからなかったこと
- Swift
- Xcode
- AutoLayout
- どうやって販売するのか
等々、まるで初めてだったのでわからないことだらけだった。
ただし、以前にPlayStation Mobileのアプリの開発・販売をしたことがあり、その経験が役に立った。
学習
まずはじめに、Udemyの以下のビデオ講座を購入し受講。
定価は1万数千円だったが、セールで2400円で購入。
このビデオを一通りみて、おおよその開発の流れと、GUIパーツの仕様や使用方法、Xcodeの使用方法を摑む。
ただしAutoLayoutには一切触れられていなかったので別途、ドットインストールのプレミアム会員に1ヶ月だけなり、iOSレイアウト入門 の動画を全て視聴しオートレイアウトを学習。
その他に、書籍を2冊購入するなど。
直面した課題
Swiftで困惑した記法
Swiftは以前にも実践抜きで座学だけしたことあるが、今まで触ったどの言語とも違う感じで難解だという印象だった。
今回、数年越しで実践に踏み込めて触った印象は、やはりややこしい!
オプショナル型、アンパック
オプショナル型という概念は、Swift特有のもので、今まで触って切った言語にはなかったので、いまいち理解できずにいた。
オプショナル型やアンラップの概念については下記の記事がもっとも理解しやすかった。
_という記法、ワイルドカード
Swiftでは_
(アンダーバー、アンダースコア)という記号が使える。
これはワイルドカードを意味していて、使い捨ての匿名の変数のような形で使うことができる。
外部引数名
Swiftには外部引数名という概念があり、これも他の言語にはみられない特徴だ。
外部引数名とは、メソッドの等の定義で内部で使う引数名とは別に、メソッドを呼び出す側からはどう見えるかということを扱う概念。
func bmi(weight kg:Double, height cm:Double) -> Double { // ... }
weight、heightのところが外部引数名である。
一般的に見慣れている形は以下のような感じなので、上の記法を初めてみると、一体なんのこっちゃ???とかなり面を喰らう。
func bmi(kg:Double, cm:Double) -> Double { // ... }
この外部引数名によって、呼び出す側からは、kgとcmではなく、wightとheightを指定することになる。
カルーセルみたいなパーツの作り方
『妄想通知』では、上図の赤枠の部分をWebサイトでいうカルーセルのように左右に指でスワイプできるようになっている。
構想で、そういう機能にしようと決めたものの、iOSアプリでそれをどのように実現するかはわかっていなかった。
iOSアプリでカルーセルビューの作り方
これをiOSアプリで簡単に実現するためには、「Scroll View」というUIパーツを使う。
StoryBoard上でScrollView(以下SV)をビュー上に配置する。
だが、このままだとWebサイトを見る時のような区切りのないスクロールになってしまう。
Paging Enabled
というプロパティにチェックを入れるだけで、ページ単位でスワイプできるようになる。
後はこのSVの子としてSVと同じ幅と高さの要素を3つ、重ならないように追加していく。
その追加する要素はコードで生成しているが、構想した機能として、名前
と背景画像
を持つオブジェクトが3つと規定していたので、それぞれ独立した名前と背景画像を3つずつ配置するのではなく、名前と背景画像がワンセットとなったパーツとしてのオブジェクトを3つ、しかもその見た目はインターフェースビルダー(IB)で設定したいと考えていた。
PlayStation Mobile(以下PSM)の時は、そういったサブビュー、あるいはカスタムビューを、親のメインビューの子として、配置できたので、Xcodeではどのように実現すればいいのか探ることにした。
文章ではわかりづらいが、自分がやりたかったのは以下のようなことである。
クラスのように、ひとつの機能がまとまったオブジェクトを簡単にインスタンスとして配置、なおかつそのパーツの配置や設定は、メインのビューとは独立したインターフェース上でしたかった。
カスタムビュー(xib)を作成しそれを本体ビューで使う
PSMの時は、それが簡単手軽にできたので、Xcodeでもきっとできるはずと探ると、xibという概念が浮上した。
CustomView.xib
と命名したこの、メインビューとは独立したパーツ用として作成したこのビューでは、文字が重なっていてわかりにくいが背景画像用のImageViewと名前用のLabelを配置している。
ここで作成した小パーツをメインビュー上で、配置しようというわけである。
StoryBoard上でもこれを見た目を反映した上で配置できるらしいが、妄想通知ではコード上で動的に生成している。
ちなみに、SB上で見た目を反映させた上で配置するためには
@IBDesignable class Hoge: UIView {
このように、xibファイルと対にしたソースファイルのクラスの上に@IBDesignable
というデコレーションをする必要がある。
xibファイルとソースコードを関連づけるためには、File's Owner
を設定してあげる必要がある。
参考
サブビューの初期化コード
override init(frame: CGRect) { super.init(frame: frame) loadXib() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! loadXib() } // 初期化 fileprivate func loadXib() { let bundle = Bundle(for: type(of: self)) let nib = UINib(nibName: "CustomView", bundle: bundle) let view = nib.instantiate(withOwner: self, options: nil).first as! UIView addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false let bindings = ["view": view] addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options:NSLayoutFormatOptions(rawValue: 0), metrics:nil, views: bindings)) addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options:NSLayoutFormatOptions(rawValue: 0), metrics:nil, views: bindings)) // ...
Swiftの配列
スクリプト言語に慣れてると扱いが難しい
ここが一番わかりやすかった
データの保存(自作クラスのシリアライズ)
ビルトインの型であれば、簡単な手順で保存できるが、独自にクラス定義したデータの保存は、シリアライズ、デシリアライズ処理が必要となり少々面倒くさい。
妄想通知は以下のようになっている。
書き込み
var dataArray:[SendToData] = [SendToData]() // ... // シリアライズして書き込み let encodeData = NSKeyedArchiver.archivedData(withRootObject: dataArray) userDefaults.set(encodeData, forKey: "DataStore") userDefaults.synchronize()
読み込み
let decoded = UserDefaults.standard.object(forKey: "DataStore") as! Data dataArray = NSKeyedUnarchiver.unarchiveObject(with: decoded) as! [SendToData]
クラス
// データ保存用のクラス class SendToData: NSObject, NSCoding { var image: UIImage? var name: String init(image: UIImage?, name: String) { self.image = image self.name = name } // MARK: - NSCoding public required init(coder aDecoder: NSCoder){ name = aDecoder.decodeObject(forKey: "name") as! String image = aDecoder.decodeObject(forKey: "image") as? UIImage } public func encode(with aCoder: NSCoder){ aCoder.encode(name, forKey: "name") aCoder.encode(image, forKey: "image") } }
このクラスでは通知される宛名var name: String
と、背景画像var image: UIImage?
を保存する構造になっている。
データのシリアライズとデシリアライズを行いたい場合、NSObject
とNSCoding
という物を継承したクラスを作る必要があるらしい。
参考
背景写真の登録
妄想通知では、宛名の背景部分をタップすると背景写真の登録を行える。
カメラロールへアクセスするためには、info.plist
にPrivacy - Photo Library Usage Description
を登録する必要がある。
value
は任意の文字列で審査が通った。
コード
class CustomView: UIView, UITextFieldDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate { @IBOutlet var backImageView: UIImageView! @IBOutlet var toSendLabel: UILabel! @IBOutlet var nameInputField: UITextField! var index: Int? var sv: UIViewController? var dataArray = [SendToData]() override init(frame: CGRect) { super.init(frame: frame) loadXib() } required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder)! loadXib() } // 初期化 fileprivate func loadXib() { // ... // タップジェスチャーの登録 backImageView.isUserInteractionEnabled = true let aSelector2 : Selector = #selector(self.setImage) let tapGesture2 = UITapGestureRecognizer(target: self, action: aSelector2) tapGesture.numberOfTapsRequired = 1 backImageView.addGestureRecognizer(tapGesture2) // ... } // 画像の登録 func setImage() { // フォトライブラリを使用できるか確認 if UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.photoLibrary) { let picker = UIImagePickerController() picker.modalPresentationStyle = UIModalPresentationStyle.popover picker.delegate = self picker.sourceType = UIImagePickerControllerSourceType.photoLibrary sv!.present(picker, animated: true, completion: nil) } } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) { // 選択した画像・写真を取得し、imageViewに表示 if let pickedImage = info[UIImagePickerControllerOriginalImage] as? UIImage { backImageView.image = pickedImage saveData(value: pickedImage, type: .image) } // フォトライブラリの画像・写真選択画面を閉じる picker.dismiss(animated: true, completion: nil) }
多言語対応
PSMの時の多言語対応はビューを作るUI上でできたのだが、Xcodeでの多言語対応は少々手順がややこしかった。
コード上で出てくる部分については以下を参考にし
StoryBoard上のパーツの多言語化は以下を参考にした。
通知の登録
通知には、発信はデバイス内アプリから出してデバイス上だけで完結するローカル通知
と、サーバーから発信しデバイスで受信するPush通知
の2種類がある。
Push通知では、鍵や認証局等のサーバーのややこしい登録が必要だが(サーバーといっても主にFirebaseが使用されている)、
ローカルだけで完結する場合の通知は大した手間がいらない。
ただし、iOS10からは、この通知のフレームワークが一新されたらしく、それ以前の情報は役に立たないことがあるので注意されたい。
AppDelegete.swift
AppDelegete.swift
ファイルにUNUserNotificationCenterDelegate
と以下のコードを実装する。
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // ... // ローカル通知 if #available(iOS 10.0, *) { // iOS 10 let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.badge, .sound, .alert], completionHandler: { (granted, error) in if error != nil { return } if granted { debugPrint("通知許可") center.delegate = self } else { debugPrint("通知拒否") } }) } else { // iOS 9 let settings = UIUserNotificationSettings(types: [.badge, .sound, .alert], categories: nil) UIApplication.shared.registerUserNotificationSettings(settings) } return true }
ViewController.swift
class ViewController: UIViewController, UITextViewDelegate, UNUserNotificationCenterDelegate, UITextFieldDelegate, UIScrollViewDelegate { // ... // 通知登録 @IBAction func send(_ sender: Any) { // もしメッセージが空なら通知を登録しない if validation() { let _ = showAlert(isValidSuccese: true) } else { let _ = showAlert(isValidSuccese: false) return } let trigger: UNNotificationTrigger trigger = UNTimeIntervalNotificationTrigger(timeInterval: Double(picker.placementAnswer.value!), repeats: false) let content = UNMutableNotificationContent() content.title = cvArray[getCurrentPageNumber()].toSendLabel.text! content.body = messageInputField.text content.sound = UNNotificationSound.default() let request = UNNotificationRequest(identifier: NSUUID().uuidString, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) } // フォアグラウンドで通知受信する場合は実装 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .sound]) // 通知バナー表示、通知音の再生を指定 } // 通知開封 func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { debugPrint("opened") completionHandler() // 通知センターへストックされているこのアプリからの全通知を削除 // center.removeAllDeliveredNotifications() }
func userNotificationCenter
を実装しない場合は、フォアグラウンド中に動作中のアプリの通知が発火されても通知が出現しない。
参考
iOS10
iOS9以前の通知
以下随時更新
- ピッカービューを下から出す
- オートレイアウト
- オブジェクトの一辺にだけボーダーを引く、IB上でも編集できるようにする
- レビューを促すダイアログを出すライブラリの導入
- 実機での動作確認
- AppStoreへの申請
- 今回使用したツール、ライブラリ
- 妄想通知のソースコード一式(Github)
今後試してみたいこと
- 他アプリ起動中の共有パネルでのアクション登録
- Open In xxxx
- DLCコンテンツ、課金システムの実装
随時更新。