- 投稿日:2020-09-15T23:38:18+09:00
異なる階層の View の座標を取得しよう
今回も初学者向けに階層の異なる特定の View の座標を取得したい時に使用する
convert()
関数の使い方を簡単に紹介したいと思います。実際に使ってみる??
下記のような階層の View を参考に
convert()
メソッドを使って見て座標を取得してみます。緑色からみた黄色
これは、そもそも
緑色の View
の中に黄色の View
が含まれている(addSubView()
されている)ので、黄色の View の Frame を取得すれば座標が分かります。let greenToYellowRect = yellowView.frame print(greenToYellowRect.toString) // (x: 19.0, y: 38.5)また、
toString
というプロパティは下記のように CGRect の Extension プロパティとして追加すると独自に値を取得するためのプロパティを追加することができるようになります? 特に iOS 開発ではこの Extension 記法は頻繁に使用するので、どんどん書いて使い方をマスターしましょう!extension CGRect { var toString: String { return "(x: \(self.origin.x), y: \(self.origin.y))" } }赤色からみた黄色
次に、赤色の View から見た黄色の View の座標を取得していきましょう。先ほどと同じように
frame
を使って座標を取得したいところですが、今回は赤色から見た黄色の座標を取得したいので、frame
で取得してしまうと緑色から見た黄色になってしまうので気をつけましょう。それでは早速、
convert()
メソッドを使って赤色から見た黄色の座標を取得してみます。let redToYellowRect = greenView.convert(yellowView.frame, to: redView) print(redToYellowRect.toString) // (x: 94.5, y: 146.0)それぞれの View の指定方法は、最後のセクションにまとめてあります。
白色からみた黄色
次に、白色の View からみた黄色の View の座標を取得していきます。コードは下記のようになります。
let whiteToYellowRect = greenView.convert(yellowView.frame, to: self.view) print(whiteToYellowRect.toString) // (x: 181.5, y: 315.0)それぞれの View からみた黄色の View の正しい座標が取れるようになりました?
まとめ
convert()
メソッドは、どこにどの View を指定するのか忘れがちなので下記のように覚えておくと便利です。
convert(to:)
の場合let redToYellowRect = 座標を知りたいViewの親View.convert(座標を知りたいView.frame, to: 座標変換の基準となるView)
convert(from:)
の場合let redToYellowRect = 座標変換の基準となるView.convert(座標を知りたいView.frame, from: 座標を知りたいViewの親View)参考
- 投稿日:2020-09-15T22:59:20+09:00
【1日目】Youtubeアプリ開発日記(iOS) / 各種設定からYoutube API をアプリからリクエスト
概要
iPhoneアプリ開発を行う人のために、教材となればいいなと思い、この記事を書いていきます。
つくっていくアプリは、YoutubeのiPhoneアプリを開発します。この記事のゴール
- YoutubeのAPIを使えるようにする
- iPhoneアプリでYoutubeのAPIをリクエストできるようにする
アプリの使用ライブラリを先に紹介
- Alamofire (API通信ライブラリ)
- Moya/RxSwift (Alamofire補完ライブラリ)
- RxSwift (リアクティブプログラミング)
準備
いきなりハードルが高くなりそうですが、まずはYoutube Data API を登録します。
https://developers.google.com/youtube/v3/getting-started?hl=ja手順
- Googleアカウントで、Google Cloud Console へのアクセス
- プロジェクトを作成
- API Keyをこちらで作成
- API Keyは、アプリで使うのでメモしておきましょう。
- Services から、YouTube Data API を検索して、ステータスをオンにする
補足
Google Cloud Consoleで、プロジェクト作成
API を試してみよう
API Keyを作成できたら、そのKeyを使ってAPIが実際に使えるのかを確認しましょう。
APIは、Youtube動画を検索できるAPI (/youtube/v3/docs/search/list
) を使います。APIの構成
https://www.googleapis.com/youtube/v3/search?part=snippet&q={検索キーワード}&key={自分のAPI KEY}
レスポンス例
{ "kind": "youtube#searchListResponse", "etag": "BaxNpbPgVkyQQVmx4qeEzGdzCTo", "nextPageToken": "CAEQAA", "regionCode": "JP", "pageInfo": { "totalResults": 1000000, "resultsPerPage": 1 }, "items": [ { "kind": "youtube#searchResult", "etag": "MkmmQLdB1WA8Icy7XbfCfXQcdBI", "id": { "kind": "youtube#channel", "channelId": "UCZf__ehlCEBPop-_sldpBUQ" }, "snippet": { "publishedAt": "2011-07-19T11:31:43Z", "channelId": "UCZf__ehlCEBPop-_sldpBUQ", "title": "HikakinTV", "description": "HikakinTVはヒカキンが日常の面白いものを紹介するチャンネルです。 ◇プロフィール◇ YouTubeにてHIKAKIN、HikakinTV、HikakinGames、HikakinBlogと 4つの ...", "thumbnails": { "default": { "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s88-c-k-no-mo-rj-c0xffffff/photo.jpg" }, "medium": { "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s240-c-k-no-mo-rj-c0xffffff/photo.jpg" }, "high": { "url": "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s800-c-k-no-mo-rj-c0xffffff/photo.jpg" } }, "channelTitle": "HikakinTV", "liveBroadcastContent": "upcoming", "publishTime": "2011-07-19T11:31:43Z" } } ] }Firebaseプロジェクトを設定
Firebaseコンソール にアクセスして、自分のアプリのプロジェクトを作成しましょう。
ドキュメントのSTEP1 ~ STEP5までを行えば、基本的には問題ないですが、Firebaseは親切なので、以下の画像のように順番にやるべきことを指示してくれます。
アプリを作成
Xcodeをインストールして、アプリの初期画面を作成するというのは、こちらの記事などを参考にしてみてください。
ライブラリをインストール
Firebaseの初期設定で、すでにcocoapodsで
Podfile
を作成していると思いますので、cocoapodsを使ってライブラリを追加していきます。Podfile
の基本的な使い方はこちらの記事を参考にしてみると良いでしょう。それでは、実際に
Podfile
に、前述のライブラリを追加しましょう。target 'アプリ名' do pod 'Firebase/Analytics' pod 'Alamofire', '~> 5.2' pod 'Kingfisher', '~> 5.0' pod 'Moya/RxSwift', '~> 14.0' endこれで、
pod install
をしてください。
アプリ.xcworkspace
を開けば、インストールしたライブラリが使える状態になっています。ようやくコードが書けます!お疲れ様です!
UIViewController
にAPIリクエストを実装import UIKit import Alamofire class ViewController: UIViewController { private let apiKey = "xxxxxxxxx" override func viewDidLoad() { super.viewDidLoad() let keyword = "ヒカキン" let urlString = "https://www.googleapis.com/youtube/v3/search?part=snippet&q=\(keyword)&key=\(apiKey)" let request = AF.request(urlString) request.responseJSON { response in print(response) } } }Youtube API の通信
ここでAPI通信処理を担うライブラリ
Alamofire
を使うためにインポートします。import Alamofireあとは簡単。↓で、リクエストするURLを生成します。
let keyword = "ヒカキン" let urlString = "https://www.googleapis.com/youtube/v3/search?part=snippet&q=\(keyword)&key=\(apiKey)"↓で、APIにリクエストします。
let request = AF.request(urlString)最後に、受け取ったレスポンスを処理します。
ここでは、レスポンスをログに出力するのみにしています。request.responseJSON { response in print(response) }レスポンス
以下のように、コンソールにログが出力されたら成功です。
success({ etag = "1pYoINxiqsB91hilou77FGlS_1E"; items = ( { etag = MkmmQLdB1WA8Icy7XbfCfXQcdBI; id = { channelId = "UCZf__ehlCEBPop-_sldpBUQ"; kind = "youtube#channel"; }; kind = "youtube#searchResult"; snippet = { channelId = "UCZf__ehlCEBPop-_sldpBUQ"; channelTitle = HikakinTV; description = "HikakinTV\U306f\U30d2\U30ab\U30ad\U30f3\U304c\U65e5\U5e38\U306e\U9762\U767d\U3044\U3082\U306e\U3092\U7d39\U4ecb\U3059\U308b\U30c1\U30e3\U30f3\U30cd\U30eb\U3067\U3059\U3002 \U25c7\U30d7\U30ed\U30d5\U30a3\U30fc\U30eb\U25c7 YouTube\U306b\U3066HIKAKIN\U3001HikakinTV\U3001HikakinGames\U3001HikakinBlog\U3068 \Uff14\U3064\U306e ..."; liveBroadcastContent = upcoming; publishTime = "2011-07-19T11:31:43Z"; publishedAt = "2011-07-19T11:31:43Z"; thumbnails = { default = { url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s88-c-k-no-mo-rj-c0xffffff/photo.jpg"; }; high = { url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s800-c-k-no-mo-rj-c0xffffff/photo.jpg"; }; medium = { url = "https://yt3.ggpht.com/-NFhw6-eus8Y/AAAAAAAAAAI/AAAAAAAAAAA/rtPbnb9gvAQ/s240-c-k-no-mo-rj-c0xffffff/photo.jpg"; }; }; title = HikakinTV; }; } ); kind = "youtube#searchListResponse"; nextPageToken = CAEQAA; pageInfo = { resultsPerPage = 1; totalResults = 577518; }; regionCode = JP; })
- 投稿日:2020-09-15T22:12:31+09:00
iOSからQiitaのAPIを呼ぼうとしたら懐かしいエラーに遭遇した。未来の誰かのためにここに記す
iOS懐かしのエラー
昔よく見たエラーにふとした拍子に出くわししました 1
App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.これはメッセージの通り、HTTP通信をしようとしたときにiOSがブロックしてくる奴です。
QiitaのAPIを呼ぼうとしていた
URLSession
に渡しているurlは間違いなくhttpsのはず……let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")! let task = urlSession.dataTask(with: url) { data, response, error in ...エラーメッセージを確認してみる
- Error Domain=NSURLErrorDomain Code=-1022 "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection." UserInfo={NSUnderlyingError=0x6000007b4b40 {Error Domain=kCFErrorDomainCFNetwork Code=-1022 "(null)"}, NSErrorFailingURLStringKey=http://qiita.com/api/v2/items, NSErrorFailingURLKey=http://qiita.com/api/v2/items, NSLocalizedDescription=The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.}?!
NSErrorFailingURLKey=http://qiita.com/api/v2/items?per_page=3なぜだかわかりませんが、URLスキームがhttpに変わっています???
そして、ローカルホストに繋いだりするときと同じように、App Transport Security Settings の Allow Arbitrary Loads を YES に設定すると問題なく結果が返ってきました。
犯人は……?
小一時間ハマったのち、ようやく犯人が見つかりました。2
let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!よく見ると、URLの末尾にスラッシュがついている……?
え?でも、このURLをcurlで叩いても普通にレスポンスが返ってきてたような?
もう一度curlで叩いてみた
$ curl -D - https://qiita.com/api/v2/items/ HTTP/2 301 date: Tue, 15 Sep 2020 12:45:32 GMT content-type: text/html content-length: 178 location: http://qiita.com/api/v2/items server: nginx <html> <head><title>301 Moved Permanently</title></head> <body bgcolor="white"> <center><h1>301 Moved Permanently</h1></center> <hr><center>nginx</center> </body> </html>?!
なぜかhttpにリダイレクトされています3
QiitaのAPIを使うときは、末尾のスラッシュに気をつけましょう
という結論でした
let url = URL(string: "https://qiita.com/api/v2/items?per_page=3")!万が一同じ現象に遭遇した人がいれば、ここに辿り着けますように。
- 投稿日:2020-09-15T22:12:31+09:00
QiitaのAPIを呼ぶときに末尾にスラッシュをつけるとhttp://~にリダイレクトされるので気をつけましょう
TL;DR
タイトル通り。curlの結果はこちら。
iOS懐かしのエラー
昔よく見たエラーにふとした拍子に出くわししました 1
App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.これはメッセージの通り、HTTP通信をしようとしたときにiOSがブロックしてくる奴です。
QiitaのAPIを呼ぼうとしていた
URLSession
に渡しているurlは間違いなくhttpsのはず……let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")! let task = urlSession.dataTask(with: url) { data, response, error in ...エラーメッセージを確認してみる
- Error Domain=NSURLErrorDomain Code=-1022 "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection." UserInfo={NSUnderlyingError=0x6000007b4b40 {Error Domain=kCFErrorDomainCFNetwork Code=-1022 "(null)"}, NSErrorFailingURLStringKey=http://qiita.com/api/v2/items, NSErrorFailingURLKey=http://qiita.com/api/v2/items, NSLocalizedDescription=The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.}?!
NSErrorFailingURLKey=http://qiita.com/api/v2/items?per_page=3なぜだかわかりませんが、URLスキームがhttpに変わっています???
そして、ローカルホストに繋いだりするときと同じように、App Transport Security Settings の Allow Arbitrary Loads を YES に設定すると問題なく結果が返ってきました。
犯人は……?
小一時間ハマったのち、ようやく犯人が見つかりました。2
let url = URL(string: "https://qiita.com/api/v2/items/?per_page=3")!よく見ると、URLの末尾にスラッシュがついている……?
え?でも、このURLをcurlで叩いても普通にレスポンスが返ってきてたような?
もう一度curlで叩いてみた
$ curl -D - https://qiita.com/api/v2/items/ HTTP/2 301 date: Tue, 15 Sep 2020 12:45:32 GMT content-type: text/html content-length: 178 location: http://qiita.com/api/v2/items server: nginx <html> <head><title>301 Moved Permanently</title></head> <body bgcolor="white"> <center><h1>301 Moved Permanently</h1></center> <hr><center>nginx</center> </body> </html>?!
なぜかhttpにリダイレクトされています3
QiitaのAPIを使うときは、末尾のスラッシュに気をつけましょう
という結論でした
let url = URL(string: "https://qiita.com/api/v2/items?per_page=3")!万が一同じ現象に遭遇した人がいれば、ここに辿り着けますように。
- 投稿日:2020-09-15T16:20:08+09:00
Tabbarの数を調整する方法 (TabBarController)
概要
・Swift5
・Xcode11.3
での検証になります!こちらは、個人的なメモ書きとして残しております。
参考になった方は「いいね!」をしてもらえると嬉しいです。実装例
前提条件
・ 追加StoryBoardには、TabbarItemがついていることを想定しています。
・ 5つ目追加したり、削除したりする処理になっています。追加(1番後ろに追加します)
if (self.tabBarController?.viewControllers?.count ?? 5) > 4 { return } guard let viewControllers = self.tabBarController?.viewControllers else { return } if viewControllers.count > 4 { return } let storyboard = UIStoryboard(name: "xxxxxxx", bundle: nil) guard let viewController = storyboard.instantiateInitialViewController() else { return } self.tabBarController?.viewControllers?.append(viewController) // self.tabBarController?.viewControllers? を viewControllersにするとうまく動かないので注意ですm(_ _)m削除(1番最後の分を削除する)
guard let viewControllers = self.tabBarController?.viewControllers else { return } if viewControllers.count != 5 { return } self.tabBarController?.viewControllers?.removeLast()
- 投稿日:2020-09-15T16:20:08+09:00
TabBarControllerの数を調整(変更)する方法
概要
・Swift5
・Xcode 11.3
での検証になります!こちらは、個人的なメモ書きとして残しております。
参考になった方は「いいね!」をしてもらえると嬉しいです。実装例
前提条件
・ 追加StoryBoardには、TabbarItemがついていることを想定しています。
・ 5つ目追加したり、削除したりする処理になっています。
・ ViewController内で記載します。追加(1番後ろに追加します)
if (self.tabBarController?.viewControllers?.count ?? 5) > 4 { return } guard let viewControllers = self.tabBarController?.viewControllers else { return } if viewControllers.count > 4 { return } let storyboard = UIStoryboard(name: "xxxxxxx", bundle: nil) guard let viewController = storyboard.instantiateInitialViewController() else { return } self.tabBarController?.viewControllers?.append(viewController) // self.tabBarController?.viewControllers? を viewControllersにするとうまく動かないので注意ですm(_ _)m削除(1番最後の分を削除する)
guard let viewControllers = self.tabBarController?.viewControllers else { return } if viewControllers.count != 5 { return } self.tabBarController?.viewControllers?.removeLast()
- 投稿日:2020-09-15T14:47:44+09:00
【Swift】UILabelをUITextViewに変更する際の注意点(メモ)
UILabelで作っていたが、後々ハイパーリンクにしたいなどで、
UILabel→UITextViewに変更しなくては。。などというケースの注意点メモ。。。環境
- Xcode: 11.3.1
- Swift5
・UITextViewのスクロールをオフ
オートレイアウトを使用している場合、スクロールがONだと高さが計算されないので、
以下コードを追記textView.isScrollEnabled = false・UITextViewの余白(padding)を削除
実際に文字を表示してみると上下に余白ができてしまう。
以下コードを追記textView.textContainerInset = .zero備考
アプリ公開しました!よろしければインストールお願いします。
とらんぽTwitter始めました!よろしければフォローお願いします。
@yajima_tohshu
- 投稿日:2020-09-15T09:29:34+09:00
PHPickerViewControllerを使った動画の読み込み方法
iOS14で新しく登場したPHPickerViewControllerにおける動画の読み込み方法
PHPickerViewController
iOS14で新しくPHPickerViewControllerが追加されました。写真許可をユーザーからとる必要なく、また、OS標準の写真アプリとおなじUIで写真・動画選択ができるとても便利なものです。
Apple Documentation動画の読み込み
PHPickerViewControllerの表示
func addButtonTapped(_ sender: Any) { var configuration = PHPickerConfiguration() configuration.filter = .videos configuration.selectionLimit = 1 configuration.preferredAssetRepresentationMode = .current <- 動画を読み込む時のキモ let phPicker = PHPickerViewController(configuration: configuration) phPicker.delegate = self present(phPicker, animated: true, completion: nil) }動画の読み込み方法
- NSItemProviderを使います
- resultsのなかにPHAssetのassetLocalIdentifierが入っていますが、それを用いて
PHAsset. fetchAssets(withLocalIdentifiers:options:)
とやってしまうと写真許可のアラートが表示されてしまいます。- 写真許可がいらなく動画を読み込むためにはNSItemProviderを使います。
- ここで
NSItemProvider.loadFileRepresentation(forTypeIdentifier:completionHandler:)
を使います。- 先ほどのPHPickerViewControllerの表示のところで PHPickerConfiguration.preferredAssetRepresentationModeを
.current
に設定しておくのが大事です。ここで.currentに設定しない場合、システムがどのバージョンの動画を読み込むかの判定に時間がかかるため、数秒のタイムラグが発生します。ここで.currentを指定しておけば、1秒以内に動画を再生させることができます。 (参考: https://developer.apple.com/forums/thread/652695?answerId=629922022#629922022)func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) guard let provider = results.first?.itemProvider else { return } guard let typeIdentifier = provider.registeredTypeIdentifiers.first else { return } if provider.hasItemConformingToTypeIdentifier(typeIdentifier) { // not provider.loadInPlaceFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] (url, success, error) in // provider.loadFileRepresentation should be used // PHPickerConfiguration.preferredAssetRepresentationMode should be set .current, otherwise loading takes too long // https://developer.apple.com/forums/thread/652695?answerId=629922022#629922022 provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] (url, error) in if let error = error { print("*** error: \(error)") } if let url = url { let asset = AVURLAsset(url: url) // ... } } } }
- 投稿日:2020-09-15T08:43:16+09:00
【運動が続かない怠け癖をなくす】今までにない新しいフィットネスアプリを作ってみた
はじめに
初めまして, iOSエンジニアのakaakozこと小澤です。
今回は先月(2020年8月)リリースした周りのユーザーと監視しながら一緒に頑張るフィットネスiOSアプリ『モニトレ』を紹介したいと思います。
今年6月の中旬から開発をスタートして、9月15日にApp Storeにリリースしました。
この記事ではどんなサービスなのか、開発に至った背景、サービスの仕様と使用したコードについて書いていきます。
モニトレとは?
『モニトレ』は週に設定した筋トレやエクササイズのノルマを達成しながら、周りのユーザーと一緒に頑張るフィットネスアプリです。
(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)
例えば週に3回のノルマを設定した場合、週に3回自撮りしたエクササイズ動画をアップロードしないとアプリの"ナマケモノ"の欄に記載されてしまう、"辱め"という新しい形のモチベーションでユーザーの運動を継続させるサービスです。
以下の方にオススメのサービスです。
・運動の継続が難しい方、モチベーションが上がらない方
・値段が高いダイエットプログラムに加入せずに痩せたい方
・コロナ渦でジムに行けず、お家でトレーニングをしたい方ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/サービスを作った背景
私は前サービス『WalCal』(訪日外国人向け体験型サービス)を含めサービス開発をスタートしてから2年間、ほぼ休みなしでプログラミングをしていました。(週7日、起きている時間はほぼプログラミング)
そんな多忙な日々を送っていたある日、ストレートネックを発症し首を傷めてしまい、1週間ベットから起き上がれない状態になってしまいました。
あまりの痛みで自力で起き上がれなくなってしまったため、28歳にして親から介護を受ける事になってしまいました。(親には感謝です。ありがとう)
それから1週間後、自力で起き上がれる様になりましたが、まだ残っていた首の痛みとめまいからデスクに座って作業が出来ない状況がさらに1週間続きました。
以下の写真がその時に撮ったレントゲンです。(2020年6月3日)
運動の大切さを分かっていながらも忙しいことを理由に怠け癖がついていました。
あと"1時間後にやる"が"1日後にやる"になり気付いたらそれが1週間続き、運動&ストレッチを全くしない期間がザラにありました。1日10分すら自らの健康の時間を取れずにストレートネックを発症し、2週間何も作業が出来ず大切な時間を失ってしまいました。
この私が経験した様な、運動が継続的に出来ない怠け癖を解決出来ないかと考え開発に至りました。
仕様
1.周りのユーザーを評価
モニトレは週に設定したノルマを達成しないと、アプリの"ナマケモノ"の欄に記載されてしまいます。(達成出来れば"ヤルキモノ"になります。)
1人ではどうしても怠けが出てしまいますので、周りのユーザーと励まし合い、時には喝を入れながら切磋琢磨出来る機能です。2.ノルマの達成方法は自撮りのエクササイズ動画をアップロード
まずはモニトレのエクササイズ動画(スクワット、腕立て、腹筋、ストレッチなどのメニュー)を選んで一緒にエクササイズします。それと同時に自撮りのレコーディングも行っているので、終わったらそのままアップロード出来る仕様となっています。(※他ユーザーには自撮りのエクササイズ動画は閲覧出来ません。)
実際に私がモニトレで筋トレをしている様子です(YouTube)
モニトレのエクササイズ動画ですがYouTubeにアップロードしたものをYouTube Data Apiで引っ張っています。
3.スケジュール機能でエクササイズ管理
モニトレはスケジュール機能がついており、日々のエクササイズを管理できます。
メモ帳も使わずに過去のエクササイズ動画や自らのフォームもチェックできるため便利な機能かと思います。後ほどこの機能の実装方法を紹介します。
スケジュール機能のコードを紹介
開発言語はSwiftとデータベースはFirebaseを使用しています。(Android版も開発予定)
モニトレのスケジュール機能はサードパーティーライブラリのFSCalendarを使用して実装しています。
FSCalendar: https://github.com/WenchaoD/FSCalendar
カレンダー系アプリなどを作る時にものすごい便利なのでオススメです。Step1: FSCalendarをインストール
Cocoapods/CathageでXcodeプロジェクトにFSCalendarをインストール
Podfileを開きFSCalendarのpodを以下の様に入力use_frameworks! target '<Your Target Name>' do pod 'FSCalendar' endPodをインストール
$ pod installStep2:FSCalendarを実装
//Model import Foundation import UIKit import AVFoundation struct UploadedVideoInfo { let videoUrl: String let uploadDate: Date let min: Double let weekth: Int let dateString: String let thumbnailImageUrl: String init(dictionary: [String: Any]) { self.videoUrl = dictionary["videoUrl"] as? String ?? "" let secondsFrom1970 = dictionary["uploadDate"] as? Double ?? 0.0 self.uploadDate = Date(timeIntervalSince1970: secondsFrom1970) let dateFormatter = DateFormatter() dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP")) dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo") let strDate = dateFormatter.string(from: uploadDate) self.dateString = strDate self.min = dictionary["min"] as? Double ?? 0.0 self.weekth = dictionary["weekth"] as? Int ?? 0 self.thumbnailImageUrl = dictionary["thumbnailImageUrl"] as? String ?? "" } func dateFromComponent(date: Date) -> Int { let year = Calendar.current.component(.year, from: date) let month = Calendar.current.component(.month, from: date) let day = Calendar.current.component(.day, from: date) return year + month + day } }import UIKit import FSCalendar import Firebase import AVFoundation import Photos class CalendarController: UICollectionViewController, UICollectionViewDelegateFlowLayout, FSCalendarDelegate, FSCalendarDataSource, FSCalendarDelegateAppearance { var allUploadVideos = [UploadedVideoInfo]() var todayUploadVideos = [UploadedVideoInfo]() private let fsCalendarCellId = "fsCalendarCellId" //FSCalenderを定義 lazy var fsCalendar: FSCalendar = { let calender = FSCalendar() calender.backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1) calender.layer.cornerRadius = 5 return calender }() //VideoCollectionControllerはFSCalenarの日付をタップした時に過去のエクササイズを表示させるためのViewController lazy var videoCollectionController: VideoCollectionController = { let viewController = VideoCollectionController(collectionViewLayout: UICollectionViewFlowLayout()) return viewController }() override func viewDidLoad() { super.viewDidLoad() collectionView.backgroundColor = .black navigationItem.title = "スケジュール" navigationController?.navigationBar.prefersLargeTitles = true navigationController?.navigationBar.backgroundColor = .black navigationController?.navigationBar.barTintColor = .black navigationController?.navigationBar.barTintColor = .black navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] //FSCalendarの設定 fsCalendar.register(FSCalendarCell.self, forCellReuseIdentifier: fsCalendarCellId) fsCalendar.dataSource = self fsCalendar.delegate = self fsCalendar.appearance.headerTitleColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1) fsCalendar.appearance.weekdayTextColor = #colorLiteral(red: 0.1152259931, green: 0.8450154662, blue: 0.3753072321, alpha: 1) //以下のコードで日本語記載にします fsCalendar.locale = Locale(identifier: "ja_JP") fetchVideoInfo() setupFSCalendarView() } //過去のアップロードを引っ張ってくる関数 fileprivate func fetchVideoInfo() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP")) dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo") let today = dateFormatter.string(from: Date()) //let uploadDate = dateFormatter.string(from: vi) print("today", today) guard let uid = Auth.auth().currentUser?.uid else {return} Firestore.firestore().collection("exercise_uploads").document(uid).collection("exercise_uploadId").getDocuments { (snapshot, err) in if let err = err { print("failed to fetch video data", err.localizedDescription) return } snapshot?.documents.forEach({ (document) in let dict = document.data() let uploadedVideoInfo = UploadedVideoInfo(dictionary: dict) //Firestoreのデータベースから取ってきた self.allUploadVideos.append(uploadedVideoInfo) //ここで本日付の動画をフィルターします self.todayUploadVideos = self.allUploadVideos.filter({$0.dateString == today}) DispatchQueue.main.async { print("checking uploadvideos array", self.allUploadVideos) self.videoCollectionController.todayUploadVideos = self.todayUploadVideos self.fsCalendar.reloadData() } }) } } //ビューの配置と制約 fileprivate func setupFSCalendarView() { view.addSubview(fsCalendar) fsCalendar.anchor(top: view.safeAreaLayoutGuide.topAnchor, left: view.leftAnchor, bottom: nil, right: view.rightAnchor, paddingTop: 5, paddingLeft: 5, paddingBottom: 0, paddingRight: 5, height: view.frame.height / 2, width: 0) view.addSubview(videoCollectionController.view) videoCollectionController.view.anchor(top: fsCalendar.bottomAnchor, left: view.leftAnchor, bottom: view.safeAreaLayoutGuide.bottomAnchor, right: view.rightAnchor, paddingTop: 5, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0) } //FSCalendarのnumberofEventsForメソッドで日付ごとのエクササイズアップロード数を日付ごとの表示 func calendar(_ calendar: FSCalendar, numberOfEventsFor date: Date) -> Int { let dateFormatter = DateFormatter() dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP")) dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo") let calendarDay = dateFormatter.string(from: date) print("calendarDay", calendarDay) let todayUploadVideos = self.allUploadVideos.filter({$0.dateString == calendarDay}) return todayUploadVideos.count } //日付をタップした際に、VideoCollectionController内のCellへ過去のアップロード動画を表示させる関数 func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) { self.todayUploadVideos.removeAll() let dateFormatter = DateFormatter() dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yMMMd", options: 0, locale: Locale(identifier: "ja_JP")) dateFormatter.timeZone = TimeZone(abbreviation: "Asia/Tokyo") let calendarDay = dateFormatter.string(from: date) self.todayUploadVideos = allUploadVideos.filter({$0.dateString == calendarDay}) DispatchQueue.main.async { print("checking uploadvideos array", self.todayUploadVideos) //self.videoCollectionController.uploadVideos = self.uploadVideos self.videoCollectionController.todayUploadVideos = self.todayUploadVideos self.collectionView.reloadData() } } } class VideoCollectionController: UICollectionViewController, UICollectionViewDelegateFlowLayout { private let videoCollectionCellId = "videoCollectionCellId" private let videoCollectionFooterId = "videoCollectionFooterId" var todayUploadVideos: [UploadedVideoInfo]? { didSet { print("todayUploadVideos", todayUploadVideos) DispatchQueue.main.async { self.collectionView.reloadData() } } } override func viewDidLoad() { super.viewDidLoad() collectionView.register(VideoCollectionCell.self, forCellWithReuseIdentifier: videoCollectionCellId) collectionView.register(VideoCollectionFooter.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: videoCollectionFooterId) collectionView.backgroundColor = .black } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return todayUploadVideos?.count ?? 0 } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: videoCollectionCellId, for: indexPath) as! VideoCollectionCell let thumbnailImageUrl = todayUploadVideos?[indexPath.item].thumbnailImageUrl cell.thumbnailCustomImageView.loadImage(urlString: thumbnailImageUrl ?? "") return cell } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: view.frame.width / 3, height: view.frame.height) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return 1 } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return 0 } override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: videoCollectionFooterId, for: indexPath) as! VideoCollectionFooter footer.label.text = "アップロードした動画はありません" return footer } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { if todayUploadVideos?.count == 0 { return CGSize(width: view.frame.width, height: view.frame.height / 8) } else { return CGSize(width: 0, height: 0) } } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { print("tapping cell") let playVideoController = PlayVideoController(collectionViewLayout: UICollectionViewFlowLayout()) playVideoController.videoUrl = todayUploadVideos?[indexPath.item].videoUrl self.view.window?.rootViewController?.present(playVideoController, animated: true, completion: nil) } } class VideoCollectionCell: UICollectionViewCell { let thumbnailCustomImageView: CustomImageView = { let iv = CustomImageView() iv.backgroundColor = .black iv.contentMode = .scaleAspectFit iv.layer.masksToBounds = true iv.layer.borderWidth = 0.5 iv.layer.borderColor = UIColor.white.cgColor iv.clipsToBounds = true return iv }() lazy var playButton: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(named: "play")?.withRenderingMode(.alwaysOriginal), for: .normal) //button.addTarget(self, action: #selector(handlePlay), for: .touchUpInside) return button }() let readyLabel: UILabel = { let label = UILabel() label.textAlignment = .center label.text = "Ready?" label.font = .boldSystemFont(ofSize: 32) label.textColor = .white return label }() var playerLayer: AVPlayerLayer? var player: AVPlayer? @objc fileprivate func handlePlay() { print("handling play") } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = #colorLiteral(red: 0.290171057, green: 0.2902101278, blue: 0.2901577652, alpha: 1) setupPlayButton() } fileprivate func setupPlayButton() { addSubview(thumbnailCustomImageView) thumbnailCustomImageView.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0) thumbnailCustomImageView.addSubview(playButton) playButton.anchor(top: thumbnailCustomImageView.topAnchor, left: nil, bottom: nil, right: thumbnailCustomImageView.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: thumbnailCustomImageView.frame.height / 3, width: thumbnailCustomImageView.frame.width / 3) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } class VideoCollectionFooter: UICollectionViewCell { let label: UILabel = { let label = UILabel() label.font = UIFont.boldSystemFont(ofSize: 18) label.textColor = .white label.textAlignment = .center return label }() override init(frame: CGRect) { super.init(frame: frame) setupVideoCollectionFooterView() } fileprivate func setupVideoCollectionFooterView() { addSubview(label) label.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 10, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, height: 0, width: 0) label.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true //label.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }メディア紹介
以下のメディア/サービスに『モニトレ』が紹介されました。
StartupTimes(スタートアップを取り上げるメディア):
https://startuptimes.jp/2020/08/19/211803/Find Web(最新のウェブ/アプリを紹介するサイト):
https://findweb.jp/health/2481NewLaun-ch(新サービスを紹介するサイト):
https://newlaun-ch.com/archives/products/bf77e713-418b-4337-a854-8fc8da38ab9b最後に
現在はモニトレを使って継続的に筋トレとストレッチも出来ていて、筋力もかなりついてきたと実感しています。
『モニトレ』で目指したいのは"Endless Wellness=健康の永久化"が出来る世界です。
自らの首を痛めてしまって2週間身動きが取れないのは肉体的にも精神的にもとても辛い経験でした。
まさに『体は資本』を身に感じた経験でもあり、この記事をご覧のエンジニアの方には体に支障が出る前に予防して欲しいと願っています。
運動の継続が難しい方、モチベーションが上がらない方、 是非モニトレを利用してみてください。
無料で登録&利用出来ます。(GメールまたはAppleIDで登録可)
ダウンロードリンク:https://apple.co/3izK84n
公式ウェブサイト: https://monitore-f4d84.web.app/
YouTubeチャンネル:https://www.youtube.com/channel/UCdhcHH9OjyOnireh5vTpkRQ/『モニトレ』のサービス内容に関しての質問、サービスフィードバック、今回紹介したコードの質問があればコメント欄へどうぞ!
- 投稿日:2020-09-15T00:19:59+09:00
[Swift]アプリ起動中に、コードによりホーム画面に戻る方法
はじめに
とあるiOSアプリを触っていると、設定画面に言語変更のオプションがあり興味本位で変更してみるとアプリの再起動を促されて自動でホーム画面に戻るというUXを見つけました。そこでどういう実装をしているのか気になり調べてみました。
完成図↓これはアプリからコードでホーム画面に戻る方法 pic.twitter.com/Q9rmDxzlpK
— M (@p_x9) September 14, 2020アプリを意図的に落とすUXはどうなのか
この点に関しては以下の記事に詳しく書かれていました。
https://news.mynavi.jp/article/20190528-iphone_why/メモリ不足に起因するリソース不足などについてもシステム側でメモリの開放が行われることから、アプリを落とさなければならないという状況は極めて限定的ではあると考えられます。しかし表示言語の変更などのやむをえない場合についてはこのようなUXを実装しても問題なさそうです。
単純にアプリを落とす方法
ただ単にアプリを落とすだけなら、exit(0)などで落とすことができます。ただし、これだけだとアプリが突然クラッシュしたかのような挙動となりあまりふさわしいとは思えません。
また、exit(0)以外にも、fatalError()やassertなどで落とすことができますが、今回の言語の変更を目的とした、使用用途としては不適だと言えます。これらの使い分けについては以下の記事が大変参考になりましたのでご覧ください。
https://scior.hatenablog.com/entry/2019/04/04/202352
exit(0)//今回使用する fatalError()//不適 assert(false, "")//不適そこで
そこでアプリを終了させる前にホーム画面に戻るという処理を行います。こうすることでクラッシュしたかのような挙動は避けることができました。
コードは以下の通り単純です。UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)この処理の後に先程のexit(0)を呼べば良いのですが以下のように直後に呼んでしまうと一瞬画面がブラックアウトしたように見えてしまい、これもまた好ましい挙動ではありません。
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) exit(0)よって、タイマーを用いて、ほんの少しの間遅延させて実行することとします。
自分は以下のように実装しました。完成したコード
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in exit(0) }最後に
これからちょくちょく記事を書いて行くのでよかったら見て行ってもらえたら嬉しいです。
何かご指摘や質問(わからないかもですが)などありましたら気軽にコメントでもDMでもください。追記
AppStore公開時の審査について
StackOverflowなどでこの方法では審査に通らないとする意見も見られるそうです。
しかし自分で調べたところ大手アプリにおいてもこの方法を使用しているものが見受けられたので、一概に落とされるとも限りません。
もし、この方法を用いたアプリを審査申請された方がおられましたらどうなったかコメント頂けると助かります。自分も次のアプリ公開時にこの方法を使用して試してみようと思います。